Хеши, деревья и графы

Хеш-таблицы и хеш-функции, деревья поиска и кучи, графы и их обходы

Вячеслав Федоров

Лекция 18

Разработка и применение ПО

в физических исследованиях

O(1)

Содержание

От поиска за O(n) к O(1) и O(log n)

01

Хеш-таблицы

02

Хеш-функции

03

Деревья

04

Деревья поиска

05

Кучи

06

Графы

07

Пути и остов

Цель лекции

Выбирать структуру данных по операциям, которые она выполняет быстро, и понимать, когда её гарантии нарушаются

Хеш-таблица обеспечивает O(1) лишь в среднем и при равномерной хеш-функции, дерево поиска — O(log n) лишь при сбалансированности. Задачи о связях — соседство пикселей, маршруты, зависимости этапов обработки — сводятся к графам и их обходам.

От O(n)

к O(1) и O(log n)

СтруктураПоискВставкаМинимумПо порядку
listO(n)O(1)*O(n)O(n log n)
list + bisectO(log n)O(n)O(1)O(n)
dict, setO(1)*O(1)*O(n)O(n log n)
дерево поискаO(log n)O(log n)O(log n)O(n)
куча, heapqO(n)O(log n)O(1)O(n log n)

Пояснение

* — в среднем или амортизированно; для дерева поиска — при сбалансированности.

Для физика

Справочник калибровок — dict, отбор k лучших событий — куча, выборка по интервалу энергий — отсортированный массив или дерево.

Хеш-таблицы

Отображение, корзины, коллизии, заполнение и поисковый индекс

01

Хеш-таблицы

Хеш-функции

Деревья

Деревья поиска

Кучи

Графы

Пути и остов

Ассоциативный

массив

Новосибирск ──┐
Москва      ──┼──▶ Россия
Казань      ──┘
Минск       ─────▶ Беларусь
Астана      ─────▶ Казахстан
 
get(key) -> value
set(key, value)

01

Отображение

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

02

Массив

отображение целых индексов в значения

03

Ключ

строка, число, кортеж — любой хешируемый объект

ЯзыкХеш-таблицаДерево поиска
Pythondict, setнет (bisect)
C++unordered_mapmap
JavaHashMapTreeMap

Для физика

Справочник «имя канала → калибровочный коэффициент»: тысячи каналов и обращение при каждом событии.

Наивная

реализация

class NaiveMap:
    def __init__(self):
        self.pairs = []            # пары (key, value)
 
    def get(self, key):
        for k, v in self.pairs:    # перебор: O(n)
            if k == key:
                return v
        return None
 
    def set(self, key, value):
        for i, (k, _) in enumerate(self.pairs):
            if k == key:
                self.pairs[i] = (key, value)
                return
        self.pairs.append((key, value))

01

get

перебор всех пар до совпадения ключа

02

set

тот же перебор, затем замена или append

03

Сложность

O(n) на операцию, O(n²) на заполнение

Пояснение

Хеш-таблица хранит те же пары, но ищет ключ только в одной короткой корзине.

Хеш-таблица

и хеш-функция

ключ

«груша»

хеш

70

номер корзины

70 mod 11 = 4

значение

120 кг

хеш-функция

остаток

массив

ALPHABET = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя'
 
def letters_hash(key):         # сумма номеров букв
    return sum(ALPHABET.index(c) + 1 for c in key)
 
letters_hash('груша') % 11     # 70 % 11 = 4
letters_hash('яблоко') % 11    # 92 % 11 = 4

01

Хеш-функция

ключ → целое число, хеш

02

Номер корзины

хеш → индекс от 0 до M − 1, например h mod M

03

Корзина

индекс → ячейка массива с парами

Разрешение

коллизий

метод цепочек:
корзина 3: []
корзина 4: [('груша', 120), ('яблоко', 300)]
корзина 5: []
 
открытая адресация, пробы h, h + 1, …:
ячейка 4: ('груша', 120)
ячейка 5: ('яблоко', 300)    # следующая свободная

Метод цепочекОткрытая адресация
Корзинасписок пародна пара
Коллизиядобавление в списокпоиск свободной ячейки
Поисккорзина и перебор цепочкипробы до ключа или пустой ячейки
Удалениеиз спискаметка «удалено»
ПримерHashMap в Javadict и set в CPython

Важно

Равномерная раскладка и достаточное число корзин — O(1) в среднем; массовые коллизии — O(n).

Заполнение

и перехеширование

α = n / M          коэффициент заполнения
 
P(корзина пуста) = (1 − 1/M)^n ≈ e^(−α)
позиция ключа в цепочке ≈ 1 + α/2
 
порог CPython: dict — 2/3, set — 3/5
перестройка: O(n), рост таблицы кратный

Пояснение

dict и set в CPython: открытая адресация и таблица размером 2^k.

01

Пустые корзины

доля e^(−α) при случайной раскладке

02

Цепочка

в среднем 1 + α/2 сравнения на успешный поиск

03

Перехеширование

новый массив корзин, все ключи раскладываются заново

04

Амортизация

рост кратный — O(1) на вставку в среднем

Демонстрация:

хеш-таблица

01

Каналы BPM:0042:X, n = 1000…8000

02

Список пар, цепочки при M = 11, перехеширование, dict

03

Случайные 64-битные идентификаторы, M = 1009

04

Перестройки dict и set в CPython

Что наблюдается

Список пар: 5,97 → 50,7 мкс при росте n в 8 раз; с перехешированием 0,44–0,47 мкс, dict — 0,012–0,014. При α = 1 пусты 0,358 корзины против e^(−α) = 0,368.

root@lab — запись со стенда

Поисковый

индекс

index = defaultdict(list)      # слово → номера записей
for num, text in enumerate(log):
    for word in set(text.split()):
        index[word].append(num)
 
index['клистрон']              # O(1) в среднем

Для физика

Электронный журнал смены установки: все записи о клистроне без перебора тысяч строк.

ИндексЧисло парПамять
все подстрокиN(N + 1)/2O(N³)
словаO(N)O(N)

01

Подстроки

N(N + 1)/2 пар, ключи длиной O(N)

02

Слова

пар не больше, чем слов в тексте

03

Применение

поисковые системы, полнотекстовый поиск в БД

Демонстрация:

поисковый индекс

01

Журнал смены: 3000 записей

02

Индекс слов: построение и память

03

«клистрон»: индекс против перебора

04

Индекс подстрок, N = 125…500

Что наблюдается

Индекс слов: 1,6 мс и 0,38 МБ; «клистрон» — 0,018 мкс против 477 мкс перебором. Индекс подстрок: 2,0 → 10,6 → 63,6 МБ, рост в 5,4 и 6,0 раза.

root@lab — запись со стенда

Хеш-функции

Номер корзины, хеши строк, свойства, hash() в Python и криптографические хеши

02

Хеш-таблицы

Хеш-функции

Деревья

Деревья поиска

Кучи

Графы

Пути и остов

Метод деления

и число корзин

k = j·M + r,   r ∈ [0, M)
bucket(k) = k mod M
 
-13 % 5             # 2 в Python
math.fmod(-13, 5)   # -3.0, как в C
 
ключи 64·k + 5, M = 64: одна корзина
занято M / НОД(64, M) корзин

01

Закономерности

шаг ключей и делители M порождают коллизии

02

Простое M

защита при слабой хеш-функции

03

Таблица 2^k

dict, HashMap: биты хеша перемешиваются

Важно

Остаток отрицательного числа зависит от языка: номер корзины может стать отрицательным.

Метод

умножения

bucket(h) = ⌊M · {h·α}⌋
{x} — дробная часть, ⌊x⌋ — целая
 
α = (√5 − 1)/2 ≈ 0,6180339887
 
int(M * ((h * ALPHA) % 1.0))

Пояснение

Кнут предложил α = 1/φ: последовательные ключи рассеиваются по корзинам наиболее равномерно.

01

Умножение

h·α рассеивает соседние ключи

02

Дробная часть

значение в интервале [0, 1)

03

Масштаб

умножение на M и целая часть: от 0 до M − 1

04

Любое M

метод работает и при M = 2^k

Полиномиальный

хеш строк

h(s) = (s[0]·q^(n−1) + … + s[n−1]) mod R
 
def poly_hash(s, q=31, R=2**32):
    h = 0
    for ch in s:                 # схема Горнера
        h = (h * q + ord(ch)) % R
    return h
 
q = 256:  256⁴ mod 2³² = 0

01

R

2³² или 2⁶⁴ — размер целого типа

02

q

нечётное, лучше большое простое

03

Чётное q

старшие степени обнуляются по модулю 2³²

04

q = 256

хеш зависит от четырёх последних символов

Важно

Выбор q важен и для длинных строк: при q = 256 все имена run_0000.dat … run_9999.dat получают один хеш.

Демонстрация:

корзины

01

1000 ключей 64·k + 5: деление при M = 64, 128, 1000

02

Простые M = 61 и 1009, умножение при M = 64 и 1024

03

10 000 имён run_0000.dat … run_9999.dat

04

SHA-256, сумма кодов, q = 256 и 31

Что наблюдается

Деление при M = 64 — одна корзина на 1000 ключей, простое M = 1009 — 1000 корзин, умножение при M = 1024 — 837. При q = 256 один хеш на 10 000 имён и 5000,5 сравнения на поиск, при q = 31 — 5,7.

root@lab — запись со стенда

Свойства

хеш-функции

01

Детерминизм

один и тот же ключ — всегда один и тот же хеш

02

Эффективность

вычисление хеша дешевле перебора ключей

03

Ограниченность

номер корзины в диапазоне от 0 до M − 1

04

Равномерность

значения равновероятны, корзины заполнены поровну

Пояснение

Криптографическим хешам дополнительно необходимы лавинность и необратимость.

Важно

Сумма кодов неравномерна: run_0012.dat и run_0021.dat дают одно число, 990.

Функция

hash() в Python

hash(42)        # 42: int по модулю 2⁶¹ − 1
hash(2**61)     # 1
hash(-1)        # -2: значение -1 занято
hash('груша')   # меняется от запуска к запуску
 
sys.hash_info.algorithm    # 'siphash13'
PYTHONHASHSEED=42 python3 run.py
hash([1, 2])    # TypeError: unhashable type

01

Целые числа

без соли: остаток по модулю 2⁶¹ − 1

02

Строки, bytes

SipHash со случайной солью, PEP 456

03

Ключи

хешируемые объекты; list, dict, set — нет

04

Порядок

dict — порядок вставки, set — не определён

Важно

Алгоритм, перебирающий set строк, недетерминирован: ответ решения 2 из лекции 17 зависит от запуска.

Криптографические

хеши

import hashlib, hmac, os
hashlib.sha256(data).hexdigest()     # 256 бит
hmac.new(key, msg, 'sha256').digest()
 
salt = os.urandom(16)                # хранится с хешем
hashlib.scrypt(pw, salt=salt, n=2**14, r=8, p=1)
hashlib.pbkdf2_hmac('sha256', pw, salt, 600_000)

Важно

Быструю хеш-функцию пароля перебирают со скоростью миллиардов вариантов в секунду.

ЗадачаСредство
Целостность файлаSHA-256, контрольная сумма
Подлинность сообщенияHMAC, электронная подпись
Хранение паролейсоль и медленная функция: scrypt, Argon2, PBKDF2
Шифрованиене хеш: AES, обратимо

Демонстрация:

свойства хешей

01

hash('груша') в новых процессах, PYTHONHASHSEED=42

02

Порядок обхода set и dict в пяти процессах

03

Решение 2 задачи о частом символе при соли 0…7

04

SHA-256: лавинность, контрольная сумма, пароли

Что наблюдается

Соль меняет hash() строк и порядок set от запуска к запуску; решение 2 на 'abab' даёт четыре ответа a и четыре b. SHA-256 соседних имён расходится в 140 битах из 256; PBKDF2 дольше SHA-256 в 219 128 раз.

root@lab — запись со стенда

Деревья

Определения, двоичные деревья, обходы в глубину и в ширину, деревья в физике

03

Хеш-таблицы

Хеш-функции

Деревья

Деревья поиска

Кучи

Графы

Пути и остов

Дерево:

определения

A

B

C

D

E

F

G

корень

поддерево B

глубина 0

глубина 1

глубина 2

глубина 3

01

Корень

единственный узел без родителя

02

Родитель

у каждого из остальных узлов ровно один

03

Лист

узел без потомков: D, F, G

04

Глубина, высота

рёбра от корня; высота — наибольшая глубина, здесь 3

Для физика

Каталоги файловой системы, группы HDF5, объёмы Geant4, дерево вызовов профилировщика.

Двоичное

дерево и Node

class Node:
    def __init__(self, value, left=None, right=None):
        self.value = value      # данные узла
        self.left = left        # левый потомок или None
        self.right = right      # правый потомок или None
 
root = Node(5, Node(3, Node(1), Node(4)), Node(8))

01

Дерево

одна ссылка на корень

02

Пустое дерево

None — базовый случай рекурсии

5

3

8

1

4

Пояснение

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

Обходы

в глубину и в ширину

def preorder(root):          # корень, левое, правое
    if root is None:
        return
    print(root.value)        # in-order: между вызовами
    preorder(root.left)      # post-order: после них
    preorder(root.right)
 
def level_order(root):       # по уровням: очередь
    queue = deque([root] if root else [])
    while queue:
        node = queue.popleft()
        print(node.value)
        if node.left:
            queue.append(node.left)
        if node.right:
            queue.append(node.right)

ОбходПорядокПрименение
прямой5 3 1 4 8копирование, сериализация
центрированный1 3 4 5 8ключи по возрастанию
обратный1 4 3 8 5вычисления снизу вверх
в ширину5 3 8 1 4узлы по уровням

Пояснение

Время O(n); память O(h) на стек рекурсии у DFS и до O(n) на очередь у BFS.

Демонстрация:

обходы

01

Иерархия объёмов детектора: прямой обход

02

Массы поддеревьев: обратный обход

03

Объёмы по уровням: обход в ширину

04

Дерево главы и E = √(p² + m²)

Что наблюдается

Обратный обход суммирует массы снизу вверх: калориметр 3440 кг, детектор 3884 кг, мир 7884 кг. Дерево главы: 5 3 1 4 8, 1 3 4 5 8, 1 4 3 8 5, 5 3 8 1 4; выражение — p p * m m * + sqrt, E = 5,0 ГэВ.

root@lab — запись со стенда

Деревья

в физических задачах

from scipy.spatial import KDTree
rng = np.random.default_rng(0)
points = rng.random((100_000, 3))   # частицы в кубе
tree = KDTree(points)               # O(N log N)
 
dist, idx = tree.query(points[0], k=10)
near = tree.query_ball_point(points[0], r=0.05)
pairs = tree.query_pairs(r=0.01)    # близкие пары

Пояснение

Барнс — Хат: ячейка с угловым размером s/d < θ заменяется суммарной массой в центре масс.

01

k-d дерево

поиск соседей в среднем O(log N) вместо O(N)

02

Октодерево

куб делится на 8 октантов, в узле — масса и центр масс

03

Задача N тел

O(N log N) на шаг вместо O(N²), коды наподобие GADGET

04

Иерархии

HDF5, геометрия Geant4, выражения SymPy

Демонстрация:

k-d дерево

01

N частиц в единичном кубе: 5000, 10 000, 20 000

02

Все пары ближе r = 0,01: перебор на NumPy

03

Те же пары: KDTree.query_pairs

04

Сверка с ожиданием N²/2 · (4/3)πr³

Что наблюдается

Перебор растёт в 4,0 раза при удвоении N, до 731,5 мс; дерево — в 1,5 и 2,4 раза, до 6,0 мс. При N = 20 000 найдено 898 пар при ожидании 838 ± 29, ответы совпадают.

root@lab — запись со стенда

Деревья поиска

Свойство порядка, поиск и вставка за O(h), сбалансированность, число деревьев поиска

04

Хеш-таблицы

Хеш-функции

Деревья

Деревья поиска

Кучи

Графы

Пути и остов

Двоичное

дерево поиска

def find(root, key):              # спуск: O(h)
    while root is not None and root.value != key:
        root = root.left if key < root.value else root.right
    return root
 
def insert(root, key):            # рекурсия главы
    if root is None:
        return Node(key)
    if key < root.value:
        root.left = insert(root.left, key)
    elif key > root.value:
        root.right = insert(root.right, key)
    return root

01

Свойство порядка

слева все ключи меньше узла, справа — больше

02

Поиск, вставка

один путь от корня: O(h)

03

In-order

ключи по возрастанию за O(n)

Пояснение

Отсортированный массив медленно вставляет, хеш-таблица не хранит порядка; дерево поиска обеспечивает и то и другое.

Удаление

и проверка

01

Нет потомков

узел убирается, ссылка родителя — None

02

Один потомок

потомок встаёт на место узла

03

Два потомка

место узла занимает преемник — минимум правого поддерева

parent, succ = root, root.right   # преемник
while succ.left is not None:
    parent, succ = succ, succ.left

5

3

8

1

4

5

3

8

1

6

дерево поиска

не дерево поиска: 6 > 5

def is_bst(root, low=-inf, high=inf):
    if root is None:
        return True
    if not low < root.value < high:
        return False
    return (is_bst(root.left, low, root.value)
            and is_bst(root.right, root.value, high))

Высота

и сбалансированность

ДеревоВысота hO(log n)
случайный порядок≈ 2–3·log₂ nв среднем
упорядоченные ключиn − 1, цепочканет: O(n)
АВЛ-дерево< 1,44·log₂(n + 2)всегда
красно-чёрное≤ 2·log₂(n + 1)всегда

средняя глубина узла ≈ 2 ln n ≈ 1,39·log₂ n
высота ≈ 4,311 ln n − 1,953 ln ln n

Пояснение

В стандартной библиотеке Python дерева поиска нет: обычно достаточно dict, set и bisect.

01

Цена операций

O(h): всё определяет высота

02

Случайный порядок

h = O(log n), но с множителем больше единицы

03

Упорядоченные ключи

каждый узел — правый потомок: цепочка

04

Сбалансированное

h = O(log n) при любых вставках, повороты

Важно

Рекурсивная insert главы на ключах 1, 2, 3, … исчерпывает предел рекурсии уже на тысяче ключей.

Демонстрация:

высота дерева

01

Случайный порядок, n = 10³…10⁵

02

Ключи по порядку: n = 1000 и 2000

03

Рекурсивная insert главы на ключах 1, 2, 3, …

04

Поиск: дерево, цепочка, bisect, dict

Что наблюдается

Высота 19, 29, 40 — от 1,91 до 2,41·log₂ n; упорядоченные ключи — 999 и 1999. Рекурсивная insert — RecursionError на ключе 999. Поиск: дерево 0,337 мкс, цепочка 14,0 мкс, bisect 0,090 мкс, dict 0,017 мкс.

root@lab — запись со стенда

Результат:

высота дерева

Слева высота дерева поиска при упорядоченных и случайных ключах в логарифмических осях; справа случайный порядок: высота пяти деревьев, средняя глубина узла, формула и log₂ n

Что наблюдается

Упорядоченные ключи дают прямую n − 1, случайный порядок — логарифмический рост. Высота растёт втрое круче log₂ n, средняя глубина лежит на формуле 2(1 + 1/n)·H_n − 4.

Сколько

деревьев поиска

C(n) = Σ C(k − 1)·C(n − k),  k = 1…n,  C(0) = 1
C(n) = C(2n, n) / (n + 1)      # числа Каталана
 
def count_bst(n):
    return comb(2 * n, n) // (n + 1)
 
count_bst(20)                  # 6564120420

Важно

Вычисления — в целых числах: float теряет точность выше 2⁵³.

01

Корень k

слева k − 1 ключей, справа n − k

02

Динамика

по рекуррентности: O(n²) операций, O(n) памяти

03

Формула

биномиальный коэффициент: O(n) умножений

04

Каталан

скобочные последовательности, пути Дика

Демонстрация:

числа Каталана

01

Формула и динамика при n = 0…10

02

Перебор всех деревьев при n = 1…8

03

C₂₀ и факториалы во float

04

Деление целых '/' и граница 2⁵³

Что наблюдается

Три способа дают 1, 1, 2, 5, 14, 42, …; C₂₀ = 6 564 120 420. Факториалы во float ошибаются уже при n = 20: 6564120419.999999. Деление '/' точно до n = 30: C₃₀ < 2⁵³ < C₃₁.

root@lab — запись со стенда

Кучи

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

05

Хеш-таблицы

Хеш-функции

Деревья

Деревья поиска

Кучи

Графы

Пути и остов

Куча

в массиве

9

7

8

3

1

4

[0]

[1]

[2]

[3]

[4]

[5]

9

7

8

3

1

4

0

1

2

3

4

5

01

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

узел не меньше потомков: максимум в корне

02

Почти полное дерево

уровни заполнены слева направо, h = ⌊log₂ n⌋

03

Индексы

потомки 2i + 1 и 2i + 2, родитель ⌊(i − 1)/2⌋

04

Просеивание

вверх и вниз по одному пути: O(log n)

Пояснение

Между левым и правым поддеревьями порядка нет: куча — не дерево поиска.

Приоритетная

очередь и heapq

import heapq
h = [5.2, 1.1, 3.7, 0.4]
heapq.heapify(h)              # O(n)
heapq.heappush(h, 2.6)        # O(log n)
heapq.heappop(h)              # 0.4 — минимум
h[0]                          # минимум без извлечения
 
heapq.heappush(mx, -x)        # max-heap: знак минус
heapq.heappush_max(mx, x)     # Python 3.14 и новее
heapq.nlargest(10, energies)  # k лучших

ОперацияЦена
добавлениеO(log n)
просмотр минимумаO(1)
извлечение минимумаO(log n)
построение, heapifyO(n)
отбор k лучших из nO(n log k)

Для физика

Десять самых энергичных событий из миллиона — без сортировки всего набора.

Пирамидальная

сортировка

def heap_sort(items):
    n = len(items)
    for start in range((n - 2) // 2, -1, -1):
        sift_down(items, start, n - 1)   # max-heap
    for end in range(n - 1, 0, -1):
        items[0], items[end] = items[end], items[0]
        sift_down(items, 0, end - 1)     # n − 1 раз
 
def sort_key(user):                      # задача 6
    login, solved, penalty = user
    return (-solved, penalty, login)

01

Этап 1

просеивание внутренних узлов: max-heap за O(n)

02

Этап 2

n − 1 обменов корня с концом и просеиваний

03

Свойства

O(n log n) всегда, O(1) памяти, неустойчива

04

Задача 6

ключ (−решено, штраф, логин)

Пояснение

На практике медленнее быстрой сортировки: просеивание обращается к далёким ячейкам массива.

Демонстрация:

куча

01

10 самых энергичных событий из 1 000 000

02

heapq.nlargest, куча из 10 элементов и sorted

03

Сравнения: 100 000 вызовов heappush и heapify

04

Пирамидальная сортировка главы и задача 6

Что наблюдается

nlargest — 7,1 мс и пик памяти 0,00 МБ, sorted — 119,6 мс и 12 МБ. Построение кучи: heappush — 14,69 сравнения на элемент, heapify — 1,50. Heap sort главы — 124 мс против 9,4 мс у sorted().

root@lab — запись со стенда

Графы

Определения, хранение, обходы в глубину и в ширину, компоненты связности, графы в физике

06

Хеш-таблицы

Хеш-функции

Деревья

Деревья поиска

Кучи

Графы

Пути и остов

Граф:

определения

1

2

3

4

5

6

Шесть вершин и семь рёбер: граф демонстрации порядка обходов

01

G = (V, E)

вершины и рёбра; n = |V|, m = |E|

02

Виды

неориентированный, ориентированный, взвешенный

03

Путь, цикл

цепочка смежных вершин; замкнутая — цикл

04

Связность

компонента — максимальная связная часть

Пояснение

Дерево — связный граф без циклов, у него n − 1 рёбер; граф без циклов называется лесом.

Хранение

графа

matrix = [[0] * n for _ in range(n)]   # не [[0] * n] * n
matrix[u][v] = matrix[v][u] = 1
 
graph = defaultdict(list)              # списки смежности
graph[u].append(v)
graph[v].append(u)
 
edges = [(w, u, v), ...]               # список рёбер

ОперацияМатрицаСписки
памятьO(n²)O(n + m)
есть ли ребро (u, v)O(1)O(deg u)
соседи вершины uO(n)O(deg u)
добавить реброO(1)O(1)

Для физика

Реальные графы разрежены: у атома в молекуле единицы связей, у пикселя не больше восьми соседей.

Обходы графа:

DFS и BFS

def dfs(graph, start):              # явный стек
    visited, stack = set(), [start]
    while stack:
        v = stack.pop()
        if v in visited:            # после снятия
            continue
        visited.add(v)              # обработка v
        stack.extend(reversed(graph[v]))
 
def bfs(graph, start):              # очередь
    visited, queue = {start}, deque([start])
    while queue:
        v = queue.popleft()         # обработка v
        for u in graph[v]:
            if u not in visited:
                visited.add(u)      # при постановке
                queue.append(u)

01

Посещённые

множество обязательно: в графе есть циклы

02

DFS

стек; вершина может попасть в стек повторно

03

BFS

очередь; вершины по удалению от старта

04

Сложность

O(n + m): вершины и рёбра — по одному разу

Пояснение

Компоненты связности — обход из каждой ещё не посещённой вершины.

Демонстрация:

кластеры

01

Матрица 64 × 64: следы частиц и шумовые пиксели

02

Граф соседства: матрица против списков

03

Кластеры — компоненты связности, BFS

04

Порядок DFS и BFS на графе из шести вершин

Что наблюдается

179 пикселей и 145 пар соседей: 32 041 ячейка матрицы против 290 записей в списках. 44 кластера, крупнейший — 24 пикселя. DFS: 1 2 4 3 6 5, BFS: 1 2 3 4 6 5.

root@lab — запись со стенда

Графы

в физических задачах

01

Электрические цепи

законы Кирхгофа: m − n + 1 независимых контуров

02

Модель Изинга

кластеры Свендсена — Ванга и Вольфа

03

Перколяция

компонента, соединяющая края решётки

04

Молекулы

кольца — циклы, графовые нейронные сети

05

Марковские цепи

переходы с вероятностями — взвешенный орграф

06

Трассировка

кабели, маршруты, план эксперимента

import networkx as nx
lattice = nx.grid_2d_graph(4, 4)          # решётка 4 × 4
nx.number_connected_components(lattice)
nx.shortest_path(lattice, (0, 0), (3, 3))
nx.minimum_spanning_tree(lattice)

Пояснение

Библиотека не подскажет, что задача сводится к компонентам связности: это решает постановка.

Демонстрация:

перколяция

01

Решётка L × L, узел занят с вероятностью p

02

BFS сразу из всех занятых узлов верхней строки

03

Доля протекающих решёток из 200 испытаний

04

L = 16, 32, 64; p от 0,50 до 0,70

Что наблюдается

При p = 0,50 решётка 64 × 64 не протекает ни разу, при 0,65 — в 99 % испытаний; у L = 16 переход растянут от 0,05 до 0,94. Порядок кривых меняется между 0,58 и 0,60 — у порога p_c ≈ 0,5927.

root@lab — запись со стенда

Результат:

перколяция

Доля протекающих решёток L × L от вероятности занятости узла p для L = 16, 32 и 64, по 200 испытаний на точку; пунктир — порог p_c ≈ 0,5927

Что наблюдается

Чем больше решётка, тем круче переход; кривые трёх размеров пересекаются вблизи p_c ≈ 0,5927, а статистический разброс точек при 200 испытаниях составляет несколько процентов.

Пути и остов

Кратчайшие пути, топологическая сортировка, минимальное остовное дерево

07

Хеш-таблицы

Хеш-функции

Деревья

Деревья поиска

Кучи

Графы

Пути и остов

Кратчайшие

пути

def dijkstra(graph, start):     # веса w ≥ 0
    dist = {start: 0}
    heap = [(0, start)]
    while heap:
        d, v = heapq.heappop(heap)
        if d > dist[v]:
            continue            # устаревшая запись
        for u, w in graph[v]:
            if d + w < dist.get(u, inf):
                dist[u] = d + w     # релаксация
                heapq.heappush(heap, (d + w, u))
    return dist

01

BFS

кратчайший путь по числу рёбер, предки — для восстановления

02

Дейкстра

жадно: вершина с наименьшей оценкой окончательна

03

Релаксация

d(v) + w < d(u): оценка соседа улучшается

04

Куча

O(m log n); устаревшие записи пропускаются

Важно

Веса неотрицательны; при отрицательных — алгоритм Беллмана — Форда.

Демонстрация:

Дейкстра

01

12 помещений комплекса, 16 проходов с длиной в метрах

02

BFS: путь с наименьшим числом переходов

03

Дейкстра с heapq: путь наименьшей длины

04

Извлечения из кучи и устаревшие записи

Что наблюдается

BFS: 2 перехода и 320 м через зал А. Дейкстра: 180 м за 4 перехода через галерею, лестницу и туннель 1. Из 14 извлечений из кучи 2 оказались устаревшими.

root@lab — запись со стенда

Топологическая

сортировка

def kahn(graph):                # {u: [v, ...]}
    indeg = {v: 0 for v in graph}
    for u in graph:
        for v in graph[u]:
            indeg[v] += 1
    queue = deque(v for v in graph if indeg[v] == 0)
    order = []
    while queue:
        u = queue.popleft()
        order.append(u)
        for v in graph[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                queue.append(v)
    if len(order) < len(graph):
        raise ValueError('цикл в зависимостях')
    return order

01

DAG

ребро u → v: этап u выполняется раньше v

02

DFS

вершина — после потомков, затем разворот; цикл — три цвета

03

Кан

вершины без входящих рёбер; цикл — оставшиеся вершины

ts = graphlib.TopologicalSorter(deps)
list(ts.static_order())    # CycleError при цикле

Для физика

Этапы обработки данных эксперимента: калибровка, выравнивание, треки, события.

Демонстрация:

порядок этапов

01

Десять этапов обработки, двенадцать зависимостей

02

DFS главы, проверка рёбер и graphlib

03

Ошибочное ребро треки → выравнивание

04

Два DFS, алгоритм Кана и graphlib на цикле

Что наблюдается

Без цикла все три способа корректны. С циклом DFS без проверки нарушает ребро выравнивание → треки без ошибки; DFS главы с тремя цветами, алгоритм Кана (5 этапов из 10) и graphlib цикл обнаруживают.

root@lab — запись со стенда

Минимальное

остовное дерево

def kruskal(n, edges):           # edges: [(w, u, v)]
    parent = list(range(n))
    def find(x):                 # корень множества
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x
    total = 0
    for w, u, v in sorted(edges):    # по весу
        ru, rv = find(u), find(v)
        if ru != rv:             # не замыкает цикл
            parent[ru] = rv
            total += w
    return total

01

Остов

n − 1 рёбер, все вершины, без циклов

02

Прим

дерево растёт из вершины, куча: O(m log n)

03

Краскал

рёбра по весу, DSU против циклов: O(m log m)

04

Максимум

вес со знаком минус — максимальное дерево

Для физика

В цепи m − n + 1 независимых контуров — столько ветвей остаётся вне остова.

Демонстрация:

остовное дерево

01

10 крейтов электроники, 45 возможных кабелей

02

Алгоритмы Прима и Краскала

03

Звезда от крейта 0 и максимальное дерево

04

Сетка резисторов 3 × 3: остов и контуры

Что наблюдается

Прим и Краскал: 36,59 м и один и тот же набор из 9 рёбер; звезда — 85,57 м, в 2,34 раза длиннее. Сетка 3 × 3: 12 ветвей, в остове 8, m − n + 1 = 4 контура.

root@lab — запись со стенда

Итоги

лекции

01

Хеш-таблица — O(1) в среднем при равномерной хеш-функции и ограниченном α.

02

Закономерности ключей и чётное основание q нарушают равномерность.

03

Соль hash() меняет порядок обхода set строк от запуска к запуску.

04

Дерево поиска — O(h), а высота h зависит от порядка вставки.

05

Куча — минимум за O(1), отбор k лучших за O(n log k).

06

Граф хранится списками смежности; обходы требуют O(n + m).

07

Пути, топологический порядок и остов — обходы с кучей или DSU.

Упражнения

и задание

01

Открытая адресация

длина пробирования при α = 0,5…0,9 против цепочек

02

АВЛ-дерево

повороты и высота на упорядоченных ключах

03

Кластеры через DSU

объединение множеств вместо BFS, сверка с clusters.py

04

Беллман — Форд

и граф, на котором Дейкстра с множеством обработанных вершин ошибается

Пояснение

Задачи глав «Деревья» и «Графы» с автоматической проверкой собраны в тренажёрах Яндекс Контеста.

Для физика

Собственная реализация проверяется сравнением с эталоном: dict, sorted, graphlib, scipy.spatial.

Источники

1

Книга курса: главы «Хеш-функции», «Деревья» и «Графы» части «Алгоритмы и структуры данных», тренажёры задач глав

phys-dev.github.io/soft-dev-book

2

Кормен Т., Лейзерсон Ч., Ривест Р., Штайн К. Алгоритмы: построение и анализ. – 3-е изд. – Вильямс, 2013; Седжвик Р., Уэйн К., Дондеро Р. Программирование на языке Python: учебный курс. – Вильямс, 2017

3

Кнут Д. Искусство программирования. Т. 1: Основные алгоритмы. – 3-е изд. – Вильямс, 2018; Т. 3: Сортировка и поиск. – 2-е изд. – Вильямс

4

Virtanen P. et al. SciPy 1.0 // Nature Methods. – 2020. – Vol. 17; Newman M. E. J., Ziff R. M. // Phys. Rev. Lett. – 2000. – Vol. 85; Devroye L. // J. ACM. – 1986. – Vol. 33

5

PEP 456: Secure and interchangeable hash algorithm; OWASP Password Storage Cheat Sheet

peps.python.org/pep-0456

6

Документация Python: hashlib, heapq, bisect, graphlib

docs.python.org/3/library

Спасибо за внимание

Вопросы

phys-dev.github.io/soft-dev-book

18