Skip to content

Binary heap with lazy deletion

Moduleds.06 · build · Rust · Pass 3 · 4 to 6 h
You buildrust/crates/tl-ds/src/heap.rs: Heap<T, F> (new, new_min, with_capacity, from_vec, push, pop, peek, len, is_empty, clear, array, into_sorted_vec) and LazyHeap<T, F> with Handle (push, remove, contains, pop, pop_with_handle, peek, len, is_empty, stale_len, compact, clear)
Contractthe interface in section 4 (no Rust trait file yet; the tests pin it)
Testscourse/tests/rust/ds_06.rs, 12 tests (what they check: section 4) · your own tests in rust/crates/tl-ds/tests/ds06_heap.rs, rung R3 (tests first, ol tdd red), graded by mutation (threshold 0.70, mutant s02 required)
Needsreading: lang.04 the Rust primer (closures, generics) · the crate root is ds.05’s
Used byL1.5 runs every BPE merge from a LazyHeap keyed by (rank, position) · later: L10.2 keeps the engine’s waiting queue in one; ds.04 (top-k in C) builds on this chapter
MilestoneMS-L1 (the Rust tokenizer’s merges come out of your heap)
Optional depthCormen et al., Introduction to Algorithms, chapter 6 (heapsort and priority queues); Williams, Algorithm 232: Heapsort (1964); Floyd, Algorithm 245: Treesort 3 (1964, the O(n) build)
  • A binary heap is a complete binary tree stored in an array: the children of index ii are 2i+12i + 1 and 2i+22i + 2, so push and pop are O(log⁡n)O(\log n) swaps along one path (hand_example_sift, pop_sifts_down_through_the_smaller_child).
  • Ties broken by push order make the heap stable: equal items come out first in, first out, whatever the array looks like (equal_items_come_out_in_push_order).
  • Lazy deletion removes any item in O(1)O(1): bump its slot’s generation and let pop skip the stale entry when it reaches the root; the generation stops an old handle from removing a newer item (lazy_remove_hand_example).
  • Stale entries cost memory, so the heap compacts when they outnumber live ones; pop order never changes (stale_entries_stay_bounded, lazy_differential_against_model).
  • Building a heap from nn items costs O(n)O(n), not O(nlog⁡n)O(n \log n): sift down every parent from the last one to the root (from_vec_heapifies_in_place).
Terminal window
ol start ds.06 # stubs heap.rs (the tl-ds crate root comes with ds.05)
ol tdd red ds.06 # rung R3: your tests first, and they must fail on the stub
ol tests ds.06 # read the test catalog
ol check ds.06 # exit code is the verdict; then grades your tests by mutation
ol diff ds.06 # after passing: your code against the reference

Write before, sift_up, sift_down, and pop_root as free functions over a slice of Node<T>: both heaps share them, and the hand example checks them through Heap::array.


Your Rust tokenizer (L1.5) has to apply BPE merges in rank order: in each pre-token, repeatedly merge the adjacent pair with the lowest rank, the leftmost on a tie. Rescanning every pair after every merge, as the definition says and as your Python may do, costs O(n2)O(n^2) per pre-token; a 300-byte run of one letter (they occur in real text) makes it visible. A priority queue gives the next pair in O(log⁡n)O(\log n). But each merge also destroys the two pairs that touched the merged symbols, and a binary heap cannot delete from the middle. This module builds the heap, then the lazy deletion that makes the merge queue work. The engine’s scheduler (L10.2) needs the same thing: a queue by priority from which a cancelled request disappears.

SymbolMeaningType
nnnumber of items in the arrayusize
iian array index, 0≤i<n0 \le i < nusize
parent(i)=⌊(i−1)/2⌋\text{parent}(i) = \lfloor (i - 1)/2 \rfloor, left(i)=2i+1\text{left}(i) = 2i + 1, right(i)=2i+2\text{right}(i) = 2i + 2the tree links, computed, never storedusize
a≺ba \prec baa comes out before bb: the comparator says Less, or Equal and aa was pushed firstrelation
gsg_sthe generation of slot ss in a lazy heapu32

A complete binary tree fills each level left to right, so it fits in an array with no gaps and no pointers. The heap property: no child comes out before its parent (parent(i)⪯i\text{parent}(i) \preceq i). The root, index 0, is then the next item out. The height is ⌊log⁡2n⌋\lfloor \log_2 n \rfloor.

Push appends at index nn and sifts up: while the new item comes out before its parent, swap them. Pop takes the root, moves the last item to index 0, and sifts down: while one of its children comes out before it, swap it with the child that comes out first (swapping with the other would put the larger child above the smaller one). Each touches one root-to-leaf path: O(log⁡n)O(\log n).

Pushing nn items one by one costs O(nlog⁡n)O(n \log n). Floyd’s construction treats the array as a tree whose leaves (the last ⌈n/2⌉\lceil n/2 \rceil indices) are already heaps, then sifts down each parent from index ⌊n/2⌋−1\lfloor n/2 \rfloor - 1 back to 0. A node at height hh sifts at most hh levels and there are about n/2h+1n / 2^{h+1} of them, so the total is ∑hh n/2h+1≤n\sum_h h\, n / 2^{h+1} \le n.

The order is a comparator cmp(a, b) -> Ordering, so one heap type serves a min-heap (Heap::new_min), a max-heap (|a, b| b.cmp(a)), and the merge queue ((rank, position) as a tuple). A binary heap is not stable by itself: two equal items can come out in either order depending on the array’s history. Each pushed item gets a sequence number, and ties compare by it, so equal items leave first in, first out. Determinism (P11) needs this: the engine’s queue must not reorder two requests of the same priority differently on two runs.

2.4 Lazy deletion with generation counters

Section titled “2.4 Lazy deletion with generation counters”

To remove an item from the middle, LazyHeap::push returns a Handle { slot, gen }. The heap keeps one generation counter gsg_s per slot and a free list of slots. remove(h) checks that gh.slot=h.geng_{h.slot} = h.gen (the item is still in the heap), then increments gh.slotg_{h.slot} and frees the slot, in O(1)O(1); the array entry stays where it is, now stale. pop and peek first discard stale entries from the root (“purge”), then pop as usual. A reused slot gets the new generation, so a stale handle never matches again: without the generation, an old handle would remove whatever item reused its slot.

Stale entries take memory and slow the sifts, so when the array holds more than 2⋅live+322 \cdot \text{live} + 32 entries, remove compacts: drop every stale entry and rebuild the heap in O(n)O(n). Pop order depends only on the comparator and the push order, so compaction never changes it. clear empties the heap but keeps the arrays, bumping every generation so that no handle from before survives: L1.5 clears one heap between pre-tokens and allocates once per text.

Pushes. Push 5, 3, 8, 1, 9, 2 into an empty min-heap and write the array after each push:

PushSift-up pathArray
5root[5]
3index 1, parent 0 holds 5: swap[3, 5]
8index 2, parent 0 holds 3: stay[3, 5, 8]
1index 3, parent 1 holds 5: swap; index 1, parent 0 holds 3: swap[1, 3, 8, 5]
9index 4, parent 1 holds 3: stay[1, 3, 8, 5, 9]
2index 5, parent 2 holds 8: swap; index 2, parent 0 holds 1: stay[1, 3, 2, 5, 9, 8]

Pop. Take 1 from the root, move the last item (8) to index 0: [8, 3, 2, 5, 9]. Its children are 3 and 2; the one that comes out first is 2 (index 2), and 2 comes out before 8: swap, [2, 3, 8, 5, 9]. Index 2 has no children: done. These two tables are hand_example_sift and pop_sifts_down_through_the_smaller_child.

Lazy removal. Push 5, 3, 8 into a LazyHeap: handles h5=(0,0)h_5 = (0, 0), h3=(1,0)h_3 = (1, 0), h8=(2,0)h_8 = (2, 0) as (slot, generation). remove(h_3): g1g_1 becomes 1 and slot 1 is free; the array still holds 3 at the root, so stale_len is 1. peek finds the root’s handle (1,0)(1, 0) stale (g1=1g_1 = 1), pops it, and returns 5. Push 4: it reuses slot 1 with handle (1,1)(1, 1). remove(h_3) now compares h3.gen=0h_3.gen = 0 with g1=1g_1 = 1: stale, returns false, and 4 stays. This is lazy_remove_hand_example.

rust/crates/tl-ds/src/heap.rs
pub struct Heap<T, F: Fn(&T, &T) -> Ordering> { /* data: Vec<Node<T>>, seq, cmp */ }
impl<T: Ord> Heap<T, fn(&T, &T) -> Ordering> { pub fn new_min() -> Self; }
impl<T, F: Fn(&T, &T) -> Ordering> Heap<T, F> {
pub fn new(cmp: F) -> Self; pub fn with_capacity(n: usize, cmp: F) -> Self;
pub fn from_vec(items: Vec<T>, cmp: F) -> Self; // O(n); equal items keep their order
pub fn push(&mut self, item: T); pub fn pop(&mut self) -> Option<T>; pub fn peek(&self) -> Option<&T>;
pub fn len(&self) -> usize; pub fn is_empty(&self) -> bool; pub fn clear(&mut self);
pub fn array(&self) -> Vec<&T>; // array order, root first
pub fn into_sorted_vec(self) -> Vec<T>; // pop order
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct Handle { /* slot: u32, gen: u32 */ }
pub struct LazyHeap<T, F: Fn(&T, &T) -> Ordering> { /* data, gens, free, live, seq, cmp */ }
impl<T, F: Fn(&T, &T) -> Ordering> LazyHeap<T, F> {
pub fn new(cmp: F) -> Self;
pub fn push(&mut self, item: T) -> Handle;
pub fn remove(&mut self, h: Handle) -> bool; // O(1); false if already popped or removed
pub fn contains(&self, h: Handle) -> bool;
pub fn pop(&mut self) -> Option<T>; pub fn pop_with_handle(&mut self) -> Option<(Handle, T)>;
pub fn peek(&mut self) -> Option<&T>; // &mut: discards stale entries above it
pub fn len(&self) -> usize; pub fn is_empty(&self) -> bool; pub fn stale_len(&self) -> usize;
pub fn compact(&mut self); pub fn clear(&mut self);
}
TestKINDChecksWhy it matters downstream
hand_example_siftunitthe section 3 push table, array after every pushyou and the tests agree on the layout
pop_sifts_down_through_the_smaller_childunitpop gives [2, 3, 8, 5, 9], then [3, 5, 8, 9]the swap goes to the child that comes out first
equal_items_come_out_in_push_orderunitseven items with three keys leave stablyL10.2 serves equal priorities in arrival order
lazy_remove_hand_exampleunitthe section 3 handles: stale count, purge on peek, slot reuse, stale handles refusedL1.5 removes destroyed pairs by handle
empty_and_single_itemboundaryempty pop and peek, one item, clear, an emptied lazy heapthe queue of a short piece
comparator_decides_the_orderunita reversed comparator is a max-heapone type for every queue
from_vec_heapifies_in_placeunitO(n) build pops sorted; equal items keep input order; push after buildbuilding a queue from a batch
bpe_merge_queue_pops_lowest_rank_then_leftmostunit(rank, position) keys with two removals by handlethe exact use in L1.5
differential_against_sorted_vecdifferential20,000 random pushes and pops agree with a stably sorted Vecthe heap is a priority queue
lazy_differential_against_modeldifferential20,000 pushes, removes by live and stale handles, and pops agree with a model of the live itemslazy deletion never leaks a removed item
stale_entries_stay_boundedboundaryafter 10,000 pushes and 9,900 removes at most 2 x live + 32 entries; survivors in ordermemory stays proportional to the live queue
clear_reuses_the_heap_and_stales_every_handleboundaryafter clear, no old handle removes or contains a new itemL1.5 clears one heap between pieces

Your tests (rung R3). Write rust/crates/tl-ds/tests/ds06_heap.rs first and run ol tdd red ds.06 (they must fail on the stub), then write the code and ol tdd green ds.06. Suggested tests: pops_in_order, array_after_pushes, ties_are_fifo, from_vec_sorts, removed_items_never_pop, many_removes_stay_bounded. At least 70% of the planted bugs, and the tie-breaking bug s02, must make one of them fail.

PitfallSymptomCaught by
Sifting up when the child does NOT come out before its parentthe root is not the minimum; everything pops out of orderhand_example_sift, differential_against_sorted_vec (mutant s01)
Breaking ties by reversed push orderequal priorities leave last in, first outequal_items_come_out_in_push_order (mutant s02)
Freeing a slot without bumping its generationan old handle removes the new item in its slot: a BPE merge deletes a live pairlazy_remove_hand_example (mutant s03)
Popping without purging stale entries firstremoved items come back out; the live count underflowslazy_remove_hand_example, bpe_merge_queue_pops_lowest_rank_then_leftmost (mutant s04)
Sifting down into the left child alwaysa larger child rises above a smaller onepop_sifts_down_through_the_smaller_child (mutant s05)
Never compactingthe array grows with every removal: the merge queue of a long word holds every pair it ever sawstale_entries_stay_bounded (mutant s06)
Starting the heapify loop at index 1the root is never sifted; from_vec returns a non-heapfrom_vec_heapifies_in_place (mutant s07)
Compacting without rebuilding the heapafter a compaction pops come out in array orderlazy_differential_against_model, stale_entries_stay_bounded (mutant s08)
Clearing without bumping generationsa handle from the previous piece removes a pair of the nextclear_reuses_the_heap_and_stales_every_handle (mutant s09)
DirectionModuleHow it uses this
Backlang.04closures as comparators, generic structs, Option
ForwardL1.5LazyHeap<(u32, usize), _> holds every mergeable pair of a pre-token; a merge pops one pair, removes two by handle, and pushes up to two
ForwardL10.2the continuous-batching scheduler’s waiting queue by (priority, arrival), with cancellation by handle
Forwardds.04the C top-k keeps a size-k min-heap over logits with the same sift operations

If you skip this module, ol check L1.5 stops with needs ds.06: build it, or pass --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
Heapstd::collections::BinaryHeapa max-heap over Ord with into_sorted_vec, peek_mut, and sift with a “hole” instead of swapslibrary/alloc/src/collections/binary_heap/mod.rs
LazyHeap for BPEHugging Face tokenizers word mergingthe same lazy queue: stale merges are skipped when poppedtokenizers/src/models/bpe/word.rs (merge_all)
lazy deletionindexed (addressable) heaps, pairing heapsdecrease_key in place by keeping each item’s array indexDijkstra’s algorithm in any graph library
the waiting queuevLLM’s schedulerpriority plus preemption policies over waiting and running queuesvllm/core/scheduler.py