Tarjan算法解 强连通分量 & 循环依赖

目录

[一、核心概念:强连通分量 & 循环依赖](#一、核心概念:强连通分量 & 循环依赖)

[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) :有向图中任意两个节点互相可达的最大子图。
  • 循环依赖判定规则
    1. 单个强连通分量内节点数 ≥ 2:必然存在循环依赖(节点互相引用);
    2. 若强连通分量只有1 个节点 :分两种:
      • 节点存在自环(自己依赖自己) → 也算循环依赖;
      • 无自环 → 无循环依赖。

2. 两种图存储结构对比

存储结构 优点 缺点 适用场景
邻接矩阵 实现简单、判边快 空间复杂度 \(O(n^2)\),节点多则浪费 节点数量少(百级以内)
邻接表 空间 \(O(n+m)\)(m 为边数),稀疏图高效 遍历邻接点稍繁琐 业务依赖图(绝大多数场景)

3. Tarjan 算法原理(求强连通分量)

核心变量

  • dfn[]:时间戳,记录节点首次被访问的次序
  • low[]:节点或其可达子树中最小的 dfn 值
  • 栈:保存当前遍历路径上的节点;
  • inStack[]:标记节点是否在栈中。

算法流程

  1. 遍历所有未访问节点,启动 DFS;
  2. 进入节点 u:初始化 dfn[u] = low[u] = 时间戳,u 入栈、标记在栈中;
  3. 遍历 u 的所有邻接节点 v:
    • v 未访问:递归 DFS (v),回溯后更新 low[u] = min(low[u], low[v])
    • v 已访问 且在栈中 :更新 low[u] = min(low[u], dfn[v])
  4. 若遍历完所有邻接点后,dfn[u] == low[u]:说明 u 是当前强连通分量的根,不断弹栈直到 u 出栈,弹出的所有节点构成一个 SCC;
  5. 对每个 SCC,按规则判断是否存在循环依赖。

二、完整 Java 实战实现

分为三部分:

  1. 邻接矩阵实现 + Tarjan + 循环依赖检测
  2. 邻接表实现 + Tarjan + 循环依赖检测(生产常用)
  3. 测试用例(普通依赖、双向循环、自环、多节点环)

通用工具常量 & 基础封装

复制代码
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);
        }
    }

三、核心:循环依赖判断逻辑

分别适配邻接矩阵邻接表,统一判断规则:

  1. SCC 节点数 > 1 → 循环依赖;

  2. 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. 业务场景扩展

  1. Spring Bean 循环依赖:本质就是有向图循环依赖,底层思想和本题一致;
  2. 包 / 类依赖校验:代码架构校验工具常用 Tarjan 检测包循环引用;
  3. 权限 / 流程节点循环:工作流节点互相引用也可用该算法检测。

4. 边界问题处理

  • 孤立节点(无边):单个节点、无自环 → 无循环;
  • 多连通图:代码中全量遍历所有节点,保证非连通图也能全部检测;
  • 重复边:业务中依赖重复不影响,算法天然兼容。

五、补充:简易总结判断逻辑

  1. 用 Tarjan 拆分出所有强连通分量
  2. 分量节点数 > 1 → 循环依赖;
  3. 分量只有 1 个节点 → 检查是否自环,有则循环依赖;
  4. 全部分量都不满足 → 无循环依赖。
相关推荐
散峰而望1 小时前
【算法练习】算法练习精选:从 Phone numbers 到 Decrease,覆盖字符串、模拟、图论思维题
数据结构·c++·算法·贪心算法·github·动态规划·图论
人道领域2 小时前
【LeetCode刷题日记】538.把二叉搜索树转换为累加树
java·开发语言·后端·算法·leetcode
Lsk_Smion2 小时前
力扣实训 _ [33].搜索旋转排序数组 _ [92].翻转链表Ⅱ
java·数据结构·算法
MrZhao4002 小时前
多 Agent 协作与通信:MessageBus 最小实现
算法
Zhang~Ling2 小时前
二叉搜索树(BST)详解:插入、删除、查找与 Key/Value 实战场景
数据结构·c++·算法
8Qi82 小时前
LeetCode 76. 最小覆盖子串(Minimum Window Substring)
数据结构·算法·leetcode·滑动窗口·哈希表
weixin_BYSJ19872 小时前
springboot旅游管理系统04470(附源码+开发文档+部署教程)
java·spring boot·python·算法·django·flask·旅游
Bingorl2 小时前
机器学习之朴素贝叶斯算法
人工智能·算法·机器学习
8Qi82 小时前
LeetCode 209. 长度最小的子数组(Minimum Size Subarray Sum)
java·算法·leetcode·双指针·滑动窗口