并查集

128. 最长连续序列

思路

方法1 最朴素的解法
先排序,从前往后找最长连续上升序列,但是复杂度已经至少有O(nlogn)。

方法2 哈希集合
外层需要枚举 O(n),只有当一个数是连续序列的第一个数的情况下才会进入内层循环

方法3 动态规划
哈希表记录连续区间长度

  • 遍历nums数组中的所有数字num
  • 当num是第一次出现时:
    • 分别获取到左相邻数字num-1的连续区间长度left和右相邻数字num+1的连续区间长度right;
    • 计算得到当前的区间长度为curLen=left+right+1curLen=left+right+1;
    • 更新最长区间长度ans以及左右边界的区间长度。

方法4 并查集
记录右边界的,所有在一个连续区间内的元素都会在一个连通分量中,且这些元素的根结点都为最远的右边界元素。

  • 遍历所有元素num,如果num+1存在,将num加入到num+1所在的连通分量中;
  • 重新遍历一遍所有元素num,通过find函数找到num所在分量的根结点,也就是最远右边界,从而求得连续区间的长度。

代码

哈希集合

class Solution {
    public int longestConsecutive(int[] nums) {
        Set<Integer> num_set = new HashSet<Integer>();
        for (int num : nums) {
            num_set.add(num);
        }

        int longestStreak = 0;

        for (int num : num_set) {
            if (!num_set.contains(num - 1)) {
                int currentNum = num;
                int currentStreak = 1;

                while (num_set.contains(currentNum + 1)) {
                    currentNum += 1;
                    currentStreak += 1;
                }

                longestStreak = Math.max(longestStreak, currentStreak);
            }
        }

        return longestStreak;
    }
}

动态规划

class Solution {
    public int longestConsecutive(int[] nums) {
        // key表示num,value表示num所在连续区间的长度
        Map<Integer, Integer> map = new HashMap<>();
        int ans = 0;
        for (int num : nums) {
            // 当map中不包含num,也就是num第一次出现
            if (!map.containsKey(num)) {
                // left为num-1所在连续区间的长度,更进一步理解为:左连续区间的长度
                int left = map.getOrDefault(num - 1, 0);
                // right为num+1所在连续区间的长度,更进一步理解为:右连续区间的长度
                int right = map.getOrDefault(num + 1, 0);
                // 当前连续区间的总长度
                int curLen = left + right + 1;
                ans = Math.max(ans, curLen);
                // 将num加入map中,表示已经遍历过该值。其对应的value可以为任意值。
                map.put(num, -1);
                // 更新当前连续区间左边界和右边界对应的区间长度
                map.put(num - left, curLen);
                map.put(num + right, curLen);
            }
        }
        return ans;
    }
}

并查集

class UnionFind {
    // 记录每个节点的父节点
    private Map<Integer, Integer> parent;

    public UnionFind(int[] nums) {
        parent = new HashMap<>();
        // 初始化父节点为自身
        for (int num : nums) {
            parent.put(num, num);
        }
    }

    // 寻找x的父节点,实际上也就是x的最远连续右边界,这点类似于方法2
    public Integer find(int x) {
        // nums不包含x
        if (!parent.containsKey(x)) {
            return null;
        }
        // 遍历找到x的父节点
        while (x != parent.get(x)) {
            // 进行路径压缩,不写下面这行也可以,但是时间会慢些
            parent.put(x, parent.get(parent.get(x)));
            x = parent.get(x);
        }
        return x;
    }

    // 合并两个连通分量,在本题中只用来将num并入到num+1的连续区间中
    public void union(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        if (rootX == rootY) {
            return;
        }
        parent.put(rootX, rootY);
    }
}

class Solution {
    public int longestConsecutive(int[] nums) {
        UnionFind uf = new UnionFind(nums);
        int ans = 0;
        
        for (int num : nums) {
            // 当num+1存在,将num合并到num+1所在集合中
            if (uf.find(num + 1) != null) {
                uf.union(num, num + 1);
            }
        }

        for (int num : nums) {
            // 找到num的最远连续右边界
            int right = uf.find(num);
            ans = Math.max(ans, right - num + 1);
        }
        return ans;
    }
}

还可以加入了一个count哈希表,用于记录每个连通分量的节点个数,这样可在主函数一次遍历便可得到答案。

并查集

并查集的引入

并查集的重要思想在于,用集合中的一个元素代表集合。可以把集合比喻成帮派,而代表元素则是帮主。

最开始,大家各自为战。他们各自的帮主自然就是自己。

现在1号和3号比武,假设1号赢了,那么3号就认1号作帮主(合并1号和3号所在的集合,1号为代表元素)。

现在2号想和3号比武(合并3号和2号所在的集合),但3号表示,别跟我打,让我帮主来收拾你(合并代表元素)。不妨设这次又是1号赢了,那么2号也认1号做帮主。

现在假设4、5、6号也进行了一番帮派合并。

现在假设2号想与6号比。1号胜利后,4号认1号为帮主,他的手下也都是跟着投降了。

我们可以直接把它画成一棵树:

并查集的实现

最简单的并查集

class UnionFind {

    int[] parent;
    int[] rank;
    int outlierNum;

    // 初始化:一开始先将它们的父节点设为自己
    public UnionFind(int n) {
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
    }

    // 查询:一层一层访问父节点,直至根节点(根节点的标志就是父节点是本身)
    // 要判断两个元素是否属于同一个集合,只需要看它们的根节点是否相同即可
    public int find(int x) {
        if (parent[x] == x) {
            return x;
        } else {
            parent[x] = find(parent[x]);
            return parent[x];
        }
    }

    // 合并:先找到两个集合的代表元素,然后将前者的父节点设为后者(也可以将后者的父节点设为前者)
    public void merge(int x, int y) {
        int i = find(x);
        int j = find(y);
        if (i == j) {
            return;
        }
        parent[i] = j;
    }
}

路径压缩

随着合并可能会形成一条长长的链,随着链越来越长,我们想要从底部找到根节点会变得越来越难。

使用路径压缩的方法,让每个元素到根节点的路径尽可能短,最好只需要一步:

我们需要在查询的过程中,把沿途的每个节点的父节点都设为根节点。

按秩合并

有些人可能有一个误解,以为路径压缩优化后,并查集始终都是一个菊花图(只有两层的树的俗称)。但其实由于路径压缩只在查询时进行,也只压缩一条路径,所以并查集最终的结构仍然可能是比较复杂的:

我们应该把简单的树往复杂的树上合并,而不是相反。因为这样合并后,到根节点距离变长的节点个数比较少。所以这时我们merge(7,8),要把8的父节点设为7。

代码

class UnionFind {

    int[] parent;
    int[] rank;
    int outlierNum;

    // 初始化
    public UnionFind(int n) {
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
        rank = new int[n];
        Arrays.fill(rank, 1);
        outlierNum = n;
    }

    // 查询
    public int find(int x) {
        if (parent[x] == x) {
            return x;
        } else {
            parent[x] = find(parent[x]);
            return parent[x];
        }
    }

    // 合并(按秩合并)
    public void merge(int x, int y) {
        int i = find(x);
        int j = find(y);
        if (i == j) {
            return;
        }
        if (rank[i] > rank[j]) {
            parent[j] = i;
        } else {
            parent[i] = j;
        }
        if (rank[i] == rank[j]) {
            rank[i]++;
        }
        outlierNum--;
    }
}

可解决的问题

posted @ 2022-03-21 23:56  当康  阅读(61)  评论(0编辑  收藏  举报