目录

并查集

在计算机科学中,并查集(英文:Disjoint-set data structure,直译为不交集数据结构)是一种数据结构,用于处理一些不交集(Disjoint sets,一系列没有重复元素的集合)的合并及查询问题。

并查集支持如下操作:

  • 查询:查询某个元素属于哪个集合,通常是返回集合内的一个“代表元素”。这个操作是为了判断两个元素是否在同一个集合之中。
  • 合并:将两个集合合并为一个。
  • 添加:添加一个新集合,其中有一个新元素。添加操作不如查询和合并操作重要,常常被忽略。

由于支持查询和合并这两种操作,并查集在英文中也被称为联合-查找数据结构(Union-find data structure)或者合并-查找集合(Merge-find set)。

“并查集” 可以用来指代任何支持上述操作的数据结构,但是一般来说,“并查集”特指其中最常见的一种实现:不交集森林(Disjoint-set forest)。经过优化的不交集森林有线性的空间复杂度($O(n)$,n为元素数目,下同),以及接近常数的单次操作平均时间复杂度($O(\alpha)$},$\alpha$ 为反阿克曼函数),是效率最高的常见数据结构之一。

并查集是用于计算最小生成树的克鲁斯克尔(Kruskal)算法中的关键。由于最小生成树在网络路由等场景下十分重要,并查集也得到了广泛的引用。此外,并查集在符号计算,寄存器分配等方面也有应用。

不交集森林

不交集森林把每一个集合以一棵树表示,每一个节点即是一个元素。节点保存着到它的父节点的引用,树的根节点则保存一个空引用或者到自身的引用或者其他无效值,以表示自身为根节点。

并查集是来解决图的连通性问题

  • Union – 连接两个节点
  • Find – 查找所属的连通分量

所以,并查集主要就是实现以下接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class UF {
    /* 将 p 和 q 连接 */
    public void union(int p, int q);
    /* 判断 p 和 q 是否连通 */
    public boolean connected(int p, int q);
    /* 返回图中有多少个连通分量 每次建立连接减一 */
    public int count();
    
    /* 返回当前节点的根节点 */
    private int find(int x);
}

给定N个对象

  • Union 连接2个对象
  • Find/connected 判断2个对象是否有连接路径
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
union(4, 3);
union(3, 8);
union(6, 5);
union(9, 4);
union(2, 1);

connected(0, 7);  // false
connected(8, 9);  // true

union(5, 0);
union(7, 2);
union(6, 1);
union(1, 0);
connected(0, 7);  // true

/images/data-structure/disjoint-set/001.png

  • 数字图片的像素
  • 网络中的计算机
  • 社交网络中的好友
  • 电脑芯片中的晶体管

编程时,命名对象为 0 ~ N-1

  • 对象用 int 就像数组的 index
  • 禁止与并查集无关的信息

建模连接

p 连接 q

  • 对称性 如果p 连接 q,那么 q 连接 p
  • 传递性 如果 p 连接 q 以及 q 连接 r,那么 p 连接 r

/images/data-structure/disjoint-set/002.png

解法: 数组下标分组

数据结构

  • 大小为 N 的 Integer 数组 id[]
  • 说明:p 与 q 连接 那么他们有相同的 id
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class QuickFindUF {

  private int[] id;

  public QuickFindUF(int N) {
    id = new int[N];
    for (int i = 0; i < N; i++) {
      id[i] = i;
    }
  }

  public boolean connected(int p, int q) {
    return id[p] == id[q];
  }

  public void union(int p, int q) {
    int pid = id[p];
    int qid = id[q];
    for (int i = 0; i < id.length; i++) {
      if (id[i] == pid) {
        id[i] = qid;
      }
    }
  }
}

解法: 数组存父节点构成森林,两个对象的 root 相同则连通

数据结构

  • 大小为 N 的 Integer 数组 id[]
  • 说明: id[i] 是 i 的父节点
  • Root(根节点) i = id[id[…id[i]…]]

数据结构数组, 节点关系可以看成森林。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class QuickFindUF1 {

  private int[] id;

  public QuickFindUF1(int N) {
    id = new int[N];
    for (int i = 0; i < N; i++) {
      id[i] = i;
    }
  }

  private int root(int i) {
    while (i != id[i]) {
      i = id[i];
    }
    return i;
  }

  public boolean connected(int p, int q) {
    return root(p) == root(q);
  }

  public void union(int p, int q) {
    int i = root(p);
    int j = root(q);
    id[i] = j;
  }
}

给并查集加权重

  • 避免树太高
  • 记录每棵树的大小 (对象数)
  • 连接 比较小的数的 root 到 大树的 root 来保持平衡
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class QuickFindUF2 {

  private int[] id;

  private int[] sz;

  public QuickFindUF2(int N) {
    id = new int[N];
    sz = new int[N];
    for (int i = 0; i < N; i++) {
      id[i] = i;
      sz[i] = 1;
    }
  }

  private int root(int i) {
    while (i != id[i]) {
      // 路径压缩
      id[i] = id[id[i]];
      i = id[i];
    }
    return i;
  }

  public boolean connected(int p, int q) {
    return root(p) == root(q);
  }

  public void union(int p, int q) {
    int i = root(p);
    int j = root(q);
    if (i == j) {
      return;
    }
    // 平衡性优化
    if (sz[i] < sz[j]) {
      id[i] = j;
      sz[j] += sz[i];
    } else {
      id[j] = i;
      sz[i] += sz[j];
    }
  }
}

被围绕的区域

并查集常用来解决连通性的问题,即将一个图中连通的部分划分出来。当我们判断图中两个点之间是否存在路径时,就可以根据判断他们是否在一个连通区域。

被围绕的区域

/images/data-structure/disjoint-set/003.svg
被围绕的区域

由于并查集我们一般用一维数组来记录,方便查找 parents,所以我们将二维坐标用函数转化为一维坐标。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void solve(char[][] board) {
    int m = board.length;
    int n = board[0].length;
    // 多一个节点用来存放 dummy
    UF uf = new UF(m * n + 1);
    int dummy = m * n;
    // 将 dummy 和四条边的所有 'O' 相连
    for (int i = 0; i < m; i++) {
        if (board[i][0] == 'O') uf.union(dummy, i * n);
        if (board[i][n - 1] == 'O') uf.union(dummy, i * n + n - 1);
    }
    for (int j = 0; j < n; j++) {
        if (board[0][j] == 'O') uf.union(dummy, j);
        if (board[m - 1][j] == 'O') uf.union(dummy, (m - 1) * n + j);
    }
    // 将内圈的所有相邻(上下左右)的 'O' 全部连起来 
    int[][] dirs = new int[][]{ {1, 0}, {0, 1}, {0, -1}, {-1, 0} };
    for (int i = 1; i < m - 1; i++) {
        for (int j = 1; j < n - 1; j++) {
            if (board[i][j] == 'O') {
                for (int[] d : dirs) {
                    int newI = i + d[0];
                    int newJ = j + d[1];
                    if (board[newI][newJ] == 'O') {
                        uf.union(i * n + j, newI * n + newJ);
                    }
                }
            }
        }
    }
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (board[i][j] == 'O' && !uf.connected(dummy, i * n + j)) board[i][j] = 'X';
        }
    }
}

最长连续序列

longest-consecutive-sequence

  • 利用Map进行了一个「下标」和「值」的对应
  • 利用Map进行重复元素的排除
  • 利用Map可快速判断当前并查集中已有元素
  • 将 num[i] 和 num[i] - 1 && num[i] + 1 相连
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Solution {
    public int longestConsecutive(int[] nums) {

        Map<Integer, Integer> map = new HashMap<>();
        UF uf = new UF(nums.length);

        for (int i = 0; i < nums.length; i++) {
            // 存在重复元素,跳过
            if (map.containsKey(nums[i])) continue;

            if (map.containsKey(nums[i] - 1)) {
                uf.union(i, map.get(nums[i] - 1));
            }
            if (map.containsKey(nums[i] + 1)) {
                uf.union(i, map.get(nums[i] + 1));
            }
            map.put(nums[i], i);        
        }
        return uf.getMaxConnectSize();
    }
}
class UF {
    private int[] parent;
    private int[] size;

    public UF(int n) {
        parent = new int[n];
        size = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            size[i] = 1;
        }
    }
    public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        if (rootP == rootQ) return;
        parent[rootP] = rootQ;
        // 注意 别写反了
        size[rootQ] += size[rootP];
    }
    // get root id
    private int find(int x) {
        while (x != parent[x]) {
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }
    public int getMaxConnectSize() {
        int maxSize = 0;
        for (int i = 0; i < parent.length; i++) {
            if (i == parent[i]) {
                maxSize = Math.max(maxSize, size[i]);
            }
        }
        return maxSize;
    }
}