Queue Fundamentals
Queue = FIFO (First In, First Out) data structure
Enqueue 10, 20, 30:
enqueue → ┌────┬────┬────┐ → dequeue
│ 10 │ 20 │ 30 │
└────┴────┴────┘
front back
Dequeue → returns 10 (first enqueued)
┌────┬────┐
│ 20 │ 30 │
└────┴────┘
front back
| Operation | Action | Time |
enqueue | Add to back | O(1) |
dequeue | Remove from front | O(1) |
peek | View front without removing | O(1) |
Real-world analogies:
Checkout line — first person in line served first
Print jobs — first document sent prints first
Customer service — callers handled in order received
Conveyor belt — items processed in arrival order
| Variant | Description |
| Simple Queue | Standard FIFO — enqueue at back, dequeue from front |
| Deque | Double-ended — insert/remove at both front and back |
| Priority Queue | Dequeue by priority, not insertion order |
| Circular Queue | Fixed-size, wraps around — avoids shifting |
- FIFO: the element added first is removed first — the defining property of a queue
- 3 core operations — enqueue, dequeue, peek — all
O(1)
- BFS is the canonical queue algorithm — layer-by-layer exploration uses FIFO ordering
Circular array queue (wrap-around):
Index: [0] [1] [2] [3] [4] [5] [6] [7]
Value: _ _ 10 20 30 40 _ _
↑ front ↑ back
capacity=8, size=4
After enqueue 50, 60, 70, 80 (wraps around):
Index: [0] [1] [2] [3] [4] [5] [6] [7]
Value: 70 80 10 20 30 40 50 60
↑ back ↑ front ← wraps: back = (front + size) % capacity
Linked-list queue:
head(front) → [10] → [20] → [30] → [40] → null ← tail(back)
dequeue from head = O(1) enqueue at tail = O(1)
| Implementation | Memory | Notes |
ArrayDeque | Compact, contiguous | Circular buffer, excellent cache locality |
LinkedList | Extra pointer per node | No resize, but scattered memory |
Custom IntArray | Minimal overhead | No boxing, best for primitives |
| Blocking queues | Varies | Thread-safe, producer-consumer patterns |
- Circular arrays are ideal for queues —
O(1) enqueue/dequeue without shifting elements
- Index wrapping via modulo:
back = (front + size) % capacity
- Excellent cache locality — contiguous memory means CPU prefetching works well
| Type | Backing | Notes |
ArrayDeque | Resizable circular array | Recommended — Kotlin stdlib, fast |
java.util.ArrayDeque | Resizable circular array | Java version, also good |
LinkedList | Doubly linked list | Implements both Queue & Deque, poor cache locality |
PriorityQueue | Binary heap (array) | Dequeue by priority, not FIFO |
| Blocking queues | Various | Thread-safe — ArrayBlockingQueue, LinkedBlockingQueue |
Queue interface hierarchy:
Queue
├── PriorityQueue
│
Deque (extends Queue)
├── java.util.ArrayDeque
└── LinkedList
kotlin.collections.ArrayDeque
(standalone, not java.util.Queue)
// ── Kotlin ArrayDeque as queue ──
val queue = ArrayDeque<Int>()
queue.addLast(10) // enqueue
queue.addLast(20) // enqueue
queue.removeFirst() // dequeue → 10
queue.first() // peek → 20
// ── java.util.ArrayDeque as queue ──
val jQueue = java.util.ArrayDeque<Int>()
jQueue.offer(10) // enqueue (null-safe)
jQueue.offer(20) // enqueue
jQueue.poll() // dequeue → 10 (null if empty)
jQueue.peek() // peek → 20 (null if empty)
ArrayDeque is the primary choice for queues in Kotlin — fast, unsynchronized, circular buffer
PriorityQueue for heap-based processing — dequeues smallest element, not FIFO
- Two method flavors: throwing (
add/remove/element) vs null-returning (offer/poll/peek)
Core Operations
// ── Empty queue ──
val queue = ArrayDeque<Int>()
// ── From a list ──
val queue = ArrayDeque(listOf(1, 2, 3)) // 1 is front
// ── PriorityQueue (min-heap — default) ──
val minHeap = java.util.PriorityQueue<Int>()
// ── PriorityQueue (max-heap) ──
val maxHeap = java.util.PriorityQueue<Int>(compareByDescending { it })
val maxHeap2 = PriorityQueue<Int>(reverseOrder())
// ── PriorityQueue (custom comparator) ──
val pq = java.util.PriorityQueue<IntArray>(compareBy { it[1] })
addLast = enqueue, removeFirst = dequeue, first() = peek
PriorityQueue is min-heap by default — use reverseOrder() or custom comparator for max-heap
val queue = ArrayDeque<Int>()
// ── Enqueue ──
queue.addLast(10) // [10]
queue.addLast(20) // [10, 20]
queue.addLast(30) // [10, 20, 30]
// ── Peek (view front) ──
queue.first() // 10 — throws if empty
queue.firstOrNull() // 10 — null if empty (safe)
// ── Dequeue (remove front) ──
queue.removeFirst() // 10 — throws if empty
queue.removeFirstOrNull() // 20 — null if empty (safe)
// ── Safe patterns ──
if (queue.isNotEmpty()) queue.removeFirst()
queue.removeFirstOrNull() ?: defaultValue
| Throwing | Null-safe | Action |
add | offer | Enqueue |
remove | poll | Dequeue |
element | peek | View front |
- Two flavors: throwing methods raise exceptions on empty; null-safe methods return
null
- Always prefer null-safe variants (
firstOrNull, removeFirstOrNull) or check isEmpty() first
// ── Drain (dequeue all elements) ──
while (queue.isNotEmpty()) {
val item = queue.removeFirst()
// process item (FIFO order)
}
// ── Iterate without modifying ──
for (item in queue) { } // front → back order
// ── Level-based processing (BFS pattern) ──
while (queue.isNotEmpty()) {
val levelSize = queue.size // snapshot current level
repeat(levelSize) {
val node = queue.removeFirst()
// process node, enqueue children
}
// entire level processed — move to next
}
- Drain pattern (
while isNotEmpty removeFirst) processes elements in FIFO order
- Level size snapshot is critical for BFS — capture
queue.size before processing a level, as the queue grows during iteration
// ── Circular Array Queue ──
class CircularArrayQueue(private val capacity: Int) {
private val data = IntArray(capacity)
private var front = 0
private var size = 0
fun enqueue(value: Int) {
if (size == capacity) throw IllegalStateException("Full")
val back = (front + size) % capacity
data[back] = value
size++
}
fun dequeue(): Int {
if (size == 0) throw NoSuchElementException()
val value = data[front]
front = (front + 1) % capacity
size--
return value
}
fun peek(): Int {
if (size == 0) throw NoSuchElementException()
return data[front]
}
fun isEmpty() = size == 0
}
// ── Linked Queue ──
class LinkedQueue<T> {
private class Node<T>(val value: T, var next: Node<T>? = null)
private var head: Node<T>? = null
private var tail: Node<T>? = null
fun enqueue(item: T) {
val node = Node(item)
if (tail != null) tail!!.next = node else head = node
tail = node
}
fun dequeue(): T {
val node = head ?: throw NoSuchElementException()
head = node.next
if (head == null) tail = null
return node.value
}
fun peek(): T = head?.value ?: throw NoSuchElementException()
}
| Aspect | CircularArrayQueue | LinkedQueue |
| enqueue | O(1) | O(1) |
| dequeue | O(1) | O(1) |
| Memory per element | ~4-8 bytes | ~24-32 bytes |
| Cache locality | Excellent | Poor |
| Resize | Fixed capacity (or amortized copy) | Never needed |
Deque & Priority Queue
// ── ArrayDeque as deque — O(1) at both ends ──
val deque = ArrayDeque<Int>()
deque.addFirst(10) // push front [10]
deque.addLast(20) // push back [10, 20]
deque.addFirst(5) // push front [5, 10, 20]
deque.removeFirst() // pop front → 5 [10, 20]
deque.removeLast() // pop back → 20 [10]
deque.first() // peek front → 10
deque.last() // peek back → 10
// ── Use as queue: addLast / removeFirst ──
val queue = ArrayDeque<Int>()
queue.addLast(1) // enqueue
queue.removeFirst() // dequeue
// ── Use as stack: addLast / removeLast ──
val stack = ArrayDeque<Int>()
stack.addLast(1) // push
stack.removeLast() // pop
| Pattern | Use Deque When |
| Sliding window | Need to add/remove from both ends as window slides |
| Palindrome check | Compare first and last characters simultaneously |
| Work stealing | Owner pops from one end, thieves steal from the other |
| 01-BFS | 0-weight edges go to front, 1-weight edges go to back |
- O(1) at both ends — insert/remove from front and back efficiently
- Replaces both stack (
addLast/removeLast) and queue (addLast/removeFirst)
- Kotlin
ArrayDeque is the universal choice — one data structure for stack, queue, and deque
Min-heap — tree representation:
1
/ \
3 2
/ \ / \
5 8 4 7
Array representation:
Index: [0] [1] [2] [3] [4] [5] [6]
Value: 1 3 2 5 8 4 7
Index formulas:
Parent = (i - 1) / 2
Left child = 2 * i + 1
Right child= 2 * i + 2
// ── Min-heap (default) ──
val minHeap = java.util.PriorityQueue<Int>()
minHeap.add(5); minHeap.add(1); minHeap.add(3)
minHeap.peek() // 1 — smallest element
minHeap.poll() // 1 — removes smallest
// ── Max-heap ──
val maxHeap = PriorityQueue<Int>(reverseOrder())
maxHeap.add(5); maxHeap.add(1); maxHeap.add(3)
maxHeap.peek() // 5 — largest element
maxHeap.poll() // 5 — removes largest
// ── Custom comparator ──
data class Task(val name: String, val priority: Int)
val taskPQ = java.util.PriorityQueue<Task>(compareBy { it.priority })
| Operation | Time |
add (insert) | O(log n) |
poll (remove min/max) | O(log n) |
peek (view min/max) | O(1) |
contains | O(n) |
| Build from collection | O(n) |
- Min-heap by default —
peek()/poll() return the smallest element
- Iterating a PQ does NOT return sorted order — you must
poll() repeatedly for sorted output
- Never mutate elements after insertion — the heap does not re-heapify automatically. Remove, modify, re-add instead
Algorithm Patterns
Sliding Window → see Arrays page for general sliding window patterns |
Merge K Sorted Lists → see Linked Lists page for linked-list merge |
Monotonic Stack → see Stacks page for monotonic stack patterns
Layer-by-layer exploration:
A Layer 0: A
/ \
B C Layer 1: B, C
/ \ \
D E F Layer 2: D, E, F
Queue state per step:
[A] → process A, enqueue B,C → [B,C]
→ process B, enqueue D,E → [C,D,E]
→ process C, enqueue F → [D,E,F]
→ process D,E,F → []
// ── BFS graph traversal ──
fun bfs(graph: Map<Int, List<Int>>, start: Int) {
val visited = mutableSetOf(start)
val queue = ArrayDeque<Int>()
queue.addLast(start)
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
// process node
for (neighbor in graph[node].orEmpty()) {
if (visited.add(neighbor)) { // mark visited when ENQUEUING
queue.addLast(neighbor)
}
}
}
}
// ── BFS with distance tracking ──
fun bfsWithDistance(graph: Map<Int, List<Int>>, start: Int): Map<Int, Int> {
val dist = mutableMapOf(start to 0)
val queue = ArrayDeque<Int>()
queue.addLast(start)
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
for (neighbor in graph[node].orEmpty()) {
if (neighbor !in dist) {
dist[neighbor] = dist[node]!! + 1
queue.addLast(neighbor)
}
}
}
return dist
}
When to Use
shortest path (unweighted)Why: BFS explores by distance. All neighbors at distance d are visited before d+1, so the first time you reach a node is the shortest path.
number of islandsWhy: Start BFS from each unvisited land cell to flood-fill the entire island. Each BFS = one connected component.
minimum moves/stepsWhy: Each BFS level = one move. First arrival at target = fewest moves. Works for grid puzzles, state-space search.
nearest target searchWhy: BFS radiates outward uniformly. The first target found is guaranteed to be the closest one.
clone graphWhy: BFS visits every node exactly once. Use a HashMap to map original→clone, wiring neighbors as you go.
is graph bipartiteWhy: BFS with 2-coloring. Alternate colors at each level. If a neighbor has the same color → not bipartite.
- THE canonical queue algorithm — BFS is to queues what DFS is to stacks
- Explores all nodes at distance d before distance d+1
- Always mark visited when ENQUEUING, not when processing — prevents duplicate enqueues
▶ Visualize
// ── Level-order traversal (returns list of levels) ──
fun levelOrder(root: TreeNode?): List<List<Int>> {
if (root == null) return emptyList()
val result = mutableListOf<List<Int>>()
val queue = ArrayDeque<TreeNode>()
queue.addLast(root)
while (queue.isNotEmpty()) {
val levelSize = queue.size // snapshot!
val level = mutableListOf<Int>()
repeat(levelSize) {
val node = queue.removeFirst()
level.add(node.`val`)
node.left?.let { queue.addLast(it) }
node.right?.let { queue.addLast(it) }
}
result.add(level)
}
return result
}
// ── Zigzag level-order ──
fun zigzagLevelOrder(root: TreeNode?): List<List<Int>> {
if (root == null) return emptyList()
val result = mutableListOf<List<Int>>()
val queue = ArrayDeque<TreeNode>()
queue.addLast(root)
var leftToRight = true
while (queue.isNotEmpty()) {
val levelSize = queue.size
val level = mutableListOf<Int>()
repeat(levelSize) {
val node = queue.removeFirst()
level.add(node.`val`)
node.left?.let { queue.addLast(it) }
node.right?.let { queue.addLast(it) }
}
if (!leftToRight) level.reverse()
result.add(level)
leftToRight = !leftToRight
}
return result
}
// ── Right side view ──
fun rightSideView(root: TreeNode?): List<Int> {
if (root == null) return emptyList()
val result = mutableListOf<Int>()
val queue = ArrayDeque<TreeNode>()
queue.addLast(root)
while (queue.isNotEmpty()) {
val levelSize = queue.size
repeat(levelSize) { i ->
val node = queue.removeFirst()
if (i == levelSize - 1) result.add(node.`val`)
node.left?.let { queue.addLast(it) }
node.right?.let { queue.addLast(it) }
}
}
return result
}
When to Use
level-order traversalWhy: Process all nodes at depth d before depth d+1 using queue.size snapshot per level.
zigzag level-orderWhy: Same as level-order but reverse every other level. Collect level, conditionally reverse.
right side viewWhy: The last (or first) node processed in each level is the rightmost (or leftmost) visible node.
minimum depthWhy: BFS finds the first leaf node — that's the shallowest leaf = minimum depth. Faster than DFS which must explore all paths.
average of levelsWhy: Process each level, compute sum/count. Level snapshot ensures clean separation.
largest value in each rowWhy: Track max while processing each level. One pass per level, no sorting needed.
queue.size snapshot is the critical technique — captures how many nodes belong to the current level
- Process exactly
levelSize nodes before moving on — children enqueued during this loop belong to the next level
▶ Visualize
// ── Rotting Oranges — multi-source BFS ──
fun orangesRotting(grid: Array<IntArray>): Int {
val rows = grid.size; val cols = grid[0].size
val queue = ArrayDeque<IntArray>()
var fresh = 0
// Enqueue ALL rotten oranges initially
for (r in 0 until rows)
for (c in 0 until cols)
when (grid[r][c]) {
2 -> queue.addLast(intArrayOf(r, c))
1 -> fresh++
}
val dirs = listOf(
-1 to 0, 1 to 0,
0 to -1, 0 to 1
)
var minutes = 0
while (queue.isNotEmpty() && fresh > 0) {
val size = queue.size
repeat(size) {
val (r, c) = queue.removeFirst()
for (d in dirs) {
val nr = r + d[0]; val nc = c + d[1]
if (nr in 0 until rows && nc in 0 until cols && grid[nr][nc] == 1) {
grid[nr][nc] = 2
fresh--
queue.addLast(intArrayOf(nr, nc))
}
}
}
minutes++
}
return if (fresh == 0) minutes else -1
}
When to Use
rotten orangesWhy: All rotten oranges start spreading simultaneously. Enqueue all rotten cells, BFS radiates in parallel — each level = 1 minute.
walls and gatesWhy: Enqueue all gates, BFS fills each empty room with distance to nearest gate. Multi-source = all gates radiate simultaneously.
shortest path from multiple startsWhy: Virtual super-source connected to all start points with 0-weight edges. One BFS covers all.
01 matrix (nearest 0)Why: Enqueue all 0-cells, BFS outward. Each 1-cell gets filled with its distance to the nearest 0 on first visit.
pacific atlantic water flowWhy: Reverse multi-source BFS from each ocean border inward. Cells reachable from both oceans are the answer.
- Enqueue ALL sources initially — equivalent to adding a virtual super-source connected to all start points
- BFS radiates from all sources in parallel — each layer = one time step
- Same complexity as single-source BFS:
O(V+E)
▶ Visualize
// ── Word Ladder — BFS shortest transformation ──
fun ladderLength(
beginWord: String,
endWord: String,
wordList: List<String>
): Int {
val wordSet = wordList.toMutableSet()
if (endWord !in wordSet) return 0
val queue = ArrayDeque<String>()
queue.addLast(beginWord)
var steps = 1
while (queue.isNotEmpty()) {
val size = queue.size
repeat(size) {
val word = queue.removeFirst()
val chars = word.toCharArray()
for (i in chars.indices) {
val original = chars[i]
for (c in 'a'..'z') {
chars[i] = c
val newWord = String(chars)
if (newWord == endWord) return steps + 1
if (wordSet.remove(newWord)) {
queue.addLast(newWord)
}
}
chars[i] = original
}
}
steps++
}
return 0
}
When to Use
word ladderWhy: Implicit graph: words are nodes, edges connect words differing by 1 letter. BFS finds shortest transformation.
open the lockWhy: State = lock combination (4-digit string). Each turn = 1 edge. BFS from "0000" to target avoiding deadends.
minimum genetic mutationWhy: Same as word ladder but with gene strings (ACGT). BFS through valid mutations in the gene bank.
sliding puzzleWhy: State = board configuration. Each move = swap blank with neighbor. BFS through state space for shortest solution.
- Model as implicit graph — words differing by 1 letter are connected by an edge
- BFS finds the shortest transformation sequence
- Bidirectional BFS reduces search space from
O(bd) to O(bd/2)
▶ Visualize
Monotonic decreasing deque — front = max of window:
Array: [1, 3, -1, -3, 5, 3, 6, 7] k=3
Window [1,3,-1] → deque: [3, -1] max=3
Window [3,-1,-3] → deque: [3, -1, -3] max=3
Window [-1,-3,5] → deque: [5] max=5
Window [-3,5,3] → deque: [5, 3] max=5
Window [5,3,6] → deque: [6] max=6
Window [3,6,7] → deque: [7] max=7
Stores indices in decreasing value order
Front always holds current window maximum
// ── Sliding Window Maximum ──
fun maxSlidingWindow(nums: IntArray, k: Int): IntArray {
val deque = ArrayDeque<Int>() // stores indices
val result = IntArray(nums.size - k + 1)
for (i in nums.indices) {
// Remove expired indices from front
while (deque.isNotEmpty() && deque.first() <= i - k) {
deque.removeFirst()
}
// Remove smaller values from back (maintain decreasing order)
while (deque.isNotEmpty() && nums[deque.last()] <= nums[i]) {
deque.removeLast()
}
deque.addLast(i)
if (i >= k - 1) {
result[i - k + 1] = nums[deque.first()]
}
}
return result
}
When to Use
sliding window maximumWhy: Monotonic decreasing deque: front = window max. Remove expired from front, remove smaller from back. O(n) total.
sliding window minimumWhy: Same pattern but monotonic increasing deque. Front = window min.
max in all subarrays of size kWhy: Identical to sliding window maximum. Output result when window reaches size k.
shortest subarray with sum ≥ kWhy: Monotonic deque on prefix sums. Remove from front when valid, from back when not useful. Handles negative numbers.
jump game VIWhy: DP + monotonic deque: dp[i] = nums[i] + max(dp[j]) for j in window. Deque tracks max dp value in sliding window.
- Deque stores indices in decreasing value order — not values directly
- Remove expired from front (index outside window), remove smaller from back (maintain monotonicity)
- Front always holds the current window maximum
▶ Visualize
// ── Stack using two queues ──
class StackUsingQueues {
private var q1 = ArrayDeque<Int>()
private var q2 = ArrayDeque<Int>()
fun push(x: Int) {
q2.addLast(x)
while (q1.isNotEmpty()) q2.addLast(q1.removeFirst())
val tmp = q1; q1 = q2; q2 = tmp // swap
}
fun pop(): Int = q1.removeFirst()
fun top(): Int = q1.first()
fun empty(): Boolean = q1.isEmpty()
}
// ── Stack using single queue ──
class StackUsingSingleQueue {
private val queue = ArrayDeque<Int>()
fun push(x: Int) {
queue.addLast(x)
// Rotate: move all elements before x to the back
repeat(queue.size - 1) {
queue.addLast(queue.removeFirst())
}
}
fun pop(): Int = queue.removeFirst()
fun top(): Int = queue.first()
fun empty(): Boolean = queue.isEmpty()
}
When to Use
stack using queuesWhy: Classic data structure design question. Tests understanding of LIFO vs FIFO inversion. Push-heavy or pop-heavy variants.
- Make push O(n) by rotating elements — ensures front of queue = top of stack
- Single-queue variant rotates
size - 1 elements after each push
▶ Visualize
Pour pushStack → popStack reverses order = FIFO:
Push 1,2,3:
pushStack: [1, 2, 3] ← top popStack: []
Dequeue (popStack empty, so pour):
pushStack: [] popStack: [3, 2, 1] ← top
pop → 1 (correct FIFO order!)
Each element moves at most twice: push → pop stack
// ── Queue using two stacks (amortized O(1)) ──
class QueueUsingStacks {
private val pushStack = ArrayDeque<Int>()
private val popStack = ArrayDeque<Int>()
fun push(x: Int) = pushStack.addLast(x)
fun pop(): Int {
transfer()
return popStack.removeLast()
}
fun peek(): Int {
transfer()
return popStack.last()
}
fun empty(): Boolean = pushStack.isEmpty() && popStack.isEmpty()
private fun transfer() {
if (popStack.isEmpty()) {
while (pushStack.isNotEmpty()) {
popStack.addLast(pushStack.removeLast())
}
}
}
}
When to Use
queue using stacksWhy: Classic design question. Tests amortized analysis. Two-stack lazy transfer achieves O(1) amortized per operation.
- Each element moved at most twice (push stack → pop stack) — amortized
O(1) per operation
- Lazy transfer: only pour push stack into pop stack when pop stack is empty
▶ Visualize
// ── Moving Average with queue + running sum ──
class MovingAverage(private val windowSize: Int) {
private val queue = ArrayDeque<Int>()
private var sum = 0.0
fun next(value: Int): Double {
queue.addLast(value)
sum += value
if (queue.size > windowSize) {
sum -= queue.removeFirst()
}
return sum / queue.size
}
}
// Usage:
val ma = MovingAverage(3)
ma.next(1) // 1.0
ma.next(10) // 5.5 (1+10)/2
ma.next(3) // 4.666... (1+10+3)/3
ma.next(5) // 6.0 (10+3+5)/3 — 1 evicted
When to Use
moving averageWhy: Queue holds the last k values. On each new value: enqueue, dequeue oldest if over k. Running sum gives O(1) average.
hit counterWhy: Queue of timestamps. Dequeue expired hits (older than 5 minutes). Queue size = hit count in window.
rate limiterWhy: Queue of request timestamps. If queue size ≥ limit within time window → reject. Dequeue expired entries.
recent calls counterWhy: Queue stores call timestamps. On each call, dequeue entries older than 3000ms. Queue size = count.
- Queue maintains a window of k values — evicts oldest when full
- Running sum avoids recomputation — add new, subtract evicted =
O(1) per call
▶ Visualize
// ── 01-BFS — shortest path with 0/1 weights ──
fun shortestPath01BFS(
graph: Map<Int, List<Pair<Int, Int>>>, // node → [(neighbor, weight 0 or 1)]
start: Int, n: Int
): IntArray {
val dist = IntArray(n) { Int.MAX_VALUE }
dist[start] = 0
val deque = ArrayDeque<Int>()
deque.addFirst(start)
while (deque.isNotEmpty()) {
val u = deque.removeFirst()
for ((v, w) in graph[u].orEmpty()) {
val newDist = dist[u] + w
if (newDist < dist[v]) {
dist[v] = newDist
if (w == 0) deque.addFirst(v) // 0-weight → front
else deque.addLast(v) // 1-weight → back
}
}
}
return dist
}
When to Use
min cost grid pathWhy: Grid cells have direction arrows. Following costs 0, changing costs 1. 0/1 weights → 01-BFS instead of Dijkstra.
shortest path binary matrixWhy: When movement costs are 0 (same terrain) or 1 (obstacle bypass), 01-BFS is O(V+E) vs Dijkstra's O((V+E) log V).
graph with 0/1 weightsWhy: Deque replaces priority queue: 0-weight → front (same distance), 1-weight → back (one more). Linear time.
- 0-weight edges →
addFirst (front), 1-weight edges → addLast (back)
O(V+E) vs Dijkstra's O((V+E) log V) — no heap overhead needed
▶ Visualize
// ── Task Scheduler with max-heap + cooldown queue ──
fun leastInterval(tasks: CharArray, n: Int): Int {
val freq = IntArray(26)
for (t in tasks) freq[t - 'A']++
val maxHeap = PriorityQueue<Int>(reverseOrder())
for (f in freq) if (f > 0) maxHeap.add(f)
// (remaining count, available at time)
val cooldown = ArrayDeque<Pair<Int, Int>>()
var time = 0
while (maxHeap.isNotEmpty() || cooldown.isNotEmpty()) {
time++
if (maxHeap.isNotEmpty()) {
val count = maxHeap.poll() - 1
if (count > 0) cooldown.addLast(count to time + n)
}
if (cooldown.isNotEmpty() && cooldown.first().second == time) {
maxHeap.add(cooldown.removeFirst().first)
}
}
return time
}
When to Use
task schedulerWhy: Greedy: always run the most frequent available task. Max-heap picks it, cooldown queue holds it until ready. Minimizes idle slots.
CPU scheduling with cooldownWhy: Same pattern: pick highest priority ready task, enforce minimum gap between same-type tasks.
rearrange string k apartWhy: Place most frequent char, wait k positions before reusing. Max-heap + cooldown queue enforces the gap.
- Greedily schedule most frequent task — max-heap ensures optimal ordering
- Cooldown queue holds tasks waiting to become available — dequeued when their time arrives
- Max-heap + cooldown queue = greedy scheduling with constraints
▶ Visualize
// ── Top K Frequent with min-heap of size k ──
fun topKFrequent(nums: IntArray, k: Int): IntArray {
val freq = mutableMapOf<Int, Int>()
for (n in nums) freq[n] = freq.getOrDefault(n, 0) + 1
// Min-heap by frequency — smallest frequency at top
val minHeap = java.util.PriorityQueue<Int>(compareBy { freq[it] })
for (num in freq.keys) {
minHeap.add(num)
if (minHeap.size > k) minHeap.poll() // remove least frequent
}
return minHeap.toIntArray()
}
When to Use
top K frequent elementsWhy: Count frequencies, then min-heap of size k. Poll smallest when over k. What remains = top k. O(n log k).
top K frequent wordsWhy: Same pattern but with custom comparator for alphabetical tie-breaking.
sort characters by frequencyWhy: Count frequencies, max-heap by frequency, build result by polling. O(n log k) where k = distinct chars.
K closest points to originWhy: Max-heap of size k by distance. Poll farthest when over k. Remaining k points are closest. O(n log k).
- Min-heap of size k — when heap exceeds k, poll smallest; what remains are the top k
- Bucket sort alternative is
O(n) — use when k is large relative to n
▶ Visualize
// ── Kth Largest Element (min-heap of size k) ──
fun findKthLargest(nums: IntArray, k: Int): Int {
val minHeap = java.util.PriorityQueue<Int>()
for (num in nums) {
minHeap.add(num)
if (minHeap.size > k) minHeap.poll()
}
return minHeap.peek() // root = kth largest
}
// ── Merge K Sorted Lists ──
fun mergeKLists(lists: Array<ListNode?>): ListNode? {
val minHeap = java.util.PriorityQueue<ListNode>(compareBy { it.`val` })
for (head in lists) head?.let { minHeap.add(it) }
val dummy = ListNode(0)
var tail = dummy
while (minHeap.isNotEmpty()) {
val node = minHeap.poll()
tail.next = node
tail = node
node.next?.let { minHeap.add(it) }
}
return dummy.next
}
When to Use
kth largest elementWhy: Min-heap of size k. After all elements: root = kth largest. O(n log k) — better than full sort when k ≪ n.
kth largest in a streamWhy: Same min-heap of size k, maintained as elements arrive. Root always = current kth largest. O(log k) per add.
merge K sorted listsWhy: Min-heap of k list heads. Poll smallest, push its successor. Always merging the global minimum. O(N log k).
kth smallest in sorted matrixWhy: Min-heap with (value, row, col). Poll k times, each time pushing the next element from same row. O(k log k).
k pairs with smallest sumsWhy: Min-heap of (a[i]+b[j], i, j). Poll and expand neighbors. Lazily explores pairs in sorted order.
- Min-heap of size k for kth largest — root = answer after all elements processed
- For merge-k: heap holds heads of each list, poll smallest and push its next node
▶ Visualize
Two heaps — balanced partition:
maxHeap(smaller) minHeap(larger)
┌─────────┐ ┌─────────┐
│ 1 3 5 │ │ 7 9 11 │
│ ↑top │ │ ↑top │
└─────────┘ └─────────┘
Median = average of tops = (5 + 7) / 2 = 6.0
Invariant: sizes differ by at most 1
If odd count: larger heap's top = median
// ── MedianFinder with two heaps ──
class MedianFinder {
// maxHeap: smaller half (top = largest of small half)
private val maxHeap = PriorityQueue<Int>(reverseOrder())
// minHeap: larger half (top = smallest of large half)
private val minHeap = java.util.PriorityQueue<Int>()
fun addNum(num: Int) {
maxHeap.add(num)
minHeap.add(maxHeap.poll()) // balance: move largest of small half
if (minHeap.size > maxHeap.size) {
maxHeap.add(minHeap.poll()) // keep maxHeap ≥ minHeap in size
}
}
fun findMedian(): Double {
return if (maxHeap.size > minHeap.size)
maxHeap.peek().toDouble()
else
(maxHeap.peek() + minHeap.peek()) / 2.0
}
}
When to Use
find median from data streamWhy: Two heaps split data into halves. maxHeap top = largest of small half, minHeap top = smallest of large half. Median from tops.
sliding window medianWhy: Same two-heap approach but with lazy deletion. Track elements to remove, clean up when they reach heap top.
balance two halvesWhy: Any problem where you need O(1) access to the "middle" of a sorted stream. Percentile tracking, balanced partitions.
- maxHeap holds smaller half, minHeap holds larger half
- Invariant: sizes differ by at most 1 — rebalance after each insertion
- Median = top of larger heap (odd) or average of both tops (even)
▶ Visualize
// ── Dijkstra's with PriorityQueue ──
fun dijkstra(
graph: Map<Int, List<Pair<Int, Int>>>, // node → [(neighbor, weight)]
start: Int, n: Int
): IntArray {
val dist = IntArray(n) { Int.MAX_VALUE }
dist[start] = 0
// Min-heap of (distance, node)
val pq = java.util.PriorityQueue<Pair<Int, Int>>(compareBy { it.first })
pq.add(0 to start)
while (pq.isNotEmpty()) {
val (d, u) = pq.poll()
if (d > dist[u]) continue // skip stale entries
for ((v, w) in graph[u].orEmpty()) {
val newDist = dist[u] + w
if (newDist < dist[v]) {
dist[v] = newDist
pq.add(newDist to v)
}
}
}
return dist
}
When to Use
shortest path (weighted)Why: Min-heap PQ processes closest unvisited node. With non-negative weights, settled distance is final. O((V+E) log V).
network delay timeWhy: Dijkstra from source, find max of all shortest distances. If any node unreachable → return -1.
cheapest flights within K stopsWhy: Modified Dijkstra/Bellman-Ford with stop limit. Track (cost, node, stops) in priority queue.
path with minimum effortWhy: Edge weight = abs height difference. Dijkstra finds path minimizing the maximum single-step effort.
swim in rising waterWhy: Grid cells have elevation. Min-heap BFS: always expand the cell with lowest elevation. First to reach (n-1,n-1) = answer.
- Min-heap PQ always processes the closest unvisited node — greedy and optimal
- Skip already-visited nodes —
if (d > dist[u]) continue handles stale entries
- Does NOT work with negative weights — use Bellman-Ford for negative edges
▶ Visualize
// ── Reorganize String — no adjacent duplicates ──
fun reorganizeString(s: String): String {
val freq = IntArray(26)
for (c in s) freq[c - 'a']++
// Check impossible case
val maxFreq = freq.max()
if (maxFreq > (s.length + 1) / 2) return ""
// Max-heap of (frequency, char)
val maxHeap = PriorityQueue<Pair<Int, Char>>(
compareByDescending { it.first }
)
for (i in 0..25) {
if (freq[i] > 0) maxHeap.add(freq[i] to ('a' + i))
}
val sb = StringBuilder()
var prev: Pair<Int, Char>? = null
while (maxHeap.isNotEmpty()) {
val (count, ch) = maxHeap.poll()
sb.append(ch)
prev?.let { if (it.first > 0) maxHeap.add(it) }
prev = (count - 1) to ch
}
return sb.toString()
}
When to Use
reorganize stringWhy: No two adjacent chars the same. Max-heap greedily picks most frequent available char. Hold previous aside to prevent repeats.
distant barcodesWhy: Same pattern: rearrange array so no two adjacent elements are equal. Max-heap by frequency, alternate placement.
interleave charactersWhy: Any problem requiring "spread out" the most frequent element. If maxFreq > (n+1)/2 → impossible.
- Greedily place most frequent char — max-heap ensures optimal selection
- Hold previous char aside until next iteration — prevents adjacent duplicates
- Impossible if
maxFreq > (n+1)/2
▶ Visualize
Common Pitfalls
// ── PITFALL 1: Wrong end of ArrayDeque ──
val queue = ArrayDeque<Int>()
queue.addLast(1); queue.addLast(2); queue.addLast(3)
queue.removeLast() // ✗ This is STACK behavior (LIFO), not queue!
queue.removeFirst() // ✓ This is QUEUE behavior (FIFO)
// ── PITFALL 2: BFS — marking visited at wrong time ──
// ✗ WRONG — mark when processing (causes duplicate enqueues)
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
visited.add(node) // ✗ Too late! Node may be enqueued multiple times
for (neighbor in graph[node].orEmpty()) {
if (neighbor !in visited) queue.addLast(neighbor)
}
}
// ✓ CORRECT — mark when ENQUEUING
if (visited.add(neighbor)) queue.addLast(neighbor)
// ── PITFALL 3: Iterating PriorityQueue expecting sorted order ──
val pq = java.util.PriorityQueue(listOf(3, 1, 2))
for (x in pq) println(x) // ✗ NOT guaranteed sorted!
while (pq.isNotEmpty()) // ✓ poll() gives sorted order
println(pq.poll())
// ── PITFALL 4: Mutating elements inside PriorityQueue ──
data class Item(var priority: Int)
val pq2 = java.util.PriorityQueue<Item>(compareBy { it.priority })
val item = Item(5)
pq2.add(item)
item.priority = 1 // ✗ Heap is now CORRUPTED! No re-heapify happens
// ✓ Remove → modify → re-add
pq2.remove(item)
item.priority = 1
pq2.add(item)
// ── PITFALL 5: Forgetting level size snapshot in BFS ──
// ✗ WRONG — queue.size changes during iteration
for (i in 0 until queue.size) { // size changes as children are added!
val node = queue.removeFirst()
queue.addLast(child) // increases queue.size mid-loop
}
// ✓ CORRECT — snapshot before loop
val levelSize = queue.size
repeat(levelSize) { /* ... */ }
// ── PITFALL 6: Using java.util.Stack instead of ArrayDeque ──
val bad = java.util.Stack<Int>() // ✗ Legacy, synchronized, extends Vector
val good = ArrayDeque<Int>() // ✓ Modern, fast, unsynchronized
addLast/removeFirst for queue — removeLast is stack behavior
- Mark visited when ENQUEUING, not when processing — prevents duplicate work
poll() for sorted PQ output — iteration order is NOT sorted
- Never mutate elements in a PQ — remove, modify, re-add instead
Quick Reference
| Operation | ArrayDeque | LinkedList |
| enqueue (addLast) | O(1) amortized | O(1) |
| dequeue (removeFirst) | O(1) | O(1) |
| peek (first) | O(1) | O(1) |
| contains | O(n) | O(n) |
| size | O(1) | O(1) |
| Memory per element | ~4-8 bytes | ~24-32 bytes |
| Cache locality | Excellent | Poor |
| Priority Queue Operation | Time |
add (insert) | O(log n) |
poll (remove min/max) | O(log n) |
peek (view min/max) | O(1) |
contains | O(n) |
| Build from collection | O(n) |
| Algorithm | Time | Space |
| BFS (graph) | O(V+E) | O(V) |
| Level-order traversal | O(n) | O(w) |
| Shortest path (unweighted) | O(V+E) | O(V) |
| Multi-source BFS | O(V+E) | O(V) |
| Word ladder | O(n×L²) | O(n×L) |
| Sliding window max/min | O(n) | O(k) |
| Stack using queues | O(n) push | O(n) |
| Queue using stacks | O(1) amortized | O(n) |
| Moving average | O(1) per call | O(k) |
| 01-BFS | O(V+E) | O(V) |
| Task scheduler | O(n×m) | O(m) |
| Top K frequent | O(n log k) | O(n) |
| Merge K sorted lists | O(n log k) | O(k) |
| Kth largest element | O(n log k) | O(k) |
| Median from stream | O(log n) add | O(n) |
| Dijkstra's shortest path | O((V+E) log V) | O(V+E) |
| Reorganize string | O(n log k) | O(k) |
| Need | Use |
| FIFO processing | ArrayDeque |
| Priority-based processing | PriorityQueue |
| Insert/remove at both ends | Deque (ArrayDeque) |
| Sliding window max/min | Monotonic deque |
| Bounded buffer | Circular queue |
| Weighted shortest path | PQ + Dijkstra |
| 0/1 edge weights | 01-BFS deque |
| Running median | Two PQs (max-heap + min-heap) |
| Top-K elements | PQ of size k |
1. BFS (Breadth-First Search)
Layer-by-layer traversal, shortest path unweighted, level-order
2. Multi-source BFS
Rotten oranges, walls & gates — enqueue ALL sources, radiate in parallel
3. Monotonic Deque
Sliding window max/min — O(n) via decreasing/increasing deque
4. Priority Queue (Heap)
Top-K, merge-K, Dijkstra, task scheduling — greedy by priority
5. Two Heaps
Running median, balanced partition — maxHeap + minHeap boundary
- BFS: "shortest path" or "level-by-level" — think queue immediately
- Multi-source: multiple starting points expanding simultaneously — enqueue all sources first
- Monotonic deque: "sliding window max/min" — deque maintains candidates in order
- Priority queue: "top K", "merge K sorted", "greedy by priority" — heap-based selection
- Two heaps: "running median" or "balanced partition" — boundary is at the heap tops