一、引言:从迷宫到算法的奇妙联系
玩过迷宫游戏吗?当你站在迷宫入口,眼前有三条岔路。你选了最左边的路一直走,走到尽头发现是死胡同 —— 这时候你不会傻站着,而是退回到最近的岔路口,换中间的路继续尝试。
这种 "一条路走到黑,不通就退回换路" 的逻辑,就是DFS(深度优先搜索) 的核心思想。作为前端工程师,你可能在这些场景用到它:
-
树形组件的递归渲染(比如多级导航菜单)
-
表单验证的深层规则校验
-
前端可视化中的节点遍历(如力导向图的关系探索)
-
LeetCode 算法题(二叉树遍历、组合总和等高频考点)
今天我们用 JS 手把手实现 DFS,从基础到实战,让你既能在业务中用起来,也能在面试中讲清楚。
二、DFS 算法基础概念
(一)DFS 定义:什么是 "深度优先"?
DFS(Depth-First Search) 是一种从起点出发,优先沿着一条路径 "挖到底",遇到死胡同再回溯到上一个岔路口选择新路径的搜索算法。
可以用一句话概括其流程:
访问一个未探索的节点,标记它为已探索,然后递归地探索它的所有未探索邻节点。
用生活例子类比:
- 像走迷宫时用粉笔画记号(避免重复走)
- 像剥洋葱时从外层一片摸到芯,再换另一片
(二)与 BFS 对比:什么时候选 DFS?
很多人会把 DFS 和 BFS(广度优先搜索)搞混,这里用表格对比核心差异:
| 维度 | DFS(深度优先) | BFS(广度优先) |
|---|---|---|
| 搜索方式 | 沿路径深入,遇阻回溯 | 先探索所有邻节点,再深入下一层 |
| 数据结构 | 栈(递归本质是调用栈) | 队列 |
| 适用场景 | 路径搜索、连通性判断、拓扑排序 | 最短路径、层次遍历(如朋友圈层数) |
| 空间复杂度 | O (h)(h 为深度,最坏情况 O (n)) | O (w)(w 为最大宽度,最坏情况 O (n)) |
| JS 实现特点 | 递归简洁,非递归需手动维护栈 | 需手动维护队列 |
简单说:如果需要 "找一条可行路径"(不管是不是最短),用 DFS;如果需要 "找最短路径",用 BFS。
三、DFS 算法的 JS 实现:递归与非递归
(一)递归实现:最符合直觉的写法
递归是实现 DFS 最自然的方式 —— 因为递归本身就是栈的逻辑(函数调用栈)。
核心步骤:
-
标记当前节点为 "已访问"(避免重复访问)
-
处理当前节点(如打印、收集数据)
-
遍历当前节点的所有未访问邻节点,递归调用 DFS
代码示例(以树结构为例) :
// 定义树节点结构
class TreeNode {
constructor(val, children = []) {
this.val = val;
this.children = children; // 子节点数组(多叉树)
}
}
// DFS递归实现
function dfsRecursive(node, visited = new Set()) {
// 终止条件:节点为空或已访问
if (!node || visited.has(node)) return;
// 1. 标记为已访问
visited.add(node);
// 2. 处理当前节点(这里简单打印值)
console.log("访问节点:", node.val);
// 3. 递归遍历所有子节点
for (const child of node.children) {
dfsRecursive(child, visited); // 递归深入
}
}
// 测试:构建一棵简单的树
const root = new TreeNode("A", [
new TreeNode("B", [new TreeNode("D"), new TreeNode("E")]),
new TreeNode("C", [new TreeNode("F")])
]);
// 执行DFS
dfsRecursive(root);
// 输出顺序:A → B → D → E → C → F(典型的深度优先顺序)
关键点:
visited集合必须通过引用传递(或用闭包),否则递归中会重新创建- 多叉树用
children数组存储子节点,二叉树则是left和right
(二)非递归实现:用栈模拟递归过程
递归虽简单,但在 JS 中存在调用栈深度限制(默认约 10000 层),深层递归可能导致栈溢出。这时候需要用手动维护栈的方式实现非递归 DFS。
核心步骤:
-
创建栈,将起点节点入栈
-
当栈不为空时,弹出栈顶节点
-
若节点未访问:
-
标记为已访问并处理
-
将其邻节点逆序入栈(保证访问顺序和递归一致)
-
代码示例(同上树结构) :
function dfsNonRecursive(root) {
if (!root) return;
const stack = [root]; // 用数组模拟栈
const visited = new Set();
while (stack.length > 0) {
// 1. 弹出栈顶节点
const node = stack.pop();
// 2. 跳过已访问节点
if (visited.has(node)) continue;
// 3. 标记并处理当前节点
visited.add(node);
console.log("访问节点:", node.val);
// 4. 子节点逆序入栈(关键!保证左节点先被访问)
// 因为栈是"后进先出",逆序入栈才能让第一个子节点先弹出
for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i];
if (!visited.has(child)) {
stack.push(child);
}
}
}
}
// 测试(同上树结构)
dfsNonRecursive(root);
// 输出顺序:A → B → D → E → C → F(和递归结果完全一致)
为什么子节点要逆序入栈?
假设节点 B 的子节点是 [D, E]:
- 正序入栈:先推 D 再推 E → 栈内是 [E, D] → 弹出 E 先访问(不符合预期)
- 逆序入栈:先推 E 再推 D → 栈内是 [D, E] → 弹出 D 先访问(和递归顺序一致)
四、实际案例应用:从算法到业务
(一)案例 1:迷宫寻路(经典 DFS 场景)
迷宫可以抽象为二维数组:0表示可走,1表示墙壁,2表示终点。我们需要找到从起点 (0,0) 到终点 (3,3) 的一条路径。
需求:输出路径坐标(如 [(0,0), (0,1), ..., (3,3)])
// 迷宫数据:4x4网格,0=可走,1=墙,2=终点
const maze = [
[0, 1, 0, 0],
[0, 1, 0, 1],
[0, 0, 0, 1],
[1, 1, 0, 2]
];
// 方向向量:上、右、下、左(顺时针)
const directions = [
[-1, 0], // 上
[0, 1], // 右
[1, 0], // 下
[0, -1] // 左
];
// DFS寻路函数
function findPath(maze, startX, startY) {
const rows = maze.length;
const cols = maze[0].length;
const visited = Array(rows).fill().map(() => Array(cols).fill(false)); // 记录已访问
const path = []; // 存储当前路径
// 递归探索
function dfs(x, y) {
// 终止条件1:越界或撞墙或已访问
if (x < 0 || x >= rows || y < 0 || y >= cols || maze[x][y] === 1 || visited[x][y]) {
return false;
}
// 终止条件2:找到终点
if (maze[x][y] === 2) {
path.push([x, y]); // 加入终点
return true;
}
// 标记当前位置为已访问,加入路径
visited[x][y] = true;
path.push([x, y]);
// 尝试四个方向
for (const [dx, dy] of directions) {
const nextX = x + dx;
const nextY = y + dy;
// 如果某个方向能找到终点,直接返回true(不再探索其他方向)
if (dfs(nextX, nextY)) {
return true;
}
}
// 如果四个方向都走不通,回溯(从路径中移除当前位置)
path.pop();
return false;
}
// 从起点开始搜索
dfs(startX, startY);
return path;
}
// 测试:从(0,0)出发
const result = findPath(maze, 0, 0);
console.log("找到的路径:", result);
// 输出:[[0,0], [1,0], [2,0], [2,1], [2,2], [3,2], [3,3]]
核心技巧:
- 用二维数组
visited避免重复走同一个格子(否则会绕圈) - 回溯时用
path.pop()移除无效路径(关键的 "退一步" 操作) - 找到终点后立即返回,避免多余搜索
(二)案例 2:前端组件的递归遍历(业务场景)
前端开发中,多级嵌套组件(如表单控件、导航菜单)的遍历可以用 DFS 实现。比如需要收集所有带required属性的表单字段。
// 模拟嵌套表单组件结构(类似React/Vue的虚拟DOM)
const formTree = {
type: "Form",
children: [
{ type: "Input", name: "username", required: true },
{
type: "FormItem",
children: [
{ type: "Input", name: "password", required: true },
{
type: "FormItem",
children: [{ type: "Input", name: "verifyCode" }]
}
]
},
{ type: "Select", name: "role", required: false }
]
};
// 用DFS收集所有required为true的字段名
function collectRequiredFields(node, result = []) {
// 如果是输入控件且必填,收集name
if (node.type === "Input" && node.required) {
result.push(node.name);
}
// 递归处理子节点(如果有children)
if (node.children && node.children.length) {
for (const child of node.children) {
collectRequiredFields(child, result);
}
}
return result;
}
// 执行收集
const requiredFields = collectRequiredFields(formTree);
console.log("必填字段:", requiredFields); // 输出:['username', 'password']
业务延伸:
- 可以扩展为 "表单深层校验"(递归检查所有必填项)
- 可以用于 "组件权限过滤"(递归移除无权限的组件)
五、优化策略与注意事项
(一)剪枝优化:提前排除无效路径
DFS 的时间复杂度是O(N)(N 为节点总数),但实际中可以通过剪枝减少无效搜索。
比如 "组合总和" 问题:从数组中找和为 target 的组合,当当前和已超过 target 时,无需继续递归。
// 剪枝示例:组合总和(只选一次,不重复)
function combinationSum(candidates, target) {
const result = [];
candidates.sort((a, b) => a - b); // 排序便于剪枝
function dfs(start, path, currentSum) {
// 终止条件:找到符合条件的组合
if (currentSum === target) {
result.push([...path]);
return;
}
// 剪枝1:当前和已超过target,无需继续
if (currentSum > target) {
return;
}
// 从start开始遍历,避免重复组合(如[2,3]和[3,2]视为同一组合)
for (let i = start; i < candidates.length; i++) {
const num = candidates[i];
// 剪枝2:如果当前数和前一个相同,跳过(去重)
if (i > start && num === candidates[i-1]) continue;
path.push(num);
dfs(i + 1, path, currentSum + num); // i+1表示不重复选当前数
path.pop(); // 回溯
}
}
dfs(0, [], 0);
return result;
}
剪枝核心思路:在递归前判断 "当前路径是否可能有效",无效则直接返回。
(二)避免重复访问:标记的艺术
不标记已访问节点,会导致两种问题:
-
无限循环(如环状结构)
-
重复处理(增加时间开销)
常见标记方式:
- 集合(Set) :适合节点是对象的场景(如树节点、DOM 元素)
- 布尔数组:适合节点用索引访问的场景(如数组、二维网格)
- 原地标记:直接修改原数据(如迷宫中把 0 改为 1 表示已访问,节省空间)
// 原地标记示例(迷宫寻路优化)
function findPathInPlace(maze, x, y, path = []) {
// 越界或撞墙或已访问(用-1标记已访问)
if (x < 0 || x >= maze.length || y < 0 || y >= maze[0].length || maze[x][y] === 1 || maze[x][y] === -1) {
return false;
}
// 找到终点
if (maze[x][y] === 2) {
path.push([x, y]);
return true;
}
// 原地标记为已访问(修改原数组)
maze[x][y] = -1;
path.push([x, y]);
// 探索四个方向
const directions = [[-1,0],[0,1],[1,0],[0,-1]];
for (const [dx, dy] of directions) {
if (findPathInPlace(maze, x + dx, y + dy, path)) {
return true;
}
}
// 回溯:移除标记(如果需要恢复原数组)
maze[x][y] = 0;
path.pop();
return false;
}
(三)栈溢出问题及解决
递归实现的 DFS 在处理深层结构(如 10 万层的树)时,会触发Maximum call stack size exceeded错误。
解决方法:
-
改用非递归实现(手动维护栈,无调用栈限制)
-
尾递归优化(但 JS 仅部分引擎支持,如 Safari 的 JavaScriptCore)
-
分片处理(每次递归一定层数后,用 setTimeout 或 requestIdleCallback 让出主线程)
// 分片递归示例(避免长时间阻塞主线程)
function dfsWithChunk(node, handler, chunkSize = 100) {
const stack = [node];
const visited = new Set();
let count = 0;
function process() {
while (stack.length > 0 && count < chunkSize) {
const current = stack.pop();
if (visited.has(current)) continue;
visited.add(current);
handler(current); // 处理节点
// 子节点逆序入栈
for (let i = current.children.length - 1; i >= 0; i--) {
stack.push(current.children[i]);
}
count++;
}
// 如果还有剩余节点,下次事件循环继续处理
if (stack.length > 0) {
count = 0;
setTimeout(process, 0); // 让出主线程
}
}
process();
}
六、总结:DFS 的本质与应用边界
DFS 的核心是 "深度优先 + 回溯":
-
深度优先保证我们能探索到所有可能路径
-
回溯保证我们不浪费时间在无效路径上
作为前端工程师,掌握 DFS 不仅能解决算法题,更能优化业务代码:
-
处理嵌套结构(树形组件、JSON 数据)
-
实现复杂交互(如流程图的节点遍历)
-
优化性能(通过剪枝减少不必要的计算)
最后留一个思考题:如何用 DFS 实现前端路由的嵌套匹配(比如 React Router 的嵌套路由)?欢迎在评论区交流你的思路!