Cairn
data structures · double-ended priority queue · O(log n) insert / delete-min / delete-max · O(1) find-min / find-max

back to Array-Backed Trees

Min-Max Heap

A Binary Heap answers exactly one question fast: what's smallest right now (or, flipped, what's largest — never both from the same heap). Needing both from one live, changing structure is a genuinely different problem, and the obvious fix doesn't work: keeping a separate min-heap and max-heap copy of the same data means every insert and delete has to happen twice, and the two copies have no way to tell each other where an element that was just removed from one used to sit in the other. A Min-Max Heap answers both from a single array, in a single tree shape, with the same O(log n) insert/delete cost a plain heap already has and O(1) for both extremes at once. The entire trick is one extra rule layered on top of the ordinary complete-binary-tree array shape every heap on this site already uses: levels alternate between guaranteeing a minimum and guaranteeing a maximum, and the guarantee holds against every descendant below a node, not just its immediate children.

Try it

The root (level 0) is a min level — shown in black — guaranteed smaller than every single node beneath it anywhere in the tree, not just its two children. Level 1 is a max level — shown in red — each of its (at most two) nodes guaranteed bigger than everything in its own subtree. Level 2 is min again, level 3 max, and so on, all the way down. Insert a value and watch it trickle up through parent and grandparent comparisons; delete the minimum or maximum and watch the last element drop in and trickle back down. The stats line always reports both extremes directly, since finding either one is O(1) here.

black = min level (≤ everything below it) · red = max level (≥ everything below it) · ring = touched by the last operation
Loaded a sample heap. Insert a number, or delete the minimum or maximum and watch the last element trickle back into place.

Why it works

The maximum is always one of exactly two candidates. Because every max-level node is bigger than its entire subtree, and level 1 holds the only max-level nodes directly under the root, the single largest value in the whole heap has to be one of the root's two children — nowhere else could it hide without violating some max level's guarantee on the way there. So findMax is a straight O(1) comparison of at most two values (one, if the heap has exactly two elements; the root itself, if it has exactly one). findMin is even simpler: the root is the minimum, full stop, the same as a plain heap.

Trickling up compares with the grandparent, not the parent — on purpose. A newly inserted value at a min-level index first checks its immediate parent, which sits on a max level: if the new value is bigger than that parent, it violates the parent's max guarantee, so they swap and the new value continues trickling up the max chain from there instead. If it isn't bigger, the parent poses no problem at all — but the new value could still be smaller than some earlier ancestor on its own level type, two levels up, which is the grandparent. So the min-side trickle-up skips the parent entirely and keeps comparing against the grandparent, great-great-grandparent, and so on, swapping and climbing two levels at a time while the new value is smaller. Comparing against the immediate parent instead of the grandparent during this second phase is exactly the mistake measured in Pitfalls below — it looks reasonable and breaks the invariant almost every time.

Trickling down has to look at grandchildren too, and re-check one more comparison after swapping with one. When deleteMin drops the last element into the root and sifts it down, the candidate to swap with isn't just the smaller of the two children — a min-level guarantee has to hold against every descendant, so the candidate is the smallest among up to two children and up to four grandchildren. If that overall smallest value lives at a grandchild and is smaller than the sinking element, they swap — but that same swap can just as easily break the guarantee at the grandchild's own immediate parent (a max-level node, one level below the node being sifted), since whatever value the swap just pushed down there might now be bigger than it. The algorithm re-checks that one specific comparison immediately after the swap and fixes it on the spot, before continuing to sift further down from the grandchild's original position. Skip that recheck and the sift moves past that spot for good, leaving a real violation sitting one level below where the swap happened — measured directly in Pitfalls.

Reference implementation

function level(i) {
  let lvl = 0, start = 0, size = 1;
  while (i >= start + size) { start += size; size *= 2; lvl++; }
  return lvl;
}
const parent = (i) => Math.floor((i - 1) / 2);
const grandparent = (i) => (i < 3 ? -1 : parent(parent(i)));
const isMinLevel = (i) => level(i) % 2 === 0;

class MinMaxHeap {
  #a = [];

  size() { return this.#a.length; }
  #swap(i, j) { [this.#a[i], this.#a[j]] = [this.#a[j], this.#a[i]]; }

  findMin() { return this.#a.length ? this.#a[0] : null; }
  findMax() {
    const n = this.#a.length;
    if (n === 0) return null;
    if (n === 1) return this.#a[0];
    if (n === 2) return this.#a[1];
    return this.#a[1] >= this.#a[2] ? this.#a[1] : this.#a[2];   // one of exactly two candidates
  }

  #children(i) {
    const n = this.#a.length, out = [];
    if (2 * i + 1 < n) out.push(2 * i + 1);
    if (2 * i + 2 < n) out.push(2 * i + 2);
    return out;
  }
  #grandchildren(i) {
    const out = [];
    for (const c of this.#children(i)) out.push(...this.#children(c));
    return out;
  }

  #pushUpMin(i) {
    let gp = grandparent(i);
    while (gp !== -1 && this.#a[i] < this.#a[gp]) { this.#swap(i, gp); i = gp; gp = grandparent(i); }
  }
  #pushUpMax(i) {
    let gp = grandparent(i);
    while (gp !== -1 && this.#a[i] > this.#a[gp]) { this.#swap(i, gp); i = gp; gp = grandparent(i); }
  }

  insert(x) {
    this.#a.push(x);
    const i = this.#a.length - 1;
    if (i === 0) return;
    const p = parent(i);
    if (isMinLevel(i)) {
      if (this.#a[i] > this.#a[p]) { this.#swap(i, p); this.#pushUpMax(p); }
      else this.#pushUpMin(i);
    } else {
      if (this.#a[i] < this.#a[p]) { this.#swap(i, p); this.#pushUpMin(p); }
      else this.#pushUpMax(i);
    }
  }

  #pushDownMin(i) {
    for (;;) {
      const kids = this.#children(i), gkids = this.#grandchildren(i);
      const cands = kids.concat(gkids);
      if (cands.length === 0) break;
      let m = cands[0];
      for (const c of cands) if (this.#a[c] < this.#a[m]) m = c;
      if (gkids.includes(m)) {
        if (this.#a[m] >= this.#a[i]) break;
        this.#swap(m, i);
        const p = parent(m);                                 // m's own immediate parent
        if (this.#a[m] > this.#a[p]) this.#swap(m, p);        // re-check, fix on the spot
        i = m;
      } else {
        if (this.#a[m] < this.#a[i]) this.#swap(m, i);
        break;
      }
    }
  }
  #pushDownMax(i) {   // exact mirror of #pushDownMin with every comparison flipped
    for (;;) {
      const kids = this.#children(i), gkids = this.#grandchildren(i);
      const cands = kids.concat(gkids);
      if (cands.length === 0) break;
      let m = cands[0];
      for (const c of cands) if (this.#a[c] > this.#a[m]) m = c;
      if (gkids.includes(m)) {
        if (this.#a[m] <= this.#a[i]) break;
        this.#swap(m, i);
        const p = parent(m);
        if (this.#a[m] < this.#a[p]) this.#swap(m, p);
        i = m;
      } else {
        if (this.#a[m] > this.#a[i]) this.#swap(m, i);
        break;
      }
    }
  }

  deleteMin() {
    if (this.#a.length === 0) return null;
    const min = this.#a[0];
    const last = this.#a.pop();
    if (this.#a.length > 0) { this.#a[0] = last; this.#pushDownMin(0); }
    return min;
  }
  deleteMax() {
    const n = this.#a.length;
    if (n === 0) return null;
    const idx = n === 1 ? 0 : (n === 2 ? 1 : (this.#a[1] >= this.#a[2] ? 1 : 2));
    const max = this.#a[idx];
    const last = this.#a.pop();
    if (idx < this.#a.length) { this.#a[idx] = last; this.#pushDownMax(idx); }
    return max;
  }
}

Verified against a naive multiset reference (a plain array, re-sorted on every query) across 20,000 randomized trials of up to 40 interleaved insert/delete-min/delete-max operations each — every extracted value matched the reference's true min or max, sizes stayed in sync, and a full level-by-level invariant check (every node against every child and every grandchild, not just children) passed after every single operation, 0 mismatches. A separate pass drained 3,000 randomly built heaps completely two ways — repeated deleteMin and, independently, repeated deleteMax on a fresh copy of the same elements — and checked the output against Array.sort() both ascending and descending: 0 mismatches, confirming this doubles as a min-max-heapsort. Edge cases checked directly: empty heap (findMin/findMax both null), a single element (both extremes equal that element), and an all-duplicate heap of three equal values (still a valid heap, both deletions return the duplicate correctly). This page's own demo script below reimplements the same logic with path-tracking added for the highlight animation, exercised the same way against the reference before shipping.

Pitfalls

Comparing against the parent instead of the grandparent when trickling up silently breaks the invariant almost every time. It's a natural mistake: a plain Binary Heap's sift-up only ever looks at the immediate parent, so carrying that same instinct into the min-side or max-side chain here (once the immediate-parent check has already been handled) looks like the obvious continuation. It isn't — the parent is the wrong level type to compare a same-level guarantee against. Measured directly: swapping grandparent(i) for parent(i) in the trickle-up chain and running 3,000 randomized insert-only sequences produced an invalid heap 99.6% of the time.

Skipping the grandchild's-own-parent recheck after a trickle-down swap leaves a real, silent violation one level below where the swap happened. The swap moves a smaller value up into an internal node, but whatever value it displaced downward into the grandchild's old slot might now be bigger than the grandchild's immediate parent — a comparison the algorithm has to make and fix on the spot, not defer, because the sift never comes back that way again. Measured directly: removing that one recheck and running a single deleteMin after 5,000 randomly built heaps (5-34 elements each) left the heap invalid 10.8% of the time — a fraction low enough that a handful of manual spot-checks on small examples would plausibly miss it entirely.

findMax needs its own size-1 and size-2 cases, not just max(a[1], a[2]). The "maximum is one of two candidates" shortcut only holds once both of the root's children actually exist. A heap of exactly one element has neither, so the maximum is the root itself; a heap of exactly two has only a[1], and reading a[2] anyway hits undefinedMath.max(a[1], undefined) evaluates to NaN in JavaScript, not a clean crash and not an obviously wrong-looking number either. Measured directly: naive max(a[1], a[2]) against 2,000 randomly built one- or two-element heaps was wrong 100% of the time at those two sizes specifically (correct at every larger size tested alongside it).

Complexity

Time: insert, deleteMin, and deleteMax are all O(log n) — the trickle chains climb or descend two levels per comparison instead of one, so the real constant factor is roughly half a plain Binary Heap's, but the asymptotic bound is identical, for the same reason: a complete binary tree has height O(log n) regardless of which levels alternate what. findMin and findMax are both O(1) — see Why It Works for why the maximum in particular never needs more than a two-value comparison. Space: O(n), one flat array, zero pointers — identical to a plain heap; the alternating-level rule is purely a rule about which comparisons get made, not an extra field stored anywhere.

This site's guide, Choosing a Range Query Structure, sets this entry aside the same way it already sets aside Binary Heap itself — repeatedly reporting an extreme value out of a changing set isn't a query over an arbitrary range of array positions, the question every other entry there answers. If a single extreme is all that's ever needed, the plain Binary Heap is simpler code and a smaller constant factor for exactly that reason; reach for this entry specifically when both ends of the same live, changing set need to come out cheaply, without paying for two unsynchronized heaps.