[NOIP2020] 排水系统

GitHub跳转原题关系图返回列表

在 DAG 上按拓扑序传播精确分数流量,每个点把当前污水均分给所有出边,最后统计所有汇点的分数结果。

OJ: luogu

题目 ID: P7113

难度:普及+/提高

标签:图论拓扑排序数学模拟

日期: 2026-06-19 23:32

题意

这题给了一张没有环的有向图。

  • m 个点是污水接收口,每个点一开始各有 1 吨污水
  • 每个点会把自己当前的污水平均分给所有出边
  • 没有出边的点就是最终排水口

要求输出每个最终排水口最后流出的污水量,而且必须用最简分数表示。

样例图

这张图展示样例中的排水关系:

digraph G {
  rankdir=LR;
  1 -> 2;
  1 -> 3;
  1 -> 5;
  2 -> 4;
  2 -> 5;
  3 -> 5;
  3 -> 4;
}

1 号点先把 1 吨水均分成三份,分别流向 2,3,5。 其中流到 23 的水还会继续被均分一次,所以最后 4 收到 1/35 收到 2/3。 因为题目要求精确输出,所以这里不能用浮点数近似,必须直接维护分数。

思路

先看一个最直接的小数据暴力:

cpp
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 20;

struct Fraction {
    long long num, den;

    Fraction(long long numerator = 0, long long denominator = 1) {
        num = numerator;
        den = denominator;
        normalize();
    }

    void normalize() {
        if (den < 0) {
            num = -num;
            den = -den;
        }
        long long g = std::gcd(llabs(num), llabs(den));
        if (g != 0) {
            num /= g;
            den /= g;
        }
    }
};

Fraction operator+(const Fraction &a, const Fraction &b) {
    __int128 numerator = (__int128) a.num * b.den + (__int128) b.num * a.den;
    __int128 denominator = (__int128) a.den * b.den;
    return Fraction((long long) numerator, (long long) denominator);
}

Fraction operator/(const Fraction &a, long long x) {
    return Fraction(a.num, a.den * x);
}

int n, m;
vector<int> graph[MAXN];
int outdeg[MAXN];
Fraction ans[MAXN]; // ans[i] : 最终流到汇点 i 的污水量

// 直接按题意递归分流。
void dfs(int u, Fraction cur) {
    if (outdeg[u] == 0) {
        ans[u] = ans[u] + cur;
        return;
    }

    Fraction each = cur / outdeg[u];
    for (int v : graph[u]) {
        dfs(v, each);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;

    for (int i = 1; i <= n; i++) {
        graph[i].clear();
        ans[i] = Fraction(0, 1);

        int d;
        cin >> d;
        outdeg[i] = d;
        for (int j = 1; j <= d; j++) {
            int v;
            cin >> v;
            graph[i].push_back(v);
        }
    }

    for (int i = 1; i <= m; i++) {
        dfs(i, Fraction(1, 1));
    }

    for (int i = 1; i <= n; i++) {
        if (outdeg[i] == 0) {
            cout << ans[i].num << ' ' << ans[i].den << '\n';
        }
    }

    return 0;
}

这个暴力完全按题意递归分流:

  • 当前来到点 u,手里有 cur 吨污水
  • 如果 u 是汇点,就把 cur 加到答案里
  • 否则把 cur 平均分成 outdeg[u] 份,递归流向每个后继

这个写法很直观,但如果图很大、路径很多,就会重复遍历很多公共后缀。

题目已经保证整张图是 DAG,所以更高效的办法是按拓扑序传播流量。

做法如下:

  1. 用分数 num / den 精确保存每个点当前累计到的污水量。
  2. m 个接收口初始都设成 1/1
  3. 求整张图的拓扑序。
  4. 按拓扑序处理每个点 u
    • 如果 u 不是汇点,就把 water[u] 均分成 outdeg[u]
    • 每个后继 v 都加上这一份
  5. 最后所有出度为 0 的点就是答案。

这里用到的关键实现是一个简单分数类:

  • 加法时通分再约分
  • 除以整数时直接把分母乘上这个整数

代码结构上和你算法书里的拓扑模板保持一致:先建图和统计入度,再用队列按拓扑序顺推。

代码

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

const int MAXN = 100005;

struct Fraction {
    long long num, den;

    Fraction(long long numerator = 0, long long denominator = 1) {
        num = numerator;
        den = denominator;
        normalize();
    }

    void normalize() {
        if (den < 0) {
            num = -num;
            den = -den;
        }
        long long g = std::gcd(llabs(num), llabs(den));
        if (g != 0) {
            num /= g;
            den /= g;
        }
    }
};

Fraction operator+(const Fraction &a, const Fraction &b) {
    __int128 numerator = (__int128) a.num * b.den + (__int128) b.num * a.den;
    __int128 denominator = (__int128) a.den * b.den;
    return Fraction((long long) numerator, (long long) denominator);
}

Fraction operator/(const Fraction &a, long long x) {
    return Fraction(a.num, a.den * x);
}

int n, m;
vector<int> graph[MAXN];
int indeg[MAXN];
int outdeg[MAXN];
Fraction water[MAXN]; // water[i] : 当前流到结点 i 的总污水量

void read_input() {
    cin >> n >> m;

    for (int i = 1; i <= n; i++) {
        graph[i].clear();
        indeg[i] = 0;
        outdeg[i] = 0;
        water[i] = Fraction(0, 1);
    }

    for (int i = 1; i <= n; i++) {
        int d;
        cin >> d;
        outdeg[i] = d;
        for (int j = 1; j <= d; j++) {
            int v;
            cin >> v;
            graph[i].push_back(v);
            indeg[v]++;
        }
    }

    for (int i = 1; i <= m; i++) {
        water[i] = Fraction(1, 1);
    }
}

void solve() {
    queue<int> q;
    int deg[MAXN];
    memcpy(deg, indeg, sizeof(indeg));

    for (int i = 1; i <= n; i++) {
        if (deg[i] == 0) {
            q.push(i);
        }
    }

    while (!q.empty()) {
        int u = q.front();
        q.pop();

        if (outdeg[u] > 0) {
            Fraction each = water[u] / outdeg[u];
            for (int v : graph[u]) {
                water[v] = water[v] + each;
                deg[v]--;
                if (deg[v] == 0) {
                    q.push(v);
                }
            }
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    read_input();
    solve();

    for (int i = 1; i <= n; i++) {
        if (outdeg[i] == 0) {
            cout << water[i].num << ' ' << water[i].den << '\n';
        }
    }

    return 0;
}

复杂度

设点数为 n,边数为 E

  • 拓扑排序扫描每个点、每条边各一次,复杂度 O(n+E)O(n + E)
  • 每次分数运算都只做常数次 gcd

总时间复杂度 O(n+E)O(n + E),空间复杂度 O(n+E)O(n + E)

总结

这题本质是 DAG 上的流量传播。识别出“无环 + 每条边均分”以后,直接按拓扑序把分数往后推即可。真正容易错的点只有一个:一定要用精确分数,不能用 double

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析