diff --git a/src/04-stack/__test__/stack-js.test.ts b/src/04-stack/__test__/stack-js.test.ts new file mode 100644 index 00000000..3f9656c8 --- /dev/null +++ b/src/04-stack/__test__/stack-js.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Stack = require('../stack.js') as new () => { + push(item: any): void; + pop(): any; + peek(): any; + isEmpty(): boolean; + size: number; + clear(): void; + toString(): string; +}; + +describe('Stack (stack.js)', () => { + let stack: InstanceType; + + beforeEach(() => { + stack = new Stack(); + }); + + test('should create an empty stack', () => { + expect(stack.isEmpty()).toBe(true); + expect(stack.size).toBe(0); + expect(stack.peek()).toBeUndefined(); + expect(stack.pop()).toBeUndefined(); + expect(stack.toString()).toBe('Empty Stack'); + }); + + test('should push items onto the top of the stack', () => { + stack.push(1); + stack.push(2); + stack.push(3); + expect(stack.size).toBe(3); + expect(stack.isEmpty()).toBe(false); + expect(stack.toString()).toBe('1, 2, 3'); + }); + + test('should peek the top item without removing it', () => { + stack.push('a'); + stack.push('b'); + expect(stack.peek()).toBe('b'); + expect(stack.size).toBe(2); + }); + + test('should pop items in LIFO order', () => { + stack.push(1); + stack.push(2); + stack.push(3); + expect(stack.pop()).toBe(3); + expect(stack.pop()).toBe(2); + expect(stack.size).toBe(1); + expect(stack.peek()).toBe(1); + expect(stack.pop()).toBe(1); + expect(stack.isEmpty()).toBe(true); + expect(stack.pop()).toBeUndefined(); + }); + + test('should clear all items from the stack', () => { + stack.push(1); + stack.push(2); + stack.clear(); + expect(stack.isEmpty()).toBe(true); + expect(stack.size).toBe(0); + expect(stack.toString()).toBe('Empty Stack'); + }); + + test('should stringify object items using JSON.stringify', () => { + stack.push({ a: 1 }); + stack.push([1, 2]); + expect(stack.toString()).toBe('{"a":1}, [1,2]'); + }); + + test('should stringify primitive items using String()', () => { + stack.push(1); + stack.push('two'); + stack.push(true); + stack.push(null); + stack.push(undefined); + expect(stack.toString()).toBe('1, two, true, null, undefined'); + }); + + test('should handle a single item', () => { + stack.push(42); + expect(stack.size).toBe(1); + expect(stack.peek()).toBe(42); + expect(stack.toString()).toBe('42'); + expect(stack.pop()).toBe(42); + expect(stack.isEmpty()).toBe(true); + }); +}); diff --git a/src/04-stack/stack.js b/src/04-stack/stack.js index 22677113..4cdcf445 100644 --- a/src/04-stack/stack.js +++ b/src/04-stack/stack.js @@ -63,7 +63,7 @@ class Stack { if (typeof item === 'object' && item !== null) { // {2} return JSON.stringify(item); // Handle objects } else { - return item.toString(); // Handle other types {3} + return String(item); // Handle other types {3} } }).join(', '); // {4} } diff --git a/src/05-queue-deque/__test__/queue-js.test.ts b/src/05-queue-deque/__test__/queue-js.test.ts new file mode 100644 index 00000000..261767ec --- /dev/null +++ b/src/05-queue-deque/__test__/queue-js.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Queue = require('../queue.js') as new () => { + enqueue(item: any): void; + dequeue(): any; + front(): any; + isEmpty(): boolean; + size: number; + clear(): void; + toString(): string; +}; + +describe('Queue (queue.js)', () => { + let queue: InstanceType; + + beforeEach(() => { + queue = new Queue(); + }); + + test('should create an empty queue', () => { + expect(queue.isEmpty()).toBe(true); + expect(queue.size).toBe(0); + expect(queue.front()).toBeUndefined(); + expect(queue.dequeue()).toBeUndefined(); + expect(queue.toString()).toBe('Empty Queue'); + }); + + test('should enqueue items to the rear of the queue', () => { + queue.enqueue(1); + queue.enqueue(2); + queue.enqueue(3); + expect(queue.size).toBe(3); + expect(queue.isEmpty()).toBe(false); + expect(queue.toString()).toBe('1, 2, 3'); + }); + + test('should return the front item without removing it', () => { + queue.enqueue('a'); + queue.enqueue('b'); + expect(queue.front()).toBe('a'); + expect(queue.size).toBe(2); + }); + + test('should dequeue items in FIFO order', () => { + queue.enqueue(1); + queue.enqueue(2); + queue.enqueue(3); + expect(queue.dequeue()).toBe(1); + expect(queue.dequeue()).toBe(2); + expect(queue.size).toBe(1); + expect(queue.front()).toBe(3); + expect(queue.dequeue()).toBe(3); + expect(queue.isEmpty()).toBe(true); + expect(queue.dequeue()).toBeUndefined(); + }); + + test('should clear all items from the queue', () => { + queue.enqueue(1); + queue.enqueue(2); + queue.clear(); + expect(queue.isEmpty()).toBe(true); + expect(queue.size).toBe(0); + expect(queue.toString()).toBe('Empty Queue'); + }); + + test('should stringify object items using JSON.stringify', () => { + queue.enqueue({ a: 1 }); + queue.enqueue([1, 2]); + expect(queue.toString()).toBe('{"a":1}, [1,2]'); + }); + + test('should stringify primitive items using String()', () => { + queue.enqueue(1); + queue.enqueue('two'); + queue.enqueue(true); + queue.enqueue(null); + queue.enqueue(undefined); + expect(queue.toString()).toBe('1, two, true, null, undefined'); + }); + + test('should handle a single item', () => { + queue.enqueue(42); + expect(queue.size).toBe(1); + expect(queue.front()).toBe(42); + expect(queue.toString()).toBe('42'); + expect(queue.dequeue()).toBe(42); + expect(queue.isEmpty()).toBe(true); + }); +}); diff --git a/src/05-queue-deque/queue.js b/src/05-queue-deque/queue.js index abbf3bd7..3f67f9f0 100644 --- a/src/05-queue-deque/queue.js +++ b/src/05-queue-deque/queue.js @@ -36,7 +36,7 @@ class Queue { if (typeof item === 'object' && item !== null) { // {2} return JSON.stringify(item); // Handle objects } else { - return item.toString(); // Handle other types {3} + return String(item); // Handle other types {3} } }).join(', '); // {4} } diff --git a/src/06-linked-list/__test__/circular-linked-list_.test.ts b/src/06-linked-list/__test__/circular-linked-list_.test.ts new file mode 100644 index 00000000..899114c8 --- /dev/null +++ b/src/06-linked-list/__test__/circular-linked-list_.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const CircularLinkedList = require('../circular-linked-list_.js') as new () => { + append(data: any): void; + prepend(data: any): void; + insert(data: any, position: number): boolean; + removeAt(position: number): any; + size: number; + isEmpty(): boolean; + clear(): void; + remove(data: any): any; + indexOf(data: any): number; + toString(): string; + reverse(): void; +}; + +describe('CircularLinkedList (circular-linked-list_.js)', () => { + let list: InstanceType; + + beforeEach(() => { + list = new CircularLinkedList(); + }); + + test('should create an empty list', () => { + expect(list.isEmpty()).toBe(true); + expect(list.size).toBe(0); + expect(list.toString()).toBe(''); + }); + + test('should append elements and keep the list circular', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.toString()).toBe('1 -> 2 -> 3'); + expect(list.size).toBe(3); + expect(list.isEmpty()).toBe(false); + }); + + test('should append a single element pointing to itself', () => { + list.append(1); + expect(list.toString()).toBe('1'); + expect(list.size).toBe(1); + }); + + test('should prepend elements to the beginning of the list', () => { + list.append(2); + list.append(3); + list.prepend(1); + expect(list.toString()).toBe('1 -> 2 -> 3'); + expect(list.size).toBe(3); + }); + + test('should prepend to an empty list', () => { + list.prepend(1); + expect(list.toString()).toBe('1'); + expect(list.size).toBe(1); + }); + + test('should keep the list circular after multiple prepends', () => { + list.prepend(3); + list.prepend(2); + list.prepend(1); + expect(list.toString()).toBe('1 -> 2 -> 3'); + expect(list.indexOf(3)).toBe(2); + expect(list.size).toBe(3); + }); + + test('should insert an element at position 0 (prepend behavior)', () => { + list.append(2); + expect(list.insert(1, 0)).toBe(true); + expect(list.toString()).toBe('1 -> 2'); + }); + + test('should insert an element in the middle', () => { + list.append(1); + list.append(3); + expect(list.insert(2, 1)).toBe(true); + expect(list.toString()).toBe('1 -> 2 -> 3'); + expect(list.size).toBe(3); + }); + + test('should return false when inserting at an invalid position', () => { + list.append(1); + expect(list.insert(5, -1)).toBe(false); + expect(list.insert(5, 10)).toBe(false); + expect(list.size).toBe(1); + }); + + test('should remove from the head', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.removeAt(0)).toBe(1); + expect(list.toString()).toBe('2 -> 3'); + expect(list.size).toBe(2); + }); + + test('should remove from the tail', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.removeAt(2)).toBe(3); + expect(list.toString()).toBe('1 -> 2'); + expect(list.size).toBe(2); + }); + + test('should remove from the middle', () => { + list.append(1); + list.append(2); + list.append(3); + list.append(4); + expect(list.removeAt(1)).toBe(2); + expect(list.toString()).toBe('1 -> 3 -> 4'); + expect(list.size).toBe(3); + }); + + test('should remove the only element and become empty', () => { + list.append(1); + expect(list.removeAt(0)).toBe(1); + expect(list.isEmpty()).toBe(true); + expect(list.size).toBe(0); + expect(list.toString()).toBe(''); + }); + + test('should remove the tail of a single-element list', () => { + list.append(1); + expect(list.removeAt(0)).toBe(1); + expect(list.isEmpty()).toBe(true); + }); + + test('should throw when removing from an empty list', () => { + expect(() => list.removeAt(0)).toThrow(RangeError); + }); + + test('should throw when removing at an invalid position', () => { + list.append(1); + expect(() => list.removeAt(-1)).toThrow(RangeError); + expect(() => list.removeAt(5)).toThrow(RangeError); + }); + + test('should keep the list circular after removing from the middle', () => { + list.append(1); + list.append(2); + list.append(3); + list.removeAt(1); + // append again to make sure the internal circular link was preserved + list.append(4); + expect(list.toString()).toBe('1 -> 3 -> 4'); + }); + + test('should find the index of an element', () => { + list.append('a'); + list.append('b'); + list.append('c'); + expect(list.indexOf('b')).toBe(1); + expect(list.indexOf('z')).toBe(-1); + }); + + test('should return -1 when calling indexOf on an empty list', () => { + expect(list.indexOf('anything')).toBe(-1); + }); + + test('should remove an element by value', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.remove(2)).toBe(2); + expect(list.toString()).toBe('1 -> 3'); + expect(list.size).toBe(2); + }); + + test('should return null when removing a value not present', () => { + list.append(1); + expect(list.remove(99)).toBeNull(); + expect(list.size).toBe(1); + }); + + test('should clear all elements', () => { + list.append(1); + list.append(2); + list.clear(); + expect(list.isEmpty()).toBe(true); + expect(list.size).toBe(0); + expect(list.toString()).toBe(''); + }); + + test('should reverse the list', () => { + list.append(1); + list.append(2); + list.append(3); + list.reverse(); + expect(list.toString().startsWith('3 -> 2 -> 1')).toBe(true); + }); +}); diff --git a/src/06-linked-list/__test__/doubly-linked-list_.test.ts b/src/06-linked-list/__test__/doubly-linked-list_.test.ts new file mode 100644 index 00000000..42a01229 --- /dev/null +++ b/src/06-linked-list/__test__/doubly-linked-list_.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const DoublyLinkedList = require('../doubly-linked-list_.js') as new () => { + append(data: any): void; + prepend(data: any): void; + insert(data: any, position: number): boolean; + removeAt(position: number): any; + size: number; + isEmpty(): boolean; + indexOf(data: any, compareFunction?: (a: any, b: any) => boolean): number; + clear(): void; + toString(): string; + inverseToString(): string; + reverse(): void; +}; + +describe('DoublyLinkedList (doubly-linked-list_.js)', () => { + let list: InstanceType; + + beforeEach(() => { + list = new DoublyLinkedList(); + }); + + test('should create an empty list', () => { + expect(list.isEmpty()).toBe(true); + expect(list.size).toBe(0); + expect(list.toString()).toBe(''); + expect(list.inverseToString()).toBe(''); + }); + + test('should append elements to the end of the list', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.toString()).toBe('1, 2, 3'); + expect(list.size).toBe(3); + expect(list.isEmpty()).toBe(false); + }); + + test('should prepend elements to the beginning of the list', () => { + list.prepend(1); + expect(list.toString()).toBe('1'); + list.append(2); + list.prepend(0); + expect(list.toString()).toBe('0, 1, 2'); + expect(list.size).toBe(3); + }); + + test('should insert an element at position 0 (prepend behavior)', () => { + list.append(1); + expect(list.insert(0, 0)).toBe(true); + expect(list.toString()).toBe('0, 1'); + }); + + test('should reject insert at position equal to size (unreachable append branch, matches .ts reference)', () => { + list.append(1); + list.append(2); + // Note: position === size is treated as invalid by #isInvalidPosition before + // the "last position" branch is ever reached, so it returns false rather than + // appending. This mirrors the .ts sibling's own documented/tested behavior. + expect(list.insert(3, 2)).toBe(false); + expect(list.toString()).toBe('1, 2'); + }); + + test('should insert an element in the middle', () => { + list.append(1); + list.append(3); + expect(list.insert(2, 1)).toBe(true); + expect(list.toString()).toBe('1, 2, 3'); + expect(list.size).toBe(3); + }); + + test('should return false when inserting at an invalid position', () => { + list.append(1); + expect(list.insert(5, -1)).toBe(false); + expect(list.insert(5, 10)).toBe(false); + expect(list.size).toBe(1); + }); + + test('should remove from the head', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.removeAt(0)).toBe(1); + expect(list.toString()).toBe('2, 3'); + expect(list.size).toBe(2); + }); + + test('should remove from the tail', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.removeAt(2)).toBe(3); + expect(list.toString()).toBe('1, 2'); + expect(list.size).toBe(2); + }); + + test('should remove from the middle', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.removeAt(1)).toBe(2); + expect(list.toString()).toBe('1, 3'); + expect(list.size).toBe(2); + }); + + test('should remove the only element and become empty', () => { + list.append(1); + expect(list.removeAt(0)).toBe(1); + expect(list.isEmpty()).toBe(true); + expect(list.size).toBe(0); + expect(list.toString()).toBe(''); + }); + + test('should throw when removing from an empty list', () => { + expect(() => list.removeAt(0)).toThrow(RangeError); + }); + + test('should throw when removing at an invalid position', () => { + list.append(1); + expect(() => list.removeAt(-1)).toThrow(RangeError); + expect(() => list.removeAt(5)).toThrow(RangeError); + }); + + test('should find the index of an element', () => { + list.append('a'); + list.append('b'); + list.append('c'); + expect(list.indexOf('b')).toBe(1); + expect(list.indexOf('z')).toBe(-1); + }); + + test('should support a custom compare function for indexOf', () => { + list.append({ id: 1 }); + list.append({ id: 2 }); + expect(list.indexOf({ id: 2 }, (a: any, b: any) => a.id === b.id)).toBe(1); + }); + + test('should clear all elements', () => { + list.append(1); + list.append(2); + list.clear(); + expect(list.isEmpty()).toBe(true); + expect(list.size).toBe(0); + expect(list.toString()).toBe(''); + }); + + test('should stringify object elements using JSON.stringify', () => { + list.append({ a: 1 }); + list.append([1, 2]); + expect(list.toString()).toBe('{"a":1}, [1,2]'); + }); + + test('should stringify primitive elements using String()', () => { + list.append(1); + list.append('two'); + list.append(true); + list.append(null); + list.append(undefined); + expect(list.toString()).toBe('1, two, true, null, undefined'); + }); + + test('should produce the inverse string representation', () => { + list.append(1); + list.append(2); + list.append(3); + expect(list.inverseToString()).toBe('321'); + }); + + test('should reverse the list in place, keeping prev/next links consistent', () => { + list.append(1); + list.append(2); + list.append(3); + list.reverse(); + expect(list.toString()).toBe('3, 2, 1'); + expect(list.inverseToString()).toBe('123'); + // after reversing, the list should still support normal operations + // (verifies head/tail and prev/next pointers were correctly swapped) + expect(list.removeAt(0)).toBe(3); + expect(list.toString()).toBe('2, 1'); + expect(list.removeAt(list.size - 1)).toBe(1); + expect(list.toString()).toBe('2'); + }); + + test('should reverse a single-element list', () => { + list.append(1); + list.reverse(); + expect(list.toString()).toBe('1'); + expect(list.inverseToString()).toBe('1'); + }); + + test('should reverse an empty list without error', () => { + expect(() => list.reverse()).not.toThrow(); + expect(list.toString()).toBe(''); + }); +}); diff --git a/src/06-linked-list/circular-linked-list_.js b/src/06-linked-list/circular-linked-list_.js index e85f3888..8d985ed8 100644 --- a/src/06-linked-list/circular-linked-list_.js +++ b/src/06-linked-list/circular-linked-list_.js @@ -30,17 +30,17 @@ class CircularLinkedList { prepend(data) { const newNode = new LinkedListNode(data, this.#head); - if (!this.head) { - this.head = newNode; - newNode.next = this.head; // make it circular + if (!this.#head) { + this.#head = newNode; + newNode.next = this.#head; // make it circular } else { // Find the last node - let current = this.head; - while (current.next !== this.head) { + let current = this.#head; + while (current.next !== this.#head) { current = current.next; } current.next = newNode; - this.head = newNode; + this.#head = newNode; } this.#size++; } @@ -119,6 +119,19 @@ class CircularLinkedList { } + #removeFromMiddle(position) { + let previous = null; + let current = this.#head; + let index = 0; + while (index++ < position) { + previous = current; + current = current.next; + } + previous.next = current.next; + this.#size--; + return current.data; + } + #isInvalidPosition(position) { return position < 0 || position >= this.#size; } @@ -136,7 +149,7 @@ class CircularLinkedList { this.#size = 0; } - remove(data: T): T | null { + remove(data) { const index = this.indexOf(data); if (index === -1) { return null; @@ -144,7 +157,7 @@ class CircularLinkedList { return this.removeAt(index); } - indexOf(data: T): number { + indexOf(data) { let current = this.#head; let index = 0; while (current) { diff --git a/src/06-linked-list/doubly-linked-list_.js b/src/06-linked-list/doubly-linked-list_.js index 68e0a98b..0d237a25 100644 --- a/src/06-linked-list/doubly-linked-list_.js +++ b/src/06-linked-list/doubly-linked-list_.js @@ -197,7 +197,7 @@ class DoublyLinkedList { if (typeof data === 'object' && data !== null) { return JSON.stringify(data); } else { - return data.toString(); + return String(data); } } diff --git a/src/07-set/__test__/set-js.test.ts b/src/07-set/__test__/set-js.test.ts new file mode 100644 index 00000000..6f323315 --- /dev/null +++ b/src/07-set/__test__/set-js.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const MySet = require('../set.js') as new () => { + add(value: any): boolean; + addAll(values: any[]): void; + delete(value: any): boolean; + has(value: any): boolean; + values(): any[]; + size: number; + getSizeWithoutSizeProperty(): number; + isEmpty(): boolean; + clear(): void; + union(otherSet: any): any; + intersection(otherSet: any): any; + difference(otherSet: any): any; + isSubsetOf(otherSet: any): boolean; + isSupersetOf(otherSet: any): boolean; + toString(): string; +}; + +describe('MySet (set.js)', () => { + let setA: InstanceType; + let setB: InstanceType; + + beforeEach(() => { + setA = new MySet(); + setB = new MySet(); + }); + + // ─── add / has ────────────────────────────────────────────────────────────── + + test('add returns true for new items', () => { + expect(setA.add('a')).toBe(true); + expect(setA.add('b')).toBe(true); + }); + + test('add returns false for duplicate items', () => { + setA.add('a'); + expect(setA.add('a')).toBe(false); + }); + + test('has returns true for existing item', () => { + setA.add('x'); + expect(setA.has('x')).toBe(true); + }); + + test('has returns false for missing item', () => { + expect(setA.has('z')).toBe(false); + }); + + test('has does not throw and works correctly for values named after Object.prototype methods (regression)', () => { + expect(() => setA.add('hasOwnProperty')).not.toThrow(); + expect(setA.has('hasOwnProperty')).toBe(true); + expect(() => setA.has('anything')).not.toThrow(); + expect(setA.has('anything')).toBe(false); + }); + + test('has works correctly after adding "toString" and "constructor" as values (regression)', () => { + setA.add('toString'); + setA.add('constructor'); + expect(setA.has('toString')).toBe(true); + expect(setA.has('constructor')).toBe(true); + expect(setA.has('missing')).toBe(false); + }); + + // ─── addAll ───────────────────────────────────────────────────────────────── + + test('addAll adds every value', () => { + setA.addAll(['a', 'b', 'c']); + expect(setA.values().sort()).toEqual(['a', 'b', 'c']); + }); + + // ─── delete ───────────────────────────────────────────────────────────────── + + test('delete removes the item and returns true', () => { + setA.add('a'); + expect(setA.delete('a')).toBe(true); + expect(setA.has('a')).toBe(false); + }); + + test('delete returns false for non-existent item', () => { + expect(setA.delete('missing')).toBe(false); + }); + + // ─── values / size ────────────────────────────────────────────────────────── + + test('values returns all items', () => { + setA.add('a'); + setA.add('b'); + setA.add('c'); + expect(setA.values().sort()).toEqual(['a', 'b', 'c']); + }); + + test('size tracks item count', () => { + expect(setA.size).toBe(0); + setA.add('a'); + expect(setA.size).toBe(1); + setA.add('b'); + expect(setA.size).toBe(2); + setA.delete('a'); + expect(setA.size).toBe(1); + }); + + test('getSizeWithoutSizeProperty matches size', () => { + setA.addAll(['a', 'b', 'c']); + expect(setA.getSizeWithoutSizeProperty()).toBe(3); + expect(setA.getSizeWithoutSizeProperty()).toBe(setA.size); + }); + + test('getSizeWithoutSizeProperty is 0 for an empty set', () => { + expect(setA.getSizeWithoutSizeProperty()).toBe(0); + }); + + test('getSizeWithoutSizeProperty does not throw for prototype-method-named values (regression)', () => { + setA.add('hasOwnProperty'); + expect(() => setA.getSizeWithoutSizeProperty()).not.toThrow(); + expect(setA.getSizeWithoutSizeProperty()).toBe(1); + }); + + // ─── isEmpty / clear ──────────────────────────────────────────────────────── + + test('isEmpty returns true on empty set', () => { + expect(setA.isEmpty()).toBe(true); + }); + + test('isEmpty returns false when items exist', () => { + setA.add('a'); + expect(setA.isEmpty()).toBe(false); + }); + + test('clear empties the set', () => { + setA.add('a'); + setA.add('b'); + setA.clear(); + expect(setA.isEmpty()).toBe(true); + expect(setA.size).toBe(0); + }); + + // ─── union ────────────────────────────────────────────────────────────────── + + test('union contains all elements from both sets', () => { + setA.addAll(['a', 'b', 'c']); + setB.addAll(['c', 'd', 'e']); + const u = setA.union(setB); + expect(u.values().sort()).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + test('union of two empty sets is empty', () => { + const u = setA.union(setB); + expect(u.isEmpty()).toBe(true); + }); + + test('union with an empty set returns the same elements', () => { + setA.addAll(['a', 'b']); + const u = setA.union(setB); + expect(u.values().sort()).toEqual(['a', 'b']); + }); + + // ─── intersection ─────────────────────────────────────────────────────────── + + test('intersection contains only common elements', () => { + setA.addAll(['a', 'b', 'c']); + setB.addAll(['b', 'c', 'd']); + const inter = setA.intersection(setB); + expect(inter.values().sort()).toEqual(['b', 'c']); + }); + + test('intersection of disjoint sets is empty', () => { + setA.addAll(['a', 'b']); + setB.addAll(['c', 'd']); + expect(setA.intersection(setB).isEmpty()).toBe(true); + }); + + test('intersection when this set is larger than otherSet', () => { + setA.addAll(['a', 'b', 'c', 'd']); + setB.addAll(['b', 'c']); + expect(setA.intersection(setB).values().sort()).toEqual(['b', 'c']); + }); + + test('intersection when this set is smaller than otherSet', () => { + setA.addAll(['b', 'c']); + setB.addAll(['a', 'b', 'c', 'd']); + expect(setA.intersection(setB).values().sort()).toEqual(['b', 'c']); + }); + + test('intersection of fully-overlapping sets equals either set', () => { + setA.addAll(['a', 'b']); + setB.addAll(['a', 'b']); + expect(setA.intersection(setB).values().sort()).toEqual(['a', 'b']); + }); + + test('intersection with an empty set is empty', () => { + setA.addAll(['a', 'b']); + expect(setA.intersection(setB).isEmpty()).toBe(true); + }); + + // ─── difference ───────────────────────────────────────────────────────────── + + test('difference contains elements in A but not B', () => { + setA.addAll(['a', 'b', 'c']); + setB.addAll(['b', 'c', 'd']); + const diff = setA.difference(setB); + expect(diff.values()).toEqual(['a']); + }); + + test('difference with an empty otherSet returns all elements', () => { + setA.addAll(['a', 'b']); + expect(setA.difference(setB).values().sort()).toEqual(['a', 'b']); + }); + + test('difference of fully-overlapping sets is empty', () => { + setA.addAll(['a', 'b']); + setB.addAll(['a', 'b']); + expect(setA.difference(setB).isEmpty()).toBe(true); + }); + + // ─── isSubsetOf / isSupersetOf ────────────────────────────────────────────── + + test('isSubsetOf returns true when all items are in the other set', () => { + setA.addAll(['b', 'c']); + setB.addAll(['a', 'b', 'c', 'd']); + expect(setA.isSubsetOf(setB)).toBe(true); + }); + + test('isSubsetOf returns false when items are missing from the other set', () => { + setA.addAll(['a', 'e']); + setB.addAll(['a', 'b', 'c']); + expect(setA.isSubsetOf(setB)).toBe(false); + }); + + test('isSubsetOf returns false immediately when this set is larger', () => { + setA.addAll(['a', 'b', 'c']); + setB.addAll(['a']); + expect(setA.isSubsetOf(setB)).toBe(false); + }); + + test('an empty set is a subset of any set', () => { + setB.addAll(['a', 'b']); + expect(setA.isSubsetOf(setB)).toBe(true); + }); + + test('isSupersetOf returns true when the set contains all items of the other', () => { + setA.addAll(['a', 'b', 'c', 'd']); + setB.addAll(['b', 'c']); + expect(setA.isSupersetOf(setB)).toBe(true); + }); + + test('isSupersetOf returns false when items are missing', () => { + setA.addAll(['a', 'b']); + setB.addAll(['b', 'c', 'd']); + expect(setA.isSupersetOf(setB)).toBe(false); + }); + + test('isSupersetOf returns false immediately when this set is smaller', () => { + setA.addAll(['a']); + setB.addAll(['a', 'b', 'c']); + expect(setA.isSupersetOf(setB)).toBe(false); + }); + + test('any set is a superset of an empty set', () => { + setA.addAll(['a', 'b']); + expect(setA.isSupersetOf(setB)).toBe(true); + }); + + // ─── toString ─────────────────────────────────────────────────────────────── + + test('toString returns comma-separated values', () => { + setA.add('x'); + setA.add('y'); + expect(setA.toString()).toContain('x'); + expect(setA.toString()).toContain('y'); + }); + + test('toString on empty set returns empty string', () => { + expect(setA.toString()).toBe(''); + }); +}); diff --git a/src/07-set/set.js b/src/07-set/set.js index f681a8dc..fe1d0e15 100644 --- a/src/07-set/set.js +++ b/src/07-set/set.js @@ -25,7 +25,7 @@ class MySet { } has(value) { - return this.#items.hasOwnProperty(value); + return Object.prototype.hasOwnProperty.call(this.#items, value); } values() { @@ -39,7 +39,7 @@ class MySet { getSizeWithoutSizeProperty() { let count = 0; for (const key in this.#items) { - if (this.#items.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(this.#items, key)) { count++; } } diff --git a/src/08-dictionary-hash/__test__/hash-table-js.test.ts b/src/08-dictionary-hash/__test__/hash-table-js.test.ts new file mode 100644 index 00000000..a1cc6e50 --- /dev/null +++ b/src/08-dictionary-hash/__test__/hash-table-js.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const HashTable = require('../hash-table.js') as new () => { + put(key: any, value: any): boolean; + get(key: any): any; + remove(key: any): boolean; + hash(key: any): number; + toString(): string; +}; + +describe('HashTable (hash-table.js)', () => { + let ht: InstanceType; + + beforeEach(() => { + ht = new HashTable(); + }); + + test('put and get a value', () => { + ht.put('name', 'Alice'); + expect(ht.get('name')).toBe('Alice'); + }); + + test('get returns undefined for a non-existent key', () => { + expect(ht.get('missing')).toBeUndefined(); + }); + + test('get returns undefined when key is null', () => { + expect(ht.get(null)).toBeUndefined(); + }); + + test('put overwrites an existing key', () => { + ht.put('name', 'Alice'); + ht.put('name', 'Bob'); + expect(ht.get('name')).toBe('Bob'); + }); + + test('put returns true on success', () => { + expect(ht.put('name', 'Alice')).toBe(true); + }); + + test('put returns false when key is null', () => { + expect(ht.put(null, 'value')).toBe(false); + }); + + test('put returns false when value is null', () => { + expect(ht.put('key', null)).toBe(false); + }); + + test('put returns false when both key and value are null', () => { + expect(ht.put(null, null)).toBe(false); + }); + + test('put does not throw when key is null (regression)', () => { + expect(() => ht.put(null, 'value')).not.toThrow(); + }); + + test('put handles non-string keys by stringifying them', () => { + expect(ht.put(42 as any, 'number-key')).toBe(true); + expect(ht.get(42 as any)).toBe('number-key'); + }); + + test('put handles object keys by stringifying them', () => { + expect(ht.put({ id: 1 } as any, 'object-key')).toBe(true); + }); + + test('hash returns consistent results', () => { + expect(ht.hash('name')).toBe(ht.hash('name')); + expect(ht.hash('abc')).toBeGreaterThanOrEqual(0); + expect(ht.hash('abc')).toBeLessThan(37); + }); + + test('should store multiple keys', () => { + ht.put('name', 'Alice'); + ht.put('city', 'London'); + expect(ht.get('name')).toBe('Alice'); + expect(ht.get('city')).toBe('London'); + }); + + test('remove returns true for an existing key and clears it', () => { + ht.put('name', 'Alice'); + expect(ht.remove('name')).toBe(true); + expect(ht.get('name')).toBeUndefined(); + }); + + test('remove returns false for a non-existent key', () => { + expect(ht.remove('missing')).toBe(false); + }); + + test('remove returns false when key is null', () => { + expect(ht.remove(null)).toBe(false); + }); + + test('remove returns true and works for a falsy stored value (0) (regression)', () => { + ht.put('key', 0); + expect(ht.remove('key')).toBe(true); + expect(ht.get('key')).toBeUndefined(); + }); + + test('remove returns true and works for a falsy stored value (empty string) (regression)', () => { + ht.put('key', ''); + expect(ht.remove('key')).toBe(true); + }); + + test('remove returns true and works for a falsy stored value (false) (regression)', () => { + ht.put('key', false); + expect(ht.remove('key')).toBe(true); + }); + + test('two keys hashing to the same slot overwrite each other (no collision handling)', () => { + // 'a' and hashing colliding keys just overwrite the previous slot value + // since this basic HashTable has no chaining/probing. + ht.put('key1', 'val1'); + ht.put('key2', 'val2'); + // Regardless of collision, at least the most recently written of any + // colliding keys must be retrievable via its own key. + expect(ht.get('key2')).toBe('val2'); + }); + + test('toString produces output containing the stored value', () => { + ht.put('name', 'Alice'); + const result = ht.toString(); + expect(result).toContain('Alice'); + }); + + test('toString with multiple distinct slots covers the loop body', () => { + ht.put('name', 'Alice'); + ht.put('zip', 'London'); + const result = ht.toString(); + expect(result).toContain('Alice'); + expect(result).toContain('London'); + }); + + test('toString does not throw on an empty table (regression)', () => { + expect(() => ht.toString()).not.toThrow(); + }); + + test('toString uses JSON.stringify for the first entry when it is an object (regression)', () => { + ht.put('name', { a: 1 }); + const result = ht.toString(); + expect(result).toContain('{"a":1}'); + expect(result).not.toContain('[object Object]'); + }); +}); diff --git a/src/08-dictionary-hash/hash-table.js b/src/08-dictionary-hash/hash-table.js index 3833eb72..96231e31 100644 --- a/src/08-dictionary-hash/hash-table.js +++ b/src/08-dictionary-hash/hash-table.js @@ -17,7 +17,7 @@ class HashTable { } put(key, value) { - if (key == null && value == null) { + if (key == null || value == null) { return false; } const index = this.hash(key); @@ -38,7 +38,7 @@ class HashTable { return false; } const index = this.hash(key); - if (this.#table[index]) { + if (this.#table[index] != null) { delete this.#table[index]; return true; } @@ -49,13 +49,13 @@ class HashTable { if (typeof data === 'object' && data !== null) { return JSON.stringify(data); } else { - return data.toString(); + return String(data); } } toString() { const keys = Object.keys(this.#table); - let objString = `{${keys[0]} => ${this.#table[keys[0]].toString()}}`; + let objString = `{${keys[0]} => ${this.#elementToString(this.#table[keys[0]])}}`; for (let i = 1; i < keys.length; i++) { const value = this.#elementToString(this.#table[keys[i]]).toString(); objString = `${objString}\n{${keys[i]} => ${value}}`; diff --git a/src/10-tree/__test__/avl-tree-js.test.ts b/src/10-tree/__test__/avl-tree-js.test.ts new file mode 100644 index 00000000..95c81ee4 --- /dev/null +++ b/src/10-tree/__test__/avl-tree-js.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const AVLTree = require('../avl-tree.js') as new ( + compareFn?: (a: any, b: any) => number +) => { + insert(data: any): void; + search(data: any): boolean; + remove(data: any): void; + min(): any; + max(): any; + readonly root: { data: any; left: any; right: any; height: number } | null; + inOrderTraverse(callback: (data: any) => void): void; + preOrderTraverse(callback: (data: any) => void): void; + postOrderTraverse(callback: (data: any) => void): void; +}; + +function height(node: { left: any; right: any; height: number } | null): number { + return node ? node.height : 0; +} + +function isBalanced(node: { left: any; right: any } | null): boolean { + if (!node) return true; + const diff = height(node.left) - height(node.right); + return Math.abs(diff) <= 1 && isBalanced(node.left) && isBalanced(node.right); +} + +describe('AVLTree (avl-tree.js)', () => { + let avl: InstanceType; + + beforeEach(() => { + avl = new AVLTree(); + }); + + test('starts empty: root null, min/max null, search false', () => { + expect(avl.root).toBeNull(); + expect(avl.min()).toBeNull(); + expect(avl.max()).toBeNull(); + expect(avl.search(1)).toBe(false); + }); + + test('insert sets the root', () => { + avl.insert(5); + expect(avl.root?.data).toBe(5); + }); + + test('search finds inserted values and reports missing ones as false', () => { + [10, 20, 30, 40, 50, 25].forEach(v => avl.insert(v)); + expect(avl.search(25)).toBe(true); + expect(avl.search(10)).toBe(true); + expect(avl.search(999)).toBe(false); + }); + + test('min/max return smallest/largest inserted values', () => { + [10, 20, 30, 40, 50, 25].forEach(v => avl.insert(v)); + expect(avl.min()).toBe(10); + expect(avl.max()).toBe(50); + }); + + test('inOrderTraverse returns sorted values after inserts/rotations', () => { + [10, 20, 30, 40, 50, 25].forEach(v => avl.insert(v)); + const result: number[] = []; + avl.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([10, 20, 25, 30, 40, 50]); + }); + + test('preOrderTraverse and postOrderTraverse do not throw and visit all nodes', () => { + [10, 20, 30, 40, 50, 25].forEach(v => avl.insert(v)); + const pre: number[] = []; + const post: number[] = []; + avl.preOrderTraverse(v => pre.push(v)); + avl.postOrderTraverse(v => post.push(v)); + expect(pre.sort((a, b) => a - b)).toEqual([10, 20, 25, 30, 40, 50]); + expect(post.sort((a, b) => a - b)).toEqual([10, 20, 25, 30, 40, 50]); + }); + + test('remains balanced after ascending inserts (left-left case -> right rotation)', () => { + [1, 2, 3, 4, 5, 6, 7].forEach(v => avl.insert(v)); + expect(isBalanced(avl.root)).toBe(true); + expect(avl.root?.data).toBe(4); + }); + + test('remains balanced after descending inserts (right-right case -> left rotation)', () => { + [7, 6, 5, 4, 3, 2, 1].forEach(v => avl.insert(v)); + expect(isBalanced(avl.root)).toBe(true); + expect(avl.root?.data).toBe(4); + }); + + test('left-right rotation case keeps tree balanced', () => { + [30, 10, 20].forEach(v => avl.insert(v)); + expect(isBalanced(avl.root)).toBe(true); + expect(avl.root?.data).toBe(20); + }); + + test('right-left rotation case keeps tree balanced', () => { + [10, 30, 20].forEach(v => avl.insert(v)); + expect(isBalanced(avl.root)).toBe(true); + expect(avl.root?.data).toBe(20); + }); + + test('inserting many values keeps the tree balanced and searchable', () => { + const values = Array.from({ length: 50 }, (_, i) => i + 1); + values.forEach(v => avl.insert(v)); + expect(isBalanced(avl.root)).toBe(true); + values.forEach(v => expect(avl.search(v)).toBe(true)); + expect(avl.search(0)).toBe(false); + expect(avl.search(51)).toBe(false); + }); + + test('remove from empty tree does not throw', () => { + expect(() => avl.remove(1)).not.toThrow(); + expect(avl.root).toBeNull(); + }); + + test('remove the only node empties the tree', () => { + avl.insert(5); + avl.remove(5); + expect(avl.root).toBeNull(); + expect(avl.search(5)).toBe(false); + }); + + test('remove a leaf node keeps remaining values searchable', () => { + [10, 20, 5, 15, 30].forEach(v => avl.insert(v)); + avl.remove(15); + expect(avl.search(15)).toBe(false); + expect(avl.search(10)).toBe(true); + expect(avl.search(20)).toBe(true); + expect(avl.search(5)).toBe(true); + expect(avl.search(30)).toBe(true); + }); + + test('remove a node with two children replaces with in-order successor', () => { + [10, 20, 30, 40, 50, 25].forEach(v => avl.insert(v)); + avl.remove(30); + expect(avl.search(30)).toBe(false); + const result: number[] = []; + avl.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([10, 20, 25, 40, 50]); + expect(isBalanced(avl.root)).toBe(true); + }); + + test('removing all values one by one empties the tree and stays balanced', () => { + const values = [10, 20, 30, 40, 50, 25, 5, 1, 15, 35]; + values.forEach(v => avl.insert(v)); + values.forEach(v => { + avl.remove(v); + expect(avl.search(v)).toBe(false); + expect(isBalanced(avl.root)).toBe(true); + }); + expect(avl.root).toBeNull(); + }); + + test('removing a non-existent value does not throw and keeps tree intact', () => { + [10, 20, 30].forEach(v => avl.insert(v)); + expect(() => avl.remove(999)).not.toThrow(); + expect(avl.search(10)).toBe(true); + expect(avl.search(20)).toBe(true); + expect(avl.search(30)).toBe(true); + }); + + test('works with a custom comparator (strings)', () => { + const strAvl = new AVLTree( + (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + ); + ['banana', 'apple', 'cherry', 'date'].forEach(v => strAvl.insert(v)); + expect(strAvl.search('apple')).toBe(true); + expect(strAvl.min()).toBe('apple'); + expect(strAvl.max()).toBe('date'); + }); +}); diff --git a/src/10-tree/__test__/binary-search-tree-js.test.ts b/src/10-tree/__test__/binary-search-tree-js.test.ts new file mode 100644 index 00000000..0d2b3169 --- /dev/null +++ b/src/10-tree/__test__/binary-search-tree-js.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const BinarySearchTree = require('../binary-search-tree.js') as new ( + compareFn?: (a: any, b: any) => number +) => { + insert(data: any): void; + search(data: any): boolean; + remove(data: any): void; + min(): any; + max(): any; + readonly root: { data: any; left: any; right: any } | null; + inOrderTraverse(callback: (data: any) => void): void; + preOrderTraverse(callback: (data: any) => void): void; + postOrderTraverse(callback: (data: any) => void): void; +}; + +describe('BinarySearchTree (binary-search-tree.js)', () => { + let bst: InstanceType; + + beforeEach(() => { + bst = new BinarySearchTree(); + }); + + test('starts empty: root null, min/max null, search false', () => { + expect(bst.root).toBeNull(); + expect(bst.min()).toBeNull(); + expect(bst.max()).toBeNull(); + expect(bst.search(1)).toBe(false); + }); + + test('insert sets the root on first insert', () => { + bst.insert(5); + expect(bst.root?.data).toBe(5); + }); + + test('search returns true for inserted values, false otherwise', () => { + [5, 3, 7].forEach(v => bst.insert(v)); + expect(bst.search(5)).toBe(true); + expect(bst.search(3)).toBe(true); + expect(bst.search(7)).toBe(true); + expect(bst.search(99)).toBe(false); + }); + + test('min and max return smallest/largest inserted values', () => { + [5, 3, 7, 1, 4, 8].forEach(v => bst.insert(v)); + expect(bst.min()).toBe(1); + expect(bst.max()).toBe(8); + }); + + test('duplicate values are inserted to the right subtree', () => { + bst.insert(5); + bst.insert(5); + const result: number[] = []; + bst.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([5, 5]); + }); + + test('single-node tree: remove root (leaf) empties the tree', () => { + bst.insert(5); + bst.remove(5); + expect(bst.root).toBeNull(); + expect(bst.search(5)).toBe(false); + }); + + test('remove a leaf node', () => { + [5, 3, 7].forEach(v => bst.insert(v)); + bst.remove(3); + expect(bst.search(3)).toBe(false); + expect(bst.search(5)).toBe(true); + expect(bst.search(7)).toBe(true); + }); + + test('remove a node with only a left child', () => { + [5, 3, 7, 6].forEach(v => bst.insert(v)); + bst.remove(7); + expect(bst.search(7)).toBe(false); + expect(bst.search(6)).toBe(true); + }); + + test('remove a node with only a right child', () => { + [5, 3, 7, 8].forEach(v => bst.insert(v)); + bst.remove(7); + expect(bst.search(7)).toBe(false); + expect(bst.search(8)).toBe(true); + }); + + test('remove a node with two children replaces with in-order successor', () => { + [5, 3, 7, 1, 4, 6, 8].forEach(v => bst.insert(v)); + bst.remove(3); + expect(bst.search(3)).toBe(false); + expect(bst.search(1)).toBe(true); + expect(bst.search(4)).toBe(true); + const result: number[] = []; + bst.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([1, 4, 5, 6, 7, 8]); + }); + + test('remove the root with two children', () => { + [5, 3, 7, 1, 4, 6, 8].forEach(v => bst.insert(v)); + bst.remove(5); + expect(bst.search(5)).toBe(false); + const result: number[] = []; + bst.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([1, 3, 4, 6, 7, 8]); + }); + + test('removing a non-existent value does not throw and keeps tree intact', () => { + [5, 3, 7].forEach(v => bst.insert(v)); + expect(() => bst.remove(999)).not.toThrow(); + expect(bst.search(5)).toBe(true); + expect(bst.search(3)).toBe(true); + expect(bst.search(7)).toBe(true); + }); + + test('remove on empty tree does not throw', () => { + expect(() => bst.remove(1)).not.toThrow(); + expect(bst.root).toBeNull(); + }); + + test('inOrderTraverse visits nodes in sorted order', () => { + [5, 3, 7, 1, 4, 6, 8].forEach(v => bst.insert(v)); + const result: number[] = []; + bst.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([1, 3, 4, 5, 6, 7, 8]); + }); + + test('preOrderTraverse visits root first, then left, then right', () => { + [5, 3, 7].forEach(v => bst.insert(v)); + const result: number[] = []; + bst.preOrderTraverse(v => result.push(v)); + expect(result).toEqual([5, 3, 7]); + }); + + test('postOrderTraverse visits root last', () => { + [5, 3, 7].forEach(v => bst.insert(v)); + const result: number[] = []; + bst.postOrderTraverse(v => result.push(v)); + expect(result).toEqual([3, 7, 5]); + }); + + test('traversals on an empty tree do not invoke the callback', () => { + const calls: number[] = []; + bst.inOrderTraverse(v => calls.push(v)); + bst.preOrderTraverse(v => calls.push(v)); + bst.postOrderTraverse(v => calls.push(v)); + expect(calls).toEqual([]); + }); + + test('works with a custom comparator (strings)', () => { + const strBst = new BinarySearchTree( + (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + ); + ['banana', 'apple', 'cherry'].forEach(v => strBst.insert(v)); + expect(strBst.search('apple')).toBe(true); + expect(strBst.min()).toBe('apple'); + expect(strBst.max()).toBe('cherry'); + }); +}); diff --git a/src/10-tree/__test__/binary-search-tree.test.ts b/src/10-tree/__test__/binary-search-tree.test.ts index eb4528cf..d906a40f 100644 --- a/src/10-tree/__test__/binary-search-tree.test.ts +++ b/src/10-tree/__test__/binary-search-tree.test.ts @@ -138,6 +138,40 @@ describe('AVLTree', () => { [7, 6, 5, 4, 3, 2, 1].forEach(v => avl.insert(v)); }).not.toThrow(); }); + + // ─── correctness ────────────────────────────────────────────────────────── + + test('search finds inserted values and rejects missing ones', () => { + [10, 20, 5, 15, 30, 25, 1].forEach(v => avl.insert(v)); + [10, 20, 5, 15, 30, 25, 1].forEach(v => expect(avl.search(v)).toBe(true)); + expect(avl.search(999)).toBe(false); + }); + + test('min and max return the correct values', () => { + [10, 20, 5, 15, 30, 25, 1].forEach(v => avl.insert(v)); + expect(avl.min()).toBe(1); + expect(avl.max()).toBe(30); + }); + + test('root is populated after insertion and null when empty', () => { + expect(avl.root).toBeNull(); + avl.insert(10); + expect(avl.root).not.toBeNull(); + }); + + test('inOrderTraverse visits nodes in sorted order', () => { + [10, 20, 5, 15, 30, 25, 1].forEach(v => avl.insert(v)); + const result: number[] = []; + avl.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([1, 5, 10, 15, 20, 25, 30]); + }); + + test('search reflects removal', () => { + [10, 20, 5, 15, 30].forEach(v => avl.insert(v)); + avl.remove(20); + expect(avl.search(20)).toBe(false); + expect(avl.search(10)).toBe(true); + }); }); // ─── RedBlackTree ───────────────────────────────────────────────────────────── @@ -209,4 +243,45 @@ describe('RedBlackTree', () => { rbt.remove(5); }).not.toThrow(); }); + + // ─── correctness ────────────────────────────────────────────────────────── + + test('search finds inserted values and rejects missing ones', () => { + [10, 20, 30, 15, 25, 5, 1].forEach(v => rbt.insert(v)); + [10, 20, 30, 15, 25, 5, 1].forEach(v => expect(rbt.search(v)).toBe(true)); + expect(rbt.search(999)).toBe(false); + }); + + test('min and max return the correct values', () => { + [10, 20, 30, 15, 25, 5, 1].forEach(v => rbt.insert(v)); + expect(rbt.min()).toBe(1); + expect(rbt.max()).toBe(30); + }); + + test('root is populated after insertion and null when empty', () => { + expect(rbt.root).toBeNull(); + rbt.insert(10); + expect(rbt.root).not.toBeNull(); + }); + + test('inOrderTraverse visits nodes in sorted order', () => { + const values = [10, 20, 5, 15, 30, 25, 1, 2, 3, 4, 6, 7, 8, 9]; + values.forEach(v => rbt.insert(v)); + const result: number[] = []; + rbt.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([...values].sort((a, b) => a - b)); + }); + + test('search reflects removal, including single-child promotion', () => { + const values = [10, 20, 5, 15, 30, 25, 1, 2, 3, 4, 6, 7, 8, 9]; + values.forEach(v => rbt.insert(v)); + rbt.remove(20); + expect(rbt.search(20)).toBe(false); + rbt.remove(1); + expect(rbt.search(1)).toBe(false); + const remaining = values.filter(v => v !== 20 && v !== 1).sort((a, b) => a - b); + const result: number[] = []; + rbt.inOrderTraverse(v => result.push(v)); + expect(result).toEqual(remaining); + }); }); diff --git a/src/10-tree/__test__/fenwick-tree-js.test.ts b/src/10-tree/__test__/fenwick-tree-js.test.ts new file mode 100644 index 00000000..ae0759db --- /dev/null +++ b/src/10-tree/__test__/fenwick-tree-js.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const FenwickTree = require('../fenwick-tree.js') as new ( + arraySize: number +) => { + update(index: number, value: number): void; + query(index: number): number; + readonly arraySize: number; + toString(): string; +}; + +describe('FenwickTree (fenwick-tree.js)', () => { + test('can be constructed via require()', () => { + expect(() => new FenwickTree(5)).not.toThrow(); + }); + + test('arraySize getter returns the size passed to the constructor', () => { + const tree = new FenwickTree(10); + expect(tree.arraySize).toBe(10); + }); + + test('query on a freshly constructed tree returns 0', () => { + const tree = new FenwickTree(5); + for (let i = 1; i <= 5; i++) { + expect(tree.query(i)).toBe(0); + } + }); + + describe('update / query', () => { + let tree: InstanceType; + + beforeEach(() => { + tree = new FenwickTree(8); + }); + + test('update adds value at index and reflects in subsequent prefix sums', () => { + tree.update(3, 5); + expect(tree.query(2)).toBe(0); + expect(tree.query(3)).toBe(5); + expect(tree.query(8)).toBe(5); + }); + + test('multiple updates accumulate into prefix sums correctly', () => { + tree.update(1, 3); + tree.update(3, 5); + tree.update(5, 2); + expect(tree.query(1)).toBe(3); + expect(tree.query(2)).toBe(3); + expect(tree.query(3)).toBe(8); + expect(tree.query(4)).toBe(8); + expect(tree.query(5)).toBe(10); + expect(tree.query(8)).toBe(10); + }); + + test('update at the same index twice accumulates (not overwrites)', () => { + tree.update(4, 2); + tree.update(4, 3); + expect(tree.query(4)).toBe(5); + }); + + test('negative values can be applied via update', () => { + tree.update(2, 10); + tree.update(2, -4); + expect(tree.query(2)).toBe(6); + }); + + test('query(arraySize) returns the total sum of all updates', () => { + [1, 2, 3, 4, 5, 6, 7, 8].forEach(i => tree.update(i, i)); + expect(tree.query(8)).toBe(36); + }); + }); + + describe('bounds validation', () => { + let tree: InstanceType; + + beforeEach(() => { + tree = new FenwickTree(5); + }); + + test('update throws when index is below 1', () => { + expect(() => tree.update(0, 1)).toThrow('Index is out of range'); + }); + + test('update throws when index is above arraySize', () => { + expect(() => tree.update(6, 1)).toThrow('Index is out of range'); + }); + + test('query throws when index is below 1', () => { + expect(() => tree.query(0)).toThrow('Index is out of range'); + }); + + test('query throws when index is above arraySize', () => { + expect(() => tree.query(6)).toThrow('Index is out of range'); + }); + + test('update/query at the boundary indexes (1 and arraySize) do not throw', () => { + expect(() => tree.update(1, 1)).not.toThrow(); + expect(() => tree.update(5, 1)).not.toThrow(); + expect(() => tree.query(1)).not.toThrow(); + expect(() => tree.query(5)).not.toThrow(); + }); + }); + + test('toString returns a comma-separated string of the internal tree array', () => { + const tree = new FenwickTree(3); + expect(tree.toString()).toBe('0, 0, 0, 0'); + tree.update(1, 7); + expect(tree.toString()).toBe(tree.toString()); + expect(typeof tree.toString()).toBe('string'); + }); +}); diff --git a/src/10-tree/__test__/red-black-tree-js.test.ts b/src/10-tree/__test__/red-black-tree-js.test.ts new file mode 100644 index 00000000..a38a4059 --- /dev/null +++ b/src/10-tree/__test__/red-black-tree-js.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const RedBlackTree = require('../red-black-tree.js') as new ( + compareFn?: (a: any, b: any) => number +) => { + insert(data: any): void; + search(data: any): boolean; + remove(data: any): void; + min(): any; + max(): any; + readonly root: { data: any; left: any; right: any; color: number } | null; + inOrderTraverse(callback: (data: any) => void): void; + preOrderTraverse(callback: (data: any) => void): void; + postOrderTraverse(callback: (data: any) => void): void; + print(): void; +}; + +const BLACK = 1; + +type RBNode = { data: any; left: RBNode | null; right: RBNode | null; color: number } | null; + +// Bounds are `low` (inclusive) and `high` (exclusive): every node's data must +// satisfy low <= data < high. Right subtrees use the parent's own value as the +// (inclusive) low bound because this tree routes values equal to a node to +// its right subtree (see #insertNode: `lessThan` decides left, else right). +function isValidBST(node: RBNode, low: number | null, high: number | null): boolean { + if (!node) return true; + if (low !== null && node.data < low) return false; + if (high !== null && node.data >= high) return false; + return isValidBST(node.left, low, node.data) && isValidBST(node.right, node.data, high); +} + +// Verifies the "no red node has a red child" property and returns the black-height, +// or -1 if the black-height is inconsistent between subtrees (a structural violation). +function blackHeight(node: RBNode): number { + if (!node) return 1; + const leftHeight = blackHeight(node.left); + const rightHeight = blackHeight(node.right); + if (leftHeight === -1 || rightHeight === -1 || leftHeight !== rightHeight) { + return -1; + } + if (node.color !== BLACK) { + if ((node.left && node.left.color !== BLACK) || (node.right && node.right.color !== BLACK)) { + return -1; + } + } + return leftHeight + (node.color === BLACK ? 1 : 0); +} + +describe('RedBlackTree (red-black-tree.js)', () => { + let rbt: InstanceType; + + beforeEach(() => { + rbt = new RedBlackTree(); + }); + + test('starts empty: root null, min/max null, search false', () => { + expect(rbt.root).toBeNull(); + expect(rbt.min()).toBeNull(); + expect(rbt.max()).toBeNull(); + expect(rbt.search(1)).toBe(false); + }); + + test('insert sets the root and colors it black', () => { + rbt.insert(5); + expect(rbt.root?.data).toBe(5); + expect(rbt.root?.color).toBe(BLACK); + }); + + test('search finds inserted values and reports missing ones as false', () => { + [10, 20, 30, 15, 25, 5, 1].forEach(v => rbt.insert(v)); + expect(rbt.search(25)).toBe(true); + expect(rbt.search(1)).toBe(true); + expect(rbt.search(999)).toBe(false); + }); + + test('min/max return smallest/largest inserted values', () => { + [10, 20, 30, 15, 25, 5, 1].forEach(v => rbt.insert(v)); + expect(rbt.min()).toBe(1); + expect(rbt.max()).toBe(30); + }); + + test('inOrderTraverse returns values in sorted order', () => { + [10, 20, 30, 15, 25, 5, 1].forEach(v => rbt.insert(v)); + const result: number[] = []; + rbt.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([1, 5, 10, 15, 20, 25, 30]); + }); + + test('preOrderTraverse and postOrderTraverse visit all nodes', () => { + [10, 20, 30, 15, 25].forEach(v => rbt.insert(v)); + const pre: number[] = []; + const post: number[] = []; + rbt.preOrderTraverse(v => pre.push(v)); + rbt.postOrderTraverse(v => post.push(v)); + expect(pre.sort((a, b) => a - b)).toEqual([10, 15, 20, 25, 30]); + expect(post.sort((a, b) => a - b)).toEqual([10, 15, 20, 25, 30]); + }); + + test('root is always black and tree stays a valid BST/RB tree after ascending inserts', () => { + [1, 2, 3, 4, 5, 6, 7].forEach(v => rbt.insert(v)); + expect(rbt.root?.color).toBe(BLACK); + expect(isValidBST(rbt.root, null, null)).toBe(true); + expect(blackHeight(rbt.root)).toBeGreaterThan(0); + }); + + test('root is always black and tree stays valid after descending inserts', () => { + [7, 6, 5, 4, 3, 2, 1].forEach(v => rbt.insert(v)); + expect(rbt.root?.color).toBe(BLACK); + expect(isValidBST(rbt.root, null, null)).toBe(true); + expect(blackHeight(rbt.root)).toBeGreaterThan(0); + }); + + test('insert [1,3,2] (right-left case) keeps tree valid', () => { + [1, 3, 2].forEach(v => rbt.insert(v)); + expect(isValidBST(rbt.root, null, null)).toBe(true); + expect(blackHeight(rbt.root)).toBeGreaterThan(0); + [1, 2, 3].forEach(v => expect(rbt.search(v)).toBe(true)); + }); + + test('inserting many values keeps the tree valid and searchable', () => { + const values = Array.from({ length: 40 }, (_, i) => i + 1); + values.forEach(v => rbt.insert(v)); + expect(isValidBST(rbt.root, null, null)).toBe(true); + expect(blackHeight(rbt.root)).toBeGreaterThan(0); + values.forEach(v => expect(rbt.search(v)).toBe(true)); + expect(rbt.search(0)).toBe(false); + expect(rbt.search(41)).toBe(false); + }); + + test('remove from empty tree does not throw', () => { + expect(() => rbt.remove(1)).not.toThrow(); + expect(rbt.root).toBeNull(); + }); + + test('remove the only node empties the tree', () => { + rbt.insert(5); + rbt.remove(5); + expect(rbt.root).toBeNull(); + expect(rbt.search(5)).toBe(false); + }); + + test('remove a leaf node keeps remaining values searchable and tree valid', () => { + [10, 5, 15].forEach(v => rbt.insert(v)); + rbt.remove(5); + expect(rbt.search(5)).toBe(false); + expect(rbt.search(10)).toBe(true); + expect(rbt.search(15)).toBe(true); + expect(isValidBST(rbt.root, null, null)).toBe(true); + }); + + test('remove a node with two children replaces with in-order successor', () => { + [10, 20, 30, 15, 25, 5, 1].forEach(v => rbt.insert(v)); + rbt.remove(20); + expect(rbt.search(20)).toBe(false); + const result: number[] = []; + rbt.inOrderTraverse(v => result.push(v)); + expect(result).toEqual([1, 5, 10, 15, 25, 30]); + expect(isValidBST(rbt.root, null, null)).toBe(true); + }); + + // Regression test: #rotateLeft/#rotateRight did not return the new subtree + // root, so #balance (called from #removeNode) could return undefined and + // overwrite an already-correct parent link with undefined, corrupting the + // tree structure whenever a removal triggered a rebalancing rotation. + test('removing many values one by one (triggers rebalancing) keeps tree valid and complete', () => { + const values = Array.from({ length: 30 }, (_, i) => i + 1); + values.forEach(v => rbt.insert(v)); + + const toRemove = [5, 15, 25, 1, 30, 10, 20, 2, 3, 4]; + let remaining = [...values]; + toRemove.forEach(v => { + rbt.remove(v); + remaining = remaining.filter(x => x !== v); + + expect(rbt.search(v)).toBe(false); + expect(isValidBST(rbt.root, null, null)).toBe(true); + + const result: number[] = []; + rbt.inOrderTraverse(x => result.push(x)); + expect(result).toEqual(remaining); + }); + }); + + test('removing a non-existent value does not throw and keeps tree intact', () => { + [10, 5, 15].forEach(v => rbt.insert(v)); + expect(() => rbt.remove(99)).not.toThrow(); + expect(rbt.search(10)).toBe(true); + expect(rbt.search(5)).toBe(true); + expect(rbt.search(15)).toBe(true); + }); + + test('removing all inserted values empties the tree', () => { + const values = [10, 20, 5, 15, 30, 25]; + values.forEach(v => rbt.insert(v)); + values.forEach(v => rbt.remove(v)); + expect(rbt.root).toBeNull(); + values.forEach(v => expect(rbt.search(v)).toBe(false)); + }); + + test('print() does not throw', () => { + [10, 20, 5, 15].forEach(v => rbt.insert(v)); + expect(() => rbt.print()).not.toThrow(); + }); + + test('print() on empty tree does not throw', () => { + expect(() => rbt.print()).not.toThrow(); + }); + + test('works with a custom comparator (strings)', () => { + const strRbt = new RedBlackTree( + (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + ); + ['banana', 'apple', 'cherry', 'date'].forEach(v => strRbt.insert(v)); + expect(strRbt.search('apple')).toBe(true); + expect(strRbt.min()).toBe('apple'); + expect(strRbt.max()).toBe('date'); + }); + + // Fuzz test to exercise the full range of insertion-fixup and deletion-rebalancing + // branches (left-left/left-right/right-right/right-left cases in both paths), + // asserting BST validity and structural correctness after every operation. + // Uses distinct values deliberately (see the dedicated duplicate-values test + // below for a documented limitation involving duplicates). + test('fuzz: many random insert/remove sequences (distinct values) keep the tree a valid BST', () => { + let seed = 42; + function next(): number { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed; + } + + for (let trial = 0; trial < 100; trial++) { + const size = 5 + (next() % 25); + const valueSet = new Set(); + while (valueSet.size < size) valueSet.add(next() % 1000); + const values = [...valueSet]; + + const tree = new RedBlackTree(); + values.forEach(v => tree.insert(v)); + expect(isValidBST(tree.root, null, null)).toBe(true); + + let remaining = [...values].sort((a, b) => a - b); + const removalOrder = [...values].sort(() => (next() % 3) - 1); + removalOrder.forEach(v => { + tree.remove(v); + const idx = remaining.indexOf(v); + if (idx >= 0) remaining.splice(idx, 1); + expect(isValidBST(tree.root, null, null)).toBe(true); + const result: number[] = []; + tree.inOrderTraverse(x => result.push(x)); + expect(result).toEqual(remaining); + }); + expect(tree.root).toBeNull(); + } + }); + + // Known limitation (shared identically with red-black-tree.ts): the deletion + // algorithm's "successor value copy" technique mutates a node's `.data` in + // place while keeping its original identity/color, and the subsequent + // top-down rebalancing step (#balance) can then rotate that node based on + // its *old* position rather than its new value. When duplicate values are + // present, this can produce a structurally invalid BST (e.g. a value ending + // up in the left subtree of a node holding an equal value) after a removal + // that triggers such a rotation. This does not occur when all values are + // distinct (see the fuzz test above). Fixing this generally would require a + // more substantial rewrite of the deletion-rebalancing algorithm, which is + // out of scope for a minimal, behavior-preserving fix and mirrors the same + // limitation present in the .ts implementation. + test('duplicate values: search still finds a representative value after churn', () => { + [10, 5, 15, 5, 20, 5].forEach(v => rbt.insert(v)); + rbt.remove(10); + rbt.remove(5); + // Regardless of the known structural edge case above, at least one + // instance of a duplicated value should remain discoverable, and + // never-removed values must remain searchable. + expect(rbt.search(5)).toBe(true); + expect(rbt.search(15)).toBe(true); + expect(rbt.search(20)).toBe(true); + }); +}); diff --git a/src/10-tree/__test__/segment-tree-js.test.ts b/src/10-tree/__test__/segment-tree-js.test.ts new file mode 100644 index 00000000..df9076a9 --- /dev/null +++ b/src/10-tree/__test__/segment-tree-js.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const SegmentTree = require('../segment-tree.js') as new ( + inputArray: number[], + operationFallback: (a: number, b: number) => number +) => { + query(leftIndex: number, rightIndex: number): number; + update(index: number, value: number): void; + toString(): string; +}; + +describe('SegmentTree (segment-tree.js)', () => { + test('can be constructed via require()', () => { + expect(() => new SegmentTree([1, 2, 3], (a, b) => a + b)).not.toThrow(); + }); + + describe('sum segment tree', () => { + let tree: InstanceType; + const input = [1, 3, 5, 7, 9, 11]; + + beforeEach(() => { + tree = new SegmentTree([...input], (a, b) => a + b); + }); + + test('query over the full range returns the total sum', () => { + expect(tree.query(0, input.length - 1)).toBe(input.reduce((a, b) => a + b, 0)); + }); + + test('query over a sub-range returns the correct partial sum', () => { + expect(tree.query(1, 3)).toBe(3 + 5 + 7); + expect(tree.query(0, 0)).toBe(1); + expect(tree.query(2, 2)).toBe(5); + }); + + test('query outside the array bounds returns 0 (no overlap)', () => { + expect(tree.query(10, 20)).toBe(0); + }); + + test('update changes a single element and recomputes affected ranges', () => { + tree.update(2, 100); + expect(tree.query(2, 2)).toBe(100); + expect(tree.query(0, input.length - 1)).toBe(1 + 3 + 100 + 7 + 9 + 11); + }); + + test('update at the first and last index works correctly', () => { + tree.update(0, 50); + tree.update(input.length - 1, 60); + expect(tree.query(0, 0)).toBe(50); + expect(tree.query(input.length - 1, input.length - 1)).toBe(60); + }); + + test('toString returns a string representation of the internal tree', () => { + expect(typeof tree.toString()).toBe('string'); + expect(tree.toString().length).toBeGreaterThan(0); + }); + }); + + describe('min segment tree', () => { + let tree: InstanceType; + const input = [5, 2, 8, 1, 9, 3]; + + beforeEach(() => { + tree = new SegmentTree([...input], (a, b) => Math.min(a, b)); + }); + + test('query over the full range returns the minimum', () => { + expect(tree.query(0, input.length - 1)).toBe(1); + }); + + // "no overlap" base case always returns 0, which only works for `sum`. + // For `min`, this makes partial-range queries return 0 whenever the + // real values are all positive. Shared with the .ts sibling; pinning + // the current (incorrect) behavior here. + test('query over a partial sub-range incorrectly returns 0 (documented limitation)', () => { + expect(tree.query(0, 2)).toBe(0); + expect(tree.query(3, 5)).toBe(0); + }); + + test('update changes a single element (full-range query reflects it)', () => { + tree.update(3, 100); + expect(tree.query(0, input.length - 1)).toBe(2); + }); + }); + + + describe('single-element array', () => { + test('query and update work on a single-element segment tree', () => { + const tree = new SegmentTree([42], (a, b) => a + b); + expect(tree.query(0, 0)).toBe(42); + tree.update(0, 7); + expect(tree.query(0, 0)).toBe(7); + }); + }); +}); diff --git a/src/10-tree/avl-tree.js b/src/10-tree/avl-tree.js index a0b99884..6a043053 100644 --- a/src/10-tree/avl-tree.js +++ b/src/10-tree/avl-tree.js @@ -1,6 +1,6 @@ // src/10-tree/avl-tree.js -const Comparator = require('./comparator'); -const BinarySearchTree = require('./binary-search-tree'); +const Comparator = require('./comparator.js'); +const BinarySearchTree = require('./binary-search-tree.js'); const BalanceFactor = { UNBALANCED_RIGHT: 1, @@ -168,6 +168,87 @@ class AVLTree extends BinarySearchTree { return this.#findMinNode(node.left); } + #findMaxNode(node) { + if (!node.right) { + return node; + } + return this.#findMaxNode(node.right); + } + + get root() { + return this.#root; + } + + search(data) { + return this.#searchNode(data, this.#root); + } + + #searchNode(data, currentNode) { + if (!currentNode) { + return false; + } + + if (this.#compareFn.equal(data, currentNode.data)) { + return true; + } + + if (this.#compareFn.lessThan(data, currentNode.data)) { + return this.#searchNode(data, currentNode.left); + } else { + return this.#searchNode(data, currentNode.right); + } + } + + min() { + if (!this.#root) { + return null; + } + return this.#findMinNode(this.#root).data; + } + + max() { + if (!this.#root) { + return null; + } + return this.#findMaxNode(this.#root).data; + } + + inOrderTraverse(callback) { + this.#inOrderTraverseNode(this.#root, callback); + } + + #inOrderTraverseNode(node, callback) { + if (node) { + this.#inOrderTraverseNode(node.left, callback); + callback(node.data); + this.#inOrderTraverseNode(node.right, callback); + } + } + + preOrderTraverse(callback) { + this.#preOrderTraverseNode(this.#root, callback); + } + + #preOrderTraverseNode(node, callback) { + if (node) { + callback(node.data); + this.#preOrderTraverseNode(node.left, callback); + this.#preOrderTraverseNode(node.right, callback); + } + } + + postOrderTraverse(callback) { + this.#postOrderTraverseNode(this.#root, callback); + } + + #postOrderTraverseNode(node, callback) { + if (node) { + this.#postOrderTraverseNode(node.left, callback); + this.#postOrderTraverseNode(node.right, callback); + callback(node.data); + } + } + } module.exports = AVLTree; \ No newline at end of file diff --git a/src/10-tree/avl-tree.ts b/src/10-tree/avl-tree.ts index 88d9780c..0c32b669 100644 --- a/src/10-tree/avl-tree.ts +++ b/src/10-tree/avl-tree.ts @@ -181,6 +181,87 @@ class AVLTree extends BinarySearchTree { } return this.#findMinNode(node.left); } + + #findMaxNode(node: AVLNode): AVLNode { + if (!node.right) { + return node; + } + return this.#findMaxNode(node.right); + } + + override get root(): AVLNode | null { + return this.#root; + } + + override search(data: T): boolean { + return this.#searchNode(data, this.#root); + } + + #searchNode(data: T, currentNode: AVLNode | null): boolean { + if (!currentNode) { + return false; + } + + if (this.#compareFn.equal(data, currentNode.data)) { + return true; + } + + if (this.#compareFn.lessThan(data, currentNode.data)) { + return this.#searchNode(data, currentNode.left); + } else { + return this.#searchNode(data, currentNode.right); + } + } + + override min(): T | null { + if (!this.#root) { + return null; + } + return this.#findMinNode(this.#root).data; + } + + override max(): T | null { + if (!this.#root) { + return null; + } + return this.#findMaxNode(this.#root).data; + } + + override inOrderTraverse(callback: (data: T) => void): void { + this.#inOrderTraverseNode(this.#root, callback); + } + + #inOrderTraverseNode(node: AVLNode | null, callback: (data: T) => void): void { + if (node) { + this.#inOrderTraverseNode(node.left, callback); + callback(node.data); + this.#inOrderTraverseNode(node.right, callback); + } + } + + override preOrderTraverse(callback: (data: T) => void): void { + this.#preOrderTraverseNode(this.#root, callback); + } + + #preOrderTraverseNode(node: AVLNode | null, callback: (data: T) => void): void { + if (node) { + callback(node.data); + this.#preOrderTraverseNode(node.left, callback); + this.#preOrderTraverseNode(node.right, callback); + } + } + + override postOrderTraverse(callback: (data: T) => void): void { + this.#postOrderTraverseNode(this.#root, callback); + } + + #postOrderTraverseNode(node: AVLNode | null, callback: (data: T) => void): void { + if (node) { + this.#postOrderTraverseNode(node.left, callback); + this.#postOrderTraverseNode(node.right, callback); + callback(node.data); + } + } } export default AVLTree; diff --git a/src/10-tree/binary-search-tree.js b/src/10-tree/binary-search-tree.js index 598b73e7..c2b60b1e 100644 --- a/src/10-tree/binary-search-tree.js +++ b/src/10-tree/binary-search-tree.js @@ -1,6 +1,6 @@ // src/10-tree/binary-search-tree.js -const Comparator = require('./comparator'); +const Comparator = require('./comparator.js'); class BSTNode { constructor(data) { diff --git a/src/10-tree/fenwick-tree.js b/src/10-tree/fenwick-tree.js index 342df8db..b0912bb5 100644 --- a/src/10-tree/fenwick-tree.js +++ b/src/10-tree/fenwick-tree.js @@ -43,4 +43,6 @@ class FenwickTree { toString() { return this.#tree.join(', '); } -} \ No newline at end of file +} + +module.exports = FenwickTree; \ No newline at end of file diff --git a/src/10-tree/red-black-tree.js b/src/10-tree/red-black-tree.js index 2b207d77..618d6724 100644 --- a/src/10-tree/red-black-tree.js +++ b/src/10-tree/red-black-tree.js @@ -1,7 +1,7 @@ // src/10-tree/red-black-tree.js -const Comparator = require('./comparator'); -const BinarySearchTree = require('./binary-search-tree'); +const Comparator = require('./comparator.js'); +const BinarySearchTree = require('./binary-search-tree.js'); const NodeColor = { RED: 0, @@ -138,6 +138,8 @@ class RedBlackTree extends BinarySearchTree { newRoot.left = node; node.parent = newRoot; + + return newRoot; } #rotateRight(node) { @@ -160,6 +162,8 @@ class RedBlackTree extends BinarySearchTree { newRoot.right = node; node.parent = newRoot; + + return newRoot; } remove(data) { @@ -181,10 +185,12 @@ class RedBlackTree extends BinarySearchTree { } if (!currentNode.left) { + currentNode.right.parent = currentNode.parent; return currentNode.right; } if (!currentNode.right) { + currentNode.left.parent = currentNode.parent; return currentNode.left; } @@ -203,6 +209,87 @@ class RedBlackTree extends BinarySearchTree { return this.#findMinNode(node.left); } + #findMaxNode(node) { + if (!node.right) { + return node; + } + return this.#findMaxNode(node.right); + } + + get root() { + return this.#root; + } + + search(data) { + return this.#searchNode(data, this.#root); + } + + #searchNode(data, currentNode) { + if (!currentNode) { + return false; + } + + if (this.#compareFn.equal(data, currentNode.data)) { + return true; + } + + if (this.#compareFn.lessThan(data, currentNode.data)) { + return this.#searchNode(data, currentNode.left); + } else { + return this.#searchNode(data, currentNode.right); + } + } + + min() { + if (!this.#root) { + return null; + } + return this.#findMinNode(this.#root).data; + } + + max() { + if (!this.#root) { + return null; + } + return this.#findMaxNode(this.#root).data; + } + + inOrderTraverse(callback) { + this.#inOrderTraverseNode(this.#root, callback); + } + + #inOrderTraverseNode(node, callback) { + if (node) { + this.#inOrderTraverseNode(node.left, callback); + callback(node.data); + this.#inOrderTraverseNode(node.right, callback); + } + } + + preOrderTraverse(callback) { + this.#preOrderTraverseNode(this.#root, callback); + } + + #preOrderTraverseNode(node, callback) { + if (node) { + callback(node.data); + this.#preOrderTraverseNode(node.left, callback); + this.#preOrderTraverseNode(node.right, callback); + } + } + + postOrderTraverse(callback) { + this.#postOrderTraverseNode(this.#root, callback); + } + + #postOrderTraverseNode(node, callback) { + if (node) { + this.#postOrderTraverseNode(node.left, callback); + this.#postOrderTraverseNode(node.right, callback); + callback(node.data); + } + } + #balance(node) { if (node.isRed()) { return node; diff --git a/src/10-tree/red-black-tree.ts b/src/10-tree/red-black-tree.ts index e94af54c..fe8d72a2 100644 --- a/src/10-tree/red-black-tree.ts +++ b/src/10-tree/red-black-tree.ts @@ -128,7 +128,7 @@ class RedBlackTree extends BinarySearchTree { uncle.color = NodeColor.BLACK; } - #rotateLeft(node: RedBlackNode): void { + #rotateLeft(node: RedBlackNode): RedBlackNode { const newRoot = node.right!; node.right = newRoot.left; @@ -148,9 +148,11 @@ class RedBlackTree extends BinarySearchTree { newRoot.left = node; node.parent = newRoot; + + return newRoot; } - #rotateRight(node: RedBlackNode): void { + #rotateRight(node: RedBlackNode): RedBlackNode { const newRoot = node.left!; node.left = newRoot.right; @@ -170,6 +172,8 @@ class RedBlackTree extends BinarySearchTree { newRoot.right = node; node.parent = newRoot; + + return newRoot; } /** @@ -196,10 +200,12 @@ class RedBlackTree extends BinarySearchTree { } if (!currentNode.left) { + currentNode.right!.parent = currentNode.parent; return currentNode.right; } if (!currentNode.right) { + currentNode.left.parent = currentNode.parent; return currentNode.left; } @@ -218,6 +224,87 @@ class RedBlackTree extends BinarySearchTree { return this.#findMinNode(node.left); } + #findMaxNode(node: RedBlackNode): RedBlackNode { + if (!node.right) { + return node; + } + return this.#findMaxNode(node.right); + } + + override get root(): RedBlackNode | null { + return this.#root; + } + + override search(data: T): boolean { + return this.#searchNode(data, this.#root); + } + + #searchNode(data: T, currentNode: RedBlackNode | null): boolean { + if (!currentNode) { + return false; + } + + if (this.#compareFn.equal(data, currentNode.data)) { + return true; + } + + if (this.#compareFn.lessThan(data, currentNode.data)) { + return this.#searchNode(data, currentNode.left); + } else { + return this.#searchNode(data, currentNode.right); + } + } + + override min(): T | null { + if (!this.#root) { + return null; + } + return this.#findMinNode(this.#root).data; + } + + override max(): T | null { + if (!this.#root) { + return null; + } + return this.#findMaxNode(this.#root).data; + } + + override inOrderTraverse(callback: (data: T) => void): void { + this.#inOrderTraverseNode(this.#root, callback); + } + + #inOrderTraverseNode(node: RedBlackNode | null, callback: (data: T) => void): void { + if (node) { + this.#inOrderTraverseNode(node.left, callback); + callback(node.data); + this.#inOrderTraverseNode(node.right, callback); + } + } + + override preOrderTraverse(callback: (data: T) => void): void { + this.#preOrderTraverseNode(this.#root, callback); + } + + #preOrderTraverseNode(node: RedBlackNode | null, callback: (data: T) => void): void { + if (node) { + callback(node.data); + this.#preOrderTraverseNode(node.left, callback); + this.#preOrderTraverseNode(node.right, callback); + } + } + + override postOrderTraverse(callback: (data: T) => void): void { + this.#postOrderTraverseNode(this.#root, callback); + } + + #postOrderTraverseNode(node: RedBlackNode | null, callback: (data: T) => void): void { + if (node) { + this.#postOrderTraverseNode(node.left, callback); + this.#postOrderTraverseNode(node.right, callback); + callback(node.data); + } + } + #balance(node: RedBlackNode): RedBlackNode | void { if (node.isRed()) { return node; @@ -229,7 +316,7 @@ class RedBlackTree extends BinarySearchTree { } if (node.left.right && node.left.right.isRed()) { - node.left = this.#rotateLeft(node.left) as unknown as RedBlackNode | null; + node.left = this.#rotateLeft(node.left); return this.#rotateRight(node); } } @@ -240,7 +327,7 @@ class RedBlackTree extends BinarySearchTree { } if (node.right.left && node.right.left.isRed()) { - node.right = this.#rotateRight(node.right) as unknown as RedBlackNode | null; + node.right = this.#rotateRight(node.right); return this.#rotateLeft(node); } } diff --git a/src/10-tree/segment-tree.js b/src/10-tree/segment-tree.js index 3bcc929a..82004a34 100644 --- a/src/10-tree/segment-tree.js +++ b/src/10-tree/segment-tree.js @@ -83,4 +83,6 @@ class SegmentTree { toString() { return this.#segmentTree.join(', '); } -} \ No newline at end of file +} + +module.exports = SegmentTree; \ No newline at end of file diff --git a/src/12-trie/__test__/trie-js.test.ts b/src/12-trie/__test__/trie-js.test.ts new file mode 100644 index 00000000..7ab951c4 --- /dev/null +++ b/src/12-trie/__test__/trie-js.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test, beforeEach } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Trie = require('../trie.js') as new () => { + insert(word: string): void; + search(word: string): boolean; + startsWith(prefix: string): boolean; + remove(word: string): boolean; +}; + +describe('Trie (trie.js)', () => { + let trie: InstanceType; + + beforeEach(() => { + trie = new Trie(); + }); + + test('should return false for search on empty trie', () => { + expect(trie.search('hello')).toBe(false); + }); + + test('should return false for startsWith on empty trie', () => { + expect(trie.startsWith('h')).toBe(false); + }); + + test('should insert and search a word', () => { + trie.insert('hello'); + expect(trie.search('hello')).toBe(true); + }); + + test('should return false for a word that does not exist', () => { + trie.insert('hello'); + expect(trie.search('world')).toBe(false); + }); + + test('should return false when searching a prefix that is not a full word', () => { + trie.insert('hello'); + expect(trie.search('hell')).toBe(false); + }); + + test('should return true for startsWith with an existing prefix', () => { + trie.insert('hello'); + expect(trie.startsWith('hel')).toBe(true); + }); + + test('should return false for startsWith with a non-existing prefix', () => { + trie.insert('hello'); + expect(trie.startsWith('xyz')).toBe(false); + }); + + test('should return true for startsWith when the full word is the prefix', () => { + trie.insert('hello'); + expect(trie.startsWith('hello')).toBe(true); + }); + + test('should insert multiple words with shared prefix', () => { + trie.insert('car'); + trie.insert('card'); + trie.insert('care'); + expect(trie.search('car')).toBe(true); + expect(trie.search('card')).toBe(true); + expect(trie.search('care')).toBe(true); + expect(trie.startsWith('car')).toBe(true); + expect(trie.search('ca')).toBe(false); + }); + + test('inserting the same word twice keeps it searchable', () => { + trie.insert('hello'); + trie.insert('hello'); + expect(trie.search('hello')).toBe(true); + }); + + test('search and startsWith on empty string', () => { + expect(trie.search('')).toBe(false); + trie.insert(''); + expect(trie.search('')).toBe(true); + expect(trie.startsWith('')).toBe(true); + }); + + test('should remove an existing word so search returns false', () => { + trie.insert('hello'); + expect(trie.remove('hello')).toBe(true); + expect(trie.search('hello')).toBe(false); + }); + + test('should remove a word that is a prefix of another without affecting the longer word', () => { + trie.insert('car'); + trie.insert('card'); + trie.remove('car'); + expect(trie.search('car')).toBe(false); + expect(trie.search('card')).toBe(true); + }); + + test('should return false when removing a non-existent word', () => { + trie.insert('hello'); + expect(trie.remove('world')).toBe(false); + }); + + test('should return false when removing a word not present with a divergent path', () => { + trie.insert('hello'); + expect(trie.remove('help')).toBe(false); + expect(trie.search('hello')).toBe(true); + }); + + test('should keep startsWith working after removing a word that shares a prefix', () => { + trie.insert('car'); + trie.insert('card'); + trie.remove('card'); + expect(trie.search('card')).toBe(false); + expect(trie.search('car')).toBe(true); + expect(trie.startsWith('car')).toBe(true); + }); + + test('removing a leaf word cleans up unshared nodes so unrelated prefixes are unaffected', () => { + trie.insert('cat'); + trie.insert('dog'); + trie.remove('cat'); + expect(trie.search('cat')).toBe(false); + expect(trie.startsWith('cat')).toBe(false); + expect(trie.search('dog')).toBe(true); + }); + + test('removing from an empty trie returns false', () => { + expect(trie.remove('anything')).toBe(false); + }); + + test('removing a word twice returns false the second time', () => { + trie.insert('hello'); + expect(trie.remove('hello')).toBe(true); + expect(trie.remove('hello')).toBe(false); + }); +}); diff --git a/src/13-graph/__test__/bfs-js.test.ts b/src/13-graph/__test__/bfs-js.test.ts new file mode 100644 index 00000000..60389dd1 --- /dev/null +++ b/src/13-graph/__test__/bfs-js.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Graph = require('../graph.js') as new (isDirected?: boolean) => { + addVertex(vertex: string): void; + addEdge(vertex: string, edge: string): void; + vertices: string[]; + adjList: Map; +}; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { breadthFirstSearch, bfsShortestPath } = require('../bfs.js') as { + breadthFirstSearch: (graph: InstanceType, startVertex: string, callback?: (vertex: string) => void) => void; + bfsShortestPath: (graph: InstanceType, startVertex: string) => { + distances: Record; + predecessors: Record; + }; +}; + +function buildSampleGraph(): InstanceType { + const g = new Graph(); + ['A', 'B', 'C', 'D', 'E'].forEach(v => g.addVertex(v)); + g.addEdge('A', 'B'); + g.addEdge('A', 'C'); + g.addEdge('B', 'D'); + g.addEdge('C', 'E'); + return g; +} + +describe('breadthFirstSearch (js)', () => { + test('visits all reachable vertices', () => { + const g = buildSampleGraph(); + const visited: string[] = []; + breadthFirstSearch(g, 'A', v => visited.push(v)); + expect(visited.sort()).toEqual(['A', 'B', 'C', 'D', 'E']); + }); + + test('works without a callback', () => { + const g = buildSampleGraph(); + expect(() => breadthFirstSearch(g, 'A')).not.toThrow(); + }); + + test('does not revisit vertices in a graph with cycles', () => { + const g = new Graph(); + g.addEdge('A', 'B'); + g.addEdge('B', 'C'); + g.addEdge('C', 'A'); // cycle + const visited: string[] = []; + breadthFirstSearch(g, 'A', v => visited.push(v)); + expect(visited.sort()).toEqual(['A', 'B', 'C']); + }); + + test('single-vertex graph visits only itself', () => { + const g = new Graph(); + g.addVertex('A'); + const visited: string[] = []; + breadthFirstSearch(g, 'A', v => visited.push(v)); + expect(visited).toEqual(['A']); + }); + + test('disconnected vertices are not visited from unrelated start', () => { + const g = new Graph(); + g.addEdge('A', 'B'); + g.addVertex('Z'); // disconnected + const visited: string[] = []; + breadthFirstSearch(g, 'A', v => visited.push(v)); + expect(visited).not.toContain('Z'); + }); +}); + +describe('bfsShortestPath (js)', () => { + test('returns correct distances from start vertex', () => { + const g = buildSampleGraph(); + const { distances } = bfsShortestPath(g, 'A'); + expect(distances['A']).toBe(0); + expect(distances['B']).toBe(1); + expect(distances['C']).toBe(1); + expect(distances['D']).toBe(2); + expect(distances['E']).toBe(2); + }); + + test('predecessors point toward the start vertex', () => { + const g = buildSampleGraph(); + const { predecessors } = bfsShortestPath(g, 'A'); + expect(predecessors['A']).toBeNull(); + expect(predecessors['B']).toBe('A'); + expect(predecessors['D']).toBe('B'); + }); + + test('unreachable vertex keeps default distance of 0 and null predecessor', () => { + const g = new Graph(); + g.addEdge('A', 'B'); + g.addVertex('Z'); // disconnected from A + const { distances, predecessors } = bfsShortestPath(g, 'A'); + expect(distances['Z']).toBe(0); + expect(predecessors['Z']).toBeNull(); + }); +}); diff --git a/src/13-graph/__test__/dfs-js.test.ts b/src/13-graph/__test__/dfs-js.test.ts new file mode 100644 index 00000000..368b3bc1 --- /dev/null +++ b/src/13-graph/__test__/dfs-js.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Graph = require('../graph.js') as new (isDirected?: boolean) => { + addVertex(vertex: string): void; + addEdge(vertex: string, edge: string): void; + vertices: string[]; + adjList: Map; +}; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { depthFirstSearch, enhancedDepthFirstSearch } = require('../dfs.js') as { + depthFirstSearch: (graph: InstanceType, callback?: (vertex: string) => void) => void; + enhancedDepthFirstSearch: (graph: InstanceType) => { + discovery: Record; + finished: Record; + predecessors: Record; + }; +}; + +function buildSampleGraph(): InstanceType { + const g = new Graph(); + ['A', 'B', 'C', 'D', 'E'].forEach(v => g.addVertex(v)); + g.addEdge('A', 'B'); + g.addEdge('A', 'C'); + g.addEdge('B', 'D'); + g.addEdge('C', 'E'); + return g; +} + +describe('depthFirstSearch (js)', () => { + test('visits all vertices', () => { + const g = buildSampleGraph(); + const visited: string[] = []; + depthFirstSearch(g, v => visited.push(v)); + expect(visited.sort()).toEqual(['A', 'B', 'C', 'D', 'E']); + }); + + test('works without a callback', () => { + const g = buildSampleGraph(); + expect(() => depthFirstSearch(g)).not.toThrow(); + }); + + test('visits disconnected components', () => { + const g = new Graph(); + g.addEdge('A', 'B'); + g.addVertex('Z'); // disconnected + const visited: string[] = []; + depthFirstSearch(g, v => visited.push(v)); + expect(visited.sort()).toEqual(['A', 'B', 'Z']); + }); + + test('does not infinite loop on a cyclic graph', () => { + const g = new Graph(); + g.addEdge('A', 'B'); + g.addEdge('B', 'C'); + g.addEdge('C', 'A'); // cycle + const visited: string[] = []; + depthFirstSearch(g, v => visited.push(v)); + expect(visited.sort()).toEqual(['A', 'B', 'C']); + }); + + test('single-vertex graph visits only itself', () => { + const g = new Graph(); + g.addVertex('A'); + const visited: string[] = []; + depthFirstSearch(g, v => visited.push(v)); + expect(visited).toEqual(['A']); + }); + + test('empty graph visits nothing', () => { + const g = new Graph(); + const visited: string[] = []; + depthFirstSearch(g, v => visited.push(v)); + expect(visited).toEqual([]); + }); +}); + +describe('enhancedDepthFirstSearch (js)', () => { + test('returns discovery, finished, and predecessors for all vertices', () => { + const g = buildSampleGraph(); + const result = enhancedDepthFirstSearch(g); + const vertices = ['A', 'B', 'C', 'D', 'E']; + vertices.forEach(v => { + expect(result.discovery[v]).toBeGreaterThan(0); + expect(result.finished[v]).toBeGreaterThan(0); + expect(result.finished[v]).toBeGreaterThan(result.discovery[v]); + }); + // Predecessors: A is a root + expect(result.predecessors['A']).toBeNull(); + }); + + test('sets predecessor for discovered neighbor', () => { + const g = buildSampleGraph(); + const result = enhancedDepthFirstSearch(g); + expect(result.predecessors['B']).toBe('A'); + }); + + test('handles disconnected components with multiple roots', () => { + const g = new Graph(); + g.addEdge('A', 'B'); + g.addVertex('Z'); // disconnected + const result = enhancedDepthFirstSearch(g); + expect(result.predecessors['A']).toBeNull(); + expect(result.predecessors['Z']).toBeNull(); + expect(result.finished['Z']).toBeGreaterThan(0); + }); +}); diff --git a/src/13-graph/__test__/dijkstra-js.test.ts b/src/13-graph/__test__/dijkstra-js.test.ts new file mode 100644 index 00000000..e3898cd3 --- /dev/null +++ b/src/13-graph/__test__/dijkstra-js.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const dijkstra = require('../dijkstra.js') as ( + graph: number[][], + src: number +) => { distances: number[]; predecessors: Record }; + +const flightCosts = [ + // SEA MDW DEN MCO STL JFK ATL + [0, 300, 220, 1000, 0, 0, 0], // SEA (0) + [300, 0, 0, 0, 50, 210, 190], // MDW (1) + [220, 0, 0, 0, 350, 0, 0], // DEN (2) + [1000, 0, 0, 0, 150, 250, 0], // MCO (3) + [0, 50, 350, 150, 0, 0, 0], // STL (4) + [0, 210, 0, 250, 0, 0, 200], // JFK (5) + [0, 190, 0, 0, 0, 200, 0], // ATL (6) +]; + +describe('dijkstra (js)', () => { + test('distance from source to itself is 0', () => { + const { distances } = dijkstra(flightCosts, 0); + expect(distances[0]).toBe(0); + }); + + test('shortest path SEA -> MDW = 300', () => { + const { distances } = dijkstra(flightCosts, 0); + expect(distances[1]).toBe(300); + }); + + test('shortest path SEA -> DEN = 220', () => { + const { distances } = dijkstra(flightCosts, 0); + expect(distances[2]).toBe(220); + }); + + test('shortest path SEA -> STL = 350 (via MDW)', () => { + const { distances } = dijkstra(flightCosts, 0); + expect(distances[4]).toBe(350); + }); + + test('shortest path SEA -> MCO = 500 (via MDW+STL)', () => { + const { distances } = dijkstra(flightCosts, 0); + expect(distances[3]).toBe(500); + }); + + test('shortest path SEA -> JFK and SEA -> ATL are computed correctly', () => { + const { distances } = dijkstra(flightCosts, 0); + expect(distances[5]).toBe(510); // SEA -> MDW -> JFK = 300 + 210 + expect(distances[6]).toBe(490); // SEA -> MDW -> ATL = 300 + 190 + }); + + test('predecessors array is populated for reachable nodes', () => { + const { predecessors } = dijkstra(flightCosts, 0); + expect(predecessors[0]).toEqual([0]); + expect(predecessors[1]).toContain(0); + }); + + test('full predecessor path for a multi-hop route', () => { + const { predecessors } = dijkstra(flightCosts, 0); + // Shortest path SEA -> STL is via MDW: [0, 1, 4] + expect(predecessors[4]).toEqual([0, 1, 4]); + }); + + test('single-vertex graph returns distance 0 for itself', () => { + const { distances, predecessors } = dijkstra([[0]], 0); + expect(distances[0]).toBe(0); + expect(predecessors[0]).toEqual([0]); + }); + + test('unreachable vertex keeps INF distance and a trivial path', () => { + const g = [ + [0, 1, 0], + [1, 0, 0], + [0, 0, 0], // vertex 2 is isolated + ]; + const { distances, predecessors } = dijkstra(g, 0); + expect(distances[2]).toBe(Number.MAX_SAFE_INTEGER); + expect(predecessors[2]).toEqual([2]); + }); +}); diff --git a/src/13-graph/__test__/floyd-warshall-js.test.ts b/src/13-graph/__test__/floyd-warshall-js.test.ts new file mode 100644 index 00000000..afbfac70 --- /dev/null +++ b/src/13-graph/__test__/floyd-warshall-js.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const floydWarshall = require('../floyd-warshall.js') as (graph: number[][]) => number[][]; + +describe('floydWarshall (js)', () => { + const INF = Infinity; + const g = [ + [0, 1, INF], + [INF, 0, 2], + [3, INF, 0], + ]; + + test('diagonal is always 0', () => { + const dist = floydWarshall(g); + expect(dist[0][0]).toBe(0); + expect(dist[1][1]).toBe(0); + expect(dist[2][2]).toBe(0); + }); + + test('direct edge costs are preserved', () => { + const dist = floydWarshall(g); + expect(dist[0][1]).toBe(1); + expect(dist[1][2]).toBe(2); + expect(dist[2][0]).toBe(3); + }); + + test('indirect paths are computed', () => { + const dist = floydWarshall(g); + // 0 -> 1 -> 2 = 1 + 2 = 3 + expect(dist[0][2]).toBe(3); + // 1 -> 2 -> 0 = 2 + 3 = 5 + expect(dist[1][0]).toBe(5); + // 2 -> 0 -> 1 = 3 + 1 = 4 + expect(dist[2][1]).toBe(4); + }); + + test('single vertex graph returns a 1x1 zero matrix', () => { + const dist = floydWarshall([[0]]); + expect(dist).toEqual([[0]]); + }); + + test('fully disconnected graph keeps INF for unreachable pairs', () => { + const disconnected = [ + [0, INF], + [INF, 0], + ]; + const dist = floydWarshall(disconnected); + expect(dist[0][1]).toBe(Number.MAX_SAFE_INTEGER); + expect(dist[1][0]).toBe(Number.MAX_SAFE_INTEGER); + }); + + test('handles a graph with no edges at all (only diagonal zeros)', () => { + const empty = [ + [0, INF, INF], + [INF, 0, INF], + [INF, INF, 0], + ]; + const dist = floydWarshall(empty); + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + if (i === j) { + expect(dist[i][j]).toBe(0); + } else { + expect(dist[i][j]).toBe(Number.MAX_SAFE_INTEGER); + } + } + } + }); +}); diff --git a/src/13-graph/__test__/graph-js.test.ts b/src/13-graph/__test__/graph-js.test.ts new file mode 100644 index 00000000..dfbd7ace --- /dev/null +++ b/src/13-graph/__test__/graph-js.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Graph = require('../graph.js') as new (isDirected?: boolean) => { + addVertex(vertex: string): void; + addEdge(vertex: string, edge: string): void; + vertices: string[]; + adjList: Map; + toString(): string; +}; + +describe('Graph (js, undirected)', () => { + test('addVertex adds vertices and creates adjacency list entries', () => { + const g = new Graph(); + g.addVertex('A'); + g.addVertex('B'); + expect(g.vertices).toContain('A'); + expect(g.vertices).toContain('B'); + expect(g.adjList.has('A')).toBe(true); + }); + + test('addVertex does not add duplicate vertices', () => { + const g = new Graph(); + g.addVertex('A'); + g.addVertex('A'); + expect(g.vertices).toEqual(['A']); + }); + + test('addEdge creates bidirectional edges in undirected graph', () => { + const g = new Graph(); + g.addEdge('A', 'B'); + expect(g.adjList.get('A')).toContain('B'); + expect(g.adjList.get('B')).toContain('A'); + }); + + test('addEdge auto-creates missing vertices', () => { + const g = new Graph(); + g.addEdge('X', 'Y'); + expect(g.vertices).toContain('X'); + expect(g.vertices).toContain('Y'); + }); + + test('toString returns a non-empty string with vertex/neighbor formatting', () => { + const g = new Graph(); + g.addEdge('A', 'B'); + const s = g.toString(); + expect(s.length).toBeGreaterThan(0); + expect(s).toContain('A -> B'); + expect(s).toContain('B -> A'); + }); + + test('toString on empty graph returns empty string', () => { + const g = new Graph(); + expect(g.toString()).toBe(''); + }); +}); + +describe('Graph (js, directed)', () => { + test('addEdge in directed graph does NOT add reverse edge', () => { + const g = new Graph(true); + g.addEdge('A', 'B'); + expect(g.adjList.get('A')).toContain('B'); + expect(g.adjList.get('B')).not.toContain('A'); + }); + + test('supports self-loops', () => { + const g = new Graph(true); + g.addEdge('A', 'A'); + expect(g.adjList.get('A')).toContain('A'); + }); +}); diff --git a/src/13-graph/__test__/kruskal-js.test.ts b/src/13-graph/__test__/kruskal-js.test.ts new file mode 100644 index 00000000..6d016435 --- /dev/null +++ b/src/13-graph/__test__/kruskal-js.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const kruskal = require('../kruskal.js') as (graph: number[][]) => number[]; + +const cities = [ + [0, 2, 4, 0, 0, 0], + [2, 0, 2, 4, 2, 0], + [4, 2, 0, 0, 3, 0], + [0, 4, 0, 0, 3, 2], + [0, 2, 3, 3, 0, 2], + [0, 0, 0, 2, 2, 0], +]; + +describe('kruskal (js)', () => { + test('produces a non-empty result for cities graph', () => { + const mst = kruskal(cities); + expect(Array.isArray(mst)).toBe(true); + expect(mst.length).toBeGreaterThan(0); + }); + + test('produces correct MST for simple 3-vertex graph', () => { + // g has edges: (0,1)=1, (0,2)=2, (1,2)=3 → MST: (0,1) and (0,2) + const g = [ + [0, 1, 2], + [1, 0, 3], + [2, 3, 0], + ]; + const mst = kruskal(g); + // parent[1]=0 and parent[2]=0 (both connected to vertex 0) + expect(mst[1]).toBe(0); + expect(mst[2]).toBe(0); + }); + + test('two-vertex graph connects the single edge', () => { + const g = [ + [0, 5], + [5, 0], + ]; + const mst = kruskal(g); + expect(mst[1]).toBe(0); + }); + + test('a redundant edge consumes the edge budget and can leave a vertex disconnected', () => { + // find() treats parent[i] === 0 as "no parent" (falsy), so a vertex whose + // true root is 0 can be misreported as its own root, causing a redundant + // union that consumes an edge without connecting anything. Same behavior + // as kruskal.ts, left unfixed here. + const g = [ + [0, 1, 3, 0], + [1, 0, 2, 0], + [3, 2, 0, 4], + [0, 0, 4, 0], + ]; + const mst = kruskal(g); + expect(mst[1]).toBe(0); + expect(mst[2]).toBe(1); + expect(mst[3]).toBeUndefined(); + }); + + test('skips an edge that would create a cycle (union returns false)', () => { + // Triangle among 1-2(1), 2-3(2), 1-3(3), with 0-1(10) as the only link + // to vertex 0. After 1-2 and 2-3 are picked, the edge 1-3 would connect + // two vertices already in the same set (both resolve to root 1), so + // union() must return false and skip incrementing the edge count. + const g = [ + [0, 10, 0, 0], + [10, 0, 1, 3], + [0, 1, 0, 2], + [0, 3, 2, 0], + ]; + const mst = kruskal(g); + expect(mst[1]).toBe(0); + expect(mst[2]).toBe(1); + expect(mst[3]).toBe(1); + }); + + test('picks the minimum weight edge when multiple edges tie for a vertex', () => { + // 0-1 weight 1, 0-2 weight 1, 1-2 weight 5: MST total weight should be 2 (edges 0-1 and 0-2) + const g = [ + [0, 1, 1], + [1, 0, 5], + [1, 5, 0], + ]; + const mst = kruskal(g); + // Both vertex 1 and vertex 2 should connect directly to vertex 0 (lowest-cost edges) + expect(mst[1]).toBe(0); + expect(mst[2]).toBe(0); + }); + + test('four-vertex chain graph connects all vertices with total weight 6', () => { + // Chain: 0-1(1), 1-2(2), 2-3(3); no other edges. MST must include all 3 edges. + const g = [ + [0, 1, 0, 0], + [1, 0, 2, 0], + [0, 2, 0, 3], + [0, 0, 3, 0], + ]; + const mst = kruskal(g); + expect(mst[1]).toBe(0); + // Vertex 3 is unioned into the same set as vertices 0/1 once 2-3 is processed. + expect([1, 2]).toContain(mst[3]); + expect(mst[2]).toBe(1); + }); +}); diff --git a/src/13-graph/__test__/prim-js.test.ts b/src/13-graph/__test__/prim-js.test.ts new file mode 100644 index 00000000..435d1fb3 --- /dev/null +++ b/src/13-graph/__test__/prim-js.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from '@jest/globals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const prim = require('../prim.js') as (graph: number[][]) => number[]; + +const cities = [ + [0, 2, 4, 0, 0, 0], + [2, 0, 2, 4, 2, 0], + [4, 2, 0, 0, 3, 0], + [0, 4, 0, 0, 3, 2], + [0, 2, 3, 3, 0, 2], + [0, 0, 0, 2, 2, 0], +]; + +describe('prim (js)', () => { + test('produces MST matching expected parent array for cities graph', () => { + const mst = prim(cities); + expect(mst).toEqual([-1, 0, 1, 5, 1, 4]); + }); + + test('single-vertex graph returns a root-only parent array', () => { + const mst = prim([[0]]); + expect(mst).toEqual([-1]); + }); + + test('two-vertex graph connects the single edge', () => { + const g = [ + [0, 5], + [5, 0], + ]; + const mst = prim(g); + expect(mst).toEqual([-1, 0]); + }); + + test('four-vertex chain graph produces expected parent chain', () => { + // Chain: 0-1(1), 1-2(2), 2-3(3); no other edges + const g = [ + [0, 1, 0, 0], + [1, 0, 2, 0], + [0, 2, 0, 3], + [0, 0, 3, 0], + ]; + const mst = prim(g); + expect(mst).toEqual([-1, 0, 1, 2]); + }); + + test('prefers the minimum-weight edge when multiple edges are available', () => { + // 0-1 weight 1, 0-2 weight 1, 1-2 weight 5: vertex 2 should connect via lowest cost edge + const g = [ + [0, 1, 1], + [1, 0, 5], + [1, 5, 0], + ]; + const mst = prim(g); + expect(mst[1]).toBe(0); + expect(mst[2]).toBe(0); + }); +});