目录

题目描述

676. 实现一个魔法字典

题意分析

要什么:设计一个数据结构,buildDict 一次性载入一批互不相同的单词;search(word) 回答「能否把 word恰好一个字符换成另一个字符,使它变成字典里的某个单词」。
约束透露的信号:「恰好一个」是双向的硬约束——完全相同(改动 0 处)要返回 false,差异两处以上也要返回 false。这意味着替换后的候选串必须与原串在且仅在一个位置不同,枚举替换时必须显式跳过「换成自己」。另外,只有替换操作,没有插入和删除,所以匹配的两个串长度必然相等,可以按长度先做一层筛选。字符集是小写字母共 26 个,单词数与长度都不超过 100,规模很小,允许每次查询做常数倍的枚举。
边界buildDict 只调用一次,之后才会有若干次 search,所以预处理可以做得重一些;字典中的单词互不相同,不必去重;单词长度可能为 1,此时唯一的位置就是全部;查询词可能根本不在字典里,也可能恰好在字典里(此时必须返回 false)。

解法:枚举替换 + 哈希集合

核心思路

最朴素的做法是每次查询都遍历字典里的每个单词,逐字符比较统计差异个数,恰好为 1 就返回 true。设字典有 n 个词、词长 L,单次查询是 $O(nL)$。这在本题数据下其实也能过,但它把「查字典」这件事退化成了线性扫描,没有利用「字符集只有 26 个」这个更强的条件。
瓶颈在于比较的方向反了:我们在拿一个查询词去和所有字典词逐个对照,而其实满足条件的字典词只有极少量候选——把查询词的某一位换成另一个字母,得到的串数量只有 $25L$ 个,且每一个都能直接查表判断在不在字典里。
于是把问题反过来做:主动生成所有「与查询词恰好差一位」的候选串,逐个到哈希集合里查存在性。候选集的规模只与词长和字符集大小有关,与字典规模完全无关。
由此确定要维护的状态:一个存放全部字典单词的哈希集合。查询过程的不变量是:在处理第 i 位时,字符数组中除第 i 位外的所有位置都保持查询词的原始字符——这条不变量保证生成的每个候选与原串差异恰好为 1,也正是为什么每轮内层循环结束后必须把第 i 位复原。
「恰好一个」的下界(不能是 0 处改动)靠内层跳过 ch == old 来保证;上界(不能是 2 处以上)靠一次只改一位、且改完立刻复原来保证。

解题步骤

  • buildDict 把所有单词塞进哈希集合。为什么用集合而不是列表:查询阶段需要的是 $O(L)$ 的存在性判断(哈希一次字符串),列表只能线性查找,会把每次查询拖回 $O(nL)$。
  • search 先把查询词转成可变的字符数组。为什么要转数组:Java 的 String 和 Go 的 string 都不可变,若每次替换都用切片拼接会产生大量临时对象;转成 char[] / []byte 后只需改一个位置再整体构造一次候选串。
  • 外层遍历位置 i,先把原字符存进 old为什么必须先存:内层会反复覆盖这一位,没有备份就无法复原,也无法判断「换成的是不是自己」。
  • 内层遍历 'a''z',遇到 ch == old 就跳过。为什么必须跳过:不跳过就等于允许「改动 0 处」,查询词本身若在字典里会被误判为 true,直接违反「恰好一个」。
  • 把第 i 位改成 ch,构造候选串查集合;命中就复原并返回 true为什么命中也要复原:本题里返回后数组即被丢弃,复原不影响正确性,但保持「函数不留下副作用」是好习惯;若把字符数组提升为成员变量复用,不复原就会污染下一次查询。
  • 内层结束后把第 i 位复原成 old,再进入下一个位置。为什么这一步是正确性的关键:不复原会让上一轮的改动残留,下一轮再改一位就变成「差异两处」,既可能漏判也可能错判。
  • 全部位置试完仍无命中,返回 false
  • buildDict(["hello", "leetcode"]) 后调用 search("hhllo") 走一遍。字符数组为 ['h','h','l','l','o']i = 0old = 'h',依次把首位换成 ab、…(跳过 h),得到 ahllobhllo 等 25 个候选,都不在集合里;内层结束后复原首位为 hi = 1old = 'h',换成 ahallo 不在集合,换成 bcd 均不在,换成 e 得到 hello——命中集合,复原后立即返回 true。整个过程只查了 30 次左右,与字典大小无关。再看 search("hello"):每一位都会被换成 25 个别的字母,得到的 125 个候选没有一个在字典里(hello 自己因 ch == old 被跳过),最终返回 false,这正是「不允许 0 处改动」的体现。

代码实现

// 核心实现:枚举替换 + 哈希集合,维护必要状态并避免重复处理。
class MagicDictionary {
    private final Set<String> set = new HashSet<>();

    public void buildDict(String[] dictionary) {
        for (String w : dictionary) {
            set.add(w);
        }
    }

    public boolean search(String searchWord) {
        char[] s = searchWord.toCharArray();
        for (int i = 0; i < s.length; i++) {
            char old = s[i];
            for (char ch = 'a'; ch <= 'z'; ch++) {
                if (ch == old) {
                    continue;
                }
                s[i] = ch;
                if (set.contains(new String(s))) {
                    s[i] = old;
                    return true;
                }
            }
            s[i] = old;
        }
        return false;
    }
}
// 核心实现:枚举替换 + 哈希集合,维护必要状态并避免重复处理。
type MagicDictionary struct {
    set map[string]struct{}
}

func Constructor() MagicDictionary {
    return MagicDictionary{set: make(map[string]struct{})}
}

func (m *MagicDictionary) BuildDict(dictionary []string) {
    for _, w := range dictionary {
        m.set[w] = struct{}{}
    }
}

func (m *MagicDictionary) Search(searchWord string) bool {
    b := []byte(searchWord)
    for i := 0; i < len(b); i++ {
        old := b[i]
        for ch := byte('a'); ch <= byte('z'); ch++ {
            if ch == old {
                continue
            }
            b[i] = ch
            if _, ok := m.set[string(b)]; ok {
                b[i] = old
                return true
            }
        }
        b[i] = old
    }
    return false
}

复杂度分析

  • 时间复杂度buildDict 为 $O(\sum w )$,即字典总字符数,每个单词入集合需要哈希一遍;search 为 $O(25 L^2)$,其中 L 是查询词长度——外层 L 个位置、内层 25 个候选字母,每个候选都要构造并哈希一个长度为 L 的字符串。凭什么与字典规模无关:候选集合完全由查询词和字符集决定,字典只承担 $O(L)$ 的哈希查表。
  • 空间复杂度:$O(\sum w )$。凭什么:哈希集合完整保存了所有字典单词;查询时只额外用一个长度 L 的字符数组和一个候选串,量级更小。

关键点总结

  • 候选枚举的方向要选规模小的那一边:与其拿查询词去比对整个字典(规模 $n$),不如生成「差一位」的全部候选(规模 $25L$)再查表。当字典可能很大而字符集很小的时候,这个方向的收益极为可观。
  • 「恰好 K 处不同」这类约束要拆成下界与上界两条分别落实:本题的下界靠「跳过换成自己」,上界靠「一次只改一位并及时复原」。少任何一条都会得到看起来能过样例、实则错误的实现。
  • 复原是可变缓冲区的纪律。用可变数组做枚举是为了省内存和时间,代价就是必须自己维护「除当前位外一切照旧」这条不变量,写完枚举循环立刻补上复原语句已经该成为肌肉记忆(回溯法里也是同一条纪律)。
  • 设计类题目要先看调用比例:本题 buildDict 只调一次而 search 会调很多次,所以把成本压在预处理上、让查询尽量快是正确的取舍。
  • 面试视角:本题另有一条 Trie + 带修改次数的 DFS 解法——在树上沿查询词下行,额外携带「已用掉几次修改」的参数,允许在某个节点转向别的字符分支但只允许一次,走到词尾时要求修改次数恰好为 1。它在字典极大、需要支持前缀相关扩展时更优。能同时给出两条并说明选择依据(「字符集小、词长短就枚举替换;要支持前缀查询或字典巨大就上 Trie」)是本题的加分答法。

易错点总结

  • 错误写法:内层不跳过 ch == old;用例 buildDict(["hello"])search("hello") → 候选串包含 hello 自己,命中集合返回 true,正确答案是 false
  • 错误写法:内层循环结束后忘记把 s[i] 复原;用例 buildDict(["hello"])search("hallo") → 处理完 i = 0 后首位残留成 z 之类,处理 i = 1 时生成的是「差两位」的串,hello 永远拼不出来,返回 false,正确答案是 true
  • 错误写法:命中后直接 return true 却把字符数组声明成了成员变量并且不复原;用例 连续两次 search("hhllo") → 第二次查询在被污染的数组上进行,结果不可预测。
  • 错误写法:改用「逐个比对字典单词、统计差异数」但把条件写成 diff <= 1;用例 buildDict(["hello"])search("hello") → 差异数 0 也被接受,返回 true,正确答案是 false
  • 错误写法:逐个比对时忘记先判断长度是否相等;用例 buildDict(["hello"])search("hell") → 逐位比较时下标越界,或按较短长度比较得出「差 0 处」的错误结论。
  • 错误写法:在 buildDict 里就预先生成所有「差一位」的变体存进集合;用例 字典有 100 个长度 100 的单词 → 集合膨胀到 25 万条长串,内存与建表时间都远超必要;更糟的是查询时无法区分「命中的是变体还是原词」,search 传入字典中已有的词会被误判为 true
  • 错误写法:内层从 'a' 循环到 'z' 时用 intchar 混用导致越界,例如 Go 里写 for ch := 'a'; ch <= 'z'; ch++ 得到 rune 再直接赋给 []byte;用例 任意查询 → 类型不匹配编译失败,或强转后写入错误字节。
  • 错误写法:用 List 而不是 Set 存字典并用 contains 查找;用例 字典规模较大且查询频繁 → 每次候选查找退化成 $O(nL)$,总代价变成 $O(25nL^2)$,在更大数据下超时。
  • 错误写法:认为只需比较相同首字母的单词从而按首字母分桶,却忘了差异位可能就在首位;用例 buildDict(["hello"])search("jello") → 按首字母 j 分桶找不到任何候选,返回 false,正确答案是 true
  • 错误写法:buildDict 被重复调用时没有清空旧集合(若题目允许多次调用);用例 先后两次 buildDict → 旧词残留导致查询命中已被替换掉的字典内容。本题只调用一次所以安全,但把状态清理写进构建方法是更稳妥的习惯。

相似题目

题目 难度 考察点
208. 实现 Trie (前缀树) 中等 精确匹配与前缀判断,没有任何模糊匹配,是本题 Trie 解法的地基
211. 添加与搜索单词 - 数据结构设计 中等 通配符 . 可出现任意多次且位置已知,DFS 无需携带「剩余修改次数」
648. 单词替换 中等 匹配的是最短前缀而非等长模糊串,命中即停,不涉及任何字符替换