貢獻者: 梅仁耀
1
考慮一個單向 linked list,其結構定義為:
已知不存在 circular (環狀結構),接下來將對該單向 linked list 進行下列操作:
add_entry
: 新增節點,當 linked list 沒有內容時,必須由開發者更新指向開頭的指標。因此實際得到 reference,而非 copyremove_entry
: 移去指定節點,指向開頭的指標可能因此變更,所以需要用到 a pointer to a pointer (指標的指標)swap_pair
: 交換一對相鄰的節點,取自 LeetCode: Swap Nodes in Pairs,給定 1->2->3->4
,則該回傳 2->1->4->3
reverse
: 將給定的 linked list 其內節點予以反向,即 1->2->3->4
,則該回傳 4->3->2->1
remove(B)
操作完成後,就會變成 A C 的節點關係。特別注意到 B 這個節點其實還存在於記憶體中,只是我們無法從節點間的關係看出來。於是 "remove" 可解讀為 "take away" (將某物帶走)對應的程式碼如下:
參考執行輸出: (第 1 行是換行符號)
請補完程式碼。
背景知識:
貢獻者: RinHizakura
注意圖中的所有 head
是以 main(caller) 裡的 head
pointer 的角度,而不是做為參數的 pointer to pointer 的 head
add_entry
把一個 node 加到 linked list 的尾端:
透過 assert 去檢查 new_node 是否為 NULL(malloc 是否失敗),因此 AA1
= assert(new_node)
assert
的位置需要調整!詳細請閱讀往下的 Issue 1: where to use assertindirect 如果指向的位置上不是 NULL,就往指向位置存放的 node 的下一個 node 指去,直到指到的位置上是放 NULL
AA2
= *indirect = new_node
為了模擬 malloc 出 NULL 的情境,設計了一個總是回傳 NULL 的 MALLOC,假裝配置記憶體失敗。
意料之中,上面 add_entry
的寫法在 assert
前就有透過 new_node->value = new_value
去存取 new_node,因此會產生 Segmentation fault。
Segmentation fault (core dumped)
從 valgrind 偵測的結果來看,問題也確實是如此 (linked_list.c:16 等同上面程式的第 11 行)。
至於如果改成把 assert
接續在 malloc
之後,測試後會得到以下結果。
從兩個結果的差異來思考。首先,如果我們的目的只是希望整個 linked list 的操作符合預期,並且在發生錯誤時 shut down,那麼是不是放不放 assert
都沒關係呢?(反正 dereference pointer 會發生 exception?)
為此我們需要了解 NULL pointer 的一些細節,在 C 語言規格書中對 NULL pointer 的定義是:
6.3.2.3 Pointers:
An integer constant expression with the value 0, or such an expression cast to type void *, is called a null pointer constant. If a null pointer constant is converted to a pointer type, the resulting pointer, called a null pointer, is guaranteed to compare unequal to a pointer to any object or function.
因此,int* ptr = (int *)0;
或者 int* ptr = (int *)(void *)0
都同等於是 NULL pointer。而規格書裡也有提到:
Conversion of a null pointer to another pointer type yields a null pointer of that type.
Any two null pointers shall compare equal.
所以下面的程式會印出 equal!!
。
而 dereference 的操作,在規格書裡如是說:
6.5.3.2 Address and indirection operators
If an invalid value has been assigned to the pointer, the behavior of the unary * operator is undefined
Deference NULL pointer 是 undefined behavior。在 Null pointer 中也提到,在某些系統上 dereference NULL pointer 是可能會繼續執行然而導致非預期的結果的。所以如果想要即時的掌握錯誤,assert
應該接續在 malloc 之後,才能保證即時的檢查到錯誤。
我們在 lab0 的時候,以類似的插入節點的 function q_insert_head
為例,對 malloc 的檢查是用 if
分支,雖然也對 malloc 的失敗做了處理,但與 assert
程式會保持 linked list 的原始模樣但程式不會直接 crash。
那麼在真實的應用上,在甚麼情境下使用哪個方法可能會比較好呢?
assert 的中文是斷言,因此,程式碼的 assert 就像是在對閱讀程式碼的人說 「這一行 statement 必定是這種情況」,當出現程式撰寫者預期外的情況,則必須要回頭來檢查錯誤。因此,assert 基本就是一個比較粗糙、但方便的 debug 工具,我們可以在編譯中加上 -DNDEBUG
將其變成 no operation。
使用 if 檢查的情況,則比較像是我們預期使用程式的人可能誤用(錯誤的參數等),程式可以容忍使用者犯傻,告訴他 「這個操作不合法喔」,然後維持正確的狀態讓程式往下執行。
因此,結論來說,以上面的程式而言,如果我們的目的只是希望在發生錯誤時 shut down,考慮到 dereference 也有可能不會發生 exception,所以 assert
的存在仍然是必要的,至於 assert
該放在原本的位置或者接續在 malloc
之後,以此為目的的話都可以。但是如果我們想要在出錯時可以直接抓到錯誤之處,因為 Segmentation fault 並不會直接反映出錯的地方,或者考慮到使用 assert
的邏輯的話,我會認為接續在 malloc
之後才是正確的。
find_entry
從 head 開始,逐步尋找符合儲存的值為 value
的 node。
remove_entry
移除目標的 entry。pointer to pointer 的使用手法類似前面,假設初始為下圖,而 head
是 A,entry
是 C。
entry
B->next
下的節點換成 D,於是就變成了entry
指向的節點 C 釋放掉,大功告成swap_pair
將節點兩兩交換(第 0 個跟第 1 個交換、第 2 個跟第 3 個…)。下面以前兩個 node 的操作來解釋 swap。
node_t *tmp = *node
BB2
會是 *node = (*node)->next;
。注意到因為交換的是節點的位址,因此第一個 iteration 的這一步會把 head 的位址變成 B 的位址,也就是讓 head 指向變成開頭的 B。tmp->next = (*node)->next
(*node)->next = tmp
。node = &((*node)->next->next)
根據 C 語言規格書
&
(address of) 是屬於 6.5.3 Unary operators
->
則是屬於 6.5.2 Postfix operators又在規格書第 76 頁中有提到:
The syntax specifies the precedence of operators in the evaluation of an expression, which is the same as the order of the major subclauses of this subclause, highest precedence first.The exceptions are cast expressions (6.5.4) as operands of unary operators
(6.5.3), and an operand contained between any of the following pairs of operators: grouping parentheses () (6.5.1), subscripting brackets [] (6.5.2.1), function-call parentheses () (6.5.2.2), and the conditional operator ? : (6.5.15).因此在章節比較前面的
->
優先權會大於&
,所以&((*node)->next->next)
和node = &(*node)->next->next
等價也可以透過下面的程式來驗證這點。
得到結果:
reverse
將 linked list 倒置。一開始 linked list 大概如下圖:
node_t *next = head->next;
head->next = cursor; cursor = head
head = next;
。後面就重覆前面的步驟,把 linked list 所指的方向逆轉。最後 cursor 會指向原本 linked list 的最尾端,也就是新的 head
,return 之。delete_list
為了方便做實驗時更新 linked list 且避免 memory leak,實作一個釋放 linked list 上所有節點的 function。
swap_pair
由於原本的 node
已經是在對 pointer to pointer 做操作,所以可以簡單的改寫 swap_pair
成:
考慮到我們的 linked list 結構比較簡單,node 裡只有下一個的位置和儲存的數值,這裡有另外一個也可以做到結果相同的 swap 方法。我們可以只是把節點中的 value
做交換。
我想要知道哪個方法可能運行的比較快,所以設計了一段測試程式。考慮到 cache 可能的影響,所以每次 swap 的都是一個新的 linked list,並且透過 taskset
命令僅在單核上運作。
考慮到可能的誤差,因此我通過簡單的統計方法 standard deviation around the mean 來分析運行時間(詳細參閱 GitHub)。得到如下的圖:
原本我預期交換數值的方法會比較快,畢竟不用調整 next 指去哪,又可以通過 bitwise 運算提升交換的效率。然而在圖中驚訝的發現,兩個方法的快慢其實沒有顯著的差異。
初步猜測是 XOR 裡的 branch 判斷導致。因為可以確認自己對 XOR 的使用是對於兩個相異的 object(粗淺的說,不同的變數),因此我們把程式調整為沒有分支的 XORSWAP_UNSAFE
,然後得到的結果是:
感覺好像真的是 branch 產生影響?於是我試圖用 perf 去觀察使用 XORSWAP_UNSAFE
和 XOR
的在 node 數量 2000 時的 branch misses,
XORSWAP
XORSWAP_UNSAFE
從結果來看,XORSWAP_UNSAFE
的 branch miss 雖然看似少一些,但兩者的差異並不大。雖然也有可能是因為圖中的時間是用 ns 等級,所以看起來會差距比較明顯,但是如此接近的 branch miss 不該是導致如此差距的原因。為了可以得知更多的細節,我嘗試使用 Cachegrind 檢查記憶體的實際情況:
XORSWAP
XORSWAP_UNSAFE
觀察一下兩者的差異:
17,116,037 - 17,109,037 = 7000
與 swap_pair_by_value
的 55,014-48014 = 7000
相等8,303,820 - 8,298,820 = 5000
與 swap_pair_by_value
的 41,005 - 36,005 = 5000
相等仔細一想,就發現其實自己把問題複雜化了,因為 XORSWAP_UNSAFE
的指令會比 XORSWAP
少,不需要讀取記憶體位址檢查是否相同,所以可以執行的快一些原本就十分合理。而當節點變多,指令的差異量就會表現在時間上。
設計實驗時,應考慮 linked list 節點結構中的資料可能很多項,成員可能還指向其他記憶體空間。
reverse
概念和原本的 reverse
並無太大差異,只是為了確保 head
最後會是指到原本 linked list 的尾端,把 while 迴圈中的判斷條件條件調整成判斷下一個是否為 NULL。
reverse
整體的概念和前面的 reverse 相同: 首先改變現在的 node 的指向,把它往前指。如果下一個要被調整的 node 為 NULL,表示已經找到原本 linked list 的尾端,因此要把 head 更新到該位置上並且 return。否則就繼續往下 recursive。
Fisher–Yates shuffle 是用來將一段有限的數列做隨機排序(排列的結果是等概率的)的演算法。
最原始的方法是由 Ronald Fisher 和 Frank Yates 手寫的,大致的概念如下:
隨機數範圍 | 選擇隨機數 | 原始序列 | 產生序列 |
---|---|---|---|
1 2 3 4 5 |
隨機數範圍 | 選擇隨機數 | 原始序列 | 產生序列 |
---|---|---|---|
1-5 | 3 | 1 2 3 4 5 | 3 |
隨機數範圍 | 選擇隨機數 | 原始序列 | 產生序列 |
---|---|---|---|
1-4 | 4 | 1 2 |
3 5 |
考慮到在計算機上的使用,Richard Durstenfeld 提出了改進。因為原始方法 「 從左開始數的第 n 個數字」這個過程涉及對原始 array 的額外調整,所以時間複雜度會是 ,這裡的時間複雜度則是 。此外,不需另外產生一個儲存的序列(inplace),而是透過 swap 的方式達到同等的效果。
這裡的時間複雜度當是以 array 儲存的前題下,考慮到資料結構的差異可能也會有複雜度的差異,必須思考自己使用的資料結構給出最恰當的演算法。
隨機數範圍 | 選擇隨機數 | 原始序列 |
---|---|---|
1 2 3 4 5 |
隨機數範圍 | 選擇隨機數 | 更新後序列 |
---|---|---|
1-5 | 3 | 1 2 5 4 3 |
隨機數範圍 | 選擇隨機數 | 更新後序列 |
---|---|---|
1-4 | 2 | 1 4 5 2 3 |
new
作為新的 linked list 的 dummy node
new_head
總是指向新 linked list 的頭,這是為了回傳時更新 headnew_tail
總是指向新 linked list 的尾端,這是為了重新 append 的時候不需要從頭找起n
,找到第 n
個 node,拆下來 append 到新的 linked list 上len == 0
)延伸問題: