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
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.
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.
1record Event(int time, int type, int id) {} // type 0 = arrival, 1 = departure2static int[] simulate(int[] arrival, int[] service) { // single server, first come first served3PriorityQueue<Event> events = new PriorityQueue<>(Comparator.comparingInt(Event::time).thenComparingInt(Event::type).thenComparingInt(Event::id));4for (int i = 0; i < arrival.length; i++) events.add(new Event(arrival[i], 0, i));5int[] departure = new int[arrival.length];6Deque<Integer> waiting = new ArrayDeque<>();7boolean busy = false;8while (!events.isEmpty()) {9Event e = events.poll(); // the earliest event: advance the clock to it10if (e.type() == 0) {11if (busy) waiting.add(e.id());12else { busy = true; events.add(new Event(e.time() + service[e.id()], 1, e.id())); }13} else {14departure[e.id()] = e.time();15if (waiting.isEmpty()) busy = false;16else { int next = waiting.poll(); events.add(new Event(e.time() + service[next], 1, next)); }17}18}19return departure;20}
CPU scheduling with a priority queue: priority, shortest-job-first and preemption
Interactive · edit the inputCPU (running)
arrivals
ready queue (best first)
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).
1record Task(String name, int arrival, int burst, int priority) {}2static double schedule(List<Task> tasks) { // highest priority first; ties go to the earlier arrival3tasks.sort(Comparator.comparingInt(Task::arrival));4PriorityQueue<Task> ready = new PriorityQueue<>(5Comparator.comparingInt(Task::priority).reversed().thenComparingInt(Task::arrival));6int time = 0, next = 0, totalWait = 0;7while (next < tasks.size() || !ready.isEmpty()) {8while (next < tasks.size() && tasks.get(next).arrival() <= time) ready.add(tasks.get(next++)); // arrivals9if (ready.isEmpty()) { time = tasks.get(next).arrival(); continue; } // idle gap10Task run = ready.poll(); // the best waiting task11totalWait += time - run.arrival();12time += run.burst(); // non-preemptive: it runs to completion13}14return (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
time
0
waiting
0
done
0
Execution trace (dry run table)Show 8 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | time=0 waiting=0 done=0 | 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). |
| 2 | L8 | time=0 waiting=1 done=0 | Time 0: T1 arrived and joined the ready queue. |
| 3 | L11 | time=5 waiting=0 done=1 | T1 ran to time 5 and finished (waited 0). |
| 4 | L8 | time=5 waiting=3 done=1 | Time 5: T2, T3, T4 arrived and joined the ready queue. |
| 5 | L11 | time=7 waiting=2 done=2 | T4 ran to time 7 and finished (waited 2). |
| 6 | L11 | time=10 waiting=1 done=3 | T2 ran to time 10 and finished (waited 6). |
| 7 | L11 | time=11 waiting=0 done=4 | T3 ran to time 11 and finished (waited 8). |
| 8 | L14 | time=11 waiting=0 done=4 | All tasks finished at time 11. Waiting times T1: 0, T2: 6, T3: 8, T4: 2; average waiting time 4.00. |
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.
1record Task(String name, int arrival, int burst, int priority) {}2static double schedule(List<Task> tasks) { // highest priority first; ties go to the earlier arrival3tasks.sort(Comparator.comparingInt(Task::arrival));4PriorityQueue<Task> ready = new PriorityQueue<>(5Comparator.comparingInt(Task::priority).reversed().thenComparingInt(Task::arrival));6int time = 0, next = 0, totalWait = 0;7while (next < tasks.size() || !ready.isEmpty()) {8while (next < tasks.size() && tasks.get(next).arrival() <= time) ready.add(tasks.get(next++)); // arrivals9if (ready.isEmpty()) { time = tasks.get(next).arrival(); continue; } // idle gap10Task run = ready.poll(); // the best waiting task11totalWait += time - run.arrival();12time += run.burst(); // non-preemptive: it runs to completion13}14return (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 inputCPU (running)
arrivals
ready queue (best first)
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).
1record Task(String name, int arrival, int burst, int priority) {}2static double schedule(List<Task> tasks) { // highest priority first; ties go to the earlier arrival3tasks.sort(Comparator.comparingInt(Task::arrival));4PriorityQueue<Task> ready = new PriorityQueue<>(5Comparator.comparingInt(Task::priority).reversed().thenComparingInt(Task::arrival));6int time = 0, next = 0, totalWait = 0;7while (next < tasks.size() || !ready.isEmpty()) {8while (next < tasks.size() && tasks.get(next).arrival() <= time) ready.add(tasks.get(next++)); // arrivals9if (ready.isEmpty()) { time = tasks.get(next).arrival(); continue; } // idle gap10Task run = ready.poll(); // the best waiting task11totalWait += time - run.arrival();12time += run.burst(); // non-preemptive: it runs to completion13}14return (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
time
0
waiting
0
done
0
Execution trace (dry run table)Show 8 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | time=0 waiting=0 done=0 | 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). |
| 2 | L8 | time=0 waiting=1 done=0 | Time 0: T1 arrived and joined the ready queue. |
| 3 | L11 | time=5 waiting=0 done=1 | T1 ran to time 5 and finished (waited 0). |
| 4 | L8 | time=5 waiting=3 done=1 | Time 5: T2, T3, T4 arrived and joined the ready queue. |
| 5 | L11 | time=7 waiting=2 done=2 | T4 ran to time 7 and finished (waited 2). |
| 6 | L11 | time=10 waiting=1 done=3 | T2 ran to time 10 and finished (waited 6). |
| 7 | L11 | time=11 waiting=0 done=4 | T3 ran to time 11 and finished (waited 8). |
| 8 | L14 | time=11 waiting=0 done=4 | All tasks finished at time 11. Waiting times T1: 0, T2: 6, T3: 8, T4: 2; average waiting time 4.00. |
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.
1// adj.get(u) holds {v, weight}, all weights >= 02static long[] dijkstra(List<List<int[]>> adj, int source) {3long[] dist = new long[adj.size()];4Arrays.fill(dist, Long.MAX_VALUE);5dist[source] = 0;6PriorityQueue<long[]> pq = new PriorityQueue<>(Comparator.comparingLong(e -> e[1])); // {vertex, distance}7pq.add(new long[]{source, 0});8while (!pq.isEmpty()) {9long[] cur = pq.poll();10int u = (int) cur[0];11if (cur[1] > dist[u]) continue; // a stale entry: a shorter path was found after this was pushed12for (int[] edge : adj.get(u)) {13long nd = cur[1] + edge[1];14if (nd < dist[edge[0]]) { dist[edge[0]] = nd; pq.add(new long[]{edge[0], nd}); }15}16}17return 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 inputdist[] from vertex 0
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.
1static int[] dijkstra(int n, List<int[]>[] adj, int src) { // adj[u] = list of {v, weight}2int[] dist = new int[n]; Arrays.fill(dist, INF); dist[src] = 0;3boolean[] done = new boolean[n];4for (int step = 0; step < n; step++) {5int u = -1;6for (int v = 0; v < n; v++) if (!done[v] && dist[v] < INF && (u == -1 || dist[v] < dist[u])) u = v; // closest unsettled vertex7if (u == -1) break;8done[u] = true; // final: valid only when no edge is negative9for (int[] e : adj[u]) if (!done[e[0]] && dist[u] + e[1] < dist[e[0]]) dist[e[0]] = dist[u] + e[1]; // relax10}11return dist;12}
Live state
vertices
4
negative
false
Execution trace (dry run table)Show 11 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | vertices=4 negative=false | 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. |
| 2 | L8 | settled=0 dist=0 | Settle vertex 0 with distance 0: from now on it will not be updated. |
| 3 | L9 | edge=0>1:4 new dist=4 | Relax 0>1: 0 + 4 = 4 beats the old distance. |
| 4 | L9 | edge=0>2:1 new dist=1 | Relax 0>2: 0 + 1 = 1 beats the old distance. |
| 5 | L8 | settled=2 dist=1 | Settle vertex 2 with distance 1: from now on it will not be updated. |
| 6 | L9 | edge=2>1:2 new dist=3 | Relax 2>1: 1 + 2 = 3 beats the old distance. |
| 7 | L9 | edge=2>3:5 new dist=6 | Relax 2>3: 1 + 5 = 6 beats the old distance. |
| 8 | L8 | settled=1 dist=3 | Settle vertex 1 with distance 3: from now on it will not be updated. |
| 9 | L9 | edge=1>3:1 new dist=4 | Relax 1>3: 3 + 1 = 4 beats the old distance. |
| 10 | L8 | settled=3 dist=4 | Settle vertex 3 with distance 4: from now on it will not be updated. |
| 11 | L11 | return=[0, 3, 1, 4] reference=[0, 3, 1, 4] | Distances [0, 3, 1, 4] match the Bellman-Ford reference. |
Best-first search
**Best-first search** keeps a priority queue of frontier states and always expands the most promising one. The priority decides the algorithm: by distance travelled g, it is Dijkstra; by a guess of the remaining distance h only (**greedy best-first**), it is fast but can return a longer path; by g + h, it is **A\***, which is optimal when h never overestimates the true remaining distance (an *admissible* heuristic).
On a grid with unit steps and four directions, the Manhattan distance to the goal is admissible, and A* returns a shortest path while usually expanding far fewer cells than a plain breadth-first search. The code compares well with BFS in tests: both must return the same length.
| Priority of a frontier state | Algorithm | Optimal path? |
|---|---|---|
| g (cost so far) | Dijkstra / uniform-cost search | yes |
| h (estimated remaining) | Greedy best-first | not guaranteed |
| g + h, h admissible | A* | yes |
1// grid: '#' is a wall; returns the number of steps from (sr, sc) to (tr, tc), or -12static int aStar(char[][] g, int sr, int sc, int tr, int tc) {3int R = g.length, C = g[0].length;4int[][] best = new int[R][C];5for (int[] row : best) Arrays.fill(row, Integer.MAX_VALUE);6PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(e -> e[0])); // {g + h, g, r, c}7best[sr][sc] = 0;8pq.add(new int[]{Math.abs(sr - tr) + Math.abs(sc - tc), 0, sr, sc});9int[] dr = {1, -1, 0, 0}, dc = {0, 0, 1, -1};10while (!pq.isEmpty()) {11int[] cur = pq.poll();12int gCost = cur[1], r = cur[2], c = cur[3];13if (r == tr && c == tc) return gCost;14if (gCost > best[r][c]) continue; // a stale entry15for (int d = 0; d < 4; d++) {16int nr = r + dr[d], nc = c + dc[d];17if (nr < 0 || nc < 0 || nr >= R || nc >= C || g[nr][nc] == '#') continue;18if (gCost + 1 < best[nr][nc]) {19best[nr][nc] = gCost + 1;20pq.add(new int[]{gCost + 1 + Math.abs(nr - tr) + Math.abs(nc - tc), gCost + 1, nr, nc});21}22}23}24return -1;25}
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.
1static long huffmanCost(int[] freq) { // the total encoded length in bits2PriorityQueue<Long> heap = new PriorityQueue<>();3for (int f : freq) heap.add((long) f);4long total = 0;5while (heap.size() > 1) {6long merged = heap.poll() + heap.poll(); // the two lightest trees7total += merged; // every merge adds one bit to every symbol below it8heap.add(merged);9}10return 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 inputHuffman forest · 5 nodes
priority queue (lightest first)
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).
1static Map<Character, String> huffman(String text) {2Map<Character, Integer> freq = new TreeMap<>();3for (char c : text.toCharArray()) freq.merge(c, 1, Integer::sum);4PriorityQueue<Node> pq = new PriorityQueue<>(Comparator.<Node>comparingInt(n -> n.weight).thenComparingInt(n -> n.order));5int order = 0; for (var e : freq.entrySet()) pq.add(new Node(e.getKey(), e.getValue(), order++));6while (pq.size() > 1) {7Node a = pq.poll(), b = pq.poll(); // the two rarest subtrees8pq.add(new Node(a, b, a.weight + b.weight, order++)); // merge: a becomes the left (0) child, b the right (1)9}10Map<Character, String> codes = new TreeMap<>();11assign(pq.poll(), "", codes); // left edge = 0, right edge = 112return codes;13}
Live state
symbols
5
length
11
Execution trace (dry run table)Show 6 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L5 | symbols=5 length=11 | 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). |
| 2 | L8 | merged=1 + 1 weight=2 trees=4 | Merge 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. |
| 3 | L8 | merged=2 + 2 weight=4 trees=3 | Merge 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. |
| 4 | L8 | merged=2 + 4 weight=6 trees=2 | Merge the lightest two (2 and 4) into a node of weight 6: the first becomes the 0-branch, the second the 1-branch. |
| 5 | L8 | merged=5 + 6 weight=11 trees=1 | Merge the lightest two (a:5 and 6) into a node of weight 11: the first becomes the 0-branch, the second the 1-branch. |
| 6 | L12 | bits=23 fixed=33 return=a=0, b=110, c=100, d=101, r=111 | Encoded 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.