랭크에 의한 합치기와 역 아커만 상한
트리를 평평하게 유지하도록 랭크 기반 합치기를 추가하고, 두 최적화를 결합하면 상각 시간이 O(alpha(n)), 즉 사실상 상수 시간이 되는 이유를 이해합니다.
랭크에 의한 합치기와 역 아커만 상한은(는) CoddyKit의 무료 Coding Interview Prep 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Coding Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Coding Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
랭크가 없으면 트리가 높아지는 이유
단순한 경로 압축은 순회가 끝난 후에는 높은 트리를 방지하지만, 초기 union 연산 중에는 큰 트리의 루트를 작은 트리 아래에 항상 연결하여 여전히 높은 트리를 만들 수 있습니다. 랭크 기준 union은 트리 높이의 상한인 랭크를 추적하고, 항상 얕은 트리를 깊은 트리 아래에 연결하여 이 문제를 해결합니다.
랭크는 정확히 트리의 높이를 나타내지는 않습니다. 경로 압축으로 인해 실제 높이가 랭크보다 작아질 수 있기 때문입니다. 하지만 랭크는 높이의 상한입니다. 더 깊은 트리를 새로운 루트로 유지하면 같은 랭크의 두 트리가 합쳐질 때만 랭크가 증가하므로 최대 랭크가 O(log n)으로 제한됩니다.
class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n # initially all trees have rank 0
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # path compression
return self.parent[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py:
return False
# Attach lower-rank tree under higher-rank tree
if self.rank[px] < self.rank[py]:
px, py = py, px
self.parent[py] = px
if self.rank[px] == self.rank[py]:
self.rank[px] += 1 # only increases when ranks are equal
return True랭크 기준 union의 세 가지 경우
루트가 px와 py인 두 요소를 합칠 때 랭크에 따라 세 가지 경우가 발생합니다.
- rank[px] > rank[py]: py를 px 아래에 연결합니다 — px의 랭크는 변하지 않습니다
- rank[px] < rank[py]: px를 py 아래에 연결합니다 — py의 랭크는 변하지 않습니다
- rank[px] == rank[py]: py를 px 아래에 연결합니다(또는 그 반대) — 새로운 루트의 랭크가 1 증가합니다
랭크는 랭크가 같은 경우에만 증가합니다. 따라서 랭크가 n이 되려면 최소 2^n개의 노드가 필요하며, 최대 랭크는 O(log n)입니다. 이 때문에 경로 압축이 없어도 find 경로가 짧게 유지됩니다.
# Illustrating rank behaviour with 8 nodes
dsu_parent = list(range(8))
dsu_rank = [0] * 8
def find(x):
while dsu_parent[x] != x:
x = dsu_parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py: return
if dsu_rank[px] < dsu_rank[py]:
px, py = py, px
dsu_parent[py] = px
if dsu_rank[px] == dsu_rank[py]:
dsu_rank[px] += 1
# Build balanced tree step by step
union(0,1); union(2,3); union(4,5); union(6,7)
union(0,2); union(4,6)
union(0,4)
print('Ranks:', dsu_rank) # max rank <= log2(8) = 3
print('Root of all:', find(0))경로 압축과 랭크 기준 union 결합
경로 압축과 랭크 기준 union을 함께 사용하면 연산당 상각 시간이 O(alpha(n))으로 감소합니다. 이는 역 아커만 함수입니다. 실용적인 모든 입력 크기(2^65536 이하)에서 alpha(n)은 최대 4입니다. 사실상 상수 시간이라고 할 수 있습니다.
경로 압축은 순회 후 아래에서 위로 트리를 평탄화하고, 랭크 기준 union은 병합 중 위에서 아래로 트리가 높아지는 것을 방지합니다. 두 방식은 서로 보완적입니다. 랭크는 초기 깊이를 제한하고, 압축은 첫 번째 순회 후 그 깊이를 없앱니다.
class OptimalDSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x): # path compression
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y): # union by rank
px, py = self.find(x), self.find(y)
if px == py:
return False
if self.rank[px] < self.rank[py]:
px, py = py, px
self.parent[py] = px
if self.rank[px] == self.rank[py]:
self.rank[px] += 1
return True
dsu = OptimalDSU(1000)
import random; random.seed(42)
for _ in range(5000):
dsu.union(random.randint(0,999), random.randint(0,999))
print('Max rank reached:', max(dsu.rank)) # stays very small역 아커만 함수 이해하기
아커만 함수 A(m, n)은 엄청나게 빠르게 증가하며, 모든 원시 재귀 함수보다 빠르게 증가합니다. 그 역함수인 alpha(n)은 A(m, m) >= n을 만족하는 가장 작은 m으로 정의됩니다. 아커만 함수가 매우 빠르게 증가하기 때문에 alpha(n)은 상상하기 어려울 정도로 느리게 증가합니다.
관측 가능한 우주의 원자 개수에 해당하는 n = 10^80에서도 alpha(n)은 여전히 4에 불과합니다. 이것이 두 최적화를 적용한 DSU를 모든 실용적인 환경에서 사실상 상수 시간으로 간주하는 이유입니다. alpha(n)이 5를 초과할 만큼 큰 실제 문제를 만날 일은 없습니다.
# Showing how slowly alpha(n) grows
# alpha(n) = smallest m such that A(m,m) >= n
# A(0,n) = n+1
# A(1,n) = n+2
# A(2,n) = 2n+3
# A(3,n) = 2^(n+3) - 3
# A(4,4) = 2^(2^(2^(2^2))) - 3 which is astronomically large
alpha_thresholds = {
1: 'n=1',
2: 'n up to 3',
3: 'n up to about 2048',
4: 'n up to 10^19728 (far beyond atoms in universe)',
5: 'essentially unreachable in practice',
}
for k, v in alpha_thresholds.items():
print(f'alpha(n)={k}: {v}')
print('\nConclusion: DSU operations are effectively O(1) for all real inputs.')랭크와 크기: 무엇을 사용할까요
랭크 기준 union의 대안은 크기 기준 union입니다. 항상 크기가 작은 트리를 크기가 큰 트리 아래에 연결합니다. 두 방식 모두 트리 높이에 대해 O(log n)이라는 동일한 보장을 제공합니다. 크기는 정확한 개수인 반면 랭크는 경로 압축 후 실제 높이를 반영하지 않을 수 있는 상한이므로, 크기 기준 union이 추론하기 더 쉬운 경우가 많습니다.
면접에서는 어느 방식을 사용해도 괜찮습니다. 크기 기준 union은 요소 크기를 추가 비용 없이 제공한다는 장점이 있으며, 많은 문제에서 이 정보가 필요합니다. 랭크 기준 union은 이론적으로 약간 더 우아하고, 역 아커만 한계에 대한 타잔의 원래 증명과도 일치합니다.
class DSUBySize:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py:
return False
if self.size[px] < self.size[py]:
px, py = py, px # always attach smaller under larger
self.parent[py] = px
self.size[px] += self.size[py]
return True
dsu = DSUBySize(8)
for u, v in [(0,1),(2,3),(0,2),(4,5),(6,7),(4,6),(0,4)]:
dsu.union(u, v)
print('Size of giant component:', dsu.size[dsu.find(0)])증명 개요: 랭크가 O(log n)으로 유지되는 이유
랭크가 r인 DSU 트리에 최소 2^r개의 노드가 포함된다는 것을 귀납법으로 증명할 수 있습니다. 기본 단계에서는 랭크 0이 단일 노드를 의미합니다(2^0 = 1). 귀납 단계에서는 랭크가 r-1인 두 트리가 합쳐질 때만 랭크가 r로 증가합니다. 귀납 가정에 따라 각 하위 트리에는 최소 2^(r-1)개의 노드가 있으므로, 합쳐진 트리에는 최소 2 × 2^(r-1) = 2^r개의 노드가 있습니다.
랭크가 r인 트리에는 최소 2^r개의 노드가 있고 전체 노드 수가 n이므로, 최대 랭크는 log₂(n) 이하입니다. 따라서 경로 압축이 없는 find는 O(log n) 시간이 걸리고, 경로 압축을 사용하면 상각 비용이 훨씬 더 줄어듭니다.
# Verify the 2^rank lower bound empirically
class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
self.size = [1] * n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py: return
if self.rank[px] < self.rank[py]: px, py = py, px
self.parent[py] = px
self.size[px] += self.size[py]
if self.rank[px] == self.rank[py]: self.rank[px] += 1
n = 32
dsu = DSU(n)
for i in range(n - 1): dsu.union(i, i + 1)
for root in range(n):
if dsu.find(root) == root:
r = dsu.rank[root]
print(f'Root {root}: rank={r}, size={dsu.size[root]}, 2^rank={2**r}')경쟁 프로그래밍용 DSU 템플릿
경쟁 프로그래밍과 면접에서는 짧고 정확하며 모든 예외 상황을 처리하는 검증된 DSU 템플릿이 필요합니다. 아래 템플릿은 경로 절반 단축(단일 패스 압축)과 크기 기준 union을 결합합니다. 빠르게 입력하기 쉽고 재귀를 완전히 피할 수 있는 조합입니다.
항상 parent[i] = i와 size[i] = 1을 초기화해야 합니다. find 이후에는 루트의 size가 전체 요소의 크기를 나타낸다는 점을 기억하세요. size[x]를 직접 사용하지 말고, 항상 size[find(x)]를 사용해야 합니다.
class DSU:
def __init__(self, n):
self.p = list(range(n))
self.sz = [1] * n
def find(self, x):
while self.p[x] != x:
self.p[x] = self.p[self.p[x]] # path halving
x = self.p[x]
return x
def union(self, x, y):
x, y = self.find(x), self.find(y)
if x == y: return False
if self.sz[x] < self.sz[y]: x, y = y, x
self.p[y] = x
self.sz[x] += self.sz[y]
return True
def same(self, x, y): return self.find(x) == self.find(y)
def size(self, x): return self.sz[self.find(x)]
# Usage
dsu = DSU(10)
dsu.union(0, 5)
dsu.union(5, 9)
print(dsu.same(0, 9)) # True
print(dsu.size(0)) # 3DSU만으로 충분하지 않은 경우
DSU는 집합을 합칠 수 있지만 집합을 다시 두 개로 분할할 수는 없습니다. 그룹을 합치는 작업과 분리하는 작업이 모두 필요한 문제라면 다른 자료 구조(예: 링크-컷 트리)가 필요합니다. 또한 DSU는 각 그룹의 원소를 기본적으로 저장하지 않으므로, 이를 위해 추가 인접 리스트나 딕셔너리가 필요합니다.
또한 표준 DSU는 수정 없이 가중치가 있는 간선을 지원하지 않습니다(가중치 DSU는 더 고급인 변형입니다). 연결된 노드 사이의 최단 경로와 같은 문제에는 다익스트라나 BFS가 더 적합합니다. DSU의 적용 범위를 이해하면 DSU를 잘못 적용하는 일을 방지할 수 있습니다.
# DSU is perfect for: connected-components, cycle detection,
# Kruskal's MST, accounts-merge, number-of-provinces
# DSU is NOT suitable for:
# - Splitting/removing edges from a group
# - Finding the actual path between two nodes
# - Storing all members of a group efficiently
# - Directed graphs (without modification)
# Example of storing group members alongside DSU
from collections import defaultdict
class DSUWithMembers:
def __init__(self, n):
self.p = list(range(n))
self.members = defaultdict(set)
for i in range(n): self.members[i].add(i)
def find(self, x):
while self.p[x] != x: self.p[x] = self.p[self.p[x]]; x = self.p[x]
return x
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py: return
self.members[px] |= self.members[py]
del self.members[py]
self.p[py] = px연결성을 위한 DSU와 BFS/DFS 비교
BFS/DFS와 DSU 모두 정적 연결성 질의를 해결하지만 강점이 서로 다릅니다. BFS/DFS는 O(V + E) 시간에 실행되며 노드 사이의 실제 경로를 찾을 수 있습니다. DSU는 간선 집합이 점진적으로 커지는 상황에서 연결성 질의에 대해 질의당 거의 O(1) 시간으로 답합니다. 간선이 한 번에 하나씩 도착하는 온라인 알고리즘에 이상적입니다.
모든 간선을 미리 받고 연결성만 필요하다면 둘 중 어느 것이나 사용할 수 있습니다. 간선이 동적으로 도착하고 새 간선이 추가될 때마다 연결성 질의에 답해야 한다면 DSU가 확실히 더 적합합니다. 최단 경로도 필요한 문제라면 BFS를 사용하십시오.
# Comparing DSU vs BFS for 1000 nodes, 2000 edges
# After all edges given => BFS works fine
# But with online edge arrival + interleaved queries => DSU shines
from collections import deque
def bfs_connected(graph, src, dst, n):
visited = set([src])
q = deque([src])
while q:
node = q.popleft()
if node == dst: return True
for nb in graph.get(node, []):
if nb not in visited:
visited.add(nb); q.append(nb)
return False
# DSU for same query:
# dsu.same(src, dst) -- O(alpha(n)) amortised
# BFS for same query:
# O(V + E) every time -- not suitable for repeated queries
print('DSU is preferred for repeated connectivity queries.')
print('BFS/DFS is preferred when you also need the actual path.')연습: DSU를 사용한 최소 신장 트리
최소 신장 트리를 위한 Kruskal 알고리즘은 DSU를 직접 사용합니다. 모든 간선을 가중치 순으로 정렬한 다음, 양 끝점이 서로 다른 컴포넌트에 있을 때(사이클이 없을 때) 각 간선을 탐욕적으로 추가합니다. DSU는 사이클 검사를 거의 O(1)에 제공합니다. 결과는 n-1개 간선으로 이루어진 MST입니다.
이는 DSU의 강력함을 보여 주는 전형적인 예입니다. 즉, 순진한 O(E × V) 사이클 검사를 O(E × alpha(n)) 과정으로 바꿉니다. E log E 정렬을 포함하면 Kruskal의 총 시간 복잡도는 O(E log E)이고, DSU 연산은 매우 빨라 정렬에 비하면 무시할 수 있습니다.
def kruskal(n, edges):
edges.sort(key=lambda e: e[2]) # sort by weight
parent = list(range(n))
rank = [0] * n
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px == py: return False
if rank[px] < rank[py]: px, py = py, px
parent[py] = px
if rank[px] == rank[py]: rank[px] += 1
return True
mst_weight = 0
mst_edges = []
for u, v, w in edges:
if union(u, v):
mst_weight += w
mst_edges.append((u, v, w))
return mst_weight, mst_edges
edges = [(0,1,4),(0,2,3),(1,2,1),(1,3,2),(2,3,5)]
w, e = kruskal(4, edges)
print('MST weight:', w) # 6: edges (1,2,1)+(1,3,2)+(0,2,3)
print('MST edges:', e)rollback을 사용하는 DSU: 오프라인 연결성
표준 DSU는 실행 취소 연산을 지원하지 않습니다. 그러나 rollback을 사용하는 DSU(이력 기능이 있는 DSU라고도 함)는 실행 취소를 지원합니다. 실행 취소하기 어려운 경로 압축 대신 랭크에 의한 union만 사용하고, 각 union을 스택에 기록합니다. 실행을 되돌리려면 스택에서 pop하여 부모와 랭크를 복원합니다. 이를 통해 간선을 추가하고 제거할 수 있는 오프라인 동적 연결성 문제를 해결할 수 있습니다.
이는 표준 면접에서는 거의 볼 수 없는 고급 변형이지만, 경로 압축이 아니라 랭크에 의한 union이 핵심 불변식이라는 점을 보여 줍니다. 경로 압축이 없으면 각 find는 O(log n)이고, rollback을 사용할 때 스택 연산은 O(1)이므로 전체적으로 연산당 O(log n)이 됩니다. 이는 O(alpha(n))보다 크지만, 실행을 되돌릴 수 있습니다.
class DSUWithRollback:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
self.history = [] # stack of (node, old_parent, node2, old_rank)
def find(self, x): # NO path compression (cannot undo)
while self.parent[x] != x:
x = self.parent[x]
return x
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py: return False
if self.rank[px] < self.rank[py]: px, py = py, px
# Record state before modifying
self.history.append((py, self.parent[py], px, self.rank[px]))
self.parent[py] = px
if self.rank[px] == self.rank[py]: self.rank[px] += 1
return True
def rollback(self):
py, old_par_py, px, old_rank_px = self.history.pop()
self.parent[py] = old_par_py
self.rank[px] = old_rank_px
dsu = DSUWithRollback(5)
dsu.union(0, 1); dsu.union(1, 2)
print('0 and 2 connected:', dsu.find(0) == dsu.find(2)) # True
dsu.rollback()
print('After rollback:', dsu.find(0) == dsu.find(2)) # False빠른 확인
이 단원에서 다룬 자료 구조 및 알고리즘 — 코딩 면접 준비 개념을 얼마나 이해했는지 확인해 보십시오.
단원 요약
이 단원에서는 랭크에 의한 union은 항상 더 얕은 트리를 더 깊은 트리 아래에 연결한다는 것, 랭크는 랭크가 같은 두 트리가 합쳐질 때만 증가하므로 트리 높이를 O(log n)으로 유지한다는 것, 그리고 경로 압축과 랭크에 의한 union을 결합하면 O(alpha(n))의 상각 시간이 되어 사실상 상수 시간이 된다는 것을 배웠습니다. 다음으로는 완전히 최적인 DSU를 중복 연결과 그래프의 사이클 탐지에 적용해 보겠습니다.
자주 묻는 질문
“랭크에 의한 합치기와 역 아커만 상한” 강의는 무료인가요?
네 — “랭크에 의한 합치기와 역 아커만 상한” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Coding Interview Prep 강의 전체를 잠금 해제할 수 있습니다. Coding Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
“랭크에 의한 합치기와 역 아커만 상한”에서 뭘 배우나요?
트리를 평평하게 유지하도록 랭크 기반 합치기를 추가하고, 두 최적화를 결합하면 상각 시간이 O(alpha(n)), 즉 사실상 상수 시간이 되는 이유를 이해합니다. 브라우저에서 직접 실행하는 실습 코드로 Coding Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Coding Interview Prep을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Coding Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.
“랭크에 의한 합치기와 역 아커만 상한” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Coding Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Coding Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 경로 압축을 적용한 DSU
- 랭크에 의한 합치기와 역 아커만 상한
- 중복 간선과 사이클 탐지
- 계정 병합과 연결 요소