Part I · Arrays, Strings and Pointers Pattern 1 4 problems

Sliding Window

Turn a nested loop over every subarray into a single pass, by never recomputing what you already know.

The sliding window is the first pattern worth learning because it converts the most common brute force in interviews, check every subarray, from quadratic to linear. The brute force is O(n²) or O(n²·k). The window is O(n). The gap is the whole question.

Contents

  1. When to use
  2. Core idea
  3. The two templates
  4. Common mistakes
  5. Maximum Subarray of Size K
  6. Longest Substring Without Repeating Characters
  7. Minimum Size Subarray Sum
  8. Minimum Window Substring
  9. Recap

When to use

The trigger. The input is a linear structure, an array, a string or a linked list. You are asked for the longest, shortest, or best contiguous run that meets some condition. The word to listen for is contiguous: subarray, substring, window, or a run of k in a row.

Three signals put together tell you it is a window problem.

SignalWhat it looks like in the prompt
Contiguity“subarray”, “substring”, “consecutive”, “in a row”. Not “subsequence”.
An extremum“longest”, “shortest”, “maximum sum”, “minimum length”, “count how many”.
A monotone conditionGrowing the window can only make the condition harder, shrinking it can only make it easier. Sums of non-negative numbers, distinct-character counts, and “at most k of X” all behave this way.

When not to use it

The negative-number trap. “Smallest subarray with sum at least target” is a clean window problem when all values are positive. Put one negative number in the array and the window breaks, because total is no longer monotone in the window width. Always ask the interviewer whether values can be negative. Asking earns points; assuming loses them.

Core idea

Keep two indices, left and right, marking a half-open run s[left..right]. Keep alongside them a small summary of what is inside: a running sum, a character count, a number of distinct values. Move right forward to grow, move left forward to shrink. Each move updates the summary in O(1). Nothing is ever recomputed from scratch.

The brute force recomputes the summary of each candidate run from its own elements, so an element is touched once per window that contains it. The window instead updates the summary incrementally: one addition when an element enters, one subtraction when it leaves.

step t 2 1 5 1 3 2 left right window sum = 7 slide one step step t+1 2 1 5 1 3 2 7 − 1 + 3 = 9 two arithmetic operations, not three
Figure 1.1 — A fixed window of width 3. Sliding costs one subtraction and one addition, whatever the window width.

Why the total work is linear

The inner while loop that shrinks the window looks like it might make the algorithm quadratic. It does not, and the reason is worth being able to state cleanly in an interview.

The amortised argument. left and right each only ever increase, and neither can exceed n. So across the whole run there are at most n increments of right and at most n increments of left, giving at most 2n pointer moves in total. Each move does O(1) work. Total: O(n), no matter how the inner loop is distributed.

Put another way: every element enters the window exactly once and leaves it at most once. Two touches per element.

The invariant

Every window problem has one sentence that is true at the top of each iteration. Write it as a comment. It is how you convince yourself, and the interviewer, that the code is right.

The two templates

Nearly every window problem is one of these two. Learn both by heart.

Template A — fixed width k
def fixed_window(nums: list[int], k: int) -> int:
    """Best value over every window of exactly k elements."""
    # Pay for the first window once. [:k] = indices 0..k-1, exactly k items.
    window = sum(nums[:k])
    best = window                    # the first window is the best seen so far

    # right: index of the item entering the window on this pass.
    # Invariant: at the top, window == sum(nums[right - k : right]).
    for right in range(k, len(nums)):  # start at k: items 0..k-1 are already summed
        # right - k: the item k steps back is the one leaving on the left.
        window += nums[right] - nums[right - k]   # add entering, drop leaving
        best = max(best, window)     # keep the best window seen so far

    return best

Use when the prompt names the width. There is no left variable, because left is always right - k + 1.

Template B — variable width
def variable_window(items: list[int]) -> int:
    """Longest (or shortest) window satisfying some condition."""
    left = 0                         # 0: the window starts at the first item
    # state: the running summary of the window. A sum here, so empty starts at 0.
    state = 0
    best = 0                         # 0: no window recorded yet

    # is_invalid(state): placeholder test for "the window broke the rule".
    # Longest Substring: a character now appears twice. Min Window: never used,
    # that problem shrinks while the window is still valid instead.
    # right: index of the item that just entered.
    # Invariant: at the top, items[left..right-1] is a legal window.
    for right, value in enumerate(items):
        state += value               # 1. grow: value enters on the right

        # Drop items from the left until the rule holds again.
        while is_invalid(state):     # 2. shrink until the window is legal again
            state -= items[left]     # undo the leftmost item's share of state
            left += 1                # +1: the window now starts one step right

        # right - left + 1: window length. +1 because both ends count.
        best = max(best, right - left + 1)   # 3. record

    return best

Three beats: grow, shrink, record. For a longest answer you record after the shrink loop. For a shortest answer you record inside the shrink loop, because each shrink step gives a smaller valid window.

rule: window sum ≤ 7 1. grow 2 3 1 4 2 left right 4 enters: sum 6 + 4 = 10 10 > 7, so the window is illegal 2. shrink 2 3 1 4 2 left right drop 2: sum 8, still illegal drop 3: sum 5, legal again 3. record 2 3 1 4 2 left right length = right − left + 1 = 2 best = max(3, 2) = 3
Figure 1.2 — One pass of Template B is grow, shrink until legal, then record.

Reading the figure. Blue cells are in the window. Amber is the cell that just entered. Red cells were dropped from the left. Green is the legal window that gets recorded. Notice that left only moves right.

The one rule that decides where best goes. Ask what the while condition means. If the loop runs while the window is illegal, the window is legal only after the loop, so record after. If the loop runs while the window is legal (you are shrinking a valid window to find the tightest one), record inside.

Common mistakes

The problems

1. Maximum Subarray of Size K Easy

Problem

Given an array of integers nums and an integer k, return the maximum sum of any contiguous subarray of length exactly k.

Approach

Solution

def max_subarray_sum_of_size_k(nums: list[int], k: int) -> int:
    """Return the largest sum of any contiguous subarray of length k.

    Args:
        nums: Input numbers. Values may be negative.
        k: Window length. Must satisfy 1 <= k <= len(nums).

    Returns:
        The maximum window sum.

    Raises:
        ValueError: If k is outside 1..len(nums).

    Example:
        >>> max_subarray_sum_of_size_k([2, 1, 5, 1, 3, 2], 3)
        9
    """
    # 1: a window needs at least one item. len(nums): it cannot be bigger.
    if not 1 <= k <= len(nums):
        raise ValueError(f"k must be in 1..{len(nums)}, got {k}")

    # Invariant: window_sum == sum(nums[right - k + 1 : right + 1])
    # (true after each update). The +1s give the k items that end at right.
    window_sum = sum(nums[:k])       # [:k]: the first window, indices 0..k-1
    best = window_sum                # the first window is the best so far

    # right: index of the item entering. Start at k: 0..k-1 are already summed.
    for right in range(k, len(nums)):
        # right - k: the item k steps back slides out on the left.
        window_sum += nums[right] - nums[right - k]
        best = max(best, window_sum) # keep the largest window sum

    return best

Walkthrough

With nums = [2, 1, 5, 1, 3, 2] and k = 3:

rightentersleaveswindow_sumbest
—2, 1, 5—88
31278
43199
52569
first window 2 1 5 1 3 2 2 + 1 + 5 = 8 best = 8 right = 3 2 1 5 1 3 2 8 − 2 + 1 = 7 best = 8 right = 4 2 1 5 1 3 2 7 − 1 + 3 = 9 best = 9 right = 5 2 1 5 1 3 2 9 − 5 + 2 = 6 best = 9
Figure 1.3 — Each slide costs one add and one subtract, and the best window is 5, 1, 3 with sum 9.

Reading the figure. Blue cells are in the window. Amber is the cell entering on the right. Red is the cell leaving on the left. The green box marks the best window seen.

TimeO(n)SpaceO(1)

Edge cases to raise

Say this out loud: “Neighbouring windows share k - 1 elements, so I only need the difference between them, which makes each step constant time.”

2. Longest Substring Without Repeating Characters Medium

Problem

Given a string s, return the length of the longest substring with no repeated characters. For "abcabcbb" the answer is 3, from "abc".

Approach

Solution

def length_of_longest_substring(s: str) -> int:
    """Length of the longest substring of s with all-distinct characters.

    Args:
        s: Input string. May be empty.

    Returns:
        The length of the longest run with no repeats. 0 for an empty string.

    Example:
        >>> length_of_longest_substring("pwwkew")
        3
    """
    last_seen: dict[str, int] = {}   # character -> index of its last occurrence
    left = 0                         # first index inside the window. 0: start of s
    best = 0                         # 0: right answer for an empty string

    # right: index of the newest character. Invariant at the top of each pass:
    # s[left..right-1] has no repeats, and best is the longest such run so far.
    for right, ch in enumerate(s):
        # Only a repeat *inside* the current window forces a jump.
        # >= left: an older copy before left is already outside, so ignore it.
        if ch in last_seen and last_seen[ch] >= left:
            # + 1: start just past the old copy, so ch appears once again.
            left = last_seen[ch] + 1

        last_seen[ch] = right        # remember the newest position of ch
        # + 1: right - left counts gaps. Add 1 so both ends count.
        best = max(best, right - left + 1)

    return best

Why the >= left check matters

The dictionary remembers characters that have already fallen out of the window. Take "abba". At right = 3 the character 'a' was last seen at index 0, but left is already 2, so 'a' is not in the window and no jump is needed. Without the guard, left would move backwards to 1 and the answer would be wrong. This one comparison is what the interviewer is watching for.

Walkthrough

s = "abba":

rightchjump?leftwindowbest
0ano0"a"1
1bno0"ab"2
2byes, to 22"b"2
3ano, 0 < left2"ba"2
right = 0 a b b a left, right a is new. window "a", best 1 right = 1 a b b a left right b is new. window "ab", best 2 right = 2 a b b a left, right b was last seen at 1, and 1 ≥ left jump left to 2. window "b", best 2 right = 3 a b b a left right a was last seen at 0, but 0 < left stale entry, no jump. window "ba"
Figure 1.4 — The jump uses the last index of each character, and the guard ignores an index that is already outside the window.

Reading the figure. Blue cells are in the window. Amber is the new character. Red cells were skipped by the jump. In the last row the saved index of a is 0, which is left of left, so it must not move left back.

TimeO(n)SpaceO(min(n, σ))σalphabet size

Edge cases to raise

Say this out loud: “When I hit a repeat I do not shrink one step at a time, I jump left past the previous occurrence, because every start before that point is already invalid.”

3. Minimum Size Subarray Sum Medium

Problem

Given an array of positive integers nums and a positive integer target, return the minimal length of a contiguous subarray whose sum is at least target. Return 0 if no such subarray exists.

Approach

Solution

import math


def min_subarray_len(target: int, nums: list[int]) -> int:
    """Shortest contiguous subarray of nums with sum >= target.

    Assumes every value in nums is positive, which is what makes the
    running total monotone in the window width and the shrink step valid.

    Args:
        target: Required minimum sum, positive.
        nums: Positive integers.

    Returns:
        The length of the shortest qualifying subarray, or 0 if there is none.

    Example:
        >>> min_subarray_len(7, [2, 3, 1, 2, 4, 3])
        2
    """
    left = 0                         # 0: the window starts at the first item
    total = 0                        # 0: the sum of an empty window
    best = math.inf                  # inf: "no answer yet". Any real length beats it.

    # right: index of the newest item. Invariant at the top of each pass:
    # total == sum(nums[left:right]) and total < target.
    for right, value in enumerate(nums):
        total += value               # value joins on the right

        # The window is valid. Squeeze it from the left while it stays valid.
        while total >= target:
            # + 1: both ends count, so the length is right - left + 1.
            best = min(best, right - left + 1)
            total -= nums[left]      # the leftmost item leaves
            left += 1                # +1: the window now starts one step right

    # 0: the agreed answer when no window reaches target.
    # int(): best is still a float type because it started as math.inf.
    return 0 if best == math.inf else int(best)

Walkthrough

target = 7, nums = [2, 3, 1, 2, 4, 3]:

righttotal after addshrink stepsbest
0..22, 5, 6none, below target∞
38drop 2 → 6, left=14
410drop 3 → 7, drop 1 → 6, left=33
59drop 2 → 7, drop 4 → 3, left=52
right 0..2 2 3 1 2 4 3 total 2, 5, 6: below 7, just grow right = 3 2 3 1 2 4 3 total 8: record 4, drop 2 → 6 best = 4 right = 4 2 3 1 2 4 3 total 10: record 4, drop 3 → 7 record 3, drop 1 → 6. best = 3 right = 5 2 3 1 2 4 3 total 9: record 3, drop 2 → 7 record 2 from [4, 3]. best = 2
Figure 1.5 — The window records a length inside the shrink loop, and the shortest valid window is 4, 3.

Reading the figure. Blue cells are in the window after the shrink. Red cells were dropped while the total stayed at least 7. Green is the answer window of length 2. Each record happens before the drop.

The answer is 2, from [4, 3].

TimeO(n)SpaceO(1)

The follow-up you will be asked

“What if the numbers can be negative?” The window collapses, because adding an element can lower the total, so “shrink while valid” is no longer safe. Switch to prefix sums plus a monotonic deque, which gives O(n), or prefix sums in a sorted structure for O(n log n). Naming the failure and the replacement is the answer they want.
There is also an O(n log n) solution for the positive case: build the prefix-sum array, which is strictly increasing, then for each end index binary-search for the earliest start whose prefix is small enough. Worth mentioning as a second approach, but the window is strictly better.

Edge cases to raise

Say this out loud: “Because all values are positive the total only grows as the window widens, so the first time it crosses the target I can shrink greedily and stay correct.”

4. Minimum Window Substring Hard

Problem

Given strings s and t, return the shortest substring of s that contains every character of t, counting repeats. Return "" if there is none. For s = "ADOBECODEBANC" and t = "ABC" the answer is "BANC".

Approach

Solution

import math
from collections import Counter


def min_window(s: str, t: str) -> str:
    """Shortest substring of s containing every character of t, with repeats.

    Args:
        s: The string to search.
        t: The required characters, with multiplicity.

    Returns:
        The shortest qualifying substring, or "" if none exists.

    Example:
        >>> min_window("ADOBECODEBANC", "ABC")
        'BANC'
    """
    # Empty input, or t longer than s: no window can hold all of t.
    if not s or not t or len(t) > len(s):
        return ""

    need: Counter[str] = Counter(t)   # >0 means still owed, <0 means surplus
    missing = len(t)                  # required characters not yet covered
    # 0: placeholder start. inf: "no window yet", so any real length beats it.
    best_start, best_len = 0, math.inf
    left = 0                          # 0: the window starts at the first char

    # right: index of the newest char. Invariant at the top of each pass:
    # missing > 0, so s[left..right-1] does not cover t yet.
    for right, ch in enumerate(s):
        # Grow. A positive need means this character actually pays down a debt.
        if need[ch] > 0:              # > 0: t still wants a copy of ch
            missing -= 1              # -1: one fewer required char owed
        need[ch] -= 1                 # -1: the window holds one more ch

        # Valid window. Shrink from the left while it stays valid.
        while missing == 0:           # 0: nothing owed, so the window covers t
            # + 1: both ends count. Keep this window if it is the shortest yet.
            if right - left + 1 < best_len:
                best_start, best_len = left, right - left + 1

            leaving = s[left]
            need[leaving] += 1        # +1: the window hands this char back
            if need[leaving] > 0:     # we just gave back a character we needed
                missing += 1          # +1: one required char is owed again
            left += 1                 # +1: the window now starts one step right

    # inf: no window ever covered t. Else take best_len chars from best_start.
    return "" if best_len == math.inf else s[best_start : best_start + best_len]

Why the counter is allowed to go negative

This is the subtle part, and the reason the code is short. Suppose t = "AB" and the window is "AAB". Then need['A'] == -1. When the leftmost 'A' leaves, need['A'] becomes 0, which is not positive, so missing stays at 0 and the window is still valid, correctly. Only when a genuinely needed character leaves does need rise above zero and missing tick back up. The sign of the counter encodes surplus versus debt for free.

Walkthrough

s = "ADOBECODEBANC", t = "ABC". The window first becomes valid at "ADOBEC", length 6. Shrinking is blocked immediately, because dropping 'A' breaks it. Later the window "CODEBA" is valid at length 6, then "BANC" at length 4, which is the answer.

First valid windowLengthbest
ADOBEC6ADOBEC
CODEBA6ADOBEC
BANC4BANC
s = ADOBECODEBANC, t = ABC 0 A 1 D 2 O 3 B 4 E 5 C 6 O 7 D 8 E 9 B 10 A 11 N 12 C ADOBEC, length 6 CODEBA, length 6 BANC, length 4
Figure 1.6 — Three windows become valid in turn, and BANC is the shortest one.

Reading the figure. Violet cells are the characters of t. Each bar is a window at the moment it becomes valid, with missing == 0. Blue bars are valid but not the best. The green bar is the answer.

TimeO(|s| + |t|)SpaceO(|t|)
On the space bound. need also picks up entries for characters of s that are not in t, so strictly it is O(|s| + |t|) keys in the worst case. Guarding with if ch in need before touching the counter keeps it at O(|t|) and is worth a sentence if the interviewer pushes on memory.

Edge cases to raise

Say this out loud: “I keep one integer, missing, so checking whether the window is valid is O(1) instead of comparing two dictionaries. The counter goes negative to record surplus characters, which is what makes the shrink step correct.”

Recap

The six things to carry forward

Where this goes next

Pattern 2, Two Pointers, keeps the two-index idea but changes the motion: instead of both pointers walking forward, they start at opposite ends and converge. That small change unlocks sorted-array problems the window cannot touch.


← All 12 patterns 2 — Two Pointers →