拓扑排序算法

MrHe··4 min read

给一堆“任务”或者说“节点”排个队。什么队呢?一个有向无环图(DAG, Directed Acyclic Graph)中,所有节点的线性序列。

这个序列有个硬性要求:如果图中有一条从 A 到 B 的边,那么在排好的队里,A 必须在 B 的前面。

说人话就是,你大学选课,想学《算法进阶》,就必须先学《数据结构》。这个先后顺序,就是拓扑序。想编译代码,得先把依赖的库给编译了。这个编译顺序,也是拓扑序。所以,只要看到问题里有“依赖关系”、“先后顺序”、“前提条件”这类词,脑子里就得把“拓扑排序”这四个大字给我亮起来。

还有一个关键点:只有有向无环图(DAG)才有拓扑排序。你想想,如果 A 依赖 B,B 又依赖 A,这不就死锁了吗?谁都别想先开始。所以,如果图里有环,那就没法排,直接告诉面试官:“这事儿办不成!”。

好,背景聊完,上题。通常题目会这么给你:

给定一个整数 numCourses,代表课程总数,从 0numCourses-1。再给你一个数组 prerequisites,其中 prerequisites[i] = [ai, bi] 表示若想学习课程 ai,你必须先学习课程 bi。请你返回一个可行的课程学习顺序。如果无法完成所有课程,则返回一个空数组。

这不就是拓扑排序的裸题吗?别慌,咱们从最直观的思路开始,一步步把它拿下。


解法一:广度优先搜索 (BFS) - 俗称“剥洋葱法”

我觉得这是最符合直觉的一种思路。咱们想啊,要安排一个学习顺序,最先能学的是什么课?肯定是那些没有任何先修课要求的课,对吧?

这在图里怎么表示?就是一个节点的入度(in-degree)为 0

这个思路就很自然了:

  1. 先把所有入度为 0 的节点(课程)找出来,它们是第一批可以学习的。

  2. 把这些课学完,然后呢?是不是有一些原本需要这些课作为先修课的后续课程,现在它们的“前置条件”就满足了一个?

  3. 对,学完一门课 A,所有依赖 A 的课程 BCD,它们的入度就可以减 1。

  4. 在减 1 的过程中,如果发现课程 B 的入度也变成 0 了,那说明 B 的所有先修课都学完了,它就成了下一批可以学习的课程。

  5. 我们不断重复这个过程:找到入度为 0 的课 -> 学掉 -> 更新后续课程的入度 -> 找到新的入度为 0 的课... 直到所有课程都学完。

这个过程,像不像一层一层剥洋葱?每次都把最外层(入度为0)的剥掉,露出新的一层。

怎么用数据结构实现这个“剥洋葱”的过程呢?

  • 一个数组 inDegree 记录每个节点的入度。

  • 一个队列 queue 存放所有当前入度为 0 的节点。

  • 一个邻接表 graph 存储图的结构,方便我们找到一个节点的所有后继节点。

具体流程:

  1. 建图和统计入度:遍历 prerequisites,构建邻接表 graph,同时填充 inDegree 数组。

  2. 初始化队列:遍历所有课程,把 inDegree 为 0 的课程全部扔进队列 queue

  3. BFS循环

    • 当队列不为空时,出队一个课程 c

    • c 加入结果列表。

    • 遍历 c 的所有后继课程 next_c(通过邻接表)。

    • next_c 的入度减 1。

    • 如果 next_c 的入度减为 0,说明它的前置条件都满足了,将其入队。

  4. 检查结果:循环结束后,检查结果列表中的课程数量是否等于总课程数 numCourses。如果相等,说明所有课程都学完了,返回结果;如果不等,说明图里有环,有些课程的入度永远无法变为 0,就没法入队,所以返回空数组。

这个方法也叫Kahn算法,听着高大上,其实就是这个朴素的道理。

class Solution {
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        // 1. 构建邻接表和入度数组
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) {
            graph.add(new ArrayList<>());
        }
        int[] inDegree = new int[numCourses];
        for (int[] p : prerequisites) {
            // p[1] -> p[0], 即学 p[0] 之前要先学 p[1]
            graph.get(p[1]).add(p[0]);
            inDegree[p[0]]++;
        }

        // 2. 将所有入度为0的节点入队
        Queue<Integer> queue = new LinkedList<>();
        for (int i = 0; i < numCourses; i++) {
            if (inDegree[i] == 0) {
                queue.offer(i);
            }
        }

        int[] result = new int[numCourses];
        int count = 0; // 记录已修课程的数量

        // 3. BFS过程
        while (!queue.isEmpty()) {
            int currentCourse = queue.poll();
            result[count++] = currentCourse;

            // 遍历当前课程的后继课程
            for (int nextCourse : graph.get(currentCourse)) {
                inDegree[nextCourse]--;
                // 如果后继课程的入度变为0,则入队
                if (inDegree[nextCourse] == 0) {
                    queue.offer(nextCourse);
                }
            }
        }

        // 4. 检查是否所有课程都已修完
        if (count == numCourses) {
            return result;
        }
        return new int[0]; // 存在环,无法完成
    }
}

解法二:深度优先搜索 (DFS) - 逆向思维的产物

BFS是从“起点”开始,找入度为0的。那DFS呢?咱们可以换个角度想。

最终排好序的列表,排在最后面的课程应该是什么样的?

它应该没有任何后继课程,或者说它的后继课程都已经排在它后面了。说白了,它不作为任何其他课程的先修课。在图里,就是出度为 0 的节点

这启发我们,可以从任意一个节点开始,做深度优先搜索,一直搜到底(搜到出度为0的节点)。这个“底”,就是拓扑序中比较靠后的节点。

这个过程有点像递归的“归”过程。

  1. 我们从一个节点 A 开始DFS。

  2. A 依赖 BB 依赖 C。我们的搜索路径是 A -> B -> C

  3. 当搜到 C 时,发现 C 没有依赖任何其他节点了,它已经走到了“底”。好,我们把 C 记录下来。

  4. 然后回溯到 BB 的所有依赖(只有C)都已经被访问并记录过了。说明 B 这条路也走完了,我们记录下 B

  5. 同理,回溯到 A,记录下 A

你看,我们记录的顺序是 C, B, A。而真正的拓扑序是 A -> B -> C。所以,DFS的后序遍历结果,反转一下,就是拓扑排序的结果。是不是有点意思?

但是!别忘了环。DFS怎么判断有环?

如果在DFS的路径上,你从节点 A 出发,走着走着,又回到了 A,那就有环了。所以我们需要一个标记来记录当前这次DFS“正在访问”的节点。

我们需要三种状态来标记一个节点:

  • 0 (UNVISITED): 还没访问过。

  • 1 (VISITING): 正在访问。说白了,这个节点在当前的递归栈里。

  • 2 (VISITED): 已访问过。这个节点以及它的所有后续节点都已经被彻底访问完了。

DFS流程:

  1. 建图:只需要邻接表 graph

  2. 状态数组 visited:初始化所有节点状态为 0

  3. 结果栈 stack:用一个栈来存储后序遍历的结果,栈顶到栈底就是拓扑序。

  4. 主循环:遍历所有课程,如果课程状态为 0,就从它开始进行DFS。

  5. DFS函数 dfs(course)

    • course 的状态置为 1 (VISITING)。

    • 遍历 course 的所有后继课程 next_c

      • 如果 next_c 的状态是 0,递归调用 dfs(next_c)。如果递归返回有环,直接一路返回有环。

      • 如果 next_c 的状态是 1,说明在当前路径上又遇到了这个点,发现环!,直接返回有环。

      • 如果 next_c 的状态是 2,说明它已经被别的路径访问完了,是安全的,跳过。

    • 如果循环正常结束,说明 course 的所有后继都访问完了。

    • course 的状态置为 2 (VISITED)。

    • course 压入栈中。

    • 返回无环。

  6. 检查结果:如果在主循环中任何一次 dfs调用返回了有环,则拓扑排序失败。否则,将栈中的元素依次弹出,就是最终结果。

class Solution {
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) {
            graph.add(new ArrayList<>());
        }
        for (int[] p : prerequisites) {
            graph.get(p[1]).add(p[0]);
        }

        // 0: unvisited, 1: visiting, 2: visited
        int[] visited = new int[numCourses];
        Deque<Integer> stack = new ArrayDeque<>(); // 用栈来存储后序遍历结果

        for (int i = 0; i < numCourses; i++) {
            if (visited[i] == 0) {
                if (hasCycle(i, graph, visited, stack)) {
                    // 如果有环,直接返回空数组
                    return new int[0];
                }
            }
        }

        int[] result = new int[numCourses];
        for (int i = 0; i < numCourses; i++) {
            result[i] = stack.pop();
        }
        return result;
    }

    private boolean hasCycle(int course, List<List<Integer>> graph, int[] visited, Deque<Integer> stack) {
        visited[course] = 1; // 标记为正在访问

        for (int nextCourse : graph.get(course)) {
            if (visited[nextCourse] == 0) {
                if (hasCycle(nextCourse, graph, visited, stack)) {
                    return true;
                }
            } else if (visited[nextCourse] == 1) {
                // 在当前递归路径上再次遇到,说明有环
                return true;
            }
        }

        visited[course] = 2; // 标记为已访问
        stack.push(course); // 入栈
        return false;
    }
}

解法三:DFS的另一种实现 - 使用两个Set

上面那个三状态的DFS是标准写法,但有时候用 Set 来实现可能更清晰,能帮你更深刻地理解“当前路径”和“已访问”的区别。这个解法本质和解法二是一样的,只是换了数据结构,算是对思维的一个拓展。

  • visited Set: 存所有已访问过的节点(对应状态2)。作用是剪枝,如果一个节点已经确认无环并且处理完毕,就没必要再从它开始走一遍DFS了。

  • onPath Set: 存当前递归路径上的节点(对应状态1)。它的作用就是检测环。

DFS流程 (Set版本):

  1. 建图。

  2. 初始化 visitedonPath 两个 HashSet,以及结果栈 stack

  3. 主循环遍历所有课程,如果课程不在 visited 里,则对其 dfs

  4. DFS函数 dfs(course)

    • course 加入 onPathvisited

    • 遍历其邻居 next_c:

      • 如果 next_conPath 中,说明有环,返回 true

      • 如果 next_c 不在 visited里,对其递归 dfs,如果递归返回有环,也返回 true

    • 回溯:将 courseonPath 中移除。这是关键,因为当 course 的递归调用结束时,它就不在“当前路径”上了。

    • course 压栈。

    • 返回无环 (false)。

class Solution {
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) {
            graph.add(new ArrayList<>());
        }
        for (int[] p : prerequisites) {
            graph.get(p[1]).add(p[0]);
        }

        Set<Integer> visited = new HashSet<>(); // 存已访问过的节点
        Set<Integer> onPath = new HashSet<>();  // 存当前递归路径上的节点
        Deque<Integer> stack = new ArrayDeque<>();

        for (int i = 0; i < numCourses; i++) {
            if (!visited.contains(i)) {
                if (hasCycle(i, graph, visited, onPath, stack)) {
                    return new int[0];
                }
            }
        }

        int[] result = new int[numCourses];
        for (int i = 0; i < numCourses; i++) {
            result[i] = stack.pop();
        }
        return result;
    }

    private boolean hasCycle(int course, List<List<Integer>> graph, Set<Integer> visited, Set<Integer> onPath, Deque<Integer> stack) {
        visited.add(course);
        onPath.add(course);

        for (int nextCourse : graph.get(course)) {
            if (onPath.contains(nextCourse)) {
                return true; // 发现环
            }
            if (!visited.contains(nextCourse)) {
                if (hasCycle(nextCourse, graph, visited, onPath, stack)) {
                    return true;
                }
            }
        }

        onPath.remove(course); // 回溯
        stack.push(course);
        return false;
    }
}

这个版本和三状态数组的版本在逻辑上是等价的,但 Setadd, contains, remove 操作语义更明确,有时写起来更舒服。

总结一下

咱们今天聊了拓扑排序的两种主流思路,三种实现:

  1. BFS (Kahn算法):正向思维,像剥洋葱。从入度为0的节点开始,一层层向外扩展。实现起来是迭代,比较好理解。

  2. DFS:逆向思维,像探险。深入到最底层(没有出度),然后回溯时记录路径。记录顺序和拓扑序是反的,所以要倒序输出。实现上是递归,关键在于用三种状态(或两个Set)来正确地检测环。

这两个方法时间复杂度都是 O(V+E),V是顶点数,E是边数,因为每个节点和每条边都只访问一次。空间复杂度也是 O(V+E),用于存储图、队列/栈、以及状态数组。