☰
Tarjan算法详解:用一次DFS找出有向图所有强连通分量
2026/10/11 0:01:40 网站建设 项目流程

有向图里的“互相可达”现象,其实比你想的更常见。模块A调用模块B,模块B又回调模块A;两个微服务互为依赖;社交平台上你关注我、我关注你,这些一旦被画成一张有向图,就会出现一群节点互相之间都能走通的小团体。这群小团体在图论里有个正式名字:强连通分量,缩写就是SCC。为什么要揪出SCC?因为只要找到它,很多棘手问题会瞬间变简单:循环依赖一眼就能看出来,一团互相纠缠的逻辑可以当成一个整体处理,原本带环的图还能被压成没有环的DAG。Tarjan算法则是求SCC最常见的高效解法,它用一次DFS就能把所有SCC找完,代码短、常数小,是搞算法、搞工程、准备竞赛的人都绕不开的基础功。这篇文章我会把Tarjan从概念、原理、代码到避坑点完整梳理一遍,就算你之前只看过一点DFS,跟着推一遍也能彻底搞懂。

1. 从一道图的“互相可达”说起:强连通分量到底找什么

1.1 什么是SCC:一个极大“互达圈”的精确定义

在有向图里,如果从u能走到v,并且从v也能走到u,就说u和v强连通。注意这是有向图专属的概念:无向图只要连通就行,但“有向”意味着路径方向不能随便反向。一个强连通分量,就是满足“内部任意两点互相可达”的极大节点集合。重点在这个“极大”:不是随便找几个互相可达的点就能叫分量,而是必须把所有能互相到达的节点都装进来。

举个很容易懂的比方。假设有一场线下活动,参与者之间可以“单向认识”别人。你认识小明,小明也认识你,那你们俩就形成了一个小圈子。如果小红能通过小明认识你、你也通过小红认识她,那她也要被拉进这个圈子;只要有人能被圈子里的某条单向链拉进来,并且也能通过另一条单向链回到圈子里的任意人,那就必须吸收进来,直到再也塞不进新人。这个最终收满的圈子,才是SCC。

从代码角度看,判断“任意两点互相可达”最粗暴的方法是Floyd-Warshall或者对每个点跑BFS,复杂度高得离谱。Tarjan能在一次DFS里把这些极大圈子干净利落地切出来,这个能力就是它最大的价值。后面你会看到,Tarjan不是靠定义去“检查”可达性,而是靠DFS的遍历顺序和回边识别来自动切分,思路完全不一样。

1.2 SCC能解决的真实问题:循环依赖、社区、2-SAT

SCC不是纯粹的理论玩具,它在真实工程里的应用非常多。我做过的项目里,遇到最典型的场景就是依赖关系检测:模块A import B,B import A,这种循环依赖编译期会报错,但运行时发现的循环调用往往藏得很深。把整个调用关系建成一张有向图,跑一次SCC,所有成环的模块自动归为一组,一眼就能看出是谁在抱团。

数据库和微服务领域也有类似的场景。多个服务互相调用来调用去,一旦其中一个挂了,调用链可能会形成死锁或者雪崩。用SCC把这些“互相咬合”的服务组识别出来,就可以在发布顺序、熔断策略、超时设置上做专门处理。比如两个服务A和B互相依赖,那么发布时就不能先停A再停B,必须当成一个整体去规划,否则中间任何一个时刻请求都可能打到半个不可用的系统上。

竞赛算法里,SCC更是一块跳板:求完强连通分量后可以把每个分量缩成一个点,整张图变成DAG,拓扑排序、动态规划、最长链问题都能接踵而至。我后面会讲的2-SAT,直接把每个布尔变量的真假拆成两个节点,再通过SCC判断是否矛盾,套路一套一个准。还有社交网络里的“朋友圈”挖掘,本质上也是找出互相关注得最紧密的一坨人。总之,SCC是图论中最通用的“聚类工具”之一。

1.3 为什么优先学Tarjan:一次DFS就把活干完

求SCC的算法不止一个,常见的有Kosaraju、Tarjan和Gabow。Kosaraju思路简单:先在第一张图上跑DFS记录拓扑顺序,再在方向相反的图上按逆序跑一遍DFS,输出结果。它正确性容易理解,但需要两次DFS,还要能快速访问原图的反向图。你要是用邻接表存图,就得额外建一个逆图,内存和时间都会多一份开销。

Tarjan走的是另一条路:只做一次DFS,边搜边维护节点的时间戳和回溯值,用一个栈把尚未归属的节点串起来。它不需要逆图,常数也小,写完就是几十行。代价是第一次看会有点绕,dfn、low、栈三者互相配合,不像Kosaraju那么直观。所以我一般给身边人建议:先学Kosaraju找感觉,再啃Tarjan拿效率;如果直接就想在竞赛或者工程里高效落地,Tarjan更值得下功夫。

2. Tarjan算法的核心原理:一套可以手推的直觉

2.1 dfn和low到底记录了什么

Tarjan依赖两个核心标记:dfn和low。dfn是“深度优先搜索编号”,也可以理解成上门服务的时间戳:每个节点在被DFS第一次访问时按顺序编号,你进了这间房,就在门上刻一个递增的数字。low是“通过当前DFS子树走到的最早回退编号”:当你在房间里探索各种边时,如果发现某条边能绕回到编号更小的房间,就把low更新成那个更小的编号。

听起来抽象,但换一个思维模型就顺了。想象你在一个迷宫里探路,每进入一个新房间就给它发一个递增的门牌号,这就是dfn。迷宫里有单向密道,你站在当前房间,顺着密道看能不能回到某个已经发过牌子、人还没走完的旧房间。能回到的最早那个门牌号,就是low要记录的东西。等某个房间的low等于自己的dfn,就说明从这里往后,所有人只能在自己管辖的迷宫里折腾,通不出去,可以关门把这一批人打包了。

一个关键细节:判断能不能用旧房间更新low时,不能光看“这个点被访问过”,还要看它是不是还在当前处理栈里。如果某个旧房间已经被完整处理好并弹出,说明它属于一个已经成团的分量,跟当前分支已经没有关系了,再用它更新low会把相邻分量错误合并。

2.2 标准流程:从入栈到弹栈的完整框架

Tarjan的递归代码结构,本质上是对DFS做了一些附加操作。我先把流程铺开:对每个尚未访问的节点调用一次tarjan函数;函数内部先给当前节点u分配dfn和low,然后入栈;接着依次查看u的每个邻居v。如果v没访问过,就递归处理它,处理完回来用low[v]更新low[u];如果v访问过并且还在栈里,就用dfn[v]更新low[u];如果v已经出栈,直接忽略。等所有邻居处理完,检查low[u]是否等于dfn[u],如果相等,就不断弹栈,直到把u弹出,这些弹出来的节点一起构成一个SCC。

整个框架里,栈的作用极其关键。它专门存放“已经被访问、但还没被归类到某个SCC”的节点。为什么需要它?因为DFS在回溯时,会遇到一系列处于“半完成”状态的节点,这些节点之间可能通过回边组成环,但它们还没到结算时刻。栈把这些候选节点按访问时间压在一起,一旦某个根节点条件满足,从它往上直到栈顶的节点都能被一起弹出。

这里有初学者常常忽略的地方:递归处理完子树后,用low[v]更新low[u]只是其中一条更新路径,判断“v访问过且在栈中”时的更新同样必不可少。如果漏掉这种情况下用dfn[v]更新,等于无视了从u直接指向祖先的回边,很多环根本识别不出来。你可以在示例图里故意去掉这句,会发现问题非常隐蔽。

2.3 为什么 low[u] == dfn[u] 就是根节点

这是整个算法最值得想明白的地方。一个节点u被访问后,它的low值表示:在它自己的DFS子树内,通过各种边能追溯到的、还在栈中的最小编号。如果这个最小值比u自己的dfn还小,说明u这个分支里一定有回边通到了DFS树里更早的祖先,那u和那个祖先处在同一个强连通分量中,此刻不能把u切出去。

反过来,当u的所有邻居都处理完,low[u]依然等于dfn[u],意思就是:不管怎么绕,u的子树的回边最远也就到u本身,通不到u的任何祖先。那么当前栈中从u到栈顶的所有节点,就构成了一个封闭的“互达团体”。为什么是封闭的?因为如果他们中间有指向更早节点的回边,这个更早的编号一定小于dfn[u],low[u]就会被更新;既然没有被更新,说明往外走的路都被切断了。此时把栈顶到u的一串节点弹出,就是一个SCC,而且因为它是在DFS过程中按递归边界划分出来的,天然是极大的。

这个洞察能帮你省掉很多死记硬背。每次看到low[u] == dfn[u],脑子里的第一反应应该是:门牌号最小的一间房被关上了,整个房间组成了一个独立区域。

2.4 复杂度为什么只有O(V+E),以及更新细节的讲究

Tarjan之所以高效,是因为每个节点最多入栈出栈一次,每条边最多被检查一次。总的DFS遍历是O(V+E),栈操作是O(V),合起来还是O(V+E)。空间上需要dfn、low、scc三个数组和一个栈,都是O(V)级别。这个量级意味着它在百万节点级别的图上也完全扛得住,只要注意递归深度问题。

有个很多模板会写但不一定解释清楚的点:当邻居v已经访问过并且在栈中时,标准更新是low[u] = min(low[u], dfn[v]),而不是low[v]。理由是这样:v在栈中,意味着v是当前DFS路径上的祖先,dfn[v]是能够准确度量这个祖先层次的编号。用dfn[v]更新,语义最干净——一条回边指回编号dfn[v]的节点,我就能把low缩小到dfn[v]。有些实现用low[v]替换也常常能跑对,因为祖先的low可能本身就更小;但为了和论文原版保持一致、也为了推导时不产生歧义,建议始终写dfn[v]。

如果你看别的博客能看到low[v]的写法,不必立刻觉得别人错了,但和标准版对比时要有意识:两种写法都把“回到栈中最早祖先”这条信息传给了u,差别只在取的是祖先的dfn还是祖先的low。对绝大多数数据,这两者结果一致,但标准写法更能反映算法本意。

3. 代码实现与手工模拟:从模板到彻底跑通

3.1 一份带注释的C++模板

我直接给出一个能在竞赛和工程里改着用的模板,基于邻接表。

const int MAXN = 100005; vector<int> G[MAXN]; int dfn[MAXN], low[MAXN], sccId[MAXN]; int timer = 0, sccCnt = 0; stack<int> st; bool inStack[MAXN]; void tarjan(int u) { dfn[u] = low[u] = ++timer; st.push(u); inStack[u] = true; for (int v : G[u]) { if (!dfn[v]) { // v 还没被访问过 tarjan(v); low[u] = min(low[u], low[v]); // 用子树结果更新 } else if (inStack[v]) { // v 访问过,且还在栈中,说明是祖先 low[u] = min(low[u], dfn[v]); // 用祖先的 dfn 更新 } // 如果 v 已经出栈,说明属于别的 SCC,忽略 } if (low[u] == dfn[u]) { ++sccCnt; while (true) { int x = st.top(); st.pop(); inStack[x] = false; sccId[x] = sccCnt; if (x == u) break; } } } // main 里 for (int i = 1; i <= n; ++i) { if (!dfn[i]) tarjan(i); }

这段代码有几个位置值得停下来多看两眼。第一个是dfn[u] = low[u] = ++timer,必须放在函数最前面,保证每个节点只有一次被分配编号;第二个是遍历邻居时的三种分支,顺序不能乱;第三个是弹栈时,先弹出节点再标记sccId和inStack=false,最后判断是否到u,这个循环把从栈顶到u的所有节点一次性归到一个分量里。漏掉其中任何一步,都会造成分量残缺或者重复入栈。

有个小建议:实际写的时候可以把vector<int> G[MAXN]换成vector<vector<int>> G或者邻接表封装,都没问题。关键是把dfn、low、inStack的理解带出去,换语言只是换个壳。用Python写的话逻辑完全一致,只要把数组换成list、递归前设置好递归深度上限就行。

3.2 手工模拟一个简单图,让过程“肉眼可见”

纸上谈兵一千遍,不如手动跑一遍。我拿一张六条边的图:0→1、1→2、2→0、2→3、3→4、4→3。这个图应该有两个强连通分量:{0,1,2}是一个三节点环,{3,4}是一个两节点环。

从0开始DFS。进入0,dfn[0]=1,low[0]=1,入栈。走到1,dfn[1]=2,low[1]=2,入栈。走到2,dfn[2]=3,low[2]=3,入栈。2的邻接边有两条,第一条去0,发现0还在栈中,于是low[2]=min(3,1)=1;第二条去3,3没访问过,递归进入3,dfn[3]=4,low[3]=4,入栈。3走进4,dfn[4]=5,low[4]=5,入栈。4发现可以去3,而且3在栈中,于是low[4]=min(5,4)=4。4处理完,回到3,3用low[4]更新自己,low[3]=min(4,4)=4。此时low[3]==dfn[3]==4,命中根节点,弹栈直到3:先弹4,再弹3,SCC编号1分给{3,4}。

接着回溯到2,2因为已经访问完3这个分支,用low[3]=4更新low[2],但min(1,4)还是1。2处理完,low[2]=1不等于dfn[2]=3,不弹。回到1,low[1]=min(2,1)=1,不弹。回到0,low[0]=min(1,1)=1,low[0]==dfn[0]==1,命中,弹栈直到0:先弹2、再弹1、最后弹0,SCC编号2分给{0,1,2}。

这个例子把两种更新都覆盖了:一条回边直接指向栈中祖先,用dfn更新low;一条树边通过子树递归,用low[v]更新low。弹栈发生在low等于dfn的节点上,且每次都把一批节点整体带走。拿一张纸照着这个流程写一遍,比看十遍代码都管用。你也可以自己随便画一张带环的图,然后按这个节奏手推,很快就建立起对算法时序的直觉。

3.3 大图怎么办:非递归写法才是稳妥方案

如果图的节点数到几十万、上百万,递归调用很容易把系统栈压爆。C++在Windows下可以加#pragma comment(linker, "/STACK:102400000,102400000"),Linux下可以调ulimit -s unlimited,但这只是临时手段。更稳定的做法是把Tarjan改成非递归,手动用栈模拟系统调用栈。

思路是把每个节点包装成一个“任务帧”:记录当前节点u、当前遍历到邻接表第几个邻居、以及u作为递归返回点的状态。进栈时先做dfn赋值和入算法栈;每处理完一个邻居,根据情况更新low;等所有邻居处理完,再判断low==dfn并完成弹栈。代码会比递归版长一些,但复杂度不变,而且能处理超大图。

如果只是在学校作业或中小型数据集上用,递归版完全够。但一旦面对百万节点的真实业务图,非递归就是刚需。我自己的习惯是:小图调试用递归版,逻辑清楚;正式处理大图时写一版非递归,常备在模板库里。如果你只是学习算法,先把递归版看懂,非递归更多是工程上的“保险桥”。

4. 易错点、常见坑和验证技巧

4.1 我踩过的四个经典错误

第一,把更新目标写错,写成dfn[u] = min(dfn[u], dfn[v])。这让dfn这个“唯一时间戳”被反复修改,整个算法的编号体系直接崩溃,low和dfn的关系彻底乱套。每次写完代码,用肉眼扫一遍low[u] = min(...),确认左边是low不是dfn。

第二,弹栈循环写成只弹一次或者while (st.top() != u)但忘记先处理栈顶。正确顺序是:先拿到栈顶节点、弹出、标记inStack和sccId、再判断是不是u。有人习惯先判等再弹,最后会漏掉u本身。

第三,只从一个节点开始跑算法。主函数里如果不是for (int i=1; i<=n; ++i) if (!dfn[i]) tarjan(i);,那么第一棵DFS树之外的孤立点和分支就会被漏掉。很多新手用一个单连通图测没问题,换多分量图就出奇怪结果,十有八九是这个原因。

第四,忘记在弹栈时清除inStack[x]。这个标记很关键,如果不清,后续节点看到它还在栈里,会用它的dfn更新low,把已经结束的SCC错误牵扯回来。调试时一旦发现SCC的节点编号混乱,先检查inStack的清理逻辑。

4.2 如何验证你的SCC代码是对的

最稳妥的验证方法是对拍:写一个暴力解法,用BFS或Floyd判断任意两点是否互相可达,然后合并出所有极大强连通块,再和Tarjan的输出对比。暴力正确性一目了然,虽然慢,只在小图上跑就行。随机生成几十张小图,两边结果一致,代码基本就稳了。

再准备一组边界测试:空图、单点图、自环图、一条链、一个完整有向环、两个互不相交的环、带孤立点的图、完全有向图。特别是自环,很多人会混淆:单个带自环的节点,本身就是一个SCC;完全有向图中所有节点属于同一个SCC。把这些case跑一遍,很多隐患能提前暴露。

另外可以检查输出分量的性质:分量内任意两点互相可达,分量之间压缩后不存在环。如果发现缩点后有环,那说明某个强连通块被切碎了。这个性质检查写起来也不难,遍历每条边u→v,如果sccId[u] != sccId[v],就在缩点图上加一条边,最后对这个缩点图再排一遍拓扑或检查环。

4.3 Tarjan和Kosaraju怎么选

对比维度TarjanKosaraju
图的遍历次数1次2次
是否依赖逆图否是
空间开销O(V)栈需要原图和逆图
理解门槛中等偏高低,逻辑直观
适用场景工程、竞赛、大图教学入门、实现简单优先

我的实际建议分两层:如果是学习阶段,先写Kosaraju,它几乎不会写错,能帮你形成“SCC就是闭包”的正确直觉;如果是要处理竞赛题或者上生产,Tarjan才是更省心省内存的选择。两者结果完全一致,所以你甚至可以先用Kosaraju交叉验证Tarjan的正确性。

5. 把SCC用起来:缩点、2-SAT与更多场景

5.1 缩点变成DAG:后续处理的全新展开

把每个SCC压缩成一个点,边由原图关系继承,就得到一张有向无环图。为什么无环?因为如果压缩后还有环,环上所有缩点对应的原始节点集合其实可以合并成更大的强连通分量,这就违背了“极大”的定义。有了DAG,很多问题都能放心做:拓扑排序、最长路DP、关键路径、依赖分层。

举个例子,处理一组带约束的构建任务时,互相依赖的任务构成强连通块,压缩后每个块要么先执行,要么后执行,不会陷入循环等待。配合拓扑排序,就能给出一个无环的执行顺序。工程里的“依赖图分析器”基本就是这个逻辑。做竞赛题时,缩点也经常是第一步:先缩点,然后在DAG上跑动态规划,复杂度从原来的NP问题降成多项式问题。

5.2 用SCC解2-SAT:一个经典套路

2-SAT是判断一组布尔约束能否同时满足的问题。它的核心技巧是,把每个变量x拆成两个节点:x为真和x为假。每条约束(a∨b)转成两个蕴含边:(¬a→b)和(¬b→a),意思是如果a不成立,b必须成立;如果b不成立,a必须成立。建完图后跑SCC,如果x和¬x落在同一个强连通分量里,说明自相矛盾,无解;否则一定有可行赋值,按SCC的拓扑逆序给每个变量赋值即可。

这套东西在博弈题、调度题、逻辑判断题里出现频率很高。很多看起来毫无关系的条件判定,最后都能被拆成一堆蕴含边扔进SCC里。理解了SCC,等于拿到了2-SAT的钥匙。你可能不会天天写2-SAT,但一旦遇到,Tarjan就是那个隐藏在背后的基础工具。

5.3 其他场景:从编译器到社交网络

我前面提到的循环依赖检测、微服务调用分组、社交圈子挖掘,都只是冰山一角。在编译器中,函数调用的递归环可以通过SCC识别;在静态分析中,数据流的循环结构可以用SCC化简;在推荐系统里,强连通簇常常意味着紧密的关系群,可以直接拿来当特征。再往后学,Tarjan的思路还被推广到割点、桥、双连通分量,它们和SCC共用同一套“dfn + low”的思维框架,学会了Tarjan,等于打开了图连通性分析的整扇门。

最后说点我自己的实践体会。Tarjan这套思路看起来绕,但你一旦亲手推演一遍,会发现它其实就是“时间戳+栈+区间闭合”的组合拳。我当初第一次接触时,也是卡在“为什么弹栈到u就是分量”上很久,后来我把代码里的递归调用全部展开成手写栈之后,突然就想通了。如果你也卡在某个环节,强烈建议把示例图换成自己随便画的一张,逼着自己一步步写出dfn、low和栈的状态,这个过程的收获远大于反复背模板。以后遇到任何跟“互相可达”“闭环分组”沾边的问题,先想到跑一遍SCC,很多难题的最优解就藏在这几十行代码里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询