开始我最爱的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]
,这就矛盾了。
具体算法运行
还是那么说感性理解
具体过程为,我们任取一个点为 \(root\) 根节点,从 \(root\) 开始,当前点为 \(u\),对于每个 \(u\) 我们分配一个时间戳 \(timestamp\) 即当前的 \(dfn\),并且加入当前的强连通分量里 \(stk\),并标记 \(in_stk\),然后我们去枚举 \(u\) 的子节点。如果这个点没有 \(dfn\),说明这个点没枚举过,那么我们给它\(timestamp\) 并且处理它的子树,处理好后,我们更新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]
若成功则说明,\(u\) 是一个强连通分量起始点,之前我们的stk有什么性质?是可能在当前SCC中的点而现在确定了\(u\) 是起始点,而且 \(dfn\) 最小,\(dfn\)最小也说明 \(u\) 的在 \(stk\) 中的位置最深,最先入栈嘛,那么在栈中比它浅的,就是在它子树中的,可以成为此SCC的点。
剩下的就是记录这个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 很像。
关键的信息——桥和双连通分量。
中心思想
这里也有时间戳的概念,几个数组的意义上面的一样。
即有 \(dfn,low,timestamp,stk\) 等等
怎么找桥?
如图,如果 \(x\to y\) 这条边是桥的话,必定有 \(low[y] > dfn[x]\),即 \(y\) 永远不可能通过别的边和点到达 \(x\) 点。统计时因为是无向边,所以两条边都要统计,这时候就体现出链式前向星的好处了,可以直接用异或操作,就可以找到一条边的反向变。
如何找双连通分量?
第一个办法,可以把桥去掉,实际上就是标记上,不走这条边不就是在一个双连通分量里嘛。然后重新扫一遍图,一个连通块内的就是一个双连通分量。
第二个,就是利用 \(stk\) 数组,道理和 SCC 的很相似但判断条件不同,因为我们要找到双连通分量的那个桥连着的点,上面说了判断桥的条件为 \(low[y] > dfn[x]\),但是我们可以再推一下,对于 \(y\) 这个点来说,它是桥所连的点就相当于 \(low[y] = dfn[y]\),这时候,在 \(stk\) 内,在 \(y\) 上面的点(包括 \(y\))就是一个双连通分量里面的。
这里证明一下为何此时 \(stk\) 中的就是一个双连通分量里面的。首先,我们算法会运行到相当于叶子节点的东西,这时候肯定可以找出一个双连通分量,向上的,不断就会把对应的双连通分量用掉,它们也就出栈了,到了我们这,就应当是我们的了,这里比较感性。
如果有一个点 \(j\) 不是一个双连通分量里面的,但此时在栈中且在 \(y\) 的上面,说明肯定是在 \(y\) 之后入栈,也就是在 \(y\) 之后扫描到的,因为 \(y\) 的双连通还没统计完,那么所以它不可能在别的子树中,就说明它只能是 \(y\) 这个双连通分量里了,因为如果这时候还不是,别的树上的时间戳一定比 \(j\) 小,它就不可能被统计了。因此是的。
第一种很好,但第二种更好,且稳定,所以一般用第二种。
具体操作流程
- 从进行 tarjan,此点为 u,来边为 from
- 赋值 \(dfn,low\) 时间戳,把 u 放进栈 \(stk\) 中
- 枚举每个子节点,如果没有时间戳,则进行 tarjan,tarjan 完成后,更新 \(low_u\);如何有时间戳,判断是否是父边,如果不是就可以更新 \(low_u\),否则不更新(更新会造成错误判断双连通分量)。
- 像 SCC 那样统计双连通分量。
- 重复 \(2\) 到 \(4\)
为什么判断父边,而不是判断父节点,如果两个点之间有多条路径,我当然可以走上去,但如果判断父节点的话,就走不上去了(更新 \(low_u\)),即为了正确更新 \(low_u\),正确判断双连通分量。而更新 \(low_u\) 可以用 \(dfn_j\),也可以用 \(low_j\),这一步的作用和 SCC 中的一样,即判断 DCC。
代码
注意啊,在枚举 tarjan 的时候和 SCC 类似,最好枚举每个点,不重不漏地进行。
值得注意的是,下面的判断 dfn[j]
存在时,只能用 \(dfn_j\) 来更新,不能用 \(low_j\),这里是看看是否能到达那个点,靠直接边,如果用 \(low_j\),从全局上看,整个是一个连通图,我可以直接到根节点,这还要 \(low\) 有什么用。和点的双连通分量相似
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);
}
}
点的双连通分量
这是这几个里面最难的一个。
尽量通俗地写出来,证明可能有不足之处。
中心思想
在点的双连通分量里面最头疼的就是根节点的处理了。在这几个算法里面,都涉及 \(dfn,low,timstamp,stk\) 等变量,含义和上面的相同,但有个特殊的变量 \(cnt\),表示子树数量。
求割点
如果一个点是根节点,那么它至少得有两个子树才能说明他是一个割点,因为它上面没有父节点了。而对于非根节点,只要它的子节点,能到达的最低点即 \(low\) 不小于它即可,因为它上面还有父节点,删去后一定可以分为两个连通块。这就是求割点的基本思想。
求双连通分量
求割点还算比较简单,但是双连通分量就有点难了,和上面几个算法一样,都是利用栈 \(stk\) 来辅助求出。
中心思想:先判断子节点 j 能否到达,当前节点 u 的上方,即 \(dfn_u > low_j\) 是否成立。如果成立,好,说明 u 和 j 在一个环里面,那么删去 u 对于当前来说,无济于事;如果不成立,即 \(dfn_u \le low_j\) 成立,说明删去 u 之后,可能 u 和这个子树可能是一个双连通分量,然后我们判断 u 是否是根节点,如果不是,根据求割点的思想来说,u 就是一个割点,那么 u 点和 j 子树就是一个双连通分量;如果是根节点,那么就判断子树是否有两个,如果有那么 u 是一个割点,同理 u 点和 j 子树就是一个双连通分量。然后进行栈,直到把 j 排出来,最后再把 u 这个点加进去,就求出了一个双连通分量。
在上面有很多问题,需要证明,首先,如何保证求出的双连通分量正确,这里感性一下吧,暂时没法很清楚的写出。只要想着,下面的双连通分量处理完了,这次剩下的就肯定是这次的,不然留不到现在。
代码
值得注意的是,下面的判断 dfn[j]
存在时,只能用 \(dfn_j\) 来更新,不能用 \(low_j\),这里是看看是否能到达那个点,靠直接边,如果用 \(low_j\),从全局上看,整个是一个连通图,我可以直接到根节点,这还要 \(low\) 有什么用。和边的双连通分量相似。
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; // 说明是割点,在上面函数里面写也一样
}
标签:Tarjan,int,连通,stk,dfn,low,分量
From: https://www.cnblogs.com/blind5883/p/18238196