LeetCode Union-Find(并查集) 专题(一)

来源:互联网 发布:星星知我心原唱是谁 编辑:程序博客网 时间:2024/06/07 18:23

LeetCode Union-Find(并查集) 专题(一)

前言
写这个专题本意还是和之前一样 ,一篇一篇写博客写的腻了。来个专题换换感觉。(手动滑稽

好吧其实是觉得并查集是一个比较实用的数据结构。 之前听老师在课上提到过一次, 在那之前很早就听过这个, 也是一只因为懒没去真正了解。 趁着这两天空闲时间把它熟悉了一下, 发现并不难。(难度和二分查找的变式差不多吧)

下面进入正题。

    • LeetCode Union-Find并查集 专题一
      • 并查集 Union-Find 简介
        • 并查集是什么
        • 并查集的对比
      • 并查集的基本实现
        • 一个最简单的基本实现
        • 并查集的优化
        • 并查集的简单套路
      • 并查集的简单应用举例
        • LeetCode 547 Friend Circles
        • LeetCode 737 Sentence Similarity II
      • 本篇小结续篇说明
      • The End


并查集 Union-Find 简介

并查集是什么

并查集就是像它名字说的那样, 为了实现快速合并(Union)以及查找 (Find)操作的数据结构。
其实到这儿就完了。 为了说清楚, 再说详细一些。
首先考虑查找,查找复杂度最低的数据结构是? HashMap 几乎是肯定的。 除了这个呢? Set,BBST 都是对数级别的复杂度的(内部都是用的红黑树实现的吧没记错的话)。 HashMap 实现合并几乎是不可能的(本身几乎没有组织形式, 要合并起来至少复杂度得要 O(n) 级别吧。其他的, 树的合并, 复杂度倒是只有O(1) 但是这是不考虑再平衡的情况下。

再来先考虑合并, 最先想到的还是树,朴素的树的合并应该是算数据结构中很简单的了。 所以目前来看,树形结构在UnionFind问题上应该是一个较好的解决方案(事实正是如此, 并查集也是借鉴了树的结构)

我所理解的并查集实现, 就是基于树的组织形式, 把其简化抽象到几乎只剩下 UnionFind 两个功能的数据结构。(事实上这两者可能延伸出一些其他功能,之后再说) 由于舍弃了一些结构信息, 根据tradeoff法则, 换得了更好的时间上的效率。

这里先描述一下并查集(以下用UF来代指 Union-Find)的大体思想。 以整数无序数对为例。
考虑以下例子:
(1, 2), (2, 3), (6, 7), (5, 6), (7, 4)


我们把每个数对看做存在着关系,并且假定这种关系具有传递性(很多实际问题正是如此)。

现在我们把这些关系聚类。 每个类选出一个代表, 来表示这个类的所有元素(一般是第一个存在于这个类中的元素)。设i-th元素代表为下标为father[i]的元素。 这样代表之后, 合并和查找按照如下方式进行:
1. 合并: 设我们现在要合并(a, b), 那么我们要先找出`a, b各自的代表(记为father[a], father[j])。 令一个的代表, 作为另一个的代表即可。 即father[father[a]] = father[b]

注意:朴素并查集这里并没有规定合并方向。 上式反过来亦可。 但是之后的优化以及对具体问题可能会对此做出约束
2. 查找: 要查找两个元素是否位于同一个类别里面, 只需要看其代表元素是否相同即可。即 father[a] == father[b]

并查集的对比

本来这里想写优势的, 想了一下还是放到下一篇吧。 这里先写一些和树的对比, 为后续优化做点铺垫。
其实上面描述的,就是一棵树的建立和合并。 完全相同。
之前的“代表元素” 实际上就是树的根root, 初始情况每个点都是一棵树, 每次得到的一个新关系, 就是为一棵树添加一个叶子节点。 对于合并, 就是把一棵树的根作为另一棵树的子节点; 对于查找, 就是看两个点的根节点是否相同。

那么这样的话为什么又要建立这样一个数据结构的概念呢? 这就是因为优化了。至少我认为朴素的并查集其实就是直接的由原始数据构成的多叉树森林。 不过是以数组的形式表现的罢了。
之前也说过, 可以优化, 是因为对于UF, 我们可以舍弃树的部分结构只需要留下支持合并和查找的结构即可。优化的方向还是从复杂度入手: 合并复杂度O(1)已经到上限, 而查找复杂度来说并不乐观: 平均复杂度和最佳复杂度为 O(logN), 最差情况下为 O(N)
那就分为如下两种情况:
1. 对于最差复杂度 O(N), 造成这种情况的原因明显是由于构造出的树不是平衡的而近似线性链表(退化)。要优化只需要尽可能平衡之即可。 当然我们不可能去执行平衡化算法。 但是采取这样一个操作: 每次合并的时候, 都将层数较小的树作为层数较大的树的子节点(注: 并查集中这种层数的概念叫做 秩 (rank) )。 这样我们在合并的时候总会倾向于使树趋于平衡的方式进行合并。
2. 对于平均复杂度, 注意到其花费的时间正比于根到节点的路径长度。 因此压缩路径成为了另一个优化的方向。 我们希望的当然是路径长度只有1, 那么复杂度将变为 O(1)
注意: 这里的压缩路径即 trade-off 的交换。对于普通树是需要记录下根到叶子的路径, 但是对于只需要合并查找而言, 这种结构并不需要,因此以之作为牺牲换取更高的效率。

下面介绍的优化将主要就是讨论基于上面两种想法优化的实现。不过,在此之前, 我们先了解一下UF的基本实现。 (事实上很多题而言, 不加优化的朴素并查集已经够用了)


并查集的基本实现

一个最简单的基本实现

下面代码是对整数对之间关系的并查集实现:

struct UF {    UF(int n) {        nums.resize(n);        for (int i = 0; i < n; ++i) {            nums[i] = i;        }    }    bool Union(int a, int b) {        int ancesterA = Find(a), ancesterB = Find(b);        if (ancesterA == ancesterB) return false; // need not to union.        else {            nums[ancesterB] = ancesterA;            return true;        }    }    int Find(int k) {        int i = k;        while (i != nums[i]) i = nums[i]; //Here i is the root.        return i;    }    vector<int> nums;};

整个数据结构非常简洁。稍做一点解释:
1. nums数组就是记录每个点的父节点的下标的数组。 对于根节点, num[i] == i
2. 初始化每个点都是一个独立的树, 自己即为根。
3. 每次查找沿着父节点向上, 直到根节点。
4. 每次合并把一个根节点的父节点设置为另一个根节点。

并查集的优化

前面说过两种优化的方式了。 我们只需要将这两种具体实现即可。
1. 首先来说说秩(rank)优化。
这种优化并不复杂。 即是对每个根节点维护一个深度数组, 每次执行合并时, 选择深度小的树合并到深度大的树上。 这样可以使整棵树深度值较低。(深度不变)
深度的更新在于,当两棵树的深度一样的时候, 随便选择合并方式, 但是此时合并后新的根节点的深度+ 1

  1. 下面是压缩路径优化。 值得注意的是,这个过程不是在合并的时候,而是每次查找的时候, 把当前查找路径上面的节点直接作为与根节点相连的叶子节点。
    这个优化写起来比较简单。 采用递归写法甚至直接一行搞定, 如下:
int Find(x) {    if (father[x] != x) father[x] = Find(father[x]);    return father[x];}

我一般采用非递归如下写法:

    int Find(int k) {        int i = k, j = k;        while (i != nums[i]) i = nums[i]; //Here i is the ancester.        while (nums[j] != i) {  // update the whole path to connect to the ancester directly.            int t = nums[j];            nums[j] = i;            j = t;        }        return i;    }

并查集的简单套路

由于总共也没做几道题, 所以暂时了解的套路也不多。应该是基础的应用吧。
1. 对于无向图的连通分量数求解。
这个可以说是最基础的并查集了。把并查集用上扫描一遍边就出了答案。

  1. 对于区间的合并
    这一类问题要注意的是每次合并的选择。 如果是任意选固然可以, 但是如果人为规定某个位置(区间最左或区间最右) 为根, 那么我们在合并的时候主要讲精力放在一边的处理即可。 而不是两边。
    这个在之后有一道题专门说这个的。暂时就不展开讨论了。

  2. 在二维矩阵中的合并。
    二维矩阵中的问题和区间的很相似, 不同的是矩阵内的合并是连续的, 而不像区间有可能是不连续。事实上这里也是要注意确定一个根节点的方向性(例如在左上之类)。
    由于二维的合并其实比较复杂(主要是合并需要考虑的方向多)。 有的时候并查集并不是一个好的选择。 通常情况下除非有一定的动态查询要求, 我认为用BFS或者DFS来的要比使用并查集更加简单。


并查集的简单应用举例

1. LeetCode 547. Friend Circles

这道题目题目大意就是求出以矩阵表示的一幅无向图中的连通分量数。
这道题是一个并查集应用的典型题。 代码如下。 注意这里几乎就是照搬基本的并查集而没有做什么修改。 只是增加了一个变量用来确定合并后的数目。

//Union Findstruct UF {    UF(int n) {        nums.resize(n);        for (int i = 0; i < n; ++i) {            nums[i] = i;        }        setNum = n;    }    bool Union(int a, int b) {        int ancesterA = Find(a), ancesterB = Find(b);        if (ancesterA == ancesterB) return false; // need not to union.        else {            nums[ancesterB] = ancesterA;            --setNum;            return true;        }    }    int Find(int k) {        int i = k, j = k;        while (i != nums[i]) i = nums[i]; //Here i is the ancester.        while (nums[j] != i) {  // update the whole path to connect to the ancester directly.            int t = nums[j];            nums[j] = i;            j = t;        }        return i;    }    vector<int> nums;    int setNum;};class Solution {public:    int findCircleNum(vector<vector<int>>& M) {        UF uf_set(M.size());        for (int i = 0; i < M.size(); ++i) {            for (int j = 0; j < M[i].size(); ++j) {                if (M[i][j] == 1) uf_set.Union(i, j);            }        }        return uf_set.setNum;    }};

2. LeetCode 737. Sentence Similarity II

这道题题目大意是给定一个相似的单词对序列, 并且这种相似性有传递性(这句话是个很明显的提示, 需要进行合并操作)。 然后给出两个单词向量, 问是否这两个向量每对都是相似的。

基本思路: 根据相似单词对列表建立并查集,建立完成之后扫描两个单词向量, 看每一对是否都在一个相似域中。(即每一对单词的根是否一致)

这里有个小trick : 按照题目直接进行并查集建立, 需要一个map[string, string].但是, 如果预先把单词利用一个map[string, int]做一个映射, 那么 我们需要处理的又是一个最简单的并查集了。

代码如下:

struct UF {    UF(int n) {        father.resize(n + 1);        for (int i = 0; i < father.size(); ++i) {            father[i] = i;        }    }    bool Union(int a, int b) {        int root1 = Find(a), root2 = Find(b);        if (root1 != root2) {            father[root2] = root1; //or father[root1] = root2;            return true;        }        return false;    }    int Find(int k) {        int i = k, j = k;        while (i != father[i]) i = father[i]; // i is the root of k.        //path comdense (to be continued)        while (j != father[j]) {            int tmp = father[j];            father[j] = i;            j = tmp;        }        return i;    }    vector<int> father;};class Solution {public:    bool areSentencesSimilarTwo(vector<string>& words1, vector<string>& words2, vector<pair<string, string>> pairs) {        if (words1.size() != words2.size()) return false;        unordered_map<string, int> index;        int count = 0;        for (int i = 0; i < pairs.size(); ++i) {            if (index[pairs[i].first] == 0) {                ++count;                index[pairs[i].first] = count;            }            if (index[pairs[i].second] == 0) {                ++count;                index[pairs[i].second] = count;            }        }        UF uf_set(count);        for (int i = 0; i < pairs.size(); ++i) {            uf_set.Union(index[pairs[i].first], index[pairs[i].second]);        }        for (int i = 0; i < words1.size(); ++i) {            if (words1[i] == words2[i]) continue;            int root1 = uf_set.Find(index[words1[i]]), root2 = uf_set.Find(index[words2[i]]);            if (root1 == 0 || root2 == 0 || root1 != root2) return false;        }        return true;    }};

本篇小结,续篇说明

我这篇主要内容是想说明什么是并查集, 以及在什么时候需要用到。 最后给出了两个实际的较为简单的并查集使用的例题。

下一篇的话, 将会考虑用一些更难的并查集题目继续作为例子说明并查集的应用。

The End.

原创粉丝点击