拆点,边拆点,虚点,隐式建图
C++ 图论建模高级技巧完全详解(超长版)
本文系统讲解:拆点、边拆点、虚点、隐式建图。
代码统一使用数组模拟,不使用动态指针,不使用链式前向星(用vector存图)。
含公式、定理、推论、专业名词解释、ASCII 图示、典型例题与完整代码。
目录
- 拆点(Vertex Splitting)
1.1 拆点导论
1.2 点权转边权
1.3 点容量(网络流最小割)
1.4 状态拆点(分层图)
1.5 时间拆点
1.6 奇偶拆点(二分图判定)
1.7 二分图匹配中的拆点
1.8 有向图最小路径覆盖中的拆点
1.9 典型例题:P4568 飞行路线
1.10 典型例题:P1345 奶牛的电信
1.11 典型例题:P2764 最小路径覆盖 - 边拆点(Edge Splitting)
2.1 边拆点导论
2.2 边权转点权
2.3 边容量(网络流)
2.4 边状态拆点
2.5 线段树优化建图
2.6 前缀和优化建图
2.7 典型例题:线段树建图 - 虚点(Virtual Vertex)
3.1 虚点导论
3.2 超级源点与超级汇点
3.3 辅助点减少边数
3.4 虚点处理区间连边
3.5 虚树中的虚点
3.6 典型例题:网络流建图 - 隐式建图(Implicit Graph)
4.1 隐式建图导论
4.2 状态空间搜索
4.3 康托展开与哈希
4.4 双向 BFS
4.5 IDA*
4.6 典型例题:八数码 - 综合例题精讲
- 卡常与调试技巧
- 总结与习题清单
1. 拆点(Vertex Splitting)
1.1 拆点导论
拆点(Vertex Splitting):把一个点拆成多个点,用来处理点权、点容量、状态、时间等限制。
为什么需要拆点
很多图论算法的限制是边上的:
- 最短路:边有权值。
- 网络流:边有容量。
- 匹配:边表示关系。
但实际问题中,限制可能在点上:
- 点有权值(经过花费)。
- 点有容量(经过次数限制)。
- 点有状态(方向、颜色、时间)。
拆点就是把点上的限制转化为边上的限制,从而套用标准算法。
拆点的核心思想
把点变成边。具体做法:
- 把一个点 \(u\) 拆成两个点 \(u_{in}\) 和 \(u_{out}\)。
- 在 \(u_{in}\) 和 \(u_{out}\) 之间连一条边,表示点的限制。
- 所有原来的入边连 \(u_{in}\),出边从 \(u_{out}\) 出发。
原图: 拆点后:
u -> v u_in --(点限制)--> u_out --> v_in --(点限制)--> v_out
拆点的类型
| 类型 | 用途 | 关键 |
|---|---|---|
| 点权转边权 | 最短路 | 点权加到入/出边 |
| 点容量 | 网络流最小割 | 入点-出点连容量边 |
| 状态拆点 | 分层图 | 每层一个状态 |
| 时间拆点 | 动态图 | 每个时刻一层 |
| 奇偶拆点 | 二分图 | 按奇偶分层 |
| 匹配拆点 | 二分图匹配 | 左右部拆点 |
| 路径覆盖拆点 | 最小路径覆盖 | 每个点拆成入/出 |
1.2 点权转边权
问题
给定有向图,每个点有权值 \(w_u\),经过点 \(u\) 需要花费 \(w_u\)(起点和终点也计算),求 \(s\) 到 \(t\) 的最短路。
做法
方法一:把边 \((u,v)\) 的权值改为 \(w_v\)(或 \(w_u\))。
- 若边权改为 \(w_v\):从 \(s\) 出发,走到 \(v\) 时加上 \(w_v\)。最后加 \(w_s\)。
- 若边权改为 \(w_u\):从 \(u\) 出发时加上 \(w_u\)。最后加 \(w_t\)。
// 边 (u,v) 的原权为 w
// 修改后边权为 w + val[v]
for(auto e:g[u])
{
int v=e.fi;
int w=e.se;
new_w=w+val[v];
}
方法二:拆点。把 \(u\) 拆成 \(u_{in}\) 和 \(u_{out}\),中间连权值为 \(w_u\) 的边。入边连 \(u_{in}\),出边从 \(u_{out}\) 出发。
int id_in(int u){return u*2;}
int id_out(int u){return u*2+1;}
// u_in -> u_out 权 val[u]
// 原边 (u,v,w):u_out -> v_in 权 w
示例
问题:\(n=4\) 的有向图,点权 \(w=[1,2,3,4]\),边 \(1\to 2, 2\to 3, 3\to 4\),求 \(1\to 4\) 的最短路。
方法一:边权改为目标点权。
- \(1\to 2\) 权 \(2\)
- \(2\to 3\) 权 \(3\)
- \(3\to 4\) 权 \(4\)
- 加起点 \(w_1=1\)
- 总权 \(1+2+3+4=10\)
方法二:拆点。
- \(1_{in}\to 1_{out}\) 权 1
- \(2_{in}\to 2_{out}\) 权 2
- \(3_{in}\to 3_{out}\) 权 3
- \(4_{in}\to 4_{out}\) 权 4
- \(1_{out}\to 2_{in}\) 权 0
- \(2_{out}\to 3_{in}\) 权 0
- \(3_{out}\to 4_{in}\) 权 0
- 从 \(1_{in}\) 到 \(4_{out}\) 最短路 = 1+2+3+4 = 10
复杂度
- 时间:与原图相同。
- 空间:点数翻倍。
1.3 点容量(网络流最小割)
问题
给定有向图,每个点 \(u\) 有容量 \(cap_u\),边容量为 \(\infty\),求 \(s\) 到 \(t\) 的最大流(即最小割,割掉点使 \(s,t\) 不连通)。
做法
把点 \(u\) 拆成 \(u_{in}\) 和 \(u_{out}\):
- \(u_{in}\to u_{out}\) 容量 \(cap_u\)。
- 原边 \((u,v)\) 改为 \(u_{out}\to v_{in}\),容量 \(\infty\)。
原图: 拆点后:
u -> v u_in --cap_u--> u_out --inf--> v_in --cap_v--> v_out
原理
最小割割掉的是 \(u_{in}\to u_{out}\) 这条边,代价为 \(cap_u\),对应"删掉点 \(u\)"。边容量 \(\infty\) 保证不会割边。
代码
void add_edge(int u,int v,int w)
{
// 添加边 u -> v 容量 w
}
void build(int n)
{
for(int u=1;u<=n;u++)
{
add_edge(u,u+n,cap[u]);
}
for(auto e:edges)
{
int u=e.u;
int v=e.v;
add_edge(u+n,v,inf);
}
}
典型例题:P1345 奶牛的电信
题意:给定无向图,求最少删去多少个点使 \(s\) 和 \(t\) 不连通。
做法:点拆成入点和出点,中间容量 1,边容量 inf,求最小割。
#include<bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=1e5+5;
const int mod=998244353;
const int inf=1e18;
struct Edge
{
int to,nxt,cap;
};
vector<Edge>g[N];
int head[N],tot=1;
void add(int u,int v,int w)
{
g[u].push_back({v,(int)g[v].size(),w});
g[v].push_back({u,(int)g[u].size()-1,0});
}
int dep[N],cur[N];
bool bfs(int s,int t)
{
memset(dep,0,sizeof(dep));
queue<int>q;
q.push(s);
dep[s]=1;
while(!q.empty())
{
int u=q.front();
q.pop();
for(auto e:g[u])
{
if(e.cap&&!dep[e.to])
{
dep[e.to]=dep[u]+1;
q.push(e.to);
}
}
}
return dep[t];
}
int dfs(int u,int t,int flow)
{
if(u==t)return flow;
int res=0;
for(int &i=cur[u];i<(int)g[u].size();i++)
{
auto &e=g[u][i];
if(e.cap&&dep[e.to]==dep[u]+1)
{
int f=dfs(e.to,t,min(flow-res,e.cap));
if(f)
{
e.cap-=f;
g[e.to][e.nxt].cap+=f;
res+=f;
if(res==flow)break;
}
}
}
return res;
}
int dinic(int s,int t)
{
int ans=0;
while(bfs(s,t))
{
memset(cur,0,sizeof(cur));
ans+=dfs(s,t,inf);
}
return ans;
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m,s,t;
cin>>n>>m>>s>>t;
for(int i=1;i<=n;i++)
{
if(i==s||i==t)add(i,i+n,inf);
else add(i,i+n,1);
}
for(int i=1;i<=m;i++)
{
int u,v;
cin>>u>>v;
add(u+n,v,inf);
add(v+n,u,inf);
}
cout<<dinic(s,t)<<"\n";
return 0;
}
1.4 状态拆点(分层图)
问题
给定图,每个点有多个状态,状态之间可以转移,求最短路。
做法
把 \((u,state)\) 作为新节点,边表示状态转移。
常见状态
- 方向:\((u,dir)\),表示到达 \(u\) 时的方向。
- 颜色:\((u,color)\),表示到达 \(u\) 时的颜色。
- 次数:\((u,cnt)\),表示用了 \(cnt\) 次特殊操作。
- 奇偶:\((u,parity)\),表示路径长度的奇偶。
编号方法
int id(int u,int state)
{
return u*K+state; // K 为状态数
}
典型例题:P4568 飞行路线
题意:\(n\) 个点,\(m\) 条边,可免费走 \(k\) 条边,求 \(s\) 到 \(t\) 的最短路。
做法:拆成 \(k+1\) 层,\((u,j)\) 表示在 \(u\) 用了 \(j\) 次免费机会。
- 普通边:\((u,j)\to(v,j)\),权 \(w\)。
- 免费边:\((u,j)\to(v,j+1)\),权 \(0\)。
int id(int u,int j)
{
return u*(k+1)+j;
}
// 普通边
g[id(u,j)].push_back({id(v,j),w});
// 免费边
if(j<k)g[id(u,j)].push_back({id(v,j+1),0});
完整代码:
#include<bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=1e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,k,s,t;
vector<pair<int,int>>g[N*12];
int dist[N*12];
int id(int u,int j)
{
return u*(k+1)+j;
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m>>k>>s>>t;
for(int i=1;i<=m;i++)
{
int u,v,w;
cin>>u>>v>>w;
for(int j=0;j<=k;j++)
{
g[id(u,j)].push_back({id(v,j),w});
g[id(v,j)].push_back({id(u,j),w});
if(j<k)
{
g[id(u,j)].push_back({id(v,j+1),0});
g[id(v,j)].push_back({id(u,j+1),0});
}
}
}
for(int i=0;i<=n*(k+1);i++)dist[i]=inf;
priority_queue<pair<int,int>,vector<pair<int,int>>,greater<pair<int,int>>>pq;
dist[id(s,0)]=0;
pq.push({0,id(s,0)});
while(!pq.empty())
{
auto [d,u]=pq.top();
pq.pop();
if(d>dist[u])continue;
for(auto e:g[u])
{
int v=e.fi;
int w=e.se;
if(dist[v]>d+w)
{
dist[v]=d+w;
pq.push({dist[v],v});
}
}
}
int ans=inf;
for(int j=0;j<=k;j++)ans=min(ans,dist[id(t,j)]);
cout<<ans<<"\n";
return 0;
}
状态拆点的复杂度
- 点数:\(O(nK)\)。
- 边数:\(O(mK)\)。
- 时间:\(O((n+m)K\log(nK))\)。
1.5 时间拆点
问题
每个时刻图不同,求最短路。
做法
把 \((u,t)\) 作为节点,边表示时刻 \(t\) 到 \(t+1\) 的状态转移。
int id(int u,int t)
{
return u*T+t;
}
典型例题:P2754 家园 / 星际转移问题
题意:地球到月球,飞船按固定路线循环,每个时刻运载能力有限,求最少时间。
做法:按时间拆点 + 网络流。
1.6 奇偶拆点(二分图判定)
问题
判断图是否为二分图,或处理奇偶性约束。
做法
每个点拆成两个,\((u,0)\) 和 \((u,1)\),边连接相反奇偶。
- 若原图有边 \((u,v)\),则 \((u,0)\to(v,1)\),\((u,1)\to(v,0)\)。
- 若 \((u,0)\) 和 \((u,1)\) 连通,则不是二分图。
代码
int id(int u,int p)
{
return u*2+p;
}
bool check_bipartite(int n)
{
vector<int>color(n*2,-1);
queue<int>q;
for(int i=0;i<n*2;i++)
{
if(color[i]!=-1)continue;
color[i]=0;
q.push(i);
while(!q.empty())
{
int u=q.front();
q.pop();
for(int v:g[u])
{
if(color[v]==-1)
{
color[v]=color[u]^1;
q.push(v);
}
else if(color[v]==color[u])return false;
}
}
}
return true;
}
1.7 二分图匹配中的拆点
问题
二分图最大匹配。
做法
把左部点 \(u\) 拆成 \(u_L\),右部点 \(v\) 拆成 \(v_R\),边 \((u,v)\) 改为 \(u_L\to v_R\),跑最大流。
1.8 有向图最小路径覆盖中的拆点
问题
DAG 最小路径覆盖。
做法
每个点 \(u\) 拆成 \(u_{in}\) 和 \(u_{out}\):
- 源点 \(S\to u_{in}\),容量 1。
- \(u_{out}\to T\),容量 1。
- 原边 \((u,v)\):\(u_{out}\to v_{in}\),容量 1。
- 最小路径覆盖 = \(n-\) 最大匹配。
典型例题:P2764 最小路径覆盖问题
#include<bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=1e5+5;
const int mod=998244353;
const int inf=1e18;
struct Edge
{
int to,nxt,cap;
};
vector<Edge>g[N];
int dep[N],cur[N];
void add(int u,int v,int w)
{
g[u].push_back({v,(int)g[v].size(),w});
g[v].push_back({u,(int)g[u].size()-1,0});
}
bool bfs(int s,int t)
{
memset(dep,0,sizeof(dep));
queue<int>q;
q.push(s);
dep[s]=1;
while(!q.empty())
{
int u=q.front();
q.pop();
for(auto e:g[u])
{
if(e.cap&&!dep[e.to])
{
dep[e.to]=dep[u]+1;
q.push(e.to);
}
}
}
return dep[t];
}
int dfs(int u,int t,int flow)
{
if(u==t)return flow;
int res=0;
for(int &i=cur[u];i<(int)g[u].size();i++)
{
auto &e=g[u][i];
if(e.cap&&dep[e.to]==dep[u]+1)
{
int f=dfs(e.to,t,min(flow-res,e.cap));
if(f)
{
e.cap-=f;
g[e.to][e.nxt].cap+=f;
res+=f;
if(res==flow)break;
}
}
}
return res;
}
int dinic(int s,int t)
{
int ans=0;
while(bfs(s,t))
{
memset(cur,0,sizeof(cur));
ans+=dfs(s,t,inf);
}
return ans;
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
int S=0,T=2*n+1;
for(int i=1;i<=n;i++)
{
add(S,i,1);
add(i+n,T,1);
}
for(int i=1;i<=m;i++)
{
int u,v;
cin>>u>>v;
add(u,v+n,1);
}
int match=dinic(S,T);
cout<<n-match<<"\n";
return 0;
}
1.9 典型例题:P4568 飞行路线
见 1.4。
1.10 典型例题:P1345 奶牛的电信
见 1.3。
1.11 典型例题:P2764 最小路径覆盖
见 1.8。
2. 边拆点(Edge Splitting)
2.1 边拆点导论
边拆点(Edge Splitting):把一条边拆成一个点,原边变为两条边经过该点。
为什么需要边拆点
- 边权转点权:算法只支持点权。
- 边容量:网络流中点有额外限制。
- 边状态:边有方向、颜色等状态。
- 线段树建图:区间连边。
- 减少边数:一个点连向多个点。
核心思想
把边变成点。对于边 \((u,v)\):
- 新建点 \(e\)。
- 连 \(u\to e\) 和 \(e\to v\)。
- 点 \(e\) 携带原边的信息。
原图: 边拆点后:
u --w--> v u --0--> e --0--> v
(点权 w)
2.2 边权转点权
问题
边有权值,但算法只支持点权(如某些匹配、覆盖问题)。
做法
把每条边 \((u,v,w)\) 拆成点 \(e\),连 \(u\to e\) 和 \(e\to v\),点 \(e\) 权值为 \(w\)。
示例
问题:二分图最大权匹配。
做法:边拆点,转成点权匹配。
2.3 边容量(网络流)
问题
边有容量,但点有额外限制。
做法
边拆成点,点容量设为边容量。
示例
问题:每条边最多经过 1 次。
做法:边拆点,点容量 1。
2.4 边状态拆点
问题
边有方向、颜色等状态。
做法
边拆成点,用点表示状态。
示例
问题:每条边有颜色,路径要求颜色交替。
做法:边拆点,按颜色分层。
2.5 线段树优化建图
问题
一个点向区间 \([l,r]\) 内所有点连边,或区间内所有点向一个点连边。
暴力做法
直接连 \(O(n)\) 条边,太慢。
线段树优化
建立线段树,用线段树节点作为虚点:
- 点 → 区间:点连到线段树节点,线段树节点连到区间内的点。
- 区间 → 点:线段树节点连到点。
建树
// 两棵线段树:入树和出树
// 入树:父 -> 子(向下连边)
// 出树:子 -> 父(向上连边)
int id_in(int p){return p;}
int id_out(int p){return p+4*n;}
void build(int p,int l,int r)
{
if(l==r)
{
// 叶子节点连原图点
return;
}
int mid=(l+r)>>1;
build(p<<1,l,mid);
build(p<<1|1,mid+1,r);
// 入树:p -> 儿子
add(id_in(p),id_in(p<<1),inf);
add(id_in(p),id_in(p<<1|1),inf);
// 出树:儿子 -> p
add(id_out(p<<1),id_out(p),inf);
add(id_out(p<<1|1),id_out(p),inf);
}
连边
- 点 \(u\) → 区间 \([l,r]\):\(u\to id\_in\) 覆盖节点。
- 区间 \([l,r]\) → 点 \(u\):\(id\_out\) 覆盖节点 \(\to u\)。
复杂度
- 点数:\(O(n)\)。
- 边数:\(O((n+m)\log n)\)。
典型例题:CF 786B Legacy
题意:三种操作:
- 点 \(u\) 向点 \(v\) 连边。
- 点 \(u\) 向区间 \([l,r]\) 内所有点连边。
- 区间 \([l,r]\) 内所有点向点 \(u\) 连边。
求最短路。
#include<bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=1e6+5;
const int mod=998244353;
const int inf=1e18;
int n,m,s;
vector<pair<int,int>>g[N];
int dist[N];
int id_in(int p){return p;}
int id_out(int p){return p+4*n;}
void add(int u,int v,int w)
{
g[u].push_back({v,w});
}
void build(int p,int l,int r)
{
if(l==r)
{
add(id_in(p),l,0);
add(l,id_out(p),0);
return;
}
int mid=(l+r)>>1;
build(p<<1,l,mid);
build(p<<1|1,mid+1,r);
add(id_in(p),id_in(p<<1),0);
add(id_in(p),id_in(p<<1|1),0);
add(id_out(p<<1),id_out(p),0);
add(id_out(p<<1|1),id_out(p),0);
}
void update_in(int p,int l,int r,int ql,int qr,int u,int w)
{
if(ql<=l&&r<=qr)
{
add(u,id_in(p),w);
return;
}
int mid=(l+r)>>1;
if(ql<=mid)update_in(p<<1,l,mid,ql,qr,u,w);
if(qr>mid)update_in(p<<1|1,mid+1,r,ql,qr,u,w);
}
void update_out(int p,int l,int r,int ql,int qr,int u,int w)
{
if(ql<=l&&r<=qr)
{
add(id_out(p),u,w);
return;
}
int mid=(l+r)>>1;
if(ql<=mid)update_out(p<<1,l,mid,ql,qr,u,w);
if(qr>mid)update_out(p<<1|1,mid+1,r,ql,qr,u,w);
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m>>s;
build(1,1,n);
while(m--)
{
int op;
cin>>op;
if(op==1)
{
int u,v,w;
cin>>u>>v>>w;
add(u,v,w);
}
else if(op==2)
{
int u,l,r,w;
cin>>u>>l>>r>>w;
update_in(1,1,n,l,r,u,w);
}
else
{
int u,l,r,w;
cin>>u>>l>>r>>w;
update_out(1,1,n,l,r,u,w);
}
}
for(int i=1;i<N;i++)dist[i]=inf;
priority_queue<pair<int,int>,vector<pair<int,int>>,greater<pair<int,int>>>pq;
dist[s]=0;
pq.push({0,s});
while(!pq.empty())
{
auto [d,u]=pq.top();
pq.pop();
if(d>dist[u])continue;
for(auto e:g[u])
{
int v=e.fi;
int w=e.se;
if(dist[v]>d+w)
{
dist[v]=d+w;
pq.push({dist[v],v});
}
}
}
for(int i=1;i<=n;i++)
{
if(dist[i]==inf)cout<<-1<<" ";
else cout<<dist[i]<<" ";
}
cout<<"\n";
return 0;
}
2.6 前缀和优化建图
问题
点 \(i\) 向 \(1\sim i-1\) 连边。
做法
建立前缀和点 \(pre_i\),\(pre_i\to pre_{i-1}\),点 \(i\to pre_{i-1}\)。
2.7 典型例题:线段树建图
见 2.5。
3. 虚点(Virtual Vertex)
3.1 虚点导论
虚点(Virtual Vertex):人为引入的不存在的点,用于简化建图、减少边数。
常见用途
- 超级源点/汇点:多源多汇。
- 辅助点减少边数:一个点连向多个点。
- 区间连边:用虚点连接区间。
- 虚树:只保留关键点。
- 并查集虚点:合并集合。
3.2 超级源点与超级汇点
问题
多个源点、多个汇点。
做法
建立超级源点 \(S\) 连所有源点,超级汇点 \(T\) 连所有汇点。
示例
问题:网络流多源多汇。
做法:
for(int s:src)add(S,s,inf);
for(int t:dst)add(t,T,inf);
3.3 辅助点减少边数
问题
点 \(u\) 连向集合 \(\{v_1,v_2,\dots,v_k\}\)。
暴力做法
连 \(k\) 条边。
辅助点做法
建立辅助点 \(x\),连 \(u\to x\),\(x\to v_i\)。边数从 \(k\) 变为 \(k+1\),但当多个点连向同一集合时,边数大幅减少。
示例
问题:\(n\) 个点,每个点连向所有其他点。
暴力:\(O(n^2)\) 条边。
辅助点:建立虚点 \(x\),所有点连 \(x\),\(x\) 连所有点。边数 \(O(n)\)。
3.4 虚点处理区间连边
问题
点 \(u\) 向区间 \([l,r]\) 连边。
做法
建立虚点表示区间,或用线段树。
示例
问题:点 \(u\) 向 \([l,r]\) 连边。
做法:建立 \(O(\log n)\) 个虚点,每个虚点连接一个区间。
3.5 虚树中的虚点
虚树
虚树:只保留关键点和它们的 LCA,用虚点连接。
构建
- 对关键点按 DFS 序排序。
- 用栈构建虚树。
- 虚点(LCA)连接关键点。
典型例题:P2495 消耗战
3.6 典型例题:网络流建图
P2764 最小路径覆盖
见 1.8。
P2774 方格取数
题意:\(n\times m\) 方格,每个格有数,取数不能相邻,求最大和。
做法:二分图最小割。按奇偶染色,源点连奇格,偶格连汇点,相邻格连边,求最小割。
#include<bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=1e5+5;
const int mod=998244353;
const int inf=1e18;
struct Edge
{
int to,nxt,cap;
};
vector<Edge>g[N];
int dep[N],cur[N];
void add(int u,int v,int w)
{
g[u].push_back({v,(int)g[v].size(),w});
g[v].push_back({u,(int)g[u].size()-1,0});
}
bool bfs(int s,int t)
{
memset(dep,0,sizeof(dep));
queue<int>q;
q.push(s);
dep[s]=1;
while(!q.empty())
{
int u=q.front();
q.pop();
for(auto e:g[u])
{
if(e.cap&&!dep[e.to])
{
dep[e.to]=dep[u]+1;
q.push(e.to);
}
}
}
return dep[t];
}
int dfs(int u,int t,int flow)
{
if(u==t)return flow;
int res=0;
for(int &i=cur[u];i<(int)g[u].size();i++)
{
auto &e=g[u][i];
if(e.cap&&dep[e.to]==dep[u]+1)
{
int f=dfs(e.to,t,min(flow-res,e.cap));
if(f)
{
e.cap-=f;
g[e.to][e.nxt].cap+=f;
res+=f;
if(res==flow)break;
}
}
}
return res;
}
int dinic(int s,int t)
{
int ans=0;
while(bfs(s,t))
{
memset(cur,0,sizeof(cur));
ans+=dfs(s,t,inf);
}
return ans;
}
int n,m;
int id(int x,int y)
{
return (x-1)*m+y;
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
int S=0,T=n*m+1;
int sum=0;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
int x;
cin>>x;
sum+=x;
if((i+j)%2==0)
{
add(S,id(i,j),x);
if(i>1)add(id(i,j),id(i-1,j),inf);
if(i<n)add(id(i,j),id(i+1,j),inf);
if(j>1)add(id(i,j),id(i,j-1),inf);
if(j<m)add(id(i,j),id(i,j+1),inf);
}
else
{
add(id(i,j),T,x);
}
}
}
cout<<sum-dinic(S,T)<<"\n";
return 0;
}
4. 隐式建图(Implicit Graph)
4.1 隐式建图导论
隐式建图(Implicit Graph):不显式存储所有边,而是在需要时生成邻接点。适用于状态空间巨大的图。
适用场景
- 状态空间大,无法显式存储。
- 状态转移规则明确。
- 需要搜索最短路或方案数。
常见应用
- 八数码、十五数码。
- 推箱子、华容道。
- 魔方。
- 状态压缩 DP。
4.2 状态空间搜索
八数码
状态:3×3 棋盘,0 表示空格。
转移:空格与相邻格交换。
目标:从初始状态到目标状态最少步数。
BFS + 哈希
int bfs(vector<int>start,vector<int>target)
{
unordered_map<string,int>dist;
queue<vector<int>>q;
dist[encode(start)]=0;
q.push(start);
while(!q.empty())
{
auto u=q.front();
q.pop();
if(u==target)return dist[encode(u)];
// 生成邻居
for(...)
{
auto v=u;
swap(v[i],v[j]);
string key=encode(v);
if(!dist.count(key))
{
dist[key]=dist[encode(u)]+1;
q.push(v);
}
}
}
return -1;
}
4.3 康托展开与哈希
康托展开
把排列映射到整数:
其中 \(a_i\) 是第 \(i\) 位后面比它小的数的个数。
int fact[10];
void init_fact()
{
fact[0]=1;
for(int i=1;i<=9;i++)fact[i]=fact[i-1]*i;
}
int cantor(int a[],int n)
{
int res=0;
for(int i=0;i<n;i++)
{
int cnt=0;
for(int j=i+1;j<n;j++)
{
if(a[j]<a[i])cnt++;
}
res+=cnt*fact[n-i-1];
}
return res;
}
逆康托展开
void decantor(int x,int n,int a[])
{
vector<int>v;
for(int i=1;i<=n;i++)v.push_back(i);
for(int i=0;i<n;i++)
{
int r=x%fact[n-i-1];
int q=x/fact[n-i-1];
x=r;
sort(v.begin(),v.end());
a[i]=v[q];
v.erase(v.begin()+q);
}
}
4.4 双向 BFS
思想
从起点和终点同时 BFS,相遇时停止。
复杂度
从 \(O(b^d)\) 降到 \(O(b^{d/2})\)。
代码
int bidirectional_bfs(State start,State target)
{
unordered_map<State,int>d1,d2;
queue<State>q1,q2;
d1[start]=0;
d2[target]=0;
q1.push(start);
q2.push(target);
while(!q1.empty()&&!q2.empty())
{
if(q1.size()>q2.size())swap(q1,q2),swap(d1,d2);
int sz=q1.size();
while(sz--)
{
auto u=q1.front();
q1.pop();
for(auto v:neighbors(u))
{
if(d2.count(v))return d1[u]+1+d2[v];
if(!d1.count(v))
{
d1[v]=d1[u]+1;
q1.push(v);
}
}
}
}
return -1;
}
4.5 IDA*
思想
迭代加深 + 估价函数。
八数码估价
曼哈顿距离之和。
int h(State s)
{
int res=0;
for(int i=0;i<9;i++)
{
if(s.a[i]==0)continue;
int x=i/3;
int y=i%3;
int tx=(s.a[i]-1)/3;
int ty=(s.a[i]-1)%3;
res+=abs(x-tx)+abs(y-ty);
}
return res;
}
4.6 典型例题:八数码
题意:3×3 棋盘,0 表示空格,求从初始状态到目标状态的最少步数。
做法:BFS + 康托展开判重。
#include<bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=1e6+5;
const int mod=998244353;
const int inf=1e18;
int fact[10];
void init_fact()
{
fact[0]=1;
for(int i=1;i<=9;i++)fact[i]=fact[i-1]*i;
}
int cantor(int a[])
{
int res=0;
for(int i=0;i<9;i++)
{
int cnt=0;
for(int j=i+1;j<9;j++)
{
if(a[j]<a[i])cnt++;
}
res+=cnt*fact[8-i];
}
return res;
}
void decantor(int x,int a[])
{
vector<int>v;
for(int i=1;i<=9;i++)v.push_back(i);
for(int i=0;i<9;i++)
{
int q=x/fact[8-i];
x%=fact[8-i];
sort(v.begin(),v.end());
a[i]=v[q];
v.erase(v.begin()+q);
}
}
int dx[]={-1,1,0,0};
int dy[]={0,0,-1,1};
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
init_fact();
int start[9];
for(int i=0;i<9;i++)cin>>start[i];
int target[9]={1,2,3,4,5,6,7,8,9};
int s=cantor(start);
int t=cantor(target);
vector<int>dist(fact[9],-1);
queue<int>q;
dist[s]=0;
q.push(s);
while(!q.empty())
{
int u=q.front();
q.pop();
if(u==t)break;
int a[9];
decantor(u,a);
int pos=0;
for(int i=0;i<9;i++)if(a[i]==9)pos=i;
int x=pos/3;
int y=pos%3;
for(int d=0;d<4;d++)
{
int nx=x+dx[d];
int ny=y+dy[d];
if(nx<0||nx>=3||ny<0||ny>=3)continue;
int npos=nx*3+ny;
swap(a[pos],a[npos]);
int v=cantor(a);
if(dist[v]==-1)
{
dist[v]=dist[u]+1;
q.push(v);
}
swap(a[pos],a[npos]);
}
}
cout<<dist[t]<<"\n";
return 0;
}
5. 综合例题精讲
5.1 P4011 孤岛营救
题意:迷宫,有门和钥匙,求最短路。
做法:状态拆点 \((x,y,keys)\),BFS。
5.2 P2590 树的统计
题意:树上路径查询。
做法:树链剖分。
5.3 P1345 奶牛的电信
见 1.3。
5.4 P4568 飞行路线
见 1.4。
5.5 P2764 最小路径覆盖
见 1.8。
5.6 P2774 方格取数
见 3.6。
5.7 CF 786B Legacy
见 2.5。
6. 卡常与调试技巧
6.1 卡常
- 用
vector存图,避免链式前向星。 - 用数组,不用指针。
- 用快读。
- 拆点时注意编号计算。
- 隐式图注意状态压缩和哈希冲突。
- 线段树建图注意节点编号。
6.2 常见错误
- 拆点后编号重复。
- 点容量拆点忘记入边/出边。
- 状态拆点层数错误。
- 线段树建图节点数不够。
- 隐式图哈希冲突。
- 双向 BFS 未交换队列。
6.3 调试方法
- 小数据暴力对拍。
- 打印图结构。
- 检查点数、边数。
7. 总结与习题清单
7.1 核心要点
- 拆点:点权转边权、点容量、状态、时间、奇偶。
- 边拆点:边权转点权、边容量、线段树建图。
- 虚点:超级源汇、辅助点、虚树。
- 隐式建图:状态空间搜索、康托展开、双向 BFS。
7.2 习题清单
| 题目 | 难度 | 考点 |
|---|---|---|
| P4568 | 中等 | 分层图 |
| P1345 | 中等 | 点拆+最小割 |
| P4011 | 中等 | 状态拆点 |
| P2590 | 中等 | 树链剖分 |
| 八数码 | 中等 | 隐式图+BFS |
| P2764 | 中等 | 最小路径覆盖 |
| P2774 | 中等 | 方格取数 |
| CF 786B | 较难 | 线段树建图 |
| P2495 | 难 | 虚树 |
| P2754 | 较难 | 时间拆点 |
7.3 推荐练习顺序
P4568 → P1345 → P4011 → 八数码 → P2764 → P2774 → CF 786B → P2754 → P2495。
结语
拆点、边拆点、虚点、隐式建图是图论建模的核心技巧。掌握它们可以解决大量复杂问题。建议多做例题,熟悉各种变形。祝你在 OI/IOI 之路上越走越远!
本文来自博客园,作者:Aries2011,转载请注明原文链接:https://www.cnblogs.com/Aries2011/p/23131012

浙公网安备 33010602011771号