LeetCode 1307 - Verbal Arithmetic Puzzle
The problem gives a collection of words on the left side of an equation and a single result word on the right side. Each distinct uppercase letter must be assigned a unique digit from 0 to 9.
Difficulty: 🔴 Hard
Topics: Array, Math, String, Backtracking
Solution
Problem Understanding
The problem gives a collection of words on the left side of an equation and a single result word on the right side. Each distinct uppercase letter must be assigned a unique digit from 0 to 9. After replacing every letter with its assigned digit, the numerical values represented by the left-side words must sum exactly to the numerical value represented by the result word.
For example, if we map:
S -> 9E -> 5N -> 6D -> 7
then "SEND" becomes 9567.
The challenge is to determine whether there exists at least one valid assignment of digits that satisfies all constraints simultaneously.
The important constraints are:
- Different letters must map to different digits.
- Leading characters of multi-digit words cannot map to
0. - At most
10distinct letters appear overall.
That final constraint is extremely important. Since there are only ten digits available, any solution must carefully search through assignments. A naive brute-force approach can still become prohibitively expensive because even 10! = 3,628,800 assignments are possible before validation costs are considered.
Another key observation is that this is essentially a column-by-column arithmetic puzzle, exactly like manual addition. Instead of building full numbers first, we can validate the addition digit by digit from right to left.
Several edge cases are especially important:
- A leading character cannot become
0, even if doing so would satisfy the arithmetic. - The result word may be longer than all input words combined by one digit because of carry propagation.
- Some puzzles contain fewer than ten letters but still have no valid assignment.
- Multiple words may contribute digits to the same column.
- Carry handling is critical because mistakes in carry propagation cause incorrect solutions.
Approaches
Brute Force Approach
The brute-force solution collects all distinct letters and tries every possible digit assignment using permutations of digits 0-9.
For each assignment:
- Ensure no leading letter maps to
0 - Convert every word into its numeric value
- Compute the sum of the left-side words
- Compare against the numeric value of
result
This approach is correct because it exhaustively checks every possible valid mapping. If a valid assignment exists, brute force eventually finds it.
However, it is far too slow in the worst case. With up to ten unique characters, the algorithm may need to evaluate nearly 10! assignments. Additionally, every assignment requires reconstructing multiple integers repeatedly.
Key Insight for the Optimal Solution
The crucial observation is that arithmetic works column by column from right to left.
Instead of assigning all digits first and validating afterward, we can validate constraints incrementally while constructing assignments.
Suppose we process the equation like manual addition:
SEND
+ MORE
------
MONEY
Starting from the rightmost column:
D + E = Y
Then:
N + R + carry = E
At each column, we only need local consistency with the carry.
This enables aggressive pruning:
- If a partial assignment already violates the arithmetic, we immediately stop exploring that branch.
- We never construct invalid full assignments.
- Carry propagation naturally constrains future choices.
This transforms the problem into an efficient backtracking search.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(10! × k) | O(10) | Tries every digit permutation and validates afterward |
| Optimal | O(10!) worst case, heavily pruned | O(10) | Column-wise backtracking eliminates invalid states early |
Here, k represents the cost of constructing and validating numbers.
Algorithm Walkthrough
Step 1: Reverse the Words
We process addition from least significant digit to most significant digit, so reversing every word makes indexing easier.
For example:
SEND -> DNES
MORE -> EROM
MONEY -> YENOM
Now index 0 corresponds to the units place.
Step 2: Track Character Assignments
We maintain:
- A mapping from character to digit
- A boolean array indicating which digits are already used
This ensures uniqueness constraints are enforced efficiently.
A hash map is ideal because character lookup and updates are constant time.
Step 3: Identify Leading Characters
Any leading character of a multi-digit word cannot map to 0.
We store these characters in a set so we can quickly reject invalid assignments during backtracking.
Step 4: Process Column by Column
We recursively process:
- Current column index
- Current word index within that column
- Current carry value
At each column:
- Sum all digits from the left-side words
- Compare against the required digit in the result word
This mirrors elementary addition exactly.
Step 5: Handle Assigned Characters
If a character already has a digit assignment:
- Use that digit directly
- Continue recursion
No additional branching is needed.
Step 6: Try Possible Digits for Unassigned Characters
If a character is unassigned:
- Iterate through digits
0-9 - Skip already-used digits
- Skip
0for leading characters - Tentatively assign the digit
- Recurse deeper
- Backtrack if necessary
Backtracking systematically explores all valid possibilities.
Step 7: Validate the Result Column
After summing all left-side digits in a column:
expected_digit = total % 10
new_carry = total // 10
The result character must equal expected_digit.
If it does not, the branch is impossible and is immediately pruned.
Step 8: Move to the Next Column
Once a column is validated:
- Advance to the next column
- Pass the carry forward
The recursion continues until all columns are processed.
Step 9: Final Validation
When all columns are processed:
- The puzzle is solvable only if the final carry is
0
Otherwise, the arithmetic is incomplete.
Why it works
The algorithm maintains a strict invariant:
Every processed column satisfies valid arithmetic under the current assignments.
Because recursion only proceeds when each column is locally correct, any completed assignment automatically satisfies the entire equation globally. Since the search explores all valid assignments systematically, the algorithm is both correct and complete.
Python Solution
from typing import List
class Solution:
def isSolvable(self, words: List[str], result: str) -> bool:
reversed_words = [word[::-1] for word in words]
reversed_result = result[::-1]
leading_chars = set()
for word in words:
if len(word) > 1:
leading_chars.add(word[0])
if len(result) > 1:
leading_chars.add(result[0])
char_to_digit = {}
used_digits = [False] * 10
max_len = max(len(result), max(len(word) for word in words))
def backtrack(column: int, row: int, current_sum: int) -> bool:
if column == max_len:
return current_sum == 0
if row == len(reversed_words):
result_char = (
reversed_result[column]
if column < len(reversed_result)
else None
)
digit = current_sum % 10
carry = current_sum // 10
if result_char in char_to_digit:
if char_to_digit[result_char] != digit:
return False
return backtrack(column + 1, 0, carry)
if used_digits[digit]:
return False
if digit == 0 and result_char in leading_chars:
return False
char_to_digit[result_char] = digit
used_digits[digit] = True
if backtrack(column + 1, 0, carry):
return True
del char_to_digit[result_char]
used_digits[digit] = False
return False
if column >= len(reversed_words[row]):
return backtrack(column, row + 1, current_sum)
current_char = reversed_words[row][column]
if current_char in char_to_digit:
return backtrack(
column,
row + 1,
current_sum + char_to_digit[current_char]
)
for digit in range(10):
if used_digits[digit]:
continue
if digit == 0 and current_char in leading_chars:
continue
char_to_digit[current_char] = digit
used_digits[digit] = True
if backtrack(
column,
row + 1,
current_sum + digit
):
return True
del char_to_digit[current_char]
used_digits[digit] = False
return False
return backtrack(0, 0, 0)
The implementation follows the exact column-wise recursive strategy described earlier.
The words and result are first reversed so that index 0 corresponds to the least significant digit. This simplifies traversal because arithmetic addition naturally proceeds from right to left.
The leading_chars set prevents illegal assignments where a multi-digit number begins with zero.
The recursive backtrack function has three parameters:
column, the current digit columnrow, which word we are currently processing in that columncurrent_sum, the accumulated sum including carry
When all words in the current column have been processed, the algorithm validates the corresponding result digit. If the digit assignment is inconsistent, the branch is pruned immediately.
Unassigned characters are explored by trying all unused digits. After recursion returns, assignments are undone so alternative possibilities can be explored cleanly.
The recursion terminates successfully only when all columns are processed and no carry remains.
Go Solution
package main
func isSolvable(words []string, result string) bool {
reversedWords := make([][]byte, len(words))
maxLen := len(result)
for i, word := range words {
reversedWords[i] = reverse(word)
if len(word) > maxLen {
maxLen = len(word)
}
}
reversedResult := reverse(result)
leadingChars := make(map[byte]bool)
for _, word := range words {
if len(word) > 1 {
leadingChars[word[0]] = true
}
}
if len(result) > 1 {
leadingChars[result[0]] = true
}
charToDigit := make(map[byte]int)
usedDigits := make([]bool, 10)
var backtrack func(column, row, currentSum int) bool
backtrack = func(column, row, currentSum int) bool {
if column == maxLen {
return currentSum == 0
}
if row == len(reversedWords) {
if column >= len(reversedResult) {
return false
}
resultChar := reversedResult[column]
digit := currentSum % 10
carry := currentSum / 10
if assignedDigit, exists := charToDigit[resultChar]; exists {
if assignedDigit != digit {
return false
}
return backtrack(column+1, 0, carry)
}
if usedDigits[digit] {
return false
}
if digit == 0 && leadingChars[resultChar] {
return false
}
charToDigit[resultChar] = digit
usedDigits[digit] = true
if backtrack(column+1, 0, carry) {
return true
}
delete(charToDigit, resultChar)
usedDigits[digit] = false
return false
}
if column >= len(reversedWords[row]) {
return backtrack(column, row+1, currentSum)
}
currentChar := reversedWords[row][column]
if assignedDigit, exists := charToDigit[currentChar]; exists {
return backtrack(
column,
row+1,
currentSum+assignedDigit,
)
}
for digit := 0; digit <= 9; digit++ {
if usedDigits[digit] {
continue
}
if digit == 0 && leadingChars[currentChar] {
continue
}
charToDigit[currentChar] = digit
usedDigits[digit] = true
if backtrack(
column,
row+1,
currentSum+digit,
) {
return true
}
delete(charToDigit, currentChar)
usedDigits[digit] = false
}
return false
}
return backtrack(0, 0, 0)
}
func reverse(s string) []byte {
n := len(s)
reversed := make([]byte, n)
for i := 0; i < n; i++ {
reversed[i] = s[n-1-i]
}
return reversed
}
The Go implementation closely mirrors the Python version, but several language-specific details differ.
Go does not have built-in hash sets, so map[byte]bool is used for the leading character set.
Character assignments are stored using map[byte]int. Since Go distinguishes between missing keys and zero values, the code uses the two-value map lookup form:
value, exists := map[key]
Backtracking cleanup uses delete() to remove assignments cleanly.
Strings are converted into reversed byte slices because indexing bytes is more efficient and convenient than repeatedly slicing strings.
Integer overflow is not a concern because the maximum carry remains very small due to the problem constraints.
Worked Examples
Example 1
words = ["SEND", "MORE"]
result = "MONEY"
Reversed forms:
DNES
EROM
YENOM
Initial state:
| Variable | Value |
|---|---|
| column | 0 |
| carry | 0 |
| assignments | {} |
Processing column 0:
D + E = Y
Suppose recursion tries:
| Character | Digit |
|---|---|
| D | 7 |
| E | 5 |
Then:
7 + 5 = 12
So:
| Value | Result |
|---|---|
| result digit | 2 |
| carry | 1 |
Assign:
| Character | Digit |
|---|---|
| Y | 2 |
Next column:
N + R + 1 = E
Suppose:
| Character | Digit |
|---|---|
| N | 6 |
| R | 8 |
Then:
6 + 8 + 1 = 15
Result digit becomes 5, matching E.
Carry becomes 1.
The recursion continues until all columns satisfy arithmetic:
9567 + 1085 = 10652
The algorithm returns True.
Example 2
words = ["SIX", "SEVEN", "SEVEN"]
result = "TWENTY"
The recursion explores assignments column by column.
One successful mapping is:
| Character | Digit |
|---|---|
| S | 6 |
| I | 5 |
| X | 0 |
| E | 8 |
| V | 7 |
| N | 2 |
| T | 1 |
| W | 3 |
| Y | 4 |
Which produces:
650 + 68782 + 68782 = 138214
The algorithm returns True.
Example 3
words = ["LEET", "CODE"]
result = "POINT"
The recursion systematically explores assignments but repeatedly encounters contradictions:
- Digit conflicts
- Invalid carries
- Impossible result digits
Eventually all branches are exhausted without success.
The algorithm returns False.
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(10!) worst case | Backtracking may explore many digit assignments |
| Space | O(10) | Assignment maps and recursion depth are bounded by unique letters |
Although the theoretical worst-case runtime is factorial, practical performance is much better because the column-by-column pruning removes invalid assignments extremely early. Most branches terminate long before all digits are assigned.
The space complexity remains small because there are at most ten unique characters.
Test Cases
sol = Solution()
assert sol.isSolvable(
["SEND", "MORE"],
"MONEY"
) is True # classic cryptarithm example
assert sol.isSolvable(
["SIX", "SEVEN", "SEVEN"],
"TWENTY"
) is True # multiple addends with carry chains
assert sol.isSolvable(
["LEET", "CODE"],
"POINT"
) is False # no valid mapping exists
assert sol.isSolvable(
["A", "B"],
"C"
) is True # simple one-digit addition
assert sol.isSolvable(
["A", "A"],
"A"
) is False # impossible because A+A cannot equal A
assert sol.isSolvable(
["AB", "BC"],
"CD"
) is False # conflicting carry constraints
assert sol.isSolvable(
["AA", "BB"],
"CC"
) is True # repeated letters handled correctly
assert sol.isSolvable(
["THIS", "IS", "TOO"],
"FUNNY"
) is True # larger valid puzzle
assert sol.isSolvable(
["A"],
"A"
) is True # trivial equality
assert sol.isSolvable(
["A"],
"B"
) is True # distinct single-digit assignments possible
assert sol.isSolvable(
["ABC", "DEF"],
"GHI"
) is True # many valid possibilities
assert sol.isSolvable(
["AAA", "AAA"],
"AAA"
) is False # carry contradiction
| Test | Why |
|---|---|
SEND + MORE = MONEY |
Validates classic carry-heavy cryptarithm |
SIX + SEVEN + SEVEN = TWENTY |
Tests multiple addends |
LEET + CODE = POINT |
Ensures impossible puzzles return false |
A + B = C |
Simple solvable case |
A + A = A |
Tests arithmetic impossibility |
AB + BC = CD |
Validates carry handling |
AA + BB = CC |
Tests repeated character consistency |
THIS + IS + TOO = FUNNY |
Larger recursion tree |
A = A |
Minimal valid input |
A = B |
Distinct assignments allowed |
ABC + DEF = GHI |
Many valid assignments exist |
AAA + AAA = AAA |
Impossible due to carry behavior |
Edge Cases
Leading Zero Violations
A very common bug is accidentally allowing a leading character to map to 0.
For example:
SEND -> 0123
This is invalid because numbers cannot contain leading zeros.
The implementation prevents this by storing all leading characters in a set and rejecting any assignment where such a character receives digit 0.
Carry Propagation Across Multiple Columns
Some puzzles require carries to propagate through many columns consecutively.
For example:
999 + 1 = 1000
In verbal arithmetic form, similar carry chains are common. Incorrect carry handling causes subtle arithmetic errors.
The recursive algorithm explicitly computes:
digit = total % 10
carry = total // 10
for every column, ensuring carries propagate correctly.
Repeated Characters
A letter may appear many times across different words and columns.
For example:
AA + BB = CC
Every occurrence of the same character must always map to the same digit.
The implementation guarantees consistency using the char_to_digit map. Once a character receives a digit assignment, all future occurrences reuse that exact digit automatically.