DSU(Disjoint Set Union)라고도 하는 Union-Find 알고리즘은 각각의 부분 집합에서 특정 요소가 중복이되지 않는지 체크하기 위해서 사용됩니다. (유니온 파인드 알고리즘은 크루스크칼 알고리즘에서도 사용되는데 이때는 사이클이 생기지 않는지를 확인하기 위해 사용되기도 합니다.) 크게 찾기, 합치기 두 가지 주요 작업을 지원합니다.
Disjoint Set이란
- 서로 중복되지 않는 부분 집합들로 나눠진 원소들에 대한 정보를 저장하고 조작하는 자료구조
- 공통 원소가 없는 (서로소) 상호 배타적인 부분 집합들로 나눠진 원소들에 대한 자료구조를 의미함
- Disjoint Set = 서로소 집합 자료구조
이러한 작업은 종종 그래프 알고리즘에서 두 노드가 동일한 구성 요소에 연결되어있는지 여부를 빠르게 확인하고 두 구성 요소를 하나로 결합하는 데 사용됩니다. 즉, 노드들 중에 연결된 노드를 찾거나, 노드들을 서로 연결할 때 (합칠 때) 사용합니다.
Disjoint Set을 만들때도 사용되며 크루스칼 알고리즘에서 사이클을 체크할때도 사용됩니다.
초기화
Union
Find
크루스칼 알고리즘에서는 간선을 연결할때 유니온을 사용하고 선택한 간선으로 인하여 사이클이 생기는지를 체크할때는 파인드를 사용한다.
최악의 경우 위의 이미지와 같이 높이만 깊은 트리가 있다면 파인드 / 유니온을 처리할때 링크드 리스트의 맨 끝단을 찾아가는것 같은 선형적인 시간복잡도를 갖게된다.
이와 같은 상황에서는 Find/Union시 계산량이 O(N) 이 될 수 있으므로, 해당 문제를 해결하기 위해, union-by-rank, path compression 기법을 사용한다.
유니온 바이 랭크나 패스 컴프레션은 트리의 높이를 최대한 낮춰 루트노드를 빠르게 찾을 수 있게끔하는 기법이다.
유니온 바이 랭크 기법의 목적은 두 개의 부분집합이 합쳐질때 어떻게하면 높이를 작게하느냐에 초점을 맞췄다고 볼 수 있다.
각 트리에 대해 높이(rank)를 기억해 두고,Union시
Case1) 두 트리의 높이(rank)가 다르면, 높이가 작은 트리를 높이가 큰 트리에 붙인다.
즉, 높이가 큰 트리의 루트 노드가 두 트리의 루트 노드가 되게 하여 결과적으로 트리의 높이를 가장 작게 만들 수 있다.
만약 두 그래프를 합칠때 C노드에 D노드를 붙이게되면 Union/Find시 시간 복잡도가 높아지게된다.
Case2) 만약 두 트리의 높이가 같다면 한쪽 트리의 루트노드 랭크를 1 증가시키고 이 루트 노드에 다른 트리의 루트 노드를 연결한다.
루트 노드의 랭크만 증가시키는 것은 트리의 높이가 실제로 증가했음을 반영하는 것입니다. 이 때문에 루트 노드가 아닌 다른 노드들의 랭크를 증가시킬 필요가 없습니다. 그 노드들의 랭크는 그 노드를 루트로 하는 서브트리의 높이를 나타내기 때문이죠.
하위 노드들의 랭크를 갱신하지 않아도 되는 또 다른 이유는 "파인드(find)" 연산을 수행할 때 경로 압축(path compression)이라는 기법을 사용하기 때문입니다. 이 기법은 파인드 연산을 수행하면서 만난 모든 노드를 바로 루트에 연결하는 방식으로, 이로 인해 실제 트리의 높이가 랭크보다 낮아질 수 있습니다. 이 경우, 각 노드의 랭크를 그 노드를 루트로 하는 트리의 실제 높이로 갱신하려면 불필요하게 많은 연산이 필요하게 됩니다.
초기화시, 모든 원소는 높이(rank)가 0인 개별 집합인 상태에서 하나씩 원소를 합칠(Union)때, union-by-rank 기법을 사용하면 최악의 트리 구조 상황과 비교하였을때 union 작업과 find 작업 모두의 시간복잡도 O(N)을 O(logN)으로 낮출 수 있습니다.
경로 압축 기법은 Find를 실행할때 동작을 한다.
union-by-rank와 path compression 기법을 함께 사용할 경우 다음 계산식을 만족함이 수학적으로 증명되었다고한다.
N이 2^65536값을 가지더라도, log*N의 값이 5의 값을 가지므로, 거의 O(1), 즉 상수값에 가깝다고 볼 수 있다고 한다.
N | 𝑙𝑜𝑔∗𝑁 |
---|---|
1 | 0 |
2 | 1 |
4 | 2 |
16 | 3 |
65536 | 4 |
265536265536 | 5 |
Union-Find 알고리즘은 서로소 집합이 여러 개인 경우에 적합하며 요소가 특정 집합에 속하는지 확인하거나 두 집합을 병합하려는 경우에 적합합니다. 네트워크 연결, 이미지 처리, 그래프에서 주기 찾기와 같은 상황에서 매우 유용합니다.
Union-Find 알고리즘은 find
및 union
이외의 작업을 수행해야 하는 상황에는 적합하지 않습니다. 요소 검색, 요소 삭제 또는 세트의 요소 반복을 위한 것이 아닙니다.
초기에 하나의 요소로 각 집합을 초기화하는 것이 중요하며 알고리즘이 효율적이려면 찾기
작업에서 경로 압축을 수행해야 합니다.
알고리즘의 시간 복잡도는 유니온바이랭크 및 패스 컴프레션 기법에 의한 합집합을 사용하는 경우 연산에 대해 거의 O(1)입니다.
class UnionFind {
constructor(n) {
this.parent = Array(n).fill().map((_, i) => i); // 초기에는 모든 원소들이 자기 자신을 부모로 가집니다
this.rank = Array(n).fill(0); // 모든 노드의 랭크를 0으로 초기화합니다
}
find(x) {
if (this.parent[x] !== x) {
// 경로 압축: 경로에 있는 각 노드를 루트로 가리키도록 합니다
this.parent[x] = this.find(this.parent[x]);
}
return this.parent[x];
}
union(x, y) {
let xRoot = this.find(x); // x의 루트를 찾습니다
let yRoot = this.find(y); // y의 루트를 찾습니다
// 만약 같은 집합에 속해 있다면, 합칠 필요가 없습니다
if (xRoot === yRoot) return;
// 랭크에 의한 유니온:
if (this.rank[xRoot] > this.rank[yRoot]) {
// x의 랭크가 y의 랭크보다 높다면, y의 루트를 x로 합니다
this.parent[yRoot] = xRoot;
} else if (this.rank[xRoot] < this.rank[yRoot]) {
// y의 랭크가 x의 랭크보다 높다면, x의 루트를 y로 합니다
this.parent[xRoot] = yRoot;
} else {
// 랭크가 같다면, 하나를 루트로 지정하고 그 랭크를 1 증가시킵니다
this.parent[yRoot] = xRoot;
this.rank[xRoot]++;
}
}
}
let uf = new UnionFind(5); // {0}, {1}, {2}, {3}, {4}
uf.union(0, 1); // {0, 1}, {2}, {3}, {4}
console.log(uf.find(0) === uf.find(1)); // true
console.log(uf.find(0) !== uf.find(2)); // true
uf.union(2, 3); // {0, 1}, {2, 3}, {4}
console.log(uf.find(2) === uf.find(3)); // true
uf.union(0, 2); // {0, 1, 2, 3}, {4}
console.log(uf.find(0) === uf.find(3)); // true
console.log(uf.find(3) !== uf.find(4)); // true
uf.union(0, 4); // {0, 1, 2, 3, 4}
console.log(uf.find(1) === uf.find(4)); // true