Data Structures · Heaps and Priority Queues

d-Ary Heaps and How Branching Factor Changes Cost

Trading comparisons per level against levels per sift, why four children often beats two on real hardware, and where the arithmetic stops fitting in a register.

A binary heap fixes the branching factor at two without saying why. Two is the minimum that makes a tree, and it produces the shallowest tree per unit of fan-out cost, but it is not the choice that minimises total comparisons, and it is rarely the choice that minimises time on hardware where memory latency rather than comparison count dominates. Making the branching factor a parameter turns out to be a one-line change to the index arithmetic and a substantial change to the cost profile.

The trade in one formula

A heap whose internal nodes have d children has height ⌊log_d n⌋. A sift-down at each level must find the smallest of d children, which costs d − 1 comparisons, plus one comparison against the key being sifted. The total for a full descent is therefore approximately

$$ d \cdot \log_d n ;=; \frac{d}{\log_2 d} \cdot \log_2 n $$

comparisons. The factor d/log₂ d is the whole trade. At d = 2 it equals 2. At d = 3 it equals 3 / 1.585 ≈ 1.89. At d = 4 it equals 4 / 2 = 2. At d = 8, 8/3 ≈ 2.67. The function has a minimum at d = e ≈ 2.718, so the integer choices nearest the optimum are 2 and 3, with three slightly ahead, and d = 2 is already close to optimal in comparison count. Every increase in fan-out beyond three buys nothing in comparisons and costs comparisons steadily; toward two it also rises, because the tree gains a level for every small reduction in per-level work.

That analysis counts comparisons only. It is also why the practical answer differs: on real machines the number of comparisons is not the cost being paid. The cost is the number of dependent memory accesses that miss cache, and branching factor changes that number in the opposite direction.

Index arithmetic when children are not adjacent

For a d-ary heap in level order, the children of slot i occupy slots d·i + 1 through d·i + d, and the parent of slot i is ⌊(i − 1)/d⌋. At d = 2 these reduce to the familiar forms. The implementation change is confined to two expressions, and one of them becomes materially more expensive.

The parent computation (i − 1) / d is a division. For d = 2 the compiler emits a shift, for d = 4 another shift, and for d = 3 an actual division instruction or a multiply-by-reciprocal sequence, since three is not a power of two. Sift-up runs on every insertion, so at d = 3 the insertion path pays that sequence per level where the binary heap pays a shift — a difference of a few instructions per level in a loop whose memory access is already a dependent load.

The child range computation d·i + 1 is a multiply and an add, cheap for any d, and the scan over d children is a tight loop over contiguous slots. Contiguity is the important property: the d children of one node sit next to each other in the array, so finding the minimum among them touches one region of memory. In a binary heap that region is two elements, usually within one cache line already. In a four-ary heap it is four elements, still within one cache line for four-byte keys. In a sixteen-ary heap it is sixteen, spanning a line boundary for most key sizes.

Why four children often wins anyway

The comparison-count argument assumes each comparison costs the same. It does not. Each level of a sift-down is a dependent memory access: the index of the next level’s slots cannot be computed until the current level’s minimum is known, so the accesses form a serial chain of cache misses. For a heap much larger than cache, each level is likely to miss, and a miss costs on the order of a hundred cycles — two orders of magnitude more than a comparison of two integers in registers.

Halving the number of levels halves the length of that serial chain. A four-ary heap over one million keys has height 10 rather than 19, so a sift-down performs about 10 dependent accesses rather than 19, and the extra comparisons per level are paid in registers while the memory is already being waited on. The same reasoning applies to the tree’s upper levels. The first three levels of a four-ary heap hold 1 + 4 + 16 = 21 nodes against 1 + 2 + 4 = 7 for a binary heap, so a sift-down’s first three steps span 21 keys — 84 bytes at four bytes each — where the binary heap spans 7 keys in 28 bytes. Against a line of 64 bytes the binary heap’s upper levels are fully cache-resident while the four-ary heap’s third level already crosses a line boundary. The four-ary heap compensates by reaching a given number of keys with fewer dependent steps: a sift-down’s four children occupy four consecutive slots — 64 bytes of the array for keys of sixteen bytes, at most two cache lines regardless of where the level starts.

The trade reverses at the bottom. A four-ary heap spends a quarter of its slots on internal nodes rather than the half a binary heap spends, but each of those nodes has height at most log_d n instead of log₂ n. The level-counting argument still gives linear time, with a smaller constant: each level contributes about n/d^(h+1) nodes at cost d·h.

Branching factorHeight for n = 10⁶Comparisons per levelDependent accesses per sift
219319
313413
410510
8797

The table makes the shape clear. Comparisons per sift are lowest at d = 3 and rise slowly on either side; the number of dependent accesses falls monotonically and steeply at first. Since the accesses are the expensive term, a branch above two is preferable on memory-bound workloads, and larger factors pay off once a node is wide enough that the whole level of children fits in one line.

Where the layout stops being an array

The array layout depends on the tree being complete, and a d-ary heap remains complete by the same argument as the binary case: extraction moves the last element to the root and sifts down, never leaving a gap. Nothing about the mapping requires d = 2.

What does change is the arithmetic range. For a heap of n elements, the index of the last element is n − 1 and the largest index computed during a sift is d·i + d for the deepest internal node. For a heap near the limits of a 32-bit index — around 4.3 billion slots — the multiply d·i overflows while the slot index itself is still representable, and a four-ary heap reaches that point at a quarter of the range. Implementations that support such sizes use 64-bit index arithmetic even when element counts are 32-bit, which costs nothing on 64-bit hardware but must be written deliberately.

Memory footprint is unchanged in the element count but changes in alignment. Level k begins at index (d^k − 1)/(d − 1), which for d = 4 is 0, 1, 5, 21, 85 — offsets that need not align to a cache-line boundary. A binary heap has the same property, but its levels are small enough that the effect is invisible. At d = 4 the level containing 64 nodes spans 256 bytes and begins at an offset that varies by heap size, so the first and last cache lines of a level are partially used. The waste is bounded by one line per level, which for ten levels is negligible overall, and is one reason alignment is not the deciding factor here.

Choosing the factor in practice

Three considerations decide the parameter, and they can be checked without measuring.

The first is key and payload size. If a node is a four-byte key, a cache line holds sixteen keys, and d = 4 or d = 8 keeps a whole level’s children in one or two lines. If a node is a 64-byte record, a cache line holds one node, and the scan over children touches d distinct lines regardless; the fan-out then only buys height reduction, and larger d is defensible. If a node is 200 bytes, the array-of-structs layout is wrong for any d, and an index-based heap over a separate key array is the better structure.

The second is the operation mix. A workload dominated by extraction benefits from shallow trees and favours larger d. A workload dominated by insertion pays the division in the sift-up path at non-power-of-two d and is best served by d = 2 or d = 4. A workload that only reads the minimum never sifts at all and does not care.

The third is whether the structure must be merged. None of the array layouts can merge two heaps in less than linear time, because merging requires rebuilding. Structures that do support fast merge abandon the array entirely, as leftist and skew heaps describe. Within the array family, the branching factor is a tuning knob with a shallow optimum and no correctness consequences, which is why libraries that expose it treat it as a compile-time constant rather than a runtime parameter: the index arithmetic must be inlined to avoid paying for the general case.

One further interaction is worth flagging. Bulk construction calls sift-down on every internal node, and the count of internal nodes rises with the branching factor — roughly a quarter of the array at d = 4 against one half at d = 2 — while the height each of them can descend shrinks. The total work remains linear, but the constant in the level sum changes, and for an array that is built once and extracted from many times the build cost is irrelevant next to the per-extraction path length. The choice should be made on the extraction profile, as bottom-up heap construction sets out, rather than on the build.

References

  1. Data Structures and Network Algorithms — Chapter 3, Heaps — Robert E. Tarjan; SIAM, 1983
  2. Introduction to Algorithms — Chapter 6, Heapsort, and its exercise on d-ary heaps — Cormen, Leiserson, Rivest, Stein; MIT Press
  3. The Art of Computer Programming, Volume 3: Sorting and Searching — Section 5.2.3 — Donald E. Knuth; Addison-Wesley