在算法面试和编程竞赛中,图论问题一直是高频考点。其中"多余的边"这类问题考察的是对图的基本性质的理解以及并查集(Union-Find)数据结构的应用能力。这类问题通常会给出一个由边组成的集合,要求找出导致图中出现环的那条"多余"的边。
108题和109题的区别在于:
并查集是一种树型的数据结构,用于处理一些不交集合(Disjoint Sets)的合并及查询问题。它支持两种操作:
提示:在解决图论问题时,判断图中是否存在环是一个常见需求。并查集在这方面有天然优势,因为它可以高效地判断两个节点是否已经连通。
给定一个无向图,由n条边组成。每条边用一对节点表示。需要找出第一条导致图中出现环的边(按照输入顺序)。
关键点:
cpp复制#include <iostream>
#include <vector>
using namespace std;
int n;
vector<int> father(1001, 0); // 父节点数组
// 查找根节点(带路径压缩)
int find(int u) {
if (u == father[u]) return u;
else father[u] = find(father[u]); // 路径压缩
return father[u];
}
// 合并两个集合
void join(int u, int v) {
u = find(u);
v = find(v);
if (u == v) return; // 已经在同一集合
father[v] = u; // 合并
}
// 判断是否在同一集合
bool isSame(int u, int v) {
u = find(u);
v = find(v);
return u == v;
}
// 初始化并查集
void init() {
for (int i = 1; i <= n; i++) {
father[i] = i; // 初始时每个节点是自己的父节点
}
}
int main() {
int s, t;
cin >> n;
init();
while (n--) {
cin >> s >> t;
if (isSame(s, t)) { // 如果已经连通
cout << s << ' ' << t << endl; // 这就是多余的边
return 0;
} else join(s, t); // 否则合并
}
return 0;
}
father[u] = find(father[u])实现路径压缩,使得后续查询更快。注意:在实际应用中,如果节点数量很大,可以考虑使用哈希表代替数组来存储父节点关系,以节省空间。
109题比108题更复杂,因为它处理的是有向图。在有向图中,我们需要考虑边的方向性,这使得问题变得更加复杂。
关键点:
cpp复制#include <iostream>
#include <vector>
using namespace std;
int n;
vector<int> father(1001, 0);
// 并查集初始化
void init() {
for (int i = 1; i <= n; ++i) {
father[i] = i;
}
}
// 查找根节点(带路径压缩)
int find(int u) {
return u == father[u] ? u : father[u] = find(father[u]);
}
// 合并两个集合
void join(int u, int v) {
u = find(u);
v = find(v);
if (u == v) return; // 已经在同一集合
father[v] = u; // 合并
}
// 判断是否在同一集合
bool same(int u, int v) {
u = find(u);
v = find(v);
return u == v;
}
// 在有向图里找到删除的那条边,使其变成树
void getRemoveEdge(const vector<vector<int>>& edges) {
init(); // 初始化并查集
for (int i = 0; i < n; i++) { // 遍历所有的边
if (same(edges[i][0], edges[i][1])) { // 构成有向环了
cout << edges[i][0] << " " << edges[i][1];
return;
} else {
join(edges[i][0], edges[i][1]);
}
}
}
// 删一条边之后判断是不是树
bool isTreeAfterRemoveEdge(const vector<vector<int>>& edges, int deleteEdge) {
init(); // 初始化并查集
for (int i = 0; i < n; i++) {
if (i == deleteEdge) continue;
if (same(edges[i][0], edges[i][1])) { // 构成有向环了
return false;
}
join(edges[i][0], edges[i][1]);
}
return true;
}
int main() {
int s, t;
vector<vector<int>> edges;
cin >> n;
vector<int> inDegree(n + 1, 0); // 记录节点入度
// 输入处理
for (int i = 0; i < n; i++) {
cin >> s >> t;
inDegree[t]++;
edges.push_back({s, t});
}
// 找入度为2的节点所对应的边(倒序)
vector<int> vec;
for (int i = n - 1; i >= 0; i--) {
if (inDegree[edges[i][1]] == 2) {
vec.push_back(i);
}
}
// 情况一、情况二:处理入度为2的情况
if (vec.size() > 0) {
// 优先尝试删除后出现的边
if (isTreeAfterRemoveEdge(edges, vec[0])) {
cout << edges[vec[0]][0] << " " << edges[vec[0]][1];
} else {
cout << edges[vec[1]][0] << " " << edges[vec[1]][1];
}
return 0;
}
// 情况三:处理有向环的情况
getRemoveEdge(edges);
}
注意:在有向图中使用并查集检测环时,需要注意边的方向性。这与无向图的情况有所不同。
虽然示例代码中没有实现按秩合并,但在实际应用中可以考虑添加:
cpp复制vector<int> rank(1001, 1); // 初始化秩
void join(int u, int v) {
u = find(u);
v = find(v);
if (u == v) return;
if (rank[u] > rank[v]) {
father[v] = u;
} else {
father[u] = v;
if (rank[u] == rank[v]) rank[v]++;
}
}
无向图情况(108题):
有向图情况(109题):
father[u] = find(father[u]),而不是find(father[u])。对于108题(无向图):
code复制输入1:
3
1 2
1 3
2 3
输出1:2 3
输入2:
4
1 2
2 3
3 4
1 4
输出2:1 4
对于109题(有向图):
code复制输入1:
3
1 2
1 3
2 3
输出1:2 3
输入2:
4
2 1
3 1
4 2
4 3
输出2:4 3
在实际编程练习中,建议从简单的无向图问题开始,逐步过渡到更复杂的有向图问题。理解并查集的核心思想是关键,掌握了这个数据结构后,许多图论问题都能迎刃而解。