2376. Count Special Integers
Problem Description
This problem asks us to count how many "special" positive integers exist within a given range from 1 to n.
A positive integer is considered special if all of its digits are distinct (no digit appears more than once).
For example:
135
is special because all digits (1, 3, 5) are different121
is not special because the digit 1 appears twice4567
is special because all digits (4, 5, 6, 7) are different
Given a positive integer n
, we need to return the count of all special integers in the range [1, n]
inclusive.
The solution uses a technique called Digit Dynamic Programming (Digit DP) with state compression. The approach builds numbers digit by digit from left to right, keeping track of which digits have been used through a bitmask. The recursive function dfs(i, mask, lead, limit)
explores all valid ways to construct special numbers:
i
: Current position (digit index) being processedmask
: Bitmask tracking which digits (0-9) have already been usedlead
: Boolean indicating if we're still in leading zeroslimit
: Boolean indicating if we're bounded by the corresponding digit inn
The algorithm systematically tries each possible digit at each position, ensuring no digit is repeated (checked via the bitmask), and counts all valid special numbers that can be formed without exceeding n
.
Intuition
The key insight is that we need to count numbers where digits don't repeat. Instead of checking every number from 1 to n individually (which would be inefficient for large n), we can build valid numbers digit by digit.
Think about how you'd manually count special numbers up to, say, 2357:
- First, you'd consider all 1-digit numbers (1-9): all are special
- Then 2-digit numbers (10-99): you need the two digits to be different
- Then 3-digit numbers (100-999): all three digits must be distinct
- Finally, numbers from 1000-2357: all four digits must be distinct
This naturally leads to a digit-by-digit construction approach. When building a number, at each position we need to:
- Know which digits we've already used (to avoid repetition)
- Know if we're still within the bound of n
- Handle leading zeros correctly (e.g., 0012 is actually 12)
The bitmask is perfect for tracking used digits - if bit j
is set in mask
, digit j
has been used. For example, if we've used digits 2 and 5, our mask would have bits 2 and 5 set: mask = 0000100100
in binary.
The limit
flag is crucial for staying within bounds. When limit
is true, we can only choose digits up to the corresponding digit in n. For instance, if n = 2357 and we're building a number starting with 23__, the third digit can only be 0-5 (not 6-9) to stay ≤ 2357.
The lead
flag handles the special case of leading zeros. Numbers like 0087 are actually just 87, so we shouldn't mark 0 as "used" when it's a leading zero. This allows us to use 0 as an actual digit later in the number.
By recursively exploring all valid digit choices at each position while maintaining these constraints, we efficiently count all special numbers without explicitly generating each one.
Learn more about Math and Dynamic Programming patterns.
Solution Approach
The solution implements Digit Dynamic Programming with memoization using a recursive depth-first search function. Let's walk through the implementation step by step:
1. String Conversion:
First, we convert the number n
to a string s
to easily access individual digits. This allows us to process the number digit by digit from left to right.
2. DFS Function Parameters:
The recursive function dfs(i, mask, lead, limit)
uses four parameters:
i
: Current digit position (0-indexed from the left)mask
: Bitmask where bitj
being 1 means digitj
has been usedlead
: True if we're still in leading zeroslimit
: True if we must respect the upper bound fromn
3. Base Case:
if i >= len(s):
return int(lead ^ 1)
When we've processed all positions, we return 1 if we've formed a valid number (not just leading zeros), otherwise 0. The expression lead ^ 1
returns 0 when lead
is True and 1 when lead
is False.
4. Determining Upper Bound:
up = int(s[i]) if limit else 9
If limit
is True, we can only use digits up to the current digit in n
. Otherwise, we can use any digit from 0 to 9.
5. Trying Each Digit:
For each position, we iterate through all possible digits from 0 to up
:
for j in range(up + 1):
if mask >> j & 1:
continue
We skip digit j
if it's already been used (checked by seeing if bit j
is set in mask
).
6. Handling Leading Zeros:
if lead and j == 0: ans += dfs(i + 1, mask, True, limit and j == up)
If we're still in leading zeros and choose 0, we don't mark 0 as used (mask remains unchanged) and continue with lead = True
.
7. Regular Digit Placement:
else: ans += dfs(i + 1, mask | 1 << j, False, limit and j == up)
For non-leading digits, we:
- Mark digit
j
as used by setting bitj
in mask:mask | 1 << j
- Set
lead = False
since we've placed a non-zero digit - Update
limit
based on whether we chose the maximum allowed digit
8. Memoization:
The @cache
decorator automatically memoizes the results, preventing redundant calculations for the same state (i, mask, lead, limit)
.
9. Initial Call:
return dfs(0, 0, True, True)
We start from position 0, with no digits used (mask = 0), in leading zeros (lead = True), and bounded by n (limit = True).
The algorithm efficiently counts all special numbers by exploring only valid digit combinations while pruning invalid branches early. The time complexity is O(m × 2^D × D)
where m
is the number of digits in n
and D = 10
(the number of possible digits), with space complexity O(m × 2^D)
for memoization.
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 trace through the algorithm with n = 25
to understand how it counts special integers.
Setup: Convert n = 25
to string s = "25"
. We need to count all special integers from 1 to 25.
Initial call: dfs(0, 0, True, True)
- Position 0 (first digit), no digits used (mask=0), in leading zeros, limited by n
At position 0:
-
Upper bound is 2 (first digit of 25)
-
Try digits 0, 1, 2:
j = 0: Leading zero case
- Call
dfs(1, 0, True, True)
- continue with leading zeros, mask unchanged
j = 1: Place digit 1
- Mark bit 1 in mask:
mask = 0000000010
- Call
dfs(1, 2, False, False)
- no longer leading, not at limit (1 < 2)
j = 2: Place digit 2
- Mark bit 2 in mask:
mask = 0000000100
- Call
dfs(1, 4, False, True)
- no longer leading, still at limit (2 = 2)
- Call
Following branch where first digit is 2 (limit maintained):
dfs(1, 4, False, True)
- Position 1, digit 2 used (mask=4), limit=True
-
Upper bound is 5 (second digit of 25)
-
Try digits 0, 1, 3, 4, 5 (skip 2 as it's used):
j = 0: Valid, creates number 20
- Call
dfs(2, 5, False, False)
→ returns 1 (base case)
j = 1: Valid, creates number 21
- Call
dfs(2, 6, False, False)
→ returns 1
j = 3: Valid, creates number 23
- Call
dfs(2, 12, False, False)
→ returns 1
j = 4: Valid, creates number 24
- Call
dfs(2, 20, False, False)
→ returns 1
j = 5: Valid, creates number 25
- Call
dfs(2, 36, False, True)
→ returns 1
- Call
Following branch where first digit is 1 (no limit):
dfs(1, 2, False, False)
- Position 1, digit 1 used (mask=2), limit=False
- Upper bound is 9 (no limit)
- Try digits 0, 2-9 (skip 1 as it's used): Each creates a valid 2-digit number (10, 12-19) → 9 special numbers
Following leading zero branch:
dfs(1, 0, True, True)
- Position 1, no digits used, still leading
- This eventually generates single-digit numbers 1-9 → 9 special numbers
Final count:
- Single-digit: 1, 2, 3, 4, 5, 6, 7, 8, 9 → 9 numbers
- Two-digit starting with 1: 10, 12-19 → 9 numbers
- Two-digit starting with 2: 20, 21, 23, 24, 25 → 5 numbers
Total: 23 special integers from 1 to 25
The algorithm efficiently explores all valid combinations while the bitmask prevents digit repetition, the limit flag ensures we don't exceed n, and the lead flag correctly handles numbers with fewer digits than n.
Solution Implementation
1class Solution:
2 def countSpecialNumbers(self, n: int) -> int:
3 """
4 Count numbers from 1 to n that have all distinct digits.
5 Uses digit DP with states for position, used digits mask, leading zeros, and limit.
6 """
7 from functools import cache
8
9 @cache
10 def digit_dp(pos: int, used_mask: int, has_leading_zero: bool, is_limited: bool) -> int:
11 """
12 Dynamic programming function to count special numbers.
13
14 Args:
15 pos: Current position in the number (0-indexed from left)
16 used_mask: Bitmask representing which digits have been used
17 has_leading_zero: True if we haven't placed any non-zero digit yet
18 is_limited: True if we must respect the upper bound at current position
19
20 Returns:
21 Count of valid numbers that can be formed from current state
22 """
23 # Base case: reached end of number
24 if pos >= len(str_n):
25 # Return 1 if we've placed at least one non-zero digit (valid number)
26 # Return 0 if we only have leading zeros (not a valid number)
27 return 0 if has_leading_zero else 1
28
29 # Determine the maximum digit we can place at current position
30 max_digit = int(str_n[pos]) if is_limited else 9
31
32 result = 0
33
34 # Try placing each possible digit at current position
35 for digit in range(max_digit + 1):
36 # Skip if this digit has already been used
37 if (used_mask >> digit) & 1:
38 continue
39
40 # Handle leading zeros specially
41 if has_leading_zero and digit == 0:
42 # Continue with leading zero, don't mark 0 as used
43 result += digit_dp(
44 pos + 1,
45 used_mask,
46 True, # Still have leading zeros
47 is_limited and (digit == max_digit)
48 )
49 else:
50 # Place a non-zero digit or a zero after non-zero digits
51 result += digit_dp(
52 pos + 1,
53 used_mask | (1 << digit), # Mark this digit as used
54 False, # No longer have leading zeros
55 is_limited and (digit == max_digit) # Update limit constraint
56 )
57
58 return result
59
60 # Convert number to string for digit-by-digit processing
61 str_n = str(n)
62
63 # Start DP from position 0, no digits used, with leading zeros, and limited by n
64 return digit_dp(0, 0, True, True)
65
1class Solution {
2 // Character array to store digits of the input number
3 private char[] digits;
4 // Memoization table: dp[position][bitmask]
5 // position: current digit position, bitmask: used digits (0-9)
6 private Integer[][] dp;
7
8 public int countSpecialNumbers(int n) {
9 // Convert number to string then to char array for digit-by-digit processing
10 digits = String.valueOf(n).toCharArray();
11 // Initialize memoization table
12 // digits.length positions, 1024 states for bitmask (2^10 for digits 0-9)
13 dp = new Integer[digits.length][1 << 10];
14
15 // Start DFS from position 0, with empty mask, leading zeros allowed, and limit enforced
16 return dfs(0, 0, true, true);
17 }
18
19 /**
20 * Digit DP to count numbers with unique digits
21 * @param position Current digit position being processed
22 * @param usedDigitsMask Bitmask representing which digits have been used
23 * @param hasLeadingZeros True if we're still in leading zeros
24 * @param isTight True if we're bounded by the original number's digits
25 * @return Count of valid numbers from current state
26 */
27 private int dfs(int position, int usedDigitsMask, boolean hasLeadingZeros, boolean isTight) {
28 // Base case: reached end of number
29 if (position >= digits.length) {
30 // If still has leading zeros, it means the number is 0, which doesn't count
31 return hasLeadingZeros ? 0 : 1;
32 }
33
34 // Check memoization: only valid when not tight bound and no leading zeros
35 if (!isTight && !hasLeadingZeros && dp[position][usedDigitsMask] != null) {
36 return dp[position][usedDigitsMask];
37 }
38
39 // Determine upper bound for current digit
40 int upperBound = isTight ? digits[position] - '0' : 9;
41 int count = 0;
42
43 // Try each possible digit from 0 to upperBound
44 for (int digit = 0; digit <= upperBound; ++digit) {
45 // Skip if this digit has already been used
46 if ((usedDigitsMask >> digit & 1) == 1) {
47 continue;
48 }
49
50 // Handle leading zero case
51 if (hasLeadingZeros && digit == 0) {
52 // Continue with leading zeros, don't mark 0 as used
53 count += dfs(position + 1, usedDigitsMask, true, isTight && digit == upperBound);
54 } else {
55 // Non-zero digit or not a leading zero
56 // Mark current digit as used in the bitmask
57 count += dfs(position + 1, usedDigitsMask | (1 << digit), false, isTight && digit == upperBound);
58 }
59 }
60
61 // Store result in memoization table
62 // Only memoize when not tight bound and no leading zeros
63 if (!isTight && !hasLeadingZeros) {
64 dp[position][usedDigitsMask] = count;
65 }
66
67 return count;
68 }
69}
70
1class Solution {
2public:
3 int countSpecialNumbers(int n) {
4 // Convert number to string for digit-by-digit processing
5 string numStr = to_string(n);
6 int numDigits = numStr.size();
7
8 // Memoization table: dp[position][bitmask of used digits]
9 // -1 indicates uncomputed state
10 int dp[numDigits][1 << 10];
11 memset(dp, -1, sizeof(dp));
12
13 // Recursive function using digit DP technique
14 // Parameters:
15 // - pos: current position in the number (0-indexed from left)
16 // - usedDigitsMask: bitmask representing which digits (0-9) have been used
17 // - isLeadingZero: true if we're still in leading zeros
18 // - isTight: true if we're still bounded by the original number's digits
19 auto countValidNumbers = [&](this auto&& countValidNumbers,
20 int pos,
21 int usedDigitsMask,
22 bool isLeadingZero,
23 bool isTight) -> int {
24 // Base case: reached end of number
25 if (pos >= numDigits) {
26 // Return 1 if we formed a valid number (not just leading zeros)
27 return isLeadingZero ? 0 : 1;
28 }
29
30 // Check memoization table (only valid when not tight and not in leading zeros)
31 if (!isTight && !isLeadingZero && dp[pos][usedDigitsMask] != -1) {
32 return dp[pos][usedDigitsMask];
33 }
34
35 // Determine upper bound for current digit
36 int upperBound = isTight ? (numStr[pos] - '0') : 9;
37 int result = 0;
38
39 // Try all possible digits at current position
40 for (int digit = 0; digit <= upperBound; ++digit) {
41 // Skip if this digit was already used
42 if ((usedDigitsMask >> digit) & 1) {
43 continue;
44 }
45
46 // Handle leading zeros specially (they don't count as used digits)
47 if (isLeadingZero && digit == 0) {
48 // Continue with leading zeros, don't mark 0 as used
49 result += countValidNumbers(pos + 1,
50 usedDigitsMask,
51 true,
52 isTight && (digit == upperBound));
53 } else {
54 // Regular digit: mark it as used in the bitmask
55 result += countValidNumbers(pos + 1,
56 usedDigitsMask | (1 << digit),
57 false,
58 isTight && (digit == upperBound));
59 }
60 }
61
62 // Store result in memoization table (only when state is reusable)
63 if (!isTight && !isLeadingZero) {
64 dp[pos][usedDigitsMask] = result;
65 }
66
67 return result;
68 };
69
70 // Start recursion from position 0, with no digits used,
71 // with leading zeros allowed, and tight bound
72 return countValidNumbers(0, 0, true, true);
73 }
74};
75
1/**
2 * Counts the number of positive integers from 1 to n that have all unique digits
3 * @param n - The upper bound number
4 * @returns The count of special numbers with all unique digits
5 */
6function countSpecialNumbers(n: number): number {
7 // Convert number to string to process digit by digit
8 const numberString: string = n.toString();
9 const digitCount: number = numberString.length;
10
11 // Memoization table: dp[position][bitmask]
12 // -1 indicates uncomputed state
13 const memoTable: number[][] = Array.from(
14 { length: digitCount },
15 () => Array(1 << 10).fill(-1)
16 );
17
18 /**
19 * Depth-first search to count valid numbers
20 * @param position - Current digit position being processed
21 * @param usedDigitsMask - Bitmask representing which digits (0-9) have been used
22 * @param hasLeadingZeros - Whether we're still in leading zeros
23 * @param isTight - Whether we're still bounded by the original number's digits
24 * @returns Count of valid numbers from current state
25 */
26 const dfs = (
27 position: number,
28 usedDigitsMask: number,
29 hasLeadingZeros: boolean,
30 isTight: boolean
31 ): number => {
32 // Base case: reached end of number
33 if (position >= digitCount) {
34 // If still has leading zeros, it means the number is 0, which is invalid
35 return hasLeadingZeros ? 0 : 1;
36 }
37
38 // Check memoization table (only when not tight and no leading zeros)
39 if (!isTight && !hasLeadingZeros && memoTable[position][usedDigitsMask] !== -1) {
40 return memoTable[position][usedDigitsMask];
41 }
42
43 // Determine upper bound for current digit
44 const upperBound: number = isTight ? parseInt(numberString[position]) : 9;
45 let count: number = 0;
46
47 // Try each possible digit at current position
48 for (let digit = 0; digit <= upperBound; digit++) {
49 // Skip if this digit has already been used
50 if ((usedDigitsMask >> digit) & 1) {
51 continue;
52 }
53
54 // Handle leading zeros case
55 if (hasLeadingZeros && digit === 0) {
56 // Continue with leading zeros, don't mark 0 as used
57 count += dfs(
58 position + 1,
59 usedDigitsMask,
60 true,
61 isTight && digit === upperBound
62 );
63 } else {
64 // Mark current digit as used and continue
65 count += dfs(
66 position + 1,
67 usedDigitsMask | (1 << digit),
68 false,
69 isTight && digit === upperBound
70 );
71 }
72 }
73
74 // Store result in memoization table
75 if (!isTight && !hasLeadingZeros) {
76 memoTable[position][usedDigitsMask] = count;
77 }
78
79 return count;
80 };
81
82 // Start DFS from position 0 with empty mask, leading zeros, and tight bound
83 return dfs(0, 0, true, true);
84}
85
Time and Space Complexity
Time Complexity: O(D * 2^10 * D)
where D
is the number of digits in n
.
The time complexity is determined by the number of unique states in the memoized recursion:
- Position
i
: ranges from0
toD-1
, giving usD
possible values - Mask: represents which digits (0-9) have been used, resulting in
2^10 = 1024
possible states - Lead flag: can be
True
orFalse
(2 states) - Limit flag: can be
True
orFalse
(2 states)
Total unique states: D * 2^10 * 2 * 2 = D * 2^12
However, for each recursive call, we iterate through at most 10 digits (0-9), so the actual time complexity is O(D * 2^10 * 10)
which simplifies to O(D * 2^10 * D)
or approximately O(D^2 * 1024)
.
Space Complexity: O(D * 2^10)
The space complexity consists of:
- Recursion call stack depth:
O(D)
as we recurse to a maximum depth equal to the number of digits - Memoization cache: stores unique states which is
O(D * 2^10 * 2 * 2) = O(D * 2^12)
, but this simplifies toO(D * 2^10)
as the constant factors are absorbed - String conversion:
O(D)
to store the string representation ofn
The dominant factor is the memoization cache, giving us a total space complexity of O(D * 2^10)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Handling of Leading Zeros
One of the most common mistakes is incorrectly handling leading zeros, particularly marking 0
as "used" when it appears as a leading zero. This would incorrectly prevent valid numbers like 102
, 203
, etc.
Incorrect Implementation:
for digit in range(max_digit + 1):
if (used_mask >> digit) & 1:
continue
# WRONG: Always marking digit as used, even for leading zeros
result += digit_dp(
pos + 1,
used_mask | (1 << digit), # This marks 0 as used even for leading zeros!
digit != 0,
is_limited and (digit == max_digit)
)
Correct Implementation:
if has_leading_zero and digit == 0: # Don't mark 0 as used when it's a leading zero result += digit_dp(pos + 1, used_mask, True, is_limited and (digit == max_digit)) else: # Only mark digit as used when it's not a leading zero result += digit_dp(pos + 1, used_mask | (1 << digit), False, is_limited and (digit == max_digit))
2. Wrong Base Case Return Value
Another frequent error is returning the wrong value in the base case, particularly not accounting for the case where we only have leading zeros (which represents the number 0 or an invalid number).
Incorrect Implementation:
if pos >= len(str_n):
return 1 # WRONG: This counts leading zeros as valid numbers
Correct Implementation:
if pos >= len(str_n):
return 0 if has_leading_zero else 1 # Only count if we've placed actual digits
3. Forgetting to Update the Limit Flag
The limit flag must be properly propagated. It should remain True
only if we're still limited AND we choose the maximum allowed digit at the current position.
Incorrect Implementation:
# WRONG: Always passing the same limit value result += digit_dp(pos + 1, new_mask, new_lead, is_limited) # OR WRONG: Only checking if digit equals max_digit result += digit_dp(pos + 1, new_mask, new_lead, digit == max_digit)
Correct Implementation:
# Must be limited AND choosing the max digit to remain limited result += digit_dp(pos + 1, new_mask, new_lead, is_limited and (digit == max_digit))
4. Off-by-One Error with Bit Operations
When checking or setting bits in the mask, ensure you're using the correct bit position for each digit.
Incorrect Implementation:
# WRONG: Using 1-indexed instead of 0-indexed if (used_mask >> (digit + 1)) & 1: # Off by one! continue used_mask | (1 << (digit + 1)) # Off by one!
Correct Implementation:
# Digit d corresponds to bit position d (0-indexed) if (used_mask >> digit) & 1: continue used_mask | (1 << digit)
5. Not Using Memoization or Using It Incorrectly
Without memoization, the solution will have exponential time complexity and timeout for large inputs.
Incorrect Implementation:
def digit_dp(pos, used_mask, has_leading_zero, is_limited):
# No memoization - will recalculate same states multiple times
...
Correct Implementation:
from functools import cache
@cache # Essential for performance
def digit_dp(pos, used_mask, has_leading_zero, is_limited):
...
Note: If manually implementing memoization with a dictionary, be careful not to cache states where is_limited = True
incorrectly, as these states are specific to the input number and shouldn't be reused across different inputs.
Which of the following problems can be solved with backtracking (select multiple)
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!