Files
interview/16-LeetCode Hot 100/单词搜索.md
yasinshaw c0d1585a32 docs: improve solution explanations for remaining 3 LeetCode problems
- 电话号码的字母组合: 添加思路推导、详细流程、执行演示和常见错误
- 子集: 添加思路推导、详细流程、执行演示和常见错误
- 单词搜索: 完全重写,添加完整的思路推导、详细流程、边界分析等

所有文件现在都包含:
- 思路推导(从暴力解法分析)
- 详细的算法流程(含Q&A)
- 关键细节说明
- 边界条件分析
- 执行过程演示
- 常见错误分析
2026-03-08 21:33:59 +08:00

667 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 单词搜索 (Word Search)
## 题目描述
给定一个 `m x n` 二维字符网格 `board` 和一个字符串单词 `word`。如果 `word` 存在于网格中,返回 `true`;否则,返回 `false`
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中"相邻"单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
### 示例
**示例 1**
```
输入board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出true
```
**示例 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
```
### 约束条件
- `m == board.length`
- `n == board[i].length`
- `1 <= m, n <= 6`
- `1 <= word.length <= 15`
- `board``word` 仅由大小写英文字母组成
## 思路推导
### 暴力解法分析
**第一步:最直观的思路 - 从每个位置开始搜索**
```python
def exist_brute(board, word):
if not board or not word:
return False
m, n = len(board), len(board[0])
# 从每个位置开始尝试
for i in range(m):
for j in range(n):
if board[i][j] == word[0]: # 找到起始位置
if search_from(board, i, j, word, 0):
return True
return False
def search_from(board, i, j, word, index):
# 从 (i,j) 位置开始搜索 word[index:]
if index == len(word):
return True
if not is_valid(board, i, j):
return False
if board[i][j] != word[index]:
return False
# 标记访问
temp = board[i][j]
board[i][j] = '#'
# 向四个方向搜索
found = (search_from(board, i+1, j, word, index+1) or
search_from(board, i-1, j, word, index+1) or
search_from(board, i, j+1, word, index+1) or
search_from(board, i, j-1, word, index+1))
# 回溯
board[i][j] = temp
return found
```
**时间复杂度分析:**
- 最坏情况:从每个位置开始搜索
- 每次搜索最多访问 m×n 个格子
- 每个格子有 4 个方向
- **总时间复杂度O(m × n × 4^k)**,其中 k 是单词长度
**问题:**
- 这个算法已经是最优的了!
- 但可以通过剪枝优化
### 优化思考 - 如何减少不必要的搜索?
**核心观察:**
1. **提前终止**:如果单词长度超过格子数,直接返回 false
2. **字符频率检查**:如果 board 中某个字符数量不足,直接返回 false
3. **搜索顺序优化**:从稀有的字符开始搜索
**为什么用 DFS + 回溯?**
- 需要遍历所有可能的路径
- 每个位置只能访问一次(需要标记)
- 找到一条有效路径即可返回
### 为什么这样思考?
**1. 路径搜索视角**
```
board = [["A","B","C","E"],
["S","F","C","S"],
["A","D","E","E"]]
word = "ABCCED"
搜索过程:
(0,0)A → (0,1)B → (0,2)C → (1,2)C → (1,3)E → (0,3)D
✓ 找到完整路径
关键点:
- 每次只能向上下左右移动
- 不能重复使用同一个格子
- 找到一条路径即可返回 true
```
**2. 回溯的必要性**
```
为什么不直接 DFS
- 需要标记已访问的格子
- 如果一条路径不通,需要回溯并尝试其他方向
- 必须恢复原始状态(撤销标记)
回溯 = DFS + 状态恢复
```
## 解题思路
### 方法一DFS + 回溯(推荐)
**核心思想:**对每个位置进行 DFS搜索是否存在匹配的单词路径。
### 详细算法流程
**步骤1遍历所有可能的起始位置**
```python
for i in range(m):
for j in range(n):
if board[i][j] == word[0]: # 首字符匹配
if dfs(i, j, 0):
return True
return False
```
**Q: 为什么要从每个位置开始搜索?**
A: 因为单词可能从 board 的任意位置开始。举例:
```
board = [["A","B"],
["C","D"]]
word = "BD"
必须从 (0,1) 的 'B' 开始搜索
```
**步骤2设计 DFS 函数**
```python
def dfs(i, j, k):
# k 表示当前匹配到 word 的第几个字符
# 终止条件:找到完整单词
if k == len(word):
return True
# 边界检查
if i < 0 or i >= m or j < 0 or j >= n:
return False
# 已访问检查
if visited[i][j]:
return False
# 字符匹配检查
if board[i][j] != word[k]:
return False
# 标记访问
visited[i][j] = True
# 向四个方向搜索
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)
# 回溯:取消标记
visited[i][j] = False
return found
```
**Q: 为什么边界检查放在字符匹配之前?**
A: 为了避免数组越界错误。顺序很重要:
1. 先检查边界(防止越界)
2. 再检查是否已访问
3. 最后检查字符是否匹配
**Q: 为什么用 or 连接四个方向的搜索?**
A: 因为只要有一个方向找到完整单词即可返回 true。
**步骤3优化 - 提前检查字符频率**
```python
from collections import Counter
def exist_optimized(board, word):
# 检查字符频率
board_chars = Counter(c for row in board for c in row)
word_chars = Counter(word)
for char, count in word_chars.items():
if board_chars[char] < count:
return False # board 中该字符不足
# ... 后续搜索逻辑
```
### 关键细节说明
**细节1为什么需要 visited 数组?**
```python
# 没有 visited 的情况
board = [["A","A"],
["A","A"]]
word = "AAAA"
如果不标记已访问
- (0,0)A (0,1)A (0,0)A (0,1)A ...
- 会无限循环重复访问同一个格子
使用 visited
- (0,0)A (0,1)A (1,1)A (1,0)A
- 每个格子只访问一次
```
**细节2为什么找到完整单词后立即返回**
```python
if k == len(word):
return True # 立即返回,不继续搜索
```
**为什么?**
- 题目只要求判断是否存在,不要求找到所有路径
- 找到一条路径即可返回,节省时间
**细节3为什么需要回溯撤销标记**
```python
visited[i][j] = True # 标记
# ... 搜索
visited[i][j] = False # 撤销
```
**为什么必须撤销?**
- 因为其他搜索路径可能需要经过这个格子
- 举例:
```
board = [["A","B"],
["C","D"]]
word = "ABDC"
路径1(0,0)A → (0,1)B → (1,1)D → (1,0)C ✓
路径2(0,0)A → (1,0)C → (1,1)D → (0,1)B ✓
如果不撤销:
- 路径1 访问了所有格子后,所有格子都标记为已访问
- 路径2 无法再搜索虽然路径2也是有效的
```
### 边界条件分析
**边界1单词长度为 1**
```
输入board = [["A"]], word = "A"
输出true
过程:直接检查 board[0][0] == 'A'
```
**边界2单词不存在**
```
输入board = [["A","B"],["C","D"]], word = "ABCE"
输出false
原因:没有 'E' 字符
```
**边界3所有格子都相同**
```
输入board = [["A","A"],["A","A"]], word = "AAAA"
输出true
注意:需要确保不重复访问同一个格子
```
**边界4单词长度超过格子数**
```
输入board = [["A","B"]], word = "ABC"
输出false
优化:可以在开始前检查 len(word) > m×n
```
### 复杂度分析(详细版)
**时间复杂度:**
```
- 外层循环:遍历所有位置 - O(m×n)
- 内层 DFS最坏情况访问所有格子 - O(m×n×4^k)
为什么是 4^k
- 每个位置有 4 个方向
- 单词长度为 k
- 最坏情况需要搜索 4^k 条路径
**总时间复杂度O(m×n×4^k)**
实际运行中,由于边界和字符匹配的检查,实际复杂度会低很多。
```
**空间复杂度:**
```
- visited 数组O(m×n)
- 递归栈深度O(k)(单词长度)
- **总空间复杂度O(m×n)**
```
## 代码实现
### Go 实现
```go
package main
func exist(board [][]byte, word string) bool {
m, n := len(board), len(board[0])
visited := make([][]bool, m)
for i := range visited {
visited[i] = make([]bool, n)
}
var dfs func(i, j, k int) bool
dfs = func(i, j, k int) bool {
// 找到完整单词
if k == len(word) {
return true
}
// 边界检查或不匹配
if i < 0 || i >= m || j < 0 || j >= n ||
visited[i][j] || board[i][j] != word[k] {
return false
}
// 标记访问
visited[i][j] = true
// 向四个方向搜索
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)
// 回溯:取消标记
visited[i][j] = false
return found
}
for i := 0; i < m; i++ {
for j := 0; j < n; j++ {
if board[i][j] == word[0] && dfs(i, j, 0) {
return true
}
}
}
return false
}
```
## 执行过程演示
`board = [["A","B","C"],["S","F","C"],["A","D","E"]], word = "ABCCED"` 为例:
```
初始状态visited 全为 false
从 (0,0) 开始board[0][0] = 'A' == word[0]
DFS(0, 0, 0):
board[0][0] = 'A' == word[0] ✓
visited[0][0] = true
DFS(1, 0, 1): // 向下
board[1][0] = 'S' != 'B' ✗
DFS(-1, 0, 1): // 向上
越界 ✗
DFS(0, 1, 1): // 向右
board[0][1] = 'B' == word[1] ✓
visited[0][1] = true
DFS(1, 1, 2): // 向下
board[1][1] = 'F' != 'C' ✗
DFS(-1, 1, 2): // 向上
越界 ✗
DFS(0, 2, 2): // 向右
board[0][2] = 'C' == word[2] ✓
visited[0][2] = true
DFS(1, 2, 3): // 向下
board[1][2] = 'C' == word[3] ✓
visited[1][2] = true
DFS(2, 2, 4): // 向下
board[2][2] = 'E' != word[4]='E' 实际是相等的 ✓
visited[2][2] = true
DFS(3, 2, 5): // 向下
越界 ✗
DFS(1, 2, 5): // 向上
visited[1][2] = true ✗ 已访问
DFS(2, 3, 5): // 向右
越界 ✗
DFS(2, 1, 5): // 向左
board[2][1] = 'D' == word[5] ✓
DFS(2, 1, 6): // k=6 == len(word),返回 true
返回 true
```
## 常见错误
### 错误1忘记撤销访问标记
**错误写法:**
```go
visited[i][j] = true
found := dfs(i+1, j, k+1) || dfs(i-1, j, k+1) || ...
// 忘记撤销
return found
```
**正确写法:**
```go
visited[i][j] = true
found := dfs(i+1, j, k+1) || dfs(i-1, j, k+1) || ...
visited[i][j] = false // 必须撤销
return found
```
**原因:**不撤销会导致其他路径无法访问该格子。
### 错误2边界检查顺序错误
**错误写法:**
```go
if board[i][j] != word[k] || // 先检查字符,可能越界!
i < 0 || i >= m || j < 0 || j >= n {
return false
}
```
**正确写法:**
```go
if i < 0 || i >= m || j < 0 || j >= n || // 先检查边界
visited[i][j] || board[i][j] != word[k] {
return false
}
```
**原因:**必须先检查边界,否则会数组越界。
### 错误3直接修改 board 而不使用 visited
```go
// 可以这样做,但需要恢复
temp := board[i][j]
board[i][j] = '#'
// ... 搜索
board[i][j] = temp // 必须恢复
```
**问题:**如果忘记恢复,会导致错误。使用 visited 数组更安全。
## 进阶问题
### Q1: 如何找到所有可能的路径?
**A:** 不在找到第一条路径时立即返回,而是继续搜索。
```go
func findAllPaths(board [][]byte, word string) [][]string {
m, n := len(board), len(board[0])
visited := make([][]bool, m)
for i := range visited {
visited[i] = make([]bool, n)
}
result := [][]string{}
var dfs func(i, j, k int, path []string)
dfs = func(i, j, k int, path []string) {
if k == len(word) {
temp := make([]string, len(path))
copy(temp, path)
result = append(result, temp)
return
}
if i < 0 || i >= m || j < 0 || j >= n ||
visited[i][j] || board[i][j] != word[k] {
return
}
visited[i][j] = true
path = append(path, fmt.Sprintf("(%d,%d)", i, j))
dfs(i+1, j, k+1, path)
dfs(i-1, j, k+1, path)
dfs(i, j+1, k+1, path)
dfs(i, j-1, k+1, path)
visited[i][j] = false
path = path[:len(path)-1]
}
for i := 0; i < m; i++ {
for j := 0; j < n; j++ {
if board[i][j] == word[0] {
dfs(i, j, 0, []string{})
}
}
}
return result
}
```
### Q2: 如何优化搜索顺序?
**A:** 从单词中稀有的字符开始搜索。
```go
func existOptimized(board [][]byte, word string) bool {
// 统计 board 中字符频率
charCount := make(map[byte]int)
for i := range board {
for j := range board[i] {
charCount[board[i][j]]++
}
}
// 检查是否有字符不足
wordCount := make(map[byte]int)
for i := range word {
wordCount[word[i]]++
}
for char, count := range wordCount {
if charCount[char] < count {
return false
}
}
// ... 后续搜索逻辑
}
```
### Q3: 单词搜索 II - 如何搜索多个单词?
**A:** 使用 Trie 树优化。
```go
type TrieNode struct {
children [26]*TrieNode
word string
}
func findWords(board [][]byte, words []string) []string {
// 构建 Trie 树
root := buildTrie(words)
result := []string{}
var dfs func(i, j, node *TrieNode)
dfs = func(i, j, node *TrieNode) {
c := board[i][j]
if c == '#' || node.children[c-'A'] == nil {
return
}
node = node.children[c-'A']
if node.word != "" {
result = append(result, node.word)
node.word = "" // 避免重复添加
}
board[i][j] = '#' // 标记访问
if i > 0 {
dfs(i-1, j, node)
}
if i < len(board)-1 {
dfs(i+1, j, node)
}
if j > 0 {
dfs(i, j-1, node)
}
if j < len(board[0])-1 {
dfs(i, j+1, node)
}
board[i][j] = c // 恢复
}
for i := range board {
for j := range board[i] {
dfs(i, j, root)
}
}
return result
}
```
## P7 加分项
### 1. 相关题目推荐
- LeetCode 79: 单词搜索(本题)
- LeetCode 212: 单词搜索 II
- LeetCode 212 需要用 Trie 树优化
### 2. 实际应用场景
- **填字游戏**:判断单词是否可以由给定字母组成
- **Boggle 游戏**:在字母网格中找出所有有效单词
- **DNA 序列匹配**:在基因序列中查找特定模式
- **路径规划**:在迷宫中寻找特定路径
### 3. 面试技巧
**面试官可能会问:**
1. "为什么用 DFS 而不是 BFS"
2. "如何优化搜索效率?"
3. "如何处理大量单词的搜索?"
**回答要点:**
1. DFS 更适合路径搜索,自然表达递归关系
2. 可以通过字符频率检查、搜索顺序优化等方式
3. 使用 Trie 树可以共享前缀,提高效率