{x}
blog image

Numbers At Most N Given Digit Set

Given an array of digits which is sorted in non-decreasing order. You can write numbers using each digits[i] as many times as we want. For example, if digits = ['1','3','5'], we may write numbers such as '13', '551', and '1351315'.

Return the number of positive integers that can be generated that are less than or equal to a given integer n.

 

Example 1:

Input: digits = ["1","3","5","7"], n = 100
Output: 20
Explanation: 
The 20 numbers that can be written are:
1, 3, 5, 7, 11, 13, 15, 17, 31, 33, 35, 37, 51, 53, 55, 57, 71, 73, 75, 77.

Example 2:

Input: digits = ["1","4","9"], n = 1000000000
Output: 29523
Explanation: 
We can write 3 one digit numbers, 9 two digit numbers, 27 three digit numbers,
81 four digit numbers, 243 five digit numbers, 729 six digit numbers,
2187 seven digit numbers, 6561 eight digit numbers, and 19683 nine digit numbers.
In total, this is 29523 integers that can be written using the digits array.

Example 3:

Input: digits = ["7"], n = 8
Output: 1

 

Constraints:

  • 1 <= digits.length <= 9
  • digits[i].length == 1
  • digits[i] is a digit from '1' to '9'.
  • All the values in digits are unique.
  • digits is sorted in non-decreasing order.
  • 1 <= n <= 109

Solution Explanation: Digit DP

This problem asks to count positive integers less than or equal to n that can be formed using digits from a given set. A brute-force approach would be computationally expensive for large n. The optimal solution utilizes Digit Dynamic Programming (DP).

Core Idea:

The Digit DP approach breaks down the problem into smaller subproblems based on the digits of n. Instead of generating all possible numbers, it recursively counts the possibilities digit by digit. Memoization (caching results of subproblems) significantly improves efficiency.

Recursive Function (dfs)

The heart of the solution is a recursive function dfs(i, lead, limit):

  • i: The current digit position (starting from the most significant digit).
  • lead: A boolean flag indicating whether we're still processing leading zeros. Numbers with leading zeros are not counted.
  • limit: A boolean flag indicating whether the current digit is constrained by the corresponding digit in n.

Base Case:

If i exceeds the number of digits in n, it means we've processed all digits. If lead is true (we had leading zeros), we return 0; otherwise, we return 1 (a valid number was formed).

Recursive Step:

  1. Determine Upper Bound (up): If limit is true, the maximum value for the current digit is the corresponding digit in n; otherwise, it's 9.

  2. Iterate Through Digits: The code iterates through possible digits (j) from 0 to up.

  3. Handle Leading Zeros: If j is 0 and lead is true, we recursively call dfs with lead remaining true to continue processing potential leading zeros.

  4. Check Digit Validity: If j is in the allowed digits set, we recursively call dfs with lead set to false (since we've passed the leading zeros stage). limit is updated to reflect whether the next digit is constrained by n.

  5. Accumulate Results: The results of recursive calls are accumulated in ans.

  6. Memoization: The @cache decorator (Python) or equivalent mechanisms (Java, C++, Go, TypeScript) store the results of dfs calls, avoiding redundant computations.

Main Function:

  1. Convert n to a string s.
  2. Create a set nums containing the allowed digits (for efficient lookup).
  3. Call dfs(0, True, True) to start the recursive process from the most significant digit, with lead and limit initially set to True.

Time and Space Complexity:

  • Time Complexity: O(log₁₀ n * D), where D is the size of the digits set. The logarithm reflects the number of digits in n, and the process iterates through allowed digits at each position. Memoization drastically reduces the time complexity, avoiding exponential growth.

  • Space Complexity: O(log₁₀ n) due to the recursive call stack and the memoization table (which is proportional to the number of digits in n).

Example Code (Python):

class Solution:
    def atMostNGivenDigitSet(self, digits: List[str], n: int) -> int:
        @cache
        def dfs(i: int, lead: int, limit: bool) -> int:
            # ... (rest of the code as described above) ...

The code in other languages follows the same logic, with variations in syntax and memoization implementation. The key is the efficient recursive approach guided by the lead and limit flags, enhanced by memoization to handle overlapping subproblems.