Хеши, деревья и графы
Хеш-таблицы и хеш-функции, деревья поиска и кучи, графы и их обходы
Вячеслав Федоров
Лекция 18
Разработка и применение ПО
в физических исследованиях
Изложение. Восемнадцатая лекция завершает часть курса, посвящённую алгоритмам и структурам данных. Лекция 17 показала, что в массиве и связном списке поиск элемента по значению требует перебора и обходится в O(n). Структуры этой лекции снимают это ограничение: хеш-таблица находит значение по ключу в среднем за O(1), дерево поиска — за O(log n) с сохранением порядка ключей, куча выдаёт минимум за O(1). Вторая половина лекции посвящена графам — самой общей структуре, описывающей связи между объектами: соседство пикселей детектора, проходы между помещениями, зависимости этапов обработки данных. Надпись на титуле — O(1): именно эту оценку обеспечивает словарь Python, и в первых двух частях рассматривается, при каких условиях она выполняется.
Демонстрации. Слайды с розовым выделением слова «Демонстрация» отмечают места, где изложение прерывается для работы в терминале виртуальной машины lab с Python 3.12, NumPy 2.5, SciPy 1.18 и Matplotlib 3.11, далее называемой стендом: на слайде проигрывается запись тех же команд. Данные синтетические и порождаются с фиксированным зерном, поэтому раскладки ключей по корзинам, высоты деревьев, найденные кластеры и пути повторяются при каждом запуске; от машины зависят только измеренные времена. Все расчёты занимают секунды.
Содержание
От поиска за O(n) к O(1) и O(log n)
Изложение. Лекция состоит из семи частей. Первая вводит ассоциативный массив и хеш-таблицу: корзины, коллизии, коэффициент заполнения, перехеширование и поисковый индекс. Вторая посвящена хеш-функциям: вычислению номера корзины делением и умножением, полиномиальному хешу строк, свойствам хеш-функций, встроенной функции hash() и криптографическим хешам. Третья рассматривает деревья, их обходы и деревья в физических задачах. Четвёртая — двоичные деревья поиска: поиск, вставку и удаление за O(h), зависимость высоты от порядка вставки и число различных деревьев поиска. Пятая посвящена кучам и приоритетной очереди. Шестая вводит графы, способы их хранения и обходы, седьмая — кратчайшие пути, топологическую сортировку и минимальное остовное дерево.
Цель лекции
Выбирать структуру данных по операциям, которые она выполняет быстро, и понимать, когда её гарантии нарушаются
Хеш-таблица обеспечивает O(1) лишь в среднем и при равномерной хеш-функции, дерево поиска — O(log n) лишь при сбалансированности. Задачи о связях — соседство пикселей, маршруты, зависимости этапов обработки — сводятся к графам и их обходам.
Изложение. Цель лекции состоит в том, чтобы выбирать структуру данных по набору операций, которые она выполняет быстро, и понимать, при каких условиях её гарантии нарушаются. Оценки сложности структур этой лекции условны. Хеш-таблица обеспечивает поиск за O(1) лишь в среднем и лишь при равномерной хеш-функции и ограниченном заполнении: при неудачной функции та же таблица вырождается в список и снова требует перебора. Дерево поиска обеспечивает O(log n), только если его высота логарифмическая, а это зависит от порядка вставки ключей. Куча выдаёт минимум за O(1), но не позволяет быстро найти произвольный элемент. Вторая половина лекции обращается к задачам о связях между объектами. Соседство сработавших пикселей детектора, маршруты между помещениями, зависимости этапов обработки данных представляют собой графы, и ответы на вопросы о них — какие пиксели образуют кластер, какой путь кратчайший, в каком порядке выполнять этапы — дают обходы графа и несколько алгоритмов на их основе. Таким образом, лекция переходит от вопроса о том, как быстро найти элемент, к вопросу о том, как устроены связи между элементами.
От O(n)
к O(1) и O(log n)
Структура Поиск Вставка Минимум По порядку
list O(n) O(1)* O(n) O(n log n)
list + bisect O(log n) O(n) O(1) O(n)
dict, set O(1)* O(1)* O(n) O(n log n)
дерево поиска O(log n) O(log n) O(log n) O(n)
куча, heapq O(n) O(log n) O(1) O(n log n)
Пояснение
* — в среднем или амортизированно; для дерева поиска — при сбалансированности.
Для физика
Справочник калибровок — dict, отбор k лучших событий — куча, выборка по интервалу энергий — отсортированный массив или дерево.
Изложение. Сводная таблица продолжает таблицу лекции 17, в которой массив и связный список сравнивались по цене доступа, вставки и поиска. Там поиск по значению требовал перебора, O(n), а быстрый поиск в отсортированном массиве двоичным поиском, O(log n), оплачивался медленной вставкой: для вставки в середину необходимо сдвинуть часть элементов. Структуры этой лекции занимают другие клетки таблицы. Словарь и множество находят ключ в среднем за O(1), но не хранят порядка: минимум и перебор ключей по возрастанию требуют просмотра всех элементов и сортировки. Сбалансированное дерево поиска выполняет поиск, вставку и поиск минимума за O(log n) и выдаёт ключи по порядку за O(n), то есть совмещает достоинства отсортированного массива и хеш-таблицы. Куча обеспечивает доступ к минимуму за O(1) и вставку за O(log n), но поиск произвольного элемента в ней требует перебора. Звёздочка отмечает оценки, справедливые в среднем или амортизированно. Отметим, что универсальной структуры нет: выбор определяется тем, какие операции выполняются чаще всего. Справочник калибровочных коэффициентов по имени канала естественно хранить в словаре, отбор k самых энергичных событий выполнять кучей, а выборку событий в интервале энергий — по отсортированному массиву или дереву.
Хеш-таблицы
Отображение, корзины, коллизии, заполнение и поисковый индекс
01
Хеш-таблицы
Хеш-функции
Деревья
Деревья поиска
Кучи
Графы
Пути и остов
Изложение. Первая часть вводит ассоциативный массив — структуру, хранящую пары «ключ — значение», — и хеш-таблицу как основной способ его реализации: номер ячейки вычисляется по ключу, а не ищется перебором. Рассматриваются коллизии, коэффициент заполнения, перехеширование и поисковый индекс.
Ассоциативный
массив
Новосибирск ──┐ Москва ──┼──▶ Россия Казань ──┘ Минск ─────▶ Беларусь Астана ─────▶ Казахстан get(key) -> value set(key, value)
01
Отображение
каждому ключу — единственное значение
02
Массив
отображение целых индексов в значения
03
Ключ
строка, число, кортеж — любой хешируемый объект
Язык Хеш-таблица Дерево поиска
Python dict, set нет (bisect)
C++ unordered_map map
Java HashMap TreeMap
Для физика
Справочник «имя канала → калибровочный коэффициент»: тысячи каналов и обращение при каждом событии.
Изложение. Интерфейс массива состоит из двух операций — получить значение по индексу и записать значение по индексу, — и обе выполняются за O(1). Ограничение массива состоит в том, что индексом может быть только целое число, а в программах значение часто требуется найти по другому признаку: страну по названию города, калибровочный коэффициент по имени канала, запись о событии по его идентификатору. Сопоставление, при котором каждому объекту первого множества отвечает единственный объект второго, называется отображением; это другое название математического понятия функции. Город при этом служит ключом, а страна — значением: на одну страну может указывать сколько угодно городов, но у города страна одна. Массив также является частным случаем отображения — из целых индексов в значения. Структура, реализующая операции get(key) и set(key, value) для ключей произвольного типа, называется ассоциативным массивом, а сам интерфейс — Map. В Python ассоциативный массив представлен встроенным типом dict, а множество set хранит только ключи. Распространены два способа реализации: хеш-таблица, которой посвящена эта часть, и дерево поиска, рассматриваемое в четвёртой части. В Java присутствуют обе реализации, HashMap и TreeMap, в C++ — std::unordered_map и std::map, а в стандартной библиотеке Python дерева поиска нет. Для физика типичный пример — справочник калибровочных коэффициентов: тысячи каналов и обращение к справочнику при обработке каждого события.
Наивная
реализация
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²) на заполнение
Пояснение
Хеш-таблица хранит те же пары, но ищет ключ только в одной короткой корзине.
Изложение. Простейшей реализацией ассоциативного массива является обычный список пар (key, value). Метод get перебирает пары, пока не встретит нужный ключ, и возвращает значение или None, если ключа нет. Метод set выполняет тот же перебор: найдя пару с ключом, он заменяет в ней значение, а не найдя — добавляет новую пару в конец списка. Реализация корректна, но каждая операция в худшем и в среднем случае просматривает O(n) пар, а заполнение справочника из n ключей требует O(n²) сравнений, поскольку каждая вставка сначала проверяет, нет ли уже такого ключа. Для справочника из десятка записей это незаметно, для тысяч каналов, опрашиваемых на каждом событии, — неприемлемо. Хеш-таблица хранит те же пары, но разложенными по множеству коротких списков, и вычисляет по ключу, в каком из них искать; демонстрация после нескольких следующих слайдов сравнит обе реализации на одних и тех же ключах.
Хеш-таблица
и хеш-функция
номер корзины
70 mod 11 = 4
хеш-функция
остаток
массив
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
Корзина
индекс → ячейка массива с парами
Изложение. Хеш-таблица является способом реализации ассоциативного массива, при котором пары (key, value) разложены по ячейкам обычного массива — корзинам, — а номер корзины вычисляется непосредственно по ключу. Для этого необходима хеш-функция: функция, преобразующая ключ в целое число, называемое хешем. Рассмотрим пример главы — склад фруктов. Пусть хешем служит сумма порядковых номеров букв названия: для «груши» она равна 70, для «яблока» — 92. Хеш может быть сколь угодно большим, поэтому номер корзины получают из него отдельным шагом, чаще всего остатком от деления на число корзин M: при M = 11 «груша» попадает в корзину 70 mod 11 = 4. Таким образом, отображение «ключ → значение» распадается на три независимых отображения: хеш-функция переводит ключ в число, остаток переводит число в номер корзины, массив по номеру выдаёт корзину, в которой лежит значение. Разделение позволяет менять размер таблицы, не меняя хеш-функцию: при перестройке пересчитывается только остаток. Запоминать номера корзин не требуется: их достаточно в любой момент вычислить той же функцией, поэтому поиск, вставка и удаление сводятся к вычислению хеша и обращению к одной ячейке массива. Отметим последнюю строку кода: у «яблока» остаток тоже равен 4, и два разных ключа претендуют на одну корзину.
Разрешение
коллизий
метод цепочек: корзина 3: [] корзина 4: [('груша', 120), ('яблоко', 300)] корзина 5: [] открытая адресация, пробы h, h + 1, …: ячейка 4: ('груша', 120) ячейка 5: ('яблоко', 300) # следующая свободная
Метод цепочек Открытая адресация
Корзина список пар одна пара
Коллизия добавление в список поиск свободной ячейки
Поиск корзина и перебор цепочки пробы до ключа или пустой ячейки
Удаление из списка метка «удалено»
Пример HashMap в Java dict и set в CPython
Важно
Равномерная раскладка и достаточное число корзин — O(1) в среднем; массовые коллизии — O(n).
Изложение. Коллизией называется ситуация, при которой хеш-функция возвращает для разных ключей одно и то же значение или, после взятия остатка, один номер корзины. Коллизии неизбежны: ключей может быть сколько угодно, а корзин конечное число, поэтому любая хеш-таблица обязана их разрешать. Распространены два способа. В методе цепочек каждая корзина хранит список всех пар, попавших в неё; поиск вычисляет номер корзины и перебирает только её список. Так устроены хеш-таблица главы и HashMap в Java, где слишком длинная цепочка дополнительно превращается в дерево поиска. При открытой адресации в ячейке хранится не больше одной пары, а при коллизии пара помещается в другую свободную ячейку по правилу пробирования — в простейшем случае в следующую по порядку. Поиск повторяет ту же последовательность проб, пока не встретит ключ или пустую ячейку, поэтому удалённую пару нельзя просто стереть: ячейку помечают как удалённую, иначе оборвётся последовательность проб для ключей, вставленных позже. Открытую адресацию используют словарь и множество 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) на вставку в среднем
Изложение. Условие «корзин достаточно» выражается числом — коэффициентом заполнения α = n/M, отношением числа ключей к числу корзин. Оценим, что он означает при случайной раскладке, когда каждый ключ независимо попадает в любую из M корзин с равной вероятностью. Вероятность того, что корзина не получила ни одного из n ключей, равна (1 − 1/M)^n ≈ e^(−α): при α = 1 пустой остаётся примерно треть корзин, 37 %. Число ключей в корзине распределено по Пуассону со средним α, и успешный поиск в методе цепочек просматривает в среднем 1 + α/2 пар: сам ключ и все ключи, лежащие в его корзине раньше него, — в среднем половину из α остальных ключей корзины. Следовательно, пока α ограничено константой, средняя цена операции O(1) не зависит от n. Чтобы α не росло, таблица перестраивается: как только коэффициент превышает порог, выбранный реализацией, заводится массив корзин в несколько раз больше прежнего, и все ключи раскладываются по нему заново, поскольку номер корзины зависит от M. Эта операция называется перехешированием и требует O(n), но при кратном росте происходит всё реже, и по той же арифметике, что реаллокация динамического массива в лекции 17, средняя цена вставки остаётся O(1) амортизированно. В CPython порог словаря равен 2/3, множества — 3/5. Отметим, что словарь и множество CPython используют открытую адресацию и таблицы размером 2^k, а не простое число корзин; к выбору размера таблицы вернёмся во второй части.
Демонстрация:
хеш-таблица
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 — запись со стенда
Демонстрация. Сценарий table.py строит справочник каналов датчиков положения пучка: ключи вида BPM:0042:X, значения — калибровочные коэффициенты. Хеш строки вычисляется полиномиальной функцией, рассматриваемой во второй части, поэтому раскладка по корзинам одинакова при каждом запуске. Для n от 1000 до 8000 ключей измеряется время одного get. У списка пар оно растёт пропорционально n: 5,97 мкс при тысяче ключей и 50,7 мкс при восьми тысячах. Хеш-таблица на цепочках с постоянным числом корзин M = 11 быстрее в 6,6–9,3 раза, но растёт так же линейно, с 0,90 до 5,45 мкс: при неизменном M цепочки удлиняются вместе с n, и оценка O(1) не выполняется. Та же таблица с перехешированием при заполнении выше 2/3 тратит 0,44–0,47 мкс независимо от n, а встроенный dict — 0,012–0,014 мкс; разница объясняется тем, что хеш строки и перебор цепочки в собственной таблице вычисляются интерпретатором, а в dict — кодом на C. Второй запуск проверяет модель случайной раскладки на 64-битных идентификаторах событий при M = 1009. Доля пустых корзин при α = 0,5, 1, 2 и 4 составляет 0,603, 0,358, 0,127 и 0,020 против e^(−α) = 0,607, 0,368, 0,135 и 0,018, средняя позиция ключа в цепочке — 1,25, 1,49, 2,00 и 2,97 против 1 + α/2, а самая длинная цепочка даже при α = 4 содержит лишь 11 ключей. Заполнение таблицы 100 000 ключами с порогом 2/3 потребовало 14 перестроек, и каждый ключ был перенесён в среднем 1,31 раза — меньше двух, как и предсказывает амортизированная оценка. Последние строки находят моменты перестройки CPython по скачкам sys.getsizeof: словарь перестраивается при добавлении 6, 11, 22, 43, 86 и 171-го ключа, когда заполнение таблицы из 8, 16, 32 … ячеек превышает 2/3, а множество — при 5, 19, 77, 307 и 1229 элементах, то есть при пороге около 3/5 и росте таблицы вчетверо.
Поисковый
индекс
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)/2 O(N³)
слова O(N) O(N)
01
Подстроки
N(N + 1)/2 пар, ключи длиной O(N)
02
Слова
пар не больше, чем слов в тексте
03
Применение
поисковые системы, полнотекстовый поиск в БД
Изложение. Основной принцип хеш-таблицы — элемент ищется там, куда он был положен, — позволяет заранее подготовить данные к быстрому поиску. Поисковый индекс представляет собой отображение, сопоставляющее каждому объекту его расположение. Рассмотрим задачу поиска по тексту длины N. Если требуется находить любую подстроку, в хеш-таблицу необходимо заранее поместить все подстроки текста вместе с их позициями. Подстрока определяется выбором двух границ, поэтому подстрок N(N + 1)/2, то есть O(N²) пар (key, value). Ключами служат сами подстроки, средняя длина которых пропорциональна N, поэтому суммарная память составит O(N³): при удвоении текста число пар растёт вчетверо, а объём ключей — в восемь раз. Такой индекс неприменим уже для текстов в несколько тысяч символов. На практике индексируют слова: текст разбивается на слова, и в таблицу помещаются пары «слово → список позиций». Таких пар не больше, чем слов, памяти требуется O(N), а поиск целого слова сводится к одному обращению к словарю. Так устроены поисковые системы и полнотекстовый поиск в базах данных, где вместо простого списка позиций хранятся более сложные структуры. Для физика типичный пример — электронный журнал смены установки, в котором требуется найти все записи о конкретном узле.
Демонстрация:
поисковый индекс
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 — запись со стенда
Демонстрация. Сценарий index.py собирает условный журнал смены ускорителя из 3000 записей вида «клистрон перезапущен, ток 412 мА» с фиксированным зерном: 14 823 слова, из них 1285 различных. Индекс «слово → номера записей» строится за 1,6 мс и занимает 0,38 МБ. Слово «клистрон» встречается в 326 записях; обращение к индексу занимает 0,018 мкс, а перебор журнала с разбиением каждой записи на слова — 477 мкс, то есть индекс быстрее почти в 27 000 раз, причём ответы совпадают. Вторая половина вывода строит индекс всех подстрок для первых N символов журнала. При N = 125, 250 и 500 число пар равно 7875, 31 375 и 125 250 — ровно N(N + 1)/2, — а суммарная длина подстрок составляет 333 375, 2 635 500 и 20 958 500 символов, то есть растёт в восемь раз при удвоении N. Память индекса составляет 2,0, 10,6 и 63,6 МБ. Рост в 5,4 и 6,0 раза лежит между четырьмя и восемью: при малых N значительную долю памяти занимают служебные структуры словаря и списков, число которых пропорционально числу пар, а с ростом N доля самих строк увеличивается, и отношение приближается к восьми. Экстраполяция по закону N³ даёт для текста из 100 000 символов сотни терабайт. Таким образом, оценка O(N³) подтверждается, а индекс слов позволяет искать за время, не зависящее от длины журнала, при линейной памяти.
Хеш-функции
Номер корзины, хеши строк, свойства, hash() в Python и криптографические хеши
02
Хеш-таблицы
Хеш-функции
Деревья
Деревья поиска
Кучи
Графы
Пути и остов
Изложение. Вторая часть посвящена хеш-функциям: как из хеша получить номер корзины, как хешировать строки, какими свойствами должна обладать хеш-функция и чем хеш-функция словаря отличается от криптографической. Отдельно рассматривается встроенная функция hash() и её случайная соль.
Метод деления
и число корзин
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: биты хеша перемешиваются
Важно
Остаток отрицательного числа зависит от языка: номер корзины может стать отрицательным.
Изложение. Простейшим способом получить номер корзины для целого ключа k является остаток от деления на число корзин M. Любое целое число представляется в виде k = j·M + r, где r лежит в диапазоне от 0 до M − 1, — это те же числа, что служат номерами корзин. С отрицательными числами языки обращаются по-разному: в Python −13 % 5 равно 2, как и в математике, а в C и в функции math.fmod результат равен −3, и без учёта этого номер корзины может оказаться отрицательным. Метод деления чувствителен к закономерностям в ключах. Пусть ключами служат номера каналов, где пятый канал каждой платы из 64 каналов имеет номер 64·k + 5. При M = 64 все такие ключи дают один остаток и попадают в одну корзину; в общем случае занятыми окажутся M/НОД(64, M) корзин, и чем больше у M общих делителей с шагом ключей, тем хуже раскладка. Поэтому для метода деления число корзин выбирают простым: у простого M нет делителей, кроме единицы и его самого, и регулярная последовательность ключей распределяется по всем корзинам. Отметим, что эта рекомендация относится к методу деления со слабой хеш-функцией. Распространённые реализации поступают иначе: словарь и множество CPython и HashMap в Java используют таблицы размером 2^k, у которых остаток вычисляется маской младших битов, а равномерность обеспечивается перемешиванием битов хеша — в CPython старшие биты подмешиваются при пробировании, в Java к хешу применяется исключающее ИЛИ с его сдвигом на 16 бит.
Метод
умножения
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 умножается на константу α из интервала (0, 1), от произведения берётся дробная часть, лежащая в [0, 1), она умножается на число корзин M, и целая часть результата даёт номер корзины от 0 до M − 1. Умножение на иррациональную константу рассеивает соседние ключи по всему отрезку [0, 1), поэтому метод не требует простого M и работает в том числе при M = 2^k, когда умножение и взятие дробной части в целочисленной арифметике сводятся к умножению и сдвигу. Дональд Кнут предложил брать в качестве α число, обратное золотому сечению, (√5 − 1)/2 ≈ 0,618. По теореме о трёх расстояниях точки {k·α} для последовательных k при любом α делят отрезок на промежутки не более трёх различных длин; при α = 1/φ эти длины различаются меньше всего, а каждая следующая точка попадает в один из наибольших промежутков и делит его в золотом отношении, поэтому последовательные ключи рассеиваются наиболее равномерно. Отметим, что речь идёт об обоснованном выборе константы, а не о доказательстве того, что такая хеш-функция наилучшая для любых ключей. В демонстрации после следующего слайда метод умножения проверяется на тех же ключах 64·k + 5, на которых метод деления при M = 64 собирает все ключи в одну корзину.
Полиномиальный
хеш строк
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 получают один хеш.
Изложение. Строки хешируются полиномиально: коды символов служат коэффициентами многочлена от основания q, и значение многочлена берётся по модулю R. Вычисляется оно по схеме Горнера: хеш умножается на q и складывается с кодом очередного символа, поэтому на строку длины n требуется n умножений. Вычисления ведутся по модулю, поскольку для длинных строк сумма не помещается в машинное целое; R выбирают по размеру типа, 2³² или 2⁶⁴, и тогда в языках с машинными целыми остаток получается автоматически, как результат переполнения беззнакового типа. Выбор q важен и для длинных строк. Рассмотрим чётное основание q = 256: его четвёртая степень равна 2³² и по модулю R = 2³² обращается в ноль вместе со всеми старшими степенями. Следовательно, хеш зависит только от четырёх последних символов строки, а у имён файлов данных вида run_0001.dat это всегда «.dat»: все имена получают один хеш, и хеш-таблица вырождается в список. Чем меньше общих делителей у q и R, тем реже такие вырождения; при R = 2³² любое нечётное q взаимно просто с R, а большое простое q, кроме того, взаимно просто с любым числом корзин, не кратным q. Таким образом, q необходимо брать нечётным, а ради универсальности — большим простым числом. Демонстрация на следующем слайде показывает этот эффект на десяти тысячах имён файлов.
Демонстрация:
корзины
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 — запись со стенда
Демонстрация. Сценарий buckets.py раскладывает 1000 ключей вида 64·k + 5 — номера пятого канала каждой платы — разными способами. Остаток от деления при M = 64 собирает все ключи в одну корзину, при M = 128 — в две по 500, при M = 1000 занимает 125 корзин с цепочками по 8 ключей: занятыми оказываются M/НОД(64, M) корзин, что и вытекает из разложения k = j·M + r. Простые M = 61 и 1009 занимают соответственно все 61 корзину и 1000 корзин, то есть при M = 1009 коллизий нет вовсе. Метод умножения с α = 0,618 при M = 64 занимает все 64 корзины с наибольшей цепочкой 17, а при M = 1024 — 837 корзин с цепочками не длиннее двух: размер таблицы 2^k работает, если хеш перемешан. Последняя строка показывает различие остатков отрицательного числа: −13 % 5 в Python равно 2, а math.fmod(−13, 5), как в C, равно −3,0. Сценарий strhash.py хеширует 10 000 имён файлов от run_0000.dat до run_9999.dat с R = 2³² и раскладывает их по 1009 корзинам. Полином с q = 256 даёт единственный хеш, 778330484, — тот же, что у строки xyz.dat, — и поиск требует в среднем 5000,5 сравнения и 30,7 мкс. Сумма кодов символов, аналог суммы номеров букв из главы, различает лишь 37 значений: имена из одних и тех же цифр в разном порядке неразличимы, наибольшая корзина содержит 670 имён, а поиск — 241,3 сравнения. Полином с q = 31 даёт 10 000 различных хешей, наибольшую корзину из 15 имён и 5,7 сравнения, SHA-256, усечённый до 64 бит, — 6,0 сравнения; время поиска у них 0,66 и 0,33 мкс, причём SHA-256 вычисляется кодом на C и потому быстрее полинома, вычисляемого циклом на Python. Встроенный hash() также различает все 10 000 имён.
Свойства
хеш-функции
01
Детерминизм
один и тот же ключ — всегда один и тот же хеш
02
Эффективность
вычисление хеша дешевле перебора ключей
03
Ограниченность
номер корзины в диапазоне от 0 до M − 1
04
Равномерность
значения равновероятны, корзины заполнены поровну
Пояснение
Криптографическим хешам дополнительно необходимы лавинность и необратимость.
Важно
Сумма кодов неравномерна: run_0012.dat и run_0021.dat дают одно число, 990.
Изложение. Не любая функция пригодна на роль хеш-функции таблицы. Детерминизм означает, что для одного и того же ключа функция всегда возвращает одно значение: функция со случайной составляющей отправила бы повторный запрос в другую корзину, и данные оказались бы недоступны. Эффективность требует, чтобы хеш вычислялся быстро: цена поиска складывается из вычисления хеша и просмотра корзины, и функция, которая вычисляется дольше перебора всех ключей, теряет смысл. Ограниченность означает, что номер корзины лежит в диапазоне от 0 до M − 1; обычно её обеспечивает отдельный шаг — остаток или метод умножения. Равномерность требует, чтобы на большом наборе различных ключей каждое значение встречалось примерно одинаково часто, иначе одни корзины переполняются, а другие пустуют. Примером неравномерной функции является сумма кодов символов: она не различает строки, составленные из одних и тех же символов в разном порядке, и все её значения сосредоточены в узком диапазоне. Хеширование применяется и за пределами таблиц — для проверки целостности файлов, подлинности сообщений и хранения паролей, — и там к четырём свойствам добавляются ещё два: лавинность, то есть сильное изменение хеша при малом изменении входа, и необратимость — невозможность восстановить вход по хешу. Хеш-функции словаря эти свойства не обязательны, а криптографическим хешам необходимы.
Функция
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 зависит от запуска.
Изложение. Встроенная функция hash() вычисляет хеш для словаря и множества и устроена по-разному для разных типов. Для целых чисел хеш равен самому числу, взятому по модулю простого числа Мерсенна 2⁶¹ − 1, без какой-либо случайности: hash(42) равно 42, hash(2**61) — единице, а hash(−1) равно −2, поскольку значение −1 в C-интерфейсе интерпретатора обозначает ошибку. Для строк и байтов применяется функция SipHash со случайной солью, которая выбирается при запуске интерпретатора (PEP 456); начиная с Python 3.11 по умолчанию используется вариант SipHash-1-3, до этого — SipHash-2-4, что показывает sys.hash_info.algorithm. Соль защищает сервер от атаки, при которой ему присылают ключи, подобранные под одну корзину, и словарь вырождается в список; внутри одного процесса хеш строки неизменен, поэтому работа словаря от соли не зависит. Переменная окружения PYTHONHASHSEED позволяет зафиксировать соль, например для воспроизведения ошибки. Ключом может служить хешируемый объект, то есть объект, хеш которого не меняется за время его жизни. Изменяемые контейнеры list, dict и set не хешируются: изменение списка, уже лежащего ключом в словаре, изменило бы его хеш, и ключ стал бы ненаходимым, поэтому hash([1, 2]) возбуждает TypeError. Хешируемость при этом не совпадает с неизменяемостью: кортеж хешируется, только если хешируемы все его элементы, и hash((1, [2])) тоже возбуждает TypeError, а экземпляры собственных классов по умолчанию хешируются по идентичности, хотя их атрибуты можно менять. Начиная с Python 3.7 словарь гарантированно хранит порядок вставки, а порядок обхода множества не определён и для строк меняется от запуска к запуску. Отсюда практическое следствие: алгоритм, перебирающий множество строк и выбирающий первый из равных, недетерминирован. Именно так устроено решение 2 задачи о самом частом символе из лекции 17: при равных частотах ответ зависит от соли, и для детерминированного ответа необходимо перебирать sorted(set(s)).
Криптографические
хеши
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, обратимо
Изложение. Криптографические хеш-функции, например SHA-256 из модуля hashlib, помимо четырёх свойств хеш-функции таблицы обладают лавинностью и необратимостью: изменение одного бита входа меняет в среднем половину битов хеша, а найти вход по хешу или два входа с одинаковым хешем вычислительно невозможно. Это позволяет решать несколько задач. Контрольная сумма файла данных, опубликованная рядом с файлом, позволяет обнаружить повреждение при передаче или хранении: случайное совпадение 256-битных хешей практически исключено. Подлинность сообщения в сети проверяется кодом аутентификации HMAC, вычисляемым хешем от сообщения и секретного ключа, или электронной подписью. Функции MD5 и SHA-1, встречающиеся в старых программах, для проверки подлинности непригодны: для них построены коллизии, для SHA-1 — в 2017 году, поэтому применяют SHA-256 и более новые функции. Отметим, что хеш-функция не шифрует сообщения: зашифрованное сообщение расшифровывается обратно, а хеш необратим по определению; шифрование выполняют отдельные алгоритмы, например AES. Хранение паролей также требует большего, чем однократное применение хеш-функции. Быструю функцию злоумышленник, получивший базу, перебирает со скоростью миллиардов паролей в секунду на графическом процессоре, а одинаковые пароли дают одинаковые хеши. Поэтому к паролю приписывают случайную соль, хранимую рядом с хешем, и применяют намеренно медленную функцию — PBKDF2 с сотнями тысяч итераций, scrypt или Argon2, требующие ещё и памяти. В стандартной библиотеке Python для этого предназначены hashlib.pbkdf2_hmac и hashlib.scrypt; рекомендуемые на текущий момент параметры приводит OWASP Password Storage Cheat Sheet.
Демонстрация:
свойства хешей
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 — запись со стенда
Демонстрация. Сценарий props.py в первом режиме запускает новые процессы интерпретатора, сняв с окружения переменную PYTHONHASHSEED. Хеш строки «груша» во всех трёх процессах различен, а с PYTHONHASHSEED=42 трижды одинаков. Хеши целых чисел соли не содержат: hash(42) = 42, hash(2**61) = 1, hash(−1) = −2. Порядок обхода множества из шести букв в пяти процессах получается пятью разными перестановками, тогда как dict.fromkeys во всех процессах сохраняет порядок вставки «абвгде». Решение 2 задачи о самом частом символе из лекции 17, перебирающее set(s), на строке 'abab' с равными частотами при PYTHONHASHSEED от 0 до 7 печатает a, a, b, b, b, a, a, b — по четыре ответа каждого вида; с перебором sorted(set(s)) ответ всегда a: a перебирается первым, а строгое неравенство сохраняет первый из равных. Второй режим посвящён криптографическим хешам. SHA-256 имён run_0001.dat и run_0002.dat, различающихся одним символом, расходятся в 140 битах из 256 — близко к половине, как и требует лавинность, — тогда как сумма кодов символов даёт для run_0012.dat и run_0021.dat одно и то же число 990. Контрольная сумма буфера размером 1 МиБ после изменения одного бита меняется полностью. Последние строки измеряют цену одной проверки пароля на стенде: SHA-256 соли и пароля занимает 0,43 мкс, PBKDF2-HMAC-SHA256 с 600 000 итераций — 93,9 мс, то есть в 219 тысяч раз дольше, а scrypt с n = 2¹⁴ и r = 8 — 24,2 мс и 16 МиБ памяти. Для пользователя задержка в десятки миллисекунд при входе незаметна, а перебор украденной базы замедляется в сотни тысяч раз.
Деревья
Определения, двоичные деревья, обходы в глубину и в ширину, деревья в физике
03
Хеш-таблицы
Хеш-функции
Деревья
Деревья поиска
Кучи
Графы
Пути и остов
Изложение. Третья часть переходит от линейных структур к иерархическим. Вводятся дерево и его терминология, двоичное дерево и класс Node, обходы в глубину и в ширину, а также деревья, работающие в физических расчётах: k-d дерево для поиска соседей и октодерево алгоритма Барнса — Хата.
Дерево:
определения
корень
поддерево B
глубина 0
глубина 1
глубина 2
глубина 3
01
Корень
единственный узел без родителя
02
Родитель
у каждого из остальных узлов ровно один
03
Лист
узел без потомков: D, F, G
04
Глубина, высота
рёбра от корня; высота — наибольшая глубина, здесь 3
Для физика
Каталоги файловой системы, группы HDF5, объёмы Geant4, дерево вызовов профилировщика.
Изложение. Деревом называется набор узлов, соединённых рёбрами, в котором выполнены три условия: выделен один узел, корень; у каждого узла, кроме корня, ровно один родитель; от корня до любого узла ведёт единственный путь. В терминах шестой части дерево представляет собой связный граф без циклов. Узлы, родителем которых служит некоторый узел, называются его потомками; узел без потомков называется листом, узел хотя бы с одним потомком — внутренним. Узел вместе со всеми потомками, их потомками и так далее образует поддерево, как поддерево с корнем B на рисунке. Глубина узла равна числу рёбер на пути от корня, у корня она равна нулю; узлы одной глубины составляют уровень, а высота дерева равна наибольшей глубине — на рисунке трём. Глубину и высоту иногда считают в узлах, и тогда все числа на единицу больше; в задачах необходимо уточнять, какое соглашение принято. Главное свойство дерева — рекурсивность: дерево складывается из корня и поддеревьев, каждое из которых само является деревом, поэтому алгоритмы на деревьях почти всегда записываются рекурсивно, а базовым случаем служит пустое дерево. Иерархии встречаются в физических программах постоянно: каталоги файловой системы, группы и датасеты файла HDF5, геометрия детектора в Geant4 из вложенных объёмов, дерево вызовов, собранное профилировщиком. Отметим оговорку для HDF5: жёсткие ссылки позволяют включить один объект в несколько групп, поэтому, строго говоря, структура такого файла является ориентированным графом с корнем, а не деревом.
Двоичное
дерево и 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 — базовый случай рекурсии
Пояснение
Любое дерево сводится к двоичному: левая ссылка — первый потомок, правая — следующий узел того же родителя.
Изложение. Двоичным называется дерево, в котором у каждого узла не больше двух потомков, различаемых как левый и правый. На двоичных деревьях построены деревья поиска и кучи, а дерево общего вида сводится к двоичному: левая ссылка указывает на первого потомка, правая — на следующий узел того же родителя. В Python узел описывается классом с двумя ссылками, как элемент связного списка из лекции 17, только ссылок теперь две. Дерево с рисунка записывается одной строкой: корень 5 с левым поддеревом 3, у которого потомки 1 и 4, и правым листом 8. Для работы со всем деревом достаточно одной ссылки на корень, от которого достижим любой узел, а пустое дерево представлено значением None. Поэтому функции, работающие с деревьями, принимают корень и первым делом проверяют, не равен ли он None: это и есть базовый случай рекурсии.
Обходы
в глубину и в ширину
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.
Изложение. Обход дерева представляет собой посещение каждого узла ровно по одному разу. Обходы делятся на два семейства. Обход в глубину уходит по ветви до листа и только затем возвращается; три его классических варианта различаются моментом обработки узла относительно поддеревьев. Прямой обход обрабатывает узел до потомков и для дерева с предыдущего слайда выдаёт 5 3 1 4 8, центрированный — между левым и правым поддеревом, 1 3 4 5 8, обратный — после потомков, 1 4 3 8 5. Обход в ширину идёт по уровням: корень, затем все узлы глубины 1, затем глубины 2, — и выдаёт 5 3 8 1 4. Естественной реализацией обхода в ширину служит очередь: узел снимается с её начала, а его потомки ставятся в конец; рекурсивная запись также возможна — обходом в глубину, раскладывающим узлы по спискам уровней, — но очередь проще. Любой обход посещает каждый узел один раз и требует O(n) времени. Дополнительная память различается: обход в глубину хранит стек рекурсии глубиной до высоты дерева h, обход в ширину — очередь, вмещающую самый широкий уровень, до O(n) узлов. Назначение обходов определяется порядком. Прямой обход позволяет копировать и сериализовать дерево сверху вниз: узел создаётся раньше потомков. Обратный обход позволяет вычислять снизу вверх величины, зависящие от поддеревьев, — высоту, размер, значение выражения. Центрированный обход дерева поиска выдаёт ключи по возрастанию, а обход в ширину — узлы по уровням.
Демонстрация:
обходы
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 — запись со стенда
Демонстрация. Сценарий traverse.py описывает иерархию объёмов по образцу Geant4: мир содержит детектор и магнит, детектор — трекер, калориметр и мюонную систему, трекер — три слоя, калориметр — электромагнитную и адронную части; у каждого объёма своя условная масса. Прямой обход печатает иерархию с отступом, пропорциональным глубине: узел печатается раньше потомков, поэтому каждый объём оказывается над своим содержимым. Обратный обход вычисляет массу поддерева как собственную массу плюс массы поддеревьев: трекер 24 кг, калориметр 3440, детектор 3884, мир 7884 кг; массу узла нельзя вычислить раньше, чем сосчитаны все его потомки. Обход в ширину с очередью выдаёт объёмы по уровням: на уровне 0 мир, на уровне 1 детектор и магнит, на уровне 2 трекер, калориметр и мюонная система, на уровне 3 пять модулей. Второй запуск повторяет пример главы: дерево 5(3(1, 4), 8) даёт порядки 5 3 1 4 8, 1 3 4 5 8, 1 4 3 8 5 и 5 3 8 1 4. Затем строится дерево выражения для энергии частицы E = √(p² + m²), в котором внутренние узлы — операции, а листья — переменные. Центрированный обход восстанавливает привычную запись со скобками, sqrt(((p * p) + (m * m))), обратный обход даёт обратную польскую запись p p * m m * + sqrt, по которой выражение вычисляется стеком, а вычисление обратным обходом при p = 3 ГэВ и m = 4 ГэВ даёт E = 5,0 ГэВ. Так же устроены символьные выражения SymPy.
Деревья
в физических задачах
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
Изложение. Рассмотрим три сюжета вычислительной физики, в которых деревья являются рабочим инструментом. Первый — поиск соседей: в молекулярной динамике, методе SPH, при кластеризации точек измерения постоянно требуется найти частицы, лежащие рядом с выбранной. Перебор всех пар требует O(N²) операций. k-d дерево рекурсивно делит пространство плоскостями, перпендикулярными поочерёдно каждой оси, пополам по числу точек, и запрос ближайших соседей отбрасывает целые ячейки, лежащие дальше уже найденного соседа, — в среднем за O(log N). Оценка относится к пространствам малой размерности, как в задачах с тремя координатами: с ростом размерности отбрасывать ячейки удаётся всё реже, и в пространстве из десятков измерений k-d дерево почти не выигрывает у перебора. В SciPy дерево реализовано классом scipy.spatial.KDTree: построение требует O(N log N), а методы query, query_ball_point и query_pairs находят k ближайших соседей, соседей в радиусе и все близкие пары. Второй сюжет — гравитационная задача N тел. Прямое суммирование сил требует O(N²) операций на шаг, что для галактики из миллиардов звёзд неприемлемо. Алгоритм Барнса — Хата строит октодерево: куб, содержащий систему, рекурсивно делится на восемь октантов, а в каждом узле хранятся суммарная масса и центр масс его частиц. Если угловой размер ячейки s/d меньше порога θ, вклад всей ячейки учитывается одним слагаемым, и сложность снижается до O(N log N); такие деревья вместе с быстрым мультипольным методом работают в астрофизических кодах наподобие GADGET. Третий сюжет — иерархические данные: файлы 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 — запись со стенда
Демонстрация. Сценарий kdtree.py размещает N частиц равномерно в единичном кубе с фиксированным зерном и ищет все пары, расстояние между которыми меньше r = 0,01. Перебор вычисляет на NumPy квадраты всех N² попарных расстояний блоками по 500 строк по формуле |a − b|² = |a|² + |b|² − 2a·b, чтобы не создавать матрицу N × N целиком, и учитывает каждую пару один раз, то есть проверяет N(N − 1)/2 пар. Дерево строится классом KDTree, а пары находит метод query_pairs. При N = 5000, 10 000 и 20 000 найдено 58, 229 и 898 пар, и оба способа дают одинаковый ответ. Перебор занимает 45,5, 181,0 и 731,5 мс: при каждом удвоении N время растёт в 4,0 раза, как и положено алгоритму O(N²). Дерево вместе с построением тратит 1,7, 2,5 и 6,0 мс, то есть растёт в 1,5 и 2,4 раза — близко к линейному закону, — и при 20 000 частиц оказывается быстрее перебора более чем в 120 раз. Оценка сценария умножает число пар N²/2 на долю объёма шара радиуса r, (4/3)πr³, и даёт 838 ± 29 при N = 20 000; она не учитывает, что у точек вблизи граней часть шара лежит вне куба, и с этой поправкой ожидание снижается примерно до 828. Найденные 898 пар превышают его на 2,5 стандартного отклонения: при выбранном зерне выпала редкая, но случайная флуктуация — столько пар или больше набирается реже чем в одной выборке из ста. Последняя строка показывает метод query с k = 2: расстояния до ближайшего соседа первых пяти частиц, от 0,0139 до 0,0305; второй сосед берётся потому, что первым соседом точки служит она сама. Листинг главы с KDTree работает без изменений, а выигрыш от дерева растёт вместе с N.
Деревья поиска
Свойство порядка, поиск и вставка за 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)
Пояснение
Отсортированный массив медленно вставляет, хеш-таблица не хранит порядка; дерево поиска обеспечивает и то и другое.
Изложение. Двоичным деревом поиска называется двоичное дерево, в каждом узле которого выполняется свойство порядка: все ключи левого поддерева строго меньше ключа узла, а все ключи правого — строго больше. Свойство порядка превращает поиск в аналог двоичного поиска по отсортированному массиву: искомый ключ сравнивается с корнем, и если он меньше, спуск продолжается налево, если больше — направо; каждый шаг отбрасывает целое поддерево, заведомо не содержащее ключа. Вставка выполняет тот же спуск, а новый узел подвешивается там, где спуск дошёл до None; рекурсивная запись главы возвращает корень поддерева, чтобы родитель мог переприсвоить ссылку. Поиск, вставка и удаление проходят один путь от корня вниз и требуют O(h) операций, где h — высота дерева. Центрированный обход выдаёт ключи по возрастанию: сначала всё меньшее, затем узел, затем всё большее. Таким образом, дерево поиска обеспечивает одновременно быстрый поиск, быструю вставку и перебор по порядку, а также поиск минимума, максимума и ближайшего ключа. Ни отсортированный массив, у которого вставка требует сдвига элементов, ни хеш-таблица, не хранящая порядка ключей, этого не обеспечивают. К рекурсивной вставке вернёмся на слайде о сбалансированности.
Удаление
и проверка
01
Нет потомков
узел убирается, ссылка родителя — None
02
Один потомок
потомок встаёт на место узла
03
Два потомка
место узла занимает преемник — минимум правого поддерева
parent, succ = root, root.right # преемник while succ.left is not None: parent, succ = succ, succ.left
дерево поиска
не дерево поиска: 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))
Изложение. Удаление узла из дерева поиска разбирается в задаче 5 главы и сводится к трём случаям. Сначала узел находится обычным спуском. Если у него нет потомков, он просто убирается, и ссылка родителя становится равной None. Если потомок один, он подвешивается на место удаляемого узла. Если потомков два, находится преемник — узел с наименьшим ключом в правом поддереве: шаг направо, затем налево, пока есть левый потомок. Преемник больше всех ключей левого поддерева и меньше остальных ключей правого, поэтому может занять место удаляемого узла; левого потомка у него нет, и вырезается он по первому или второму случаю. Спуск к узлу и спуск к преемнику вместе образуют один путь, поэтому удаление требует O(h). Задача 3 главы — проверка, является ли двоичное дерево деревом поиска, — позволяет разобрать характерную ошибку. Проверять только соседние узлы, то есть что левый потомок меньше узла, а правый больше, недостаточно. Рассмотрим правое дерево на рисунке: узел 6 больше своего родителя 3, и локально условие выполнено, однако он лежит в левом поддереве корня 5 и обязан быть меньше 5. Условие порядка глобально: каждый узел ограничен всеми предками. Поэтому функция is_bst передаёт вниз интервал допустимых значений (low, high): при переходе налево верхней границей становится ключ узла, при переходе направо — нижней. Альтернативный критерий — центрированный обход должен выдавать строго возрастающую последовательность. Обе проверки требуют O(n) времени.
Высота
и сбалансированность
Дерево Высота h O(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, … исчерпывает предел рекурсии уже на тысяче ключей.
Изложение. Цена любой операции дерева поиска определяется его высотой h, а высота зависит от порядка вставки. Если ключи поступают в случайном порядке, высота растёт логарифмически, но с множителем, заметно большим единицы. Средняя глубина узла случайного дерева поиска близка к 2 ln n ≈ 1,39·log₂ n, а высота, согласно работам Девруа и Рида, растёт как 4,311 ln n − 1,953 ln ln n, то есть асимптотически около 3·log₂ n; при n = 10⁵ высота составляет около 2,4·log₂ n. Если же вставить ключи 1, 2, 3, …, n по порядку, каждый новый ключ больше всех прежних и уходит направо: дерево вырождается в цепочку высоты n − 1, и все операции становятся линейными. В этом же вырожденном случае рекурсивная вставка главы делает по вызову на каждый уровень и уже на тысяче ключей исчерпывает предел глубины рекурсии Python; для таких деревьев вставку записывают циклом. Дерево называется сбалансированным, если его высота составляет O(log n) при любом порядке операций. Строгий вариант — условие АВЛ-дерева: у каждого узла высоты поддеревьев отличаются не больше чем на единицу, что даёт высоту не более примерно 1,44·log₂ n. Красно-чёрное дерево поддерживает более слабое условие и гарантирует h ≤ 2·log₂(n + 1). Оба дерева восстанавливают баланс после каждой вставки и удаления локальными перестройками — поворотами — за O(log n). К сбалансированным относятся и B-деревья индексов баз данных из лекции 13: узел-страница содержит сотни ключей, и миллиону строк достаточно трёх-четырёх уровней. На красно-чёрных деревьях построены TreeMap в Java и std::map в распространённых реализациях стандартной библиотеки C++. В стандартной библиотеке Python дерева поиска нет: словарь и множество обеспечивают поиск по ключу, а модуль bisect — поиск в отсортированном списке.
Демонстрация:
высота дерева
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 — запись со стенда
Демонстрация. Сценарий bst.py строит деревья поиска из номеров событий вставкой, записанной циклом, и считает высоту в рёбрах, как в главе. При случайном порядке вставки для n = 10³, 10⁴ и 10⁵ высота равна 19, 29 и 40, то есть 1,91, 2,18 и 2,41 от log₂ n, и отношение растёт с n, приближаясь к асимптотическому значению около трёх. Средняя глубина узла — 11,28, 15,92 и 20,42 — согласуется с точной формулой для случайного дерева 2(1 + 1/n)·H_n − 4, где H_n — гармоническое число: 10,99, 15,58 и 20,18. Упорядоченные ключи дают цепочку высоты 999 при n = 1000 и 1999 при n = 2000; построение занимает 11 и 32 мс. Вставка каждого ключа проходит по всем прежним узлам цепочки, поэтому число шагов спуска n(n − 1)/2 при удвоении n растёт вчетверо; в записи время выросло лишь втрое, поскольку однократный замер длительностью около 10 мс неточен, а при повторных замерах отношение близко к четырём. Рекурсивная вставка из главы на ключах 1, 2, 3, … завершается ошибкой RecursionError на ключе 999: глубина рекурсии растёт вместе с цепочкой и достигает предела интерпретатора в 1000 кадров. Последние строки сравнивают время поиска одного ключа: случайное дерево из 10⁵ ключей — 0,337 мкс, цепочка из 2000 ключей — 14,0 мкс, двоичный поиск bisect по отсортированному списку из 10⁵ чисел — 0,090 мкс, словарь — 0,017 мкс. Дерево на Python медленнее bisect, поскольку спуск по узлам выполняется интерпретатором, а bisect реализован на C; асимптотика у них одна, O(log n).
Результат:
высота дерева
Что наблюдается
Упорядоченные ключи дают прямую n − 1, случайный порядок — логарифмический рост. Высота растёт втрое круче log₂ n, средняя глубина лежит на формуле 2(1 + 1/n)·H_n − 4.
Демонстрация. Рисунок построен тем же сценарием для n от 16 до 131 072, по пять деревьев на каждое значение. Слева в логарифмических осях сравниваются два порядка вставки: упорядоченные ключи дают прямую n − 1, а высота случайного дерева при росте n в восемь тысяч раз увеличивается лишь с 5 до 42. Справа случайный порядок показан в линейной шкале высот: синяя линия — средняя высота пяти деревьев, заливка — разброс между ними, зелёные квадраты — средняя глубина узла, штриховая линия — точная формула для средней глубины, точечная — log₂ n. Средняя глубина лежит на формуле и растёт в 1,4 раза быстрее log₂ n, а высота отклоняется от log₂ n всё сильнее: при каждом удвоении n она увеличивается почти на три уровня, тогда как log₂ n — на один. Отметим, что разброс высоты пяти деревьев достигает нескольких уровней, тогда как средняя глубина почти не флуктуирует: высота определяется одной самой длинной ветвью, а средняя глубина усредняется по всем узлам.
Сколько
деревьев поиска
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
Каталан
скобочные последовательности, пути Дика
Изложение. Задача 4 главы спрашивает, сколько различных деревьев поиска можно построить из чисел от 1 до n. Выберем корнем число k. Тогда числа от 1 до k − 1 обязаны оказаться в левом поддереве, а от k + 1 до n — в правом, и каждое поддерево само является деревом поиска. Число деревьев из m последовательных чисел зависит только от m, поэтому получим рекуррентность C_n = Σ C_(k−1)·C_(n−k) по k от 1 до n с C_0 = 1: пустое дерево одно. Решением рекуррентности являются числа Каталана: 1, 1, 2, 5, 14, 42, … Они также считают правильные скобочные последовательности из n пар скобок и блуждания из 2n шагов ±1, которые не опускаются ниже нуля и заканчиваются в нуле, — пути Дика; без условия возврата в ноль получилось бы другое число, C(2n, n). Рекуррентность решается динамическим программированием за O(n²) операций и O(n) памяти, а замкнутая формула C(2n, n)/(n + 1) — за O(n) умножений; при n = 20 ответ равен 6 564 120 420. Вычисления необходимо вести в целых числах: функция math.comb и целочисленное деление // точны при любом n. Наивная формула через факториалы и деление / возвращает float с 53 битами мантиссы, и демонстрация покажет, при каком n это приводит к ошибке и в чём её причина.
Демонстрация:
числа Каталана
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 — запись со стенда
Демонстрация. Сценарий catalan.py решает задачу 4 тремя способами: формулой C(2n, n)/(n + 1) в целых числах, динамикой по рекуррентности и прямым перебором — построением всех деревьев поиска в виде вложенных кортежей. Для n от 0 до 10 формула и динамика дают 1 1 2 5 14 42 132 429 1430 4862 16796, а перебор при n от 1 до 8 строит ровно 1, 2, 5, 14, 42, 132, 429 и 1430 деревьев. При n = 20, верхней границе условия, оба точных способа дают 6 564 120 420. Затем проверяются две наивные формулы. Если факториалы перевести во float до деления, как в языках без длинной арифметики, ошибки округления вносят перевод 40! во float, умножение и деление (20! и 21! представимы точно), и уже при n = 20 результат равен 6564120419.999999, а int отбрасывает дробную часть и даёт 6 564 120 419 — неверный ответ внутри условия задачи. Если же делить целые факториалы оператором /, Python вычисляет частное точно и округляет его к ближайшему float один раз; результат верен, пока число Каталана представимо в float точно, то есть меньше 2⁵³. Последние строки показывают границу: C₃₀ < 2⁵³ < C₃₁, и первая ошибка возникает при n = 31 — 14544636039226908 вместо …909. Таким образом, ответ до n = 30 верен не потому, что ошибки числителя и знаменателя компенсируют друг друга, а потому, что промежуточных float в этом вычислении нет вовсе.
Кучи
Свойство кучи, хранение в массиве, приоритетная очередь, пирамидальная сортировка
05
Хеш-таблицы
Хеш-функции
Деревья
Деревья поиска
Кучи
Графы
Пути и остов
Изложение. Пятая часть посвящена куче — дереву, которое не упорядочивает элементы полностью, но всегда хранит в корне максимум или минимум. Рассматриваются хранение кучи в массиве, просеивание, приоритетная очередь и модуль heapq, а также пирамидальная сортировка.
Куча
в массиве
01
Свойство кучи
узел не меньше потомков: максимум в корне
02
Почти полное дерево
уровни заполнены слева направо, h = ⌊log₂ n⌋
03
Индексы
потомки 2i + 1 и 2i + 2, родитель ⌊(i − 1)/2⌋
04
Просеивание
вверх и вниз по одному пути: O(log n)
Пояснение
Между левым и правым поддеревьями порядка нет: куча — не дерево поиска.
Изложение. Куча представляет собой структуру данных для задач, в которых требуется быстро находить максимум или минимум набора, меняющегося в процессе работы. В невозрастающей куче, max-heap, выполняется свойство кучи: ключ любого узла не меньше ключей его потомков, поэтому в корне всегда находится максимум; в min-heap, наоборот, в корне минимум. В отличие от дерева поиска, куча не упорядочивает элементы полностью: между левым и правым поддеревьями порядка нет. Взамен куча всегда остаётся почти полным двоичным деревом — все уровни, кроме последнего, заполнены целиком, а последний заполняется слева направо, — поэтому она не вырождается, и её высота равна ⌊log₂ n⌋. Почти полное дерево хранится без ссылок, в обычном массиве, с узлами, выписанными по уровням: у узла с индексом i потомки находятся в ячейках 2i + 1 и 2i + 2, а родитель — в ячейке ⌊(i − 1)/2⌋, так что для переходов по дереву достаточно арифметики индексов. Куча с рисунка, корень 9 с потомками 7 и 8, записывается массивом [9, 7, 8, 3, 1, 4]. Свойство кучи восстанавливают две операции просеивания. Просеивание вверх меняет элемент, дописанный в конец массива, местами с родителем, пока он больше родителя. Просеивание вниз меняет корень, ставший меньше потомков, местами с большим из них, пока свойство не восстановится. Обе операции проходят не больше одного пути от корня до листа и требуют 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)
построение, heapify O(n)
отбор k лучших из n O(n log k)
Для физика
Десять самых энергичных событий из миллиона — без сортировки всего набора.
Изложение. Приоритетная очередь, построенная на просеиваниях, представляет собой структуру, из которой элементы извлекаются в порядке приоритета, а не поступления: добавление требует O(log n), просмотр минимума — O(1), извлечение минимума, при котором последний элемент переставляется в корень и просеивается вниз, — O(log n). В Python куча реализована модулем heapq как min-heap над обычным списком: функции модуля принимают список и поддерживают в нём свойство кучи, а минимум всегда лежит в h[0]. Функция heapify превращает произвольный список в кучу за O(n), а не за O(n log n): она просеивает вниз внутренние узлы, начиная с последнего, и большинство узлов, лежащих у самого дна, опускаются лишь на один-два уровня. Если требуется куча на максимум, в версиях Python до 3.13 включительно в неё помещают ключи со знаком минус; начиная с Python 3.14 модуль содержит и функции для max-heap — heapify_max, heappush_max, heappop_max и другие. Функции nlargest и nsmallest отбирают k наибольших или наименьших элементов, поддерживая кучу из k элементов: каждый новый элемент сравнивается с корнем и при необходимости заменяет его, что требует O(n log k) времени и O(k) памяти вместо O(n log n) и O(n) у полной сортировки. Для физика это типичная операция: отбор десяти самых энергичных событий из миллиона, лучших кандидатов при реконструкции, ближайших соседей.
Пирамидальная
сортировка
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
ключ (−решено, штраф, логин)
Пояснение
На практике медленнее быстрой сортировки: просеивание обращается к далёким ячейкам массива.
Изложение. Куча даёт ещё один способ сортировки за O(n log n) — пирамидальную сортировку. Она состоит из двух этапов. На первом массив превращается в max-heap: внутренние узлы просеиваются вниз, начиная с последнего и заканчивая корнем, что требует O(n). На втором n − 1 раз повторяется одно и то же: корень, текущий максимум, меняется местами с последним элементом неотсортированной части и встаёт на окончательное место, а новый корень просеивается вниз в укоротившейся куче. Каждое просеивание требует O(log n), поэтому сортировка выполняется за O(n log n) на любом входе — неудачных входов, как у быстрой сортировки, у неё нет, — и не требует дополнительной памяти, в отличие от сортировки слиянием. Платой служат неустойчивость, рассмотренная в лекции 17, и константа: просеивание обращается к далеко отстоящим ячейкам массива, кеш процессора используется хуже, и на массивах из 10⁵–10⁷ чисел пирамидальная сортировка в 2–5 раз медленнее std::sort. Задача 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 — запись со стенда
Демонстрация. Сценарий heap.py разыгрывает энергии миллиона событий по экспоненциальному закону со средним 1 ГэВ и отбирает десять самых энергичных — от 12,76 до 11,46 ГэВ. Функция heapq.nlargest тратит 7,1 мс, собственная куча из десяти элементов с heapreplace — 8,3 мс, и пик дополнительной памяти у обеих близок к нулю: хранится лишь куча из k элементов. Сортировка всего списка занимает 119,6 мс, то есть в 17 раз дольше, и требует 12,00 МБ — под копию списка из миллиона ссылок и временный буфер сортировки. Второй запуск подсчитывает сравнения на худшем входе — 100 000 убывающих ключей, каждый из которых при вставке в min-heap поднимается до корня. Последовательные вызовы heappush выполняют 1 468 946 сравнений, 14,69 на элемент, что близко к n·log₂ n = 1 660 964, а heapify — 149 988, всего 1,50 сравнения на элемент: построение кучи просеиванием вниз требует O(n), а не O(n log n). Пирамидальная сортировка главы на 100 000 случайных чисел занимает 124 мс против 9,4 мс у встроенной sorted(): асимптотика одна, но собственная реализация выполняется интерпретатором, а sorted — кодом на C, который к тому же сравнивает однотипные числа float напрямую, минуя общий механизм сравнения объектов; упорядоченных участков, которые использует сортировка Python, в случайных данных нет. Последняя строка проверяет задачу 6: таблица соревнования упорядочена как gena, timofey, alla, gosha, rita, что совпадает с ответом главы.
Графы
Определения, хранение, обходы в глубину и в ширину, компоненты связности, графы в физике
06
Хеш-таблицы
Хеш-функции
Деревья
Деревья поиска
Кучи
Графы
Пути и остов
Изложение. Шестая часть вводит граф — самую общую структуру, описывающую связи, частными случаями которой являются список и дерево. Рассматриваются определения, способы хранения графа в памяти, обходы в глубину и в ширину, поиск компонент связности и физические задачи, которые сводятся к графам.
Граф:
определения
Шесть вершин и семь рёбер: граф демонстрации порядка обходов
01
G = (V, E)
вершины и рёбра; n = |V|, m = |E|
02
Виды
неориентированный, ориентированный, взвешенный
03
Путь, цикл
цепочка смежных вершин; замкнутая — цикл
04
Связность
компонента — максимальная связная часть
Пояснение
Дерево — связный граф без циклов, у него n − 1 рёбер; граф без циклов называется лесом.
Изложение. Граф G = (V, E) состоит из множества вершин V и множества рёбер E, каждое из которых соединяет пару вершин; число вершин обозначим n, число рёбер — m. В неориентированном графе ребро не имеет направления: если из u можно попасть в v, то и обратно, как по дороге с двусторонним движением. В ориентированном графе ребро (u, v) не означает существования ребра (v, u): так устроены односторонние улицы, ссылки между веб-страницами и зависимости задач. Взвешенный граф приписывает каждому ребру число — длину пути, сопротивление проводника, пропускную способность канала, энергию связи. Вершины, соединённые ребром, называются смежными, а число рёбер, инцидентных вершине, — её степенью; в ориентированном графе различают входящую и исходящую степени. Путь представляет собой последовательность вершин, в которой соседние соединены рёбрами, а цикл — путь, начинающийся и заканчивающийся в одной вершине. Граф связен, если из любой вершины можно добраться до любой другой; компонента связности является максимальной связной частью графа. Петля соединяет вершину с ней самой, а кратные рёбра — несколько рёбер между одной парой вершин. Дерево с этой точки зрения является связным неориентированным графом без циклов, у которого рёбер на одно меньше, чем вершин, а граф без циклов, не обязательно связный, называется лесом. Граф на рисунке — шесть вершин и семь рёбер — используется в демонстрации порядка обходов.
Хранение
графа
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)
соседи вершины u O(n) O(deg u)
добавить ребро O(1) O(1)
Для физика
Реальные графы разрежены: у атома в молекуле единицы связей, у пикселя не больше восьми соседей.
Изложение. От способа хранения графа зависят объём памяти и цена операций. Матрица смежности представляет собой квадратную таблицу n × n, в которой на пересечении строки i и столбца j записана единица или вес ребра, если ребро из i в j существует, и ноль в противном случае; у неориентированного графа она симметрична. Матрица позволяет проверить наличие ребра за O(1), но требует O(n²) памяти независимо от числа рёбер, а перебор соседей вершины требует просмотра всей строки, O(n). Создавать её необходимо генератором списков: запись [[0] * n] * n даёт n ссылок на один и тот же список, и запись в одну строку изменит все строки одновременно. Список смежности хранит для каждой вершины список её соседей: памяти требуется O(n + m), перебор соседей занимает время, пропорциональное их числу, а платой служит проверка конкретного ребра за O(deg u). Третий способ — список рёбер, перечень пар вершин с весами: для обходов он неудобен, но подходит алгоритмам, перебирающим рёбра целиком, например алгоритму Краскала, и в таком виде граф обычно поступает на вход. Для плотного графа, где число рёбер сравнимо с n², применяют матрицу, для разреженного — списки. Реальные графы почти всегда разрежены: у атома в молекуле единицы связей, у пикселя детектора не больше восьми соседей, — поэтому по умолчанию выбирают списки смежности.
Обходы графа:
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): вершины и рёбра — по одному разу
Пояснение
Компоненты связности — обход из каждой ещё не посещённой вершины.
Изложение. Обход графа представляет собой систематическое посещение всех вершин, достижимых из стартовой. В отличие от дерева, у графа есть циклы, поэтому обязательным элементом обхода становится множество посещённых вершин: без него обход зациклится. Поиск в глубину уходит из текущей вершины как можно дальше и, когда идти некуда, возвращается к последней развилке. Рекурсивная запись удобна, но на пути длиннее примерно тысячи вершин, например на цепочке из ста тысяч вершин, исчерпает предел рекурсии Python, поэтому DFS реализуют явным стеком. Одна и та же вершина попадает в стек несколько раз — от разных соседей, — и отсеивать её необходимо в момент снятия со стека, а не только перед добавлением. Соседей кладут в стек в обратном порядке, чтобы первым снимался первый из них: тогда явный стек обходит вершины в том же порядке, что и рекурсивная запись, — на графе со слайда определений с соседями по возрастанию это порядок 1 2 4 3 6 5, а без разворота получился бы порядок 1 3 6 5 4 2. Поиск в ширину использует очередь и посещает вершины по расходящимся кругам: сначала соседей старта, затем соседей соседей. Здесь вершину помечают в момент постановки в очередь, иначе она успеет попасть туда несколько раз. Таким образом, два обхода различаются контейнером — стеком или очередью — и моментом пометки вершины. Оба требуют 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 — запись со стенда
Демонстрация. Сценарий clusters.py моделирует пиксельный детектор 64 × 64: двенадцать следов частиц оставляют цепочки сработавших пикселей, а шум добавляет одиночные. Вершинами графа служат 179 сработавших пикселей, ребро соединяет пиксели, соседние по стороне или по углу; таких пар 145. Матрица смежности для этого графа содержала бы 179² = 32 041 ячейку, а списки смежности хранят 2m = 290 записей — более чем в сто раз меньше, что и означает разреженность. Кластер, то есть группа пикселей, оставленная одной частицей, является компонентой связности графа; компоненты находит обход в ширину, запускаемый из каждой ещё не посещённой вершины. Найдено 44 кластера: 30 одиночных пикселей — шум или край следа — и кластеры размером от 2 до 24 пикселей; центр тяжести крупнейшего находится в точке (57,2; 42,6). Поиск кластеров в пиксельном детекторе сводится, следовательно, к поиску компонент связности. Второй запуск показывает порядок обходов на графе из шести вершин со слайда определений. Обход в глубину из вершины 1 с соседями по возрастанию проходит 1 2 4 3 6 5: из вершины 2 он сразу уходит в 4, затем в 3 и 6 и лишь в конце достигает 5. Обход в ширину проходит 1 2 3 4 6 5 — по удалению от старта, — а расстояния в рёбрах равны 0 для вершины 1, 1 для вершин 2 и 3, 2 для вершин 4 и 6 и 3 для вершины 5.
Графы
в физических задачах
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)
Пояснение
Библиотека не подскажет, что задача сводится к компонентам связности: это решает постановка.
Изложение. К графам сводится множество физических задач. Электрическая схема представляет собой граф из узлов и ветвей; законы Кирхгофа записываются через матрицу инцидентности, а число независимых контурных уравнений равно m − n + 1 — числу ветвей, не вошедших в остовное дерево, о котором пойдёт речь в седьмой части. В модели Изинга спины на решётке служат вершинами, а взаимодействия соседей — рёбрами; кластерные алгоритмы Свендсена — Ванга и Вольфа, ослабляющие критическое замедление метода Монте-Карло, на каждом шаге строят кластеры связанных спинов, то есть ищут компоненты связности. Перколяция задаёт вопрос, существует ли компонента связности, соединяющая противоположные края решётки, и демонстрация после этого слайда отвечает на него обходом в ширину. Атомы и химические связи образуют граф молекулы: поиск колец сводится к поиску циклов, а графовые нейронные сети предсказывают свойства веществ, работая непосредственно с этим представлением. Состояния системы и вероятности переходов между ними образуют взвешенный ориентированный граф марковской цепи. Разводка кабелей на установке, маршрут манипулятора, план эксперимента сводятся к задачам о кратчайшем пути и остовном дереве. Для расчётов реализовывать алгоритмы вручную не обязательно: они собраны в библиотеке NetworkX. Однако разбираться в их устройстве всё равно необходимо: библиотека не подскажет, что поставленная задача сводится к поиску компонент связности.
Демонстрация:
перколяция
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 — запись со стенда
Демонстрация. Сценарий percolation.py рассматривает перколяцию узлов на квадратной решётке L × L: каждый узел занят с вероятностью p независимо от остальных, а соседями считаются узлы, имеющие общую сторону. Решётка протекает, если существует кластер занятых узлов, соединяющий верхний край с нижним. Проверку выполняет обход в ширину, который стартует сразу из всех занятых узлов верхней строки и останавливается, как только достигает нижней; на одну решётку уходит O(L²) операций. Для каждой пары L и p разыгрывается 200 решёток с фиксированным зерном. При p = 0,50 решётка 16 × 16 протекает в 5 % испытаний, 32 × 32 — в одном испытании из 200, а 64 × 64 не протекает ни разу; при p = 0,65 доли равны 0,83, 0,94 и 0,99, а при 0,70 большие решётки протекают всегда. Между 0,58 и 0,60 порядок кривых меняется: ниже порога большая решётка протекает реже малой, выше — чаще. Это проявление фазового перехода: в пределе бесконечной решётки доля протекающих решёток скачком меняется от нуля к единице при пороге p_c ≈ 0,5927, найденном Ньюменом и Зиффом с точностью до восьмого знака, а на конечной решётке переход размыт тем сильнее, чем меньше L. При L = 16 доля растёт от 0,05 до 0,94 на интервале от 0,50 до 0,70, а при L = 64 — от 0,04 до 0,99 на интервале от 0,55 до 0,65. Таким образом, обход в ширину, рассмотренный как алгоритм на графе, становится инструментом статистической физики.
Результат:
перколяция
Что наблюдается
Чем больше решётка, тем круче переход; кривые трёх размеров пересекаются вблизи p_c ≈ 0,5927, а статистический разброс точек при 200 испытаниях составляет несколько процентов.
Демонстрация. Рисунок построен тем же сценарием для p от 0,45 до 0,75 с шагом 0,01, по 200 испытаний на точку. Зелёная, синяя и оранжевая кривые соответствуют решёткам 16 × 16, 32 × 32 и 64 × 64, пунктир отмечает порог p_c ≈ 0,5927. Чем больше решётка, тем круче кривая: оранжевая резко растёт между 0,57 и 0,62, а зелёная растянута почти на весь интервал. Все три кривые пересекаются вблизи порога, где доля протекающих решёток близка к половине. Отметим неровности кривых, например выброс зелёной кривой при p = 0,49: доля, оценённая по 200 испытаниям, имеет статистическую погрешность √(P(1 − P)/200), где P — сама доля, — до 3,5 % в середине перехода. Пересечение кривых для решёток разного размера служит стандартным способом оценки порога в методе конечноразмерного скейлинга.
Пути и остов
Кратчайшие пути, топологическая сортировка, минимальное остовное дерево
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); устаревшие записи пропускаются
Важно
Веса неотрицательны; при отрицательных — алгоритм Беллмана — Форда.
Изложение. В невзвешенном графе кратчайший путь по числу рёбер находит обход в ширину: он посещает вершины в порядке удаления от старта и, запоминая для каждой вершины расстояние и предка, позволяет восстановить путь проходом от конечной вершины по ссылкам на предков с последующим разворотом. Во взвешенном графе обход в ширину неприменим: путь из трёх лёгких рёбер может иметь меньшую суммарную длину, чем одно тяжёлое ребро. Здесь используется алгоритм Дейкстры, являющийся жадным. Алгоритм поддерживает оценки расстояний от старта и на каждом шаге выбирает ещё не обработанную вершину с наименьшей оценкой; при неотрицательных весах эта оценка уже окончательна, поскольку любой обходной путь через другие вершины не может оказаться меньше. Затем алгоритм пытается улучшить оценки соседей: если d(v) + w меньше текущей оценки соседа u, оценка уменьшается — эта операция называется релаксацией. Выбор вершины с наименьшей оценкой обеспечивает куча, и общая сложность составляет O(m log n). Реализация главы не уменьшает ключ записи, уже лежащей в куче, а при улучшении оценки кладёт в кучу новую запись и оставляет старую; при извлечении устаревшая запись, у которой расстояние больше текущей оценки, пропускается. Необходимо, чтобы веса были неотрицательными: иначе вершина, уже снятая с кучи, может получить лучшую оценку, и обоснование алгоритма вместе с оценкой сложности перестаёт выполняться. Для графов с отрицательными рёбрами применяют алгоритм Беллмана — Форда, который требует O(n·m) и обнаруживает циклы отрицательного веса.
Демонстрация:
Дейкстра
01
12 помещений комплекса, 16 проходов с длиной в метрах
02
BFS: путь с наименьшим числом переходов
03
Дейкстра с heapq: путь наименьшей длины
04
Извлечения из кучи и устаревшие записи
Что наблюдается
BFS: 2 перехода и 320 м через зал А. Дейкстра: 180 м за 4 перехода через галерею, лестницу и туннель 1. Из 14 извлечений из кучи 2 оказались устаревшими.
root@lab — запись со стенда
Демонстрация. Сценарий dijkstra.py описывает условный ускорительный комплекс: 12 помещений — пультовая, галерея, лестница, залы, туннели, экспериментальные станции и склад — и 16 проходов между ними с длиной в метрах. Требуется найти путь от пультовой до станции 3. Обход в ширину находит путь с наименьшим числом переходов — два, через зал А, — но длина этого пути 320 м. Алгоритм Дейкстры с кучей heapq находит путь длиной 180 м из четырёх переходов: пультовая, галерея, лестница, туннель 1, станция 3. Оба пути восстанавливаются по ссылкам на предков. Расстояния от пультовой до всех помещений выводятся в порядке возрастания — именно в этом порядке алгоритм окончательно обрабатывает вершины: галерея 40 м, лестница 70, зал Б 110, зал А и туннель 1 по 120 и так далее до зала В, 200 м. Последняя строка показывает работу кучи: 13 улучшений оценок вместе с начальной записью дали 14 записей и 14 извлечений, из которых 2 оказались устаревшими — для двух помещений оценка улучшилась, когда прежняя запись уже лежала в куче. Поиск кратчайшего пути по числу рёбер и по суммарному весу представляет собой две разные задачи, и ответы на них различаются даже на графе из двенадцати вершин.
Топологическая
сортировка
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 при цикле
Для физика
Этапы обработки данных эксперимента: калибровка, выравнивание, треки, события.
Изложение. Пусть ориентированный граф описывает зависимости: ребро u → v означает, что этап u необходимо выполнить раньше v. Компиляция модулей, установка пакетов, этапы обработки данных эксперимента ставят одну задачу — выстроить вершины в линию так, чтобы все рёбра шли слева направо. Такое упорядочение называется топологической сортировкой и существует тогда и только тогда, когда в графе нет циклов, то есть граф является ориентированным ациклическим (DAG). Первый способ основан на обходе в глубину: вершина добавляется в список после того, как обработаны все её потомки, а в конце список разворачивается. В таком виде он цикл не обнаруживает: на графе с циклом он без ошибки возвращает порядок, нарушающий одно из рёбер. Для проверки в обходе в глубину необходимо различать три цвета вершин: вершина, встреченная повторно, пока она ещё находится в обработке, означает цикл. Второй способ — алгоритм Кана — подсчитывает для каждой вершины число входящих рёбер, ставит в очередь вершины без входящих рёбер и, снимая вершину, уменьшает счётчики её соседей; соседи, чей счётчик обнулился, встают в очередь. Если в графе есть цикл, его вершины так и не получат нулевого счётчика, и упорядоченными окажутся не все вершины, — так алгоритм обнаруживает цикл без дополнительных средств. Оба способа требуют O(n + m). В стандартной библиотеке начиная с Python 3.9 есть класс graphlib.TopologicalSorter, который при цикле возбуждает исключение CycleError и сообщает вершины цикла. Отметим, что словарь, передаваемый TopologicalSorter, сопоставляет вершине её предшественников, то есть записывает рёбра в обратную сторону по сравнению со списками смежности функции kahn: тот же словарь без разворота дал бы обратный порядок без какой-либо ошибки.
Демонстрация:
порядок этапов
01
Десять этапов обработки, двенадцать зависимостей
02
DFS главы, проверка рёбер и graphlib
03
Ошибочное ребро треки → выравнивание
04
Два DFS, алгоритм Кана и graphlib на цикле
Что наблюдается
Без цикла все три способа корректны. С циклом DFS без проверки нарушает ребро выравнивание → треки без ошибки; DFS главы с тремя цветами, алгоритм Кана (5 этапов из 10) и graphlib цикл обнаруживают.
root@lab — запись со стенда
Демонстрация. Сценарий toposort.py описывает обработку данных эксперимента десятью этапами — сырые данные, пьедесталы, калибровка энергии, геометрия, выравнивание, кластеры, треки, события, гистограммы и отчёт, — связанными двенадцатью зависимостями. Обход в глубину из главы, различающий три цвета вершин, выдаёт порядок от геометрии и выравнивания через сырые данные, пьедесталы и калибровку к кластерам, трекам, событиям, гистограммам и отчёту; проверка всех рёбер показывает ноль нарушенных зависимостей, и порядок graphlib также корректен. Затем в граф добавляется ошибочная зависимость треки → выравнивание: выравнивание детектора по трекам требует уже восстановленных треков, а восстановление треков — выполненного выравнивания, и образуется цикл. Обход в глубину без проверки цикла, то есть без трёх цветов, без какой-либо ошибки возвращает порядок из всех десяти этапов, в котором нарушено ребро выравнивание → треки: программа, выполняющая этапы в этом порядке, запустила бы восстановление треков без выравнивания. Обход главы с тремя цветами встречает вершину «треки», пока она ещё находится в обработке, и возбуждает исключение ValueError. Алгоритм Кана упорядочивает пять этапов из десяти и оставляет выравнивание, треки, события, гистограммы и отчёт — этапы, лежащие в цикле или зависящие от него. Класс graphlib.TopologicalSorter возбуждает исключение CycleError и называет цикл треки → выравнивание → треки. Таким образом, топологическая сортировка обязана проверять ацикличность графа, иначе противоречивые зависимости проходят незамеченными.
Минимальное
остовное дерево
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 независимых контуров — столько ветвей остаётся вне остова.
Изложение. Остовным деревом связного графа называется подмножество рёбер, связывающее все n вершин и не содержащее циклов, то есть дерево из n − 1 рёбер. Минимальным называется остовное дерево с наименьшим суммарным весом; классическая постановка — соединить все узлы сети кабелем наименьшей длины. Два классических алгоритма жадные. Алгоритм Прима наращивает дерево из одной вершины: на каждом шаге добавляется самое лёгкое ребро, ведущее из построенной части наружу; с кучей он требует O(m log n) и по устройству совпадает с алгоритмом Дейкстры, только минимизируется вес одного ребра, а не расстояние от старта. Алгоритм Краскала сортирует все рёбра по весу и добавляет их по очереди, пропуская те, что замкнули бы цикл. Проверку на цикл выполняет система непересекающихся множеств (DSU): каждая вершина указывает на представителя своего множества, функция find поднимается к корню и попутно сокращает путь, а ребро соединяет две разные компоненты, только если корни его концов различны. Если вдобавок подвешивать меньшее дерево к большему (объединение по рангу или размеру), операции DSU выполняются амортизированно за O(α(n)), где α(n) — обратная функция Аккермана, не превышающая 4 при любом практически встречающемся n. Сложность определяется сортировкой рёбер, O(m log m). Если вес каждого ребра взять со знаком минус, тот же алгоритм найдёт максимальное остовное дерево; так решается задача 6 главы. Если граф несвязен, остовного дерева нет, и алгоритм Прима обнаруживает это по тому, что в дерево вошли не все вершины. Для физика остовное дерево связано с законами Кирхгофа: в цепи из n узлов и m ветвей рёбра остова образуют дерево без контуров, а каждая из 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 — запись со стенда
Демонстрация. Сценарий mst.py решает задачу кабельной разводки: десять крейтов электроники в экспериментальном зале заданы условными координатами в метрах, кабель можно проложить между любыми двумя, и весом ребра служит расстояние; полный граф содержит 45 рёбер. Алгоритм Прима с кучей и алгоритм Краскала с системой непересекающихся множеств находят дерево длиной 36,59 м из девяти рёбер, причём наборы рёбер совпадают. Для сравнения, звезда — кабели от крейта 0 к каждому из остальных — имеет длину 85,57 м, в 2,34 раза больше. Максимальное остовное дерево, найденное алгоритмом Прима с весами со знаком минус, имеет длину 104,57 м. Последние строки возвращаются к электрическим цепям: сетка резисторов 3 × 3 содержит 9 узлов и 12 ветвей, остов, построенный обходом в глубину, — 8 ветвей, и число независимых контуров m − n + 1 = 4 совпадает с числом ветвей вне остова: каждая из них вместе с путём по остову замыкает свой независимый контур. Отметим, что такие контуры не обязаны совпадать с ячейками сетки: для остова сценария два из них обходят по одной ячейке, а два других — две и четыре ячейки; ячейки сетки дают другой независимый набор из четырёх контуров, на котором основан метод контурных токов. Таким образом, два разных жадных алгоритма дают один ответ, а приём со знаком минус переводит задачу о минимуме в задачу о максимуме без изменения алгоритма.
Итоги
лекции
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.
Изложение. Подведём итоги. Хеш-таблица обеспечивает поиск, вставку и удаление в среднем за O(1) при двух условиях: хеш-функция распределяет ключи равномерно, а коэффициент заполнения ограничен константой, что поддерживает перехеширование с амортизированной ценой O(1). Равномерность нарушается закономерностями в ключах — при остатке от деления на число, имеющее общие делители с шагом ключей, — и неудачным основанием полиномиального хеша: при q = 256 и R = 2³² хеш зависит лишь от четырёх последних символов. Встроенная функция hash() подмешивает к хешам строк случайную соль, поэтому порядок обхода множества строк меняется от запуска к запуску, и алгоритм, опирающийся на этот порядок, недетерминирован. Дерево поиска выполняет операции за O(h), а высота зависит от порядка вставки: при случайном порядке она логарифмическая с множителем 2–3, при упорядоченных ключах дерево вырождается в цепочку, и гарантию O(log n) дают только сбалансированные деревья. Куча выдаёт минимум за O(1) и позволяет отобрать k лучших элементов из n за O(n log k) с памятью O(k). Граф хранится списками смежности, а обходы в глубину и в ширину требуют O(n + m). Наконец, кратчайшие пути, топологический порядок и минимальное остовное дерево сводятся к обходам, дополненным кучей или системой непересекающихся множеств, причём топологическая сортировка обязана обнаруживать циклы.
Упражнения
и задание
01
Открытая адресация
длина пробирования при α = 0,5…0,9 против цепочек
02
АВЛ-дерево
повороты и высота на упорядоченных ключах
03
Кластеры через DSU
объединение множеств вместо BFS, сверка с clusters.py
04
Беллман — Форд
и граф, на котором Дейкстра с множеством обработанных вершин ошибается
Пояснение
Задачи глав «Деревья» и «Графы» с автоматической проверкой собраны в тренажёрах Яндекс Контеста.
Для физика
Собственная реализация проверяется сравнением с эталоном: dict, sorted, graphlib, scipy.spatial.
Изложение. Упражнения служат самопроверке. Первое — реализовать хеш-таблицу с открытой адресацией и линейным пробированием и сравнить среднюю длину пробирования при успешном поиске с длиной цепочки в методе цепочек при коэффициенте заполнения от 0,5 до 0,9; теория предсказывает для линейного пробирования (1 + 1/(1 − α))/2 сравнения против 1 + α/2 у цепочек. Второе — реализовать АВЛ-дерево с поворотами и сравнить его высоту на упорядоченных ключах с высотой обычного дерева поиска из демонстрации bst.py. Третье — найти кластеры пиксельного детектора из демонстрации clusters.py с помощью системы непересекающихся множеств вместо обхода в ширину, как в алгоритме Хошена — Копельмана, которым размечают кластеры в задачах перколяции, и сверить число и размеры кластеров. Четвёртое — реализовать алгоритм Беллмана — Форда и построить граф с отрицательным ребром, на котором классическая реализация алгоритма Дейкстры с множеством обработанных вершин даёт неверное расстояние. Задачи глав «Деревья» и «Графы» с автоматической проверкой собраны в тренажёрах Яндекс Контеста, ссылки на которые приведены в этих главах; собственную реализацию во всех случаях удобно проверять сравнением с эталоном из стандартной библиотеки или SciPy.
Источники
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
Изложение. Лекции соответствуют три главы части книги «Алгоритмы и структуры данных»: «Хеш-функции», «Деревья» и «Графы»; задачи глав «Деревья» и «Графы» с автоматической проверкой собраны в тренажёрах, ссылки на которые приведены в этих главах. Строгие доказательства оценок хеширования, деревьев поиска, куч и алгоритмов на графах приведены у Кормена и соавторов, реализации на Python — у Седжвика, Уэйна и Дондеро, а метод умножения и анализ деревьев — у Кнута. Работа Девруа содержит асимптотику высоты случайного дерева поиска, работа Ньюмена и Зиффа — порог перколяции p_c = 0,59274621. PEP 456 описывает хеширование строк в CPython, а рекомендации по хранению паролей сведены в OWASP Password Storage Cheat Sheet. Справочником по модулям hashlib, heapq, bisect и graphlib служит документация Python.
Спасибо за внимание
Вопросы
phys-dev.github.io/soft-dev-book
Изложение. Лекция завершена, а вместе с ней и часть курса об алгоритмах и структурах данных. Вопросы по материалу удобно задавать, опираясь на главы «Хеш-функции», «Деревья» и «Графы» и на демонстрации: все сценарии используют синтетические данные с фиксированным зерном и выполняются за секунды, поэтому любой результат можно повторить и изменить.