1. 项目概述
这道来自NOI2001的经典题目"食物链",是算法竞赛中考察并查集应用的典型例题。题目描述了一个包含N种动物的生态系统,这些动物之间存在A吃B、B吃C、C吃A的循环食物链关系。我们需要根据给定的K条陈述(两种动物是同类或捕食关系),判断其中有多少条与之前的信息矛盾。
作为OI选手必刷的经典题目,它完美展示了如何用带权并查集或扩展域并查集来维护复杂关系。我在AC这道题时,深刻体会到这两种解法的精妙之处——它们用看似简单的数据结构,优雅地处理了环形依赖关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法解析
2.1 带权并查集解法
带权并查集通过在节点间维护相对关系来解决问题。我们定义权值数组d[],其中d[x]表示x与父节点fa[x]的关系:
- 0:x与fa[x]是同类
- 1:x吃fa[x]
- 2:x被fa[x]吃
关键操作在于路径压缩和合并时的权值更新。当查询x的根节点时,我们需要同时更新d[x]的值:
cpp复制int find(int x) {
if (fa[x] != x) {
int root = find(fa[x]);
d[x] = (d[x] + d[fa[x]]) % 3;
fa[x] = root;
}
return fa[x];
}
合并操作时,假设我们要将x和y设为relation关系(0为同类,1为x吃y),需要先找到它们的根节点,然后根据权值关系判断是否矛盾:
cpp复制bool merge(int x, int y, int relation) {
int fx = find(x), fy = find(y);
if (fx == fy) {
return (d[x] - d[y] + 3) % 3 == relation;
}
fa[fx] = fy;
d[fx] = (d[y] - d[x] + relation + 3) % 3;
return true;
}
2.2 扩展域并查集解法
扩展域并查集通过将每个动物拆分为三个节点来分别表示"自身"、"食物"和"天敌"。对于编号为x的动物:
- x:自身
- x+N:食物
- x+2*N:天敌
当声明x和y是同类时,我们需要合并:
- x和y
- x+N和y+N
- x+2N和y+2N
当声明x吃y时,则需要合并:
- x和y+2*N(x吃y)
- x+N和y(x的食物是y)
- x+2*N和y+N(x的天敌是y的食物)
这种解法的优势在于逻辑直观,但需要三倍的空间:
cpp复制void merge(int x, int y) {
fa[find(x)] = find(y);
}
bool query(int x, int y) {
return find(x) == find(y);
}
// 处理同类关系
if (query(x, y+N) || query(x, y+2*N)) {
// 矛盾
} else {
merge(x, y);
merge(x+N, y+N);
merge(x+2*N, y+2*N);
}
// 处理捕食关系
if (query(x, y) || query(x, y+2*N)) {
// 矛盾
} else {
merge(x, y+2*N);
merge(x+N, y);
merge(x+2*N, y+N);
}
3. 完整代码实现
3.1 带权并查集实现
cpp复制#include <iostream>
using namespace std;
const int MAXN = 50010;
int fa[MAXN], d[MAXN]; // d[x]表示x与父节点的关系:0同类,1吃,2被吃
int find(int x) {
if (fa[x] != x) {
int root = find(fa[x]);
d[x] = (d[x] + d[fa[x]]) % 3;
fa[x] = root;
}
return fa[x];
}
int main() {
int n, k, ans = 0;
cin >> n >> k;
for (int i = 1; i <= n; i++) fa[i] = i;
while (k--) {
int op, x, y;
cin >> op >> x >> y;
if (x > n || y > n) {
ans++;
continue;
}
int fx = find(x), fy = find(y);
if (op == 1) { // x和y是同类
if (fx == fy) {
if (d[x] != d[y]) ans++;
} else {
fa[fx] = fy;
d[fx] = (d[y] - d[x] + 3) % 3;
}
} else { // x吃y
if (x == y) {
ans++;
continue;
}
if (fx == fy) {
if ((d[x] - d[y] + 3) % 3 != 1) ans++;
} else {
fa[fx] = fy;
d[fx] = (d[y] - d[x] + 4) % 3;
}
}
}
cout << ans << endl;
return 0;
}
3.2 扩展域并查集实现
cpp复制#include <iostream>
using namespace std;
const int MAXN = 50010;
int fa[MAXN * 3]; // 每个动物拆分为3个节点
int find(int x) {
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
void merge(int x, int y) {
fa[find(x)] = find(y);
}
bool query(int x, int y) {
return find(x) == find(y);
}
int main() {
int n, k, ans = 0;
cin >> n >> k;
for (int i = 1; i <= 3*n; i++) fa[i] = i;
while (k--) {
int op, x, y;
cin >> op >> x >> y;
if (x > n || y > n) {
ans++;
continue;
}
if (op == 1) { // x和y是同类
if (query(x, y+n) || query(x, y+2*n)) {
ans++;
} else {
merge(x, y);
merge(x+n, y+n);
merge(x+2*n, y+2*n);
}
} else { // x吃y
if (x == y || query(x, y) || query(x, y+2*n)) {
ans++;
} else {
merge(x, y+2*n);
merge(x+n, y);
merge(x+2*n, y+n);
}
}
}
cout << ans << endl;
return 0;
}
4. 解题思路与技巧
4.1 问题分析关键点
-
关系传递性:题目中的关系具有传递性。如果A吃B,B吃C,那么C吃A,形成循环。这种环形关系是解题的关键难点。
-
矛盾判断条件:当新陈述与已有信息冲突时即为矛盾。例如:
- 已知A吃B,又说A和B是同类
- 已知A和B是同类,又说A吃B
-
输入有效性检查:动物编号超过N的情况直接判定为假话。
4.2 算法选择考量
两种解法各有优劣:
- 带权并查集:空间效率高(O(N)),但关系维护逻辑较复杂,需要处理模运算
- 扩展域并查集:逻辑直观,易于理解,但需要三倍空间(O(3N))
在竞赛中,如果N很大(如1e6),带权并查集更优;如果N较小(如本题的5e4),两种方法均可。
4.3 常见错误与调试技巧
-
关系计算错误:在带权并查集中,容易搞混关系权值的加减方向。建议画图辅助理解:
code复制x -> fx (d[x]) y -> fy (d[y]) 合并后fx -> fy的关系应该是? -
模运算处理:注意C++中负数取模的结果可能为负,需要加上模数再取模:
cpp复制
(a - b + MOD) % MOD -
初始化问题:忘记初始化fa数组或初始化范围不足(扩展域需要初始化3N个元素)
-
输入范围检查:容易漏掉x或y超过N的情况,以及x吃x的情况
调试技巧:可以编写一个小型测试用例生成器,随机生成合法/非法的关系陈述,验证程序的正确性。
5. 性能优化与扩展
5.1 时间复杂度分析
两种解法的时间复杂度相近:
- 单次find操作:近似O(α(N)),其中α是反阿克曼函数,实际中可视为常数
- 总体复杂度:O(Kα(N))
5.2 空间优化技巧
对于扩展域并查集,如果N很大(如1e6),可以改用哈希表实现动态扩展,避免预分配3N的空间:
cpp复制unordered_map<int, int> fa;
int find(int x) {
if (!fa.count(x)) return fa[x] = x;
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
5.3 问题变种与扩展
-
更多关系类型:如果食物链关系扩展到4种或更多生物,只需调整模数或扩展域数量
-
动态关系修改:如果需要支持关系的修改操作,可以考虑使用可持久化并查集或LCT
-
部分已知关系:如果某些关系是已知的固定事实,可以预先处理这些关系
6. 竞赛应用与实战建议
6.1 竞赛中的选择策略
在OI/ACM竞赛中遇到类似问题时,建议:
- 先判断关系是否具有传递性和对称性
- 如果关系种类有限(如本题的3种),优先考虑带权并查集
- 如果关系复杂但离散,考虑扩展域并查集
- 注意数据规模,选择空间效率合适的解法
6.2 编码模板建议
建议将并查集封装成类或结构体,方便竞赛中快速调用:
cpp复制struct DSU {
vector<int> fa, d;
DSU(int n) : fa(n+1), d(n+1) {
iota(fa.begin(), fa.end(), 0);
}
int find(int x) {
if (fa[x] != x) {
int root = find(fa[x]);
d[x] = (d[x] + d[fa[x]]) % 3;
fa[x] = root;
}
return fa[x];
}
bool merge(int x, int y, int r) {
int fx = find(x), fy = find(y);
if (fx == fy) return (d[x] - d[y] + 3) % 3 == r;
fa[fx] = fy;
d[fx] = (d[y] - d[x] + r + 3) % 3;
return true;
}
};
6.3 测试用例设计
设计测试用例时应考虑以下边界情况:
- 所有陈述都是真的
- 所有陈述都是假的
- 大量重复陈述
- 最大N和K的情况
- 包含环形关系链的复杂情况
示例测试用例:
code复制5 5
1 1 2
2 2 3
2 3 4
2 4 5
2 5 1
预期输出:0(形成完整的食物链环)
7. 学习路径推荐
对于想深入掌握并查集应用的选手,建议按以下顺序学习:
- 基础并查集(连通性问题)
- 带权并查集(关系维护)
- 扩展域并查集(复杂关系)
- 可持久化并查集(历史版本查询)
- 动态图连通性(LCT应用)
推荐练习题:
- 洛谷P1196 [NOI2002] 银河英雄传说
- 洛谷P1525 关押罪犯
- Codeforces 25D Roads not only in Berland
- AtCoder ABC120 D - Decayed Bridges
在实际编码时,我发现带权并查集的路径压缩和关系维护需要特别注意方向性。一个实用的技巧是:在纸上画出节点关系图,明确每个权值表示的含义,这样能有效避免关系计算错误。另外,对于扩展域解法,给每个域取有意义的变量名(如self、food、enemy)可以提高代码可读性。
