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

15 KiB
Raw Blame History

单词搜索 (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
  • boardword 仅由大小写英文字母组成

思路推导

暴力解法分析

第一步:最直观的思路 - 从每个位置开始搜索

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遍历所有可能的起始位置

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 函数

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优化 - 提前检查字符频率

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 数组?

# 没有 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为什么找到完整单词后立即返回

if k == len(word):
    return True  # 立即返回,不继续搜索

为什么?

  • 题目只要求判断是否存在,不要求找到所有路径
  • 找到一条路径即可返回,节省时间

细节3为什么需要回溯撤销标记

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 实现

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忘记撤销访问标记

错误写法:

visited[i][j] = true
found := dfs(i+1, j, k+1) || dfs(i-1, j, k+1) || ...
// 忘记撤销
return found

正确写法:

visited[i][j] = true
found := dfs(i+1, j, k+1) || dfs(i-1, j, k+1) || ...
visited[i][j] = false  // 必须撤销
return found

**原因:**不撤销会导致其他路径无法访问该格子。

错误2边界检查顺序错误

错误写法:

if board[i][j] != word[k] ||  // 先检查字符,可能越界!
   i < 0 || i >= m || j < 0 || j >= n {
    return false
}

正确写法:

if i < 0 || i >= m || j < 0 || j >= n ||  // 先检查边界
   visited[i][j] || board[i][j] != word[k] {
    return false
}

**原因:**必须先检查边界,否则会数组越界。

错误3直接修改 board 而不使用 visited

// 可以这样做,但需要恢复
temp := board[i][j]
board[i][j] = '#'
// ... 搜索
board[i][j] = temp  // 必须恢复

**问题:**如果忘记恢复,会导致错误。使用 visited 数组更安全。

进阶问题

Q1: 如何找到所有可能的路径?

A: 不在找到第一条路径时立即返回,而是继续搜索。

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: 从单词中稀有的字符开始搜索。

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 树优化。

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 树可以共享前缀,提高效率