最小生成树
最小生成树的内核和证明
首先感性去看一下这个问题。一棵生成树的核心是把一些点联合在一起。我们会想到,对于一个集合,与其他点连接起来的时候,是不是去使用一条边权最短的向外连边会最优秀呢?其实是这样的,而这就是 Boruvka 算法在干的事情。而我们审视一下 Kruskal 在干的事情,其实是 Boruvka 的另一种表达方式:当前时刻一条集合之间的最短连边,不就是我们的 Boruvka 要找的吗!
所以这两种算法是统一的。对于它们之间的关系,我更倾向于是类似四边形不等式中二元形式和四元形式的区别:四元形式是最本质的东西,二元形式是换一种表达方式,但是其更可以证明,也更可以启发出一些性质。Kruskal 因为重新表达了 Boruvka,生成了一个以一条边为单位分层的算法,所以性质更好,更好证明。
oi-wiki 上有一个证明,我认为其实并没有从本质出发去证明它,这里介绍另一种证明方法,运用了开头所说的这个思想,其和 oi-wiki 上的证明是本质相同的,但是使用了贪心的巧妙策略,其思路是可复现的:
考虑一种生成树方案 \(T\)。考虑其第一个和 Kruskal 找到的生成树 \(\mathcal T\) 不一样的边。\(\mathcal T\) 中这一步本该连接的 \(i\) 和 \(j\) 我们暂时不连,之后有一条边将 \(i\) 和 \(j\) 所在的集合连通(这是良定义的,如同树上背包一样是可以考虑两个点什么时候连通的)。那么交换这两条边,其他边都不进行改变,会变得更优。于是这种方案并非是最好的。
定义:一个无向图中的一棵生成树,满足其边权和最小。对于一个无向图,其不一定唯一。
同时满足另外两个性质:
- 在一棵生成树 \(T\) 中,任意两个点 \(i,j\) 的路径上最大边权记为 \(\operatorname{bottleneck}_{T, {i,j}}\)(瓶颈)。那么对于图上任意一棵最小生成树 \(MST\) 和任意的 \(i,j\) 和任意的 \(T\) 都有 \(\operatorname{bottleneck}_{MST, {i,j}} \le\operatorname{bottleneck}_{T, {i,j}}\)。
- 考虑整个生成树的最大边权,MST 的也是所有生成树中最小。
证明:考虑第二个性质,由于 Kruskal 生成的时候是从小到大找,因此不能将任何边替换为更小的边,否则出现环。考虑第一个性质,对某一条边 \(u-v\) 如果不能加入,那么 \(u \rightsquigarrow v\) 存在,显然边权比 \(u-v\) 的边权小。\(u-v\) 替换了一定不可能是更小边权。
因此,如果路径权值为所有边权的 \(\max\),那么就是一个最小瓶颈路问题,在 MST 中可以做。(多源的)
具体怎么做,维护链上操作,倍增!倍增!倍增!(树剖也可以)
P1961
最大生成树,最小瓶颈也是可以做的,你可以直接把边取反,没有不对的地方。
Kruskal
时间复杂度 \(O(m \log m)\)。(排序)
证明:Kruskal 算法在任意时刻找到的生成森林一定是一个 MST 的一部分。这里考虑 MST 的定义证明。
考虑归纳。对步骤归纳。显然第 \(0\) 步骤满足题意。
考虑某一个步骤选择的生成森林为 \(F\),其对应的生成树是 \(T\)。在下一步中加边 \(e\)。证明 \(F+e\) 是某一个 MST 的一部分。
- 如果 \(F+e \in T\),那么显然成立。
- 否则,考虑 \(T+e\) 是一个基环树。找到其中的一个环。找到其中一个还没有加入的边 \(e'\)。显然 \(e \le e'\)。如果 \(e <e'\),那么 \(T - e' + e\) 是一棵小于 \(T\) 的树,\(T\) 不是 MST;否则,\(T - e' +e\) 是一棵等于 \(T\) 的树,也是 MST。
主要思想是,将需要讨论的很复杂的一棵树换成一个环来讨论。
ABC270F
https://atcoder.jp/contests/abc270/tasks/abc270_f
题意:有 \(n\) 个岛屿,初始空空如也。可以进行若干次如下操作中的一个:
- 在第 \(i\) 个岛屿上建立一个飞机场并花费 \(a_i\) 的代价。
- 在第 \(i\) 个岛屿上建立一个码头并花费 \(b_i\) 的代价。
- 对于给定的若干个二元组 \((u,v)\) 中选择一个 \((u_i,v_i)\),在 \(u_i\) 和 \(v_i\) 之间建造一条双向道路,并话费 \(c_i\) 的代价。
拥有飞机场的岛屿之间可以相互通行;拥有码头的岛屿之间可以相互通行。
求使得这 \(n\) 个岛屿联通的最小代价。
分析:这个题目上次在睿爸那里做过一道,当时没有开放补题通道就没有整理。这次又遇到还是不会做。这不太行啊。
我们转化题意,看看按照上述方法建图,连通性实际上是怎么样。建立虚点 \(n+1\) 和 \(n+2\);建立飞机场的点当作 \(i\) 和 \(n+1\) 的边,边权为 \(a_i\);码头则是 \(i\) 和 \(n+2\) 的边,边权为 \(b_i\)。这样只要求使得 \(1 \sim n\) 连通的最小生成树即可(不要求 \(n+1\) 和 \(n+2\) 连通)。
具体实现就进行四次最小生成树,分别要求 \(1 \sim n\);\(1 \sim n+1\);\(1 \sim n\) 和 \(n+2\);\(1 \sim n+2\) 连通,并且只使用 \({c}\);\(a,c\);\(b,c\);\(a,b,c\) 之间的边。然后再求最小生成树边权之间的最小值即可。
次小生成树
非严格次小生成树,就是在去掉找到的 MST 的集合中找到最小的一个。也就是但凡换一条边即可。做法是,对每一个没有加入的边 \(u-v\),找到瓶颈并且替换,更新答案。显然替换之后一定是一棵树,且次小生成树一定会被找到。
严格次小生成树,也就是还是换一条边,但是一定要换严格次小的边。这时候倍增预处理的时候也处理严格次小边即可。替换的时候如果路径上最大边一样,换成严格次大边替换即可。
Prim
加点。
考虑已经加入 MST 的点集 \(S\) 和还没有加入 MST 的点集 \(T\)。维护的 \(S\) 一直是一棵生成树(而不是森林),每次加一个点和一条边。
定义一个点的距离 \(dis_i\) 表示 \(i\) 距离 \(S\) 的距离(或者形式化地说,距离 \(S\) 中每一个点的距离的最小值)。每次加入 \(dis\) 最小的一个点。并且更新其他点的 \(dis\)。
时间复杂度:
类似 dijkstra,是 \(O((n+m) \log (n + m))\) 的,或者暴力 \(O(n^2 + m)\)。主要是因为二叉堆不支持高效率的 decrease key (修改某个权值)操作。pbds 有配对堆/Fib 堆,不太了解,可以满足这个性质,理论上是 \(O(n \log n + m)\) 的。
证明:
依然是讨论一个环来简化思考。
考虑之前一定选了这个环的一个半包。
这次只可能选临近的两条边。
考虑某一条边如果没有选,另一条边一定有选。那么就很好证明了。
Kruskal 在稀疏图上有优势,Prim 在稠密图上有优势。但是实践中主要应用 Kruskal。
Boruvka
Boruvka 算法是维护若干个连通块的过程。
其会执行若干轮。每一轮中,从每一个连通块出发找到其与任意其他连通块间最小的边。
然后,连接这所有的边,但是有个例外,看看是否成环,如果成环直接扔掉这条边不加,因为这个环上所有边都相同。如下图:(如果保证边权不同,可以略过这一个步骤)
也可以给边定一个偏序,比如按照加边顺序选择。这样也不会出现环。
重复执行,直到只剩一个连通块。
正确性:
依然归纳证明。考虑某一次之前所有边都在某一个 MST 里面。这时候注意连通块已经确定了。如果两个连通块要连,一定连最小的那个。
应用前景分析:
Boruvka 算法并不要求求出每条边的边权,只要你能够对于每个点求出其与不在同一个连通块中的点间的最小边权即可。
Kruskal 重构树
定义:将一个 \(n\) 个点的图重构为一个 \(2n-1\) 个点的图,使得这张图有特殊性质可以利用。
在 Kruskal 算法中我们找到两个尚未在一个集合中的点,并将其合并,将其连边作为最小生成树的一条边。
在 Kruskal 重构树算法中,我们首先新建 \(n\) 个集合,每个集合恰有一个节点,点权为 \(0\)。
每一次加边会合并两个集合,我们可以新建一个点,点权为加入边的边权,同时将两个集合的根节点分别设为新建点的左儿子和右儿子。然后我们将两个集合和新建点合并成一个集合。将新建点设为根。
不难发现,在进行 \(n-1\) 轮之后我们得到了一棵恰有 \(n\) 个叶子的二叉树,同时每个非叶子节点恰好有两个儿子。这棵树就叫 Kruskal 重构树。
例如下图:
它的 Kruskal 重构树就是下图:
时间复杂度 \(O(n \log n)\)。
将 Kruskal 的加边过程改成集合连向一个共同虚点祖先的过程得到一棵树,叫做 Kruskal 重构树。由于是 Kruskal 的过程,所以满足边权递增,因此会有性质:原图中两个点之间的所有简单路径上最大边权的最小值 = 最小生成树上两个点之间的简单路径上的最大值 = Kruskal 重构树上两点之间的 LCA 的权值。
好处就是,树上问题比较好做。
CF1706E
题意:给定一个 \(n\) 点 \(m\) 边无向连通图,有 \(q\) 次询问,每次给定区间 \([l,r]\),求区间内所有点之间简单路径最大边权的最小值。
分析:转化为 Kruskal 重构树,那么每次就是求区间里每个点的 LCA。
但是区间 LCA 暴力做还是复杂度不够。我们考虑区间 LCA(或者说是一堆点的 LCA)就是区间上 \(dfn\) 最大和最小的两个点的 LCA,那么我们再用俩 ST 表或者一棵线段树记录区间 \(dfn\) 的最大最小值即可。
#include<bits/stdc++.h>
using namespace std;
#define f(i, a, b) for(int i = (a); i <= (b); i++)
#define cl(i, n) i.clear(),i.resize(n);
#define endl '\n'
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int, int> pii;
const int inf = 1e9;
int N = 200000;
int n,m,q;
vector<int> g[200010];
int lg2[200010];
vector<int> dep, dfn;
vector<int> stmx[200010], stmn[200010], anc[200010];
int cha[200010];
struct rec{
int x,y,z;
}edge[500010];
bool operator<(rec a,rec b){
return a.z<b.z;
}
int qz[200010];
int fa[200010];
int get(int x){
if(fa[x]==x)return x;
else return fa[x] = get(fa[x]);
}
int kruskal() {
int cnt = n;
f(i,1,2*n)fa[i]=i;
f(i, 1, m) {
int x = get(edge[i].x);
int y = get(edge[i].y);
if(x == y)continue;
else {
int z = ++cnt;
g[x].push_back(z); g[y].push_back(z);
g[z].push_back(x); g[z].push_back(y);
fa[x]=z;fa[y]=z;
qz[z] = edge[i].z;
}
}
return cnt;
}
int ccnt;
void dfs(int now, int fa) {
dfn[now] = ++ccnt;
dep[now] = dep[fa] + 1; anc[now][0] = fa;
f(i, 1, lg2[dep[now]] - 1) {
anc[now][i] = anc[anc[now][i - 1]][i - 1];
}
f(i, 0, (int)g[now].size() -1 ) {
if(g[now][i] != fa) dfs(g[now][i], now);
}
}
int lca(int qx, int qy) {
if(dep[qx] < dep[qy]) swap(qx, qy);
while(dep[qx] > dep[qy]) {
qx = anc[qx][lg2[dep[qx]-dep[qy]] - 1];
}
if(qx == qy) return qx;
for(int k = lg2[dep[qx]] - 1; k >= 0; k--) {
if(anc[qx][k] != anc[qy][k]) {
qx = anc[qx][k]; qy = anc[qy][k];
}
}
return anc[qx][0];
}
void STmx_prework() {
f(i, 1, n) stmx[i][0] = dfn[i];
int mx = log(n) / log(2);
f(j, 1, mx) {
int mxi = n - (1 << j) + 1;
f(i, 1, mxi) {
stmx[i][j] = max(stmx[i][j - 1], stmx[i + (1 << (j - 1))][j - 1]);
}
}
}
void STmn_prework() {
f(i, 1, n) stmn[i][0] = dfn[i];
int mx = log(n) / log(2);
f(j, 1, mx) {
int mxi = n - (1 << j) + 1;
f(i, 1, mxi) {
stmn[i][j] = min(stmn[i][j - 1], stmn[i + (1 << (j - 1))][j - 1]);
}
}
}
int querymx(int l, int r) {
int mx = log(r - l + 1) / log(2);
int ans;
ans = max(stmx[l][mx], stmx[r - (1 << mx) + 1][mx]);
return ans;
}
int querymn(int l, int r) {
int mx = log(r - l + 1) / log(2);
int ans;
ans = min(stmn[l][mx], stmn[r - (1 << mx) + 1][mx]);
return ans;
}
int main() {
ios::sync_with_stdio(0);
cin.tie(NULL);
cout.tie(NULL);
f(i, 1, N) lg2[i] = lg2[i - 1] + (1 << lg2[i - 1] == i);
int t; cin >> t;
while(t--) {
cin>>n>>m>>q;
f(i, 1, m) {
int u, v; cin >> u >> v;
edge[i].x = u, edge[i].y = v, edge[i].z = i;
}
sort(edge+1,edge+m+1);
f(i,1,2*n)g[i].clear();
int root = kruskal();
n=2*n;
cl(dep, n+10);cl(dfn,n+10);
f(i,0,n+9){
cl(anc[i],30);cl(stmx[i],30);cl(stmn[i],30);
}
dfs(root, 0);
STmx_prework(); STmn_prework();
f(i,1,n) cha[dfn[i]] = i;
f(i, 1, q) {
int l, r; cin >> l >> r; if(l == r) {cout << 0 << " "; continue;}
int mx = querymx(l, r), mn = querymn(l, r);
int lcaa = lca(cha[mx], cha[mn]);
cout << qz[lcaa] <<" ";
}
cout << endl;
}
return 0;
}
也就是说,到点 \(x\) 的简单路径上最大边权的最小值 $ \le val$ 的所有点 \(y\) 均在 Kruskal 重构树上的某一棵子树内,且恰好为该子树的所有叶子节点。
我们在 Kruskal 重构树上找到 \(x\) 到根的路径上权值 $ \le val$ 的最浅的节点。显然这就是所有满足条件的节点所在的子树的根节点。
如果需要求原图中两个点之间的所有简单路径上最小边权的最大值,则在跑 Kruskal 的过程中按边权大到小的顺序加边。