DAG与拓扑排序
现实生活中我们经常要做一连串事情,这些事情之间有顺序关系或依赖关系,做一件事情之前必须先做另一件事,如安排客人的座位、穿衣服的先后、课程学习的先后等。这些事情可以抽象为图论中的拓扑排序(Topological Sorting)问题。
给定一张有向无环图(在有向图中,从一个节点出发,最终回到它自身的路径被称为“环”,不存在环的有向图即为有向无环图),若一个由图中所有点构成的序列 \(A\) 满足:对于图中的每条边 \((x,y)\),\(x\) 在 \(A\) 中都出现在 \(y\) 之前,则称 \(A\) 是该有向无环图顶点的一个拓扑序。求解序列 \(A\) 的过程就称为拓扑排序。
显然,一个图的拓扑序不一定唯一。
另外,只有在有向无环图(DAG)中才有拓扑序,如果图中有环,则没有合法的拓扑序。
例题:P4017 最大食物链计数
给出一个食物网,要求出这个食物网中最大食物链的数量。这里的“最大食物链”,指的是生物学意义上的食物链,即开头是不会捕食其他生物的生产者,结尾是不会被其他生物捕食的消费者。答案可能很大,所以要对 \(80112002\) 取模。
分析: 考虑把这个食物网转换为一个图,食物网有什么样的特点呢?
- 食物网中的捕食关系一定是单向的(比如猫吃鱼,而不是鱼吃猫)。
- 食物网中的捕食关系一定是无环的,不存在 A 捕食 B,B 捕食 C,C 捕食 A 这种情况。
所以可以发现食物网其实就是一个 DAG(有向无环图)。在这道题目中“最大食物链”的定义就是一条从入度为 \(0\) 的点开始到出度为 \(0\) 的点结束的链,即要计算这样的链的个数。
用一个数组 f[x] 表示从任意一个入度为 0 的点到点 x 的食物链计数。那么对于任意一个入度为 0 的点 y,它的 f[y]=1。对于一个入度非 0 的点 z,它的 f[z] 等于能到达点 z 的点 u 的 f[u] 之和。
如点 3,它的食物链计数等于点 1 的食物链计数加上点 10 的,即 f[3]=f[1]+f[10]=1+1=2。而对于点 6,它的食物链计数等于点 2 的食物链计数加上点 3 的,即 f[6]=f[2]+f[3]=1+2=3。这样最后只要对所有出度为 0 的点的事物链计数求和就能求出题目所求的答案了。
在计算 f[x] 的过程中,需要保证对于点 x,所有能到达点 x 的点 y 的 f[y] 已经被计算过了,这样就需要确定一个合适的计算顺序。因此使用 拓扑排序 来控制计算顺序。拓扑排序并不是对一个数列进行排序,而是在 DAG 上对点进行排序,使得在搜到点 x 时所有能到达点 x 的点 y 的结果已经计算完成了。具体流程如下:
- 统计每个点的入度,将所有入度为 0 的点加入处理队列。
- 将处于队头的点 x 取出,遍历点 x 能到达的所有点 y。
- 对于每一个 y,删去从点 x 到点 y 的边。在具体的实现中,只需要让 y 的入度减一即可。在这一步中,顺便可以对点 y 的数据进行维护,在这题中是
f[y]=(f[y]+f[x])%MOD
。 - 如果点 y 的入度减到 0 了,说明所有能到 y 的点都被计算过了,这时将点 y 加入处理队列。
- 重复步骤 2 直到处理队列为空。
这样,就保证了在食物链计数这题中求 f[x] 的顺序正确。时间复杂度为 \(O(n+m)\)。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
const int N = 5005;
const int MOD = 80112002;
vector<int> graph[N];
int ind[N], f[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int a, b; scanf("%d%d", &a, &b);
graph[a].push_back(b); // 存边
ind[b]++; // 点b的入度+1
}
queue<int> q;
for (int i = 1; i <= n; i++)
if (ind[i] == 0) {
q.push(i); f[i] = 1; // 将入度为0的点加入队列
}
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : graph[u]) {
f[v] = (f[v] + f[u]) % MOD;
ind[v]--;
if (ind[v] == 0) q.push(v); // 此时点y的依赖都解除了,将点y加入队列
}
}
int ans = 0;
for (int i = 1; i <= n; i++)
if (graph[i].size() == 0) ans = (ans + f[i]) % MOD;
printf("%d\n", ans);
return 0;
}
答案需要对 80112002 取模,在计算 f 时一边加一边取模,以及在对出度为 0 的点的食物链计数求和时一边加一边取模。如果只在输出答案时取模,那么可能在累加的过程中答案超出了数据类型存储的范围而导致答案的错误。
如果原图中有环,则拓扑排序过程中取出的点的数量会小于整个图的点数,有一些点的入度没有降到 \(0\)。
例题:P6145 [USACO20FEB] Timeline G
分析:\(s_i\) 为第 \(i\) 次挤奶最早开始的时间,初始值为输入。
在拓扑排序的过程中,设目前考虑的是点 \(u\),它有一条指向 \(v\) 的边,则 \(s_v = \max (s_v, s_u + w(u, v))\)。
参考代码
#include <cstdio>
#include <utility>
#include <vector>
#include <queue>
#include <algorithm>
using std::vector;
using std::pair;
using std::queue;
using std::max;
using ll = long long;
using edge = pair<int, int>;
const int N = 100005;
ll s[N];
int ind[N];
vector<edge> graph[N];
int main()
{
int n, m, c; scanf("%d%d%d", &n, &m, &c);
for (int i = 1; i <= n; i++) scanf("%lld", &s[i]);
for (int i = 1; i <= c; i++) {
int a, b, x; scanf("%d%d%d", &a, &b, &x);
graph[a].push_back({b, x}); ind[b]++;
}
queue<int> q;
for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
for (edge e : graph[u]) {
int v = e.first, w = e.second;
s[v] = max(s[v], s[u] + w);
ind[v]--;
if (ind[v] == 0) q.push(v);
}
}
for (int i = 1; i <= n; i++) printf("%lld\n", s[i]);
return 0;
}
例题:P10480 可达性统计
给定一个 \(N\) 个点 \(M\) 条边的有向无环图,分别统计从每个点出发能够到达的点的数量。\(N,M \le 30000\)。
分析:设从点 \(x\) 出发能够到达的点构成的集合是 \(f(x)\),则显然有:\(f(x) = \{ x \} \cup \left( \bigcup \limits_{存在有向边(x,y)} f(y) \right)\)。
也就是说,从 \(x\) 出发能够到达的点,是从“\(x\) 的各个后继节点 \(y\)”出发能够到达的点的并集,再加上点 \(x\) 自身。所以,在计算出一个点的所有后继节点的连通集合之后,就可以计算出该点的连通集合。因此可以用反向边建图,则这个图上的拓扑序是可以用来依次计算连通集合。
这里涉及到集合运算,结合 \(N \le 30000\),可以使用 STL 中的 bitset 支持集合相关的高效运算。最终时间复杂度为 \(O\left(\dfrac{N(N+M)}{\omega}\right)\),空间复杂度为 \(O\left(\dfrac{N^2}{\omega} + N + M\right)\)。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <bitset>
using std::vector;
using std::queue;
using std::bitset;
const int N = 30005;
vector<int> graph[N];
int ind[N];
bitset<N> ans[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
graph[y].push_back(x);
ind[x]++;
}
queue<int> q;
for (int i = 1; i <= n; i++) {
if (ind[i] == 0) q.push(i);
ans[i][i] = 1;
}
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : graph[u]) {
ans[v] |= ans[u];
ind[v]--;
if (ind[v] == 0) q.push(v);
}
}
for (int i = 1; i <= n; i++) printf("%d\n", (int)ans[i].count());
return 0;
}
例题:P1038 [NOIP2003 提高组] 神经网络
分析:按拓扑序计算,在向后传递的过程中注意,只有当前的 \(C\) 大于 \(0\) 才会对之后的点有贡献。
最后要统计有多少没有出度且 \(C\) 大于 \(0\) 的点,并依次输出。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <utility>
using std::pair;
using std::vector;
using std::queue;
using edge = pair<int, int>;
const int N = 105;
int u[N], c[N], ind[N];
vector<edge> graph[N];
int main()
{
int n, p; scanf("%d%d", &n, &p);
for (int i = 1; i <= n; i++) {
scanf("%d%d", &c[i], &u[i]);
if (c[i] == 0) c[i] -= u[i];
}
for (int i = 1; i <= p; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
graph[u].push_back({v, w}); ind[v]++;
}
queue<int> q;
for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i);
while (!q.empty()) {
int cur = q.front(); q.pop();
for (edge p : graph[cur]) {
int nxt = p.first, w = p.second;
ind[nxt]--;
if (c[cur] > 0) c[nxt] += w * c[cur];
if (ind[nxt] == 0) q.push(nxt);
}
}
bool null = true;
for (int i = 1; i <= n; i++)
if (graph[i].size() == 0 && c[i] > 0) {
null = false;
printf("%d %d\n", i, c[i]);
}
if (null) printf("NULL\n");
return 0;
}
例题:P1983 [NOIP2013 普及组] 车站分级
分析:希望分的级别尽量少,也就是最高级别尽量低,如果可以让每个车站取到它的最低等级,那最终的结果就是这些等级的最大值。
对于每趟车次而言,不停的站级别要小于停靠的车站,因此可以将问题建模为所有停靠的车站向不停的车站连有向边,则至少需要的等级数量等于最长链的边数加 1。
在拓扑排序过程中计算每个车站可行的最低等级,用 \(level_i\) 表示,则对于边 \((u,v)\),有 \(level_v = \max (level_v, level_u + 1)\)。这样时间复杂度为 \(O(mn^2)\)。
瓶颈在于连边的次数过多,需要减少边数。
这里的建图还可以进一步优化,对于每一趟车次可以增加一个虚拟结点,所有停靠的车站指向这个虚拟结点,而虚拟结点又指向所有不停靠的车站,这样可以将建图的边数由 \(|S_1| \times |S_2|\) 降至 \(|S_1| + |S_2|\),其中 $|S_1| $ 和 $ |S_2|$ 分别为停靠车站的数量和不停靠车站的数量。这样的话答案为最长链的边数除以 2 再加 1。这样时间复杂度为 \(O(nm)\)。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <algorithm>
using std::queue;
using std::vector;
using std::max;
const int N = 2005;
vector<int> graph[N];
int ind[N], level[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int s; scanf("%d", &s);
// virtual node (n+i)
int pre = 0;
for (int j = 1; j <= s; j++) {
int stop; scanf("%d", &stop);
if (j == 1) pre = stop;
graph[stop].push_back(n + i); ind[n + i]++;
for (int k = pre + 1; k < stop; k++) {
graph[n + i].push_back(k);
ind[k]++;
}
pre = stop;
}
}
queue<int> q;
int ans = 1;
for (int i = 1; i <= n + m; i++)
if (ind[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : graph[u]) {
level[v] = max(level[v], level[u] + 1);
ans = max(ans, level[v]);
ind[v]--;
if (ind[v] == 0) q.push(v);
}
}
printf("%d\n", ans / 2 + 1);
return 0;
}
例题:P7113 [NOIP2020] 排水系统
分析:按拓扑序计算,\(ans_i\) 的初始值为 \(1\),对于拓扑排序过程中连向 \(v\) 的边,有 \(ans_v = \sum \dfrac{ans_u}{outdeg_u}\)。
最后输出出度为 \(0\) 的点的答案。
注意结果用分数形式保存,即分子、分母分别存储。
本题的坑点:分母最大可达 \(60^{11}\),如果只用 long long
得分为 \(60 \sim 90\) 分。现在可以使用 __int128
。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <utility>
using std::vector;
using std::queue;
using std::pair;
using frac = pair<__int128, __int128>; // 分子,分母
const int N = 100005;
vector<int> graph[N];
int ind[N];
frac ans[N];
__int128 gcd(__int128 x, __int128 y) {
return y == 0 ? x : gcd(y, x % y);
}
frac add(frac f1, frac f2) {
frac res;
__int128 lcm = f1.second / gcd(f1.second, f2.second) * f2.second;
res.first = lcm / f1.second * f1.first + lcm / f2.second * f2.first;
res.second = lcm;
__int128 g = gcd(res.first, res.second);
res.first /= g; res.second /= g;
return res;
}
void output(__int128 n) {
if (n > 9) output(n / 10);
putchar(n % 10 + '0');
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
ans[i] = {0, 1};
int d; scanf("%d", &d);
for (int j = 1; j <= d; j++) {
int a; scanf("%d", &a);
graph[i].push_back(a);
ind[a]++;
}
}
queue<int> q;
for (int i = 1; i <= n; i++)
if (ind[i] == 0) {
q.push(i); ans[i] = {1, 1};
}
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : graph[u]) {
frac f = {ans[u].first, ans[u].second * (__int128)graph[u].size()};
ans[v] = add(ans[v], f);
ind[v]--;
if (ind[v] == 0) q.push(v);
}
}
for (int i = 1; i <= n; i++)
if (graph[i].size() == 0) {
output(ans[i].first); printf(" "); output(ans[i].second); printf("\n");
}
return 0;
}
例题:P4376 [USACO18OPEN] Milking Order G
分析:分两步,第一步求最大的符合条件的 \(X\),第二步求字典序最小的方案。
先看第一步,最朴素的想法是暴力枚举 \(X\),跑拓扑排序判断是否有可行解,这一步时间复杂度为 \(O(n+m)m\)。
想要进一步优化需要观察到 \(X\) 对结果的影响有单调性,可以二分答案求出最大的 \(X\),这样时间复杂度降为 \(O((n+m) \log m)\)。
第二步,求字典序最小的方案。
首先需要理解拓扑排序过程中队列里的节点的含义,这些点是目前入度为 \(0\) 的点(所有前置依赖都已释放),它们之间没有必然顺序,所以实际上不一定非得用队列存储,只是通常会使用队列来实现。
现在要求字典序最小的方案,也就是每次要取出的点是可选的点中编号最小的,因此可以把队列换成优先队列,让编号小的先出队。
这一步时间复杂度为 \(O((n+m) \log m)\),注意第一步用二分答案计算 \(X\) 时,判定是否有可行解应该使用朴素的队列,否则时间复杂度里就会有两个 \(\log\)。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
using std::vector;
using std::queue;
using std::priority_queue;
using std::greater;
const int N = 200005;
vector<int> order[N], graph[N];
int ind[N], res[N];
void init(int n) {
for (int i = 1; i <= n; i++) {
graph[i].clear(); ind[i] = 0;
}
}
bool check(int n, int x) {
init(n);
for (int i = 1; i <= x; i++) {
for (int j = 1; j < order[i].size(); j++) {
int pre = order[i][j - 1], cur = order[i][j];
graph[pre].push_back(cur); ind[cur]++;
}
}
queue<int> q;
for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : graph[u]) {
ind[v]--;
if (ind[v] == 0) q.push(v);
}
}
for (int i = 1; i <= n; i++) if (ind[i] != 0) return false;
return true;
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int cnt; scanf("%d", &cnt);
for (int j = 1; j <= cnt; j++) {
int x; scanf("%d", &x); order[i].push_back(x);
}
}
int ans = 0, l = 1, r = m;
while (l <= r) {
int mid = (l + r) / 2;
if (check(n, mid)) {
l = mid + 1; ans = mid;
} else {
r = mid - 1;
}
}
priority_queue<int, vector<int>, greater<int>> q;
init(n);
for (int i = 1; i <= ans; i++) {
for (int j = 1; j < order[i].size(); j++) {
int pre = order[i][j - 1], cur = order[i][j];
graph[pre].push_back(cur); ind[cur]++;
}
}
for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i);
int cnt = 0;
while (!q.empty()) {
cnt++; res[cnt] = q.top(); q.pop();
for (int v : graph[res[cnt]]) {
ind[v]--;
if (ind[v] == 0) q.push(v);
}
}
for (int i = 1; i <= n; i++) printf("%d ", res[i]);
return 0;
}
习题:P3243 [HNOI2015] 菜肴制作
解题思路
注意,本题并不是求字典序最小的拓扑序。
例如,\(4\) 种菜肴,\(2\) 在 \(4\) 前,\(3\) 在 \(1\) 前。
则如果求字典序最小的拓扑序会求出 \(2, 3, 1, 4\),而本题实际上想要求得的结果是 \(3, 1, 2, 4\)。
考虑制作的最后一道菜肴(其出度必为 \(0\)),这道菜在满足拓扑序的情况下,编号应该尽可能大。如果最后一道菜不是可以作为最后的菜肴中编号最大的,那么将另一道编号更大的菜肴与其交换之后会使得结果更优。
最后一道菜定了之后,可以将它移出考虑范畴了。此时问题演变为剩下 \(n-1\) 道菜的制作顺序,上面的策略可以复用。
因此本题等价于按反向边建图之后求字典序最大的拓扑序,这个结果倒过来输出就是本题要求的答案。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
using std::vector;
using std::priority_queue;
const int N = 100005;
vector<int> graph[N];
int ind[N], ans[N];
void solve() {
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
graph[i].clear(); ind[i] = 0;
}
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
graph[y].push_back(x); ind[x]++;
}
priority_queue<int> q;
for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i);
int idx = 0;
while (!q.empty()) {
int u = q.top(); q.pop();
ans[++idx] = u;
for (int v : graph[u]) {
ind[v]--;
if (ind[v] == 0) q.push(v);
}
}
if (idx < n) printf("Impossible!");
else for (int i = n; i >= 1; i--) printf("%d ", ans[i]);
printf("\n");
}
int main()
{
int t; scanf("%d", &t);
for (int i = 1; i <= t; i++) {
solve();
}
return 0;
}