高性能查找之基礎數據結構與各類算法思想的碰撞

前言

應小夥伴的號召,打算寫一篇關於數據結構的文章,也算是一段數據結構學習的總結吧

本篇文章不會詳細講解什麼是二分查找,什麼是跳錶、字典、查找樹,因爲這類資料是非常多的(其中跳錶會在下一篇文章中單獨進行講解)。本篇文章側重點在於這些查找的方式各自的優劣處與如何選擇的問題。建議讀者先大致瞭解這些東西是什麼再來閱讀本篇文章,相信會有一定的啓發作用。

這篇文章的議題主要是關於查找數據,圍繞查找這一話題展開介紹各種用於查找的數據結構,這類數據結構非常多,各自有各自的優劣之處和自己的特色。本篇的議題有如下幾個方面:

  • 爲何Redis使用跳錶做有序集合?
  • 爲何MySQL使用查找樹(B+Tree)作爲索引實現?
  • 字典表
  • 二分查找(算法,但與議題相符,也一併討論)

1. 快速查找的幾種方式

要理解爲什麼中間件要選擇某種數據結構,首先需要學習一下各自查找的數據結構和算法

很多快速的查找是 logN 時間複雜度,看起來不快,但實際計算一下會發現其其實相當恐怖,例如n如果等於2的32次方,就是42億左右(java中int的正負範圍),如果在42億個數據中進行查找一個數據,僅僅需要32次!

1.1 二分查找

首先先來講一個相對簡單的算法,雖然不是數據結構但我也將其列入此篇文章中,是因爲有如下優勢:

  • 其作爲搜索算法,具有查找某個值相當快(log(N)的時間複雜度)
  • 支持範圍查找,這點有時候還是比較關鍵的
  • 甚至比其他幾種查找的方式更節省內存,只需要一個數組就可以撐起一個二分查找算法。對比字典,需要冗餘數組空間,在解決哈希衝突時如果使用鏈表,還需要保存指針信息。對比查找樹跳錶,需要保存指針信息

但是其也具有一定的侷限性:

  • 此算法支撐的數據結構只能是數組,因爲其使用下標訪問是O(1)的複雜度,如果換成鏈表,遍歷第N個位置的數據是O(N)的複雜度,所以鏈表無法作爲其數據結構
  • 只有有序的數據纔可以使用二分查找

此時想象一個場景,一個有序的數組,其適用於二分查找,如果有新的數據來到,需要做增刪的操作,那麼就需要從數組中插入一條數據,我們知道,數組是一段連續的內存地址且容量固定(在分配時就指定好了),那麼從中間插入一條數據需要將中間到結尾的數據全部都往後移動一位,如果容量不夠,又需要新分配一個數組,然後將數據全部拷貝過去,從這點來看,二分查找是不適用於頻繁插入和刪除操作的場景,這裏我們得出結論,如果有一次排序,多次查找的場景,纔可以使用二分查找,並且這種方法又快又節省內存。

單值查找

在MySQL查詢中,IN 函數就使用了二分查找的方式,像這麼一條語句:

select * from city where city_id in (1,2,7,3,5,10)

假設 city_id 不是主鍵也沒有索引,此時MySQL會先進行全表掃描,將 city 表中的數據先全部取出來,然後一條一條的將數據中的 city_id 列與 in 列表進行二分查找對比,看看此行數據是否在 in 列表中,如果是,加入結果集。

如果不用二分查找呢?假設 in 列表中有 n 個條件,表中有 m 條數據,如果取出 in 列表與一行行數據對比 或者是取出一行數據與一個個 in 列表進行對比,都需要掃描 n * m 次,如果是二分查找,只需要logN * m次,在in列表很大的情況下將大大減少次數,但排序最快需要 N*logN 的代價,儘量保持 in 列表的有序吧!

範圍查找

二分查找最大的用處就是用在範圍查找上,場景是查找不大於18歲的人,或不小於20歲的人,這樣都是一個範圍查找,這點是字典表所做不到的。

例如通過 IP 地址來查找歸屬地,我們知道,每一段 IP 地址區間都歸屬於一個地區

// 瞎編的
[47.102.133.0, 47.102.133.255] 山東濟南
[47.102.135.0, 47.102.136.255] 福建泉州
[47.102.137.0, 47.102.137.255] 福建廈門

將全國的所有IP段起始點放到一個數組中,即可快速查詢到一個IP的歸屬地,例如上面的例子,可以在數組中這樣存放

// ip地址本來就是一個數字,使用分段顯示只不過是爲了閱讀方便
// 需要將ip地址先轉爲32位整形數
// 數字也是瞎編的,真實ip比較長,不利於我們講解
// 202.102.133.13 -> 212232285
int[] ip = new int[]{10, 40, 60}

二分查找找到最後第一個比它小的數

如果二分找到的數字比其小,後面一段數據肯定比它大,繼續往後二分,找到後需要看看前一個數字是否小於它,如果前一個數據小於它,後面一個數字又大於它,那可以證明其前面這個數字是最後一個它小的,其就是起始點。

假設來了一個 IP 地址是 47.102.135.50 ,轉化爲43,二分查找到60,其大於本數,查看前面一個數字40小於本數,則40就是最後一個比它小的,它這個起始點對應了[47.102.135.0, 47.102.136.255] 福建泉州 這個ip段。在ip數據量非常大的時候,並且ip段起始點不容易改變,符合一次排序多次查找的規則,所以這個場景適用二分查找來很快的找到某個ip的歸屬地。

1.2 跳錶

鏈表數據結構,使用多級鏈表索引組裝即爲跳錶

其數據結構較爲複雜,考慮單獨起一篇來講解跳錶

上面的二分查找有一個侷限,其只能用數組來做,在增刪操作方面有很大的劣勢,那麼有沒有一種數據結構,能使用鏈表的方式組織數據,這樣增刪操作就可以很快,而查找值又是 logN的時間複雜度呢?這就是跳錶。其具有二分查找的所有優點(除了節省內存,因爲其需要存放一些節點的指針),跳錶有如下優點:

  • 由於其鏈表結構,增刪節點非常快,只需要改變前後指針即可完成增刪操作
  • 範圍查找很快
  • 單值查找很快

單值查找

跳錶這個數據結構,和二分查找的思想十分相似,二分查找每次查找都會過濾掉一半的數據,例如猜數字,目標數字爲57,範圍爲0-100,第一次猜中點50,比其小,搜索50-100,這樣就過濾掉了0-50這一段數據,所以二分查找的時間複雜度在大數據量下會非常低(對數,相對於指數爆炸),而跳錶也是這個思想,每一次“跳躍”都可以過濾掉一段數據,所以在單值查找操作上的時間複雜度上也是對數級別

範圍查找

由於其是有序的方式組織數據,所以跳錶是有序鏈表,查找一個範圍只需要查找一個單值 logN 的時間複雜度,然後往後遍歷即可得到這個範圍。

在增刪操作上,需要保持有序特性,就需要承擔 logN 的時間複雜度,因爲增刪操作時,首先需要找到某個值,是 logN 的時間複雜度,然後再切段鏈表節點的前後指針,是 O(1) 的時間複雜度,合在一起就是 logN 的時間複雜度,相對來說,也不慢其實。

1.3 字典表

其也叫散列表、哈希表(Hash表),在Java中使用頻率極高,主要數據結構是數組(一般哈希衝突時會用到鏈表或者紅黑樹進行擴展)

字典表使用Key(鍵)、Value(值)兩個數據來組織的,特性是可以使用特定Key查到特定的Value,主要是使用一個哈希函數將Key散列成一個數字(很多東西都可以散列爲一個數字,例如圖片等等),然後由數組的長度就可以決定這個散列值對應的數組下標,然而訪問數組下標的時間複雜度是O(1),所以可以總結如下:

  • 散列表利用訪問數組下標時間複雜度爲O(1)的特性,其增刪或是查找一個數據都是相當快速的,這點比上述的二分查找或是跳錶、查找樹都是更快的

那爲什麼查找數據不都替換爲字典表呢?因爲其有如下侷限

  • 無法支持範圍查找。其組織數據的方式是無序的,在順序方面字典表沒能力做到,而跳錶和B+Tree的場景下,有很多時候都需要查找一個範圍的數據

其實字典表的時間複雜度不能單純地說成是O(1)的,影響因素有很多,先來看一下字典表的大致運轉流程:

  1. 使用哈希函數將某個Key散列爲一個散列值(整數)
  2. 數組大小決定了此散列值對應的數組下標,放入下標中
  3. 如果有哈希衝突,使用鏈表或二叉樹解決衝突

上述流程就已經講述了決定字典表性能的幾個主要因素:

  • 散列函數
  • 裝載因子(數據數量與容量的比例,例如 int[16] ,那麼如果有12個數據,此時裝載因子就是0.75)
  • 散列衝突解決策略

其中,散列函數得出的散列值不夠”散“,或是裝載因子太大(數組裝了過多的數據),都會造成散列衝突,而衝突地越多,就越會退化爲鏈表的時間複雜度。所以一個好的散列表性能,就由以上三個因素決定。

那麼下面就圍繞以上因素來談談字典表的高性能設計

設計字典表

散列函數

  • 不能太過複雜,函數的計算時間太長會過多消耗CPU,變相增多時間複雜度
  • 函數生成的值要儘可能均勻分佈,這樣才能避免散列衝突

裝載因子

如果裝載因子過大,散列函數再均勻都會哈希衝突,所以散列函數與裝載因子是相輔相成的,那麼裝載因子過大怎麼辦呢?可以參考Java中HashMap的解決方式,在大於某個裝載因子的時候直接將數組容量擴容兩倍,這樣就能減小了一半裝載因子,此時涉及新數組的分配,數據拷貝,和數據的rehash,爲什麼需要rehash呢?因爲數據在數組中的下標是由數組容量決定的,散列值模上一個數組容量(2的次方可以用與運算,更加快速)計算出這個散列值對應的下標,那麼容量變了下標就有可能會變化,例如24的散列值,16的數組容量,其下標是8,如果擴容,數組容量變爲32,下標就變爲24了。

所以,有時候如果知道元素數量,最好提前設置一個合理的初始容量,避免rehash和擴容分配,拷貝數據的開銷。

在Redis中,如果字典表數據太大,擴容就十分耗時,1G的數據擴容就要分配2G容量,計算1G的rehash,1G的數據拷貝,如果直接擴容,將會阻塞服務器很久,這對於Redis的響應性很不利,於是在Redis中就採用了慢慢遷移的策略,要擴容時先分配2G容量,在每次的定時任務遷移一小段,或是如果此時有客戶端會對Key進行操作,就在這個操作中順便遷移了。

裝載因子的閾值的設置,權衡了時間、空間複雜度,試想,如果裝載因子閾值設置爲0.1,16容量的數組只能存放1個元素,直接大大避免了哈希衝突,但也付出了冗餘15個容量的代價,使用了空間換取時間複雜度。如果閾值太大,爲2,16個容量雖然可以放置32個數據,但衝突大大增加,很大概率退化爲鏈表,這就使用了時間複雜度換取了空間

發散知識:考慮java中併發HashMap(ConcurrentHashMap),因爲需要檢查裝載因子是否超過閾值,那麼每次添加元素時就都要檢查此時的容量大小,那麼此時size這個值就是熱點數據,因爲每一個線程的put和remove都要訪問這個值,那麼如何設計這個值的高併發性,就是一門藝術了,關於這一點,可以查看這篇文章

散列衝突策略

  • 開放尋址法:代表爲Java中的ThreadLocalMap
    • put數據時,當衝突發生,再計算一次index。查找數據時,需要先查看key是否相等(equal),不相等需要使用同樣的方法再計算一次index,繼續往下查找。最簡單的就是+1法,衝突了就下標加1,繼續看看有沒有衝突
    • 優點:所有的數據都在一個數組上,這樣可以很好的利用到CPU緩存,並且序列化起來比較容易
    • 缺點:更容易造成哈希衝突,這樣就導致了性能與鏈表法持平的話需要更小的負載因子閾值,就更需要內存空間了
  • 鏈表法:代表爲Java中的HashMap
    • put數據時,當衝突發生,直接將值存放到衝突值的下一個指針中。查找數據時,需要先查看key是否相等(equal),不相等需要查找值的下一個指針是否爲空,不爲空就往下查找
    • 優點:對於大數據量的負載容忍度高,相對開放尋址法更不容易哈希衝突(如果鏈表過長,還可以變化爲紅黑樹,這是JDK1.8的做法,這樣可以使得查找時間複雜度從鏈表的 O(N)到紅黑樹的 O(logN) )

散列與鏈表的相輔相成

很多時候,散列表和鏈表兩個數據結構會一起使用,散列表擅長查找一個數據,但無序,鏈表擅長存放一個順序,但查找一個數據很慢,兩者相輔相成:

  • LRU淘汰算法:使用字典表存放元素,使用鏈表存放元素順序
    • 查找:在存放元素的時候可以使用字典錶快速查找是否緩存過這個元素,若沒有插入到鏈表的末尾節點,如果有,將這個節點刪除插入到末尾節點
    • 有序:若容量超過閾值,刪除鏈表的頭部
    • 這一過程,利用了字典表的 O(1) 查找,鏈表的順序(頭部尾部操作O(1) ),鏈表的刪除插入O(1),十分高效
  • Redis有序集合:Redis中的有序集合其實不僅僅使用跳錶(鏈表)來做,其實還與字典表結合來使用,因爲跳錶中查找一個數據是 logN 的時間複雜度,如果配合字典表將會更快
    • 字典表的查找複雜度 O(1)
      • 刪除指定元素
      • 查找指定元素
    • 跳錶的快速範圍查找
      • 按照score區間查找,例如查找score在[3, 10]之間的元素
      • 按照分值從大到小排序(跳錶本就是有序排列,只需要取出數據即可)
  • Java中的LinkedHashMap:如果想要一個Key-Value的數據結構,又想要其保持插入時的順序怎麼辦?LinkedHashMap是一個好選擇,例如在Spring解析@Configuration下的Bean時,保存BeanDefinition的時候就使用了LinkedHashMap(我在暗示Spring解析配置Bean的時候是順序初始化的,這樣可以保證優先級和自動裝配的Bean在最後才初始化,這樣有些Condition條件就可以適用了)

1.4 查找樹

更多詳情看下面的第三點,B+Tree的討論

這裏討論的是多路的查找樹,相對於二叉樹,多路查找樹一個節點會保存很多個數據,並且有多路分支,路數越多,高度就比二叉樹小很多,在查找一個單值時是 logN (樹的高度決定)的時間複雜度,而且查找樹都是有序存放的,所以支持範圍查找。這一點看來,其和跳錶很像,的確,這兩者是十分相似的,很多使用紅黑樹或是m叉樹的地方都可以使用跳錶來實現,跳錶實現起來比較簡單,但是紅黑樹實現起來比較困難,但紅黑樹歷史比較悠久,所以歷史遺留問題很多地方都是紅黑樹實現,而最近的這種場景使用跳錶實現的也越來越多了(Redis)

2. Redis選擇跳錶實現有序列表

有序列表需要完成以下需求:

  • 快速查找一個區間(範圍查找)
  • 快速輸出一個有序結果
  • 快速插入、查找、刪除

在第一第二點上,就決定了字典表無法勝任,二分查找就更不可以了,其在插入刪除時表現的特別糟糕,那就只剩查找樹和跳錶可選了,在按照某個區間查找數據這個操作,普通的像紅黑樹的效率就沒有跳錶高了(這也就是爲什麼MySQL的InnoDB引擎的B+Tree要在葉子節點存放數據鏈表的原因,類似跳錶,這利於範圍查找),跳錶只需要 logN 複雜度定位到起始節點,然後遍歷鏈表即可得到結果。

還有就是相對紅黑樹會比較好實現(在上面也提到了),只不過紅黑樹出現的比較早,佔領了很多編程語言的內部數據結構。

3. MySQL選擇B+Tree

在這裏,針對MySQL對B+Tree進行有針對性的討論。

在MySQL中,查詢需要滿足怎樣的需求呢?先來看看兩句SQL語句

  • select * from city where id = 1
  • select * from city where id < 500

首先,散列表無法支持第二條語句,二叉查找樹例如紅黑樹,也無法支持區間查找,如果是跳錶,可以支持以上操作,事實上,對跳錶稍加改造,也可以替換B+Tree,但兩者是差不多了,而B+Tree出現的又比較早,MySQL索引就沒有替換跳錶的必要而是使用B+Tree了。

數據庫的數據通常會比較大,索引相對也會大一些,索引需要存放在磁盤中,這就意味着每次對索引進行查找都將是一次磁盤IO,我們知道,磁盤IO的代價是比較重的,所以B+Tree的核心理念是減少IO次數,多路使得樹結構的高度變得很低,通常來說,一次IO是按頁(一頁通常4KB)來讀取,那麼保證B+Tree的一個節點小於4KB,並且存放足夠多的數據,其實就可以最大限度減少IO次數,提升性能了,這也就是爲什麼索引的字段不能太長,會影響索引性能的原因。

如果索引字段值長度是1B,那一個樹節點可以存放 4 * 1024 個字段值,相對字段值長度是4B,一個樹節點只能存放 1024 個字段值,兩者的樹高度是完全不一樣的,而樹的高度又決定了 IO 的次數,這樣來看,索引字段如果太大,將會影響索引性能。

4. 總結

其實所有的數據結構,基本上都是由數組和鏈表所組成,這兩個是基礎底層數據結構,配合上一些算法思想例如:

  • 哈希+數組 = 字典表
  • 二分思想+鏈表 = 跳錶
  • 二分思想+樹形結構的節點(類似鏈表的指針訪問機制) = B+Tree
  • 二分思想+數組 = 二分查找

其各自都有不同的使用場景:

  • 字典表:
    • 快速查找
    • 無序
    • 不可範圍查找
    • 配合鏈表或跳錶相輔相成,補字典表範圍查找的短板和字典表無序的特點
  • 跳錶:
    • 較快速的查找(沒字典表那麼快)
    • 有序
    • 高效的範圍查找
    • 配合字典表,補自身查找單值沒字典表那麼快的短板
  • B+Tree:
    • 較快速的查找(沒字典表那麼快)
    • 有序
    • 高效的範圍查找(MySQL的InnoDB中,葉子節點存放了有序的數據鏈表)
    • 多路特性,大幅減少IO次數,十分適合數據庫索引
  • 二分查找:
    • 較快速的查找(沒字典表那麼快)
    • 有序(需要提前排好序)
    • 高效的範圍查找(查找一組數據中,第一個大於或小於,最後一個大於或小於某個值的情況,類似IP歸屬地查找,或是2的平方根,保留 n 位小數點的計算)
    • 侷限比較大,只能使用數組,在增刪條件下顯得比較糟糕,只適用於一次排序,多次查找的場景
    • 相對以上數據結構,其是比較省空間的
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章