【圖論技巧】點邊轉化(拆點和拆邊)

一、總述

在圖論中,一張圖由點和邊構成。而點和邊都可以作爲信息的載體,比如說點權和邊權。儘管點和邊看似如此接近,但是它們的性質確實截然不同的。點表示的是一種實質上的狀態,而邊表示的是一種虛擬的狀態間的轉移。

因此,有一些圖論算法只能處理點上的信息,而另一些圖論算法只能處理邊上的信息。怎樣使得這些針對性的算法通用化呢?某些情況下,我們可以通過拆點和拆邊的方式來解決。

二、常見的有針對性的算法

- 針對點權

樹鏈剖分(套線段樹或樹狀數組)

  • Link-Cut Tree
  • 倍增
  • 強連通分量縮點

- 針對邊權

最短路

  • 最小生成樹
  • 網絡流
  • 匈牙利算法
  • 拓撲排序

容易看出,數據結構型的算法一般針對點權,因爲維護的是實體上的數據;而圖論算法一般容易維護邊權,因爲在點與點之間通過邊轉移時,容易將邊權一起轉移走。

由於無向邊可以當做兩條有向邊,因此下文均以有向邊介紹。

三、拆點

- 過程

對於某個點權爲 w 的點 v,我們可以把 v 點拆成 v1 和 v2 兩個點,其中 v1 稱爲入點,v2 稱爲出點。從 v1→v2 連一條權值爲 ​w 的有向邊。此外對於圖上原本連接某兩點 x 和 y,權值爲 z 的有向邊,改爲從 x2→y1 連權值爲 z 的邊。這就是拆點的主要過程。
在這裏插入圖片描述在這裏插入圖片描述

上圖說明了拆點的過程。

- 實例

帶點權和邊權的最短路
其實這個不用拆點也能做,就用這個來作爲拆點的入門好了。

有一張 n 個點,m 條邊的有向圖,點有點權,邊有邊權。定義一條路徑的長度爲這條路徑經過的所有點的點權和加上經過的所有邊的邊權和。求 1
號點到 n 號點的最短路。

將點拆成入點和出點,從入點向出點連權值爲該點點權的邊。直接跑一遍起點爲 1 號點的入點,終點爲 n 號點的出點的單源最短路即可。

void solve() {
  cin >> N >> M;
  for (int i = 1; i <= N; ++i) {
    int x; cin >> x; // 輸入 i 點點權
    add_edge(i, i + N, x);
  }
  for (int i = 1; i <= M; ++i) {
    int u, v, w; // 一條從 u 到 v 權值爲 w 的單向邊
    cin >> u >> v >> w;
    add_edge(u + N, v, w);
  }

 	Dijkstra(1); // 做一次源點爲 1 的單源最短路徑
  printf("%d\n", dis[N + N]); // N 號點的出點的距離即爲答案
}

- 網絡流

網絡流上的流量都在邊上,因此網絡流屬於針對邊權的典型圖論算法。當點上有權值時,都以拆點的形式解決。

直接舉一道例題 方格取數加強版

給出一個 n×n 的矩陣,每一格有一個非負整數 Ai,j (Ai,j≤1000)。現在從 (1,1) 出發,可以往右或者往下走,最後到達
(n,n)。每達到一格,把該格子的數取出來,該格子的數就變成 0,這樣一共走 K 次,現在要求 K 次所達到的方格的數的和最大。

很明顯,這裏的權值在點上。考慮拆點,將每個點拆成入點和出點。

由於每個格子的數只能取一次,因此我們從入點向出點連一條流量爲 1,權值爲 Ai,j 的邊。由於可以無數次經過,再從入點向出點連流量爲 ∞,權值爲 0 的邊。

同時爲了轉移,我們從一個格子的出點向其右方、下方的格子的入點連流量爲 ∞,權值爲 0 的邊。

最後求解原圖上 (1,1) 的入點到 (n,n) 的出點的流量爲 K​ 的最小費用流即可。
拆點技巧:

int num(int i,int j,int k){
    return (i - 1) * n + j + k * n * n;
}

具體講解:
C++學習筆記:圖論——拆點詳解

#include<iostream>
#include<algorithm>
#include<cstdio>
#include<math.h>
#include<cstring>
#include<queue>
//#define ls (p<<1)
//#define rs (p<<1|1)
#define over(i,s,t) for(register int i = s;i <= t;++i)
#define lver(i,t,s) for(register int i = t;i >= s;--i)
//#define int __int128
//#define lowbit(p) p&(-p)
using namespace std;

typedef long long ll;
typedef pair<int,int> PII;
const ll INF = 1e18;
const int N = 5e3+7;
const int M = 5e5+7;
int maxflow,s,t,k;
int n,m,ans,e;
int head[N],ver[M],nex[M],edge[M],cost[M],tot;
bool vis[N];
int dis[N],incf[N],pre[N];

void add(int x,int y,int z,int c){//正邊反邊
    ver[++tot] = y;edge[tot] = z;cost[tot] = c;
    nex[tot] = head[x];head[x] = tot;
    ver[++tot] = x;edge[tot] = 0;cost[tot] = -c;
    nex[tot] = head[y];head[y] = tot;
}

int num(int i,int j,int k){
    return (i - 1) * n + j + k * n * n;
}

bool spfa(){//spfa求最長路
    queue<int>q;
    memset(vis,0,sizeof vis);
    memset(dis,0xcf,sizeof dis);//-INF
    q.push(s);
    dis[s] = 0;vis[s] = 1;
    incf[s] = 1<<30;//增廣路各邊的最小剩餘容量
    while(q.size()){
        int x = q.front();q.pop();
        vis[x] = 0;//spfa的操作
        for(int i = head[x];i;i = nex[i]){
            if(edge[i]){//剩餘容量要>0,纔在殘餘網絡中
                int y = ver[i];
                if(dis[y] < dis[x] + cost[i]){
                    dis[y] = dis[x] + cost[i];
                    incf[y] = min(incf[x],edge[i]);//最小剩餘容量
                    pre[y] = i;//記錄前驅(前向星編號),方便找到最長路的實際方案
                    if(!vis[y])
                        vis[y] = 1,q.push(y);
                }
            }
        }
    }
    if(dis[t] == 0xcfcfcfcf)
        return false;//匯點不可達,已求出最大流
    return true;
}

//EK的老操作了,更新最長增廣路及其反向邊的剩餘容量
void update(){
    int x = t;
    while(x != s){
        int i = pre[x];
        edge[i] -= incf[t];
        edge[i ^ 1] += incf[t];//成對變換,反邊加
        x = ver[i ^ 1];//反邊回去的地方就是上一個結點
    }
    maxflow += incf[t];//順便求最大流
    ans += dis[t] * incf[t];//題目要求
}

void EK(){
    while(spfa())//瘋狂找增廣路
        update();
}

int main(){
    cin>>n>>k;
    s = 1;t = 2 * n * n;
    tot = 1;
    over(i,1,n)
    over(j,1,n){
        int c;
        scanf("%d",&c);
        add(num(i,j,0),num(i,j,1),1,c);//自己(入點0)與自己(出點1)
        add(num(i,j,0),num(i,j,1),k-1,0);//兩條邊(取k次嘛,第一次有值,以後就沒值了,用作下次選取)
        if(i < n)add(num(i,j,1),num(i+1,j,0),k,0);//自己(出點1)與下一行(入點0)或者下一列(入點0)
        if(j < n)add(num(i,j,1),num(i,j+1,0),k,0);
    }
    EK();
    printf("%d\n",ans);
    return 0;
}

四、拆邊

當維護的是有根樹的邊權時,有一種更爲方便的做法——權值下推。

我們可以讓每個點維護其與其父親的這條邊的信息。即對於某個點 x,設其父親節點爲 f,從 f 到 x 的邊權值爲 w,那麼我們可以直接讓 x 點的點權加上 w。並且當更改邊權 w 時,可以直接在點 x 的點權上修改。

特殊的是,當我們查詢樹上 u→v 路徑信息時,我們需要減掉 lca(u,v) 的額外維護的邊權,因爲 lca(u,v) 維護的是在它上面的那條邊的信息,不是我們需要的路徑信息。

- 過程

對於一條連接 u, v ,權值爲 w 的有向邊 e,我們可以通過新建一個點 x,並將 x 的點權設爲 w,從 u→x 和 v→x 各連一條有向邊。這就是拆邊的主要過程。

在這裏插入圖片描述
上圖說明了拆邊的過程。

通過拆邊,我們就讓維護點權的數據結構可以維護邊權。

- 實例

- 倍增算法(Kruskal 重構樹)

最小生成樹是一種針對邊權的算法,但是在一些生成樹的題中,我們希望能夠快速維護邊權的信息。那麼此時就可以在生成樹時直接拆邊,已達到我們的目的。這種最小生成樹算法被稱爲 Kruskal 重構樹。

Kruskal 重構樹執行的過程與最小生成樹的 Kruskal 算法類似:

  • 將原圖的每一個節點看作一棵子樹。
  • 合併兩棵子樹時,通過並查集找到它們子樹對應的根節點,記作 u, v。
  • 新開一個節點 p,從 p 分別向 u, v 兩點連邊。於是 u 和 v 兩棵子樹就併到了一棵子樹,根節點就是 p,並將 p 點權值賦爲 u↔v 這條邊的權值。

在代碼上可以如此實現:

const int MaxN = 100000 + 5, MaxV = 200000 + 5;

int N, M;
int cntv = N;  // 圖中點數
int par[MaxV]; // 並查集(注意大小開爲原圖兩倍)
int val[MaxV]; // 生成樹中各點點權
struct edge { int u, v, w; } E[MaxM];
vector<int> Tree[MaxV];

void Kruskal() {
  for (int i = 1; i <= N + N - 1; ++i) par[i] = i;
  sort(E + 1, E + 1 + M, cmp); // 按權值從小到大排序

  for (int i = 1; i <= N; ++i) val[i] = 0;
  for (int i = 1; i <= M; ++i) {
    int u = E[i].u, v = E[i].v;
    int p = Find(u), q = Find(v);
    if (p == q) continue;

    cntv++;
    par[p] = par[q] = cntv;
    val[cntv] = E[i].w;
    Tree[cntv].push_back(p);
    Tree[cntv].push_back(q);
  }
}

我們可以發現這樣建出來的最小生成樹(最大生成樹同理)有如下性質:

  • 原最小生成樹上 u 到 v 路徑上的邊權和就是現在 u 到 v 路徑上的點權和。
  • 這是一個大根堆,也是一個二叉堆。
  • 原最小生成樹上 u 到 v 路徑上的最大值,就是 u, v 的最近公共祖先(LCA)的權值。故求最小瓶頸路時,可以使用 Kruskal 重構樹的方法。

那麼我們就看一道簡單的例題:

[NOIP2013 提高組] 貨車運輸

給定一個 n 個點,m 條邊的無向圖,邊上有權值。並有 q 次詢問,每次詢問輸入兩個點 u, v,找出一條路徑,使得從 u 到 v
路徑上的最小值最大,並輸出這個最大的最小值;若從 u 不能到達 v,輸出 −1.

使用 Kruskal 重構樹算法建出最大生成樹後,直接查詢 u, v 兩點的 LCA 權值即可。

#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

const int MAXN = 10000, MAXM = 50000, MAXQ = 30000;
const int MAXV = 20000, MAXE = 30000, MAXLOG = 20;

int N, M, Q;
int U[MAXM+1], V[MAXM+1], W[MAXM+1];
int lnk[MAXM+1];
int X[MAXQ+1], Y[MAXQ+1], ans[MAXQ+1];
int Head[MAXV+1], To[MAXE+1], Next[MAXE+1];
int fa[MAXV+1][MAXLOG+1], val[MAXV+1], depth[MAXV+1]; // 樹的信息
int par[MAXV+1], par2[MAXV+1];
int Head2[MAXV+1], To2[MAXQ*2+1], Next2[MAXQ*2+1], Num[MAXQ*2+1];
bool vis[MAXV+1];
int vs, es, qs;

void init() {
  memset(fa, -1, sizeof fa );
  memset(depth, -1, sizeof depth );
  memset(val, 0x7F, sizeof val );
  scanf("%d %d", &N, &M);
  for (int i = 1; i <= N * 2; ++i) par[i] = i, par2[i] = i;
  for (int i = 1; i <= M; ++i) {
    lnk[i] = i;
    scanf("%d %d %d", &U[i], &V[i], &W[i]);
  }
  scanf("%d", &Q);
  for (int i = 1; i <= Q; ++i) scanf("%d %d", &X[i], &Y[i]);
}

inline void add_edge(int from, int to) {
  es++;
  To[es] = to;
  Next[es] = Head[from];
  Head[from] = es;
}

inline void add_query(int from, int to, int num) {
  qs++;
  To2[qs] = to;
  Num[qs] = num;
  Next2[qs] = Head2[from];
  Head2[from] = qs;
}

inline bool cmp(int x, int y) { return W[x] > W[y]; }

int Find(int x) { return par[x] == x ? x : par[x] = Find(par[x]); }
int Find2(int x) { return par2[x] == x ? x : par2[x] = Find2(par2[x]); }

// Kruskal 重構樹
void Kruskal() {
  sort(lnk + 1, lnk + 1 + M, cmp);
  vs = N;

  for (int I = 1; I <= M; ++I) {
    int i = lnk[I], u = U[i], v = V[i], w = W[i];
    int p = Find(u), q = Find(v);
    if (p != q) {
      vs++;
      add_edge(vs, p), add_edge(vs, q);
      val[vs] = w;
      par[p] = par[q] = vs;
    }
  }

  // 處理森林的情況
  for (int i = 1; i <= N; ++i) add_edge(0, Find(i));
  val[0] = -1;
}

void dfs(int u) {
  for (int i = Head[u]; i; i = Next[i]) {
    int v = To[i];
    if (depth[v] != -1) continue;
    depth[v] = depth[u] + 1;
    fa[v][0] = u;
    for (int j = 1; ( 1 << j ) <= depth[v]; ++j)
      fa[v][j] = fa[fa[v][ j - 1 ]][j - 1];
    dfs(v);
  }
}

void Tarjan(int u) {
  for (int i = Head[u]; i; i = Next[i]) {
    int v = To[i];
    if (vis[v] == true) continue;
    Tarjan(v);
    par2[v] = u;
    vis[v] = true;
  }
  for (int i = Head2[u]; i; i = Next2[i]) {
    int v = To2[i], n = Num[i];
    if (vis[v]) ans[n] = Find2(v);
  }
}

void solve() {
  Kruskal();
  depth[0] = 0;
  dfs(0);

  for (int i = 1; i <= Q; ++i)
    add_query(X[i], Y[i], i),
    add_query(Y[i], X[i], i);
  Tarjan(0);
  for ( int i = 1; i <= Q; ++i )
    printf("%d\n", val[ans[i]]);
}

int main() {
  init();
  solve();
  return 0;
}

- LCT 維護最小生成樹

還是同一個問題,最小生成樹一種邊權圖,而 LCT 是一種維護點權的數據結構。

老套路,直接拆點。我們可以直接把所有邊對應的點建好。然後每次斷邊時斷掉兩條邊,連邊時連上兩條邊。

在這裏插入圖片描述

再看一道簡單的模板題:

[WC2006] 水管局長

有一張 n 個點,m 條邊的圖,邊有邊權。你需要動態維護兩種操作:

  • 某一條邊消失。
  • 詢問 u 到 v 的最小瓶頸路。

將詢問翻轉,即從後往前做。然後就變成了動態加邊的最小生成樹問題,詢問時相當於問 u, v 在最小生成樹的路徑上最大權值。直接拆點用 LCT 維護即可。

#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

const int MaxN = 1000 + 5, MaxM = 100000 + 5, MaxQ = 100000 + 5;
const int MaxV = 101000 + 5;

int N, M, Q;
struct edge { int u, v, w, id; bool ok; } E[MaxM];
int Opt[MaxQ], X[MaxQ], Y[MaxQ];
int Mp[MaxN][MaxN], par[MaxN];
int st[MaxQ], tp;

struct LCT {
#define lson ch[0]
#define rson ch[1]
  int fa[MaxV], ch[2][MaxV];
  int val[MaxV], maxid[MaxV];
  bool rev[MaxV];

  inline int getson(int x, int f) { return rson[f] == x; }
  inline void reverse(int x) { swap(lson[x], rson[x]); }
  inline bool is_root(int x) { return lson[fa[x]] != x && rson[fa[x]] != x; }

  inline void update(int x) {
    int ls = lson[x], rs = rson[x];
    if (val[maxid[ls]] > val[maxid[rs]]) maxid[x] = maxid[ls];
    else maxid[x] = maxid[rs];
    if (val[x] > val[maxid[x]]) maxid[x] = x;
  }

  inline void push_down(int x) {
    if (rev[x] == true) {
      reverse(lson[x]); reverse(rson[x]);
      rev[lson[x]] = !rev[lson[x]]; rev[rson[x]] = !rev[rson[x]];
      rev[x] = false;
    }
  }

  inline void rotate(int x) {
    int f = fa[x], g = fa[f];
    int l = getson(x, f);

    if (is_root(f) == false) ch[getson(f, g)][g] = x;
    if (ch[l ^ 1][x] != 0) fa[ch[l ^ 1][x]] = f;
    fa[x] = g; fa[f] = x;
    ch[l][f] = ch[l ^ 1][x]; ch[l ^ 1][x] = f;
    update(f);
  }

  void erase_tag(int x) {
    if (is_root(x) == false) erase_tag(fa[x]);
    push_down(x);
  }

  inline void splay(int x) {
    erase_tag(x);
    while (is_root(x) == false) {
      int f = fa[x], g = fa[f];
      if (is_root(f) == false) {
        if (getson(f, g) == getson(x, f)) rotate(f);
        else rotate(x);
      }
      rotate(x);
    }
    update(x);
  }

  inline void access(int f) {
    int x = 0;
    while (f != 0) {
      splay(f); rson[f] = x;
      update(f);
      x = f, f = fa[f];
    }
  }

  inline void make_root(int x) {
    access(x); splay(x);
    rev[x] = !rev[x]; reverse(x);
  }

  inline void split(int x, int y) {
    make_root(x);
    access(y); splay(y);
  }

  inline void link(int x, int y) {
    make_root(x);
    fa[x] = y;
  }

  inline void cut(int x, int y) {
    split(x, y);
    fa[x] = lson[y] = 0;
    update(y);
  }
} T;

void init() {
  scanf("%d %d %d", &N, &M, &Q);
  for (int i = 1; i <= M; ++i) scanf("%d %d %d", &E[i].u, &E[i].v, &E[i].w);
  for (int i = 1; i <= Q; ++i) scanf("%d %d %d", &Opt[i], &X[i], &Y[i]);
  for (int i = 1; i <= N; ++i) par[i] = i;
}

inline bool cmp(edge x, edge y) { return x.w < y.w; }
int Find(int x) { return x == par[x] ? x : par[x] = Find(par[x]); }

void solve() {
  sort(E + 1, E + 1 + M, cmp);
  for (int i = 1; i <= M; ++i) {
    E[i].ok = true;
    E[i].id = i + N;
    Mp[E[i].u][E[i].v] = Mp[E[i].v][E[i].u] = i;
    T.val[E[i].id] = E[i].w;
  }
  for (int i = 1; i <= Q; ++i) {
    if (Opt[i] == 1) continue;
    int e = Mp[X[i]][Y[i]];
    E[e].ok = false;
  }

  for (int i = 1; i <= M; ++i) {
    int u = E[i].u, v = E[i].v;
    int p = Find(u), q = Find(v);
    if (E[i].ok == true && p != q) {
      T.link(E[i].id, u); T.link(E[i].id, v);
      par[p] = q;
    }
  }

  for (int i = Q; i >= 1; --i) {
    int opt = Opt[i], x = X[i], y = Y[i];
    if (opt == 1) {
      if (x == y) {
        st[++tp] = 0;
        continue;
      }
      T.split(x, y);
      st[++tp] = T.val[T.maxid[y]];
    } else {
      int m = Mp[x][y];

      T.split(x, y);
      int e = T.maxid[y] - N;
      if (E[e].w <= E[m].w) continue;
      T.cut(E[e].id, E[e].u); T.cut(E[e].id, E[e].v);
      T.link(E[m].id, x); T.link(E[m].id, y);
    }
  }
  while (tp > 0) printf("%d\n", st[tp--]);
}

int main() {
  init();
  solve();
  return 0;
}

五、總結

拆點和拆邊是非常經典的圖論技巧之一,而且寫起來也非常方便,很容易上手。但缺點在於空間佔用需要翻倍,使用時千千萬萬記得開兩倍的數組空間(我纔不會告訴你這種東西我寫十次 RE 九次)。

by Tweetuzkiby\ Tweetuzki

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章