AIGC标识 拆点,边拆点,虚点,隐式建图

C++ 图论建模高级技巧完全详解(超长版)

本文系统讲解:拆点、边拆点、虚点、隐式建图。
代码统一使用数组模拟,不使用动态指针,不使用链式前向星(用 vector 存图)。
含公式、定理、推论、专业名词解释、ASCII 图示、典型例题与完整代码。


目录

  1. 拆点(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 最小路径覆盖
  2. 边拆点(Edge Splitting)
    2.1 边拆点导论
    2.2 边权转点权
    2.3 边容量(网络流)
    2.4 边状态拆点
    2.5 线段树优化建图
    2.6 前缀和优化建图
    2.7 典型例题:线段树建图
  3. 虚点(Virtual Vertex)
    3.1 虚点导论
    3.2 超级源点与超级汇点
    3.3 辅助点减少边数
    3.4 虚点处理区间连边
    3.5 虚树中的虚点
    3.6 典型例题:网络流建图
  4. 隐式建图(Implicit Graph)
    4.1 隐式建图导论
    4.2 状态空间搜索
    4.3 康托展开与哈希
    4.4 双向 BFS
    4.5 IDA*
    4.6 典型例题:八数码
  5. 综合例题精讲
  6. 卡常与调试技巧
  7. 总结与习题清单

1. 拆点(Vertex Splitting)

1.1 拆点导论

拆点(Vertex Splitting):把一个点拆成多个点,用来处理点权、点容量、状态、时间等限制。

为什么需要拆点

很多图论算法的限制是边上的:

  • 最短路:边有权值。
  • 网络流:边有容量。
  • 匹配:边表示关系。

但实际问题中,限制可能在点上:

  • 点有权值(经过花费)。
  • 点有容量(经过次数限制)。
  • 点有状态(方向、颜色、时间)。

拆点就是把点上的限制转化为边上的限制,从而套用标准算法。

拆点的核心思想

把点变成边。具体做法:

  1. 把一个点 \(u\) 拆成两个点 \(u_{in}\) 和 \(u_{out}\)。
  2. 在 \(u_{in}\) 和 \(u_{out}\) 之间连一条边,表示点的限制。
  3. 所有原来的入边连 \(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)\):

  1. 新建点 \(e\)。
  2. 连 \(u\to e\) 和 \(e\to v\)。
  3. 点 \(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

题意:三种操作:

  1. 点 \(u\) 向点 \(v\) 连边。
  2. 点 \(u\) 向区间 \([l,r]\) 内所有点连边。
  3. 区间 \([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,用虚点连接。

构建

  1. 对关键点按 DFS 序排序。
  2. 用栈构建虚树。
  3. 虚点(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 康托展开与哈希

康托展开

把排列映射到整数:

\[X=a_1(n-1)!+a_2(n-2)!+\cdots+a_n\cdot 0! \]

其中 \(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 常见错误

  1. 拆点后编号重复。
  2. 点容量拆点忘记入边/出边。
  3. 状态拆点层数错误。
  4. 线段树建图节点数不够。
  5. 隐式图哈希冲突。
  6. 双向 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 之路上越走越远!

posted @ 2026-09-27 08:01  Aries2011  阅读(31)  评论(0)    收藏  举报