# Skip list & Prefix Search 在 C 語言上的簡單實作
## 為 Desktop Search 打造輕量級資料結構
在 2023 我還在念大學的時候,當時的專題指導老師 jserv 幫我想了一個非常有趣的題目。
題目是用 C 語言打造「桌面搜尋與平行化 PageRank 運算」,旨在應用 PageRank 平行化運算至本地端的桌面檔案監視與資料查詢的服務,類似 WinFS、Google Desktop,以及在 Linux 上的 Recoll、Tracker 等實作。
這個題目作為大學專題對於當時的我來說非常嚇人,光是研究各種 PageRank 演算法的平行化或許就可以獨立成為另一個專題了。還有有關於如何監視桌面下所有子目錄的事件,建立資料結構去儲存,進而生出 PageRank,再讓使用者透過 prefix/infix search 去得到想要的搜尋結果。這中間需要的思考設計、資料結構的選用、所需要了解的背景知識都遠超當時的我所預想。
當然,先求有再求好,但我一直很害怕寫出未來難以維護或擴充的資料結構,所以大學時的專題,儘管我非常喜歡這個題目,最終專題的實作較為簡陋,很多設計也因此停留於構想。
回顧完這段旅程,兩年後的我想再嘗試一次這個題目,在寫完有關於監視目錄下所有檔案的活動之後,接下來就需要一個資料結構來達到:
* 快速 prefix search
* 儲存檔案路徑與後續 PageRank 運算搭配使用
並期望這個資料結構能達到簡單、輕量。
## 為什麼用 skip list?
### 1. Prefix Search 更直覺好寫
如果要做到 prefix search,用 skip list 實作會相對直觀。skip list 的底層是一個排序的 linked list,只要找到 prefix 的起始節點後,向後線性掃描即可完成查詢。
相比之下,AVL / Red-Black Tree 屬於 binary search tree 結構,無法僅靠大小比較決定方向。實作 prefix search 時,往往還需要額外處理範圍搜尋、狀態遞迴等機制,不但複雜,還會讓整體程式的可讀性變差。
### 2. 有機會擴充為對 cache 較為友善的資料結構
雖然 skip list 的搜尋時間為期望 $O(\log n)$,不像 AVL / RB Tree 是最壞情況也能保證 $O(\log n)$,但 skip list 的記憶體存取模式(例如:連續掃描、較少指標跳躍)更符合 CPU cache 的特性。
這樣的性質使得 skip list 有機會在中小型規模資料下獲得實際效能優勢,未來甚至可以朝 cache-oblivious 的設計方向發展,保留在 high level 的 skip list 行為設計,改進底層資料結構,進一步改善效能。
### 3. 更容易擴充為多執行緒版本
因此若要設計 lock-based 的多執行緒版本,可以僅針對節點或某段區間加鎖,避免整個結構都上鎖造成效能瓶頸。
相比之下,AVL 與 RB Tree 每次插入或刪除都可能觸發旋轉、重新平衡(rotation 或 recoloring),牽動整棵樹的結構,因此不得不鎖住整個資料結構。
而若要實作 lock-free 的版本,skip list 的邏輯也比平衡樹簡單許多。
### 4. 其他原因
Skip list 資料結構在系統軟體開發時不時就會被提及一下,有時會出現在經典的 `struct list_head` 資料結構底下討論,亦或是在有些課程的課程討論區(例如:Linux 核心設計)。
網路上有很多人整理 skip list 的理論與應用,但是針對 skip list 去作出一個簡單的 C 語言實作相對比較少(至少在有公開在 HackMD 上的開發紀錄,看到的情況是這樣)。所以我就想要來試試看用 C 語言來寫一個簡單的 skip list。
## Linux 核心的 circular doubly linked list 與 skiplist
Linux kernel 中 `struct list_head` 資料結構非常經典,背後的設計思維可見:[你所不知道的 C 語言: linked list 和非連續記憶體](https://hackmd.io/@sysprog/c-linked-list#%E4%BD%A0%E6%89%80%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-C-%E8%AA%9E%E8%A8%80-linked-list-%E5%92%8C%E9%9D%9E%E9%80%A3%E7%BA%8C%E8%A8%98%E6%86%B6%E9%AB%94)。
這個資料結構背後的設計思維可以給我們許多啟發,它的特點是:
* **環狀設計**:不需要特別處理 head/tail 特例。
* **雙向鏈結**:節點可以前後移動,方便刪除節點,增加後續實作其他功能的靈活性。
* **`container_of` 巨集**:讓資料結構本體與節點分離,不用自行維護一套 doubly-linked list。
然後再參考有關於 skip list 在 linux 核心的討論:[A kernel skiplist implementation (Part 1)](https://lwn.net/Articles/551896/), [Skiplists II: API and benchmarks](https://lwn.net/Articles/553047/)
裡面討論到的 skiplist 也用 circular doubly linked list 的概念實作,這樣在實作 skip list 資料結構的時候,將每一層 skip list 都設計成是個獨立的 circular doubly linked list。
## Skip list 資料結構
但是為了有跳表的 **精神**,也就是在不同層數下有相同 value 的節點應該都要被視為是同一個節點,只能有唯一地址。即使在不同的層數,只要該節點被 "跳" 到,所存取到的地址都該是同一個地址。
所以為了這個 **精神**,很彆扭的設計出一個如下的資料結構:
```c
struct sl_node;
typedef struct sl_node_ptr {
struct sl_node *next, *prev;
} sl_node_ptr;
typedef struct sl_node {
struct sl_node_ptr *list;
int lv;
} sl_node;
typedef struct sl_data {
char path[PATH_MAX];
double pgrk;
struct sl_node node;
} sl_data;
typedef struct skiplist {
struct sl_node head;
int lv;
} sl;
```
`sl_node_ptr` 是每一層用來連接節點的 forward / backward pointer,把它抽成一個獨立的小結構,這樣每一層都可以當成一個 doubly linked list 來操作。方便後續實作 circular doubly linked list 的實現。
`sl_node` 則是包住這些 pointer,再加上一個欄位紀錄這個節點的層數 (lv)。
真正的資料本體是放在 `sl_data` 裡面,這個結構和 `sl_node` 的相關操作是分開的。如果在操作 `sl_node` 的時候想要拿到資料內容,就需要靠 `container_of` 巨集來往外找到對應的 `sl_data` 結構。
```c
#ifndef container_of
#define container_of(ptr, type, member) \
__extension__({ \
const __typeof__(((type *)0)->member) *__pmember = (ptr); \
(type *)((char *)__pmember - offsetof(type, member)); \
})
#endif
```
然後我們可以再額外定義一個 `sl_entry` 巨集:
```c
#define sl_entry(ptr, type, member) container_of(ptr, type, member)
```
---
### 初始化巨集設計
在這份 skip list 的實作中,每層都是一個獨立的 circular doubly linked list,因此初始化的時候我們就讓每一層的 pointer 指向自己。讓每層都是 circular doubly linked list 之後,就可以避免之後插入/刪除時還要處理 head/tail 特例。
初始化一個節點的 pointer 結構:
```c
#define INIT_SL_NODE_CIRCULAR(node, level) \
do { \
(node)->lv = (level); \
(node)->list = calloc(level, sizeof(sl_node_ptr)); \
for (int i = 0; i < level; i++) { \
(node)->list[i].next = (node); \
(node)->list[i].prev = (node); \
} \
} while (0)
```
初始化整個 skip list 的 head node(每層都是獨立環狀 list):
```c
#define INIT_SL_HEAD(sl, level) \
do { \
(sl)->lv = (level); \
INIT_SL_NODE_CIRCULAR(&(sl)->head, level); \
} while (0)
```
---
### 節點遍歷巨集 (Iteration Macros)
有了 circular doubly linked list 的結構後,我們就可以定義類似 Linux kernel 的 list 操作巨集。
基本的遍歷某一層可以寫成:
```c
#define sl_for_each_at_level(pos, head, level) \
for ((pos) = (head)->list[level].next; (pos) != (head); (pos) = (pos)->list[level].next)
```
可以以此類推其他像是 `sl_for_each_safe_at_level`, `sl_for_each_entry_at_level`, `sl_for_each_entry_continue_at_level` ... 等等巨集。
---
## Skip List 核心操作與對應實作
### `sl_lookup`:查找節點(同時記錄路徑)
```c
sl_node *sl_lookup(sl *skplist, const char *path, sl_node **update) {
sl_node *pos = &skplist->head;
for (int i = skplist->lv - 1; i >= 0; i--) {
while (pos->list[i].next != &skplist->head) {
sl_node *next = pos->list[i].next;
int cmp = strcmp(sl_entry(next, sl_data, node)->path, path);
if (!cmp)
return next;
if (cmp > 0)
break;
pos = next;
}
if (update) update[i] = pos;
}
return NULL;
}
```
從最上層往下層逐步搜尋節點,並找出 `path` 名稱剛好等於輸入字串的節點。這個搜尋原理其實類似於 Binary Search。
我們可以注意到:
* 走訪順序是從最上層(`list->lv - 1`)往下層(`0`)進行,每層都試圖「往後跳」直到下一個節點比目標大或是相同為止。
* 每層停下的位置(即每層中最後一個小於目標值的節點)會紀錄在 `update[i]`,方便後續實作 `sl_insert`。
* 走訪下一層開始的節點,就是上一層停下的位置。
這個函式其實還有支援兩種模式:
* 查找模式:傳入 `update = NULL`,只想知道這個節點存不存在。
* 插入前準備:傳入一個 `update[MAX_LEVEL]` 陣列,函式會順便記下每層的「插入點前一個節點」,節省後續 `sl_insert()` 時再找一次的成本。
比如在插入新節點時就會這樣用:
```c
sl_node *update[MAX_LEVEL];
if (sl_lookup(skplist, path, update)) {
// 建立新節點並使用 update[] 插入各層
}
```
這樣可以一邊查找,一邊記住每層的插入位置,避免之後還要再走一次路徑。
---
### `sl_insert`:插入新節點
```c
int sl_insert(sl *skplist, const char *path, double pgrk) {
sl_node *update[MAX_LEVEL];
for (int i = 0; i < MAX_LEVEL; i++)
update[i] = &skplist->head;
if (!path)
return -1;
if (sl_lookup(skplist, path, update))
return -1;
sl_data *entry = calloc(1, sizeof(sl_data));
if (!entry)
return -1;
snprintf(entry->path, PATH_MAX, "%s", path);
entry->pgrk = pgrk;
int new_lv = random_level();
INIT_SL_NODE_CIRCULAR(&entry->node, new_lv);
return _sl_insert(skplist, &entry->node, update);
}
```
這段程式碼主要分成幾個步驟:
* 先檢查有沒有重複
一開始會用 `sl_lookup()` 查找目標 `path`,順便把每一層未來的插入位置記在 `update[]` 裡。
如果發現已經有同名的節點,就直接回傳 `-1`,表示插入失敗。
* 準備新節點
如果沒重複,就先用 `calloc()` 分配一個新的 `sl_data` 節點,並將相關對應資料寫進去。
* 決定節點高度並初始化
接著用 `random_level()` 決定這個節點的層數(高度)。
再用 `INIT_SL_NODE_CIRCULAR()` 把每一層的 doubly linked list 初始化成 circular doubly linked list。
* 把新節點插入 skip list
最後呼叫 `_sl_insert()`,利用一開始 `sl_lookup()` 紀錄的 `update[]` 資訊,將新節點插入每一層對應的位置。
---
### `sl_delete`:刪除目標節點
```c
int sl_delete(sl *skplist, const char *path) {
if (!path)
return -1;
sl_node *target = sl_lookup(skplist, path, NULL);
if (!target)
return -1;
sl_node_remove(target);
sl_data *data = sl_entry(target, sl_data, node);
free(target->list);
free(data);
// Shrink list level if top levels are empty
while (skplist->lv > 1 && sl_empty_at_level(&skplist->head, skplist->lv - 1))
skplist->lv--;
return 0;
}
```
* 尋找目標節點
用 `sl_lookup` 找出目標節點 `target`。這裡沒有使用 `update[]`,因為刪除只需要找到該節點本身即可。
* 解除節點連結
呼叫 `sl_node_remove(target)`,透過節點內部指標逐層解除與前後節點的連結。
* 釋放記憶體
釋放該節點的指標陣列 `ptr` 和實際資料本體 `data`。
* 調整 skip list 高度(層數)
如果最高層已無節點,將 skip list 的高度 `list->lv` 往下縮減。
:::warning
**為何刪除不使用 `update[]` ?**
* 刪除時,只需知道目標節點本身,透過該節點的指標即可完成所有層級的連結解除。
* `sl_node_remove` 函式就是利用這點逐層解除連結。
```c
static inline void sl_node_remove(sl_node *n) {
for (int i = 0; i < n->lv; i++) {
sl_node_remove_at_level(n, i);
}
}
```
* 反而插入時必須事先知道每層的插入位置,因此需要用 `update[]` 紀錄查找過程中的節點位置。
:::
---
## Prefix search 實作
### `sl_lookup_prefix`:取得 prefix 開頭的多筆結果
```c
int sl_lookup_prefix(sl *skplist, const char *prefix, sl_data **results, int max) {
int prefix_len = strlen(prefix);
sl_node *head = &skplist->head;
sl_node *n = sl_lookup_prefix_lower_bound(skplist, prefix);
if (!n)
return 0;
sl_data *entry = sl_entry(n, sl_data, node);
int count = 0;
sl_for_each_entry_from_at_level(entry, head, 0, sl_data, node) {
if (strncmp(entry->path, prefix, prefix_len) || count >= max)
break;
results[count++] = entry;
}
return count;
}
```
這裡先呼叫 `sl_lookup_prefix_lower_bound`(類似 `sl_lookup` 的實作方式,只是不再找到目標之後立即 `return`),找到 prefix 在 skip list 最底層(level `0`)可能開始的位置,也就是第一個字串大於或等於 prefix 的節點。
接著從 skip list 底層(level `0`)開始遍歷整個 list,利用 `strncmp` 比較字串前綴是否相符,將所有符合的節點放入 `results` 中,直到超過 `max` 筆或遍歷結束。
---
## 資源釋放與記憶體管理
### `sl_free`
```c
void sl_free(sl *skplist) {
sl_node *pos, *tmp;
sl_for_each_safe_at_level(pos, tmp, &skplist->head, 0) {
sl_data *data = sl_entry(pos, sl_data, node);
free(data->node.list);
free(data);
}
free(skplist->head.list);
free(skplist);
}
```
在釋放整個 skip list 需要注意幾個點:
* 安全遍歷釋放節點
使用 `sl_for_each_safe_at_level` 巨集來遍歷節點,必須使用「safe」版本,避免在釋放節點時破壞遍歷鏈結,導致記憶體存取錯誤(segmentation fault)。
* 只遍歷 Level `0`
只需遍歷底層(Level `0`)的節點,因為所有層的節點指標均指向相同節點本體。當底層節點被釋放後,其它層的節點連結自然失效,無須重複釋放。
* 記憶體釋放順序
先釋放每個節點中存放不同層鏈結指標的 `ptr` 陣列,再釋放節點本體。最後釋放 skip list `head` 節點的 `ptr` 陣列,以及整個 skip list 結構本身。
---
## `main()` 簡單測試範例:插入、查詢與刪除
以下示範 Skip List 的插入、Prefix Search 與刪除功能,並展示對應的預期輸出:
```c
int main() {
init_random();
sl *list = sl_create();
sl_insert(list, "/doc/report.txt", 0.8);
sl_insert(list, "/doc/readme.md", 0.9);
sl_insert(list, "/doc/archive.zip", 0.7);
sl_insert(list, "/music/song.mp3", 0.5);
sl_insert(list, "/video/movie.mp4", 0.6);
sl_data *results[10];
int n = sl_lookup_prefix(list, "/doc", results, 10);
printf("Search prefix '/doc' found %d results:\n", n);
for (int i = 0; i < n; i++) {
printf(" %s (PageRank: %.2f)\n", results[i]->path, results[i]->pgrk);
}
sl_delete(list, "/doc/readme.md");
printf("After deleting '/doc/readme.md':\n");
n = sl_lookup_prefix(list, "/doc", results, 10);
for (int i = 0; i < n; i++) {
printf(" %s (PageRank: %.2f)\n", results[i]->path, results[i]->pgrk);
}
sl_free(list);
return 0;
}
```
預期輸出:
```
Search prefix '/doc' found 3 results:
/doc/archive.zip (PageRank: 0.70)
/doc/readme.md (PageRank: 0.90)
/doc/report.txt (PageRank: 0.80)
After deleting '/doc/readme.md':
/doc/archive.zip (PageRank: 0.70)
/doc/report.txt (PageRank: 0.80)
```
---
## 未來可以拓展的方向
* 更完善的亂數生成機制
目前使用標準 C 函式庫的 `random()` 作為隨機層數產生器,
```c
static inline uintptr_t _init_random(uintptr_t seed) {
uintptr_t x = (uintptr_t)(void *)&_init_random; // ASLR
x ^= seed;
x = (x << 13) | (x >> ((sizeof(uintptr_t) << 3) - 13));
x ^= (x >> 7);
x ^= (x << 17);
return x;
}
void init_random(void) {
struct timeval tm;
gettimeofday(&tm, NULL);
uintptr_t t = (uintptr_t)(tm.tv_sec ^ tm.tv_usec);
uintptr_t seed = _init_random(t ^ (uintptr_t)getpid());
srandom((unsigned int)(seed & 0xFFFFFFFFUL));
}
static inline int random_level() {
int lv = 1;
while (((double)random() / RAND_MAX) < P && lv < MAX_LEVEL) lv++;
return lv;
}
```
未來可引入品質更高的 PRNG 來進行實作。
> 延伸閱讀:[從模除偏差談亂數分布](https://hackmd.io/@sysprog/BkSydKkaJg)
* 引入 `sl_slot` 資料結構
讓每個節點存放多個 `key`。增加 cache 命中率,減少 `malloc/free` 次數,或可以減少 concurrency 寫入操作的開銷。
> 在 Desktop Search 中可以設計讓 skip list 節點只存目錄路徑,`sl_slot` 存目錄下所有檔案的資料。
* 多執行緒設計
將資料結構擴充為可在多執行緒環境下安全運行,甚至實現高並行。可能引入 Lazy list, Fine-grained locking, 甚至 lock-free 的設計。
> 延伸閱讀:[A Provably Correct Scalable Concurrent Skip List](https://people.csail.mit.edu/shanir/publications/OPODIS2006-BA.pdf),
[The Art of Multiprocessor Programming Chapter 14 - Skiplists and Balanced Search
](https://cs.ipm.ac.ir/asoc2016/Resources/Theartofmulticore.pdf), https://arxiv.org/html/2403.04582v2#S11
:::warning
可以思考 doubly linked list 的設計在多執行緒的環境中如何實作,因為 doubly linked list 在多執行緒下環境下,每次的插入、刪除,都要考慮到 `prev` 指標的操作,需要鎖住的節點會比 singly linked list 還要多。除了效能上的開銷比較大,在實作的難度也高許多。
:::
:::success
方案:
1. 研讀 [Userspace RCU](https://liburcu.org/) 並加入實作。
2. 不去使用 `prev`,只讓 `head` 保有 `prev` ,存取持有最大值的節點。
3. Insert 時仍然是 Doubly linked list 的 insert,remove 時只把 `next` 抹消,讓 `next` 指向 `NULL`,保留 `prev`,等到要 delete 的時候再利用 `prev` 指標往回遍歷,找到哪些節點的 `next` 值為 `NULL`,再去刪除。
:::
* Cache-Oblivious 的 skip list 設計
研究 Cache-Oblivious skip list 的資料結構,透過記憶體連續的存取模式增加 cache 命中率,降低記憶體延遲對查詢與更新效能的影響。