UNPKG

fp-search-algorithms

Version:

Functional Programming Style Search Algorithms and Unordered Containers

1,528 lines (1,520 loc) 73.2 kB
/* eslint-disable complexity */ const unequalDates = (a, b) => { return a instanceof Date && (a > b || a < b); }; const unequalBuffers = (a, b) => { return (a.buffer instanceof ArrayBuffer && a.BYTES_PER_ELEMENT && !(a.byteLength === b.byteLength && a.every((n, i) => n === b[i]))); }; const unequalArrays = (a, b) => { return Array.isArray(a) && a.length !== b.length; }; const unequalMaps = (a, b) => { return a instanceof Map && a.size !== b.size; }; const unequalSets = (a, b) => { return a instanceof Set && (a.size !== b.size || [...a].some(e => !b.has(e))); }; const unequalRegExps = (a, b) => { return a instanceof RegExp && (a.source !== b.source || a.flags !== b.flags); }; const isObject = (a) => { return typeof a === 'object' && a !== null; }; const structurallyCompatibleObjects = (a, b) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (typeof a !== 'object' && typeof b !== 'object' && (!a || !b)) return false; const nonstructural = [Promise, WeakSet, WeakMap, Function]; if (nonstructural.some(c => a instanceof c)) return false; return a.constructor === b.constructor; }; /** * Check for equality by structure of two values * * Returns true if strict equality (`===`) returns true * * Values of different `typeof` return `false` * * Objects with different constructors return `false` * * Dates return true if both `>` and `<` return false * * ArrayBuffers return true when byteLength are equal and if values at all indexes are equal * * Arrays return true when lengths are equal and when values at all indexes pass `isEqual()` recursively * * Sets returns true when both are empty or when all keys equal on both * * Maps returns true when both are empty, when all keys equal on both, and when those key's values pass `isEqual()` recursively * * Dispatches to first argument's prototype method `equals: (other) => boolean` if exists * * Objects return true when both share same enumerable keys and all key's values pass `isEqual()` recursively * * Exceptions: * * Functions, Promises, WeakSets, and WeakMaps are checked by reference * * Notes: * * `isEqual({}, Object.create(null))` will always be `false`, regardless of keys/values because they don't share the same constructor * * @category Helpers * @returns boolean indicating whether the values are equal in value, structure, or reference */ const isEqual = (x, y) => { const values = [x, y]; while (values.length) { const a = values.pop(); const b = values.pop(); if (a === b) continue; if (!isObject(a) || !isObject(b)) return false; const unequal = !structurallyCompatibleObjects(a, b) || unequalDates(a, b) || unequalBuffers(a, b) || unequalArrays(a, b) || unequalMaps(a, b) || unequalSets(a, b) || unequalRegExps(a, b); if (unequal) return false; const proto = Object.getPrototypeOf(a); if (proto !== null && typeof proto.equals === 'function') { try { if (a.equals(b)) continue; else return false; } catch { // fall-through } } if (a instanceof Map) { if (!(b instanceof Map)) return false; for (const k of a.keys()) { values.push(a.get(k), b.get(k)); } } else { // assume a and b are objects const aKeys = Object.keys(a); const bKeys = Object.keys(b); const bKeysSet = new Set(bKeys); if (aKeys.length !== bKeys.length) return false; const extra = a instanceof globalThis.Error ? ['message'] : []; for (const k of [...extra, ...aKeys]) { // @ts-expect-error values.push(a[k], b[k]); bKeysSet.delete(k); } if (bKeysSet.size) return false; } } return true; }; /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable complexity */ /* eslint-disable no-bitwise */ /* eslint-disable no-plusplus */ /* eslint-disable prefer-arrow/prefer-arrow-functions */ /* eslint-disable func-style */ // // Credit to: https://github.com/gleam-lang/stdlib/blob/main/src/dict.mjs // Ported to typescript // const referenceMap = new WeakMap(); const tempDataView = new DataView(new ArrayBuffer(8)); let referenceUID = 0; /** * hash the object by reference using a weak map and incrementing uid */ const hashByReference = (o) => { const known = referenceMap.get(o); if (known !== undefined) { return known; } const hash = referenceUID++; if (referenceUID === 0x7fffffff) { referenceUID = 0; } referenceMap.set(o, hash); return hash; }; /** * merge two hashes in an order sensitive way */ const hashMerge = (a, b) => (a ^ (b + 0x9e3779b9 + (a << 6) + (a >> 2))) | 0; /** * standard string hash popularized by java */ const hashString = (s) => { let hash = 0; const len = s.length; for (let i = 0; i < len; i++) { hash = (Math.imul(31, hash) + s.charCodeAt(i)) | 0; } return hash; }; /** * hash a number by converting to two integers and do some jumbling */ const hashNumber = (n) => { tempDataView.setFloat64(0, n); const i = tempDataView.getInt32(0); const j = tempDataView.getInt32(4); return Math.imul(0x45d9f3b, (i >> 16) ^ i) ^ j; }; /** * hash a BigInt by converting it to a string and hashing that */ const hashBigInt = (n) => hashString(n.toString()); /** * hash any js object */ const hashObject = (o) => { const proto = Object.getPrototypeOf(o); if (proto !== null && typeof proto.hashCode === 'function') { try { const code = o.hashCode(o); if (typeof code === 'number') { return code; } // eslint-disable-next-line no-empty } catch { } } if (o instanceof Promise || o instanceof WeakSet || o instanceof WeakMap) { return hashByReference(o); } if (o instanceof Date) { return hashNumber(o.getTime()); } let h = 0; if (o instanceof ArrayBuffer) { o = new Uint8Array(o); } if (Array.isArray(o) || o instanceof Uint8Array) { for (let i = 0; i < o.length; i++) { h = (Math.imul(31, h) + getHash(o[i])) | 0; } } else if (o instanceof Set) { o.forEach(v => { h = (h + getHash(v)) | 0; }); } else if (o instanceof Map) { o.forEach((v, k) => { h = (h + hashMerge(getHash(v), getHash(k))) | 0; }); } else { const keys = Object.keys(o); for (let i = 0; i < keys.length; i++) { const k = keys[i]; const v = o[k]; h = (h + hashMerge(getHash(v), hashString(k))) | 0; } } return h; }; /** * hash any js value */ function getHash(u) { if (u === null) return 0x42108422; if (u === undefined) return 0x42108423; if (u === true) return 0x42108421; if (u === false) return 0x42108420; switch (typeof u) { case 'number': return hashNumber(u); case 'string': return hashString(u); case 'bigint': return hashBigInt(u); case 'object': return hashObject(u); case 'symbol': return hashByReference(u); case 'function': return hashByReference(u); default: throw new Error('getHash - non-exhaustive switch statement'); } } /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable no-bitwise */ /* eslint-disable no-plusplus */ /* eslint-disable prefer-arrow/prefer-arrow-functions */ /* eslint-disable func-style */ // // This file is a fork of: https://github.com/gleam-lang/stdlib/blob/main/src/dict.mjs // * ported to typescript // * originally written with full immutability for Gleam, but I updated it to do in-place mutations for performance gains // * My HashMap and HashSet are intentionally mutable to match that characteristic of the native Map and Set // const SHIFT = 5; // number of bits you need to shift by to get the next bucket const BUCKET_SIZE = 2 ** SHIFT; const MASK = BUCKET_SIZE - 1; // used to zero out all bits not in the bucket const MAX_INDEX_NODE = BUCKET_SIZE / 2; // when does index node grow into array node const MIN_ARRAY_NODE = BUCKET_SIZE / 4; // when does array node shrink to index node const ENTRY = 0; const ARRAY_NODE = 1; const INDEX_NODE = 2; const COLLISION_NODE = 3; /** @internal */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const createEmptyNode = () => ({ array: [], bitmap: 0, type: INDEX_NODE, }); /** * Mask the hash to get only the bucket corresponding to shift * @internal */ function mask(hash, shift) { return (hash >>> shift) & MASK; } /** * Set only the Nth bit where N is the masked hash * @internal */ function bitpos(hash, shift) { return 1 << mask(hash, shift); } /** * Count the number of 1 bits in a number */ function bitcount(x) { x -= (x >> 1) & 0x55555555; x = (x & 0x33333333) + ((x >> 2) & 0x33333333); x = (x + (x >> 4)) & 0x0f0f0f0f; x += x >> 8; x += x >> 16; return x & 0x7f; } /** * Calculate the array index of an item in a bitmap index node */ function index(bitmap, bit) { return bitcount(bitmap & (bit - 1)); } /** * Create a new node containing two entries */ function createNode(shift, key1, val1, key2hash, key2, val2) { const key1hash = getHash(key1); if (key1hash === key2hash) { return { array: [ { k: key1, type: ENTRY, v: val1 }, { k: key2, type: ENTRY, v: val2 }, ], hash: key1hash, type: COLLISION_NODE, }; } const addedLeaf = { val: false }; return assoc(assocIndex(createEmptyNode(), shift, key1hash, key1, val1, addedLeaf), shift, key2hash, key2, val2, addedLeaf); } /** * Associate a node with a new entry, creating a new node * @internal */ function assoc(root, shift, hash, key, val, addedLeaf) { switch (root.type) { case ARRAY_NODE: return assocArray(root, shift, hash, key, val, addedLeaf); case INDEX_NODE: return assocIndex(root, shift, hash, key, val, addedLeaf); case COLLISION_NODE: return assocCollision(root, shift, hash, key, val, addedLeaf); default: throw new Error('function assoc :: non-exhaustive'); } } function assocArray(root, shift, hash, key, val, addedLeaf) { const idx = mask(hash, shift); const node = root.array[idx]; // if the corresponding index is empty set the index to a newly created node if (node === undefined) { addedLeaf.val = true; root.array[idx] = { k: key, type: ENTRY, v: val }; root.size += 1; return root; } if (node.type === ENTRY) { // if keys are equal replace the entry if (isEqual(key, node.k)) { if (val === node.v) return root; root.array[idx] = { k: key, type: ENTRY, v: val }; return root; } // otherwise upgrade the entry to a node and insert addedLeaf.val = true; root.array[idx] = createNode(shift + SHIFT, node.k, node.v, hash, key, val); return root; } // otherwise call assoc on the child node root.array[idx] = assoc(node, shift + SHIFT, hash, key, val, addedLeaf); return root; } function assocIndex(root, shift, hash, key, val, addedLeaf) { const bit = bitpos(hash, shift); const idx = index(root.bitmap, bit); // if there is already a item at this hash index.. if ((root.bitmap & bit) !== 0) { // if there is a node at the index (not an entry), call assoc on the child node const node = root.array[idx]; if (node.type !== ENTRY) { const n = assoc(node, shift + SHIFT, hash, key, val, addedLeaf); root.array[idx] = n; return root; } // otherwise there is an entry at the index // if the keys are equal replace the entry with the updated value const nodeKey = node.k; if (isEqual(key, nodeKey)) { if (val === node.v) return root; node.v = val; return root; } // if the keys are not equal, replace the entry with a new child node addedLeaf.val = true; root.array[idx] = createNode(shift + SHIFT, nodeKey, node.v, hash, key, val); root.type = INDEX_NODE; return root; } // else there is currently no item at the hash index const n = root.array.length; // if the number of nodes is at the maximum, expand this node into an array node if (n >= MAX_INDEX_NODE) { // create a 32 length array for the new array node (one for each bit in the hash) const nodes = new Array(32); // create and insert a node for the new entry const jdx = mask(hash, shift); nodes[jdx] = assocIndex(createEmptyNode(), shift + SHIFT, hash, key, val, addedLeaf); let j = 0; let { bitmap } = root; // place each item in the index node into the correct spot in the array node // loop through all 32 bits / array positions for (let i = 0; i < 32; i++) { if ((bitmap & 1) !== 0) { const node = root.array[j++]; nodes[i] = node; } // shift the bitmap to process the next bit bitmap >>>= 1; } return { array: nodes, size: n + 1, type: ARRAY_NODE, }; } // else there is still space in this index node // simply insert a new entry at the hash index addedLeaf.val = true; const newEntryNode = { k: key, type: ENTRY, v: val, }; root.array.splice(idx, 0, newEntryNode); root.bitmap |= bit; return root; } function assocCollision(root, shift, hash, key, val, addedLeaf) { // if there is a hash collision if (hash === root.hash) { const idx = collisionIndexOf(root, key); // if this key already exists replace the entry with the new value if (idx !== -1) { const entry = root.array[idx]; if (entry.v === val) return root; root.array[idx] = { k: key, type: ENTRY, v: val }; return root; } // otherwise insert the entry at the end of the array addedLeaf.val = true; root.array.push({ k: key, type: ENTRY, v: val }); return root; } // if there is no hash collision, upgrade to an index node return assoc({ array: [root], bitmap: bitpos(root.hash, shift), type: INDEX_NODE, }, shift, hash, key, val, addedLeaf); } /** * Find the index of a key in the collision node's array */ function collisionIndexOf(root, key) { const size = root.array.length; for (let i = 0; i < size; i++) { if (isEqual(key, root.array[i].k)) { return i; } } return -1; } /** * Return the found entry or undefined if not present in the root * @internal */ function find(root, shift, hash, key) { switch (root.type) { case ARRAY_NODE: return findArray(root, shift, hash, key); case INDEX_NODE: return findIndex(root, shift, hash, key); case COLLISION_NODE: return findCollision(root, key); default: throw new Error('function find :: non-exhaustive'); } } function findArray(root, shift, hash, key) { const idx = mask(hash, shift); const node = root.array[idx]; if (node === undefined) { return undefined; } if (node.type !== ENTRY) { return find(node, shift + SHIFT, hash, key); } if (isEqual(key, node.k)) { return node; } return undefined; } function findIndex(root, shift, hash, key) { const bit = bitpos(hash, shift); if ((root.bitmap & bit) === 0) { return undefined; } const idx = index(root.bitmap, bit); const node = root.array[idx]; if (node.type !== ENTRY) { return find(node, shift + SHIFT, hash, key); } if (isEqual(key, node.k)) { return node; } return undefined; } function findCollision(root, key) { const idx = collisionIndexOf(root, key); if (idx < 0) { return undefined; } return root.array[idx]; } /** * Remove an entry from the root, returning the updated root. * Returns undefined if the node should be removed from the parent. * @internal */ function without(root, shift, hash, key) { switch (root.type) { case ARRAY_NODE: return withoutArray(root, shift, hash, key); case INDEX_NODE: return withoutIndex(root, shift, hash, key); case COLLISION_NODE: return withoutCollision(root, key); default: throw new Error('function without :: non-exhaustive'); } } function withoutArray(root, shift, hash, key) { const idx = mask(hash, shift); const node = root.array[idx]; if (node === undefined) return root; // already empty let n; // if node is an entry and the keys are not equal there is nothing to remove // if node is not an entry do a recursive call if (node.type === ENTRY) { if (!isEqual(node.k, key)) { return root; // no changes } } else { n = without(node, shift + SHIFT, hash, key); } // if ENTRY and isEqual, or the recursive call returned undefined, the node should be removed if (n === undefined) { // if the number of child nodes is at the minimum, pack into an index node if (root.size <= MIN_ARRAY_NODE) { const arr = root.array; const out = new Array(root.size - 1); let i = 0; let j = 0; let bitmap = 0; while (i < idx) { const nv = arr[i]; if (nv !== undefined) { out[j] = nv; bitmap |= 1 << i; ++j; } ++i; } ++i; // skip copying the removed node while (i < arr.length) { const nv = arr[i]; if (nv !== undefined) { out[j] = nv; bitmap |= 1 << i; ++j; } ++i; } return { array: out, bitmap, type: INDEX_NODE, }; } root.array[idx] = n; root.size -= 1; return root; } root.array[idx] = n; return root; } function withoutIndex(root, shift, hash, key) { const bit = bitpos(hash, shift); if ((root.bitmap & bit) === 0) return root; // already empty const idx = index(root.bitmap, bit); const node = root.array[idx]; // if the item is not an entry if (node.type !== ENTRY) { const n = without(node, shift + SHIFT, hash, key); // if not undefined, the child node still has items, so update it if (n !== undefined) { root.array[idx] = n; return root; } // otherwise the child node should be removed // if it was the only child node, remove this node from the parent if (root.bitmap === bit) return undefined; // otherwise just remove the child node root.array.splice(idx, 1); root.bitmap ^= bit; return root; } // otherwise the item is an entry, remove it if the key matches if (isEqual(key, node.k)) { // if it was the only child node, remove this node from the parent if (root.bitmap === bit) return undefined; root.array.splice(idx, 1); root.bitmap ^= bit; return root; } return root; } function withoutCollision(root, key) { const idx = collisionIndexOf(root, key); // if the key not found, no changes if (idx < 0) return root; // otherwise the entry was found, remove it // if it was the only entry in this node, remove the whole node if (root.array.length === 1) return undefined; // otherwise just remove the entry root.array.splice(idx, 1); return root; } /** @internal */ function forEach(root, fn) { if (root === undefined) { return; } const items = root.array; const size = items.length; for (let i = 0; i < size; i++) { const item = items[i]; if (item === undefined) { continue; } if (item.type === ENTRY) { fn(item.v, item.k); continue; } forEach(item, fn); } } /** @internal */ function toArray(root) { const array = []; forEach(root, (v, k) => array.push([k, v])); return array; } /* eslint-disable no-underscore-dangle */ /* eslint-disable @typescript-eslint/unified-signatures */ /* eslint-disable @typescript-eslint/prefer-return-this-type */ /* eslint-disable no-bitwise */ /* eslint-disable no-plusplus */ // // Inspired by: https://github.com/gleam-lang/stdlib/blob/main/src/dict.mjs // Ported to typescript // /** * ### HashMap * * Key equality is determined by `isEqual` * * If your keys are Javascript [primitives](https://developer.mozilla.org/en-US/docs/Glossary/Primitive), there is no benefit in using a HashMap over the native [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map). * * #### Construction * `HashMap` is a newable class, and has the static `HashMap.from` functional constructor for convenience * * In addition to the arguments the new constructor accepts, `HashMap.from()` also accepts objects directly * * #### Native Map API * The HashMap API fully implements the Map API and can act as a drop-in replacement with a few caveats: * * Non-primitive Map keys are equal by reference, a Map may contain two different keys that share the same structure. A HashMap will not see those keys as being different. * * Order of insertion is not retained. * * Those methods are: * * `clear`, `delete`, `entries`, `forEach`, `get`, `has`, `keys`, `set`, `values`, `[Symbol.Iterator]` * * static `groupBy` * * readonly prop `size` * * #### Array API * HashMap partially implements the Array API, specifically the reduction methods that can apply. * The callbackfn signatures match their array equivalent with `k: Key` replacing the `index: number` argument. * * Those methods are: * * `map`, `filter`, `find`, `reduce`, `some`, `every` * * Notes: * * `map` and `filter` are immutable, returning a new instance of HashMap * * `find` returns a tuple `[K, V] | undefined` * * #### Additional Utility APIs * * `clone` - will return a new instance of a HashMap with the exact same key/value pair entries * * `equals` - determine if another HashMap is equal to `this` by determining they share the same key/value pair entries * * Therefor `hashMap.equals(hashMap.clone())` will always return `true` * * #### Custom Equality and Hashing * Internally, class instances who's prototypes implement `equals: (other: typeof this) => boolean` and `hashCode(self: typeof this) => number` * will be used to determine equality between instances and an instance's hash value respectively. * It is recommended to implement these on any class where equality cannot be determined testing on public properties only * * @category Structures */ class HashMap { root; _size; static from(oneOfThem) { if (oneOfThem == null) { return new HashMap(); } if (Symbol.iterator in oneOfThem) { return new HashMap(oneOfThem); } // else isObject return new HashMap(Object.entries(oneOfThem)); } constructor(iterable) { this.root = undefined; this._size = 0; if (iterable != null) { for (const [k, v] of iterable) { this.set(k, v); } } } //#endregion //#region Utility /** * Groups members of an iterable according to the return value of the passed callback. * @group Utility * @param items An iterable. * @param keySelector A callback which will be invoked for each item in items. */ static groupBy(items, keySelector) { const dict = new HashMap(); let i = 0; for (const val of items) { const key = keySelector(val, i); if (!dict.has(key)) { dict.set(key, []); } dict.get(key).push(val); ++i; } return dict; } /** * @group Utility * @returns an immutable copy of the HashMap */ clone() { const clone = new HashMap(); clone.root = structuredClone(this.root); clone._size = this.size; return clone; } /** * Check if this HashMap is equal to another * Returns `true` when * * referentially equal * * both HashMaps contain exactly the same key/value pairs * * both are empty * * @group Utility * @param other another HashMap * @returns boolean indicating whether the other HashMap has the exactly same entries as this */ equals(other) { if (this === other) return true; if (!(other instanceof HashMap) || this._size !== other._size) return false; for (const [k, v] of this) { if (!isEqual(other.get(k), v)) { return false; } } return true; } /** * Used internally by `getHash()` * @group Utility * @returns the hash of this HashMap */ hashCode() { let h = 0; this.forEach((v, k) => { h = (h + hashMerge(getHash(v), getHash(k))) | 0; }); return h; } //#endregion //#region Entries /** * @group Entries * @returns the number of elements in the HashMap. */ get size() { return this._size; } /** * Empties the HashMap, clearing out all entries. * @group Entries */ clear() { this.root = undefined; this._size = 0; } /** * @group Entries * @returns true if an element in the HashMap existed and has been removed, or false if the element does not exist. */ delete(key) { if (this.root === undefined) return false; if (!this.has(key)) return false; this.root = without(this.root, 0, getHash(key), key); this._size -= 1; return true; } /** * Executes a provided function once per each key/value pair in the Map, in insertion order. * @group Entries */ forEach(fn) { forEach(this.root, fn); } /** * Returns a specified element from the HashMap. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the HashMap. * @group Entries * @returns Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned. */ get(key) { if (this.root === undefined) return undefined; const found = find(this.root, 0, getHash(key), key); return found?.v; } /** * @group Entries * @returns boolean indicating whether an element with the specified key exists or not. */ has(key) { if (this.root === undefined) return false; return find(this.root, 0, getHash(key), key) !== undefined; } /** * Adds a new element with a specified key and value to the HashMap. If an element with the same key already exists, the element will be updated. * @group Entries */ set(key, val) { const addedLeaf = { val: false }; const root = this.root ?? createEmptyNode(); this.root = assoc(root, 0, getHash(key), key, val, addedLeaf); this._size = addedLeaf.val ? this._size + 1 : this._size; return this; } //#endregion //#region Iterables /** * Returns an iterable of entries in the HashMap. * @group Iterables */ [Symbol.iterator]() { return this.entries(); } /** * Returns an iterable of key, value pairs for every entry in the HashMap. * @group Iterables */ entries() { return Iterator.from(toArray(this.root)); } /** * Returns an iterable of keys in the HashMap. * @group Iterables */ keys() { return this.entries().map(([k]) => k); } /** * Returns an iterable of values in the HashMap. * @group Iterables */ values() { return this.entries().map(([, v]) => v); } map(callbackfn, thisArg) { const hashMap = new HashMap(); this.forEach((v, k) => { hashMap.set(k, callbackfn.call(thisArg, v, k, this)); }); return hashMap; } filter(predicate, thisArg) { const hashMap = new HashMap(); this.forEach((v, k) => { if (predicate.call(thisArg, v, k, this)) { hashMap.set(k, v); } }); return hashMap; } find(predicate, thisArg) { for (const [k, v] of this.entries()) { if (predicate.call(thisArg, v, k, this)) { return [k, v]; } } return undefined; } reduce(callbackfn, initialValue) { if (arguments.length === 1 && this.size === 0) { throw new TypeError('Reduce of empty HashMap with no initial value'); } let entries = Array.from(this.entries()); let acc; if (arguments.length === 1) { const [head, ...rest] = entries; acc = head[1]; entries = rest; } else { acc = initialValue; } for (const [k, v] of entries) { acc = callbackfn(acc, v, k, this); } return acc; } some(predicate, thisArg) { for (const [k, v] of this.entries()) { if (predicate.call(thisArg, v, k, this)) return true; } return false; } /** * Determines whether all the entries of a HashMap satisfy the specified test. * * @group Reductions * @param predicate A function that accepts up to three arguments. The every method calls * the predicate function for each element in the array until the predicate returns a value * which is coercible to the Boolean value false, or until the end of the HashMap iteration. */ every(predicate, thisArg) { for (const [k, v] of this.entries()) { if (!predicate.call(thisArg, v, k, this)) return false; } return true; } } /* eslint-disable no-bitwise */ // get parent index (intDiv(i, 2)) const parent = (i) => ((i + 1) >>> 1) - 1; // double + 1 const left = (i) => (i << 1) + 1; // double + 2 const right = (i) => (i + 1) << 1; /* eslint-enable no-bitwise */ /** * * @category Structures */ class PriorityQueue { comparator; equals; heap = []; constructor(comparator, equals = (a, b) => a === b) { this.comparator = comparator; this.equals = equals; } size() { return this.heap.length; } isEmpty() { return this.size() === 0; } peek() { return this.heap[0]; } has(other) { return this.heap.find(x => this.equals(x, other)) != null; } replace(value) { const replacedValue = this.peek(); this.heap[0] = value; this.siftDown(); return replacedValue; } push(value) { this.heap.push(value); this.siftUp(); } pop() { const poppedValue = this.peek(); const bottom = this.size() - 1; if (bottom > 0) { this.swap(0, bottom); } this.heap.pop(); this.siftDown(); return poppedValue; } reorder() { const l = this.heap.length; const original = [...this.heap]; this.heap = []; for (let i = 0; i < l; i += 1) { this.push(original[i]); } } toArray() { const copy = [...this.heap]; const arr = []; while (this.size() > 0) { arr.push(this.pop()); } this.heap = copy; return arr; } greater(i, j) { return this.comparator(this.heap[i], this.heap[j]); } swap = (i, j) => { const a = this.heap[i]; const b = this.heap[j]; this.heap[j] = a; this.heap[i] = b; }; siftUp() { let i = this.size() - 1; while (i > 0 && this.greater(i, parent(i))) { this.swap(i, parent(i)); i = parent(i); } } siftDown() { let i = 0; while ((left(i) < this.size() && this.greater(left(i), i)) || (right(i) < this.size() && this.greater(right(i), i))) { const maxChild = right(i) < this.size() && this.greater(right(i), left(i)) ? right(i) : left(i); this.swap(i, maxChild); i = maxChild; } } } /** @internal */ const createPath = (prevMap, final) => { const path = [final]; let prev = prevMap.get(final); while (prev != null) { path.unshift(prev); prev = prevMap.get(prev); } return path; }; /** * Generator function that lazily iterates through each visit of an A* search. * If you want just the found path and totalCost to the solution, use `aStarAssoc` * * Each yield is an object `{ cost: number; path: T[] }` * * Notes: * * The first yield will be the initialState with a cost of 0 * * Specific states may be visited multiple time, but through different costs and paths * * If the solved state is found, that will be the final yield, otherwise the final yield will happen once all possible states are visited * * Generator `return` value (at `done: true`) will be the found solution or undefined * * @category AStar * @param getNextStates - a function to generate list of neighboring states with associated transition costs given the current state * @param estimateRemainingCost - a heuristic function to determine remaining cost * @param determineIfFound - a function to determine if solution found * @param initial - initial state */ const generateAStarAssoc = function* (getNextStates, estimateRemainingCost, determineIfFound, initial) { const cameFrom = new HashMap(); const gScore = new HashMap().set(initial, 0); const fScore = new HashMap().set(initial, estimateRemainingCost(initial)); const queue = new PriorityQueue((a, b) => { const aScore = fScore.get(a); const bScore = fScore.get(b); return aScore < bScore; }, isEqual); queue.push(initial); while (!queue.isEmpty()) { const state = queue.pop(); const cost = gScore.get(state); const toYield = { cost, path: createPath(cameFrom, state), }; yield toYield; if (determineIfFound(state)) return toYield; const nextStates = getNextStates(state); for (const [nextState, nextCost] of nextStates) { const tentativeGScore = cost + nextCost; if (tentativeGScore < (gScore.get(nextState) ?? Infinity)) { cameFrom.set(nextState, state); gScore.set(nextState, tentativeGScore); fScore.set(nextState, tentativeGScore + estimateRemainingCost(nextState)); if (queue.has(nextState)) { queue.reorder(); } else { queue.push(nextState); } } } } return undefined; }; /** * Generator function that lazily iterates through each visit of an A* search. * If you want just the found path and totalCost to the solution, use `aStar` * * Each yield is an object `{ cast: number; path: T[] }` * * Notes: * * The first yield will be the initialState with a cost of 0 * * Specific states may be visited multiple time, but through different costs and paths * * If the solved state is found, that will be the final yield, otherwise the final yield will happen once all possible states are visited * * The return value is the total cost and path, or undefined if path to solved state is not possible * * @category AStar * @param getNextStates - a function to generate list of neighboring states given the current state * @param getCost - a function to generate transition costs between neighboring states * @param estimateRemainingCost - a heuristic function to determine remaining cost * @param determineIfFound - a function to determine if solution found * @param initial - initial state */ const generateAStar = function* (getNextStates, getCost, estimateRemainingCost, determineIfFound, initial) { const nextAssoc = (state) => getNextStates(state).map(n => [n, getCost(state, n)]); return yield* generateAStarAssoc(nextAssoc, estimateRemainingCost, determineIfFound, initial); }; /** * Performs a best-first search using the A* search algorithm * * @category AStar * @param getNextStates - a function to generate list of neighboring states with associated transition costs given the current state * @param estimateRemainingCost - a heuristic function to determine remaining cost * @param determineIfFound - a function to determine if solution found * @param initial - initial state * @returns an object with `totalCost` and the `path` with costs between states, or `undefined` if no path found */ const aStarAssoc = (getNextStates, estimateRemainingCost, determineIfFound, initial) => { const iterable = generateAStarAssoc(getNextStates, estimateRemainingCost, determineIfFound, initial); // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = iterable.next(); if (done === true) return value; } }; /** * Performs a best-first search using the A* search algorithm * * @category AStar * @param getNextStates - a function to generate list of neighboring states given the current state * @param getCost - a function to generate transition costs between neighboring states * @param estimateRemainingCost - a heuristic function to determine remaining cost * @param determineIfFound - a function to determine if solution found * @param initial - initial state * @returns an object with `totalCost` and the `path` with costs between states, or `undefined` if no path found */ const aStar = (getNextStates, getCost, estimateRemainingCost, determineIfFound, initial) => { const iterable = generateAStar(getNextStates, getCost, estimateRemainingCost, determineIfFound, initial); // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = iterable.next(); if (done === true) return value; } }; /* eslint-disable @typescript-eslint/unified-signatures */ /** * ### HashSet * * Value equality is determined by `isEqual` * * If your values are Javascript [primitives](https://developer.mozilla.org/en-US/docs/Glossary/Primitive), there is no benefit in using a HashSet over the native [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set). * * #### Construction * `HashSet` is a newable class, and has the static `HashSet.from` functional constructor for convenience and accepts the same arguments * * #### Native Set API * The HashSet API fully implements the Set API and can act as a drop-in replacement with a few caveats: * * Non-primitive Set values are equal by reference, a Set may contain two different values that share the same structure. A HashSet will not see those values as being different * * Order of insertion is not retained * * Those methods are: * * `clear`, `delete`, `entries`, `forEach`, `add`, `has`, `keys`, `values`, `[Symbol.Iterator]` * * readonly prop `size` * * #### Set Composition API * * The hashSet API also fully implements the new Composition API methods with one caveat: * * Unlike the Set methods which all except "set-like" objects, HashSet _only_ accepts another HashSet in their arguments * * Those methods are: * * `difference`, `intersection`, `isDisjointFrom`, `isSubsetOf`, `isSupersetOf`, `symmetricDifference`, `union` * * #### Array API * HashSet partially implements the Array API, specifically the reduction methods that can apply. * The callbackfn signatures match their array equivalent but without the `index: number` argument * * Those methods are: * * `map`, `filter`, `find`, `reduce`, `some`, `every` * * Notes: * * `map` and `filter` are immutable, returning a new instance of HashSet * * #### Additional Utility APIs * * `clone` - will return a new instance of a HashSet with the exact same values * * `equals` - determine if another HashSet is equal to `this` by determining they share the same values * * Therefor `hashSet.equals(hashSet.clone())` will always return `true` * * #### Custom Equality and Hashing * Internally, class instances who's prototypes implement `equals: (other: typeof this) => boolean` and `hashCode(self: typeof this) => number` * will be used to determine equality between instances and an instance's hash value respectively. * It is recommended to implement these on any class where equality cannot be determined testing on public properties only * * @category Structures */ class HashSet { dict; static from(iterable) { return new HashSet(iterable); } constructor(iterable) { this.dict = new HashMap(); if (iterable != null) { for (const v of iterable) { this.add(v); } } } //#endregion //#region Utility /** * Create an immutable copy of the HashSet * @group Utility */ clone() { const set = new HashSet(); set.dict = this.dict.clone(); return set; } /** * Check if this HashSet is equal to another * Returns `true` when * * referentially equal * * both HashSets contain exactly the same values * * both are empty * * @group Utility */ equals(other) { return this.dict.equals(other.dict); } /** * Used internally by `getHash()` * @group Utility */ hashCode() { // @ts-expect-error return this.dict.hashCode(); } //#endregion //#region Entries /** * @group Entries * @returns the number of (unique) elements in Set. */ get size() { return this.dict.size; } /** * Adds a new element with a specified value to the HashSet. * @group Entries */ add(val) { this.dict.set(val, undefined); return this; } /** * * Empties the HashSet, clearing out all values. * @group Entries */ clear() { this.dict.clear(); } /** * Removes a specified value from the HashSet. * @group Entries * @returns Returns true if an element in the HashSet existed and has been removed, or false if the element does not exist. */ delete(val) { return this.dict.delete(val); } /** * @group Entries * @returns a boolean indicating whether an element with the specified value exists in the HashSet or not. */ has(val) { return this.dict.has(val); } /** * Executes a provided function once per each value in the HashSet object. * @group Entries */ forEach(fn) { this.dict.forEach((_v, k) => { fn(k); }); } //#endregion //#region Iterables /** * Iterates over values in the HashSet * @group Iterables */ [Symbol.iterator]() { return this.keys(); } /** * Returns an iterable of [v,v] pairs for every value `v` in the HashSet. * @group Iterables */ entries() { return this.dict.keys().map(k => [k, k]); } /** * Despite its name, returns an iterable of the values in the HashSet. * @group Iterables */ keys() { return this.dict.keys(); } /** * Returns an iterable of values in the HashSet. * @group Iterables */ values() { return this.dict.keys(); } //#endregion //#region Composition /** * @group Composition * @returns a new HashSet containing all the elements in this HashSet which are not also in the argument. */ difference(other) { const [iter, check] = (this.size > other.size ? [other, this] : [this, other]); const set = this.clone(); for (const v of iter) { if (check.has(v)) { set.delete(v); } } return set; } /** * @group Composition * @returns a new HashSet containing all the elements which are both in this HashSet and in the argument. */ intersection(other) { const [iter, check] = (this.size > other.size ? [other, this] : [this, other]); const set = new HashSet(); for (const v of iter) { if (check.has(v)) { set.add(v); } } return set; } /** * @group Composition * @returns a boolean indicating whether this HashSet has no elements in common with the argument. */ isDisjointFrom(other) { const [iter, check] = this.size > other.size ? [other, this] : [this, other]; for (const v of iter) { if (check.has(v)) return false; } return true; } /** * @group Composition * @returns a boolean indicating whether all the elements in this HashSet are also in the argument. */ isSubsetOf(other) { for (const v of this) { if (!other.has(v)) return false; } return true; } /** * @group Composition * @returns a boolean indicating whether all the elements in the argument are also in this HashSet. */ isSupersetOf(other) { for (const v of other) { if (!this.has(v)) return false; } return true; } /** * @group Composition * @returns a new HashSet containing all the elements which are in either this HashSet or in the argument, but not in both. */ symmetricDifference(other) { const set = this.clone(); for (const v of other) { if (set.has(v)) { set.delete(v); } else { set.add(v); } } return set; } /** * @group Composition * @returns a new HashSet containing all the elements in this HashSet and also all the elements in the argument. */ union(other) { const set = this.clone(); const iter = other.keys(); let visit = iter.next(); while (!(visit.done ?? false)) { if (!set.has(visit.value)) { set.add(visit.value); } visit = iter.next(); } return set; } map(callbackfn, thisArg) { const hashSet = new HashSet(); this.forEach(v => { hashSet.add(callbackfn.call(thisArg, v, this)); }); return hashSet; } filter(predicate, thisArg) { const hashSet = new HashSet(); this.forEach(v => { if (predicate.call(thisArg, v, this)) { hashSet.add(v); } }); return hashSet; } find(predicate, thisArg) { for (const v of this.values()) { if (predicate.call(thisArg, v, this)) { return v; } } return undefined; } reduce(callbackfn, initialValue) { if (arguments.length === 1 && this.size === 0) { throw new TypeError('Reduce of empty HashSet with no initial value'); } let values = Array.from(this.values()); let acc; if (arguments.length === 1) { const [head, ...rest] = values; acc = head; values = rest; } else { acc = initialValue; } for (const v of values) { acc = callbackfn(acc, v, this); } return acc; } some(predicate, thisArg) { for (const v of this.values()) { if (predicate.call(thisArg, v, this)) return true; } return false; } /** * Determines whether all the values of a HashSet satisfy the specified test. * * @group Reductions * @param predicate A function that accepts up to two arguments. The every method calls * the predicate function for each value in the array until the predicate returns a value * which is coercible to the Boolean value false, or until the end of the HashSet iteration. */ every(predicate, thisArg) {