diff --git a/data_structures/unstable_indexed_heap.ts b/data_structures/unstable_indexed_heap.ts index 9d0d9acd5506..02df8faf55fd 100644 --- a/data_structures/unstable_indexed_heap.ts +++ b/data_structures/unstable_indexed_heap.ts @@ -1,11 +1,7 @@ // Copyright 2018-2026 the Deno authors. MIT license. // This module is browser compatible. -/** Allows the class to mutate priority internally. */ -interface MutableEntry { - readonly key: K; - priority: number; -} +import { ascend } from "./comparators.ts"; /** * A key-priority pair returned by {@linkcode IndexedHeap} methods. @@ -14,12 +10,13 @@ interface MutableEntry { * effect on the heap. * * @typeParam K The type of the key. + * @typeParam P The type of the priority. Defaults to `number`. */ -export interface HeapEntry { +export interface HeapEntry { /** The key that identifies this entry in the heap. */ readonly key: K; - /** The numeric priority of this entry (smaller = higher priority). */ - readonly priority: number; + /** The priority of this entry (smaller = higher priority by default). */ + readonly priority: P; } /** @@ -30,9 +27,10 @@ export interface HeapEntry { * @experimental **UNSTABLE**: New API, yet to be vetted. * * @typeParam K The type of the keys in the heap. + * @typeParam P The type of the priority. Defaults to `number`. */ -export type ReadonlyIndexedHeap = Pick< - IndexedHeap, +export type ReadonlyIndexedHeap = Pick< + IndexedHeap, | "peek" | "peekKey" | "peekPriority" @@ -44,9 +42,15 @@ export type ReadonlyIndexedHeap = Pick< | typeof Symbol.iterator >; -/** Throws if the priority is NaN, which would silently corrupt the heap. */ -function checkPriority(priority: number): void { - if (Number.isNaN(priority)) { +/** + * Throws if `priority` is the literal `NaN` value, which would silently + * corrupt the heap (`NaN` compares as neither less, greater, nor equal). + * + * `Number.isNaN` returns `false` for any non-number, so this is a no-op + * for non-numeric priorities (e.g., `bigint`, tuples, strings). + */ +function checkPriority(priority: unknown): void { + if (typeof priority === "number" && Number.isNaN(priority)) { throw new RangeError("Cannot set priority: value is NaN"); } } @@ -54,36 +58,52 @@ function checkPriority(priority: number): void { /** * A priority queue that supports looking up, removing, and re-prioritizing * entries by key. Each entry is a unique `(key, priority)` pair. The entry - * with the smallest priority is always at the front. + * with the smallest priority (under the comparator) is always at the front. * * Unlike {@linkcode BinaryHeap}, which only allows popping the top element, - * `IndexedHeap` lets you delete or update any entry by its key in - * logarithmic time. + * `IndexedHeap` lets you delete or change the priority of any entry by its + * key in logarithmic time. * - * Priorities are plain numbers, always sorted smallest-first. To sort - * largest-first instead, negate the priorities. + * Priorities default to `number` and are sorted smallest-first via + * {@linkcode ascend}. Pass a different comparator (e.g., + * {@linkcode descend} for max-heap order) or a different priority type + * (e.g., `[number, number]` for stable tie-breaking) via the `compare` + * option. * - * | Method | Time complexity | - * | --------------------- | ------------------------------------- | - * | peek() | Constant | - * | peekKey() | Constant | - * | peekPriority() | Constant | - * | pop() | Logarithmic in the number of entries | - * | push(key, priority) | Logarithmic in the number of entries | - * | delete(key) | Logarithmic in the number of entries | - * | update(key, priority) | Logarithmic in the number of entries | - * | has(key) | Constant | - * | getPriority(key) | Constant | - * | toArray() | Linear in the number of entries | - * | clear() | Linear in the number of entries | - * | drain() | Linearithmic in the number of entries | - * | [Symbol.iterator] | Linear in the number of entries | + * | Method | Time complexity | + * | ------------------- | ------------------------------------- | + * | peek() | Constant | + * | peekKey() | Constant | + * | peekPriority() | Constant | + * | pop() | Logarithmic in the number of entries | + * | push(key, priority) | Logarithmic in the number of entries | + * | set(key, priority) | Logarithmic in the number of entries | + * | delete(key) | Logarithmic in the number of entries | + * | has(key) | Constant | + * | getPriority(key) | Constant | + * | toArray() | Linear in the number of entries | + * | clear() | Linear in the number of entries | + * | drain() | Linearithmic in the number of entries | + * | [Symbol.iterator] | Linear in the number of entries | * * Iterating with `for...of` or the spread operator yields entries in * arbitrary (heap-internal) order **without** modifying the heap. To * consume entries in sorted order, use * {@linkcode IndexedHeap.prototype.drain | drain}. * + * Priorities are stored by reference. For primitive priority types + * (`number`, `bigint`, `string`, etc.) this is invisible. With object + * priorities such as tuples, treat them as immutable: the entries + * returned by {@linkcode IndexedHeap.prototype.peek | peek}, + * {@linkcode IndexedHeap.prototype.peekPriority | peekPriority}, + * {@linkcode IndexedHeap.prototype.getPriority | getPriority}, + * {@linkcode IndexedHeap.prototype.toArray | toArray}, and iteration + * share the priority reference with the heap, so mutating a returned + * priority's contents (e.g., `heap.peek().priority[0] = 0`) corrupts + * the heap invariant. Use + * {@linkcode IndexedHeap.prototype.set | set} to change a key's + * priority. + * * @experimental **UNSTABLE**: New API, yet to be vetted. * * @example Usage @@ -106,13 +126,129 @@ function checkPriority(priority: number): void { * ]); * ``` * + * @example Max-heap via `descend` + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { descend } from "@std/data-structures"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(null, { compare: descend }); + * heap.push("a", 1); + * heap.push("b", 5); + * heap.push("c", 3); + * + * assertEquals(heap.peek(), { key: "b", priority: 5 }); + * ``` + * + * @example Tuple priority for stable tie-breaking + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * // Order primarily by score, then by insertion order for ties. + * const heap = new IndexedHeap(null, { + * compare: (a, b) => a[0] - b[0] || a[1] - b[1], + * }); + * heap.push("first", [5, 0]); + * heap.push("second", [5, 1]); + * heap.push("third", [3, 2]); + * + * assertEquals([...heap.drain()].map((e) => e.key), [ + * "third", + * "first", + * "second", + * ]); + * ``` + * * @typeParam K The type of the keys in the heap. Keys are compared the * same way as `Map` keys — by reference for objects, by value for * primitives. + * @typeParam P The type of the priority. Defaults to `number`. */ -export class IndexedHeap implements Iterable> { - #data: MutableEntry[] = []; +export class IndexedHeap implements Iterable> { + #data: { key: K; priority: P }[] = []; #index: Map = new Map(); + #compare: (a: P, b: P) => number; + + /** + * Creates an empty {@linkcode IndexedHeap}, optionally populated from an + * iterable of `[key, priority]` pairs and/or configured with a custom + * comparator. Heapified in linear time. + * + * Use {@linkcode IndexedHeap.from} for inputs that are array-like instead + * of iterable, or to copy from another `IndexedHeap`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param entries An optional iterable of `[key, priority]` pairs. Each key + * must be unique; duplicates throw a `TypeError`. Pass `null` or + * `undefined` to create an empty heap with options. + * @param options Optional configuration. `compare` overrides the default + * ascending-numeric ordering; pass {@linkcode descend} for max-heap + * order, or a custom function for tuple/`bigint`/etc. priorities. + * + * @example Empty heap + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * assertEquals(heap.size, 0); + * ``` + * + * @example With initial entries + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap([["a", 3], ["b", 1], ["c", 2]]); + * assertEquals(heap.peek(), { key: "b", priority: 1 }); + * ``` + * + * @example From a Map + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const tasks = new Map([["task-a", 3], ["task-b", 1]]); + * const heap = new IndexedHeap(tasks); + * assertEquals(heap.peek(), { key: "task-b", priority: 1 }); + * ``` + * + * @example Max-heap via `descend` + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { descend } from "@std/data-structures"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap([["a", 1], ["b", 5], ["c", 3]], { + * compare: descend, + * }); + * assertEquals(heap.peek(), { key: "b", priority: 5 }); + * ``` + */ + constructor( + entries?: Iterable | null, + options?: { compare?: (a: P, b: P) => number }, + ) { + const compare = options?.compare ?? (ascend as (a: P, b: P) => number); + if (typeof compare !== "function") { + throw new TypeError( + "Cannot construct an IndexedHeap: the 'compare' option is not a function", + ); + } + this.#compare = compare; + if (entries === undefined || entries === null) return; + if ( + typeof entries !== "object" && typeof entries !== "string" || + !(Symbol.iterator in Object(entries)) + ) { + throw new TypeError( + "Cannot construct an IndexedHeap: the 'entries' parameter is not iterable, did you mean to call IndexedHeap.from?", + ); + } + this.#bulkLoad(entries); + } /** * A string tag for the class, used by `Object.prototype.toString()`. @@ -133,7 +269,8 @@ export class IndexedHeap implements Iterable> { /** * Create a new {@linkcode IndexedHeap} from an iterable of key-priority * pairs, an array-like of key-priority pairs, or an existing - * {@linkcode IndexedHeap}. + * {@linkcode IndexedHeap}. When copying from another `IndexedHeap`, the + * source's comparator is inherited unless `options.compare` overrides it. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -172,17 +309,35 @@ export class IndexedHeap implements Iterable> { * assertEquals(heap.peek(), { key: "task-b", priority: 1 }); * ``` * + * @example Re-ordering an existing heap with a different comparator + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { descend } from "@std/data-structures"; + * import { assertEquals } from "@std/assert"; + * + * const minHeap = new IndexedHeap([["a", 1], ["b", 5], ["c", 3]]); + * const maxHeap = IndexedHeap.from(minHeap, { compare: descend }); + * + * assertEquals(minHeap.peek(), { key: "a", priority: 1 }); + * assertEquals(maxHeap.peek(), { key: "b", priority: 5 }); + * ``` + * * @typeParam K The type of the keys in the heap. + * @typeParam P The type of the priority. Defaults to `number`. * @param collection An iterable or array-like of `[key, priority]` pairs, * or an existing {@linkcode IndexedHeap} to copy. + * @param options Optional configuration. `compare` overrides the + * ordering; when copying from another `IndexedHeap`, omitting this + * inherits the source's comparator. * @returns A new heap containing all entries from the collection. */ - static from( + static from( collection: - | IndexedHeap - | Iterable - | ArrayLike, - ): IndexedHeap { + | IndexedHeap + | Iterable + | ArrayLike, + options?: { compare?: (a: P, b: P) => number }, + ): IndexedHeap { if ( collection === null || collection === undefined || typeof collection !== "object" && typeof collection !== "string" || @@ -195,42 +350,66 @@ export class IndexedHeap implements Iterable> { "Cannot create an IndexedHeap: the 'collection' parameter is not iterable or array-like", ); } - const heap = new IndexedHeap(); if (collection instanceof IndexedHeap) { + const heap = new IndexedHeap(null, { + compare: options?.compare ?? collection.#compare, + }); for (const entry of collection.#data) { + const pos = heap.#data.length; heap.#data.push({ key: entry.key, priority: entry.priority }); + heap.#index.set(entry.key, pos); + } + // If the comparator is overridden, the source's heap-array order is + // no longer valid under the new ordering — re-heapify in O(n). + if (options?.compare) { + for (let i = (heap.#data.length >>> 1) - 1; i >= 0; i--) { + heap.#siftDown(i); + } } - heap.#index = new Map(collection.#index); return heap; } - const entries = Array.from(collection); - for (let i = 0; i < entries.length; i++) { - const [key, priority] = entries[i]!; + const heap = new IndexedHeap(null, options); + heap.#bulkLoad( + Symbol.iterator in Object(collection) + ? collection as Iterable + : Array.from(collection as ArrayLike), + ); + return heap; + } + + /** + * Append all `[key, priority]` pairs and heapify in linear time. Used by + * the constructor and by {@linkcode IndexedHeap.from} for the + * non-`IndexedHeap` input branch. + */ + #bulkLoad(entries: Iterable): void { + for (const [key, priority] of entries) { checkPriority(priority); - if (heap.#index.has(key)) { + if (this.#index.has(key)) { throw new TypeError( "Cannot push into IndexedHeap: key already exists", ); } - heap.#data.push({ key, priority }); - heap.#index.set(key, i); + const pos = this.#data.length; + this.#data.push({ key, priority }); + this.#index.set(key, pos); } - for (let i = (heap.#data.length >>> 1) - 1; i >= 0; i--) { - heap.#siftDown(i); + for (let i = (this.#data.length >>> 1) - 1; i >= 0; i--) { + this.#siftDown(i); } - return heap; } /** Bubble the entry at `pos` up toward the root while it is smaller than its parent. */ #siftUp(pos: number): number { const data = this.#data; const index = this.#index; + const compare = this.#compare; const entry = data[pos]!; const priority = entry.priority; while (pos > 0) { const parentPos = (pos - 1) >>> 1; const parent = data[parentPos]!; - if (priority < parent.priority) { + if (compare(priority, parent.priority) < 0) { data[pos] = parent; index.set(parent.key, pos); pos = parentPos; @@ -247,6 +426,7 @@ export class IndexedHeap implements Iterable> { #siftDown(pos: number): void { const data = this.#data; const index = this.#index; + const compare = this.#compare; const size = data.length; const entry = data[pos]!; const priority = entry.priority; @@ -258,12 +438,12 @@ export class IndexedHeap implements Iterable> { let childPri = data[left]!.priority; if (right < size) { const rp = data[right]!.priority; - if (rp < childPri) { + if (compare(rp, childPri) < 0) { childPos = right; childPri = rp; } } - if (childPri < priority) { + if (compare(childPri, priority) < 0) { const child = data[childPos]!; data[pos] = child; index.set(child.key, pos); @@ -278,8 +458,8 @@ export class IndexedHeap implements Iterable> { /** * Insert a new key with the given priority. Throws if the key already - * exists — use {@linkcode IndexedHeap.prototype.update | update} or - * {@linkcode IndexedHeap.prototype.pushOrUpdate | pushOrUpdate} instead. + * exists — use {@linkcode IndexedHeap.prototype.set | set} for upsert + * semantics instead. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -295,9 +475,9 @@ export class IndexedHeap implements Iterable> { * ``` * * @param key The key to insert. - * @param priority The numeric priority (smaller = higher priority). + * @param priority The priority (smaller = higher priority by default). */ - push(key: K, priority: number): void { + push(key: K, priority: P): void { checkPriority(priority); if (this.#index.has(key)) { throw new TypeError( @@ -332,7 +512,7 @@ export class IndexedHeap implements Iterable> { * * @returns The front entry, or `undefined` if empty. */ - pop(): HeapEntry | undefined { + pop(): HeapEntry | undefined { const size = this.#data.length; if (size === 0) return undefined; @@ -353,7 +533,11 @@ export class IndexedHeap implements Iterable> { * Return the front entry (smallest priority) without removing it, or * `undefined` if the heap is empty. * - * The returned object is a copy; mutating it does not affect the heap. + * The returned wrapper is a fresh object — replacing its `key` or + * `priority` property has no effect on the heap. Object-typed + * priorities (e.g., tuples) share their reference with the heap and + * must not be mutated; see the class-level note on priority + * mutability. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -370,9 +554,9 @@ export class IndexedHeap implements Iterable> { * assertEquals(heap.size, 2); * ``` * - * @returns A copy of the front entry, or `undefined` if empty. + * @returns A wrapper around the front entry, or `undefined` if empty. */ - peek(): HeapEntry | undefined { + peek(): HeapEntry | undefined { const entry = this.#data[0]; if (entry === undefined) return undefined; return { key: entry.key, priority: entry.priority }; @@ -428,7 +612,7 @@ export class IndexedHeap implements Iterable> { * * @returns The priority of the front entry, or `undefined` if empty. */ - peekPriority(): number | undefined { + peekPriority(): P | undefined { return this.#data[0]?.priority; } @@ -470,7 +654,7 @@ export class IndexedHeap implements Iterable> { const last = this.#data.pop()!; this.#data[pos] = last; - if (last.priority < removedPriority) { + if (this.#compare(last.priority, removedPriority) < 0) { this.#siftUp(pos); } else { this.#siftDown(pos); @@ -479,72 +663,43 @@ export class IndexedHeap implements Iterable> { } /** - * Change the priority of an existing key. Throws if the key is not - * present — use {@linkcode IndexedHeap.prototype.push | push} or - * {@linkcode IndexedHeap.prototype.pushOrUpdate | pushOrUpdate} instead. + * Set the priority of a key, inserting it if absent. This is the upsert + * counterpart to {@linkcode IndexedHeap.prototype.push | push} (which + * throws on existing keys) and is the natural operation for relaxation + * steps in graph algorithms like Dijkstra's. * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @example Usage + * @example Inserting and updating * ```ts * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; * import { assertEquals } from "@std/assert"; * * const heap = new IndexedHeap(); - * heap.push("a", 10); - * heap.push("b", 20); + * heap.set("a", 10); + * assertEquals(heap.getPriority("a"), 10); * - * heap.update("b", 1); - * assertEquals(heap.peek(), { key: "b", priority: 1 }); + * heap.set("a", 5); + * assertEquals(heap.getPriority("a"), 5); * ``` * - * @param key The key whose priority to change. - * @param priority The new priority. - */ - update(key: K, priority: number): void { - checkPriority(priority); - const pos = this.#index.get(key); - if (pos === undefined) { - throw new TypeError( - "Cannot update IndexedHeap: key does not exist", - ); - } - const entry = this.#data[pos]!; - const oldPriority = entry.priority; - if (priority === oldPriority) return; - entry.priority = priority; - if (priority < oldPriority) { - this.#siftUp(pos); - } else { - this.#siftDown(pos); - } - } - - /** - * Insert the key if absent, or update its priority if present. This is a - * convenience method combining - * {@linkcode IndexedHeap.prototype.push | push} and - * {@linkcode IndexedHeap.prototype.update | update}. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @example Usage + * @example Decrease-key reorders the heap * ```ts * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; * import { assertEquals } from "@std/assert"; * * const heap = new IndexedHeap(); - * heap.pushOrUpdate("a", 10); - * assertEquals(heap.getPriority("a"), 10); + * heap.push("a", 10); + * heap.push("b", 20); * - * heap.pushOrUpdate("a", 5); - * assertEquals(heap.getPriority("a"), 5); + * heap.set("b", 1); + * assertEquals(heap.peek(), { key: "b", priority: 1 }); * ``` * * @param key The key to insert or update. * @param priority The priority to set. */ - pushOrUpdate(key: K, priority: number): void { + set(key: K, priority: P): void { checkPriority(priority); const pos = this.#index.get(key); if (pos === undefined) { @@ -557,7 +712,7 @@ export class IndexedHeap implements Iterable> { const oldPriority = entry.priority; if (priority === oldPriority) return; entry.priority = priority; - if (priority < oldPriority) { + if (this.#compare(priority, oldPriority) < 0) { this.#siftUp(pos); } else { this.#siftDown(pos); @@ -608,7 +763,7 @@ export class IndexedHeap implements Iterable> { * @param key The key to look up. * @returns The priority of the key, or `undefined` if not present. */ - getPriority(key: K): number | undefined { + getPriority(key: K): P | undefined { const pos = this.#index.get(key); if (pos === undefined) return undefined; return this.#data[pos]!.priority; @@ -711,9 +866,9 @@ export class IndexedHeap implements Iterable> { * * @returns An iterator yielding entries from smallest to largest priority. */ - *drain(): IterableIterator> { + *drain(): IterableIterator> { while (!this.isEmpty()) { - yield this.pop() as HeapEntry; + yield this.pop() as HeapEntry; } } @@ -744,18 +899,19 @@ export class IndexedHeap implements Iterable> { * * @returns An array of entries in arbitrary (heap-internal) order. */ - toArray(): HeapEntry[] { + toArray(): HeapEntry[] { return this.#data.map(({ key, priority }) => ({ key, priority })); } /** * Yield all entries without removing them. The order is the internal - * heap-array order (not sorted by priority). The heap is not modified. + * heap-array order (not sorted by priority). The heap is not modified + * (unlike {@linkcode BinaryHeap}, whose iterator drains). * * Use {@linkcode IndexedHeap.prototype.drain | drain} to iterate in * sorted (smallest-first) order (which empties the heap). * - * Mutating the heap (`push`, `pop`, `update`, `delete`, `clear`) while + * Mutating the heap (`push`, `pop`, `set`, `delete`, `clear`) while * iterating is not supported and may skip or repeat entries. * * @experimental **UNSTABLE**: New API, yet to be vetted. @@ -777,7 +933,7 @@ export class IndexedHeap implements Iterable> { * * @returns An iterator yielding entries in arbitrary (heap-internal) order. */ - *[Symbol.iterator](): IterableIterator> { + *[Symbol.iterator](): IterableIterator> { for (const { key, priority } of this.#data) { yield { key, priority }; } diff --git a/data_structures/unstable_indexed_heap_test.ts b/data_structures/unstable_indexed_heap_test.ts index acc7b104f01e..2ecdd7dd2198 100644 --- a/data_structures/unstable_indexed_heap_test.ts +++ b/data_structures/unstable_indexed_heap_test.ts @@ -1,5 +1,6 @@ // Copyright 2018-2026 the Deno authors. MIT license. import { assertEquals, assertThrows } from "@std/assert"; +import { descend } from "./comparators.ts"; import { type HeapEntry, IndexedHeap, @@ -169,24 +170,24 @@ Deno.test("IndexedHeap delete triggers sift-up when replacement is smaller", () ]); }); -Deno.test("IndexedHeap update decrease-key bubbles up", () => { +Deno.test("IndexedHeap set decreases priority and bubbles up", () => { const heap = new IndexedHeap(); heap.push("a", 10); heap.push("b", 20); heap.push("c", 30); - heap.update("c", 1); + heap.set("c", 1); assertEquals(heap.peek(), { key: "c", priority: 1 }); assertEquals(heap.getPriority("c"), 1); }); -Deno.test("IndexedHeap update increase-key bubbles down", () => { +Deno.test("IndexedHeap set increases priority and bubbles down", () => { const heap = new IndexedHeap(); heap.push("a", 1); heap.push("b", 2); heap.push("c", 3); - heap.update("a", 100); + heap.set("a", 100); assertEquals(heap.peek(), { key: "b", priority: 2 }); assertEquals([...heap.drain()], [ @@ -196,38 +197,28 @@ Deno.test("IndexedHeap update increase-key bubbles down", () => { ]); }); -Deno.test("IndexedHeap update throws for non-existent key", () => { +Deno.test("IndexedHeap set inserts when absent and updates when present", () => { const heap = new IndexedHeap(); - heap.push("a", 1); - assertThrows( - () => heap.update("z", 5), - TypeError, - "Cannot update IndexedHeap: key does not exist", - ); -}); - -Deno.test("IndexedHeap pushOrUpdate inserts when absent, updates when present", () => { - const heap = new IndexedHeap(); - heap.pushOrUpdate("a", 10); + heap.set("a", 10); assertEquals(heap.size, 1); assertEquals(heap.getPriority("a"), 10); - heap.pushOrUpdate("a", 5); + heap.set("a", 5); assertEquals(heap.size, 1); assertEquals(heap.getPriority("a"), 5); assertEquals(heap.peek(), { key: "a", priority: 5 }); }); -Deno.test("IndexedHeap pushOrUpdate decrease then increase same key", () => { +Deno.test("IndexedHeap set decrease then increase same key", () => { const heap = new IndexedHeap(); heap.push("a", 10); heap.push("b", 20); heap.push("c", 30); - heap.pushOrUpdate("c", 1); + heap.set("c", 1); assertEquals(heap.peek(), { key: "c", priority: 1 }); - heap.pushOrUpdate("c", 50); + heap.set("c", 50); assertEquals(heap.peek(), { key: "a", priority: 10 }); assertEquals([...heap.drain()], [ @@ -237,6 +228,27 @@ Deno.test("IndexedHeap pushOrUpdate decrease then increase same key", () => { ]); }); +Deno.test("IndexedHeap set with same priority is a no-op", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + heap.push("b", 5); + heap.push("c", 5); + heap.push("d", 5); + heap.push("e", 10); + + const before = heap.toArray(); + heap.set("c", 5); + const after = heap.toArray(); + + assertEquals(after, before, "internal heap order preserved"); + + const popped: number[] = []; + while (!heap.isEmpty()) { + popped.push(heap.pop()!.priority); + } + assertEquals(popped, [1, 5, 5, 5, 10]); +}); + Deno.test("IndexedHeap size tracks push, pop, delete, clear", () => { const heap = new IndexedHeap(); assertEquals(heap.size, 0); @@ -454,7 +466,7 @@ Deno.test("IndexedHeap works correctly after clear and reuse", () => { ]); }); -Deno.test("IndexedHeap interleaved push, pop, update, delete", () => { +Deno.test("IndexedHeap interleaved push, pop, set, delete", () => { const heap = new IndexedHeap(); heap.push("a", 10); heap.push("b", 20); @@ -463,7 +475,7 @@ Deno.test("IndexedHeap interleaved push, pop, update, delete", () => { assertEquals(heap.pop(), { key: "a", priority: 10 }); heap.push("d", 5); - heap.update("c", 1); + heap.set("c", 1); assertEquals(heap.peek(), { key: "c", priority: 1 }); @@ -499,39 +511,130 @@ Deno.test("IndexedHeap push throws on NaN priority", () => { assertEquals(heap.size, 0); }); -Deno.test("IndexedHeap update throws on NaN priority", () => { +Deno.test("IndexedHeap set throws on NaN priority", () => { const heap = new IndexedHeap(); - heap.push("a", 1); assertThrows( - () => heap.update("a", NaN), + () => heap.set("a", NaN), RangeError, "Cannot set priority: value is NaN", ); - assertEquals(heap.getPriority("a"), 1); -}); + assertEquals(heap.size, 0, "absent key not inserted on NaN"); -Deno.test("IndexedHeap pushOrUpdate throws on NaN priority", () => { - const heap = new IndexedHeap(); + heap.push("b", 1); assertThrows( - () => heap.pushOrUpdate("a", NaN), + () => heap.set("b", NaN), RangeError, "Cannot set priority: value is NaN", ); + assertEquals( + heap.getPriority("b"), + 1, + "existing key's priority unchanged on NaN", + ); +}); + +Deno.test("IndexedHeap has correct Symbol.toStringTag", () => { + const heap = new IndexedHeap(); + assertEquals(heap[Symbol.toStringTag], "IndexedHeap"); + assertEquals(Object.prototype.toString.call(heap), "[object IndexedHeap]"); +}); + +Deno.test("IndexedHeap constructor with no arguments creates an empty heap", () => { + const heap = new IndexedHeap(); assertEquals(heap.size, 0); + assertEquals(heap.isEmpty(), true); + assertEquals(heap.peek(), undefined); +}); - heap.push("b", 1); +Deno.test("IndexedHeap constructor accepts undefined and null", () => { + const fromUndefined = new IndexedHeap(undefined); + assertEquals(fromUndefined.size, 0); + + const fromNull = new IndexedHeap(null); + assertEquals(fromNull.size, 0); +}); + +Deno.test("IndexedHeap constructor populates from an array of pairs", () => { + const heap = new IndexedHeap([["c", 3], ["a", 1], ["b", 2]]); + assertEquals(heap.size, 3); + assertEquals(heap.peek(), { key: "a", priority: 1 }); + assertEquals([...heap.drain()], [ + { key: "a", priority: 1 }, + { key: "b", priority: 2 }, + { key: "c", priority: 3 }, + ]); +}); + +Deno.test("IndexedHeap constructor populates from a generator", () => { + function* pairs(): Iterable<[string, number]> { + yield ["a", 5]; + yield ["b", 2]; + yield ["c", 8]; + } + const heap = new IndexedHeap(pairs()); + assertEquals(heap.size, 3); + assertEquals(heap.peek(), { key: "b", priority: 2 }); +}); + +Deno.test("IndexedHeap constructor populates from a Map", () => { + const map = new Map([["task-a", 3], ["task-b", 1], ["task-c", 2]]); + const heap = new IndexedHeap(map); + assertEquals(heap.size, 3); + assertEquals(heap.peek(), { key: "task-b", priority: 1 }); +}); + +Deno.test("IndexedHeap constructor accepts an empty iterable", () => { + const heap = new IndexedHeap([]); + assertEquals(heap.size, 0); + assertEquals(heap.isEmpty(), true); +}); + +Deno.test("IndexedHeap constructor throws on duplicate keys", () => { + assertThrows( + () => new IndexedHeap([["a", 1], ["a", 2]]), + TypeError, + "Cannot push into IndexedHeap: key already exists", + ); +}); + +Deno.test("IndexedHeap constructor throws on NaN priority", () => { assertThrows( - () => heap.pushOrUpdate("b", NaN), + () => new IndexedHeap([["a", NaN]]), RangeError, "Cannot set priority: value is NaN", ); - assertEquals(heap.getPriority("b"), 1); }); -Deno.test("IndexedHeap has correct Symbol.toStringTag", () => { - const heap = new IndexedHeap(); - assertEquals(heap[Symbol.toStringTag], "IndexedHeap"); - assertEquals(Object.prototype.toString.call(heap), "[object IndexedHeap]"); +Deno.test("IndexedHeap constructor throws TypeError on non-iterable input", () => { + const message = + "Cannot construct an IndexedHeap: the 'entries' parameter is not iterable, did you mean to call IndexedHeap.from?"; + // deno-lint-ignore no-explicit-any + assertThrows(() => new IndexedHeap(42 as any), TypeError, message); + // deno-lint-ignore no-explicit-any + assertThrows(() => new IndexedHeap(true as any), TypeError, message); + // deno-lint-ignore no-explicit-any + assertThrows(() => new IndexedHeap({} as any), TypeError, message); +}); + +Deno.test("IndexedHeap constructor redirects ArrayLike inputs to from()", () => { + // Plain ArrayLike (no Symbol.iterator) is rejected by the constructor with + // a redirect to `from()`. This catches the foot-gun where a user passes + // an array-like (e.g., NodeList, custom { length, [i]: ... } object). + const arrayLike: ArrayLike = { + length: 2, + 0: ["a", 1], + 1: ["b", 2], + }; + assertThrows( + // deno-lint-ignore no-explicit-any + () => new IndexedHeap(arrayLike as any), + TypeError, + "did you mean to call IndexedHeap.from?", + ); + + const heap = IndexedHeap.from(arrayLike); + assertEquals(heap.size, 2); + assertEquals(heap.peek(), { key: "a", priority: 1 }); }); Deno.test("IndexedHeap.from() creates heap from array of pairs", () => { @@ -557,7 +660,7 @@ Deno.test("IndexedHeap.from() creates heap from another IndexedHeap", () => { assertEquals(original.size, 3, "original unchanged"); - copy.update("z", 100); + copy.set("z", 100); assertEquals(original.getPriority("z"), 1, "original not affected by copy"); }); @@ -690,6 +793,180 @@ Deno.test("IndexedHeap iterator works with for-of", () => { assertEquals(heap.size, 2, "heap not modified by for-of"); }); +Deno.test("IndexedHeap with descend acts as max-heap", () => { + const heap = new IndexedHeap(null, { compare: descend }); + heap.push("a", 1); + heap.push("b", 5); + heap.push("c", 3); + + assertEquals(heap.peek(), { key: "b", priority: 5 }); + assertEquals(heap.pop(), { key: "b", priority: 5 }); + assertEquals([...heap.drain()].map((e) => e.priority), [3, 1]); +}); + +Deno.test("IndexedHeap constructor accepts compare option with initial entries", () => { + const heap = new IndexedHeap([["a", 1], ["b", 5], ["c", 3]], { + compare: descend, + }); + assertEquals(heap.peek(), { key: "b", priority: 5 }); + assertEquals([...heap.drain()].map((e) => e.priority), [5, 3, 1]); +}); + +Deno.test("IndexedHeap with tuple priority supports stable tie-breaking", () => { + // Order primarily by score (smallest first), then by insertion counter + // for ties. This is the canonical pattern for FIFO-within-priority. + const heap = new IndexedHeap(null, { + compare: (a, b) => a[0] - b[0] || a[1] - b[1], + }); + heap.push("first", [5, 0]); + heap.push("second", [5, 1]); + heap.push("third", [5, 2]); + heap.push("urgent", [3, 99]); + + assertEquals([...heap.drain()].map((e) => e.key), [ + "urgent", + "first", + "second", + "third", + ]); +}); + +Deno.test("IndexedHeap with bigint priority handles values beyond MAX_SAFE_INTEGER", () => { + const heap = new IndexedHeap(null, { + compare: (a, b) => a < b ? -1 : a > b ? 1 : 0, + }); + // 9_007_199_254_740_993n exceeds Number.MAX_SAFE_INTEGER (2^53 - 1). + // A number-based heap would lose precision; bigint preserves it. + heap.push("small", 1n); + heap.push("huge", 9_007_199_254_740_993n); + heap.push("medium", 100n); + + assertEquals(heap.peek(), { key: "small", priority: 1n }); + assertEquals([...heap.drain()].map((e) => e.priority), [ + 1n, + 100n, + 9_007_199_254_740_993n, + ]); +}); + +Deno.test("IndexedHeap set and delete work under custom comparator", () => { + const heap = new IndexedHeap(null, { compare: descend }); + heap.push("a", 10); + heap.push("b", 20); + heap.push("c", 30); + + // Increase "a" to 100; under max-heap order, it bubbles up to the top. + heap.set("a", 100); + assertEquals(heap.peek(), { key: "a", priority: 100 }); + + // Decrease "c" to 1; under max-heap order, it bubbles down. + heap.set("c", 1); + assertEquals([...heap.drain()].map((e) => e.priority), [100, 20, 1]); +}); + +Deno.test("IndexedHeap delete with replacement triggers correct sift under custom comparator", () => { + // Max-heap shape after inserts (priorities, with "d" at index 4 etc.): + // d(52) + // / \ + // c(51) f(5) + // / \ / \ + // r(1) a(50) b(3) e(4) + // + // Deleting "c" (index 1) moves last "e" (priority 4) into index 1. + // Parent of index 1 is "d" (priority 52). Under max-heap (descend), + // 4 is less than 52, so "e" sifts DOWN. The new top remains "d". + const heap = new IndexedHeap([ + ["r", 1], + ["a", 50], + ["b", 3], + ["c", 51], + ["d", 52], + ["e", 4], + ["f", 5], + ], { compare: descend }); + + assertEquals(heap.peek(), { key: "d", priority: 52 }); + heap.delete("c"); + assertEquals(heap.peek(), { key: "d", priority: 52 }); + assertEquals(heap.size, 6); + + assertEquals([...heap.drain()].map((e) => e.priority), [52, 50, 5, 4, 3, 1]); +}); + +Deno.test("IndexedHeap.from() inherits comparator from source heap", () => { + const minHeap = new IndexedHeap([["a", 1], ["b", 5], ["c", 3]]); + const minCopy = IndexedHeap.from(minHeap); + assertEquals(minCopy.peek(), { key: "a", priority: 1 }); + + const maxHeap = new IndexedHeap([["a", 1], ["b", 5], ["c", 3]], { + compare: descend, + }); + const maxCopy = IndexedHeap.from(maxHeap); + assertEquals(maxCopy.peek(), { key: "b", priority: 5 }); + assertEquals([...maxCopy.drain()].map((e) => e.priority), [5, 3, 1]); +}); + +Deno.test("IndexedHeap.from() with compare option overrides source comparator", () => { + const minHeap = new IndexedHeap([["a", 1], ["b", 5], ["c", 3]]); + const maxFromMin = IndexedHeap.from(minHeap, { compare: descend }); + + assertEquals(minHeap.peek(), { key: "a", priority: 1 }, "source unchanged"); + assertEquals(maxFromMin.peek(), { key: "b", priority: 5 }); + assertEquals([...maxFromMin.drain()].map((e) => e.priority), [5, 3, 1]); +}); + +Deno.test("IndexedHeap.from() iterable input accepts compare option", () => { + const heap = IndexedHeap.from( + [["a", 1], ["b", 5], ["c", 3]], + { compare: descend }, + ); + assertEquals(heap.peek(), { key: "b", priority: 5 }); + assertEquals([...heap.drain()].map((e) => e.priority), [5, 3, 1]); +}); + +Deno.test("IndexedHeap throws TypeError when compare option is not a function", () => { + assertThrows( + () => + new IndexedHeap(null, { + compare: 1 as unknown as (a: number, b: number) => number, + }), + TypeError, + "the 'compare' option is not a function", + ); + assertThrows( + () => + new IndexedHeap([["a", 1]], { + compare: "asc" as unknown as (a: number, b: number) => number, + }), + TypeError, + "the 'compare' option is not a function", + ); + assertThrows( + () => + IndexedHeap.from([["a", 1]], { + compare: {} as unknown as (a: number, b: number) => number, + }), + TypeError, + "the 'compare' option is not a function", + ); +}); + +Deno.test("IndexedHeap peek wrapper is fresh; replacing priority is safe even with object priorities", () => { + // Replacing the priority property on the returned wrapper is a no-op on + // the heap because the wrapper is a fresh object. Mutating the priority + // *contents* (e.g. `peeked.priority[0] = 0`) is undefined behavior — see + // the class-level note on priority mutability. + const heap = new IndexedHeap(null, { + compare: (a, b) => a[0] - b[0] || a[1] - b[1], + }); + heap.push("a", [1, 0]); + heap.push("b", [5, 0]); + + const peeked = heap.peek()!; + (peeked as { priority: [number, number] }).priority = [999, 999]; + assertEquals(heap.peek()!.priority, [1, 0]); +}); + /** Mulberry32: deterministic 32-bit PRNG for reproducible stress tests. */ function mulberry32(seed: number): () => number { return () => { @@ -720,9 +997,9 @@ Deno.test("IndexedHeap.from() stress test: heapify + index consistency", () => { assertEquals(heap.getPriority(key), priority); } - heap.update(0, -1); + heap.set(0, -1); assertEquals(heap.peek(), { key: 0, priority: -1 }); - heap.update(0, expected.get(0)!); + heap.set(0, expected.get(0)!); heap.delete(n - 1); assertEquals(heap.has(n - 1), false);