練習46:三叉搜尋樹
原文:Exercise 46: Ternary Search Tree
譯者:飛龍
我打算向你介紹的最後一種資料結構就是三叉搜尋樹(TSTree
),它和BSTree
很像,除了它有三個分支,low
、equal
和high
。它的用法和BStree
以及Hashmap
基本相同,用於儲存鍵值對的資料,但是它通過鍵中的獨立字元來控制。這使得TSTree
具有一些BStree
和Hashmap
不具備的功能。
TSTree
的工作方式是,每個鍵都是字串,根據字串中字元的等性,通過構建或者遍歷一棵樹來進行插入。首先由根節點開始,觀察每個節點的字元,如果小於、等於或大於則去往相應的方向。你可以參考這個標頭檔案:
#ifndef _lcthw_TSTree_h
#define _lctwh_TSTree_h
#include <stdlib.h>
#include <lcthw/darray.h>
typedef struct TSTree {
char splitchar;
struct TSTree *low;
struct TSTree *equal;
struct TSTree *high;
void *value;
} TSTree;
void *TSTree_search(TSTree *root, const char *key, size_t len);
void *TSTree_search_prefix(TSTree *root, const char *key, size_t len);
typedef void (*TSTree_traverse_cb)(void *value, void *data);
TSTree *TSTree_insert(TSTree *node, const char *key, size_t len, void *value);
void TSTree_traverse(TSTree *node, TSTree_traverse_cb cb, void *data);
void TSTree_destroy(TSTree *root);
#endif
TSTree
擁有下列成員:
splitchar
樹中該節點的字元。
low
小於splitchar
的分支。
equal
等於splitchar
的分支。
high
大於splitchar
的分支。
value
這個節點上符合當前splitchar
的值的集合。
你可以看到這個實現中含有下列操作:
search
為特定key
尋找值的典型操作。
search_prefix
尋找第一個以key
為字首的值,這是你不能輕易使用BSTree
或 Hashmap
完成的操作。
insert
將key
根據每個字元拆分,並把它插入到樹中。
traverse
遍歷整顆樹,使你能夠收集或分析所包含的所有鍵和值。
唯一缺少的操作就是TSTree_delete
,這是因為它是一個開銷很大的操作,比BSTree_delete
大得多。當我使用TSTree
結構時,我將它們視為常量資料,我打算遍歷許多次,但是永遠不會移除任何東西。它們對於這樣的操作會很快,但是不適於需要快速插入或刪除的情況。為此我會使用Hashmap
因為它由於BSTree
和TSTree
。
TSTree
的實現非常簡單,但是第一次可能難以理解。我會在你讀完之後拆分它。
#include <stdlib.h>
#include <stdio.h>
#include <assert.h>
#include <lcthw/dbg.h>
#include <lcthw/tstree.h>
static inline TSTree *TSTree_insert_base(TSTree *root, TSTree *node,
const char *key, size_t len, void *value)
{
if(node == NULL) {
node = (TSTree *) calloc(1, sizeof(TSTree));
if(root == NULL) {
root = node;
}
node->splitchar = *key;
}
if(*key < node->splitchar) {
node->low = TSTree_insert_base(root, node->low, key, len, value);
} else if(*key == node->splitchar) {
if(len > 1) {
node->equal = TSTree_insert_base(root, node->equal, key+1, len - 1, value);
} else {
assert(node->value == NULL && "Duplicate insert into tst.");
node->value = value;
}
} else {
node->high = TSTree_insert_base(root, node->high, key, len, value);
}
return node;
}
TSTree *TSTree_insert(TSTree *node, const char *key, size_t len, void *value)
{
return TSTree_insert_base(node, node, key, len, value);
}
void *TSTree_search(TSTree *root, const char *key, size_t len)
{
TSTree *node = root;
size_t i = 0;
while(i < len && node) {
if(key[i] < node->splitchar) {
node = node->low;
} else if(key[i] == node->splitchar) {
i++;
if(i < len) node = node->equal;
} else {
node = node->high;
}
}
if(node) {
return node->value;
} else {
return NULL;
}
}
void *TSTree_search_prefix(TSTree *root, const char *key, size_t len)
{
if(len == 0) return NULL;
TSTree *node = root;
TSTree *last = NULL;
size_t i = 0;
while(i < len && node) {
if(key[i] < node->splitchar) {
node = node->low;
} else if(key[i] == node->splitchar) {
i++;
if(i < len) {
if(node->value) last = node;
node = node->equal;
}
} else {
node = node->high;
}
}
node = node ? node : last;
// traverse until we find the first value in the equal chain
// this is then the first node with this prefix
while(node && !node->value) {
node = node->equal;
}
return node ? node->value : NULL;
}
void TSTree_traverse(TSTree *node, TSTree_traverse_cb cb, void *data)
{
if(!node) return;
if(node->low) TSTree_traverse(node->low, cb, data);
if(node->equal) {
TSTree_traverse(node->equal, cb, data);
}
if(node->high) TSTree_traverse(node->high, cb, data);
if(node->value) cb(node->value, data);
}
void TSTree_destroy(TSTree *node)
{
if(node == NULL) return;
if(node->low) TSTree_destroy(node->low);
if(node->equal) {
TSTree_destroy(node->equal);
}
if(node->high) TSTree_destroy(node->high);
free(node);
}
對於TSTree_insert
,我使用了相同模式的遞迴結構,其中我建立了一個小型函式,它呼叫真正的遞迴函式。我對此並不做任何檢查,但是你應該為之新增通常的防禦性程式設計策略。要記住的一件事,就是它使用了一些不同的設計,這裡並沒有單獨的TSTree_create
函式,如果你將node
傳入為NULL
,它會新建一個,然後返回最終的值。
這意味著我需要為你分解TSTree_insert_base
,使你理解插入操作。
tstree.c:10-18
像我提到的那樣,如果函式接收到NULL
,我需要建立節點,並且將*key
(當前字元)賦值給它。這用於當我插入鍵時來構建樹。
tstree.c:20-21
當*key
小於splitchar
時,選擇low
分支。
tstree.c:22
如果splitchar
相等,我就要進一步確定等性。這會在我剛剛建立這個節點時發生,所以這裡我會構建這棵樹。
tstree.c:23-24
仍然有字串需要處理,所以向下遞迴equal
分支,並且移動到下一個*key
字元。
tstree.c:26-27
這是最後一個字元的情況,所以我將值設定好。我編寫了一個assert
來避免重複。
tstree.c:29-30
最後的情況是*key
大於splitchar
,所以我需要向下遞迴high
分支。
這個資料結構的key
實際上帶有一些特性,我只會在splitchar
相等時遞增所要分析的字元。其它兩種情況我只會繼續遍歷整個樹,直到碰到了相等的字元,我才會遞迴處理下一個字元。這一操作使它對於找不到鍵的情況是非常快的。我可以傳入一個不存在的鍵,簡單地遍歷一些high
和low
節點,直到我碰到了末尾並且知道這個鍵不存在。我並不需要處理鍵的每個字元,或者樹的每個節點。
一旦你理解了這些,之後來分析TSTree_search
如何工作:
tstree.c:46
我並不需要遞迴處理整棵樹,只需要使用使用while
迴圈和當前的node
節點。
tstree.c:47-48
如果當前字元小於節點中的splitchar
,則選擇low
分支。
tstree.c:49-51
如果相等,自增i
並且選擇equal
分支,只要不是最後一個字元。這就是if(i < len)
所做的,使我不會越過最後的value
。
tstree.c:52-53
否則我會選擇high
分支,由於當前字元更大。
tstree.c:57-61
迴圈結束後如果node
不為空,那麼返回它的value
,否則返回NULL
。
這並不難以理解,並且你可以看到TSTree_search_prefix
函式用了幾乎相同的演算法。唯一的不同就是我並不試著尋找精確的匹配,而是可找到的最長字首。我在相等時跟蹤last
節點來實現它,並且在搜尋迴圈結束之後,遍歷這個節點直到發現value
。
觀察TSTree_search_prefix
,你就會開始明白TSTree
相對BSTree
和 Hashmap
在查詢操作上的另一個優點。給定一個長度為X的鍵,你可以在X步內找到任何鍵,但是也可以在X步加上額外的N步內找到第一個字首,取決於匹配的鍵有多長。如果樹中最長的鍵是十個字元,那麼你就可以在10步之內找到任意的字首。更重要的是,你可以通過對鍵的每個字元只比較一次來實現。
相比之下,使用BSTree
執行相同操作,你需要在BSTree
的每一個可能匹配的節點中檢查兩個字串是否有共同的字首。這對於尋找鍵,或者檢查鍵是否存在(TSTree_search
)是相同的。你需要將每個字元與BSTree
中的大多數字符對比,來確認是否匹配。
Hashamp
對於尋找字首更加糟糕,因為你不能夠僅僅計算字首的雜湊值。你基本上不能高效在Hashmap
中實現它,除非資料類似URL可以被解析。即使這樣你還是需要遍歷Hashmap
的所有節點。
譯者注:二叉樹和三叉樹在搜尋時都是走其中的一支,但由於二叉樹中每個節點儲存字串,而三叉樹儲存的是字元。所以三叉樹的整個搜尋過程相當於一次字串比較,而二叉樹的每個節點都需要一次字串比較。三叉樹堆疊儲存字串使搜尋起來更方便。
至於雜湊表,由於字串整體和字首計算出來的雜湊值差別很大,所以按字首搜尋時,雜湊的優勢完全失效,所以只能改為暴力搜尋,效果比二叉樹還要差。
最後的兩個函式應該易於分析,因為它們是典型的遍歷和銷燬操作,你已經在其它資料結構中看到過了。
最後,我編寫了簡單的單元測試,來確保我所做的全部東西正確。
#include "minunit.h"
#include <lcthw/tstree.h>
#include <string.h>
#include <assert.h>
#include <lcthw/bstrlib.h>
TSTree *node = NULL;
char *valueA = "VALUEA";
char *valueB = "VALUEB";
char *value2 = "VALUE2";
char *value4 = "VALUE4";
char *reverse = "VALUER";
int traverse_count = 0;
struct tagbstring test1 = bsStatic("TEST");
struct tagbstring test2 = bsStatic("TEST2");
struct tagbstring test3 = bsStatic("TSET");
struct tagbstring test4 = bsStatic("T");
char *test_insert()
{
node = TSTree_insert(node, bdata(&test1), blength(&test1), valueA);
mu_assert(node != NULL, "Failed to insert into tst.");
node = TSTree_insert(node, bdata(&test2), blength(&test2), value2);
mu_assert(node != NULL, "Failed to insert into tst with second name.");
node = TSTree_insert(node, bdata(&test3), blength(&test3), reverse);
mu_assert(node != NULL, "Failed to insert into tst with reverse name.");
node = TSTree_insert(node, bdata(&test4), blength(&test4), value4);
mu_assert(node != NULL, "Failed to insert into tst with second name.");
return NULL;
}
char *test_search_exact()
{
// tst returns the last one inserted
void *res = TSTree_search(node, bdata(&test1), blength(&test1));
mu_assert(res == valueA, "Got the wrong value back, should get A not B.");
// tst does not find if not exact
res = TSTree_search(node, "TESTNO", strlen("TESTNO"));
mu_assert(res == NULL, "Should not find anything.");
return NULL;
}
char *test_search_prefix()
{
void *res = TSTree_search_prefix(node, bdata(&test1), blength(&test1));
debug("result: %p, expected: %p", res, valueA);
mu_assert(res == valueA, "Got wrong valueA by prefix.");
res = TSTree_search_prefix(node, bdata(&test1), 1);
debug("result: %p, expected: %p", res, valueA);
mu_assert(res == value4, "Got wrong value4 for prefix of 1.");
res = TSTree_search_prefix(node, "TE", strlen("TE"));
mu_assert(res != NULL, "Should find for short prefix.");
res = TSTree_search_prefix(node, "TE--", strlen("TE--"));
mu_assert(res != NULL, "Should find for partial prefix.");
return NULL;
}
void TSTree_traverse_test_cb(void *value, void *data)
{
assert(value != NULL && "Should not get NULL value.");
assert(data == valueA && "Expecting valueA as the data.");
traverse_count++;
}
char *test_traverse()
{
traverse_count = 0;
TSTree_traverse(node, TSTree_traverse_test_cb, valueA);
debug("traverse count is: %d", traverse_count);
mu_assert(traverse_count == 4, "Didn`t find 4 keys.");
return NULL;
}
char *test_destroy()
{
TSTree_destroy(node);
return NULL;
}
char * all_tests() {
mu_suite_start();
mu_run_test(test_insert);
mu_run_test(test_search_exact);
mu_run_test(test_search_prefix);
mu_run_test(test_traverse);
mu_run_test(test_destroy);
return NULL;
}
RUN_TESTS(all_tests);
優點和缺點
TSTree
可以用於實現一些其它實用的事情:
-
除了尋找字首,你可以反轉插入的所有鍵,之後通過字尾來尋找。我使用它來尋找主機名稱,因為我想要找到
*.learncodethehardway.com
,所以如果我反向來尋找,會更快匹配到它們。 -
你可以執行“模糊”搜尋,其中你可以收集所有與鍵的大多數字符相似的節點,或者使用其它演算法用於搜尋近似的匹配。
-
你可以尋找所有中間帶有特定部分的鍵。
我已經談論了TSTree
能做的一些事情,但是它們並不總是最好的資料結構。TSTree
的缺點在於:
-
像我提到過的那樣,刪除操作非常麻煩。它們適用於需要快速檢索並且從不移除的操作。如果你需要刪除,可以簡單地將
value
置空,之後當樹過大時週期性重構它。 -
與
BSTree
和Hashmap
相比,它在相同的鍵上使用了大量的空間。它對於鍵中的每個字元都使用了完整的節點。它對於短的鍵效果更好,但如果你在TSTree
中放入一大堆東西,它會變得很大。 -
它們也不適合處理非常長的鍵,然而“長”是主觀的詞,所以應當像通常一樣先進行測試。如果你嘗試儲存一萬個字元的鍵,那麼應當使用
Hashmap
。
如何改進
像通常一樣,瀏覽程式碼,使用防禦性的先決條件、斷言,並且檢查每個函式來改進。下面是一些其他的改進方案,但是你並不需要全部實現它們:
-
你可以使用
DArray
來允許重複的value
值。 -
因為我提到刪除非常困難,但是你可以通過將值設為
NULL
來模擬,使值能夠高效被刪除。 -
目前還不能獲取到所有匹配指定字首的值,我會讓你在附加題中實現它。
-
有一些其他得更復雜的演算法會比它要好。查詢字首陣列、字首樹和基數樹的資料。
附加題
-
實現
TSTree_collect
返回DArray
包含所有匹配指定字首的鍵。 -
實現
TSTree_search_suffix
和TSTree_insert_suffix
,實現字尾搜尋和插入。 -
使用
valgrind
來檢視與BSTree
和Hashmap
相比,這個結構使用了多少記憶體來儲存資料。