Cairn
data structures · linear · O(n) amortized total · sliding window max/min

back to Linear

Monotonic Deque

A monotonic deque is a plain Deque — the exact same push/pop primitive at both ends — used with the identical invariant Monotonic Stack applies to one end of a stack, applied instead from both ends of a deque. Before every push at the back, pop off anything already there that would break a chosen order (values kept decreasing, or increasing, front to back); after every push, also drop anything that's fallen off the front because a sliding window moved past it. That gets a question that looks like it needs to recompute a fresh answer for every window — what's the maximum (or minimum) value in this window of size k, for every window position as it slides across the array — down to one O(n) left-to-right pass, instead of an O(n·k) full rescan per window. The front of the deque is always the current window's answer, kept correct incrementally rather than recomputed from scratch each time the window moves.

Try it

Each column is index:value. Step through the scan: the deque (front → back) holds candidate indices in decreasing (or increasing) value order, and its front is always the current window's answer once the window has filled. A push at the back first evicts anything it beats; every step also checks whether the deque's front index has aged out of the current window.

array (window highlighted)
deque (front → back)
window max/min found so far

Reference implementation

function slidingWindowExtreme(arr, k, useMax = true) {
  const n = arr.length;
  const result = new Array(n - k + 1);
  const deque = []; // indices only, values stay in decreasing (or increasing) order front to back

  for (let i = 0; i < n; i++) {
    // 1. evict from the back: anything the new value beats can never win again
    while (
      deque.length &&
      (useMax ? arr[deque[deque.length - 1]] <= arr[i] : arr[deque[deque.length - 1]] >= arr[i])
    ) {
      deque.pop();
    }
    deque.push(i);

    // 2. evict from the front: the current front may have aged out of the window
    if (deque[0] <= i - k) {
      deque.shift();
    }

    // 3. once the window has filled, its answer is always the front
    if (i >= k - 1) {
      result[i - k + 1] = arr[deque[0]];
    }
  }

  return result;
}

The deque holds indices, not values, for the same reason Monotonic Stack's does: the value is one array lookup away, but only the index lets step 2 tell whether an entry has aged out of the window at all — two equal values at different positions are indistinguishable by value alone.

Complexity

Time: O(n) amortized total, by the same argument as Monotonic Stack's: every index is pushed onto the back exactly once, and can be popped at most once total across the entire scan — once from the back (beaten by a later value) or once from the front (aged out), never both, since a back-pop removes it before it could ever reach the front. That bounds the whole run's push/pop/evict count well under a small constant multiple of n, regardless of k. Space: O(k) — the deque never holds more than the current window's worth of candidate indices, tighter than Monotonic Stack's O(n) worst case, since aging-out here bounds it independently of value order.

The naive alternative — recompute the max of each window from scratch — does exactly (n − k + 1) × (k − 1) comparisons no matter how the values are arranged, so unlike Monotonic Stack (where data order decides who wins), here it's window size that decides: a small k means few comparisons per window, cheap enough that the naive scan can beat the deque's own bookkeeping overhead. Measured directly, same 8-element array used above, across three window sizes, then a longer array across a wider spread to show the gap actually widen:

arraynkdeque opsnaive comparisons
demo array82157
demo array831512
demo array841515
longer array30557104
longer array301556224

At k=2 the naive scan genuinely wins (7 vs. 15) — one comparison per window is hard to beat with any bookkeeping at all. By k=4 the two are already tied, and past that the deque's near-flat operation count (bounded by a small multiple of n, barely moving from 57 to 56 as k triples on the longer array) pulls further and further ahead of the naive count's linear growth in k. The honest takeaway: this structure earns its keep on large windows, not small ones.

Pitfalls

Skipping the front-eviction check leaves a stale index in the deque, silently. The back-eviction loop (step 1) and the front-aging check (step 2) guard against two different failure modes, and it's easy to write only the first — a value comparison feels like "the algorithm," while the index-age check feels like bookkeeping. On [0, -4, -1, 0, 4] with k=2, the correct window maximums are [0, -1, 0, 4]; dropping only the front-eviction check produces [0, 0, 0, 4] — the second window, [-4, -1], silently reports the stale 0 left over from the first window instead of the real answer, -1, because index 0 was never removed from the front once the window moved past it. Checked across 20,000 random trials (varying n, k, and array values): this omission produces a wrong answer 45.2% of the time — not a rare edge case, close to a coin flip.

Emitting a result before the window has actually filled is a real off-by-one, not just a harmless extra entry. The guard if (i >= k - 1) exists because the first k - 1 positions never see a complete window — dropping it doesn't just add a few low-confidence answers at the start, it produces an output array of the wrong length, shifted against the true one. On the demo's own default array with k=3, the correct 6-entry result is [3, 3, 5, 5, 6, 7]; emitting on every iteration instead produces the 8-entry [1, 3, 3, 3, 5, 5, 6, 7] — two extra entries at the front (answers for windows that were never actually 3 elements wide), and every later real answer sitting at the wrong index because of it.

A plain JS array's shift() isn't really O(1), even though this page's reference implementation uses it for clarity. Array.prototype.shift re-indexes every remaining element, an O(n) operation — the same cost the site's own Deque entry exists specifically to avoid, via a circular buffer that wraps an index instead of physically moving memory. A production implementation of this technique should be backed by a real O(1)-front-and-back deque, not a raw array leaning on shift()/push()/pop() — this page keeps the plain-array version because the indices it evicts are always adjacent at one end, which makes the step-through easy to follow, not because it's the version to actually ship.

Where it shows up

This site's guide, Choosing a Linear Data Structure, places this entry alongside Monotonic Stack — neither competes for the sequence's storage the way the other seven Linear entries compete with each other.