일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 동적sql
- eager
- 이진탐색
- 백트래킹
- 즉시로딩
- CHECK OPTION
- exclusive lock
- 비관적락
- execute
- 연관관계
- FetchType
- BOJ
- 스토어드 프로시저
- 데코레이터
- PS
- 다대다
- querydsl
- 다대일
- 유니크제약조건
- 지연로딩
- SQL프로그래밍
- 낙관적락
- 일대다
- 연결리스트
- 스프링 폼
- JPQL
- 힙
- fetch
- shared lock
- dfs
- Today
- Total
흰 스타렉스에서 내가 내리지
그래프 알고리즘- union·find, mst - kruskal·prim, topology 본문
목차
- 서로소 집합 :: union-find
- 신장 트리 :: Spanning Tree
- 크루스칼 알고리즘
- 프림 알고리즘
- 위상 정렬 :: Topology Sort
# 1. 서로소 집합 :: Disjoint Sets
"서로소 부분 집합들로 나누어진 원소들의 데이터를 처리하기 위한 자료구조"
- 서로소 집합을 union-find 자료구조 라고 부르기도 한다.
- union과 find, 이 2개의 연산으로 조작할 수 있다.
1. union :
- 2개의 집합을 하나의 집합으로 합치는 연산이다.
2. find :
- 특정한 원소가 속한 집합이 어떤 집합인지 알려주는 연산
- 스택과 큐 자료구조가 push와 pop 연산으로 이루어졌던 것처럼, 서로소 집합 자료구조는 union과 find 연산으로 구성된다.
- 트리 자료구조를 이용하여 집합을 표현한다.
* 구현 과정
- union 연산을 확인하여, 서로 연결된 두 노드 A, B를 확인한다.
- A와 B의 루트노드 A' , B' 를 각각 찾는다.
- A' 를 B' 의 부모 노드로 설정한다. (B' 가 A' 를 가리키도록 한다.)
- 모든 union (합집합) 연산을 처리할 때까지 1번 과정을 반복한다.
- 실제로 구현할 떄는 A' 와 B' 중에서 더 번호가 작은 원소가 부모 노드가 되도록 구현하는 경우가 더 많다
- 특정 원소의 루트를 찾기 위해서는 재귀적으로 부모를 거슬러 올라가야 한다.
# 특정 원소가 속한 집합을 찾기
def find_parent(parent, x):
# 루트 노드가 아니라면, 루트 노드를 찾을 때까지 재귀적으로 호출
if parent[x] != x:
return find_parent(parent, parent[x])
return x
# 두 원소가 속한 집합을 합치기
def union(parent, a, b):
a = find_parent(parent, a)
b = find_parent(parent, b)
if a < b:
parent[b] = a
else:
parent[a] = b
- find() 함수가 비효율적이다. 최악의 경우 find 함수가 모든 노드를 확인하여, 시간복잡도가 O(V)가 된다.
- 결과적으로 최종 시간복잡도는 노드의 개수가 V개이고 find 혹은 union 연산의 개수가 M개일 때, O(VM)이 되어 비효율적이다.
- 경로 압축(Path Compression) 기법을 적용하면 find 연산의 시간 복잡도를 개선시킬 수 있다.
→ find 함수를 재귀적으로 호출한 뒤에 부모 테이블 값을 갱신하는 기법이다.
# 기존의 find 함수를 다음과 같이 변경하면 경로 압축 기법의 구현이 완료된다.
def find_parent(parent, x):
# 루트 노드가 아니라면, 루트 노드를 찾을 때까지 재귀적으로 호출
if parent[x] != x:
parent[x] = find_parent(parent, parent[x])
return parent[x]
- 자식 노드들이 바로 위 부모노드를 가리키는 것이 아닌, 무조건 루트 노드를 가리키게 된다.
- 결과적으로, find 연산에서 루트 노드에 더욱 빠르게 접근할 수 있다는 점에서 기존의 기본적인 알고리즘과 비교했을 떄 시간 복잡도가 개선된다.
* union-find의 활용 : 사이클 판별
- 무방향 그래프 내에서의 사이클을 판별할 떄 사용할 수 있다.
- 방향 그래프에서의 사이클 여부는 DFS를 이용하여 판별할 수 있다.
* 구현 과정
- 각 간선을 확인하며 두 노드의 루트 노드를 확인한다.
- 루트 노드가 서로 다르다면 두 노드에 대하여 union 연산을 수행한다.
- 루트 노드가 서로 같다면 사이클이 발생한 것이다.
- 그래프에 포함되어 있는 모든 간선에 대하여 1번 과정을 반복한다.
# 특정 원소가 속한 집합을 찾기
def find_parent(parent, x):
# 루트 노드가 아니라면, 루트 노드를 찾을 때까지 재귀적으로 호출
if parent[x] != x:
parent[x] = find_parent(parent, parent[x])
return parent[x]
# 두 원소가 속한 집합을 합치기
def union(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 = [i for i in range(v+1)]
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 수행
union(parent, a, b)
if cycle:
print("사이클이 발생했습니다.")
else:
print("사이클이 발생하지 않았습니다.")
"""
3 3
1 2
1 3
2 3
>> 사이클이 발생했습니다.
"""
# 2. 신장 트리 :: Spanning Tree
"하나의 그래프가 있을 때 모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프"
- 모든 노드가 포함되어 서로 연결되면서 사이클이 존재하지 않는다는 것은 트리의 성립 조건이기도 하기 때문에 신장 트리하고 부르는 것이다.
- 신장 트리 중에서 최소 비용으로 만들 수 있는 신장 트리를 찾는 알고리즘을 '최소 신장 트리 알고리즘'이라고 한다.
# 2-1. MST - 크루스칼 알고리즘 :: Kruskal Algorithm
- 가장 적은 비용으로 모든 노드를 연결할 수 있는데, 크루스칼 알고리즘은 그리디 알고리즘으로 분류된다.
- 모든 간선에 대하여 정렬을 수행한 뒤에 가장 거리가 짧은 간선부터 집합에 포함시킨다. 이 때, 사이클을 발생시킬 수 있는 간선의 경우, 집합에 포함시키지 않는다.
* 구현 과정
- 간선 데이터를 비용에 따라 오름차순으로 정렬한다.
- 간선을 하나씩 확인하며 현재의 간선이 사이클을 발생시키는지 확인한다.
- 사이클이 발생하지 않는 경우 최소 신장 트리에 포함시킨다.
- 사이클이 발생하는 경우 최소 신장 트리에 포함시키지 않는다.
- 모든 간선에 대하여 2번의 과정을 반복한다.
* 시간복잡도
- 간선의 개수가 E일 때, O(ElogE)의 시간 복잡도를 가진다.
- 왜냐하면 크루스칼 알고리즘에서 시간이 가장 오래 걸리는 부분이 간선을 정렬하는 작업이며, E개의 데이터를 정렬했을 때의 시간 복잡도는 O(ElogE)이기 때문이다.
- 크루스칼 내부에서 사용되는 서로소 집합 알고리즘의 시간 복잡도는 정렬 알고리즘의 시간 복잡도보다 작으므로 무시한다.
def find_parent(parent, x):
if parent[x] != x:
parent[x] = find_parent(parent, parent[x])
return parent[x]
def union(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 = [i for i in range(v+1)]
# 모든 간선을 담을 리스트와 최종 비용을 담을 변수
edges = []
result = 0
# 모든 간선에 대한 정보를 입력받기
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, a, b)
result += cost
print(result)
# 2-2. MST - 프림 알고리즘 :: Prim Algorithm
- 프림 알고리즘 역시 MST를 찾기 위한 그리디 알고리즘이다.
* 구현 과정
- 임의의 정점을 선택하여 비어있는 T 에 포함시킨다. (T는 트리 집합)
- T에 있는 노드와 T에 없는 노드 사이의 간선 중 가중치가 최소인 간선을 찾는다.
- 찾은 간선이 연결하는 두 노드 중, T에 없던 노드를 T에 포함시킨다.
- 모든 노드가 T에 포함될 때까지 1, 2번을 반복한다.
- 처음 선택했던 임의의 정점으로부터, 서서히 영역 확장을 해간다고 생각하면 된다.
- 크루스칼은 노드를 여기저기 뽑아다가 마지막에 연결되는 방식이었다.
* 시간 복잡도
- 방법 1은 배열을 사용하며, T와 각 노드를 연결하는 최소 간선 가중치를 찾기 때문에 O(V^2)이다.
- 방법 2는 최소 힙(min heap)을 사용하여 최소의 간선 가중치를 찾는 방법이며, 시간 복잡도는 O(E logE) = O(E log V)이다.
- T 안에 있는 노드가 출발점인 간선들을 모두 최소 힙에 넣어주면 된다.
* 소스코드 1 (최종 MST는 그릴 수 없음. 최종 MST를 구하려면 더 아래 코드)
import heapq
v, e = map(int, input().split())
graph = [[] for i in range(v+1)]
for _ in range(e):
a, b, c = map(int, input().split())
graph[a].append((b, c)) # (노드, 거리)
graph[b].append((a, c)) # 무방향 그래프임을 가정하였으므로 양쪽 다 추가
start = 1 # 시작 정점을 1로 선택
visited = set() # 방문한 정점을 저장하는 집합
q = [(0, start)] # 간선을 저장할 최소 힙 리스트
mst = [] # 최소 신장 트리를 저장하는 리스트
res = 0 # 최종 비용을 담는 변수
while q:
dist, now = heapq.heappop(q)
if now in visited: # 이미 방문한 경우 스킵
continue
visited.add(now) # 방문 처리
mst.append((now, dist)) # 최소 신장 트리에 현재 간선 추가
res += dist
# 현재 정점과 연결된 간선을 최소 힙에 추가
for n, d in graph[now]:
if n not in visited:
heapq.heappush(q, (d, n))
print(res)
- 지금은 최종비용만 나오는데, 그래프를 그리고 싶다면 어떻게 해야할까?
→ mst 배열에 노드와 거리 값이 포함되어 있어 다시 복기하면 되겠지만, 만약 중복되는게 있다면???
→ 그러기 위해서 아래와 같이 큐에 부모 노드도 포함해서 넣어주면 되겠다.
* 소스코드 2 (최종 MST를 그릴 수 있음)
import heapq
v, e = map(int, input().split())
graph = [[] for i in range(v+1)]
for _ in range(e):
a, b, c = map(int, input().split())
graph[a].append((b, c)) # (노드, 거리)
graph[b].append((a, c)) # 무방향 그래프임을 가정하였으므로 양쪽 다 추가
start = 1 # 시작 정점을 1로 선택
visited = set() # 방문한 정점을 저장하는 집합
q = [(0, start, start)] # 간선을 저장할 최소 힙 리스트. 최종 그래프를 구하기 위해서 부모 노드도 세번째 원소로 넣어준다.
mst = [] # 최소 신장 트리를 저장하는 리스트
res = 0 # 최종 비용을 담는 변수
while q:
dist, now, parent = heapq.heappop(q)
if now in visited: # 이미 방문한 경우 스킵
continue
visited.add(now) # 방문 처리
mst.append((parent, now, dist)) # 최소 신장 트리에 현재 간선 추가. (부모, 자식, 거리)
res += dist
# 현재 정점과 연결된 간선을 최소 힙에 추가
for n, d in graph[now]:
if n not in visited:
heapq.heappush(q, (d, n, now)) # 부모 노드도 함께 heap에 저장한다.
print(mst)
print(res)
"""input
7 9
1 2 29
1 5 75
2 3 35
2 6 34
3 4 7
4 6 23
4 7 13
5 6 53
6 7 25
>> [(1, 1, 0), (1, 2, 29), (2, 6, 34), (6, 4, 23), (4, 3, 7), (4, 7, 13), (6, 5, 53)]
>> 159
"""
# 3. 위상 정렬 :: Topology Sort
"방향 그래프의 모든 노드를 '방향성에 거스르지 않도록 순서대로 나열하는 것'"
- 대표적인 예시로 '선수과목을 고려한 학습 순서 설정'이 있다.
- 먼저 진입차수(Indegree)를 알아야 한다.
- 진입차수 : 특정 한 노드로 '들어오는' 간선의 개수
- 예) 선수과목이 2개라면 진입차수는 2이다.
* 구현 과정
- 진입차수가 0인 노드를 큐에 넣는다.
- 큐가 빌 때까지 다음의 과정을 반복한다.
- 큐에서 원소를 꺼내 해당 노드에서 출발하는 간선을 그래프에서 제거한다.
- 새롭게 진입차수가 0이 된 노드를 큐에 넣는다.
- 모든 원소를 방문하기 전에 큐가 빈다면 사이클이 존재한다고 판단할 수 있다.
- 한 단계에서 큐에 들어가는 원소가 2개 이상인 경우가 있다면, 여러 가지 답이 존재하게 된다.
* 시간복잡도
- O(V+E) 이다.
- 위상 정렬을 수행할 때는 차례대로 모든 노드를 확인하면서, 해당 노드에서 출발하는 간선을 차례대로 제거해야 한다.
- 결과적으로 노드와 간선을 모두 확인한다는 측면에서 O(V+E)의 시간이 소요되는 것이다.
'Algorithm' 카테고리의 다른 글
[노트] 0-1 KnapSack Problem (0) | 2024.01.17 |
---|---|
효율적으로 거듭제곱 계산하기 (2) | 2024.01.05 |
외판원 문제 (Traveling Salesman Problem, TSP) (0) | 2022.03.16 |
탐욕 알고리즘 (Greedy Algorithm) (0) | 2022.01.06 |
[알고리즘] 에라토스테네스의 체(Sieve of Eratosthenes) (0) | 2022.01.03 |