Priority Queue and Heap Explained — How They Work and When to Use Them
Every time Google Maps recalculates the fastest route while you drive, or a CPU scheduler decides which process runs next, or a game AI figures out the shortest path through a maze — a priority queue is doing the heavy lifting. It's one of those data structures that quietly powers the technology you use every day, yet most developers can't explain how it actually works under the hood.
What a Heap Actually Is — And Why It's Not a Sorted Array
A heap is a complete binary tree that satisfies one simple rule called the heap property. In a min-heap, every parent node is smaller than or equal to its children. In a max-heap, every parent is larger. That's it. That single constraint is what makes heaps so powerful.
Here's the key insight most tutorials skip: a heap is NOT sorted. It's partially ordered. The only guarantee is that the root is the smallest (min-heap) or largest (max-heap) element. The rest of the tree can be in any order as long as each parent beats its children.
Why does partial ordering matter? Because maintaining a fully sorted array every time you insert or remove is O(n log n) work. A heap gets you the most important element in O(1) — just look at the root — and insertion or removal costs only O(log n) because you only need to bubble up or sink down a single path from root to leaf, not reorganise everything.
Heaps are stored in a plain array, not a linked tree structure. For any element at index i, its left child lives at 2i+1, right child at 2i+2, and its parent at floor((i-1)/2). This index arithmetic replaces pointers entirely, making heaps cache-friendly and memory-efficient.
import java.util.Arrays; public class MinHeapDemo { // A hand-rolled min-heap so you can see exactly what's happening static int[] heapArray = new int[10]; static int heapSize = 0; // Insert a value and restore the heap property by bubbling UP static void insert(int value) { if (heapSize == heapArray.length) { throw new IllegalStateException("Heap is full"); } // Place the new value at the next available slot (end of array) heapArray[heapSize] = value; int insertedIndex = heapSize; heapSize++; // Bubble up: keep swapping with parent while we're smaller than it while (insertedIndex > 0) { int parentIndex = (insertedIndex - 1) / 2; if (heapArray[insertedIndex] < heapArray[parentIndex]) { // We're smaller than our parent — swap to fix heap property int temp = heapArray[insertedIndex]; heapArray[insertedIndex] = heapArray[parentIndex]; heapArray[parentIndex] = temp; insertedIndex = parentIndex; // move up and check again } else { break; // heap property is already satisfied } } } // Remove and return the minimum (root) element, then restore the heap static int extractMin() { if (heapSize == 0) throw new IllegalStateException("Heap is empty"); int minValue = heapArray[0]; // root is always the minimum // Move the last element to the root — now we need to sink it down heapArray[0] = heapArray[heapSize - 1]; heapSize--; // Sink down: swap with the smaller child until heap property is restored int currentIndex = 0; while (true) { int leftChild = 2 * currentIndex + 1; int rightChild = 2 * currentIndex + 2; int smallest = currentIndex; if (leftChild < heapSize && heapArray[leftChild] < heapArray[smallest]) { smallest = leftChild; } if (rightChild < heapSize && heapArray[rightChild] < heapArray[smallest]) { smallest = rightChild; } if (smallest == currentIndex) break; // we're already in the right spot // Swap current with the smallest child int temp = heapArray[currentIndex]; heapArray[currentIndex] = heapArray[smallest]; heapArray[smallest] = temp; currentIndex = smallest; } return minValue; } public static void main(String[] args) { int[] valuesToInsert = {40, 10, 30, 5, 20, 15}; System.out.println("Inserting values: " + Arrays.toString(valuesToInsert)); for (int value : valuesToInsert) { insert(value); System.out.println(" After inserting " + value + ": " + Arrays.toString(Arrays.copyOf(heapArray, heapSize))); } System.out.println("\nExtracting elements in priority order (smallest first):"); while (heapSize > 0) { System.out.println(" Extracted min: " + extractMin()); } } }
After inserting 40: [40]
After inserting 10: [10, 40]
After inserting 30: [10, 40, 30]
After inserting 5: [5, 10, 30, 40]
After inserting 20: [5, 10, 30, 40, 20]
After inserting 15: [5, 10, 15, 40, 20, 30]
Extracting elements in priority order (smallest first):
Extracted min: 5
Extracted min: 10
Extracted min: 15
Extracted min: 20
Extracted min: 30
Extracted min: 40
Java's PriorityQueue — Min-Heap by Default, Max-Heap by Choice
Java's built-in PriorityQueue is a min-heap out of the box, meaning poll() always returns the smallest element. For most real problems, you need to customise which element gets priority — and that's where Comparator comes in.
Want a max-heap? Pass Comparator.reverseOrder(). Want to prioritise tasks by urgency level? Write a comparator that compares on the urgency field. The heap machinery stays identical; you're just changing the definition of 'beats'.
One thing that trips people up: Java's PriorityQueue does not sort its internal array. If you call toString() or iterate with a for-each loop, the order looks arbitrary. The guarantee is only that poll() returns elements in priority order. Don't iterate — extract.
The class also doesn't allow null elements, because null can't be compared. Trying to add null throws a NullPointerException immediately, not when you poll — so the stack trace actually points you to the right line.
Real-world pattern: when you need the top-K largest elements from a stream of data, use a min-heap of size K. Every incoming element that beats the heap's minimum evicts it and takes its place. You end up with exactly the K largest, using only O(K) memory regardless of stream size.
import java.util.PriorityQueue; import java.util.Comparator; public class TaskScheduler { // A task with a name and an urgency level (higher number = more urgent) static class Task { String name; int urgencyLevel; Task(String name, int urgencyLevel) { this.name = name; this.urgencyLevel = urgencyLevel; } @Override public String toString() { return name + " (urgency: " + urgencyLevel + ")"; } } public static void main(String[] args) { // --- EXAMPLE 1: Min-heap (default) with integers --- System.out.println("=== Default Min-Heap ==="); PriorityQueue<Integer> minHeap = new PriorityQueue<>(); minHeap.add(50); minHeap.add(10); minHeap.add(30); minHeap.add(20); // poll() always removes the smallest element while (!minHeap.isEmpty()) { System.out.println("Processing ticket #" + minHeap.poll()); } // --- EXAMPLE 2: Max-heap using reverseOrder --- System.out.println("\n=== Max-Heap with reverseOrder ==="); PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder()); maxHeap.add(50); maxHeap.add(10); maxHeap.add(30); maxHeap.add(20); while (!maxHeap.isEmpty()) { System.out.println("Highest score: " + maxHeap.poll()); } // --- EXAMPLE 3: Custom object heap — most urgent task first --- System.out.println("\n=== Custom Task Scheduler (most urgent first) ==="); // Comparator: higher urgencyLevel comes out first (max-heap on urgency) PriorityQueue<Task> taskQueue = new PriorityQueue<>( Comparator.comparingInt((Task t) -> t.urgencyLevel).reversed() ); taskQueue.add(new Task("Update README", 1)); taskQueue.add(new Task("Fix production bug", 10)); taskQueue.add(new Task("Code review", 4)); taskQueue.add(new Task("Deploy hotfix", 9)); taskQueue.add(new Task("Write unit tests", 3)); System.out.println("Tasks executed in priority order:"); while (!taskQueue.isEmpty()) { System.out.println(" Executing: " + taskQueue.poll()); } // --- EXAMPLE 4: Top-K pattern — find 3 largest salaries from a stream --- System.out.println("\n=== Top-3 Salaries from Salary Stream ==="); int[] salaryStream = {45000, 92000, 67000, 110000, 38000, 88000, 75000, 120000}; int topK = 3; // Min-heap of size K — the root is always the smallest of the top-K PriorityQueue<Integer> topSalaries = new PriorityQueue<>(); for (int salary : salaryStream) { topSalaries.add(salary); // If we have more than K elements, evict the smallest // (it can't be in the top-K anymore) if (topSalaries.size() > topK) { topSalaries.poll(); // removes the current minimum } } System.out.println("Top " + topK + " salaries (ascending from heap):"); while (!topSalaries.isEmpty()) { System.out.println(" $" + topSalaries.poll()); } } }
Processing ticket #10
Processing ticket #20
Processing ticket #30
Processing ticket #50
=== Max-Heap with reverseOrder ===
Highest score: 50
Highest score: 30
Highest score: 20
Highest score: 10
=== Custom Task Scheduler (most urgent first) ===
Tasks executed in priority order:
Executing: Fix production bug (urgency: 10)
Executing: Deploy hotfix (urgency: 9)
Executing: Code review (urgency: 4)
Executing: Write unit tests (urgency: 3)
Executing: Update README (urgency: 1)
=== Top-3 Salaries from Salary Stream ===
Top 3 salaries (ascending from heap):
$92000
$110000
$120000
Dijkstra's Algorithm — Where Priority Queues Earn Their Paycheck
Dijkstra's shortest-path algorithm is the canonical real-world use case for a priority queue. The algorithm explores graph nodes in order of their current known distance from the source — always picking the closest unvisited node next. Without a priority queue, you'd scan all nodes each time to find the minimum, giving O(V²) time. With a min-heap, you get O((V + E) log V).
The pattern is always the same: push the source with distance 0. Each time you pop a node, explore its neighbours, compute the new potential distance, and push the neighbour with the updated distance if it's better than what you knew before. The heap handles the sorting automatically.
One subtlety: Java's PriorityQueue has no built-in 'decrease key' operation. The standard workaround is to push a duplicate entry with the improved distance and skip it when you pop it if you've already found a better path. It's a small memory trade-off but works correctly and is simpler to implement in interviews.
This same pattern appears in Prim's minimum spanning tree algorithm, A* pathfinding (used in every game engine), and network routing protocols like OSPF. Once you understand the priority-queue-driven graph traversal pattern, you can implement all of these.
import java.util.*; public class DijkstraShortestPath { // Represents a weighted directed edge in the graph static class Edge { int destinationNode; int travelCost; Edge(int destinationNode, int travelCost) { this.destinationNode = destinationNode; this.travelCost = travelCost; } } // Represents a node and its current best-known distance from the source static class NodeDistance implements Comparable<NodeDistance> { int node; int distanceFromSource; NodeDistance(int node, int distanceFromSource) { this.node = node; this.distanceFromSource = distanceFromSource; } @Override public int compareTo(NodeDistance other) { // Min-heap: the node with the SMALLEST distance gets popped first return Integer.compare(this.distanceFromSource, other.distanceFromSource); } } static int[] dijkstra(List<List<Edge>> graph, int sourceNode, int totalNodes) { // Initialize all distances as infinity (unreachable) int[] shortestDistance = new int[totalNodes]; Arrays.fill(shortestDistance, Integer.MAX_VALUE); shortestDistance[sourceNode] = 0; // distance from source to itself is 0 // Min-heap: always processes the closest unvisited node next PriorityQueue<NodeDistance> minHeap = new PriorityQueue<>(); minHeap.add(new NodeDistance(sourceNode, 0)); while (!minHeap.isEmpty()) { NodeDistance current = minHeap.poll(); int currentNode = current.node; int currentDistance = current.distanceFromSource; // Skip stale entries — we already found a better path to this node if (currentDistance > shortestDistance[currentNode]) { continue; // this is the "lazy deletion" workaround for no decrease-key } // Explore every neighbour of the current node for (Edge edge : graph.get(currentNode)) { int newDistance = shortestDistance[currentNode] + edge.travelCost; // Only update if we found a shorter path than previously known if (newDistance < shortestDistance[edge.destinationNode]) { shortestDistance[edge.destinationNode] = newDistance; // Push the improved distance into the heap minHeap.add(new NodeDistance(edge.destinationNode, newDistance)); } } } return shortestDistance; } public static void main(String[] args) { /* * Graph (city road network): * * City 0 --4--> City 1 * City 0 --1--> City 2 * City 2 --2--> City 1 * City 1 --1--> City 3 * City 2 --5--> City 3 * * Shortest from City 0: * to City 1: 0 -> 2 -> 1 = 3 (not 0->1 directly = 4) * to City 2: 0 -> 2 = 1 * to City 3: 0 -> 2 -> 1 -> 3 = 4 */ int totalNodes = 4; List<List<Edge>> graph = new ArrayList<>(); for (int i = 0; i < totalNodes; i++) { graph.add(new ArrayList<>()); } // Add directed edges (source -> destination, cost) graph.get(0).add(new Edge(1, 4)); graph.get(0).add(new Edge(2, 1)); graph.get(2).add(new Edge(1, 2)); graph.get(1).add(new Edge(3, 1)); graph.get(2).add(new Edge(3, 5)); int sourceCity = 0; int[] distances = dijkstra(graph, sourceCity, totalNodes); System.out.println("Shortest distances from City " + sourceCity + ":"); String[] cityNames = {"City 0 (Start)", "City 1", "City 2", "City 3"}; for (int i = 0; i < totalNodes; i++) { System.out.println(" To " + cityNames[i] + ": " + distances[i] + " km"); } } }
To City 0 (Start): 0 km
To City 1: 3 km
To City 2: 1 km
To City 3: 4 km
| Feature / Aspect | Min-Heap (PriorityQueue default) | Max-Heap (reverseOrder / custom Comparator) |
|---|---|---|
| poll() returns | Smallest element | Largest element |
| Java setup | new PriorityQueue<>() | new PriorityQueue<>(Comparator.reverseOrder()) |
| Real-world use case | Dijkstra's shortest path, top-K largest, scheduling smallest job first | Kth smallest element, leaderboard top scores, bandwidth allocation |
| Heap root stores | Global minimum | Global maximum |
| Insert time complexity | O(log n) | O(log n) |
| Remove min/max time | O(log n) | O(log n) |
| Peek min/max time | O(1) via peek() | O(1) via peek() |
| Custom object priority | Implement Comparable or pass Comparator | Same — reverse the comparator logic |
| Top-K largest values | Use min-heap of size K — eject minimum when overflow | Awkward — would need to sort or use a different approach |
| Memory layout | Array-backed binary tree — cache friendly | Identical — same data structure, different comparator |
🎯 Key Takeaways
- A heap is NOT sorted — it's partially ordered. The only guarantee is the root holds the min (or max). This partial ordering is exactly what makes O(log n) insertion and removal possible.
- Java's PriorityQueue is a min-heap by default. Flip it to a max-heap with Comparator.reverseOrder(), or inject a custom Comparator to prioritise any field on any object.
- The top-K pattern (min-heap of size K for top-K largest, max-heap for top-K smallest) is one of the most valuable heap idioms — it processes a stream of any size in O(n log K) time with only O(K) memory.
- Dijkstra's algorithm is the textbook real-world application of a priority queue — and the 'lazy deletion' trick (pushing duplicate entries instead of decreasing keys) is the standard interview-safe implementation in Java.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Iterating a PriorityQueue with for-each and expecting sorted output — Symptom: the loop prints elements in an unpredictable, unsorted order — Fix: never iterate a PriorityQueue to read in priority order. Always use poll() in a while(!queue.isEmpty()) loop. The internal array is partially ordered, not sorted, so iteration order is meaningless.
- ✕Mistake 2: Mutating a custom object's priority field after it's already inside the PriorityQueue — Symptom: poll() returns elements in the wrong order, and the heap silently produces incorrect results with no exception — Fix: treat objects in a PriorityQueue as immutable with respect to the fields used in comparison. To change priority, remove the object first (O(n)), update it, then re-insert (O(log n)). For high-frequency updates, consider an indexed priority queue instead.
- ✕Mistake 3: Building a max-heap by negating integer values instead of using a proper Comparator — Symptom: works for integers but completely breaks when you switch to long values near Long.MAX_VALUE, causing integer overflow in the negation — Fix: always use Comparator.reverseOrder() for boxed types or write an explicit Comparator. Never rely on negation tricks — they're fragile and confuse every reader of your code.
Interview Questions on This Topic
- QGiven an unsorted array of n integers, find the Kth largest element in O(n log K) time. Walk me through your approach using a heap, and explain why you'd choose a min-heap over a max-heap for this problem.
- QWhat is the time complexity of building a heap from n elements? Most people say O(n log n) — can you prove why it's actually O(n), and what algorithm achieves this?
- QJava's PriorityQueue doesn't support decreaseKey. If you're implementing Dijkstra's on a dense graph with frequent distance updates, what are the two common workarounds, and what are the trade-offs between them in terms of time and space complexity?
Frequently Asked Questions
What is the difference between a priority queue and a heap?
A priority queue is an abstract data type — a concept that describes a queue where elements are served by priority, not arrival order. A heap is one specific data structure that implements this concept efficiently. Java's PriorityQueue class uses a binary min-heap internally, but you could also implement a priority queue with a sorted array or a Fibonacci heap.
Is Java's PriorityQueue a min-heap or a max-heap?
It's a min-heap by default, meaning poll() always returns the smallest element. To make it a max-heap, pass Comparator.reverseOrder() to the constructor. For custom objects, write a Comparator that returns the inverse of the natural order comparison.
Why is building a heap O(n) but inserting n elements one by one is O(n log n)?
When you insert elements one by one, each insertion costs O(log n) because you bubble up from a leaf to the root. Over n insertions that's O(n log n). The O(n) heap-build algorithm (heapify) works differently — it starts from the middle of the array and sinks each element down. Elements near the leaves only travel a tiny distance, and there are far more leaves than internal nodes. When you do the full mathematical sum, the total work converges to O(n), not O(n log n).
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.