# 2025q1 Homework2 (quiz1+2) contributed by < [fcu-D0812998](https://github.com/fcu-D0812998) > ## [第一週測驗題](https://hackmd.io/@sysprog/linux2025-quiz1) ### 測驗一 #### 解釋程式碼運作原理 首先這個測驗是要考函數 `list_insert_before` 的運作流程, `list_insert_before` 的主要目標是在單向鏈結串列中,找到 ` before ` 節點,並將 item 節點插入在它的前面。這是一種間接修改鏈結串列的方法,因為 `list` 本身是單向鏈結串列,且這個函式的實作運用了「指標的指標」技巧,我們可以簡化對鏈結串列的操作,特別是在插入或刪除節點時。 ``` c p = &list->head; ``` 首先 `p` 是一個「指標的指標」,它最開始指向 `list->head` 的位址,也就是 `head` 的記憶體位置。 ```graphviz digraph linkedlist{ rankdir=LR p[shape=none,label=p] before[shape=none,label=before] head[shape=none,label=head] null[shape=none,label=NULL]; a [shape=record,label="{ <data> 3 | <ref> }"]; b [shape=record,label="{ <data> 2 | <ref> }"]; c [shape=record,label="{ <data> 1 | <ref> }"]; d [shape=record,label="{ <data> 4 | <ref> }"]; { rank = same; p -> head[arrowhead=vee, arrowtail=dot, dir=both,minlen=3] } head -> a before -> a a:ref:c -> b:data [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; b:ref:c -> c:data [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; c:ref:c -> d [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; d:ref:c -> null [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; } ``` * 我們現在要將值為5的node插在2之前。 ``` c for (p = &l->head; *p != before; p = &(*p)->next) ``` * 迴圈的目標是找到指向 `before ` 的指標,讓` p` 能指向` before` 前一個節點的 ` next` 指標。剛開始不太理解 `*` 跟 `&` 的應用,看了幾次之後懂了。 1. 首先 `l->head` 指向第一個節點, `&l->head` 也就是取得 `l->head` 這個指標的地址,能讓p去指向這個位址。 2. 再來要讓 `p` 再回圈內在 `before` 前停下來,就必須取值去判斷,所以使用 `*p` 。 3. 再來如果判斷不成要指向下一個節點的話就要取址,首先先用`*p`取得目前所在的指標, `(*p)->next` 是目前指到的節點的下一個節點,再取址,就能讓 `p` 指向下一個指標的位址了。 ``` c *p = item; item->next = before; ``` * `*p` 代表「目前的 next 指標」, `*p = item` 讓 next 指標改指向 item ,將新節點插入,根據圖例就是 `p = &([2]->next)` ,即 `*p ` 代表 [2] 的 next,此時 `item->next` 還沒設置,所以最後要將 `item->next` 指向 `before` 。 ```graphviz digraph linkedlist{ rankdir=LR p[shape=none,label=p] before[shape=none,label=before] head[shape=none,label=head] null[shape=none,label=NULL]; a [shape=record,label="{ <data> 3 | <ref> }"]; b [shape=record,label="{ <data> 5 | <ref> }"]; c [shape=record,label="{ <data> 2 | <ref> }"]; d [shape=record,label="{ <data> 1 | <ref> }"]; e [shape=record,label="{ <data> 4 | <ref> }"]; { rank = same; p -> head[arrowhead=vee, arrowtail=dot, dir=both,minlen=3] } head -> a before -> a a:ref:c -> b:data [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; b:ref:c -> c:data [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; c:ref:c -> d:data [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; d:ref:c -> e:data [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; e:ref:c -> null [arrowhead=vee, arrowtail=dot, dir=both, tailclip=false]; } ``` * 解釋完 `list_insert_before` 的運作流程後,往測驗1上面的程式碼做運作流程解析,上述程式碼主要是在測試 `list_insert_before` 函式的正確性,確保鏈結串列的插入操作能夠正確運作。 一開始定義了兩個巨集: 1. `my_assert()` : 這是一個條件檢查巨集,如果 test 為false,則回傳 message,表示測試失敗。 2. `my_run_test()` : 這是一個測試執行巨集,用來執行一個測試函式並計數。 * 後面依序是 1. ` list_reset()` :重設鏈結串列,確保每次測試前,鏈結串列都是空的狀態。 2. ` test_list()` :測試` list_insert_before()` 是否能正確地在不同位置插入節點。 3. ` test_suite()` :執行測試函式 ` test_list()` 。 4. ` main()` :呼叫 `test_suite()` ,輸出測試結果,因為 `test_list()` 只測試 `list_insert_before()` ,所以最後 `tests_run = 1` 。 #### 在現有的程式碼進行合併排序 ``` c list_item_t *mergeTwo(list_item_t *left, list_item_t *right) { list_item_t *head; list_item_t **ptr = &head; while (left && right) { if (left->value < right->value) { *ptr = left; left = left->next; } else { *ptr = right; right = right->next; } ptr = &(*ptr)->next; } if (left) { *ptr = left; } else { *ptr = right; } return head; } ``` ``` c list_item_t *mergeSort(list_item_t *head) { if (!head || !head->next) return head; list_item_t *slow = head; list_item_t *fast = head->next; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } list_item_t *left = head; list_item_t *right = slow->next; slow->next = NULL; list_item_t *sort_left = mergeSort(left); list_item_t *sort_right = mergeSort(right); return mergeTwo(sort_left, sort_right); } ``` * 概念跟作業1的`q_sort`想法一樣,只是這個是單向的鏈結串列,首先第一步先用快慢指標找出鏈結串列位於中間的值,將鏈結串列分成左半部分跟右半部分,利用遞迴的方式一直切成許多塊,再用 `mergeTwo` 函式比較傳入的兩個串列得 `value` 大小,比較小的話就輸入到暫存的 `ptr` 裡然後指向下一個節點繼續跟剛剛的值做比較,這樣就可以完成升序排列。 ### 測驗二 #### 解釋上方程式碼運作的原理 * 這段程式碼的作用是利用二元搜尋樹( BST )動態記憶體分配器的空閒區塊,首先一開始以為 `node_ptr` 是指向 `target` 父節點的右子樹這個指標的指標,後來發現在刪除的節點沒有子節點的情況下,要將 `node_ptr` 設成 `NULL` 就能刪除預刪除的節點就應該不會是指向 `target` 這個指標的指標,而是指向 `target` 這個節點的父節點***指向 `target`*** 的指標。 * 首先先使用 `find_free_tree` 找到指向目標節點的指標,也就是 `target`,接下來判斷三種可能: 1. **如果要刪除的節點沒有子節點** ```graphviz digraph 1{ node [fontname="Arial"]; 5 -> 3; 5 -> 7; 3 -> 2; 3 -> 4; null0 [shape=none,label=NULL]; 7 -> null0 n[shape=none,label=node_ptr]; t[shape=none,label=target]; t -> 8 p[shape=none,label=pred_ptr]; p -> 3 { rank = same; n; dummy } dummy[ shape = point, width = 0 ]; 7 -> dummy[ arrowhead = none ]; dummy -> n[ dir = back ]; dummy -> 8; } ``` ``` c else { *node_ptr = NULL; } ``` * 這樣根據開頭的解釋,就將 `node_ptr` 指向預刪除節點的父節點指向預刪除節點的指標。 2. **如果預刪除節點只有一個子節點** ```graphviz digraph 1{ node [fontname="Arial"]; 5 -> 3; 3 -> 2; 3 -> 4; n[shape=none,label=node_ptr]; t[shape=none,label=target]; c[shape=none,label=child]; null0 [shape=none,label=NULL]; 7 -> null0 t -> 7 c -> 8 7 -> 8; { rank = same; n; dummy } dummy[ shape = point, width = 0 ]; 5 -> dummy[ arrowhead = none ]; dummy -> n[ dir = back ]; dummy -> 7; } ``` ``` c else if ((*node_ptr)->l || (*node_ptr)->r) { block_t *child = ((*node_ptr)->l) ? (*node_ptr)->l : (*node_ptr)->r; *node_ptr = child; } ``` * 首先先判斷說預刪除節點` target `他是存在左子節點還是存在右子節點,圖中假設` target `是存在右子節點,那就將` child `指向右子節點,那跟剛剛一樣` node_ptr `是指向預刪除節點的父節點指向預刪除節點的指標,那如果直接將` *node_ptr = child `,就可以直接將父節點接到預刪除節點的唯一子節點。 **3.如果節點有兩個子節點** ``` c if ((*node_ptr)->l && (*node_ptr)->r) ``` 如果預刪除節點有兩個子節點,根據二元樹的定理,左子樹的值必須比父節點的值更小,而右子樹則須更大,所以必須抓取前驅節點,也就是左子樹的最大值來填補空缺。 ``` c block_t **pred_ptr = &(*node_ptr)->l; while ((*pred_ptr)->r) pred_ptr = &(*pred_ptr)->r; ``` * 此程式碼就是先將` pred_ptr` 指向預刪除節點指向左子樹的指標,再利用迴圈依序往右子樹的方向走,這樣就能找到左子樹的最大值。 ```graphviz digraph 1{ node [fontname="Arial"]; t -> 5 5 -> 3; 5 -> 7; 3 -> 2; 3 -> 4; t[shape=none,label=target]; c[shape=none,label=child]; null0 [shape=none,label=NULL]; 7 -> null0 c -> 8 7 -> 8; } ``` * 根據圖的狀況,` pred_ptr` 是指向值為 4 的這個節點上,我們就先用` remove_free_tree `刪除值為 4 的這個節點的父子點指向他的指標,再將需要替換的節點接到` target `的位置。 ``` c remove_free_tree(&old_left, *pred_ptr); *node_ptr = pred_node; ``` * 另一種情況就是左子樹的最大值就在原本要刪除節點的下一層,所以直接將` target `左子樹的右子樹指標指向` target `的右子樹指標就可完成刪除+替換。 ``` c block_t *old_right = (*node_ptr)->r; *node_ptr = *pred_ptr; (*node_ptr)->r = old_right; ``` #### 補完程式碼,使其可獨立執行 ``` c block_t **find_free_tree(block_t **root, block_t *target) { while (*root && *root != target) { if (target->size < (*root)->size) root = &(*root)->l; else root = &(*root)->r; } return root; } block_t *find_predecessor_free_tree(block_t **root, block_t *node) { block_t *pred = NULL; if (node->l) { pred = node->l; while (pred->r) pred = pred->r; } return pred; } ``` * ` find_free_tree `就是用來在 BST 中找到` target `,從` root `開始找,比他小就往左子樹走,比他大就往右子樹走,直到找到為止。 * ` find_predecessor_free_tree `就是用來在 BST 中找到` target `的左驅節點,原理的話就是先指到` target `的左子樹,再依序往右子樹走就能找到` target `的左驅節點。 #### 參閱 [memory-allocators](https://github.com/mtrebi/memory-allocators) * 看了老師提供的網址,那該網址就是再說他提供了多種自訂的 C++ 記憶體配置器用來提升動態記憶體分配的效能。因為在 C++ 中,標準的` malloc `函數用於動態分配記憶體,但在某些情況下,標準的記憶體分配方式可能無法滿足特定的效能需求,像是當你需要頻繁的分配或釋放的話,就會導致大量的系統開銷。這個專案就展示了如何透過實作自訂的記憶體配置器,來優化記憶體分配的效能。 1. ` Linear Allocator ` : 線性配置器是一個最簡單且高效的記憶體管理策略,適用於批量分配記憶體但不需要個別釋放的場景,它的核心就是利用一個指標` offset `來追蹤記憶體的使用狀態,分配記憶體時,只需將指標` offset `向前移動,因此時間複雜度為` O(1) `,且因為` offset `只會向前移動,所以無法單獨釋放某一個記憶體區塊,只能透過` Reset `一次性釋放整塊記憶體,讓` Offset = 0`,重置配置器。 ``` c currentAddress = (std::size_t)m_start_ptr + m_offset ``` * 首先計算記憶體當前的位置。 ``` c if (alignment != 0 && m_offset % alignment != 0) { // Alignment is required. Find the next aligned memory address and update offset padding = Utils::CalculatePadding(currentAddress, alignment); } ``` * 利用` alignment `變數確認是否對齊,若無則透過` Utils::CalculatePadding() `計算填充`(padding)`。 ``` c if (m_offset + padding + size > m_totalSize) return nullptr; ``` * 這段就是用來檢查是否超過記憶體池大小。 ``` c m_offset += padding; const std::size_t nextAddress = currentAddress + padding; m_offset += size; ``` * 如果上述都通過,就更新指標與偏移量。 2. ` Stack Allocator ` : 是一種` LIFO `的記憶體管理方式,跟` Linear Allocator `一樣都是利用指標` offset `來追蹤記憶體的使用狀態,他與` Linear Allocator `的差異在於` Linear Allocator ` 只能` Reset() ` 整塊釋放,而` Stack Allocator ` 可以逐步釋放記憶體,這是因為` Stack Allocator `利用了` Header `這個結構體,每次分配記憶體時,會在記憶體區塊的前方存儲` Header `,時間複雜度為 O(1)。 3. ` Pool Allocator ` : `Pool Allocator` 跟傳統 `allocator` 例如 `malloc` 或 `free` 不同。它的想法是將一大塊連續記憶體,切成很多相同大小的記憶體區塊叫做 `chunk`,且每個 `chunk` 大小固定。而 `Pool allocator` 只負責這些固定大小的 `chunk` 。這樣做的好處是 `allocate` 和 `free` 速度極快,因為只要簡單操作 `linked list`,時間複雜度為 `O(1)` 。 4. ` Free List Allocator ` : ` Free List Allocator `: 是一種較通用型的記憶體配置器,適合處理不同大小的記憶體區塊,不像 `Pool Allocator` 僅處理固定大小。它的核心是利用一條 `linked list` 來追蹤哪些記憶體區塊尚未使用,每個空閒區塊通常包含兩個欄位區塊大小與下一個空閒區塊的指標。配置記憶體時,遍歷整條 `free list` 找到最合適的區塊,可根據策略選擇 `First Fit 、 Best Fit 或 Worst Fit` ,若區塊過大則可切割剩餘的部分繼續保留在 `free list` 中。釋放時則將該區塊重新插入 `free list`,並嘗試與前後相鄰區塊做合併以減少碎片化。由於分配時需遍歷 `free list` ,時間複雜度為 `O(n)`,但提供比 `Linear` 或 `Pool` 更大的彈性,適合記憶體需求多樣化的系統。 * `explicit allocator` 在平均的時間複雜度上為 `O(nlogn)` ,只比` Free List Allocator `來的好,但比起 `Linear Allocator` 或 `Stack Allocator` 只能依照順序配置與釋放,`explicit allocator` 更具通用性,能支援任意順序的配置與釋放,與 `Pool Allocator` 相比, `explicit allocator` 不限制區塊大小, `explicit allocator` 則能處理各種大小的配置需求。 ### 測驗三 #### 解釋上述程式碼運作原理 * 測驗三利用非遞迴快速排序法,對一個以 `list_head` 結構串起來的鏈結串列排序,主要將資料不斷劃分為小於 pivot、等於 pivot、大於 pivot 的三段子序列,再逐步處理這些子序列直到完成排序。 * 解釋程式碼運作原理: ```c struct list_head *L = begin[i], *R = list_tail(begin[i]); if (L != R) { struct list_head *pivot = L; value = list_entry(pivot, node_t, list)->value; struct list_head *p = pivot->next; pivot->next = NULL; /* break the list */ } ``` * 取出堆疊中當前要排序的子串列開頭 L 和結尾 R * 將第一個節點視為 pivot * 用 p = pivot->next 逐個處理剩下的節點 * 利用 value 來將小於 pivot 的節點放入 left,大於的放入 right ```c n->next = left/right; ... begin[i] = left; begin[i+1] = pivot; begin[i+2] = right; ``` * 將目前的節點串列劃分為三個分割:left、pivot、right * 將這三段 push 回 begin[] 模擬堆疊(下一回合先處理 right) ```c if (L) { L->next = result; result = L; } i--; ``` * 若該段只剩一個節點(即無需再分割),加入已排序的 result ```c list->next = result; rebuild_list_link(list); ``` * 排序完成後,把 result 接回 list->next * 並用 rebuild_list_link() 重新設定 prev 與尾端閉環回 head(保持 list_head 結構) ```c static void rebuild_list_link(struct list_head *head) { if (!head) return; struct list_head *node, *prev; prev = head; node = head->next; while (node) { node->prev = prev; prev = node; node = node->next; } prev->next = head; head->prev=prev; /* GGGG */; } ``` * 最後這邊要將原本單向的鏈結串列(只有 next )變成雙向的,最後在收尾時要將 head 的 prev 指回最後一個節點。 #### 研讀[〈A comparative study of linked list sorting algorithms〉](https://dl.acm.org/doi/pdf/10.1145/225890.225893),分析針對雙向鏈結串列的排序演算法考量,並運用 Linux 核心風格的 List API 予以實作 此論文提及的兩種雙向鏈結串列分別為 `Sediment Sort` 跟 `Tree Sort` * `Sediment Sort` : 本質為 `Bubble Sort` 的變形,差別只在於使用了 `new_tail` 來限制交換範圍,意思就是說當你利用迴圈跑完一次後,根據泡沫排序法的方法,最大值一定會被交換到最後一個位置,看似是去優化 `Bubble Sort` ,其實並不然,因為 `Sediment Sort` 是專為 `doubly linked list` 去做設計,但在新增了 `new_tail` 後其實並沒有到很有效的縮減時間複雜度 * `Tree Sort` ## [第二週測驗題](https://hackmd.io/@sysprog/linux2025-quiz2) ### 測驗一 如何使用 Linux 核心的 `list_head` API,實作一個穩定的 `quick sort`。 ```c { struct list_head list_less, list_greater; struct listitem *pivot; struct listitem *item = NULL, *is = NULL; if (list_empty(head) || list_is_singular(head)) return; INIT_LIST_HEAD(&list_less); INIT_LIST_HEAD(&list_greater); pivot = list_first_entry(head, struct listitem, list); list_del(&pivot->list); list_for_each_entry_safe (item, is, head, list) { if (cmpint(&item->i, &pivot->i) < 0) list_move_tail(&item->list, &list_less); else list_move_tail(&item->list, &list_greater); } list_quicksort(&list_less); list_quicksort(&list_greater); list_add(&pivot->list, head); list_splice(&list_less, head); list_splice_tail(&list_greater, head); } ``` * `AAAA` : 根據快速排序, pivot 應該要為雙向 queue 的第一個節點,而 `list_first_entry` 是用來從 list 頭取出第一個節點,當作 pivot。 * `BBBB` : 這邊要從原 list 中拿掉 pivot,避免在 partition 時重複處理它,所以使用 `list_del` 。 * `CCCC` : 將 `>= pivot` 的節點移到 `list_greater` 的尾端,利用 `move_tail` 可以穩定排序 * `DDDD` : 利用 `list_add` 把 pivot 插回 head 的最前面。 * `EEEE` : `list_splice()` 能把整個 list 插入到另一個 list 的開頭位置,把 `list_less` 全部插入 head 的「前面」 → 剛好在 pivot 前面 * `FFFF` : 同理,把 `list_greater` 插入到 head 的「尾端」 ### 測驗二 實作一種高效的整數平方根 `sqrt(uint64_t)` ```c static const int mask[] = {0, 8, 12, 14}; static const int magic[] = {2, 1, 0, 0}; unsigned clz2(uint32_t x, int c) { if (!x && !c) return 32; uint32_t upper = (x >> (16 >> c)); uint32_t lower = (x & (0xFFFF >> mask[c])); if (c == 3) return upper ? magic[upper] : 2 + magic[lower]; return upper ? clz2(upper, c + 1) : (16 >> (c)) + clz2(lower, c + 0); } ``` ```c uint64_t sqrti(uint64_t x) { uint64_t m, y = 0; if (x <= 1) return x; int total_bits = 64; /* clz64(x) returns the count of leading zeros in x. * (total_bits - 1 - clz64(x)) gives the index of the highest set bit. * Rounding that index down to an even number ensures our starting m is a * power of 4. */ int shift = (total_bits - 1 - clz64(x)) & ~1; m = 1ULL << shift; while (m) { uint64_t b = y + m; y >>= 1; if (x >= b) { x -= b; y += m; } m >>= 2; } return y; } ``` 函式 `clz2(uint32_t, int)` 用來計算他的 leading zeros ,也就是從最高位往最低位算有幾個連續 0 ,先估算我們要解的值最接近 2 的多少次方,首先將此二進位表示的值切成上下部分,看哪一半為非 0 ,如果上半為 0,就只看下半,並加上上半的位元數來累計前導零,當只剩下 2 bits 時,用 `magic[]` 查表得到最後的 leading zero 數。先用 shift 找出 x 最接近 2 的多少次方,在利用迴圈 y 每次右移 1 , m 每次右移 2 ,這樣等到 m 變為 2 的 0 次方時, y 就會是 x 的整數平方根解。