微信大概是我们每天必须接触的一个APP之一,公交上、地铁上,异或工作休息时,我们都忍不住刷刷朋友圈,看看一些点赞之交的朋友当天又经历了什么。相较于QQ,微信的一个特点之一就是:除非好友的好友也是你的好友,否则你在朋友圈里看不到好友的好友对好友朋友圈的点赞和评论。
今天刷LeetCode,发现了一道名为“朋友圈”的题目:
不过题目的要求和微信朋友圈不一样。题目说明,如果A和B是朋友,B和C是朋友,那么A和C也是朋友,即朋友圈中的友谊具有传递性。这里的朋友圈也就是朋友的集合。
如何来求解这个题目呢?那就要用到一个用于表示集合内元素关系的数据结构——并查集。
1.并查集与并查集算法
1.1. 并查集
并查集是一种处理不相交集合的合并及查询问题的数据结构,主要用于处理一些不相交集合的合并问题。一些常见的用途有求连通子图、求最小生成树的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。换言之,并查集是一种树形结构,可以用来回答两个元素是否连接的问题。即,通过并查集算法,可以将两个不相连的元素连接起来,也可以查询两个元素是否已连接。这里的“连接”的含义是,两个元素是否具有同一个“根”(从这个角度可以理解,为什么是树形结构)。
1.2.并查集算法
并查集算法通常有以下几个接口:
- 查询两个元素是否连通:connected(int p, int q)
- 将给定的两个元素连通:unionElement(int p, int q)
- 返回给定集合中有多少个连通分量:count()
判断两个元素p和q是否连通,即判断元素p和q是否拥有同一个根root,这里我们需要实现一个辅助函数find(int p),用于查找元素p的根。同理,将两个元素连通,只需要保证两个元素的根是同一个元素即可。
2.并查集算法代码实现
代码实现中,我们使用一个int型的数组parent来表示每一个元素的前驱元素是谁,即它的父节点是谁。如果将元素p和q连接起来了,可以说p是q的父节点,因此parent[q] = p。此时函数find(q)的结果也就是p了,也就是说,此时去寻找q元素的根节点,也就是p了。当然,也有可能p并不是根节点,因为p的根节点可能是m,即find(p)=m。所以find(q)的最终结果是m。(可以看到这里有迭代关系)。下列是并查集算法的C++实现:
class UnionFind{
private:
// 元素个数
int n;
// 每个元素的父节点
int* parent;
// 连通分量个数
int ccount;
public:
UnionFind(int n){
this->n = n;
// 初始时,每个元素都独立,所以有n个连通分量
ccount = n;
this->parent = new int[n];
// 初始化每个元素的根节点是自己
for (int i = 0; i < n; i++){
parent[i] = i;
}
}
~UnionFind(){
// 注意释放空间
delete[]parent;
}
// 查找元素p的根
int find(int p){
while (p != parent[p]){
p = parent[p];
}
return p;
}
// 判断两个元素是否连通,即是否具有同一个根
bool connected(int p, int q){
return find(p) == find(q);
}
// 将两个元素连通
void unionElement(int p, int q){
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)return;
parent[rootQ] = rootP;
// 连通后,连通分量将减少1
ccount--;
}
// 返回连通分量个数
int count(){
return ccount;
}
};
可以看到,并查集这个高级的数据结构,其实代码实现很简单。不过,这部分代码有优化的空间,比如查找函数中,可以使用路径压缩等方法。
2.1. 路径压缩
假设有如下一个连通图,我们要查找元素7的根,最终要经过6次迭代查找,这样的查找效率是很低的(类似于退化为链表的二叉树)。
我们可以做一些优化:如果经过一次查找发现7的根并不是7,那么可以将7的父节点指向其父节点的父节点,如下图:
这样只需要经过3次迭代就可以找到最终的root了。这就是路径压缩。
路径压缩的代码如下:
// 查找元素p的根:路径压缩
int find(int p){
while (p != parent[p]){
parent[p] = parent[parent[p];
p = parent[p];
}
return p;
}
3.LeetCode相关题目
547. 朋友圈
这是本文刚开始时提到的朋友圈问题。从示例给出的矩阵可以看到,这是一个沿对角对称的矩阵,因此我们可以只考察左下或右上部分即可。如何考察呢?如果某个元素M[i][j]==1,说明i和j是好友,这时候只需要使用union将二者连接起来即可。该题目求最后有几个朋友圈,其实也就是求有几个连通分量。由并查集的上述代码实现可知,初始化时,连通分量个数被初始化为元素个数,每当成功执行一次合并操作后,连通分量个数减1。所以最后我们直接返回count函数即可。
int findCircleNum(vector<vector<int>>& M) {
int num = M.size();
UnionFind uf(num);
for(int i=0;i<num;i++){
for(int j = i+1;j<num;j++){
if(M[i][j] == 1 && !uf.connected(i,j)){
uf.unionElement(i,j);
}
}
}
return uf.count();
}
130. 被围绕的区域
此题目与朋友圈一题类似,不同之处在于,如果边界上存在'O'并且矩阵内部有与边界'O'相邻的'O',应该如何表示其连通?这里我们可以设定一个假想的'O',不妨将它的索引设置为元素个数+1。将边界上的'O'直接与整个假想'O'连通,矩阵内部与边界'O'相邻的'O'也与这个假想'O'连通。最后,我们遍历整个矩阵,只需要判断每个遍历的元素是否与假想‘O’相连通即可。
void solve(vector<vector<char>>& board) {
if (board.empty()){
return;
}
int row = board.size();
int col = board[0].size();
// 多一个元素是因为有一个假想的'O',并且假设
// 这个'O'在并查集中的序号为最后,即row*col(索引从0开始)
UnionFind uf(row*col + 1);
for (int i = 0; i < row; i++){
for (int j = 0; j<col; j++){
if (board[i][j] == 'O'){
// 在边界上的'O'直接与假想的'O'连通
if (i == 0 || i == row - 1 || j == 0 || j == col - 1){
uf.unionElement(i*col + j, row*col);
}
// 内部的'O'与上下左右的'O'连通
else{
if (i>0 && board[i - 1][j] == 'O'){
uf.unionElement(i*col + j, (i - 1)*col + j);
}
if (i < row - 1 && board[i + 1][j] == 'O'){
uf.unionElement(i*col + j, (i + 1)*col + j);
}
if (j>0 && board[i][j - 1] == 'O'){
uf.unionElement(i*col + j, i*col + j - 1);
}
if (j < col - 1 && board[i][j + 1] == 'O'){
uf.unionElement(i*col + j, i*col + j + 1);
}
}
}
}
}
for (int i = 1; i < row; i++){
for (int j = 1; j < col; j++){
// 凡是与假想'O'没有连通的元素,都赋值为'X'
if (!uf.connected(i*col + j, row*col)){
board[i][j] = 'X';
}
}
}
}
200. 岛屿数量
这道题目可以说与上一道题目一模一样。不同之处在于,最后返回连通数目时需要减1,因为题目只需求出岛屿数量,我们需要减去1个水的连通分量。
int numIslands(vector<vector<char>>& grid) {
if (grid.empty()){
return 0;
}
int row = grid.size();
int col = grid[0].size();
UnionFind uf(row*col + 1);
int c = 0;
for (int i = 0; i<grid.size(); i++){
for (int j = 0; j<grid[i].size(); j++){
if (grid[i][j] == '0'){
uf.unionElement(i*col + j, row*col);
}
else if (grid[i][j] == '1'){
// 尝试与左边和上边合并
if (i == 0 && j == 0){
uf.unionElement(0, 0);
}
else if (i == 0){// 第一行,只考虑与左边合并
if (grid[i][j - 1] == '1'){
uf.unionElement(i*col + j, i*col + j - 1);
}
}
else if (j == 0){// 第一列,只考虑与上边合并
if (grid[i - 1][j] == '1'){
uf.unionElement((i - 1)*col + j, i*col + j);
}
}
else{
if (grid[i - 1][j] == '1'){
uf.unionElement((i - 1)*col + j, i*col + j);
}
if (grid[i][j - 1] == '1'){
uf.unionElement(i*col + j, i*col + j - 1);
}
}
}
}
}
return uf.count() - 1;
}