DSA sheet · Strings · Sliding Window pattern
Longest Substring with K Unique Characters
This is the next string question in the sliding window pattern. The teacher first writes a brute force (every start, a set, stop when there are too many different characters). Then she optimises it with a sliding window, and on the way she explains three choices that come up in many string problems: why a set is not enough, why a frequency map works, and when a plain array of size 26 is better than a hash map.
Why it matters: "longest window with at most / exactly K different things" is a very common shape (Fruits Into Baskets is the same problem with K = 2). The key habit from this video is "delete the key when its count hits 0", because the size of the map is the number of different characters.
Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember
- Part 0 · What you must know before starting (sliding window from scratch)
- Part A · Brute force: every start + a set
- Part B · Sliding window with a frequency map
- Part C · Same window with an array of 26 (faster)
- Part D · Revision page
Part 0 · Before starting
Substring = a contiguous piece
A substring is a piece of the string where the characters sit next to each other. You choose a start and an end and take everything in between, with no skipping. (In arrays it's called a subarray.) A string of length n has n·(n+1)/2 non-empty substrings.
"Unique" or "distinct" characters
The number of unique (distinct) characters in a substring is how many different letters it has, however many times each one appears. "aabac" has 5 characters but only 3 distinct ones: a, b, c.
Brute force over all windows, and why it repeats work
Try every start i, and for each start every end j: two loops, O(n²) windows. When i moves one step, the inner loop re-reads characters that the previous round already looked at. Sliding window keeps what it learned instead.
The window [left..right], expanding and shrinking
The window is the current substring s[left..right]; its length is right − left + 1. The teacher's two phases:
- Expansion:
rightmoves forward, and the new character is added (its count goes up). - Shrinking:
leftmoves forward, and the character at left is removed (its count goes down).
Whatever right does, left undoes. Neither pointer ever goes back.
Fixed-size vs variable-size windows
| Fixed-size | Variable-size | |
|---|---|---|
| when | the window length k is given | the length is free; find the longest / shortest window obeying a rule |
| moves | add s[right]; when size > k, remove s[right − k] | grow right; while the rule is broken, shrink left |
| this problem | ✓ the K here is a count of different letters, not a length |
Longest vs shortest vs count
| asks for | update the answer | example |
|---|---|---|
| longest valid | after shrinking back to valid: best = max(best, right − left + 1) | this problem, Longest Substring Without Repeating |
| shortest valid | while valid: record, then shrink to try smaller | Minimum Window Substring |
| count of valid | count += right − left + 1 | Subarray Product Less Than K |
For "count of subarrays with exactly K distinct", the trick is exactly(K) = atMost(K) − atMost(K−1) (Subarrays with K Different Integers). For "longest with exactly K", like here, we don't need it: we just record the length only when the window has exactly K.
Why sliding window works here (the monotonic rule)
Adding a character can only keep or raise the number of distinct characters. Removing one can only keep or lower it. So once the window has more than K distinct, every bigger window starting at the same left also has more than K. It's safe to shrink from the left, and left never needs to go back. (In sum problems, this kind of rule fails when numbers can be negative.)
Set vs frequency map vs count array
- set: knows which characters are inside, but not how many of each. Removing one copy of 'a' would wrongly forget all the a's.
- frequency map (dict or
collections.Counter): character → count. Add:+1. Remove:−1, and delete the key at 0. Thenlen(freq)= number of distinct characters in the window. - array of 26:
count[ord(c) − ord('a')]. Same idea, no hashing. Part C uses it.
def longest_valid(s, add, remove, too_big, is_answer):
left = 0
best = -1
for right in range(len(s)):
add(s[right]) # expand
while too_big(): # rule broken -> shrink
remove(s[left]) # left undoes right
left += 1
if is_answer(): # window qualifies
best = max(best, right - left + 1)
return bestPart A · Brute force: every start + a set
GeeksforGeeks · Longest K unique characters substring
1The question in simple words
You get a string s of lower-case letters and a number k. Find the length of the longest substring that has exactly k distinct characters. If there is no such substring, return −1.
"cbebebe" uses only c, b, e: exactly 3 distinct, and nothing longer has exactly 3. Another small case: s = "aaaa", k = 2 → −1 (only one distinct letter exists).
The teacher asks: why does the question say only lower-case letters? Keep it in mind. It's the reason an array of size 26 works (Part C).
2What the constraints tell us
- length up to 10⁵. O(n²) = 10¹⁰ steps, far beyond the ~10⁸ safe line → brute force will give TLE. We'll still write it first, then optimise.
- 1 ≤ k ≤ 26. Only 26 lower-case letters, so k can't be more than 26 in a meaningful way.
- "No such substring" → −1, so the answer must start at −1, not 0.
3Intuition: every start, stop once there are too many letters
Fix a start i. Move j from i to the right, and keep the letters of s[i..j] in a set (a set keeps only unique things, so its size = number of distinct letters).
- set size < k → not enough different letters yet, keep going.
- set size == k → valid! Record
j − i + 1, and keep going (a longer one might still have exactly k). - set size > k → too many, and growing can never bring it back down → break, move i.
4Building the logic from the example
i = 0
"a" → 1 distinct, not valid. "aa" → still 1 (the set doesn't add a again). "aab" → 2. "aaba" → still 2. "aabac" → 3 ✓, length 5. Save it.
→ No! The teacher stresses this. The next character is b, which is already in the set, so "aabacb" still has exactly 3 distinct and is longer (6). If we broke at 5, we'd miss 6. We only break when the size goes above k.
"aabacbe" → e is new → size 4 > 3. From now on, every longer substring starting at 0 has at least 4 distinct letters. Break. Best so far: 6.
→ Adding letters can never remove one from the set. The size only stays the same or grows. Once it's 4, it can never come back to 3 for this start.
i = 1
New empty set, since the old one belonged to start 0. "a", "ab", "aba" (2), "abac" (3 ✓, length 4, not better than 6), "abacb" (3 ✓, 5), "abacbe" (4) → break.
→ The set describes s[i..j] for the current start. Letters from the previous start's substrings don't belong to the new ones.
Continue for every i
Doing this for every start, the biggest valid length shows up at i = 4: "cbebebe", length 7, which runs to the end of the string with only c, b, e.
5Approach steps
best = −1(in case nothing is valid).- For each start i: new empty set.
- For j from i to the end: add s[j] to the set.
- If size == k →
best = max(best, j − i + 1). - If size > k → break.
- Return best.
6Code (Python)
class Solution:
def longestKSubstr(self, s, k):
n = len(s)
best = -1 # -1 if no valid substring
for i in range(n):
seen = set() # distinct letters of s[i..j]
for j in range(i, n):
seen.add(s[j])
if len(seen) == k:
best = max(best, j - i + 1)
elif len(seen) > k:
break # can only get worse
return best7Code line by line
| line | what it means |
|---|---|
| best = -1 | If no substring ever has exactly k distinct, we must return −1, not 0. |
| seen = set() | A new set for each start. |
| seen.add(s[j]) | Grow the substring by one letter. A repeated letter doesn't change the size. |
| if len(seen) == k: | Exactly k different letters: valid, so record the length. Don't stop. |
| elif len(seen) > k: break | Too many letters, and it can't come back down → next start. |
8Dry run (hand table)
s = "aabacbebebe", k = 3. Each row is one start i.
| i | distinct count as j moves (✓ = exactly 3) | stopped | longest from i | best |
|---|---|---|---|---|
| 0 | 1, 1, 2, 2, 3 ✓(5), 3 ✓(6), 4 | e at j=6 | 6 | 6 |
| 1 | 1, 2, 2, 3 ✓(4), 3 ✓(5), 4 | e at j=6 | 5 | 6 |
| 2 | 1, 2, 3 ✓(3), 3 ✓(4), 4 | e at j=6 | 4 | 6 |
| 3 | 1, 2, 3 ✓(3), 4 | e at j=6 | 3 | 6 |
| 4 | 1, 2, 3 ✓(3), 3 ✓(4), 3 ✓(5), 3 ✓(6), 3 ✓(7) | end of string | 7 | 7 |
| 5–10 | only b and e left → never 3 | end | — | 7 |
Final answer: 7 ✓
9Complexity & remember
- Time O(n²): two nested loops. With n = 10⁵ that's 10¹⁰ → her submission got TLE, as expected.
- Space O(26) for the set.
Part B · Sliding window with a frequency map
1The question again, with the new goal
Same question, but in about one pass: O(n), so 10⁵ is easy.
2What the constraints tell us now
- n = 10⁵ → we need O(n) or O(n log n).
- Only lower-case letters → at most 26 keys in the map (and Part C's array idea).
3Intuition: where is the repeated work?
From i = 0 we already learned that s[0..5] has 3 or fewer distinct letters. When i moves to 1, the brute force reads s[1..5] all over again, even though we already know every piece inside it also has ≤ 3 distinct letters. That re-reading is the waste. Sliding window keeps the window and only fixes its left edge.
→ Two pointers is for when only the two values at the pointers matter and we can decide which pointer to move. Here we care about the whole contiguous substring between them, so it's sliding window.
4Building the logic from examples
First try: use a set (and watch it fail)
The teacher first tries the window with a plain set:
- right adds a, a (no change), b, a (no change), c → size 3, window "aabac", length 5. Then b → still 3, length 6.
- right adds e → size 4. Too many, so we shrink. left is at index 0, 'a'. Remove 'a' from the set → size 3, and left moves to index 1.
- The set now says "3 distinct, valid". But the window s[1..6] = "abacbe" still contains a (at indexes 1 and 3)! It really has 4 distinct letters.
The set forgot that there were three a's. Removing one copy shouldn't remove the letter. We need to keep removing until every a has left the window. So we need to know how many of each letter are inside → a frequency map.
The frequency map
Map: letter → how many times it's in the window. At "aabacbe" it is {a:3, b:2, c:1, e:1}. The number of distinct letters is the number of keys: 4.
- Right adds: count + 1.
- Left undoes it: count − 1 (not "remove the letter").
Shrinking until valid: a while loop
With 4 keys > k = 3, shrink:
- left = 0, 'a': a 3 → 2. Still 4 keys. left = 1.
- left = 1, 'a': a 2 → 1. Still 4 keys. left = 2.
- left = 2, 'b': b 2 → 1. Still 4 keys. left = 3.
- left = 3, 'a': a 1 → 0. left = 4. But the map still shows 4 keys, because a is there with value 0!
→
len(map) counts keys, not non-zero values. A key with count 0 is still a key. So whenever a count drops to 0, we must delete that key. After deleting a, the map is {b:1, c:1, e:1}, 3 keys, and the window "cbe" (index 4..6) is valid.while and not if?→ We just saw it: it took four shrink steps before the window was valid. One step isn't enough in general.
When do we record the answer?
After the shrinking, the window has at most k distinct letters. It might have fewer (at the start, "aab" has only 2). The question wants exactly k, so we record right − left + 1 only if len(freq) == k.
After "cbe", right keeps going: b, e, b, e. No new letters, so no shrinking, and the window grows to "cbebebe" = length 7. That's the answer.
5Approach steps
freq = {},left = 0,best = −1.- For each right:
freq[s[right]] += 1. - While
len(freq) > k: lowerfreq[s[left]], delete it if 0,left += 1. - If
len(freq) == k:best = max(best, right − left + 1). - Return best.
6Code (Python)
class Solution:
def longestKSubstr(self, s, k):
if k <= 0: # constraints say k >= 1; just a guard
return -1
freq = {} # letter -> count in window
left = 0
best = -1
for right in range(len(s)):
c = s[right]
freq[c] = freq.get(c, 0) + 1 # expand
while len(freq) > k: # too many distinct
out = s[left]
freq[out] -= 1 # left undoes right
if freq[out] == 0:
del freq[out] # 0 must not count as a key
left += 1
if len(freq) == k: # exactly k -> candidate
best = max(best, right - left + 1)
return best7Code line by line
| line | what it means |
|---|---|
| if k <= 0: return -1 | Not in her code. The constraints say k ≥ 1. Without it, k = 0 would shrink the window to empty and report 0. With it, we return −1 like the brute force (no non-empty substring has 0 distinct letters). |
| freq[c] = freq.get(c, 0) + 1 | Expansion: right brings c in. |
| while len(freq) > k: | More than k different letters → shrink until it's k again. |
| freq[out] -= 1 | One copy of the left letter leaves. |
| if freq[out] == 0: del freq[out] | No copies left → that letter is no longer in the window, so remove the key. This keeps len(freq) honest. |
| left += 1 | Move the left edge. Never backwards. |
| if len(freq) == k: | Record only windows with exactly k distinct, not fewer. |
| return best | Still −1 if no window ever had exactly k. |
8Dry run (hand table)
s = "aabacbebebe", k = 3.
| step | right (char added) | window before shrink | map after adding | shrink? (what leaves, why) | window after | best |
|---|---|---|---|---|---|---|
| 1 | 0 'a' | a | {a:1} | no (1 key) | [0..0] a | −1 |
| 2 | 1 'a' | aa | {a:2} | no | [0..1] | −1 |
| 3 | 2 'b' | aab | {a:2,b:1} | no | [0..2] | −1 |
| 4 | 3 'a' | aaba | {a:3,b:1} | no | [0..3] | −1 |
| 5 | 4 'c' | aabac | {a:3,b:1,c:1} | no (3 keys = k) | [0..4] | 5 |
| 6 | 5 'b' | aabacb | {a:3,b:2,c:1} | no | [0..5] | 6 |
| 7 | 6 'e' | aabacbe | {a:3,b:2,c:1,e:1} | yes, 4 keys: a(0)→2, a(1)→1, b(2)→1, a(3)→0 deleted | [4..6] cbe | 6 |
| 8 | 7 'b' | cbeb | {b:2,c:1,e:1} | no | [4..7] | 6 |
| 9 | 8 'e' | cbebe | {b:2,c:1,e:2} | no | [4..8] | 6 |
| 10 | 9 'b' | cbebeb | {b:3,c:1,e:2} | no | [4..9] | 6 |
| 11 | 10 'e' | cbebebe | {b:3,c:1,e:3} | no | [4..10] | 7 |
Final answer: 7 ✓
9Complexity & remember
A while inside a for looks like n², but it isn't:
rightgoes from 0 to n−1 once. It never comes back and restarts.leftalso only moves forward. It continues from where it stopped, so over the whole run it moves at most n times.- Total ≤ n + n = O(2n) = O(n). The teacher puts it as "more than n, less than 2n".
- Space O(26): at most k + 1 keys in the map.
Her submission passed, but the speed was only average (about 40%). Her reason: the hash map does extra hashing work on every update.
Part C · Same window with an array of 26
1What's the same and what changes
The window logic is exactly Part B. Only the storage changes: a list of 26 counts instead of a dict. The question said "only lower-case letters", and that's why this works.
2How can an array store letter counts?
Use the letter's code to get an index. ord('a') is 97. So ord(c) − ord('a') gives a → 0, b → 98 − 97 = 1, …, z → 25. count[0] holds a's count, count[1] holds b's, and so on.
An array has no "number of keys", so we keep our own counter distinct: +1 when a count goes 0 → 1, −1 when it goes 1 → 0. (That's the array version of "delete the key at 0".)
3When to use an array and when a map (her rule)
| use an array when | use a hash map when |
|---|---|
| the set of possible keys is small and known: 26 lower-case, 26 upper-case, 128/256 ASCII | keys are many or unknown (numbers up to 10⁹, words…), so an array would be huge |
| faster: plain indexing, no hashing | flexible, but each operation does hashing work |
Both are correct here. The array is just faster.
4Code (Python)
class Solution:
def longestKSubstr(self, s, k):
if k <= 0:
return -1
count = [0] * 26 # count[0] = a, ..., count[25] = z
distinct = 0 # letters with count > 0
left = 0
best = -1
for right in range(len(s)):
i = ord(s[right]) - ord('a')
if count[i] == 0:
distinct += 1 # a new letter enters
count[i] += 1
while distinct > k:
j = ord(s[left]) - ord('a')
count[j] -= 1
if count[j] == 0:
distinct -= 1 # that letter fully left
left += 1
if distinct == k:
best = max(best, right - left + 1)
return best5Code line by line (only the new lines)
| line | what it means |
|---|---|
| count = [0] * 26 | One slot per lower-case letter. |
| i = ord(s[right]) - ord('a') | Turns the letter into its slot number 0–25. |
| if count[i] == 0: distinct += 1 | Before adding, the letter was absent → one more distinct letter. |
| if count[j] == 0: distinct -= 1 | After removing, the letter is gone → one fewer distinct letter. |
6Dry run & complexity
The steps are identical to Part B's table: distinct follows the number of keys column (1, 1, 2, 2, 3, 3, 4 → shrink back to 3, then 3 to the end), and the answer is again 7. Time O(n), space O(26), but each step is cheaper than a dict operation.
[0]*26 and index ord(c) − ord('a'). Keep distinct yourself: +1 on 0→1, −1 on 1→0.Part D · Revision page
| Brute force | Window + map | Window + array | |
|---|---|---|---|
| memory | a set, new per start | letter → count (delete at 0) | 26 counts + distinct |
| distinct letters = | len(seen) | len(freq) | distinct |
| when too many | break, next start | while > k: shrink left | while > k: shrink left |
| record when | exactly k distinct → max(length) | ||
| time / space | O(n²) (TLE) / O(26) | O(2n) / O(26) | O(2n), faster / O(26) |
| structure | good for | problem here |
|---|---|---|
| set | "which letters are present" | forgets duplicates, so the window can't shrink correctly |
| frequency map | "how many of each" | works; must delete keys at 0 |
| array of 26 | same, for a small fixed alphabet | works and is faster |
2. A set can't shrink properly; use counts.
3. Delete a key when its count becomes 0 (or keep a
distinct counter).4. Record the length only when distinct == k; start the answer at −1.
5. Small fixed alphabet → array of 26 beats a hash map.
✗ using a set in the window (removing one 'a' forgets the other a's)
✗ leaving count-0 keys in the map
✗
if instead of while for shrinking✗ recording windows with fewer than k distinct
✗ starting best at 0 instead of −1
s = Solution()
print(s.longestKSubstr("aabacbebebe", 3)) # 7
print(s.longestKSubstr("aaaa", 2)) # -1
print(s.longestKSubstr("aaaa", 1)) # 4
print(s.longestKSubstr("abc", 3)) # 3
print(s.longestKSubstr("abc", 4)) # -1
print(s.longestKSubstr("a", 1)) # 1Based on this video: Longest Substring with K Unique Characters | Sliding Window