위상 정렬
위상 정렬(topological sort)은 방향 그래프의 정점을 모든 간선이 앞쪽 정점에서 뒤쪽 정점으로 향하도록 한 줄로 세우는 일입니다. 간선 u → v를 "u를 v보다 먼저 해야 한다"는 뜻으로 읽으면, 위상 정렬의 결과는 선후 관계를 모두 지키는 작업 순서가 됩니다. 수강 신청의 선수 과목, 빌드 도구의 작업 의존 관계, 데이터 파이프라인의 단계 순서가 모두 이 문제입니다.
선후 관계가 서로를 기다리며 돌고 있으면 순서를 정할 수 없습니다. 그래서 위상 정렬은 사이클이 없는 방향 그래프(DAG)에서만 가능합니다. 대표적인 방법은 진입 차수가 0인 정점부터 꺼내는 Kahn 알고리즘과 깊이 우선 탐색이 끝나는 순서를 뒤집는 DFS 방식입니다. 두 방법 모두 사이클 여부를 함께 알려 줍니다. 이 토픽에서는 같은 그래프에 두 방법을 적용하고, 사이클이 있는 그래프에서 어떻게 멈추는지도 봅니다.
핵심 개념
위상 순서
모든 간선 u → v에서 u가 v보다 앞에 오는 정점의 나열입니다. 선후 관계가 없는 정점 쌍은 어느 쪽이 먼저 와도 되므로 올바른 순서가 여러 개일 수 있습니다.
핵심 개념
DAG
방향이 있고 사이클이 없는 그래프(Directed Acyclic Graph)입니다. 위상 순서가 존재하는 것과 DAG라는 것은 서로 필요충분조건입니다.
핵심 개념
진입 차수
정점으로 들어오는 간선의 수입니다. 진입 차수가 0이면 먼저 끝내야 할 선행 정점이 없다는 뜻이라 지금 바로 처리할 수 있습니다.
핵심 개념
Kahn 알고리즘
진입 차수가 0인 정점을 큐에서 꺼내 결과에 넣고, 나가는 간선을 지워 이웃의 진입 차수를 줄입니다. 0이 된 이웃을 큐에 넣는 일을 큐가 빌 때까지 반복합니다.
핵심 개념
DFS 방식
깊이 우선 탐색에서 정점의 탐색이 끝나는 순서를 기록하고 마지막에 뒤집습니다. DAG에서는 간선 u → v에서 v가 u보다 먼저 끝나기 때문에 뒤집으면 u가 앞에 옵니다.
핵심 개념
사이클 탐지
Kahn 알고리즘은 결과의 정점 수가 전체보다 적으면, DFS 방식은 탐색 중인 정점으로 되돌아오는 간선을 만나면 사이클이 있다고 판단합니다.
위상 정렬의 배경
Kahn 알고리즘은 1962년 A. B. Kahn의 논문 "Topological sorting of large networks"에서 소개되었습니다. 이 논문은 대규모 네트워크의 정점을 선후 관계에 맞춰 늘어놓는 절차를 제시합니다.
깊이 우선 탐색으로 위상 정렬을 하는 방법은 알고리즘 교재의 표준 내용입니다. 방향 그래프가 DAG일 필요충분조건이 깊이 우선 탐색에서 되돌아오는 간선(back edge)이 없는 것이라는 사실이 이 방법의 바탕입니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
위상 정렬이 가능한 조건은?
방향이 있고 사이클이 없는 그래프(DAG)여야 합니다. 사이클이 있으면 정점들이 서로를 먼저라고 요구해 순서를 정할 수 없습니다.
면접 질문
Kahn 알고리즘으로 사이클을 어떻게 찾나요?
큐가 빌 때까지 처리한 정점의 수가 전체 정점 수보다 적으면 사이클이 있습니다. 사이클 안의 정점은 서로를 기다려 진입 차수가 0이 되지 않기 때문입니다.
면접 질문
사전순으로 가장 앞선 위상 순서를 구하려면?
Kahn 알고리즘의 큐를 최소 힙(우선순위 큐)으로 바꿔 진입 차수가 0인 정점 중 가장 작은 것을 먼저 꺼냅니다. 이진 힙 기준으로 O(V log V + E)이고, O((V + E) log V)로 써도 상한은 맞습니다.
면접 질문
Kahn 방식과 DFS 방식은 어떻게 다른가요?
둘 다 O(V + E)입니다. Kahn 방식은 반복문과 큐로 구현하고 앞쪽 정점부터 결과를 만들며, DFS 방식은 재귀(또는 스택)로 탐색하고 끝나는 순서를 뒤집어 결과를 만듭니다. 깊은 그래프에서는 재귀 깊이에 주의해야 합니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
Python — Kahn 알고리즘 (진입 차수 기반)
from collections import deque graph = { # 간선 u → v 는 "u가 v보다 먼저" 'A': ['C'], 'B': ['C', 'D'], 'C': ['E'], 'D': ['E', 'F'], 'E': [], 'F': [], } def kahn(graph): indeg = {v: 0 for v in graph} for v in graph: for w in graph[v]: indeg[w] += 1 # 진입 차수 계산 q = deque(v for v in graph if indeg[v] == 0) order = [] while q: v = q.popleft() order.append(v) for w in graph[v]: indeg[w] -= 1 # v에서 나가는 간선을 지운다 if indeg[w] == 0: q.append(w) return order if len(order) == len(graph) else None print(kahn(graph)) # ['A', 'B', 'C', 'D', 'E', 'F'] cyc = dict(graph) cyc['E'] = ['A'] # A → C → E → A 사이클 print(kahn(cyc)) # None
Kahn 알고리즘은 먼저 모든 정점의 진입 차수(들어오는 간선 수)를 셉니다. 진입 차수가 0인 정점은 먼저 와야 할 정점이 없으므로 큐에 넣고, 큐에서 꺼낼 때마다 그 정점을 결과에 넣은 뒤 나가는 간선을 지워 이웃의 진입 차수를 1씩 줄입니다. 진입 차수가 0이 된 이웃은 큐에 넣습니다. 이 그래프에서는 A와 B로 시작해 A, B, C, D, E, F 순서가 나옵니다. 큐가 빌 때까지 반복한 뒤 결과의 길이가 정점 수와 같으면 위상 순서이고, 모자라면 사이클이 있다는 뜻입니다. E에서 A로 가는 간선을 더해 A, C, E가 사이클을 이루게 하면 진입 차수가 0이 되지 않는 A, C, E는 큐에 들어가지 못해 함수가 None을 돌려줍니다. 시각화의 Kahn 알고리즘 탭과 사이클 탐지 탭이 보여 주는 과정과 같습니다. 정점과 간선을 한 번씩 처리하므로 O(V + E)입니다.
Python — DFS 종료 순서를 뒤집는 방식
def dfs_sort(graph): state = {} # 1: 탐색 중, 2: 종료 out = [] def visit(v): state[v] = 1 for w in graph[v]: if state.get(w) == 1: raise ValueError('cycle') # 탐색 중인 정점으로 되돌아옴 if w not in state: visit(w) state[v] = 2 out.append(v) # 끝나는 순서대로 기록 for v in graph: if v not in state: visit(v) return out[::-1] # 뒤집으면 위상 순서 print(dfs_sort(graph)) # ['B', 'D', 'F', 'A', 'C', 'E'] try: dfs_sort(cyc) except ValueError as e: print('ValueError', e) # ValueError cycle
DFS 방식은 깊이 우선 탐색을 하면서 정점의 탐색이 끝나는 순서를 기록하고, 마지막에 그 순서를 뒤집습니다. DAG에서는 간선 u → v가 있으면 v의 탐색이 u보다 먼저 끝나므로, 뒤집은 순서에서는 u가 v보다 앞에 옵니다. 이 그래프에서는 E, C, A, F, D, B 순서로 끝나고, 뒤집으면 B, D, F, A, C, E입니다. Kahn 알고리즘의 결과와 다르지만 모든 간선이 앞에서 뒤로 향하므로 둘 다 올바른 위상 순서입니다. 정점이 탐색 중(1)일 때 그 정점으로 되돌아오는 간선을 만나면 사이클입니다. 사이클을 만든 그래프에 적용하면 ValueError가 발생합니다. 재귀로 구현해서 정점이 매우 많은 그래프에서는 재귀 깊이 제한에 걸릴 수 있으며, 그런 경우에는 Kahn 방식이나 명시적 스택을 씁니다.
Python — 표준 라이브러리 graphlib
from graphlib import TopologicalSorter, CycleError deps = { # 키: 작업, 값: 먼저 끝나야 하는 작업들 'C': ['A', 'B'], 'D': ['B'], 'E': ['C', 'D'], 'F': ['D'], } print(list(TopologicalSorter(deps).static_order())) # ['A', 'B', 'C', 'D', 'E', 'F'] deps['A'] = ['E'] # 사이클 추가 try: list(TopologicalSorter(deps).static_order()) except CycleError: print('CycleError') # CycleError
파이썬 3.9부터 표준 라이브러리에 graphlib.TopologicalSorter가 들어 있습니다. 위의 두 함수와 달리 그래프를 "정점 → 먼저 끝나야 하는 정점들"의 형태로 받으며, static_order()는 위상 순서를 차례로 돌려주고 사이클이 있으면 CycleError를 일으킵니다. 빌드 작업이나 데이터 파이프라인처럼 작업의 선후 관계를 순서로 바꾸는 일에 바로 쓸 수 있습니다. 위상 순서는 하나로 정해지지 않는 경우가 많아서, 같은 그래프라도 입력을 넣은 순서에 따라 결과가 다를 수 있습니다. 순서에 의존하는 코드를 쓰기보다 모든 간선이 앞에서 뒤로 향하는지를 기준으로 확인하세요.
Q1
위상 정렬이 가능한 그래프의 조건은?
Q2
Kahn 알고리즘(진입 차수 기반 위상 정렬)의 첫 단계는?
Q3
Kahn 알고리즘을 사이클이 있는 그래프에 적용하면 어떻게 될까?