DSA DSA In Depth
0%
design two-pointersinvariantspartitioning

Two-Pointer Invariants: Converging, Fast-Slow & Dutch National Flag

7 min read Module 1.2: Contiguous Arrays, Bit Manipulation & Pointer Idioms Updated Oct 23, 2026
📄 Cheat Sheet
Dutch National Flag & 3-Way Partitioning Two-Pointer Invariants
NeetCode Dutch National Flag & 3-Way Partitioning Two-Pointer Invariants Watch on YouTube ↗

Deep-dive lecture covering Two-Pointer Invariants: Converging, Fast-Slow & Dutch National Flag.

0:00 / --:--

Eliminating Rows and Columns in the Pair Search Matrix

Why does the converging two-pointer algorithm for sorted 2-Sum ($A[L] + A[R] = T$) never miss a valid pair? Visualize all possible index pairs $(L, R)$ with $0 \le L < R < N$ as an upper-triangular grid inside an $N \times N$ matrix, starting at the top-right corner $(L = 0, R = N - 1)$:

  • If $A[L] + A[R] < T$, since the array is sorted ($A[k] \le A[R]$ for all $k < R$), pairing the current $L$ with any smaller right index $k < R$ yields $A[L] + A[k] \le A[L] + A[R] < T$. Thus the entire row $L$ is provably invalid, and incrementing $L \leftarrow L + 1$ safely discards $R - L$ pairs in $O(1)$ time.
  • If $A[L] + A[R] > T$, since $A[k] \ge A[L]$ for all $k > L$, pairing the current $R$ with any larger left index $k > L$ yields $A[k] + A[R] \ge A[L] + A[R] > T$. Thus the entire column $R$ is provably invalid, and decrementing $R \leftarrow R - 1$ safely discards $R - L$ pairs in $O(1)$ time.

The exact same pruning proof governs the Container With Most Water problem ($\max (R - L) \cdot \min(H[L], H[R])$): if $H[L] \le H[R]$, then for every $k \in (L, R)$, the width $(k - L) < (R - L)$ and the height $\min(H[L], H[k]) \le H[L] = \min(H[L], H[R])$. Therefore, $L$ can never form a larger container with any $k < R$, justifying $L \leftarrow L + 1$.

Loop Invariant: Edsger Dijkstra's 3-Way Dutch National Flag Partition

To partition an array $A[0 \dots N-1]$ around a pivot $p$ into three contiguous regions ($< p$, $== p$, and $> p$) in a single $O(N)$ pass with $O(1)$ auxiliary space, maintain three pointers $\text{low}$, $\text{mid}$, and $\text{high}$ satisfying the four-region loop invariant at the start of every iteration:

  1. $\forall k \in [0, \text{low} - 1]: A[k] < p$ (strictly less than pivot)
  2. $\forall k \in [\text{low}, \text{mid} - 1]: A[k] == p$ (equal to pivot)
  3. $\forall k \in [\text{mid}, \text{high}]: A[k]$ is unclassified / unknown
  4. $\forall k \in [\text{high} + 1, N - 1]: A[k] > p$ (strictly greater than pivot)

Potential Function (Termination Proof): Define $\Phi = \text{high} - \text{mid} + 1$, the size of the unclassified region. Initially $\text{low} = 0, \text{mid} = 0, \text{high} = N - 1$, so $\Phi = N$ and regions 1, 2, and 4 are vacuously empty. Each step inspects $A[\text{mid}]$ and either increments $\text{mid}$ or decrements $\text{high}$, strictly decreasing $\Phi$ by 1 until $\Phi = 0$ ($\text{mid} > \text{high}$), terminating in exactly $N$ comparisons.

Taxonomy of Multi-Pointer Paradigms

Paradigm Pointer Geometry Core Loop Invariant Time / Space Canonical Problems
Converging Pointers $L = 0 \to \leftarrow R = N - 1$ All pairs outside $[L, R]$ have been provably pruned $O(N)$ / $O(1)$ Sorted 2-Sum, 3-Sum, Container With Most Water, Trapping Rain Water
Reader / Writer Compaction $0 \le \text{write} \le \text{read} < N$ $A[0 \dots \text{write}-1]$ contains compacted valid prefix of $A[0 \dots \text{read}-1]$ $O(N)$ / $O(1)$ std::remove_if, Dedup Sorted Array, Move Zeroes
3-Way Partition (DNF) $0 \le \text{low} \le \text{mid} \le \text{high}+1 \le N$ 4 regions: $[0..\text{low}), [\text{low}..\text{mid}), [\text{mid}..\text{high}], (\text{high}..N)$ $O(N)$ / $O(1)$ Sort Colors (0/1/2), Quicksort 3-Way Partition (duplicate keys)

Visualizing Dijkstra's 4-Region Dutch National Flag State

Dutch National Flag Invariant at Start of Every Loop Step (Termination when mid > high) A[0 .. low-1] Strictly < pivot A[low .. mid-1] Equal == pivot A[mid .. high] Unclassified (?) A[high+1 .. N-1] Strictly > pivot ^ 0 ^ low ^ mid (inspect) high ^ N-1 ^ Why mid++ on swap(low, mid) but NOT on swap(mid, high)? Because A[low] is known (== pivot), whereas A[high] was unclassified!

Dutch National Flag State Transitions

flowchart TD A["Inspect A[mid]
while mid <= high"] --> B{"Compare A[mid]
with pivot p"} B -- "A[mid] < p" --> C["swap(A[low], A[mid])
low++, mid++"] B -- "A[mid] == p" --> D["mid++"] B -- "A[mid] > p" --> E["swap(A[mid], A[high])
high-- (DO NOT increment mid!)"]
Hardware Reality: Hardware Prefetchers Love Bidirectional Sequential Streams

Modern x86-64 (Intel Golden Cove / AMD Zen 4) and ARM Neoverse L2 stream prefetchers track both ascending (+1 stride) and descending (-1 stride) memory accesses per 4 KB page independently. Consequently, converging two-pointer scans ($L$ moving up from $0$, $R$ moving down from $N-1$) sustain near-100% L1 data cache hit rates. However, inside the Dutch National Flag loop, the 3-way branch on random data causes ~50% branch misprediction penalties (15–20 cycles per mispredict). High-performance sorting libraries (such as pdqsort, Pattern-Defeating Quicksort in Rust's standard library) eliminate this branch bottleneck using BlockQuicksort: evaluating comparisons into small branchless offset buffers via conditional moves (cmov) before executing a tight swap loop.

Two-Pointers & Variable Sliding Window Invariant Stepper
O(N) Amortized 2N Steps

Trace variable-length sliding window [L..R] and Dutch National Flag 3-way partitioning. Both L and R advance monotonically, guaranteeing at most 2N pointer movements.

Step 1 / 15
Custom Input:
Timeline

Invariant & State

Window [0..0] sum = 2 (Target ≥ 8) | Monotonic L=0, R=0 never move left
📊 Live Magnitude & Pointer Stage — Two-Pointers & Variable Sliding Window Invariant Stepper Active Pivot Match/Sorted
L,R 2 [0] 3 [1] 1 [2] 2 [3] 4 [4] 3 [5] 7 [6] 2 [7]
[0]2L,R
[1]3
[2]1
[3]2
[4]4
[5]3
[6]7
[7]2
Window [L..R]
[0..0] (len 1)
Window Sum
2
Best Valid Len
∞
Pointer Ops (≤ 2N)
1
[Step 1] Expand R -> 0 (added A[0]=2), window sum = 2

Production Implementation: Dijkstra's 3-Way Partition & O(1)-Space Trapping Rain Water

#include <algorithm>
#include <cstdint>
#include <utility>
#include <vector>

// Returns {low, high} bounds of the equal-to-pivot range [low .. high]
template <typename T>
std::pair<std::size_t, std::size_t> dutch_flag_partition(std::vector<T>& a, const T& pivot) noexcept {
  if (a.empty()) return {0, 0};
  std::size_t low = 0;
  std::size_t mid = 0;
  std::size_t high = a.size(); // Use half-open [mid, high) to avoid unsigned underflow!

  while (mid < high) {
    if (a[mid] < pivot) {
      std::swap(a[low], a[mid]);
      ++low;
      ++mid;
    } else if (a[mid] == pivot) {
      ++mid;
    } else {
      --high;
      std::swap(a[mid], a[high]);
      // Critical: do NOT increment mid; the element swapped from a[high] is unclassified!
    }
  }
  return {low, high};
}

// O(N) Time, O(1) Space Converging Two-Pointer Trapping Rain Water
// Invariant: if height[left] <= height[right], then max_left <= max_right (or height[right]
// acts as a guaranteed right-hand wall >= height[left]), so water at left is bounded
// exclusively by max_left.
std::int64_t trap_rain_water(const std::vector<int>& height) noexcept {
  if (height.size() < 3) return 0;
  std::size_t left = 0, right = height.size() - 1;
  int max_left = height[left], max_right = height[right];
  std::int64_t trapped = 0;

  while (left < right) {
    if (max_left <= max_right) {
      ++left;
      max_left = std::max(max_left, height[left]);
      trapped += (max_left - height[left]);
    } else {
      --right;
      max_right = std::max(max_right, height[right]);
      trapped += (max_right - height[right]);
    }
  }
  return trapped;
}
Production Pitfall: Incrementing mid After Swapping with high & Unsigned Underflow

In the Dutch National Flag partition, the #1 bug is incrementing mid in the A[mid] > pivot branch after swap(A[mid], A[high]). When you swap A[low] and A[mid], you already know A[low] == pivot (unless low == mid), so mid can safely advance. When you swap A[mid] and A[high], the value coming from A[high] was inside the unclassified region—incrementing mid skips inspecting it! Furthermore, if high is a size_t initialized to N - 1 and every element exceeds the pivot, --high underflows to SIZE_MAX. Always maintain the unclassified region as a half-open interval [mid, high) where high starts at N.

Check yourself Question 1 of 4
Concept Check Dijkstra's Dutch National Flag 4-Region Invariant recall

In Dijkstra's 3-way Dutch National Flag partition around pivot $p$, pointers `low`, `mid`, and `high` partition the array into four regions: $[0 \dots \text{low}-1] < p$, $[\text{low} \dots \text{mid}-1] == p$, $[\text{mid} \dots \text{high}]$ unclassified, and $[\text{high}+1 \dots N-1] > p$.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

What causes the pipeline flushes in standard 2-way/3-way partitioning, and how does BlockQuicksort (used in Rust's `pdqsort`) eliminate them?

Key Takeaways

Converging two-pointer algorithms run in O(N) by proving that each step eliminates an entire row or column of the N x N pair search matrix.
Dijkstra's 3-way Dutch National Flag partition maintains four contiguous regions: [0..low) < p, [low..mid) == p, [mid..high) unclassified, and [high..N) > p.
When swapping A[mid] with the unclassified element at the high boundary, never increment mid—the incoming element has not yet been compared against the pivot.
Use a half-open unclassified boundary [mid, high) with high initialized to N to prevent unsigned integer underflow when all elements exceed the pivot.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue