contributed by < ssheep773
>
1
運作原理
使用鏈結串列實作 Quick sort
,其中的 node_t
結構如下
透過實際例子解釋程式流程:
選擇 L
指向的節點 4 作為 pivot
,將 pivot
從串列移除,並比較剩餘節點和 pivot
的大小關係,使用 list_add
將小於 pivot
的節點加入 left
,大於 pivot
的節點加入 right
。
i = 2
, begin[2] == end[2]
將 begin[2]
加入到 result
i = 1
, begin[1] == end[1]
將 begin[1]
加入到 result
然後再回到 i = 0
重複上述的步驟完成排序
此方法都是選擇最左邊的節點作為 pivot
,並且透過 begin[n] == end[n]
來確認是否完成排序,我們的結果是由小到大的排序,所以每次優先選擇 right
做排序
改進方法
pivot
的選擇
max_level
的大小
目前的選擇 pivot
的方法在處理一個排序好的遞增串列時,會使的時間複雜度
暴力解 : 可以在排序前先檢查串列是否為排序好的遞增串列,若符合擇不執行 quick sort
直接將串列反轉
使用 median of three , 查看串列的前中後三個點的大小,取中間值作為 pivot
使用 median of median , 找尋串列的中位數作為 pivot
使用 Linux 核心風格的 List API 改寫上述程式碼,並針對鏈結串列,提出可避免最差狀況的快速排序實作,應設計效能評比的測試程式。
首先引入 list.h
以及將測試程式改成測驗一中 timsort 使用的 main.c
。
修改成 Linux 核心風格的 List API ,最需要修改的是排序節點的結構是定義在 main.c
,我們無法在 quick.c
中得知排序節點的架構,無法使用list_entry
等巨集,比較節點大小時則是透過呼叫比較函式 cmp
上面是修改後的結構圖,會在排序開始時配置整個 **begin
在排序過程中配置 *begin[]
指向 head
用來串接排序的串列,
其中因為最後是將結果接回輸入的 head
需要初始化避免出錯,所以使用list_splice_tail_init(head, begin[0])
我們可以透過檢查串列只有一個節點或是為空來決定要分割還是合併串列
begin
上面的方法中我們在一開始就考慮最差的情況,建立最大的可能使用的 begin[]
,這在一般隨機的情況下會產生資源浪費(這在後續的實驗會證明),而且要求配置龐大的連續空間,也存在失敗的風險,我希望 begin[]
可以改成以鏈結串列的方式建立
建立 begin_t
的架構,並且紀錄串列最長時的 begin_t
個數
因為 begin_t
是定義在 quick.[ch]
所以我們可以使用 lis_entry
等巨集
來到實驗的部分,當樣本數是 10000 時的最壞情況, begin_t
個數來到 19999 ,符合前面用 begin[]
建立時的大小
在相同樣本中一般情況下,串列最長時的 begin_t
個數約在 2100 個左右,可以發現個數大約是原始最大值的 10% ,減少了 90% 的空間,並且是非連續的記憶體建制。
比較使用鏈結串列與陣列建立 begin 的記憶體開銷
首先是使用陣列建立的記憶體開銷
在來是使用鏈結串列動態建立的記憶體開銷
可以看到記憶體的起伏,說明記憶體的配置與釋放
pivot
的選擇使用 middle of three比較串列的第一個、最後一個與中間的節點數值,選擇他們的中間值作為 pivot
,中間的節點是透過快慢指標取得,其中的 snode->head->next->next->next
是為了要確認目前的串列至少有兩個節點,否則使用快慢指標會進入無窮迴圈,
若排序一個排序好的串列時,原始的 quick sort 會是最壞的情況 (worse case) ,而改成使用 middle of three 後,在最壞的情況下可以減少約 99% 的比較次數。
但是在隨機串列的情況下,直接使用串列首個節點作 pivot
的比較次數會較少,並且 middle of three 會有嚴重 unstable 的情況,還需要改進。
(上面提到的最壞的情況不會有 unstable 的情況,是因為樣本中沒有重複的數字)
commit d0f0990 修改 findMiddle
找中間數值的函式,這次的修改主要是確保當 a == b == c
時,會優先選擇 a
減少 unstable 的發生,而 cmpBC
可以透過 cmpAB - cmpAC
得到,可以減少一次比較
修改後 unstable 的情況大幅減少,但是仍然會發生。
這主要是因為在前幾次分割時,選擇前中後三個數字時造成的,以例子說明:
在下面的例子中節點以 數值.次序
表示,可以看到因為 pivot = 9.10
,而我們是用 < pivot <=
來劃分,這樣數值相同但是次序較前的 9.7
會被排到 9.10
後面產生 unstable 的情況,而若改成 <= pivot <
則換成 9.15
會產生 unstable 的情況,所以目前的方法還是 unstable 。
2
Timsort 程式碼理解
這裡使用的 merge
方法與 merge sort 使用的 one-pair-at-a-time mode 相同
build_prev_link
將單向鏈結串列 list
加入雙向鏈結串列中,因為 Timsort 在排序過程中,會將原本的雙向鏈結斷掉
merge_final
與前面提到的 merge
差異是合併時是雙向鏈結串列,並且如果 a
走訪完畢則結束,但若是 b
走完則會將 a
放入 b
,再透過 build_prev_link(head, tail, b)
建立剩餘節點的雙向鏈結,並且這樣撰寫的優點是我們不必分別確認 a
跟 b
是否為走訪完
merge_at
: 函式是將 tp
和 tp->prev
合併至 tp
find_run
使用圖解的方式說明 : 這裡參考 SH 同學的圖解
第一種情況 : 升序排列的節點
next
指標中節點的值不小於 list
指標中節點的值,則繼續走訪,直到找到非升序的節點為止第二種情況 : 降序排列的節點
head
指向鏈結串列的開頭, next
指向 list
的下一個節點,不過這種情況多一個 prev
next
指標,使得下一輪能夠繼續走訪並找到下一組 run,可以發現走訪過的節點皆被反轉。接著,timsort 會檢視目前的 runs 是否可以合併,這是 timsort 的一個特色,會在分割的過程中進行合併,達到對 cache 友善的效果。
merge_collapse
是 timsort 檢查 stack 中頂層 3 個 run 的大小是否滿足以下兩個條件 : ( X在最上層 )
若沒有符合則比較 X 、 Z 的大小,較小的與 Y 合併,而如果只有 2 個 run 時則只需滿足第二個條件
X = run_size(tp)
Y = run_size(tp->prev)
Z = run_size(tp->prev->prev)
在下面的程式碼 我們不只看頂層 3 個 run , 也看頂層 2 到 4 層是否滿足上述的條件
merge_force_collapse
這裡的合併就是不斷抓出頂部三個 run ,比較第一層與第三層的大小,較小的與第二層合併,直到剩下小於 3 run。合併時只跟上下層合併是為了保持排序的 stable
timsort
首先將鏈結串列斷成單向,接著執行 find_run
將串列拆分成多個 run 存在 tp 中並透過 merge_collapse
確保符合timsort 對 run 的兩個條件。
接著執行 merge_force_collapse
使 stk_size < 3
,
而我們會有剩一個 run 以及剩兩個 run 的情況,若剩一個則執行
build_prev_link
將鏈結串列串成雙向則完成,否則呼叫merge_final
將最後兩個 run 合併
目前疑惑 這段看起來是走訪到 stack 的底部,但不知道其中用意
目前的 timsort 時做並未實作 minrun 與 galloping mode ,預計加入這兩個方法並分析效能
將 timsort 引入lab0 中 commit bcd1a78
1
首先看主函式 buildTree
,使用中序 (inorder) 的序列建立 hash table ,使用 INIT_HLIST_HEAD
初始化 hash table ,再透過 node_add
建立 hash table,最後用 dfs
搭配建立的 hash table 建構二元樹
先觀察 node_add
如何建立 hash table ,根據 hash
變數決定我們在 hash table 內的位置,透過 hlist_add_head
函式將其加至 hash table 中
可以得到如下的 hash table,值得關注的是 order_node
還儲存 inorder
串列的索引值 idx
接者是 hlist_add_head
,需在加入前先檢查原先是否有節點存在,若存在則須將原先第一個節點的 pprev
指向 &n->next
, 詳細內容的可以參見 Linux 核心的 hash table 實作
在使用中序 (inorder) 建立 hash table 後,接著看到 dfs
輸入的變數 :
preorder
pre_low
pre_high
: 前序序列 與 節點搜尋範圍
inorder
in_low
in_high
: 中序序列 與 節點搜尋範圍
in_heads
:中序建立的 hash table
size
: 是 inorderSize
同時也是 hash table 的大小
tn->val = preorder[pre_low]
因為是前序 tn
必為父點,透過 find
得知 tn
在中序的位置 idx
,我們可以透過 idx
得知節點是如何劃分的
如此遞迴下去即可建構二元樹
最後來看 find
尋找節點 val
在中序的位置 idx
,而使用 hash table 可以快速地找到目標節點資訊,其中 hlist_for_each (p, &heads[hash])
為走訪特定索引中的所有節點,找到相同 val
並回傳 idx
TODO
2
LRUCache
結構
lRUCacheCreate
新增 LRU Cache 並初始化
疑問 : 在配置 hhead[] 的記憶體時,為何是使用 sizeof(struct list_head))
而不是 hlist_head
是否因為空間相同?
lRUCacheFree
釋放先前分配的記憶體並清除 LRU 快取
我們透過 list_for_each_safe
走訪快取中的所有節點,
而節點中 link
變數表示節點儲存的資料位置,我們在刪除節點 free(cache)
前需要先將 link
刪除
lRUCacheGet
使用 hash table 能夠更快速找到節點,透過 LRUNode *cache = list_entry(pos, LRUNode, node)
我們走訪特定索引值內中的節點,而且同一個索引值節點之間是用 node
連接。
並且若我們找到對應的 key
會將該節點移至 dhead
的開頭。
lRUCachePut
可以分為三種情況:
已有相同的節點存在
與 lRUCacheGet
作法相似,因為都在找尋相同 key
值,差異在於最後找到的節點 c 賦值給 cache。
沒有相同的節點存在,快取已滿,需要刪除節點
取得 dhead
尾部的節點,表示是最久未被使用的節點,將它移動至 dhead
開頭並刪除,最後在刪除的節點位置新增新的節點資訊
沒有相同的節點存在,快取未滿,可直接加入
為 cache
配置新的記憶體位置,將節點加入 head table 的索引及 dhead
開頭,最後更新目前的快取數量
在 lRUCachePut
的部分,情況 2 使用了原本刪除節點的記憶體位置,省去新配置記憶體的失敗風險,並且因為是額滿的情況也無需更新 obj->count
改進之處並實作
lRUCacheGet
中會將剛尋找到的節點移至 dhead
的開頭,我們也應該將節點移至 hlist_head
的開頭更快速的從 hash table 中找到。
延伸問題:
在 Linux 核心找出 LRU 相關程式碼並探討
3
解釋程式碼的運作原理
首先看到 find_nth_bit
他有三個變數
addr
: 指向要尋找的位元組的指標
size
: 位元的搜尋範圍
n
: 需要確認是否為 1 的位元位置
我們會先檢查 n 是否超出搜尋範圍,接著我們需要先了解 small_const_nbits
GENMASK
這兩個巨集的作用
small_const_nbits
透過 builtin_constant_p
檢查 nbits
是否在編譯時就已經確立,並且檢查是否介於範圍 0 < nbit <= BITS_PER_LONG
GENMASK
產生長度為 BITS_PER_LONG
並且在 h
位元到 l
位元之間為 1 的 bitmask
再知曉 small_const_nbits
GENMASK
的作用後,看到下面程式碼,先確認變數是否在編譯時就確立後,並生成一個由 0 到 (size-1) 都為 1 的 mask ,與 addr 做交集,高於 size 的位元會被歸零 ,如此只保留搜尋範圍內的位元
這個判斷條件的主要是在 size 足夠小時,可以用更簡單快速的方法來找到第 n 個為 1 位元,而不需要調用到較複雜的 FIND_NTH_BIT
之後再透過 fns
尋找第 nth 位元,fns
目的是找到第 n 個被設為 1 的位置,我們呼叫 __fs
尋找位元組中設為 1 的最低位元位置,並用 __clear_bit
將那個位置歸零避免重複找到,當 n = 1 時表示找到第 n 個被設為 1 的位置
接者來看 FIND_NTH_BIT
使用 idx
以 BITS_PER_LONG
長度為一個區間走訪,透過 hweight_long
可以得知區間內的 1 個數,若個數大於 nr
則代表目標 nr
在這個區間內,再呼叫 fns
在該區間內尋找確切的位置,並且 fns
結果需加上目前所在區間位置 idx * BITS_PER_LONG
,才是最後的結果
反之若 hweight_long
區間內的 1 個數不超過 nr
代表, 目標 nr
不再區間內,往下一個區間尋找,直到超過搜尋範圍 sz
延伸問題:
在 Linux 核心原始程式碼找出 find_nth_bit 的應用案例,並予以解說和分析,需要涵蓋 CPU affinity 相關的原始程式碼。