# 2025q1 Homework2 (quiz1+2) contributed by <`MikazukiHikari`> ## quiz1 ### 測驗 `1` 這段程式碼建立了一個簡單的測試框架,專門用來測試 `list.h` 內的鏈結串列函式 ![1](https://hackmd.io/_uploads/Sy7BX-M3yg.svg) 定義鏈結串列節點 `list_item_t`、單向鏈結串列 (list_t),`head` 指向鏈結串列的第一個節點。如上圖所示: ```c #include <stddef.h> typedef struct list_item { int value; struct list_item *next; } list_item_t; typedef struct { struct list_item *head; } list_t; ``` 程式脈絡: 先看`main` ,它呼叫了 `test_suite`的巨集`my_run_test`,再呼叫`test_list`,之後便開始進行`list_insert_before`的測試,過程中透過巨集`my_assert`判斷是否有錯誤,若有則回傳對應的錯誤訊息;沒有的話回傳`NULL`,回傳的結果會給 `result` ,最後輸出`result`、其對應的 `PASS / ERROR` 以及總共跑的次數。 #### 巨集 my_assert, my_run_test `my_assert` 用來檢查條件是否成立,如果條件 test 為 false,則回傳錯誤訊息 message。 `my_run_test` 用來執行測試函式,如果測試函式回傳錯誤訊息,就直接終止測試。 #### 初始化鏈結串列 list_reset 初始化1000個`list_item`;value設為1~1000 #### 測試函式list_insert_before 根據題目說明可知`list_insert_before`的參數(`l`, `before`, `item`)及其意義 ![image](https://hackmd.io/_uploads/BJ5ZKMM31x.png) 使用`for`迴圈從 i = 0~999 執行 1000 次的 `list_insert_before` ```c for (size_t i = 0; i < N; i++) list_insert_before(&l, l.head, &items[i]); ``` 也就是說會等於在有1000次的for迴圈,每次用`list_insert_before` 在鏈結串列的最前面插入 對應次數(0~999) 的`list_item`,預期可以得到鏈結串列為: ![2](https://hackmd.io/_uploads/H1OeRMGhke.svg) 之後利用巨集`assert`檢查插入元素的`value`是否和預期相同。 第二次測試則也是1000個`list_item`,但是這次全插在鏈結串列最後面,其他同上,第三次同第二次。 ![3](https://hackmd.io/_uploads/SJwhZXMhyx.svg) 接著開始解題,運用指針的指針技巧,`p` 是指向 list_item_t * 的指標,因此只有可能用於指向 `head` 或是 `next`,它也作為 for 迴圈的索引,根據描述,我們需要遍歷整個鏈結串列直到找到`before`,因此需要從頭開始,所以 `AAAA` 的答案一定是 `head` 的地址,因此答案為`&l->head`。 之後`item`必須插入`before`的前面,因此需要遍歷整個鏈結串列直到找到`before`然後跳出迴圈,因此`BBBB`的答案應為`before`。 而`CCCC` 為 `&(*p)->next`,如此可以使原本`p`指向下個節點。 `DDDD` = `before` ,確保新插入的 `item->next` 能指向`before`使鏈結串列完整連接。 ![image](https://hackmd.io/_uploads/rkiRqEz2kl.png) #### 延伸問題:在現有的程式碼基礎上,撰寫合併排序操作 在 `merge_two_list` 使用間接指標,可以不用額外分配記憶體空間給 `head` ```c static inline list_item_t *merge_two_list(list_item_t *l1, list_item_t *l2) { list_item_t *head = NULL, **ptr = &head; while (l1 && l2) { if (l1->value < l2->value) { *ptr = l1; l1 = l1->next; } else { *ptr = l2; l2 = l2->next; } ptr = &(*ptr)->next; } *ptr = l1 ? l1 : l2; //直接附加剩餘的部分 return head; } static list_item_t *merge_sort_list(list_item_t *head) { if (!head || !head->next){ return head; //空或單一節點的情況 } //使用快慢指標找出中點 list_item_t *slow = head, *mid; for (list_item_t *fast = head; fast->next && fast->next->next; fast = fast->next->next) { slow = slow->next; } mid = slow->next; slow->next = NULL; //斷開鏈結,分成左右兩半 //遞迴排序 list_item_t *left = merge_sort_list(head); list_item_t *right = merge_sort_list(mid); //合併排序後的左右鏈結 return merge_two_list(left, right); } ``` ### 測驗 `2` `block_t` : 實現二元搜尋樹的結構體,每個節點都代表一個記憶體區塊。 `find_free_tree`:是個搜尋函式,會回傳指向 `target` 的指標 `node_ptr`。 `find_predecessor_free_tree`:用來尋找某個節點的in-order predecessor,也就是在 BST 中小於某個節點的最大節點。 `remove_free_tree`:負責從 BST 中移除指定的節點,並確保樹的結構仍然維持 BST 的特性,也是這段程式碼的重點。 1. 刪除的節點沒有子節點 → 直接刪除`*node_ptr = NULL;`。 2. 刪除的節點只有一個子節點 → 讓它的子節點取代它的位置。 3. 刪除的節點有兩個子節點 → 透過 `find_free_tree` 找到目標的位置,`**node_ptr` 指向目標位置,`**pred_ptr` 指向左子樹的根節點。接下來,需要找出 in-order predecessor ,也就是左子樹中最大的節點。 下一行的 `while` 迴圈持續向右遍歷左子樹,直到找到最右側的節點。 因此`EEEE`為 `(*pred_ptr)->r` 才能在已經找不到右子節點時跳出迴圈;而`FFFF`則為 `pred_ptr` 所指向的節點的右子節點的地址,因此為`&(*pred_ptr)->r`,如此才能使迴圈不斷向右遍歷。 之後透過`find_predecessor_free_tree`再次搜尋,並使用`assert`巨集檢查找到的 in-order predecessor 是否相同,若不相同則終止程式。 最後,把 `target` 替換為 `pred_ptr`,有兩種情況: 1. `pred_ptr` 剛好是 `target` 的左子節點,此時用 `pred_ptr` 替換 `target `;保留 `target` 的右子樹 。 2. `pred_ptr` 在 `target` 左子樹的更深處,先遞迴刪除 `pred_ptr` ,因為需要從 predecessor 的左子樹尋找它的 predecessor 。 #### 延伸問題:解釋上方程式碼運作的原理,並嘗試補完程式碼,使其可獨立執行,應提供相關的測試程式碼 以下參考 [rota1001](https://hackmd.io/@rota1001/linux2025-homework2#%E6%B8%AC%E9%A9%97-2) 大大的實作並改寫 這裡只要去實作 `find_free_tree` 和 `find_predecessor_free_tree` 這兩個函式就好 首先是 `find_free_tree` ,它在二元搜尋樹中搜尋最適合的區塊來存放`target`,優先檢查左子樹,看看是否有更小但仍然符合需求的區塊,以提高記憶體利用率;如果左子樹沒有適合的區塊,則檢查目前的節點 `*root` 是否足夠大;如果當前節點仍不夠大,則遞迴往右子樹尋找更大的區塊。如果發現所有的節點都比 `target` 的 `size` 還要小的話,就會回傳一個指向值為 `NULL` 的指標的指標 ```c block_t **find_free_tree(block_t **root, block_t *target) { if (!(*root)) return root; if ((*root)->l && ((*root)->l->size >= target->size)) return find_free_tree(&(*root)->l, target); if ((*root)->size >= target->size) return root; return find_free_tree(&(*root)->r, target); } ``` 接下來是`find_predecessor_free_tree`,在二元搜尋樹中尋找指定節點的前驅節點 (predecessor),若某節點存在左子樹,則其 predecessor 為左子樹最右邊的節點。 ```c block_t *find_predecessor_free_tree(block_t **root, block_t *node) { if (!node || !node->l) return NULL; // 沒有左子樹,沒有前驅節點 block_t *pred = node->l; while (pred->r) // 找左子樹中的最大值 pred = pred->r; return pred; } ``` 除此之外,為了測試額外添加了 `insert_tree` (插入節點) 和 `print_tree` (以中序遍歷列印樹) ```c void print_tree(block_t *node) { if (!node) return; print_tree(node->l); printf("%ld ", node->size); print_tree(node->r); } void insert_tree(block_t **root, size_t size) { if (!(*root)) { *root = malloc(sizeof(block_t)); if(!(*root)) return; (*root)->size = size; (*root)->l = (*root)->r = NULL; return; } if ((*root)->size < size){ insert_tree(&(*root)->r, size); }else if ((*root)->size > size){ insert_tree(&(*root)->l, size); }else{ return; // 若已存在相同值,直接返回 } } ``` `insert_tree`和`print_tree`之範例測試: ```c block_t *root = NULL; insert_tree(&root, 50); insert_tree(&root, 30); insert_tree(&root, 70); insert_tree(&root, 20); insert_tree(&root, 40); insert_tree(&root, 60); insert_tree(&root, 80); ``` 二元搜尋樹結構: ```css [50] / \ [30] [70] / \ / \ [20] [40][60] [80] ``` `print_tree(root)`: ``` 20 30 40 50 60 70 80 ``` 然後依序插入 `0,3,6,9,2,5,8,1,4,7`,然後依照`0,4,8,2,6,1,5,9,3,7`的順序一個一個刪除: ```c int main() { block_t *root = NULL; for (int i = 0; i < 10; i++) { insert_tree(&root, (i * 3) % 10); } print_tree(root); puts(""); block_t new_block; for (int i = 0; i < 10; i++) { new_block.size = (i * 4) % 10; remove_free_tree(&root, &new_block); print_tree(root); puts(""); } } ``` 結果如下: ``` 0 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 1 2 3 5 6 7 8 9 1 2 3 5 6 7 9 1 3 5 6 7 9 1 3 5 7 9 3 5 7 9 3 7 9 3 7 7 ``` ### 測驗 `3` 程式碼定義了一個 `node_t` 結構體,用來表示一個鏈結串列中的節點。使用了 `list_head` 來管理節點的鏈結關係,並使用 `value` 來紀錄數值。 ```c typedef struct __node { long value; struct list_head list; } node_t; ``` #### 測試程式碼 1. `list_construct`:一個指向動態分配的 `node_t` 結構體的指標`node`,設定數值 `value`,將其插入到 `list_head` 後面。 2. `list_free`:使用 `list_for_each_entry_safe` ,逐一走訪節點,並釋放其空間。 3. `list_is_ordered`:檢查是否為降序以排列符合` quick_sort` 排序結果,遍歷整個鏈結串列並確認順序,如果發現某個 `entry->value < value`,代表未排序,回傳 `false` 。 4. `shuffle`:隨機打亂陣列順序,它實現了一個Fisher-Yates Shuffle,運作方式是從陣列的開頭開始,然後將當前元素與一個隨機選中的元素交換,這樣就可以在 `O(n)` 的時間複雜度內隨機打亂陣列。 5. `main`:主要流程是,初始化 `list_head`,產生 `test_arr` 並打亂順序,然後插入到鏈結串列,執行 `quick_sort` 進行排序,再用 assert() 確保排序結果正確,最後釋放所有記憶體。 #### 輔助函式 1. `list_tail`:使用遞迴得到鏈結串列最後一個節點。 2. `list_length`:逐一走訪節點以計算整個鏈結串列有多少節點。 3. `rebuild_list_link`:在 quick_sort 排序完後對每個節點的 `prev` 進行重建,因為quick_sort 僅以節點的`next`排序,因此需透過遍歷全部的節點的重建 `prev` ,之後還需要再將鏈結串列的最後一個元素的`next`指向`head`並也將`head`的`prev`指向最後一個元素才能完成雙向循環鏈結串列,因此`GGGG`是`head->prev`。 #### quick_sort 由於每次分割都會產生兩個子列表需要做排序,最壞情況下為每次分割其中一邊只有一個元素,會導致需要分割 `n` 次,產生 `2 * n` 個需要分配的子列表,將`max_level`的值設為鏈結串列的2倍大小以確保有足夠的空間進行排序。並且將第一個需要排序的列表設定為 `list->next` 及把環狀鏈結串列打斷,轉換為非環狀結構。 以下參考[charliechiu](https://hackmd.io/@charliechiu/linux2025-homework2#%E6%B8%AC%E9%A9%97-3)大大的實作 接著便開始排序: ![image](https://hackmd.io/_uploads/BykBCPG21e.png) 首先先將 `L` 及 `R` 兩指標指向頭及尾: ![image](https://hackmd.io/_uploads/r1Flydz3yx.png) 若 `L` 及 `R` 兩者不同,則將 `pivot` 指向 `L` 所指的節點,並將 `pivot` 的數值存於 `value`。 ![image](https://hackmd.io/_uploads/HJ4lZufnyg.png) 使用 `p` 指向 `pivot` 的下一個節點,並斷開 `pivot`。 ![image](https://hackmd.io/_uploads/BkuGWdMn1x.png) 使用 `p` 遍歷節點,將小於 `value` 的置於 `left` 中,大於 `value` 則置於 `right` 中。 ```c if (n_value > value) { n->next = right; right = n; } else { n->next = left; left = n; } ``` ![image](https://hackmd.io/_uploads/H1YX7dGh1g.png) 將 `begin[i]` 指向對應的位置。 ```c begin[i]= begin[0] = left; begin[i + 1]= begin[1] = pivot; begin[i + 2]= begin[2] = right; left = right = NULL; ``` ![image](https://hackmd.io/_uploads/Syr6Xuf31g.png) 在下一輪中同樣將 `L` 指向 `begin[i]` 即為 `begin[2]` (從較大子序列開始),而` R` 則指向 `begin[2]` 的尾端。 ![image](https://hackmd.io/_uploads/SyN44dG31g.png) 此時按照先前的步驟將` pivot` 設置在` L` 並將 `p` 指向 `pivot` 下一個節點 將串列上的元素與 pivot 比較後分成 left (小於等於 pivot) 和 right (大於 pivot) 同樣遍歷節點,將小於 value 的置於 left 中,大於 value 則置於 right 中,並將 begin[] 指向對應的位置。 ```c begin[i]= begin[2] = left; begin[i + 1]= begin[3] = pivot; begin[i + 2]= begin[4] = right; left = right = NULL; ``` ![image](https://hackmd.io/_uploads/Hyvm8_Mnyx.png) `begin[0]`也是如此,以此類推,直到排序出整個鏈結串列中最大值的元素為止,這時,`L`才會第一次等於`R`。 若`L`和`R`相同,代表`begin[i]`指向的鏈結串列的元素只有一個,因此將它的`next`指標指向已經排序好的鏈結串列之中的最小元素`result`,再將`result`設為它自己後,再繼續對`begin[i-1]`指向的鏈結串列做排序,若`begin[i-1]`指向的鏈結串列不只一個元素則又會進入第一種狀況(`L` 及 `R` 兩者不同)。 因為`value`會持續的和遍歷串列的元素比大小,因此`HHHH`應該是 `pivot` 節點的數值,因此答案為`list_entry(pivot,node_t,list)->value`;`n_value`會透過`IIII`不斷更新然後和`value`做比較,因此答案為當前節點`n`的數值,為`list_entry(n,node_t,list)->value`;最後`JJJJ`和`KKKK`即為`pivot`或`right`才能將結果存在`begin`中並且符合題目說明的`quick_sort`。 ## quiz2 ### 測驗 `1` 測驗 `1` 的重點有別於尋常的快速排序,而是實現了stable sorting 的 quickSort 用於鏈結串列 。 一般的 quickSort 在陣列上運行時可能會破壞穩定性(因為相同鍵值的元素可能會因交換順序改變),但這裡可透過其他的操作來保持穩定性。 #### cmpint 用於實作 quicksort 的數字比較,強制將傳入參數轉型為 `uint16_t *` 才能比較數字大小,因為傳入的參數是void *無法存取。 * C99/C11 規格書6.3.2.3 (1) 提到: > A pointer to void may be converted to or from a pointer to any object type. A pointer to any object type may be converted to a pointer to void and back again; the result shall compare equal to the original pointer. 轉型後的數字為無號16位元整數,因此範圍為0~65535 #### getnum 用於產生亂數,宣告三個只會初始化一次的 `static` 無號16位元整數,之後每次呼叫此函式都會進行如下計算: ``` s1 = (s1 * 171) % 30269 s2 = (s2 * 172) % 30307 s3 = (s3 * 170) % 30323 ``` 再回傳只有最右邊8位元的 s1 ^ s2 ^ s3,如此產生 0~255 之間的亂數。 #### get_unsigned16 透過呼叫 `getnum` 函式兩次,生成16位元的亂數。 #### 延伸問題:改進後的 random_shuffle_array ```c static inline void random_shuffle_array(uint16_t *operations, uint16_t len) { for (uint16_t i = len - 1; i > 0; i--) { uint16_t j = get_unsigned16() % (i + 1); uint16_t temp = operations[i]; operations[i] = operations[j]; operations[j] = temp; } } ``` * 根據Fisher-Yates Shuffle,`j` 必須在 [0, i] 之間選擇,而不是 [0, i+1] 。 #### main 這段程式碼的主要功能是 測試 `list_quicksort` 是否能正確對 `testlist` 進行排序,並且與標準的 `qsort` 結果做比對。 打亂 `values` 陣列,初始化 `testlist` ,並將` values` 陣列轉換成 `testlist`,再使用 `qsort` 排序 `values` 以作為正確答案,使用 `list_quicksort` 排序 `testlist`,最後檢查 `testlist` 與 `qsort` 結果是否匹配,且釋放記憶體並確認 `testlist` 為空。 #### list_quicksort 整段程式採遞迴呼叫,前面為基礎情況檢查和初始化 `list_less` 和 `list_greater` ,之後大致可分為三個部分: ```c pivot = AAAA(head, struct listitem, list); BBBB(&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 CCCC(&item->list, &list_greater); } ``` * 選取 `pivot` 並將其從原串列中移除,根據 `pivot` 型別為元素的指針,以及選項中只有 `list_first_entry` 和返回元素的指針有關,可以判斷 `AAAA` 是 `list_first_entry` ;同時,前面提到需將其從原串列中移除,因此 `BBBB` 是 `list_del` 。 * 用 `list_for_each_entry_safe` 遍歷鏈結串列 ,根據比較大小的結果將元素移到 `list_greater` 或 `list_less`,且移動操作應該使元素保持原本的順序,故選 `list_move_tail`。 ```c list_quicksort(&list_less); list_quicksort(&list_greater); ``` * 對分組後的結果進行遞迴呼叫。 ```c DDDD(&pivot->list, head); EEEE(&list_less, head); FFFF(&list_greater, head); ``` * 當做完上述的操作,我們應該要將結果添加回去。由於 `pivot` 現在是獨立元素(沒有 `head`),所以使用 `list_add` 或 `list_add_tail` ,語意上沒差但前者短,故 `DDDD` 為 `list_add` 。 * `EEEE` 和 `FFFF` 是將排序好的兩個鏈結串列串回去 `head` ,由於 `qsort` 本身是按升冪排序,所以 `list_less` 接到 `pivot` 前,所以 `EEEE` 是 `list_splice` ;而 `list_greater` 應該接到 `pivot` 的後面,所以 `FFFF` 是 `list_splice_tail` 。 #### 延伸問題:若將 list_move_tail 換為 list_move,為何會無法滿足 stable sorting 由於 `list_move` 會將元素插入到鏈結串列的開頭,而 `list_move_tail` 則會將元素插入到鏈結串列的尾端,接著, `list_for_each_entry_safe` 會從鏈結串列的開頭逐一與 `pivot` 進行比較,因此如果使用 `list_move`,較後面的元素將被放置在前面,造成兩相同數值的元素在排序後位置互換,無法滿足 **stable sorting** 的要求。 ### 測驗 `2` #### clz2 以下參考 [Dennis Liu](https://hackmd.io/@dennis40816/linux2025q1-quiz2-writeup#%E6%B8%AC%E9%A9%97-2) 大大的作答講解 主要用來計算 `x` 的前導零數量,將當前的數字以位元為單位,切成一半。若 `upper` 是 0,下一次檢查 `lower` 部分,且紀錄 upper 位元數 (16 >> c),函式會在 `c` 不等於3時繼續遞迴呼叫,直到最後等於3時會利用`magic`陣列計算最後第 0~3 位元有幾個 0,如此即完成計算 `x` 的前導零數量。 根據規律,mask[c] 在第一步 ( `c=0` )為 0,下一步變成 8,再下一步會變成 12,即為之前累積的位移量加上這次的位移量 `16 >> c` ,因此 `GGGG` 應為 12 + 2 = 14。 前面說到: >遞迴呼叫 clz2() 函式,直到僅剩 2 位元(bits)需要檢查 leading zero,然後回傳結果。 說明 `c` 與 `JJJJ` 相等即為剩下 2 bits,`(16 >> c) == 2` ,`JJJJ` = 3 。 前面說到`magic`陣列用來計算最後第 0~3 位元有幾個前導 0 , 而如果 `upper` 非 0,直接返回 `magic[upper]`,此時 upper 只可能有三種可能: * 0b01: 有一個前導 0,對應到 `magic[1]` ➝ 1 * 0b10: 沒有前導 0,對應到 `magic[2]` ➝ 0 * 0b11: 沒有前導 0,對應到 `magic[3]` ➝ 0 ➝ `IIII` 而 `magic[0]` 只會在 `upper` 是 0 時,此時 `lower` 也是0,此時前導零數量為 4(剩下各兩 bits 都是 0),然而還有一項 `KKKK`,代表的是 `upper` 固定貢獻的前導零數量,也就是 2,所以 `magic[0]` = `HHHH` 也是 2。 最後一部分,是遞迴呼叫 `clz2` 的過程:當 `upper` 非零,計算 `upper` 的 `clz2` 並返回; 若 `upper` 是零,則計算 `lower` 的 `clz2` 並加上 `upper` 全零的前導零數量並返回,由於 `lower` 也要切一半,所以 `LLLL` 同樣也是 1 。 #### sqrti 使用逐步逼近但在位元運算上更為高效的演算法,計算 64 位元無號整數的平方根的整數值,利用剛才的 `clz2` 建構出的 `clz64`,找出從左看第一個數值為1的位元的索引值(索引值從左到右是 64 ~ 0 )。 說明指出: >(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. 強制一個數字變成偶數最好的方法就是和`~1` = `MMMM` 做邏輯 `&` 運算而把最低位強制變0,得到變數shift;之後設定64位元無號整數 `m` 往左移`shift`位,這樣 `m` 才能從 4 的冪次開始,接著利用二進制開平方法從最高位開始逐步逼近平方根。 ```c while (m) { uint64_t b = y + m; y >>= NNNN; if (x >= b) { x -= b; y += m; } m >>= PPPP; } ``` * 從最大可能的平方根 `y` 開始,每次嘗試將 `m` 加到 `y` 上,看看 `x` 是否還夠大,如果 `x >= b`,說明 `b` 仍然是平方根的一部分,所以 `x -= b`,`y += m`,且逐步減小 `m`,使其從大到小逼近真正的平方根。`NNNN` = 1,表示 每次` y` 都要右移 1 位,以確保計算過程的進位;`PPPP` = 2,因為 `m` 代表的是平方值,因此每次 `m` 需要減半的平方根對應到 `m >>= 2`,因為奇數位所表達的數值無法開根號為整數且 $\sqrt2$ 不是整數。 參考執行結果: ``` 輸入63 = 16 + 47 = 36 + 27 = 49 + 13 ``` * 在 x = 63 時,`(total_bits - 1 - clz64(x))`是 5,`shift` = 4,因此`m`為 16,因為`m< 63`,因此會繼續測試是否 $6^2 = (4+2)^2 <63$,再測試$7^2 = (4+2+1)^2 <63$ 後,可得出63的平方根的整數值`y = 7`。 #### 延伸問題 1 > 解釋上述程式碼,並探討擴充為 $⌈(\sqrt𝑥)⌉$ (向上取整數) 實作,該如何調整並保持只用整數加減及位元操作 絕大部分的情況只要將最後得出的`y` 再 +1 即可,唯獨當只有x剛好為完全平方數(x=b)時,將最後得出的`y` 再 +1會不等於$⌈(\sqrt𝑥)⌉$,因此可以在結束 while 迴圈後再補上: ```c if(x != 0){ y++; } ``` 便可完成要求的實作。 ### 測驗 `3` 題意是給定一個陣列 `nums` 和一個目標值 `target`,求找到 `nums` 的 2 個元素相加會等於 `target` 的索引值。題目確保必為單一解,且回傳索引的順序沒差異。例如給定輸入 `nums = [2, 7, 11, 15]`,` target = 9`,相加變成 9 的元素僅有 2 及 7,因此回傳這二個元素的索引值 `[0, 1]` 我們可用 hash table 記錄缺少的那一個值 (即 `target - nums[i]`) 和對應的索引,以降低時間複雜度。 ```c struct hlist_head { struct hlist_node *first; }; struct hlist_node { struct hlist_node *next, **pprev; }; ``` ![image](https://hackmd.io/_uploads/SJRDcxH31x.png) `hash table` 主要由一個 `hlist_head` 的動態陣列組成,每個 `entry` 指向一個由 `struct hlist_node` 構成的**非環狀**雙向鏈結串列。哈希表的大小依據 `bits` 設定,長度為 **2 的冪次方**。 可以發現`struct hlist_head` 只有一個 `struct hlist_node *` 成員,而 `struct hlist_node` 則包含一個 `struct hlist_node *` 及 `struct hlist_node **`。這樣的設計是因為 `hash table` 的鏈結串列為**非環狀**,只需指向鏈表的一端。如果直接使用 `struct hlist_node` 作為`head`,則無用的 `pprev` 指標將會浪費大量記憶體。 而 `struct hlist_node` 中的 `pprev` 為何使用「指標的指標」? 這是為了讓刪除節點時能夠統一處理 `head` 節點與非 `head` 節點的刪除邏輯,因此讓`pprev` 指向前一個節點的指標`next`的位置,除此之外也能節省額外的 `prev` 指標,減少記憶體使用。 #### struct map_t `bits`:用來表示**雜湊表的大小**,通常 2^bits 會是實際的桶 (bucket) 數量。 #### hash_key `hash_key`就是存放於雜湊表內的節點,其中`node`用來維護雜湊鏈表。 * `key`:此鍵值會用來計算 hash 值,決定存放在哪個 bucket。 * `data`:存放的實際數據。 #### map_init 分配 `map_t` 結構體,並設定 `bits`。分配 `hlist_head` 陣列存放節點,再初始化 hlist_head 陣列,確保所有 bucket 開始時都是空的 (`first = NULL`)。 #### hash 將輸入值`value`乘上 `0x61C88647` 後右移 `32-bits` 個位元以取得雜湊值,其中,0x61C88647轉成有號整數為 2654435769,剛好是除以黃金比例,這個數字的特性是能夠最大程度地分散輸入值,避免常見的雜湊衝突。 #### find_key & map_get 實作了一個基於 `hlist` 的雜湊表查找與獲取函數,用來尋找儲存在 `map_t` 雜湊表中的 `key`,並返回對應的 `data`,`AAAA` 應該是 `map->bits`,代表雜湊表的位元數,決定bucket 的數量 (2^bits)。 `find_key` 使用 `for` 迴圈遍歷該桶中的所有節點,尋找 `key`,若找到相符的 `key`,則返回該 `hash_key` 節點,否則返回 `NULL`。 `map_get`透過呼叫`find_key`,找出對應的`hash_key`以取得對應的 `data`。 #### map_add 將新的資料加入哈希表,不管是否有相同索引值的元素已在哈希表中,新的資料均會加入到鏈結串列的開頭(如果原本這個 bucket 裡面有節點的話,它會是`struct hlist_node first`)。 `BBBB` 應該和 `AAAA`相同都是`map->bits`,`hash`取得它在雜湊表的位置;由於`n->next = first`,因此`first`的上一個為`n`,故 `CCCC` 為 `first->pprev`,讓 `first` 的 `pprev` 指向 `n->next`的位置,如同前面說的;同理,`DDDD`為`n->pprev`,直接指向`h->first`的記憶體位址。 ![image](https://hackmd.io/_uploads/HkKamfHnJg.png) #### map_deinit 程式碼的主要目的是釋放與 map 相關的記憶體資源,並清理雜湊表中所有節點。 它會遍歷所有bucket 根據 `map->bits` 來計算需要執行多少次,接著遍歷桶中的所有節點,並判斷該節點是否已經從哈希表中移除,若節點還在則透過將該節點的`next`和`pprev`設為`NULL`並且調整上一個的`next`及下一個節點的`pprev`來從哈希表中移除節點,`EEEE` 為 `n->pprev`,讓我們可以更新它以便正確地移除節點 `n`,最後當這個 bucket 的節點都刪除後,會釋放`hash_key` 節點的資料和記憶體。 #### twoSum 在一個整數陣列中找到兩個數字,它們的和等於目標值 `target`。如果找到這樣的數字,則返回這兩個數字的索引。 先初始化 `map`,並動態分配一個包含兩個整數的陣列 `ret`,用來存儲找到的兩個索引,接著遍歷數字陣列,對於每一個`nums[i]`都會計算對應的 `target - nums[i]`,然後查詢 `map` 中是否有這個差值的數字。如果找到這個差值,說明這兩個數字的和為 `target`。此時將 `i` 和對應的索引`*p`存入 `ret`,並設定 `returnSize = 2` 表示找到了答案,跳出迴圈。 如果`map`中沒有找到符合條件的數字,則將當前數字 `nums[i]` 及其索引 `i` 存入`map`。 在函式結束之前,釋放 `map` 的資源並返回 `ret`。如果找到了符合條件的數字,`ret` 中將包含兩個索引;若沒有找到則 `ret` 將是 `NULL`,`returnSize` 為 0。 #### 延伸問題:提供測試程式碼 ```c int main() { int nums[] = {9, 5, 7, 8}; // 測試輸入陣列 int target = 16; int returnSize; // 呼叫 twoSum 函式 int *result = twoSum(nums, sizeof(nums) / sizeof(nums[0]), target, &returnSize); // 驗證結果 if (returnSize == 2) { printf("Indices: [%d, %d]\n", result[0], result[1]); free(result); // 釋放記憶體 } else { printf("No valid pair found.\n"); } return 0; } ``` 輸出: ``` Indices: [2, 0] ``` 改成 `nums[] = {10, 5, 7, 8}` 輸出: ``` No valid pair found. ```