题解:洛谷 P2999 Chocolate Milk

【题目来源】

洛谷:P2999 [USACO10NOV] Chocolate Milk S

【题目描述】

农民约翰的牛奶生产和运输是一个复杂的过程,他用挤奶器给他的那么多头奶牛挤奶,然后流入管道。

每一个管道把一台挤奶器和一个可能连有一台或多台挤奶器的接口连接起来(这样几个管道里的牛奶就汇合了)。然后牛奶流入附加管道(连在各个接口之间的管道)直到流到中央管道,通向储存室。 然后这些牛奶又经历一个逆向的过程通过管道分流到各个牛奶桶,最后被运至市场。

约翰发现对于牛奶来说,最多只有一种方式从一个接口流到另一个接口。并且由于约翰是一个高效率的人,他需要确保每一个管道都有牛奶经过,也就是说,没有多余的管道。

如果我们把每个挤奶机、接口和奶罐都看成一个节点,就共有 \(N\) 个节点,输入有序的节点对 \(A_{i}\)\(B_{i}\) ,代表牛奶从 \(A_{i}\) 节点流到 \(B_{i}\) 节点,如果没有相对应的父节点,那就说明这是一个挤奶器,同样的如果没有对应的尾节点,则这是一个奶罐。

这几个月巧克力牛奶的需求量激增,所以约翰想要在某一个接口处安装一个巧克力混合器以得到巧克力牛奶,为了节约,约翰只买了一个巧克力混合器。所以他想把这个东西放到一个所有牛奶都能经过的接口,事实上,有这种接口存在。

帮助约翰找到这样的节点(注意:不能把巧克力混合器放在挤奶机里)。


           1 ----+
                 |
                 v
           2 --> 4 --> 6 ------------------> 7 --> 8
                       ^                     |
                       |                     |
           3 --> 5 ----+                     + --> 9

所有的牛奶都会流经6号或7号节点,所以巧克力混合器可以放在这两个节点上。

【输入】

\(1\) 行一个整数 \(N\)\(2\le N\le10^5\))。

\(2\) 到第 \(N\) 行:包含空格分隔的两个整数,描述第 \(i\) 个管道连接的两个节点:\(A_{i}\)\(B_{i}\)\(1\le A_{i}<B_{i}\le N\))。

【输出】

\(1\) 行到 ??行:每行一个整数,升序输出每个可以安装巧克力混合器的节点。

【输入样例】

9
1 4
3 5
2 4
5 6
6 7
7 8
4 6
7 9

【输出样例】

6
7

【核心思想】

  1. 问题分析:给定一个有 \(N\) 个节点的有向无环图(DAG),其中入度为 0 的节点是挤奶器(源点),出度为 0 的节点是奶罐(汇点)。要求找出所有非源点节点,使得从任意源点出发到任意汇点的每一条路径都经过该节点。这样的节点被称为“必经点”或“关键节点”。由于图可能较大(\(N \le 10^5\)),需要利用拓扑排序和路径计数来高效求解。

  2. 算法选择

    • 拓扑排序 + 路径计数:在 DAG 上,计算每个节点被多少条从源点出发的路径经过,以及从该节点出发到汇点的路径数量。
    • 关键判定:一个节点 \(v\) 是所有源点到所有汇点的路径的公共必经点,当且仅当:
      • 所有源点到达 \(v\) 的路径数量等于源点的总数 \(A\)
      • \(v\) 出发到达所有汇点的路径数量等于汇点的总数 \(B\)
      • \(v\) 不是源点(挤奶器),因为题目要求不能放在挤奶器上。
    • 两次拓扑排序:一次正向计算源点到各节点的路径数 \(cnt[v]\),一次反向计算各节点到汇点的路径数 \(cnt2[v]\)
  3. 关键步骤

    • 建图:读入 \(n-1\) 条边,构建正向图 g 和反向图 g2,并分别记录入度 ind(正向)和 ind2(反向)。
    • 第一次拓扑排序(正向)
      • 将所有入度为 0 的节点(源点)入队,标记为源点,cnt[源点] = 1,统计源点总数 \(A\)
      • 按拓扑序处理节点 \(u\),对于每条边 \(u \to v\)cnt[v] += cnt[u]ind[v]--,若 ind[v] == 0 则入队。
    • 第二次拓扑排序(反向)
      • 从所有反向入度为 0 的节点(即原图中的汇点)入队,cnt2[汇点] = 1,统计汇点总数 \(B\)
      • 按反向拓扑序处理节点 \(u\),对于反向边 \(u \to v\)(对应原图 \(v \to u\)):cnt2[v] += cnt2[u]ind2[v]--,若 ind2[v] == 0 则入队。
    • 判定输出:遍历所有节点,若 cnt[i] == A && cnt2[i] == B && !isvalid[i],则输出 \(i\)
  4. 时间/空间复杂度

    • 时间复杂度:\(O(N)\),每个节点和边在两次拓扑排序中各被处理一次。
    • 空间复杂度:\(O(N)\),用于存储图、路径计数和标记数组。
  5. 拓扑排序 + 路径计数的核心思想

    • 必经点的路径计数特征:在 DAG 中,一个点 \(v\) 被所有源点到所有汇点的路径经过,等价于“到达 \(v\) 的源路径数”等于源点总数,且“从 \(v\) 出发的汇路径数”等于汇点总数。这是因为任意一条完整路径都由源点到 \(v\) 的路径和 \(v\) 到汇点的路径拼接而成,若这两种路径数量均达到最大值,则所有路径必然经过 \(v\)
    • 拓扑排序保证无后效性:DAG 的性质使得路径计数可以通过拓扑序进行递推,避免了循环依赖。
    • 适用场景:适用于在有向无环图中寻找所有源点到所有汇点的公共必经节点(即图的“关键路径”上的公共节点)问题。

【算法标签】

普及 #拓扑排序

【代码详解】

#include <bits/stdc++.h>
using namespace std;

const int N = 100005;

int n;                      // 节点总数
vector<int> g[N], g2[N];    // g: 正向图(从挤奶器流向奶罐),g2: 反向图
int ind[N], ind2[N];        // ind: 正向图的入度,ind2: 反向图的入度
int cnt[N], cnt2[N];        // cnt: 从源点(挤奶器)到该节点的路径数,cnt2: 从该节点到汇点(奶罐)的路径数
int A, B;                   // A: 源点(挤奶器)总数,B: 汇点(奶罐)总数
bool isvalid[N];            // isvalid[i] 标记节点 i 是否为源点(挤奶器)

// 第一次拓扑排序:从所有源点(挤奶器)出发,计算每个节点被多少条源路径覆盖
void topo1()
{
    queue<int> q;
    // 初始化所有源点(入度为0的节点)
    for (int i = 1; i <= n; i++)
        if (ind[i] == 0)
        {
            q.push(i);
            cnt[i] = 1;         // 源点自身算作一条路径
            isvalid[i] = 1;     // 标记为源点(挤奶器)
            A++;                // 统计源点总数
        }

    while (!q.empty())
    {
        int u = q.front();
        q.pop();
        for (auto v : g[u])     // 沿正向边传播
        {
            ind[v]--;           // 删除当前边
            cnt[v] += cnt[u];   // 累加从源点到达 v 的路径数
            if (ind[v] == 0)    // 若 v 的所有前驱都已处理
                q.push(v);
        }
    }
}

// 第二次拓扑排序:从所有汇点(奶罐)出发,反向传播,计算从每个节点到汇点的路径数
void topo2()
{
    queue<int> q;
    // 初始化所有汇点(反向图中入度为0,即原图中出度为0的节点)
    for (int i = 1; i <= n; i++)
        if (ind2[i] == 0)
        {
            q.push(i);
            cnt2[i] = 1;        // 汇点自身算作一条路径
            B++;                // 统计汇点总数
        }

    while (!q.empty())
    {
        int u = q.front();
        q.pop();
        for (auto v : g2[u])    // 沿反向边传播(相当于原图的反向)
        {
            ind2[v]--;          // 删除当前边
            cnt2[v] += cnt2[u]; // 累加从 v 出发到达所有汇点的路径数
            if (ind2[v] == 0)   // 若 v 的所有后继(在原图中)都已处理
                q.push(v);
        }
    }
}

int main()
{
    cin >> n;

    // 读入 n-1 条边,构建正向图和反向图
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);      // 正向边 u -> v
        ind[v]++;               // 正向入度
        g2[v].push_back(u);     // 反向边 v -> u
        ind2[u]++;              // 反向入度(原图中 v 的入度,用于反向拓扑)
    }

    topo1();  // 计算从所有源点出发到达每个节点的路径数
    topo2();  // 计算从每个节点出发到达所有汇点的路径数

    // 枚举每个节点,判断是否所有源点到所有汇点的路径都经过该节点
    // 条件:从源点到该节点的路径数 == 源点总数 A,且从该节点到汇点的路径数 == 汇点总数 B
    // 且该节点不是源点(挤奶器),因为题目要求不能放在挤奶器上
    for (int i = 1; i <= n; i++)
    {
        if (cnt[i] == A && cnt2[i] == B && isvalid[i] == 0)
            cout << i << endl;  // 升序输出满足条件的节点
    }

    return 0;
}

【运行结果】

9
1 4
3 5
2 4
5 6
6 7
7 8
4 6
7 9
6
7
posted @ 2026-08-30 09:19  团爸讲算法  阅读(6)  评论(0)    收藏  举报