最小生成树与并查集(leetcode684,685, 721)

最小生成树

说道并查集,不得不提的是最小生成树,因为并查集的最经典的应用就是解决最小生成树的Kruskal算法。

有两个经典的算法可以用来解决最小生成树问题:Kruskal算法和Prim算法。其中Kruskal算法中便应用了并查集这种数据结构。

Kruskal算法

  • 新建图G,G中拥有原图中相同的结点,但是没有边
  • 将原图中所有边按照权值从小到大排序
  • 从权值最小的边开始,如果这条边连接的两个节点不在一个连通分量中(也就是不形成环),则添加这条边到图G中
  • 重复3,直至图G中所有的节点都在同一个连通分量中
  • 在这里插入图片描述
    Kruskal算法是一种贪心算法,并且已经呗证明最终能够收敛到最好的结果。在实现Krusal算法时,则需要用到并查集这种数据结构来减小算法的时间复杂度,下面将详细介绍这种数据结构。

Prime算法

实现最小生成树还有一种算法叫做Prime算法,Prime算法维护的是顶点的集合,而Kruskal维护的是边的集合。
Prime算法过程:

  • 输入:顶点集合为 V V V,边集合为 E E E
  • 初始化: V n e w = n u l l , E n e w = n u l l V_{new}=null, E_{new}=null Vnew=nullEnew=null
  • 随机选择一个结点v加入到 V n e w V_{new} Vnew
  • 重复下列操作,直到 V n e w = V V_{new}=V Vnew=V
    1. 在集合 E E E中选取权值最小的边 ( u , v ) (u, v) (u,v),其中 u u u为集合 V n e w V_{new} Vnew中的元素,而 v v v V − V n e w V-V_{new} VVnew中的顶点;
    2. 将 v v v加入到集合 V n e w V_{new} Vnew中,将 ( u , v ) (u,v) (u,v)加入到边集合 E n e w E_{new} Enew中;
  • 输出:使用集合 V n e w V_{new} Vnew E n e w E_{new} Enew来描述所得到的最小生成树
    merge在这里插入图片描述

并查集

Kruskal算法中需要判断两个节点是否在同一个连通分量中,如何判断是否是一个连通分量呢??也就判断是否加入新边之后,是否会和原来已经添加的边形成环路,并查集正是高效的实现了这个功能。

三个操作

  • MakeSet是初始化操作,即为每一个node创建一个连通分量,且这个node为这个连通分量的代表,这里的连通分量的代表指的是当连通分量中有多个点中,需要从这些点中选出一个点来代表这个连通分量,而这个点也被称为这个连通分量的parent;
  • Find是指找到这个点所属的连通分量的parent;
  • Union是指将两个连通分量合并成一个连通分量,并选出代表这个连通分量的新的parent;

如何通过以上操作判断某条边是否会与原来的边集形成环路呢?

  1. 给定一条边,为这两条边的两个顶点执行find操作,如果两个顶点的parent一样,那么就说明这两个点已经在同一个连通分量中,再添加就会导致闭环,不添加该边;
  2. 当两个点的parent不同时,即两个点在不同的连通分量中,需要通过union操作将这两个连通分量连起来;
  3. 重复1,2步操作直到所有的边遍历完;

具体题目

leetcode 684

684.Redundant Connection这道题目实际上就是要找到一个无向图中形成环路的最后那条边(输入保证了所有边会形成回路)

vector<int> findRedundantConnection(vector<vector<int>>& edges) {
        vector<int> root(2000, 0);
        for(int i=0; i<2000; i++)
            root[i]=i;
        vector<int> res;
        for(auto edge : edges)
        {
            int a = edge[0];
            int b = edge[1];
            #find
            while(root[a]!=a)
                a = root[a];
            while(root[b]!=b)
                b = root[b];
            #union
            if(a==b)
                res = edge;
            else
                root[a]=b;
        }
        return res;
        
    }

首先初始化,将每个顶点设为单独的连通分量,每个顶点的根节点为自己。
每次find操作的时间复杂度为 O ( n ) O(n) O(n)
每次union的时间复杂度为 O ( 1 ) O(1) O(1)
将b结点所在连通子图的根节点当做a结点所在联通子图的根节点的根节点,也就是将a结点所在的联通子图当做b的根节点的子树。这样就将两个连通子图连通为一个连通子图。
所以总的时间复杂度为 O ( m n ) O(mn) O(mn),那么有没有一种改进总体时间复杂度的方法呢?

path compression和union by rank

Path compression:
将连通分量看作为一棵树,在循环的find操作中,将树中的每个结点都连接到parent结点,从而降低树的高度。

降低树的高度就是能够降低查找的时间复杂度,从 O ( n ) O(n) O(n)降为了 O ( l o g n ) O(logn) O(logn),因为原来的递归搜索实际上是在每个结点只有一个子节点的树上进行搜索,树的高度即为结点的个数,而通过path compression则能够有效的降低树的高度。

Union by rank:
另外一个问题就是进行Union操作时,需要将高度低的树连接到高度教高的树上,目的是减少union后的整棵树的高度。rank代表的就是树的高度。

采用了path compression和union by rank之后,find的时间复杂度变为了 O ( l o g n ) O(logn) O(logn),union的时间复杂度为 O ( 1 ) O(1) O(1),因此总的时间复杂度为 O ( m l o g n ) O(mlogn) O(mlogn) m m m为边的数目,而 n n n为点的数目。改进后的代码如下:

int root[1001];
    int rank[1001];
    int find(int node)
    {
	    int ans = node;
	    while(root[node]!=node)
		    node = root[node];
	    int r = node;
	    while(root[ans]!=r)
	    {
		    int tmp = ans;
		    ans = root[ans];
		    root[tmp] = r;
	    }
	    return r;
    }
    vector<int> findRedundantConnection(vector<vector<int>>& edges)
    {
        
        for(int i=0; i<1000; i++)
        {
            root[i]=i;
            rank[i]=0;
        }
        vector<int> res;
        for(auto edge : edges)
        {
            int a = edge[0];
            int b = edge[1];
            #find
            int p1 = find(a);
            int p2 = find(b);
            #union
            if(p1==p2)
                res = edge;
            else if(rank[p1]>rank[p2])
            {
                root[p2] = p1;
            }
            else if(rank[p1]<rank[p2])
            {
                root[p1] = p2;
            }
            else
            {
                rank[p2]+=1;
                root[p1]=p2;
            }
        }
        return res;
    }

leecode 685

685. Redundant Connection ||从前面的无向图升级到了有向图,对应的要求从原来的仅要求不形成环路升级到在不形成环路的基础上,拓扑必须要是一棵合法树,也就是每个点只能有一个父节点,例如 [[2,1],[3,1]] 这两条边虽然没有形成环路,但是 1 有两个父亲节点(2和3),因此不是一棵合法的树。

由于题目说明了输入只有一条不合法的边,因此首先可以统计一下这些边中是否存在某个点有两个父亲节点,假如有,则需要移除的边必定为连着这个点的两条边中的一条,通过上面 Union-find 的方法,可以判断出假如移除掉连着这个点的第一条边时,是否会形成回路。如果会,则说明需要移除第二条边,否则直接移除第一条边。

leetcode 721

721. Accounts-merge该任务的任务是连接同一个account的email,这非非常适用于并查集来实现。为了将这些emails进行group,每个group需要有一个代表(父节点)。在最初, 每个email是其自己的代表。每个accont中的emails很自然的属于同一个group,应该被分配到相同的parent。选择每个account中的第一个email作为父节点。在其后进行find和union操作进行查找和更新父节点。

class Solution {
public:
    vector<vector<string>> accountsMerge(vector<vector<string>>& acts) {
        map<string, string> owner;
        map<string, string> parents;
        map<string, set<string>> unions;
        for (int i = 0; i < acts.size(); i++) {
            for (int j = 1; j < acts[i].size(); j++) {
                parents[acts[i][j]] = acts[i][j];
                owner[acts[i][j]] = acts[i][0];
            }
        }
        for (int i = 0; i < acts.size(); i++) {
            string p = find(acts[i][1], parents);
            for (int j = 2; j < acts[i].size(); j++)
                parents[find(acts[i][j], parents)] = p;
        }
        for (int i = 0; i < acts.size(); i++)
            for (int j = 1; j < acts[i].size(); j++)
                unions[find(acts[i][j], parents)].insert(acts[i][j]);

        vector<vector<string>> res;
        for (pair<string, set<string>> p : unions) {
            vector<string> emails(p.second.begin(), p.second.end());
            emails.insert(emails.begin(), owner[p.first]);
            res.push_back(emails);
        }
        return res;
    }
private:
    string find(string s, map<string, string>& p) {
        return p[s] == s ? s : find(p[s], p);
    }
};

参考资料

吴良超的leetcode解题报告

©️2020 CSDN 皮肤主题: 终极编程指南 设计师:CSDN官方博客 返回首页