這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
前面已經說了倒排索引的基本原理了,原理非常簡單,也很好理解,關鍵是如何設計第二個倒排表,倒排表的第二列也很好設計,第一列就是關鍵了,為了滿足快速尋找的效能,設計第一列的結構,我們需要滿足以下兩個條件。
除了上面兩個條件以外,還有一些加分項:
如果能儘可能少的使用記憶體,那肯定是好的
如果能順序的遍曆整個列,也肯定比較好
為了滿足能尋找,能添加,我們首先想到的是順序表,也就是鏈表了,鏈表的話,添加不成問題,關鍵是尋找的複雜度是O(n),這還能忍?所以鏈表第一個不考慮了。不過有一個鏈表的變種,我們是可以考慮一下,那就是跳躍表。
跳躍表(SkipList)
什麼是跳躍表呢?跳躍表也叫跳錶,我們可以把它看成是鏈表的一個變種,是一個多層順序鏈表的並連接構的表,維基百科的定義是
是一種隨機化資料結構,基於並聯的鏈表,其效率可比擬於二叉尋找樹(對於大多數操作需要O(log n)平均時間)
我們通過一個圖來看一下跳躍表(圖片來源)
很明顯,最底層是一個順序表,然後在1,3,4,6,9節點上出現了第二層的鏈表,然後繼續在1,4,6節點上面出現了第三層鏈表,這樣構建出來的三層鏈表查詢效率比一層的就高了,一般情況下,跳錶的構建方式是按照機率來決定是否需要為這個節點增加一層,這裡在層 i 中的元素按某個固定的機率 p (通常為0.5或0.25)出現在層 i+1 中。平均起來,每個元素都在 1/(1-p) 個列表中出現,而最高層的元素(通常是在跳躍列表前端的一個特殊的頭元素)在 O(log1/p n) 個列表中出現。
尋找元素的時候,起步於頭元素和頂層列表,並沿著每個鏈表搜尋,直到到達小於或著等於目標的最後一個元素。通過跟蹤起自目標直到到達在更高列表中出現的元素的反向尋找路徑,在每個鏈表中預期的步數顯而易見是 1/p。所以尋找的總體代價是 O((log1/p n) / p),當p 是常數時是 O(log n)。通過選擇不同 p 值,就可以在尋找代價和儲存代價之間作出權衡。
比如還是上面那個圖,我們要尋找7這個元素,需要遍曆1—>4—>6—>7,比一層鏈表效率高不少吧
在實現跳錶的時候,雖然一般是用機率來決定是否需要增加當前節點的層級,但是實際中可以具體問題具體分析,比如我們知道底層鏈表大概有多長,那麼我們每格10個元素增加一個層級,那麼這樣的跳錶的儲存空間我們大概也能估算出來,平均查詢時間我們也能估算出來。
跳躍表是一個非常有用的資料結構,並且實現起來也比較容易,鏈表大家都知道實現,那麼跳躍表就是一組鏈表啦,只是增加和刪除的時候需要操作多個鏈表而已。
我的項目中暫時沒有使用跳躍表,後續有需求的時候再加上吧,所以大家看不到代碼了。讓你失望了。呵呵。
一般跳躍表可以和hash配合起來使用,因為hash有桶,佔用的記憶體較大,如果將hash值存在跳躍表中,用mmap把跳躍表載入到記憶體中,那麼既節省了記憶體,又有一個較好的查詢速度,而且實現起來還挺簡單。
跳躍表用來實現搜尋引擎的自增長類型的主鍵也比較合適,首先在搜尋引擎中,主鍵的尋找並不是那麼頻繁,一般查詢都是通過關鍵字查詢的,對主鍵來說,對查詢速度要求並不是特別高,只有在修改主鍵的時候需要進行查詢,其次自增長的主鍵一般情況下插入操作直接在鏈表後面append就可以了,不用進行查詢,所以插入的時候也比較快。
雜湊表
處理跳躍表,雜湊表也是一個實現方式,雜湊表是根據關鍵字(Key value)而直接存取在記憶體儲存位置的資料結構。也就是說,它通過計算一個關於索引值的函數,將所需查詢的資料對應到表中一個位置來訪問記錄,這加快了尋找速度。這個映射函數稱做散列函數,存放記錄的數組稱做雜湊表,也叫散列表。
雜湊是大資料技術的基礎,大家應該都有瞭解了,這裡就不深度展開了,演算法導論有一章已經講得非常清楚了,這裡說說我覺得比較有意思的一個雜湊的東西。
雜湊表的核心是雜湊演算法,一個好的雜湊演算法可以讓碰撞產生得更少,尋找速度越接近於O(1),所以一個好的雜湊演算法非常重要。
雜湊演算法很多,說都說不完,不同的演算法適應不同的情境,我知道的,傳說中有一個雜湊演算法,來自魔獸世界(!!!!為了部落!!!!),號稱暴雪雜湊,該演算法產生的雜湊值完全無法預測,被稱為"One-Way Hash"( A one-way hash is a an algorithm that is constructed in such a way that deriving the original string (set of strings, actually) is virtually impossible)。
以下是這個演算法的Go語言實現,在我的項目中也有,不過後來我沒有用hash表,所以刪掉了,號稱有這個演算法,所有字串都不在話下,碰撞機率很低。
// 初始化hash計算需要的基礎map tablefunc initCryptTable() { var seed, index1, index2 uint64 = 0x00100001, 0, 0 i := 0 for index1 = 0; index1 < 0x100; index1 += 1 { for index2, i = index1, 0; i < 5; index2 += 0x100 { seed = (seed*125 + 3) % 0x2aaaab temp1 := (seed & 0xffff) << 0x10 seed = (seed*125 + 3) % 0x2aaaab temp2 := seed & 0xffff cryptTable[index2] = temp1 | temp2 i += 1 } }}// hash, 以及相關校正hash值func HashKey(lpszString string, dwHashType int) uint64 { i, ch := 0, 0 var seed1, seed2 uint64 = 0x7FED7FED, 0xEEEEEEEE var key uint8 strLen := len(lpszString) for i < strLen { key = lpszString[i] ch = int(toUpper(rune(key))) i += 1 seed1 = cryptTable[(dwHashType<<8)+ch] ^ (seed1 + seed2) seed2 = uint64(ch) + seed1 + seed2 + (seed2 << 5) + 3 } return uint64(seed1)}
雜湊表的實現方式有很多中,最最基礎的就是數組+鏈表的形式了,也叫開鏈雜湊,數組長度就是雜湊的桶的長度,鏈表用來解決衝突,插入資料的時候如果雜湊碰撞了,把具體節點掛在該節點後面的鏈表上,查詢資料時候有衝突,就繼續線性查詢這個節點下的鏈表。
還有一種叫閉鏈雜湊,閉鏈雜湊實際是一個迴圈數組,數組長度就是桶的長度,插入資料的時候有衝突的話,移動到該節點的下一個,直到沒有衝突為止,如果移動到了末尾的話,轉到數組的頭部,尋找資料的時候類似。
這裡又出現一個小問題,如果碰撞了的話,不管是開鏈還是閉鏈雜湊,都需要進行線性匹配,而且比較的是兩個資料的實際值,所以不管是那種雜湊實現,都需要在節點中儲存原始的資料資訊,不然碰撞的時候沒辦法匹配了,這樣就衍生出來兩個問題:
然而,雷霆崖的程式員想了一個更好的辦法,用上面那個雜湊函數,通過不同的dwHashType
,雜湊了三次,得到三個整數,第一個整數用來確定位置,第二和第三個整數用來代替原始字串,儲存在雜湊表的節點中用於解決衝突,當要查詢時,先計算待查詢的Key的三個雜湊值,然後用第一個去定位,如果第一個值沒衝突,返回節點,如果衝突了,那麼不管是開鏈實現方式還是閉鏈實現方式,尋找下一個節點,然後比較這兩個節點的第二和第三雜湊值,如果一樣的話,返回節點,不一樣的話繼續尋找下一個,通過這麼倒騰,首先,儲存空間的問題解決了,每個雜湊節點只需要存3個整數,空間固定了,第二個問題也解決了,比較兩個整數總比比較字串快多了吧。
好了,跳躍表和雜湊表就是這些了,在My Code中沒有跳躍表,後續才會加上,雜湊表本來有,後來為了節省記憶體空間,用了B+樹來替代雜湊表了,所以雜湊表的代碼暫時看不到,不過我已經把暴雪雜湊寫上面了哈。
下面一章會詳細將一下B+樹了,我代碼裡面也是用的B+樹,而且幾乎所有的資料庫的索引也是用的B+樹。
最後,打一廣告,我的公眾號,目前沒什麼訂閱者 T_T。文章的更新頻率會在一周3到5篇左右吧,歡迎大家掃描一下下面的公眾號訂閱,首先會在這裡發出來:)