Facebook Pixel

357. Count Numbers with Unique Digits

Problem Description

Given an integer n, you need to count how many numbers have unique digits within the range from 0 to 10^n - 1 (inclusive of 0, exclusive of 10^n).

A number has unique digits if no digit appears more than once in its representation. For example:

  • 123 has unique digits (each digit 1, 2, 3 appears only once)
  • 112 does NOT have unique digits (digit 1 appears twice)
  • 0 has unique digits (only one digit)

The solution uses Digit Dynamic Programming with state compression. The approach tracks:

  • Position (i): Current digit position being processed, starting from the most significant digit
  • Mask: A bitmask where the j-th bit being 1 means digit j has already been used
  • Lead: A boolean flag indicating if we're still in leading zeros

The recursive function dfs(i, mask, lead) explores all valid digit combinations:

  1. If we've processed all positions (i < 0), we've formed a valid number, return 1
  2. For each digit j from 0 to 9:
    • Skip if digit j is already used (checked via mask)
    • If we have leading zeros and current digit is 0, continue with leading zeros
    • Otherwise, mark digit j as used in the mask and proceed without leading zeros

The algorithm effectively counts all numbers from 0 to 10^n - 1 that don't have repeated digits by building them digit by digit while maintaining the constraint that each digit can only be used once.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: The problem doesn't involve nodes and edges or graph traversal. We're counting numbers with a specific property.

Need to solve for kth smallest/largest?

  • No: We're not looking for the kth element, but rather counting all valid numbers.

Involves Linked Lists?

  • No: The problem deals with integer digits, not linked list operations.

Does the problem have small constraints?

  • Yes: The constraint is n where we need to check numbers from 0 to 10^n - 1. Since n is typically small (at most 8 or 9 for practical purposes), and we need to explore all possible combinations of digits where each digit can only be used once, this falls under small constraints.

Brute force / Backtracking?

  • Yes: We need to systematically explore all possible ways to construct numbers with unique digits. This involves:
    • Trying each digit (0-9) at each position
    • Tracking which digits have been used (backtracking state)
    • Recursively building the number digit by digit
    • Backtracking when a digit has already been used

Conclusion: The flowchart correctly identifies this as a backtracking problem. The solution uses backtracking with memoization (digit DP) to efficiently count all numbers with unique digits by:

  1. Building numbers digit by digit from most significant to least significant
  2. Maintaining a bitmask to track used digits
  3. Handling leading zeros appropriately
  4. Using memoization to avoid recalculating the same states
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we need to count numbers with unique digits, the key insight is that we're building valid numbers digit by digit, where each digit choice depends on what digits we've already used.

Think about how you'd manually count these numbers: for a 3-digit range (0 to 999), you'd consider:

  • Single-digit numbers: 0, 1, 2, ..., 9 (all have unique digits)
  • Two-digit numbers: 10, 12, 13, ..., 98 (excluding 11, 22, 33, etc.)
  • Three-digit numbers: 102, 103, ..., 987 (excluding any with repeated digits)

This manual process suggests a recursive approach where at each position, we try all possible digits that haven't been used yet. This is classic backtracking - we make a choice, recurse, then undo the choice.

The challenge is efficiently tracking which digits have been used. A bitmask is perfect here - we can use a 10-bit number where bit j being 1 means digit j has been used. For example, if we've used digits 2 and 5, our mask would be 0000100100 in binary.

Another subtlety is handling leading zeros. The number 012 is actually just 12, so we need to distinguish between a leading zero (which doesn't "use up" the digit 0) and a actual zero in the middle or end of the number. That's why we track a lead flag - when it's true and we encounter a 0, we don't mark 0 as used and keep the lead flag true for the next recursion.

Since many subproblems repeat (e.g., after placing digits 1 and 2 in some positions, the count of valid numbers we can form with the remaining positions is the same regardless of the order we placed 1 and 2), we can use memoization to cache results. This transforms our backtracking into digit dynamic programming, making it efficient enough to handle the problem constraints.

The recursion naturally counts all valid numbers: when we reach the end (all positions filled), we've found one valid number. The sum of all these individual counts gives us our answer.

Solution Approach

The solution implements Digit Dynamic Programming with state compression and memoization. Let's walk through the implementation step by step:

Function Design: We design a recursive function dfs(i, mask, lead) where:

  • i: Current digit position (starting from n-1 for the most significant digit, down to 0)
  • mask: Bitmask tracking used digits (bit j = 1 means digit j is used)
  • lead: Boolean flag indicating if we're still in leading zeros

Base Case: When i < 0, we've successfully constructed a valid number, so return 1.

Recursive Case: For each position i, we try all digits from 0 to 9:

  1. Check if digit is used: If mask >> j & 1 equals 1, digit j is already used, so skip it.

  2. Handle leading zeros: If lead is True and j == 0:

    • We're adding another leading zero
    • Recurse with dfs(i - 1, mask, True)
    • Keep mask unchanged (leading zeros don't count as "using" digit 0)
    • Keep lead flag as True
  3. Handle actual digits: Otherwise:

    • We're placing an actual digit (either non-zero, or zero after a non-zero digit)
    • Recurse with dfs(i - 1, mask | 1 << j, False)
    • Update mask by setting bit j to 1 using bitwise OR: mask | (1 << j)
    • Set lead flag to False since we now have actual digits

Memoization: The @cache decorator automatically memoizes results. For any combination of (i, mask, lead), the function computes the result once and reuses it for subsequent calls with the same parameters.

Starting the Search: We call dfs(n - 1, 0, True):

  • Start from position n-1 (most significant digit)
  • Initial mask is 0 (no digits used)
  • Initial lead is True (haven't placed any actual digits yet)

Example Walkthrough for n=2: For numbers from 0 to 99:

  • At position 1 (tens place): Can place 0 (leading) or 1-9
  • At position 0 (ones place): Can place any digit not already used
  • The function counts: single-digit numbers (0-9) + two-digit numbers without repeated digits (10, 12, ..., 98)

Time Complexity: O(n × 2^D × D) where D = 10

  • n positions to fill
  • 2^D possible masks (each of 10 digits can be used or not)
  • D digits to try at each state

Space Complexity: O(n × 2^D) for the memoization cache

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a small example with n = 2 (counting numbers with unique digits from 0 to 99).

Initial Call: dfs(1, 0, True)

  • Position 1 (tens place), no digits used (mask = 0), in leading zeros

Level 1 - Position 1 (tens place):

For digit 0 (leading zero):

  • Since lead = True and j = 0, this is a leading zero
  • Recurse: dfs(0, 0, True) - move to ones place, mask unchanged, still in leading zeros

For digit 1:

  • Not a leading zero, so mark digit 1 as used
  • Recurse: dfs(0, 2, False) - mask becomes 0000000010 (bit 1 set)

For digit 2:

  • Not a leading zero, so mark digit 2 as used
  • Recurse: dfs(0, 4, False) - mask becomes 0000000100 (bit 2 set)

(Similar for digits 3-9...)

Level 2 - Position 0 (ones place):

From dfs(0, 0, True) (after leading zero):

  • Can place digits 0-9, each forms a single-digit number
  • Returns 10 (for numbers 0, 1, 2, ..., 9)

From dfs(0, 2, False) (after placing 1 in tens):

  • Mask = 0000000010, so digit 1 is unavailable
  • Can place: 0, 2, 3, 4, 5, 6, 7, 8, 9 (9 choices)
  • Returns 9 (for numbers 10, 12, 13, ..., 19)

From dfs(0, 4, False) (after placing 2 in tens):

  • Mask = 0000000100, so digit 2 is unavailable
  • Can place: 0, 1, 3, 4, 5, 6, 7, 8, 9 (9 choices)
  • Returns 9 (for numbers 20, 21, 23, 24, ..., 29)

Final Count:

  • Single-digit numbers (via leading zero path): 10
  • Two-digit numbers with digit 1 in tens: 9
  • Two-digit numbers with digit 2 in tens: 9
  • ... (similarly for digits 3-9 in tens)
  • Total: 10 + 9×9 = 10 + 81 = 91

This gives us all numbers from 0 to 99 with unique digits, excluding numbers like 11, 22, 33, etc., where digits repeat.

Solution Implementation

1from functools import cache
2
3class Solution:
4    def countNumbersWithUniqueDigits(self, n: int) -> int:
5        @cache
6        def count_unique_digit_numbers(position: int, used_digits_mask: int, is_leading_zero: bool) -> int:
7            """
8            Recursively count numbers with unique digits.
9          
10            Args:
11                position: Current digit position (counting from right, 0-indexed)
12                used_digits_mask: Bitmask representing which digits have been used
13                is_leading_zero: True if we haven't placed a non-zero digit yet
14          
15            Returns:
16                Count of valid numbers from this state
17            """
18            # Base case: all positions filled successfully
19            if position < 0:
20                return 1
21          
22            total_count = 0
23          
24            # Try each digit from 0 to 9
25            for digit in range(10):
26                # Check if this digit has already been used (bit is set in mask)
27                if used_digits_mask >> digit & 1:
28                    continue
29              
30                # Handle leading zeros separately (they don't count as used digits)
31                if is_leading_zero and digit == 0:
32                    # Continue with leading zeros, don't mark 0 as used
33                    total_count += count_unique_digit_numbers(
34                        position - 1, 
35                        used_digits_mask, 
36                        True
37                    )
38                else:
39                    # Place a non-zero digit or any digit after first non-zero
40                    # Mark this digit as used by setting its bit in the mask
41                    total_count += count_unique_digit_numbers(
42                        position - 1, 
43                        used_digits_mask | (1 << digit), 
44                        False
45                    )
46          
47            return total_count
48      
49        # Start from the most significant digit position (n-1)
50        # Initially no digits are used (mask = 0) and we have leading zeros
51        return count_unique_digit_numbers(n - 1, 0, True)
52
1class Solution {
2    // Memoization table: dp[position][usedDigitsMask]
3    // Stores the count of valid numbers for a given position and set of used digits
4    private Integer[][] dp;
5
6    /**
7     * Counts numbers with unique digits from 0 to 10^n - 1
8     * @param n The number of digits (1 <= n <= 8)
9     * @return Count of numbers with all unique digits
10     */
11    public int countNumbersWithUniqueDigits(int n) {
12        // Initialize memoization table
13        // First dimension: position index (0 to n-1)
14        // Second dimension: bitmask representing used digits (2^10 possible states)
15        dp = new Integer[n][1 << 10];
16      
17        // Start DFS from the most significant digit position
18        // Initial mask is 0 (no digits used), leading zero flag is true
19        return dfs(n - 1, 0, true);
20    }
21
22    /**
23     * Recursive function to count valid numbers using digit DP
24     * @param position Current digit position (from left to right, 0-indexed)
25     * @param usedDigitsMask Bitmask representing which digits have been used
26     * @param hasLeadingZero True if we're still in leading zeros
27     * @return Count of valid numbers from this state
28     */
29    private int dfs(int position, int usedDigitsMask, boolean hasLeadingZero) {
30        // Base case: all positions filled
31        if (position < 0) {
32            return 1;
33        }
34      
35        // Use memoization only when not dealing with leading zeros
36        // (leading zeros affect the count differently)
37        if (!hasLeadingZero && dp[position][usedDigitsMask] != null) {
38            return dp[position][usedDigitsMask];
39        }
40      
41        int count = 0;
42      
43        // Try each digit from 0 to 9
44        for (int digit = 0; digit <= 9; digit++) {
45            // Check if this digit has already been used (bit is set in mask)
46            if ((usedDigitsMask >> digit & 1) == 1) {
47                continue;
48            }
49          
50            // Handle leading zero case
51            if (hasLeadingZero && digit == 0) {
52                // Continue with leading zeros, don't mark 0 as used
53                count += dfs(position - 1, usedDigitsMask, true);
54            } else {
55                // Place the digit and mark it as used in the bitmask
56                count += dfs(position - 1, usedDigitsMask | (1 << digit), false);
57            }
58        }
59      
60        // Store result in memoization table (only for non-leading-zero states)
61        if (!hasLeadingZero) {
62            dp[position][usedDigitsMask] = count;
63        }
64      
65        return count;
66    }
67}
68
1class Solution {
2public:
3    int countNumbersWithUniqueDigits(int n) {
4        // dp[position][bitmask] = count of valid numbers
5        // -1 indicates uncomputed state
6        int dp[n + 1][1 << 10];
7        memset(dp, -1, sizeof(dp));
8      
9        // Recursive function to count numbers with unique digits
10        // position: current digit position (from right, 0-indexed)
11        // usedDigitsMask: bitmask representing which digits have been used
12        // hasLeadingZeros: true if we haven't placed any non-zero digit yet
13        auto countUniqueDigitNumbers = [&](this auto&& countUniqueDigitNumbers, 
14                                          int position, 
15                                          int usedDigitsMask, 
16                                          bool hasLeadingZeros) -> int {
17            // Base case: all positions filled
18            if (position < 0) {
19                return 1;
20            }
21          
22            // Use memoization only when not dealing with leading zeros
23            if (!hasLeadingZeros && dp[position][usedDigitsMask] != -1) {
24                return dp[position][usedDigitsMask];
25            }
26          
27            int totalCount = 0;
28          
29            // Try each digit from 0 to 9
30            for (int digit = 0; digit <= 9; ++digit) {
31                // Skip if this digit has already been used
32                if (usedDigitsMask >> digit & 1) {
33                    continue;
34                }
35              
36                // Handle leading zeros separately
37                if (hasLeadingZeros && digit == 0) {
38                    // Continue with leading zeros, don't mark 0 as used
39                    totalCount += countUniqueDigitNumbers(position - 1, 
40                                                         usedDigitsMask, 
41                                                         true);
42                } else {
43                    // Place the digit and mark it as used
44                    totalCount += countUniqueDigitNumbers(position - 1, 
45                                                         usedDigitsMask | (1 << digit), 
46                                                         false);
47                }
48            }
49          
50            // Store result in dp table if not dealing with leading zeros
51            if (!hasLeadingZeros) {
52                dp[position][usedDigitsMask] = totalCount;
53            }
54          
55            return totalCount;
56        };
57      
58        // Start from the most significant digit position
59        return countUniqueDigitNumbers(n - 1, 0, true);
60    }
61};
62
1/**
2 * Counts numbers with unique digits from 0 to 10^n - 1
3 * @param n - The number of digits (0 <= n <= 8)
4 * @returns The count of numbers with all unique digits
5 */
6function countNumbersWithUniqueDigits(n: number): number {
7    // Memoization table: memo[position][bitmask]
8    // position: current digit position (0 to n-1)
9    // bitmask: represents which digits (0-9) have been used
10    const memo: number[][] = Array.from(
11        { length: n }, 
12        () => Array(1 << 10).fill(-1)
13    );
14  
15    /**
16     * Depth-first search to count valid numbers
17     * @param position - Current digit position (counting from right, 0-indexed)
18     * @param usedDigitsMask - Bitmask representing which digits have been used
19     * @param hasLeadingZero - Whether we're still in leading zeros
20     * @returns Count of valid numbers from this state
21     */
22    const dfs = (position: number, usedDigitsMask: number, hasLeadingZero: boolean): number => {
23        // Base case: processed all digit positions
24        if (position < 0) {
25            return 1;
26        }
27      
28        // Return memoized result if available (only when not in leading zero state)
29        if (!hasLeadingZero && memo[position][usedDigitsMask] !== -1) {
30            return memo[position][usedDigitsMask];
31        }
32      
33        let count: number = 0;
34      
35        // Try each digit from 0 to 9
36        for (let digit: number = 0; digit < 10; digit++) {
37            // Skip if this digit has already been used
38            if ((usedDigitsMask >> digit) & 1) {
39                continue;
40            }
41          
42            if (hasLeadingZero && digit === 0) {
43                // Continue with leading zeros, don't mark 0 as used
44                count += dfs(position - 1, usedDigitsMask, true);
45            } else {
46                // Place this digit and mark it as used in the bitmask
47                count += dfs(position - 1, usedDigitsMask | (1 << digit), false);
48            }
49        }
50      
51        // Memoize the result (only when not in leading zero state)
52        if (!hasLeadingZero) {
53            memo[position][usedDigitsMask] = count;
54        }
55      
56        return count;
57    };
58  
59    // Start DFS from the most significant digit position
60    return dfs(n - 1, 0, true);
61}
62

Time and Space Complexity

Time Complexity: O(n * 2^10 * 10) = O(n * 10240) = O(n)

The time complexity is determined by the memoized recursive function dfs:

  • The function has 3 parameters: i (ranges from 0 to n-1), mask (can have at most 2^10 = 1024 different values representing subsets of 10 digits), and lead (boolean with 2 values)
  • Total possible states: n * 1024 * 2 = O(n)
  • For each state, we iterate through 10 digits (0-9)
  • Each state is computed only once due to memoization with @cache
  • Therefore, total time complexity is O(n * 1024 * 2 * 10) = O(n)

Space Complexity: O(n * 2^10) = O(n * 1024) = O(n)

The space complexity consists of:

  • Memoization cache: stores up to n * 1024 * 2 = O(n) states
  • Recursion call stack: maximum depth is n, contributing O(n) space
  • Overall space complexity is O(n)

Common Pitfalls

1. Incorrect Handling of Zero as a Valid Number

Pitfall: A common mistake is forgetting that 0 itself is a valid number with unique digits. Some implementations might accidentally exclude it when trying to handle leading zeros, especially if they check for "no digits used" as an invalid state.

Example of Wrong Approach:

# Wrong: This might skip counting 0 as a valid number
if mask == 0 and not is_leading_zero:
    return 0  # Incorrectly treats "no digits used" as invalid

Solution: The correct approach treats leading zeros specially - they transition to the next position without marking digit 0 as "used". This ensures that the number 0 (all leading zeros until the last position) is counted as valid.

2. Misunderstanding the Range Boundaries

Pitfall: The problem asks for numbers from 0 to 10^n - 1 (inclusive of 0, exclusive of 10^n). A common error is:

  • Forgetting to include 0 in the count
  • Trying to include 10^n itself
  • Using wrong position indexing (e.g., starting from position n instead of n-1)

Example of Wrong Approach:

# Wrong: Starting from position n would count n+1 digit numbers
return count_unique_digit_numbers(n, 0, True)  # Should be n-1

Solution: Always start from position n-1 for n-digit positions (0-indexed). For n=2, we want numbers 0-99, which means positions 1 and 0.

3. Incorrect Mask Update for Leading Zeros

Pitfall: A critical error is updating the mask when encountering leading zeros. Leading zeros should NOT mark digit 0 as used because the actual number hasn't started yet.

Example of Wrong Approach:

# Wrong: This marks 0 as used even for leading zeros
if digit == 0:
    total_count += dfs(position - 1, mask | (1 << 0), is_leading_zero)

Solution: Only update the mask when placing actual digits (non-leading zeros):

if is_leading_zero and digit == 0:
    # Don't update mask for leading zeros
    total_count += dfs(position - 1, mask, True)
else:
    # Update mask only for actual digits
    total_count += dfs(position - 1, mask | (1 << digit), False)

4. Forgetting Memoization or Using Incorrect Cache Key

Pitfall: Without memoization, the solution has exponential time complexity. Also, using an incomplete cache key (e.g., omitting the lead parameter) causes incorrect results because the same (position, mask) pair behaves differently depending on whether we're in leading zeros.

Example of Wrong Approach:

# Wrong: Missing the lead parameter in memoization
memo = {}
def dfs(i, mask, lead):
    if (i, mask) in memo:  # Missing 'lead' in the key!
        return memo[(i, mask)]

Solution: Include all state parameters in the memoization key:

@cache  # Automatically handles all parameters
def dfs(i, mask, lead):
    # ... implementation

5. Off-by-One Errors in Position Handling

Pitfall: Confusion about whether positions are 0-indexed or 1-indexed, and whether we count from left to right or right to left.

Example of Wrong Approach:

# Wrong: Incorrect base case check
if position == 0:  # Should be position < 0
    return 1

Solution: Use clear, consistent indexing:

  • Position n-1 is the leftmost (most significant) digit
  • Position 0 is the rightmost (least significant) digit
  • Base case is when position < 0 (all positions processed)
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

The three-steps of Depth First Search are:

  1. Identify states;
  2. Draw the state-space tree;
  3. DFS on the state-space tree.

Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More