From 71dcdfbab7d161125bd1cc777af47384644eff58 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Wed, 10 Apr 2024 15:28:17 +0300 Subject: [PATCH] Implement HashMap similar to Python's dict Implement an abstract base class for values hashable by their numeric value and distinguishable by strict equality. Implement HashMap as an associative data structure for hashable values. Implement HashSet as a collection of hashable values. Replace FractionSet implementation with HashSet. --- package-lock.json | 18 ++- package.json | 3 + src/__tests__/hashable.spec.ts | 73 +++++++++ src/__tests__/index.spec.ts | 61 +++++++- src/fraction.ts | 36 ++++- src/hashable.ts | 261 +++++++++++++++++++++++++++++++++ src/index.ts | 47 +----- src/monzo.ts | 4 +- 8 files changed, 449 insertions(+), 54 deletions(-) create mode 100644 src/__tests__/hashable.spec.ts create mode 100644 src/hashable.ts diff --git a/package-lock.json b/package-lock.json index 1280d76..dfc2059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "xen-dev-utils", "version": "0.2.9", "license": "MIT", + "dependencies": { + "ts-essentials": "^9.4.2" + }, "devDependencies": { "@types/benchmark": "^2.1.5", "@types/node": "^20.10.1", @@ -4241,6 +4244,19 @@ "node": ">=8" } }, + "node_modules/ts-essentials": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.4.2.tgz", + "integrity": "sha512-mB/cDhOvD7pg3YCLk2rOtejHjjdSi9in/IBYE13S+8WA5FBSraYf4V/ws55uvs0IvQ/l0wBOlXy5yBNZ9Bl8ZQ==", + "peerDependencies": { + "typescript": ">=4.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -4344,7 +4360,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 48b2b3b..bf802c9 100644 --- a/package.json +++ b/package.json @@ -54,5 +54,8 @@ }, "engines": { "node": ">=10.6.0" + }, + "dependencies": { + "ts-essentials": "^9.4.2" } } diff --git a/src/__tests__/hashable.spec.ts b/src/__tests__/hashable.spec.ts new file mode 100644 index 0000000..2bcb41d --- /dev/null +++ b/src/__tests__/hashable.spec.ts @@ -0,0 +1,73 @@ +import {Primitive} from 'ts-essentials'; +import {HashMap, Hashable} from '../hashable'; +import {PRIMES} from '../primes'; +import {Monzo, monzosEqual} from '../monzo'; +import {describe, it, expect} from 'vitest'; + +/** + * Partial implementation of a rational number capable of holding very large values with a limited number of prime factors. + */ +class ImmutableMonzo extends Hashable { + vector: Readonly; + + constructor(vector: Readonly) { + super(); + this.vector = vector; + } + + valueOf(): number { + let value = 1; + for (let i = 0; i < this.vector.length; ++i) { + value *= PRIMES[i] ** this.vector[i]; + } + return value; + } + + strictEquals(other: Hashable | Primitive): boolean { + if (other instanceof ImmutableMonzo) { + return monzosEqual(this.vector, other.vector); + } + return false; + } +} + +function M(vector: Readonly) { + return Object.freeze(new ImmutableMonzo(Object.freeze(vector))); +} + +describe('Hash-map / dictionary', () => { + it('can set / get keys that hash to the same value', () => { + const thirty = M([1, 1, 1]); + const big = M([100000]); + const alsoBig = M([-1, 1000]); + expect(thirty.valueOf()).toBe(30); + expect(big.valueOf()).toBe(alsoBig.valueOf()); + + const map = new HashMap([ + [30, 'number'], + [thirty, 'thirty'], + [big, 'biig'], // Oops typo. To be overriden. + ]); + + map.set(alsoBig, 'also big'); + map.set(M([100000]), 'big'); + + expect(map.get(30)).toBe('number'); + expect(map.get(M([1, 1, 1]))).toBe('thirty'); + expect(map.get(M([100000]))).toBe('big'); + expect(map.get(M([-1, 1000]))).toBe('also big'); + + expect(map.size).toBe(4); + }); + + it('can be cleared', () => { + const map = new HashMap(); + map.set(3, 4); + expect(map.size).toBe(1); + map.clear(); + expect(map.size).toBe(0); + expect(map.get(3)).toBe(undefined); + }); + + // TODO: Rest of the methods of HashMap +}); diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 1ebb389..7f8bdee 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -1,7 +1,6 @@ import {describe, it, expect} from 'vitest'; import { Fraction, - FractionSet, arraysEqual, binomial, ceilPow2, @@ -19,7 +18,10 @@ import { iteratedEuclid, norm, valueToCents, + F, } from '../index'; +import {HashMap, HashSet, Hashable} from '../hashable'; +import {DeepReadonly, Primitive} from 'ts-essentials'; describe('Array equality tester', () => { it('works on integer arrays', () => { @@ -207,11 +209,11 @@ describe('Farey sequence generator', () => { }); it('agrees with the brute force method', () => { - const everything = new FractionSet(); + const everything = new HashSet>(); const N = Math.floor(Math.random() * 50) + 1; for (let d = 1; d <= N; ++d) { for (let n = 0; n <= d; ++n) { - everything.add(new Fraction(n, d)); + everything.add(F(n, d)); } } const brute = Array.from(everything); @@ -254,11 +256,11 @@ describe('Farey interior generator', () => { }); it('agrees with the brute force method', () => { - const everything = new FractionSet(); + const everything = new HashSet>(); const N = Math.floor(Math.random() * 50) + 1; for (let d = 1; d <= N; ++d) { for (let n = 1; n < d; ++n) { - everything.add(new Fraction(n, d)); + everything.add(F(n, d)); } } const brute = Array.from(everything); @@ -382,3 +384,52 @@ describe('Constant structure checker with a margin of equivalence', () => { expect(hasMarginConstantStructure([1199, 1200], 2)).toBe(false); }); }); + +describe('Fractions as hashmap keys', () => { + it('can override values using equivalent keys', () => { + const hashMap = new HashMap([ + [F(3, 2), 'fif'], + [F(2), 'octave'], + ]); + hashMap.set(F('5/4'), 'third'); + hashMap.set(F(1.5), 'fifth'); + const entries = Array.from(hashMap).sort((a, b) => a[0].compare(b[0])); + expect(entries).toEqual([ + [{s: 1, n: 5, d: 4}, 'third'], + [{s: 1, n: 3, d: 2}, 'fifth'], + [{s: 1, n: 2, d: 1}, 'octave'], + ]); + }); + + it('plays nicely with mixed keys and mixed data', () => { + class OtherHashable extends Hashable { + name: string; + constructor(name: string) { + super(); + this.name = name; + } + valueOf(): string { + return this.name; + } + strictEquals(other: Hashable | Primitive): boolean { + if (other instanceof OtherHashable) { + return this.name === other.name; + } + return false; + } + } + const hashMap = new HashMap< + number | string | Fraction | OtherHashable, + string | number | boolean + >([ + [1, 'one'], + [F(1), 'other one'], + ['1', 1], + [Object.freeze(new OtherHashable('1')), true], + ]); + expect(hashMap.get(1)).toBe('one'); + expect(hashMap.get(F('7/7'))).toBe('other one'); + expect(hashMap.get('1')).toBe(1); + expect(hashMap.get(Object.freeze(new OtherHashable('1')))).toBe(true); + }); +}); diff --git a/src/fraction.ts b/src/fraction.ts index 8500f8c..1ac1599 100644 --- a/src/fraction.ts +++ b/src/fraction.ts @@ -3,6 +3,8 @@ import {valueToCents} from './conversion'; import {PRIMES} from './primes'; +import {Hashable} from './hashable'; +import {DeepReadonly, Primitive} from 'ts-essentials'; export type UnsignedFraction = {n: number; d: number}; @@ -76,7 +78,7 @@ export function mmod(a: number, b: number) { * new Fraction("13e-3"); // scientific notation * ``` */ -export class Fraction { +export class Fraction extends Hashable { /** Sign: +1, 0 or -1 */ s: number; /** Numerator */ @@ -85,6 +87,7 @@ export class Fraction { d: number; constructor(numerator: FractionValue, denominator?: number) { + super(); if (denominator !== undefined) { if (typeof numerator !== 'number') { throw new Error('Numerator must be a number when denominator is given'); @@ -831,6 +834,24 @@ export class Fraction { } } + /** + * Check if two {@link Fraction} instances represent the same rational number. + * Returns `false` for other {@link Hashable} objects. + * Examples: + * ```ts + * new Fraction("19.7").strictEquals(new Fraction("98/5")) // false + * new Fraction("19.6").strictEquals(new Fraction("98/5")) // true + * new Fraction("19.5").strictEquals(new Fraction("98/5")) // false + * ``` + **/ + strictEquals(other: Hashable | Primitive) { + if (other instanceof Fraction) { + const {s, n, d} = other; + return this.s === s && this.n === n && this.d === d; + } + return false; + } + /** * Check if two rational numbers are divisible * (i.e. this is an integer multiple of other) @@ -1114,3 +1135,16 @@ export class Fraction { return other_.pow(exponent); } } + +/** + * Construct an immutable {@link Fraction} instance suitable for a key in a {@link HashMap} or a value in {@link HashSet}. + * @param numerator Integer numerator, floating point value, string or another {@link Fraction} to convert. + * @param denominator Integer denominator (defaults to `1`). + * @returns An immutable rational number. + */ +export function F( + numerator: FractionValue, + denominator?: number +): DeepReadonly { + return Object.freeze(new Fraction(numerator, denominator)); +} diff --git a/src/hashable.ts b/src/hashable.ts new file mode 100644 index 0000000..c8d0f8f --- /dev/null +++ b/src/hashable.ts @@ -0,0 +1,261 @@ +import {DeepReadonly, Primitive} from 'ts-essentials'; + +/** + * A hashable type expected to remain immutable so that it can be used as a dictionary / hash map key. + */ +export abstract class Hashable { + abstract strictEquals(other: Hashable | Primitive): boolean; + + abstract valueOf(): Primitive; +} + +/** + * Python style dictionary implementation using hashable keys. + */ +export class HashMap | Primitive, Value> + implements Map +{ + private map: Map; + + constructor(entries?: Iterable<[Key, Value]>) { + this.map = new Map(); + if (entries === undefined) { + return; + } + for (const [key, value] of entries) { + this.set(key, value); + } + } + + set(key: Key, value: Value) { + if (typeof key !== 'object' || key === null) { + const subEntries = this.map.get(key) ?? []; + for (let i = 0; i < subEntries.length; ++i) { + const [existing] = subEntries[i]; + if (key === existing) { + subEntries[i] = [key, value]; + return this; + } + } + subEntries.push([key, value]); + this.map.set(key, subEntries); + return this; + } + const hash = key.valueOf(); + const subEntries = this.map.get(hash) ?? []; + for (let i = 0; i < subEntries.length; ++i) { + const [existing] = subEntries[i]; + if (key.strictEquals(existing)) { + subEntries[i] = [key, value]; + return this; + } + } + subEntries.push([key, value]); + this.map.set(hash, subEntries); + return this; + } + + get(key: Key) { + if (typeof key !== 'object' || key === null) { + const subEntries = this.map.get(key); + if (subEntries === undefined) { + return undefined; + } + for (const [existing, value] of subEntries) { + if (key === existing) { + return value; + } + } + return undefined; + } + const hash = key.valueOf(); + const subEntries = this.map.get(hash); + if (subEntries === undefined) { + return undefined; + } + for (const [existing, value] of subEntries) { + if (key.strictEquals(existing)) { + return value; + } + } + return undefined; + } + + clear(): void { + this.map.clear(); + } + + delete(key: Key): boolean { + if (typeof key !== 'object' || key === null) { + const subEntries = this.map.get(key); + if (subEntries === undefined) { + return false; + } + for (let i = 0; i < subEntries.length; ++i) { + const [existing] = subEntries[i]; + if (key === existing) { + subEntries.splice(i, 1); + if (!subEntries.length) { + this.map.delete(key); + } + return true; + } + } + return false; + } + const hash = key.valueOf(); + const subEntries = this.map.get(hash); + if (subEntries === undefined) { + return false; + } + for (let i = 0; i < subEntries.length; ++i) { + const [existing] = subEntries[i]; + if (key.strictEquals(existing)) { + subEntries.splice(i, 1); + if (!subEntries.length) { + this.map.delete(hash); + } + return true; + } + } + return false; + } + + has(key: Key): boolean { + if (typeof key !== 'object' || key === null) { + const subEntries = this.map.get(key); + if (subEntries === undefined) { + return false; + } + for (const [existing] of subEntries) { + if (key === existing) { + return true; + } + } + return false; + } + const hash = key.valueOf(); + const subEntries = this.map.get(hash); + if (subEntries === undefined) { + return false; + } + for (const [existing] of subEntries) { + if (key.strictEquals(existing)) { + return true; + } + } + return false; + } + + get size(): number { + let total = 0; + for (const subEntries of this.map.values()) { + total += subEntries.length; + } + return total; + } + + *entries(): IterableIterator<[Key, Value]> { + for (const subEntries of this.map.values()) { + for (const entry of subEntries) { + yield entry; + } + } + } + + *keys(): IterableIterator { + for (const subEntries of this.map.values()) { + for (const [key] of subEntries) { + yield key; + } + } + } + + *values(): IterableIterator { + for (const subEntries of this.map.values()) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [_, value] of subEntries) { + yield value; + } + } + } + + *[Symbol.iterator]() { + yield* this.entries(); + } + + get [Symbol.toStringTag]() { + return 'HashMap'; + } + + forEach( + callbackfn: (value: Value, key: Key, map: HashMap) => void, + thisArg?: any + ): void { + callbackfn = callbackfn.bind(thisArg); + for (const subEntries of this.map.values()) { + for (const [key, value] of subEntries) { + callbackfn(value, key, this); + } + } + } +} + +/** + * Python style set implementation using hashable values. + */ +export class HashSet | Primitive> + implements Set +{ + private hashmap: HashMap; + constructor(values?: Iterable) { + this.hashmap = new HashMap(); + if (values === undefined) { + return; + } + for (const value of values) { + this.hashmap.set(value, null); + } + } + add(value: T) { + this.hashmap.set(value, null); + return this; + } + clear(): void { + this.hashmap.clear(); + } + delete(value: T): boolean { + return this.hashmap.delete(value); + } + has(value: T): boolean { + return this.hashmap.has(value); + } + get size() { + return this.hashmap.size; + } + *entries(): IterableIterator<[T, T]> { + for (const key of this.hashmap.keys()) { + yield [key, key]; + } + } + *keys(): IterableIterator { + yield* this.hashmap.keys(); + } + *values(): IterableIterator { + yield* this.hashmap.keys(); + } + *[Symbol.iterator]() { + yield* this.hashmap.keys(); + } + + get [Symbol.toStringTag]() { + return 'HashSet'; + } + forEach( + callbackfn: (value: T, value2: T, set: Set) => void, + thisArg?: any + ): void { + callbackfn = callbackfn.bind(thisArg); + this.hashmap.forEach((_, key) => callbackfn(key, key, this)); + } +} diff --git a/src/index.ts b/src/index.ts index 2e914f8..908f5e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from './conversion'; export * from './combinations'; export * from './monzo'; export * from './approximation'; +export * from './hashable'; export interface AnyArray { [key: number]: any; @@ -124,52 +125,6 @@ export function iteratedEuclid(params: Iterable) { return coefs; } -/** - * Collection of unique fractions. - */ -export class FractionSet extends Set { - /** - * Check `value` membership. - * @param value Value to check for membership. - * @returns A boolean asserting whether an element is present with the given value in the `FractionSet` object or not. - */ - has(value: Fraction) { - for (const other of this) { - if (other.equals(value)) { - return true; - } - } - return false; - } - - /** - * Appends `value` to the `FractionSet` object. - * @param value Value to append. - * @returns The `FractionSet` object with added value. - */ - add(value: Fraction) { - if (this.has(value)) { - return this; - } - super.add(value); - return this; - } - - /** - * Removes the element associated to the `value`. - * @param value Value to remove. - * @returns A boolean asserting whether an element was successfully removed or not. `FractionSet.prototype.has(value)` will return `false` afterwards. - */ - delete(value: Fraction) { - for (const other of this) { - if (other.equals(value)) { - return super.delete(other); - } - } - return false; - } -} - // https://stackoverflow.com/a/37716142 // step 1: a basic LUT with a few steps of Pascal's triangle const BINOMIALS = [ diff --git a/src/monzo.ts b/src/monzo.ts index cce980e..a8a01be 100644 --- a/src/monzo.ts +++ b/src/monzo.ts @@ -12,7 +12,7 @@ export type Monzo = number[]; * @param b The second monzo. * @returns `true` if the two values are equal when interpreted as fractions. */ -export function monzosEqual(a: Monzo, b: Monzo) { +export function monzosEqual(a: Readonly, b: Readonly) { if (a === b) { return true; } @@ -38,6 +38,8 @@ export function monzosEqual(a: Monzo, b: Monzo) { return true; } +// TODO: Make types readonly where applicable. + /** * Add two monzos. * @param a The first monzo.