LEETCODE 079Medium
单词搜索
网格 DFS 逐字符匹配,把走过的格子原地改掉当访问标记,回溯时改回来,一个 visited 数组都不用开。
问题拆解
在字符网格里找一条上下左右相邻的路径,恰好拼出 word,同一个格子在一条路径里不能重复使用。
这是典型的路径搜索:以每个格子为起点尝试匹配 word[0],匹配上就向四个方向递归匹配下一个字符。因为同一格不能重复用,需要记录当前路径访问过哪些格子——常规做法是开一个 visited 布尔矩阵,进入时标记、回溯时清除。
关键观察有两个。其一,visited 可以省掉:把当前格子临时改成一个不可能出现的字符(比如 '#'),递归返回后再改回原字符,网格自己就是标记。其二,剪枝要放在递归入口处——越界或当前字符与 word[k] 不匹配,立刻返回 false,绝大多数无效分支在第一层就被砍掉了。
回溯的纪律:进入节点时做的任何修改,离开时必须原样撤销——无论这条分支成功还是失败。
原地标记的网格回溯
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 k) {
if (i < 0 || i >= board.length || j < 0 || j >= board[0].length
|| board[i][j] != word.charAt(k)) {
return false; // 越界或字符不匹配,立刻剪掉
}
if (k == word.length() - 1) return true;
board[i][j] = '#'; // 原地标记,防止本条路径重复使用
boolean found = dfs(board, word, i + 1, j, k + 1)
|| dfs(board, word, i - 1, j, k + 1)
|| dfs(board, word, i, j + 1, k + 1)
|| dfs(board, word, i, j - 1, k + 1);
board[i][j] = word.charAt(k); // 回溯时恢复现场
return found;
}
def exist(board: List[List[str]], word: str) -> bool:
m, n = len(board), len(board[0])
def dfs(i: int, j: int, k: int) -> bool:
if i < 0 or i >= m or j < 0 or j >= n or board[i][j] != word[k]:
return False # 越界或字符不匹配,立刻剪掉
if k == len(word) - 1:
return True
board[i][j] = "#" # 原地标记,防止本条路径重复使用
found = (dfs(i + 1, j, k + 1) or dfs(i - 1, j, k + 1)
or dfs(i, j + 1, k + 1) or dfs(i, j - 1, k + 1))
board[i][j] = word[k] # 回溯时恢复现场
return found
return any(dfs(i, j, 0) for i in range(m) for j in range(n))
func exist(board [][]byte, word string) bool {
m, n := len(board), len(board[0])
var dfs func(i, j, k int) bool
dfs = func(i, j, k int) bool {
if i < 0 || i >= m || j < 0 || j >= n || board[i][j] != word[k] {
return false // 越界或字符不匹配,立刻剪掉
}
if k == len(word)-1 {
return true
}
board[i][j] = '#' // 原地标记,防止本条路径重复使用
found := dfs(i+1, j, k+1) || dfs(i-1, j, k+1) ||
dfs(i, j+1, k+1) || dfs(i, j-1, k+1)
board[i][j] = word[k] // 回溯时恢复现场
return found
}
for i := 0; i < m; i++ {
for j := 0; j < n; j++ {
if dfs(i, j, 0) {
return true
}
}
}
return false
}
pub fn exist(board: Vec<Vec<char>>, word: String) -> bool {
fn dfs(board: &mut Vec<Vec<char>>, word: &[char], i: i32, j: i32, k: usize) -> bool {
let (m, n) = (board.len() as i32, board[0].len() as i32);
if i < 0 || i >= m || j < 0 || j >= n || board[i as usize][j as usize] != word[k] {
return false; // 越界或字符不匹配,立刻剪掉
}
if k == word.len() - 1 {
return true;
}
let (ui, uj) = (i as usize, j as usize);
board[ui][uj] = '#'; // 原地标记,防止本条路径重复使用
let found = dfs(board, word, i + 1, j, k + 1)
|| dfs(board, word, i - 1, j, k + 1)
|| dfs(board, word, i, j + 1, k + 1)
|| dfs(board, word, i, j - 1, k + 1);
board[ui][uj] = word[k]; // 回溯时恢复现场
found
}
let mut board = board;
let word: Vec<char> = word.chars().collect();
for i in 0..board.len() {
for j in 0..board[0].len() {
if dfs(&mut board, &word, i as i32, j as i32, 0) {
return true;
}
}
}
false
}
最容易错的地方在恢复现场:board[i][j] = word[k] 要无条件执行,不管这条分支成没成功。本题里“成功后不恢复”碰巧无害——找到后会层层直接返回——但只要题目要求枚举所有解或继续搜索,漏恢复就是脏数据;把“改与还”写成成对的固定动作,不要依赖运气。另外入口检查的顺序也有讲究:越界判断必须放在字符比较之前,否则 Python 里 board[-1][j] 这样的负下标不报错却语义全错,比数组越界异常隐蔽得多。
Rust 版用 i32 做行列坐标,是为了让 i - 1 在边界处不会触发 usize 下溢,越界统一交给入口检查处理。
复杂度
| 指标 | 复杂度 | 原因 |
|---|---|---|
| 时间 | O(m·n·3^L) |
每个格子都可能是起点;路径上除第一步外最多三个方向(不走回头路),L 为单词长度 |
| 空间 | O(L) |
递归栈深度即已匹配长度,没有额外标记数组 |
可以迁移的模式
- 网格路径搜索的标准骨架:入口统一处理越界与不匹配,四方向递归,进出成对地标记与恢复;
- 状态能原地改就原地改,省一份
visited空间,但必须保证“改与还”成对出现; - 剪枝放得越早越好:在递归入口一行返回,比展开四个方向前各判一次干净得多。
回溯题的正确性往往不难,难在任何一条返回路径都不能漏掉恢复现场。