Алгоритмы с нуля
Операции с кучей
У вас есть куча. Теперь вы хотите добавить новый элемент или удалить корень. Когда вы это делаете, куча ломается — родитель становится больше его ребёнка, или массив больше не представляет полное дерево. Две операции восстанавливают порядок: просеивание вверх (подбросить нового элемента вверх мимо его родителя) и просеивание вниз (потопить неправильно расположенный элемент вниз мимо его детей). Вместе они сохраняют свойство кучи живым. И есть третье чудо: вы можете превратить любой неупорядоченный массив в кучу путём просеивания вниз каждого не-листового узла, в O(n) времени — быстрее, чем вставлять n элементов один за другим.
После этого урока вы можете вставить элемент в кучу с просеиванием вверх и объяснить, почему это занимает O(log n) времени. Вы можете удалить корень, обменявшись с последним элементом и просеяв вниз, и объяснить стоимость O(log n). Вы можете превратить произвольный массив в валидную кучу, вызвав просеивание вниз на каждом не-листовом узле, начиная с последнего, за O(n) общее время — не O(n log n). Вы можете трассировать все три операции на конкретном массиве кучи, шаг за шагом, проверяя свойство кучи после.
Две фундаментальные операции: просеивание вверх и вниз
Куча валидна только если свойство кучи выполняется: каждый родитель ≤ его дети (мин-куча) или ≥ (макс-куча). Когда вы добавляете или удаляете элемент, это свойство нарушается. Две операции восстанавливают его.
Просеивание вверх (всплытие): Когда новый элемент добавляется в конец массива кучи, он может быть меньше (в мин-куче) его родителя. Просеивание вверх повторно сравнивает элемент со своим родителем и обменивается вверх до тех пор, пока свойство кучи не восстановится или не будет достигнут корень.
Просеивание вниз (погружение): Когда корень удаляется и последний элемент перемещается в корень, он может быть больше его детей. Просеивание вниз повторно сравнивает элемент со своим меньшим (или большим) ребёнком и обменивается вниз до тех пор, пока свойство кучи не восстановится или не будет достигнут лист.
Почему это работает: Свойство кучи нарушается только в одном узле за раз. Просеивание вверх и вниз исправляют ровно одну пару родитель-ребёнок за каждый обмен, и потому что дерево сбалансировано (высота O(log n)), необходимо максимум O(log n) обменов.
Push (вставка)
Push добавляет новый элемент в кучу.
- Добавьте новое значение в конец массива кучи.
- Вызовите просеивание вверх на вновь добавленном элементе.
- Просеивание вверх останавливается, когда новый элемент находится в правильной позиции (свойство кучи восстановлено) или достигнут корень.
Пример: Вставьте 0 в мин-кучу [1, 3, 2, 5, 4].
- Добавьте: [1, 3, 2, 5, 4, 0]
- Просеивание вверх с индекса 5 (значение 0):
- Родитель на индексе (5-1)/2 = 2 (значение 2).
- 0 < 2, обмен: [1, 3, 0, 5, 4, 2].
- Теперь на индексе 2 (значение 0), родитель на индексе (2-1)/2 = 0 (значение 1).
- 0 < 1, обмен: [0, 3, 1, 5, 4, 2].
- Теперь на индексе 0 (корень), стоп.
- Результат: [0, 3, 1, 5, 4, 2]. Свойство кучи проверено: 0 ≤ (3, 1), 3 ≤ (5, 4), 1 ≤ (2). ✓
Pop (извлечение-минимум или извлечение-максимум)
Pop удаляет и возвращает корень (минимальный или максимальный элемент).
- Сохраните значение корня (чтобы вернуть его).
- Переместите последний элемент в массиве в позицию корня.
- Удалите последний элемент из массива (размер уменьшается на 1).
- Вызовите просеивание вниз на корне.
- Просеивание вниз останавливается, когда свойство кучи восстановлено или достигнут лист.
Пример: Pop из мин-кучи [1, 3, 2, 5, 4].
- Сохраните корень 1 (чтобы вернуть).
- Переместите последний элемент (4) в корень: [4, 3, 2, 5].
- Просеивание вниз с индекса 0 (значение 4):
- Дети на индексах 1 (значение 3) и 2 (значение 2). Меньший ребёнок — 2 на индексе 2.
- 4 > 2, обмен: [2, 3, 4, 5].
- Теперь на индексе 2 (значение 4), дети на индексах 5 и 6 (не существуют).
- Стоп.
- Результат: [2, 3, 4, 5]. Свойство кучи проверено: 2 ≤ (3, 4), 3 ≤ (5). ✓ Возвращаемое значение: 1.
Построение кучи (heapify)
Вы можете построить валидную кучу из произвольного массива за O(n) времени — быстрее, чем вызывая push n раз (это было бы O(n log n)).
Построение кучи (иначе heapify) работает путём просеивания вниз каждого не-листового узла, начиная с последнего не-листового и работая в обратном направлении к корню.
Последний не-листовый узел находится на индексе ⌊n/2⌋ - 1. Почему? Потому что куча с n элементами имеет высоту ⌈log₂(n+1)⌉, а последний не-листовый — последний узел, у которого существуют дети.
Для каждого не-листового вызовите просеивание вниз. Хотя просеивание вниз O(log n) на один узел, построение кучи O(n) всего потому, что большинство узлов — листья (что требует O(1) просеивания), а внутренние узлы у дна имеют короткие поддеревья.
Пример: Превратьте [5, 3, 8, 1, 4] в мин-кучу.
- n = 5. Последний индекс не-листового = ⌊5/2⌋ - 1 = 1.
- Просеивание вниз индекса 2 (значение 8): Нет детей (индексы 5, 6 не существуют). Нет обмена.
- Просеивание вниз индекса 1 (значение 3):
- Дети на индексах 3 (значение 1) и 4 (значение 4). Меньший — 1 на индексе 3.
- 3 > 1, обмен: [5, 1, 8, 3, 4].
- Теперь на индексе 3, нет детей. Стоп.
- Просеивание вниз индекса 0 (значение 5):
- Дети на индексах 1 (значение 1) и 2 (значение 8). Меньший — 1 на индексе 1.
- 5 > 1, обмен: [1, 5, 8, 3, 4].
- Теперь на индексе 1, дети на индексах 3 (значение 3) и 4 (значение 4). Меньший — 3 на индексе 3.
- 5 > 3, обмен: [1, 3, 8, 5, 4].
- Теперь на индексе 3, нет детей. Стоп.
- Результат: [1, 3, 8, 5, 4]. Свойство кучи проверено: 1 ≤ (3, 8), 3 ≤ (5, 4). ✓
Просеивание вверх в коде
1
function siftUp(heap, index) {
2
// Повторно обменивайтесь с родителем, пока свойство кучи не будет восстановлено или не будет достигнут корень
3
while (index > 0) {
4
const parentIndex = Math.floor((index - 1) / 2);
5
// В мин-куче, ребёнок < родитель — нарушение; обмен и продолжение
6
if (heap[index] < heap[parentIndex]) {
7
[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]];
8
index = parentIndex;
9
} else {
10
break; // Свойство кучи восстановлено
11
}
12
}
13
}
14
15
function push(heap, value) {
16
heap.push(value);
17
siftUp(heap, heap.length - 1);
18
}
- L1 Просеивание вверх восстанавливает свойство кучи после вставки.
- L3 Продолжайте, пока узел не является корнем.
- L4 Вычислите индекс родителя, используя стандартную формулу.
- L6 В мин-куче, если ребёнок < родитель, свойство нарушено.
- L7 Обменяйте ребёнка с родителем и движение вверх.
- L10 Push добавляет значение в конец и просеивает его вверх.
Просеивание вниз в коде
1
function siftDown(heap, index) {
2
// Повторно обменивайтесь с меньшим (или большим) ребёнком, пока свойство будет выполнено
3
while (true) {
4
const leftChild = 2 * index + 1;
5
const rightChild = 2 * index + 2;
6
let smallest = index;
7
8
// Проверьте, существует ли левый ребёнок и меньше ли он текущего наименьшего
9
if (leftChild < heap.length && heap[leftChild] < heap[smallest]) {
10
smallest = leftChild;
11
}
12
13
// Проверьте, существует ли правый ребёнок и меньше ли он
14
if (rightChild < heap.length && heap[rightChild] < heap[smallest]) {
15
smallest = rightChild;
16
}
17
18
// Если ребёнок меньше, обмен и продолжение; иначе стоп
19
if (smallest !== index) {
20
[heap[index], heap[smallest]] = [heap[smallest], heap[index]];
21
index = smallest;
22
} else {
23
break; // Свойство кучи восстановлено
24
}
25
}
26
}
27
28
function pop(heap) {
29
const root = heap[0];
30
heap[0] = heap[heap.length - 1];
31
heap.pop();
32
if (heap.length > 0) {
33
siftDown(heap, 0);
34
}
35
return root;
36
}
- L1 Просеивание вниз восстанавливает свойство кучи после удаления корня.
- L4 Вычислите индексы левого и правого детей.
- L5 Отслеживайте индекс наименьшего элемента (для мин-кучи).
- L8 Если левый ребёнок существует и меньше, обновите наименьший.
- L13 Проверьте правый ребёнок так же.
- L18 Если наименьший не текущий узел, обмен необходим.
- L19 Обменяйте и продолжайте просеивание с новой позиции.
- L24 Pop возвращает корень и восстанавливает свойство кучи.
Построение кучи в коде
1
function buildHeap(arr) {
2
// Начните с последнего не-листового узла и просейте вниз каждый
3
const lastNonLeaf = Math.floor(arr.length / 2) - 1;
4
for (let i = lastNonLeaf; i >= 0; i--) {
5
siftDown(arr, i);
6
}
7
return arr;
8
}
9
10
// Пример: превратить неупорядоченный массив в мин-кучу
11
const unordered = [5, 3, 8, 1, 4];
12
buildHeap(unordered);
13
console.log("Куча:", unordered); // [1, 3, 8, 5, 4]
- L1 buildHeap преобразует любой массив в валидную кучу.
- L3 Последний не-листовый узел на индексе floor(n/2) - 1.
- L4 Повторяйте от последнего не-листового до корня (индекс 0).
- L5 Просейте вниз каждый узел, чтобы восстановить свойство кучи в его поддереве.
- L11 buildHeap — O(n), потому что большинство узлов листья (O(1) просеивание) и внутренние узлы имеют короткие поддеревья.
1
const minHeap = [1, 3, 2, 5, 4];
2
// Push 0
3
minHeap.push(0); // [1, 3, 2, 5, 4, 0]
4
// Просеивание вверх с индекса 5
1
const minHeap = [1, 3, 2, 5, 4];
2
// Pop корень (1) и просейте вниз
Время просеивания вверх и вниз
Просеивание вверх перемещает элемент вверх на один уровень за обмен. Мин-куча с n элементами имеет высоту ⌈log₂(n+1)⌉, поэтому происходит максимум O(log n) обменов. Каждый обмен O(1) (два сравнения, один доступ к массиву).
- Время: O(log n)
Просеивание вниз перемещает элемент вниз на один уровень за обмен, также O(log n) в худшем случае.
- Время: O(log n)
Push и pop
Push добавляет (O(1)) и вызывает просеивание вверх (O(log n)).
- Время: O(log n)
Pop перемещает последний в корень (O(1)), удаляет последний (O(1)) и просеивает вниз (O(log n)).
- Время: O(log n)
Время построения кучи: почему O(n) а не O(n log n)?
Наивный подход вызвал бы push n раз: O(n log n). Но построение кучи работает иначе. Оно вызывает просеивание вниз на n/2 узлах. Это похоже на O(n * log n), но не так.
Ключевой инсайт: просеивание вниз с узла на высоте h требует O(h) времени (не O(log n) для всех узлов). Куча имеет:
- 1 узел на высоте ⌈log₂ n⌉ (корень).
- 2 узла на высоте ⌈log₂ n⌉ - 1.
- 4 узла на высоте ⌈log₂ n⌉ - 2.
- …
- n/2 узлов на высоте 0 (листья).
Общая работа:
1*⌈log₂ n⌉ + 2*(⌈log₂ n⌉ - 1) + 4*(⌈log₂ n⌉ - 2) + ... + (n/2)*0
= Σ(2^h * (⌈log₂ n⌉ - h)) для h от 0 до ⌈log₂ n⌉Эта сумма равна n * (постоянная, что зависит от логарифма), которая сходится к постоянной < 2. Поэтому время O(n).
- Построение кучи: O(n)
Пространственная сложность
Все три операции (push, pop, построение кучи) работают на месте. Куча хранится в одном массиве.
- Пространство: O(1) (не считая сам массив)
После вставки нового элемента в мин-кучу, просеивание вверх останавливается, когда элемент находится в позиции, где его значение больше значения своего родителя. Сколько обменов может потребоваться в худшем случае для кучи с 1000 элементами?
У вас мин-куча с 16 элементами. Какой индекс последнего не-листового узла при вызове построения кучи?
Почему построение кучи O(n) вместо O(n log n)?
Вы извлекаете корень из мин-кучи [1, 4, 2, 7, 5]. Стандартная операция pop перемещает последний элемент в корень, затем просеивает вниз. Какой массив кучи получится в результате?
После применения построения кучи к [10, 5, 20, 15, 30], какое минимальное значение на корне результирующей мин-кучи?
lesson.inset.undefined
Почему операции с кучей важны. Push и pop — O(log n), намного быстрее, чем добавление в конец отсортированного списка (O(n), потому что вы должны сдвинуть все большие элементы). Построение кучи за O(n) означает, что вы можете взять любой набор данных и подготовить его для эффективных приоритетных очередей. Алгоритм Дейкстры, планирование задач и поиск медианы — всё полагается на эти операции.
lesson.inset.undefined
Путаница в направлениях просеивания. Просеивание вверх — для вставки (начните с листа, движение к корню). Просеивание вниз — для удаления (начните с корня, движение к листьям). Они восстанавливают свойство в разных направлениях, поэтому логика не симметрична. Проверьте ваш код: просеивание вверх идёт вверх (родитель = (i-1)/2), просеивание вниз идёт вниз (дети = 2i+1, 2i+2).
lesson.inset.undefined
Куча с одним элементом. Вставка в пустую кучу или кучу с одним элементом работает нормально: новый элемент добавляется, и просеивание вверх останавливается сразу потому что условие родителя не нарушено. Pop из кучи с одним элементом оставляет пустую кучу; просеивание вниз не должно быть вызвано, если куча пуста.
Объясни различие между просеиванием вверх и просеиванием вниз, и когда вы используете каждое.
Чтобы сохранить кучу при добавлении или удалении элементов, используйте две операции: просеивание вверх (поднять новый элемент путём сравнения и обмена с его родителем, O(log n)) и просеивание вниз (опустить неправильный элемент путём сравнения и обмена с его меньшим/большим ребёнком, O(log n)). Push добавляет новое значение и просеивает вверх; pop извлекает корень, перемещает последний элемент в корень и просеивает вниз — оба O(log n). Чудо-операция — построение кучи (также heapify): просейте вниз каждый не-листовый узел, начиная с последнего, превращая любой массив в валидную кучу за O(n) общее время. Это быстрее, чем push n элементов один за другим (O(n log n)), потому что большинство узлов листья (O(1) работа) и внутренние узлы имеют неглубокие поддеревья. Общая работа — сумма счёта узлов × глубина, которая сходится к O(n). Дальше: Раздел 08, урок 03 использует кучи для построения приоритетных очередей, фундаментальной структуры данных для алгоритма Дейкстры и планирования задач.