contributed by < 25077667
>
lab0-c
程式In shorts:
比較特別值得紀錄的是,想說先用 Copilot 鬼混一些簡單的問題
但是很不幸的:基礎題沒有一題是答對的
以 code review 的角度先看 Copilot 寫出看起來正常的程式碼
就是順邏輯而已,沒有仔細琢磨指標的移動情形
所以只能自己寫了,也考慮邏輯:
因為曾經被 clang-tidy 提醒過
即使 realloc 失敗也是需要 free,因為參考到 7.22.3.1 (std)
The order and contiguity of storage allocated by successive calls to the
aligned_alloc, calloc, malloc, and realloc functions is unspecified. The
pointer returned if the allocation succeeds is suitably aligned so that it may be assigned to
a pointer to any type of object with a fundamental alignment requirement and then used
to access such an object or an array of such objects in the space allocated (until the space
is explicitly deallocated). The lifetime of an allocated object extends from the allocation
until the deallocation. Each such allocation shall yield a pointer to an object disjoint from
any other object. The pointer returned points to the start (lowest byte address) of the
allocated space. If the space cannot be allocated, a null pointer is returned. If the size of
the space requested is zero, the behavior is implementation-defined: either a null pointer
is returned, or the behavior is as if the size were some nonzero value, except that the
returned pointer shall not be used to access an object.
我想在 malloc 也是 free 比較好,同時也因為 7.22.3.3 對 free()
說道:
If ptr is a null pointer, no action occurs.
所以 free 是絕對不會出錯的。
這邊設計考慮到因為我本身是 C++ major 應該隨時注意到 RAII issue
所以我應該要把 cur
以及 safe
指標做初始化,避免 dangling pointer
即使聽過有人說在 C 語言裡面考慮記憶體安全就是個笑話
The C programming language gives you a knife that could kill you.
這邊有個較優雅的寫法,源自於去年的練習:
避免贅字,無特別標示的狀況,此處都該是「你的意見」。工程人員說話要精準有效。
使用到 function pointer 讓 insert 這個操作具有一定的抽象性
使用更精準的詞彙,並探究其優勢。
總之,就是比較有彈性,把實際操作的 head tail 當作 callback。
所以 q_insert_tail 就可以這樣寫了:
這邊跟上面稍微不一樣的方式,上面都有可以呼叫的 function pointer 當作 callback function
但是 remove 目前 sysprog21/lab0-c 專案沒有,所以寫這樣的形式
當然如果想要自己放 renmove head/tail function 也是可以剌
所以 remove_head:
這邊沒有保證須要 時間完成,於是採用 方式實作:
這邊採用一個比較經典的演算法是快慢指標 (Floyd's Algorithm (Tortoise and Hare Algorithm))
基本上就是:
這樣快指標到結尾的時候,慢指標就是在一半的位置。
作法 naive ,沒有好解釋的。
凡事都有優缺點,去探討。
要探討的話,有個議題是 cache locality 因為這樣每走兩步就交換,這樣會有 linked-list 容易 major page fault (這需要實驗做討論)。
不過如果可以假設有很低成本的 perfect-hash function 那我可以把 strcmp()
換成
這樣有機會減少比較次數。
每次移動兩步
交換相鄰兩節點,運用到指標的指標技巧
把每個節點搬到「頭」,就是把整個 list 反轉
覺得被坑,一開始 code review 這覺得沒什麼問題,想說 Copilot 可以把 leetcode 寫的很好。
但是很不幸的, Copilot 是錯的。需要重新想一次。
想說時間不多,先參考同學的 komark06 並且改寫成自己的版本。
好處我交給 chatGPT 說:
第一個程式碼相對於第二個程式碼更佳優秀。原因在於第一個程式碼檢查了一個非常特殊的邊界條件:當傳入的串列 head 是空的、沒有節點或者 k 小於等於 1 時,直接返回,這有助於確保在進行操作之前,傳入的參數符合要求,從而保證程式碼的正確性和穩定性。
此外,第一個程式碼使用了 __glibc_unlikely 巨集來提示編譯器機率很低,這有助於改善程式碼的效能。同時,程式碼還使用了一個計數器 count 變量,以計算從遍歷過的節點數量。當計數器達到 k 時,程式碼創建了一個臨時串列 tmp,然後通過 list_cut_position 函數從原始串列中切下一部分節點,然後反轉這部分節點,最後使用 list_splice_init 函數將它們重新連接到原始串列中。
這種程式碼結構清晰,可讀性強,更容易理解和維護。從軟體工程的角度來看,良好的程式碼風格可以減少代碼維護成本、提高代碼的可讀性和可維護性,從而使軟體系統更穩定、更可靠。
Q: 請說明上述上述程式 對變數「限制作用域」的好處
A: 將變數限制在其實際需要使用的作用域內,可以增加代碼的可讀性和可維護性,有助於減少代碼中的錯誤和問題。這是因為限制變數作用域可以:
Q: 請說明這兩份程式碼的共同優點
A: 這兩份程式碼的共同優點是它們都對原始串列進行了反轉並且在一定的 k 範圍內將它分成了多個小塊進行處理,這樣可以減少整個串列反轉的時間和空間複雜度,並且更方便進行代碼的維護和擴展。此外,這兩份程式碼都使用了 Linux 內核中常見的「鏈結串列」數據結構,這是一種高效、可靠的數據結構,因此在處理類似問題時可以考慮使用這種數據結構。
這回答不是我要的答案,所以我再進行提示。
Q: 你應該針對重用 q_reverse
函式進行論述
A: 這兩份程式碼的共同優點是在 q_reverse
函式上的重用。在第一份程式碼中,當找到一組大小為 k 的節點時,它會切割出這些節點形成一個新的串列,然後調用 q_reverse
函式來反轉它們的順序。在第二份程式碼中,同樣地,當找到一組大小為 k 的節點時,它也會切割出這些節點形成一個新的串列,然後調用 q_reverse
函式來反轉它們的順序。
這樣做的好處是可以減少代碼的重複,使代碼更容易維護和修改。如果將 q_reverse
函式中的邏輯更改或修復錯誤,這將反映在這兩個函式中,從而避免了修改多處代碼的風險。這種重複使用代碼的方法也符合軟體工程中「避免代碼重複」的最佳實踐,有助於提高代碼的可重用性和可維護性。
Q: 雖然 C 語言標準中沒有對 RAII 進行保證,但是請說明 RAII 在此處運用的優勢。
A: RAII(Resource Acquisition Is Initialization)是一種資源管理方式,透過在物件建立時取得資源,並在物件生命週期結束時自動釋放資源,可以避免資源洩漏和重複釋放等問題。
在這段程式碼中,第一份程式碼使用了 RAII 的概念,當需要反轉的節點形成一個臨時的串列時,使用了 LIST_HEAD 宏定義了一個 struct list_head 結構的空頭節點。因為它是在自動變數中聲明的,所以在該變數的生命週期結束時,它的資源也會被釋放。這可以避免在出錯的情況下忘記釋放該節點的資源,從而造成資源洩漏的問題。
相較之下,第二份程式碼沒有使用 RAII 機制,而是在函數中聲明一個 dummy 節點,並在函數結束時手動釋放它的資源。這樣容易忘記釋放資源,並且也不如 RAII 一樣優雅、安全和方便。因此,使用 RAII 可以使程式碼更加健壯、可靠和易於維護。
覺得說沒有必要自己重新想一次,我只需要先去吸收前人的查克拉,參考別人的實驗結果再繼續向前改進。
於是,花了一天時間琢磨 Merge Sort 與它的變化
決定修改過來用。
但是目前遇到一個很嚴重的問題是,C 語言沒有 type decuction
所以在使用 linux 核心風格的 API 時,常常混淆到底現在的資料型態是什麼?而後就要重新推導一次。
如此反覆覺得自己記憶力不好,又容易出錯。
因為根據以前經驗,Jserv 會測驗 sort 的非遞迴時作版本(而故意使我們遞迴實作的 sort stack overflow)
所以在這邊就直接參考非遞迴的 merge sort,而原本該文章的實作是只有 singly-linked list,並且最後的 next 是 NULL
在我們本次作業的實踐中,是 doubly-linked list 並且是 circuler-linked list 首尾相接。
曾經有看過有人的做法是把 doubly-linked list 當作 singly-linked list 來存取。
寫起來應該沒有比較簡單?
對於這點我不確定,要再仔細思考
總之,修改原作的思考如下:
首先是 merge two list
修改過程中有看到 indirect pointer 的做法,但是這邊過於抽象。
我想把改成 indirect pointer 的實作放到未來再改進。
而且用 indirect pointer 的時候會有一個問題需要考慮:
這邊傳進來的是element_t *
,如果使用 indirect poiner 表示:element_t **
會發現
list_entry(L1->list.next, element_t, list) -> 這是一個element_t *
而已
這時候再對他取 reference ???
associate 到的位址會在 stack 上,而不是在 heap 上。
這就不是我們要的那個物件。
所以我認為在這實作 indirect pointer 有困難
這邊同時未來可以把 strcmp
改成 callback 如上面的標示,就可以變成 descending 或 ascending
寫道這邊 code review 又發現問題:
上面這段程式碼的最後兩行:
請問這時候 ptr->list.next 是什麼資料型態?
struct list_head *
那右邊 L1
, L2
是什麼資料型態?
element_t *
喔不, C 語言沒有 strong-type 這樣在沒有 compile 的情況,要發現這邊 type incompatible 不容易
所以應該修正如下:
寫到這邊再 review 一次上面的程式碼,又發現尚有一個缺失:
這會造成無窮迴圈
看到這,對,你發現了。
這麼顯而易見的語意,在這是不行的,因為這是 circuler linked-list …
永遠不會碰到 NULL pointer 的情況,使得這迴圈停止。
這樣的問題,修改如下:
你答對了,重新再看一次又發現問題:
最後一行這有什麼問題呢?
這個 list 還沒有連結反向的 prev 啊!!!
所以要在 if {} else {} 裏面連結好,再把最後一個連回到: ptr->list.next
對,因為 ptr->list 是區域變數,他的 next 才是我們真正合併的頭。
改了幾下,又看到,後面這邊的 list_empty(L1) 似乎有些問題
因為 list_empty 定義如下:
在上面搬動 L1 與 L2 的過程中,會不會影響 head->next 其實永遠不會 == head 的情況?
這件事情一再告訴我們物件導向、模組化的重要性。
寫到這邊還要往前去檢查 list_empty 這判斷的檢查是不是合乎在這樣條件下邏輯。
總之,最後參考了 @alanjian85 改成自己的風格後加上適當註解
接下來,看到 divide_to_sorted 因為觀察到,在 Merge Sort 與他的變化報告中,
這個的效能顯著比其他兩者的好。比快慢指標大約快 4 倍,並且 小很多,不會有巨幅震盪的情況。
目前初次 code review 改寫之後的程式,覺得沒有問題。
然後寫了:
並且寫這些 helper 的總和,回到 q_sort
進行測試:
FATAL ERROR: Calls to malloc disallowed
但是我不想處理不能用 malloc 的問題,想先暫時用 mmap 繞過它。
然後在這目前拿到 65 分
改成 mmap ,然後稍微做以下更動就跑到 71 分。
但是目前有一個問題是 sort 進行第一次執行是正確的,但是當我做第二次 sort 的時候
在 qtest.c 的 do_sort :
這段第 10 行
這邊想問一個問題,我們在稱程式碼 line 的時候都稱「行」
但是根據繁體中文,橫的應該稱作「列」
比如說 command line 應該稱作「命令列」
並且根據 線代啟示錄 :「在台灣,橫向稱為列,縱向稱為行。」
所以這邊是大陸用語的誤用嗎?
這背後是段歷史故事,下次課堂再澄清。英語詞彙的翻譯不該是總是一對一,否則就陷入簡體中文「魯棒性」一類的謬誤。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
好,回到 line 10,
會顯示 segmentation fault 在我的 mmap 版本 sort 當中
解釋一下改成 mmap 的邏輯:
這邊想要做到的根據 Merge Sort 與它的變化 的實驗指出:
divide to sorted 的效能可以是快慢指標的 倍之多
於是採納這樣的實作邏輯:
這段程式碼將一個循環雙向鏈結串列 (head) 分割成多個已排序元素的小鏈結串列。
這些小鏈結串列的大小儲存在 listsSize
中,它是 struct list_head 的陣列。函數 divide_to_sorted 接受三個參數:head
是要被分割的原 list,arr
是用於存儲分割後的小鏈結串列的 struct list_head 陣列,而 len
是一個指向 size_t 變量的指標,將更新為 arr
陣列的大小。
該函數開始時將 sorted
初始化為 head。然後進入一個 do-while 循環,直到 sorted
等於 head 為止(即,我們已經遍歷了整個循環鏈結串列)。
在循環內部,該函數使用 list_for_each 走訪當前子鏈結串列中的元素,從 sorted
元素開始。循環會繼續直到達到子鏈結串列的末尾(所以也就會是遍歷),或者直到遇到一個元素的值小於前一個元素(這意味著子鏈結串列不再是升序的)為止。
一旦已排序的子鏈結串列被識別出來,該函數就在 arr
中創建一個新鏈結串列,並將已排序的子鏈結串列存儲其中。這是通過使用 INIT_LIST_HEAD 初始化新鏈結串列並使用 list_cut_position 從原始循環鏈結串列中刪除已排序的子鏈結串列並將其添加到 arr
中的新鏈結串列中來完成的。
在從原始鏈結串列中刪除已排序的子鏈結串列之後,該函數會更新 sorted
,使其指向剛剛刪除的子鏈結串列的最後一個元素。這是因為我們希望從下一個已排序的子鏈結串列的第一個元素開始下一次循環的迭代。
一旦循環結束,該函數就會將原始循環鏈結串列中的任何剩餘元素(不屬於任何已排序子鏈結串列的元素)連接到一個新的鏈結串列中,並將其存儲在 arr
中。 arr
陣列的大小然後存儲在 len
。
總體而言,該函數實現了將一個循環鏈雙向鏈結串列分割成多個已排序子鏈表的算法。這可以用於排序或分區數據,或者在其他情況下需要分割和處理列表的情況下使用。
linked list 翻譯為「鏈結串列」,不要說「鏈表」,後者實際不是「表」
上面這段我只是請 chatGPT 幫我寫 ><
邏輯如下圖
假如原本的 linked-list 如下:
而經過 divide_to_sorted
會變成
這邊有一個比較特別的實作問題:
原先上面的做法是傳入 element_t *lists[]
, 相當物件導向的寫法
但是會遇到一個問題是對 stack 取 container_of
是 non-sense 的操作;因為物件在 heap 上,但是傳入 the value of the poiner 再取用 address of 會拿到的地址是在 stack 進而推算出毫無意義的記憶體位址,而又型態正常。
所以斟酌後改用 linux kernel 風格,傳入 struct list_head arr[]
作為實體儲存空間。
另外還有一個問題在於,原先我們的 queue
最前頭,要有一個 list_head 作為標示才對
併入 sorted list 之後,我們就可以將 arr[0] … 的記憶體空間,放置「最前頭」的 prev
, next
指標。
而後再看到 interval_merge
這個步驟的核心是一個迴圈,迴圈的運行次數是一個指數數列(1, 2, 4, 8, 16, …),並對應到要合併的子串的大小。每次迴圈處理的是相鄰的兩個子串,先將這兩個子串進行合併,再將合併後的子串放入到輸入的 lists 陣列中的左邊子串的位置(即 lists[i])。這個操作實際上是在不斷地將輸入的子串進行合併,直到得到一個包含所有元素的已排序的 linked-list 為止。
這個步驟的輸入是一個包含多個已排序的 linked-list 的 lists 陣列,listsSize 變數表示 lists 陣列的大小。如果 listsSize 為 1,則直接返回 lists 陣列。否則,執行合併算法,最終返回 lists 陣列。
(src: Merge Sort 與它的變化 )
但是請注意到這邊回傳的 linked list 「頭」會被記錄在 mmap 分配到的空間上,
我們在使用完這份 page 之後,應該要注意歸還記憶體,即便 jserv 提供 hooked-malloc 沒做這檢查,使得我們可以 bypass
所以我們回到 q_sort
:
觀察 trace-03 確實是第二次會爛掉,但是仔細深入發現其實這是一個 general case
當輸入
ih m
ih n
ih a
sort
的時候會壞掉,因為反轉後的串列的頭節點是反轉前的尾節點,反轉後的尾節點是反轉前的頭節點,這樣可能會導致問題。為了避免這些問題,我們需要修復原始串列的頭節點和尾節點。
我們需要調整 head 的 prev 指向 arr->prev 的地址,以及 head 的 next 指向 arr->next 的地址,但不能直接使用 arr 的地址因為它會被 munmap 釋放。
因此,修正如下:
測試案例 (test case) 和測試資料不同,這裡是指前者。
回顧到 list_cut_position
的實際行為會發現:
TO BE EDITED!!!
leetcode 原題目要求:
刪除每個節點,如果其右側的任何位置都存在一個值嚴格大於該節點的節點。
在這裡我們採用 naive 的方式,如果遇到 prev 的字串小於自己,就把 prev 清除。
然後我們看一下 chatGPT 幫我寫的解釋:
這段程式碼是用來對雙向鏈表中的元素進行降序排序的函數。它使用了冒泡排序的思想,從鏈表頭開始遍歷,對於每個元素,它再次從頭開始遍歷,尋找比當前元素值更小的元素並交換位置。具體來說,它遍歷鏈表中每個元素,然後從它前面的元素開始,對比其值,如果前一個元素的值比當前元素的值更大或相等,則停止遍歷,否則就交換這兩個元素的位置,並繼續向前遍歷。這樣,當遍歷完成時,鏈表中的元素就會按照降序排列。該函數返回排序後鏈表的元素個數。
資源截圖:
請注意到語言模型相當有可能在鬼扯,他們只是中文房間(Chinese room)
T 檢定是檢定兩分配的差距,判斷是否獨立同分配(理論上面對相同測資,同一顆 cpu 執行同一段程式碼,執行時間應該要獨立同分配)
但是 Jserv 在這做一個很不合理的操作,他讓兩筆測資不同
class 0 跟 class 1
我們可以看到這段:
設定 input_data
作為使用者的測資,但是在這被 memset 成 NULL
所以這裡永遠會拿到空字串,但是反觀 classes[i] 如果是 1 也就是另外一組被 T 檢定的目標卻是完整的 7-bytes 內容
注意到可能還是會有空字元在內容之中,所以我們不能在這邊說字串長度是 7
所以根據 insert head 需要根據字串大小分配空間,這樣在計算 strlen() 就會是另外一個的約 7 倍時間
嚴格計算的話要算期望值,就 1/256 … 去窮舉累加
所以這樣會讓測試的分配相當容易落入拒絕區。
因為內容預期會有點多,所以開新的紀錄:
Learn More →