一、最小生成树简介
一个有 n n n个顶点的连通图 G = ( V , E ) G=(V,E) G=(V,E)的生成树是包含 G G G中全部顶点的一个极小连通子图,它有且仅有 n − 1 n-1 n−1条边。也就是说,如果添加一条边,则构成回路;如果删去任何一条边,则生成树不再连通。一个生成树的代价为该生成树中所有边权的总和。称代价最小的生成树为最小生成树(Minimum Spanning Tree, MST)。
最小生成树是图的一种重要应用,在城市道路交通规划、网络路由选择、城市通信网架设等实际问题中应用广泛。例如,在 n n n个城市之间架设通信网路,最多可设置 n ( n − 1 ) 2 n(n-1)\over2 2n(n−1)条线路,每条线路都有一定的成本,如何从这 n ( n − 1 ) 2 n(n-1)\over2 2n(n−1)条线路中选择 n − 1 n-1 n−1条线路,使得总成本最小?将这一问题表示为带权连通图,用图中的顶点表示城市,边表示城市之间的通信线路,边的权值为设置该线路所需的成本,则问题就可以转化为求这个图的最小生成树。
二、最小生成树的性质
定理1 最小生成树的子树也是最小生成树。
证明:设
T
=
(
V
,
E
T
)
T=(V,E_T)
T=(V,ET)是图
G
G
G的一棵最小生成树,
T
′
=
(
U
,
E
U
)
⊆
T
T'=(U,E_U)\subseteq T
T′=(U,EU)⊆T,下面证明
T
′
T'
T′是
U
U
U的导出子图
G
′
G'
G′的最小生成树。若
T
′
T'
T′不是
G
′
G'
G′的最小生成树,则取
G
′
G'
G′的最小生成树
T
∗
T^*
T∗,用
T
∗
T^*
T∗中的边替换
T
T
T在
U
U
U中的边
E
U
E_U
EU可以得到代价更小的生成树,这与
T
T
T是最小生成树矛盾。因此
T
′
T'
T′一定是
G
′
G'
G′的最小生成树。∎
定理2(Key Property) 设
T
=
(
V
,
E
T
)
T=(V,E_T)
T=(V,ET)是图
G
=
(
V
,
E
)
G=(V,E)
G=(V,E)的一棵最小生成树,
w
(
e
)
w(e)
w(e)表示边
e
e
e的权值。假设
F
⊆
E
T
F\subseteq E_T
F⊆ET(即
F
F
F是
T
T
T的边集的子集),
U
⊂
V
U\subset V
U⊂V是
G
G
G的一个点集,边集
M
=
{
(
u
,
v
)
∈
E
∣
u
∈
U
,
v
∈
V
−
U
}
M=\{(u,v)\in E|u\in U,v\in V-U\}
M={(u,v)∈E∣u∈U,v∈V−U}是所有连接
U
U
U和
V
−
U
V-U
V−U的边的集合,且
F
∩
M
=
∅
F\cap M=\emptyset
F∩M=∅。设
e
e
e是
M
M
M中权值最小的边,则
F
∪
{
e
}
F\cup\{e\}
F∪{e}是某个最小生成树
T
′
T'
T′(可能不等于
T
T
T)的边集的子集。
证明:若
e
∈
T
e\in T
e∈T,则无需证明。所以我们假设
e
∉
T
e\notin T
e∈/T。
将
e
e
e加入
T
T
T的边集
E
T
E_T
ET中,必形成回路。取
e
′
∈
E
T
∩
M
e'\in E_T\cap M
e′∈ET∩M(即
e
′
e'
e′是
T
T
T中连接
U
U
U和
V
−
U
V-U
V−U的桥梁),则有
w
(
e
)
≤
w
(
e
′
)
w(e)\le w(e')
w(e)≤w(e′)。令
T
′
=
(
V
,
(
E
T
−
{
e
′
}
)
∪
{
e
}
)
T'=(V,(E_T-\{e'\})\cup\{e\})
T′=(V,(ET−{e′})∪{e}),则
T
′
T'
T′的代价不高于
T
T
T,所以
T
′
T'
T′是一棵最小生成树。∎
三、Prim算法
设
G
=
(
V
,
E
)
G=(V,E)
G=(V,E)为带权连通图,要在
G
G
G中构造一棵最小生成树
T
=
(
U
,
E
T
)
T=(U,E_T)
T=(U,ET)。Prim算法的基本思想如下:
(1) 初始化顶点集
U
=
{
u
0
}
U=\{u_0\}
U={u0},其中
u
0
∈
V
u_0\in V
u0∈V是
U
U
U中唯一的元素;令
E
T
=
∅
E_T=\emptyset
ET=∅,即一开始树中没有边。
(2) 在所有满足
u
∈
U
u\in U
u∈U、
v
∈
V
−
U
v\in V-U
v∈V−U的边
(
u
,
v
)
∈
E
(u,v)\in E
(u,v)∈E中选择一条权值最小的边
e
=
(
u
∗
,
v
∗
)
e=(u^*,v^*)
e=(u∗,v∗)加入最小生成树的边集
E
T
E_T
ET中,同时将顶点
v
∗
v^*
v∗并入
U
U
U中。
重复以上过程,直到
U
=
V
U=V
U=V为止。此时
∣
E
T
∣
=
n
−
1
|E_T|=n-1
∣ET∣=n−1,
T
T
T是
G
G
G的一棵最小生成树。
因为每次操作是将一个节点并入 U U U中,所以Prim算法也称扩点法。
正确性证明:
归纳假设:每一步得到的树
T
U
=
(
U
,
E
U
)
T_U=(U,E_U)
TU=(U,EU)是都是某棵最小生成树的子树。
① 归纳基础:初始条件
T
{
u
0
}
=
(
{
u
0
}
,
∅
)
T_{\{u_0\}}=(\{u_0\},\emptyset)
T{u0}=({u0},∅)是所有最小生成树的子树(因为空集是所有集合的子集)。
② 归纳步:设我们已经得到
T
U
=
(
U
,
E
U
)
T_U=(U,E_U)
TU=(U,EU)是最小生成树
T
T
T的子树。按规则选取边
e
=
(
u
∗
,
v
∗
)
e=(u^*,v^*)
e=(u∗,v∗)。根据定理2,
E
U
∪
{
e
}
E_U\cup\{e\}
EU∪{e}是某棵最小生成树
T
′
T'
T′的边集的子集。因此该步得到的树
T
U
∪
{
v
∗
}
=
(
U
∪
{
v
∗
}
,
E
U
∪
{
e
}
)
T_{U\cup\{v^*\}}=(U\cup\{v^*\},E_U\cup\{e\})
TU∪{v∗}=(U∪{v∗},EU∪{e})是
T
′
T'
T′的最小生成树。
③ 终止:最后
U
=
V
U=V
U=V时,
T
V
=
(
V
,
E
V
)
T_V=(V,E_V)
TV=(V,EV)是某棵最小生成树的子树,而这棵最小生成树就是
T
V
T_V
TV本身,因此我们求得了
G
G
G的一棵最小生成树。∎
代码实现:
(1) 不加优化的暴力解法
暴力选取边 e = ( u ∗ , v ∗ ) e=(u^*,v^*) e=(u∗,v∗)。时间复杂度 O ( n m ) O(nm) O(nm)。
int Prim()
{
in[1] = true; // u0
int ans = 0;
for(int i = 1; i < n; ++i)
{
int min_val = 1e9, min_e = -1;
for(int u = 1; u <= n; ++u)
{
if(!in[u]) continue;
for(int e = first[u]; e; e = nxt[e])
{
int v = go[e];
if(in[v]) continue;
if(val[e] < min_val)
{
min_val = val[e];
min_e = e;
}
}
}
in[go[min_e]] = true;
ans += min_val;
}
return ans;
}
(2) 堆优化的Prim算法
用堆来优化选取权值最小的边
e
=
(
u
∗
,
v
∗
)
e=(u^*,v^*)
e=(u∗,v∗)的过程。我们定义vis
数组表示节点是否属于集合
U
U
U,dis
数组表示
V
−
U
V-U
V−U中的节点到
U
U
U的最短边。每当我们向
U
U
U中加入一个节点,就将它的vis
标记为true
,并更新它所连接的节点的dis
值,若dis
值被更新就将这条边加入堆中。从堆中取出最小值时,有可能边的两个端点都在
U
U
U中了,所以要检查端点的vis
值。注意,堆中存储的边不是集合
M
=
{
(
u
,
v
)
∈
E
∣
u
∈
U
,
v
∈
V
−
U
}
M=\{(u,v)\in E|u\in U,v\in V-U\}
M={(u,v)∈E∣u∈U,v∈V−U},有两点区别:
① 堆中可能会有两端点都属于
U
U
U的边;
② 当dis
值没有被更新时,该边不会入队,从而降低了时间复杂度。
时间复杂度:下面的代码复杂度高达
O
(
m
log
m
)
O(m\log m)
O(mlogm),因为同一个v
可能在Q
里出现多次,导致堆中元素数量在
O
(
m
)
O(m)
O(m)级别。但堆优化的Prim算法复杂度理论上是
O
(
m
log
n
)
O(m\log n)
O(mlogn)的,在
Q
Q
Q里进行的操作是Decrease Key
,即改变某个节点对应的dis
值,这样每个节点只会在堆中出现一次。
int dis[MAXN];
bool vis[MAXN];
int Prim()
{
int ans = 0;
memset(dis, 0x3f, sizeof(dis));
priority_queue<node> Q;
Q.push({1, 0});
for(int cnt = 1; cnt <= n;)
{
node nd = Q.top();
Q.pop();
int u = nd.u;
if(vis[u]) continue;
vis[u] = true;
ans += nd.d;
++cnt;
for(int e = first[u]; e; e = nxt[e])
{
int v = go[e];
if(dis[v] > val[e])
{
dis[v] = val[e];
Q.push({v, val[e]});
}
}
}
return ans;
}
四、Kruskal算法
Kruskal算法的基本思想如下:
(1) 初始状态为
T
=
(
V
,
∅
)
T=(V,\emptyset)
T=(V,∅),即开始时最小生成树
T
T
T中只包含了所有的顶点,而没有边,此时
T
T
T中有
n
n
n个连通分量。
(2) 将
E
E
E中的边按权值递增的顺序排列,并按照这一顺序一次尝试将边加入最小生成树
T
T
T中:如果这条边的端点分别位于
T
T
T的不同的连通分量中,则将该边加入
T
T
T;否则舍弃该边(为了保证不出现环)。
依此类推,直到
T
T
T中有
n
−
1
n-1
n−1条边为止,此时
T
T
T中只有一个连通分量。
正确性证明:
考虑加入边 e = ( u , v ) e=(u,v) e=(u,v)的操作。假设已经被算法选定的边集为 E U E_U EU,令 E U E_U EU中所有边关联的所有顶点的集合为 U U U。显然, U U U和 V − U V-U V−U是不连通的。根据定理2,我们只需证明 e e e是连接集合 U U U和 V − U V-U V−U的权值最小的边,就能推出最后得到的树是最小生成树。
假设还有比 e e e权值更小的连接集合 U U U和 V − U V-U V−U的边 e ′ e' e′,则根据算法的步骤, e ′ e' e′一定会在 e e e之前被算法考虑。因为此时 U U U和 V − U V-U V−U一定不连通(否则后面考虑 e e e的时候 U U U和 V − U V-U V−U就连通了),所以 e ′ e' e′一定会被选择。但加入边 e ′ e' e′后会导致 U U U和 V − U V-U V−U连通,与已知条件矛盾。所以假设不成立,我们证明了 e e e是连接集合 U U U和 V − U V-U V−U的权值最小的边。∎
代码实现:
边按权值排序+并查集。时间复杂度 O ( m log m ) O(m\log m) O(mlogm)。
struct edge
{
int u, v, w;
bool operator<(const edge& o) const
{
return w < o.w;
}
} e[MAXM];
int fa[MAXN]; // 并查集
int getfa(int x)
{
return x == fa[x] ? x : fa[x] = getfa(fa[x]);
}
int Kruskal()
{
sort(e + 1, e + m + 1); // 按边权排序
for(int i = 1; i <= n; ++i) fa[i] = i;
int k = 0, ans = 0;
for(int i = 1; i <= m && k < n; ++i)
{
int x = getfa(e[i].u);
int y = getfa(e[i].v);
if(x != y)
{
++k;
ans += e[i].w;
fa[x] = y;
}
}
return ans;
}