sysprog2018
主講人: jserv / 課程討論區: 2018 年系統軟體課程
Makefile
撰寫trie 與 binary search tree 不同在於,不把整個字串包在同一個 node 中 ,而是根據 node 的位置決定代表的字串為何。也就是一個看結果(最終 node),一個看過程 (經過的 node)。因此利用 trie,可以快速的找出有相同 prefix 的字串。以下將舉例示範 trie 如何儲存 data。
延伸閱讀: 字典樹 Trie
以下逐步建構 trie:
一開始,trie tree 起始於一個空的 root: ◎
◎
先插入一個字串 and
◎
/
a
/
n
/
d
再插入一個字串 ant
◎
/
a
/
n
/ \
d t
隨後插入 bear
bea 會產生像這樣子的 tree
◎
/ \
a b
/ \
n e
/ \ \
d t a (3)
(0) (1) \
r (2)
以上,我們注意到 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
a
|
n
|
d
接著插入單字 ant
a
|
n
|
d-
|
t
接著插入單字 age
a
|
-n
| |
g d-
| |
e t
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
的順序插入
延伸閱讀:
$ git clone https://github.com/sysprog21/dict
$ cd dict
$ make
$ ./test_cpy
ternary_tree, loaded 259112 words in 0.151270 sec
Commands:
a add word to the tree
f find word in tree
s search words matching prefix
d delete word from the tree
q quit, freeing all data
choice:
f
隨後按下 Enter 鍵,會得到 find word in tree:
的提示畫面,輸入 Taiwan
(記得按 Enter),預期會得到以下訊息:find word in tree: Taiwan
found Taiwan in 0.000002 sec.
s
隨後按下 Enter 鍵,會得到 find words matching prefix (at least 1 char):
的提示訊息,輸入 Tain
,預期會得到以下訊息: Tain - searched prefix in 0.000011 sec
suggest[0] : Tain,
suggest[1] : Tainan,
suggest[2] : Taino,
suggest[3] : Tainter
suggest[4] : Taintrux,
T
來當作輸入,可得到世界 9 萬多個城市裡頭,以 T
開頭的有效名稱a
和 d
就由你去探索具體作用當我們要在大量的資料中搜尋一個字串時,最簡單的方式就是走訪每個資料存放單位,這樣時間複雜度則是
而,Bloom Filter 就是利用一個 n bits 的 table,不走訪資料結構就能透過這個 table 預測字串是不是在這個資料結構內。而這樣時間複雜度就變為
這樣的應用其實很常見。例如在社群網站 facebook ,在搜尋欄鍵入名字時,能夠在 20 億個註冊使用者中很快的找到結果,甚至是根據與使用者的關聯度排序。
首先,建立一個 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 ) 的可能。
此外,資料只能夠新增,而不能夠刪除,試想今天有兩個 string x1 , x2經過某個 hash function hi 轉換後的結果 hi(x1)=hi(x2) ,若今天要刪除x1 而把 table 中 set 的 1 改為 0,豈不是連 x2 都受到影響了 ?
首先假設我們的所有字串 set 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 的機率是
其中
由
以上為全部的字串轉完後某個 bit 還沒有被 set 的機率。
因此誤判的機率=全部經由 k 個 hash 轉完後的 k bit 已經被其他人 set 的機率:
轉完後某個 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 的即可。
unsigned int djb2(const void *_str) {
const char *str = _str;
unsigned int hash = 5381;
char c;
while ((c = *str++))
hash = ((hash << 5) + hash) + c;
return hash;
}
unsigned int jenkins(const void *_str) {
const char *key = _str;
unsigned int hash=0;
while (*key) {
hash += *key;
hash += (hash << 10);
hash ^= (hash >> 6);
key++;
}
hash += (hash << 3);
hash ^= (hash >> 11);
hash += (hash << 15);
return hash;
}
再來宣告我們的 Bloom filter 主體,這裡用一個結構體涵蓋以下:
func
是用來把我們的 hash function 做成一個 list 串起來,使用這個方法的用意是方便擴充。若我們今天要加進第三個 hash function,只要串進 list ,後面在實做時就會走訪 list 內的所有 function。bits
是我們的 filter 主體,也就是 table 。 size 則是 table 的大小。typedef unsigned int (*hash_function)(const void *data);
typedef struct bloom_filter *bloom_t;
struct bloom_hash {
hash_function func;
struct bloom_hash *next;
};
struct bloom_filter {
struct bloom_hash *func;
void *bits;
size_t size;
};
主要的程式碼如下。
首先在 bloom_create
的部份,可以看到我們把兩個 hash function 透過bloom_add_hash
加入結構中。
再來 bloom_add
裡面是透過對目標字串 item
套用 hash ,並把 hash 產生的結果 module size (第20行),這樣才會符合 table size 。最後第21行把產生的 bit 設定為 1 。
最後bloom_test
就是去檢驗使用者輸入的 item
是不是在結構當中。與 add大致上都相等,只是把 bit 設定為 1 的部份改為「檢驗是不是 1」,若是任一個 bit 不是 1 ,我們就可以說這個結構裡面沒有這個字串。
這樣的實做,讓我們可以在 bloom_create 初始化完,接下來要加入字串就呼叫 bloom_add ,要檢驗字串就呼叫 bloom_test,不需要其他的操作。
bloom_t bloom_create(size_t size) {
bloom_t res = calloc(1, sizeof(struct bloom_filter));
res->size = size;
res->bits = malloc(size);
// 加入 hash function
bloom_add_hash(res, djb2);
bloom_add_hash(res, jenkins);
return res;
}
void bloom_add(bloom_t filter, const void *item) {
struct bloom_hash *h = filter->func;
uint8_t *bits = filter->bits;
while (h) {
unsigned int hash = h->func(item);
hash %= filter->size;
bits[hash] = 1;
h = h->next;
}
}
bool bloom_test(bloom_t filter, const void *item) {
struct bloom_hash *h = filter->func;
uint8_t *bits = filter->bits;
while (h) {
unsigned int hash = h->func(item);
hash %= filter->size;
if (!(bits[hash])) {
return false;
}
h = h->next;
}
return true;
}
再來是 main
改的部份,這裡修改 test_ref.c
檔案。需要更動的地方是 add 和 find 功能 (delete 會失去效用,注意!)
首先 find 功能修改:
t1 = tvgetf();
// 用 bloom filter 判斷是否在 tree 內
if (bloom_test(bloom, word) == 1) {
// version1: bloom filter 偵測到在裡面就不走下去
t2 = tvgetf();
printf(" Bloomfilter found %s in %.6f sec.\n", word, t2 - t1);
printf(" Probability of false positives:%lf\n", pow(1 - exp(-(double)HashNumber / (double) ((double) TableSize /(double) idx)), HashNumber));
// version2: loom filter 偵測到在裡面,就去走 tree (防止偵測錯誤)
t1 = tvgetf();
res = tst_search(root, word);
t2 = tvgetf();
if (res)
printf(" ----------\n Tree found %s in %.6f sec.\n", (char *) res, t2 - t1);
else
printf(" ----------\n %s not found by tree.\n", word);
} else
printf(" %s not found by bloom filter.\n", word);
break;
由以上可見,第 3 行會先用 bloom filter 去判斷是否在結構內,若不存在的話就不用走訪。如果在的話,程式印出 bloom filter 找到的時間,以及錯誤機率,隨後走訪節點,判斷是否在 tree 裡面。
上述參數的配置為:
cities.txt
數據量為 259112計算出理論錯誤率約為 0.009693
,也就是不到 1%
。
以下是 add 的部份:
if (bloom_test(bloom, Top) == 1) // 已被 Bloom filter 偵測存在,不要走 tree
res = NULL;
else { // 否則就走訪 tree,並加入 bloom filter
bloom_add(bloom,Top);
res = tst_ins_del(&root, &p, INS, REF);
}
t2 = tvgetf();
if (res) {
idx++;
Top += (strlen(Top) + 1);
printf(" %s - inserted in %.10f sec. (%d words in tree)\n",(char *) res, t2 - t1, idx);
} else
printf(" %s - already exists in list.\n", (char *) res);
if (argc > 1 && strcmp(argv[1], "--bench") == 0) // a for auto
goto quit;
break;
以上程式碼可以看到,第 1 行的部份會先偵測要加入的字串是否在結構中,若是回傳 1 表示 bloom filter 偵測在結構中,已經重複了。因此會把 res 設為 null,這樣下方第 9 行的判斷自然就不會成立。如果偵測字串不在結構中,會跑進第 3 行的 else,加入結構還有 Bloom filter 當中。
把 dict 程式碼和給定的資料分用從 CPY 和 REF,用統計的方式重畫,其中 X-axis 是 cycles 數,Y-axis 是到目前為止已累積多少筆相同 cycle 數的資料。
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
有別於我們一般常態分佈的對稱性,不對稱性剛好是卜瓦松分布的特色
Probability of events for a Poisson distribution:
P ( k events in interval ) =
但是為了做圖方便所以將 5974.116 (cycles) 為一個單位時間,這樣
放大版
ideal 為階梯狀的原因是因為 5974.116 cycles 為一個單位,然後公式裏面又有 k 階層,所以在小數的部份會被忽略,但 real 的部份又是 1 個 cycle 為單位,應以 5974.116 cycles 為單位製圖較容易觀察差異
以 5974.116 cycles 為一個單位製圖
這樣就可確定 CPY 比起 Normal distribution 確實符合 Poisson distribution
代入 Poisson 的 95% confidence interval
兩者的 95% 信賴區間
Makefile
以允許 $ make plot
時,視覺化 ternary search tree + bloom filter 的效能表現。注意,需要設計實驗,允許不同的字串輸入並探討 TST 回應的統計分佈