From a4c67cb2582e34e20f27db42c81bfaf6f0bb2b6e 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. --- src/__tests__/index.spec.ts | 34 ++++- src/fraction.ts | 18 ++- src/hashable.ts | 271 ++++++++++++++++++++++++++++++++++++ src/index.ts | 47 +------ 4 files changed, 320 insertions(+), 50 deletions(-) create mode 100644 src/hashable.ts diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 1ebb389..70eadc6 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, @@ -20,6 +19,7 @@ import { norm, valueToCents, } from '../index'; +import {HashMap, HashSet} from '../hashable'; describe('Array equality tester', () => { it('works on integer arrays', () => { @@ -207,7 +207,7 @@ 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) { @@ -254,7 +254,7 @@ 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) { @@ -382,3 +382,31 @@ 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([ + [new Fraction(3, 2), 'fif'], + [new Fraction(2), 'octave'], + ]); + hashMap.set(new Fraction('5/4'), 'third'); + hashMap.set(new Fraction(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('can hold mixed data with mixed keys', () => { + const hashMap = new HashMap([ + [1, 'one'], + [new Fraction(1), 'other one'], + ['1', 1], + ]); + expect(hashMap.get(1)).toBe('one'); + expect(hashMap.get(new Fraction('7/7'))).toBe('other one'); + expect(hashMap.get('1')).toBe(1); + }); +}); diff --git a/src/fraction.ts b/src/fraction.ts index 8500f8c..2f3a09a 100644 --- a/src/fraction.ts +++ b/src/fraction.ts @@ -3,6 +3,7 @@ import {valueToCents} from './conversion'; import {PRIMES} from './primes'; +import {Hashable} from './hashable'; export type UnsignedFraction = {n: number; d: number}; @@ -76,7 +77,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 +86,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 +833,20 @@ export class Fraction { } } + /** + * Check if two {@link Fraction} instances represent the same rational number. + * 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: Fraction) { + const {s, n, d} = other; + return this.s === s && this.n === n && this.d === d; + } + /** * Check if two rational numbers are divisible * (i.e. this is an integer multiple of other) diff --git a/src/hashable.ts b/src/hashable.ts new file mode 100644 index 0000000..a4665d2 --- /dev/null +++ b/src/hashable.ts @@ -0,0 +1,271 @@ +/** + * 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): boolean; + + abstract valueOf(): number; +} + +type Primitive = string | number | bigint | boolean | undefined | symbol | null; + +/** + * Python style dictionary implementation using hashable keys. + */ +export class HashMap + 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 (typeof existing !== 'object' || existing === null) { + continue; + } + 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 (existing === key) { + 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 (typeof existing !== 'object' || existing === null) { + continue; + } + if (existing.strictEquals(key)) { + 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 (existing === key) { + 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 (typeof existing !== 'object' || existing === null) { + continue; + } + if (existing.strictEquals(key)) { + 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 (existing === key) { + 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 (typeof existing !== 'object' || existing === null) { + continue; + } + if (existing.strictEquals(key)) { + return true; + } + } + return false; + } + + get size(): number { + let total = 0; + for (const subEntries of this.map) { + 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 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, undefined); + } + } + add(value: T) { + this.hashmap.set(value, undefined); + 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 = [