분리집합(Union-Find)
"A와 B가 같은 그룹인가?"를 빠르게 답하고, 두 그룹을 하나로 합치는 일을 반복해야 할 때가 있습니다. 친구 관계의 연결 여부, 네트워크의 연결 요소, 그래프에서 사이클 판별이 그런 문제입니다. 분리집합(Disjoint Set, Union-Find)은 원소들을 서로 겹치지 않는 집합으로 나누어 두고, 원소가 속한 집합을 찾는 find와 두 집합을 합치는 union을 두 가지 최적화를 함께 쓰면 분할 상환으로 거의 상수 시간에 처리하는 자료구조입니다.
각 집합을 트리 하나로 표현하고, 원소마다 부모를 가리키는 parent 배열을 둡니다(크기 기준 합치기를 쓰면 루트별 크기 배열이 하나 더 필요합니다). 부모가 자기 자신인 노드가 집합의 대표(루트)입니다. find는 루트까지 올라가고, union은 한 루트를 다른 루트 밑에 붙입니다. 아무 최적화 없이 합치면 트리가 한 줄로 길어질 수 있어서, 크기 기준 합치기와 경로 압축이라는 두 가지 최적화를 함께 씁니다. 이 토픽에서는 같은 연산이 방식에 따라 트리 높이를 얼마나 바꾸는지 단계별로 봅니다.
핵심 개념
서로소 집합
모든 원소가 정확히 한 집합에만 속하도록 나눈 것입니다. 처음에는 원소마다 자기만 든 집합이고, union으로 합치면 집합의 개수가 하나씩 줄어듭니다.
핵심 개념
parent 배열
parent[i]는 i의 부모입니다. parent[i] == i이면 i가 루트이고, 두 원소의 루트가 같으면 같은 집합입니다. 포인터 없이 배열 하나로 숲을 표현합니다.
핵심 개념
find와 union
find(x)는 x에서 부모를 따라 루트까지 올라갑니다. union(a, b)는 두 루트를 찾아 하나를 다른 쪽 밑에 붙입니다. 두 루트가 이미 같으면 합칠 것이 없습니다.
핵심 개념
크기 기준 합치기
작은 트리의 루트를 큰 트리의 루트 밑에 붙입니다. 깊이가 늘어나는 쪽은 항상 작은 트리라서, 높이가 log2(n)을 넘지 않습니다. 크기 대신 높이의 상한인 랭크를 기준으로 삼는 방식도 있습니다.
핵심 개념
경로 압축
find가 루트를 찾으며 지나온 모든 노드의 부모를 루트로 바꿉니다. 트리가 납작해져서 같은 경로를 다시 걸을 때는 한 번에 루트에 닿습니다.
핵심 개념
역 아커만 함수
두 최적화를 함께 쓰면 연산 하나의 분할 상환 비용이 역 아커만 함수 α(n)에 비례합니다. α(n)은 현실적인 입력 크기에서 정의에 따라 4 또는 5 이하여서 사실상 상수로 봅니다.
분리집합의 배경
트리로 집합을 표현하는 방식은 1964년 갤러(Galler)와 피셔(Fischer)가 동치 관계를 다루는 알고리즘으로 소개했고, 이후 크기·랭크 기준 합치기와 경로 압축의 효율이 분석되었습니다. 1975년 타잔(Tarjan)은 두 최적화를 함께 쓸 때 연산당 비용이 역 아커만 함수에 비례함을 보였습니다. 컴파일러의 타입 동치 판별, 이미지의 연결 영역 찾기, 그래프 알고리즘에서 널리 쓰입니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
Union-Find는 어떤 자료구조인가요?
서로소 집합을 트리로 표현해, 원소가 속한 집합의 대표를 찾는 find와 두 집합을 합치는 union을 지원합니다. parent 배열(크기 기준 합치기를 쓰면 크기 배열 포함)로 구현하고, 같은 집합인지는 두 원소의 루트가 같은지로 판단합니다.
면접 질문
경로 압축과 크기 기준 합치기는 무엇인가요?
경로 압축은 find 중에 지나간 노드를 루트에 직접 연결하고, 크기 기준 합치기는 작은 트리를 큰 트리 밑에 붙입니다. 둘 다 트리를 낮게 유지해 find를 빠르게 합니다.
면접 질문
크루스칼에서 왜 Union-Find를 쓰나요?
간선을 추가할 때 두 끝점이 이미 같은 집합이면 사이클이 생기므로 건너뛰어야 합니다. 이 판별을 find로 거의 상수 시간에 하고, 채택한 간선은 union으로 합칩니다.
면접 질문
시간 복잡도는 어떻게 되나요?
두 최적화를 함께 쓰면 연산 m번의 총 비용이 O(m α(n))이고, α는 역 아커만 함수라서 사실상 상수입니다. 최적화가 없으면 find 한 번이 최악에 O(n)입니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
Python — 경로 압축과 크기 기준 합치기
class UnionFind: def __init__(self, n): self.parent = list(range(n)) # 처음에는 모두 자기가 루트 self.size = [1] * n self.count = n # 집합의 개수 def find(self, x): root = x while self.parent[root] != root: root = self.parent[root] while self.parent[x] != root: # 경로 압축: 지나온 노드를 루트에 직접 연결 self.parent[x], x = root, self.parent[x] return root def union(self, a, b): ra, rb = self.find(a), self.find(b) if ra == rb: return False # 이미 같은 집합 if self.size[ra] < self.size[rb]: ra, rb = rb, ra # 큰 쪽이 루트가 되도록 self.parent[rb] = ra self.size[ra] += self.size[rb] self.count -= 1 return True uf = UnionFind(6) print(uf.union(0, 1), uf.union(1, 2), uf.union(0, 2)) # True True False print(uf.find(2) == uf.find(0), uf.find(3) == uf.find(0), uf.count) # True False 4
parent 배열이 숲을 표현합니다. parent[i] == i이면 i가 루트이고, 아니면 i의 부모가 parent[i]입니다. find는 루트까지 올라간 뒤, 한 번 더 올라가며 지나온 노드의 부모를 루트로 바꿔 경로 압축을 합니다. union은 두 원소의 루트를 찾아, 이미 같으면 아무것도 하지 않고 False를 돌려줍니다. 다르면 크기가 작은 쪽 루트를 큰 쪽 루트 밑에 붙이고 크기를 더합니다. 이 반환값이 크루스칼 알고리즘에서 사이클 판별에 그대로 쓰입니다. 0, 1, 2를 합치면 세 번째 union(0, 2)는 이미 같은 집합이라 False입니다. 원소 6개에서 두 번 합쳤으므로 집합은 4개가 남고, 3은 아직 0과 다른 집합입니다.
Python — 합치기 방식에 따른 트리 높이
def height(parent): def depth(x): d = 0 while parent[x] != x: x = parent[x]; d += 1 return d return max(depth(i) for i in range(len(parent))) def build(n, by_size): parent = list(range(n)); size = [1] * n def root(x): # 경로 압축 없는 find while parent[x] != x: x = parent[x] return x for i in range(n - 1): # union(0,1), union(1,2), ... 순서 ra, rb = root(i), root(i + 1) if by_size: if size[ra] < size[rb]: ra, rb = rb, ra parent[rb] = ra; size[ra] += size[rb] else: parent[ra] = rb # 첫 루트를 둘째 루트 밑에 return height(parent) print(build(6, False), build(6, True)) # 5 1 print(build(1000, False), build(1000, True)) # 999 1
같은 순서의 union을 두 방식으로 실행해 트리 높이를 비교합니다. 첫 루트를 둘째 루트 밑에 붙이는 기본 방식은 한 줄로 이어져 원소 n개에서 높이가 n - 1까지 자랍니다. 시각화의 기본 합치기 탭이 보여 주는 높이 5와 같습니다. 크기 기준은 이 순서에서 큰 트리에 작은 트리를 계속 붙이므로 높이가 1에 머뭅니다. 원소가 1000개여도 999와 1의 차이가 납니다. 이 순서는 기본 방식에 가장 불리한 입력이어서 차이가 큽니다. 크기 기준의 높이는 어떤 순서에서도 log2(n) 이하라는 점이 보장입니다.
Python — 크루스칼 최소 신장 트리
def kruskal(n, edges): uf = UnionFind(n) total, used = 0, [] for w, a, b in sorted(edges): # 가중치가 작은 간선부터 if uf.union(a, b): # 서로 다른 집합일 때만 채택 total += w used.append((a, b, w)) return total, used # (가중치, 정점, 정점) edges = [(4, 0, 1), (8, 0, 2), (2, 1, 2), (6, 1, 3), (3, 2, 3), (5, 2, 4), (7, 3, 4)] print(kruskal(5, edges)) # (14, [(1, 2, 2), (2, 3, 3), (0, 1, 4), (2, 4, 5)])
크루스칼 알고리즘은 간선을 가중치 순으로 훑으며, 두 끝점이 서로 다른 집합일 때만 간선을 고르고 두 집합을 합칩니다. 같은 집합이면 이미 연결된 두 정점을 또 잇는 것이어서 사이클이 생기므로 건너뜁니다. 이 판별과 합치기를 union 한 번이 모두 해 줍니다. 예에서 가중치 2, 3, 4, 5인 간선 4개가 채택되어 합이 14입니다. 가중치 6, 7, 8인 간선은 끝점이 이미 같은 집합이라 버려집니다. 연결 그래프이면 정점 V개에서 간선 V - 1개를 채택하고 끝납니다. 간선 정렬이 O(E log E)이고 union은 거의 상수 시간이어서 전체 시간은 정렬이 좌우합니다. 최소 신장 트리 자체는 알고리즘 과목의 MST 토픽에서 자세히 다룹니다.
Q1
Union-Find(분리 집합)의 두 가지 핵심 연산은?
Q2
경로 압축(Path Compression)의 효과는?
Q3
크루스칼(Kruskal) 최소 신장 트리 알고리즘에서 Union-Find의 용도는?