图论 LeetCode 专题:拓扑排序、最短路径、并查集与最小生成树

LeetCode 图论高频题专题精讲:拓扑排序(Course Schedule)、最短路径(Network Delay Time)、并查集(Number of Provinces)、最小生成树(Connecting Cities)等经典题目,覆盖 BFS/DFS、Dijkstra、Kruskal/Prim 核心算法在面试题中的实战应用。

图论 LeetCode 专题

图的遍历、搜索与优化算法在面试中高频出现,与树和递归同等重要。

图论题目在 LeetCode 中约占总量的 8%,但在一线大厂面试中的出现率超过 20%。掌握图论的关键不在于背诵模板,而在于理解图的表示方法和算法选型。


一、图的表示与遍历基础

图的两种存储方式

# 邻接矩阵:适合稠密图
adj_matrix = [
    [0, 1, 0, 1],
    [1, 0, 1, 0],
    [0, 1, 0, 1],
    [1, 0, 1, 0]
]

# 邻接表:适合稀疏图(大多数面试题)
from collections import defaultdict
adj_list = defaultdict(list)
edges = [[0, 1], [0, 3], [1, 2], [2, 3]]
for u, v in edges:
    adj_list[u].append(v)
    adj_list[v].append(u)  # 无向图

面试建议:除非题目明确给出稠密图特征,一律使用邻接表。

图的遍历模板

from collections import deque

def bfs(graph, start):
    """BFS 求最短路径(无权图)"""
    visited = {start}
    queue = deque([(start, 0)])
    while queue:
        node, dist = queue.popleft()
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append((neighbor, dist + 1))
    return visited

def dfs(graph, node, visited):
    """DFS 遍历(递归版)"""
    visited.add(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)

二、拓扑排序

题目 1:Course Schedule(LeetCode 207)

判断课程是否能全部修完(有向图是否有环)。

Kahn 算法(BFS):

from collections import deque, defaultdict

def canFinish(numCourses, prerequisites):
    # 构建图和入度数组
    graph = defaultdict(list)
    indegree = [0] * numCourses
    for course, prereq in prerequisites:
        graph[prereq].append(course)
        indegree[course] += 1

    # 入度为 0 的节点入队
    queue = deque([i for i, d in enumerate(indegree) if d == 0])
    visited = 0

    while queue:
        node = queue.popleft()
        visited += 1
        for neighbor in graph[node]:
            indegree[neighbor] -= 1
            if indegree[neighbor] == 0:
                queue.append(neighbor)

    return visited == numCourses

复杂度:时间 (O(V + E)),空间 (O(V + E))。

题目 2:Course Schedule II(LeetCode 210)

找出一种可行的修课顺序。

在上题基础上,只需记录出队顺序即可。如果最后出队节点数不足 numCourses,说明有环。

题目 3:Alien Dictionary(LeetCode 269)

根据外星单词的字典序,推导字母顺序。

思路:

  1. 相邻单词逐字符比较,找出第一个不同字符对 → 有向边
  2. 对字母构建图,拓扑排序得到顺序
def alienOrder(words):
    # 构建有向图和入度
    chars = set(''.join(words))
    graph = {c: [] for c in chars}
    indegree = {c: 0 for c in chars}

    for i in range(len(words) - 1):
        w1, w2 = words[i], words[i + 1]
        # 检查前缀问题:["abc", "ab"] 非法
        if len(w1) > len(w2) and w1[:len(w2)] == w2:
            return ""
        for a, b in zip(w1, w2):
            if a != b:
                graph[a].append(b)
                indegree[b] += 1
                break

    # Kahn 拓扑排序
    queue = deque([c for c in chars if indegree[c] == 0])
    result = []
    while queue:
        c = queue.popleft()
        result.append(c)
        for neighbor in graph[c]:
            indegree[neighbor] -= 1
            if indegree[neighbor] == 0:
                queue.append(neighbor)

    return ''.join(result) if len(result) == len(chars) else ""

三、并查集(Union-Find)

题目 4:Number of Provinces(LeetCode 547)

给定城市连接关系,求省份数量(连通分量)。

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.count = n  # 连通分量数

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # 路径压缩
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return
        # 按秩合并
        if self.rank[px] < self.rank[py]:
            px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
        self.count -= 1

def findCircleNum(isConnected):
    n = len(isConnected)
    uf = UnionFind(n)
    for i in range(n):
        for j in range(i + 1, n):
            if isConnected[i][j] == 1:
                uf.union(i, j)
    return uf.count

题目 5:Redundant Connection(LeetCode 684)

树中多了一条边,找出这条冗余边(最后出现的)。

思路:并查集,如果两个节点已在同一集合,当前边就是冗余边。

def findRedundantConnection(edges):
    n = len(edges)
    uf = UnionFind(n + 1)
    for u, v in edges:
        if uf.find(u) == uf.find(v):
            return [u, v]
        uf.union(u, v)
    return []

题目 6:Accounts Merge(LeetCode 721)

根据邮箱关联,合并同一人的账户。

思路:邮箱为节点,同一账户的邮箱之间连边,然后按连通分量合并。


四、最短路径

题目 7:Network Delay Time(LeetCode 743)

从节点 K 发出的信号,多久能到所有节点?

Dijkstra 算法:单源最短路径,无负权边。

import heapq
from collections import defaultdict

def networkDelayTime(times, n, k):
    # 构建图
    graph = defaultdict(list)
    for u, v, w in times:
        graph[u].append((v, w))

    # Dijkstra
    dist = {i: float('inf') for i in range(1, n + 1)}
    dist[k] = 0
    pq = [(0, k)]

    while pq:
        d, node = heapq.heappop(pq)
        if d > dist[node]:
            continue
        for neighbor, weight in graph[node]:
            new_dist = d + weight
            if new_dist < dist[neighbor]:
                dist[neighbor] = new_dist
                heapq.heappush(pq, (new_dist, neighbor))

    max_dist = max(dist.values())
    return max_dist if max_dist < float('inf') else -1

复杂度:时间 (O((V + E) \log V)),空间 (O(V + E))。

题目 8:Cheapest Flights Within K Stops(LeetCode 787)

最多经停 K 次的最便宜航班。

Bellman-Ford 变体:限制边数的最短路径。

def findCheapestPrice(n, flights, src, dst, k):
    # Bellman-Ford: dist[i][v] = 最多 i 条边到达 v 的最小成本
    dist = [float('inf')] * n
    dist[src] = 0

    for _ in range(k + 1):
        new_dist = dist[:]
        for u, v, w in flights:
            if dist[u] + w < new_dist[v]:
                new_dist[v] = dist[u] + w
        dist = new_dist

    return dist[dst] if dist[dst] < float('inf') else -1

追问:“如果 K 很大怎么办?” → 提前剪枝:如果在某轮迭代中没有 relax 操作发生,可以提前终止。


五、最小生成树(MST)

题目 9:Min Cost to Connect All Points(LeetCode 1584)

连接所有点的最小成本(曼哈顿距离)。

Kruskal 算法:

def minCostConnectPoints(points):
    n = len(points)
    # 构建所有边
    edges = []
    for i in range(n):
        for j in range(i + 1, n):
            dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1])
            edges.append((dist, i, j))

    edges.sort()
    uf = UnionFind(n)
    cost = 0
    edges_used = 0

    for dist, u, v in edges:
        if uf.find(u) != uf.find(v):
            uf.union(u, v)
            cost += dist
            edges_used += 1
            if edges_used == n - 1:
                break

    return cost

Prim 算法版本(稠密图更优):

import heapq

def minCostConnectPointsPrim(points):
    n = len(points)
    visited = [False] * n
    # (cost, node)
    pq = [(0, 0)]
    total_cost = 0
    edges_used = 0

    while edges_used < n:
        cost, node = heapq.heappop(pq)
        if visited[node]:
            continue
        visited[node] = True
        total_cost += cost
        edges_used += 1

        for neighbor in range(n):
            if not visited[neighbor]:
                dist = abs(points[node][0] - points[neighbor][0]) + \
                       abs(points[node][1] - points[neighbor][1])
                heapq.heappush(pq, (dist, neighbor))

    return total_cost

六、综合应用

题目 10:Word Ladder(LeetCode 127)

字典中找出从 beginWord 到 endWord 的最短转换序列。

双向 BFS:从两端同时搜索,减少搜索空间。

from collections import deque

def ladderLength(beginWord, endWord, wordList):
    wordSet = set(wordList)
    if endWord not in wordSet:
        return 0

    # 双向 BFS
    front = {beginWord}
    back = {endWord}
    length = 1

    while front:
        # 每次都从较小的一端扩展
        if len(front) > len(back):
            front, back = back, front

        next_front = set()
        for word in front:
            for i in range(len(word)):
                for c in 'abcdefghijklmnopqrstuvwxyz':
                    next_word = word[:i] + c + word[i+1:]
                    if next_word in back:
                        return length + 1
                    if next_word in wordSet:
                        next_front.add(next_word)
                        wordSet.remove(next_word)

        front = next_front
        length += 1

    return 0

题目 11:Number of Islands(LeetCode 200)

二维网格中岛屿的数量。

def numIslands(grid):
    if not grid:
        return 0
    rows, cols = len(grid), len(grid[0])
    count = 0

    def dfs(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
            return
        grid[r][c] = '0'  # 标记已访问
        for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
            dfs(r + dr, c + dc)

    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                count += 1
                dfs(r, c)
    return count

图论题解速查表

题目编号核心算法难度
Course Schedule207拓扑排序(Kahn)Medium
Course Schedule II210拓扑排序 + 路径记录Medium
Alien Dictionary269拓扑排序 + 字符串处理Hard
Number of Provinces547并查集Medium
Redundant Connection684并查集Medium
Accounts Merge721并查集 + 哈希映射Medium
Network Delay Time743DijkstraMedium
Cheapest Flights Within K Stops787Bellman-FordMedium
Min Cost Connect Points1584Kruskal / PrimMedium
Word Ladder127双向 BFSHard
Number of Islands200DFS / BFSMedium
Clone Graph133DFS / BFSMedium
Pacific Atlantic Water Flow417DFS / BFS 多源搜索Medium
Evaluate Division399并查集 / Floyd-WarshallMedium

面试追问

追问回答要点
“Kahn 和 DFS 拓扑排序怎么选?”Kahn 更直观,DFS 后序反转代码更短,都能检测环
“并查集路径压缩和按秩合并能同时用吗?”可以,时间复杂度接近 O(α(n)),α 是反阿克曼函数
“Dijkstra 为什么不能用负权边?”贪心策略基于当前最短,负权可能导致已确定的最短路径被更新
“Prim vs Kruskal 怎么选?”稠密图 Prim(O(V²) 堆优化前),稀疏图 Kruskal(O(E log E))
“双向 BFS 为什么更快?”减少搜索空间,从 b^d 降到 2 * b^(d/2)
“图论题怎么识别?”看到"关系"“连接"“依赖"“路径"“网络"等关键词

关键知识点总结

算法适用场景时间复杂度面试频率
拓扑排序DAG 依赖排序、课程安排O(V + E)⭐⭐⭐⭐⭐
BFS无权图最短路径、层级遍历O(V + E)⭐⭐⭐⭐⭐
DFS连通分量、环检测、回溯O(V + E)⭐⭐⭐⭐⭐
并查集连通性、MST、集合合并O(α(N))⭐⭐⭐⭐⭐
Dijkstra单源最短路径(无负权)O((V+E) log V)⭐⭐⭐⭐
Bellman-Ford带负权的最短路径O(VE)⭐⭐⭐
Kruskal/Prim最小生成树O(E log E) / O(V²)⭐⭐⭐⭐

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页