目录

题目描述

820. 单词的压缩编码

题意分析

题目给的是一套编码规则:把若干单词拼进一个参考串 s,再给每个单词配一个下标 indices[i],要求从 s[indices[i]] 一路读到下一个 # 恰好得到第 i 个单词。求 s 的最短长度。

把规则翻译一下就清楚了:s 一定是若干段「若干字符 + #」拼起来的,每个单词必须是某一段(不含 # 的部分)的后缀。所以真正要决定的是「选哪些单词单独占一段」,其余单词都得搭别人的车。

由此得到最关键的约束信号:能搭车的条件是「是别人的后缀」,不是前缀,也不是子串。"me" 能藏在 "time#" 的尾部,但 "tim" 就不行,因为从 't' 读起会读到 "time" 而不是 "tim"

另一个约束信号是数组里允许出现重复单词。两个一模一样的单词天然共用同一个下标,只该算一次长度。

边界上要留心:只有一个单词、所有单词互不为后缀、某个单词是多个单词的公共后缀、以及输入含完全重复的单词。

解法:哈希集合删除所有后缀

核心思路

暴力做法是两两比较,对每个单词检查它是否是别的某个单词的后缀,是就丢掉,最后把剩下的长度加一累加。两两比较是 $O(n^2)$ 次,每次后缀判断还要 $O(L)$,n 到 $2000$ 时勉强能过,但写起来还得小心「一个单词不能把自己判成自己的后缀」。

瓶颈在于「找谁是谁的后缀」这个方向搞反了。正向去找覆盖关系要做全体配对;反过来想,每个单词的后缀集合是可以直接列举出来的——长度为 L 的单词只有 $L - 1$ 个真后缀。既然能列举,就不必配对:把所有单词丢进一个哈希集合,然后对每个单词把它的全部真后缀从集合里删掉。

这样做的效果是:一个单词只要是另一个单词的真后缀,就一定会在处理那个更长单词时被删除。而删除是幂等的,重复删同一个串没有副作用。

显式的不变量是:处理完所有单词后,集合中剩下的每一个单词,都不是集合中任何其他单词的真后缀,也就是所谓的「极大单词」。这些单词必须各自单独占一段,贡献 $ w + 1$ 的长度(那个 +1 就是它自己的 #);而被删掉的单词全都能挂在某个极大单词的尾巴上,一个字符也不额外花。

顺带一提,哈希集合还免费解决了重复单词的问题:相同的单词进集合就自动合并成一个。

解题步骤

  • words 全部装进一个哈希集合。装集合的第一个作用是去重,第二个作用是让后面的删除操作是常数时间。
  • 遍历原始的 words 数组(而不是遍历集合)来枚举后缀。之所以必须遍历数组,是因为循环体里要修改集合,边遍历边删同一个容器会触发并发修改异常。
  • 对每个单词 word,让 i1 走到 word.length - 1,把 word 从下标 i 开始的后缀从集合里删掉。起点取 1 而不是 0 是关键:i = 0 对应单词自己,删了就等于把这个单词也当成可省略的,答案会偏小。
  • 删除时不需要判断该后缀是否存在,哈希集合的删除对不存在的键是空操作。
  • 遍历删干净后的集合,对每个剩余单词累加 长度 + 1。这个 +1 是它自己那个 #,不能漏。
  • 返回累加结果。

words = ["time", "me", "bell"] 走一遍:初始集合是 {"time", "me", "bell"}

处理 "time"i = 1 得到后缀 "ime",集合里没有,删除无效果;i = 2 得到 "me",命中,集合变成 {"time", "bell"}i = 3 得到 "e",不在集合里。

处理 "me"i = 1 得到 "e",不在集合里,集合不变。注意此时 "me" 本身已经不在集合中了,但这不影响枚举它的后缀,因为枚举的来源是数组而不是集合。

处理 "bell":依次得到 "ell""ll""l",都不在集合里,集合仍是 {"time", "bell"}

最后累加:"time" 贡献 $4 + 1 = 5$,"bell" 贡献 $4 + 1 = 5$,总长度是 10,对应的参考串正是 "time#bell#""me" 挂在下标 2 上。

再看重复用例 words = ["t", "t"]:集合装完只剩 {"t"},两个单词都只有一位,内层循环一次都不执行,最终返回 $1 + 1 = 2$,参考串是 "t#",两个下标都指向 0

代码实现

class Solution {
    public int minimumLengthEncoding(String[] words) {
        Set<String> set = new HashSet<>(Arrays.asList(words));
        for (String word : words) {
            for (int i = 1; i < word.length(); i++) {
                set.remove(word.substring(i));
            }
        }

        int ans = 0;
        for (String word : set) {
            ans += word.length() + 1;
        }
        return ans;
    }
}
func minimumLengthEncoding(words []string) int {
    set := make(map[string]bool)
    for _, word := range words {
        set[word] = true
    }

    for _, word := range words {
        for i := 1; i < len(word); i++ {
            delete(set, word[i:])
        }
    }

    ans := 0
    for word := range set {
        ans += len(word) + 1
    }
    return ans
}

复杂度分析

  • 时间复杂度:$O(nL^2)$,n 是单词个数,L 是单词最大长度。每个单词产生 $L - 1$ 个后缀,每个后缀的切分和哈希都要 $O(L)$,题目限定 $L \le 7$,所以实际接近线性。
  • 空间复杂度:$O(nL)$,哈希集合最多存下全部 n 个单词,每个单词占 $O(L)$ 字符;枚举后缀时临时构造的子串会被立刻回收,不叠加。

关键点总结

  • 当「找出谁被谁覆盖」难做时,试试反过来「把所有可能的被覆盖者删掉」。这题的后缀集合是可枚举且规模很小的,反向删除把 $O(n^2)$ 的两两配对降成了对每个单词的常数次哈希操作。
  • 把题面的操作规则先翻译成一句结构性判断(「每个单词必须是某段的后缀」),再翻译成一个集合性质(「留下的都是极大单词」),是这类构造题的标准解法路径。直接对着 indices# 硬想很容易卡住。
  • 哈希集合在这里一鱼两吃:既做去重,又做删除标记。识别出「重复单词天然共用下标」这个隐含条件,比补一段专门的去重代码更干净。
  • 修改容器和遍历容器必须分开。这里遍历原数组、修改集合,是回避并发修改问题最省事的写法。
  • 面试视角:面试官大概率会追问 Trie 解法。标准答案是把每个单词反过来插进一棵字典树,然后统计所有叶子节点的深度加一之和,复杂度同样是 $O(nL)$ 但不依赖哈希,且在 L 很大时优势明显。能主动说出「后缀问题就把串反过来变成前缀问题」这句转化,是这题的加分项。
  • 面试视角:说清 +1 是分隔符 #、说清 i1 起是为了排除单词自身,这两个细节比整体思路更能反映有没有真的写过一遍。

易错点总结

  • 错误写法:枚举后缀时让 i0 起,把单词自己也删掉。用例 ["time", "me", "bell"] → 处理 "time" 时先把 "time" 自己删了,处理 "bell" 时也一样,集合最后空掉,返回 0 而不是 10
  • 错误写法:不去重,直接遍历数组累加长度加一。用例 ["me", "me"] → 两个相同单词各算一次得 6,而它们共用同一个下标,正确答案是 3
  • 错误写法:把覆盖关系当成前缀关系,枚举 word.substring(0, i)。用例 ["time", "me", "bell"] → 删掉的是 "t""ti""tim" 之类,"me" 一直留在集合里,返回 $5 + 3 + 5 = 13$ 而不是 10
  • 错误写法:一边遍历集合一边从集合里删元素。用例 ["time", "me"] → 处理 "time" 时删掉了 "me",迭代器随即失效,Java 抛出 ConcurrentModificationException
  • 错误写法:累加时只加单词长度,漏掉分隔符。用例 ["time", "bell"] → 返回 4 + 4 = 8,而每段结尾都需要一个 #,正确答案是 10
  • 错误写法:只删掉去掉首字符后的那一个后缀,不枚举全部真后缀。用例 ["time", "me"] → 只删了 "ime""me" 留在集合里,返回 $5 + 3 = 8$ 而不是 5
  • 错误写法:认为更长的单词必然覆盖更短的,按长度排序后只保留最长的那个。用例 ["time", "bell"] → 两者长度相同且互不为后缀,各自都要单独编码,正确答案是 10,只留一个会返回 5
  • 错误写法:改用两两 endsWith 判断却忘了排除自己和自己比。用例 ["time"]"time".endsWith("time") 为真,唯一的单词被判成可省略,返回 0 而不是 5

相似题目

题目 难度 考察点
LCR 065. 单词的压缩编码 中等 同题的另一入口,适合对照哈希删除与反向 Trie 两种写法
208. 实现 Trie (前缀树) 中等 手写字典树的插入与查询,是本题 Trie 解法的前置基本功
648. 单词替换 中等 在字典树上找最短前缀匹配,方向与本题的后缀覆盖相反
14. 最长公共前缀 简单 同为一组字符串的公共结构,但只需纵向逐列比较