cswork / 模板打开 500 路题单

第 16 站 · 图论

拓扑排序与依赖关系

Kahn 算法:入度为 0 的先出队 O(V + E)

周赛难度分 难度分 1693–难度分 1962· 中位 难度分 1836(4/7 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 16 站 · 7 题
先学
学完去
没有后续站依赖它。
面试频次
9 家公司考过 · 面经出现 12 次Uber 3、TikTok / ByteDance 2、Adobe 1、Meta 1、Netflix 1

先做这道

LC 207课程表中等
卡住再看

把课程先修关系建为有向图,通过拓扑排序检测环。

要点

  1. 入度为 0 的点没有未完成的前置依赖,可以先放;放下之后把它指向的点入度减一。
  2. 输出的点数少于 n,说明剩下的点都在环上:「能不能修完所有课」就是这样判断的。
  3. 要求字典序最小的拓扑序时,把队列换成小根堆。

易错点

  • 边的方向读反(「a 依赖 b」应该建 b → a),整个顺序就倒过来了。
  • 只把一开始入度为 0 的点入队,后面减到 0 的点忘了入队。
  • DFS 写拓扑排序时只有「访问过 / 没访问」两种状态是判不出环的,还需要「访问中」这第三种状态。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

拓扑排序 · Kahn 算法

按先修关系给 6 门课排一个可行的学习顺序

换个输入
ABCDEF
0入度 A
0入度 B
2入度 C
1入度 D
2入度 E
2入度 F
当前处理已发现 / 已计算答案
队首 → 队尾
AB
输出顺序
—

箭头 X → Y 表示先修 X 才能修 Y。入度为 0 的 A、B 没有先修要求,先入队。

始终成立队列里只放入度为 0 的节点:它的先修都已输出,现在输出不会违反任何边。

模板

选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 207。

Python
from collections import deque


def topo_order(n: int, edges: list[tuple[int, int]]) -> list[int]:
    """Edge (u, v) means u comes before v. Returns [] if there is a cycle."""
    graph = [[] for _ in range(n)]
    indeg = [0] * n
    for u, v in edges:
        graph[u].append(v)
        indeg[v] += 1
    q = deque(i for i in range(n) if indeg[i] == 0)
    order = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in graph[u]:
            indeg[v] -= 1
            if indeg[v] == 0:  # every prerequisite of v is placed
                q.append(v)
    return order if len(order) == n else []
C++
// Edge {u, v} means u comes before v. Returns {} if there is a cycle.
vector<int> topoOrder(int n, const vector<pair<int, int>>& edges) {
    vector<vector<int>> graph(n);
    vector<int> indeg(n, 0);
    for (auto [u, v] : edges) {
        graph[u].push_back(v);
        indeg[v]++;
    }
    queue<int> q;
    for (int i = 0; i < n; i++)
        if (indeg[i] == 0) q.push(i);
    vector<int> order;
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        order.push_back(u);
        for (int v : graph[u])
            if (--indeg[v] == 0) q.push(v);  // every prerequisite of v is placed
    }
    if ((int)order.size() < n) order.clear();
    return order;
}
Java
class Solution {
    // Edge {u, v} means u comes before v. Returns an empty list if there is a cycle.
    List<Integer> topoOrder(int n, int[][] edges) {
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
        int[] indeg = new int[n];
        for (int[] e : edges) {
            graph.get(e[0]).add(e[1]);
            indeg[e[1]]++;
        }
        Deque<Integer> q = new ArrayDeque<>();
        for (int i = 0; i < n; i++) if (indeg[i] == 0) q.offer(i);
        List<Integer> order = new ArrayList<>();
        while (!q.isEmpty()) {
            int u = q.poll();
            order.add(u);
            for (int v : graph.get(u))
                if (--indeg[v] == 0) q.offer(v); // every prerequisite of v is placed
        }
        return order.size() == n ? order : new ArrayList<>();
    }
}
Go
// topoOrder takes edges {u, v} meaning u comes before v; it returns nil if there is a cycle.
func topoOrder(n int, edges [][2]int) []int {
	graph := make([][]int, n)
	indeg := make([]int, n)
	for _, e := range edges {
		graph[e[0]] = append(graph[e[0]], e[1])
		indeg[e[1]]++
	}
	var q []int
	for i, d := range indeg {
		if d == 0 {
			q = append(q, i)
		}
	}
	order := make([]int, 0, n)
	for len(q) > 0 {
		u := q[0]
		q = q[1:]
		order = append(order, u)
		for _, v := range graph[u] {
			indeg[v]--
			if indeg[v] == 0 { // every prerequisite of v is placed
				q = append(q, v)
			}
		}
	}
	if len(order) < n {
		return nil
	}
	return order
}

常见变体

这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。

DFS 三色标记:灰色是当前路径时间 O(V + E) · 空间 O(V)

用 DFS 判环或找「走不到环」的点:白色没访问,灰色在当前递归路径上,黑色已经确认安全;DFS 时碰到灰色就是回到了路径上,也就是有环。LC 802 找到最终的安全状态、LC 207 课程表的 DFS 写法。

Python
def eventual_safe_nodes(graph: list[list[int]]) -> list[int]:
    """Nodes every path from which ends at a terminal node: no cycle is reachable (LC 802)."""
    WHITE, GRAY, BLACK = 0, 1, 2  # unvisited / on the current DFS path / finished and safe
    color = [WHITE] * len(graph)

    def safe(u: int) -> bool:
        if color[u] != WHITE:
            return color[u] == BLACK  # GRAY: back on the current path, so a cycle
        color[u] = GRAY
        if not all(safe(v) for v in graph[u]):
            return False  # stays GRAY: on a cycle or leading to one
        color[u] = BLACK
        return True

    return [u for u in range(len(graph)) if safe(u)]
C++
// Nodes every path from which ends at a terminal node: no cycle is reachable (LC 802).
vector<int> eventualSafeNodes(const vector<vector<int>>& graph) {
    enum { WHITE, GRAY, BLACK };  // unvisited / on the current DFS path / finished and safe
    vector<int> color(graph.size(), WHITE);
    function<bool(int)> safe = [&](int u) {
        if (color[u] != WHITE) return color[u] == BLACK;  // GRAY: back on the current path, so a cycle
        color[u] = GRAY;
        for (int v : graph[u])
            if (!safe(v)) return false;  // stays GRAY: on a cycle or leading to one
        color[u] = BLACK;
        return true;
    };
    vector<int> res;
    for (int u = 0; u < (int)graph.size(); u++)
        if (safe(u)) res.push_back(u);
    return res;
}
Java
class Solution {
    private static final int WHITE = 0, GRAY = 1, BLACK = 2; // unvisited / on the current DFS path / finished and safe
    private int[][] graph;
    private int[] color;

    // Nodes every path from which ends at a terminal node: no cycle is reachable (LC 802).
    List<Integer> eventualSafeNodes(int[][] graph) {
        this.graph = graph;
        color = new int[graph.length];
        List<Integer> res = new ArrayList<>();
        for (int u = 0; u < graph.length; u++)
            if (safe(u)) res.add(u);
        return res;
    }

    private boolean safe(int u) {
        if (color[u] != WHITE) return color[u] == BLACK; // GRAY: back on the current path, so a cycle
        color[u] = GRAY;
        for (int v : graph[u])
            if (!safe(v)) return false; // stays GRAY: on a cycle or leading to one
        color[u] = BLACK;
        return true;
    }
}
Go
// eventualSafeNodes returns the nodes every path from which ends at a terminal node (LC 802).
func eventualSafeNodes(graph [][]int) []int {
	const (
		white = iota // unvisited
		gray         // on the current DFS path
		black        // finished and safe
	)
	color := make([]int, len(graph))
	var safe func(u int) bool
	safe = func(u int) bool {
		if color[u] != white {
			return color[u] == black // gray: back on the current path, so a cycle
		}
		color[u] = gray
		for _, v := range graph[u] {
			if !safe(v) {
				return false // stays gray: on a cycle or leading to one
			}
		}
		color[u] = black
		return true
	}
	res := []int{}
	for u := range graph {
		if safe(u) {
			res = append(res, u)
		}
	}
	return res
}
拓扑序上做 DP:最长路径 / 最少学期时间 O(V + E) · 空间 O(V + E)

依赖关系构成 DAG、要算「最早什么时候能做完」「最长链有多长」:按 Kahn 的出队顺序推进,每处理一条边 u→v 就用 term[u] + 1(或加上 u 的耗时)更新 term[v]。LC 1136 并行课程;LC 2050 并行课程 III 把 +1 换成课程时长。

Python
from collections import deque


def min_semesters(n: int, relations: list[tuple[int, int]]) -> int:
    """Courses 1..n, (a, b): a before b, any number per term. Fewest terms, or -1 on a cycle (LC 1136)."""
    graph = [[] for _ in range(n + 1)]
    indeg = [0] * (n + 1)
    for a, b in relations:
        graph[a].append(b)
        indeg[b] += 1
    term = [1] * (n + 1)  # term[v]: the earliest term v can be taken
    q = deque(v for v in range(1, n + 1) if indeg[v] == 0)
    done = 0
    while q:
        u = q.popleft()
        done += 1
        for v in graph[u]:
            term[v] = max(term[v], term[u] + 1)  # DP in topological order: u is final when it leaves
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    return max(term[1:]) if done == n else -1
C++
// Courses 1..n, {a, b}: a before b, any number per term. Fewest terms, or -1 on a cycle (LC 1136).
int minSemesters(int n, const vector<pair<int, int>>& relations) {
    vector<vector<int>> graph(n + 1);
    vector<int> indeg(n + 1, 0), term(n + 1, 1);  // term[v]: the earliest term v can be taken
    for (auto [a, b] : relations) {
        graph[a].push_back(b);
        indeg[b]++;
    }
    queue<int> q;
    for (int v = 1; v <= n; v++)
        if (indeg[v] == 0) q.push(v);
    int done = 0;
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        done++;
        for (int v : graph[u]) {
            term[v] = max(term[v], term[u] + 1);  // DP in topological order: u is final when it leaves
            if (--indeg[v] == 0) q.push(v);
        }
    }
    return done == n ? *max_element(term.begin() + 1, term.end()) : -1;
}
Java
class Solution {
    // Courses 1..n, {a, b}: a before b, any number per term. Fewest terms, or -1 on a cycle (LC 1136).
    int minSemesters(int n, int[][] relations) {
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i <= n; i++) graph.add(new ArrayList<>());
        int[] indeg = new int[n + 1], term = new int[n + 1]; // term[v]: the earliest term v can be taken
        Arrays.fill(term, 1);
        for (int[] r : relations) {
            graph.get(r[0]).add(r[1]);
            indeg[r[1]]++;
        }
        Deque<Integer> q = new ArrayDeque<>();
        for (int v = 1; v <= n; v++)
            if (indeg[v] == 0) q.offer(v);
        int done = 0, best = 0;
        while (!q.isEmpty()) {
            int u = q.poll();
            done++;
            best = Math.max(best, term[u]);
            for (int v : graph.get(u)) {
                term[v] = Math.max(term[v], term[u] + 1); // DP in topological order: u is final when it leaves
                if (--indeg[v] == 0) q.offer(v);
            }
        }
        return done == n ? best : -1;
    }
}
Go
// minSemesters returns the fewest terms for courses 1..n ({a, b}: a before b), or -1 on a cycle.
func minSemesters(n int, relations [][2]int) int {
	graph := make([][]int, n+1)
	indeg, term := make([]int, n+1), make([]int, n+1) // term[v]: the earliest term v can be taken
	for _, r := range relations {
		graph[r[0]] = append(graph[r[0]], r[1])
		indeg[r[1]]++
	}
	var q []int
	for v := 1; v <= n; v++ {
		term[v] = 1
		if indeg[v] == 0 {
			q = append(q, v)
		}
	}
	done, best := 0, 0
	for len(q) > 0 {
		u := q[0]
		q = q[1:]
		done++
		best = max(best, term[u])
		for _, v := range graph[u] {
			term[v] = max(term[v], term[u]+1) // DP in topological order: u is final when it leaves
			indeg[v]--
			if indeg[v] == 0 {
				q = append(q, v)
			}
		}
	}
	if done != n {
		return -1
	}
	return best
}

为什么对

  1. 入度为 0 的点没有未完成的前驱,放在当前位置不会违反任何边的方向;删掉它的出边后,剩下的图仍然是同样的问题。
  2. 环上每个点的入度永远至少为 1(它在环上的前驱永远不会被取出),所以输出的点少于 n ⇔ 图中有环。
  3. 任何有向无环图都至少有一个入度为 0 的点(否则沿入边一直往回走,终会重复某个点、形成环),所以只要无环,队列就不会提前变空。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
拓扑排序我用 Kahn 算法:先统计入度,把入度为 0 的点入队;每取出一个点就放进结果,并把它指向的点入度减一,减到 0 就入队。入度为 0 意味着前驱都已完成,所以放在这里不会违反依赖。如果最后输出的点少于 n,说明图中有环(剩下的点要么在环上,要么在环的下游),课程表这类题就是靠这个判断。时间 O(V + E)。
English
For topological order I use Kahn's algorithm: count in-degrees and enqueue every node with none. Each node I dequeue goes into the result, and every node it points to loses one in-degree and is enqueued when it reaches zero. In-degree zero means all its prerequisites are done, so placing it violates nothing. If fewer than n nodes come out, the graph has a cycle (every leftover node is on one or downstream of one), which answers course schedule problems, all in O(V + E).

自评一下

对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/3)
这道题你会了吗?

小测

3 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

3 道题
  1. 用 Kahn 算法(反复取出入度为 0 的点)做拓扑排序,结束时输出的顶点数少于 n,说明什么?
  2. 基于 DFS 的拓扑排序,结果是哪个顺序?
  3. V = n 个顶点,E = len(edges) 条边。时间复杂度是?
    from collections import deque
    
    def topo(n, edges):
        adj = [[] for _ in range(n)]
        indeg = [0] * n
        for u, v in edges:
            adj[u].append(v)
            indeg[v] += 1
        q = deque(i for i in range(n) if indeg[i] == 0)
        order = []
        while q:
            u = q.popleft()
            order.append(u)
            for v in adj[u]:
                indeg[v] -= 1
                if indeg[v] == 0:
                    q.append(v)
        return order

作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。

模板练习

用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。

下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。

from collections import deque


def topo_order(n: int, edges: list[tuple[int, int]]) -> list[int]:
    """Edge (u, v) means u comes before v. Returns [] if there is a cycle."""
    graph = [[] for _ in range(n)]
                q.append(v)
    return order if len(order) == n else []

分档练习

本站在 500 路里的 7 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。

基础 1

#题目一句话思路难度分
207课程表把课程先修关系建为有向图,通过拓扑排序检测环。

核心 4

#题目一句话思路难度分
210课程表 II在检测环的基础上输出合法学习顺序。
269火星词典 🔒从相邻单词提取字母依赖,处理非法前缀和环。
802找到最终的安全状态找出最终进入终止节点的安全状态,区分当前递归路径与已解决状态。难度分 1962
1136并行课程 🔒按拓扑层次并行处理课程,求完成所有课程的最少学期。难度分 1710

进阶 2

#题目一句话思路难度分
1462课程表 IV计算先修关系的传递可达性,支持多次关系查询。难度分 1693
2392给定条件下构造矩阵分别解决行、列偏序约束,再组合成矩阵坐标。难度分 1961

Core Skills

不调库,亲手实现这一站用到的数据结构或算法,四种语言都能判题。