主講人: jserv / 課程討論區: 2020 年系統軟體課程
返回「進階電腦系統理論與實作」課程進度表Image Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
Makefile
撰寫Binary Search Tree (BST) 查詢與建立搜尋表的時間複雜度是 O(log2n),但是在最糟的情況下時間複雜度可能轉為 O(n),除了要儲存的字串外不須另外分配空間。
Trie (亦稱 prefix-tree 或字典樹) 的時間複雜度是 O(n),能夠實現 binary search tree 難以做到的 prefix-search,缺點是需要保存大量的空指標,空間開銷較大;
Ternary Search Tree 是種 trie 資料結構,結合 trie 的時間效率和 BST 的空間效率優點。
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 個
Trie 能夠實現 prefix search 但會有大量空指標
引入 Ternary search tree (TST),可望解決上述記體開銷的問題。這種樹的每個節點最多只會有 3 個 children,分別代表小於,等於,大於。在 dict 內建的 TST 實作中的 tst.c
可看到資料結構如下:
假設目前搜尋到的 prefix = x 被搜尋的 node key = cur 則
x == cur
x > cur
x < cur
而其中 key
為 0 時,代表 struct tst_node *eqkid
,不是指向下個 node,而是該路徑所代表的字串。
以下範例解釋 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
就由你去探索具體作用上述的 ./test_common CPY
表示 CPY
也就是 COPY 機制,與其相對的是 REF
,即 reference 機制,對應的命令列為 ./test_common REF
。
程式碼中的 copy 與 reference 機制:
CPY
): 傳入 tst_ins_del
的字串地址所表示的值隨時會被覆寫,因此 eqkid
需要 malloc
新空間將其 copy 存起來;REF
): 傳進來的字串地址所表示的值一直屬於該字串,以此可將其當作 reference 直接存起來,不用透過 malloc
新空間。執行以下命令,比較 copy 與 reference 機制的效率:
參考輸出:
預設動作是 s Tai
(搜尋 Tai
開頭的字串),可改為搜尋 T
開頭的字串,如下:
請留意以上機制的行為,後續我們會探討效能表現。
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
值。
以 hash function的數量、table size 的大小做為橫軸縱軸,以錯誤率做為深淺。而畫面中紫色的線為 ,其中93827 為字典中 item 數量。
我們想要最小的空間及執行速度,並符合一定的錯誤率,於是期望的數值在可行域的左下角。
為了讓圖片不明顯的部分,能被看得清楚,將圖片轉為灰階後做直方圖均衡化
把此圖視為高線圖,可以發現山谷都在 上,而在這裡實際的意義就是錯誤率最小值發生在 與推導的結果相符。透過開四次方根把錯誤率之間的差距加大,再製圖,可更明顯看出上述的內容。
參考的字典檔案 裡面總共有 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_common.c
檔案。需要更動的地方是 add 和 find 功能。
首先 find 功能修改:
由以上可見,第 3 行會先用 bloom filter 去判斷是否在結構內,若不存在的話就不用走訪。如果在的話,程式印出 bloom filter 找到的時間,以及錯誤機率,隨後走訪節點,判斷是否在 tree 裡面。
上述參數的配置為:
cities.txt
資料量為 206849計算出理論錯誤率約為 0.009693
,也就是不到 1%
。
以下是 add 的部份:
以上程式碼可以看到,第 1 行的部份會先偵測要加入的字串是否在結構中,若是回傳 1 表示 bloom filter 偵測在結構中,已經重複了。因此會把 res 設為 null,這樣下方第 9 行的判斷自然就不會成立。如果偵測字串不在結構中,會跑進第 3 行的 else,加入結構還有 Bloom filter 當中。
以
cities.txt
作為輸入搜尋
從結果中可以發現:
原本整個 table 都把 byte 當 bit 來用,浪費 的 table 空間
做以下改善:
為了改善空間浪費,在 bloom_create
內做以上更改
res->bits = calloc((size + 7) >> 3, 1)
先對齊 1-byte (round up) 再 shift 3 bits
(size + 7) & -8
因為會 shift 掉res->bits = calloc((size + 7) >> 3, 1)
改成 calloc
因為要確保原本 memory content 為 0在以上二個函式中,因為 table 的儲存已經改成 bit-level 所以
bits[hash >> 3]
先找到存放位置在第幾個 byte0x40 >> (hash & 7)
再找到要放在第幾個 bit
1 << (hash & 7)
,只是前者邏輯上較直觀資料結構:
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
就不會分身乏術。
檔案 cities.txt
提供超過 9 萬筆城市和所屬的國家/地區。城市名後面跟着逗號 (,
),由於國情和文化落差,我們務必要留意到這些城市的命名行為及特例。我們可運用 regular expression 來分析。
,(.*)?,(.*)?,(.*)?,
: 有 4 個逗號的資料,共有 1 個結果
命令:
$ egrep ",(.*)?,(.*)?,(.*)?," cities.txt
Villa Presidente Frei, Ñuñoa, Santiago, Chile, Chile
, 依序為 建築物名稱, 城鎮名, 城市名, 國名
。其中城市名的部份為 Santiago, Chile
,後者為 "Santiago de Chile" 的縮寫,(.*)?,(.*)?,
: 有 3 個逗號的資料,共有 3 個結果
命令:
$ egrep ",(.*)?,(.*)?," cities.txt
Oliver-Valdefierro, Oliver, Valdefierro, Spain
, 依序為 城市名, 區名, 區名, 國名
。 其中 Oliver 和 Valdefierro 是兩個相同層級的行政區Earth, TX, Texas, United States
, 依序為 城市名, 州名縮寫, 州名, 國名
。這是例外,因為除此之外的所有美國的地名都是遵循 城市名, 州名, 國名
的格式。,(.*)?,
: 有 2 個逗號的資料,共有 19191 個結果
命令:
$ egrep ",(.*)?," cities.txt
城市名, 國名
為了解析的便利,我們可將上述有三個以上逗號的資料取代如下:
Villa Presidente Frei, Ñuñoa, Santiago, Chile, Chile
Ñuñoa, Chile
城市名, 國名
的格式,而且 Santiago, Chile
已存在Oliver-Valdefierro, Oliver, Valdefierro, Spain
Oliver-Valdefierro, Spain
城市名, 國名
的格式Earth, TX, Texas, United States
-> Earth, Texas, United States
城市名, 州名, 國名
的格式還有個例外是 Xeraco,Jaraco, Spain
,該城市名的逗號後方沒有空白鍵(僅此一筆例外)。這因 Xeraco 和 Jaraco 實際指同一個地方,只是拼法不同,因此保留西班牙本地常用的 Xeraco 即可。
如此一來,這份資料就只剩下「有一個逗號的資料」跟「有兩個逗號的資料」了,在我們假設「有兩個逗號的資料」都是城市名, 州名/省名, 國名
的情況下,我們可以用以下程式碼讀取輸入(此方法會將逗號和換行符都排除):
Perf 全名是 Performance Event,是在 Linux 2.6.31 以後內建的系統效能分析工具,它隨著核心一併釋出。藉由 perf,應用程式可以利用 PMU (Performance Monitoring Unit), tracepoint 和核心內部的特殊計數器 (counter) 來進行統計,另外還能同時分析運行中的核心程式碼,從而更全面了解應用程式中的效能瓶頸。
參考 使用 perf_events 分析程式效能 和 簡介 perf_events 與 Call Graph 這兩份中文介紹,事先安裝好 perf
並熟悉其使用方式。
CPY 機制在 prefix-search 時效率較好的原因,可見 tst.c
的 tst_ins_del
:
CPY 機制在 insert 時,建立 leaf 後會間接呼叫 malloc
來保存字串,在這之前的最後一次 malloc
是爲了配置該 leaf 的空間。一直 malloc
時,heap 也會形同 stack 一般地增長,所以像給定測試條件很規律的狀況下,字串地址與 leaf 的地址很可能都很靠近。
換言之,CPY 機制在一開始p->eqkid
後,字串就很大可能在 cache 裡面了,因此 cache miss 機率較低,這就是它相對來說在 prefix-search 上效率比較好的原因。
CPY 機制與 REF 機制在建 tree 的效率比較
word
strdup
將地名存起來malloc
,可能涉及大量系統呼叫tst_ins_del()
當中頻繁以 calloc() 配置節點空間造成的效能衝擊,應許以改進。