# [2020q3](http://wiki.csie.ncku.edu.tw/sysprog/schedule) 第 8 週測驗題
###### tags: `sysprog2020`
:::info
目的: 檢驗學員對 [dynamic programming](https://en.wikipedia.org/wiki/Dynamic_programming), [bitwise 操作](https://hackmd.io/@sysprog/c-bitwise), [linked list 和非連續記憶體操作](https://hackmd.io/@sysprog/c-linked-list) 的認知
:::
==[作答表單](https://docs.google.com/forms/d/e/1FAIpQLSdlE1HdhJZDyO-rDwcA7GbzS5aN1yoACrObEaEpGT7sBZ3piA/viewform)==
### 測驗 `1`
在講解題目之前,先看經典的 [Coin Problem](https://en.wikipedia.org/wiki/Coin_problem): 給予一組硬幣 $\{1,2,5,10,20,50,100,200\}$,若金額 $n=520$,如何找出最少的硬幣組成呢?硬幣可重複選取。
最佳解為: $n = 520 = 200 + 200 + 100 + 20$
貪婪演算法 (greedy algorithm) 的做法是從最大硬幣開始選取,直到 `520` 被湊出來,其中 $1, 5 , 10 ,50 , 100$ 僅會在最佳解出現一次。
[dynamic programming](https://en.wikipedia.org/wiki/Dynamic_programming) 的本質是暴力法 (brute force; complete search),但因它採用表格欄位去紀錄答案 (memorization),所以每個 subproblem 僅運算一次即可。
簡化問題,給定 $\texttt{coins} = \{1,3,4\}$ 及 $\texttt{solve}(10)=3$,因為至少需要 3 種硬幣才能得到總和 `10`,於是最佳解為 $3 + 3 + 4 = 10$.
\begin{array}{lcl}
\texttt{solve}(0) & = & 0 \\
\texttt{solve}(1) & = & 1 \\
\texttt{solve}(2) & = & 2 \\
\texttt{solve}(3) & = & 1 \\
\texttt{solve}(4) & = & 1 \\
\texttt{solve}(5) & = & 2 \\
\texttt{solve}(6) & = & 2 \\
\texttt{solve}(7) & = & 2 \\
\texttt{solve}(8) & = & 2 \\
\texttt{solve}(9) & = & 3 \\
\texttt{solve}(10) & = & 3 \\
\end{array}
我們可依據條件寫成等式 :
\begin{equation*}
\begin{split}
\texttt{solve}(x) = \min( & \texttt{solve}(x-1)+1, \\
& \texttt{solve}(x-3)+1, \\
& \texttt{solve}(x-4)+1).
\end{split}
\end{equation*}
擴展成通式:
\begin{equation*}
\texttt{solve}(x) = \begin{cases}
\infty & x < 0\\
0 & x = 0\\
\min_{c \in \texttt{coins}} \texttt{solve}(x-c)+1 & x > 0 \\
\end{cases}
\end{equation*}
對應的遞迴求解程式如下: (`C++`)
```cpp
int solve(int x) {
if(x < 0) return INF; // infinity
if(x == 0) return 0;
int best = INF;
for (auto c:C)
best = min(best, solve(x - c) + 1);
return best;
}
```
> 注: 此處的 `INF` 可用 `INT_MAX` 實作
上述程式碼會耗費指數時間 (exponential time) 以計算 $x$,於是我們可利用 $memoization$ 手法,將把子問題 (subproblem) 的解保存下來,避免每次都要重算,如此我們可建構 recursion + memoziation 的程式碼,如下:
```cpp
int solve(int x) {
if (x < 0) return INF;
if (x == 0) return 0;
if (ready[x]) return value[x]; /* if slove(x) is ready, stop to compute it. */
int best = INF;
for (auto c:C)
best = min(best, solve(x - c) + 1);
/* save subproblem solution of slove(x) */
value[x] = best;
ready[x] = true;
return best;
}
```
可改寫為迴圈形式的程式碼,從而避免遞迴:
```cpp
bool ready[N] = {0};
int value[N] = {0};
value[0] = 0;
/* caculate slove(x) from 1 to n */
for (int x = 1; x <= n; x++) {
value[x] = INF;
for (auto c : coins) {
/* check index boundary */
if (x-c >= 0) {
// don't take c take c
value[x] = min(value[x], value[x - c] + 1);
}
}
}
```
倘若我們想輸出 $solve(10) = 4+3+3$,該如何做?
我們可用一個陣列去記錄每個最佳子問題的第一個硬幣為何,依據貪婪演算法,第一個硬幣也會是最大的硬幣,因此拼湊出來即是最佳解的全部硬幣組合。將 `value[x-c]+1 < value[x]` 這條件從 `min()` 搬移到 `if` 敘述:
```cpp
int first[N];
value[0] = 0;
for (int x = 1; x <= n; x++) {
value[x] = INF;
for (auto c : coins) {
// value[x-c]+1 < value[x]
if ((x - c >= 0) && (value[x - c] + 1 < value[x]) {
value[x] = value[x - c] + 1;
first[x] = c;
}
}
}
```
隨後我們再將組合印出:
```cpp
while (n > 0) {
printf("%d\n", first[n]);
n -= first[n];
}
```
接著我們又可擴充題目,計算出 `solve(x)` 有多少種硬幣組合方式,以 `solve(5)` 為例,可能的組合如下:
* $1 + 1 + 1 + 1 + 1$
* $1 + 1 + 3$
* $1 + 3 + 1$
* $3 + 1 + 1$
* $1 + 4$
* $4 + 1$
用上述的條件,令 $\texttt{coins}=\{1,3,4\}$,則 $\texttt{solve}(5)=6$ 對應的遞迴表示如下:
\begin{equation*}
\begin{split}
\texttt{solve}(x) = & \texttt{solve}(x-1) + \\
& \texttt{solve}(x-3) + \\
& \texttt{solve}(x-4) .
\end{split}
\end{equation*}
通式如下,要留意到「不放」也算是其中一種方法:
\begin{equation*}
\texttt{solve}(x) = \begin{cases}
0 & x < 0\\
1 & x = 0\\
\sum_{c \in \texttt{coins}} \texttt{solve}(x-c) & x > 0 \\
\end{cases}
\end{equation*}
對應的程式碼如下:
```cpp
/* There is only one way to form an empty sum */
count[0] = 1;
int solve(x) {
for (int x = 1; x <= n ; x++)
for (auto c : Coins)
if (x - c >= 0) count[x] += count[x - c];
}
```
若我們不想算出全部 `solve(x)` 的解,可運用模數運算 (modulo) 來化簡。舉例來說,令 $m=10^9 + 7$,在 `count[x] += count[x - c]` 敘述增加模數運算,如:
```cpp
count[x] += count[x - c];
count[x] %= m;
```
參考資訊:
* [Why we like Modulo $10^9 + 7$ (`1000000007`)](https://www.geeksforgeeks.org/modulo-1097-1000000007/)
LeetCode [1494. Parallel Courses II](https://leetcode.com/problems/parallel-courses-ii/) 給定一整數 $n$ 表示某所大學裡課程的數目,編號為 $1$ 到 $n$,給定陣列 `dependencies`,使得 $dependencies[i] = [x_i, y_i]$ 表示先修課的關係,亦即課程 $x_i$ 必須在課程 $y_i$ 之前選修並通過。另外,給予一整數 $k$,表示在一個學期裡頭,你最多可同時選修 $k$ 門課,前提是這些課的先修課在之前的學期裡已修過。程式應該回傳上完所有課最少需要多少個學期。題目保證一定存有一種修完所有課的方式。
- [ ] 範例 1
![](https://i.imgur.com/5CfmtZa.png)
給定輸入:
* n = 4 (即 4 門課程)
* dependencies = $[[2, 1], [3, 1], [1, 4]]$
* 即選修 `1` 這門課,應當先修過 `2` 和 `3` 這二門課,又,選修 `4` 之前應當先修過 `1` 這門課
* k = 2 (一個學期中,你至多可同時選修 2 門課程)
預期輸出: 3
* 上圖展現可能的修課方式: 第一個學期選修課程 `2` 和課程 `3`,然後第二個學期選修課程 `1` ,第三個學期選修課程 `4`。
- [ ] 範例 2
![](https://i.imgur.com/wbkebAf.png)
給定輸入:
* n = 5 (即 5 門課程)
* dependencies = $[[2, 1], [3, 1], [4, 1], [1, 5]]$
* 即選修 `1` 這門課,應當先修過 `2`, `3` 和 `4` 這三門課,又,選修 `5` 之前應當先修過 `1` 這門課
* k = 2 (一個學期中,你至多可同時選修 2 門課程)
預期輸出: 4
* 上圖展現可能的修課方式: 第一學期選修課程 `2` 和 `3` (注意一學期僅能同時選修 2 門課程),第二學期選修課程 `4`,第三學期選修課程 `1`,第四學期選修課程 `5`
本題的限制:
* $1 \leq n \leq 15$
* $1 \leq k \leq n$
* $0 \leq dependencies.length \leq \frac{n \times (n - 1)}{2}$
* $dependencies[i].length = 2$
* $1 \leq x_i, y_i \leq n$
* $x_i \neq y_i$
* 所有先修關係都不同,也就是說 $dependencies[i] \neq dependencies[j]$
* 題目輸入的圖是個有向無環圖 ([Directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph))
注意本題是 [NP 問題](https://en.wikipedia.org/wiki/NP_(complexity)),運用貪婪演算法 (greedy algorithm) 很容易遇到 TLE (time limit exceeded),於是我們可嘗試狀態壓縮和 [dynamic programming](https://en.wikipedia.org/wiki/Dynamic_programming) 結合的技巧。狀態壓縮是利用二進位來表達中間的狀態,也就是說,狀態只能是 0 或 1,若用集合來思考,就是「在」或「不在」集合中。此手法適用於資料量不大的案例。先用陣列儲存之前的狀態,再由狀態及其對應的數值,推導出狀態轉移方程式,最終可得適當的解法。
以本題來說,$n$ 至多為 $15$,而且一門課「選」或者「不選」即可運用二進位的位元表示,符合上述的狀態壓縮技巧。演算法如下:
1. 計算每個課程的直接相依課程的 bit mask (位元遮罩),記為 `pre`
2. 令狀態 $f(S)$ 表示完成位元遮罩 $S$ 的課程所需要的最少學期數
3. 在初始階段,$f(0)=0$,其餘為位元遮罩有效範圍以外的數值
4. 狀態移轉時,從某個已修過的課程位元遮罩 $S_0$,嘗試求出 $S_1$,後者表示目前可選擇的新課程(新課程也該滿足相依條件),再從 $S_1$ 逐次找出小於等於 $k$ 門課程,其位元遮罩記為 $S$,移轉關係為 $f(S_0 \lor S) = min(f(S_0 \lor S), f(S_0) + 1)$。
5. 最終解為 $f((1 \ll n)−1)$
對應的遞迴程式碼如下: (`recursive-nos1.c`)
```cpp
#include <string.h>
#include <limits.h>
#define min(a, b) (((a) < (b)) ? (a) : (b))
static void solve(int i, int s, int k, int n, int s0, int s1, int *dp)
{
if ((k == 0) || (i == n)) {
dp[s0 | s] = min(dp[s0 | s], dp[s0] + 1);
return;
}
solve(i + 1, s, k, n, s0, s1, dp);
if ((s1 >> i) & 1)
solve(i + 1, s | 1 << i, k - 1, n, s0, s1, dp);
}
int minNumberOfSemesters(int n,
int **dependencies,
int dependenciesSize,
int *dependenciesColSize,
int k)
{
const size_t cap = 1 << n;
int pre[cap];
memset(pre, 0, sizeof(pre));
for (int i = 0; i < dependenciesSize; i++)
pre[dependencies[i][1] - 1] |= 1 << (dependencies[i][0] - 1);
int dp[cap];
for (int i = 1; i < cap; i++)
dp[i] = INT_MAX;
dp[0] = 0;
for (int s0 = 0; s0 < cap; s0++) {
if (dp[s0] == INT_MAX)
continue;
int s1 = 0;
for (int i = 0; i < n; i++)
if (!((s0 >> i) & 1) && ((pre[i] & s0) == pre[i]))
s1 |= 1 << i;
solve(0, 0, k, n, s0, s1, dp);
}
return dp[cap - 1];
}
```
:::info
:angel: `recursive-nos1.c` 在 LeetCode 提交後的執行結果
Runtime: 124 ms
Memory Usage: 6.2 MB
:::
我們可改寫為非遞迴的形式。先計算出每個數在二進位表示中有幾個 `1`,亦即選修幾門課,保存於陣列 `count[]` 中,共有 $2^n$ 種狀態。陣列 `pre[]` 表示特定的狀態所需要的前導課程。對應的程式碼如下: (`iterative-nos1.c`)
```cpp
#define min(a, b) (((a) < (b)) ? (a) : (b))
int minNumberOfSemesters(int n,
int **dependencies,
int dependenciesSize,
int *dependenciesColSize,
int k)
{
uint16_t prerequisite[n];
memset(prerequisite, 0, sizeof(prerequisite));
for (int i = 0; i < dependenciesSize; ++i)
prerequisite[dependencies[i][1] - 1] |= 1 << (dependencies[i][0] - 1);
const int cap = 1 << n;
uint8_t dp[cap];
memset(dp, 1 << 6, cap);
dp[0] = 0;
for (uint32_t i = 0; i < cap; ++i) {
uint32_t ready_for_take = 0;
for (uint32_t j = 0; j < n; ++j) {
if ((prerequisite[j] & i) == prerequisite[j])
ready_for_take |= 1 << j;
}
ready_for_take &= ~i;
for (uint32_t j = ready_for_take; j; j = ready_for_take & (j - 1)) {
if (__builtin_popcount(j) <= k)
dp[i | j] = min(dp[i | j], dp[i] + 1);
}
}
return dp[cap - 1];
}
```
:::info
:angel: `interative-nos1.c` 在 LeetCode 提交後的執行結果
Runtime: 80 ms
Memory Usage: 6.1 MB
:::
我們可改良上述的非遞迴的實作,如下: (`iterative-nos2.c`)
```cpp
#define min(a, b) (((a) < (b)) ? (a) : (b))
int minNumberOfSemesters(int n,
int **dependencies,
int dependenciesSize,
int *dependenciesColSize,
int k)
{
const size_t cap = 1 << n;
/* pre[i]: the bit representation of all dependencies of course i */
uint16_t pre[cap];
memset(pre, 0, sizeof(pre));
for (int i = 0; i < dependenciesSize; i++)
pre[dependencies[i][1] - 1] |= 1 << (dependencies[i][0] - 1);
/* i is the bit representation of a combination of courses.
* dp[i] is the minimum days to complete all the courses.
*/
uint8_t dp[cap];
memset(dp, INIT, sizeof(dp));
dp[0] = 0;
uint8_t count[cap];
memset(count, 0, sizeof(count));
for (uint32_t i = 0; i < cap; i++) {
for (uint32_t j = 0; j < n; j++)
if (i & (1 << j))
count[i]++;
}
for (uint32_t i = 0; i < cap; i++) {
uint32_t b = 0;
/* figure out "b", the bit representation of the all courses
* that we can study now. Since we have i and pre[j], we know
* course j can be studied if i contains all its prerequisites.
*/
for (uint32_t j = 0; j < n; j++) {
if (COND && (pre[j] & i) == pre[j])
b |= 1 << j;
}
if (count[b] <= k) {
dp[i | b] = min(dp[i | b], dp[i] + 1);
} else {
for (uint32_t j = b; j; j = (j - 1) & b) {
if (count[j] == k)
dp[i | j] = min(dp[i | j], dp[i] + 1);
}
}
}
return dp[cap - 1];
}
```
:::info
:angel: `interative-nos2.c` 在 LeetCode 提交後的執行結果
Runtime: 44 ms
Memory Usage: 6.1 MB
:warning: 作答時請務必考慮到相對的效能表現
:::
請考慮到程式碼的正確和效率表現,補完程式碼。
INIT = ?
* `(a)` 0x5F
* `(b)` 0
* `(c)` 1
* `(c)` 2
* `(d)` 4
* `(e)` 8
COND = ?
* `(a)` 1
* `(b)` `!(i & (1 << j))`
* `(c)` `(i | (1 << j)`
* `(d)` `(i & (1 << j)`
* `(e)` `(i ^ (1 << j)`
參考資訊:
* [LeetCode 1494. Parallel Courses II 解說和答題錄影](https://youtu.be/p0wmkvWY_uE)
:::success
延伸問題:
1. 解釋上述程式碼運作原理,為何 `interative-nos2.c` 會比 `interative-nos1.c` 有更短的執行時間呢?`interative-nos2.c` 可如何改進?
2. 嘗試實作不同上述的程式碼 (限制為 C99/C11 + GNU extensions),應比較遞迴和非遞迴的形式在效能的落差
3. 分析上述 (包含你的改作練習) 程式碼的時間和空間複雜度
4. 練習 LeetCode [1595. Minimum Cost to Connect Two Groups of Points](https://leetcode.com/problems/minimum-cost-to-connect-two-groups-of-points/)
:::
---
### 測驗 `2`
Linked list (鏈結串列) 允許時間複雜度為 $O(1)$ 的隨機插入 (insert) 及隨機移去 (remove) 節點,但搜尋卻需要 $O(n)$ 時間複雜度。假設一個 linked list 內容如下:
$$
HEAD \to 1 \to 10 \to 18 \to 38 \to 22 \to 39 \to 47 \to 59 \to next
$$
從開頭 (即上方 `HEAD`) 找起,`59` 這個節點需要存取或比對 7 次才能找到,你直覺可能想到[二分法](https://en.wikipedia.org/wiki/Binary_search_algorithm),但對於 linked list 來說,隨機存取節點的時間複雜度仍是 $O(n)$,也就是說,二分法無法帶來效益。
我們可在鏈結串列中,增加額外的 "skip" (作用是提供資料存取的捷徑) 節點,如下:
![](https://i.imgur.com/3NsWOgj.png)
> "skip" 一詞在英語的意思很多,這裡取[韋伯字典](https://www.merriam-webster.com/dictionary/skip)提出的解釋: "to bound off one point after another"
如此一來,我們就可依據 "skip" 節點查詢,只需要比對 3 次即可找到上述的 `59`。至於若想搜尋 `47`,我們先根據 "skip" 節點來比對,於是在節點 `22` 上,它的 "skip" 指標會指向 `59`,後者大於 `47`,因此我們可知,`47` 可能會存在於節點 `22` 和節點 `59` 之間,這時候再根據鏈結串列順序搜尋,一共要比對 5 次。
隨著節點的增多,我們的 "skip" 鏈結串列會變成這樣:
![](https://i.imgur.com/L8be57J.png)
"skip" 節點的密度約為普通節點的一半,能否再提高密度呢?我們可在這基礎上再加一層新的 "skip" 節點,這層節點的密度為第一層 "skip" 節點的一半。
![](https://i.imgur.com/Rfo7Lkg.png)
換個視角,呈現如下:
![](https://i.imgur.com/h7ItYq9.png)
我們再給予新的限制:每層的 "skip" 節點都由它下一層的 "skip" 節點中產生,最終我們可得類似下圖的 "Skip List":
![](https://i.imgur.com/jYvCqBu.png)
> Skip List 示意圖
於是,我們不再區分每層是原節點還是 "skip" 節點,而將最底下的那層節點通稱為第一層 (level 1) 節點,第一層節點上面為第二層 (level 2) 節點,再來是第三層,以此類推。假設每層的節點數為下一層的一半,於是搜尋的時間複雜度為 $O(\log n)$。
一般的 linked list 搜尋時必須從第一個開始,按照順序一個一個搜尋,因此不論是搜尋或存取的時間複雜度皆為 $O( N)$,而 [**Skip list**](https://en.wikipedia.org/wiki/Skip_list) 可將存取、搜尋、新增和刪除等操作的平均時間複雜度降到 $O(\log N)$
> [Know Thy Complexities!](https://www.bigocheatsheet.com/)
skip list 是種針對 sorted linked list 的資料結構,透過不同 level 做出不同的 linked list 達到加速搜尋。
舉例來說,當我們想在上方 Skip List 示意圖中搜尋 `7`,步驟如下:
1. 從 level 4 (圖中左上方) 比對,因 `1 <= 7` 得知 level 4 不存在 `7`,但其他 level 可能具備。於是跳到 level 3 繼續找
2. 從 level 3 比對,因 `4 <= 7` 且 `6 <= 7` 得知 level 3 一樣不存在 `7`,於是跳到 level 2 繼續找
3. 從 level 2 比對,因 `9 >= 7` 表示此 level 不存在 `7`,跳去 level 1
4. 在 level 1 比對,發現 `7`
總共比對為 5 次,比直接循序查詢(需要 7 次存取) 還快。
如之前所及,skip list 是具有分層結構的鏈結串列,那麼每層的節點該如何產生呢?
我們可在新增元素時,使用隨機方法 (稱作 "coin flip",也就是「擲硬幣看正反面結果」) 決定這個元素有幾層節點。設定該元素僅有 1 層節點機率為 $\frac{1}{2}$,且有 2 層節點機率為 $\frac{1}{4}$,僅有 3 層節點機率為 $\frac{1}{8}$,以此類推。然後觸發隨機事件,當機率為 $\frac{1}{2}$ 的事件發生時,該元素有 1 層節點,機率為 $\frac{1}{2}$ 的事件發生時,該元素有 2 層節點,以此類推。另外,我們限定一個 "skip" 表格應當具有最大的層數限制。
假設一個 "skip" 表格最大層數限制為4,於是我們可設定一個整數區間為$[1, 2^{4-1}]$,即 $[1, 8]$。然後取一個介於 1 到 8 之間的隨機數,當落在 $[5, 8]$ 區間時有 1 層節點,落在 $[3, 4]$ 區間時有 2 層節點,落在 $[2, 2]$ 區間時有 3 層,落在 $[1, 1]$ 上時有 4 層。
總結 Skip List 的特徵:
* 第一層包含所有的元素
* 每一層都是個有序的 linked list
* skip list 是隨機產生,所以即使元素完全相同的 linked list ,最終的生成的 skip list 有可能不一樣
* 如果節點 x 出現在第 i 層,則所有比 i 小的層都包含 x
建立 skip list 的流程:
1. 最底層 (level 1) 是包含所有元素的 linked list
2. 保留第一個和最後一個元素,從其他元素中隨機選出一些元素形成新的一層 linked list
3. 為剛選出的每個元素新增指標,指向下一層和自己相等的元素
4. 重複上述 (2) 和 (3) 步驟,直到不再能選擇出除了第一個和最後一個元素以外的元素
下方動畫展示在 Skip List 新增節點:
![](https://upload.wikimedia.org/wikipedia/commons/2/2c/Skip_list_add_element-en.gif)
Google 的開放原始碼 Key-Value storage (可簡稱 KV) 專案 [LevelDB](https://github.com/google/leveldb) 和 Facebook 延伸 LevelDB 而發展出的 [RocksDB](https://rocksdb.org/),都用到 Skip List 的變形。[Redis](https://redis.io/) 內建一個高效率的 Skip List 實作。
延伸閱讀:
* [Skip 視覺化呈現](https://people.ok.ubc.ca/ylucet/DS/SkipList.html)
* [Skip List](https://www.jianshu.com/p/9d8296562806)
下方程式嘗試實作一個 Skip List,並進行以下變更:
* 將 "coin flip" 改為插入節點前,事先建立
* "coin flip" 原本透過機率,現在取代為特定的雜湊 (hash) 函數,此處選用 [djb2](http://www.cse.yorku.ca/~oz/hash.html)
* Skip List 的高度原本是隨機結果,但透過雜湊函數,得到一個相依於資料分布的實作
此實作以 C99 的前置處理器開發,如下 (`list.h`):
```cpp
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#define LIST_MAX_HEIGHT 32
typedef unsigned long custom_t; /* specify user-defined type */
typedef struct __list_node {
uint8_t *key;
size_t key_len;
uint8_t height;
custom_t val;
struct __list_node *next[1];
} list_node_t;
typedef struct {
uint8_t height;
uint32_t bits, reset;
list_node_t *head;
} skiplist;
#define LIST_ALLOC(e, s) \
do { \
e = malloc(s); \
assert(e && "out of memory."); \
memset(e, 0, s); \
} while (0)
#define LIST_HEIGHT(d, str, l, z) \
do { \
bool flip = false; \
for (z = 0; !flip; z++) { \
if (!d->reset) { \
d->bits = 5381; /* djb2 hash */ \
for (size_t i = 0; i < l; i++) \
d->bits = ((d->bits << 5) + d->bits) + str[i]; \
d->reset = LIST_MAX_HEIGHT; \
} \
flip = (bool) d->bits & 1; \
d->bits >>= 1; \
--(d->reset); \
} \
} while (0)
#define LIST_INIT(d) \
do { \
LIST_ALLOC(d, sizeof(skiplist)); \
LIST_ALLOC(d->head, (sizeof(list_node_t) + ((sizeof(list_node_t *)) * \
(LIST_MAX_HEIGHT - 1)))); \
d->height = 0; \
} while (0)
#define LIST_GET(d, k, l, v) LIST_FIND(d, k, l, v, NULL)
#define LIST_FIND(d, k, l, v, u) \
do { \
list_node_t *iterator = d->head, **ud = u; \
for (int i = d->height - 1; i >= 0; --i) { \
while (iterator->next[i] && \
memcmp(k, iterator->next[i]->key, l) > 0) \
iterator = iterator->next[i]; \
if (ud) \
ud[i] = iterator; \
} \
list_node_t *n = iterator->next[0]; \
v = (n && l == n->key_len && !memcmp(k, n->key, l)) ? n->val : 0; \
} while (0)
#define LIST_NODE_INIT(n, k, l, v, h) \
do { \
LIST_ALLOC( \
n, (sizeof(list_node_t) + ((sizeof(list_node_t *)) * (h - 1)))); \
LIST_ALLOC(n->key, l + 1); \
memcpy(n->key, k, l); \
n->val = (custom_t) v, n->height = h, n->key_len = l; \
} while (0)
#define LIST_INSERT(d, k, l, v) \
do { \
custom_t vl; \
list_node_t *u[LIST_MAX_HEIGHT]; \
LIST_FIND(d, k, l, vl, u); \
if (vl) \
break; \
int h; \
LIST_HEIGHT(d, k, l, h); \
if (h > d->height) { \
HHH; \
u[h - 1] = d->head; \
} \
list_node_t *n; \
LIST_NODE_INIT(n, k, l, v, h); \
while (--h >= 0) { \
n->next[h] = u[h]->next[h]; \
u[h]->next[h] = n; \
} \
} while (0)
#define LIST_ITERATE(d, callback) \
do { \
assert(callback); \
list_node_t *iterator = d->head; \
while (iterator->next[0]) { \
III; \
} \
} while (0)
#endif
```
其中 [djb2](http://www.cse.yorku.ca/~oz/hash.html) 的作用是產生隨機分佈的的雜湊函數,常見的形式為:
$$
f(hash)= hash \times 33 + c
$$
為何選擇 `33` 呢?
1. 乘法易於位移或相加
2. 從位移和加法實作中可見,使用 `33` 可複製雜湊累加器中的大多數輸入位元,並將這些位元相對地分散開來
3. $32 = 2^5$,32 與位移 5 位元相關,有助於將每個字串的每個位元都用到最終的雜湊數值中
至於為何選用初始的 `5381` 呢?這沒有太大影響,其他質數數也可很好地執行。除了 djb2,可考慮其他雜湊函數,如:
* [MurmurHash](https://en.wikipedia.org/wiki/MurmurHash)
* [xxHash](https://github.com/Cyan4973/xxHash)
針對上述 `list.h` 撰寫的測試程式如下:
```cpp
#include <stdio.h>
#include "list.h"
static void print_key(list_node_t *n) {
puts((const char *) n->key);
}
int main()
{
/* initialize the list */
skiplist *d;
LIST_INIT(d);
/* create a key */
char *key = malloc(6);
strncpy(key, "hello", 5);
key[5] = 0;
/* create a value */
char *val = malloc(6);
strncpy(val, "world", 5);
val[5] = 0;
/* insert into the list */
int l = 5;
LIST_INSERT(d, key, l, (custom_t) val);
/* iterate through the list and print */
LIST_ITERATE(d, print_key);
/* retrieve from a list */
custom_t v;
LIST_GET(d, key, l, v);
printf("Hello, %s!\n", (char *) v);
return 0;
}
```
參考執行輸出:
```
hello
Hello, world!
```
請補完 `list.h` 程式碼,使測試程式執行符合預期。我們假設所有記憶體配置都可成功。
==作答區==
HHH = ?
* `(a)` `h = d->height`
* `(b)` `h = (d->height)++`
* `(c)` `h = (d->height)--`
* `(d)` `h = ++(d->height)`
* `(e)` `h = --(d->height)`
III = ?
* `(a)` `iterator = iterator->next[0]; callback(iterator)`
* `(b)` `callback(iterator); iterator = iterator->next[0]`
* `(c)` `if (callback) callback(iterator)`
:::success
延伸問題:
1. 解釋上述程式碼運作原理,探討用 hash 替換 Skip List 裡頭 random 的效益和潛在的議題,並實作 delete 操作
2. 研讀 [Cache-Sensitive Skip List: Efficient Range Queries on modern CPUs](https://www2.informatik.hu-berlin.de/~sprengsz/papers/cssl.pdf),指出考慮到現代處理器架構的 Skip List 實作,有哪些考量和取捨
* [簡報檔案](http://www.adms-conf.org/2016/cssl-imdm.pdf)
* 參考實作: [Cache-Sensitive Skip List (CSSL)](https://github.com/flippingbits/cssl)
3. 練習 LeetCode [1206. Design Skiplist](https://leetcode.com/problems/design-skiplist/),除了提交 C 語言的實作並通過自動評分系統,也該提供正確性測試和效能分析
* 參見 [Skip List](https://github.com/yfernandezgou/skip-list-c)
:::
---