출처: 이것이 취업을 위한 코딩테스트다 with 파이썬, 나동빈, 2021
강의: https://youtu.be/aOhhNFTIeFI?si=H13MEVg0QIqqpAvE
서로소 집합 자료구조 Union-Find
서로소 집합 정의
서로소 집합이란 공통 원소가 없는 두 집합을 의미한다.
서로소 집합 자료구조
서로소 부분 집합들로 나누어진 원소를 처리하기 위한 자료구조.
두 종류의 연산을 지원한다.
- 합집합(Union): 두 개의 원소가 포함된 집합을 하나의 집합으로 합치는 연산
- 찾기(Find): 특정한 원소가 속한 집합(루트 노드)을 찾는 연산
이것을 `Union-Find 자료구조`라고도 부른다.
동작 과정
- `Union 연산`으로, 노드 A와 노드 B를 연결한다.
- A와 B의 루트 노드 A'과 B'을 각각 찾는다. `Find 연산`
- A'를 B'의 부모 노드로 설정한다. (노드 번호가 작은 것이 부모가 되도록)
- 입력된 모든 Union 연산을 처리할 때까지 1번의 과정을 반복한다.
연결성
- 서로소 집합 자료구조에서는 손쉽게 집합의 형태로 연결 여부를 확인할 수 있다.
- 기본적인 형태의 서로소 집합 자료구조에서는 루트 노드에 즉시 접근할 수 없다.
- 오른쪽 그림의 예시에서는, 노드 3의 루트를 찾기 위해서 노드 2 -> 노드 1 순서로 접근해야 한다.
구현: 기본적인 형태
# 특정 원소가 속한 집합 찾기
def find_parent(parent, x):
if parent != x:
return find_parent(parent, parent[x]) # 루트 찾을 때까지 재귀 호출
return x
# 두 원소가 속한 집합을 합치기
def union_parent(parent, a, b):
a = find_parent(parent, a)
b = find_parent(parent, b)
if a < b:
parent[b] = a
else:
parent[a] = b
# 일반적인 문제 형태 ...
# 노드 개수와 간선(Union 연산)의 개수 입력 받기
v, e = map(int, input().split())
parent = [0] * (v + 1)
# 부모 테이블을 자기 자신으로 초기화
for i in range(1, v + 1):
parent[i] = i
# Union 연산
for i in range(e):
a, b = map(int, input().split())
union_parent(parent, a, b)
# 각 원소가 속한 집합(루트 노드) 출력하기
for i in range(1, v + 1):
print(find_parent(parent, i))
기본적인 구현 방법의 문제점
Union 연산이 편향되게 이루어 지는 경우 Find 연산이 비효율적으로 동작한다.
최악의 경우 Find 연산이 모든 노드를 다 확인해야 하므로 시간 복잡도가 O(V)이다.
이것을 개선하기 위해 `경로 압축`이 필요하다.
구현: 경로 압축
Find 연산을 최적화하기 위한 방법으로 경로 압축(Path Compression)을 이용할 수 있다.
Find 함수를 재귀적으로 호출한 뒤에, 부모 테이블 값을 바로 갱신한다.
def find_parent(parent, x):
if parent != x:
parent[x] = find_parent(parent, parent[x]) # 부모 테이블을 즉시 갱신
return parent[x]
경로 압축의 과정에서, 각 Find 연산이 수행될 떄마다 경로 상의 모든 노드를 루트로 직접 연결하면서 트리의 높이를 평탄화한다.
(이때 부모 노드는 곧 루트 노드이다.)
경로 압축을 적용했을 때 Union-Find 연산의 시간 복잡도는 `O(α(N))`이다.
`α(x)`는 애커만 함수의 역함수이다.
애커만 함수는 재귀 함수의 일종으로 입력 값이 작더라도 매우 빠르게 증가한다.
반대로 역 애커만 함수는 x가 2^65536 일때 그 값이 4 ~ 5 정도이다. 거의 상수 시간이라고 할 수 있다.
아래 링크에 Union-Find 알고리즘의 형태별 시간 복잡도가 자세히 소개되어 있다.
https://velog.io/@nmrhtn7898/Union-Find
서로소 집합을 활용한 사이클 판별
트리 VS 그래프
그래프 | 트리 | |
정의 | 노드와 간선의 집합. | 그래프의 종류. 방향성이 있는 비순환 그래프. |
방향성 | 방향 / 무방향 | 방향 |
사이클 | 순환 / 비순환 | 비순환 |
루트 | X | O |
부모-자식 | X | O |
간선 수 | 최대: 무방향: n(n-1)/2, 방향 n(n-1) | 노드가 n개 일때, 간선은 n-1개. |
경로 | 두 노드간 여러 가지 경로 가능. | 두 노드 간 유일한 경로 |
용도 | 네트워크, 경로 찾기 | 계층적 구조. 조직도. |
정의
서로소 집합은 무방향 그래프(DAG) 내에서 사이클을 판별할 때 사용할 수 있다.
(방향 그래프에서의 사이클 여부는 DFS로 판별 가능)
동작 과정
- 각 간선을 확인하면서 두 노드의 루트 노드를 확인.
- 루트 노드가 서로 다르다면 두 노드 Union 연산 수행.
- 루트 노드가 서로 같다면 Cycle 발생. 탐색 종료.
- 그래프에 포함된 모든 간선에 대하여 1번 과정 반복
구현
# 특정 원소가 속한 집합 찾기
def find_parent(parent, x):
if parent != x:
return find_parent(parent, parent[x]) # 루트 찾을 때까지 재귀 호출
return x
# 두 원소가 속한 집합을 합치기
def union_parent(parent, a, b):
a = find_parent(parent, a)
b = find_parent(parent, b)
if a < b:
parent[b] = a
else:
parent[a] = b
# 일반적인 문제 형태 ...
# 노드 개수와 간선(Union 연산)의 개수 입력 받기
v, e = map(int, input().split())
parent = [0] * (v + 1)
# 부모 테이블을 자기 자신으로 초기화
for i in range(1, v + 1):
parent[i] = i
cycle = False # 사이클 발생 여부
for i in range(e):
a, b = map(int, input().split())
if find_parent(parent, a) == find_parent(parent, b):
cycle = True
break
else:
union_parent(parent, a, b)
if cycle:
print("사이클 발생")
else:
print("사이클 발생하지 않음")
신장 트리 Spanning Tree
정의
모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프
최소 신장 트리 MST, Minimum Spanning Tree
최소한의 비용으로 구성되는 신장 트리
크루스칼 알고리즘 Kruskal Algorithm
정의
최소한의 비용으로 만들 수 있는 최소 신장 트리를 찾는 알고리즘.
대표적인 최소 신장 트리 알고리즘.
그리디 알고리즘으로 분류.
동작 과정
- 간선 데이터를 비용에 따라 오름차순으로 정렬
- 간선을 하나씩 확인하며 현재 간선이 사이클을 발생시키는지 확인
- 사이클이 발생하지 않는 경우, 최소 신장 트리에 포함
- 사이클이 발생하는 경우, 최소 신장 트리에 포함하지 않음
- 모든 간선에 대하여 2번의 과정을 반복
구현
def find_parent(parent, x):
if parent[x] != x:
parent[x] = find_parent(parent, parent[x])
return parent[x]
def union_parent(parent, a, b):
a = find_parent(parent, a)
b = find_parent(parent, b)
if a < b:
parent[b] = a
else:
parent[a] = b
v, e = map(int, input().split())
parent = [0] * (v + 1)
edges = [] # 모든 간선을 담을 리스트
result = 0 # 최종 비용
for i in range(1, v + 1):
parent[i] = i
for _ in range(e):
a, b, cost = map(int, input().split())
edges,append((cost, a, b))
edges.sort() # 간선을 비용 순으로 정렬
for edge in edges:
cost, a, b = edge
if find_parent(parent, a) != find_parent(parent, b): # 사이클이 발생하지 않는 경우 집합에 포함
union_parent(parent, a, b)
result += cost # 비용 더하기
print(result)
특징
간선의 개수가 E개일 때 `O(ElogE)`의 시간 복잡도를 갖는다.
가장 많은 시간을 요구하는 곳은 간선을 정렬하는 부분이다.
위상 정렬 Topological Sorting
정의
사이클이 없는 방향 그래프(DAG)의 모든 노드를 방향성에 거스르지 않도록 순서대로 나열.
순서가 정해져 있는 작업을 차례대로 수행해야 할 때, 그 순서를 결정하는 알고리즘이다.
예컨대, 라면을 끓이려면 물이 먼저 끓어야 한다. 특정 과목을 수강하려면 선수 과목을 수강해야 한다.
진입차수와 진출차수
진입차수: 특정한 노드로 들어오는 간선의 개수
진출차수: 특정한 노드에서 나가는 간선의 개수
동작 과정: 큐 (진입 차수)
- 진입차수가 0인 모든 노드를 큐에 넣는다.
- 큐가 텅 빌 때까지 다음의 과정을 반복한다.
- 큐에서 원소를 꺼내 해당 노드에서 나가는 간선을 그래프에서 제거한다.
- 새롭게 진입차수가 0이 된 노드를 큐에 넣는다.
- 결과적으로 각 노드가 큐에 들어온 순서가, 위상 정렬을 수행한 결과와 같다.
구현: 큐
from collections import deque
v, e = map(int, input().split())
indegree = [0] * (v + 1) # 모든 노드에 대한 진입차수는 0으로 초기화
graph = [[] for i in range(v + 1)] # 간선 정보를 담을 리스트
for _ in range(e):
a, b = map(int, input().split())
graph[a].append(b) # 노드 A에서 B로 이동 가능
indegree[b] += 1
def queue_topological_sort():
result = []
q = deque()
for i in range(1, v + 1):
if indegree[i] == 0: # 진입차수가 0인 노드를 큐에 삽입
q.append(i)
while q:
now = q.popleft()
result.append(now)
for i in graph[now]:
indegree[i] -= 1 # 현재 노드와 연결된 노드의 진입 차수 1 빼기
if indegree[i] == 0: # 새롭게 진입 차수 0이 된 노드 큐에 삽입
q.append(i)
for i in result:
print(i, end=" ")
topological_sort()
동작 과정: 스택 (진출 차수)
- 방문하지 않은 임의의 노드에서 출발한다.
- DFS를 수행하며 방문하는 노드를 스택에 담는다.
- 모든 정점을 방문할 때까지 1번을 반복한다.
- 모든 정점을 방문했다면 스택에 담긴 정점을 출력한다.
DFS는 깊이 우선 탐색이라는 사실을 떠올려보자.
하나의 노드에서 DFS를 수행하면 결국 리프 노드까지 내려갈 것이다.
리프 노드는 진출 차수가 0이다.
즉, 가장 마지막에 수행되어야 하는 작업이다.
가장 깊은 리프 노드가 스택에 먼저 담기고,
루트 노드가 가장 나중에 스택 최상단에 담길 것이다.
LIFO 형태의 스택을 차례대로 출력하면 위상 정렬의 결과와 같다.
임의의 노드를 선택해도 상관이 없는 이유는,
DFS는 선택한 노드보다 늦게 수행되어야 할 노드(더 깊은 노드)만 방문하는 원리이다.
탐색 중인 그래프가 DAG인지 확인하기 위해 사이클 유무를 판단할 수 있어야 한다.
방문 처리된 노드를 DFS 호출 과정에서 만나면 사이클이 존재한다는 뜻이다.
구현: 스택
v, e = map(int, input().split())
graph = [[] for i in range(v + 1)] # 간선 정보를 담을 리스트
visited = [False] * (v + 1) # 방문 처리
stack = []
for _ in range(e):
a, b = map(int, input().split())
graph[a].append(b) # 노드 A에서 B로 이동 가능
def dfs(v):
visited[v] = True
for next in graph[v]:
if not visited[next]:
dfs(next)
stack.append(v)
def stack_topological_sort():
for i in range(1, v + 1):
if not visited[i]:
dfs(i)
while stack:
print(stack.pop(), end=" ")
stack_topological_sort()
위상 정렬 특징
시간 복잡도는 `O(V + E)`이다.
차례대로 모든 노드를 확인하며, 각 노드에서 나가는 간선을 차례대로 제거해야 한다.
위상 정렬은 DAG(Direct Acyclic Grpah, 방향이 있는 비순환 그래프)에 대해서만 수행 가능하다.
여러 가지 답이 존재할 수 있다.
같은 단계에 큐에 삽입되는 노드가 여러 개 있다면, 그들의 순서는 상관 없다.
모든 원소를 방문하기 전에 큐가 빈다면 사이클이 존재한다고 판단할 수 있다.
사이클에 포함된 원소 중 어떤 원소도 큐에 삽입될 수 없다.
스택을 활용한 DFS로 위상 정렬을 수행할 수 있다.
참고
https://bigsong.tistory.com/33
https://velog.io/@nmrhtn7898/Union-Find
https://sorjfkrh5078.tistory.com/36
'CS > 알고리즘' 카테고리의 다른 글
[알고리즘] 이코테(6) - 최단 경로 알고리즘 (다익스트라, 플로이드워셜) (0) | 2024.04.02 |
---|---|
[알고리즘] 이코테(5) - 다이나믹 프로그래밍 DP (1) | 2024.03.30 |
[알고리즘] 이코테(4) - 이진 탐색 / 이분 탐색 (0) | 2024.03.22 |
[알고리즘] 이코테(3) - 정렬 알고리즘 (0) | 2024.03.21 |
[알고리즘] 이코테(2) - DFS & BFS (0) | 2024.03.21 |