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"
andk = 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
.
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 fromi
- For a substring from position
i
toj
, if its value is ≤k
, we recursively solve for positionj+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
(wheren
is the length of string), we've processed the entire string, so we return0
(no more partitions needed).
Recursive Case:
For each position i
, we:
- Initialize
res = inf
to track the minimum partitions andv = 0
to build the current substring's value - Try extending the substring by iterating
j
fromi
ton-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 exceedk
- Otherwise, we recursively solve for the remaining string:
dfs(j + 1)
- Update our minimum:
res = min(res, dfs(j + 1))
- Update the value:
- 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 EvaluatorExample 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 = 1dfs(1)
from "6" → "5" path = 1 +dfs(2)
= 1 + 1 = 2dfs(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
- Path 1: "1" → remaining string = 1 +
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 positioni
from0
ton-1
, giving usn
possible states - For each state
i
, the inner loop iterates from positioni
to potentially positionn-1
in the worst case, which takesO(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 becomesO(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.
Which data structure is used in a depth first search?
Recommended Readings
Greedy Introduction div class responsive iframe iframe src https www youtube com embed WTslqPbj7I title YouTube video player frameborder 0 allow accelerometer autoplay clipboard write encrypted media gyroscope picture in picture web share allowfullscreen iframe div When do we use greedy Greedy algorithms tend to solve optimization problems Typically they will ask you to calculate the max min of some value Commonly you may see this phrased in the problem as max min longest shortest largest smallest etc These keywords can be identified by just scanning
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!