cswork / 模板打开 500 路题单

第 17 站 · 图论

最短路径与图状态

Dijkstra:堆 + 懒删除 O((V + E) log V)

周赛难度分 难度分 1780–难度分 2201· 中位 难度分 1948(9/12 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 17 站 · 12 题
先学
学完去
没有后续站依赖它。
面试频次
6 家公司考过 · 面经出现 7 次Snap 2、Airbnb 1、Amazon 1、DoorDash 1、Salesforce 1

先做这道

LC 127单词接龙困难
卡住再看

把单词变化建模为无权图最短路,控制邻居生成和重复访问。

要点

  1. 每次从堆里取出当前距离最小的点,它的距离就确定了;前提是所有边权非负。
  2. 不做 decrease-key:直接把新距离压进堆,弹出时发现比 dist 大就跳过(懒删除)。
  3. 边权只有 0 和 1 时用双端队列 BFS;有负权用 Bellman-Ford;状态里带「已用次数」时,把 (点, 次数) 当成一个新结点。

易错点

  • 不跳过过期的堆元素,同一个点会被反复松弛,稠密图上会超时。
  • 距离累加超出 32 位整数;用 INT_MAX 当无穷大时,d + w 还会溢出成负数。
  • 有向图当成无向图建边(或者反过来),所有距离都会错。

看动画

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

交互演示 · 逐步观察

Dijkstra · 带权最短路

从 A 出发,到每个点的最短距离是多少?

421510263A0B∞C∞D∞E∞F∞
0A
∞B
∞C
∞D
∞E
∞F
当前处理已发现 / 已计算答案
优先队列(按距离从小到大)
A:0
已确定
0 / 6
当前节点
—
A→F
∞

起点 A 距离为 0,其余为 ∞。节点下方的数字是目前已知的最短距离。

始终成立从优先队列弹出的未确定节点,其距离已是最短;前提是边权都不为负。

模板

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

Python
import heapq


def dijkstra(n: int, edges: list[tuple[int, int, int]], src: int) -> list[int]:
    """Directed edges (u, v, w) with w >= 0. Unreachable nodes get -1."""
    graph = [[] for _ in range(n)]
    for u, v, w in edges:
        graph[u].append((v, w))
    dist = [float("inf")] * n
    dist[src] = 0
    heap = [(0, src)]
    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue  # stale entry: u was settled with a shorter distance
        for v, w in graph[u]:
            if d + w < dist[v]:
                dist[v] = d + w
                heapq.heappush(heap, (dist[v], v))
    return [d if d != float("inf") else -1 for d in dist]
C++
// Directed edges {u, v, w} with w >= 0. Unreachable nodes get -1.
vector<long long> dijkstra(int n, const vector<array<int, 3>>& edges, int src) {
    vector<vector<pair<int, int>>> graph(n);
    for (auto& [u, v, w] : edges) graph[u].push_back({v, w});
    vector<long long> dist(n, LLONG_MAX);
    priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<>> pq;  // min-heap
    dist[src] = 0;
    pq.push({0, src});
    while (!pq.empty()) {
        auto [d, u] = pq.top();
        pq.pop();
        if (d > dist[u]) continue;  // stale entry: u was settled with a shorter distance
        for (auto [v, w] : graph[u])
            if (d + w < dist[v]) {
                dist[v] = d + w;
                pq.push({dist[v], v});
            }
    }
    for (auto& d : dist)
        if (d == LLONG_MAX) d = -1;
    return dist;
}
Java
class Solution {
    // Directed edges {u, v, w} with w >= 0. Unreachable nodes get -1.
    long[] dijkstra(int n, int[][] edges, int src) {
        List<List<int[]>> graph = new ArrayList<>();
        for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
        for (int[] e : edges) graph.get(e[0]).add(new int[] {e[1], e[2]});
        long[] dist = new long[n];
        Arrays.fill(dist, Long.MAX_VALUE);
        PriorityQueue<long[]> pq = new PriorityQueue<>((a, b) -> Long.compare(a[0], b[0]));
        dist[src] = 0;
        pq.offer(new long[] {0, src});
        while (!pq.isEmpty()) {
            long[] top = pq.poll();
            long d = top[0];
            int u = (int) top[1];
            if (d > dist[u]) continue; // stale entry: u was settled with a shorter distance
            for (int[] e : graph.get(u))
                if (d + e[1] < dist[e[0]]) {
                    dist[e[0]] = d + e[1];
                    pq.offer(new long[] {dist[e[0]], e[0]});
                }
        }
        for (int i = 0; i < n; i++) if (dist[i] == Long.MAX_VALUE) dist[i] = -1;
        return dist;
    }
}
Go
import "container/heap"

type item struct{ dist, node int }
type minHeap []item

func (h minHeap) Len() int           { return len(h) }
func (h minHeap) Less(i, j int) bool { return h[i].dist < h[j].dist }
func (h minHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any)        { *h = append(*h, x.(item)) }
func (h *minHeap) Pop() any {
	old := *h
	x := old[len(old)-1]
	*h = old[:len(old)-1]
	return x
}

// dijkstra takes directed edges {u, v, w} with w >= 0; unreachable nodes get -1.
func dijkstra(n int, edges [][3]int, src int) []int {
	graph := make([][][2]int, n)
	for _, e := range edges {
		graph[e[0]] = append(graph[e[0]], [2]int{e[1], e[2]})
	}
	dist := make([]int, n)
	for i := range dist {
		dist[i] = -1 // not reached yet
	}
	dist[src] = 0
	h := &minHeap{{0, src}}
	for h.Len() > 0 {
		top := heap.Pop(h).(item)
		if top.dist > dist[top.node] {
			continue // stale entry: the node was settled with a shorter distance
		}
		for _, e := range graph[top.node] {
			v, nd := e[0], top.dist+e[1]
			if dist[v] == -1 || nd < dist[v] {
				dist[v] = nd
				heap.Push(h, item{nd, v})
			}
		}
	}
	return dist
}

常见变体

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

隐式图 BFS:状态即结点时间 O(状态数 × 每个状态的邻居数) · 空间 O(状态数)

边权都是 1 时不用 Dijkstra:状态(密码锁的四位数、单词、棋盘)就是结点,「一步能到的状态」现场生成,BFS 第一次到达就是最短。用一个 dist 表同时记「见过」和距离。LC 752 打开转盘锁、LC 127 单词接龙、LC 773 滑动谜题。

Python
from collections import deque


def open_lock(deadends: list[str], target: str) -> int:
    """Fewest wheel turns from "0000" to target, never resting on a deadend (LC 752)."""
    dead = set(deadends)
    if "0000" in dead:
        return -1
    dist = {"0000": 0}  # every state seen so far, with its distance
    q = deque(["0000"])
    while q:
        s = q.popleft()
        if s == target:
            return dist[s]
        for i in range(4):
            for step in (1, -1):  # turn wheel i up or down
                t = s[:i] + str((int(s[i]) + step) % 10) + s[i + 1 :]
                if t not in dead and t not in dist:
                    dist[t] = dist[s] + 1
                    q.append(t)
    return -1
C++
// Fewest wheel turns from "0000" to target, never resting on a deadend (LC 752).
int openLock(const vector<string>& deadends, const string& target) {
    unordered_set<string> dead(deadends.begin(), deadends.end());
    if (dead.count("0000")) return -1;
    unordered_map<string, int> dist{{"0000", 0}};  // every state seen so far, with its distance
    queue<string> q;
    q.push("0000");
    while (!q.empty()) {
        string s = q.front();
        q.pop();
        if (s == target) return dist[s];
        for (int i = 0; i < 4; i++)
            for (int step : {1, 9}) {  // turn wheel i up, or down (+9 is -1 mod 10)
                string t = s;
                t[i] = '0' + (s[i] - '0' + step) % 10;
                if (!dead.count(t) && !dist.count(t)) {
                    dist[t] = dist[s] + 1;
                    q.push(t);
                }
            }
    }
    return -1;
}
Java
class Solution {
    // Fewest wheel turns from "0000" to target, never resting on a deadend (LC 752).
    int openLock(String[] deadends, String target) {
        Set<String> dead = new HashSet<>(Arrays.asList(deadends));
        if (dead.contains("0000")) return -1;
        Map<String, Integer> dist = new HashMap<>(Map.of("0000", 0)); // every state seen so far, with its distance
        Deque<String> q = new ArrayDeque<>(List.of("0000"));
        while (!q.isEmpty()) {
            String s = q.poll();
            if (s.equals(target)) return dist.get(s);
            for (int i = 0; i < 4; i++)
                for (int step : new int[] {1, 9}) { // turn wheel i up, or down (+9 is -1 mod 10)
                    char[] t = s.toCharArray();
                    t[i] = (char) ('0' + (t[i] - '0' + step) % 10);
                    String next = new String(t);
                    if (!dead.contains(next) && !dist.containsKey(next)) {
                        dist.put(next, dist.get(s) + 1);
                        q.offer(next);
                    }
                }
        }
        return -1;
    }
}
Go
// openLock returns the fewest wheel turns from "0000" to target, never resting on a deadend (LC 752).
func openLock(deadends []string, target string) int {
	dead := map[string]bool{}
	for _, d := range deadends {
		dead[d] = true
	}
	if dead["0000"] {
		return -1
	}
	dist := map[string]int{"0000": 0} // every state seen so far, with its distance
	q := []string{"0000"}
	for len(q) > 0 {
		s := q[0]
		q = q[1:]
		if s == target {
			return dist[s]
		}
		for i := 0; i < 4; i++ {
			for _, step := range []byte{1, 9} { // turn wheel i up, or down (+9 is -1 mod 10)
				t := []byte(s)
				t[i] = '0' + (t[i]-'0'+step)%10
				next := string(t)
				if _, seen := dist[next]; !seen && !dead[next] {
					dist[next] = dist[s] + 1
					q = append(q, next)
				}
			}
		}
	}
	return -1
}
限制边数的 Bellman-Ford时间 O(k × E) · 空间 O(V)

最短路限定「最多经过 k 站」(或图里有负权边)时,Dijkstra 不再适用:做 k + 1 轮松弛,每轮只从上一轮的结果出发,这样第 i 轮恰好代表最多 i 条边。LC 787 K 站中转内最便宜的航班。不限轮数、跑满 V - 1 轮就是普通 Bellman-Ford,还能再跑一轮检测负环。

Python
def cheapest_within(n: int, flights: list[tuple[int, int, int]], src: int, dst: int, k: int) -> int:
    """Cheapest price from src to dst with at most k stops, i.e. k + 1 flights (LC 787)."""
    INF = float("inf")
    dist = [INF] * n
    dist[src] = 0
    for _ in range(k + 1):  # after round i: best prices using at most i flights
        prev = dist[:]  # relax from last round only, so each round adds at most one flight
        for u, v, w in flights:
            if prev[u] + w < dist[v]:
                dist[v] = prev[u] + w
    return -1 if dist[dst] == INF else dist[dst]
C++
// Cheapest price from src to dst with at most k stops, i.e. k + 1 flights (LC 787).
long long cheapestWithin(int n, const vector<array<int, 3>>& flights, int src, int dst, int k) {
    const long long INF = LLONG_MAX / 4;
    vector<long long> dist(n, INF);
    dist[src] = 0;
    for (int round = 0; round <= k; round++) {  // after round i: best prices using at most i flights
        vector<long long> prev = dist;  // relax from last round only, so each round adds at most one flight
        for (auto [u, v, w] : flights)
            if (prev[u] + w < dist[v]) dist[v] = prev[u] + w;
    }
    return dist[dst] == INF ? -1 : dist[dst];
}
Java
class Solution {
    // Cheapest price from src to dst with at most k stops, i.e. k + 1 flights (LC 787).
    long cheapestWithin(int n, int[][] flights, int src, int dst, int k) {
        final long INF = Long.MAX_VALUE / 4;
        long[] dist = new long[n];
        Arrays.fill(dist, INF);
        dist[src] = 0;
        for (int round = 0; round <= k; round++) { // after round i: best prices using at most i flights
            long[] prev = dist.clone(); // relax from last round only, so each round adds at most one flight
            for (int[] f : flights)
                if (prev[f[0]] + f[2] < dist[f[1]]) dist[f[1]] = prev[f[0]] + f[2];
        }
        return dist[dst] == INF ? -1 : dist[dst];
    }
}
Go
// cheapestWithin returns the cheapest price from src to dst with at most k stops (LC 787).
func cheapestWithin(n int, flights [][3]int, src, dst, k int) int {
	const inf = 1 << 60
	dist := make([]int, n)
	for i := range dist {
		dist[i] = inf
	}
	dist[src] = 0
	for round := 0; round <= k; round++ { // after round i: best prices using at most i flights
		prev := append([]int(nil), dist...) // relax from last round only, so each round adds at most one flight
		for _, f := range flights {
			if prev[f[0]]+f[2] < dist[f[1]] {
				dist[f[1]] = prev[f[0]] + f[2]
			}
		}
	}
	if dist[dst] == inf {
		return -1
	}
	return dist[dst]
}
状态压缩 BFS:(结点, 已访问集合)时间 O(2^n × n²) · 空间 O(2^n × n)

要求「走遍所有点」的最短路,而点数很少(n ≤ 12 左右):状态是 (当前结点, 已访问集合的位掩码),所有起点一起入队做多源 BFS,第一次出现全 1 掩码时的步数就是答案。LC 847 访问所有节点的最短路径。

Python
from collections import deque


def shortest_path_all_nodes(graph: list[list[int]]) -> int:
    """Length of the shortest walk that visits every node; edges may repeat (LC 847)."""
    n = len(graph)
    full = (1 << n) - 1
    seen = [[False] * (1 << n) for _ in range(n)]  # state (node, mask of visited nodes)
    q = deque()
    for i in range(n):  # every node is a start, at distance 0
        seen[i][1 << i] = True
        q.append((i, 1 << i))
    steps = 0
    while q:
        for _ in range(len(q)):
            u, mask = q.popleft()
            if mask == full:
                return steps
            for v in graph[u]:
                nxt = mask | 1 << v
                if not seen[v][nxt]:
                    seen[v][nxt] = True
                    q.append((v, nxt))
        steps += 1
    return -1
C++
// Length of the shortest walk that visits every node; edges may repeat (LC 847).
int shortestPathAllNodes(const vector<vector<int>>& graph) {
    int n = graph.size(), full = (1 << n) - 1;
    vector<vector<bool>> seen(n, vector<bool>(1 << n, false));  // state (node, mask of visited nodes)
    queue<pair<int, int>> q;
    for (int i = 0; i < n; i++) {  // every node is a start, at distance 0
        seen[i][1 << i] = true;
        q.push({i, 1 << i});
    }
    for (int steps = 0; !q.empty(); steps++)
        for (int k = q.size(); k > 0; k--) {
            auto [u, mask] = q.front();
            q.pop();
            if (mask == full) return steps;
            for (int v : graph[u]) {
                int next = mask | 1 << v;
                if (!seen[v][next]) {
                    seen[v][next] = true;
                    q.push({v, next});
                }
            }
        }
    return -1;
}
Java
class Solution {
    // Length of the shortest walk that visits every node; edges may repeat (LC 847).
    int shortestPathAllNodes(int[][] graph) {
        int n = graph.length, full = (1 << n) - 1;
        boolean[][] seen = new boolean[n][1 << n]; // state (node, mask of visited nodes)
        Deque<int[]> q = new ArrayDeque<>();
        for (int i = 0; i < n; i++) { // every node is a start, at distance 0
            seen[i][1 << i] = true;
            q.offer(new int[] {i, 1 << i});
        }
        for (int steps = 0; !q.isEmpty(); steps++)
            for (int k = q.size(); k > 0; k--) {
                int[] state = q.poll();
                if (state[1] == full) return steps;
                for (int v : graph[state[0]]) {
                    int next = state[1] | 1 << v;
                    if (!seen[v][next]) {
                        seen[v][next] = true;
                        q.offer(new int[] {v, next});
                    }
                }
            }
        return -1;
    }
}
Go
// shortestPathAllNodes returns the length of the shortest walk that visits every node (LC 847).
func shortestPathAllNodes(graph [][]int) int {
	n := len(graph)
	full := 1<<n - 1
	seen := make([][]bool, n) // state (node, mask of visited nodes)
	var q [][2]int
	for i := range seen { // every node is a start, at distance 0
		seen[i] = make([]bool, 1<<n)
		seen[i][1<<i] = true
		q = append(q, [2]int{i, 1 << i})
	}
	for steps := 0; len(q) > 0; steps++ {
		var next [][2]int // the following BFS level
		for _, s := range q {
			if s[1] == full {
				return steps
			}
			for _, v := range graph[s[0]] {
				if mask := s[1] | 1<<v; !seen[v][mask] {
					seen[v][mask] = true
					next = append(next, [2]int{v, mask})
				}
			}
		}
		q = next
	}
	return -1
}

为什么对

  1. 出堆即确定:堆顶 u 的距离 d 是所有未确定点里最小的。别的路径要到 u,必须先经过某个未确定点 v(距离 ≥ d),再加上非负边权,不可能比 d 更短。
  2. 负边会打破这个论证。反例:A→B 权 1,A→C 权 2,C→B 权 −2。B 以距离 1 先出堆,但真实最短是 A→C→B = 0。给出堆的点打「已完成」标记的写法会直接返回 1;懒删除写法会再次松弛 B,但复杂度保证没有了,所以负边要换 Bellman-Ford。
  3. 懒删除不影响正确性:过期条目的 d 大于 dist[u],而 u 更短的那个条目更早出堆、已经松弛过邻居,跳过过期条目不会丢信息。

30 秒口述稿

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

中文
非负权最短路我用 Dijkstra:堆里按当前距离取最小的点,取出即确定,再去松弛它的邻居。之所以取出即确定,是因为别的路径要绕过来,得先经过一个距离不小于它的点,再加非负边,不可能更短。我不用 decrease-key,而是把新距离直接入堆,出堆时跳过比 dist 大的过期条目。复杂度 O((V+E) log V);有负边就换 Bellman-Ford,0/1 边权用双端队列 BFS。
English
For shortest paths with non-negative weights I use Dijkstra: pop the node with the smallest tentative distance, treat it as final, and relax its neighbours. It is final because any other route would have to pass through a node at least as far away and then add non-negative edges. Instead of decrease-key I push the new distance and skip stale entries larger than dist when they pop. That is O((V + E) log V); with negative edges I switch to Bellman-Ford, and with 0/1 weights to a deque BFS.

自评一下

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

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

小测

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

5 道题
  1. 有向图有负权边但没有负权环,求单源最短路,应该用哪个算法?
  2. 边权只有 0 和 1 的图上做「0-1 BFS」,用双端队列时边的处理规则是?
  3. 图有 V 个顶点、E 条边,边权非负。时间复杂度是?
    import heapq
    
    def dijkstra(adj, s):
        dist = {s: 0}
        pq = [(0, s)]
        while pq:
            d, u = heapq.heappop(pq)
            if d > dist[u]:
                continue
            for v, w in adj[u]:
                if d + w < dist.get(v, float('inf')):
                    dist[v] = d + w
                    heapq.heappush(pq, (d + w, v))
        return dist
  4. V = n 个顶点,E = len(edges) 条边。时间复杂度是?
    def bellman_ford(n, edges, s):
        dist = [float('inf')] * n
        dist[s] = 0
        for _ in range(n - 1):
            for u, v, w in edges:
                if dist[u] + w < dist[v]:
                    dist[v] = dist[u] + w
        return dist
  5. d 是 V×V 的距离矩阵。时间复杂度是?
    def all_pairs(d):
        n = len(d)
        for k in range(n):
            for i in range(n):
                for j in range(n):
                    if d[i][k] + d[k][j] < d[i][j]:
                        d[i][j] = d[i][k] + d[k][j]
        return d

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

模板练习

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

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

import heapq


def dijkstra(n: int, edges: list[tuple[int, int, int]], src: int) -> list[int]:
    """Directed edges (u, v, w) with w >= 0. Unreachable nodes get -1."""
    graph = [[] for _ in range(n)]
                heapq.heappush(heap, (dist[v], v))
    return [d if d != float("inf") else -1 for d in dist]

分档练习

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

核心 6

#题目一句话思路难度分
127单词接龙把单词变化建模为无权图最短路,控制邻居生成和重复访问。
433最小基因变化在有限合法基因集合中进行最少替换搜索。
752打开转盘锁把锁盘组合视为状态节点,处理死锁和按位旋转边界。难度分 1878
909蛇梯棋正确映射棋盘编号,区分掷骰移动和梯子蛇的强制跳转。难度分 2020
1129颜色交替的最短路径把上一条边颜色加入状态,求满足交替颜色约束的最短路。难度分 1780
743网络延迟时间用Dijkstra求单源最短路并判断全体节点是否收到信号。

进阶 6

#题目一句话思路难度分
773滑动谜题编码棋盘状态并进行BFS,识别不可达排列。难度分 1815
815公交路线以公交线路或站点建立搜索关系,优化反复扫描同一路线。难度分 1964
787K 站中转内最便宜的航班在路径成本之外保留停靠次数限制,避免错误地只按城市去重。难度分 1786
1631最小体力消耗路径将路径成本定义为最大边权,调整最短路的松弛公式。难度分 1948
778水位上升的泳池中游泳用最小瓶颈路径或阈值连通性解决逐渐涨水的可达问题。难度分 2097
847访问所有节点的最短路径把已访问节点集合加入BFS状态,求访问所有节点的最短路径。难度分 2201

Core Skills

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