# prefix-search contributed by < `e94046165` > ## 系統環境 ``` Architecture: x86_64 CPU 作業模式: 32-bit, 64-bit Byte Order: Little Endian CPU(s): 8 On-line CPU(s) list: 0-7 每核心執行緒數:2 每通訊端核心數:4 Socket(s): 1 NUMA 節點: 1 供應商識別號: GenuineIntel CPU 家族: 6child nodes 型號: 60 Model name: Intel(R) Core(TM) i7-4720HQ CPU @ 2.60GHz 製程: 3 CPU MHz: 2594.026 CPU max MHz: 3600.0000 CPU min MHz: 800.0000 BogoMIPS: 5188.05 虛擬: VT-x L1d 快取: 32K L1i 快取: 32K L2 快取: 256K L3 快取: 6144K NUMA node0 CPU(s): 0-7 ``` ## Trie(字典樹) 在實作 Prefix-search 的時候,當然不能不提到 trie 這樣特別的樹狀資料結構。以下為 trie 的簡介: 字典樹最大的特性為"利用單字的共同前綴(common prefix)作為儲存依據",用來減少佔用的記憶體與加快搜尋的效能。共同前綴說穿了就是數個單字"前面幾個相同的字母",像是 CUT 與 CUTE 就有 CUT 這個共同前綴。 舉例而言,若依序輸入 CAT、CUT、CUTE、TO、B 五筆資料,則會建立如下圖的字典樹。 ![](https://i.imgur.com/hUc4Ori.png) (圖片來源自:https://goo.gl/vyvzAG) 而 Trie 建立的規則如下: 1. 所有 nodes 皆接在 root 這個不包含任何字母的節點底下。 2. 除了 root 以外,其他 nodes 皆代表一個字母。 3. 每個 node 最多可能有 26 個子節點(若universal set 為所有不分大小寫的字母)。 4. 每個 children 皆為字串可能的下一個字母,在上圖中,C 節點後有 A 與 U 兩個可能的字母。 5. 整棵樹的高度為 "最長字串 + 1" 6. "並非"只有葉子代表一個字串的結尾,從 root 向下走訪的每個節點,皆可能是某個字串的結尾。如上圖中的 CUT。 建立樹的步驟(以上面的字典樹為例): ``` 1. 一開始,建立一個 value = NULL 的節點 root; root 2. 欲插入字串為 CAT,將 CAT 拆成 C、A、T; 3. root 沒有 value == C 的 children, 建立一個 children,value = C;移動到 C; root / C 4. 因 C 沒有 value == A 的 children, 建立一個 children,value = A;移動到 A; root / C / A 5. 因 A 沒有 value == T 的 children, 建立一個 children,value = T;移動到 T; root / C / A / T 6. 欲插入字串為 CUT,將 CUT 拆成 C、U、T; 7. 在 root 找到 children C,移動到 C; 8. 因 C 沒有 value == U 的 children, 建立一個 children,value = U;移動到 U; root / C / \ A U / T 9. 因 U 沒有 value == T 的 children, 建立一個 children,value = T;移動到 T; root / C / \ A U / \ T T . . . ``` **優點:** trie 的優點顯而易見,即是運作速度快且容易撰寫,針對搜尋目標字串是否存在與前綴相關的議題有不錯的效果。 **缺點:** 因為每個 node 皆可能有最大分支度的 child nodes 數,因此在 node struct 宣告時必須宣告 N(最大分支度) 個指標。在多數情況下,這些指標不會被充份利用,更可能有許多空指標,勢必造成巨大的記憶體成本。 ## Ternary search tree (TST) Ternary search tree 可以解決 trie 大量無用空指標的缺點,因為每個 TST 中的節點皆只有三個子節點。分別用來儲存小於、等於、大於父節點的子節點。 部份參考自[rex662624](https://hackmd.io/K4v3wF-8SFudCT2-ckK9Hg?both) 與上例相同,依序輸入 CAT、CUT、CUTE、TO、B 五筆資料,則會建立如下圖的字典樹。 ``` C C C C C | | | | \ / | \ A ==> A ==> A ==> A T ==>B A T | |\ |\ |\ | |\ | T T U T U T U O T U O | | | | T T T T | | | E E E ``` [ternary search tree](https://www.cs.usfca.edu/~galles/visualization/TST.html) 提供視覺化的種樹(?)過程,不想手畫的話可以用。 TST 與 trie 的差異在於: 1. 移除空的 root 2. 當輸入的字串與已有的前綴分歧時,依小於、等於、大於,插入到該節點之下。 如上圖所示,當字典樹只有 CAT 時,插入 CUTE 之動作如下 比對第一個字 C 與 root(節點C)值相同(C == C),向下移動到 A ==> U > A,且 A 的右(大於)子節點為空,令 A 的 right child = U ==> 接著在 U 底下接入 T、E 完成插入 與 trie 相比省下不少維護子節點指標的空間,但樹的高度隨之增加,代表 search 時須花費更多時間。 此外,TST 還有一個隱含的問題: **樹的形狀受插入順序影響** 若插入順序為 CAT、CUT、CUTE、TO、B 則建立樹如左圖 ![](https://i.imgur.com/Hd9ezBq.png)![](https://i.imgur.com/pUe6eMh.png) 但順序改成 TO、CAT、CUT、CUTE、B 則會如右圖 可以看到各字串的深度會跟著樹的形狀改變,在極端情況如將所有的字串由小而大輸入,則 TST 會有相當差的效能。 :::info TODO: 在輸入字串固定的情況下,如何安排輸入順序使得樹的平均深度(不確定這個詞是否正確,僅用來表示所有 nodes 的3個子節點都被儘量填滿的情況)最小,應該是值得討論的議題。 ::: ## 程式碼理解&測試 在讀了 [rex662624](https://hackmd.io/s/SJbHD5sYM) 和 [tina0405](https://hackmd.io/s/SkEFpwh2-#) 後,大致理解了程式碼中未完善的地方,也就不用對整個程式碼進行地毯式的探索。需修改的點大概有以下幾個: 1. 在 test_ref.c line 55 最後一個係數由 CPY 改成 REF ```C if (!tst_ins_del(&root, &p, INS, CPY)) if (!tst_ins_del(&root, &p, INS, REF)) ``` 2. 接著到 tst.c 中觀察 tst_ins_del() 中與更改的參數相關的程式碼: 發現 tst_ins_del() 只有在 for (;;) 中的 if(cpy) 與更動 CPY-->REF 有關 ```C if (cpy) { /* allocate storage for 's' */ const char *eqdata = strdup(*s); if (!eqdata) return NULL; curr->eqkid = (tst_node *) eqdata; return (void *) eqdata; } else { /* save pointer to 's' (allocated elsewhere) */ curr->eqkid = (tst_node *) *s; return (void *) *s; } ``` 當參數為 CPY 時,cpy == 1,由註解可以看猜出接下來要做的動作是為新加入的字串 allocate storage,實作則用 strdup()實現。strdup() 在 string.h 中被宣告。可以看到,strdup 其實就是把傳入的字串保存到一個未被使用的、新分配的記憶體空間。 ```C char * __strdup (const char *s){ size_t len =strlen (s) + 1; void *new =malloc (len); if (new == NULL) return NULL; return (char *)memcpy (new, s, len); } ``` 3. 當把參數由 CPY 改為 REF(cpy != 1) ,也就沒有為 pointer 分配空間,測試後果然與前人發生了一樣的問題: ``` choice: s find words matching prefix (at least 1 char): Ta Ta - searched prefix in 0.000166 sec suggest[0] : Ta suggest[1] : Ta suggest[2] : Ta suggest[3] : Ta suggest[4] : Ta suggest[5] : Ta suggest[6] : Ta suggest[7] : Ta suggest[8] : Ta suggest[9] : Ta suggest[10] : Ta suggest[11] : Ta suggest[12] : Ta suggest[13] : Ta suggest[14] : Ta suggest[15] : Ta suggest[16] : Ta ``` 4. 首先,一樣照著在別人共筆中讀到的方法,以 array 保存字串,並且減少輸入的資料來避開 core dumped ```C int q = 0; char word2[20000][WRDMAX]; while ((rtn = fscanf(fp, "%s", word2[q])) != EOF) { char *p = word2[q]; q++; /* FIXME: insert reference to each string */ if (!tst_ins_del(&root, &p, INS, REF)) { fprintf(stderr, "error: memory exhausted, tst_insert.\n"); fclose(fp); return 1; } idx++; } ``` ``` choice: s find words matching prefix (at least 1 char): Ta Ta - searched prefix in 0.000190 sec suggest[0] : Ta‘izz, suggest[1] : Ta’if, suggest[2] : Tabūk, suggest[3] : Tabasco, suggest[4] : Taboão suggest[5] : Tabora, suggest[6] : Tabrīz, suggest[7] : Tachikawa, suggest[8] : Tacna, suggest[9] : Tacoma, suggest[10] : Taegu, suggest[11] : Taganrog, suggest[12] : Tagbilaran, suggest[13] : Tagil, suggest[14] : Tagiura, suggest[15] : Tahoua, suggest[16] : Tahta, suggest[17] : Tai’an, suggest[18] : Taicheng, suggest[19] : Taichung, suggest[20] : Taiden, suggest[21] : Tainan, suggest[22] : Taipei, suggest[23] : Taiping, suggest[24] : Taitung .... ``` 至此可以看到 search words matching prefix 的功能可以正常運作了。稍後再試著解決 array 不能宣告太大的問題。 ```C char word2[300000][WRDMAX]; ``` 其實只要把這行 array 宣告放到 main 以外(作為 Global varible),這麼一來就不會 core dumped 了。[參考資料](http://iamcgod.blogspot.tw/2016/12/globalstackheap.html) **三種記憶體區間:global,stack,heap** ●global: 存放 全域變數(global variable) 靜態變數(static variable) ●stack: 存放 區域變數(local variable) 函式參數(function/method parameter) 函數的返回位址(function/method return address) ●heap: 通常是給動態記憶配置(dynamic memory allocation)使用,需要 programmer 自己申請,並指明大小,在 c 中 malloc 函數 ![](https://i.imgur.com/tTZk3qq.png) 4. 接著再往下測試,發現 delete 與 quit 無法正常運作。因此察看相對應的函式: **quit :** 原來是 call tst_free_all(root): ```C /** free the ternary search tree rooted at p, data storage internal. */ void tst_free_all(tst_node *p) { if (!p) return; tst_free_all(p->lokid); if (p->key) tst_free_all(p->eqkid); tst_free_all(p->hikid); if (!p->key) free(p->eqkid); free(p); } ``` 改為 tst_free(root): ```C /** free the ternary search tree rooted at p, data storage external. */ void tst_free(tst_node *p) { if (!p) return; tst_free(p->lokid); if (p->key) tst_free(p->eqkid); tst_free(p->hikid); free(p); } ``` 這樣就不會 free 失敗了耶嘿~ **delete :** 更改 tst_ins_del() 中 : `return tst_del_word(root, curr, &stk, 1);` 的最後一個參數改為 cpy,這樣就可以順利 delete 了,這裡還有一個小細節要注意:data 中 城市名後面的 "," 在 fscanf 時也被讀進去了,所以如果想 find 或 delete Tainan 是會失敗的,必須 find/delete Tainan, 才行。 ## 效能分析