图论入门:BFS、Dijkstra与拓扑排序的完整攻略

前言

图论是算法中的高级内容,很多人觉得图论很难。其实图就是节点+边的组合,掌握BFS、Dijkstra、拓扑排序这三个核心算法,90%的图论题都能解决。

我并没有能力让你看完就精通所有图论,我只是想让你理解图的表示方法、BFS求最短路的思路、Dijkstra的优化过程、以及拓扑排序的应用场景。

摘要

从"网络延迟时间"问题出发,剖析图论的核心算法与应用场景。通过BFS的层序遍历、Dijkstra从O(n²)到O(nlogn)的优化、以及拓扑排序的入度法实现,揭秘图论的解题套路。配合LeetCode高频题目与详细图解,给出图论算法的完整攻略。


一、什么是图

周二下午,哈吉米遇到图论题,懵了。

南北绿豆:"先搞清楚图是什么。"

1.1 图的概念

图(Graph) = 节点(Vertex)+ 边(Edge)

生活化场景

社交网络:
节点 = 人
边 = 朋友关系

地图导航:
节点 = 城市
边 = 道路

课程依赖:
节点 = 课程
边 = 先修关系

哈吉米:"懂了,图就是用来描述关系的。"

1.2 图的分类

南北绿豆:"图分两大类。"

无向图 vs 有向图

无向图:A ←→ B(双向)
示例:朋友关系(互相是朋友)

有向图:AB(单向)
示例:关注关系(A关注BB不一定关注A

无权图 vs 带权图

无权图:A —— B(边没有权重)
示例:是否是朋友(只有是和否)

带权图:A5km— B(边有权重)
示例:城市间的距离

1.3 图的存储

阿西噶阿西:"图有两种存储方式:邻接矩阵和邻接表。"

邻接矩阵(适合稠密图):

int[][] graph = new int[n][n];
graph[i][j] = 1; // 表示i到j有边

邻接表(适合稀疏图):

List<List<Integer>> graph = new ArrayList<>();
graph.get(i).add(j); // 表示i到j有边

图示

flowchart LR
    A["图的存储"]
    B["邻接矩阵<br/>二维数组<br/>O(n²)空间"]
    C["邻接表<br/>List数组<br/>O(V+E)空间"]
    
    A --> B
    A --> C
    
    style B fill:#ffe6e6
    style C fill:#e1ffe1

二、BFS求最短路(无权图)

南北绿豆:"BFS是图论最基础的算法。"

2.1 题目

LeetCode 1091 - 二进制矩阵中的最短路径

给你一个 n × n 的二进制矩阵 grid 中,返回矩阵中最短畅通路径的长度。
如果不存在这样的路径,返回 -1 。

畅通路径是指从左上角 (0, 0) 到右下角 (n - 1, n - 1) 的路径,
该路径中所有访问的单元格都是 0 ,且所有相邻单元格应当在 8 个方向之一 上连通。

示例:
输入:grid = [[0,1],[1,0]]
输出:2

输入:grid = [[0,0,0],[1,1,0],[1,1,0]]
输出:4

输入:grid = [[1,0,0],[1,1,0],[1,1,0]]
输出:-1

2.2 思路分析

南北绿豆:"BFS的核心思想:一层一层扩散,第一次到达终点就是最短路。"

为什么BFS能求最短路?

阿西噶阿西:"因为BFS是按距离从近到远搜索,第一次到达的一定是最短的。"

图示

起点(0,0)
  ↓
第1层:距离1的所有点
  ↓
第2层:距离2的所有点
  ↓
...
第k层:到达终点

2.3 BFS模板

Java版本

public int bfs(int[][] grid) {
    Queue<int[]> queue = new LinkedList<>();
    boolean[][] visited = new boolean[n][m];
    
    // 起点入队
    queue.offer(new int[]{0, 0});
    visited[0][0] = true;
    int steps = 0;
    
    while (!queue.isEmpty()) {
        int size = queue.size();
        steps++; // 层数+1
        
        // 遍历当前层
        for (int i = 0; i < size; i++) {
            int[] curr = queue.poll();
            int x = curr[0], y = curr[1];
            
            // 到达终点
            if (x == n - 1 && y == m - 1) {
                return steps;
            }
            
            // 扩展8个方向
            for (int[] dir : directions) {
                int nx = x + dir[0];
                int ny = y + dir[1];
                
                if (isValid(nx, ny) && !visited[nx][ny]) {
                    queue.offer(new int[]{nx, ny});
                    visited[nx][ny] = true;
                }
            }
        }
    }
    
    return -1; // 无法到达
}

C++版本

int bfs(vector<vector<int>>& grid) {
    queue<pair<int, int>> q;
    vector<vector<bool>> visited(n, vector<bool>(m, false));
    
    q.push({0, 0});
    visited[0][0] = true;
    int steps = 0;
    
    while (!q.empty()) {
        int size = q.size();
        steps++;
        
        for (int i = 0; i < size; i++) {
            auto [x, y] = q.front();
            q.pop();
            
            if (x == n - 1 && y == m - 1) {
                return steps;
            }
            
            for (auto& dir : directions) {
                int nx = x + dir[0];
                int ny = y + dir[1];
                
                if (isValid(nx, ny) && !visited[nx][ny]) {
                    q.push({nx, ny});
                    visited[nx][ny] = true;
                }
            }
        }
    }
    
    return -1;
}

Python版本

def bfs(grid):
    from collections import deque
    
    queue = deque([(0, 0)])
    visited = set()
    visited.add((0, 0))
    steps = 0
    
    while queue:
        size = len(queue)
        steps += 1
        
        for _ in range(size):
            x, y = queue.popleft()
            
            if x == n - 1 and y == m - 1:
                return steps
            
            for dx, dy in directions:
                nx, ny = x + dx, y + dy
                
                if isValid(nx, ny) and (nx, ny) not in visited:
                    queue.append((nx, ny))
                    visited.add((nx, ny))
    
    return -1

三、Dijkstra最短路(带权图)

南北绿豆:"BFS只能处理无权图(或者所有边权重相同),带权图要用Dijkstra。"

3.1 题目

LeetCode 743 - 网络延迟时间

有 n 个网络节点,标记为 1 到 n。

给你一个列表 times,times[i] = [ui, vi, wi],表示从节点 ui 到节点 vi 的信号传递时间为 wi。

现在从某个节点 k 发出一个信号。需要多久才能使所有节点都收到信号?
如果不能使所有节点收到信号,返回 -1 。

示例:
输入:times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2
输出:2

3.2 思路分析

南北绿豆:"Dijkstra的核心思想:每次选择距离起点最近的未访问节点,松弛它的邻居。"

生活化理解

阿西噶阿西:"想象你从起点出发,手里有一张各城市的最短距离表。"

初始:
起点距离=0
其他城市距离=∞

每一轮:
1. 选距离最小的未访问城市
2. 从这个城市出发,更新邻居的距离
3. 标记这个城市已访问

哈吉米:"贪心选最近的,然后更新。"

3.3 Dijkstra执行过程

示例times = [[2,1,1],[2,3,1],[3,4,1]], n=4, k=2

图结构

  1
  ↑1
  2 →1→ 3 →1→ 4

执行过程

轮次选择节点dist数组更新操作
初始-[∞,∞,0,∞,∞]起点2距离=0
12(dist=0)[∞,1,0,1,∞]更新1(0+1=1),更新3(0+1=1)
21(dist=1)[∞,1,0,1,∞]1没有出边
33(dist=1)[∞,1,0,1,2]更新4(1+1=2)
44(dist=2)[∞,1,0,1,2]4没有出边

最终答案:max(dist) = 2

3.4 Dijkstra代码(堆优化)

Java版本

public int networkDelayTime(int[][] times, int n, int k) {
    // 建图:邻接表
    List<int[]>[] graph = new ArrayList[n + 1];
    for (int i = 1; i <= n; i++) {
        graph[i] = new ArrayList<>();
    }
    for (int[] time : times) {
        graph[time[0]].add(new int[]{time[1], time[2]}); // [邻居, 权重]
    }
    
    // dist[i]:起点到i的最短距离
    int[] dist = new int[n + 1];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[k] = 0;
    
    // 优先队列:按距离排序
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
    pq.offer(new int[]{k, 0}); // [节点, 距离]
    
    while (!pq.isEmpty()) {
        int[] curr = pq.poll();
        int node = curr[0];
        int distance = curr[1];
        
        // 已经找到更短的路径了,跳过
        if (distance > dist[node]) {
            continue;
        }
        
        // 松弛操作:更新邻居的距离
        for (int[] edge : graph[node]) {
            int neighbor = edge[0];
            int weight = edge[1];
            int newDist = dist[node] + weight;
            
            if (newDist < dist[neighbor]) {
                dist[neighbor] = newDist;
                pq.offer(new int[]{neighbor, newDist});
            }
        }
    }
    
    // 找最大距离
    int maxDist = 0;
    for (int i = 1; i <= n; i++) {
        if (dist[i] == Integer.MAX_VALUE) {
            return -1; // 有节点到不了
        }
        maxDist = Math.max(maxDist, dist[i]);
    }
    
    return maxDist;
}

C++版本

int networkDelayTime(vector<vector<int>>& times, int n, int k) {
    vector<vector<pair<int, int>>> graph(n + 1);
    for (auto& time : times) {
        graph[time[0]].push_back({time[1], time[2]});
    }
    
    vector<int> dist(n + 1, INT_MAX);
    dist[k] = 0;
    
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
    pq.push({0, k}); // {距离, 节点}
    
    while (!pq.empty()) {
        auto [distance, node] = pq.top();
        pq.pop();
        
        if (distance > dist[node]) continue;
        
        for (auto& [neighbor, weight] : graph[node]) {
            int newDist = dist[node] + weight;
            
            if (newDist < dist[neighbor]) {
                dist[neighbor] = newDist;
                pq.push({newDist, neighbor});
            }
        }
    }
    
    int maxDist = 0;
    for (int i = 1; i <= n; i++) {
        if (dist[i] == INT_MAX) return -1;
        maxDist = max(maxDist, dist[i]);
    }
    
    return maxDist;
}

Python版本

def networkDelayTime(times, n, k):
    import heapq
    from collections import defaultdict
    
    graph = defaultdict(list)
    for u, v, w in times:
        graph[u].append((v, w))
    
    dist = [float('inf')] * (n + 1)
    dist[k] = 0
    
    pq = [(0, k)]  # (距离, 节点)
    
    while pq:
        distance, node = heapq.heappop(pq)
        
        if distance > dist[node]:
            continue
        
        for neighbor, weight in graph[node]:
            newDist = dist[node] + weight
            
            if newDist < dist[neighbor]:
                dist[neighbor] = newDist
                heapq.heappush(pq, (newDist, neighbor))
    
    maxDist = max(dist[1:])
    return -1 if maxDist == float('inf') else maxDist

时间复杂度:O((V+E)logV),V是节点数,E是边数


四、拓扑排序

南北绿豆:"拓扑排序处理有向无环图DAG)的排序问题。"

4.1 题目

LeetCode 207 - 课程表

你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。

在选修某些课程之前需要一些先修课程。
先修课程按数组 prerequisites 给出,prerequisites[i] = [ai, bi] 表示如果要学习课程 ai 则必须先学习课程 bi 。

请你判断是否可能完成所有课程的学习?

示例:
输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。

输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,需要先完成课程 0 ;并且学习课程 0 之前,你需要先完成课程 1 。这是不可能的。

4.2 思路分析

南北绿豆:"这题本质:判断有向图是否有环。"

拓扑排序思路

  1. 统计每个节点的入度(有多少条边指向它)
  2. 把入度为0的节点入队(没有前置依赖)
  3. BFS遍历,每次取出入度为0的节点,将其邻居的入度-1
  4. 如果能遍历完所有节点,说明无环;否则有环

图示

课程关系:
0 → 1 → 3
    ↓
    2

入度统计:
0: 入度0(没有前置课程)
1: 入度1(需要先学0)
2: 入度1(需要先学1)
3: 入度1(需要先学1)

拓扑排序:
第1轮:0入队(入度0)
第2轮:1入队(0学完后,1的入度变0)
第3轮:2和3入队

顺序:0 → 1 → 2 → 3

如果有环

0 → 1
↑   ↓
└── 2

入度统计:
0: 入度1
1: 入度1
2: 入度1

所有节点入度都>0,没有入度为0的节点
无法开始拓扑排序 → 有环 ✗

4.3 代码实现

Java版本

public boolean canFinish(int numCourses, int[][] prerequisites) {
    // 建图
    List<Integer>[] graph = new ArrayList[numCourses];
    for (int i = 0; i < numCourses; i++) {
        graph[i] = new ArrayList<>();
    }
    
    int[] inDegree = new int[numCourses];
    
    for (int[] pre : prerequisites) {
        int from = pre[1];
        int to = pre[0];
        graph[from].add(to);
        inDegree[to]++;
    }
    
    // BFS:入度为0的节点入队
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; i++) {
        if (inDegree[i] == 0) {
            queue.offer(i);
        }
    }
    
    int count = 0; // 已学习的课程数
    
    while (!queue.isEmpty()) {
        int course = queue.poll();
        count++;
        
        // 邻居的入度-1
        for (int next : graph[course]) {
            inDegree[next]--;
            if (inDegree[next] == 0) {
                queue.offer(next);
            }
        }
    }
    
    return count == numCourses; // 能学完所有课程
}

C++版本

bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
    vector<vector<int>> graph(numCourses);
    vector<int> inDegree(numCourses, 0);
    
    for (auto& pre : prerequisites) {
        graph[pre[1]].push_back(pre[0]);
        inDegree[pre[0]]++;
    }
    
    queue<int> q;
    for (int i = 0; i < numCourses; i++) {
        if (inDegree[i] == 0) {
            q.push(i);
        }
    }
    
    int count = 0;
    
    while (!q.empty()) {
        int course = q.front();
        q.pop();
        count++;
        
        for (int next : graph[course]) {
            inDegree[next]--;
            if (inDegree[next] == 0) {
                q.push(next);
            }
        }
    }
    
    return count == numCourses;
}

Python版本

def canFinish(numCourses, prerequisites):
    from collections import defaultdict, deque
    
    graph = defaultdict(list)
    inDegree = [0] * numCourses
    
    for to, frm in prerequisites:
        graph[frm].append(to)
        inDegree[to] += 1
    
    queue = deque([i for i in range(numCourses) if inDegree[i] == 0])
    
    count = 0
    
    while queue:
        course = queue.popleft()
        count += 1
        
        for next_course in graph[course]:
            inDegree[next_course] -= 1
            if inDegree[next_course] == 0:
                queue.append(next_course)
    
    return count == numCourses

五、图论算法总结

5.1 三种算法对比

算法适用场景复杂度典型题目
BFS无权图最短路O(V+E)LeetCode 1091、102
Dijkstra带权图最短路(非负权)O((V+E)logV)LeetCode 743、787
拓扑排序有向无环图排序、判环O(V+E)LeetCode 207、210

5.2 识别技巧

阿西噶阿西

  • 看到最短路径、最少步数,想BFS或Dijkstra
  • 看到课程、依赖、先后顺序,想拓扑排序
  • 看到是否有环,想拓扑排序或DFS

5.3 核心要点

南北绿豆

  1. BFS用队列:层序遍历,第一次到达就是最短
  2. Dijkstra用优先队列:每次选距离最小的节点
  3. 拓扑排序用入度:BFS+入度为0的节点入队

哈吉米:"图论核心就是这三个算法。"


参考资料

  • 《算法第四版》- Robert Sedgewick
  • 《算法导论》- Thomas H. Cormen
  • LeetCode题解 - 图论专题