# 2023q1 Homework1 (lab0) contributed by < [`Shiritai`](https://github.com/Shiritai) > ### Reviewed by `Urbaner` * 考慮重新整理,理出一條主要的開發線,善用開關標籤以免迷失在過長的筆記。 :::info 以一個主要個開發線或者主題來敘述開發是很好的想法,未來我將嘗試這樣做。 不過 Hackmd 本身提供快速移動到某標題的功能 (table of content),以快速閱覽任意長度且設置正常標題的筆記,故似乎沒有必要使用開關標籤。 我認為開關標籤的使用是省略可選的內容,比如本筆記內隱藏針對 VSCode 中使用註解格式的議題,這是因為此部分僅與使用 VSCode 的使用者有關,且並不直接與開發的邏輯有關。 > [name=Eroiko] ::: * 有提到不需貼出整段程式碼,僅需數行即可。考慮精簡頁面,突顯重點。 :::info ok > [name=Eroiko] ::: ## 開發環境 ```bash $ gcc --version gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.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): 12 On-line CPU(s) list: 0-11 Vendor ID: GenuineIntel Model name: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz CPU family: 6 Model: 158 Thread(s) per core: 2 Core(s) per socket: 6 Socket(s): 1 Stepping: 10 CPU max MHz: 4100.0000 CPU min MHz: 800.0000 BogoMIPS: 4399.99 ``` ## queue 開發過程 ### q_new $O(1)$ 建立新鏈結串列節點,僅在成功配置記憶體時初始化回傳值 `ret` 為首位節點,並回傳。若配置失敗,`ret == NULL`,正好符合函式要求。 ```c= struct list_head *q_new() { struct list_head *ret = malloc(sizeof(struct list_head)); if (ret) // if not null INIT_LIST_HEAD(ret); return ret; } ``` ### q_free $O(n)$ :::danger 留意[資訊科技詞彙翻譯](https://hackmd.io/@sysprog/it-vocabulary),變更下方用語。 :notes: jserv > ok,本以為真的走訪全部可稱遍歷。 今後會多加注意名詞的使用。 [name=Eroiko] ::: 透過 `list_for_each_entry_safe` 巨集走訪所有元素節點,分別先釋放 `value` 成員再釋放元素節點本身。使用 `_safe` 版巨集走訪是因為一路上的節點會被釋放。最後不忘要釋放首位元素節點。 ```c= void q_free(struct list_head *l) { if (!l) // guard invalid condition return; element_t *cur, *nxt; // iterators // this macro works with DS that contains // a member of type "list_head" list_for_each_entry_safe (cur, nxt, l, list) { // cur->value must have value except queue head // which won't be iterated q_release_element(cur); } free(l); // free head } ``` ### q_element_alloc 輔助函式 $O(1 + \tt{memcpy(sizeof(element\_t)) + strdup(s)})$ :::info Allocate 翻為配置。 > [name=Eroiko] ::: 由於 `q_insert_head` 和 `q_insert_tail` 的邏輯相似,皆需「配置新元素節點並複製給定字串值」,故將此邏輯獨立為本函式。我定義回傳值為指標,非 `NULL` 表成功,否則表 `element_t` 或 `element_t::value` 配置失敗。 ```c= /** * Allocate and construct a new element * @param s value to save as element * @return non-null if success, null if any bad thing happens */ static element_t *q_element_alloc(const char *s) { // allocate a new element node element_t *n_ele = malloc(sizeof(element_t)); if (!n_ele) // guard invalid condition return NULL; // copy value of s to element member n_ele->value = strdup(s); if (!n_ele->value) { // allocation failed free(n_ele); return NULL; } return n_ele; } ``` 注意以下四點: * 若 `n_ele` 配置成功但 `n_ele->value` 失敗,則回傳 `NULL` 前不忘歸還 `n_ele`。 * 宣告為 `static` 是為了縮小可見度 (<s>封閉性</s> ?),~~原本名稱前面兩底線更進一步表達此函式的私有性~~。 :::success 已修改 > [name=Eroiko] ::: * 要複製的字串參數實際上應該被限縮為常數,因為我們不需要修改之。 * 使用 [doxyden](https://zh.wikipedia.org/zh-tw/Doxygen) 文本註解格式,這是由於在 VSCode 中比起 [Linux Kernel-doc](https://docs.kernel.org/doc-guide/kernel-doc.html) 能辨識並給予清楚的提示。實際上,doxygen 也是當前大多數程式語言都使用的註解格式。 :::warning `static` 是為了 [visibility](https://gcc.gnu.org/wiki/Visibility),而非「封閉性」,後者是數學用語。 :notes: jserv > 確實... [name=Eroiko] ::: :::spoiler 舉例而言... * 辛辛苦苦使用 Linux Kernel-doc 的註解不能即時得到回饋。 ![Linux Kernel-doc](https://i.imgur.com/e5J1Vf3.png) :::info 如果讀者您知道哪些 VSCode 擴充套件可以解析 Linux Kernel-doc 的,請務必告訴我。 ::: * 使用 doxygen 可以得到關鍵字的辨識與參數提示。 ![doxygen](https://i.imgur.com/wHe6bMd.png) ::: ### q_insert_head 和 q_insert_tail 的實作與重構 一開始使用最普通的實作。由於都有「配置新元素節點並複製給定字串值」,故將該邏輯獨立為 `q_element_alloc`。最後使用 `list_add_tail` 巨集幫助完成 push front/back 的邏輯。 ```c= bool q_insert_head(struct list_head *head, char *s) { if (!head) // NULL queue return false; // allocate a new element node element_t *n_ele = q_element_alloc(s); if (!n_ele) return false; // insert to queue head // i.e. head->next become &n_ele->list list_add(&n_ele->list, head); return true; } ``` ```c= bool q_insert_tail(struct list_head *head, char *s) { if (!head) // NULL queue return false; // allocate a new element node element_t *n_ele = q_element_alloc(s); if (!n_ele) return false; // insert to queue tail // i.e. head->prev become &n_ele->list list_add_tail(&n_ele->list, head); return true; } ``` 會發現這倆函式行為極其相似,僅差在函式名稱以及插入位置的不同。不妨使用前處理器的技巧,讓程式更好維護,如下。 :::success 注意到前處理的換行不能被 "//" 屏蔽,故使用 C 風格的註解。 > [name=Eroiko] ::: ```c= #define gen_q_insert(fn_suffix, list_macro) \ bool q_insert_##fn_suffix(struct list_head *head, char *s) \ { \ if (!head) /* NULL queue */ \ return false; \ /* allocate a new element node */ \ element_t *n_ele = q_element_alloc(s); \ if (!n_ele) \ return false; \ /* insert into queue */ \ list_macro(&n_ele->list, head); \ return true; \ } ``` 如此,兩函式的實作變為簡單兩行。 ```c= /* Insert an element at head of queue */ gen_q_insert(head, list_add); /* Insert an element at tail of queue */ gen_q_insert(tail, list_add_tail); ``` ### q_remove_head 和 q_remove_tail 的實作與重構 首先處理邊界情況,接著取得串列的首位準備移除,當 `sp` 合法時複製欲移除節點之值,最後回傳。 ```c= element_t *q_remove_head(struct list_head *head, char *sp, size_t bufsize) { if (!head || list_empty(head)) // guard return NULL; // fetch last element element_t *ret = list_first_entry(head, element_t, list); list_del(&ret->list); if (sp) { // sp is valid memcpy(sp, ret->value, bufsize); sp[bufsize - 1] = '\0'; } return ret; } ``` ```c= element_t *q_remove_tail(struct list_head *head, char *sp, size_t bufsize) { if (!head || list_empty(head)) // guard return NULL; // fetch last element element_t *ret = list_last_entry(head, element_t, list); list_del(&ret->list); if (sp) { // sp is valid memcpy(sp, ret->value, bufsize); sp[bufsize - 1] = '\0'; } return ret; } ``` 與插入的情況相同,可以發現這倆函式行為極其相似,僅差在函式名稱以及插入位置的不同。不仿再次使用前處理器的技巧,讓程式更好維護,如下。 ```c= #define gen_q_remove(fn_suffix, list_macro) \ element_t *q_remove_##fn_suffix(struct list_head *head, char *sp, \ size_t bufsize) \ { \ if (!head || list_empty(head)) /* guard */ \ return NULL; \ /* fetch first element */ \ element_t *ret = list_macro(head, element_t, list); \ list_del(&ret->list); \ if (sp) { \ /* sp is valid */ \ memcpy(sp, ret->value, bufsize); \ sp[bufsize - 1] = '\0'; \ } \ return ret; \ } ``` 注意字串的複製我使用 `memcpy` 來加快效率,並在最後補上 `\0`。 於是兩刪除節點函式的實作變成: ```c= /* Remove an element from head of queue */ gen_q_remove(head, list_first_entry); /* Remove an element from tail of queue */ gen_q_remove(tail, list_last_entry); ``` 減少多餘的程式碼與人工同步的成本。 :::warning 注意用語!在電腦科學和工程中,[冗餘](https://zh.wikipedia.org/wiki/%E5%86%97%E9%A4%98) (英語: redundancy) 是指系統為了提昇其可靠度,刻意組態重複的零件或是機能。顯然這裡不是 redundancy 的寓意,應該避免該詞彙的使用。 :notes: jserv > ok [name=Eroiko] ::: ### q_size $O(n)$ 如 Spec 給我們的提示,遍歷整個鏈結串列得所求大小。 ```c= int q_size(struct list_head *head) { if (!head) return 0; int len = 0; struct list_head *li; list_for_each (li, head) len++; return len; } ``` ### q_find_mid 輔助函式 $O(n)$ 有鑒於「尋找中點」需求不僅應用於一處,我將此邏輯獨立為本靜態函式。注意第二個參數 `offset` 可用來儲存「中點」實際上自 `head` 往 `next` 方向之距離,不浪費用來存取非連續記憶體的辛勞;若`offset == NULL` 則忽視此參數。相似的,文件註解採 doxygen 格式。 ```graphviz digraph find_mid { label = <<b>Initial state</b>>; bgcolor="transparent"; node[shape=record]; graph[rankdir=TD]; head[label="<p> prev | head\n(back) | <n> next"]; front[label="<p> prev | front | <n> next"]; next_dotting[label="... (front direction)", shape=plaintext]; prev_dotting[label="... (back direction)", shape=plaintext]; head:n -> front; front -> next_dotting head:p -> prev_dotting } ``` 算法是利用雙向鏈結串列的特性,以兩個指標分別向 `next` 和 `prev` 方向等速移動,透過 `front` 向 `next`、`back` 向 `prev` 遍歷,目標是讓 `front` 最終指向目標。由於每次各走一步,總位移量為 $2$,故有兩種相遇的可能: 1. `back` 和 `front` 最接近時差一,表有偶數個節點,此情況必有一次迴圈中 `back->prev == front`。 2. `back` 和 `front` 會相遇,表有奇數個節點。 ```graphviz digraph find_mid { label = <<b>Terminate state</b>>; bgcolor="transparent"; node[shape=record]; graph[rankdir=TD]; subgraph find_mid_odd { head[label="<p> prev | head | <nxt> next"]; next_dotting1[label="... (front direction)", shape=plaintext]; next_dotting2[label="... (front direction)", shape=plaintext]; prev_dotting[label="... (back direction)", shape=plaintext]; front[label="<p> prev | <m> front\n(odd middle) | <nxt> next"]; back[label="<p> prev | <m> back | <nxt> next"]; head:nxt -> next_dotting1; head:p -> prev_dotting; next_dotting1 -> next_dotting2; next_dotting2:s -> front:m:ne; prev_dotting -> back:m; back:p:s -> front:m:nw; // front:nxt:n -> back:m:s; // front:p:n -> next_dotting2:s; } subgraph find_mid_even { head_ev[label="<p> prev | head | <nxt> next"]; next_dotting1_ev[label="... (front direction)", shape=plaintext]; next_dotting2_ev[label="... (front direction)", shape=plaintext]; prev_dotting_ev[label="... (back direction)", shape=plaintext]; front_ev[label="<p> prev | <m> front | <nxt> next"]; mid[label="<p> prev | <m> even middle | <nxt> next"]; back_ev[label="<p> prev | <m> back | <nxt> next"]; head_ev:nxt -> next_dotting1_ev; head_ev:p -> prev_dotting_ev; next_dotting1_ev -> next_dotting2_ev; next_dotting2_ev -> front_ev:m; prev_dotting_ev -> back_ev:m; front_ev:nxt -> mid:me; back_ev:p -> mid:mw; // mid:p -> front_ev; // mid:nxt -> back_ev:m:s; } } ``` 用以上兩情況決定迴圈的持續條件。 ```c= /** * Find the middle point in given list * * @param offset pointer to obtain position count of mid point. * Can pass NULL of you don't need this result. */ static struct list_head *q_find_mid(struct list_head *head, int *offset) { if (!head || list_empty(head)) return NULL; // nothing to find // traverse in different direction struct list_head *front = head->next, *back = head; int _o = 1; // two possible cases for "two step in each loop" // in both cases, "front" will be what we want to delete // 1. back == front, then there are even nodes // 2. back->prev == front, then are odd nodes while (back != front && back->prev != front) { front = front->next; back = back->prev; ++_o; } if (offset) *offset = _o; return front; } ``` ### q_delete_mid 利用 `q_find_mid`,根據以上找到欲刪除節點後,便是正常的刪除操作。 ```c= bool q_delete_mid(struct list_head *head) { // https://leetcode.com/problems/delete-the-middle-node-of-a-linked-list/ struct list_head *mid = q_find_mid(head, NULL); if (!mid) return false; list_del(mid); // unlink mid node element_t *to_del = list_entry(mid, element_t, list); q_release_element(to_del); return true; } ``` ### q_delete_dup 要刪除重複的節點,首先列表至少要有兩元素,故邊界條件額外考慮僅單節點的情況,可使用 `list_is_singlar` 巨集來判斷。 接下來的演算法為:透過比較鄰近兩元素 (`cur`, `nxt`) 的字串決定行為。 若字串相同,則 1. 刪除 `cur` 2. 以 `mark_del` 標記 `nxt` 為「待刪除元素」 若不同且標記不為空,則 1. 刪除標記下的元素,即上個迴圈的 `nxt` 2. 重設標記為空,避免重複釋放記憶體 節點的走訪可搭配運用 `list_for_each_entry_safe` 巨集。 ```c= bool q_delete_dup(struct list_head *head) { // https://leetcode.com/problems/remove-duplicates-from-sorted-list-ii/ if (!head || list_empty(head) || list_is_singular(head)) return false; // nothing to be deleted // delete cur when cur->value == nxt->value element_t *cur, *nxt, *mark_del; // if deletion happens, set mark_del as nxt // else, delete held node and reset to NULL mark_del = NULL; list_for_each_entry_safe (cur, nxt, head, list) { // notice that nxt == head means the last case, // just try to go to "else" if (&nxt->list != head && !strcmp(cur->value, nxt->value)) { mark_del = nxt; list_del(&cur->list); q_release_element(cur); } else if (mark_del) { list_del(&mark_del->list); q_release_element(mark_del); mark_del = NULL; } } return true; } ``` ### q_swap $O(n)$ 由於後面會實作 `q_reverseK` 函式,而 `q_swap` 實際上就是 `q_reverseK` 的特例,故直接複用即可,這使程式更加易於維護。 ```c= void q_swap(struct list_head *head) { // https://leetcode.com/problems/swap-nodes-in-pairs/ // reuse special case of implemented algorithm q_reverseK(head, 2); } ``` ### q_reverse $O(n)$ 利用巨集,可透過遍歷所有節點,並將節點的 `list_head::next` 和 `list_head::prev` 交換,即表示反轉串列,故以下為第一種實作。 ```c= void q_reverse(struct list_head *head) { if (!head) return; // nothing to be reversed struct list_head *cur, *nxt; list_for_each_safe (cur, nxt, head) { cur->next = cur->prev; cur->prev = nxt; } // in the end, we should also swap the links of head struct list_head *tmp = head->next; head->next = head->prev; head->prev = tmp; } ``` 特例是要另外反轉 `head` 的兩個指標。若要消除此例外,可以使用 `do-while` 迴圈取代,便是第二種實作如下。 ```c= void q_reverse(struct list_head *head) { if (!head) return false; // nothing to be reversed struct list_head *cur = head, *nxt = head->next; do { // swap links cur->next = cur->prev; cur->prev = nxt; // iterate cur = nxt; nxt = nxt->next; } while (cur != head); } ``` 考慮邊界條件,只要 `head` 存在,整套算法即可正常運行。 ### q_reverseK $O(n)$ 本函式乍一看與 `q_reverse` 的運作邏輯相似,就是複雜了一點。每 k 個元素一群,要滿 k 個元素才可以進行反轉,故不能邊遍歷邊反轉,得先確認眼下真有 k 個元素。復用 `q_reverse` 得到的演算法可以是: * 以迴圈不斷... * 走訪 $k$ 個元素,確認是不是真有 $k$ 個。 * 將一鏈結串列的一定範圍截斷。 * 利用巨集 `list_cut_position`,其根據給定之 `head` 和右界 * 為此我們需一指標 `lead` 向前遍歷 k 位後確認要截斷的位置。 * 注意 `lead` 的起點不可為 `head`,因為 `list_cut_position` 的截斷是「包含」給定之右界: $(head, lead\big]$,範圍是左開右閉。 * 為了不失去領頭做標記的 `lead`,將其初始化為 `head->next`,截斷之右界也順應變成 `lead->prev`。 * 接入暫存串列 `rev_head`。 * 利用已經實作好的 `q_reverse` 反轉暫存串列。 * 接入最終串列 `new_head`。 * 最後不忘將結果接回 `head` 串列。 ```c= void q_reverseK(struct list_head *head, int k) { // https://leetcode.com/problems/reverse-nodes-in-k-group/ if (!head) return; struct list_head *lead = head->next; LIST_HEAD(new_head); do { // move k steps int i; // counter for (i = 0; i < k && lead != head; ++i) lead = lead->next; if (i == k) { // there are k elements: [from, lead) // cut k nodes from head, reverse them, and paste to new_head LIST_HEAD(rev_head); list_cut_position(&rev_head, head, lead->prev); q_reverse(&rev_head); list_splice_tail_init(&rev_head, &new_head); } } while (lead != head); list_splice_init(&new_head, head); } ``` 與 `q_reverse` 相似,考慮邊界條件,只要 `head` 存在,整套算法即可正常運行。 ### in_order 輔助函式 由於 [Merge request #133](https://github.com/sysprog21/lab0-c/commit/16fbb731fa3d06e1492967e3c02ddd594940900b) 加入對升序、降序的支援,我決定將「是否正確的排序」的邏輯從原本呼叫 `strcmp` 後比對回傳值的操作獨立成 `in_order` 函式,以便多處重複利用。 實作的部分即儲存 `strcmp` 的結果,對三種可能進行確認,詳見函式內註解。 另由於本函式小巧玲瓏,故加入 `inline` 修飾,期待編譯器將呼叫處改為程式碼展開。 ```c= /** * Return whether a and b with order: [... , a, b, ...] is in correct ordering, * where a and b are strings for comparison. * @param descend whether we're using descending order */ static inline bool in_order(const char *a, const char *b, bool descend) { int _cmp = strcmp(a, b); /** * strcmp(a, b) == 0: always in order * strcmp(a, b) < 0: inorder if using ascending order * strcmp(a, b) > 0: inorder if using descending order */ return !_cmp || (_cmp < 0 && !descend) || (_cmp > 0 && descend); } ``` ### merge_two_lists 輔助函式 本輔助函式的意義在將 `sub` 串列併入 `major` 串列中,假設兩者都排完序。不同於上課介紹過的「先找一暫存串列,將兩目標串列的節點併入暫存串列後轉移回原串列」的做法,本函式假設一定併入 `major`,故可以省略與 `major` 長度相等之「併入」操作,降低執行時的負擔。 原本的實作為以下: ```c= /** * Helper function to merge two sorted lists. * @param major major list of merging, all nodes will * move to this linked list, must not be empty! * @param sub list to be merged. */ static void merge_two_lists(struct list_head *major, struct list_head *sub) { if (list_empty(major)) { list_splice_init(sub, major); return; } else if (!sub || list_empty(sub)) { return; } struct list_head *m = major->next; element_t *m_ele = list_entry(m, element_t, list); element_t *s_ele = list_first_entry(sub, element_t, list); do { if (strcmp(m_ele->value, s_ele->value) > 0) { list_move_tail(sub->next, m); s_ele = list_first_entry(sub, element_t, list); } else if (m->next != major) { m = m->next; m_ele = list_entry(m, element_t, list); } else { list_splice_tail_init(sub, major); return; } } while (!list_empty(sub)); } ``` 除了 edge case 有點雜亂外, `do-while` 迴圈內也不怎麼「美觀」,函式的其中一個返回的可能甚至在 `else` 條件下,實在說不上有品味... 經重構後為以下,邏輯相等不過好看多了。 ```c= /** * Helper function to merge two sorted lists. * @param major major list of merging, all nodes will * move to this linked list * @param sub list to be merged. */ static void merge_two_lists(struct list_head *major, struct list_head *sub) { if (!sub) return; struct list_head *m = major->next; element_t *m_ele = list_entry(m, element_t, list); element_t *s_ele = list_first_entry(sub, element_t, list); while (!list_empty(sub) && m != major) { if (strcmp(m_ele->value, s_ele->value) > 0) { list_move_tail(sub->next, m); s_ele = list_first_entry(sub, element_t, list); } else { m = m->next; m_ele = list_entry(m, element_t, list); } } list_splice_tail_init(sub, major); } ``` 新的 `merge_two_lists` 應該提供升/降序的合併,故將比較部分從 ```c= strcmp(m_ele->value, s_ele->value) > 0 ``` 改為 ```c= !in_order(m_ele->value, s_ele->value, descend) ``` 注意新的函式簽名額外加入 `descend` 參數。 ### q_i_sort (insertion sort) 輔助函式 考慮到後面我們要實作穩定的排序,不一定要使用 merge sort,insertion sort 也是某些情境下的好選擇。 在資料結構中我們學到,在欲排序元素較少的情況下,尤其趨近排序完成的情況下,`insertion sort` 擁有優異的效能表現,無論是時間抑或空間複雜度上。 時間上可使用迴圈完成,無需 function call 的額外負擔。具體而言可能有參數的預備、calling stack 的操作 push/pop,code memory 中的跳導致的 cache miss 等。 空間上 insertion sort 可以就地排序,且僅需 $O(1)$ 的額外空間,比起 merge sort 使用 calling stack、quick sort 使用 calling stack 或普通 stack 還好。 注意到我的演算法參數有二,標示著欲排序的範圍。不直接如 `q_sort` 傳入 `head` 是為了擴展性,是為了讓本函式可以進行局部排序。 ```c= /** * Sort list of closed range [from, to] using insertion sort. * Assume from != NULL and to != NULL !!! * @param from starting node of sorting (include), must NOT be NULL * @param to ending node of sorting (include), must NOT be NULL */ static void q_i_sort(struct list_head *from, const struct list_head *to) { const struct list_head *l_bd = from->prev, *r_bd = to->next; // boundary never change! struct list_head *cur = from->next; while (cur != r_bd) { // loop if haven't reach end of sorting range struct list_head *nxt = cur->next, *prv = cur; element_t *c_ele = list_entry(cur, element_t, list), *p_ele; // traverse (back) to correct position // cur should finally be placed at prv->next; do { prv = prv->prev; p_ele = list_entry(prv, element_t, list); } while (prv != l_bd && strcmp(p_ele->value, c_ele->value) > 0); list_move(cur, prv); cur = nxt; // iterator increment }; } ``` 順帶一提,一開始實作時由於不是先以 `l_bd` (left bound) 和 `r_bd` (right bound) 兩恆定指標將我們感興趣的邊界記下,而是動態去與 `from`、`to` 的鄰居比較,未考量 `from`、`to` 本身也是會被移動的對象,所以除錯搞了很久... 重構時為邊界加上 `const` 修飾。 新的 `q_i_sort` 應該提供升/降序的合併,故將比較部分從 ```c= strcmp(m_ele->value, s_ele->value) > 0 ``` 改為 ```c= !in_order(m_ele->value, s_ele->value, descend) ``` 注意新的函式簽名額外加入 `descend` 參數。 ### q_sort 本函式為 merge sort + insertion sort 的混合穩定排序。當欲排序數量大於 `THRESHOLD` 時使用 merge sort 的 partition,反之使用前面實作好的 insertion sort `q_i_sort` 直接排序。最後透過同樣實作好的 `merge_two_lists` 完成合併。 程式碼可分為三塊解釋。 1. 邊界條件與變數初始化 `THRESHOLD` 的大小我認為與 insertion sort 的甜蜜點和 cache 的大小有關。 :::info 另外注意到之所以我不將單節點串列納入邊界情況,是因為我的實作確保以下兩點: 1. 每次必定切成近乎等長的兩串列呼叫遞迴 2. 使用 insertion sort 針對長度較小的情況排序 如此,遇到長度很低的串列自然會使用 insertion sort 排序完成,省略了大量函式呼叫。 ::: ```c= if (!head) return; // Use merge sort + insertion sort // threshold to use insertion sort optimization static const int THRESHOLD = 32; // for merge sort, we first divide then conquer // initialize stack two heads LIST_HEAD(other); ``` 2. 切分與遞迴 中點的尋找我採用前面實作過的 `q_find_mid` 來同時取得分割點與此點兩邊串列大致的長度,後續將以此決定切分後的邏輯。 ```c= // find pivot /** * Offset of left side (mid point), which is * approximately the same as right side */ int offset; struct list_head *pivot = q_find_mid(head, &offset); // divide into two lists: head and other list_cut_position(&other, head, pivot); if (offset > THRESHOLD) { q_sort(head); q_sort(&other); } else { q_i_sort(head->next, head->prev); q_i_sort(other.next, other.prev); } ``` 3. 合併 以 `head` 為標的合併。 ```c= // merge then complete merge_two_lists(head, &other); ``` 新的 `q_sort` 函式簽名加入 `descend` 參數,連帶調整呼叫 `q_sort`, `q_i_sort` 和 `merge_two_lists`。 ### q_descend 和 q_ascend 的實作與重構 $O(n)$ 解決 `q_descend` 的演算法為將題意換句話說,就是維護從右往左看的單調上升序列。 ```c= int q_descend(struct list_head *head) { // https://leetcode.com/problems/remove-nodes-from-linked-list/ if (!head || list_empty(head) || list_is_singular(head)) return 0; int cnt = 0; struct list_head *lead = head->prev, *test = lead->prev; // not necessarily change in each loop, declare outside element_t *l_ele = list_entry(lead, element_t, list); do { // must change in each loop element_t *t_ele = list_entry(test, element_t, list); struct list_head *tmp = test->prev; if (strcmp(t_ele->value, l_ele->value) > 0) l_ele = t_ele; // save in new list else { list_del(test); // remove from doubly linked list q_release_element(t_ele); // delete element node ++cnt; } test = tmp; // move test } while (test != head); return cnt; } ``` 由於新版 `lab0-c` 改成要實作 `q_ascend` 和 `q_descend` 兩個維護單調序列的函式,此兩函式實際上唯一的區別在[輔助函式 `in_order`](#in_order-%E8%BC%94%E5%8A%A9%E5%87%BD%E5%BC%8F) 的回傳值。如同 [q_insert_head 和 q_insert_tail 的實作與重構](#q_insert_head-%E5%92%8C-q_insert_tail-%E7%9A%84%E5%AF%A6%E4%BD%9C%E8%88%87%E9%87%8D%E6%A7%8B),我以 `gen_q_monotonic` 巨集實現兩函式,透過巨集參數決定演算法維護的是單調升序抑或降序的序列。 與舊版的 `q_descend` 相比,調換 `if-else` 的內容,使「升序」(`descend` 替換為 `false`) 為預設的序列,這符合程式的直觀行為。 ```c= #define gen_q_monotonic(fn_suffix, descend) \ int q_##fn_suffix(struct list_head *head) \ { \ /* https://leetcode.com/problems/remove-nodes-from-linked-list/ */ \ if (!head || list_empty(head) || list_is_singular(head)) \ return 0; \ int cnt = 0; \ struct list_head *lead = head->prev, *test = lead->prev; \ \ /* not necessarily change in each loop, declare outside */ \ element_t *l_ele = list_entry(lead, element_t, list); \ \ do { \ /* t_ele must change in each loop */ \ element_t *t_ele = list_entry(test, element_t, list); \ struct list_head *tmp = test->prev; \ \ if (!in_order(t_ele->value, l_ele->value, descend)) { \ list_del(test); /* remove from linked list */ \ q_release_element(t_ele); /* delete element node */ \ ++cnt; \ } else { \ l_ele = t_ele; /* save in new list */ \ } \ \ test = tmp; /* move test */ \ } while (test != head); \ return cnt; \ } ``` 如此一來,`q_ascend` 和 `q_descend` 的實作變成簡單兩行。 ```c= /* Remove every node which has a node with a strictly less value anywhere to * the right side of it */ gen_q_monotonic(ascend, false); /* Remove every node which has a node with a strictly greater value anywhere to * the right side of it */ gen_q_monotonic(descend, true); ``` ### q_merge $O(n)$ 當中 $n$ 為總元素數。 要將所有 queue 的內容併為一個,相當於不斷套用前面實作好的「將一串列併入某串列」的邏輯。要使 `merge_two_lists` 正常運作的條件為 `major` 參數需非 `NULL`,故將等價意義的 `!head || list_empty(head)` 設為邊界條件。 過程為先將首個 queue 元素 `first` 自原串列分離,遍歷所有剩下的串列並將當中的 queue 一個個併入 `first->q`,最後把 `first` 接回元素串列。 ```c= int q_merge(struct list_head *head) { // https://leetcode.com/problems/merge-k-sorted-lists/ if (!head || list_empty(head)) return 0; queue_contex_t *first = list_first_entry(head, queue_contex_t, chain); // unlink first queue list_del(&first->chain); // iterate through remaining queues and merge into "first->q" queue_contex_t *cur = NULL; list_for_each_entry (cur, head, chain) { merge_two_lists(first->q, cur->q); first->size += cur->size; } // relink first queue list_add(&first->chain, head); // restore the first queue return first->size; } ``` 新的 `q_merge` 應該提供升/降序的合併,故將比較部分從 ```c= strcmp(m_ele->value, s_ele->value) > 0 ``` 改為 ```c= !in_order(m_ele->value, s_ele->value, descend) ``` 注意新的函式簽名額外加入 `descend` 參數。 ## Valgrind 的使用與記憶體分析 為了更好的閱讀 Valgrind 的輸出報告,可以使用命令列的重新導向功能,比如 ```bash (make valgrind) > make_valgrind_output.log 2 > make_valgrind_err.log ``` 若要將 `stdout` 和 `stderr` 重新導向至同一檔案,可改成以下 ```bash (make valgrind) > make_valgrind.log 2>&1 ``` 如此一來我們便能仔細端詳 `stdout` 和 `stderr`。 ### memcpy 記憶體讀取問題 在 `q_remove_head` 和 `q_remove_tail` 中,我原本認為使用 `memcpy` 理當較 `strncpy` 高效,故使用前者。不過開啟 Valgrind 後發現 `memcpy` 會讀取到非法位置。 ```text scripts/driver.py -p /tmp/qtest.YkLqZX --valgrind # Test of insert_head and remove_head ==320616== Invalid read of size 8 ==320616== at 0x4852AF8: memmove (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so) ==320616== by 0x10FD60: memcpy (string_fortified.h:29) ==320616== by 0x10FD60: q_remove_head (queue.c:102) ==320616== by 0x10CA60: queue_remove (qtest.c:354) ==320616== by 0x10CC08: do_rh (qtest.c:411) ==320616== by 0x10E356: interpret_cmda (console.c:181) ==320616== by 0x10E90B: interpret_cmd (console.c:201) ==320616== by 0x10ED0C: cmd_select (console.c:610) ==320616== by 0x10F5F8: run_console (console.c:729) ==320616== by 0x10D748: main (qtest.c:1338) ==320616== Address 0x4b80128 is 8 bytes before a block of size 2,049 alloc'd ==320616== at 0x4848899: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so) ==320616== by 0x10C7FF: queue_remove (qtest.c:319) ==320616== by 0x10CC08: do_rh (qtest.c:411) ==320616== by 0x10E356: interpret_cmda (console.c:181) ==320616== by 0x10E90B: interpret_cmd (console.c:201) ==320616== by 0x10ED0C: cmd_select (console.c:610) ==320616== by 0x10F5F8: run_console (console.c:729) ==320616== by 0x10D748: main (qtest.c:1338) ==320616== ==320616== Invalid read of size 8 ``` ## list_sort.c 排序比較 :::info TODO ::: > 以下為小筆記 Linux 核心 list_sort.c 引用關係表,省略所有名為 `linux` 的路徑。 ```mermaid graph TD; LIST_SORT_C["list_sort.c"] --> LIST_SORT_H["list_sort.h"] --> TYPES_H["type.h"]; LIST_SORT_C --> CONTAINER_OF_H["container_of.h"]; LIST_SORT_C --> LIST_H["list.h"]; LIST_H --> CONTAINER_OF_H; LIST_H --> TYPES_H["type.h"]; TYPES_H --> UAPI_TYPES_H["uapi/type.h"]; UAPI_TYPES_H --> ASM_TYPES_H["asm/type.h"]; ``` ## Web server 調整 :::info TODO ::: > 以下為小筆記 * `select` 資訊 以下資訊來自 [Linux Manual Page](https://man7.org/linux/man-pages/man2/select.2.html)。 `select` 函式功能 > select() allows a program to monitor ***multiple file descriptors***, waiting until one or more of the file descriptors become "ready" for some class of I/O operation (e.g., input possible). A file descriptor is ***considered ready if it is possible to perform a corresponding I/O operation*** (e.g., read(2), or a sufficiently small write(2)) ***without blocking***. 關於 File descriptor sets > The principal arguments of select() are three "sets" of file descriptors (declared with the type fd_set), which ***allow the caller to wait for three classes of events on the specified set of file descriptors***. Each of the fd_set arguments may be specified as NULL if no file descriptors are to be watched for the corresponding class of events. 注意官方提到若使用於迴圈內,也就是 `fd_set` 可能在迴圈內被修改,則我們應該 (用 `FD_ZERO`) 重新初始化 `fd_set`。 以下四個巨集協助修改 file descriptor set。 * `FD_ZERO`: 初始化 `fd_set`。 * `FD_SET`: 加入某 `fd` 至 `fd_set`。 * `FD_CLR`: 將某 `fd` 自 `fd_set` 移除。 * `FD_ISSET`: 確認某 `fd` 是否在 `fd_set` 中,存在則非零,不存在則為零。 另一函式 `pselect` 與 `select` 有些許不同。 * 加入 `select` 於 `linenoice:line_edit` 中加入 Spec 建議的程式碼後,須引入以下標頭檔。 ```c= #include <netinet/in.h> /* sockaddr_in */ #include <sys/select.h> /* fd_set */ ``` 並修改 `listenfd` 為 web 輸入的檔案描述子 `l.ifd`、將 `SA` 縮寫改為 `struct sockaddr_in`。 ## 其他紀錄 ### :penguin: [作業完成度](https://hackmd.io/@sysprog/linux2023-lab0/%2F%40sysprog%2Flinux2023-lab0-f) * [x] 在 GitHub 上 fork [lab0-c](https://github.com/sysprog21/lab0-c) * [x] 參閱 [Git 教學和 GitHub 設定指引](https://hackmd.io/@sysprog/git-with-github) ^附教學影片^ * [x] 依據上述指示著手修改 `queue.[ch]` 和連帶的檔案,測試後用 Git 管理各項修改,要能滿足 `$ make test` 自動評分系統的所有項目。 * [x] 在提交程式變更前,務必詳閱 [如何寫好 Git Commit Message](https://blog.louie.lu/2017/03/21/%E5%A6%82%E4%BD%95%E5%AF%AB%E4%B8%80%E5%80%8B-git-commit-message/) * [x] 詳閱「[你所不知道的 C 語言: linked list 和非連續記憶體](https://hackmd.io/@sysprog/c-linked-list)」並應用裡頭提及的手法,例如 pointer to pointer (指向某個指標的指標,或說 indirect pointer),讓程式碼更精簡且有效 * [ ] 研讀 Linux 核心原始程式碼的 [lib/list_sort.c](https://github.com/torvalds/linux/blob/master/lib/list_sort.c) 並嘗試引入到 `lab0-c` 專案,比較你自己實作的 merge sort 和 Linux 核心程式碼之間效能落差 * [ ] 確保 `qtest` 提供的 `web` 命令得以搭配上述佇列實作使用,目前一旦網頁伺服器啟動後,原本處理命令列操作的 `linenoise` 套件就無法使用,請提出改善機制 * [ ] 提示: 使用 [select](http://man7.org/linux/man-pages/man2/select.2.html) 系統呼叫 * [ ] 在 `qtest` 提供新的命令 `shuffle`,允許藉由 [Fisher–Yates shuffle](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle) 演算法,對佇列中所有節點進行洗牌 (shuffle) 操作,需要以統計的原理來分析你的實作,探究洗牌的「亂度」 * [ ] 在 `qtest` 中執行 `option entropy 1` 並搭配 `ih RAND 10` 一類的命令,觀察亂數字串的分布,並運用「資訊與熵互補,資訊就是負熵」的觀念,設計一組新的命令或選項,得以在 `qtest` 切換不同的 PRNG 的實作 (可選定 [Xorshift](https://en.wikipedia.org/wiki/Xorshift)),搭配統計分析工具,比較亂數產生器 (如 Xorshift vs. 內建) 的品質 * [ ] 參見: [由大數法則理解訊息熵 (entropy) 的數學意義](https://hackmd.io/@8dSak6oVTweMeAe9fXWCPA/ryoegPgw_) * [ ] 參見: [The Linux Pseudorandom Number Generator Revisited](https://eprint.iacr.org/2012/251.pdf) * [ ] 研讀論文〈[Dude, is my code constant time?](https://eprint.iacr.org/2016/1123.pdf)〉,解釋本程式的 "simulation" 模式是如何透過以實驗而非理論分析,達到驗證時間複雜度,需要解釋 [Student's t-distribution](https://en.wikipedia.org/wiki/Student%27s_t-distribution) 及程式實作的原理。 * [ ] 注意:現有實作存在若干致命缺陷,請討論並提出解決方案 * [ ] 在 [oreparaz/dudect](https://github.com/oreparaz/dudect) 的程式碼具備 percentile 的處理,但在 [lab0-c](https://github.com/sysprog21/lab0-c) 則無。 * [ ] 指出現有程式的缺陷或者可改進之處 (例如更全面地使用 Linux 核心風格的鏈結串列 API),嘗試實作並提交 pull request * [ ] 除了修改程式,也要編輯「[作業區](https://hackmd.io/@sysprog/linux2023-homework1)」,增添開發紀錄和 GitHub 連結,除了提及你如何逐步達到自動評分程式的要求外,共筆也要涵蓋以下: * [ ] 詳閱第 1 週所有教材及作業描述 [(一)](/@sysprog/linux2023-lab0-a), [(二)](/@sysprog/linux2023-lab0-b), [(三)](/@sysprog/linux2023-lab0-c), [(四)](/@sysprog/linux2023-lab0-d), [(五)](/@sysprog/linux2023-lab0-e),記錄你的啟發和疑惑 * [ ] 開啟 [Address Sanitizer](https://github.com/google/sanitizers/wiki/AddressSanitizer),修正 `qtest` 執行過程中的錯誤 * [ ] 運用 Valgrind 排除 `qtest` 實作的記憶體錯誤,並透過 Massif 視覺化 "simulation" 過程中的記憶體使用量,需要設計對應的實驗 * [ ] 解釋 [select](http://man7.org/linux/man-pages/man2/select.2.html) 系統呼叫在本程式的使用方式,並分析 [console.c](https://github.com/sysprog21/lab0-c/blob/master/console.c) 的實作,說明其中運用 CS:APP [RIO 套件](http://csapp.cs.cmu.edu/2e/ch10-preview.pdf) 的原理和考量點。可對照參考 [CS:APP 第 10 章重點提示](https://hackmd.io/@sysprog/H1TtmVTTz) ### Quick Note * 以規定之標準格式化程式碼檔案 ```bash clang-format -i *.[ch] ``` * `if-else` 採 K&R 風格 ```c= if (condition1) { } else if (condition2) { } else { } ``` * GDB 重要命令與用法紀錄 * 帶參數除錯 ```bash gdb --args EXECUTABLE ARG1 ARG2 ... ``` * 使用重新導向除錯 ```bash gdb EXECUTABLE ... (gdb) run < FILE_TO_REDIRECT_AS_INPUT ``` * 常用命令 | Command | Alias | Argument | Usage | |:-----------:|:-----:|:------------------:|:----------------------------------------------------------------------------:| | `help` | `h` | `ALL_GDB_COMMAND` | 啥都不知道時先 `help` 一下就對了 | | `print` | `p` | `SOMETHING` | 印出 `SOMETHING`,可以是參數、<br>指標,甚至呼叫函式的節果 | | `break` | `b` | `FILE:LINE_NUMBER` | 在 `FILE` 的第 `LINE_NUMBER`<br>設定中斷點,比如 `b queue.c:123` | | `step` | - | - | 單步執行 | | `continue` | `c` | - | 從當前中斷點繼續執行<br>(至下個中斷點或執行結束 or 報錯) | | `info` | `i` | `SOME_GDB_COMMAND` | 印出 `SOME_GDB_COMMAND` 的相關訊息,可使用 `help info` 查看哪些命令可供查看 | | `backtrace` | `bt` | - | 印出 function stack | | `frame` | `f` | `FRAME_NUMBER` | 移動至 function stack 中編號 `FRAME_NUMBER` 的 frame,目前有哪些 frame 可用 `bt` 查詢 | | `up` | - | - | 切換到上層 frame | | `down` | - | - | 切換到下層 frame | :::warning 查閱教育部辭典「[幀](https://dict.revised.moe.edu.tw/dictView.jsp?ID=7954)」的釋義: 量詞。計算照片、字畫等的單位。但這樣的量詞跟 GDB 原本的 stack frame 不相符,後者更在意可切換 stack context 的範疇。因應漢語的侷限,這裡不該翻譯。 :notes: jserv > ok ::: * Valgrind * 透過維護一組暫存器和主記憶體的副本,安全監視程式的運行。寫好的 Machine Code 先被轉譯為虛擬指令集 VEX (中間表示法 IR),再以 JIT 的方式重新翻譯為目標架構的 Machine Code。 * 自動測試程式 * 透過 function hooking 搭配雙向鏈結串列,儲存記憶體使用的 * 測試時將 `malloc`, `calloc` 透過巨集與 `test_malloc`, `test_calloc` 掛鉤。 ### Sync fork 紀錄 由於我 fork 的 repository 與源 repository 有不少落差,今希望將源 repository (i.e. `upstream`) 的修改也加入我 fork 的 repository。 * Git 為遠端加入 `upstream` * 查看當前遠端 repository ```bash > git remote -v origin https://github.com/Shiritai/lab0-c.git (fetch) origin https://github.com/Shiritai/lab0-c.git (push) ``` * 新增 `upstream` ```bash > git remote add upstream https://github.com/sysprog21/lab0-c.git ``` * 確認 ```bash > git remote -v origin https://github.com/Shiritai/lab0-c.git (fetch) origin https://github.com/Shiritai/lab0-c.git (push) upstream https://github.com/sysprog21/lab0-c.git (fetch) upstream https://github.com/sysprog21/lab0-c.git (push) ``` * Sync fork * 載入 `upstream` ```bash > git fetch upstream remote: Enumerating objects: 94, done. remote: Counting objects: 100% (58/58), done. remote: Compressing objects: 100% (10/10), done. remote: Total 94 (delta 48), reused 54 (delta 48), pack-reused 36 Unpacking objects: 100% (94/94), 32.80 KiB | 645.00 KiB/s, done. From https://github.com/sysprog21/lab0-c * [new branch] master -> upstream/master ``` * 保險起見將本地主分支備份為 `backup` ```bash > git checkout -b backup Switched to a new branch 'backup' > git checkout master Switched to branch 'master' > git branch backup * master (END) ``` * 嘗試與 `upstream` 合併 ```bash > git merge upstream/master Auto-merging .github/workflows/main.yml CONFLICT (content): Merge conflict in .github/workflows/main.yml Auto-merging linenoise.c Auto-merging qtest.c Auto-merging queue.c CONFLICT (content): Merge conflict in queue.c Auto-merging report.c CONFLICT (content): Merge conflict in report.c Automatic merge failed; fix conflicts and then commit the result. ``` 使用 git 自動合併的話,可以看到數個檔案出現衝突,這符合預期,接下來便是針對衝突逐一解決。 * merge 紀錄 * 使用 VSCode 內 Merge editer 的 accept combination 會自動嘗試合併兩方的修改。 * `q_sort` 的部分,由於改成支援升序與降序兩種方式,`q_sort` 的簽名進行了調整,需合併衝突:採用新簽名。 * 新增 `q_ascend` 函式,直接併入即可。 ### 遇到問題 * SSH 設定與 `ssh-private-key` 問題 * 這可能是由於自己已經有一 ssh key,卻又不小心生成另一個,因為自以為舊的可能不會派上用場。 * 目前透過修改 `main.yml` 使 github action 可運行測試。 * 當然這顯然不是個好方法,僅權宜 ```yml # TODO uncomment this # - uses: webfactory/ssh-agent@v0.7.0 # with: # ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} ``` * ~~具體等完成 lab 的其他部分後再來研究...~~ * 透過 sync fork,將 `upstream` 上同學的 [merge request](https://github.com/sysprog21/lab0-c/pull/112) 併入後得到解決。 * 目前實作之分數為 $100/100$,不知為何突然最後一項常數時間的測試場景通過了... * Cppcheck 警告 * 警告位置: `repost.c:report_event` ```bash static char *msg_name_text[N_MSG] = { "WARNING", "ERROR", "FATAL ERROR", }; ``` * 原本透過加上 `const` 解決。 * `upstream` 的 [commit 0180045](https://github.com/sysprog21/lab0-c/commit/0180045dba46fd4571f48ff10faed7d1872a8ad8) 將 `pre-commit.hook` 修改,我將其 sync 回我 fork 的 repository 而解決。 ### Typos or patch?! * 要先 fork + 打開 Github Action 後,照 Spec 的「小試身手」操作才會正確運行 * git commit 後會自動進行測試、報告結果,並寄送至郵件 * Valgrind + 自動測試程式 * Signal 處理與應用 * `queue_init` 不存在,應為 `q_init` * `sigsegvhandler` 不存在,應為 `sigsegv_handler` * `sigalrmhandler` 不存在,應為 `sigalrm_handler` :::warning 請修改 HackMD 頁面。 :notes: jserv > 好的,修改完畢。 ::: * `web` * `linenoiseEdit` 可改為 `linenoice.c:line_edit` 方能立刻找到 * 程式碼的諸多函式名稱有誤,需舉燭 * 部分操作難以理解,似乎因為是針對以前的程式碼說明?! * Linux 核心排序實作的演進 * 數學推導過程沒採用 LaTeX 風格於 Markdown 的置中語法,有點不習慣。即: ```md $$ \rm{example statement} $$ ``` * 整個 Hackmd 都有的排版建議 * 程式碼區於列表下 (i.e. `*` 無序列表或 `1. ` 有序列表) 幾乎都沒有縮排,也許是為了控制單行字數能保持一致,不過有點違和。