图论详解
图论详解
本文系统讲解图论连通性相关算法与变形,包含 Tarjan 系列算法、DFS 树、割点、桥、点双连通分量、边双连通分量、强连通分量、缩点、圆方树、2-SAT 等内容。
代码统一使用vector邻接表存图,不使用动态指针,不使用链式前向星。
目录
- 基本概念与符号
- DFS 生成树与时间戳
- 无向图割点与割边(桥)
- 无向图边双连通分量(e-DCC)
- 无向图点双连通分量(v-DCC)
- 有向图强连通分量(SCC)
- 缩点与 DAG 应用
- 有向图弱连通、单向连通、强连通判定
- 2-SAT 与 SCC 变形
- 圆方树(Block-Cut Tree)
- 综合应用与经典变形
- 复杂度总结与模板整合
- 常见错误与调试技巧
第1章 基本概念与符号
1.1 图的定义
图 \(G=(V,E)\),其中 $ V $ 是顶点集合,\(E\) 是边集合。
- 无向图:边 \(e=(u,v)\) 是无序对,表示 \(u\) 与 \(v\) 互相连接。
- 有向图:边 \(e=(u,v)\) 是有序对,表示存在从 \(u\) 到 \(v\) 的有向边。
- 阶:图的顶点数,通常记为 \(n=|V|\)。
- 边数:图的边数,通常记为 \(m=|E|\)。
- 简单图:无重边、无自环的图。
- 重边:两个顶点之间有多条边。
- 自环:一条边连接一个顶点到它自身。
在竞赛代码中,我们通常用 \(n,m\) 表示顶点数和边数。
1.2 邻接表存图
无向图存储
对于无向图,每条边存两次:
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
vector<vector<int>> g(n+1);
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
}
return 0;
}
有向图存储
有向图只存一次:
vector<vector<int>> g(n+1);
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
}
如果题目需要处理重边或需要知道边的编号,可以存 pair<int,int> 表示 {邻接点, 边编号}:
vector<vector<pair<int,int>>> g(n+1);
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back({v,i});
g[v].push_back({u,i}); // 无向图
}
第2章 DFS 生成树与时间戳
2.1 深度优先搜索树
对无向连通图从任意一点出发进行 DFS,每个点第一次被访问时,从父亲到该点的边会形成一棵树,称为 DFS 生成树。
对于有向图,DFS 同样会形成一棵生成森林(如果图不连通)。
在 DFS 过程中,我们可以对边进行分类:
- 树边:DFS 树中的边,由父亲指向儿子。
- 返祖边(回边):无向图中,指向 DFS 树上祖先的非树边。
- 前向边:有向图中,由祖先指向后代的非树边。
- 交叉边:有向图中,连接两个无祖先后代关系的节点的边。
无向图有一个重要性质:无向图的非树边全是返祖边,不存在交叉边。这是因为无向图 DFS 时,如果访问到一个已经访问过的节点,该节点一定是当前节点的祖先。
2.2 时间戳 dfn 与 low
dfn[u]
\(dfn[u]\) 表示 DFS 第一次访问顶点 \(u\) 的时间戳,即访问顺序编号。
low[u]
\(low[u]\) 表示顶点 \(u\) 及其后代通过至多一条返祖边能到达的最小 \(dfn\) 值。
更形式化地:
在 Tarjan 算法中,low 值的正确维护是核心。
2.3 代码框架
vector<vector<int>> g;
vector<int> dfn,low;
int timer;
void dfs(int u,int fa){
dfn[u]=low[u]=++timer;
for(int v:g[u]){
if(v==fa) continue;
if(!dfn[v]){
dfs(v,u);
low[u]=min(low[u],low[v]);
}
else{
low[u]=min(low[u],dfn[v]);
}
}
}
在无向图中,必须处理父亲节点,避免重复走回父亲导致 low 被错误更新。如果存在重边,仅用父亲节点判断不够,需要记录边编号。
第3章 无向图割点与割边(桥)
3.1 割点定义
割点(cut vertex / articulation point):删除该顶点及其所有关联边后,图的连通分量数量增加。
形式化:若原图连通,删除顶点 \(u\) 后图不再连通,则 \(u\) 是割点。对于不连通图,只要在某一连通块中删除后该连通块分裂,则 \(u\) 是割点。
3.2 桥定义
桥(bridge / cut edge):删除该边后,图的连通分量数量增加。
若原图连通,删除边 \(e\) 后图不再连通,则 \(e\) 是桥。
3.3 定理与判定条件
定理 3.1(割点判定定理)
在无向图的 DFS 树中:
- 非根节点 \(u\) 是割点,当且仅当存在一个 DFS 树上的儿子 \(v\),满足:
- 根节点 \(u\) 是割点,当且仅当它在 DFS 树上有至少两个儿子。
定理 3.2(桥判定定理)
在无向图的 DFS 树中,树边 \((u,v)\)(\(u\) 是父亲,\(v\) 是儿子)是桥,当且仅当:
证明思路
若 \(low[v] \ge dfn[u]\),说明 \(v\) 及其后代无法通过返祖边回到 \(u\) 的祖先,因此删除 \(u\) 后,\(v\) 所在子树与 \(u\) 的祖先部分断开,故 \(u\) 是割点。
若 \(low[v] > dfn[u]\),说明 \(v\) 及其后代无法回到 \(u\),因此边 \((u,v)\) 是连接两个连通块的唯一路径,删除后图分裂,故是桥。
3.4 算法实现
3.4.1 无重边版本
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
vector<vector<int>> g;
vector<int> dfn,low;
int timer;
vector<int> cut;
vector<pair<int,int>> bridges;
void dfs(int u,int fa){
dfn[u]=low[u]=++timer;
int child=0;
for(int v:g[u]){
if(v==fa) continue;
if(!dfn[v]){
child++;
dfs(v,u);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u]&&fa!=0) cut[u]=1;
if(low[v]>dfn[u]) bridges.push_back({u,v});
}
else{
low[u]=min(low[u],dfn[v]);
}
}
if(fa==0&&child>1) cut[u]=1;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
g.assign(n+1,{});
dfn.assign(n+1,0);
low.assign(n+1,0);
cut.assign(n+1,0);
timer=0;
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
}
for(int i=1;i<=n;i++){
if(!dfn[i]) dfs(i,0);
}
vector<int> ans;
for(int i=1;i<=n;i++) if(cut[i]) ans.push_back(i);
cout<<ans.size()<<"\n";
for(int x:ans) cout<<x<<" ";
cout<<"\n";
cout<<bridges.size()<<"\n";
for(auto e:bridges) cout<<e.fi<<" "<<e.se<<"\n";
return 0;
}
3.4.2 有重边版本
如果图存在重边,仅用 fa 判断会出错。需要记录边编号,避免走回同一条边。
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
vector<vector<pair<int,int>>> g; // {v, edge_id}
vector<int> dfn,low;
int timer;
vector<int> cut;
vector<int> is_bridge;
void dfs(int u,int pre_id){
dfn[u]=low[u]=++timer;
int child=0;
for(auto e:g[u]){
int v=e.fi,id=e.se;
if(id==pre_id) continue;
if(!dfn[v]){
child++;
dfs(v,id);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u]&&pre_id!=0) cut[u]=1;
if(low[v]>dfn[u]) is_bridge[id]=1;
}
else{
low[u]=min(low[u],dfn[v]);
}
}
if(pre_id==0&&child>1) cut[u]=1;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
g.assign(n+1,{});
dfn.assign(n+1,0);
low.assign(n+1,0);
cut.assign(n+1,0);
is_bridge.assign(m+1,0);
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back({v,i});
g[v].push_back({u,i});
}
timer=0;
for(int i=1;i<=n;i++) if(!dfn[i]) dfs(i,0);
vector<int> ans;
for(int i=1;i<=n;i++) if(cut[i]) ans.push_back(i);
cout<<ans.size()<<"\n";
for(int x:ans) cout<<x<<" ";
cout<<"\n";
vector<int> br;
for(int i=1;i<=m;i++) if(is_bridge[i]) br.push_back(i);
cout<<br.size()<<"\n";
for(int x:br) cout<<x<<" ";
cout<<"\n";
return 0;
}
3.5 割点与桥的变形
变形1:求删除每个点后的连通块数量
设原图连通块数为 \(C\),删除点 \(u\) 后连通块数变为 \(C-1+child\),其中 \(child\) 是 DFS 树中满足 \(low[v]\ge dfn[u]\) 的儿子数量(若 \(u\) 是根则用 \(child\) 数量,若根且 \(child=0\) 则为0)。更具体地:
- 非根节点 \(u\):删除后连通块数增加 \(\text{满足 } low[v]\ge dfn[u]\text{ 的儿子数}\)。
- 根节点 \(u\):删除后连通块数增加 \(\max(0, child-1)\)。
变形2:求桥的编号并输出连接的点
使用有重边版本,记录边编号即可。
变形3:割边的在线维护
一般竞赛不要求,但可使用 LCT 或并查集缩边双动态维护,复杂度较高。
第4章 无向图边双连通分量(e-DCC)
4.1 定义
边双连通性:无向图中,如果任意两点之间存在两条边不相交的路径,则称该图是边双连通的。
等价定义:图中不存在桥。
边双连通分量(Edge-Biconnected Component, e-DCC):极大的边双连通子图。删除图中所有桥后,每个连通块就是一个边双连通分量。
4.2 定理
定理 4.1
一个无向连通图是边双连通的,当且仅当图中没有桥。
定理 4.2
边双连通分量内部任意两点之间不存在必经边。
定理 4.3
将所有边双连通分量缩成一个点后,原图变成一棵树(如果原图连通),树边就是原来的桥。这棵树称为 桥树。
4.3 算法一:删桥后 BFS/DFS
首先用 Tarjan 求出所有桥,然后标记这些桥,最后在忽略桥的图上进行 BFS/DFS,每个连通块就是一个 e-DCC。
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
vector<vector<pair<int,int>>> g;
vector<int> dfn,low;
vector<int> is_bridge;
int timer;
void dfs(int u,int pre_id){
dfn[u]=low[u]=++timer;
for(auto e:g[u]){
int v=e.fi,id=e.se;
if(id==pre_id) continue;
if(!dfn[v]){
dfs(v,id);
low[u]=min(low[u],low[v]);
if(low[v]>dfn[u]) is_bridge[id]=1;
}
else{
low[u]=min(low[u],dfn[v]);
}
}
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
g.assign(n+1,{});
dfn.assign(n+1,0);
low.assign(n+1,0);
is_bridge.assign(m+1,0);
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back({v,i});
g[v].push_back({u,i});
}
timer=0;
for(int i=1;i<=n;i++) if(!dfn[i]) dfs(i,0);
vector<int> dcc(n+1,0);
int cnt=0;
for(int i=1;i<=n;i++){
if(!dcc[i]){
cnt++;
queue<int> q;
q.push(i);
dcc[i]=cnt;
while(!q.empty()){
int u=q.front();q.pop();
for(auto e:g[u]){
int v=e.fi,id=e.se;
if(is_bridge[id]) continue;
if(!dcc[v]){
dcc[v]=cnt;
q.push(v);
}
}
}
}
}
cout<<cnt<<"\n";
for(int i=1;i<=n;i++) cout<<dcc[i]<<" ";
cout<<"\n";
return 0;
}
4.4 算法二:Tarjan 栈染色
在 DFS 过程中,当回溯时若 \(low[v]>dfn[u]\) 则为桥,否则将 \(v\) 加入当前 e-DCC。此方法略复杂,删桥法更常用。
4.5 变形与应用
变形1:最少加多少条边使无向图变为边双连通
先缩 e-DCC 得到桥树。设桥树叶子节点数为 \(leaf\),则最少需要加:
条边。若原图已经是边双连通,则答案为0。
变形2:桥树上路径问题
缩点成桥树后,任意两点之间的桥的数量等于它们在桥树上的路径长度。
变形3:动态加边维护边双
离线逆序用并查集缩 e-DCC,此处不展开。
第5章 无向图点双连通分量(v-DCC)
5.1 定义
点双连通性:无向图中,如果任意两点之间存在两条内部点不相交的路径(除起点和终点外),则称该图是点双连通的。
等价定义:任意两条边同属于一个简单环;图中不存在割点。
点双连通分量(Vertex-Biconnected Component, v-DCC):极大的点双连通子图。
注意:
- 一条边连接两个点,且这两个点只靠这条边连通,这两个点也构成一个点双。
- 孤立点本身是一个点双。
- 割点会同时属于多个点双分量。
5.2 定理
定理 5.1
点双连通分量之间至多共享一个点,即割点。
定理 5.2
每个非割点只属于一个 v-DCC,每个割点属于至少两个 v-DCC。
定理 5.3
把每个 v-DCC 看作一个方点,原图的每个顶点看作圆点,则每个割点连接它所属的所有 v-DCC,得到的图是一棵树,称为 圆方树(Block-Cut Tree)。
5.3 算法原理
维护一个边栈。DFS 访问边时压栈。当判定 \(low[v]\ge dfn[u]\) 时,从栈顶弹出边直到弹出边 \((u,v)\),这些边的端点构成一个 v-DCC。
5.4 算法实现
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
vector<vector<int>> g;
vector<int> dfn,low;
int timer;
vector<vector<int>> bcc;
vector<pair<int,int>> stk;
void dfs(int u,int fa){
dfn[u]=low[u]=++timer;
int child=0;
for(int v:g[u]){
if(v==fa) continue;
if(!dfn[v]){
stk.push_back({u,v});
child++;
dfs(v,u);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u]){
vector<int> comp;
while(true){
auto e=stk.back();stk.pop_back();
int a=e.fi,b=e.se;
comp.push_back(a);
comp.push_back(b);
if(a==u&&b==v) break;
}
sort(comp.begin(),comp.end());
comp.erase(unique(comp.begin(),comp.end()),comp.end());
bcc.push_back(comp);
}
}
else if(dfn[v]<dfn[u]){
stk.push_back({u,v});
low[u]=min(low[u],dfn[v]);
}
}
if(fa==0&&child==0){
bcc.push_back({u});
}
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
g.assign(n+1,{});
dfn.assign(n+1,0);
low.assign(n+1,0);
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
}
timer=0;
for(int i=1;i<=n;i++) if(!dfn[i]) dfs(i,0);
cout<<bcc.size()<<"\n";
for(auto &c:bcc){
cout<<c.size()<<" ";
for(int x:c) cout<<x<<" ";
cout<<"\n";
}
return 0;
}
5.5 圆方树构建
在求出所有 v-DCC 后,可以构建圆方树。
- 圆点:原图的 \(n\) 个顶点,编号 \(1\sim n\)。
- 方点:每个 v-DCC 一个,编号 \(n+1\sim n+bcc.size()\)。
建边:每个方点连接它包含的所有圆点。
int N=n+bcc.size();
vector<vector<int>> T(N+1);
for(int i=0;i<bcc.size();i++){
int f=n+i+1;
for(int x:bcc[i]){
T[f].push_back(x);
T[x].push_back(f);
}
}
5.6 圆方树应用
- 求两点路径上的必经点(割点):路径上的圆点就是必经点。
- 求点双路径点权和:对圆方树做树上前缀和或 LCA。
- 判断某点是否是两点间的必经点:等价于该点在圆方树上两点路径上。
第6章 有向图强连通分量(SCC)
6.1 定义
有向图中,若任意两个顶点 \(u,v\) 互相可达,则称该图是强连通的。
强连通分量(Strongly Connected Component, SCC):极大的强连通子图。
另有:
- 弱连通:将有向边视为无向边后,图连通。
- 单向连通:对任意两点 \(u,v\),存在 \(u\to v\) 或 \(v\to u\) 的路径。
6.2 定理
定理 6.1
SCC 之间不存在环。缩点后得到有向无环图(DAG)。
定理 6.2
每个 SCC 是顶点集上的等价类。
定理 6.3
Kosaraju 算法和 Tarjan 算法均可在 \(O(n+m)\) 时间内求出所有 SCC。
6.3 Tarjan 求 SCC
维护 dfn 和 low,栈记录当前 DFS 路径上尚未确定 SCC 的节点。当 \(dfn[u]=low[u]\) 时弹出栈直到 \(u\),这些点构成一个 SCC。
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
vector<vector<int>> g;
vector<int> dfn,low,belong,instk;
vector<int> stk;
int timer,scc_cnt;
void dfs(int u){
dfn[u]=low[u]=++timer;
stk.push_back(u);
instk[u]=1;
for(int v:g[u]){
if(!dfn[v]){
dfs(v);
low[u]=min(low[u],low[v]);
}
else if(instk[v]){
low[u]=min(low[u],dfn[v]);
}
}
if(dfn[u]==low[u]){
scc_cnt++;
int v;
do{
v=stk.back();stk.pop_back();
instk[v]=0;
belong[v]=scc_cnt;
}while(v!=u);
}
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
g.assign(n+1,{});
dfn.assign(n+1,0);
low.assign(n+1,0);
belong.assign(n+1,0);
instk.assign(n+1,0);
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
}
timer=0;scc_cnt=0;
for(int i=1;i<=n;i++) if(!dfn[i]) dfs(i);
cout<<scc_cnt<<"\n";
for(int i=1;i<=n;i++) cout<<belong[i]<<" ";
cout<<"\n";
return 0;
}
6.4 Kosaraju 算法
两次 DFS:
- 对原图做 DFS,记录点的完成时间顺序。
- 在反向图中按完成时间逆序做 DFS,每个 DFS 连通块是一个 SCC。
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
vector<vector<int>> g,rg;
vector<int> order,comp,vis;
void dfs1(int u){
vis[u]=1;
for(int v:g[u]) if(!vis[v]) dfs1(v);
order.push_back(u);
}
void dfs2(int u,int c){
comp[u]=c;
for(int v:rg[u]) if(!comp[v]) dfs2(v,c);
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
g.assign(n+1,{});
rg.assign(n+1,{});
vis.assign(n+1,0);
comp.assign(n+1,0);
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
rg[v].push_back(u);
}
for(int i=1;i<=n;i++) if(!vis[i]) dfs1(i);
int cnt=0;
for(int i=order.size()-1;i>=0;i--){
int u=order[i];
if(!comp[u]) dfs2(u,++cnt);
}
cout<<cnt<<"\n";
for(int i=1;i<=n;i++) cout<<comp[i]<<" ";
cout<<"\n";
return 0;
}
6.5 Gabow 算法
Gabow 算法也是 \(O(n+m)\),使用两个栈,但实际竞赛中 Tarjan 和 Kosaraju 已足够。
第7章 缩点与 DAG 应用
7.1 缩点
将每个 SCC 缩成一个新点,建立新图。新图一定是有向无环图(DAG)。
vector<vector<int>> ng(scc_cnt+1);
vector<int> in(scc_cnt+1),out(scc_cnt+1);
for(int u=1;u<=n;u++){
for(int v:g[u]){
if(belong[u]!=belong[v]){
ng[belong[u]].push_back(belong[v]);
out[belong[u]]++;
in[belong[v]]++;
}
}
}
// 去重
for(int i=1;i<=scc_cnt;i++){
sort(ng[i].begin(),ng[i].end());
ng[i].erase(unique(ng[i].begin(),ng[i].end()),ng[i].end());
}
7.2 DAG 上最长路径 / 路径计数
缩点后拓扑排序 + DP。
vector<int> topo;
queue<int> q;
for(int i=1;i<=scc_cnt;i++) if(in[i]==0) q.push(i);
while(!q.empty()){
int u=q.front();q.pop();
topo.push_back(u);
for(int v:ng[u]){
in[v]--;
if(in[v]==0) q.push(v);
}
}
vector<int> dp(scc_cnt+1);
for(int i=topo.size()-1;i>=0;i--){
int u=topo[i];
for(int v:ng[u]){
dp[u]=max(dp[u],dp[v]+1);
}
}
第8章 有向图弱连通、单向连通、强连通判定
8.1 弱连通
将有向边视为无向边,做 DFS/BFS 判断连通性。
8.2 强连通
SCC 数量是否为 1。
8.3 单向连通
先缩点得到 DAG。单向连通当且仅当 DAG 存在一条路径经过所有点。判定方法:
- 入度为 0 的点唯一;
- 拓扑排序过程中,任意时刻队列中最多一个点;
- 且拓扑序中相邻两点在原 DAG 中有边。
若满足,则原图单向连通。
第9章 2-SAT 与 SCC 变形
9.1 2-SAT 问题
给定 \(n\) 个布尔变量 \(x_1,\ldots,x_n\),和 \(m\) 个子句,每个子句是两个文字的逻辑或,例如:
要求判断是否存在一组赋值使所有子句为真。
9.2 建图
对于每个变量 \(x_i\),建立两个点:
- 点 \(i\) 表示 \(x_i=true\);
- 点 \(i+n\) 表示 \(x_i=false\)。
每个子句 \((a\lor b)\) 添加两条蕴含边:
其中 \(a,b\) 是文字。
9.3 SCC 判定
若 \(x_i\) 和 \(\neg x_i\) 在同一个 SCC 中,则无解;否则有解。
赋值时,选择拓扑序较后的 SCC 对应的文字为真。
9.4 代码框架
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
vector<vector<int>> g;
vector<int> dfn,low,belong,instk;
vector<int> stk;
int timer,scc_cnt;
void dfs(int u){
dfn[u]=low[u]=++timer;
stk.push_back(u);instk[u]=1;
for(int v:g[u]){
if(!dfn[v]){
dfs(v);
low[u]=min(low[u],low[v]);
}
else if(instk[v]) low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u]){
scc_cnt++;
int v;
do{
v=stk.back();stk.pop_back();
instk[v]=0;belong[v]=scc_cnt;
}while(v!=u);
}
}
int id(int x,int val){
return x+n*val;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
g.assign(2*n+1,{});
dfn.assign(2*n+1,0);
low.assign(2*n+1,0);
belong.assign(2*n+1,0);
instk.assign(2*n+1,0);
for(int i=1;i<=m;i++){
int a,va,b,vb;
cin>>a>>va>>b>>vb;
int na=a+n*(va^1);
int nb=b+n*(vb^1);
int pa=a+n*va;
int pb=b+n*vb;
g[na].push_back(pb);
g[nb].push_back(pa);
}
for(int i=1;i<=2*n;i++) if(!dfn[i]) dfs(i);
for(int i=1;i<=n;i++){
if(belong[i]==belong[i+n]){
cout<<"IMPOSSIBLE\n";
return 0;
}
}
cout<<"POSSIBLE\n";
for(int i=1;i<=n;i++){
cout<<(belong[i]>belong[i+n])<<" ";
}
return 0;
}
第10章 圆方树(Block-Cut Tree)
10.1 定义
圆方树是无向图点双连通分量的一种树形表示。
- 圆点:原图顶点。
- 方点:每个点双连通分量。
- 边:每个方点与它包含的所有圆点连边。
圆方树是一棵树,常用于处理与割点、点双路径相关的问题。
10.2 构建
在前面求出 v-DCC 的基础上:
int N=n+bcc.size();
vector<vector<int>> T(N+1);
for(int i=0;i<bcc.size();i++){
int f=n+i+1;
for(int x:bcc[i]){
T[f].push_back(x);
T[x].push_back(f);
}
}
10.3 应用
- 两点间路径上的割点:圆方树上路径经过的圆点。
- 点双路径点权和:树上差分或前缀和。
- 判断必经点:点 \(c\) 是 \(u,v\) 间的必经点当且仅当 \(c\) 是圆方树上 \(u,v\) 路径上的圆点。
第11章 综合应用与经典变形
11.1 割点 + 点双统计
问题:对于每个点,求删除它后图的连通块数。
先求割点,并统计每个割点的“分裂儿子数”。
vector<int> sz(n+1); // 分裂数
// 在 dfs 中:
if(low[v]>=dfn[u]){
sz[u]++;
}
// 答案:原图连通块数 - 1 + sz[u]
11.2 桥 + 边双:最少加边使全图边双
缩 e-DCC 成桥树,叶子数 \(leaf\),答案 \(\lceil leaf/2\rceil\)。
11.3 SCC 缩点 + 拓扑排序/DP
最长路径、路径计数、可达性查询。
11.4 2-SAT 建图
用于约束满足问题,如每对中选一个、有冲突等。
11.5 圆方树路径问题
处理无向图必经点、点双路径。
11.6 动态加边询问连通性
离线逆序并查集,配合 Tarjan 预处理。
第12章 复杂度总结与模板整合
| 算法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| Tarjan 割点 | \(O(n+m)\) | \(O(n+m)\) | 无向图割点 |
| Tarjan 桥 | \(O(n+m)\) | \(O(n+m)\) | 无向图桥 |
| e-DCC | \(O(n+m)\) | \(O(n+m)\) | 无向图边双 |
| v-DCC | \(O(n+m)\) | \(O(n+m)\) | 无向图点双 |
| Kosaraju SCC | \(O(n+m)\) | \(O(n+m)\) | 有向图强连通 |
| Tarjan SCC | \(O(n+m)\) | \(O(n+m)\) | 有向图强连通 |
| 2-SAT | \(O(n+m)\) | \(O(n+m)\) | 布尔约束 |
| 圆方树 | \(O(n+m)\) | \(O(n+m)\) | 点双路径 |
第13章 常见错误与调试技巧
- 无向图未处理父亲节点:导致
low被更新为父亲,误判割点或桥。 - 重边未记录边编号:导致桥漏判。
- 根节点割点判断错误:根节点需要至少两个儿子。
- SCC 栈标记未更新:弹栈后忘记
instk[v]=0。 - 缩点后重边未去重:可能导致拓扑排序出度入度计算错误。
- 2-SAT 变量编号混乱:注意真假点的映射。
#define int long long后main未写signed:导致编译错误。- DFS 递归深度过大:可使用递归或手工栈;竞赛中通常无问题。
low更新时机错误:有向图中只有栈内节点才更新low[u]。- v-DCC 边栈未清空:应在每次找到一个 v-DCC 后立即弹出相应边。

浙公网安备 33010602011771号