Facebook Pixel

2522. Partition String Into Substrings With Values at Most K

Problem Description

You are given a string s that contains only digits from 1 to 9, and an integer k.

Your task is to partition the string s into substrings such that:

  • Every digit in s belongs to exactly one substring (no overlapping, no digits left out)
  • The numerical value of each substring must be less than or equal to k

A partition that satisfies these conditions is called a "good" partition.

You need to find the minimum number of substrings required to create a good partition. If it's impossible to create a good partition, return -1.

For example:

  • If s = "123" and k = 50, you could partition it as ["1", "23"] (values: 1 and 23, both ≤ 50) using 2 substrings
  • The value of a substring is its interpretation as an integer (e.g., "23" has value 23)
  • A substring is a contiguous sequence of characters from the original string

The challenge is to find the optimal way to split the string into the fewest possible valid substrings, where each substring's numerical value doesn't exceed k.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we look at this problem, we need to find the minimum number of substrings to partition the string. This immediately suggests a dynamic programming or recursive approach where we try different ways to split the string and choose the best one.

The key insight is that at each position in the string, we have choices: we can take just the current digit as a substring, or we can try to extend it by including more digits, as long as the resulting value doesn't exceed k.

Think of it like this: standing at position i in the string, we ask ourselves - "What are all the valid substrings I can take starting from here?" For each valid substring (one whose value is ≤ k), we would then need to solve the same problem for the remaining part of the string.

This naturally leads to a recursive solution where:

  • At each position i, we try all possible valid substrings starting from i
  • For a substring from position i to j, if its value is ≤ k, we recursively solve for position j+1
  • We want to minimize the total number of partitions, so we take the minimum among all valid choices

The recursive nature of the problem becomes clear: minimum partitions from position i = 1 + minimum partitions from position j+1, where we try all valid j values and pick the best one.

Since we might revisit the same positions multiple times through different recursive paths, memoization helps us avoid redundant calculations by storing the results for each position we've already computed.

If at any position we can't form any valid substring (like when even a single digit exceeds k), the problem has no solution, which we handle by returning infinity during recursion and ultimately returning -1.

Learn more about Greedy and Dynamic Programming patterns.

Solution Approach

The solution implements a memoization search (top-down dynamic programming) approach using recursion with caching.

Implementation Details:

We define a recursive function dfs(i) that calculates the minimum number of partitions needed starting from index i of the string s.

Base Case:

  • When i >= n (where n is the length of string), we've processed the entire string, so we return 0 (no more partitions needed).

Recursive Case: For each position i, we:

  1. Initialize res = inf to track the minimum partitions and v = 0 to build the current substring's value
  2. Try extending the substring by iterating j from i to n-1:
    • Update the value: v = v * 10 + int(s[j]) (this builds the integer value digit by digit)
    • If v > k, we break immediately as any further extension would also exceed k
    • Otherwise, we recursively solve for the remaining string: dfs(j + 1)
    • Update our minimum: res = min(res, dfs(j + 1))
  3. Return res + 1 (adding 1 for the current partition)

Memoization: The @cache decorator is used to memorize previously computed results for each index i, preventing redundant calculations when the same subproblem is encountered again.

Final Answer:

  • Call dfs(0) to get the minimum partitions starting from index 0
  • If the result is inf, it means no valid partition exists, so return -1
  • Otherwise, return the computed minimum number of partitions

Time Complexity: O(n²) in the worst case, where we might check all possible substrings Space Complexity: O(n) for the recursion stack and memoization cache

The algorithm efficiently explores all valid partitioning possibilities while avoiding repeated work through memoization, ensuring we find the optimal solution.

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 the solution with s = "165" and k = 42.

We start at index 0 with dfs(0):

Step 1: From index 0

  • Try substring "1" (value = 1, ≤ 42): Valid! Recursively call dfs(1)
  • Try substring "16" (value = 16, ≤ 42): Valid! Recursively call dfs(2)
  • Try substring "165" (value = 165, > 42): Invalid! Stop extending

Step 2: Exploring dfs(1) (from "1" partition)

  • Try substring "6" (value = 6, ≤ 42): Valid! Recursively call dfs(2)
  • Try substring "65" (value = 65, > 42): Invalid! Stop extending

Step 3: Exploring dfs(2) (from multiple paths)

  • From "1" → "6" path: Try substring "5" (value = 5, ≤ 42): Valid! Call dfs(3)
  • From "16" path: Try substring "5" (value = 5, ≤ 42): Valid! Call dfs(3)

Step 4: Base case dfs(3)

  • Index 3 ≥ length(3), return 0 (end of string)

Backtracking with results:

  • dfs(2) = 1 + dfs(3) = 1 + 0 = 1
  • dfs(1) from "6" → "5" path = 1 + dfs(2) = 1 + 1 = 2
  • dfs(1) from "65" path fails (value > k)
  • dfs(0) has two valid options:
    • Path 1: "1" → remaining string = 1 + dfs(1) = 1 + 2 = 3
    • Path 2: "16" → remaining string = 1 + dfs(2) = 1 + 1 = 2

The minimum is 2, achieved by partitioning as ["16", "5"].

The memoization ensures that when we compute dfs(2) once, we reuse that result (value = 1) for all future calls to dfs(2), avoiding redundant calculations. This optimization is crucial as the recursion tree can have overlapping subproblems.

Solution Implementation

1class Solution:
2    def minimumPartition(self, s: str, k: int) -> int:
3        from functools import cache
4      
5        @cache
6        def dfs(start_index: int) -> int:
7            """
8            Recursively find minimum partitions starting from given index.
9          
10            Args:
11                start_index: Current position in string to start partitioning from
12          
13            Returns:
14                Minimum number of partitions needed from this position
15            """
16            # Base case: reached end of string
17            if start_index >= string_length:
18                return 0
19          
20            # Initialize minimum partitions to infinity and current number value
21            min_partitions = float('inf')
22            current_value = 0
23          
24            # Try all possible partition endpoints from current position
25            for end_index in range(start_index, string_length):
26                # Build the number by adding next digit
27                current_value = current_value * 10 + int(s[end_index])
28              
29                # If current value exceeds k, no point continuing further
30                if current_value > k:
31                    break
32              
33                # Recursively find minimum partitions for remaining string
34                min_partitions = min(min_partitions, dfs(end_index + 1))
35          
36            # Add 1 for current partition
37            return min_partitions + 1
38      
39        # Store string length for efficiency
40        string_length = len(s)
41      
42        # Calculate minimum partitions starting from index 0
43        result = dfs(0)
44      
45        # Return -1 if no valid partition exists, otherwise return result
46        return result if result < float('inf') else -1
47
1class Solution {
2    // Memoization array to store computed results for each index
3    private Integer[] memo;
4    // Length of the input string
5    private int stringLength;
6    // Input string to be partitioned
7    private String inputString;
8    // Maximum allowed value for each partition
9    private int maxValue;
10    // Constant representing infinity (impossible case)
11    private static final int INFINITY = 1 << 30;
12
13    /**
14     * Finds the minimum number of partitions needed to split the string
15     * such that each partition's numeric value is at most k.
16     * 
17     * @param s The input string containing digits
18     * @param k The maximum allowed value for each partition
19     * @return The minimum number of partitions, or -1 if impossible
20     */
21    public int minimumPartition(String s, int k) {
22        // Initialize instance variables
23        stringLength = s.length();
24        memo = new Integer[stringLength];
25        this.inputString = s;
26        this.maxValue = k;
27      
28        // Start DFS from index 0
29        int result = dfs(0);
30      
31        // Return -1 if no valid partition exists, otherwise return the result
32        return result < INFINITY ? result : -1;
33    }
34
35    /**
36     * Recursive DFS function with memoization to find minimum partitions
37     * starting from index i.
38     * 
39     * @param startIndex The starting index for current partition
40     * @return Minimum number of partitions needed from startIndex to end
41     */
42    private int dfs(int startIndex) {
43        // Base case: reached end of string, no more partitions needed
44        if (startIndex >= stringLength) {
45            return 0;
46        }
47      
48        // Return memoized result if already computed
49        if (memo[startIndex] != null) {
50            return memo[startIndex];
51        }
52      
53        // Initialize result to infinity (worst case)
54        int minPartitions = INFINITY;
55        // Current partition value as we extend the substring
56        long currentValue = 0;
57      
58        // Try all possible partition endings starting from startIndex
59        for (int endIndex = startIndex; endIndex < stringLength; ++endIndex) {
60            // Build the current partition value digit by digit
61            currentValue = currentValue * 10 + (inputString.charAt(endIndex) - '0');
62          
63            // If current value exceeds maximum, no point continuing
64            if (currentValue > maxValue) {
65                break;
66            }
67          
68            // Recursively find minimum partitions for remaining string
69            // and update the minimum result
70            minPartitions = Math.min(minPartitions, dfs(endIndex + 1));
71        }
72      
73        // Store and return the result (add 1 for current partition)
74        return memo[startIndex] = minPartitions + 1;
75    }
76}
77
1class Solution {
2public:
3    int minimumPartition(string s, int k) {
4        int n = s.size();
5      
6        // dp[i] stores the minimum number of partitions needed starting from index i
7        // 0 means not computed yet
8        int dp[n];
9        memset(dp, 0, sizeof(dp));
10      
11        const int INF = 1 << 30;  // Large value representing impossible case
12      
13        // Recursive function with memoization to find minimum partitions
14        // Returns minimum number of partitions needed from index i to end
15        function<int(int)> dfs = [&](int i) -> int {
16            // Base case: reached end of string
17            if (i >= n) {
18                return 0;
19            }
20          
21            // Return memoized result if already computed
22            if (dp[i] != 0) {
23                return dp[i];
24            }
25          
26            int minPartitions = INF;
27            long currentValue = 0;
28          
29            // Try all possible partitions starting from index i
30            for (int j = i; j < n; ++j) {
31                // Build the number by appending digits
32                currentValue = currentValue * 10 + (s[j] - '0');
33              
34                // If current value exceeds k, no need to continue
35                if (currentValue > k) {
36                    break;
37                }
38              
39                // Recursively compute minimum partitions for remaining string
40                minPartitions = min(minPartitions, dfs(j + 1));
41            }
42          
43            // Store result in dp array (add 1 for current partition)
44            dp[i] = minPartitions + 1;
45            return dp[i];
46        };
47      
48        // Get the minimum number of partitions starting from index 0
49        int result = dfs(0);
50      
51        // Return -1 if impossible, otherwise return the result
52        return result < INF ? result : -1;
53    }
54};
55
1function minimumPartition(s: string, k: number): number {
2    const n: number = s.length;
3  
4    // dp[i] stores the minimum number of partitions needed starting from index i
5    // -1 means not computed yet
6    const dp: number[] = new Array(n).fill(-1);
7  
8    const INF: number = 1 << 30;  // Large value representing impossible case
9  
10    /**
11     * Recursive function with memoization to find minimum partitions
12     * Returns minimum number of partitions needed from index i to end
13     * @param i - Starting index in the string
14     * @returns Minimum number of partitions from index i to end
15     */
16    const dfs = (i: number): number => {
17        // Base case: reached end of string
18        if (i >= n) {
19            return 0;
20        }
21      
22        // Return memoized result if already computed
23        if (dp[i] !== -1) {
24            return dp[i];
25        }
26      
27        let minPartitions: number = INF;
28        let currentValue: number = 0;
29      
30        // Try all possible partitions starting from index i
31        for (let j = i; j < n; j++) {
32            // Build the number by appending digits
33            currentValue = currentValue * 10 + parseInt(s[j]);
34          
35            // If current value exceeds k, no need to continue
36            if (currentValue > k) {
37                break;
38            }
39          
40            // Recursively compute minimum partitions for remaining string
41            minPartitions = Math.min(minPartitions, dfs(j + 1));
42        }
43      
44        // Store result in dp array (add 1 for current partition)
45        dp[i] = minPartitions + 1;
46        return dp[i];
47    };
48  
49    // Get the minimum number of partitions starting from index 0
50    const result: number = dfs(0);
51  
52    // Return -1 if impossible, otherwise return the result
53    return result < INF ? result : -1;
54}
55

Time and Space Complexity

The time complexity is O(n^2) in the worst case, where n is the length of string s. This occurs because:

  • The recursive function dfs(i) can be called for each position i from 0 to n-1, giving us n possible states
  • For each state i, the inner loop iterates from position i to potentially position n-1 in the worst case, which takes O(n) time
  • Due to memoization with @cache, each state is computed only once
  • Therefore, the total time complexity is O(n) * O(n) = O(n^2)

However, the reference answer states O(n). This could be achievable if the value constraint k limits how far the inner loop can extend. If k has at most d digits (where d is constant), then from any position i, we can only extend the substring by at most d positions before v > k. In this case:

  • Each state still computes in O(d) = O(1) time
  • With n states and constant work per state, the time complexity becomes O(n)

The space complexity is O(n):

  • The memoization cache stores at most n states (one for each starting position)
  • The recursion depth can go up to n in the worst case when each partition contains only one character
  • Therefore, the space complexity is O(n) for both the cache and the recursion stack

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Integer Overflow When Building Substring Values

One critical pitfall in this solution is the potential for integer overflow when building the value of a substring. As we iterate through the string and build current_value = current_value * 10 + int(s[end_index]), this value can grow very large, especially with long substrings.

Why it's a problem:

  • In languages with fixed integer sizes, this could cause overflow
  • Even in Python (which handles arbitrary precision), comparing extremely large numbers with k becomes inefficient
  • The algorithm might waste time building huge numbers that will obviously exceed k

Solution: Add an early termination check before updating the value:

# Check if adding the next digit would definitely exceed k
next_digit = int(s[end_index])
if current_value > (k - next_digit) // 10:
    break
current_value = current_value * 10 + next_digit

2. Not Handling Single Digit Greater Than k

Another pitfall is not immediately checking if any single digit in the string is already greater than k. If k = 5 and the string contains '9', it's impossible to create a valid partition.

Why it's a problem:

  • The algorithm will unnecessarily explore all possibilities before returning infinity
  • This wastes computational resources on an impossible case

Solution: Add a preprocessing check:

def minimumPartition(self, s: str, k: int) -> int:
    # Early check: if any single digit > k, impossible to partition
    if any(int(digit) > k for digit in s):
        return -1
  
    # Continue with the rest of the implementation...

3. Inefficient String to Integer Conversion

The current implementation converts each character to an integer repeatedly using int(s[end_index]). While not a major issue, it can be optimized.

Why it's a problem:

  • Repeated string-to-integer conversions add overhead
  • Each digit is converted multiple times across different recursive calls

Solution: Pre-convert all digits once:

def minimumPartition(self, s: str, k: int) -> int:
    # Convert string digits to integers once
    digits = [int(ch) for ch in s]
  
    @cache
    def dfs(start_index: int) -> int:
        # ... rest of the code
        for end_index in range(start_index, string_length):
            current_value = current_value * 10 + digits[end_index]
            # ... rest of the loop

4. Cache Memory Issues with Very Long Strings

The @cache decorator creates an unbounded cache that stores results for every unique input. For very long strings, this could consume significant memory.

Why it's a problem:

  • Memory usage grows linearly with string length
  • In memory-constrained environments, this could cause issues

Solution: Use lru_cache with a maximum size or implement manual memoization with controlled memory usage:

from functools import lru_cache

@lru_cache(maxsize=1000)  # Limit cache size
def dfs(start_index: int) -> int:
    # ... implementation

These optimizations make the solution more robust and efficient, especially for edge cases and large inputs.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which data structure is used in a depth first search?


Recommended Readings

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

Load More