圆方树介绍
点双连通分量
我们都知道有向图的强连通分量叫 scc,无向图也有一种叫双连通分量的连通分量,分为点双(v-dcc)&边双(e-dcc)。
-
一个图为点双连通图等价于对于任意两个不同的点 \(u,v\),存在两条(除端点外)不相交的从 \(u\) 到 \(v\) 的简单路径。特别地,仅存在两个点和一条连接它们的边的图也是点双连通图。
- 即不存在割点的无向连通图。
-
一个图为边双连通图等价于每条边都在某一个简单环中。
- 即不存在割边的无向连通图。
-
一个图的极大点双联通子图为它的一个点双连通分量。
-
一个图的极大边双联通子图为它的一个边双连通分量。
-
当一个图被划分为 v-dcc 和 e-dcc 时,相邻两个 e-dcc 之间没有公共点,分开它们的是割边;相邻两个 v-dcc 之间有一个公共点,该点为割点。换句话说,一个点可以存在于多个点双连通分量中。
圆方树
对于一个无向连通图,求出每一个 v-dcc 后,为每一个 v-dcc 建立一个新点,将 v-dcc 内的所有点与该新点相连。则该新点为方点,原图中的点为圆点,由新边构成的新图将成为一颗树状结构,发明它的陈俊琨叫它圆方树。非连通图将构成森林。圆方树可以将图转为树,从而实现树上操作。
【例1】[APIO2018]Duathlon铁人两项
求不一定连通的简单无向图中,满足「存在一条路径 \(s\to f\) 经过 \(c\) 」的 \(\lang s,c,f\rang\) 的个数。\(\lang s,c,f\rang\) 和 \(\lang f,c,s\rang\) 算不同的元组。
点双的性质:对于一个点双连通分量中的两个点 \(u,v\),从 \(u\) 到 \(v\) 的所有路径的并为点双连通分量的点集。证明略。
当我们建立圆方树之后,求固定的 \(s\to f\) 的 \(c\) 的个数等于与「从圆点 \(s\) 到圆点 \(f\) 上的所有方点」相连的圆点的个数 \(-2\)。\(-2\) 是因为 \(s,f\) 不算。
遇到用圆方树统计路径数的问题,重要做法是给每一个方点和圆点赋权值。
显然,每个方点应赋值为其所代表的点双节点数。所以圆点赋值 \(-1\),以去重两个点双的公共部分。
这样,树上 \(s\to f\) 的点权值和就代表 \(c\) 的个数。注意,不需要再 \(-2\),道理显然。
问题转化为:
求树上所有有序圆点点对间路径点权和之和。
这是一个简单的问题,只需要用“统计贡献”的角度计算即可。由于文字不方便描述,见下面代码:
void dfs(int x,int p){
vis[x]=1; //vis[x]标记x到达,不重要
ll sum=0; //sum统计有多少组点对。在这里先不考虑顺序,因为s!=f所以最后答案*2即可。
//第12行及以前sum统计的都是x子树内的路径条数
for(int i=0;i<T[x].size();i++){
int y=T[x][i];
if(y==p)continue;
dfs(y,x);
sum+=1ll*siz[y]*siz[x]; //这里的siz[x]还是已经遍历过的儿子节点的子树中圆点个数的总和
siz[x]+=siz[y];
}
if(x<=n)sum+=siz[x],siz[x]++; //x<=n代表x是圆点,那么由x连向
sum+=1ll*siz[x]*(tmp-siz[x]); //由x子树内的圆点连向x子树外的圆点必经过x;tmp是连通块内节点个数(因为图不连通)
f[x]*=sum; //f[x]会事先存住x的点权
ans+=f[x];
}
代码:
#include <bits/stdc++.h>
using namespace std;
const int N=1e5+5;
typedef long long ll;
int n,m,t,dfc,cnt,tmp,stk[N],dfn[N],low[N],siz[N*2],vis[N*2];
ll ans,f[N*2];
vector<int>G[N],T[N*2];
void Tarjan(int x){ //注:圆方树的Tarjan严格讲不完全是v-dcc,因x->px的边也被low使用,即会让两个孤立的点之间也用方点串起
stk[++t]=x;
dfn[x]=low[x]=++dfc;
for(int i=0;i<G[x].size();i++){
int y=G[x][i];
if(!dfn[y]){
Tarjan(y);
low[x]=min(low[x],low[y]);
if(low[y]==dfn[x]){
cnt++;
while(t){
T[cnt].push_back(stk[t]),T[stk[t]].push_back(cnt);
f[cnt]++;
if(stk[t--]==y)break;
}
T[cnt].push_back(x),T[x].push_back(cnt);
f[cnt]++;
}
}
else low[x]=min(low[x],dfn[y]);
}
}
void dfs1(int x,int p){
tmp+=x<=n;
for(int i=0;i<T[x].size();i++){
int y=T[x][i];
if(y^p)dfs1(y,x);
}
}
void dfs(int x,int p){
vis[x]=1;
ll sum=0;
for(int i=0;i<T[x].size();i++){
int y=T[x][i];
if(y==p)continue;
dfs(y,x);
sum+=1ll*siz[y]*siz[x];
siz[x]+=siz[y];
}
if(x<=n)sum+=siz[x],siz[x]++;
sum+=1ll*siz[x]*(tmp-siz[x]);
f[x]*=sum;
ans+=f[x];
}
int main(){
scanf("%d%d",&n,&m),cnt=n;
for(int i=1,u,v;i<=m;i++)scanf("%d%d",&u,&v),G[u].push_back(v),G[v].push_back(u);
for(int i=1;i<=n;i++)f[i]=-1;
for(int i=1;i<=cnt;i++)if(!vis[i])Tarjan(i),tmp=0,dfs1(i,0),dfs(i,0);
cout<<ans*2;
}
【例2】战略游戏
一个 \(n\) 点 \(m\) 边无向图,有 \(q\) 次询问,每次给出点集 \(S\),求有多少个 \(c\notin S\),使得存在 \(u,v\in S(u\ne v)\) 满足删去 \(c\) 及与之相连的边后 \(u,v\) 不连通。多组数据。
这个题思维难度其实很低,连我都能独立想出来,是圆方树的套路题。
连通性问题可以想关于 Tarjan 的算法。通过点双的定义和性质可以发现,由于 \(u\) 到 \(v\) 的所有路径之并等于与圆方树上的所有方点相连的圆点集,则 \(u\) 到 \(v\) 的路径上的圆点为必经点。而必经点就是我们要求的点。所以如果固定了 \(u,v\),那么答案就是圆方树上 \(u\sim v\) 路径上面圆点个数(不含自己)。
而我们要求的是 \(S\) 中的 \(u,v\) 两两之间的路径并起来形成的那一棵树里所有圆点个数(不含\(S\))。
很好处理,因为我们都知道一个常见的结论就是把一棵树的每两个相邻叶子之间的距离加起来等于树边总权值÷2(第一个和最后一个的叶子也相邻)。记得一道洛谷月赛题就是用的这个结论可以轻松过掉。
这样一来就很好想了,我们只需要把相邻的两个\(S\)中元素之间的答案(圆点个数,不含自己)加起来除以二即可。但是,什么是“相邻”呢?当然不是指的在\(S\)中相邻,而是在那棵我们想像中的树里面dfn相邻。不难想到其实这是等价于在现在这棵树里dfn相邻的。所以只需要事先对\(S\)按dfn排序即可通过½∑ask(S[i],S[i-1])来求得。具体实现采用树链剖分。
但把我卡了一下的地方是要注意我们的结论是基于边的,不是点权的,因此需要用边权的角度考虑,那么就让x到fa[x]的边权代表x的点权,在此基础上执行上面过程;最后,再加上lca(s1,s2,...)是不是圆点(同时特判lca是否属于s)。注意,维护边的树链剖分return的时候是不能算最高点的值的。
但是这样还没有考虑完,原因在于你该怎么实现“S中的点不算”。是直接每次-2吗?这是不对的,因为有些路径在路径中央还有S中的点。我们需要在一开始的时候就把所有S中点的点权置为0(该次询问结束后再恢复)。
便可以AC了。
v-dcc&e-dcc std
//e-dcc
#include <bits/stdc++.h>
using namespace std;
const int N=5e5+5;
int n,m,tp,dfc,cnt,stk[N],dfn[N],low[N],a[N],b[N];
vector<int>G[N];
void Tarjan(int x,int p){
dfn[x]=low[x]=++dfc;
stk[++tp]=x;
for(int i=0;i<G[x].size();i++){
int y=G[x][i];
if(!dfn[y]){
Tarjan(y,x);
low[x]=min(low[x],low[y]);
}
else if(y^p)low[x]=min(low[x],dfn[y]);
}
if(low[x]==dfn[x]||!p){
cnt++;
while(tp){
b[cnt]^=a[stk[tp]];
if(stk[tp--]==x)break;
}
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1,u,v;i<=m;i++){
scanf("%d%d",&u,&v);
G[u].push_back(v),G[v].push_back(u);
}
for(int i=1;i<=n;i++)if(!dfn[i])Tarjan(i,0);
cout<<cnt;
}
//v-dcc
void Tarjan(int x,int p){
stk[++t]=x;
dfn[x]=low[x]=++dfc;
for(int i=0;i<G[x].size();i++){
int y=G[x][i];
if(!dfn[y]){
Tarjan(y,x);
low[x]=min(low[x],low[y]);
if(low[y]==dfn[x]){
cnt++;
while(t){
T[cnt].push_back(stk[t]),T[stk[t]].push_back(cnt);
f[cnt]++;
if(stk[t--]==y)break;
}
T[cnt].push_back(x),T[x].push_back(cnt);
f[cnt]++;
}
}
else low[x]=min(low[x],dfn[y]);
}
}
推荐题目(v-dcc&e-dcc)
CF639F Bear and Chemistry
给定一张 \(n\) 个点 \(m\) 条边的初始无向图。
\(q\) 次询问,每次询问给定一个点集 \(V\) 和边集 \(E\)。
你需要判断,将 \(E\) 中的边加入初始无向图之后,\(V\) 中任意两个点 \(x,y\) 是否都能在每条边至多经过一次的情况下从 \(x\) 到 \(y\) 再回到 \(x\)。
\(n,m,q,\sum |V|, \sum |E| \le 3 \times 10^5\),强制在线。
题目等价于问每次加入若干条边后,一个点集内的点是不是属于同一个边双。
如果两个点在初始图当中两个点就属于同一个边双,那么询问时也属于,不难想到最开始就跑一次边双缩点,询问时再加边在新图跑边双、判连通块。但这样做是 \(O(nq)\) 的,考虑每次对缩点后的初图建出虚树再加边、跑边双,不难发现建虚树是不会影响连通性的。
你会直接地感受到此题代码量应该不小,所以下面的一些注意事项或许能帮你节省调试时间:
- 清空新图时一定要清空
dfn
。 - 【虚树+加入 \(E\)】的图可能会出现重边(不是你虚树建错了,而是 \(E\) 中的边对应在虚树上是重边),这种情况下不要在
tarjan
函数中直接把两条当成一条。另外原图中也可能有重边。 - 一定要注意什么时候是
x
,什么时候是bel[x]
,甚至是新图的bel[x]
还是初图的bel[x]
。 rotate
函数的 \(R\) 要开 LL