강연결 요소와 2-SAT — 그래프의 구조를 분석하는 법
방향 그래프에서 "서로 도달 가능한 정점들의 묶음"을 어떻게 찾을 수 있을까요?
강연결 요소(SCC)란
강연결 요소(Strongly Connected Component, SCC) 는 방향 그래프에서 모든 정점 쌍 사이에 양방향 경로가 존재하는 최대 부분 그래프입니다.
쉽게 말해, SCC 안의 어떤 정점에서 출발해도 같은 SCC 안의 다른 모든 정점에 도달할 수 있습니다.
예: A → B → C → A (하나의 SCC)
D → E (D에서 E는 가지만, E에서 D로는 못 감 → 같은 SCC 아님)
왜 필요한가
SCC를 찾으면 복잡한 방향 그래프를 DAG(방향 비순환 그래프) 로 압축할 수 있습니다. 각 SCC를 하나의 노드로 치환하면 사이클이 사라지므로, 위상 정렬 같은 DAG 전용 알고리즘을 적용할 수 있게 됩니다.
실무에서의 활용도 다양합니다.
- **의존성 분석 **: 모듈 간 순환 의존성 탐지 (Maven, Gradle 빌드 도구)
- ** 데드락 탐지 **: 트랜잭션 대기 그래프에서 순환 찾기
- ** 소셜 네트워크 **: 커뮤니티 탐지
- ** 컴파일러 **: 상호 재귀 함수 그룹 식별
동작 원리 — Kosaraju 알고리즘
Kosaraju 알고리즘은 DFS를 두 번 수행합니다. 이해하기 쉬워서 먼저 다루겠습니다.
단계
- 원래 그래프에서 DFS를 수행하며, ** 종료 시점 **을 스택에 기록합니다
- 모든 간선을 ** 역전 **한 그래프를 만듭니다
- 스택에서 하나씩 꺼내며 역전 그래프에서 DFS를 수행합니다. 각 DFS가 하나의 SCC입니다
왜 작동하는가
원래 그래프에서 DFS 종료가 늦은 정점은 "더 상위"의 SCC에 속합니다. 역전 그래프에서 이 순서로 탐색하면, SCC 간 경계를 넘지 못하기 때문에 정확히 하나의 SCC만 탐색하게 됩니다.
public class Kosaraju {
private int n;
private List<List<Integer>> graph; // 원래 그래프
private List<List<Integer>> reversed; // 역전 그래프
private boolean[] visited;
private Deque<Integer> finishOrder; // 종료 순서 스택
public List<List<Integer>> findSCCs(List<List<Integer>> graph, int n) {
this.n = n;
this.graph = graph;
this.reversed = new ArrayList<>();
this.visited = new boolean[n];
this.finishOrder = new ArrayDeque<>();
// 역전 그래프 구성
for (int i = 0; i < n; i++) reversed.add(new ArrayList<>());
for (int u = 0; u < n; u++) {
for (int v : graph.get(u)) {
reversed.get(v).add(u);
}
}
// 1단계: 원래 그래프에서 DFS, 종료 순서 기록
for (int i = 0; i < n; i++) {
if (!visited[i]) dfs1(i);
}
// 2단계: 역전 그래프에서 종료 역순으로 DFS
Arrays.fill(visited, false);
List<List<Integer>> sccs = new ArrayList<>();
while (!finishOrder.isEmpty()) {
int node = finishOrder.pop();
if (!visited[node]) {
List<Integer> scc = new ArrayList<>();
dfs2(node, scc);
sccs.add(scc);
}
}
return sccs;
}
private void dfs1(int u) {
visited[u] = true;
for (int v : graph.get(u)) {
if (!visited[v]) dfs1(v);
}
finishOrder.push(u); // 종료 시점에 스택에 추가
}
private void dfs2(int u, List<Integer> scc) {
visited[u] = true;
scc.add(u);
for (int v : reversed.get(u)) {
if (!visited[v]) dfs2(v, scc);
}
}
}
시간 복잡도는 O(V + E) 입니다. DFS를 두 번 하지만 각각 O(V + E)이므로 전체도 선형입니다.
동작 원리 — Tarjan 알고리즘
Tarjan 알고리즘은 DFS 한 번으로 SCC를 찾습니다.
핵심 아이디어
각 정점에 발견 순서(disc) 와 도달 가능한 최소 순서(low) 를 부여합니다. DFS를 돌면서 low[u] == disc[u]인 정점을 만나면, 그 정점이 SCC의 루트이고, 스택에서 해당 정점까지 꺼내면 하나의 SCC가 됩니다.
public class Tarjan {
private int n, timer;
private int[] disc, low;
private boolean[] onStack;
private Deque<Integer> stack;
private List<List<Integer>> graph;
private List<List<Integer>> sccs;
public List<List<Integer>> findSCCs(List<List<Integer>> graph, int n) {
this.n = n;
this.graph = graph;
this.timer = 0;
this.disc = new int[n];
this.low = new int[n];
this.onStack = new boolean[n];
this.stack = new ArrayDeque<>();
this.sccs = new ArrayList<>();
Arrays.fill(disc, -1);
for (int i = 0; i < n; i++) {
if (disc[i] == -1) dfs(i);
}
return sccs;
}
private void dfs(int u) {
disc[u] = low[u] = timer++;
stack.push(u);
onStack[u] = true;
for (int v : graph.get(u)) {
if (disc[v] == -1) {
dfs(v);
low[u] = Math.min(low[u], low[v]);
} else if (onStack[v]) {
low[u] = Math.min(low[u], disc[v]);
}
}
// u가 SCC의 루트인 경우
if (low[u] == disc[u]) {
List<Integer> scc = new ArrayList<>();
while (true) {
int v = stack.pop();
onStack[v] = false;
scc.add(v);
if (v == u) break;
}
sccs.add(scc);
}
}
}
SCC 축약(DAG 압축)
SCC를 찾은 후, 각 SCC를 하나의 노드로 치환하면 DAG가 됩니다.
// SCC 축약 후 DAG 구성
int[] sccId = new int[n]; // 각 정점이 속한 SCC 번호
// ... SCC 찾기 과정에서 sccId 설정 ...
Set<Long> dagEdges = new HashSet<>();
List<List<Integer>> dag = new ArrayList<>();
for (int i = 0; i < sccCount; i++) dag.add(new ArrayList<>());
for (int u = 0; u < n; u++) {
for (int v : graph.get(u)) {
if (sccId[u] != sccId[v]) {
long key = (long) sccId[u] * n + sccId[v];
if (dagEdges.add(key)) {
dag.get(sccId[u]).add(sccId[v]);
}
}
}
}
// 이제 dag에 위상 정렬 등을 적용할 수 있습니다
2-SAT 문제
2-SAT은 SCC의 대표적인 응용입니다. 각 변수가 참 또는 거짓인 불리언 만족 가능성 문제 로, 절(clause)이 모두 2개의 리터럴로 구성됩니다.
2-SAT → 함의 그래프
(x₁ ∨ x₂) 같은 절을 함의(implication)로 바꿉니다.
(x₁ ∨ x₂) → (¬x₁ → x₂) ∧ (¬x₂ → x₁)
이렇게 만든 함의 그래프 에서 SCC를 구하면 됩니다.
판정 기준
- 어떤 변수 x에 대해 x와 ¬x가 같은 SCC에 속하면 → 해가 없습니다 (UNSATISFIABLE)
- 그렇지 않으면 → 해가 존재합니다
백엔드/실무 연결
Spring 순환 의존성
Spring에서 빈(Bean) 간 순환 참조가 발생하면 BeanCurrentlyInCreationException이 발생합니다. 이는 의존성 그래프에서 SCC(사이클)를 탐지하는 것과 같은 원리입니다.
빌드 도구의 의존성 분석
Maven이나 Gradle은 모듈 간 의존성을 DAG로 관리합니다. 순환 의존성이 발견되면 빌드를 실패시키는데, 이때 SCC 알고리즘으로 어느 모듈들이 순환을 이루는지 알려줍니다.
데이터베이스 데드락 탐지
트랜잭션 대기 그래프(wait-for graph)에서 사이클이 있으면 데드락입니다. SCC를 찾으면 어떤 트랜잭션들이 서로 기다리고 있는지 식별할 수 있습니다.
Kosaraju vs Tarjan 비교
| 항목 | Kosaraju | Tarjan |
|---|---|---|
| DFS 횟수 | 2회 | 1회 |
| 역전 그래프 | 필요 | 불필요 |
| 구현 난이도 | 쉬움 | 중간 |
| 시간 복잡도 | O(V + E) | O(V + E) |
| 실무 선호 | 이해하기 좋음 | 메모리 효율적 |
주의할 점
Tarjan과 Kosaraju 알고리즘의 구현 세부사항 혼동
Kosaraju는 DFS 후위 순서 + 역방향 그래프에서 DFS를 수행합니다. Tarjan은 하나의 DFS에서 low-link 값으로 SCC를 찾습니다. 두 알고리즘의 구현을 섞으면 정확한 결과가 나오지 않습니다.
2-SAT에서 변수와 부정의 인덱스 매핑 실수
변수 x를 2i, 부정 ~x를 2i+1로 매핑하는 관례에서 인덱스를 잘못 계산하면 논리식 전체가 틀어집니다.
정리
- SCC 는 방향 그래프에서 서로 도달 가능한 정점들의 최대 묶음입니다
- Kosaraju: DFS 2회 + 역전 그래프로 직관적으로 SCC를 찾습니다
- Tarjan: DFS 1회 + low/disc 값으로 효율적으로 SCC를 찾습니다
- SCC를 축약하면 DAG 가 되어 위상 정렬 등 다양한 알고리즘을 적용할 수 있습니다
- 2-SAT 은 SCC의 대표적 응용으로, 함의 그래프에서 SCC를 구해 만족 가능성을 판정합니다
- 순환 의존성 탐지, 데드락 감지 등 백엔드에서도 핵심적인 역할을 합니다