对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 79. 单词搜索
1. 题目描述
给定一个 m x n 的二维字符网格 board 和一个字符串单词 word。要求找出单词是否存在于网格中。
单词必须按照字母顺序,通过相邻的单元格 内的字母构成,其中"相邻"单元格是那些水平相邻或垂直相邻 的单元格。同一个单元格内的字母不允许被重复使用。
示例 1:
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出:true
解释:在网格中可以找到一条路径 A->B->C->C->E->D。
示例 2:
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "SEE"
输出:true
示例 3:
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCB"
输出:false
2. 问题分析
这是一个典型的二维平面上的搜索(遍历)问题,需要在有限的、有约束的空间内寻找一条特定路径。其核心特征包括:
- 搜索空间 :
m x n的二维网格。 - 约束条件 :
- 路径必须连续(上下左右四个方向)。
- 路径不能重复使用同一个网格单元。
- 路径的字符序列必须完全匹配目标单词。
- 目标:判断是否存在至少一条满足条件的路径。
从前端视角看,这类问题类似于:
- 可视化搭建平台:检查用户绘制的连线图是否能形成某个特定序列。
- 游戏逻辑:如"一笔画"、"寻找单词"等小游戏的底层判断逻辑。
- DOM 树或虚拟 DOM 的特定节点序列查找:在复杂的树形结构中,寻找符合特定规则的节点路径。
3. 解题思路(给出复杂度并指出最优解)
面对此类"在约束条件下寻找所有可能路径"的问题,深度优先搜索(DFS)配合回溯是直觉且主流的解法。这也是本题的最优解,因为我们需要探索"一条路走到黑"的可能性,并在失败时"回头"尝试其他岔路。
核心思路(回溯算法):
- 起点 :遍历网格中的每一个单元格
(i, j),将其作为搜索的起点。 - DFS 递归搜索:从当前单元格出发,向上下左右四个方向进行递归探索。
- 递归定义 :定义函数
dfs(i, j, k),表示从网格(i, j)位置开始,能否匹配到单词word从第k个字符开始的后缀。 - 递归三部曲 :
- 终止条件 :
k === word.length:说明已经成功匹配整个单词,返回true。i或j越界,或board[i][j] !== word[k],或当前单元格已被访问:当前路径失败,返回false。
- 处理当前层 :标记当前单元格
(i, j)为"已访问",防止在本轮搜索中重复使用。 - 下探到下一层 :向四个方向
(i+1, j),(i-1, j),(i, j+1),(i, j-1)发起递归调用dfs(nextI, nextJ, k+1)。 - 回溯清理 :这是关键步骤 。在从当前单元格返回之前,撤销对其的"已访问"标记,以便其他搜索路径可以正常使用该单元格。
- 终止条件 :
- 结果整合 :如果任何起点的 DFS 返回
true,则最终结果为true;否则为false。
为什么这是最优解?
- 该算法本质上是穷举所有可能的路径,但由于引入了"访问标记"和及时剪枝(字符不匹配立即返回),避免了大量的无效搜索。
- 其时间复杂度在 worst-case 下虽为指数级,但对于此类 NP 难问题的子集,回溯是已知最有效的精确解法。
- 空间复杂度主要取决于递归调用栈的深度,最大为单词长度
L,是可控的。
4. 各思路代码实现 (JavaScript)
4.1 思路一:DFS + 回溯(最优解实现)
javascript
/**
* @param {character[][]} board
* @param {string} word
* @return {boolean}
*/
var exist = function(board, word) {
const m = board.length;
const n = board[0].length;
const wordLen = word.length;
// 方向数组,代表上下左右四个方向的偏移量
const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]];
// 用于标记单元格是否在当前搜索路径中被访问过
const visited = new Array(m).fill(0).map(() => new Array(n).fill(false));
/**
* DFS 回溯函数
* @param {number} i - 当前行索引
* @param {number} j - 当前列索引
* @param {number} k - 当前匹配到单词的第几个字符 (0-indexed)
* @return {boolean}
*/
const dfs = (i, j, k) => {
// 1. 递归终止条件(失败)
if (
i < 0 || i >= m ||
j < 0 || j >= n ||
visited[i][j] ||
board[i][j] !== word[k]
) {
return false;
}
// 2. 递归终止条件(成功)
if (k === wordLen - 1) {
return true;
}
// 3. 处理当前层:标记为已访问
visited[i][j] = true;
// 4. 递归下探到下一层(四个方向)
for (const [dx, dy] of directions) {
const newI = i + dx;
const newJ = j + dy;
// 如果任意一个方向能找到剩余部分,立即返回true,不再探索其他方向
if (dfs(newI, newJ, k + 1)) {
// 注意:这里返回前也需要清理visited?不,因为成功找到了,整个调用栈会依次返回true,visited的状态不再重要。
// 但为了逻辑清晰和 visited 数组的复用,可以在最终返回前统一清理,或者在成功路径返回前也清理。
// 更常见的做法是:在最终成功返回true前,先清理当前节点的状态,确保函数退出时状态是干净的。
// 我们调整一下代码结构,将清理放在 finally 位置。
visited[i][j] = false; // 回溯,清理当前节点状态
return true;
}
}
// 5. 四个方向都走不通,回溯:撤销当前节点的访问标记
visited[i][j] = false;
return false;
};
// 主循环:尝试每一个单元格作为起点
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
// 优化:如果起点字符就不匹配,直接跳过
if (board[i][j] === word[0]) {
if (dfs(i, j, 0)) {
return true;
}
}
}
}
return false;
};
优化技巧(代码中已体现):
- 方向数组:使代码更简洁,避免写四个类似的递归调用。
- 起点字符预判 :在调用
dfs前,先判断起点字符是否匹配单词首字符,减少不必要的递归入口。 - 回溯状态的及时清理 :确保递归函数返回后,
visited状态恢复到进入前的样子,这是回溯算法的精髓。
5. 各实现思路的复杂度、优缺点对比表格
| 特性 | DFS + 回溯 (上述实现) | 备注 |
|---|---|---|
| 时间复杂度 | O(m * n * 3^L) | 最坏情况下,每个起点都要探索,每个节点(除了第一步)有3个方向可走(不能走回头路),探索深度为单词长度 L。这是一个理论上界。 |
| 空间复杂度 | O(L + m * n) | O(L) 为递归栈的最大深度;O(m*n) 为 visited 辅助矩阵的开销(可优化至 O(L),见下文)。 |
| 优点 | 1. 思路直观,符合人的思维方式。 2. 剪枝及时,实际运行效率在一般数据下可接受。 3. 是解决此类约束性路径搜索的标准且最优方法。 | |
| 缺点 | 1. 最坏情况时间复杂度高,在网格大、单词长时可能超时(但LeetCode测试用例通常规避了最坏情况)。 2. 需要额外的 visited 空间。 |
|
| 空间优化点 | 可以使用原地修改 board 的方式省去 visited 矩阵:将访问过的单元格暂时修改为一个不可能出现在 word 中的字符(如 # 或 \0),递归返回前再改回来。这样空间复杂度可降为 O(L)。 |
优化后代码片段 : javascript<br>const temp = board[i][j];<br>board[i][j] = '#'; // 标记已访问<br>// ... 递归 ...<br>board[i][j] = temp; // 回溯恢复<br> |
6. 总结
LeetCode 79. 单词搜索是一道经典的回溯算法入门题,它清晰地展示了DFS在探索所有可能性、遇到障碍时回退的完整流程。
对前端开发者的核心价值:
- 算法思维训练 :强化"递归+回溯"的思维模式。这种模式在前端处理树形结构操作 (如虚拟DOM的diff、目录树的展开/收起、权限路由的递归检查)、状态空间搜索(如表单配置的多步骤验证、工作流审批路径)时非常有用。
- 解决实际问题 :
- 在线文档/表格:实现类似"公式追踪"功能,查找某个单元格的值是如何由其他单元格计算而来的路径。
- 可视化搭建/低代码平台:检查用户拖拽连接的组件节点是否能形成一条有效的逻辑链。
- 游戏开发:开发网页版的"Boggle"(找单词)或"数独"类游戏,核心逻辑就是此类网格搜索。
- 性能意识提升:通过分析该算法的时间复杂度,你会更深刻地理解为什么某些DOM操作(如深度嵌套的递归查询)或大规模数据遍历会成为性能瓶颈,从而在设计阶段就考虑优化方案。