Skip to main content

Trie (Prefix Tree)

Trie 常用於字串的查詢和搜尋推薦的自動完成功能 (auto complete),讀音同 Try。

前幾篇提到的 Three 中每個節點最多只有兩個子節點。但是 Trie 中每個節點最多有 N 個子節點,其中 N 是字符的種類數量,以下為了簡單講解會先以英文為例。

Fundamental

英文共有 26 個字母,所以每個 Trie 節點最多有 26 個子節點,每個節點本身會代表一個字符。

例如: 將 'apple' 這個字加入 Trie 中,最終 a 節點底下會有 p 節點,p 節點底下會有 p 節點,p 節點底下會有 l 節點,l 節點底下會有 e 節點,因為 e 節點是這個字的最後一個,所以 e 節點的 isEndtrue

至於為何要特別設置一個 isEnd 的屬性呢,假設現在插入一個單字是 'apple',接著我想搜尋是否有 'app' 這個單字,如果沒有 isEnd 屬性的話,就沒法判斷這個單字有沒有插入過。

class TrieNode {
constructor(value) {
this.value = value;
this.children = new Map(); // 也可以使用陣列或物件,但是 Map 在插入和刪除的效能會比較好
this.isEnd = false;
}
}

class Trie {
constructor() {
this.root = new TrieNode('');
}
}

附上圖方便理解:

trie

其中 Root Node 的 value 是空的,方便從頭搜尋與插入。

Insert

遞迴要插入的字串的每個字符,在最後一個字符設置 isEnd = true 代表到這個位置時是一個完整的字串。

class Trie {
constructor() {
this.root = new TrieNode('');
}

insert(word) {
let currentNode = this.root
for (let i = 0; i < word.length; i++) {
if (!currentNode.children.has(word[i])) {
currentNode.children.set(word[i], new TrieNode())
}
currentNode = currentNode.children.get(w)
}
currentNode.isEnd = true
};
}

這裡的搜尋是指要匹配完整的字串,遞迴字串的每個字符到最後時檢查 isEnd 是否為 true

class Trie {
constructor() {
this.root = new TrieNode('');
}

search(word) {
let currentNode = this.root
for (let i = 0; i < word.length; i++) {
if (!currentNode.children.has(word[i])) {
return false
}
currentNode = currentNode.children.get(word[i])
}
if (currentNode.isEnd) {
return true
}
return false
};
}

StartsWith

這裡只需要匹配完給定的 word 即可,不用檢查 isEnd

這裡常用於搜尋推薦和自動完成功能 (auto complete)。

class Trie {
constructor() {
this.root = new TrieNode('');
}

startsWith(word) {
let currentNode = this.root
for (let i = 0; i < word.length; i++) {
if (!currentNode.children.has(word[i])) {
return false
}
currentNode = currentNode.children.get(word[i])
}
return true
};
}

Delete

刪除就需要注意比較多細節了,首先要確定該字串之前有沒有插入過,接著往回做刪除。在刪除節點的同時需要考慮該節點是否同時為其他字串的子節點。

class Trie {
constructor() {
this.root = new TrieNode('');
}

delete(word) {
let currentNode = this.root
const parents = [this.root]
for (let i = 0; i < word.length; i++) {
if (!currentNode.children.has(word[i])) {
return null
}
currentNode = currentNode.children.get(word[i])
parents.push(currentNode)
}
if (!currentNode.isEnd) {
return null
}

if (currentNode.children.size > 0) {
currentNode.isEnd = false
return word
}

for (let i = parents.length - 2; i >= 0; i--) {
const childNode = parents[i].children.get(word[i])
if (childNode.children.size > 0 || childNode.isEnd) {
return word
}
parents[i].children.delete(word[i])
}
return word
};
}

Implementation

完整實作可以看以下:

Implementation
class TrieNode {
constructor(value) {
this.value = value;
this.children = new Map();
this.isEnd = false;
}
}

class Trie {
constructor() {
this.root = new TrieNode('');
}

insert(word) {
let currentNode = this.root
for (let i = 0; i < word.length; i++) {
if (!currentNode.children.has(word[i])) {
currentNode.children.set(word[i], new TrieNode())
}
currentNode = currentNode.children.get(w)
}
currentNode.isEnd = true
};

search(word) {
let currentNode = this.root
for (let i = 0; i < word.length; i++) {
if (!currentNode.children.has(word[i])) {
return false
}
currentNode = currentNode.children.get(word[i])
}
if (currentNode.isEnd) {
return true
}
return false
};

startsWith(word) {
let currentNode = this.root
for (let i = 0; i < word.length; i++) {
if (!currentNode.children.has(word[i])) {
return false
}
currentNode = currentNode.children.get(word[i])
}
return true
};

delete(word) {
let currentNode = this.root
const parents = [this.root]
for (let i = 0; i < word.length; i++) {
if (!currentNode.children.has(word[i])) {
return null
}
currentNode = currentNode.children.get(word[i])
parents.push(currentNode)
}
if (!currentNode.isEnd) {
return null
}

if (currentNode.children.size > 0) {
currentNode.isEnd = false
return word
}

for (let i = parents.length - 2; i >= 0; i--) {
const childNode = parents[i].children.get(word[i])
if (childNode.children.size > 0 || childNode.isEnd) {
return word
}
parents[i].children.delete(word[i])
}
return word
};
}

附上測試用例:

const trie = new Trie();
trie.insert('apple');
trie.insert('app');
trie.insert('apples');

console.log(trie.search('apple'), true);
console.log(trie.search('app'), true);
console.log(trie.search('apples'), true);
console.log(trie.search('ap'), false);
console.log(trie.delete('appple'), null);
console.log(trie.delete('apple'), 'apple');
console.log(trie.search('apple'), false);
console.log(trie.search('app'), true);
console.log(trie.search('apples'), true);
console.log(trie.startsWith('app'), true);
console.log(trie.startsWith('apple'), true);
console.log(trie.startsWith('apples'), true);

Big O Complexity

Time Complexity (Insertion)Time Complexity (Search)Time Complexity (Deletion)Space Complexity
O(n)O(n)O(n)O(n*k)

插入、搜尋、刪除的部分很單純,一個個字母做處理,時間複雜度都是 O(n)。

空間複雜度則是 O(n*k),其中 n 是存入的字串中最長的長度,k 是字符的種類數量。以上面純英文字母的範例,k 為 26,所以最多會有 n * 26 個節點。