浅谈 Kosaraju

· · 算法·理论

0x00 前言

声明:本文中文字,图片都是作者本人亲自编辑。如使用请经过作者的允许。

图论是 OI 的一大板块,其中有一个重要算法 Tarjan。或许你第一次学习 Tarjan 就是求强连通分量,也或许你还不会求强连通分量,本文将介绍另一种较冷门算法——Kosaraju 来求强连通分量。

0x01 强连通分量&缩点

在将如何求强连通分量之前,我们先介绍一下强连通分量。

强连通的定义是:有向图 G 强连通是指,G 中任意两个结点连通。

强连通分量(Strongly Connected Components,SCC)的定义是:极大的强连通子图。

这是 oi-wiki 给出的定义,似乎比较晦涩。关于强连通分量,我的理解就是一个点的集合,集合内的点满足互相可达,加入任何一个其他点都将不再满足。

强连通分量有一个很重要的性质:把有向图中所有强连通分量缩成一个顶点,就得到了一个有向无环图(Directed Acyclic Graph,DAG),而这个过程被称为 缩点

如图,每个红线框中的点就是一个强连通分量,而缩点后变成了一个有向无环图。这里很好理解,如果有环在环上转一圈就双向可达了,那就是同一个强连通分量了。

正因为有这个性质以及有向无环图许多优秀的性质,很多时候只要转换成 DAG 题目就很简单了,于是在很多题目中我们就有了求强连通分量的必要性。

0x02 算法原理

Kosaraju 算法:也称为 Kosaraju-Sharir 算法,最早在 1978 年由 S. Rao Kosaraju 在一篇未发表的论文上提出,但 Micha Sharir 最早发表了它。

首先有这样一个基本事实:一个有向图 G,把 G 的所有的边反向,建立反图 G',反图 G' 不会改变原图 G 的强连通性。也就是说,原来是 SCC 的子图,反图中仍是 SCC;原来不是 SCC 的子图,反图中仍不是 SCC。

这个基本事实很好理解。u,v 强连通等价与存在路径 u\to v 与路径 v\to u,那反图中分别变成路径 v\to uu\to v,仍然强连通。反之,若原图不存在 u\to v,反图就不存在 v\to u,不强连通,不存在 v\to u 同理。

基于此,我们可以建立正图与反图,分别使用 dfs 遍历一次:

不难发现这个做法的时间复杂度是线性的。

0x03 模拟过程

仍以上图作例。

第一次遍历我们从 1 开始。

$6$ 能到 $10$,$10$ 入栈能到 $11$,$11$ 入栈能到 $12$,$12$ 入栈能到 $13$。$13$ 入栈无处可去,出栈。 所有点已经被遍历一遍,以入栈顺序倒序依次出栈。 得到后序遍历为:$9,8,7,13,12,11,10,6,5,4,3,2,1$。 第二次遍历,需要遍历反图,反图如图。 ![反图](https://cdn.luogu.com.cn/upload/image_hosting/7vzyy9eb.png?x-oss-process=image/resize,m_lfit,h_510,w_675) 按上文说的,后序遍历最后一个是 $1$,$1$ 入栈无处可去,出栈,第一个 SCC 为 $\{1\}$。 此时最后一个是 $2$,一样无处可去,第二个 SCC 为 $\{2\}$。 $3$ 能到 $5$,$5$ 能到 $4$。第三个 SCC 为 $\{3,4,5\}$。 $6$ 能到 $9$,依次遍历 $6\to9\to8\to7$,这四个点组成第四个 SCC。 同理,还有三个 SCC 分别为 $\{10\}$,$\{11,12\}$,$\{13\}$。 结果正确。你也可以试试其他遍历顺序,来验证这个算法的正确性。 ## 0x04 感性理解 猜你想问:为什么这样两遍 dfs 就能找出所有强连通分量了?~~(让你问你就问!)~~ 我们已经获得了一个后序遍历,我们从最后面的一个元素 $u$ 开始遍历,到达的每个节点 $v$ 都满足后序遍历在 $u$ 之前且 $G$ 中存在 $v\to u$ 的路径。 直觉上,后序遍历中,原图中能走到很多节点的入栈较靠前的节点排在前面,在最后的点是能去到的点很少的点。那么很少的点 $u$ 都能到 $v$ 了,我们可以认为 $v$ 可以到 $u$。那么也就能大致理解为什么 $u$ 和 $v$ 在同一个 SCC 了。 事实上这不是 Kosaraju 正确的真正原因,主要是在于以逆后序遍历顺序遍历反图的巧妙性。上述只是本人的感性理解方式,大佬们可以当看个乐子。 ## 0x05 严谨证明 其实能理解会写就够了,处于完整考虑还是给出证明。 观察得到:在 $G$ 上进行 dfs 得到的后序遍历中,对于任意两个节点 $u$ 和 $v$,如果存在从 $u$ 到 $v$ 的路径,那么 $u$ 的出栈时间晚于 $v$ 的出栈时间。换句话说,$v$ 会在后序序列中出现在 $u$ 的前面。 考虑后序遍历中的最后一个元素 $v_1$,在 $G$ 中,$v_1$ 是出栈时间最晚的节点,假设 $v_1$ 属于强连通分量 $C_1$,在 $G^{SCC}$ 中,$C_1$ 必须是源分量(没有入边的分量)。因为如果存在其他分量 $C'$ 指向 $C_1$,那么 $C'$ 中必有节点 $u$ 存在到 $v_1$ 的路径,而 $u$ 的出栈时间应晚于 $v_1$,与 $v_1$ 是出栈时间最晚的节点,矛盾。在反图 $G'$ 中,源分量变成汇分量(没有出边的分量),因此从 $v_1$ 开始在 $G'$ 上 dfs,只能访问到 $C_1$ 内的节点。 归纳一下:假设前 $k$ 次 dfs 已经正确找出了 $k$ 个强连通分量,从图中移除这 $k$ 个分量后,考虑剩余图中逆后序最靠前的未访问节点 $v_{k+1}$。 设 $v_{k+1}$ 属于强连通分量 $C_{k+1}$,在剩余子图中,$C_{k+1}$ 是源分量。因为如果存在剩余的其他分量 $C'$ 指向 $C_{k+1}$,那么 $C'$ 中必有节点 $u$ 存在到 $v_{k+1}$ 的路径,$u$ 的出栈时间应晚于 $v_{k+1}$,与 $v_{k+1}$ 是剩余图中逆后序最靠前的节点矛盾。在反图 $G'$ 的剩余部分中,$C_{k+1}$ 变成汇分量,因此从 $v_{k+1}$ 开始在 $G'$ 的剩余部分上 dfs,只能访问到 $C_{k+1}$ 内的节点。 ## 0x06 代码实现 这是最轻松的部分,个人喜欢 Kosaraju 的一大原因就是代码十分简单!只需照着上述思路模拟即可,可以说没有代码难度。下面给出本人封装后的丑陋代码,不喜轻喷。 ```cpp namespace 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 算法时间复杂度 $O(n+m)$,与 Tarjan 相同,稠密图等价于 $O(n^2)$。 ## 0x07 优化 一般情况下,Tarjan 比 Kosaraju 有更小的常数,泛用性也更强。但稠密图可以轻松地卡掉 Tarjan,原因是 Kosaraju 可以使用 `bitset` 优化。 上文刚说完稠密图中 Kosaraju 和 Tarjan 的复杂度达到 $O(n^2)$,但 Kosaraju 可以优化为 $O(\frac{n^2}{\omega})$。你只需要把邻接表改成邻接矩阵即可,`_Find_first` 函数可以直接找下一个 $1$。 ```cpp 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 模板题](https://www.luogu.com.cn/problem/P4782) 的题面。 我们发现题目给出的限制是 $p \vee q$ 的形式,那么 $\neg p \wedge \neg q$ 的情况不合法,故有 $\neg p$ 则 $q$,$\neg q$ 则 $p$。这种一个条件推出另一个条件的形式,可以抽象为一条有向边。我们将每个命题于其否定拆开(两个命题互为矛盾命题),于是就可以建出一个有向图。 这时我们发现,如果两个点同属于一个 SCC,则这两个点对应的命题互为充要条件,即一个 SCC 具有相同的真伪性。显然存在一组矛盾命题在同一个 SCC 内与此问题无解是充要条件。 对应在我们的实现中就是:将命题抽象成点($i$ 表示第 $i$ 个命题,$i+n$ 表示其否定),将限制抽象为有向边,缩点后,无解即 $\exists i,c_i=c_{i+n}$。 接下来的问题是,有解的情况下,我们该如何构造出一组合法的答案?事实上我们只需要按逆拓扑序构造,Kosaraju 算法保证了 SCC 的编号顺序是拓扑序的逆序,所以对于 $i$ 和 $i+n$ 取 $c$ 更小的使其为真即可。 不难证明这是正确的。首先如果存在边 $u+n \to v$,根据建图方式必然有 $v+n \to u$。假设存在不满足的限制 $u\vee v$,即 $u$ 和 $v$ 都为假,有 $c_{u+n}<c_u$ 和 $c_{v+n}<c_v$;而由于有向边的存在又有 $c_{v+n}>c_u$ 和 $c_{u+n}>c_v$。二者显然是矛盾的,所以这种构造方式不会产生冲突。 在做题过程中需要注意的是,你的 $i$ 和 $i+n$ 分别对应的是题目中的什么状态,以及题目的约束条件究竟应该转换成什么哪两个点之间的有向边。例如在[本题](https://www.luogu.com.cn/problem/P4782)中,我选择将 $i$ 对应 $1$,$i+n$ 对应 $0$,所以最后输出的是 $c_i>c_{i+n}$。 在求解 2-sat 问题时,个人认为 Kosaraju 对比 Tarjan 具有显著的优势。一方面是过程直接保证逆拓扑序,另一方面就是代码实现没有任何细节。 ## 0x09 缺陷 虽然上文提及了 Kosaraju 的诸多优点,但也不得不承认,更大的常数和更窄的使用范围,使得实用性是远不如 Tarjan 的。面对割点,桥,点双,边双之类的经典问题 Kosaraju 就没有一战之力了。 ## 0x0A 总结 我说完了。