contributed by < reberu6
>
目前透過自動評分系統得到的分數是 100/100。
q_size
此函式回傳佇列所包含的節點數量。
實作上使用了list_for_each()
巨集,這本質上是一個走訪每一個節點的 for 迴圈,然後每走訪一個節點就對計數器加1
即可計算節點的數量。若給定的佇列開頭指標不合法或是給定一空佇列,直接回傳 0 即可。
q_new
此函式會創建一個空的佇列並初始化成next
和prev
指標都指向自己。
在queue.h
給定的函式定義中,將struct list_head
的指標作為回傳值,可得知此處需要透過malloc
來分配struct list_head
這個結構所需之記憶體空間(此結構大小則透過sizeof()
取得),才得以在函式結束時仍保留記憶體資訊。分配記憶體也有可能出現失敗的情況,所以在分配完畢時就判斷如果新建的new_list
為空則代表分配失敗則函式回傳NULL
。
q_free
此函式傳入佇列的位址後就會把整個佇列從記憶體中釋放。
處理head
為NULL
:
如果傳入的佇列的head
是NULL
的話就代表佇列本身就不存在而就不需要額外去釋放記憶體,所以就在程式的一開始就判斷。
透過走訪每個節點來進行記憶體的釋放:
原本想說只要使用list_head
類型的指標從頭到尾走訪每個節點並使用free
來釋放記憶就可以了,但是佇列有兩種類型的節點一種是list_head
另一種則是element_t
,所以無法直接操作element_t
類型的節點。後來我想說head->next
會指向element_t
類型的節點,那直接把head->next
指向的位置直接給element_t *
類型的current
就可以直接操作,而後面的操作就是分成兩種狀況 :
head
下一個節點是自己的話代表沒有element_t
類型的節點,所以就直接把head
給釋放。element_t
類型的節點,先暫存current
下一個節點next
等我們釋放完element_t
再把current
移過去,所以一開始就先釋放節點的value
接著釋放節點本身就可以了,最後往下個節點移動。current
是head
節點,也就是把element_t
類型的節點都已是放完畢。進行編譯卻出現 :
程式中第 5 和 7 都出現了指標類型不相容的錯誤,後來我看了指標的介紹發現我之前想的不對,不能直接把
這是因為head->next
的類型是list_head
,而current
所需element_t
的類型,這會導致編譯失敗。雖然head
下個節點的確是element_t
的類型,但是事實上head->next
是連結到element_t
裡面的list_head
結構的list
,程式中的第 7 行也是同樣的錯誤,所以應該做的修正就是把current
跟next
的類型改成list_head
來走訪每個節點。
那現在的問題就是由於current
跟next
類型是list_head
的話要如何操控element_t
類型的記憶體去釋放element_t
中的value
,所以只好創一個新的element_t
類型的指標去接管這個記憶體,但之前說過head->next
所連結的位置是element_t
中的list
,所以沒辦法取得下個節點的起始位置,這時要透過結構的記憶體排列來回推element_t
的位置,例如 :
我們會知道tmp.list
的位置,所以回推到tmp
的位置就會是:
假設tmp.list
的位置是0x1008
的話,那麼0x1008 - sizeof(char *)
則可得到tmp
的位置。具體的計算如下:
原本current
是list_head
類型的指標,但需要先把它轉成char
類型的指標,這是因為在計算位址的時候,若是current - 1
的話會依照list_head
的單位計算,那麼假設current = 0x1008
的話,減一之後也不會是0x1007
而是current
減去一個list_head
的單位,所以若我們先把current
轉成char
類型的指標的話就能正確的計算,這是因為char
的單位是 1 byte。
至於為何是用offsetof
而不是sizeof
去計算偏移量的原因是不同環境和編譯器的原因需要處理對齊的問題所以有些會有 padding,比如說 32 位元的電腦在存取資料時一次都是 4 bytes (32 bits),而使用sizeof
的話就會忽略那個可能性而導致出錯,offset
本身就是用來計算結構體成員的偏移量(bytes),而sizeof
則是計算一個類型的總大小。
q_insert_head
Commit
Commit 修正字串長度超過 4 個字元時記憶體釋放錯誤
Commit 修正指標更新順序,確保正確連結
Commit 精簡程式
此函式會在佇列的最前面插入一個元素 (element)。
佇列不存在 :
一開始就先判斷佇列是否存在比較好,避免後面的操作。
分配失敗 :
這跟q_new
的檢查一樣,記憶體不一定分配就會成功。這裡要注意的是如果分配失敗的話,記得檢查前面有沒有分配的操作,有的話在這裡分配失敗需要釋放掉它。
插入最前面 :
插入最前面只需修改 4 個指標,比如說一開始是 :
然後插入一元素b
head->next
之前,先把新增的元素b
指向原本head->next
所指的地方,比如說a
,避免遺失a
的位置。head->next
指向新元素b
。完成後 :
但向左的連結prev
還是 :
所以需要以下操作 :
a
指向b
,之前先把b
指向a
所指的地方,避免遺失head
位置。a
指向b
完成後 :
字串處理 :
基於要求,需要把字串存入一塊新的記憶體空間。
\0
的特性來計算,只需透過while
迴圈來進行迭代計算長度。for
迴圈要迭代幾次。當元素的value
超過 4 個字元的時候出現重複釋放的問題 :
我在想為何是 4 這個數字,因為程式中根本沒有直接使用到接近 4 的常數,後來才發現應該要直接分配字串長度加上\0
的長度len
,而不需要sizeof
去計算,因為這樣反而是計算len
這個變數型別的大小,len
是int
型別,所以之前每次分配記憶時都只有分配 4 bytes,這就導致了只要插入 4 個字元或以上時就會出現記憶體錯誤的問題。
修正指標更新的順序確保連結正確
這個錯誤是在實做q_insert_tail
時才發現的,我發現順序不對的話是沒法把元素插到尾端的,這時我想起在做q_insert_head
好像沒特別意識到這件事,於是回去檢查後發現了錯誤。在實現q_insert_head
後也有測試,但功能都正常,我想這是因為把head->next
指向b
的話是沒問題的,但被影響的是head->next-prev
,也就是a
的prev
沒指向b
,而是讓b
指向了自己,所以這沒影響到後續要在最前面插入新的元素。在釋放測試時也正常是因為q_free
是透過next
來向右移動,而向右的連結都沒錯的話當然也可以正常釋放了。
q_insert_tail
程式基本上跟q_insert_head
一樣,所以只須想出如何插入在最後面。
插入最後面 :
同樣只需修改 4 個指標,比如說一開始是 :
然後插入一元素b
在尾端,由於需要透過head->prev
存取a
,所以這個指標最後改 :
b
直接指向head
。head->prev->next
讓a
指向b
。完成後 :
但向左的連結還是 :
所以需要以下操作 :
b
指向a
(也就是head->prev
所指的位置)。head->prev
指向b
。完成後,向左的連結就完成了 :
q_remove_head
此函式會移除最前面的元素。
移除最前面的元素 :
比如說一開始是 :
要將b
移除的話,要注意head->next
和head->next->next
的變更順序是較低的,因為分別透過它們存取b
和a
。操作順序如以下 :
a
指向head
。head->next
,所以透過tmp
將b
的位置暫存。head->next
指向a
。b
的next
和prev
指向NULL
。若傳入的sp
非空且成功移除b
:
由於bufsize
的大小限制且最後得放\0
,最多只能複製bufsize - 1
個字元,所以透過for
迴圈複製前bufsize - 1
個字元到sp
。
q_remove_tail
移除最後面的元素 :
比如說一開始是 :
要移除b
同樣也得注意操作順序 :
a
指向head
。tmp
暫存b
的位置。head
指向a
。b
的next
和prev
指向NULL
。q_delete_mid
一開始想到的方法是先計算出總共有幾個節點n
,接著計算出中間節點的位置為第個 (從 0 開始計算),接著透過for
迴圈迭代變更指標current
的位置到中間節點的前一個 (後來在使用快慢指標時發現其實直接移到中間節點也可以,因為雙向鏈結所以進行指標更新時b
不會遺失a
和c
的位置),進行釋放和指標更新。
假如一開始只看向右的連結的話如下:
那透過走訪每個節點可以算出佇列的長度為 3 ,接著要走到a
然後釋放b
,但如果就這樣釋放b
的話會導致遺失c
的位置,所以順序如下:
a
的索引是0
,所以中間節點是b
。b
的前一節點a
。b
的下一個節點c
暫存。b
並把a
指向c
。接下來看向左的連結:
b
已經被釋放了所以沒法從b
到a
,而c
仍指向b
原本的記憶體位置,但如果存取c->prev
會導致未定義的行為。最後只須透過剛才暫存的指標讓c
指向當前位置a
,這樣一來就完成了刪除和正確連結。
完成後沒法通過提交檢查出現了NO dictionary found
,由於它是fmtscan
的執行結果,所以看了lab0-c/tools/fmtscan.c
的內容後發現會用到/usr/share/dict/american-english
和lab0-c/scripts/aspell-pws
,因為aspell-pws
存在所以去看american-english
有沒有存在,發現沒有,後來執行sudo apt install wamerican
安裝完後,有了american-english
就可以了。
快慢指標
參考分析「快慢指標」實現找到中間的點,也了解雖然在演算法上的複雜度都是一樣的,但是根據操作的方式不同讓時間局部性產生差異,這需要了解硬體架構才有可能意識到這點。
q_delete_dup
刪除具有重複的字串的節點,就需要比對的動作,目前是想到用兩個指標來完成 :
a
指向要被比較的對象,另一指標b
則指向比較的對象,如以下示意 :a
,然後b
不斷向右並與a
的字串進行比對,有相同就是刪除元素並更新指標。b
下個是尾端後,a
就往下個元素移動。a
到達最後一個元素就完成了所有的比對,在程式中我使用了while (a != b)
來判斷是因為a
不可能和b
是同個元素,這樣就不需比對了。完成後才發現做錯了,比如輸入[a, a, a]
,結果出現ERROR: Queue has more than 0 elements
,我一開始以為是要實現跟 LeetCode 相同的功能,後來回去看才發現是刪除所有有重複字串的元素,只保留原始佇列中不重複的字串。
在修改的過程中程式的運作一直不如預期,所以使用 gdb 來看過程,結果發現:
雖然ae->value
和be->value
都是"a"
,但是程式碼中ae->value == be->value
的結果卻是false
,這才發現因為ae->value
是char
的指標,所以比較的是指標的地址,所以不管怎麼比都是false
,所以改用string.h
中的strcmp
比較字串是否相等。
完成q_delete_dup
後發現,只要有相同的字串中間有別的字串的話就會出現錯誤,如以下示意:
出現ERROR: Duplicate strings are in queue or distinct strings are not in queue
雖然功能正確但還是出現了錯誤,這裡不確定是queue.h
中的描述錯誤還是檢查程式的錯誤,但這個錯誤並沒有在make test
中檢測出來。
q_swap
q_swap
在實作上只需要修改 6 個指標就能完成兩個點的位置交換。
以下為示意:
需要清楚的知道更改指標的順序,這樣一來就可以只透過一個指標修改。 先把c
指向佇列中的第一個元素a
,接下來c->prev
跟c->next
會比較晚更改,這是分別為了操作head
和b
:
c->prev
把head->next
指向b
。c->next
把b->prev
指向head
。接下來就可修改c->prev
跟c->next
:
c->prev
指向b
,也就是b
在a
前。c->next
指向b->next
,也就是head
前。既然知道c-prev
是b
和c->next
是head
最後再更新一些指標就完成了:
b->next
指向a
。head->prev
指向a
。最後如以下:
接著就是想元素有兩個以上的時候要怎麼處裡可以重複以上的動作,那麼經過以上的操作,可以知道c
只要指向要交換的a
與b
相對位置中的a
就能夠進行操作的話,接著只需要檢查後面有沒有元素可操作「交換」,有的話把c
移到適當的位置即可。
假如新增兩元素x
和y
:
a
的下個元素如果是head
代表沒其他元素則結束函式,否則c
往下個元素移動。所以如果最後有成功移動的話則如以下:c
的下個元素是不是head
,如果不是的話代表有兩個元素足以進行「交換」,這時把此條件當作while
的判斷條件,這是因為c
已經到了跟一開始a
和b
相對位置中的a
,所以透過此判斷可以決定while
的內的交換過程要不要執行。q_reverse
雖然q_reverse
和q_swap
在只有兩個元素時的效果一樣,但其實操作的過程不同,q_swap
在操作的時候會把a
放到b
的後面,而q_reverse
則是讓所有的next
變成prev
和prev
變成next
,達成第一個變最後一個,最後一個變第一個。目前是採用兩個指標達成q_reverse
:
a
指向要被修改的對象,另一指標b
則指向暫存位置的對象,如以下示意 :a
的前一個head
會變成a
的下一個,修改a->next
為a->prev
所指。a
的前一個會是b
,所以修改a->prev
為b
(剛才暫存b
所以不怕 2 修改了a->next
)。next
,下方則是prev
:b
是否在head
的位置,如果不是a
和b
往下移動。b
在head
的位置則結束後可完成所有指標的修改。q_reverseK
雖然q_reverseK
很像q_reverse
,但是會每k
個一組,進行反轉。q_reverseK
的核心「反轉」可以和q_reverse
很像,但需要做些修改 :
原本修改a->prev
的時機點 :
原本是改完a->next
之後接著修改a->prev
,但是如果這麼改的話會發現 :
如果a
已經走到該組的尾端的時候,不應該把a->prev
指向b
,而是前一組的尾端,此時又會有兩種可能 :
a
所在的組別是第一組的話,就得把a->prev
設成head
。a->prev
指向前一組的尾端,所以得暫存前一組的尾端,而前一組的尾端暫存的時機就是在反轉那組時的頭部。能反轉一組之後,接著就只需要算出要反轉幾組後透過for
迴圈迭代反轉每一組。
元素不足以組成一組 :
那如果有些元素不足以組成一組時不須進行反轉,所以就把剛才暫存的尾端指向剩餘的部分的開頭即可(也就是b
)。
q_sort
在想如何排序的時候,我是用暴力法去排序,排完後複雜度是 還沒有達到make test
要求的 ,但想不出來,所以只好查 google 看還有甚麼排序的辦法時才發現我用的是 Bubble Sort 。
暴力法 (Bubble Sort) :
最一開始的想法就是:
a
和b
一開始分別指向佇列中的第一個和第二個元素 :如果發現a
的數字比b
還大就交換位置 :
所以若佇列中有n
個節點且最大的數字放在最前方的話,那麼至少也得比較且移動n-1
次才會到最後方,這部分可透過for
迴圈迭代完成,交換的部分跟之前的q_swap
是一樣的。
n-1
個元素,所以就再對前n-1
元素進行排序,透過head
可以把a
和b
又放在最前方,接著就是迭代直到所有的元素排好 :string.h
裡的strcmp
來達成比較的動作即可。合併排序 (Merge sort)
合併排序概念上是透過分治法(由上而下)來解決,所以透過遞迴來實現的話是很適合,但是若採用遞迴的實現方式的話,會大量使用到 stack 的記憶體空間,尤其是遞迴深度較大的情況,所以採用由下而上的方式,直接處理最小的情況後,接著往上處理較大的情況,但是遇到了許多實作上的問題。
一開始為了實現合併排序,我把q_merge
中的「合併兩個佇列」獨立成一個函式為merge_two
,但想使用這個函式需要傳入兩個有head
的佇列。
原本會傳入兩個佇列的
head
後,沒有輸出(回傳值)但會直接合併到第一個head
的佇列當中。
那麼現在的問題是,由於是採用由下而上的方式,那麼需要把一個佇列切割成n
個佇列之後兩兩合併後往上合併直到剩下一個佇列,這時想到使用跟q_merge
中管理多個佇列的queue_contex_t
的結構,但後來發現若要把每個元素變成佇列的話會需要分配記憶體空間給每個佇列的head
,因為元素的數量是變數,所以我沒法每個元素都手動把head
串上去和用queue_contex_t
來管理,得用for
迴圈迭代幫我達成,但是這樣的話會到用區域變數的head
和queue_context_t
的話,會迭代完後就消失,但是使用分配記憶體空間在q_sort
是禁止的,所以這方法是不可行,最後我直接在傳入的佇列上進行操作。
實現的概念是這樣的,得分成幾個部分來討論 :
那麼如何分辨一個佇列是從哪開始到哪結束?
雖然一開始,每個子佇列都只有一個元素,所以只要用兩個list_head
類型的指標指向兩個元素,再補上head
(為了符合merge_two
的輸入)進行合併的操作即可,但合併後會變成兩個,所以得知道開頭和結尾,否則無法知道head
要如何連結。
假設已經合併完一輪,現在每個子佇列的元素最多為2
的情況,現在要開始新一輪的合併,把要合併的兩個佇列分成左邊和右邊,分別稱為L
和R
,那麼L
的開頭是現在會是佇列中的第一個元素,所以就標記成l_s
。
如何計算佇列結尾的位置?
這時得想到佇列的元素數量變化為1、2、4、8、...
,所以使用
來計算每一輪子佇列元素的數量size
,那麼它不可能會超過佇列的大小n
。最後透過移動指標並計數的方式,就能找到L
的結尾並標記成l_e
,接著R
的開頭會是l_e
的下一個,R
的結尾的定位方式也是一樣,最後會得到 :
實際的操作方式
透過一個指標c
來表示當前位置,等c
走訪完一個整個佇列 (n
個元素) 就完成了一輪的合併,下一輪合併開始c
又會回到最前面,直到size >= n
後就完成了,細節如下 :
c
會在第一個元素(head->next
)的位置 :c
是不是head
,如果是的話代表沒有元素可以被當成佇列,所以結束這輪for
迴圈;不是的話,透過size
計數並移動c
找到L
跟R
的開頭和結尾 :r_s
的位置,因為可以分成三種情況 :L
的元素不足,所以r_s
會停留在head
上。R
沒有元素,所以r_s
會是head
。R
的元素不足,但必定至少有一個所以可以跟L
合併。r_s
是head
的話,就不進行合併。head
。L
跟R
,由於merge_two
會把結果合併到第一個傳入的佇列,所以得到 :l_s
的前一個位置,這樣在合併完後才能透過它和c
把這個合併完的L
插入佇列當中。執行L
插入佇列當中。c
是不是head
,如果是的話代表這輪的合併已經結束了;不是的話,表示還有後續,那麼又會回到 2. ,直到這輪結束,重複好幾輪直到size >= n
後停止。其實在寫這個合併排序的過程中會擔心merge_two
會不會不適用,但後面會發現,佇列有head
是比較好的方式,這樣就不需要傳入佇列的開頭和結尾的位置。
q_ascend & q_descend
q_ascend
和q_descend
核心概念是一樣的,所以先講如何實現q_descend
。
以q_descend
來說,要求元素不可小於後面任一元素,所以得把不符合的部分刪除後,會得到一個遞減的佇列。那麼我的想法是 :
一開始先把指標t, a, b
分別指向最後方三個元素 :
固定t, a, b
的相對位置,a
和b
開始不斷的比較把不符合的刪除,這樣一來可以把b
前方比b
小的刪掉直到b
前方的a
比它大後,接著所有指標往前移,接重複以上的動作後就會得到一個遞減的佇列,如以下示範 :
a
和b
的元素後可分成兩種情況:a
的沒比b
的小 : 那麼t, a, b
往前移即可。a
的比b
的小 : 因為1
比4
小,所以a
所指向的元素會被刪除 :t
把a
指向t
指向的元素,然後t
往前 :a
是head
後停止後完成。那麼q_ascend
的話就會是,元素不可比的前方任一元素還小,否則刪除,那麼我們就把剛才的t, a, b
改成a, b, t
,同樣a
與b
不斷比較,但這次刪除的會是b
,接著的操作核心概念都與q_descend
相同,所以最後會得到一個遞增的佇列。
q_merge
q_merge
會把所有佇列合併且排序後放到第一個佇列,還有就是合併前的的佇列必須是排序好的。雖然有很多個佇列要合併,但可以先考慮核心「兩個佇列如何合併」:
a
和b
:c
當中 :合併的過程的概念是很直覺的,但實際操作過程中會有許多指標的操作 :
head
,這樣一來在走訪一個佇列的過程中可以判斷一個佇列是不是空的了,如果是空的了就直接把c
的佇列的尾端指向另一個佇列即可。c
會是佇列a
的head
,還有剛才 1 的情況,如果另一個非空的佇列尾端不是第一個佇列的head
的話需要改成第一個佇列的head
。a
或b
中的元素搬到c
的話,需要透過 3 個指標,比如說x, y, z
:x
和y
的元素比較完後,把z
指向較小的那個。z
和x
都往下個元素移動比較完後的移動 :
NULL-queue :
在我實現了q_merge
後,發現 :
若我對兩個佇列進行合併後,接著釋放合併好的佇列,會出現有block
沒有被釋放的錯誤訊息,此時我就在想,函式定義說會對queue_contex_t
和它的q
在外部釋放記憶體,所以我是不需要進行釋放的動作,那麼會不會是在合併完後把佇列變成「NULL-queue」這個動作造成的,NULL-queue 的定義我是依照2025 年 Linux 核心設計課程作業 —— lab0 (A)所提到的,所以我把第一個佇列以外的q
指向NULL
,把它指向NULL
那它的佇列位置head
位置不就遺失了嗎?要怎麼在外部進行釋放,所以我抱著此疑問去看其他同學們是如何做的,後來我看到 ginsengAttack 同學的開發紀錄後才發現其實是要把它變成「empty queue」,修改完後測試就通過了。
q_merge
的NULL-queue
處理方式