contributed by <Dennis40816
>
AAAA
: &l->head
BBBB
: before
CCCC
: &(*p)->next
DDDD
: item->next
本題中使用單向鏈結串列。
先從 list_insert_before
的解釋著手:
得知 before
指向的,是當插入新節點 item
後, item
的下一個元素。
當 before
是頭部節點,item
將被插入在最前方,如果是 NULL
則被插入在最尾端。
list_insert_before
做的事情是:
before
目前所在的位置 (第 2 - 3 行)before
用 item
替換 (第 4 行)item
的下個元素指向 before
(第 5 行)因此,AAAA
- DDDD
的內容推論如下:
before
應當從頭部節點開始,struct list_t
中僅有成員 head
,故 AAAA
為 &l->head
。*p
指向的節點與 before
相同時,故 BBBB
為 before
。應注意,list_insert_before
的註釋提到當 before
不存在於 l
中時,可能產生未定義行為,推測將因為對尾部節點的下一個,即 NULL
解引用導致 core dump。p
應該往下移動,故 CCCC
為 &(*p)->next
。應注意運算子的優先級別,在此為 ->
> *
> &
,詳見 C Operator Precedence,故括弧應僅包含 *p
。item
的下一個節點換作 before
,故 DDDD
為 item->next
。以 Graphviz 呈現:
當循環開始時,*p
指向 head
當 *p
指向的節點為 before
時,插入 item
將 item->next
指向 before
注意 p 指向的是節點 next
的地址,從圖中看便是指向 next
,而 next
再指向下一個節點,可以描述出 p
是 indirect pointer。之所以說優雅是因為頭部節點的地址實際存儲在 &head
記憶體中,「像是」一個 next
的存在,這使的操作得以一致化。
測試程式 test_list
的流程如下:
list_reset
初始化全域串列與節點陣列,將每個節點的值設定為索引值並清除所有鏈結,對於所有測試前都會先執行。before
為 l.head
以逆序方式將陣列中的內容插入到鏈結串列中,並確保測試後的鏈結串列數值是反向排列的。before
為 NULL
,以順序方式將陣列中的內容插入到鏈結串列中,並確保數值是順序的。可以透過以下方式進行遞迴式單向鏈結串列排序:
head
指標指向,第二段頭部則是 slow->next
指向的節點,注意要透過另一個指標(e.g., mid
儲存)。slow->next == NULL
)head
和 mid
傳入 merge_sort_list
(遞迴呼叫)head
和 mid
已經排序完並返回後,呼叫 merge_lists
合併兩者為單個鏈結串列。合併中,當兩個鏈結串列非空時,取出值較小者,放入要返回的鏈結串列尾部,並將被取出值的鏈結串列往下一個移動
實作如下:
藉由以下修改使 merge_lists
消除 if - else:
EEEE
: (*pred_ptr)->r
FFFF
: &(*pred_ptr)->r
當要快速解題時,著重在以下內容:
註釋提到要找左子樹中最右下角的節點,起始位置已經設定成左子樹,因此我們只需要確認每個節點是否具有右子節點。如果有則更新成該節點並繼續,反之結束。
EEEE
要確認是否具有右子節點(非 NULL
),故用 (*pred_ptr)->r
FFFF
是更新節點,pred_ptr
是 indirect pointer 故為 &(*pred_ptr)->r
。*
和 ->
的優先級。先探討試題中的內容。
首先看到 block_t
定義:
其中 size
用於紀錄該 block 的大小 (bytes)。
接著有兩個未提供的實作的函式,我們稍後探討這部分的實作:
關鍵函式 remove_free_tree
:
裡面用到幾個關鍵變數,如下表:
Variable | Type | Description |
---|---|---|
target |
block_t* |
A pointer to the target node, i.e., the node that is intended to be removed from the tree. |
pred_ptr |
block_t** |
An indirect pointer to the field in the left subtree of the target node, used to locate its in-order predecessor (i.e. the rightmost node in the left subtree). |
node_ptr |
block_t** |
An indirect pointer pointing to the field (in the parent or root) that holds the target node's address, enabling direct modification of the tree structure during removal. |
remove_free_tree
可以分成三個階段:
target
,並用其親代的左或右成員地址表達,並紀錄在 node_ptr
中。target
的左節點和右節點,在移除前更新親代的子節點,避免直接移除破壞樹結構。target
的子節點,避免被刪除的節點還能存取樹中的其他節點。針對第二點,可以分為三種情況:
target
同時有左和右子節點。target
僅具有單邊節點。target
不具有任何子節點。第二種情況只要將 node_ptr
指向唯一的子節點即可:
第三種情況則更新成 NULL
:
Note
我認為第二種情況可以和第三種合併,因為第三種情況不論取左右子節點,都是 NULL
。
針對第一種情況,要先尋找到目標節點的中序前驅節點。中序前驅中,中序是走訪方式(左 ➞ 中 ➞ 右);前驅則指最靠近目標節點,但比目標節點小的節點。因此中序前驅可以理解為前一個。注意靠近並非路徑靠近,而是值靠近,例如此處應為 size
接近。
對於二元搜尋樹 (Binary Search Tree, BST) 而言,中序前驅節點位於左子樹最右下角的節點,反之中序後繼則在右子樹最左下角。
當找到中序前驅節點後,分兩種情況:
*pred_ptr == (*node_ptr)->l
)對於前者:
target
右節點 ( (*node_ptr)->r
)。
target
左節點的左子樹原因是,即將用 target
左節點替換掉 target
,其本身的 l
成員已有該資訊,缺乏的僅是右側節點的資訊。target
的 *node_ptr
改指向中序前驅節點 *pred_ptr
,注意不是 node_ptr = pred_ptr
而是 *node_ptr = *pred_ptr
,後者才會改變樹結構。對於後者:
target
左右節點資訊。l
成員會被置 NULL
導致遺失左節點的問題,用 pred_node
紀錄。remove_free_tree
)*node_ptr
指向 pred_node
指向的節點。注意不可用 node_ptr = &pred_node
因為 pred_node
是區域變數。實作前可以思考以下問題:
為什麼
find_free_tree
需要返回block_t**
?
如果只是要修改 block_t
內容,block_t*
就足夠了。但若要更新其親代節點的子節點,則應返回 block_t**
,並透過以下方式進行更新:
我們不用再特別尋找親代節點的指標再透過該指標去修改,而是直接返回親代的左子樹或右子樹 field 的地址 ( &parent->l
or &parent->r
),如此便能直接藉由一次解引用更新裡面指向的節點。 remove_free_tree
中的使用方法便是如此 ( *node_ptr = *pred_ptr
) 。
下圖描述返回值 block_t**
( ptr
) 所指向的位置。
我們需要實作以下的函式:
create_block
insert_free_tree
remove_free_tree
(同上方實作,移除檢查部分)find_free_tree
free_free_tree
find_predecessor_free_tree
(optional)print_free_tree
(optional)以下是實現方法,運行範例請見 Compiler Explorer:
create_block
insert_free_tree
當找到 NULL
處即可插入,以下是迭代寫法:
遞迴寫法:
find_free_tree
採用迭代走訪子樹,可以善用 BST 的特性,以 size 為判斷條件決定繼續走訪左子節點(小於)還是右子節點(大等於)。當 *root
為 target
時就返回。
當然也能透過遞迴作法實現:
free_free_tree
要使當前程式為真正的記憶體配置器,要針對 block_t
結構體進行修改,例如需要紀錄真正可以讀寫的 buffer 地址。
root
,其決定該樹的平衡狀況,如果 root
為極端值,則操作速度會退化,是否能使用其他平衡樹?GGGG
: head->prev=prev
HHHH
: list_entry(pivot,node_t,list)->value
IIII
: list_entry(n,node_t,list)->value
JJJJ
: pivot
KKKK
: right
先看到 GGGG
所在的輔助函式:
只做以下流程:
prev
紀錄目前 head
。head->next
用 node
紀錄。node
不是 NULL
(走訪到尾)。prev
, node
等操作,建立沿途每個元素的 prev
關係 ( next
已經被建立)。prev
的 next
指向 head
,而 head->prev
則指向最後一個元素 prev
。故得 GGGG
為 head->prev=prev
。接著看到 HHHH
的關鍵程式:
可知 value
是比較的基準值,在快速排序中,我們以 pivot
的值作為基準,因此 value
是類似 to_node_t_func(pivot)->value
,這可以透過 list_entry
巨集達成。由於 list_head
儲存在成員 list
中。故 HHHH
為 list_entry(pivot,node_t,list)->value
。
接著移動到 IIII
:
我們能夠推斷 n_value
實際上就是 to_node_t(n)->value
,也就是 list_entry(n,node_t,list)->value
即 IIII
。
最後的 JJJJ
和 KKKK
可以放在一起看。快速排序中,合併後的順序是 left
➜ pivot
➜ right
。題目中的作法是從取出第 begin[i]
,而 i
是遞減的 ( i--
),也就是實際合併時,會先將 begin[i+2]
處理後的放到 result
前面,之後是 begin[i+1]
處理後的,…。所以 right
應該要放置在第一個被拿取的地方,即:
在〈Optimized QuickSort — C Implementation (Non-Recursive)〉提到的陣列快速排序流程分成作者自己提出的和本來就存在於 Wiki 百科上的,可以用以下文字解釋:
Darel Rex Finley's Quick Sort
將 head
指向的元素複製到 pivot
中,L
從最左側開始,R
從最右側開始
R
往左側移動直到遇到比 pivot
小的值,此處為第七個元素 1,因此將該值複製到 L
指向的空間
R
已經置換過,換成 L
往右邊移動直到遇到比 pivot
大的值,此處為第二個元素 5
,將該值複製到 R
指向的地方
繼續往左移動 R
,最終指到第五個元素 2
接下來又換到 L
,停在第四個元素 6
此時,換 R
,但此時 R
碰到 L
故結束,將 pivot
的值複製進 L
指向的地方,並更新 begin
和 end
begin[i]
是 0
、end[i]
是 8
begin[i+1]
是 L+1 (4)
、end[i+1]
是 end[i] (8)
、end[i]
被更新為 L (3)
,且 i
往上遞增 1 為 1
繼續從 i = 1
開始,L 的新值為指向 index 為 4 的 6
,R
指向 index 為 7 的 7
,pivot 值改為 6
接著 R
往左移動到第七個元素 5
,並將該值複製到 L
處
L
往右移動到第六個元素 8
時,複製到 R
換 R
但碰到 L
,將 pivot
複製到 L
後結束第二輪
begin[i]
是 4
、end[i]
是 8
begin[i+1]
是 L+1 (6)
、end[i+1]
是 end[i] (8)
、end[i]
被更新為 L (5)
,且 i
往上遞增 1 為 2
第三輪,L
指向第七個元素 8
,R
指向第八個元素 7
。由於 R
當前指向的已經小於新的 pivot
值 8
,因此將該值複製到 L
。
而此時顯而易見的是,L
將會碰到 R
,所以 pivot
的值將被放入最後一個元素中
達到該步驟後,剩下的就是更新 begin
、end
並重走訪先前排序好的子陣列。剩下的過程中不會有任何除了 L
的值被替換(被 pivot
替換),直到 i < 0
分析完陣列的作法後,結合原本題目的說明來看看鏈結串列的快速排序吧!
題目中一開始舉的例子省略中間步驟,導致有點難快速理解,其流程如下:
L
一開始在 array[0]
,R
在 array[5]
,當 R
往左邊走一格就碰到了小於 pivot
的值,因此發生 array[0] = array[4]
。L
移動,到 array[3]
發現大於 pivot
的值,所以 array[3] = array[4]
L
和 R
碰到,把 pivot
值給 array[3]
接著看到非 Linux Kernel list 的 quick_sort
實現,有幾點可以注意:
max_level
設定成 2n
是確保在極端情況下,不發生數組越界。list_tail
獲得最後一個元素 (相當於 R = end[i] - 1
)L
從 pivot
的下一個開始(和陣列不一樣)L
和 R
的複製,因為交換成本高,而是拆分成兩個子鏈結串列,小於等於的放 left
,大於的放 right
begin
沒有 end
,放置到 begin
的順序按照索引順序為 left
、 pivot
、right
這些部分也會在用 list 實作的 quick_sort
中。
實作中有三處遮罩:
CCCC
: p->next
DDDD
: list_tail(&left)
(先放左邊)EEEE
: list_tail(&right)
(再放右邊)最後看回使用 list 實作的 quick_sort
,其測試的流程是透過 shuffle
先打亂有 100000 的鏈結串列,之後使用 quick_sort
排序並透過 list_is_ordered
驗證。
shuffle
使用 rand
實作,這能透過先前實作 PRNG 進行改進。list_tail
在這邊沒有使用 prev
的作法是因為 quick_sort
過程中,會只維護單邊 ( netx
方向) 的鏈結,到最後才從新建立另一邊鏈結。AAAA
: list_first_entry
BBBB
: list_del
CCCC
: list_move_tail
DDDD
: list_add
EEEE
: list_splice
FFFF
: list_splice_tail
該測驗延續上週的快速排序,差別是更改了 API 讓使用者能夠傳入自定義的比較函式指標。
觀察核心程式碼,在開始時檢查邊緣情況確認是否能提早返回。接著進行 list_head
的初始化,再往下碰到了 AAAA
。快速排序通常取頭部節點作為 pivot
,因此 AAAA
對應的操作是取出成員 list
為 ad
的節點地址操作,如此後續才能執行如 pivot->list
等操作。故 AAAA
為 container_of
。而由於 pivot
要移出鏈結串列外,因此 BBBB
為 list_del
。
接著來到比較大小的部分,比 pivot
小的放到 list_less
中,其它放到 list_greater
,應注意題目要求符合 stable sorting,意謂要符合原鏈結串列順序。因此插入時應插入在尾端。故 CCCC
和另一分支都使用 list_move_tail
。
分區完成後,透過遞迴方式呼叫 list_quicksort
,並於遞迴結束後,按序將 list_less
、pivot
、list_greater
,題目中首先操作的是 pivot
,因此可以使用 list_add
將其先加入 head
中,同時也是 head
的唯一節點。接著透過 EEEE
和 FFFF
分別操作 list_less
、list_greater
,如前述 EEEE
要讓 list_less
在 head
唯一的節點前,因此是從頭部插入,相反 FFFF
則要從尾部插入。故 EEEE
為 list_splice
而 FFFF
為 list_splice_tail
。
在此,我們記錄 list_splice
和 list_cut_position
的功能。前者用於拼接鏈結串列,注意拼接後的哨兵節點 (例如 list_less
) 是不能直接承接其他節點的!需要進行初始化後才能繼續使用。或者使用 list_splice_init
來簡化該流程。
list_cut_position
是輔助使用者從 head
的第一個節點到指定節點(含)移動到另一個鏈結串列中,剩下的留在原鏈結串列中。
在 random_shuffle_array
註釋提到:
所謂 biased shuffling 是指取樣時,由於使用取餘操作而結果非 0 時,會導致小於取餘結果的值有更高的機會被取樣。儘管機率很小,依然會降低隨機性。
可以藉由移除多出的尾端,使每種取餘結果出現的次數是相同的。
但要注意,目前的作法是拒絕取樣(rejection sampling),極端情況下可能會經歷多論迴圈一直重新取樣,這種非常數的運行時間有被惡意偵測去猜測(只要猜測尾端)的可能性!
若將 list_move_tail
置換成 list_move
會使得原先在後的節點移動到新鏈結串列時被插入到頭部,最終得到的鏈結串列就會和原本的順序顛倒,也會使排序過程變成非穩定的。
穩定排序對於多重排序非常重要,例如 PCI 匯流排上的裝置被掃描時,會依照 bus number、device number、function number 等進行排序,若在排序第三種鍵時,發現兩個目標值相同而改變了兩者順序,可能導致依照第一、二種鍵的排序被破壞! 因此,應 list_move_tail
不能被 list_move
替換。
GGGG
:14
HHHH
:2
IIII
:0
JJJJ
:3
KKKK
:2
LLLL
:1
MMMM
:~1
NNNN
:PPPP
: 2
首先觀察 clz2
:
對照題目給的 step 1 - 3,其中:
step 1 中,根據 #define clz32(x) clz2(x, 0)
可知道 c
的初始值是 0。只要上半部非 0,c
每次遞增 1。因此 upper 的右移位數將會是 16、8、4、2 (根據 step 3,會在 2 bits 的情況時結束遞迴)。
對於 lower,遮罩 0xFFFF >> mask[c]
中,低位為 1 的數量與當前的 upper 位數 (也就是 upper 右移位數) 相同。也就是低為是 1 的數量也是 16、8、4、2。mask
陣列和上述數量的總和固定會是 16,所以 mask
如下:
接著讓我們跳到 step 3,對於停止條件是 2 bits,也就是 c
等於 3 時。故 JJJJ
為 3。line 12 告訴我們,當 upper 非 0,返回 magic[upper]
,否則返回 KKKK + magic[lower]
。變相告訴我們:
magic[1:3]
跟高半段有關 ( upper
為 0 會走另一個分支)。magic[0]
只跟低半段有關。magic
陣列是針對 0b00
、0b01
、0b10
、0b11
而言,各提供了幾個 leading zero,因此 magic
陣列為
對於 KKKK
,當 2 bits 下,upper
是 0,代表 upper 固定提供兩個 leading zero,故 KKKK
為 2。
最終回到 step 2,重點在處理完 upper
後處理低半段的地方:
其中:
16 >> c
: 代表當前 upper 全是 0 會提供的 leading zero 數量。例如 c
是 0 也就是第一輪 upper
就都是 0,就會提供 16 >> 0
個 leading zero。clz2(lower, c + LLLL)
: 代表繼續處理下半部,每次遞迴 c
都遞增,故 LLLL
為 1。探討完 clz2
和 clz32
,可以繼續擴展至 clz64
,後者依照上半段是否全 0 分為兩種情況
clz32
處理下半段 + 32 (上半段全 0)。clz32
處理上半段。至此,輔助函式探討完畢,進入 sqrti
,參數是 x
。根據註解,我們要計算 m
,該值由 1 << shift
表達,且 m
要是 4 的冪。而 shift
為 fls(x) & MMMM
。首先,fls
代表 find last set,即最高為是 1 的位元索引。該部分可以由 clz64
改寫,即 line 2:
m
要求是 4 的冪代表了 shift
必須偶數,如此 m
就會是 ,確保其為 4 的冪。進而推斷 bitwise and MMMM
會使 fls
向下取偶,例如 29 會變成 28。該過程可以透過將第 0 位放 0 其他放 1 達成。故 MMMM
為 ~1
,注意 ~
的優先級比 bitwise and 高,因此不需要加上括號。
NNNN
和 PPPP
放在下節說明。
延伸我們手算平方根的原理,可以推導出以下方式:
流程
0b100101
逐位試商方法可在任意進制 下高效求整數平方根。首先將整數 從最高位起,每次成組(兩位)帶下作為前綴。假設已得高位商為 ,剩餘前綴為 ,我們在 中找 最大 ,使得新商 的平方不超過前綴。
透過 「帶下 → 試商 → 扣除 → 更新商」 的迭代,直至所有組處理完畢,即可得到 。
根據平方和公式:
由於每次計算都已經會從 扣除 ,因此我們在試商時使用的判斷式便是:
的更新則是
範例
讓我們以 10 進制開始示範,以 12312 為例子,此處 可以寫成 。
分組
處理第一組
處理第二組
處理第三組
結果驗證
因此答案是 110。
以 2 進制寫成 C code,, 作為試除數, :
其中迴圈對 的操作能夠更簡化些:
題目中的作法可以參考 從 √2 的存在談開平方根的快速運算 - Digit-by-digit Method、 Hacker's Delight Edition 2 中 11-2 Hardware Algorithm 和 2025-02-25 問答簡記。
老師推導中的核心被開根號數 () 可以被拆解成多項和 ,且多元平方和中,如果多增加一項 (),則增量 () 可以由下式表達:
此處 是指從最前面的項往下加到 前一項,例如對於 ,注意此處 代表的是 的前一項而不是後一項:
這是很好的特性,因為能知道增加下一項 會不會超過 。將此概念套用到套用到 2 進制整數系統後,數字 可表示成 :
此時, 可表示成:
此時,誤差 可表達為 :
而 可進一步表達成
我們能將其拆解成 和
其中 可寫成:
移向後得:
的關係則如下:
移向後得
對應到題目中的實作可知:
b
: y
: m
: ,不論如何持續右移 2 bits故 NNNN
對應 1 (,右移 1),PPPP
對應 2 (,右移 2)。
另外,若不希望分支存在,可以使用:
向上取整的實作可以基於前述向下取整的實作,只需要判斷 x
殘留值是否大於 0。因此返回值更改為以下即可:
根據 C11 §6.5.8 - 6
Each of the operators < (less than), > (greater than), <= (less than or equal to), and >= (greater than or equal to) shall yield 1 if the specified relation is true and 0 if it is
false. The result has type int.
判斷大小的運算子只可能返回 0 或 1,因此可用來進行向上取整。
When we successively insert the points into the interval , Theorem S asserts that each new point always splits one of the largest remaining subintervals.
If the interval is thereby broken into two parts and , we call it a bad break if one of these parts is more than twice as long as the other, namely if
Exercise 8 (Section 6.4): Prove that bad breaks will occur for some unless
and that for these latter values of , no bad breaks ever occur.