最小生成树

关于图的几个概念定义:

  • 连通图:在无向图中,若任意两个顶点vi与vj都有路径相通,则称该无向图为连通图。
  • 强连通图:在有向图中,若任意两个顶点vi与vj都有路径相通,则称该有向图为强连通图。
  • 连通网:在连通图中,若图的边具有一定的意义,每一条边都对应着一个数,称为;权代表着连接连个顶点的代价,称这种连通图叫做连通网。
  • 生成树:一个连通图的生成树是指一个连通子图,它含有图中全部n个顶点,但只有足以构成一棵树的n-1条边。一颗有n个顶点的生成树有且仅有n-1条边,如果生成树中再添加一条边,则必定成环
  • 最小生成树:在连通网的所有生成树中,所有边的代价和最小的生成树,称为最小生成树。
    在这里插入图片描述

两种求最小生成树算法

1.Kruskal算法

此算法可以称为“加边法”,初始最小生成树边数为0,每迭代一次就选择一条满足条件的最小代价边,加入到最小生成树的边集合里。

  1. 把图中的所有边按代价从小到大排序;
  2. 把图中的n个顶点看成独立的n棵树组成的森林;
  3. 按权值从小到大选择边,所选的边连接的两个顶点ui,vi应属于两颗不同的树,则成为最小生成树的一条边,并将这两颗树合并作为一颗树。
  4. 重复(3),直到所有顶点都在一颗树内或者有n-1条边为止。
    在这里插入图片描述

2.Prim算法

此算法可以称为“加点法”,每次迭代选择代价最小的边对应的点,加入到最小生成树中。算法从某一个顶点s开始,逐渐长大覆盖整个连通网的所有顶点。

图的所有顶点集合为V;初始令集合u={s},v=V−u;
在两个集合u,v能够组成的边中,选择一条代价最小的边(u0,v0),加入到最小生成树中,并把v0并入到集合u中。
重复上述步骤,直到最小生成树有n-1条边或者n个顶点为止。

由于不断向集合u中加点,所以最小代价边必须同步更新;需要建立一个辅助数组closedge,用来维护集合v中每个顶点与集合u中最小代价边信息,:

struct
{
  char vertexData   //表示u中顶点信息
  UINT lowestcost   //最小代价
}closedge[vexCounts]

在这里插入图片描述

例题

leetcode1584. 连接所有点的最小费用

给你一个points 数组,表示 2D 平面上的一些点,其中 points[i] = [xi, yi] 。
连接点 [xi, yi] 和点 [xj, yj] 的费用为它们之间的 曼哈顿距离 :|xi - xj| + |yi - yj| ,其中|val| 表示 val 的绝对值。
请你返回将所有点连接的最小总费用。只有任意两点之间 有且仅有 一条简单路径时,才认为所有点都已连接。

方法一: Prim算法:

Part1. 解题思路

抽象(假想:假设存在,但不存在)出两个集合,集合V 和集合Vnew

最开始,所以的图节点都在集合V中

如果一个节点加入到了最小生成树中,则将该节点加入到Vnew(即Vnew保存的是最小生成树中的节点)

说明: Vnew即最小生成树

Part2. 数据结构

Prim算法主要维护2个数组

lowcost 数组,表示V中的节点,保存V中每个节点离Vnew中所有节点的最短距离。如果节点已经加入到了Vnew中,则置为-1

v 数组,表示V中节点的访问情况,最开始全部为0,表示未加入到Vnew中,若某节点加入到了Vnew中, 则将其置为-1

Part3. 步骤:

  1. 随机选择一个起点,并将其加入到Vnew中。同时,更新此时的lowcost和v
  2. 遍历lowcost,寻找lowcost中的最小值min(假设索引为 j ),将与索引 j相对应的节点加入到Vnew中,更新lowcost[j]和v[j]。
  3. 找到最小值 j后,此时lowcost中的所有节点都要更新,因为此时Vnew节点增加了,V集合中的节点离Vnew集合的距离可能会缩短。
  4. 此时已更新完所有的lowcost。
  5. 重复步骤2,直到访问了所有的节点。

很明显,最后需要计算的最小生成树各节点之间的距离和便是每一步lowcost中的最小值min的和

Part4. 举例:

在这里插入图片描述
Part5.代码

class Solution {
public:
    int prim(vector<vector<int> >& points, int start) {
        int n = points.size();
        int res = 0;
        // 1. 将points转化成邻接矩阵, 这一步可有可无
        vector<vector<int> > g(n, vector<int>(n));
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                int dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]);
                g[i][j] = dist;
                g[j][i] = dist;
            }
        }
        // 记录V[i]到Vnew的最近距离
        vector<int> lowcost(n, INT_MAX);
        // 记录V[i]是否加入到了Vnew
        vector<int> v(n, -1);

        // 2. 先将start加入到Vnew
        v[start] = 0;
        for (int i = 0; i < n; i++) {
            if (i == start) continue;
            lowcost[i] = g[i][start];
        }

        // 3. 剩余n - 1个节点未加入到Vnew,遍历
        for (int i = 1; i < n; i++) {
            // 找出此时V中,离Vnew最近的点
            int minIdx = -1;
            int minVal = INT_MAX;
            for (int j = 0; j < n; j++) {
                if (v[j] == 0) continue;
                if (lowcost[j] < minVal) {
                    minIdx = j;
                    minVal = lowcost[j];
                }
            }
            
            // 将该点加入Vnew,更新lowcost和v
            res += minVal;
            v[minIdx] = 0;
            lowcost[minIdx] = -1;

            // 更新集合V中所有点的lowcost
            for (int j = 0; j < n; j++) {
                if (v[j] == -1 && g[j][minIdx] < lowcost[j]) {
                    lowcost[j] = g[j][minIdx];
                }
            }
        }
        return res;

    }
    int minCostConnectPoints(vector<vector<int>>& points) {
        return prim(points, 0);  
    }
};

Part6. 分析
时间复杂度:O(n * n)
空间复杂度:O(n * n)

需要注意的是,这里的Prim算法是最常规的实现,每次寻找距离Vnew最近的点都有着O(n)的时间复杂度。而在实际应用过程中,使用堆来降低时间复杂度为O(m * log(n))的, m是连通图的边数。 Prim算法的时间复杂度与堆的实现方式有关,二叉堆或者斐波拉契堆,具体未深究。

补充一个Prim的堆优化

class Solution {
public:
    int prim(vector<vector<int> >& points, int start) {
        int n = points.size();
        if (n == 0) return 0;
        int res = 0;

        // 将points转化成邻接表
        vector<vector<int> > g(n);
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (i == j) continue;
                g[i].push_back(j);
                g[j].push_back(i);
            }
        }
        
        // 记录V[i]到Vnew的最近距离
        vector<int> lowcost(n, INT_MAX);
        // 记录V[i]是否加入到了Vnew
        vector<int> v(n, -1);

        // 格式:<距离, 下标>
        priority_queue<pair<int, int>, vector<pair<int, int> >, greater<> > pq;
        pq.push(make_pair(0, start));
        
        while (!pq.empty()) {
            auto [dist, i] = pq.top();
            pq.pop();
            if (v[i] == 0) continue;
            v[i] = 0;
            res += dist;

            for (int k = 0; k < g[i].size(); k++) {
                int j = g[i][k];
                int w = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]);
                if (v[j] == -1 && lowcost[j] > w) {
                    lowcost[j] = w;
                    pq.push(make_pair(w, j));
                }
            }
        }
        return res;

    }
    int minCostConnectPoints(vector<vector<int>>& points) {
        return prim(points, 0);  
    }
};

方法二、Kruskal(并查集)

并查集不了解的可以学习此博客

Part1. 解题思路

Kruskal算法与prim算法不同:

Prim算法是以顶点为基础(每次寻找离Vnew最近的顶点);

而Kruskal算法是以为基础,每次从边集合中寻找最小的边(不管两个顶点属于V还是Vnew),然后判断该边的两个顶点是否属于同一个连通图

Kruskal需要对所有的边进行排序,然后从小到大,依次遍历每条边,同时判断每条边是否属于同一个连通图,直到所有顶点属于同一个连通图,算法结束。

看到这里,数据结构已经很明显了,没错,我们离不开并查集了。

Part2. 数据结构

因为算法要求我们对所有边进行排序,同时需要知道每条边的两个端点

所以可以建立一个结构体,保存以上三个属性<start,end,len>(这里称为点-边式)。

其中start和end分别为两个顶点, len为两顶点的权值,即两点之间的距离

struct VP {
    int start; // 顶点1
    int end;   // 顶点2
    int len;   // 长度
};

Part3. 步骤

  1. 初始化:将图(邻接矩阵或邻接表)转换成点-边式,并对点-边式按边长度进行排序。同时,初始化并查集(有关并查集,这里就不过多赘述)。
  2. 依次遍历所有的点-边式,取最小值。
  3. 作如下判断:如果该点-边式的两个顶点同源,跳过;如果该点-边式的两个顶点不同源,则将这两个源(连通分量)合并
  4. 重复步骤2,直到存在一个连通分量,包含了所有的节点 算法结束

Part4. 举例
在这里插入图片描述
Part5.代码

class Djset{
public:
    vector<int> parent;//记录根节点
    vector<int> rank;
    vector<int> size;//记录每个连通分量的节点个数
    vector<int> len;//记录每个连通分量的所有边长度
    int num;//记录节点个数

    //构造函数 初始化并查集
    Djset(int n): parent(n), rank(n), len(n, 0), size(n, 1), num(n){
        for(int i = 0; i < n; i++){
            parent[i] = i;
        }
    }
    //查
    int find(int x){
        // 压缩方式:直接指向根节点
        if (x != parent[x]) {
            parent[x] = find(parent[x]);
        }
        return parent[x];
    } 
    //并:length为两点间的距离
    int merge(int x, int y, int length) {
        int rootx = find(x);
        int rooty = find(y);
        if (rootx != rooty) {
            if (rank[rootx] < rank[rooty]) {
                swap(rootx, rooty);
            }
            parent[rooty] = rootx;
            if (rank[rootx] == rank[rooty]) rank[rootx] += 1;
            // rooty的父节点设置为rootx,同时将rooty的节点数和边长度累加到rootx,
            size[rootx] += size[rooty];
            len[rootx] += len[rooty] + length;
            // 如果某个连通分量的节点数 包含了所有节点,直接返回边长度
            if (size[rootx] == num) return len[rootx];
        }
        return -1;
    }
};

struct Edge{
    int start;//顶点1
    int end;//顶点2
    int len;//曼哈顿距离
};

class Solution {
public:
    int minCostConnectPoints(vector<vector<int>>& points) {
        int res = 0;
        int n = points.size();
        Djset ds(n);
        vector<Edge> edges;
        // 建立点-边式数据结构
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                Edge edge = {i, j, abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1])};
                edges.emplace_back(edge);
            }
        }
        // 按边长度排序
        sort(edges.begin(), edges.end(), [](const auto& a, const auto& b) {
            return a.len < b.len;
        });

        // 连通分量合并
        for (auto& e : edges) {
           res = ds.merge(e.start, e.end, e.len);
           if (res != -1) return res;
        }
        return 0;
    }
};

Part6. 分析

时间复杂度:O(m log(m) + m α(m) ), 排序带来了m log(m)的时间复杂度,并查集合并带来mα(m)的时间复杂度,m为索引对的数量,近似于n * n。
空间复杂度:O(n * n)

总结

Prim算法,该算法以顶点为单元,与图中边数无关,比较适合于稠密图

Kruskal算法,该算法以边为单元,时间主要取决于边数,比较适合于稀疏图

题解参考链接:https://leetcode-cn.com/problems/min-cost-to-connect-all-points/solution/prim-and-kruskal-by-yexiso-c500/

leetcode1489. 找到最小生成树里的关键边和伪关键边

给你一个 n 个点的带权无向连通图,节点编号为 0 到 n-1 ,同时还有一个数组 edges ,其中 edges[i] = [fromi, toi, weighti] 表示在 fromi 和 toi 节点之间有一条带权无向边。最小生成树 (MST)是给定图中边的一个子集,它连接了所有节点且没有环,而且这些边的权值和最小。
请你找到给定图中最小生成树的所有关键边和伪关键边。如果从图中删去某条边,会导致最小生成树的权值和增加,那么我们就说它是一条关键边。伪关键边则是可能会出现在某些最小生成树中但不会出现在所有最小生成树中的边。
请注意,你可以分别以任意顺序返回关键边的下标和伪关键边的下标。

思路

  1. 实现并查集;(注意初始化并查集用点的个数n,遍历合并时需要次数是边的个数m)
  2. 实现Kruskal算法:
    2.1 记录初始边的下标序号,答案里会用到
    2.2 对边按权值从小到大排序
    2.3 依次遍历排序好的边,进行并查集的合并,计算出生成的最小生成树的 权值和value
  3. 依次枚举去除某个边后得到的最小生成树及新的权值和v,和一开始的value比较判断(具体条件见下面代码注释)
    3.1 先判断该边是否为关键边,符合就continue到下边判断,不符合到3.2
    3.2 因为存在无用的权值大的边过滤掉,所以要先把该边连上,重新计算v,看v是否==value, ==则为伪关键边,否则就是无用边(不用管)。

具体实现细节都在下面代码注释中

代码

//并查集固定写法
class UFSet{
public:
    vector<int> fa, rank;
    int Unlink;//n个点的图,最小生成树会连n-1个边,还剩一个。该变量用来判断是否生成了最小生成树。

    //初始化并查集
    UFSet(int n): fa(n), rank(n, 1), Unlink(n){
        iota(fa.begin(), fa.end(), 0);//iota是STL中自动递增赋值的函数,此处从0开始0,1,2,3...依次赋值
    }
    //查
    int find(int x){
        return fa[x] == x ? x : (fa[x] = find(fa[x]));
    }
    //并
    bool merge(int x, int y){
        int rootx = find(x), rooty = find(y);
        if(rootx == rooty) return false;
        //相当于rank[rootx] >= rank[rooty],虽然交换了,只是为了方便处理。连在一个连通域后,根节点变为同一个,和不交换连一样
        if(rank[rootx] < rank[rooty]){
            swap(rootx, rooty);
        }
        fa[rooty] = rootx;
        if(rank[rootx] == rank[rooty]) rank[rootx]++;
        Unlink--;//连了一条边就减一,最后连完判断是否等于1就知道是否生成了最小生成树
        return true;
    }
};

/*
1.关键边:
如果最小生成树中删去某条边,会导致最小生成树的权值和增加,那么我们就说它是一条关键边。也就是说,如果设原图最小生成树的权值和为value,那么去掉这条边后:
    要么整个图不连通,不存在最小生成树;
    要么整个图联通,对应的最小生成树的权值和为 v,其 严格大于 初始的value。

2.伪关键边:
可能会出现在某些最小生成树中但不会出现在所有最小生成树中的边。也就是说,我们可以在计算最小生成树的过程中,最先考虑这条边,即最先将这条边的两个端点在并查集中合并。设最终得到的最小生成树权值和为 v,如果 v=value,那么这条边就是伪关键边。

需要注意的是,关键边也满足伪关键边对应的性质。因此,我们首先对原图执行 Kruskal 算法,得到最小生成树的权值和 value,随后我们枚举每一条边,首先根据上面的方法判断其是否是关键边,如果不是关键边,再判断其是否是伪关键边。

***注意:排序后原来edgs中边的顺序就变了,需要先记录下初始边的下标顺序,后面添加边idx才不会错!!!
*/

class Solution {
public:
    vector<vector<int>> findCriticalAndPseudoCriticalEdges(int n, vector<vector<int>>& edges) {
        int m = edges.size();
        vector<vector<int>> ans(2);

        //勿忘:先把edges中初始边的下标顺序记录下来,后面返回答案会用,否则排序后就乱了
        for(int i = 0; i < m; i++) edges[i].push_back(i);
        //1.把edges按权值从小到大排序
        sort(edges.begin(), edges.end(), [](const auto &a, const auto &b){return a[2] < b[2];});

        //2.生成最小生成树,计算权值和 value
        UFSet ufs_base(n);//点的个数来初始化
        int value = 0; 
        for(int i = 0; i < m; i++){//边个数来遍历
            if(ufs_base.merge(edges[i][0], edges[i][1])){
                value += edges[i][2];//如果两点连接了,则累加权值
            }
        }
        
        //3.1开始枚举依次去掉每条边后生成的最小生成树与原value的关系,从而判断当前去掉的边是 关键边 还是 非关键边
        for(int i = 0; i < m; i++){//控制每次去掉的边
            UFSet ufs_tmp(n);
            int v = 0;//每次生成和判断,都需要重新开始
            for(int j = 0; j < m; j++){
                if(i != j && ufs_tmp.merge(edges[j][0], edges[j][1])){//i==j后面就不执行了
                    v += edges[j][2];
                }
            }
            //生成完一个就来判断当前去掉的边属于什么类型
            //不能直接if else,因为存在权值很大的无用边 既不属于 关键边 也不属于 伪关键边。
            if(ufs_tmp.Unlink != 1 || (ufs_tmp.Unlink == 1 && v > value)){
                ans[0].push_back(edges[i][3]);//属于关键边就加入答案
                continue;//后面伪关键边不判断,开始下一条边
            }
            //3.2不属于关键边,就判断伪关键边:先把该边连上,也不影响最终v==value则为伪关键边,这样就可过滤掉无用的权值大的边
            ufs_tmp = UFSet(n);//重新初始化
            ufs_tmp.merge(edges[i][0], edges[i][1]);
            v = edges[i][2];
            for(int j = 0; j < m; j++){
                if(i != j && ufs_tmp.merge(edges[j][0], edges[j][1])){
                    v += edges[j][2];
                }
            }
            if(v == value) ans[1].push_back(edges[i][3]);
        }
        return ans;   
    }
};
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值