> 事情要從 GitHub 上的一個 issue 談起:https://github.com/LeuisKen/leuisken.github.io/issues/2,需求裏面的我指代爲 issue 裏面的我。
從一個需求談起
在我以前的項目中,曾經遇到過這樣一個需求,編寫一個級聯選擇器,大概是這樣:javascript
1html
圖中的示例使用的是 Ant-Design 的 Cascader 組件。前端
要實現這一功能,我須要相似這樣的數據結構:java
var data = [{
"value": "浙江",
"children": [{
"value": "杭州",
"children": [{
"value": "西湖"
}]
}]
}, {
"value": "四川",
"children": [{
"value": "成都",
"children": [{
"value": "錦裏"
}, {
"value": "方所"
}]
}, {
"value": "阿壩",
"children": [{
"value": "九寨溝"
}]
}]
}]
一個具備層級結構的數據,實現這個功能很是容易,由於這個結構和組件的結構是一致的,遞歸遍歷就能夠了。git
可是,因爲後端一般採用的是關係型數據庫,因此返回的數據一般會是這個樣子:程序員
var data = [{
"province": "浙江",
"city": "杭州",
"name": "西湖"
}, {
"province": "四川",
"city": "成都",
"name": "錦裏"
}, {
"province": "四川",
"city": "成都",
"name": "方所"
}, {
"province": "四川",
"city": "阿壩",
"name": "九寨溝"
}]
前端這邊想要將數據轉換一下其實也不難,由於要合併重複項,能夠參考數據去重的方法來作,因而我寫了這樣一個版本。github
'use strict'
/**
* 將一個沒有層級的扁平對象,轉換爲樹形結構({value, children})結構的對象
* @param {array} tableData - 一個由對象構成的數組,裏面的對象都是扁平的
* @param {array} route - 一個由字符串構成的數組,字符串爲前一數組中對象的key,最終
* 輸出的對象層級順序爲keys中字符串key的順序
* @return {array} 保存具備樹形結構的對象
*/
var transObject = function(tableData, keys) {
let hashTable = {}, res = []
for( let i = 0; i < tableData.length; i++ ) {
if(!hashTable[tableData[i][keys[0]]]) {
let len = res.push({
value: tableData[i][keys[0]],
children: []
})
// 在這裏要保存key對應的數組序號,否則還要涉及到查找
hashTable[tableData[i][keys[0]]] = { $$pos: len - 1 }
}
if(!hashTable[tableData[i][keys[0]]][tableData[i][keys[1]]]) {
let len = res[hashTable[tableData[i][keys[0]]].$$pos].children.push({
value: tableData[i][keys[1]],
children: []
})
hashTable[tableData[i][keys[0]]][tableData[i][keys[1]]] = { $$pos: len - 1 }
}
res[hashTable[tableData[i][keys[0]]].$$pos].children[hashTable[tableData[i][keys[0]]][tableData[i][keys[1]]].$$pos].children.push({
value: tableData[i][keys[2]]
})
}
return res
}
var data = [{
"province": "浙江",
"city": "杭州",
"name": "西湖"
}, {
"province": "四川",
"city": "成都",
"name": "錦裏"
}, {
"province": "四川",
"city": "成都",
"name": "方所"
}, {
"province": "四川",
"city": "阿壩",
"name": "九寨溝"
}]
var keys = ['province', 'city', 'name']
console.log(transObject(data, keys))
還好 keys 的長度只有 3 ,這種東西長了根本沒辦法寫,很明顯能夠看出來這裏面有重複的部分,能夠經過循環搞定,可是想了好久都沒有思路,就擱置了。算法
後來,有一天晚飯後不是很忙,就跟旁邊作數據的同事聊了一下這個需求,請教一下該怎麼用循環來處理。他看了一下,就問我:「你知道 trie 樹嗎?」。我頭一次聽到這個概念,他簡單的給我講了一下,而後說感受處理的問題有些相似,讓我能夠研究一下 trie 樹的原理並試着優化一下。數據庫
講道理, trie 樹這個數據結構網上確實有不少資料,但不多有使用 JavaScript 實現的,不過原理卻是不難。嘗試以後,我就將transObject
的代碼優化成了這樣。(關於 trie 樹,還請讀者本身閱讀相關材料)後端
var transObject = function(tableData, keys) {
let hashTable = {}, res = []
for (let i = 0; i < tableData.length; i++) {
let arr = res, cur = hashTable
for (let j = 0; j < keys.length; j++) {
let key = keys[j], filed = tableData[i][key]
if (!cur[filed]) {
let pusher = {
value: filed
}, tmp
if (j !== (keys.length - 1)) {
tmp = []
pusher.children = tmp
}
cur[filed] = { $$pos: arr.push(pusher) - 1 }
cur = cur[filed]
arr = tmp
} else {
cur = cur[filed]
arr = arr[cur.$$pos].children
}
}
}
return res
}
這樣,解決方案就和 keys 的長短無關了。
這種解決方案正如《三體》裏面使用「二向箔」對宇宙文明進行降維打擊通常乾淨利落!
若是你對「Trie」樹的相關概念不瞭解的話,能夠繼續往下查看進行閱讀學習。
這是以前的寫的一篇舊文,小吳這裏進行了必定的修改和排版
Trie樹
Trie 這個名字取自「retrieval」,檢索,由於 Trie 能夠只用一個前綴即可以在一部字典中找到想要的單詞。
雖然發音與「Tree」一致,但爲了將這種 字典樹 與 普通二叉樹 以示區別,程序員小吳通常讀「Trie」尾部會重讀一聲,能夠理解爲讀「TreeE」。
Trie 樹,也叫「字典樹」。顧名思義,它是一個樹形結構。它是一種專門處理字符串匹配的數據結構,用來解決在一組字符串集合中快速查找某個字符串的問題。
此外 Trie 樹也稱前綴樹(由於某節點的後代存在共同的前綴,好比 pan 是 panda 的前綴)。
它的key都爲字符串,能作到高效查詢和插入,時間複雜度爲 O(k),k 爲字符串長度,缺點是若是大量字符串沒有共同前綴時很耗內存。
它的核心思想就是經過最大限度地減小無謂的字符串比較,使得查詢高效率,即「用空間換時間」,再利用共同前綴來提升查詢效率。
Trie樹的特色
假設有 5 個字符串,它們分別是:code,cook,five,file,fat。如今須要在裏面屢次查找某個字符串是否存在。若是每次查找,都是拿要查找的字符串跟這 5 個字符串依次進行字符串匹配,那效率就比較低,有沒有更高效的方法呢?
若是將這 5 個字符串組織成下圖的結構,從肉眼上掃描過去感官上是否是比查找起來會更加迅速。
Trie樹樣子
經過上圖,能夠發現 Trie樹 的三個特色:
- 根節點不包含字符,除根節點外每個節點都只包含一個字符
- 從根節點到某一節點,路徑上通過的字符鏈接起來,爲該節點對應的字符串
- 每一個節點的全部子節點包含的字符都不相同
經過動畫理解 Trie 樹構造的過程。在構造過程當中的每一步,都至關於往 Trie 樹中插入一個字符串。當全部字符串都插入完成以後,Trie 樹就構造好了。
Trie 樹構造
Trie樹的插入操做
Trie樹的插入操做
Trie樹的插入操做很簡單,其實就是將單詞的每一個字母逐一插入 Trie樹。插入前先看字母對應的節點是否存在,存在則共享該節點,不存在則建立對應的節點。好比要插入新單詞cook
,就有下面幾步:
- 插入第一個字母
c
,發現root
節點下方存在子節點c
,則共享節點c
- 插入第二個字母
o
,發現c
節點下方存在子節點o
,則共享節點o
- 插入第三個字母
o
,發現o
節點下方不存在子節點o
,則建立子節點o
- 插入第三個字母
k
,發現o
節點下方不存在子節點k
,則建立子節點k
- 至此,單詞
cook
中全部字母已被插入 Trie樹 中,而後設置節點k
中的標誌位,標記路徑root->c->o->o->k
這條路徑上全部節點的字符能夠組成一個單詞cook
Trie樹的查詢操做
在 Trie 樹中查找一個字符串的時候,好比查找字符串 code
,能夠將要查找的字符串分割成單個的字符 c,o,d,e,而後從 Trie 樹的根節點開始匹配。如圖所示,綠色的路徑就是在 Trie 樹中匹配的路徑。
code的匹配路徑
若是要查找的是字符串cod
(鱈魚)呢?仍是能夠用上面一樣的方法,從根節點開始,沿着某條路徑來匹配,如圖所示,綠色的路徑,是字符串cod
匹配的路徑。可是,路徑的最後一個節點「d」並非橙色的,並非單詞標誌位,因此cod
字符串不存在。也就是說,cod
是某個字符串的前綴子串,但並不能徹底匹配任何字符串。
cod的匹配路徑
Trie樹的刪除操做
Trie樹的刪除操做與二叉樹的刪除操做有相似的地方,須要考慮刪除的節點所處的位置,這裏分三種狀況進行分析:
刪除整個單詞(好比 hi )
刪除整個單詞
- 從根節點開始查找第一個字符
h
- 找到
h
子節點後,繼續查找h
的下一個子節點i
i
是單詞hi
的標誌位,將該標誌位去掉i
節點是hi
的葉子節點,將其刪除- 刪除後發現
h
節點爲葉子節點,而且不是單詞標誌位,也將其刪除 - 這樣就完成了
hi
單詞的刪除操做
刪除前綴單詞(好比 cod )
刪除前綴單詞
這種方式刪除比較簡單。
只須要將cod
單詞整個字符串查找完後,d
節點由於不是葉子節點,只需將其單詞標誌去掉便可。
刪除分支單詞(好比 cook )
刪除分支單詞
與 刪除整個單詞 狀況相似,區別點在於刪除到 cook
的第一個 o
時,該節點爲非葉子節點,中止刪除,這樣就完成cook
字符串的刪除操做。
Trie樹的應用
事實上 Trie樹 在平常生活中的使用隨處可見,好比這個:
具體來講就是常常用於統計和排序大量的字符串(但不只限於字符串),因此常常被搜索引擎系統用於文本詞頻統計。它的優勢是:最大限度地減小無謂的字符串比較,查詢效率比哈希表高。
1. 前綴匹配
例如:找出一個字符串集合中全部以 五分鐘
開頭的字符串。咱們只須要用全部字符串構造一個 trie樹,而後輸出以 五−>分−>鍾 開頭的路徑上的關鍵字便可。
trie樹前綴匹配經常使用於搜索提示。如當輸入一個網址,能夠自動搜索出可能的選擇。當沒有徹底匹配的搜索結果,能夠返回前綴最類似的可能
google搜索
2. 字符串檢索
給出 N 個單詞組成的熟詞表,以及一篇全用小寫英文書寫的文章,按最先出現的順序寫出全部不在熟詞表中的生詞。
檢索/查詢功能是Trie樹最原始的功能。給定一組字符串,查找某個字符串是否出現過,思路就是從根節點開始一個一個字符進行比較:
- 若是沿路比較,發現不一樣的字符,則表示該字符串在集合中不存在。
- 若是全部的字符所有比較完而且所有相同,還需判斷最後一個節點的標誌位(標記該節點是否表明一個關鍵字)。
Trie樹的侷限性
如前文所講,Trie 的核心思想是空間換時間,利用字符串的公共前綴來下降查詢時間的開銷以達到提升效率的目的。
假設字符的種數有m
個,有若干個長度爲n的字符串構成了一個 Trie樹 ,則每一個節點的出度爲 m
(即每一個節點的可能子節點數量爲m
),Trie樹 的高度爲n
。很明顯咱們浪費了大量的空間來存儲字符,此時Trie樹的最壞空間複雜度爲O(m^n)
。也正因爲每一個節點的出度爲m
,因此咱們可以沿着樹的一個個分支高效的向下逐個字符的查詢,而不是遍歷全部的字符串來查詢,此時Trie樹的最壞時間複雜度爲O(n)
。
這正是空間換時間的體現,也是利用公共前綴下降查詢時間開銷的體現。
LeetCode 第 208 號問題就是 實現 Trie (前綴樹),感興趣的小夥伴能夠去實操一下。
但願今天的這篇文章能幫你們認識到掌握好了數據結構能夠在工做中帶來多大的幫助,你們加油:)