Union-Find는 Kruskal 알고리즘을 구현하기 위해 핵심적으로 알아야 할 자료구조이다.
Disjoint Set(서로소 집합 자료구조)을 표현할 때 사용하는 자료구조이다.
서로소 집합은 공통 요소가 하나도 없는, 즉 교집합이 전혀 없는 서로 다른 부분집합들을 의미한다.
그리고 Union-Find는 이 Disjoint-Set을 구성하고 각 요소에 대한 조작, 검색을 수행하도록 설계된 개념이다.
해당 자료구조는 언어 자체에 내장되어 있거나 그렇진 않고, 일반적으로 딕셔너리(트리 구조)로 구현한다.
트리 구조의 부모-자식 관계를 이용하여 어떠한 부분집합의 개별 요소들을 연결한다.
그리고 트리 구조 꼭대기에 있는 루트 노드는 곧 그 집합을 대표하는 대표 노드의 역할을 한다.
배열로도 구현할 수는 있지만 union 연산에 있어서 시간 복잡도가 O(N^2)이 될 수 있으므로, 비효율적이다.
이 자료구조는 1) 초기화 연산, 자신의 이름 그대로 2) Union과 3) Find 연산을 갖고있다.
Union-Find 자료구조는 트리 자료구조를 활용하긴 하지만, 노드와 노드를 하나씩 연결하다보면 자칫하다간 연결 리스트의 형태를 띌 수 있게 된다는 한계점을 갖고 있다. 이는 연산의 효율성을 떨어트린다. 때문에 최적화를 통해 균형잡힌 트리의 모습을 유지해줄 필요가 있다.
그 방법론으로는 크게 두 가지가 제시된다.
아래 그림을 예제로 사용해서 이해해보자.
1~7까지 노드가 있는 그래프에서 무작위로 union을 수행해봤더니 왼쪽 트리가 나왔다고 하자.
이때의 union-find 테이블은 다음과 같다.
여기서 만약 find(0)을 수행한다면, 0번 노드의 루트 노드를 찾기 위해 0 -> 2 -> 3 -> 4 -> 5(루트) 순으로 찾아갈 것이다.
따라서 자기 자신(0)을 포함한 2, 3, 4의 부모 노드도 5로 통일시켜준다.
그 결과, 테이블은 다음과 같이 재구성된다.
find(0) 과정에서 만나지 못한 1, 6, 7의 경우엔 아직 최적화되지 않고 그대로 2를 가리키고 있다.
find(0) 수행 예시: 0 -> 2 -> 3 -> 4 -> 5(루트) 순으로 간다. 따라서 자기 자신(0)을 포함한 2, 3, 4의 부모 노드도 5로 통일시켜준다. (만나지 못한 1, 6, 7의 경우엔 아직 최적화되지 않고 그대로 2를 가리키고 있음.)
이러한 최적화 기법들을 통해 Union-Find 알고리즘의 시간 복잡도는 거의 상수에 가까워진다.
지금껏 다룬 핵심 연산(트리 초기화, union, find)과 최적화 기법(union by rank, 경로 압축)을 모두 포함해서 코드로 어떻게 구현하는지 알아보자.
1. make-set(n)
### 초기화 연산 ###
def make_set(n):
# 각 노드의 부모 노드 정보를 저장하는 리스트 (리스트를 사용했지만 트리를 구현한 것임)
parent = [i for i in range(n)] # 초기에는 모든 노드가 루트 노드이므로 자기 자신을 부모로 가진다.
# 각 노드의 랭크 정보를 저장하는 리스트 (Union by rank 적용을 위해)
rank = [0 for _ in range(n)]
return parent, rank
parent는 리스트로 선언되긴 했지만, 부모(값)-자식(index) 관계를 표현하므로 트리 구조를 구현한 것이다.
2. union(parent, rank, i, j)
### Union 연산 ###
def union(parent, rank, i, j):
# i의 루트와 j의 루트를 찾음
root_i = find(parent, i)
root_j = find(parent, j)
# 만일 두 노드가 이미 같은 집합이라면 union() 수행할 필요 X
if root_i == root_j:
return
# Union by rank: 두 트리의 루트 노드의 랭크를 비교하여 작은 트리를 큰 트리 아래에 붙인다.
if rank[root_i] < rank[root_j]: # 루트[j]의 랭크가 더 높다면
parent[root_i] = root_j # 루트[i]의 부모를 자기 자신 -> 루트[j]로 할당
elif rank[root_i] > rank[root_j]: # 루트[i]의 랭크가 더 높다면
parent[root_j] = root_i # 루트[j]의 부모를 자기 자신 -> 루트[i]로 할당
else: # 두 루트의 랭크가 같다면
parent[root_j] = root_i # 둘 중 하나(일반적으로 더 낮은 수)의 밑으로 통합시키고
rank[root_i] += 1 # 트리 높이를 1 증가. (높이가 같은 걸 밑으로 붙였으니, 트리의 꼬리(?)가 더 길어짐)
3. find(parent, i)
### Find 연산 ###
def find(parent, i):
# 루트 노드가 아니라면 루트 노드를 찾을 때까지 재귀적으로 호출
if parent[i] != i:
parent[i] = find(parent, parent[i]) # 경로 압축 (만나는 노드의 부모 노드를 루트 노드로 바꿔주기)
# 자기 자신을 가리키면 루트 노드 (parent[i] == i)
return parent[i]
#### Find 연산 (경로 압축 X 버전) ###
# 실제로 쓸 일은 없으니 이해용으로만 보기!
def find(parent, i):
# 루트 노드가 아니라면 루트 노드를 찾을 때까지 재귀적으로 호출
if parent[i] != i:
find(parent, parent[i])
# 자기 자신을 가리키면 루트 노드 (parent[i] == i)
return i
복습할 때는 안 보고 백지에 필기해보기
https://hongjw1938.tistory.com/26
https://gmlwjd9405.github.io/2018/08/31/algorithm-union-find.html
https://blog.naver.com/ndb796/221230967614
Red-Black Tree (RB 트리) | 기본 개념, 삽입 연산 (0) | 2023.11.03 |
---|---|
자료구조 | 힙(Heap), 우선순위 큐(Priority Queue) (0) | 2023.10.26 |
자료구조 | 신장 트리(ST), 최소 신장 트리(MST) (1) | 2023.10.22 |
자료구조 | 그래프 기초(Graph) (1) | 2023.10.21 |
자료구조 | 이진 탐색 트리(Binary Search Tree, BST) (0) | 2023.10.20 |