Tarjan
开始我最爱的tarjan吧。
说一句,Tarjan 最难的不是算法学习,而是如何使用。
有向图的强连通分量
有向图的强连通分量,是指在有向图的一块地方,在这块地方里面,每个点都能互相到达,这就叫做一个强连通分量
定义
这里是OI wiki上的定义
强连通的定义是:有向图 G 强连通是指,G 中任意两个 结点连通
强连通分量(Strongly Connected Components,SCC)的定义是:极大的强连通子图。
连通分量:就是一个可以互相到达的图,相当于一个连通块。
极大:表示一个连通分量可以达到的最大。
注意一个点也算一个连通分量。
算法推导
好了回到正题
这里详细补一下tarjan算法,因为好久没用过了(得4个月了吧),也方便我以后看。
时间复杂度O(n + m) (点数加边数)
在一个有向图里面,我们定义有4种边,(这里为了方便,就套用wiki上的图了)
- 树边,就是里面黑色的边
- 回边,即回到祖先节点的边(这个点的祖先节点),红色
- 横叉边,即连接到另一个子树中,蓝色(不是这个点的祖先节点)
- 前向边,回到之前已经搜过的子节点,绿色(实际上就是特殊的树边)
更准确的在OI wiki上
我们按照dfs序去枚举每个点,并给每个点,一个dfs序数,设当前为u点。
我们记录两个值,dfn[u], low[u]
即u点的dfs序和能到达的最小dfs序
在定义两个数组stk[u]
和in_stk[u]
,分别存储,当前可能的强连通分量中的点,和这个点是否在当前强连通分量里面(bool)
首先对于一个点u,如果low[u] == dfn[u]
说明以u为根节点的树,形成一个强连通分量,且u是这个树里面第一个被枚举到的点即dfn[u]
最小。
证明
反证法,如果有一个点k,不在u的树内,但和u的树形成强连通分量,根据我们的判断,dfn[k]
一定大于dfn[u]
才能形成一个强连通分量,而u的树内一定有一点和k有边相连,而此边只能为回边或者横叉边,那么dfn[k]
一定小于dfn[u]
,这就矛盾了。
具体算法运行
还是那么说感性理解
具体过程为,我们任取一个点为 low[u] = min(low[u], low[k])
。
如果这个点有dfn,说明之前走过了,那我们我们查看它是否在当前SCC(强连通分量)里面,如果不在即in_stk == false
说明这个点是之前的SCC中的。相反如果它在这个SCC里面,而我们之前走过,则说明这是回边或者横叉边的情况,而回边可以构成一个强连通分量,我们更新这个low[u] = min(low[u], dfn[k])
这里用low[k]或者dfn[k]
都一样,因为是要判断最小可以到达的点,而k被搜过,它的low也一定会在总的low里面,而还要更新可以防止这个点成为low[u] == dfn[u]
的情况把这个点当成了一个强连通分量,从而导致代码的错误。
做完上面的工作,就判断dfn[u] == low[u]
若成功则说明,
剩下的就是记录这个SCC的信息。
最后出栈(别忘了in_stk也要改变),就可以完成这个SCC的操作了。
看看代码吧
代码
int dfn[N], low[N], scc_cnt, id[N], sizes[N], timestamp; // 当前点时间戳, 当前点可到达最小时间戳, SCC数, 每个点对应的SCC, 每个SCC内的点数, 时间戳 void tarjan(int u) { dfn[u] = low[u] = ++ timestamp; stk[ ++ top] = u, in_stk[u] = true; for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; if (!dfn[j]) { tarjan(j); low[u] = min(low[u], low[j]); } else if (in_stk[j]) low[u] = min(low[u], dfn[j]); } if (low[u] == dfn[u]) { int y; scc_cnt ++ ; do { 记录强连通分量的信息 y = stk[top -- ]; // 当前点 in_stk[y] = false; } while (y != u); // 用do-while是为了保证把u也弹出去 } }
另外的,因为是按 dfs 序枚举的,而 dfs 序具有拓扑性质,有向无环,所有 scc_cnt 可以直接拓扑,不用再排序一次(注意是逆序的,可以自己想想),总结一下,逆序scc_cnt具有拓扑性质。
模版题:[[AcWing 1174. 受欢迎的牛]]
关于做题的一些技巧
对一个有向图进行 tarjan,相当于缩点,即把回路缩成一个点,而对缩点后的图,我们一般要记录几个信息。
id[y]
// 节点y对于的 scc 编号,方便从点找到 scc 从而把点的信息引到 scc 上去
scc_cnt
// 多少个强连通分量
sizes[scc_cnt]
// 每个强连通分量内节点的数量
一般来说上面三个是必须有的
对于缩点后的图可以新开一个hr[]
数组来存储头结点。
对应的(加边)add函数可带上int h[]
, 来确定是哪一个图
void add(int h[], int a, int b) { e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ; }
而对于有的问题需要记录每个SCC的出度和入度即dout[]
, din[]
对于枚举SCC的每一条边我们可以,直接枚举原图的边,用SCC的编号即可
for (int u = 1; u <= n; u ++ ) { for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; if (id[j] != id[u]) { 对每条边进行操作 } } }
有的时候我们需要建立缩点后的图,如果只想每个SCC 间只有一条边的话,可以用 hash 来判断重边,具体见下
unordered_set<LL> s; for (int u = 1; u <= n; u ++ ) { for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; LL hashs = (LL)100000000ll * id[u] + id[j]; // 保证只有一条边 if (id[j] != id[u] && !s.count(hashs)) { s.insert(hashs); add(hr, id[u], id[j]); dout[id[u]] ++ ; din[id[j]] ++ ; } } }
这里贴一个例题1175. 最大半连通子图 - AcWing题库
无向图的双连通分量
分为两种一种是边的双连通分量即:E-DCC,另一种是点的双连通分量即:V-DCC。
双连通分量,如果在一个无向图里,去掉某个点就可以形成两个或多个不能互相到达的连通块我们称这个点为割点,同理如果是去掉的某个边,我们就成这个边为桥,而这些形成的不能互相到达的连通块,我们叫做(点的/边的)双连通分量。
双连通分量的另一种说法就是不包含给割点/桥的极大连通分量。
给两个个经典的图片(微软自带的编辑太糊了)
边的双连通分量
在这里先提个醒,我们知道,强连通分量的 tarjan 是可以缩点的,但是,一般来说,双连通分量是没法缩什么的,不要搞混了,我们只是要桥或者割点的信息。当然,想重新建图,比如去掉割点或者割边的,还是可以的,就是不要和强连通分量搞混了!它的运行模式和有向图的 tarjan 很像。
关键的信息——桥和双连通分量。
中心思想
这里也有时间戳的概念,几个数组的意义上面的一样。
即有
怎么找桥?
如图,如果
如何找双连通分量?
第一个办法,可以把桥去掉,实际上就是标记上,不走这条边不就是在一个双连通分量里嘛。然后重新扫一遍图,一个连通块内的就是一个双连通分量。
第二个,就是利用
这里证明一下为何此时
如果有一个点
第一种很好,但第二种更好,且稳定,所以一般用第二种。
具体操作流程
- 从进行 tarjan,此点为 u,来边为 from
- 赋值
时间戳,把 u 放进栈 中 - 枚举每个子节点,如果没有时间戳,则进行 tarjan,tarjan 完成后,更新
;如何有时间戳,判断是否是父边,如果不是就可以更新 ,否则不更新(更新会造成错误判断双连通分量)。 - 像 SCC 那样统计双连通分量。
- 重复
到
为什么判断父边,而不是判断父节点,如果两个点之间有多条路径,我当然可以走上去,但如果判断父节点的话,就走不上去了(更新
代码
注意啊,在枚举 tarjan 的时候和 SCC 类似,最好枚举每个点,不重不漏地进行。
值得注意的是,下面的判断 dfn[j]
存在时,只能用
void tarjan(int u, int from) { dfn[u] = low[u] = ++ timestamp; stk[ ++ top] = u; for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; if (!dfn[j]) { tarjan(j, i); low[u] = min(low[u], low[j]); if (dfn[u] < low[j]) is_brdige[i] = is_brdige[i ^ 1] = true; } else if (i != (from ^ 1)) // ^ 1 可以找到它的反边 因为输入边时是直接输入一对正反边,如0,1是一对边 // 0 ^ 1 = 1, 1 ^ 1 = 0, 可以直接找到他的反边 low[u] = min(low[u], dfn[j]); } if (dfn[u] == low[u]) { int y; dcc_cnt ++ ; do { y = stk[top -- ]; id[y] = dcc_cnt; } while (y != u); } }
点的双连通分量
这是这几个里面最难的一个。
尽量通俗地写出来,证明可能有不足之处。
中心思想
在点的双连通分量里面最头疼的就是根节点的处理了。在这几个算法里面,都涉及
求割点
如果一个点是根节点,那么它至少得有两个子树才能说明他是一个割点,因为它上面没有父节点了。而对于非根节点,只要它的子节点,能到达的最低点即
求双连通分量
求割点还算比较简单,但是双连通分量就有点难了,和上面几个算法一样,都是利用栈
中心思想:先判断子节点 j 能否到达,当前节点 u 的上方,即
在上面有很多问题,需要证明,首先,如何保证求出的双连通分量正确,这里感性一下吧,暂时没法很清楚的写出。只要想着,下面的双连通分量处理完了,这次剩下的就肯定是这次的,不然留不到现在。
代码
值得注意的是,下面的判断 dfn[j]
存在时,只能用
void tarjan(int u) { dfn[u] = low[u] = ++ timestamp; stk[ ++ top] = u; if (h[u] == -1 && root == u) { dcc_cnt ++ ; dcc[dcc_cnt].push_back(u); return; } int cnt = 0; for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; if (!dfn[j]) { tarjan(j); low[u] = min(low[u], low[j]); if (dfn[u] <= low[j]) // 判断 { cnt ++ ; // 子树数量 if (u != root || cnt > 1) cut[u] = true; int y; dcc_cnt ++ ; do { y = stk[top -- ]; dcc[dcc_cnt].push_back(y); } while (y != j); dcc[dcc_cnt].push_back(u); // 最后存点别忘了u } } else low[u] = min(low[u], dfn[j]); } if ((u != root && cnt >= 1) || cnt > 1) is[u] = true; // 说明是割点,在上面函数里面写也一样 }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 零经验选手,Compose 一天开发一款小游戏!
· 因为Apifox不支持离线,我果断选择了Apipost!
· 通过 API 将Deepseek响应流式内容输出到前端