# 2025q1 Homework2 (quiz1+2) contributed by < `thwuedwin` > ## 第 1 週測驗一 ### 程式碼運作原理分析 這個測驗主要考察對指標操作的熟練度。目標是實作 `list_insert_before` 函式,它的功能是在鏈結串列中找到指定的節點 `before`,並將 `item` 插入到它的前面。 該函式會接收以下參數: - `l`:指向鏈結串列的指標 - `before`:目標節點 - `item`:要插入的節點 實作方式是從 `l->head` 開始遍歷鏈結串列,直到找到 `before`,然後將 `item` 插入至其前方。 **不使用間接指標的程式碼** ```c list_item_t *p; for (p = l->head; p->next != before; p = p->next) ; p->next = item; item->next = before; ``` 這個版本直接使用 `p` 作為普通指標,在找到 `before` 之前,持續向後移動 `p`,最後修改 `p->next` 來完成插入。 **使用間接指標的程式碼** ```c list_item_t **p; for (p = &l->head; *p != before; p = &(*p)->next) ; *p = item; item->next = before; ``` 這個版本改用指向指標的指標 `p`,直接追蹤 `next` 指標的位置,這樣在插入 `item` 時,不需要額外處理 `p->next`,程式碼更加簡潔。 | 題目 | 答案 | | -------- | -------- | AAAA| `&l->head` BBBB| `before` CCCC| `&(*p)->next` DDDD| `item->next` **優雅之處** 不使用間接指標的版本雖然可行,但需要處理 `p->next`,而使用間接指標的版本則巧妙地利用 `p` 直接指向 `next` 指標的位置,使得: 1. 迴圈條件 `*p != before` 更直覺,不需要額外比較 `next` 2. `*p = item` 直接修改指標,不需要額外處理 `p->next` 這樣的設計讓程式碼更加簡潔,減少了變數存取的間接層級,提高了可讀性和可維護性。 ### 實作:合併排序 ```c= void merge_sort(list_t *l) { if (list_empty(l) || list_is_singular(l)) return; list_t left, right; list_item_t *slow, *fast, *before; slow = fast = l->head; while (fast && fast->next) { before = slow; slow = slow->next; fast = fast->next->next; } left.head = l->head; right.head = slow; before->next = NULL; merge_sort(&right); merge_sort(&left); // merge list_item_t *head, *l1, *l2; list_item_t **p = &head; l1 = left.head; l2 = right.head; for (; l1 && l2; p = &(*p)->next) { if (l1->value <= l2->value) { *p = l1; l1 = l1->next; } else { *p = l2; l2 = l2->next; } } *p = (list_item_t *)((uintptr_t)l1 | (uintptr_t)l2); l->head = head; } ``` 1. **尋找中間節點(第 5~15 行)** 使用快慢指標來找到鏈結串列的中間節點。 由於此實作使用的是單向鏈結串列,無法直接回溯,因此我們額外使用變數 `before` 記錄 `slow` 前一個節點,以便切斷鏈結,將其拆分成左右兩個子串列。 示意圖如下: ![merge_sort_1](https://hackmd.io/_uploads/ryxsIA7hkx.png) ![merge_sort_2](https://hackmd.io/_uploads/S1mjUA73Jg.png) ![merge_sort_3](https://hackmd.io/_uploads/S1BsUAmnyx.png) 2. **遞迴排序子串列(第 17~18 行)** 將拆分後的 `left` 和 `right` 子串列遞迴地進行合併排序。 3. **合併兩個排序好的子串列(第 21~35 行)** - 利用**間接指標**來避免對特殊情況的額外處理,例如首節點的變更。 此次實作也善用了我在第 2 週測驗一學習到的想法,用堆疊的區域變數來管理暫存的節點。 ## 第 1 週測驗二 ### 程式碼運作原理分析 根據函式名稱 `remove_free_tree`,我推測它的目的是尋找可用空間並將其從 free tree 中移除。然而,我一開始無法理解 `find_free_tree` 的具體功能,尤其是它的回傳型態竟然是 `block_t **`,這點讓我感到困惑。 在仔細閱讀程式碼後,我發現 `remove_free_tree` 的主要操作是更新變數 `node_ptr`,並根據 `*node_ptr` 子節點的數量,選擇不同的方式來更新它。此外,並且該函式的初始化方式是` find_free_tree(root, target)`。基於以上觀察,我推測這個函式的作用如下: 1. `find_free_tree(root, target)` 在 free tree 中搜尋 `target`,並回傳**指向 `target` 之親代節點指標的指標**。例如,若 `node->l == target` 為真,則回傳 `&node->l`。該回傳值由 `block_t **node_ptr` 接收,這樣一來,只要更新 `node_ptr`,便能直接修改親帶節點的指向。這種設計相當聰明且優雅。如示意圖: ![free_tree](https://hackmd.io/_uploads/HkuoEx4hJe.png) 2. 根據 `target` 子樹的結構,決定用哪個節點來取代 `target`: - 若 `target` 有兩個子節點,則選擇左子樹最右側的節點來取代 `target`。 - 若 `target` 僅有一個子節點,則讓該子節點直接取代 `target`。 - 若 `target` 沒有子節點,則僅需將其移除。 3. 在移除 `target` 之前,將其子節點指標設為 `NULL`,避免產生錯誤的參照。 **尋找左子樹最右節點的程式碼** ```c block_t **pred_ptr = &(*node_ptr)->l; while ((*pred_ptr)->r) pred_ptr = &(*pred_ptr)->r; ``` | 題目 | 答案 | | -------- | -------- | EEEE| `(*pred_ptr)->r` FFFF| `&(*pred_ptr)->r` ### 實作: Custom memory allocators 目標是實作一個以 free tree 管理可用空間的記憶體分配器,目前實作尚未完成,以下是 ftalloc.h 程式碼: ```c= #define BRK_SIZE 135168 typedef struct block { size_t size; struct block *l, *r; } block_t; typedef struct memory { void *addr; block_t block; } memory_t; block_t *root = NULL; void init_free_tree(); void brk_free_tree(size_t size); void free_free_tree(); block_t **find_free_tree(block_t **root, block_t *target); block_t *find_predecessor_free_tree(block_t **root, block_t *node); void remove_free_tree(block_t **root, block_t *target); void insert_free_tree(block_t **root, block_t *target); void *ftalloc(size_t size); void ftfree(void *p); ``` - `ftalloc` 和 `ftfree` 函式是預計提供給使用者呼叫的,功能應該與 `malloc` 和 `free` 相同。 - 第 7 - 9 行的函式負責管理可動態配置出的記憶體: - 在使用此記憶體空間配置器前,必需先呼叫 `init_free_tree`。`init_free_tree` 會使用 `malloc` 預先配置預設為 `BRK_SIZE` 大小的空間,作為初始可動態配置的記憶體空間。該區塊會由 free tree 管理。 - 若需要動態配置的空間超出 free tree 所管理的範圍,則呼叫 `brk_free_tree` 來增加可配置的空間並將新增的空間加入 free tree,預設擴展大小為`BRK_SIZE`。 - 程式結束後需呼叫 `free_free_tree` 來釋放由 `init_free_tree` 和 `brk_free_tree` 分配的記憶體區塊。 - 第 18 - 21 行的函式負責新增和移除 free tree 節點: - 在使用者呼叫 `ftalloc` 時,`remove_free_tree` 會在 free tree 中尋找適當的空間,將該區塊從 free tree 移除,並維護 free tree 的結構與完整性。詳細見上方程式碼分析。 - 在使用者呼叫 `ftfree` 時,`insert_free_tree` 會將釋放的空間重新加入 free tree,恢復其可用狀態。 **困難點** - 按照題目的指示,應該要預先分配好不同大小的區塊,以增加空間區域性(spatial locality)。但這會增加記憶體管理所需的開銷。在 64-bit 系統中,每個指標變數的大小為 8 位元組,`memory_t` 結構至少佔 28 位元組,若每個 4 位元組的空間都由一個 `memory_t` 來管理的成本太高了。 - **解決方案**: - 我目前的思路是將 memory_t 管理的最小單位設定為一個 page。每個 page 內會包含多個小區塊,並且使用額外的標籤來標明每個 `page` 所儲存空間的特定大小及其內部的 free list。對於每個 page 內的 free list,我打算使用更高效且成本較低的資料結構,例如 bit map,來儲存每個區塊的使用狀態。 - 另一個需要考量的問題是記憶體對齊問題(memory alignment)。目前我還沒有確定具體的解決方案,但這部分應該需要參考現有的記憶體配置器,了解如何處理對齊需求。一般來說,記憶體分配器會確保分配的區塊大小能夠符合系統的對齊要求。 ## 第 1 週測驗三 ### 程式碼運作原理分析 此測驗快速排序法的方式雖然不是使用遞迴實作,仍然是採用了分治的想法。 1. 把串列的第一個節點當作樞紐(pivot),走訪串列所有節點,將大於樞紐值的節點加入 `right` 串列,反之加入 'left' 串列。最終形成三個子串列,分別由 `left`、`pivot` 和 `right` 所指向。 2. 依序檢查 `right`、`pivot` 和 `left` 指向的串列是否需要排序,此演算法會先優先處理最右邊的串列。 流程比較複雜不好簡單解釋,直接參考程式碼: ```c while (i >= 0) { struct list_head *L = begin[i], *R = list_tail(begin[i]); if (L != R) { /* Distribute each node into the * appropriate list: left, pivot, * or right, based on its value. */ begin[i] = left; begin[i + 1] = pivot; begin[i + 2] = right; left = right = NULL; i += 2; } else { if (L) { L->next = result; result = L; } i--; } ``` 第 3 行的 `if(L != R)` 判斷該串列是否包含兩個以上的元素,若是,則需排序。分類後會產生三個子串列,因此做 `i += 2`,代表下一輪處理最後新增的串列。而`else` 對應到串列僅有一個元素,表示已經排序完成,直接合併到 `result` 內。 **為何 `max_level` 的設值為 $2n$?** ```c int max_level = 2 * n; struct list_head *begin[max_level]; ``` 我思考流程是,考慮當串列大小為 $n$,且原先已排序(升序)時,每次選擇樞紐值都會選到當前最小的元素。因此: - `left` 為空 - `pivot` 僅包含該最小值 - `right` 包含剩餘的 $n-1$ 個元素 由於該實作不是穩定排序,下一次迭代會選擇 `right` 的最大值作為新樞紐,接著又選擇最小值,如此交替進行。結果是: - `right` 為空 - 下一輪會將當前最大值合併至 `result` 這個方法不會產生 $2n$ 個串列,最只會產生 $n$ 個。於是我改變思路,我希望每次選樞紐都選到最小的樞紐。觀察到在將原始串列搬移到 `left` 或 `right` 時是有尾端插入,可以利用這個特性建構串列。舉實際例子,假設有一串列順序為 `[1 3 5 7 8 6 4 2]`,則排序過程為 1. `[] [1] [2 4 6 8 7 5 3]` 2. `[] [1] [] [2] [3 5 7 8 6 4]` 3. `[] [1] [] [2] [] [3] [4 6 8 7 5]` ... 依此類推,最終就會產生 $2n$ 個串列。 | 題目 | 答案 | | -------- | -------- | GGGG| `head->prev=prev` HHHH| `list_entry(pivot,node_t,list)->value` IIII| `list_entry(n,node_t,list)->value` JJJJ| `pivot` KKKK| `right` ### 研讀[〈A comparative study of linked list sorting algorithms〉](https://dl.acm.org/doi/pdf/10.1145/225890.225893) 本文探討六種常見排序演算法的效率,並分析其時間複雜度與實際執行效能。下方表格皆來自 [〈A comparative study of linked list sorting algorithms〉](https://dl.acm.org/doi/pdf/10.1145/225890.225893) **大 O 表示法與實際效率** 雖然在理論上,排序演算法的效率通常以 $O(f(n))$ 表示,但由於常數因子的影響,只有在 $n$ 足夠大時,$O(n^2)$ 的演算法才會比 $O(n\log n)$ 的演算法顯著低效。本研究的實驗範圍為 $n \geq 100$,因此可從結果觀察不同演算法在實務上的表現。若在 $n,100$ 的區間可能會有不同的結果。 從下表可見,在時間複雜度為 $O(n\log n)$ 的排序演算法中,二元樹排序法(Tree Sort)的效率優於快速排序法(Qsort)及合併排序法(Msort)。 ![截圖 2025-03-16 16.24.40](https://hackmd.io/_uploads/H1N0cbN2yl.png) ![截圖 2025-03-16 16.24.55](https://hackmd.io/_uploads/r1bJo-VhJx.png) **常數因子分析** 根據表格 1 和表格 2 的數據,可透過理論時間複雜度擬合出各演算法的常數,結果整理於表格 3。 ![截圖 2025-03-16 16.31.28](https://hackmd.io/_uploads/BknD3-4nyx.png) 表格 3 中的 ratio 欄位 代表當 $n \leq 1000$ 與 $n > 1000$ 時,對應的時間常數比值: - 比值大於 1:表示該演算法在較小的 $n$ 下效率較佳,此實驗中複雜度為 $O(n^2)$ 的演算法屬於這類。 - 比值小於 1:表示該演算法在較大的 $n$ 下效率較佳,此實驗中複雜度為 $O(n\log n)$ 的演算法屬於這類。 這些常數也可用來評估不同演算法之間的效率差異,提供量化的效能對照。 此外,值得注意的是,本研究的測試方法是對同一組輸入進行多次實驗後取平均值。換句話說,輸入串列的排列方式可能僅限於少數幾種固定模式,這可能導致比較結果的不公平性。例如,雖然合併排序、快速排序和二元樹排序在平均時間複雜度皆為 $O(n\log n)$,但快速排序與二元樹排序在最差情況下可能達到 $O(n^2)$,而合併排序的最差時間複雜度仍維持在 $O(n\log n)$。然而,本文並未深入探討這些最差情況下的差異。此外,該研究僅以執行效率作為評估標準,並未考量演算法是否為**穩定排序法**(Stable Sort)或**原地演算法**(In-place Algorithm),這些因素在實務應用上可能同樣重要。 ### 實作:雙向鏈結串列排序演算法 根據前述的實驗結果,二元樹排序(Tree Sort)在效率上表現較佳,因此我選擇實作此演算法。 二元樹排序的核心概念是: 1. 建立一棵二元搜尋樹(BST, Binary Search Tree),將鏈結串列中的節點依序插入二元搜尋樹。 2. 使用**深度優先搜尋(DFS, Depth-First Search)** 走訪 BST,並依序將節點還原為排序後的鏈結串列。 插入二元搜尋樹的平均時間複雜度為 $O(\log n)$,最差情況下則為 $O(n)$。由於共需插入 $n$ 個節點,整體排序的平均時間複雜度為 $O(n\log n)$,而最差情況下可能達到 $O(n^2)$。 樹狀結構如示意圖: ![treesort](https://hackmd.io/_uploads/HJbTaiNh1g.png) **實作** 排序函式 `treesort` 是主流程,此外,我實作了 `bst_insert` 和 `bst_traverse` 兩個輔助函式來處理二元搜尋樹的建立與遍歷。 主排序函式:`treesort` `treesort` 會遍歷鏈結串列,將節點依序從原串列刪除後插入二元搜尋樹,最後透過 `bst_traverse` 將二元搜尋樹轉回排序後的鏈結串列。 ```c static void treesort(struct list_head *head) { struct list_head *node, *safe, *root = NULL; list_for_each_safe (node, safe, head) { list_del(node); node->prev = NULL; node->next = NULL; bst_insert(&root, node); } bst_traverse(root, head); } ``` 插入函式:`bst_insert` `bst_insert` 會將給定的節點插入二元搜尋樹。透過遞迴尋找適當的插入位置,並使用間接指標來處理二元搜尋樹的根節點與子樹的連結關係。 `if (root_element->value > node_element->value) `這個條件確保二元搜尋樹遵循穩定排序的特性,。 ```c void bst_insert (struct list_head **root, struct list_head *node) { if (!*root) { *root = node; return; } element_t *root_element, *node_element; root_element = list_entry(*root, element_t, list); node_element = list_entry(node, element_t, list); if (root_element->value > node_element->value) bst_insert(&(*root)->prev, node); else bst_insert(&(*root)->next, node); } ``` 遍歷函式:`bst_traverse` `bst_traverse` 會 **以中序遍歷(In-Order Traversal)** 的方式走訪二元搜尋樹,並將最小的節點依序插回鏈結串列,達到排序的效果。 ```c void bst_traverse (struct list_head *root, struct list_head *head) { struct list_head *work; if (root) { bst_traverse(root->prev, head); work = root->next; INIT_LIST_HEAD(root); //printf("%d ", list_entry(root, element_t, list) ->value); list_add_tail(root, head); bst_traverse(work, head); } } ``` ## 第 2 週測驗一 ### 程式碼運作原理分析 此測驗實作的**是遞迴版本**的快速排序,採用鏈結串列的方式進行排序。 1. 選取一個節點作為樞紐(pivot),走訪每個節點,將小於樞紐值的節點加入 `list_less`,其餘則加入 `list_greater`。 2. 遞迴地對 `list_less` 和 `list_greater` 進行排序。 3. 最終合併 `list_less`、`pivot` 和 `list_greater`,得到完整的排序結果。 | 題目 | 答案 | | -------- | -------- | AAAA| `list_first_entry` BBBB| `list_del` CCCC| `list_move_tail` DDDD| `list_add` EEEE| `list_splice` FFFF| `list_splice_tail` **學習心得** 在實作時,我經常猶豫應該使用區域變數還是動態配置記憶體來宣告 struct 變數。例如,我原本可能會寫出以下動態配置的版本: ```c { struct list_head *list_less, *list_greater; list_less = malloc(sizeof(stuct list_head)); list_greater = malloc(sizeof(stuct list_head)); // ... } ``` 這種做法需要額外處理記憶體釋放 (free),並且因為變數的生命週期變長,必須在額外的地方確保值的正確性,這可能反而會**降低程式的可讀性和安全性**。 相比之下,若改為**區域變數**,則可以利用**遞迴的堆疊行為自動釋放記憶體**,提升一致性與安全性: ```c { struct list_head list_less, list_greater; INIT_LIST_HEAD(&list_less); INIT_LIST_HEAD(&list_greater); // ... list_quicksort(&list_less); list_quicksort(&list_greater); } ``` 這樣的設計方式讓變數的生命週期受到更好的控制,避免了手動管理記憶體可能帶來的錯誤。 ### 穩定排序(stable sorting)分析 快速排序的穩定性主要取決於節點加入 `list_less` 和 `list_greater` 的方式。 1. 由於樞紐選擇最左側節點,因此與樞紐值相等的節點應該放在 `list_greater`,確保相同數值的元素仍維持原本的相對順序。 2. 關鍵點:加入串列時必須從尾端插入,如果從前端插入,原本的順序會被反轉。因此,應使用 `list_move_tail` 來保持排序的穩定性: ```c 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); } ``` 這樣的做法確保了排序的穩定性,即原本相同數值的節點在排序後仍維持其相對順序。 ### 實作:改善 `random_shuffle_array` ### 實作:整合 `qtest` 並使用 perf 進行效能分析 **整合 `qtest`** - 修改 `qtest.c` - 在 `console_init` 函式內新增 ```c ADD_COMMAND(qsort, "Sort queue in ascending/descening order implemented by quicksort", ""); ``` - 實作 `do_qsort` 函式,並呼叫對應的快速排序函式 `q_qsort` - 修改 `queue.c` - 實作 `q_qsort` 函式 ```c void q_qsort(struct list_head *head, bool descend) { list_quicksort(head); if (descend) q_reverse(head); } ``` **以 perf 進行效能分析** 我實作了一個函式來讀取輸入檔案,該檔案包含 1024 個長度為 8 的字串。測試函式會將這些字串依序插入鏈結串列,並根據命令列參數選擇相應的排序方法: - sort:對應原本實作的合併排序(Merge Sort)。 - qsort:對應此次新增的快速排序(Quicksort)。 :::spoiler 實驗環境 ```shell $ cat /etc/os-release PRETTY_NAME="Ubuntu 24.04.1 LTS" NAME="Ubuntu" VERSION_ID="24.04" VERSION="24.04.1 LTS (Noble Numbat)" VERSION_CODENAME=noble ID=ubuntu ID_LIKE=debian HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" UBUNTU_CODENAME=noble LOGO=ubuntu-logo $ lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Address sizes: 46 bits physical, 48 bits virtual Byte Order: Little Endian CPU(s): 20 On-line CPU(s) list: 0-19 Vendor ID: GenuineIntel Model name: 12th Gen Intel(R) Core(TM) i7-12700 CPU family: 6 Model: 151 Thread(s) per core: 2 Core(s) per socket: 12 Socket(s): 1 Stepping: 2 CPU(s) scaling MHz: 25% CPU max MHz: 4900.0000 CPU min MHz: 800.0000 BogoMIPS: 4224.00 Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx p dpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf tsc_known_freq pni pclm ulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes x save avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow flexpriority ept vpid e pt_ad fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid rdseed adx smap clflushopt clwb intel_pt sha_ni xsaveopt xsavec xgetbv1 x saves split_lock_detect user_shstk avx_vnni dtherm ida arat pln pts hwp hwp_notify hwp_act_window hwp_epp hwp_pkg_req hfi vnmi umip pku ospke waitpkg gfni vaes vpclmulqdq rdpid movdiri movdir64b fsrm md_clear serialize pconfig arch_lbr ibt flush_l1d arch_capabilit ies Virtualization features: Virtualization: VT-x Caches (sum of all): L1d: 512 KiB (12 instances) L1i: 512 KiB (12 instances) L2: 12 MiB (9 instances) L3: 25 MiB (1 instance) NUMA: NUMA node(s): 1 NUMA node0 CPU(s): 0-19 Vulnerabilities: Gather data sampling: Not affected Itlb multihit: Not affected L1tf: Not affected Mds: Not affected Meltdown: Not affected Mmio stale data: Not affected Reg file data sampling: Mitigation; Clear Register File Retbleed: Not affected Spec rstack overflow: Not affected Spec store bypass: Mitigation; Speculative Store Bypass disabled via prctl Spectre v1: Mitigation; usercopy/swapgs barriers and __user pointer sanitization Spectre v2: Mitigation; Enhanced / Automatic IBRS; IBPB conditional; RSB filling; PBRSB-eIBRS SW sequence; BHI BHI_DIS_S Srbds: Not affected Tsx async abort: Not affected $ gcc --version gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0 Copyright (C) 2023 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. ``` ::: 執行結果如下: ```shell $ sudo perf stat -e cycles,instructions ./test sort Performance counter stats for './test sort': <not counted> cpu_atom/cycles/ (0.00%) 1,918,165 cpu_core/cycles/ <not counted> cpu_atom/instructions/ (0.00%) 2,850,215 cpu_core/instructions/ # 1.49 insn per cycle 0.000717136 seconds time elapsed 0.000675000 seconds user 0.000000000 seconds sys $ sudo perf stat -e cycles,instructions ./test qsort Performance counter stats for './test qsort': <not counted> cpu_atom/cycles/ (0.00%) 1,648,884 cpu_core/cycles/ <not counted> cpu_atom/instructions/ (0.00%) 2,862,565 cpu_core/instructions/ # 1.74 insn per cycle 0.000584501 seconds time elapsed 0.000000000 seconds user 0.000609000 seconds sys ``` 目前尚未詳細分析兩者的效能差異,後續將進一步探討如何進行合理的比較,並補充更詳細的測試與分析結果。 ## 第 2 週測驗二 ### 程式碼運作原理分析 此測驗涉及 `clz`(counting leading zero)和整數開平方根 `sqrti` 的實作。 #### `clz` 分析 `clz` 的目標是計算數值的 leading zeros 的數量。實作方法是將數值拆分為上半部(upper)與下半部(lower),並根據以下邏輯進行遞迴計算: • 若 `upper` 為零,則遞迴計算 `lower` 的 leading zero,並加上 `upper` 佔據的位數。 • 若 `upper` 不為零,則遞迴計算 `upper` 的 leading zero。 **思路** 由於遞迴的流程較難直觀理解,因此我先找出**初始條件與遞迴結束條件**。 - if (c == JJJJ) 條件下並未進行遞迴呼叫,推測這是遞迴終止的條件。 - c + 1 會在每次遞迴時遞增,當 c == 3 時應該要終止,否則可能導致超出陣列範圍存取錯誤。 ```c if (c == JJJJ) return upper ? magic[upper] : KKKK + magic[lower]; return upper ? clz2(upper, c + 1) : (16 >> (c)) + clz2(lower, c + LLLL); ``` 根據巨集定義 `#define clz32(x) clz2(x, 0)`,推測 `x` 為目標數值,而 `c` 則記錄目前處理的階段。 - `c == 0` 時,處理 32 位元數值 - `c == 1` 時,處理 16 位元數值 - `c == 2` 時,處理 8 位元數值 - `c == 3` 時,處理 4 位元數值 **逐行分析 `clz2`** ```c= static const int mask[] = {0, 8, 12, GGGG}; static const int magic[] = {HHHH, 1, 0, IIII}; 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 == JJJJ) return upper ? magic[upper] : KKKK + magic[lower]; return upper ? clz2(upper, c + 1) : (16 >> (c)) + clz2(lower, c + LLLL); } ``` - 第 9 行:`upper` 取得 `x` 的上半部位元。 - 第 10 行:`lower` 取得 `x` 的下半部位元,其中 `0xFFFF` 是 16 位的遮罩,前 16 位為 0,後 16 位為 1,透過 `mask[c]` 決定要保留的位數。當 `c` 為 3 時,應該要留下末兩位元,因此 `mask[3]` 為 14。 - 當 `c == 3`(處理 4 位元)時,`magic[]` 儲存 **2 位元對應的 leading zero 數量**,因此: - `magic[] = {2, 1, 0, 0}` - 當 `upper != 0`,回傳 `magic[upper]` - 當 `upper == 0`,leading zero 為 `upper` 的 2 位元加上 `magic[lower]` | 題目 | 答案 | | -------- | -------- | GGGG| 14 HHHH| 2 IIII| 0 JJJJ| 3 KKKK| 2 LLLL| 1 #### `sqrti` 分析 參考:[從 √2 的存在談開平方根的快速運算](https://hackmd.io/@sysprog/sqrt#Digit-by-digit-Method) **理論分析** 設 $N$ 為正整數,目標是求出 $N$ 的平方根值(以整數表示)。 $$ \begin{align} N&=(b_n\times2^n+b_{n-1}\times2^{n-1}+...+b_0\times2^0)^2 \\ &=(a_n+a_{n-1}+...+a_0)^2 \end{align} $$ 其中,$b_i=0$ or $1,\ i=1,\ 2,...,\ n$、$a_i=b_i\times2^i$。 假設最高位為非零元素為第 k 位,亦即,$b_i=0,$ for $i>k$。我們有 $$a_k=2^k>\sum_{i=0}^{k-1}a_i=2^k-1$$ 又對於任何 $x\geq2$,$x^2\geq2x$。於是對於第一個 $a_k^2\leq N$,必須把 $b_k$ 設成 $1$。否則就算把第 k 位以下都設成 $1$,對於估計 $\sqrt N$ 還是太小。我認為這是一個不嚴謹但相對直觀的解釋,搭配實作可以很好的幫助理解。 因此演算法可以以這個方法實作 1. 從最高位 $n$ 開始檢查,直到找到第一個 $a_k^2\leq N$,則把 $b_k$ 設成 $1$。 2. 接者,若 $\left(a_k+a_{k-1}\right)^2\leq N$,則把 $b_{k-1}$ 設為 $1$,反之設為 $0$。設 $P_m=\sum_{i=m+1}^na_i$,因為 $a_i$ 在第 $k$ 位以上都是 $0$,也可以簡化為 $P_m=\sum_{i=m+1}^{k}a_i$,以現階段為例就是 $P_{k-2}=\sum_{i=k-1}^{k}a_i=a_k+a_{k-1}$。 3. 計算 $P_{k-3}^2=\left(a_k+a_{k-1}+a_{k-2}\right)^2$,並和 $N$ 比較,若 $P_{k-3}^2\leq N$,則把 $b_{k-2}$ 設為 $1$,反之設為 $0$。 4. 以此類推直到計算到 $P_0$,最終結果 $\sqrt N$ 可以用 $(b_kb_{k-1}...b_0)_2$ 來估計。 定義 $X_m=N-P_m^2$ 為實際值和逼近值的誤差,其中 $Y_m=(2P_{m+1}+a_m)a_m$,可以縮減計算過程。 1. $X_m=N-P_m^2=X_{m+1}-Y_m$ 2. $Y_m=P_m^2-P_{m+1}^2=(2P_{m+1}+a_m)a_m$ **實作** ```c uint64_t sqrti(uint64_t x) { uint64_t m, y = 0; if (x <= 1) return x; int total_bits = 64; 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; } ``` 此程式碼可以直接對應到上述理論。`m = 1ULL << shift` 是將初始的 $P_n$ 設定為 $a_n$。若 `y` 記錄 $P_m$,`b = y + m`為 $P_{m-1}$。不斷重複計算直到 $P_0$。 | 題目 | 答案 | | -------- | -------- | MMMM| `~1` NNNN| `1` PPPP| `2` ### 實作:開平方根向上取整 $\left\lceil \sqrt N\right\rceil$ 我一開始打算往數學的方式思考。正如前面提到的,此實作會比較 $P_m^2$ 和 $N$ 的大小,若小於 $N$ 則加入。但如果要做向上取整就比較困難,向上取整代表勢必需要 $P_m^2\geq N$,但很難知道到底需要大多少。於是我認為這個方向行不通。 我改成從實作著眼,若非完全平方數則需要加 $1$,完全平方數則不需要。我的思路轉為去思考**完全平方數和非完全平方數的差別**。從理論思考,我猜測在迴圈結束的時候,若為完全平方數 `x` 應該要為 $0$。於是我做了初步的實驗,我將 $1$ 到 $100$ 帶入,確認函式正確並且檢視函式結束時的 `x` 值。發現結果和我猜測的一樣: ``` x: 1, N: 2, sqrt(N): 1 x: 2, N: 3, sqrt(N): 1 x: 0, N: 4, sqrt(N): 2 x: 1, N: 5, sqrt(N): 2 x: 2, N: 6, sqrt(N): 2 x: 3, N: 7, sqrt(N): 2 x: 4, N: 8, sqrt(N): 2 x: 0, N: 9, sqrt(N): 3 x: 1, N: 10, sqrt(N): 3 x: 2, N: 11, sqrt(N): 3 x: 3, N: 12, sqrt(N): 3 x: 4, N: 13, sqrt(N): 3 x: 5, N: 14, sqrt(N): 3 x: 6, N: 15, sqrt(N): 3 x: 0, N: 16, sqrt(N): 4 x: 1, N: 17, sqrt(N): 4 x: 2, N: 18, sqrt(N): 4 ``` 但我認為在接近無號整數最大值附近會出問題於是我在 `sqrti` 的最後加了一行 `assert(x == 0)`,並只傳入完全平方數。測試函式如下: ```c for (int i = 0; i < 65536; ++i) { sqrti(i*i) } ``` 但是 assert 報錯了,我目前測試到 $2^{15}$ 都沒有問題,還沒找到最小會報錯的數字,原因要進一步分析。至少確定在 $N<2^{30}$ 要實作 $\left\lceil \sqrt N\right\rceil$ 僅需要在 `sqrti` 函式的最後加上: ``` if (x != 0) ++y; ``` ### 實作:不依賴除法的浮點數開平方根 `sqrtf` ### 實作:藉由查表的平方根運算 ## 第 2 週測驗三 此測驗涉及雜湊表(hash table)的實作,並在發生雜湊碰撞(hash collision)時,將碰撞的兩個值用鏈結串列的方式串接起來。以下是程式碼和示意圖: ![hlist](https://hackmd.io/_uploads/SyRj6_m21l.png) ```c struct list_head { struct list_head *next, *prev; }; struct hlist_head { struct hlist_node *first; }; struct hlist_node { struct hlist_node *next, **pprev; }; ``` 從程式碼中可以看出,雜湊表使用的鏈結串列與一般的雙向鏈結串列稍有不同,主要有以下兩點差異: 1. **節省空間**:雜湊表的鏈結串列**不應該**頻繁需要遍歷,也不需要快速存取尾節點。因此將首節點指向前一個元素的指標 `pprev` 省略,可以節省空間。因為雜湊表在建立時會創建大量的 `hlist_head`,其中有一部分可能完全不會使用到。 2. **簡化刪除操作**:`pprev` 是指向前一個節點 `next` 屬性的指標,這使得在刪除節點時更加方便。 **Two Sum 實作** 此測驗關於 LeetCode [Two Sum](https://leetcode.com/problems/two-sum/description/)。目標是高效率(O(N))的尋找兩個數字,使它們的和為指定數字 `n`。為了達到這個目標,會構建一個雜湊表 `ht`。當現在搜尋的數字為 `k` 時,會檢查 `ht[k]` 是否存在。如果不存在,則建立 `ht[n-k]`;若存在,則表示找到所求的組合,並從儲存的資料中取出。 因此,資料結構如下: ```c typedef struct { int bits; struct hlist_head *ht; } map_t; struct hash_key { int key; void *data; struct hlist_node node; }; ``` 其中 `key` 是 $n-k$ 的雜湊值,`data` 儲存相關資料(本題為索引值)。 **各個函式的運作分析** - `map_init`:這個函式會動態配置出 `map_t` 結構的變數 `map`,並根據指定的雜湊位元數動態配置出雜湊表的首節點,管理在 `map` 內。 - ``map_add``:這個函式將傳入的 `key` 和 `data` 加入雜湊表。會先檢查這個 `key` 是否已經在雜湊表中;若不存在,則會動態配置出一個新的 `hlist_node` 空間,並管理在 `map` 中。 - ``map_deinit``:這個函式會遍歷整個雜湊表並釋放 `map_init` 和 `map_add` 所配置的記憶體。 | 題目 | 答案 | | -------- | -------- | AAAA| `map->bits` BBBB| `map->bits` CCCC| `first->pprev` DDDD| `n->pprev` EEEE| `n->pprev`