본문 바로가기

두두의 알고리즘/공부

[알고리즘] 그래프 - (서로소 집합 / 최소 신장 트리 / 위상 정렬)

728x90
  • 트리 자료구조는 최소 힙으로 항상 부모 노드가 자식 노드보다 크기가 작은 자료구조이다.

@ 서로소 집합

서로소 집합이란?

  • 공통 원소가 없는 두 집합

서로소 집합 자료구조(union-find 자료구조)란?

  • 서로소 부분 집합들로 나누어진 원소들의 데이터를 처리하기 위한 자료구조
  • union : 2개의 원소가 포함된 집합을 하나의 집합으로 합치는 연산
  • find : 특정한 원소가 속한 집합이 어떤 집합인지 알려주는 연산

[트리를 이용해 서로소 집합을 계산하는 알고리즘]

  1. union 연산을 확인하여, 서로 연결된 두 노드 A, B를 확인한다.
    1. A와 B의 루트 노드 A', B'를 각각 찾는다.
    2. 보통 작은 번호가 부모, 큰 번호가 자식이 된다.
    3. A'를 B'의 부모 노드로 설정한다.(B'가 A'를 가리키도록 한다.)
  2. 모든 union 연산을 처리할 때까지 1번 과정을 반복한다.
#개선된 서로소 집합 알고리즘
#시간복잡도 : O(V+M(1+log_{2-M/V}V) : 노드가 1000개 일 때, 1000만 번 연산

'''
6 4
1 4
2 3
2 4
5 6
'''

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())
	union_parent(parent, a, b)   #union 연산 각각 수행

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=' ')

 

[서로소 집합을 활용한 사이클 판별]

  • 무향 그래프에서만 적용 가능
  1. 각 간선을 확인하며 두 노드의 루트 노드를 확인한다.
    1. 루트 노드가 서로 다르다면 두 노드에 대하여 union 연산을 수행한다.
    2. 루트 노드가 서로 같다면 사이클이 발생한 것이다.
  2. 그래프에 포함되어 있는 모든 간선에 대하여 1번 과정을 반복한다.
'''
3 3 
1 2
1 3
2 3
'''

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

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, 또는 신장 트리 라고 불리움 (Spanning Tree가 보다 자연스러워 보임)
  • 원래의 그래프의 모든 노드가 연결되어 있으면서 트리의 속성을 만족하는 그래프
  • 신장 트리의 조건
      - 본래의 그래프의 모든 노드를 포함해야 함
      - 모든 노드가 서로 연결
      - 트리의 속성을 만족시킴 (사이클이 존재하지 않음)
  • 하나의 그래프가 있을 때 모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프

최소신장트리란?

  • Minimum Spanning Tree, MST 라고 불리움
  • 가능한 Spanning Tree 중에서, 간선의 가중치 합이 최소인 Spanning Tree를 지칭함
  • 그래프에서 최소 신장 트리를 찾을 수 있는 알고리즘이 존재함
  • 대표적인 최소 신장 트리 알고리즘
      - Kruskal’s algorithm (크루스칼 알고리즘), Prim's algorithm (프림 알고리즘)

 

크루스칼 알고리즘이란?

  1. 모든 정점을 독립적인 집합으로 만든다.
  2. 모든 간선을 비용을 기준으로 정렬하고, 비용이 작은 간선부터 양 끝의 두 정점을 비교한다.
  3. 두 정점의 최상위 정점을 확인하고, 서로 다를 경우 두 정점을 연결한다. (최소 신장 트리는 사이클이 없으므로, 사이클이 생기지 않도록 하는 것임)

> 탐욕 알고리즘을 기초로 하고 있음 (당장 눈 앞의 최소 비용을 선택해서, 결과적으로 최적의 솔루션을 찾음)

  • 신장 트리 중에서 최소 비용으로 만들 수 있는 신장 트리를 찾는 대표적인 알고리즘
  • 시간 복잡도 : O(E log E)
    •   - 다음 단계에서 2번, 간선을 비용 기준으로 정렬하는 시간에 좌우됨 (즉 간선을 비용 기준으로 정렬하는 시간이 가장 큼)
    • 1. 모든 정점을 독립적인 집합으로 만든다.
    •  2. 모든 간선을 비용을 기준으로 정렬하고, 비용이 작은 간선부터 양 끝의 두 정점을 비교한다.
    •    - 퀵소트를 사용한다면 시간 복잡도는 O(n log n) 이며, 간선이 n 이므로 O(E log E)
    •   3. 두 정점의 최상위 정점을 확인하고, 서로 다를 경우 두 정점을 연결한다. (최소 신장 트리는 사이클이 없으므로, 사이클이 생기지 않도록 하는 것임)
    •   - union-by-rank 와 path compression 기법 사용시 시간 복잡도가 결국 상수값에 가까움, O(1)

 

  1. 간선 데이터를 비용에 따라 오름차순으로 정렬한다.
  2. 간선을 하나씩 확인하며 현재의 간선이 사이클을 발생시키는지 확인한다.
    1. 사이클이 발생하지 않는 경우 최소 신장 트리에 포함시킨다.
    2. 사이클이 발생하는 경우 최소 신장 트리에 포함시키지 않는다.
'''
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
'''

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 i 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)
#패스트캠퍼스

mygraph = {
    'vertices': ['A', 'B', 'C', 'D', 'E', 'F', 'G'],
    'edges': [
        (7, 'A', 'B'),
        (5, 'A', 'D'),
        (7, 'B', 'A'),
        (8, 'B', 'C'),
        (9, 'B', 'D'),
        (7, 'B', 'E'),
        (8, 'C', 'B'),
        (5, 'C', 'E'),
        (5, 'D', 'A'),
        (9, 'D', 'B'),
        (7, 'D', 'E'),
        (6, 'D', 'F'),
        (7, 'E', 'B'),
        (5, 'E', 'C'),
        (7, 'E', 'D'),
        (8, 'E', 'F'),
        (9, 'E', 'G'),
        (6, 'F', 'D'),
        (8, 'F', 'E'),
        (11, 'F', 'G'),
        (9, 'G', 'E'),
        (11, 'G', 'F')
    ]
}

parent = dict()
rank = dict()


def find(node):
    # path compression 기법
    if parent[node] != node:
        parent[node] = find(parent[node])
    return parent[node]


def union(node_v, node_u):
    root1 = find(node_v)
    root2 = find(node_u)
    
    # union-by-rank 기법
    if rank[root1] > rank[root2]:
        parent[root2] = root1
    else:
        parent[root1] = root2
        if rank[root1] == rank[root2]:
            rank[root2] += 1
    
    
def make_set(node):
    parent[node] = node
    rank[node] = 0

def kruskal(graph):
    mst = list()
    
    # 1. 초기화
    for node in graph['vertices']:
        make_set(node)
    
    # 2. 간선 weight 기반 sorting
    edges = graph['edges']
    edges.sort()
    
    # 3. 간선 연결 (사이클 없는)
    for edge in edges:
        weight, node_v, node_u = edge
        if find(node_v) != find(node_u):
            union(node_v, node_u)
            mst.append(edge)
    
    return mst

 

프림 알고리즘이란?

  • 시작 정점을 선택한 후, 정점에 인접한 간선중 최소 간선으로 연결된 정점을 선택하고, 해당 정점에서 다시 최소 간선으로 연결된 정점을 선택하는 방식으로 최소 신장 트리를 확장해가는 방식
  • Kruskal's algorithm 과 Prim's algorithm 비교
      - 둘다, 탐욕 알고리즘을 기초로 하고 있음 (당장 눈 앞의 최소 비용을 선택해서, 결과적으로 최적의 솔루션을 찾음)
      - Kruskal's algorithm은 가장 가중치가 작은 간선부터 선택하면서 MST를 구함
      - Prim's algorithm은 특정 정점에서 시작, 해당 정점에 연결된 가장 가중치가 작은 간선을 선택, 간선으로 연결된 정점들에 연결된 간선 중에서 가장 가중치가 작은 간선을 택하는 방식으로 MST를 구함
  1. 임의의 정점을 선택, '연결된 노드 집합'에 삽입
  2. 선택된 정점에 연결된 간선들을 간선 리스트에 삽입
  3. 간선 리스트에서 최소 가중치를 가지는 간선부터 추출해서,
       - 해당 간선에 연결된 인접 정점이 '연결된 노드 집합'에 이미 들어 있다면, 스킵함(cycle 발생을 막기 위함)
       - 해당 간선에 연결된 인접 정점이 '연결된 노드 집합'에 들어 있지 않으면, 해당 간선을 선택하고, 해당 간선 정보를 '최소 신장 트리'에 삽입
  4. 추출한 간선은 간선 리스트에서 제거
  5. 간선 리스트에 더 이상의 간선이 없을 때까지 3-4번을 반복

- 최악의 경우, while 구문에서 모든 간선에 대해 반복하고, 최소 힙 구조를 사용하므로 O($ElogE$) 시간 복잡도를 가짐

myedges = [
    (7, 'A', 'B'), (5, 'A', 'D'),
    (8, 'B', 'C'), (9, 'B', 'D'), (7, 'B', 'E'),
    (5, 'C', 'E'),
    (7, 'D', 'E'), (6, 'D', 'F'),
    (8, 'E', 'F'), (9, 'E', 'G'),
    (11, 'F', 'G')
]

from collections import defaultdict
from heapq import *

def prim(start_node, edges):
    mst = list()
    adjacent_edges = defaultdict(list)
    for weight, n1, n2 in edges:
        adjacent_edges[n1].append((weight, n1, n2))
        adjacent_edges[n2].append((weight, n2, n1))

    connected_nodes = set(start_node)
    candidate_edge_list = adjacent_edges[start_node]
    heapify(candidate_edge_list)
    
    while candidate_edge_list:
        weight, n1, n2 = heappop(candidate_edge_list)
        if n2 not in connected_nodes:
            connected_nodes.add(n2)
            mst.append((weight, n1, n2))
            
            for edge in adjacent_edges[n2]:
                if edge[2] not in connected_nodes:
                    heappush(candidate_edge_list, edge)

    return mst

 

개선된 프림 알고리즘이란?

- 간선이 아닌 노드를 중심으로 우선순위 큐를 적용하는 방식
  - 초기화 - 정점:key 구조를 만들어놓고, 특정 정점의 key값은 0, 이외의 정점들의 key값은 무한대로 놓음.  모든 정점:key 값은 우선순위 큐에 넣음
  - 가장 key값이 적은 정점:key를 추출한 후(pop 하므로 해당 정점:key 정보는 우선순위 큐에서 삭제됨), (extract min 로직이라고 부름)
  - 해당 정점의 인접한 정점들에 대해 key 값과 연결된 가중치 값을 비교하여 key값이 작으면 해당 정점:key 값을 갱신
    - 정점:key 값 갱신시, 우선순위 큐는 최소 key값을 가지는 정점:key 를 루트노드로 올려놓도록 재구성함 (decrease key 로직이라고 부름)

- 개선된 프림 알고리즘 구현시 고려 사항
  - 우선순위 큐(최소힙) 구조에서, 이미 들어가 있는 데이터의 값 변경시, 최소값을 가지는 데이터를 루트노드로 올려놓도록 재구성하는 기능이 필요함
  - 구현 복잡도를 줄이기 위해, heapdict 라이브러리를 통해, 해당 기능을 간단히 구현

- 개선된 프림 알고리즘의 시간 복잡도: O(ElogV) 

from heapdict import heapdict

def prim(graph, start):
    mst, keys, pi, total_weight = list(), heapdict(), dict(), 0
    for node in graph.keys():
        keys[node] = float('inf')
        pi[node] = None
    keys[start], pi[start] = 0, start

    while keys:
        current_node, current_key = keys.popitem()
        mst.append([pi[current_node], current_node, current_key])
        total_weight += current_key
        for adjacent, weight in mygraph[current_node].items():
            if adjacent in keys and weight < keys[adjacent]:
                keys[adjacent] = weight
                pi[adjacent] = current_node
    return mst, total_weight
    
mygraph = {
    'A': {'B': 7, 'D': 5},
    'B': {'A': 7, 'D': 9, 'C': 8, 'E': 7},
    'C': {'B': 8, 'E': 5},
    'D': {'A': 5, 'B': 9, 'E': 7, 'F': 6},
    'E': {'B': 7, 'C': 5, 'D': 7, 'F': 8, 'G': 9},
    'F': {'D': 6, 'E': 8, 'G': 11},
    'G': {'E': 9, 'F': 11}    
}
mst, total_weight = prim(mygraph, 'A')
print ('MST:', mst)
print ('Total Weight:', total_weight)

 

@위상 정렬

  • 방향 그래프의 모든 노드를 '방향성에 거스르지 않도록 순서대로 나열하는 것'
  • 진입 차수(Indegree) : 특정한 노드로 들어오는 간선의 개수
  • 시간 복잡도 : O(V+E)
  1. 진입 차수가 0인 노드를 큐에 넣는다.
  2. 큐가 빌 때까지 다음의 과정을 반복한다.
    1. 큐에서 원소를 꺼내 해당 노드에서 출발하는 간선을 그래프에서 제거한다.
    2. 새롭게 진입차수가 0이 된 노드를 큐에 넣는다.
'''
7 8
1 2
1 5
2 3
2 6
3 4
4 7
5 6
6 4
'''

from collections import deque

v,e = map(int, input().split())
indegree = [0] * (v+1)
graph = [[] for i in range(v+1)]

for _ in range(e):
	a,b = map(int, input().split())
	graph[a].append(b)
	indegree[b] += 1

def topology_sort():
	result = []
	q = deque()
	for i in range(1, v+1):
		if indegree[i]==0:
			q.append(i)
	while q:
		now = q.popleft()
		result.append(now)
		for i in graph[now]:
			indegree[i] -= 1
			if indegree[i] == 0:
				q.append(i)
for i in result:
	print(i, end=' ')

topology_sort()