數據結構和算法(三):二分查找、跳表、散列表、哈希算法

從廣義上來講:數據結構就是一組數據的存儲結構 , 算法就是操作數據的方法

數據結構是為算法服務的,算法是要作用在特定的數據結構上的。

10個最常用的數據結構:數組、鏈表、棧、隊列、散列表、二叉樹、堆、跳表、圖、Trie樹

10個最常用的算法:遞歸、排序、二分查找、搜索、哈希算法、貪心算法、分治算法、回溯算法、動態規劃、字符串匹配算法

本文總結了20個最常用的數據結構和算法,不管是應付面試還是工作需要,只要集中精力攻克這20個知識點就足夠了。

數據結構和算法(一):復雜度、數組、鏈表、棧、隊列的傳送門

數據結構和算法(二):遞歸、排序、通用排序算法的傳送門

數據結構和算法(三):二分查找、跳表、散列表、哈希算法的傳送門

數據結構和算法(四):二叉樹、紅黑樹、遞歸樹、堆和堆排序、堆的應用的傳送門

數據結構和算法(五):圖、深度優先搜索和廣度優先搜索、字符串匹配算法、Trie樹、AC自動機的傳送門

數據結構和算法(六):貪心算法、分治算法、回溯算法、動態規劃、拓撲排序的傳送門

第十一章 二分查找

一、什么是二分查找?
    1. 二分查找是對一個有序的數據集合進行查找,通過每次跟區間中間的元素對比,將待查找的區間縮小為之前的一半,直到找到元素或者區間為空為止。
    1. 對于排好序的數組來說,使用二分查找,查找某個元素的時間復雜度為:O(logn)。第一次查找時區間大小為n,第二次查找時區間大小為n/2,第三次查找時區間大小為n/4,...,第k次查找時區間為n/(2 ^ k),最壞情況下第k次的時候區間變成空了,也就是n/(2^k) = 1,k =log2n,所以時間復雜度為O(logn)。
    1. O(logn)的查找效率非常驚人,有時候甚至比常量級時間復雜度O(1)效率還要高,為什么這么說呢?logn是個非常“恐怖”的數量級,即便n非常大,對應的logn也很小。例如:2的32次方,也就是大約42億,在42億個數據中,使用二分查找,最多需要比較32次。而O(1)代表的常數可能是一個非常大的數字,例如O(1000)、O(10000)等等,所以常數級時間復雜度算法有時候還沒有O(logn)級別的執行效率高。
二、二分查找的局限性
    1. 二分查找依賴的是順序表結構,簡單來說就是數組。因為數組通過下標隨機訪問的時間復雜度為O(1),而鏈表想要隨機訪問一個數時間復雜度為O(n),通過一些系列計算,我們可以分析出來基于鏈表的二分查找時間復雜度為O(n)。
    1. 二分查找針對的是有序數據。如果數組沒有序,那我們就需要先排序,一般排序的時間復雜度最低為O(nlogn),如果我們針對的是一組靜態的數據,沒有頻繁的插入和刪除,我們可以一次排序,多次二分查找,這樣排序的成本可被均攤,二分查找的邊際成本就會比較低。
    • 但是,如果我們的數據集合涉及到頻繁的插入、刪除操作,維護有序的成本都會比較高,所以二分查找只適合于插入、刪除操作不頻繁、一次排序多次查找的場景中。針對動態變化的的數據集合的查找,可以使用二叉樹。
    1. 數據量太少不適合二分查找。如果處理的數據很少,那就完全沒有必要使用二分查找了,順序遍歷就夠了;當數據量比較大時,二分查找的優勢才會比較明顯。
    • 但是有一個特例,那就是比較操作特別耗時的時候,此時不管數據量大小,都推薦使用二分查找。例如:數組中存儲的是長度超過300的字符串,如此長的字符串之間的大小比較非常耗時,我們就需要盡可能的減少比較次數,減少比較次數就會大大提高性能,這時二分查找就會比順序遍歷更有優勢。
    1. 數據量太大也不適合二分查找。因為二分查找的底層依賴于數組,數組這種數據結構為了支持隨機訪問的特性,占用的空間必須是連續的,對內存要求比較苛刻。例如我們有個1G的數據要查找,用數組存儲的話就需要連續的1G內存空間,如果系統剩余的空間都是零散的,不是連續的,也就無法開辟這樣的數組了。所以太大的數據用數組存儲就比較吃力,也就不能用二分查找了。
三、簡單二分查找的代碼實現
//二分查找-循環
var array = [1,2,3,4,5,6,7,8,9,11]
func searchArray(array: Array<NSInteger>,target: NSInteger) -> NSInteger{
    var low = 0
    var hight = array.count - 1
    while low <= hight {
        let middle = (low + hight) / 2
        if target == array[middle]{
            return middle
        }else if target < array[middle]{
            hight = middle - 1
        }else{
            low = middle + 1
        }
    }
    return -1
}
searchArray(array: array, target: 10)
searchArray(array: array, target: 3)
//二分查找-遞歸
func searchInternally(array:Array<NSInteger>,low:NSInteger,high:NSInteger,target:NSInteger) -> NSInteger{
    if low > high{
        return -1;
    }
    let middle = (low + high) / 2
    if array[middle] == target{
        return middle
    }else if array[middle] > target{
        let newHigh = middle - 1
        return searchInternally(array: array, low: low, high: newHigh, target: target)
    }else{
        let newLow = middle + 1
        return searchInternally(array: array, low: newLow, high: high, target: target)
    }
}
func recursionSearch(array:Array<NSInteger>,target:NSInteger) -> NSInteger{
    return searchInternally(array: array, low: 0, high: array.count - 1, target: target)
}

recursionSearch(array: array, target: 10)
recursionSearch(array: array, target: 3)
四、二分查找的四個變體問題
    1. 查找第一個值等于給定值的元素
//二分查找-第一個值等于給定值的元素
var array = [1,2,3,4,5,6,6,6,7,8,9,11]
func searchFistEqual(array:Array<NSInteger>,target:NSInteger) -> NSInteger{
    var low = 0
    var high = array.count - 1
    while low <= high {
        let middle = (low + high) / 2
        if array[middle] > target{
            high = middle - 1
        }else if array[middle] < target{
            low = middle + 1
        }else{
            if middle == 0 || array[middle - 1] != target{
                return middle
            }else{
                high = middle - 1
            }
        }
    }
    return -1
}

let a1 = searchFistEqual(array: array, target: 6)
print(a1)
    1. 查找最后一個值等于給定值的元素
//二分查找-最后一個值等于給定值的元素
var array = [1,2,3,4,5,6,6,6,7,8,9,11]
func searchLastEqual(array:Array<NSInteger>,target:NSInteger) -> NSInteger{
    var low = 0
    var high = array.count - 1
    while low <= high {
        let middle = (low + high) / 2
        if array[middle] > target{
            high = middle - 1
        }else if array[middle] < target{
            low = middle + 1
        }else{
            if (middle == array.count - 1 || array[middle + 1] != target){
                return middle
            }else{
                low = middle + 1
            }
        }
    }
    return -1
}
let a2 = searchLastEqual(array: array, target: 6)
print(a2)
    1. 查找第一個大于等于給定值的元素
//二分查找-查找第一個大于等于給定值的元素
var array3 = [1,2,3,4,5,6,6,6,7,7,8,9,11]
func searchFirstResult(array: Array<NSInteger>,target: NSInteger) -> NSInteger{
    var low = 0
    var high = array.count - 1
    while low <= high {
        let middle = (low + high) / 2
        if(array[middle] >= target){
            if middle == 0 || array[middle - 1] < target{
                return middle
            }else{
                high = middle - 1
            }
        }else{
            low = middle + 1
        }
    }
    return -1
}
let a3 = searchFirstResult(array: array3, target: 7)
print(a3)
    1. 查找最后一個小于等于給定值的元素
//二分查找-查找最后一個小于等于給定值的元素
var array4 = [1,2,3,4,5,6,6,6,7,7,8,9,11]
func searchLastResult(array: Array<NSInteger>,target: NSInteger) -> NSInteger{
    var low = 0
    var high = array.count - 1
    while low <= high {
        let middle = (low + high) / 2
        if array[middle] <= target{
            if middle == array.count - 1 || array[middle + 1] > target{
                return middle
            }else{
                low = middle + 1
            }
        }else{
            high = middle - 1
        }
    }
    return -1
}

let a4 = searchLastResult(array: array4, target: 5)
print(a4)
    1. 一般情況下,凡使用二分查找能解決的,絕大部分我們更傾向于用散列表或者二叉查找樹。即便是二分查找更省內存,但是畢竟內存如此緊俏的時候不多,那么二分查找真的沒什么作用了嗎? 實際上,二分查找更適合用在“近似”查找問題上,在這類問題上二分查找的優勢比較明顯,例如這幾個變體問題,用其他數據結構,比如二叉樹、散列表,就比較難以實現了。
五、課后題

第十二章 跳表

我們知道二分查找依賴的是數組的隨機訪問特性,那么鏈表真的無法使用二分查找了嗎?

一、什么是跳表
    1. 跳表使用了空間換時間的設計思想,通過構建多級索引提高查詢效率,實現了基于鏈表的“二分查找”,插入、刪除、查找的時間復雜度都是O(logn)。(跳表其實就是鏈表+多級索引,索引占用了更多的內存空間,同時索引也提高了提高查找效率)
    1. 跳表的空間復雜度為O(n),可以通過改變索引構建策略,有效平衡執行效率和內存消耗。
二、跳表的索引是怎么構建的呢?
    1. 對于一個單鏈表來說,即便鏈表中存儲的數據是有序的,查找一個元素也只能從頭開始遍歷,時間復雜度就會很高,是O(n)。
單向鏈表
    1. 為了提高效率,我們像下圖一樣建立一級索引,每兩個節點提取一個節點到上一級,提取出來的節點就構成了索引。圖中的down表示down指針,指向下一級結點。
    • 如下圖,原來查找“16”需要遍歷10個結點,建立1級索引之后,查找16只需要遍歷7個結點了,查詢效率提高了。
第一級索引
    1. 按照上述方式,我們在建立二級索引,查找效率又會提升很多。
    • 如下圖,一級索引的時候查找“16”需要遍歷7個結點,建立二級索引之后,就只需要6個結點了。
二級索引
    1. 由于案例中的數據量并不大,所以即便是建立了二級索引,效率提升也不是很明顯,下圖是包含64個結點的鏈表,為它建立五級索引之后,查找效率大幅提高。
    • 原來沒有建立索引時,查找“62”,需要遍歷62個結點,建立五級索引后,只需要遍歷11個結點了,查找效率大幅提高。
五級索引
三、跳表的查詢有多快?
跳表查詢的時間復雜度為O(logn),這是怎樣計算出來的呢?
    1. 假設一個鏈表一共有n個結點,每兩個結點抽取一個建立一級索引,那么第一級索引的節點數量大約為n/2,第二級索引大約有n/4個結點,第三級索引大約有n/8個結點...
    1. 依次類推,第k級索引大約有n/(2k)個結點,已知最高等級索引有2個結點,所以當第k級索引為最高等級的索引時,即 n/(2k) = 2,k=log2n - 1,在加上原始鏈表這一層之后,整個跳表就有log2n個等級的索引,也可以說跳表的高度為log2n。
    1. 我們在鏈表中查詢某個數據時,通過索引查找,不僅需要橫向遍歷每一級,又要一級一級往下走,所以如果每一層都要遍歷m個結點,一共有log2n層,那么總共就需要遍歷m * log2n個結點,時間復雜度也就是O(m * logn)
    1. 我們每兩個結點取一個結點作為索引,那么每一級索引最多只需要遍歷3個結點就可以了,也就是m = 3,所以時間復雜度就變成了O(3 * log2n),也就是O(logn)。
    1. 從這個分析過程我們可以看出,跳表通過建立索引進行查詢,雖然額外占用了內存空間,但是可以大幅提高查找效率,這就是典型的空間換時間的設計思想。
四、跳表額外占用了多大的內存空間呢?
    1. 假設鏈表有n個結點,每兩個結點抽取一個建立一級索引,那么第一級索引的節點數量大約為n/2,第二級索引大約有n/4個結點,第三級索引大約有n/8個結點...
  • 2.額外使用的節點個數為n/2 + n/4 + n/8 + ... + 2,這是一個等比數列,通過公式求和,我們可以知道總共額外使用了n-2個結點,也就是空間復雜度為O(n)

  • 3.也是就說n個結點的鏈表,每兩個結點抽取一個建立索引,就額外需要大約n個結點存儲索引。為了節省內存空間,我們可以每三個結點或每五個結點,抽取一個建立索引,額外需要的內存空間就會變成n/2或n/4.

    1. 但是實際開發中,大可不必如此在意額外內存空間,因為原始鏈表中存放的很可能是很大的對象,而索引結點只需要存儲關鍵值和幾個指針,并不需要存儲對象,所以當對象遠大于索引結點時,索引占用的內存空間就可以忽略了。
五、跳表的插入和刪除
跳表插入、刪除查詢的時間復雜度為O(logn),這是怎樣計算出來的呢?
    1. 在單鏈表中,一旦確認好要插入的位置,那么插入和刪除操作的時間復雜度都是O(1)
    1. 在跳表中,查找某個位置需要O(logn),之后在進行插入和刪除,所以跳表的插入和刪除時間復雜度也是O(logn)。
六、跳表索引的動態更新
    1. 當我們不停的往跳表中插入數據,如果我們不更新索引,就有可能出現某兩個索引結點中間數據非常多,極端情況下,跳表還會退化成鏈表。
    1. 跳表作為一種動態的數據結構,是通過隨機函數來維護索引和原始鏈表大小之間的平衡的,也就是說,鏈表結點多了,索引結點也就相應增加一點,避免復雜度退化,以及查找、插入、刪除性能下降。
    1. 跳表通過隨機函數,來決定將這個結點插入到哪幾級索引中,比例隨機生成了3,那么我們就將這個結點插入到第一級索引到第三級索引,這三個等級的索引中。隨機函數的選擇非常有講究,從概率上要保證跳表的索引大小和數據大小的平衡性,不至于讓性能退化。

第十三章 散列表

一、什么是散列表?
    1. 散列表,也叫哈希表,利用了數組支持下標隨機訪問的特性,通過散列函數把元素的鍵值key映射為數組的下標,然后把數據存儲在下標對應的位置。按照鍵值key查找數據時,只需要用同樣的散列函數,就可以把key轉化為數組下標,進而從數組下標的位置取到數據,時間復雜度為O(1)。
    1. 散列函數,在散列表中起到了非常關鍵的作用,它是一個函數,所以我們可以把它定義為hash(key),其中key代表元素的鍵值,hash(key)的值代表經過散列函數計算得到的散列值。
散列表來源于數組,借助散列函數對數組進行了擴展.png
    1. 散列函數的設計非常考究,需要滿足以下三個條件:
    - (1). 散列函數計算得出的散列值是一個非負整數;
    - (2). 如果key1 = key2,那么hash(k1) = hash(k2);
    - (3). 如果key1 != key2,那么hash(k1) != hash(k2)。
    1. 第一點很好理解,因為數組下標從0開始的,所以需要非負整數;第二點也好理解,key相同,那么散列值也應相同;
    • 第三點,看起來合情合理,但是實際情況下,想要找到key不同,散列值一定不同的散列函數幾乎是不可能的,即便是業界著名的MD5、SHA、CRC等哈希算法,也無法完全避免這種散列沖突。而且數組的存儲空間有限,也會加大散列沖突的概率。
二、如何解決散列沖突?

所謂散列沖突,上面也講過了,就是key不同的時候,散列值hash(key)卻意外的相同了。而且再好的散列函數也無法避免散列沖突,那么應該怎么解決呢? 常用的有兩種解決辦法:開放尋址法、鏈表法

1. 開放尋址法
  • (1). 開放尋址法的核心思想就是:如果出現了散列沖突,我們就重新探測一個空閑位置,插入進去。

  • (2). 那么如何重新探測空閑位置呢?這里介紹一個比較簡單的方法:線性探測,當我們給散列表插入數據時,如果發現存儲位置已經被占用了,我們就從當前位置依次往后查找,直到找到空閑位置為止。

  • (3). 探測空閑位置除了線性探測法,還有兩種比較經典的探測方法:二次探測法、雙重散列法

    • 二次探測跟線性探測很相似,線性探測每次探測的步長是1,探測的下標為hash(key) + 0、hash(key) + 1、hash(key) + 2、hash(key) + 3......,而二次探測步長是原來的二次方,探測下標為hash(key) + 02 、hash(key) + 12、hash(key) + 22、hash(key) + 32......

    • 雙重散列法,就是不僅要使用一個散列函數,而是使用一組散列函數hash1(key)、hash2(key)、hash3(key)、hash4(key)......,我們先使用第一個散列函數,如果計算得到的存儲空間被占用了,在用第二個散列函數,依次類推,知道找到空閑的存儲位置。

  • (4). 不管用哪種探測辦法,當散列表中空閑位置不多時,散列沖突發生的概率都會大大提高。為了更加直觀的表示散列表中的空閑位置的多少,引入了裝載因子的概念,裝載因子越大,說明空閑位置越少,沖突發生的概率越大,散列表的性能下降。

//裝載因子用來衡量散列中空閑位置的多少
散列表的裝載因子 = 填入表中的數據 / 散列表的長度
2. 鏈表法
  • (1). 鏈表法是一種更加常用的散列沖突解決辦法,相比于開發尋址法,鏈表法要簡單的多,如下圖所示,在散列表中,每個桶(bucket)或者槽(slot)都會對應一條鏈表,所有散列值相同的元素都放在相同的槽位所對應的鏈表中。


    鏈表法
  • (2). 使用鏈表法解決散列沖突的散列表,插入元素的時候,只需要通過散列函數計算出數組下標,然后插入到下標所對應的鏈表中即可,所以時間復雜度為O(1)

  • (3). 但是查找和刪除的時間復雜度就跟鏈表長度有關系了,如果鏈表長度是k的話,那么查找和刪除的時間復雜度就是O(k)。如果是散列比較均勻的散列函數,分到每個槽的數據都差不多,平均鏈表長度k就等于數據總數/槽的個數。

三、如何設計工業級別的散列表?

散列表的查詢效率不能簡單地說是O(1),它跟散列函數、裝載因子、散列沖突都有關系,如果散列函數設計的不好或者裝載因子過高,都有可能導致散列沖突發生的概率提高,查詢效率下降。

極端情況下,有些惡意攻擊者,會精心構造數據,使所有的數據經過散列函數之后,都跳到同一個槽里,如果用鏈表法解決散列沖突,散列表就會退化為鏈表,時間復雜度從O(1)退化到O(n)

如果散列表中有10萬條數據,那么退化后的查詢效率就下降了10萬倍,原先只需要0.1秒就可以查詢到,現在需要1萬秒,這樣的查詢操作就會消耗大量CPU或線程資源,導致系統無法響應其他請求,從而達到拒絕服務攻擊(DOS)的目的,這就是散列表碰撞攻擊的基本原理。

那么,如何設計一個可以應對各種異常情況的工業級別的散列表,避免在散列沖突的
情況下,散列表的性能急劇下降,并且能抵抗散列碰撞攻擊?

1. 工業級散列表的要求
    1. 支持快速的插入、查詢、刪除操作
    1. 內存占用合理,不能浪費過多內存空間
    1. 性能穩定,極端情況下,散列表的性能也不能退化到無法接受。
2. 如何設計一個合適的散列函數?
    1. 散列函數設計的不能太復雜,太復雜勢必會消耗很多計算時間,間接影響了散列表性能。
    1. 散列函數生成的值要盡可能隨機并且均勻分布,這樣才能最小化散列沖突,即便出現沖突,散列到每個槽里的數據也會比較均勻,不會出現某個槽里的數據特別多的情況。
    1. 實際工作中,還需要考慮各種因素,包括key的長度、特點、分布,還有散列表的大小。常用的設計方法有:數據分析法、直接尋址法、平方取中法、折疊法、隨機數法等等。
3. 如何設計動態擴容策略?
    1. 隨著不斷的插入操作,裝載因子不斷擴大,當裝載因子大到一定程度后,散列沖突就會變得不可接受。這個時候我們就需要動態擴容了,當裝載因子達到一定的閾值的時候,重新申請一個更大的散列表,將數據搬移到新的散列表中。
    1. 裝載因子閾值的設置要權衡時間、空間復雜度。如果內存不緊張,對執行效率要求比較高,就可以適當降低裝載因子閾值;相反,如果內存空間緊張,對執行效率要求又不高,那么就可以適當增加裝載因子閾值。
    1. 大部分情況下,往散列表中插入一個數據都會很快,但是當裝載因子達到閾值之后,需要進行動態擴容的時候,需要新建一個更大的散列表,并且進行數據搬運操作,數據搬運的時候還得重新計算哈希值,此時就會非常耗時。
    • 為了避免這種極個別的非常耗時操作,我們一般都會把集中一次的數據搬運操作,分散到多次操作中,當裝載因子達到閾值之后,我們申請新的散列表,但并不搬移數據,當有新數據插入的時候直接插入到新的散列表,并且從舊散列表中搬運一條數據到新散列表,沒有集中的搬運操作,就使得每次插入操作都很快了。

    • 這期間的查詢操作,需要先查新的散列表,再查老散列表,通過這樣均攤下來,將以此擴容分攤到多次插入操作中,就可以保證任何情況下插入一個數據的時間復雜度都是O(1)了。

4. 如何選擇合適的沖突解決辦法?
    1. 開放尋址法的數據都在數組中,可以有效利用CPU緩存加快查詢速度;但是刪除數據比較麻煩,沖突的代價更高,裝載因子的上限不能太大。
    • 開放尋址法的裝載因子不能超過1,接近1時,就會產生大量的散列沖突,導致大量的探測、再散列等,性能會下降很多。

    • 而鏈表法的裝載因子即便變成10,只要散列函數的值隨機均勻,也就是鏈表的長度長了點而已,還是比順序查找快多了。

總結一下: 當數據量較小、裝載因子小的時候,適合采用開放尋址法。
    1. 鏈表法對內存利用率較高,對大的裝載因子容忍度更高;但是鏈表的內存是零散的,對CPU緩存不友好。
    • 其實我們對鏈表法稍加改造,將鏈表法中的鏈表改成跳表、紅黑樹等更高效的數據結構,就會更加高效。這樣的話,即便出現散列沖突,極端情況下,所有的數據都進入到一個槽里,退化之后的查詢時間也只不過是O(logn),這樣就有效的避免了散列碰撞攻擊。
總結一下: 鏈表法更適合于存儲大對象、大數據量的散列表,支持更多的優化策略,例如用紅黑樹代替鏈表。
四、舉例說明工業級別的散列表,以JAVA中的HashMap為例
    1. 初始大小,HashMap中的默認初始大小是16,如果事先知道大致數據量,就可以通過修改默認大小,減少動態擴容次數,提高HashMap性能。
    1. 裝載因子和動態擴容,HashMap的裝載因子默認是0.75,超過這個閾值,就會啟動動態擴容,每次擴容都會變成原來大小的2倍。
    1. 散列沖突解決辦法,HashMap底層采用鏈表法解決散列沖突,當鏈表長度過長時,鏈表就會轉化成紅黑樹;當紅黑樹結點個數少于8個時,紅黑樹又會轉化為鏈表。
    1. 散列函數,HashMap的散列函數并不復雜,追求的是簡單高效、分局均勻,如下所示:
int hash(Object key) {
    int h = key.hashCode();
    return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
}

第十四章 為什么散列表和鏈表經常放在一起使用?

一、為什么散列表和鏈表經常放在一起使用?
    1. 散列表支持高效的查詢、插入、刪除操作,時間復雜度都是O(1),但是散列表中的數據都是通過散列函數打亂之后無規律存放的,也就是說,它無法支持按照某種順序快速的遍歷數據。
    1. 因為散列表是動態數據結構,會有很多插入或刪除操作,當我們想按照順序遍歷散列表中的數據時,就需要先排序,這樣效率勢必會很低,為了解決這個問題,我們將散列表和鏈表(或者跳表)結合在一起使用。
二、如何將散列表和鏈表組合使用?

以一個插入、刪除、查找時間復雜度均為O(1)的,LRU緩存淘汰算法為例。

    1. 先回顧一下如何用鏈表實現的LRU緩存淘汰算法的
    • (1). 我們需要維護一個訪問時間從大到小有序排列的鏈表結構,因為緩存大小有限,當緩存空間不足時,將鏈表的頭部結點刪除。

    • (2). 當要插入某個數據時,先查找鏈表中是否存在,存在的話,就把它移動到鏈表尾部;不存在的話,就直接把它放到鏈表尾部;當要刪除某個數據時,先查找到數據的位置再刪除它。因為查找數據需要遍歷整個鏈表,所以單純用鏈表實現的LRU緩存淘汰算法,插入、刪除、查找的時間復雜度都會很高,均為O(n)。

    • (3). 那么我們如何優化一下這個LRU算法,可以使得插入、刪除、查找的時間復雜度都變成O(1)呢?

    1. 答案就是將散列表和鏈表組合使用
    • (1). 我們使用雙向鏈表存儲數據,鏈表中每個節點都需要存儲:數據(data)、前驅指針(prev)、后繼指針(next)、hnext指針(解決散列沖突用的)

    • (2). 同時呢也使用散列表存儲數據(這個散列表使用鏈表法來解決散列沖突),所以每個節點都會在兩條鏈中。一條是雙向鏈表鏈、一條是散列表的鏈表鏈。前驅指針和后繼指針是為了將結點串在雙向鏈表中,hnext指針為了將結點串在散列表的鏈表中。最終組合效果如下圖所示:

散列表和雙向鏈表組合使用,以實現高效的LRU
    1. 通過這樣組合使用之后呢,查找一個數據可以直接使用散列表查找,時間復雜度就變成了O(1)了,同理刪除和插入的時間復雜度也降低為O(1)了。
總結:鏈表的優勢是可以記錄順序,散列表的優勢是可以快速查找,兩個結合使用,可以大幅提高效率。

第十五章 哈希算法

一、什么是哈希算法?
    1. 哈希算法的定義非常簡單,一句話就可以概括,將任意長度的二進制值串映射為固定長度的二進制串,這個映射的規則就是哈希算法,原始數據經過映射得到的二進制值串,就是哈希值
    1. 設計一個優秀的哈希算法并不容易,一般需要滿足以下條件:
    • 從哈希值不能反向推導出原始數據(所以哈希算法也叫單向哈希算法)

    • 對輸入數據非常敏感,哪怕原始數據只修改了1Bit,最后得到的哈希值也大不相同。

    • 散列沖突的概率很小,針對不同的原始數據進行哈希,得到相同哈希值的概率很小。

    • 哈希算法的執行效率要盡量高效,針對較長文本,也能快速計算得出哈希值。

    1. 例如經典的MD5哈希算法,即便只有一個字符不同,得到的哈希值也大相庭徑。
MD5(" 我今天講哈希算法!") = 425f0d5a917188d2c3c3dc85b5e4f2cb
MD5(" 我今天講哈希算法 ") = a1fb91ac128e6aa37fe42c663971ac3d
    1. 哈希算法的應用非常多,這了介紹常見的7個應用:安全加密、唯一標識、數據校驗、散列函數、負載均衡、數據分片、分布式存儲。
二、哈希算法的應用 - 安全加密
    1. 最常見的用于加密的哈希算法有:MD5(Message-Digest Algorithm,MD5 消息摘要算法) 和 SHA(Secure Hash Algorithm,SHA 安全散列算法),除此之外,還有很多關于加密的哈希算法,例如DES(Data Encryption Standard,數據加密標準)、AES(Advanced Encryption Standard,高級加密標準)
    1. 對于加密的哈希算法而言,有兩點格外重要,第一點是很難通過哈希值反向推導出原始數據,第二點是散列沖突概率要小。
    1. 無論再好的哈希算法都無法避免散列沖突,這是為什么呢?這里有一個鴿巢理論,就是說,如果有10個鴿巢,11只鴿子,那么肯定有1個鴿巢中鴿子的數量超過1只,也就是肯定有1個鴿巢存在至少2只鴿子。
    1. 同理,哈希算法產生的哈希值的長度是固定的且有限的,例如MD5哈希算法,產生的哈希值是固定128位的二進制串,能表示的數據是有限的,最多能表示2^128個數據,而我們要哈希的數據是無窮的,就必然存在哈希值相同的情況。
    1. 沒有絕對安全的加密,越復雜、越難破解的加密方法,所需要的計算時間也就越長。例如SHA-265就比SHA-1更復雜、更安全,響應的計算時間也就會比較長。
三、哈希算法的應用 - 唯一標識
    1. 通過哈希算法,可以將部分數據抽取,并進行哈希,作為整體數據的唯一標識
    1. 例如,如何從海量的圖庫中搜索一張圖?我們就可以從圖片的二進制碼串抽取數據,從開頭取100個字節,從中間取100個字節,從結尾取100個字節,然后將這300個字節放在一起,通過哈希算法,生成一個哈希值,作為唯一標識,通過這個唯一標識判斷是否在圖庫中。
    1. 如果想進一步優化,我們就可以把圖片的唯一標識和圖片路徑存儲在散列表中,查詢的時候,通過哈希算法對圖片取唯一標識,然后去散列表中查找是否存在;如果不存在,則圖片不在圖庫中;如果存在,就用圖片路徑取到圖片,然后與要對比的圖片一起做全量對比,完全一致,則為同一張圖。
四、哈希算法的應用 - 數據校驗
    1. 我們使用BT下載文件時,需要先下載文件的種子,種子里面包含了文件的哈希值,文件下載完畢后,我們對文件進行哈希得到哈希值,與種子中的哈希值進行對比,就可以知道文件是否被篡改過或者缺失過,通過這種方式,我們就可以實現數據校驗。
    1. 之所以可以這樣對比,是因為哈希算法有一個特點,就是對數據非常敏感,只要文件有一丁點內容改變,最后得出的哈希值也會完全不同,所以我們可以利用哈希算法數據敏感的特性,來實現數據校驗。
五、哈希算法的應用 - 散列函數
    1. 散列表中的散列函數,就是哈希算法的一種應用,散列函數是散列表的關鍵所在,直接決定了散列沖突的概率和散列表的性能。
    1. 散列函數對是否能反向解密并不關心,更加關心散列后的值是否可以分布均勻;即便出現散列沖突也沒事,只要不太過嚴重,都可以通過開放尋址法和鏈表法解決。
六、哈希算法的應用 - 負載均衡
    1. 如何實現一個會話粘滯的負載均衡算法呢?即同一個客戶端,一次會話中所有的請求都路由到同一個服務器上
    1. 最簡單的方法就是維護一張散列表,散列表中存放客戶端IP地址與服務器編號的映射關系,客戶端每次發出請求,都需要在映射表中查找服務器編號,再進行請求。這種做法很簡單,但是有幾個缺陷:
    • 如果客戶端很多,那么映射表可能會非常大,比較浪費內存空間
    • 如果客戶端上線、下線,映射表擴容、縮容時都會導致映射失敗,需要重新維護映射表。
    1. 借助哈希算法,我們就可以很完美的解決這些問題,我們可以通過哈希算法,對客戶端IP地址計算哈希值,將哈希值與服務器列表的大小進行取模運算,最終得到的值就是應該被路由的服務器編號。這樣,我們就可以做到同一個IP地址過來的所有請求,都路由到同一個后端服務器上了。
七、哈希算法的應用 - 數據分片
    1. 如果我們對海量的數據進行分析,一臺機器的內存不夠用怎么辦?我們可以采用多機分布式處理,對數據進行分片,以突破單機內存、CPU等資源的限制。具體可以這樣來操作:我們將數據通過哈希算法計算出哈希值,與機器總數n進行取模,得到的值就是應該被分配到的機器編號,就可以將數據分配到不同的機器上。
    1. 例如:我們前邊講過的,如何快速判斷圖片是否在圖庫中?就是取圖片的唯一標識與路徑構建散列表。假設我們有1億張圖片,很顯然,所需要的內存遠遠超過了單臺機器的上限。
    1. 我們就可以使用數據分片,采用多臺機器處理。從圖庫中取一張圖,計算唯一標識,然后與機器總數n取模,得到的值就是要分配的機器編號,然后將唯一標識和圖片路徑發往對應的機器構建散列表,這就完成了數據分片。
    1. 判斷某張圖是否在圖庫中時,就可以通過同樣的哈希算法,計算出唯一標識,然后與機器總數n取模,得到機器編號,然后去對應的機器中查詢散列表。
    1. 現在,我們來計算一下,給1億張圖片構建散列表大約需要多少臺機器呢?
    1. 假設我們用MD5計算哈希值,哈希值長度就是128比特,也就是16字節;文件路徑平均大小是128字節;用鏈表法解決散列沖突的話,需要存儲指針,指針占用8字節;所以散列表中的每個數據單元大約占用152個字節。
    1. 假設一臺機器內存為2G大小,散列表的裝載因子為0.75,那么一臺機器大概可以給1000萬張圖片構建散列表,2x1024x1024x1024x0.75/152大約為1千萬,1億張圖片大約需要10臺機器。在工程中這種估算還是很重要的,能事先對需要投入的資源、資金有個大概了解,更好的評估解決方案的可行性。
八、哈希算法的應用 - 分布式存儲
    1. 現在的互聯網都是有海量的數據的,而海量的數據需要緩存,一個緩存機器肯定是不夠的,所以我們需要將數據分布在多臺機器上,這就是分布式存儲的思想。
    1. 我們可以借用數據分片的思想,通過哈希算法取到哈希值,然后與機器個數取模,得到機器編號,將數據存儲在對應編號的機器上。但是,這種做法會產生一個問題,就是需要擴容時,就會增加機器數,這是否取模的結果就會發生變化,導致所有數據請求都會穿透緩存,直接去請求數據庫,這樣就可能發生雪崩效應,壓垮數據庫。(例如:有10臺機器,數據13與10取模得到3,去3號機器拿緩存數據,此時,增加了1臺機器,數據13與11取模得到了2,就回去2號機器拿緩存數據,拿不到緩存,就會去請求數據庫,從而引發雪崩,所以機器擴容、縮容后,要對數據做大量的搬移工作,以避免雪崩)
    1. 但是大量的數據遷移費時費力,有沒有辦法可以避免大量的數據遷移呢?一致性哈希算法就要登場了。一致性哈希算法可以使我們在新增機器后,不需要大量遷移數據。
      這里有個漫畫很好的解釋了一致性哈希算法的原理,可以點擊查看
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,936評論 6 535
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,744評論 3 421
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,879評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,181評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,935評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,325評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,384評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,534評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,084評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,892評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,623評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,322評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,735評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,990評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,800評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,084評論 2 375

推薦閱讀更多精彩內容