컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
algo · 11

위상 정렬

DAG그래프위상정렬

위상 정렬(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 방식은 탐색 중인 정점으로 되돌아오는 간선을 만나면 사이클이 있다고 판단합니다.

시간은 O(V + E)입니다 — 두 방법 모두 정점마다 한 번, 간선마다 한 번씩만 처리합니다. 인접 리스트로 그래프를 저장했을 때의 값입니다.
공간은 O(V)입니다 — Kahn 알고리즘은 진입 차수 배열과 큐, DFS 방식은 방문 상태와 종료 목록이 필요합니다. 그래프 자체를 저장하는 공간은 별도입니다.
결과가 하나로 정해지지 않습니다 — 큐에서 꺼내는 순서, DFS에서 이웃을 보는 순서가 달라지면 다른 올바른 순서가 나옵니다. 모든 정점을 한 번씩 지나는 방향 경로가 있는 경우에만 순서가 하나뿐입니다.
같은 시점에 큐에 있는 정점은 서로 독립입니다 — 선행 조건이 모두 끝난 상태이므로 병렬로 처리할 수 있습니다. 병렬로 실행할 수 있는 작업을 찾는 데 쓸 수 있습니다.
이 시각화에서 확인할 것 — "Kahn 알고리즘" 탭에서 진입 차수가 줄어들며 큐가 채워지는 모습을, "DFS 방식" 탭에서 정점이 끝나는 순서를 뒤집는 과정을, "사이클 탐지" 탭에서 사이클 정점이 끝내 처리되지 못하고 남는 모습을 STEP으로 확인하세요.

위상 정렬의 배경

Kahn 알고리즘은 1962년 A. B. Kahn의 논문 "Topological sorting of large networks"에서 소개되었습니다. 이 논문은 대규모 네트워크의 정점을 선후 관계에 맞춰 늘어놓는 절차를 제시합니다.

깊이 우선 탐색으로 위상 정렬을 하는 방법은 알고리즘 교재의 표준 내용입니다. 방향 그래프가 DAG일 필요충분조건이 깊이 우선 탐색에서 되돌아오는 간선(back edge)이 없는 것이라는 사실이 이 방법의 바탕입니다.

숫자로 확인하기

예시 그래프 — 정점 A부터 F까지 6개와 간선 6개(A → C, B → C, B → D, C → E, D → E, D → F)입니다. 진입 차수는 A 0, B 0, C 2, D 1, E 2, F 1입니다.
Kahn 알고리즘 — 진입 차수 0인 A, B를 큐에 넣고 시작합니다. A를 꺼내면 C가 2에서 1이 되고, B를 꺼내면 C와 D가 0이 되어 큐에 들어갑니다. 이렇게 A, B, C, D, E, F 순서가 나옵니다.
DFS 방식 — A에서 시작하면 A, C, E 순으로 깊이 들어가 E, C, A 순서로 끝납니다. 이어 B에서 D, F로 들어가 F, D, B 순서로 끝납니다. 끝난 순서 E, C, A, F, D, B를 뒤집으면 B, D, F, A, C, E입니다.
순서는 여러 개 — 두 방법의 결과가 서로 다르지만 모든 간선이 앞에서 뒤로 향하므로 둘 다 올바릅니다. 이 그래프의 올바른 위상 순서는 모두 14가지입니다.
사이클 탐지 — E에서 A로 가는 간선을 더하면 A, C, E가 사이클이 됩니다. Kahn 알고리즘은 B, D, F 3개만 처리하고 멈추며 3은 전체 6보다 작습니다. 남은 A, C, E의 진입 차수는 끝내 0이 되지 않습니다.
파이썬 확인 — graphlib.TopologicalSorter로 같은 의존 관계를 정렬하면 A, B, C, D, E, F가 나오고(문서상 순서는 입력을 넣은 순서에 따라 달라질 수 있습니다), A가 E를 기다리게 하면 CycleError가 발생합니다.

자주 하는 오해

위상 순서는 하나뿐이다 — 선후 관계가 없는 정점 쌍이 있으면 순서가 여러 개입니다. 간선이 하나도 없는 정점 3개는 3! = 6가지 순서가 모두 올바릅니다.
무방향 그래프에도 쓸 수 있다 — 위상 정렬은 간선의 방향이 선후를 뜻하는 방향 그래프에서 정의됩니다. 무방향 간선은 어느 쪽이 먼저인지 알려 주지 않습니다.
사이클이 있어도 일부는 정렬된다 — Kahn 알고리즘이 사이클 밖의 정점 일부를 처리하긴 하지만, 그 결과는 전체의 위상 순서가 아닙니다. 사이클이 있으면 위상 순서는 존재하지 않습니다.
DFS 방식은 종료 순서 그대로 쓰면 된다 — 끝나는 순서는 선행 정점이 뒤에 오는 역순입니다. 반드시 뒤집어야 모든 간선이 앞에서 뒤로 향합니다.
Kahn 알고리즘의 큐는 FIFO여야 한다 — 진입 차수가 0인 정점은 어느 것을 먼저 꺼내도 결과가 올바릅니다. 스택이나 우선순위 큐를 써도 되고, 올바름은 그대로이고 결과 순서(힙이면 시간 복잡도도)가 달라집니다.

면접에서 이렇게 물어요

면접 질문

위상 정렬이 가능한 조건은?

방향이 있고 사이클이 없는 그래프(DAG)여야 합니다. 사이클이 있으면 정점들이 서로를 먼저라고 요구해 순서를 정할 수 없습니다.

면접 질문

Kahn 알고리즘으로 사이클을 어떻게 찾나요?

큐가 빌 때까지 처리한 정점의 수가 전체 정점 수보다 적으면 사이클이 있습니다. 사이클 안의 정점은 서로를 기다려 진입 차수가 0이 되지 않기 때문입니다.

면접 질문

사전순으로 가장 앞선 위상 순서를 구하려면?

Kahn 알고리즘의 큐를 최소 힙(우선순위 큐)으로 바꿔 진입 차수가 0인 정점 중 가장 작은 것을 먼저 꺼냅니다. 이진 힙 기준으로 O(V log V + E)이고, O((V + E) log V)로 써도 상한은 맞습니다.

면접 질문

Kahn 방식과 DFS 방식은 어떻게 다른가요?

둘 다 O(V + E)입니다. Kahn 방식은 반복문과 큐로 구현하고 앞쪽 정점부터 결과를 만들며, DFS 방식은 재귀(또는 스택)로 탐색하고 끝나는 순서를 뒤집어 결과를 만듭니다. 깊은 그래프에서는 재귀 깊이에 주의해야 합니다.

실제 시스템에서는

빌드 도구 — Gradle은 태스크 사이의 의존 관계로 방향 비순환 그래프를 만들고, 그 그래프에 따라 실행 순서를 정합니다.
워크플로 스케줄러 — Apache Airflow는 작업과 그 의존 관계를 DAG로 정의하며, 기본적으로 선행 작업이 성공한 뒤에 실행합니다(trigger rule로 바꿀 수 있습니다).
선수 과목과 패키지 의존 — 과목의 선수 관계나 라이브러리의 의존 관계를 간선으로 보면 수강·설치 순서가 위상 순서입니다. 의존 관계가 순환하면 위상 순서가 존재하지 않습니다. 이를 오류로 알리는지, 허용하고 끊는지는 도구마다 다릅니다.
언어 표준 라이브러리 — 파이썬 3.9부터 graphlib.TopologicalSorter가 위상 정렬을 제공하고 사이클이 있으면 CycleError를 일으킵니다.

함께 보면 좋은 토픽

그래프 — 방향 그래프와 진입 차수, 인접 리스트를 봅니다.
BFS와 DFS — DFS 방식의 바탕인 깊이 우선 탐색과, 큐를 쓰는 너비 우선 탐색을 봅니다.
스택과 큐 — Kahn 알고리즘의 큐와 DFS의 스택을 봅니다.
교착 상태 — 서로를 기다리는 사이클이 만드는 문제를 봅니다.

참고 자료

논문 — Kahn, A. B., "Topological sorting of large networks", Communications of the ACM 5(11), 1962
교재 — Cormen, Leiserson, Rivest, Stein, "Introduction to Algorithms", 3rd ed., Section 22.4 Topological sort
공식 문서 — Python graphlib
공식 문서 — Gradle Build Lifecycle
공식 문서 — Apache Airflow DAGs

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

위상 정렬이 가능한 그래프의 조건은?

선후 관계가 순환하면 순서를 정할 수 없습니다.
위상 정렬은 "A가 B보다 먼저" 같은 선후 관계를 지키는 정점 순서를 구하는 것이라 방향이 있고 사이클이 없는 그래프(DAG)에서만 가능합니다.

Q2

Kahn 알고리즘(진입 차수 기반 위상 정렬)의 첫 단계는?

선행 조건이 하나도 없는 정점부터 시작합니다.
진입 차수가 0인 정점은 먼저 처리해야 할 선행 정점이 없습니다. 이들을 큐에 넣고 꺼내면서 연결된 간선을 지우고, 진입 차수가 0이 된 정점을 큐에 추가하는 과정을 반복합니다.

Q3

Kahn 알고리즘을 사이클이 있는 그래프에 적용하면 어떻게 될까?

사이클 안의 정점은 진입 차수가 끝까지 0이 되지 않습니다.
사이클에 속한 정점들은 서로를 기다리므로 진입 차수가 0이 되지 않아 큐에 들어가지 못합니다. 결과에 포함된 정점 수가 전체보다 적으면 사이클이 있다고 판단할 수 있습니다.