deheap

package module
v1.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 8, 2026 License: MIT Imports: 3 Imported by: 13

README

deheap

Go Reference Go Report Card codecov

A doubly-ended heap (min-max heap) for Go. Provides O(log n) access to both the minimum and maximum elements of a collection through a single data structure, with zero external dependencies.

go get github.com/aalpar/deheap

Why a doubly-ended heap?

A standard binary heap gives you efficient access to one extremum — the smallest or the largest element — but not both. Many practical problems need both ends simultaneously:

Scheduling and resource allocation. Operating system schedulers and job queues routinely need the highest-priority task (to run next) and the lowest-priority task (to evict or age). A doubly-ended priority queue avoids maintaining two separate heaps and the bookkeeping to keep them synchronized.

Bounded-size caches and buffers. When a priority queue has a capacity limit, insertions must discard the least valuable element. With a min-max heap, both the insertion (against the max) and the eviction (from the min, or vice versa) are logarithmic — no linear scan required.

Median maintenance and order statistics. Streaming median algorithms typically partition data into a max-heap of the lower half and a min-heap of the upper half. A single min-max heap can serve double duty, simplifying the implementation.

Network packet scheduling. Rate-controlled and deadline-aware packet schedulers (e.g., in QoS systems) need to dequeue by earliest deadline and drop by lowest priority, both efficiently.

Search algorithms. Algorithms like SMA* (Simplified Memory-Bounded A*) maintain an open set where the node with the lowest f-cost is expanded next and the node with the highest f-cost is pruned when memory is exhausted.

API

deheap provides two API surfaces.

Generic API (Go 1.21+)

For cmp.Ordered types — int, float64, string, and friends — use the type-safe generic API directly:

import "github.com/aalpar/deheap"

// Construct from existing elements.
h := deheap.From(5, 3, 8, 1, 9)

// Or build incrementally.
h := deheap.New[int]()
h.Push(5)
h.Push(3)

// O(1) access to both extrema.
fmt.Println(h.Peek())    // smallest
fmt.Println(h.PeekMax()) // largest

// O(log n) removal from either end.
min := h.Pop()
max := h.PopMax()

// Remove by index.
val := h.Remove(2)

// Update an element and restore heap order.
h.Push(10)
h.Push(20)
// ... mutate the element at index 0 ...
h.Fix(0)

// Check if the heap is valid.
fmt.Println(h.Verify()) // true
Interface API

For custom types, implement heap.Interface and use the package-level functions. This is the original v1 API and remains stable.

import "github.com/aalpar/deheap"

type IntHeap []int

func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *IntHeap) Push(x interface{}) { *h = append(*h, x.(int)) }

func (h *IntHeap) Pop() interface{} {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[:n-1]
    return x
}

h := &IntHeap{2, 1, 5, 6}
deheap.Init(h)
deheap.Push(h, 3)
min := deheap.Pop(h)
max := deheap.PopMax(h)

// Update an element and restore heap order.
(*h)[0] = 42
deheap.Fix(h, 0)

// Check if the heap is valid.
fmt.Println(deheap.Verify(h)) // true

Implementation

The underlying data structure is a min-max heap stored in a flat slice with no pointers and no additional bookkeeping. Nodes on even levels (0, 2, 4, ...) satisfy the min-heap property with respect to their descendants, and nodes on odd levels (1, 3, 5, ...) satisfy the max-heap property. The root is always the minimum; the maximum is one of its two children.

Level parity is determined by bit-length of the 1-based index, computed via math/bits.Len — a single CPU instruction on most architectures. Insertions bubble up through grandparent links; deletions bubble down through grandchild links, with a secondary swap against the binary-tree parent when the element crosses a level boundary.

The generic API implements the same algorithms directly on []T with native < comparisons, eliminating interface dispatch, adapter allocation, and boxing overhead.

Complexity
Operation Time Space
Push O(log n) O(1) amortized
Pop O(log n) O(1)
PopMax O(log n) O(1)
Remove O(log n) O(1)
Fix O(log n) O(1)
Peek O(1) O(1)
PeekMax O(1) O(1)
Init O(n) O(1)
Verify O(n) O(1)

Storage is a single contiguous slice — one element per slot, no child pointers, no color bits, no auxiliary arrays. Memory overhead beyond the elements themselves is the slice header (24 bytes on 64-bit systems).

Benchmarks

Measured on Apple M4 Max (arm64), Go 1.23:

Generic API (Deheap[T] — direct < comparisons, no interface dispatch):

Operation ns/op B/op allocs/op
Push 12 45 0
Pop 225 0 0
PopMax 225 0 0

Interface API (v1 — heap.Interface):

Operation ns/op B/op allocs/op
Push 21 54 0
Pop 288 7 0
PopMax 282 7 0

Standard library (container/heap) for comparison:

Operation ns/op B/op allocs/op
Push 22 55 0
Pop 208 7 0

The generic API is faster than container/heap on Push. Pop is ~8% slower due to examining up to four grandchildren per node (vs two children in a binary heap), but provides O(log n) access to both extrema. The v1 interface API pays the same heap.Interface dispatch cost as container/heap. All operations are zero-allocation in steady state.

Benchmark descriptions

The benchmarks compare deheap's two API surfaces against each other and against container/heap. Baseline measurements isolate the cost of slice operations from heap logic. All benchmarks use a heap of 10,000 int elements to ensure the working set fits in L1 cache while exercising the full tree depth.

Sample run (Apple M4 Max, Go 1.23):

$ go test -bench . -benchmem
goos: darwin
goarch: arm64
pkg: github.com/aalpar/deheap
cpu: Apple M4 Max
BenchmarkMin4-16              	261428533	         4.784 ns/op	       0 B/op	       0 allocs/op
BenchmarkBaselinePush-16      	1000000000	         6.411 ns/op	      42 B/op	       0 allocs/op
BenchmarkPush-16              	44383689	        23.16 ns/op	      50 B/op	       0 allocs/op
BenchmarkPop-16               	 5666707	       326.3 ns/op	       7 B/op	       0 allocs/op
BenchmarkPopMax-16            	 5644575	       319.7 ns/op	       7 B/op	       0 allocs/op
BenchmarkPushPop-16           	 5098502	       297.2 ns/op	      65 B/op	       1 allocs/op
BenchmarkHeapPushPop-16       	 6203408	       256.5 ns/op	      56 B/op	       1 allocs/op
BenchmarkHeapPop-16           	 8475319	       234.7 ns/op	       7 B/op	       0 allocs/op
BenchmarkHeapPush-16          	46837188	        21.57 ns/op	      48 B/op	       0 allocs/op
BenchmarkOrderedPush-16       	100000000	        12.38 ns/op	      45 B/op	       0 allocs/op
BenchmarkOrderedPop-16        	 8680756	       249.4 ns/op	       0 B/op	       0 allocs/op
BenchmarkOrderedPopMax-16     	10103798	       237.3 ns/op	       0 B/op	       0 allocs/op
BenchmarkOrderedPushPop-16    	 8926204	       233.2 ns/op	      44 B/op	       0 allocs/op
PASS
Benchmark What it measures
OrderedPush Generic Deheap[int].Push: append + bubble-up with direct < comparisons.
OrderedPop Generic Deheap[int].Pop: remove minimum and bubble-down with direct <.
OrderedPopMax Generic Deheap[int].PopMax: remove maximum and bubble-down with direct <.
OrderedPushPop Generic push-then-drain throughput.
Min4 Cost of min4, the internal function that finds the extremum among up to 4 grandchildren during bubble-down.
BaselinePush Raw slice append with no heap ordering — establishes the floor cost of memory allocation and copying.
Push deheap.Push (v1): append + bubble-up via heap.Interface.
Pop deheap.Pop (v1): remove the minimum element and bubble-down via heap.Interface.
PopMax deheap.PopMax (v1): remove the maximum element and bubble-down via heap.Interface.
PushPop v1 push-then-drain throughput.
HeapPushPop Same push-then-drain pattern using container/heap for direct comparison.
HeapPop container/heap.Pop in isolation, comparable to Pop above.
HeapPush container/heap.Push in isolation, comparable to Push above.

Testing

The test suite includes 54 test functions covering internal helpers, algorithmic correctness, edge cases (empty, single-element, two-element heaps), and large-scale randomized validation. Six native Go fuzz targets (testing.F) exercise both API surfaces under arbitrary input. Tests are run against Go 1.21, 1.22, and 1.23 in CI.

go test ./...          # run all tests
go test -bench . ./... # run benchmarks
go test -fuzz .        # run fuzz tests

Requirements

  • Go 1.21 or later (generic API)
  • Go 1.13 or later (interface API only)
  • Zero external dependencies

References

  1. M.D. Atkinson, J.-R. Sack, N. Santoro, and T. Strothotte. "Min-Max Heaps and Generalized Priority Queues." Communications of the ACM, 29(10):996–1000, October 1986. https://doi.org/10.1145/6617.6621

  2. J. van Leeuwen and D. Wood. "Interval Heaps." The Computer Journal, 36(3):209–216, 1993.

  3. P. Brass. Advanced Data Structures. Cambridge University Press, 2008.

License

MIT — see LICENSE.

Documentation

Overview

Package deheap provides a doubly-ended heap (min-max heap).

A min-max heap gives O(log n) access to both the smallest AND largest element in a collection — something a standard binary heap cannot do.

How a min-max heap works

Like a binary heap, a min-max heap is a complete binary tree stored in a flat slice. The difference is that tree levels alternate between "min levels" and "max levels":

Level 0 (min):               1             ← guaranteed minimum
                           /   \
Level 1 (max):            9     5          ← maximum is among these
                        / | \  / \
Level 2 (min):         2  3  4 3  2        ← each ≤ all descendants
                      /|
Level 3 (max):       6  8                  ← each ≥ all descendants

The invariant: every node on a min level is ≤ all of its descendants, and every node on a max level is ≥ all of its descendants.

This means:

  • The root (index 0) is always the global minimum.
  • The global maximum is one of the root's children (index 1 or 2).
  • Both Peek and PeekMax are O(1).

The tree is stored as a flat slice in level-order, exactly like a standard binary heap:

Index:   0  1  2  3  4  5  6  7  8  9
Value: [ 1, 9, 5, 2, 3, 4, 3, 2, 6, 8 ]
Level:   0  1  1  2  2  2  2  3  3  3
Kind:  min max    min            max

Worked example: Push(0) into [1, 9, 5, 4, 6, 8, 7, 3, 2]

We append 0 at index 9, then bubble up:

Step 0 — append to end:

                 1
               /   \
              9     5
            / | \  / \
           4  6  8 7  3
          /|
         2  0  ← new element at index 9 (max level)

Step 1 — index 9 is on a max level, but 0 < parent 6 (index 4).
         0 is smaller than its parent, so it belongs on a min level.
         Swap with parent:

                 1
               /   \
              9     5
            / | \  / \
           4  0  8 7  3
          /|
         2  6

Step 2 — now at index 4 (max level), but we switched to min-level
         bubbling. Compare with grandparent (index 0): 0 < 1.
         Swap with grandparent:

                 0          ← new minimum
               /   \
              9     5
            / | \  / \
           4  1  8 7  3
          /|
         2  6

Done. The heap property is restored. The new minimum 0 is at the root.

Worked example: Pop() from [1, 9, 5, 4, 6, 3, 2]

Step 0 — swap root (index 0) with last element (index 6), then
         remove the last element (the old root, value 1):

         Before:              After swap & remove:

              1                    2
            /   \               /   \
           9     5             9     5
          /|\   /             /|\
         4  6 3 2            4  6  3

Step 1 — bubble down from index 0 (min level). Find the smallest
         among children {9,5} and grandchildren {4,6,3}. Smallest
         is 3 at index 5 (a grandchild).
         Swap index 0 with index 5:

              3
            /   \
           9     5
          /|\
         4  6  2  ← but wait, 2 < parent 5, so swap with parent

Step 2 — after grandchild swap, check if the moved element (2)
         violates the max-level parent. 2 < 5, so swap 2 and 5:

              3
            /   \
           9     2
          /|\
         4  6  5

Done. Returned 1 (the old minimum). New minimum is 3.
Example (IntHeap)

This example inserts several ints into an IntHeap, checks the minimum, and removes them in order of priority.

package main

import (
	"fmt"

	"github.com/aalpar/deheap"
)

// An IntHeap is a min-heap of ints.
type IntDeheap []int

func (h IntDeheap) Len() int           { return len(h) }
func (h IntDeheap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntDeheap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *IntDeheap) Push(x interface{}) {

	*h = append(*h, x.(int))
}

func (h *IntDeheap) Pop() interface{} {
	old := *h
	n := len(old)
	x := old[n-1]
	*h = old[:n-1]
	return x
}

func main() {
	h := &IntDeheap{2, 1, 5, 6}
	deheap.Init(h)
	deheap.Push(h, 3)
	fmt.Printf("minimum: %d\n", (*h)[0])
	for h.Len() > 3 {
		fmt.Printf("%d ", deheap.PopMax(h))
	}
	for h.Len() > 1 {
		fmt.Printf("%d ", deheap.Pop(h))
	}
	fmt.Printf("middle value: %d\n", (*h)[0])
}
Output:

minimum: 1
6 5 1 2 middle value: 3
Example (Ordered)
package main

import (
	"fmt"

	"github.com/aalpar/deheap"
)

func main() {
	h := deheap.From(2, 1, 5, 6)
	h.Push(3)
	fmt.Printf("minimum: %d\n", h.Peek())
	fmt.Printf("maximum: %d\n", h.PeekMax())
	for h.Len() > 3 {
		fmt.Printf("%d ", h.PopMax())
	}
	for h.Len() > 1 {
		fmt.Printf("%d ", h.Pop())
	}
	fmt.Printf("middle value: %d\n", h.Peek())
}
Output:

minimum: 1
maximum: 6
6 5 1 2 middle value: 3

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Fix added in v1.1.1

func Fix(h heap.Interface, i int)

Fix re-establishes the heap ordering after the element at index i has changed its value. Equivalent to, but cheaper than, Remove(h, i) followed by Push of the new value.

The index i must be in the range [0, h.Len()). It panics if i is out of bounds.

The complexity is O(log n) where n = h.Len().

func Init

func Init(h heap.Interface)

Init establishes the min-max heap ordering on an arbitrary slice. Call this once on a non-empty slice before calling Pop, PopMax, or Push.

If the data already satisfies the heap property, Init returns after a linear scan with no modifications. Otherwise it uses Floyd's bottom-up heap construction, processing nodes from the last non-leaf down to the root. Most work happens near the leaves where subtrees are small and memory accesses are local.

Time complexity is O(n), where n = h.Len().

func Pop

func Pop(h heap.Interface) interface{}

Pop removes and returns the smallest element from the heap. Returns nil if the heap is empty.

The root (index 0) is always the minimum. To remove it, swap it with the last element, pop the last element off the slice, and bubbledown from the root to restore the heap property.

Time complexity is O(log n), where n = h.Len().

func PopMax

func PopMax(h heap.Interface) interface{}

PopMax removes and returns the largest element from the heap. Returns nil if the heap is empty.

The maximum lives at index 1 or 2 (the root's children, both on max level 1). We find which child is larger, swap it with the last element, pop the last off the slice, and bubbledown from the vacated child position using max-level ordering.

          min: 1
            /   \
max: [9]     5       ← max is at index 1; swap it out

Time complexity is O(log n), where n = h.Len().

func Push

func Push(h heap.Interface, o interface{})

Push adds element o to the heap, maintaining the min-max heap property.

The element is appended to the end of the slice (the next open slot in the complete binary tree), then bubbled up to its correct position.

Time complexity is O(log n), where n = h.Len().

func Remove

func Remove(h heap.Interface, i int) (q interface{})

Remove removes and returns the element at index i from the heap.

The element at i is swapped with the last element, the last element is popped off the slice, and then the replacement is sifted into place. Because the replacement can come from anywhere in the tree, both bubbledown AND bubbleup are needed to restore the invariant.

The complexity is O(log n) where n = h.Len().

func Verify added in v1.1.1

func Verify(h heap.Interface) bool

Verify reports whether h satisfies the min-max heap property.

Time complexity is O(n), where n = h.Len().

Example

This example demonstrates using Verify to check heap validity.

package main

import (
	"fmt"

	"github.com/aalpar/deheap"
)

// An IntHeap is a min-heap of ints.
type IntDeheap []int

func (h IntDeheap) Len() int           { return len(h) }
func (h IntDeheap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntDeheap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *IntDeheap) Push(x interface{}) {

	*h = append(*h, x.(int))
}

func (h *IntDeheap) Pop() interface{} {
	old := *h
	n := len(old)
	x := old[n-1]
	*h = old[:n-1]
	return x
}

func main() {
	h := &IntDeheap{2, 1, 5, 6}
	deheap.Init(h)
	fmt.Println(deheap.Verify(h))

	// Corrupt the heap by placing a small value at a max-level position.
	(*h)[1] = -1
	fmt.Println(deheap.Verify(h))
}
Output:

true
false

Types

type Deheap

type Deheap[T cmp.Ordered] struct {
	// contains filtered or unexported fields
}

Deheap is a type-safe doubly-ended heap for cmp.Ordered types.

Unlike the v1 interface-based API, this implementation operates directly on the underlying []T slice with native < comparisons, avoiding interface dispatch, adapter allocation, and boxing overhead.

Usage:

h := deheap.From(5, 3, 8, 1, 9)
h.Peek()    // 1  — O(1) minimum
h.PeekMax() // 9  — O(1) maximum
h.Pop()     // 1  — O(log n) remove minimum
h.PopMax()  // 9  — O(log n) remove maximum

func From

func From[T cmp.Ordered](items ...T) *Deheap[T]

From constructs a Deheap from the given elements and initializes the heap ordering using Floyd's bottom-up heap construction.

If the input already satisfies the heap property, From returns after a linear scan with no modifications.

func New

func New[T cmp.Ordered]() *Deheap[T]

New returns an empty Deheap.

func (*Deheap[T]) Fix added in v1.1.1

func (p *Deheap[T]) Fix(i int)

Fix re-establishes the heap ordering after the element at index i has changed its value. Equivalent to, but cheaper than, Remove(i) followed by Push of the new value.

The index i must be in the range [0, p.Len()). It panics if i is out of bounds.

The complexity is O(log n) where n = p.Len().

func (*Deheap[T]) Len

func (p *Deheap[T]) Len() int

Len returns the number of elements in the heap.

func (*Deheap[T]) Peek

func (p *Deheap[T]) Peek() T

Peek returns the smallest element without removing it. It panics if the heap is empty.

func (*Deheap[T]) PeekMax

func (p *Deheap[T]) PeekMax() T

PeekMax returns the largest element without removing it. It panics if the heap is empty.

In a min-max heap the maximum is always one of the root's children (index 1 or 2), since they sit on the first max level. With only one element the root is both min and max; with two elements the sole child at index 1 is the max.

    1          ← min (root)
  /   \
[9]    5       ← max is the larger child

func (*Deheap[T]) Pop

func (p *Deheap[T]) Pop() T

Pop removes and returns the smallest element from the heap. Returns the zero value of T if the heap is empty. Time complexity is O(log n), where n = h.Len().

func (*Deheap[T]) PopMax

func (p *Deheap[T]) PopMax() T

PopMax removes and returns the largest element from the heap. Returns the zero value of T if the heap is empty. Time complexity is O(log n), where n = h.Len().

func (*Deheap[T]) Push

func (p *Deheap[T]) Push(o T)

Push adds an element to the heap. Time complexity is O(log n), where n = h.Len().

func (*Deheap[T]) Remove

func (p *Deheap[T]) Remove(i int) T

Remove removes and returns the element at index i. It panics if i is out of bounds. Time complexity is O(log n), where n = h.Len().

func (*Deheap[T]) Verify added in v1.1.1

func (p *Deheap[T]) Verify() bool

Verify reports whether the heap satisfies the min-max heap property.

Time complexity is O(n), where n = p.Len().

Example
package main

import (
	"fmt"

	"github.com/aalpar/deheap"
)

func main() {
	h := deheap.From(2, 1, 5, 6)
	fmt.Println(h.Verify())

	h.Push(3)
	fmt.Println(h.Verify())
}
Output:

true
true

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL