Sliding Window on Strings
A sliding window with a character frequency map finds substrings satisfying frequency constraints in O(n) time. This chapter covers the variable-size window (minimum window substring), the fixed-size window (find all anagrams / permutation match), and the 'at most k distinct characters' family.
Table of contents
- The problem with brute force
- The supermarket restocking story
- The one idea to remember
- Variant 1 — Minimum Window Substring (LC #76)
- Frame-by-frame: s = "ADOBECODEBANC", t = "ABC"
- Variant 2 — Find All Anagrams / Permutation in String (fixed-size window)
- Permutation in String (LC #567)
- Find All Anagrams in a String (LC #438)
- Variant 3 — At most k distinct characters (variable window)
- Decision guide: which variant?
- Common traps
- Complexity
- Say it like a pro (interview one-liner)
- Check yourself
- Practice problems
The problem with brute force
You need to find the shortest substring of s that contains every character of pattern t. The brute-force approach checks every possible (start, end) pair and counts characters — O(n² × m) for string length n and pattern length m. Far too slow for n = 100 000.
We already know from Chapter 1.5 that a variable-size sliding window shrinks and grows in O(n) total steps. The new ingredient for strings is a character frequency map that tracks how many required characters are still missing from the current window.
The supermarket restocking story
A real-life story
A store manager has a shopping list of required items (the pattern). A loading dock worker moves along a conveyor belt (the string) picking up items. When the worker has collected every item on the list, the manager shouts "shrink from the left!" — the worker gives back items from the left end of his trolley until the list is no longer satisfied. At that moment, the window is as tight as possible. The worker records the current trolley size and then takes one more step right.
Every expand-then-shrink cycle takes O(1). Over n steps the trolley visits each position at most twice (once when the right pointer adds it, once when the left pointer removes it) → O(n) total.
The one idea to remember
The entire pattern in one sentence
A frequency difference map (need) counts how many more of each character the window still requires; a single integer formed tracks how many distinct characters are fully satisfied — when formed == required, the window is valid and you try to shrink from the left.
Variant 1 — Minimum Window Substring (LC #76)
Find the smallest window in s that contains all characters of t (including duplicates).
from collections import Counter
def minWindow(s: str, t: str) -> str:
if not t or not s:
return ""
need = Counter(t) # how many of each char we still need
have = {} # frequency of each char in current window
required = len(need) # number of distinct chars with unmet quota
formed = 0 # distinct chars whose quota is currently met
left = 0
best_len = float("inf")
best_start = 0
for right in range(len(s)):
c = s[right]
have[c] = have.get(c, 0) + 1
# did adding c satisfy c's quota exactly?
if c in need and have[c] == need[c]:
formed += 1
# try to shrink from the left while window is valid
while formed == required:
# record if this window is the best so far
if right - left + 1 < best_len:
best_len = right - left + 1
best_start = left
# remove leftmost character
lc = s[left]
have[lc] -= 1
if lc in need and have[lc] < need[lc]:
formed -= 1 # lc's quota is no longer met
left += 1
return "" if best_len == float("inf") else s[best_start: best_start + best_len]
Frame-by-frame: s = "ADOBECODEBANC", t = "ABC"
need = {A:1, B:1, C:1} required = 3 formed = 0
right=0 'A': have={A:1} formed=1 (A satisfied)
right=1 'D': have={A:1,D:1} formed=1
right=2 'O': have={..O:1} formed=1
right=3 'B': have={..B:1} formed=2 (B satisfied)
right=4 'E': have={..E:1} formed=2
right=5 'C': have={..C:1} formed=3 ← window valid!
Shrink: left=0 'A' → have[A]=0 < need[A]=1 → formed=2; left=1
Window no longer valid, stop shrinking.
best = s[0:6] = "ADOBEC", len=6
right=6 'O' right=7 'D' right=8 'E': formed still 2
right=9 'B': have[B]=2 (quota already met, formed unchanged)
right=10 'A': have[A]=1 == need[A]=1 → formed=3 ← valid!
Shrink: left=1 'D' → not in need, just remove. left=2
left=2 'O' → not in need. left=3
left=3 'B' → have[B]=1, still ≥ need[B]=1. left=4
left=4 'E' → not in need. left=5
left=5 'C' → have[C]=0 < need[C]=1 → formed=2; left=6
Window "BANC" (s[6..10]) has len 5 < 6 → new best!
right=11 'N' right=12 'C': have[C]=1 → formed=3
Shrink: left=6 'O' → not in need. left=7 'D' → not in need. left=8 'E' → not in need.
left=9 'B' → have[B]=0 < need[B]=1 → formed=2; left=10
Window "ANC" not valid. Stop.
Answer: "BANC" ✅
Variant 2 — Find All Anagrams / Permutation in String (fixed-size window)
When the required window size is fixed (= length of pattern), you don't need a formed counter — just compare frequency maps directly or use a deficit counter.
Permutation in String (LC #567)
Does s2 contain any permutation of s1?
def checkInclusion(s1: str, s2: str) -> bool:
if len(s1) > len(s2):
return False
need = Counter(s1)
have = Counter(s2[:len(s1)]) # first window
k = len(s1)
if have == need:
return True
for i in range(k, len(s2)):
# add new right character
have[s2[i]] += 1
# remove old left character
old = s2[i - k]
have[old] -= 1
if have[old] == 0:
del have[old] # keep map clean — avoid false non-equality
if have == need:
return True
return False
Pause & think
Why delete have[old] when its count reaches 0 instead of leaving it as {old: 0}?
Answer
Counter({'a': 1}) == Counter({'a': 1, 'b': 0}) evaluates to False in Python because the second Counter has an extra key. Leaving zero-count keys in have would cause false "not equal" results even when the window truly is an anagram of s1. Deleting the key keeps have clean so the == comparison works correctly.
Find All Anagrams in a String (LC #438)
Same idea, but collect all start indices where an anagram window begins.
def findAnagrams(s: str, p: str) -> list[int]:
k = len(p)
if k > len(s):
return []
need = Counter(p)
have = Counter(s[:k])
result = []
if have == need:
result.append(0)
for i in range(k, len(s)):
have[s[i]] += 1
old = s[i - k]
have[old] -= 1
if have[old] == 0:
del have[old]
if have == need:
result.append(i - k + 1)
return result
Variant 3 — At most k distinct characters (variable window)
Find the length of the longest substring with at most k distinct characters.
def lengthOfLongestSubstringKDistinct(s: str, k: int) -> int:
have = {}
left = 0
best = 0
for right in range(len(s)):
c = s[right]
have[c] = have.get(c, 0) + 1
while len(have) > k: # window has too many distinct chars
lc = s[left]
have[lc] -= 1
if have[lc] == 0:
del have[lc]
left += 1
best = max(best, right - left + 1)
return best
The while len(have) > k shrink condition replaces the formed == required check from Variant 1 — the map itself tells you whether the constraint is satisfied.
Decision guide: which variant?
| Problem shape | Window size | Validity signal | Variant |
|---|---|---|---|
| Contains all chars of pattern | Variable | formed == required | Minimum Window |
| Is a permutation / anagram | Fixed (= |pattern|) | have == need | Fixed Anagram |
| At most k distinct | Variable | len(have) <= k | k-Distinct |
| Exactly k distinct | Variable | (at most k) − (at most k−1) | Subtract variants |
Common traps
Watch out for these
- Comparing
Countermaps with zero-count keys. Always delete a key when its count drops to 0, orhave == needwill give wrong results. - Updating
formedtoo eagerly. Only incrementformedwhenhave[c]reaches exactlyneed[c](not when it exceeds it). Similarly, only decrement whenhave[lc]drops belowneed[lc]. - Forgetting to handle duplicates in
t. Ift = "AAB", you need two A's in the window, not just one. The Counter approach handles this automatically —need['A'] = 2. - Off-by-one in fixed window start index. When a fixed window match is found at
right, the start index isright - k + 1, notright - k.
Complexity
| Variant | Time | Space |
|---|---|---|
| Minimum Window Substring | O(n + m) | O(m) for need map |
| Fixed Anagram / Permutation | O(n + m) | O(m) for Counter |
| At most k distinct | O(n) | O(k) |
Say it like a pro (interview one-liner)
"I'll use a variable sliding window with a frequency difference map. I expand the right pointer one step at a time; when the window is valid (formed == required distinct chars satisfied), I shrink from the left to find the tightest valid window. Each character is added and removed at most once, giving O(n) time."
Remember this forever
Sliding Window on Strings
need = Counter(t)
have = {}
formed = 0
required = len(need) # distinct chars with quota
for right in range(len(s)):
c = s[right]; have[c] = have.get(c,0)+1
if c in need and have[c] == need[c]: formed += 1
while formed == required:
update best
lc = s[left]; have[lc] -= 1
if lc in need and have[lc] < need[lc]: formed -= 1
left += 1
Fixed-window anagram: have == need after each slide (delete zero-count keys).
k-distinct: shrink while len(have) > k.
Key trap: only increment/decrement formed at the exact threshold (==), not above/below.
Check yourself
In Minimum Window Substring, why is `formed` incremented only when `have[c] == need[c]` and not when `have[c] > need[c]`?
formed tracks the count of distinct characters whose quota is exactly met for the first time. If have[c] exceeds need[c], the quota was already met at the exact-match step — formed was already incremented then. Incrementing again on every further occurrence would overcount and cause the window to be considered "invalid" (formed > required) prematurely. We only care that each required character appears at least as many times as needed; additional copies are irrelevant to formed.
Why does the minimum window algorithm shrink inside a `while` loop rather than a single `if`?
After the window becomes valid, removing one character from the left may keep the window valid (if that character had excess copies). We should keep shrinking as long as the window remains valid to find the tightest (shortest) possible window before recording the answer. A single if would only try one shrink per right-pointer step and miss tighter windows.
How would you count "exactly k distinct characters" substrings using the at-most-k variant?
exactly_k(s, k) = atMost(s, k) - atMost(s, k-1). Each call runs the at-most variant returning the total count of valid substrings. The difference eliminates substrings with fewer than k distinct characters, leaving exactly k. This is a standard "subtract two sliding window results" technique.
Practice problems
| Problem | Difficulty | What to notice | Link |
|---|---|---|---|
| Minimum Window Substring | Hard | Variable window; formed/required counter | LC #76 |
| Permutation in String | Medium | Fixed window; have == need; delete zero keys | LC #567 |
| Find All Anagrams in a String | Medium | Fixed window; collect all start indices | LC #438 |
| Longest Substring with At Most K Distinct | Medium | Variable window; shrink when len(have) > k | LC #340 |
| Longest Substring Without Repeating Characters | Medium | At-most-1-distinct variant (each count ≤ 1) | LC #3 |
Next up: KMP Pattern Matching — the O(n) text search algorithm that never backtracks in the text by pre-computing a failure function from the pattern.