目录

题目描述

91. 解码方法

题意分析

编码规则是 A 对应 1B 对应 2,一直到 Z 对应 26。现在给一个只含数字的字符串,问把它按这套规则反过来解码,一共有多少种不同的方案。注意题目只要方案数这一个整数,既不要求列出所有方案,也不要求给出字典序最小的那一种——这决定了我们不必真的做搜索,只需要计数。

关键约束在于编码表里没有 0。这带来两条彼此独立的限制。第一,字符 '0' 永远不能单独成为一个字母,任何时候把一个 '0' 当作独立单元切出来都是非法的。第二,两位组合不允许有前导零,因为 066 在编码表里不是同一件事,编码 F 时写下的一定是 6 而不是 06,所以 "06" 的方案数是 0 而不是 1。很多人只记住了「两位要在 10 到 26 之间」这半句,其实下界 10 正是在排除前导零。

由此可以把合法的切分单元收敛成两类:一个字符,且它不是 '0';或者两个字符,且它们组成的数落在 1026 的闭区间内。整个串必须被这些单元不重不漏地铺满,铺法的数量就是答案。

边界方面:字符串非空,长度可达上百,方案数题目保证在 32 位整数范围内。首字符如果是 '0',那它既不能单独解码、又没有前一位可以合并,直接判定为 0 种方案。串中出现 "00"、或者出现一个前面数字大于 2'0'(比如 "30"),同样会让方案数归零。这些情况不需要逐个特判,只要转移写对,0 会自然地被算出来并顺着递推传下去。

解法:滚动动态规划统计解码数

核心思路

问题关键:每个字母只可能由 1 位或 2 位数字解码;0 不能单独解码,两位数只有 10..26 合法。暴力递归会在每个位置反复计算相同后缀,最坏呈指数增长。

为什么选动态规划:一个前缀的方案数只取决于它去掉最后 1 位或 2 位后的方案数。定义 dp[i] 为前 i 个字符的解码方案数,dp[0] = 1 表示“空前缀有一种不做任何事的方案”,使整段两位数也能从统一公式转移。

对前缀末尾分类:

  • s[i - 1] != '0',末位可单独解码,贡献 dp[i - 1]
  • 若末两位数在 10..26,它们可合并解码,贡献 dp[i - 2]

两类按“最后一个编码单元的长度”划分,互斥且覆盖所有合法解码,因此贡献直接相加。又因为 dp[i] 只依赖前两项,用 pre2pre1 滚动即可。

正确性:任意完整方案的最后一个单元必为合法的 1 位或 2 位编码,删除它后分别唯一对应一个 dp[i - 1]dp[i - 2] 的方案;反过来,在这些方案末尾接上合法单元仍是合法且不会重复。由此转移精确计数全部方案。

解题步骤

  1. 首字符为 0 时直接返回 0;它既不能单独解码,也没有前一位可合并。
  2. 初始化 pre2 = dp[0] = 1pre1 = dp[1] = 1
  3. 从第二个字符开始遍历:先令 cur = 0,末位非 0 时加 pre1,末两位在 10..26 时加 pre2
  4. pre2 = pre1pre1 = cur 的顺序滚动,最后返回 pre1

面试口述示例226 中,dp[2] = dp[1] + dp[0] = 22|222);到 6 时,单独解码贡献 2,26 合并再贡献 1,所以答案为 3。

边界反例06 因首位为零返回 0;10 只有整体解码这一种;100 在最后一个 0 处既不能单独取,00 也不合法,方案数自然降为 0。

代码实现

class Solution {
    public int numDecodings(String s) {
        if (s.charAt(0) == '0') {
            return 0;
        }

        int pre2 = 1;
        int pre1 = 1;
        for (int i = 1; i < s.length(); i++) {
            int cur = 0;
            if (s.charAt(i) != '0') {
                cur += pre1;
            }

            int twoDigits = (s.charAt(i - 1) - '0') * 10
                    + s.charAt(i) - '0';
            if (twoDigits >= 10 && twoDigits <= 26) {
                cur += pre2;
            }

            pre2 = pre1;
            pre1 = cur;
        }
        return pre1;
    }
}
func numDecodings(s string) int {
    if s[0] == '0' {
        return 0
    }

    pre2, pre1 := 1, 1
    for i := 1; i < len(s); i++ {
        cur := 0
        if s[i] != '0' {
            cur += pre1
        }

        twoDigits := int(s[i-1]-'0')*10 + int(s[i]-'0')
        if twoDigits >= 10 && twoDigits <= 26 {
            cur += pre2
        }

        pre2, pre1 = pre1, cur
    }
    return pre1
}

复杂度分析

  • 时间复杂度:$O(n)$,只从左到右扫描一次字符串,每个位置做常数次判断和加法。
  • 空间复杂度:$O(1)$,只保存相邻两个 DP 状态和当前值。

关键点总结

  • 计数型 DP 可以按“最后一步”划分方案;本题只需枚举末尾取 1 位还是 2 位。
  • dp[0] = 1 是空前缀的计数单位,不代表空字符串有一个字母;它保证 12 整体解码时能贡献一种方案。
  • 0 的约束应放进转移:单字符必须非零,两字符必须位于 10..26
  • 两条转移可能同时成立,必须相加而不是写成 if / else if;滚动变量则要保留更新前的两项。

易错点总结

  • 无条件加入单字符分支:106 会把 0 单独解码,错算出额外方案。
  • 两位数只检查 <= 26:会把带前导零的 06 当成合法编码,必须同时检查 >= 10
  • dp[0] 设为 0:12 会漏掉整体解码成 L 的方案。
  • 两条合法分支写成 else if11 只能计入 1|111 之一,答案从 2 错成 1。
  • 滚动时先覆盖 pre1 再保存旧值:后续转移会读取到同一项,破坏 dp[i-2]dp[i-1] 的含义。

相似题目

题目 难度 考察点
剑指 Offer 46. 把数字翻译成字符串 中等 同型转移但区间是 10 到 25,且输入是整数需先逐位拆解
70. 爬楼梯 简单 剥去合法性判断后的裸骨架,两支转移永远成立
509. 斐波那契数 简单 同样的两项递推,用来练滚动变量与边界项的含义
198. 打家劫舍 中等 转移同样看「最后一步选一格还是两格」,但目标从计数变最值
1155. 掷骰子等于目标和的方法数 中等 计数 DP 的多分支版本,末位枚举从 2 支扩到 k 支且需取模