contributed by < thwuedwin
>
這個測驗主要考察對指標操作的熟練度。目標是實作 list_insert_before
函式,它的功能是在鏈結串列中找到指定的節點 before
,並將 item
插入到它的前面。
該函式會接收以下參數:
l
:指向鏈結串列的指標before
:目標節點item
:要插入的節點實作方式是從 l->head
開始遍歷鏈結串列,直到找到 before
,然後將 item
插入至其前方。
不使用間接指標的程式碼
這個版本直接使用 p
作為普通指標,在找到 before
之前,持續向後移動 p
,最後修改 p->next
來完成插入。
使用間接指標的程式碼
這個版本改用指向指標的指標 p
,直接追蹤 next
指標的位置,這樣在插入 item
時,不需要額外處理 p->next
,程式碼更加簡潔。
題目 | 答案 |
---|---|
AAAA | &l->head |
BBBB | before |
CCCC | &(*p)->next |
DDDD | item->next |
優雅之處
不使用間接指標的版本雖然可行,但需要處理 p->next
,而使用間接指標的版本則巧妙地利用 p
直接指向 next
指標的位置,使得:
*p != before
更直覺,不需要額外比較 next
*p = item
直接修改指標,不需要額外處理 p->next
這樣的設計讓程式碼更加簡潔,減少了變數存取的間接層級,提高了可讀性和可維護性。
尋找中間節點(第 5~15 行)
使用快慢指標來找到鏈結串列的中間節點。
由於此實作使用的是單向鏈結串列,無法直接回溯,因此我們額外使用變數 before
記錄 slow
前一個節點,以便切斷鏈結,將其拆分成左右兩個子串列。
示意圖如下:
遞迴排序子串列(第 17~18 行)
將拆分後的 left
和 right
子串列遞迴地進行合併排序。
合併兩個排序好的子串列(第 21~35 行)
此次實作也善用了我在第 2 週測驗一學習到的想法,用堆疊的區域變數來管理暫存的節點。
根據函式名稱 remove_free_tree
,我推測它的目的是尋找可用空間並將其從 free tree 中移除。然而,我一開始無法理解 find_free_tree
的具體功能,尤其是它的回傳型態竟然是 block_t **
,這點讓我感到困惑。
在仔細閱讀程式碼後,我發現 remove_free_tree
的主要操作是更新變數 node_ptr
,並根據 *node_ptr
子節點的數量,選擇不同的方式來更新它。此外,並且該函式的初始化方式是 find_free_tree(root, target)
。基於以上觀察,我推測這個函式的作用如下:
find_free_tree(root, target)
在 free tree 中搜尋 target
,並回傳指向 target
之親代節點指標的指標。例如,若 node->l == target
為真,則回傳 &node->l
。該回傳值由 block_t **node_ptr
接收,這樣一來,只要更新 node_ptr
,便能直接修改親帶節點的指向。這種設計相當聰明且優雅。如示意圖:target
子樹的結構,決定用哪個節點來取代 target
:
target
有兩個子節點,則選擇左子樹最右側的節點來取代 target
。target
僅有一個子節點,則讓該子節點直接取代 target
。target
沒有子節點,則僅需將其移除。target
之前,將其子節點指標設為 NULL
,避免產生錯誤的參照。尋找左子樹最右節點的程式碼
題目 | 答案 |
---|---|
EEEE | (*pred_ptr)->r |
FFFF | &(*pred_ptr)->r |
目標是實作一個以 free tree 管理可用空間的記憶體分配器,目前實作尚未完成,以下是 ftalloc.h 程式碼:
ftalloc
和 ftfree
函式是預計提供給使用者呼叫的,功能應該與 malloc
和 free
相同。init_free_tree
。init_free_tree
會使用 malloc
預先配置預設為 BRK_SIZE
大小的空間,作為初始可動態配置的記憶體空間。該區塊會由 free tree 管理。brk_free_tree
來增加可配置的空間並將新增的空間加入 free tree,預設擴展大小為BRK_SIZE
。free_free_tree
來釋放由 init_free_tree
和 brk_free_tree
分配的記憶體區塊。ftalloc
時,remove_free_tree
會在 free tree 中尋找適當的空間,將該區塊從 free tree 移除,並維護 free tree 的結構與完整性。詳細見上方程式碼分析。ftfree
時,insert_free_tree
會將釋放的空間重新加入 free tree,恢復其可用狀態。困難點
memory_t
結構至少佔 28 位元組,若每個 4 位元組的空間都由一個 memory_t
來管理的成本太高了。page
所儲存空間的特定大小及其內部的 free list。對於每個 page 內的 free list,我打算使用更高效且成本較低的資料結構,例如 bit map,來儲存每個區塊的使用狀態。此測驗快速排序法的方式雖然不是使用遞迴實作,仍然是採用了分治的想法。
right
串列,反之加入 'left' 串列。最終形成三個子串列,分別由 left
、pivot
和 right
所指向。right
、pivot
和 left
指向的串列是否需要排序,此演算法會先優先處理最右邊的串列。流程比較複雜不好簡單解釋,直接參考程式碼:
第 3 行的 if(L != R)
判斷該串列是否包含兩個以上的元素,若是,則需排序。分類後會產生三個子串列,因此做 i += 2
,代表下一輪處理最後新增的串列。而else
對應到串列僅有一個元素,表示已經排序完成,直接合併到 result
內。
為何 max_level
的設值為 ?
我思考流程是,考慮當串列大小為 ,且原先已排序(升序)時,每次選擇樞紐值都會選到當前最小的元素。因此:
left
為空pivot
僅包含該最小值right
包含剩餘的 個元素由於該實作不是穩定排序,下一次迭代會選擇 right
的最大值作為新樞紐,接著又選擇最小值,如此交替進行。結果是:
right
為空result
left
或 right
時是有尾端插入,可以利用這個特性建構串列。舉實際例子,假設有一串列順序為 [1 3 5 7 8 6 4 2]
,則排序過程為[] [1] [2 4 6 8 7 5 3]
[] [1] [] [2] [3 5 7 8 6 4]
[] [1] [] [2] [] [3] [4 6 8 7 5]
題目 | 答案 |
---|---|
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〉
大 O 表示法與實際效率
雖然在理論上,排序演算法的效率通常以 表示,但由於常數因子的影響,只有在 足夠大時, 的演算法才會比 的演算法顯著低效。本研究的實驗範圍為 ,因此可從結果觀察不同演算法在實務上的表現。若在 的區間可能會有不同的結果。
從下表可見,在時間複雜度為 的排序演算法中,二元樹排序法(Tree Sort)的效率優於快速排序法(Qsort)及合併排序法(Msort)。
常數因子分析
根據表格 1 和表格 2 的數據,可透過理論時間複雜度擬合出各演算法的常數,結果整理於表格 3。
表格 3 中的 ratio 欄位 代表當 與 時,對應的時間常數比值:
這些常數也可用來評估不同演算法之間的效率差異,提供量化的效能對照。
此外,值得注意的是,本研究的測試方法是對同一組輸入進行多次實驗後取平均值。換句話說,輸入串列的排列方式可能僅限於少數幾種固定模式,這可能導致比較結果的不公平性。例如,雖然合併排序、快速排序和二元樹排序在平均時間複雜度皆為 ,但快速排序與二元樹排序在最差情況下可能達到 ,而合併排序的最差時間複雜度仍維持在 。然而,本文並未深入探討這些最差情況下的差異。此外,該研究僅以執行效率作為評估標準,並未考量演算法是否為穩定排序法(Stable Sort)或原地演算法(In-place Algorithm),這些因素在實務應用上可能同樣重要。
根據前述的實驗結果,二元樹排序(Tree Sort)在效率上表現較佳,因此我選擇實作此演算法。
二元樹排序的核心概念是:
插入二元搜尋樹的平均時間複雜度為 ,最差情況下則為 。由於共需插入 個節點,整體排序的平均時間複雜度為 ,而最差情況下可能達到 。
樹狀結構如示意圖:
實作
排序函式 treesort
是主流程,此外,我實作了 bst_insert
和 bst_traverse
兩個輔助函式來處理二元搜尋樹的建立與遍歷。
主排序函式:treesort
treesort
會遍歷鏈結串列,將節點依序從原串列刪除後插入二元搜尋樹,最後透過 bst_traverse
將二元搜尋樹轉回排序後的鏈結串列。
插入函式:bst_insert
bst_insert
會將給定的節點插入二元搜尋樹。透過遞迴尋找適當的插入位置,並使用間接指標來處理二元搜尋樹的根節點與子樹的連結關係。
if (root_element->value > node_element->value)
這個條件確保二元搜尋樹遵循穩定排序的特性,。
遍歷函式:bst_traverse
bst_traverse
會 以中序遍歷(In-Order Traversal) 的方式走訪二元搜尋樹,並將最小的節點依序插回鏈結串列,達到排序的效果。
此測驗實作的是遞迴版本的快速排序,採用鏈結串列的方式進行排序。
list_less
,其餘則加入 list_greater
。list_less
和 list_greater
進行排序。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 變數。例如,我原本可能會寫出以下動態配置的版本:
這種做法需要額外處理記憶體釋放 (free),並且因為變數的生命週期變長,必須在額外的地方確保值的正確性,這可能反而會降低程式的可讀性和安全性。
相比之下,若改為區域變數,則可以利用遞迴的堆疊行為自動釋放記憶體,提升一致性與安全性:
這樣的設計方式讓變數的生命週期受到更好的控制,避免了手動管理記憶體可能帶來的錯誤。
快速排序的穩定性主要取決於節點加入 list_less
和 list_greater
的方式。
list_greater
,確保相同數值的元素仍維持原本的相對順序。list_move_tail
來保持排序的穩定性:這樣的做法確保了排序的穩定性,即原本相同數值的節點在排序後仍維持其相對順序。
random_shuffle_array
qtest
並使用 perf 進行效能分析整合 qtest
qtest.c
console_init
函式內新增- 實作 `do_qsort` 函式,並呼叫對應的快速排序函式 `q_qsort`
queue.c
q_qsort
函式以 perf 進行效能分析
我實作了一個函式來讀取輸入檔案,該檔案包含 1024 個長度為 8 的字串。測試函式會將這些字串依序插入鏈結串列,並根據命令列參數選擇相應的排序方法:
執行結果如下:
目前尚未詳細分析兩者的效能差異,後續將進一步探討如何進行合理的比較,並補充更詳細的測試與分析結果。
此測驗涉及 clz
(counting leading zero)和整數開平方根 sqrti
的實作。
clz
分析clz
的目標是計算數值的 leading zeros 的數量。實作方法是將數值拆分為上半部(upper)與下半部(lower),並根據以下邏輯進行遞迴計算:
• 若 upper
為零,則遞迴計算 lower
的 leading zero,並加上 upper
佔據的位數。
• 若 upper
不為零,則遞迴計算 upper
的 leading zero。
思路
由於遞迴的流程較難直觀理解,因此我先找出初始條件與遞迴結束條件。
根據巨集定義 #define clz32(x) clz2(x, 0)
,推測 x
為目標數值,而 c
則記錄目前處理的階段。
c == 0
時,處理 32 位元數值c == 1
時,處理 16 位元數值c == 2
時,處理 8 位元數值c == 3
時,處理 4 位元數值逐行分析 clz2
upper
取得 x
的上半部位元。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 的存在談開平方根的快速運算
理論分析
設 為正整數,目標是求出 的平方根值(以整數表示)。
其中, or 、。
假設最高位為非零元素為第 k 位,亦即, for 。我們有
又對於任何 ,。於是對於第一個 ,必須把 設成 。否則就算把第 k 位以下都設成 ,對於估計 還是太小。我認為這是一個不嚴謹但相對直觀的解釋,搭配實作可以很好的幫助理解。
因此演算法可以以這個方法實作
定義 為實際值和逼近值的誤差,其中 ,可以縮減計算過程。
實作
此程式碼可以直接對應到上述理論。m = 1ULL << shift
是將初始的 設定為 。若 y
記錄 ,b = y + m
為 。不斷重複計算直到 。
題目 | 答案 |
---|---|
MMMM | ~1 |
NNNN | 1 |
PPPP | 2 |
我一開始打算往數學的方式思考。正如前面提到的,此實作會比較 和 的大小,若小於 則加入。但如果要做向上取整就比較困難,向上取整代表勢必需要 ,但很難知道到底需要大多少。於是我認為這個方向行不通。
我改成從實作著眼,若非完全平方數則需要加 ,完全平方數則不需要。我的思路轉為去思考完全平方數和非完全平方數的差別。從理論思考,我猜測在迴圈結束的時候,若為完全平方數 x
應該要為 。於是我做了初步的實驗,我將 到 帶入,確認函式正確並且檢視函式結束時的 x
值。發現結果和我猜測的一樣:
但我認為在接近無號整數最大值附近會出問題於是我在 sqrti
的最後加了一行 assert(x == 0)
,並只傳入完全平方數。測試函式如下:
但是 assert 報錯了,我目前測試到 都沒有問題,還沒找到最小會報錯的數字,原因要進一步分析。至少確定在 要實作 僅需要在 sqrti
函式的最後加上:
sqrtf
此測驗涉及雜湊表(hash table)的實作,並在發生雜湊碰撞(hash collision)時,將碰撞的兩個值用鏈結串列的方式串接起來。以下是程式碼和示意圖:
從程式碼中可以看出,雜湊表使用的鏈結串列與一般的雙向鏈結串列稍有不同,主要有以下兩點差異:
pprev
省略,可以節省空間。因為雜湊表在建立時會創建大量的 hlist_head
,其中有一部分可能完全不會使用到。pprev
是指向前一個節點 next
屬性的指標,這使得在刪除節點時更加方便。Two Sum 實作
此測驗關於 LeetCode Two Sum。目標是高效率(O(N))的尋找兩個數字,使它們的和為指定數字 n
。為了達到這個目標,會構建一個雜湊表 ht
。當現在搜尋的數字為 k
時,會檢查 ht[k]
是否存在。如果不存在,則建立 ht[n-k]
;若存在,則表示找到所求的組合,並從儲存的資料中取出。
因此,資料結構如下:
其中 key
是 的雜湊值,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 |