在LeetCode的字符串类题目中,“添加与搜索单词”是一道经典的设计题,核心考察**字典树(Trie)**的应用,同时结合深度优先搜索(DFS)解决通配符匹配问题。这道题不仅能帮我们掌握字典树的基本操作,还能理解递归在复杂匹配场景中的灵活运用,今天就来一步步拆解这道题的解题思路和代码实现。
一、题目解读:核心需求与难点
题目要求我们设计一个WordDictionary类,支持两个核心操作:
-
addWord(word):将单词添加到数据结构中,用于后续匹配; -
search(word):判断数据结构中是否存在与目标单词匹配的字符串,其中word中可能包含.,每个.可以匹配任意一个英文字母。
这道题的难点不在于“添加单词”,而在于“搜索单词”中的通配符.——它打破了常规的精确匹配,需要我们在搜索时考虑所有可能的字母组合,这也是为什么需要结合DFS的原因。
二、数据结构选择:为什么是字典树?
面对“字符串添加”和“前缀/模糊匹配”的场景,字典树(Trie)是最优选择之一,原因如下:
-
字典树的结构天然适合存储字符串集合,每个节点代表一个字符,从根节点到叶子节点的路径对应一个完整单词,添加单词时效率为O(n)(n为单词长度);
-
对于模糊匹配(如包含
.),字典树可以方便地遍历当前节点的所有子节点,结合递归(DFS)实现通配符的匹配逻辑; -
相比哈希表,字典树在处理前缀匹配、模糊匹配时,空间利用率更高,且避免了哈希冲突的问题。
字典树的每个节点需要包含两个核心属性:
-
children:存储当前节点的子节点,key为字符,value为子节点对象(形成递归结构); -
isEnd:标记当前节点是否为某个单词的结尾(避免区分“前缀”和“完整单词”)。
三、完整代码实现(TypeScript)
先给出完整的代码实现,后续逐方法拆解核心逻辑,代码兼顾可读性和效率,与题目要求完全匹配:
class WordDictionary {
private children: Record<string, WordDictionary>;
private isEnd: boolean;
constructor() {
this.children = {}; // 存储子节点,key为字符,value为子节点
this.isEnd = false; // 标记当前节点是否是单词结尾
}
addWord(word: string): void {
let node: WordDictionary = this; // 从根节点开始遍历
for (const ch of word) {
// 如果当前字符对应的子节点不存在,创建新节点
if (!node.children[ch]) {
node.children[ch] = new WordDictionary();
}
// 移动到子节点,继续处理下一个字符
node = node.children[ch];
}
// 单词遍历结束,标记当前节点为单词结尾
node.isEnd = true;
}
search(word: string): boolean {
// 深度优先搜索:index表示当前匹配到的单词下标,node表示当前遍历的字典树节点
const dfs = (index: number, node: WordDictionary): boolean => {
// 递归终止条件:匹配完所有字符,判断当前节点是否是单词结尾
if (index === word.length) {
return node.isEnd;
}
const ch = word[index];
// 情况1:当前字符不是通配符,精确匹配
if (ch !== '.') {
const child = node.children[ch];
// 子节点存在,且递归匹配下一个字符成功,返回true
if (child && dfs(index + 1, child)) {
return true;
}
} else {
// 情况2:当前字符是通配符,遍历所有子节点,尝试匹配
for (const str in node.children) {
const child = node.children[str];
// 任意一个子节点递归匹配成功,就返回true
if (child && dfs(index + 1, child)) {
return true;
}
}
}
// 以上情况都不满足,匹配失败
return false;
}
// 从根节点、下标0开始DFS
return dfs(0, this);
}
}
/**
* Your WordDictionary object will be instantiated and called as such:
* var obj = new WordDictionary()
* obj.addWord(word)
* var param_2 = obj.search(word)
*/
四、核心方法拆解
1. 构造函数(constructor)
初始化字典树的根节点:
-
children:用对象(Record)存储子节点,键是字符(如'a'、'b'),值是子节点实例,这样可以快速通过字符定位子节点; -
isEnd:初始化为false,因为根节点本身不代表任何单词的结尾。
2. 添加单词(addWord方法)
添加单词的逻辑本质是“构建字典树”,步骤如下:
-
从根节点(
this)开始,遍历单词的每一个字符; -
对于当前字符,如果当前节点的
children中没有该字符对应的子节点,就创建一个新的WordDictionary实例作为子节点; -
将当前节点移动到该子节点,继续处理下一个字符;
-
当单词遍历完毕,将最后一个节点的
isEnd设为true,标记这是一个完整单词的结尾。
示例:添加单词"apple",会从根节点开始,依次创建'a'、'p'、'p'、'l'、'e'对应的节点,最后将'e'节点的isEnd设为true。
3. 搜索单词(search方法)
搜索单词是本题的核心,由于存在通配符.,我们采用**深度优先搜索(DFS)**来处理所有可能的匹配情况,核心逻辑分为两部分:
(1)DFS辅助函数
定义一个内部DFS函数,接收两个参数:
-
index:当前匹配到单词的第几个字符(从0开始); -
node:当前遍历到字典树的哪个节点。
递归终止条件:当index等于单词长度时,说明已经匹配完所有字符,此时只需判断当前节点的isEnd是否为true(即是否是一个完整单词)。
(2)两种匹配情况
-
精确匹配(当前字符不是'.'):
-
检查当前节点的
children中是否存在该字符对应的子节点; -
如果存在,递归调用DFS,处理下一个字符(
index+1),并将当前子节点作为新的node; -
如果子节点不存在,或递归匹配失败,继续判断其他情况(最终返回
false)。
-
-
通配符匹配(当前字符是'.'):
-
通配符可以匹配任意字符,因此需要遍历当前节点的所有子节点;
-
对每个子节点,递归调用DFS,处理下一个字符;
-
只要有一个子节点递归匹配成功,就直接返回
true(剪枝优化,避免不必要的遍历)。
-
示例:搜索"ap.ple",当遍历到第3个字符'.'时,会遍历当前节点(第二个'p')的所有子节点,只要有一个子节点(此处是'l')能继续匹配后续字符,就返回true。
五、易错点与优化思路
1. 易错点总结
-
忘记标记
isEnd:添加单词时,如果不将最后一个节点的isEnd设为true,会导致搜索时无法区分“前缀”和“完整单词”(例如添加"app"后,搜索"app"会返回false); -
DFS递归终止条件错误:终止条件必须是
index === word.length,而不是node.children为空,否则会漏掉“单词长度与路径长度一致但不是结尾”的情况; -
通配符遍历遗漏:遍历子节点时,要确保遍历所有
children中的键,不能遗漏任何一种可能的字符。
2. 优化思路
-
空间优化:如果单词集合中重复前缀较少,字典树的空间优势不明显,可以考虑结合哈希表分组(按单词长度分组),搜索时先根据长度过滤,再进行匹配,减少DFS的次数;
-
时间优化:在DFS中加入剪枝逻辑,一旦找到匹配的子节点,立即返回
true,避免不必要的遍历; -
边界处理:针对空字符串、单个字符、全通配符(如
"....")等边界情况,提前做判断,提升效率。
六、总结
LeetCode 211题的核心是“字典树+DFS”的组合应用:字典树负责高效存储和快速定位字符串前缀,DFS负责处理通配符带来的模糊匹配问题。通过这道题,我们不仅掌握了字典树的基本操作(添加、遍历),还理解了递归在复杂匹配场景中的应用逻辑。
其实字典树的应用非常广泛,比如搜索引擎的自动补全、拼写检查、IP路由最长前缀匹配等场景,掌握这道题的思路,能为后续解决类似的字符串匹配问题打下坚实的基础。