luaguides

Priority Queues and Heaps in Lua

A regular queue serves elements in the order they were added. A priority queue is different: each element carries a priority value, and the queue always serves the element with the highest priority first. Lower numbers often mean higher priority, so a task with priority 1 gets processed before a task with priority 5.

This distinction matters more than it sounds. When a CPU scheduler decides which process runs next, or when an event-driven simulation needs the earliest scheduled event, a priority queue is exactly the right tool. The underlying data structure that makes this efficient is called a binary heap.

What is a Binary Heap?

A binary heap is a complete binary tree stored compactly in an array. “Complete” means every level is filled except possibly the last, and the last level is filled from left to right. This shape guarantees the tree has height ⌊log₂ n⌋, which is why heap operations stay fast.

In a min-heap, every node’s key is less than or equal to the keys of its children. The smallest element always sits at the root. In a max-heap, the largest element is at the root.

The heap lives in a Lua table using 1-indexed positions. Because Lua tables start at index 1 rather than 0, the parent and child formulas are:

parent(i)  = math.floor(i / 2)
left(i)    = 2 * i
right(i)   = 2 * i + 1
```lua

For example, a min-heap with elements `2, 5, 3, 8, 7` looks like this as a tree and as a table:

```lua
        2               Table: { 2, 5, 3, 8, 7 }
       / \              Index:   1  2  3  4  5
      5   3
     / \
    8   7

The root at heap[1] is 2 — the minimum. The children of index 2 (the 5) are at indices 4 and 5, which holds 8 and 7. This mapping falls out of the arithmetic with no pointers needed.

The Core Heap Operations

Two procedures do the heavy lifting: sift up and sift down. Everything else builds on them.

Sift Up — Restoring Order After Insert

When you insert a new element, you place it at the end of the array and compare it with its parent. If it violates the heap property (smaller than its parent in a min-heap), you swap them and continue upward. You stop when the element finds its correct position or reaches the root.

local function heapParent(i)
    return math.floor(i / 2)
end

local function siftUp(heap, idx)
    while idx > 1 do
        local p = heapParent(idx)
        if heap[idx] < heap[p] then
            heap[idx], heap[p] = heap[p], heap[idx]
            idx = p
        else
            break
        end
    end
end
```lua

This costs `O(log n)` in the worst case — at most one swap per level.

### Sift Down — Restoring Order After Extract

When you remove the root, you move the last element to the root and push it down. At each step, you compare it with its two children and swap it with the smaller one (in a min-heap). This continues until the element is smaller than both children or reaches a leaf.

```lua
local function heapLeft(i)
    return 2 * i
end

local function heapRight(i)
    return 2 * i + 1
end

local function siftDown(heap, idx)
    local n = #heap
    while true do
        local smallest = idx
        local l = heapLeft(idx)
        if l <= n and heap[l] < heap[smallest] then
            smallest = l
        end
        local r = heapRight(idx)
        if r <= n and heap[r] < heap[smallest] then
            smallest = r
        end
        if smallest ~= idx then
            heap[idx], heap[smallest] = heap[smallest], heap[idx]
            idx = smallest
        else
            break
        end
    end
end
```lua

Extract-min from `[2, 5, 3, 8, 7]`:
1. Remove root `2`
2. Move last (`7`) to root: `[7, 5, 3, 8]`
3. `7 > 3`, swap with `3`: `[3, 5, 7, 8]`
4. `7` has no children (index `3` has children `6` and `7`, both beyond size `4`). Done.

## Building a Priority Queue in Lua

With `siftUp` and `siftDown` in hand, the priority queue operations are straightforward.

```lua
local PriorityQueue = {}
PriorityQueue.__index = PriorityQueue

function PriorityQueue.new(isMax)
    local less = function(a, b)
        return isMax and (a > b) or (a < b)
    end

    local function heapParent(i)
        return math.floor(i / 2)
    end

    local function siftUp(heap, idx)
        while idx > 1 do
            local p = heapParent(idx)
            if less(heap[p], heap[idx]) then
                heap[idx], heap[p] = heap[p], heap[idx]
                idx = p
            else break end
        end
    end

    local function siftDown(heap, idx)
        local n = #heap
        while true do
            local smallest = idx
            local l = 2 * idx
            if l <= n and less(heap[smallest], heap[l]) then smallest = l end
            local r = 2 * idx + 1
            if r <= n and less(heap[smallest], heap[r]) then smallest = r end
            if smallest ~= idx then
                heap[idx], heap[smallest] = heap[smallest], heap[idx]
                idx = smallest
            else break end
        end
    end

    local function push(value)
        table.insert(self.data, value)
        siftUp(self.data, #self.data)
    end

    local function pop()
        local top = self.data[1]
        local last = table.remove(self.data)
        if #self.data > 0 then
            self.data[1] = last
            siftDown(self.data, 1)
        end
        return top
    end

    local self = setmetatable({ data = {}, isMax = isMax or false }, PriorityQueue)
    self.push = push
    self.pop = pop
    return self
end

function PriorityQueue:peek()
    return self.data[1]
end

function PriorityQueue:size()
    return #self.data
end
```lua

Using it:

```lua
local pq = PriorityQueue.new()
pq:push(3)
pq:push(1)
pq:push(4)
pq:push(1)
pq:push(5)

pq:pop()  --> 1
pq:pop()  --> 1
pq:pop()  --> 3
```lua

The duplicate `1` values are fine — equal priorities resolve in FIFO order among themselves, which matches most real-world scheduling semantics.

## Heapify — Building a Heap in O(n)

Inserting elements one at a time costs `O(n log n)` to build. But you can build a heap from an unsorted array in `O(n)` by calling `siftDown` on each non-leaf node, starting from the last internal node and working backward to the root.

```lua
local function heapify(arr, isMax)
    local less = function(a, b)
        return isMax and (a > b) or (a < b)
    end

    local function siftDown(heap, idx)
        local n = #heap
        while true do
            local smallest = idx
            local l = 2 * idx
            if l <= n and less(heap[smallest], heap[l]) then smallest = l end
            local r = 2 * idx + 1
            if r <= n and less(heap[smallest], heap[r]) then smallest = r end
            if smallest ~= idx then
                heap[idx], heap[smallest] = heap[smallest], heap[idx]
                idx = smallest
            else break end
        end
    end

    local n = #arr
    for i = math.floor(n / 2), 1, -1 do
        siftDown(arr, i)
    end
    return arr
end
```lua

For a max-heap, pass `true` as the second argument. This is the same algorithm that heap sort uses under the hood.

## Task Scheduler Example

Here is a more complete example: a task scheduler where each task is a table with a `name` and a numeric `priority`. Lower numbers mean more urgent.

```lua
local TaskScheduler = {}
TaskScheduler.__index = TaskScheduler

function TaskScheduler.new()
    return setmetatable({ tasks = {} }, TaskScheduler)
end

-- Sift up for tasks ordered by priority
function TaskScheduler:_siftUp(idx)
    while idx > 1 do
        local p = math.floor(idx / 2)
        if self.tasks[idx].priority < self.tasks[p].priority then
            self.tasks[idx], self.tasks[p] = self.tasks[p], self.tasks[idx]
            idx = p
        else break end
    end
end

-- Sift down for tasks ordered by priority
function TaskScheduler:_siftDown(idx)
    local n = #self.tasks
    while true do
        local smallest = idx
        local l = 2 * idx
        if l <= n and self.tasks[l].priority < self.tasks[smallest].priority then
            smallest = l
        end
        local r = 2 * idx + 1
        if r <= n and self.tasks[r].priority < self.tasks[smallest].priority then
            smallest = r
        end
        if smallest ~= idx then
            self.tasks[idx], self.tasks[smallest] = self.tasks[smallest], self.tasks[idx]
            idx = smallest
        else break end
    end
end

function TaskScheduler:add(name, priority)
    table.insert(self.tasks, { name = name, priority = priority })
    self:_siftUp(#self.tasks)
end

function TaskScheduler:next()
    local task = self.tasks[1]
    local last = table.remove(self.tasks)
    if #self.tasks > 0 then
        self.tasks[1] = last
        self:_siftDown(1)
    end
    return task
end

function TaskScheduler:size()
    return #self.tasks
end

-- Demo
local sched = TaskScheduler.new()
sched:add("Crash recovery", 1)
sched:add("Send heartbeat", 5)
sched:add("Log rotation", 3)
sched:add("Memory compaction", 2)

while sched:size() > 0 do
    local t = sched:next()
    print(t.priority, t.name)
end
```lua

Output:

```lua
1   Crash recovery
2   Memory compaction
3   Log rotation
5   Send heartbeat
```lua

The heap always delivers the most urgent task first. Adding a new task costs `O(log n)`, and extracting the next task also costs `O(log n)`. This is significantly faster than scanning an unsorted list for every extraction.

## Event Queue for Simulations

Game engines and simulators often need to process events in chronological order. An event queue backed by a min-heap gives you exactly that: schedule events with timestamps, and the next event to process is always at the root.

```lua
local EventQueue = {}
EventQueue.__index = EventQueue

function EventQueue.new()
    return setmetatable({ events = {} }, EventQueue)
end

function EventQueue:_siftUp(idx)
    while idx > 1 do
        local p = math.floor(idx / 2)
        if self.events[idx].time < self.events[p].time then
            self.events[idx], self.events[p] = self.events[p], self.events[idx]
            idx = p
        else break end
    end
end

function EventQueue:_siftDown(idx)
    local n = #self.events
    while true do
        local smallest = idx
        local l = 2 * idx
        if l <= n and self.events[l].time < self.events[smallest].time then
            smallest = l
        end
        local r = 2 * idx + 1
        if r <= n and self.events[r].time < self.events[smallest].time then
            smallest = r
        end
        if smallest ~= idx then
            self.events[idx], self.events[smallest] = self.events[smallest], self.events[idx]
            idx = smallest
        else break end
    end
end

function EventQueue:schedule(event, time)
    table.insert(self.events, { event = event, time = time })
    self:_siftUp(#self.events)
end

function EventQueue:pop()
    local next = self.events[1]
    local last = table.remove(self.events)
    if #self.events > 0 then
        self.events[1] = last
        self:_siftDown(1)
    end
    return next.event, next.time
end

function EventQueue:peekTime()
    return self.events[1] and self.events[1].time
end

-- Demo: game event simulation
local eq = EventQueue.new()
eq:schedule("Player lands", 1.0)
eq:schedule("Player jumps", 0.5)
eq:schedule("Game over", 3.0)
eq:schedule("Spawn enemy", 2.1)

while #eq.events > 0 do
    local evt, t = eq:pop()
    print(string.format("%.1f: %s", t, evt))
end
```lua

Output:

```lua
0.5: Player jumps
1.0: Player lands
2.1: Spawn enemy
3.0: Game over
```lua

The heap kept events ordered by timestamp without any explicit sorting step. Scheduling is `O(log n)` and each extraction is also `O(log n)`.

## Dijkstra's Algorithm with a Priority Queue

Priority queues are not just for scheduling. In Dijkstra's shortest-path algorithm, the heap stores unvisited nodes keyed by their current known distance. The algorithm repeatedly extracts the node with the smallest distance, then relaxes its neighbors.

```lua
local function dijkstra(graph, start)
    local dist = {}
    dist[start] = 0

    -- Min-heap of { node, dist }
    local pq = PriorityQueue.new()
    pq:push({ node = start, dist = 0 })

    while pq:size() > 0 do
        local entry = pq:pop()
        local u, d = entry.node, entry.dist

        -- Skip stale entries
        if d > (dist[u] or math.huge) then
            goto continue
        end

        for _, edge in ipairs(graph[u] or {}) do
            local v = edge.to
            local w = edge.weight
            if (dist[v] or math.huge) > d + w then
                dist[v] = d + w
                pq:push({ node = v, dist = dist[v] })
            end
        end

        ::continue::
    end

    return dist
end

A naive implementation like this inserts a new entry every time a shorter path is found, which means the heap can contain multiple entries for the same node. Stale entries get skipped on extraction. For better performance with many distance updates, a position map (a dictionary from node to its current heap index) enables true decrease-key operations in O(log n) instead of inserting duplicates.

Complexity Summary

OperationTime
Insert / pushO(log n)
Extract-min / popO(log n)
PeekO(1)
Decrease-key (with position map)O(log n)
Build heap (heapify)O(n)
SpaceO(n)

The binary heap wins over alternatives like a sorted array (slow insert) or an unsorted array (slow extract) because it keeps both operations at O(log n). Compared to a balanced BST, the heap has a smaller constant factor since it avoids the overhead of maintaining tree balance.

See Also