# 2025q1 Homework2 (quiz1+2) contributed by < `yy214123` > 雖然這是我第二次修這門課,但我深刻意識到自己仍有許多不足。目前的隨堂測驗幾乎都拿了 0 分,在有限的時間內,我仍無法迅速找出正確答案,這表示我還需要更加努力。 相較於去年,有時我會依賴 ChatGPT 來幫助解題;但今年,我決定誠實面對自己的能力與弱點,缺什麼就補什麼。 ## 2025 q1 第 1 週測驗題 ### 測驗 `1` ```graphviz digraph linked_list { rankdir=LR; // 水平顯示 node [shape=record, style=rounded]; head [shape=circle, label="head", style=filled, fillcolor=lightblue]; A [label="A"]; B [label="B"]; C [label="C"]; null [shape=plaintext, label="NULL"]; head -> A; A -> B; B -> C; C -> null; } ``` 根據 `list_insert_before` 的描述,會有下列幾種插入情況: - [ ] 情況 1 `list_insert_before(list, A, new);` 此時新插入的節點會成為串列的新頭部節點: ```graphviz digraph linked_list { rankdir=LR; // 水平顯示 node [shape=record, style=rounded]; head [shape=circle, label="head", style=filled, fillcolor=lightblue]; new [label="new"]; A [label="A"]; B [label="B"]; C [label="C"]; null [shape=plaintext, label="NULL"]; head -> new; new -> A; A -> B; B -> C; C -> null; } ``` - [ ] 情況 2 `list_insert_before(list, C, new);` 此時會在節點 `B` 與 節點 `C` 中間插入新節點: ```graphviz digraph linked_list { rankdir=LR; // 水平顯示 node [shape=record, style=rounded]; head [shape=circle, label="head", style=filled, fillcolor=lightblue]; new [label="new"]; A [label="A"]; B [label="B"]; C [label="C"]; null [shape=plaintext, label="NULL"]; head -> A; A -> B; B -> new; new -> C C -> null; } ``` - [ ] 情況 3 `list_insert_before(list, NULL, new);` 此時新插入的節點會成為串列的最尾端節點: ```graphviz digraph linked_list { rankdir=LR; // 水平顯示 node [shape=record, style=rounded]; head [shape=circle, label="head", style=filled, fillcolor=lightblue]; new [label="new"]; A [label="A"]; B [label="B"]; C [label="C"]; null [shape=plaintext, label="NULL"]; head -> A; A -> B; B -> C; C -> new; new -> null; } ``` 我覺得這裡使用的串列和 lab0 之間最大的不同在於 `head` 節點。在這裡,`head` 只是一個指向第一個節點 `A` 的指標,而在 `lab0` 中的雙向環狀鍊結串列,`head` 節點本身也擁有 `next` 和 `prev` 這兩個指標,因此兩者的設計角度有所不同。 使用間接指標來實現遍歷佇列的節點,首先,間接指標所儲存的值是另一個指標的記憶體地址,因此可以用 `head` 指標的記憶體地址來初始化間接指標(假設叫做 `pp`)。此時的結構如下 | 記憶體位址 | 變數名稱 | 值 | | -------- | -------- | -------- | | 0x0000 | `pp` | head 的記憶體位址(即0x0001) | | 0x0001 | `head` | Node A 的記憶體位址(即0x0002) | | 0x0002 | `Node A` | value 及 next(0x0003)| | 0x0003 | `Node B` | value 及 next(0x0004) | | 0x0004 | `Node C` | value 及 next(NULL) | 接著進行串列的走訪。此處顯然無法直接使用 `pp = pp->next`來訪問下一個節點,因為 `pp` 並不是一個結構體,因此沒有名為 `next` 的成員。我們可以使用 你所不知道的 C 語言: linked list 和非連續記憶體 中提及的間接指標技巧,即每次將 `pp` 指向當前節點的 `next` 指標所在的記憶體地址。根據上述記憶體表格,這相當於將下一個節點(例如節點 `A` 的 `next` 欄位)的記憶體地址存入 `pp` 中。此時,透過對 `pp` 進行解引用操作(即 `*pp`),即可取得下一個節點的位址(例如節點 `B`),如此反覆即可實現完整的串列走訪。 不過,根據題目需求,我們必須在特定位置插入新的節點,因此需要設定適當的中止條件。前面已經提到,`*pp` 指向的是下一個節點的位址。若 `*pp` 恰好為指定的插入位置(`before`),此時 `pp` 即指向前一個節點的 `next` 指標。掌握此概念後,節點插入就非常直觀了:只需將 `*pp` 設為新插入節點,再將新插入節點的 `next` 指向原先 `*pp` 所表示的節點,即可完成插入操作。 ### 測驗 `2` 程式一開始宣告了一個間接指標 `node_ptr`,並將其初始化為樹狀結構中 `root` 節點的記憶體位址。因此,對 `node_ptr` 解引用一次,即可取得對應的 `root` 節點。 ```c /* If the target node has two children, we need to find a replacement. */ if ((*node_ptr)->l && (*node_ptr)->r) { /* Find the in-order predecessor: * This is the rightmost node in the left subtree. */ ``` 根據註解上下文可推測,題目要求填空處的目的在於尋找左子樹的最右側節點。因此,再次宣告一個間接指標 `pred_ptr`。此處與第一題採用的方法本質相同,差別僅在資料結構的不同:`pred_ptr` 會存放當前節點內 `r` 指標的記憶體位址(而在第一題是 next 指標)。透過對 `pred_ptr` 進行解引用,即可取得該節點的右子節點,然後持續往右側進行走訪,直至最右端的節點。因此,`while` 迴圈的中止條件即為檢查當前節點的右子節點是否為 `NULL`,若非 `NULL`,則持續走訪。 此處有兩個 helper function 還沒實作完成: `find_free_tree`: 在 BST 中找到指定節點的位置 `find_predecessor_free_tree`:找出一個節點的 in-order predecessor,也就是左子樹裡的最大節點 > 這個會用來驗證 `while` 找出的節點是否正確。 首先先實作 `find_free_tree`,這邊可以看到傳入兩個參數: ```c block_t **find_free_tree(block_t **root, block_t *target){}; ``` 首先要考慮 root 為 NULL 及 target 是 root 的情況: ```diff block_t **find_free_tree(block_t **root, block_t *target) { + if(!*root) + return NULL; + if(*root == target) + return root; } ``` 接下來,根據 binary search tree 的特性,我們需依據 target->size 與當前節點大小比較: * 若 target 比當前節點大,則往右子樹遞迴。 * 若小於或等於(我預設大小相等時插入到左子樹),則往左子樹遞迴。 ```c block_t **find_free_tree(block_t **root, block_t *target) { if(!*root) return NULL; if(*root == target) return root; if (target->size > (*root)->size) find_free_tree(&(*root)->r,target); else find_free_tree(&(*root)->l,target); } ``` 上面的方法使用的是遞迴方式,`&(*root)->r` 及 `&(*root)->l` 會指向上一層節點的 `r` 及 `l` 指標,下一次遞迴時解引用就會是對應的右子節點或左子節點,此外,也可以使用迴圈版本來實作,以避免遞迴的額外堆疊開銷。邏輯上完全相同,只是改用 `while` 迴圈實現節點的搜尋與指標更新。 ```c block_t **find_free_tree(block_t **root, block_t *target) { while(*root){ if(*root == target) return root; if (target->size > (*root)->size) root = &(*root)->r; else root = &(*root)->l; } return NULL; } ``` 接著來實作 `find_predecessor_free_tree`,這邊可以看到傳入兩個參數: ```c block_t *find_predecessor_free_tree(block_t **root, block_t *node){} ``` 由於這個函式的主要用途是「作為驗證工具」,並不是實際邏輯的一部分,因此我們可以將它視為一個用來比對結果的對照組。 在這樣的前提下,它的角色比較像是「我們在建構測試用的 tree 時,透過紙筆追蹤或視覺化方式,事先知道某個節點的 predecessor 是誰」,並將該節點直接作為回傳值。 這樣做的目的,是為了驗證填空區的 while 迴圈是否正確地找出了與我們預期一致的 predecessor。 ```c block_t *find_predecessor_free_tree(block_t **root, block_t *node) { return known_predecessor_pointer; } ``` ### 測驗 `3` ## 2025 q1 第 2 週測驗題 ### 測驗 `1` 首先會初始化兩個新的串列: ```c INIT_LIST_HEAD(&list_less); INIT_LIST_HEAD(&list_greater); ``` > 一個用來存放比 `pivot`小的元素,另一個用來存放比 `pivot` 大的元素。 以第一個元素作為 `pivot`,這跟 lab0 一樣內嵌於其他結構體,所以需要使用對應的 `container_of` 來取得結構體內的數值成員,才能進行比較,並先將 `pivot` 從串列中移除。接著走訪整個串列,以 `pivot` 作為基準,決定各元素要放入哪一個新串列(`list_less` 和 `list_greater`),然後對這兩個新串列進行遞迴。遞迴完成後,首先將原本移除的 `pivot` 插入串列中,並在其左側插入遞迴完成的 `list_less` 串列,右側插入遞迴完成的 `list_greater` 串列,這樣便完成了排序的實作。 ### 測驗 `2` 首先需要判斷輸入的數是否完全為 `0`,若輸入為 `0`,則不需要進行繁瑣的運算,可直接回傳 `32`;若不是 `0`,則需要對該數進行分割。接下來將函式的回傳部分展開為更易於理解的形式: ```c return upper ? magic[upper] : KKKK + magic[lower]; //展開 if (upper) return magic[upper]; else return KKKK + magic[lower]; ``` 只有當遞迴執行到 `upper` 和 `lower` 都剩下 `2bits` 時,這樣的情況才會成立。考慮到 `2 bits` 共有四種組合: > 00:(lower 有兩個 leading zero) > 01:(lower 有一個 leading zero) > 10:(lower 沒有 leading zero) > 11:(lower 沒有 leading zero) 因此當 `upper` 為 `00` 時,`lower` 也可能會被考量進去,這就是 `magic` 陣列各個索引值的來源。反之,當 `upper` 不是 `00` 時,則不需要考慮 `lower` 的組成,直接返回 `lower` 的組成並找到對應的 `magic` 陣列元素即可。 另一個情況是: ```c return upper ? clz2(upper, c + 1) : (16 >> (c)) + clz2(lower, c + LLLL); //展開 if (upper) { return clz2(upper, c + 1); } else { return (16 >> c) + clz2(lower, c + LLLL); ``` 這裡的道理也是一樣,只是這次 `upper` 和 `lower` 不是 2 bits,以 4bits 為例,假設 `upper` 為 0000,此時 `lower` 也可能貢獻 leading zero,透過將 16 右移分割次數來將 `upper` 的四個 leading zero 和 `lower` 的 leading zero 加總在一起。 以下舉例: 假設 0000 0100 0000 0000 0000 0000 0000 0000,先分割成兩個最大的 `upper` 與 `lower`: > upper = 0000 0100 0000 0000 > lower = 0000 0000 0000 0000 此時 upper 非 0,會執行 `return clz2(upper, c + 1);`,從這邊可以看到 `lower` 的部分就不會再理他了,因為無論其組成方式如何,都不會貢獻 leading zero。 接著再分割 0000 0100 0000 0000: > upper = 0000 0100 > lower = 0000 0000 同理,`lower` 也不再貢獻 leading zero,專注於 `upper` 即可,接著再分割 0000 0100 > upper = 0000 > lower = 0100 此時 `upper` 是 0,代表 `lower` 有貢獻 leading zero 的可能,所以會執行 `return (16 >> c) + clz2(lower, c + LLLL);`,此時是第三輪所以 c = 2,16 >> 2 = 4,這恰好就是 `upper` 的 4 個 leading zero。接著分割 `lower` 0100: > upper = 01 > lower = 00 此時 c = 4,即先前提到 upper 與 lower 皆 2 bits 的情況,同時 upper 非 0,會 return magic[upper](即 1),這是 `clz2(lower, c + LLLL)` 的結果,而上一層會回傳 `return (16 >> c) + clz2(lower, c + LLLL)`(即 4 + 1),所以最後得到共 5 個 leading zero。 這個演算法本質上是一種在位元層面的二分搜尋法,每次將待檢查的位元區間分成兩部分,先檢查上半部是否包含非零位元,若有則在上半部繼續搜尋,否則跳過上半部並累加其位元數後在下半部尋找。這種不斷縮小搜尋範圍、定位目標位元(第一個 1)的方式,就和二分搜尋在有序資料中查找目標元素的策略非常相似。 接著是開平方根實作,`sqrti` 函式的參數是一個 64 bits 無號數,也可以將其拆成 upper 與 lower,再用先前說明的遞迴來找出 leading zero 總數。有了 leading zero,我們也可以反推其最高位元 set bit 之索引。 我還無法理解這邊的後續實作邏輯 ### 測驗 `3`