目录
零、前言
并查集解决的问题:集合问题,两个节点在不在一个集合,可以将两个节点添加到一个集合中。
并查集问题模板(来源:代码随想录):
int n = 1005; // 节点数量3 到 1000
int father[1005];
// 并查集初始化
void init() {
for (int i = 0; i < n; ++i) {
father[i] = i;
}
}
// 并查集里寻根的过程
int find(int u) {
return u == father[u] ? u : father[u] = find(father[u]);
}
// 将v->u 这条边加入并查集
void join(int u, int v) {
u = find(u);
v = find(v);
if (u == v) return ;
father[v] = u;
}
// 判断 u 和 v是否找到同一个根
bool same(int u, int v) {
u = find(u);
v = find(v);
return u == v;
}
并查集主要有三个功能:
- 寻找根节点,函数:find(int u),也就是判断这个节点的祖先节点是哪个
- 将两个节点接入到同一个集合,函数:join(int u, int v),将两个节点连在同一个根节点上
- 判断两个节点是否在同一个集合,函数:same(int u, int v),就是判断两个节点是不是同一个根节点
注意find函数中的返回值father[u] = find(father[u]),具有路径压缩的功能。
684. 冗余连接
int n = 1001; // 节点数量 3 <= n <= 1000
int father[1001];
// 并查集寻根:u == father[u]说明无根,否则递归找根
int find(int u) {
return u == father[u] ? u : father[u] = find(father[u]);
}
// 判断是否同根
bool isSame(int u, int v) {
u = find(u);
v = find(v);
return u == v;
}
// 将边u -> v加入并查集
void join(int u, int v) {
u = find(u);
v = find(v);
if (u == v) return;
// u和v这里已经改变为其根的值,在根节点进行连线
father[u] = v;
}
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
// 并查集初始化
for (int i = 0; i < n; i++) father[i] = i;
for (vector<int> v : edges) {
if (isSame(v[0], v[1])) return v;
else join(v[0], v[1]);
}
return {};
}
理解了并查集方法后, 本题只需从前向后遍历每一条边,边的两个节点如果不在同一个集合,就加入集合(即:同一个根节点);否则说明已成环,返回这条边即可。
685. 冗余连接 II[*]
static const int n = 1001; // 节点数量 3 <= n <= 1000
int father[1001];
// 并查集寻根:u == father[u]说明无根,否则递归找根
int find(int u) {
return u == father[u] ? u : father[u] = find(father[u]);
}
// 判断是否同根
bool isSame(int u, int v) {
u = find(u);
v = find(v);
return u == v;
}
// 将边u -> v加入并查集
void join(int u, int v) {
u = find(u);
v = find(v);
if (u == v) return;
// u和v这里已经改变为其根的值,在根节点进行连线
father[u] = v;
}
vector<int> findRedundantDirectedConnection(vector<vector<int>>& edges) {
// 记录节点入度
int inDegree[n] = { 0 };
for (vector<int> v : edges) {
inDegree[v[1]]++;
}
// 多余一条边,有两种情况:1.出现入度为2的点; 2.出现环
// 处理出现入度为2点的情况,从后之前删除,判断是否满足树的条件
vector<int> vec;
for (int i = edges.size() - 1; i >= 0; i--) {
if (inDegree[edges[i][1]] == 2) {
vec.push_back(i);
}
}
// 若为第一种情况,vec中会有两条边(这两个边出度为2),判断哪个可以删除
if (!vec.empty()) {
if (isTreeAfterRemoveEdge(edges, vec[0])) return edges[vec[0]];
else return edges[vec[1]];
}
// 若为第二种情况,结构中有有向环,找到删除那条边可以成树的边
return getRemovedEdge(edges);
}
bool isTreeAfterRemoveEdge(const vector<vector<int>>& edges, int deleteEdge) {
// 初始化并查集
for (int i = 0; i < n; i++) father[i] = i;
for (int i = 0; i < edges.size(); i++) {
if (i == deleteEdge) continue;
// 移除目标边后,若两点所在边还有相同的根,则有有向环【且有向环只有唯一解,可以直接返回】
else {
if (isSame(edges[i][0], edges[i][1])) return false;
join(edges[i][0], edges[i][1]);
}
}
return true;
}
vector<int> getRemovedEdge(const vector<vector<int>>& edges) {
// 初始化并查集
for (int i = 0; i < n; i++) father[i] = i;
for (auto v : edges) {
// 待添加的这条边已经构成有向环了,需要移除【且这种情况下只有唯一解,可以直接返回】
if (isSame(v[0], v[1])) return v;
else join(v[0], v[1]);
}
return {};
}
并查集的基本原理与上一题是类似的,难点在于在有向图中,多余一条边的情况分解。
- 第一种情况:有一个点的入度为2(来源:代码随想录),这个点会对应两条边,以出现顺序从后之前的顺序(因为要返回靠后的结果),判断移除后这条边能否满足构成树的条件。
第一种情况下的子情况1,这两条边为[1,2]和[2,3],移除前者没问题,移除后者结构中有环,加入[2,4]时isSame会返回true。因此结果只能是[1,2];
第一种情况下的子情况2,这两条边为[1,3]和[2,3],移除这两者都可以(因为移除后结构中没有有向环了),返回这两条边的靠后者即可。
- 第二种情况:结构中有有向环
这种情况遍历移除每一条边,判断移除后结构中是否还有环即可。【有环就意味着某条边加入时,其两个顶点值已经有公共父亲了】
另外,一些使用 DFS 深度优先算法解决的问题,也可以用 Union-Find 算法解决。
130. 被围绕的区域
把那些不需要被替换的 O
看成一个拥有独门绝技的门派,它们有一个共同「祖师爷」叫 dummy
,这些 O
和 dummy
互相连通,而那些需要被替换的 O
与 dummy
不连通。 只有和边界 O
相连的 O
才具有和 dummy
的连通性,他们不会被替换。(来源:labuladong)
class Solution {
public:
class UF {
public:
vector<int> father;
// 并查集寻根:无根/递归找根
int find(int u) {
return u == father[u] ? u : father[u] = find(father[u]);
}
// 判断是否同根
bool isSame(int u, int v) {
u = find(u);
v = find(v);
return u == v;
}
// 将u -> v加入并查集
void join(int u, int v) {
u = find(u);
v = find(v);
if (u == v) return;
father[u] = v;
}
};
void solve(vector<vector<char>>& board) {
// 构建并查集
UF uf;
int m = board.size();
int n = board[0].size();
// 给不需要被替换的 O 赋予共同的虚父结点,并为其预留一个位置
uf.father.resize(m * n + 1);
for (size_t i = 0; i < m * n + 1; ++i) uf.father[i] = i;
int dummy = m * n;
// 将首列和末列的 O 与 dummy 连通
for (int i = 0; i < m; i++) {
if (board[i][0] == 'O')
uf.join(i * n, dummy);
if (board[i][n - 1] == 'O')
// 二维数组转化为一维数组
uf.join(i * n + n - 1, dummy);
}
// 将首行和末行的 O 与 dummy 连通
for (int j = 0; j < n; j++) {
if (board[0][j] == 'O')
uf.join(j, dummy);
if (board[m - 1][j] == 'O')
uf.join(n * (m - 1) + j, dummy);
}
// 方向数组 d 是上下左右搜索的常用手法
int d[4][2] = { {1,0}, {0,1}, {0,-1}, {-1,0} };
for (int i = 1; i < m - 1; i++)
for (int j = 1; j < n - 1; j++)
if (board[i][j] == 'O')
// 将此 O 与上下左右的 O 连通
for (int k = 0; k < 4; k++) {
int x = i + d[k][0];
int y = j + d[k][1];
if (board[x][y] == 'O')
uf.join(x * n + y, i * n + j);
}
// 所有不和 dummy 连通的 O,都要被替换
for (int i = 1; i < m - 1; i++)
for (int j = 1; j < n - 1; j++)
if (!uf.isSame(dummy, i * n + j))
board[i][j] = 'X';
}
};
990. 等式方程的可满足性
class Solution {
public:
class UF {
public:
vector<int> father;
// 并查集寻根:无根/递归找根
int find(int u) {
return u == father[u] ? u : father[u] = find(father[u]);
}
// 判断是否同根
bool isSame(int u, int v) {
u = find(u);
v = find(v);
return u == v;
}
// 将u -> v加入并查集
void join(int u, int v) {
u = find(u);
v = find(v);
if (u == v) return;
father[u] = v;
}
};
bool equationsPossible(vector<string>& equations) {
UF uf;
// 26个英文小写字母,初始化其并查集
uf.father.resize(26);
for (size_t i = 0; i < 26; ++i) {
uf.father[i] = i;
}
// 根据相等公式,构建并查集关系
for (auto equ : equations) {
if (equ[1] == '=') {
uf.join(equ[0] - 'a', equ[3] - 'a');
}
}
// 根据不等公式,检查并查集关系
for (auto equ : equations) {
if (equ[1] == '!') {
if (uf.isSame(equ[0] - 'a', equ[3] - 'a')) return false;
}
}
return true;
}
};
核心思想是,将 equations
中的算式根据 ==
和 !=
分成两部分,先处理 ==
算式,使得他们通过相等关系各自勾结成门派(连通分量);然后处理 !=
算式,检查不等关系是否破坏了相等关系的连通性。