[算法鉴赏][图论]强连通分量问题---Tarjan算法

本文涉及十分多的概念,同时也有精彩的算法Tarjan,为了方便理解,本文 的一部分gif来源于其他博客,特此说明。
一,相关概念
1.联通:在无向图中,如果存在一条路径从 \(u\) 到 \(v\),那么称 \(u,v\) 是联通的
2.联通图:如果在无向图中选取任意一对顶点都是联通的,那么称这个图是连通图
3.联通分量:如果一个无向图不是联通的,但是其中的一个极大子图是联通的,那么就称那个极大子图是联通分量(极大指节点个数尽可能多)
4.强连通分量(Strongly Connected Components,SCC):在有向图中,一个联通分量中的任意两点都联通,也称为css
5.弱联通分量:在有向图中,忽略边方向的情况下是连通的
二,Tarjan算法
1.dfs生成树
由于tarjan算法是基于搜索树展开的,所以先来了解dfs生成树
当我们在一个有向图中,对每一个未访问的节点进行dfs操作,发现新点时,就加入生成树,最终新生成的树,称为dfs生成树,并且里面会包含所有原图中的点
由于边具有方向性,以及dfs的搜索特性,会出现许许多多有奇妙性质的边,大致可以分为四类:
树边(tree edge):示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边.所有相邻的树边组成 DFS 生成树.
反祖边(back edge):也称回边,示意图中以红色边表示(即 \(7 \rightarrow 1\))指在搜索过程中,从某个结点指向其祖先结点的非树边.
前向边(forward edge):示意图中以绿色边表示(即 \(3 \rightarrow 6\))指在搜索过程中,从某个结点指向其子树中后代结点的非树边.
横叉边(cross edge):示意图中以蓝色边表示(即 \(9 \rightarrow 7\))指在搜索过程中,从某个结点指向非祖先、非后代且已访问的结点的边,即不属于上述三类的边.
2.Tarjan实现
Tarjan算法是基于对图深度优先搜索的算法,每个强连通分量为搜索树中的一棵子树。搜索时,把当前搜索树中未处理的节点加入一个堆栈,回溯时可以判断栈顶到栈中的节点是否为一个强连通分量。
Tarjan在dfs中维护了以下信息:
\(dfn_i\):dfs到 \(i\) 第一次的次序,也被称为时间戳
\(low_i\):为 \(i\) 或 \(i\) 的子树能够在栈中追溯的最早的栈中节点的次序号(即最小的 \(dfn\) 值)(实际操作中 \(low_i\) 不一定最小,但不会影响程序的最终结果)(另外一种关于 \(low_u\) 的表述:记录该点所在的强连通子图所在搜索子树的根节点的 \(dfn\) 值)
一个栈 \(st\):当前搜索路径上且尚未归属SCC的顶点(用处在下方会说)
其中也有以下性质:
1.一个结点的子树内结点的 \(dfn\) 都大于该结点的 \(dfn\).(因为dfs一定先到根)
2.从根开始的一条路径上的 \(dfn\) 严格递增,\(low\) 严格非降.(追溯到的节点一定已经访问过,所以 \(low\) 一定会比现在的小)
当通过 \(u\) 搜索到一个新的节点 \(v\) 的时候可以有多种情况:
1、结点 \(u\) 通过树边到达结点 \(v\)
\(low_u = \min ( low_u, low_v)\)
2、结点 \(u\) 通过反祖边到达结点 \(v\) ,或者通过横叉边到达结点 \(v\) 并且满足 \(low\) 定义中 \(v\) 的性质
\(low_u = \min ( low_u, low_v)\)
认真思考,容易发现节点 \(u\) 是某个强连通分量等价于 \(dfn_u = low_u\);
所以在发现 \(dfn_u = low_u\) 时,我们只需要把栈中尚未归属强连通分量的顶点归属至 \(u\),当栈中的栈顶元素 \(k\) 不满足 \(low_k = dfn_u\) 时,说明栈中所以属于以 \(u\) 为根的强连通分量已经全部找到
所以tarjan的代码如下:
vector<int>pic[maxn];
int dfn[maxn],low[maxn],css[maxn];
stack<int>st;
bool vst[maxn];
void tarjan(int p)
{
dfn[p]=low[p]=++cnt;
st.push(p);vst[p]=1;
for(auto i:pic[p]){
if(dfn[i]==0){
tarjan(i);
low[p]=min(low[i],low[p]);
}
else if(dfn[i]!=0&&vst[i]){
low[p]=min(low[p],dfn[i]);
}
}
if(dfn[p]==low[p])
{
w[p]=0;
while(1)
{
int t=st.top();
st.pop();
vst[t]=0;
sccn[t]=p;
if(t==p)break;
}
}
}
附上一个gif(来自csxx601cjy 十分感谢!)

这张动图演示了 Tarjan 算法的流程,其中黑色数字是结点编号(为了方便,dfn 和点权也是同样的数字),红色数字是 low,被彩色笔圈起来表示出栈了,不同颜色代表不同强连通分量。这个例子中,一共有三个强连通分量
3.例题与鉴赏
P3387 【模板】缩点 / 强连通分量
题意十分简明,尤为需要注意的是重复经过的点,权值只计算一次。
想想什么时候会出现重复经过一个点(注意是有向图),就是强连通分量中对吧
所以我们要用到一个叫做缩点的技巧:简单来说,就是把一个强连通分量当作一个点来看,运用拓扑排序(因为每一个强连通分量都用有向边连接)建图,我们只要求新图的最大点权之和
除了要维护tarjan相关变量以外,需要多维护一个邻接表用来建缩点后的图,一个数组 \(w\) 来维护一个强连通分量缩点后的权值,一个 \(dp\) 数组计算最大的点权之和
转移式也很好想:对于 \(u \rightarrow v\) 的边,\(dp_v = \max(dp_v, dp_u + w_v)\)
#include<bits/stdc++.h>
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define dep(i,a,b) for(int i=(a);i>=(b);i--)
#define ll long long
using namespace std;
const int maxn=1e5+7;
const int inf=0x3f3f3f3f;
int n,m,cnt=0,in[maxn];
vector<int>pic[maxn],pic2[maxn];
ll a[maxn],u[maxn],v[maxn],w[maxn];
int dfn[maxn],low[maxn];
ll dp[maxn],sccn[maxn];
stack<int>st;
bool vst[maxn];
void tarjan(int p)
{
dfn[p]=low[p]=++cnt;
st.push(p);vst[p]=1;
for(auto i:pic[p]){
if(dfn[i]==0){
tarjan(i);
low[p]=min(low[i],low[p]);
}
else if(dfn[i]!=0&&vst[i]){
low[p]=min(low[p],dfn[i]);
}
}
if(dfn[p]==low[p])
{
w[p]=0;
while(1&&!st.empty())
{
int t=st.top();
st.pop();
vst[t]=0;
sccn[t]=p;
w[p]+=a[t];
if(t==p)break;
}
}
}
void topo(){
queue<int>q;
rep(i,1,n) dp[i] = -inf;
rep(i,1,n){
if(i==sccn[i]&&in[i]==0){
q.push(i);
dp[i]=w[i];
}
}
while(!q.empty())
{
int s=q.front();q.pop();
for(auto i:pic2[s]){
dp[i]=max(dp[i],dp[s]+w[i]);
if(--in[i]==0)q.push(i);
}
}
}
int main()
{
cin>>n>>m;
rep(i,1,n)cin>>a[i];
rep(i,1,m)
{
cin>>u[i]>>v[i];
pic[u[i]].push_back(v[i]);
}
rep(i,1,n)if(dfn[i]==0)tarjan(i);
rep(i,1,m)
{
int x=sccn[u[i]],y=sccn[v[i]];
if(x!=y){
pic2[x].push_back(y);
in[y]++;
}
}
topo();
ll ans=-inf;
rep(i,1,n)ans=max(ans,dp[i]);
cout<<ans;
return 0;
}

4.代码鉴赏
待完成2026/8/15日前
2026年8月13日

浙公网安备 33010602011771号