割点和桥 | 无向图的双连通分量
割点和桥
桥(割边):
给定一无向连通图,对于其中一边 (u,v),若从图中删掉 (u,v)后,原图分裂成2个或以上不相连的子图(也就是图中的连通分量数增加),则称 (u,v)为原图的割边(或桥)。
割点:
给定一无向连通图,对于其中一点 u,若从图中删掉 u 和所有与 u 相连的边后,原图分裂成成 2个或以上不相连的子图(也就是图中的连通分量数增加),则称u 为原图的割点(或割顶)。
边的双连通分量 e-dcc
极大的不包含桥的连通块(每个节点之间至少用两条不含公共边的路径)
点的双连通分量 v-dcc
极大的不包含割点的连通块
tarjan 判断桥 边双连通分量
算法理解
和强连通分量一样,引入两个变量:
dfn[u] : 当前到达节点u的时间戳(dfs序)
low[u] : x能达到的时间戳最小的点
dfn很好理解,再理解下low吧。
比如说下面的图:圈内的就是dfn[u], 圈外的[1]和[6]就是low[u]。
如何求图上的桥?
通过观察可知
x和y之间是桥 <====> dnt[x]<low[y]
表示y无论如何往上走不到x
虽然找不到但是可以感性理解和证明正确性
根据定义,dnt[x]<low[y]说明从subtree(y)出发,在不经过(x,y)的前提下,无论走哪条边都无法到达x或比x更早访问的节点。
若把(x,y)删除,则subtree(y)与节点x就没有边相连,图就断开成立两部分。
因此tarjan判断桥的模板代码就是:
//防止搜反向边 引入from
void tarjan(int u, int from)
{
//更新时间戳
dfn[u] = low[u] = ++ timestamp;
//遍历图
for (int i = h[u]; i!=-1; i = ne[i])
{
int j = e[i];
//如果j未遍历过,则遍历且更新low[u]
if (!dfn[j])
{
tarjan(j, i);//dfs(j)
low[u] = min(low[u], low[j]);//用low[j]更新
//j到不了u,则x-y的边为桥,
if (dfn[u] < low[j])
//正向边is_bridge[i] 反向边is_bridge[i ^ 1]都是桥
is_bridge[i] = is_bridge[i ^ 1] = true;
// 这里i==idx 如果idx==奇数 则反向边=idx-1 = idx^1
// 如果idx==偶数 则反向边=idx+1 = idx^1
}
// 如果j遍历过 且i不是反向边(即i不是指向u的父节点的边),则直接更新low[u]
else if (i != (from ^ 1))
low[u] = min(low[u], dfn[j]);//用dfn[j]更新
}
}
模板题
395. 冗余路径
题意:
新建道路 使得每两个草场之间都至少有两条分离的路径。
思路:
因为 一个边的双连通分量 <=> 任何两个点之间至少存在两个不相交路径。
所以题意就是求解加多少边才能将整个图变成双连通分量。
很明显如果本来就是双连通分量,则不需要加边,需要加边的地方就是桥。
对双连通分量做缩点 此时图上只剩桥和点
o
/ \
o o
/\ /\
o o o o
/ \
o o
可以发现对左右两个叶子节点连通后,根节点连向左右叶子节点的边就可以删去了
o
/ \
o o
/\ /\
o o-o o
/ \ | |
o o__| |
|_________|
同理 再把第2个和第4个叶子节点连通后,根节点连向第2个和第4个叶子节点的边也可以删去
第3个叶子节点随便连
给叶子节点按对称性加上边后就没有桥 <=> 变成边的双连通分量
根据上面的分析,也易得,需要加[(cnt+1)/2]条边,cnt为缩完点后度数==1的点(叶子节点)的个数
上面分析有两个问题未解决,
1. 证明:一个边的双连通分量任何两个点之间至少存在两个不相交路径
充分性: 对于每两个点都有互相分离的路径的话,则必然为强连通分量
反证 假设有桥(非双连通) x,y必然经过中间的桥 则只x→y的路径必在桥上相交
o-x 桥y-o
| | ↓ | |
o-o - o-o
必要性:图是一个边双连通分量 等价于 不包含桥
则一定对任意两点x,y
x,y之间至少存在两条互相分离(不相交)的路径
反证:假设存在两条相交路径
那么x→y中间必然有桥
// o-o-o-o-o 蓝色路径 (从x出发到y经过边数最少的路径)
// - - - - 绿色路径
// x y
2.如何缩点:
为了方便:将双连通分量缩点为该双联通分量的最高点
判断当前节点是双联通分量的最高点
对某一个点x,他走完所有的子节点之后,他的dnt[x]==low[x],那么就说明x最高遍历得到的节点就是他自己,也就是最高点了。
代码:
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 5010, M = 20010;
int n, m;
int h[N], to[M], pre[M], idx;
int dfn[N], low[N], timestamp;
int stk[N], top;//模拟栈,用于求出最高点
int id[N], dcc_cnt;// id记录节点i所在的双连通分量的编号;dcc_cnt为双连通分量的编号
bool is_bridge[M]; //是不是桥
int d[N]; //度数
void add(int a,int b){
to[idx]=b,pre[idx]=h[a],h[a]=idx++;
}
void tarjan(int u,int fa){
dfn[u]=low[u]=++timestamp;
stk[++top]=u;//将遍历的元素都入栈
for(int i=h[u];i!=-1;i=pre[i]){
int j=to[i];
if(!dfn[j]){
tarjan(j,i);
low[u]=min(low[u],low[j]);
if(dfn[u]<low[j]){
is_bridge[i]=is_bridge[i^1]=true;
}
}
else if(i!=(fa^1)){
low[u]=min(low[u],dfn[j]);
}
}
//如果u是最高点,则将u上面的节点出栈,并且记录他们位于哪个双连通分量
if(dfn[u]==low[u]){
++dcc_cnt;//双连通分量数量加一
int y;
do{
y=stk[top--];
id[y]=dcc_cnt;
}
while(y!=u);
}
}
int main()
{
cin >> n >> m;
memset(h, -1, sizeof h);
while (m--)
{
int a, b;
cin >> a >> b;
add(a, b), add(b, a);//双向边
}
tarjan(1, -1);
for (int i = 0; i < idx; i++)
//如果边i是桥 在其所连的出边的点j所在强连通分量的度+1
// 桥两边的双连通分量各+1
if (is_bridge[i])
d[id[to[i]]]++;
int cnt = 0;
for (int i = 1; i <= dcc_cnt; i++)
if (d[i] == 1)//多少个度数为1的节点(强连通分量)
cnt++;//需要加的边的数量
cout << (cnt + 1) / 2 << endl;
return 0;
}
tarjan 判断割点
如何求图上的割点
- 若\(x\)不是搜索树的根节点(dfs的起点),
则x是割点当且仅当搜索树上存在 x 的一个子节点y,满足dfn[x] ≤ low[y]
- 若\(x\)是搜索树的根节点,
则x是割点当且仅当搜索树上存在至少两个子节点 \(y_1 和 y_2\) 满足上述条件。
下面看图理解下:
如果x不是根节点
o
|
x
/ \
y y2
如果删除x,则y及其子节点一定不和x的父节点o相连。
如果x是根节点
如果只有一个子结点y1
x
|
y1
如果删除x,则y1 子节点部分还是连通的,此时x不是割点
如果有两个子结点
x
/ \
y1 y2
如果删除x,则y1 y2 之间不连通,此时x是割点
模板题
电力
acwing:1183
牛客
题意:
求无向图删除一个节点之后最多还剩下多少连通块
思路:
先统计连通块的个数,
- 如果不删除节点,那答案就是连通块的个数
- 如果删除节点不是割点,那答案也是连通块的个数。
因为如果不是割点,图中的连通分量数一定不增加。(割点定义) - 如果删除节点是割点,那答案就是删去割点后连通块的个数。
代码:
#include <iostream>
#include <cstring>
using namespace std;
const int N = 10010, M = 30010;
int n, m;
int h[N], e[M], ne[M], idx;
int dfn[N], low[N], timestamp; //dfn兼判重数组
int root; // 记录每个连通块的"根节点"
int ans; // 记录每个连通块去掉一个点形成的连通块数目的最大值
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void tarjan(int u) {
dfn[u] = low[u] = ++ timestamp;
int s = 0; // 如果当前点u是割点的话,去掉该点u得到的连通分量的个数
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (!dfn[j]) {
tarjan(j);
low[u] = min(low[u], low[j]);
if (dfn[u] <= low[j]) // 说明u是可能是割点, u存在一棵子树(删除割点u)
s++;
} else low[u] = min(low[u], dfn[j]);
}
//如果不是根节点
/*
/
u 删掉u后 除子节点yi外
/ \ 还要要加上父节点部分+1
o o
*/
//最后还要加上父节点部分1
if (u != root) s++; // 不用加上&& s的判断,因为u不是割点的话,s要取1
ans = max(ans, s);
}
int main() {
while (scanf("%d%d", &n, &m), n || m) {
memset(dfn, 0, sizeof dfn); // dfn还具有判重数组的作用
memset(h, -1, sizeof h);
idx = timestamp = 0;
while (m--) {
int a, b;
scanf("%d%d", &a, &b);
add(a, b), add(b, a);
}
ans = 0; //记录删除不同割点之后形成的连通块的最大值
int cnt = 0; // 记录连通块的数目
//每次将其中联通块遍历,用tarjan
for (root = 0; root < n; root++) // 节点编号从0~n-1
if (!dfn[root]) { //dfn数组兼判重数组,求联通块的数量
cnt++;
tarjan(root);
}
printf("%d\n", cnt + ans - 1);
}
return 0;
}
tarjan 求点双连通分量
算法理解
什么是点双连通分量?
首先要明确点双连通分量和“删除割点后图中剩余的连通块”不同。
比如下图:有四个点双连通分量
很明显,对于节点\(1,6\)两个节点对于其他连通块都是割点,但是对于\(1,6\)因为两者都不是割点,因此是一个双连通分量。
如何求点双连通分量?
在tarjan求割点过程中,当节点满足 dfn[u] <= low[j]
时,则说明,对于栈中的u节点的子树,如果u再连接一个其他节点,u就是割点,因此此时就是一个极大的不包含割点的图。
此时栈中的u节点的子树、加上u节点就是一个点双连通分量。
如何缩点?
因为一个割点可能属于多个v-DCC,缩点方式和之前不同。
设图 中共有 p 个割点和 t 个 v-DCC,我们建立一张包含 p+t个节点的新图,把每个 vDCC和每个割点都作为新图中的节点,并在每个割点与包含它的所有v-DCC之间连边。
(容易发现,这张新图其实是一棵树(或森林))
如下图所示:
模板题
题意:
给一个不一定连通的无向图
问最少在几个点设置出口
使得任意一个出口坏掉后,其他所有点都可以和某个出口连通
分析:
首先考虑****
-
出口数量>=2
因为:如果只有一个出口 那这个出口坏了就没有可以用的出口了 -
最终方案数 = 各连通块方案数乘积
因为:不同连通块之间相互独立。
对一个连通块
-
无割点 <=> 度数==0的点<=> 孤立的点 <=> 不管我删掉哪个点 图剩余部分都是连通的
因此我们只需要设置2个出口就可以满足。 -
有割点 ,先缩点,建新图,看V-DCC度数(割点的数量)
-
- 如果V-DCC==1 意味着它只包含一个割点,必须在V-DCC内(非割点)放置一个出口
因为如果这个割点是出口且坏掉,这个V-DCC就无法连到其他出口了
- 如果V-DCC==1 意味着它只包含一个割点,必须在V-DCC内(非割点)放置一个出口
-
- 如果V-DCC>1,就不需要设置出口
如果其中一个割点坏了,则还可以到另一个割点联通的V-DCC的出口。
- 如果V-DCC>1,就不需要设置出口
代码:
#include <bits/stdc++.h>
using namespace std;
#define int long long
typedef unsigned long long ULL;
const int N = 510, M = 1010;
int n, m;
int to[N], pre[N], h[M], idx;
int dfn[N], low[N], times;
int stk[N], top;
int dcc_cnt;
vector<int> dcc[N];//记录双连通分量的节点
int root;
bool cut[N];//判断是否为割点
void add(int a, int b)
{
to[idx] = b, pre[idx] = h[a], h[a] = idx++;
}
void tarjan(int u)
{
dfn[u] = low[u] = ++times;
stk[++top] = u;
//u是孤立点时 特殊判断
if (u == root && h[u] == -1)
{
dcc_cnt++;
dcc[dcc_cnt].push_back(u);
return;
}
int cnt = 0;
for (int i = h[u]; i != -1; i = pre[i])
{
int j = to[i];
if (!dfn[j])
{
tarjan(j);
low[u] = min(low[u], low[j]);
if (dfn[u] <= low[j])
{
cnt++;
// 判断u是否是割点
if (u != root || cnt > 1)
cut[u] = true;
//双连通分量缩点
dcc_cnt++;
int y;
do
{
y = stk[top--];
dcc[dcc_cnt].push_back(y);
} while (y != j);
//弹出栈不是弹到u为止 而是弹到j为止
dcc[dcc_cnt].push_back(u);
}
}
else
low[u] = min(low[u], dfn[j]);
}
}
int T = 1;
signed main()
{
while (cin >> m && m)
{
memset(h, -1, sizeof h);
for (int i = 0; i <= n; i++)
dcc[i].clear();
memset(dfn, 0, sizeof dfn);
memset(low, 0, sizeof low);
memset(cut, 0, sizeof cut);
n = idx = times = top = dcc_cnt = 0;
while (m--)
{
int a, b;
cin >> a >> b;
n = max(m, max(a, b));
add(a, b), add(b, a);
}
for (root = 1; root <= n; root++)
{
if (!dfn[root])
tarjan(root);
}
int res = 0;
ULL num = 1;
for (int i = 1; i <= dcc_cnt; i++)
{
int cnt = 0;
for (int t : dcc[i])
{
if (cut[t])
cnt++;
}
if (cnt == 0)
{
if (dcc[i].size() > 1)
res += 2, num *= dcc[i].size() * (dcc[i].size() - 1) / 2;
else
res++;
}
if (cnt == 1)
{
res++, num *= dcc[i].size() - 1;
}
}
cout << "Case " << T++ << ": " << res << " " << num << endl;
}
return 0;
}