An open API service indexing awesome lists of open source software.

https://github.com/aalpar/deheap

Generic double-ended heap (min-max heap) for Go — O(1) min and max, O(log n) push/pop
https://github.com/aalpar/deheap

data-structure deque double-ended-queue generics go golang heap heaps min-max-heap priority-queue

Last synced: 16 days ago
JSON representation

Generic double-ended heap (min-max heap) for Go — O(1) min and max, O(log n) push/pop

Awesome Lists containing this project

README

          

# deheap

[![Go Reference](https://pkg.go.dev/badge/github.com/aalpar/deheap.svg)](https://pkg.go.dev/github.com/aalpar/deheap)
[![Go Report Card](https://goreportcard.com/badge/github.com/aalpar/deheap)](https://goreportcard.com/report/github.com/aalpar/deheap)
[![codecov](https://codecov.io/gh/aalpar/deheap/graph/badge.svg)](https://codecov.io/gh/aalpar/deheap)

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:

```go
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.

```go
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.

```bash
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](LICENSE).