🔄【拓扑排序进阶】HDU 4857 - 逃生 题解 | 反向拓扑 + 最大堆的巧妙应用
📋 问题重述
在一个狭窄的逃生通道中,有 n 个人(编号从 1 到 n )需要排成一列通过。现在存在 m 个约束条件,每个条件形如:a 必须在 b 之前。
此外,社会存在等级差异,编号越小的人越富有(1号最富)。负责人收了富人的好处,需要安排一个排队顺序,在满足所有约束条件的前提下:
- 让 1 号尽可能靠前
- 如果还有多种可能,则让 2 号尽可能靠前
- 依此类推...
题目保证一定存在解(即约束条件不会形成环),且本题含有SPJ(Special Judge),输出任意一种满足要求的顺序即可。
🔍 核心矛盾与常见误区
❌ 直觉误区:直接使用最小堆进行拓扑排序
许多人第一反应是使用小顶堆 进行拓扑排序,认为这样能得到"最小字典序"的解。但这道题的要求不是求字典序最小!
反例说明
考虑以下约束:
n = 3, m = 2
约束:3 -> 1
约束:1 -> 2
如果使用正向拓扑 + 小顶堆:
- 初始入度为0的点:{3}
- 输出3,更新入度,入度为0的点:{1}
- 输出1,更新入度,入度为0的点:{2}
- 输出2
结果为:3 1 2
但这不是最优解!实际上,更优的解是 1 3 2 或 1 2 3,因为1号更加靠前了。
💡 算法思路:反向建图 + 最大堆
核心思想
为了让编号小的人尽量靠前 ,我们可以逆向思考:
- 反向建图:将原图中所有边反向
- 使用最大堆 :每次选择编号最大的入度为0的节点
- 逆序输出结果:最后将得到的序列反转
🤔 为什么这样可行?
逻辑推导:
- 正向思考:我们希望小号节点尽量靠前 → 等价于大号节点尽量靠后
- 从队列的最后面 开始安排位置:
- 每次选择当前可以放在最后面 的节点中编号最大的
- 这样可以保证大号节点尽量靠后,从而小号节点自然就靠前了
- 最后将得到的序列反转,就得到了满足要求的正序
算法步骤
- 输入处理与去重:题目可能包含重边,需要去重避免重复计算入度
- 反向建图:将所有边(a, b)反向存储为(b, a)
- 计算入度:计算反向图中每个节点的入度
- 最大堆拓扑排序 :
- 将所有入度为0的节点加入最大堆
- 每次取出堆顶(编号最大的节点)加入结果序列
- 更新其邻接节点的入度,将新的入度为0的节点加入堆中
- 反转输出:将得到的结果序列反转即为答案
🖥️ 代码实现与详细注释
cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
int T; // 测试用例数量
scanf("%d", &T);
while (T--) {
int n, m;
scanf("%d%d", &n, &m);
// 反向建图:adj[b] 存储所有必须在b之后的节点a
vector<vector<int>> adj(n + 1);
vector<int> in_degree(n + 1, 0); // 入度数组
// 使用哈希集合去重,避免重复边影响入度计算
unordered_set<long long> edge_set;
for (int i = 0; i < m; ++i) {
int a, b;
scanf("%d%d", &a, &b);
// 为反向建图生成唯一边标识符:b -> a
long long edge_id = (long long)b * (n + 1) + a;
if (edge_set.find(edge_id) == edge_set.end()) {
// 存储反向边:b -> a (在原约束中 a必须在b之前)
adj[b].push_back(a);
in_degree[a]++; // 在反向图中,a的入度增加
edge_set.insert(edge_id);
}
}
// 最大堆:保证每次取出编号最大的节点
priority_queue<int> max_heap;
// 将所有入度为0的节点加入最大堆
for (int i = 1; i <= n; ++i) {
if (in_degree[i] == 0) {
max_heap.push(i);
}
}
vector<int> result; // 存储结果序列(从后往前构建)
// 拓扑排序过程
while (!max_heap.empty()) {
int current = max_heap.top();
max_heap.pop();
result.push_back(current);
// 遍历当前节点的所有后继节点(在反向图中)
for (int neighbor : adj[current]) {
// 减少后继节点的入度
in_degree[neighbor]--;
// 如果入度变为0,加入最大堆
if (in_degree[neighbor] == 0) {
max_heap.push(neighbor);
}
}
}
// 反转结果:因为我们是从后往前构建的
reverse(result.begin(), result.end());
// 输出结果
for (size_t i = 0; i < result.size(); ++i) {
printf("%d%c", result[i], " \n"[i == result.size() - 1]);
}
}
return 0;
}
📊 算法正确性证明
命题
反向拓扑排序(使用最大堆)得到的序列,在反转后满足:
- 所有约束条件(a必须在b之前)
- 对于任意两个合法序列,本算法得到的序列中,编号较小的节点尽可能靠前
证明概要
1. 约束条件满足性
- 原约束:a → b(a必须在b之前)
- 反向建图后:b → a
- 在反向拓扑排序中,b会先于a被处理(因为a依赖于b)
- 反转序列后,a就会出现在b之前
- 因此,所有原始约束都被满足
2. 最优性证明(反证法)
假设存在一个更优的序列S',使得某个编号i在S'中比在我们算法得到的序列S中更靠前,且i是满足此条件的最小编号。
考虑两种情况:
- 在S'中,i的前驱节点集合与S相同:那么i可以在算法中更早被处理,与算法使用最大堆矛盾
- 在S'中,i的某个前驱节点在S中位于i之后:这违反了拓扑排序的基本性质,因为前驱必须在前
因此,不存在这样的更优序列S',算法得到的是最优解。
⏱️ 复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 建图与去重 | O(m) | O(n + m) |
| 拓扑排序 | O((n+m)log n) | O(n) |
| 总体复杂度 | O((n+m)log n) | O(n + m) |
说明:
- 使用
unordered_set去重,平均O(1)的查找和插入 - 优先队列操作每次O(log n),共进行n次
- 每个节点和每条边被访问一次
📝 示例详解
示例1:简单情况
输入:
1
4 3
1 3
2 4
3 4
处理过程:
- 反向建图:
- 1→3 变为 3→1
- 2→4 变为 4→2
- 3→4 变为 4→3
- 入度计算:
- 节点1入度:1(来自3)
- 节点2入度:1(来自4)
- 节点3入度:1(来自4)
- 节点4入度:0
- 拓扑排序(反向):
- 初始堆:{4}
- 输出4,更新入度,堆:{3, 2}(最大堆取3)
- 输出3,更新入度,堆:{2, 1}
- 输出2,更新入度,堆:{1}
- 输出1
- 结果序列:[4, 3, 2, 1]
- 反转:[1, 2, 3, 4]
示例2:复杂情况
输入:
1
5 4
1 4
2 3
3 4
4 5
正确答案 :1 2 3 4 5
算法验证:
- 反向建图后拓扑排序得到 [5, 4, 3, 2, 1]
- 反转后为 [1, 2, 3, 4, 5]
- 满足所有约束,且小号节点尽量靠前
💎 关键点总结
- 问题转化:将"小号尽量靠前"转化为"大号尽量靠后"
- 反向思维:从后往前安排位置,最后反转序列
- 数据结构选择 :
- 最大堆保证每次选择当前可用的最大编号
- 哈希集合去重避免入度计算错误
- 算法核心:反向拓扑排序 + 最大堆 + 结果反转
🎯 同类问题扩展
这种"反向拓扑+最大堆"的技巧适用于一类特殊要求的问题:
- 要求编号小的节点尽量靠前(非字典序最小)
- 有优先级约束的调度问题
- 需要满足偏序关系的最优排列问题
重要提醒 :当题目要求字典序最小时 ,应使用正向拓扑+最小堆 ;当要求编号小尽量靠前 时,应使用反向拓扑+最大堆。两者有本质区别!