or
or
By clicking below, you agree to our terms of service.
New to HackMD? Sign up
Syntax | Example | Reference | |
---|---|---|---|
# Header | Header | 基本排版 | |
- Unordered List |
|
||
1. Ordered List |
|
||
- [ ] Todo List |
|
||
> Blockquote | Blockquote |
||
**Bold font** | Bold font | ||
*Italics font* | Italics font | ||
~~Strikethrough~~ | |||
19^th^ | 19th | ||
H~2~O | H2O | ||
++Inserted text++ | Inserted text | ||
==Marked text== | Marked text | ||
[link text](https:// "title") | Link | ||
 | Image | ||
`Code` | Code |
在筆記中貼入程式碼 | |
```javascript var i = 0; ``` |
|
||
:smile: | ![]() |
Emoji list | |
{%youtube youtube_id %} | Externals | ||
$L^aT_eX$ | LaTeX | ||
:::info This is a alert area. ::: |
This is a alert area. |
On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?
Please give us some advice and help us improve HackMD.
Syncing
xxxxxxxxxx
2022q1 Homework1 (lab0)
contributed by <
kdnvt
>Github
開發環境
作業要求
lab0
開發過程
list_head_t
我覺得每次都要寫
struct
過於冗長,所以定義了一個list_head_t
。不要這樣作!這是取鏡於 Linux 核心的 API,請保持一致的風格。課程要求學員做好協作的準備,這類的細節和態度就是其一。現在就改回來!
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
Learn More →已將程式碼中 list_head_t 的部份都刪除。
q_new
q_free
一開始的程式為以下
但當我完成 insert , remove 等程式,發現有關 malloc failure on new 的測試有時會發生錯誤,而且是 segmentation fault 。我決定用
make valgrind
來檢查出錯的地方,於是有了以下訊息:根據以上訊息,可以知道問題發生在
q_free
中,而q_free
中第一個遇到的就是list_for_each_entry_safe
這個 MACRO ,於是我去看list.h
中此巨集的實作方法:可以看到這個巨集一開始就直接用
將
list_entry
的內容指派給entry
。而且也直接使用(head)->next
,所以我很快發現我忘記檢查傳入的引數是否為NULL
或 empty queue,於是我將程式碼改成以下:考慮用
q_release_element
取代迴圈內部。其他想法:
我覺得上面那段程式中
有點冗長,於是我嘗試將程式碼改成以下:
其中用到了 || 及 && 在前者條件已經確定下,後者不會被執行的技巧。而後面的
(free(l),true)
則是我參閱規格書中 comma operator :因此我想它的行為應該會是執行完前面的
free(l)
後直接以 true 來取代這段 expression 。在 qtest 中上面這段程式碼可以正確執行,但我還沒有放到
正式版本中。疑問:不知道類似以上的寫法是不是好的實作方式,有沒有其他我沒想到的問題或是可讀性太差?
最後發現根本不用這麼麻煩,因為如果傳入 free 的指標本身就指向 NULL ,free 是不會有任何行為的,所以根本不用多做一個檢查。
簡化後的版本:
q_insert_head
在實作
q_insert_head
以及q_insert_tail
時,我發現兩者有許多重複的地方,於是我嘗試將那些部份寫成另一個函式element_new
。此函式以一個字串s
為參數,配置一個新的記憶體空間給element_t
,並將value
複製為該字串(也是獨立配置記憶體空間),如果過程中配置失敗會回傳NULL
,如果s
指向NULL
則會將value
也指向NULL
。疑問:我看到許多危險的函式,如
strcpy
,gets
等等。strlen
會不會也有潛在風險?在與教授的討論中,提到了
sizeof(*str)
(同sizeof(char)
) 因為結果是1
,所以其實可以拿掉不用寫。我因此產生出了:有沒有可能在某些機器或實作上,
sizeof(char)
不是一?查了一些資料發現, c 語言規格書 6.5.3 中就有提到:
因此, sizeof(char) 的結果永遠會是一,程式碼中確實可以刪去這部份使其更精簡。
不要花太多心思在「查了一些資料發現」,不如用在探索 C 語言規格書。
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
Learn More →簡化後的版本:
q_insert_tail
q_remove_head
q_remove_tail
q_size
q_delete_mid
q_delete_dup
因為走訪整個 queue 的過程中有可能會移除當前的 node ,因此使用
list_for_each_safe
而不是list_for_each
。原始版本:
經過教授的建議,我重新思考是否可以移除特例,並減少重複的程式碼,最後改成以下:
q_swap
在一開始的想法中,每次迴圈都讓
node
往前兩次,後來發現 swap 的過程中node
所指的struct list_head
本身就會往前一次,因此迴圈結束只要往前一次。q_reverse
q_sort
一開始決定以
疊代迭代的方式實作 merge sort 。依據《國家教育研究院雙語詞彙、學術名詞暨辭書資訊網》,"iterative" 一詞翻譯為「迭代」。
注意「疊」和「迭」語意不同:
依本處情境來,乃逐一節點走訪,存取特定的記憶體內容,因此該用「迭」
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
Learn More →初始版本:
我覺得上面的
merge
的實作有點笨,本來是想把右列的 sorted list 一個一個判斷後插入左列,但越寫越不直覺,還要想一些例外狀況,可讀性差也不易理解,於是寫完之後決定重寫一份:上面這段程式碼是先判斷找出
value
最小的 element 當成第一個 node ,然後比較左右列最小的 node 一個一個插入進去。執行起來較為對稱,也沒有太多例外狀況,較好理解可讀性也較佳。
需要特別注意的是為了保持排序的穩定性,條件為
cmp <= 0
而不是cmp < 0
。不過
q_sort
中有一個致命的缺點,就是陣列 sorted 是固定長度,若 queue 太小會很浪費空間,太大則會無法執行。另外,這次作業似乎禁止在q_sort
中使用malloc
,不過就算能用,對於記憶體也可能會是很大的負擔。於是我想了另一套實作方法來解決這個問題:這個方法主要使用每個 sorted list 尾端節點的
next
指向下一個 sorted list ,如此一來我就可以用 linked_list 來走訪各個子 sorted list ,不需要額外的空間來記錄這些 sorted list 的位置。結構大致上如下:
圖中 1 5 7 為一個 sorted list , 2 3 6 為另一個 sorted list ,以一個
first
來記錄整個 linked list 的位置,最後一個節點則指向head
,而 head 指向自己。註:此圖僅為說明,若是圖中的 queue ,以程式碼執行的結果應該不會出現三個三個一組的情形。
範例說明:
假如一個 queue 順序為 1 6 7 2 5 3 。
程式一開始先以
將 queue 切割成單個 sorted list ,圖如下:
接著對每兩個相鄰的 sorted list 做合併:
最後再將
head
插回排序好的 queue 中,再回傳就完成了。研讀 lib/list_sort.c
除了程式碼及註解外,我參考了 研讀 Linux 核心的 lib/list_sort.c 原始程式碼 ,才發現 commit message 也是很重要的資訊來源。如果實作上較為複雜或有其他考量,開發者會把重要的資訊及考量的點也寫在 commit message 中。
研讀
lib/list_sort.c
時我發現list_sort.c
實作方法跟我的有一些相似之處,例如用prev
來串起排序好的子串列,以及用 bottom up 的方法實作排序。不過也有許多不同的地方,list_sort.c
在合併的過程中,直接把子串列當成單向鏈結串列,因此操作上省去了許多prev
的指標操作,只要在最後一次 merge 時再串起即可。而選取合併的子串列時,雖然是用 bottom up 的方法,但不同於一般的 level order merge ——即每一層相同大小的子串列都合併完後,才合併更大的子串列——
list_sort.c
的實作上採用了 depth-first order ,目的是為了避免 thrashing 。level order merge 在每一層都會幾乎讀取所有子串列(有些實作上最後一條子串列可能不對讀到),當要排序的雙向鏈結串列超過 L1 cache 的大小,同一層前面排序好的子串列必須從 cache 中搬出,讓給後面要排序的子串列。而到了下一層,剛被搬出的子串列又要被搬進 cache ,造成資料不斷被搬進搬出 cache ,頻繁讀取卻沒有利用到 cache ,對於效能是很大的傷害。如果採用 depth-first order,就可以趁相近的子串列都還在 L1 cache 時,盡量將大小相同的兩者合併,進而打造出 cache friendly 的實作方法。
不過只用 depth-first order 還有一個很大的問題,就是會造成 unbalanced merge ,即兩個大小差異極大的子串列做合併。(我的實作中沒有考慮到這一點,這是一個可以再改進的部份。)
因此,這份 commit :lib/list_sort: optimize number of calls to comparison function 的作者 George Spelvin 提出了另一種實作方式來改善這個缺點。
首先,他分析了不同實作下所需的 compares 次數。
\(n*\log_{2}(n) - K*n + O(1)\)
其中使用一般 Top down merge sort 因為可以將子串列平均分割,因此平均情況下 K = 1.248 ,而一般的 bottom up merge sort 平均情況下 K = 0.965 。(George Spelvin 做的實驗顯示 K = 1.01)
但是使用 Top down merge sort 或其他改良版的 merge sort (如 queue merge sort)時,都會用到 breadth-first multi-pass structure ,與我們想要以 depth-first order 善用 cache 的需求不符。
George Spelvin 透過確保每次合併時,兩個子串列的大小不會大於 \(2 : 1\) ,成功的將 K 值增加到 1.207,省下了 0.2 * n 次的 compares 操作。
確保 2 : 1 的實作方法
這段操作十分精簡且高效,因此花了我好長一段時間去理解,這到底是什麼巫術。
George Spelvin 甚至在 commit message 的最後寫上了:
先引入一個變數
count
來記錄目前讀進的元素個數,並根據其 bit pattern 來判斷每次的合併。節錄
list_sort.c
中的註解:其中
左邊的 0/1 代表第 k 個 bit ,右邊的 0/1 代表第 k-1 個 bit 。
一開始不知道那一個才是 bit k ,看了好久才看懂。
x 最多為 k-2 bits ,因此最大為 \(2^{k-1}-1\) 。
對於第 k bit ,可以將其分為以下六種狀態,並由其所在的狀態來判斷目前長度為 \(2^k\) 的串列數量:
0: 00x: 0 pending of size 2^k; x pending of sizes < 2^k
代表目前有 x 數量個元素在長度小於 \(2^k\) 的子串列中。
1: 01x: 0 pending of size 2^k; 2^(k-1) + x pending of sizes < 2^k
代表有 (\(2^{k-1}\) + x) 個元素在長度小於 \(2^k\) 的子串列中。
2: x10x: 0 pending of size 2^k; 2^k + x pending of sizes < 2^k
代表有 (\(2^k\) + x) 個元素在長度小於 \(2^k\) 的子串列中,但為了確保之後的合併不會超過 2 : 1 ,因此不會將那 \(2^k\) 個合併成一個長度為 \(2^k\) 的串列。
3: x11x: 1 pending of size 2^k; 2^(k-1) + x pending of sizes < 2^k
代表有一個長度為 \(2^k\) 的串列,另外有 (\(2^k\) + x) 個元素在長度小於 \(2^k\) 的子串列中。
因為至少有 \(2^k\) + \(2^{k-1}\) 個元素,所以可以將那 \(2^k\) 個元素合併。如果之後都沒有其他元素了,剩下長度差異最多也就是 \(2^k : 2^{k-1}=2:1\) 。
4: y00x: 1 pending of size 2^k; 2^k + x pending of sizes < 2^k
代表有一個長度為 \(2^k\) 的串列,另外有 (\(2^k\) + x) 個元素在長度小於 \(2^k\) 的子串列中。跟狀態 2 一樣,雖然有可合併成 \(2^k\) 的子串列,但不會立即合併成 \(2^k\)。
5: y01x: 2 pending of size 2^k; 2^(k-1) + x pending of sizes < 2^k
代表有兩個長度為 \(2^k\) 的串列,另外有 (\(2^{k-1}\) + x) 個元素在長度小於 \(2^k\) 的子串列中。兩個長度為 \(2^k\) 的子串列一樣不會立即合併成 \(2^{k+1}\) ,唯有確定後面的元素個數達到 \(2^k\) ,足以確保差距不會超過 \(2^{k+1}:2^k =2:1\) ,才會進行合併並且回到狀態 2 。
有了以上的初步理解,就可以來看 list_sort.c 程式實作:
先分析每一次讀取時
count
的變化,有可能是:\(0\overbrace{11..11}^\text{k 個} = 01x\) (狀態 1)
這樣的話會被下面的程式排除,不給合併。
另一種則是
\(y0\overbrace{11..11}^\text{k 個} = y01x\) (狀態 5)
每一次
count
+ 1 ,都會有一個 bit k 從 0 進位成 1 ,這個 bit 為 least signifcant clear bit 。而這個 bit 處於狀態 5 ,也就是目前有兩個長度為 \(2^k\) 的子串列。在它之前的所有 bits 皆為狀態 3 。
\(x111..11 = x11x\) (狀態 3)
包括第 0 個 bit
根據狀態 3 的敘述,可以說目前 \(\forall \ i \in Z, 0\le i\le k-1\) 都有剛好一條長度為 \(2^i\) 的子串列。
也就是目前的子串列大小排列是:
\(\overset{pending}{\implies}\overbrace{2^0\overset{prev}{\implies}2^1\overset{prev}{\implies}.....\overset{prev}{\implies}2^k}^\text{k 個 prev}\overset{prev}{\implies}2^k\overset{prev}{\implies}..\)
目前在長度小於 \(2^k\) 的子串列中的元素個數為:
\((2^0 + 2^1 + 2^2 + ... + 2^{k-1}) + 1 = (2^k - 1)+1 = 2^k\)
數學式中最後的 \(1\) 並不在各個子串列中,他是這個迴圈的結尾才會被加進的元素。這個數學式讓我們得知這個迴圈結束時長度小於 \(2^k\) 的子串列中的元素個數已經到了 \(2^k\) ,因此可以合併兩條大小為 \(2^k\) 的子串列。
所以在一開始的迴圈只要往前 k 次就可以到達要合併子串列:
到達後進行合併:
然後輸入下一個元素:
沒有待輸入的元素時,將剩餘的子串列從小到大合併:
最後在用
merge_final
合併最後兩條子串列,並串起prev
:用這種方式就可以達成 depth-first merge sort 同時保證兩者差異不會大於 \(2:1\) 。
一些證明
前提:所有子串列其長度皆為 2 的冪,所有合併皆為同大小串列。
設 \(t_i\) 為目前長度小於 \(2^i\) 的所有子串列的節點數總和。\(i\) 為正整數。
第一點:
證明當 \(t_i\) 從 \(3\times2^{i-1}-1\) 加一變成 \(3\times2^{i-1}\) 時,不存在正整數 \(j \neq i\) 使得 \(t_j\) = \(3\times2^{j-1}\) 。
假設存在 \(j>i\) 且 \(t_j\) = \(3\times2^{j-1}\) 。則有一個正整數 \(l\) 使得 \(3\times2^{i-1} + l = 3\times2^{j-1}\) 。因為長度小於 \(2^{i}\) 的所有子串列的節點數總和都包含在 \(t_i\) 裡面了,所以 \(l\) 都在長度大於等於 \(2^i\) 的串列裡。因此可以把 \(l\) 寫成 \(A\times2^i\) 。其中 A 等於任意正整數。
\(3\times2^{i-1} + A\times2^i = 3\times2^{j-1}\)
\((3+2A)\times2^{i-1} = 3\times2^{j-1}\)
\(\dfrac{(3+2A)}{3} = 2^{j-i}\)
\(1+\dfrac{2A}{3} = 2^{j-i}\) 令 \(A = 3B\) 代入,\(B\) 為任意正整數。
\(1+2B = 2^{j-i}\) 因為 \(j>i\) ,所以 \(2^{j-i}\) 必為偶數,不存在任何正整數 \(B\) 使得 \(1+2B\) 等於偶數,假設不成立,因此不存在 \(j>i\) 且 \(t_j\) = \(3\times2^j\) 。
假設存在 \(j<i\) 且 \(t_j\) = \(3\times2^{j-1}\) 。則有一個正整數 \(l\) 使得 \(3\times2^{i-1} = 3\times2^{j-1}+l\) 。因為長度小於 \(2^{j}\) 的所有子串列的節點數總和都包含在 \(t_j\) 裡面了,所以 \(l\) 都在長度大於等於 \(2^j\) 的串列裡。因此可以把 \(l\) 寫成 \(A\times2^j\) 。其中 A 等於任意正整數。
\(3\times2^{i-1} = 3\times2^{j-1} + A\times2^j\)
\(3\times2^{i-1} = (3+2A)\times2^{j-1}\)
\(\dfrac{(3+2A)}{3} = 2^{i-j}\)
\(1+\dfrac{2A}{3} = 2^{i-j}\) 令 \(A = 3B\) 代入,\(B\) 為任意正整數。
\(1+2B = 2^{i-j}\) 因為 \(j<i\) ,所以 \(2^{i-j}\) 必為偶數,不存在任正整數 \(B\) 使得 \(1+2B\) 等於偶數,假設不成立,因此不存在 \(j<i\) 且 \(t_j\) = \(3\times2^j\) 。
總和上述兩點,可得證。
第二點:
證明在 "能保持差異最大 \(2:1\) 的情況下,能合併則合併。" 的條件下,當\(t_{n+1}\) 從 \(3\times2^n-1\) 增加到 \(3\times2^n\) 時,一定存在兩個長度為 \(2^n\) 的子串列可供合併。
當 \(n = 0\),因為長度最短為 1, \(3\times2^0\) = 3,長度分別為 1,1,1 ,因此可以合併,成立。
設當 \(n = k\) 時成立,則當 n = k+1 時,必須證明當 \(t_{k+2}\) 增加到 \(3\times2^{k+1}\) 時,一定存在兩個長度為 \(2^{k+1}\) 的子串列可合併。
第一個 \(2^{k+1}\) 子串列在 \(t_{k+1}\)來到 \(3\times2^k\) 時根據假設誕生。此時 \(t_{k+2}\) 也等於 \(3\times2^k\) ,合併後 \(t_{k+2}\) = \(3\times2^k\),\(t_{k+1}\) = \(2^k\) 。
第二個 \(2^{k+1}\) 子串列在 \(t_{k+1}\)再次來到 \(3\times2^k\) 時根據假設誕生,此時 \(t_{k+2}\) 來到 \(5\times2^k\) 。
由上述兩點可知,在 \(t_{k+2}\) 從 0 增加到 \(3\times2^{k+1}\) 的過程中,一定會經過 \(3\times2^k\) 以及 \(5\times2^k\) ,並在這兩時刻產生出長度為 \(2^{k+1}\) 的串列。所以當 \(t_{k+2}\) 增加到 \(6\times2^{k} = 3\times2^{k+1}\) 時,一定存在兩個長度為 \(2^{k+1}\) 的子串列可供合併,根據數學歸納法得證。
第三點:
證明 \(2^i\) 長度串列的合併(兩個 \(2^{i-1}\) 合併成 \(2^i\))只會發生在 \(t_i\) 變成 \(3\times2^{i-1}\) 。
由第二點可知,對所有自然數 \(i\) ,\(t_i\) 不會超過 \(3\times 2^{i-1}\) ,因為只要等於 \(3\times2^{i-1}\) 就會合併串列並變回 \(2^{i-1}\) 。所以合併不會發生在 \(t_i > 3\times2^{i-1}\) 。
當 \(t_i < 3\times 2^{i-1}\) ,此時合併成 \(2^i\) 串列無法保證兩條串列比例差異不會超過 \(2:1\) ,所以不會合併。
故合併只會發生在 \(t_i = 3\times 2^{i-1}\) 得證。
綜合上述三點,可得出當 count 增加到 count+1 ,最多只會存在一正整數 \(i\) 使得 \(t_i\) 為 \(3\times 2^{i-1}\) 。若此 \(i\) 存在,則一定可合併成長度為 \(2^i\) 的串列,且此串列為此時合併的唯一串列。
每次 count 遞增所合併的串列也不會造成更長串列的連鎖合併,因為合併成 \(2^i\) 只會改變 \(t_i\) 的值,不會改變其他的 \(t\) 值,所以不會有其他 \(t_j\) 增加成 \(3\times 2^{j-1}\) 而造成合併。
為什麼 list_sort 中串列大小的比例差距限制是二比一
因為目的是為了避免合併差距過大,因此使用二比一而不是更大三比一、四比一很合理。需要考慮的是為什麼不用三比二或四比三這種比例。
先簡述一下 list_sort 的演算法:
第一階段:
將節點一個一個讀取,如果確定合併的大小不會超過整體的特定比例,就將之前的串列合併。因為本質還是 bottom up merge sort ,所以此階段的合併都是同大小,所有子串列也都會是二的冪。
第二階段:
當所有節點都讀進並盡可能的同大小合併後,就來到必須合併不同大小的階段。這個階段會將子串列由小大到一個接一個合併。
從以上兩階段可以看出,因為第一階段都是二的冪,在維持 \(2m : 2m+1\) 比例的情況下,第一階段結束時最大的子串列就是 \(2^{\lfloor log_22mn/{(2m+1)} \rfloor}\) 。
在第二篇論文 The cost distribution of queue-mergesort, optimal mergesorts, and
power-of-two rules 中看到以下這段話:
這段話其實沒有很精準,因為當 \(n = 3\times 2^k\) 時, 在包括的情況下介於 \(n/3\) 以及 \(2n/3\) 的二的冪會有兩個,再不包括的情況下卻又會不存在。如果是寫成
\(n/3 < 2^{\lfloor log_22n/3 \rfloor} \leq 2n/3\)
就符合這段話的描述。
而這段敘述講述了一件事, \(2^{\lfloor log_22n/3 \rfloor}\) 可以找到最接近 \(n/2\) 的二的冪。
如果今天是用 \(2^{\lfloor log_23n/5 \rfloor}\) 去求 \(2^k\) ,則當 \(n = 13\) 時,\(2^{\lfloor log_23n/5 \rfloor} = 4\),而最接近 \(13/2\) 的二的冪卻是 \(8\) 。
這代表如果在第一階段用 \(3:2\) 的比例去限制,在第二階段合併中,最後一次合併會是 \(9:4\) ,這個比例差距已經超過第一階段的 \(3:2\) ,而同樣的反例在 \(2m:2m+1 ,m>2\) 中也都會發生。因此這樣的演算法只能用在 \(2:1\) 的情況。
證明 list_sort 的演算法是 optimal mergesort
optimal merge sort 定義:在最差的情況下,比較次數小於等於所有其他 mergesort 。
對所有 mergesort ,都可以繪製出一個 merge tree 來表示其合併的順序及長度,每一個節點的權重代表當下的串列長度。方形的是 external node (leaf) ,圓形的是 internal node 。如下圖所示:
internal node 的數量為 leaf 數減一。在最差的情況下,合併 (x,y) 的次數為 x+y-1 ,因此最差情況下總共的比較次數就是
\(\sum\limits_{v}^{T}({w(v)} - 1) = \sum\limits_{v}^{T}{w(v)} - (n-1)\)
其中 \(n\) 為這次排序的節點總數,也是 leaf 的總數。而 \(v\) 為所有的 internal node 。\(w(v)\) 為該 internal node 的權重(也就是長度)。在這邊因為對所有 合併排序 n - 1 都是固定的,所以只要看 \(\sum\limits_{v}^{T}{w(v)}\) 就好。
引述論文 Queue-Mergesort 中以下段落:
以及以下段落:
因此我們可以得知,只要證明 list_sort 的 merge tree 符合所有 leaf 都在最下面兩層這個條件,就可以證明它是 optimal merge sort 。
分析 list_sort 的演算法,可以得出以下兩點:
第一點:在合併的過程中,最多只有一個子串列不是二的冪(第二階段排最後的子串列)。
第二點:在合併的過程中,兩者差異不大於兩倍。
證明合併過程中對所有長度 n 的子串列,其 merge tree 的所有 leaf 都在最下面兩層。
證明過程:
第一階段所有合併皆為二的冪,符合命題。
用數學歸納法證明第二階段:
最小端的子串列只可能是 (1,1) 或 (1,2),兩者合併都符合命題。
對第二階段過程中的任意合併,假設其兩個子串列都符合命題。
則合併的過程中,由第一點可知,其中一個子串列一定為二的冪,因此其 merge tree 為 full binary tree ,所有 leaf 都在最底層。另其長度為 \(2^{k_1}\) ,則 merge tree 的高度為 \(k_1\) 。
當另一個子串列如果也是二的冪,因為第二點中兩者差異不大於兩倍,因此其大小只可能是 \(2^{k_1-1}\) 或 \(2^{k_1+1}\) 。 merge tree 的高度 \(k_2 = k_1\pm1\),符合命題。
當第二的子串列不為二的冪,那根據假設,此串列的 merge tree 所有 leaf 都在最下面兩層,而其中一層就是 \(k_1\) ,否則會違反第二點兩者差異不大於兩倍的描述,因此也符合命題。
根據數學歸納法,對第二階段過程中的任意合併,其 merge tree 的所有 leaf 都在最下面兩層。所以可以得出最後一次合併所產生的 merge tree 也符合命題。
根據論文中所述,可得知 list_sort 中的演算法也是 optimal merge sort 。
用效能分析工具比較 list_sort.c 及自己實作的排序
設計實驗並用效能分析工具比較兩者個差異,除了執行時間外,會特別關注
list_sort.c
特別優化的以下兩點:我先用
ADD_COMMAND
加入了kernel_sort
這到命令,執行時會呼叫引入qtest.c
的list_sort
。用以下命令檢測執行時間。我特別挑了 260000 以及 270000 這兩個數字,一個在 \(2^{18}\)(262144) 前,一個在 \(2^{18}\) 後,剛好可以看出 queue 的長度對於 comparison 數的影響。關於這部份的分析及圖表在下方有更詳細的實驗。
在 260000 的長度下,兩者差了 2.18 倍。 而在 270000 的長度下,則差了 2.38 倍。
comparison 次數
因為對於 George Spelvin 在其 commit message 中所提到的 \(K\) 值特別感興趣,因此參閱了他所提供的第一篇論文:Bottom-up Mergesort: A Detailed Analysis
主要是用理論來分析 bottom up 以及 top down 兩種方法輸入的 sequence 為最佳,最差以及平均的順序下,輸入的順序對於兩者比較次數的影響。
這篇論文一開始就以以下數學式為例:
\(C_{w,td}(N) = N lg N + NA(lg N)+1\)
其中 \(C_{w,td}\) 代表 top down 方法中最差情況下比較次數的函數。而 \(A\) 則如下:
\(A(x) = 1 - \left\{x\right\} - 2^{1-\left\{x\right\}}\)
其中大括號 \(\left\{ \right\}\) 是取小數的意思,\(\left\{x \right\}\) 就是取 \(x\) 的小數部份。
所以也寫成 \(A(x) = A(\left\{x \right\})\) ,可以看到 \(A\) 是一個跟輸入變數的小數有關的函數,也可說他是一個週期為一的週期函數。而不只是 \(C_{w,td}(N)\) , \(C_{b,td}(N)\) 其他如 \(C_{a,td}(N)\), \(C_{w,td}(N)\) ,\(C_{b,bu}(N)\) , \(C_{b,bu}(N)\) 以及 \(C_{b,bu}(N)\) 都有類似的特性。
將所有情況總結成以下式子:
\(C(N) = NlgN - NK(lgN) + O(1)\)
其中 \(K\) 擁有剛剛所提到週期函數的特性。
所以我在設計有關估計 \(K\) 值的實驗時,只要讓輸入變數從一個整數跑到另一個整數,把過程中產生的函數值取平均,就可以得出 \(K\) 的平均。
而 \(lgN\) 從一個整數 \(N_1\) 到另一個整數 \(N_2\) ,最簡單的方法就是讓 \(N_1\) 為二的次方,而讓 \(N_2\) 為 \(N_1\times 2-1\) 。
最後我設計出了一個實驗函式:
這個函式有兩個參數,第一個
size
就是剛剛所說的 \(N_1\) ,呼叫的人必須確保該變數是二次方,否則會被內部的檢查擋掉。第二個變數
kernel
則是用來切換我自己實作的q_sort
以及 linux kernel 所用的list_sort
。我新增了一個變數
cmp_count
來記錄比較次數,當q_sort
或list_sort
被呼叫時,他們會先將此變數清零,然後每次比較就將他加一。雖然 average case 的正確定義是所有可能排列的比較次數平均值,但要跑過所有可能排列的時間複雜度實在太高,因此在我的實驗中是隨機產生出一串 queue ,再對 \(N\) 到 \(2N-1\) 測出的 \(K\) 值取平均。
以下是實驗結果:
可以看到 kernel 的
list_sort
K 值都在 1.208 上下,而我自己實作的q_sort
則在 1.01 到 1.03 之間,確實差了快要 0.2 。而 \(K\) 又是 \(N\) 的係數,這樣下來就差了 \(0.2\times N\) 次的比較。而對於 George Spelvin 中提到 1025 跟 1028 相比,1025 的表現會比較好,因為剩下的那一個平均到最常串列的二分之一就會被合併完,而 1028 剩下的 4 個則是要平均到最長串列的四分之三。我也有做類似的實驗,不過是用 1030 而不是 1028 。
我先用了
add_param
將剛剛所提到的cmp_count
加入 option ,並在每次排序完記下它的值並算出 \(K\) 值。使用
q_sort
的情況下,隨機插入 1025 個值並排序,三次的 \(K\) 值分別為 1.25 , 1.16 , 0.7 。而長度為 1030 的 \(K\) 值則為 0.49 , 0.5 , 0.43 。
確實如 George Spelvin 所述,長度為 1025 的 queue 的 \(K\) 值有可能很差,但也有可能很好。但長度為 1030 的 queue 就幾乎都很差了。
在使用
list_sort
的情況下,長度為 1025 的三個 \(K\) 值分別為 1.23 , 1.23 , 1.25 ,長度為 1030 的 \(K\) 值則為 1.25 , 1.24 , 1.25 。強制 \(2:1\) 的條件真的讓 \(K\) 值增加且穩定很多。將各個長度的 queue 算出的 \(K\) 值用 gnuplot 繪製成折線圖,將長度從 2048 算到 4095 ,可以得到下圖:
其中 \(x\) 軸為 \(log_2N\),\(y\) 軸為 \(K\) 值。
在我的實驗以及 George Spelvin 的 commit message 中, \(K\) 值前面是有加負號的,但在論文中類似的函數並沒有。為了與論文中的圖做比較,我另外繪製了一張 \(y\) 軸為 \(-K\) 的圖:
而以下是論文中 top down merge sort (下面的函數) 與 bottom up merge sort (上面的函數) 的 average case 比較圖:
雖然實驗中沒有照 average case 的嚴格定義針對所有的排列都做統計,只是以同長度的隨機排序去做測試,但我所實作的 bottom up merge sort 整體趨勢還是與理論上的圖形相似。
從圖中可以看到,一般的 bottom up merge sort 中 \(K\) 值隨著 \(log_{2}N\) 的小數部份不同變化很大,而且可能很小。
但 kernel 中實作的
list_sort
中,強制每次合併相差不超過 \(2:1\) 後,\(K\) 值十分穩定,最差也只是在 1.2 附近徘徊。最後附上一張從 512 算到 2045 的圖,可以看出 K 值的週期性。
改良合併排序
在第三篇論文 Queue-mergesort 中可以看到以下這段:
我試著套用他所說的方法,將程式碼加入函式
q_sort
的以下片段:以下是改良後我的排序與 list_sort 的 \(K\) 值比較圖:

用 valgrind cachegrind 分析 cache miss:
測試的過程中整個執行時期可能會用到 q_insert_head 等等跟排序無關的函式,為了能更深入聚焦在排序的 cache miss,我參考了 bakudr18 的共筆,用 valgrind cachegrind 來更細節的分析各個函式的 cache miss 。
此實驗主要是執行排序效能測試的 trace-15-perf.cmd
以及我新增的 trace-15-perf.cmd ,內容是將 trace-15-perf.cmd 中的 sort command 都改成 kernel_sort command (核心所用的 list_sort):
以下是 trace-15-perf.cmd 的執行結果,因為數據太多,只節錄跟排序有關的部份:
以下是 trace-18-perf.cmd 的執行結果:
註: strcmp 數據中每個排序各有兩條,這部份我還不知道兩者有何區別,此表是將每個排序中分開的兩條相加。
從 DLmr (LL cache data read misses) 的數據中可以看到,
q_sort
總數為 8,796,052 ,list_sort
則是 2,743,449 ,兩者差了好幾倍。不過這包括了其他函式,因此我們再細看跟排序有關的幾個。讓我意外的是,原本預期
merge
的 cache misses 是最高,但竟然冒出一個strcmp
,在q_sort
中比merge
還要高。但想一想發現並不奇怪,因為在merge
中呼叫strcmp
所傳入的字串只是該字串的位址,真正要讀取到字串的記憶體內容已經到strcmp
中了,所以 cache misses 才會很高。剩下的還有q_sort
函式本體, 加總為 7,486,323 次。list_sort
也有類似的現象, 最高的是strcmp
,而因為實際讀取element_t
是在my_cmp
——即自訂的比較函式——裡面,所以再來就是my_cmp
。接著是list_sort
本體,merge
反而最低,加總為 1,390,552 。因此,與函式相關的 cache misses 就差了快六倍。可見雖然都是 bottom up 的合併排序,但深度優先或廣度優先對於 cache misses 確實差很多。
為了驗證我前面對於
strcmp
數量這麼高的解釋,並想出辦法改善,我想起之前看到 komark06的共筆,他曾經在配置element_t
以及其中的字串時,將兩者的記憶體只用一次malloc
配置,以達到 cache friendly 的效果。如果使用此方法,我應該就有很大的機會可以在
merge
中使用讀取element_t
的記憶體時,利用 locality 將字串也一同放進 cache 中,避免strcmp
cache misses 。以下是採用該方法的數據:
q_sort:
list_sort:
可以看到只進行一次
malloc
將element_t
以及字串配置在一起的話,不論是那一種排序,對 DLmr 都有很大的改善。細看各個函式的 miss 次數,也可以看到strcmp
佔所有 DLmr 的比例也大幅下降,因為在讀到字串之前就會先讀到element_t
,因此遇到 element_t 的merge
(q_sort
) 以及my_cmp
(list_sort
) 佔所有 DLmr 的比例也相對提高。在 qtest 提供新的命令 shuffle
在 qtest.c 中加入以下程式碼:
本實作時間複雜度為 \(O(n^2)\) ,希望可以找到更好的方法改善。
在
console_init()
中加入這一行:RIO 套件的使用
在
lab0-c
專案中,主要用到 rio 套件的地方是console.c
中的readline
這個函式 。首先要分析 rio 套件的目的以及優點。
一般讀檔案時,是像下圖:
直接透過
read
等 system call (trap) 將檔案的內容讀進 user buffer 中。但這會有個問題,如果今天要執行一連串單位小數量多的讀取動作,如console.c
中的readline
一個一個字元去檢查是否為'\n'
,則勢必需要呼叫很多次read
。但是 system call 涉及了 kernel/user space 之間的切換,因此大量的 system call 非常沒效率。於是就有了 rio 套件,透過引入一個中間的 rio buffer ,只用一次 system call 將大量的資料讀進這個 buffer 中,當 usr_buffer 要讀取檔案時,只需要從 rio buffer 將資料搬運到 user buffer 就好。以這種方式,就可以避免 readline 的時候,重複使用 system call 。
rio 套件現有缺陷
比較
readline
與 cs:app 中的類似函式rio_readlineb
會發現,readline
並沒有任何參數,而rio_readlineb
則有許多參數:rio_readlineb(rio_t *rp, char *usrbuf, size_t maxline)
。其中
rp
是指向rio_t
的指標,這個結構如下:它包含了目標檔案的 file descriptor ,一個 buffer ,還有兩個變數用來記目前 buffer 內還沒讀的 byte 以及目前讀到的地方。
prev
是lab0-c
專案中特別加入的,為了可以讀取複數個檔案,用 singly linked list 的方式實作一個簡單的 stack ,讓程式可以 push pop 不同的檔案。另一個參數
userbuf
是呼叫rio_readlineb
要自己提供的 buffer 。最後一個參數
maxline
則是讀取的上限。readline
沒有接收任何rio_t
以及 buffer 相關的參數,它要把資料放在哪裡呢?答案是這兩個變數:
linebuf
以及buf_stack
。他們是宣告在
console.c
中的 static global variable 。這樣的實作方式有幾個缺點:
第一個是這個 function 並沒有 reentrant 的特性。
當一個 thread 正在使用
readline
,在用完之前被中斷(如被作業系統切換或被 signal handler 切換成別的程式,甚至是自己)並在繼續執行之前有其他 thread (或是自己) 呼叫的readline
,就可能會產生錯誤 。考慮以下程式:
如果在
readline
執行到一半時,程式接到了SIGALRM
這個 signal ,那他就會跳到sigalrmhandler
執行另一個readline
,最後回到一開始的readline
時,因為linebuf
中的內容可能已經被蓋掉,而且buf_stack
中的資料也可能被讀走,因此結果就可能有錯。如果是用課本給的
readlineb
版本,因為 buffer 都是呼叫者所提供的,所以不會衝突到,可以安全的讀寫也能任意被打斷。第二點是如果執行過程中要讀取複數個檔案,而且是交錯讀取不是一次讀取,如檔案 A 讀一行檔案 B 讀一行再換檔案 A 讀一行,則必須不斷 push 、 pop file ,而且還要記下 pop 出來的 file 。
如過是用課本給的
readlineb
版本,傳入的參數本身就會附上要讀寫的檔案以及相對應的 buffer ,所以也不用頻繁的 pop 、 push file 。以上兩點在目前的
lab0-c
中還不太會發生。