diff --git a/dist/grove.js b/dist/grove.js index 3f455d3..3a94aff 100644 --- a/dist/grove.js +++ b/dist/grove.js @@ -1,2 +1,2 @@ -"use strict";class t{constructor(e={key:"",node:null},r=!1){if(!(r||e.key&&"string"==typeof e.key))throw new Error("Parent key cannot be null, empty or not type of string!");if(!(r||e.node&&e.node instanceof t))throw new Error("Parent node cannot be null, empty or not class of TrieNode");this._parent=e,this._children={},this.data=null,this.isEndOfWord=!1,this.word=null}get parent(){return this._parent}get children(){return this._children}update(t){this.isEndOfWord=!!t,this.data=t,this.isEndOfWord||(this.word=null)}unlink(){this._parent={key:"",node:null}}hasChildren(){return Object.keys(this._children).length>0}deleteChild(t){this._children[t]&&(this._children[t].update(null),this._children[t].unlink(),this._children[t].word=null,delete this._children[t])}addChild(t,e){if(!t||!e)return null;const r=this._children[t];return this._children[t]=e,r}hasChild(t){return!!this._children[t]}}exports.Trie=class{constructor(){this._root=new t(null,!0),this._lastIndex=1}get root(){return this._root}insert(t,e){return this._insertWord(t,e,this._root,0)}_insertWord(e,r,n,s){if(s===e.length)return n.word=e,n.update(r||this._getNextIndex()),!0;let h=e.charAt(s);return n.hasChild(h)||n.addChild(h,new t({key:h,node:n})),this._insertWord(e,r,n.children[h],s+1)}search(t){const e=this._searchNode(t,this._root,0);return e?e.data:null}_searchNode(t,e,r){if(r===t.length)return e.isEndOfWord?e:null;let n=t.charAt(r);return e.hasChild(n)?this._searchNode(t,e.children[n],r+1):null}delete(t){const e=this._searchNode(t,this._root,0);return!!e&&(e.hasChildren()?(e.update(null),!0):(this._deleteWord(e),!0))}_deleteWord(t){if(t===this._root)return;const e=t.parent;e.node.deleteChild(e.key),e.node.hasChildren()||this._deleteWord(e.node)}update(t,e){const r=this._searchNode(t,this._root,0);return!!r&&(r.update(e),!0)}getDataNode(t){return this._searchNode(t,this._root,0)}getPath(t){const e=[];e.push(this._root);for(let r=1;r<=t.length;r++)e.push(this._searchNode(t.substring(0,r),this._root,0));return e}_getNextIndex(){return this._lastIndex++}},exports.TrieNode=t; +"use strict";class t{constructor(e={key:"",node:null},r=!1){if(!(r||e.key&&"string"==typeof e.key))throw new Error("Parent key cannot be null, empty or not type of string!");if(!(r||e.node&&e.node instanceof t))throw new Error("Parent node cannot be null, empty or not class of TrieNode");this._parent=e,this._children={},this.data=null,this.isEndOfWord=!1,this.word=null}get parent(){return this._parent}get children(){return this._children}update(t){this.isEndOfWord=!!t,this.data=t,this.isEndOfWord||(this.word=null)}unlink(){this._parent={key:"",node:null}}hasChildren(){return Object.keys(this._children).length>0}deleteChild(t){this._children[t]&&(this._children[t].update(null),this._children[t].unlink(),this._children[t].word=null,delete this._children[t])}addChild(t,e){if(!t||!e)return null;const r=this._children[t];return this._children[t]=e,r}hasChild(t){return!!this._children[t]}}class e{constructor(t={key:null,node:null},r=!1,n=2){if(!r&&(null===t.key||"number"!=typeof t.key))throw new Error("Parent key cannot be null or not type of number!");if(!(r||t.node&&t.node instanceof e))throw new Error("Parent node cannot be null or not instance of CardinalTrieNode");this._parent=t,this.k=n,this._children=new Array(n).fill(null),this.data=null,this.isEndOfWord=!1,this.word=null}get parent(){return this._parent}get children(){return this._children}update(t){this.isEndOfWord=!!t,this.data=t,this.isEndOfWord||(this.word=null)}unlink(){this._parent={key:null,node:null}}hasChildren(){return this._children.some((t=>null!==t))}deleteChild(t){t<0||t>=this.k||null===this._children[t]||(this._children[t].update(null),this._children[t].unlink(),this._children[t].word=null,this._children[t]=null)}addChild(t,e){if(t<0||t>=this.k||!e)return null;const r=this._children[t];return this._children[t]=e,r}hasChild(t){return t>=0&&t=this.k)throw new Error("Invalid character in word: "+t.charAt(h));return n.hasChild(i)||n.addChild(i,new e({key:i,node:n},!1,this.k)),this._insertWord(t,r,n.children[i],h+1)}search(t){const e=this._searchNode(t,this._root,0);return e?e.data:null}_searchNode(t,e,r){if(r===t.length)return e.isEndOfWord?e:null;let n;if(this.alphabet){const e=t.charAt(r);if(!(e in this.charToIndex))return null;n=this.charToIndex[e]}else if(n=parseInt(t.charAt(r),10),isNaN(n)||n<0||n>=this.k)return null;return e.hasChild(n)?this._searchNode(t,e.children[n],r+1):null}delete(t){const e=this._searchNode(t,this._root,0);return!!e&&(e.hasChildren()?(e.update(null),!0):(this._deleteWord(e),!0))}_deleteWord(t){if(t===this._root)return;const e=t.parent;e.node.deleteChild(e.key),e.node.hasChildren()||this._deleteWord(e.node)}update(t,e){const r=this._searchNode(t,this._root,0);return!!r&&(r.update(e),!0)}getDataNode(t){return this._searchNode(t,this._root,0)}getPath(t){const e=[];e.push(this._root);for(let r=1;r<=t.length;r++)e.push(this._searchNode(t.substring(0,r),this._root,0));return e}_getNextIndex(){return this._lastIndex++}},exports.CardinalTrieNode=e,exports.Trie=class{constructor(){this._root=new t(null,!0),this._lastIndex=1}get root(){return this._root}insert(t,e){return this._insertWord(t,e,this._root,0)}_insertWord(e,r,n,h){if(h===e.length)return n.word=e,n.update(r||this._getNextIndex()),!0;let i=e.charAt(h);return n.hasChild(i)||n.addChild(i,new t({key:i,node:n})),this._insertWord(e,r,n.children[i],h+1)}search(t){const e=this._searchNode(t,this._root,0);return e?e.data:null}_searchNode(t,e,r){if(r===t.length)return e.isEndOfWord?e:null;let n=t.charAt(r);return e.hasChild(n)?this._searchNode(t,e.children[n],r+1):null}delete(t){const e=this._searchNode(t,this._root,0);return!!e&&(e.hasChildren()?(e.update(null),!0):(this._deleteWord(e),!0))}_deleteWord(t){if(t===this._root)return;const e=t.parent;e.node.deleteChild(e.key),e.node.hasChildren()||this._deleteWord(e.node)}update(t,e){const r=this._searchNode(t,this._root,0);return!!r&&(r.update(e),!0)}getDataNode(t){return this._searchNode(t,this._root,0)}getPath(t){const e=[];e.push(this._root);for(let r=1;r<=t.length;r++)e.push(this._searchNode(t.substring(0,r),this._root,0));return e}_getNextIndex(){return this._lastIndex++}},exports.TrieNode=t; //# sourceMappingURL=grove.js.map diff --git a/src/trie/CardinalTrie.js b/src/trie/CardinalTrie.js new file mode 100644 index 0000000..a29b750 --- /dev/null +++ b/src/trie/CardinalTrie.js @@ -0,0 +1,181 @@ +import CardinalTrieNode from "./CardinalTrieNode.js"; + +export default class CardinalTrie { + /** + * Constructor for the Cardinal Trie data structure + * @param {number} k The degree (k) of the cardinal tree + * @param {Array} [alphabet] Optional alphabet for mapping characters to indices + */ + constructor(k = 2, alphabet = null) { + this.k = k; + this._root = new CardinalTrieNode(null, true, k); + this._lastIndex = 1; + + if (alphabet) { + if (alphabet.length !== k) + throw new Error("Alphabet length must match the k"); + + this.alphabet = alphabet; + this.charToIndex = {}; + this.indexToChar = {}; + for (let i = 0; i < alphabet.length; i++) { + const char = alphabet[i]; + this.charToIndex[char] = i; + this.indexToChar[i] = char; + } + } else { + this.alphabet = null; + this.charToIndex = null; + this.indexToChar = null; + } + } + + /** + * Get the root node of the Cardinal Trie + * @returns {CardinalTrieNode} + */ + get root() { + return this._root; + } + + /** + * Insert a word into the Cardinal Trie and map data onto it. + * If data is not provided, it is automatically generated as an increasing number. + * @param {string} word + * @param {Object} [data] + */ + insert(word, data) { + return this._insertWord(word, data, this._root, 0); + } + + _insertWord(word, data, currentNode, wordIndex) { + if (wordIndex === word.length) { + currentNode.word = word; + currentNode.update(data || this._getNextIndex()); + return true; + } + + let index; + if (this.alphabet) { + const char = word.charAt(wordIndex); + if (!(char in this.charToIndex)) + throw new Error("Invalid character in word: " + char); + index = this.charToIndex[char]; + } else { + index = parseInt(word.charAt(wordIndex), 10); + if (isNaN(index) || index < 0 || index >= this.k) + throw new Error("Invalid character in word: " + word.charAt(wordIndex)); + } + + if (!currentNode.hasChild(index)) { + currentNode.addChild(index, new CardinalTrieNode({ key: index, node: currentNode }, false, this.k)); + } + + return this._insertWord(word, data, currentNode.children[index], wordIndex + 1); + } + + /** + * Search for data indexed by the provided word in the Cardinal Trie. + * @param {string} word + * @returns {Object|null} Returns the data if found, otherwise null. + */ + search(word) { + const node = this._searchNode(word, this._root, 0); + return !node ? null : node.data; + } + + _searchNode(word, currentNode, wordIndex) { + if (wordIndex === word.length) { + return currentNode.isEndOfWord ? currentNode : null; + } + + let index; + if (this.alphabet) { + const char = word.charAt(wordIndex); + if (!(char in this.charToIndex)) + return null; + index = this.charToIndex[char]; + } else { + index = parseInt(word.charAt(wordIndex), 10); + if (isNaN(index) || index < 0 || index >= this.k) + return null; + } + + return currentNode.hasChild(index) ? this._searchNode(word, currentNode.children[index], wordIndex + 1) : null; + } + + /** + * Delete a word from the Cardinal Trie. + * @param {string} word + * @returns {boolean} True if the word was deleted, otherwise false. + */ + delete(word) { + const node = this._searchNode(word, this._root, 0); + if (!node) + return false; + + if (node.hasChildren()) { + node.update(null); + return true; + } + + this._deleteWord(node); + return true; + } + + _deleteWord(currentNode) { + if (currentNode === this._root) + return; + const parent = currentNode.parent; + + parent.node.deleteChild(parent.key); + if (parent.node.hasChildren()) + return; + + this._deleteWord(parent.node); + } + + /** + * Update data associated with a word in the Cardinal Trie. + * @param {string} word + * @param {*} data + * @returns {boolean} True if the word was updated, otherwise false. + */ + update(word, data) { + const node = this._searchNode(word, this._root, 0); + if (!node) + return false; + + node.update(data); + return true; + } + + /** + * Get the node containing data for the given word. + * @param {string} word + * @returns {CardinalTrieNode|null} + */ + getDataNode(word) { + return this._searchNode(word, this._root, 0); + } + + /** + * Get the path (nodes) representing the given word in the Cardinal Trie. + * @param {string} word + * @returns {Array} + */ + getPath(word) { + const path = []; + path.push(this._root); + + for (let i = 1; i <= word.length; i++) { + path.push(this._searchNode(word.substring(0, i), this._root, 0)); + } + + return path; + } + + _getNextIndex() { + return this._lastIndex++; + } +} diff --git a/src/trie/CardinalTrieNode.js b/src/trie/CardinalTrieNode.js new file mode 100644 index 0000000..2c704a5 --- /dev/null +++ b/src/trie/CardinalTrieNode.js @@ -0,0 +1,104 @@ +export default class CardinalTrieNode { + /** + * Constructor of a new node in the Cardinal Trie data structure + * @param {Object} parent Parent config object + * @param {number} parent.key Index for this node in its parent node + * @param {CardinalTrieNode} parent.node Reference to the parent node + * @param {boolean} [isRoot] Boolean flag indicating if this node is the root + * @param {number} k The degree (k) of the cardinal tree + */ + constructor(parent = { key: null, node: null }, isRoot = false, k = 2) { + if (!isRoot && (parent.key === null || typeof parent.key !== 'number')) + throw new Error("Parent key cannot be null or not type of number!"); + if (!isRoot && (!parent.node || !(parent.node instanceof CardinalTrieNode))) + throw new Error("Parent node cannot be null or not instance of CardinalTrieNode"); + + this._parent = parent; + this.k = k; + this._children = new Array(k).fill(null); + this.data = null; + this.isEndOfWord = false; + this.word = null; + } + + /** + * Get parent object consisting of the child index and parent node. + * @returns {{key: number, node: CardinalTrieNode}} + */ + get parent() { + return this._parent; + } + + /** + * Get array of all node's children. + * @returns {Array} Array of child nodes. + */ + get children() { + return this._children; + } + + /** + * Update indexed data of the node. + * @param {*} data If data is set, the node is marked as the end of a word. + */ + update(data) { + this.isEndOfWord = !!data; + this.data = data; + + if (!this.isEndOfWord) + this.word = null; + } + + /** + * Remove parent object from this node. + */ + unlink() { + this._parent = { key: null, node: null }; + } + + /** + * Check if the node has any child nodes attached to it. + * @returns {boolean} True if it has any children, otherwise false. + */ + hasChildren() { + return this._children.some(child => child !== null); + } + + /** + * Delete child at the given index. + * @param {number} index Child index + */ + deleteChild(index) { + if (index < 0 || index >= this.k || this._children[index] === null) + return; + + this._children[index].update(null); + this._children[index].unlink(); + this._children[index].word = null; + this._children[index] = null; + } + + /** + * Add a child to the node at the specified index. + * @param {number} index Child index + * @param {CardinalTrieNode} node Child node to add + * @returns {CardinalTrieNode|null} Returns the old child if it existed, otherwise null. + */ + addChild(index, node) { + if (index < 0 || index >= this.k || !node) + return null; + + const old = this._children[index]; + this._children[index] = node; + return old; + } + + /** + * Check if the node has a child at the specified index. + * @param {number} index Child index + * @returns {boolean} True if a child exists at the index, otherwise false. + */ + hasChild(index) { + return index >= 0 && index < this.k && this._children[index] !== null; + } +} diff --git a/src/trie/index.js b/src/trie/index.js index ab77f26..243faeb 100644 --- a/src/trie/index.js +++ b/src/trie/index.js @@ -1,2 +1,4 @@ export {default as Trie} from "./Trie"; export {default as TrieNode} from "./TrieNode.js"; +export {default as CardinalTrie} from "./CardinalTrie.js"; +export {default as CardinalTrieNode} from "./CardinalTrieNode.js"; \ No newline at end of file diff --git a/test-report.xml b/test-report.xml new file mode 100644 index 0000000..68284d2 --- /dev/null +++ b/test-report.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/trie.spec.js b/test/trie.spec.js index c86727a..05bb124 100644 --- a/test/trie.spec.js +++ b/test/trie.spec.js @@ -1,5 +1,5 @@ const {describe, expect, test, beforeEach} = require("@jest/globals"); -const {Trie, TrieNode} = require("../dist/grove.js"); +const {Trie, TrieNode, CardinalTrie, CardinalTrieNode} = require("../dist/grove.js"); describe("Trie spec", () => { @@ -25,3 +25,257 @@ describe("Trie spec", () => { expect(trie.search(word)).toBe(1); }) }); + +describe("CardinalTrie spec", () => { + + let cardinalTrie; + + beforeEach(() => { + const alphabet = 'abcdefghijklmnopqrstuvwxyz1234567890'.split(''); + cardinalTrie = new CardinalTrie(36, alphabet); + }); + + test("should insert and search words", () => { + const word = "caca0"; + + cardinalTrie.insert(word); + expect(cardinalTrie.search(word)).toBe(1); + expect(cardinalTrie.search("caca")).toBe(null); + expect(cardinalTrie.search("cacap")).toBe(null); + cardinalTrie.insert("cake"); + expect(cardinalTrie.search("cake")).toBe(2); + expect(cardinalTrie.search("ca")).toBe(null); + cardinalTrie.insert("cat"); + expect(cardinalTrie.search("cat")).toBe(3); + }); + + test("should delete a word from the trie", () => { + const word = "delete"; + cardinalTrie.insert(word); + expect(cardinalTrie.search(word)).toBe(1); + cardinalTrie.delete(word); + expect(cardinalTrie.search(word)).toBe(null); + }); + + test("should update the data of a word", () => { + const word = "update"; + const initialData = 1; + const updatedData = 99; + + cardinalTrie.insert(word, initialData); + expect(cardinalTrie.search(word)).toBe(initialData); + + cardinalTrie.update(word, updatedData); + expect(cardinalTrie.search(word)).toBe(updatedData); + }); + + test("should retrieve the path of a word", () => { + const word = "path"; + + cardinalTrie.insert(word); + const path = cardinalTrie.getPath(word); + + expect(path.length).toBe(word.length + 1); + expect(path[path.length - 1].data).toBe(1); + }); + + test("should handle insertion and search with overlapping prefixes", () => { + cardinalTrie.insert("prefix"); + cardinalTrie.insert("prefixes"); + cardinalTrie.insert("prefixed"); + + expect(cardinalTrie.search("prefix")).toBe(1); + expect(cardinalTrie.search("prefixes")).toBe(2); + expect(cardinalTrie.search("prefixed")).toBe(3); + expect(cardinalTrie.search("prefi")).toBe(null); + }); + + test("should throw an error for invalid characters in word", () => { + const invalidWord = "invalid!"; + + expect(() => cardinalTrie.insert(invalidWord)).toThrowError( + new Error("Invalid character in word: !") + ); + expect(cardinalTrie.search(invalidWord)).toBe(null); + }); + + test("should return null when searching in an empty trie", () => { + expect(cardinalTrie.search("anyword")).toBe(null); + }); + + test("inserting very long word to test recursion depth", () => { + const longWord = 'a'.repeat(5500); + expect(() => cardinalTrie.insert(longWord)).not.toThrow(); + expect(cardinalTrie.search(longWord)).toBe(1); + }); +}); + +describe("CardinalTrie without alphabet", () => { + let cardinalTrie; + + beforeEach(() => { + cardinalTrie = new CardinalTrie(10); + }); + + test("should insert and search numeric words", () => { + const word = "0123456789"; + cardinalTrie.insert(word); + expect(cardinalTrie.search(word)).toBe(1); + expect(cardinalTrie.search("01234")).toBe(null); + }); + + test("should throw an error for invalid characters (non-digit)", () => { + const invalidWord = "123a5"; + expect(() => cardinalTrie.insert(invalidWord)).toThrowError( + new Error("Invalid character in word: a") + ); + }); + + test("should handle insertion and search with overlapping numeric prefixes", () => { + cardinalTrie.insert("123"); + cardinalTrie.insert("1234"); + cardinalTrie.insert("12345"); + + expect(cardinalTrie.search("123")).toBe(1); + expect(cardinalTrie.search("1234")).toBe(2); + expect(cardinalTrie.search("12345")).toBe(3); + expect(cardinalTrie.search("12")).toBe(null); + }); + + test("should return null when searching for non-existent numeric word", () => { + expect(cardinalTrie.search("98765")).toBe(null); + }); +}); + +describe("CardinalTrieNode spec", () => { + test("should throw an error if parent key is null and not root", () => { + expect(() => { + new CardinalTrieNode({ key: null, node: new CardinalTrieNode({}, true) }, false, 2); + }).toThrowError("Parent key cannot be null or not type of number!"); + }); + + test("should throw an error if parent node is null and not root", () => { + expect(() => { + new CardinalTrieNode({ key: 0, node: null }, false, 2); + }).toThrowError("Parent node cannot be null or not instance of CardinalTrieNode"); + }); + + test("should create a root node without a parent", () => { + const rootNode = new CardinalTrieNode({}, true, 2); + expect(rootNode).toBeDefined(); + expect(rootNode.isEndOfWord).toBe(false); + expect(rootNode.hasChildren()).toBe(false); + }); +}); + + +describe("CardinalTrieNode methods", () => { + let node, childNode; + + beforeEach(() => { + node = new CardinalTrieNode({}, true, 2); + childNode = new CardinalTrieNode({ key: 0, node: node }, false, 2); + node.addChild(0, childNode); + }); + + test("should check if node has a child at a specific index", () => { + expect(node.hasChild(0)).toBe(true); + expect(node.hasChild(1)).toBe(false); + }); + + test("should add and delete child nodes correctly", () => { + const newChild = new CardinalTrieNode({ key: 1, node: node }, false, 2); + node.addChild(1, newChild); + expect(node.hasChild(1)).toBe(true); + + node.deleteChild(1); + expect(node.hasChild(1)).toBe(false); + }); + + test("should verify if node has any children", () => { + expect(node.hasChildren()).toBe(true); + node.deleteChild(0); + expect(node.hasChildren()).toBe(false); + }); + + test("should unlink a child node from its parent", () => { + expect(childNode.parent.node).toBe(node); + childNode.unlink(); + expect(childNode.parent.node).toBe(null); + }); + + test("should update node data correctly", () => { + childNode.update("testData"); + expect(childNode.data).toBe("testData"); + expect(childNode.isEndOfWord).toBe(true); + + childNode.update(null); + expect(childNode.data).toBe(null); + expect(childNode.isEndOfWord).toBe(false); + }); +}); + +describe("CardinalTrie additional tests", () => { + let cardinalTrie; + + beforeEach(() => { + const alphabet = 'abc'.split(''); + cardinalTrie = new CardinalTrie(3, alphabet); + }); + + test("should handle insertion with provided data", () => { + const word = "abc"; + const data = { value: 42 }; + cardinalTrie.insert(word, data); + expect(cardinalTrie.search(word)).toBe(data); + }); + + test("should update existing word with new data", () => { + const word = "abc"; + const initialData = { value: 1 }; + const updatedData = { value: 2 }; + cardinalTrie.insert(word, initialData); + cardinalTrie.update(word, updatedData); + expect(cardinalTrie.search(word)).toBe(updatedData); + }); + + test("should delete a word and ensure it's removed", () => { + const word = "abc"; + cardinalTrie.insert(word); + expect(cardinalTrie.search(word)).toBe(1); + cardinalTrie.delete(word); + expect(cardinalTrie.search(word)).toBe(null); + }); + + test("should retrieve the correct path for a given word", () => { + const word = "abc"; + cardinalTrie.insert(word); + const path = cardinalTrie.getPath(word); + expect(path.length).toBe(word.length + 1); + expect(path[0]).toBe(cardinalTrie.root); + expect(path[word.length].isEndOfWord).toBe(true); + }); + + test("should return null when searching for a non-existent word", () => { + expect(cardinalTrie.search("nonexistent")).toBe(null); + }); + + test("should handle insertion and search with overlapping prefixes without alphabet", () => { + cardinalTrie = new CardinalTrie(2); + cardinalTrie.insert("0"); + cardinalTrie.insert("01"); + cardinalTrie.insert("011"); + + expect(cardinalTrie.search("0")).toBe(1); + expect(cardinalTrie.search("01")).toBe(2); + expect(cardinalTrie.search("011")).toBe(3); + expect(cardinalTrie.search("1")).toBe(null); + }); + + test("should throw an error when inserting a word with invalid character without alphabet", () => { + cardinalTrie = new CardinalTrie(2); + expect(() => cardinalTrie.insert("012")).toThrowError( + "Invalid character in word: 2" + ); + }); +}); \ No newline at end of file