The sliding window works beautifully when your state is a running sum. Add the incoming, subtract the outgoing. Two operations per slide.
But “find the longest substring with at most 2 distinct characters” is different. Your window state is not a number --- it is a set of characters and how many times each appears. You need a frequency map.
Adding a character? Increment its count. Removing one? Decrement. Simple enough. But there is a subtle trap hiding in the decrement step that catches nearly everyone.
Think about it: you have a Map<string, number> tracking character frequencies. The string is "aababc" and you have expanded the window to include all 6 characters. The frequency map reads {a: 3, b: 2, c: 1}, and map.size is 3 --- which violates your constraint of at most 2 distinct characters. So you start shrinking from the left.
You remove "a" from position 0. The map becomes {a: 2, b: 2, c: 1}. Still 3 distinct. Remove another "a". Now {a: 1, b: 2, c: 1}. Still 3. Remove "b". Now {a: 1, b: 1, c: 1}. Still 3. Remove "a". Now the count for "a" hits zero: {a: 0, b: 1, c: 1}.
How many distinct characters does the map say you have? map.size reports 3. But the window only contains "b" and "c". The "a" key is a ghost --- it has a count of zero but it is still sitting in the map, inflating your distinct count, making the algorithm think the window is still invalid.
Try both approaches and watch map.size:
Can you find the fix?
You just traced through the ghost entry problem in your head. Now let's see if you can catch it in the act.
The string is "aababc" and the constraint is: the window may contain at most 2 distinct characters. You will expand the window character by character, watching a live frequency map update as each character enters. When the distinct count exceeds 2, you need to shrink from the left.
Here is the part that matters: when you shrink past a character and its count drops to zero, what happens to the map? Does map.size reflect reality? Or does it lie? On the previous screen, you saw the ghost key inflate the count from 2 to 3 even though the window only contained two distinct characters. Now you will feel it firsthand --- and notice the exact moment map.size diverges from the truth. That moment is the entire reason the deletion contract exists.
Expand the window, violate the constraint, then shrink. Watch the frequency map like a hawk. When does the lie begin?
Find the longest substring with at most 2 distinct characters. Tap the next character to expand the window.
The fix is a single line of code, but forgetting it is one of the most common sliding window bugs. Before you see it --- what should you do when a character's count drops to zero?
When a character's count hits zero in the frequency map, should you:
freq.set(char, freq.get(char)! - 1);if (freq.get(char) === 0) freq.delete(char);Every time you decrement a character's frequency, immediately check if it hit zero. If it did, delete the key entirely. This is the deletion contract: zero-count keys must never exist in the map.
Why does this matter so much? Because map.size is the standard way to check “how many distinct elements are in the window.” If ghost keys linger at count zero, map.size lies. Your shrink loop keeps running when it should stop, or your validity check fails when the window is actually fine.
Here is the full pattern for LC 3 (Longest Substring Without Repeating Characters):
function lengthOfLongestSubstring(s: string): number { const freq = new Map<string, number>(); let left = 0, maxLen = 0; for (let right = 0; right < s.length; right++) { freq.set(s[right], (freq.get(s[right]) ?? 0) + 1); while (freq.get(s[right])! > 1) { // duplicate detected const leftChar = s[left]; freq.set(leftChar, freq.get(leftChar)! - 1); if (freq.get(leftChar) === 0) freq.delete(leftChar); left++; } maxLen = Math.max(maxLen, right - left + 1); } return maxLen;}Notice the deletion contract on line 9: if (freq.get(leftChar) === 0) freq.delete(leftChar). Without it, the function still produces correct results for this problem (because the while condition checks the specific character's count, not map.size). But in problems where validity depends on map.size --- like “at most K distinct characters” --- the missing deletion turns correct code into subtly broken code.
The frequency map is your second window state tool, after the running sum. Sums track magnitude. Frequency maps track composition. Together, they cover almost every sliding window constraint you will encounter.