用 Tarjan 算法求解有向图的强连通分量
图论中的连通性概念是许多算法与应用的基础。当我们研究网络结构、依赖关系或路径问题时,理解图中的连通性质至关重要。对于不同类型的图,连通性有着不同的表现形式和算法解决方案。
无向图与有向图的连通性
在无向图中,连通分量是指图中任意两个顶点之间都存在路径的最大子图。寻找无向图的连通分量相对简单,通过一次深度优先搜索(DFS)或广度优先搜索(BFS)就能识别所有连通分量。
然而,在有向图中,情况变得复杂得多。因为有向图中的边具有方向性,从顶点 A 能到达顶点 B,并不意味着从 B 也能到达 A。这就引出了强连通分量(Strongly Connected Component, SCC)的概念:在有向图中,如果一个子图内的任意两个顶点 u 和 v 都满足 u 可以到达 v 且 v 也可以到达 u,那么这个子图就是强连通的。“极大”要求,每个图都可以划分成多个强连通分量。的强连通子图,就是一个强连通分量,由于有了“极大”要求,每个图都可以划分成多个强连通分量。
强连通分量的重要性
强连通分量分析在许多领域都有重要应用:
- 编译器优化:识别代码中的循环依赖,优化执行顺序
- 社交网络分析:发现紧密互动的用户群体
- 电子电路设计:分析信号传播路径
- 生态系统建模:研究物种间的相互依赖关系
Tarjan算法的地位
在众多求解强连通分量的算法中,Robert Tarjan于1972年提出的Tarjan算法因其高效性和优雅性而广受推崇。与Kosaraju算法相比,Tarjan算法具有以下优势:
- 单次DFS遍历:只需一次深度优先搜索即可完成
- 线性时间复杂度:O(V+E)的时间复杂度,其中V是顶点数,E是边数
- 空间效率:仅需维护几个辅助数组和栈
Tarjan 算法的原理到实现
Tarjan 算法通过一次 DFS 来划分出所有的强连通分量,在搜索中,需要维护几个关键数组和数据结构来追踪图中节点的状态:
发现时间数组(disc):记录每个节点在DFS遍历中被首次访问的时间戳。这个时间戳单调递增,为每个节点提供唯一的访问序号。
最低访问数组(low):存储每个节点通过树边和后向边能够回溯到的最早访问节点的发现时间。这是识别SCC的核心依据。
栈状态标记(onStack):布尔数组,指示节点当前是否在算法使用的辅助栈中。这帮助我们区分有效的后向边。
栈(stk):按照DFS访问顺序存储节点,用于在发现完整SCC时提取相关节点。
这些数据结构共同协作,使得我们能够在单次DFS遍历中完成SCC识别。初始化时,disc和low数组设为0,onStack设为false,栈为空。
用深度遍历遍历求解强连通分量
算法的核心在于精心设计的DFS遍历,它不仅仅进行简单的图遍历,还通过维护上述数据结构来识别SCC:
void dfs(int u) {
// 设置发现时间和初始low值
disc[u] = low[u] = ++time;
stk.push(u);
onStack[u] = true;
// 遍历所有邻接节点
for (int v : edges[u]) {
if (!disc[v]) { // 未访问的节点(树边情况)
dfs(v);
low[u] = min(low[u], low[v]);
}
else if (onStack[v]) { // 已访问但在栈中(后向边情况)
low[u] = min(low[u], disc[v]);
}
}
// 检查是否是SCC的根节点
if (low[u] == disc[u]) {
vector<int> scc;
while (true) {
int v = stk.top();
stk.pop();
onStack[v] = false;
scc.push_back(v);
if (v == u) break;
}
sccs.push_back(scc);
}
}
节点首次访问
当DFS首次访问一个节点u时,算法执行以下关键操作:
disc[u] = low[u] = ++time;
stk.push(u);
onStack[u] = true;
按照定义 disc[u]记录的是节点的"发现时间",这个时间戳随着遍历严格单调递增(每个节点获得唯一序号),反映DFS遍历的拓扑顺序。
初始时low[u]设为与disc[u]相同,表示目前只知道 u 能到达自身,稍后随着搜索进行,low[u]可能会降低。
入栈操作将 u 本身放入,并以onStack标记,并把似乎是调用栈的副本。但是在函数结束后并没有被弹出,他们会在回溯到if (low[u] == disc[u])时被统一弹出。
DFS 递归调用
if (!disc[v]) {
dfs(v);
low[u] = min(low[u], low[v]);
}
若下一个节点从未访问过,则正常访问,并按照定义更新low[u]。这个过程如同节点在问:"我的子节点能连接到多早的祖先?"
else if (onStack[v]) {
low[u] = min(low[u], disc[v]);
}
若下一个节点已经访问过,且仍在大栈内呢?那么这条边叫做后向边,是指向DFS栈中活跃节点的边,它揭示了潜在的环路:
使用disc[v]而非low[v]来更新。因为我们需要记录的是"直接"通过这条后向边能到达的最早节点
- 使用
low[v]可能导致跨SCC的信息污染(如图中存在多个SCC时) onStack[v]检查确保我们只考虑当前DFS路径上的节点(灰色节点),忽略已经处理完的SCC(黑色节点)
若下一个节点已经访问过,且不在大栈内呢?那么这条边叫做横叉边(cross edge),是指连接不同子树的边。算法中我们故意忽略不在栈中的已访问节点,这是因为忽略不在栈中的已访问节点不会影响SCC识别,不在栈内的这些节点属于已经划分处理的SCC。
实例说明:
考虑图A→B→C→A:
- 当处理边C→A时,发现A在栈中
- 于是更新
low[C] = min(low[C], disc[A]) - 这个信息会通过递归返回传播到B和A
- 最终A的
low[A]等于disc[A],识别出SCC
SCC识别的过程
SCC识别的核心代码段:
if (low[u] == disc[u]) {
vector<int> scc;
while (true) {
int v = stk.top();
stk.pop();
onStack[v] = false;
scc.push_back(v);
if (v == u) break;
}
sccs.push_back(scc);
}
为什么这个条件能识别SCC根?
low[u] == disc[u]表明u无法回溯到更早的节点- 从u出发的所有路径最终都只能回到u或其后代
- 栈中u上方的节点都满足:
- 是u在DFS树中的后代
- 都能通过某种路径回到u(否则它们的low值会使u的low值变小)
栈结构的精妙设计:
- 栈维护了当前DFS路径的所有活跃节点
- 节点出栈顺序保证了SCC的完整性:
- 后进先出的特性确保总是先处理完所有后代
- 当遇到SCC根时,其所有后代都位于栈顶连续位置
此时,u 和其上方所有节点出栈,他们构成一个强连通分量。
Tarjan 搜索树的性质
下面这些性质可以帮助你更好的理解算法的工作原理。
SCC 形成子树的证明
引理1:在DFS树中,一个SCC的所有节点形成一棵连通的子树。
证明:
- 设SCC的根节点为r(
disc[r]最小) - 对SCC中任意节点u,存在路径u→r和r→u
- 由于r最早被发现,路径r→u必须全部由u的祖先组成
- 因此u必须是r的后代
推论:SCC识别可以限制在DFS树的单个子树范围内。
low值传播的正确性
定理1:low[u]正确计算了u能回溯到的最早祖先。
归纳证明:
- 基例:叶子节点的
low值正确(只能通过后向边更新) - 归纳步骤:假设所有子节点的
low值正确- 树边传播:
low[u] = min(low[u], low[v]) - 后向边更新:
low[u] = min(low[u], disc[v]) - 这两种更新覆盖了所有可能的回溯路径
- 树边传播:
栈维护的完整性
引理2:当low[u] == disc[u]时,栈中u上方的节点恰好构成以u为根的SCC。
证明:
- 这些节点都是u的后代(由DFS栈的性质保证)
- 每个节点v都能到达u:
- 因为
low[u]没有被这些节点减小 - 即不存在从这些节点到u的祖先的路径
- 因为
- u能到达所有这些节点(因为是它们的祖先)
- 极大性由栈的弹出操作保证
总的来说,在有多个SCC的图中,算法的正确性依赖于:
- 隔离性:不同SCC的处理互不干扰
- 顺序性:SCC按照拓扑逆序被识别(最深的SCC最先被处理)
- 完备性:每个节点最终都会被某个SCC包含
这种隔离处理的能力使得算法能够高效处理大规模复杂图结构。
class Graph {
vector<vector<int>> edges;
int n;
int time = 0;
vector<int> disc, low;
vector<bool> onStack;
stack<int> stk;
vector<vector<int>> sccs;
void dfs(int u) {
// ...
}
public:
Graph(int n) : n(n), edges(n), disc(n), low(n), onStack(n) {}
void addEdge(int u, int v) {
edges[u].push_back(v);
}
vector<vector<int>> findSCCs() {
for (int i = 0; i < n; ++i) {
if (!disc[i]) dfs(i);
}
return sccs;
}
void printSCCs() const {
for (const auto& scc : sccs) {
cout << "SCC: ";
for (int v : scc) cout << v << " ";
cout << endl;
}
}
};
复杂度
时间复杂度:\(O(V + E)\),仅执行一次搜索,每个节点和边只被处理一次。
空间复杂度:\(O(V)\),用于存储各种辅助数组和栈。
基于Tarjan算法的拓展应用
图的缩点技术(DAG收缩)
DAG 指“无环的有向图”,即每个点都自成一个强连通分量,有去无回。有些时候,需要将“有环有向图”中的环都去掉(实际上就是合并所有超过一个点的 SCC)。
缩点技术是将每个强连通分量压缩为单个超级节点的图变换方法。经过这种转换后,原有的有向图将简化为一个有向无环图(DAG),这一过程我们称之为图的DAG收缩。
关键实现步骤:
- 使用Tarjan算法识别图中的所有强连通分量
- 为每个SCC创建对应的超级节点,每个节点代表原来整个 SCC。
- 重建边关系:
- 保留不同SCC之间的原始边
- 消除同一SCC内部的边(避免自环)
DAG 指“无环的有向图”,即每个点都自成一个强连通分量,有去无回。有些时候,需要将“有环有向图”中的环都去掉(实际上就是合并所有超过一个点的 SCC)。
典型应用场景如:
- 依赖关系分析:在软件工程中分析模块依赖,识别循环依赖组
- 路径优化:将复杂网络简化为DAG后更高效地计算最长/最短路径
- 控制流分析:编译器优化中识别代码基本块之间的关系
- 任务调度:解决存在约束条件的任务排序问题
缩点后的DAG保持原图的关键路径特性,同时消除了循环依赖带来的复杂性。例如,在拓扑排序中,对缩点后的DAG进行排序可以确定各组件的处理顺序,而同一SCC内的组件则代表需要特殊处理的循环依赖单元。代码略
2-SAT 问题求解
2-SAT(二维可满足性)问题是一类特殊的布尔可满足性问题,其特征为:
- 每个子句恰好包含两个文字(变量或其否定)
- 所有子句均为逻辑或(∨)关系
- 整个表达式为各子句的逻辑与(∧)
此问题有多种解决方案,转化为 SCC 问题就是其中之一。关键转化技巧:
将每个布尔变量x拆分为两个节点:x(真)和¬x(假)
将逻辑蕴含关系转化为有向边:
- 子句(a ∨ b)等价于(¬a → b)和(¬b → a)
构建蕴含图(implication graph)
在蕴含图上运行Tarjan算法识别SCC
可满足性判定准则:
- 当且仅当没有变量x使得x和¬x属于同一SCC时,2-SAT问题有解
解构造方法:
- 对缩点后的DAG进行拓扑排序
- 按照逆拓扑序为各SCC赋值(优先选择代表"真"的组件)
缩点技术和2-SAT问题求解展示了Tarjan算法的强大扩展能力。其核心在于:
- 循环依赖识别:通过SCC检测揭示问题的核心约束
- 层次结构构建:将复杂关系简化为可处理的DAG结构
- 高效求解:利用线性时间算法处理原本复杂的问题
这两种应用体现了同一个深刻见解:许多复杂问题中真正造成困难的是元素之间的循环依赖关系。Tarjan算法提供的SCC识别能力,正是打破这些循环、将问题简化为可处理形式的关键工具。在算法设计中,这种"识别循环→消除循环→分层处理"的思路具有广泛的适用性,这也是Tarjan算法在理论计算机科学和实际工程中都备受重视的原因。当然,这只是 tarjan 算法能解决的各种众多扩展问题之二。
用 Tarjan 算法求解有向图的强连通分量的更多相关文章
- Tarjan算法 求 有向图的强连通分量
百度百科 https://baike.baidu.com/item/tarjan%E7%AE%97%E6%B3%95/10687825?fr=aladdin 参考博文 http://blog.csdn ...
- Tarjan算法求有向图的强连通分量
算法描述 tarjan算法思想:从一个点开始,进行深度优先遍历,同时记录到达该点的时间(dfn记录到达i点的时间),和该点能直接或间接到达的点中的最早的时间(low[i]记录这个值,其中low的初始值 ...
- Tarjan算法初探 (1):Tarjan如何求有向图的强连通分量
在此大概讲一下初学Tarjan算法的领悟( QwQ) Tarjan算法 是图论的非常经典的算法 可以用来寻找有向图中的强连通分量 与此同时也可以通过寻找图中的强连通分量来进行缩点 首先给出强连通分量的 ...
- tarjan算法-解决有向图中求强连通分量的利器
小引 看到这个名词-tarjan,大家首先想到的肯定是又是一个以外国人名字命名的算法.说实话真的是很佩服那些算法大牛们,佩服得简直是五体投地啊.今天就遇到一道与求解有向图中强连通分量的问题,我的思路就 ...
- Kosaraju算法解析: 求解图的强连通分量
Kosaraju算法解析: 求解图的强连通分量 欢迎探讨,如有错误敬请指正 如需转载,请注明出处 http://www.cnblogs.com/nullzx/ 1. 定义 连通分量:在无向图中,即为连 ...
- poj2186Popular Cows(Kosaraju算法--有向图的强连通分量的分解)
/* 题目大意:有N个cows, M个关系 a->b 表示 a认为b popular:如果还有b->c, 那么就会有a->c 问最终有多少个cows被其他所有cows认为是popul ...
- 有向图的强连通分量的求解算法Tarjan
Tarjan算法 Tarjan算法是基于dfs算法,每一个强连通分量为搜索树中的一颗子树.搜索时,把当前搜索树中的未处理的结点加入一个栈中,回溯时可以判断栈顶到栈中的结点是不是在同一个强连通分量中.当 ...
- 『Tarjan算法 有向图的强连通分量』
有向图的强连通分量 定义:在有向图\(G\)中,如果两个顶点\(v_i,v_j\)间\((v_i>v_j)\)有一条从\(v_i\)到\(v_j\)的有向路径,同时还有一条从\(v_j\)到\( ...
- TarJan 算法求解有向连通图强连通分量
[有向图强连通分量] 在有向图G中,如果两个 顶点间至少存在一条路径,称两个顶点强连通(strongly connected).如果有向图G的每两个顶点都强连通,称G是一个强连通图.非强连通图有向图的 ...
- Tarjan算法求有向图强连通分量并缩点
// Tarjan算法求有向图强连通分量并缩点 #include<iostream> #include<cstdio> #include<cstring> #inc ...
随机推荐
- EasyExcel合并行处理并优化
业务场景 由于业务需要导出如下图中订单数据和订单项信息,而一个订单对应多个订单项,所以会涉及到自定义合并行 1.简单处理项目使用的EasyExcel,经查找发现Excel种有个AbstractMerg ...
- 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)
现在使用Uni-app开发手机端APP已经变得很普遍,同一套代码就可以打包成Android App 和 iOS App,相比原生开发,可以节省客观的人力成本.那么如何使用Uni-app来开发视频聊天软 ...
- 用于敏捷开发的最佳免费 UML 工具 2022
Table of Contents hide 1 最好的在线免费 UML图工具 2 免费的 UML Visual Paradigm 在线平台 3 其他福利 4 用于正式和大规模可视化建模的 Vis ...
- docker - [01] docker入门
弱小和无知不是生存的障碍,傲慢才是. -- <三体> 一.相关链接 Docker官网:https://www.docker.com/ 文档地址:https://docs.docker.co ...
- Hive - 表相关
一.文件存储格式 Hive的文件存储格式包括:textfile.sequence.rcfile.orc.parquet textfile (简介)默认的文件格式,基于行存储.建表时不指定存储格式即为t ...
- springboot2.1.6整合activiti6.0(一)
一.pom <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3. ...
- 使用PySide6/PyQt6实现Python跨平台表格数据分页打印预览处理
我曾经在前面使用WxPython开发跨平台应用程序的时候,写了一篇<WxPython跨平台开发框架之列表数据的通用打印处理>,介绍在WxPython下实现表格数据分页打印处理的过程,在Wi ...
- Basics of using bash, and shell tools for covering several of the most common tasks
Basics of using bash, and shell tools for covering several of the most common tasks Introduction M ...
- 【Bug记录】Powershell 无法将“vue”项识别为 cmdlet、函数、脚本文件或可运行程序的名称 - PowerShell 执行策略
Powershell 无法将"vue"项识别为 cmdlet.函数.脚本文件或可运行程序的名称 造成该问题主要是 PowerShell 执行策略,不支持执行全局脚本和程序的运行. ...
- ubuntu 刷新 hosts 命令
systemd-resolved 服务 sudo systemctl restart systemd-resolved 这个命令将重启 systemd-resolved 服务,该服务负责 DNS 解析 ...