A dynamic programming solution for counting decodings of a digit string with wildcard characters.
Problem Restatement
We are given a string s.
The string contains digits and the wildcard character *.
The normal decoding rule is:
| Code | Letter |
|---|---|
"1" | A |
"2" | B |
| … | … |
"26" | Z |
The wildcard * can represent any digit from "1" to "9".
It cannot represent "0".
We need to count how many valid ways the string can be decoded.
Because the answer can be large, return it modulo:
10**9 + 7Input and Output
| Item | Meaning |
|---|---|
| Input | A string s containing digits and * |
| Output | Number of valid decodings modulo 10^9 + 7 |
| Valid single code | "1" to "9" |
| Valid two-digit code | "10" to "26" |
| Wildcard rule | * means any digit from "1" to "9" |
Example function shape:
def numDecodings(s: str) -> int:
...Examples
Example 1:
s = "*"* can be any digit from 1 to 9.
So there are:
9ways.
Output:
9Example 2:
s = "1*"For single-character decoding:
"1" + "*"The * has 9 choices, so this gives 9 ways.
For two-character decoding:
"1*"The * can be 1 to 9, giving:
11, 12, 13, 14, 15, 16, 17, 18, 19That gives another 9 ways.
Total:
18Example 3:
s = "2*"Single-character decoding gives 9 ways.
Two-character decoding can be:
21, 22, 23, 24, 25, 26That gives 6 ways.
Total:
15First Thought: Expand Every *
A direct approach is to replace every * with digits from 1 to 9.
Then for every expanded string, run the normal Decode Ways algorithm.
This is too slow.
If the string has m wildcard characters, there are:
9**mexpanded strings.
We need to count choices without generating them.
Key Insight
This is the same dynamic programming structure as Decode Ways.
At each position, the current character can contribute in two ways:
| Choice | Meaning |
|---|---|
| Single character | Decode s[i] alone |
| Two characters | Decode s[i - 1:i + 1] together |
The difference is that * can represent many possible digits.
So instead of asking whether a character or pair is valid, we ask:
How many valid decodings can this character or pair represent?Define two helper functions:
ways_one(ch)returns the number of valid single-character decodings.
ways_two(a, b)returns the number of valid two-character decodings.
Then the recurrence is:
dp[i] = ways_one(s[i - 1]) * dp[i - 1] + ways_two(s[i - 2], s[i - 1]) * dp[i - 2]Here, dp[i] means the number of ways to decode the first i characters.
Counting Single Characters
For one character:
| Character | Ways | Reason |
|---|---|---|
"0" | 0 | Zero cannot be decoded alone |
"1" to "9" | 1 | Each maps to one letter |
"*" | 9 | It can be 1 through 9 |
So:
ways_one("*") = 9
ways_one("0") = 0
ways_one("7") = 1Counting Two Characters
For two characters, we need values from 10 to 26.
| Pair | Ways | Valid meanings |
|---|---|---|
"**" | 15 | 11 to 19, and 21 to 26 |
"1*" | 9 | 11 to 19 |
"2*" | 6 | 21 to 26 |
"3*" to "9*" | 0 | No valid two-digit code |
"*0" to "*6" | 2 | 10 to 16, or 20 to 26 |
"*7" to "*9" | 1 | 17 to 19 |
| digit + digit | 1 if between 10 and 26, else 0 |
The case "**" has 15 ways because:
11, 12, 13, 14, 15, 16, 17, 18, 19
21, 22, 23, 24, 25, 26That is 9 + 6 = 15.
Algorithm
- Let
dp0representdp[i - 2]. - Let
dp1representdp[i - 1]. - Initialize:
dp0 = 1, for the empty prefix.dp1 = ways_one(s[0]).
- For each index
ifrom1tolen(s) - 1:- Compute the single-character contribution.
- Compute the two-character contribution.
- Add both under modulo.
- Shift the rolling DP variables.
- Return
dp1.
Implementation
class Solution:
def numDecodings(self, s: str) -> int:
MOD = 10**9 + 7
def ways_one(ch: str) -> int:
if ch == "*":
return 9
if ch == "0":
return 0
return 1
def ways_two(a: str, b: str) -> int:
if a == "*" and b == "*":
return 15
if a == "*":
if "0" <= b <= "6":
return 2
return 1
if b == "*":
if a == "1":
return 9
if a == "2":
return 6
return 0
value = int(a + b)
if 10 <= value <= 26:
return 1
return 0
prev2 = 1
prev1 = ways_one(s[0])
for i in range(1, len(s)):
current = ways_one(s[i]) * prev1
current += ways_two(s[i - 1], s[i]) * prev2
current %= MOD
prev2 = prev1
prev1 = current
return prev1Code Explanation
The helper:
ways_one(ch)counts how many valid one-character decodings ch can produce.
The helper:
ways_two(a, b)counts how many valid two-character decodings the pair (a, b) can produce.
The DP starts with:
prev2 = 1
prev1 = ways_one(s[0])prev2 = 1 means the empty prefix has one way.
prev1 is the number of ways to decode the first character.
For each next character, the answer is built from two sources.
Single-character contribution:
ways_one(s[i]) * prev1This means we decode s[i] alone, after decoding the prefix before it.
Two-character contribution:
ways_two(s[i - 1], s[i]) * prev2This means we decode the last two characters together, after decoding the prefix before those two characters.
Then we shift:
prev2 = prev1
prev1 = currentso the next loop has the correct previous states.
Correctness
Let dp[i] be the number of ways to decode the first i characters of s.
For the last character of the first i characters, every valid decoding falls into exactly one of two cases.
First, the last character is decoded alone. The number of possibilities for this last character is ways_one(s[i - 1]). The prefix before it has dp[i - 1] valid decodings. So this contributes:
ways_one(s[i - 1]) * dp[i - 1]Second, the last two characters are decoded together. The number of valid two-character meanings is ways_two(s[i - 2], s[i - 1]). The prefix before them has dp[i - 2] valid decodings. So this contributes:
ways_two(s[i - 2], s[i - 1]) * dp[i - 2]These two cases are disjoint because the final decoding unit has length either one or two. They also cover all valid decodings because every decoding ends with either one character or two characters.
Therefore, the recurrence counts exactly all valid decodings.
The rolling variables prev2 and prev1 store dp[i - 2] and dp[i - 1], so the implementation computes the same recurrence. Thus the returned value is correct.
Complexity
Let n = len(s).
| Metric | Value | Why |
|---|---|---|
| Time | O(n) | Each character is processed once |
| Space | O(1) | Only rolling DP variables are stored |
Alternative Solution: DP Array
A full DP array can make the recurrence easier to inspect.
class Solution:
def numDecodings(self, s: str) -> int:
MOD = 10**9 + 7
def ways_one(ch: str) -> int:
if ch == "*":
return 9
if ch == "0":
return 0
return 1
def ways_two(a: str, b: str) -> int:
if a == "*" and b == "*":
return 15
if a == "*":
return 2 if "0" <= b <= "6" else 1
if b == "*":
if a == "1":
return 9
if a == "2":
return 6
return 0
return 1 if 10 <= int(a + b) <= 26 else 0
n = len(s)
dp = [0] * (n + 1)
dp[0] = 1
dp[1] = ways_one(s[0])
for i in range(2, n + 1):
dp[i] += ways_one(s[i - 1]) * dp[i - 1]
dp[i] += ways_two(s[i - 2], s[i - 1]) * dp[i - 2]
dp[i] %= MOD
return dp[n]| Metric | Value |
|---|---|
| Time | O(n) |
| Space | O(n) |
Testing
def run_tests():
s = Solution()
assert s.numDecodings("*") == 9
assert s.numDecodings("1*") == 18
assert s.numDecodings("2*") == 15
assert s.numDecodings("**") == 96
assert s.numDecodings("0") == 0
assert s.numDecodings("10") == 1
assert s.numDecodings("*0") == 2
assert s.numDecodings("30") == 0
assert s.numDecodings("**********") == 483456820
print("all tests passed")
run_tests()Test meaning:
| Test | Why |
|---|---|
"*" | Single wildcard has 9 choices |
"1*" | Single and pair contributions both matter |
"2*" | Pair contribution has 6 choices |
"**" | Checks the 15 two-character wildcard case |
"0" | Zero cannot stand alone |
"10" | Zero can appear inside a valid pair |
"*0" | Only 10 and 20 are valid |
"30" | Invalid two-character code |
| Many wildcards | Checks modulo handling |