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 · 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.

saabac"aba" is a substring
saabac"abc" here is not (characters skipped)

"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:

Whatever right does, left undoes. Neither pointer ever goes back.

Fixed-size vs variable-size windows

Fixed-sizeVariable-size
whenthe window length k is giventhe length is free; find the longest / shortest window obeying a rule
movesadd 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 forupdate the answerexample
longest validafter shrinking back to valid: best = max(best, right − left + 1)this problem, Longest Substring Without Repeating
shortest validwhile valid: record, then shrink to try smallerMinimum Window Substring
count of validcount += right − left + 1Subarray 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

variable-size "longest valid window" template (Part 0 helper)
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 best

Part 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.

index012345678910
saabacbebebek = 3 → "cbebebe", answer 7

"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

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).

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.

Doubt 1: we found a valid one. Should we break now?
→ 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.

Doubt 2: why is it safe to break at size > k?
→ 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.

Doubt 3: why reset the set for every i?
→ 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

  1. best = −1 (in case nothing is valid).
  2. For each start i: new empty set.
  3. For j from i to the end: add s[j] to the set.
  4. If size == k → best = max(best, j − i + 1).
  5. If size > k → break.
  6. Return best.

6Code (Python)

Brute force: every start + a set, O(n²)
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 best

7Code line by line

linewhat it means
best = -1If 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: breakToo 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.

idistinct count as j moves (✓ = exactly 3)stoppedlongest from ibest
01, 1, 2, 2, 3 ✓(5), 3 ✓(6), 4e at j=666
11, 2, 2, 3 ✓(4), 3 ✓(5), 4e at j=656
21, 2, 3 ✓(3), 3 ✓(4), 4e at j=646
31, 2, 3 ✓(3), 4e at j=636
41, 2, 3 ✓(3), 3 ✓(4), 3 ✓(5), 3 ✓(6), 3 ✓(7)end of string77
5–10only b and e left → never 3end—7

Final answer: 7 ✓

9Complexity & remember

Remember the brute forceNew set per start. Size == k → record, keep going. Size > k → break. Start best at −1.

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

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.

Doubt 1: two pointers or sliding window?
→ 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:

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.

Shrinking until valid: a while loop

With 4 keys > k = 3, shrink:

  1. left = 0, 'a': a 3 → 2. Still 4 keys. left = 1.
  2. left = 1, 'a': a 2 → 1. Still 4 keys. left = 2.
  3. left = 2, 'b': b 2 → 1. Still 4 keys. left = 3.
  4. left = 3, 'a': a 1 → 0. left = 4. But the map still shows 4 keys, because a is there with value 0!
Doubt 2: a's count is 0. Why does the map still say 4?
→ 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.
Doubt 3: why 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

  1. freq = {}, left = 0, best = −1.
  2. For each right: freq[s[right]] += 1.
  3. While len(freq) > k: lower freq[s[left]], delete it if 0, left += 1.
  4. If len(freq) == k: best = max(best, right − left + 1).
  5. Return best.

6Code (Python)

Sliding window: frequency map, O(n)
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 best

7Code line by line

linewhat it means
if k <= 0: return -1Not 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) + 1Expansion: right brings c in.
while len(freq) > k:More than k different letters → shrink until it's k again.
freq[out] -= 1One 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 += 1Move the left edge. Never backwards.
if len(freq) == k:Record only windows with exactly k distinct, not fewer.
return bestStill −1 if no window ever had exactly k.

8Dry run (hand table)

s = "aabacbebebe", k = 3.

stepright (char added)window before shrinkmap after addingshrink? (what leaves, why)window afterbest
10 'a'a{a:1}no (1 key)[0..0] a−1
21 'a'aa{a:2}no[0..1]−1
32 'b'aab{a:2,b:1}no[0..2]−1
43 'a'aaba{a:3,b:1}no[0..3]−1
54 'c'aabac{a:3,b:1,c:1}no (3 keys = k)[0..4]5
65 'b'aabacb{a:3,b:2,c:1}no[0..5]6
76 '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] cbe6
87 'b'cbeb{b:2,c:1,e:1}no[4..7]6
98 'e'cbebe{b:2,c:1,e:2}no[4..8]6
109 'b'cbebeb{b:3,c:1,e:2}no[4..9]6
1110 'e'cbebebe{b:3,c:1,e:3}no[4..10]7
step 6aabacbebebe3 distinct, length 6
L    R     
step 7aabacbebebee came in; left walked 4 steps until every a had left
    L R    
step 11aabacbebebelength 7 = answer
    L     R

Final answer: 7 ✓

9Complexity & remember

A while inside a for looks like n², but it isn't:

Her submission passed, but the speed was only average (about 40%). Her reason: the hash map does extra hashing work on every update.

Remember the sliding windowRight: count + 1. While keys > k: left count − 1, delete at 0, left + 1. Record only when keys == k. Start best at −1.

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.

index01234…25
letterabcde…z
count32101…0the window "aabacbe"

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 whenuse a hash map when
the set of possible keys is small and known: 26 lower-case, 26 upper-case, 128/256 ASCIIkeys are many or unknown (numbers up to 10⁹, words…), so an array would be huge
faster: plain indexing, no hashingflexible, but each operation does hashing work

Both are correct here. The array is just faster.

4Code (Python)

Sliding window: count array of 26 + distinct counter
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 best

5Code line by line (only the new lines)

linewhat it means
count = [0] * 26One 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 += 1Before adding, the letter was absent → one more distinct letter.
if count[j] == 0: distinct -= 1After 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.

Remember the array versionFixed small alphabet → [0]*26 and index ord(c) − ord('a'). Keep distinct yourself: +1 on 0→1, −1 on 1→0.

Part D · Revision page

Brute forceWindow + mapWindow + array
memorya set, new per startletter → count (delete at 0)26 counts + distinct
distinct letters =len(seen)len(freq)distinct
when too manybreak, next startwhile > k: shrink leftwhile > k: shrink left
record whenexactly k distinct → max(length)
time / spaceO(n²) (TLE) / O(26)O(2n) / O(26)O(2n), faster / O(26)
structuregood forproblem 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 26same, for a small fixed alphabetworks and is faster
If you remember only 5 lines 1. Distinct letters only go up as the window grows → shrink from the left when there are too many.
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.
Mistakes to avoid ✗ breaking as soon as size == k (longer valid ones are missed)
✗ 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
test it yourself (paste under any Solution above)
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))             # 1

Based on this video: Longest Substring with K Unique Characters | Sliding Window