对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 207. 课程表
1. 题目描述
你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1。
在选修某些课程之前需要先修一些课程。先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则必须先学习课程 bi。
请判断是否可能完成所有课程的学习?如果可以,返回 true;否则,返回 false。
示例 1:
输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0。这是可能的。
示例 2:
输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0;并且学习课程 0 之前,你需要完成课程 1。这是不可能的。
2. 问题分析
2.1 核心问题转化
这是一个典型的拓扑排序问题,可以转化为:
- 将课程看作有向图中的节点
- 将先修关系
[a, b]看作从b指向a的有向边(b是a的先修课) - 问题等价于:判断有向图中是否存在环
- 如果存在环,则无法完成所有课程
- 如果不存在环,则可以完成(存在拓扑排序)
2.2 前端开发中的实际应用
- 任务调度:前端构建工具(如Webpack)中的任务依赖关系
- 组件加载顺序:Vue/React中组件的依赖加载
- 状态管理:Redux中异步action的执行顺序
- 路由守卫:导航守卫的执行顺序依赖
3. 解题思路
3.1 思路一:拓扑排序(Kahn算法,BFS实现)
时间复杂度:O(V+E) ,空间复杂度:O(V+E)
- V 是课程数(节点数),E 是先修条件数(边数)
- 最优解之一,直观且易于实现
算法步骤:
- 构建邻接表表示图
- 计算每个节点的入度(有多少先修课)
- 将入度为0的节点加入队列
- BFS遍历:每次取出入度为0的节点,将其后继节点的入度减1
- 如果所有节点都被访问过,说明无环;否则有环
3.2 思路二:深度优先搜索(DFS判断环)
时间复杂度:O(V+E) ,空间复杂度:O(V+E)
- 同样是最优时间复杂度,但需要记录三种状态
- 递归深度可能较大,需要注意栈溢出
算法步骤:
- 构建邻接表
- 为每个节点维护三种状态:
- 0:未访问
- 1:访问中(当前DFS路径中)
- 2:已访问完成
- DFS遍历,如果遇到状态为1的节点,说明有环
4. 代码实现
4.1 拓扑排序(BFS)实现
javascript
/**
* 拓扑排序(Kahn算法)- BFS实现
* @param {number} numCourses - 课程总数
* @param {number[][]} prerequisites - 先修课程关系
* @return {boolean} - 是否能完成所有课程
*/
var canFinish = function(numCourses, prerequisites) {
// 1. 初始化邻接表和入度数组
const graph = new Array(numCourses).fill(0).map(() => []);
const inDegree = new Array(numCourses).fill(0);
// 2. 构建图和入度数组
for (const [course, prereq] of prerequisites) {
graph[prereq].push(course); // prereq -> course
inDegree[course]++; // course的入度+1
}
// 3. 初始化队列,将所有入度为0的节点加入
const queue = [];
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
queue.push(i);
}
}
// 4. BFS遍历
let visitedCount = 0;
while (queue.length > 0) {
const current = queue.shift();
visitedCount++;
// 遍历当前节点的所有后继节点
for (const neighbor of graph[current]) {
// 将后继节点的入度减1
inDegree[neighbor]--;
// 如果入度变为0,加入队列
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
}
}
// 5. 判断是否所有节点都被访问
return visitedCount === numCourses;
};
// 测试用例
console.log(canFinish(2, [[1,0]])); // true
console.log(canFinish(2, [[1,0],[0,1]])); // false
步骤分解说明:
- 构建邻接表 :
graph[prereq]存储所有以prereq为先修课的课程 - 计算入度 :
inDegree[course]表示课程course需要先修多少门课 - BFS遍历:从入度为0的课程开始学习,每学完一门课,将其后继课程的入度减1
- 判断结果:如果所有课程都能学到(入度都变为0),则返回true
4.2 深度优先搜索(DFS)实现
javascript
/**
* DFS判断图中是否有环
* @param {number} numCourses - 课程总数
* @param {number[][]} prerequisites - 先修课程关系
* @return {boolean} - 是否能完成所有课程
*/
var canFinishDFS = function(numCourses, prerequisites) {
// 1. 构建邻接表
const graph = new Array(numCourses).fill(0).map(() => []);
for (const [course, prereq] of prerequisites) {
graph[prereq].push(course);
}
// 2. 状态数组:0=未访问,1=访问中,2=已访问完成
const state = new Array(numCourses).fill(0);
/**
* 深度优先搜索
* @param {number} node - 当前节点
* @return {boolean} - 是否有环(true表示有环)
*/
const hasCycle = (node) => {
// 如果当前节点正在访问中,说明有环
if (state[node] === 1) return true;
// 如果已经访问完成,直接返回
if (state[node] === 2) return false;
// 标记为访问中
state[node] = 1;
// 递归访问所有后继节点
for (const neighbor of graph[node]) {
if (hasCycle(neighbor)) {
return true;
}
}
// 标记为已访问完成
state[node] = 2;
return false;
};
// 3. 对每个未访问的节点进行DFS
for (let i = 0; i < numCourses; i++) {
if (state[i] === 0) {
if (hasCycle(i)) {
return false; // 有环,无法完成
}
}
}
return true; // 无环,可以完成
};
// 测试用例
console.log(canFinishDFS(4, [[1,0],[2,1],[3,2]])); // true
console.log(canFinishDFS(3, [[0,1],[1,2],[2,0]])); // false
状态机说明:
- 状态0(未访问):节点尚未开始处理
- 状态1(访问中) :节点正在当前DFS路径中被处理
- 如果DFS中再次遇到状态1的节点,说明存在环
- 状态2(已访问完成):节点及其所有后继节点都已处理完成
5. 实现思路对比
| 特性 | 拓扑排序(BFS) | 深度优先搜索(DFS) |
|---|---|---|
| 时间复杂度 | O(V+E) | O(V+E) |
| 空间复杂度 | O(V+E) | O(V+E) |
| 实现难度 | 中等 | 中等 |
| 优势 | 1. 直观易懂 2. 天然得到拓扑排序结果 3. 递归深度小 | 1. 代码简洁 2. 一次DFS即可判断环 3. 更容易记录路径 |
| 劣势 | 1. 需要额外队列 2. 需要维护入度数组 | 1. 递归可能导致栈溢出 2. 状态管理稍复杂 |
| 适用场景 | 需要拓扑排序结果时 | 需要记录环的具体路径时 |
| 前端应用 | 构建工具任务调度 | 组件依赖关系检查 |
6. 总结
6.1 通用解题模板
拓扑排序(BFS)模板:
javascript
function topologicalSort(numNodes, edges) {
// 1. 构建图和入度数组
const graph = Array(numNodes).fill(0).map(() => []);
const inDegree = Array(numNodes).fill(0);
for (const [u, v] of edges) {
graph[v].push(u); // v -> u
inDegree[u]++;
}
// 2. 初始化队列
const queue = [];
for (let i = 0; i < numNodes; i++) {
if (inDegree[i] === 0) queue.push(i);
}
// 3. BFS
let count = 0;
const result = [];
while (queue.length) {
const node = queue.shift();
result.push(node);
count++;
for (const neighbor of graph[node]) {
inDegree[neighbor]--;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
}
}
// 4. 判断是否有环
return count === numNodes ? result : [];
}
DFS检测环模板:
javascript
function hasCycleDFS(numNodes, edges) {
const graph = Array(numNodes).fill(0).map(() => []);
for (const [u, v] of edges) {
graph[v].push(u);
}
const state = Array(numNodes).fill(0); // 0:未访问, 1:访问中, 2:已访问
function dfs(node) {
if (state[node] === 1) return true;
if (state[node] === 2) return false;
state[node] = 1;
for (const neighbor of graph[node]) {
if (dfs(neighbor)) return true;
}
state[node] = 2;
return false;
}
for (let i = 0; i < numNodes; i++) {
if (state[i] === 0 && dfs(i)) {
return false; // 有环
}
}
return true; // 无环
}
6.2 类似题目推荐
- LeetCode 210. 课程表 II - 本题的进阶版,要求返回拓扑排序结果
- LeetCode 269. 火星词典 - 拓扑排序在字典序问题中的应用
- LeetCode 444. 序列重建 - 拓扑排序的验证问题
- LeetCode 1136. 平行课程 - 拓扑排序的层数计算
- LeetCode 1462. 课程表 IV - 课程表的可达性问题