# 2025q1 Homework2 (quiz1+2) contributed by < `LinMarc1210` > {%hackmd NrmQUGbRQWemgwPfhzXj6g %} ## 開發環境 ```shell $ gcc --version gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0 $ lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Address sizes: 39 bits physical, 48 bits virtual Byte Order: Little Endian CPU(s): 16 On-line CPU(s) list: 0-15 Vendor ID: GenuineIntel Model name: 12th Gen Intel(R) Core(TM) i7-1260P CPU family: 6 Model: 154 Thread(s) per core: 2 Core(s) per socket: 12 Socket(s): 1 Stepping: 3 CPU(s) scaling MHz: 21% CPU max MHz: 4700.0000 CPU min MHz: 400.0000 BogoMIPS: 4992.00 Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge m ca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 s s ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nons top_tsc cpuid aperfmperf tsc_known_freq pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma c x16 xtpr pdcm sse4_1 sse4_2 x2apic movbe popcnt tsc_de adline_timer aes xsave avx f16c rdrand lahf_lm abm 3dn owprefetch cpuid_fault epb ssbd ibrs ibpb stibp ibrs_e nhanced tpr_shadow flexpriority ept vpid ept_ad fsgsba se tsc_adjust bmi1 avx2 smep bmi2 erms invpcid rdseed adx smap clflushopt clwb intel_pt sha_ni xsaveopt xsav ec xgetbv1 xsaves split_lock_detect user_shstk avx_vnn i dtherm ida arat pln pts hwp hwp_notify hwp_act_windo w hwp_epp hwp_pkg_req hfi vnmi umip pku ospke waitpkg gfni vaes vpclmulqdq rdpid movdiri movdir64b fsrm md_c lear serialize arch_lbr ibt flush_l1d arch_capabilitie s Virtualization features: Virtualization: VT-x Caches (sum of all): L1d: 448 KiB (12 instances) L1i: 640 KiB (12 instances) L2: 9 MiB (6 instances) L3: 18 MiB (1 instance) NUMA: NUMA node(s): 1 NUMA node0 CPU(s): 0-15 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 prct l Spectre v1: Mitigation; usercopy/swapgs barriers and __user pointe r sanitization Spectre v2: Mitigation; Enhanced / Automatic IBRS; IBPB conditiona l; RSB filling; PBRSB-eIBRS SW sequence; BHI BHI_DIS_S Srbds: Not affected Tsx async abort: Not affected ``` ## 第一週 - 測驗 1 > Commit [84f412b](https://github.com/LinMarc1210/linux-hw2/commit/84f412bf3607032909a96148a501512257a5e69f) > Commit [2a6ac64](https://github.com/LinMarc1210/linux-hw2/commit/2a6ac64bce80b97834a0ec6645266f0f71c40535) 觀察 `list_insert_before`,此函式的目的在於透過 **指標的指標** 走訪所有節點並插入。參考 [你所不知道的 C 語言: linked list 和非連續記憶體](https://hackmd.io/@sysprog/c-linked-list#circular-linked-list),若只用 `list_item_t` 的指標,會遇到特例,我嘗試用指標寫法做和 `list_insert_before` 一樣的事情,但是當我們的 `before == l->head` 的情況,就必須另外用 if 分支處理這個情況,並且將插入的 `item` 改為新的 `head`,後續才可照正常的迴圈走訪,讓 `pos` 指向 `before` 的前一個節點,並且更改 `item` 和 `pos` 的 `next` ,才能插入完成。 ```c void list_insert_before_naive(list_t *l, list_item_t *before, list_item_t *item) { list_item_t *pos = l->head; if (before == l->head) { item->next = before; l->head = item; return; } while (pos && pos->next != before) { pos = pos->next; } item->next = before; pos->next = item; } ``` 但是指標的指標不會有特例,並且程式碼更精簡,如考試的範例: `*p = item` 可以直接更改原本在 `before` 的前一個節點的 `next` 指向至新節點 `item` ,因為 `p` 是指標的指標,我們更改 `*p` 實際上直接更改了 `next` 指標的指向。這樣的方式可以避免我們直接 dereference NULL pointer 而產生錯誤。 ```c void list_insert_before(list_t *l, list_item_t *before, list_item_t *item){ list_item_t **p; for (p = &(l)->head; *p != before; p = &(*p)->next) ; *p = item; item->next = before; } ``` ```graphviz digraph linked_list { node [shape=record]; rankdir=LR; head [label="head"]; item1 [label="<val>1 | <next>"]; item2 [label="<val>2 | <next>"]; item3 [label="<val>3 | <next>"]; newItem [label="<val>99 | <next>", color=blue, style=filled, fillcolor=lightblue]; // Linked list connections head -> item1; item1:next -> item2; item2:next -> item3; item3:next -> null [label="NULL"]; null [shape=point]; // Virtual node to simulate pointing to the arrow arrow_mid [label="", shape=point, width=0.00]; item1:next -> arrow_mid [style=invis]; arrow_mid -> item2 [label="*p", style=invis]; // Pointer p p [label="p (pointer to pointer)", shape=plaintext]; p -> arrow_mid [style=dashed, color=green, label="point to *p"]; // before pointer before [label="before", shape=plaintext]; before -> item2 [style=dashed, color=blue]; // Overall explanation label="Linked List: insert '99' before '2'"; labelloc="t"; } ``` ```graphviz digraph linked_list { node [shape=record]; rankdir=LR head [label="head"]; item1 [label="<val>1 | <next>"]; item2 [label="<val>2 | <next>"]; item3 [label="<val>3 | <next>"]; newItem [label="<val>99 | <next>", color=blue, style=filled, fillcolor=lightblue]; // Linked list connections head -> item1; item2:next -> item3; item3:next -> null [label="NULL"]; null [shape=point]; // Virtual node to simulate pointing to the arrow arrow_mid [label="", shape=point, width=0.00]; item1:next -> arrow_mid [style=invis]; arrow_mid -> newItem [label="*p", style=invis]; p [label="p (pointer to pointer)", shape=plaintext]; p -> arrow_mid [style=dashed, color=green, label="point to *p"]; // Insertion illustration item1:next -> newItem [style=dashed, color=red]; newItem:next -> item2 [style=dashed, color=red]; // before pointer before [label="before", shape=plaintext]; before -> item2 [style=dashed, color=blue]; // Overall explanation label="Linked List: insert '99' before '2'"; labelloc="t"; } ``` 在測試程式方面則是定義兩個巨集:`my_assert` 用來檢查條件,在這裡都是檢查 `list_size` ,而 `my_run_test` 是用來測試指定的函式,透過傳遞 function pointer 測試此 function。要是 `message` 不為 `NULL`,代表 function 執行有問題,並回傳錯誤訊息。 ### 撰寫合併排序操作 ## 第一週 - 測驗 2 > Commit [6c6a52f](https://github.com/LinMarc1210/linux-hw2/commit/6c6a52fb56aae995a2695e9bb9efb20f97c61347) ### 解釋並補完測試程式碼 首先需要先了解 `block_t` 的結構,`block_t` 是記憶體管理樹的節點結構,而透過 `block_t` 構成的樹狀結構是用來維護 free blocks 的,因此可以說 `block_t` 就是一小塊還未被使用的記憶體。串成的樹結構如下,其中 `root` 是指標的指標, `*root` 是一個指向根節點的指標,而 `root` 是指向這個指標的指標。 ```graphviz digraph BlockTree { node [shape=record, style=plaintext]; // 指標 root (block_t **) root_ptr_ptr [label="root", shape=plaintext]; root_ptr [label="*root", shape=plaintext]; // 範例樹節點 node1 [label="<l> | <data> size=16 | <r>"]; node2 [label="<l> | <data> size=8 | <r>"]; node3 [label="<l> | <data> size=24 | <r>"]; node4 [label="<l> | <data> size=4 | <r>"]; node5 [label="<l> | <data> size=12 | <r>"]; node6 [label="<l> | <data> size=20 | <r>"]; node7 [label="<l> | <data> size=28 | <r>"]; // 節點之間的連結(l 表示 left child, r 表示 right child) node1:l -> node2:data; node1:r -> node3:data; node2:l -> node4:data; node2:r -> node5:data; node3:l -> node6:data; node3:r -> node7:data; root_ptr_ptr->root_ptr [style=dashed]; root_ptr->node1 [style=dashed]; } ``` 此結構是為了實作動態記憶體分配器,也就是 heap,在這邊使用二元搜尋樹結構,因為這樣可以確保每個元素在時間複雜度 $O(\log n)$ 之內一定可以被找到。而我們需要快速找到適合被分配的 free block,因此需要 `find_free_tree` 和 `find_predecessor_free_tree` 兩個函式協助實作,需要分配記憶體的時候就要用 `remove_free_tree` 從樹中移除一個 free block 來分配。 接下來是 `remove` 的邏輯,首先要用 `find_free_tree` 找到目標節點 `target` 並回傳一個指標的指標。在這裡使用指標的指標是因為我們可以直接修改 `*node_ptr` 來改變樹狀結構,但如果 `node_ptr` 只是一個指向 `block_t` 的指標,則我們只是在改變這個指標本身,而未改變樹狀結構中節點的 `*l`, `*r` 指向。 ```diff + *node_ptr = child // for (block_t **)node_ptr - node_ptr = child // for (block *)node_ptr ``` 刪除可以分成 `target` 有 2, 1, 或 0 個子節點的情況來處理,其中 1 個或 0 個子節點的情況都較簡單,只需要直接刪除並且把 `*node_ptr` 變成其子節點就好,而在有 2 個節點的情況就需要決定用 predecessor 還是 successor 來補位,以維持 BST 的特性。小考的實作是以 predecessor 進行補位,即**左子樹的最右節點**,因此用 if 條件式確認 predecessor 是正好位於 `target` 的左子節點,還是在左子樹更深的地方。 找 predecessor 的程式碼: ```c block_t **pred_ptr = &(*node_ptr)->l; // 從左子節點開始 while ((*pred_ptr)->r) pred_ptr = &(*pred_ptr)->r; ``` 取得 `pred_ptr` 後,會跟 `find_predecessor_free_tree` 比較,確保我們所找到的 predecessor 是正確的。接下來則判斷 predecessor 是否為 `target` 的左子節點,若是,則左子節點直接移到 target 的位置,並且原本 `target` 的右子節點變成了剛補上來的 `*pred_ptr` 的右子節點。 ```c if (*pred_ptr == (*node_ptr)->l) { block_t *old_right = (*node_ptr)->r; *node_ptr = *pred_ptr; /* Replace target with its left child. */ (*node_ptr)->r = old_right; /* Attach the original right subtree. */ // ...assert } ``` *pred_ptr` 的左、右子節點。要特別注意的是移除 predecessor 時,會遞迴呼叫 `remove_free_tree` ,這是為了避免 predecessor 也有自己的子節點,所以遞迴呼叫會變成子節點數量為 0 或 1 的 case(不可能還有 2 個子節點,否則不會是 rightmost )。 ```c else { /* The predecessor is deeper in the left subtree. */ block_t *old_left = (*node_ptr)->l; block_t *old_right = (*node_ptr)->r; block_t *pred_node = *pred_ptr; /* Remove the predecessor from its original location. */ remove_free_tree(&old_left, *pred_ptr); /* Replace the target node with the predecessor. */ *node_ptr = pred_node; (*node_ptr)->l = old_left; (*node_ptr)->r = old_right; ``` - `remove_free_tree` 的示意圖: ```graphviz digraph RemoveNode { node [shape=record, style=plaintext]; // Initial Tree State subgraph cluster_0 { label="Step 1: Initial Tree"; node1 [label="<l> | <data> size=16 | <r>"]; node2 [label="<l> | <data> size=8 | <r>"]; node3 [label="<l> | <data> size=24 | <r>"]; node4 [label="<l> | <data> size=4 | <r>"]; node5 [label="<l> | <data> size=12 | <r>"]; node6 [label="<l> | <data> size=20 | <r>"]; node7 [label="<l> | <data> size=28 | <r>"]; node1:l -> node2:data; node1:r -> node3:data; node2:l -> node4:data; node2:r -> node5:data; node3:l -> node6:data; node3:r -> node7:data; } // Predecessor Removal subgraph cluster_1 { label="Step 2: Removing Predecessor (16)"; // Main nodes node1b [label="<l> | | <r>"]; node2b [label="<l> | <data> size=8 | <r>"]; node3b [label="<l> | <data> size=24 | <r>"]; node4b [label="<l> | <data> size=4 | <r>"]; node5b [label="<l> | <data> size=12 | <r>"]; node6b [label="<l> | <data> size=20 | <r>"]; node7b [label="<l> | <data> size=28 | <r>"]; // Predecessor pointers pred_ptr_ptr [label="pred_ptr", shape=plaintext, color=red]; pred_ptr [label="*pred_ptr", shape=plaintext, color=red]; // Define tree structure node1b:l -> node2b:data; node1b:r -> node3b:data; node2b:l -> node4b:data; node2b:r -> node5b:data; node3b:l -> node6b:data; node3b:r -> node7b:data; // Highlight the predecessor pointer pred_ptr_ptr -> pred_ptr [style=dashed, color=red]; pred_ptr -> node5b:data [style=dashed, color=red]; // Ensure left and right nodes are at the same rank for proper positioning {rank=same; node4b; node5b;} {rank=same; node6b; node7b;} } // Tree after Replacement subgraph cluster_2 { label="Step 3: Predecessor (12) Replaces Target (16)"; node1c [label="<l> | <data> size=12 | <r>"]; node2c [label="<l> | <data> size=8 | <r>"]; node3c [label="<l> | <data> size=24 | <r>"]; node4c [label="<l> | <data> size=4 | <r>"]; node6c [label="<l> | <data> size=20 | <r>"]; node7c [label="<l> | <data> size=28 | <r>"]; pred_ptr_ptr2 [label="pred_ptr", shape=plaintext, color=red]; pred_ptr2 [label="*pred_ptr", shape=plaintext, color=red]; pred_ptr_ptr2 -> pred_ptr2 [style=dashed, color=red]; pred_ptr2 -> node1c:data [style=dashed, color=red]; node1c:l -> node2c:data; node1c:r -> node3c:data; node2c:l -> node4c:data; node3c:l -> node6c:data; node3c:r -> node7c:data; } } ``` 接下來則是補完程式碼,讓其可以運作,我補完了 `find_free_tree` 和 `find_predecessor_free_tree` ,其中我使用遞迴 DFS 的方式尋找目標節點,讓程式碼保持簡潔。在尋找 predecessor 的時候則要先判斷 target 有沒有左子節點,若有則直接找左子樹的 rightmost node,若無同時紀錄目前節點和前一個節點,直到 pos 走到 target 之後,pred 就會紀錄到 target 的 predecessor。 - 找 target ```c block_t **left_result = find_free_tree(&(*root)->l, target); if (left_result) return left_result; return find_free_tree(&(*root)->r, target); ``` - 找 target 的 predecessor(沒有 leftchild 的情況) ```c while (pos) { if (pos->size < node->size) { pred = pos; pos = pos->r; } else { pos = pos->l; } } ``` 完整程式碼包含測試程式,見 Commit [6c6a52f](https://github.com/LinMarc1210/linux-hw2/commit/6c6a52fb56aae995a2695e9bb9efb20f97c61347) ## 第一週 - 測驗 3 ### 解釋程式碼運作原理 小考示範使用 `begin` 和 `end` 兩個指標陣列來模擬堆疊,並透過索引值進行遞迴排序。其中 `L` 和 `R` 會取得該子陣列的頭和尾,並且判斷 `L` 是否等於 `R` ,其用意在於判斷子陣列是否僅剩下一個元素,與遞迴終止條件相同。當子陣列只剩一個元素的時候則將元素加到 `result` ,並且每次都加到串列的最前端,因為我們是從右邊開始進行排序的,這樣做才能保持元素的排序是正確的。 而在需要我們填空的那段程式碼與前面用來講解的,最大的不同處在於底下這段不用另外開一個 `end` 陣列去紀錄子陣列的尾,而是從結構上直接斷開鏈結,因此我們只須紀錄頭,並且每次使用 `list_tail` 取得其最後一個元素即可。過程保持單向鏈結串列的結構走訪,並且在最後呼叫 `rebuild_list_link` 重建雙向環狀鏈結串列。 而在迴圈內我們用指標 `p` 走訪節點並逐一和 `pivot` 比較 `value` ,比 `pivot` 小則用 `left` 去加進左子串列,比 `pivot` 大則用 `right` 去加進右子串列,如下: - 原本的串列 ```graphviz digraph graphname{ node[shape=box] rankdir = LR A[label=4] B[label=1] C[label=3] D[label=5] E[label=2] F[label=7] G[label=8] NULL[label=NULL,shape=plaintext] A->B->C->D->E->F->G->NULL { rank="same"; L[label="L",shape=plaintext,fontcolor=red] pivot[label="pivot",shape=plaintext,fontcolor=blue] pivot->L[color=blue] L->A[color=red]; } { rank="same"; R[label="R",shape=plaintext,fontcolor=red] R->G[color=red]; } { rank="same"; p[label="p",shape=plaintext,fontcolor=green] p->B[color=green]; } } ``` - 加入子串列的過程: ```graphviz digraph graphname{ node[shape=box] A[label=1] NULL[label=NULL,shape=plaintext] NULL { rank="same"; left[label="left",shape=plaintext,fontcolor=green] left->NULL[color=green]; } { rank="same"; n[label="n",shape=plaintext,fontcolor=green] n->A[color=green]; } } ``` ```graphviz digraph graphname{ node[shape=box] rankdir = LR B[label=1] NULL[label=NULL,shape=plaintext] B->NULL { rank="same"; p[label="left",shape=plaintext,fontcolor=green] p->B[color=green]; } } ``` - 完成 partition 後: ```graphviz digraph{ ## rankdir=TD rankdir=LR node [shape=box,color=black] //tp [shape=none,label=pivot,fontcolor=red] A [shape=none,label=left] B [shape=none,label=right] C [shape=none,label=pivot,fontcolor=blue] C->4 B->8->7->5 A->2->3->1 } ``` 這樣就完成了將走訪到的節點 1 加進左子串列,並且在 p 走完所有節點後,left 和 right 會分別停在左子串列和右子串列的頭部,因此就可以拿來紀錄 `begin[i]`, `begin[i+2]`,之後只要用 `list_tail` 就可以得到子串列的尾端。 為了模擬遞迴呼叫,每次根據 pivot 完成 partition 之後,會將索引值 `i += 2` ,每次都從右半部開始進行排序,直到只剩一個元素則加進 `result` ,這裡必須要進行 `i--` ,讓索引值可以往左半部,並對左半部的串列進行排序。 #### swap 為主體的方法 根據 [quiz1 - 測驗 3](https://hackmd.io/@sysprog/linux2025-quiz1#%E6%B8%AC%E9%A9%97-3) 對於非遞迴的快速排序解釋如下,但我覺得這邊解釋的不清楚: > 假定下方是起始的陣列內容。採取 head 作為 pivot,並且 L 會從左邊開始掃,紀錄有多少值小於 pivot,R 從右邊開始,紀錄有多少值大於 pivot。(R 一開始會定義為陣列的 length,以減法的方式紀錄) 但我參考 [Optimized QuickSort — C Implementation (Non-Recursive)](https://alienryderflex.com/quicksort/) 的解釋,L 和 R 應該是從左邊界和右邊界開始走訪,只要 L 漸增的過程中遇到有節點比 pivot 大,就把目前的元素複製到 R 的位置,並且換 R 漸減,若遇到有節點比 pivot 小,就複製到 L 的位置,並且換成 L 漸減,直到 `L >= R` 停止,然後把 `pivot` 放到 L 的位置,這樣才能解釋為什麼要 swap,因為比較小的元素被換到 pivot 左邊,比較大的元素被換到 pivot 右邊,並且繼續下一輪的排序。 #### 測試程式解釋 ```c shuffle(test_arr, count); while (count--) list_construct(list, test_arr[count]); quick_sort(list); assert(list_is_ordered(list)); ``` 這題在 `main()` 裡面先將 `test_arr` 所儲存的值洗牌,並且用來建構串列,然後再呼叫 `quick_sort` 並且用 `list_is_ordered()` 檢查元素是否排序完成。 ### 研讀〈A comparative study of linked list sorting algorithms〉 ## 第二週 - 測驗 1 ### 解釋程式碼與探討 stable sort 先來補充一下一般遞迴版本 Quick Sort 的 Pseudo Code : ``` QUICKSORT(A, p, r) { if p < r q = PARTITION(A, p, r) QUICKSORT(A, p, q-1) QUICKSORT(A, q+1, r) } ``` 流程是先用 `PARTITION` 選定一個 pivot,並且將陣列中分成比 pivot 小的和比 pivot 大的兩部份,最後分別遞迴呼叫左半部和右半部,再次進行一樣的動作,直到子陣列只剩一個元素。注意這邊遞迴呼叫 `QUICKSORT` 的時候,是不應該將 pivot 再放到任意子陣列的,否則到後面切分永遠都會回傳一樣的子陣列,因為子陣列所有元素都比 pivot 小(或大),造成無限遞迴而導致記憶體 stack 爆掉。 小考使用 Linux 核心風格 List API 的簡化標頭檔 `"list.h"` 進行開發,其中最特別的點是,一般的遞迴版本並不會保持 stable sort ,而小考的版本則會,根據《 Introduction to Algorithm 》,書內的快速排序在做 partition 是透過交換來決定 pivot 的位置,但交換時並不會特別考量相同元素的相對順序,原版的 `PARTITION` 程式碼如下: ``` PARTITION(A, p, r) { x = A[r]; i = p - 1; for j = p to r - 1 do if A[j] <= x then i = i + 1 exchange A[i] , A[j] exchange A[i+1], A[r] return i + 1 } ``` 其無法保持 stable sort 的例子如下,在 pivot 放到最後位置的時候並不會考慮 5(red) 和 5(blue) 的相對順序,導致在交換之後相對順序亂掉。 ```graphviz digraph LomutoPartition { rankdir=TB; node [shape=box, style=filled, fontname="Arial"]; subgraph cluster_start { label = "Start"; style = dashed; start_3 [label="3 (pivot)", fillcolor="lightgrey"]; start_5b [label="5", fillcolor="lightcoral"]; start_1 [label="1", fillcolor="white"]; start_5a [label="5", fillcolor="lightblue"]; } subgraph cluster_step1 { label = "Step 1: 5 > 3 → No Swap"; style = dashed; step1_3 [label="3 (pivot)", fillcolor="lightgrey"]; step1_5b [label="5", fillcolor="lightcoral"]; step1_1 [label="1", fillcolor="white"]; step1_5a [label="5", fillcolor="lightblue"]; } subgraph cluster_step2 { label = "Step 2: 1 <= 3 → Swap 5 and 1"; style = dashed; step2_3 [label="3 (pivot)", fillcolor="lightgrey"]; step2_5b [label="5", fillcolor="lightcoral"]; step2_5a [label="5", fillcolor="lightblue"]; step2_1 [label="1", fillcolor="white"]; } subgraph cluster_step3 { label = "Step 3: 5 > 3 → No Swap"; style = dashed; step3_3 [label="3 (pivot)", fillcolor="lightgrey"]; step3_5b [label="5", fillcolor="lightcoral"]; step3_5a [label="5", fillcolor="lightblue"]; step3_1 [label="1", fillcolor="white"]; } subgraph cluster_step4 { label = "Step 4: Swap Pivot 3 with 5a"; style = dashed; step4_5a [label="5", fillcolor="lightblue"]; step4_5b [label="5", fillcolor="lightcoral"]; step4_3 [label="3 (pivot)", fillcolor="lightgrey"]; step4_1 [label="1", fillcolor="white"]; } start_3 -> step1_3 [style=invis]; start_5b -> step1_5b [style=invis]; start_1 -> step1_1 [style=invis]; start_5a -> step1_5a [style=invis]; step1_3 -> step2_3 [style=invis]; step1_5b -> step2_5b [style=invis]; step1_1 -> step2_5a [style=invis]; step1_5a -> step2_1 [style=invis]; step2_3 -> step3_3 [style=invis]; step2_5b -> step3_5b [style=invis]; step2_5a -> step3_5a [style=invis]; step2_1 -> step3_1 [style=invis]; step3_3 -> step4_5a [style=invis]; step3_5b -> step4_5b [style=invis]; step3_5a -> step4_3 [style=invis]; step3_1 -> step4_1 [style=invis]; } ``` 接下來分析小考 `list_quicksort` 的實作,首先為了將串列分割成比 pivot 小和比 pivot 大的子串列,會使用兩個 dummy node,分別是 `list_less` 和 `list_greater` 作為子串列的頭。這裡不用 `list_head *` 而是用 `list_head` 是因為我們需要真實的記憶體區段,才有辦法使用 `head->next` ,否則會在使用 `->` 的時候發生 dereference a NULL pointer 的錯誤。 ```c struct list_head list_less, list_greater; ... INIT_LIST_HEAD(&list_less); INIT_LIST_HEAD(&list_greater); ``` 回想最基本版的快速排序 pseudo code,我們得知 pivot 是不應該被算進去字串列的,因此在進行分割串列之前,得先將 pivot 從串列中移除,所以使用 `list_first_entry` 直接取得該鏈結串列的第一個元素。 ```c pivot = list_first_entry(head, struct listitem, list); list_del(&pivot->list); ``` 再來,我們希望將小的元素放至 `list_less` 的串列,而大的放去 `list_greater` 的串列,因此使用 `list_move_tail`。在這裡就是保持穩定排序的關鍵,首先是判斷式,當元素值與 pivot 相等時,`cmpint` 會回傳 0,此時程式會進到 else 分支,而 else 分支會將元素加進 `list_greater` 子串列當中,而因為我們的 **pivot** 選定的是串列的**第一個元素**,這代表即使串列當中有和 pivot 相同的元素,也應該在排序後保持在 pivot 的右邊(即 `list_greater` 子陣列)才可以維持 pivot 本來就比較前面的相對順序。又因 `list_for_each_entry_safe` 這個巨集是從左走訪至右,因此有相同的元素時,先被走到的應該仍然要在被加到同個串列時保持在左邊,這也使得我們要使用 `list_move_tail` ,將元素移至串列的尾端,如果用的是 `list_move` 則會讓原本的相對順序反過來了。 ```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); } ``` 最後執行遞迴呼叫,將左半和右半都排序完成。`list_add` 和 `list_splice` 的差別只在於前者是只能從 `head` 之後插入一個節點(新的頭元素),而後者是插入一整段子串列至 `head` 後面。`list_splice_tail` 則是插入一整段子串列至串列尾端。這樣子執行,正好可以在 partition 排序完成後,依照 less->pivot->greater 的升序組合回原本的串列。 ```c list_quicksort(&list_less); list_quicksort(&list_greater); list_add(&pivot->list, head); list_splice(&list_less, head); list_splice_tail(&list_greater, head); ``` ### 改進 random_shuffle_array ## 第二週 - 測驗 2 ### 解釋程式碼 針對 leading zero 的個數要怎麼算,這邊使用遞迴,每次將位元數切半,只要算一半位元的 leading zero 即可,並且當 upper 為 0,就代表 upper 全部都是 leading zero,只要計算 lower 還有幾個就好 ; 反之若 upper 不為 0 ,則 lower 都不需要再計算,因為 upper 一定還有位元為 1,使得 lower 的位元不可能是 leading zero。 根據這樣的邏輯,我們可以實作 `clz2` ,並且針對 32 位元的整數操作。小考題目有說到僅剩 2 位元時,就需要檢查 leading zero 了,因此我們可以判斷從 32 位元的數字要切半 4 次才有辦法將 x 切到僅剩 2 位元,因此我們除了傳入要計算 leading zero 的數字 x 以外,還要傳入現在是第幾刀,以 `c = 0, 1, 2, 3` 表示切 4 次,這樣的用意是為了要知道我們要使用 x 的幾個最低位元。 程式使用 `mask[c]` 來取得 lower 的位元,因此我們要決定第幾刀的時候,要將多少位元清空和留下哪些位元。對於 `uint32_t lower` 來說,最高 16 位元是不需要的,我們只關注最低 16 位元,所以用一個 16 位元的 mask `0xFFFF` 來保留最低 16 位。 4 次切半 lower 分別需要保留 16, 8, 4, 2 個最低位元,因此 `0xFFFF` 應該要往右分別 shift 0, 8, 12, 14。 而當 `c == 3` 也就是切到剩 2 個位元時,必須要開始計算 leading zero 了。 2 個位元可以表示成四種數值,換成十進制會正好是 0, 1, 2, 3 也就是 c 的數值。我們用表格說明遞迴終止的情況,就可以得知 `magic[upper]` 其實就是表示 upper 此時的 leading zero 有幾個,lower 也一樣。 | upper (lower) 二進位 | upper (lower) 十進位 | leading zero | | -------- | -------- | -------- | | `00` | 0 | **2** | | `01` | 1 | **1** | | `10` | 2 | **0** | | `11` | 3 | **0** | 因此程式碼在遞迴時都是判斷 upper 是否為 0 ,若 upper 不為 0 則遞迴呼叫 `clz2` 計算 upper ,為 0 則將 upper 現在的位元數 `16 >> c` 加上遞迴呼叫計算 `lower` 得到的結果,並且每次遞迴呼叫 c 要加 1,表示要進行新一次的切半了。 ```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 + 1); } ``` 而 64 位元的 `clz64` 只需要再多增一層遞迴就可以達到一樣的效果:切半後呼叫 `clz32` 。 ```c static inline int clz64(uint64_t x) { /* If the high 32 bits are nonzero, count within them. * Otherwise, count in the low 32 bits and add 32. */ return (x >> 32) ? clz32((uint32_t) (x >> 32)) : clz32((uint32_t) x) + 32; } ``` 在求整數平方根時, ```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)) & MMMM; m = 1ULL << shift; while (m) { uint64_t b = y + m; y >>= 1; if (x >= b) { x -= b; y += m; } m >>= 2; } return y; } ``` ## 第二週 - 測驗 3 ### 解釋程式碼 透過 [Linux 核心的 hash table 實作](https://hackmd.io/@sysprog/linux-hashtable) 得知 Linux 核心中使用 `hlist` 的結構來處理 hash collision,並且用 `**pprev` 取代 `*prev` ,改成存「指向自己的指標」,結構如下: - 使用 `prev` 時:指向前個節點 ```graphviz digraph G { rankdir = LR; splines = false; node[shape = "record"] list_head[label = "<m>list_head | <n>first"] node_1[label = "<m>dll_node_1 | {<p>prev | <n>next}", group = list]; node_2[label = "<m>dll_node_2 | {<p>prev | <n>next}", group = list]; node_3[label = "<m>dll_node_3 | {<p>prev | <n>next}", group = list]; NULL_1, NULL_2[shape = plaintext, label = "NULL", group = list] {rank = "min" list_head NULL_1} list_head -> node_1:m; node_1:p -> NULL_1 node_1:n -> node_2:m; node_2:p -> node_1:m; node_2:n -> node_3:m; node_3:p -> node_2:m; node_3:n -> NULL_2; } ``` - 使用 `pprev` 時:指向前一個節點的成員指標 `next` ```graphviz digraph G { rankdir = LR; splines = false; node[shape = "record"] list_head[label = "<m>list_head | <n>first"] node_1[label = "<m>hlist_node_1 | {<p>pprev | <n>next}", group = list]; node_2[label = "<m>hlist_node_2 | {<p>pprev | <n>next}", group = list]; node_3[label = "<m>hlist_node_3 | {<p>pprev | <n>next}", group = list]; NULL[shape = plaintext, label = "NULL", group = list] {rank = "min" list_head} list_head -> node_1 -> node_2 -> node_3[ weight = 10, style = invis ] list_head:n -> node_1:m; node_1:p -> list_head:n; node_1:n -> node_2:m; node_2:p -> node_1:n; node_2:n -> node_3:m; node_3:p -> node_2:n; node_3:n -> NULL; } ``` 也就是 `**pprev` 其實會指到自己的節點,因此在做刪除時,我們只需要傳入要刪除的節點,不需要再傳入 `head`,我們也可以在教材中看到對於 `hlist` 的小結。 > 至此,我們理解到,`hlist` (即 hash list) 是針對雜湊表特製的鏈結串列,儘管尋常的雙向鏈結串列亦可實作雜湊表,但由於後者開頭和結尾需要各用 2 個指標,當 bucket 很大時,會增加記憶體開銷。 回到小考題目,我們要用 hash table (以下簡稱 `HT` )讓 [Two Sum](https://leetcode.com/problems/two-sum/description/) 的時間複雜度降為 $O(n)$,因此可用 `HT` 紀錄缺少的值:`target - nums[i]` ,並且讓 `HT[target - nums[i]] = i`,因此若現在的 `nums[i]` 是一個與前面某個值相加可以成為 `target` 的值,則會回傳 `[i, HT[target - i]]`。 接下來觀察結構 `map_t` ,裡面內嵌一個 `hlist_head` 的結構,代表整個 `hlist` 的起始端,`bits` 則是 hash table 的大小,用來實作在教材中提及雜湊函數的 Multiplication Method,本來的雜湊函數 $h(K) = \lfloor m \times (KA - \lfloor KA \rfloor) \rfloor$ 可以由 $h(K) = K \times 2^w \times A >> (w - p)$ 實作,適用於我們不知道 key 的範圍與分佈情形。`hash_key` 則是內嵌 `hlist_node`,代表這是元素經過 hash function 計算後分配的節點。原本的 key 以 `key` 存,原本的值則用 `value` 存,並且透過內嵌的 `hlist_node` 處理碰撞時需要用的鏈結串列。 用來檢索 key 的 `find_key` 如下,我們需要傳入 `map_t` ,也就是表示整個 hash table 的結構,還有我們要尋找的 `key`。其中我們使用 `hash(key, map->bits)` 呼叫雜湊函數計算對應的 key 值,並且在 `map->ht` 中尋找對應 bucket 的 `hlist_head` ,最後還需要使用 `&` 取址才能丟給 `head`。後續再從對應的 `hlist` 依照 `key` 找正確的節點,並回傳整個節點結構 `hash_key`,最後再讓 `map_get` 呼叫 `find_key` 並取出 `data` 來完成 key-value 的檢索。 ```c static struct hash_key *find_key(map_t *map, int key) { struct hlist_head *head = &(map->ht)[hash(key, map->bits)]; for (struct hlist_node *p = head->first; p; p = p->next) { struct hash_key *kn = container_of(p, struct hash_key, node); if (kn->key == key) return kn; } return NULL; } ``` 新增一個節點則需要考慮是否已存在一樣的 key,若不存在才會繼續進行新增,並且透過 `hash` 決定要安插在哪個 bucket。插入時如果該 bucket 已經有東西了,則需要接在該鏈結串列的最前面。 ``` void map_add(map_t *map, int key, void *data) { struct hash_key *kn = find_key(map, key); ... struct hlist_head *h = &map->ht[hash(key, map->bits)]; struct hlist_node *n = &kn->node, *first = h->first; n->next = first; if (first) first->pprev = &n->next; h->first = n; n->pprev = &h->first; } ``` `map_deinit` 釋放記憶體,依序將 hash table 的每個 bucket 裡面的 `hlist` 依序釋放,並且透過 `if(!n->pprev)` 檢查 n 是否有插入至 hash table,但我目前還未釐清為什麼是用 `!n->pprev` 作為條件來判斷是否插入節點,還有什麼情況會發生這樣的問題。 ```c void map_deinit(map_t *map) { ... for (int i = 0; i < MAP_HASH_SIZE(map->bits); i++) { ... for (struct hlist_node *p = head->first; p;) { ... if (!n->pprev) /* unhashed */ goto bail; struct hlist_node *next = n->next, **pprev = n->pprev; *pprev = next; if (next) next->pprev = pprev; n->next = NULL, n->pprev = NULL; bail: free(kn->data); free(kn); } } free(map); } ``` 最後,就可以只用一層迴圈,每次將走訪到的元素放進 hash table,同時確認是否有對應的 key,以及其互補值在 `nums` 的 index 為多少。