@naturalcycles/js-lib
Version:
Standard library for universal (browser + Node.js) javascript
254 lines (217 loc) • 6.43 kB
text/typescript
import { _assert } from '../error/index.js'
import type { Comparator } from '../types.js'
export interface KeySortedMapOptions<K> {
/**
* Defaults to undefined.
* Undefined (default comparator) works well for String keys.
* For Number keys - use comparators.numericAsc (or desc),
* otherwise sorting will be wrong (lexicographic).
*/
comparator?: Comparator<K>
}
/**
* Maintains sorted array of keys.
* Sorts **on insertion**, not on retrieval.
*
* - set(): O(log n) search + O(n) splice only when inserting a NEW key
* - get/has: O(1)
* - delete: O(log n) search + O(n) splice if present
* - iteration: O(n) over pre-sorted keys (no sorting at iteration time)
*
* @experimental
*/
export class KeySortedMap<K, V> implements Map<K, V> {
private readonly map: Map<K, V>
readonly #sortedKeys: K[]
constructor(entries: [K, V][] = [], opt: KeySortedMapOptions<K> = {}) {
this.#comparator = opt.comparator
this.map = new Map(entries)
this.#sortedKeys = [...this.map.keys()]
this.sortKeys()
}
readonly #comparator: Comparator<K> | undefined
/**
* Convenience way to create KeySortedMap from object.
*/
static of<V>(obj: Record<any, V>): KeySortedMap<string, V> {
return new KeySortedMap(Object.entries(obj))
}
get size(): number {
return this.map.size
}
clear(): void {
this.map.clear()
this.#sortedKeys.length = 0
}
has(key: K): boolean {
return this.map.has(key)
}
get(key: K): V | undefined {
return this.map.get(key)
}
/**
* Allows to set multiple key-value pairs at once.
*/
setMany(obj: Record<any, V>): this {
for (const [k, v] of Object.entries(obj)) {
this.map.set(k as K, v)
this.#sortedKeys.push(k as K)
}
// Resort all at once
this.sortKeys()
return this
}
/**
* Insert or update. Keeps keys array sorted at all times.
* Returns this (Map-like).
*/
set(key: K, value: V): this {
if (this.map.has(key)) {
// Update only; position unchanged.
this.map.set(key, value)
return this
}
// Find insertion index (lower_bound).
const i = this.lowerBound(key)
// Only insert into keys when actually new.
this.#sortedKeys.splice(i, 0, key)
this.map.set(key, value)
return this
}
/**
* Delete by key. Returns boolean like Map.delete.
*/
delete(key: K): boolean {
if (!this.map.has(key)) return false
this.map.delete(key)
// Remove from keys using binary search to avoid O(n) find.
const i = this.lowerBound(key)
// Because key existed, it must be at i.
if (i < this.#sortedKeys.length && this.#sortedKeys[i] === key) {
this.#sortedKeys.splice(i, 1)
} else {
// Extremely unlikely if external mutation happened; safe guard.
// Fall back to linear search (shouldn't happen).
const j = this.#sortedKeys.indexOf(key)
if (j !== -1) this.#sortedKeys.splice(j, 1)
}
return true
}
/**
* Iterables (Map-compatible), all in sorted order.
*/
*keys(): MapIterator<K> {
for (const key of this.#sortedKeys) {
yield key
}
}
*values(): MapIterator<V> {
for (const key of this.#sortedKeys) {
yield this.map.get(key)!
}
}
*entries(): MapIterator<[K, V]> {
for (const k of this.#sortedKeys) {
yield [k, this.map.get(k)!]
}
}
[Symbol.iterator](): MapIterator<[K, V]> {
return this.entries()
}
toString(): string {
console.log('toString called !!!!!!!!!!!!!!!!!!!!!')
return 'abc'
}
[Symbol.toStringTag] = 'KeySortedMap'
/**
* Zero-allocation callbacks over sorted data (faster than spreading to arrays).
*/
forEach(cb: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void {
const { map } = this
for (const k of this.#sortedKeys) {
cb.call(thisArg, map.get(k)!, k, this)
}
}
firstKeyOrUndefined(): K | undefined {
return this.#sortedKeys[0]
}
firstKey(): K {
_assert(this.#sortedKeys.length, 'Map.firstKey called on empty map')
return this.#sortedKeys[0]!
}
lastKeyOrUndefined(): K | undefined {
return this.#sortedKeys.length ? this.#sortedKeys[this.#sortedKeys.length - 1] : undefined
}
lastKey(): K {
_assert(this.#sortedKeys.length, 'Map.lastKey called on empty map')
return this.#sortedKeys[this.#sortedKeys.length - 1]!
}
firstValueOrUndefined(): V | undefined {
return this.map.get(this.#sortedKeys[0]!)
}
firstValue(): V {
_assert(this.#sortedKeys.length, 'Map.firstValue called on empty map')
return this.map.get(this.#sortedKeys[0]!)!
}
lastValueOrUndefined(): V | undefined {
return this.#sortedKeys.length
? this.map.get(this.#sortedKeys[this.#sortedKeys.length - 1]!)
: undefined
}
lastValue(): V {
_assert(this.#sortedKeys.length, 'Map.lastValue called on empty map')
return this.map.get(this.#sortedKeys[this.#sortedKeys.length - 1]!)!
}
firstEntryOrUndefined(): [K, V] | undefined {
if (!this.#sortedKeys.length) return
const k = this.#sortedKeys[0]!
return [k, this.map.get(k)!]
}
firstEntry(): [K, V] {
_assert(this.#sortedKeys.length, 'Map.firstEntry called on empty map')
const k = this.#sortedKeys[0]!
return [k, this.map.get(k)!]
}
lastEntryOrUndefined(): [K, V] | undefined {
if (!this.#sortedKeys.length) return
const k = this.#sortedKeys[this.#sortedKeys.length - 1]!
return [k, this.map.get(k)!]
}
lastEntry(): [K, V] {
_assert(this.#sortedKeys.length, 'Map.lastEntry called on empty map')
const k = this.#sortedKeys[this.#sortedKeys.length - 1]!
return [k, this.map.get(k)!]
}
toJSON(): Record<string, V> {
return this.toObject()
}
toObject(): Record<string, V> {
return Object.fromEntries(this.entries())
}
/**
* Clones the KeySortedMap into ordinary Map.
*/
toMap(): Map<K, V> {
return new Map(this.entries())
}
/**
* lowerBound: first index i s.t. keys[i] >= target
*/
private lowerBound(target: K): number {
let lo = 0
let hi = this.#sortedKeys.length
while (lo < hi) {
// oxlint-disable-next-line no-bitwise
const mid = (lo + hi) >>> 1
if (this.#sortedKeys[mid]! < target) {
lo = mid + 1
} else {
hi = mid
}
}
return lo
}
private sortKeys(): void {
this.#sortedKeys.sort(this.#comparator)
}
}