open atlas
← Все проекты

algorithms · intermediate · 4d

Union-Find (система непересекающихся множеств)

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

Union-Find (система непересекающихся множеств) — одна из наиболее элегантных структур данных в информатике: три строки сжатия пути превращают обход цепочки O(n) в почти константную амортизированную операцию, а комбинация с объединением по рангу даёт оценку обратной функции Аккермана — фактически O(1) для любого входа, с которым ты когда-либо столкнёшься. Это также одна из наиболее широко применяемых структур: MST Краскала, динамическая связность, сегментация изображений, кластеризация сетей и перколяция — все сводятся к вопросу «находятся ли эти два элемента в одной компоненте?». Проект заставляет понять не только реализацию, но и амортизированный анализ, объясняющий, почему оптимизации так мощно компонуются.

Результат

Класс DSU с объединением по рангу и сжатием пути, чьи find и union работают за амортизированное O(α(n)), плюс реализация Краскала, использующая его для поиска MST взвешенного графа за O(E log E).

Этапы

0/6 · 0%
  1. 01Наивный массив родителей: find обходом вверх, union сменой указателя

    Начни с простейшей корректной реализации: массив `parent`, где `parent[i]` — представитель элемента i, или сам i, если i является корнем. `find(x)` поднимается по указателям родителей до корня — в худшем случае это O(n) шагов, когда структура вырождается в цепочку (union всегда дописывает в хвост). `union(a, b)` вызывает find для обоих, затем указывает один корень на другой. `connected(a, b)` возвращает, равны ли find(a) и find(b). `count()` отслеживает число непересекающихся множеств. Сначала построй и протестируй эту версию: отточи все инварианты до того, как оптимизации скроют логику. Два элемента в одной компоненте тогда и только тогда, когда их значения find равны — это единственная аксиома, которую ты сохранишь во всех последующих изменениях.

    Критерии готовности
    • Свежий DSU(n) возвращает count()===n, и ни одна пара различных элементов не соединена до вызова union.
    • После union(a, b) connected(a, b) истинно, а count() равен n-1; второй union уже соединённых элементов не меняет count().
    Самопроверка

    Покажи цепочку худшего случая для find и путь O(n), который она проходит; senior-ревьюер проверяет, что union корректно обновляет count только при различных корнях и что в наивной версии нет скрытых оптимизаций.

  2. 02Объединение по рангу: деревья остаются неглубокими

    Наивный union всегда произвольно прикрепляет один корень под другой, поэтому n-1 объединений могут построить цепочку глубиной n-1, превращая каждый find в обход O(n). Объединение по рангу предотвращает это: каждый узел несёт `rank` (верхнюю оценку высоты своего поддерева), изначально 0. При слиянии двух деревьев помещай корень меньшего ранга под корень большего, так что комбинированное дерево не глубже наиболее глубокого из двух. При равных рангах повышай один (например, b под a) и увеличивай ранг a на единицу. Это гарантирует, что дерево ранга k содержит минимум 2^k узлов, поэтому максимальная глубина любого дерева на n элементах — O(log n) — find теперь O(log n) в худшем случае, а не O(n). Держи массив рангов рядом с parent; ранги никогда не убывают и не меняются после того, как узел перестаёт быть корнем.

    Критерии готовности
    • Последовательность n-1 объединений никогда не строит дерево глубже O(log n) — проверь, сравнив высоту дерева после построения «злобной» цепочки в наивной версии и с union-by-rank.
    • Ранг увеличивается только при слиянии двух деревьев равного ранга, а ранг узла не меняется после того, как он перестаёт быть корнем.
    Самопроверка

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

  3. 03Сжатие пути: выпрямление traversal-пути

    Объединение по рангу ограничивает высоту дерева O(log n), но можно лучше: каждый раз, когда find(x) проходит цепочку до корня r, переназначи parent каждого узла на этом пути прямо на r. Это сжатие пути (или «полное сжатие пути» / «path halving» — оба варианта допустимы). Следующий вызов find для любого из этих узлов — O(1), потому что они теперь указывают прямо на корень. Сжатие пути не меняет ранги — они остаются статичными верхними оценками, высота дерева не пересчитывается. Совместный эффект union-by-rank и сжатия пути даёт амортизированную стоимость операции O(α(n)), где α — обратная функция Аккермана — для всех практических значений n (до 2^65536) α(n) ≤ 5. Это так близко к константе, как это возможно для известных структур данных с той же выразительностью.

    Критерии готовности
    • После find(x) на глубокой цепочке каждый узел на пути к корню теперь указывает прямо на корень (проверь, инспектируя parent[] после вызова).
    • Представитель, возвращаемый find(x), остаётся стабильным и корректным через множество вызовов union и find — сжатие пути не должно искажать разбиение.
    Самопроверка

    Покажи массив parent[] до и после find() на цепочке длиной 5; senior-ревьюер проверяет, что parent каждого промежуточного узла теперь — корень, и что повторные вызовы find() после этого — O(1).

  4. 04Запросы связности: count, connected, стресс-тест

    Убедившись, что обе оптимизации работают, проверь публичный контракт на adversarial входах. `connected(a, b)` — это просто `find(a) === find(b)` — никогда не реализуй его ручным обходом дерева. `count()` должен уменьшаться ровно на 1 при каждом объединении двух ранее несвязанных элементов и оставаться неизменным, если они уже были связаны. Напиши стресс-тест: начни с n=1000 элементов, применяй случайные объединения и после каждого пакета проверяй, что count равен n минус количество успешных объединений (тех, что реально слили два разных множества). Это инвариант, который ловит ошибки off-by-one в условии защиты union. Также явно проверь транзитивность: после union(0,1) и union(1,2) connected(0,2) должно быть истинным, даже если 0 и 2 никогда не объединялись напрямую.

    Критерии готовности
    • Транзитивность выполняется: после union(0,1) и union(1,2) connected(0,2) истинно; после построения двух компонент по 3 элемента из 6 элементов count()===2.
    • Объединение уже связанных элементов не декрементирует count() — условие защиты срабатывает корректно.
    Самопроверка

    Пройди через union(2, 5), когда 2 и 5 уже в одной компоненте: покажи вызовы find, сравнение корней и ветку no-op; senior-ревьюер проверяет, что count() не декрементируется и ранг не меняется.

  5. 05Амортизированный анализ: измерение O(α(n)) на практике

    Граница обратной функции Аккермана — не просто теоретический курьёз — докажи это эмпирически. Запусти бенчмарк: для n из [1000, 10 000, 100 000, 1 000 000] выполни n случайных объединений, затем n случайных find, и измерь общее время. Если реализация корректна, время должно расти почти линейно с n (а не n log n у версии только с рангом). Критическая проверка — что сжатие пути реально срабатывает: после пакета find на структуре, построенной adversarial последовательными объединениями, убедись, что средняя длина цепочки parent сократилась до почти 1. Это упражнение также раскрывает разницу между worst-case и амортизированным анализом: один find на свежей цепочке всё ещё может быть O(log n), но амортизированно по последовательности из m операций на n элементах итого O(m α(n)) — амортизация по последовательности, а не по одному вызову.

    Критерии готовности
    • Бенчмарк на четырёх порядках величины показывает почти линейный рост времени, и ты можешь сформулировать амортизированную оценку O(α(n)) против worst-case оценки одного вызова O(log n).
    • После пакета find на adversarially построенной структуре средняя длина цепочки parent ≤ 2 — подтверждая, что сжатие пути выровняло дерево.
    Самопроверка

    Объясни, почему α(n) ≤ 5 для всех практических n, и почему время на операцию «ощущается» константным; senior-ревьюер проверяет, что ты различаешь амортизированное-по-последовательности и worst-case-на-вызов, и можешь назвать реальный сценарий, когда один find всё ещё медленный (первый доступ после многих последовательных объединений).

  6. 06Применение: MST Краскала (или порог перколяции)

    Примени DSU к реальной задаче. Алгоритм Краскала находит минимальное остовное дерево взвешенного неориентированного графа за O(E log E): отсортируй все рёбра по весу, затем итерируй; для каждого ребра (u, v, w), если u и v в разных компонентах (не связаны), добавь ребро в MST и выполни union(u, v). Останови, когда count() равен 1 (все узлы связаны) или все рёбра исчерпаны. DSU — единственная причина эффективности Краскала — проверка связности и слияние оба почти O(1). Как альтернатива (или дополнение), реализуй симуляцию перколяции: на сетке n×n открывай ячейки по одной в случайном порядке и используй DSU для обнаружения первого момента, когда верхний и нижний ряды становятся связанными — это порог перколяции, классическая демонстрация того, что DSU превращает иначе O(n^4) симуляцию в O(n^2 α(n^2)).

    Критерии готовности
    • Алгоритм Краскала выдаёт корректный вес MST на минимум трёх вручную составленных графах, включая несвязный граф, где MST охватывает только связную компоненту.
    • Ты можешь назвать итоговую сложность Краскала с DSU (O(E log E) определяется сортировкой, а не операциями union-find) и объяснить, почему почти O(1) DSU делает это возможным.
    Самопроверка

    Пройди Краскала на взвешенном графе из 5 узлов и 7 рёбер шаг за шагом, показывая каждую пару find и вызов union; senior-ревьюер проверяет, что ты пропускаешь ребро, когда оба конца уже связаны, и что MST содержит ровно n-1 рёбер для связного графа.

Стартер

  • README.md
  • src/dsu.ts
  • test/dsu.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Рубрика

Джуниор Миддл Сеньор
Корректность find/union DSU инициализируется корректно, find поднимается до корня, union связывает корни, connected сравнивает результаты find — но без оптимизаций adversarial входы деградируют до O(n) на операцию. Оба — union by rank и сжатие пути — реализованы и корректно компонуются: find возвращает стабильного представителя, count() точно отслеживает слияния, а connected никогда не реализован повторным обходом дерева. Ты можешь доказать, что две оптимизации ортогональны (сжатие пути не влияет на семантику ранга), объяснить амортизированную оценку O(α(n)) с первых принципов и назвать один сценарий, когда find всё ещё дорог (первый доступ к глубокой цепочке до срабатывания сжатия).
Балансировка (объединение по рангу/размеру) Union всегда указывает один корень под другой без учёта глубины — цепочка объединений строит вырожденное линейное дерево. Union прикрепляет корень меньшего ранга под корень большего; ранги увеличиваются только при слиянии корней равного ранга; высота результирующего дерева доказуемо O(log n). Ты можешь построить worst-case последовательность рангов (все слияния равных рангов), показать, что она достигает высоты log₂ n, и объяснить, почему ранги — верхняя оценка высоты, а не точная высота — особенно после того, как сжатие пути выровняло поддеревья без обновления их рангов.
Сжатие пути и амортизированная сложность Без сжатия пути — повторные find на одной и той же глубокой цепочке каждый раз O(depth). Полное сжатие пути (или path halving) срабатывает при find, и массив parent заметно уплощается после первого обхода — последующие find по тем же узлам — O(1). Ты можешь эмпирически продемонстрировать амортизированную оценку (почти линейный рост с n), назвать обратную функцию Аккермана α(n) и её практическую границу ≤ 5, и объяснить, почему комбинация ранга и сжатия строго лучше каждого по отдельности — ранг ограничивает глубину, так что сжатие редко встречает длинные цепочки; сжатие делает большинство будущих find тривиальными.
Эталонный разбор (спойлер)

Почему union-by-rank и сжатие пути компонуются: ранг ограничивает глубину дерева O(log n), поэтому начальные цепочки, которые сжатие пути выравнивает, имеют длину не более O(log n). Сжатие пути затем сводит будущие затраты find к O(1). Взаимодействие — ранг ограничивает то, что видит сжатие, сжатие гарантирует, что пессимистическая оценка ранга достигается редко — и объясняет, почему комбинированный анализ даёт O(α(n)), а не O(log n) + O(1).

Сжатие пути не должно искажать разбиение: после выравнивания каждый узел в цепочке всё ещё имеет того же представителя (корень), просто достижимого за один шаг. Инвариант корректности: find(x) возвращает корень компоненты x; сжатие пути сохраняет это, потому что только перенаправляет указатели parent на корень, никогда не пересекая компоненты.

Корректность MST Краскала опирается на свойство разреза: для любого разреза графа (разбиение на два непустых множества вершин) ребро минимального веса, пересекающее разрез, принадлежит некоторому MST. Краскал обрабатывает рёбра в порядке веса и добавляет каждое ребро, если оно не образует цикл (обнаруживается через connected(u, v)), что жадное применение свойства разреза гарантирует построение MST.

DSU с откатом меняет сжатие пути на возможность отмены: без сжатия ранги — точные высоты, а массив parent можно восстановить из стека троек (узел, old_parent, old_rank). Это поддерживает офлайн-алгоритмы типа «офлайн динамическая связность», где рёбра и добавляются, и удаляются, используя декомпозицию дерева отрезков по времени. Сжатие пути делает откат невозможным, потому что операция выравнивания затрагивает много узлов и не может быть дёшево обращена.

Обратная функция Аккермана α(n) растёт медленнее любого итерированного логарифма: α(n) ≤ 4 для n ≤ 2^(2^(2^(65536))), что превышает число атомов в наблюдаемой вселенной на много порядков. На практике α(n) ≤ 4 для любого входа, с которым ты когда-либо столкнёшься. Оценка точная — без обеих оптимизаций вместе ни одна структура данных не может решить задачу union-find за O(α(n)) амортизированно на операцию.

Сделай по-сеньорски

  • Реализуй объединение по размеру вместо ранга (прикрепляй меньшее дерево под большее по числу узлов, а не по оценке высоты) и докажи, что оба дают высоту дерева O(log n) — объясни, почему размер часто предпочтительнее на практике для взвешенных эвристик объединения.
  • Реализуй DSU с откатом (link-by-rank без сжатия пути) для использования в офлайн-алгоритмах: операции union можно отменять в обратном порядке, восстанавливая массивы parent и rank через стек — докажи корректность и объясни, почему сжатие пути делает откат невозможным.
  • Добавь взвешенные рёбра в DSU («small-to-large» или «union с потенциалом»), чтобы find возвращал не только представитель, но и накопленный вес пути от x до его корня — используй это для ответов на запросы «сумма элементов в одном множестве» за O(α(n)) на запрос.
  • Примени DSU к динамической связности: дана последовательность добавлений рёбер и запросов связности офлайн, используй link-cut tree или персистентный DSU (с откатом) для ответа на все запросы за O((n + q) log n) вместо наивного O(n·q) BFS на запрос.

Навыки

disjoint set unionunion by rankpath compressionamortized analysisKruskal MST

Рекомендуемый стек

typescript