在无向图中,割点(Articulation Point)是指删除该点后图的连通分量数增加的顶点;割边(Bridge)是指删除该边后图的连通分量数增加的边。割点和割边在网络可靠性分析、交通路网设计、社交网络关键节点识别等领域有重要应用。本文用 Java 实现基于 DFS lowlink 值的割点割边判定算法,并扩展至双连通分量的分解。
一、算法原理:DFS 树与 lowlink
对无向图执行 DFS,得到一棵 DFS 生成树。图中的边分为两类:
– 树边:DFS 遍历经过的边
– 回边:连接节点与其祖先的非树边
定义两个时间戳数组:
– dfn[u]:节点 u 的 DFS 发现时间
– low[u]:从 u 出发通过树边和至多一条回边能到达的最小 dfn 值
low[u] = min(dfn[u], min(low[v] for v is child of u), min(dfn[w] for (u,w) is back edge))
1.1 割点判定
对于 DFS 树的根节点 u:若 u 有两个或以上子树,则 u 是割点。
对于非根节点 u:若 u 存在子节点 v 满足 low[v] ≥ dfn[u],则 u 是割点。
含义:v 及其子树无法通过回边到达 u 的祖先,因此删除 u 后 v 子树与图其余部分断开。
1.2 割边判定
树边 (u, v) 是割边当且仅当 low[v] > dfn[u](v 是 u 的子节点)。
含义:v 及其子树无法通过回边到达 u 或其祖先,删除该边后 v 子树被分离。
二、Java 完整实现
import java.util.*;
/**
* 割点与割边求解器
* 核心算法:DFS + lowlink 值
*/
public class ArticulationPointBridge {
private int n; // 顶点数
private List<Integer>[] adj; // 邻接表
private int[] dfn; // DFS发现时间
private int[] low; // 能回溯到的最小时间
private boolean[] visited;
private int timestamp;
private boolean[] isArticulation; // 割点标记
private List<int[]> bridges; // 割边列表 [u, v]
@SuppressWarnings("unchecked")
public ArticulationPointBridge(int n) {
this.n = n;
this.adj = new ArrayList[n];
for (int i = 0; i < n; i++) adj[i] = new ArrayList<>();
this.dfn = new int[n];
this.low = new int[n];
this.visited = new boolean[n];
this.isArticulation = new boolean[n];
this.bridges = new ArrayList<>();
}
public void addEdge(int u, int v) {
adj[u].add(v);
adj[v].add(u);
}
/**
* DFS 求割点和割边
* @param u 当前节点
* @param parent 父节点(-1表示根)
*/
private void dfs(int u, int parent) {
visited[u] = true;
dfn[u] = low[u] = ++timestamp;
int childCount = 0; // 用于根节点的子树计数
for (int v : adj[u]) {
if (!visited[v]) {
childCount++;
dfs(v, u);
// 回溯时更新 low[u]
low[u] = Math.min(low[u], low[v]);
// 割点判定(非根节点)
if (parent != -1 && low[v] >= dfn[u]) {
isArticulation[u] = true;
}
// 割边判定
if (low[v] > dfn[u]) {
bridges.add(new int[]{u, v});
}
} else if (v != parent) {
// 回边:更新 low[u]
// 注意:无向图中要排除父节点,但需处理重边情况
low[u] = Math.min(low[u], dfn[v]);
}
}
// 根节点的割点判定
if (parent == -1 && childCount >= 2) {
isArticulation[u] = true;
}
}
/**
* 处理重边情况:使用边编号而非顶点判断父节点
*/
private void dfsWithEdgeId(int u, int edgeId) {
visited[u] = true;
dfn[u] = low[u] = ++timestamp;
int childCount = 0;
for (int[] next : adjWithEdgeId.get(u)) {
int v = next[0];
int eid = next[1];
if (!visited[v]) {
childCount++;
dfsWithEdgeId(v, eid);
low[u] = Math.min(low[u], low[v]);
if (edgeId != -1 && low[v] >= dfn[u]) {
isArticulation[u] = true;
}
if (low[v] > dfn[u]) {
bridges.add(new int[]{u, v});
}
} else if (eid != edgeId) {
// 只通过非当前边的回边更新
low[u] = Math.min(low[u], dfn[v]);
}
}
if (edgeId == -1 && childCount >= 2) {
isArticulation[u] = true;
}
}
private List<int[]>[] adjWithEdgeId; // [v, edgeId]
private int edgeCounter;
/**
* 添加带编号的边(用于处理重边)
*/
public void addEdgeWithId(int u, int v) {
if (adjWithEdgeId == null) {
adjWithEdgeId = new ArrayList[n];
for (int i = 0; i < n; i++) adjWithEdgeId[i] = new ArrayList<>();
edgeCounter = 0;
}
adjWithEdgeId[u].add(new int[]{v, edgeCounter});
adjWithEdgeId[v].add(new int[]{u, edgeCounter});
edgeCounter++;
}
/**
* 执行割点割边计算(简单图版本)
*/
public void solve() {
Arrays.fill(visited, false);
Arrays.fill(isArticulation, false);
Arrays.fill(dfn, 0);
Arrays.fill(low, 0);
bridges.clear();
timestamp = 0;
for (int i = 0; i < n; i++) {
if (!visited[i]) {
dfs(i, -1);
}
}
}
/**
* 执行割点割边计算(支持重边版本)
*/
public void solveWithEdgeId() {
Arrays.fill(visited, false);
Arrays.fill(isArticulation, false);
Arrays.fill(dfn, 0);
Arrays.fill(low, 0);
bridges.clear();
timestamp = 0;
for (int i = 0; i < n; i++) {
if (!visited[i]) {
dfsWithEdgeId(i, -1);
}
}
}
public List<Integer> getArticulationPoints() {
List<Integer> points = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (isArticulation[i]) points.add(i);
}
return points;
}
public List<int[]> getBridges() {
return new ArrayList<>(bridges);
}
/**
* 求边双连通分量(删除割边后的连通块)
*/
public List<List<Integer>> getEdgeBiconnectedComponents() {
// 临时移除割边,然后做连通分量标记
boolean[][] isBridge = new boolean[n][n];
for (int[] b : bridges) {
isBridge[b[0]][b[1]] = isBridge[b[1]][b[0]] = true;
}
List<List<Integer>> components = new ArrayList<>();
boolean[] compVisited = new boolean[n];
for (int i = 0; i < n; i++) {
if (compVisited[i]) continue;
List<Integer> comp = new ArrayList<>();
Queue<Integer> queue = new LinkedList<>();
queue.add(i);
compVisited[i] = true;
while (!queue.isEmpty()) {
int u = queue.poll();
comp.add(u);
for (int v : adj[u]) {
if (!compVisited[v] && !isBridge[u][v]) {
compVisited[v] = true;
queue.add(v);
}
}
}
if (!comp.isEmpty()) components.add(comp);
}
return components;
}
/**
* 求点双连通分量(基于割点的块分解)
* 使用栈记录 DFS 路径上的边
*/
public List<List<int[]>> getVertexBiconnectedComponents() {
List<List<int[]>> components = new ArrayList<>();
Stack<int[]> edgeStack = new Stack<>();
boolean[] vVisited = new boolean[n];
int[] vDfn = new int[n];
int[] vLow = new int[n];
int[] vTime = {0};
for (int i = 0; i < n; i++) {
if (!vVisited[i]) {
findVertexBCC(i, -1, vVisited, vDfn, vLow, vTime, edgeStack, components);
// 弹出剩余边
if (!edgeStack.isEmpty()) {
List<int[]> comp = new ArrayList<>();
while (!edgeStack.isEmpty()) comp.add(edgeStack.pop());
components.add(comp);
}
}
}
return components;
}
private void findVertexBCC(int u, int parent, boolean[] vis, int[] d, int[] l,
int[] t, Stack<int[]> stack, List<List<int[]>> comps) {
vis[u] = true;
d[u] = l[u] = ++t[0];
for (int v : adj[u]) {
if (!vis[v]) {
stack.push(new int[]{u, v});
findVertexBCC(v, u, vis, d, l, t, stack, comps);
l[u] = Math.min(l[u], l[v]);
if (l[v] >= d[u]) {
// 找到一个点双连通分量
List<int[]> comp = new ArrayList<>();
while (!stack.isEmpty()) {
int[] edge = stack.pop();
comp.add(edge);
if ((edge[0] == u && edge[1] == v) || (edge[0] == v && edge[1] == u)) {
break;
}
}
comps.add(comp);
}
} else if (v != parent && d[v] < d[u]) {
// 回边,且只向祖先连边避免重复
stack.push(new int[]{u, v});
l[u] = Math.min(l[u], d[v]);
}
}
}
public static void main(String[] args) {
// 测试图1:经典示例
// 0
// / \
// 1---2
// | |
// 3---4
System.out.println("=== 测试图1:基础连通图 ===");
ArticulationPointBridge solver1 = new ArticulationPointBridge(5);
solver1.addEdge(0, 1);
solver1.addEdge(0, 2);
solver1.addEdge(1, 2);
solver1.addEdge(1, 3);
solver1.addEdge(2, 4);
solver1.addEdge(3, 4);
solver1.solve();
System.out.println("割点: " + solver1.getArticulationPoints()); // []
System.out.println("割边: " + formatEdges(solver1.getBridges())); // []
// 测试图2:含割点和割边
// 0
// / \
// 1 2
// | |
// 3 4---5
System.out.println("\n=== 测试图2:含割点和割边 ===");
ArticulationPointBridge solver2 = new ArticulationPointBridge(6);
solver2.addEdge(0, 1);
solver2.addEdge(0, 2);
solver2.addEdge(1, 3);
solver2.addEdge(2, 4);
solver2.addEdge(4, 5);
solver2.solve();
System.out.println("割点: " + solver2.getArticulationPoints()); // [0, 2, 4]
System.out.println("割边: " + formatEdges(solver2.getBridges())); // [(0,1), (1,3), (2,4), (4,5)]
// 测试图3:链状图
System.out.println("\n=== 测试图3:链状图 ===");
ArticulationPointBridge solver3 = new ArticulationPointBridge(4);
solver3.addEdge(0, 1);
solver3.addEdge(1, 2);
solver3.addEdge(2, 3);
solver3.solve();
System.out.println("割点: " + solver3.getArticulationPoints()); // [1, 2]
System.out.println("割边: " + formatEdges(solver3.getBridges())); // 所有边
// 测试图4:含重边
System.out.println("\n=== 测试图4:含重边(需用 edgeId 版本) ===");
ArticulationPointBridge solver4 = new ArticulationPointBridge(3);
solver4.addEdgeWithId(0, 1);
solver4.addEdgeWithId(0, 1); // 重边
solver4.addEdgeWithId(1, 2);
solver4.solveWithEdgeId();
System.out.println("割点: " + solver4.getArticulationPoints()); // [1](0-1间的重边使0-1不再是割边,但1仍是割点)
System.out.println("割边: " + formatEdges(solver4.getBridges())); // [(1,2)]
// 边双连通分量测试
System.out.println("\n=== 边双连通分量测试(测试图2) ===");
List<List<Integer>> ebcc = solver2.getEdgeBiconnectedComponents();
for (int i = 0; i < ebcc.size(); i++) {
System.out.println("分量 " + (i+1) + ": " + ebcc.get(i));
}
}
private static String formatEdges(List<int[]> edges) {
StringBuilder sb = new StringBuilder("[");
for (int[] e : edges) {
sb.append("(").append(e[0]).append(",").append(e[1]).append(") ");
}
if (!edges.isEmpty()) sb.setLength(sb.length() - 1);
sb.append("]");
return sb.toString();
}
}
三、复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 割点割边计算 | O(V + E) | O(V + E) |
| 边双连通分量 | O(V + E) | O(V + E) |
| 点双连通分量 | O(V + E) | O(V + E) |
所有操作均为线性时间,这是 Tarjan 系列算法的标志性特征。
四、易错点与注意事项
- 根节点的特殊处理:根节点是割点的判定条件与普通节点不同,必须有两个以上子树
- 重边的影响:简单版本中通过
v != parent判断回边,在有重边时会误判。应使用边编号区分 - 自环的处理:自环不影响割点割边判定,DFS 中可忽略
- low 值更新方向:只能通过回边向祖先更新,不能向子孙更新(即只接受
dfn[v] < dfn[u]的更新)
五、实际应用场景
- 网络可靠性:识别通信网络中的单点故障和单链路故障
- 交通路网:找出关键路口和路段,指导冗余建设
- 社交网络:发现维系社区连通性的关键人物
- 电力系统:识别电网中的脆弱节点和输电线路
- 分子生物学:分析蛋白质相互作用网络的关键节点
六、扩展:双连通图的判定与构造
- 点双连通图:无割点的连通图,任意两点间存在至少两条点不相交路径
- 边双连通图:无割边的连通图,任意两点间存在至少两条边不相交路径
通过加边消除所有割点/割边,可将图转化为双连通图,这是网络加固的基础问题。贪心策略是:对每条割边,在其分离的两个连通分量间添加一条新边。