首页 > 技术文章 > Tarjan无向图的割点和桥(割边)全网详解&算法笔记&通俗易懂

gzh-red 2019-07-26 22:18 原文

更好的阅读体验&惊喜&原文链接
感谢@yxc的腿部挂件 大佬,指出本文不够严谨的地方,万分感谢!

Tarjan无向图的割点和桥(割边)

导言

在掌握这个算法前,咱们有几个先决条件.

  • 认真的大脑(滑稽)

如果您都具备了,那么您就是巨佬了,您就可以轻松解决Tarjan算法了.


初学算法

概念掌握

割点

概念定义什么的,看上去好烦好烦好烦的,怎么办呢?

Acwing小剧场开播了,门票一枚AC币.

现在Acwing推出了一款战略游戏,名为Tarjan无向图的割点和桥.

贪玩Tarjan,介个是泥从未丸过的船新版本.

整个游戏,由\(N\)个岛屿构成,而且有\(M\)条航线联络着这些岛屿.

Tarjan算法与无向图连通性3.png

我们发现熊熊助教y总这两个点非常重要,是整张地图的核心战略要塞.

假如说缺少了任意一个点,我们发现总会有两个岛屿,不能联系了.

因此我们得出了,核心战略要塞,就是交通联络点.

所以这些点要重要中药保护,避免被破坏,

因此我们把这些要重要保护的点,也就是交通联络点,称之为割点.


概念 定义
割点 删掉这个点和这个点有关的边,图就不是连通图,分裂成为了多个不相连的子图
割边

同样的有核心战略要塞,也就会有黄金水道.

什么是黄金水道呢?

难道是航运量大的航道?

不不不不,这个概念不一样.

如果说黄金水道被破坏了,那么将会有两个和两个以上的岛屿,不能够通航连接了.

比如说,熊熊助教y总连接的一条航道.

还有熊熊助教song666连接的航道.

Tarjan算法与无向图连通性4.png

这就是我们的黄金水道,也就是战略航道.

因此我们给这些战略航道定义为桥(割边).

概念 定义
删除这条边后,图就不是连通图,分裂成为多个不相连的子图.

时间戳

其实啊,我们完全可以给这些岛屿们编号.这样便于管理,有利于愉悦身心健康.

因此我们得出了下面这张图片.

Tarjan算法与无向图连通性5.png

我们发现删除了一些多余的边,然后每个多了一个学号.

其实我们的学号,就是每一个节点的时间戳.

什么是时间戳,就是每一个点的学号.

但是这个学号是怎么来的呢?总不能是一顿瞎排的吧.

其实这个学号,就是我们的DFS序,刚开始我们任意选择一个点开始,然后一次深度优先搜索,每新搜索到一个点后,就给这个点标记一个学号.

然后来一个gif动画看一看.

录制_2019_07_26_21_41_07_920.gif

因此时间戳的定义我们就知道了.

概念 定义
时间戳 \(dfn[x]\)表示节点x第一次被访问的编号.

这就是时间戳的概念,其实就是学号编辑的过程.


追溯值

追溯,追溯,就是寻找一个节点,以他为,可以抵达的最小学号.

我们设置一个小概念

\[subtree(x)表示搜索树中以x节点为根的子树节点集合. \]

比如说我们举一个例子.

录制_2019_07_26_21_54_54_814.gif

这些红色标记节点,其实也就是熊熊助教的搜索树.

因此我们得出.

\[subtree(熊熊助教)=(熊熊助教,song666,秦淮岸,Chicago) \]

那么我们设置一下追溯值数组.

\[low[x]定义为以下节点的时间戳的最小值. \]

  1. \(subtree(x)\)中的节点
  2. 通过\(1\)条不在搜索树上的边,能够抵达\(subtree(x)\)中的节点.

这个第一条我们上面解释过了,那么第二条怎么解释呢,还是一样,我们再来一个解释gif.

判定法则

割边判断法则

无向\((x,y)\)是桥,当且仅当搜索树上存在\(x\)的一个子节点\(y\),满足

\[dfn[x]<low[y] \]

首先一个公式,很难让人理解,我们得有一点人性化理解.

桥是干什么的?

它是用来连接两个个连通块的,没有了桥,就没有连通性,就会出现世外桃源.

什么是世外桃源,就是自成一派,与外人无往来.

我们需要知道追溯值,是什么.

就是一个节点可以在自己子树子树可以拓展的节点中找到一个最小的编号.

删掉了桥,那么在世外桃源,请问对于任何一个节点而言,他们存在,一个可以往外面拓展的节点吗?

没有,因为他们是世外桃源,不与外人有任何连接.

于是世外桃源内所有的节点,他们的最小追溯值一定不会小于宗主的编号.

咱们要知道,自给自足是很难成功的,总得有一个人出去买加碘海盐,那么这个人就是吃货宗的宗主

我们认为宗主就是所有节点中编号最小的节点,也就是有可能与外人有所连接的节点.

换句话说,也就是\((x,y)\)这个桥两端中,在世外桃源内的节点就是宗主\(y\).

正经语言说一说就是.

因此当\(dfn[x]<low[y]\)的时候

  1. 我们发现\(y\)节点出发,在不经过\((x,y)\)的前提下,不管走哪一条边,我们都无法抵达\(x\)节点,或者比\(x\)节点更早出现的节点
  2. 此时我们发现\(y\)所在的子树似乎形成了一个封闭圈,那么\((x,y)\)自然也就是桥了.

割点判断法则

其实和上面的判断,只有一点修改.

\(x\)不是搜索树的根节点,若\(x\)节点是割点,那么当且仅当搜索树上存在\(x\)的一个儿子节点\(y\),满足

\[dfn[x] \le low[y] \]

宗主节点,是所有人中实力最强大的,所以肯定是最先学习的.

既然如此,那么显然他的\(dfn\),就是代表学习的开始时间,必然就是最小的.

而且割点是一个世外桃源和外界的唯一通道,所有的儿子节点的\(dfn\)都必须大于等于它,不可以小于它,因此证毕.

其实证明和上面的正经证明是一模一样的,只不过多了一个等于号罢了.

特殊定义:请记住根是不是割点,必须保证有至少两个儿子节点,否则不叫作割点.


代码解析

割边模板
#include <bits/stdc++.h>
using namespace std;
const int N=1e5+20;
int head[N],edge[N<<1],Next[N<<1],tot;
int dfn[N],low[N],n,m,num;
bool bridge[N<<1];
void add_edge(int a,int b)
{
	edge[++tot]=b;
	Next[tot]=head[a];
	head[a]=tot;
}
void Tarjan(int x,int Edge)
{
	dfn[x]=low[x]=++num;//DFS序标记 
	for(int i=head[x]; i; i=Next[i])//访问所有出边 
	{
		int y=edge[i];//出边
		if (!dfn[y])//不曾访问过,也就是没有标记,可以认为是儿子节点了 
		{
			Tarjan(y,i);//访问儿子节点y,并且设置边为当前边
			low[x]=min(low[x],low[y]);//看看能不能更新,也就是定义中的,subtree(x)中的节点最小值为low[x] 
			if (low[y]>dfn[x]) //这就是桥的判定
				bridge[i]=bridge[i^1]=true;//重边也是桥 
		} else if (i!=(Edge^1))
			low[x]=min(low[x],dfn[y]);//第二类定义,也就是通过1条不在搜索树上的边,能够抵达subtree(x)的节点 
	}
}
int main()
{
	scanf("%d%d",&n,&m);
	tot=1;//边集从编号1开始 
	for(int i=1; i<=m; i++)
	{
		int a,b;
		scanf("%d%d",&a,&b);
		add_edge(a,b);
		add_edge(b,a);
	}
	for(int i=1;i<=n;i++)
		if (!dfn[i])//一个无向图,可能由多个搜索树构成 
			Tarjan(i,0);
	for(int i=2;i<=tot;i+=2)//无向边不要管,直接跳2格 
		if (bridge[i])
			printf("%d %d\n",edge[i^1],edge[i]);//桥的左右两端 	
	return 0;
}
割点模板
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+20;
int head[N],edge[N<<1],Next[N<<1],tot;
int dfn[N],low[N],n,m,num,root,ans;
bool cut[N];
void add_edge(int a,int b)
{
	edge[++tot]=b;
	Next[tot]=head[a];
	head[a]=tot;
}
void Tarjan(int x)
{
	dfn[x]=low[x]=++num;//DFS序标记
	int flag=0;
	for(int i=head[x]; i; i=Next[i])//访问所有出边
	{
		int y=edge[i];//出边
		if (!dfn[y])//不曾访问过,也就是没有标记,可以认为是儿子节点了
		{
			Tarjan(y);//访问儿子节点y,并且设置边为当前边
			low[x]=min(low[x],low[y]);//看看能不能更新,也就是定义中的,subtree(x)中的节点最小值为low[x]
			if (low[y]>=dfn[x]) //这就是割点的判定
			{
				flag++;//割点数量++
				if (x!=root || flag>1)//不能是根节点,或者说是根节点,但是有至少两个子树节点是割点
					cut[x]=true;
			}
		}
		else low[x]=min(low[x],dfn[y]);//第二类定义,也就是通过1跳不在搜索树上的边,能够抵达subtree(x)的节点
	}
}
int main()
{
	scanf("%d%d",&n,&m);
	memset(cut,false,sizeof(cut));
	for(int i=1; i<=m; i++)
	{
		int a,b;
		scanf("%d%d",&a,&b);
		add_edge(a,b);
		add_edge(b,a);
	}
	for(int i=1; i<=n; i++)
		if (!dfn[i])//一个无向图,可能由多个搜索树构成
			root=i,Tarjan(i);
	for(int i=1; i<=n; i++) //统计割点个数 
		if (cut[i])
			ans++;
	printf("%d\n",ans);
	for(int i=1; i<=n; i++) //顺序遍历,康康哪些点是割点
		if (cut[i])
			printf("%d ",i);
	return 0;
}

经典题目

第一题 B城

原题连接

题目描述

B城有 \(n\) 个城镇,\(m\) 条双向道路。

每条道路连结两个不同的城镇,没有重复的道路,所有城镇连通。

把城镇看作节点,把道路看作边,容易发现,整个城市构成了一个无向图。

输入格式

第一行包含两个整数 \(n\)\(m\)

接下来\(m\)行,每行包含两个整数 \(a\)\(b\),表示城镇 \(a\)\(b\) 之间存在一条道路。

输出格式

输出共\(n\)行,每行输出一个整数。

\(i\) 行输出的整数表示把与节点 \(i\) 关联的所有边去掉以后(不去掉节点 \(i\) 本身),无向图有多少个有序点\((x,y)\),满足$ x$ 和$ y$ 不连通。

数据范围

\[n \le 100000 \\\\ m \le 500000 \]

输入样例:

5 5
1 2
2 3
1 3
3 4
4 5

输出样例:

8
8
16
14
8

解题报告

题意理解

一张图,每次删除一个节点,包括它和其他节点的边,问删除这个节点过后,会有多少个有序节点\((x,y)\)之间无法连通.

Hint:有序节点\((x,y)\)和有序节点\((y,x)\)不是同样的节点对.我们要计算两次.


算法解析

删除一个节点,使得图不连通,这不就是割点的定义吗?

  1. 假如删掉的节点不是割点.

此时我们发现,除了这个节点与其他节点都不连通,其他节点都是连通的.

因此答案为.

\[ans=2 \times (n-1) \]

也就是除了当前节点,其余的\((n-1)\)个节点都与当前节点不连通.(自己和自己当然是连通的)

然后答案要计算两遍,因此\((n-1) \times 2\)

  1. 假如说删除的节点是割点

删除割点,会使得图变得四分五裂,成为了若干个连通块.

那么连通块本身内部的节点,当然还是互相连通的.

但是两个不同的连通块的节点,显然就不连通了.

比如说\(a\)节点属于\(1\)号连通块.

然后\(b\)节点属于\(2\)号连通块.

请问他们连通吗?

不连通!

所以答案+1.

那么我们从特解到通解.

假如说此时有\(1,2,3,4,5\)这五个连通块.

我们提出一个概念.

\[size[i]表示i连通块的节点数 \]

而且\(s\)节点属于\(1\)号连通块.

那么除了自己所在\(1\)号连通块内部节点与自己连通,其他连通块节点和自己都没有任何关系.

因此我们得出如下公式.

显然与\(s\)节点相连的节点个数有

\[size[x]-1 \]

那么与\(s\)节点不连通的节点个数有

\[n-(size[x]-1)-1=n-size[x] \\\\ 总节点-与s节点相通的节点总数-s自身节点=与s不连通的节点数 \]

那么\(s\)节点的贡献是什么呢?

\[s号节点的贡献=(n-size[1]) \]

因此我们推出一个连通块贡献的价值.

\[size[s_1] \times (n-size[s_1]) \]

但是一个割点显然不会只有1个连通块,我们假设它有

\[s_1,s_2,s_3,s_4,......,s_k连通块. \]

因此一个割点的子连通块贡献了.

\[size[s_1] \times (n-size[s_1])+size[s_2] \times(n-size[s_2])+...+size[s_k] \times (n-size[s_k]) \]

所有儿子连通块+自身,一共有多少个节点呢,我们算一下.

\[1+\sum_{i=1}^ksize[s_i] \]

但是根据搜索树定义,割点有自己的儿子连通块,当然也就有不是儿子连通块.

因此我们得出不是儿子连通块的个数为.

\[(n-(1+\sum_{i=1}^ksize[s_i])) \]

其实也就是.

\[n-(1+(size[s_1]+size[s_2]+...+size[s_k])) \\\\ -1,是因为还要抛去自身这个节点 \\\\ Hint:一棵树等于子树节点+根节点. \]

那么这些不是儿子连通块,显然与是儿子联通块是不连通了,因为删除这个割点,所以贡献代价为

\[(n-(1+\sum_{i=1}^ksize[s_i])) \times (1+\sum_{i=1}^ksize[s_i]) \]

此时我们还要知道,节点自身也是有贡献的,毕竟和其他节点都不连通.

\[(n-1) \times 1 \]

那么总和上面贡献,就是最终答案了.我就不打了,上面很清晰了,下面也有代码解释.


代码解析

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N=100000*2+200;
long long head[N],edge[N],Next[N],tot,num,ans[N],root;
long long n,m,size[N],dfn[N],low[N];
bool cut[N];
void add_edge(int a,int b)//加边
{
	edge[++tot]=b;
	Next[tot]=head[a];
	head[a]=tot;
}
void Tarjan(int x)
{
	dfn[x]=low[x]=++num;//编号
	size[x]=1;//初始时候就自己这一个孤寡老人
	int flag=0,sum=0;
	for (int i=head[x]; i; i=Next[i])
	{
		int y=edge[i];//儿子节点
		if (!dfn[y])//不曾访问
		{
			Tarjan(y);//访问
			size[x]+=size[y];//儿子节点贡献的子树大小
			low[x]=min(low[x],low[y]);//更新一下
			if (low[y]>=dfn[x])//发现了割点
			{
				flag++;
				ans[x]+=size[y]*(n-size[y]);//加上y儿子连通块带来的贡献
				sum+=size[y];//这里是统计儿子连通块总节点数
				if (x!=root || flag>1)//是割点
					cut[x]=true;
			}
		}
		else
			low[x]=min(low[x],dfn[y]);//更新
	}
	if (cut[x])//是割点
		ans[x]+=(n-sum-1)*(sum+1)+(n-1);//非儿子连通块的贡献+自身节点贡献
	else
		ans[x]=2*(n-1);//不是割点
}
signed main()
{
	scanf("%lld%lld",&n,&m);
	memset(cut,false,sizeof(cut));//刚开始都不是割点
	for(int i=1; i<=m; i++)
	{
		int a,b;
		scanf("%lld%lld",&a,&b);
		if (a==b)//无用边
			continue;
		add_edge(a,b);
		add_edge(b,a);
	}
	root=1;//根节点为1
	Tarjan(1);
	for(int i=1; i<=n; i++)
		printf("%lld\n",ans[i]);
	return 0;
}

推荐阅读