广义串并联图

如果你有一个序列上的问题,可以尝试将其放在树上。

如果你有一个树上的问题,可以尝试将其放在仙人掌[1]上。

如果你有一个仙人掌上的问题,可以尝试将其放在广义串并联图上。

广义串并联图

定义

不存在同胚于 \(K_4\) 的子图的无向图。即,对于任意 \(4\) 个点,不能存在下图:

011

串联与并联

其实也可以借助电路来理解。广义串并联图只能通过下述 $3$ 种方法生成(最开始有一个点,电源):

  • 增加新的用电器。即从一个已有的点 $u$ 上建边 $(u,v)$,增加点 $v$。

  • 在串联电路上增加用电器。即在边 $(u,v)$ 上增加点 $x$,删除 $(u,v)$,增加 $(u,x),(x,v)$。

  • 并联。即对于 $u,v$ 连通,增加边 $(u,v)$。

常见的树、基环树、仙人掌都是广义串并联图。太好了不用学仙人掌了。

性质

  • 广义串并联图为平面图。

  • 广义串并联图可以通过三种操作缩为一个点:

    • 删一度点(rake)。
    • 缩二度点(compress)。
    • 叠合重边(twist)。

    可以发现,这三个操作实际上就是三个生成广义串并联图的逆操作。

    并且,上述操作中,每一条边都对应原图的一个子图。

    这三种操作,我们称之为「广义串并联图方法」。

对于一个 \(n\) 个点,\(m\) 条边的一般图,若 \(m-n\leq k\),可以通过广义串并联图方法将原图缩为一个满足 \(n\leq 2k,m\leq 3k\) 的新图。因此当 \(k\) 比较小的时候,可以缩图之后跑暴力。

实现

考虑如何维护缩图的过程。

显然每次需要维护的点都是度数小于等于 \(2\) 的点,其余点不用动。并且,我们每次操作的都是「最外层」的点,可以对原图进行一个类似于拓扑排序的操作。

维护一个队列 \(q\),初始时将所有度数小于等于 \(2\) 的点加入 \(q\),之后每次取出队首 \(x\) 并弹出。

\(x\) 在队列中,说明其度数不大于 \(2\)

  • 若度数为 \(1\),则设其唯一连边的节点为 \(v\),将 \(x\) 的答案转移到 \(v\) 上之后删去边 \((x,v)\)

  • 若度数为 \(2\),则设其连边为 \((u,x),(x,v)\),需要将其缩二度点转化为 \((u,v)\)

    \((u,x),(x,v)\) 的答案转移到 \((u,v)\) 后,添加边 \((u,v)\)。若 \((u,v)\) 已经存在,还需要叠合重边。

删除之后,如果另一个点度数小于等于 \(2\),也要加入队列 \(q\)

具体维护上,因为一般都需要维护关于边的信息,因此可以对于每一个点 \(x\) 开哈希表存储二元组 \((v,\textit{value})\) 表示边 \((u,v)\) 的信息为 \(\textit{value}\)

也可以用 map 维护,复杂度多一个 \(\log\)

例题

Luogu P6790 [SNOI2020] 生成树

给定无向连通图 \(G\),满足 \(G\) 删去一条边后为仙人掌,求 \(G\) 的生成树个数对 \(998244353\) 取模的结果。

原图满足广义串并联图的定义。

设答案为 \(\textit{ans}\),初始时维护 \(\textit{ans}=1\)

那么接下来就考虑如何维护三种操作。

\(f_{e,0/1}\) 表示边 \(e\) 选/不选的其对应子图的方案数。

  • 删一度点,边为 \(e\),那么将 \(f_{e,1}\) 乘入答案即可。

  • 缩二度点,合并边 \(e_1,e_2\)\(e\)

    \(e_1,e_2\) 不可能同时不选(否则 \(e\) 不存在),那么则有:

    \[\begin{aligned} f_{e,0}&=f_{e_1,0}f_{e_2,1}+f_{e_1,1}f_{e_2,0}\\ f_{e,1}&=f_{e_1,1}f_{e_2,1} \end{aligned} \]

  • 叠合重边 \(e_1,e_2\)\(e_1,e_2\) 不可能同时选,等效于一条边 \(e\)

    \[\begin{aligned} f_{e,0}&=f_{e_1,0}f_{e_2,0}\\ f_{e,1}&=f_{e_1,0}f_{e_2,1}+f_{e_1,1}f_{e_2,0} \end{aligned} \]

初始时,\(f_{e,0}=f_{e,1}=1\)。时间复杂度 \(\mathcal O(n)\)

参考代码
//#include<bits/stdc++.h>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<iomanip>
#include<cstdio>
#include<string>
#include<vector>
#include<cmath>
#include<ctime>
#include<deque>
#include<queue>
#include<stack>
#include<list>
#include<array>
#include<unordered_map>
using namespace std;
constexpr const int N=2e5,P=998244353;
int n;
unordered_map<int,array<int,2>>f[N+1];
array<int,2> twist(array<int,2>a,array<int,2>b){
	return {1ll*a[0]*b[0]%P,(1ll*a[0]*b[1]+1ll*a[1]*b[0])%P};
}
int main(){
	/*freopen("test.in","r",stdin);
	freopen("test.out","w",stdout);*/
	
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	
	int m;
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		int u,v;
		cin>>u>>v;
		if(f[u].count(v)){
			f[u][v]=f[v][u]=merge(f[u][v],{1,1});
		}else{
			f[u][v]=f[v][u]={1,1};
		}
	}
	queue<int>q;
	for(int i=1;i<=n;i++){
		if(f[i].size()<=2){
			q.push(i);
		}
	}
	int ans=1;
	while(q.size()){
		int x=q.front();q.pop();
		if(f[x].size()==1){
			auto [v,e]=*f[x].begin();
			ans=1ll*ans*e[1]%P;
			f[v].erase(x);
			if(f[v].size()<=2){
				q.push(v);
			}
		}else if(f[x].size()==2){
			auto [u,e1]=*f[x].begin();
			auto [v,e2]=*next(f[x].begin());
			array<int,2>e={(1ll*e1[1]*e2[0]+1ll*e1[0]*e2[1])%P,1ll*e1[1]*e2[1]%P};
			if(f[u].count(v)){
				f[u][v]=f[v][u]=merge(f[u][v],e);
			}else{
				f[u][v]=f[v][u]=e;
			}
			f[u].erase(x);
			f[v].erase(x);
			if(f[u].size()<=2){
				q.push(u);
			}
			if(f[v].size()<=2){
				q.push(v);
			}
		}
		f[x].clear();
	}
	cout<<ans<<'\n';
	
	cout.flush();
	
	/*fclose(stdin);
	fclose(stdout);*/
	return 0;
}

Luogu P10779 小 C 的独立集

给定简单无向图 \(G = (V, E)\),保证每条边属于且仅属于一个简单环,求 \(G\) 的最大独立集大小。

其实是一个仙人掌,但同样属于广义串并联图。考虑仙人掌太难写了懒得学,因此考虑广义串并联图的方法求解。

\(f_{e=(u,v),0/1,0/1}\) 表示边 \(e=(u,v)\)\(u\) 选/不选,\(v\) 选/不选的对应子图的最大独立集,实际维护中需要注意 \(f_{(u,v),i,j}=f_{(v,u),j,i}\)

考虑删一度点不好维护,设 \(g_{x,0/1}\) 表示当前选/不选点 \(x\) 且不考虑边,对于独立集的贡献。

  • 删一度点,设边为 \((x,v)\),有:

    \[g_{v,i}\leftarrow g_{v,i}+\max\left(g_{x,0}+f_{(x,v),0,i},g_{x,1}+f_{(x,v),1,i}\right) \]

  • 缩二度点,设 \((x,u),(x,v)\) 缩为 \((u,v)\),有:

    \[f_{(u,v),i,j}=\max\left(f_{(x,u),0,i}+f_{(x,v),0,j}+g_{x,0},f_{(x,u),1,i}+f_{(x,v),1,j}+g_{x,1}\right) \]

  • 叠合重边,设 \(e_1,e_2\) 叠合为 \(e\)

    这个比较简单:

    \[f_{e,i,j}=f_{e_1,i,j}+f_{e_2,i,j} \]

初始时,由定义可知 \(g_{x,0}=0,g_{x,1}=1\)\(f_{e,0,0}=f_{e,0,1}=f_{e,1,0}=0,f_{e,1,1}=-\infty\)

\(-\infty\) 可以避免转移过程中对于一条边两个端点都取的特判。

时间复杂度 \(\mathcal O(n)\)

参考代码
//#include<bits/stdc++.h>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<iomanip>
#include<cstdio>
#include<string>
#include<vector>
#include<cmath>
#include<ctime>
#include<deque>
#include<queue>
#include<stack>
#include<list>
#include<array>
#include<unordered_map>
using namespace std;
typedef array<array<int,2>,2> value;
constexpr const int N=5e4,inf=0x3f3f3f3f;
int n,g[N+1][2];
unordered_map<int,value>f[N+1];
value twist(value a,value b){
	for(int i=0;i<=1;i++){
		for(int j=0;j<=1;j++){
			if(a[i][j]==-inf||b[i][j]==-inf){
				a[i][j]=-inf;
			}else{
				a[i][j]+=b[i][j];
			}
		}
	}
	return a;
}
value inv(value a){
	return {a[0][0],a[1][0],a[0][1],a[1][1]};
}
int main(){
	/*freopen("test.in","r",stdin);
	freopen("test.out","w",stdout);*/
	
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	
	int m;
	cin>>n>>m;
	while(m--){
		int u,v;
		cin>>u>>v;
		if(f[u].count(v)){
			f[v][u]=inv(f[u][v]=twist(f[u][v],{0,0,0,-inf}));
		}else{
			f[v][u]=inv(f[u][v]={0,0,0,-inf});
		}
	}
	queue<int>q;
	for(int i=1;i<=n;i++){
		if(f[i].size()<=2){
			q.push(i);
		}
		g[i][1]=1;
	}
	int lst=0;
	while(q.size()){
		int x=q.front();q.pop();
		if(f[x].size()==1){
			auto [v,e]=*f[x].begin();
			for(int i=0;i<=1;i++){
				g[v][i]+=max(g[x][0]+e[0][i],g[x][1]+e[1][i]);
			}
			f[v].erase(x);
			if(f[v].size()<=2){
				q.push(v);
			}
		}else if(f[x].size()==2){
			auto [u,e1]=*f[x].begin();
			auto [v,e2]=*next(f[x].begin());
			value e;
			for(int i=0;i<=1;i++){
				for(int j=0;j<=1;j++){
					e[i][j]=max(e1[0][i]+e2[0][j]+g[x][0],e1[1][i]+e2[1][j]+g[x][1]);
				}
			}
			if(f[u].count(v)){
				f[v][u]=inv(f[u][v]=twist(f[u][v],e));
			}else{
				f[v][u]=inv(f[u][v]=e);
			}
			f[u].erase(x);
			f[v].erase(x);
			if(f[u].size()<=2){
				q.push(u);
			}
			if(f[v].size()<=2){
				q.push(v);
			}
		}
		f[x].clear();
		lst=x;
	}
	cout<<max(g[lst][0],g[lst][1])<<'\n';
	
	cout.flush();
	
	/*fclose(stdin);
	fclose(stdout);*/
	return 0;
}

Luogu P4426 [HNOI/AHOI2018] 毒瘤

给定一张 \(n\) 个点 \(m\) 条边的图,求独立集数量(包括空集)对 \(998244353\) 取模的结果。

\(n\leq10^5,n-1\leq m\leq n+10\)

容易发现 \(m-n\leq10\),因此可以通过广义串并联图方法缩图后跑 \(\mathcal O\left(2^{2k}\times3k\right)\) 的暴力。

同时类似于上一题,本题需要维护独立集的数量。

\(f_{(u,v),0/1,0/1}\) 为边 \((u,v)\)\(u\) 选/不选,\(v\) 选/不选时对应子图的独立集数量;\(g_{x,0/1}\)\(x\) 不考虑剩余边,\(x\) 选/不选时对应子图的独立集数量。

  • 删一度点 \(x\),边为 \((x,v)\)

    \[g_{v,i}\leftarrow g_{v,i}\sum_{j=0}^1g_{x,j}\cdot f_{(x,v),j,i} \]

  • 缩二度点, \((u,x),(v,x)\)\((u,v)\)

    \[f_{(u,v),i,j}=\sum_{k=0}^1f_{(x,u),k,i}\cdot f_{(x,v),k,j}\cdot g_{x,k} \]

  • 叠合重边 \(e_1,e_2\)\(e\)

    \[f_{e,i,j}=f_{e_1,i,j}\cdot f_{e_2,i,j} \]

缩完图后,如果原图没有边,只剩下缩完的点 \(x\),则答案为 \(g_{x,0}+g_{x,1}\)

否则,暴力枚举剩余每个点是否在独立集中,乘上对应的 \(g_{x,i},f_{e,i,j}\),求和即可。

参考代码
//#include<bits/stdc++.h>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<iomanip>
#include<cstdio>
#include<string>
#include<vector>
#include<cmath>
#include<ctime>
#include<deque>
#include<queue>
#include<stack>
#include<list>
#include<array>
#include<unordered_map>
using namespace std;
typedef array<array<int,2>,2> value;
constexpr const int N=1e5,K=10,P=998244353,inf=0x3f3f3f3f;
int n,ans,g[N+1][2];
unordered_map<int,value>f[N+1];
value twist(value a,value b){
	for(int i=0;i<=1;i++){
		for(int j=0;j<=1;j++){
			a[i][j]=1ll*a[i][j]*b[i][j]%P;
		}
	}
	return a;
}
value inv(value a){
	return {a[0][0],a[1][0],a[0][1],a[1][1]};
}
int id[K<<1|1];
bool mode[N+1];
void dfs(int p){
	if(p>n){
		int cnt=1;
		for(int i=1;i<=n;i++){
			int u=id[i];
			cnt=1ll*cnt*g[u][mode[u]]%P;
			for(auto [v,e]:f[u]){
				if(u<=v){
					cnt=1ll*cnt*e[mode[u]][mode[v]]%P;
				}
			}
		}
		ans=(ans+cnt)%P;
		return;
	}
	mode[id[p]]=0;
	dfs(p+1);
	mode[id[p]]=1;
	dfs(p+1);
}
main(){
	/*freopen("test.in","r",stdin);
	freopen("test.out","w",stdout);*/
	
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	
	int m;
	cin>>n>>m;
	while(m--){
		int u,v;
		cin>>u>>v;
		if(f[u].count(v)){
			f[v][u]=inv(f[u][v]=twist(f[u][v],{1,1,1,0}));
		}else{
			f[v][u]=inv(f[u][v]={1,1,1,0});
		}
	}
	queue<int>q;
	for(int i=1;i<=n;i++){
		if(f[i].size()<=2){
			q.push(i);
		}
		g[i][0]=g[i][1]=1;
	}
	int x;
	while(q.size()){
		x=q.front();q.pop();
		if(f[x].size()==1){
			auto [v,e]=*f[x].begin();
			for(int i=0;i<=1;i++){
				g[v][i]=(1ll*g[x][0]*e[0][i]+1ll*g[x][1]*e[1][i])%P*g[v][i]%P;
			}
			f[v].erase(x);
			if(f[v].size()<=2){
				q.push(v);
			}
		}else if(f[x].size()==2){
			auto [u,e1]=*f[x].begin();
			auto [v,e2]=*next(f[x].begin());
			value e;
			for(int i=0;i<=1;i++){
				for(int j=0;j<=1;j++){
					e[i][j]=(1ll*e1[0][i]*e2[0][j]%P*g[x][0] + 1ll*e1[1][i]*e2[1][j]%P*g[x][1])%P;
				}
			}
			if(f[u].count(v)){
				f[v][u]=inv(f[u][v]=twist(f[u][v],e));
			}else{
				f[v][u]=inv(f[u][v]=e);
			}
			f[u].erase(x);
			f[v].erase(x);
			if(f[u].size()<=2){
				q.push(u);
			}
			if(f[v].size()<=2){
				q.push(v);
			}
		}
		f[x].clear();
	}
	int cnt=0;
	for(int i=1;i<=n;i++){
		if(f[i].size()){
			id[++cnt]=i;
		}
	}
	n=cnt;
	if(n>0){
		dfs(1);
		cout<<ans<<'\n';
	}else{
		cout<<(g[x][0]+g[x][1])%P<<'\n';
	}
	
	cout.flush();
	
	/*fclose(stdin);
	fclose(stdout);*/
	return 0;
}

  1. 每条边都只在一个简单环中的图称之为仙人掌。多棵仙人掌构成的图称之为沙漠 ↩︎

posted @ 2026-02-23 21:11  TH911  阅读(1)  评论(0)    收藏  举报