Learning Platform
Глоссарий Troubleshooting
Урок 12.02 · 25 мин
Начальный
array layoutheap memorycache localityindex formulas

Главное преимущество heap: array без указателей

Heap — это бинарное дерево, но хранится в обычном массиве. Никаких указателей left/right. Никаких узлов, размещённых случайно в heap’е процесса. Только массив value’ов.

индекс: 0  1  2  3  4  5  6
массив:[2, 5, 3, 8, 6, 4, 9]

соответствующее дерево:

         2 (idx 0)
        / \
       5   3      (idx 1, 2)
      / \  /
     8  6 4       (idx 3, 4, 5)
     /
    9             (idx 6 — продолжение последнего уровня)

Это работает благодаря тому, что heap всегда complete binary tree — заполнен слева направо без дырок. Никаких пустых позиций, индексы плотные.

Формулы навигации

Зная индекс узла в массиве, вычисляем индексы parent, left и right детей:

def parent(i):
    return (i - 1) // 2

def left_child(i):
    return 2 * i + 1

def right_child(i):
    return 2 * i + 2

Эти формулы работают для любого индекса в массиве. Только корень (i=0) не имеет parent (формула дала бы -0, нужно отдельно проверять).

Доказательство, почему именно эти формулы — через индукцию по уровням:

  • Уровень 0: индексы 0..0, 1 узел.
  • Уровень 1: индексы 1..2, 2 узла.
  • Уровень 2: индексы 3..6, 4 узла.
  • Уровень k: индексы 2^k - 1 .. 2^(k+1) - 2, 2^k узлов.

Дети узла i ∈ уровень k лежат на уровне k+1, в позициях, которые легко вычислить через арифметику геометрической прогрессии. Результат — простые формулы 2i+1 и 2i+2.

Heap в массиве — точное соответствие индексов и узлов

Каждый индекс однозначно соответствует узлу дерева, и формулы дают мгновенную навигацию.

массив[2, 5, 3, 8, 6, 4, 9]
уровень 0idx 0root
уровень 1idx 1, 2дети root: parent(1)=0, parent(2)=0
уровень 2idx 3, 4, 5, 6дети уровня 1
формулы навигацииparent(i) = (i-1)//2; left(i) = 2i+1; right(i) = 2i+2

Доступ к узлу — sequential

Главное практическое преимущество array layout — cache locality:

сравнение:

linked tree:
   адрес узла A:   0x7f8a3000
   адрес node.left: 0x7f8a4500   <- случайно где-то в heap'е процесса
   адрес node.right: 0x7f8a1800   <- ещё дальше
   каждый переход — потенциальный cache miss

array layout:
   адрес node[5]: arr_start + 5*8 = arr_start + 40
   адрес node[6]: arr_start + 6*8 = arr_start + 48  <- соседний байт!
   доступ к соседнему индексу — это L1 cache hit, ~1ns

Sift up/down (см. следующий урок) делает 1-log(n) переходов по массиву с регулярным паттерном. CPU prefetcher предсказывает эти переходы и заранее тащит данные в L1/L2 кеш. Это даёт огромное практическое ускорение.

Эксперимент: heap vs linked tree

Сравним скорость heappush для array-based heapq и условного linked tree:

import heapq
import time

class LinkedNode:
    __slots__ = ('value', 'left', 'right')
    def __init__(self, v):
        self.value = v
        self.left = None
        self.right = None

# Простой linked min-heap (упрощённая имплементация)
class LinkedHeap:
    def __init__(self):
        self.root = None
        self.size = 0
        self.last = None
    
    # упрощённый push (без правильного complete-инварианта)
    # просто для демонстрации стоимости alloc'ов
    def push(self, value):
        new_node = LinkedNode(value)
        if self.root is None:
            self.root = new_node
        else:
            # упрощённо: вставка в конец
            self._insert(self.root, new_node)
        self.size += 1
    
    def _insert(self, root, node):
        # BFS до первого free слота — это упрощение
        from collections import deque
        q = deque([root])
        while q:
            cur = q.popleft()
            if cur.left is None:
                cur.left = node
                return
            if cur.right is None:
                cur.right = node
                return
            q.append(cur.left)
            q.append(cur.right)

# array-based
n = 100_000
h = []
start = time.perf_counter()
for i in range(n):
    heapq.heappush(h, i)
t_heapq = time.perf_counter() - start

print(f"heapq (array): {t_heapq*1000:.0f} ms")
# ~30 ms

# linked: на 100K elements это уже больно
n_small = 5_000
lh = LinkedHeap()
start = time.perf_counter()
for i in range(n_small):
    lh.push(i)
t_linked = time.perf_counter() - start

print(f"linked tree (just allocs + BFS): {t_linked*1000:.0f} ms for {n_small}")
# ~500-1000 ms — катастрофически медленнее

Разница на одинаковых данных: heapq на 100K элементах ~30ms, linked tree на 5K элементах ~500ms. В 30x больше работы на 1/20 данных — это и есть цена потери cache locality + alloc’ов.

Память: array vs linked

Память тоже отличается в разы:

heap из 1M int'ов:

array-based (Python list of ints):
- list overhead: ~56 байт
- каждый int в Python: ~28 байт, но CPython caches small ints
- для 1M unique ints: ~28 MB
- если int'ы маленькие (<256), почти бесплатно — shared singleton'ы

linked tree:
- 1M узлов
- каждый узел: ~56 байт (header) + value + 2 указателя = ~88 байт
- итого: ~88 MB

Array экономит ~3× на памяти. Плюс не фрагментирует heap процесса — все ячейки массива лежат подряд, а не разбросаны.

Когда array layout не работает

Array layout идеален для complete binary trees, где нет «дырок». Если в дереве могут быть пустые позиции, массив будет содержать None’ы — трата памяти.

Для skewed дерева (вырожденного, как linked list) array layout катастрофичен: на N узлов потребуется массив размера 2^N - 1 (пустые позиции для гипотетических соседей).

К счастью, heap всегда complete — это его инвариант. Поэтому array layout — оптимальный выбор именно для heap.

Сегментные деревья тоже используют array layout: они «полные» по построению, и для интервальных queries это самая эффективная структура.

BST обычно не complete (зависит от вставок), поэтому хранится linked. Исключения — сбалансированные деревья поверх массива, но это редкость.

Когда array layout оптимален

Только для complete trees. BST лучше linked.

array layout — хорошо
heapвсегда complete
segment treeвсегда полное
статическое perfect treeML decision trees
плюсыcache, память, скорость
array layout — плохо
BSTформа зависит от вставокдырки тратят память
skewed tree2^N - 1 ячеек на N узловэкспоненциальная трата
произвольное деревоNone'ы для каждого пропуска
альтернативаlinked nodes

Размер heap: dynamic array

Python list умеет автоматически расти при достижении capacity (см. модуль 5). Heap наследует это: heappush в полный heap триггерит resize массива. Стоимость amortized O(1) per push.

Если вы знаете заранее, сколько элементов будет в heap’е, можно заранее аллоцировать массив:

# не оптимизированно — Python list растёт автоматически
h = []
for i in range(1_000_000):
    heapq.heappush(h, i)

# pre-allocated
h = [0] * 1_000_000   # пустой массив нужного размера
# но в heap'е нет direct API для pre-size; обычно используют heapify

Лучший трюк — построить heap сразу из существующего массива через heapify:

data = list(range(1_000_000, 0, -1))   # обратный порядок
heapq.heapify(data)                     # сделать heap'ом за O(n)
# теперь data — валидная min-heap

heapify работает за O(n), а не O(n log n) — это удивительный факт, который разберём в уроке 04.

Heap в HashMap’ах через handle

В сложных алгоритмах (Dijkstra с decrease-key) нужно обновлять приоритет элемента в heap’е. Чистый heap не поддерживает «найти элемент» за O(log n) — нужно знать его индекс.

Решение — handle-based heap: хранится дополнительный dict {element -> index_in_heap}, обновляемый при каждом sift up/down. Это даёт обновление приоритета за O(log n).

class IndexedHeap:
    def __init__(self):
        self.heap = []                    # массив (priority, item)
        self.index = {}                   # item -> index в массиве

    def push(self, priority, item):
        self.heap.append((priority, item))
        self.index[item] = len(self.heap) - 1
        self._sift_up(len(self.heap) - 1)

    def _swap(self, i, j):
        self.heap[i], self.heap[j] = self.heap[j], self.heap[i]
        self.index[self.heap[i][1]] = i
        self.index[self.heap[j][1]] = j
    
    # _sift_up и остальное аналогично базовому heap'у, но с update индексов

Это не входит в стандартный heapq, нужно писать свой или брать стороннюю либу. Но идея простая: array layout + дополнительный hash для поиска индекса.

Применение: heapq.merge для k-way merge

Python heapq.merge использует heap для слияния нескольких отсортированных итераторов в один отсортированный поток:

import heapq

streams = [
    [1, 5, 9, 13],
    [2, 6, 10, 14],
    [3, 7, 11, 15],
    [4, 8, 12, 16],
]

merged = list(heapq.merge(*streams))
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]

Внутри: heap из K «текущих голов» потоков. На каждый шаг extract-min (минимум из голов), advance тот поток, у которого извлекли. Сложность O(N log K), память O(K). Это основа external sort: сортировка файла, не помещающегося в RAM.

Попробуй сам

import heapq

# 1. Реализуйте формулы навигации
def parent(i): return (i - 1) // 2
def left(i): return 2 * i + 1
def right(i): return 2 * i + 2

# проверьте на конкретном heap'е
h = [1, 3, 5, 7, 9, 11, 13]   # валидный min-heap
print(f"size: {len(h)}")
for i in range(len(h)):
    p = parent(i) if i > 0 else None
    l = left(i) if left(i) < len(h) else None
    r = right(i) if right(i) < len(h) else None
    p_val = h[p] if p is not None else 'N/A'
    l_val = h[l] if l is not None else 'N/A'
    r_val = h[r] if r is not None else 'N/A'
    print(f"i={i} val={h[i]}: parent={p_val}, left={l_val}, right={r_val}")

# 2. Проверка heap-инварианта вручную
def is_min_heap(arr):
    for i in range(len(arr)):
        l = 2 * i + 1
        r = 2 * i + 2
        if l < len(arr) and arr[i] > arr[l]:
            return False
        if r < len(arr) and arr[i] > arr[r]:
            return False
    return True

print(is_min_heap([1, 3, 5, 7, 9]))    # True
print(is_min_heap([5, 1, 3, 7, 9]))    # False — 5 > 1

# 3. Heapify из произвольного массива
import random
data = list(range(100))
random.shuffle(data)
print(f"Before: {data[:10]}")
heapq.heapify(data)
print(f"After:  {data[:10]}")
print(f"Is valid heap: {is_min_heap(data)}")

# 4. K-way merge
streams = [sorted(random.sample(range(100), 10)) for _ in range(4)]
print(f"4 sorted streams, each 10 elements")
merged = list(heapq.merge(*streams))
print(f"Merged: {len(merged)} total elements")
print(f"Is sorted: {merged == sorted(merged)}")

# 5. Сравнение памяти array vs linked
import sys

# array heap
arr_heap = list(range(1000))
print(f"\nArray heap (1K ints): {sys.getsizeof(arr_heap)} bytes")
# linked tree: 1000 nodes, каждый ~56 байт
print(f"Linked equivalent: ~{1000 * 56} bytes (estimated)")
TIP

Array layout heap — это идеальное использование hardware: cache-friendly sequential access, нет alloc’ов на узлы, нет указателей. Это причина, по которой heapq в Python (C-имплементация) работает в 30-100 раз быстрее любой Python-имплементации linked heap. Когда вам нужен priority queue — используйте heapq, не пишите свой через классы.

В следующем уроке разберём операции push/pop в деталях: что такое sift up и sift down, почему они работают за O(log n), и как именно поддерживают heap-инвариант.

Проверка знанийKnowledge check
Heap всегда хранится в массиве через формулы parent(i)=(i-1)//2, left(i)=2i+1, right(i)=2i+2. Почему этот подход НЕ работает для произвольного бинарного дерева (например, BST)?
ОтветAnswer
Array layout требует, чтобы дерево было COMPLETE — все уровни кроме последнего полностью заполнены, последний — слева направо без дырок. Тогда индексы плотно соответствуют узлам по уровням. Heap всегда complete — это его инвариант. BST же может быть произвольной формы: skewed (как linked list), или с дырками между узлами. Если попытаться хранить такой BST в массиве, пришлось бы оставлять None в "пустых" позициях. Для skewed BST высотой h это потребовало бы массив 2^(h+1) - 1 для всего h+1 узлов — экспоненциальное расходование. Например, на 30 узлов в линейной цепочке нужен массив на 2^30 ~ миллиард ячеек. Это делает array layout непрактичным для BST. Решение для BST — linked layout: каждый узел отдельный объект с указателями left/right, размещается там, где есть свободное место в heap'е процесса. Цена: больше памяти на указатели и worse cache locality, но универсальная гибкость. Поэтому heap -> array, BST -> linked.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 6. В массиве heap [1, 3, 5, 7, 9, 11], каков индекс родителя узла с индексом 4 и индексы его детей?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5