Chapter 27: Heaps and Priority Queues
27.05Intermediate / Advanced5 topics18 min4 interactive animations

Priority scheduling

Priority queues in scheduling and search: event simulation, weighted tasks, Dijkstra's queue, best-first search and Huffman construction.

Learning goals

  • Drive a discrete-event simulation with a queue ordered by time
  • Schedule tasks by priority or length with a heap of the ready tasks
  • Use a priority queue as the frontier of Dijkstra's algorithm and A*
  • Build a Huffman tree by repeated extraction

Prerequisites

Heap operationsGreedy algorithms and exchange arguments

Whenever a program must always do the best available thing next, and 'best' changes as items arrive, a priority queue is the structure that decides.

27.05.1

Event simulation

A **discrete-event simulation** keeps a priority queue of future events ordered by time. The loop is always the same: remove the earliest event, advance the clock to its time, handle it, and schedule whatever new events it causes. Nothing happens between events, so the clock jumps instead of ticking. A single-server queue (a bank counter) needs only two kinds of event, arrival and departure.

The code below simulates customers served in arrival order, and the departure time of customer i must equal max(arrival, previous departure) + service time, which the test cross-checks. The scheduling animation on this page uses the same 'jump to the next event' idea in its timeline.

Java
1record Event(int time, int type, int id) {} // type 0 = arrival, 1 = departure
2static int[] simulate(int[] arrival, int[] service) { // single server, first come first served
3 PriorityQueue<Event> events = new PriorityQueue<>(Comparator.comparingInt(Event::time).thenComparingInt(Event::type).thenComparingInt(Event::id));
4 for (int i = 0; i < arrival.length; i++) events.add(new Event(arrival[i], 0, i));
5 int[] departure = new int[arrival.length];
6 Deque<Integer> waiting = new ArrayDeque<>();
7 boolean busy = false;
8 while (!events.isEmpty()) {
9 Event e = events.poll(); // the earliest event: advance the clock to it
10 if (e.type() == 0) {
11 if (busy) waiting.add(e.id());
12 else { busy = true; events.add(new Event(e.time() + service[e.id()], 1, e.id())); }
13 } else {
14 departure[e.id()] = e.time();
15 if (waiting.isEmpty()) busy = false;
16 else { int next = waiting.poll(); events.add(new Event(e.time() + service[next], 1, next)); }
17 }
18 }
19 return departure;
20}

CPU scheduling with a priority queue: priority, shortest-job-first and preemption

Interactive · edit the input

Your input

Try
t = 0

CPU (running)

empty

arrivals

T1
T2
T3
T4
01234

ready queue (best first)

none
Setup

Highest priority first. Tasks are given as arrival:duration:priority. A priority queue keeps the waiting tasks, so the next task to run is always found in O(log n).

Step 1 / 8
Java
1record Task(String name, int arrival, int burst, int priority) {}
2static double schedule(List<Task> tasks) { // highest priority first; ties go to the earlier arrival
3 tasks.sort(Comparator.comparingInt(Task::arrival));
4 PriorityQueue<Task> ready = new PriorityQueue<>(
5 Comparator.comparingInt(Task::priority).reversed().thenComparingInt(Task::arrival));
6 int time = 0, next = 0, totalWait = 0;
7 while (next < tasks.size() || !ready.isEmpty()) {
8 while (next < tasks.size() && tasks.get(next).arrival() <= time) ready.add(tasks.get(next++)); // arrivals
9 if (ready.isEmpty()) { time = tasks.get(next).arrival(); continue; } // idle gap
10 Task run = ready.poll(); // the best waiting task
11 totalWait += time - run.arrival();
12 time += run.burst(); // non-preemptive: it runs to completion
13 }
14 return (double) totalWait / tasks.size();
15}
16// preemptive variant: run only until the next arrival, then put the unfinished remainder back into the queue

Live state

0 context switches

time

0

waiting

0

done

0

No condition evaluated on this step
Execution trace (dry run table)Show 8 steps
StepLineStateWhat happens
1L1time=0 waiting=0 done=0Highest priority first. Tasks are given as arrival:duration:priority. A priority queue keeps the waiting tasks, so the next task to run is always found in O(log n).
2L8time=0 waiting=1 done=0Time 0: T1 arrived and joined the ready queue.
3L11time=5 waiting=0 done=1T1 ran to time 5 and finished (waited 0).
4L8time=5 waiting=3 done=1Time 5: T2, T3, T4 arrived and joined the ready queue.
5L11time=7 waiting=2 done=2T4 ran to time 7 and finished (waited 2).
6L11time=10 waiting=1 done=3T2 ran to time 10 and finished (waited 6).
7L11time=11 waiting=0 done=4T3 ran to time 11 and finished (waited 8).
8L14time=11 waiting=0 done=4All tasks finished at time 11. Waiting times T1: 0, T2: 6, T3: 8, T4: 2; average waiting time 4.00.
27.05.2

Weighted task scheduling

Tasks with arrival times, durations and priorities are scheduled by keeping the **ready tasks in a priority queue**: whenever the CPU is free, take the best waiting task. 'Best' is the policy: highest priority, or shortest job first (which minimises the average waiting time among non-preemptive orders). **Preemptive** priority scheduling also checks at every arrival whether the newcomer outranks the running task.

For the four tasks 0:5:2, 1:3:4, 2:1:1, 3:2:5 (arrival:duration:priority) the animation's three presets give average waiting times of 4.00 (priority), 3.25 (shortest job first) and 3.75 (preemptive priority). Ties are broken by arrival time and then by task number so that results are reproducible.

Java
1record Task(String name, int arrival, int burst, int priority) {}
2static double schedule(List<Task> tasks) { // highest priority first; ties go to the earlier arrival
3 tasks.sort(Comparator.comparingInt(Task::arrival));
4 PriorityQueue<Task> ready = new PriorityQueue<>(
5 Comparator.comparingInt(Task::priority).reversed().thenComparingInt(Task::arrival));
6 int time = 0, next = 0, totalWait = 0;
7 while (next < tasks.size() || !ready.isEmpty()) {
8 while (next < tasks.size() && tasks.get(next).arrival() <= time) ready.add(tasks.get(next++)); // arrivals
9 if (ready.isEmpty()) { time = tasks.get(next).arrival(); continue; } // idle gap
10 Task run = ready.poll(); // the best waiting task
11 totalWait += time - run.arrival();
12 time += run.burst(); // non-preemptive: it runs to completion
13 }
14 return (double) totalWait / tasks.size();
15}

Best

O(n log n)

Average

O(n log n)

Worst

O(n log n)

Space

O(n)

CPU scheduling with a priority queue: priority, shortest-job-first and preemption

Interactive · edit the input

Your input

Try
t = 0

CPU (running)

empty

arrivals

T1
T2
T3
T4
01234

ready queue (best first)

none
Setup

Highest priority first. Tasks are given as arrival:duration:priority. A priority queue keeps the waiting tasks, so the next task to run is always found in O(log n).

Step 1 / 8
Java
1record Task(String name, int arrival, int burst, int priority) {}
2static double schedule(List<Task> tasks) { // highest priority first; ties go to the earlier arrival
3 tasks.sort(Comparator.comparingInt(Task::arrival));
4 PriorityQueue<Task> ready = new PriorityQueue<>(
5 Comparator.comparingInt(Task::priority).reversed().thenComparingInt(Task::arrival));
6 int time = 0, next = 0, totalWait = 0;
7 while (next < tasks.size() || !ready.isEmpty()) {
8 while (next < tasks.size() && tasks.get(next).arrival() <= time) ready.add(tasks.get(next++)); // arrivals
9 if (ready.isEmpty()) { time = tasks.get(next).arrival(); continue; } // idle gap
10 Task run = ready.poll(); // the best waiting task
11 totalWait += time - run.arrival();
12 time += run.burst(); // non-preemptive: it runs to completion
13 }
14 return (double) totalWait / tasks.size();
15}
16// preemptive variant: run only until the next arrival, then put the unfinished remainder back into the queue

Live state

0 context switches

time

0

waiting

0

done

0

No condition evaluated on this step
Execution trace (dry run table)Show 8 steps
StepLineStateWhat happens
1L1time=0 waiting=0 done=0Highest priority first. Tasks are given as arrival:duration:priority. A priority queue keeps the waiting tasks, so the next task to run is always found in O(log n).
2L8time=0 waiting=1 done=0Time 0: T1 arrived and joined the ready queue.
3L11time=5 waiting=0 done=1T1 ran to time 5 and finished (waited 0).
4L8time=5 waiting=3 done=1Time 5: T2, T3, T4 arrived and joined the ready queue.
5L11time=7 waiting=2 done=2T4 ran to time 7 and finished (waited 2).
6L11time=10 waiting=1 done=3T2 ran to time 10 and finished (waited 6).
7L11time=11 waiting=0 done=4T3 ran to time 11 and finished (waited 8).
8L14time=11 waiting=0 done=4All tasks finished at time 11. Waiting times T1: 0, T2: 6, T3: 8, T4: 2; average waiting time 4.00.
27.05.3

Dijkstra queue

Dijkstra's algorithm is a priority queue of vertices keyed by tentative distance: pop the closest unsettled vertex, relax its edges and push the improved neighbours. A binary heap has no decrease-key in Java's PriorityQueue, so the usual code pushes a **new entry** whenever a distance improves and skips stale entries when popped (the same lazy idea as before). With a heap it costs O((V + E) log V).

The “Non-negative weights” preset ends with distances [0, 3, 1, 4]. The “Negative edge: Dijkstra is wrong” preset shows what the algorithm assumes: popping a vertex means its distance is final, which fails with negative weights.

Java
1// adj.get(u) holds {v, weight}, all weights >= 0
2static long[] dijkstra(List<List<int[]>> adj, int source) {
3 long[] dist = new long[adj.size()];
4 Arrays.fill(dist, Long.MAX_VALUE);
5 dist[source] = 0;
6 PriorityQueue<long[]> pq = new PriorityQueue<>(Comparator.comparingLong(e -> e[1])); // {vertex, distance}
7 pq.add(new long[]{source, 0});
8 while (!pq.isEmpty()) {
9 long[] cur = pq.poll();
10 int u = (int) cur[0];
11 if (cur[1] > dist[u]) continue; // a stale entry: a shorter path was found after this was pushed
12 for (int[] edge : adj.get(u)) {
13 long nd = cur[1] + edge[1];
14 if (nd < dist[edge[0]]) { dist[edge[0]] = nd; pq.add(new long[]{edge[0], nd}); }
15 }
16 }
17 return dist;
18}

Best

O((V + E) log V)

Average

O((V + E) log V)

Worst

O((V + E) log V)

Space

O(V + E)

Dijkstra's shortest paths, and why negative edges break it

Interactive · edit the input

Your input

Try

dist[] from vertex 0

0v0
∞v1
∞v2
∞v3
Setup

Distances start at ∞ except the source. Each step settles the closest unsettled vertex and relaxes its outgoing edges. Its distance is declared final, which is only justified when no edge is negative.

Step 1 / 11
Java
1static int[] dijkstra(int n, List<int[]>[] adj, int src) { // adj[u] = list of {v, weight}
2 int[] dist = new int[n]; Arrays.fill(dist, INF); dist[src] = 0;
3 boolean[] done = new boolean[n];
4 for (int step = 0; step < n; step++) {
5 int u = -1;
6 for (int v = 0; v < n; v++) if (!done[v] && dist[v] < INF && (u == -1 || dist[v] < dist[u])) u = v; // closest unsettled vertex
7 if (u == -1) break;
8 done[u] = true; // final: valid only when no edge is negative
9 for (int[] e : adj[u]) if (!done[e[0]] && dist[u] + e[1] < dist[e[0]]) dist[e[0]] = dist[u] + e[1]; // relax
10 }
11 return dist;
12}

Live state

vertices

4

negative

false

No condition evaluated on this step
Execution trace (dry run table)Show 11 steps
StepLineStateWhat happens
1L2vertices=4 negative=falseDistances start at ∞ except the source. Each step settles the closest unsettled vertex and relaxes its outgoing edges. Its distance is declared final, which is only justified when no edge is negative.
2L8settled=0 dist=0Settle vertex 0 with distance 0: from now on it will not be updated.
3L9edge=0>1:4 new dist=4Relax 0>1: 0 + 4 = 4 beats the old distance.
4L9edge=0>2:1 new dist=1Relax 0>2: 0 + 1 = 1 beats the old distance.
5L8settled=2 dist=1Settle vertex 2 with distance 1: from now on it will not be updated.
6L9edge=2>1:2 new dist=3Relax 2>1: 1 + 2 = 3 beats the old distance.
7L9edge=2>3:5 new dist=6Relax 2>3: 1 + 5 = 6 beats the old distance.
8L8settled=1 dist=3Settle vertex 1 with distance 3: from now on it will not be updated.
9L9edge=1>3:1 new dist=4Relax 1>3: 3 + 1 = 4 beats the old distance.
10L8settled=3 dist=4Settle vertex 3 with distance 4: from now on it will not be updated.
11L11return=[0, 3, 1, 4] reference=[0, 3, 1, 4]Distances [0, 3, 1, 4] match the Bellman-Ford reference.
27.05.5

Huffman construction

Huffman coding builds its tree with a min-heap: put every symbol in with its frequency, then repeatedly **extract the two lightest** trees, join them under a new node whose weight is their sum, and insert it back. The last tree is the code tree. The total encoded length equals the sum of all the internal weights.

The “abracadabra” preset needs 23 bits against 33 for a fixed 3-bit code on 11 characters, and “Equal frequencies” (abcd) gets no saving: 8 bits against 8. The greedy chapter proved why merging the two smallest is optimal; the code here is the whole construction cost: n - 1 merges of O(log n) each.

Java
1static long huffmanCost(int[] freq) { // the total encoded length in bits
2 PriorityQueue<Long> heap = new PriorityQueue<>();
3 for (int f : freq) heap.add((long) f);
4 long total = 0;
5 while (heap.size() > 1) {
6 long merged = heap.poll() + heap.poll(); // the two lightest trees
7 total += merged; // every merge adds one bit to every symbol below it
8 heap.add(merged);
9 }
10 return total;
11}

Best

O(n log n)

Average

O(n log n)

Worst

O(n log n)

Space

O(n)

Huffman coding: merge the two rarest symbols

Interactive · edit the input

Your input

Try

Huffman forest · 5 nodes

a:5
b:2
c:1
d:1
r:2
Merged nowLeafNew internal node

priority queue (lightest first)

c:1
d:1
b:2
r:2
a:5
Setup

Count the symbols: a×5, b×2, c×1, d×1, r×2. Each symbol starts as a one-node tree; repeatedly merge the two lightest trees. Rare symbols end up deep (long codes), frequent ones near the root (short codes).

Step 1 / 6
Java
1static Map<Character, String> huffman(String text) {
2 Map<Character, Integer> freq = new TreeMap<>();
3 for (char c : text.toCharArray()) freq.merge(c, 1, Integer::sum);
4 PriorityQueue<Node> pq = new PriorityQueue<>(Comparator.<Node>comparingInt(n -> n.weight).thenComparingInt(n -> n.order));
5 int order = 0; for (var e : freq.entrySet()) pq.add(new Node(e.getKey(), e.getValue(), order++));
6 while (pq.size() > 1) {
7 Node a = pq.poll(), b = pq.poll(); // the two rarest subtrees
8 pq.add(new Node(a, b, a.weight + b.weight, order++)); // merge: a becomes the left (0) child, b the right (1)
9 }
10 Map<Character, String> codes = new TreeMap<>();
11 assign(pq.poll(), "", codes); // left edge = 0, right edge = 1
12 return codes;
13}

Live state

0 merges

symbols

5

length

11

No condition evaluated on this step
Execution trace (dry run table)Show 6 steps
StepLineStateWhat happens
1L5symbols=5 length=11Count the symbols: a×5, b×2, c×1, d×1, r×2. Each symbol starts as a one-node tree; repeatedly merge the two lightest trees. Rare symbols end up deep (long codes), frequent ones near the root (short codes).
2L8merged=1 + 1 weight=2 trees=4Merge the lightest two (c:1 and d:1) into a node of weight 2: the first becomes the 0-branch, the second the 1-branch.
3L8merged=2 + 2 weight=4 trees=3Merge the lightest two (b:2 and r:2) into a node of weight 4: the first becomes the 0-branch, the second the 1-branch.
4L8merged=2 + 4 weight=6 trees=2Merge the lightest two (2 and 4) into a node of weight 6: the first becomes the 0-branch, the second the 1-branch.
5L8merged=5 + 6 weight=11 trees=1Merge the lightest two (a:5 and 6) into a node of weight 11: the first becomes the 0-branch, the second the 1-branch.
6L12bits=23 fixed=33 return=a=0, b=110, c=100, d=101, r=111Encoded length 23 bits, against 33 bits with fixed-length codes. No code is a prefix of another, so decoding is unambiguous.

Practice ladder

Now try it yourself

Easy

  • Last Stone Weight
  • Minimum Cost to Connect Sticks

Medium

  • Task Scheduler
  • Network Delay Time
  • Meeting Rooms II

Advanced

  • Shortest Path in a Grid with Obstacles
  • The Skyline Problem

Key takeaways

  • A discrete-event simulation repeatedly removes the earliest event from a priority queue.
  • Scheduling policies are comparators on the ready queue: priority, shortest job, deadline.
  • Dijkstra's frontier is a priority queue; with Java's PriorityQueue push a new entry and skip stale ones.
  • Best-first search orders by g (Dijkstra), h (greedy) or g + h (A*, optimal for an admissible h).
  • Huffman repeatedly merges the two lightest trees with a min-heap.