From c0d1585a3292e8a98038fdcf7167adb8333f2e5b Mon Sep 17 00:00:00 2001 From: yasinshaw Date: Sun, 8 Mar 2026 21:33:59 +0800 Subject: [PATCH] docs: improve solution explanations for remaining 3 LeetCode problems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 电话号码的字母组合: 添加思路推导、详细流程、执行演示和常见错误 - 子集: 添加思路推导、详细流程、执行演示和常见错误 - 单词搜索: 完全重写,添加完整的思路推导、详细流程、边界分析等 所有文件现在都包含: - 思路推导(从暴力解法分析) - 详细的算法流程(含Q&A) - 关键细节说明 - 边界条件分析 - 执行过程演示 - 常见错误分析 --- 16-LeetCode Hot 100/单词搜索.md | 590 +++++++++++++++++++++++++++++++- 1 file changed, 571 insertions(+), 19 deletions(-) diff --git a/16-LeetCode Hot 100/单词搜索.md b/16-LeetCode Hot 100/单词搜索.md index 5742f7e..a8c79f2 100644 --- a/16-LeetCode Hot 100/单词搜索.md +++ b/16-LeetCode Hot 100/单词搜索.md @@ -26,20 +26,314 @@ 输出: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. 遍历网格的每个位置 -2. 如果当前位置字符匹配单词首字符,开始 DFS -3. DFS 过程中: - - 标记当前已访问 - - 向四个方向递归搜索 - - 如果找到完整单词,返回 true - - 回溯时撤销访问标记 +### 详细算法流程 + +**步骤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)** +``` ## 代码实现 @@ -95,20 +389,278 @@ func exist(board [][]byte, word string) bool { } ``` -**LeetCode 212:** 给定一个 m x n 二维字符网格 board 和一个单词列表 words,返回所有在二维网格和字典中出现的单词。 +## 执行过程演示 +以 `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 -func findWords(board [][]byte, words []string) []string { - // 构建 Trie 树 - trie := buildTrie(words) - result := []string{} +visited[i][j] = true +found := dfs(i+1, j, k+1) || dfs(i-1, j, k+1) || ... +// 忘记撤销 +return found +``` - for i := 0; i < len(board); i++ { - for j := 0; j < len(board[0]); j++ { - dfsBoard(board, i, j, trie, &result) - } - } +✅ **正确写法:** +```go +visited[i][j] = true +found := dfs(i+1, j, k+1) || dfs(i-1, j, k+1) || ... +visited[i][j] = false // 必须撤销 +return found +``` - return result +**原因:**不撤销会导致其他路径无法访问该格子。 + +### 错误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 树可以共享前缀,提高效率