# 单词搜索 (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 树可以共享前缀,提高效率