← 2026-09-15 📂 All Days 2026-09-20 →

🔬 Saturday Deep Dive: Decode Ways — 从计数到通配解码 (LC #91 + #639) (18 min read)

📊 NeetCode: 104/150 · SysDesign: 60/60 · Behavioral: 60/60 · Frontend: 37/50 · AI: 30/30

🗓️ Day 134 · Expert Phase


Overview / 概述

中文:

解码方式是经典的一维动态规划入门题,但它极度迷惑人。表面上只是把数字字符串解码成字母,实际上边界情况能让你在面试中翻车。LC #91(Medium)靠边界条件拦人;LC #639(Hard)再加一个 * 通配符,让题目的 case 数量翻几倍。

这道题在 Meta、Google、Amazon、Microsoft 的面试中高频出现,因为它同时测试:

  1. 一维 DP 的状态定义能力
  2. 字符串处理的细心程度
  3. 对"边界条件如何传播"的理解

把这两道题一起搞定,你对 1-D DP 的掌握就算真正扎实了。

English:

Decode Ways looks deceivingly simple: count valid decodings of a digit string using the mapping 1→A, 2→B, ..., 26→Z. But the edge cases are treacherous. Level it up with wildcard `*` (LC #639) and it becomes a rigorous test of DP transition mastery.

This pair appears frequently in FAANG interviews because it rewards careful state definition and boundary thinking — not just pattern recognition.


Part 1: Theory / 理论基础 (5 min)

问题定义 / Problem Definition

**中文:** 给定只含数字的字符串 `s`,统计有多少种方式将其解码为字母序列(1→A, 2→B, ..., 26→Z)。
**English:** Given digit string `s`, return the number of valid decodings using the mapping 1→A through 26→Z.
"12" → "AB" or "L" → 2 ways "226" → "BBF", "BZ", "VF" → 3 ways "06" → (none) → 0 ways ← leading zero, "06">26 AND "0" alone invalid "10" → "J" only → 1 way ← "1"+"0" fails; "10"=J works

为什么贪心不行 / Why Greedy Fails

中文:

如果你贪心地尽量取两位数,你会漏路径;如果贪心地取一位数,你也会漏路径。原因是每一步的选择影响后续所有子问题——这是重叠子结构的教科书定义,DP 的信号。

English:

Every choice at position i (take 1 digit or 2?) branches into subproblems that share computation. That's overlapping subproblems. Greedy picks one path; DP counts all valid paths simultaneously.

DP 状态定义 / State Definition

dp[i] = 前 i 个字符的有效解码方式数
      = number of ways to decode s[0..i-1]

转移方程 / Transition

在位置 i(1-indexed),有两个选择:

选择1 — 单位解码 s[i-1]:
  if s[i-1] != '0':           # '0' 单独无法解码
      dp[i] += dp[i-1]

选择2 — 两位解码 s[i-2..i-1]:
  two = int(s[i-2:i])
  if 10 <= two <= 26:         # 有效范围:10-26
      dp[i] += dp[i-2]

Base Cases

dp[0] = 1   # 空字符串算1种(递推的出发点)
dp[1] = 1 if s[0] != '0' else 0

Mental Model — 楼梯思维

中文: 把字符串想象成一排台阶,每步可以迈 1 格或 2 格,但某些台阶是"损坏的"('0' 单独、两位数>26)。dp[i] = 从起点走到第 i 阶的总路径数。关键:损坏的台阶不会把之前的路径数清零——它只是让"从这里出发的那一步"贡献 0。

English: Decode Ways = Climbing Stairs with forbidden steps. A '0' makes the single-step contribution 0; a two-digit > 26 makes the double-step contribution 0. dp[i] = total paths to stair i, accumulated from valid steps only.


Part 2: Step-by-Step Implementation / 一步一步实现 (8 min)

方法1:暴力递归 (Exponential) — 用于理解结构

def numDecodings_brute(s: str) -> int:
    """
    Naive recursion — O(2^n) time, O(n) stack space.
    Don't use in interviews; use to understand the branching structure.
    """
    def dfs(i: int) -> int:
        # Base: consumed all characters successfully
        if i == len(s):
            return 1
        # Dead end: current character is '0', no valid single-digit mapping
        if s[i] == '0':
            return 0

        # Branch 1: decode s[i] as a single character (1-9)
        ways = dfs(i + 1)

        # Branch 2: decode s[i:i+2] as a two-digit number (10-26)
        if i + 1 < len(s):
            two_digit = int(s[i:i+2])
            if 10 <= two_digit <= 26:
                ways += dfs(i + 2)

        return ways

    return dfs(0)

方法2:记忆化递归 Top-Down — O(n) time, O(n) space

from functools import lru_cache

def numDecodings_memo(s: str) -> int:
    """
    Top-down DP with memoization.
    Identical logic to brute force, but each unique index computed once.
    """
    @lru_cache(maxsize=None)
    def dfs(i: int) -> int:
        if i == len(s):
            return 1
        if s[i] == '0':
            return 0

        ways = dfs(i + 1)  # single-digit decode

        if i + 1 < len(s) and 10 <= int(s[i:i+2]) <= 26:
            ways += dfs(i + 2)  # two-digit decode

        return ways

    return dfs(0)

方法3:迭代 DP Bottom-Up — 标准解法 ⭐

def numDecodings(s: str) -> int:
    """
    Bottom-up DP — O(n) time, O(n) space.

    dp[i] = number of ways to decode s[0..i-1]

    Recurrence:
      if s[i-1] != '0':           dp[i] += dp[i-1]  (1-digit decode)
      if 10 <= s[i-2:i] <= 26:    dp[i] += dp[i-2]  (2-digit decode)
    """
    n = len(s)
    dp = [0] * (n + 1)

    dp[0] = 1                          # empty string baseline
    dp[1] = 0 if s[0] == '0' else 1   # first char: 1 way unless it's '0'

    for i in range(2, n + 1):
        # --- Option 1: decode the single character s[i-1] ---
        if s[i-1] != '0':              # '0' has no standalone mapping
            dp[i] += dp[i-1]

        # --- Option 2: decode the two-character s[i-2:i] ---
        two_digit = int(s[i-2:i])
        if 10 <= two_digit <= 26:      # valid two-digit encoding range
            dp[i] += dp[i-2]

    return dp[n]


# --- Trace: s = "226" ---
#
# dp = [1, 1, 0, 0]   (initialized)
#
# i=2: s[1]='2'  → != '0' → dp[2] += dp[1] = 1
#       s[0:2]="22" → 10<=22<=26 → dp[2] += dp[0] = 1   → dp[2] = 2
#
# i=3: s[2]='6'  → != '0' → dp[3] += dp[2] = 2
#       s[1:3]="26" → 10<=26<=26 → dp[3] += dp[1] = 1   → dp[3] = 3
#
# Answer: 3  ✓  (BBF, BZ, VF)

方法4:空间优化 O(1) — 面试首选 ⭐⭐

def numDecodings_optimized(s: str) -> int:
    """
    Space-optimized DP — O(n) time, O(1) space.

    Since dp[i] only depends on dp[i-1] and dp[i-2],
    replace the array with two rolling variables.
    """
    n = len(s)

    prev2 = 1                           # dp[0]: empty string
    prev1 = 0 if s[0] == '0' else 1    # dp[1]: first character

    if n == 1:
        return prev1

    for i in range(2, n + 1):
        curr = 0

        # Single digit
        if s[i-1] != '0':
            curr += prev1

        # Two digit
        two_digit = int(s[i-2:i])
        if 10 <= two_digit <= 26:
            curr += prev2

        # Slide window: prev2 ← prev1, prev1 ← curr
        prev2, prev1 = prev1, curr

    return prev1

进阶:Decode Ways II (LC #639, Hard) 🔥

中文:

在 LC #639 中,字符串可以包含 *,代表 1-9 中的任意数字(不包括 0)。答案对 10^9+7 取模。

* 引入了多路分支,我们不再是加 0/1,而是加一个倍数。

核心枚举:

单位解码 '*': 9 种可能 (1-9) 两位解码(以 * 开头或结尾): prev='1', curr='*' → "1*" → 11-19 → 9 种 prev='2', curr='*' → "2*" → 21-26 → 6 种 (只有 1-6 有效) prev='*', curr='*' → "**" → 11-19(9种) + 21-26(6种) = 15 种 prev='*', curr=数字c: if c <= '6': "1c" 有效 + "2c" 有效 → ×2 if c > '6': 只有 "1c" 有效 → ×1

English:

LC #639 adds (represents 1–9, never 0). The key is multiplying dp values by the count of valid interpretations of each rather than just adding 0 or 1.

def numDecodings_ii(s: str) -> int:
    """
    Decode Ways II — handles '*' wildcard.
    O(n) time, O(1) space. Result mod 10^9 + 7.

    '*' represents any digit in 1-9 (NOT 0).
    """
    MOD = 10**9 + 7
    n = len(s)

    # dp[1] for the first character
    prev2 = 1
    if s[0] == '*':
        prev1 = 9       # '*' can be 1-9 → 9 ways
    elif s[0] == '0':
        prev1 = 0       # '0' alone is never valid
    else:
        prev1 = 1       # any other digit: exactly 1 way

    if n == 1:
        return prev1

    for i in range(2, n + 1):
        curr = 0
        c = s[i-1]   # current character
        p = s[i-2]   # previous character

        # ---- Single-digit decode ----
        if c == '*':
            curr += 9 * prev1      # '*' = 1-9: 9 choices
        elif c != '0':
            curr += prev1          # literal non-zero digit: 1 choice

        # ---- Two-digit decode ----
        if p == '*' and c == '*':
            # "**": 11-19 (9 combos) + 21-26 (6 combos) = 15
            curr += 15 * prev2

        elif p == '*' and c != '*':
            # prev='*', curr=literal digit c
            # "1c" is always valid (10-19, c=0-9)
            # "2c" is valid only if c <= '6' (20-26)
            if c <= '6':
                curr += 2 * prev2  # both "1c" and "2c" work
            else:
                curr += 1 * prev2  # only "1c" works (c>6 → "2c">26)

        elif p != '*' and c == '*':
            # prev=literal digit p, curr='*'
            if p == '1':
                curr += 9 * prev2  # "1*" → 11-19 → 9 ways
            elif p == '2':
                curr += 6 * prev2  # "2*" → 21-26 → 6 ways
            # else p >= '3': "3*" >= 31 > 26, always invalid → +0

        else:
            # Both literal digits: same as LC #91
            two_digit = int(s[i-2:i])
            if 10 <= two_digit <= 26:
                curr += prev2

        curr %= MOD
        prev2, prev1 = prev1, curr

    return prev1

✅ Step 4 Cross-Check: 对照代码与状态图

追踪 s = "1*" 的执行路径(验证图与代码一致):

状态图 / Flow Diagram: s[0]='1' s[1]='*' │ │ dp[1]=1 dp[2]=? │ ┌───┴───────────────────┐ │ 单位解码'*' 两位解码"1*" │ 9 × dp[1] = 9 9 × dp[0] = 9 │ └───────────────────────┘ │ dp[2] = 18 代码路径 (i=2, p='1', c='*'): 单位: c=='*' → curr += 9 * prev1 = 9 * 1 = 9 ← 对应图中"单位解码" 两位: p=='1', c=='*' → curr += 9 * prev2 = 9 * 1 = 9 ← 对应图中"两位解码" curr = 18 ✓ — 代码与图完全一致

Part 3: Edge Cases & Gotchas / 边界情况 (2 min)

InputOutput原因 / Why
"0"0'0' 单独无映射
"06"0dp[1]=0 因为 s[0]='0',级联为 0
"10"1"10"=J 有效;"1"+"0" 无效('0' 单独)
"30"0"30">26 无效;"3"+"0" 无效
"100"0"1"+"0"+"0": '0' 单独;"10"+"0": '0' 单独
"*"91-9 各一种
"1*"18如上图
"**"96dp[1]=9; dp[2]=9×9(单) + 15×1(两) = 81+15 = 96

最常踩的坑 / Most Common Pitfalls:

  1. dp[0] = 1 忘了 — 这是空字符串的出发点,必须为 1,否则两位解码永远贡献 0
  2. s[i-2:i] 写成 s[i-1:i+1] — Python 切片 off-by-one 是经典翻车
  3. LC #639 忘记 % MOD — Python bigint 不会溢出但运算变慢;C++ 直接溢出
  4. 代表 1-9 不代表 0 — 单独 一定是 9 种,而不是 10 种
  5. 两位解码的下界检查 — 很多人只检查 <= 26 忘了 >= 10,导致 "01", "09" 等被算进去

Part 4: Real-World Application / 实际应用 (2 min)

中文:

  1. 压缩算法的解码验证:霍夫曼编码给定一段比特流,统计有多少种合法前缀解码方式,用于检测码本歧义性
  2. 中文/日文 NLP 分词:"结婚的和尚未结婚的" → 多种切法,本质就是 DP 计数有效切分方案数
  3. 生物信息学 — 读码框分析:DNA 每3个碱基编码1个氨基酸,但如果读码框有偏移或突变,计算有效解码路径数帮助分析突变影响
  4. 密码学暴力分析:给定替代密码的密文,枚举所有合法明文候选(Decode Ways 是其简化版)

English:

  1. Codec Ambiguity Detection: Huffman coding — does a given bitstream have exactly one valid decoding? Count > 1 means the codebook is ambiguous.
  2. CJK NLP Tokenization: Chinese/Japanese have no spaces. Counting valid word-boundary segmentations is structurally identical to Decode Ways.
  3. Bioinformatics — ORF Analysis: Counting valid Open Reading Frames across a mutated DNA sequence uses the same DP with more complex validity rules.
  4. QR / Barcode Error Recovery: When partial data is corrupted, estimate how many valid payloads are consistent with the readable portions.

Part 5: Interview Simulation / 面试模拟 (3 min)

Q1: 如果要返回所有解码字符串(而不只是计数),怎么改?

A(中文): 把 dp[i] = int 改成 dp[i] = List[str],状态转移变成字符串拼接而非数字相加。时间复杂度从 O(n) 变成 O(n × 答案数),最坏 O(n × 2^n)。这就是为什么面试通常问计数而不是枚举——后者的输出本身就是指数级的。

A (EN): Change dp[i] from int to List[str]. Transitions become string concatenation. Time blows up to O(n × answer count) — exponential worst case. That's why interviews ask for count: the all-paths output can't be represented in polynomial time.


Q2: 这题和 Climbing Stairs 有什么本质关系?

A(中文): 完全同构。Climbing Stairs 是 dp[i] = dp[i-1] + dp[i-2],没有任何限制。Decode Ways 是同样的方程,但每一项都加了 guard(s[i] != '0' 和 10 <= two <= 26),guard 为假时该项贡献 0。可以说 Decode Ways 是"带禁行台阶的爬楼梯"。

A (EN): They're isomorphic. Climbing Stairs = unrestricted dp[i-1] + dp[i-2]. Decode Ways = same recurrence with guards that zero out individual contributions. Decode Ways is Climbing Stairs on a partially broken staircase.


Q3: 两个滚动变量为什么够用?什么情况需要三个?

A(中文): 因为转移方程最多往回看2步(dp[i-1] 和 dp[i-2])。如果题目变成"每次可以迈1、2或3步"(Tribonacci),那就需要3个变量。一般原则:滚动变量数 = 转移方程的"历史深度"。

A (EN): The recurrence only looks back 2 positions. If the recurrence had dp[i-3] (like the Tribonacci sequence / "3-step staircase"), you'd need 3 variables. Rule: rolling variable count = recurrence lookback depth.


Q4: LC #639 中,prev='*' + curr=数字c 为什么有时是 ×2 有时是 ×1?

A(中文): * 可以是 1-9。前缀能构成有效两位数(10-26)的情况:

  • * = 1 时:1c 恒有效(10-19,无论 c 是什么)
  • * = 2 时:2c 仅在 c ∈ {0,1,2,3,4,5,6} 时有效(20-26)

所以当 c <= '6',两种前缀都行(×2);当 c > '6',只有"前缀为1"能成立(×1)。

A (EN): * can be 1 or 2 (among others). 1c is always in 10-19 (valid). 2c is in 20-2c; only valid if 2c <= 26, i.e., c <= '6'. So c ≤ 6: both '1' and '2' work (×2). c > 6: only '1' works (×1).


Q5: 如果字符串长度是 10^7,有哪些实现注意点?

A(中文):

  1. O(1) 空间的滚动变量解法直接可用,内存没问题
  2. LC #639 必须在每次迭代末尾执行 % MOD,不要等到最后——Python bigint 不会溢出,但操作变慢;C++/Java 会溢出
  3. Python 切片 s[i-2:i] 在长字符串中每次创建新 str 对象,可以改用 ord(s[i-2]) 10 + ord(s[i-1]) - 11 ord('0') 避免对象分配(微优化,面试不必提)

A (EN): O(1) space rolling-variable solution handles it. For LC #639: apply % MOD every iteration, not at the end — Python bigint grows; C++/Java overflows. For Python hot paths, avoid string slicing in the inner loop; use arithmetic on ord() values instead.


🔗 LeetCode #91 — Decode Ways: https://leetcode.com/problems/decode-ways/

🔗 LeetCode #639 — Decode Ways II: https://leetcode.com/problems/decode-ways-ii/

📹 NeetCode #91: https://www.youtube.com/watch?v=6aEyTjOwlJU

📹 NeetCode #639: https://www.youtube.com/watch?v=aNnlf2qJMaI