Очереди с приоритетом
АТД очередь с приоритетом: всегда извлекай элемент с наивысшим приоритетом. Реализация двоичная куча: insert и extractMin O(log n), peek O(1). Применение: планирование, Дейкстра, слияние потоков. Ключевое: АТД это контракт, куча это один способ его выполнить.
У вас есть куча. Теперь вы обёртываете её новым именем и обещанием: «Ты никогда не ищешь максимум или минимум. Ты всегда получаешь его за одну операцию — самый срочный, наиболее приоритетный, самый дешёвый элемент вперёд». Это обещание — очередь с приоритетом — не структура данных (это куча внизу), а контракт, что говорит, что можно делать. Куча — один способ держать этот контракт. Алгоритм Дейкстры её использует. Планировщики её используют. Каждый раз, когда нужно всегда обработать самую важную вещь следующей, ты берёшь очередь с приоритетом.
После этого урока ты можешь объяснить разницу между абстрактным типом данных и реализацией. Ты можешь назвать интерфейс очереди с приоритетом: insert новый элемент, extractMin (или extractMax) чтобы удалить и вернуть элемент с наивысшим приоритетом, и peek посмотреть его без удаления. Ты понимаешь, что двоичная куча — стандартная реализация и почему (O(log n) insert и extract, O(1) peek). Ты можешь узнать, когда задача требует очередь с приоритетом вместо обычной очереди или массива, и назвать три реальных применения: планирование задач, кратчайший путь Дейкстры и симуляция событий.
Что такое очередь с приоритетом?
Очередь с приоритетом — это абстрактный тип данных — контракт, не конкретная структура данных. Описывает, что можно делать, а не как построено.
Контракт говорит: у тебя есть коллекция элементов, каждый с приоритетом (число или правило сравнения). Ты можешь:
- Insert (вставить) новый элемент.
- ExtractMin (или extractMax): удалить и вернуть элемент с наименьшим (или наибольшим) приоритетом.
- Peek (подглядеть): посмотреть элемент с наивысшим приоритетом без удаления.
Вот интерфейс. Куча живёт внизу.
АТД против реализации
Абстрактный тип данных (АТД) — это спецификация — контракт. Говорит, какие операции поддерживаются и что они делают. НЕ говорит как.
Очередь с приоритетом — это АТД. Её операции: insert, extractMin и peek.
Реализация — это код. Двоичная куча — одна частая реализация: использует полное двоичное дерево, хранящееся в массиве, со свойством мин-кучи (каждый родитель ≤ дети).
Один АТД может иметь много реализаций. Очередь с приоритетом может быть:
- Двоичной кучей (быстрый insert и extract, эффективна по памяти).
- Отсортированным массивом (быстрый peek, медленный insert и extract).
- Связным списком (медленно всё).
- Сбалансированным двоичным деревом поиска вроде красно-чёрного дерева (log n все операции, но больше констант).
Вместе эти варианты делают компромисс очевидным: куча побеждает, потому что балансирует все три операции и умещается в плоский массив. Если когда-нибудь перейдёшь на кучу Фибоначчи или bucket queue — клиентский код не изменится, в этом и смысл АТД.
Когда кодишь, используешь абстракцию АТД. Спрашиваешь: insert, extractMin, peek. Реализация заменяема — поменяй её, клиентский код не изменится.
Почему двоичная куча?
Двоичная куча — стандартная реализация, потому что быстра (O(log n) insert и extract, O(1) peek) и памяти эффективна (нет указателей, просто массив). Простая настолько, чтобы написать руками, но боевая.
Мин-куча против макс-кучи
В мин-куче extractMin возвращает наименьший элемент. Используется когда хочешь обработать наименьшую стоимость, кратчайшее время или наименее срочную задачу первой.
В макс-куче extractMax возвращает наибольший элемент. Используется когда хочешь наивысший приоритет, наибольший вес или самую срочную задачу первой.
Логика симметрична — если нужна макс-куча и есть только мин-куча библиотека, инвертируй приоритеты и используй мин-кучу.
Интерфейс очереди с приоритетом в псевдокоде
PriorityQueue:
insert(element, priority)
Добавь элемент в очередь.
extractMin()
Удали и верни элемент с наименьшим приоритетом.
peek()
Верни (без удаления) элемент с наименьшим приоритетом.
isEmpty()
Истина если очередь пуста.
size()
Количество элементов в очереди.Пример: планирование задач
У тебя есть очередь задач. Каждая задача имеет приоритет (1 = срочно, 10 = низкий приоритет). Задачи приходят в случайные моменты. Ты хочешь всегда работать над самой срочной задачей следующей.
Queue: [print job (приоритет 3), compile (приоритет 5), test (приоритет 1)]
extractMin() → test с приоритетом 1 (хотя добавлена последней)
insert(deploy, приоритет 2)
Queue: [deploy (приоритет 2), print job (приоритет 3), compile (приоритет 5)]
extractMin() → deploy с приоритетом 2Обычная очередь (FIFO) здесь не работает — заставила бы делать print job первой, потом compile, тратя время на низкоприоритетную работу.
Интерфейс очереди с приоритетом в коде
class MinPriorityQueue { constructor() { this.heap = []; } // Вставь элемент со значением (меньше = выше приоритет) insert(element, value) { this.heap.push({ element, value }); this.siftUp(this.heap.length - 1); } // Удали и верни элемент с наименьшим значением extractMin() { const root = this.heap[0]; this.heap[0] = this.heap[this.heap.length - 1]; this.heap.pop(); if (this.heap.length > 0) { this.siftDown(0); } return root.element; } // Подглядни элемент с наименьшим значением без удаления peek() { return this.heap.length > 0 ? this.heap[0].element : null; } // Проверь если пуста isEmpty() { return this.heap.length === 0; } // Количество элементов size() { return this.heap.length; } // Просей вверх чтобы восстановить свойство кучи после вставки siftUp(index) { while (index > 0) { const parentIndex = Math.floor((index - 1) / 2); if (this.heap[index].value < this.heap[parentIndex].value) { [this.heap[index], this.heap[parentIndex]] = [this.heap[parentIndex], this.heap[index]]; index = parentIndex; } else { break; } } } // Просей вниз чтобы восстановить свойство кучи после удаления siftDown(index) { while (true) { const left = 2 * index + 1; const right = 2 * index + 2; let smallest = index; if (left < this.heap.length && this.heap[left].value < this.heap[smallest].value) { smallest = left; } if (right < this.heap.length && this.heap[right].value < this.heap[smallest].value) { smallest = right; } if (smallest !== index) { [this.heap[index], this.heap[smallest]] = [this.heap[smallest], this.heap[index]]; index = smallest; } else { break; } } } } - L1 MinPriorityQueue обёртывает массив кучи.
- L5 Вставь элемент с приоритет значением. Меньше значение = выше приоритет.
- L9 extractMin удаляет и возвращает элемент с наименьшим значением (корень мин-кучи).
- L17 peek возвращает элемент с наименьшим значением без удаления.
- L29 siftUp восстанавливает свойство кучи после вставки, двигая элемент вверх.
- L39 siftDown восстанавливает свойство кучи после удаления, двигая элемент вниз.
Использование очереди с приоритетом
const pq = new MinPriorityQueue(); pq.insert('task1', 5); pq.insert('task2', 2); pq.insert('task3', 1); const pq = new MinPriorityQueue(); // После вставок: куча имеет [(task3,1), (task1,5), (task2,2)] const extracted = pq.extractMin(); Операции очереди с приоритетом с использованием двоичной кучи
Insert: Добавляет новый элемент и вызывает просей вверх. Просей вверх двигает элемент вверх один уровень за обмен, и куча с n элементами имеет высоту O(log n).
- Время: O(log n)
ExtractMin: Удаляет корень, двигает последний элемент в корень и вызывает просей вниз. Просей вниз тоже берёт O(log n).
- Время: O(log n)
Peek: Возвращает корень за один доступ к массиву.
- Время: O(1)
IsEmpty и size: Проверяют длину массива.
- Время: O(1)
Почему не другие реализации?
Отсортированный массив может дать O(1) peek, но insert и extract требуют сдвига O(n) элементов — O(n) за операцию.
Неотсортированный массив дает O(1) insert, но extractMin требует сканирования всех элементов чтобы найти наименьший — O(n) за операцию.
Связный список это O(n) за всё.
Двоичная куча балансирует все три: O(log n) insert и extract, O(1) peek. Поэтому стандартный выбор для очередей с приоритетом.
Пространственная сложность
Куча использует один массив, так что пространство это O(n) для n элементов.
В очереди с приоритетом на основе двоичной мин-кучи, какая операция гарантировано O(1)?
Ты строишь планировщик задач. Задачи имеют приоритеты (меньше число = срочнее). Ты хочешь всегда выполнить самую срочную задачу следующей. Что использовать?
Какая разница между абстрактным типом данных (АТД) и реализацией?
В алгоритме кратчайшего пути Дейкстры, нужно повторно извлекать вершину с наименьшим расстоянием. Какая это структура данных?
Куча с приоритетом мин-кучи имеет 8 элементов. Сколько шагов (сравнений и обменов) extractMin берёт в худшем случае?
Нужно слить 4 отсортированные списки в один отсортированный список. Какой подход использует очередь с приоритетом?
▸lesson.inset.undefined
Почему очереди с приоритетом имеют значение. Любая реальная система с задачами, событиями или запросами имеет приоритеты. Веб-сервер имеет запросы от пользователей; некоторые важнее (платящие клиенты первыми). Робот имеет датчики и исполнительные элементы; срочные события безопасности имеют приоритет. Больница имеет пациентов в очереди; критические случаи видены первыми. Без очереди с приоритетом, ты или обрабатываешь вещи случайно (несправедливо, неэффективно) или вручную ищешь самую важную вещь (медленно, O(n)). Очередь с приоритетом решает оба: всегда дай мне самую важную вещь, быстро.
▸lesson.inset.undefined
Смешивание АТД и реализации. Очередь с приоритетом это НЕ куча. Куча это один способ реализовать очередь с приоритетом. Если кто-то спросит «Является ли очередь с приоритетом кучей?», ответ это «Нет, очередь с приоритетом это АТД; куча это реализация этого АТД.» Это отличие имеет значение, потому что на интервью ты можешь сказать «Я буду использовать очередь с приоритетом, поддержанную кучей» чтобы понятно показать, что ты думаешь обоих уровнях.
▸lesson.inset.undefined
Понижение приоритета. Некоторые проблемы с очередью с приоритетом (вроде Дейкстры) нужно понизить приоритет элемента после вставки. Двоичная куча это не поддерживает напрямую — пришлось бы перестроить или использовать более сложную реализацию (куча Фибоначчи, или вставить заново с новым приоритетом и пропустить старый). Для этого урока, предполагаем простую очередь с приоритетом что поддерживает только insert, extractMin и peek.
Объясни отношение между очередью с приоритетом (АТД) и двоичной кучей (реализация). Какие операции должен поддерживать АТД и почему куча хороший выбор?
Очередь с приоритетом это абстрактный тип данных — контракт, что обещает три операции: insert новый элемент, extractMin чтобы удалить и вернуть элемент с наивысшим приоритетом, и peek чтобы посмотреть элемент с наивысшим приоритетом без удаления. Двоичная куча это стандартная реализация, дающая O(log n) insert и extract, O(1) peek. Понимай разницу: АТД это интерфейс; реализация это код. Очередь с приоритетом это не FIFO очередь и не LIFO стек — она всегда дает тебе самую важную вещь следующей. Используй очередь с приоритетом когда нужно всегда обрабатывать самый срочный элемент: планирование задач, кратчайший путь Дейкстры, симуляция событий, слияние отсортированных потоков. Следующее: Урок 04 применяет очередь с приоритетом к проблемам top-K, и урок 05 показывает k-way merge. Теперь, когда тянешься за отсортированным списком чтобы «всегда обрабатывать самое срочное», спроси себя сначала: не решает ли это очередь с приоритетом за O(log n) вместо O(n)?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.