Skip to content

Commit

Permalink
Implement HashMap similar to Python's dict
Browse files Browse the repository at this point in the history
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<Fraction>.
  • Loading branch information
frostburn committed Apr 11, 2024
1 parent 0536280 commit 71dcdfb
Show file tree
Hide file tree
Showing 8 changed files with 449 additions and 54 deletions.
18 changes: 17 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,8 @@
},
"engines": {
"node": ">=10.6.0"
},
"dependencies": {
"ts-essentials": "^9.4.2"
}
}
73 changes: 73 additions & 0 deletions src/__tests__/hashable.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Monzo>;

constructor(vector: Readonly<Monzo>) {
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<Monzo>) {
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<number | ImmutableMonzo, string>([
[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
});
61 changes: 56 additions & 5 deletions src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {describe, it, expect} from 'vitest';
import {
Fraction,
FractionSet,
arraysEqual,
binomial,
ceilPow2,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -207,11 +209,11 @@ describe('Farey sequence generator', () => {
});

it('agrees with the brute force method', () => {
const everything = new FractionSet();
const everything = new HashSet<DeepReadonly<Fraction>>();
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);
Expand Down Expand Up @@ -254,11 +256,11 @@ describe('Farey interior generator', () => {
});

it('agrees with the brute force method', () => {
const everything = new FractionSet();
const everything = new HashSet<DeepReadonly<Fraction>>();
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);
Expand Down Expand Up @@ -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);
});
});
36 changes: 35 additions & 1 deletion src/fraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 */
Expand All @@ -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');
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<Fraction> {
return Object.freeze(new Fraction(numerator, denominator));
}
Loading

0 comments on commit 71dcdfb

Please sign in to comment.