Find Maximum Number of Non Intersecting Substrings
MedPrompt
You are given a string word.
Return the maximum number of non-intersecting substrings of word that are at least four characters long and start and end with the same letter.
Example 1:
Input: word = "abcdeafdef"
Output: 2
Explanation:
The two substrings are "abcdea" and "fdef".
Example 2:
Input: word = "bcdaaaab"
Output: 1
Explanation:
The only substring is "aaaa". Note that we cannot also choose "bcdaaaab" since it intersects with the other substring.
Constraints:
1 <= word.length <= 2 * 105wordconsists only of lowercase English letters.
Approaches
4 approaches with complexity analysis and trade-offs.
This approach uses recursion to explore all possible combinations of valid non-intersecting substrings. It tries every possibility at each character: either skip it or form a valid substring starting with it. This exhaustive search ensures correctness but at a very high computational cost.
Algorithm
- Define a recursive function, say
solve(startIndex), that computes the maximum number of valid non-intersecting substrings in the suffix of the string starting fromstartIndex. - The base case for the recursion is when
startIndexis greater than or equal to the string length, in which case we return 0 as no more substrings can be formed. - For a given
startIndex, there are two possibilities:- We do not start a substring at
startIndex. In this case, the result issolve(startIndex + 1). - We try to form a valid substring starting at
startIndex. We iterate through all possible end indicesjfromstartIndex + 3ton-1. Ifword[startIndex] == word[j], we have found a valid substring. The number of substrings for this choice is1 + solve(j + 1).
- We do not start a substring at
- The function
solve(startIndex)returns the maximum value among all these possibilities. - The initial call to start the process is
solve(0).
Walkthrough
A recursive function solve(startIndex) is designed to calculate the maximum number of valid substrings from the suffix word[startIndex...]. The function considers two main choices:
- Skip: We don't start a substring at
startIndex. The problem reduces to finding the maximum substrings in the rest of the string, which is given by a recursive callsolve(startIndex + 1). - Include: We try to form a valid substring starting at
startIndex. A substring is valid if it's at least 4 characters long and starts and ends with the same letter. We iterate through all possible end indicesjfromstartIndex + 3onwards. Ifword.charAt(startIndex) == word.charAt(j), we have a valid substring[startIndex, j]. We can take this substring and then recursively find the maximum number of substrings in the remaining part of the string, which starts fromj + 1. This choice gives a total of1 + solve(j + 1)substrings.
The function takes the maximum over all these possibilities. Since the same subproblems (e.g., solve(k) for some k) are solved multiple times, the algorithm has an exponential time complexity.
public int solve(String word, int startIndex) { if (startIndex >= word.length()) { return 0; } // Option 1: Don't start a substring at startIndex int maxCount = solve(word, startIndex + 1); // Option 2: Try to form a valid substring starting at startIndex for (int j = startIndex + 3; j < word.length(); j++) { if (word.charAt(startIndex) == word.charAt(j)) { maxCount = Math.max(maxCount, 1 + solve(word, j + 1)); } } return maxCount;}Complexity
Time
O(2^n), where n is the length of the string. In the worst case, the recursive calls branch out, leading to an exponential number of states to explore.
Space
O(n), where n is the length of the string. This is for the recursion stack depth in the worst case.
Trade-offs
Pros
Conceptually simple and directly follows the problem definition.
Cons
Extremely inefficient due to a large number of redundant computations.
Will result in a 'Time Limit Exceeded' error for the given constraints.
This approach models the problem as a classic interval scheduling problem. First, we generate all possible valid substrings and represent each as an interval [start, end]. Then, we apply a standard greedy algorithm to find the maximum number of non-overlapping intervals from this set.
Algorithm
- Step 1: Generate All Valid Intervals
- Create an empty list to store intervals.
- Use nested loops to iterate through all possible pairs of indices
(i, j)with0 <= i < nandi + 3 <= j < n. - If
word.charAt(i) == word.charAt(j), a valid substringword[i...j]is found. Add the interval[i, j]to the list.
- Step 2: Sort Intervals
- Sort the list of generated intervals based on their end points in ascending order.
- Step 3: Greedy Selection
- Initialize
count = 0andlastEnd = -1. - Iterate through the sorted intervals
[start, end]. - If
start > lastEnd, the current interval does not overlap with the previously selected one. Select it by incrementingcountand updatinglastEnd = end.
- Initialize
- Step 4: Return Result
- Return
count.
- Return
Walkthrough
The core of this method is to transform the problem into a well-known one: finding the maximum number of non-overlapping intervals.
-
Interval Generation: We exhaustively search for all valid substrings. A substring from index
itojis valid if it starts and ends with the same character (word[i] == word[j]) and has a length of at least 4 (j - i + 1 >= 4). Each such valid substring is stored as an interval[i, j]. This can result in up to O(n^2) intervals. -
Greedy Algorithm (Activity Selection): To find the maximum number of non-overlapping intervals, a greedy strategy is optimal. The strategy is to always pick the interval that finishes first. By sorting all intervals by their end points, we can iterate through them and pick an interval if it doesn't overlap with the last one we picked. An interval
[start, end]doesn't overlap with the last picked interval (which ended atlastEnd) ifstart > lastEnd.
import java.util.ArrayList;import java.util.Collections;import java.util.Comparator;import java.util.List; class Solution { public int maxNumOfSubstrings(String word) { int n = word.length(); List<int[]> intervals = new ArrayList<>(); // 1. Generate all valid intervals for (int i = 0; i < n; i++) { for (int j = i + 3; j < n; j++) { if (word.charAt(i) == word.charAt(j)) { intervals.add(new int[]{i, j}); } } } if (intervals.isEmpty()) { return 0; } // 2. Sort intervals by end point intervals.sort(Comparator.comparingInt(a -> a[1])); // 3. Greedy selection int count = 1; int lastEnd = intervals.get(0)[1]; for (int i = 1; i < intervals.size(); i++) { int[] currentInterval = intervals.get(i); if (currentInterval[0] > lastEnd) { count++; lastEnd = currentInterval[1]; } } return count; }}Complexity
Time
O(n^2 log n). Generating all intervals takes O(n^2). Sorting O(n^2) intervals takes O(n^2 log(n^2)) which simplifies to O(n^2 log n).
Space
O(n^2), to store the list of all possible valid intervals.
Trade-offs
Pros
Reduces the problem to a standard, well-understood algorithm (Interval Scheduling).
More structured and efficient than pure brute-force recursion.
Cons
The number of valid substrings can be up to O(n^2), leading to high time and space complexity.
Inefficient for the given constraints due to the O(n^2) factor.
This approach significantly optimizes the interval generation step of the previous method. Instead of generating all O(n^2) possible valid substrings, we observe that for any given starting point, it's always optimal to choose the shortest possible valid substring. This reduces the number of intervals to consider to at most n, making the subsequent sorting and selection steps much faster.
Algorithm
- Step 1: Prune the intervals. For any starting index
i, if there are multiple valid substrings[i, j1], [i, j2], ..., the one with the smallest end point is always the best choice because it leaves the most room for subsequent substrings. So, for eachi, we only need to find the smallestj >= i + 3such thatword[i] == word[j]. - Step 2: Efficiently find minimal end points. To find these minimal
j's efficiently, we can pre-process the string. One way is to store the indices of each character in a hash map (Map<Character, List<Integer>>). Then for eachi, we can perform a binary search on the list of indices forword[i]to find the smallest indexj >= i + 3. - Step 3: Generate O(n) intervals. Iterate from
i = 0ton-1, find the minimaljfor eachias described above, and add the interval[i, j]to a list. This creates at mostnintervals. - Step 4: Apply greedy selection. Sort these
O(n)intervals by their end points and apply the same greedy selection algorithm as in the previous approach.
Walkthrough
The key insight here is that we don't need to consider all valid substrings. For a fixed start i, consider two valid substrings S1 = word[i...j1] and S2 = word[i...j2] with j1 < j2. If we choose S1, the next non-overlapping substring can start from j1 + 1. If we choose S2, it can only start from j2 + 1. Since j1 + 1 < j2 + 1, choosing S1 (the shorter substring) provides more opportunities for subsequent substrings. Thus, we only need to generate one interval per starting position: [i, j_min], where j_min is the smallest valid end index.
To implement this, we can precompute the indices of all characters. A HashMap mapping each character to a sorted list of its indices works well. For each starting index i, we look up the list for word.charAt(i) and use binary search to find the first index j in that list that is greater than or equal to i + 3. This process gives us a list of at most n 'optimal' intervals, which we can then solve using the standard greedy interval scheduling algorithm.
// This is a conceptual snippet. A full implementation would require// a binary search helper function.List<int[]> intervals = new ArrayList<>();Map<Character, List<Integer>> positions = new HashMap<>();for (int i = 0; i < n; i++) { positions.computeIfAbsent(word.charAt(i), k -> new ArrayList<>()).add(i);} for (int i = 0; i < n; i++) { char c = word.charAt(i); List<Integer> posList = positions.get(c); // Perform binary search on posList to find the smallest index j >= i + 3 int j_min = findFirstIndexGreaterThanOrEqualTo(posList, i + 3); if (j_min != -1) { intervals.add(new int[]{i, j_min}); }} // Sort and apply greedy selection on the 'intervals' list as before.// ...Complexity
Time
O(n log n). O(n) to build the position map. O(n log k) to generate intervals, where k is the max frequency of a character (k<=n), so O(n log n). O(n log n) to sort the intervals. The greedy pass is O(n).
Space
O(n), to store the character positions and the O(n) generated intervals.
Trade-offs
Pros
Drastically reduces the number of intervals from O(n^2) to O(n).
Achieves a much better time complexity of O(n log n), which is efficient enough for the given constraints.
Cons
The implementation is more complex than the previous approach, involving data structures and binary search.
The O(n log n) complexity might still be too slow for extremely large N, though it passes the given constraints.
The most efficient solution uses dynamic programming. We build the solution from right to left. We define dp[i] as the maximum number of valid non-intersecting substrings that can be found in the suffix of the string starting at index i. By considering whether to start a new substring at each index i or not, we can build up the solution in linear time.
Algorithm
- Step 1: Precomputation. Create a 2D array
next[i][char]that stores the index of the first occurrence ofcharat or after indexi. This can be computed in O(n * alphabet_size) by iterating backwards from the end of the string. - Step 2: Define DP State. Let
dp[i]be the maximum number of non-intersecting valid substrings in the suffixword[i...n-1]. - Step 3: DP Recurrence. We compute
dpvalues fromi = n-1down to0. For eachi,dp[i]is the maximum of two choices:- Skip
i: Don't start a substring ati. The result isdp[i+1]. - Start at
i: Find the smallest indexj >= i + 3whereword[j] == word[i]. Thisjcan be found in O(1) using our precomputednexttable:j = next[i+3][word[i]]. If such ajexists, this choice yields1 + dp[j+1]substrings.
- The recurrence is
dp[i] = max(dp[i+1], 1 + dp[j+1]).
- Skip
- Step 4: Final Answer. The result for the whole string is
dp[0].
Walkthrough
This approach avoids generating and sorting intervals altogether. We iterate from the end of the string to the beginning, making a decision at each character.
Let dp[i] be the maximum number of valid substrings in word[i...n-1]. Our goal is to find dp[0]. The base cases are dp[n] = 0 and dp[n+1] = 0.
For each index i from n-1 down to 0, we have two choices:
- We don't start a substring at
i. The maximum number of substrings is then the same as the solution for the suffixword[i+1...n-1], which isdp[i+1]. - We start a substring at
i. To maximize the total count, we should choose the shortest possible valid substring starting ati, as this leaves the most room for subsequent substrings. We need to find the smallest indexj >= i + 3such thatword[i] == word[j]. If we find such aj, we take this substring, and the total count becomes1 + dp[j+1].
To find this j efficiently, we precompute a next[k][c] table, which gives the index of the first occurrence of character c at or after index k. This allows finding j in O(1) time. The overall DP calculation then takes O(n) time.
class Solution { public int maxNumOfSubstrings(String word) { int n = word.length(); if (n < 4) { return 0; } // next[i][char_code] = index of first occurrence of char at or after index i int[][] next = new int[n + 1][26]; for (int j = 0; j < 26; j++) { next[n][j] = n; // Sentinel for not found } for (int i = n - 1; i >= 0; i--) { for (int j = 0; j < 26; j++) { next[i][j] = next[i + 1][j]; } next[i][word.charAt(i) - 'a'] = i; } int[] dp = new int[n + 2]; // dp[n] and dp[n+1] are implicitly 0 for (int i = n - 1; i >= 0; i--) { // Option 1: Don't start a substring at i dp[i] = dp[i + 1]; // Option 2: Start a substring at i int charCode = word.charAt(i) - 'a'; int requiredStartIndexForJ = i + 3; if (requiredStartIndexForJ < n) { int j = next[requiredStartIndexForJ][charCode]; if (j < n) { // Found a valid substring [i, j] dp[i] = Math.max(dp[i], 1 + dp[j + 1]); } } } return dp[0]; }}Complexity
Time
O(n * k), where k is the alphabet size. Precomputation takes O(n*k) and the DP loop takes O(n). Since k is a constant (26), the total time complexity is O(n).
Space
O(n * k), where n is the string length and k is the alphabet size (26). This is for the `next` table and the `dp` array. Since k is constant, this is O(n).
Trade-offs
Pros
Optimal time complexity of O(n).
Directly solves the problem without transforming it to another problem like interval scheduling.
Cons
Requires more space for the precomputation table compared to an O(n log n) approach that doesn't use one.
The logic can be slightly less intuitive than the interval-based approaches.
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.