Пусть мы хотим поддерживать множество элементов и быстро выполнять на нём следующие операции: добавлять и удалять элементы, а также находить минимум.
Можно попытаться сделать это с помощью динамического массива или списка. Тогда мы сможем добавлять и удалять элементы за $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)$.
1
2
getMin():
return a[1]
Добавление нового элемента
Пусть свойство кучи “практически” выполняется: для некоторого $i$ известно, что $a[i]=x$ можно увеличить так, что массив $a$ станет кучей. Такую почти кучу можно “починить”, не меняя множество лежащих в ней значений, следующим образом: просто будем “поднимать” $x$ вверх, пока $x$ меньше значения в родителе.
1
2
3
4
siftUp(i):
while i >1and a[i] < a[i /2]:
swap(a[i], a[i /2])
i /=2
1
2
3
4
5
6
7
8
9
10
11
12
13
defsift_up(arr, i):
"""
Функция для просеивания элемента вверх в куче
arr - массив, представляющий кучу
i - индекс элемента для просеивания (в нотации с 0)
"""while i >0:
parent = (i -1) //2if arr[i] < arr[parent]:
arr[i], arr[parent] = arr[parent], arr[i]
i = parent
else:
break
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<vector>#include<algorithm>voidsiftUp(std::vector<int>& arr, int i) {
while (i >0) {
int parent = (i -1) /2;
if (arr[i] < arr[parent]) {
std::swap(arr[i], arr[parent]);
i = parent;
} else {
break;
}
}
}
Свойства
Пусть известно, что $a[i]=x$ можно увеличить так, что массив $a$ станет кучей. Тогда после выполнения $\operatorname{siftUp}(i)$ массив $a$ станет кучей.
Доказательство
Докажем утверждение индукцией по $i$.
База. Если $i=1$, то $a$ уже является кучей (если корень можно увеличить так, что он станет не больше детей, то он и сейчас не больше детей). siftUp(1) не меняет массив $a$.
Переход. Заметим сначала, что если $a$ уже является кучей, то $\operatorname{siftUp}(i)$ ничего не сделает, поэтому утверждение предложения верно.
Если $a$ не является кучей, то единственная пара родитель-ребёнок, для которой не выполняется свойство кучи - это $x=a[i] < a\left[\left\lfloor\frac{i}{2}\right\rfloor\right]=y$ (поскольку для пары, в которой $a[i]$ является родителем, увеличение $a[i]$ может лишь начать нарушать неравенство). Тогда $a$ точно станет кучей, если увеличить значение $a[i]$ до $y$.
После первой итерации цикла $x$ и $y$ поменяются местами: $y=a[i] > a\left[\left\lfloor\frac{i}{2}\right\rfloor\right]=x$. Заметим, что такой массив $a$ станет кучей, если значение $a\left[\left\lfloor\frac{i}{2}\right\rfloor\right]$ увеличить до $y$ (это равносильно увеличению $a[i]$ до $y$ в исходном массиве). Тогда, поскольку последующие действия siftUp эквивалентны рекурсивному вызову $\operatorname{siftUp}(i / 2)$, по индукции верно, что по завершении операции массив $a$ станет кучей.
Количество итераций цикла в siftUp(i) не превосходит глубины $i$-й вершины, то есть время работы оценивается как $O(\log i+1)$.
Чтобы добавить новый элемент $x$ в кучу размера $n$, запишем $x$ в $a[n+1]$ (мы предполагаем, что массив $a$ достаточно большого размера либо динамический). При этом $a$ мог перестать быть кучей, но $a[n+1]$ можно увеличить так, что $a$ снова станет кучей (например, сделав его больше всех остальных элементов), поэтому достаточно запустить от него siftUp. Получаем время работы $O(\log n)$.
1
2
3
4
insert(x):
sz +=1# sz - размер кучи a[sz] = x
siftUp(sz)
Удаление минимума
Пусть известно, что $a[i]=x$ можно уменьшить так, что массив $a$ станет кучей. Такую почти кучу тоже можно “починить”: будем “опускать” $x$ вниз, пока $x$ больше хотя бы одного из значений в детях, при этом будем менять $x$ местами с тем ребёнком, значение в котором меньше.
1
2
3
4
5
6
7
8
9
10
siftDown(i):
while2* i <= sz:
j =2* i
if2* i +1<= sz and a[2* i +1] < a[2* i]:
j =2* i +1# j - номер минимального по значению ребёнка i-й вершиныif a[j] >= a[i]:
break swap(a[j], a[i])
i = j
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
defsiftDown(arr, i, sz):
"""
arr: массив
i: индекс текущего элемента (индексация с 0)
sz: размер кучи (не включая индекс sz)
"""while2* i +1< sz: # пока есть левый ребенок j =2* i +1# левый ребенок# если есть правый ребенок и он меньше левогоif2* i +2< sz and arr[2* i +2] < arr[2* i +1]:
j =2* i +2# правый ребенок# j - индекс минимального ребенка i-го элементаif arr[j] >= arr[i]:
break arr[i], arr[j] = arr[j], arr[i]
i = j
#include<vector>#include<algorithm>voidsiftDown(std::vector<int>& arr, int i, int sz) {
// i: индекс текущего элемента (индексация с 0)
// sz: размер кучи (не включая индекс sz)
while (2* i +1< sz) { // пока есть левый ребенок
int j =2* i +1; // левый ребенок
// если есть правый ребенок и он меньше левого
if (2* i +2< sz && arr[2* i +2] < arr[2* i +1]) {
j =2* i +2; // правый ребенок
}
// j - индекс минимального ребенка i-го элемента
if (arr[j] >= arr[i]) {
break;
}
std::swap(arr[i], arr[j]);
i = j;
}
}
Свойства
Пусть известно, что $a[i]=x$ можно уменьшить так, что массив $a$ размера $n$ станет кучей. Тогда после выполнения siftDown(i) массив $a$ станет кучей.
Доказательство
Докажем утверждение “обратной” индукцией по $i$.
База. Если $i>2 n$, то $a$ уже является кучей (если лист можно уменьшить так, что он станет не меньше родителя, то он и сейчас не меньше родителя). siftDown(i) в таком случае не поменяет массив $a$.
Переход. Заметим сначала, что если $a$ уже является кучей, то siftDown(i) ничего не сделает, поэтому утверждение предложения верно.
Если $a$ не является кучей, то свойство кучи может не выполняться только для тех пар родитель-ребёнок, в которых $a[i]$ является родителем. Пусть $j$ - номер минимального по значению из детей $i$-й вершины, $y=a[j] < x$. Тогда $a$ точно станет кучей, если уменьшить значение $a[i]$ до $y$.
После первой итерации цикла $x$ и $y$ поменяются местами: $y=a[i] < a[j] = x$. Заметим, что такой массив $a$ станет кучей, если значение $a[j]$ уменьшить до $y$ (это равносильно уменьшению $a[i]$ до $y$ в исходном массиве). Тогда, поскольку последующие действия siftDown эквивалентны рекурсивному вызову $\operatorname{siftDown(j)}$, по индукции верно, что по завершении операции массив $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)$.
voidsiftDown(std::vector<int>& arr, int i, int sz) {
// Функция для восстановления свойства кучи
// i - индекс элемента, который нужно просеять вниз
// sz - текущий размер кучи
while (true) {
int left =2* i +1; // левый потомок (с учетом индексации с 0)
int right =2* i +2; // правый потомок
int smallest = i;
if (left < sz && arr[left] < arr[smallest]) {
smallest = left;
}
if (right < sz && arr[right] < arr[smallest]) {
smallest = right;
}
if (smallest == i) {
break;
}
std::swap(arr[i], arr[smallest]);
i = smallest;
}
}
Построение кучи
Самый простой способ построить кучу из $n$ элементов - начать с пустой кучи, и добавлять элементы по одному. В худшем случае (если все вызовы $\operatorname{siftUp}$ будут подниматься до корня, то есть если элементы добавляются в убывающем порядке) получим время работы $\Theta(n \log n)$.
Можно построить кучу быстрее: сложим элементы в массив в произвольном порядке, после чего поочерёдно запустим siftDown от всех элементов, не являющихся листьями, в порядке убывания их номеров.
1
2
3
4
build(a, n):
sz = n
for i = (n /2)..1:
siftDown(i)
classHeap:
def__init__(self, arr):
self.heap = arr
self.sz = len(arr)
defbuild(self):
"""Построение кучи из массива""" self.sz = len(self.heap)
# Начинаем с последнего родителя (n//2 - 1 в 0-индексации)for i in range(self.sz //2-1, -1, -1):
self._sift_down(i)
# Дополнительный просеивание корня (для надежности)if self.sz >0:
self._sift_down(0)
def_sift_down(self, i):
"""Просеивание элемента вниз""" largest = i
left =2* i +1# Левый потомок right =2* i +2# Правый потомок# Сравниваем с левым потомкомif left < self.sz and self.heap[left] > self.heap[largest]:
largest = left
# Сравниваем с правым потомкомif right < self.sz and self.heap[right] > self.heap[largest]:
largest = right
# Если нужно, меняем местами и продолжаем просеиваниеif largest != i:
self.heap[i], self.heap[largest] = self.heap[largest], self.heap[i]
self._sift_down(largest)
defget_heap(self):
return self.heap
# Пример использованияdefbuild_heap(arr, n):
heap = Heap(arr)
heap.build()
return heap.get_heap()
# Тестированиеarr = [4, 10, 3, 5, 1, 8, 7]
result = build_heap(arr, len(arr))
print("Построенная куча:", result)
#include<iostream>#include<vector>#include<algorithm>classHeap {
private: std::vector<int> heap;
int sz;
voidsiftDown(int i) {
int largest = i;
int left =2* i +1; // Левый потомок (с учетом 0-индексации)
int right =2* i +2; // Правый потомок
// Сравниваем с левым потомком
if (left < sz && heap[left] > heap[largest]) {
largest = left;
}
// Сравниваем с правым потомком
if (right < sz && heap[right] > heap[largest]) {
largest = right;
}
// Если нужно, меняем местами и продолжаем просеивание
if (largest != i) {
std::swap(heap[i], heap[largest]);
siftDown(largest);
}
}
public: Heap(const std::vector<int>& arr) : heap(arr), sz(arr.size()) {}
voidbuild() {
sz = heap.size();
// Начинаем с последнего родителя (n/2 - 1 в 0-индексации)
for (int i = sz /2-1; i >=0; i--) {
siftDown(i);
}
// Дополнительное просеивание корня (для надежности)
if (sz >0) {
siftDown(0);
}
}
std::vector<int> getHeap() const {
return heap;
}
};
// Функция для построения кучи (интерфейс как в псевдокоде)
std::vector<int> buildHeap(std::vector<int>& arr, int n) {
Heap heap(arr);
heap.build();
return heap.getHeap();
}
intmain() {
std::vector<int> arr = {4, 10, 3, 5, 1, 8, 7};
int n = arr.size();
std::vector<int> result = buildHeap(arr, n);
std::cout <<"Построенная куча: ";
for (int val : result) {
std::cout << val <<" ";
}
std::cout << std::endl;
return0;
}
Лемма
После выполнения $\operatorname{build}$ массив $a$ станет кучей.
Доказательство
Докажем “обратной” индукцией по $i$, что после запуска $\operatorname{siftDown(i)}$ поддерево $i$-й вершины является кучей.
База. $i>\left\lfloor\frac{n}{2}\right\rfloor$ (на самом деле для таких $i$ мы не вызываем $\operatorname{siftDown}$, потому что он всё равно ничего не сделает). В этом случае поддерево состоит из одной вершины, поэтому утверждение верно.
Переход. По предположению индукции, поддеревья детей $i$-й вершины являются кучами. Значит, $a[i]$ можно уменьшить так, что и поддерево $i$-й вершины станет кучей (например, сделав $a[i]$ меньше значений в детях). Тогда после запуска $\operatorname{siftDown(i)}$ поддерево $i$-й вершины станет кучей.
Время работы $\operatorname{build}$ тоже складывается из $O(n)$ запусков функций, каждая из который работает за $O(\log n)$. Однако оценка времени работы $O(n \log n)$ в этом случае не является точной.
Теорема
Время работы $\operatorname{build}$ есть $\Theta(n)$.
Доказательство
Воспользуемся более точной оценкой времени работы $\operatorname{siftUp}$: пусть $\lfloor\log n\rfloor=k$, то есть $2^{k} \leqslant n < 2^{k+1}$. Высоту один имеет $n-2^{k}+1 \leqslant 2^{k}$ вершин. Высоту $1 < h \leqslant k+1$ имеет $2^{k+2-h}-2^{k+1-h}=2^{k+1-h}$ вершин. Тогда время работы $\operatorname{build}$ есть
С помощью двоичной кучи можно отсортировать массив за $O(n \log n)$ : построим кучу на массиве, после чего $n$ раз извлечём минимум. Если в корне кучи хранится максимум, а не минимум (свойство кучи в таком случае меняется на противоположное: любой элемент не больше своего родителя), то при извлечении максимума из кучи размера $k$ он как раз оказывается на $k$-й позиции в массиве, поэтому алгоритму даже не нужна дополнительная память.
1
2
3
4
heapSort(a, n):
build(a, n) # строим кучу, в корне которой хранится максимумfor i =0..(n -1):
extractMax()
defheapSort(a, n):
# Строим кучу, в корне которой хранится максимум build(a, n)
for i in range(n -1, 0, -1):
# Обмениваем корень (максимум) с последним элементом a[0], a[i] = a[i], a[0]
# Уменьшаем размер кучи и восстанавливаем свойство кучи siftDown(a, 0, i)
defbuild(a, n):
# Начинаем с последнего родителя и идем к корнюfor i in range(n //2-1, -1, -1):
siftDown(a, i, n)
defsiftDown(a, i, n):
largest = i
left =2* i +1# левый потомок (в 0-индексации) right =2* i +2# правый потомок# Если левый потомок больше корняif left < n and a[left] > a[largest]:
largest = left
# Если правый потомок больше, чем самый большой элемент на данный моментif right < n and a[right] > a[largest]:
largest = right
# Если самый большой элемент не кореньif largest != i:
a[i], a[largest] = a[largest], a[i] # меняем местами# Рекурсивно просеиваем затронутую подкучу siftDown(a, largest, n)
# Пример использованияif __name__ =="__main__":
arr = [12, 11, 13, 5, 6, 7]
heapSort(arr, len(arr))
print("Отсортированный массив:", arr)
#include<iostream>#include<vector>voidsiftDown(std::vector<int>& a, int i, int n) {
int largest = i;
int left =2* i +1; // левый потомок (в 0-индексации)
int right =2* i +2; // правый потомок
// Если левый потомок больше корня
if (left < n && a[left] > a[largest]) {
largest = left;
}
// Если правый потомок больше, чем самый большой элемент на данный момент
if (right < n && a[right] > a[largest]) {
largest = right;
}
// Если самый большой элемент не корень
if (largest != i) {
std::swap(a[i], a[largest]); // меняем местами
// Рекурсивно просеиваем затронутую подкучу
siftDown(a, largest, n);
}
}
voidbuild(std::vector<int>& a, int n) {
// Начинаем с последнего родителя и идем к корню
for (int i = n /2-1; i >=0; i--) {
siftDown(a, i, n);
}
}
voidheapSort(std::vector<int>& a, int n) {
// Строим кучу, в корне которой хранится максимум
build(a, n);
for (int i = n -1; i >0; i--) {
// Обмениваем корень (максимум) с последним элементом
std::swap(a[0], a[i]);
// Уменьшаем размер кучи и восстанавливаем свойство кучи
siftDown(a, 0, i);
}
}
// Пример использования
intmain() {
std::vector<int> arr = {12, 11, 13, 5, 6, 7};
int n = arr.size();
heapSort(arr, n);
std::cout <<"Отсортированный массив: ";
for (int i =0; i < n; i++) {
std::cout << arr[i] <<" ";
}
std::cout << std::endl;
return0;
}
✍️ 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)$.
1
2
3
4
5
6
delete(i):
swap(a[i], a[sz])
sz -=1if i <= sz:
siftUp(i)
siftDown(i)
Точно так же можно менять значение приоритета у произвольного объекта. Пусть объект находится в куче в позиции $i$, тогда поменяем значение приоритета и снова запустим $\operatorname{siftUp(i)}$, $\operatorname{siftDown(i)}$. Это работает ровно по тем же причинам, что и в случае удаления. Время работы снова $O(\log n)$.
1
2
3
4
change(i, x):
a[i] = x
siftUp(i)
siftDown(i)
✍️ Существуют и более сложные кучи, обладающие различными дополнительными полезными свойствами. Фибоначчиевы кучи (Fredman, Tarjan, 1987) известны тем, что позволяют в среднем за $O(1)$ не только находить минимум, но и добавлять новый элемент, а также уменьшать значение элемента. Удаление работает в среднем за $O(\log n)$. Использование фибоначчиевой кучи позволяет улучшить теоретическую оценку времени работы некоторых алгоритмов. Проблема в том, что из-за сложности реализации и большой константы в оценке времени работы на практике фибоначчиевы кучи, как правило, работают не быстрее обычных, поэтому практически не применяются и имеют скорее теоретическую ценность.