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 = [