В информатике структура данных непересекающихся множеств , также называемая структурой данных «объединение–поиск» или «слияние–поиск множеств» , представляет собой структуру данных , которая хранит коллекцию непересекающихся (непересекающихся) множеств . Эквивалентно, она хранит разбиение множества на непересекающиеся подмножества . Она предоставляет операции для добавления новых множеств, слияния множеств (замены их их объединением ) и поиска представительного члена множества. Последняя операция позволяет эффективно выяснить, находятся ли любые два элемента в одном и том же или разных множествах.
Хотя существует несколько способов реализации структур данных непересекающихся множеств, на практике они часто отождествляются с конкретной реализацией, называемой лесом непересекающихся множеств . Это специализированный тип леса , который выполняет объединения и поиск за почти постоянное амортизированное время . Для выполнения последовательности из m операций сложения, объединения или поиска в лесу непересекающихся множеств с n узлами требуется общее время O ( m α( n )) , где α( n ) — чрезвычайно медленно растущая обратная функция Аккермана . Леса непересекающихся множеств не гарантируют такую производительность на основе каждой операции. Отдельные операции объединения и поиска могут занять больше времени, чем константа, умноженная на α( n ) , но каждая операция заставляет лес непересекающихся множеств подстраиваться так, чтобы последующие операции выполнялись быстрее. Леса непересекающихся множеств являются как асимптотически оптимальными , так и практически эффективными.
Структуры данных непересекающихся множеств играют ключевую роль в алгоритме Краскала для поиска минимального остовного дерева графа. Важность минимальных остовных деревьев означает, что структуры данных непересекающихся множеств лежат в основе широкого спектра алгоритмов. Кроме того, структуры данных непересекающихся множеств также имеют приложения к символьным вычислениям, а также в компиляторах, особенно для задач распределения регистров .
Леса непересекающихся множеств были впервые описаны Бернардом А. Галлером и Майклом Дж. Фишером в 1964 году. [2] В 1973 году их временная сложность была ограничена до , итерированного логарифма , Хопкрофтом и Ульманом . [ 3] В 1975 году Роберт Тарьян был первым, кто доказал верхнюю границу ( обратную функцию Аккермана ) временной сложности алгоритма. [4] Он также доказал, что она узкая. В 1979 году он показал, что это нижняя граница для определенного класса алгоритмов, которые включают структуру Галлера-Фишера. [5] В 1989 году Фредман и Сакс показали, что (амортизированные) слова битов должны быть доступны любой структуре данных непересекающихся множеств за одну операцию, [6] тем самым доказав оптимальность структуры данных в этой модели.
В 1991 году Галил и Итальяно опубликовали обзор структур данных для непересекающихся множеств. [7]
В 1994 году Ричард Дж. Андерсон и Хизер Уолл описали параллельную версию Union–Find, которая никогда не должна блокироваться. [8]
В 2007 году Сильвен Коншон и Жан-Кристоф Филлиатр разработали полуперсистентную версию структуры данных непересекающегося леса и формализовали ее корректность с помощью помощника доказательства Coq . [9] «Полуперсистентный» означает, что предыдущие версии структуры эффективно сохраняются, но доступ к предыдущим версиям структуры данных делает более поздние недействительными. Их самая быстрая реализация достигает производительности почти такой же эффективной, как и неперсистентный алгоритм. Они не выполняют анализ сложности.
Также были рассмотрены варианты непересекающихся структур данных с лучшей производительностью на ограниченном классе задач. Габов и Тарьян показали, что если возможные объединения ограничены определенным образом, то возможен действительно линейный алгоритм времени. [10]
Каждый узел в непересекающемся лесу состоит из указателя и некоторой вспомогательной информации, либо размера, либо ранга (но не того и другого). Указатели используются для создания родительских деревьев указателей , где каждый узел, который не является корнем дерева, указывает на своего родителя. Чтобы отличить корневые узлы от других, их родительские указатели имеют недопустимые значения, такие как циклическая ссылка на узел или контрольное значение. Каждое дерево представляет набор, хранящийся в лесу, причем членами набора являются узлы в дереве. Корневые узлы предоставляют представителей наборов: два узла находятся в одном наборе тогда и только тогда, когда корни деревьев, содержащих узлы, равны.
Узлы в лесу могут храниться любым удобным для приложения способом, но распространенным методом является хранение их в массиве. В этом случае родители могут быть указаны их индексом массива. Каждая запись массива требует Θ(log n ) бит памяти для родительского указателя. Для остальной части записи требуется сопоставимый или меньший объем памяти, поэтому количество бит, требуемых для хранения леса, составляет Θ( n log n ) . Если реализация использует узлы фиксированного размера (тем самым ограничивая максимальный размер леса, который может быть сохранен), то необходимое хранилище линейно зависит от n .
Структуры данных непересекающихся множеств поддерживают три операции: создание нового множества, содержащего новый элемент; поиск представителя множества, содержащего заданный элемент; и слияние двух множеств.
Операция MakeSet
добавляет новый элемент в новый набор, содержащий только новый элемент, и новый набор добавляется в структуру данных. Если же структура данных рассматривается как раздел набора, то MakeSet
операция расширяет набор, добавляя новый элемент, и расширяет существующий раздел, помещая новый элемент в новый подмножество, содержащее только новый элемент.
В непересекающемся лесу MakeSet
инициализирует родительский указатель узла и размер или ранг узла. Если корень представлен узлом, который указывает на себя, то добавление элемента можно описать с помощью следующего псевдокода:
функция MakeSet( x ) — если x еще не находится в лесу , то x .parent := x x .size := 1 // если узлы хранят размер x .rank := 0 // если узлы хранят ранг end if end function
Эта операция имеет постоянную временную сложность. В частности, инициализация непересекающегося леса с n узлами требует O ( n ) времени.
Отсутствие назначенного узлу родителя означает, что узел отсутствует в лесу.
На практике, MakeSet
должна предшествовать операция, которая выделяет память для хранения x . Пока выделение памяти является амортизированной постоянной операцией времени, как это происходит для хорошей реализации динамического массива , это не изменяет асимптотическую производительность леса случайных множеств.
Операция Find
следует по цепочке родительских указателей от указанного узла запроса x до тех пор, пока не достигнет корневого элемента. Этот корневой элемент представляет набор, к которому принадлежит x , и может быть самим x . Find
возвращает корневой элемент, которого он достигает.
Выполнение Find
операции представляет собой важную возможность для улучшения леса. Время в Find
операции тратится на погоню за родительскими указателями, поэтому более плоское дерево приводит к более быстрым Find
операциям. Когда Find
выполняется a, нет более быстрого способа достичь корня, чем следовать каждому родительскому указателю последовательно. Однако родительские указатели, посещенные во время этого поиска, могут быть обновлены, чтобы указывать ближе к корню. Поскольку каждый элемент, посещенный на пути к корню, является частью одного и того же набора, это не изменяет наборы, хранящиеся в лесу. Но это ускоряет будущие Find
операции не только для узлов между узлом запроса и корнем, но и для их потомков. Это обновление является важной частью амортизированной гарантии производительности леса непересекающихся наборов.
Существует несколько алгоритмов Find
, которые достигают асимптотически оптимальной временной сложности. Одно семейство алгоритмов, известное как сжатие пути , делает каждый узел между узлом запроса и корнем указателем на корень. Сжатие пути может быть реализовано с помощью простой рекурсии следующим образом:
функция Find( x ) — если x .parent ≠ x , то x .parent := Find( x .parent) возвращает x .parent иначе возвращает x конец, если функция end
Эта реализация делает два прохода, один вверх по дереву и один обратно вниз. Она требует достаточно рабочей памяти для хранения пути от узла запроса к корню (в приведенном выше псевдокоде путь неявно представлен с помощью стека вызовов). Это можно уменьшить до постоянного объема памяти, выполнив оба прохода в одном направлении. Реализация постоянной памяти проходит от узла запроса к корню дважды, один раз для поиска корня и один раз для обновления указателей:
функция Find( x ) is root := x while root .parent ≠ root do root := root .parent end while пока x .parent ≠ root do parent := x .parent x .parent := root x := parent end while функция возврата корня конца
Тарьян и Ван Лиувен также разработали однопроходные Find
алгоритмы, которые сохраняют ту же самую сложность в худшем случае, но более эффективны на практике. [4] Они называются расщеплением пути и делением пути пополам. Оба они обновляют родительские указатели узлов на пути между узлом запроса и корнем. Расщепление пути заменяет каждый родительский указатель на этом пути указателем на прародителя узла:
функция Find( x ) is while x .parent ≠ x do ( x , x .parent) := ( x .parent, x .parent.parent) end while return x end function
Деление пути пополам работает аналогично, но заменяет только каждый второй родительский указатель:
функция Find( x ) is while x .parent ≠ x do x .parent := x .parent.parent x := x .parent end while return x end function
Операция заменяет множество, содержащее x , и множество, содержащее y , их объединением. first используется для определения корней деревьев, содержащих x и y . Если корни одинаковы, то больше ничего делать не нужно. В противном случае два дерева должны быть объединены. Это делается либо установкой родительского указателя корня x на y , либо установкой родительского указателя корня y на x .Union(x, y)
Union
Find
Выбор того, какой узел станет родительским, влияет на сложность будущих операций на дереве. Если это сделать небрежно, деревья могут стать чрезмерно высокими. Например, предположим, что Union
всегда делал дерево, содержащее x, поддеревом дерева, содержащего y . Начнем с леса, который только что был инициализирован элементами , и выполним , , ..., . Полученный лес содержит единственное дерево, корень которого n , а путь от 1 до n проходит через каждый узел в дереве. Для этого леса время выполнения составляет O ( n ) .Union(1, 2)
Union(2, 3)
Union(n - 1, n)
Find(1)
В эффективной реализации высота дерева контролируется с помощью union by size или union by rank . Оба варианта требуют, чтобы узел хранил информацию, помимо указателя на родителя. Эта информация используется для принятия решения о том, какой корень станет новым родителем. Обе стратегии гарантируют, что деревья не станут слишком глубокими.
В случае объединения по размеру узел сохраняет свой размер, который является просто числом его потомков (включая сам узел). Когда деревья с корнями x и y объединяются, узел с большим числом потомков становится родителем. Если два узла имеют одинаковое число потомков, то любой из них может стать родителем. В обоих случаях размер нового родительского узла устанавливается равным его новому общему числу потомков.
функция Union( x , y ) is // Заменить узлы корнями x := Find( x ) y := Find( y ) если x = y тогда return // x и y уже находятся в одном и том же множестве end if // При необходимости поменяйте местами переменные, чтобы гарантировать, что // x имеет по крайней мере столько же потомков, сколько и y if x .size < y .size then ( x , y ) := ( y , x ) end if // Сделать x новым корнем y .parent := x // Обновить размер x x .size := x .size + y .size end function
Число бит, необходимое для хранения размера, очевидно, равно числу бит, необходимому для хранения n . Это добавляет постоянный фактор к требуемому объему памяти леса.
Для объединения по рангу узел сохраняет свой ранг , который является верхней границей его высоты. Когда узел инициализируется, его ранг устанавливается равным нулю. Чтобы объединить деревья с корнями x и y , сначала сравните их ранги. Если ранги различны, то дерево с большим рангом становится родителем, а ранги x и y не изменяются. Если ранги одинаковы, то любой из них может стать родителем, но ранг нового родителя увеличивается на единицу. Хотя ранг узла явно связан с его высотой, хранение рангов более эффективно, чем хранение высот. Высота узла может меняться во время операции Find
, поэтому хранение рангов позволяет избежать дополнительных усилий по поддержанию правильной высоты. В псевдокоде объединение по рангу выглядит следующим образом:
функция Union( x , y ) is // Заменить узлы корнями x := Find( x ) y := Find( y ) если x = y тогда return // x и y уже находятся в одном и том же множестве end if // При необходимости переименуйте переменные, чтобы гарантировать, что // x имеет ранг не меньше, чем у y if x .rank < y .rank then ( x , y ) := ( y , x ) end if // Сделать x новым корнем y .parent := x // При необходимости увеличить ранг x if x .rank = y .rank then x .rank := x .rank + 1 end if end function
Можно показать, что каждый узел имеет ранг или меньше. [11] Следовательно, каждый ранг может быть сохранен в O (log log n ) бит, а все ранги могут быть сохранены в O ( n log log n ) бит. Это делает ранги асимптотически пренебрежимо малой частью размера леса.
Из приведенных выше реализаций ясно, что размер и ранг узла не имеют значения, если только узел не является корнем дерева. Как только узел становится дочерним, его размер и ранг больше никогда не будут доступны.
Реализация леса с непересекающимися множествами Find
, в которой не обновляются родительские указатели и Union
не делается попыток контролировать высоту деревьев, может иметь деревья высотой O ( n ) . В такой ситуации операции Find
и Union
требуют O ( n ) времени.
Если реализация использует только сжатие пути, то последовательность из n MakeSet
операций, за которой следуют до n − 1 Union
операций и f Find
операций, имеет худшее время выполнения . [11]
Использование объединения по рангу, но без обновления родительских указателей во время Find
, дает время выполнения for m операций любого типа, до n из которых являются операциями. [11]MakeSet
Комбинация сжатия пути, разбиения или деления пополам с объединением по размеру или по рангу сокращает время выполнения для m операций любого типа, до n из которых являются MakeSet
операциями, до . [4] [5] Это делает амортизированное время выполнения каждой операции . Это асимптотически оптимально, что означает, что каждая структура данных непересекающегося множества должна использовать амортизированное время на операцию. [6] Здесь функция является обратной функцией Аккермана . Обратная функция Аккермана растет необычайно медленно, поэтому этот коэффициент равен 4 или меньше для любого n , которое фактически может быть записано в физической вселенной. Это делает операции непересекающегося множества практически амортизированными за постоянное время.
Точный анализ производительности непересекающегося леса довольно сложен. Однако существует гораздо более простой анализ, который доказывает, что амортизированное время для любых m Find
или Union
операций в непересекающемся лесу, содержащем n объектов, равно O ( m log * n ) , где log * обозначает итерированный логарифм . [12] [13] [14] [15]
Лемма 1: По мере того, как функция find следует по пути к корню, ранг узла, с которым она сталкивается, увеличивается.
Мы утверждаем, что при применении операций Find и Union к набору данных этот факт остается верным с течением времени. Изначально, когда каждый узел является корнем своего собственного дерева, это тривиально верно. Единственный случай, когда ранг узла может быть изменен, — это когда применяется операция Union by Rank. В этом случае дерево с меньшим рангом будет присоединено к дереву с большим рангом, а не наоборот. И во время операции find все посещенные по пути узлы будут присоединены к корню, который имеет больший ранг, чем его потомки, поэтому эта операция также не изменит этот факт.
Лемма 2: Узел u , являющийся корнем поддерева ранга r, имеет по крайней мере узлов.
Изначально, когда каждый узел является корнем своего собственного дерева, это тривиально верно. Предположим, что узел u с рангом r имеет не менее 2 r узлов. Затем, когда два дерева с рангом r объединяются с помощью операции Union by Rank, получается дерево с рангом r + 1 , корень которого имеет не менее узлов.
Лемма 3: Максимальное количество узлов ранга r не превышает
Из леммы 2 мы знаем, что узел u, являющийся корнем поддерева с рангом r, имеет не менее узлов. Мы получим максимальное количество узлов ранга r , когда каждый узел с рангом r является корнем дерева, имеющего ровно узлов. В этом случае количество узлов ранга r равно
В любой конкретной точке выполнения мы можем сгруппировать вершины графа в «корзины» в соответствии с их рангом. Мы определяем диапазоны корзин индуктивно следующим образом: Корзина 0 содержит вершины ранга 1. Корзина 1 содержит вершины рангов 2 и 3. В общем случае, если B - я корзина содержит вершины с рангами из интервала , то (B+1)-я корзина будет содержать вершины с рангами из интервала
Для пусть . Тогда ведро будет иметь вершины с рангами в интервале .
Мы можем сделать два замечания относительно размеров ведер.
Пусть F представляет собой список выполненных операций «поиска», и пусть
Тогда общая стоимость m находок составит
Поскольку каждая операция поиска совершает ровно один обход, который приводит к корню, мы имеем T 1 = O ( m ) .
Кроме того, из приведенной выше границы количества блоков следует, что T 2 = O ( m log * n ) .
Для T 3 предположим, что мы проходим по ребру от u до v , где u и v имеют ранг в корзине [ B , 2 B − 1] и v не является корнем (во время этого прохода, в противном случае проход учитывался бы в T 1 ). Зафиксируем u и рассмотрим последовательность , которая играет роль v в различных операциях поиска. Из-за сжатия пути и отсутствия учета ребра к корню эта последовательность содержит только разные узлы, и из-за леммы 1 мы знаем, что ранги узлов в этой последовательности строго возрастают. Поскольку оба узла находятся в корзине, мы можем заключить, что длина k последовательности (количество раз, когда узел u присоединен к другому корню в той же корзине) не превышает числа рангов в корзинах B , то есть не более
Поэтому,
Из наблюдений 1 и 2 можно сделать вывод, что
Поэтому,
Худшее время операции Find
в деревьях с объединением по рангу или объединением по весу равно (т. е. оно равно и эта граница жесткая). В 1985 году Н. Блюм дал реализацию операций, которая не использует сжатие пути, но сжимает деревья в течение . Его реализация выполняется за время на операцию, [16] и, таким образом, по сравнению со структурой Галлера и Фишера она имеет лучшее худшее время на операцию, но худшее амортизированное время. В 1999 году Альструп и др. дали структуру, которая имеет оптимальное худшее время вместе с амортизированным временем обратного Аккермана. [17]
Обычная реализация в виде непересекающихся лесов не реагирует благоприятно на удаление элементов, в том смысле, что время для Find
не улучшится в результате уменьшения количества элементов. Однако существуют современные реализации, которые допускают удаление за постоянное время и где ограничение по времени для Find
зависит от текущего количества элементов [18] [19]
Структуры данных непересекающихся множеств моделируют разбиение множества , например, для отслеживания связанных компонентов неориентированного графа . Затем эту модель можно использовать для определения того, принадлежат ли две вершины одному и тому же компоненту или приведет ли добавление ребра между ними к циклу. Алгоритм Union–Find используется в высокопроизводительных реализациях объединения . [20]
Эта структура данных используется библиотекой Boost Graph для реализации ее функциональности Incremental Connected Components. Она также является ключевым компонентом в реализации алгоритма Крускала для поиска минимального остовного дерева графа.
Алгоритм Хошена-Копельмана использует в своей работе метод Union-Find.
Теорема 5: Любая реализация CPROBE(log n ) задачи объединения множеств требует Ω( m α( m , n )) времени для выполнения m операций Find и n −1 операций Union, начиная с n одноэлементных наборов.