7. Двоичная куча
Пусть мы хотим поддерживать множество элементов и быстро выполнять на нём следующие операции: добавлять и удалять элементы, а также находить минимум.
Можно попытаться сделать это с помощью динамического массива или списка. Тогда мы сможем добавлять и удалять элементы за $O(1)$ (чтобы быстро удалить элемент из массива, поменяем его местами с последним элементом, после чего уменьшим длину массива на один). Однако быстро находить минимум не получится: можно пытаться поддерживать указатель на минимальный элемент, но после каждого удаления минимума новый минимум получится найти лишь перебором всех оставшихся элементов за $\Theta(n)$ (где $n$ - число элементов в множестве). Можно было бы поддерживать массив в отсортированном порядке (тогда минимум всегда будет в начале), но тогда не получится быстро добавлять или удалять произвольный элемент.
Двоичная куча (пирамида), binary heap - структура данных, которая позволяет добавлять и удалять элементы за $O(\log n)$, а также в любой момент иметь доступ к минимальному элементу в множестве за $O(1)$.
Определение
Двоичная куча из $n$ элементов - это массив $a$ с индексами от 1 до $n$, образующий двоичное дерево: для любого $1 < i \leqslant n$ родитель $i$-го элемента - это элемент с номером $\left\lfloor\frac{i}{2}\right\rfloor ;$ соответственно, для любого $1 \leqslant i \leqslant n$ дети $i$-го элемента имеют номера $2 i, 2 i+1$ (если элементы с такими номерами существуют).
При этом выполняется свойство кучи: значение любого элемента не меньше значения его родителя: $a[i] \geqslant a\left[\left\lfloor\frac{i}{2}\right]\right]$. Такую кучу также могут называть неубывающая пирамида. Аналогично, если $a[i] \leqslant a\left[\left\lfloor\frac{i}{2}\right]\right]$ - то невозрастающая пирамида.
Заметим, что отсортированный массив всегда является кучей. Но фиксированный набор элементов обычно можно упорядочить и многими другими способами так, что тоже получится куча.
Будем пользоваться терминами из теории графов. Элементы дерева (кучи) будем называть вершинами. Корень дерева - единственная вершина без родителя (в нашем случае это $a[1]$ ). Потомки вершины - это она сама, её дети, а также все их потомки; у листа (вершины без детей) потомков (кроме него самого) нет. Предок вершины $v$ - любая такая вершина $u$, что $v$ - потомок $u$. Поддерево вершины $v$ состоит из всех её потомков; $v$ - корень своего поддерева.
Иногда мы будем называть кучей не массив с индексами от 1 до $n$, а просто дерево, в котором свойство кучи выполняется для всех пар родитель-ребёнок. В этом смысле любое поддерево кучи тоже является кучей.
Глубина $x$ - количество вершин на пути от корня дерева до $x$ (все вершины являются потомками корня). Высота вершины $x$ - максимальное количество вершин на пути от $x$ до какого-либо её потомка. Высота дерева - максимум из высот вершин, то есть высота корня.
Глубина $i$-й вершины равна $\lfloor\log i\rfloor+1$, так как $i$ нужно поделить на два нацело $\lfloor\log i\rfloor$ раз, чтобы получить 1.
Высота корня - это максимум из глубин всех его потомков. Максимальная глубина у $n$-й вершины $-\lfloor\log n\rfloor+1$. Значит, высота кучи из $n$ элементов равна $\lfloor\log n\rfloor+1$.
Базовые операции
Научимся выполнять три простых операции: getMin() - поиск минимального элемента в куче, insert(x) - добавление нового элемента $x$ в кучу, и extractMin() - удаление минимума из кучи. Последние две операции будут пользоваться вспомогательными операциями $\operatorname{siftUp(i)~и~siftDown(i).~}$
Поиск минимума
Из определения кучи сразу же следует, что минимальный элемент всегда будет находиться в корне. Тогда просто вернём $a[1]$, время работы $-O(1)$.
|
|
Добавление нового элемента
Пусть свойство кучи “практически” выполняется: для некоторого $i$ известно, что $a[i]=x$ можно увеличить так, что массив $a$ станет кучей. Такую почти кучу можно “починить”, не меняя множество лежащих в ней значений, следующим образом: просто будем “поднимать” $x$ вверх, пока $x$ меньше значения в родителе.
|
|
Свойства
Пусть известно, что $a[i]=x$ можно увеличить так, что массив $a$ станет кучей. Тогда после выполнения $\operatorname{siftUp}(i)$ массив $a$ станет кучей.
Количество итераций цикла в siftUp(i) не превосходит глубины $i$-й вершины, то есть время работы оценивается как $O(\log i+1)$.
Чтобы добавить новый элемент $x$ в кучу размера $n$, запишем $x$ в $a[n+1]$ (мы предполагаем, что массив $a$ достаточно большого размера либо динамический). При этом $a$ мог перестать быть кучей, но $a[n+1]$ можно увеличить так, что $a$ снова станет кучей (например, сделав его больше всех остальных элементов), поэтому достаточно запустить от него siftUp. Получаем время работы $O(\log n)$.
|
|
Удаление минимума
Пусть известно, что $a[i]=x$ можно уменьшить так, что массив $a$ станет кучей. Такую почти кучу тоже можно “починить”: будем “опускать” $x$ вниз, пока $x$ больше хотя бы одного из значений в детях, при этом будем менять $x$ местами с тем ребёнком, значение в котором меньше.
|
|
Свойства
Пусть известно, что $a[i]=x$ можно уменьшить так, что массив $a$ размера $n$ станет кучей. Тогда после выполнения siftDown(i) массив $a$ станет кучей.
Количество итераций цикла в $\operatorname{siftDown(i)}$ не больше высоты $i$-й вершины, поэтому время работы оценивается как $O(\log n-\log i+1)=O\left(\log \frac{n}{i}+1\right)$, или, более грубо, $O(\log n)$.
Чтобы удалить минимум из кучи размера $n$, поменяем местами $a[1]$ и $a[n]$, и уменьшим размер кучи на один. Теперь в $a[1]$ находится возможно не минимальный элемент, но его можно уменьшить так, что $a$ опять станет кучей (например, сделать его меньше всех остальных элементов). Тогда достаточно запустить $\operatorname{siftDown(1)}$. Получаем время работы $O(\log n)$.
|
|
Построение кучи
Самый простой способ построить кучу из $n$ элементов - начать с пустой кучи, и добавлять элементы по одному. В худшем случае (если все вызовы $\operatorname{siftUp}$ будут подниматься до корня, то есть если элементы добавляются в убывающем порядке) получим время работы $\Theta(n \log n)$.
Можно построить кучу быстрее: сложим элементы в массив в произвольном порядке, после чего поочерёдно запустим siftDown от всех элементов, не являющихся листьями, в порядке убывания их номеров.
|
|
Лемма
После выполнения $\operatorname{build}$ массив $a$ станет кучей.
Теорема
Время работы $\operatorname{build}$ есть $\Theta(n)$.
Heapsort
С помощью двоичной кучи можно отсортировать массив за $O(n \log n)$ : построим кучу на массиве, после чего $n$ раз извлечём минимум. Если в корне кучи хранится максимум, а не минимум (свойство кучи в таком случае меняется на противоположное: любой элемент не больше своего родителя), то при извлечении максимума из кучи размера $k$ он как раз оказывается на $k$-й позиции в массиве, поэтому алгоритму даже не нужна дополнительная память.
|
|
✍️ Heapsort не является стабильной сортировкой.
Очередь с приоритетами
Очередь с приоритетами (priority queue) отличается от обычной очереди тем, что у каждого элемента есть приоритет, и извлекается не тот элемент, что был добавлен в очередь раньше всех, а элемент с максимальным приоритетом. Встроенная в C++ реализация очереди с приоритетами - std::priority_queue.
Потребность в использовании очереди с приоритетами возникает, например, при планировании задач в многозадачных операционных системах (процессы с большим приоритетом нужно выполнить раньше). Также очередь с приоритетами используется во многих алгоритмах, некоторые из которых нам ещё встретятся.
Очередь с приоритетами легко реализовать с помощью двоичной кучи с максимумом в корне: будем сравнивать элементы по их приоритету.
Удаление или изменение произвольного элемента
При использовании очереди с приоритетами часто хочется иметь возможность делать дополнительные операции: менять значение приоритета у объекта, лежащего в очереди, или удалять из очереди произвольный объект, не обязательно с максимальным приоритетом. Покажем, что двоичная куча поддерживает такие операции.
Если приоритет объекта не максимален, то сложно быстро понять, где именно в куче он находится. Поэтому когда возникает потребность в таких операциях, обычно для каждого объекта дополнительно поддерживается ссылка на его позицию в куче (обычно это просто номер этой позиции), а для каждой позиции в куче поддерживается ссылка на объект, который находится в этой позиции. Так, если объекты пронумерованы числами от $1$ до $n$, можно поддерживать массив pos, в $i$-й ячейке которого будет храниться позиция в куче элемента с номером $i$. При перестройке кучи этот массив несложно пересчитывать.
Итак, пусть мы хотим удалить объект из кучи $a$ размера $n$, при этом мы знаем, что сейчас он находится на позиции $i$. Поступим так же, как и при удалении максимума: поменяем $i$-й элемент местами с последним, и уменьшим размер кучи. После этого $a$ может перестать быть кучей, но $i$-й элемент можно либо увеличить, либо уменьшить так, что $a$ снова станет кучей (например, можно вернуть старое значение $i$-го элемента). Значит после вызова $\operatorname{siftUp(i)}$ или $\operatorname{siftDown(i)}$ $a$ снова станет кучей. Если мы вызовем обе операции, то одна из них “починит” кучу, а вторая ничего не сделает. Время работы $O(\log n)$.
|
|
Точно так же можно менять значение приоритета у произвольного объекта. Пусть объект находится в куче в позиции $i$, тогда поменяем значение приоритета и снова запустим $\operatorname{siftUp(i)}$, $\operatorname{siftDown(i)}$. Это работает ровно по тем же причинам, что и в случае удаления. Время работы снова $O(\log n)$.
|
|
✍️ Существуют и более сложные кучи, обладающие различными дополнительными полезными свойствами. Фибоначчиевы кучи (Fredman, Tarjan, 1987) известны тем, что позволяют в среднем за $O(1)$ не только находить минимум, но и добавлять новый элемент, а также уменьшать значение элемента. Удаление работает в среднем за $O(\log n)$. Использование фибоначчиевой кучи позволяет улучшить теоретическую оценку времени работы некоторых алгоритмов. Проблема в том, что из-за сложности реализации и большой константы в оценке времени работы на практике фибоначчиевы кучи, как правило, работают не быстрее обычных, поэтому практически не применяются и имеют скорее теоретическую ценность.