A learning sheet β turn O(nΒ²) subarray scans into one linear pass by reusing the window you already built.
The one idea: a contiguous sub-array/sub-string answer depends on a window [left, right].
Instead of rebuilding every window from scratch (O(nΒ²)), slide: when right moves one step, you only
add one element; when you must shrink, left only ever moves forward. Each index enters and leaves the window once β O(n).
The whole topic is one question: "when is my window valid, and which end do I move to fix it?" Answer that with an invariant and the template writes itself.
right always advances; left only catches up when validity demands it.left; when do you record the answer β while valid, or after restoring validity? That last choice is the whole difference between "longest" and "shortest."O(nΒ²) or worse.[left, right] has no repeat and you push right, you don't re-scan the whole thing β you only need to know if the new char already sits in the window. And when it does, sliding left backward is pointless: any earlier start contains the duplicate too. left only moves forward.lastSeen[char]. On each right, jump left to max(left, lastSeen[c] + 1) β past the previous copy. Record right β left + 1.right once and by left once β O(n).| right | char | lastSeen[c] β₯ left? | left | window | maxLen |
|---|---|---|---|---|---|
| 2 | c | β | 0 | abc | 3 |
| 3 | a | yes (0) | 1 | bca | 3 |
| 6 | b | yes (4) | 5 | cb | 3 |
Input "abcabcbb" β answer 3. Watch left jump forward on each repeat, never back.
| Ask | If yes β | Pattern |
|---|---|---|
| Contiguous subarray/substring? (not a subsequence) | sliding window is in play | β |
| Window size is given (k)? | add new, drop the one that fell off | Fixed (Β§4.1) |
| Find the longest window satisfying a constraint? | grow; shrink only while invalid; record after | Variable Β· longest (Β§4.2) |
| Find the shortest window satisfying a constraint? | grow until valid; shrink while valid; record during | Variable Β· shortest (Β§4.3) |
| Count subarrays with an exact property? | exactly(K) = atMost(K) β atMost(Kβ1) | Counting (Β§4.4) |
| Need the max/min within each window? | monotonic deque of indices | Monotonic deque (Β§4.5) |
| Words in the prompt | Reach for |
|---|---|
| "subarray/substring of size k", "every window of length k", "average of k" | Fixed Β§4.1 |
| "longest/maximum substring withβ¦", "no repeating", "at most k distinct", "β€ k replacements/flips" | Longest Β§4.2 |
| "shortest/minimum window/subarray that containsβ¦ / sums to β₯β¦" | Shortest Β§4.3 |
| "number of subarrays with exactly k distinct / sum = goal / k odds" | Counting Β§4.4 |
| "maximum/minimum in each window", "sliding window maximum" | Deque Β§4.5 |
Each = a one-line model, a π invariant, a template, a picture, and a π€ interview lens. The two variable templates differ in one line β where you record the answer.
βAdd the newcomer, drop the one that fell off the back.β
Setup: maintain a running window state for exactly k elements. Each step is O(1): add a[right], and once the window is full, subtract a[rightβk].
right, the window state describes exactly a[rightβk+1 .. right]. Because you remove precisely the element leaving and add the one entering, the state is always a faithful summary of the current k-window.// Maximum sum of any size-k subarray
function maxSumK(a: number[], k: number): number {
let sum = 0, best = -Infinity;
for (let right = 0; right < a.length; right++) {
sum += a[right]; // add newcomer
if (right >= k) sum -= a[right - k]; // drop the one that fell off
if (right >= k - 1) best = Math.max(best, sum); // window is full
}
return best;
}
O(1) β no re-summing the window. Β· Escalation: sum β average β "permutation/anagram in string" (a fixed window with a 26-length frequency match) β "first negative in every window" (a queue of negatives).βGrow greedily; shrink only while broken; measure after.β
Setup: expand right every step. If the window became invalid, shrink from left until it's valid again β then record the length.
[left, right] is the longest valid window ending at right. Since left only moves forward, a window that became invalid can't be fixed by an earlier left β so we never miss a longer valid window.// Longest substring with at most K distinct characters
function longestKDistinct(s: string, k: number): number {
const count = new Map<string, number>();
let left = 0, best = 0;
for (let right = 0; right < s.length; right++) {
count.set(s[right], (count.get(s[right]) ?? 0) + 1); // grow
while (count.size > k) { // shrink WHILE invalid
const c = s[left++];
const n = count.get(c)! - 1;
if (n === 0) count.delete(c); else count.set(c, n);
}
best = Math.max(best, right - left + 1); // record (now valid)
}
return best;
}
windowLen β maxFreq β€ k. The intentional bug everyone questions: maxFreq is never decreased on shrink. It can go stale β and that's fine, because a longer answer needs a larger maxFreq anyway, so a stale value never lets the window grow falsely.left never move backward (prove the amortized O(n))? Β· Escalation: no-repeat β at-most-K-distinct β "Max Consecutive Ones III" (β€ k zeros) β "Longest Repeating Char Replacement" (the stale-maxFreq argument above).βGrow until valid; then squeeze as much as it stays valid.β
Setup: mirror of 4.2. Expand until the window is valid; then shrink from left while still valid, recording the length during the shrink.
right before right moves again. Recording inside that loop catches every minimal window.// Minimum Size Subarray Sum β₯ target (positives only)
function minSubArrayLen(target: number, a: number[]): number {
let left = 0, sum = 0, best = Infinity;
for (let right = 0; right < a.length; right++) {
sum += a[right];
while (sum >= target) { // shrink WHILE valid
best = Math.min(best, right - left + 1); // record DURING
sum -= a[left++];
}
}
return best === Infinity ? 0 : best;
}
The one-line difference vs Β§4.2: longest shrinks while invalid and records after; shortest shrinks while valid and records during. Same skeleton, opposite trigger.
need frequency map + a formed counter that tracks how many required chars are fully satisfied).βYou can't window βexactlyβ; window βat mostβ twice and subtract.β
Why: "exactly K" isn't monotonic (a valid window can become invalid and valid again as you grow), so a single window can't count it. But "at most K" is monotonic. So:
exactly(K) = atMost(K) β atMost(Kβ1).
atMost window [left, right] that's valid, every subarray ending at right and starting in [left, right] is also valid. That's right β left + 1 new subarrays β add it each step.// Subarrays with exactly K distinct integers
function subarraysKDistinct(a: number[], k: number): number {
const atMost = (k: number): number => {
if (k < 0) return 0;
const count = new Map<number, number>();
let left = 0, res = 0;
for (let right = 0; right < a.length; right++) {
count.set(a[right], (count.get(a[right]) ?? 0) + 1);
while (count.size > k) {
const n = count.get(a[left])! - 1;
if (n === 0) count.delete(a[left]); else count.set(a[left], n);
left++;
}
res += right - left + 1; // # of valid subarrays ending at right
}
return res;
};
return atMost(k) - atMost(k - 1);
}
exactly = atMost(K) β atMost(Kβ1), and justify res += rightβleft+1. Β· Escalation: K distinct β "Binary Subarrays With Sum = goal" β "Count Number of Nice Subarrays" (exactly k odd numbers; same trick on parity).βKeep a deque of candidates in decreasing order; the front is the answer.β
Setup: store indices in a deque whose values are strictly decreasing. The front is always the window max. Pop the back while it's β€ the newcomer (it can never be the max again); pop the front when it slides out of the window.
O(n) for all windows combined.[rightβk+1, right]. A smaller-or-equal value behind a newer larger one is dominated forever, so dropping it loses nothing.// Sliding Window Maximum β max of every size-k window
function maxSlidingWindow(a: number[], k: number): number[] {
const dq: number[] = []; // indices; a[dq] strictly decreasing
let head = 0; // β head pointer β never shift(), O(1) evict
const res: number[] = [];
for (let i = 0; i < a.length; i++) {
while (dq.length > head && a[dq[dq.length - 1]] <= a[i]) dq.pop(); // keep decreasing
dq.push(i);
if (dq[head] <= i - k) head++; // evict out-of-window front β O(1), no shift
if (i >= k - 1) res.push(a[dq[head]]); // front = current max
}
return res;
}
O(n) (each index pushed/popped once), and why Array.shift() would silently make this O(nΒ²) β a head pointer keeps front-eviction O(1) since this deque only ever pops from the back and evicts from the front, never inserts at the front. Β· Escalation: max β min (mirror the comparison) β "Longest subarray with abs(maxβmin) β€ limit" (run two deques at once).| Pattern | Time | Space | Note |
|---|---|---|---|
| Fixed window | O(n) | O(1) / O(Ο) | Ο = alphabet if a frequency map is needed |
| Variable β longest / shortest | O(n) | O(Ο) | left + right each traverse β€ n |
| Counting (at-most Γ2) | O(n) | O(Ο) | two linear passes, still linear |
| Monotonic deque | O(n) | O(k) | each index pushed/popped once |
right advances n times; left advances at most n times total across the whole run (never resets). So even though there's an inner while, the combined work is O(n) β say this explicitly, don't let the nested loop scare you into O(nΒ²).| Bug | The input that exposes it |
|---|---|
Shrinking with if instead of while (longest) β one removal may not restore validity. | "aaab", k=1 distinct β an if leaves 2 distinct in the window. |
| Recording the answer at the wrong moment (longest = after; shortest = during). | Min-window on [2,3,1,2,4,3], target 7 β recording after the shrink loop misses length 2 ([4,3]). |
Off-by-one window length (right β left vs + 1). | Any single-element answer, e.g. target = a[i] exactly β should report length 1. |
| Fixed window: forgetting to subtract the element that left. | [1,1,1,1], k=2 β window sum keeps growing past 2 elements. |
| Using a naive window with negative numbers. | [2,-1,2], target 3 β growing doesn't monotonically raise the sum; misses / mis-shrinks. |
| Deque stores values, not indices β can't evict by position. | Any window where the max repeats, e.g. [5,3,5], k=2. |
Frequency map keeps zero entries β size overcounts distinct. | at-most-k on "aba", k=1 β stale a:0 inflates size. |
// 560 Subarray Sum Equals K β NEGATIVES break the window β prefix sums + counts
function subarraySum(nums: number[], k: number): number {
const freq = new Map<number, number>([[0, 1]]); // empty prefix, so subarrays starting at 0 count
let sum = 0, count = 0;
for (const x of nums) {
sum += x;
count += freq.get(sum - k) ?? 0; // earlier prefixes that leave exactly k
freq.set(sum, (freq.get(sum) ?? 0) + 1); // insert AFTER the lookup
}
return count;
}
// 219 Contains Duplicate II β fixed window of the last k values
function containsNearbyDuplicate(nums: number[], k: number): boolean {
const win = new Set<number>();
for (let i = 0; i < nums.length; i++) {
if (win.has(nums[i])) return true;
win.add(nums[i]);
if (win.size > k) win.delete(nums[i - k]); // evict AFTER adding
}
return false;
}
{0: 1} and look up before inserting the current prefix. 219: the window holds at most k elements.Own each pattern and these are in range. Bold = Hard tier.
| Pattern | Representative LeetCode | Tier |
|---|---|---|
| Fixed window | 643 Max Average Β· 567 Permutation in String Β· 438 Find All Anagrams Β· 219 Contains Duplicate II | Med |
| Longest variable | 3 Longest Substring No Repeat Β· 424 Char Replacement Β· 340 K Distinct | Med |
| Shortest variable | 209 Min Size Subarray Sum Β· 76 Minimum Window Substring | Med β Hard |
| Exactly-K (atMost trick) | 930 Binary Subarrays Β· 992 Subarrays w/ K Distinct Β· 1248 Β· 560 Subarray Sum = K (negatives β prefix sums) | Med β Hard |
| Monotonic deque | 239 Sliding Window Maximum Β· 1438 Limit β€ abs diff | Med β Hard |
Cover the right column (blurred β hover/tap to reveal). Say your answer out loud first.
| Prompt | Answer |
|---|---|
| What is the one-line difference between the longest and shortest variable templates? | Longest shrinks while invalid and records after; shortest shrinks while valid and records during. |
Why is the whole scan O(n) despite a nested while? | left only moves forward and never resets, so it advances β€ n times total across the run. Amortized linear. |
| How do you count subarrays with exactly K distinct? | atMost(K) β atMost(Kβ1). "Exactly" isn't monotonic; "at most" is. |
In an atMost window, why add right β left + 1? | Every subarray ending at right and starting anywhere in [left,right] is also valid β that's how many new ones end at right. |
| Why does the monotonic deque store indices, not values? | To evict the front when it slides out of the window (a position check) β values alone can't tell you that. |
| When does sliding window fail? | Negative numbers (sum not monotonic) or non-contiguous (subsequence) problems; the shrink-once invariant breaks. |
Longest Repeating Char Replacement: why is the stale maxFreq not a bug? | A longer answer needs a larger maxFreq; a stale (too-small) value can only under-grow the window, never falsely grow it. |
| Why can't you use a sliding window for Subarray Sum Equals K? | Negative numbers: extending the window can lower the sum, so shrinking has no monotone rule. Use prefix sums and count earlier prefixes equal to sum β k. |
| Min Window Subsequence β why scan backward after the forward pass? | The forward pass fixes the earliest end; walking backward matching t in reverse finds the tightest start for that end. |
maxSumK and anagram-in-string (26-freq match)longestKDistinct + the replacement-trap variantminSubArrayLen and minimum-window-substringsubarraysKDistinct via atMostmaxSlidingWindowFor each: write the validity condition and where you record the answer as a comment first, then the loop.