First version.

This commit is contained in:
krahets
2026-01-20 15:08:42 +08:00
parent 2213a59ff6
commit 8071daddaa
106 changed files with 11790 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# Построение кучи
В некоторых случаях мы хотим использовать все элементы списка для построения кучи. Этот процесс называется "построением кучи".
## Реализация с помощью операции вставки
Сначала создается пустая куча, затем выполняется обход списка, и для каждого элемента выполняется "операция вставки в кучу", т. е. сначала элемент добавляется в основание кучи, а затем для этого элемента выполняется упорядочивание "снизу вверх".
Каждый раз, когда элемент вставляется в кучу, длина кучи увеличивается на единицу. Поскольку узлы добавляются в двоичное дерево последовательно сверху вниз, куча строится "сверху вниз".
Пусть количество элементов равно $n$, операция вставки каждого элемента занимает $O(\log{n})$ времени, следовательно, временная сложность этого метода построения кучи составляет $O(n \log n)$.
## Реализация через обход с упорядочиванием
На самом деле можно реализовать более эффективный метод построения кучи, который состоит из двух шагов.
1. Все элементы списка добавляются в кучу без изменений, при этом свойства кучи еще не выполняются.
2. Выполняется обратный обход кучи (обратный порядок обхода по уровням), и для каждого нелистового узла выполняется "упорядочивание сверху вниз".
**После упорядочивания каждого узла поддерево с корнем в этом узле становится корректной подкучей**. А поскольку используется обратный обход, куча строится "снизу вверх".
Причина выбора обратного обхода заключается в том, что это гарантирует, что поддерево под текущим узлом уже является корректной подкучей, что делает упорядочивание текущего узла эффективным.
Стоит отметить, что **поскольку листовые узлы не имеют дочерних узлов, они по своей природе являются корректными подкучами и не требуют упорядочивания**. Как показано в следующем коде, последний нелистовой узел является родительским узлом последнего узла, и мы начинаем обратный обход и выполняем упорядочивание с него:
```src
[file]{my_heap}-[class]{max_heap}-[func]{__init__}
```
## Анализ сложности
Далее попытаемся вывести временную сложность второго метода построения кучи.
- Предположим, что количество узлов полного двоичного дерева равно $n$, тогда количество листовых узлов составляет $(n + 1) / 2$, где $/$ -- целочисленное деление вниз. Следовательно, количество узлов, требующих упорядочивания, составляет $(n - 1) / 2$.
- В процессе упорядочивания сверху вниз каждый узел может быть упорядочен максимум до листового узла, поэтому максимальное количество итераций равно высоте двоичного дерева $\log n$.
Перемножив эти два значения, получаем временную сложность процесса построения кучи $O(n \log n)$. **Но этот расчет неточен, поскольку мы не учли тот факт, что количество узлов на нижних уровнях двоичного дерева намного больше, чем на верхних**.
Далее выполним более точный расчет. Чтобы упростить вычисления, предположим, что дано "совершенное двоичное дерево" с количеством узлов $n$ и высотой $h$. Это предположение не повлияет на правильность результата вычислений.
![Количество узлов на каждом уровне совершенного двоичного дерева](../assets/heapify_operations_count.png)
Как показано на рисунке выше, максимальное количество итераций "упорядочивания узла сверху вниз" равно расстоянию от этого узла до листового узла, а это расстояние и есть "высота узла". Следовательно, можно просуммировать "количество узлов × высота узла" для каждого уровня, **получив общее количество итераций упорядочивания для всех узлов**.
$$
T(h) = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{(h-1)}\times1
$$
Для упрощения этого выражения необходимо использовать знания о числовых последовательностях из средней школы. Сначала умножим $T(h)$ на $2$, получим:
$$
\begin{aligned}
T(h) & = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{h-1}\times1 \newline
2 T(h) & = 2^1h + 2^2(h-1) + 2^3(h-2) + \dots + 2^{h}\times1 \newline
\end{aligned}
$$
Используя метод разностного вычитания, вычтем верхнее выражение $T(h)$ из нижнего $2 T(h)$, получим:
$$
2T(h) - T(h) = T(h) = -2^0h + 2^1 + 2^2 + \dots + 2^{h-1} + 2^h
$$
Наблюдая за этим выражением, видим, что $T(h)$ является геометрической прогрессией, можно напрямую использовать формулу суммы, получив временную сложность:
$$
\begin{aligned}
T(h) & = 2 \frac{1 - 2^h}{1 - 2} - h \newline
& = 2^{h+1} - h - 2 \newline
& = O(2^h)
\end{aligned}
$$
Далее, количество узлов совершенного двоичного дерева высотой $h$ равно $n = 2^{h+1} - 1$, легко получить сложность $O(2^h) = O(n)$. Приведенные выше вычисления показывают, что **временная сложность ввода списка и построения кучи составляет $O(n)$, что очень эффективно**.
+200
View File
@@ -0,0 +1,200 @@
# Куча
<u>Куча (heap)</u> -- это полное двоичное дерево, удовлетворяющее определенным условиям, и делится на два основных типа, как показано на рисунке ниже.
- <u>Минимальная куча (min heap)</u>: значение любого узла ≤ значений его дочерних узлов.
- <u>Максимальная куча (max heap)</u>: значение любого узла ≥ значений его дочерних узлов.
![Минимальная и максимальная кучи](../assets/min_heap_and_max_heap.png)
Куча, как частный случай полного двоичного дерева, обладает следующими свойствами:
- узлы на самом нижнем уровне заполняются слева, остальные уровни полностью заполнены;
- корневой узел двоичного дерева называется вершиной кучи, а самый правый узел на нижнем уровне -- основанием кучи;
- для максимальной (минимальной) кучи значение элемента на вершине (т. е. корневом узле) является наибольшим (наименьшим).
## Основные операции с кучей
Следует отметить, что многие языки программирования содержат <u>приоритетную очередь (priority queue)</u>, которая является абстрактной структурой данных, определяемой как очередь с приоритетной сортировкой.
На практике **куча часто используется для реализации приоритетной очереди, где максимальная куча соответствует приоритетной очереди, из которой элементы извлекаются в порядке убывания**. С точки зрения использования приоритетную очередь и кучу можно считать эквивалентными структурами данных. Поэтому в данной книге они не различаются и называются просто кучей.
Основные операции с кучей представлены в таблице ниже, названия методов в разных языках программирования могут отличаться.
<p align="center"> Таблица <id> &nbsp; Эффективность операций с кучей </p>
| Метод | Описание | Временная сложность |
| ----------- | ---------------------------------------------------------- | ------------------- |
| `push()` | Вставка элемента в кучу | $O(\log n)$ |
| `pop()` | Извлечение элемента с вершины кучи | $O(\log n)$ |
| `peek()` | Доступ к элементу на вершине кучи (макс./мин. значение) | $O(1)$ |
| `size()` | Получение количества элементов в куче | $O(1)$ |
| `isEmpty()` | Проверка кучи на пустоту | $O(1)$ |
В реальных приложениях можно напрямую использовать классы кучи (или приоритетной очереди), предоставляемые языком программирования.
Подобно сортировочным алгоритмам по возрастанию и по убыванию, можно установить флаг или изменить компаратор для преобразования минимальной кучи в максимальную и наоборот. Ниже приведен пример кода.
=== "Python"
```python title="heap.py"
# Инициализация минимальной кучи
min_heap, flag = [], 1
# Инициализация максимальной кучи
max_heap, flag = [], -1
# Модуль heapq в Python по умолчанию реализует минимальную кучу
# Рассматривается вариант, при котором элементы инвертируются перед
# добавлением в кучу, что позволяет изменить порядок и реализовать максимальную кучу
# В этом примере flag = 1 соответствует минимальной куче, flag = -1 -- максимальной
# Вставка элемента в кучу
heapq.heappush(max_heap, flag * 1)
heapq.heappush(max_heap, flag * 3)
heapq.heappush(max_heap, flag * 2)
heapq.heappush(max_heap, flag * 5)
heapq.heappush(max_heap, flag * 4)
# Доступ к элементу на вершине кучи
peek: int = flag * max_heap[0] # 5
# Извлечение элемента с вершины кучи
# Извлеченные элементы образуют последовательность по убыванию
val = flag * heapq.heappop(max_heap) # 5
val = flag * heapq.heappop(max_heap) # 4
val = flag * heapq.heappop(max_heap) # 3
val = flag * heapq.heappop(max_heap) # 2
val = flag * heapq.heappop(max_heap) # 1
# Получение размера кучи
size: int = len(max_heap)
# Проверка кучи на пустоту
is_empty: bool = not max_heap
# Построение кучи из списка
min_heap: list[int] = [1, 3, 2, 5, 4]
heapq.heapify(min_heap)
```
<!-- 🔴 Русская версия не содержит примеров кода для других языков программирования -->
<!-- Китайский оригинал содержит примеры для: C++, Java, C#, Go, Swift, JS, TS, Dart, Rust, C, Kotlin, Ruby -->
## Реализация кучи
Ниже приведена реализация максимальной кучи. Для преобразования в минимальную кучу достаточно инвертировать все логические сравнения (например, заменить ≥ на ≤). Заинтересованные читатели могут реализовать это самостоятельно.
### Хранение и представление кучи
В разделе «Двоичные деревья» упоминалось, что полные двоичные деревья удобно представлять в виде массива. **Поскольку куча является таким деревом, для ее хранения будем использовать массив**.
При использовании массива для представления двоичного дерева элементы представляют значения узлов, а индексы -- их положение в дереве. **Указатели на узлы реализуются через формулы индексации**.
Как показано на рисунке ниже, для заданного индекса массива $i$ индекс левого дочернего узла равен $2i + 1$, правого -- $2i + 2$, а индекс родительского узла -- $(i - 1) / 2$ (целочисленное деление вниз). Выход за пределы индексации обозначает пустой узел или его отсутствие.
![Представление и хранение кучи](../assets/representation_of_heap.png)
Формулы индексации можно для удобства использования обернуть в функции.
```src
[file]{my_heap}-[class]{max_heap}-[func]{parent}
```
### Доступ к элементу на вершине кучи
Элемент на вершине кучи -- это корневой узел двоичного дерева, т. е. первый элемент списка.
```src
[file]{my_heap}-[class]{max_heap}-[func]{peek}
```
### Вставка элемента в кучу
Нам дан элемент `val`, который сначала добавляется в основание кучи. После добавления условия корректности кучи могут быть нарушены, поскольку элемент `val` может быть больше других элементов кучи. **Поэтому необходимо восстановить порядок на пути от вставленного узла до корневого узла**. Эта операция называется <u>упорядочиванием кучи (heapify)</u>.
Рассмотрим **выполнение упорядочивания кучи снизу вверх**, начиная с узла, который был добавлен. Как показано на рисунке ниже, необходимо сравнивать значения вставленного узла и его родительского узла. Если вставленный узел больше, они меняются местами. Затем продолжается выполнение этой операции с исправлением каждого узла кучи снизу вверх, пока не будет достигнут корневой узел или не встретится узел, который не требует обмена.
=== "<1>"
![Этапы добавления элемента в кучу](../assets/heap_push_step1.png)
=== "<2>"
![heap_push_step2](../assets/heap_push_step2.png)
=== "<3>"
![heap_push_step3](../assets/heap_push_step3.png)
=== "<4>"
![heap_push_step4](../assets/heap_push_step4.png)
=== "<5>"
![heap_push_step5](../assets/heap_push_step5.png)
=== "<6>"
![heap_push_step6](../assets/heap_push_step6.png)
=== "<7>"
![heap_push_step7](../assets/heap_push_step7.png)
=== "<8>"
![heap_push_step8](../assets/heap_push_step8.png)
=== "<9>"
![heap_push_step9](../assets/heap_push_step9.png)
Пусть общее количество узлов равно $n$, тогда высота дерева будет $O(\log n)$. Из этого следует, что максимальное количество циклов операции упорядочивания кучи также будет $O(\log n)$. **Тогда и временная сложность операции добавления элемента в кучу составит $O(\log n)$**. Ниже приведен код реализации.
```src
[file]{my_heap}-[class]{max_heap}-[func]{sift_up}
```
### Извлечение элемента с вершины кучи
Элемент на вершине кучи является корневым узлом двоичного дерева, т. е. первым элементом списка. Если просто удалить первый элемент из списка, индексы всех узлов в двоичном дереве изменятся, что затруднит дальнейшее исправление с помощью упорядочивания кучи. Чтобы минимизировать изменения индексов элементов, используется следующий порядок действий:
1. обмен вершины кучи с элементом в основании кучи (обмен корневого узла с самым правым листовым узлом);
2. после обмена удаляется элемент в основании кучи из списка (обратите внимание, что фактически удаляется исходный элемент на вершине кучи, так как они были поменяны);
3. **упорядочивание кучи сверху вниз**, начиная с корневого узла.
**Направление операции упорядочивания кучи сверху вниз противоположно операции упорядочивания кучи снизу вверх**, как показано на рисунке ниже. Значение корневого узла сравнивается со значениями его двух дочерних узлов, и самый большой дочерний узел обменивается с корневым узлом. Затем эта операция выполняется циклически, пока не будет достигнут листовой узел или не встретится узел, который не требует обмена.
=== "<1>"
![Этапы извлечения элемента с вершины кучи](../assets/heap_pop_step1.png)
=== "<2>"
![heap_pop_step2](../assets/heap_pop_step2.png)
=== "<3>"
![heap_pop_step3](../assets/heap_pop_step3.png)
=== "<4>"
![heap_pop_step4](../assets/heap_pop_step4.png)
=== "<5>"
![heap_pop_step5](../assets/heap_pop_step5.png)
=== "<6>"
![heap_pop_step6](../assets/heap_pop_step6.png)
=== "<7>"
![heap_pop_step7](../assets/heap_pop_step7.png)
=== "<8>"
![heap_pop_step8](../assets/heap_pop_step8.png)
=== "<9>"
![heap_pop_step9](../assets/heap_pop_step9.png)
=== "<10>"
![heap_pop_step10](../assets/heap_pop_step10.png)
Подобно операции добавления элемента в кучу, временная сложность операции извлечения элемента с вершины кучи также составляет $O(\log n)$. Ниже приведен код реализации.
```src
[file]{my_heap}-[class]{max_heap}-[func]{sift_down}
```
## Типичные применения кучи
- **Приоритетная очередь**: куча обычно является предпочтительной структурой данных для реализации приоритетной очереди, операции вставки и извлечения имеют временную сложность $O(\log n)$, а операция построения кучи -- $O(n)$, все эти операции очень эффективны.
- **Пирамидальная сортировка**: для заданного набора данных можно построить кучу, а затем последовательно извлекать элементы, получая отсортированные данные. Однако обычно используется более элегантный способ реализации пирамидальной сортировки, подробности см. в разделе «Пирамидальная сортировка».
- **Получение наибольших $k$ элементов**: это классическая алгоритмическая задача и типичное применение, например, выбор 10 самых популярных новостей для горячих тем в Weibo, выбор 10 самых продаваемых товаров и т. д.
+3
View File
@@ -0,0 +1,3 @@
# Куча
![](../assets/media/image334.jpeg){width="3.8072911198600177in" height="4.927083333333333in"}
+17
View File
@@ -0,0 +1,17 @@
# Резюме
### Ключевые моменты
- Куча -- это полное двоичное дерево, которое в зависимости от условий делится на максимальную и минимальную кучу. Элемент на вершине максимальной (минимальной) кучи является наибольшим (наименьшим).
- Приоритетная очередь определяется как очередь с приоритетом извлечения элементов и обычно реализуется с помощью кучи.
- Основные операции с кучей и их временная сложность включают: вставка элемента в кучу $O(\log n)$, извлечение элемента с вершины кучи $O(\log n)$ и доступ к элементу на вершине кучи $O(1)$ и т. д.
- Полное двоичное дерево очень удобно представлять в виде массива, поэтому для хранения кучи обычно используется массив.
- Операция упорядочивания кучи используется для поддержания свойств кучи и применяется как при вставке, так и при извлечении элементов.
- Временная сложность построения кучи из $n$ элементов может быть оптимизирована до $O(n)$, что очень эффективно.
- Top-k -- это классическая алгоритмическая задача, которая может быть эффективно решена с помощью структуры данных кучи с временной сложностью $O(n \log k)$.
### Вопросы и ответы
**В:** Являются ли «куча» в структурах данных и «куча» в управлении памятью одним и тем же понятием?
Это не одно и то же понятие, просто они случайно называются одинаково -- «куча». Куча в памяти компьютерной системы является частью динамического распределения памяти, которую программа может использовать для хранения данных во время выполнения. Программа может запросить определенный объем памяти кучи для хранения сложных структур, таких как объекты и массивы. Когда эти данные больше не нужны, программа должна освободить эту память, чтобы предотвратить утечки памяти. По сравнению с памятью стека, управление и использование памяти кучи требует большей осторожности, неправильное использование может привести к утечкам памяти и висячим указателям.
+73
View File
@@ -0,0 +1,73 @@
# Задача Top-k
!!! question
Дан неупорядоченный массив `nums` длиной $n$. Необходимо вернуть $k$ наибольших элементов массива.
Для решения этой задачи сначала рассмотрим два метода с более прямолинейным подходом, а затем более эффективное решение с использованием кучи.
## Метод 1: обход с выбором
Можно выполнить $k$ раундов обхода, как показано на рисунке ниже, извлекая в каждом раунде 1-й, 2-й, $\dots$, $k$-й по величине элемент. Временная сложность составляет $O(nk)$.
Этот метод подходит только для случаев, когда $k \ll n$, поскольку при $k$, близком к $n$, временная сложность приближается к $O(n^2)$, что очень затратно по времени.
![Обход для поиска k наибольших элементов](../assets/top_k_traversal.png)
!!! tip
При $k = n$ мы получаем полную упорядоченную последовательность, что эквивалентно алгоритму «сортировка выбором».
## Метод 2: сортировка
Как показано на рисунке ниже, можно сначала отсортировать массив `nums`, а затем вернуть $k$ крайних правых элементов. Временная сложность составляет $O(n \log n)$.
Очевидно, что этот метод выполняет задачу «с избытком», поскольку нам нужно найти только $k$ наибольших элементов, а не сортировать остальные элементы.
![Сортировка для поиска k наибольших элементов](../assets/top_k_sorting.png)
## Метод 3: куча
Задачу Top-k можно решить более эффективно с использованием кучи. Процесс показан на рисунке ниже.
1. Инициализируется минимальная куча, элемент на вершине которой является наименьшим.
2. Сначала первые $k$ элементов массива последовательно добавляются в кучу.
3. Начиная с элемента $k + 1$, если текущий элемент больше элемента на вершине кучи, элемент с вершины извлекается, а текущий элемент добавляется в кучу.
4. После завершения обхода в куче сохраняются $k$ наибольших элементов.
=== "<1>"
![Поиск k наибольших элементов с использованием кучи](../assets/top_k_heap_step1.png)
=== "<2>"
![top_k_heap_step2](../assets/top_k_heap_step2.png)
=== "<3>"
![top_k_heap_step3](../assets/top_k_heap_step3.png)
=== "<4>"
![top_k_heap_step4](../assets/top_k_heap_step4.png)
=== "<5>"
![top_k_heap_step5](../assets/top_k_heap_step5.png)
=== "<6>"
![top_k_heap_step6](../assets/top_k_heap_step6.png)
=== "<7>"
![top_k_heap_step7](../assets/top_k_heap_step7.png)
=== "<8>"
![top_k_heap_step8](../assets/top_k_heap_step8.png)
=== "<9>"
![top_k_heap_step9](../assets/top_k_heap_step9.png)
Пример кода приведен ниже:
```src
[file]{top_k}-[class]{}-[func]{top_k_heap}
```
Всего выполняется $n$ раундов добавления и извлечения элементов, максимальная длина кучи равна $k$, поэтому временная сложность составляет $O(n \log k)$. Этот метод очень эффективен: при малых $k$ временная сложность стремится к $O(n)$; при больших $k$ временная сложность не превышает $O(n \log n)$.
Кроме того, этот метод подходит для сценариев использования с динамическими потоками данных. При непрерывном добавлении данных можно постоянно поддерживать элементы в куче, обеспечивая динамическое обновление $k$ наибольших элементов.