Google Translate 一類的線上辭典不僅能夠快速查詢詞彙,還有自動補完和提示相近詞彙,你知道背後的資料結構及數學原理嗎?
Binary Search Tree (BST) 查詢與建立搜尋表的時間複雜度是 O(log2n),但是在最糟的情況下時間複雜度可能轉為 O(n),除了要儲存的字串外不須另外分配空間。
Trie (亦稱 prefix-tree 或字典樹) 的時間複雜度是 O(n),能夠實現 binary search tree 難以做到的 prefix-search,缺點是需要保存大量的空指標,空間開銷較大;
Ternary Search Tree 是種 trie 資料結構,結合 trie 的時間效率和 BST 的空間效率優點。
* 每個節點 (node) 最多有三個子分支,以及一個 Key;
* Key 用來記錄 Keys 中的其中一個字元 (包括 EndOfString);
* low: 用以指向比目前節點的 Key 值來得小的節點;
* equal: 用來指向與目前節點的 Key 值一樣大的節點;
* high: 用來指向比目前節點的 Key 值來得大的節點;
Ternary Search Tree 的應用:
對於 Web 應用程式來說,自動完成的繁重處理工作絕大部分要交給伺服器。許多時候,自動完成不適宜一下子全都下載到客戶端,TST 是保存在伺服器上,客戶端把使用者已輸入的開頭詞彙提交到伺服器進行查詢,然後伺服器依據 TST 獲取相對應的資料列表,最後把候選的資料列表返回給客戶端。
ab
, abs
, absolute
等字串逐次輸入並觀察trie 與 binary search tree 不同在於,不把整個字串包在同一個節點中 ,而是根據節點的位置決定代表的字串為何。也就是一個看結果(最終節點),一個看過程 (經過的節點)。因此利用 trie 可快速找出有相同 prefix 的字串。以下舉例示範 trie 如何儲存資料。
延伸閱讀: 字典樹 Trie
以下逐步建構 trie:
一開始,trie tree 起始於一個空的 root: ◎
先插入一個字串 and
再插入一個字串 ant
隨後插入 bear
bea 會產生像這樣子的 tree
以上,我們注意到 trie 中,若有儲存的字串有許多相同的 prefix ,那麼 trie 可以節省大量儲存空間,考慮到 C-style string 的設計,因為 Trie 中每個字串都是經由一個字元接著另一個字元的形式來儲存,所以具有相同 prefix 的部份都會有共同的 node。
相對的,若是資料非常大量,而且幾乎沒有相同的 prefix,將會非常浪費儲存空間。因為 trie 在記憶體的儲存方式其實是像下圖這樣,每個 node 建立時,都必須同時創造 26 個值為 NULL 的子節點。
下圖的 universal set = { a, b, c, d, e}
,所以只創造了 5 個
引入 Ternary search tree (TST),可望解決上述記體開銷的問題。這種樹的每個節點最多只會有 3 個 children,分別代表小於,等於,大於。
以下範例解釋 Ternary search tree 的運作。
插入一個字串 and
與 trie 概念相同,但是在 root 是直接擺 a,而不需要一個 null
接著插入單字 ant
接著插入單字 age
Ternary Search Tree 此網址提供了透過動畫呈現,展示如何加入與刪除給定字串的流程。
我們可發現 TST 幾種特性:
考慮以下兩種插入順序:
aa
ac
ae
af
ag
ah
的順序af
ae
ac
aa
ag
ah
的順序試想,如果我們要尋找 ah
,那麼前者要比較 6 次,後者只要比較 3 次。而若是建立 trie ,只需要 2 次。
用 aa -> ac -> ae -> af -> ag -> ah
的順序插入
用 af -> ae -> ac -> aa -> ag -> ah
的順序插入
延伸閱讀:
f
隨後按下 Enter 鍵,會得到 find word in tree:
的提示畫面,輸入 Taiwan
(記得按 Enter),預期會得到以下訊息:s
隨後按下 Enter 鍵,會得到 find words matching prefix (at least 1 char):
的提示訊息,輸入 Tain
,預期會得到以下訊息:T
來當作輸入,可得到世界 9 萬多個城市裡頭,以 T
開頭的有效名稱a
和 d
就由你去探索具體作用Bloom filter 利用 hash function,在不用走訪全部元素的前提,預測特定字串是否存於資料結構中。因此時間複雜度是 ,而非傳統逐一尋訪的 。
0
到 n - 1
) ,所以有 k 個 hash function ,就該有 k 個 index ,然後將該 table[index]
上的 bit set但這做法存在錯誤率,例如原本沒有在資料結構中的字串 s1 經過 hash 轉換後得出的 bit 的位置和另一個存在於資料結構中的字串 s2 經過 hash 之後的結果相同,如此一來,先前不存在的 s1 便會被認為存在,這就是 false positive (指進行實用測試後,測試結果有機會不能反映出真正的面貌或狀況)。
Bloom filter 一類手法的應用很常見。例如在社群網站 Facebook,在搜尋欄鍵入名字時,能夠在 20 億個註冊使用者 (2017 年統計資料) 中很快的找到結果,甚至是根據與使用者的關聯度排序。
延伸閱讀:
首先,建立一個 n bits 的 table,並將每個 bit 初始化為 0。
我們將所有的字串構成的集合 (set) 表示為 S = { x1, x2, x3, … ,xn },Bloom Filter 會使用 k 個不同的 hash function,每個 hash function 轉換後的範圍都是 0 到 n-1 (為了能夠對應上面建立的 n bits table)。而對每個 S 內的 element xi,都需要經過 k 個 hash function,一一轉換成 k 個 index。轉換過後,table 上的這 k 個 index 的值就必須設定為 1
。
注意: 可能會有同一個 index 被多次設定為
1
的狀況
Bloom Filter 這樣的機制,存在一定的錯誤率。若今天想要找一個字串 x 在不在 S 中,這樣的資料結構只能保證某個 x1 一定不在 S 中,但沒辦法 100% 確定某個 x2一定在 S 中。因為會有誤判 (false positive ) 的可能。
此外,資料只能夠新增,而不能夠刪除,試想今天有兩個字串 x1, x2 經過某個 hash function hi 轉換後的結果 hi(x1) = hi(x2),若今天要刪除 x1 而把 table 中 set 的 1 改為 0,豈不是連 x2 都受到影響?
首先假設我們的所有字串集合 S 裡面有 n 個字串, hash 總共有 k 個,Bloom Filter 的 table 總共 m bits。我們會判斷一個字串存在於 S 內,是看經過轉換後的每個 bits 都被 set 了,我們就會說可能這個字串在 S 內。但試想若是其實這個字串不在 S 內,但是其他的 a b c 等等字串經過轉換後的 index ,剛好涵蓋了我們的目標字串轉換後的 index,就造成了誤判這個字串在 S 內的情況。
如上述,Bloom Filter 存有錯誤機率,程式開發應顧及回報錯誤機率給使用者,以下分析錯誤率。
當我們把 S 內的所有字串,每一個由 k 個 hash 轉換成 index 並把 table[index]
設為 1,而全部轉完後, table 中某個 bits 仍然是 0 的機率是 。
其中 是每次 hash 轉換後,table 的某個 bit 仍然是 0 的機率。因為我們把 hash 轉換後到每個 index (而這個 index 會被 set) 的機率視為相等 (每人 ),所以用 1 減掉即為不會被 set 的機率。我們總共需要做 kn 次 hash,所以就得到答案。
由 特性,
以上為全部的字串轉完後某個 bit 還沒有被 set 的機率。
因此誤判的機率等同於全部經由 k 個 hash 轉完後的 k bit 已經被其他人 set 的機率:
轉完後某個 bit 被 set 機率是: ,因此某 k bit 被 set 機率為:
延伸閱讀:
如何選 k 值?
我們必須讓錯誤率是最小值,因此必須讓 的值是最小。先把原式改寫成 ,我們只要使 最小,原式就是最小值。可以看出當 時,會達到最小值。因此 即能達到最小值。
Bloom Filter calculator 和 Bloom Filter calculator 2 這兩個網站可以透過設定自己要的錯誤率與資料的多寡,得到 k
和 m
值。
參考的字典檔案 裡面總共有 36922 個詞彙。
經由上述運算得知,當我們容許 5% 錯誤 (=0.05) 時:
先用原來的 cities.txt
檔案做測試,以驗證估程式。
首先,這裡選用了兩種 hash function:jenkins
和djb2
,另外 murmur3 hash 也是個不錯的選擇,這裡主要是選用夠 uniform 的即可。
再來宣告我們的 Bloom filter 主體,這裡用一個結構體涵蓋以下:
func
是用來把我們的 hash function 做成一個 list 串起來,使用這個方法的用意是方便擴充。若我們今天要加進第三個 hash function,只要串進 list ,後面在實做時就會走訪 list 內的所有 function。bits
是我們的 filter 主體,也就是 table 。 size 則是 table 的大小。主要的程式碼如下。
首先在 bloom_create
的部份,可以看到我們把兩個 hash function 透過bloom_add_hash
加入結構中。
再來 bloom_add
裡面是透過對目標字串 item
套用 hash ,並把 hash 產生的結果對 filter->size
取餘數 (第 19 行),這樣才會符合 table size 。最後第 19 行把產生的 bit 設定為 1 。
最後bloom_test
就是去檢驗使用者輸入的 item
是不是在結構當中。與 add大致上都相等,只是把 bit 設定為 1 的部份改為「檢驗是不是 1」,若是任一個 bit 不是 1 ,我們就可以說這個結構裡面沒有這個字串。
這樣的實做,讓我們可以在 bloom_create 初始化完,接下來要加入字串就呼叫 bloom_add ,要檢驗字串就呼叫 bloom_test,不需要其他的操作。
再來是 main
改的部份,這裡修改 test_ref.c
檔案。需要更動的地方是 add 和 find 功能 (delete 會失去效用,注意!)
首先 find 功能修改:
由以上可見,第 3 行會先用 bloom filter 去判斷是否在結構內,若不存在的話就不用走訪。如果在的話,程式印出 bloom filter 找到的時間,以及錯誤機率,隨後走訪節點,判斷是否在 tree 裡面。
上述參數的配置為:
cities.txt
數據量為 259112計算出理論錯誤率約為 0.009693
,也就是不到 1%
。
以下是 add 的部份:
以上程式碼可以看到,第 1 行的部份會先偵測要加入的字串是否在結構中,若是回傳 1 表示 bloom filter 偵測在結構中,已經重複了。因此會把 res 設為 null,這樣下方第 9 行的判斷自然就不會成立。如果偵測字串不在結構中,會跑進第 3 行的 else,加入結構還有 Bloom filter 當中。
資料結構:
tst_ins_del
實作:
乍看之下一頭霧水,為何有時將 eqkid
傳出去當 word 起始位置,有時又用它來取得下個 node,有時將傳進來的 const char *s
轉型成 tst_node *
呢?其實從 tst_suggest
可以看出些端倪:
若 key
非 0(key
爲 0 的 node 的存在是爲了表示 parent node 的結尾),則將 eqkid
當 node 指標進行 recursive call,否則當作字串使用。
這裡需要記錄字串是因爲走訪到 node 之後要得知該 node 代表的字串是有點麻煩的事情(若有記錄 parent,可以往上透過一些規則找回),但這裡很巧妙的利用了 eqkid
當作 char *
指向該 node 表示的字串。
關於程式碼的 copy (CPY
) 與 reference (REF
) 機制,前者是傳入 tst_ins_del
的字串地址所表示的值隨時會被覆寫,因此 eqkid
需要 malloc
新空間將其 copy 存起來,而後者是傳進來的字串地址所表示的值一直屬於該字串,以此可將其當作 reference 直接存起來,不用 malloc
新空間。
承接上面,eqkid
分飾多角,沒有需要重複使用的時候嗎?
Insert XY | Insert XYZ | Insert XY and XYZ |
---|---|---|
如上面第三種情況,node Z 的 eqkid
該指向 "XY"
表示該字串結束,還是指向下一個 key
爲 0 的 node 表示 "XYZ"
的結束呢?
再仔細觀察及思考會發現 node Z 的 key 爲 'z'
,已無法表示字串 "XY"
的結束,tst_node
中也沒有類似綠色的屬性,所以 tst.c 的實作實際上是這樣的:
Insert XY and XYZ in order | Insert XYZ and XY in order |
---|---|
注意:上圖(兩張都)請將 N 當作小綠空圓(表示字串
"XY"
的結束)並無視它下面的小綠空圓,而左圖>N
爲>0
可見用來表示結束的 node 永遠不會有 ternary equal child, 因此 eqkid
就不會分身乏術。
dict 程式碼中的 copy 與 reference 機制:
CPY
): 傳入 tst_ins_del
的字串地址所表示的值隨時會被覆寫,因此 eqkid
需要 malloc
新空間將其 copy 存起來;REF
): 傳進來的字串地址所表示的值一直屬於該字串,以此可將其當作 reference 直接存起來,不用透過 malloc
新空間。把 dict 程式碼和給定的資料分用從 CPY 和 REF,用統計的方式重畫,其中 X-axis 是 cycles 數,Y-axis 是到目前為止已累積多少筆相同 cycle 數的資料。
轉換圖表 X-Y 的數據後我們可以很明顯的看出 REF 資料密集程度遠大於 CPY ,不只在 count 累積的數目,也在 cycles 的 range 有很大的差異
REF_PDF ideal model (理想情況):
REF_PDF real model (真實情況):
顯然真實情況偏離理想狀況,但我們可將出現機率很小且不符合 95% 信賴區間的數值拿掉不考量。
The fundamental trait of the Poisson is its asymmetry, and this trait it preserves at any value of
有別於我們一般常態分佈的對稱性,不對稱性剛好是 Poisson 分布的特色
x 軸代的 k 是 indexm, y 軸則是代入 k 後的機率
代 : 這邊的意思拿 CPY 來說就是平均每 5974.116 個 cycle 可以完成一次搜尋。但為了做圖方便所以將 5974.116 (cycles) 為一個單位時間,這樣 代 1
放大版本
ideal 為階梯狀的原因是 5974.116 cycles 為一個單位,然後公式裏面又有 k 階層,所以在小數的部份會被忽略,但 real 的部份又是 1 個 cycle 為單位,應以 5974.116 cycles 為單位製圖較容易觀察差異。
以 5974.116 cycles 為一個單位製圖
參考 使用 perf_events 分析程式效能 和 簡介 perf_events 與 Call Graph 這兩份中文介紹。
在 Makefile
新增 perf-search
標的:
執行 make perf-search
明明搜索過程一致,但兩者的 cache-misses 次數卻相差甚遠。
仔細思考兩者的差異,其實只有 ternimal node 的 eqkid
所指向的字串地址來源不同,CPY 機制是單獨 malloc
的小空間,REF 機制是 malloc
大空間的一小部分。所以過程中的差異是,僅最後對字串地址進行 dereference,到對應的地址讀出字串的地方。
嘗試刪掉最後的 dereference 相關的部分(原本用來檢查是否找到符合的 prefix,刪掉會導致結果不正確,但這裡的重點是尋找 REF 相對耗時的地方:
重新用 perf 分析:
也就是說,造成 REF 相對慢的原因,與 *(((char *) p->eqkid) + nchr - 1)
操作有關。
進一步用 perf 分析每個 intruction 發生 cache miss 的次數:
由於測試的是 prefix-search,時間都花在 tst_suggest
上不意外,先附上 tst_suggest
前半段中每一行程式碼對應的組語指令
儘管上方為 x86_64 組合語言,但我們只需要理解 mov
時 0x8(%rax)
會對 %rax + 0x8
這地址做 dereference,而 (%rax)
可理解爲 0x0(%rax)
的縮寫。
另外還需知道 x86 General registers 。
進一步比較兩種機制的差異,左邊的數值是 cache miss 發生在該指令的 period。
test_ref
2 處熱點:
和
test_cpy
1 處熱點
爲什麼第一處是發生在 mov -0x10(%rbp),%r8d
(把 max
的值給 %r8d
register),原來 perf 在回報時會有偏差,於是這裡看似實際發生 cache miss 是在上一個 instruction:
mov 0x8(%rax),%rax
即對 p 做 dereference(這裡是 function 裡的第一次,之後這 function 再對 p
做 dereference 時就很大機率 cache hit)
p->lokid
=>(*p).lokid
其實以編譯器的角度(也是實際過程)應該是
*(tst_node*)((char*)p + offsetof(tst_node, lokid))
這裡的 0x8
即是所謂的 offsetof(tst_node, lokid)
(在編譯時期可算出,對應指令中的 0x8
),rax
在這裡是 p
。
第二處便是之前提到的字串 dereference
*(((char *) p->eqkid) + nchr - 1)
這裡一樣有回報偏差,實際上也是上一道指令 movzbl (%rax),%eax
),原先推測 REF 機制在這裡發生 cache miss 的機率只是比 CPY 機制稍高,沒想到竟然高了十幾倍!應該說 CPY 機制在這裡完全沒有特別容易 cache miss 的傾向。
把地址印出觀察:
測試:
回頭看 malloc 的描述:
NOTES
Normally, malloc() allocates memory from the heap, and adjusts the size of the heap as required, using sbrk(2). When allocating blocks of memory larger than MMAP_THRESHOLD bytes, the glibc malloc() implementation allocates the memory as a private anonymous mapping using mmap(2). MMAP_THRESHOLD is 128 kB by default, but is adjustable using mallopt(3). Prior to Linux 4.7 allocations performed using mmap(2) were unaffected by the RLIMIT_DATA resource limit; since Linux 4.7, this limit is also enforced for allocations performed using mmap(2).
原來在 GNU/Linux 中,malloc
面對大於 MMAP_THRESHOLD
bytes 的空間時,會呼叫 mmap
,而 mmap
會(在 heap 和 stack 之間)建立一個 vm_area_struct
,將檔案映射到虛擬記憶體空間。
Standard segment layout in a Linux process. (32-bit)
但這樣仍無法解釋實驗結果的差異,再對程式進行以下觀察:
測試:
終於找到 CPY 機制在 prefix-search 時效率比較好的原因
回想 tst.c 的 tst_ins_del
:
CPY 機制在 insert 時,建立 leaf 後會間接呼叫 malloc
來保存字串,在這之前的最後一次 malloc
是爲了配置該 leaf 的空間。一直 malloc
時,heap 也會形同 stack 一般地增長,所以像給定測試條件很規律的狀況下,字串地址與 leaf 的地址很可能都很靠近。
換言之,CPY 機制在一開始 mov 0x8(%rax),%rax
(p->eqkid
)後,字串就很大可能在 cache 裡面了,因此 cache miss 機率較低,這就是它相對來說在 prefix-search 上效率比較好的原因!
CPY 機制與 REF 機制在建 tree 的效率比較
word
strdup
將地名存起來malloc
,可能涉及大量系統呼叫