数据结构与算法系列
数据结构与算法之哈希表
数据结构与算法之跳跃表
数据结构与算法之字典树
数据结构与算法之2-3树
数据结构与算法之平衡二叉树
数据结构与算法之十大经典排序
数据结构与算法之二分查找三模板
数据结构与算法之动态规划
数据结构与算法之回溯算法
数据结构与算法之Morris算法
数据结构与算法之贪心算法
数据结构与算法之拓扑排序
目录
数据结构与算法之拓扑排序
前言
今天忽然想起了去年华为软挑的题目,与图论有关,其中也涉及到了一部分拓扑排序的知识,然后本来想写一些BFS和DFS总结的一些套路,忽然觉得不如直接拓扑排序讲解一下,也是甚好,那么今天就介绍一下拓扑排序吧。
定义
以下是拓扑排序在维基百科上的定义。
拓扑排序(Topological sorting)
在计算机科学领域,有向图的拓扑排序是对其顶点的一种线性排序,使得对于从顶点u到顶点v的每个有向边uv,u在排序中都在v之前。
例如,图形的顶点可以表示要执行的任务(Activity),并且边可以表示一个任务必须在另一个任务之前执行的约束;在这个应用中,拓扑排序只是一个有效的任务顺序。
当且仅当图中没有定向环时(即有向无环图),才有可能进行拓扑排序。
任何有向无环图至少有一个拓扑排序。已知有算法可以在线性时间内,构建任何有向无环图的拓扑排序。
从拓扑排序的定义可以知晓,这一算法的作用是两个,第一,用来梳理图顶点的指向顺序;第二,可以用来判断图是否为有向无环图(DAG,Directed Acyclic Graph)。此外,拓扑排序并不是一个排序算法,而是给出一个任务执行的线性顺序(不唯一
)。
名词解释
针对前文定义以及后文需要,这里加入一些图论中的名词解释。
- 前驱活动:拓扑排序中的定义提到有向边用来表示任务之间的约束,而前驱活动即为要执行某一任务的前驱任务。
- 入度:顶点p的入度指的是有其余顶点指向顶点p的边总数
- 出度:顶点p的出度指的是顶点p指向其余顶点的边总数
- 有向无环图:在图论中,如果一个有向图从任意顶点出发无法经过若干条边回到该点,则这个图是一个有向无环图(DAG)。
算法流程
拓扑排序有两种算法实现。
卡恩算法
卡恩算法的步骤如下:
- 假设L为最后结果集合,先找到入度为0的顶点放入到L中,去除掉这些顶点的边(出度)
- 重复步骤1直到找不出入度为0的顶点。
- 比较L中顶点个数与图中节点总数是否一致。
这么一看,卡恩算法其实就是BFS的操作先遍历所有出度为0的顶点,然后遍历出度为0的所有出度指向的顶点,以此类推。
2. DFS(深度优先搜索)
深度优先搜索算法就比较随意了,其实应该称作为DFS+回溯算法。
从一个顶点开始,沿着顶点出度的某一路径进行深度优先搜索访问
- 一直访问到到出度为0的顶点后往前回溯,继续其他出度顶点的访问。
- 如果遇到顶点出度指向是路径中已经包含的顶点,那么说明有环,无法进行拓扑排序。
下面就举一个典型例子来进行讲解。
拓扑排序例题
课程表II
课程表II是剑指offer中选出的比较有特殊的贪心算法的题目。
问题描述
力扣
现在你总共有 n 门课需要选,记为 0 到 n-1。
在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
给定课程总量以及它们的先决条件,返回你为了学完所有课程所安排的学习顺序。
问题分析
题目是典型的拓扑排序,题目意思很明显了,就是想让我们使用BFS(卡恩算法去解决),这里我们还是分别给出两种算法的代码,供大家去参考一下。
二者共同的地方都需要先建立我们的有向图(存储我们的有向图)。
随后,
卡恩算法需要关注入度为0顶点的出度。
DFS算法需要注意深度搜素时顶点状态的变化。
卡恩算法
class Solution {
// 存储有向图
List<List<Integer>> edges;
// 存储每个节点的入度
int[] indegree;
// 存储答案
int[] result;
int index;
public int[] findOrder(int numCourses, int[][] prerequisites) {
//建图
edges = new ArrayList<List<Integer>>();
for (int i = 0; i < numCourses; ++i) {
edges.add(new ArrayList<Integer>());
}
//初始化入度和结果数组
indegree = new int[numCourses];
result = new int[numCourses];
for (int[] vertex : prerequisites) {
edges.get(vertex[1]).add(vertex[0]);
indegree[vertex[0]]++;
}
//BFS使用队列形式实现
Queue<Integer> queue = new LinkedList<Integer>();
// 将所有入度为 0 的节点放入队列中
for (int i = 0; i < numCourses; ++i) {
if (indegree[i] == 0) {
queue.offer(i);
}
}
index = 0;
while (!queue.isEmpty()) {
// 逐个取出入度为0的节点u
int u = queue.poll();
// 放入答案中
result[index] = u;
index++;
for (int v: edges.get(u)) {//取出度指向顶点v
indegree[v]--;
// 再去除掉u->v的边之后,如果相邻节点v的入度为0,同样加入到队列中
if (indegree[v] == 0) {
queue.offer(v);
}
}
}
if (index != numCourses) {
return new int[0];
}
return result;
}
}
DFS代码
DFS算法相对简单,就直接使用力扣官方的答案给大家咯。
//作者:LeetCode-Solution
//链接:https://leetcode-cn.com/problems/course-schedule-ii/solution/ke-cheng-biao-ii-by-leetcode-solution/
class Solution {
// 存储有向图
List<List<Integer>> edges;
// 标记每个节点的状态:0=未搜索,1=搜索中,2=已完成
int[] visited;
// 用数组来模拟栈,下标 n-1 为栈底,0 为栈顶
int[] result;
// 判断有向图中是否有环
boolean valid = true;
// 栈下标
int index;
public int[] findOrder(int numCourses, int[][] prerequisites) {
edges = new ArrayList<List<Integer>>();
for (int i = 0; i < numCourses; ++i) {
edges.add(new ArrayList<Integer>());
}
visited = new int[numCourses];
result = new int[numCourses];
index = numCourses - 1;
for (int[] info : prerequisites) {
edges.get(info[1]).add(info[0]);
}
// 每次挑选一个「未搜索」的节点,开始进行深度优先搜索
for (int i = 0; i < numCourses && valid; ++i) {
if (visited[i] == 0) {
dfs(i);
}
}
if (!valid) {
return new int[0];
}
// 如果没有环,那么就有拓扑排序
return result;
}
public void dfs(int u) {
// 将节点标记为「搜索中」
visited[u] = 1;
// 搜索其相邻节点
// 只要发现有环,立刻停止搜索
for (int v: edges.get(u)) {
// 如果「未搜索」那么搜索相邻节点
if (visited[v] == 0) {
dfs(v);
if (!valid) {
return;
}
}
// 如果「搜索中」说明找到了环
else if (visited[v] == 1) {
valid = false;
return;
}
}
// 将节点标记为「已完成」
visited[u] = 2;
// 将节点入栈
result[index--] = u;
}
}
总结
拓扑排序是图论中进行有向无环图判断的最常用的方法。而实现拓扑排序又有着两种实现方法,卡恩算法(BFS)和DFS算法。在拓扑排序中,两种算法都有着自己的注意点。卡恩算法需要注意取出入度为0顶点之后,这些顶点出度的删除;DFS算法则需要在回溯和路径深入过程中访问顶点的状态变化。
如有兴趣,可以关注我的公众号,每周和你一起修炼数据结构与算法。