Skip to content

Commit f6cc742

Browse files
feedback
1 parent d82df6e commit f6cc742

2 files changed

Lines changed: 212 additions & 47 deletions

File tree

data_structures/unstable_indexed_heap.ts

Lines changed: 136 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ function checkPriority(priority: unknown): void {
5555
}
5656
}
5757

58+
/**
59+
* Conditional rest-parameter tuple for the comparator options bag. When
60+
* the priority `P` is one of the primitive types that {@linkcode ascend}
61+
* orders correctly (`number`, `bigint`, `string`, plus their literal and
62+
* union subtypes), the options bag — and therefore `compare` itself —
63+
* is optional. For any other `P` (object, tuple, custom class), the
64+
* options bag is required and must provide `compare`; without it,
65+
* `ascend` would silently produce nonsense ordering (e.g., lexicographic
66+
* string comparison of `[number, number]` tuples).
67+
*/
68+
type IndexedHeapCompareArgs<P> = [P] extends [number | bigint | string]
69+
? [options?: { compare?: (a: P, b: P) => number }]
70+
: [options: { compare: (a: P, b: P) => number }];
71+
5872
/**
5973
* A priority queue that supports looking up, removing, and re-prioritizing
6074
* entries by key. Each entry is a unique `(key, priority)` pair. The entry
@@ -102,7 +116,10 @@ function checkPriority(priority: unknown): void {
102116
* priority's contents (e.g., `heap.peek().priority[0] = 0`) corrupts
103117
* the heap invariant. Use
104118
* {@linkcode IndexedHeap.prototype.set | set} to change a key's
105-
* priority.
119+
* priority. `set` itself uses reference equality (`===`) to short-circuit
120+
* when the priority is unchanged, so passing a structurally-equal but
121+
* distinct object/tuple priority replaces the reference and runs a sift
122+
* (no observable reorder, but not a no-op).
106123
*
107124
* @experimental **UNSTABLE**: New API, yet to be vetted.
108125
*
@@ -183,9 +200,12 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
183200
* @param entries An optional iterable of `[key, priority]` pairs. Each key
184201
* must be unique; duplicates throw a `TypeError`. Pass `null` or
185202
* `undefined` to create an empty heap with options.
186-
* @param options Optional configuration. `compare` overrides the default
187-
* ascending-numeric ordering; pass {@linkcode descend} for max-heap
188-
* order, or a custom function for tuple/`bigint`/etc. priorities.
203+
* @param options Optional configuration when `P` is `number`, `bigint`,
204+
* or `string`; required when `P` is any other type (e.g. tuples or
205+
* objects) so the heap has a valid comparator. `compare` overrides the
206+
* default ascending-numeric ordering; pass {@linkcode descend} for
207+
* max-heap order, or a custom function for tuple/`bigint`/etc.
208+
* priorities.
189209
*
190210
* @example Empty heap
191211
* ```ts
@@ -227,6 +247,10 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
227247
* assertEquals(heap.peek(), { key: "b", priority: 5 });
228248
* ```
229249
*/
250+
constructor(
251+
entries?: Iterable<readonly [K, P]> | null,
252+
...options: IndexedHeapCompareArgs<P>
253+
);
230254
constructor(
231255
entries?: Iterable<readonly [K, P]> | null,
232256
options?: { compare?: (a: P, b: P) => number },
@@ -240,7 +264,7 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
240264
this.#compare = compare;
241265
if (entries === undefined || entries === null) return;
242266
if (
243-
typeof entries !== "object" && typeof entries !== "string" ||
267+
typeof entries !== "object" ||
244268
!(Symbol.iterator in Object(entries))
245269
) {
246270
throw new TypeError(
@@ -267,22 +291,14 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
267291
readonly [Symbol.toStringTag] = "IndexedHeap" as const;
268292

269293
/**
270-
* Create a new {@linkcode IndexedHeap} from an iterable of key-priority
271-
* pairs, an array-like of key-priority pairs, or an existing
272-
* {@linkcode IndexedHeap}. When copying from another `IndexedHeap`, the
273-
* source's comparator is inherited unless `options.compare` overrides it.
294+
* Create a shallow copy of an existing {@linkcode IndexedHeap}. The
295+
* source's comparator is inherited unless `options.compare` overrides
296+
* it; when overridden, the copy is re-heapified in O(n).
274297
*
275-
* @experimental **UNSTABLE**: New API, yet to be vetted.
298+
* Because the source already carries a valid comparator, `compare`
299+
* stays optional regardless of the priority type `P`.
276300
*
277-
* @example Creating from an array of pairs
278-
* ```ts
279-
* import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap";
280-
* import { assertEquals } from "@std/assert";
281-
*
282-
* const heap = IndexedHeap.from([["a", 3], ["b", 1], ["c", 2]]);
283-
* assertEquals(heap.peek(), { key: "b", priority: 1 });
284-
* assertEquals(heap.size, 3);
285-
* ```
301+
* @experimental **UNSTABLE**: New API, yet to be vetted.
286302
*
287303
* @example Creating from another IndexedHeap (shallow copy)
288304
* ```ts
@@ -299,6 +315,56 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
299315
* assertEquals(original.size, 2);
300316
* ```
301317
*
318+
* @example Re-ordering an existing heap with a different comparator
319+
* ```ts
320+
* import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap";
321+
* import { descend } from "@std/data-structures";
322+
* import { assertEquals } from "@std/assert";
323+
*
324+
* const minHeap = new IndexedHeap<string>([["a", 1], ["b", 5], ["c", 3]]);
325+
* const maxHeap = IndexedHeap.from(minHeap, { compare: descend });
326+
*
327+
* assertEquals(minHeap.peek(), { key: "a", priority: 1 });
328+
* assertEquals(maxHeap.peek(), { key: "b", priority: 5 });
329+
* ```
330+
*
331+
* @typeParam K The type of the keys in the heap.
332+
* @typeParam P The type of the priority. Defaults to `number`.
333+
* @param collection The source {@linkcode IndexedHeap} to copy.
334+
* @param options Optional configuration. Omitting it inherits the
335+
* source's comparator; passing `compare` overrides it and triggers
336+
* a re-heapify.
337+
* @returns A new heap containing all entries from the source heap.
338+
*/
339+
static from<K, P = number>(
340+
collection: IndexedHeap<K, P>,
341+
options?: { compare?: (a: P, b: P) => number },
342+
): IndexedHeap<K, P>;
343+
/**
344+
* Create a new {@linkcode IndexedHeap} from an iterable or array-like
345+
* of `[key, priority]` pairs.
346+
*
347+
* When the priority type `P` is `number`, `bigint`, or `string`, the
348+
* default {@linkcode ascend} comparator orders correctly and the
349+
* `options` bag is optional. For any other `P` (tuples, objects), the
350+
* `compare` option is required at the type level so the heap has a
351+
* valid comparator — otherwise `ascend` would silently mis-order.
352+
*
353+
* Use the {@linkcode IndexedHeap.from} overload that takes an existing
354+
* {@linkcode IndexedHeap} to copy from another heap instead.
355+
*
356+
* @experimental **UNSTABLE**: New API, yet to be vetted.
357+
*
358+
* @example Creating from an array of pairs
359+
* ```ts
360+
* import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap";
361+
* import { assertEquals } from "@std/assert";
362+
*
363+
* const heap = IndexedHeap.from([["a", 3], ["b", 1], ["c", 2]]);
364+
* assertEquals(heap.peek(), { key: "b", priority: 1 });
365+
* assertEquals(heap.size, 3);
366+
* ```
367+
*
302368
* @example Creating from a Map (iterable of [key, value] pairs)
303369
* ```ts
304370
* import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap";
@@ -309,28 +375,30 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
309375
* assertEquals(heap.peek(), { key: "task-b", priority: 1 });
310376
* ```
311377
*
312-
* @example Re-ordering an existing heap with a different comparator
378+
* @example Tuple priority requires an explicit comparator
313379
* ```ts
314380
* import { IndexedHeap } from "@std/data-structures/unstable-indexed-heap";
315-
* import { descend } from "@std/data-structures";
316381
* import { assertEquals } from "@std/assert";
317382
*
318-
* const minHeap = new IndexedHeap<string>([["a", 1], ["b", 5], ["c", 3]]);
319-
* const maxHeap = IndexedHeap.from(minHeap, { compare: descend });
320-
*
321-
* assertEquals(minHeap.peek(), { key: "a", priority: 1 });
322-
* assertEquals(maxHeap.peek(), { key: "b", priority: 5 });
383+
* const heap = IndexedHeap.from<string, [number, number]>(
384+
* [["a", [5, 0]], ["b", [3, 1]]],
385+
* { compare: (a, b) => a[0] - b[0] || a[1] - b[1] },
386+
* );
387+
* assertEquals(heap.peek(), { key: "b", priority: [3, 1] });
323388
* ```
324389
*
325390
* @typeParam K The type of the keys in the heap.
326391
* @typeParam P The type of the priority. Defaults to `number`.
327-
* @param collection An iterable or array-like of `[key, priority]` pairs,
328-
* or an existing {@linkcode IndexedHeap} to copy.
329-
* @param options Optional configuration. `compare` overrides the
330-
* ordering; when copying from another `IndexedHeap`, omitting this
331-
* inherits the source's comparator.
392+
* @param collection An iterable or array-like of `[key, priority]` pairs.
393+
* @param options Optional configuration when `P` is `number`, `bigint`,
394+
* or `string`; required when `P` is any other type. `compare`
395+
* overrides the default ascending-numeric ordering.
332396
* @returns A new heap containing all entries from the collection.
333397
*/
398+
static from<K, P = number>(
399+
collection: Iterable<readonly [K, P]> | ArrayLike<readonly [K, P]>,
400+
...options: IndexedHeapCompareArgs<P>
401+
): IndexedHeap<K, P>;
334402
static from<K, P = number>(
335403
collection:
336404
| IndexedHeap<K, P>
@@ -340,7 +408,7 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
340408
): IndexedHeap<K, P> {
341409
if (
342410
collection === null || collection === undefined ||
343-
typeof collection !== "object" && typeof collection !== "string" ||
411+
typeof collection !== "object" ||
344412
!(
345413
Symbol.iterator in Object(collection) ||
346414
"length" in Object(collection)
@@ -351,7 +419,7 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
351419
);
352420
}
353421
if (collection instanceof IndexedHeap) {
354-
const heap = new IndexedHeap<K, P>(null, {
422+
const heap = IndexedHeap.#createInternal<K, P>({
355423
compare: options?.compare ?? collection.#compare,
356424
});
357425
for (const entry of collection.#data) {
@@ -368,7 +436,7 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
368436
}
369437
return heap;
370438
}
371-
const heap = new IndexedHeap<K, P>(null, options);
439+
const heap = IndexedHeap.#createInternal<K, P>(options);
372440
heap.#bulkLoad(
373441
Symbol.iterator in Object(collection)
374442
? collection as Iterable<readonly [K, P]>
@@ -377,6 +445,22 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
377445
return heap;
378446
}
379447

448+
/**
449+
* Allocate a new {@linkcode IndexedHeap} from inside the class, bypassing
450+
* the public constructor's conditional rest-tuple constraint. Used by
451+
* {@linkcode IndexedHeap.from} where `P` is still generic and the
452+
* conditional `IndexedHeapCompareArgs<P>` cannot be resolved.
453+
*/
454+
static #createInternal<K, P>(
455+
options?: { compare?: (a: P, b: P) => number },
456+
): IndexedHeap<K, P> {
457+
type RelaxedCtor = new <K2, P2>(
458+
entries?: Iterable<readonly [K2, P2]> | null,
459+
options?: { compare?: (a: P2, b: P2) => number },
460+
) => IndexedHeap<K2, P2>;
461+
return new (IndexedHeap as RelaxedCtor)<K, P>(null, options);
462+
}
463+
380464
/**
381465
* Append all `[key, priority]` pairs and heapify in linear time. Used by
382466
* the constructor and by {@linkcode IndexedHeap.from} for the
@@ -533,11 +617,9 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
533617
* Return the front entry (smallest priority) without removing it, or
534618
* `undefined` if the heap is empty.
535619
*
536-
* The returned wrapper is a fresh object — replacing its `key` or
537-
* `priority` property has no effect on the heap. Object-typed
538-
* priorities (e.g., tuples) share their reference with the heap and
539-
* must not be mutated; see the class-level note on priority
540-
* mutability.
620+
* The returned entry is a fresh wrapper, but for object-typed priorities
621+
* the `priority` field shares its reference with the heap — see the
622+
* class-level note on treating object priorities as immutable.
541623
*
542624
* @experimental **UNSTABLE**: New API, yet to be vetted.
543625
*
@@ -668,6 +750,13 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
668750
* throws on existing keys) and is the natural operation for relaxation
669751
* steps in graph algorithms like Dijkstra's.
670752
*
753+
* When `key` already exists, the no-op fast path uses reference equality
754+
* (`===`) to skip work when the new priority is identical to the old.
755+
* For object/tuple priorities this means a structurally-equal but
756+
* distinct reference still replaces the stored reference and runs a
757+
* sift (no observable reorder, but not free). See the class-level note
758+
* on treating object priorities as immutable.
759+
*
671760
* @experimental **UNSTABLE**: New API, yet to be vetted.
672761
*
673762
* @example Inserting and updating
@@ -880,6 +969,10 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
880969
* Use {@linkcode IndexedHeap.prototype.drain | drain} to retrieve entries
881970
* in sorted (smallest-first) order.
882971
*
972+
* Each returned entry is a fresh wrapper, but for object-typed priorities
973+
* the `priority` field shares its reference with the heap — see the
974+
* class-level note on treating object priorities as immutable.
975+
*
883976
* @experimental **UNSTABLE**: New API, yet to be vetted.
884977
*
885978
* @example Usage
@@ -914,6 +1007,10 @@ export class IndexedHeap<K, P = number> implements Iterable<HeapEntry<K, P>> {
9141007
* Mutating the heap (`push`, `pop`, `set`, `delete`, `clear`) while
9151008
* iterating is not supported and may skip or repeat entries.
9161009
*
1010+
* Each yielded entry is a fresh wrapper, but for object-typed priorities
1011+
* the `priority` field shares its reference with the heap — see the
1012+
* class-level note on treating object priorities as immutable.
1013+
*
9171014
* @experimental **UNSTABLE**: New API, yet to be vetted.
9181015
*
9191016
* @example Usage

data_structures/unstable_indexed_heap_test.ts

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -727,14 +727,31 @@ Deno.test("IndexedHeap.from() throws TypeError on non-iterable, non-array-like i
727727
assertThrows(() => IndexedHeap.from({} as any), TypeError, message);
728728
});
729729

730-
Deno.test("IndexedHeap.from() accepts strings (iterable of characters)", () => {
731-
// A string is iterable, so it passes the `from()` guard. Each destructured
732-
// character yields `key = char`, `priority = undefined`, resulting in a
733-
// heap with two undefined-priority entries. Not useful in practice, but
734-
// documents that strings bypass the type guard.
735-
// deno-lint-ignore no-explicit-any
736-
const heap = IndexedHeap.from("ab" as any);
737-
assertEquals(heap.size, 2);
730+
Deno.test("IndexedHeap constructor rejects a string with a helpful error", () => {
731+
// Strings are technically iterable but never as `Iterable<[K, P]>`.
732+
// Route them through the same "did you mean `from`?" guard as any other
733+
// non-iterable-of-pairs input, instead of letting them silently fail
734+
// inside the destructuring loop with a confusing error.
735+
assertThrows(
736+
// deno-lint-ignore no-explicit-any
737+
() => new IndexedHeap("ab" as any),
738+
TypeError,
739+
"the 'entries' parameter is not iterable, did you mean to call IndexedHeap.from?",
740+
);
741+
});
742+
743+
Deno.test("IndexedHeap.from() rejects a string with a helpful error", () => {
744+
// A string is both iterable (yielding characters) and array-like (has
745+
// `length`), so it would otherwise bypass the type guard and silently
746+
// build a heap of undefined-priority entries from destructured chars.
747+
// Reject it explicitly so the caller learns the type was wrong, instead
748+
// of debugging a heap full of `priority: undefined`.
749+
assertThrows(
750+
// deno-lint-ignore no-explicit-any
751+
() => IndexedHeap.from("ab" as any),
752+
TypeError,
753+
"the 'collection' parameter is not iterable or array-like",
754+
);
738755
});
739756

740757
Deno.test("IndexedHeap toArray() returns all entries without modifying heap", () => {
@@ -951,6 +968,57 @@ Deno.test("IndexedHeap throws TypeError when compare option is not a function",
951968
);
952969
});
953970

971+
Deno.test("IndexedHeap requires compare at the type level when priority is not a primitive", () => {
972+
// Primitive priorities (number, bigint, string) accept ascend by default,
973+
// so the options bag — and therefore `compare` — stays optional.
974+
const _numHeap = new IndexedHeap<string>();
975+
const _bigintHeap = new IndexedHeap<string, bigint>();
976+
const _strHeap = new IndexedHeap<string, string>();
977+
978+
// Non-primitive priorities (tuples, objects) would silently lexicographically
979+
// mis-order under the default `ascend`, so `compare` is required at the
980+
// type level. The casts below pin the @ts-expect-error to the missing
981+
// `compare`, not to anything else; if the typed overload regresses the
982+
// compiler will stop flagging these lines and the test will fail to
983+
// typecheck (because @ts-expect-error itself errors when nothing errors).
984+
985+
// @ts-expect-error - 'compare' is required when P is a tuple
986+
const _tupleHeap = new IndexedHeap<string, [number, number]>();
987+
// @ts-expect-error - 'compare' is required when P is an object
988+
const _objHeap = new IndexedHeap<string, { score: number }>();
989+
// @ts-expect-error - 'compare' is required when P is a tuple
990+
const _tupleFromIter = IndexedHeap.from<string, [number, number]>([]);
991+
992+
// Supplying `compare` satisfies the requirement.
993+
const _tupleHeapOk = new IndexedHeap<string, [number, number]>(null, {
994+
compare: (a, b) => a[0] - b[0] || a[1] - b[1],
995+
});
996+
const _objHeapOk = new IndexedHeap<string, { score: number }>([], {
997+
compare: (a, b) => a.score - b.score,
998+
});
999+
1000+
// Copying from an existing IndexedHeap inherits the source's comparator,
1001+
// so `compare` stays optional even when P is non-primitive.
1002+
const _tupleCopy = IndexedHeap.from(_tupleHeapOk);
1003+
1004+
// Reference all locals so this stays a runtime-passing test even if the
1005+
// type-only assertions above are ever removed.
1006+
assertEquals(
1007+
[
1008+
_numHeap.size,
1009+
_bigintHeap.size,
1010+
_strHeap.size,
1011+
_tupleHeap.size,
1012+
_objHeap.size,
1013+
_tupleFromIter.size,
1014+
_tupleHeapOk.size,
1015+
_objHeapOk.size,
1016+
_tupleCopy.size,
1017+
],
1018+
[0, 0, 0, 0, 0, 0, 0, 0, 0],
1019+
);
1020+
});
1021+
9541022
Deno.test("IndexedHeap peek wrapper is fresh; replacing priority is safe even with object priorities", () => {
9551023
// Replacing the priority property on the returned wrapper is a no-op on
9561024
// the heap because the wrapper is a fresh object. Mutating the priority

0 commit comments

Comments
 (0)