一句话说明核心方法
从网格的每个格子出发,沿上下左右做 DFS,字符匹配就深入、不匹配就剪枝;为避免重复使用同一格子,用原地标记 + 回溯撤销 来记录访问状态,任意一条路径走到 index == word.length() 即成功。
思路推导
题意转化 :在二维网格里找一条路径,顺序访问若干相邻格子,拼出 word。每个格子只能用一次。
关键观察:
- 起点不固定 ------
word[0]可能在网格的任意位置,所以必须遍历每个格子作为起点。 - 路径顺序固定 ------ 一旦确定从
(i,j)出发,下一步只能去(i±1,j)或(i,j±1),不是任选,这与子集/排列"自由选择下一个"完全不同。 - 状态维度是"格子是否已访问" ------ 用 boolean 数组太占空间,而直接修改 boardij 为特殊字符,递归结束再还原,是最经济的做法。
算法选择:DFS + 回溯。每层决策"从当前格子往四个方向中的某一个走",剪枝条件 = 越界 OR 已访问 OR 字符不匹配。
与之前回溯题的区别 :子集/组合/字母组合的"决策树"是在抽象数据结构上展开,而本题的决策树是真实的空间路径,每一步还可能走回头路(因为标记撤销后会"释放"格子),所以终止条件更微妙。
回溯路径示意
(以 word = "ABCCED" 为例)网格中的成功路径:
col: 0 1 2 3
row 0: A → B → C E
↓
row 1: S F C ↑ S
↓
row 2: A ← D ← E E
↑
成功路径: (0,0)A → (0,1)B → (0,2)C → (1,2)C → (2,2)E → (2,1)D ✓
对应的 DFS 关键调用链(省略大量失败分支):
dfs(0,0, 0) board[0][0]='A' = 'A' ✓
标记 [0][0]
└─ dfs(0,1, 1) 'B' = 'B' ✓
标记 [0][1]
└─ dfs(0,2, 2) 'C' = 'C' ✓
标记 [0][2]
├─ dfs(-1,2, 3) 越界 ✗
├─ dfs(0,1, 3) 已标记 ✗
├─ dfs(0,3, 3) 'E' ≠ 'C' ✗
└─ dfs(1,2, 3) 'C' = 'C' ✓
标记 [1][2]
├─ dfs(0,2, 4) 已标记 ✗
├─ dfs(1,1, 4) 'F' ≠ 'E' ✗
├─ dfs(1,3, 4) 'S' ≠ 'E' ✗
└─ dfs(2,2, 4) 'E' = 'E' ✓
标记 [2][2]
├─ dfs(1,2, 5) 已标记 ✗
├─ dfs(3,2, 5) 越界 ✗
├─ dfs(2,3, 5) 'E' ≠ 'D' ✗
└─ dfs(2,1, 5) 'D' = 'D' ✓
标记 [2][1]
└─ dfs(..., 6) index == len → return TRUE ✓
注意:每条失败路径都会撤销标记,所以同一格子在另一条路径中可被重新使用。
Java 完整代码
class Solution {
public boolean exist(char[][] board, String word) {
int m = board.length;
int n = board[0].length;
// 任意格子都可以作为起点,必须遍历所有 (i,j)
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (dfs(board, word, i, j, 0)) {
return true;
}
}
}
return false;
}
private boolean dfs(char[][] board, String word, int i, int j, int index) {
// 成功:已经匹配完 word 的所有字符
if (index == word.length()) {
return true;
}
// 失败:越界 / 字符不匹配(已访问的情况也被这一行兜住,因为标记后 board[i][j] = '0')
if (i < 0 || i >= board.length || j < 0 || j >= board[0].length
|| board[i][j] != word.charAt(index)) {
return false;
}
// 关键:原地标记,避免 visited 数组
char tmp = board[i][j];
board[i][j] = '0';
// 四个方向任一成功即可
boolean res = dfs(board, word, i - 1, j, index + 1)
|| dfs(board, word, i + 1, j, index + 1)
|| dfs(board, word, i, j - 1, index + 1)
|| dfs(board, word, i, j + 1, index + 1);
// 回溯:撤销标记,让其他路径能再次使用该格子
board[i][j] = tmp;
return res;
}
}
关键代码逐行解释
- 双重 for 遍历起点 :
word[0]可能在网格任意位置,只从一个格子出发是错的 。外层枚举(i,j),任何一个起点成功就立即返回 true。 - 第一个
if (index == word.length()) return true:成功终止。注意这里index是"下一个要匹配的字符下标",所以当index等于word.length()时,说明前一步已经匹配完最后一个字符。 - 第二个
if同时处理越界和字符不匹配 :board[i][j] != word.charAt(index)这一行还顺带兜住了"已访问"的判断 ------因为已经访问的格子被改成了'0',而word里没有'0',自然不等,等价于 visited 数组的效果。 char tmp = board[i][j]; board[i][j] = '0':原地标记的精髓。省掉了 boolean\[\]\[\] visited ,空间复杂度从 O(mn) 降到 O(1) 额外。'0'是占位符,选它是因为题目保证 board 元素是大写或小写字母,不会和真实字符冲突。- 四个 dfs 用
||串接 :短路求值,一旦某个方向返回 true,后续三个方向立即不再递归 ,既省时间也省栈。写成||是性能关键,不要写成if-else链或者全部算完再||。 board[i][j] = tmp撤销标记 :这一行决定整个算法是否正确 。如果漏掉,后续路径都会以为这个格子已访问,合法路径会被错误剪掉,导致误判 false。加入-撤销必须严格配对,这是回溯的核心约束。
时间、空间复杂度
- 时间复杂度:O(m · n · 4^L) (L =
word.length())- 外层遍历 m·n 个起点,每个起点 DFS 最多 4^L 个节点(每个格子 4 个方向)。实际运行中字符不匹配和已访问都会剪枝 ,有效分支远少于 4^L;
m,n ≤ 6、L ≤ 15,规模很小。 - 进阶提示:对更大的 board,可以用 Trie(LeetCode 212 单词搜索 II)把多个查询合并剪枝,单次查询仍可用本方法。
- 外层遍历 m·n 个起点,每个起点 DFS 最多 4^L 个节点(每个格子 4 个方向)。实际运行中字符不匹配和已访问都会剪枝 ,有效分支远少于 4^L;
- 空间复杂度:O(L) (不计原 board)
- 递归栈深度 = word 长度,无额外 visited 数组。原 board 本身不计在额外空间里。
易错点
- 忘了撤销标记 :
board[i][j] = tmp这一行删掉的话,DFS 跑完后整张网格大部分格子都会被改成'0',下一条路径根本无路可走,误判 false。这是本题最高频的 bug。 - 只从一个起点开始 :只检查
(0,0)或word[0]第一次出现的位置,忽略了 word 路径起点可能在网格任意位置,会漏判。必须双重 for。 - 判断"已访问"用
==字符比较 :写成if (board[i][j] == '0') return false单独加一行判断也能跑,但和"字符不匹配"合并到一行更简洁,逻辑也更不容易出错(因为'0'永远不等于 word 里的合法字母)。 - 没有用
||的短路特性 :写成 4 个变量分别存结果再res = up || down || left || right;也能跑,但失去了短路求值的好处------找到 true 后还白白递归完其余三个方向,在大网格上会显著变慢。 - 起点 i, j 写反 :双重 for 里
i是行(board.length),j是列(board[0].length),传给 dfs 时不要把行列搞反,否则边界判断会错位。
可复用模板
网格 DFS 通用框架(单词搜索、岛屿、路径类问题都适用):
class Solution {
// 四方向偏移量(上、下、左、右)
private static final int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
public boolean exist(char[][] board, String word) {
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[0].length; j++) {
if (dfs(board, word, i, j, 0)) return true;
}
}
return false;
}
private boolean dfs(char[][] board, String word, int i, int j, int index) {
if (index == word.length()) return true; // 成功
if (outOfBounds(board, i, j) || board[i][j] != word.charAt(index)) { // 越界 / 不匹配
return false;
}
char tmp = board[i][j];
board[i][j] = '#'; // 原地标记
boolean found = false;
for (int[] d : DIRS) { // 四方向
if (dfs(board, word, i + d[0], j + d[1], index + 1)) {
found = true;
break; // 短路
}
}
board[i][j] = tmp; // 撤销
return found;
}
private boolean outOfBounds(char[][] board, int i, int j) {
return i < 0 || i >= board.length || j < 0 || j >= board[0].length;
}
}
变体提示:
- 岛屿 / 路径覆盖类 (无需还原)→ 删掉最后一行
board[i][j] = tmp,且不关心返回值,只关心是否访问过。 - 多个单词同时搜 → 入口加 Trie 节点,dfs 里传 TrieNode 推进,见 LeetCode 212。
- 统计路径条数 → 把返回类型从
boolean换成int,四个方向的结果相加。
相似题及区别
- LeetCode 200 岛屿数量 :同样是网格 DFS + 标记已访问,但不撤销标记(因为是求连通块而不是路径),且不需要 word 字符串,只数连通区域个数。
- LeetCode 212 单词搜索 II:本题的多查询版本,字典里有多个 word。用 Trie 把多查询合并,dfs 时同步推进 Trie 节点,效率远高于"每个 word 跑一遍 exist"。
- LeetCode 980 不同路径 III:网格 DFS + 状态回溯,但要"走遍所有空格且只用一次",需要记录剩余步数。本题是"沿 word 走"路径搜索,只要路径长度匹配即可。
- LeetCode 1091 二进制矩阵中的最短路径:BFS 找最短路,不是回溯(因为需要"最短");本题只问"是否存在",DFS 即可。