并查集(上)

150 阅读3分钟

并查集简要介绍:

我们先讲并查集的一般使用场景,之后再讲并查集的具体细节以及原理。

并查集的使用一般是如下的场景:
  1. 一开始每个元素都拥有自己的集合,在自己的集合里只有这个元素自己。
  2. find(i)find(i):查找ii所在集合的代表元素,代表元素来代表ii所在的集合。
  3. bool isSameSet(a,b)bool ~isSameSet(a,b):判断aabb在不在一个集合里。
  4. void union(a,b)void ~union(a,b)aa所在集合所有元素 与 bb所在集合所有元素 合并成一个集合。
  5. 各种操作单次调用的均摊时间复杂度为O(1)O(1)

并查集的原理也比较简单,看下面原理图解就明白了:

微信图片_20240919104035.jpg

​ 这张图清晰地表现了unionunion操作的原理,我们很容易分析出来,时间复杂度是O(1)O(1),两个集合进行合并,本质上就是两个集合的代表元素进行合并,如上图,我们选了集合2代表元素d作为新集合代表,那么集合1代表元素d就要解除自环并指向d,d仍然保持自环。

微信图片_20240919105144.jpg

find(i)find(i)操作就是从元素ii开始沿着指针向上找,当找到自环元素时,说明找到了ii元素所在集合的代表元素,这个代表元素就是我们要找的结果。

isSameSet(a,b)isSameSet(a,b)操作就是找aabb分别所在集合的代表元素,如果两个元素所在集合的代表元素相同,则说明这两个元素在同一个集合里。

并查集的数组实现

在前两张图中,最明显的是我们使用了指针这个概念,我的第一反应是用链表去实现并查集,但是链表查找起来太麻烦了,参考链表的静态数组实现,我们对并查集也有对应的数组实现。

并查集数组实现方式的关键成员:

  • int father[]father[i]father[i]中存储的是元素i的父元素jj,也就是说并查集中存在关系 i>ji -> j
  • int size[]size[i]size[i]中存储的是元素ii所代表的集合的元素个数,用于union()union()函数中进行小挂大优化,小集合挂载到大集合下。
  • int stack[]stack[]stack[]用于Find(i)Find(i)函数中,对数据进行扁平化处理。

扁平化处理原理如下图:

微信图片_20240919163600.jpg

上图中是一个比较极端的并查集,现在我们依次找a,b,c,d,e,f,ga,b,c,d,e,f,g 的代表元素,如果我们不做扁平化处理,只利用简单循环去做的话,需要找7+6+5+4+3+2+17+6+5+4+3+2+1次,而做了扁平化处理的话,中间某个节点被重复遍历的次数就会减少,最优情况下,只需要找7次。这个最优情况也就是一开始就找最底层元素aa的代表元素。

Problem1Problem1 并查集的实现 牛客

www.nowcoder.com/practice/e7…

描述

给定一个没有重复值的整形数组arr,初始时认为arr中每一个数各自都是一个单独的集合。请设计一种叫UnionFind的结构,并提供以下两个操作。

  1. boolean isSameSet(int a, int b): 查询a和b这两个数是否属于一个集合
  2. void union(int a, int b): 把a所在的集合与b所在的集合合并在一起,原本两个集合各自的元素以后都算作同一个集合

[要求]

如果调用isSameSetunion的总次数逼近或超过O(N),请做到单次调用isSameSetunion方法的平均时间复杂度为O(1)

输入描述:

第一行两个整数N, M。分别表示数组大小、操作次数 接下来M行,每行有一个整数opt 若opt = 1,后面有两个数x, y,表示查询(x, y)这两个数是否属于同一个集合 若opt = 2,后面有两个数x, y,表示把x, y所在的集合合并在一起

输出描述:

对于每个opt = 1的操作,若为真则输出"Yes",否则输出"No"

示例1

输入:

4 5
1 1 2
2 2 3
2 1 3
1 1 1
1 2 3

输出:

No
Yes
Yes

说明:

每次2操作后的集合为
({1}, {2}, {3}, {4})
({1}, {2, 3}, {4})
({1, 2, 3}, {4})

套用并查集模板即可,解决代码如下:

#include<cstdio>
#include <iostream>
#include <vector>
using namespace std;

int MAXN = 1000000;
vector<int> father(MAXN);
vector<int> setSize(MAXN);
vector<int> stack(MAXN);  // 利用栈进行扁平化处理

void build(int N) {
    for (int i = 0; i < N; i++) {
        father[i] = i;
        setSize[i] = 1;
    }
}

int Find(int i) {
    int Size = 0;
    while (father[i] != i) {
        stack[Size++] = i;
        i = father[i];
    }

    // 沿途节点收集完毕,找到代表元素,同时也是栈中所有元素的代表元素
    while (Size > 0) {
        father[stack[--Size]] = i;
    }
    return i;
}

void Union(int x, int y) {
    int fx = Find(x);
    int fy = Find(y);
    if (fx != fy) {
        if (setSize[fx] > setSize[fy]) {
            setSize[fx] += setSize[fy];
            father[fy] = fx;
        } else {
            setSize[fy] += setSize[fx];
            father[fx] = fy;
        }
    }
}

bool isSameSet(int x, int y) {
    return Find(x) == Find(y);
}

int main() {
    int N, M;
    scanf("%d", &N);
    scanf("%d", &M);
    build(N);

    vector<vector<int>> Edge(M, vector<int>(3));
    for (int i = 0; i < M; i++) {
        int opt, x, y;
        scanf("%d", &opt);
        scanf("%d", &x);
        scanf("%d", &y);
        Edge[i][0] = opt;
        Edge[i][1] = x;
        Edge[i][2] = y;
    }
    for (int i = 0; i < M; i++) {
        if(Edge[i][0] == 1){
            if(isSameSet(Edge[i][1],Edge[i][2])){
                printf("Yes\n");
            }else{
                printf("No\n");
            }
        }
        if(Edge[i][0] == 2){
            Union(Edge[i][1], Edge[i][2]);
        }
    }
    return 0;
}

并查集的小挂大操作在一些场景中是不必要的,对于不进行小挂大操作,我们有另一种实现并查集的方式,用下面这个例子来给你详细说明。

Problem2Problem2 【模板】并查集 洛谷P3367

如题,现在有一个并查集,你需要完成合并和查询操作。

第一行包含两个整数 N,MN,M ,表示共有 NN 个元素和 MM 个操作。

接下来 MM 行,每行包含三个整数 Zi,Xi,YiZ_i,X_i,Y_i

Zi=1Z_i=1 时,将 XiX_iYiY_i 所在的集合合并。

Zi=2Z_i=2 时,输出 XiX_iYiY_i 是否在同一集合内,是的输出 Y ;否则输出 N

输出格式

对于每一个 Zi=2Z_i=2 的操作,都有一行输出,每行包含一个大写字母,为 Y 或者 N

输入:

4 7
2 1 2
1 1 2
2 1 2
1 3 4
2 1 4
1 2 3
2 1 4

输出

N
Y
N
Y

提示

对于 30%30\% 的数据,N10N \le 10M20M \le 20

对于 70%70\% 的数据,N100N \le 100M103M \le 10^3

对于 100%100\% 的数据,1N1041\le N \le 10^41M2×1051\le M \le 2\times 10^51Xi,YiN1 \le X_i, Y_i \le NZi{1,2}Z_i \in \{ 1, 2 \}


#include <cstdio>
#include <iostream>
#include <vector>
using namespace std;

int MAXN = 1000000;
vector<int> father(MAXN);

void build(int N) {
  for (int i = 1; i <= N; i++) {
    father[i] = i;
  }
}

int Find(int i) {
  // 利用堆栈进行扁平化处理
  if (i != father[i]) {
    father[i] = Find(father[i]);
  }
  return father[i];
}

void Union(int x, int y) { father[Find(x)] = Find(y); }

bool isSameSet(int x, int y) { return Find(x) == Find(y); }

int main() {
  int N, M;
  scanf("%d", &N);
  scanf("%d", &M);
  build(N);

  vector<vector<int>> Edge(M, vector<int>(3));
  for (int i = 0; i < M; i++) {
    int opt, x, y;
    scanf("%d", &opt);
    scanf("%d", &x);
    scanf("%d", &y);
    Edge[i][0] = opt;
    Edge[i][1] = x;
    Edge[i][2] = y;
  }
  for (int i = 0; i < M; i++) {
    if (Edge[i][0] == 2) {
      if (isSameSet(Edge[i][1], Edge[i][2])) {
        printf("Y\n");
      } else {
        printf("N\n");
      }
    }
    if (Edge[i][0] == 1) {
      Union(Edge[i][1], Edge[i][2]);
    }
  }
  return 0;
}

Problem3Problem3 情侣牵手LeetCode765

n 对情侣坐在连续排列的 2n 个座位上,想要牵到对方的手。

人和座位由一个整数数组 row 表示,其中 row[i] 是坐在第 i 个座位上的人的 ID。情侣们按顺序编号,第一对是 (0, 1),第二对是 (2, 3),以此类推,最后一对是 (2n-2, 2n-1)

返回 最少交换座位的次数,以便每对情侣可以并肩坐在一起每次交换可选择任意两人,让他们站起来交换座位。

示例 1:

输入: row = [0,2,1,3]
输出: 1
解释: 只需要交换row[1]和row[2]的位置即可。

示例 2:

输入: row = [3,2,0,1]
输出: 0
解释: 无需交换座位,所有的情侣都已经可以手牵手了。

提示:

  • 2n == row.length
  • 2 <= n <= 30
  • n 是偶数
  • 0 <= row[i] < 2n
  • row 中所有元素均无重复

问题分析:

乍一眼看去,这道题好像和并查集扯不上关系,这种一眼看过去并不能快速反应出来需要使用哪种算法的题需要我们仔细地去分析,去挖掘其中的数学关系。

我们先枚举几个简单的例子来看看题目具体情况:

如果只有1对情侣在2个座位上,那么他们一定是并肩坐在一起的,

如果2对情侣坐在4个座位上(没有任何一对情侣坐在一起),那么我们只需要交换1次。

微信图片_20240919231010.jpg

如果3对情侣坐在6个座位上,那么我们只需要交换2次。

微信图片_20240919231511.jpg

由于座位一直是2n2n个,nn对情侣最终要并肩坐在一起,我们就可以形象地表示成 最终n对情侣都坐在各自的沙发上。

现在继续往下分析,如果nn对情侣分别坐在nn个沙发上(并且没有任何一对情侣坐在同一张沙发上),我们需要交换几次呢?

我们从第一张沙发开始分析,要想让第一张沙发中的两个人a,ba,b坐上一对情侣,可以让第一张沙发中的其中一人(假设让b)和aa的男(女)朋友进行交换,这样第一张沙发就安排好了,现在安排第二张沙发,继续同样的操作,一直这样下去做n2n-2次操作,最终只剩下最后两张沙发,最后两张沙发只要再做一次交换操作就好了。

所以我们可以得出结论:

【结论1】如果有KK对情侣混坐在一起,那么我们至少需要K1K-1次交换操作。

所以现在所有情侣的情况如下:

N1N_1对情侣混坐在一起,N2N_2对情侣混坐在一起, ... ,NrN_r对情况混坐在一起,

​ 剩余对情侣都是配对好了的。

按照结论1,我们最终的结果是N1+N2+N3+N4+...+NrrN_1 + N_2 + N_3 + N_4 + ... + N_r - rN1N_1对情况混坐在一起我们将其看成集合A1A_1N2N_2对情况混坐在一起我们将其看成集合A2A_2, ...,NrN_r对情侣混坐在一起我们将其看成集合ArA_r。最朴素的想法是求出这rr个集合分别的元素个数,之后累加再r-r

注意,这里每个集合的元素个数是情侣对数,现在就剩下一个问题了,怎么判断NN对情侣混坐在一起?

为了简单化,我们把一对一对情侣进行编号:第0对:0,1;第1对:2,3....;第n对,2n2*n2n+12*n + 1。所以ii会出现两次,情侣两人各出现一次。现在我将原来的编号转化为情侣对数编号并将混在一起的情侣进行合并,原理如下图:

合并结束之后,我们就可以利用并查集来找那些集合中元素大于1个的集合。

for(int i = 0;i < n;i++){
    if(father[i] == i){
        if(size[i] > 1){
            result += size[i] - 1;
        }
    }
}

但是利用循环去找太浪费时间了,我们继续分析,这个N1+N2+N3+...+NrN_1 + N_2 + N_3 + ... + N_r加起来是没有坐在一起的情侣的总对数,剩下的情侣都是坐在一起的,我们记坐在一起的情侣有NsN_s对,那么

N1+N2+N3+...+Nr+Ns=情侣总对数nN_1 + N_2 + N_3 + ... + N_r + N_s = 情侣总对数n

NsN_s对情侣都是单独坐好的,在并查集中表现为NsN_s个元素个数为1的集合,所以

N1+N2+N3+...+Nrr=N1+N2+N3+....+Nr+NsrNs=n(r+Ns)=nsetsN_1 + N_2 + N_3 +... + N_r - r \\ = N_1 + N_2 + N_3 + .... + N_r + N_s - r -N_s\\ = n - (r + N_s)\\ = n - sets

setssets指的是并查集中的集合个数。

最终处理方法:

先对并查集初始化,有nn对情侣嘛,一开始都未入座时,情侣肯定是两两一对的,所以有n个集合。

void build(int n){
    for(int i = 0 ; i < n;i++){ //情侣编号从0开始的
        father[i] = i;
    }
    sets = n;
}
最终解决代码:
class Solution {
public:
    std::vector<int> father; // 不直接初始化大小
    int sets;                // 表示并查集中集合个数

    // 构造函数
    Solution() {
        father.resize(30); // 可以在构造函数中调整大小
    }
    void build(int N) {
        for (int i = 0; i < N; i++) {
            father[i] = i;
        }
        sets = N;
    }
    int find(int x) {
        if (x != father[x]) {
            father[x] = find(father[x]);
        }
        return father[x];
    }

    void Union(int x,
               int y) { // 一张沙发上的两人不是情侣,则说明混在一起,将集合合并
        int fx = find(x);
        int fy = find(y);
        if (fx != fy) {
            father[fx] = fy;
            sets--;
        }
    }

    int minSwapsCouples(vector<int>& row) {
        int N = row.size() / 2;
        build(N);
        for (int i = 0; i < N; i++) {
            Union(row[2*i] / 2, row[2*i + 1] / 2);
        }
        return N - sets;
    }
};

这道题的问题分析写得不是很好,同学们可以去看左程云老师的【并查集(上)】课程视频,看完视频后再看我的解析,应该就能明白我在讲什么了。

Problem4Problem4 相似字符串组 LeetCode839

如果交换字符串 X 中的两个不同位置的字母,使得它和字符串 Y 相等,那么称 XY 两个字符串相似。如果这两个字符串本身是相等的,那它们也是相似的。

例如,"tars""rats" 是相似的 (交换 02 的位置); "rats""arts" 也是相似的,但是 "star" 不与 "tars""rats",或 "arts" 相似。

总之,它们通过相似性形成了两个关联组:{"tars", "rats", "arts"}{"star"}。注意,"tars""arts" 是在同一组中,即使它们并不相似。形式上,对每个组而言,要确定一个单词在组中,只需要这个词和该组中至少一个单词相似。

给你一个字符串列表 strs。列表中的每个字符串都是 strs 中其它所有字符串的一个字母异位词。请问 strs 中有多少个相似字符串组?

示例 1:

输入:strs = ["tars","rats","arts","star"]
输出:2

示例 2:

输入:strs = ["omv","ovm"]
输出:1

问题分析:

这道题和**Problem3Problem3 情侣牵手问题**很相似,都是先判断两个元素是否有一定关系,如果有关系,则将两个元素所在集合合并,如果没有关系,则不合并。

Problem3Problem3中,同一张沙发上的两个人如果不是一对情侣,那么就将两个人分别所在的集合合并,表示编号A编号A情侣两人和编号B编号B情侣两人混坐在一起。如果是一对情侣,我们也做出了 将两人分别所在集合合并 操作,一对情侣中两人的情侣编号相同(假设为KK),KKKK合并之后还是KK,相当于KK所在集合中只有KK,类似于**KK与其他集合不进行合并**。

回到Problem4Problem4,和Problem3Problem3一样,我们在初始状态把各个字符串都放在自己单独的集合里,如果两个字符串相似,再将两个字符串所在集合合并。有一点不同的是,如果字符串stringAstringA单独所在集合最终与集合setAsetA合并,只需要和setAsetA中的其中一个字符串相似即可,所以我们需要遍历集合setAsetA。所以我们可以按照数组顺序来合并集合。

解决代码:
int MAXN = 3000;
vector<int> father;
int sets;
Solution() { father.resize(MAXN); }
void build(int N) {
    for (int i = 0; i < N; i++) {
        father[i] = i;
    }
    sets = N;
}

int find(int x) {
    if (x != father[x]) {
        father[x] = find(father[x]);
    }
    return father[x];
}

void Union(int x, int y) {
    int fx = find(x);
    int fy = find(y);
    if (fx != fy) {
        father[fx] = fy;
        sets--;
    }
}

// 判断两个字符串是否相似,两处字符不同则为相似
bool isSimilar(string a, string b) {
    int length = a.size();
    int diff = 0;
    for (int i = 0; i < length && diff < 3; i++) {
        if (a[i] != b[i]) {
            diff++;
        }
    }
    return diff == 2 || diff == 0;
}

int numSimilarGroups(vector<string>& strs) {
    int N = strs.size();
    build(N);
    // 对每个字符串进行遍历,并与排在它前面的字符串进行比较
    for(int i = 1; i < N; i++){
        for(int j = 0; j < i; j++){
            if(isSimilar(strs[i],strs[j])){
             	   Union(i,j);
            }
        }
    }
    return sets;
}

Problem5Problem5 岛屿数量 LeetCode200

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

示例 1:

输入:grid = [  ["1","1","1","1","0"],
  ["1","1","0","1","0"],
  ["1","1","0","0","0"],
  ["0","0","0","0","0"]
]
输出:1

问题分析:

这道题和前面几题一样,都是用并查集一直合并集合,最终拿到最后所剩的集合数,也就是岛屿的数量。

值得一提的是,我们对每个由单个字符串构成的集合的遍历是有顺序的,在第二题中,我们是对数组从左到右逐个遍历,只有按照顺序来,遍历过的字符串的最终集合才能确定下来。这道题同样如此,只有按照行从上到下,列从左到右的遍历顺序来,遍历过的节点才能确定最终在哪片岛屿中。

举个反例,如果不按照顺序来遍历,那要求某个节点究竟在哪片岛屿中,就要以此节点为中心,向上下左右四个方向搜索陆地节点并合并集合。这时又会出现一个问题,看下面这张图:

微信图片_20240921122534.jpg

红色节点1最先会被划分到蓝色集合中,之后又和下面橙色节点1合并,划分到橙色集合中,但其实正确结果是红色节点1被划分到蓝色集合,并且下方的橙色节点1最终也是要被划分到蓝色集合的。为什么会出现这种结果呢?

究其原因是橙色节点1还是处于最开始(每个节点独自构成一个集合)的初始状态,也就是说橙色节点的最终归属集合还没有确定好,要想得到并查集的最终结果,我们在某一节点向四周搜索时必须搜索已经有归属集合的节点。

因为我们对数组遍历是从行从上到下,列从左到右的,所以遍历某个节点时只需要搜索其左边的节点和上边的节点。

解决代码:

最终代码如下:

int MAXN = 100001;
vector<int> father;
int sets;
Solution() { father.resize(MAXN); }
void build(vector<vector<char>>& grid) {
    int rows = grid.size();
    int columns = grid[0].size();
    sets = 0;
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < columns; j++) {
            if (grid[i][j] == '1') {
                int index = Index(i, j, columns);
                father[index] = index;
                sets++;
            }
        }
    }
}

int Find(int i) {
    // 利用堆栈进行扁平化处理
    if (i != father[i]) {
        father[i] = Find(father[i]);
    }
    return father[i];
}

void Union(int x1, int y1, int x2, int y2, int columns) {
    int index1 = Index(x1, y1, columns);
    int index2 = Index(x2, y2, columns);
    int f1 = Find(index1);
    int f2 = Find(index2);
    if (f1 != f2) {
        father[f1] = f2;
        sets--;
    }
}

int Index(int i, int j, int columns) { // 将二维坐标映射到一维
    return i * columns + j;
}

int numIslands(vector<vector<char>>& grid) {
    int rows = grid.size();
    int columns = grid[0].size();
    build(grid);
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < columns; j++) {
            if (grid[i][j] == '1') {
                // 尝试能否与左边节点所在集合合并
                if (j > 0 && grid[i][j - 1] == '1') {
                    // 左边节点存在并且是陆地,则可以考虑合并
                    Union(i, j, i, j - 1, columns);
                }
                // 尝试能否与上边节点所在集合合并
                if (i > 0 && grid[i - 1][j] == '1') {
                    // 右边节点存在并且是陆地,则可以考虑合并
                    Union(i, j, i - 1, j, columns);
                }
            }
        }
    }
    return sets;
}