◈ python mapVol 3 · Ch 38/47
Volume 3 Python, from the metal up · chapter 38

38Two pointers & the sliding window

In Chapter 37 we bought O(1) lookups the honest way: a hash table, extra memory traded for speed. Here we chase that same collapse of a nested loop, but this time we pay nothing extra at all. The plan is two array patterns, back to back. First come two pointers that march in from both ends and let the data eliminate itself. Then comes a sliding window that patches its running answer as it moves instead of rebuilding it from scratch. The whole way through we keep asking the one question that turns O(n²) into O(n) — how much of this work did the previous step already do for me? By the end you'll look at a nested double loop and see, on sight, whether it's secretly a single sweep in disguise. And you'll know exactly how to set it free.

★ YOU ALREADY RUN THIS · the sliding windowThe seven-day step average on your wrist
Your watch shows a seven-day step average, and it refreshes at midnight without you ever noticing. Think about what the lazy version would have to do: haul back seven days of totals and add them up again, every night, forever. It doesn't. It holds one number. At midnight it subtracts the day that just fell off the back, last Tuesday, and adds the day that just ended. One subtraction, one addition, and the figure on your wrist is correct again. Ask it for a thirty-day average and the midnight work does not grow at all. Six of those seven days never changed.
the seven days on screenthe window, width k
last Tuesday falls off the backtotal -= a[i - k]
today's steps arrivetotal += a[i]
the six days that didn't movethe overlap — never recompute it
re-adding all seven each nightthe nested loop · O(n·k)
pin it: your watch never re-adds the week — it subtracts the day that left and adds the day that arrived, and that is the whole window.
iolinked · chapter 38 — the checkpoints6 steps
$ sections covered in Two pointers & the sliding window
01Two fingers, marching inward
02When it's sorted, every look kills half the mistakes
03The sliding window: never recompute the overlap
04A window that breathes — grow right, shrink left
05The minimum window — cover, then squeeze
06The one move: reuse the overlap

01Two fingers, marching inward

Let's start with the array you already know from Volume 1. It's a row of contiguous slots, where a[i] is a single multiply-and-jump — O(1) random access. The usual way to walk it is one index sliding left to right, and for most jobs that's exactly right. But watch for a particular shape of problem, one with a hidden symmetry: the answer depends on the two ends at once. For those, don't march in from one side. Put one finger on the first slot and one on the last, then step them toward each other until they meet.

One quick demystification before we go further, because the word "pointer" scares people who have heard C stories. A pointer here is nothing but a plain Python int sitting in a variable, holding an index. There's no address arithmetic, no memory magic, nothing you can dereference by accident. When we say "move the pointer," we mean lo += 1, an integer getting bigger by one. That's the whole machinery, and it's why the pattern costs O(1) extra space: two integers are all the state we carry, no matter how big the array grows. Keep the contrast with Chapter 37 in view as we go, too. The hash bought speed by spending memory, while this chapter buys speed by exploiting structure the data already has.

Reversing a list is the cleanest place to watch the pattern earn its keep. Swap the pair of values under the two fingers, then step both fingers one slot inward. When the fingers meet in the middle, the whole list is reversed and you're done. You've touched each slot exactly once, and you never allocated a second array.

both_ends.pypython
def reverse(a):
    lo, hi = 0, len(a) - 1
    while lo < hi:                    # stop the instant they meet
        a[lo], a[hi] = a[hi], a[lo]   # swap the two ends in one statement
        lo += 1; hi -= 1              # step both pointers inward
    return a

def is_palindrome(s):
    lo, hi = 0, len(s) - 1
    while lo < hi:
        if s[lo] != s[hi]:            # a single mismatch settles it — bail early
            return False
        lo += 1; hi -= 1
    return True

print("".join(reverse(list("REWARD"))))   # DRAWER   (3 swaps for 6 letters)
print(is_palindrome("racecar"))           # True
print(is_palindrome("python"))            # False

Read reverse line by line. lo, hi start at the two ends. The while lo < hi guard is the whole trick — the loop runs only while the fingers haven't crossed. Each pass swaps the pair they point at and pulls both inward by one. For 6 letters that's 3 swaps (I ran it: REWARDDRAWER); for n items it's exactly n // 2 swaps. Count the operations: one loop, running n/2 times, constant work inside — that's O(n) time. And crucially, O(1) extra space: no copy, no second array. is_palindrome is the same skeleton with a comparison instead of a swap, and it can bail early the moment two ends disagree.

Watch the early bail pay off. Run is_palindrome on REWARD: the very first comparison puts R against D, they disagree, and we return False after a single check — the other four letters never get looked at. Now run it on RACECAR: R=R, then A=A, then C=C, the fingers meet on the middle E, and it returns True after exactly 3 comparisons — that's n // 2 for 7 letters. A real palindrome pays the full n // 2; a mismatch usually pays far less, because it stops the instant two ends disagree.

start — swap ends, step inward R E W A R D lo hi swap 1 swap 2 · swap 3 (center pair) result D R A W E R
Fig — Two pointers converge: three swaps turn REWARD into DRAWER. n/2 swaps, one pass, zero extra memory. (Reverse-in-place is stepped in trace T32.)
Why in-place is the machine's favourite
The slick one-liner a[::-1] also reverses — but it allocates a whole new list of n references (O(n) space) and copies into it. The two-pointer version touches the array where it already lives: the slots are contiguous, so lo and hi walk straight through cache lines the CPU has already prefetched (Volume 1, chapter 2). Same Big-O, but no allocation and friendlier to the memory hierarchy. In pure CPython the C-level slice is often faster on wall-clock; the in-place pattern is what you reach for in a compiled language or when memory is tight.

Both-ends only helps when you act on both ends. But watch what happens when we point those two fingers at a sorted array and go hunting for a pair. →

02When it's sorted, every look kills half the mistakes

Here's the classic: find two numbers in an array that add up to a target. The brute force tries every pair with two nested loops, which costs n(n-1)/2 comparisons — O(n²). In the hashing chapter we crushed that to O(n) with a hash "seen" set. But the set costs O(n) extra space, and its one real virtue is that it works on any order. If the array is already sorted, two pointers do the job in O(n) time and O(1) space, with no hash at all.

It helps to see what the brute force is actually walking. Picture every candidate pair laid out as a grid, one row per choice of the first number, one column per choice of the second. An array of n values gives roughly n²/2 distinct pairs to try, and the nested loops grind through them one at a time. For n = 100,000 that is about five billion pairs. Hold that picture, because the sorted version is about to delete it a whole row at a time.

Put lo on the smallest value and hi on the largest, then look at their sum. Now comes the magic, and I want you to savour it, because this is the whole pattern. Since the array is sorted, that one comparison tells you exactly which pointer to move.

Here's the rule, and you can check it on your fingers. If the sum is too big, the only way down is to shrink the large side, so pull hi inward. If the sum is too small, the only way up is to grow the small side, so push lo forward. Try it with a target of 26 when the fingers read 2 and 40. The sum is 42, an overshoot. Now notice the stronger fact: 2 is the smallest value in the array, so 40 plus anything here overshoots. That means 40 can never be part of any answer, and pulling hi in discards it for good. One comparison has eliminated a number forever, and that's the engine of the whole walk.

Every single step permanently eliminates one number from all of its remaining pairings. That's a whole row, or a whole column, of the n² grid of candidate pairs, gone in one comparison. And since there are only n numbers to eliminate, the pointers must meet in at most n − 1 steps.

sorted_twosum.pypython
def two_sum_sorted(a, target):        # a is sorted ascending
    lo, hi = 0, len(a) - 1
    while lo < hi:
        s = a[lo] + a[hi]
        if s == target:
            return lo, hi             # found it
        elif s < target:
            lo += 1                    # too small -> raise the low end
        else:
            hi -= 1                    # too big  -> lower the high end
    return None

a = [2, 7, 11, 15, 20, 24, 40]
print(two_sum_sorted(a, 26))          # (0, 5)  ->  2 + 24 = 26,  found in 2 steps

I instrumented both versions to make this concrete. On that little array, the two-pointer walk found 2 + 24 = 26 in just 2 steps. On a sorted array of 100,000 numbers, it found a middle pair in 11,232 steps, comfortably under the n − 1 = 99,999 ceiling. The brute force would have needed up to n(n−1)/2 ≈ 5,000,000,000 comparisons on that same array. Five billion versus eleven thousand, from the same data, just by letting sortedness do the eliminating.

2 7 11 15 20 24 40 lo hi target = 26 2 + 40 = 42 too big → drop 40 one comparison eliminates one end — and every pair it belonged to
Fig — The sum overshoots, so the largest value (40) can't be in any answer — discard it and pull hi in. Each step deletes a whole family of pairs, so the search is O(n), not O(n²).
InteractiveWatch the two pointers close in
2 + 35 = 37 too big → move hi left target = 29
step 0
Every step throws away one end for good — so a converging walk finishes in at most n − 1 steps, never n².

Myth

"Finding a pair that sums to a target always needs a hash map for O(n)."

Reality

If the data is sorted, two converging pointers get you O(n) time with O(1) space. No hash, no extra memory, nothing allocated at all. The hash wins when the data is unsorted and you can't afford to sort it, and the two-pointer walk wins whenever the order is already there.

One honest caveat before we move on, because it matters in practice. If the data arrives unsorted, sorting it first costs O(n log n), and that dominates the O(n) walk after it. So "sort, then two-pointer" is an O(n log n) plan overall, slower in time than the hash's O(n). What you buy for that time is space, since the sort-then-walk route needs no hash table at all. And when the data is already sorted, or you needed it sorted anyway, the walk is pure profit. Engineering is choosing which of those costs your problem can actually afford.

The precondition is load-bearing
The whole argument — "too big means hi is hopeless" — is only true because the array is sorted. On unsorted data this returns wrong answers. Sorting first costs O(n log n) (that machinery lives elsewhere in this volume), so if you have to sort just for this, the O(n) hash from the hashing chapter is usually the better trade. Reach for two pointers when the data arrives sorted for free.

The same "move the pointer that can't possibly help" idea powers container with most water. Given a row of wall heights, pick the two walls that hold the most water between them. Start with the widest span, then always move the shorter wall inward. Here's why: moving the taller wall can only shrink the width, and it can never lift the limiting height. I ran it on [1,8,6,2,5,4,8,3,7] and got a max area of 49 in a single O(n) pass.

Let's do the area arithmetic, because it's the whole reason the rule is safe. Water trapped between two walls is width × the shorter of the two heights — the taller wall just spills over the short one, so the short wall caps the fill. Start at the full span of [1,8,6,2,5,4,8,3,7]: the ends are height 1 and height 7, width 8, so area = 8 × min(1,7) = 8. That left wall of height 1 is the limit. Moving the tall wall inward can only shrink the width while the 1 still caps the height, so it's pure loss — that's why we move the short wall. Do that until the ends read height 8 and height 7 with width 7: area = 7 × min(8,7) = 49, exactly the max the code reported.

So far the pointers move toward each other. Now aim them the same way — one leading, one trailing — and the gap between them becomes a window you drag across the data. →

03The sliding window: never recompute the overlap

New problem: in an array of numbers, find the largest sum of any k consecutive elements. The obvious approach is to take each starting position, add up its k elements, and keep the best. There are n − k + 1 windows, and each one costs k − 1 additions to sum. So the total is (n − k + 1)(k − 1), which is roughly n·k, and that's O(n²) when k grows with n. I ran it for n = 1000 and k = 100 and counted 89,199 additions.

Make it tiny enough to check by hand. Take a = [4, 2, 9, 7, 1] with k = 3. The windows are 4+2+9 = 15, then 2+9+7 = 18, then 9+7+1 = 17, so the best is 18. That's n − k + 1 = 3 windows at k − 1 = 2 additions each, 6 additions in total. Six is nothing, but the cost scales as the product n·k, and products of growing things are exactly what this volume has taught us to fear.

Now stare at two neighbouring windows, because the waste is hiding in plain sight there. They overlap in k − 1 elements, which is almost everything either window contains. The brute force re-adds that whole shared middle every single time it moves one step. That re-adding is the wasted work. Kill it: when the window slides one step right, the new sum is just the old sum, minus the element that fell off the left, plus the element that entered on the right. One subtraction and one addition — exactly 2 operations per step, no matter how big k is.

Run the delta on that same tiny array and watch it agree. The first window costs its full price: 4+2+9 = 15. To slide, drop the 4 that left and add the 7 that entered: 15 − 4 + 7 = 18. Slide again, drop the 2 and add the 1: 18 − 2 + 1 = 17. Those are the same three sums the brute force produced, but each slide cost 2 operations instead of a fresh re-sum — and if k were 1000, a slide would still cost exactly 2.

3 1 4 1 5 9 old window [0..3] shared by both windows — re-added by brute force, reused by sliding new window [1..4] − 3 (leaves) + 9 (enters)
Fig — Adjacent windows share k − 1 cells. Sliding computes the change — subtract the red leaver, add the green enterer — instead of re-summing the amber overlap. O(1) per step.
slide.pypython
def max_window(a, k):
    s = sum(a[:k])              # first window: k-1 additions, paid once
    best = s
    for r in range(k, len(a)):
        s += a[r] - a[r - k]    # +entering, -leaving : exactly 2 ops, any k
        if s > best:
            best = s
    return best

Let's read it line by line, starting with sum(a[:k]), which pays for the first window exactly one time. Then r walks the entering index across the rest of the array. The single line s += a[r] - a[r-k] is the entire idea. a[r] joins the window, a[r-k] leaves it, and s is patched in O(1). The leaving element sits exactly k slots back, so it's always the one falling off the left. There is no inner loop anywhere, so the total work is (k − 1) additions for the first window plus 2 per slide — about 2n, which is O(n). I ran it against the brute force and got the same answer from 1,899 additions instead of 89,199 — 47× fewer. On a bigger case, n = 200,000 with k = 1000, the wall-clock went from roughly 0.66 s → 0.014 s, about 46× faster on this machine. Timings vary by hardware, but the shape doesn't.

InteractiveSlide the window — count the work
+ slide (Δ) 0 ops recompute 0 ops cumulative additions to reach this window: sliding vs recomputing from scratch
0
Crank k up: the red "recompute" bar balloons while the green "slide" bar barely stirs — the bigger the overlap, the more sliding wins.
Where you meet this — and why it's literally called a "window"
The name isn't a metaphor programmers borrowed; it is the networking term. TCP's sliding window is how every internet connection controls flow: the receiver advertises how many bytes it can accept, and the sender slides a window of unacknowledged bytes forward as acks arrive — reusing the connection's state instead of renegotiating from zero. You also meet the pattern in rate limiting ("max 100 requests per 60 seconds" is a window of timestamps that slides with the clock), in streaming analytics and moving averages (a rolling mean updates by dropping the oldest sample and adding the newest — the exact subtract-left/add-right trick), and in log and text scanning.

Fixed-size windows are the easy half. The real power shows up when the window has to breathe — growing and shrinking on its own to keep some rule true. →

04A window that breathes — grow right, shrink left

Here's the next problem: find the length of the longest substring with no repeated character. This window isn't a fixed size like the last one was. It's defined by an invariant instead, a condition we promise to keep true: "the characters inside are all distinct." Keep two pointers, left and right. Push right forward to grow the window. The moment the new character is already inside, the invariant breaks, so pull left forward until it holds again. At every step the window is the longest valid one ending at right, so track the best length you ever see.

grow_shrink.pypython
def longest_unique(s):
    last = {}                        # char -> most recent index it appeared
    left = 0
    best = 0
    for right, ch in enumerate(s):
        if ch in last and last[ch] >= left:
            left = last[ch] + 1      # jump left PAST the earlier copy, in one move
        last[ch] = right
        best = max(best, right - left + 1)
    return best

print(longest_unique("abcabcbb"))    # 3   ("abc")
print(longest_unique("playlist"))    # 6   ("aylist")

Let's walk the code. last remembers the index where each character was last seen — a hash, because the hashing chapter never really leaves you. For each new ch at index right, we check whether we've seen it before. If its last position is inside the current window (>= left), we jump left to just past that old copy. That restores "all distinct" in a single move rather than one slot at a time. Then we record ch's new position and update best. I ran it: "abcabcbb" → 3, and our running example "playlist" → 6. That 6 is the window "aylist", found after the repeated l forces left forward.

Trace "playlist" by hand to feel that jump. The letters sit at indices 0–7: p l a y l i s t. right grows the window across p, l, a, y — all distinct, length 4 — until index 4 hands us a second l. Its last position was index 1, which is still inside the window, so left leaps from 0 straight to 2, one slot past that old l. Now the window is a, y, l, and it keeps growing through i, s, t with no further repeats, finishing as "aylist" at length 6. One clean jump, not four little single-slot steps — and that jump is the O(1) restore the last hash pays for.

Now pause on the cost, because this loop shape fools almost everyone at first. A left that moves inside a loop over right looks like a nested loop, and nested loops smell like O(n²). But count total travel instead of counting iterations. right moves forward n times over the whole run, and left only ever moves forward too, so it moves at most n times in total across every trigger combined. Total pointer travel is at most 2n moves, which is O(n). This style of counting has a name, amortized analysis: charge the work to the whole journey, not to the single step.

NOW WRITE IT YOURSELFwrite max_window_sum and longest_unique_substring — the fixed window, then the one that breathes
Two windows, one fixed and one that breathes. Start with max_window_sum(a, k): return the largest sum of k consecutive items and the index that window starts at. Pay for the first window with one honest sum(a[:k]), then never sum again — each slide is s += a[r] - a[r - k], two operations however large k gets. Handle the sizes that break naive versions: what should it do when k is larger than the list, or zero? Decide, write it down, and make the code say so. Then the classic: longest_unique_substring(s) — the length of the longest stretch with no repeated character, plus the stretch itself. Grow right one character at a time into a seen set. When the new character is already in the set, evict from the left — seen.discard(s[left]), left += 1until it is gone, then add it. That word matters: use while, not if, and be able to say why. Test 'playlist', 'abcabcbb', 'bbbb' and '' before you trust it. Then prove both against an oracle. Write the obvious slow versions — sum every window; check every substring — seed with random.seed(38), and assert agreement over 3,000 random cases. Time the fixed window at n = 200,000 with k = 1,000, and run the substring version over 200,000 characters. Two questions from your own numbers. Your loop has a pointer moving inside another loop, so why is it not O(n²) — what do you count instead of nesting depth? And the chapter's version jumps left in one assignment using a dict of last positions, while yours steps it one slot at a time: both are O(n), so when is each one the right choice?
show the solution
"""two_windows.py -- write both, then let a brute-force oracle grade you."""
import random, time

def max_window_sum(a, k):
    """Largest sum of k consecutive items, and the index that window starts at."""
    if k <= 0 or k > len(a):
        return None                       # no window of that size exists -- say so
    s = sum(a[:k])                        # pay for the first window once
    best, start = s, 0
    for r in range(k, len(a)):
        s += a[r] - a[r - k]              # +enterer, -leaver: 2 ops, whatever k is
        if s > best:
            best, start = s, r - k + 1
    return best, start

def longest_unique_substring(s):
    """Longest stretch with no repeated character -- a seen-set window that breathes."""
    seen, left, best, span = set(), 0, 0, ""
    for right, ch in enumerate(s):
        while ch in seen:                 # WHILE, not if: evict until the clash is gone
            seen.discard(s[left]); left += 1
        seen.add(ch)
        if right - left + 1 > best:
            best, span = right - left + 1, s[left:right + 1]
    return best, span

def max_window_brute(a, k):               # the oracle: obviously right, obviously slow
    if k <= 0 or k > len(a): return None
    sums = [sum(a[i:i + k]) for i in range(len(a) - k + 1)]
    return max(sums), sums.index(max(sums))

def longest_unique_brute(s):              # the oracle: every substring, checked
    best, span = 0, ""
    for i in range(len(s)):
        for j in range(i, len(s)):
            piece = s[i:j + 1]
            if len(set(piece)) == len(piece) and len(piece) > best:
                best, span = len(piece), piece
    return best, span

print("max_window_sum([4, 2, 9, 7, 1], 3)  ->", max_window_sum([4, 2, 9, 7, 1], 3))
print("max_window_sum([1, 2], 5)           ->", max_window_sum([1, 2], 5))
print("longest_unique_substring('playlist')->", longest_unique_substring("playlist"))
print("longest_unique_substring('abcabcbb')->", longest_unique_substring("abcabcbb"))
print("longest_unique_substring('bbbb')    ->", longest_unique_substring("bbbb"))
print("longest_unique_substring('')        ->", longest_unique_substring(""))

random.seed(38)
for _ in range(3_000):                    # 3,000 random cases against both oracles
    a = [random.randrange(-9, 10) for _ in range(random.randint(1, 25))]
    k = random.randint(1, len(a))
    assert max_window_sum(a, k) == max_window_brute(a, k), (a, k)
    s = "".join(random.choice("abcd") for _ in range(random.randint(0, 20)))
    assert longest_unique_substring(s)[0] == longest_unique_brute(s)[0], s
print("\n3,000 random cases, both functions, 0 mismatches")

big = [random.randrange(1_000) for _ in range(200_000)]
for fn in (max_window_brute, max_window_sum):
    t0 = time.perf_counter(); r = fn(big, 1_000); dt = (time.perf_counter() - t0) * 1000
    print(f"  {fn.__name__:<16} n=200,000 k=1,000 -> {r}  {dt:>8.2f} ms")

text = "".join(random.choice("abcdefghij") for _ in range(200_000))
t0 = time.perf_counter(); n, _ = longest_unique_substring(text); dt = (time.perf_counter() - t0) * 1000
print(f"  longest_unique_substring on 200,000 characters -> {n}  {dt:>8.2f} ms  (one pass)")


# ---------- what it printed ----------
#
# max_window_sum([4, 2, 9, 7, 1], 3)  -> (18, 1)
# max_window_sum([1, 2], 5)           -> None
# longest_unique_substring('playlist')-> (6, 'aylist')
# longest_unique_substring('abcabcbb')-> (3, 'abc')
# longest_unique_substring('bbbb')    -> (1, 'b')
# longest_unique_substring('')        -> (0, '')
#
# 3,000 random cases, both functions, 0 mismatches
#   max_window_brute n=200,000 k=1,000 -> (529554, 162015)   1125.26 ms
#   max_window_sum   n=200,000 k=1,000 -> (529554, 162015)     17.00 ms
#   longest_unique_substring on 200,000 characters -> 10     32.99 ms  (one pass)
#
#
# ---------- the two questions ----------
#
# WHY IT IS NOT O(n^2).
#   Count total pointer TRAVEL, not nesting depth. `right` advances once per
#   character: n moves, full stop. `left` only ever moves forward, and it can
#   never pass `right`, so across the entire run it also moves at most n times.
#   Total <= 2n moves -> O(n). The inner while looks like a second loop, but it
#   is not paid per outer step -- it is paid once, spread over the whole
#   journey. That is amortized reasoning, and the giveaway is that neither
#   pointer ever moves backward. If `left` could reset to 0, this argument dies
#   and the loop really is quadratic.
#
# WHY THE 200,000-CHARACTER RUN ANSWERS 10.
#   The alphabet has ten letters, so no distinct stretch can be longer than 10 --
#   the eleventh character must repeat one of the first ten. The window is
#   bounded by the alphabet, not by the text. That is also why the timing stays
#   linear: the set never holds more than ten items.
#
# STEP-BY-STEP vs THE JUMP.
#   This version steps `left` one slot at a time and needs only a set. The
#   chapter's version keeps a dict of last positions and jumps `left` straight
#   past the old copy in a single assignment. Both are O(n) by the same travel
#   argument, and both do about the same total work. Prefer the JUMP when you
#   can compute the destination -- it is fewer operations and reads cleanly.
#   Prefer the STEP whenever the shrink target cannot be precomputed, which is
#   exactly the minimum-window problem in section 05: how far to squeeze depends
#   on a running coverage count you can only discover by stepping and watching
#   it flip.
p l a y l i s t old l (dup) right: new l enters left jumps past the old l → window becomes "ayl…" invariant restored: every character in the window is distinct
Fig — When the second l arrives, left leaps to just after the first l. The window shrinks exactly enough to stay valid, then keeps growing.
InteractiveScrub the window across "playlist"
left right window "p" · length 1 longest so far: 1
0
Right only ever moves right; left only ever moves right. Neither backtracks — that's why the whole scan is O(n), not O(n²).
↺ The thing people get backwards
That code has a pointer-advance nested inside a for loop, so most people slap an O(n²) label on it and move on. Backwards. You don't count nesting depth — you count total pointer travel. right advances at most n times over the whole run, and left advances at most n times over the whole run, because neither ever moves backward. Total moves ≤ 2n → O(n). I stress-tested it on a 5000-character string built to force endless shrinking: right advanced 5000 times, left advanced 4998 times, total 9998 — right under the 2n = 10000 ceiling. This is amortized reasoning (the amortized-analysis chapter): the expensive-looking shrink is paid for by the growth it undoes.
The deeper cut
There are two flavours of "shrink." The longest_unique above jumps left in one assignment using the remembered index — clean, but it needs a hash to know where to jump. The alternative is a while loop that steps left forward one cell at a time, discarding characters from a set until the duplicate is gone. Both are O(n) overall by the same total-travel argument; the step-by-step version is what generalizes to windows where you can't precompute the jump target — like the minimum-window problem next, where "how far to shrink" depends on a running coverage count you can only discover by stepping.

Longest-valid grows greedily and shrinks only when forced. Flip the goal — find the smallest window that satisfies a condition — and the window learns a second move: squeeze. →

05The minimum window — cover, then squeeze

Now the hardest classic in the family. Given a text and a set of characters you need, find the smallest slice of the text that contains all of them. Think "shortest stretch of a log that mentions all three error codes." The window now alternates between two phases: first expand right until the window covers everything needed. Then squeeze left as far as it can go while staying covered, recording the smallest covered window each time. When the squeeze finally drops coverage, go back to expanding, and repeat until the text runs out. Picture an inchworm moving along the text: the front end stretches forward, then the back end catches up.

The invariant tracker here is a single counter called missing, the count of needed characters still uncovered. It updates in O(1) every time a character enters or leaves the window. So we always know the exact moment coverage flips, without ever rescanning the window to check.

min_window.pypython
from collections import Counter

def min_window(text, need):
    want = Counter(need)               # how many of each char we still want
    missing = len(need)                # total chars still uncovered
    left = 0
    best = ""
    for right, ch in enumerate(text):
        if want[ch] > 0:               # this char was actually needed
            missing -= 1
        want[ch] -= 1                  # may go negative: a surplus copy
        while missing == 0:            # covered! squeeze from the left
            if best == "" or right - left + 1 < len(best):
                best = text[left:right + 1]
            want[text[left]] += 1
            if want[text[left]] > 0:   # we just dropped a NEEDED char
                missing += 1           # uncovered again -> stop squeezing
            left += 1
    return best

print(min_window("ADOBECODEBANC", "ABC"))   # 'BANC'

The heart of the code is the two counters. want[ch] tracks how many of each character the window still needs. It's allowed to go negative for surplus copies, and that negative is what tells the squeeze phase a character is expendable. missing hits 0 exactly when the window first covers the target. That zero is the trigger for the inner while, which shrinks the window from the left. Each shrink step checks whether this is the smallest covered window yet. The squeeze stops the instant it would drop a genuinely needed character, which shows up as want[...] > 0 after adding it back. I ran it on the textbook input "ADOBECODEBANC" needing "ABC" and got 'BANC', the shortest slice holding an A, a B, and a C. The cost argument is the same total-travel one as before. Both pointers only ever move forward, so the whole thing is O(text length), not O(n²).

Walk the missing counter on the textbook case: text "ADOBECODEBANC", needed set "ABC", so missing starts at 3. Expand right and watch it fall: the A at index 0 drops it to 2, the B at index 3 drops it to 1, and the C at index 5 drops it to 0. That zero is coverage for the first time, over the slice "ADOBEC" of length 6, and it fires the squeeze. Left and right keep breathing until the tightest covered slice surfaces: "BANC", length 4. Notice we never once rescanned the window to test coverage — a single counter, nudged by ±1 as characters cross the edges, told us the exact instant it flipped.

A D O B E C O D E B A N C first cover: "ADOBEC" (6) — expand right until A, B, C all present smallest cover: "BANC" (4) squeeze left as far as coverage allows need = {A, B, C}
Fig — Two phases alternate: expand right to reach coverage, then squeeze left to the tightest slice still covered. The missing counter flips at exactly those two moments, so nothing is ever rescanned.
A recipe for any window problem
Ask three questions. (1) What is the window's invariant? — "all distinct," "sum ≥ target," "covers the need set." (2) When does growing right break it, and how do I restore it by moving left? (3) What single O(1) quantity tracks the invariant so I never rescan — a running sum, a "seen" set, a missing counter? Answer those and the code writes itself.
Wait —
reverse, palindrome, sorted two-sum, max-window, longest-unique, min-window — six different problems, all solved by two indices that never backtrack. Is there a single idea underneath all of them?

06The one move: reuse the overlap

There is, and it's the whole point of the chapter. A brute-force nested loop restarts its inner work from scratch at every outer step, re-summing an overlap, re-scanning for a partner, re-checking a window it just checked. But consecutive states of a scan overlap almost completely. The move that separates a coder from an engineer is to compute the change between states, not each state from zero. Two pointers and the sliding window are just the array-shaped incarnation of that one reflex: don't restart — update.

So how do you actually spot one of these in the wild? Look at what the inner loop touches, and ask how much of it the previous outer step already touched. If the two workloads overlap heavily, say a shared sum or a shared stretch of text, the loop is a sweep in disguise. Then find the delta: what enters and what leaves. Ask what tiny record lets you patch the answer in O(1): a running sum, a counter, a hash of last positions. If the data is ordered, ask the other question too: does one comparison prove that some element can never matter again? A yes to either question is the moment O(n²) quietly becomes O(n).

SYNTAX · the three sweep shapes — slide, breathe, convergesubtract the leaver and add the enterer; grow right and shrink left; or march the two ends inward
1. FIXED WINDOW -- k never changes, so a step costs 2 ops whatever k is s = sum(a[:k]) pay for the first window, once for r in range(k, len(a)): s += a[r] - a[r - k] +enterer, -leaver. the leaver is always k slots back. best = max(best, s) 2. BREATHING WINDOW -- the size is whatever keeps a rule true left = 0 for right, x in enumerate(a): add(x) grow: the enterer joins the state while broken(state): WHILE, not if: one enterer can evict several remove(a[left]); left += 1 best = max(best, right - left + 1) inclusive ends, so the +1 is real 3. CONVERGING POINTERS -- sorted input, both ends marching inward lo, hi = 0, len(a) - 1 while lo < hi: if too_small: lo += 1 a[lo] can never help again else: hi -= 1 a[hi] can never help again
a[r - k]The leaver sits exactly k slots behind the enterer, so a fixed window needs no bookkeeping at all.
right - left + 1The length of a window with inclusive ends. Drop the +1 and every answer comes out one short.
while, not ifOne entering element can break the rule several times over, so the shrink has to loop until it holds again.
total travelleft and right each advance at most n times, so the nested-looking loop is 2n moves — O(n).
the O(1) stateA running sum, a counter, a seen set. If patching the state costs O(k), you have quietly rebuilt the brute force.
enumerateYou need the index and the value on every step, and enumerate hands you both without a manual counter.
the sorted preconditionShape 3 is only correct on sorted data. That one comparison is a proof about order, not a guess.
sum(a[left:right+1])Write this inside the loop and you are back to O(n·k). Not recomputing it is the entire point of the chapter.
you type
# ---------- windows.py ----------
import random, time

def moving_avg(a, k):                     # 1. FIXED: patch the sum, never rebuild it
    s, ops = sum(a[:k]), k - 1
    out = [s / k]
    for r in range(k, len(a)):
        s += a[r] - a[r - k]                # +enterer, -leaver: exactly 2 ops, any k
        ops += 2
        out.append(s / k)
    return out, ops

def moving_avg_brute(a, k):                 # the same answer, re-summing every window
    out = [sum(a[i:i + k]) / k for i in range(len(a) - k + 1)]
    return out, (len(a) - k + 1) * (k - 1)  # k-1 additions per window, every window

def longest_under(a, limit):                # 2. BREATHING: grow right, shrink left
    left, s, best, travel = 0, 0, 0, 0
    for right, x in enumerate(a):
        s += x; travel += 1                 # the enterer joins the state
        while s > limit and left <= right:  # while, not if: one enterer can evict several
            s -= a[left]; left += 1; travel += 1
        best = max(best, right - left + 1)
    return best, travel

def two_sum_sorted(a, target):              # 3. CONVERGING: the ends march inward
    lo, hi, ops = 0, len(a) - 1, 0
    while lo < hi:
        ops += 1
        s = a[lo] + a[hi]
        if s == target: return (lo, hi), ops
        if s < target: lo += 1
        else:          hi -= 1
    return None, ops

small = [4, 2, 9, 7, 1]                     # same three averages, two ways of paying
print("1. fixed window, k = 3, on", small)
for fn in (moving_avg_brute, moving_avg):
    out, ops = fn(small, 3)
    print(f"   {fn.__name__:<16} {[round(v, 2) for v in out]}  in {ops} additions")

random.seed(38)
big, k = [random.randrange(100) for _ in range(200_000)], 1_000
for fn in (moving_avg_brute, moving_avg):
    t0 = time.perf_counter(); out, ops = fn(big, k); dt = (time.perf_counter() - t0) * 1000
    print(f"   n=200,000 k=1,000  {fn.__name__:<16} {ops:>12,} ops  {dt:>9.2f} ms")

print("\n2. breathing window -- longest stretch summing to at most 15")
print("  ", longest_under([5, 1, 3, 8, 2, 1, 1, 9], 15), " <- (length, total pointer travel)")
worst = [1] * 50_000 + [10 ** 6]            # forces the window to collapse at the very end
best, travel = longest_under(worst, 10)
print(f"   n = {len(worst):,}: travel = {travel:,}, and the 2n ceiling is {2 * len(worst):,}")

print("\n3. converging pointers on a sorted array, target 26")
print("  ", two_sum_sorted([2, 7, 11, 15, 20, 24, 40], 26), " <- ((lo, hi), looks taken)")

$ python windows.py
you see
1. fixed window, k = 3, on [4, 2, 9, 7, 1]
   moving_avg_brute [5.0, 6.0, 5.67]  in 6 additions
   moving_avg       [5.0, 6.0, 5.67]  in 6 additions
   n=200,000 k=1,000  moving_avg_brute  198,801,999 ops     646.96 ms
   n=200,000 k=1,000  moving_avg            398,999 ops      23.61 ms

2. breathing window -- longest stretch summing to at most 15
   (5, 12)  <- (length, total pointer travel)
   n = 50,001: travel = 100,002, and the 2n ceiling is 100,002

3. converging pointers on a sorted array, target 26
   ((0, 5), 2)  <- ((lo, hi), looks taken)
where beginners trip
  • At k = 3 both versions spend 6 additions — the slide wins nothing. At k = 1,000 it spends 399k against 198.8 million.
  • Measured above: 646.96 ms re-summing against 23.61 ms sliding, for byte-identical output on 200,000 numbers.
  • The travel counter landed on exactly 100,002 for 50,001 items — the 2n ceiling, touched and never crossed.
  • Swap the while for an if in shape 2 and the window keeps an invalid element. The answer is wrong, not slow.
  • On floats the patched sum drifts: 199,000 slides left it 1.0 × 10−11 from a fresh re-sum. Keep integers, or re-sum periodically.
  • Shape 3 on unsorted data returns confident nonsense. Sorting first costs O(n log n), and then chapter 37's hash is usually the better trade.
  • A window needs random access: a[r - k] cannot reach backwards into a generator. Materialise the data, or keep a deque.
  • Off-by-one check: range(k, len(a)) runs exactly n − k times, which is the n − k + 1 windows minus the first one you paid for.
input size n → work done → recompute — O(n²) slide — O(n) same problem, same answer — the gap is the wasted re-work you deleted
Fig — The volume's opening fan-out, in miniature: restarting the inner loop rides the red curve; reusing the overlap rides the green one. That gap is pure recovered time.

This reflex earns its keep far past interview arrays. Finance: a rolling volatility or a moving-average crossover is a sliding window over a price stream. Recomputing the whole trailing average on every tick would cost O(n·window), while the delta update is O(1). Networking: TCP's window is why the internet doesn't melt under its own bookkeeping. Video codecs encode each frame as the difference from the last frame, never the whole picture again — the same "store the change" instinct at 30 frames a second. Rate limiters guarding every serious API are sliding windows over timestamps. Even your eyes, reading this line, drag a small focus window across the text rather than re-photographing the page at every word.

InteractiveCrank the scale — count the additions you delete
SAME ANSWER · WORK TO REACH IT n = 10,000 · k = 1,000 recompute re-sum each window 0 adds slide (Δ) −left +right 0 adds ADDITIONS THE SLIDE NEVER PERFORMS 0 = 0 additions LESS WORK ×1 ≈ k / 2 at ~1 billion adds/s: recompute 9.0 ms · slide 19 µs The red bar is re-added overlap — work the slide flatly refuses to do.
×473
Push window k right: the red "recompute" bar and the hero number balloon, while "slide" barely stirs — because the speedup is almost exactly k/2, the fraction of each window that is pure re-added overlap. Bars are log-scaled (each is 10× the one before at equal spacing); the numbers carry the real gap.
The trap that hides in plain sight
The most common O(n²) blunder isn't an obviously bad algorithm — it's an innocent-looking loop that recomputes something it already knew. "Sum this window," "does the rest of the list contain X," "recheck the max so far" — each rebuilt from scratch inside another loop. Whenever you see an inner loop, ask: how much of this did the previous iteration already compute? If the answer is "almost all of it," a pointer or a window is waiting to save you a factor of n.

The everyday transfer is real too. To answer "what changed in this document?" you don't re-read it from the top, you track the delta. When a list is sorted, you rarely need to inspect everything in it. You can walk in from the ends and let the ordering eliminate whole regions, the way binary search bisects a bug. That is two-pointer thinking off the screen: let structure do the discarding, and never redo work you can update. Carry that reflex out of this chapter, because it will follow you into every system you ever profile.

Every technique so far has been careful — reuse the overlap, restore the invariant, discard the impossible. The next chapter gets reckless on purpose: grab the best-looking option in front of you right now and never look back. It sounds like a way to be wrong — so when does that greedy gamble actually hand you the optimal answer, and when does it quietly betray you? →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 38, in working code

Twelve tiny programs where two indices do all the work — marching inward, converging on a sorted pair, or dragging a window across the data — each one refusing to redo a scrap of work it can simply update.

Two fingers, marching inward
Put one finger on the first slot and one on the last, then step them toward each other until they meet — one pass, no second array.
When it's sorted, the pointers converge
On ordered data a single comparison tells you which end is hopeless — so each step deletes a whole family of pairs, turning O(n²) into O(n) with no extra memory.
The sliding window — reuse the overlap
Adjacent windows share almost every element. Instead of re-summing the overlap, patch the total: subtract the leaver, add the enterer. O(1) per slide, any window size.
A window that breathes
Now the window has no fixed size — an invariant decides it. Grow right greedily; shrink left only when forced. Both pointers move forward only, so the whole scan is O(n).
end of chapter 38 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked