contributed by < shauming1020
>
homework
待釐清的議題 quiz8
測驗 1
recursive-nos1.c
interative-nos1.c
和 interative-nos2.c
之間的差異,為何 interative-nos2.c
執行時間較短呢 ?測驗 2
LeetCode 1494. Parallel Courses II 給定一整數 表示某所大學裡課程的數目,編號為 到 ,給定陣列 dependencies
,使得 表示先修課的關係,亦即課程 必須在課程 之前選修並通過。另外,給予一整數 ,表示在一個學期裡頭,你最多可同時選修 門課,前提是這些課的先修課在之前的學期裡已修過。程式應該回傳上完所有課最少需要多少個學期。題目保證一定存有一種修完所有課的方式。
範例 1.
給定輸入:
n = 4 (即 4 門課程)
dependencies =即選修
1
這門課,應當先修過2
和3
這二門課,又,選修4
之前應當先修過1
這門課
k = 2 (一個學期中,你至多可同時選修 2 門課程)預期輸出 : 3
上圖展現可能的修課方式: 第一個學期選修課程2
和課程3
,然後第二個學期選修課程1
,第三個學期選修課程4
。
範例 2.
給定輸入:
n = 5 (即 5 門課程)
dependencies =即選修
1
這門課,應當先修過2
,3
和4
這三門課,又,選修5
之前應當先修過1
這門課
k = 2 (一個學期中,你至多可同時選修 2 門課程)預期輸出: 4
上圖展現可能的修課方式: 第一學期選修課程2
和3
(注意一學期僅能同時選修2
門課程),第二學期選修課程4
,第三學期選修課程1
,第四學期選修課程5
本題限制:
Dynamic Programming(DP) 本質是暴力法 (brute force; complete search),但因它採用表格欄位去紀錄答案 (memorization),所以每個 subproblem 僅運算一次即可。
狀態壓縮是利用二進位來表達中間的狀態,也就是說,狀態只能是 0 或 1,若用集合來思考,就是「在」或「不在」集合中。此手法適用於資料量不大的案例。先用陣列儲存之前的狀態,再由狀態及其對應的數值,推導出狀態轉移方程式,最終可得適當的解法。
以本題來說, 至多為 ,而且一門課「選」或者「不選」即可運用二進位的位元表示,符合上述的狀態壓縮技巧。演算法如下:
pre
而 Dynamic Programming(DP) 在本題的使用時機為 …
解釋上述程式碼運作原理,為何 interative-nos2.c 會比 interative-nos1.c 有更短的執行時間呢?interative-nos2.c 可如何改進?
我們先從遞迴版本 recursive-nos1.c
來開始理解
範例 1
n = 4,k = 2,dependencies =
狀態 cap
= 0010 表示已修完課程 2
,cap
= 1111 表示已修完所有課程。
宣告 pre[cap]
來記錄所有狀態 cap 的先修課程,其中 cap
大小為 ,
用 OR 運算來選課,例如 pre[0] = 0110,表課程 1
的先修課為 2
和 3
,,
pre[3] = 0001,表課程 4
的先修課為 1
,而其他狀態( 如沒有先修課的課程) 皆被設成 0
。
Note: 共有 n
門課,因此將 pre 的大小設為 n
即可。
使用陣列 dp
來存放已計算過的某狀態所需最少修課學期,
例如 dp[2] = dp[0010] = 1
,表修完課程 2
最少需要 1
個學期,而當修完課程 1
時,也已完成先修課 2
和 3
,
因此 dp[7] = dp[0111] = 2 = min(dp[0111], dp[0110] + 1)
,dp[1] = dp[0001] = NULL
,
故 dp[15] = dp[1111] = dp[cap - 1]
則是我們欲求的修完所有課程所需最少學期數。
由於我們要找出最小值,因此初始化 dp 的每個值為極大值 INT_MAX
。
遍歷所有修課狀態 s0
,0000 -> 0001 -> 0010 -> 0011 -> … -> 1111
s0
為合法的狀態,其 dp[s0] 不會是初值 INT_MAX
,因為已經透過 solve
函式解出其最少所需的學期數並存於 dp 中。s0
表當前已修完課程的狀態,s1
表 s0
可選的新課程,例如 s0 = 0000, s1 = 0110
、s0 = 0100, s1 = 0010
、s0 = 0110, s1 = 0001
接下來從狀態 s0
開始遍歷,從可選的新課程 s1
中選至多 k
門課得到欲選課程 s
,而修了課程後的狀態 [s0|s
] ,其最少修課學期數為:比較修課狀態 s0
的最少修課學期數 +1
與原 [s0|s
] 的最少修課學期數。
引數說明 :
1. 位移 i
位元來檢查 s1
還有哪些課可選 (s1 >> i) & 1
,有的話則選這些課 s | 1 << i
來繼續遞迴求解
2. 從可選的新課程 s1
中選了s
課 (也可不選,即 s
= 0000)
3. 尚可修 k
門課
4. 共 n
門課,因此至多位移 n
位元
5. 從修課狀態 s0
開始遍歷
6. s1
為 s0
可選的新課程
e.g.
接下來探討非遞迴版本 iterative-nos1.c
範例 2
n = 5,k = 2,dependencies =
與遞迴版本一樣, dp
紀錄狀態 cap
所需最少學期個數,用 1<<6 = 64
作為 dp 的初始值, prerequisite
紀錄狀態 cap
的先修課程。
遍歷所有狀態,根據當前狀態 i
下學期可選的課程 ready_for_take
,去求出選了其中至多 k
門課的最少學期數。
逐一比對狀態 i
是否已包含課程 j
的先修課程條件,滿足的話則將 j
課納入候選課名單 ready_for_take
,否則,將那些沒有先修課的課程納入候選名單,
因此執行完 ready_for_take &= ~i
會得到狀態 i
的下學期可選課程,
e.g.
修課狀態 i = 01110
符合課程 j = 0
的先修課條件 prerequisite[0] = 01110
,因此 ready_for_take = 01111
,ready_for_take&= ~i => 00001
,
修課狀態 i = 00110
皆不符合課程 j = 0, 4
的先修課條件,因此將沒有先修限制的課程納入 ready_for_take = 01110
,ready_for_take&= ~i => 01000
。
for (j = ready_for_take; j; j = ready_for_take & (j - 1))
列出 ready_for_take 所有位元 1 的位置排列組合
e.g.
j = 11110 = ready_for_take
j = 11100
j = 11010
…
j = 00100
j = 00010
從可選課程 ready_for_take
中挑出滿足至多同時選課數 k
的課程,計算選了這些課的修課狀態 [i | j]
之最少所需學期數。
不合法修課的狀態如 i = 00001, i = 11000, i = 10010, ..., etc
,其 dp 值始終為初始值 64
,這是因為唯有滿足先修課條件的課程狀態才有機會更新 dp 值,而一切都從初始課程狀態 i = 00000
開始,其 dp 值為 0。
參數說明:
1. 修課狀態 i
2. 下學期可選課程 ready_for_take
比較另一個非遞迴版本 iterative-nos2.c
pre 紀錄狀態 cap
的先修課程,用 INIT 作為 dp 的初始值,count 紀錄每個狀態有幾個位元 1。
e.g.
Note: 題目有給予課程數量的限制:至多 15 門課,因此最壞情況為每門課恰只有一門先修課,至多需要花費 15 學期,如此一來 INIT 只需大於等於 0xF 即可,選項中只有 0x5F 滿足此條件。
對修課狀態 i
計算下學期可選課程 b
,即非遞迴版本 1 的 ready_for_take
,
若滿足下列條件
i
已完成課程 j
的先修課程條件。j
還沒選,即 COND。則將課程 j
納入候選課名單 b |= 1 << j
。
與版本 1
不同,透過預先計算好所有狀態的課程數 count
,即位元 1 的個數,利用查表的方式來避免多次 __builtin_popcount
的運算,因此會比 iterative-nos1.c
快。
改進 iterative-nos2.c
在 LeetCode 提交後的執行結果
Runtime: 4 ms
Memory Usage: 5.9 MB
count
if (dp[i] == 0xf) continue;
。if (k == 1) return n;
。嘗試實作不同上述的程式碼 (限制為 C99/C11 + GNU extensions),應比較遞迴和非遞迴的形式在效能的落差
不使用 DP 的話,可以用比較直覺的深度或廣度優先搜尋演算,去搜尋最少完成修課學期數。
範例 2
n = 5,k = 2,dependencies =
BFS (非遞迴)
在 LeetCode 提交後的執行結果
Runtime: 936 ms
Memory Usage: 6.1 MB
DFS (遞迴)
在 LeetCode 提交後的執行結果
Runtime: 716 ms
Memory Usage: 6.3 MB
分析上述 (包含你的改作練習) 程式碼的時間和空間複雜度
bfs
dfs
recursive-nos1.c
iterative-nos2.c
練習 LeetCode 1595. Minimum Cost to Connect Two Groups of Points
給予兩群的節點,第一群有 size1
個節點,第二群有 size2
個節點,其中 size1 >= size2
。
在這兩群的節點之間有權重邊相連,cost[i][j]
為第一群的第 i
節點與第二群的第 j
節點相連之權重,如果兩群中的每個節點都與另一群的一個或多個節點連接,則稱這兩群是連通的,換言之,第一群中的每個節點必須至少與第二群中的一個節點連接,且第二群中的每個節點必須至少與第一群中的一個節點連接。
求出連接兩群的最低成本。
範例 1
Input: cost = [[15, 96], [36, 2]]
Output: 17
Explanation: The optimal way of connecting the groups is:
1–A
2–B
This results in a total cost of 17.
範例 2
Input: cost = [[1, 3, 5], [4, 1, 1], [1, 5, 3]]
Output: 4
Explanation: The optimal way of connecting the groups is:
1–A
2–B
2–C
3–A
This results in a total cost of 4.
Note that there are multiple points connected to point 2 in the first group and point A in the second group. This does not matter as there is no limit to the number of points that can be connected. We only care about the minimum total cost.
範例 3
Input: cost = [[2, 5, 1], [3, 4, 7], [8, 1, 2], [6, 2, 4], [3, 8, 8]]
Output: 10
限制
- size1 == cost.length
- size2 == cost[i].length
- 1 <= size1, size2 <= 12
- size1 >= size2
- 0 <= cost[i][j] <= 100
解題
最小生成樹 MST
將兩群視為一張無向圖,初始取的邊集合 S = {},每次從圖中取一最小邊並檢驗取的邊加到 S 中是否會形成 cycle,若會形成 cycle 則不取此邊,直到圖中的每個邊皆被檢驗過,得到一個邊的總和為最小的樹。
而在此題最優解未必為一個樹,反例如下
若以 MST 的解法會取 2-B 使得取出的所有邊形成一個樹,但最佳解不需取 2-B ,且最後會形成一個森林 (T1: 1-A-2-C, T2: 3-B)。
Dynamic Programming (DP)
定義 dp[i][j] 表在第一群的前 i 個節點與第二群的前 j 個節點之最低連接成本,但這樣無法透過解子問題來求,反例如下
最佳解為 dp[3][3]: {1-A, 2-C, 3-B},但解子問題 dp[2][3]: {1-A, 2-C, B} 時,B 必須與 3 相連,否則孤立的 B 會使兩群不連通。
狀態壓縮 + Dynamic Programming (DP)
定義 dp[i][j] 表在第一群的前 i 個節點與第二群所取的節點集合狀態 s 之最低連接成本,例如 s=101 表只取 A 和 C 節點,因此問題則是從 dp[0][0] = 0 開始遍歷所有 i, s ,而最後 dp[m][(1<<n)-1] 表第一群的所有 m 個節點與第二群所有的節點之最低連接成本,即為所求。
狀態轉移如下:
dp[i][s | (1<<j)]
表在第二群當前所取節點狀態 s 中加入第 j 個節點,與第一群前 i 個節點之最低成本dp[i-1][s] + cost[i][j]
表第 i 個節點與第 j 個節點都沒被取過,因此取完看看是否會為最小。dp[i][s] + cost[i][j]
表已取完第 i 個節點但第 j 個節點還沒被取。以下圖為例
參考素材: 花花酱 LeetCode 1595. Minimum Cost to Connect Two Groups of Points - 刷题找工作 EP358
程式碼
Runtime: 20 ms, faster than 100.00% of C online submissions for Minimum Cost to Connect Two Groups of Points.
Memory Usage: 6.2 MB, less than 100.00% of C online submissions for Minimum Cost to Connect Two Groups of Points.
Skip List 新增節點:
list.h
測試程式
解釋上述程式碼運作原理,探討用 hash 替換 Skip List 裡頭 random 的效益和潛在的議題,並實作 delete 操作
探討測試程式
key
, val
> = <"hello"
, "world"
>。LIST_INSERT(d, key, l, (custom_t) val)
對 skip list : d 插入鍵值對 <key
, val
>,其中鍵 : key 的長度 : l。LIST_ITERATE(d, print_key)
遍歷 skip list : d 並 print 出每個 node 的 key。 LIST_GET(d, key, l, v)
輸入 d, key, l
並賦予 v
為對應 key
的值。結構體
skiplist
__list_node
初始化
LIST_INIT
配置空間給 skip list
: d 以及 d 的 head node
,並初始 d 的高度為 0。
LIST_ALLOC
配置 size : s 大小的空間,若配置成功則回傳指向其空間的 pointer : e,並將值設為 0。
LIST_NODE_INIT
null terminator
) 給 n->key。搜尋
LIST_FIND
for (int i = d->height - 1; i >= 0; --i)
,利用 memcmp
來比對節點 : iterator
next 的 key 值,如果指向的下一個節點非空 iterator->next[i]
且 k
大於 iterator->next[i]->key
,則將 iterator
指向下一個節點 iterator = iterator->next[i]
。memcmp
回傳值 > 0 表 key: k
大於 iterator->next[i]->key
。iterator
節點指派 if (ud) ud[i] = iterator
,其中 u[i] 用來存放高度 i 指向目標節點的節點,而 ud 為指向 u 的 pointer to pointer 。 Find key : 3 之後, u =
iterator
為指向此目標節點的節點且 level 在最底層,因此 list_node_t *n = iterator->next[0]
, n 即為目標節點,若 n 為合法的節點時,將其 val
指派給 v
,否則 v
被指派為 0 。LIST_GET
將找到的 key
值 val
指派給引數 v
。
插入
LIST_HEIGHT
LIST_MAX_HEIGHT
設為初始值。for (z = 0; !flip; z++); flip = (bool) d->bits & 1;
,因此增加的次數(即高度) 為剩於的 d->reset 值。d->bits >>= 1;
並將 d->reset 減 1 --(d->reset);
,再回到 1. 和 2. 判斷。((d->bits << 5) + d->bits)
為 d->bits 的運算,其中 d->bits 採用質數 5381
作為初始值。LIST_INSERT
vl
不為 0,表不需要新增節點,離開迴圈。if (h > d->height)
,h 與 d->height 皆被設為 d->height + 1 (HHH 為 h = ++(d->height);
),並將 u[最高層] 指派為 head node,而此時 list_node_t 陣列 u 為存放指向 key : k 合法位置的節點。初始化節點 n 後,將 n 插入到合法的位置。
e.g. (例 1.)
Iterator
LIST_ITERATE
走訪 skip list : d 內的每一個節點去執行函式 : callback,而節點中的最底層指向最鄰近的節點,因此 III 為 iterator = iterator->next[0]; callback(iterator);
。實作刪除
索引中對應的節點需一併刪除。
先透過 LIST_FIND 搜尋目標鍵值 : k 有無存在 skip list : d 中,若不存在 vl 為 0,表不需要刪除,因此跳出迴圈即離開函式。
否則從 skip list 當前的高度開始往下走訪 u, u 為指向目標節點的 list_node_t 陣列,並將其 next 指向目標節點的 next 來達到刪除的效果,而在之前先用一個 list_node_t 指標 target 指向目標節點,並在最後釋放所指向的記憶體空間。
e.g. (以上圖為例,Find key : 9 之後, u 為 )
探討用 hash 替換 Skip List 裡頭 random 的效益和潛在的議題
本題將 hash function 作為 Pseudo Random Number Generator (PRNG) 來使用,實作的 github-skip list
Random level (coin flip)
Sample 出高度 z
的機率為 ,其中 z
= 1, 2, 3, …, LIST_MAX_HEIGHT - 1 。
於 LIST_INSERT 內替換掉 LIST_HEIGHT,並修改如下
srand(time(NULL));
使得每次建 list 的節點高度都不相同。Xorshift (LevelDB)
參考 grb72t3yde
同學對 LevelDB 研究的專題報告,嘗試實作 LevelDB 所使用到的 xorshift PRNG
Xorshift
效能比較
bench.c
(測試程式碼)使用
字母集為 10 個數字 + 26 個英文大小寫,共 62 個字母。
測試資料共一千萬筆,為隨機生成長度 MAX_LEN = 8
之內的字串 (其中可能含重複的字串)。
測試結果
分別測試 INSERT(INS)、FIND(F) 和 DELETE(DEL) 三種函式,插入、查詢和刪除所有節點。
一千萬筆測資 | 單位 msec |
---|---|
djb2 |
61875.333786 msec |
rand() |
16907.362938 msec |
xorshift |
19749.794960 msec |
一千萬筆測資 | 單位 msec |
---|---|
djb2 |
63355.950594 msec |
rand() |
18052.890301 msec |
xorshift |
24746.976852 msec |
一千萬筆測資 | 單位 msec |
---|---|
djb2 |
58632.202387 msec |
rand() |
17096.779823 msec |
xorshift |
19624.391794 msec |
Cache 表現
Valgrind 記憶體快照
djb2
rand()
xorshift
分析
Hash function 決定當前節點高度的方式為,如果 skip list 的 hash 值 : d->bits 為 0 時才會增加,而增加的次數為剩餘的 d->reset 值,當前 hash 值 d->bits 則當 d->reset 被減為 0 時才會重設,透過計算當前插入的節點鍵值之 hash 值來作為重設值。
因此,當 hash 值 : d->bits 之最左側位元 1 的位置 (即 LIST_MAX_HEIGHT - 1 -__builtin_clz(d->bits)
) 一直都大於 d->reset 時,會造成 d->bits 還來不及右移成 0 讓高度 : z 增加,而 d->reset 早已被減為 0,使得 d->bits 又被重設,因此大多節點的高度仍維持在低層,建出來的 skip list 偏向單向鍊結而失去 skip 的功能,所以插入/搜尋/刪除 n 筆資料的成本會退化為 。
改進 Hash function
盡量讓 d->bits 最左側位元 1 的位置少於 d->reset 值,因此將 d->bits 初始值 5381
改為 2
,並每次右移 d->bits 2
個位元。
一千萬筆測資 | 單位 msec |
---|---|
Insert | 29932.145596 msec |
Find | 34056.064129 msec |
Delete | 29762.346745 msec |
增加右移量使 d->bits 比 d->reset 提早為 0,增加節點高度。
c 語言的 rand() 為 Linear congruential generator,優點在於速度快但週期短缺乏安全性,只要取得亂數種子即可預測接下來的亂數值。
a 16-bit LCG with high 8-bit output
from Visualizing the Heart of Some PRNGs
Xorshift 則是 Cryptographically secure pseudorandom number generator 當中執行最快速的,具有 "隨機性:看起來夠亂,沒有規律,所有數字分佈平均 以及 不可預測性:無法從之前的亂數數列猜出下一個亂數的值" 的特性,因此相較於 rand() 更具安全性。
Example random distribution of Xorshift128
from wiki-Xorshift
採用 hash function 的效益與潛在議題
Pseudo random number generator (PRNG) 是一種生成近似隨機序列的演算法,但生成結果會相依於初始值 (亂數種子, seed),只要能取得亂數種子就有可能預測接下來的亂數,且參數若沒有設定好則可能導致數值出現的週期過短,常常輸出重複值;
而 hash function 則是可以根據某次輸入的資料來輸出亂數值,因此當輸入資料範圍大且輸入順序不可預測時,輸出的亂數值則可以夠亂,但潛在的風險是過於依賴資料分佈,若資料彼此之間太相近則會輸出相同值,造成節點高度大都一樣,使 skip list 退化成一般的 linked list;
Skip list 是一種概率的資料結構,根據 PRNG 來決定整體結構,因此當生成的亂數值不夠亂的時候則很容易使節點分佈過於一致進而降低效能。
研讀 Cache-Sensitive Skip List: Efficient Range Queries on modern CPUs,指出考慮到現代處理器架構的 Skip List 實作,有哪些考量和取捨
另外撰寫 CSSL,摘要如下
採用上述的 fat keys (節點內包含許多資料,如指向每個通道下個節點的指針以及數組資料) 來實作 skip list ,由於節點皆一致因此具有方便實作的優點,但缺乏 locality 的性質故不利於 cache 的加速,例如一個具有 5 個通道且每個節點存放一個 32-bit 整數鍵值的 skip list,則每個節點需佔用 4 + (5+1) * 8 = 52 bytes 的空間大小,且假設 cache line 為 64 bytes ,因此遍歷一個節點幾乎佔用了整個 cache line 的空間,且指針指向的位址大多皆不為連續的,從而導致大量的 cache miss 。
Cache-Sensitive Skip List (CSSL) 基於 balanced skip list 作為骨架,將每層的通道採用陣列來實作,由於 balanced skip list 的第 i + 1 level 通道為 level i 通道的每 1/p 位的元素所組成,例如下圖 p = 0.5 , Level 2 的元素為 Level 1 的每 1/0.5 = 2 位的元素 (1, 5, 7, …) 所組成,因此通道的元素數量是已知的,由此也可簡單的計算出後續元素位置,而採用陣列來實作具有 spatial locality 的特性,減少 cache miss 的發生,同時增加更多的空間利用性,上述/傳統的實作使得大部分節點的指針指向 NULL,需要 的空間,而 CSSL 只須 ,其中 n 為資料量、m 為通道數。
陣列實作的通道可支援 SIMD 暫存器使 skip list 的操作可以並行處理。
雖然 CSSL 可以較有效地利用 cache ,但仍需考慮一些情況使用,如陣列要求配置連續的記憶體空間,因此當可用的記憶體空間過於分散時,則可能會配置失敗或是需要減少通道數,導致效能降低,而插入的鍵值比當前最大鍵值還大時,則需要重新配置通道陣列,因此若在不確定範圍數值的情況下使用則可能花費大量的重新配置通道時間成本。
練習 LeetCode 1206. Design Skiplist,除了提交 C 語言的實作並通過自動評分系統,也該提供正確性測試和效能分析