图论基础:图的表示、拓扑排序与经典例题

说实话,图论这块内容,我当年学的时候也觉得挺抽象的。一堆节点和边,画在纸上还行,一写代码就懵。但后来做项目多了才发现,图这玩意儿无处不在——社交网络的好友关系、地图导航的路径规划、甚至你点外卖时的配送路线优化,背后都是图在干活。

今天咱们就聊聊图论里最基础、也最实用的几个点。我会结合自己踩过的坑,帮你把这块啃下来。

图的表示方式:邻接矩阵 vs 邻接表

图怎么存?这是写任何图算法前必须想清楚的事。我个人习惯用两种方式,各有各的适用场景。

表示方式 空间复杂度 判断边是否存在 遍历邻居 适用场景
邻接矩阵 O(V²) O(1) O(V) 稠密图、小规模图
邻接表 O(V+E) O(degree) O(degree) 稀疏图、大规模图

邻接矩阵说白了就是一个二维数组,graph[i][j] = 1 表示 i 到 j 有边。优点是判断两点是否相连特别快,缺点是太费内存。你想想看,10000 个节点,光矩阵就得 1 亿个格子,这谁顶得住?

邻接表就聪明多了——每个节点只存它自己的邻居。用 List<Integer>[] 或者 Map<Integer, List<Integer>> 都行。我在项目中几乎只用邻接表,因为实际场景的图基本都是稀疏的。

核心要点:面试和竞赛中,90% 的情况用邻接表就够了。除非题目明确说节点数很少(比如 n ≤ 100),否则别碰邻接矩阵。

拓扑排序:有向无环图的线性化

拓扑排序,说白了就是给有向无环图(DAG)排个序,保证每条边的起点都在终点前面。这玩意儿在任务调度、课程安排、编译依赖里特别常见。

实现方式有两种:Kahn 算法(BFS 思路)和 DFS 后序遍历。我个人更推荐 Kahn 算法,因为它直观,而且能顺便检测环。

// Kahn 算法模板
public int[] topologicalSort(int n, int[][] edges) {
    // 1. 建图 + 统计入度
    List<Integer>[] graph = new ArrayList[n];
    int[] indegree = new int[n];
    for (int i = 0; i < n; i++) graph[i] = new ArrayList<>();
    for (int[] edge : edges) {
        int from = edge[0], to = edge[1];
        graph[from].add(to);
        indegree[to]++;
    }
    
    // 2. 入度为 0 的节点入队
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < n; i++) {
        if (indegree[i] == 0) queue.offer(i);
    }
    
    // 3. BFS 拓扑排序
    int[] result = new int[n];
    int idx = 0;
    while (!queue.isEmpty()) {
        int cur = queue.poll();
        result[idx++] = cur;
        for (int next : graph[cur]) {
            indegree[next]--;
            if (indegree[next] == 0) queue.offer(next);
        }
    }
    
    // 4. 检查是否有环
    return idx == n ? result : new int[0];
}
小技巧:如果题目要求输出字典序最小的拓扑序,把 Queue 换成 PriorityQueue 就行。我曾经在面试时被问到这个变种,还好提前想过。

经典例题一:课程表 II

这道题是 LeetCode 第 210 题,也是拓扑排序的经典应用。题目说你要修 n 门课,有些课有前置课程,让你返回一个合理的修课顺序。

嗯,这里要注意:题目可能给的是有环的图——比如 A 依赖 B,B 又依赖 A,那就没法排了。所以拓扑排序的结果长度如果小于 n,说明有环,直接返回空数组。

我当初刷这道题时犯过一个错:把入度数组初始化为 0,但忘了处理节点编号从 0 开始还是从 1 开始。后来养成了习惯,先看题目说明,再动手写代码。

// 课程表 II 的完整解法
public int[] findOrder(int numCourses, int[][] prerequisites) {
    // 建图
    List<Integer>[] graph = new ArrayList[numCourses];
    int[] indegree = new int[numCourses];
    for (int i = 0; i < numCourses; i++) graph[i] = new ArrayList<>();
    for (int[] pre : prerequisites) {
        int from = pre[1], to = pre[0];
        graph[from].add(to);
        indegree[to]++;
    }
    
    // 入度为 0 的节点入队
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; i++) {
        if (indegree[i] == 0) queue.offer(i);
    }
    
    // BFS
    int[] result = new int[numCourses];
    int idx = 0;
    while (!queue.isEmpty()) {
        int cur = queue.poll();
        result[idx++] = cur;
        for (int next : graph[cur]) {
            indegree[next]--;
            if (indegree[next] == 0) queue.offer(next);
        }
    }
    
    return idx == numCourses ? result : new int[0];
}
避坑指南:我曾经在项目里用拓扑排序做任务调度,结果线上出了 bug。排查半天发现是图里有环,但我的代码没检测出来。从那以后,我每次写完拓扑排序都会加一句 if (idx != n) throw new RuntimeException("存在环");

经典例题二:克隆图

LeetCode 第 133 题,克隆图。这题看着简单,其实暗藏玄机。图里可能有环,所以不能简单递归复制,否则会死循环。

解法是用一个 HashMap 做缓存,记录已经克隆过的节点。每次遇到一个新节点,先创建它的副本,存到 map 里,然后再递归处理它的邻居。

// 克隆图的 DFS 解法
Map<Node, Node> visited = new HashMap<>();

public Node cloneGraph(Node node) {
    if (node == null) return null;
    
    // 如果已经克隆过,直接返回
    if (visited.containsKey(node)) {
        return visited.get(node);
    }
    
    // 克隆当前节点
    Node clone = new Node(node.val, new ArrayList<>());
    visited.put(node, clone);
    
    // 递归克隆邻居
    for (Node neighbor : node.neighbors) {
        clone.neighbors.add(cloneGraph(neighbor));
    }
    
    return clone;
}
个人经验:克隆图这题,BFS 也能做,但我更习惯 DFS,代码更简洁。不过要注意,如果图特别深(比如一条长链),DFS 可能栈溢出。这时候用 BFS 更安全。

知识体系总览

下面这张图是我自己整理的图论基础知识结构,帮你快速建立整体认知。

图论基础 图的表示 邻接矩阵 邻接表 拓扑排序 Kahn算法 DFS后序 经典例题 课程表II 克隆图 应用场景 任务调度 课程安排 编译依赖

图论这块,说白了就是「节点 + 边」的学问。你只要把图的表示搞明白,拓扑排序的模板背熟,课程表 II 和克隆图这两道题吃透,面试里遇到图论题基本就不慌了。

我记得自己第一次在面试里写拓扑排序时,手都在抖。后来练了十几道题,闭着眼都能写出来。你也别急,多练几遍,肌肉记忆就形成了。


公众号:蓝海资料掘金营,微信deep3321