目录
[一、核心概念:强连通分量 & 循环依赖](#一、核心概念:强连通分量 & 循环依赖)
[1. 基础定义](#1. 基础定义)
[2. 两种图存储结构对比](#2. 两种图存储结构对比)
[3. Tarjan 算法原理(求强连通分量)](#3. Tarjan 算法原理(求强连通分量))
[二、完整 Java 实战实现](#二、完整 Java 实战实现)
[通用工具常量 & 基础封装](#通用工具常量 & 基础封装)
[一、邻接矩阵 版本](#一、邻接矩阵 版本)
[1. 邻接矩阵图结构 + Tarjan + 依赖检测](#1. 邻接矩阵图结构 + Tarjan + 依赖检测)
[二、邻接表 版本(生产推荐)](#二、邻接表 版本(生产推荐))
[四、测试入口 & 多场景用例](#四、测试入口 & 多场景用例)
[四、关键点解析 & 生产优化](#四、关键点解析 & 生产优化)
[1. 两种存储选型](#1. 两种存储选型)
[2. Tarjan 时间复杂度](#2. Tarjan 时间复杂度)
[3. 业务场景扩展](#3. 业务场景扩展)
[4. 边界问题处理](#4. 边界问题处理)
一、核心概念:强连通分量 & 循环依赖
1. 基础定义
- 有向图:节点间边有方向,依赖场景天然是有向图(A→B 代表 A 依赖 B)。
- 强连通分量 (SCC) :有向图中任意两个节点互相可达的最大子图。
- 循环依赖判定规则 :
- 若单个强连通分量内节点数 ≥ 2:必然存在循环依赖(节点互相引用);
- 若强连通分量只有1 个节点 :分两种:
- 节点存在自环(自己依赖自己) → 也算循环依赖;
- 无自环 → 无循环依赖。
2. 两种图存储结构对比
| 存储结构 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 邻接矩阵 | 实现简单、判边快 | 空间复杂度 \(O(n^2)\),节点多则浪费 | 节点数量少(百级以内) |
| 邻接表 | 空间 \(O(n+m)\)(m 为边数),稀疏图高效 | 遍历邻接点稍繁琐 | 业务依赖图(绝大多数场景) |
3. Tarjan 算法原理(求强连通分量)
核心变量
dfn[]:时间戳,记录节点首次被访问的次序;low[]:节点或其可达子树中最小的 dfn 值;- 栈:保存当前遍历路径上的节点;
inStack[]:标记节点是否在栈中。
算法流程
- 遍历所有未访问节点,启动 DFS;
- 进入节点 u:初始化
dfn[u] = low[u] = 时间戳,u 入栈、标记在栈中; - 遍历 u 的所有邻接节点 v:
- v 未访问:递归 DFS (v),回溯后更新
low[u] = min(low[u], low[v]); - v 已访问 且在栈中 :更新
low[u] = min(low[u], dfn[v]);
- v 未访问:递归 DFS (v),回溯后更新
- 若遍历完所有邻接点后,
dfn[u] == low[u]:说明 u 是当前强连通分量的根,不断弹栈直到 u 出栈,弹出的所有节点构成一个 SCC; - 对每个 SCC,按规则判断是否存在循环依赖。
二、完整 Java 实战实现
分为三部分:
- 邻接矩阵实现 + Tarjan + 循环依赖检测
- 邻接表实现 + Tarjan + 循环依赖检测(生产常用)
- 测试用例(普通依赖、双向循环、自环、多节点环)
通用工具常量 & 基础封装
import java.util.*;
/**
* 有向图循环依赖检测(基于Tarjan求强连通分量)
* 支持:邻接矩阵、邻接表两种存储
*/
public class CycleDependencyDetector {
// 全局时间戳
private int timeStamp;
// dfn: 节点访问时间戳
private int[] dfn;
// low: 节点能回溯到的最小时间戳
private int[] low;
// 标记节点是否在栈中
private boolean[] inStack;
// 遍历用栈
private Deque<Integer> stack;
// 最终所有强连通分量
private List<List<Integer>> sccList;
public CycleDependencyDetector() {}
一、邻接矩阵 版本
1. 邻接矩阵图结构 + Tarjan + 依赖检测
// ===================== 邻接矩阵实现 =====================
/**
* @param graph 邻接矩阵 graph[u][v] = 1 表示 u -> v 存在依赖边
* @param nodeCount 节点总数(节点编号:0 ~ nodeCount-1)
* @return true: 存在循环依赖 false: 无循环依赖
*/
public boolean checkByMatrix(int[][] graph, int nodeCount) {
// 初始化数组
timeStamp = 0;
dfn = new int[nodeCount];
low = new int[nodeCount];
inStack = new boolean[nodeCount];
stack = new ArrayDeque<>();
sccList = new ArrayList<>();
// 初始化为0,表示未访问
Arrays.fill(dfn, 0);
Arrays.fill(low, 0);
// 遍历所有节点,防止非连通图
for (int i = 0; i < nodeCount; i++) {
if (dfn[i] == 0) {
tarjanMatrix(i, graph, nodeCount);
}
}
// 遍历所有强连通分量,判断是否存在循环依赖
return judgeCycle(graph, sccList);
}
/**
* Tarjan 算法 - 邻接矩阵版 DFS
*/
private void tarjanMatrix(int u, int[][] graph, int nodeCount) {
// 标记访问时间
dfn[u] = low[u] = ++timeStamp;
stack.push(u);
inStack[u] = true;
// 遍历所有节点,找 u 的邻接点 v
for (int v = 0; v < nodeCount; v++) {
// u -> v 有边
if (graph[u][v] == 1) {
if (dfn[v] == 0) {
// 未访问,递归
tarjanMatrix(v, graph, nodeCount);
// 回溯更新low
low[u] = Math.min(low[u], low[v]);
} else if (inStack[v]) {
// 已访问且在栈中,属于当前环
low[u] = Math.min(low[u], dfn[v]);
}
}
}
// dfn[u] == low[u] 找到一个强连通分量
if (dfn[u] == low[u]) {
List<Integer> scc = new ArrayList<>();
int cur;
do {
cur = stack.pop();
inStack[cur] = false;
scc.add(cur);
} while (cur != u);
sccList.add(scc);
}
}
二、邻接表 版本(生产推荐)
稀疏依赖图(绝大多数业务场景)优先使用邻接表,空间效率极高。
// ===================== 邻接表实现(推荐) =====================
/**
* @param adj 邻接表 adj.get(u) 存放 u 指向的所有节点
* @param nodeCount 节点总数
* @return true: 存在循环依赖
*/
public boolean checkByAdjList(List<List<Integer>> adj, int nodeCount) {
timeStamp = 0;
dfn = new int[nodeCount];
low = new int[nodeCount];
inStack = new boolean[nodeCount];
stack = new ArrayDeque<>();
sccList = new ArrayList<>();
Arrays.fill(dfn, 0);
Arrays.fill(low, 0);
for (int i = 0; i < nodeCount; i++) {
if (dfn[i] == 0) {
tarjanAdj(i, adj);
}
}
return judgeCycleByList(adj, sccList);
}
/**
* Tarjan 算法 - 邻接表版 DFS
*/
private void tarjanAdj(int u, List<List<Integer>> adj) {
dfn[u] = low[u] = ++timeStamp;
stack.push(u);
inStack[u] = true;
// 直接遍历 u 的所有邻接点
for (int v : adj.get(u)) {
if (dfn[v] == 0) {
tarjanAdj(v, adj);
low[u] = Math.min(low[u], low[v]);
} else if (inStack[v]) {
low[u] = Math.min(low[u], dfn[v]);
}
}
// 弹出整个强连通分量
if (dfn[u] == low[u]) {
List<Integer> scc = new ArrayList<>();
int cur;
do {
cur = stack.pop();
inStack[cur] = false;
scc.add(cur);
} while (cur != u);
sccList.add(scc);
}
}
三、核心:循环依赖判断逻辑
分别适配邻接矩阵 和邻接表,统一判断规则:
-
SCC 节点数 > 1 → 循环依赖;
-
SCC 节点数 = 1 → 检查自环(自己依赖自己)→ 有自环也判定为循环依赖。
// ===================== 循环依赖判断逻辑 ===================== /** * 邻接矩阵版 依赖判断 */ private boolean judgeCycle(int[][] graph, List<List<Integer>> sccList) { for (List<Integer> scc : sccList) { int size = scc.size(); // 情况1:分量节点数≥2,必然循环依赖 if (size > 1) { printSCC(scc); return true; } // 情况2:单个节点,判断是否存在自环 u->u int u = scc.get(0); if (graph[u][u] == 1) { System.out.println("检测到自环节点:" + u); return true; } } return false; } /** * 邻接表版 依赖判断 */ private boolean judgeCycleByList(List<List<Integer>> adj, List<List<Integer>> sccList) { for (List<Integer> scc : sccList) { int size = scc.size(); if (size > 1) { printSCC(scc); return true; } // 单个节点判自环 int u = scc.get(0); if (adj.get(u).contains(u)) { System.out.println("检测到自环节点:" + u); return true; } } return false; } /** * 打印强连通分量(用于日志/调试) */ private void printSCC(List<Integer> scc) { System.out.print("发现循环依赖的强连通分量:"); for (Integer node : scc) { System.out.print(node + " "); } System.out.println(); }
四、测试入口 & 多场景用例
覆盖 无依赖、双向循环、三节点环、自环 四大典型场景:
// ===================== 测试主方法 =====================
public static void main(String[] args) {
CycleDependencyDetector detector = new CycleDependencyDetector();
// 场景1:邻接矩阵测试 - 双向循环依赖 0<->1
System.out.println("===== 场景1:邻接矩阵 - 双向循环依赖 =====");
int[][] matrix1 = {
{0, 1, 0}, // 0→1
{1, 0, 0}, // 1→0
{0, 0, 0} // 2 无依赖
};
boolean hasCycle1 = detector.checkByMatrix(matrix1, 3);
System.out.println("是否存在循环依赖:" + hasCycle1 + "\n");
// 场景2:邻接矩阵测试 - 自环 0→0
System.out.println("===== 场景2:邻接矩阵 - 节点自环 =====");
int[][] matrix2 = {
{1, 0, 0},
{0, 0, 0},
{0, 0, 0}
};
boolean hasCycle2 = detector.checkByMatrix(matrix2, 3);
System.out.println("是否存在循环依赖:" + hasCycle2 + "\n");
// 场景3:邻接表测试 - 正常单向依赖(无循环) 0→1→2
System.out.println("===== 场景3:邻接表 - 单向依赖(无循环) =====");
List<List<Integer>> adj1 = new ArrayList<>();
for (int i = 0; i < 3; i++) adj1.add(new ArrayList<>());
adj1.get(0).add(1);
adj1.get(1).add(2);
boolean hasCycle3 = detector.checkByAdjList(adj1, 3);
System.out.println("是否存在循环依赖:" + hasCycle3 + "\n");
// 场景4:邻接表测试 - 三节点环 0→1→2→0
System.out.println("===== 场景4:邻接表 - 三节点循环依赖 =====");
List<List<Integer>> adj2 = new ArrayList<>();
for (int i = 0; i < 3; i++) adj2.add(new ArrayList<>());
adj2.get(0).add(1);
adj2.get(1).add(2);
adj2.get(2).add(0);
boolean hasCycle4 = detector.checkByAdjList(adj2, 3);
System.out.println("是否存在循环依赖:" + hasCycle4);
}
}
三、运行结果说明
===== 场景1:邻接矩阵 - 双向循环依赖 =====
发现循环依赖的强连通分量:1 0
是否存在循环依赖:true
===== 场景2:邻接矩阵 - 节点自环 =====
检测到自环节点:0
是否存在循环依赖:true
===== 场景3:邻接表 - 单向依赖(无循环) =====
是否存在循环依赖:false
===== 场景4:邻接表 - 三节点循环依赖 =====
发现循环依赖的强连通分量:2 1 0
是否存在循环依赖:true
四、关键点解析 & 生产优化
1. 两种存储选型
- 邻接矩阵 :仅用于节点数少(<500)、教学 / 简单工具场景,节点一多内存爆炸;
- 邻接表 :项目中类加载依赖、Bean 依赖、接口调用依赖全部用邻接表。
2. Tarjan 时间复杂度
- 邻接矩阵:\(O(n^2)\)
- 邻接表:\(O(n+m)\)(n 节点,m 边),线性复杂度,性能最优。
3. 业务场景扩展
- Spring Bean 循环依赖:本质就是有向图循环依赖,底层思想和本题一致;
- 包 / 类依赖校验:代码架构校验工具常用 Tarjan 检测包循环引用;
- 权限 / 流程节点循环:工作流节点互相引用也可用该算法检测。
4. 边界问题处理
- 孤立节点(无边):单个节点、无自环 → 无循环;
- 多连通图:代码中全量遍历所有节点,保证非连通图也能全部检测;
- 重复边:业务中依赖重复不影响,算法天然兼容。
五、补充:简易总结判断逻辑
- 用 Tarjan 拆分出所有强连通分量;
- 分量节点数 > 1 → 循环依赖;
- 分量只有 1 个节点 → 检查是否自环,有则循环依赖;
- 全部分量都不满足 → 无循环依赖。