并查集

一、基本概念和定义

参考文章
并查集(Union-find Sets)是一种非常精巧而实用的数据结构,它主要用于处理一些不相交集合的合并问题。一些常见的用途有求连通子图、求最小生成树的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。
使用并查集时,首先会存在一组不相交的动态集合 ,一般都会使用一个整数表示集合中的一个元素。每个集合可能包含一个或多个元素,并选出集合中的某个元素作为代表。每个集合中具体包含了哪些元素是不关心的,具体选择哪个元素作为代表一般也是不关心的。我们关心的是,对于给定的元素,可以很快的找到这个元素所在的集合(的代表),以及合并两个元素所在的集合,而且这些操作的时间复杂度都是常数级的。

并查集的基本操作有三个:
init():初始化一个新的并查集,其中包含 s 个单元素集合。
merge(x, y):把元素 x 和元素 y 所在的集合合并,要求 x 和 y 所在的集合不相交,如果相交则不合并。
find(x):找到元素 x 所在的集合的代表,该操作也可以用于判断两个元素是否位于同一个集合,只要将它们各自的代表比较一下就可以了。

二、原理解释

相信大家每次在搜索资料查询并查集的时候总是邹巴巴的文字,甚至连图片都没有。下面,掀起江湖的腥风血雨来趣味解释并查集的原理。

话说江湖上散落着各式各样的大侠,有上千个之多。他们没有什么正当职业,整天背着剑在外面走来走去,碰到和自己不是一路人的,就免不了要打一架。但大侠们有一个优点就是讲义气,绝对不打自己的朋友。而且他们信奉“朋友的朋友就是我的朋友”,只要是能通过朋友关系串联起来的,不管拐了多少个弯,都认为是自己人。这样一来,江湖上就形成了一个一个的群落,通过两两之间的朋友关系串联起来。而不在同一个群落的人,无论如何都无法通过朋友关系连起来,于是就可以放心往死了打。但是两个原本互不相识的人,如何判断是否属于一个朋友圈呢?我们可以在每个朋友圈内推举出一个比较有名望的人,作为该圈子的代表人物,这样,每个圈子就可以这样命名“齐达内朋友之队”“罗纳尔多朋友之队”……两人只要互相对一下自己的队长是不是同一个人,就可以确定敌友关系了。
但是还有问题啊,大侠们只知道自己直接的朋友是谁,很多人压根就不认识队长,要判断自己的队长是谁,只能漫无目的的通过朋友的朋友关系问下去:“你是不是队长?你是不是队长?”这样一来,队长面子上挂不住了,而且效率太低,还有可能陷入无限循环中。于是队长下令,重新组队。队内所有人实行分等级制度,形成树状结构,我队长就是根节点,下面分别是二级队员、三级队员。每个人只要记住自己的上级是谁就行了。遇到判断敌友的时候,只要一层层向上问,直到最高层,就可以在短时间内确定队长是谁了。由于我们关心的只是两个人之间是否连通,至于他们是如何连通的,以及每个圈子内部的结构是怎样的,甚至队长是谁,并不重要。所以我们可以放任队长随意重新组队,只要不搞错敌友关系就好了。于是,门派产生了。

下面我们来看并查集的实现。
各个门派掌门在成立各派之前,还是孤家寡人的时候,每个元素都是一个单元素集合,即父节点是其自身:
int pre[1000];
这个数组,记录了每个大侠的上级是谁。大侠们从1或者0开始编号(依据题意而定),pre[15]=3就表示15号大侠的上级是3号大侠。如果一个人的上级就是他自己,那说明他就是掌门人了,查找到此为止。也有孤家寡人自成一派的,比如欧阳锋,那么他的上级就是他自己。每个人都只认自己的上级。比如胡青牛同学只知道自己的上级是杨左使。张无忌是谁?不认识!要想知道自己的掌门是谁,只能一级级查上去。

void init(){
    //for(int i=0;i

接下来,就是 find 操作了,find这个函数就是找掌门用的.

int find(int x){
    // return pre[x] == x ? x : find(pre[x]);
    return pre[x] < 0 ? x : find(pre[x]);
}

如果每次都沿着父节点向上查找,那时间复杂度就是树的高度,完全不可能达到常数级。这里需要应用一种非常简单而有效的策略——路径压缩。
再来看看路径压缩算法。建立门派的过程是用join函数两个人两个人地连接起来的,谁当谁的手下完全随机。最后的树状结构会变成什么样,我也完全无法预计,一字长蛇阵也有可能。这样查找的效率就会比较低下。最理想的情况就是所有人的直接上级都是掌门,一共就两级结构,只要找一次就找到掌门了。哪怕不能完全做到,也最好尽量接近。这样就产生了路径压缩算法。
设想这样一个场景:两个互不相识的大侠碰面了,想知道能不能揍。
于是赶紧打电话问自己的上级:“你是不是掌门?”
上级说:“我不是呀,我的上级是谁谁谁,你问问他看看。”
一路问下去,原来两人的最终boss都是东厂曹公公。
“ 哎呀呀,原来是自己,西礼西礼,在下三营六组白面葫芦娃!”
“幸会幸会,在下九营十八组仙子狗尾巴花!”
两人高高兴兴地手拉手喝酒去了。
“等等等等,两位同学请留步,还有事情没完成呢!”我叫住他俩。
“哦,对了,还要做路径压缩。”两人醒悟。
白面葫芦娃打电话给他的上级六组长:“组长啊,我查过了,其习偶们的掌门是曹公公。不如偶们一起及接拜在曹公公手下吧,省得级别太低,以后查找掌门麻环。”
“唔,有道理。”
白面葫芦娃接着打电话给刚才拜访过的三营长……仙子狗尾巴花也做了同样的事情。
这样,查询中所有涉及到的人物都聚集在曹公公的直接领导下。每次查询都做了优化处理,所以整个门派树的层数都会维持在比较低的水平上。路径压缩的代码所实现的功能就是这么个意思。

int find(int x, vector &pre) {  //有路径压缩版
    int p=x, q;
    while(pre[p]>=0) p = pre[p];

    while(x!=p){
        q = pre[x];
        pre[x] = p;
        x = q;
    }
    return x;
}

再来看看merge函数,就是在两个点之间连一条线,这样一来,原先它们所在的两个板块的所有点就都可以互通了。这在图上很好办,画条线就行了。但我们现在是用并查集来描述武林中的状况的,一共只有一个pre[]数组,该如何实现呢?
还是举江湖的例子,假设现在武林中的形势如图所示。虚竹小和尚与周芷若MM是我非常喜欢的两个人物,他们的终极boss分别是玄慈方丈和灭绝师太,那明显就是两个阵营了。我不希望他们互相打架,就对他俩说:“你们两位拉拉勾,做好朋友吧。”他们看在我的面子上,同意了。这一同意可非同小可,整个少林和峨眉派的人就不能打架了。这么重大的变化,可如何实现呀,要改动多少地方?其实非常简单,我对玄慈方丈说:“大师,麻烦你把你的上级改为灭绝师太吧。这样一来,两派原先的所有人员的终极boss都是师太,那还打个球啊!反正我们关心的只是连通性,门派内部的结构不要紧的。”玄慈一听肯定火大了:“我靠,凭什么是我变成她手下呀,怎么不反过来?我抗议!”抗议无效,上天安排的,最大。反正谁加入谁效果是一样的,我就随手指定了一个。

void merge(int x, int y)//虚竹和周芷若做朋友 
{
    int fx = find(x), fy = find(y); //一个老大是玄慈,一个是灭绝师太
    if (fx != fy)//不是同一个人
    {
        pre[fy] += pre[fx];//归顺以后原先是两个门派的成员变为一个门派,人数自然是两个门派之和
        pre[fx] = fy;//方丈很委屈的做了灭绝的小弟 //选定一个原则,如果是靠左原则,则将所有左边的归顺右边,反之亦然。
    }
}

## 三、算法模板

贴一个完整代码:这个程序用于统计n个人,m个两两关系的门派的个数以及每一个门派的人员数量。

#include
#include
#include
using namespace std;

//int find(int x, vector &pre) {  //无路径压缩版
//  while(pre[x]>=0) x=pre[x];
//  return x;
//}

int find(int x, vector &pre) {  //有路径压缩版
    int p=x, q;
    while(pre[p]>=0) p = pre[p];

    while(x!=p){
        q = pre[x];
        pre[x] = p;
        x = q;
    }
    return x;
}

void merge(int x, int y, vector &pre) {   //虚竹和周芷若做朋友
    int fx = find(x, pre);
    int fy = find(y, pre);//一个老大是玄慈,一个是灭绝师太
    if (fx != fy){          //不是同一个人
        pre[fy] += pre[fx];
        pre[fx] = fy;//方丈很委屈的做了灭绝的小弟 ,靠右原则;
    }
}

int main(){
    int n, m;  //n个人,m个两两团伙,团伙之间存在传递性,即A<->B,B<->C那么A.B.C均是同一门派。
    cin >> n >> m;
    int ans=0;
    vector pre(n+1, -1);
    for(int i = 0; i> x >> y;
        merge(x, y, pre);
    }

    for (int i = 1; i<=n; i++){
        if (pre[i]<0){
            ans++;
            cout << -pre[i] << endl;  //输出每一门派的人员数量
        }
    }

    for(auto n:pre)
        cout<< n << " ";
    return 0;
}
/*
6 5
1 3
2 4
2 5
3 6
1 2
*/

四、做几个题检查一下

1.leetcode547-朋友圈

题目描述

班上有 N 名学生。其中有些人是朋友,有些则不是。他们的友谊具有是传递性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么我们可以认为 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。给定一个 N * N 的矩阵 M,表示班级中学生之间的朋友关系。如果M[i][j] = 1,表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。

示例 :
输入:
[[1,1,0],
[1,1,0],
[0,0,1]]
输出: 2
说明:已知学生0和学生1互为朋友,他们在一个朋友圈。
第2个学生自己在一个朋友圈。所以返回2。

解题思路

套并查集模板就行,很简单!

代码

class Solution {
public:
    int find(int x, vector &pre){
        while(pre[x]>=0) x = pre[x];
        return x;
    }

    void merge(int x, int y, vector &pre){
        int fx=find(x, pre);
        int fy=find(y, pre);
        if(fx!=fy){
            pre[fx] += pre[fy];
            pre[fy] = fx;
        }
    }

    int findCircleNum(vector>& M) {
        vector pre(M.size(), -1);
        for(int i=0;i

2.leetcode990-等式方程的可满足性

题目描述

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:"a==b" 或 "a!=b"。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true,否则返回 false。

示例 :
输入:["a==b","b!=a"]
输出:false
解释:如果我们指定,a = 1 且 b = 1,那么可以满足第一个方程,但无法满足第二个方程。没有办法分配变量同时满足这两个方程。

解题思路

套并查集模板就可以,需要注意的是先把所有相等的字符联通,然后再处理不等式,检查是否出现矛盾.

代码

class Solution {
public:
    int find(int x, vector &pre){
        while(pre[x]>=0) x = pre[x];
        return x;
    }

    void merge(int x, int y, vector &pre){
        int fx = find(x, pre);
        int fy = find(y, pre);
        if(fx!=fy){
            pre[fx] += pre[fy];
            pre[fy] = fx;
        }
    }

    bool equationsPossible(vector& equations) {
        vector pre(26, -1);
        for(int i=0;i

3.leetcode130-被围绕的区域

题目描述

给定一个二维的矩阵,包含 'X' 和 'O'(字母 O)。找到所有被 'X' 围绕的区域,并将这些区域里所有的 'O' 用 'X' 填充。

示例:
X X X X
X O O X
X X O X
X O X X
运行你的函数后,矩阵变为:
X X X X
X X X X
X X X X
X O X X
解释:被围绕的区间不会存在于边界上,换句话说,任何边界上的 'O' 都不会被填充为 'X'。 任何不在边界上,或不与边界上的 'O' 相连的 'O' 最终都会被填充为 'X'。如果两个元素在水平或垂直方向相邻,则称它们是“相连”的。

解题思路一(并查集)

我们的思路是把所有边界上的 O看做一个连通区域。遇到 O 就执行并查集合并操作,这样所有的 O就会被分成两类

  • 和边界上的 O 在一个连通区域内的。这些 O 我们保留。
  • 不和边界上的 O 在一个连通区域内的。这些 O就是被包围的,替换。
    由于并查集我们一般用一维数组来记录,方便查找 parants,所以我们将二维坐标用 node 函数转化为一维坐标。
    但是有一个样例过不了,哭了.百思不得其解,心好累!

代码

class Solution {
public:
    int find(int x, vector &pre){
        while(pre[x]>=0) x=pre[x];
        return x;
    }

    void merge(int x, int y, vector &pre){
        int fx=find(x, pre);
        int fy=find(y, pre);
        if(fx!=fy){
            pre[fx] += pre[fy];
            pre[fy] = fx;
        }
    }

    void solve(vector>& board) {
        int m=board.size();
        if(m<=0) return ;
        int n=board[0].size();

        vector pre(m*n+1, -1);
        for(int i=0;i0&&board[i-1][j]=='O') merge((i-1)*n+j, i*n+j, pre);
                        if(i0&&board[i][j-1]=='O') merge(i*n+j-1, i*n+j, pre);
                        if(j

解题思路二(DFS+标记)

  • 边界出现'O'就深搜,改成标记,这里用的是'A',然后再需要一个集合去标记访问过的点,避免重复访问。
  • 最后按标记变更矩阵。

代码

class Solution {
public:
    void dfs(vector>& board, int i, int j){
        if(i>=0&&i=0&&j>& board) {
        int m=board.size();
        if(m<=0) return ;
        int n=board[0].size();

        for(int i=0;i

4.leetcode839-相似字符串组

题目描述

如果我们交换字符串 X 中的两个不同位置的字母,使得它和字符串 Y 相等,那么称 X 和 Y 两个字符串相似。例如,"tars" 和 "rats" 是相似的 (交换 0 与 2 的位置); "rats" 和 "arts" 也是相似的,但是 "star" 不与 "tars","rats",或 "arts" 相似。总之,它们通过相似性形成了两个关联组:{"tars", "rats", "arts"} 和 {"star"}。注意,"tars" 和 "arts" 是在同一组中,即使它们并不相似。形式上,对每个组而言,要确定一个单词在组中,只需要这个词和该组中至少一个单词相似。我们给出了一个不包含重复的字符串列表 A。列表中的每个字符串都是 A 中其它所有字符串的一个字母异位词。请问 A 中有多少个相似字符串组?

示例:
输入:["tars","rats","arts","star"]
输出:2
提示:
A.length <= 2000; A[i].length <= 1000; A.length * A[i].length <= 20000; A 中的所有单词都只包含小写字母。A 中的所有单词都具有相同的长度,且是彼此的字母异位词。此问题的判断限制时间已经延长。
备注:字母异位词[anagram],一种把某个字符串的字母的位置(顺序)加以改换所形成的新词。

解题思路

  • 两个字符串是否相似直接扫一遍看对应位置不同的字母个数
  • 维护并查集,判断是否相似,相似添加进并查集

代码

class Solution {
public:
    int find(int x, vector &pre) {  //有路径压缩版
        int p=x, q;
        while(pre[p]>=0) p = pre[p];

        while(x!=p){
            q = pre[x];
            pre[x] = p;
            x = q;
        }
        return x;
    }
    
    void merge(int x, int y, vector &pre){
        int fx = find(x, pre);
        int fy = find(y, pre);
        if(fx!=fy){
            pre[fx] += pre[fy];
            pre[fy] = fx;
        }
    }

    bool is_ok(string &a, string &b){
        int cnt = 0;
        for(int i=0; i 2) return false;
        }
        return true;
    }

    int numSimilarGroups(vector& A) {
        A.erase(unique(A.begin(), A.end()), A.end());
        vector pre(A.size(), -1);
        for(int i=1;i

5.leetcode721-账号合并

题目描述

给定一个列表 accounts,每个元素 accounts[i] 是一个字符串列表,其中第一个元素 accounts[i][0] 是 名称(name),其余元素是 emails 表示该帐户的邮箱地址。现在,我们想合并这些帐户。如果两个帐户都有一些共同的邮件地址,则两个帐户必定属于同一个人。请注意,即使两个帐户具有相同的名称,它们也可能属于不同的人,因为人们可能具有相同的名称。一个人最初可以拥有任意数量的帐户,但其所有帐户都具有相同的名称。合并帐户后,按以下格式返回帐户:每个帐户的第一个元素是名称,其余元素是按顺序排列的邮箱地址。accounts 本身可以以任意顺序返回。

Input:
accounts = [["John", "[email protected]", "[email protected]"], ["John", "[email protected]"], ["John", "[email protected]", "[email protected]"], ["Mary", "[email protected]"]]
Output: [["John", '[email protected]', '[email protected]', '[email protected]'], ["John", "[email protected]"], ["Mary", "[email protected]"]]
Explanation: 第一个和第三个 John 是同一个人,因为他们有共同的电子邮件 "[email protected]"。 第二个 John 和 Mary 是不同的人,因为他们的电子邮件地址没有被其他帐户使用。我们可以以任何顺序返回这些列表,例如答案[['Mary','[email protected]'],['John','[email protected]'], ['John','[email protected]','[email protected]','[email protected]']]仍然会被接受。

解题思路

  • 并查集的思路,自己写的时候遇到很多麻烦,就是邮箱的顺序一直不知道怎么写才好!
    最好参考了大佬的写法,比较清晰, 唉, 菜是原罪啊! 日常许愿,希望尽快能拿到一个满意的offer吧!!!

代码

class Solution {
public:
    int find(int x, vector &pre) {  //有路径压缩版
        int p=x, q;
        while(pre[p]>=0) p = pre[p];

        while(x!=p){
            q = pre[x];
            pre[x] = p;
            x = q;
        }
        return x;
    }

    void merge(int x, int y, vector &pre){
        int fx=find(x, pre);
        int fy=find(y, pre);
        if(fx!=fy){
            pre[fx] += pre[fy];
            pre[fy] = fx;
        }
    }

    vector > accountsMerge(vector>& accounts) {
        vector > reaccounts;
        int n = accounts.size();
        if (!n) return reaccounts;
        vector pre(n, -1);
        map m;  //邮箱和行号的映射
        for (int i = 1; i < accounts[0].size(); i++){   //添加第一行邮箱
            m[accounts[0][i]] = 0;  //初始化第一行邮箱的父亲行号为0
        }
        //先初始化第一行,然后从第二行开始,判断是否有重复,逐行将邮箱往m中添加
        for(int i = 1; i < n;i++){
            for(int j = 1;j < accounts[i].size();j++){
                if(m.find(accounts[i][j]) != m.end()){
                    merge(m[accounts[i][j]],i, pre);  //重复则合并值为父亲行号
                }else{
                    m[accounts[i][j]] = i;//如果不重复,插入,设值为该行号
                }
            }
        }//至此找出了邮箱和对应行号的关系,确保了唯一性,接着找出邮箱和人的确定集合
        map > man;   //姓名+邮箱的 账户集合
        for (auto &it:m){    //遍历邮箱和行号的 映射集合
            int k = find(it.second, pre);
            if (man.find(k) == man.end()){   //没有该账户时才新增账户
                man[k].push_back(accounts[k][0]);
            }
            man[k].push_back(it.first); //添加邮箱到集合
        }
        for(auto it:man) reaccounts.push_back(it.second);    //添加每个账户
        return reaccounts;
    }
};

你可能感兴趣的:(并查集)