sysprog2020
目的: 檢驗學員對 dynamic programming, bitwise 操作, linked list 和非連續記憶體操作 的認知
1
在講解題目之前,先看經典的 Coin Problem: 給予一組硬幣 ,若金額 ,如何找出最少的硬幣組成呢?硬幣可重複選取。
最佳解為:
貪婪演算法 (greedy algorithm) 的做法是從最大硬幣開始選取,直到 520
被湊出來,其中 僅會在最佳解出現一次。
dynamic programming 的本質是暴力法 (brute force; complete search),但因它採用表格欄位去紀錄答案 (memorization),所以每個 subproblem 僅運算一次即可。
簡化問題,給定 及 ,因為至少需要 3 種硬幣才能得到總和 10
,於是最佳解為 .
我們可依據條件寫成等式 :
擴展成通式:
對應的遞迴求解程式如下: (C++
)
注: 此處的
INF
可用INT_MAX
實作
上述程式碼會耗費指數時間 (exponential time) 以計算 ,於是我們可利用 手法,將把子問題 (subproblem) 的解保存下來,避免每次都要重算,如此我們可建構 recursion + memoziation 的程式碼,如下:
可改寫為迴圈形式的程式碼,從而避免遞迴:
倘若我們想輸出 ,該如何做?
我們可用一個陣列去記錄每個最佳子問題的第一個硬幣為何,依據貪婪演算法,第一個硬幣也會是最大的硬幣,因此拼湊出來即是最佳解的全部硬幣組合。將 value[x-c]+1 < value[x]
這條件從 min()
搬移到 if
敘述:
隨後我們再將組合印出:
接著我們又可擴充題目,計算出 solve(x)
有多少種硬幣組合方式,以 solve(5)
為例,可能的組合如下:
用上述的條件,令 ,則 對應的遞迴表示如下:
通式如下,要留意到「不放」也算是其中一種方法:
對應的程式碼如下:
若我們不想算出全部 solve(x)
的解,可運用模數運算 (modulo) 來化簡。舉例來說,令 ,在 count[x] += count[x - c]
敘述增加模數運算,如:
參考資訊:
LeetCode 1494. Parallel Courses II 給定一整數 表示某所大學裡課程的數目,編號為 到 ,給定陣列 dependencies
,使得 表示先修課的關係,亦即課程 必須在課程 之前選修並通過。另外,給予一整數 ,表示在一個學期裡頭,你最多可同時選修 門課,前提是這些課的先修課在之前的學期裡已修過。程式應該回傳上完所有課最少需要多少個學期。題目保證一定存有一種修完所有課的方式。
給定輸入:
1
這門課,應當先修過 2
和 3
這二門課,又,選修 4
之前應當先修過 1
這門課預期輸出: 3
2
和課程 3
,然後第二個學期選修課程 1
,第三個學期選修課程 4
。給定輸入:
1
這門課,應當先修過 2
, 3
和 4
這三門課,又,選修 5
之前應當先修過 1
這門課預期輸出: 4
2
和 3
(注意一學期僅能同時選修 2 門課程),第二學期選修課程 4
,第三學期選修課程 1
,第四學期選修課程 5
本題的限制:
注意本題是 NP 問題,運用貪婪演算法 (greedy algorithm) 很容易遇到 TLE (time limit exceeded),於是我們可嘗試狀態壓縮和 dynamic programming 結合的技巧。狀態壓縮是利用二進位來表達中間的狀態,也就是說,狀態只能是 0 或 1,若用集合來思考,就是「在」或「不在」集合中。此手法適用於資料量不大的案例。先用陣列儲存之前的狀態,再由狀態及其對應的數值,推導出狀態轉移方程式,最終可得適當的解法。
以本題來說, 至多為 ,而且一門課「選」或者「不選」即可運用二進位的位元表示,符合上述的狀態壓縮技巧。演算法如下:
pre
對應的遞迴程式碼如下: (recursive-nos1.c
)
:angel: recursive-nos1.c
在 LeetCode 提交後的執行結果
Runtime: 124 ms
Memory Usage: 6.2 MB
我們可改寫為非遞迴的形式。先計算出每個數在二進位表示中有幾個 1
,亦即選修幾門課,保存於陣列 count[]
中,共有 種狀態。陣列 pre[]
表示特定的狀態所需要的前導課程。對應的程式碼如下: (iterative-nos1.c
)
:angel: interative-nos1.c
在 LeetCode 提交後的執行結果
Runtime: 80 ms
Memory Usage: 6.1 MB
我們可改良上述的非遞迴的實作,如下: (iterative-nos2.c
)
: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)
8COND = ?
(a)
1(b)
!(i & (1 << j))
(c)
(i | (1 << j)
(d)
(i & (1 << j)
(e)
(i ^ (1 << j)
參考資訊:
延伸問題:
interative-nos2.c
會比 interative-nos1.c
有更短的執行時間呢?interative-nos2.c
可如何改進?2
Linked list (鏈結串列) 允許時間複雜度為 的隨機插入 (insert) 及隨機移去 (remove) 節點,但搜尋卻需要 時間複雜度。假設一個 linked list 內容如下:
從開頭 (即上方 HEAD
) 找起,59
這個節點需要存取或比對 7 次才能找到,你直覺可能想到二分法,但對於 linked list 來說,隨機存取節點的時間複雜度仍是 ,也就是說,二分法無法帶來效益。
我們可在鏈結串列中,增加額外的 "skip" (作用是提供資料存取的捷徑) 節點,如下:
"skip" 一詞在英語的意思很多,這裡取韋伯字典提出的解釋: "to bound off one point after another"
如此一來,我們就可依據 "skip" 節點查詢,只需要比對 3 次即可找到上述的 59
。至於若想搜尋 47
,我們先根據 "skip" 節點來比對,於是在節點 22
上,它的 "skip" 指標會指向 59
,後者大於 47
,因此我們可知,47
可能會存在於節點 22
和節點 59
之間,這時候再根據鏈結串列順序搜尋,一共要比對 5 次。
隨著節點的增多,我們的 "skip" 鏈結串列會變成這樣:
"skip" 節點的密度約為普通節點的一半,能否再提高密度呢?我們可在這基礎上再加一層新的 "skip" 節點,這層節點的密度為第一層 "skip" 節點的一半。
換個視角,呈現如下:
我們再給予新的限制:每層的 "skip" 節點都由它下一層的 "skip" 節點中產生,最終我們可得類似下圖的 "Skip List":
Skip List 示意圖
於是,我們不再區分每層是原節點還是 "skip" 節點,而將最底下的那層節點通稱為第一層 (level 1) 節點,第一層節點上面為第二層 (level 2) 節點,再來是第三層,以此類推。假設每層的節點數為下一層的一半,於是搜尋的時間複雜度為 。
一般的 linked list 搜尋時必須從第一個開始,按照順序一個一個搜尋,因此不論是搜尋或存取的時間複雜度皆為 ,而 Skip list 可將存取、搜尋、新增和刪除等操作的平均時間複雜度降到
skip list 是種針對 sorted linked list 的資料結構,透過不同 level 做出不同的 linked list 達到加速搜尋。
舉例來說,當我們想在上方 Skip List 示意圖中搜尋 7
,步驟如下:
1 <= 7
得知 level 4 不存在 7
,但其他 level 可能具備。於是跳到 level 3 繼續找4 <= 7
且 6 <= 7
得知 level 3 一樣不存在 7
,於是跳到 level 2 繼續找9 >= 7
表示此 level 不存在 7
,跳去 level 17
總共比對為 5 次,比直接循序查詢(需要 7 次存取) 還快。
如之前所及,skip list 是具有分層結構的鏈結串列,那麼每層的節點該如何產生呢?
我們可在新增元素時,使用隨機方法 (稱作 "coin flip",也就是「擲硬幣看正反面結果」) 決定這個元素有幾層節點。設定該元素僅有 1 層節點機率為 ,且有 2 層節點機率為 ,僅有 3 層節點機率為 ,以此類推。然後觸發隨機事件,當機率為 的事件發生時,該元素有 1 層節點,機率為 的事件發生時,該元素有 2 層節點,以此類推。另外,我們限定一個 "skip" 表格應當具有最大的層數限制。
假設一個 "skip" 表格最大層數限制為4,於是我們可設定一個整數區間為,即 。然後取一個介於 1 到 8 之間的隨機數,當落在 區間時有 1 層節點,落在 區間時有 2 層節點,落在 區間時有 3 層,落在 上時有 4 層。
總結 Skip List 的特徵:
建立 skip list 的流程:
下方動畫展示在 Skip List 新增節點:
Google 的開放原始碼 Key-Value storage (可簡稱 KV) 專案 LevelDB 和 Facebook 延伸 LevelDB 而發展出的 RocksDB,都用到 Skip List 的變形。Redis 內建一個高效率的 Skip List 實作。
延伸閱讀:
下方程式嘗試實作一個 Skip List,並進行以下變更:
此實作以 C99 的前置處理器開發,如下 (list.h
):
其中 djb2 的作用是產生隨機分佈的的雜湊函數,常見的形式為:
為何選擇 33
呢?
33
可複製雜湊累加器中的大多數輸入位元,並將這些位元相對地分散開來至於為何選用初始的 5381
呢?這沒有太大影響,其他質數數也可很好地執行。除了 djb2,可考慮其他雜湊函數,如:
針對上述 list.h
撰寫的測試程式如下:
參考執行輸出:
請補完 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)
延伸問題: