28. 그래프의 깊이 우선 탐색
28.1 도입
- 탐색(search) 알고리즘: 트리의 순회와 같이 그래프의 모든 정점들을 특정헌 순서에 따라 방문하는 알고리즘들
- 정점들을 정해진 순서대로 둘러보기 위한 알고리즘, 순회 알고리즘
- 탐색 과정에서 얻는 정보가 아주 중요
- 탐색 과정에서 어떤 간선이 사용되었는지, 또 어떤 순서로 정점들이 방문되었는지를 통해 그래프의 구조를 알 수 있다.
28.1.1 깊이 우선 탐색(depth-first search)
깊이 우선 탐색(depth-first search, DFS) 는 그래프의 모든 정점을 발견하는 가장 단순하고 고전적인 방법
깊이 우선 탐색은 그래프의 시작 노드에서 출발하여 탐색할 한 쪽 분기를 정하여 최대 깊이까지 탐색을 마친 후, 다른 쪽 분기로 이동하여 다시 탐색을 수행하는 알고리즘.
현재 정점과 인접한 간선들을 하나씩 검사하다가, 아직 방문하지 않은 정점으로 향하는 간선이 있다면 그 간선을 무조건 따라간다.
이 과정에서 더이상 갈 곳이 없는 막힌 정점에 도달하면 포기하고, 마지막에 따라왔던 간선을 따라 뒤로 돌아간다.
탐색의 각 과정에서 가능한 한 그래프 안으로 ‘깊이’ 들어가려고 시도하며, 막힌 정점에 도달하지 않는 한 뒤로 돌아가지 않는다.
더 따라갈 간선이 없을 경우 이전으로 돌아간다.
지금까지 방문한 정점들을 모두 저장(기록) 해야 한다.
재귀 호출을 이용하면 이와 같은 일을 간단히 할 수 있게 된다.
‘기본’ 적인 재귀, dfs 코드
얼마든지 문제에 따라 변형 할 수 있어야 한다.
|
|
- ‘기본’ 적인 반복문, dfs 코드
|
|
그래프에서는 모든 정점들이 간선을 통해 연결되어 있다는 보장이 없기 때문에,
dfs()
만으로는 모든 정점을 순서대로 발견한다는 목적에 부합하지 않는다.dfsAll()
로 모든 그래프 조각을 발견(탐색)한다.
깊이 우선 탐색은 그래프 전체의 구조를 파악하기 위해 사용,
- 그래프의 구조상 한 번에 모든 정점을 다 볼 수 없는 경우에도 모든 정점을 다 방문할 필요가 있다. =>
dfsAll()
- 그래프의 구조상 한 번에 모든 정점을 다 볼 수 없는 경우에도 모든 정점을 다 방문할 필요가 있다. =>
28.1.2 깊이 우선 탐색의 시간 복잡도
인접 리스트의 경우
dfs()
는 한 정점마다 수행되므로, 정확히 V번 호출모든 정점(V)에 대해
dfs()
를 수행하고 나면 모든 간선(E)을 정확히 한 번(방향 그래프의 경우) 혹은 두 번(무향 그래프) 확인함따라서 깊이 우선 탐색의 시간 복잡도는 O(V+E)
인접 행렬을 사용하는 경우에도 dfs()의 호출 횟수는 V번
- 하지만, 인접 행렬을 사용할 때는 dfs() 내부에서 다른 모든 정점을 순회하며 두 정점 사이에 간선이 있는가를 확인해야 함
- 한 번의
dfs()
실행에 O(V)의 시간이 든다 - 따라서 인접 행렬의 경우 전체 시간 복잡도는 O(V^2) 이다.
28.1.3 위상 정렬
위상 정렬은 의존성이 있는 작업들이 주어질 때, 이들을 어떤 순서로 수행해야 하는지 계산해 준다.
- 위상 정렬의 결과는 항상 같지 않다. 구현 방법에 따라 달라진다.
각 작업들을 정점으로 표현하고, 작업 간의 의존 관계를 간선으로 표현한 방향 그래프를 의존성 그래프(dependency graph) 라고 한다.
- 의존성 그래프는 그래프에 사이클이 없다.
- 이 그래프는 사이클이 없는 방향 그래프(DAG) 가 된다.
의존성 그래프의 정점들을 일렬로 늘어놓고, 왼쪽에서부터 하나씩 수행한다고 하자
- 이때 모든 의존성이 만족되려면 모든 간선이 왼쪽에서 오른쪽으로 가야 한다.
- 이렇게 DAG의 정점을 배열하는 문제를 위상 정렬(topological sort) 이라고 한다.
위상 정렬을 구현하는 방법은
dfsAll()
을 수행하며dfs()
가 종료할 때마다 현재 정점의 번호를 기록하는 것dfsAll()
이 종료한 뒤 기록된 순서를 뒺비으면 위상 정렬 결과를 얻을 수 있다.dfs()
가 늦게 종료한 정점일 수록 정렬 결과의 앞에 온다.
dfs가 아닌 queue 를 이용한 위상 정렬
- 입력 차수, 진입 차수 (In-degree): 방향 그래프에서 한 정점으로 들어오는 간선의 수
- 출력 차수, 진출 차수 (Out-degree): 방향 그래프에서 한 정점에서 나가는 간선의 수
- 차수 (degree): 한 정점에서 인접한 간선의 수
|
|
28.2, 28.3 고대어 사전, DICTIONARY, 난이도: 하
- 각 알파벳을 정점으로 표현, 한 알파벳이 다른 알파벳 앞에 와야 할 때 두 정점을 방향 간선으로 연결
- 우리가 원하는 알파벳 순서는 이 그래프를 위상 정렬 한 결과
- 인접한 단어들만 검사하더라도 그래프의 위상 정렬 결과는 모든 단어 쌍을 검사했을 때와 같다.
- 단어 A, B, C가 순서대로 등장한다면, (A, C)는 검사하지 않고, (A, B), (B, C)쌍 만 검사해도 된다.
- 그래프가 DAG면 빈 리스트 반환 / 아니면, 위상 정렬 결과 반환
28.7 이론적 배경과 응용
28.7.1 깊이 우선 탐색과 간선의 분류
DFS를 수행하면, 그 과정에서 그래프의 모든 간선을 한 번씩 만나게 된다.
일부 간선은 처음 발견한 정점으로 따라가고, 나머지는 무시하게 된다.
- 그러나 이 간선들을 무시하지 않고 이들에 대한 정보를 수집하면 그래프의 구조에 대해 많은 것을 알 수 있다.
탐색이 따라가는 간선들만 모아 보면 트리 형태를 띠게 된다.
- 이런 트리를 주어진 그래프의
DFS 스패닝 트리(DFS Spanning Tree)
라고 부른다.
- 이런 트리를 주어진 그래프의
DFS 스패닝 트리를 생성하면, 그래프 중 모든 간선을 네 가지 중 하나로 분류 할 수 있다.
간선 분류
트리 간선(tree edge): 스패닝 트리에 포함된 간선
순방향 간선(forward edge): 스패닝 트리의 선조에서 자손으로 연겨되지만 트리 간선이 아닌 간선
역방향 간선(back edge): 스패닝 트리의 자손에서 선조로 연결되는 간선
교차 간선(cross edge): 1.2.3번 3가지 를 제외한 나머지 간선들,
- 교차 간선은 트리에서 선조와 자손 관계가 아닌 정점들 간에 연결된 간선들을 의미
그래프의 간선을 항상 4가지 중 하나로 분류해야 하는 것은 아니다.
깊이 우선 탐색이 어느 순서대로 정점을 방문하느냐(실제 구현 방법)에 따라 서로 다른 트리가 생성될 수 있다.
무향 그래프 간선의 분류
- 무향 그래프는 교차 간선, 순방향, 역방향 간선의 구분이 있을 수 없다.
28.7.2 사이클 존재 여부 확인
간선 구분
의 사용 예시, 방향 그래프에 사이클이 존재하는지 여부를 판정- ‘사이클의 존재 여부’는 ‘역방향 간선 존재 여부’ 와 동치
- 사이클에 포함된 정점 중 dfs 에서 처음 만나는 정점을 u라고 할 때,
- dfs(u)는 u에서 갈 수 있는 정점들을 모두 방문한 후에야 종료한다.
- 따라서, dfs는 사이클에서 u 이전에 있는 정점을 dfs(u)가 종료하기 전에 방문하게 되는데,
- 이 정점에서 u로가는 정점은 항상 역방향 간선이 된다.
28.7.3 간선 구분
하는 방법
정점이 방문할 때 이 정점이 방문되었다는 사실 뿐만 아니라, 이 정점이 몇 번째로 발견되었는지도 동시에 기록
탐색 과정에서 각 정점을 몇 번째로 발견했는지를
배열 discovered[]
에 저장- (u, v)가 순방향 간선 이려면, v는 u의 자손, 따라서 v는 u보다 더 늦게 발견되어야 한다.
- (u, v)가 역방향 간선 이려면 v는 u의 선조, 따라서 v는 u보다 일찍 발견되어야 한다.
- (u, v)가 교차 간선 이려면, dfs(v)가 종류한 후 dfs(u)가 호출되어야 한다. 따라서 v는 u보다 일찍 발견되어야 한다.
이처럼 발견 순서 정보를 이용하면 해당 간선이 순방향 간선인지를 알아낼 수 있다.
반면 v가 u보다 먼저 방문되었다면 v가 u의 부모인지 아닌지를 구분할 방법이 없다.
이때 구분하는 한 가지 방법은 dfs(v)가 종료햇는지를 확인하는 것
dfs(v)가 아직 종료하지 않았다면 v는 u의 선조 및 (u, v)는 역뱡향 간선
아니면 교차 간선
간선을 구분하는 깊이 우선 탐색 알고리즘 python 구현
|
|
28.7.3 예제: 절단점 찾기 알고리즘
그래프의 절단점(cut vertex) 를 찾는 문제 - DFS를 이용해서 풀 수 있는 응용 문제 중 하나
무향 그래프의 절단점: 인접한 간선들을 모두 지웠을 때 해당 컴포넌트가 두 개 이상으로 나뉘어지는 정점을 말한다.
- 1, 3, 5번이 절단점이 된다.
어떤 정점이 절단점인지 확인하는 간단한 방법은 해당 정점을 그래프에서 삭제한 뒤, 컴포넌트의 개수가 이전보다 늘어났는지를 확인하는 것
모든 정점의 절단점 여부를 확인하려면, 깊이 우선 탐색을 V번 수행
그러나 탐색 과정에서 얻는 정보를 잘 이용하면 한 번의 깊이 우선 탐색만으로 그래프의 모든 절단점을 찾을 수 있다.
임의의 정점에서 DFS를 수행해 DFS 스패닝 트리를 얻는다.
어떤 정점 u 가 절단점인지 알 수 있는 방법
- 그래프가 쪼개지지 않는 유일한 경우는 그림 처럼 u의 선조와 자손들이 전부 역뱡향 간선으로 연결되어 있을 때 뿐이다.
- 이것을 확인하는 방법은 DFS를 수행하는 dfs2()가 각 정점을 루트로 하는 서브트레엇 역뱡향 간선을 통해 갈 수 있는 정점의 최소 깊이를 반환하도록 하는 것
- 만약 u의 자손들이 모두 역방향 간선을 통해 u의 선조로 올라갈 수 있다면 u는 절단점이 아니다.
u가 스패닝 트리의 루트라서 선조가 없다면 어떻게 될까?
- u는 무조건 절단점이라고 생각하기 쉽지만, 간과하기 쉬운 예외가 있다.
- 바로 자손이 하나도 없거나 하나밖에 없는 경우다.
- 이 경우 u가 없어져도 그래프는 쪼개지지 않는다.
- 따라서 u가 루트인 경우 둘 이상의 자손을 가질 때만 절단점이 된다.
실제로 이 알고리즘을 구현할 때는 각 정점의 깊이를 비굔하는 대신, 각 정점의 발견 순서를 비교하는 형태로 코드를 작성해 간단히 구현할 수 있다.
- 알고 싶은 것은 해당 서브트리가 u의 조상 중 하나와 연결되어 있는지인데, u의 조상들은 항상 u보다 먼저 발견된다.
- 따라서, 깊이 우선 탐색 함수가 해당 정점을 루트로 하는 서브트리에서 역방향 간선을 통해 닿는 정점들의 최소 발견 순서를 반환하면 된다.
무향 그래프에서 절단점을 찾는 알고리즘 python 구현
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 32
N = 10 discovered = [-1 for _ in range(N)] # i번 정점의 발견 순서 finished = [False for _ in range(N)] # dfs가 종료했으면 True 아니면 False isCutVertex = [False for _ in range(N)] # True면 절단점 graph = [[] for _ in range(N)] result = dict() counter = 0 # 지금까지 발견한 정점의 수 # node를 루트로 하는 서브트리에 있는 절단점들을 찾는다 # 반환 값은 해당 서브트리에서 역방향 간선으로 갈 수 있는 정점 중, 가장 일찍 발견된 정점의 발견 시점 # 처음 호출 할때는 isRoot = True 로 둔다. def findCutVertex(node, isRoot): global counter counter += 1 discovered[node] = counter ret = discovered[node] children = 0 nxtLst = graph[node] for nxt in nxtLst: if discovered[nxt] == -1: children += 1 # 루트인 경우의 절단점 판정을 위해 자손 서브트리의 개수를 샌다. subTreeMaxNode = findCutVertex(nxt, False) # 서브 트리에서 갈 수 있는 가장 높은 정점의 번호 ret = min(ret, subTreeMaxNode) if not isRoot and subTreeMaxNode >= discovered[node]: # 그 노드가 자기 자신의 이하에 있다면 현재 위치는 절단점이다. isCutVertex[node] = True else: ret = min(ret, discovered[nxt]) if isRoot: isCutVertex[node] = (children >= 2) # 루트인 경우 절단점 판정은 서브트리의 개수 return ret
무향 그래프에서 절단점을 포함하지 않는 서브 그래프를
이중 결합 컴포넌트(biconnected component)
라고 부른다.
Reference
- 프로그래밍 대회에서 배우는 알고리즘 문제 해결 전략 (구종만, 인사이트)
- https://www.algospot.com/