contributed by < Shawn531
>
在這個測驗中,參考 Optimized QuickSort — C Implementation (Non-Recursive),實作非遞迴 (non-recursive; iterative) 的快速排序法。
這一支程式使用的鏈結串結構體如下,使用了單向串列,有別於 list.h
的雙向串列,而結構體中的 left
right
並未使用,因此在此不討論。
max_level = 2 * n
為堆疊的最大深度,而呼叫這個函式的一開始就會先宣告兩個深度為 2n 的陣列,來保存每個堆疊的開始結束節點,也就是說不管事甚麼情況,這隻程式皆會開出兩個大小為 2n 的空間來做堆疊。
進到 while 迴圈後,首先用 L 和 R 獲取當前堆疊的開始和結束節點。之後我們選定基準值 pivot
並保存到 value
中,最一開始的 pivot 都會以最左邊的節點當作 pivot,但這樣或許對部分排序的 list 會造成浪費,因此我認為能夠在 sorting 前加入判斷是否有部分排列的函式來去選擇 pivot,不過也需要對結束條件一起做考慮。
爾後利用 while (p) {...}
處理剩餘的節點,若是大於 value
則放進 right
,反之則放進 left
。
我們可以將整個 list 依大小分為三個部分,一個是大於 pivot->value
的集合 right
、 pivot
和小於 pivot->value
的集合。這一步驟後我們即可以獲得三個集合。之後left = right = NULL; i += 2;
重置 left 和 right,並移動到下一層次。
可以看到從原本的 [4 1 3 5 2 7] 可以分成 left, pivot, right三堆。
由於 i = i + 2;
因此我們從上一個 iteration 的 right 開始解析,同樣可以在拆成 left, pivot, right,然後到下一個 iteration 時,由於剛剛的 pivot, right 都只有一個節點,因此會依序加到 result 中。
之後我們從 right 解析來看,上一輪的 right 在這一輪又可以分堆成 left, pivot, right,這邊的 left 雖然為 NULL,但在後面步驟會檢查因此不用擔心。
所以整個 list 目前就會像下方這樣,會看到有一個 3 個元素的串列和 3 個 1 個 元素的串列,下一次 iteration 的時候,就會把這一些只有一個元素的串列處理並依序加入 result
中:
其實在一般遞迴版本的 quiksort 裡面,到目前為止的步驟都是相似的,差別就是一般是使用遞迴方式實作,而在這段程式碼則使用了 stack 來代替,我認為使用 stack 來代替遞迴的優勢在於,雖然在一開始就必須先開 2 個 2n 的陣列讓他放,而遞迴版本實際上也是用 stack ,只不過它是由 assembler 所產生,因為程式遞迴而產生的組語的大小我認為會比使用 stack 代替的陣列來的小很多,而在運行時間也會優化許多雖然說這取決於編譯器。
回到程式說明的部分,之後我們就會回到迴圈一開始然後 node_t *L = begin[i], *R = end[i];
,然後檢查 L 和 R 是不是指向同一個位置,如果不是,就繼續做上述步驟,也就是繼續拆解的意思。如果他們是同一個,那就代表這是最後一個了,該把這個節點加進 result 裡面了,然後 i–,做到最後就可以完成整個排序。如下圖,result
是已經排序好的結果,因此我們只需針對尚未處理過的照著上面的流程走過一遍,即可完成排序。
我們發現發現 4
5
7
現在都是自己一堆了,因此他會被依序加入 result 中,然後我們就可以繼續處理最一開始遺留下來的 left ,接下來的步驟就跟剛剛完全一模一樣了。
list_tail
要找串列的最後一個元素,理所當然的 AAAA
會是 (*left)->next
。
list_length
透過走訪整條串列計算長度,因此 BBBB
也會是 (*left)->next
。
在測驗一使用到的操作函式 list_add
list_tail
list_length
list_construct
list_free
在 list.h
皆有相同功能的操作函式。因此我們可以直接改寫 quick_sort
。在此須特別注意的是單向與雙向鏈結串列的處理。
再新增串列陣列時,有遇到以下問題,C90 標準不允許使用 variable length array,因此我將程式碼改為用 malloc
配置記憶體。我也試過將 Makefile 的 CFLAGS
改成 C99 的標準,但後來想想這好像不符合規範,因此採用動態配置記憶體的方式。
目前是將q_quicksort
整合進 lab0-c
的專案中,額外開了一個檔案addop.c
來存放額外的函式如 shuffle
。我有參閱其他同學的作法,很多人的實作方式是將 quicksort 會引用到的 funciton 進行改寫,我的作法是因為舊功能來說,list.h
的功能已經相當完善,因此我是針對 quicksort 這個函式做修改,然而在實作方面遇到了一些記憶體洩漏的問題尚未解決,因此先在此記錄,未來待改善。
Tim Peters注意到在現實世界的資料中,通常存在著部分已經排序的子序列,這些子序列被稱為 "run"。他的排序策略是先識別這些已排序的子序列,然後通過不斷地合併這些 run 來實現對整個資料範圍的排序。
通過尋找 run 來實現資料的分堆效果,同時注意到單調遞減的 run 會在合併過程中被反轉成遞增的序列。
為了提高合併過程的均衡性,Timsort 定義了一個參數 minrun,表示每個 run 的最小長度。然而在這次的實作中,我們暫時不考慮 minrun,未來可能會引入以進一步提升效能。最佳的效能通常在 run 的數量等於或者略小於 2 的冪時實現,而在 run 數量略大於 2 的冪時,效能會下降。
也不考慮 Galloping mode 的問題,gallping 的特點就是不用從頭開始比對,假設有 A B 兩個排列好的數列,首先尋找 B 的首個元素在 A 中的排序位置,從而確定 A 中有一段是小於 B 的第一位,可以將這部分放回 A,接著尋找剩餘 A 的首個元素在 B 中的位置,如此反覆進行,直到完成排序。這次實作也沒有實現這一個功能,因此會於未來嘗試改進。
直接看到主要的函式 timsort
,首先會先檢查 head 並將其轉換為以 null 結尾的單向鏈表,之後開始查找 run 的步驟。find_run
的主要功能就是將 run 進行分堆,會先找尋升序的元素直到不再升序為止,此時則會開始找尋降序的元素直到升序為止,如此交替進行便可以找到每一個 run。更新 run 開始節點的 prev 指針,使用 tp
指向的前一個 run 的結束節點,將下一個 run 的開始節點的 prev 指向它。更新 tp 指針使 tp
指向當前 run 的開始節點。然後將 list
指向當前 run 的結束節點的下一個節點,以繼續尋找下一個 run。增加堆疊大小 stk_size。最後使用 merge_collapse
函數對遞增的 run 堆疊進行合併。
值得注意的是,若此串列是降序的排列,我們需要將它反轉成升序排列,做法是先初始化一個指針 prev
用於記錄反轉後的前一個節點。do-while 在遞減的 run 中遍歷,將目前節點 list 的 next 指向 prev,然後更新 prev 和 list。最後再將一個list->next
設置為 prev,完成反轉。
merge_collapse
簡單來說就是在讓堆疊中的 run 進行合併操作,而其中必須符合演算法中的一些規範,才會進行合併,利用這個函式來確保堆疊上的 run 長度保持平衡。該函式主要檢查堆疊頂端的 3 個 run 是否滿足以下原則:
若不符合則有相對應的 A+(B+C) 或 (A+B)+C 的合併操作,一邊進行 run 的劃分一邊進行 merge 是 Timsort 的獨特設計。
merge_at
用於在指定的位置 at 合併兩個相鄰的 run。該函數首先計算兩個 run 的大小總和,然後使用 merge 執行實際的合併操作,最後將結果更新到鏈表中。
merge
函数的目的是將兩個已排序的 run 合併成一个更大的有序 run。AAAA
是指向指標的指標,最初指向合併結果的 head
指標,因此AAAA = &head
。當比較 a
和 b
的首元素大小時,如果 a
的首元素小於等於 b
的首元素,則將 *tail
設置為 a
,並更新 tail
為 &a->next
,即指向 a
的下一個節點的指針。這樣做是為了追蹤 run 的尾部。
a
的首元素小於等於 b
的首元素時,BBBB = &a->next
,以追蹤 run a 的尾部。a
的首元素大於 b
的首元素時,CCCC = &b->next
,以追蹤 run b 的尾部。
之後會進到 tp = merge_force_clollapse(priv, cmp, tp);
這段程式碼的主要用意是要把剩餘的 run 合併到一起,會強制合併成 1 個或 2 個 run,其基本也是呼叫 merge_at
做合併,只是條件不同罷了。
往下看程式碼,主要的功能就是在重建 prev
links,但當 stk_size
若是只有一個的話,那他就可以跳過最後的merge_final
,直接使用build_prev_link
,所以FFFF = 1
。build_prev_link
的功用在於建立一個雙向鏈結串列,首先將傳入的 tail 的 next 指標連接到 list,建立鏈表的初始連結,之後透過迴圈遍歷整個列表,對每個節點進行處理。而最後的兩行設定了循環列表的頭尾相連。因此DDDD = tail->next
,EEEE = head->prev
。簡單來說就是將 list
連接到tail
,因為若stk_size <= 1
那就不會有剩餘的狀況,因此將stk0
連到head
中。
最後來解析merge_final
的目的是將兩個已排序的雙向鏈表合併成一個更大的已排序雙向鏈表(head),基本上跟merge
大同小異,差異就是最後的一段build_prev_link
,可以透過這個函式將後面剩餘的串列直接加到tail
後面。
Binary Tree 是一種樹狀結構,其中每個節點最多有兩個子節點,分別稱為左子樹和右子樹。
preorder:
首先訪問根節點,然後遍歷左子樹,最後遍歷右子樹。在遍歷過程中,節點的訪問順序是「root-left-right」。
inorder:
首先遍歷左子樹,然後訪問根節點,最後遍歷右子樹。在遍歷過程中,節點的訪問順序是「left-root-right」。
因此我們就可以利用 preorder 和 inorder 跟在不同的位置,得知目前左子樹和右子樹的範圍,只要走訪完整兩個 order,便可以完整建構一個 binary tree。在這個測驗主要是透過 dfs 深度優先搜索來建立。
在這個程式中的結構體主要是利用單向鏈結串列實現,可以看到下圖的圖示說明。
除了上面的基本結構外,此測驗也加入了 Hash Table 的概念,基本上就是就是將 hlist_head
結構體變成 array,以空間換取時間,圖示說明如下:
首先先看到 hlist_add_head
,這段程式碼的功用在於加入節點到串列的第一位,因此首先檢查 h->first
是否存在,若不存在需要手動初始化,由於我們要把節點 n 加入到第一個,因此我們要將n->next
指向原本串列的第一個,所以AAAA = h->first
,再來將n->pprev
指向原本的&h->first
,最後將 n 放到 h->first
。
node_add
的功能是將節點加入 Hash Table 中對應值的鏈結串列的頭部,hlist_head_add
可以理解為將節點加入串列頭部的基本操作,而node_add
則需要先透過 Hash Table 找到想要操作的串列,因此 DDDD = &heads[hash]
find
主要用來在 Hash Table 查找特定數字的函式。首先計算 Hash Value,決定我們要在哪一個串列做搜索。接著透過 hlist_for_each 遍歷欲搜索的鏈結串列,BBBB = &head[hash]
。為了由struct hlist_node *p
這個型態映射到order_node
,可以使用container_of
達到目的,也可以使用list_entry
,因此CCCC = container_of
。這裡值得注意的是,不同於以往的遍歷鏈結串列搜索 O(n),透過 Hash Table 的方式進行搜尋可以將搜索時間減少到常數時間 O(1)。
接下來看到最主要建構 binary tree 的函式 dfs
,使用遞迴的方式來操作,傳入的參數有:
int *preorder
為 preorder list 的第一個位址。int pre_low
指該次遞迴的 preorder 下限。int pre_high
指該次遞迴的 preorder 上限。int *inorder
為 inorder list的第一個位址。int in_low
指該次遞迴的 inorder 下限。int in_high
指該次遞迴的 inorder 上限。stuct hlist_head *in_heads
Hash Table 結構體的位址。int size
Hash Table 的欄位總數在函式的一開始先決定了中止條件,若上限小於下限則會 return NULL
。
現在開始建構 binary tree,以範例為例,preorder 為 [3, 9, 20, 15, 7]
,inorder 為 [9, 3, 15, 20, 7]
。首先配置記憶體給要加入的節點,並把 preorder[pre_low]
加入 tn->val
,初始的 pre_low
為 0,因為 preorder 的順序為 中-左-右,因此第一個元素一定代表了 root,所以我們可以透過find(preorder[pre_low], size, in_heads)
到 inorder list 查找 root 的位置,如此一來在這一遞迴就可以劃清左子樹以及右子數。
然後就可以進到下一次左子樹的遞迴,tn->left = dfs(preorder, pre_low + 1, pre_low + (idx - in_low), inorder, in_low, idx - 1, in_heads, size);
,說明一下裡面的參數,preorder 的下限會加一,搜尋範圍則會透過idx - in_low
決定,意義就是透過 inorder 的 root 位置和 inorder 下限所形成的範圍,也就是說上一次遞迴左子樹的部分,inorder 則會透過原先的下限 in_low
和上一次遞迴的 root 位置 -1 所決定。當左子樹找完就可以找右子樹。上下限的表示如下圖所示,只要根據目前的位置以及範圍就可以準確圈出上下限。
最後建構 binary tree 的函式,先定義以及初始化 preorder list 和 inorder list,然後再進行 dfs
。
LRU(Least Recently Used) Cache 是一種實作 Cache 的策略,由於 Cache 的容量並不是無限大,因此需要針對元素的使用模式來決定哪些元素應該被保留或丟棄。
簡單來說,LRU Cache 的原則是保持最近使用的元素,淘汰最久未使用的元素。實作方法就是將使用過元素記錄到串列中,如此一來便可實現淘汰最久未使用的元素。通常使用雙向鏈結串列和雜湊表 Hash Table 的結合,在這邊 Hash Table 使用單向鏈結串列實作。雙向鏈結串列用於維護元素的使用順序,而哈希表則用於實現快速的查找和刪除操作,因此可以看到兩種不同的串列型態。
不同於雙向鏈結串列的,**pprev
是一個指標的指標的方式呈現,通常用於在修改鏈表結構時更新前一個節點的next指標。這樣設計的目的是為了能夠在操作中修改前一個節點的next指標,而不需要返回上一個節點,若一樣寫成 *prev
的話,那他在 function 就會變成透過 pass by value 傳遞,造成效率不彰。
下方的 function hlist_add_head
可以看到如果 list 裡面沒有東西的話,則更新 head 的 pprev pointer,將新節點的 next 指標指向原串列的第一個節點,並將新節點的 pprev 指標指向鏈表的頭部指標的地址,最後更新串列的 head pointer,使其指向新的節點 n。
接下來看到 hlist_del function,一開始先從節點 n 中取得下一個節點 next
以及前一個節點的 next
指標的地址 pprev
,之後更新前一個節點的 next
指標,將其指向下一個節點,就是跳過節點 n
的意思,最後如果下一個節點存在,需要把下一個節點的 pprev
指標指向前一個節點的 next 指標的地址。因此EEEE = next->pprev
。
雙向串列的部分基本與 lab0-c 一樣,在這邊就不多加討論。
直接看到結構體的部分,定義了兩個結構體,分別是 LRUCache
和 LRUNode
。
LRUCache
裡包括了 capacity
表示 LRUCache 的最大容量,count
則表示當前已儲存的 key-value pair 數量。dhead
用來記錄 key-value pair 的使用順序,這個雙向串列的節點就是 LRUNode
的 link
成員。hhead[]
是一個 Hash Table 的 hlist_head array,可以加速查找 value。而每個 hhead 大小皆為 capacity。
LRUNode
裡面包括了 key
和 value
,主要會透過他們去做映射,node
是用於 Hash Table 的節點,link
則是用來記錄 LRU 順序的節點。
了解基本結構體後就可以來看函式操作了,有四個函式分別是 lRUCacheCreate
lRUCacheFree
lRUCacheGet
lRUCachePut
,比較特別的操作是 lRUCacheGet
和 lRUCachePut
。lRUCacheGet
的功用是透過傳遞 key 取值,需要判斷 key-value 有沒有在 cache 裡面,有的話 return value ,沒有的話 return -1。lRUCachePut
則是要放進去,如果有這對 key-value pair 的話,那麼直接更新 value 即可,如果沒有的話則要從記憶體存取並更新。
lRUCacheCreate
首先會根據LRUCache
結構體去分配記憶體,使用INIT_HLSIT_HEAD
來初始化LRUCache
的 head array hhead
。
利用list_for_each_safe
走訪已使用過的&obj->dhead
雙向鏈結,可以透過 n (下一個節點)來安全的刪除節點,因此 list_del(GGGG)
裡面應為 n->prev
確保不會因為刪除了當前節點而造成斷裂。由於 pos
的資料型態為 list_head
,因此在 LRUNode 中pos
屬於link
成員,list_entry(pos, LRUNode, FFFF)
應該填入link
。
接下來來到了最關鍵的兩個 function,先來分析lRUCacheGet
,這個函式是用來讀值,首先先計算 key 的 Hash value,接下來使用 hlist_for_each
走訪 obj->hhead[hash]
Hash Table 中的 key-value pair。使用list_entry(pos, LRUNode, HHHH)
來獲取 pos 在 LRUNode 的結構,跟上一個函式大同小異,可以透過觀察 pos
資料型態來判斷HHHH = node
。若傳入的 key 與 cache->key 相同的話則將此節點移到obj->dhead
,用來記錄使用的順序,因此IIII = &cache->link
,若已走訪整條串列還沒有找到相同的 key 值,則 return -1
。
lRUCachePut
的功能為更新和寫 cache,一開始同樣要計算 Hash value,一樣透過 hlist_for_each
走訪 obj->hhead[has]
,同樣的邏輯JJJJ = node
,如果 c->key
和傳入的 key
匹配到的話,則同樣的將此節點加入obj->dhead
紀錄使用順序,所以KKKK = &c->link
,到這為止基本上與lRUCacheGet
差不多,然而如果key
不匹配的話,我們則要另外做處理。
此時會有兩種狀況,第一種是 cache 還有尚未使用的空間,這邊是使用count
和capacity
互相比較,若count < capacity
,就可以直接將傳入的 key
value
新增至串列中,所以我們需要先分配一個節點,把key
value
加入,並將他記錄到 obj->dhead
中。
第二種狀況則是 cache 已滿,那這時候我們就要考慮要刪除誰了,Least Recently Used 的節點就變成我們需要刪除的對象,如此才有空間把新的加進來,因此可以透過cache = list_last_entry(&obj->dhead, LRUNode, link);
將最少用到的節點抓出來,然後把它移到 dhead 的最前面,代表最近被使用過了,然後從 Hash 串列刪除,然後再將新的節點加入 hhead 中。如此一來則可以完成 LRU Cache 的實作。
考慮 find_nth_bit 可在指定的記憶體空間找出第 N 個設定的位元。
首先先來看到fns
,要如何在一個 unsigned long 的長度的 word
找到第 N 個設定的位元。傳遞的參數有兩個:
首先當然是先檢查這個 word 是不是有效的,再來我們可以透過__ffs
來去尋找第一個被設定的位元,然後我們再透過一個 n--
的機制就可以找到第 N 個被設定的位置。值得注意的是我們在找到每一個 1 後,需要用__clear_bit
將找到的 bit 清除掉,否則他永遠會找到第一個 bit。
接下來看到 __ffs
,這邊就是在尋找一個 word 的第一個被設定的位元,從最低位開始找會有幾個 0。會先從最低的 32 位開始檢查,若成立 num += 32,word 會右移 32 位,再檢查最低的 16 位,一直檢查到 1 位。檢查的方式是透過與 2 的冪位全 1 做且運算,因此 AAAA = 0xffffffff
。
__clear_bit
的共用是將第 nr
位的 bit 設為 0。使用到了兩個巨集BIT_MASK
和BIT_WORD
,BIT_WORD
的用意在於獲取 nr / BITS_PER_LONG
的商數,若nr
大於BITS_PER_LONG
的話,我們就需要使用獲取的商數對addr
做偏移,才能取到我們想要的地方。
舉例來說,我們要操作的函式為 __clear_bit(66, addr)
,所以我們要處理的數字應該是010...100000...000
,65 個 0。
所以p
會存取到下方這張圖的值。
就可以與 ~mask
做且運算,如此一來可以精準的清除掉第 66 個位元的 1 而不會動到其他位元的值,因此BBBB = ~mask
。
對比 fns
只是在一個 word 內尋找,接下來我們要將範圍擴增到 memory region。說明一下傳遞的參數:
首先若要找的 n
大於 size
則會直接 return size
。然後會根據small_const_nbits(size)
來區分成兩種情況,簡單來說small_const_nbits
其實就是在判斷 size
是不是小於BITS_PER_LONG
,如果 size
小於BITS_PER_LONG
的話會進到 if statement 裡面,首先我們會使用 *addr & GENMASK(size - 1, 0)
讓val
只在第 0 bit到第size
bit的位元數為原本的值,範圍外的皆為 0。然後就可以使用fns
去尋找。
但若是size
大於BITS_PER_LONG
則會使用另外一個巨集FIND_NTH_BIT
處理,裡面使用一個 for loop 處理,一次處理一個 unsigned long 的長度,也可以分做幾種情況:
if (idx * BITS_PER_LONG + nr >= sz)
代表我們要找的目標已經超出 size,所以會跑到 out 的地方,return sz。
if (w > nr)
其中w = hweight_long(tmp)
代表我們再tmp
總共觀察到了幾個 1,tmp = addr[idx]
,因此可以理解為如果觀察到的 1 的數量大於我們要找的 nr
的話,代表我們的目標一定在這個區段內,因此可以直接跳到 found
,然後利用 fns
再加上已經偏移的地址就可以得到 sz。如果w < nr
代表目標不再這個區段,因此nr -= w
。
這一段在說的是如果我們的 sz
不能夠被 BITS_PER_LONG
整除的話,代表最後會有剩餘的 bits,因此我們需要將他擴充為 64 bits,所以CCCC = %
。