3032. Count Numbers With Unique Digits II đ
Problem Description
Given two positive integers a
and b
, you need to count how many numbers in the range [a, b]
(inclusive) have all unique digits.
A number has unique digits if no digit appears more than once in that number. For example:
123
has unique digits (each digit 1, 2, 3 appears exactly once)112
does not have unique digits (digit 1 appears twice)9876
has unique digits (each digit appears exactly once)
The solution uses digit dynamic programming with state compression. The approach calculates the count by finding all valid numbers from 1 to b
with unique digits, then subtracting all valid numbers from 1 to a-1
.
The key technique involves:
- Using a bitmask to track which digits (0-9) have already been used in the current number being formed
- The recursive function
dfs(pos, mask, limit)
where:pos
: current digit position being processedmask
: bitmask representing which digits have been used (biti
is set if digiti
has been used)limit
: whether we're constrained by the upper bound at the current position
- For each position, we try placing digits 0-9 (respecting the limit) and skip any digit that's already been used (checked via the bitmask)
- Special handling for leading zeros: they don't count as "used" digits until we encounter the first non-zero digit
The final answer is computed as f(b) - f(a-1)
where f(n)
counts numbers with unique digits from 1 to n.
Intuition
When we need to count numbers with a specific property in a range [a, b]
, a common technique is to transform it into counting from [1, b]
minus counting from [1, a-1]
. This simplifies our problem to just counting valid numbers up to a given limit.
The challenge is efficiently counting all numbers up to n
that have unique digits. A naive approach would be to check each number individually, but this would be too slow for large ranges.
The key insight is that we can build valid numbers digit by digit from left to right. As we construct each number, we need to track which digits we've already used to ensure uniqueness. This naturally leads to using a bitmask - if we've used digit 3
, we set bit 3 in our mask; if we've used digit 7
, we set bit 7, and so on.
Why digit DP? When building numbers digit by digit, we notice that many subproblems repeat. For instance, if we're building a 5-digit number and we've filled the first 3 positions with certain digits, the count of valid ways to fill the remaining 2 positions depends only on:
- Which digits we've already used (captured by the mask)
- Whether we're still bounded by the original number's digits (the limit flag)
- The current position we're filling
This observation allows us to memoize our results and avoid redundant calculations.
The limit
flag is crucial for correctness. When building a number up to, say, 523
, if we've placed 5
in the first position and 2
in the second position, then the third position can only go up to 3
. But if we had placed 4
in the first position, then all subsequent positions could use any digit from 0-9
(respecting uniqueness). This is why we track whether we're still "hugging" the upper bound.
Leading zeros require special attention. The number 0123
is actually just 123
, so the leading 0
shouldn't count as a "used" digit. We handle this by keeping the mask at 0
until we place our first non-zero digit, effectively ignoring leading zeros in our uniqueness check.
Learn more about Math and Dynamic Programming patterns.
Solution Approach
The solution implements digit DP with state compression using a recursive memoized function. Here's how the implementation works:
Main Function Structure:
The numberCount
function calculates the result using the difference formula: f(b) - f(a-1)
. It processes each bound by converting it to a string and calling the digit DP function.
Core DP Function - dfs(pos, mask, limit)
:
The recursive function has three parameters:
pos
: Current digit position being processed (0 for leftmost digit)mask
: Bitmask tracking which digits (0-9) have been usedlimit
: Boolean indicating if we're constrained by the upper bound
Base Case:
if pos >= len(num):
return 1 if mask else 0
When we've processed all positions, we return 1 if we've formed a valid number (mask â 0, meaning it's not all leading zeros), otherwise 0.
Recursive Case:
-
Determine the upper bound for current digit:
up = int(num[pos]) if limit else 9
If
limit
is True, we can only use digits up tonum[pos]
. Otherwise, we can use any digit 0-9. -
Try each possible digit:
for i in range(up + 1): if mask >> i & 1: continue
We iterate through possible digits, skipping any that have already been used (checked by testing if bit
i
is set in the mask). -
Handle leading zeros:
nxt = 0 if mask == 0 and i == 0 else mask | 1 << i
If the mask is 0 (no non-zero digits yet) and we're placing a 0, keep the mask as 0 (treating it as a leading zero). Otherwise, set the bit corresponding to digit
i
. -
Recursive call:
ans += dfs(pos + 1, nxt, limit and i == up)
Move to the next position with the updated mask. The
limit
remains True only if it was True before AND we chose the maximum possible digit.
Memoization:
The @cache
decorator automatically memoizes the function results, preventing redundant calculations for identical states.
Computing the Final Answer:
num = str(a - 1)
x = dfs(0, 0, True)
dfs.cache_clear()
num = str(b)
y = dfs(0, 0, True)
return y - x
- Calculate count for numbers from 1 to
a-1
- Clear the cache (important since we're reusing the same function with different
num
) - Calculate count for numbers from 1 to
b
- Return the difference to get the count in range
[a, b]
The time complexity is O(D Ă 2^10 Ă 2)
where D is the number of digits, as we have at most D positions, 2^10 possible masks, and 2 possible values for the limit flag.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through counting numbers with unique digits in the range [10, 25]
.
Step 1: Transform the problem
- We need to find:
f(25) - f(9)
- This counts all valid numbers from 1 to 25, then subtracts valid numbers from 1 to 9
Step 2: Calculate f(9)
Building numbers up to 9 digit by digit:
- Position 0 (only position), limit=True, mask=0 (no digits used yet)
- We can place digits 0-9 (but limited by 9)
- Place 0: mask stays 0 (leading zero), contributes 0
- Place 1: mask becomes 0000000010 (bit 1 set), contributes 1
- Place 2: mask becomes 0000000100 (bit 2 set), contributes 1
- ... continues for 3,4,5,6,7,8,9
- Result: f(9) = 9 (the numbers 1,2,3,4,5,6,7,8,9 all have unique digits)
Step 3: Calculate f(25)
Building 2-digit numbers up to 25:
Starting with dfs(0, 0, True)
for "25":
- Position 0 (tens place), limit=True, mask=0
- Try digit 0: mask stays 0 (leading zero)
- Position 1, limit=False, mask=0
- Can place 1-9 (not 0 since mask=0), each valid â contributes 9
- Try digit 1: mask=0000000010
- Position 1, limit=False, mask=0000000010
- Can place 0,2,3,4,5,6,7,8,9 (not 1) â contributes 9
- Try digit 2: mask=0000000100
- Position 1, limit=True (since 2=upper bound), mask=0000000100
- Can only place 0,1,3,4,5 (limited by 5, can't use 2)
- Contributes 5
- Try digit 0: mask stays 0 (leading zero)
Total for f(25):
- From digit 0 in tens place: 9 (numbers 01-09, which are really 1-9)
- From digit 1 in tens place: 9 (numbers 10,12,13,14,15,16,17,18,19)
- From digit 2 in tens place: 5 (numbers 20,21,23,24,25)
- f(25) = 9 + 9 + 5 = 23
Step 4: Final answer
- Count in [10, 25] = f(25) - f(9) = 23 - 9 = 14
The valid numbers are: 10,12,13,14,15,16,17,18,19,20,21,23,24,25 (exactly 14 numbers)
Note how the algorithm efficiently handles:
- Leading zeros: When placing 0 in the tens place, mask stays 0, allowing all digits in the ones place
- Limit flag: When we place 2 in tens place (matching the upper bound), we're limited to digits 0-5 in ones place
- Bitmask tracking: Each used digit sets its corresponding bit, preventing reuse
Solution Implementation
1class Solution:
2 def numberCount(self, a: int, b: int) -> int:
3 from functools import cache
4
5 def count_unique_digit_numbers(upper_bound: str) -> int:
6 """
7 Count numbers from 1 to upper_bound that have all unique digits.
8 Uses digit DP with memoization.
9 """
10
11 @cache
12 def digit_dp(position: int, used_digits_mask: int, is_tight: bool) -> int:
13 """
14 Recursive function to count valid numbers.
15
16 Args:
17 position: Current digit position being processed (0-indexed from left)
18 used_digits_mask: Bitmask representing which digits (0-9) have been used
19 is_tight: Whether we're still bounded by the upper_bound's digits
20
21 Returns:
22 Count of valid numbers from this state
23 """
24
25 # Base case: reached the end of the number
26 if position >= len(upper_bound):
27 # Return 1 if we've used at least one digit (not a leading zero case)
28 return 1 if used_digits_mask else 0
29
30 # Determine the maximum digit we can place at this position
31 max_digit = int(upper_bound[position]) if is_tight else 9
32
33 total_count = 0
34
35 # Try each possible digit at the current position
36 for digit in range(max_digit + 1):
37 # Skip if this digit has already been used
38 if used_digits_mask >> digit & 1:
39 continue
40
41 # Update the mask for the next position
42 # Handle leading zeros: don't set mask if we haven't started the number yet
43 if used_digits_mask == 0 and digit == 0:
44 # Still in leading zeros, keep mask as 0
45 next_mask = 0
46 else:
47 # Mark this digit as used
48 next_mask = used_digits_mask | (1 << digit)
49
50 # Recursively count for the next position
51 # Remain tight only if we used the maximum possible digit
52 total_count += digit_dp(
53 position + 1,
54 next_mask,
55 is_tight and (digit == max_digit)
56 )
57
58 return total_count
59
60 # Start the DP from position 0, no digits used, bounded by upper_bound
61 result = digit_dp(0, 0, True)
62
63 # Clear the cache for the next calculation
64 digit_dp.cache_clear()
65
66 return result
67
68 # Count numbers with unique digits from 1 to (a-1)
69 count_below_a = count_unique_digit_numbers(str(a - 1))
70
71 # Count numbers with unique digits from 1 to b
72 count_up_to_b = count_unique_digit_numbers(str(b))
73
74 # Return the count in range [a, b]
75 return count_up_to_b - count_below_a
76
1class Solution {
2 private String numberString;
3 private Integer[][] memoization;
4
5 /**
6 * Counts how many numbers between a and b (inclusive) have all unique digits
7 * @param a Lower bound of the range
8 * @param b Upper bound of the range
9 * @return Count of numbers with unique digits in [a, b]
10 */
11 public int numberCount(int a, int b) {
12 // Calculate count for numbers from 1 to (a-1)
13 numberString = String.valueOf(a - 1);
14 memoization = new Integer[numberString.length()][1 << 10];
15 int countBelowA = dfs(0, 0, true);
16
17 // Calculate count for numbers from 1 to b
18 numberString = String.valueOf(b);
19 memoization = new Integer[numberString.length()][1 << 10];
20 int countUpToB = dfs(0, 0, true);
21
22 // Return difference to get count in range [a, b]
23 return countUpToB - countBelowA;
24 }
25
26 /**
27 * Recursive function with memoization to count valid numbers
28 * @param currentPosition Current digit position being processed
29 * @param usedDigitsMask Bitmask representing which digits (0-9) have been used
30 * @param hasUpperLimit Whether current number is still bounded by the original number
31 * @return Count of valid numbers from current state
32 */
33 private int dfs(int currentPosition, int usedDigitsMask, boolean hasUpperLimit) {
34 // Base case: reached end of number
35 if (currentPosition >= numberString.length()) {
36 // Return 1 if at least one digit was used (excluding leading zeros)
37 return usedDigitsMask > 0 ? 1 : 0;
38 }
39
40 // Check memoization table if not limited by upper bound
41 if (!hasUpperLimit && memoization[currentPosition][usedDigitsMask] != null) {
42 return memoization[currentPosition][usedDigitsMask];
43 }
44
45 // Determine maximum digit we can place at current position
46 int maxDigit = hasUpperLimit ? numberString.charAt(currentPosition) - '0' : 9;
47 int totalCount = 0;
48
49 // Try each possible digit from 0 to maxDigit
50 for (int digit = 0; digit <= maxDigit; ++digit) {
51 // Skip if this digit has already been used
52 if ((usedDigitsMask >> digit & 1) == 1) {
53 continue;
54 }
55
56 // Update mask for next recursion
57 // Handle leading zeros: don't set bit for leading zeros
58 int nextMask = (usedDigitsMask == 0 && digit == 0) ? 0 : usedDigitsMask | (1 << digit);
59
60 // Recursive call with updated parameters
61 totalCount += dfs(
62 currentPosition + 1,
63 nextMask,
64 hasUpperLimit && (digit == maxDigit)
65 );
66 }
67
68 // Store result in memoization table if not limited
69 if (!hasUpperLimit) {
70 memoization[currentPosition][usedDigitsMask] = totalCount;
71 }
72
73 return totalCount;
74 }
75}
76
1class Solution {
2public:
3 int numberCount(int a, int b) {
4 // Convert upper bound to string for digit-by-digit processing
5 string upperBoundStr = to_string(b);
6
7 // DP memoization table: dp[position][bitmask of used digits]
8 // -1 indicates uncomputed state
9 int dp[upperBoundStr.size()][1 << 10];
10 memset(dp, -1, sizeof(dp));
11
12 // Recursive function to count numbers with unique digits
13 // Parameters:
14 // currentPos: current position in the number (0-indexed from left)
15 // usedDigitsMask: bitmask representing which digits have been used
16 // isTight: whether we're still bounded by the upper limit
17 function<int(int, int, bool)> countUniqueDigitNumbers = [&](int currentPos, int usedDigitsMask, bool isTight) {
18 // Base case: reached the end of the number
19 if (currentPos >= upperBoundStr.size()) {
20 // Return 1 if we've used at least one digit (not a leading zero case)
21 return usedDigitsMask ? 1 : 0;
22 }
23
24 // Use memoized result if available and not in tight constraint
25 if (!isTight && dp[currentPos][usedDigitsMask] != -1) {
26 return dp[currentPos][usedDigitsMask];
27 }
28
29 // Determine the maximum digit we can place at current position
30 int maxDigit = isTight ? upperBoundStr[currentPos] - '0' : 9;
31 int totalCount = 0;
32
33 // Try each possible digit at current position
34 for (int digit = 0; digit <= maxDigit; ++digit) {
35 // Skip if this digit has already been used
36 if (usedDigitsMask >> digit & 1) {
37 continue;
38 }
39
40 // Update the mask for next position
41 // Handle leading zeros: don't mark 0 as used if we haven't started the number yet
42 int nextMask = (usedDigitsMask == 0 && digit == 0) ? 0 : usedDigitsMask | (1 << digit);
43
44 // Recursively count for next position
45 // Maintain tight constraint if current digit equals maxDigit
46 totalCount += countUniqueDigitNumbers(currentPos + 1, nextMask, isTight && digit == maxDigit);
47 }
48
49 // Memoize the result if not under tight constraint
50 if (!isTight) {
51 dp[currentPos][usedDigitsMask] = totalCount;
52 }
53
54 return totalCount;
55 };
56
57 // Count numbers with unique digits from 0 to b
58 int countUpToB = countUniqueDigitNumbers(0, 0, true);
59
60 // Prepare to count numbers from 0 to a-1
61 upperBoundStr = to_string(a - 1);
62 memset(dp, -1, sizeof(dp));
63
64 // Count numbers with unique digits from 0 to a-1
65 int countUpToAMinus1 = countUniqueDigitNumbers(0, 0, true);
66
67 // Return count in range [a, b] = count[0, b] - count[0, a-1]
68 return countUpToB - countUpToAMinus1;
69 }
70};
71
1/**
2 * Counts numbers with distinct digits in the range [a, b]
3 * Uses digit DP (Dynamic Programming) with bitmask to track used digits
4 * @param a - Lower bound of the range (inclusive)
5 * @param b - Upper bound of the range (inclusive)
6 * @returns Count of numbers with all distinct digits in [a, b]
7 */
8function numberCount(a: number, b: number): number {
9 // Convert upper bound to string for digit-by-digit processing
10 let upperBoundStr: string = b.toString();
11
12 // DP memoization table: dp[position][bitmask]
13 // bitmask represents which digits (0-9) have been used
14 const dpMemo: number[][] = Array(upperBoundStr.length)
15 .fill(0)
16 .map(() => Array(1 << 10).fill(-1)); // 2^10 states for 10 digits (0-9)
17
18 /**
19 * Recursive function to count valid numbers using digit DP
20 * @param position - Current position in the number being formed
21 * @param usedDigitsMask - Bitmask indicating which digits have been used
22 * @param isTight - Whether we're still bounded by the original number's digits
23 * @returns Count of valid numbers from this state
24 */
25 const countDistinctDigitNumbers = (
26 position: number,
27 usedDigitsMask: number,
28 isTight: boolean
29 ): number => {
30 // Base case: reached end of number
31 if (position >= upperBoundStr.length) {
32 // Return 1 if we've used at least one digit (valid number formed)
33 return usedDigitsMask ? 1 : 0;
34 }
35
36 // Check memoization (only when not tight bound)
37 if (!isTight && dpMemo[position][usedDigitsMask] !== -1) {
38 return dpMemo[position][usedDigitsMask];
39 }
40
41 // Determine upper limit for current digit
42 const maxDigit: number = isTight ? +upperBoundStr[position] : 9;
43 let totalCount: number = 0;
44
45 // Try each possible digit at current position
46 for (let digit = 0; digit <= maxDigit; digit++) {
47 // Skip if digit already used (check bit in mask)
48 if ((usedDigitsMask >> digit) & 1) {
49 continue;
50 }
51
52 // Update mask with current digit
53 let nextMask: number = usedDigitsMask | (1 << digit);
54
55 // Handle leading zeros: don't mark 0 as used if we haven't started the number yet
56 if (usedDigitsMask === 0 && digit === 0) {
57 nextMask = 0;
58 }
59
60 // Recursive call for next position
61 totalCount += countDistinctDigitNumbers(
62 position + 1,
63 nextMask,
64 isTight && digit === maxDigit
65 );
66 }
67
68 // Memoize result when not in tight bound
69 if (!isTight) {
70 dpMemo[position][usedDigitsMask] = totalCount;
71 }
72
73 return totalCount;
74 };
75
76 // Calculate count for numbers from 0 to b
77 const countUpToB: number = countDistinctDigitNumbers(0, 0, true);
78
79 // Prepare for counting numbers from 0 to a-1
80 upperBoundStr = (a - 1).toString();
81 dpMemo.forEach(row => row.fill(-1)); // Reset memoization table
82
83 // Calculate count for numbers from 0 to a-1
84 const countUpToAMinus1: number = countDistinctDigitNumbers(0, 0, true);
85
86 // Return difference to get count in range [a, b]
87 return countUpToB - countUpToAMinus1;
88}
89
Time and Space Complexity
The time complexity is O(m Ă 2^10 Ă 10)
, where m
is the number of digits in b
.
This complexity arises from:
m
: The recursion depth equals the number of digit positions we need to process (length of the number string)2^10
: The mask parameter can have at most2^10
different states since we're tracking which digits (0-9) have been used via a bitmask10
: At each position, we iterate through at most 10 possible digit choices (0-9)
The space complexity is O(m Ă 2^10)
.
This complexity comes from:
- The memoization cache stores results for unique combinations of
(pos, mask, limit)
parameters pos
can havem
different values (0 to m-1)mask
can have at most2^10
different values (representing all possible combinations of used digits)limit
is a boolean with 2 possible values- The total number of cached states is bounded by
O(m Ă 2^10 Ă 2) = O(m Ă 2^10)
- Additionally, the recursion stack depth is
O(m)
, but this is dominated by the cache storage
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Forgetting to Clear Cache Between Function Calls
One critical pitfall is reusing the memoized function without clearing the cache between different upper bounds. Since the digit_dp
function uses the outer variable upper_bound
, the cached results from one call become invalid when processing a different number.
Incorrect Implementation:
def numberCount(self, a: int, b: int) -> int:
@cache
def digit_dp(position, mask, is_tight):
# ... implementation ...
# First call with a-1
upper_bound = str(a - 1)
count_below_a = digit_dp(0, 0, True)
# Second call with b - WRONG! Cache still has results from a-1
upper_bound = str(b)
count_up_to_b = digit_dp(0, 0, True)
return count_up_to_b - count_below_a
Solution: Always clear the cache between calls or use separate function instances:
# Option 1: Clear cache explicitly
digit_dp.cache_clear()
# Option 2: Create separate function instances
def create_counter(upper_bound):
@cache
def digit_dp(position, mask, is_tight):
# ... implementation ...
return digit_dp(0, 0, True)
2. Incorrect Handling of Leading Zeros
A common mistake is treating leading zeros as "used digits" which would prevent valid numbers like 102
(where 0 appears after non-zero digits).
Incorrect Implementation:
# Wrong: Always marks digit as used, even for leading zeros next_mask = used_digits_mask | (1 << digit)
Solution: Only mark zeros as used when they appear after the first non-zero digit:
if used_digits_mask == 0 and digit == 0: next_mask = 0 # Still in leading zeros else: next_mask = used_digits_mask | (1 << digit)
3. Off-by-One Error in Range Calculation
When calculating numbers in range [a, b]
, forgetting to subtract 1 from a
leads to excluding a
itself from the count.
Incorrect Implementation:
# Wrong: This counts numbers in range [a+1, b]
count_below_a = count_unique_digit_numbers(str(a))
count_up_to_b = count_unique_digit_numbers(str(b))
return count_up_to_b - count_below_a
Solution:
Use a-1
as the lower bound:
count_below_a = count_unique_digit_numbers(str(a - 1))
count_up_to_b = count_unique_digit_numbers(str(b))
return count_up_to_b - count_below_a
4. Incorrect Base Case Return Value
Returning 1 unconditionally at the base case counts numbers consisting only of leading zeros as valid.
Incorrect Implementation:
if position >= len(upper_bound):
return 1 # Wrong: counts "000" as valid
Solution: Check if at least one non-zero digit was used:
if position >= len(upper_bound):
return 1 if used_digits_mask else 0
5. Not Propagating the Tight Constraint Correctly
The tight constraint should only remain true if we chose the maximum possible digit at the current position.
Incorrect Implementation:
# Wrong: Always passes the same tight value digit_dp(position + 1, next_mask, is_tight)
Solution: Update tight constraint based on the chosen digit:
digit_dp(position + 1, next_mask, is_tight and (digit == max_digit))
Which of the tree traversal order can be used to obtain elements in a binary search tree in sorted order?
Recommended Readings
Math for Technical Interviews How much math do I need to know for technical interviews The short answer is about high school level math Computer science is often associated with math and some universities even place their computer science department under the math faculty However the reality is that you
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Want a Structured Path to Master System Design Too? Donât Miss This!