前端算法秘籍:DFS 算法的 JS 魔法之旅🤩

193 阅读10分钟

一、引言:从迷宫到算法的奇妙联系

image.png

玩过迷宫游戏吗?当你站在迷宫入口,眼前有三条岔路。你选了最左边的路一直走,走到尽头发现是死胡同 —— 这时候你不会傻站着,而是退回到最近的岔路口,换中间的路继续尝试。

这种 "一条路走到黑,不通就退回换路" 的逻辑,就是DFS(深度优先搜索)  的核心思想。作为前端工程师,你可能在这些场景用到它:

  • 树形组件的递归渲染(比如多级导航菜单)

  • 表单验证的深层规则校验

  • 前端可视化中的节点遍历(如力导向图的关系探索)

  • LeetCode 算法题(二叉树遍历、组合总和等高频考点)

今天我们用 JS 手把手实现 DFS,从基础到实战,让你既能在业务中用起来,也能在面试中讲清楚。

二、DFS 算法基础概念

image.png

(一)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 实现:递归与非递归

image.png

(一)递归实现:最符合直觉的写法

递归是实现 DFS 最自然的方式 —— 因为递归本身就是栈的逻辑(函数调用栈)。

核心步骤

  1. 标记当前节点为 "已访问"(避免重复访问)

  2. 处理当前节点(如打印、收集数据)

  3. 遍历当前节点的所有未访问邻节点,递归调用 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数组存储子节点,二叉树则是leftright

(二)非递归实现:用栈模拟递归过程

递归虽简单,但在 JS 中存在调用栈深度限制(默认约 10000 层),深层递归可能导致栈溢出。这时候需要用手动维护栈的方式实现非递归 DFS。

核心步骤

  1. 创建栈,将起点节点入栈

  2. 当栈不为空时,弹出栈顶节点

  3. 若节点未访问:

    • 标记为已访问并处理

    • 将其邻节点逆序入栈(保证访问顺序和递归一致)

代码示例(同上树结构)

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 先访问(和递归顺序一致)

四、实际案例应用:从算法到业务

image.png

(一)案例 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']

业务延伸

  • 可以扩展为 "表单深层校验"(递归检查所有必填项)
  • 可以用于 "组件权限过滤"(递归移除无权限的组件)

五、优化策略与注意事项

image.png

(一)剪枝优化:提前排除无效路径

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;
}

剪枝核心思路:在递归前判断 "当前路径是否可能有效",无效则直接返回。

(二)避免重复访问:标记的艺术

不标记已访问节点,会导致两种问题:

  1. 无限循环(如环状结构)

  2. 重复处理(增加时间开销)

常见标记方式:

  • 集合(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错误。

解决方法

  1. 改用非递归实现(手动维护栈,无调用栈限制)

  2. 尾递归优化(但 JS 仅部分引擎支持,如 Safari 的 JavaScriptCore)

  3. 分片处理(每次递归一定层数后,用 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 的本质与应用边界

image.png

DFS 的核心是 "深度优先 + 回溯"

  • 深度优先保证我们能探索到所有可能路径

  • 回溯保证我们不浪费时间在无效路径上

作为前端工程师,掌握 DFS 不仅能解决算法题,更能优化业务代码:

  • 处理嵌套结构(树形组件、JSON 数据)

  • 实现复杂交互(如流程图的节点遍历)

  • 优化性能(通过剪枝减少不必要的计算)

最后留一个思考题:如何用 DFS 实现前端路由的嵌套匹配(比如 React Router 的嵌套路由)?欢迎在评论区交流你的思路!