并查集1

190 阅读2分钟

并查集的概念引人

并查集,在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。

这一类问题近几年来反复出现在信息学的国际国内赛题中。其特点是看似并不复杂,但数据量极大,若用正常的数据结构来描述的话,往往在空间上过大,计算机无法承受;即使在空间上勉强通过,运行的时间复杂度也极高,根本就不可能在比赛规定的运行时间(1~3秒)内计算出试题需要的结果,只能用并查集来描述。

并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。常常在使用中以森林来表示。

  • from 百度

#亲戚问题 现有0-4编号的人,他们之间存在父辈的那种亲戚关系,亲戚关系具有传递,如果a和b是亲戚,b和c是亲戚,那么a和c是亲戚。现在要找出他们之间最多几个人是亲戚。 现在假设输入数据如下:

人员编号01234
人员的亲戚编号00144

如上图,0的亲戚是自己,1的亲戚是0,2的亲戚是1,3的亲戚是4......

现在找出每个人的父亲戚。

/**
    * 查询根节点
    *
    * @param id
    * @return
    */
   public int find0(int id) {
       if (data[id] == id) {
           return id;
       }
       return find0(data[id]);
   }


@Test
   public void test() {
       //初始化测试数据
       data = new int[]{0, 0, 1, 4, 4};
       for (int i = 0; i < data.length; i++) {
           //为每个人查找亲戚链条中的始祖
           data[i] = find0(i);
       }
       System.out.println(JSONObject.toJSONString(data)); //[0,0,0,4,4]
   }

得出数据:

人员编号01234
人员的亲戚编号00044

现在变动下数据,将3的父辈亲戚变为2,那么3也会合并到0,1,2的亲戚群中:

人员编号01234
人员的亲戚编号00024

合并代码:

/**
    * 合并
    * 把 from 合并到 into 中去,就是把 from 的双亲结点设为 into 的
    * 注意,这里是根结点的合并
    *
    * @param into
    * @param from
    */
   public void merge0(int into, int from) {
       data[find(data[from])] = find(data[into]);
   }

现在再回去看原始数据:

人员编号01234
人员的亲戚编号00124
得出结果00004

当我们在查找3的亲戚时候,先找到2,通过2再找到1,之后通过1再找到0,这个递归链条比较长,如果我们通过3找到亲戚2时候,亲戚2早已经将自己的父亲戚结点置于最顶层的0人员编号,这时候我们的查找就能更快的找到。这里优化下查找的代码,每次查找人员的父亲戚,找到后都直接调整值,那么优化后的代码为:

/**
   * 查询根节点----使用路径压缩
   * 查找过程中,把途经的结点设置为根节点
   *
   * @param id
   * @return
   */
  public int find(int id) {
      if (data[id] == id) {
          return id;
      }
      data[id] = find(data[id]);
      return data[id];
  }

再回过来看原始数据:

人员编号01234
人员的亲戚编号00124
对于人员0-3,他们的家族树如下:
0
1
2
3

现在有一个单独的4,变动下数据,如果0的上层是4:

4
0
1
2
3

那么这个树的大部分结点(0-3)顺着链条去查找根节点时候难度就会增加一层时间复杂度,但是如果4的父亲戚是0,此时之前的每个结点深度将不会改变。

ps,对于树状结构的查找和构建,要尽量去减小树的深度,才能提高查找的性能(这个在之前我们讲树的时候提到过,有兴趣的可以翻一翻)。

   //原始数据
    public int[] data;
    //记录每个根结点对应的树的深度
    public int[] rank;

/**
     * 按秩合并
     * 用rank[ ]数组来记录每个根结点对应的树的深度
     * (如果不是根结点,则rank中的元素大小表示的是以当前结点作为根结点的子树的深度);
     * 一开始,把所有元素的rank设为1,即自己就为一颗树,且深度为1;
     * 合并的时候,比较两个根结点,把rank较小者合并到较大者中去。
     *
     * @param index1
     * @param index2
     */
    public void merge(int index1, int index2) {
        //查找根节点
        int r1 = find(data[index1]);
        int r2 = find(data[index2]);
        if (r1 == r2) {
            return;
        }
        //将深度小的合并到深度大的
        if (rank[r1] < rank[r2]) {
            data[r1] = r2;
        } else {
            if (rank[r1] == rank[r2]) {
                //深度相同,随便啦,但是合并过去深度+1了
                rank[r1]++;
            }
            data[r2] = r1;
        }
    }

路径压缩和按秩合并总结

路径压缩提高了我们在查找时候的递归深度; 按秩合并则是通过减小树的深度来提高查找效率。 #最长连续序列问题解析

  • 给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。

  • 设计并实现时间复杂度为 O(n) 的解决方案。(题目来源于LeetCode)

例如:nums = [100,4,200,1,3,2]。最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。

下面我们通过并查集来解决这个问题:

public int longestConsecutive(int[] nums) {
    //放置结点深度
    int maxLength = 0;
    Set<Integer> datas = new HashSet<>();  // 值 :index
    //初始化设置
    for (int i = 0; i < nums.length; i++) {
        datas.add(nums[i]);
    }
    //计算每个值的层级
    for (int i = 0; i < nums.length; i++) {
        //以当前值为起始值,往下加1,直到加到某个值不存在时候停止,记录下此时累加的长度
        int v = nums[i] + 1;  //当前查找值
        int r = 1;  //深度
        while (datas.contains(v)) {
            v++;
            r++;
        }
        if (r > maxLength) {
            maxLength = r;
        }
    }
    return maxLength;
}