DSA Field Notes
Course map / 08 Heap / Priority Queue
NeetCode · DSA for Beginners · Lessons 23-25

Heap / Priority Queue Field Notes

A tree that lives inside an array and always keeps the smallest (or largest) item at the front. After this page you can draw a heap as a tree and as an array, push and pop by hand, build one in O(n), and use Python's heapq without looking anything up.

0Why heaps exist

Analogy

A hospital emergency room. People arrive in some order, but the doctor does not take them in that order. The doctor takes the most urgent patient next, every time. Nobody re-sorts the whole waiting room when a new patient walks in; the room only needs to know who is most urgent right now.

A normal queue is FIFO: first in, first out. A priority queue removes items by priority instead: the item with the highest priority comes out first, no matter when it was inserted. A heap is the data structure that implements a priority queue, and the two words are often used as if they mean the same thing. It is a special tree-based structure, and its trick is that it can give you the minimum (or maximum) in O(1) and add or remove an item in O(log n), without ever fully sorting.

QUEUE (FIFO) inserted in this order: 26, 14, 19, 16 26 14 19 16 26 out first in, first out PRIORITY QUEUE (min) same four values, same insertion order 26 14 19 16 14 out smallest out, then 16, 19, 26
Same input, different rule. A queue cares about arrival time. A min priority queue cares only about value: 14 leaves first even though 26 arrived before it. "Highest priority" means "smallest value" in a min heap and "largest value" in a max heap.

Two types. A min heap keeps the smallest value at the root, so the smallest has the highest priority to be removed. A max heap keeps the largest at the root. The lessons focus on min heaps; a max heap is the same code with the comparisons flipped.

1Heap properties

Two rules a binary tree must satisfy to be a heap

A binary heap is a binary tree with two extra rules. Both must hold at all times.

  1. Structure property. The tree is a complete binary tree: every level is completely filled except possibly the lowest level, and the lowest level is filled contiguously from left to right (no gaps).
  2. Order property (min heap). Every descendant is greater than or equal to its ancestors. If the root has value y, every node in the left subtree and the right subtree has a value >= y. This is recursive, like the BST property: it holds again at every subtree. In a max heap every node in the subtrees is <= y.

Unlike a BST, a heap may contain duplicate values. The lesson heap below has 19 twice.

COMPLETE ✓ 14 19 16 21 26 19 next slot 14 19 16 21 26 19 array has no gaps → index formulas work NOT COMPLETE ✗ 14 19 16 ? ? 19 68 gaps on the left 14 19 16 ? ? 19 68 holes in the array → formulas break
Complete means "no holes when you read level by level, left to right". The left tree is complete even though its last level is not full: the filled part is on the left and the next slot is on the right. The right tree has values on the right of the last level but empty slots on the left. That is not complete, and you can see why it matters: its array would have holes.

Implementation: arrays under the hood

Heaps are drawn as trees but stored as arrays. No node objects, no left or right pointers. The lesson's example heap is the tree below: root 14; children 19 and 16; 19's children 21 and 26; 16's children 19 and 68; 21's children 65 and 30. In NeetCode's drawing the array is [14,19,16,21,26,19,68,65,30,null,null,null,null,null,null], with unused slots shown as null.

How to fill the array: take an array of size n + 1 where n is the number of nodes. Visit the nodes in BFS order (level by level, left to right) and write them one after another, but start at index 1, not index 0.

14 [1] 19 [2] 16 [3] 21 [4] 26 [5] 19 [6] 68 [7] 65 [8] 30 [9] i//2 = 1 2i = 4 2i+1 = 5 BFS order: level by level, left to right 0 1 2 3 4 5 6 7 8 9 dummy 14 19 16 21 26 19 68 65 30 index 10+ unused i//2 = 1 2i = 4 2i+1 = 5
One heap, two pictures. Read the tree level by level and you get the array: 14, 19, 16, 21, 26, 19, 68, 65, 30 at indices 1 to 9. Index 0 holds a dummy value. Node 19 at index 2 finds its left child at index 4 (21), its right child at index 5 (26), and its parent at index 1 (14) using arithmetic only.

One-based indexing: the three formulas

Starting at index 1 lets us compute where a node's children and parent live. Because a binary heap is a complete tree, the array has no gaps, so no pointers are needed. For a node at index i:

leftChild  = 2 * i
rightChild = 2 * i + 1
parent     = i // 2        # integer division

# example: node 19 sits at index 2
#   left child  = 4  → 21
#   right child = 5  → 26
#   parent      = 1  → 14

These formulas only work when the tree is complete and the array is filled contiguously from left to right. That is exactly why the structure property exists.

Why not index 0?

If 14 were stored at index 0, the formula 2 * 0 = 0 would say its left child is also at index 0. That is wrong. Starting at index 1 makes all three formulas clean. (Python's heapq uses index 0 with slightly different formulas; section 4 shows them.)

class Heap:
    def __init__(self):
        self.heap = [0]      # index 0 holds a dummy value; real data starts at index 1
Keep in mind

The number of real elements is len(self.heap) - 1. The last real element is self.heap[-1], which is also the right-most node of the lowest level. Push and pop both use that fact.

2Push and pop

Reading the min (or max) is O(1): return self.heap[1]. Push and pop are more work, because they must keep both properties true, but each is still O(log n). Both use the same idea: put the value in the only place the structure property allows, then swap it up or down until the order property is true again. That swapping is called percolating (or bubbling).

Push: append, then percolate up

Same heap as before; push 17. The structure property says a complete tree is filled contiguously, so 17 must go at index 10 (the next free slot, which is the left child of 26 at index 5). That may break the order property, because 17 is smaller than 26. Fix it by percolating 17 up until its parent is smaller:

  1. Compare 17 with its parent 26 (index 5). Since 17 < 26, swap.
  2. Compare 17 with its new parent 19 (index 2). 17 < 19, swap.
  3. Compare 17 with its new parent 14 (index 1). 17 > 14, stop.
① 17 < 26 → swap 14 19 16 21 26 19 68 65 30 17 17 appended at index 10 ② 17 < 19 → swap 14 19 16 21 17 19 68 65 30 26 17 now at index 5, 26 at 10 ③ 17 > 14 → stop 14 17 16 21 19 19 68 65 30 26 17 at index 2; dashed edge = no swap
Orange = the pair being swapped in that step. 17 climbs one level per swap. It can climb at most the height of the tree, and a complete tree with n nodes has height about log n, so push is O(log n). Notice that 17 always swaps with its parent only; it never looks at siblings.
def push(self, val):
    self.heap.append(val)
    i = len(self.heap) - 1

    # Percolate up
    while i > 1 and self.heap[i] < self.heap[i // 2]:
        tmp = self.heap[i]
        self.heap[i] = self.heap[i // 2]
        self.heap[i // 2] = tmp
        i = i // 2

// is floor division: it rounds down (5.5 becomes 5), which we need because indices must be whole numbers. The loop condition i > 1 stops at the root, which has no parent. The three-line tmp swap is NeetCode's version; in Python you can write it as self.heap[i], self.heap[i // 2] = self.heap[i // 2], self.heap[i]. For a max heap, flip the comparison to >.

StepiCompareActionArray after (index 0 is the dummy)
append10-17 goes to the next free slot[0, 14, 19, 16, 21, 26, 19, 68, 65, 30, 17]
11017 vs parent 26 at 517 < 26 → swap, i = 5[0, 14, 19, 16, 21, 17, 19, 68, 65, 30, 26]
2517 vs parent 19 at 217 < 19 → swap, i = 2[0, 14, 17, 16, 21, 19, 19, 68, 65, 30, 26]
3217 vs parent 14 at 117 > 14 → loop endsunchanged

Pop: the obvious way is wrong

Pop removes the highest-priority value, which is the root. The tempting way: remove the root, pull up the smaller child to fill the gap, then fill that gap with the smaller of its children, and so on. The order property stays fine. But look at the structure: after popping 14 and pulling up 16, then pulling 19 up to replace 16, node 19 at index 3 is missing its left child. The tree is no longer complete, the array has a hole, and every index formula breaks.

Pop: the right way

  1. Read the root element. That is the value to return.
  2. Take the right-most node of the last level (the last array element) and move it into the root. Now the structure property is maintained, because you removed a node from the only place a complete tree can lose one. The order property is violated instead.
  3. Percolate the new root down: swap it with min(left_child, right_child) again and again, until both children are >= it or it has no children. Here: 30 goes to the root, swaps with 16, then swaps with 19.
WRONG: pull children up 16 19 19 21 26 68 65 30 ? hole at 6 order OK, structure broken RIGHT ①: last (30) → root 30 19 16 21 26 19 68 65 slot 9 freed 14 returned structure OK, order broken RIGHT ②③: 30 sinks 16 19 19 21 26 30 68 65 ② ③ both properties OK
Move the last element to the root, then sink it. The wrong method keeps the order property but punches a hole at index 6. The right method first fixes structure (remove from the end, which is always safe in a complete tree), then fixes order by percolating down. At each step 30 swaps with the smaller child: 16 rather than 19 in step ②, 19 rather than 68 in step ③.
def pop(self):
    if len(self.heap) == 1:
        return None
    if len(self.heap) == 2:
        return self.heap.pop()

    res = self.heap[1]
    # Move last value to root
    self.heap[1] = self.heap.pop()
    i = 1
    # Percolate down
    while 2 * i < len(self.heap):
        if (2 * i + 1 < len(self.heap) and
            self.heap[2 * i + 1] < self.heap[2 * i] and
            self.heap[i] > self.heap[2 * i + 1]):
            # Swap right child
            tmp = self.heap[i]
            self.heap[i] = self.heap[2 * i + 1]
            self.heap[2 * i + 1] = tmp
            i = 2 * i + 1
        elif self.heap[i] > self.heap[2 * i]:
            # Swap left child
            tmp = self.heap[i]
            self.heap[i] = self.heap[2 * i]
            self.heap[2 * i] = tmp
            i = 2 * i
        else:
            break
    return res

Walking through pop() line by line

  • Heap is empty (length 1, only the dummy): return None.
  • One node (length 2): self.heap.pop() removes and returns it. No adjustment needed.
  • Otherwise store the root in res, then move the last value into the root with self.heap[1] = self.heap.pop(). Python's list.pop() with no argument removes the last element and returns it, which is exactly "take the right-most node of the last level".
  • The while loop runs as long as a left child exists: 2 * i < len(self.heap). There are three cases for a node: no children, only a left child, two children. A heap can never have only a right child, because that would not be complete.
  • Inside the loop a left child is guaranteed. So first check whether a right child exists (2 * i + 1 in bounds), whether it is smaller than the left child, AND whether it is smaller than the current node. If all three: swap with the right child.
  • Else, if the current node is greater than its left child: swap with the left child. (This also handles the case where the right child exists but the left one is smaller or equal.)
  • Else the node is already in the right position: break.
StepiChildrenActionArray after
start--res = 14; last element 30 moves to index 1[0, 30, 19, 16, 21, 26, 19, 68, 65]
11left 19 at 2, right 16 at 3right < left and 30 > 16 → swap right, i = 3[0, 16, 19, 30, 21, 26, 19, 68, 65]
23left 19 at 6, right 68 at 7right is not smaller; 30 > 19 → swap left, i = 6[0, 16, 19, 19, 21, 26, 30, 68, 65]
362 * 6 = 12 >= 9: noneloop ends; return 14unchanged
Ties go left

The right child is chosen only when it is strictly smaller than the left child. If both children are 19, the code swaps with the left one. Either choice gives a valid heap, but if you trace by hand you must follow the code's rule or your array will not match.

OperationBig-O
Get min / maxO(1)
PushO(log n)
PopO(log n)

Push and pop are O(log n) because a complete tree is always balanced: its height is about log n, and each percolation moves at most one level per swap.

3Heapify: build a heap in O(n)

Building a BST with n elements costs O(n log n): n insertions, each O(log n). Building a heap by pushing the elements one at a time also costs O(n log n). But there is a faster way. Heapify takes any array and turns it into a valid heap in O(n).

The idea

The structure property is free: an array with no gaps is a complete tree. Only the order property (parent <= children) needs work. We get it by percolating each element down until it is in the correct position, exactly like the second half of pop(). Leaf nodes cannot move lower, so we skip the whole last level and start from the last non-leaf node, working backwards to the root.

def heapify(self, arr):
    # 0-th position is moved to the end
    arr.append(arr[0])

    self.heap = arr
    cur = (len(self.heap) - 1) // 2
    while cur > 0:
        # Percolate down
        i = cur
        while 2 * i < len(self.heap):
            if (2 * i + 1 < len(self.heap) and
                self.heap[2 * i + 1] < self.heap[2 * i] and
                self.heap[i] > self.heap[2 * i + 1]):
                # Swap right child
                tmp = self.heap[i]
                self.heap[i] = self.heap[2 * i + 1]
                self.heap[2 * i + 1] = tmp
                i = 2 * i + 1
            elif self.heap[i] > self.heap[2 * i]:
                # Swap left child
                tmp = self.heap[i]
                self.heap[i] = self.heap[2 * i]
                self.heap[2 * i] = tmp
                i = 2 * i
            else:
                break
        cur -= 1
  1. Move the element at index 0 to the end, so index 0 becomes the dummy slot and the real data starts at index 1. (The old value is still sitting at index 0, but nothing ever reads it.)
  2. Compute the index of the first non-leaf node: n // 2, where n is the number of elements. In the code that is (len(self.heap) - 1) // 2.
  3. Starting from that node, percolate down exactly as in pop().
  4. Decrement the index by 1 and repeat for the next non-leaf node, until the root is done.

Take the unsorted array [68, 30, 65, 21, 19, 14, 26, 16, 19]. After step 1 the heap array is [68, 30, 65, 21, 19, 14, 26, 16, 19, 68]; the real tree is indices 1 to 9. With n = 9 the first non-leaf is index 4, so indices 5 to 9 are leaves and are skipped. The lesson's own visual does the same: heapify runs on the nodes from index 4 down to 1.

30 [1] 65 [2] 21 [3] 19 [4] 14 [5] 26 [6] 16 [7] 19 [8] 68 [9] ① cur = 4 ② cur = 3 ③ cur = 2 ④ cur = 1 faded = leaf, never percolated leaves: nothing below them to swap with 0 1 2 3 4 5 6 7 8 9 68 30 65 21 19 14 26 16 19 68 stale copy non-leaves 1..4 = n//2 → percolate down, right to left leaves 5..9: skipped
Half the nodes never move. Heapify visits only indices 4, 3, 2, 1 in that order (right to left, bottom to top). The order matters: when the root's turn comes, both of its subtrees are already valid heaps, so one percolate-down from the root fixes everything.
curNodeChildrenWhat happensIndices 1..9 after
41919 at 8, 68 at 919 is not greater than either child → no swap[30, 65, 21, 19, 14, 26, 16, 19, 68]
32126 at 6, 16 at 7right 16 is smaller and 21 > 16 → swap right; 21 lands on a leaf[30, 65, 16, 19, 14, 26, 21, 19, 68]
26519 at 4, 14 at 5right 14 is smaller and 65 > 14 → swap right; 65 lands on a leaf[30, 14, 16, 19, 65, 26, 21, 19, 68]
13014 at 2, 16 at 3swap left (14); at 2: children 19, 65 → swap left (19); at 4: children 19, 68 → swap left (19); at 8: leaf, stop[14, 19, 16, 19, 65, 26, 21, 30, 68]

The result is a valid min heap: 14 at the top, and every parent is <= its children. Note that the root did 3 swaps while index 4 did none. That imbalance is the whole reason heapify is O(n).

Why O(n) and not O(n log n)

With n nodes, roughly n/2 are leaves and n/2 are non-leaves. Only the non-leaves percolate down. Nodes on the second-lowest level can percolate at most 1 level. The level above that can percolate at most 2. And so on: the root can percolate all the way down. But the number of nodes halves each level you go up, while the distance they can travel only grows by one. The sum of all that work simplifies to O(n).

LEVEL NODES × MAX LEVELS TO SINK root 1 × 3 = 3 level 1 2 × 2 = 4 level 2 4 × 1 = 4 leaves 8 × 0 = 0 (n/2 leaves, skipped) total 11 < n = 15 n/2·0 + n/4·1 + n/8·2 + n/16·3 + … ≤ n
Many nodes do little work; few nodes do much work. The only node that can sink 3 levels is the root, and there is one of it. The 8 leaves, half the tree, do nothing. The series n/4·1 + n/8·2 + n/16·3 + ... adds up to less than n, so heapify is O(n). The full proof is mathematical and is not asked in interviews; the lesson points to Virginia Tech's slides for why about n/2 of the nodes are leaves.
OperationBig-O
Get min / maxO(1)
PushO(log n)
PopO(log n)
HeapifyO(n)
Keep in mind

Heapify is O(n), but popping everything out afterwards is n pops of O(log n) each, so "heapify then pop all" is O(n log n). That is heap sort. Heapify only wins when you need the heap itself, not a fully sorted list.

4heapq: the heap you actually use in Python

The lesson code is for understanding. In interviews and real code you use the heapq module from the standard library. It is a min heap built on a plain Python list. Three things to know before anything else: it is 0-indexed (no dummy slot), it is a set of functions that operate on your list (not a class), and it only does min heaps directly.

parent = (i-1)//2 = 0 014 119 216 321 426 519 668 765 830 heap[0] is the min no dummy slot 2i+1 = 3 2i+2 = 4
Same heap, shifted one slot left. With 0-indexing the formulas become 2i+1, 2i+2 and (i-1)//2. The minimum is always heap[0]. You rarely write these formulas yourself with heapq, but you must know that heap[0] is the min and that the rest of the list is not sorted.

The API

import heapq

heap = []
heapq.heappush(heap, 19)        # O(log n), percolate up
heapq.heappush(heap, 14)
heapq.heappush(heap, 16)
heap[0]                         # → 14   peek at the min, O(1). Do NOT pop to look.
heapq.heappop(heap)             # → 14   O(log n), last-to-root then percolate down

nums = [14, 19, 16, 21, 26, 19, 68, 65, 30]
heapq.heapify(nums)             # O(n), in place, reorders the SAME list
nums[0]                         # → 14

heapq.heappushpop(heap, x)      # push x, then pop the min  (faster than two calls)
heapq.heapreplace(heap, x)      # pop the min, then push x  (heap must be non-empty)

heapq.nlargest(2, [5, 1, 9, 3, 7])            # → [9, 7]
heapq.nsmallest(2, [5, 1, 9, 3, 7])           # → [1, 3]
heapq.nsmallest(1, ["kiwi", "fig", "banana"], key=len)   # → ["fig"]

nlargest and nsmallest return a sorted list of the k extreme items and accept a key= like sorted does. For small k they run in about O(n log k); for k close to n it is cheaper to just sort.

Max heap: negate on the way in, negate on the way out

heapq has no max-heap mode. The standard trick: push -x instead of x. The smallest negative is the largest original, so -heap[0] is the max. Every place you read a value, put the minus sign back.

maxh = []
for v in [3, 10, 7]:
    heapq.heappush(maxh, -v)     # store negatives
-maxh[0]                         # → 10   the max
-heapq.heappop(maxh)             # → 10
-heapq.heappop(maxh)             # → 7

# LeetCode 1046 Last Stone Weight: always smash the two heaviest
def lastStoneWeight(stones):
    heap = [-s for s in stones]
    heapq.heapify(heap)
    while len(heap) > 1:
        first = -heapq.heappop(heap)          # heaviest
        second = -heapq.heappop(heap)         # second heaviest
        if first != second:
            heapq.heappush(heap, -(first - second))
    return -heap[0] if heap else 0

lastStoneWeight([2, 7, 4, 1, 8, 1])   # → 1

Tuples: priority first, tie-breaker second, payload last

Python compares tuples element by element, so a heap of tuples is ordered by the first element, then the second, and so on. Put the priority first. If two items can have the same priority, put a tie-breaker (a counter, an index) second, so the comparison never reaches the payload. This matters when the payload is an object that cannot be compared: Python raises TypeError.

tasks = []
heapq.heappush(tasks, (2, 1, "write tests"))    # (priority, insertion order, payload)
heapq.heappush(tasks, (1, 2, "fix bug"))
heapq.heappush(tasks, (1, 0, "answer pager"))
heapq.heappop(tasks)     # → (1, 0, "answer pager")   priority 1, earliest
heapq.heappop(tasks)     # → (1, 2, "fix bug")
heapq.heappop(tasks)     # → (2, 1, "write tests")

# without a tie-breaker, equal priorities compare the payloads:
heapq.heappush(bad, (1, Job("a")))
heapq.heappush(bad, (1, Job("b")))   # TypeError: '<' not supported between Job and Job
heapq mutates your list

heapq.heapify(nums) returns None and reorders nums in place. Writing heap = heapq.heapify(nums) gives you heap = None. Also, never call heappush on a list you did not heapify first (unless it started empty): the functions assume the list is already a valid heap.

5Moves that solve most heap problems

The lessons teach the structure. Interviews test whether you recognize when a heap is the tool. These four moves cover most heap questions.

Move 1: Top-K with a heap of size k

When: "find the k largest / smallest / closest / most frequent". Keep a heap of size k. For the k largest, use a min heap: push every element, and whenever the heap grows past k, pop the smallest. What survives is the k largest, and the root is the k-th largest. Cost O(n log k), which beats sorting when k is small.

def findKthLargest(nums, k):          # LeetCode 215
    heap = []
    for x in nums:
        heapq.heappush(heap, x)
        if len(heap) > k:
            heapq.heappop(heap)          # throw away the smallest
    return heap[0]                       # k-th largest sits at the root

findKthLargest([3, 2, 1, 5, 6, 4], 2)     # → 5


class KthLargest:                        # LeetCode 703: same idea, but the stream keeps coming
    def __init__(self, k, nums):
        self.k = k
        self.heap = nums
        heapq.heapify(self.heap)
        while len(self.heap) > k:
            heapq.heappop(self.heap)

    def add(self, val):
        heapq.heappush(self.heap, val)
        if len(self.heap) > self.k:
            heapq.heappop(self.heap)
        return self.heap[0]


def kClosest(points, k):                 # LeetCode 973: k SMALLEST → max heap of size k
    heap = []
    for x, y in points:
        d = x * x + y * y
        heapq.heappush(heap, (-d, x, y))     # negate: max heap on distance
        if len(heap) > k:
            heapq.heappop(heap)              # drop the farthest
    return [[x, y] for _, x, y in heap]


def topKFrequent(nums, k):               # LeetCode 347: count, then nlargest by count
    count = Counter(nums)
    return [x for x, _ in heapq.nlargest(k, count.items(), key=lambda p: p[1])]
Keep in mind

k largest → min heap of size k (the root is the weakest survivor, so pop it). k smallest → max heap of size k (negate). Beginners get this backwards. Say out loud: "the root is the one I am willing to throw away".

Move 2: k-way merge

When: "merge k sorted lists / arrays / streams". Put the first element of every list in a heap, tagged with which list it came from. Pop the smallest, then push the next element from that same list. Every element passes through a heap of size at most k, so the total is O(N log k) for N elements. The tuple is (value, list_index, position): the list index is the tie-breaker.

def mergeKSorted(lists):                 # LeetCode 23 on plain lists
    heap = []
    for li, lst in enumerate(lists):
        if lst:
            heapq.heappush(heap, (lst[0], li, 0))    # (value, which list, index in it)
    out = []
    while heap:
        val, li, j = heapq.heappop(heap)
        out.append(val)
        if j + 1 < len(lists[li]):
            heapq.heappush(heap, (lists[li][j + 1], li, j + 1))
    return out

mergeKSorted([[1, 4, 5], [1, 3, 4], [2, 6]])   # → [1, 1, 2, 3, 4, 4, 5, 6]

For linked lists (the real LeetCode 23) push (node.val, i, node) and advance with node.next; the i keeps two equal values from ever comparing ListNode objects.

Move 3: Two heaps for a running median

When: "median of a stream", or any "split the data into a lower half and an upper half" problem. Keep the lower half in a max heap and the upper half in a min heap. The median is then at one root or the average of both. Two rules after every insert: every element of the lower half must be <= every element of the upper half, and the sizes may differ by at most 1.

small: lower half, MAX heap 3 1 2 stored as [-3, -1, -2]; root = -small[0] large: upper half, MIN heap 5 8 10 stored as [5, 8, 10]; root = large[0] 3 ≤ 5 ✓ sizes 3 and 3 ✓ median = (3 + 5) / 2 = 4 both roots, O(1)
The two roots face each other. The max of the lower half and the min of the upper half are the two middle values. Everything below the roots is unsorted and does not matter. After each insert you may need to move one root across to restore the two rules.
class MedianFinder:                      # LeetCode 295
    def __init__(self):
        self.small = []      # max heap (negated): lower half
        self.large = []      # min heap: upper half

    def addNum(self, num):
        heapq.heappush(self.small, -num)
        # rule 1: every element of small must be <= every element of large
        if self.large and -self.small[0] > self.large[0]:
            heapq.heappush(self.large, -heapq.heappop(self.small))
        # rule 2: sizes may differ by at most 1
        if len(self.small) > len(self.large) + 1:
            heapq.heappush(self.large, -heapq.heappop(self.small))
        if len(self.large) > len(self.small) + 1:
            heapq.heappush(self.small, -heapq.heappop(self.large))

    def findMedian(self):
        if len(self.small) > len(self.large):
            return -self.small[0]
        if len(self.large) > len(self.small):
            return self.large[0]
        return (-self.small[0] + self.large[0]) / 2

Move 4: Heap or sort? The decision

A heap is not "a faster sort". It is a structure for when the data keeps changing or when you need only a few extremes. Use this table when you feel the urge to call sorted().

You needUseCost
Everything in order, once, data is fixedsorted()O(n log n)
The k smallest / largest, k much smaller than nheap of size k, or nsmallest / nlargestO(n log k)
Only the min (or max), oncemin() / max()O(n)
The min repeatedly, with inserts in betweenheapO(log n) each
Items arrive over time (a stream) and you must answer as you goheap (or two heaps)O(log n) each
Merge k sorted inputsheap of size kO(N log k)
Always process the "most urgent" thing next (scheduling, Dijkstra)heap of (priority, item)O(log n) each

Rule of thumb: if the word "stream", "repeatedly", "each time", "k-th", or "top k" appears in the problem, think heap. If it says "sort" and the data does not change, just sort.

6Big-O summary

Say these out loud in interviews. n is the number of elements in the heap; k is the size of a bounded heap.

OperationBinary heapSorted Python listUnsorted Python list
Peek min (heap[0])O(1)O(1)O(n)
PushO(log n)O(n) insert + shiftO(1)
Pop minO(log n)O(n) pop(0)O(n) find + remove
Build from n itemsO(n) heapifyO(n log n) sortO(n)
Build by n pushesO(n log n)--
Search for an arbitrary valueO(n)O(log n) binary searchO(n)
Top-k of n (size-k heap)O(n log k)O(n log n) sort then slice-
SpaceO(n), one array, no pointersO(n)O(n)

The heap gives up one thing to get its speed: there is no fast search for a random value, and no fast "delete this specific element". A heap only knows where its min is. If you need arbitrary deletes, the usual trick is lazy deletion: mark the item as dead in a set, and skip dead items when they reach the root.

7Mistakes to watch for

Assuming the array is sorted

Only heap[0] is guaranteed to be the min. heap[1] is not the second smallest; it is just the smaller of two children. To get items in order you must pop repeatedly.

Mixing 1-indexed and 0-indexed formulas

The lesson's class uses a dummy at index 0 and 2i, 2i+1, i//2. heapq has no dummy and uses 2i+1, 2i+2, (i-1)//2. Pick one and stay there; mixing them gives an invalid heap that looks fine at the root.

Pulling children up in pop

Replacing the root with the smaller child, repeatedly, keeps order but leaves a hole: the tree is no longer complete. Always move the last element to the root first, then percolate down.

Percolating down toward the larger child

You must swap with the smaller child, or the larger one becomes the parent of the smaller one and the order property breaks one level down. Check the right child exists before reading it.

heap = heapq.heapify(nums)

heapify works in place and returns None. Write heapq.heapify(nums) on its own line and keep using nums.

Forgetting the minus sign on the way out

With the negation trick, every read must be negated: -heap[0], -heapq.heappop(heap). Forgetting once returns the wrong sign and usually the wrong element.

Min heap for k smallest

For the k smallest you need a size-k max heap, so the root is the largest survivor and can be thrown away. A size-k min heap throws away the small ones, the opposite of what you want.

Tuples with unorderable payloads

(priority, node) raises TypeError the first time two priorities tie. Insert a counter or index: (priority, i, node).

Popping from an empty heap

heapq.heappop([]) raises IndexError. Guard with while heap: or if heap:. The lesson's class returns None instead; know which behavior you are relying on.

Interview one-liners

  • "A heap is a complete binary tree stored in an array, with every parent smaller than its children (min heap). Peek is O(1), push and pop are O(log n)."
  • "Complete means filled level by level, left to right, so the children of index i are at 2i and 2i+1 and the parent is at i//2 with 1-based indexing."
  • "Push appends at the end and percolates up; pop moves the last element to the root and percolates down toward the smaller child."
  • "Heapify is O(n), not O(n log n): half the nodes are leaves and do nothing, and the number of nodes halves each level while the distance grows by one."
  • "For the top k I keep a heap of size k, so the whole pass is O(n log k) instead of sorting in O(n log n)."
  • "Python's heapq is a 0-indexed min heap over a list; for a max heap I push negated values, and I use tuples with a tie-breaker for priorities."
  • "A heap does not support search or arbitrary delete; if I need that I use lazy deletion or a different structure."

8Practice, in this order

Notes do not make you good at this. Solving does. Do these on LeetCode with no notes open, then check against the templates above.

#ProblemMove it trainsLevel
1046Last Stone Weightmax heap via negation, pop two / push oneEasy
703Kth Largest Element in a Streamsize-k min heap on a streamEasy
215Kth Largest Element in an Arraysize-k min heap (also try nlargest)Medium
973K Closest Points to Originsize-k max heap of (−distance, x, y)Medium
347Top K Frequent ElementsCounter + heap of (count, value)Medium
373Find K Pairs with Smallest Sumsk-way merge with (sum, i, j) tuplesMedium
621Task Schedulermax heap of counts + a cooldown queueMedium
767Reorganize Stringmax heap of counts, always take the most frequentMedium
355Design Twitterk-way merge of per-user feeds with a heapMedium
23Merge k Sorted Listsk-way merge, (val, i, node) tie-breakerHard
295Find Median from Data Streamtwo heapsHard

Your next step today: LeetCode 1046. Write it with heapq and the negation trick from memory, run it, then re-read the max heap block in section 4 and compare.

9Self-check

Answer out loud before opening. If you cannot answer in one breath, reread that section.

Q1What are the two heap properties? Which one does "complete" belong to?

Structure property: the tree is complete, every level full except the last, which is filled left to right. Order property: in a min heap every descendant is >= its ancestors (every parent <= its children). "Complete" is the structure property.

Q2With 1-based indexing, where are the children and the parent of index 5?

Left child at 2 * 5 = 10, right child at 2 * 5 + 1 = 11, parent at 5 // 2 = 2. In the lesson heap, index 5 holds 26 and its parent at index 2 is 19.

Q3Why does the lesson start at index 1 and not 0?

With index 0, 2 * 0 = 0 would say the root's left child is the root itself. Starting at 1 makes 2i, 2i+1, i//2 all correct. heapq uses 0 with the adjusted formulas 2i+1, 2i+2, (i-1)//2.

Q4Can a heap contain duplicate values? Can a BST?

A heap can: the lesson heap has 19 twice, and the order property uses >=. A standard BST cannot (or must pick a side rule).

Q5Describe push in one sentence. Why is it O(log n)?

Append the value at the end (the next free slot of the complete tree), then swap it with its parent while it is smaller than the parent. It moves up one level per swap and the tree has about log n levels, because a complete tree is always balanced.

Q6Why is "replace the root with its smaller child, repeatedly" the wrong way to pop?

It keeps the order property but breaks the structure property: the last node moved up leaves a hole somewhere in the middle of the tree, so the tree is no longer complete and the index formulas no longer work.

Q7In percolate-down, which child do you swap with, and what happens if you pick the other one?

The smaller child. If you swap with the larger child, that larger value becomes the parent of the smaller child, which violates the order property one level down.

Q8Why is heapify O(n) when n pushes cost O(n log n)?

Heapify percolates down from the last non-leaf to the root. About n/2 nodes are leaves and do nothing; n/4 nodes can move at most 1 level, n/8 at most 2, and so on. The number of nodes halves while the distance grows by 1, and the sum is bounded by n. Pushing one at a time percolates up, and most nodes end up near the bottom where the path up is long.

Q9Which node does heapify start from, for n elements, and which nodes does it skip?

It starts at index n // 2, the last non-leaf node, and walks down to index 1. It skips indices n // 2 + 1 to n, which are all leaves. For n = 9, it processes 4, 3, 2, 1 and skips 5 to 9.

Q10How do you get a max heap from heapq, and what is the bug people write?

Push -x and negate again on every read: -heap[0], -heapq.heappop(heap). The bug is forgetting the second minus sign on the way out.

Q11You want the k smallest items from a huge list in O(n log k). Min heap or max heap of size k, and why?

A max heap of size k. Its root is the largest of the survivors, which is exactly the one to throw away when the heap grows past k. (For the k largest, use a min heap.)

Q12Trace it: push 12 onto the lesson heap [0, 14, 19, 16, 21, 26, 19, 68, 65, 30]. Give the array after each swap.

Append: 12 at index 10, parent 26 at 5. 12 < 26 → [0, 14, 19, 16, 21, 12, 19, 68, 65, 30, 26]. Parent now 19 at 2, 12 < 19 → [0, 14, 12, 16, 21, 19, 19, 68, 65, 30, 26]. Parent now 14 at 1, 12 < 14 → [0, 12, 14, 16, 21, 19, 19, 68, 65, 30, 26]. i = 1, loop ends. 12 is the new root.

Q13Trace it: pop twice from the lesson heap. What is returned each time and what is the array at the end?

Pop 1 returns 14: 30 goes to the root, swaps with 16, then with 19 → [0, 16, 19, 19, 21, 26, 30, 68, 65]. Pop 2 returns 16: 65 goes to the root; children are 19 and 19, the tie goes left → [0, 19, 65, 19, 21, 26, 30, 68]; at index 2 the children are 21 and 26, swap left → [0, 19, 21, 19, 65, 26, 30, 68]; index 4 has no children, stop.