강한 연결 요소(SCC) — 코사라주와 타잔 알고리즘
방향 그래프에서 서로 도달 가능한 노드 집합인 SCC를 DFS 2회의 코사라주와 DFS 1회의 타잔 알고리즘으로 O(V+E)에 구하는 방법을 완전 정리합니다.
지난 글에서 무방향 그래프의 MST를 구하는 프림을 배웠습니다. 이번에는 방향 그래프(Directed Graph) 에서 핵심 구조인 **강한 연결 요소(SCC, Strongly Connected Component)**를 찾는 알고리즘을 다룹니다. SCC는 서로 양방향으로 도달 가능한 노드들의 최대 집합입니다. 웹 페이지 링크 분석, 컴파일러의 순환 의존성 탐지, 게임 맵의 폐쇄 구역 분석 등에 활용됩니다.
SCC란?
방향 그래프에서 두 노드 u, v가 u → v 경로와 v → u 경로가 모두 존재할 때 같은 SCC에 속합니다.
모든 SCC를 하나의 노드로 축소하면 **압축 그래프(Condensation Graph)**가 만들어지는데, 이는 항상 **DAG(사이클 없는 방향 그래프)**입니다.
코사라주(Kosaraju) 알고리즘
가장 직관적인 방법입니다.
- 1단계: 원래 그래프에서 DFS 수행, 노드 종료 순서를 스택에 기록
- 2단계: 그래프의 역방향(transpose) 에서 스택에서 꺼내는 순서대로 DFS — 각 DFS가 탐색하는 노드가 하나의 SCC
from collections import defaultdict
def kosaraju(n, adj):
# 역방향 그래프 구성
radj = defaultdict(list)
for u in adj:
for v in adj[u]:
radj[v].append(u)
# 1단계: 종료 순서 스택
visited = [False] * n
order = []
def dfs1(v):
visited[v] = True
for w in adj[v]:
if not visited[w]:
dfs1(w)
order.append(v)
for v in range(n):
if not visited[v]:
dfs1(v)
# 2단계: 역방향 그래프 DFS
visited2 = [False] * n
sccs = []
def dfs2(v, comp):
visited2[v] = True
comp.append(v)
for w in radj[v]:
if not visited2[w]:
dfs2(w, comp)
for v in reversed(order):
if not visited2[v]:
comp = []
dfs2(v, comp)
sccs.append(comp)
return sccs
타잔(Tarjan) 알고리즘
DFS 한 번으로 SCC를 모두 찾습니다. 각 노드에 disc(발견 시각)와 low(도달 가능한 최소 disc)를 부여하고, low[u] == disc[u]이면 u가 SCC의 루트입니다.
def tarjan(n, graph):
disc = [-1] * n
low = [-1] * n
on_stack = [False] * n
stack = []
timer = [0]
sccs = []
def dfs(u):
disc[u] = low[u] = timer[0]
timer[0] += 1
stack.append(u)
on_stack[u] = True
for v in graph[u]:
if disc[v] == -1:
dfs(v)
low[u] = min(low[u], low[v])
elif on_stack[v]:
low[u] = min(low[u], disc[v])
# SCC 루트: low[u] == disc[u]
if low[u] == disc[u]:
scc = []
while True:
w = stack.pop()
on_stack[w] = False
scc.append(w)
if w == u:
break
sccs.append(scc)
for v in range(n):
if disc[v] == -1:
dfs(v)
return sccs
재귀 깊이 문제 해결 — 반복 타잔
Python의 기본 재귀 한도(1000)를 초과하는 큰 그래프에서는 반복 버전이 필요합니다.
import sys
sys.setrecursionlimit(10 ** 6) # 간단한 해결책
# 또는 반복 DFS로 타잔 구현
def tarjan_iterative(n, graph):
disc = [-1] * n
low = [-1] * n
on_stack = [False] * n
stack = []
timer = [0]
sccs = []
call_stack = []
for start in range(n):
if disc[start] != -1:
continue
call_stack.append((start, iter(graph[start])))
disc[start] = low[start] = timer[0]
timer[0] += 1
stack.append(start)
on_stack[start] = True
while call_stack:
u, neighbors = call_stack[-1]
try:
v = next(neighbors)
if disc[v] == -1:
disc[v] = low[v] = timer[0]
timer[0] += 1
stack.append(v)
on_stack[v] = True
call_stack.append((v, iter(graph[v])))
elif on_stack[v]:
low[u] = min(low[u], disc[v])
except StopIteration:
call_stack.pop()
if call_stack:
parent = call_stack[-1][0]
low[parent] = min(low[parent], low[u])
if low[u] == disc[u]:
scc = []
while True:
w = stack.pop()
on_stack[w] = False
scc.append(w)
if w == u:
break
sccs.append(scc)
return sccs
활용 — 2-SAT
SCC의 대표적인 응용은 **2-SAT(2-Satisfiability Problem)**입니다. 각 리터럴 xi와 ¬xi를 노드로 만들고 논리 함의 관계를 간선으로 표현한 뒤, xi와 ¬xi가 같은 SCC에 있으면 UNSAT입니다.
def two_sat(n, clauses):
"""
n개 변수, clauses: [(a, b)] where a or b = True
변수 i: 노드 2i, 변수 ¬i: 노드 2i+1
"""
graph = defaultdict(list)
for a, b in clauses:
# (a or b) == (¬a → b) and (¬b → a)
graph[a ^ 1].append(b)
graph[b ^ 1].append(a)
sccs = tarjan(2 * n, graph)
comp = [0] * (2 * n)
for i, scc in enumerate(sccs):
for v in scc:
comp[v] = i
for i in range(n):
if comp[2 * i] == comp[2 * i + 1]:
return None # UNSAT
return [comp[2 * i] > comp[2 * i + 1] for i in range(n)]
알고리즘 비교
| 특성 | 코사라주 | 타잔 |
|---|---|---|
| DFS 횟수 | 2회 | 1회 |
| 시간 복잡도 | O(V+E) | O(V+E) |
| 추가 공간 | 역방향 그래프 | disc/low/stack |
| 구현 난이도 | 낮음 | 중간 |
| 실용성 | 이해하기 쉬움 | 상수 작음 |
요약
SCC는 방향 그래프에서 동치 클래스를 정의합니다. 코사라주는 두 번의 DFS로 직관적으로, 타잔은 한 번의 DFS로 low 값을 활용해 구합니다. 압축 그래프(Condensation)를 만들면 복잡한 방향 그래프를 DAG로 단순화해 후속 처리가 쉬워집니다.
지난 글: 프림(Prim) 알고리즘 — 그리디 방식으로 MST 확장
다음 글: 단절점과 단절선 — 그래프의 취약 구조 탐지
읽어주셔서 감사합니다. 😊