From e35f10b0ee3f2ddf8b2532236096d22ef6506e60 Mon Sep 17 00:00:00 2001 From: "Johnny C. Lam" Date: Sat, 14 Aug 2021 04:42:13 -0400 Subject: [PATCH] feat: add doubly-linked list implementation (#970) * feat: add doubly-linked list implementation Add doubly-linked list implemented as a circular list. The API of the `List` class is almost identical to the `Deque` API. A `List` uses more memory than a `Queue`, but a `List` takes O(1) time to do insertions and removals anywhere in the list, whereas a `Queue` only takes O(1) time to do insertions and removals at the front and back of the queue, and O(n) everywhere else. Also add a testsuite for the `List` class. * feat: adjust push() and unshift() to return the added list node * feat: add LRU and MRU cache implementations * feat: add remove() to cache classes Add remove(value) to remove a value from the cache independently from the eviction policy. --- src/tools/cache.spec.ts | 100 +++++++++++ src/tools/cache.ts | 67 +++++++ src/tools/list.spec.ts | 383 ++++++++++++++++++++++++++++++++++++++++ src/tools/list.ts | 258 +++++++++++++++++++++++++++ 4 files changed, 808 insertions(+) create mode 100644 src/tools/cache.spec.ts create mode 100644 src/tools/cache.ts create mode 100644 src/tools/list.spec.ts create mode 100644 src/tools/list.ts diff --git a/src/tools/cache.spec.ts b/src/tools/cache.spec.ts new file mode 100644 index 000000000..0c93f97c3 --- /dev/null +++ b/src/tools/cache.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@jest/globals"; +import { LRUCache, MRUCache } from "./cache"; + +test("new LRU cache is not full", () => { + const cache = new LRUCache(3); + expect(cache.isFull()).toBe(false); +}); + +test("put into empty LRU cache", () => { + const cache = new LRUCache(3); + const value = cache.put(10); + expect(value).toBe(undefined); + expect(cache.oldest()).toBe(10); + expect(cache.oldest()).toBe(cache.newest()); +}); + +test("remove from empty LRU cache", () => { + const cache = new LRUCache(3); + cache.remove(10); + expect(cache.asArray()).toEqual({}); +}); + +test("put into non-empty LRU cache", () => { + const cache = new LRUCache(3); + cache.put(10); + cache.put(20); + const value = cache.put(30); + expect(value).toBe(undefined); + expect(cache.oldest()).toBe(10); + expect(cache.newest()).toBe(30); + expect(cache.asArray()).toEqual({ 1: 10, 2: 20, 3: 30 }); +}); + +test("remove from non-empty LRU cache", () => { + const cache = new LRUCache(3); + cache.put(10); + cache.put(20); + cache.remove(10); + expect(cache.oldest()).toBe(20); + expect(cache.asArray()).toEqual({ 1: 20 }); +}); + +test("put into full LRU cache", () => { + const cache = new LRUCache(3); + cache.put(10); + cache.put(20); + cache.put(30); + const value = cache.put(40); + expect(value).toBe(10); + expect(cache.asArray()).toEqual({ 1: 20, 2: 30, 3: 40 }); +}); + +test("new MRU cache is not full", () => { + const cache = new MRUCache(3); + expect(cache.isFull()).toBe(false); +}); + +test("remove from empty MRU uache", () => { + const cache = new MRUCache(3); + cache.remove(10); + expect(cache.asArray()).toEqual({}); +}); + +test("put into empty MRU cache", () => { + const cache = new MRUCache(3); + const value = cache.put(10); + expect(value).toBe(undefined); + expect(cache.oldest()).toBe(10); + expect(cache.oldest()).toBe(cache.newest()); +}); + +test("put into non-empty MRU cache", () => { + const cache = new MRUCache(3); + cache.put(10); + cache.put(20); + const value = cache.put(30); + expect(value).toBe(undefined); + expect(cache.oldest()).toBe(10); + expect(cache.newest()).toBe(30); + expect(cache.asArray()).toEqual({ 1: 10, 2: 20, 3: 30 }); +}); + +test("remove from non-empty MRU cache", () => { + const cache = new MRUCache(3); + cache.put(10); + cache.put(20); + cache.remove(10); + expect(cache.oldest()).toBe(20); + expect(cache.asArray()).toEqual({ 1: 20 }); +}); + +test("put into full MRU cache", () => { + const cache = new MRUCache(3); + cache.put(10); + cache.put(20); + cache.put(30); + const value = cache.put(40); + expect(value).toBe(30); + expect(cache.asArray()).toEqual({ 1: 10, 2: 20, 3: 40 }); +}); diff --git a/src/tools/cache.ts b/src/tools/cache.ts new file mode 100644 index 000000000..cf0589bbb --- /dev/null +++ b/src/tools/cache.ts @@ -0,0 +1,67 @@ +import { LuaObj } from "@wowts/lua"; +import { List, ListNode } from "./list"; + +class Cache { + list: List; + nodeByValue: LuaObj> = {}; + + constructor(public size: number) { + this.list = new List(); + } + + isFull() { + return this.list.length >= this.size; + } + + newest() { + return this.list.back(); + } + + oldest() { + return this.list.front(); + } + + asArray() { + return this.list.asArray(); + } + + evict() { + return this.list.shift(); + } + + put(value: T) { + this.remove(value); + const evicted = (this.isFull() && this.evict()) || undefined; + /* Pretend to cast to string to satisfy TypeScript. + * Lua tables can accept anything as a valid key. + */ + const key = value as unknown as string; + this.nodeByValue[key] = this.list.push(value); + return evicted; + } + + remove(value: T) { + /* Pretend to cast to string to satisfy TypeScript. + * Lua tables can accept anything as a valid key. + */ + const key = value as unknown as string; + const node = this.nodeByValue[key]; + if (node) { + this.list.remove(node); + } + } +} + +export class LRUCache extends Cache { + evict() { + // LRU policy evicts the oldest item + return this.list.shift(); + } +} + +export class MRUCache extends Cache { + evict() { + // MRU policy evicts the newest item + return this.list.pop(); + } +} diff --git a/src/tools/list.spec.ts b/src/tools/list.spec.ts new file mode 100644 index 000000000..b98424553 --- /dev/null +++ b/src/tools/list.spec.ts @@ -0,0 +1,383 @@ +import { expect, test } from "@jest/globals"; +import { LuaArray } from "@wowts/lua"; +import { List } from "./list"; + +test("new list is empty", () => { + const l = new List(); + expect(l.isEmpty()).toBe(true); + expect(l.length).toBe(0); + expect(l.front()).toBe(undefined); + expect(l.back()).toBe(undefined); +}); + +test("from empty array", () => { + const l = new List(); + l.fromArray({}); + expect(l.length).toBe(0); + expect(l.asArray()).toEqual({}); +}); + +test("nodeOf from empety list", () => { + const l = new List(); + const [node, index] = l.nodeOf(10); + expect(node).toBe(undefined); + expect(index).toBe(undefined); +}); + +test("indexOf from empty list", () => { + const l = new List(); + expect(l.indexOf(10)).toBe(0); +}); + +test("from one-element array", () => { + const l = new List(); + const expected = { 1: 10 }; + l.fromArray(expected); + expect(l.length).toBe(1); + expect(l.back()).toBe(10); + expect(l.asArray()).toEqual(expected); +}); + +test("from two-element array", () => { + const l = new List(); + const expected = { 1: 10, 2: 20 }; + l.fromArray(expected); + expect(l.length).toBe(2); + expect(l.front()).toBe(10); + expect(l.back()).toBe(20); + expect(l.asArray()).toEqual(expected); +}); + +test("as array", () => { + const l = new List(); + const t = { 1: 10, 2: 20, 3: 30 }; + l.fromArray(t); + expect(l.asArray()).toEqual(t); + expect(l.asArray(true)).toEqual({ 1: 30, 2: 20, 3: 10 }); +}); + +test("push onto empty list", () => { + const l = new List(); + const node = l.push(10); + expect(l.length).toBe(1); + expect(l.back()).toBe(10); + expect(l.head).toBe(node); +}); + +test("unshift empty list", () => { + const l = new List(); + const node = l.unshift(10); + expect(l.length).toBe(1); + expect(l.front()).toBe(10); + expect(l.head).toBe(node); +}); + +test("pop from empty list", () => { + const l = new List(); + expect(l.pop()).toBe(undefined); +}); + +test("shift empty list", () => { + const l = new List(); + expect(l.shift()).toBe(undefined); +}); + +test("one-element list has same front and back", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + expect(l.front()).toBe(l.back()); +}); + +test("insert after into one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + const [node] = l.nodeOf(10); + l.insertAfter(node, 20); + expect(l.asArray()).toEqual({ 1: 10, 2: 20 }); +}); + +test("insert before into one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + const [node] = l.nodeOf(10); + l.insertBefore(node, 20); + expect(l.asArray()).toEqual({ 1: 20, 2: 10 }); +}); + +test("push onto one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + const node = l.push(20); + expect(l.length).toBe(2); + expect(l.back()).toBe(20); + expect(l.head && l.head.next).toBe(node); +}); + +test("unshift one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + const node = l.unshift(20); + expect(l.length).toBe(2); + expect(l.front()).toBe(20); + expect(l.head).toBe(node); +}); + +test("pop from one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + expect(l.pop()).toBe(10); + expect(l.length).toBe(0); +}); + +test("shift one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + expect(l.shift()).toBe(10); + expect(l.length).toBe(0); +}); + +test("remove at 1 of one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + expect(l.removeAt(1)).toBe(10); + expect(l.length).toBe(0); +}); + +test("nodeOf missing from one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + const [node, index] = l.nodeOf(20); + expect(node).toBe(undefined); + expect(index).toBe(undefined); +}); + +test("indexOf missing from one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + expect(l.indexOf(20)).toBe(0); +}); + +test("insert after front of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node] = l.nodeOf(10); + l.insertAfter(node, 40); + expect(l.asArray()).toEqual({ 1: 10, 2: 40, 3: 20, 4: 30 }); +}); + +test("insert after back of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node] = l.nodeOf(30); + l.insertAfter(node, 40); + expect(l.asArray()).toEqual({ 1: 10, 2: 20, 3: 30, 4: 40 }); +}); + +test("insert after middle of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node] = l.nodeOf(20); + l.insertAfter(node, 40); + expect(l.asArray()).toEqual({ 1: 10, 2: 20, 3: 40, 4: 30 }); +}); + +test("insert before front of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node] = l.nodeOf(10); + l.insertBefore(node, 40); + expect(l.asArray()).toEqual({ 1: 40, 2: 10, 3: 20, 4: 30 }); +}); + +test("insert before back of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node] = l.nodeOf(30); + l.insertBefore(node, 40); + expect(l.asArray()).toEqual({ 1: 10, 2: 20, 3: 40, 4: 30 }); +}); + +test("insert before middle of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node] = l.nodeOf(20); + l.insertBefore(node, 40); + expect(l.asArray()).toEqual({ 1: 10, 2: 40, 3: 20, 4: 30 }); +}); + +test("push onto list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20 }); + const node = l.push(30); + expect(l.asArray()).toEqual({ 1: 10, 2: 20, 3: 30 }); + expect(l.head && l.head.next.next).toBe(node); +}); + +test("unshift list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20 }); + const node = l.unshift(30); + expect(l.asArray()).toEqual({ 1: 30, 2: 10, 3: 20 }); + expect(l.head).toBe(node); +}); + +test("pop from list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.pop()).toBe(30); + expect(l.asArray()).toEqual({ 1: 10, 2: 20 }); +}); + +test("shift list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.shift()).toBe(10); + expect(l.asArray()).toEqual({ 1: 20, 2: 30 }); +}); + +test("insert at front of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + l.insertAt(1, 40); + expect(l.asArray()).toEqual({ 1: 40, 2: 10, 3: 20, 4: 30 }); +}); + +test("insert at middle of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + l.insertAt(l.length, 40); + expect(l.asArray()).toEqual({ 1: 10, 2: 20, 3: 40, 4: 30 }); +}); + +test("remove at front of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.removeAt(1)).toBe(10); + expect(l.asArray()).toEqual({ 1: 20, 2: 30 }); +}); + +test("remove at back of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.removeAt(l.length)).toBe(30); + expect(l.asArray()).toEqual({ 1: 10, 2: 20 }); +}); + +test("remove at middle of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.removeAt(2)).toBe(20); + expect(l.asArray()).toEqual({ 1: 10, 2: 30 }); +}); + +test("nodeOf missing from list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node, index] = l.nodeOf(40); + expect(node).toBe(undefined); + expect(index).toBe(undefined); +}); + +test("nodeOf existing from front of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node, index] = l.nodeOf(10); + expect(node && node.value).toBe(10); + expect(index).toBe(1); +}); + +test("nodeOf existing from back of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node, index] = l.nodeOf(30); + expect(node && node.value).toBe(30); + expect(index).toBe(3); +}); + +test("nodeOf existing from middle of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const [node, index] = l.nodeOf(20); + expect(node && node.value).toBe(20); + expect(index).toBe(2); +}); + +test("indexOf missing from list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.indexOf(40)).toBe(0); +}); + +test("indexOf existing from front of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.indexOf(10)).toBe(1); +}); + +test("indexOf existing from back of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.indexOf(30)).toBe(3); +}); + +test("indexOf existing from middle of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + expect(l.indexOf(20)).toBe(2); +}); + +test("back to front iterator of empty list", () => { + const l = new List(); + const iterator = l.backToFrontIterator(); + expect(iterator.next()).toBe(false); +}); + +test("back to front iterator of one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + const t: LuaArray = {}; + const iterator = l.backToFrontIterator(); + for (let i = 1; iterator.next(); i++) { + t[i] = iterator.value; + } + expect(t).toEqual(l.asArray()); +}); + +test("back to front iterator of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const t: LuaArray = {}; + const iterator = l.backToFrontIterator(); + for (let i = 1; iterator.next(); i++) { + t[i] = iterator.value; + } + expect(t).toEqual(l.asArray(true)); +}); + +test("front to back iterator of empty list", () => { + const l = new List(); + const iterator = l.frontToBackIterator(); + expect(iterator.next()).toBe(false); +}); + +test("front to back iterator of one-element list", () => { + const l = new List(); + l.fromArray({ 1: 10 }); + const t: LuaArray = {}; + const iterator = l.frontToBackIterator(); + for (let i = 1; iterator.next(); i++) { + t[i] = iterator.value; + } + expect(t).toEqual(l.asArray()); +}); + +test("front to back iterator of list", () => { + const l = new List(); + l.fromArray({ 1: 10, 2: 20, 3: 30 }); + const t: LuaArray = {}; + const iterator = l.frontToBackIterator(); + for (let i = 1; iterator.next(); i++) { + t[i] = iterator.value; + } + expect(t).toEqual(l.asArray()); +}); diff --git a/src/tools/list.ts b/src/tools/list.ts new file mode 100644 index 000000000..6d0f4dbf7 --- /dev/null +++ b/src/tools/list.ts @@ -0,0 +1,258 @@ +import { LuaArray, ipairs } from "@wowts/lua"; + +interface Iterator { + value: T; + next(): boolean; +} + +class ListBackToFrontIterator implements Iterator { + public value!: T; + public node: ListNode | undefined; + private remaining = 0; + + constructor(list: List) { + this.node = list.head; + this.remaining = list.length; + } + + next() { + if (this.node && this.remaining > 0) { + this.node = this.node.prev; + this.value = this.node.value; + this.remaining -= 1; + return this.remaining >= 0; + } + return false; + } +} + +class ListFrontToBackIterator implements Iterator { + public value!: T; + public node: ListNode | undefined; + private remaining = 0; + + constructor(list: List) { + this.node = (list.head && list.head.prev) || undefined; + this.remaining = list.length; + } + + next() { + if (this.node && this.remaining > 0) { + this.node = this.node.next; + this.value = this.node.value; + this.remaining -= 1; + return this.remaining >= 0; + } + return false; + } +} + +export class ListNode { + next: ListNode; + prev: ListNode; + constructor(public value: T) { + this.next = this; + this.prev = this; + } +} + +/* Doubly-linked circular list: + * - O(1) complexity to do insertions and removals anywhere. + * - O(n) complexity to find a value in the list. + */ +export class List { + head: ListNode | undefined; + length = 0; + + constructor() { + this.head = undefined; + } + + isEmpty() { + return this.length == 0; + } + + front() { + return (this.head && this.head.value) || undefined; + } + + back() { + return (this.head && this.head.prev.value) || undefined; + } + + backToFrontIterator() { + return new ListBackToFrontIterator(this); + } + + frontToBackIterator() { + return new ListFrontToBackIterator(this); + } + + fromArray(t: LuaArray) { + for (const [, value] of ipairs(t)) { + this.push(value); + } + } + + asArray(reverse?: boolean) { + const t: LuaArray = {}; + const iterator = + (reverse == true && this.backToFrontIterator()) || + this.frontToBackIterator(); + for (let i = 1; iterator.next(); i++) { + t[i] = iterator.value; + } + return t; + } + + nodeOf(value: T): [ListNode | undefined, number | undefined] { + let node = this.head; + let index = 1; + if (node) { + for (let remains = this.length; remains > 0; remains--) { + if (node.value == value) { + return [node, index]; + } + node = node.next; + index += 1; + } + } + return [undefined, undefined]; + } + + nodeAt(index: number) { + const length = this.length; + if (length > 0) { + while (index > length) { + index -= length; + } + while (index < 1) { + index += length; + } + if (this.head) { + let node = this.head; + if (index <= length - index) { + for (let remains = index - 1; remains > 0; remains--) { + node = node.next; + } + } else { + for ( + let remains = length - index + 1; + remains > 0; + remains-- + ) { + node = node.prev; + } + } + return node; + } + } + return undefined; + } + + insertAfter(node: ListNode | undefined, value: T) { + if (node) { + const head = this.head; + this.head = node.next; + this.unshift(value); + this.head = head; + } + } + + insertBefore(node: ListNode | undefined, value: T) { + if (node) { + const head = this.head; + if (node == head) { + this.unshift(value); + } else { + this.head = node; + this.push(value); + this.head = head; + } + } + } + + remove(node: ListNode | undefined) { + if (node) { + const head = this.head; + this.head = node.next; + this.pop(); + if (this.head && head != node) { + this.head = head; + } + } + } + + indexOf(value: T) { + const [, index] = this.nodeOf(value); + return index || 0; + } + + at(index: number) { + const node = this.nodeAt(index); + return (node && node.value) || undefined; + } + + insertAt(index: number, value: T) { + const node = this.nodeAt(index); + if (node) { + this.insertBefore(node, value); + } + } + + removeAt(index: number) { + const node = this.nodeAt(index); + if (node) { + this.remove(node); + return node.value; + } + return undefined; + } + + push(value: T) { + const node = new ListNode(value); + if (!this.head) { + this.head = node; + } else { + node.next = this.head; + node.prev = this.head.prev; + this.head.prev.next = node; + this.head.prev = node; + } + this.length += 1; + return node; + } + + pop() { + if (this.head) { + const node = this.head.prev; + const value = node.value; + if (node == this.head) { + this.head = undefined; + this.length = 0; + } else { + node.prev.next = this.head; + this.head.prev = node.prev; + this.length -= 1; + } + return value; + } + return undefined; + } + + unshift(value: T) { + this.push(value); + if (this.head) { + this.head = this.head.prev; + } + return this.head; + } + + shift() { + const value = this.front(); + if (this.head) { + this.remove(this.head); + } + return value; + } +}