今天我主要介绍Tarjan算法在割点割边以及强连通分量中的应用以及缩点技巧
按照老规矩, 先上两道模板题 【模板】强连通分量 【模板】割点(割顶)
割点割边
一, 离散数学中的定义:
割点: 无向连通图中,去掉一个顶点及和它相邻的所有边,图中的连通分量数增加,则该顶点称为割点。
割边: 无向联通图中,去掉一条边,图中的连通分量数增加,则这条边,称为割边。
二, Tarjan算法
优点:
想象一下如果用暴力法, 你会如何求解割点或者割边的数目呢?
最容易想到的当然是对于每一个点, 去掉它后对整个图dfs一遍, 看看连通分量是否增加, 若增加, 则这个去掉的点是割点, 对于割边也是一样的思路, 所以对于暴力法, 你需要`dfs``好多遍.
而使用Tarjan算法, 你只需要`dfs'一遍即可.
需要用到的数据结构:
这个算法需要用到好几个辅助数组, 下面我来详细介绍它们的作用
int dfn[MAXN];//用来记录一个顶点第一次被访问时的时间戳
int low[MAXN];//用来记录一个顶点不经过它的父亲顶点最高能访问到它的祖先节点中的最小时间戳, 通俗易懂的来说, 就是与结点i连接的所有点中dfn[]值最小的一个。
int cut[MAXN];//用来记录该点是否是割点, 因为一个割点可能多次被记录
//这是链式前向星, 用来存储边的一个数据结构
int head[MAXN], cnt;
struct Edge {
int to;
int nxt;
} e[MAXM];
算法的主要流程:
1. 选定一个点作为根节点来进行dfs遍历, 但由于图可能不是连通图, 故外面要套一个for, 这可以理解吧
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
tarjan(i, i);
}
}
这段代码中我没有另外使用一个
vis数组记录节点是否被访问过, 因为一个节点若被访问过, 那么dfn数组肯定不为0, 故可以通过dfn数组的值来判断该节点是否已经被访问过
tarjan函数第二个参数传入的是父亲节点, 根节点是个例外, 父亲节点就当做它本身, 便于后面的特判
2. tarjan函数
对于一个根节点, 判断它是不是割点很简单, 如果它有两个或者两个以上的子树, 那么去掉根节点这几颗子树就不连通了, 故这时可以判定根节点是割点.
对于一个非根节点U, 相对要麻烦一点.
用U顶点的dfn值和它的所有的孩子顶点的low值进行比较,如果存在至少一个孩子顶点V满足low[v] >= dnf[u],就说明顶点V访问顶点U的祖先顶点,必须通过顶点U,而不存在顶点V到顶点U祖先顶点的其它路径,所以顶点U就是一个割点。对于没有孩子顶点的顶点,显然不会是割点。
接下来说一说这几个数组的值得更新操作
1. 对于dfn数组, 只会在它第一次访问的时候赋值, 其值等于访问时的时间戳, 而且后续其值永远不会改变
2. 而对于low数组, 它在第一次访问一个顶点时也会赋值, 也是第一次访问时的时间戳, 但后续操作它的值可能会改变, 即假设当前顶点为u,则默认low[u]=dfn[u],即最早只能回溯到自身。有一条边(u, v),如果v未访问过,继续DFS,DFS完之后,low[u]=min(low[u], low[v]);如果v访问过(且u不是v的父亲),就不需要继续DFS了,一定有dfn[v]<dfn[u],low[u]=min(low[u], dfn[v])。
说说对于根节点的特判
我们的tarjan函数传入了两个参数, 挡在主函数中第一次调用时, 是tarjan(i, i), 我说过, 第二个参数代表的是父亲节点, 但i的父亲节点怎么会是它本身呢? 嘿嘿, 这就是一个用来特判根节点的技巧啦, 如果对于一个顶点u, 有u == fa, 那么u就是根节点, 下面上函数代码, 您可以慢慢体会, 有详细的注释
//fa代表父亲节点
void tarjan (int u, int fa) {
dfn[u] = low[u] = ++id; //id代表时间戳
int child = 0; //child代表子树数目, 只有u是根节点时, 这个变量才会起作用哟
for (int i = head[u]; i; i = e[i].nxt) { //链式前向星的遍历操作
int to = e[i].to;
if (!dfn[to]) {
//如果顶点to没有访问过, 那么继续dfs
tarjan(to, fa); //传入当前节点以及父节点作为参数
low[u] = min(low[u], low[to]); //回溯的时候更新low数组的值
if (low[to] >= dfn[u] && u != fa) { //注意这里特判了不是根节点
cut[u] = 1;//标记为割点
}
if (u == fa) { //特判是根节点
child++; //子树数目加1
}
}
low[u] = min(low[u], dfn[to]); //这里的更新操作不要漏掉了
}
if (child >= 2 && u == fa) { //若根节点的子树数目大于或等于2
cut[u] == 1; //则根节点也是割点
}
}
然后遍历一遍cut数组, 更新割点的数目
for (int i = 1; i <= n; i++) {
if (cut[i]) {
ans++;
}
}
最后输出割点数即可
cout << ans << endl;
return 0;
二, 强连通分量以及缩点技巧
我们不仅可以利用tarjan算法来寻找割边割点的数目, 我们还可以用它来寻找强连通分量, 这时相对于割点割边会复杂许多, 希望您能耐心看完
什么是强连通分量?
根据离散数学中的定义
1,强连通:在一个有向图中,如果两个点可以互相到达,就称为这两个点强连通。
2,强连通图:在一个有向图中,如果任意两个点强连通,就把这个图称为强连通图。
3,强连通分量:在一个非强连通图中的最大强连通子图,称为强连通分量。
关于强连通, 您只需要知道这么多就够了.
这一题需要用到的数据结构比上一题要多, 我来详细介绍一下
int low[MAXN]; //同上一题
int dfn[MAXN]; //同上一题
int stack_[MAXN]; //栈
int exist[MAXN]; //判断第i个元素是否在栈中
int color[MAXN]; //用于对不同的连通分量染色的数组
//链式前向星
struct Cow {
int to;
int nxt;
} cow[MAXM];
//注:下面这两个数组是针对我给的模板题而言的
int num[MAXN]; //用于记录每个连通分量有多少格元素的数组
int outDgree[MAXN]; //用于记录出度的数组
强连通分量模板题, 希望您能好好做一下这一题帮助您理解这个算法
算法流程
我觉得这一段伪代码很好的总结了其算法流程 出处
tarjan(u)
{
DFN[u]=Low[u]=++Index // 为节点u设定次序编号和Low初值
Stack.push(u) // 将节点u压入栈中
for each (u, v) in E // 枚举每一条边
if (v is not visted) // 如果节点v未被访问过
tarjan(v) // 继续向下找
Low[u] = min(Low[u], Low[v])
else if (v in S) // 如果节点v还在栈内
Low[u] = min(Low[u], DFN[v])
if (DFN[u] == Low[u]) // 如果节点u是强连通分量的根
repeat
v = S.pop // 将v退栈,为该强连通分量中一个顶点
print v
until (u== v)
}
我来用语言概括一下核心步骤: 从一个点出发, 开始遍历并跟新dfn和low, 如果一个点u无路可走了, 那么若dfn[u] == low[u], 就弹出栈顶到u的元素, 这些元素是属于一个强连通分量内的. 用语言很难描述清楚, 来模拟一下吧.
对于如下一幅图
寻找它的强连通分量
- 从节点1开始DFS,把遍历到的节点加入栈中。搜索到节点u=6时,DFN[6]=LOW[6],找到了一个强连通分量。退栈到u=v为止,{6}为一个强连通分量。
- 返回节点5,发现DFN[5]=LOW[5],退栈后{5}为一个强连通分量。
- 返回节点3,继续搜索到节点4,把4加入堆栈。发现节点4向节点1有后向边,节点1还在栈中,所以LOW[4]=1。节点6已经出栈,(4,6)是横叉边,返回3,(3,4)为树枝边,所以LOW[3]=LOW[4]=1。
- 继续回到节点1,最后访问节点2。访问边(2,4),4还在栈中,所以LOW[2]=DFN[4]=5。返回1后,发现DFN[1]=LOW[1],把栈中节点全部取出,组成一个连通分量{1,3,4,2}。
- 至此,算法结束。经过该算法,求出了图中全部的三个强连通分量{1,3,4,2},{5},{6}。
上述过程用代码描述是这样的
void dfs(int x) {
dfn[x] = low[x] = ++tot; //都初始化为x
stack_[++top] = x; //点x入栈
exist[x] = 1; //表示点x在栈中
for (int i = head[x]; i; i = cow[i].nxt) {
if (!dfn[cow[i].to]) {
//如果与它相连的这个点还没有被遍历
dfs(cow[i].to);
low[x] = min(low[x], low[cow[i].to]);
} else if (exist[cow[i].to]) {
//如果与它相连的这个点在栈中, 表示它们在同一个连通分量中
low[x] = min(low[x], low[cow[i].to]);
}
}//end for
if (low[x] == dfn[x]) {
//如果节点x是强连通分量的根
id++; //每个连通分量的标号
do {
color[stack_[top]] = id;
num[id]++;
exist[stack_[top]] = 0;
} while (x != stack_[top--]);
}
}
这段流程相当的清楚. 现在您应该已经懂得了如何求得强连通分量了.
下面, 我将介绍缩点法
1. 当我们找到一个连通分量的时候,因为这个连通分量里的点都是强连通的,所以我们可以把它们看成一个点。
所以有了上述的思想, 奶牛这一题就迎刃而解了, 当我们把每一个连通分量都进完缩点时,我们只需要在剩下的这张图中找明星就行了,如果这张图里的每一个点都是强连通,那么显然这张图里的牛都是明星。否则我们可以找到出度为0的牛,这样可以保证其他所有的牛都喜欢它,但是如果有2个以上的出度为0的牛,那么很显然这张图里是不存在明星的。
2. 关于计算出度
在我们已经染色完成并用缩点法转化后, 对于一个缩点的出度只用看颜色i的顶点的出边是否是另一种颜色, 是的话颜色i的强连通分量出度+1
for (int i = 1; i <= n; i++) {
for (int j = head[i]; j; j = cow[j].nxt) {
if (color[i] != color[cow[j].to]) {
outDgree[color[i]]++;
}
}
}
3. 如何判断是否有明星, 谁是明星?
首先, 判断是否有明星, 若只有一个缩点的出度为0, 那么该缩点就是明星.
若有超过一个缩点的出度不为0, 那么是没有明星的.
若一个缩点为明星, 那么代表这个缩点所代表的强连通分量内的奶牛全部是明星
for (int i = 1; i <= id; i++) {
if (outDgree[i] == 0) {
if (ans) {
cout << 0;
return 0;
}
ans = i;
}
}
cout << num[ans];
return 0;
然后就AC啦
蒟蒻我整理了一个晚上, 觉得帮助到您了点个赞呗 ^_^! (小声bb
本文参考博客: