Skip to content
DSA Patterns

Learn

Two Heaps

The two-heaps pattern: a max-heap for the lower half, a min-heap for the upper half, and the rebalancing rule that keeps the median at the tops in O(log n).

3 min readUpdated 2 Sept 2026

#Heap#Streaming#Median#Design

A small pattern with one big idea: when you need the middle of a changing dataset, keep the halves in two heaps facing each other. Sorting on every query is O(n log n) each time; this is O(log n) per insert and O(1) per query.

The idea

  • low — a max-heap holding the smaller half. Its root is the largest of the small

values.

  • high — a min-heap holding the larger half. Its root is the smallest of the large

values.

Keep them balanced in size, and the median is either low's root (odd count) or the average of both roots (even count). Everything at the boundary is instantly reachable; everything else stays unsorted, which is exactly the work you avoid.

low — max-heaphigh — min-heap3125915median = (3 + 5) / 2 = 4
Every value on the left is ≤ every value on the right, and the sizes differ by at most one — so the median is always at the two roots.

The implementation

Python's heapq is min-only, so low stores negated values.

import heapq

class MedianFinder:
    def __init__(self):
        self.low = []      # max-heap via negation: smaller half
        self.high = []     # min-heap: larger half

    def add_num(self, num):
        # 1. always push to low first, then hand its largest to high.
        #    Going through low guarantees the value lands on the correct
        #    side without any comparison against the current median.
        heapq.heappush(self.low, -num)
        heapq.heappush(self.high, -heapq.heappop(self.low))

        # 2. rebalance: low may hold one extra, never one fewer.
        if len(self.high) > len(self.low):
            heapq.heappush(self.low, -heapq.heappop(self.high))

    def find_median(self):
        if len(self.low) > len(self.high):
            return -self.low[0]
        return (-self.low[0] + self.high[0]) / 2

The push-then-transfer trick is what makes this short. The obvious version — compare against the current median, decide which heap, then rebalance — needs several branches and an empty-heap special case. Pushing through low unconditionally handles both.

The invariant

Two conditions, and every bug in this pattern is one of them broken:

  1. Ordering: every value in low ≤ every value in high.
  2. Size: len(low) == len(high) or len(low) == len(high) + 1.

Restore both after every insert and the median query stays O(1) forever.

Sliding Window Median

The Hard variant: the same two heaps, but elements leave as well as arrive. Heaps have no efficient arbitrary delete, so the standard technique is lazy deletion — record the value as removed in a hash map, and discard it when it surfaces at a root.

def prune(heap, to_remove, sign):
    while heap and to_remove.get(sign * heap[0], 0) > 0:
        to_remove[sign * heap[0]] -= 1
        heapq.heappop(heap)

Track the effective sizes separately from the raw heap lengths, or the balance check counts entries that are logically gone. In an interview it is fair to say a balanced BST or an order-statistic tree is the cleaner structure here, and that lazy deletion is the practical workaround.

IPO

The variant that shows the pattern is not only about medians: two heaps can also mean two different orderings of the same data. Keep projects in a min-heap by capital requirement, and as your capital grows, move everything now affordable into a max-heap by profit — then greedily take that heap's root. One heap answers "what can I afford", the other "what pays best".

Complexity

O(log n) per insert, O(1) per median query, O(n) space. Compare against re-sorting at O(n log n) per query, or an insertion into a sorted array at O(n) per insert — the two heaps beat both, and the reason is that you never sort what you do not need ordered.

Mistakes that cost the round

  • Forgetting to negate on the way in or on the way out of the max-heap.
  • Rebalancing before ordering. Sizes can be right while a value sits on the wrong side;

push through low first, then rebalance.

  • Integer division on the even-count median. Use float division.
  • Not deciding which heap keeps the extra element. Pick one, and make the query match.
  • Assuming heaps support delete. They do not — that is what lazy deletion is for.

What to drill

  1. Find Median from Data Stream — the pattern, bare.
  2. Sliding Window Median — with lazy deletion.
  3. IPO — two heaps on two different keys.

All on the 22 DSA Patterns sheet. If heaps in general are new, start with top K elements.

Frequently asked

How do two heaps find a running median?

A max-heap holds the smaller half of the values and a min-heap holds the larger half. Keep every value in the max-heap at or below every value in the min-heap, and keep their sizes within one of each other. The median is then the max-heap's root, or the average of both roots when the count is even — O(1) to read, O(log n) to insert.

Why push to the max-heap first and then transfer?

It removes every branch. Pushing to low and immediately moving its largest element to high guarantees the new value ends up on the correct side without comparing it to the current median, and it works when either heap is empty. A single size rebalance afterwards restores the invariant.

How do you remove an arbitrary element from a heap?

You cannot do it efficiently — a binary heap supports removing the root, not an arbitrary value. The standard workaround is lazy deletion: record the value in a to-remove map and discard it only when it reaches the root, tracking effective sizes separately. If arbitrary deletion is central, a balanced BST is the better structure.

Related