P2597 [ZJOI2012] 灾难 解题报告
P2597 [ZJOI2012] 灾难 解题报告
一、题目解读与分析
首先,我们来弄清楚题目到底在说什么。
- 输入是一个食物网:这是一个有向无环图(DAG)。如果生物
A
吃生物B
,就有一条从B
到A
的有向边(B -> A
)。这代表着A
的生存依赖于B
。 - 灭绝规则:一个生物什么时候会灭绝?
- 生产者:没有食物来源的生物(在图中没有入边),它们不依赖其他生物,可以看作是生态系统的基石。
- 消费者:需要吃其他生物才能活。一个消费者会灭绝,当且仅当它 所有 的食物都灭绝了。这是本题最关键的规则。
- 灾难值:如果我们手动让生物
X
灭绝,会引发一连串的连锁反应,导致其他一些生物也跟着灭绝。这个连锁反应中,除了X
自己,总共灭绝的生物数量,就是X
的“灾难值”。
举个例子:狼吃羊,也吃兔子。
- 如果羊灭绝了,狼还有兔子可以吃,所以狼不会灭绝。
- 如果兔子也灭绝了,狼的所有食物(羊和兔子)都灭绝了,这时狼就会跟着灭绝。
所以,一个生物 A
的灭绝,依赖于它所有食物 F1, F2, ..., Fk
的共同灭绝。
二、核心思路:构建“灭绝树”
直接模拟每个生物灭绝后的连锁反应,复杂度太高了,肯定会超时。我们需要找到一个更高效的结构来描述这种“灭绝依赖”关系。
这个新结构,我们称之为 “灭绝树”。
灭绝树的性质:在这棵树中,如果节点 A
是节点 B
的祖先,那么 A
的灭绝必然导致 B
的灭绝。
那么,如何构建这棵树呢?一个生物 i
的灭绝依赖于它所有食物 p1, p2, ..., pk
的灭绝。在灭绝树上,i
的父节点应该是谁?
应该是那个“一旦它灭绝,就能保证 p1, p2, ..., pk
全部灭绝”的节点。这个节点,正是在灭绝树上 p1, p2, ..., pk
的最近公共祖先(LCA)。
- 为什么是LCA?
- LCA是
p1
到pk
所有节点的共同祖先中,深度最大的那一个。 - 当
LCA(p1, ..., pk)
灭绝时,它的所有后代(包括p1
到pk
)也都会灭绝。 - 这正好满足了生物
i
的灭绝条件。LCA代表了导致i
灭绝的“最直接的源头”。
- LCA是
所以,我们的核心任务就转化为了:
- 构建这棵描述灭绝关系的“灭绝树”。
- 在这棵树上,一个节点
i
的灾难值,就是以它为根的子树的大小,再减去它自己(因为灾难值不包括自己)。
三、算法步骤详解
我们遇到了一个“鸡生蛋,蛋生鸡”的问题:为了确定节点 i
的父亲 LCA(p1, ..., pk)
,我们需要先知道 p1, ..., pk
在树中的位置;但 p1, ..., pk
的位置也是这么确定的。
这种具有先后依赖关系的问题,天然的解决方案就是 拓扑排序。
在食物网中,一个生物 i
总是排在它的食物 p
的后面。所以,我们按照食物网的拓扑序来处理节点,就能保证在处理 i
的时候,它的所有食物 p1, ..., pk
都已经被处理过,并且已经加入了我们正在构建的灭绝树中。
具体步骤如下:
-
预处理与建图
- 反向建图:题目输入
i
吃a
,我们建立一条从a
到i
的边 (a -> i
),并统计每个节点i
的入度(即它有多少种食物)。 - 引入“太阳”节点:对于生产者(入度为0的生物),它们不依赖任何生物。为了方便处理,我们引入一个虚拟的“太阳”节点(编号为0),让所有生产者都成为太阳的直接儿子。这样,整个食物网森林就变成了一棵以太阳为根的树。
- 反向建图:题目输入
-
拓扑排序与建树 (核心)
- 初始化:
- 建立一个队列,用于拓扑排序。
- 将所有入度为0的生物(生产者)放入队列。
- 创建一个
dad[i]
数组,用于记录节点i
在灭绝树中的父亲。初始化所有dad[i]
为 -1(表示未知)。对于所有生产者p
,设置dad[p] = 0
(父亲是太阳)。
- 循环建树:当队列不为空时,执行以下操作:
- a. 出队:取出一个节点
u
。此时,u
的父亲dad[u]
已经确定。 - b. 确定
u
在树中的信息:将u
正式加入灭绝树(逻辑上连接dad[u] -> u
)。然后,计算u
的深度de[u]
和用于求LCA的倍增数组anc[u][...]
。因为拓扑排序保证了u
的所有祖先都已处理完毕,所以这些信息可以立刻确定。 - c. 更新后继节点:遍历所有
u
指向的节点v
(即吃u
的生物)。u
是v
的一种食物。- 更新
v
的父亲:我们需要用u
来更新v
在灭绝树中的潜在父亲dad[v]
。- 如果
dad[v]
还是 -1(说明u
是v
的第一个被处理的食物),则直接令dad[v] = u
。 - 如果
dad[v]
已有值(说明v
的其他食物已被处理),则v
的父亲应该是这些食物对应节点的LCA。我们更新dad[v] = lca(dad[v], u)
。这里的LCA是在我们已经部分构建好的灭绝树上求的。
- 如果
- 入度减一:
v
的一种食物u
已经被我们考虑过了,所以将v
的入度减1。 - 入队:如果
v
的入度变为0,说明它的所有食物都已被处理完毕,此时dad[v]
也已最终确定。将v
加入队列。
- 更新
- a. 出队:取出一个节点
- 初始化:
-
计算灾难值
- 当拓扑排序结束后,我们已经得到了完整的灭绝树(由
dad
数组定义父子关系)。 - 从“太阳”节点0开始,对整棵灭绝树进行一次深度优先搜索(DFS),计算出每个节点
i
的子树大小size[i]
。 - 对于每个生物
i
(从1到n),它的灾难值就是size[i] - 1
。
- 当拓扑排序结束后,我们已经得到了完整的灭绝树(由
四、代码实现与细节
这里附上经过注释和整理的代码,帮助你理解实现过程。
#include<bits/stdc++.h>
#define N 65536 // 题目给定的最大N
using namespace std;
// === 全局变量定义 ===
int n;
// 原食物网(有向无环图)
vector<int> adj[N]; // adj[x] 存储所有吃 x 的生物
int in_degree[N]; // 每个生物的入度(食物数量)
// 灭绝树
vector<int> tree_adj[N]; // 灭绝树的邻接表
int dad[N]; // dad[i] 记录 i 在灭绝树中的父亲
int depth[N]; // 深度
int anc[N][18]; // LCA倍增数组 anc[i][j] 是 i 的第 2^j 个祖先
int subtree_size[N]; // 子树大小
// 快速读入
void read(int &x) {
x = 0; char ch = getchar();
while (ch < '0' || ch > '9') ch = getchar();
while (ch >= '0' && ch <= '9') {x = x * 10 + ch - '0'; ch = getchar();}
}
// 求两个节点在灭绝树上的LCA
int lca(int x, int y) {
if (depth[x] < depth[y]) swap(x, y);
// 1. 将 x 跳到和 y 同样的深度
for (int i = 17; i >= 0; i--) {
if (depth[anc[x][i]] >= depth[y]) {
x = anc[x][i];
}
}
if (x == y) return x;
// 2. x 和 y 一起向上跳,直到它们的父节点相同
for (int i = 17; i >= 0; i--) {
if (anc[x][i] != anc[y][i]) {
x = anc[x][i];
y = anc[y][i];
}
}
return anc[x][0];
}
// DFS计算灭绝树的子树大小
void dfs_size(int u) {
subtree_size[u] = 1;
for (int v : tree_adj[u]) {
dfs_size(v);
subtree_size[u] += subtree_size[v];
}
}
int main() {
read(n);
// 1. 读入数据并构建食物网
memset(dad, -1, sizeof(dad)); // 初始化dad数组为-1
for (int i = 1; i <= n; i++) {
int food;
read(food);
while (food != 0) {
adj[food].push_back(i); // food -> i 的边
in_degree[i]++;
read(food);
}
}
// 2. 拓扑排序与构建灭绝树
queue<int> q;
// 将所有生产者(入度为0)入队,其父节点设为虚拟的“太阳”0号节点
for (int i = 1; i <= n; i++) {
if (in_degree[i] == 0) {
q.push(i);
dad[i] = 0;
}
}
// 太阳节点0的深度和祖先初始化
depth[0] = 0;
for(int i=0; i<18; ++i) anc[0][i] = 0;
while (!q.empty()) {
int u = q.front(); q.pop();
// 当一个节点出队时,它的父亲dad[u]已经最终确定
// a. 将其正式加入灭绝树
tree_adj[dad[u]].push_back(u);
// b. 计算其深度和LCA倍增数组
depth[u] = depth[dad[u]] + 1;
anc[u][0] = dad[u];
for (int i = 1; i < 18; i++) {
anc[u][i] = anc[anc[u][i - 1]][i - 1];
}
// c. 遍历所有吃 u 的生物 v,更新 v 的父亲信息
for (int v : adj[u]) {
if (dad[v] == -1) { // 如果 v 的父亲还未确定
dad[v] = u;
} else { // 如果 v 已有暂定父亲,更新为LCA
dad[v] = lca(dad[v], u);
}
in_degree[v]--;
if (in_degree[v] == 0) { // 如果 v 的所有食物都已处理完
q.push(v);
}
}
}
// 3. DFS计算子树大小
dfs_size(0);
// 4. 输出结果
for (int i = 1; i <= n; i++) {
// 灾难值 = 子树大小 - 1 (不包括自己)
printf("%d\n", subtree_size[i] - 1);
}
return 0;
}
五、总结
这道题的核心是将一个看似复杂的动态连锁反应问题,通过巧妙的建模,转化为了一个静态的树上问题。
- 关键洞察:一个生物的灭绝,等价于它在“灭绝树”上的LCA父节点的灭绝。
- 关键技术:利用拓扑排序的顺序来动态构建灭绝树,同时利用LCA(倍增法)来确定树的结构。
- 最终求解:问题转化为在构建好的树上求子树大小,大大简化了计算。
希望这份报告能帮助你彻底理解这道题的精髓!