서로소 집합 자료 구조

서로소 집합 연결 리스트 (Disjoint-set linked lists)

서로소 집합 데이터 구조는 간단히 각 집합을 표현하기 위하여 연결 리스트(linked list)를 사용한다. 각 리스트의 헤드(head) 부분에는 해당 집합의 대표 원소를 저장한다.
MakeSet은 한 원소만을 가지는 리스트를 생성한다. 유니온은 두 리스트를 붙이는 연산을 수행하며, 이때 한 리스트의 헤드 부분이 다른 리스트의 꼬리를 가리켜 상수-시간(constant-time) 연산을 수행한다. 파인드 연산시 특정 원소로부터 리스트의 헤드까지 반대 방향으로 탐사해야 하며, 그렇기 때문에 선형 시간(linear time) 또는 O(n) 시간을 요구한다는 단점을 가진다.
이 단점은 각 연결 리스트 노드에 헤드를 가리키는 포인터를 포함시켜 해결 할 수 있다.
이 포인터를 통하여 직접적으로 대표 원소를 참조 할 수 있으며, 그 때문에 파인드 연산은 상수 시간이 걸린다. 그러나 이 경우 유니온 연산시 덧붙여지는 각 원소들이 새로운 리스트의 헤드를 가리키도록 갱신해야하기 때문에 O(n) 시간을 요구하게 된다.
만약 각 리스트의 길이를 추적 할 수 있다면, 항상 짧은 리스트를 긴 리스트에 덧붙이면서 요구 시간(required time) 성능을 향상시킬 수 있다. 이 방법은 가중치-유니온 휴리스틱(weighted-union heuristic) 라 불리며, n개의 원소들의 m개의 연이은 메이크셋, 유니온, 그리고 파인드 연산을 수행할 경우 O(m + nlog n) 시간을 요구한다. 점근적으로(asymptotically) 더 빠른 연산을 위해서 다른 자료 구조가 필요하다.

단순한 접근법 분석

지금부터 위 연산이  제한을 갖는다는 것을 설명하겠다.
만약 여러 리스트들이 있고 각 리스트의 노드들은 속한 리스트의 이름과 원소의 개수 정보를 저장하는 객체를 포함한다고 가정하다. 그리고 모든 리스트들의 전체 원소 개수가  개 있다고 가정하자. 이 리스트들 중에 어느 두 개의 리스트를 병합하고, 각 리스트들이 속한 리스트 이름이 유지되도록 노드의 업데이트 하려고 한다. 만약 리스트 의 길이가 리스트 B보다 길 경우 병합하는 규칙은 의 원소들을  안으로 병합 후 에 속했던 원소들의 정보를 갱신하는 것이다.
리스트 에서 특정 원소 를 정하자. 우리는 최악의 경우 얼마나 많이 는 속한 리스트의 이름을 갱신하는지 계산 하려고 한다. 원소 가 속한 리스트의 길이보다 길거나 같은 리스트와 병합을 할 때 는 속한 리스트 이름을 갱신한다. 갱신이 일어날 때마다 가 속한 리스트의 크기는 최소 두 배 이상 증가한다. 그러므로 질문은 다음과 같다. “크기가 n이기 이전에 얼마나 자주 숫자가 두 배가 되는가?” (그러면 를 포함하는 리스트는 모든 개의 요소들을 포함할 것이다). 답은 정확히 이다. 그래서 특정 리스트 안의 특정 한 원소는 최악의 경우  번의 갱신이 필요할 것이다. 그러므로 개의 원소를 가지는 한 리스트는 최악의 경우  시간이 걸린다. 이 구조에서 각 노드는 원소가 속한 리스트의 이름을 포함하기 때문에, 파인드 연산은  시간이 걸린다.
마찬가지로 위의 주장은 트리 자료구조의 병합에도 유의하며 다음 장에서 기술하였다.
추가적으로 이는 이진 힙(binomial heap) 과 피보나치 힙(Fibonacci heap) 자료구조에서 여러 가지 연산들의 시간을 분석하는데 활용된다.

서로소 집합 숲 (Disjoint-set forest)

서로소 집합 숲(Disjoint-set forest)는 각 집합이 트리로 표현되는 자료구조이며 각 노드들은 부모 노드를 참조한다. 이 구조는 1964년 Bernard A. Galler와 Michael J. Fischer가 처음으로 고안하였으며, 이후 정밀한 분석은 수년이 걸렸다.
서로소 집합 숲에서 각 집합의 대표는 해당 트리의 루트(root) 노드이다. 파인드 연산은 특정 노드에서 루트 노드에 도달할 때까지 부모 노드를 따라 참조한다. 유니온은 두 개의 트리를 하나로 병합하는 연산으로 한 루트 노드를 다른 루트 노드에 연결한다.
이 단순한 서로소 집합 숲 방법은 매우 불균형적인 트리를 생성 할 수 있기 때문에, 연결 리스트 방법과 다를 바 없다.

단순한 형태

위의 연산들의 구현은 아래와 같다:
 function MakeSet(x)
     x.parent := x
 function Find(x)
     if x.parent == x
        return x
     else
        return Find(x.parent)
 function Union(x, y)
     xRoot := Find(x)
     yRoot := Find(y)
     xRoot.parent := yRoot
단순한 형태의 연산들은 매우 불균형적인 트리를 생성하기 때문에 성능 측면에서 연결 리스트 방법과 다를 바 없다.

유니온 바이 랭크(Union by rank)

다음 두 방법으로 위 방법의 성능을 향상시킬 수 있다.
첫 번째는 유니온 바이 랭크(union by rank) 방법으로 항상 작은 트리를 큰 트리 루트에 붙이는 방법이다. 트리의 깊이(depth)가 실행 시간에 영향을 주기 때문에, 깊이가 적은 트리를 깊이가 더 깊은 트리의 루트 아래에 추가한다. 그러면 두 트리의 깊이가 같을 경우에만 깊이가 증가한다. 만약 경로 압축(path compression)을 활용 한다면 깊이가 같을 경우 알고리즘 동작이 멈추기 때문에 깊이 대신 랭크(rank)라는 용어를 사용한다. 한 개의 원소를 가지는 트리는 랭크 0을 가지고, 같은 랭크 r을 가지는 두 트리가 합쳐질 경우 랭크 r+1의 트리가 만들어 진다. 이 기술을 활용하여 유니온 또는 파인드 연산은 최악의 경우 O(log n) 실행 시간을 가진다. 향상된 메이크셋과 유니온 연산의 의사코드는 다음과 같다:
 function MakeSet(x)
     x.parent := x
     x.rank   := 0
 function Union(x, y)
     xRoot := Find(x)
     yRoot := Find(y)
     // if x and y are already in the same set (i.e., have the same root or representative)
     if xRoot == yRoot
         return
     // x and y are not in same set, so we merge them
     if xRoot.rank < yRoot.rank
         xRoot.parent := yRoot
     else if xRoot.rank > yRoot.rank
         yRoot.parent := xRoot
     else
         yRoot.parent := xRoot
         xRoot.rank := xRoot.rank + 1

경로 압축

두 번째 방법은 경로 압축(path compression)으로 파인드 연산을 수행 할 때마다 트리의 구조를 평평하게 만드는 방법이다. 이 방법은 루트 노드까지 순회중 방문한 각 노드들이 직접 루트 노드를 가리키도록 갱신하는 것으로, 모든 노드들은 같은 대표 노드를 공유하게 된다. 트리 윗부분을 재귀적으로 순회하면서 각 노드들이 루트 노드를 부모 노드로 참조하도록 갱신한다.
최종적으로 생성된 트리는 보다 평평하며, 이는 해당 원소뿐만 아니라 이들을 직/간접적으로 참조하는 연산들의 속도를 빠르게 해준다. 다음은 향상된 파인드 연산이다:
 function Find(x)
     if x.parent != x
        x.parent := Find(x.parent)
     return x.parent
이 두 기술들은 상호 보완 및 적용 가능하며, 각 연산마다 분할상환 시간(amortized time)은 단지 이며 여기서 는 함수 의 역 함수이다. (는 극도로 빠르게 성장하는 아커만 함수 (Ackermann function) 이다. 는 이 함수의 역함수이기 때문에,  는 의 모든 원격 실용적인 값(remotely practical value)으로 5보다 작다.
실제로 이는 점근적 최적값(asymptotically optimal)이다: Fredman 과 Saks는 1989년에 서로소 집합 자료구조의 각 연산마다  단어들을 접근 할 수 있다고 보였다.

응용

서로소 집합 자료구조는 집합의 분할을 모델링한다. 예를 들어 이 자료구조를 활용하여 무향 그래프(undirected graph)의 연결된 요소들을 추적 할 수 있다. 그리고 이 모델은 두 개의 꼭짓점(vertex)들이 같은 요소에 속하는지 또는 그 꼭짓점들을 엣지(edge)로 연결 할 때 싸이클(cycle)이 발생하는지 등을 결정 할 수 있다. 유니온-파인드 알고리즘은 고성능 단일화(unification)의 구현에 활용된다.
이 데이터 구조는 점진적으로 연결된 요소(Incremental Connected Components) 기능을 구현하기 위하여 Boost Graph Library에서 활용하고 있다. 또한 쿠르스칼(Kruskal) 알고리즘에서 그래프의 최소 신장 트리(minimum spanning tree)를 찾는데 활용된다.
위의 서로소 집합 숲 구현은 엣지의 삭제는 지원하지 않는다(심지어 경로 압축 또는 랭크 휴리스틱(rank heuristic)이 없이도).

역사

서로소 집합 숲에 사용된 아이디어는 오랫동안 익숙하지만, Robert Tarjan은 1975년 처음으로 역 아커만 함수에 관하여 상한(upper bound)을 증명하였다. 이때까지 각 연산별 시간에 대하여 최적 경계(best bound)는 O(log* n)이고 (Hopcroft와 Ullman가 증명) 여기서 n은 알고리즘의 반복 횟수이다. (하지만 역 아커만 함수 보다 그다지 느리지 않다)
Tarjan 과 Van Leeuwen은 최악 경우 복잡도(worst-case complexity)는 유지하면서 실제 더 효율적인 한 경로(one-pass) 파인드 알고리즘을 개발 하였다.
2007년에는 Sylvain Conchon과 Jean-Christophe Filliâtre은 서로소 집합 숲 자료구조의 지속 버전을 개발하였다. 이는 Coq의 증명을 활용하여 이전 버전의 구조는 효율적으로 유지하고 정확성을 형식화한 버전이다.