Tarjan 算法的应用
写在前面
近期一直在刷这方面的题,因为没法学新知识,但又想写点什么,就水篇博文吧。
Upd on 2021.6.27:修了下排版和部分错误,同时重写了下代码。
关于 Tarjan算法
发明者 Robert E.Tarjan 罗伯特·塔扬,美国计算机科学家。
塔老爷子发明过很多算法,而且大多是以他的名字命名的,所以 Tarjan算法 也分很多种。
这里主要讲 缩点,割点,割边,2-SAT 以及如何求 LCA。
引理一
什么是强连通分量?
强连通分量的定义是:极大的强连通子图。又叫 SCC。
简单来说,在一个有向图中,若所有点之间两两互相直接可达,则将这个图成为强连通分量。
求一个图中的强连通分量可以使用 Tarjan,Kosaraju 或者 Garbow 算法。
引理二
什么是双联通分量?
双联通分为点双联通与边双连通两种。
在一张连通的无向图中,对于两个点 \(u\) 和 \(v\),如果无论删去哪一条边都不能使它们不连通,我们就说 \(u\) 和 \(v\) 边双连通。
在一张连通的无向图中,对于两个点 \(u\) 和 \(v\) ,如果无论删去哪一个除自己之外的点都不能使它们不连通,我们就说 \(u\) 和 \(v\) 点双连通。
这里有两个结论:
-
边双连通具有传递性,即若 \(x\),\(y\) 边双连通, \(y\),\(z\) 边双连通,则 \(x\),\(z\) 边双连通。
-
点双连通不具有传递性。
手玩几组样例即可证明,比较显然。
有向图缩点
缩点,简单说就是把一个图中所有的强连通分量缩成一个点,使之形成一个 DAG。
缩完点后的图中每个点会有一个新的编号,同处一个强连通分量中的点编号相同。
想要完成这一操作,首先需要知道什么是 DFS 序。
一个结点 \(x\) 的 DFS 序是指深度优先搜索遍历时改结点被搜索的次序,简记为 \(dfn_x\)。
然后,再维护另一个变量 \(low_x\)。
\(low_x\) 表示以下节点的 DFS 序的最小值:以 \(x\) 为根的子树中的结点 和 从该子树通过一条不在搜索树上的边能到达的结点。
根据 DFS 的遍历原理可以发现:
-
一个结点的子树内结点的 DFS 序都大于该结点的 DFS 序;
-
从根开始的一条路径上的 DFS 序严格递增,low 值严格非降。
知道了这些,再来看 Tarjan 算法求强连通分量的具体内容。
我们一般只对还没有确定其 DFS 序的节点进行操作,操作主要包括两个部分。
第一部分
以 DFS 的形式,处理出当前点 \(x\) 的 \(dfn_x\) 和 \(low_x\)。
对当前点打一个标记表示已经遍历过,在之后的 DFS 中根据是否遍历过来进行不同处理,具体方式如下:
设当前枚举点为 \(u\),\(u\) 连出去的点记为 \(v\)。
-
\(v\) 未被访问:继续对 \(v\) 进行深度搜索。在回溯过程中,用 \(low_v\) 更新 \(low_u\)。因为存在从 \(u\) 到 \(v\) 的直接路径,所以 \(v\) 能够回溯到的已经在栈中的结点, \(u\) 也一定能够回溯到。
-
\(v\) 被访问过,已经在栈中:即已经被访问过,根据 low 值的定义(能够回溯到的最早的已经在栈中的结点),则用 \(dfn_v\) 更新 \(low_u\) 。
-
\(v\) 被访问过,已不在在栈中:说明 \(v\) 已搜索完毕,其所在连通分量已被处理,所以不用对其做操作。
这一部分代码实现如下:
low[fr]=dfn[fr]=++cnt;vis[fr]=1;
for(int i=head[fr];i;i=e[i].nxt){
int to=e[i].to;
if(!dfn[to]) tarjan(to),low[fr]=min(low[fr],low[to]);
else if(vis[to]) low[fr]=min(low[fr],dfn[to]);
}
第二部分
对于一个连通分量图,我们很容易想到,在该连通图中有且仅有一个 \(dfn_x=low_x\)。
该结点一定是在深度遍历的过程中,该连通分量中第一个被访问过的结点,因为它的 DFS 序和 low 值最小,不会被该连通分量中的其他结点所影响。
我们可以维护一个栈,存储所有枚举到的点。
在回溯的过程中,判定 \(dfn_x=low_x\) 的条件是否成立,如果成立,则从栈中取出一个点,处理它所在的强连通分量的编号以及大小,也可以处理其他的一些操作,这样直到把所有点处理完为止。
这一部分的代码实现如下:
zhan[++top]=u;
if(dfn[u]==low[u]){
++t;
int pre=zhan[top--];
vis[pre]=0;
...//相应操作
while(pre!=u){
pre=zhan[top--];
vis[pre]=0;
...//相应操作
}
}
至此,便可以处理出一个点所在的强连通分量,时间复杂度为 \(O(n+m)\)。
无向图缩点
这里说的其实是求无向图的双联通分量。
可以处理割点与桥以及双联通分量相关的一些题。
边双连通
因为是无向图,必须加两条边,而加两条边后跑 Tarjan 会很麻烦。
这里有另一个处理方法:通过 异或 来一次选中两条边。
我们知道 \(0\oplus1=1\),\(1\oplus1=0\);\(2\oplus1=3\),\(3\oplus1=2\);\(4\oplus1=5\),\(5\oplus1=4\)。
而建边的时候两条边的编号相差 \(1\),所以可以每次处理第 \(i\) 条边的时候处理第 \(i\oplus 1\) 条边,解决这个问题。
而有向图和无向图 Tarjan 的写法也差不多,low 值的更新方式和缩点的编号等都相同,只有标记的地方不一样。
代码实现如下:
void tarjan(int u){
low[u]=dfn[u]=++cnt;zhan[++top]=u;
for(int i=head[u];i;i=e[i].nxt){
if(!vis[i]){
vis[i]=vis[i^1]=1;
int to=e[i].to;
if(!dfn[to]) tarjan(to),low[u]=min(low[u],low[to]);
else low[u]=min(low[u],dfn[to]);
}
}
if(dfn[u]==low[u]){
++t;
int pre=zhan[top--];
...//相应操作
while(pre!=u){
pre=zhan[top--];
...//相应操作
}
}
}
点双联通
舍去了对边的判断,也不需要处理双向边这种问题。
代码如下:
void tarjan(int u){
zhan[++Top]=u;dfn[u]=low[u]=++cnt;
for(int i=head[u];i;i=e[i].nxt){
int to=e[i].to;
if(!dfn[to]){
tarjan(to);low[u]=min(low[u],low[to]);
if(low[to]>=dfn[u]){
++t;int pre;
do{
pre=zhan[Top--];
...//相应操作
}while(pre!=to);
...//相应操作
}
}
else low[u]=min(low[u],dfn[to]);
}
}
2-SAT
SAT 是适定性(Satisfiability)问题的简称。一般形式为 k-适定性问题,简称 k-SAT。而当 \(k>2\) 时该问题为 NP 完全的。所以我们只研究 \(k=2\) 的情况。 —— OI Wiki
个人感觉,就是一个实际应用类的知识吧。
就是指定 \(n\) 个集合,每个集合包含两个元素,给出若干个限制条件,每个条件规定不同集合中的某两个元素不能同时出现,最后问在这些条件下能否选出 \(n\) 个不在同一集合中的元素。
这个问题一般用 Tarjan 来求解,也可以暴搜,可以参考 OI Wiki 上的说明,这里就只讲用 Tarjan 实现。
但这种问题的实现主要不是难在 Tarjan 怎么写,而是难在图怎么建。
我们可以考虑怎么通过图来构造其中的关系。
既然给出了条件 \(a\) 和 \(b\),必须只满足其中之一,那么存在两种情况,一是选择 \(a\) 与 \(\lnot b\),二是选择 \(b\) 与 \(\lnot a\)。
那我们就可以将 \(a\) 连向 \(\lnot b\),\(b\) 连向 \(\lnot a\),表示选了 \(a\) 必须选 \(\lnot b\),选了 \(b\) 必须选 \(\lnot a\)。
举个例子,假设这里有两个集合 \(A=\{x_1,y_1\}\),\(B=\{x_2,y_2\}\),规定 \(x_1\) 与 \(y_2\) 不可同时出现,那我们就建两条有向边 \((x_1,y_1)\),\((y_2,x_2)\),表示选了 \(x_1\) 必须选 \(y_1\),,选了 \(y_2\) 必须选 \(x_2\)。
这样建完边之后只需要跑一边 Tarjan 缩点判断有无解,若有解就把几个不矛盾的强连通分量拼起来就好了。
这里注意,因为跑 Tarjan 用了栈,根据拓扑序的定义和栈的原理,可以得到跑出来的强连通分量编号是反拓扑序这一结论。
我们就可以利用这一结论,在输出方案时倒序得到拓扑序,然后确定变量取值即可。
具体形如这样:
//mem[i] 表示非 i
for(int i=1;i<=n;i++)if(num[i]==num[mem[i]]){printf("无解");return 0;}//若两条件必须同时选,则不成立
for(int i=1;i<=n*2;i++)if(num[i]<num[mem[i]]) printf("%d\n",i);return 0;//输出其中一种选择方案
时间复杂度为 \(O(n+m)\)。
求割点
什么是割点?
如果在一个无向图中,删去某个点可以使这个图的极大连通分量数增加,那么这个点被称为这个图的割点,也叫割顶。
求割点比较暴力的做法是,对于每个点尝试删除然后判断图的连通性,不过显然复杂度极高。
考虑用 Tarjan 做。
同缩点一样,用 Tarjan 求割点也需要处理出点的 DFS 序和 low 值。
每次枚举一个点,判断这个点是否为割点的依据是:
- 如果它有至少一个儿子的 low 值大于它本身的 DFS 序,那么它就是割点;
- 如果它本身被搜到且有不少于两个儿子,那么它就是割点。
对于第一个依据的说明是:若一个儿子的 low 值大于它本身的 DFS 序,说明删去它之后它的这个儿子无法回到祖先点,那么它肯定是割点。
对于第二个依据的说明是:若它的儿子小于两个,那么删去他不会造成任何影响,所以它不会是割点。
更新 low 值的方式与缩点相似,但是约束条件不同,放伪代码感性理解一下:
如果 v 是 u 的儿子 low[u] = min(low[u], low[v]);
否则 low[u] = min(low[u], num[v]);
其实割点 Tarjan 的全部代码实现有很多别的细节,原理很简单,代码实现如下:
void tarjan(int u,int fa){
vis[u]=1;int chi=0;//统计孩子数量
dfn[u]=low[u]=++cnt;
for(int i=head[u];i;i=e[i].nxt){
int to=e[i].to;
if(!vis[to]){
chi++;tarjan(to,u);
low[u]=min(low[to],low[u]);
if(fa!=u&&low[to]>=dfn[u]&&!flag[u]){//第一个依据
flag[u]=1;
res++;//割点数量
}
}
else if(to!=fa)
low[u]=min(low[u],dfn[to]);
}
if(fa==u&&chi>=2&&!flag[u]){//第二个依据
flag[u]=1;
res++;
}
}
但是这样跑 Tarjan 针对的不是没有确定 DFS 序的点,而是没有访问过的点,并且每次初始父亲都是自己。
也就是这样:
for(int i=1;i<=n;i++) if(!vis[i]) cnt=0,tarjan(i,i);
这样跑一边 Tarjan 后,带有 \(flag\) 标记的点就是割点。
求割边
按割点的理解方式,割边应该是删去后能使无向图极大连通分量数量增加的边。
没错,就是这样。
割边,也叫桥。严谨来说,假设有连通图 \(G=\{V,E\}\),\(e\) 是其中一条边(即 \(e\in E\)),如果 \(G-e\) 是不连通的,则边 \(e\) 是图 \(G\) 的一条割边(桥)。
原理和割点差不多,实现也差不多,只要改一处:\(low_v>dfn_u\) 就可以了,而且不需要考虑根节点的问题。
与判断割点的第一条依据类似,当一条边 \((u,v)\) 的 \(low_v>dfn_u\) 时,删去这条边,\(v\) 就无法回到祖先节点,因此满足此条件的边就是图的割边。
代码实现如下:
void tarjan(int u,int fat){
fa[u]=fat;
low[u]=dfn[u]=++cnt;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(!dfn[v]){
tarjan(v,u);
low[u]=min(low[u],low[v]);
if(low[v]>dfn[u]){vis[v]=true;++bri;}//bri 是割边的数量
}
else if(dfn[v]<dfn[u]&&v!=fat)
low[u]=min(low[u],dfn[v]);
}
}
其中,当 \(vis_x=1\) 时,\((fa_x,x)\) 是一条割边。
求 LCA
用 Tarjan 来求 LCA,需要用到并查集来维护某个结点的祖先结点。
-
首先接受输入、查询。查询边其实是虚拟加上去的边,为了方便,每次输入查询边的时候,将这个边及其反向边都存上。
-
然后对其进行一次 DFS 遍历,同时记录某个结点是否被访问过以及当前结点的父亲结点。
-
其中涉及到了回溯思想,我们每次遍历到某个结点的时候,认为这个结点的根结点就是它本身。让以这个结点为根节点的 DFS 全部遍历完毕了以后,再将这个结点的根节点 设置为这个结点的父一级结点。
-
回溯的时候,如果以该节点为起点时,查询边的另一个结点也恰好访问过了,则直接更新查询边的 LCA 结果。
-
最后输出结果
Tarjan 算法需要初始化并查集,所以预处理的时间复杂度为 \(O(n)\) ,Tarjan 算法处理所有 \(m\) 次询问的时间复杂度为 \(O(n+m)\) 。但是 Tarjan 算法的常数比倍增算法大。
需要注意的是,Tarjan 算法中使用的并查集性质比较特殊,在仅使用路径压缩优化的情况下,单次调用 find()
函数的时间复杂度为均摊 \(O(1)\) ,而不是 \(O(\log n)\) 。
代码实现如下:
void tarjan(int u) {
parent[u] = u;
visited[u] = 1;
for (int i = head[u]; i != -1; i = edge[i].next) {
Edge& e = edge[i];
if (!visited[e.toVertex]) {
tarjan(e.toVertex);
parent[e.toVertex] = u;
}
}
for (int i = queryHead[u]; i != -1; i = queryEdge[i].next) {
Edge& e = queryEdge[i];
if (visited[e.toVertex]) {
queryEdge[i ^ 1].LCA = e.LCA = find(e.toVertex);
}
}
}
注:此代码来自 OI Wiki。
总的来说,Tarjan 算法求 LCA,拥有更优秀的时间复杂度,但它的常数也更大。
倍增来求 LCA 更易理解,也更实用。
例题
「一本通 3.6 例 1」分离的路径
「一本通 3.6 例 2」矿场搭建
[APIO2009]抢掠计划
[USACO5.3]校园网Network of Schools
[ZJOI2007]最大半连通子图
[POI2001]和平委员会
写在后面
Tarjan 虽说为了实现不同的目的有不同的写法,但是却没有固定的模板,可以根据自己喜好来变通,也可以更改其中维护的变量。
还可以对同一个图跑多种 Tarjan 来求它的各种值等。
最主要的还是理解它的思想。
希望对大家有帮助。