在LeetCode的中等难度题目中,「单词搜索」是一道经典的DFS(深度优先搜索)+ 回溯算法应用题。它不仅考察对搜索算法的理解,更考验对边界条件的把控和空间优化的思路。今天就带大家一步步拆解这道题,从题目分析到代码实现,再到易错点总结,帮你彻底搞懂这道高频面试题。
一、题目解读
先明确题目要求,避免理解偏差:
-
给定一个 m x n 的二维字符网格 board 和一个字符串 word;
-
判断 word 是否能在网格中找到,要求:字母顺序一致,通过「相邻单元格」(水平或垂直相邻,斜向不算)构成;
-
同一个单元格的字母不允许重复使用;
-
找到则返回 true,否则返回 false。
举个简单例子:若 board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]],word = "ABCCED",则返回 true;若 word = "ABCB",则返回 false(因为第二个B和第一个B不相邻,且无法重复使用同一个B)。
二、核心思路:DFS + 回溯
这道题的核心是「深度优先搜索」,搭配「回溯」来处理"重复使用单元格"的问题。为什么选择DFS?因为我们需要从某个单元格出发,沿着相邻方向一步步匹配单词的每个字符,直到匹配完所有字符(成功),或走到死路(失败,回溯到上一步)。
核心逻辑拆解:
-
遍历整个网格,找到单词的「起始字符」(即 word[0]),以此作为DFS的起点;
-
从起点出发,向上下左右四个方向探索,判断下一个单元格的字符是否与单词的下一个字符匹配;
-
为了避免重复使用单元格,在探索当前单元格时,先将其标记为"已使用"(比如用特殊字符#覆盖);
-
若探索到某一步,字符不匹配或越界,则回溯:将当前单元格的标记恢复,回到上一步继续探索其他方向;
-
若匹配到单词的最后一个字符,则直接返回 true;若遍历完所有起点都无法匹配,返回 false。
三、完整代码实现(TypeScript)
下面是题目给出的完整代码,我们逐行拆解每一部分的作用,让你看懂每一行代码的意义:
typescript
function exist(board: string[][], word: string): boolean {
const rows = board.length; // 网格的行数
const cols = board[0].length; // 网格的列数(假设board非空)
// 定义四个移动方向:上、下、左、右(行和列的偏移量)
const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const wordLen = word.length; // 单词的长度,用于判断是否匹配完成
// DFS函数:参数为当前单元格坐标(row, col)和当前匹配到的单词索引(index)
const dfs = (row: number, col: number, index: number): boolean => {
// 1. 终止条件1:当前单元格字符与单词当前索引字符不匹配,直接返回false
if (board[row][col] !== word.charAt(index)) {
return false;
}
// 2. 终止条件2:已经匹配到单词的最后一个字符,返回true(匹配成功)
else if (index === wordLen - 1) {
return true;
}
// 3. 标记当前单元格为已使用(避免重复使用)
const temp = board[row][col]; // 保存当前单元格的原始字符,用于回溯
board[row][col] = '#'; // 用#覆盖,标记为已使用
// 4. 遍历四个方向,进行深度优先搜索
for (const [dx, dy] of directions) {
const newR = row + dx; // 新的行坐标
const newC = col + dy; // 新的列坐标
// 过滤无效坐标:越界 或 已使用(#标记)
if (newR < 0 || newR >= rows || newC < 0 || newC >= cols || board[newR][newC] === '#') {
continue; // 跳过无效方向
}
// 递归探索下一个方向,若找到匹配则直接返回true
if (dfs(newR, newC, index + 1)) return true;
}
// 5. 回溯:恢复当前单元格的原始字符(当前方向探索失败,回到上一步)
board[row][col] = temp;
// 6. 所有方向都探索失败,返回false
return false;
}
// 遍历整个网格,寻找单词的起始字符(word[0]),作为DFS的起点
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (dfs(i, j, 0)) { // 若从(i,j)出发能匹配到完整单词,返回true
return true;
}
}
}
// 所有起点都探索完毕,仍未匹配到,返回false
return false;
};
四、关键代码解析(重点必看)
1. 方向数组 directions
directions = [[-1, 0], [1, 0], [0, -1], [0, 1]] 表示四个移动方向:
-
-1, 0\]:向上移动(行减1,列不变);
-
0, -1\]:向左移动(列减1,行不变);
用循环遍历这个数组,就能实现"向四个方向探索"的逻辑,避免重复写四次判断。
2. DFS函数的终止条件
这是DFS的核心,直接决定了搜索的效率和正确性:
-
第一个终止条件:当前单元格字符 != word[index],说明当前路径无法匹配,直接返回false;
-
第二个终止条件:index === wordLen - 1,说明已经匹配到单词的最后一个字符,此时前面的字符都已匹配成功,返回true。
3. 回溯的核心:标记与恢复
这是解决"单元格不能重复使用"的关键:
-
在探索当前单元格前,用 temp 保存原始字符,再将其设为 #(标记为已使用);
-
当四个方向都探索失败后,再将 temp 恢复到当前单元格(回溯),让后续的搜索可以重新使用这个单元格。
举个例子:若从A出发,探索到B后发现后续无法匹配,就会回溯,将B恢复为原始字符,再去探索A的其他方向(比如A的下方)。
4. 网格遍历的作用
外层的双重for循环,是为了遍历网格中的每一个单元格,寻找单词的起始字符(word[0])。因为单词的起点可能在网格的任意位置,所以必须逐个尝试。
五、易错点总结(避坑指南)
这道题看似简单,但很容易踩坑,分享几个常见的错误点:
-
忘记边界判断:新坐标 newR 和 newC 必须在 [0, rows-1] 和 [0, cols-1] 范围内,否则会出现数组越界错误;
-
忘记回溯恢复:探索失败后,没有将 # 恢复为原始字符,导致后续搜索无法使用该单元格,漏判正确路径;
-
提前返回的时机:当某一个方向的DFS返回true时,要直接返回true,避免继续遍历其他方向(浪费时间);
-
假设board非空:代码中直接使用 board[0].length,若board为空(rows=0),会报错。实际做题时可添加边界判断(if (rows === 0) return false;),但LeetCode测试用例中board通常非空。
六、复杂度分析
了解复杂度,能帮你更好地理解算法的效率,应对面试中的提问:
- 时间复杂度:O(m×n×3^k),其中 m 是网格行数,n 是网格列数,k 是单词长度。
解释:每个单元格最多被访问一次,每次访问时要探索3个方向(排除来时的方向),最多探索k层(单词长度),因此总时间复杂度为 O(m×n×3^k)。
- 空间复杂度:O(k),主要是DFS递归调用的栈空间,递归深度最多为k(单词长度);若考虑网格的标记(修改原数组),则空间复杂度为O(1)(不使用额外空间)。
七、总结与拓展
这道题的核心是「DFS + 回溯」,核心思想是"探索-标记-回溯-再探索",本质上是对所有可能的路径进行遍历,找到符合条件的路径。
拓展思考:
-
如果网格很大、单词很长,如何优化?可以考虑剪枝(比如提前判断网格中单词的字符数量是否足够,若单词中某个字符在网格中不存在,直接返回false);
-
如果不允许修改原网格(即不能用#标记),可以用一个额外的二维布尔数组记录是否已使用,此时空间复杂度变为 O(m×n)。
其实,LeetCode中很多回溯题(比如岛屿问题、路径搜索问题)都用到了类似的思路,掌握这道题的解法,能帮你举一反三,轻松应对同类题目。