提出問題
從一個我常用的面試題,也是真實需求開始聊起:
你需要在前端展示 5000 條甚至更多的數據,每一條數據的數據結構是一個對象,裏面有格式各樣的屬性。每個屬性的值又可以是基本類型,對象,甚至數組。這裏的對象或者數組內部的元素又可以繼續包含對象或者數組並且允許無限嵌套下去。比如
{
"name": {
"firstName": "yi",
"lastName": "li"
},
"age": 23,
"roles": ['developer', 'admin'],
"projects": [{
"name": "demo",
"repo": ""
}]
}
頁面上提供一個搜索框,用戶通過輸入搜索的內容可以找到包含這個內容的數據。注意,只要任意數據對象的任意屬性值 (比如在上面的數據結構中,只要 name
, age
, roles
任何一個屬性的值)包含這個關鍵詞即可。如果屬性值是數組或者對象,那麼數組的元素或者對象的值繼續對輸入內容進行匹配檢測,並遞歸的檢測下去,只要有命中,便算該數據匹配
如何設計這個功能,讓搜索功能儘可能的快?
解決思路
如果你稍有程序員的敏感度,此時你的腦海裏應該有兩個念頭:
-
遍歷以及深度優先遍歷是最直接的方式
-
如果要求夠快的話遍歷我就輸了
的確,遍歷是最簡單但也是最慢的。所以通常的優化方法之一是通過空間換取時間;而另一個方法……稍後再引出。
這裏我們嘗試通過建立字典樹(Trie)來優化搜索。
如果你還不瞭解什麼是字典樹,下面做簡單的介紹:假設我們有一個簡單的對象,鍵值的對應關係如下:
我們根據「鍵」的字母出現順次構建出一棵樹出來,葉子節點值即有可能是某個「鍵」的值
那麼此時無論用戶想訪問任何屬性的值,只要從樹的根節點出發,依據屬性字母出現的順序訪問樹的葉子節點,即可得到該屬性的值。比如當我們想訪問tea
時:
但是在我們需要解決的場景中,我們不需要關心「屬性」,我們只關心「值」是否匹配上搜索的內容。所以我們只需要對「值」建立字典樹。
假設有以下的對象值
const o = {
message: 'ack'
fruit: 'apple',
unit: 'an',
name: 'anna',
}
建立的樹狀結構如下:
root--a
|--c
|--k
|--p
|--p
|--l
|--e
|--n
|--n
|--a
當用戶搜索 apple
時,從a
開始訪問,至最後訪問到字母 e
時,若在樹中有對應的節點,表示命中;當用戶搜索 aha
時,在訪問 h
時就已經無法在樹中找到對應的節點了,表示該對象不符合搜索條件
但實際工作中我們會有非常多個對象值,多個對象值之間可能有重複的值,所以匹配時,我們要把所有可能的匹配結果都返回。比如
[
{
id: 1,
message: 'ack'
fruit: 'apple',
unit: 'an',
name: 'anna',
},
{
id: 2,
message: 'ack'
fruit: 'banana',
unit: 'an',
name: 'lee',
},
]
上面兩個對象有相同的值 ack
和 an
,所以在樹上的葉子節點中我們還要添加對象的 id 辨識信息
root--a
|--c
|--k (ids: [1,2])
|--p
|--p
|--l
|--e (ids: [1])
|--n (ids: [1, 2])
|--n
|--a (ids: [1])
這樣當用戶搜索 an
時,我們能返回所有的匹配項
OK,有了思路之後我們開始實現代碼。
代碼實現
假數據
首先要解決的一個問題是如果快速的僞造 5000 條數據?這裏我們使用 https://randomuser.me/api/ 開源 API。爲了簡單起見,我們讓它只返回 gender
, email
, phone
, cell
, nat
基本數據類型的值,而不返回嵌套結構(對象和數組)。注意這裏只是爲了便於代碼展示和理解,略去了複雜的結構,也就避免了複雜的代碼。加入複雜結構之後代碼其實也沒有大的變化,只是增加了遍歷的邏輯和遞歸邏輯而已。
請求 https://randomuser.me/api/?results=5000&inc=gender,email,phone,cell,nat 結果如下:
{
"results": [
{
"gender": "male",
"email": "[email protected]",
"phone": "02-65-13-26-00",
"cell": "06-09-02-19-99",
"nat": "FR"
},
{
"gender": "male",
"email": "[email protected]",
"phone": "011-376-3811",
"cell": "081-697-1414",
"nat": "IE"
}
//...
]
}
葉子節點數據結構
根據思路中的描述,數據結構描述如下:
class Leaf {
constructor(id = "", value = "") {
this.ids = id ? [id] : [];
this.value = value;
this.children = {};
}
share(id) {
this.ids.push(id);
}
}
share
方法用於向該葉子節點添加多個相同的匹配的id
幫助函數
在編碼的過程中我們需要一些幫助函數,比如:
-
isEmptyObject
: 判斷是否是空對象 -
distinct
: 移除一個數組中的重複元素
這兩個函數可以借用lodash
類庫實現,即使手動實現起來也很簡單,這裏就不贅述了
另一個重要的方法是normalize
,我更習慣將normalize
翻譯爲「扁平化」(而不是「標準化」),因爲這樣更形象。該方法用於將一個數組裏的對象拆分爲 id 與對象的映射關係。
比如將
[
{
id: 1,
message: 'ack'
fruit: 'apple',
unit: 'an',
name: 'anna',
},
{
id: 2,
message: 'ack'
fruit: 'banana',
unit: 'an',
name: 'lee',
},
]
扁平化之後爲
{
'1': {
id: 1,
message: 'ack'
fruit: 'apple',
unit: 'an',
name: 'anna',
},
'2': {
id: 2,
message: 'ack'
fruit: 'banana',
unit: 'an',
name: 'lee',
}
}
之所以要這麼做是爲了當檢索結果返回一個 id 數組時:[1, 2, 3]
,我們只需要遍歷一邊返回結果就能通過 id 在扁平化的 Map
裏立即找到對應的數據。否則還要不停的遍歷原始數據數組找到對應的數據.
因爲 https://randomuser.me/ 返回的信息中不包含 id 信息,所以我們暫時用 email 信息作爲唯一標示。normalize
的實現如下:
function normalize(identify, data) {
const id2Value = {};
data.forEach(item => {
const idValue = item[identify];
id2Value[idValue] = item;
});
return id2Value;
}
構建一棵樹
這部分代碼就沒有什麼祕密了,完全是按照遞算法歸構建一顆樹了
fetch("https://randomuser.me/api/?results=5000&inc=gender,email,phone,cell,nat")
.then(response => {
return response.json();
})
.then(data => {
const { results } = data;
const root = new Leaf();
const identifyKey = "email";
results.forEach(item => {
const identifyValue = item[identifyKey];
Object.values(item).forEach(itemValue => {
// 注意這裏會把 Number 和 Boolean 類型也字符串化
const stringifiedValue = String(itemValue);
let tempRoot = root;
const arraiedStringifiedValue = Array.from(stringifiedValue);
arraiedStringifiedValue.forEach((character, characterIndex) => {
const reachEnd =
characterIndex === arraiedStringifiedValue.length - 1;
if (!tempRoot.children[character]) {
tempRoot.children[character] = new Leaf(
reachEnd ? identifyValue : "",
character
);
tempRoot = tempRoot.children[character];
} else {
if (reachEnd) {
tempRoot.children[character].share(identifyValue);
}
tempRoot = tempRoot.children[character];
}
});
});
});
模糊搜索
搜索部分代碼也沒有什麼祕密,按圖索驥而已:
function searchBlurry(root, keyword, userMap) {
const keywordArr = Array.from(String(keyword));
let tempRoot = root;
let result = [];
for (let i = 0; i < keywordArr.length; i++) {
const character = keywordArr[i];
if (!tempRoot.children[character]) {
break;
} else {
tempRoot = tempRoot.children[character];
}
if (keywordArr.length - 1 === i) {
result = [
...tempRoot.ids,
...collectChildrenInsideIds(tempRoot.children)
];
}
}
return distinct(result).map(id => {
return userMap[id];
});
}
注意這裏有一個collectChildrenInsideIds
方法,這個方法用於收集該葉子節點下所有的子節點的 id。這麼做是因爲當前操作模糊匹配,當你搜索a
時,apple
, anna
, ack
都算匹配。
常規搜索辦法以及字典樹的缺陷
爲了對比效率,並且爲了測試搜索結果的正確性,我們仍然需要編寫一個常規的遍歷的搜索方法:
function regularSearch(searchKeyword) {
const regularSearchResults = [];
results.forEach(item => {
for (const key in item) {
const value = item[key];
if (String(value).startsWith(searchKeyword)) {
regularSearchResults.push(item);
break;
}
}
});
return regularSearchResults
}
注意在測試對象值是否匹配搜索詞時,我們使用了startsWith
,而不是indexOf
,**這是因爲字典樹的缺陷在於只能匹配以搜索詞開頭的詞!**比如當你搜索a
時,只能匹配apple
、anna
而不能匹配banana
。爲了便於對比,我們不得不使用startsWith
性能的對比
性能的對比結果是很有意思的:
-
當數據量較小時,查找效率不會有大的差異
-
當數據量較大時,比如 5000 條的情況下,當你的搜索詞非常短小,比如
a
,那麼字典樹的查找效率會比遍歷搜索低,也就是反而花費的時間長;當搜索詞變得具體時,比如ali
,字典樹的查找效率會比遍歷搜索高
效率反而低的問題不難想到是爲什麼:當你搜索詞簡單時,訪問的葉子節點會少,所以只能掃描children
收集子節點的所有的可能 id,這步操作中遍歷的過程佔用了大部分時間
但是我們仍然需要滿足這部分的查詢需求,所以我們要針對這個場景做一些優化
優化簡短搜索的場景
我們回想一下簡單搜索的場景,性能的瓶頸主要在於我們需要遍歷葉子節點下的所有子節點。好辦,鑑於樹構建完之後不會再發生變化,那麼我們只需要提前計算好每個葉子節點的所以子 id 就好了,這就是文章開頭說的第二類優化方案,即預計算。
我編寫了一個新的方法,用於遞歸的給每個葉子節點添加它所有子節點的 id:
function decorateWithChildrenIds(root) {
const { children } = root;
root.childrenIds = collectChildrenInsideIds(root.children);
for (const character in children) {
const characterLeaf = children[character];
characterLeaf.childrenIds = collectChildrenInsideIds(
characterLeaf.children
);
decorateWithChildrenIds(characterLeaf);
}
}
那麼在構建完樹之後,用這個方法把所有葉子節點「裝飾」一遍就好了
結論
在通過預計算之後,在 5000 條數據的情況下,無論是短搜索還是長搜索,字典樹的查找效率基本是在 1ms 左右,而常規的遍歷查找則處於 10ms 左右,的確是十倍的提升。但是這個提升的代價是建立在犧牲空間,以及提前花費了時間計算的情況下。相信如果數據結構變得更復雜,效率提升會更明顯
本文源代碼的地址是 https://github.com/hh54188/search-trie-tree
最後留下一個問題給大家:當需要搜尋的數據量變大時,比如 1000 時,偶爾會出現字典樹搜索結果和遍歷搜索結果不一致的情況,而當數據量變得更大時,比如 5000 條,那麼這個「問題」會穩定出現。這個問題算不上 bug,但是問題出在哪呢 ?