专栏文章
浅谈 Kosaraju
算法·理论参与者 4已保存评论 5
文章操作
快速查看文章及其快照的属性,并进行相关操作。
- 当前评论
- 5 条
- 当前快照
- 1 份
- 快照标识符
- @mi4d7htg
- 此快照首次捕获于
- 2025/11/18 17:22 3 个月前
- 此快照最后确认于
- 2025/12/01 22:54 3 个月前
0x00 前言
声明:本文中文字,图片都是作者本人亲自编辑。如使用请经过作者的允许。
图论是 OI 的一大板块,其中有一个重要算法 Tarjan。或许你第一次学习 Tarjan 就是求强连通分量,也或许你还不会求强连通分量,本文将介绍另一种较冷门算法——Kosaraju 来求强连通分量。
0x01 强连通分量&缩点
在将如何求强连通分量之前,我们先介绍一下强连通分量。
强连通的定义是:有向图 强连通是指, 中任意两个结点连通。强连通分量(Strongly Connected Components,SCC)的定义是:极大的强连通子图。
这是 oi-wiki 给出的定义,似乎比较晦涩。关于强连通分量,我的理解就是一个点的集合,集合内的点满足互相可达,加入任何一个其他点都将不再满足。
强连通分量有一个很重要的性质:把有向图中所有强连通分量缩成一个顶点,就得到了一个有向无环图(Directed Acyclic Graph,DAG),而这个过程被称为 缩点。

如图,每个红线框中的点就是一个强连通分量,而缩点后变成了一个有向无环图。这里很好理解,如果有环在环上转一圈就双向可达了,那就是同一个强连通分量了。
正因为有这个性质以及有向无环图许多优秀的性质,很多时候只要转换成 DAG 题目就很简单了,于是在很多题目中我们就有了求强连通分量的必要性。
0x02 算法原理
Kosaraju 算法:也称为 Kosaraju-Sharir 算法,最早在 1978 年由 S. Rao Kosaraju 在一篇未发表的论文上提出,但 Micha Sharir 最早发表了它。
首先有这样一个基本事实:一个有向图 ,把 的所有的边反向,建立反图 ,反图 不会改变原图 的强连通性。也就是说,原来是 SCC 的子图,反图中仍是 SCC;原来不是 SCC 的子图,反图中仍不是 SCC。
这个基本事实很好理解。 强连通等价与存在路径 与路径 ,那反图中分别变成路径 与 ,仍然强连通。反之,若原图不存在 ,反图就不存在 ,不强连通,不存在 同理。
基于此,我们可以建立正图与反图,分别使用 dfs 遍历一次:
-
第一次遍历正图,每次任选一点开始,直到所有点被遍历恰好一次。在这个过程中求出后序遍历。这里的后序遍历可以理解为将所有节点按离开递归栈的时间排序。
-
第二次遍历反图,每次从仍未遍历的点中选择后序遍历中最靠后的点开始,直到所有点被遍历恰好一次。在这次遍历中,由同一个点作为起点遍历到的所有点组成一个强连通分量。(即按逆后序遍历。)
不难发现这个做法的时间复杂度是线性的。
0x03 模拟过程
仍以上图作例。
第一次遍历我们从 开始。
入栈能到 , 入栈能到 , 入栈能到 , 能到 , 入栈能到 , 入栈能到 , 入栈能到 , 入栈能到 。 入栈无处可去,出栈, 出栈, 出栈。
能到 , 入栈能到 , 入栈能到 , 入栈能到 。 入栈无处可去,出栈。
所有点已经被遍历一遍,以入栈顺序倒序依次出栈。
得到后序遍历为:。
入栈能到 , 入栈能到 , 入栈能到 , 能到 , 入栈能到 , 入栈能到 , 入栈能到 , 入栈能到 。 入栈无处可去,出栈, 出栈, 出栈。
能到 , 入栈能到 , 入栈能到 , 入栈能到 。 入栈无处可去,出栈。
所有点已经被遍历一遍,以入栈顺序倒序依次出栈。
得到后序遍历为:。
第二次遍历,需要遍历反图,反图如图。

按上文说的,后序遍历最后一个是 , 入栈无处可去,出栈,第一个 SCC 为 。
此时最后一个是 ,一样无处可去,第二个 SCC 为 。
能到 , 能到 。第三个 SCC 为 。
能到 ,依次遍历 ,这四个点组成第四个 SCC。
同理,还有三个 SCC 分别为 ,,。
此时最后一个是 ,一样无处可去,第二个 SCC 为 。
能到 , 能到 。第三个 SCC 为 。
能到 ,依次遍历 ,这四个点组成第四个 SCC。
同理,还有三个 SCC 分别为 ,,。
结果正确。你也可以试试其他遍历顺序,来验证这个算法的正确性。
0x04 感性理解
猜你想问:为什么这样两遍 dfs 就能找出所有强连通分量了?(让你问你就问!)
我们已经获得了一个后序遍历,我们从最后面的一个元素 开始遍历,到达的每个节点 都满足后序遍历在 之前且 中存在 的路径。
直觉上,后序遍历中,原图中能走到很多节点的入栈较靠前的节点排在前面,在最后的点是能去到的点很少的点。那么很少的点 都能到 了,我们可以认为 可以到 。那么也就能大致理解为什么 和 在同一个 SCC 了。
事实上这不是 Kosaraju 正确的真正原因,主要是在于以逆后序遍历顺序遍历反图的巧妙性。上述只是本人的感性理解方式,大佬们可以当看个乐子。
0x05 严谨证明
其实能理解会写就够了,处于完整考虑还是给出证明。
观察得到:在 上进行 dfs 得到的后序遍历中,对于任意两个节点 和 ,如果存在从 到 的路径,那么 的出栈时间晚于 的出栈时间。换句话说, 会在后序序列中出现在 的前面。
考虑后序遍历中的最后一个元素 ,在 中, 是出栈时间最晚的节点,假设 属于强连通分量 ,在 中, 必须是源分量(没有入边的分量)。因为如果存在其他分量 指向 ,那么 中必有节点 存在到 的路径,而 的出栈时间应晚于 ,与 是出栈时间最晚的节点,矛盾。在反图 中,源分量变成汇分量(没有出边的分量),因此从 开始在 上 dfs,只能访问到 内的节点。
归纳一下:假设前 次 dfs 已经正确找出了 个强连通分量,从图中移除这 个分量后,考虑剩余图中逆后序最靠前的未访问节点 。
设 属于强连通分量 ,在剩余子图中, 是源分量。因为如果存在剩余的其他分量 指向 ,那么 中必有节点 存在到 的路径, 的出栈时间应晚于 ,与 是剩余图中逆后序最靠前的节点矛盾。在反图 的剩余部分中, 变成汇分量,因此从 开始在 的剩余部分上 dfs,只能访问到 内的节点。
0x06 代码实现
这是最轻松的部分,个人喜欢 Kosaraju 的一大原因就是代码十分简单!只需照着上述思路模拟即可,可以说没有代码难度。下面给出本人封装后的丑陋代码,不喜轻喷。
CPPnamespace Kosaraju{
int n,c[N],tot;
bool vis[N];
stack<int>st;
vector<int>e[N],_e[N],g[N];
void dfs1(int u){
vis[u]=1;
for(int v:e[u])if(!vis[v])dfs1(v);
st.push(u);
}
void dfs2(int u,int o){
c[u]=o;
//此处处理点权(将u的信息合并到o)
for(int v:_e[u])
if(!c[v])dfs2(v,o);
}
void Rebuild(){
for(int u=1;u<=n;u++)for(int v:e[u])
if(c[u]^c[v])g[c[u]].push_back(c[v]);
}
void Kos(){
for(int u=1;u<=n;u++)for(int v:e[u])
_e[v].push_back(u);
for(int i=1;i<=n;i++)
if(!vis[i])dfs1(i);
while(!st.empty()){
if(!c[st.top()])dfs2(st.top(),++tot);
st.pop();
}
Rebuild();
}
}
也可以使用一个
map 或者 bitset 记录边有没有加过避免重边。Kosaraju 算法时间复杂度 ,与 Tarjan 相同,稠密图等价于 。
0x07 优化
一般情况下,Tarjan 比 Kosaraju 有更小的常数,泛用性也更强。但稠密图可以轻松地卡掉 Tarjan,原因是 Kosaraju 可以使用
bitset 优化。上文刚说完稠密图中 Kosaraju 和 Tarjan 的复杂度达到 ,但 Kosaraju 可以优化为 。你只需要把邻接表改成邻接矩阵即可,
CPP_Find_first 函数可以直接找下一个 。namespace Kosaraju{
int n,c[N],tot;
stack<int>st;
vector<int>G[N],g[N];
bitset<N>vis,e[N],_e[N];
#define nxt _Find_first
void dfs1(int u){
vis[u]=1;
bitset<N>x=e[u]&(~vis);
for(int v=x.nxt();v<=n;v=x.nxt(v))
dfs1(v);
st.push(u);
}
void dfs2(int u,int o){
c[u]=o,vis[u]=1;
bitset<N>x=_e[u]&(~vis);
for(int v=x.nxt();v<=n;v=x.nxt(v))
dfs2(v,o);
}
void Rebuild(){
for(int u=1;u<=n;u++)
for(int v=e[u].nxt();v<=n;v=e[u].nxt(v))
if(c[u]!=c[v])g[c[u]].push_back(c[v]);
}
void Kos(){
for(int u=1;u<=n;u++)
for(int v:G[u])
e[u][v]=_e[v][u]=1;
for(int i=1;i<=n;i++)
if(!vis[i])dfs1(i);
vis.reset();
while(!st.empty()){
if(!c[st.top()])dfs2(st.top(),++tot);
st.pop();
}
Rebuild();
}
}
同样,你也可以再开一个
bitset 来保证生成的 DAG 中没用重边。0x08 2-sat
既然 Kosaraju 可以求 SCC,那么显然,我们也可以用他来解决经典的 2-sat 问题。那么在解决之前,我们先来看看什么叫 2-sat。
你可以先阅读 2-sat 模板题 的题面。
我们发现题目给出的限制是 的形式,那么 的情况不合法,故有 则 , 则 。这种一个条件推出另一个条件的形式,可以抽象为一条有向边。我们将每个命题于其否定拆开(两个命题互为矛盾命题),于是就可以建出一个有向图。
这时我们发现,如果两个点同属于一个 SCC,则这两个点对应的命题互为充要条件,即一个 SCC 具有相同的真伪性。显然存在一组矛盾命题在同一个 SCC 内与此问题无解是充要条件。
对应在我们的实现中就是:将命题抽象成点( 表示第 个命题, 表示其否定),将限制抽象为有向边,缩点后,无解即 。
接下来的问题是,有解的情况下,我们该如何构造出一组合法的答案?事实上我们只需要按逆拓扑序构造,Kosaraju 算法保证了 SCC 的编号顺序是拓扑序的逆序,所以对于 和 取 更小的使其为真即可。
不难证明这是正确的。首先如果存在边 ,根据建图方式必然有 。假设存在不满足的限制 ,即 和 都为假,有 和 ;而由于有向边的存在又有 和 。二者显然是矛盾的,所以这种构造方式不会产生冲突。
在求解 2-sat 问题时,个人认为 Kosaraju 对比 Tarjan 具有显著的优势。一方面是过程直接保证逆拓扑序,另一方面就是代码实现没有任何细节。
0x09 缺陷
虽然上文提及了 Kosaraju 的诸多优点,但也不得不承认,更大的常数和更窄的使用范围,使得实用性是远不如 Tarjan 的。面对割点,桥,点双,边双之类的经典问题 Kosaraju 就没有一战之力了。
0x0A 总结
我说完了。
相关推荐
评论
共 5 条评论,欢迎与作者交流。
正在加载评论...