@zakkster/lite-logn 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -1
- package/LogN.d.ts +49 -0
- package/LogN.js +377 -1
- package/README.md +49 -7
- package/llms.txt +36 -3
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,58 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.0] - 2026-09-20
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **MinMaxHeap** -- the seventh member and the family's DOUBLE-ENDED priority queue
|
|
14
|
+
(DEPQ): a single array-embedded binary heap whose levels ALTERNATE min / max (Atkinson,
|
|
15
|
+
Sack, Santoro & Strothotte 1986). Even depth (the root is depth 0) is a MIN level, odd
|
|
16
|
+
depth a MAX level, so the global minimum sits at the root and the global maximum is the
|
|
17
|
+
LARGER of the root's up-to-two children. `peekMin` / `peekMax` / `peekMinKey` /
|
|
18
|
+
`peekMaxKey` are O(1); `push` / `popMin` / `popMax` are all WORST-case O(log n) from that
|
|
19
|
+
ONE heap (no second heap, no paired-heap correspondence). Surface: `push(id, key)` /
|
|
20
|
+
`popMin` / `popMax` / `peekMin` / `peekMax` / `peekMinKey` / `peekMaxKey` / `clear` /
|
|
21
|
+
`forEach` / `[Symbol.iterator]`, and `size` / `capacity` getters, plus a Floyd O(n)
|
|
22
|
+
`MinMaxHeap.build(ids, keys, capacity)` (deepest-first, level-aware sift-down). Keys are
|
|
23
|
+
finite numbers (typeof-guarded before coercion -- Symbol / BigInt / NaN / +-Infinity fail
|
|
24
|
+
closed with a `[lite-logn]` throw, key checked FIRST, then the id, then a full heap).
|
|
25
|
+
- **id + key payload, NON-addressable (the asymmetry vs BinaryHeap).** Two parallel
|
|
26
|
+
pointer-free typed-array columns (`_id` Uint32Array + `_key` Float64Array), the BinaryHeap
|
|
27
|
+
id+key idiom -- but with NO `_pos` reverse map, and therefore deliberately NO `changeKey` /
|
|
28
|
+
`remove`. The id is an OPAQUE Uint32 payload (NOT unique; the full [0, 2^32) domain, wider
|
|
29
|
+
than BinaryHeap's [0, capacity)). A DEPQ's job is the two extremes; addressability is the
|
|
30
|
+
separable concern BinaryHeap already carries. There is also NO `kind` argument / getter (a
|
|
31
|
+
DEPQ has both ends; a kind getter would be a lie).
|
|
32
|
+
- **Classic one-element-per-node min-max heap only.** The interval-heap DEPQ (two elements
|
|
33
|
+
per node) is a deliberately deferred alternative -- named, never silently omitted (see
|
|
34
|
+
`decisions/0009-minmaxheap.md` and "What this is not").
|
|
35
|
+
- **Level parity, computed zero-alloc.** Slot i is a MIN level iff
|
|
36
|
+
`((31 - Math.clz32(i + 1)) & 1) === 0`. The sifts are hole-punching (one write per level,
|
|
37
|
+
only local scalar temporaries): `push` compares to the parent to pick the own-level vs
|
|
38
|
+
other-level chain then bubbles by grandparents; `popMin` / `popMax` trickle down over the
|
|
39
|
+
up-to-six descendants (children + grandchildren), bound-checking every grandchild index
|
|
40
|
+
against the live size (the classic min-max off-by-one, verified at n = 1, 2, 3, 4).
|
|
41
|
+
|
|
42
|
+
### Verified
|
|
43
|
+
|
|
44
|
+
- **Witness (D-M5).** `MinMaxHeap.popMin` is the gated O(log n) witness op (the full-height
|
|
45
|
+
level-aware trickle-down). It inherits the FROZEN family R^2 floor 0.958 and calibrates its
|
|
46
|
+
OWN slope band by the shared ADR-0004 method: median-of-15 popMin fit-runs = 10.30 ns/level
|
|
47
|
+
(runs spanned 9.38..10.59, R^2 0.9897..0.9991), band = median x `[0.6, 1.4]` = `[6.18, 14.42]`.
|
|
48
|
+
A DEPQ whose push / popMin / popMax are ALL worst-case, so there is NO max-single-op line.
|
|
49
|
+
The O(n) foil is a linear min-scan-and-splice extract-min (R^2 ~ 0.77, OFF the line).
|
|
50
|
+
- **Zero-GC.** `node --expose-gc test/torture.mjs` -- 0 B/op on the popMin / popMax / mixed
|
|
51
|
+
push+popMin+popMax churn and the peek read lanes, gc major = 0, the deliberately-allocating
|
|
52
|
+
control lane still non-zero (teeth), arrayBuffers do not grow across fill/clear soak cycles.
|
|
53
|
+
`npm run test:perf` -- 0 scavenges + `grows === 0` on every MinMaxHeap scenario.
|
|
54
|
+
|
|
55
|
+
### Notes
|
|
56
|
+
|
|
57
|
+
- `LogN.js` gains `MMH_MAX_CAPACITY` (`0x7FFFFFFF`) + the `MinMaxHeap` class, appended after
|
|
58
|
+
Scapegoat; the prior six classes stay BYTE-IDENTICAL (only the `VERSION` const changes above
|
|
59
|
+
the append point). Version bumped to 0.7.0 across `package.json`, `LogN.js`, and `llms.txt`.
|
|
60
|
+
|
|
9
61
|
## [0.6.0] - 2026-09-20
|
|
10
62
|
|
|
11
63
|
### Added
|
|
@@ -312,7 +364,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
312
364
|
[`decisions/0003-pack.md`](./decisions/0003-pack.md) (D-07: `files[]` ships the
|
|
313
365
|
six files only; `test/`, `benchmark/`, `decisions/`, `demo/` are repo-only).
|
|
314
366
|
|
|
315
|
-
[Unreleased]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.
|
|
367
|
+
[Unreleased]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.7.0...HEAD
|
|
368
|
+
[0.7.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.6.0...v0.7.0
|
|
316
369
|
[0.4.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.3.0...v0.4.0
|
|
317
370
|
[0.3.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.2.0...v0.3.0
|
|
318
371
|
[0.2.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.1.0...v0.2.0
|
package/LogN.d.ts
CHANGED
|
@@ -280,3 +280,52 @@ export class Scapegoat {
|
|
|
280
280
|
/** Empty the tree, keeping capacity. */
|
|
281
281
|
clear(): this;
|
|
282
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A min-max heap: a DOUBLE-ENDED priority queue (DEPQ) in ONE array-embedded binary heap
|
|
286
|
+
* whose levels ALTERNATE min / max (Atkinson et al. 1986). The minimum is the root; the
|
|
287
|
+
* maximum is the larger of the root's up-to-two children. peekMin / peekMax are O(1);
|
|
288
|
+
* push / popMin / popMax are O(log n) WORST-case. Two parallel typed-array columns (_key
|
|
289
|
+
* Float64, _id Uint32); the id is an OPAQUE Uint32 payload in [0, 2^32) (not unique, no
|
|
290
|
+
* reverse map), so there is deliberately NO changeKey / remove (the asymmetry vs
|
|
291
|
+
* BinaryHeap). Keys are finite numbers (typeof-guarded before coercion; Symbol / BigInt /
|
|
292
|
+
* NaN / +-Infinity fail closed). Fixed capacity: a full heap throws. Every hot op
|
|
293
|
+
* allocates zero bytes.
|
|
294
|
+
*/
|
|
295
|
+
export class MinMaxHeap {
|
|
296
|
+
/** @param capacity exact max live entries; integer in [1, 2^31-1]. */
|
|
297
|
+
constructor(capacity: number);
|
|
298
|
+
|
|
299
|
+
/** Live entry count. */
|
|
300
|
+
readonly size: number;
|
|
301
|
+
/** The fixed capacity this heap was sized for. */
|
|
302
|
+
readonly capacity: number;
|
|
303
|
+
|
|
304
|
+
/** Insert id with priority key. Throws on non-finite key, out-of-range id, or a full heap. */
|
|
305
|
+
push(id: number, key: number): void;
|
|
306
|
+
/** Remove and return the id at the minimum key, or undefined if empty. */
|
|
307
|
+
popMin(): number | undefined;
|
|
308
|
+
/** Remove and return the id at the maximum key, or undefined if empty. */
|
|
309
|
+
popMax(): number | undefined;
|
|
310
|
+
/** The id at the minimum key, or undefined if empty. */
|
|
311
|
+
peekMin(): number | undefined;
|
|
312
|
+
/** The id at the maximum key, or undefined if empty. */
|
|
313
|
+
peekMax(): number | undefined;
|
|
314
|
+
/** The minimum key, or undefined if empty. */
|
|
315
|
+
peekMinKey(): number | undefined;
|
|
316
|
+
/** The maximum key, or undefined if empty. */
|
|
317
|
+
peekMaxKey(): number | undefined;
|
|
318
|
+
/** Empty the heap, keeping capacity. */
|
|
319
|
+
clear(): void;
|
|
320
|
+
/** Visit every live (id, key) pair in unspecified (heap-array) order. */
|
|
321
|
+
forEach(fn: (id: number, key: number, heap: MinMaxHeap) => void): void;
|
|
322
|
+
/** Iterate live entity ids in unspecified (heap-array) order (NOT sorted). */
|
|
323
|
+
[Symbol.iterator](): IterableIterator<number>;
|
|
324
|
+
|
|
325
|
+
/** Floyd O(n) bulk build from parallel ids/keys arrays (level-aware sift-down). */
|
|
326
|
+
static build(
|
|
327
|
+
ids: ArrayLike<number>,
|
|
328
|
+
keys: ArrayLike<number>,
|
|
329
|
+
capacity: number,
|
|
330
|
+
): MinMaxHeap;
|
|
331
|
+
}
|
package/LogN.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
/** Package version. One of the three version sites (package.json / VERSION / llms.txt). */
|
|
42
|
-
export const VERSION = '0.
|
|
42
|
+
export const VERSION = '0.7.0';
|
|
43
43
|
|
|
44
44
|
// --- members land here, append-only, one tree-shakeable class each -----------
|
|
45
45
|
// BinaryHeap (v0.1.0 session) -- indexed O(log n) min|max heap (BELOW)
|
|
@@ -2496,3 +2496,379 @@ export class Scapegoat {
|
|
|
2496
2496
|
throw new RangeError('[lite-logn] Scapegoat full (capacity ' + this._cap + ')');
|
|
2497
2497
|
}
|
|
2498
2498
|
}
|
|
2499
|
+
|
|
2500
|
+
// MinMaxHeap (v0.7.0 session) -- a DEPQ (double-ended priority queue), array-embedded min-max heap (BELOW).
|
|
2501
|
+
|
|
2502
|
+
/**
|
|
2503
|
+
* Max MinMaxHeap capacity: slot indices 0..cap-1 index the two parallel, pointer-free
|
|
2504
|
+
* typed-array columns (`_key` Float64Array, `_id` Uint32Array). Children of slot i are
|
|
2505
|
+
* `2i+1` / `2i+2` and grandchildren `4i+3 .. 4i+6`, so the deepest index arithmetic a
|
|
2506
|
+
* hot op performs is `4*i + 6`; capping capacity at 2^31-1 keeps every derived index a
|
|
2507
|
+
* positive int32. The index arithmetic, not the byte count, is the hard ceiling -- the
|
|
2508
|
+
* same "the arithmetic caps it" reasoning as BinaryHeap's BH_MAX_CAPACITY.
|
|
2509
|
+
*/
|
|
2510
|
+
const MMH_MAX_CAPACITY = 0x7FFFFFFF; // 2^31 - 1
|
|
2511
|
+
|
|
2512
|
+
/**
|
|
2513
|
+
* A MIN-MAX HEAP: a DOUBLE-ENDED priority queue (DEPQ) held in ONE array-embedded binary
|
|
2514
|
+
* heap whose levels ALTERNATE min / max (Atkinson, Sack, Santoro & Strothotte 1986). Even
|
|
2515
|
+
* depth (the root is depth 0) is a MIN level, odd depth is a MAX level, so the global
|
|
2516
|
+
* minimum sits at the root and the global maximum is the LARGER of the root's up-to-two
|
|
2517
|
+
* children. That single alternating heap answers BOTH ends: `peekMin` / `peekMax` are
|
|
2518
|
+
* O(1); `push` / `popMin` / `popMax` are O(log n) WORST-case. It is the family's DEPQ --
|
|
2519
|
+
* where BinaryHeap fixes one extreme at construction, a min-max heap serves both from one
|
|
2520
|
+
* structure without a second heap or a paired-heap correspondence to maintain.
|
|
2521
|
+
*
|
|
2522
|
+
* Storage (allocated once, sized to capacity), the BinaryHeap id+key idiom:
|
|
2523
|
+
* - `_key` Float64Array -- the priority key at each heap SLOT (min-max-ordered).
|
|
2524
|
+
* - `_id` Uint32Array -- the opaque payload id at each heap SLOT (moves with its key).
|
|
2525
|
+
* There is NO reverse-index map (`_pos`) and so NO addressable `changeKey` / `remove`: the
|
|
2526
|
+
* id is an OPAQUE Uint32 payload, NOT a unique handle -- duplicate ids are allowed, and the
|
|
2527
|
+
* id domain is the full Uint32 range [0, 2^32) (a wider domain than BinaryHeap's [0,
|
|
2528
|
+
* capacity), which only that member's `_pos` map constrains). See decisions/0009.
|
|
2529
|
+
*
|
|
2530
|
+
* Level parity is computed zero-alloc: slot i (0-based) is a MIN level iff
|
|
2531
|
+
* `((31 - Math.clz32(i + 1)) & 1) === 0` (the depth `31 - clz32(i+1)` is even). The sift
|
|
2532
|
+
* is HOLE-PUNCHING (not a 3-write swap chain): the moving element is cached in locals once
|
|
2533
|
+
* and the hole walks writing ONE slot per level.
|
|
2534
|
+
* - push: append at the tail, compare the new element to its PARENT to pick the own-level
|
|
2535
|
+
* vs other-level chain, then bubble by GRANDPARENT comparisons up the min-or-max chain.
|
|
2536
|
+
* - popMin / popMax: open a hole at the root (min) or at the max-of-{slot1,slot2} (max),
|
|
2537
|
+
* move the last element into it, and trickle DOWN over CHILDREN + GRANDCHILDREN (a min
|
|
2538
|
+
* level sinks toward the smallest of the up-to-six descendants, a max level toward the
|
|
2539
|
+
* largest); a GRANDCHILD move does the extra parent re-check that keeps the alternating
|
|
2540
|
+
* order intact. Every one of the four grandchild indices is bound-checked against the
|
|
2541
|
+
* live size (the classic min-max off-by-one). Zero bytes allocated after construction.
|
|
2542
|
+
*
|
|
2543
|
+
* Keys are FINITE numbers (typeof-guarded BEFORE coercion -- Symbol / BigInt / NaN /
|
|
2544
|
+
* +-Infinity fail closed with a `[lite-logn]` throw); ids are integers in [0, 2^32). A key
|
|
2545
|
+
* guard fires FIRST, then the id guard, then the full-heap guard -- any throw leaves `size`
|
|
2546
|
+
* unchanged. Every peek / pop on an EMPTY heap returns `undefined` and NEVER throws. Fixed
|
|
2547
|
+
* capacity: overflow throws, never silently drops.
|
|
2548
|
+
*
|
|
2549
|
+
* This is the classic ONE-element-per-node min-max heap ONLY; the interval-heap DEPQ (two
|
|
2550
|
+
* elements per node) is a deliberately deferred alternative -- see decisions/0009 + NOT FOR.
|
|
2551
|
+
*/
|
|
2552
|
+
export class MinMaxHeap {
|
|
2553
|
+
/**
|
|
2554
|
+
* @param {number} capacity exact max live entries; integer in [1, 2^31-1].
|
|
2555
|
+
*/
|
|
2556
|
+
constructor(capacity) {
|
|
2557
|
+
// typeof guard BEFORE coercion (Number.isInteger is Symbol/BigInt-safe).
|
|
2558
|
+
if (typeof capacity !== 'number' || !Number.isInteger(capacity) ||
|
|
2559
|
+
capacity < 1 || capacity > MMH_MAX_CAPACITY) {
|
|
2560
|
+
throw new RangeError(
|
|
2561
|
+
'[lite-logn] MinMaxHeap capacity must be an integer in [1, 2^31-1], got ' +
|
|
2562
|
+
String(capacity));
|
|
2563
|
+
}
|
|
2564
|
+
this._key = new Float64Array(capacity); // key at each heap slot
|
|
2565
|
+
this._id = new Uint32Array(capacity); // opaque payload id at each heap slot
|
|
2566
|
+
this._cap = capacity; // exact fixed capacity
|
|
2567
|
+
this._n = 0; // live entries (heap size)
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
/** Live entry count. O(1). */
|
|
2571
|
+
get size() { return this._n; }
|
|
2572
|
+
|
|
2573
|
+
/** The fixed capacity this heap was sized for. O(1). */
|
|
2574
|
+
get capacity() { return this._cap; }
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* Insert entity `id` with priority `key`. O(log n). Fails closed: a non-finite /
|
|
2578
|
+
* non-number key (checked FIRST), a non-integer / out-of-range id, or a full heap each
|
|
2579
|
+
* throw `[lite-logn]` as a no-op (size unchanged).
|
|
2580
|
+
* @param {number} id integer in [0, 2^32), an OPAQUE payload (not required unique)
|
|
2581
|
+
* @param {number} key a finite number
|
|
2582
|
+
*/
|
|
2583
|
+
push(id, key) {
|
|
2584
|
+
if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
|
|
2585
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id < 0 || id > 0xFFFFFFFF) {
|
|
2586
|
+
return this._badId(id);
|
|
2587
|
+
}
|
|
2588
|
+
const n = this._n;
|
|
2589
|
+
if (n === this._cap) return this._full();
|
|
2590
|
+
this._n = n + 1;
|
|
2591
|
+
this._siftUp(n, key, id);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/**
|
|
2595
|
+
* Remove and return the id at the MINIMUM key (the root), or `undefined` if empty
|
|
2596
|
+
* (never throws on empty). O(log n).
|
|
2597
|
+
* @returns {number|undefined}
|
|
2598
|
+
*/
|
|
2599
|
+
popMin() {
|
|
2600
|
+
const n = this._n;
|
|
2601
|
+
if (n === 0) return undefined;
|
|
2602
|
+
const top = this._id[0];
|
|
2603
|
+
const last = n - 1;
|
|
2604
|
+
this._n = last;
|
|
2605
|
+
if (last > 0) this._siftDownMin(0, this._key[last], this._id[last]);
|
|
2606
|
+
return top;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/**
|
|
2610
|
+
* Remove and return the id at the MAXIMUM key (the larger of the root's up-to-two
|
|
2611
|
+
* children, or the root itself when the heap holds one element), or `undefined` if
|
|
2612
|
+
* empty (never throws on empty). O(log n).
|
|
2613
|
+
* @returns {number|undefined}
|
|
2614
|
+
*/
|
|
2615
|
+
popMax() {
|
|
2616
|
+
const n = this._n;
|
|
2617
|
+
if (n === 0) return undefined;
|
|
2618
|
+
if (n === 1) { this._n = 0; return this._id[0]; }
|
|
2619
|
+
const K = this._key;
|
|
2620
|
+
// Max is at slot 1, unless slot 2 exists (n > 2) and holds a larger key.
|
|
2621
|
+
let mi = 1;
|
|
2622
|
+
if (n > 2 && K[2] > K[1]) mi = 2;
|
|
2623
|
+
const top = this._id[mi];
|
|
2624
|
+
const last = n - 1;
|
|
2625
|
+
this._n = last;
|
|
2626
|
+
// If the max WAS the last element, dropping the tail already removed it.
|
|
2627
|
+
if (mi !== last) this._siftDownMax(mi, K[last], this._id[last]);
|
|
2628
|
+
return top;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
/** The id at the minimum key (root), or `undefined` if empty. Read-only. O(1). */
|
|
2632
|
+
peekMin() { return this._n === 0 ? undefined : this._id[0]; }
|
|
2633
|
+
|
|
2634
|
+
/** The minimum key (root), or `undefined` if empty. O(1). */
|
|
2635
|
+
peekMinKey() { return this._n === 0 ? undefined : this._key[0]; }
|
|
2636
|
+
|
|
2637
|
+
/** The id at the maximum key, or `undefined` if empty. Read-only. O(1). */
|
|
2638
|
+
peekMax() {
|
|
2639
|
+
const n = this._n;
|
|
2640
|
+
if (n === 0) return undefined;
|
|
2641
|
+
if (n === 1) return this._id[0];
|
|
2642
|
+
const K = this._key;
|
|
2643
|
+
return (n > 2 && K[2] > K[1]) ? this._id[2] : this._id[1];
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/** The maximum key, or `undefined` if empty. O(1). */
|
|
2647
|
+
peekMaxKey() {
|
|
2648
|
+
const n = this._n;
|
|
2649
|
+
if (n === 0) return undefined;
|
|
2650
|
+
if (n === 1) return this._key[0];
|
|
2651
|
+
const K = this._key;
|
|
2652
|
+
return (n > 2 && K[2] > K[1]) ? K[2] : K[1];
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/** Empty the heap. O(1) (resets size; the columns are kept). */
|
|
2656
|
+
clear() { this._n = 0; }
|
|
2657
|
+
|
|
2658
|
+
/**
|
|
2659
|
+
* Visit every live (id, key) pair in UNSPECIFIED (heap-array) order -- NOT sorted /
|
|
2660
|
+
* not pop order. O(n) cold scan, allocation-free (pass a hoisted callback).
|
|
2661
|
+
* @param {(id:number, key:number, heap:MinMaxHeap)=>void} fn
|
|
2662
|
+
*/
|
|
2663
|
+
forEach(fn) {
|
|
2664
|
+
const id = this._id, key = this._key, n = this._n;
|
|
2665
|
+
for (let i = 0; i < n; i++) fn(id[i], key[i], this);
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/**
|
|
2669
|
+
* Iterate live entity ids in UNSPECIFIED (heap-array) order -- NOT sorted. The one
|
|
2670
|
+
* documented per-protocol allocator (a {value, done} per step); use forEach for the
|
|
2671
|
+
* alloc-free scan.
|
|
2672
|
+
*/
|
|
2673
|
+
*[Symbol.iterator]() {
|
|
2674
|
+
const id = this._id, n = this._n;
|
|
2675
|
+
for (let i = 0; i < n; i++) yield id[i];
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
/**
|
|
2679
|
+
* Floyd O(n) bulk build: load every (ids[i], keys[i]) pair then heapify bottom-up in
|
|
2680
|
+
* O(n) (deepest-first, level-aware sift-down), rather than n individual O(log n)
|
|
2681
|
+
* pushes. COLD path; fails closed on any violation (non-array-like / length mismatch,
|
|
2682
|
+
* count > capacity, non-integer / out-of-range id, non-finite key) before use.
|
|
2683
|
+
* @param {ArrayLike<number>} ids integers in [0, 2^32) (not required unique)
|
|
2684
|
+
* @param {ArrayLike<number>} keys finite numbers, keys.length === ids.length
|
|
2685
|
+
* @param {number} capacity
|
|
2686
|
+
* @returns {MinMaxHeap}
|
|
2687
|
+
*/
|
|
2688
|
+
static build(ids, keys, capacity) {
|
|
2689
|
+
const heap = new MinMaxHeap(capacity);
|
|
2690
|
+
if (ids == null || keys == null ||
|
|
2691
|
+
typeof ids.length !== 'number' || typeof keys.length !== 'number') {
|
|
2692
|
+
throw new TypeError('[lite-logn] MinMaxHeap.build needs array-like ids and keys');
|
|
2693
|
+
}
|
|
2694
|
+
const count = ids.length;
|
|
2695
|
+
if (keys.length !== count) {
|
|
2696
|
+
throw new RangeError(
|
|
2697
|
+
'[lite-logn] MinMaxHeap.build ids/keys length mismatch (' +
|
|
2698
|
+
count + ' vs ' + keys.length + ')');
|
|
2699
|
+
}
|
|
2700
|
+
if (count > capacity) {
|
|
2701
|
+
throw new RangeError(
|
|
2702
|
+
'[lite-logn] MinMaxHeap.build count ' + count + ' exceeds capacity ' + capacity);
|
|
2703
|
+
}
|
|
2704
|
+
const K = heap._key, I = heap._id;
|
|
2705
|
+
for (let i = 0; i < count; i++) {
|
|
2706
|
+
const id = ids[i];
|
|
2707
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id < 0 || id > 0xFFFFFFFF) {
|
|
2708
|
+
throw new RangeError(
|
|
2709
|
+
'[lite-logn] MinMaxHeap.build id must be an integer in [0, 2^32), got ' +
|
|
2710
|
+
String(id));
|
|
2711
|
+
}
|
|
2712
|
+
const key = keys[i];
|
|
2713
|
+
if (typeof key !== 'number' || !Number.isFinite(key)) {
|
|
2714
|
+
throw new TypeError(
|
|
2715
|
+
'[lite-logn] MinMaxHeap.build key must be a finite number, got ' + String(key));
|
|
2716
|
+
}
|
|
2717
|
+
K[i] = key;
|
|
2718
|
+
I[i] = id;
|
|
2719
|
+
}
|
|
2720
|
+
heap._n = count;
|
|
2721
|
+
// Floyd: sift down every internal node, deepest-first, level-aware. O(n).
|
|
2722
|
+
for (let i = (count >> 1) - 1; i >= 0; i--) {
|
|
2723
|
+
if (((31 - Math.clz32(i + 1)) & 1) === 0) heap._siftDownMin(i, K[i], I[i]);
|
|
2724
|
+
else heap._siftDownMax(i, K[i], I[i]);
|
|
2725
|
+
}
|
|
2726
|
+
return heap;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// ---- private hole-punching sifts (hot bodies) --------------------------
|
|
2730
|
+
|
|
2731
|
+
/**
|
|
2732
|
+
* Sift UP from `hole` after an append: compare to the PARENT to decide whether the new
|
|
2733
|
+
* element belongs on its own level's chain or the other level's, then bubble it up by
|
|
2734
|
+
* GRANDPARENT comparisons. One write per level; zero temporaries.
|
|
2735
|
+
* @private
|
|
2736
|
+
*/
|
|
2737
|
+
_siftUp(hole, key, id) {
|
|
2738
|
+
const K = this._key, I = this._id;
|
|
2739
|
+
if (hole === 0) { K[0] = key; I[0] = id; return; }
|
|
2740
|
+
const parent = (hole - 1) >> 1;
|
|
2741
|
+
// Depth parity of `hole`: MIN level iff (31 - clz32(hole+1)) is even.
|
|
2742
|
+
if (((31 - Math.clz32(hole + 1)) & 1) === 0) { // hole is on a MIN level
|
|
2743
|
+
if (key > K[parent]) { // parent is a MAX node: element belongs above it
|
|
2744
|
+
K[hole] = K[parent]; I[hole] = I[parent];
|
|
2745
|
+
this._bubbleUpMax(parent, key, id);
|
|
2746
|
+
} else {
|
|
2747
|
+
this._bubbleUpMin(hole, key, id);
|
|
2748
|
+
}
|
|
2749
|
+
} else { // hole is on a MAX level
|
|
2750
|
+
if (key < K[parent]) { // parent is a MIN node: element belongs above it
|
|
2751
|
+
K[hole] = K[parent]; I[hole] = I[parent];
|
|
2752
|
+
this._bubbleUpMin(parent, key, id);
|
|
2753
|
+
} else {
|
|
2754
|
+
this._bubbleUpMax(hole, key, id);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
/** Bubble a MIN-level hole up by grandparents while the element is smaller. @private */
|
|
2760
|
+
_bubbleUpMin(hole, key, id) {
|
|
2761
|
+
const K = this._key, I = this._id;
|
|
2762
|
+
while (hole > 2) { // has a grandparent (hole >= 3)
|
|
2763
|
+
const gp = (hole - 3) >> 2; // (((hole-1)>>1)-1)>>1
|
|
2764
|
+
if (key < K[gp]) { K[hole] = K[gp]; I[hole] = I[gp]; hole = gp; }
|
|
2765
|
+
else break;
|
|
2766
|
+
}
|
|
2767
|
+
K[hole] = key; I[hole] = id;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** Bubble a MAX-level hole up by grandparents while the element is larger. @private */
|
|
2771
|
+
_bubbleUpMax(hole, key, id) {
|
|
2772
|
+
const K = this._key, I = this._id;
|
|
2773
|
+
while (hole > 2) { // has a grandparent (hole >= 3)
|
|
2774
|
+
const gp = (hole - 3) >> 2; // (((hole-1)>>1)-1)>>1
|
|
2775
|
+
if (key > K[gp]) { K[hole] = K[gp]; I[hole] = I[gp]; hole = gp; }
|
|
2776
|
+
else break;
|
|
2777
|
+
}
|
|
2778
|
+
K[hole] = key; I[hole] = id;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
/**
|
|
2782
|
+
* Trickle a MIN-level hole DOWN toward the SMALLEST of its up-to-six descendants
|
|
2783
|
+
* (children `2h+1`/`2h+2`, grandchildren `4h+3..4h+6`). A grandchild move does the
|
|
2784
|
+
* extra max-parent re-check that keeps the alternating order. Every grandchild index
|
|
2785
|
+
* is bound-checked against the live size. One write per level; zero temporaries.
|
|
2786
|
+
* @private
|
|
2787
|
+
*/
|
|
2788
|
+
_siftDownMin(hole, key, id) {
|
|
2789
|
+
const K = this._key, I = this._id, n = this._n;
|
|
2790
|
+
for (;;) {
|
|
2791
|
+
const c1 = (hole << 1) + 1;
|
|
2792
|
+
if (c1 >= n) break; // leaf: no children -> settle here
|
|
2793
|
+
const c2 = c1 + 1;
|
|
2794
|
+
let m = c1, mGrand = false; // smallest descendant so far
|
|
2795
|
+
if (c2 < n && K[c2] < K[m]) m = c2;
|
|
2796
|
+
const gEnd = (c2 << 1) + 2; // 4h+6, the last grandchild index
|
|
2797
|
+
for (let g = (c1 << 1) + 1; g < n && g <= gEnd; g++) { // grandchildren 4h+3..4h+6
|
|
2798
|
+
if (K[g] < K[m]) { m = g; mGrand = true; }
|
|
2799
|
+
}
|
|
2800
|
+
if (K[m] >= key) break; // element is <= every descendant -> settle
|
|
2801
|
+
if (!mGrand) { // smallest is a direct child (a leaf) -> place, done
|
|
2802
|
+
K[hole] = K[m]; I[hole] = I[m];
|
|
2803
|
+
hole = m;
|
|
2804
|
+
break;
|
|
2805
|
+
}
|
|
2806
|
+
// smallest is a grandchild: pull it up, then reconcile with its MAX-level parent.
|
|
2807
|
+
K[hole] = K[m]; I[hole] = I[m];
|
|
2808
|
+
const p = (m - 1) >> 1;
|
|
2809
|
+
if (key > K[p]) { // element too big under max parent p: settle it at p,
|
|
2810
|
+
const pk = K[p], pid = I[p]; // carry p's (smaller) value on down from m.
|
|
2811
|
+
K[p] = key; I[p] = id;
|
|
2812
|
+
key = pk; id = pid;
|
|
2813
|
+
}
|
|
2814
|
+
hole = m;
|
|
2815
|
+
}
|
|
2816
|
+
K[hole] = key; I[hole] = id;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
/**
|
|
2820
|
+
* Trickle a MAX-level hole DOWN toward the LARGEST of its up-to-six descendants. Mirror
|
|
2821
|
+
* of `_siftDownMin`: a grandchild move re-checks the MIN-level parent. Every grandchild
|
|
2822
|
+
* index is bound-checked. One write per level; zero temporaries.
|
|
2823
|
+
* @private
|
|
2824
|
+
*/
|
|
2825
|
+
_siftDownMax(hole, key, id) {
|
|
2826
|
+
const K = this._key, I = this._id, n = this._n;
|
|
2827
|
+
for (;;) {
|
|
2828
|
+
const c1 = (hole << 1) + 1;
|
|
2829
|
+
if (c1 >= n) break; // leaf: no children -> settle here
|
|
2830
|
+
const c2 = c1 + 1;
|
|
2831
|
+
let m = c1, mGrand = false; // largest descendant so far
|
|
2832
|
+
if (c2 < n && K[c2] > K[m]) m = c2;
|
|
2833
|
+
const gEnd = (c2 << 1) + 2; // 4h+6, the last grandchild index
|
|
2834
|
+
for (let g = (c1 << 1) + 1; g < n && g <= gEnd; g++) { // grandchildren 4h+3..4h+6
|
|
2835
|
+
if (K[g] > K[m]) { m = g; mGrand = true; }
|
|
2836
|
+
}
|
|
2837
|
+
if (K[m] <= key) break; // element is >= every descendant -> settle
|
|
2838
|
+
if (!mGrand) { // largest is a direct child (a leaf) -> place, done
|
|
2839
|
+
K[hole] = K[m]; I[hole] = I[m];
|
|
2840
|
+
hole = m;
|
|
2841
|
+
break;
|
|
2842
|
+
}
|
|
2843
|
+
// largest is a grandchild: pull it up, then reconcile with its MIN-level parent.
|
|
2844
|
+
K[hole] = K[m]; I[hole] = I[m];
|
|
2845
|
+
const p = (m - 1) >> 1;
|
|
2846
|
+
if (key < K[p]) { // element too small under min parent p: settle it at p,
|
|
2847
|
+
const pk = K[p], pid = I[p]; // carry p's (larger) value on down from m.
|
|
2848
|
+
K[p] = key; I[p] = id;
|
|
2849
|
+
key = pk; id = pid;
|
|
2850
|
+
}
|
|
2851
|
+
hole = m;
|
|
2852
|
+
}
|
|
2853
|
+
K[hole] = key; I[hole] = id;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// ---- cold path only: throw builders (string concat off the hot body) ---
|
|
2857
|
+
|
|
2858
|
+
/** @private */
|
|
2859
|
+
_badId(id) {
|
|
2860
|
+
throw new RangeError(
|
|
2861
|
+
'[lite-logn] MinMaxHeap id must be an integer in [0, 2^32), got ' + String(id));
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
/** @private */
|
|
2865
|
+
_badKey(key) {
|
|
2866
|
+
throw new TypeError(
|
|
2867
|
+
'[lite-logn] MinMaxHeap key must be a finite number, got ' + String(key));
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
/** @private */
|
|
2871
|
+
_full() {
|
|
2872
|
+
throw new RangeError('[lite-logn] MinMaxHeap full (capacity ' + this._cap + ')');
|
|
2873
|
+
}
|
|
2874
|
+
}
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-logn
|
|
2
2
|
|
|
3
|
-
> Zero-GC, O(log n) data structures that PROVE their logarithm. The O(log n) sibling of `@zakkster/lite-o1`: where lite-o1 holds the constant (a flat ops/ms line), lite-logn holds the logarithm (a straight line on a log-x axis -- one added level per doubling of n). v0.
|
|
3
|
+
> Zero-GC, O(log n) data structures that PROVE their logarithm. The O(log n) sibling of `@zakkster/lite-o1`: where lite-o1 holds the constant (a flat ops/ms line), lite-logn holds the logarithm (a straight line on a log-x axis -- one added level per doubling of n). v0.7.0 ships seven members: BinaryHeap (array-embedded O(log n) push / pop min|max heap), Fenwick / BIT (O(log n) point-update AND prefix-sum via the `i & -i` walk), SegmentTree (O(log n) associative range-query -- min / max / sum / gcd -- plus point-update over a flat 2n array), SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool), Treap (a randomized-balanced augmented ordered map with O(log n) rank / select / split / merge), Scapegoat (a DETERMINISTIC weight-balanced augmented ordered map: worst-case-O(log n) get, amortized-O(log n) set / delete, zero-GC rebuild), and MinMaxHeap (an array-embedded double-ended priority queue: O(1) peekMin / peekMax, O(log n) push / popMin / popMax) -- each zero-GC, each shipped with a log-linear Witness that fits `nsPerOp = intercept + slope*log2(n)` and shows the straight log line while an O(n) foil leaves it.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@zakkster/lite-logn)
|
|
6
6
|
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
@@ -19,7 +19,7 @@ Almost no JavaScript data-structure library ships the evidence that its Big-O cl
|
|
|
19
19
|
|
|
20
20
|
lite-logn is the O(log n) sibling of [`@zakkster/lite-o1`](https://www.npmjs.com/package/@zakkster/lite-o1). lite-o1 proves a FLAT ops/ms line on a log-x axis (the constant -- slope ~ 0); lite-logn proves a STRAIGHT line on that same axis (one added level per doubling of `n` -- slope > 0, within a per-member band). The gate SHAPE differs; the discipline is identical: zero allocation on every hot path and a witness that turns "trust me, it is O(log n)" into a straight line you can see, with a foil that leaves it.
|
|
21
21
|
|
|
22
|
-
**v0.
|
|
22
|
+
**v0.7.0 ships seven members: BinaryHeap, Fenwick, SegmentTree, SkipList, Treap, Scapegoat and MinMaxHeap.** Members land one per session, each append-only so prior members stay byte-identical. The planned roster below fills in per release.
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
npm install @zakkster/lite-logn
|
|
@@ -62,6 +62,7 @@ Every hot op allocates zero bytes after construction, and `npm run witness` prov
|
|
|
62
62
|
- [SkipList](#skiplist)
|
|
63
63
|
- [Treap](#treap)
|
|
64
64
|
- [Scapegoat](#scapegoat)
|
|
65
|
+
- [MinMaxHeap](#minmaxheap)
|
|
65
66
|
- [Zero-GC design notes](#zero-gc-design-notes)
|
|
66
67
|
- [Testing](#testing)
|
|
67
68
|
- [What this is not](#what-this-is-not)
|
|
@@ -86,7 +87,7 @@ lite-logn ships the O(log n) structures that matter with the allocation removed
|
|
|
86
87
|
|
|
87
88
|
## The roster
|
|
88
89
|
|
|
89
|
-
One member per session, each landing append-only (prior members stay byte-identical). At v0.
|
|
90
|
+
One member per session, each landing append-only (prior members stay byte-identical). At v0.7.0, BinaryHeap, Fenwick, SegmentTree, SkipList, Treap, Scapegoat and MinMaxHeap are shipped.
|
|
90
91
|
|
|
91
92
|
| Member | Version | Status | Shape | Hot ops |
|
|
92
93
|
| --- | --- | --- | --- | --- |
|
|
@@ -96,6 +97,7 @@ One member per session, each landing append-only (prior members stay byte-identi
|
|
|
96
97
|
| **SkipList** | 0.4.0 | shipped | pointer-free over a private free-list node pool; expected O(log n) | `get` / `set` / `delete` / `successor` / `predecessor` |
|
|
97
98
|
| **Treap** | 0.5.0 | shipped | randomized-balanced augmented BST over the same node pool; expected O(log n) | `get` / `has` / `set` / `delete` / `rank` / `select` / `successor` / `predecessor` / `forEach` / `rangeIter` / `split` + `merge` |
|
|
98
99
|
| **Scapegoat** | 0.6.0 | shipped | DETERMINISTIC weight-balanced augmented BST over the same node pool; worst-case O(log n) get, amortized O(log n) set/delete (zero-GC rebuild) | `get` / `has` / `set` / `delete` / `rank` / `select` / `successor` / `predecessor` / `forEach` / `rangeIter` (NO split/merge) |
|
|
100
|
+
| **MinMaxHeap** | 0.7.0 | shipped | array-embedded double-ended PQ (DEPQ): one binary heap whose levels alternate min/max, two flat columns (`_key`/`_id`) | `push` / `popMin` / `popMax` O(log n), `peekMin` / `peekMax` / `peekMinKey` / `peekMaxKey` O(1) (non-addressable: NO changeKey/remove) |
|
|
99
101
|
|
|
100
102
|
Later tiers (OrderStatTree, IndexedHeap, SortedArray, MinMaxHeap, SplayTree, and presets) are queued in [`ROADMAP.md`](./ROADMAP.md).
|
|
101
103
|
|
|
@@ -107,7 +109,7 @@ The family anchor. Time a fixed batch of the hot op at each `n` in a geometric s
|
|
|
107
109
|
- `slope` inside the member's band (the per-level cost, ns/level), AND
|
|
108
110
|
- the FOIL leaves the line (low `R^2` -- the O(n) default a working programmer reaches for, shown losing as `n` grows).
|
|
109
111
|
|
|
110
|
-
For amortized / randomized members the witness also prints the MAX single-op time -- the honesty hook: a rebuild spike or a degenerate tail shows as a tall bar even when the mean still fits the line. The `R^2` floor (0.958) is frozen family-wide in BinaryHeap; each member then calibrates its OWN per-op slope band (median-of-15 fit-runs x `[0.6, 1.4]`), because a cheaper op honestly has a lower per-level slope (see [`decisions/0004-witness-band.md`](./decisions/0004-witness-band.md)). At v0.
|
|
112
|
+
For amortized / randomized members the witness also prints the MAX single-op time -- the honesty hook: a rebuild spike or a degenerate tail shows as a tall bar even when the mean still fits the line. The `R^2` floor (0.958) is frozen family-wide in BinaryHeap; each member then calibrates its OWN per-op slope band (median-of-15 fit-runs x `[0.6, 1.4]`), because a cheaper op honestly has a lower per-level slope (see [`decisions/0004-witness-band.md`](./decisions/0004-witness-band.md)). At v0.7.0 the witness gates ten ops: BinaryHeap `pop` (R^2 ~ 0.99, slope ~ 8-10 ns/level), Fenwick `update` (R^2 ~ 0.98-0.99, slope ~ 2.9-3.0 ns/level) and `prefix` (R^2 ~ 0.97, slope ~ 2.6-2.7 ns/level), SegmentTree `update` (R^2 ~ 0.99, slope ~ 3.2 ns/level, band `[2.29, 5.35]`) and `query` (R^2 ~ 0.99, slope ~ 7 ns/level, band `[4.30, 10.04]`), SkipList `get` (R^2 ~ 0.97-0.99, slope ~ 9 ns/level, band `[5.27, 12.30]`) and `set` (R^2 ~ 0.97-0.99, slope ~ 14 ns/level, band `[8.36, 19.50]`), Treap `get` (R^2 ~ 0.99, slope ~ 4 ns/level, band `[2.55, 5.95]`), Scapegoat `get` (R^2 ~ 0.99, slope ~ 4 ns/level, band `[2.41, 5.63]`), and MinMaxHeap `popMin` (R^2 ~ 0.99, slope ~ 10.3 ns/level, band `[6.18, 14.42]`) all ON the line. Scapegoat is DETERMINISTIC, so its `get` is WORST-case (not expected) O(log n); its rebuild spike lives on the AMORTIZED `set` path and is proven not by a per-op line but by an amortized-trace assertion -- the cumulative ascending-insert (rebuild-heavy) cost/op tracks a LOG curve (last/first ratio ~1.5x over `[2^11, 2^17]`, gated `< 4x`) where a rebuild-less BST would degenerate to an O(n)-amortized chain and blow the ratio to ~64x. Treap's descent touches one node per level, so its per-level slope is lower than SkipList's tower search -- expected, which is why only the R^2 floor is shared and each op calibrates its own band. SkipList's two ops are gated over DIFFERENT sweeps -- each measured where its logarithm is visible, not where the cache wall is: `get` (a clean search with no per-op randomness) over `[2^11, 2^17]` for dynamic range; `set` (a heavier insert+delete churn whose per-insert tower height is random) over the smaller, fully cache-resident `[2^9, 2^14]` so the fit sees the structural level count, not DRAM latency. Because SkipList is EXPECTED (not worst-case) O(log n), the witness also prints the MAX single insert over a realistic randomized build trace -- the unlucky-tower tail a mean hides. Each op's O(n) foil fits well below the floor: the sorted-array insert (BinaryHeap / SkipList) foil runs R^2 ~ 0.77-0.87, the Fenwick foils (prefix-array rebuild, naive re-sum) and SkipList's linear-scan search foil hold at R^2 ~ 0.75-0.82, and SegmentTree's foils (whole-tree rebuild per update, scan-fold per query) fit at R^2 ~ 0.72-0.85 -- all foil families sit comfortably under the 0.958 floor.
|
|
111
113
|
|
|
112
114
|
## Benchmarks
|
|
113
115
|
|
|
@@ -148,6 +150,7 @@ Each op fits `nsPerOp = intercept + slope*log2(n)`. ON-LINE = `R^2 >= 0.958` (th
|
|
|
148
150
|
| `SkipList.set` | 0.985 | 12.1 | `[8.36, 19.50]` | ON | sorted-array insert | 0.77 | off |
|
|
149
151
|
| `Treap.get` | 0.988 | 4.0 | `[2.55, 5.95]` | ON | linear scan | 0.79 | off |
|
|
150
152
|
| `Scapegoat.get` | 0.988 | 3.8 | `[2.41, 5.63]` | ON | linear scan | 0.79 | off |
|
|
153
|
+
| `MinMaxHeap.popMin` | 0.99 | 10.3 | `[6.18, 14.42]` | ON | linear min-scan-and-splice | 0.77 | off |
|
|
151
154
|
|
|
152
155
|
**Scapegoat amortized-trace (the rebuild honesty).** Scapegoat's `get` is WORST-case O(log n) (a deterministic weight-balance height bound), so it carries no expected-op MAX-single-op disclosure. The rebuild spike lives on the AMORTIZED `set` path; D1 proves the amortization not with a per-op line but with an amortized-trace assertion -- the cumulative ascending-insert (rebuild-heavy) cost/op tracks a LOG curve (last/first ratio `~1.5x` over `[2^11, 2^17]`, gated `< 4x`) where a rebuild-less BST would blow to `~64x`.
|
|
153
156
|
|
|
@@ -183,8 +186,8 @@ All seven gated op-rows report **0 B/op** across the `n = 1e3..1e6` sweep, with
|
|
|
183
186
|
|
|
184
187
|
- **D2 amortized cost** -- cumulative ns/op stays bounded over a `~1M`-op mixed trace (drift `< 1.0` here: the trace speeds up as the JIT warms, never degrades).
|
|
185
188
|
- **D4 cache (PROXY, labelled)** -- dense `forEach` iteration vs random single-element lookup; the random/dense gap is `~1.9x` (SegmentTree) to `~2.9x` (SkipList). No native perf counters.
|
|
186
|
-
- **D7 scalability** -- numeric substrates: string + object keys read `n/a`. Load factors `0.3/0.5/0.7/0.9`; insertion order (sorted / random / adversarial-reverse) applies to the comparison-ordered BinaryHeap + SkipList + Treap + Scapegoat, `n/a` for the index-addressed Fenwick + SegmentTree.
|
|
187
|
-
- **D8 workloads** -- churn (all members) + an ordered scan (`successor` + `rangeIter`, SkipList + Treap + Scapegoat; `n/a` elsewhere).
|
|
189
|
+
- **D7 scalability** -- numeric substrates: string + object keys read `n/a`. Load factors `0.3/0.5/0.7/0.9`; insertion order (sorted / random / adversarial-reverse) applies to the comparison-ordered BinaryHeap + SkipList + Treap + Scapegoat + MinMaxHeap, `n/a` for the index-addressed Fenwick + SegmentTree.
|
|
190
|
+
- **D8 workloads** -- churn (all members) + an ordered scan (`successor` + `rangeIter`, SkipList + Treap + Scapegoat; `n/a` elsewhere, including MinMaxHeap -- a DEPQ, not an ordered map).
|
|
188
191
|
|
|
189
192
|
## API reference
|
|
190
193
|
|
|
@@ -415,6 +418,41 @@ sg.successor(20); // -> 50 (smallest key strictly greater)
|
|
|
415
418
|
|
|
416
419
|
Member signatures for later members are appended here as each ships.
|
|
417
420
|
|
|
421
|
+
### MinMaxHeap
|
|
422
|
+
|
|
423
|
+
A **min-max heap**: a **double-ended priority queue (DEPQ)** held in ONE array-embedded binary heap whose levels **alternate min / max** (Atkinson, Sack, Santoro & Strothotte 1986). Even depth (the root is depth 0) is a **MIN** level, odd depth a **MAX** level, so the global minimum is the root and the global maximum is the **larger of the root's up-to-two children**. That single alternating heap answers BOTH ends: `peekMin` / `peekMax` / `peekMinKey` / `peekMaxKey` are **O(1)**; `push` / `popMin` / `popMax` are all **worst-case O(log n)** -- no second heap, no paired-heap correspondence to maintain. It uses the BinaryHeap **id + key** idiom (two parallel pointer-free columns: `_id` `Uint32Array`, `_key` `Float64Array`), so it carries an opaque payload per entry with no object nodes. Keys are finite numbers (typeof-guarded before coercion; Symbol / BigInt / NaN / +-Infinity fail closed -- the key is checked FIRST, then the id, then a full heap). Every hot op allocates zero bytes after construction.
|
|
424
|
+
|
|
425
|
+
The asymmetry vs BinaryHeap: MinMaxHeap is **non-addressable**. There is no `_pos` reverse map and therefore deliberately **no `changeKey` / `remove`**; the id is an OPAQUE `Uint32` payload (NOT unique -- duplicates allowed -- over the full `[0, 2^32)` domain, wider than BinaryHeap's `[0, capacity)`). A DEPQ's job is the two extremes; addressability is the separable concern BinaryHeap already carries. There is also **no `kind` argument / getter** (a DEPQ has both ends; a kind getter would be a lie). This is the classic **one-element-per-node** min-max heap; the interval-heap DEPQ (two elements per node) is a deliberately deferred alternative (see [`decisions/0009-minmaxheap.md`](./decisions/0009-minmaxheap.md)). Level parity is computed zero-alloc as `((31 - Math.clz32(i + 1)) & 1) === 0` (min iff even depth); the sifts are hole-punching (one write per level) and every grandchild index is bound-checked against the live size (the classic min-max off-by-one, verified at n = 1, 2, 3, 4).
|
|
426
|
+
|
|
427
|
+
```js
|
|
428
|
+
import { MinMaxHeap } from '@zakkster/lite-logn';
|
|
429
|
+
|
|
430
|
+
const h = new MinMaxHeap(1000); // capacity 1000 (no kind: a DEPQ serves both ends)
|
|
431
|
+
h.push(1, 5.0); // push id 1 with key 5.0
|
|
432
|
+
h.push(2, 1.0);
|
|
433
|
+
h.push(3, 9.0);
|
|
434
|
+
h.peekMinKey(); // -> 1.0 -- O(1)
|
|
435
|
+
h.peekMaxKey(); // -> 9.0 -- O(1)
|
|
436
|
+
h.popMin(); // -> 2 (the id at the minimum key) -- worst-case O(log n)
|
|
437
|
+
h.popMax(); // -> 3 (the id at the maximum key) -- worst-case O(log n)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
| Member | Signature | Complexity | Notes |
|
|
441
|
+
| --- | --- | --- | --- |
|
|
442
|
+
| constructor | `new MinMaxHeap(capacity)` | O(capacity) | `capacity` integer in `[1, 2^31-1]`. Allocates two typed arrays (`_key` `Float64`, `_id` `Uint32`) once. NO `kind` argument. |
|
|
443
|
+
| `push` | `push(id, key) -> void` | worst-case O(log n) | `id` integer in `[0, 2^32)` (opaque, not required unique); `key` finite. Key checked FIRST, then id, then a full heap -- each throws `[lite-logn]` as a no-op (size unchanged). |
|
|
444
|
+
| `popMin` | `popMin() -> number \| undefined` | worst-case O(log n) | Removes and returns the id at the minimum key; `undefined` if empty (no throw). |
|
|
445
|
+
| `popMax` | `popMax() -> number \| undefined` | worst-case O(log n) | Removes and returns the id at the maximum key; `undefined` if empty (no throw). |
|
|
446
|
+
| `peekMin` / `peekMax` | `-> number \| undefined` | O(1) | The id at the minimum / maximum key; `undefined` if empty (never throw). |
|
|
447
|
+
| `peekMinKey` / `peekMaxKey` | `-> number \| undefined` | O(1) | The minimum / maximum key; `undefined` if empty (never throw). |
|
|
448
|
+
| `clear` | `clear() -> void` | O(1) | Empties the heap, keeps capacity. |
|
|
449
|
+
| `forEach` | `forEach(fn) -> void` | O(n) | Visits `(id, key, heap)` in UNSPECIFIED (heap-array) order -- NOT sorted / pop order. |
|
|
450
|
+
| `[Symbol.iterator]` | `-> IterableIterator<number>` | O(n) | Yields live ids in UNSPECIFIED (heap-array) order. |
|
|
451
|
+
| `size` / `capacity` | getters | O(1) | Live entry count / fixed capacity. |
|
|
452
|
+
| `MinMaxHeap.build` | `build(ids, keys, capacity) -> MinMaxHeap` | O(n) | Floyd bulk build from parallel arrays (deepest-first, level-aware sift-down); fails closed on non-array-like / length mismatch, count > capacity, out-of-range id, or non-finite key. |
|
|
453
|
+
|
|
454
|
+
Member signatures for later members are appended here as each ships.
|
|
455
|
+
|
|
418
456
|
## Zero-GC design notes
|
|
419
457
|
|
|
420
458
|
- **Array-embedded members allocate no nodes.** BinaryHeap, Fenwick, and SegmentTree live in flat typed arrays; there is no `new Node` per op, so there is nothing to collect. The parent / child / sibling relationships are index arithmetic (`2i+1`, `i & -i`), not pointers.
|
|
@@ -444,8 +482,11 @@ Member signatures for later members are appended here as each ships.
|
|
|
444
482
|
| `Scapegoat` constructor / `clear` | O(capacity) five columns + pool + two rebuild scratch buffers, once (cold) |
|
|
445
483
|
| `Scapegoat` forEach | 0 B/op in the loop body (recursive in-order walk, hoisted callback) |
|
|
446
484
|
| `Scapegoat` rangeIter | one iterator + `{value, done}` per step (the documented per-protocol allocator; transient, not retained) |
|
|
485
|
+
| `MinMaxHeap` push / popMin / popMax / peekMin / peekMax / peekMinKey / peekMaxKey | 0 B/op (two flat columns; the hole-punching sifts use only local scalar temporaries) |
|
|
486
|
+
| `MinMaxHeap` constructor / `build` / `clear` | O(capacity) two typed arrays, once (cold) |
|
|
487
|
+
| `MinMaxHeap` forEach | 0 B/op in the loop body (pass a hoisted callback) |
|
|
447
488
|
|
|
448
|
-
Gated witness numbers (this machine, shared R^2 floor 0.958): BinaryHeap `pop` R^2 ~ 0.99, slope ~ 8-10 ns/level; Fenwick `update` R^2 ~ 0.98-0.99, slope ~ 2.9-3.0 ns/level (band `[1.84, 4.30]`); Fenwick `prefix` R^2 ~ 0.97, slope ~ 2.6-2.7 ns/level (band `[1.76, 4.10]`); SegmentTree `update` R^2 ~ 0.99, slope ~ 3.2 ns/level (band `[2.29, 5.35]`); SegmentTree `query` R^2 ~ 0.99, slope ~ 7 ns/level (band `[4.30, 10.04]`); SkipList `get` R^2 ~ 0.97-0.99, slope ~ 9 ns/level (band `[5.27, 12.30]`, sweep `[2^11, 2^17]`); SkipList `set` R^2 ~ 0.97-0.99, slope ~ 14 ns/level (band `[8.36, 19.50]`, cache-resident sweep `[2^9, 2^14]`); Treap `get` R^2 ~ 0.99, slope ~ 4 ns/level (band `[2.55, 5.95]`, sweep `[2^11, 2^17]`); Scapegoat `get` R^2 ~ 0.99, slope ~ 3.8-4.0 ns/level (band `[2.41, 5.63]`, sweep `[2^11, 2^17]`) -- WORST-case (deterministic), with the AMORTIZED `set` rebuild spike proven by the amortized-trace assertion (ratio `< 4x`), not a per-op line. The allocation table is extended per member as each lands.
|
|
489
|
+
Gated witness numbers (this machine, shared R^2 floor 0.958): BinaryHeap `pop` R^2 ~ 0.99, slope ~ 8-10 ns/level; Fenwick `update` R^2 ~ 0.98-0.99, slope ~ 2.9-3.0 ns/level (band `[1.84, 4.30]`); Fenwick `prefix` R^2 ~ 0.97, slope ~ 2.6-2.7 ns/level (band `[1.76, 4.10]`); SegmentTree `update` R^2 ~ 0.99, slope ~ 3.2 ns/level (band `[2.29, 5.35]`); SegmentTree `query` R^2 ~ 0.99, slope ~ 7 ns/level (band `[4.30, 10.04]`); SkipList `get` R^2 ~ 0.97-0.99, slope ~ 9 ns/level (band `[5.27, 12.30]`, sweep `[2^11, 2^17]`); SkipList `set` R^2 ~ 0.97-0.99, slope ~ 14 ns/level (band `[8.36, 19.50]`, cache-resident sweep `[2^9, 2^14]`); Treap `get` R^2 ~ 0.99, slope ~ 4 ns/level (band `[2.55, 5.95]`, sweep `[2^11, 2^17]`); Scapegoat `get` R^2 ~ 0.99, slope ~ 3.8-4.0 ns/level (band `[2.41, 5.63]`, sweep `[2^11, 2^17]`) -- WORST-case (deterministic), with the AMORTIZED `set` rebuild spike proven by the amortized-trace assertion (ratio `< 4x`), not a per-op line; MinMaxHeap `popMin` R^2 ~ 0.99, slope ~ 10.3 ns/level (band `[6.18, 14.42]`, sweep `[1e4, 1e6]`) -- WORST-case (a DEPQ whose push / popMin / popMax are all worst-case, so no MAX-single-op line), a touch ABOVE BinaryHeap.pop because a min-max trickle-down compares against up to six descendants per level. The allocation table is extended per member as each lands.
|
|
449
490
|
|
|
450
491
|
## Testing
|
|
451
492
|
|
|
@@ -463,6 +504,7 @@ Gated witness numbers (this machine, shared R^2 floor 0.958): BinaryHeap `pop` R
|
|
|
463
504
|
- **Not a bounded-integer priority queue.** If your priorities are small bounded integers, a heap's O(log n) is the wrong tool -- use `@zakkster/lite-o1`'s `BucketQueue` (Dial, O(1)) or `@zakkster/lite-scheduler`'s `FastBitScheduler`. lite-logn's heap is the GENERAL comparator PQ at O(log n).
|
|
464
505
|
- **Not an approximate-membership library.** Bloom / cuckoo / binary-fuse filters live in `@zakkster/lite-filter`. lite-logn owns exact ordered structures.
|
|
465
506
|
- **Not a cache.** `@zakkster/lite-lru` uses ordering internally for eviction but is a cache, not an ordered-collection library.
|
|
507
|
+
- **Not an addressable double-ended queue.** MinMaxHeap is a DEPQ, but it is NON-addressable: its id is an opaque, non-unique payload (no reverse map), so it has no `changeKey` / `remove`. For an addressable single-ended priority queue (reprioritize / remove by entity id) use **BinaryHeap**. MinMaxHeap also ships the classic one-element-per-node min-max heap only -- the interval-heap DEPQ (two elements per node) is a deliberately deferred alternative (see [`decisions/0009-minmaxheap.md`](./decisions/0009-minmaxheap.md)).
|
|
466
508
|
- **Not a grow-on-demand collection.** Capacity is fixed at construction and overflow fails closed.
|
|
467
509
|
|
|
468
510
|
## Ecosystem
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-logn
|
|
2
2
|
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
License: MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
5
5
|
Runtime dependencies: none. ESM only. ASCII-only source. sideEffects: false.
|
|
6
6
|
Node: >= 18.
|
|
@@ -19,8 +19,8 @@ FLAT ops/ms line on a log-x axis), lite-logn holds the logarithm (a STRAIGHT
|
|
|
19
19
|
line on that same axis, one added level per doubling of n).
|
|
20
20
|
|
|
21
21
|
v0.1.0 shipped BinaryHeap; v0.2.0 adds Fenwick; v0.3.0 adds SegmentTree; v0.4.0
|
|
22
|
-
adds SkipList; v0.5.0 adds Treap; v0.6.0 adds Scapegoat.
|
|
23
|
-
session, each landing append-only):
|
|
22
|
+
adds SkipList; v0.5.0 adds Treap; v0.6.0 adds Scapegoat; v0.7.0 adds MinMaxHeap. The
|
|
23
|
+
roster (one member per session, each landing append-only):
|
|
24
24
|
|
|
25
25
|
- BinaryHeap (v0.1.0) -- an INDEXED binary heap (addressable priority queue): a
|
|
26
26
|
min|max binary heap over three parallel typed arrays (`_key` Float64Array,
|
|
@@ -75,6 +75,19 @@ session, each landing append-only):
|
|
|
75
75
|
be O(n) rebuilds) -- the documented asymmetry vs Treap. set updates an existing key's
|
|
76
76
|
value in place. The amortized-trace witness shows cumulative insert cost/op tracks log n
|
|
77
77
|
despite the rebuild spikes. Zero allocation on every hot op.
|
|
78
|
+
- MinMaxHeap (v0.7.0) -- a DOUBLE-ENDED priority queue (DEPQ) held in ONE array-embedded
|
|
79
|
+
binary heap whose levels ALTERNATE min / max (Atkinson et al. 1986). Even depth is a MIN
|
|
80
|
+
level, odd depth a MAX level, so the minimum is the root and the maximum is the LARGER of
|
|
81
|
+
the root's up-to-two children: peekMin / peekMax are O(1); push / popMin / popMax are all
|
|
82
|
+
WORST-case O(log n) from that single heap (no second heap, no paired-heap correspondence).
|
|
83
|
+
Two parallel typed-array columns (`_key` Float64, `_id` Uint32) in the BinaryHeap id+key
|
|
84
|
+
idiom; the id is an OPAQUE Uint32 payload in [0, 2^32) (NOT unique, no reverse map), so
|
|
85
|
+
there is deliberately NO changeKey / remove -- the documented asymmetry vs BinaryHeap.
|
|
86
|
+
Level parity is `((31 - Math.clz32(i + 1)) & 1) === 0` (min iff even depth), computed
|
|
87
|
+
zero-alloc; the sifts are hole-punching (one write per level). Keys are finite numbers
|
|
88
|
+
(typeof-guarded before coercion; Symbol / BigInt / NaN / +-Infinity fail closed). This is
|
|
89
|
+
the classic one-element-per-node min-max heap only; the interval-heap DEPQ is deferred.
|
|
90
|
+
Zero allocation on every hot op.
|
|
78
91
|
|
|
79
92
|
## Exports (from the single main file LogN.js)
|
|
80
93
|
|
|
@@ -238,6 +251,26 @@ session, each landing append-only):
|
|
|
238
251
|
- There is deliberately NO `split` / `merge` (the documented asymmetry vs Treap: no
|
|
239
252
|
priority heap to merge by; an honest deterministic split/merge would be O(n) rebuilds).
|
|
240
253
|
|
|
254
|
+
- `MinMaxHeap` -- class. A DOUBLE-ENDED priority queue (DEPQ) in ONE array-embedded binary
|
|
255
|
+
heap whose levels ALTERNATE min / max (Atkinson et al. 1986). The minimum is the root; the
|
|
256
|
+
maximum is the larger of the root's up-to-two children. Two parallel typed-array columns
|
|
257
|
+
(`_key` Float64, `_id` Uint32); the id is an OPAQUE Uint32 payload in [0, 2^32) (not unique,
|
|
258
|
+
no reverse map). Keys are finite numbers (typeof-guarded before coercion; Symbol / BigInt /
|
|
259
|
+
NaN / +-Infinity fail closed). Non-addressable: deliberately NO changeKey / remove.
|
|
260
|
+
- `new MinMaxHeap(capacity)` -- capacity an integer in [1, 2^31-1]. Allocates the two typed
|
|
261
|
+
arrays once. NO `kind` argument (a DEPQ serves both ends).
|
|
262
|
+
- `push(id, key)` -> void. id integer in [0, 2^32); key finite. The key is checked FIRST,
|
|
263
|
+
then the id, then a full heap; each throws `[lite-logn]` as a no-op (size unchanged).
|
|
264
|
+
- `popMin()` -> id | undefined. Removes the minimum; undefined if empty (no throw).
|
|
265
|
+
- `popMax()` -> id | undefined. Removes the maximum; undefined if empty (no throw).
|
|
266
|
+
- `peekMin()` / `peekMax()` -> id | undefined. `peekMinKey()` / `peekMaxKey()` -> key |
|
|
267
|
+
undefined. All O(1), read-only, undefined if empty (never throw).
|
|
268
|
+
- `size` / `capacity` getters; `clear()`; `forEach(fn)` and `[Symbol.iterator]()` yield
|
|
269
|
+
live ids in UNSPECIFIED (heap-array) order -- NOT sorted / pop order.
|
|
270
|
+
- `MinMaxHeap.build(ids, keys, capacity)` -> MinMaxHeap. Floyd O(n) bulk build from parallel
|
|
271
|
+
arrays (deepest-first, level-aware sift-down); fails closed on non-array-like / length
|
|
272
|
+
mismatch, count > capacity, out-of-range id, or non-finite key.
|
|
273
|
+
|
|
241
274
|
Member exports (one tree-shakeable class each) are appended here as each member
|
|
242
275
|
ships.
|
|
243
276
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-logn",
|
|
3
3
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Zero-dependency, zero-GC family of O(log n) data structures that proves its logarithm: BinaryHeap (array-embedded O(log n) push/pop min-heap), Fenwick/BIT (O(log n) point-update AND prefix-sum via the i & -i walk), SegmentTree (O(log n) associative range-query + point-update), SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool), Treap (randomized-balanced augmented ordered map with O(log n) rank/select/split/merge),
|
|
4
|
+
"version": "0.7.0",
|
|
5
|
+
"description": "Zero-dependency, zero-GC family of O(log n) data structures that proves its logarithm: BinaryHeap (array-embedded O(log n) push/pop min-heap), Fenwick/BIT (O(log n) point-update AND prefix-sum via the i & -i walk), SegmentTree (O(log n) associative range-query + point-update), SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool), Treap (randomized-balanced augmented ordered map with O(log n) rank/select/split/merge), Scapegoat (DETERMINISTIC weight-balanced augmented ordered map: worst-case-O(log n) get, amortized-O(log n) set/delete, zero-GC rebuild), and MinMaxHeap (array-embedded double-ended priority queue: O(1) peekMin/peekMax, O(log n) push/popMin/popMax) with a log-linear O(log n) Witness harness that fits nsPerOp = intercept + slope*log2(n) and shows the straight log line while an O(n) foil leaves it. Tree-shakeable named exports.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./LogN.js",
|
|
8
8
|
"module": "./LogN.js",
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"binary-heap",
|
|
40
40
|
"heap",
|
|
41
41
|
"priority-queue",
|
|
42
|
+
"min-max-heap",
|
|
43
|
+
"double-ended-priority-queue",
|
|
44
|
+
"depq",
|
|
42
45
|
"d-ary-heap",
|
|
43
46
|
"fenwick",
|
|
44
47
|
"bit",
|