contributed by < JimmyLiu0530
>
進階電腦系統理論與實作
給定一整數 表示某所大學裡課程的數目,編號為 到 ,給定陣列 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
)
recursive-nos1.c
在 LeetCode 提交後的執行結果我們可改寫為非遞迴的形式。對應的程式碼如下: (iterative-nos1.c
)
iterative-nos1.c
在 LeetCode 提交後的執行結果我們可改良上述的非遞迴的實作,先計算出每個數在二進位表示中有幾個 1
,亦即選修幾門課,保存於陣列 count[]
中,共有 種狀態。如下: (iterative-nos2.c
)
iterative-nos2.c
在 LeetCode 提交後的執行結果這三個程式碼其實都環繞在同一個想法上,只是使用不同的方法實作而已,譬如說利用遞迴或迭代。所以下面的說明一概以iterative-nos2.c
為例,至於上面那兩個程式碼,請自行參考對照。
首先,將 dependencies[]
內的資料移植到 pre[]
中 (line 13-14),其中 pre[i]
代表要修課程 i+1
之前要先修的課程的位元表示法 ( 即 pre[2] = 3 = 代表要修課程 3 之前要先修過課程 1 跟 2 )。
dp[]
用來儲存修過某些課程所需的最少學期數。舉例來說, = ,代表最少需要 2 個學期才能修完課程 1 跟 3。 line 19-21 在做 dp[]
的宣告以及初始化,要注意的是初始化的值至少要大於總課程數 n
,又 ,因此,INIT = 0x5F
。
接著,line 31 的 for
迴圈中的 i
代表之前修過的課的二進位表示法,透過窮舉去看所有課程
來決定這學期可以修哪些課,並儲存在 b
(line 37-40)。因此,COND = !(i & (1 << j))
,用來檢查是否已修過此堂課。
NOTE: 前兩個程式碼都將此步驟(i.e. 將修過的課移除) 移到 for 迴圈外面去做
最後,列舉所有這學期可以修的課的排列組合,去看總堂數有沒有超過 k
堂,若小於等於 k
,則更新對應的 dp[]
with
dp[之前修過 | 這學期修的] = min(dp[之前修過 | 這學期修的], dp[之前修過] + 1)
;
若超過 k
堂,則從中選取 k
堂,一樣更新 dp[]
。
:Notes: 為何 interative-nos2.c
會比 interative-nos1.c
有更短的執行時間呢?
主要是因為在最後檢查所有這學期可以修的課的排列組合時,interative-nos2.c
檢查的比 interative-nos1.c
來的精簡。舉例來說對於可以修的課堂數小於等於 k
的情況,前者只會更新 "全取" 的情況;而後者卻會更新所有可能性,造成不必要的更新。這是因為當這學期可以修的課堂數小於等於 k
,那一定是全選會使得所需的總學期數最小。再者,對於這學期可以修的課堂數大於 k
,前者也只會更新那些加起來課程數為 k
的組合而已。
用預先算好的 count[]
取代 __builtin_popcount()
。
interative-nos2.c
可如何改進?pre[]
的大小從 cap
下降至 n
即可Linked list (鏈結串列) 允許時間複雜度為 的隨機插入 (insert) 及隨機移去 (remove) 節點,但搜尋卻需要 時間複雜度。假設一個 linked list 內容如下:
從開頭 (即上方 HEAD
) 找起,59
這個節點需要存取或比對 7 次才能找到,你直覺可能想到二分法,但對於 linked list 來說,隨機存取節點的時間複雜度仍是 ,也就是說,二分法無法帶來效益。
我們可在鏈結串列中,增加額外的 “skip” (作用是提供資料存取的捷徑) 節點,如下:
如此一來,我們就可依據 “skip” 節點查詢,只需要比對 3 次即可找到上述的 59
。
至於若想搜尋 47
,我們先根據 “skip” 節點來比對,於是在節點 22
上,它的 “skip” 指標會指向 59
,後者大於 47
,因此我們可知 47
可能會存在於節點 22
和節點 59
之間,這時候再根據鏈結串列順序搜尋,一共要比對 5 次。
隨著節點的增多,我們的 “skip” 鏈結串列會變成這樣:
“skip” 節點的密度約為普通節點的一半,能否再提高密度呢?我們可在這基礎上再加一層新的 “skip” 節點,這層節點的密度為第一層 “skip” 節點的一半。
換個視角,呈現如下:
我們再給予新的限制:每層的 “skip” 節點都由它下一層的 “skip” 節點中產生,最終我們可得類似下圖的 “Skip List”:
於是,我們不再區分每層是原節點還是 “skip” 節點,而將最底下的那層節點通稱為第一層 (level 1) 節點,第一層節點上面為第二層 (level 2) 節點,再來是第三層,以此類推。假設每層的節點數為下一層的一半,於是搜尋的時間複雜度縮減為 。
一般的 linked list 搜尋時必須從第一個開始,按照順序一個一個搜尋,因此不論是搜尋或存取的時間複雜度皆為 ,而 Skip list 可將存取、搜尋、新增和刪除等操作的平均時間複雜度降到
Skip list 是種針對 sorted linked list 的資料結構,透過不同 level 做出不同的 linked list 達到加速搜尋。
舉例來說,當我們想在上方 Skip List 示意圖中搜尋 7,步驟如下:
7
,但其他 level 可能具備。於是跳到 level 3 繼續找7
,於是跳到 level 2 繼續找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_INIT(d)
skiplist
大小的空間給 d
。list_node_t
加上 LIST_MAX_HEIGHT
個指向 list_node_t
指標所需的空間給 d->head
。d->height
初始為 0。LIST_FIND(d, k, l, v, u)
k
小的最後一個元素,i.e. 待會 k
要插入在這個後面k
,順便更新 u
。 若有找到鍵值 k
相對應的 value,則將之存入 v
;否則將 0 存入 v
。LIST_HEIGHT(d, k, l, h)
djb2
hash function 去決定 k
有多少層節點。LIST_NODE_INIT(n, k, l, v, h)
list_node_t
的指標 n
,配置空間,並初始化一些變數。LIST_INSERT(d, k, l, v)
u
(與上面提到的 u
相同)。LIST_FIND()
看看鍵值 k
是否存在於 skiplist d
中。
LIST_HEIGHT()
決定 k
有多少層節點。
k
的層數 大於 目前 skiplist 擁有的層數,則將 skiplist 向上擴充一層,所以 HHH 為 h = ++(d->height)
。此外,還要更新剛擴充這層的 u
,好讓 k
在剛擴充這層找到插入點。LIST_NODE_INIT()
建立並初始化一個 list_node_t
的物件。u
從最上層往下將含有鍵值 k
的物件插入到正確的位置。LIST_ITERATE(d, callback)
i
節點的鏈結串列是存在放 d->head->next[i]
所指的空間中,因此 III 為 iterator = iterator->next[0]; callback(iterator)
。
- 參見 Skip List