深蓝黑背景封面。左侧是一张带权任务依赖图:五个圆形节点 A(9)、B(5)、C(7)、D(8)、E(3),箭头表示依赖方向 A→C、A→D、B→D、C→E、D→E,节点圆圈内写任务名、下方标注权重。图右侧一座大根堆金字塔,堆顶青色高亮的 9 正要弹出,旁注「入度归零即入堆,每次弹权重最大者」。下方一行执行序列:A → C → B → D → E,注「不违背依赖,且重量级尽量靠前」。右侧大标题「带权任务有依赖,怎么让重要的任务尽量先执行」,副标题「堆优化拓扑排序:Kahn + 大根堆」。左上标注「笔试真题 · 算法」,右上圆角胶囊标注「Day 08 · 11-25」。 图 1:依赖图加大根堆,高权重任务尽量往前排。

先说明属性:这是一篇笔试真题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题目要求对带权的有向无环图(DAG)排序:不违背依赖关系,且权重大的任务尽量靠前。它在 Agent 任务编排、数据管线调度里天天出现——「依赖约束加优先级」几乎是最常见的排序需求。考点不是 Kahn 算法本身,而是把队列换成大根堆这一步。

先回顾普通拓扑排序在做什么。

Kahn 算法的骨架:统计每个节点的入度,把所有入度为 0 的节点放进一个「可用集合」,每次从中取一个输出,删掉它的出边让后继入度减 1,凡减到 0 的立刻加入可用集合,循环直到取完。它能给出任意一个合法拓扑序,但「取哪一个」是随意的——用普通队列实现时,先解锁的先出,权重完全不参与决策。

本题的要求恰恰是把权重拉进决策:在每个位置上,从当前可用集合里挑权重最大的。这一步改动极小——把队列换成按权重排序的大根堆——但性质完全不同。

为什么这个贪心是安全的。

关键是想清楚「可用集合」的含义:入度为 0 意味着这个任务的所有前置依赖都已排定,此刻选它不违背任何约束。既然选谁对合法性无影响,那就应该选「当前最重要」的——把权重大的往后拖没有任何好处,只会让它错失更早的位置。每一步都取当前可用集合的权重最大者,得到的就是贪心意义下最优的拓扑序。这也是这类题的通用范式:约束条件交给拓扑序保证,优化目标交给堆来贪心。

手工推演一遍。

用这张图里的例子:五个任务 A(9)、B(5)、C(7)、D(8)、E(3),依赖关系 A→C、A→D、B→D、C→E、D→E。初始入度为 0 的是 A 和 B,入堆。

第一步:堆里 {A9, B5},弹出权重最大的 A;A 的后继里 C 的入度归零入堆,D 还差 B 没排。第二步:堆里 {B5, C7},弹出 C——注意 C 虽后入堆但权重更大,这正是换堆的意义;C 的后继 E 还差 D,无人解锁。第三步:堆里只剩 {B5},弹出 B,D 的两个前置终于齐了,入堆。第四步:弹出 {D8},E 解锁入堆。第五步:弹出 E,结束。最终序列 A → C → B → D → E:依赖全部满足,且在第二步确实把更重的 C 提到了 B 前面。

代码实测。

用 Node.js 22.15.0 运行(node topoheap.js),除了上面的例子,再拿一道只含数字编号的原题样例交叉验证:

class MaxHeap {
  constructor() { this.a = []; }
  push(x) { this.a.push(x); let i = this.a.length - 1;
    while (i > 0) { const p = (i - 1) >> 1;
      if (this.a[p].w >= this.a[i].w) break;
      [this.a[p], this.a[i]] = [this.a[i], this.a[p]]; i = p; } }
  pop() { const top = this.a[0], last = this.a.pop();
    if (this.a.length) { this.a[0] = last; let i = 0;
      for (;;) { let l = 2 * i + 1, r = l + 1, m = i;
        if (l < this.a.length && this.a[l].w > this.a[m].w) m = l;
        if (r < this.a.length && this.a[r].w > this.a[m].w) m = r;
        if (m === i) break; [this.a[m], this.a[i]] = [this.a[i], this.a[m]]; i = m; } }
    return top; }
  get size() { return this.a.length; }
}
function weightedTopo(nodes, edges) {
  const indeg = new Map(nodes.map(n => [n.id, 0]));
  const adj = new Map(nodes.map(n => [n.id, []]));
  for (const [s, t] of edges) { adj.get(s).push(t); indeg.set(t, indeg.get(t) + 1); }
  const heap = new MaxHeap();
  for (const n of nodes) if (indeg.get(n.id) === 0) heap.push(n);
  const order = [];
  while (heap.size) {
    const cur = heap.pop(); order.push(cur.id);
    for (const nxt of adj.get(cur.id)) {
      indeg.set(nxt, indeg.get(nxt) - 1);
      if (indeg.get(nxt) === 0) heap.push(nodes.find(n => n.id === nxt));
    }
  }
  return order;
}
const nodes = [{ id: "A", w: 9 }, { id: "B", w: 5 }, { id: "C", w: 7 },
               { id: "D", w: 8 }, { id: "E", w: 3 }];
const edges = [["A","C"],["A","D"],["B","D"],["C","E"],["D","E"]];
console.log("带权示例:", weightedTopo(nodes, edges).join(" "));
// 交叉验证:4 节点权重 2/3/5/4,边 1→2 1→3 2→4 3→4,期望 1 3 2 4
const n2 = [{id:"1",w:2},{id:"2",w:3},{id:"3",w:5},{id:"4",w:4}];
const e2 = [["1","2"],["1","3"],["2","4"],["3","4"]];
console.log("交叉样例:", weightedTopo(n2, e2).join(" "));

实测输出:

带权示例: A C B D E
交叉样例: 1 3 2 4

两组输出与手工推演完全一致。交叉样例里节点 3 权重 5 大于节点 2 的 3,所以 1 之后先排 3——和带权示例第二步的逻辑一模一样。

深蓝黑背景概念图。左侧是算法流程小循环:三个卡片「入度为 0 的任务全部入大根堆」「弹出堆顶(权重最大)加入结果」「把它的后继入度减 1,归零者入堆」用青色箭头首尾相连成环。右侧一张五步推演表,列为步骤、堆里可用集合(含权重)、弹出、新解锁:第一步堆 {A9, B5} 弹 A 解锁 C;第二步 {B5, C7} 弹 C,无新解锁(E 还等 D);第三步 {B5} 弹 B 解锁 D;第四步 {D8} 弹 D 解锁 E;第五步 {E3} 弹 E 结束。结果行以青色高亮:A → C → B → D → E,并注「普通 Kahn 用队列得到任意拓扑序;换大根堆就在所有合法拓扑序中让高权重尽量靠前」。底部总结条:堆让每个位置的决策都是当前可用集合中的最优,整体得到权重贪心意义下的最优拓扑序。 图 2:五步推演表——堆里永远弹当前可用的最大权重。

收尾:复杂度与变形。

每个节点入堆出堆各一次,每条边只扫一遍,堆操作 O(log V),总复杂度 O((V + E) log V),相对普通 Kahn 只多一个对数因子。两个常见追问要有准备:一是这个贪心是「尽量」而非全局最优——它保证每个位置取当前最优,不保证某种全局指标最优,题目措辞也是「尽量先执行」;二是变形「字典序最小的拓扑序」用的是同一个模子,只是把大根堆换成按编号的小根堆,骨架一行不改。

答题建议顺序:先复述 Kahn 的可用集合概念,再指出「取谁」是自由度所在,给出大根堆改造与贪心安全性论证,最后补复杂度与字典序变形。能主动讲清「约束归拓扑序、优化归堆」这条分工线的答案,通常能直接过关。你在工程里遇到过「依赖约束加优先级」的调度需求吗?最后是用堆实现的,还是干脆上了调度框架?欢迎分享。