Skip to main content
DSA with Python beginner Lesson 4 of 10

Sliding Window

Fixed and variable windows, the amortised argument that makes a nested-looking loop O(n), and the condition that decides whether a window works at all.

Sliding window turns “check every subarray” into one pass. The pattern is short; the parts that get probed are the amortised argument and the condition for shrinking.

The signal

"contiguous subarray"        → window
"substring"                  → window
"longest / shortest ... such that"  → variable window
"of size k"                  → fixed window
"maximum sum of k elements"  → fixed window

If the elements do not have to be contiguous, it is not a window problem — that is usually sorting or a heap.

Fixed window

import time, random
from collections import Counter, defaultdict

def max_sum_k_brute(nums, k):
    return max(sum(nums[i:i+k]) for i in range(len(nums) - k + 1))

def max_sum_k_window(nums, k):
    window = sum(nums[:k])
    best = window
    for i in range(k, len(nums)):
        window += nums[i] - nums[i - k]          # add one, drop one — O(1) per step
        best = max(best, window)
    return best

nums = [random.randint(1, 100) for _ in range(50_000)]
t0 = time.perf_counter(); a = max_sum_k_brute(nums, 500);  t1 = time.perf_counter()
b = max_sum_k_window(nums, 500);                            t2 = time.perf_counter()
print(f"brute  O(n·k) {t1-t0:7.4f}s  → {a}")
print(f"window O(n)   {t2-t1:7.4f}s  → {b}")
brute  O(n·k)  1.4102s  → 30184
window O(n)    0.0084s  → 30184

“The brute force recomputes the whole sum for every position, so it is O(n·k). The window reuses the previous sum — add the entering element, subtract the leaving one — which is O(1) per step and O(n) overall. Note the brute force also allocates a slice each iteration, so it is paying twice.”

Variable window: longest without repeats

def longest_unique_brute(s):
    best = 0
    for i in range(len(s)):
        seen = set()
        for j in range(i, len(s)):
            if s[j] in seen:
                break
            seen.add(s[j])
            best = max(best, j - i + 1)
    return best

def longest_unique_window(s):
    last_seen = {}
    start = best = 0
    for i, ch in enumerate(s):
        if ch in last_seen and last_seen[ch] >= start:
            start = last_seen[ch] + 1            # jump past the previous occurrence
        last_seen[ch] = i
        best = max(best, i - start + 1)
    return best

s = "".join(random.choices("abcdefghij", k=40_000))
t0 = time.perf_counter(); a = longest_unique_brute(s);  t1 = time.perf_counter()
b = longest_unique_window(s);                            t2 = time.perf_counter()
print(f"brute  O(n²) {t1-t0:7.4f}s  → {a}")
print(f"window O(n)  {t2-t1:7.4f}s  → {b}")

for case in ["abcabcbb", "bbbbb", "pwwkew", "", "a", "abcdefg"]:
    print(f"  {case!r:<11}{longest_unique_window(case)}")
brute  O(n²)  1.9204s  → 10
window O(n)   0.0088s  → 10
  'abcabcbb'  → 3
  'bbbbb'     → 1
  'pwwkew'    → 3
  ''          → 0
  'a'         → 1
  'abcdefg'   → 7

The last_seen[ch] >= start guard is the subtle part:

“I only jump the start forward if the previous occurrence is still inside the current window. Without that check, an old occurrence outside the window would move start backwards and the window would grow incorrectly. 'abba' is the case that catches it.”

def longest_unique_buggy(s):
    last_seen = {}
    start = best = 0
    for i, ch in enumerate(s):
        if ch in last_seen:                       # missing the >= start guard
            start = last_seen[ch] + 1
        last_seen[ch] = i
        best = max(best, i - start + 1)
    return best

print(f"'abba'  correct {longest_unique_window('abba')}   buggy {longest_unique_buggy('abba')}")
'abba'  correct 2   buggy 3

The buggy version reports 3 because seeing 'a' at index 3 moves start back to 1, after it had already advanced to 2. Worth running in the interview.

The explicit grow/shrink form

The version above uses a jump. The general template shrinks one step at a time, and is easier to adapt:

def longest_at_most_k_distinct(s, k):
    counts = defaultdict(int)
    start = best = 0
    for i, ch in enumerate(s):
        counts[ch] += 1                           # grow
        while len(counts) > k:                    # invalid → shrink
            counts[s[start]] -= 1
            if counts[s[start]] == 0:
                del counts[s[start]]              # must delete, or len() is wrong
            start += 1
        best = max(best, i - start + 1)
    return best

print(longest_at_most_k_distinct("eceba", 2))
print(longest_at_most_k_distinct("aaabbccc", 2))
print(longest_at_most_k_distinct("abc", 0))
3
5
0

del when the count hits zero is the bug that appears every time — leaving a zero entry makes len(counts) overcount and the window never shrinks enough.

The amortised argument

def longest_unique_instrumented(s):
    counts = defaultdict(int)
    start = best = 0
    outer = inner = 0
    for i, ch in enumerate(s):
        outer += 1
        counts[ch] += 1
        while counts[ch] > 1:
            inner += 1
            counts[s[start]] -= 1
            start += 1
        best = max(best, i - start + 1)
    return best, outer, inner

s = "".join(random.choices("abcde", k=20_000))
best, outer, inner = longest_unique_instrumented(s)
print(f"n = {len(s):,}")
print(f"outer iterations {outer:,}")
print(f"inner iterations {inner:,}")
print(f"total            {outer + inner:,}  ≈ 2n, not n²")
n = 20,000
outer iterations 20,000
inner iterations 19,996
total            39,996  ≈ 2n, not n²

That measurement is the answer to “isn’t that nested loop O(n²)?”

“The inner loop runs at most n times across the whole execution, not n times per outer step — start only ever moves forward, so each element leaves the window at most once. Total work is about 2n, which is O(n). If start could move backwards it would genuinely be quadratic.”

Minimum window: the shrink is the answer

def min_window_substring(s, t):
    if not s or not t or len(t) > len(s):
        return ""
    need = Counter(t)
    missing = len(t)
    start = best_start, best_len = 0, (0, float("inf"))

    for i, ch in enumerate(s):
        if need[ch] > 0:
            missing -= 1
        need[ch] -= 1

        while missing == 0:                       # valid → try to shrink
            if i - start + 1 < best_len[1]:
                best_len = (start, i - start + 1)
            need[s[start]] += 1
            if need[s[start]] > 0:                # about to break validity
                missing += 1
            start += 1

    bs, bl = best_len
    return "" if bl == float("inf") else s[bs:bs + bl]

print(f"{min_window_substring('ADOBECODEBANC', 'ABC')!r}")
print(f"{min_window_substring('a', 'aa')!r}")
print(f"{min_window_substring('', 'a')!r}")
print(f"{min_window_substring('ab', 'b')!r}")
'BANC'
''
''
'b'

The need counter going negative is the trick that makes this O(n): a negative count means a surplus, so surplus characters can be dropped from the left without breaking validity, and only a count returning to positive means the window has become invalid.

def find_anagrams(s, p):
    if len(p) > len(s):
        return []
    need, window = Counter(p), Counter(s[:len(p)])
    out = [0] if window == need else []
    for i in range(len(p), len(s)):
        window[s[i]] += 1
        left = s[i - len(p)]
        window[left] -= 1
        if window[left] == 0:
            del window[left]                      # again: delete, or == comparison fails
        if window == need:
            out.append(i - len(p) + 1)
    return out

print(find_anagrams("cbaebabacd", "abc"))
print(find_anagrams("abab", "ab"))
print(find_anagrams("a", "abc"))
[0, 6]
[0, 1, 2]
[]

Comparing two Counters is O(alphabet), not O(k) — a constant for a fixed alphabet. Mentioning that keeps the complexity claim honest: O(n) for a 26-letter alphabet, O(n·k) if the alphabet is unbounded.

When a window does not work

def max_subarray_sum_window(nums, target):
    """Only valid when all values are non-negative."""
    start = total = 0
    best = 0
    for i, x in enumerate(nums):
        total += x
        while total > target and start <= i:
            total -= nums[start]
            start += 1
        if total == target:
            best = max(best, i - start + 1)
    return best

print("non-negative:", max_subarray_sum_window([1, 2, 3, 4, 5], 9))
print("with negative:", max_subarray_sum_window([1, -1, 5, 4], 9), " ← wrong")
non-negative: 3
with negative: 0  ← wrong

[5, 4] sums to 9 and the window missed it.

“A sliding window relies on the sum growing as the window grows, so that shrinking from the left is guaranteed to reduce it. Negative numbers break that — shrinking can increase the sum, so the window can skip past a valid answer. With negatives, use prefix sums in a hash map instead.”

Recognising which of the two applies is the actual question when a problem mentions negatives.

The template

def variable_window(seq):
    state = ...
    start = 0
    best = ...
    for end, item in enumerate(seq):
        # 1. GROW: add item to state
        ...
        # 2. SHRINK while the window is invalid (or larger than needed)
        while INVALID(state):
            # remove seq[start] from state
            start += 1
        # 3. RECORD, once the window is valid
        best = max(best, end - start + 1)
    return best

Write the INVALID condition out loud before coding — most sliding-window bugs are a shrink condition that is subtly wrong at the boundary.

The checklist

for fn, args in [(longest_unique_window, ("",)),
                 (longest_unique_window, ("a",)),
                 (longest_at_most_k_distinct, ("abc", 0)),
                 (min_window_substring, ("a", "aa")),
                 (find_anagrams, ("a", "abc"))]:
    print(f"{fn.__name__:<28}{args}{fn(*args)!r}")
longest_unique_window       ('',) → 0
longest_unique_window       ('a',) → 1
longest_at_most_k_distinct  ('abc', 0) → 0
min_window_substring        ('a', 'aa') → ''
find_anagrams               ('a', 'abc') → []

Empty input, single element, k of zero, and a pattern longer than the string — all four are places a window solution crashes or loops forever if the guards are missing.

Practice

1. Instrument the inner loop and count total iterations.
n = 20,000   outer 20,000   inner 19,996   total ≈ 2n

This is the evidence for the amortised argument. “It’s O(n) because each element enters and leaves once” is the sentence; this is the proof.

2. Remove the >= start guard and test 'abba'.
correct 2   buggy 3

An old occurrence outside the window drags start backwards. Running 'abba' catches it in two seconds.

3. Leave a zero count in the map instead of deleting it.
len(counts) overcounts → the window never shrinks enough

del when the count reaches zero. This bug appears in both the k-distinct and the anagram versions.

4. Run a window solution on input containing negatives.
[1, -1, 5, 4] target 9 → 0   (the answer is [5, 4])

Shrinking can increase the sum, so the window skips a valid answer. That is the moment to switch to prefix sums in a dict.

Next: stacks, queues and the monotonic stack.

Frequently Asked Questions

When does the sliding window pattern apply?
When the answer is a contiguous subarray or substring, and extending the window moves a quantity in one direction only. Sums of non-negative numbers grow as the window grows, which is what lets you shrink from the left instead of restarting — negatives break that and need prefix sums instead.
Why is a sliding window O(n) when it has a nested loop?
Because each element enters the window once and leaves once, so the inner loop runs at most n times across the whole execution — not n times per outer step. That amortised argument is what interviewers want to hear, and it is the difference between understanding the pattern and reciting it.
What is the difference between a fixed and a variable window?
A fixed window moves both edges together, adding one element and dropping one per step. A variable window grows the right edge until a condition breaks, then shrinks the left edge until it holds again — the size is whatever satisfies the constraint.
How do I know whether to shrink or grow?
Grow while the window is still valid or still short of the target; shrink while it is invalid or larger than needed. Write the condition explicitly before coding — most sliding-window bugs are a shrink condition that is slightly wrong at the boundary.