contributed by < tsaiiuo >
1
在這題所提出的資料結構如下,就是是一個標準的單向鏈結串列。
透過 Graphviz 圖呈現大致如下。
這測驗一中主要是要實做list_insert_before
這個函式,函式的敘述如下:
實做可以透過指標的指標與迴圈去找到放置 before 的指標,並且將此指標指向的指標改成要插入的元素,最後再將插入的元素的 next 指向 before 即可完成list_insert_before
的功能。
AAAA
= &l->head (初始化第一個)
BBBB
= before (透過迴圈尋找存放 before 的指標)
CCCC
= &(*p)->next (指標的指標更新)
DDDD
= item->next (將新的元素的 next 指向 before)
1
延伸問題首先定義了兩個巨集
my_assert
用來判斷回傳 error ,在程式碼中就用來盼對是否 list size 在不同操作過後是否正確, list value 是否在進行完list_insert_before
後是否正確,若不正確的話則回傳參數 message 告訴我們哪裡出錯了。
my_run_test
用來執行測試程式碼,如果有收到回傳的 message 則終止回傳。
函式的部分
list_insert_before
上述介紹過。
list_reset
用來初始化整個 list_item 分別為1~1000,並且將 next 指向 NULL。
list_size
獲取 list 的長度。
test_list
測試程式碼本身,透過判斷執行list_insert_before
後 list value 和 list size 在操作後是否正確,來確認list_insert_before
的功能是否正確。
程式運作脈絡:
呼叫巨集test_suite
執行test_list
,在test_list
中執行一系列圍繞著list_insert_before
的操作,並透過巨集my_assert
判斷是否操作符合預期,若有問題則回傳錯誤消息; 沒有的話回傳 NULL 回傳的結果會給 result ,若 result 不等價於 NULL 代表有錯誤,則輸出錯誤資訊,反之則輸出測試通過。
透過遞迴呼叫的方式撰寫合併排序,並且配合 list_insert_before
等先前定義好的函式,實做測試函式 test_merge_sort
去驗證正確性。
2
這題所提出用來操作的資料結構為 block_t 如下:
就是一個實現二元搜尋樹的類資料結構。
要尋求的答案EEEE
、FFFF
其實也不用懂remove_free_tree
的整個操作就可得出先觀察敘述
可以知道這段程式碼的用意是要找到左子樹中最大的點(也就是左子樹中最右邊的點),因此EEEE
= (*pred_ptr) -> r 、 FFFF
= &(*pred_ptr) -> r,這樣這個迴圈才可以不斷的向右遍歷找到答案。
2
延伸問題首先定義了兩個函式
find_free_tree
應該是個搜尋函式,會回傳指向 target 的指標,在remove_free_tree
中有使用到回傳值被保存在 node_ptr。
find_predecessor_free_tree
應該是個搜尋函式,用來尋找參數 node 的 in-order predecessor。
主函式
remove_free_tree
負責移除二元搜尋樹的指定節點,並且要確保移除後樹還保持著二元搜尋樹的定義。
程式運作脈絡:
先透過find_free_tree
取得要指向預計刪除節點的子樹主要分為三種狀況
狀況1
:左右子樹皆有
這種情況會先透過find_predecessor_free_tree
找到左子樹的 in-order predecessor 而find_predecessor_free_tree
的結果也有可能為 node_ptr 的左子樹本身或 node_ptr 的左子樹有右子樹兩種情況:
若為 node_ptr 的左子樹本身,則先紀錄原先的 node_ptr 的右子樹 在這裡透過 old_right 儲存(因為接下來會透過指標的指標直接更新 node_ptr 指向的指標),接著更新 node_ptr 指向的指標的元素為 左子樹的 in-order predecessor(這裡在程式碼內是*pred_ptr),最後再將更新完後的 node_ptr 指向的元素的右子樹指向 old_right。
若為不是 node_ptr 的左子樹本身,則原先 node_ptr 的左右子樹皆需先記錄起來,理由跟上面一樣,只是因為現在要拿來取代刪除節點不是 node_ptr 的左子樹本身,因此左子樹也須記載(會透過指標的指標直接更新 node_ptr 指向的指標的元素)為新節點做後續的左右子樹更新。
狀況2
:左右子樹只擁有其中一個
直接將 node_ptr 指向存在的那一個的 root 的指標更新即可。
狀況3
:左右子樹皆沒有
直接將 node_ptr 指向的指標更新為 NULL 。
先將上述完整的 remove_free_tree
函式實做出來。
實做 find_predecessor_free_tree
和 find_free_tree
, find_predecessor_free_tree
利用二元搜尋樹的特性利用迴圈配合判斷大小即可找到目標元素, find_free_tree
沒有左子樹的情況回傳 NULL ; 反之,利用找到第一個左子樹並且配合迴圈找到最右邊的子樹就是答案。
撰寫相對應測試,主要利用二元搜尋樹的特性用 inorder_traversal
去拜訪的話會得到由小到大的元素,因為 inorder_traversal
的拜訪順序是 Left->Root->Right 。
3
此題用來操作的資料結構 node_t 如下:
也是具有 Linux 核心風格的 List API 的雙向鏈結串列
先看GGGG
的答案為何,他在rebuild_list_link
的函式中出現,就必須要了解這個函式的用途:
可以看到這個函式的作用是在執行 quick_sort 完後要對 prev 重新鏈結,因 quick_sort 只利用 next 指標進行排序的操作。這邊迴圈的用意就是在根據節點的 next 跟節點進行 prev 的鏈結,並取在最後的時候需將最後一個節點的 next 鏈結到 head ,也須將 head 的 prev 鏈結到最後一個節點,因此GGGG
答案是 head -> prev = prev。
接下來HHHH
->KKKK
就需要了解 quick_sort
函式的實作了,先看向程式碼,一開始先將雙向鏈結串列的結尾 head -> prev -> next = NULL 防止有循環無法中止遍歷鏈結串列, L 和 R 透過 begin[i] 儲存的起點配合list_tal
取得結尾去宣告,並且將第一個元素宣告為 p 也就是 pivot 得簡寫,並且透過迴圈將大與 pivot value 的元素儲存在 right 這個鏈結串列上; 反之儲存在 left 這格鏈結串列上,因此可以先得知HHHH
答案為 pivot -> value ; IIII
答案為 n -> value 。之後根據 quick_sort 的描述可以看到將 begin[i] 更新為 left , JJJJ
答案為pivot , KKKK
答案為right,再執行 i +=2 進行後續的迴圈,透過這個操作可以優先對大於 pivot 的鏈結串列進行分割。在分割到 L = R 時代表分割出來只有一個元素,然後由於我們是優先對大於 pivot 的鏈結串列進行分割,因此這邊的第一個元素會是最大的元素將他與 result 鏈結串列進行連結並且更新 result 為該元素,同時執行 i- - 這樣可以讓迴圈下一次存取相較於本身的次高鏈結串列,若需分割則繼續分割,不需則執行 L = R 一樣的 result 更新,最後則可得到升序順利的鏈結串列,但此時這個鏈結串列只有維護 next 並沒有維護 prev 因此需執行 rebuild_list_link
去重新連結正確的 prev。
3
延伸問題函式:
list_tail
透過迴圈去尋找鏈結串列的尾巴。
list_length
透過 list_for_each
這個 API 去計算鏈結串列的長度。
list_construct
創建一個 value 為 n 的 node_t 並透過 list_add
API 加進鏈結串列裡。
list_free
透過 list_for_each_entry_safe
這個 API 去釋放鏈結串列的記憶體。
list_is_ordered
透過 list_for_each_entry
API 去檢測鏈結串列式經過排序的。
shuffle
實現 Fisher-Yates Shuffle 打亂陣列的元素。
quick_sort
上面已有詳細解釋。
rebuild_list_link
上面已有詳細解釋。
程式運作脈絡:
先使用 malloc 分配陣列的記憶體,再透過迴圈賦予值,之後執行 shuffle
打亂陣列的排序,接著配合迴圈去創建鏈結串列,創建完成之後執行 quick_sort
函式,並執行 list_is_ordered
去檢測是否排序正確,若正確的話則 list_free
; 反之 assert 會中斷程式碼。
1
AAAA
-> FFFF
是圍繞在函式 list_quicksort
裡面,因此須先了解該如何使用 quick_sort 並且使用 Linux 核心 API 來回答問題,程式碼本身先定義了 list_less 和 list_greater 分別保存小於 pivot 和大於 pivot 的元素, pivot 是鏈結串列的第一個點,因此 AAAA
答案是 list_first_entry
,接著需將 pivot 從原先的鏈結串列中移除,在這裡 BBBB
答案是 list_del
,再來透過 list_for_each_entry_safe
遍歷鏈結串列並對每個元素對 pivot 的 value 比大小,將結果透過 list_move_tail
與 list_less 和 list_greater 分別鏈結起來,因此 CCCC
答案為 list_move_tail
,接著透過遞迴呼叫 list_quicksort
對 list_less 和 list_greater 做處理,最後分別透過 list_add
list_splice
將 pivot 和 list_less 鏈結回 head ,最後再將 list_greater 透過 list_splice_tail
鏈結回尾巴,因此 DDDD
和 EEEE
答案分別為 list_add
、 list_splice
,FFFF
答案為 list_splice_tail
,這樣就有升序排序的結果。
1
延伸問題函式:
cmpint
用來比較兩個無號16位元的整數。
getnum
透過三個靜態變數(只會在第一次呼叫初始化一次)進行不同的乘法和算餘數,再將三個變數進行 XOR 運算,是用來生成隨機無號8位元數的。
get_unsigned16
16位元就是兩個 byte 因此透過兩次 getnum
函式配合 |= 運算可以生成一個隨機的無號16位元整數。
random_shuffle_array
是一個 shuffle 函數但不是用標準 Fisher–Yates 演算法實現。
list_quicksort
上述已有詳細描述。
程式運作脈絡:
宣告陣列,透過迴圈賦值,並執行 random_shuffle_array
擾亂順序,使用 list_add_tail
配合迴圈將陣列內的值分配給新的元素插入鏈結串列,在執行 list_quicksort
完後使用 list_for_each_entry_safe
檢查是否元素的值是正確的,主程式中間有錯誤都會透過 assert 中斷。
random_shuffle_array
更改後的程式碼如下:
get_uniform_random
先計算 (i+1) 在最大為 UINT16_MAX 下的最大公倍數 max_usable 並且呼叫原本的 get_unsigned16
但多做一個檢查是是否在 0 ~ max_usable 的值域下,若是的話就 return 代表這次抽樣是公平的; 反之則重新抽取。
random_shuffle_array
就更改為標準的 Fisher-Yates Shuffle。
list_for_each_entry_safe
建構的迴圈中,若將 list_move_tail
換為 list_move
,為何會無法滿足 stable sorting ?因為 stable sorting 的定義是若兩個元素相同,那在經歷排序後他們的順序會符合原始順序。那假設我們採用 list_move
就會發生順序顛倒的情況。
範例:
初始狀況假設以 3 為 pivot
在執行原版list_for_each_entry_safe
建構的迴圈後
list_move
版本
雖然兩種都不影響最終數值排序的結果,但可以看到在 list_move
版本中排序的過程會照成順序顛倒,那在假設有元素相同的存在出現時就無法保證排序完的順序會是他們的初始順序。
2
完整的程式碼:
此函數要回傳前導零有多少個,並透過題目原本的敘述可以得知他要達到遞迴呼叫直到僅剩 2 位元(bits)需要檢查 leading zero ,因此這邊每一層的 upper 他是用 (x >> (16 >> c)); 來進行獲取,而這邊觀察遞迴呼叫的順序是當有 upper 時 c + 1 那可得知 c 的範圍就是0~3 ,分別用來分割 16、8、4、2 位元,並且 lower 也須透過與 mask[c] 進行 & 運算取得該取得,並且當 c = 3時應該要終止,因為分割到2位元了就可以直接查表獲取前導零。
最後透過觀察可得:
GGGG
= 14
HHHH
= 2 ( 002 的前導數為 2 )
IIII
= 0 ( 112 的前導數為 0 )
JJJJ
= 3
KKKK
= 2 (若 upper 為零則前導數為 upper 所關注的位元數,像在這裡 c = 3 代表 upper 關注二位元)
之後 MMMM
-> PPPP
在函式 uint64_t sqrti
裡,首先這個函式是想實做開平方根,而看向函式 (total_bits - 1 - clz64(x)) 這邊是想做取最高位 1 的位置,並且希望 shift 是 4 的冪次方因此還做了 & MMMM
的操作,為了達成 m 為 4 的冪次方,因此 MMMM
的值為 -1,因為 (-1)2 = 11111111 11111111 11111111 111111102,這樣可以幫助他去除最低位元,這樣 m << 的操作的值只會是 2 的冪次方,而 m 又是 1ULL << 2 的冪次方的數,以數學表示為 ,接下來就是逼近法的實作,在迴圈中 NNNN
= 1 透過這樣在運算的結尾才會得到該 m 的平方根, PPPP
= 2,這邊保持 m 四進位的看法下去看很直觀詳細解釋參考
allen chao的解釋
在迴圈中,會將m
加到y
,因為最後的答案應為每次加到y
的m
的開根號,因此每次迴圈均會將y
除以2,讓y
透過位元右移對其中不同的m
值開根號,以 x = 1000 為例
y = 0
y = 256
y = = 128
y = + 64 = 192
y = + = 96
y = + + 16 = 112
y = + + = 56
y = + + + 4 = 60
y = + + + = 30
y = + + + + 1 = 31
這也可以驗證為何一開始要找偶數位元且每次m
要右移2位()而不是1位,因為奇數位所表達的數值無法開根號為整數且不是整數
2
延伸問題在迴圈之後加入以下判斷式即可對向上取整數,代表有剩下的數字,因此直接把 y ++ 即可涵蓋,達成向上取整數的效果。
3
這題是要用 Two Sum 來帶出 hash table 的應用和實作,這邊題目所提出的資料結構如下:
詳細結構設計解釋我覺得老師已經講得很清楚了,這邊直接看到題目本身,
首先先定義 hash
函式,這個函式是利用黃金比例 32 位元黃金比例常數 0x61C88647 與輸入的參數相乘,並保存當作哈希值。
再來看到 find_key
函式,這裡函式得目標是要取得輸入 key 所對應的 hashkey ,在一開始 struct hlist_head *head = &(map->ht)[hash(key, AAAA)];
這個操作是想獲取這個 key 所對應的哈希值的起頭,因此 AAAA
= map->bits ,這樣才能正確獲取 map 的位元的哈希值,獲取哈希值後透過迴圈則可配合 container_of
獲取 hashkey 本身並回傳。
map_get
配合 find_key
回傳 key 所對應的 data。
map_add
用意是要新增一個 hashkey 進入相對應的 map 內, BBBB
= maps->bits ,跟先前的 find_key
一樣是用來取得正確的哈希值,CCCC
= first->pprev ,更新原先第一個元素的 pprev 為西增的元素的 next 指標, DDDD
= n->pprev,直接指向 h->first 的記憶體位址。
map_deinit
這個函式是要用來釋放記憶體, EEEE
= n->pprev ,這段程式碼是要將前一個節點的 next 指向現在要刪除節點的下一個元素,也就是把目前的元素從表上移除。
3
延伸問題程式碼解析上述解釋的差不多了,剩下最後的 two_sum
函式:
透過一個迴圈 O(N) 的時間複雜度,透過 map_get
去檢查有沒有 target - 目前的陣列的值,若有則回傳這兩個數字; 沒有則透過 map_add
新增目前陣列的值,最後透過 map_deinit
釋放整個 hash map 的記憶體,回傳答案。