P2597 [ZJOI2012] 灾难 解题报告


P2597 [ZJOI2012] 灾难 解题报告

一、题目解读与分析

首先,我们来弄清楚题目到底在说什么。

  1. 输入是一个食物网:这是一个有向无环图(DAG)。如果生物 A 吃生物 B,就有一条从 BA 的有向边(B -> A)。这代表着 A 的生存依赖于 B
  2. 灭绝规则:一个生物什么时候会灭绝?
    • 生产者:没有食物来源的生物(在图中没有入边),它们不依赖其他生物,可以看作是生态系统的基石。
    • 消费者:需要吃其他生物才能活。一个消费者会灭绝,当且仅当它 所有 的食物都灭绝了。这是本题最关键的规则。
  3. 灾难值:如果我们手动让生物 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是 p1pk 所有节点的共同祖先中,深度最大的那一个。
    • LCA(p1, ..., pk) 灭绝时,它的所有后代(包括 p1pk)也都会灭绝。
    • 这正好满足了生物 i 的灭绝条件。LCA代表了导致 i 灭绝的“最直接的源头”。

所以,我们的核心任务就转化为了:

  1. 构建这棵描述灭绝关系的“灭绝树”。
  2. 在这棵树上,一个节点 i 的灾难值,就是以它为根的子树的大小,再减去它自己(因为灾难值不包括自己)。

三、算法步骤详解

我们遇到了一个“鸡生蛋,蛋生鸡”的问题:为了确定节点 i 的父亲 LCA(p1, ..., pk),我们需要先知道 p1, ..., pk 在树中的位置;但 p1, ..., pk 的位置也是这么确定的。

这种具有先后依赖关系的问题,天然的解决方案就是 拓扑排序

在食物网中,一个生物 i 总是排在它的食物 p 的后面。所以,我们按照食物网的拓扑序来处理节点,就能保证在处理 i 的时候,它的所有食物 p1, ..., pk 都已经被处理过,并且已经加入了我们正在构建的灭绝树中。

具体步骤如下:

  1. 预处理与建图

    • 反向建图:题目输入 ia,我们建立一条从 ai 的边 (a -> i),并统计每个节点 i 的入度(即它有多少种食物)。
    • 引入“太阳”节点:对于生产者(入度为0的生物),它们不依赖任何生物。为了方便处理,我们引入一个虚拟的“太阳”节点(编号为0),让所有生产者都成为太阳的直接儿子。这样,整个食物网森林就变成了一棵以太阳为根的树。
  2. 拓扑排序与建树 (核心)

    • 初始化
      • 建立一个队列,用于拓扑排序。
      • 将所有入度为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 的生物)。uv 的一种食物。
        • 更新 v 的父亲:我们需要用 u 来更新 v 在灭绝树中的潜在父亲 dad[v]
          • 如果 dad[v] 还是 -1(说明 uv 的第一个被处理的食物),则直接令 dad[v] = u
          • 如果 dad[v] 已有值(说明 v 的其他食物已被处理),则 v 的父亲应该是这些食物对应节点的LCA。我们更新 dad[v] = lca(dad[v], u)。这里的LCA是在我们已经部分构建好的灭绝树上求的。
        • 入度减一v 的一种食物 u 已经被我们考虑过了,所以将 v 的入度减1。
        • 入队:如果 v 的入度变为0,说明它的所有食物都已被处理完毕,此时 dad[v] 也已最终确定。将 v 加入队列。
  3. 计算灾难值

    • 当拓扑排序结束后,我们已经得到了完整的灭绝树(由 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(倍增法)来确定树的结构。
  • 最终求解:问题转化为在构建好的树上求子树大小,大大简化了计算。

希望这份报告能帮助你彻底理解这道题的精髓!

posted @ 2025-07-11 19:48  surprise_ying  阅读(16)  评论(0)    收藏  举报