902. Numbers At Most N Given Digit Set
Problem Description
You are given an array of digits
sorted in non-decreasing order. You can use each digit from this array as many times as you want to form positive integers.
For example, if digits = ['1','3','5']
, you can create numbers like:
'13'
(using digit 1 and digit 3)'551'
(using digit 5 twice and digit 1 once)'1351315'
(using digits 1, 3, and 5 multiple times)
Your task is to count how many positive integers you can generate using only the given digits that are less than or equal to a given integer n
.
Key points to understand:
- You can only use the digits provided in the
digits
array - Each digit can be used multiple times (unlimited usage)
- The numbers you form must be positive integers (no leading zeros, must be > 0)
- The numbers must be ≤
n
For instance, if digits = ['1', '3', '5']
and n = 100
, you need to count all valid numbers like 1, 3, 5, 11, 13, 15, 31, 33, 35, 51, 53, 55 that can be formed using only digits 1, 3, and 5, and are less than or equal to 100.
Intuition
When we need to count numbers formed from specific digits that are less than or equal to n
, we can think about this digit by digit. The key insight is that we're essentially building numbers from left to right, and at each position, we need to make decisions about which digit to place.
Consider n = 523
and digits = ['1', '3', '5']
. We can form numbers with:
- 1 digit: 1, 3, 5 (all valid since they're < 523)
- 2 digits: 11, 13, 15, 31, 33, 35, 51, 53, 55 (all valid)
- 3 digits: We need to be careful here - not all combinations will be ≤ 523
This naturally leads us to think about Dynamic Programming on digits. The core idea is to traverse the number n
digit by digit from left to right, and at each position, decide which digits from our available set we can place.
At each position, we face two scenarios:
- We're still bounded by
n
: If we've been matchingn
exactly so far (like placing '5' at the first position whenn = 523
), then at the current position, we can only place digits that don't exceed the corresponding digit inn
. - We're already smaller than
n
: If we've already placed a smaller digit earlier (like placing '3' at the first position whenn = 523
), then we're free to place any available digit at subsequent positions.
We also need to handle leading zeros carefully. Since we want positive integers, we can't have numbers that start with 0. However, when we're building numbers digit by digit, we need a way to represent "we haven't started the actual number yet" - this is where the lead
parameter comes in.
The recursive structure emerges naturally:
- Start from the leftmost position
- For each position, try all valid digits
- Keep track of whether we're still bounded by
n
(thelimit
flag) - Keep track of whether we're still in the leading zero phase (the
lead
flag) - When we reach the end, we've successfully formed a valid number (unless it's all leading zeros)
This digit DP approach is powerful because it systematically explores all possibilities while avoiding redundant calculations through memoization. The beauty is that we're not generating all possible numbers explicitly - we're counting them efficiently by recognizing that many subproblems repeat.
Learn more about Math, Binary Search and Dynamic Programming patterns.
Solution Approach
The solution implements a Digit Dynamic Programming approach with memoization. Let's break down the implementation step by step:
1. Initial Setup:
s = str(n)
nums = {int(x) for x in digits}
- Convert the integer
n
to a strings
to easily access individual digits - Convert the
digits
array to a set of integersnums
for O(1) lookup
2. The Recursive Function:
The core of the solution is the dfs
function with three parameters:
i
: Current position in the strings
(0-indexed)lead
: Boolean indicating if we're still in the leading zeros phase (haven't placed any non-zero digit yet)limit
: Boolean indicating if we're still bounded by the upper limitn
3. Base Case:
if i >= len(s):
return lead ^ 1
When we've processed all positions:
- If
lead
is True (all leading zeros), return 0 (not a valid positive integer) - If
lead
is False (we've placed at least one non-zero digit), return 1 (found one valid number) - The XOR operation
lead ^ 1
elegantly returns 0 whenlead=True
and 1 whenlead=False
4. Calculating the Upper Bound:
up = int(s[i]) if limit else 9
- If
limit
is True, we can only place digits up tos[i]
(the digit at positioni
inn
) - If
limit
is False, we can place any digit from 0 to 9
5. Trying All Valid Digits:
for j in range(up + 1):
if j == 0 and lead:
ans += dfs(i + 1, 1, limit and j == up)
elif j in nums:
ans += dfs(i + 1, 0, limit and j == up)
For each possible digit j
from 0 to up
:
-
Case 1: Leading Zero (
j == 0 and lead
):- We're still in the leading zeros phase
- Move to next position with
lead=True
- Update
limit
: remains True only if we're at the limit AND placed the maximum allowed digit
-
Case 2: Valid Digit (
j in nums
):- We can only place digits that exist in our available set
- Move to next position with
lead=False
(we've placed a non-zero digit) - Update
limit
: remains True only if we're at the limit AND placed the maximum allowed digit
6. Memoization:
The @cache
decorator automatically memoizes the results, preventing redundant calculations for the same (i, lead, limit)
state.
7. Final Answer:
return dfs(0, 1, True)
Start the recursion from position 0, with leading zeros allowed initially (lead=True
), and bounded by the limit (limit=True
).
Example Walkthrough:
For n = 100
and digits = ['1', '3']
:
- Position 0: Can place 0 (leading zero) or 1 (valid digit, at limit)
- If we place 1 at position 0:
- Position 1: Can only place 0 (since
limit=True
ands[1]='0'
) - But 0 is not in our digits, so this path ends
- Position 1: Can only place 0 (since
- If we place 0 at position 0 (leading zero):
- Continue with
lead=True
to position 1 - Can try placing 1 or 3 (both create 2-digit numbers < 100)
- Continue with
The algorithm efficiently counts all valid combinations without explicitly generating each number, achieving a time complexity of O(log n × D)
where D is the number of unique digits (at most 10).
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example with n = 35
and digits = ['1', '5']
.
We need to count all positive integers ≤ 35 that can be formed using only digits 1 and 5.
Setup:
- Convert
n = 35
to strings = "35"
- Convert digits to set:
nums = {1, 5}
- Start with
dfs(0, lead=True, limit=True)
Step-by-step recursion:
Position 0 (tens place):
- Current state:
i=0, lead=True, limit=True
- Upper bound:
up = int(s[0]) = 3
(sincelimit=True
) - Try digits 0 to 3:
j=0
: Leading zero, continue withdfs(1, True, False)
j=1
: Valid digit (1 ∈ nums), continue withdfs(1, False, False)
j=2
: Not in nums, skipj=3
: Not in nums, skip
Branch 1: Placed nothing yet (leading zero at position 0)
- Current state:
i=1, lead=True, limit=False
- Upper bound:
up = 9
(no limit anymore) - Try digits 0 to 9:
j=0
: Still leading zero, would reach base case returning 0j=1
: Valid digit, forms number "1", reaches base case returning 1j=2-4
: Not in nums, skipj=5
: Valid digit, forms number "5", reaches base case returning 1j=6-9
: Not in nums, skip
- This branch contributes: 2 numbers (1 and 5)
Branch 2: Placed '1' at position 0
- Current state:
i=1, lead=False, limit=False
- Upper bound:
up = 9
(not at limit since we placed 1 < 3) - Try digits 0 to 9:
j=0
: Not in nums, skipj=1
: Valid digit, forms "11", reaches base case returning 1j=2-4
: Not in nums, skipj=5
: Valid digit, forms "15", reaches base case returning 1j=6-9
: Not in nums, skip
- This branch contributes: 2 numbers (11 and 15)
Total count: 2 + 2 = 4
The valid numbers are: 1, 5, 11, 15
Key observations from this walkthrough:
- The
lead
parameter prevents counting empty numbers or those with only zeros - The
limit
parameter becomes False as soon as we place a digit smaller than the corresponding digit inn
, giving us freedom in subsequent positions - We only count combinations using digits from our available set
- The recursion efficiently explores all valid paths without generating actual numbers
Solution Implementation
1class Solution:
2 def atMostNGivenDigitSet(self, digits: List[str], n: int) -> int:
3 from functools import cache
4
5 # Convert n to string for digit-by-digit processing
6 n_str = str(n)
7 # Convert digit strings to a set of integers for O(1) lookup
8 available_digits = {int(digit) for digit in digits}
9
10 @cache
11 def count_valid_numbers(position: int, is_leading_zero: bool, is_bounded: bool) -> int:
12 """
13 Count valid numbers using digit DP.
14
15 Args:
16 position: Current position in the number (0-indexed from left)
17 is_leading_zero: True if we haven't placed any non-zero digit yet
18 is_bounded: True if we must stay within the upper bound of n
19
20 Returns:
21 Count of valid numbers from this state
22 """
23 # Base case: reached the end of the number
24 if position >= len(n_str):
25 # Return 1 if we've placed at least one non-zero digit (valid number formed)
26 # Return 0 if we only have leading zeros (no number formed)
27 return 0 if is_leading_zero else 1
28
29 # Determine the maximum digit we can place at this position
30 max_digit = int(n_str[position]) if is_bounded else 9
31
32 total_count = 0
33
34 # Try each possible digit from 0 to max_digit
35 for digit in range(max_digit + 1):
36 if digit == 0 and is_leading_zero:
37 # Skip this position (extend leading zeros)
38 # Stay bounded if we were bounded and placed the max digit
39 total_count += count_valid_numbers(
40 position + 1,
41 True,
42 is_bounded and (digit == max_digit)
43 )
44 elif digit in available_digits:
45 # Place this digit (it's in our allowed set)
46 # No longer have leading zeros after placing a non-zero digit
47 # Stay bounded if we were bounded and placed the max digit
48 total_count += count_valid_numbers(
49 position + 1,
50 False,
51 is_bounded and (digit == max_digit)
52 )
53
54 return total_count
55
56 # Start from position 0, with leading zeros, and bounded by n
57 return count_valid_numbers(0, True, True)
58
1class Solution {
2 // Set to store allowed digits for quick lookup
3 private Set<Integer> allowedDigits = new HashSet<>();
4 // Character array representation of the upper bound number
5 private char[] upperBoundDigits;
6 // Memoization array for dynamic programming
7 private Integer[] memo;
8
9 public int atMostNGivenDigitSet(String[] digits, int n) {
10 // Convert the upper bound number to character array for digit-by-digit processing
11 upperBoundDigits = String.valueOf(n).toCharArray();
12 // Initialize memoization array with size equal to number of digits in n
13 memo = new Integer[upperBoundDigits.length];
14
15 // Convert string digits to integers and store in set
16 for (String digit : digits) {
17 allowedDigits.add(Integer.parseInt(digit));
18 }
19
20 // Start DFS from position 0 with leading zeros and limit constraint
21 return dfs(0, true, true);
22 }
23
24 /**
25 * Dynamic programming with digit DP approach
26 * @param position - current position in the number being formed
27 * @param hasLeadingZero - whether we still have leading zeros (number hasn't started yet)
28 * @param isLimited - whether we're bounded by the upper limit (n)
29 * @return count of valid numbers that can be formed from current state
30 */
31 private int dfs(int position, boolean hasLeadingZero, boolean isLimited) {
32 // Base case: reached end of digits
33 if (position >= upperBoundDigits.length) {
34 // If still has leading zeros, no valid number was formed
35 // Otherwise, we formed exactly one valid number
36 return hasLeadingZero ? 0 : 1;
37 }
38
39 // Check memoization for non-leading, non-limited states
40 if (!hasLeadingZero && !isLimited && memo[position] != null) {
41 return memo[position];
42 }
43
44 // Determine upper bound for current digit
45 // If limited by n, use the corresponding digit from n; otherwise use 9
46 int upperLimit = isLimited ? upperBoundDigits[position] - '0' : 9;
47 int count = 0;
48
49 // Try all possible digits from 0 to upperLimit
50 for (int digit = 0; digit <= upperLimit; ++digit) {
51 if (digit == 0 && hasLeadingZero) {
52 // Skip this position (leading zero case)
53 // Continue with leading zeros and maintain limit if current digit equals upper bound
54 count += dfs(position + 1, true, isLimited && digit == upperLimit);
55 } else if (allowedDigits.contains(digit)) {
56 // Valid digit found: no more leading zeros
57 // Maintain limit only if we're at the upper bound
58 count += dfs(position + 1, false, isLimited && digit == upperLimit);
59 }
60 // If digit is not in allowedDigits and not a leading zero, skip it
61 }
62
63 // Memoize result for non-leading, non-limited states
64 if (!hasLeadingZero && !isLimited) {
65 memo[position] = count;
66 }
67
68 return count;
69 }
70}
71
1class Solution {
2public:
3 int atMostNGivenDigitSet(vector<string>& digits, int n) {
4 // Convert n to string for digit-by-digit processing
5 string targetStr = to_string(n);
6
7 // Convert digit strings to integer set for O(1) lookup
8 unordered_set<int> availableDigits;
9 for (const auto& digitStr : digits) {
10 availableDigits.insert(stoi(digitStr));
11 }
12
13 // Number of digits in n
14 int numDigits = targetStr.size();
15
16 // Memoization array for dynamic programming
17 // dp[i] stores the count of valid numbers starting from position i
18 int dp[numDigits];
19 memset(dp, -1, sizeof(dp));
20
21 // Recursive function with memoization (digit DP)
22 // Parameters:
23 // - pos: current position in the number being formed
24 // - hasLeadingZero: true if we haven't placed any non-zero digit yet
25 // - isTight: true if we're still bounded by the upper limit n
26 auto countValidNumbers = [&](this auto&& countValidNumbers,
27 int pos,
28 bool hasLeadingZero,
29 bool isTight) -> int {
30 // Base case: reached the end of number formation
31 if (pos >= numDigits) {
32 // If still has leading zero, we haven't formed any valid number
33 return hasLeadingZero ? 0 : 1;
34 }
35
36 // Check memoization (only valid when not constrained by leading zeros or tight bound)
37 if (!hasLeadingZero && !isTight && dp[pos] != -1) {
38 return dp[pos];
39 }
40
41 // Determine the upper bound for current digit
42 int upperBound = isTight ? (targetStr[pos] - '0') : 9;
43
44 int totalCount = 0;
45
46 // Try all possible digits at current position
47 for (int digit = 0; digit <= upperBound; ++digit) {
48 if (digit == 0 && hasLeadingZero) {
49 // Skip leading zeros - move to next position without placing a digit
50 totalCount += countValidNumbers(pos + 1,
51 true, // still has leading zero
52 isTight && (digit == upperBound));
53 } else if (availableDigits.count(digit)) {
54 // Place the digit if it's available in our digit set
55 totalCount += countValidNumbers(pos + 1,
56 false, // no more leading zeros
57 isTight && (digit == upperBound));
58 }
59 }
60
61 // Store result in memoization table
62 if (!hasLeadingZero && !isTight) {
63 dp[pos] = totalCount;
64 }
65
66 return totalCount;
67 };
68
69 // Start the recursive counting from position 0
70 return countValidNumbers(0, true, true);
71 }
72};
73
1/**
2 * Counts how many positive integers can be formed using digits from the given array
3 * that are less than or equal to n
4 * @param digits - Array of digit strings that can be used to form numbers
5 * @param n - The upper bound for valid numbers
6 * @returns The count of valid numbers that can be formed
7 */
8function atMostNGivenDigitSet(digits: string[], n: number): number {
9 // Convert n to string to process digit by digit
10 const nString: string = n.toString();
11 const digitCount: number = nString.length;
12
13 // Memoization array for dynamic programming
14 // Stores results for positions when there's no leading zero and no limit
15 const memo: number[] = Array(digitCount).fill(-1);
16
17 // Convert digit strings to a set of numbers for O(1) lookup
18 const availableDigits: Set<number> = new Set<number>(
19 digits.map((digit: string) => parseInt(digit))
20 );
21
22 /**
23 * Recursive function to count valid numbers using digit DP technique
24 * @param position - Current position in the number being formed (0-indexed)
25 * @param hasLeadingZero - Whether we still have leading zeros (number hasn't started yet)
26 * @param isLimited - Whether we're still bounded by the corresponding digit in n
27 * @returns Count of valid numbers from this state
28 */
29 const countValidNumbers = (
30 position: number,
31 hasLeadingZero: boolean,
32 isLimited: boolean
33 ): number => {
34 // Base case: reached the end of digit positions
35 if (position >= digitCount) {
36 // Return 1 if we've formed a valid number (no leading zero), 0 otherwise
37 return hasLeadingZero ? 0 : 1;
38 }
39
40 // Check memoization for non-leading-zero and non-limited states
41 if (!hasLeadingZero && !isLimited && memo[position] !== -1) {
42 return memo[position];
43 }
44
45 // Determine the upper bound for current digit
46 // If limited by n, use n's digit at this position; otherwise use 9
47 const upperBound: number = isLimited ? parseInt(nString[position]) : 9;
48
49 let totalCount: number = 0;
50
51 // Try each possible digit from 0 to upperBound
52 for (let currentDigit: number = 0; currentDigit <= upperBound; currentDigit++) {
53 if (currentDigit === 0 && hasLeadingZero) {
54 // Skip leading zeros - continue with leading zero state
55 totalCount += countValidNumbers(
56 position + 1,
57 true,
58 isLimited && currentDigit === upperBound
59 );
60 } else if (availableDigits.has(currentDigit)) {
61 // Use this digit if it's in our available set
62 totalCount += countValidNumbers(
63 position + 1,
64 false,
65 isLimited && currentDigit === upperBound
66 );
67 }
68 }
69
70 // Memoize result for non-leading-zero and non-limited states
71 if (!hasLeadingZero && !isLimited) {
72 memo[position] = totalCount;
73 }
74
75 return totalCount;
76 };
77
78 // Start the recursive counting from position 0 with leading zero and limited by n
79 return countValidNumbers(0, true, true);
80}
81
Time and Space Complexity
Time Complexity: O(log(n) * |digits|)
The algorithm uses digit dynamic programming (digit DP) to count valid numbers. The recursive function dfs
processes each digit position of the number n
:
- The string representation of
n
haslog₁₀(n)
digits, which determines the depth of recursion - At each position
i
, we iterate through at mostmin(10, |digits| + 1)
possibilities (considering leading zeros) - Since we're checking membership in
nums
set withO(1)
lookup, and the loop runs at most|digits| + 1
times (for leading zero case), each recursive call doesO(|digits|)
work - The
@cache
decorator memoizes results, ensuring each unique state(i, lead, limit)
is computed only once - Total unique states:
O(log(n) * 2 * 2)
=O(log(n))
- Total time:
O(log(n) * |digits|)
Space Complexity: O(log(n))
The space usage comes from:
- The string conversion
s = str(n)
takesO(log(n))
space - The set
nums
stores at most|digits|
elements, which isO(|digits|)
but typically|digits| ≤ 9
- The recursion stack depth is at most
len(s)
=O(log(n))
- The memoization cache stores at most
O(log(n) * 2 * 2)
=O(log(n))
states - Overall space complexity:
O(log(n) + |digits|)
=O(log(n))
(since|digits|
is bounded by 9)
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrectly Handling the Leading Zero State
Pitfall: A common mistake is misunderstanding when and how to handle leading zeros. Developers often incorrectly allow leading zeros to transition to non-leading state, or fail to properly skip positions when encountering leading zeros.
Incorrect Implementation:
# WRONG: This allows 0 to be treated as a valid digit when it's in the digits array if digit in available_digits: total_count += count_valid_numbers(position + 1, False, is_bounded and (digit == max_digit))
Why it's wrong: If digits = ['0', '1', '3']
and we're trying to form numbers ≤ 100, this would incorrectly count numbers like "01", "03" as valid, when they should be treated as "1" and "3" respectively.
Correct Implementation:
# Handle leading zeros separately from regular digits if digit == 0 and is_leading_zero: # Continue with leading zeros, don't place any digit total_count += count_valid_numbers(position + 1, True, is_bounded and (digit == max_digit)) elif digit in available_digits: # Only place non-zero digits or zero after a non-zero digit total_count += count_valid_numbers(position + 1, False, is_bounded and (digit == max_digit))
2. Forgetting to Update the Bounded State Correctly
Pitfall: Failing to properly update the is_bounded
parameter when making recursive calls, especially forgetting that it should only remain True
when both conditions are met: we were already bounded AND we placed the maximum allowed digit.
Incorrect Implementation:
# WRONG: Always passing the same bounded state total_count += count_valid_numbers(position + 1, False, is_bounded) # OR WRONG: Only checking if digit equals max_digit total_count += count_valid_numbers(position + 1, False, digit == max_digit)
Correct Implementation:
# Bounded remains true only if BOTH conditions are met total_count += count_valid_numbers( position + 1, False, is_bounded and (digit == max_digit) )
3. Not Handling Numbers with Fewer Digits Than n
Pitfall: The algorithm naturally handles numbers with fewer digits through the leading zero mechanism, but developers sometimes try to add special logic for this case, which can lead to double counting or missed cases.
Incorrect Approach:
# WRONG: Trying to handle shorter numbers separately
def count_valid_numbers(position, is_leading_zero, is_bounded):
if position >= len(n_str):
return 0 if is_leading_zero else 1
# Trying to add special case for shorter numbers
if not is_leading_zero:
# This creates problems with counting
total_count = 1 # Count current number as valid
Why it works correctly without special handling:
The leading zero mechanism naturally handles shorter numbers. For example, with n = 100
:
- "5" is represented as "005" with two leading zeros
- "13" is represented as "013" with one leading zero
- The algorithm correctly counts these by allowing positions to be skipped via leading zeros
4. Using the Wrong Data Structure for Digit Lookup
Pitfall: Using a list instead of a set for checking if a digit is available, leading to O(D) lookup time instead of O(1).
Inefficient Implementation:
# WRONG: Keep digits as a list
available_digits = [int(digit) for digit in digits]
# Later in the code - O(D) lookup
if digit in available_digits: # Linear search
# process
Efficient Implementation:
# CORRECT: Convert to set for O(1) lookup
available_digits = {int(digit) for digit in digits}
# O(1) lookup
if digit in available_digits:
# process
5. Incorrect Base Case Return Value
Pitfall: Returning the wrong value in the base case, particularly confusing when to return 0 vs 1.
Incorrect Implementation:
# WRONG: Always returning 1
if position >= len(n_str):
return 1
# OR WRONG: Using wrong logic
if position >= len(n_str):
return 1 if is_leading_zero else 0 # Backwards!
Correct Implementation:
if position >= len(n_str):
# Return 1 only if we've formed a valid number (not all leading zeros)
return 0 if is_leading_zero else 1
# Or equivalently: return is_leading_zero ^ 1
Key Insight: The base case should return 1 only when we've successfully formed a valid positive integer (at least one non-zero digit was placed), and 0 when we've only encountered leading zeros (no number was formed).
Problem: Given a list of tasks and a list of requirements, compute a sequence of tasks that can be performed, such that we complete every task once while satisfying all the requirements.
Which of the following method should we use to solve this problem?
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
Binary Search Speedrun For each of the Speedrun questions you will be given a binary search related problem and a corresponding multiple choice question The multiple choice questions are related to the techniques and template s introduced in the binary search section It's recommended that you have gone through at
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
Want a Structured Path to Master System Design Too? Don’t Miss This!