contributed by < NOVBobLee
>
Two sum 若以暴力法,則時間複雜度為 。若改以使用湊雜表,可降低時間複雜度到 以下。以下為使用 Linux 湊雜表結構的湊雜表實作,拆開解釋,最後利用他來完成 Two sum 。
Linux 的湊雜表使用兩種結構,一為 hlist_head
,為湊雜表 bucket 的開頭,是單向鍊結結構,二為 hlist_node
,為 bucket 裡的元素連結,是雙向鍊結結構。為何不使用通用的 list_head
,其原因在湊雜表為減少碰撞發生,會把 bucket 的數量增大,且不少 bucket 是空的,若使用雙向鍊結結構,一個開頭就需要兩個指標的空間。第二是如果碰撞變少,那麼 bucket 裡的元素數量也不多,不需要像環狀雙向鍊結結構可以從尾部或從頭部兩種方向走訪。
為使 hlist_node
的插入刪除方便,其使用雙向鍊結結構。但是 bucket 的開頭與元素連結卻是使用不同的結構,為使雙練鍊結結構與單向鍊結結構結合,且插入刪除不會因為前一個是開頭而需要新的分支, hlist_node
改使用的 pprev
,指標的指標,非常漂亮。
而湊雜表本體為 map_t
,其包含多個 buckets ( ht
),而其數量會使用巨集 MAP_HASH_SIZE
來決定,固定為 的冪。為何存 bits
而非實際數量?是因為湊砸函數 hash
頻繁使用到,而實際數量只有在建立和釋放湊雜表的時候用到( 2 次)而已。
輸入為 bits
,代入剛提到的巨集 MAP_HASH_SIZE
會得到湊雜表 bucket 要配置的數量。
湊雜表元素 hash_key
,包含一個 key
,拿來定位 bucket 的位置,一個 data
,存放資料的地址,和 node
,與湊雜表連結用。
如果我們從 bucket 的開頭出發,得到的會是 hlist_node
,這樣要怎麼找到包裝他的容器 hash_key
?其方法是使用下面的巨集 container_of
,在得知 hlist_node
在 hash_key
裡的位移量後,可以反推得到容器的開頭位置。
湊雜函數則是使用以下的函式 hash
,用來取得 key_hash
的 key
值,其使用的是 Fibonacci hashing 。可以看到我們 buckets 的數量用 的冪,取 modulo 可以直接使用 bit-shift ,比起使用 modulo 任意數更快。
hash_key
輸入 key
找尋相對應的 hash_key
。
key
對應的資料用 find_key
搜尋 hash_key
湊砸表元素,找到則返回裡面存的資料 data
,否則返回 NULL
。
此函式為插入新的湊雜表元素用,若 key
值已經被使用,則失敗,必須使用沒使用過的 key
值。
這邊出現 AAA 與 BBB 填空,在建立新的湊雜表元素後,我們要將其加入到湊雜表中,可以看到 h
是湊雜表的 bucket , n
是剛建立的湊雜表元素的連結 node
,也就是說要把 n
加入 h
中。
要幫 n
作連結卻沒看到 n->next
與 n->pprev
的賦值,所以這兩個填空應該與剛提的兩個賦值相關。
然後 first
是 bucket 的第一個元素,加上第 18 行 h->first = n
,所以 n
是要插在 bucket 第一個的位置。又在第 17 行 first->pprev = &n->next
會使用到 n->next
的值,所以 AAA 應該是 n->next = first
。
那麼剩下的 BBB 就是要完整雙向鍊結結構,將 n->pprev
指到 h->first
的位置上,即 n->pprev = &h->first
。
遍歷湊雜表的 buckets 和 bucket 裡的元素,一一釋放空間。
看了一下插入刪除元素的操作,沒看到會有 if (!n->pprev)
為真的情況。待查。
給定一數 target
,在給定數列 nums
中找尋兩個數相加的和為 target
的組合。
延伸問題:
給定一個已排序過的單向鍊結結構,移除重複值的元素,如 1-1-2-3 變成 2-3 。
程式碼可以看出是使用遞迴,然後遞迴的結束是回傳 NULL
或者返回 head
。再來看第二個 if ,若 COND1 為真,則會進入 remove all duplicate numbers
的區域,所以可以推測出 COND1 應該跟檢查重複值有關,而且檢查重複值元素的第一個,所以 COND1 應該是 head->next && head->val == head->next->val
。
然後繼續推測他是如何移除重複值元素,試想一個例子, 1-2-2-3 會變成 1-3 ,其中 1, 3 要作連結。在將這例子套進程式碼, 1->next
會進入 2
的遞迴,等待回傳 3
的指標。而 2
的遞迴會進入 COND1 的區塊,這時要把 head
要移到哪裡,代入 head->next
才可以返回 3
的指標?因為返回只有兩種, NULL
和 head
,所以剛才要帶入的 head->next
應該就是 3
的指標。也就是說 head
應該要移到 3
的前一個元素, 2
,那麼 COND2 區塊就是要把 head
移到重複元素的最後一個,那麼應該填 head->next && head->val == head->next->val
。
遞迴方式:走訪每一個元素,若有重複值的元素,則尋找下一個不重複值的元素,傳回其指標,跟第一個重複值元素的前一個元素相接。若沒遇到重複值元素,則照原連結方式連接。
改寫成迭代方式,原理跟上面方法一樣,只是我們不能用遞迴的 stack 紀錄前一個不重複值元素的指標,所以加入間接指標 dup_pprev
代替。
延伸問題:
使用類似 Linux list_head
雙向循環鍊結結構的版本(假設 head
沒有 val
值,只作雙向鍊結結構的開頭):
在 cache 上的管理,因為空間是有限的,所以我們會希望有方法可以符合需求和效率,知道哪些 cache 該留,哪些該捨棄。而 Least Recently Used, LRU 為其中一種,若某一個 cache 很久沒有使用了,那可能之後也不會再使用了。
從結構上看來, LRUCache
有一個湊雜表 hheads
和一個雙向陣列結構的開頭 dhead
,有 cache 的上限數量 capacity
和已使用的數量 count
。而 LRUNode
則是 cache 的單元,包括拿來湊雜搜尋用的 key
和 cache 儲存的內容 value
,還有分別對應 hheads
和 dhead
用的元素 hlink
, dlink
,其連結方式如上圖。
給定 cache 得容量 capacity
,然後依照其值配置對應的記憶體。最初還沒有使用 cache ,所以 count
會是零, dhead
, hheads
分別指向自己,代表還是空的。
從結構上來看,要移除 cache LRUCache
需要先移除其元素 LRUNode
,其連接方式有兩種,若要找到所有元素最快的方法是從 dhead
遍歷。而 dhead
與 dlink
相連,利用 list_entry
可以找到對應的 LRUNode
,且我們會移除 dlink
,所以 MMM1 要使用 list_for_each_entry_safe
。
當要讀取某特定的 cache 的時候,需要給該 cache 的 key
值,使用湊雜表快速搜尋該 cache 的位置,找到則回傳 cache 儲存的內容 value
,否則回傳 。過程中因為要使用 LRU 方法,所以會需要排列雙向鍊結結構的順序,
找到 bucket 位置,然後遍歷該 bucket ,依照 LRU 的規則,遍歷方向是 next 的方向。遍歷的變數是 lru
,型態為 LRUNode
,加上只要查看,沒有要移除節點,所以 MMM2 應該為 list_for_each_entry
。
輸入資料 value
和對應的 key
值,將這些內容存入或更新對應 key
值的 cache 的元素 LRUNode
。但是就如剛才所提的,這些元素是有限的,所以當我們是存入(沒有對應 key
值的 cache 元素),遇到已使用的元素數量 count
到達上限 capacity
時,我們需要利用 LRU 的規則,把最久沒有使用的 cache 元素給移除,然後再插入新的元素。而如果是更新,那只需要移動其元素在雙向鍊結結構裡的位置,以符合 LRU 要求。
就上述所說, MMM3 的區塊是更新元素的區塊,所以會遍歷對應 bucket 裡的元素,依照 LRU 規則,方向為 next 。而 lru
為 LRUNode
結構,移動完元素位置後就停止遍歷,所以 MMM3 為 list_for_each_entry
。
而下一個區塊會檢查 count
與 capacity
,可以知道是要存入元素。而如果相同,根據 LRU 的規則,那就要先移除最久沒有使用的元素,在雙向鍊結結構的末尾,所以 MMM4 應該為 list_last_entry
。移除後將要存入的資料更新上去,然後在加回到 lRUCache
中。
延伸問題:
給定一未排序陣列,找出最長連續數列的長度,比如 1-2-5-3-7-8 ,最長連續數列為 1-2-3 ,所以長度是 3 。此處使用湊雜表,在建立完湊雜表(時間複雜度 )後,要搜尋數列下一個元素的時間複雜度變為 ,總時間複雜度為 。
此為湊雜表的元素, num
是陣列的某一位置的數值, link
是與湊雜表和湊雜表元素連結的成員。
搜尋湊雜表元素,湊砸函數使用到陣列的數值 num
和大小 size
,得到湊雜值 hash
後在對應的 bucket 裡( heads[hash]
)找尋元素,找到則回傳該指標,否則回傳 NULL
。
首先先建立湊雜表,並將陣列數值放入湊雜表中。在來依序取陣列的數值,找尋包含該數值之最長連續數列長度 len
,若找到的長度比 length
更長,則更新最終最長長度 length
。
兩個填空 LLL, RRR 剛好是在對稱的位置上,從第 31 行看到 node
被移除,也就是 num
已經造訪過了,之後應該要往數值的兩邊作找尋,也就是第 34, 39 行。從第 33 行得知 left
與 right
是 num
,所以兩填空應該要為 --left
與 ++right
才會進入迴圈。
延伸問題:
在 Linux 裡湊雜表元素是使用 sturct hlist_node
,這裡替換原本的 struct list_head
。
在 Linux 裡的湊雜函數是使用 Fibonacci hasing ,輸入一個 key 然後得到 bucket 的位置,這裡我們直接使用 num
當 key 值。 hash_for_each_possible
會遍歷對應 key 值的 bucket ,這裡替換原本的 list_for_each_entry
和 hash 計算。
最後是主函式,需要建立一個空湊雜表,原本是用迴圈和 INIT_LIST_HEAD
初始化,在 Linux 裡則是有 __hash_init
可以使用,其湊雜表的大小固定為 的冪,所以要先算一下湊雜表的大小,至少是大於 n_size
的。補充說明一下,為何不用 HASH_SIZE
巨集,因為我們要使用動態配置記憶,這樣導致 heads
不是陣列,所以無法使用。另外因為同樣的理由,沒有使用 hash_init
和 DEFINE_HASHTABLE
。
剩下的部份有兩個函式有替換,第一個是 list_add
插入湊雜表元素,這裡換為 hash_add
,不一樣的地方是要多輸入 key 值,也就是他會在裡面算對應的 bucket 位置。而另一個函式是 list_del
,利用雙向連結結構, hash_del
一樣只需要輸入要移除的元素即可。