拆點和拆邊


拆點和拆邊

一、總述

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

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

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

針對點權

  • 樹鏈剖分(套線段樹或樹狀數組)
  • Link-Cut Tree
  • 倍增
  • 強連通分量縮點

針對邊權

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

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

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

三、拆點

過程

對於某個點權為 \(w\) 的點 \(v\),我們可以把 \(v\) 點拆成 \(v_1\)\(v_2\) 兩個點,其中 \(v_1\) 稱為入點,\(v_2\) 稱為出點。從 \(v_1 \to v_2\) 連一條權值為 ​\(w\) 的有向邊。此外對於圖上原本連接某兩點 \(x\)\(y\),權值為 \(z\) 的有向邊,改為從 \(x_2 \to y_1\) 連權值為 \(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 \times n\) 的矩陣,每一格有一個非負整數 \(A_{i, j}\) \((A_{i, j} \le 1000)\)。現在從 \((1,1)\) 出發,可以往右或者往下走,最后到達 \((n,n)\)。每達到一格,把該格子的數取出來,該格子的數就變成 \(0\),這樣一共走 \(K\) 次,現在要求 \(K\) 次所達到的方格的數的和最大。

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

由於每個格子的數只能取一次,因此我們從入點向出點連一條流量為 \(1\),權值為 \(A_{i, j}\) 的邊。由於可以無數次經過,再從入點向出點連流量為 \(\infty\),權值為 \(0\) 的邊。

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

最后求解原圖上 \((1, 1)\) 的入點到 \((n, n)\) 的出點的流量為 \(K​\) 的最小費用流即可。

#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <queue>
using namespace std;

struct edge {
    int to,cap,cost,rev;
};

typedef pair<int,int> P;
const int maxn=5000, INF=0x7F7F7F7F;
int n;
vector <edge> G[maxn+1];

edge make_edge(int to, int cap, int cost, int rev) {
    edge x;
    x.to=to, x.cap=cap, x.cost=cost, x.rev=rev;
    return x;
}

void add_edge(int from, int to, int cap, int cost) {
    G[from].push_back(make_edge(to,cap,cost,G[to].size()));
    G[to].push_back(make_edge(from,0,-cost,G[from].size()-1));
}

int make_crn(int x, int y) {
    return (x-1)*n+y;
}

void init(int &f) {
    scanf("%d%d",&n,&f);
    for (int i=1; i<=n; i++)
        for (int j=1; j<=n; j++) {
            int x;
            scanf("%d",&x);
            add_edge(make_crn(i,j),make_crn(i,j)+n*n,1,-x);
            add_edge(make_crn(i,j),make_crn(i,j)+n*n,INF,0);
        }
    for (int i=1; i<=n; i++)
        for (int j=1; j<n; j++)
            add_edge(make_crn(i,j)+n*n,make_crn(i,j+1),INF,0);
    for (int i=1; i<n; i++)
        for (int j=1; j<=n; j++)
            add_edge(make_crn(i,j)+n*n,make_crn(i+1,j),INF,0);
}

namespace EK_SPFA {
    int dis[maxn+1];
    int prev[maxn+1];
    int pree[maxn+1];

    void bfs(int s) {
        bool mark[maxn+1];
        queue <int> q;
        memset(dis,0x7F,sizeof(dis));
        memset(mark,0,sizeof(mark));
        memset(prev,-1,sizeof(prev));
        memset(pree,-1,sizeof(pree));
        dis[s]=0;
        mark[s]=true;
        q.push(s);
        while (!q.empty()) {
            int x=q.front();
            q.pop();
            mark[x]=false;
            for (int i=0; i<G[x].size(); i++) {
                edge &e=G[x][i];
                if (e.cap>0&&dis[x]+e.cost<dis[e.to]) {
                    dis[e.to]=dis[x]+e.cost;
                    prev[e.to]=x;
                    pree[e.to]=i;
                    if (!mark[e.to]) {
                        mark[e.to]=true;
                        q.push(e.to);
                    }
                }
            }
        }
    }

    int min_cost_flow(int s, int t, int f) {
        int cost=0;
        while (f>0) {
            bfs(s);
            if (dis[t]==INF) return -1;
            int d=f;
            for (int i=t; prev[i]!=-1; i=prev[i])
                d=min(d,G[prev[i]][pree[i]].cap);
            f-=d;
            cost+=d*dis[t];
            for (int i=t; prev[i]!=-1; i=prev[i]) {
                edge &e=G[prev[i]][pree[i]];
                e.cap-=d;
                G[e.to][e.rev].cap+=d;
            }
        }
        return cost;
    }
}

int main() {
    int f;
    init(f);
    printf("%d\n",-EK_SPFA::min_cost_flow(1,make_crn(n,n)+n*n,f));
    return 0;
}

好久以前寫的,碼風都不一樣,將就着看吧……

四、拆邊

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

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

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

過程

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

上圖說明了拆邊的過程。

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

實例

倍增算法(Kruskal 重構樹)

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

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

  • 將原圖的每一個節點看作一棵子樹。
  • 合並兩棵子樹時,通過並查集找到它們子樹對應的根節點,記作 \(u\), \(v\)
  • 新開一個節點 \(p\),從 \(p\) 分別向 \(u\), \(v\) 兩點連邊。於是 \(u\)\(v\) 兩棵子樹就並到了一棵子樹,根節點就是 \(p\),並將 \(p\) 點權值賦為 \(u \leftrightarrow 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\) 條邊的圖,邊有邊權。你需要動態維護兩種操作:

  1. 某一條邊消失。
  2. 詢問 \(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 九次)。

\[\texttt{by Tweetuzki} \ \mathcal{2019.2.23} \]



免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM