From 22f7f080abbfede502263958c2f48a74f0d9b4a6 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Wed, 1 Apr 2026 19:41:06 +0200 Subject: [PATCH 01/10] feat(data-structures/unstable): add --- data_structures/deno.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data_structures/deno.json b/data_structures/deno.json index da895e9caadc..8e1e0ca97be1 100644 --- a/data_structures/deno.json +++ b/data_structures/deno.json @@ -11,6 +11,7 @@ "./red-black-tree": "./red_black_tree.ts", "./unstable-2d-array": "./unstable_2d_array.ts", "./unstable-rolling-counter": "./unstable_rolling_counter.ts", - "./unstable-deque": "./unstable_deque.ts" + "./unstable-deque": "./unstable_deque.ts", + "./unstable-indexed-heap": "./unstable_indexed_heap.ts" } } From 96dda252a2f64a61f3b1a744743816ded71f550b Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Wed, 1 Apr 2026 19:41:16 +0200 Subject: [PATCH 02/10] feat(data-structures/unstable): add --- data_structures/unstable_indexed_heap.ts | 564 ++++++++++++++++++ data_structures/unstable_indexed_heap_test.ts | 525 ++++++++++++++++ 2 files changed, 1089 insertions(+) create mode 100644 data_structures/unstable_indexed_heap.ts create mode 100644 data_structures/unstable_indexed_heap_test.ts diff --git a/data_structures/unstable_indexed_heap.ts b/data_structures/unstable_indexed_heap.ts new file mode 100644 index 000000000000..36e486355539 --- /dev/null +++ b/data_structures/unstable_indexed_heap.ts @@ -0,0 +1,564 @@ +// 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; +} + +/** + * A key-priority pair returned by {@linkcode IndexedHeap} methods. + * + * Fields are `readonly` to signal that mutating a returned entry has no + * effect on the heap. + * + * @typeParam K The type of the key. + */ +export interface HeapEntry { + readonly key: K; + readonly priority: number; +} + +/** + * Read-only view of an {@linkcode IndexedHeap}. Exposes only query methods + * (`peek`, `has`, `getPriority`, `size`, `isEmpty`), hiding all methods + * that modify the heap. Follows the same pattern as `ReadonlyMap` and + * `ReadonlySet`. + * + * Note: `[Symbol.iterator]` is intentionally excluded because the heap's + * iterator is destructive (it drains all entries). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam K The type of the keys in the heap. + */ +export type ReadonlyIndexedHeap = Pick< + IndexedHeap, + | "peek" + | "has" + | "getPriority" + | "size" + | "isEmpty" +>; + +/** Throws if the priority is NaN, which would silently corrupt the heap. */ +function assertValidPriority(priority: number): void { + if (Number.isNaN(priority)) { + throw new RangeError("Cannot set priority: value is NaN"); + } +} + +/** Returns the parent index for a given child index. */ +function getParentIndex(index: number): number { + return ((index + 1) >>> 1) - 1; +} + +/** + * 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. + * + * Unlike {@linkcode BinaryHeap}, which only allows popping the top element, + * `IndexedHeap` lets you delete or update any entry by its key in + * logarithmic time. + * + * Priorities are plain numbers, always sorted smallest-first. To sort + * largest-first instead, negate the priorities. + * + * | Method | Time complexity | + * | --------------------- | -------------------------------- | + * | peek() | 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 | + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 3); + * heap.push("b", 1); + * heap.push("c", 2); + * + * assertEquals(heap.peek(), { key: "b", priority: 1 }); + * assertEquals(heap.pop(), { key: "b", priority: 1 }); + * assertEquals([...heap], [{ key: "c", priority: 2 }, { key: "a", priority: 3 }]); + * ``` + * + * @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. + */ +export class IndexedHeap implements Iterable> { + #data: MutableEntry[] = []; + #index: Map = new Map(); + + /** 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 entry = data[pos]!; + const priority = entry.priority; + while (pos > 0) { + const parentPos = getParentIndex(pos); + const parent = data[parentPos]!; + if (priority < parent.priority) { + data[pos] = parent; + index.set(parent.key, pos); + pos = parentPos; + } else { + break; + } + } + data[pos] = entry; + index.set(entry.key, pos); + return pos; + } + + /** Bubble the entry at `pos` down while a child is smaller. */ + #siftDown(pos: number): void { + const data = this.#data; + const index = this.#index; + const size = data.length; + const entry = data[pos]!; + const priority = entry.priority; + while (true) { + const left = 2 * pos + 1; + if (left >= size) break; + const right = left + 1; + let childPos = left; + let childPri = data[left]!.priority; + if (right < size) { + const rp = data[right]!.priority; + if (rp < childPri) { + childPos = right; + childPri = rp; + } + } + if (childPri < priority) { + const child = data[childPos]!; + data[pos] = child; + index.set(child.key, pos); + pos = childPos; + } else { + break; + } + } + data[pos] = entry; + index.set(entry.key, pos); + } + + /** + * 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. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("task-1", 10); + * assertEquals(heap.size, 1); + * assertEquals(heap.peek(), { key: "task-1", priority: 10 }); + * ``` + * + * @param key The key to insert. + * @param priority The numeric priority (smaller = higher priority). + */ + push(key: K, priority: number): void { + assertValidPriority(priority); + if (this.#index.has(key)) { + throw new Error( + `Cannot push into IndexedHeap: key already exists`, + ); + } + const pos = this.#data.length; + this.#data.push({ key, priority }); + this.#index.set(key, pos); + this.#siftUp(pos); + } + + /** + * Remove and return the front entry (smallest priority), or `undefined` + * if the heap is empty. The returned entry is removed from the heap so + * the caller owns it. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 2); + * heap.push("b", 1); + * + * assertEquals(heap.pop(), { key: "b", priority: 1 }); + * assertEquals(heap.pop(), { key: "a", priority: 2 }); + * assertEquals(heap.pop(), undefined); + * ``` + * + * @returns The front entry, or `undefined` if empty. + */ + pop(): HeapEntry | undefined { + const size = this.#data.length; + if (size === 0) return undefined; + + const root = this.#data[0]!; + this.#index.delete(root.key); + + if (size === 1) { + this.#data.pop(); + return root; + } + + const last = this.#data.pop()!; + this.#data[0] = last; + this.#index.set(last.key, 0); + this.#siftDown(0); + return root; + } + + /** + * 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. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("x", 5); + * heap.push("y", 3); + * + * assertEquals(heap.peek(), { key: "y", priority: 3 }); + * assertEquals(heap.size, 2); + * ``` + * + * @returns A copy of the front entry, or `undefined` if empty. + */ + peek(): HeapEntry | undefined { + const entry = this.#data[0]; + if (entry === undefined) return undefined; + return { key: entry.key, priority: entry.priority }; + } + + /** + * Remove the entry with the given key. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 1); + * heap.push("b", 2); + * + * assertEquals(heap.delete("a"), true); + * assertEquals(heap.delete("z"), false); + * assertEquals(heap.size, 1); + * ``` + * + * @param key The key to remove. + * @returns `true` if the key was present, `false` otherwise. + */ + delete(key: K): boolean { + const pos = this.#index.get(key); + if (pos === undefined) return false; + + this.#index.delete(key); + const lastIndex = this.#data.length - 1; + + if (pos === lastIndex) { + this.#data.pop(); + return true; + } + + const last = this.#data.pop()!; + this.#data[pos] = last; + this.#index.set(last.key, pos); + + const afterUp = this.#siftUp(pos); + if (afterUp === pos) { + this.#siftDown(pos); + } + return true; + } + + /** + * 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. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```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.update("b", 1); + * assertEquals(heap.peek(), { key: "b", priority: 1 }); + * ``` + * + * @param key The key whose priority to change. + * @param priority The new priority. + */ + update(key: K, priority: number): void { + assertValidPriority(priority); + const pos = this.#index.get(key); + if (pos === undefined) { + throw new Error( + `Cannot update IndexedHeap: key does not exist`, + ); + } + this.#data[pos]!.priority = priority; + const afterUp = this.#siftUp(pos); + if (afterUp === pos) { + 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 + * ```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.pushOrUpdate("a", 5); + * assertEquals(heap.getPriority("a"), 5); + * ``` + * + * @param key The key to insert or update. + * @param priority The priority to set. + */ + pushOrUpdate(key: K, priority: number): void { + assertValidPriority(priority); + if (this.#index.has(key)) { + this.update(key, priority); + } else { + this.push(key, priority); + } + } + + /** + * Check whether the key is in the heap. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 1); + * + * assertEquals(heap.has("a"), true); + * assertEquals(heap.has("b"), false); + * ``` + * + * @param key The key to look up. + * @returns `true` if the key is present, `false` otherwise. + */ + has(key: K): boolean { + return this.#index.has(key); + } + + /** + * Return the priority of the given key, or `undefined` if not present. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 42); + * + * assertEquals(heap.getPriority("a"), 42); + * assertEquals(heap.getPriority("b"), undefined); + * ``` + * + * @param key The key to look up. + * @returns The priority of the key, or `undefined` if not present. + */ + getPriority(key: K): number | undefined { + const pos = this.#index.get(key); + if (pos === undefined) return undefined; + return this.#data[pos]!.priority; + } + + /** + * The number of entries in the heap. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * assertEquals(heap.size, 0); + * heap.push("a", 1); + * assertEquals(heap.size, 1); + * ``` + * + * @returns The number of entries in the heap. + */ + get size(): number { + return this.#data.length; + } + + /** + * Remove all entries from the heap. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 1); + * heap.push("b", 2); + * heap.clear(); + * + * assertEquals(heap.size, 0); + * assertEquals(heap.isEmpty(), true); + * ``` + */ + clear(): void { + this.#data = []; + this.#index = new Map(); + } + + /** + * Check whether the heap is empty. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * assertEquals(heap.isEmpty(), true); + * + * heap.push("a", 1); + * assertEquals(heap.isEmpty(), false); + * ``` + * + * @returns `true` if the heap is empty, `false` otherwise. + */ + isEmpty(): boolean { + return this.#data.length === 0; + } + + /** + * Create an iterator that removes and yields every entry from + * smallest to largest priority. The heap is empty when the iterator + * finishes. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 3); + * heap.push("b", 1); + * heap.push("c", 2); + * + * assertEquals([...heap.drain()], [ + * { key: "b", priority: 1 }, + * { key: "c", priority: 2 }, + * { key: "a", priority: 3 }, + * ]); + * assertEquals(heap.size, 0); + * ``` + * + * @returns An iterator yielding entries from smallest to largest priority. + */ + *drain(): IterableIterator> { + while (!this.isEmpty()) { + yield this.pop() as HeapEntry; + } + } + + /** + * Create an iterator that removes and yields every entry from + * smallest to largest priority. The heap is empty afterwards. + * + * This has the same behavior as + * {@linkcode IndexedHeap.prototype.drain | drain}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 3); + * heap.push("b", 1); + * heap.push("c", 2); + * + * assertEquals([...heap], [ + * { key: "b", priority: 1 }, + * { key: "c", priority: 2 }, + * { key: "a", priority: 3 }, + * ]); + * assertEquals([...heap], []); + * ``` + * + * @returns An iterator yielding entries from smallest to largest priority. + */ + *[Symbol.iterator](): IterableIterator> { + yield* this.drain(); + } +} diff --git a/data_structures/unstable_indexed_heap_test.ts b/data_structures/unstable_indexed_heap_test.ts new file mode 100644 index 000000000000..665d5e91d8de --- /dev/null +++ b/data_structures/unstable_indexed_heap_test.ts @@ -0,0 +1,525 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals, assertThrows } from "@std/assert"; +import { + type HeapEntry, + IndexedHeap, + type ReadonlyIndexedHeap, +} from "./unstable_indexed_heap.ts"; + +Deno.test("IndexedHeap push / pop / peek with ascending priorities", () => { + const heap = new IndexedHeap(); + + assertEquals(heap.size, 0); + assertEquals(heap.isEmpty(), true); + assertEquals(heap.peek(), undefined); + assertEquals(heap.pop(), undefined); + + for ( + const [key, priority] of [["d", 4], ["b", 2], ["e", 5], ["a", 1], [ + "c", + 3, + ]] as const + ) { + heap.push(key, priority); + } + + assertEquals(heap.size, 5); + assertEquals(heap.isEmpty(), false); + assertEquals(heap.peek(), { key: "a", priority: 1 }); + + const popped: HeapEntry[] = []; + while (!heap.isEmpty()) { + popped.push(heap.pop()!); + } + assertEquals(popped, [ + { key: "a", priority: 1 }, + { key: "b", priority: 2 }, + { key: "c", priority: 3 }, + { key: "d", priority: 4 }, + { key: "e", priority: 5 }, + ]); + assertEquals(heap.size, 0); + assertEquals(heap.peek(), undefined); + assertEquals(heap.pop(), undefined); +}); + +Deno.test("IndexedHeap push throws on duplicate key", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + assertThrows( + () => heap.push("a", 2), + Error, + "Cannot push into IndexedHeap: key already exists", + ); +}); + +Deno.test("IndexedHeap delete root", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + heap.push("b", 2); + heap.push("c", 3); + + assertEquals(heap.delete("a"), true); + assertEquals(heap.size, 2); + assertEquals(heap.has("a"), false); + assertEquals(heap.peek(), { key: "b", priority: 2 }); + + assertEquals([...heap], [ + { key: "b", priority: 2 }, + { key: "c", priority: 3 }, + ]); +}); + +Deno.test("IndexedHeap delete middle element triggers sift-down", () => { + // Heap shape (array order): a=1, b=5, c=3, d=10, e=8 + // a(1) + // / \ + // b(5) c(3) + // / \ + // d(10) e(8) + // Deleting "c" (index 2) moves "e" (priority 8) into index 2. + // 8 > children? No children at that index, so it stays — but "c" is gone. + // Deleting "b" (index 1) moves last element into index 1 and sifts down. + const heap = new IndexedHeap(); + heap.push("a", 1); + heap.push("b", 5); + heap.push("c", 3); + heap.push("d", 10); + heap.push("e", 8); + + heap.delete("b"); + assertEquals(heap.size, 4); + assertEquals(heap.has("b"), false); + + const result = [...heap]; + assertEquals(result, [ + { key: "a", priority: 1 }, + { key: "c", priority: 3 }, + { key: "e", priority: 8 }, + { key: "d", priority: 10 }, + ]); +}); + +Deno.test("IndexedHeap delete last array element (no sift needed)", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + heap.push("b", 2); + + assertEquals(heap.delete("b"), true); + assertEquals(heap.size, 1); + assertEquals(heap.peek(), { key: "a", priority: 1 }); +}); + +Deno.test("IndexedHeap delete and getPriority for non-existent key", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + assertEquals(heap.delete("z"), false); + assertEquals(heap.getPriority("z"), undefined); + assertEquals(heap.size, 1); +}); + +Deno.test("IndexedHeap delete only element", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + assertEquals(heap.delete("a"), true); + assertEquals(heap.size, 0); + assertEquals(heap.isEmpty(), true); + assertEquals(heap.peek(), undefined); +}); + +Deno.test("IndexedHeap delete triggers sift-up when replacement is smaller", () => { + const heap = new IndexedHeap(); + heap.push("a", 10); + heap.push("b", 20); + heap.push("c", 15); + heap.push("d", 25); + heap.push("e", 30); + heap.push("f", 5); + + // Deleting "b" (priority 20): last element "f" (priority 5) replaces it + // and must sift up past "a" (priority 10) to become the new root. + heap.delete("b"); + + assertEquals(heap.peek(), { key: "f", priority: 5 }); + const result = [...heap]; + for (let i = 1; i < result.length; i++) { + if (result[i]!.priority < result[i - 1]!.priority) { + throw new Error("Not sorted after delete-triggered sift-up"); + } + } +}); + +Deno.test("IndexedHeap update decrease-key bubbles up", () => { + const heap = new IndexedHeap(); + heap.push("a", 10); + heap.push("b", 20); + heap.push("c", 30); + + heap.update("c", 1); + assertEquals(heap.peek(), { key: "c", priority: 1 }); + assertEquals(heap.getPriority("c"), 1); +}); + +Deno.test("IndexedHeap update increase-key bubbles down", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + heap.push("b", 2); + heap.push("c", 3); + + heap.update("a", 100); + assertEquals(heap.peek(), { key: "b", priority: 2 }); + + assertEquals([...heap], [ + { key: "b", priority: 2 }, + { key: "c", priority: 3 }, + { key: "a", priority: 100 }, + ]); +}); + +Deno.test("IndexedHeap update throws for non-existent key", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + assertThrows( + () => heap.update("z", 5), + Error, + "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); + assertEquals(heap.size, 1); + assertEquals(heap.getPriority("a"), 10); + + heap.pushOrUpdate("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", () => { + const heap = new IndexedHeap(); + heap.push("a", 10); + heap.push("b", 20); + heap.push("c", 30); + + heap.pushOrUpdate("c", 1); + assertEquals(heap.peek(), { key: "c", priority: 1 }); + + heap.pushOrUpdate("c", 50); + assertEquals(heap.peek(), { key: "a", priority: 10 }); + + assertEquals([...heap], [ + { key: "a", priority: 10 }, + { key: "b", priority: 20 }, + { key: "c", priority: 50 }, + ]); +}); + +Deno.test("IndexedHeap size tracks push, pop, delete, clear", () => { + const heap = new IndexedHeap(); + assertEquals(heap.size, 0); + + heap.push("a", 1); + assertEquals(heap.size, 1); + + heap.push("b", 2); + assertEquals(heap.size, 2); + + heap.pop(); + assertEquals(heap.size, 1); + + heap.push("c", 3); + heap.delete("b"); + assertEquals(heap.size, 1); + + heap.clear(); + assertEquals(heap.size, 0); +}); + +Deno.test("IndexedHeap iterator drains in ascending order", () => { + const heap = new IndexedHeap(); + heap.push("c", 3); + heap.push("a", 1); + heap.push("b", 2); + + assertEquals([...heap], [ + { key: "a", priority: 1 }, + { key: "b", priority: 2 }, + { key: "c", priority: 3 }, + ]); + assertEquals(heap.size, 0); + assertEquals([...heap], []); +}); + +Deno.test("IndexedHeap drain() yields in ascending order", () => { + const heap = new IndexedHeap(); + heap.push("x", 10); + heap.push("y", 5); + heap.push("z", 15); + + assertEquals([...heap.drain()], [ + { key: "y", priority: 5 }, + { key: "x", priority: 10 }, + { key: "z", priority: 15 }, + ]); + assertEquals(heap.size, 0); +}); + +Deno.test("IndexedHeap drain() on empty heap yields nothing", () => { + const heap = new IndexedHeap(); + assertEquals([...heap.drain()], []); +}); + +Deno.test("IndexedHeap peek returns a copy, not a reference", () => { + const heap = new IndexedHeap(); + heap.push("a", 10); + + const peeked = heap.peek()!; + (peeked as { priority: number }).priority = 999; + + assertEquals(heap.peek()!.priority, 10); +}); + +Deno.test("IndexedHeap is assignable to ReadonlyIndexedHeap", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + heap.push("b", 2); + + const ro: ReadonlyIndexedHeap = heap; + assertEquals(ro.peek(), { key: "a", priority: 1 }); + assertEquals(ro.has("a"), true); + assertEquals(ro.has("z"), false); + assertEquals(ro.getPriority("a"), 1); + assertEquals(ro.getPriority("z"), undefined); + assertEquals(ro.size, 2); + assertEquals(ro.isEmpty(), false); + + assertEquals(heap.size, 2, "heap unchanged after readonly queries"); +}); + +Deno.test("IndexedHeap handles duplicate priorities", () => { + const heap = new IndexedHeap(); + heap.push("a", 5); + heap.push("b", 5); + heap.push("c", 5); + + assertEquals(heap.size, 3); + const results: HeapEntry[] = []; + while (!heap.isEmpty()) { + results.push(heap.pop()!); + } + assertEquals(results.length, 3); + for (const entry of results) { + assertEquals(entry.priority, 5); + } + assertEquals(results.map((e) => e.key).sort(), ["a", "b", "c"]); +}); + +Deno.test("IndexedHeap with object keys uses reference identity", () => { + const keyA = { id: "a" }; + const keyB = { id: "b" }; + const keyADuplicate = { id: "a" }; + + const heap = new IndexedHeap<{ id: string }>(); + heap.push(keyA, 1); + heap.push(keyB, 2); + heap.push(keyADuplicate, 3); + + assertEquals(heap.size, 3); + assertEquals(heap.has(keyA), true); + assertEquals(heap.has(keyADuplicate), true); + + assertEquals(heap.getPriority(keyA), 1); + assertEquals(heap.getPriority(keyADuplicate), 3); +}); + +Deno.test("IndexedHeap handles negative priorities", () => { + const heap = new IndexedHeap(); + heap.push("a", -10); + heap.push("b", -5); + heap.push("c", 0); + heap.push("d", 5); + + assertEquals([...heap], [ + { key: "a", priority: -10 }, + { key: "b", priority: -5 }, + { key: "c", priority: 0 }, + { key: "d", priority: 5 }, + ]); +}); + +Deno.test("IndexedHeap handles Infinity and -Infinity priorities", () => { + const heap = new IndexedHeap(); + heap.push("pos", Infinity); + heap.push("neg", -Infinity); + heap.push("zero", 0); + + assertEquals([...heap], [ + { key: "neg", priority: -Infinity }, + { key: "zero", priority: 0 }, + { key: "pos", priority: Infinity }, + ]); +}); + +Deno.test("IndexedHeap works correctly after clear and reuse", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + heap.push("b", 2); + heap.clear(); + + heap.push("c", 30); + heap.push("d", 10); + heap.push("e", 20); + + assertEquals(heap.size, 3); + assertEquals(heap.peek(), { key: "d", priority: 10 }); + assertEquals(heap.has("a"), false); + assertEquals(heap.has("d"), true); + + assertEquals([...heap], [ + { key: "d", priority: 10 }, + { key: "e", priority: 20 }, + { key: "c", priority: 30 }, + ]); +}); + +Deno.test("IndexedHeap interleaved push, pop, update, delete", () => { + const heap = new IndexedHeap(); + heap.push("a", 10); + heap.push("b", 20); + heap.push("c", 30); + + assertEquals(heap.pop(), { key: "a", priority: 10 }); + + heap.push("d", 5); + heap.update("c", 1); + + assertEquals(heap.peek(), { key: "c", priority: 1 }); + + heap.delete("b"); + heap.push("e", 3); + + assertEquals(heap.size, 3); + assertEquals([...heap], [ + { key: "c", priority: 1 }, + { key: "e", priority: 3 }, + { key: "d", priority: 5 }, + ]); +}); + +Deno.test("IndexedHeap pop with two elements exercises general path", () => { + const heap = new IndexedHeap(); + heap.push("big", 100); + heap.push("small", 1); + + // pop() with size=2 takes the general path: move last to root, sift-down + assertEquals(heap.pop(), { key: "small", priority: 1 }); + assertEquals(heap.size, 1); + assertEquals(heap.pop(), { key: "big", priority: 100 }); +}); + +Deno.test("IndexedHeap push throws on NaN priority", () => { + const heap = new IndexedHeap(); + assertThrows( + () => heap.push("a", NaN), + RangeError, + "Cannot set priority: value is NaN", + ); + assertEquals(heap.size, 0); +}); + +Deno.test("IndexedHeap update throws on NaN priority", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + assertThrows( + () => heap.update("a", NaN), + RangeError, + "Cannot set priority: value is NaN", + ); + assertEquals(heap.getPriority("a"), 1); +}); + +Deno.test("IndexedHeap pushOrUpdate throws on NaN priority", () => { + const heap = new IndexedHeap(); + assertThrows( + () => heap.pushOrUpdate("a", NaN), + RangeError, + "Cannot set priority: value is NaN", + ); + assertEquals(heap.size, 0); + + heap.push("b", 1); + assertThrows( + () => heap.pushOrUpdate("b", NaN), + RangeError, + "Cannot set priority: value is NaN", + ); + assertEquals(heap.getPriority("b"), 1); +}); + +Deno.test("IndexedHeap stress test: push N, pop all, verify sorted", () => { + const heap = new IndexedHeap(); + const n = 200; + const priorities: number[] = []; + for (let i = 0; i < n; i++) { + const p = Math.floor(Math.random() * 10000); + priorities.push(p); + heap.push(i, p); + } + + assertEquals(heap.size, n); + + const popped: number[] = []; + while (!heap.isEmpty()) { + popped.push(heap.pop()!.priority); + } + + for (let i = 1; i < popped.length; i++) { + if (popped[i]! < popped[i - 1]!) { + throw new Error( + `Heap invariant violated: ${popped[i - 1]} > ${ + popped[i] + } at index ${i}`, + ); + } + } + assertEquals(popped.length, n); +}); + +Deno.test("IndexedHeap stress test: push N, delete random subset, pop rest", () => { + const heap = new IndexedHeap(); + const n = 200; + for (let i = 0; i < n; i++) { + heap.push(i, Math.floor(Math.random() * 10000)); + } + + const toDelete = new Set(); + for (let i = 0; i < n / 2; i++) { + const key = Math.floor(Math.random() * n); + if (!toDelete.has(key)) { + toDelete.add(key); + heap.delete(key); + } + } + + const remaining = n - toDelete.size; + assertEquals(heap.size, remaining); + + const popped: number[] = []; + while (!heap.isEmpty()) { + popped.push(heap.pop()!.priority); + } + + for (let i = 1; i < popped.length; i++) { + if (popped[i]! < popped[i - 1]!) { + throw new Error( + `Heap invariant violated after deletes: ${popped[i - 1]} > ${ + popped[i] + } at index ${i}`, + ); + } + } + assertEquals(popped.length, remaining); +}); From ef5a8c52ec5efc46ea801411e0801a8a2a037423 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Wed, 1 Apr 2026 20:02:31 +0200 Subject: [PATCH 03/10] fix doc --- data_structures/unstable_indexed_heap.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data_structures/unstable_indexed_heap.ts b/data_structures/unstable_indexed_heap.ts index 36e486355539..dbe1fb47e6ae 100644 --- a/data_structures/unstable_indexed_heap.ts +++ b/data_structures/unstable_indexed_heap.ts @@ -16,7 +16,9 @@ interface MutableEntry { * @typeParam K The type of the key. */ 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; } From 4ea11e6822aebc75a56b7c4b8698c21f0c1c4960 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Fri, 3 Apr 2026 18:17:25 +0200 Subject: [PATCH 04/10] perf improvements --- data_structures/unstable_indexed_heap.ts | 43 +++++++++----- data_structures/unstable_indexed_heap_test.ts | 58 ++++++++++++++----- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/data_structures/unstable_indexed_heap.ts b/data_structures/unstable_indexed_heap.ts index dbe1fb47e6ae..0cd1f4106b1a 100644 --- a/data_structures/unstable_indexed_heap.ts +++ b/data_structures/unstable_indexed_heap.ts @@ -51,11 +51,6 @@ function assertValidPriority(priority: number): void { } } -/** Returns the parent index for a given child index. */ -function getParentIndex(index: number): number { - return ((index + 1) >>> 1) - 1; -} - /** * A priority queue that supports looking up, removing, and re-prioritizing * entries by key. Each entry is a unique `(key, priority)` pair. The entry @@ -103,6 +98,22 @@ export class IndexedHeap implements Iterable> { #data: MutableEntry[] = []; #index: Map = new Map(); + /** + * A string tag for the class, used by `Object.prototype.toString()`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * assertEquals(heap[Symbol.toStringTag], "IndexedHeap"); + * ``` + */ + readonly [Symbol.toStringTag] = "IndexedHeap" as const; + /** Bubble the entry at `pos` up toward the root while it is smaller than its parent. */ #siftUp(pos: number): number { const data = this.#data; @@ -110,7 +121,7 @@ export class IndexedHeap implements Iterable> { const entry = data[pos]!; const priority = entry.priority; while (pos > 0) { - const parentPos = getParentIndex(pos); + const parentPos = (pos - 1) >>> 1; const parent = data[parentPos]!; if (priority < parent.priority) { data[pos] = parent; @@ -188,7 +199,6 @@ export class IndexedHeap implements Iterable> { } const pos = this.#data.length; this.#data.push({ key, priority }); - this.#index.set(key, pos); this.#siftUp(pos); } @@ -229,7 +239,6 @@ export class IndexedHeap implements Iterable> { const last = this.#data.pop()!; this.#data[0] = last; - this.#index.set(last.key, 0); this.#siftDown(0); return root; } @@ -299,7 +308,6 @@ export class IndexedHeap implements Iterable> { const last = this.#data.pop()!; this.#data[pos] = last; - this.#index.set(last.key, pos); const afterUp = this.#siftUp(pos); if (afterUp === pos) { @@ -372,10 +380,17 @@ export class IndexedHeap implements Iterable> { */ pushOrUpdate(key: K, priority: number): void { assertValidPriority(priority); - if (this.#index.has(key)) { - this.update(key, priority); + const pos = this.#index.get(key); + if (pos !== undefined) { + this.#data[pos]!.priority = priority; + const afterUp = this.#siftUp(pos); + if (afterUp === pos) { + this.#siftDown(pos); + } } else { - this.push(key, priority); + const newPos = this.#data.length; + this.#data.push({ key, priority }); + this.#siftUp(newPos); } } @@ -471,8 +486,8 @@ export class IndexedHeap implements Iterable> { * ``` */ clear(): void { - this.#data = []; - this.#index = new Map(); + this.#data.length = 0; + this.#index.clear(); } /** diff --git a/data_structures/unstable_indexed_heap_test.ts b/data_structures/unstable_indexed_heap_test.ts index 665d5e91d8de..554750b1e783 100644 --- a/data_structures/unstable_indexed_heap_test.ts +++ b/data_structures/unstable_indexed_heap_test.ts @@ -128,25 +128,45 @@ Deno.test("IndexedHeap delete only element", () => { }); Deno.test("IndexedHeap delete triggers sift-up when replacement is smaller", () => { + // Heap array: [r(1), a(50), b(3), c(51), d(52), e(4), f(5)] + // r(1) + // / \ + // a(50) b(3) + // / \ / \ + // c(51) d(52) e(4) f(5) + // + // Deleting "c" (index 3) moves last element "f" (priority 5) into index 3. + // Parent of index 3 is "a" (priority 50). Since 5 < 50, "f" sifts up. const heap = new IndexedHeap(); - heap.push("a", 10); - heap.push("b", 20); - heap.push("c", 15); - heap.push("d", 25); - heap.push("e", 30); - heap.push("f", 5); + for ( + const [key, priority] of [ + ["r", 1], + ["a", 50], + ["b", 3], + ["c", 51], + ["d", 52], + ["e", 4], + ["f", 5], + ] as const + ) { + heap.push(key, priority); + } - // Deleting "b" (priority 20): last element "f" (priority 5) replaces it - // and must sift up past "a" (priority 10) to become the new root. - heap.delete("b"); + heap.delete("c"); - assertEquals(heap.peek(), { key: "f", priority: 5 }); - const result = [...heap]; - for (let i = 1; i < result.length; i++) { - if (result[i]!.priority < result[i - 1]!.priority) { - throw new Error("Not sorted after delete-triggered sift-up"); - } - } + assertEquals(heap.peek(), { key: "r", priority: 1 }); + assertEquals(heap.has("c"), false); + assertEquals(heap.size, 6); + assertEquals(heap.getPriority("f"), 5); + + assertEquals([...heap], [ + { key: "r", priority: 1 }, + { key: "b", priority: 3 }, + { key: "e", priority: 4 }, + { key: "f", priority: 5 }, + { key: "a", priority: 50 }, + { key: "d", priority: 52 }, + ]); }); Deno.test("IndexedHeap update decrease-key bubbles up", () => { @@ -459,6 +479,12 @@ Deno.test("IndexedHeap pushOrUpdate throws on NaN priority", () => { 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 stress test: push N, pop all, verify sorted", () => { const heap = new IndexedHeap(); const n = 200; From 9c9d8be41d3b079054a4fe0edf59d2e8525f1a12 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sun, 5 Apr 2026 11:52:35 +0200 Subject: [PATCH 05/10] add peekKey and peekPriority --- data_structures/unstable_indexed_heap.ts | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/data_structures/unstable_indexed_heap.ts b/data_structures/unstable_indexed_heap.ts index 0cd1f4106b1a..88293b5f9817 100644 --- a/data_structures/unstable_indexed_heap.ts +++ b/data_structures/unstable_indexed_heap.ts @@ -38,6 +38,8 @@ export interface HeapEntry { export type ReadonlyIndexedHeap = Pick< IndexedHeap, | "peek" + | "peekKey" + | "peekPriority" | "has" | "getPriority" | "size" @@ -66,6 +68,8 @@ function assertValidPriority(priority: number): void { * | 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 | @@ -272,6 +276,60 @@ export class IndexedHeap implements Iterable> { return { key: entry.key, priority: entry.priority }; } + /** + * Return the key of the front entry (smallest priority), or `undefined` + * if the heap is empty. Unlike + * {@linkcode IndexedHeap.prototype.peek | peek}, does not allocate a + * wrapper object. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("x", 5); + * heap.push("y", 3); + * + * assertEquals(heap.peekKey(), "y"); + * assertEquals(heap.size, 2); + * ``` + * + * @returns The key of the front entry, or `undefined` if empty. + */ + peekKey(): K | undefined { + return this.#data[0]?.key; + } + + /** + * Return the priority of the front entry (smallest priority), or + * `undefined` if the heap is empty. Unlike + * {@linkcode IndexedHeap.prototype.peek | peek}, does not allocate a + * wrapper object. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("x", 5); + * heap.push("y", 3); + * + * assertEquals(heap.peekPriority(), 3); + * assertEquals(heap.size, 2); + * ``` + * + * @returns The priority of the front entry, or `undefined` if empty. + */ + peekPriority(): number | undefined { + return this.#data[0]?.priority; + } + /** * Remove the entry with the given key. * From 3c8ee237125db399dda3f7603cfac55974958d63 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 9 Apr 2026 19:28:33 +0200 Subject: [PATCH 06/10] feedback --- data_structures/unstable_indexed_heap.ts | 162 ++++++++++++--- data_structures/unstable_indexed_heap_test.ts | 194 +++++++++++++++--- 2 files changed, 306 insertions(+), 50 deletions(-) diff --git a/data_structures/unstable_indexed_heap.ts b/data_structures/unstable_indexed_heap.ts index 88293b5f9817..c69a2c512026 100644 --- a/data_structures/unstable_indexed_heap.ts +++ b/data_structures/unstable_indexed_heap.ts @@ -23,13 +23,9 @@ export interface HeapEntry { } /** - * Read-only view of an {@linkcode IndexedHeap}. Exposes only query methods - * (`peek`, `has`, `getPriority`, `size`, `isEmpty`), hiding all methods - * that modify the heap. Follows the same pattern as `ReadonlyMap` and - * `ReadonlySet`. - * - * Note: `[Symbol.iterator]` is intentionally excluded because the heap's - * iterator is destructive (it drains all entries). + * Read-only view of an {@linkcode IndexedHeap}. Exposes query and + * iteration methods, hiding all methods that modify the heap. Follows + * the same pattern as `ReadonlyMap` and `ReadonlySet`. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -44,6 +40,8 @@ export type ReadonlyIndexedHeap = Pick< | "getPriority" | "size" | "isEmpty" + | "toArray" + | typeof Symbol.iterator >; /** Throws if the priority is NaN, which would silently corrupt the heap. */ @@ -76,6 +74,12 @@ function assertValidPriority(priority: number): void { * | update(key, priority) | Logarithmic in the number of entries | * | has(key) | Constant | * | getPriority(key) | Constant | + * | toArray() | 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}. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -91,7 +95,12 @@ function assertValidPriority(priority: number): void { * * assertEquals(heap.peek(), { key: "b", priority: 1 }); * assertEquals(heap.pop(), { key: "b", priority: 1 }); - * assertEquals([...heap], [{ key: "c", priority: 2 }, { key: "a", priority: 3 }]); + * assertEquals(heap.size, 2); + * + * assertEquals([...heap.drain()], [ + * { key: "c", priority: 2 }, + * { key: "a", priority: 3 }, + * ]); * ``` * * @typeParam K The type of the keys in the heap. Keys are compared the @@ -118,6 +127,76 @@ export class IndexedHeap implements Iterable> { */ readonly [Symbol.toStringTag] = "IndexedHeap" as const; + /** + * Create a new {@linkcode IndexedHeap} from an iterable of key-priority + * pairs, an array-like of key-priority pairs, or an existing + * {@linkcode IndexedHeap}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Creating from an array of pairs + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = IndexedHeap.from([["a", 3], ["b", 1], ["c", 2]]); + * assertEquals(heap.peek(), { key: "b", priority: 1 }); + * assertEquals(heap.size, 3); + * ``` + * + * @example Creating from another IndexedHeap (shallow copy) + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const original = new IndexedHeap(); + * original.push("x", 10); + * original.push("y", 5); + * + * const copy = IndexedHeap.from(original); + * assertEquals(copy.peek(), { key: "y", priority: 5 }); + * assertEquals(copy.size, 2); + * assertEquals(original.size, 2); + * ``` + * + * @example Creating from a Map (iterable of [key, value] pairs) + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new Map([["task-a", 3], ["task-b", 1]]); + * const heap = IndexedHeap.from(map); + * assertEquals(heap.peek(), { key: "task-b", priority: 1 }); + * ``` + * + * @typeParam K The type of the keys in the heap. + * @param collection An iterable or array-like of `[key, priority]` pairs, + * or an existing {@linkcode IndexedHeap} to copy. + * @returns A new heap containing all entries from the collection. + */ + static from( + collection: + | IndexedHeap + | Iterable + | ArrayLike, + ): IndexedHeap { + const heap = new IndexedHeap(); + if (collection instanceof IndexedHeap) { + for (const entry of collection.#data) { + heap.#data.push({ key: entry.key, priority: entry.priority }); + } + heap.#index = new Map(collection.#index); + return heap; + } + const entries = "length" in collection + ? Array.from(collection) + : collection; + for (const [key, priority] of entries) { + heap.push(key, priority); + } + 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; @@ -236,15 +315,14 @@ export class IndexedHeap implements Iterable> { const root = this.#data[0]!; this.#index.delete(root.key); - if (size === 1) { + if (size > 1) { + const last = this.#data.pop()!; + this.#data[0] = last; + this.#siftDown(0); + } else { this.#data.pop(); - return root; } - - const last = this.#data.pop()!; - this.#data[0] = last; - this.#siftDown(0); - return root; + return { key: root.key, priority: root.priority }; } /** @@ -605,11 +683,12 @@ export class IndexedHeap implements Iterable> { } /** - * Create an iterator that removes and yields every entry from - * smallest to largest priority. The heap is empty afterwards. + * Return a shallow copy of all entries as an array. The order is the + * internal heap-array order (not sorted by priority). The heap is not + * modified. * - * This has the same behavior as - * {@linkcode IndexedHeap.prototype.drain | drain}. + * Use {@linkcode IndexedHeap.prototype.drain | drain} to retrieve entries + * in sorted (smallest-first) order. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -623,17 +702,46 @@ export class IndexedHeap implements Iterable> { * heap.push("b", 1); * heap.push("c", 2); * - * assertEquals([...heap], [ - * { key: "b", priority: 1 }, - * { key: "c", priority: 2 }, - * { key: "a", priority: 3 }, - * ]); - * assertEquals([...heap], []); + * const arr = heap.toArray(); + * assertEquals(arr.length, 3); + * assertEquals(heap.size, 3); * ``` * - * @returns An iterator yielding entries from smallest to largest priority. + * @returns An array of entries in arbitrary (heap-internal) order. + */ + 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. + * + * Use {@linkcode IndexedHeap.prototype.drain | drain} to iterate in + * sorted (smallest-first) order (which empties the heap). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap"; + * import { assertEquals } from "@std/assert"; + * + * const heap = new IndexedHeap(); + * heap.push("a", 3); + * heap.push("b", 1); + * heap.push("c", 2); + * + * const keys = [...heap].map((e) => e.key); + * assertEquals(keys.length, 3); + * assertEquals(heap.size, 3); + * ``` + * + * @returns An iterator yielding entries in arbitrary (heap-internal) order. */ *[Symbol.iterator](): IterableIterator> { - yield* this.drain(); + 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 554750b1e783..1d31434e9ca7 100644 --- a/data_structures/unstable_indexed_heap_test.ts +++ b/data_structures/unstable_indexed_heap_test.ts @@ -64,7 +64,7 @@ Deno.test("IndexedHeap delete root", () => { assertEquals(heap.has("a"), false); assertEquals(heap.peek(), { key: "b", priority: 2 }); - assertEquals([...heap], [ + assertEquals([...heap.drain()], [ { key: "b", priority: 2 }, { key: "c", priority: 3 }, ]); @@ -91,7 +91,7 @@ Deno.test("IndexedHeap delete middle element triggers sift-down", () => { assertEquals(heap.size, 4); assertEquals(heap.has("b"), false); - const result = [...heap]; + const result = [...heap.drain()]; assertEquals(result, [ { key: "a", priority: 1 }, { key: "c", priority: 3 }, @@ -159,7 +159,7 @@ Deno.test("IndexedHeap delete triggers sift-up when replacement is smaller", () assertEquals(heap.size, 6); assertEquals(heap.getPriority("f"), 5); - assertEquals([...heap], [ + assertEquals([...heap.drain()], [ { key: "r", priority: 1 }, { key: "b", priority: 3 }, { key: "e", priority: 4 }, @@ -189,7 +189,7 @@ Deno.test("IndexedHeap update increase-key bubbles down", () => { heap.update("a", 100); assertEquals(heap.peek(), { key: "b", priority: 2 }); - assertEquals([...heap], [ + assertEquals([...heap.drain()], [ { key: "b", priority: 2 }, { key: "c", priority: 3 }, { key: "a", priority: 100 }, @@ -230,7 +230,7 @@ Deno.test("IndexedHeap pushOrUpdate decrease then increase same key", () => { heap.pushOrUpdate("c", 50); assertEquals(heap.peek(), { key: "a", priority: 10 }); - assertEquals([...heap], [ + assertEquals([...heap.drain()], [ { key: "a", priority: 10 }, { key: "b", priority: 20 }, { key: "c", priority: 50 }, @@ -258,19 +258,21 @@ Deno.test("IndexedHeap size tracks push, pop, delete, clear", () => { assertEquals(heap.size, 0); }); -Deno.test("IndexedHeap iterator drains in ascending order", () => { +Deno.test("IndexedHeap iterator is non-destructive", () => { const heap = new IndexedHeap(); heap.push("c", 3); heap.push("a", 1); heap.push("b", 2); - assertEquals([...heap], [ - { key: "a", priority: 1 }, - { key: "b", priority: 2 }, - { key: "c", priority: 3 }, - ]); - assertEquals(heap.size, 0); - assertEquals([...heap], []); + const first = [...heap]; + assertEquals(first.length, 3); + assertEquals(heap.size, 3, "heap not modified by iteration"); + + const second = [...heap]; + assertEquals(second, first, "iterating again yields same entries"); + + const keys = first.map((e) => e.key).sort(); + assertEquals(keys, ["a", "b", "c"]); }); Deno.test("IndexedHeap drain() yields in ascending order", () => { @@ -292,6 +294,24 @@ Deno.test("IndexedHeap drain() on empty heap yields nothing", () => { assertEquals([...heap.drain()], []); }); +Deno.test("IndexedHeap peekKey() returns the key of the front entry", () => { + const heap = new IndexedHeap(); + assertEquals(heap.peekKey(), undefined); + + heap.push("a", 5); + heap.push("b", 1); + assertEquals(heap.peekKey(), "b"); +}); + +Deno.test("IndexedHeap peekPriority() returns the priority of the front entry", () => { + const heap = new IndexedHeap(); + assertEquals(heap.peekPriority(), undefined); + + heap.push("a", 5); + heap.push("b", 1); + assertEquals(heap.peekPriority(), 1); +}); + Deno.test("IndexedHeap peek returns a copy, not a reference", () => { const heap = new IndexedHeap(); heap.push("a", 10); @@ -316,6 +336,9 @@ Deno.test("IndexedHeap is assignable to ReadonlyIndexedHeap", () => { assertEquals(ro.size, 2); assertEquals(ro.isEmpty(), false); + assertEquals(ro.toArray().length, 2); + assertEquals([...ro].length, 2); + assertEquals(heap.size, 2, "heap unchanged after readonly queries"); }); @@ -362,7 +385,7 @@ Deno.test("IndexedHeap handles negative priorities", () => { heap.push("c", 0); heap.push("d", 5); - assertEquals([...heap], [ + assertEquals([...heap.drain()], [ { key: "a", priority: -10 }, { key: "b", priority: -5 }, { key: "c", priority: 0 }, @@ -376,7 +399,7 @@ Deno.test("IndexedHeap handles Infinity and -Infinity priorities", () => { heap.push("neg", -Infinity); heap.push("zero", 0); - assertEquals([...heap], [ + assertEquals([...heap.drain()], [ { key: "neg", priority: -Infinity }, { key: "zero", priority: 0 }, { key: "pos", priority: Infinity }, @@ -398,7 +421,7 @@ Deno.test("IndexedHeap works correctly after clear and reuse", () => { assertEquals(heap.has("a"), false); assertEquals(heap.has("d"), true); - assertEquals([...heap], [ + assertEquals([...heap.drain()], [ { key: "d", priority: 10 }, { key: "e", priority: 20 }, { key: "c", priority: 30 }, @@ -422,7 +445,7 @@ Deno.test("IndexedHeap interleaved push, pop, update, delete", () => { heap.push("e", 3); assertEquals(heap.size, 3); - assertEquals([...heap], [ + assertEquals([...heap.drain()], [ { key: "c", priority: 1 }, { key: "e", priority: 3 }, { key: "d", priority: 5 }, @@ -485,14 +508,138 @@ Deno.test("IndexedHeap has correct Symbol.toStringTag", () => { assertEquals(Object.prototype.toString.call(heap), "[object IndexedHeap]"); }); +Deno.test("IndexedHeap.from() creates heap from array of pairs", () => { + const heap = IndexedHeap.from([["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.from() creates heap from another IndexedHeap", () => { + const original = new IndexedHeap(); + original.push("x", 10); + original.push("y", 5); + original.push("z", 1); + + const copy = IndexedHeap.from(original); + assertEquals(copy.size, 3); + assertEquals(copy.peek(), { key: "z", priority: 1 }); + + assertEquals(original.size, 3, "original unchanged"); + + copy.update("z", 100); + assertEquals(original.getPriority("z"), 1, "original not affected by copy"); +}); + +Deno.test("IndexedHeap.from() creates heap from Map entries", () => { + const map = new Map([["task-a", 3], ["task-b", 1], ["task-c", 2]]); + const heap = IndexedHeap.from(map); + assertEquals(heap.size, 3); + assertEquals(heap.peek(), { key: "task-b", priority: 1 }); +}); + +Deno.test("IndexedHeap.from() creates heap from generator", () => { + function* pairs(): Iterable<[string, number]> { + yield ["a", 5]; + yield ["b", 2]; + yield ["c", 8]; + } + const heap = IndexedHeap.from(pairs()); + assertEquals(heap.size, 3); + assertEquals(heap.peek(), { key: "b", priority: 2 }); +}); + +Deno.test("IndexedHeap.from() creates empty heap from empty iterable", () => { + const heap = IndexedHeap.from([]); + assertEquals(heap.size, 0); + assertEquals(heap.isEmpty(), true); +}); + +Deno.test("IndexedHeap.from() throws on duplicate keys", () => { + assertThrows( + () => IndexedHeap.from([["a", 1], ["a", 2]]), + Error, + "Cannot push into IndexedHeap: key already exists", + ); +}); + +Deno.test("IndexedHeap toArray() returns all entries without modifying heap", () => { + const heap = new IndexedHeap(); + heap.push("a", 3); + heap.push("b", 1); + heap.push("c", 2); + + const arr = heap.toArray(); + assertEquals(arr.length, 3); + assertEquals(heap.size, 3, "heap not modified"); + + const keys = arr.map((e) => e.key).sort(); + assertEquals(keys, ["a", "b", "c"]); + + const priorities = arr.map((e) => e.priority).sort(); + assertEquals(priorities, [1, 2, 3]); +}); + +Deno.test("IndexedHeap toArray() returns empty array for empty heap", () => { + const heap = new IndexedHeap(); + assertEquals(heap.toArray(), []); +}); + +Deno.test("IndexedHeap toArray() returns defensive copies", () => { + const heap = new IndexedHeap(); + heap.push("a", 10); + + const arr = heap.toArray(); + (arr[0] as { priority: number }).priority = 999; + + assertEquals(heap.getPriority("a"), 10, "heap not affected by mutation"); +}); + +Deno.test("IndexedHeap iterator returns defensive copies", () => { + const heap = new IndexedHeap(); + heap.push("a", 10); + + for (const entry of heap) { + (entry as { priority: number }).priority = 999; + } + + assertEquals(heap.getPriority("a"), 10, "heap not affected by mutation"); +}); + +Deno.test("IndexedHeap iterator works with for-of", () => { + const heap = new IndexedHeap(); + heap.push("x", 5); + heap.push("y", 3); + + const collected: HeapEntry[] = []; + for (const entry of heap) { + collected.push(entry); + } + assertEquals(collected.length, 2); + assertEquals(heap.size, 2, "heap not modified by for-of"); +}); + +/** Mulberry32: deterministic 32-bit PRNG for reproducible stress tests. */ +function mulberry32(seed: number): () => number { + return () => { + seed |= 0; + seed = (seed + 0x6D2B79F5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + Deno.test("IndexedHeap stress test: push N, pop all, verify sorted", () => { + const rand = mulberry32(42); const heap = new IndexedHeap(); const n = 200; - const priorities: number[] = []; for (let i = 0; i < n; i++) { - const p = Math.floor(Math.random() * 10000); - priorities.push(p); - heap.push(i, p); + heap.push(i, Math.floor(rand() * 10000)); } assertEquals(heap.size, n); @@ -515,15 +662,16 @@ Deno.test("IndexedHeap stress test: push N, pop all, verify sorted", () => { }); Deno.test("IndexedHeap stress test: push N, delete random subset, pop rest", () => { + const rand = mulberry32(123); const heap = new IndexedHeap(); const n = 200; for (let i = 0; i < n; i++) { - heap.push(i, Math.floor(Math.random() * 10000)); + heap.push(i, Math.floor(rand() * 10000)); } const toDelete = new Set(); for (let i = 0; i < n / 2; i++) { - const key = Math.floor(Math.random() * n); + const key = Math.floor(rand() * n); if (!toDelete.has(key)) { toDelete.add(key); heap.delete(key); From 9703ccf6cb37465e29811b696ec20153dd07cdc6 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Fri, 10 Apr 2026 21:51:29 +0200 Subject: [PATCH 07/10] fix --- data_structures/unstable_indexed_heap.ts | 22 ++++-- data_structures/unstable_indexed_heap_test.ts | 69 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/data_structures/unstable_indexed_heap.ts b/data_structures/unstable_indexed_heap.ts index c69a2c512026..df14cf3c53cc 100644 --- a/data_structures/unstable_indexed_heap.ts +++ b/data_structures/unstable_indexed_heap.ts @@ -188,11 +188,20 @@ export class IndexedHeap implements Iterable> { heap.#index = new Map(collection.#index); return heap; } - const entries = "length" in collection - ? Array.from(collection) - : collection; - for (const [key, priority] of entries) { - heap.push(key, priority); + const entries = Array.from(collection); + for (let i = 0; i < entries.length; i++) { + const [key, priority] = entries[i]!; + assertValidPriority(priority); + if (heap.#index.has(key)) { + throw new Error( + `Cannot push into IndexedHeap: key already exists`, + ); + } + heap.#data.push({ key, priority }); + heap.#index.set(key, i); + } + for (let i = (heap.#data.length >>> 1) - 1; i >= 0; i--) { + heap.#siftDown(i); } return heap; } @@ -652,7 +661,8 @@ export class IndexedHeap implements Iterable> { /** * Create an iterator that removes and yields every entry from * smallest to largest priority. The heap is empty when the iterator - * finishes. + * finishes. If iteration is abandoned early (e.g. via `break`), + * the heap retains the remaining entries. * * @experimental **UNSTABLE**: New API, yet to be vetted. * diff --git a/data_structures/unstable_indexed_heap_test.ts b/data_structures/unstable_indexed_heap_test.ts index 1d31434e9ca7..c927931a5554 100644 --- a/data_structures/unstable_indexed_heap_test.ts +++ b/data_structures/unstable_indexed_heap_test.ts @@ -294,6 +294,32 @@ Deno.test("IndexedHeap drain() on empty heap yields nothing", () => { assertEquals([...heap.drain()], []); }); +Deno.test("IndexedHeap drain() retains remaining entries on early break", () => { + const heap = new IndexedHeap(); + heap.push("a", 1); + heap.push("b", 2); + heap.push("c", 3); + heap.push("d", 4); + + const drained: HeapEntry[] = []; + for (const entry of heap.drain()) { + drained.push(entry); + if (entry.key === "b") break; + } + + assertEquals(drained, [ + { key: "a", priority: 1 }, + { key: "b", priority: 2 }, + ]); + assertEquals(heap.size, 2); + assertEquals(heap.has("c"), true); + assertEquals(heap.has("d"), true); + assertEquals([...heap.drain()], [ + { key: "c", priority: 3 }, + { key: "d", priority: 4 }, + ]); +}); + Deno.test("IndexedHeap peekKey() returns the key of the front entry", () => { const heap = new IndexedHeap(); assertEquals(heap.peekKey(), undefined); @@ -634,6 +660,49 @@ function mulberry32(seed: number): () => number { }; } +Deno.test("IndexedHeap.from() stress test: heapify + index consistency", () => { + const rand = mulberry32(99); + const n = 300; + const pairs: [number, number][] = []; + const expected = new Map(); + for (let i = 0; i < n; i++) { + const priority = Math.floor(rand() * 10000); + pairs.push([i, priority]); + expected.set(i, priority); + } + + const heap = IndexedHeap.from(pairs); + assertEquals(heap.size, n); + + for (const [key, priority] of expected) { + assertEquals(heap.has(key), true); + assertEquals(heap.getPriority(key), priority); + } + + heap.update(0, -1); + assertEquals(heap.peek(), { key: 0, priority: -1 }); + heap.update(0, expected.get(0)!); + + heap.delete(n - 1); + assertEquals(heap.has(n - 1), false); + assertEquals(heap.size, n - 1); + + const popped: number[] = []; + while (!heap.isEmpty()) { + popped.push(heap.pop()!.priority); + } + for (let i = 1; i < popped.length; i++) { + if (popped[i]! < popped[i - 1]!) { + throw new Error( + `Heap invariant violated after from(): ${popped[i - 1]} > ${ + popped[i] + } at index ${i}`, + ); + } + } + assertEquals(popped.length, n - 1); +}); + Deno.test("IndexedHeap stress test: push N, pop all, verify sorted", () => { const rand = mulberry32(42); const heap = new IndexedHeap(); From 04058246a7cfb483c9a4c63f7ae7c87332a10d5b Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans <113360400+tomas-zijdemans@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:10:37 +0200 Subject: [PATCH 08/10] feat(crypto): stabilize AES-GCM encryption (#7089) --- crypto/{unstable_aes_gcm.ts => aes_gcm.ts} | 7 ++++--- ...stable_aes_gcm_test.ts => aes_gcm_test.ts} | 20 ++++++++++++++++++- crypto/deno.json | 2 +- crypto/mod.ts | 1 + 4 files changed, 25 insertions(+), 5 deletions(-) rename crypto/{unstable_aes_gcm.ts => aes_gcm.ts} (95%) rename crypto/{unstable_aes_gcm_test.ts => aes_gcm_test.ts} (89%) diff --git a/crypto/unstable_aes_gcm.ts b/crypto/aes_gcm.ts similarity index 95% rename from crypto/unstable_aes_gcm.ts rename to crypto/aes_gcm.ts index e665bf9a4874..f0c20451431c 100644 --- a/crypto/unstable_aes_gcm.ts +++ b/crypto/aes_gcm.ts @@ -12,7 +12,7 @@ export type { Uint8Array_ }; * * @example Usage * ```ts - * import { encryptAesGcm, decryptAesGcm } from "@std/crypto/unstable-aes-gcm"; + * import { encryptAesGcm, decryptAesGcm } from "@std/crypto/aes-gcm"; * import { assertEquals } from "@std/assert"; * * const key = await crypto.subtle.generateKey( @@ -48,7 +48,7 @@ export interface AesGcmOptions { * * @example Usage * ```ts - * import { encryptAesGcm } from "@std/crypto/unstable-aes-gcm"; + * import { encryptAesGcm } from "@std/crypto/aes-gcm"; * import { assertNotEquals } from "@std/assert"; * * const key = await crypto.subtle.generateKey( @@ -88,6 +88,7 @@ export async function encryptAesGcm( iv: nonce, tagLength: TAG_LENGTH * 8, }; + if (options?.additionalData !== undefined) { params.additionalData = options.additionalData; } @@ -109,7 +110,7 @@ export async function encryptAesGcm( * * @example Usage * ```ts - * import { decryptAesGcm, encryptAesGcm } from "@std/crypto/unstable-aes-gcm"; + * import { decryptAesGcm, encryptAesGcm } from "@std/crypto/aes-gcm"; * import { assertEquals } from "@std/assert"; * * const key = await crypto.subtle.generateKey( diff --git a/crypto/unstable_aes_gcm_test.ts b/crypto/aes_gcm_test.ts similarity index 89% rename from crypto/unstable_aes_gcm_test.ts rename to crypto/aes_gcm_test.ts index d78cd84c8915..a7dca3414e95 100644 --- a/crypto/unstable_aes_gcm_test.ts +++ b/crypto/aes_gcm_test.ts @@ -1,7 +1,7 @@ // Copyright 2018-2026 the Deno authors. MIT license. import { assertEquals, assertNotEquals, assertRejects } from "@std/assert"; -import { decryptAesGcm, encryptAesGcm } from "./unstable_aes_gcm.ts"; +import { decryptAesGcm, encryptAesGcm } from "./aes_gcm.ts"; const encoder = new TextEncoder(); @@ -192,3 +192,21 @@ Deno.test("encryptAesGcm()/decryptAesGcm() round-trips DataView inputs", async ( const decrypted = await decryptAesGcm(key, encryptedView); assertEquals(decrypted, plaintext); }); + +Deno.test("encryptAesGcm()/decryptAesGcm() round-trips Uint8Array subarray with non-zero byteOffset", async () => { + const key = await generateKey(256); + const plaintext = encoder.encode("offset test"); + + const paddedPlaintext = new Uint8Array(16 + plaintext.byteLength); + paddedPlaintext.set(plaintext, 16); + const plaintextSubarray = paddedPlaintext.subarray(16); + + const encrypted = await encryptAesGcm(key, plaintextSubarray); + + const paddedEncrypted = new Uint8Array(8 + encrypted.byteLength); + paddedEncrypted.set(encrypted, 8); + const encryptedSubarray = paddedEncrypted.subarray(8); + + const decrypted = await decryptAesGcm(key, encryptedSubarray); + assertEquals(decrypted, plaintext); +}); diff --git a/crypto/deno.json b/crypto/deno.json index 06cb36ddd27e..a21e380a83b9 100644 --- a/crypto/deno.json +++ b/crypto/deno.json @@ -5,7 +5,7 @@ ".": "./mod.ts", "./crypto": "./crypto.ts", "./timing-safe-equal": "./timing_safe_equal.ts", - "./unstable-aes-gcm": "./unstable_aes_gcm.ts" + "./aes-gcm": "./aes_gcm.ts" }, "exclude": [ "_wasm/target" diff --git a/crypto/mod.ts b/crypto/mod.ts index 43113d4a0c5e..589c028c334c 100644 --- a/crypto/mod.ts +++ b/crypto/mod.ts @@ -20,5 +20,6 @@ * @module */ +export * from "./aes_gcm.ts"; export * from "./crypto.ts"; export * from "./timing_safe_equal.ts"; From 1b9ac3ad0924bc3acc6ca46a590cd8edaa8b46e8 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans <113360400+tomas-zijdemans@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:38:07 +0200 Subject: [PATCH 09/10] feat(data-structures/unstable): add `includes()` and `removeAt()` to `Deque` (#7083) --- data_structures/unstable_deque.ts | 143 +++++++++++++++++++++---- data_structures/unstable_deque_test.ts | 102 ++++++++++++++++++ 2 files changed, 222 insertions(+), 23 deletions(-) diff --git a/data_structures/unstable_deque.ts b/data_structures/unstable_deque.ts index a6af1aac742b..604cda924a9f 100644 --- a/data_structures/unstable_deque.ts +++ b/data_structures/unstable_deque.ts @@ -20,6 +20,7 @@ export type ReadonlyDeque = Pick< | "peekFront" | "peekBack" | "at" + | "includes" | "find" | "findIndex" | "toArray" @@ -44,6 +45,26 @@ function nextPowerOfTwo(n: number): number { * A double-ended queue backed by a ring buffer. Pushing, popping, and indexed * access stay fast as the deque grows. * + * | Method | Average Case | Worst Case | + * | ------------- | ------------ | ----------- | + * | pushBack() | O(1) | O(n) amort. | + * | pushFront() | O(1) | O(n) amort. | + * | popBack() | O(1) | O(1) | + * | popFront() | O(1) | O(1) | + * | peekFront() | O(1) | O(1) | + * | peekBack() | O(1) | O(1) | + * | at() | O(1) | O(1) | + * | isEmpty() | O(1) | O(1) | + * | clear() | O(1) | O(1) | + * | removeAt() | O(n) | O(n) | + * | removeFirst() | O(n) | O(n) | + * | includes() | O(n) | O(n) | + * | find() | O(n) | O(n) | + * | findIndex() | O(n) | O(n) | + * | retain() | O(n) | O(n) | + * | toArray() | O(n) | O(n) | + * | Deque.from() | O(n) | O(n) | + * * @experimental **UNSTABLE**: New API, yet to be vetted. * * @example Usage @@ -206,17 +227,11 @@ export class Deque implements Iterable, ReadonlyDeque { * @returns The new length of the deque. */ pushBack(value: T, ...rest: T[]): number { - if (this.#length === this.#capacity) { - if (this.#head === 0) this.#growWithoutCopying(); - else this.#grow(); - } + this.#maybeGrow(); this.#buffer[(this.#head + this.#length) & this.#mask] = value; this.#length++; for (let i = 0; i < rest.length; i++) { - if (this.#length === this.#capacity) { - if (this.#head === 0) this.#growWithoutCopying(); - else this.#grow(); - } + this.#maybeGrow(); this.#buffer[(this.#head + this.#length) & this.#mask] = rest[i]!; this.#length++; } @@ -246,12 +261,12 @@ export class Deque implements Iterable, ReadonlyDeque { */ pushFront(value: T, ...rest: T[]): number { for (let i = rest.length - 1; i >= 0; i--) { - if (this.#length === this.#capacity) this.#grow(); + this.#maybeGrow(); this.#head = (this.#head - 1) & this.#mask; this.#buffer[this.#head] = rest[i]!; this.#length++; } - if (this.#length === this.#capacity) this.#grow(); + this.#maybeGrow(); this.#head = (this.#head - 1) & this.#mask; this.#buffer[this.#head] = value; this.#length++; @@ -336,19 +351,44 @@ export class Deque implements Iterable, ReadonlyDeque { removeFirst(predicate: (value: T, index: number) => boolean): T | undefined { const i = this.#findIndex(predicate); if (i === -1) return undefined; + return this.#removeAtUnchecked(i); + } - const val = this.#buffer[(this.#head + i) & this.#mask] as T; - - const closerToFront = i < this.#length - i - 1; - if (closerToFront) { - this.#closeGapFromFront(i); - } else { - this.#closeGapFromBack(i); - } - - this.#length--; - this.#maybeShrink(); - return val; + /** + * Remove and return the element at the given index (0-based from front). + * Negative indices count from the back (`-1` is the last element). Returns + * `undefined` for out-of-range indices. The gap is closed by shifting + * whichever side (front or back) has fewer elements to move. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Removing by index + * ```ts + * import { Deque } from "@std/data-structures/unstable-deque"; + * import { assertEquals } from "@std/assert"; + * + * const deque = new Deque([10, 20, 30, 40]); + * assertEquals(deque.removeAt(1), 20); + * assertEquals([...deque], [10, 30, 40]); + * ``` + * + * @example Removing with a negative index + * ```ts + * import { Deque } from "@std/data-structures/unstable-deque"; + * import { assertEquals } from "@std/assert"; + * + * const deque = new Deque([10, 20, 30, 40]); + * assertEquals(deque.removeAt(-1), 40); + * assertEquals([...deque], [10, 20, 30]); + * ``` + * + * @param index The zero-based index. Negative values count from the back. + * @returns The removed element, or `undefined` if the index is out of range. + */ + removeAt(index: number): T | undefined { + if (index < 0) index += this.#length; + if (index < 0 || index >= this.#length) return undefined; + return this.#removeAtUnchecked(index); } /** @@ -474,6 +514,44 @@ export class Deque implements Iterable, ReadonlyDeque { return this.#buffer[(this.#head + index) & this.#mask]; } + /** + * Check whether the deque contains a value, using + * {@link https://tc39.es/ecma262/#sec-samevaluezero | SameValueZero} + * comparison (like {@linkcode Array.prototype.includes}). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Checking for membership + * ```ts + * import { Deque } from "@std/data-structures/unstable-deque"; + * import { assertEquals } from "@std/assert"; + * + * const deque = new Deque([1, 2, 3]); + * assertEquals(deque.includes(2), true); + * assertEquals(deque.includes(99), false); + * ``` + * + * @example NaN is found (SameValueZero semantics) + * ```ts + * import { Deque } from "@std/data-structures/unstable-deque"; + * import { assertEquals } from "@std/assert"; + * + * const deque = new Deque([1, NaN, 3]); + * assertEquals(deque.includes(NaN), true); + * ``` + * + * @param value The value to search for. + * @returns `true` if the deque contains the value, otherwise `false`. + */ + includes(value: T): boolean { + for (let i = 0; i < this.#length; i++) { + const el = this.#buffer[(this.#head + i) & this.#mask]; + // SameValueZero: === for everything except NaN + if (el === value || (el !== el && value !== value)) return true; + } + return false; + } + /** * Remove all elements and release the backing buffer. * @@ -661,7 +739,7 @@ export class Deque implements Iterable, ReadonlyDeque { const mapped: U[] = options?.map ? Array.from(unmappedValues, options.map, options.thisArg) - : Array.from(unmappedValues as unknown as Iterable); + : Array.from(unmappedValues as ArrayLike & Iterable); const capacity = nextPowerOfTwo(mapped.length); result.#buffer = new Array(capacity); @@ -756,6 +834,19 @@ export class Deque implements Iterable, ReadonlyDeque { return -1; } + /** Extract value, close the gap, update length, and optionally shrink. */ + #removeAtUnchecked(index: number): T { + const val = this.#buffer[(this.#head + index) & this.#mask] as T; + if (index < this.#length - index - 1) { + this.#closeGapFromFront(index); + } else { + this.#closeGapFromBack(index); + } + this.#length--; + this.#maybeShrink(); + return val; + } + /** Close the gap at `i` by shifting elements before it one slot toward the back. */ #closeGapFromFront(i: number): void { for (let j = i; j > 0; j--) { @@ -777,6 +868,12 @@ export class Deque implements Iterable, ReadonlyDeque { this.#buffer[(this.#head + this.#length - 1) & this.#mask] = undefined; } + #maybeGrow(): void { + if (this.#length < this.#capacity) return; + if (this.#head === 0) this.#growWithoutCopying(); + else this.#grow(); + } + #grow(): void { this.#realloc(this.#capacity * 2); } diff --git a/data_structures/unstable_deque_test.ts b/data_structures/unstable_deque_test.ts index a037d59eb040..34bfd8ff7299 100644 --- a/data_structures/unstable_deque_test.ts +++ b/data_structures/unstable_deque_test.ts @@ -496,6 +496,107 @@ Deno.test("Deque.retain() triggers shrink on large deque", () => { assertEquals(deque.at(7), 127); }); +// -- includes() -- + +Deno.test("Deque.includes() returns true for a present value", () => { + const deque = new Deque([1, 2, 3]); + assertEquals(deque.includes(2), true); +}); + +Deno.test("Deque.includes() returns false for an absent value", () => { + const deque = new Deque([1, 2, 3]); + assertEquals(deque.includes(99), false); +}); + +Deno.test("Deque.includes() returns false on an empty deque", () => { + assertEquals(new Deque().includes(1), false); +}); + +Deno.test("Deque.includes() finds NaN via SameValueZero", () => { + const deque = new Deque([1, NaN, 3]); + assertEquals(deque.includes(NaN), true); +}); + +Deno.test("Deque.includes() works after wrap-around", () => { + const deque = new Deque(); + for (let i = 0; i < 6; i++) deque.pushBack(i); + for (let i = 0; i < 6; i++) deque.popFront(); + deque.pushBack(10, 20, 30); + assertEquals(deque.includes(20), true); + assertEquals(deque.includes(99), false); +}); + +// -- removeAt() -- + +Deno.test("Deque.removeAt() removes and returns element at positive index", () => { + const deque = new Deque([10, 20, 30, 40]); + assertEquals(deque.removeAt(1), 20); + assertEquals([...deque], [10, 30, 40]); +}); + +Deno.test("Deque.removeAt() supports negative indices", () => { + const deque = new Deque([10, 20, 30, 40]); + assertEquals(deque.removeAt(-1), 40); + assertEquals([...deque], [10, 20, 30]); + assertEquals(deque.removeAt(-2), 20); + assertEquals([...deque], [10, 30]); +}); + +Deno.test("Deque.removeAt() returns undefined for out-of-range index", () => { + const deque = new Deque([1, 2, 3]); + assertStrictEquals(deque.removeAt(3), undefined); + assertStrictEquals(deque.removeAt(-4), undefined); + assertEquals([...deque], [1, 2, 3]); +}); + +Deno.test("Deque.removeAt() removes the first element", () => { + const deque = new Deque([10, 20, 30]); + assertEquals(deque.removeAt(0), 10); + assertEquals([...deque], [20, 30]); +}); + +Deno.test("Deque.removeAt() removes the last element", () => { + const deque = new Deque([10, 20, 30]); + assertEquals(deque.removeAt(2), 30); + assertEquals([...deque], [10, 20]); +}); + +Deno.test("Deque.removeAt() on single-element deque", () => { + const deque = new Deque([42]); + assertEquals(deque.removeAt(0), 42); + assertEquals(deque.length, 0); +}); + +Deno.test("Deque.removeAt() works after wrap-around", () => { + const deque = new Deque(); + for (let i = 0; i < 6; i++) deque.pushBack(i); + for (let i = 0; i < 6; i++) deque.popFront(); + deque.pushBack(10, 20, 30, 40, 50); + assertEquals(deque.removeAt(2), 30); + assertEquals([...deque], [10, 20, 40, 50]); +}); + +Deno.test("Deque.removeAt() shifts front side for front-half index", () => { + const deque = new Deque([10, 20, 30, 40, 50]); + assertEquals(deque.removeAt(1), 20); + assertEquals([...deque], [10, 30, 40, 50]); +}); + +Deno.test("Deque.removeAt() shifts back side for back-half index", () => { + const deque = new Deque([10, 20, 30, 40, 50]); + assertEquals(deque.removeAt(3), 40); + assertEquals([...deque], [10, 20, 30, 50]); +}); + +Deno.test("Deque.removeAt() triggers shrink on large deque", () => { + const deque = new Deque(); + for (let i = 0; i < 128; i++) deque.pushBack(i); + for (let i = 0; i < 112; i++) deque.popFront(); + assertEquals(deque.removeAt(8), 120); + assertEquals(deque.length, 15); + assertEquals(deque.at(0), 112); +}); + // -- Symbol.toStringTag -- Deno.test("Deque has correct Symbol.toStringTag", () => { @@ -508,6 +609,7 @@ Deno.test("Deque has correct Symbol.toStringTag", () => { Deno.test("ReadonlyDeque exposes read-only methods", () => { const deque: ReadonlyDeque = new Deque([1, 2, 3, 4]); + assertEquals(deque.includes(3), true); assertEquals(deque.find((v) => v === 3), 3); assertEquals(deque.findIndex((v) => v === 3), 2); assertEquals(deque[Symbol.toStringTag], "Deque"); From 1d29086411c4d8395c3b31347b8cb0d30ab16ff3 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans <113360400+tomas-zijdemans@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:40:10 +0200 Subject: [PATCH 10/10] BREAKING(async/unstable): change `Lazy.peek()` return type, add AbortSignal support (#7084) --- async/unstable_lazy.ts | 156 +++++++++++++++++++++++++----------- async/unstable_lazy_test.ts | 149 ++++++++++++++++++++++++++++++---- 2 files changed, 244 insertions(+), 61 deletions(-) diff --git a/async/unstable_lazy.ts b/async/unstable_lazy.ts index 5ecff0e3b9c9..f9852b60b6b6 100644 --- a/async/unstable_lazy.ts +++ b/async/unstable_lazy.ts @@ -1,13 +1,32 @@ // Copyright 2018-2026 the Deno authors. MIT license. // This module is browser compatible. +/** + * Options for {@linkcode Lazy.prototype.get}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface LazyGetOptions { + /** + * Signal used to abort the wait for initialization. + * + * Aborting does not cancel the underlying initializer — it only rejects the + * caller's promise. Other callers and any in-flight initialization are + * unaffected. + */ + signal?: AbortSignal; +} + /** * A lazy value that is initialized at most once, with built-in deduplication of * concurrent callers. Prevents the common race where two concurrent `get()` calls * both trigger the initializer; only one initialization runs and all callers share * the same promise. * - * @experimental **UNSTABLE**: New API, yet to be vetted. + * If the initializer rejects, the error is propagated to all concurrent callers + * and the internal state is cleared — the next {@linkcode Lazy.prototype.get} + * call will re-run the initializer. Compose with {@linkcode retry} for + * automatic back-off on transient failures. * * @example Concurrent deduplication * @@ -39,6 +58,8 @@ * await db.get(); * ``` * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @typeParam T The type of the lazily initialized value. */ export class Lazy { @@ -65,8 +86,6 @@ export class Lazy { * * Always returns a promise, even when the initializer is synchronous. * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * * @example Usage * ```ts no-assert * import { Lazy } from "@std/async/unstable-lazy"; @@ -75,37 +94,72 @@ export class Lazy { * const value = await config.get(); * ``` * + * @example Abort a slow initialization + * ```ts + * import { Lazy } from "@std/async/unstable-lazy"; + * import { assertRejects } from "@std/assert"; + * + * const slow = new Lazy(() => new Promise(() => {})); + * const controller = new AbortController(); + * controller.abort(new Error("timed out")); + * await assertRejects( + * () => slow.get({ signal: controller.signal }), + * Error, + * "timed out", + * ); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param options Optional settings for this call. * @returns The cached or newly initialized value. */ - get(): Promise { - if (this.#promise !== undefined) { - return this.#promise; + get(options?: LazyGetOptions): Promise { + if (this.#settled) return Promise.resolve(this.#value as T); + const signal = options?.signal; + if (signal?.aborted) return Promise.reject(signal.reason); + + if (this.#promise === undefined) { + const p = new Promise((resolve, reject) => { + Promise.resolve().then(() => this.#init()).then( + (value) => { + if (this.#promise === p) { + this.#value = value; + this.#settled = true; + } + resolve(value); + }, + (err) => { + if (this.#promise === p) { + this.#promise = undefined; + } + reject(err); + }, + ); + }); + this.#promise = p; } - const p = Promise.resolve().then(() => this.#init()); - this.#promise = p; - p.then( - (value) => { - if (this.#promise === p) { - this.#value = value; - this.#settled = true; - } - return value; - }, - (_err) => { - if (this.#promise === p) { - this.#promise = undefined; - } - }, - ); - return p; + + if (!signal) return this.#promise; + + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + this.#promise!.then( + (value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }, + (err) => { + signal.removeEventListener("abort", abort); + reject(err); + }, + ); + }); } /** - * Whether the value has been successfully initialized. Useful for - * distinguishing "not yet initialized" from "initialized with `undefined`" - * when `T` can be `undefined`. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. + * Whether the value has been successfully initialized. * * @example Check initialization state * ```ts @@ -118,6 +172,8 @@ export class Lazy { * assertEquals(lazy.initialized, true); * ``` * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @returns `true` if the value has been initialized, `false` otherwise. */ get initialized(): boolean { @@ -125,32 +181,40 @@ export class Lazy { } /** - * Returns the value if already resolved, `undefined` otherwise. Useful for - * fast-path checks where you do not want to await. Returns `undefined` while - * initialization is in-flight. - * - * If `T` can be `undefined`, use {@linkcode initialized} to distinguish - * "not yet initialized" from "initialized with `undefined`". - * - * @experimental **UNSTABLE**: New API, yet to be vetted. + * Returns the value if already resolved, or indicates that it is not yet + * available. The discriminated union avoids ambiguity when `T` itself can + * be `undefined`. * * @example Fast-path when already initialized - * ```ts no-assert + * ```ts * import { Lazy } from "@std/async/unstable-lazy"; + * import { assertEquals } from "@std/assert"; * * const config = new Lazy(async () => ({ port: 8080 })); * await config.get(); * - * const cached = config.peek(); - * if (cached !== undefined) { - * console.log("using cached", cached.port); - * } + * const result = config.peek(); + * assertEquals(result, { ok: true, value: { port: 8080 } }); * ``` * - * @returns The resolved value, or `undefined` if not yet initialized or still in-flight. + * @example Not yet initialized + * ```ts + * import { Lazy } from "@std/async/unstable-lazy"; + * import { assertEquals } from "@std/assert"; + * + * const lazy = new Lazy(() => 42); + * assertEquals(lazy.peek(), { ok: false }); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns `{ ok: true, value }` if the value has been initialized, or + * `{ ok: false }` if not yet initialized or still in-flight. */ - peek(): T | undefined { - return this.#settled ? this.#value : undefined; + peek(): { ok: true; value: T } | { ok: false } { + return this.#settled + ? { ok: true, value: this.#value as T } + : { ok: false }; } /** @@ -158,8 +222,6 @@ export class Lazy { * not cancel an in-flight initialization; callers that already have the * promise will still receive its result. * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * * @example Force reload * ```ts ignore * import { Lazy } from "@std/async/unstable-lazy"; @@ -169,6 +231,8 @@ export class Lazy { * config.reset(); * const fresh = await config.get(); * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. */ reset(): void { this.#promise = undefined; diff --git a/async/unstable_lazy_test.ts b/async/unstable_lazy_test.ts index c04c8dd6257e..5db6fb7de2c9 100644 --- a/async/unstable_lazy_test.ts +++ b/async/unstable_lazy_test.ts @@ -1,6 +1,6 @@ // Copyright 2018-2026 the Deno authors. MIT license. -import { assertEquals, assertStrictEquals } from "@std/assert"; +import { assertEquals, assertRejects, assertStrictEquals } from "@std/assert"; import { Lazy } from "./unstable_lazy.ts"; Deno.test("Lazy.get() initializes and returns sync value", async () => { @@ -65,22 +65,55 @@ Deno.test("Lazy.initialized reflects lifecycle", async () => { }), ); - // Before init assertEquals(lazy.initialized, false); - // In-flight const getPromise = lazy.get(); await Promise.resolve(); assertEquals(lazy.initialized, false); - // After init holder.resolve(1); await getPromise; assertEquals(lazy.initialized, true); - // After reset lazy.reset(); assertEquals(lazy.initialized, false); +}); + +Deno.test("Lazy.initialized is false after rejection", async () => { + const lazy = new Lazy(() => Promise.reject(new Error("fail"))); + try { + await lazy.get(); + } catch { + // expected + } + assertEquals(lazy.initialized, false); +}); + +Deno.test("Lazy.peek() reflects lifecycle", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + + // Before init + assertEquals(lazy.peek(), { ok: false }); + + // In-flight + const getPromise = lazy.get(); + await Promise.resolve(); + assertEquals(lazy.peek(), { ok: false }); + + // After init + holder.resolve(1); + await getPromise; + assertEquals(lazy.peek(), { ok: true, value: 1 }); + + // After reset + lazy.reset(); + assertEquals(lazy.peek(), { ok: false }); // After rejected init const failing = new Lazy(() => Promise.reject(new Error("fail"))); @@ -89,19 +122,17 @@ Deno.test("Lazy.initialized reflects lifecycle", async () => { } catch { // expected } - assertEquals(failing.initialized, false); + assertEquals(failing.peek(), { ok: false }); }); -Deno.test("Lazy.initialized disambiguates T = undefined", async () => { +Deno.test("Lazy.peek() disambiguates T = undefined", async () => { const lazy = new Lazy(() => undefined); - assertEquals(lazy.initialized, false); - assertEquals(lazy.peek(), undefined); + assertEquals(lazy.peek(), { ok: false }); await lazy.get(); - assertEquals(lazy.initialized, true); - assertEquals(lazy.peek(), undefined); + assertEquals(lazy.peek(), { ok: true, value: undefined }); }); -Deno.test("Lazy.peek() returns undefined while in-flight", async () => { +Deno.test("Lazy.peek() returns { ok: false } while in-flight", async () => { const holder: { resolve: (v: number) => void } = { resolve: () => {} }; const lazy = new Lazy( () => @@ -111,15 +142,15 @@ Deno.test("Lazy.peek() returns undefined while in-flight", async () => { ); const getPromise = lazy.get(); await Promise.resolve(); - assertEquals(lazy.peek(), undefined); + assertEquals(lazy.peek(), { ok: false }); holder.resolve(99); assertEquals(await getPromise, 99); }); -Deno.test("Lazy.peek() returns value after initialization", async () => { +Deno.test("Lazy.peek() returns { ok: true, value } after initialization", async () => { const lazy = new Lazy(() => 42); await lazy.get(); - assertEquals(lazy.peek(), 42); + assertEquals(lazy.peek(), { ok: true, value: 42 }); }); Deno.test("Lazy.reset() causes re-initialization", async () => { @@ -149,3 +180,91 @@ Deno.test("Lazy.reset() does not affect in-flight initialization", async () => { const value = await getPromise; assertEquals(value, "ok"); }); + +Deno.test("Lazy.get() resolves falsy values correctly", async (t) => { + await t.step("0", async () => { + const lazy = new Lazy(() => 0); + assertEquals(await lazy.get(), 0); + assertEquals(lazy.peek(), { ok: true, value: 0 }); + }); + + await t.step("false", async () => { + const lazy = new Lazy(() => false); + assertEquals(await lazy.get(), false); + assertEquals(lazy.peek(), { ok: true, value: false }); + }); + + await t.step("empty string", async () => { + const lazy = new Lazy(() => ""); + assertEquals(await lazy.get(), ""); + assertEquals(lazy.peek(), { ok: true, value: "" }); + }); + + await t.step("null", async () => { + const lazy = new Lazy(() => null); + assertEquals(await lazy.get(), null); + assertEquals(lazy.peek(), { ok: true, value: null }); + }); +}); + +Deno.test("Lazy.get() rejects immediately with already-aborted signal", async () => { + const lazy = new Lazy(() => 42); + const reason = new Error("aborted"); + await assertRejects( + () => lazy.get({ signal: AbortSignal.abort(reason) }), + Error, + "aborted", + ); + assertEquals(lazy.peek(), { ok: false }); +}); + +Deno.test("Lazy.get() rejects when signal is aborted during initialization", async () => { + const lazy = new Lazy( + () => new Promise(() => {}), + ); + const controller = new AbortController(); + const getPromise = lazy.get({ signal: controller.signal }); + controller.abort(new Error("cancelled")); + await assertRejects( + () => getPromise, + Error, + "cancelled", + ); +}); + +Deno.test("Lazy.get() signal does not affect other callers", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + const controller = new AbortController(); + const abortable = lazy.get({ signal: controller.signal }); + const normal = lazy.get(); + controller.abort(new Error("cancelled")); + + await assertRejects(() => abortable, Error, "cancelled"); + + holder.resolve(42); + assertEquals(await normal, 42); + assertEquals(lazy.peek(), { ok: true, value: 42 }); +}); + +Deno.test("Lazy.get() with signal rejects when initializer fails", async () => { + const lazy = new Lazy(() => Promise.reject(new Error("boom"))); + const controller = new AbortController(); + await assertRejects( + () => lazy.get({ signal: controller.signal }), + Error, + "boom", + ); +}); + +Deno.test("Lazy.get() signal is ignored after successful initialization", async () => { + const lazy = new Lazy(() => 42); + await lazy.get(); + const value = await lazy.get({ signal: AbortSignal.abort() }); + assertEquals(value, 42); +});