在 LeetCode 的字符串与矩阵类题目中,212. 单词搜索 II 算是难度中等偏上的经典题。它结合了「前缀树(Trie)」和「深度优先搜索(DFS)」两大核心算法,既考察对数据结构的灵活运用,也考验对回溯思想的理解。今天就来一步步拆解这道题,从题目分析到代码实现,再到细节优化,帮大家彻底搞懂它的解题逻辑。
一、题目核心要求
先明确题目给出的条件和目标,避免解题走偏:
-
输入:一个 m x n 的二维字符网格 board,一个单词列表 words;
-
输出:所有能在网格中找到的单词(去重);
-
规则:单词必须通过「相邻单元格」的字母按顺序构成(水平/垂直相邻,斜向不算),且同一个单元格的字母不能重复使用。
举个简单例子:若 board 中有「oath」四个相邻字母,且 words 中包含该单词,则它会被计入结果;但如果字母不相邻,或有重复使用的单元格,则不算。
二、解题思路分析(为什么用 Trie+DFS?)
先思考一个朴素解法:遍历 words 中的每个单词,再用 DFS/BFS 在网格中搜索该单词是否存在。这种方法的问题很明显——效率极低。
假设 words 有 k 个单词,每个单词长度为 L,网格大小为 m*n。每个单词的搜索时间是 O(m*n*3^L)(3 是因为每个步骤有 3 个方向可选,排除来时的方向),总时间就是 O(k*m*n*3^L)。当 k 较大、L 较长时,很容易超时。
而 Trie(前缀树)的核心作用,就是「合并单词的公共前缀」,减少重复搜索。比如 words 中有「oath」和「oaht」,它们的前缀「oa」是公共的,用 Trie 存储后,DFS 时只需搜索一次「oa」,就能同时匹配两个单词,大幅提升效率。
因此,最优解题思路是:
-
将 words 中的所有单词插入到 Trie 中,构建前缀树;
-
遍历网格的每个单元格,以该单元格为起点,进行 DFS;
-
DFS 过程中,结合 Trie 进行剪枝:如果当前路径的字符不在 Trie 的前缀中,直接终止该路径的搜索;
-
当搜索到 Trie 中的「单词结尾节点」时,将该单词加入结果集(去重);
-
利用「标记已访问」的方式(修改网格字符为特殊符号),避免重复使用单元格,搜索结束后回溯恢复。
三、代码逐段解析(附完整代码)
下面结合题目给出的完整代码,逐部分拆解,搞懂每一行的作用。
1. 前缀树(Trie)的实现
首先定义 Trie 的节点结构(TrieNode)和 Trie 本身,这是整个解法的基础。
class TrieNode {
children: Map<string, TrieNode>; // 存储当前节点的子节点,key是字符,value是子节点
isEnd: boolean; // 标记当前节点是否是某个单词的结尾
word: string; // 存储当前节点对应的完整单词(仅当isEnd为true时有效)
constructor() {
this.children = new Map(); // 初始化子节点集合
this.isEnd = false; // 初始不是单词结尾
this.word = ''; // 初始无对应单词
}
}
class Trie {
root: TrieNode; // 前缀树的根节点(空节点)
constructor() {
this.root = new TrieNode(); // 初始化根节点
}
// 插入单词到前缀树中
insert(word: string) {
if (!word) return; // 空单词无需插入
let node: TrieNode = this.root; // 从根节点开始遍历
for (const char of word) {
// 如果当前字符不在子节点中,创建新节点
if (!node.children.has(char)) {
node.children.set(char, new TrieNode());
}
// 移动到当前字符对应的子节点
node = node.children.get(char)!;
}
// 遍历完单词后,标记当前节点为单词结尾,并存储完整单词
node.isEnd = true;
node.word = word;
}
}
关键说明:
-
TrieNode 中的 children 用 Map 存储,比对象更灵活(避免字符作为键名的兼容问题);
-
isEnd 用于标记单词结尾,比如插入「oath」后,最后一个「h」对应的节点 isEnd 为 true;
-
word 存储完整单词,这样找到结尾节点时,无需再拼接字符,直接获取即可,提升效率。
2. 主函数 findWords 实现
主函数负责初始化 Trie、遍历网格、触发 DFS,并返回结果集。
function findWords(board: string[][], words: string[]): string[] {
const result = new Set<string>(); // 用Set去重,避免重复添加相同单词
// 边界判断:网格为空或单词列表为空,直接返回空数组
if (board.length === 0 || board[0].length === 0 || words.length === 0) {
return Array.from(result);
}
const rows = board.length; // 网格行数
const cols = board[0].length; // 网格列数
const trie = new Trie(); // 初始化前缀树
words.forEach((word) => trie.insert(word)); // 将所有单词插入Trie
// 定义四个方向:上下左右(水平/垂直相邻)
const dir = [[0, 1], [0, -1], [1, 0], [-1, 0]];
// DFS函数:从(row, col)位置开始,结合Trie搜索单词
const dfs = (row: number, col: number, node: TrieNode) => {
// 终止条件(剪枝):
// 1. 越界(行/列超出网格范围)
// 2. 当前单元格为空(被标记为已访问)
// 3. 当前单元格字符不在当前Trie节点的子节点中(前缀不匹配)
if (row < 0 || row >= rows || col< 0 || col >= cols || !board[row][col] || board[row][col] === '#' || !node.children.has(board[row][col])) {
return;
}
const currChar = board[row][col]; // 当前单元格的字符
const currNode = node.children.get(currChar)!; // 当前字符对应的Trie节点
// 如果当前节点是单词结尾,将单词加入结果集
if (currNode.isEnd && currNode.word) {
result.add(currNode.word);
}
// 标记当前单元格为已访问(用'#'替换,避免重复使用)
board[row][col] = '#';
// 遍历四个方向,继续DFS
for (const [dx, dy] of dir) {
dfs(row + dx, col + dy, currNode);
}
// 回溯:恢复当前单元格的原始字符(供其他路径使用)
board[row][col] = currChar;
}
// 遍历网格的每个单元格,作为DFS的起点
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
dfs(i, j, trie.root);
}
}
return Array.from(result); // 将Set转为数组返回
};
3. 核心细节拆解(重点!)
这部分是解题的关键,也是容易出错的地方,一定要重点理解:
(1)边界判断与剪枝
DFS 的终止条件包含多个剪枝场景,目的是尽早终止无效路径,提升效率:
-
越界判断:row 和 col 必须在 [0, rows-1] 和 [0, cols-1] 范围内;
-
已访问判断:board[row][col] === '#' 表示该单元格已被当前路径使用,不能重复使用;
-
前缀不匹配判断:如果当前单元格的字符不在 Trie 节点的子节点中,说明当前路径无法构成任何单词,直接终止。
(2)标记与回溯
在 DFS 开始前,将当前单元格标记为 '#'(已访问),避免在同一路径中重复使用;DFS 结束后,再将其恢复为原始字符。这是「回溯思想」的典型应用——探索完一条路径后,恢复现场,供其他路径探索。
(3)去重处理
用 Set 存储结果,而不是数组,是因为网格中可能存在多条路径找到同一个单词(比如不同起点但路径相同),Set 能自动去重,最后转为数组返回即可。
(4)Trie 的作用体现
每次 DFS 都从 Trie 的根节点(或当前匹配的子节点)出发,只有当前字符在 Trie 中时,才继续搜索。比如 words 中没有以「z」开头的单词,当网格中遇到「z」时,DFS 会直接终止,无需继续探索,这就是 Trie 带来的剪枝优化。
四、复杂度分析
理解复杂度,能更好地掌握算法的优劣:
-
时间复杂度:O(m
*n*3^L),其中 m、n 是网格的行数和列数,L 是 words 中最长单词的长度。-
m
*n:遍历网格的每个单元格; -
3^L:每个单元格有 3 个有效方向(排除来时的方向),最多搜索 L 层(单词长度)。
-
-
空间复杂度:O(k
*L),其中 k 是 words 中单词的数量,L 是最长单词的长度。主要用于存储 Trie 的节点(每个单词的每个字符对应一个节点)。
五、常见问题与优化建议
1. 常见错误点
-
忘记回溯:标记为 '#' 后未恢复原始字符,导致后续路径无法正常搜索;
-
未去重:用数组存储结果,导致同一单词被多次添加;
-
Trie 实现错误:比如插入单词时,未正确移动到子节点,或未标记 isEnd 和 word;
-
方向数组错误:加入斜向方向,违反题目要求。
2. 优化方向
如果遇到超时问题,可以尝试以下优化:
-
Trie 剪枝:当某个 Trie 节点的子节点为空,且不是单词结尾时,可以删除该节点,减少后续搜索的分支;
-
提前终止:当结果集的大小等于 words 的大小(所有单词都找到),可以直接终止所有搜索;
-
网格预处理:统计网格中每个字符的出现次数,如果 words 中的某个单词包含网格中没有的字符,直接跳过该单词(无需插入 Trie)。
六、总结
LeetCode 212. 单词搜索 II 的核心是「Trie 优化 DFS」,本质是用 Trie 合并公共前缀,减少重复搜索,再用 DFS 探索网格中的所有可能路径,结合回溯思想避免重复使用单元格。
解题的关键的是:
-
正确实现 Trie 的插入逻辑,确保单词能被正确存储和匹配;
-
掌握 DFS 的剪枝技巧,尽早终止无效路径;
-
理解回溯思想,正确标记和恢复已访问的单元格。
这道题不仅考察算法的运用,更考察对数据结构与算法结合的理解。掌握这种思路后,类似的「字符串匹配+网格搜索」题目(比如 LeetCode 79. 单词搜索)也能迎刃而解。建议大家动手敲一遍代码,调试每一步的执行过程,加深对 Trie 和 DFS 结合的理解。