目录

题目描述

211. 添加与搜索单词 - 数据结构设计

题意分析

需要设计一个支持两种操作的容器:addWord 把一个单词存进去,search 判断是否存在一个已存过的单词能与给定模式串完全匹配,模式串里可能出现 .,一个 . 恰好匹配任意一个小写字母。

关键在于「完全匹配」而不是「前缀匹配」,长度必须相等,所以 . 不能吞掉多个字符,也不能匹配空。

字符集只有 26 个小写字母,单词长度不超过 25,addWordsearch 合计最多调用 10^4 次,其中带 . 的查询保证最多含 2 个 .。字符集小且固定,暗示每个节点可以用定长 26 的数组而不是哈希表来存孩子;. 的数量被限制住,暗示在 . 处做分叉枚举是被出题人允许的开销。

边界包括:同一个单词被重复添加、模式串全是 .、模式串比库中所有单词都长或都短、以及在还没添加任何单词时就查询。

解法:Trie + DFS

核心思路

暴力做法是把所有单词存进一个列表,每次 search 遍历列表逐个做「同长度且逐位匹配,. 位跳过」的比较。这是对的,但每次查询要扫全部单词,总代价是「单词数 × 单词长度」,在 10^4 次查询下退化成千万次字符比较,瓶颈在于不同单词的公共前缀被反复重新比较了无数遍。

观察点是:所有单词按字符逐位展开后,公共前缀天然共享同一条路径。如果把单词组织成一棵以字符为边的树,那么「按位比较」就变成「沿边下沉」,一次下沉同时代表了所有共享该前缀的单词,公共前缀的重复比较被彻底消掉。

. 的存在让匹配不再是唯一路径,而是从当前节点出发的一次分叉。但分叉只发生在 . 所在的这一层,且分支数被字符集大小 26 卡死,因此变成一个可控的深度优先搜索。

数据结构不变量:树上从根到任意节点的边序列,恰好是某个已添加单词的一个前缀;节点上的布尔标记 isEnd 为真,当且仅当从根到该节点的边序列本身是一个被完整添加过的单词。有了这条不变量,「完全匹配」就等价于「沿着模式串走满全部字符后,落点节点的 isEnd 为真」。

递归函数的语义固定为:dfs(pos, node) 返回「以 node 为根的子树中,是否存在一条路径能匹配模式串从下标 pos 到末尾的这一段」。这个返回值含义在空节点、走到串尾、普通字符、通配符四种情况下必须完全一致。

解题步骤

  • 定义节点结构:一个长度 26 的孩子指针数组加一个 isEnd 布尔位。之所以用定长数组而不是哈希表,是因为字符集固定,数组下标 c - 'a' 的定位是无分支的常数操作,且在 . 分叉时可以直接顺序枚举 26 个槽位,代码比遍历哈希表的 entry 更短更稳。
  • addWord 从根出发逐字符下沉,孩子为空就新建节点,走完全部字符后把落点的 isEnd 置真。之所以只在末尾置真而不在中途置真,是因为 isEnd 承担的正是「这里是一个完整单词的终点」这一唯一语义,中途置真会让 "apple" 的前缀 "app" 被误判为已存在。
  • search 直接调用 dfs(word, 0, root),把所有逻辑收进递归。之所以不写迭代,是因为 . 会产生分叉,迭代需要手动维护栈,而分叉深度天然对应递归深度,递归写法更贴合问题结构。
  • dfs 的第一件事是判空节点返回 false。之所以要判,是因为普通字符分支会直接把 node.next[c - 'a'] 传下去而不做检查,把空指针的处理统一收拢到函数入口,比在每个调用点各写一次判断更不容易漏。
  • dfs 的第二件事是判 pos == word.length() 时返回 node.isEnd。之所以返回 isEnd 而不是 true,正是「完全匹配」与「前缀匹配」的分水岭:模式串走完了,但当前节点未必是某个单词的终点。
  • 遇到 . 时枚举 26 个孩子,任一非空孩子的 dfs(pos + 1, child) 为真就立刻返回真。之所以能短路返回,是因为题目只问存在性,找到一条可行路径后其余分支的结果不影响答案。
  • 遇到普通字符时只递归唯一确定的那个孩子。之所以不需要循环,是因为普通字符把分支因子压回 1,这也是为什么 . 的个数才是复杂度的主导因素。

以调用序列 addWord("bad")addWord("dad")addWord("mad")search("pad")search(".ad") 走一遍。三次添加后,根节点的 bdm 三个槽位各挂一棵深度为 3 的链,三条链的末端节点 isEnd 都为真。

search("pad")dfs(0, root)pos = 0 不等于 3,字符 p 不是 .,取 root.next['p' - 'a'],这个槽从未被写过,为空,递归进入后在入口判空返回 false,整个查询返回 false,符合预期。

search(".ad")dfs(0, root),字符是 .,进入枚举。下标 1 对应 b,孩子非空,递归 dfs(1, b节点)pos = 1,字符 a 非通配,取 b节点.next['a' - 'a'],非空,递归 dfs(2, ba节点);字符 d,取 ba节点.next['d' - 'a'],非空,递归 dfs(3, bad节点)pos = 3 等于串长,返回该节点的 isEnd,为真。真值沿调用链一路短路返回,. 的枚举在第一个命中的分支就停下,不再试 dm,最终返回 true

代码实现

class WordDictionary {
    private final TrieNode root = new TrieNode();

    public WordDictionary() {
    }

    public void addWord(String word) {
        TrieNode node = root;
        for (int i = 0; i < word.length(); i++) {
            int idx = word.charAt(i) - 'a';
            if (node.next[idx] == null) {
                node.next[idx] = new TrieNode();
            }
            node = node.next[idx];
        }
        node.isEnd = true;
    }

    public boolean search(String word) {
        return dfs(word, 0, root);
    }

    private boolean dfs(String word, int pos, TrieNode node) {
        if (node == null) {
            return false;
        }
        if (pos == word.length()) {
            return node.isEnd;
        }

        char c = word.charAt(pos);
        if (c == '.') {
            for (TrieNode child : node.next) {
                if (child != null && dfs(word, pos + 1, child)) {
                    return true;
                }
            }
            return false;
        }

        return dfs(word, pos + 1, node.next[c - 'a']);
    }

    private static class TrieNode {
        TrieNode[] next = new TrieNode[26];
        boolean isEnd;
    }
}
type WordDictionary struct {
    root *TrieNode
}

type TrieNode struct {
    next  [26]*TrieNode
    isEnd bool
}

func Constructor() WordDictionary {
    return WordDictionary{root: new(TrieNode)}
}

func (w *WordDictionary) AddWord(word string) {
    node := w.root
    for i := 0; i < len(word); i++ {
        idx := word[i] - 'a'
        if node.next[idx] == nil {
            node.next[idx] = &TrieNode{}
        }
        node = node.next[idx]
    }
    node.isEnd = true
}

func (w *WordDictionary) Search(word string) bool {
    var dfs func(int, *TrieNode) bool
    dfs = func(pos int, node *TrieNode) bool {
        if node == nil {
            return false
        }
        if pos == len(word) {
            return node.isEnd
        }

        c := word[pos]
        if c == '.' {
            for i := 0; i < 26; i++ {
                if node.next[i] != nil && dfs(pos+1, node.next[i]) {
                    return true
                }
            }
            return false
        }

        return dfs(pos+1, node.next[c-'a'])
    }

    return dfs(0, w.root)
}

复杂度分析

  • 时间复杂度addWord 为 $O(L)$,因为只是沿着 $L$ 个字符各下沉一层;search 为 $O(26^k \cdot L)$,其中 $L$ 是模式串长度、$k$ 是其中 . 的个数,凭据是普通字符处分支因子恒为 1,只有 . 处会把当前搜索状态扩展成至多 26 个,$k$ 个通配符叠乘就得到这个上界,题目限定 $k \le 2$ 时实际只有常数倍开销。
  • 空间复杂度:$O(26 \cdot N)$,其中 $N$ 是所有已添加单词的字符总数,凭据是每插入一个新字符最多新建一个节点,而每个节点固定持有 26 个孩子指针;递归栈深度不超过模式串长度 $L$,被节点开销吸收。

关键点总结

  • 「多个字符串共享前缀」这个信号出现时,应该立刻想到把线性容器换成前缀树,把重复的前缀比较折叠成一次路径下沉,这是所有前缀类题目的通用第一步。
  • isEnd 标记是区分「前缀存在」和「单词存在」的唯一手段,任何基于前缀树的设计题都必须先想清楚这个标记放在哪、由谁置位、被谁读取。
  • 递归函数的返回值语义必须一次性定死并在所有分支保持一致,本题定为「子树内能否匹配剩余模式段」,空节点、串尾、通配、普通四个分支才能各自独立地写正确。
  • 把空指针检查上提到递归入口,而不是分散在每个调用点,能显著减少分支遗漏,这在树和图的 DFS 里是可以稳定复用的写法。
  • 分支因子的来源要单独识别:本题只有 . 会制造分叉,识别出这一点后复杂度分析和优化方向都变得清晰,同样的思路可用于任何「带通配的匹配」问题。
  • 面试视角:面试官期待你先说清朴素列表存储的瓶颈在哪,再主动提出前缀树,然后当场把 . 的处理定位为「唯一的分叉点」并给出 $26^k$ 的估计。如果被追问优化,可以提「按长度分桶后再建树」或「记录每层最长单词长度以提前剪枝」,但不必真的写出来。

易错点总结

  • pos == word.length() 处直接 return true:用例先 addWord("apple")search("app"),会错误返回 true,因为把前缀当成了完整单词,必须返回 node.isEnd
  • 普通字符分支不判空就解引用,例如写成 return dfs(word, pos + 1, node.next[c - 'a'].xxx) 或在调用前访问孩子字段:用例 search("pad") 而库中只有 "bad"root.next['p'-'a']null,会直接抛空指针异常。
  • . 分支枚举孩子时写成 for (TrieNode child : node.next) return dfs(...),只试第一个非空孩子就返回:用例 addWord("bad")addWord("mad")search(".ad") 在字典只含 "mad" 时会返回 false,因为遇到空槽或首个失败分支就提前结束,必须遍历完所有 26 个槽才能返回 false
  • . 分支中把子递归的 false 也当成最终结果返回,例如 return dfs(word, pos + 1, child) 写在循环体内:用例 addWord("bad")addWord("dad")search(".ad"),先试到 b 失败就整体返回 false,漏掉了后面能匹配的 d 分支。
  • addWord 的循环中途给每个经过的节点都置 isEnd = true:用例 addWord("bad")search("ba") 会返回 true,把所有前缀都变成了合法单词。
  • . 当作可匹配零个或多个字符处理:用例 addWord("bad")search("."),会错误返回 true,而正确答案是 false,因为一个 . 只匹配恰好一个字符。
  • 忘记为每次 search 重新从 root 开始,而是复用了上一次查询结束时的节点指针:用例连续 search("bad")search("mad"),第二次会从 bad 的末端继续下沉,必然返回 false
  • HashMap<Character, TrieNode> 存孩子却在 . 分支里遍历 'a''z' 并对不存在的键调用 get 后直接使用:用例任何含 . 的查询,返回的 null 未判空会抛异常;用定长数组则天然把「不存在」表示为 null 槽位。
  • Go 版本把 next 声明成 []*TrieNode 但没有 make 初始化:用例第一次 AddWord("bad"),对 nil 切片按下标赋值会 panic,声明成数组 [26]*TrieNode 才是零值可用的。

相似题目

题目 难度 考察点
208. 实现 Trie (前缀树) 中等 无通配的基础版,只需迭代下沉,重点是区分 searchstartsWith
212. 单词搜索 II 困难 把前缀树挂到网格回溯上,考察边搜索边剪枝与命中后摘除节点
648. 单词替换 中等 求最短匹配前缀,下沉过程中一旦遇到 isEnd 就立即停止
677. 键值映射 中等 节点上存的是数值而非布尔,需要处理同键覆盖时的差值更新
720. 词典中最长的单词 中等 要求路径上每个前缀都是合法单词,遍历时需沿 isEnd 链推进