赞
踩
其主要操作:
简单来说,动态连通性可以抽象为一幅图连线。比如下面图中,一共有10个节点,它们互不相连,分别用
0
9
0~9
0 9标记
并查集算法需要实现如下操作:
class UF{
public:
// 将p和q相连
void unionElements(int p, int q);
// 判断p和q是否连通
bool isConnected(int p, int q);
// 返回图中有多少个连通分量
int count();
};
这里说的[连通]是一种等价关系,也就是说具有如下三个性质:
如上图:
判断这种「等价关系」 ⾮常实⽤, ⽐如说编译器判断同⼀个变量的不同引⽤, ⽐如社交⽹络中的朋友圈计算等等。
而Union-Find 算法的关键就在于 unionElements和 isConnected函数的效率。 那么⽤什么模型来表⽰这幅图的连通状态呢? ⽤什么数据结构来实现代码呢?
怎么实现
怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。刚开始时,图并没有相互连通,如下:
class UnionFind{ private: // 记录连通分量 int cnt; // 节点 x 的节点是 parent[x] std::vector<int> parent; public: /* 构造函数, n 为图的节点总数 */ explicit UnionFind(int n){ // ⼀开始互不连通, ⽗节点指针初始指向⾃⼰ this->cnt = n; parent.resize(n); for (int i = 0; i < n; ++i) { parent[i] = i; } } //返回当前的连通分量个数 int count() const{ return cnt; } private: // 查找某个节点的父节点 int findParent(int n){ return parent[n]; } // 返回某个节点 x 的根节点 int findRoot(int x){ // 根节点的 parent[x] == x while (parent[x] != x){ x = parent[x]; } return x; } };
如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上
// 将p和q相连
void unionElements(int p, int q){
int rootP = findRoot(p);
int rootQ = findRoot(q);
if(rootP == rootQ){
return;
}
// 将两棵树合并为⼀棵
parent[rootP] = rootQ;
--cnt;
}
这样, 如果节点 p 和 q 连通的话, 它们⼀定拥有相同的根节点:
// 判断p和q是否连通
bool isConnected(int p, int q){
int rootP = findRoot(p);
int rootQ = findRoot(q);
return rootQ == rootP;
}
⾄此, Union-Find 算法就基本完成了。我们来分析下时间复杂度。可以发现,主要API isConnected和 unionElements中的复杂度都是 findRoot函数造成的, 所以说它们的复杂度和 findRoot⼀样。
findRoot主要功能就是从某个节点向上遍历到树根, 其时间复杂度就是树的⾼度。 我们可能习惯性地认为树的⾼度就是 logN , 但这并不⼀定。 logN 的⾼度只存在于平衡⼆叉树, 对于⼀般的树可能出现极端不平衡的情况, 使得「树」 ⼏乎退化成「链表」 , 树的⾼度最坏情况下可能变成 N
所以说上⾯这种解法, findRoot, isConnected, unionElements的时间复杂度都是 O(N)。这个复杂度很不理想的, 你想图论解决的都是诸如社交⽹络这样数据规模巨⼤的问题, 对于 unionElements和 isConnected的调⽤⾮常频繁, 每次调⽤需要线性时间完全不可忍受。
问题的关键在于, 如何想办法避免树的不平衡呢?
平衡性优化
我们要知道哪种情况下可能出现不平衡现象,关键在于union
过程:
// 将p和q相连
void unionElements(int p, int q){
int rootP = findRoot(p);
int rootQ = findRoot(q);
if(rootP == rootQ){
return;
}
// 将两棵树合并为⼀棵
parent[rootP] = rootQ;
--cnt;
}
我们⼀开始就是简单粗暴的把 p 所在的树接到 q 所在的树的根节点下⾯,那么这⾥就可能出现「头重脚轻」 的不平衡状况, ⽐如下⾯这种局⾯:
⻓此以往, 树可能⽣⻓得很不平衡。 我们其实是希望, ⼩⼀些的树接到⼤⼀些的树下⾯, 这样就能避免头重脚轻, 更平衡⼀些。解决⽅法是额外使⽤⼀个 size 数组, 记录每棵树包含的节点数, 我们不妨称为「重量」
private: // 记录连通分量 int cnt; // 节点 x 的节点是 parent[x] std::vector<int> parent; // 新增⼀个数组记录树的“重量” std::vector<int> weight; public: /* 构造函数, n 为图的节点总数 */ explicit UnionFind(int n){ // ⼀开始互不连通, ⽗节点指针初始指向⾃⼰ this->cnt = n; parent.resize(n); weight.resize(n); for (int i = 0; i < n; ++i) { parent[i] = i; weight[i] = 1; } }
⽐如说 weight[3] = 5 表⽰, 以节点 3 为根的那棵树, 总共有 5 个节点。 这样我们可以修改⼀下 union ⽅法:
// 将p和q相连 void unionElements(int p, int q){ int rootP = findRoot(p); int rootQ = findRoot(q); if(rootP == rootQ){ return; } // ⼩树接到⼤树下⾯, 较平衡 if(weight[rootP] > weight[rootQ]){ parent[rootQ] = rootP; weight[rootP] += weight[rootQ]; }else{ parent[rootP] = rootQ; weight[rootQ] += weight[rootP]; } --cnt; }
这样, 通过⽐较树的重量, 就可以保证树的⽣⻓相对平衡, 树的⾼度⼤致在 logN 这个数量级, 极⼤提升执⾏效率。
此时, findRoot, isConnected, unionElements 的时间复杂度都下降为 O(logN), 即便数据规模上亿, 所需时间也⾮常少
路径压缩
这步优化特别简单,所以非常巧妙。我们能不能进一步压缩每棵树的高度,使树高始终保持为常数?这样find就能以 O(1) 的时间找到某一节点的根节点,相应的,isConnected和unionElements 复杂度都下降为 O(1)。
要做到这一点,非常简单,只需要在要做到这一点,非常简单,只需要在find中加一行代码:中加一行代码:
// 返回某个节点 x 的根节点
int findRoot(int x){
// 根节点的 parent[x] == x
while (parent[x] != x){
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
可见,调用find函数每次向树根遍历的同时,顺手将树高缩短了,最终所有树高都不会超过 3(union的时候树高可能达到 3)。
问题:既然有了路径压缩, weight数组的重量平衡还需要吗? 这个问题很有意思, 因为路径压缩保证了树⾼为常数(不超过 3) , 那么树就算不平衡, ⾼度也是常数, 基本没什么影响。
论时间复杂度的话,确实,不需要重量也是O(1)。但是如果加上 weight数组辅助, 效率还是略微⾼⼀些, ⽐如下⾯这种情况:
如果带有重量平衡优化, ⼀定会得到情况⼀, ⽽不带重量优化, 可能出现情况⼆。 ⾼度为 3 时才会触发路径压缩那个 while 循环, 所以情况⼀根本不会触发路径压缩, ⽽情况⼆会多执⾏很多次路径压缩, 将第三层节点压缩到第⼆层。
也就是说, 去掉重量平衡, 虽然对于单个的 find 函数调⽤, 时间复杂度依然是 O(1), 但是对于 API 调⽤的整个过程, 效率会有⼀定的下降。 当然, 好处就是减少了⼀些空间, 不过对于 Big O 表⽰法来说, 时空复杂度都没变。
总结如下:
class UnionFind{ private: // 记录连通分量 int cnt; // 节点 x 的节点是 parent[x] std::vector<int> parent; // 新增⼀个数组记录树的“重量” std::vector<int> weight; public: /* 构造函数, n 为图的节点总数 */ explicit UnionFind(int n){ // ⼀开始互不连通, ⽗节点指针初始指向⾃⼰ this->cnt = n; parent.resize(n); weight.resize(n); for (int i = 0; i < n; ++i) { parent[i] = i; weight[i] = 1; } } //返回当前的连通分量个数 int count() const{ return cnt; } // 将p和q相连 void unionElements(int p, int q){ int rootP = findRoot(p); int rootQ = findRoot(q); if(rootP == rootQ){ return; } // ⼩树接到⼤树下⾯, 较平衡 if(weight[rootP] > weight[rootQ]){ parent[rootQ] = rootP; weight[rootP] += weight[rootQ]; }else{ parent[rootP] = rootQ; weight[rootQ] += weight[rootP]; } --cnt; } // 判断p和q是否连通 bool isConnected(int p, int q){ int rootP = findRoot(p); int rootQ = findRoot(q); return rootQ == rootP; } private: int findParent(int n){ return parent[n]; } // 返回某个节点 x 的根节点 int findRoot(int x){ // 根节点的 parent[x] == x while (parent[x] != x){ // 进行路径压缩 parent[x] = parent[parent[x]]; x = parent[x]; } return x; } };
算法的关键点有 3 个:
我们通过一个数组来实现一个并查集,数组索引作为数据编号:
从上面的图可以知道:
class Node{ int value; public: explicit Node(int v) : value(v){ } }; class UnionFind{ std::map<int, std::shared_ptr<Node>> nodes; std::map<std::shared_ptr<Node>, std::shared_ptr<Node>> parents; std::map<std::shared_ptr<Node>, int> sizeMap; public: ~UnionFind(){ for(auto i : nodes){ } } UnionFind(const std::list<int>& values){ for(auto v : values){ auto node = std::make_shared<Node>(v); nodes[v] = node; parents[node] = node; sizeMap[node] = 1; } } // 给你一个节点,请你往上到不能再往上,把代表返回 std::shared_ptr<Node> findRoot(std::shared_ptr<Node> cur){ std::stack<std::shared_ptr<Node>> path; while (cur != parents[cur]){ path.emplace(cur); cur = parents[cur]; } while (!path.empty()){ parents[path.top()] = cur; } return cur; } bool isSameSet(int a, int b){ return findRoot(nodes[a]) == findRoot(nodes[b]); } void unionEle(int a, int b){ auto aRoot = findRoot(nodes[a]); auto bRoot = findRoot(nodes[b]); if(aRoot != bRoot){ int aSetSize = sizeMap[aRoot]; int bSetSize = sizeMap[bRoot]; auto big = aSetSize >= bSetSize ? aRoot : bRoot; auto small = big == aRoot ? bRoot : aRoot; parents[small] = big; sizeMap[big] = aSetSize + bSetSize; sizeMap.erase(small); } } int sets(){ return sizeMap.size(); } };
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。