Структуры данных и двоичная куча

От компромиссов массива к куче и сортировке за O(n log n)

01

Структура данных и операции

В первой лекции мы оценивали отдельный алгоритм. Теперь рассмотрим задачу, в которой действия повторяются: в симулятор поступают события, и каждый раз нужно обработать ближайшее по времени. Одного поиска минимума недостаточно — после удаления события и добавления новых данных его приходится искать снова.

Можно хранить все события в массиве. Но прежде чем выбирать представление, выпишем, что именно программа должна уметь делать и сколько раз потребуется каждое действие. Так мы переходим от алгоритма над готовым входом к структуре данных, которая поддерживает целую последовательность операций.

Симулятор событий: что должна делать структура?

Число — время события. Выполните insert(7), insert(2), getMin(), removeMin(). Перед последней командой предположите, что останется.

Пустой массив

Пустой симулятор. Добавьте события на моменты 7 и 2.

Структура данных (Data structure) — Представление данных вместе с набором поддерживаемых операций и оценками их стоимости.

Для нашего примера нужны три операции: добавить событие, посмотреть ближайшее и извлечь его. Их объединяет абстракция очереди с приоритетами: первым обрабатывается элемент с наименьшим приоритетом, независимо от времени его добавления.

Очередь с приоритетами (Priority queue) — Абстрактная структура, которая хранит элементы с приоритетами и позволяет добавлять элемент и извлекать наименьший (или наибольший).

Будем обозначать операции как insert(x), getMin() и removeMin(). Повторы разрешены: удаление убирает одно вхождение минимума. Если одинаковые приоритеты должны обслуживаться в порядке прихода, можно хранить пары «приоритет, номер добавления».

ОперацияКонтрактПустая структура
insert(x)Добавить одно вхождение xДопустимо
getMin()Вернуть наименьший xОшибка / особый результат
removeMin()Удалить одно минимальное xОшибка / особый результат
Принцип проектированияСначала операции и их стоимость — потом представление. Массив с произвольным порядком и отсортированный массив хранят те же данные, но имеют принципиально разные характеристики.

В примере ниже используем готовую очередь из стандартной библиотеки. Пока важно увидеть её контракт; в следующих разделах самостоятельно построим двоичную кучу, которая этот контракт реализует.

Минимальная очередь стандартной библиотеки

Python heapq хранит min-кучу в списке. Rust BinaryHeap по умолчанию извлекает максимум; Reverse меняет порядок на минимум. Пустая очередь обрабатывается проверкой или Option. Начальное построение из набора — O(n), каждое извлечение — O(log n); полный вывод всех элементов — O(n log n).

Минимальная очередь стандартной библиотеки / Python
import heapq

def ordered(values: list[int]) -> list[int]:
    heap = values.copy()
    heapq.heapify(heap)
    result = []
    while heap:
        result.append(heapq.heappop(heap))
    return result

print(ordered([5, 1, 4, 2]))
Ожидаемый вывод
[1, 2, 4, 5]

В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

События по времени

Симулятор соревнования получает новые события и должен обрабатывать ближайшее. При q ≤ 200 000 операциях извлечение минимума сканированием большого массива может дать O(q²). Куча позволяет поддерживать следующий момент за O(log q); список всех событий заранее можно просто отсортировать.

Итак, мы определили поведение структуры, но ещё не выбрали способ хранения. Начнём с двух вариантов на массиве: их ограничения покажут, какую работу должна ускорить куча.

Выполните команды над пустым симулятором событий.

Задания раздела

insert(7), insert(2), getMin(): что вернёт getMin()?

Затем removeMin(): какое значение будет возвращено?

Какие события останутся после всех четырёх команд?

Попыток: 0
02

Два варианта на массиве

Этот раздел откроется после предыдущих проверок.

Продолжить: Структура данных и операции →

Добавим 7, затем 2. Без порядка получим [7,2]; по убыванию — тоже [7,2]. А вставка 5 уже различит варианты: [7,2,5] против [7,5,2]. Предскажите, где будет легче извлечь минимум.

Одна последовательность — два представления

Сравнение — проверка двух ключей; перемещение — сдвиг существующего значения или замена удалённого последним. Запись нового значения и освобождение конца в этот счётчик не входят.

insert(7) → insert(2) → getMin() → insert(5) → removeMin() → insert(1) → getMin() → removeMin()

Без порядка

Пустой массив

Пустой массив.

Сравнений: 0; перемещений: 0.

По убыванию

Пустой массив

Пустой массив.

Сравнений: 0; перемещений: 0.

Выполнено 0 из 8 операций

Перед insert(5) сравните ожидаемые затраты. После getMin() набор не меняется; после removeMin() исчезает только один минимум.

Первый вариант — неупорядоченный массив. Новый элемент дописываем в конец за O(1), если свободная ёмкость уже есть. Зато минимум приходится искать среди всех n элементов: порядок хранения ничего о нём не сообщает.

После поиска удалять элемент со сдвигом всего хвоста необязательно. Порядок нам не нужен, поэтому заменим найденный минимум последним элементом и сократим используемую часть массива. Поиск остаётся линейным, само удаление после поиска — постоянным.

Неупорядоченный массив: удалить минимум
i = argmin(A[0..n−1])
swap(A[i], A[n−1])
n = n − 1

Физически уменьшаем используемый префикс, а не обязательно размер выделенной памяти.

Второй вариант — поддерживать массив отсортированным по убыванию. Тогда минимум всегда в конце, и его чтение и удаление стоят O(1). Цена переносится во вставку: чтобы сохранить порядок, иногда приходится сдвинуть почти весь массив.

Сохранённый отдельно минимум ускоряет только чтение: после его удаления новый минимум придётся искать заново. У отсортированного по убыванию массива дорогой становится вставка, зато извлечение с конца не требует поиска. Сравните на одной последовательности два представления ниже.

Чтобы выбрать вариант, оцените всю нагрузку. Если сначала идут n добавлений, а затем одно извлечение, неупорядоченного массива достаточно. Если вставки и извлечения чередуются примерно поровну, линейная операция выполняется снова и снова. В модели ниже можно сравнить именно такие последовательности.

Какую структуру выбрать?
insert ≈
1
getMin ≈
64
removeMin ≈
64

Модель для 64 операций: примерно 2080 условных действий.

Сравниваем масштаб, не время процессора; размер структуры для оценки фиксирован равным n.

Мы увидели компромисс двух массивов. В основном маршруте перейдём к коротким путям в двоичном дереве. Разбиение на блоки можно исследовать отдельно: оно не требуется для понимания кучи.

Сравните реальную последовательность из восьми операций.

Задания раздела

insert(7), insert(2), getMin(), insert(5), removeMin(), insert(1), getMin(), removeMin(): сколько сравнений ключей сделает неупорядоченный массив?

Сколько существующих значений сдвигает отсортированный по убыванию массив при insert(5) в [7,2]?

Нужны 200 000 добавлений, а затем только одно извлечение. Какой из двух массивов даёт линейную суммарную работу?

Попыток: 0
03

Полное двоичное дерево в массиве

Этот раздел откроется после предыдущих проверок.

Продолжить: Структура данных и операции →

Для иерархии возьмём двоичное дерево: у каждого узла не больше двух детей. Важно ограничить его высоту, иначе цепочка из n узлов снова приведёт к линейной работе. Поэтому заранее зафиксируем форму дерева.

Сначала разместим 6 узлов: один в корне, два на следующем уровне, затем три на третьем слева направо. Массив [0,1,2,3,4,5] хранит эти позиции подряд. Если оставить дыру, следующая ячейка уже не соответствовала бы следующему месту дерева.

Заполните дерево по уровням и выберите узел

Здесь подписи — индексы, а не приоритеты. Увеличивайте размер по одному: следующая ячейка занимает первое свободное место слева.

012345
0[0]
1[1]
2[2]
3[3]
4[4]
5[5]

Узел 1; родитель: 0; дети: 3, 4.

Двоичное дерево описывает связи. Дерево поиска дополнительно требует меньшие ключи слева и большие справа. Для кучи этого требования нет: она сравнивает только родителя с детьми.

Полное бинарное дерево (Complete binary tree) — Все уровни, кроме последнего, заполнены; последний заполняется слева направо.

Если заполнять уровни слева направо без пропусков, на глубине h помещается до 2ʰ узлов, а высота непустого дерева равна ⌊log₂ n⌋. Новое место всегда известно: это следующая свободная позиция на последнем уровне.

Расположим узлы в массиве по уровням. Тогда рёбра не нужно хранить явно: дети и родитель вычисляются по индексу. Например, у узла 1 дети находятся на позициях 3 и 4, а его родитель — на позиции 0.

left(i) = 2i + 1, right(i) = 2i + 2, parent(i) = ⌊(i−1)/2⌋

Нумерация начинается с нуля.

Граница индексовФормулы детей возвращают потенциальные индексы. Ребёнок существует только если индекс меньше текущего количества элементов n. У корня родителя нет.

Модель показывает два вида одних данных: ячейки массива и узлы дерева. Пока проследите, как позиции соответствуют друг другу; правило размещения значений разберём в следующем разделе.

Куча: массив и дерево

В модели до 31 элемента. Следующая операция применяется к завершённому результату предыдущей.

Размер
7
Минимум
3
386141097
3[0]
8[1]
6[2]
14[3]
10[4]
9[5]
7[6]
Исходная корректная min-куча.
Шаг 1 / 1

Форма дерева дала короткие пути и удобное хранение. Теперь зададим порядок значений на рёбрах, чтобы минимум всегда находился в заранее известном месте.

Проверьте связи и заполнение полного дерева.

Задания раздела

Каков индекс родителя узла 5 при нумерации с нуля?

Размер n = 6. Какие из потенциальных детей узла 2 существуют?

При n = 6 куда попадёт седьмой узел?

Попыток: 0
04

Свойство кучи

Этот раздел откроется после предыдущих проверок.

Продолжить: Структура данных и операции →

Полностью сортировать массив было дорого из-за требования упорядочить каждую пару соседей. Для быстрого доступа к минимуму достаточно более слабого условия: родитель должен быть не больше своих детей.

Инвариант min-кучи (Heap property) — Для каждого узла значение его родителя не превосходит значения любого из его детей.

A[parent(i)] ≤ A[i] для каждого i > 0

Внутри кучи допускаются одинаковые значения.

Возьмём любой узел и пройдём от него к корню. При каждом шаге вверх значение не увеличивается, значит значение корня не больше выбранного элемента. Так локальное правило для каждого ребра гарантирует глобальный минимум в корне, и getMin() становится O(1).

Например, массив [1, 4, 2, 8, 5, 3] — корректная min-куча: каждый родитель не больше детей. При этом 4 стоит раньше 2, а 5 раньше 3. Порядок между разными ветвями не нужен; именно эта свобода позволит быстро менять структуру.

Исправьте каждое нарушенное ребро

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

142350

[1] → [3]: 4 > 3; [2] → [5]: 2 > 0

Почему минимум в корне?
Для каждого элемента существует путь из корня. На каждом ребре значение не убывает при движении от родителя к ребёнку, поэтому значение корня не больше любого потомка.

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

Проверьте рёбра, а не порядок соседей массива.

Задания раздела

Какая последовательность является min-кучей?

В [1,4,2,3,5,0] отметьте все нарушенные рёбра.

Попыток: 0
05

Вставка: просеивание вверх

Этот раздел откроется после предыдущих проверок.

Продолжить: Структура данных и операции →

Чтобы вставить x и сохранить форму дерева, допишем его в конец массива. Все старые рёбра остаются прежними. Нарушиться может только новое ребро: x иногда меньше своего родителя.

Если это произошло, поменяем x с родителем. Теперь x поднялся на один уровень, и проверять нужно только его нового родителя. Повторяем, пока x не окажется в корне или пока родитель не станет не больше него. Это просеивание вверх.

Вставьте 2 в [3,5,4,9,8] и сначала предскажите число обменов. Дописывание даёт [3,5,4,9,8,2]; индекс 5 имеет родителя 2 со значением 4. После первого обмена [3,5,2,9,8,4]. Родитель индекса 2 — корень со значением 3; второй обмен даёт [2,5,3,9,8,4]. Теперь i = 0 и подъём заканчивается.

insert(x) и siftUp
A.push(x)
i = length(A) − 1
while i > 0 and A[i] < A[(i−1)//2]:
    swap(A[i], A[(i−1)//2])
    i = (i−1)//2

Например, вставим 2 в кучу [3, 5, 4, 9, 8]. Элемент попадёт на позицию 5, обменяется с 4, затем с 3. Получится [2, 5, 3, 9, 8, 4]. Мы исправили один путь к корню, а не пересортировали все шесть элементов.

Что сохраняется?Форма полного дерева не меняется после добавления в конец. При каждом обмене восстанавливается нарушенное ребро «родитель–ребёнок»; остальные части дерева сохраняют свойство кучи.
T_insert(n) = O(log n)

За обмен поднимаемся на один уровень, а уровней логарифмически мало.

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

Куча: массив и дерево

В модели до 31 элемента. Следующая операция применяется к завершённому результату предыдущей.

Размер
7
Минимум
3
386141097
3[0]
8[1]
6[2]
14[3]
10[4]
9[5]
7[6]
Исходная корректная min-куча.
Шаг 1 / 1

Код ниже применяет тот же механизм к потоку данных: хранит только k наибольших значений. В min-куче из k кандидатов корень — самый слабый из выбранных, поэтому по нему удобно решать, достоин ли новый элемент попасть в набор.

k-й наибольший элемент потока

Храним только k наибольших элементов. Минимум этой min-кучи — текущий k-й наибольший. После каждой вставки лишний минимум удаляется. Время O(n log(k + 1)), память O(k); если элементов меньше k или k = 0, результата нет.

k-й наибольший элемент потока / Python
import heapq

def kth_largest(values: list[int], k: int) -> int | None:
    if k <= 0:
        return None
    heap = []
    for value in values:
        heapq.heappush(heap, value)
        if len(heap) > k:
            heapq.heappop(heap)
    return heap[0] if len(heap) == k else None

print(kth_largest([3, 2, 1, 5, 6, 4], 2))
Ожидаемый вывод
5

В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

Таблица лучших k участников

При n = 10⁶ и k = 100 число уровней в поддерживаемой куче около семи, а не около двадцати. Время O(n log(k + 1)), память O(k). Уточните, считаются ли равные результаты отдельными местами: в приведённом примере считаются.

При вставке потенциальная ошибка двигалась от листа к корню. Для удаления минимума потребуется обратное направление: место освобождается в корне, и исправлять порядок будем сверху вниз.

Напишите sift_up(a): в конце a уже добавлен новый элемент, а весь предшествующий префикс был min-кучей. Поднимите последний элемент обменами с родителем и верните a. Пустой список оставьте пустым. Реализуйте просеивание без heapq и сортировки.

Tab — табуляция · Ctrl / ⌘ + L — выделить строку · Alt + ↑/↓ — переместить строку или выделенный блок · Ctrl / ⌘ + Enter — запустить. Esc, затем Tab — выйти из редактора.

Открыто 0 из 3

    5 секунд на тесты. При первом запуске загрузится Python; нужен интернет. Код выполняется в браузере.

    Тест 1 sift_up([3, 5, 4, 9, 8, 2])Ожидается: [2, 5, 3, 9, 8, 4]
    Тест 2 sift_up([])Ожидается: []
    Тест 3 sift_up([7])Ожидается: [7]

    Дополнительные тесты: 3. Проверяются границы и повторы.

    Попыток: 0
    06

    Удаление: просеивание вниз

    Этот раздел откроется после предыдущих проверок.

    Продолжить: Структура данных и операции →

    Минимум находится в корне, но просто вырезать первую ячейку нельзя: сдвиг изменит все связи между родителями и детьми. Сохраним ответ, перенесём в корень последний элемент и уменьшим размер массива. Форма полного дерева сохранится.

    У нового корня могут оказаться меньшие дети. Выберем меньшего из существующих детей и обменяемся с ним, если он меньше текущего элемента. Меньший ребёнок займёт верхнюю позицию корректно, а возможное нарушение спустится на один уровень. Повторяем до восстановления порядка.

    Четыре отдельных действия: 1) сохранить A[0] как ответ; 2) снять последний элемент и, если куча не пуста, перенести его в корень; 3) выбрать меньшего из существующих детей; 4) обменять только при строгом нарушении и повторить ниже. При равных детях можно выбрать любого; модель выбирает левого. При равенстве родителя и меньшего ребёнка останавливаемся.

    removeMin: кого выбрать следующим?
    1327549
    1[0]
    3[1]
    2[2]
    7[3]
    5[4]
    4[5]
    9[6]

    Сначала сохраните минимум и перенесите последний элемент в корень.

    removeMin()
    if length(A) == 0: error("empty")
    if length(A) == 1: return A.pop()
    answer = A[0]
    A[0] = A.pop()
    i = 0
    while 2*i+1 < length(A):
        j = 2*i+1
        if j+1 < length(A) and A[j+1] < A[j]: j += 1
        if A[i] <= A[j]: break
        swap(A[i], A[j]); i = j
    return answer

    При удалении последнего единственного элемента ответ возвращают сразу, без обращения к A[0].

    Почему выбираем меньшего ребёнка?Если обменяться с большим из детей, новый родитель может всё ещё быть больше второго ребёнка: свойство min-кучи не восстановится.

    Для кучи из одного элемента после удаления ничего просеивать не нужно. Для остальных случаев каждый обмен опускается ровно на уровень, поэтому извлечение, как и вставка, занимает не больше O(log n). Чтение минимума обходится без изменений структуры.

    T_removeMin(n) = O(log n), T_getMin(n) = O(1)

    Теперь контракт очереди с приоритетами реализован полностью. Проверим, что получится, если применить извлечение минимума ко всем элементам подряд: так структура данных превратится в алгоритм сортировки.

    Восстановите удаление минимума и проверьте границы.

    Задания раздела

    Расположите этапы removeMin в правильном порядке.

    1. Выбрать меньшего существующего ребёнка
    2. Сохранить минимум из корня
    3. Обменять при строгом нарушении и продолжить ниже
    4. Снять последний элемент и перенести его в непустой корень

    Корень 9, дети 3 и 2. С кем обменяться?

    Сколько обменов выполняет removeMin для кучи [7]?

    Узел 2, дети 2 и 2. Что делать?

    Попыток: 0
    07

    Сортировка с помощью кучи

    Этот раздел откроется после предыдущих проверок.

    Продолжить: Структура данных и операции →

    Положим все n элементов в min-кучу и будем извлекать минимум, пока куча не опустеет. Каждый следующий ответ не меньше предыдущего, а каждый исходный элемент извлекается ровно один раз. Значит, полученная последовательность отсортирована.

    Для min-кучи [1,3,2] последовательные removeMin() возвращают 1, затем 2, затем 3; выходной массив растёт [] → [1] → [1,2] → [1,2,3], а куча уменьшается. Вариант ниже с max-кучей использует освободившийся конец исходного массива вместо отдельного выхода.

    n вставок и n удалений дают O(n log n) времени. В таком варианте куча и результат используют дополнительную память порядка n. Чтобы отсортировать в исходном массиве, слегка изменим организацию алгоритма: будем хранить max-кучу.

    Максимум меняем с последним элементом текущей кучи. В конце массива постепенно растёт отсортированный суффикс, а в оставшемся префиксе после каждого обмена восстанавливается max-куча. Суффикс больше не трогаем.

    Heapsort на месте (max-heap)
    buildMaxHeap(A)
    for end = n−1 down to 1:
        swap(A[0], A[end])
        siftDownMax(A, root=0, size=end)
    Heapsort на месте
    12[0]
    5[1]
    16[2]
    2[3]
    9[4]
    3[5]
    11[6]
    8[7]
    4[8]
    7[9]
    6[10]
    Исходный неупорядоченный массив.
    Шаг 1 / 35

    Выделенный справа суффикс уже отсортирован и исключён из кучи.

    Разделение на префикс-кучу и готовый суффикс — инвариант сортировки. Просеивание можно написать циклом, поэтому этому варианту достаточно O(1) дополнительной памяти. Однако обычный heapsort неустойчив: дальние обмены могут поменять порядок равных ключей.

    СвойствоHeapsort
    Худшее времяΘ(n log n)
    Дополнительная памятьO(1) при реализации на месте
    УстойчивостьНет в стандартном варианте
    Зависимость от входного порядкаГарантия Θ(n log n)
    ПодходВремя в худшем случаеДополнительная памятьУстойчивость
    Сортировка вставкамиO(n²)O(1)Да, при сдвиге только строго больших
    MergeSort с буферомO(n log n)O(n)Да, если равные берём слева
    Heapsort на местеO(n log n)O(1)Нет

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

    Для heapsort на месте по возрастанию какую кучу держат в ещё не готовом префиксе?

    Выберите один ответ
    Попыток: 0
    08

    Построение кучи за линейное время

    Этот раздел откроется после предыдущих проверок.

    Продолжить: Структура данных и операции →

    Один и тот же готовый массив можно собрать n вставками сверху, до O(n log n), или исправлять поддеревья снизу. Во втором варианте листья уже готовы; их примерно половина, и просеивать их вообще не нужно.

    Операция heapify превращает готовый массив в кучу. Все листья уже являются корректными кучами из одного элемента. Начнём с последнего внутреннего узла и будем двигаться к корню, просеивая каждый узел вниз.

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

    Heapify: от последнего родителя к корню
    12516293118476
    12[0]
    5[1]
    16[2]
    2[3]
    9[4]
    3[5]
    11[6]
    8[7]
    4[8]
    7[9]
    6[10]
    Исходный неупорядоченный массив.
    Шаг 1 / 14
    Нижнее построение кучи
    for i = floor(n/2)−1 down to 0:
        siftDown(A, i, n)

    Грубая оценка «n узлов по log n» завышает работу: большинство узлов находится почти у листьев и далеко спуститься не может. Примерно n/2 листьев не требуют просеивания, около n/4 узлов спускаются не более чем на уровень, около n/8 — не более чем на два. Суммируем стоимость по высотам, а не умножаем все узлы на высоту корня.

    Углубление: сумма по высотам
    Σₕ O((n / 2ʰ⁺¹) · h) = O(n) Сумма h/2ʰ сходится; дополнительный множитель log n не возникает. Число вершин высоты h не превосходит ⌈n / 2⁽ʰ⁺¹⁾⌉. Каждая просеивается не более h уровней. Суммарная работа пропорциональна n·Σ(h/2ʰ), то есть O(n).

    В реализации ниже отдельно показаны построение и извлечение. Обратите внимание: обе операции используют одно и то же просеивание вниз, но запускают его для разных узлов и с разной текущей границей кучи.

    Итоговая памятка по структурамТеперь сравним уже знакомые правила и операции. Стек и FIFO выбирают по порядку прихода; очередь с приоритетами выбирает по ключу. Блочные оценки относятся к дополнительному исследованию.

    Сравнивать структуры удобнее в два шага: сначала понять, какое правило они сохраняют, затем посмотреть стоимость действий. Инвариант — правило, которое остаётся верным после каждой операции. Например, в min-куче родитель не больше своих детей: поэтому минимум всегда находится в корне, хотя весь массив не отсортирован.

    СтруктураКакой инвариант сохраняемЧто это даёт
    СтекДобавляем и удаляем только с вершины. Значения не сортируем.Последним добавили → первым извлекли (LIFO).
    Очередь FIFOДобавляем в хвост, удаляем из головы. Порядок прихода сохраняется.Первым добавили → первым извлекли.
    Неупорядоченный массивЭлементы занимают сплошной участок массива; порядка по значениям нет.Можно быстро дописать элемент или заменить удаляемый последним.
    Массив + сохранённый минимумПорядка по значениям нет; отдельно хранится актуальный минимум.Чтение минимума быстрое. После его удаления нужно найти новый.
    Массив по убываниюКаждый элемент не меньше следующего: A[i] ≥ A[i + 1].Максимум в начале, минимум в конце.
    Отсортированные блокиВнутри каждого блока значения убывают. Между блоками порядка нет; все блоки, кроме последнего, заполнены до b элементов.Минимум ищем среди концов блоков, максимум — среди их начал.
    Двоичная min-кучаДерево заполняется по уровням слева направо. Каждый родитель не больше своих детей.Минимум в корне. Между соседними ячейками массива общего порядка нет.

    Теперь уточним действия. Добавить x — принять новый элемент и сохранить инвариант; нужное место выбирает сама структура. Вставить в позицию i — поместить элемент в указанную ячейку, сдвинув следующие вправо. В отсортированном массиве, блоках и куче произвольная позиция может нарушить инвариант, поэтому такой операции в их интерфейсе нет.

    Удалить — убрать элемент из уже известной позиции, без затрат на его поиск; для стека удаляем вершину, для очереди — голову. Найти x — найти позицию заданного значения. Мин и Макс — прочитать наименьшее и наибольшее значения, не удаляя их. Так мы не смешиваем чтение минимума с его извлечением.

    СтруктураДобавить xУдалитьНайти xВставить в позицию iМинМакс
    СтекO(1)*O(1), вершинуO(n)†—O(n)†O(n)†
    Очередь FIFOO(1)*O(1), головуO(n)†—O(n)†O(n)†
    Неупорядоченный массивO(1)*O(1), заменой последнимO(n)O(n)O(n)O(n)
    Массив + сохранённый минимумO(1)*O(n), если удалён минимумO(n)O(n)O(1)O(n)
    Массив по убываниюO(n)O(n); минимум из конца — O(1)O(log n)—O(1)O(1)
    Отсортированные блокиO(b)*O(b), с восстановлением блоковO(k log(b + 1))—O(k)O(k)
    Двоичная min-кучаO(log n)*O(log n), с просеиваниемO(n)—O(1)O(n)

    Здесь n — число элементов, b — размер блока, k = ⌈n/b⌉ — число блоков. Для поиска значения в блоках выполняем двоичный поиск в каждом блоке. Прочерк означает, что операция не предусмотрена этим интерфейсом. Знак † обозначает дополнительный проход по содержимому: поиск, минимум и максимум не входят в основные операции стека и FIFO-очереди.

    Оценки даны для худшего случая; звёздочкой отмечена амортизированная стоимость с учётом расширения динамического массива. Стек хранится в массиве, FIFO-очередь — в кольцевом буфере, все варианты используют O(n) памяти. Размер b выбираем заранее под ожидаемый масштаб данных; его изменение требует пересборки. Чтобы удалить заданное значение, сначала прибавьте стоимость его поиска. Например, удаление произвольного x из кучи занимает O(n), а извлечение известного минимума из корня — O(log n).

    Какую структуру выбрать?
    insert ≈
    1
    getMin ≈
    64
    removeMin ≈
    64

    Модель для 64 операций: примерно 2080 условных действий.

    Сравниваем масштаб, не время процессора; размер структуры для оценки фиксирован равным n.

    Свои heapify и removeMin

    Сначала просеиваем родителей от конца к корню: построение занимает O(n), потому что большинство узлов низкие. Функция remove_min возвращает None на пустом входе. Здесь показана min-куча; это отдельный массив, а не heapsort на месте.

    Свои heapify и removeMin / Python
    def sift_down(a: list[int], i: int) -> None:
        while 2 * i + 1 < len(a):
            j = 2 * i + 1
            if j + 1 < len(a) and a[j + 1] < a[j]:
                j += 1
            if a[i] <= a[j]:
                break
            a[i], a[j] = a[j], a[i]
            i = j
    
    def heapify(a: list[int]) -> None:
        for i in range(len(a) // 2 - 1, -1, -1):
            sift_down(a, i)
    
    def remove_min(a: list[int]) -> int | None:
        if not a:
            return None
        result = a[0]
        last = a.pop()
        if a:
            a[0] = last
            sift_down(a, 0)
        return result
    
    data = [7, 2, 9, 4, 1, 8]
    heapify(data)
    result = []
    while data:
        result.append(remove_min(data))
    print(result)
    Ожидаемый вывод
    [1, 2, 4, 7, 8, 9]

    В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

    Готовый стартовый набор задач

    Если в симуляторе сразу дано 200 000 начальных событий, сначала примените heapify, затем добавляйте новые события по мере появления. Это сокращает начальную фазу, но не делает всю последовательность извлечений линейной.

    Теперь вы умеете проверять min-кучу по рёбрам, выполнять вставку и извлечение, выбирать кучу под последовательность запросов. Для одного нужного ранга иногда можно обойтись без хранения всей очереди: эту идею продолжит QuickSelect в лекции 03.

    Напишите is_min_heap(a): верните True, если каждый родитель не больше своих детей. Для пустого массива и одного элемента верните True. Достаточно проверить связь каждого индекса i > 0 с родителем.

    Tab — табуляция · Ctrl / ⌘ + L — выделить строку · Alt + ↑/↓ — переместить строку или выделенный блок · Ctrl / ⌘ + Enter — запустить. Esc, затем Tab — выйти из редактора.

    Открыто 0 из 3

      5 секунд на тесты. При первом запуске загрузится Python; нужен интернет. Код выполняется в браузере.

      Тест 1 is_min_heap([1, 4, 2, 8, 5, 3])Ожидается: True
      Тест 2 is_min_heap([1, 4, 2, 3])Ожидается: False
      Тест 3 is_min_heap([])Ожидается: True

      Дополнительные тесты: 4. Проверяются границы и повторы.

      Попыток: 0
      А—Я

      Справочник терминов

      Справочник собирает определения из разобранных разделов. Используйте поиск, если нужно восстановить термин; затем переходите к оценке ограничений и задачам для самостоятельного решения.

      Общий справочник всех лекций ↗

      Найдено: 14 из 14

      Структура данных / Data structureПредставление данных и допустимые операции.
      Очередь с приоритетами / Priority queueХранит приоритеты, позволяет добавлять элементы и извлекать экстремум.
      getMin / Find minimumЧтение минимального элемента без удаления.
      removeMin / Extract minimumУдаление одного вхождения минимума.
      Двоичная куча / Binary heapПолное двоичное дерево со свойством кучи.
      Min-куча / Min-heapУ каждого родителя значение ≤ значениям детей.
      Max-куча / Max-heapУ каждого родителя значение ≥ значениям детей.
      Просеивание вверх / Sift upВосстановление свойства кучи после вставки.
      Просеивание вниз / Sift downВосстановление свойства кучи после замены корня.
      Heapify / Heap constructionПерестройка массива в кучу; снизу вверх выполняется за O(n).
      Heapsort / Heap sortСортировка с помощью кучи за Θ(n log n).
      Амортизированная стоимость / Amortized costСредняя стоимость операции в последовательности в смысле суммарной верхней оценки.
      Инвариант / InvariantУсловие, сохраняющееся на этапах алгоритма.
      Полное бинарное дерево / Complete binary treeВсе уровни, кроме последнего, заполнены, последний — слева направо.
      09

      Ограничения и выбор алгоритма

      Этот раздел откроется после предыдущих проверок.

      Продолжить: Структура данных и операции →

      Мы сравнили представления по стоимости отдельных операций. Для задачи теперь сложим эти стоимости: параметры здесь — не только число элементов n, но и число операций q, размер ответа k и максимальная текущая длина очереди. Выпишите последовательность запросов: стоимость одной структуры может отличаться на порядки для разных нагрузок.

      Подставляем максимумы в суммарную стоимость

      • Для q = 200 000 извлечений со сканированием массива длиной порядка q возможны около q²/2 ≈ 2 · 10¹⁰ просмотров. Куча уменьшает оценку до O(q log q).
      • Если нужно k = 100 лучших из n = 10⁶ результатов, куча размера k требует O(n log(k + 1)) времени и O(k) памяти; не храните миллион элементов без необходимости.
      • Если весь стартовый набор уже дан, постройте кучу за O(n). Последовательная вставка всех значений допускает O(n log n); последовательное извлечение всех элементов после heapify также занимает O(n log n).

      Память, идентификаторы и повторы

      Массив из 200 000 значений i64 занимает 1,6 МБ полезных данных; пары с идентификаторами и Python-объекты требуют больше. Если нужны удаление произвольной записи или изменение ключа по id, одной стандартной кучи недостаточно: потребуется индекс или стратегия отложенного удаления с учётом её памяти.

      Сумма qᵢ log(nᵢ + 1) по тестам важнее стоимости одного максимального теста. Проверьте пустую очередь, один элемент, много одинаковых приоритетов, чередующиеся вставки и удаления. При вычисляемом приоритете, например x² + y², выбирайте тип, вмещающий промежуточный результат.

      Необязательное применение: слияние k потоков

      КОНТЕСТ / СЛИЯНИЕ k ПОТОКОВ

      Минимум среди текущих голов

      У каждого из k отсортированных списков достаточно хранить текущую голову. Извлекли минимум — добавили следующую запись того же списка. Для N суммарных элементов получаем O(N log(k + 1)), а в куче одновременно не больше k записей. Пустые списки пропускаются.

      Нужно хранить 100 лучших из миллиона результатов. Какая дополнительная память нужна для поточной кучи?

      Выберите один ответ
      Попыток: 0
      10

      Практика: 3 уровня

      Этот раздел откроется после предыдущих проверок.

      Продолжить: Структура данных и операции →

      Разминка перед внешними задачами

      Сначала проверьте каждое ребро кучи, затем самостоятельно реализуйте просеивание вниз. Вставку можно повторить в третьей вкладке: черновик и зачёт общие с главой. После этого переходите к LeetCode 703 и Codeforces 681C; top-k и слияние потоков доступны как дополнительные применения.

      Напишите is_min_heap(a): верните True, если каждый родитель не больше своих детей. Для пустого массива и одного элемента верните True. Достаточно проверить связь каждого индекса i > 0 с родителем.

      Tab — табуляция · Ctrl / ⌘ + L — выделить строку · Alt + ↑/↓ — переместить строку или выделенный блок · Ctrl / ⌘ + Enter — запустить. Esc, затем Tab — выйти из редактора.

      Открыто 0 из 3

        5 секунд на тесты. При первом запуске загрузится Python; нужен интернет. Код выполняется в браузере.

        Тест 1 is_min_heap([1, 4, 2, 8, 5, 3])Ожидается: True
        Тест 2 is_min_heap([1, 4, 2, 3])Ожидается: False
        Тест 3 is_min_heap([])Ожидается: True

        Дополнительные тесты: 4. Проверяются границы и повторы.

        Попыток: 0

        Примените разобранные приёмы самостоятельно. Для каждой задачи сначала сформулируйте требования к ответу и предложите простое решение. Затем подставьте максимальные ограничения, оцените время и память и решите, какое улучшение требуется. Подсказки с идеей и оценкой раскрываются отдельно.

        Уровни задают порядок практики в курсе, а не единую шкалу платформ. Если уровень отличается от отметки LeetCode, это указано в карточке. Для Codeforces используется оценка курса.

        01

        Лёгкие

        Три задачи для закрепления основных приёмов.

        1. LeetCode · 1046 · Easy

          Last Stone Weight

          Последовательно извлекать два максимума и возвращать разность в очередь.

          Ограничения. До 30 камней, веса от 1 до 1000.

          Идея и оценка сложности

          Для такого n допустимо и повторное сканирование, но max-куча тренирует контракт. Каждая итерация уменьшает число элементов; всего O(n log n) времени. В Python можно менять знак ключа, в Rust использовать BinaryHeap без Reverse.

        2. LeetCode · 703 · Easy

          Kth Largest Element in a Stream

          Поддерживать только k лучших значений после каждого добавления.

          Ограничения. Начальный массив и число вызовов add — до 10⁴; значения по модулю до 10⁴.

          Идея и оценка сложности

          Min-куча размера не больше k хранит текущие k наибольших. Её корень отвечает на запрос; лишний минимум удаляется. O(log(k+1)) на добавление, O(k) памяти. Повторы учитываются как отдельные элементы.

        3. LeetCode · 506 · Easy

          Relative Ranks

          Связать порядок извлечения с исходными индексами записей.

          Ограничения. До 10⁴ уникальных результатов; значения до 10⁶.

          Идея и оценка сложности

          Храните пары (результат, индекс) в max-куче либо отсортируйте их. Извлекайте по убыванию и записывайте место по исходному индексу. Полный порядок требует O(n log n); первые три места имеют специальные подписи в условии.

        02

        Средние

        Две задачи на сочетание идей и анализ ограничений.

        1. Codeforces · 681C

          Heap Operations

          Восстановить корректную последовательность insert, getMin и removeMin.

          Ограничения. До 10⁵ исходных операций; значения по модулю до 10⁹.

          Идея и оценка сложности

          Перед getMin x удаляйте меньшие значения и при необходимости добавляйте x. Перед удалением из пустой кучи вставьте значение. Докажите, что добавленные действия необходимы. Число удалений ограничено числом вставок, поэтому сумма стоит O(n log n), несмотря на внутренний while.

        2. LeetCode · 973 · Medium

          K Closest Points to Origin

          Поддерживать k минимальных ключей через кучу противоположного направления.

          Ограничения. До 10⁴ точек; координаты по модулю до 10⁴.

          Идея и оценка сложности

          Сравнивайте x²+y² без корня. Max-куча размера k выбрасывает самую далёкую из выбранных точек: O(n log(k+1)), O(k). Используйте достаточно широкий тип для суммы квадратов. Ответ разрешён в любом порядке, сортировать выбранные точки не обязательно.

        03

        Тяжёлая

        Одна задача для переноса идеи на новую постановку.

        1. LeetCode · 23 · Hard

          Merge k Sorted Lists

          Расширить идею минимума до слияния нескольких отсортированных потоков.

          Ограничения. До 10⁴ списков, суммарно до 10⁴ узлов; допускаются пустые списки.

          Идея и оценка сложности

          В куче храните только текущие головы непустых списков; после извлечения добавляйте следующий узел того же списка. O(N log(k+1)) времени, O(k) дополнительных ссылок. Работа со связными узлами — дополнение к лекции; в Python разруливайте равные ключи явным идентификатором, не сравнением объектов-узлов.

        Выберите структуру под всю последовательность операций.

        Задания раздела

        Нужно добавить n событий, затем ровно один раз найти минимум. Что достаточно?

        200 000 вставок и извлечений чередуются, размер может расти до 100 000. Что даёт логарифмическую стоимость каждого изменения?

        Объясните выбор кучи для смешанных запросов.

        Попыток: 0
        Дополнительные материалыДля углублённого разбора · не влияют на прохождение
        ДОП.

        Компромисс: разбиение на блоки

        Необязательное исследование. Блоки дают ещё один компромисс между вставкой и поиском минимума. Его можно пропустить и продолжить с деревом: основная практика не использует блочное разбиение.

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

        Для вставки используем последний неполный блок или создаём новый. Внутри блока нужно найти место и сдвинуть не более b элементов. Для извлечения минимума сравним последние элементы всех примерно n/b блоков, затем удалим минимум из выбранного блока.

        Чтобы блоки не становились всё менее заполненными, освободившееся место можно занять элементом последнего блока, восстановив порядок внутри затронутых блоков за O(b). Последний пустой блок удаляем. Так число блоков остаётся порядка n/b, и получаем следующую оценку.

        insert: O(b), removeMin: O(n/b + b)

        Параметр b регулирует компромисс между локальной работой и числом блоков.

        Маленький b удешевляет вставку, но увеличивает число кандидатов на минимум. Большой b уменьшает число блоков, но делает локальные перемещения дорогими. Уравняем вклады: b ≈ n/b, откуда b ≈ √n. Обе операции получают стоимость O(√n) для выбранного масштаба n.

        Выбор размера блока
        Вставка ~ b
        12
        Число блоков
        12
        b + ⌈n/b⌉
        24
        √n
        12.00
        121212121212121212121212

        Каждая рамка обозначает блок, число внутри — количество элементов. Баланс достигается около b = √n.

        Почему сравнивать n/b и b?
        Минимум суммы b + n/b достигается около √n (при положительном b). Для изменяющегося числа элементов структура потребовала бы пересборки блоков или другой стратегии управления размером.

        Блоки улучшили компромисс, но минимум всё ещё приходится искать среди многих кандидатов. Следующий шаг — связать кандидатов иерархией, в которой путь от элемента к вершине имеет всего O(log n) уровней.

        Как выбрать b, чтобы уравнять вклады b и n/b в блочной структуре?

        Выберите один ответ
        Попыток: 0

        ПРОДОЛЖИТЬ ОБСУЖДЕНИЕ

        Промпт для ИИ

        Скопируйте промпт с текстом лекции в чат с ИИ, чтобы обсудить непонятные места. В него входят дополнительные материалы, псевдокод и примеры на обоих языках.