目录

题目描述

423. 从英文中重建数字

题意分析

输入是若干个英文数字单词(zeronine)的字母被完全打乱后拼在一起的字符串,要求还原出这些数字,并按升序输出成一个数字串。

最强的约束信号是「顺序被打乱」和「保证输入有效」这两句。前者意味着字母的先后位置不携带任何信息,字符串实际退化成了一个字母的多重集,唯一可用的量就是每个字母出现了多少次;后者意味着不必考虑无解或残留字母,任何合法推导得到的计数都一定是非负且自洽的。

输出要求升序,进一步说明连「哪个单词在前」都不需要还原,只需要知道每个数字各出现了几次。

规模上字符串长度可达 $10^5$ 量级,所以输出拼接不能用反复复制的方式;字符集固定为 26 个小写字母,计数结构可以开成定长数组而不是哈希表。

边界包括:空串(返回空串)、同一个数字重复出现很多次、以及只有一种数字的输入。

解法:唯一字母计数

核心思路

最直接的想法是搜索:维护当前剩余的字母计数,每次尝试减去某个单词所需的字母,减得动就递归下去,减不动就回溯。这样确实能还原,但分支因子是 10,深度是单词个数,在长输入下完全不可接受,而且要额外证明解的唯一性才敢在第一次成功时返回。

瓶颈在于搜索把十个单词一视同仁,完全没有利用它们字母构成上的差异。

观察这十个单词的字母分布会发现一件事:有些字母只属于唯一一个单词。字母 z 只出现在 zerow 只出现在 twou 只出现在 fourx 只出现在 sixg 只出现在 eight。于是 0、2、4、6、8 的个数可以被这五个字母的计数直接读出来,不需要任何搜索。

把这批数字的贡献扣掉之后,原本被共享的字母就变成了只含一个未知数的等式:h 出现在 threeeighteight 已知,于是 3 的个数等于 h 的计数减去 8 的个数;同理 f 出现在 fourfives 出现在 sixseven,分别定出 5 和 7。最后 o 出现在 zeroonetwofour 中,除 one 外都已知,定出 1;i 出现在 fivesixeightnine 中,除 nine 外都已知,定出 9。

因此本解法维持的不变量是:按「无共享字母的 0/2/4/6/8」→「只差一项的 3/5/7」→「依赖前两批的 1/9」这个顺序求解时,每一步待求数字所依赖的其他数字都已经被确定,整条消元链无环,一次线性扫描即可解完。

解题步骤

  • 开一个长度 26 的计数数组,扫描字符串统计每个小写字母的出现次数。之所以用定长数组而不是哈希表,是因为字符集固定,数组的常数更小且不会有装箱开销。
  • 用五个唯一字母直接定出第一批:nums[0] = cnt['z']nums[2] = cnt['w']nums[4] = cnt['u']nums[6] = cnt['x']nums[8] = cnt['g']。每个单词里这些字母都恰好出现一次,所以计数就等于个数,不需要再除以任何系数。
  • 用第一批的结果消元得到第二批:nums[3] = cnt['h'] - nums[8]nums[5] = cnt['f'] - nums[4]nums[7] = cnt['s'] - nums[6]。这三条式子成立的前提是右边被减去的数字已经确定,所以顺序不能提前。
  • 用前两批的结果解出最后两个:nums[1] = cnt['o'] - nums[0] - nums[2] - nums[4]nums[9] = cnt['i'] - nums[5] - nums[6] - nums[8]。注意 nums[9] 依赖第二批里的 nums[5],所以它必须排在 5 之后。
  • 从数字 0 到 9 依次遍历,把每个数字按它的个数追加到结果里。因为遍历顺序就是升序,所以天然满足输出要求,不需要再排序。
  • 用可变字符缓冲区拼接结果并一次性转成字符串。输入规模到 $10^5$ 时,用不可变字符串反复拼接会退化成平方复杂度。

"fviefuro" 走一遍:先统计字母,得到 f 出现 2 次,vieuro 各出现 1 次,其余为 0。第一批:nums[0] = cnt['z'] = 0nums[2] = cnt['w'] = 0nums[4] = cnt['u'] = 1nums[6] = cnt['x'] = 0nums[8] = cnt['g'] = 0。第二批:nums[3] = cnt['h'] - nums[8] = 0 - 0 = 0nums[5] = cnt['f'] - nums[4] = 2 - 1 = 1nums[7] = cnt['s'] - nums[6] = 0 - 0 = 0。第三批:nums[1] = cnt['o'] - nums[0] - nums[2] - nums[4] = 1 - 0 - 0 - 1 = 0nums[9] = cnt['i'] - nums[5] - nums[6] - nums[8] = 1 - 1 - 0 - 0 = 0。最终只有 nums[4] = 1nums[5] = 1,从 0 到 9 遍历依次追加,得到 "45"

代码实现

class Solution {
    // z,w,u,x,g 分别只出现在 zero,two,four,six,eight 中,可以先确定这些数字数量。
    public String originalDigits(String s) {
        int[] count = new int[26];
        for (char c : s.toCharArray()) {
            count[c - 'a']++;
        }

        int[] nums = new int[10];
        nums[0] = count['z' - 'a'];
        nums[2] = count['w' - 'a'];
        nums[4] = count['u' - 'a'];
        nums[6] = count['x' - 'a'];
        nums[8] = count['g' - 'a'];
        nums[3] = count['h' - 'a'] - nums[8];
        nums[5] = count['f' - 'a'] - nums[4];
        nums[7] = count['s' - 'a'] - nums[6];
        nums[1] = count['o' - 'a'] - nums[0] - nums[2] - nums[4];
        nums[9] = count['i' - 'a'] - nums[5] - nums[6] - nums[8];

        StringBuilder builder = new StringBuilder(s.length());
        for (int i = 0; i < 10; i++) {
            for (int j = 0; j < nums[i]; j++) {
                builder.append(i);
            }
        }

        return builder.toString();
    }
}
func originalDigits(s string) string {
    // z,w,u,x,g 分别只出现在 zero,two,four,six,eight 中,可以先确定这些数字数量。
    count := make([]int, 26)
    for i := 0; i < len(s); i++ {
        count[s[i]-'a']++
    }

    nums := make([]int, 10)
    nums[0] = count['z'-'a']
    nums[2] = count['w'-'a']
    nums[4] = count['u'-'a']
    nums[6] = count['x'-'a']
    nums[8] = count['g'-'a']
    nums[3] = count['h'-'a'] - nums[8]
    nums[5] = count['f'-'a'] - nums[4]
    nums[7] = count['s'-'a'] - nums[6]
    nums[1] = count['o'-'a'] - nums[0] - nums[2] - nums[4]
    nums[9] = count['i'-'a'] - nums[5] - nums[6] - nums[8]

    builder := make([]byte, 0, len(s))
    for i := 0; i < 10; i++ {
        for j := 0; j < nums[i]; j++ {
            builder = append(builder, byte('0'+i))
        }
    }

    return string(builder)
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 为输入长度。统计字母是一次线性扫描,十个数字的消元是常数次运算,拼接结果的总字符数不超过 $n / 3$(最短的单词 six 也有三个字母),因此整体保持线性。
  • 空间复杂度:$O(1)$,只用了长度 26 的字母计数数组和长度 10 的数字计数数组,都与输入规模无关;返回值本身属于必要输出,不计入额外空间。

关键点总结

  • 一旦题目声明「顺序被打乱」,字符串就退化为字母的多重集,唯一可提取的信息是计数。这是所有变位词类问题的第一反应。
  • 在一组互相耦合的等式里先找「只含一个未知数」的那条,解出来再回代,本质就是高斯消元的手工版。找唯一标识元素是把耦合系统解耦的通用手段。
  • 求解顺序必须与依赖关系一致。把十个数字分成三批书写,能让依赖方向一目了然,也避免了「9 依赖 5」这种隐蔽的顺序陷阱。
  • 输出要求升序,等价于只关心每个数字的出现次数,所以完全不需要(也无法)还原原始单词序列。识别出「不需要做的事」和识别出「需要做的事」同样重要。
  • 面试视角:面试官想看的是你能否现场推出那张依赖关系,而不是背下五个魔法字母。可行的做法是把十个单词写在纸上,逐个字母数它出现在哪些单词里,当场圈出只出现一次的 zwuxg,推导过程本身就是最好的答案。
  • 面试视角:可以补一句「题目保证输入有效,所以省略了校验;若要写健壮版本,最后应检查所有字母计数都被恰好消耗为零,否则返回空串」,体现对前置条件的敏感度。

易错点总结

  • 错误写法nums[9] 漏掉 eight 的贡献,写成 cnt['i'] - nums[5] - nums[6]。用例 "eight"cnt['i'] = 1nums[5] = nums[6] = 0,算出 nums[9] = 1,输出 "89",正确答案是 "8"
  • 错误写法nums[1] 漏掉 four 的贡献,写成 cnt['o'] - nums[0] - nums[2]。用例 "four"cnt['o'] = 1,算出 nums[1] = 1,输出 "14",正确答案是 "4"
  • 错误写法nums[3] 直接取 cnt['h'],忘记扣掉 eight。用例 "eight"cnt['h'] = 1,算出 nums[3] = 1,输出 "38",正确答案是 "8"
  • 错误写法:把 nums[9] 排在 nums[5] 之前计算。用例 "five" → 求 nums[9]nums[5] 还是初始值 0,cnt['i'] = 1 使 nums[9] = 1,输出多出一个 9,正确答案是 "5"
  • 错误写法:以为 oone 的唯一标识,直接令 nums[1] = cnt['o']。用例 "zero"cnt['o'] = 1 被当成一个 one,输出 "01",正确答案是 "0"
  • 错误写法:以为 nnine 的唯一标识。用例 "one"cnt['n'] = 1 被判成一个 nine,输出 "19" 之类结果;实际 n 还出现在 oneseven 里,而且 nine 自身带两个 n,这条路径连系数都不对。
  • 错误写法:按数字被确定的先后顺序(0、2、4、6、8、3、5、7、1、9)拼接输出。用例 "owoztneoer" → 得到 nums[0] = nums[1] = nums[2] = 1,却输出成 "021",正确答案是升序的 "012"
  • 错误写法:用不可变字符串反复拼接(ans += i)。用例 长度 $10^5$ 的输入 → 每次拼接都要复制整串,整体退化成 $O(n^2)$,直接超时。
  • 错误写法:Java 里把追加写成 builder.append((char) i)。用例 "zero" → 追加的是码点为 0 的控制字符而非字符 '0',输出是不可见的乱码;正确写法是追加整数 i,或显式转成 (char) ('0' + i)

相似题目

题目 难度 考察点
242. 有效的字母异位词 简单 只需比较两串的字母计数是否完全相同,计数之间没有依赖需要消元
389. 找不同 简单 计数差值只剩一个字符,可以用求和或异或把空间压到常数
1002. 查找共用字符 简单 需要在多个字符串的计数之间逐位取最小值
451. 根据字符出现频率排序 中等 统计之后要按频次降序重排字符,考察计数与排序的衔接