python 单词搜索(回溯-矩阵-字符串-中等)含源码(二十)
问题说明(含示例)
问题描述:给定一个 m x n
的二维字符网格 board
和一个字符串 word
,判断 word
是否存在于网格中。单词需按字母顺序,通过相邻(水平 / 垂直,无对角线)单元格的字母构成,且同一个单元格的字母不允许重复使用。
示例
输入 | 输出 | 解释 |
---|---|---|
board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']], word = "ABCCED" | true | 路径为 (0,0)→(0,1)→(0,2)→(1,2)→(2,2)→(2,1) ,匹配所有字符 |
board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']], word = "SEE" | true | 路径为 (1,3)→(2,3)→(2,2) ,匹配所有字符 |
board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']], word = "ABCB" | false | 无法找到不重复使用单元格且相邻的路径,如第二个 B 无合法相邻单元格 |
解题关键
核心思路是回溯法,通过 “状态标记 - 相邻探索 - 状态恢复” 实现路径搜索,而回溯的底层依赖栈(Python 解释器的递归调用栈或手动模拟的列表栈)管理状态。关键步骤如下:
- 初始过滤:若网格为空,直接返回
false
;遍历网格,仅将与word[0]
匹配的单元格作为回溯起始点(剪枝优化)。 - 方向定义:用
(0,1)、(0,-1)、(-1,0)、(1,0)
表示右、左、上、下四个相邻方向,确保仅按规则探索。 - 栈式回溯:
- 递归实现:依赖 Python 底层递归调用栈自动管理状态(参数、局部变量、返回地址);
- 迭代实现:用列表模拟栈手动存储状态(单元格坐标、匹配索引、已访问集合)。
- 状态管理:通过修改网格字符(如用
$
标记已访问)避免重复使用,探索失败后恢复原字符(回溯核心)。
核心逻辑 + 关键细节(含 Python 栈的内部设定)
一、回溯的核心逻辑:“标记 - 探索 - 恢复” 闭环
无论用递归栈还是手动栈,回溯的核心是通过栈记忆 “当前状态”,确保探索失败后能回退到上一步,具体闭环如下:
- 标记:将当前单元格标记为已访问(如
board[i][j] = '$'
),避免同一路径重复使用; - 探索:按四个方向遍历相邻单元格,若符合 “不越界、未访问、字符匹配”,则进入下一层探索;
- 恢复:若所有方向探索失败,恢复当前单元格的原字符(如
board[i][j] = temp
),回退到上一步状态。
二、Python 栈的内部设定(两种实现方式)
栈的核心作用是 **“记忆状态 + 控制回退”**,Python 中存在两种栈的使用场景,内部设定差异显著:
1. 递归实现:依赖 Python 解释器的 “递归调用栈”(自动管理)
(1)栈的内部结构:栈帧(Stack Frame)
每次调用递归函数(如 backtrack(i, j, k)
),Python 解释器会自动创建一个栈帧,压入递归调用栈。栈帧包含以下关键信息(用户无需手动定义,由解释器维护):
栈帧内容 | 作用 | 示例(backtrack(0,0,0) ) |
---|---|---|
函数参数 | 记录当前探索的单元格坐标(i,j )和匹配进度(k ) | i=0, j=0, k=0 |
局部变量 | 存储当前单元格的原字符(temp ),用于后续恢复 | temp = 'A' (board[0][0] 的原字符) |
返回地址 | 记录函数执行完 return 后,需回到的上一层代码位置 | 主函数中 if backtrack(0,0,0): 这一行 |
(2)栈的操作时机(自动执行)
- 压栈(Push):调用递归函数时触发。例如,从
backtrack(0,0,0)
调用backtrack(0,1,1)
,会将(i=0,j=1,k=1, temp='B', 返回地址=backtrack(0,0,0)的for循环)
压入栈; - 弹栈(Pop):函数执行
return
时触发。例如,backtrack(0,1,1)
探索失败返回false
,其栈帧会从栈中弹出,控制权回到上一层的返回地址(继续遍历下一个方向)。
(3)示例:递归栈的工作流程(匹配 “AB”)
1. 主函数调用 backtrack(0,0,0) → 压栈帧1(i=0,j=0,k=0,temp='A');
2. 探索右方向,调用 backtrack(0,1,1) → 压栈帧2(i=0,j=1,k=1,temp='B');
3. k=1 == len(word)-1(word="AB"),返回 true → 弹栈帧2;
4. 栈帧1接收到true,返回主函数 → 弹栈帧1;
5. 主函数返回 true,流程结束。
2. 迭代实现:用 “列表模拟栈”(手动管理)
若递归深度过大(如网格尺寸 1000x1000
),可能触发栈溢出,此时需用列表手动模拟栈,内部设定完全由用户控制:
(1)栈的内部结构:自定义状态元组
列表中每个元素是一个状态元组,包含探索所需的全部信息(需手动定义),格式为 (i, j, k, visited)
:
状态字段 | 作用 | 示例 |
---|---|---|
i,j | 当前单元格坐标 | (0,0) |
k | 当前匹配的 word 索引 | 0 (匹配 word[0] ) |
visited | 已访问的单元格集合(避免重复) | {(0,0)} |
(2)栈的操作时机(手动执行)
- 压栈(Push):用
list.append()
实现,将符合条件的新状态加入列表尾部(栈顶); - 弹栈(Pop):用
list.pop()
实现,从列表尾部取出最后压入的状态(栈顶),即 “回退到上一步”。
(3)示例:手动栈的工作流程(匹配 “AB”)
stack = [(0,0,0, {(0,0)})] # 初始压栈:起始点状态
while stack:i,j,k,visited = stack.pop() # 弹栈:取栈顶状态if k == 1: # 匹配完"AB"return Truefor dx,dy in direction:new_i,new_j = i+dx,j+dy# 符合条件:不越界、未访问、字符匹配if 0<=new_i<n and 0<=new_j<m and (new_i,new_j) not in visited and board[new_i][new_j] == word[k+1]:new_visited = visited.copy() # 复制集合,避免状态污染new_visited.add((new_i,new_j))stack.append((new_i,new_j,k+1,new_visited)) # 压栈新状态
return False
三、关键细节:避坑与优化
- 方向数组不可重复:需确保四个方向不重复(如避免
(0,-1)
出现两次),否则会遗漏方向或重复探索; - 边界判断需精准:行索引范围是
0<=i<n
(n
为网格行数),列索引是0<=j<m
(m
为网格列数),避免i>n
这类错误(会导致越界访问); - 状态恢复的必要性:递归中若不恢复原字符(
board[i][j] = temp
),会导致后续路径无法复用单元格;迭代中若不复制visited
集合(直接修改原集合),会导致状态污染; - 剪枝优化:仅从与
word[0]
匹配的单元格开始探索,减少无效递归 / 压栈操作。
对应代码(两种栈实现方式)
1. 递归版(依赖 Python 底层递归调用栈)
from typing import Listclass Solution:def exist(self, board: List[List[str]], word: str) -> bool:# 1. 初始过滤与变量定义n = len(board)if n == 0:return Falsem = len(board[0])direction = [(0,1), (0,-1), (-1,0), (1,0)] # 右、左、上、下word_len = len(word)# 2. 回溯函数(依赖Python递归调用栈)def backtrack(i: int, j: int, k: int) -> bool:# 终止条件1:匹配完所有字符if k == word_len:return True# 终止条件2:越界、字符不匹配if i < 0 or i >= n or j < 0 or j >= m or board[i][j] != word[k]:return False# 标记:保存原字符,用$标记已访问(栈帧中存储temp)temp = board[i][j]board[i][j] = '$'# 探索四个方向(递归调用,自动压栈)for dx, dy in direction:new_i, new_j = i + dx, j + dyif backtrack(new_i, new_j, k + 1):return True # 找到有效路径,直接返回(弹栈后向上传递)# 恢复:回溯,恢复原字符(栈帧弹出前执行)board[i][j] = tempreturn False # 所有方向失败,返回(弹栈)# 3. 遍历起始点,启动回溯for i in range(n):for j in range(m):if board[i][j] == word[0]:if backtrack(i, j, 0):return Truereturn False
2. 迭代版(用列表手动模拟栈)
from typing import Listclass Solution:def exist(self, board: List[List[str]], word: str) -> bool:# 1. 初始过滤与变量定义n = len(board)if n == 0:return Falsem = len(board[0])direction = [(0,1), (0,-1), (-1,0), (1,0)]word_len = len(word)stack = []# 2. 初始化栈:压入所有匹配word[0]的起始点状态for i in range(n):for j in range(m):if board[i][j] == word[0]:stack.append((i, j, 0, {(i, j)})) # 手动压栈:(坐标, 匹配索引, 已访问集合)# 3. 迭代处理栈(手动弹栈、压栈)while stack:i, j, k, visited = stack.pop() # 手动弹栈:取栈顶状态# 终止条件:匹配完所有字符if k == word_len:return True# 探索四个方向,手动压栈新状态for dx, dy in direction:new_i = i + dxnew_j = j + dy# 检查:不越界、未访问、字符匹配if 0 <= new_i < n and 0 <= new_j < m:if (new_i, new_j) not in visited and board[new_i][new_j] == word[k + 1]:# 复制已访问集合,避免状态污染new_visited = visited.copy()new_visited.add((new_i, new_j))# 手动压栈:新状态加入栈顶stack.append((new_i, new_j, k + 1, new_visited))# 所有状态探索失败return False
对应的基础知识
1. Python 递归调用栈的内部机制
- 自动管理:递归调用栈由 Python 解释器底层维护,用户无需手动操作,仅需编写递归逻辑;
- 栈帧生命周期:函数调用时创建栈帧(压栈),函数返回时销毁栈帧(弹栈),栈帧中的局部变量仅在当前函数调用中有效;
- 栈溢出风险:Python 默认递归深度约为 1000(可通过
sys.setrecursionlimit()
修改,但不推荐),若网格尺寸过大(如2000x2000
),递归深度可能超过限制,触发RecursionError
。
2. 列表模拟栈的原理
- 数据结构匹配:Python 列表的
append()
(尾部添加)和pop()
(尾部删除)操作均为O(1)
时间复杂度,符合栈 “先进后出(LIFO)” 的特性; - 状态独立性:迭代中需复制
visited
集合(如new_visited = visited.copy()
),因为列表中的状态共享引用,直接修改会导致所有相关状态的visited
被污染; - 灵活性:手动栈可自定义状态字段(如增加 “方向索引” 记录已探索的方向),避免重复探索,而递归栈无法直接控制。
3. 二维网格的索引操作
- 行数与列数:
n = len(board)
表示网格行数(外层列表长度),m = len(board[0])
表示网格列数(内层列表长度),需先判断board
非空再获取m
; - 相邻坐标计算:通过方向数组
(dx, dy)
计算新坐标new_i = i + dx
、new_j = j + dy
,避免硬编码(如i+1
、j-1
)导致的代码冗余。
对应的进阶知识
1. 两种栈实现的效率对比
对比维度 | 递归调用栈(自动) | 列表模拟栈(手动) |
---|---|---|
代码复杂度 | 低(逻辑简洁,无需手动管理状态) | 高(需手动处理压栈、弹栈、状态复制) |
运行效率 | 较低(函数调用有栈帧创建 / 销毁开销) | 较高(无函数调用开销,仅列表操作) |
栈溢出风险 | 高(递归深度受限,大网格易溢出) | 低(列表大小仅受内存限制,无深度限制) |
适用场景 | 小规模网格(如 100x100 以内)、代码可读性优先 | 大规模网格、递归深度超限时 |
2. 时间与空间复杂度
- 时间复杂度:
O(nm×3ᵏ)
(n
= 行数,m
= 列数,k
=word 长度);每个单元格最多作为起始点一次(O(nm)
),每个单元格探索时最多有 3 个有效方向(排除来时的方向),递归 / 迭代深度为k
,故每个起始点的时间为O(3ᵏ)
。 - 空间复杂度:
- 递归版:
O(k)
(递归栈深度为k
,网格标记无额外空间); - 迭代版:
O(nm)
(最坏情况下栈存储所有单元格状态,visited
集合最大为nm
)。
- 递归版:
3. 优化策略:避免重复探索
- 方向剪枝:迭代版中,可在状态元组中增加 “方向索引”(如
(i,j,k,visited, dir_idx)
),记录已探索的方向,下次弹栈时从dir_idx+1
开始遍历,避免重复探索同一方向; - 原地标记 vs 额外集合:递归版用 “原地修改网格” 标记已访问(空间
O(1)
),迭代版用 “visited
集合”(空间O(nm)
),前者空间更优,但需确保状态恢复正确。
编程思维与启示
1. “栈” 是回溯的核心载体
回溯的本质是 “深度优先搜索 + 状态回退”,而栈的 “先进后出” 特性完美匹配这一逻辑 —— 栈记住 “当前路径的所有状态”,回退时只需弹出栈顶状态,即可回到上一步,这是 “为什么回溯离不开栈” 的根本原因。
2. “就地修改” 与 “状态隔离” 的平衡
- 递归版用 “就地修改网格”(
$
标记)实现状态标记,通过 “恢复原字符” 实现状态隔离,空间效率高,但需确保每个修改都有对应的恢复; - 迭代版用 “复制
visited
集合” 实现状态隔离,无需修改网格,但空间效率低,需在 “空间” 与 “代码复杂度” 间权衡。
3. 问题拆解的简化思路
将 “单词搜索” 拆解为 “起始点筛选→相邻探索→状态管理” 三个子问题,每个子问题用对应的数据结构解决:
- 起始点筛选:用网格遍历 + 字符匹配(剪枝);
- 相邻探索:用方向数组 + 边界判断;
- 状态管理:用栈(自动 / 手动)实现回溯。这种 “分而治之” 的思维可迁移到 “岛屿数量”“路径总和” 等网格类问题。