contributed by < LinMarc1210
>
觀察 list_insert_before
,此函式的目的在於透過 指標的指標 走訪所有節點並插入。參考 你所不知道的 C 語言: linked list 和非連續記憶體,若只用 list_item_t
的指標,會遇到特例,我嘗試用指標寫法做和 list_insert_before
一樣的事情,但是當我們的 before == l->head
的情況,就必須另外用 if 分支處理這個情況,並且將插入的 item
改為新的 head
,後續才可照正常的迴圈走訪,讓 pos
指向 before
的前一個節點,並且更改 item
和 pos
的 next
,才能插入完成。
但是指標的指標不會有特例,並且程式碼更精簡,如考試的範例:
*p = item
可以直接更改原本在 before
的前一個節點的 next
指向至新節點 item
,因為 p
是指標的指標,我們更改 *p
實際上直接更改了 next
指標的指向。這樣的方式可以避免我們直接 dereference NULL pointer 而產生錯誤。
在測試程式方面則是定義兩個巨集:my_assert
用來檢查條件,在這裡都是檢查 list_size
,而 my_run_test
是用來測試指定的函式,透過傳遞 function pointer 測試此 function。要是 message
不為 NULL
,代表 function 執行有問題,並回傳錯誤訊息。
Commit 6c6a52f
首先需要先了解 block_t
的結構,block_t
是記憶體管理樹的節點結構,而透過 block_t
構成的樹狀結構是用來維護 free blocks 的,因此可以說 block_t
就是一小塊還未被使用的記憶體。串成的樹結構如下,其中 root
是指標的指標, *root
是一個指向根節點的指標,而 root
是指向這個指標的指標。
此結構是為了實作動態記憶體分配器,也就是 heap,在這邊使用二元搜尋樹結構,因為這樣可以確保每個元素在時間複雜度 之內一定可以被找到。而我們需要快速找到適合被分配的 free block,因此需要 find_free_tree
和 find_predecessor_free_tree
兩個函式協助實作,需要分配記憶體的時候就要用 remove_free_tree
從樹中移除一個 free block 來分配。
接下來是 remove
的邏輯,首先要用 find_free_tree
找到目標節點 target
並回傳一個指標的指標。在這裡使用指標的指標是因為我們可以直接修改 *node_ptr
來改變樹狀結構,但如果 node_ptr
只是一個指向 block_t
的指標,則我們只是在改變這個指標本身,而未改變樹狀結構中節點的 *l
, *r
指向。
刪除可以分成 target
有 2, 1, 或 0 個子節點的情況來處理,其中 1 個或 0 個子節點的情況都較簡單,只需要直接刪除並且把 *node_ptr
變成其子節點就好,而在有 2 個節點的情況就需要決定用 predecessor 還是 successor 來補位,以維持 BST 的特性。小考的實作是以 predecessor 進行補位,即左子樹的最右節點,因此用 if 條件式確認 predecessor 是正好位於 target
的左子節點,還是在左子樹更深的地方。
找 predecessor 的程式碼:
取得 pred_ptr
後,會跟 find_predecessor_free_tree
比較,確保我們所找到的 predecessor 是正確的。接下來則判斷 predecessor 是否為 target
的左子節點,若是,則左子節點直接移到 target 的位置,並且原本 target
的右子節點變成了剛補上來的 *pred_ptr
的右子節點。
*pred_ptr的左、右子節點。要特別注意的是移除 predecessor 時,會遞迴呼叫
remove_free_tree` ,這是為了避免 predecessor 也有自己的子節點,所以遞迴呼叫會變成子節點數量為 0 或 1 的 case(不可能還有 2 個子節點,否則不會是 rightmost )。
remove_free_tree
的示意圖:接下來則是補完程式碼,讓其可以運作,我補完了 find_free_tree
和 find_predecessor_free_tree
,其中我使用遞迴 DFS 的方式尋找目標節點,讓程式碼保持簡潔。在尋找 predecessor 的時候則要先判斷 target 有沒有左子節點,若有則直接找左子樹的 rightmost node,若無同時紀錄目前節點和前一個節點,直到 pos 走到 target 之後,pred 就會紀錄到 target 的 predecessor。
完整程式碼包含測試程式,見 Commit 6c6a52f
小考示範使用 begin
和 end
兩個指標陣列來模擬堆疊,並透過索引值進行遞迴排序。其中 L
和 R
會取得該子陣列的頭和尾,並且判斷 L
是否等於 R
,其用意在於判斷子陣列是否僅剩下一個元素,與遞迴終止條件相同。當子陣列只剩一個元素的時候則將元素加到 result
,並且每次都加到串列的最前端,因為我們是從右邊開始進行排序的,這樣做才能保持元素的排序是正確的。
而在需要我們填空的那段程式碼與前面用來講解的,最大的不同處在於底下這段不用另外開一個 end
陣列去紀錄子陣列的尾,而是從結構上直接斷開鏈結,因此我們只須紀錄頭,並且每次使用 list_tail
取得其最後一個元素即可。過程保持單向鏈結串列的結構走訪,並且在最後呼叫 rebuild_list_link
重建雙向環狀鏈結串列。
而在迴圈內我們用指標 p
走訪節點並逐一和 pivot
比較 value
,比 pivot
小則用 left
去加進左子串列,比 pivot
大則用 right
去加進右子串列,如下:
這樣就完成了將走訪到的節點 1 加進左子串列,並且在 p 走完所有節點後,left 和 right 會分別停在左子串列和右子串列的頭部,因此就可以拿來紀錄 begin[i]
, begin[i+2]
,之後只要用 list_tail
就可以得到子串列的尾端。
為了模擬遞迴呼叫,每次根據 pivot 完成 partition 之後,會將索引值 i += 2
,每次都從右半部開始進行排序,直到只剩一個元素則加進 result
,這裡必須要進行 i--
,讓索引值可以往左半部,並對左半部的串列進行排序。
根據 quiz1 - 測驗 3 對於非遞迴的快速排序解釋如下,但我覺得這邊解釋的不清楚:
假定下方是起始的陣列內容。採取 head 作為 pivot,並且 L 會從左邊開始掃,紀錄有多少值小於 pivot,R 從右邊開始,紀錄有多少值大於 pivot。(R 一開始會定義為陣列的 length,以減法的方式紀錄)
但我參考 Optimized QuickSort — C Implementation (Non-Recursive) 的解釋,L 和 R 應該是從左邊界和右邊界開始走訪,只要 L 漸增的過程中遇到有節點比 pivot 大,就把目前的元素複製到 R 的位置,並且換 R 漸減,若遇到有節點比 pivot 小,就複製到 L 的位置,並且換成 L 漸減,直到 L >= R
停止,然後把 pivot
放到 L 的位置,這樣才能解釋為什麼要 swap,因為比較小的元素被換到 pivot 左邊,比較大的元素被換到 pivot 右邊,並且繼續下一輪的排序。
這題在 main()
裡面先將 test_arr
所儲存的值洗牌,並且用來建構串列,然後再呼叫 quick_sort
並且用 list_is_ordered()
檢查元素是否排序完成。
先來補充一下一般遞迴版本 Quick Sort 的 Pseudo Code :
流程是先用 PARTITION
選定一個 pivot,並且將陣列中分成比 pivot 小的和比 pivot 大的兩部份,最後分別遞迴呼叫左半部和右半部,再次進行一樣的動作,直到子陣列只剩一個元素。注意這邊遞迴呼叫 QUICKSORT
的時候,是不應該將 pivot 再放到任意子陣列的,否則到後面切分永遠都會回傳一樣的子陣列,因為子陣列所有元素都比 pivot 小(或大),造成無限遞迴而導致記憶體 stack 爆掉。
小考使用 Linux 核心風格 List API 的簡化標頭檔 "list.h"
進行開發,其中最特別的點是,一般的遞迴版本並不會保持 stable sort ,而小考的版本則會,根據《 Introduction to Algorithm 》,書內的快速排序在做 partition 是透過交換來決定 pivot 的位置,但交換時並不會特別考量相同元素的相對順序,原版的 PARTITION
程式碼如下:
其無法保持 stable sort 的例子如下,在 pivot 放到最後位置的時候並不會考慮 5(red) 和 5(blue) 的相對順序,導致在交換之後相對順序亂掉。
接下來分析小考 list_quicksort
的實作,首先為了將串列分割成比 pivot 小和比 pivot 大的子串列,會使用兩個 dummy node,分別是 list_less
和 list_greater
作為子串列的頭。這裡不用 list_head *
而是用 list_head
是因為我們需要真實的記憶體區段,才有辦法使用 head->next
,否則會在使用 ->
的時候發生 dereference a NULL pointer 的錯誤。
回想最基本版的快速排序 pseudo code,我們得知 pivot 是不應該被算進去字串列的,因此在進行分割串列之前,得先將 pivot 從串列中移除,所以使用 list_first_entry
直接取得該鏈結串列的第一個元素。
再來,我們希望將小的元素放至 list_less
的串列,而大的放去 list_greater
的串列,因此使用 list_move_tail
。在這裡就是保持穩定排序的關鍵,首先是判斷式,當元素值與 pivot 相等時,cmpint
會回傳 0,此時程式會進到 else 分支,而 else 分支會將元素加進 list_greater
子串列當中,而因為我們的 pivot 選定的是串列的第一個元素,這代表即使串列當中有和 pivot 相同的元素,也應該在排序後保持在 pivot 的右邊(即 list_greater
子陣列)才可以維持 pivot 本來就比較前面的相對順序。又因 list_for_each_entry_safe
這個巨集是從左走訪至右,因此有相同的元素時,先被走到的應該仍然要在被加到同個串列時保持在左邊,這也使得我們要使用 list_move_tail
,將元素移至串列的尾端,如果用的是 list_move
則會讓原本的相對順序反過來了。
最後執行遞迴呼叫,將左半和右半都排序完成。list_add
和 list_splice
的差別只在於前者是只能從 head
之後插入一個節點(新的頭元素),而後者是插入一整段子串列至 head
後面。list_splice_tail
則是插入一整段子串列至串列尾端。這樣子執行,正好可以在 partition 排序完成後,依照 less->pivot->greater 的升序組合回原本的串列。
針對 leading zero 的個數要怎麼算,這邊使用遞迴,每次將位元數切半,只要算一半位元的 leading zero 即可,並且當 upper 為 0,就代表 upper 全部都是 leading zero,只要計算 lower 還有幾個就好 ; 反之若 upper 不為 0 ,則 lower 都不需要再計算,因為 upper 一定還有位元為 1,使得 lower 的位元不可能是 leading zero。
根據這樣的邏輯,我們可以實作 clz2
,並且針對 32 位元的整數操作。小考題目有說到僅剩 2 位元時,就需要檢查 leading zero 了,因此我們可以判斷從 32 位元的數字要切半 4 次才有辦法將 x 切到僅剩 2 位元,因此我們除了傳入要計算 leading zero 的數字 x 以外,還要傳入現在是第幾刀,以 c = 0, 1, 2, 3
表示切 4 次,這樣的用意是為了要知道我們要使用 x 的幾個最低位元。
程式使用 mask[c]
來取得 lower 的位元,因此我們要決定第幾刀的時候,要將多少位元清空和留下哪些位元。對於 uint32_t lower
來說,最高 16 位元是不需要的,我們只關注最低 16 位元,所以用一個 16 位元的 mask 0xFFFF
來保留最低 16 位。 4 次切半 lower 分別需要保留 16, 8, 4, 2 個最低位元,因此 0xFFFF
應該要往右分別 shift 0, 8, 12, 14。
而當 c == 3
也就是切到剩 2 個位元時,必須要開始計算 leading zero 了。 2 個位元可以表示成四種數值,換成十進制會正好是 0, 1, 2, 3 也就是 c 的數值。我們用表格說明遞迴終止的情況,就可以得知 magic[upper]
其實就是表示 upper 此時的 leading zero 有幾個,lower 也一樣。
upper (lower) 二進位 | upper (lower) 十進位 | leading zero |
---|---|---|
00 |
0 | 2 |
01 |
1 | 1 |
10 |
2 | 0 |
11 |
3 | 0 |
因此程式碼在遞迴時都是判斷 upper 是否為 0 ,若 upper 不為 0 則遞迴呼叫 clz2
計算 upper ,為 0 則將 upper 現在的位元數 16 >> c
加上遞迴呼叫計算 lower
得到的結果,並且每次遞迴呼叫 c 要加 1,表示要進行新一次的切半了。
而 64 位元的 clz64
只需要再多增一層遞迴就可以達到一樣的效果:切半後呼叫 clz32
。
在求整數平方根時,
透過 Linux 核心的 hash table 實作 得知 Linux 核心中使用 hlist
的結構來處理 hash collision,並且用 **pprev
取代 *prev
,改成存「指向自己的指標」,結構如下:
prev
時:指向前個節點pprev
時:指向前一個節點的成員指標 next
也就是 **pprev
其實會指到自己的節點,因此在做刪除時,我們只需要傳入要刪除的節點,不需要再傳入 head
,我們也可以在教材中看到對於 hlist
的小結。
至此,我們理解到,
hlist
(即 hash list) 是針對雜湊表特製的鏈結串列,儘管尋常的雙向鏈結串列亦可實作雜湊表,但由於後者開頭和結尾需要各用 2 個指標,當 bucket 很大時,會增加記憶體開銷。
回到小考題目,我們要用 hash table (以下簡稱 HT
)讓 Two Sum 的時間複雜度降為 ,因此可用 HT
紀錄缺少的值:target - nums[i]
,並且讓 HT[target - nums[i]] = i
,因此若現在的 nums[i]
是一個與前面某個值相加可以成為 target
的值,則會回傳 [i, HT[target - i]]
。
接下來觀察結構 map_t
,裡面內嵌一個 hlist_head
的結構,代表整個 hlist
的起始端,bits
則是 hash table 的大小,用來實作在教材中提及雜湊函數的 Multiplication Method,本來的雜湊函數 可以由 實作,適用於我們不知道 key 的範圍與分佈情形。hash_key
則是內嵌 hlist_node
,代表這是元素經過 hash function 計算後分配的節點。原本的 key 以 key
存,原本的值則用 value
存,並且透過內嵌的 hlist_node
處理碰撞時需要用的鏈結串列。
用來檢索 key 的 find_key
如下,我們需要傳入 map_t
,也就是表示整個 hash table 的結構,還有我們要尋找的 key
。其中我們使用
hash(key, map->bits)
呼叫雜湊函數計算對應的 key 值,並且在 map->ht
中尋找對應 bucket 的 hlist_head
,最後還需要使用 &
取址才能丟給 head
。後續再從對應的 hlist
依照 key
找正確的節點,並回傳整個節點結構 hash_key
,最後再讓 map_get
呼叫 find_key
並取出 data
來完成 key-value 的檢索。
新增一個節點則需要考慮是否已存在一樣的 key,若不存在才會繼續進行新增,並且透過 hash
決定要安插在哪個 bucket。插入時如果該 bucket 已經有東西了,則需要接在該鏈結串列的最前面。
map_deinit
釋放記憶體,依序將 hash table 的每個 bucket 裡面的 hlist
依序釋放,並且透過 if(!n->pprev)
檢查 n 是否有插入至 hash table,但我目前還未釐清為什麼是用 !n->pprev
作為條件來判斷是否插入節點,還有什麼情況會發生這樣的問題。
最後,就可以只用一層迴圈,每次將走訪到的元素放進 hash table,同時確認是否有對應的 key,以及其互補值在 nums
的 index 為多少。