🔬 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 的面试中高频出现,因为它同时测试:
- 一维 DP 的状态定义能力
- 字符串处理的细心程度
- 对"边界条件如何传播"的理解
把这两道题一起搞定,你对 1-D DP 的掌握就算真正扎实了。
English:
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
为什么贪心不行 / 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,而是加一个倍数。
核心枚举:
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*" 的执行路径(验证图与代码一致):
Part 3: Edge Cases & Gotchas / 边界情况 (2 min)
| Input | Output | 原因 / Why |
|---|---|---|
"0" | 0 | '0' 单独无映射 |
"06" | 0 | dp[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' 单独 |
"*" | 9 | 1-9 各一种 |
"1*" | 18 | 如上图 |
"**" | 96 | dp[1]=9; dp[2]=9×9(单) + 15×1(两) = 81+15 = 96 |
最常踩的坑 / Most Common Pitfalls:
dp[0] = 1忘了 — 这是空字符串的出发点,必须为 1,否则两位解码永远贡献 0s[i-2:i]写成s[i-1:i+1]— Python 切片 off-by-one 是经典翻车- LC #639 忘记
% MOD— Python bigint 不会溢出但运算变慢;C++ 直接溢出 代表 1-9 不代表 0 — 单独一定是 9 种,而不是 10 种- 两位解码的下界检查 — 很多人只检查
<= 26忘了>= 10,导致 "01", "09" 等被算进去
Part 4: Real-World Application / 实际应用 (2 min)
中文:
- 压缩算法的解码验证:霍夫曼编码给定一段比特流,统计有多少种合法前缀解码方式,用于检测码本歧义性
- 中文/日文 NLP 分词:"结婚的和尚未结婚的" → 多种切法,本质就是 DP 计数有效切分方案数
- 生物信息学 — 读码框分析:DNA 每3个碱基编码1个氨基酸,但如果读码框有偏移或突变,计算有效解码路径数帮助分析突变影响
- 密码学暴力分析:给定替代密码的密文,枚举所有合法明文候选(Decode Ways 是其简化版)
English:
- Codec Ambiguity Detection: Huffman coding — does a given bitstream have exactly one valid decoding? Count > 1 means the codebook is ambiguous.
- CJK NLP Tokenization: Chinese/Japanese have no spaces. Counting valid word-boundary segmentations is structurally identical to Decode Ways.
- Bioinformatics — ORF Analysis: Counting valid Open Reading Frames across a mutated DNA sequence uses the same DP with more complex validity rules.
- 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(中文):
- O(1) 空间的滚动变量解法直接可用,内存没问题
- LC #639 必须在每次迭代末尾执行
% MOD,不要等到最后——Python bigint 不会溢出,但操作变慢;C++/Java 会溢出 - 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