Maximum Number of Non-Overlapping Substrings is a sneaky greedy problem. The hard part is not the greedy pick at the end — it is realizing that each character forces a minimal valid interval, and that once you have those intervals the problem collapses into a familiar "pick the most non-overlapping intervals" exercise.
Problem. Given a string s of lowercase letters, find the maximum number of non-overlapping
substrings such that every substring is valid: if a character appears inside it, then all
occurrences of that character must be inside it too. Among all answers with the maximum count, return
the substrings with the smallest total length.
Example: s = 'abab' → answer ['abab']. The only valid substring is the whole string, because a and
b interleave — any smaller window splits one of them.
The slow way first
You might try every possible substring s[lo:hi+1], check whether it is valid, then search for the best non-overlapping combination. There are O(n²) substrings and checking validity is O(n) each, and the combination search explodes. Far too slow.
The question to ask: for a given character, what is the smallest valid substring that even contains it? If we can compute that one interval per character cheaply, we never need to consider arbitrary substrings at all.
The idea: one minimal interval per character
Each character c has a first and last occurrence. Its smallest interval must at least span [first[c], last[c]]. But that window may contain another character d whose own occurrences leak outside the window — so we expand the right end to swallow d, and keep scanning. If while expanding we ever find a character whose first occurrence sits before our left end, this interval can never be valid, so we drop it.
Once every character has produced (at most) one valid interval, sort by right endpoint and greedily keep an interval whenever it starts after the previously kept interval's end. That is the classic interval-scheduling greedy, which maximizes the count, and because each interval is already minimal it also minimizes total length.
Walk through it
Step through the animation on 'abab'. We start with a, which spans indices 0 to 2. But index 1 holds a b, and b also lives at index 3 — outside the window — so the window expands its right end to 3. Now [0, 3] holds every a and every b, so it is valid. b produces the same window. After sorting, only [0, 3] survives the greedy scan, giving one substring.
Pseudocode
record first[c] and last[c] for every character c
define grow(lo):
hi = last[s[lo]]
i = lo
while i <= hi:
if first[s[i]] < lo: # a char leaks left -> invalid
return nothing
hi = max(hi, last[s[i]]) # expand to contain this char
i += 1
return interval (lo, hi)
build a valid interval for each distinct character
sort the valid intervals by right endpoint
prev = -1
for each interval (lo, hi):
if lo > prev: # non-overlapping -> keep it
take s[lo:hi+1]
prev = hi
return the kept substringsThe Python solution
def max_substrings(s):
first = {c: s.index(c) for c in set(s)}
last = {c: s.rindex(c) for c in set(s)}
def grow(lo):
hi = last[s[lo]]
i = lo
while i <= hi:
if first[s[i]] < lo:
return None
hi = max(hi, last[s[i]])
i += 1
return (lo, hi)
spans = sorted(filter(None, (grow(first[c]) for c in set(s))), key=lambda p: p[1])
res, prev = [], -1
for lo, hi in spans:
if lo > prev:
res.append(s[lo:hi + 1]); prev = hi
return resfirstandlastmap each character to its first and last index — the seed of every interval.grow(lo)starts the window atlast[s[lo]]and walks rightward, expandinghito contain any character it meets.- The
first[s[i]] < locheck aborts an interval that can never be valid: a character inside it also appears to the left oflo. - We start each character at
first[c]so the window genuinely begins at its earliest occurrence. spansis the list of valid intervals, sorted by right endpoint — the key to the greedy pick.- The final loop keeps an interval only when
lo > prev, guaranteeing non-overlap while maximizing the count.
Complexity
| Case | Time | Notes |
|---|---|---|
| Build first/last | O(n) (moderate) | one pass over the string |
| Grow all intervals | O(n) (moderate) | each index visited O(1) amortized across windows |
| Sort + greedy | O(k log k) (moderate) | k distinct chars, at most 26 |
O(1) (fast)With only 26 possible characters the whole thing is effectively linear in the string length. The space is O(1) because the maps hold at most 26 entries.
When this pattern shows up
Whenever a problem says "a valid piece must contain all of something," think minimal interval that is closed under a containment rule, then interval-scheduling greedy. The two moves — expand a window until it is self-consistent, then pick non-overlapping pieces by right endpoint — are reusable far beyond this one problem.
Do not forget the abort case. If you only expand and never check first[s[i]] < lo, you can return an
interval whose characters leak out the left side, which is invalid. Sorting by right endpoint (not left)
is also load-bearing — it is what makes the greedy optimal.
Practice
For s = 'abab', after building a's window starting at index 0, why does hi expand from 2 to 3?
1. Why must a window expand its right endpoint while scanning?
2. What does the check first[s[i]] < lo detect?
3. Why sort the valid intervals by right endpoint before the greedy pick?
4. For s = 'abab', how many non-overlapping valid substrings are returned?