Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.21', '1.22', '1.23']
steps:
- uses: actions/checkout@v4

- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test -race ./...
40 changes: 40 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.21', '1.22', '1.23']
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}

- name: Test
run: go test -race ./...

release:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Binaries
*.exe
*.dll
*.so
*.dylib

# Test binary
*.test

# Coverage
*.out

# Fuzz cache
testdata/fuzz/

# IDE files
.idea
2 changes: 1 addition & 1 deletion LICENSE.txt → LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright 2019 Aaron H. Alpar
Copyright 2019-2026 Aaron H. Alpar

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files
Expand Down
58 changes: 58 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
PROJECT=deheap
GO=go
GO_BUILD=$(GO) build
GO_TEST=$(GO) test
GO_VET=$(GO) vet
GO_BENCH=$(GO_TEST) -bench .
GIT=git
SH_TOOLS_DIR=./tools/sh
BUILD_VERSION:=$(shell cat ./VERSION 2>/dev/null || echo "0.0.0")

.PHONY: all
all: build test vet

.PHONY: build
build:
$(GO_BUILD) ./...

.PHONY: test
test:
$(GO_TEST) ./...

.PHONY: bench
bench:
$(GO_BENCH) ./...

.PHONY: vet
vet:
$(GO_VET) ./...

.PHONY: clean
clean:
$(GO) clean -cache -testcache -fuzzcache

# Create an annotated git tag from the version in ./VERSION.
# make tag
.PHONY: tag
tag:
@echo "$(BUILD_VERSION)" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9_.-]+)?$$' || (echo "Error: invalid version '$(BUILD_VERSION)'"; exit 1)
$(GIT) tag -a "$(BUILD_VERSION)" -m "Release $(BUILD_VERSION)"
@echo "Created tag $(BUILD_VERSION)"

# Bump the major version in VERSION (resets minor and patch to 0).
# make bump-major
.PHONY: bump-major
bump-major:
$(SH_TOOLS_DIR)/bump-version.sh major

# Bump the minor version in VERSION (resets patch to 0).
# make bump-minor
.PHONY: bump-minor
bump-minor:
$(SH_TOOLS_DIR)/bump-version.sh minor

# Bump the patch version in VERSION.
# make bump-patch
.PHONY: bump-patch
bump-patch:
$(SH_TOOLS_DIR)/bump-version.sh patch
226 changes: 217 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,222 @@
# deheap

Package deheap provides the implementation of a doubly ended heap.
Doubly ended heaps are heaps with two sides, a min side and a max side.
Like normal single-sided heaps, elements can be pushed onto and pulled
off of a deheap. deheaps have an additional `Pop` function, `PopMax`, that
returns elements from the opposite side of the ordering.
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.

This implementation has emphasized compatibility with existing libraries
in the sort and heap packages.
```
go get github.com/aalpar/deheap
```

Performace of the deheap functions should be very close to the
performance of the functions of the heap library
## 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)
```

### 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)
```

## 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) |
| `Peek` | O(1) | O(1) |
| `PeekMax` | O(1) | O(1) |
| `Init` | 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 and competitive on
Pop despite examining grandchildren (up to four per node vs two). The v1
interface API pays the same `heap.Interface` dispatch cost as `container/heap`.
All operations are zero-allocation in steady state.

#### Benchmark descriptions

| 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 36 test functions covering internal helpers,
algorithmic correctness, edge cases (empty, single-element, two-element
heaps), and large-scale randomized validation. Four 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).
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.0
v1.0.0
1 change: 0 additions & 1 deletion corpus/0

This file was deleted.

1 change: 0 additions & 1 deletion corpus/1

This file was deleted.

1 change: 0 additions & 1 deletion corpus/2

This file was deleted.

Loading
Loading