【演算法學習筆記】生成樹問題探究

B 發表於 2021-04-17
演算法
本文探究了生成樹問題的相關知識。

寫在前面

生成樹問題是圖論的重點,其中最小生成樹更是最基礎的入門問題。

Murabito 將會在這裡記錄他有關生成樹的理解。此類問題眾多,本文將不斷更新。

問題介紹

給定一個 \(n\) 個頂點, \(m\) 條邊的無向圖。要求你從中選擇 \(n−1\) 條邊,構成一個具有特殊性質的樹。

ACM 中常見的生成樹包括:

  • 最大/小生成樹
  • 次大/小生成樹
  • 生成樹計數

解法介紹

最大/小生成樹

以最小生成樹 Minimum Spanning Tree(簡稱 MST)為例。最大生成樹和最小生成樹解法是完全一樣的。不過似乎最大生成樹也叫 MST

首先給出定義:假定每條邊都有一個權值,那麼所有生成樹中權值最小的即為最小生成樹。

問法有兩種:

  • 詢問最小生成樹的權值/構成
  • 最小生成樹計數

解法是著名的 Kruskal 演算法,演算法得名於他的發現者Joseph Kruskal

權值是唯一的,但是構成可能有很多種。如果詢問你某種特定的邊的優先順序的順序下的最小生成樹,那麼只需修改排序的法則即可。因為 kruskal 演算法基於貪心,讓權值相同的邊中優先順序高的排在前面就行。

Kruskal

將圖 \(G=\{V,E\}\) 中的所有邊按照長度由小到大進行排序,等長的邊可以按任意順序。

初始化圖 \(G′\)\(\{V,∅\}\) ,從前向後掃描排序後的邊,如果掃描到的邊 \(e\)\(G′\) 中連線了兩個相異的連通塊,則將它插入 \(G′\) 中。

最後得到的圖 \(G′\) 就是圖 \(G\) 的最小生成樹。

簡單地說:

對所有邊進行排序,從小到大進行列舉,每次貪心選邊加入答案。使用並查集維護連通性,若當前邊兩端不連通即可選擇這條邊。

因為要排序並列舉每一條邊,所以需要用邊集陣列儲存這張圖。時間複雜度 \(\mathcal{O}(m\ log⁡m)\)

詢問權值/構成

模板 (Luogu 3366
#include <bits/stdc++.h>
using namespace std;
const int N = 5010, M = 200010;
struct Edge {
    int x, y, w;
} e[M];
bool cmp(Edge a, Edge b) { return a.w < b.w; }
int f[N], n, m;
int find(int x) { return f[x] == x ? x : f[x] = find(f[x]); }
int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) f[i] = i;
    for (int i = 1; i <= m; ++i) cin >> e[i].x >> e[i].y >> e[i].w;
    sort(e + 1, e + 1 + n, cmp);
    int sum = 0, num = 1;
    for (int i = 1; i <= m && num < n; ++i) {
        int fx = find(e[i].x), fy = find(e[i].y);
        if (fx == fy) continue;
        f[fx] = fy, sum += e[i].w, num++;
    }
    if (num < n) cout << "orz\n";
    else
        cout << sum;
    return 0;
}

最小生成樹計數

因為最小生成樹的構成可能有很多種,所以有時候會詢問到底有多少種最小生成樹,而且答案一般都很大,需要取模。

計數問題中依舊要用到 Kruskal 演算法,演算法詳情見上。

首先明確:一個無向圖所有最小生成樹的權值構成唯一

即若一個最小生成樹的邊權值分別為 \(a_1,a_2…a_{n−1}\) ,則其它的最小生成樹的權值也均為 \(a_1,a_2…a_{n−1}\)

那麼我們應該將所有權值相同的邊的處理當作一個整體來分階段看待。

kruskal 處理完第 \(i\) 階段後得到的圖為 \(G_i\) ,那麼有:任意的 \(G_i\) 的連通性唯一

即在 kruskal 演算法中的任意時刻,我們並不需要關注 \(G′\) 的具體形態,而只要關注各個點的連通性如何。

這樣一來,各個階段即互相獨立開來,對於每一階段計算他有多少種擇邊方式,最後統一相乘即可。

如果題目中沒有限制等長邊的數量,那麼可以使用矩陣樹做法,詳見生成樹計數部分。

需要注意的是這裡的並查集不能路徑優化,否則會丟失一部分路徑資訊。

時間複雜度不夠好看,假定等長邊至多可能有 k 條,則時間複雜度為 \(\mathcal{O}(2^kmlog⁡n)\)

程式碼(luogu4208
#include <bits/stdc++.h>
using namespace std;
const int N = 110, M = 1010, mod = 31011;
struct Edge {
    int x, y, w;
} e[M];
bool cmp(Edge a, Edge b) { return a.w < b.w; }
int f[N], n, m;
int find(int x) { return f[x] == x ? x : find(f[x]); } //注意這裡沒有路徑壓縮
struct sEdge {
    int l, r, w;
} se[1010];
int dfs(int x, int now, int k) {
    if (now == se[x].r + 1) return k == se[x].w;
    int sum = 0;
    int fx = find(e[now].x), fy = find(e[now].y);
    if (fx != fy) {
        f[fx] = fy;
        sum += dfs(x, now + 1, k + 1);
        f[fx] = fx, f[fy] = fy; // 把 find 中 路徑壓縮放置這裡
    }
    return sum + dfs(x, now + 1, k);
}
int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) f[i] = i;
    for (int i = 1; i <= m; ++i) cin >> e[i].x >> e[i].y >> e[i].w;
    sort(e + 1, e + m + 1, cmp);
    int num = 1, snum = 0;
    for (int i = 1; i <= m; i++) {
        if (e[i].w != e[i - 1].w) se[snum++].r = i - 1, se[snum].l = i;
        int fx = find(e[i].x), fy = find(e[i].y);
        if (fx != fy) f[fx] = fy, se[snum].w++, num++;
    }
    if (num < n) {
        cout << 0;
        return 0;
    }
    se[snum].r = m;
    for (int i = 1; i <= n; ++i) f[i] = i;
    int ans = 1;
    for (int i = 1; i <= snum; ++i) {
        ans = ans * dfs(i, se[i].l, 0) % mod;
        for (int j = se[i].l; j <= se[i].r; ++j) {
            int fx = find(e[j].x), fy = find(e[j].y);
            if (fx != fy) f[fx] = fy;
        }
    }
    cout << ans;
    return 0;
}

次大/小生成樹

以次小生成樹為為例。次大生成樹和次小生成樹解法是完全一樣的。

次小生成樹分兩種:

  • 非嚴格次小生成樹
  • 嚴格次小生成樹

非嚴格次小生成樹

首先給出定義:一個圖的非嚴格次小生成樹,是指異於該圖的最小生成樹的權值最小的生成樹。

需要注意的是,這裡的次小生成樹可能與最小生成樹權值相等。

首先明確:次小生成樹可以由最小生成樹更換一條邊得到

首先構造原圖的最小生成樹。然後嘗試將每一條不在最小生成樹中的邊 (u, v, w) 加入生成樹。

加入邊的過程中會產生環,所以在加邊之前刪去最小生成樹上 uv 的路徑上權值最大的邊。在列舉每一條邊時我們都會得到一棵生成樹,這些生成樹中邊權和最小的即為要求的次小生成樹。

要在構造最小生成樹時將完整的樹結構構造出來,並且使用樹上倍增查詢兩點間邊權值最大值。

時間複雜度 \(\mathcal{O}(m log\ m)\).

模板(POJ1679
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>
using namespace std;
#define inf 0x3f3f3f3f
#define N 101
int G[N][N];
int f[N], n, m;
int dis[N][N], used[N * N], vis[N];
struct Edge {
    int x, y, w;
} e[N * N];
bool cmp(Edge a, Edge b) { return a.w < b.w; }
int find(int x) { return f[x] == x ? x : f[x] = find(f[x]); }
void search(int u, int v, int w) {
    vis[v] = true, dis[u][v] = w;
    for (int i = 1; i <= n; i++)
        if (G[v][i] != inf && !vis[i]) search(u, i, max(w, G[v][i]));
}
int main() {
    int _;
    for (cin >> _; _--;) {
        memset(G, 0x3f, sizeof(G));
        memset(dis, 0, sizeof(dis));
        memset(used, 0, sizeof(used));
        cin >> n >> m;
        for (int i = 1; i <= m; i++) cin >> e[i].x >> e[i].y >> e[i].w;
        for (int i = 1; i <= n; i++) f[i] = i;
        sort(e + 1, e + m + 1, cmp);
        int num = 1, sum = 0;
        for (int i = 1; num < n && i <= m; i++) {
            int fx = find(e[i].x), fy = find(e[i].y);
            if (fx == fy) continue;
            f[fx]             = fy, sum += e[i].w, num++;
            used[i]           = true;
            G[e[i].x][e[i].y] = G[e[i].y][e[i].x] = e[i].w;
        }
        for (int i = 1; i <= n; i++) {
            memset(vis, 0, sizeof(vis));
            search(i, i, 0);
        }
        bool flag = 1;
        for (int i = 1; flag && i <= m; i++)
            if (!used[i] && e[i].w == dis[e[i].x][e[i].y]) flag = false;
        if (flag) cout << sum << "\n";
        else
            cout << "Not Unique\n";
    }
    return 0;
}

嚴格次小生成樹

首先給出定義:一個圖的非嚴格次小生成樹,是指權值大於該圖的最小生成樹的權值的權值最小的生成樹。(我在說什麼

需要注意的是,這裡的次小生成樹不可能與最小生成樹權值相等。

做法和非嚴格次小生成樹一樣,樹上倍增多維護一個兩點間邊權次大值即可。

時間複雜度 \(\mathcal{O}(m log\ m)\)

模板(luogu4180
#include <bits/stdc++.h>
using namespace std;
#define ll long long
#define N 100001
#define M 300001
#define S 21
struct Edge {
    int x, y, w;
} e[M];
bool cmp(Edge A, Edge B) { return A.w < B.w; }
int f[N];
int find(int x) { return x == f[x] ? x : f[x] = find(f[x]); }
int hd[N], nx[N << 1], ed[N << 1], wt[N << 1], num;
void Addedge(int x, int y, int w) {
    nx[++num] = hd[x], hd[x] = num, ed[num] = y, wt[num] = w;
    nx[++num] = hd[y], hd[y] = num, ed[num] = x, wt[num] = w;
}
int m1[N][S], m2[N][S], dep[N];
void dfs(int x, int fa) {
    dep[x] = dep[fa] + 1;
    for (int i = hd[x]; i; i = nx[i])
        if (ed[i] != fa) {
            m1[ed[i]][0] = x, m2[ed[i]][0] = wt[i];
            dfs(ed[i], x);
        }
}
int query(int x, int y, int w) {
    int maxn = 0;
    if (dep[x] < dep[y]) swap(x, y);
    for (int i = 20; i >= 0; i--)
        if (dep[m1[x][i]] >= dep[y]) {
            if (m2[x][i] < w) maxn = max(maxn, m2[x][i]);
            x = m1[x][i];
        }
    if (x == y) return w - maxn;
    for (int i = 20; i >= 0; i--)
        if (m1[x][i] != m1[y][i]) {
            if (m2[x][i] < w) maxn = max(maxn, m2[x][i]);
            if (m2[y][i] < w) maxn = max(maxn, m2[y][i]);
            x = m1[x][i], y = m1[y][i];
        }
    if (m2[x][0] < w) maxn = max(maxn, m2[x][0]);
    if (m2[y][0] < w) maxn = max(maxn, m2[y][0]);
    return w - maxn;
}
int n, m, used[M];
int main() {
    cin >> n >> m;
    for (int i = 1; i <= m; i++) cin >> e[i].x >> e[i].y >> e[i].w;
    sort(e + 1, e + m + 1, cmp);
    for (int i = 1; i <= n; i++) f[i] = i;
    ll sum = 0;
    for (int i = 1, num = 1; num < n && i <= m; i++) {
        int fx = find(e[i].x), fy = find(e[i].y);
        if (fx == fy) continue;
        f[fx] = fy, sum += e[i].w, num++, used[i] = true;
        Addedge(e[i].x, e[i].y, e[i].w);
    }
    dfs(1, 0);
    for (int i = 1; i <= 20; i++)
        for (int j = 1; j <= n; j++) {
            int p    = m1[j][i - 1];
            m1[j][i] = m1[p][i - 1];
            m2[j][i] = m2[m2[j][i - 1] > m2[p][i - 1] ? j : p][i - 1];
        }
    ll ans = 1e18;
    for (int i = 1; i <= m; i++)
        if (!used[i]) ans = min(ans, sum + query(e[i].x, e[i].y, e[i].w));
    cout << ans << '\n';
    return 0;
}

生成樹計數

生成樹計數需要用到矩陣樹 Matrix-Tree 定理。

矩陣樹定理

首先定義 \(deg[i]\) 表示 \(i\) 號點的入度, \(g[i][j]\) 表示 \(i\)\(j\) 直連邊的數量(考慮到重邊)。

那麼定義這張無向圖的的 n 階基爾霍夫矩陣 Kirchhoff Matrix\(A\) ,有:

\[A(i,j)= \begin{Bmatrix} deq[i],\quad i=j\\-g[i][j],\quad i\ne j\end{Bmatrix} \]

無向圖的生成樹數就是其基爾霍夫矩陣的任意 \(n−1\) 階餘子式。

有向圖的樹形圖數是刪除第 \(x\) 行和第 \(x\) 列的 \(n−1\) 階餘子式,其中 \(x\) 為選做為根的節點。

求餘子式的具體做法是高斯消元,時間複雜度 :\(\mathcal{O}(n^3)\)

普通生成樹計數

模板(SPOJ HIGH
#include <bits/stdc++.h>
using namespace std;
#define ll long long
#define N 20
ll a[N][N];
int g[N][N], dge[N];
ll gauss(int n) {
    ll sum = 1;
    for (int i = 2; i <= n; i++) {
        for (int j = i + 1; j <= n; j++) {
            while (a[j][i]) {
                ll t = a[i][i] / a[j][i];
                for (int k = i; k <= n; k++) a[i][k] = (a[i][k] - a[j][k] * t);
                for (int k = i; k <= n; k++) swap(a[i][k], a[j][k]);
                sum = -sum;
            }
        }
        if (a[i][i] == 0) return 0;
        sum *= a[i][i];
    }
    return sum > 0 ? sum : -sum;
}
int t, n, m;
int main() {
    scanf("%d", &t);
    while (t--) {
        memset(dge, 0, sizeof(dge));
        memset(a, 0, sizeof(a));
        memset(g, 0, sizeof(g));
        scanf("%d%d", &n, &m);
        for (int i = 1, u, v; i <= m; i++) {
            scanf("%d%d", &u, &v);
            g[u][v]++, g[v][u]++;
            dge[u]++, dge[v]++;
        }
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++) a[i][j] = i == j ? dge[i] : -g[i][j];
        printf("%lld\n", gauss(n));
    }
    return 0;
}

最小生成樹計數

原理見最小生成樹部分的最小生成樹計數的暴力做法,這裡把暴力部分替換為 \(O(n^3)\) 的矩陣樹。

總體時間複雜度 \(O(n^4)\)

模板(luogu4208
#include <bits/stdc++.h>
using namespace std;
#define mod 31011
#define N 101
#define M 1001
struct Edge {
    int x, y, w;
} e[M], se[N];
bool cmp(Edge a, Edge b) { return a.w < b.w; }
int f[N];
int find(int x) { return f[x] == x ? x : find(f[x]); }
int a[N][N];
int gauss(int n) {
    int res = 1;
    for (int i = 1; i < n; i++) {
        for (int j = i + 1; j < n; j++)
            while (a[j][i]) {
                int t = a[i][i] / a[j][i];
                for (int k = i; k < n; k++)
                    a[i][k] = (a[i][k] - t * a[j][k] + mod) % mod;
                swap(a[j], a[i]);
                res = -res;
            }
        res = res * a[i][i] % mod;
    }
    return (res + mod) % mod;
}
int col[M], cw[M], n, m;
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) f[i] = i;
    for (int i = 1; i <= m; i++) scanf("%d%d%d", &e[i].x, &e[i].y, &e[i].w);
    sort(e + 1, e + m + 1, cmp);
    int num = 1, w_num = 0;
    for (int i = 1; num < n && i <= m; i++) {
        int fx = find(e[i].x), fy = find(e[i].y);
        if (fx == fy) continue;
        f[fx] = fy, se[num++] = e[i];
        if (e[i].w != cw[w_num]) cw[++w_num] = e[i].w;
    }
    if (num < n) {
        printf("0\n");
        return 0;
    }
    int ans = 1;
    for (int i = 1; i <= w_num; i++) {
        for (int j = 1; j <= n; j++) f[j] = j;
        for (int j = 1; j < n; j++)
            if (se[j].w != cw[i]) {
                int fx = find(se[j].x), fy = find(se[j].y);
                if (fx != fy) f[fx] = fy;
            }
        int col_num = 0;
        for (int j = 1; j <= n; j++)
            if (find(j) == j) col[j] = ++col_num;
        for (int j = 1; j <= n; j++) col[j] = col[find(j)];
        memset(a, 0, sizeof(a));
        for (int j = 1; j <= m; j++)
            if (e[j].w == cw[i]) {
                int x = col[e[j].x], y = col[e[j].y];
                a[x][x]++, a[y][y]++, a[x][y]--, a[y][x]--;
            }
        ans = ans * gauss(col_num) % mod;
    }
    printf("%d\n", (ans + mod) % mod);
    return 0;
}