Priority queue
In short: A priority queue always returns the most important element, such as the most urgent order; on a heap both adding and taking are O(log n), and JavaScript has none built in, so you write a small class.
Problem
Who goes first
The delivery dispatcher hands orders to the couriers. Every order has a deadline: in how many minutes it must be at the customer's door. A free courier always gets the most urgent order.
Orders come in all the time. On a Friday evening in a big city 10,000 wait at once, and 100,000 pass through in a day.
The first solution is a list sorted by deadline. For every new order you add it and sort the list again. Sorting is O(n log n) (see merge sort), and that is for every order. Even the smartest sort, which notices that the list is almost sorted, must at least walk through it: n steps for every new order.
And the courier does not need the whole order of the list. Only the first one.
The idea
A queue that only knows who is first
A structure with two actions: add an order and take the most urgent. This is a priority queue. It does not promise an order for all of them, only who is first.
That is exactly what the heap from the lesson on heap sort does. Here it is turned around: a min-heap, where every parent is at most as big as its children. Then the earliest deadline is always at index 0.
- Taking you already know from heap sort. The top leaves, the last element takes its place and sinks down. The difference is that it sinks towards the more urgent child.
- Adding is the new part: sift up. The new order goes to the end of the array. Then it is compared with its parent at index (i - 1) / 2. If it is more urgent, they swap places, and it keeps going up. It stops when the parent is more urgent or when it reaches the top.
See it
Sift up and sift down
Adding: to the end, then up.
push(order) { this.#items.push(order); let i = this.#items.length - 1; while (i > 0) { const parent = Math.floor((i - 1) / 2); if (!this.#less(i, parent)) return; this.#swap(i, parent); i = parent; }}Taking: the top leaves, the last one takes its place and sinks.
poll() { const items = this.#items; const top = items[0]; items[0] = items[items.length - 1]; items.pop(); if (items.length > 0) this.#siftDown(0); return top;}
#siftDown(i) { const n = this.#items.length; while (true) { let low = i; const left = 2 * i + 1; const right = 2 * i + 2; if (left < n && this.#less(left, low)) { low = left; } if (right < n && this.#less(right, low)) { low = right; } if (low === i) return; this.#swap(i, low); i = low; }}#items and #siftDown are private: from outside you only see push, poll and size.Six orders are waiting. A new one with 18 minutes comes in, and the courier takes the most urgent:
const waiting = [ { id: "A-101", deadline: 10 }, { id: "A-102", deadline: 15 }, { id: "A-103", deadline: 30 }, { id: "A-104", deadline: 20 }, { id: "A-105", deadline: 25 }, { id: "A-106", deadline: 40 },];const queue = new DeadlineQueue();for (const o of waiting) queue.push(o);console.log(`Heap: ${queue.deadlines()}`);queue.push({ id: "A-107", deadline: 18 });console.log(`After A-107: ${queue.deadlines()}`);const next = queue.poll();console.log(`The courier takes ${next.id} (${next.deadline} min)`);console.log(`Left: ${queue.deadlines()}`);Heap: [10, 15, 30, 20, 25, 40]After A-107: [10, 15, 18, 20, 25, 40, 30]The courier takes A-101 (10 min)Left: [15, 20, 18, 30, 25, 40]class DeadlineQueue { #items = [];
push(order) { this.#items.push(order); let i = this.#items.length - 1; while (i > 0) { const parent = Math.floor((i - 1) / 2); if (!this.#less(i, parent)) return; this.#swap(i, parent); i = parent; } }
poll() { const items = this.#items; const top = items[0]; items[0] = items[items.length - 1]; items.pop(); if (items.length > 0) this.#siftDown(0); return top; }
#siftDown(i) { const n = this.#items.length; while (true) { let low = i; const left = 2 * i + 1; const right = 2 * i + 2; if (left < n && this.#less(left, low)) { low = left; } if (right < n && this.#less(right, low)) { low = right; } if (low === i) return; this.#swap(i, low); i = low; } }
#less(i, j) { const items = this.#items; return items[i].deadline < items[j].deadline; }
#swap(i, j) { const items = this.#items; [items[i], items[j]] = [items[j], items[i]]; }
get size() { return this.#items.length; }
deadlines() { const all = this.#items.map((o) => o.deadline); return `[${all.join(", ")}]`; }}Step by step, in words
- Six waiting orders, deadlines in minutes. A min-heap: every parent is at most its children, so the most urgent, 10, is on top.
- push: a new order with 18 minutes goes to the end of the array, index 6. In the tree it is a child of 30.
- Sift up: the parent of 6 is (6 - 1) / 2 = 2. 18 is more urgent than 30, so they swap.
- The parent of 2 is 0, with 10 minutes. 10 is more urgent than 18: the order stops. One swap, at most one per level.
- poll: the courier takes the top, the order with 10 minutes. There is a hole at index 0.
- The last element, 30, jumps to the top and the array shrinks to six. Now #siftDown puts it in its place.
- Sift down, as in heap sort but towards the more urgent child: 30 against 15 and 18 swaps with 15, then against 20 and 25 swaps with 20.
- Index 3 has no children: done. On top is 15, the next most urgent. Both moves cost O(log n): the height of the tree.
The array is not sorted: 18 comes before 20. Only what matters is sorted: the earliest deadline is on top.
Try it
Predict the output
Five orders go into an empty queue. What is in the array after that?
const queue = new DeadlineQueue();queue.push({ id: "B-1", deadline: 40 });queue.push({ id: "B-2", deadline: 25 });queue.push({ id: "B-3", deadline: 30 });queue.push({ id: "B-4", deadline: 10 });queue.push({ id: "B-5", deadline: 35 });console.log(queue.deadlines());Try it
Fill in
Sifting up compares the element at index i with its parent. Where is the parent?
const parent = ;The children of p are at 2p + 1 and 2p + 2. The reverse is (i - 1) / 2 with integer division: both 3 and 4 give 1.
The idea
What it costs
- Adding: at most one comparison per level. A tree of n elements has about log₂ n levels: O(log n).
- Taking: two comparisons per level while sinking: also O(log n).
- Who is first: the top is at index 0, without a single comparison: O(1).
Run the dispatcher with both versions. Orders come in one by one, after every second one a courier takes the most urgent, and at the end the couriers take the rest. The count is the comparisons between two deadlines:
- Sorted list: re-sort after every order
O(n²) - Min-heap: sift up and sift down
O(n log n)
| Algorithm | n = 10 | n = 1,000 | n = 10,000 | n = 100,000 |
|---|---|---|---|---|
| Sorted list: re-sort after every order | 11 | 63,430 | 6,250,042 | ≈ 625,004,200 |
| Min-heap: sift up and sift down | 28 | 17,397 | 256,807 | 3,386,161 |
class DeadlineQueue { #items = [];
push(order) { this.#items.push(order); let i = this.#items.length - 1; while (i > 0) { const parent = Math.floor((i - 1) / 2); if (!this.#less(i, parent)) return; this.#swap(i, parent); i = parent; } }
poll() { const items = this.#items; const top = items[0]; items[0] = items[items.length - 1]; items.pop(); if (items.length > 0) this.#siftDown(0); return top; }
#siftDown(i) { const n = this.#items.length; while (true) { let low = i; const left = 2 * i + 1; const right = 2 * i + 2; if (left < n && this.#less(left, low)) { low = left; } if (right < n && this.#less(right, low)) { low = right; } if (low === i) return; this.#swap(i, low); i = low; } }
#less(i, j) { const items = this.#items; return items[i].deadline < items[j].deadline; }
#swap(i, j) { const items = this.#items; [items[i], items[j]] = [items[j], items[i]]; }
get size() { return this.#items.length; }
deadlines() { const all = this.#items.map((o) => o.deadline); return `[${all.join(", ")}]`; }}With 10 orders the sorted list makes 11 comparisons and the heap 28: with few orders the list is perfectly fine. With 10,000 it is 6,250,042 against 256,807. With 100,000 the heap makes 3,386,161, and the list about 625 million. Bench does not run that one; it estimates it from the curve.
The sorted list in Bench is at its best: the new order only slides to its place, and nothing else is sorted. It is still O(n) per order, because it passes on average half the list. The heap is O(log n) per order.
More precisely, if you are curious
Which sort notices that the list is almost sorted? TimSort, used by sort in Python, List.sort and Collections.sort in Java and Array.prototype.sort in JavaScript (see the built-in sort). It finds the sorted part with about n comparisons and puts the new element into it: O(n) per order. Bench counts an even cheaper version, sliding from the end, and it still comes out O(n). C# List.Sort is introsort and does not look for sorted parts: for every order it makes about n log n comparisons, even more.
Why is sifting up fast in practice too? A new order is usually somewhere in the middle by urgency. Half the elements of a heap are leaves, a quarter are just above them, and so on. So on average a random order stops after a level or two. The worst case (the new one is the most urgent of all) is still only log₂ n.
Try it
Find the bug
A colleague wrote the sinking, but the couriers sometimes leave with a later order before an earlier one:
#siftDown(i) { const n = this.#items.length; while (true) { let low = i; const left = 2 * i + 1; const right = 2 * i + 2; if (left < n && this.#less(left, low)) { low = left; } if (right < n && this.#less(right, i)) { low = right; } if (low === i) return; this.#swap(i, low); i = low; }}const queue = new DeadlineQueue();queue.push({ id: "C-1", deadline: 5 });queue.push({ id: "C-2", deadline: 10 });queue.push({ id: "C-3", deadline: 20 });queue.push({ id: "C-4", deadline: 30 });queue.push({ id: "C-5", deadline: 40 });queue.push({ id: "C-6", deadline: 50 });const taken = [];while (queue.size > 0) taken.push(queue.poll().deadline);console.log(`[${taken.join(", ")}]`);[5, 20, 10, 30, 40, 50]Which line has the bug?
Pick the line with the bug.
The right child must be compared with the most urgent one so far (low), not with the parent (i). Here both children are more urgent than 50, so both checks pass and the right one, 20, wins, although the left one, 10, is more urgent. 20 ends up above 10 and the heap is broken. With low the output is [5, 10, 20, 30, 40, 50].
See it
The ready-made queues of every language
In a project you do not write a heap yourself. Almost every language has a priority queue, and each one has its quirk.
JavaScript has no built-in priority queue. So we use our DeadlineQueue from above. In a project you use a ready-made package from npm or a short class like this one.
const queue = new DeadlineQueue();queue.push({ id: "A-101", deadline: 30 });queue.push({ id: "A-102", deadline: 10 });queue.push({ id: "A-103", deadline: 20 });queue.push({ id: "A-104", deadline: 25 });console.log(`Inside: ${queue.deadlines()}`);while (queue.size > 0) { const o = queue.poll(); console.log(`${o.id} (${o.deadline} min)`);}Inside: [10, 25, 20, 30]A-102 (10 min)A-103 (20 min)A-104 (25 min)A-101 (30 min)More precisely, if you are curious
None of these queues is stable: two orders with the same deadline can come out in any order (see stability). If the order of arrival matters, add a sequence number, as in Python above.
None of the three can change the deadline of an order that is already waiting. remove in Java searches for the element linearly: O(n). The usual solution is to add the order again with the new deadline and skip the old copy when it comes out. Dijkstra works like that too, in the upcoming graphs module (algo-graphs).
How it is in the other languages: Java has PriorityQueue, C# PriorityQueue<TElement, TPriority> (since .NET 6), Python heapq (functions on a list), and JavaScript has no built-in priority queue.
See it
Two more jobs for a heap
The top k. Which 10 out of a million products sell the most? A min-heap that never holds more than 10 elements: every new one is compared with the top. That is in the lesson on the heap, with a Bench too.
Merging k sorted lists. Three warehouses send their orders sorted by deadline. You need one shared route, again by deadline. The heap holds only the head of every list. You take the earliest one, and in its place you put the next one from the same warehouse:
In JavaScript this is ourDeadlineQueue again: it compares only deadline, so it also accepts objects that remember the warehouse and the position.const lists = [[5, 20, 45], [10, 15, 50], [12, 30]];const names = ["North", "South", "Centre"];const heads = new DeadlineQueue();lists.forEach((list, k) => heads.push({ deadline: list[0], list: k, index: 0 }));const route = [];while (heads.size > 0) { const h = heads.poll(); route.push(`${h.deadline} ${names[h.list]}`); const next = h.index + 1; if (next < lists[h.list].length) { heads.push({ deadline: lists[h.list][next], list: h.list, index: next }); }}console.log(`Route: ${route.join(", ")}`);Route: 5 North, 10 South, 12 Centre, 15 South, 20 North, 30 Centre, 45 North, 50 SouthThe heap never holds more than k elements (3 here). So N orders cost O(N log k), not O(N log N) for sorting everything. This is also how the sorted parts of a file that does not fit in memory are merged.
More precisely, if you are curious
heapq.merge. In Java, C# and JavaScript you write it as here.Merge sort merges sorted parts too, but two at a time (merge sort). A heap merges k at once.
Exercise
A fair queue
Solve the task in your editor.
Same deadline, the earlier one first. Make a queue FairQueue in which, with equal deadlines, the order that came in earlier goes out first. Keep a sequence number with every order and compare by deadline first, then by number. Run it with the orders A-1 (30), A-2 (20), A-3 (30), A-4 (20), A-5 (10), A-6 (20), in this order:
Order: A-5, A-2, A-4, A-6, A-1, A-3More tasks
- Max-heap. Flip the comparison and make a queue that gives the most expensive order first.
- Count the comparisons. Add a counter to sift up and sift down and run 1000 random orders. How many comparisons fall on one order?
- The top three. How do you get the three most urgent orders without taking the others out of the queue?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Best, average and worst case · Merge sort and "divide and conquer" · Heap and heap sort · Stable sorting and the built-in sort
- Continues in: Dijkstra · Minimum spanning tree · A hash table as a counter · City navigation
- Tasks on this topic: Data structures