Want Help Cracking FAANG?

(Then click this)

×
Back to Question Bank

1307. Verbal Arithmetic Puzzle - Leetcode Solution

Problem Description

The Verbal Arithmetic Puzzle (also known as Alphametics or Cryptarithmetic) asks you to determine if a set of words can be assigned digits (0-9) such that when the words are interpreted as numbers and summed, they equal a given result word. Each letter represents a unique digit, and no word can start with the digit zero.

You are given a list of strings words and a string result. Each string contains only uppercase English letters. Assign a digit to each unique letter so that:

  • Each letter maps to a different digit (0-9).
  • No word (including result) starts with the digit 0.
  • The sum of the integer values of words equals the integer value of result.

Return true if there is at least one valid assignment; otherwise, return false.

Key Constraints:

  • Each letter must map to a unique digit.
  • No leading zeros in any word.
  • There may be at most one solution.

Thought Process

At first glance, it seems like we could try every possible mapping of digits to letters, but with up to 10 unique letters, there are 10! (3,628,800) possible assignments. This brute-force approach is computationally expensive.

To optimize, we notice:

  • We only need to map the unique letters present in the puzzle.
  • We must avoid mappings that assign zero to the first letter of any word or the result.
  • We can use backtracking to incrementally build up possible assignments, pruning partial assignments that can't possibly lead to a solution.
The idea is to use recursion: try assigning a digit to each letter, skip used digits, and check if the current assignment is still valid. If at any point the partial sums can't possibly match the result, we can backtrack early.

Solution Approach

Let's break down the algorithm step-by-step:

  1. Identify Unique Letters:
    • Collect all unique letters from words and result.
    • If there are more than 10 unique letters, return false (since only digits 0-9 are available).
  2. Calculate Place Values:
    • For each letter, compute its total "weight" (i.e., how much it contributes to the sum based on its position in each word/result).
    • For example, in the word "SEND", S is in the thousands place, so its weight is 1000.
    • For the result, subtract its weights (since the sum of words minus result should be zero).
  3. Backtracking Search:
    • Try all possible assignments of digits to letters using recursion.
    • Skip assignments that would cause a leading zero in any word or the result.
    • At each step, keep track of which digits have already been used.
    • If all letters are assigned, check if the weighted sum is zero (i.e., the equation is satisfied).
  4. Pruning:
    • If at any point the current partial sum cannot possibly lead to a solution (e.g., the remaining unassigned letters can't make up the needed value), backtrack early.

We use a hash map for fast letter-to-digit lookup, and a set to track used digits. This approach is efficient because it prunes large portions of the search space that can't possibly lead to a solution.

Example Walkthrough

Let's consider the classic example:

      words = ["SEND", "MORE"]
      result = "MONEY"
    
  • Unique letters: S, E, N, D, M, O, R, Y (8 letters)
  • Assign digits so that SEND + MORE = MONEY
Iteration Steps:
  1. Start with an empty mapping and no digits used.
  2. Assign S = 9 (cannot be 0, as it's a leading letter)
  3. Assign E = 5, N = 6, D = 7, M = 1, O = 0, R = 8, Y = 2 (one possible assignment)
  4. Calculate SEND = 9567, MORE = 1085, MONEY = 10652
  5. Check: 9567 + 1085 = 10652, which matches MONEY.
  6. Thus, a valid mapping is found.

The algorithm tries assignments recursively, skipping any that violate the constraints (e.g., reusing a digit, leading zeros), and backtracks when necessary.

Time and Space Complexity

Brute-force Approach:

  • There are at most 10 unique letters, so at most 10! (3,628,800) permutations of digit assignments.
  • For each permutation, we check if the assignment is valid, which takes O(L) time, where L is the total number of letters in all words and the result.
  • Total time: O(10! * L)
Optimized (Backtracking) Approach:
  • Still worst-case O(10!) if all branches are explored, but in practice, pruning and constraints (like leading zeros and early sum checks) eliminate many branches early.
  • Space: O(U) for the mapping (where U is the number of unique letters), plus O(1) for the used digits set.

The solution is feasible due to the small number of unique letters (max 10) and the aggressive pruning from backtracking.

Summary

The Verbal Arithmetic Puzzle is a classic constraint satisfaction problem. By representing the puzzle as a set of equations and using backtracking with pruning, we efficiently search for a valid mapping of letters to digits. The key insights are:

  • Map each unique letter to a digit, ensuring no leading zeros and no digit reuse.
  • Use place values to convert words to numbers.
  • Backtrack and prune impossible assignments early.
This approach is both elegant and practical, leveraging the small search space and strong constraints to find a solution efficiently.

Code Implementation

class Solution:
    def isSolvable(self, words, result):
        from collections import defaultdict
        # Step 1: Find all unique letters
        letters = set()
        first_letters = set()
        for word in words + [result]:
            letters |= set(word)
            first_letters.add(word[0])
        letters = list(letters)
        if len(letters) > 10:
            return False

        # Step 2: Calculate weights for each letter
        weights = defaultdict(int)
        for word in words:
            for i, c in enumerate(word[::-1]):
                weights[c] += 10 ** i
        for i, c in enumerate(result[::-1]):
            weights[c] -= 10 ** i

        # Step 3: Backtracking
        def backtrack(idx, assignment, used_digits):
            if idx == len(letters):
                # Check if the weighted sum is zero
                return sum(weights[c] * assignment[c] for c in letters) == 0
            c = letters[idx]
            for d in range(10):
                if d in used_digits:
                    continue
                if d == 0 and c in first_letters:
                    continue
                assignment[c] = d
                used_digits.add(d)
                if backtrack(idx + 1, assignment, used_digits):
                    return True
                used_digits.remove(d)
                del assignment[c]
            return False

        return backtrack(0, dict(), set())
      
class Solution {
public:
    bool isSolvable(vector<string>& words, string result) {
        vector<char> letters;
        unordered_set<char> unique_letters, first_letters;
        for (auto& word : words) {
            for (char c : word) unique_letters.insert(c);
            first_letters.insert(word[0]);
        }
        for (char c : result) {
            unique_letters.insert(c);
            first_letters.insert(result[0]);
        }
        for (char c : unique_letters) letters.push_back(c);
        if (letters.size() > 10) return false;

        unordered_map<char, int> weights;
        for (auto& word : words) {
            int n = word.size();
            for (int i = 0; i < n; ++i)
                weights[word[n - i - 1]] += pow(10, i);
        }
        int n = result.size();
        for (int i = 0; i < n; ++i)
            weights[result[n - i - 1]] -= pow(10, i);

        function<bool(int, unordered_map<char, int>&, unordered_set<int>&)> backtrack;
        backtrack = [&](int idx, unordered_map<char, int>& assign, unordered_set<int>& used) {
            if (idx == letters.size()) {
                long sum = 0;
                for (auto& c : letters)
                    sum += weights[c] * assign[c];
                return sum == 0;
            }
            char c = letters[idx];
            for (int d = 0; d < 10; ++d) {
                if (used.count(d)) continue;
                if (d == 0 && first_letters.count(c)) continue;
                assign[c] = d;
                used.insert(d);
                if (backtrack(idx + 1, assign, used)) return true;
                used.erase(d);
                assign.erase(c);
            }
            return false;
        };

        unordered_map<char, int> assign;
        unordered_set<int> used;
        return backtrack(0, assign, used);
    }
};
      
import java.util.*;

class Solution {
    public boolean isSolvable(String[] words, String result) {
        Set<Character> uniqueLetters = new HashSet<>();
        Set<Character> firstLetters = new HashSet<>();
        for (String word : words) {
            for (char c : word.toCharArray()) uniqueLetters.add(c);
            firstLetters.add(word.charAt(0));
        }
        for (char c : result.toCharArray()) uniqueLetters.add(c);
        firstLetters.add(result.charAt(0));
        if (uniqueLetters.size() > 10) return false;
        List<Character> letters = new ArrayList<>(uniqueLetters);

        Map<Character, Integer> weights = new HashMap<>();
        for (String word : words) {
            int n = word.length();
            for (int i = 0; i < n; ++i) {
                char c = word.charAt(n - i - 1);
                weights.put(c, weights.getOrDefault(c, 0) + (int)Math.pow(10, i));
            }
        }
        int n = result.length();
        for (int i = 0; i < n; ++i) {
            char c = result.charAt(n - i - 1);
            weights.put(c, weights.getOrDefault(c, 0) - (int)Math.pow(10, i));
        }

        return backtrack(0, letters, weights, new HashMap<>(), new HashSet<>(), firstLetters);
    }

    private boolean backtrack(int idx, List<Character> letters, Map<Character, Integer> weights,
                              Map<Character, Integer> assign, Set<Integer> used, Set<Character> firstLetters) {
        if (idx == letters.size()) {
            int sum = 0;
            for (char c : letters)
                sum += weights.get(c) * assign.get(c);
            return sum == 0;
        }
        char c = letters.get(idx);
        for (int d = 0; d < 10; ++d) {
            if (used.contains(d)) continue;
            if (d == 0 && firstLetters.contains(c)) continue;
            assign.put(c, d);
            used.add(d);
            if (backtrack(idx + 1, letters, weights, assign, used, firstLetters)) return true;
            assign.remove(c);
            used.remove(d);
        }
        return false;
    }
}
      
var isSolvable = function(words, result) {
    const unique = new Set(), firstLetters = new Set();
    for (const word of words) {
        for (const c of word) unique.add(c);
        firstLetters.add(word[0]);
    }
    for (const c of result) unique.add(c);
    firstLetters.add(result[0]);
    const letters = Array.from(unique);
    if (letters.length > 10) return false;

    const weights = {};
    for (const word of words) {
        for (let i = 0; i < word.length; ++i) {
            const c = word[word.length - i - 1];
            weights[c] = (weights[c] || 0) + Math.pow(10, i);
        }
    }
    for (let i = 0; i < result.length; ++i) {
        const c = result[result.length - i - 1];
        weights[c] = (weights[c] || 0) - Math.pow(10, i);
    }

    function backtrack(idx, assign, used) {
        if (idx === letters.length) {
            let sum = 0;
            for (const c of letters)
                sum += weights[c] * assign[c];
            return sum === 0;
        }
        const c = letters[idx];
        for (let d = 0; d < 10; ++d) {
            if (used.has(d)) continue;
            if (d === 0 && firstLetters.has(c)) continue;
            assign[c] = d;
            used.add(d);
            if (backtrack(idx + 1, assign, used)) return true;
            used.delete(d);
            delete assign[c];
        }
        return false;
    }
    return backtrack(0, {}, new Set());
};