【数据结构与算法/图论】Prim和Kruskal最小生成树算法正确性的证明

一、最小生成树简介

一个有 n n n个顶点的连通图 G = ( V , E ) G=(V,E) G=(V,E)的生成树是包含 G G G中全部顶点的一个极小连通子图,它有且仅有 n − 1 n-1 n1条边。也就是说,如果添加一条边,则构成回路;如果删去任何一条边,则生成树不再连通。一个生成树的代价为该生成树中所有边权的总和。称代价最小的生成树为最小生成树(Minimum Spanning Tree, MST)。

最小生成树是图的一种重要应用,在城市道路交通规划、网络路由选择、城市通信网架设等实际问题中应用广泛。例如,在 n n n个城市之间架设通信网路,最多可设置 n ( n − 1 ) 2 n(n-1)\over2 2n(n1)条线路,每条线路都有一定的成本,如何从这 n ( n − 1 ) 2 n(n-1)\over2 2n(n1)条线路中选择 n − 1 n-1 n1条线路,使得总成本最小?将这一问题表示为带权连通图,用图中的顶点表示城市,边表示城市之间的通信线路,边的权值为设置该线路所需的成本,则问题就可以转化为求这个图的最小生成树。

二、最小生成树的性质

定理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 FET(即 F F F T T T的边集的子集), U ⊂ V U\subset V UV 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)EuU,vVU}是所有连接 U U U V − U V-U VU的边的集合,且 F ∩ M = ∅ F\cap M=\emptyset FM=。设 e e e M M M中权值最小的边,则 F ∪ { e } F\cup\{e\} F{e}是某个最小生成树 T ′ T' T(可能不等于 T T T)的边集的子集。
证明:若 e ∈ T e\in T eT,则无需证明。所以我们假设 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 eETM(即 e ′ e' e T T T中连接 U U U V − U V-U VU的桥梁),则有 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是一棵最小生成树。∎

定理2示意图

三、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 u0V U U U中唯一的元素;令 E T = ∅ E_T=\emptyset ET=,即一开始树中没有边。
(2) 在所有满足 u ∈ U u\in U uU v ∈ V − U v\in V-U vVU的边 ( 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=n1 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 Udis数组表示 V − U V-U VU中的节点到 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)EuU,vVU},有两点区别:
① 堆中可能会有两端点都属于 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 n1条边为止,此时 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 VU是不连通的。根据定理2,我们只需证明 e e e是连接集合 U U U V − U V-U VU的权值最小的边,就能推出最后得到的树是最小生成树。

假设还有比 e e e权值更小的连接集合 U U U V − U V-U VU的边 e ′ e' e,则根据算法的步骤, e ′ e' e一定会在 e e e之前被算法考虑。因为此时 U U U V − U V-U VU一定不连通(否则后面考虑 e e e的时候 U U U V − U V-U VU就连通了),所以 e ′ e' e一定会被选择。但加入边 e ′ e' e后会导致 U U U V − U V-U VU连通,与已知条件矛盾。所以假设不成立,我们证明了 e e e是连接集合 U U U V − U V-U VU的权值最小的边。∎

代码实现

边按权值排序+并查集。时间复杂度 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;
}

参考文献

  1. https://courses.cs.duke.edu/spring19/compsci330/lecture13scribe.pdf
  2. https://courses.cs.duke.edu/spring19/compsci330/lecture14scribe.pdf
  • 12
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Prim算法构造最小生成树的步骤如下: 1. 初始化一个空的最小生成树集合,将任意一个顶点加入其中。 2. 对于所有不在最小生成树集合中的点,计算它与最小生成树集合中点的边的权重,选择其中权重最小的边所连接的点加入最小生成树集合中。 3. 重复步骤2直到最小生成树集合包含所有点。 C语言中给定图的邻接矩阵数据结构Prim算法的代码如下: ```c #define INF 0x3f3f3f3f // 定义正无穷 int prim(int n, int graph[][n]) { int i, j, k; int lowcost[n]; // 存储当前点到最小生成树集合的最短距离 int closest[n]; // 存储当前点到最小生成树集合中距离最近的点 int sum = 0; // 最小生成树的权值和 for (i = 1; i < n; i++) { lowcost[i] = graph[0][i]; // 初始化当前点到最小生成树集合的距离 closest[i] = 0; // 初始化当前点到最小生成树集合中距离最近的点为0 } for (i = 1; i < n; i++) { int min = INF; for (j = 1; j < n; j++) { if (lowcost[j] > 0 && lowcost[j] < min) { min = lowcost[j]; k = j; } } sum += min; lowcost[k] = 0; for (j = 1; j < n; j++) { if (lowcost[j] > 0 && graph[k][j] < lowcost[j]) { lowcost[j] = graph[k][j]; closest[j] = k; } } } return sum; } ``` Kruskal算法构造最小生成树的步骤如下: 1. 初始化一个空的最小生成树集合,将所有边按照权重从小到大排序。 2. 依次选择权重最小的边,如果边所连接的两个点不在同一个连通块中,则将这条边加入最小生成树集合中。 3. 重复步骤2直到最小生成树集合包含所有点。 C语言中给定图的邻接矩阵数据结构Kruskal算法的代码如下: ```c #define MAX_EDGE (n * (n - 1) / 2) // 最大边数 typedef struct { int u, v, w; // 边的两个端点和权重 } Edge; int cmp(const void* a, const void* b) { return ((Edge*)a)->w - ((Edge*)b)->w; } int find(int parent[], int i) { if (parent[i] == -1) { return i; } return find(parent, parent[i]); } void union_set(int parent[], int x, int y) { int xset = find(parent, x); int yset = find(parent, y); parent[xset] = yset; } int kruskal(int n, int graph[][n]) { Edge edges[MAX_EDGE]; // 存储所有边 int parent[n]; // 存储每个点所在的连通块 int sum = 0; // 最小生成树的权值和 int e = 0; // 已加入最小生成树集合的边数 for (int i = 0; i < n; i++) { parent[i] = -1; for (int j = i + 1; j < n; j++) { if (graph[i][j] != 0) { edges[e].u = i; edges[e].v = j; edges[e].w = graph[i][j]; e++; } } } qsort(edges, e, sizeof(Edge), cmp); int i = 0; while (e < n - 1 && i < n * (n - 1) / 2) { int u = edges[i].u; int v = edges[i].v; int w = edges[i].w; i++; int x = find(parent, u); int y = find(parent, v); if (x != y) { sum += w; union_set(parent, x, y); e++; } } return sum; } ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值