Select K Disjoint Special Substrings
MedPrompt
Given a string s of length n and an integer k, determine whether it is possible to select k disjoint special substrings.
A special substring is a substring where:
- Any character present inside the substring should not appear outside it in the string.
- The substring is not the entire string
s.
Note that all k substrings must be disjoint, meaning they cannot overlap.
Return true if it is possible to select k such disjoint special substrings; otherwise, return false.
Example 1:
Input: s = "abcdbaefab", k = 2
Output: true
Explanation:
- We can select two disjoint special substrings:
"cd"and"ef". "cd"contains the characters'c'and'd', which do not appear elsewhere ins."ef"contains the characters'e'and'f', which do not appear elsewhere ins.
Example 2:
Input: s = "cdefdc", k = 3
Output: false
Explanation:
There can be at most 2 disjoint special substrings: "e" and "f". Since k = 3, the output is false.
Example 3:
Input: s = "abeabe", k = 0
Output: true
Constraints:
2 <= n == s.length <= 5 * 1040 <= k <= 26sconsists only of lowercase English letters.
Approaches
2 approaches with complexity analysis and trade-offs.
A standard approach for problems asking for a maximum number of objects satisfying certain properties is dynamic programming. We can define a DP state based on the prefixes of the string.
Algorithm
Let dp[i] be the maximum number of disjoint special substrings in the prefix s[0...i-1].
Precompute the first and last occurrence index for each character in s.
Initialize dp array of size n+1 to all zeros.
Iterate i from 1 to n to compute dp[i]:
Initialize dp[i] = dp[i-1] (option to not end a substring at i-1).
Iterate j from i-1 down to 0 (potential start of a substring s[j...i-1]):
Keep track of min_first and max_last for characters in s[j...i-1].
If min_first >= j and max_last < i, then s[j...i-1] is a special substring.
If it is special (and not the whole string), update dp[i] = max(dp[i], (j > 0 ? dp[j] : 0) + 1).
After the loops, dp[n] holds the maximum number of disjoint special substrings.
Return dp[n] >= k.
Walkthrough
Let dp[i] be the maximum number of disjoint special substrings that can be formed entirely within the prefix s[0...i-1].
Our goal is to compute dp[n] and check if it's greater than or equal to k.
The base case is dp[0] = 0, as no substrings can be formed from an empty prefix.
For the transition, to compute dp[i], we have two choices:
- We don't form a special substring ending at index
i-1. In this case, the maximum number of substrings is the same as for the prefixs[0...i-2]. So,dp[i] = dp[i-1]. - We form a special substring ending at index
i-1. Let this substring bes[j...i-1]for some0 <= j < i. Ifs[j...i-1]is a valid special substring, we can potentially form one more substring than the maximum we could form in the prefixs[0...j-1]. This would givedp[j] + 1substrings. We should try this for all possible start indicesj.
Combining these, the recurrence relation is:
dp[i] = max(dp[i-1], max(dp[j] + 1)) for all j such that s[j...i-1] is a special substring.
To implement this, we first need an efficient way to check if a substring s[j...i-1] is special. A substring is special if every character within it does not appear outside of it. This means for every character c in s[j...i-1], its first and last occurrences in the original string s must be within the bounds [j, i-1].
We can precompute the first and last occurrences of all characters in s in O(n) time. Let's call them first[c] and last[c].
The check for s[j...i-1] being special becomes:
min(first[s[p]]) >= jforpfromjtoi-1.max(last[s[p]]) <= i-1forpfromjtoi-1.- The substring is not the entire string
s.
The overall algorithm is:
- Precompute
firstandlastarrays for all 26 lowercase letters. O(n). - Initialize a
dparray of sizen+1with zeros. - Iterate
ifrom 1 ton: a. Setdp[i] = dp[i-1]. b. Iteratejfromi-1down to0: i. Check ifs[j...i-1]is a special substring. This check involves iterating fromjtoi-1to find the minimum offirstoccurrences and maximum oflastoccurrences for the characters in the substring. This can be optimized by updating these min/max values asjdecreases. ii. Ifs[j...i-1]is special (and not the whole string), updatedp[i] = max(dp[i], dp[j] + 1). - Finally, return
dp[n] >= k.
class Solution { public boolean canSelect(String s, int k) { if (k == 0) { return true; } int n = s.length(); int[] first = new int[26]; int[] last = new int[26]; java.util.Arrays.fill(first, -1); java.util.Arrays.fill(last, -1); for (int i = 0; i < n; i++) { int charIndex = s.charAt(i) - 'a'; if (first[charIndex] == -1) { first[charIndex] = i; } last[charIndex] = i; } int[] dp = new int[n + 1]; for (int i = 1; i <= n; i++) { dp[i] = dp[i - 1]; int minFirst = n; int maxLast = -1; for (int j = i - 1; j >= 0; j--) { int charIndex = s.charAt(j) - 'a'; minFirst = Math.min(minFirst, first[charIndex]); maxLast = Math.max(maxLast, last[charIndex]); if (minFirst >= j && maxLast < i) { if (j == 0 && i == n) continue; // Not the entire string dp[i] = Math.max(dp[i], (j > 0 ? dp[j] : 0) + 1); } } } return dp[n] >= k; }}Complexity
Time
O(n^2), where n is the length of the string. The nested loops for the DP calculation dominate the complexity. The outer loop runs `n` times, and the inner loop runs up to `n` times.
Space
O(n) for the `dp` array. The `first` and `last` arrays take O(1) space as the alphabet size is constant (26).
Trade-offs
Pros
Correctly solves the problem for all cases.
Conceptually straightforward application of dynamic programming.
Cons
The time complexity is too high for the given constraints.
The O(n^2) DP approach is too slow. We can observe that the problem has a decomposable structure. We can partition the string into independent blocks and solve the problem for each block recursively. This avoids redundant computations on parts of the string that are independent of each other, leading to a better average-case time complexity.
Algorithm
This approach is a recursive (or memoized) version of the DP. Let solve(i) be the max number of disjoint special substrings in s[i...n-1].
The base case is solve(n) = 0.
To compute solve(i):
One option is to not start a special substring at i. The result is solve(i+1).
Another option is to find a special substring s[i...j]. If one is found, the result is 1 + solve(j+1).
We iterate j from i to n-1 to find all possible special substrings s[i...j].
A substring s[i...j] is special if min_first(i,j) >= i and max_last(i,j) <= j.
We take the maximum over all these possibilities.
solve(i) = max(solve(i+1), max_{j | s[i..j] is special} (1 + solve(j+1))).
Memoization is used to store the results of solve(i) to avoid recomputing, effectively making it a DP.
The final answer for the whole string is solve(0).
Walkthrough
A key observation is how special substrings are constrained by the last occurrences of their characters. Let's define a "suffix-closed" block. A substring s[i...j] is suffix-closed if for any character c in s[i...j], its last occurrence in the whole string s is at most j. This is equivalent to max(last[s[p]]) for p from i to j being equal to j.
The string s can be uniquely partitioned into a sequence of disjoint suffix-closed blocks. For example, if s = "abacaca", last['a']=6, last['b']=2, last['c']=5. The partitions are s[0..2] (aba) and s[3..6] (caca). Any special substring must be entirely contained within one of these blocks.
This suggests a divide-and-conquer approach:
- Partition the string
sinto its suffix-closed blocks. - The total number of special substrings is the sum of the maximum number of special substrings found in each block.
- For each block, we can recursively apply the same logic. A block is solved by partitioning it into its own suffix-closed sub-blocks (based on
firstandlastoccurrences re-calculated for the block's substring).
When a block cannot be partitioned further, it's an "irreducible" block. For such an irreducible block, we must resort to the O(m^2) DP approach, where m is the length of the block.
The overall algorithm:
- Precompute
firstandlastarrays for the original strings. - Implement a function, say
solve(start, end), that computes the max number of disjoint special substrings withins[start...end]. solve(start, end): a. Partitions[start...end]into suffix-closed blocks. This is done by iterating fromstarttoend, keeping track of the maximumlastoccurrence seen so far. A block[block_start, i]is found when this maximum equalsi. b. If the partition results in multiple blocks, the total is the sum ofsolve()called on each block. c. Ifs[start...end]is itself an irreducible block, we must check if this block itself is a special substring with respect to the original strings. This is true ifmin(first[s[p]]) >= startforpfromstarttoend. If it is, we can either take this whole block as 1 special substring and add the result ofsolve()on its interior (by breaking its irreducibility, a complex step), or more simply, we can just run the O(m^2) DP on this block to find the max number of special substrings strictly inside it. A simpler implementation is to always run the DP on irreducible blocks.
This approach is faster on average because for many inputs, the string decomposes into small blocks, and sum(m_i^2) is much smaller than n^2. The worst-case is still O(n^2) if the string is one large irreducible block.
class Solution { int[] first; int[] last; String s; int n; int[] memo; public boolean canSelect(String s, int k) { if (k == 0) { return true; } this.s = s; this.n = s.length(); this.first = new int[26]; this.last = new int[26]; java.util.Arrays.fill(first, -1); java.util.Arrays.fill(last, -1); for (int i = 0; i < n; i++) { int charIndex = s.charAt(i) - 'a'; if (first[charIndex] == -1) { first[charIndex] = i; } last[charIndex] = i; } this.memo = new int[n + 1]; java.util.Arrays.fill(memo, -1); return solve(0) >= k; } private int solve(int start) { if (start >= n) { return 0; } if (memo[start] != -1) { return memo[start]; } // Option 1: Skip s.charAt(start) and solve for the rest int res = solve(start + 1); // Option 2: Try to form a special substring starting at `start` int minFirst = n; int maxLast = -1; for (int end = start; end < n; end++) { int charIndex = s.charAt(end) - 'a'; minFirst = Math.min(minFirst, first[charIndex]); maxLast = Math.max(maxLast, last[charIndex]); if (minFirst >= start && maxLast == end) { if (start == 0 && end == n - 1) { // This is the whole string, not allowed as a single special substring. // We must find substrings inside it. // A full DP on the inside is complex. A simpler interpretation is to just not count it. } else { res = Math.max(res, 1 + solve(end + 1)); } } } return memo[start] = res; }}The provided code snippet is a memoized recursion which is equivalent to the DP but explores the state space differently. It tries to find a special substring starting at start and if it finds one s[start...end], it adds 1 and recurses on solve(end+1). This is another way to frame the DP, which can be more intuitive. Its complexity remains O(n^2) in the worst case but performs better if special substrings are found early, pruning the search space.
Complexity
Time
O(n^2) in the worst case. For each state `solve(i)`, the loop to find an endpoint `j` can run up to `n-i` times. With `n` states, this gives O(n^2). However, on average, it can be much faster.
Space
O(n) for the memoization table and recursion stack depth.
Trade-offs
Pros
More efficient on average than the naive DP, especially on strings that decompose into many small independent blocks.
The recursive formulation with memoization can be more intuitive to some.
Correctly solves the problem.
Cons
Worst-case time complexity is still O(n^2), which might be too slow if the string forms one large irreducible block.
Video walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.