强连通分量(Strongly Connected Components,简称SCC)是有向图算法中的核心概念。如果一个有向图中任意两个节点互相可达,则称该图是强连通的;强连通分量则是极大的强连通子图。SCC分解在编译器优化、死代码检测、社交网络社区发现、游戏关卡可达性分析等领域有广泛应用。
问题定义
给定有向图 G = (V, E),将其分解为若干强连通分量,使得每个分量内部任意两节点互相可达,不同分量之间不存在双向路径。
关键概念
- dfn[u]:节点u在DFS中的访问次序(时间戳)
- low[u]:从u出发通过树边和最多一条非树边(回边/横叉边)能到达的最小dfn值
- 当
dfn[u] == low[u]时,u是一个强连通分量的根
Tarjan算法(O(V + E))
核心思路
Tarjan算法通过一次深度优先搜索,利用栈维护当前搜索路径上的节点。low 数组记录节点能回溯到的最小时间戳。当 dfn[u] == low[u] 时,说明u无法回到任何更早的节点,从栈中弹出节点直到u,这些节点构成一个强连通分量。
Java实现
import java.util.*;
public class TarjanSCC {
private int n; // 节点数
private List<Integer>[] graph; // 邻接表
private int[] dfn; // 时间戳
private int[] low; // 能回溯到的最小时间戳
private boolean[] inStack; // 是否在栈中
private Stack<Integer> stack; // 维护当前DFS路径
private boolean[] visited; // 是否被访问
private int timestamp; // 当前时间戳
private List<List<Integer>> sccs; // 所有强连通分量
private int[] sccId; // 每个节点所属的分量编号
private int sccCount; // 强连通分量数量
@SuppressWarnings("unchecked")
public TarjanSCC(int n) {
this.n = n;
this.graph = new ArrayList[n];
this.dfn = new int[n];
this.low = new int[n];
this.inStack = new boolean[n];
this.stack = new Stack<>();
this.visited = new boolean[n];
this.timestamp = 0;
this.sccs = new ArrayList<>();
this.sccId = new int[n];
this.sccCount = 0;
Arrays.fill(dfn, -1);
Arrays.fill(low, -1);
Arrays.fill(sccId, -1);
for (int i = 0; i < n; i++) {
graph[i] = new ArrayList<>();
}
}
/**
* 添加有向边
*/
public void addEdge(int from, int to) {
graph[from].add(to);
}
/**
* 执行Tarjan算法,分解所有强连通分量
*/
public void findSCCs() {
for (int i = 0; i < n; i++) {
if (dfn[i] == -1) {
tarjan(i);
}
}
}
private void tarjan(int u) {
dfn[u] = low[u] = ++timestamp;
stack.push(u);
inStack[u] = true;
visited[u] = true;
for (int v : graph[u]) {
if (dfn[v] == -1) {
// v未被访问,继续DFS
tarjan(v);
low[u] = Math.min(low[u], low[v]);
} else if (inStack[v]) {
// v在栈中,说明找到回边
low[u] = Math.min(low[u], dfn[v]);
}
// 如果v已被访问但不在栈中,说明v属于已处理的SCC,忽略
}
// 如果u是SCC的根节点
if (dfn[u] == low[u]) {
List<Integer> scc = new ArrayList<>();
int v;
do {
v = stack.pop();
inStack[v] = false;
scc.add(v);
sccId[v] = sccCount;
} while (v != u);
sccs.add(scc);
sccCount++;
}
}
/**
* 获取所有强连通分量
*/
public List<List<Integer>> getSCCs() {
return sccs;
}
/**
* 获取节点所属的分量编号
*/
public int getSccId(int u) {
return sccId[u];
}
/**
* 获取强连通分量数量
*/
public int getSccCount() {
return sccCount;
}
/**
* 构建缩点后的DAG(有向无环图)
*/
@SuppressWarnings("unchecked")
public List<Integer>[] buildCondensationGraph() {
List<Integer>[] dag = new ArrayList[sccCount];
for (int i = 0; i < sccCount; i++) {
dag[i] = new ArrayList<>();
}
Set<String> edges = new HashSet<>(); // 去重边
for (int u = 0; u < n; u++) {
for (int v : graph[u]) {
int su = sccId[u];
int sv = sccId[v];
if (su != sv) {
String edge = su + "->" + sv;
if (!edges.contains(edge)) {
edges.add(edge);
dag[su].add(sv);
}
}
}
}
return dag;
}
public static void main(String[] args) {
// 构建示例有向图
// 0 -> 1 -> 2 -> 0 (SCC1)
// 1 -> 3
// 3 -> 4 -> 5 -> 3 (SCC2)
TarjanSCC tarjan = new TarjanSCC(6);
tarjan.addEdge(0, 1);
tarjan.addEdge(1, 2);
tarjan.addEdge(2, 0);
tarjan.addEdge(1, 3);
tarjan.addEdge(3, 4);
tarjan.addEdge(4, 5);
tarjan.addEdge(5, 3);
tarjan.findSCCs();
System.out.println("强连通分量数量: " + tarjan.getSccCount());
System.out.println("各分量:");
for (int i = 0; i < tarjan.getSCCs().size(); i++) {
System.out.println(" SCC " + i + ": " + tarjan.getSCCs().get(i));
}
System.out.println("\n缩点后的DAG边:");
List<Integer>[] dag = tarjan.buildCondensationGraph();
for (int i = 0; i < dag.length; i++) {
System.out.println(" " + i + " -> " + dag[i]);
}
}
}
Kosaraju算法(O(V + E))
核心思路
Kosaraju算法通过两次DFS实现:
1. 第一次DFS:在原图上按完成时间逆序给节点排序
2. 第二次DFS:在转置图上按上述顺序进行DFS,每次DFS访问到的节点构成一个SCC
Java实现
import java.util.*;
public class KosarajuSCC {
private int n;
private List<Integer>[] graph; // 原图
private List<Integer>[] transpose; // 转置图
private boolean[] visited;
private Stack<Integer> finishOrder; // 完成时间顺序
private List<List<Integer>> sccs;
@SuppressWarnings("unchecked")
public KosarajuSCC(int n) {
this.n = n;
this.graph = new ArrayList[n];
this.transpose = new ArrayList[n];
this.visited = new boolean[n];
this.finishOrder = new Stack<>();
this.sccs = new ArrayList<>();
for (int i = 0; i < n; i++) {
graph[i] = new ArrayList<>();
transpose[i] = new ArrayList<>();
}
}
public void addEdge(int from, int to) {
graph[from].add(to);
transpose[to].add(from); // 同时构建转置图
}
public void findSCCs() {
// 第一次DFS:确定完成顺序
Arrays.fill(visited, false);
for (int i = 0; i < n; i++) {
if (!visited[i]) {
dfs1(i);
}
}
// 第二次DFS:在转置图上按逆序处理
Arrays.fill(visited, false);
while (!finishOrder.isEmpty()) {
int u = finishOrder.pop();
if (!visited[u]) {
List<Integer> scc = new ArrayList<>();
dfs2(u, scc);
sccs.add(scc);
}
}
}
private void dfs1(int u) {
visited[u] = true;
for (int v : graph[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 : transpose[u]) {
if (!visited[v]) {
dfs2(v, scc);
}
}
}
public List<List<Integer>> getSCCs() {
return sccs;
}
public static void main(String[] args) {
KosarajuSCC kosaraju = new KosarajuSCC(6);
kosaraju.addEdge(0, 1);
kosaraju.addEdge(1, 2);
kosaraju.addEdge(2, 0);
kosaraju.addEdge(1, 3);
kosaraju.addEdge(3, 4);
kosaraju.addEdge(4, 5);
kosaraju.addEdge(5, 3);
kosaraju.findSCCs();
System.out.println("Kosaraju算法结果:");
for (int i = 0; i < kosaraju.getSCCs().size(); i++) {
System.out.println(" SCC " + i + ": " + kosaraju.getSCCs().get(i));
}
}
}
缩点与DAG上的动态规划
强连通分量分解的核心价值在于缩点:将每个SCC收缩为一个节点,得到的有向图必然是DAG(有向无环图)。在DAG上可以方便地进行拓扑排序和动态规划。
应用示例:最少加多少条边使有向图强连通
public class MakeStronglyConnected {
/**
* 计算使有向图强连通最少需要添加的边数
* 答案 = max(入度为0的SCC数, 出度为0的SCC数)
*/
public int minEdgesToAdd(TarjanSCC tarjan) {
int sccCount = tarjan.getSccCount();
if (sccCount == 1) return 0; // 已经强连通
int[] inDegree = new int[sccCount];
int[] outDegree = new int[sccCount];
// 统计缩点后的入度和出度
for (int u = 0; u < tarjan.n; u++) {
for (int v : tarjan.graph[u]) {
int su = tarjan.getSccId(u);
int sv = tarjan.getSccId(v);
if (su != sv) {
outDegree[su]++;
inDegree[sv]++;
}
}
}
int inZero = 0, outZero = 0;
for (int i = 0; i < sccCount; i++) {
if (inDegree[i] == 0) inZero++;
if (outDegree[i] == 0) outZero++;
}
return Math.max(inZero, outZero);
}
}
两种算法的对比
| 特性 | Tarjan算法 | Kosaraju算法 |
|---|---|---|
| 遍历次数 | 1次DFS | 2次DFS |
| 额外空间 | 栈(O(V)) | 转置图(O(V+E)) |
| 代码复杂度 | 稍复杂(需维护low值) | 较直观 |
| 效率 | 常数更小 | 需建转置图 |
| 适用场景 | 在线算法、空间敏感 | 代码简洁优先 |
复杂度分析
两种算法的时间复杂度均为 O(V + E),空间复杂度均为 O(V + E)(邻接表存储)。
实际应用场景
- 编译器优化:识别循环和控制流图中的基本块
- 死代码检测:无法从入口到达的SCC即为死代码
- 网页排名:网页链接图的强连通社区分析
- 游戏设计:关卡可达性分析和最短通关路径
- 社交网络:识别紧密互动的用户群体
总结
强连通分量分解揭示了有向图的层次结构。Tarjan算法通过精巧的low值维护实现单次DFS分解,Kosaraju算法则以更直观的两次DFS完成任务。掌握SCC分解,配合缩点技术将复杂有向图转化为DAG,是解决图论高级问题的关键能力。