Sequences & Strings/Two Pointers & Sliding Windows

Lesson 5.13,601 words

Two Pointers & Sliding Windows

A family of array idioms that collapse an obvious O(n2)O(n^2) scan into a single O(n)O(n) pass by maintaining an invariant as indices move. We meet two pointers (converging on a sorted array, and a fast/slow pair for in-place rewriting) and the sliding window (fixed and variable size, amortized O(n)O(n)).

╌╌╌╌

We open a new module on sequences (arrays and strings), and the first thing to learn is a way of thinking that recurs everywhere in it. A great many array questions have an obvious brute-force answer that examines every pair or every subarray: two nested loops, work. The techniques in this lesson share a single trick for collapsing that quadratic scan into a single linear pass. Instead of re-examining the array from scratch at each step, we maintain a small amount of state (one or two indices, a window) together with an invariant that this state always tells us something true. Each step advances an index and patches the invariant in , so the whole pass is .1 The art is choosing the invariant. The loop-invariant discipline from our first algorithm, establish it, maintain it, read off the answer at termination, is the reasoning we use to prove these correct.2

Two pointers, opposite ends

The cleanest instance lives on a sorted array. Suppose is sorted ascending and we want indices with for a target . Brute force tries all pairs. Instead, place one pointer at each end, and , and let them converge:

At each step we look at . If we are done. If , then paired with anything still available is too small. Since is the largest sum involving in the window and it already fell short, cannot be part of any solution, and we discard it by advancing . The case is symmetric: is too large to pair with anything remaining, so we drop it by decrementing .

The invariant has a picture worth keeping in mind. At every moment the array is split into three zones: a left zone of indices already discarded because each was proven too small to pair with anything that remained, a right zone discarded as too large, and the live window in between. The lemma says every discard is safe, so by induction the window always contains both ends of every surviving candidate pair. When the pointers meet, the window holds fewer than two indices, and we may conclude that no solution exists at all — not merely that we failed to find one. The brute-force scan needs comparisons to establish that certificate of absence; the invariant gives it in .

The exclusion invariant midway through a scan: every index left of was proven too small to appear in any pair, every index right of too large. If a pair summing to exists, both of its ends lie in

Here is the full run on with target :

  1. : . Even the smallest available partner overshoots with , so index joins no pair: .
  2. : . Now falls short even with the largest remaining partner: .
  3. : , so .
  4. : , so .
  5. : . Done.
Converging pointers on sorted , target . Each step compares to and discards one end; the pair is found in five steps

The pointers start apart and each step closes the gap by one, so the loop runs at most times: time, space. This is Two Sum II on a sorted input, and the reason sorting first () can beat a hash table when the array is already sorted or space is tight.

The comparison with the hash-table solution is worth making precise. On an unsorted array, one pass with a map answers Two Sum in expected time: for each , look up among the elements already inserted. That beats sort-then-scan's , but it spends extra space and gives expected rather than worst-case time. When the input arrives sorted, the pointers win outright: worst case, space, no hashing. One caveat if you sort yourself: sorting scrambles the original positions, so a problem that wants indices back forces you to sort (value, index) pairs first.

Two edge cases matter. The condition is , strict, because an element cannot pair with itself; when the pointers meet, the invariant says the remaining window holds no pair, so report failure. And each miss must move exactly one pointer, the one the proof licenses. Moving when (or both pointers at once) discards indices the invariant has said nothing about, and solutions can be lost. Duplicates need no special handling for existence; to enumerate all distinct pairs, step past runs of equal values after each hit.

two_sum_sorted.pypython
from typing import Optional, Sequence

def two_sum_sorted(values: Sequence[int], target: int) -> Optional[tuple[int, int]]:
  """
    Indices `(left, right)` with `left < right` and\n
    `values[left] + values[right] == target`, or None if no such pair\n
    exists. `values` must be sorted ascending.\n
  """
  left: int = 0
  right: int = len(values) - 1

  while left < right:
    current_sum: int = values[left] + values[right]
    if current_sum == target:
      return (left, right)

    # too small: raise the floor; too big: lower the ceiling.
    if current_sum < target:
      left += 1
    else:
      right -= 1

  return None

The same converging-pointers move solves Container With Most Water: with heights , the area between walls and is . We start at the widest pair and always advance the pointer at the shorter wall.

One pass, . Here is the full run on :

  1. : width , height , area . , so advance .
  2. : width , height , area . , so retreat .
  3. : width , height , area . Advance .
  4. : width , height , area . Advance .
  5. : width , height , area . Advance ; the pointers meet. Maximum: , between walls and .

The candidate areas do not climb monotonically — step 4 drops to after the maximum has already been seen. The scan promises only that the true optimum is among the candidates it evaluates, which is what the claim guarantees: no discarded wall could have anchored anything better. When , both proofs apply and either move is safe; advancing by convention keeps the code branch-free.

container_with_most_water.pypython
from typing import Sequence

def max_water_area(heights: Sequence[int]) -> int:
  """
    The maximum area of water any two walls in `heights` can hold.\n
    Returns 0 for fewer than two walls.\n
  """
  left: int = 0
  right: int = len(heights) - 1
  best_area: int = 0

  while left < right:
    # the binding wall is the shorter one; width shrinks as the ends close.
    width: int = right - left
    binding_height: int = min(heights[left], heights[right])
    best_area = max(best_area, width * binding_height)

    # advance the shorter wall — the only move that can raise the height.
    if heights[left] <= heights[right]:
      left += 1
    else:
      right -= 1

  return best_area
Container With Most Water on : the area is , bound by the shorter wall, here , giving . Moving the taller wall inward only loses width at the same capped height, so we advance the shorter wall instead

Two pointers, same direction (fast/slow)

A second flavor sends both pointers the same way at different speeds. The canonical use is rewriting an array in place: a write pointer trails a read pointer , and only advances when is an element we want to keep. To remove duplicates from a sorted array:

Algorithm:Dedup(a)\textsc{Dedup}(a) — compact a sorted array in place, returning new length
  1. 1
    w1w \gets 1
  2. 2
    for r1r \gets 1 to n1n-1 do
  3. 3
    if a[r]a[w1]a[r] \ne a[w-1] then
  4. 4
    a[w]a[r]a[w] \gets a[r]
  5. 5
    ww+1w \gets w + 1
  6. 6
    return ww

The read pointer scans every element once and the write pointer never overtakes it, so the routine is time and extra space; it overwrites the input rather than allocating output. Trace it on :

  • . At : , a duplicate; skip.
  • : , so write and set . The array now reads .
  • : , so write , .
  • : ; skip. Return : the prefix is the answer, and everything past it is garbage the caller ignores.

The figure below freezes the run just before the step: the earlier write already replaced with , so the kept prefix reads even though the cells to its right still hold stale values.

Fast/slow dedup on input , frozen just before the step: the write at overwrote with , so holds the compacted prefix

Two details deserve care. The pseudocode assumes (it initializes , silently keeping ); guard the empty array separately. And the filter is stable: kept elements retain their relative order, because the write pointer copies them in read order. The same skeleton solves remove element (keep everything not equal to a given ) and move zeroes (keep the nonzeros, then zero-fill from to the end) with a one-line change to the keep test. The trailing-write pattern also underlies in-place partition (the core of quicksort and quickselect), where a write pointer marks the boundary between elements already placed below the pivot and the rest; that variant swaps rather than copies, and gives up stability in exchange.

dedup_sorted.pypython
from typing import MutableSequence

def dedup_sorted(values: MutableSequence[int]) -> int:
  """
    Compact the sorted array `values` in place, overwriting its front with\n
    the distinct elements in order. Returns the count of distinct elements;\n
    `values[:count]` is the de-duplicated array.\n
  """
  if len(values) == 0:
    return 0

  # keep only elements that differ from the last one written.
  write: int = 1
  for read in range(1, len(values)):
    if values[read] != values[write - 1]:
      values[write] = values[read]
      write += 1
  return write
partition_in_place.pypython
from typing import MutableSequence

def partition_in_place(
  values: MutableSequence[int], low: int, high: int
) -> int:
  """
    Partition `values[low .. high]` around the pivot `values[high]`.\n
    Rearranges the slice so everything left of the returned boundary is\n
    `<= pivot` and everything right is `> pivot`, and returns the final\n
    index of the pivot.\n
  """
  pivot: int = values[high]
  boundary: int = low

  # swap each element below the pivot down to the boundary.
  for read in range(low, high):
    if values[read] <= pivot:
      values[boundary], values[read] = values[read], values[boundary]
      boundary += 1

  # drop the pivot into its sorted position.
  values[boundary], values[high] = values[high], values[boundary]
  return boundary

Sliding windows

A window is a contiguous range that we slide rightward across the array while keeping it consistent with some property. Two regimes appear.

Fixed size . To compute, say, every window's sum, we do not re-add elements each time. We add the entering element and subtract the leaving one: when the window advances from to , update . On with : the first window sums to ; sliding to gives ; then gives ; then gives . Recomputing each window from scratch costs additions; the incremental version pays for the first window and two operations per slide, . (For floating-point data the running sum accumulates rounding error over many slides; recompute it from scratch periodically if that matters.)

fixed_window_sums.pypython
from typing import Sequence

def fixed_window_sums(values: Sequence[int], width: int) -> list[int]:
  """
    The sum of every contiguous window of size `width` in `values`, in left\n
    to right order. Returns an empty list when `width` is non-positive or\n
    exceeds the length.\n
  """
  count: int = len(values)
  if width <= 0 or width > count:
    return []

  window_sum: int = sum(values[:width])
  sums: list[int] = [window_sum]

  # slide one step at a time: the new element enters, the oldest one leaves.
  for right in range(width, count):
    window_sum += values[right] - values[right - width]
    sums.append(window_sum)
  return sums

Variable size. Here the window grows and shrinks to stay feasible. The pattern: advance to expand the window greedily; whenever the window violates its constraint, advance to shrink it until the constraint holds again. The double loop looks , but it is not:

The accounting behind the lemma is worth spelling out, because the same argument recurs across this module. Count pointer increments instead of loop iterations. The outer loop increments exactly times. Every pass through the inner while increments ; since never decreases and never exceeds , the inner loop runs at most times summed over the entire outer loop, no matter how unevenly those shrinks cluster. One outer step may trigger five shrinks and the next ten steps none; the total is still bounded by pointer moves, each with bookkeeping attached. Equivalently, charge each element two coins: one spent when brings it into the window, one when evicts it. coins pay for everything.1

A variable window slides across the array; and each move only rightward, so the pass is amortized

Worked: smallest subarray with sum . Given positive integers and a target , find the shortest contiguous subarray whose sum is at least (Minimum Size Subarray Sum). Keep a running window sum; expand to grow it, and the moment the sum reaches , shrink from to find the tightest window ending at .

Algorithm:MinSubarray(a,S)\textsc{MinSubarray}(a, S) — shortest window with sum S\ge S, in O(n)O(n)
  1. 1
    l0,  sum0,  bestl \gets 0,\ \ \text{sum} \gets 0,\ \ \text{best} \gets \infty
  2. 2
    for r0r \gets 0 to n1n-1 do
  3. 3
    sumsum+a[r]\text{sum} \gets \text{sum} + a[r]
  4. 4
    while sumS\text{sum} \ge S do
  5. 5
    bestmin(best, rl+1)\text{best} \gets \min(\text{best},\ r - l + 1)
  6. 6
    sumsuma[l]\text{sum} \gets \text{sum} - a[l]
  7. 7
    ll+1l \gets l + 1
  8. 8
    return (best=) ? 0:best(\text{best} = \infty)\ ?\ 0 : \text{best}

Because all entries are positive, the window sum is monotone in width, so once it drops below no further shrinking helps — the while exits and moves on.

Trace it on with , watching the running sum:

  • : sums , , — all below , the window just grows to .
  • : sum . Record length . Shrink: drop , sum , ; below , stop.
  • : sum . Record length (no improvement). Shrink: drop , sum , ; still , record length . Shrink again: drop , sum , ; stop.
  • : sum . Record length (tie). Shrink: drop , sum , ; record length . Shrink: drop , sum , ; stop.

The answer is , the window . Every recorded window is the tightest one ending at its , and the true optimum ends somewhere, so the minimum over all recordings is correct.

on with : each row shows a window state, with the window sum and the action taken at the right. Both pointers only ever move right; the best window has length 2

Edge cases: if no window ever reaches , stays and we return by convention. If some single element , the shrink loop tightens the window to length the moment passes it, so the algorithm needs no special case for it.

The positivity assumption is essential. Take and : the scan reaches with sum , records length , shrinks once to sum , and stops — final answer . But alone is a valid window of length . The shrink loop quit early because dropping would have raised the sum back to , and the once the sum dips below , shrinking further never helps claim is false with negative entries. With mixed signs, use prefix sums plus a monotonic structure (a cousin of the monotonic stack) instead of a window.

min_size_subarray_sum.pypython
from typing import Sequence

def min_size_subarray_sum(values: Sequence[int], target: int) -> int:
  """
    The length of the shortest contiguous subarray of `values` whose sum is\n
    at least `target`, or 0 if no such subarray exists. Assumes\n
    non-negative entries.\n
  """
  left: int = 0
  window_sum: int = 0
  best_length: int = len(values) + 1

  for right in range(len(values)):
    window_sum += values[right]

    # shrink from the left while the window still meets the target.
    while window_sum >= target:
      best_length = min(best_length, right - left + 1)
      window_sum -= values[left]
      left += 1

  return 0 if best_length == len(values) + 1 else best_length

Worked: longest substring without repeats. For Longest Substring Without Repeating Characters, the window must contain no duplicate character. We keep a map last[c] of the most recent index of each character. Expand ; if was last seen at a position , that occurrence is inside the window, so we jump to one past it. The window is always duplicate-free, and we track its maximum length.

Algorithm:LongestUnique(a)\textsc{LongestUnique}(a) — longest duplicate-free window, in O(n)O(n)
  1. 1
    l0,  best0,  last{}l \gets 0,\ \ \text{best} \gets 0,\ \ \text{last} \gets \{\}
  2. 2
    for r0r \gets 0 to n1n-1 do
  3. 3
    if a[r]lasta[r] \in \text{last} and last[a[r]]l\text{last}[a[r]] \ge l then
  4. 4
    llast[a[r]]+1l \gets \text{last}[a[r]] + 1
  5. 5
    last[a[r]]r\text{last}[a[r]] \gets r
  6. 6
    bestmax(best, rl+1)\text{best} \gets \max(\text{best},\ r - l + 1)
  7. 7
    return best\text{best}

Each character is visited once by , and only moves forward, so this is time and space for an alphabet of size .

On the string abcabcbb:

  • : a, b, c are all new; the window is , .
  • : a was last seen at index , so ; the window is bca.
  • : b last at , so : cab. : c last at , so : abc.
  • : b last at , so jumps to : cb. : b last at , so : b.

never improves past (abc), and the jump at shows why the map beats shrinking one step at a time: moves straight past every index that cannot start a duplicate-free window.

on \texttt{abcabcbb}, at the moment reaches the second \texttt{a} (index 3): the map says \texttt{a} was last seen at index , so jumps to and the window is duplicate-free again

The guard is the step most often botched. The map is never cleaned, so it accumulates stale entries for characters that have long since left the window, and acting on one moves backward. On abba: at the second b sends to ; at , a has — that occurrence sits outside the window and is no conflict. With the guard, the window = ba is correct. Without it, would retreat to and window = bba contains a duplicate, breaking the invariant and inflating the answer.

An equivalent formulation keeps a count map and shrinks one step at a time (while the entering character's count exceeds , decrement the count of and advance ). It runs in the same amortized and generalizes more smoothly to constraints like at most distinct characters, where there is no single index to jump to.

longest_unique_substring.pypython
def longest_unique_substring(text: str) -> int:
  """
    The length of the longest substring of `text` containing no repeated\n
    character.\n
  """
  last_seen: dict[str, int] = {}
  left: int = 0
  best_length: int = 0

  for right, character in enumerate(text):
    # if this character repeats inside the window, drop the left edge past it.
    previous: int = last_seen.get(character, -1)
    if previous >= left:
      left = previous + 1

    # record the latest position and stretch the best length to here.
    last_seen[character] = right
    best_length = max(best_length, right - left + 1)

  return best_length

Choosing the tool

The techniques overlap enough that recognizing which one fits is most of the work. A checklist for the pointer-and-window family:

  • Sorted input, question about a pair (sum, difference, closest to a target): converging pointers from the ends. If the input is unsorted and order does not matter, sort first () or use a hash map ( time and space).
  • One-pass, in-place rewrite (dedup, filter, compact): fast/slow pointers. Stable, extra space.
  • Optimize over contiguous subarrays, feasibility monotone in window width (all-positive sums, distinct-character counts): variable-size sliding window, amortized . The monotonicity is the license; check it before trusting the shrink loop.
  • Sliding-window maximum or minimum fits none of the above (max is not invertible the way sums are) and needs the monotonic deque, covered in the next lesson.
  • Contiguous subarrays with negative entries, or exact-sum counting, break the window's monotonicity outright. Prefix sums handle these: see the companion lesson.

Amortization, stream processing, and sweep lines

The amortized argument for the variable window — each index enters and leaves once — is the same potential bookkeeping Tarjan formalized for data structures (Tarjan, Amortized Computational Complexity, SIAM J. Alg. Disc. Meth., 1985): assign each element a small constant of credit when it enters, spend it when it leaves, and the total spend bounds the whole run regardless of how unevenly the work clusters. The two-coin accounting in this lesson is that theorem in miniature.

Two-pointer and window scans also underpin stream processing. A fixed window of width over an unbounded stream — a moving average, a rate limiter counting events in the last seconds — reduces to the incremental add-the- entrant, subtract-the-departer update, and it is how time-series databases and monitoring systems (Prometheus's range queries, for instance) compute windowed aggregates without rescanning history. The distinction the lesson draws between invertible aggregates (sum, count — a departing element can be subtracted) and non-invertible ones (max, min — a departing maximum cannot be subtracted back out) is the line between what a plain running total handles and what needs the monotonic deque of the next lesson; the same split reappears in database window functions, where SUM() OVER slides cheaply but MAX() OVER does not.

Finally, the converging-pointer move on a sorted array is the one-dimensional base case of a recurring geometric pattern: 3Sum and k-Sum fix outer indices and run the two-pointer scan inside, and the same advance the provably useless end logic drives sweep-line algorithms in computational geometry (Preparata & Shamos, Computational Geometry, 1985), where a pointer sweeps a sorted event list and never backtracks.

Takeaways

  • These idioms all replace a pair-or-subarray scan with a single pass by maintaining an invariant as indices advance and patching it in per step.3
  • Two pointers from opposite ends solve sorted-array pair problems (Two Sum II, Container With Most Water): the move that discards an index provably discards no solution, giving time, space.
  • Fast/slow pointers (a trailing write behind a read) rewrite an array in place, dedup or partition, in time and extra space.
  • A sliding window expands and shrinks to keep a property; since each index enters and leaves the window once, the nested loop is amortized .
  • The window needs its feasibility monotone in width — positivity for sums, a duplicate test for distinct characters. When negative entries break that monotonicity, the tool changes.

This continues in Prefix Sums & Difference Arrays, which restores range-sum queries and subarray counting when the window's positivity assumption no longer holds.

Footnotes

  1. Erickson, Ch. — Arrays and Amortization: the amortized argument that a two-pointer window, though nested, does total work because each index is enqueued and dequeued once. 2 3
  2. CLRS, Ch. 2 — Getting Started (§2.1): the loop-invariant method (initialization, maintenance, termination) used here to prove each pointer scheme correct.
  3. Skiena, § — Sorting & array techniques: two-pointer and windowing idioms on sorted arrays as the linear-time alternative to a quadratic scan.
Practice

╌╌ END ╌╌