contributed by < yanjiew1
>
GitHub 連結: https://github.com/yanjiew1/linux23q1-quiz1
我們先來看 list_sort
函式的程式碼。程式一開頭出現,這裡是判斷傳入的串列是不是空的,或是只有一個元素。若是這樣就不用再排序,直接 return 即可,這裡是 list_sort
遞迴的 base case 。
接下來看到宣告了 list_less
和 list_greater
還有 pivot
。可知這應該是一個 quick sort 演算法。
而這段程式 struct item *pivot = list_first_entry(head, AAA, BBB);
由 list_first_entry
巨集的定義和說明可知,第二個參數要填入 container 的 type ,在這裡是 struct item
,而第三個參數要填入在 struct item
裡 struct list_head
member 的名稱,故為 list
。
list_first_entry
資訊以下為 list.h
中的 list_first_entry
巨集內容
一開始 pivot
指向第一個元素
list_del(&pivot->list);
則是把 pivot
從原本在 head
的串列中取下來。故執行 list_del
後,pivot
不再屬於 head
指向的 list
接下來即將進入迴圈。這裡出現了 CCC ,後面有四個參數,由此可知要填入 list_for_each_entry_safe
。 list_for_each_entry_safe
可以用來走訪串列中的每一個節點。與只有三個參數的 list_for_each_entry
版本比起來, safe 版本,可以把走訪中的節點從 list 中移除,不影響之後的走訪。
迴圈中,使用 cmpint 來比大小。若目前走訪的元素 itm
比 pivot
的數值小,則把它移到 list_less
串列中,反之則移進 list_greater
串列中。這裡會把節點從一個串列中移到另一個串列。故使用 list_move
,即為 DDD 和 EEE 答案。
到目前為止 list_less
存放比 pivot
小的節點,而 list_greater
則存放大於等於 pivot
的節點。
使用遞迴呼叫 list_sort
來對 list_less
和 list_greater
排序
最後把 list_less
、 pivot
和 list_greater
接回 head
。其中 list_greater
要接到串列中的最後,故用 list_splice_tail
,排序即完成
不用列出參考解答,你著重在程式行為即可。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
因為是遞迴呼叫,而 quicksort 在最壞的情況下,可能會需要 n 次遞迴呼叫,很容易就會 stack overflow。這時可以用 hybrid sort 解決,解決方式是當深度太深時,可以再用 mergesort 排序。
另外這段程式:
可以利用 LIST_HEAD
巨集,改為
更為優雅。
目前這個版本的程式在已經排序好的 linked list ,可能到 worst case 並產生 stack overflow。改進的方法除了成本比較大的 median of medians 外,我比較傾向用隨機挑選 pivot 的方式。
TODO 實作此改進
TODO
目前打算用 quicksort + mergesort + insertion sort
目前希望能開發基於 Timsort 的變種。因為 Timsort 是針對陣列排序開發的,故可在裡面使用 binary search 等需要 random access 的演算法。我打算修改 Timsort 演算法讓它適用於 Linux 風格的 doubly-linked list 。初步閱讀 Timsort 文章,感覺有機會加速 Linux kernel doubly-linked list 在 common case (即 list 中有些東西是排序好的) 下的排序速度。
Timsort 是基於 insertion sort + merge sort 再加上一些獨特排序技巧。
這個演算法預期會用非遞迴方式實作。
Timsort 參考資料:
由於 powersort 的 merge 策略較難理解,會先用舊版本實作後,再來研究 powersort 。
另外因為這幾種 merge 策略都需要知道每一個 run 長度,有二種方法解決:
TODO 補完 Timsort 說明和程式實作。實驗後,若有正面效益,再進行 Linux kernel 貢獻
期待!
之後的研究結果會放在這裡: Timsort 研究與對 Linux 核心貢獻嘗試
TODO 我還不熟悉用 Graphviz ,所以這題先沒放圖,之後再補
這裡跟第一題一樣,如果 list 是空的或只有一個元素,就不用排序,直接 return 。
宣告 stack ,裡面是存放尚未排序完成的串列。並將其用 INIT_LIST_HEAD
初始化。 top
是存放 stack 頂端的 index 。
之後再用 list_splice_init
把 head
裡面的東西移進 stack 。故現在 stack 包含一個未排序的 list 。
宣告等一下迴圈中,正在處理當中的 list 。迴圈一開始會把 stack top 上的 list 放入 partition 中。
先來看當未排序 list (partition
) 有二個以上元素的程式。
在這裡跟測驗 1 很像,先宣告 list_less
和 list_greater
,分別存放比 pivot 小和比 pivot 大的元素。
把 pivot
設為 partition
中的第一個元素,並把 pivot
從 list 中移除。
這裡跟測驗 1 一樣,走訪每一個元素,把比 pivot 大的元素放到 list_greater
,把比 pivot 小的元素放到 list_less
。而 GGGG 也跟測驗 1 一樣是 list_for_each_entry_safe
,會用 safe 的版本是因為過程中,走訪中的元素會被移走。
到目前為址,list_less
會存放比 pivot 小的節點, list_greater
會存放大於等於 pivot 的節點。
接下來把 pivot 移到 list_less
串列的末端(因為 pivot 比 list_less
每一個元素都大。因為要移到末端,故要用 list_move_tail
,即為 HHHH 的答案。
因為 list_less
和 list_greater
都是未排好的 list ,要把它放到 stack 中,並且把小的放在大的上面。因為是要推入 stack 中,故 IIII 和 JJJJ 都是 &stack[++top]
。
接下來看看如果 partition
只有一個元素要怎麼處理。特別註意的是,在剛才的程式中,如果 list 是空,就不會被放進 stack ,故 stack 中的 list 一定不為空,所以 partition
在 else 裡面一定是只有一個元素的 list。
首先,先把 partition
放回 stack 。這裡會放回 stack ,是因為等一下的 while 迴圈會從 stack 中一個個取出元素來處理 stack 中的內容。
這裡的 while 迴圈會不斷地從 stack 頂端的 list 取出元素,把它接在 head
的後面,直到 stack 為空或 stack 頂端的 list 不是只有一個元素為止。 在前面的程式,我們知道放進 stack 的順序是由大到小,故這裡取出元素是由小到大,也因為這樣,所以把他們往 head
後面排,剛好會是正確的順序由小到大。
這裡的 KKKK 比較 tricky 一點。其實用 list_del
把 stack top 上的 list 元素移除後,它應該就是空了。所以不用再初始化一次,但因為題目這樣子出,且我們發現 top
在這裡尚未因把 stack 元素取出而減 1 ,故這裡可以填 &stack[top--]
。
觀察上述,我們會發現不管迴圈怎麼跑, stack 內的 list 具有下面性質:
head
是就會是排好的順序。head
內的元素還小。這段程式最大的問題跟測驗 1 類似, quicksort 在最壞的情況下可能讓 stack 大小不夠用,就會出現 buffer overflow 。
LIST_HEAD
巨集這裡如同測驗 1 ,可改用 LIST_HEAD
巨集。
改寫成
partition
至迴圈內partition
只在迴圈內用到,可把 partition
的部份可以移至迴圈內,並使用 LIST_HEAD
改寫,成為
pivot
直接移到 list_less
內我們可以把 pivot
從 partition
移除,最後再移到 list_less
。這裡就可以直接移到 list_less
內。即把
改成
並且把後面的 HHHH (&pivot->list, &list_less);
刪除。
if 內的條件式中,因為 stack 內每一個 list 都一定有一個以上的元素。故可簡化:
可改為
partition
只有一個元素時的情況另外在 else 裡面,又多了一層迴圈。但實際上也是不斷的從 stack 中取出 list 放到 head
裡。故 else body 直接用 list_move_tail
把從 stack 取出來的 list 中唯一的元素放到 head
內即可。整個 else body 可改為
經過上面的簡化, else 內只剩下一個 statement ,故改用 early continue 的方式,把原本 if body 拿到外面,而 if statement 改為
因為這部份的改寫比較複雜,建議直接看下一節的程式碼。
跟據上面的優化,可以更加優雅
MAX_DEPTH
解決方案上面的改寫,仍擺脫不了 MAX_DEPTH
的問題。我們觀察到每一個 struct list_head
都有二個指標 next
和 prev
。若我們仿造 Linux Kernel 內的 list_sort.c
,把 list 改為 singly-linked list ,這樣子空下來的指標 prev
就可以用來實作 stack 。但缺點是,因為排序的過程中, linked list 就不是原本的 doubly-linked list ,故原本可以優雅得用 list.h
裡的巨集,可能很多地方就不適用。
以下的程式實作上述想法,並目改用 Linux kernel 風格的 list_sort
函式。
目前這個版本的程式在已經排序好的 linked list ,可能到 的 worst case 。
改進的方法:除了成本比較大的 median of medians 外,我比較傾向用隨機挑選 pivot 的方式。
除此之外,再加入 fat pivot ,來改善全部節點都是相等的情況。 另外隨機選 pivot 希望還是能保持穩定排序的特性。
TODO 實作此改進
TODO 基於 quicksort + mergesort + insertion sort 來開發。
以下為實驗數據。測試方式是隨機產生 10000 個 struct item
測資,用指定方式排序,量測所花費的 CPU cycle 數。每一項排序演算法均實驗 100 次。實驗結果如下:
排序演算法 | 結果 (cycle 數) | 說明 |
---|---|---|
測驗1 | 2113507.71 | 題目原始遞迴版演算法 |
測驗2 | 3852102.95 | 題目原始非遞迴版演算法 |
測驗2改進1 | 2358003.45 | 不依賴 MAX_DEPTH 演算法 |
Kernel list_sort | 1823407.13 | Linux 核心的 list_sort 演算法 |
為了讓實驗結果更準確,原始題目的演算法都改為需傳入 list_cmp_func_t
比較函式的,並透過傳入的比較函式進行數值比較。
Graphviz 圖片之後再補上
xor linked list 主要由二個結構體組成,分別是 xor_list_t
和 xor_note_t
。xor_list_t
是 xor linked list 的主要結構體,會用它來表示一個 xor linked list 。而 xor_note_t
則是各個節點的結構體。
xor_node_t
是用來表示節點的結構體,其 cmp
成員變數是上一個節點位址和下一個節點位址的 xor 運算結構。
xor_list_t
中嵌入了二個 xor_note_t
,分別是 head
和 tail
,其分別是放在 xor linked list 開頭的節點,不放置使用者資料 (以下簡稱 head 節點);而 tail
則是放在 xor linked list 結尾的結點,一樣也不放置使用者資料 (以下簡稱 tail 節點)。xor_list_t
另一個成員變數 count
則會放置整個 xor linked list 的節點數量(不包含不放置使用者資料的 head
和 tail
節點)
在初始化 xor_list_t
時, count
會被設為 0 , head.cmp
會被設為第一個有放置使用者節點的位址,若串列為空,則直接指向 tail 節點。 tail.cmp
會被設為最後一個有放置使用者節點的位址,若串列為空,則直接指向 head 節點。
節點走訪時,我們除了需要知道目前節點的位址外,還需要上一個節點的位址。如此一來,可以透過計算目前節點的 cmp
值和上一個節點的記憶體位址的 xor 結果,即可得知下一個節點的位址。
其原理是透過 xor 的特性: A ^ B ^ B = A
,即由 xor 組成的運算式,同樣的數值 xor 二次會被抵消掉。
我覺得 cmp
用指標不是很好。二個記憶體位址進行 xor 後的結果就已經不是指向一個有效的記憶體位址,不該再用指標來存。我覺得可以多定義一個型別 typedef uintptr_t xorlist_cmp_t;
來區分比較好。
XOR_COMP
巨集,會把 a
和 b
二個 xor_node_t
指標記憶體位址進行 xor 運算再回傳運算結果。但因為在 C 語言內,指標不能進行 xor 運算,故要先轉型為 uintptr_t
,它是一個無號整數,其大小足以儲存指標內的記憶體位址。運算完成後,再轉型回 xor_note_t
。
address_of
函式用來取得前一個節點或下一個節點的記憶體位址,基本上就是 xor 運算。n1
和 n2
其中一個放 xor_node_t
中的 cmp
變數,另一個放前一個節點的位址來取得下一節點的位址或放下一個節點的位址來取得前一個節點的位址。
跟 Linux kernel 中的 container_of
作用一樣。結定一個 struct 內成員的指標 ptr
、 struct 本身的型別 type
和 struct 裡面成員變數的名稱 member
,即可取得該 struct 本身的指標。
例如:
假設有一個指標 struct list_head *ptr
,指向 test_t
結構內的 list
。
若要取得指向 test_t
的指標,可以用 container_of(ptr, test_t, list)
。
可參考Linux 核心原始程式碼巨集: container_of
xorlist_for_each
此巨集可用來走訪 xor linked list 的每一個節點。此功能跟 list.h
內的 list_for_each_
一樣。 node
是目前走訪的節點, rp
會存放下一個節點的位址,而 rn
是暫存用變數,在每一回合結束,要計算下個節點時,用來協助暫放下一個節點的位址。
其運作原理是:
rp
(rp = &(list)->head
),而 node
則指定給 head
節點的下一個節點。rp
上一個節點的位址跟 node->cmp
做運算,得到下一個節點的位址,先暫存到 rn
中,然後讓 rp
指向目前節點,node
指向下個節點 (即直接把剛才算好的 rn
指定給 node
) 。透過這樣子操作,讓 rp
和 node
都前進一個節點。node != &(list)->tail
這是判斷是否要執行接下來迴圈的判斷式。即當 node
還沒有到達 tail 節點時,都會一直走訪下去。這個迴圈執行過程中,都會保持 node
是指向目前走訪的節點;rp
是指向上一個節點。
xorlist_for_each_prev
跟 xorlist_for_each
一樣,只差在是從串列最後一個節點走訪到第一個節點。故不再多作解釋。
xorlist_for_each_from
巨集用來從某個特定節點開始走訪到最後一個節點。pos1
要傳入要開始走訪節點的上一個節點, pos2
要傳入要走訪的節點。 rp
, rn
均為暫存變數,rp
會保留上一個節點的指標,rn
則會在計算下一個節點位址時被使用。
xorlist_for_each_from_prev
跟前一個巨集很像,只是差在從某節節點由後往前走訪,pos1
要指向要走訪節點的後一個節點。
xorlist_delete_prototype
和 xorlist_delete_call
可以用來協助定義和使用刪除節點用的函式,此函式要放置釋放記憶體空間相關的程式,會由後面要講解的 xorlist_del
呼叫。
因為 xor linked list 本身是嵌入在節點內部的節構,其相關操作函式不會知道 xor linked list 的使用者,要如何配置記憶體及釋放記憶體。故這二個巨集用來協助使用者寫出符合 xor linked list 用的釋放記憶體函式。
xornode_init
、 XORNODE_INIT
都是用來初始化節點 (xor_node_t
)。其中 xornode_init
是以函式的形式實作,而 XORNODE_INIT
是以巨集的方式實作。其功能都是把節點的 cmp
成員函數設為 NULL
。
用來初始化 xor_list_t
(用來表示串列的主要資料結構)的巨集。從這裡可以觀查到,一個新的沒有存放節點的串列,會把 head 節點的 cmp
成員變數指向 tail 節點,代表 head 節點下一個節點就是 tail 節點;也把 tail 節點的 cmp
成員變數指向 head 節點,代表 tail 節點的上一個節點就是 head 節點。
xorlist_add
把節點 n
加入到 list
開頭。
real_prev
和 real_next
為分別指向上一個節點和下一個節點的指標。
一開始先設定 real_prev
和 real_next
的位址。因為 xorlist_add
要把 n
加入到 list
開頭。故 n
的上一個節點會是 head 節點,故就直接把 head 節點的位址指定給 real_prev
,而 real_next
則跟據 list
是否為空,來決定要設定為 real_prev->cmp
或 tail 節點。
這裡其實不用這麼麻煩,因為 list 為空時 head 節點內的 cmp
就會是 tail 節點。
最後再計算和更新 real_prev
, real_next
和 p
的 cmp
值。
cmp
會指向第一個節點,故直接把其 cmp
值更新為 n
。n
其 cmp
, 按照 xor linked list 定義,就是前一個節點和下一個節點的 xor 結果,故 n->cmp = XOR_COMP(real_prev, real_next)
。real_next->cmp
時,要把取得 real_next
的下一個節點的位址 XOR_COMP(real_prev, real_next->cmp)
,再和目前節點做 xor 運算 XOR_COMP(n, XOR_COMP(real_prev, real_next->cmp))
。count
要加 1簡化版但又好理解的程式
xorlist_del
: 給定要刪除節點的前一個節點 n
、要刪除的節點 target
和節點記憶體釋放的函式 delete_func
來刪除節點。
前面二個 assert ,是確保 list
, n
, target
, delete_func
都不為 NULL
。以及 要刪除節點不能是 head 節點和 tail 節點。
這個函式的變數命名相較於 xorlist_add
很不友善。但從程式的邏輯可以推出各個變數的功能:
nn
為 target
和 n->cmp
的 xor 結果,故它應為上上一個節點的位址an
為 n
和 target->cmp
的 xor 結果,故它應為下一個節點的位址ana
為 an->cmp
和 target
的 xor 結果,故它應為下下一個節點的位址因為要刪除 target
節點,故其前後節點(n
和 an
)的 cmp
都要進行修改。
n->cmp = XOR_COMP(nn, an);
上一個節點的 cmp
改為上上一個節點的位址與下一個節點的位址 xor 的結果an->cmp = XOR_COMP(n, ana);
下一個節點的 cmp
改為下下一個節點的位址與上一個節點的位址 xor 的結果最後把節點記憶體釋放 delete_func(target);
, 也把 count
減 1 。
雖然這個函式裡面變數命名不好,但會這樣子命名,可能是因為它考慮到 n
傳入 target
的下一個節點也可以運作正常的特性來命名。但或許它仍需要有更好的名字。
另外,雖然這個函式是把節點移除,但節點之後可能可以用到其他地方,故我覺得在這裡就要釋放記憶體反而限縮了它的應用。
另一個問題是呼叫 delete_func
沒有檢查記憶體釋放是否成功。這和後面的 xorlist_destroy
函式有進行檢查刪除是否成功形成對比。
如果是我, 我會這樣改進:
這個函式不需要傳入 delete_func
,釋放記憶體的工作就交給 caller 來做。
xorlist_destroy
把整個 xor linked list 刪除,此函數實作錯誤。
一開始先確保 delete_func 不為空。
接下來宣告和設定此函數內變數的初始值。 real_prev
是目前走訪節點的上一個節點,設定為 head 節點。 node
為目前走訪的節點,設定為第一個節點。 real_prev
為目前走訪節點的下一個節點,設定為 node
的下一個節點。
以下程式在進入迴圈前,會把 preal_prev
和 node
各往前走訪一個節點。因此在這段程式執行完後, real_prev
指向第一個節點,node
指向第一個節點的下一個節點
以下程式走訪每一個節點,並刪除每一個節點。迴圈內前面四行會先把 real_prev
和 node
都先指向下一個節點,原本的 real_prev
暫存在 tmp
中,最後再把 tmp
刪除。
這段程式很明顯實作錯誤。在 while 迴圈走訪時,每次都是刪除 node
的前一個節點。而最後在 node
為 tail 節點時,就不會進入迴圈了。故最後一個節點的空間無法被釋放。
改進如下:
與原本題目不同時,每次刪除的節點不是 real_prev
而且目前走訪的節點 node
。且 node
在這段程式確實會從第一個節點走訪到最後一個節點,故可以確保每個節點都刪除。
接下來的程式為測試上述 xor linked list 的程式碼。
以下為測試用的struct test_node
結構體。有二個成員變數,分別是 value
和 xornode
。xornode
就是用來讓此結構體可以串接在 xor linked list 。
透過 xorlist_delete_prototype
巨集來宣告刪除節點用的函式。
分析 main
內的函數。先看裡頭宣告的變數:
xor_list_t list;
宣告一個 xor linked listxor_node_t *p1, *p2;
: p1
和 p2
為等一下測試時,放置倒數第 6 (i=5)和第 7 (i=6)個節點。xor_node_t *real_prev, *real_next, *node;
: node
為等一下走訪時,表示正在走訪的節點。 而 real_prev
和 real_next
均為等一下走訪時的暫存變數。以下為加入測試資料的程式碼:
上述程式碼中,XORLIST_INIT
巨集用來初始化 list
這一個 xor_list_t
結構。
迴圈內則透過 malloc
來配置新節點記憶體,用 xornode_init
來初始化新節點 (把 cmp
設成 0)。並且設定節點的 value
值 new->value = i;
,再將節點加入串列 xorlist_add(&list, &new->xornode);
。另外特別把 p1
設成倒數第 6 個節點和倒數第 7 個節點。
上述程式碼執行完成後,串列內的值從最前面節點由 999 倒數至最後面節點 0 。
接下來就是測試 xorlist_for_each
、 xorlist_for_each_from
、xorlist_for_each_from_prev
、 xorlist_for_each_prev
這四個走訪節點的巨集,其型式和程式碼都很簡單。裡面就是單純把節點的值輸出而已。比較可以注意的是這裡特別用 container_of(node, struct test_node, xornode)
巨集,讓我們可以從指向 struct test_node
結構體內 xornode
的指標,取得指向 struct test_node
結構體的指標。
最後再走訪一次每一個節點,用 xorlist_del
刪除每一個節點後,再用 xorlist_destroy
把整個 list 刪除。 但最後的 xorlist_destroy
有點多餘,因為前面已經把每一個節點都刪除,記憶體都釋放了。