awesome-everything EN
↑ Обратно к восхождению

Алгоритмы с нуля

Операции с кучей

Суть Push, pop и построение кучи: как мутировать кучу, сохраняя свойство кучи. Просеивание вверх и вниз восстанавливают порядок. Построение кучи превращает любой массив в кучу за O(n) времени — не O(n log n).
◷ 30 min

У вас есть куча. Теперь вы хотите добавить новый элемент или удалить корень. Когда вы это делаете, куча ломается — родитель становится больше его ребёнка, или массив больше не представляет полное дерево. Две операции восстанавливают порядок: просеивание вверх (подбросить нового элемента вверх мимо его родителя) и просеивание вниз (потопить неправильно расположенный элемент вниз мимо его детей). Вместе они сохраняют свойство кучи живым. И есть третье чудо: вы можете превратить любой неупорядоченный массив в кучу путём просеивания вниз каждого не-листового узла, в O(n) времени — быстрее, чем вставлять n элементов один за другим.

Цель

После этого урока вы можете вставить элемент в кучу с просеиванием вверх и объяснить, почему это занимает O(log n) времени. Вы можете удалить корень, обменявшись с последним элементом и просеяв вниз, и объяснить стоимость O(log n). Вы можете превратить произвольный массив в валидную кучу, вызвав просеивание вниз на каждом не-листовом узле, начиная с последнего, за O(n) общее время — не O(n log n). Вы можете трассировать все три операции на конкретном массиве кучи, шаг за шагом, проверяя свойство кучи после.

Идея

Две фундаментальные операции: просеивание вверх и вниз

Куча валидна только если свойство кучи выполняется: каждый родитель ≤ его дети (мин-куча) или ≥ (макс-куча). Когда вы добавляете или удаляете элемент, это свойство нарушается. Две операции восстанавливают его.

Просеивание вверх (всплытие): Когда новый элемент добавляется в конец массива кучи, он может быть меньше (в мин-куче) его родителя. Просеивание вверх повторно сравнивает элемент со своим родителем и обменивается вверх до тех пор, пока свойство кучи не восстановится или не будет достигнут корень.

Просеивание вниз (погружение): Когда корень удаляется и последний элемент перемещается в корень, он может быть больше его детей. Просеивание вниз повторно сравнивает элемент со своим меньшим (или большим) ребёнком и обменивается вниз до тех пор, пока свойство кучи не восстановится или не будет достигнут лист.

Почему это работает: Свойство кучи нарушается только в одном узле за раз. Просеивание вверх и вниз исправляют ровно одну пару родитель-ребёнок за каждый обмен, и потому что дерево сбалансировано (высота O(log n)), необходимо максимум O(log n) обменов.

Push (вставка)

Push добавляет новый элемент в кучу.

  1. Добавьте новое значение в конец массива кучи.
  2. Вызовите просеивание вверх на вновь добавленном элементе.
  3. Просеивание вверх останавливается, когда новый элемент находится в правильной позиции (свойство кучи восстановлено) или достигнут корень.

Пример: Вставьте 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. Сохраните значение корня (чтобы вернуть его).
  2. Переместите последний элемент в массиве в позицию корня.
  3. Удалите последний элемент из массива (размер уменьшается на 1).
  4. Вызовите просеивание вниз на корне.
  5. Просеивание вниз останавливается, когда свойство кучи восстановлено или достигнут лист.

Пример: 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) просеивание) и внутренние узлы имеют короткие поддеревья.
Output
 
Пошаговый разбор
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) (не считая сам массив)
Практика 0 / 5

После вставки нового элемента в мин-кучу, просеивание вверх останавливается, когда элемент находится в позиции, где его значение больше значения своего родителя. Сколько обменов может потребоваться в худшем случае для кучи с 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 использует кучи для построения приоритетных очередей, фундаментальной структуры данных для алгоритма Дейкстры и планирования задач.

Продолжить восхождение ↑Очереди с приоритетом
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.