contributed by < BooleanII
>
佇列與串列的操作,單透過文字時常難以清楚描述,需要輔以圖示說明讓整個流程更加易懂,作業要求中提到使用 Graphviz
進行圖片繪製,以下為使用 Graphviz 的筆記。
Grpahviz 安裝 (under Ubuntu)
Graphviz 提供多種 layout engine 繪製不同類型的圖片,甚至可以自行建立客製化的 layout engine 畫出自己想要的圖片風格,下列皆為同一組描述文字檔使用不同 layout engine 繪製的圖片。
Layout Engine | 範例圖片 |
---|---|
dot | |
neato | |
fdp | |
sfdp | |
circo | |
twopi | |
osag | |
patchwork |
圖片內容以 .dot 檔進行描述,描述順序不影響繪圖結果,註解的語法除了 C 語言中的 //
和 /**/
外,還多了 #
可以使用,其常用語法範例如下所示:
需注意使用 digraph
或 graph
進行描述的差異,一開始測試因為這個問題花了不少時間。前者的連線帶有箭頭,且連線的描述文字為 ->
,而後者的連線描述文字為 --
,如混用則該文字檔進行編譯時系統會顯示 syntax error。
撰寫好描述文字檔後,需使用命令進行編譯產生圖片,命令格式如下:
透過 cmd 選擇要使用上面提到的那一個 layout engine 進行繪圖,並以 -Tpng
選擇輸出圖片格式為 png 檔、 -o filename
指定輸出圖檔名稱,範例如下:
參考文件
1
題目提到是使用 Optimized QuickSort — C Implementation (Non-Recursive) 的 Quick Sort 演算法進行修改,故先閱讀該網頁中的介紹,該演算法提出使用 L
與 R
紀錄需交換資料的指標,每次的 pivot run 排序流程如下:
R
自串列末端節點開始,往串列的前一個節點移動,直到該節點的數值小於 pivot 時停止,將該節點的資料複製到 L
L
自串列起始節點開始,往串列的下一個節點移動,直到該節點的數值大於 pivot 時停止,將該節點的資料複製到 R
R
的位置比 L
更接近串列起始點L
的節點寫入 pivot 數值該作者表示其演算法的具備下列優缺點:
在測驗 1 題目描述中,說明作法是將 'L' 與 'R' 找出需要交換的數量,以範例的串列為例,取串列的 head 為 pivot , L 應會紀錄到3筆小於 pivot 的節點,而 R 則紀錄到有2筆大於 pivot 的節點(題目解說需要修改)。
然而一開始無法將測驗的題目描述與提供的程式碼內容對應起來,以為 L
與 R
直接用於儲存需要交換的序列長度,但程式碼中將兩者宣告為 node_t
結構體,明顯並非單純紀錄數量。閱讀整份程式碼並對照 Optimized QuickSort — C Implementation (Non-Recursive) 後才慢慢想通。
題目的快速排序同樣使用第一筆資料做為 pivot ,但 L
與 R
的用途為標示待排序序列的起始點與末端點,而 begin
與 end
用於儲存尚未排列好的序列起始點與末端點,存入 stack 的順序如下:
每一輪的排序使用 begin 陣列中的最後一筆序列,在第一輪排序中,我們將 list 的開頭與末端分別放入 begin 和 end 中, list_tail 函式功能為走訪序列上的每一個節點,回傳 list 末端的 node 的指標。而在 quick_sort 函式中被呼叫的 list_length 函式與 list_tail 大同小異,同樣是以走訪每一個節點的方式計算序列的長度,故兩者皆透過下列程式碼將 left 指向序列中的下一個節點:
p
節點指標用於走訪整個序列,將每個節點的值與 pivot 進行比較。因此,在 while 迴圈中, p
需要指向下一個節點,並執行 quick_sort 函式中最關鍵的部分:將小於等於 pivot 的節點存入 left
序列,大於 pivot 的節點存入 right
序列中。
根據前述描述,將比較後的 left 與 right 序列分別存入 begin
和 end
stack 中,由於 end
代表的是序列的末端點,可以透過呼叫 list_tail 函式取得,以下是存入 stack 的完整程式碼如下:
當 stack 中的最後一筆序列的起始點與末端點為同一個節點時,表示該序列只包含一個已完成排序的節點。在這種情況下,我們可以將 stack 中的資料 pop 出來,並用 list_add 將它們加入 result 序列中。然後對 stack 中剩下的序列重複執行上述流程,以獲得排序完成的序列。
總結測驗 1 的答案如下:
這邊以 lab0-c 作業中的結構體來進行實現。 node_t
可使用變形後的 element_t
結構體取代,將 value
成員的型態由 char *
改為 int
,沿用 list_head 結構體來達成 Linux 核心風格的佇列結構,並搭配 list.h 提供的佇列操作函式進行相關實作。
儲存未排序佇列的 begin 與 end 兩個 stack ,因可利用 list_head 結構體中的 prev
指標,避免使用 list_tail 走訪整個佇列,以獲得佇列中的最後一個節點,並省去使用 end 儲存未排序的末端點。
因佇列的 head
並未儲存資料,故進行排序時將 head
移除,保留第一個資料節點的 prev
指向佇列中的末端點,佇列末端點的 next
指向 NULL ,使佇列變形為串列。修改後的 quick sort 程式碼如下:
實作後發現為了使用 list_head 結構體中的 prev 成員儲存串列的末端點,程式碼較原本的版本多了好幾處的細節處理,如 R 需判斷當前的 stack 內容是否為 NULL , list_add 函式也需針對輸入的串列指標是否指向 NULL 來決定 prev assign 的內容。
在實際進行改進之前,我們先比較原始的程式碼和使用 List API 改寫後的版本。這兩個版本在不同資料量下使用 stack 的深度是相同的,因為它們僅僅是對資料結構的不同實作方式。此外,我們也需要留意到,對於相同資料量,shuffle 函式會產生相同的洗牌結果。因此,對於相同長度的資料,使用 shuffle 函式處理後進行測試,stack 的使用深度也將保持一致。
在 Linux 系統中,可以使用 clock 函式來獲取程式碼的執行時間,以檢測程式碼在不同的資料長度下的執行時間。請注意,這僅測量快速排序的執行時間,不包含 shuffle 的執行時間。以下是執行 100,000 次的平均結果,顯示使用 list API 實作的版本速度比原始程式碼更快。
以 1000 筆資料的排序條件下進行比較,使用 perf record 紀錄兩者執行 100000 次,所獲得的性能指標。從結果可發現原始程式碼雖然有較高的 IPC ,但實際執行時間卻比 List API 版本來的久。
Event | 原始程式碼 | 使用 List API | Better |
---|---|---|---|
Cache misses | 171757.2383 | 66258.646 | List API |
Cache references | 401250.1308 | 64210.7886 | 原始程式碼 |
Branch misses | 454205710.1405 | 42053879.6032 | List API |
Branches | 8143817422.8188 | 8007732828.4409 | List API |
Instructions | 95711793.1548 | 58791077.9339 | 原始程式碼 |
Cycles | 36256592463.9717 | 27774658834.164 | 原始程式碼 |
Page faults | 0 | 0 | Same |
CPU migration | 3.9996 | 4 | |
Context Switch | 60.0038 | 26.9982 | List API |
Task clock | 8815.5827 | 6417.4784 | List API |
當使用第一筆資料作為 pivot ,且資料是最已排序的或者是逆序排列時,就會導致最壞情況發生。這種情況下,快速排序的效率會降到最低,時間複雜度將達到 。這是因為在這種情況下,每次切割都只會將一個元素移到正確的位置上,而不是將資料均勻地分割為兩部分,造成每筆資料都會單獨存入 stack 中,使 stack 使用的深度將與資料長度相同。
由上述的比較可看出待改進的點有二:
2
Timsort演算法主要行為是將序列中已完成排序的部份切割為子序列( run ),當 run 的滿足特定條件時,使用合併排序( merge sort )將兩個 run 進行合併。
在題目解說中,有提到『合併序列時,若 run 的數量等於或者略小於 2 的冪,效率會最高;若略大於 2 的冪,效率就會特別低。』。乍看之下不是很懂哪個要略小於 2 的冪才能獲得較高效率,是指 minrun 的長度還是切割後 run 的數量?
參考維基百科的介紹進行整理:
Timsort 運作時會定義最一開始切割的每個 run 最小長度為 minrun ,切割時當已排序的 run 長度不足 minrun 時,會將方的資料節點以 insertion sort 方式插入,使該 run 長度滿足 minrun 。
切割完 run 後, Timsort 會使用合併排序將兩個 run 合併為一個 run ,而要將哪兩個 run 進行合併則依據 run 的長度,以下列關係式進行決定。當 XYZ 三個 run 之間無法滿足關係式時, X 與 Y run 會進行合併。
因 Timesort 為 stable sorting 演算法(同樣大小的節點的順序在排序後不變),為了在時間與空間成本上達到平衡,在合併 run 時只變更兩個 run 間需要合併的範圍。
首先, Timsort 會搜尋 Y run 中的第一個節點在 X run 中合併時要插入的位置,接著搜尋 X run 中末端節點合併入 Y run時要插入的位置。合併時僅對這兩個插入點之間的節點進行操作,節省 merge 時所需使用的暫存記憶體空間。
維基百科引述下列連結文章,指出當 run 的數量小於或等於 2 的冪時,在合併階段可以得到較好的效率,降低 run 在合併時因長度落差過大造成的效率降低問題。
名詞定義
minrun :run的最小分割長度
N :序列資料總數
n :切割後的 run 長度
測驗題目的 timsort 在合併時不需 malloc 暫存記憶體空間 (in-place algorithm) ,共分為六個函式進行實作,為了統計排序過程中比較次數,在每個函式輸入 priv 的 void 指標,用來傳遞並累加比較次數的參數 count 。
需注意題目的比較節點資料大小的方式,有別於直接使用 if 判斷式,而是透過呼叫 輸入參數的 cmp
的函式指標,以函式 compare
進行比較,增加整體程式碼的靈活度(如透過呼叫不同的 compare 函式決定排序的方向為 descend 或 ascend )。
首先使用 find_run 函式進行串列的 run 切割,當發現到資料串列中存在降序排列時,會將該節點切割放入 prev 串列中,放入的過程中會進行反轉使其成為升序排列;如資料為升序排列時,則會繼續走訪下一個節點,直到出現降序排列時停止。這個切割方法未如同傳統 timsort 演算法介紹中提到的使用特定 minrun 大小進行切割,是後續可以進行改進的地方。
切割好的 run 串列透過 pair 結構體的 head 成員進行儲存,並回傳至作為 stack 使用的 tp 串列,而剩餘尚未切割的串列則儲存於 next 成員中。需注意 find_run 切割時未處理節點的 prev 指標內容,僅將 run 長度儲存於該 run 串列中第二個資料節點的 prev 進行儲存 ( head->next->prev )。
以下圖串列 [8,11,9,7,5] 為例
共可被切割為兩個 run :
並依序存入 tp stack 中:
在此測驗的程式碼中區分了好幾種合併的函式,當每完成一個 run 切割,皆會呼叫一次 merge_collapse 函式,如 stack 中的 run 長度滿下列條件就進行合併。
merge_collapse 函式的合併是使用 merge 函式進行,為實現 in-place algorithm , merge 函式未 malloc 暫存的記憶體空間,而使用間接指標 **tail 來達成資料合併。該操作手法可以參照 你所不知道的 C 語言:指標篇 ,以間接指標 tail 指向當前完成合併的串列末端點,故在最一開始的時候應指向回傳合併串列的 head ,將 tail assign 為 &head 儲存 head 的位址。
完成比較後,將 *tail assign 為較小的資料節點指標,使 head 指向 a 或 b 串列中最小的節點,並將 tail assign 為該資料節點結構體中的 next 成員,讓 *tail 在下一次比較中可指向次小的節點,不斷執行到其中一方的串列走訪完畢時,將 *tail assgin 剩餘的串列。
當輸入串列完成所有的 run 切割後,呼叫 merge_force_collapse 函式將 stack 中所有的 run 兩兩進行合併,直到 stack 中的 run 數量小於三個才停止,此處看起來是個可以改進的地方,如果能夠將所有 run 在此函式中完成合併,若 stack 中只剩兩個 run 時,後續就不用額外使用 merge_final 函式進行合併。
最後,要注意因先前的串列操作皆未對 list_head 結構體中的 prev 指標進行處理,故完成排序後透過 build_prev_link 函式,將 prev 重新寫入正確的指向節點,並在最後將 tail->next 需指向 head ,而 head->prev 需指向 tail讓整個排序好的串列可回到 Linux 風格的環狀佇列架構。
因為 build_prev_link 函式是在完成所有 run 的合併後才能執行,故 timsort 函式最後的 if 判斷式數值應為 1 。
總結測驗 2 的答案如下:
1
2
3
題目為實作 find_nth_bit
函式,讓其可在連續的記憶體空間找出第 N 個設定的位元。由於直接從 find_nth_bit 開始看程式碼看不太懂運作原理,故先從此函式的的程式碼最終會使用到 __ffs
開始說明。
__ffs 用於找出輸入的 word 中哪個位元是第一個為 1 的位元(由最低有效位元 Least Significant Bit 開始找)。 __ffs
函式使用 binary search 方式進行實作,透過 bit mask 確認右半邊的位元是否皆為零,若皆為零則將輸入的數值向右位移,搜尋剩下的另外一半位元。以 64 bit 長度的 word 為例,使用 '0xffffffff' 的 bit mask 確認右半邊的 32 bit 資料是否皆為0,若成立則將位置變數 num 累加 32 ,並將 word 向右位移 32 bit ,接續確認剩餘的 32 bit 資料。需注意此函式如果輸入的 word 內容為 0
時,輸出結果會是錯誤的 63 。
接續來看有呼叫 __ffs 的 fns
函式,其功能為找出輸入的 word 中,第 'n' 個為 1 的位元位置,注意這裡的 n 是由 0 開始數,當 n 的數值非零時,代表 __ffs 找到的位元位置被非所要的位置,要將該位元設為 0 ,重新執行 __ffs 找到下一個為 1 的位置。以函式輸入 word 為 5 、 n 為 1 ,其輸出結果應為 2 。
fns 中使用 __clear_bit 函式進行上述將 word 特定位元設為 0 的實做,由於 find_nth_bit
函式的功能需能用在連續記憶體空間中,故這邊使用指標處理當指定的位元位置超出一個 long 的長度時的狀況,並在最後以BIT_MASK 函式獲得的 bit mask 將目標的位元設為 0
。需注意由於是將目標位元設為 0 ,故在與 *p 做 & 處理前需將 bit mask 取 not 運算。
接續看到 small_const_nbits 巨集( macro ),當中用到 gcc 提供的 built-in function __builtin_constant_p
,該函式的功能是讓 gcc 在編譯時檢查輸入的參數( argument )是否為常數,若 gcc 無法在優化選項的條件下判斷其為常數則回傳為 0 ,反之則回傳 1 代表確定是常數。故該函式僅有在 nbits 同時滿足大於零、小於等於 long 長度且 gcc 判斷為常數時回傳為 1 。故當 find_nth_bit 要找尋的資料範圍大於一個 long 長度時,將使用 FIND_NTH_BIT 找處第 N 個為 1 的位元位置。
GENMASK 巨集因涉及到了 bitwise 操作,透過直接帶入數字實驗很快就能抓到邏輯,該巨集的作用為建立指定區間為 1(set) 的 bit mask , h 作為 head 決定最後一個為 set 的位元, l 作為 last 決定第一個為 set 的位元。其運作的原理首先透過 ~0UL 獲得 0xFFFF FFFF FFFF FFFF ,將 ~0UL 減去 1ul向左位移 l 後的數值,使 l 位元設定為 0 ,在透過加 1 讓較 l 低的位元設定為 0 ;而透過向右位移 ~0UL 可讓較 h 高的位元設為 0 ,使用 h 與 l 分別產生的兩個 bit mask 取 & 獲得兩者皆為 1 的位元 bit mask。
以 long 為 64 bits 的電腦為例,當 h 為 15 、 l 為 4 時, GENMASK 的輸出為 0x0000 0000 0000 FFF0 。此巨集在 find_nth_bit 函式中,用於限制 fns 搜尋的範圍,避免 find_nth_bit 回傳超出 size 參數範圍的結果。
再來看到 FIND_NTH_BIT 巨集實作所使用的 hweight_long 巨集,接連使用 __const_hweight64 、 __const_hweight32 、 __const_hweight16 和 __const_hweight8 組成,由呼叫的方式可推測以 byte 為單位,逐步擴展到 64 bits 的運算。
從最底層的 __const_hweight8 原理開始解說,此巨集最巧妙的地方在於連續使用兩次 not 的邏輯運算,非零的數值經過兩次 not 邏輯運算後結果皆為 true ( 1 ),再透過搭配 bit mask 可以計算該 byte 中為 1 的位元數量,並以此為基礎擴充到可計算 64 bits 長度資料中為 1 的位元數量。
接續解說 BITMAP_LAST_WORD_MASK ,其功能為列出最低位元 nbits 皆為 1 的 bit mask。以byte 長度的 bit mask 為例, nbits 為 1 時結果為 0x01 ; nbits 為 3 時結果為 0x07 。
回到 FIND_NTH_BIT 巨集,其輸入共有 FETCH 、 size 、 num 三個參數,傳入的資訊如下:
FIND_NTH_BIT 巨集的關鍵為下列 for 迴圈, idx 用於決定搜尋的記憶體 index 位置,當 idx 為 0 ,搜尋的記憶體位址為 FETCH ;當 idx 為 1 時,搜尋的記憶體位址為 FETCH+1 。故在 for 迴圈中的結束判斷條件中,使用當 idx 指到的記憶體位置超出搜尋範圍 size 大小時,則終止 for 迴圈的執行。
在該 for 迴圈中,每當呼叫 hweight_long 獲得當前記憶體位置的資料中為 1 的位元數量後,會將 num 數值減去該結果,代表還剩下多少個為 1 的位元尚未找尋到。
使用 goto 決定該巨集輸出的結果,第一個 if 判斷式將搜尋為 1 的位元數量納入是否要離開 for 迴圈的判斷依據。以 idx 為 0 時來看,當搜尋為 1 的位元數量大於等於搜尋的記憶體位元長度時, 代表需要搜尋為 1 的位元數量已超出所設定的搜尋範圍,故使用 goto 跳躍到 out 程式位置直接回傳 size 數值。反之,則使用 hweight_long 獲得當下記憶體內容中為 1 的位元數量,如大於目標搜尋數量,則代表該位址中存在搜尋的目標位元,使用 goto 跳躍到 found 程式位置,以 fns 找出並回傳位元的位置。需注意此處使用 min 比較 fns 計算結果與 size 哪個比較小後決定實際的輸出結果,主要是當 fns 未找到目標位元時,會回傳 BITS_PER_LONG ,透過 min 可限制 FIND_NTH_BIT 回傳的數值最大為 size 。
需注意當 size 非 64 bits 的倍數時,需要額外透過下列程式碼進行處理,透過 BITMAP_LAST_WORD_MASK 將超出 size 範圍的位元設為 0 ,避免後續的 fns 將其算入,故 CCCC 的答案為 < 。
最後提出我對這段程式碼感到疑惑且驚訝的地方,FIND_NTH_BIT 在 find_nth_bit 函式中以 addr[idx] 作為第一個輸入參數,使 FIND_NTH_BIT 在 idx 大於 0 時,可以透過 addr[idx] 讓 tmp assign 超出 64 bits 範圍的資料,這是我第一次看到的操作方式,這部份應該是 preprocessor 方面的語法應用,後續會花時間進行研究。
總結測驗 3 的答案如下: