在图论中,强连通分量(Strongly Connected Component, SCC)是指有向图中任意两个顶点互相可达的最大子图。求解 SCC 是编译器优化、社交网络分析、死锁检测等领域的核心问题。Tarjan 算法由 Robert Tarjan 于 1972 年提出,仅用一次深度优先搜索(DFS)即可在 (O(V+E)) 时间内找出所有强连通分量,是图论算法中最优雅的实现之一。本文将完整实现 Tarjan 算法,并扩展到割点与割边的求解。
核心概念
强连通分量(SCC)
在有向图 (G=(V,E)) 中,若子图 (G’) 中任意两点 (u,v) 都存在从 (u) 到 (v) 和从 (v) 到 (u) 的路径,则称 (G’) 为强连通分量。
Tarjan 算法的关键变量
- dfn[u]:节点
u在 DFS 中的访问次序(时间戳)。 - low[u]:节点
u及其后代节点通过回边能追溯到的最早祖先的 dfn 值。 - 栈:维护当前 DFS 路径上的节点,用于提取 SCC。
判定条件:当 dfn[u] == low[u] 时,说明 u 是其所在 SCC 的”根”,将栈中节点依次弹出直到 u,即构成一个 SCC。
Tarjan 算法完整实现
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;
public class TarjanSCC {
private final List<List<Integer>> graph;
private final int n;
private int[] dfn; // 时间戳
private int[] low; // 能追溯到的最早祖先
private boolean[] inStack;
private int timestamp; // 当前时间戳计数器
private final Stack<Integer> stack;
private final List<List<Integer>> sccs; // 存储所有强连通分量
public TarjanSCC(List<List<Integer>> graph) {
this.graph = graph;
this.n = graph.size();
this.dfn = new int[n];
this.low = new int[n];
this.inStack = new boolean[n];
this.timestamp = 0;
this.stack = new Stack<>();
this.sccs = new ArrayList<>();
// 初始化 dfn 为 -1 表示未访问
for (int i = 0; i < n; i++) {
dfn[i] = -1;
}
}
/**
* 执行 Tarjan 算法,返回所有强连通分量
*/
public List<List<Integer>> findSCCs() {
for (int i = 0; i < n; i++) {
if (dfn[i] == -1) {
dfs(i);
}
}
return sccs;
}
private void dfs(int u) {
// 初始化 u 的时间戳和 low 值
dfn[u] = low[u] = ++timestamp;
stack.push(u);
inStack[u] = true;
// 遍历 u 的所有邻接点
for (int v : graph.get(u)) {
if (dfn[v] == -1) {
// v 未被访问,继续 DFS
dfs(v);
// 回溯时更新 low[u]
low[u] = Math.min(low[u], low[v]);
} else if (inStack[v]) {
// v 在栈中,说明 (u,v) 是回边,更新 low[u]
low[u] = Math.min(low[u], dfn[v]);
}
// 若 v 已访问但不在栈中,说明 v 属于另一个 SCC,无需处理
}
// 若 u 是 SCC 的根,则弹出栈中节点构成一个 SCC
if (dfn[u] == low[u]) {
List<Integer> scc = new ArrayList<>();
int v;
do {
v = stack.pop();
inStack[v] = false;
scc.add(v);
} while (v != u);
sccs.add(scc);
System.out.println("发现强连通分量: " + scc);
}
}
/**
* 构建缩点后的 DAG(有向无环图)
*/
public List<List<Integer>> condenseDAG() {
int[] sccId = new int[n];
for (int i = 0; i < sccs.size(); i++) {
for (int node : sccs.get(i)) {
sccId[node] = i;
}
}
List<List<Integer>> dag = new ArrayList<>();
for (int i = 0; i < sccs.size(); i++) {
dag.add(new ArrayList<>());
}
boolean[][] visited = new boolean[sccs.size()][sccs.size()];
for (int u = 0; u < n; u++) {
for (int v : graph.get(u)) {
if (sccId[u] != sccId[v] && !visited[sccId[u]][sccId[v]]) {
dag.get(sccId[u]).add(sccId[v]);
visited[sccId[u]][sccId[v]] = true;
}
}
}
return dag;
}
public static void main(String[] args) {
int n = 8;
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
// 构建示例有向图
graph.get(0).add(1);
graph.get(1).add(2);
graph.get(2).add(0); // 0-1-2 构成 SCC
graph.get(2).add(3);
graph.get(3).add(4);
graph.get(4).add(5);
graph.get(5).add(3); // 3-4-5 构成 SCC
graph.get(6).add(5);
graph.get(6).add(7);
graph.get(7).add(6); // 6-7 构成 SCC
System.out.println("===== Tarjan 算法求解强连通分量 =====\n");
TarjanSCC tarjan = new TarjanSCC(graph);
List<List<Integer>> sccs = tarjan.findSCCs();
System.out.println("\n===== 结果汇总 =====");
System.out.println("图中共有 " + sccs.size() + " 个强连通分量");
for (int i = 0; i < sccs.size(); i++) {
System.out.println("SCC " + (i + 1) + ": " + sccs.get(i));
}
// 缩点
List<List<Integer>> dag = tarjan.condenseDAG();
System.out.println("\n===== 缩点后的 DAG =====");
for (int i = 0; i < dag.size(); i++) {
System.out.println("组件 " + i + " -> " + dag.get(i));
}
}
}
割点(Articulation Point)求解
在无向连通图中,若删除某顶点及其关联边后,图的连通分量数量增加,则该顶点为割点。Tarjan 算法同样可以高效求解割点:
public class TarjanCutVertex {
private final List<List<Integer>> graph;
private final int n;
private int[] dfn;
private int[] low;
private boolean[] isCutVertex;
private int timestamp;
private int root; // 当前 DFS 树根节点
private int rootChildren; // 根节点的子树数量
public TarjanCutVertex(List<List<Integer>> graph) {
this.graph = graph;
this.n = graph.size();
this.dfn = new int[n];
this.low = new int[n];
this.isCutVertex = new boolean[n];
this.timestamp = 0;
for (int i = 0; i < n; i++) {
dfn[i] = -1;
}
}
public List<Integer> findCutVertices() {
List<Integer> cutVertices = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (dfn[i] == -1) {
root = i;
rootChildren = 0;
dfs(i, -1);
// 根节点若有超过一个子树,则是割点
if (rootChildren > 1) {
isCutVertex[root] = true;
}
}
}
for (int i = 0; i < n; i++) {
if (isCutVertex[i]) {
cutVertices.add(i);
}
}
return cutVertices;
}
private void dfs(int u, int parent) {
dfn[u] = low[u] = ++timestamp;
int children = 0;
for (int v : graph.get(u)) {
if (v == parent) continue;
if (dfn[v] == -1) {
children++;
if (u == root) rootChildren++;
dfs(v, u);
low[u] = Math.min(low[u], low[v]);
// 割点判定:u 不是根,且 v 无法回溯到 u 的祖先
if (u != root && low[v] >= dfn[u]) {
isCutVertex[u] = true;
}
} else {
// v 已访问,回边更新 low[u]
low[u] = Math.min(low[u], dfn[v]);
}
}
}
public static void main(String[] args) {
int n = 6;
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
// 构建无向图
addUndirectedEdge(graph, 0, 1);
addUndirectedEdge(graph, 0, 2);
addUndirectedEdge(graph, 1, 2);
addUndirectedEdge(graph, 1, 3);
addUndirectedEdge(graph, 3, 4);
addUndirectedEdge(graph, 3, 5);
TarjanCutVertex tarjan = new TarjanCutVertex(graph);
List<Integer> cutVertices = tarjan.findCutVertices();
System.out.println("割点: " + cutVertices); // [1, 3]
}
private static void addUndirectedEdge(List<List<Integer>> g, int u, int v) {
g.get(u).add(v);
g.get(v).add(u);
}
}
割边(Bridge)求解
若删除某条边后,图的连通分量数量增加,则该边为割边(桥):
public class TarjanBridge {
private final List<List<Integer>> graph;
private final int n;
private int[] dfn;
private int[] low;
private final List<int[]> bridges;
private int timestamp;
public TarjanBridge(List<List<Integer>> graph) {
this.graph = graph;
this.n = graph.size();
this.dfn = new int[n];
this.low = new int[n];
this.bridges = new ArrayList<>();
this.timestamp = 0;
for (int i = 0; i < n; i++) {
dfn[i] = -1;
}
}
public List<int[]> findBridges() {
for (int i = 0; i < n; i++) {
if (dfn[i] == -1) {
dfs(i, -1);
}
}
return bridges;
}
private void dfs(int u, int parent) {
dfn[u] = low[u] = ++timestamp;
for (int v : graph.get(u)) {
if (v == parent) continue;
if (dfn[v] == -1) {
dfs(v, u);
low[u] = Math.min(low[u], low[v]);
// 割边判定:v 无法回溯到 u 或其祖先
if (low[v] > dfn[u]) {
bridges.add(new int[]{u, v});
System.out.println("发现割边: (" + u + ", " + v + ")");
}
} else {
low[u] = Math.min(low[u], dfn[v]);
}
}
}
public static void main(String[] args) {
int n = 5;
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
addUndirectedEdge(graph, 0, 1);
addUndirectedEdge(graph, 1, 2);
addUndirectedEdge(graph, 2, 0); // 0-1-2 构成环
addUndirectedEdge(graph, 1, 3);
addUndirectedEdge(graph, 3, 4); // (1,3) 和 (3,4) 是割边
TarjanBridge tarjan = new TarjanBridge(graph);
List<int[]> bridges = tarjan.findBridges();
System.out.println("\n所有割边:");
for (int[] b : bridges) {
System.out.println("(" + b[0] + ", " + b[1] + ")");
}
}
private static void addUndirectedEdge(List<List<Integer>> g, int u, int v) {
g.get(u).add(v);
g.get(v).add(u);
}
}
实战应用:社交网络关键人物识别
在社交关系网络中,将每个人视为节点,关注关系视为有向边。强连通分量代表了”小圈子”——圈内成员互相关注;而割点则代表了”关键人物”——删除该人物会导致社交网络分裂:
/**
* 社交网络分析器
*/
public class SocialNetworkAnalyzer {
public static void analyze(List<List<Integer>> followGraph, String[] userNames) {
// 1. 找出所有"小圈子"(强连通分量)
TarjanSCC tarjan = new TarjanSCC(followGraph);
List<List<Integer>> sccs = tarjan.findSCCs();
System.out.println("===== 社交网络分析报告 =====");
System.out.println("平台共有 " + sccs.size() + " 个互相关注的小圈子\n");
for (int i = 0; i < sccs.size(); i++) {
List<Integer> scc = sccs.get(i);
System.out.print("圈子 " + (i + 1) + "(" + scc.size() + "人): ");
for (int uid : scc) {
System.out.print(userNames[uid] + " ");
}
System.out.println();
}
// 2. 构建无向图分析关键人物
List<List<Integer>> undirected = new ArrayList<>();
for (int i = 0; i < followGraph.size(); i++) undirected.add(new ArrayList<>());
for (int u = 0; u < followGraph.size(); u++) {
for (int v : followGraph.get(u)) {
if (!undirected.get(u).contains(v)) {
undirected.get(u).add(v);
undirected.get(v).add(u);
}
}
}
TarjanCutVertex cutVertexFinder = new TarjanCutVertex(undirected);
List<Integer> keyUsers = cutVertexFinder.findCutVertices();
System.out.println("\n关键人物(删除后网络会分裂):");
for (int uid : keyUsers) {
System.out.println(" - " + userNames[uid]);
}
}
}
复杂度分析
| 问题 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 强连通分量(SCC) | (O(V + E)) | (O(V)) |
| 割点(Cut Vertex) | (O(V + E)) | (O(V)) |
| 割边(Bridge) | (O(V + E)) | (O(V)) |
Tarjan 算法的线性时间复杂度使其成为处理大规模图数据的首选算法。
总结
Tarjan 算法通过巧妙维护 dfn 和 low 两个时间戳数组,在一次 DFS 中同时完成强连通分量、割点和割边的判定。其核心思想——”通过回边追溯最早祖先”——不仅是图论算法的经典范式,更在编译器的控制流分析、操作系统的死锁检测、互联网的拓扑分析等场景中广泛应用。掌握 Tarjan 算法,意味着你拥有了一把剖析图结构的”瑞士军刀”。