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 10, 2024
1 parent 0536280 commit a4c67cb
Show file tree
Hide file tree
Showing 4 changed files with 320 additions and 50 deletions.
34 changes: 31 additions & 3 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 @@ -20,6 +19,7 @@ import {
norm,
valueToCents,
} from '../index';
import {HashMap, HashSet} from '../hashable';

describe('Array equality tester', () => {
it('works on integer arrays', () => {
Expand Down Expand Up @@ -207,7 +207,7 @@ describe('Farey sequence generator', () => {
});

it('agrees with the brute force method', () => {
const everything = new FractionSet();
const everything = new HashSet<Fraction>();
const N = Math.floor(Math.random() * 50) + 1;
for (let d = 1; d <= N; ++d) {
for (let n = 0; n <= d; ++n) {
Expand Down Expand Up @@ -254,7 +254,7 @@ describe('Farey interior generator', () => {
});

it('agrees with the brute force method', () => {
const everything = new FractionSet();
const everything = new HashSet<Fraction>();
const N = Math.floor(Math.random() * 50) + 1;
for (let d = 1; d <= N; ++d) {
for (let n = 1; n < d; ++n) {
Expand Down Expand Up @@ -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<number | string | Fraction, string | number>([
[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);
});
});
18 changes: 17 additions & 1 deletion src/fraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {valueToCents} from './conversion';
import {PRIMES} from './primes';
import {Hashable} from './hashable';

export type UnsignedFraction = {n: number; d: number};

Expand Down Expand Up @@ -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 */
Expand All @@ -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');
Expand Down Expand Up @@ -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)
Expand Down
271 changes: 271 additions & 0 deletions src/hashable.ts
Original file line number Diff line number Diff line change
@@ -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<Key extends Hashable | Primitive, Value>
implements Map<Key, Value>
{
private map: Map<Primitive, [Key, Value][]>;

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<Key> {
for (const subEntries of this.map.values()) {
for (const [key] of subEntries) {
yield key;
}
}
}

*values(): IterableIterator<Value> {
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<Key, Value>) => 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<T extends Hashable | Primitive> implements Set<T> {
private hashmap: HashMap<T, undefined>;
constructor(values?: Iterable<T>) {
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<T> {
yield* this.hashmap.keys();
}
*values(): IterableIterator<T> {
yield* this.hashmap.keys();
}
*[Symbol.iterator]() {
yield* this.hashmap.keys();
}

get [Symbol.toStringTag]() {
return 'HashSet';
}
forEach(
callbackfn: (value: T, value2: T, set: Set<T>) => void,
thisArg?: any
): void {
callbackfn = callbackfn.bind(thisArg);
this.hashmap.forEach((_, key) => callbackfn(key, key, this));
}
}
Loading

0 comments on commit a4c67cb

Please sign in to comment.