앞서 배운 그래프 알고리즘 유형으로 'DFS/BFS'와 '최단 경로' 알고리즘이 있었다. 알고리즘 문제를 접했을 때 '서로 다른 개체(혹은 객체)가 연결되어 있다'라는 사실을 확인하면 가장 먼저 그래프 알고리즘을 떠올려야 한다.
트리 자료구조는 부모에서 자식으로 내려오는 계층적인 모델이며 주로 방향 그래프로 간주된다. 한 예로서 최소 힙은 부모 노드가 항상 자식 노드보다 크기가 작은 자료구조이다. 트리는 그래프와 달리 루트 노드가 존재하며, 부모와 자식 관계가 존재한다.
그래프의 구현 방법은 2가지 방식이 존재한다.
인접 행렬은 간선 정보를 저장하기 위해 O(V^2)의 메모리 공간이 필요하는 반면 인접 리스트는 간선 개수인 O(E)만큼만 메모리 공간이 필요하다. 또한 인접 행렬은 노드 A에서 노드 B로 이어진 간선의 비용을 O(1)의 시간으로 즉시 알 수 있지만 인접 리스트는 O(V)만큼의 시간이 소요된다.
우선순위 큐를 이용하는 다익스트라 최단 경로 알고리즘은 인접 리스트 방식을 이용하였다. 노드의 개수가 V이면 V개의 리스트를 만들어 각 노드와 연결된 모든 간선에 대한 정보를 리스트에 저장했다.
플로이드 워셜 알고리즘은 인접 행렬 방식을 이용하였다. 모든 노드에 대해 다른 노드로 가는 최소 비용을 V^2 크기의 2차원 리스트에 저장 후 해당 비용을 갱신하여 최단 거리를 계산했다.
최단 경로를 찾아야 하는 문제가 출제되었을 때, 노드의 개수가 적은 경우에는 플로이드 워셜 알고리즘을 사용할 수 있다. 반면 노드와 간선의 개수가 모두 많으면 우선순위 큐를 이용하는 다익스트라 알고리즘을 이용하면 유리하다.
서로소 집합이란 공통 원소가 없는 두 집합을 의미한다. 예를 들어 집합 {1, 2}와 집합 {3, 4}는 서로소 관계이다.
그렇다면 서로소 집합 자료구조란 서로소 부분 집합들로 나누어진 원소들의 데이터를 처리하기 위한 자료구조이다. 서로소 집합 자료구조는 union과 find 이 2개의 연산으로 조작할 수 있으며, union-find 자료구조라고 불리기도 한다.
union(합집합) 연산은 2개의 원소가 포함된 집합을 하나의 집합으로 합치는 연산이며, find(찾기) 연산은 특정 원소가 속한 집합이 어떤 집합인지 알려주는 연산이다.
서로소 집합 자료구조를 구현할 때는 트리 자료구조를 이용하여 집합을 표현하는데, 트리를 이용한 서로소 집합 계산 알고리즘은 다음과 같다.
실제로 구현 시에는 A'와 B' 중 번호가 더 작은 원소가 부모 노드가 되도록 구현하는 경우가 많다. (ex) A'가 1이고, B'가 3이면 B'가 A'를 가리키도록 설정) 이때 '가리킨다'라는 표현은 부모 노드로 설정한다는 뜻이다.
예시를 통해 서로소 집합 계산 알고리즘 동작을 살펴보자.
전체 집합 {1, 2, 3, 4, 5, 6}이 있고, 다음과 같은 4개의 union 연산이 주어져 있다.
union x, y는 'x와 y는 같은 집합'이라는 의미를 가진다. '같은 집합에 속한다'는 정보를 담은 union 연산들은 간선으로 표현된다. 즉 위의 4개의 union 연산은 6개의 노드와 4개의 간선이 존재하는 그래프로도 생각할 수 있다.
앞서 말했듯이, 각 원소의 집합 정보를 표현하려면 트리 구조를 이용한다. 일반적으로 서로소 집합을 표현할 때는 번호가 큰 노드가 번호가 작은 노드를 간선으로 가리킨다. 즉, 번호가 작은 노드가 부모가 되고, 번호가 큰 노드가 자식이 된다.
이제 서로소 집합 알고리즘을 자세히 살펴보자. union 연산을 하나씩 확인하며 서로 다른 두 원소에 대해 합집합을 수행해야 할 때는, 각각 두 원소의 루트 노드를 찾아 더 큰 루트 노드가 더 작은 루트 노드를 가리키도록 한다. 동작 과정은 아래와 같다.
유의할 점은, 루트 노드를 즉시 계산할 수 없다. 노드 3의 루트 노드는 2가 아닌 1인 것처럼 말이다.
find 연산은 각 원소의 최종적인 루트 노드를 찾아준다. 이때 경로 압축은 find 함수를 재귀적으로 호출한 뒤에 부모 테이블 값을 갱신하는 기법이다. 모든 union 함수를 수행한 후, 각 원소에 대해 find 연산을 수행하면 각 부모 노드 번호 테이블 값이 루트 노드 번호로 갱신된다.
아래 코드는 find 연산을 재귀적으로 호출하여 부모 테이블 값을 루트 노드 번호로 갱신하는 경로 압축 기법을 이용한 것이다. 따라서 부모 테이블 결과 역시 1 1 1 1 5 5 로 나오게 된다.
import sys
input = sys.stdin.readline
#특정 원소가 속한 집합 찾기 (즉, 재귀적으로 부모를 찾으며 원소의 루트 노드 찾기)
def find_parent(parent, x) :
# 루트 노드가 아니라면, 루트 노드를 찾을 때까지 재귀적으로 호출
if parent[x] != x :
parent[x] = find_parent(parent, parent[x])
return parent[x]
# union 연산
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, 간선(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)
# 각 원소가 속한 집합 출력
print('각 원소가 속한 집합: ', end=' ')
for i in range(1, v+1) :
print(find_parent(parent, i), end=' ')
print()
# 부모 테이블 내용 출력
print('부모 테이블: ', end=' ')
for i in range(1, v+1) :
print(parent[i], end=' ')
서로소 집합은 무방향 그래프 내에서의 사이클을 판별할 때 사용할 수 있다. 앞서 union 연산은 그래프에서 간선으로 표현될 수 있다고 했다. 따라서 간선을 하나씩 확인하면서 두 노드가 포함되어 있는 집합을 합치는 과정을 반복하는 것으로도 사이클 판별이 가능하다. 알고리즘은 아래와 같다.
import sys
input = sys.stdin.readline
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)
for i in range(1, v+1) :
parent[i] = i
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('사이클 발생하지 않음.')
Reference
『이것이 코딩 테스트다 with 파이썬』, 나동빈 지음