Data Structures & Algorithms (DSA) Module 10 – Heaps and priority queues
Heapify and heapsort: building a heap in O(n)
Bottom-up heapify turns a list into a heap in O(n) by sifting down from the last parent; heapsort then sorts it in place in O(n log n), traced step by step.
What you will learn
- Build a heap bottom-up in linear time
- Implement heapsort in place
- Compare heapsort with quicksort and merge sort
Before you start
What you’ll learn · Before you start
What you will learn
- Build a heap bottom-up in linear time
- Implement heapsort in place
- Compare heapsort with quicksort and merge sort
Before you start
On this page
Bottom-up heapify, published by Robert Floyd in 1964, turns any list into a heap in O(n) time: it sifts down every parent of the tree, starting with the last parent, at index n // 2 − 1, and ending at the root. Heapsort, published by J. W. J. Williams the same year, then sorts the list in place. Build a max-heap, swap its root, the largest item, with the last item of the heap, shrink the heap by one and sift the new root down; after n − 1 such steps the list is in ascending order. Heapsort takes O(n log n) time on every input and O(1) extra memory, but it is not stable, and on large arrays it is usually slower than quicksort and merge sort, because it jumps around in memory.
Heapify: sift down every parent, last one first
When all the items are already in a list, there is a cheaper way than the one push per item of the
previous lesson. Leave the items where they are and repair the heap property from the
bottom up, as Python’s heapq.heapify does “in-place, in linear time”, in the words of its documentation:
- The indices n // 2 to n − 1 are leaves, and a single item is already a heap.
- For i from n // 2 − 1 down to 0, sift
a[i]down: swap it with its larger child (for a max-heap) while that child is larger. - When the loop has handled index 0, the whole list is a heap.
The order matters. Sifting down at index i assumes that both subtrees below i are heaps already, so that the larger child is the largest item of its subtree. Going from the last parent towards the root makes that true at every step.
The cost is linear because most items live near the bottom of the tree, where there is little room to sink. Half the items are leaves and never move. The quarter just above the leaves can sink one level each, the eighth above them two, and every level higher holds half as many items with one more level of room. With pushes it is the other way round: the many items at the bottom are the ones that may climb the whole height.
Most items sit near the bottom, where there is little room to sink
Text description of the diagram
The diagram is a complete binary tree of 15 items in four levels. Each circle holds the most levels its item can sink during bottom-up heapify, which is the height of that node.
- The root can sink 3 levels.
- The 2 items at depth 1 can sink 2 levels each.
- The 4 items at depth 2 can sink 1 level each.
- The 8 leaves at depth 3 cannot sink at all.
Adding them up, heapify makes at most 4 × 1 + 2 × 2 + 1 × 3 = 11 swaps. Building the same heap with 15 pushes, each new item can climb as far as its depth: 2 items 1 level, 4 items 2 levels and 8 items 3 levels, up to 2 × 1 + 4 × 2 + 8 × 3 = 34 swaps. The deep levels hold most of the items, which is what makes the bottom-up order cheap and the pushes expensive.
Heapsort: the largest item to the end, n − 1 times
Heapsort uses a max-heap, so that the largest item is at index 0 and can move straight to the end of the list:
- Heapify the whole list into a max-heap.
- For
endfrom n − 1 down to 1: swapa[0]witha[end], then sift the new root down insidea[0:end]only.
After each swap the slots from end onwards hold the largest items in ascending order and are never touched again,
and the heap part shrinks by one. The code is short:
The trace · heapsort/trace.py
"""Heapsort on eight numbers: the bottom-up heapify, then every move of the largest item to the sorted tail."""
from heap_tools import sift_down
a = [4, 10, 3, 5, 1, 8, 7, 2]
print(f"start: {a}")
n = len(a)
for i in range(n // 2 - 1, -1, -1):
item = a[i]
swaps = sift_down(a, i, n)
print(f"sift {item:>2} at {i}: {a} ({swaps} swap{'' if swaps == 1 else 's'})")
print(f"max-heap: {a}")
for end in range(n - 1, 0, -1):
a[0], a[end] = a[end], a[0]
swaps = sift_down(a, 0, end)
print(f"{a[end]:>2} to {end}: {a[:end]} | {a[end:]} ({swaps} swap{'' if swaps == 1 else 's'})")
print(f"sorted: {a}") Output
start: [4, 10, 3, 5, 1, 8, 7, 2] sift 5 at 3: [4, 10, 3, 5, 1, 8, 7, 2] (0 swaps) sift 3 at 2: [4, 10, 8, 5, 1, 3, 7, 2] (1 swap) sift 10 at 1: [4, 10, 8, 5, 1, 3, 7, 2] (0 swaps) sift 4 at 0: [10, 5, 8, 4, 1, 3, 7, 2] (2 swaps) max-heap: [10, 5, 8, 4, 1, 3, 7, 2] 10 to 7: [8, 5, 7, 4, 1, 3, 2] | [10] (2 swaps) 8 to 6: [7, 5, 3, 4, 1, 2] | [8, 10] (2 swaps) 7 to 5: [5, 4, 3, 2, 1] | [7, 8, 10] (2 swaps) 5 to 4: [4, 2, 3, 1] | [5, 7, 8, 10] (2 swaps) 4 to 3: [3, 2, 1] | [4, 5, 7, 8, 10] (1 swap) 3 to 2: [2, 1] | [3, 4, 5, 7, 8, 10] (1 swap) 2 to 1: [1] | [2, 3, 4, 5, 7, 8, 10] (0 swaps) sorted: [1, 2, 3, 4, 5, 7, 8, 10]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trace.py
Python · heapsort/heap_tools.py
"""Bottom-up heapify and heapsort on a Python list, with a max-heap: no child is larger than its parent.
Indices start at 0, so the children of i are 2i + 1 and 2i + 2, and only a[0:n] belongs to the heap.
"""
def sift_down(a, i, n):
"""Moves a[i] down inside a[0:n] until no child is larger. Returns the number of swaps it made."""
swaps = 0
while True:
child = 2 * i + 1
if child >= n:
return swaps # a[i] is a leaf
if child + 1 < n and a[child + 1] > a[child]:
child += 1 # the larger child
if a[child] <= a[i]:
return swaps
a[i], a[child] = a[child], a[i]
swaps += 1
i = child
def heapify(a):
"""Turns the whole list into a max-heap in place: sift down every parent, the last parent first."""
return sum(sift_down(a, i, len(a)) for i in range(len(a) // 2 - 1, -1, -1))
def heapsort(a):
"""Sorts a in place in ascending order: heapify, then swap the maximum to the end of the heap n - 1 times."""
heapify(a)
for end in range(len(a) - 1, 0, -1):
a[0], a[end] = a[end], a[0] # the largest item left joins the sorted tail
sift_down(a, 0, end) # the heap is now a[0:end] Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Heapify starts at index 3, the last parent of 8 items. 5 is already larger than its only child, 3 sinks below its larger child 8, 10 stays, and 4 sinks twice, past 10 and then past 5: three swaps build the heap. Each later line moves the root into the slot just left of the sorted tail, 10 to index 7, then 8 to index 6, and sifts the item that took its place down again.
Heapify against n pushes, counted
This run builds max-heaps of 2¹⁰ to 2¹⁶ items both ways, from shuffled input and from ascending input, and counts the swaps. The ratio column compares each size with the one before, which is half as large:
"""Swaps made by bottom-up heapify against n pushes into an empty max-heap, for n = 2**10 to 2**16."""
import random
from heap_tools import heapify
def build_by_pushes(items):
"""Pushes the items one at a time (append, then sift up). Returns the heap and the swaps."""
heap, swaps = [], 0
for x in items:
heap.append(x)
i = len(heap) - 1
while i > 0 and heap[(i - 1) // 2] < heap[i]:
heap[i], heap[(i - 1) // 2] = heap[(i - 1) // 2], heap[i]
swaps += 1
i = (i - 1) // 2
return heap, swaps
rng = random.Random(5)
for order in ("random", "ascending"):
print(f"{order} input: swaps, and the ratio to the size before")
previous = None
for k in range(10, 17):
n = 2**k
items = list(range(n))
if order == "random":
rng.shuffle(items)
floyd = items[:]
h = heapify(floyd)
_, p = build_by_pushes(items)
ratio = f" x{h / previous[0]:.2f} and x{p / previous[1]:.2f}" if previous else ""
print(f" n = 2^{k} = {n:>6,}: heapify {h:>7,} ({h / n:.2f} n), pushes {p:>9,} ({p / n:.2f} n){ratio}")
previous = (h, p) Output
random input: swaps, and the ratio to the size before n = 2^10 = 1,024: heapify 770 (0.75 n), pushes 1,294 (1.26 n) n = 2^11 = 2,048: heapify 1,537 (0.75 n), pushes 2,640 (1.29 n) x2.00 and x2.04 n = 2^12 = 4,096: heapify 3,069 (0.75 n), pushes 5,223 (1.28 n) x2.00 and x1.98 n = 2^13 = 8,192: heapify 6,111 (0.75 n), pushes 10,543 (1.29 n) x1.99 and x2.02 n = 2^14 = 16,384: heapify 12,254 (0.75 n), pushes 21,074 (1.29 n) x2.01 and x2.00 n = 2^15 = 32,768: heapify 24,447 (0.75 n), pushes 42,187 (1.29 n) x2.00 and x2.00 n = 2^16 = 65,536: heapify 48,552 (0.74 n), pushes 83,895 (1.28 n) x1.99 and x1.99 ascending input: swaps, and the ratio to the size before n = 2^10 = 1,024: heapify 1,015 (0.99 n), pushes 8,204 (8.01 n) n = 2^11 = 2,048: heapify 2,038 (1.00 n), pushes 18,445 (9.01 n) x2.01 and x2.25 n = 2^12 = 4,096: heapify 4,085 (1.00 n), pushes 40,974 (10.00 n) x2.00 and x2.22 n = 2^13 = 8,192: heapify 8,180 (1.00 n), pushes 90,127 (11.00 n) x2.00 and x2.20 n = 2^14 = 16,384: heapify 16,371 (1.00 n), pushes 196,624 (12.00 n) x2.00 and x2.18 n = 2^15 = 32,768: heapify 32,754 (1.00 n), pushes 426,001 (13.00 n) x2.00 and x2.17 n = 2^16 = 65,536: heapify 65,521 (1.00 n), pushes 917,522 (14.00 n) x2.00 and x2.15
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_counts.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
On shuffled input both stay close to linear: about 0.75 swaps per item for heapify and 1.29 for the pushes. Ascending input is the worst case for pushes into a max-heap, since every new item is the largest so far and climbs to the root: 8 swaps per item at 1,024 items and 14 at 65,536, each doubling costing 2.15 to 2.25 times as much, the signature of n log n. Heapify stayed at 1.00 swap per item. When all the items are there at the start, heapify is the way to build a heap; pushes are for items that arrive one at a time.
Heapsort, quicksort and merge sort compared
| Criterion | Heapsort | Quicksort | Merge sort |
|---|---|---|---|
| Worst-case time | O(n log n) | O(n²), or O(n log n) as introsort | O(n log n) |
| Extra memory | O(1) | O(log n) for the recursion | A buffer of n items |
| Stable | No | No | Yes |
| Memory access | Jumps between parents and children far apart | Scans ranges from both ends | Reads and writes runs in order |
| Speed on large arrays | Usually the slowest of the three | Usually the fastest | Close to quicksort |
| When to choose | A guaranteed worst case with no extra memory | General in-memory sorting | Stable sorting, linked lists, data on disk |
Heapsort is the only one of the three that guarantees O(n log n) time with O(1) extra memory, which is why libraries keep it as a safety net inside their quicksort.
Exercise and self-check
Write heapsort yourself, in place, without the built-in sort. The sample tests also count the comparisons, so a quadratic sort fails on 4,096 items.
Exercise · Medium · Python, JavaScript
Heapsort a list in place
Write heapsort(a) in heapsort.py (JavaScript: heapsort(a) in heapsort.mjs). It sorts the list a of numbers in ascending order in place: it rearranges the items inside a itself, keeps no second list and returns nothing. Use a max-heap inside a: build it bottom up, then move the largest item to the end of the heap part and sift the new root down, until the heap part holds one item.
Do not call the built-in sort. The sample tests check small lists, repeated and negative numbers, that the list itself is sorted, 200 random lists against the built-in sort, and the number of comparisons on 4,096 items, which must stay below 2n log₂ n + 2n, about 106,000: a quadratic sort needs millions.
Python · Starter code · heapsort.py
def heapsort(a):
"""Sorts the list a in ascending order in place, with a max-heap kept in a itself. Returns nothing."""
# Replace this line: heapify a bottom up, then move the largest item to the end n - 1 times.
raise NotImplementedError The sample tests · test_heapsort.py
import math
import random
from heapsort import heapsort
class NoSort(list):
"""A list whose built-in sort refuses to run."""
def sort(self, *args, **kwargs):
raise AssertionError("write heapsort yourself instead of calling sort()")
def test_small_lists():
"""sorts the lesson's eight numbers and small edge cases in place"""
for values in ([4, 10, 3, 5, 1, 8, 7, 2], [], [7], [2, 1], [3, 1, 3, 1, 2], [-5, 0, -5, 9], [1, 2, 3, 4], [9, 7, 5, 3, 1]):
a = NoSort(values)
assert heapsort(a) is None, "heapsort returns nothing: it sorts the list it was given"
assert list(a) == sorted(values), f"{values}: got {list(a)}"
def test_random_lists():
"""200 random lists of up to 60 numbers match sorted()"""
rng = random.Random(4)
for _ in range(200):
values = [rng.randint(-20, 20) for _ in range(rng.randint(0, 60))]
a = NoSort(values)
heapsort(a)
assert list(a) == sorted(values), f"{values}: got {list(a)}"
class Counted:
"""A number that counts every comparison made with it."""
comparisons = 0
def __init__(self, v):
self.v = v
def __lt__(self, other):
Counted.comparisons += 1
return self.v < other.v
def __le__(self, other):
Counted.comparisons += 1
return self.v <= other.v
def __gt__(self, other):
Counted.comparisons += 1
return self.v > other.v
def __ge__(self, other):
Counted.comparisons += 1
return self.v >= other.v
def test_comparisons_on_4096_items():
"""4,096 random numbers take fewer than 2n log2 n + 2n comparisons"""
rng = random.Random(8)
n = 4096
values = [rng.random() for _ in range(n)]
a = NoSort(Counted(v) for v in values)
Counted.comparisons = 0
heapsort(a)
limit = 2 * n * math.log2(n) + 2 * n
assert Counted.comparisons < limit, f"{Counted.comparisons:,} comparisons; the limit is {limit:,.0f}"
assert [x.v for x in a] == sorted(values) JavaScript · Starter code · heapsort.mjs
/** Sorts the array a in ascending order in place, with a max-heap kept in a itself. Returns nothing. */
export function heapsort(a) {
// Replace this line: heapify a bottom up, then move the largest item to the end n - 1 times.
throw new Error('not written yet');
} The sample tests · heapsort.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { heapsort } from './heapsort.mjs';
const ascending = (a, b) => a - b;
function noSort(values) {
const a = [...values];
a.sort = () => { throw new Error('write heapsort yourself instead of calling sort()'); };
return a;
}
test("sorts the lesson's eight numbers and small edge cases in place", () => {
for (const values of [[4, 10, 3, 5, 1, 8, 7, 2], [], [7], [2, 1], [3, 1, 3, 1, 2], [-5, 0, -5, 9], [1, 2, 3, 4], [9, 7, 5, 3, 1]]) {
const a = noSort(values);
assert.equal(heapsort(a), undefined, 'heapsort returns nothing: it sorts the array it was given');
assert.deepEqual([...a], [...values].sort(ascending), JSON.stringify(values));
}
});
test('200 random arrays of up to 60 numbers match a sorted copy', () => {
let seed = 4;
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
for (let k = 0; k < 200; k++) {
const values = Array.from({ length: Math.floor(random() * 61) }, () => Math.floor(random() * 41) - 20);
const a = noSort(values);
heapsort(a);
assert.deepEqual([...a], [...values].sort(ascending), JSON.stringify(values));
}
});
test('4,096 random numbers take fewer than 2n log2 n + 2n comparisons', () => {
// Comparing two of these numbers with <, <=, > or >= reads both values, so the reads are twice the comparisons.
const box = { reads: 0 };
let seed = 8;
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
const n = 4096;
const values = Array.from({ length: n }, random);
const a = noSort(values.map((v) => ({ v, valueOf() { box.reads++; return v; } })));
heapsort(a);
const comparisons = box.reads / 2;
const limit = 2 * n * Math.log2(n) + 2 * n;
assert.ok(comparisons < limit, `${comparisons} comparisons; the limit is ${limit}`);
assert.deepEqual(a.map((x) => x.v), [...values].sort(ascending));
}); A hint
Write sift_down(a, i, n) first: it moves a[i] down inside a[0:n] only, always towards the larger child, until no child is larger. Heapify calls it for i from len(a) // 2 - 1 down to 0 with n = len(a). Then, for end from len(a) - 1 down to 1, swap a[0] with a[end] and call it with n = end, so the sorted tail is never touched again.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Why heapify is linear: the invariant, the exact bound and the comparisons Pro: opens with a Pro, Premium or Ultimate pass
Linear time is a claim about a sum, and the sum can be pinned down exactly. This part proves the loop correct, reproduces the failure of the opposite order, gives the bound in closed form, checks it against every order of up to nine items and counts the comparisons that the bottom-up sift-down saves.
In this part:
- The loop invariant, and the root-first order failing
- The bound in closed form: n minus the 1-bits of n
- Comparisons, not swaps: the bottom-up sift-down
2 runnable examples (Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Heapsort in depth: its proof, its instability and its speed, measured Pro: opens with a Pro, Premium or Ultimate pass
Heapsort’s guarantees are easy to prove and its weak points are easy to show. This part proves the extraction loop correct, reproduces its instability on student records and times it against the C++ library’s quicksort and merge sort, with the comparisons counted as well.
In this part:
- Why the output is sorted: the invariant of the extraction loop
- Equal keys reordered: the instability, reproduced
- Fewer comparisons, still slower: timed against std::sort and std::stable_sort
- Where heapsort earns its place
2 runnable examples (Build and run, C++, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Heapsort in five languages, the slips that break it, and a practice set Pro: opens with a Pro, Premium or Ultimate pass
The same heapsort reads almost line for line in every interview language, and its slips are small enough to make under time pressure. This part gives recorded runs in five languages, reproduces four slips on the shortest inputs that catch them and ends with a practice set with answers.
In this part:
- Heapsort in Python, JavaScript, Java, C++ and Go
- Four slips, reproduced on lists of two to four items
- Practice set
3 runnable examples (Build and run all three, C++, Go, Java, JavaScript, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
In bottom-up heapify, why does the loop run from index n // 2 − 1 down to 0, and what goes wrong if it runs from 0 upwards?
Sifting an item down is only correct when both subtrees below it are already heaps, because the larger child must be the largest item of its subtree. The indices n // 2 to n − 1 are leaves, heaps of one item, so the loop starts at the last parent, n // 2 − 1, and each step works on a node whose children have been fixed already; when it reaches index 0, the whole list is a heap. Run from the root upwards and that premise is missing: the root is sifted while large items are still buried lower down, and nothing moves them past it later, so a large item can end up below a smaller one. Starting at n − 1 instead of n // 2 − 1 is harmless, only wasteful: leaves have nothing to sift.
-
Mid to senior
A microcontroller must sort 50,000 sensor records of 16 bytes in place, with 8 KB of spare RAM, and the control loop needs a guaranteed worst-case time. Quicksort, merge sort or heapsort: which do you choose, and how many comparisons do you budget for?
-
Mid to senior
A game keeps 1,000,000 scores in an array and must list the top 100, in order, after each nightly batch. You may rearrange the array. Estimate the comparisons of heapify followed by 100 pops against a full sort, and say which you would ship.
-
Mid to senior
A payroll job heapsorts 10 million employee records by salary, but auditors require employees with equal salaries to stay in employee-ID order, the order of the input file. What goes wrong, and which fixes would you consider, with their extra memory?
Key takeaways
- Bottom-up heapify sifts down every parent from index n // 2 − 1 to 0 and builds a heap in O(n): most items are near the bottom and can barely sink.
- Pushes are for items that arrive one at a time; on ascending input they cost O(n log n), 14 swaps per item at 65,536 items against heapify’s 1.
- Heapsort heapifies into a max-heap, then swaps the root to the end and sifts down n − 1 times: O(n log n) on every input, O(1) extra memory, not stable.
- Among the three classic O(n log n) sorts, heapsort is the one that needs no extra memory and has no bad case, which is why libraries keep it as a fallback for quicksort.
References
- Algorithm 245: Treesort (R. W. Floyd, Communications of the ACM 7(12), 1964) (Association for Computing Machinery (ACM))
- Algorithm 232: Heapsort (J. W. J. Williams, Communications of the ACM 7(6), 1964) (Association for Computing Machinery (ACM))
- heapq: Heap queue algorithm (The Python Standard Library) (Python Software Foundation)
- Lib/heapq.py (CPython 3.14 source) (Python Software Foundation (CPython source))
- build-heap (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- heapsort (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- std::sort (cppreference.com) (cppreference.com)
- std::sort_heap (cppreference.com) (cppreference.com)
- libc++ source: pop_heap.h (LLVM Project) (LLVM Project)
- Array.Sort Method (.NET API reference) (Microsoft)
- GATE 2027 syllabus for Computer Science and Information Technology (CS) (IIT Madras (GATE 2027 organising institute))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress