【算法学习笔记】生成树问题探究
写在前面
生成树问题是图论的重点,其中最小生成树更是最基础的入门问题。
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\ logm)\) 。
询问权值/构成
模板 (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^kmlogn)\) 。
代码(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)
加入生成树。
加入边的过程中会产生环,所以在加边之前删去最小生成树上 u
到 v
的路径上权值最大的边。在枚举每一条边时我们都会得到一棵生成树,这些生成树中边权和最小的即为要求的次小生成树。
要在构造最小生成树时将完整的树结构构造出来,并且使用树上倍增查询两点间边权值最大值。
时间复杂度 \(\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\) ,有:
\]
无向图的生成树数就是其基尔霍夫矩阵的任意 \(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;
}