散列表(上)

散列思想

散列表就是咱們日常說的哈希表,英文名叫"Hash Table",其基礎依據就是:java

散列表用的是數組支持按照下標隨機訪問數據的特性,因此散列表其實就是數組的一種擴展,由數組演化而來。能夠說,若是沒有數組,就沒有散列表。

這裏仍是直接使用老師的例子來講事吧.中間添加本身的思想就好了.本身想例子又得半天,並且咱們的目標也不是想一個好例子,而是真正理解並掌握知識.對吧?web


用一個例子來解釋一下。假如咱們有 89 名選手參加學校運動會。爲了方便記錄成績,每一個選手胸前都會貼上本身的參賽號碼。這 89 名選手的編號依次是 1 到 89。如今咱們但願編程實現這樣一個功能,經過編號快速找到對應的選手信息。你會怎麼作呢?算法

咱們能夠把這 89 名選手的信息放在數組裏。編號爲 1 的選手,咱們放到數組中下標爲 1 的位置;編號爲 2 的選手,咱們放到數組中下標爲 2 的位置。以此類推,編號爲 k 的選手放到數組中下標爲 k 的位置。 由於參賽編號跟數組下標一一對應,當咱們須要查詢參賽編號爲 x 的選手的時候,咱們只須要將下標爲 x 的數組元素取出來就能夠了,時間複雜度就是 O(1)。這樣按照編號查找選手信息,效率是否是很高?編程

實際上,這個例子已經用到了散列的思想。在這個例子裏,參賽編號是天然數,而且與數組的下標造成一一映射,因此利用數組支持根據下標隨機訪問的時候,時間複雜度是 O(1) 這一特性,就能夠實現快速查找編號對應的選手信息。api

你可能要說了,這個例子中蘊含的散列思想還不夠明顯,那我來改造一下這個例子。數組

假設校長說,參賽編號不能設置得這麼簡單,要加上年級、班級這些更詳細的信息,因此咱們把編號的規則稍微修改了一下,用 6 位數字來表示。好比 051167,其中,前兩位 05 表示年級,中間兩位 11 表示班級,最後兩位仍是原來的編號 1 到 89。這個時候咱們該如何存儲選手信息,纔可以支持經過編號來快速查找選手信息呢?緩存

思路仍是跟前面相似。儘管咱們不能直接把編號做爲數組下標,但咱們能夠截取參賽編號的後兩位做爲數組下標,來存取選手信息數據。當經過參賽編號查詢選手信息的時候,咱們用一樣的方法,取參賽編號的後兩位,做爲數組下標,來讀取數組中的數據。 這就是典型的散列思想。其中,參賽選手的編號咱們叫做鍵(key)或者關鍵字。咱們用它來標識一個選手。咱們把參賽編號轉化爲數組下標的映射方法就叫做散列函數(或「Hash 函數」「哈希函數」),而散列函數計算獲得的值就叫做散列值(或「Hash 值」「哈希值」)。
在這裏插入圖片描述數據結構

因此散列表的思想就是:svg

散列表用的就是數組支持按照下標隨機訪問的時候,時間複雜度是 O(1) 的特性。咱們經過散列函數把元素的鍵值映射爲下標,而後將數據存儲在數組中對應下標的位置。當咱們按照鍵值查詢元素時,咱們用一樣的散列函數,將鍵值轉化數組下標,從對應的數組下標的位置取數據。函數

散列函數

從上面的例子咱們能夠看出,如何將鍵轉換爲數組的下標這是相當重要的一步,而這一步正是散列函數所起得做用.

散列函數其實就是hash(key),其中key就是鍵值,hash(key)表示通過散列函數計算獲得的散列值.

好比,改造後的例子的散列函數就是:

int hash_fun(string key)
{
	//提取最後兩位字符
    string LastTwoChars = key.substr(key.length() - 2, 2);
    //將其轉換爲 int 類型
    int index = stoi(LastTwoChars);
    return index;
}

以上就是散列的入門了,如今咱們來看一些複雜的操做.

若是參賽選手的編號是隨機生成的 6 位數字,又或者用的是 a 到 z 之間的字符串,該如何構造散列函數呢?我總結了三點散列函數設計的基本要求:

  • 散列函數計算獲得的散列值是一個非負整數(要做爲數組下標來使用)
  • 若是 key1 = key2,那 hash(key1) == hash(key2)(相同的key,相同的散列值)
  • 若是 key1 ≠ key2,那 hash(key1) ≠ hash(key2)(難以辦到,會出現散列衝突。並且,由於數組的存儲空間有限,也會加大散列衝突的機率)

散列衝突:其實就是說,有時候可能key不相同,但經過該散列函數計算出來的散列值是相同的.

如何解決散列衝突?

再好的散列函數也沒法避免散列衝突。那究竟該如何解決散列衝突問題呢?咱們經常使用的散列衝突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)

1. 開放尋址法

開放尋址法的核心思想就是,若是出現了散列衝突,咱們就從新探測一個空閒位置,將其插入。那如何從新探測新的位置呢?主要有如下幾種方法:

(1) 線性探測(Linear Probing)。

當咱們往散列表中插入數據時,若是某個數據通過散列函數散列以後,存儲位置已經被佔用了,咱們就從當前位置開始,依次日後查找,看是否有空閒位置,直到找到爲止。

舉一個例子具體說明一下。這裏面黃色的色塊表示空閒位置,橙色的色塊表示已經存儲了數據。
在這裏插入圖片描述

從圖中能夠看出,散列表的大小爲 10,在元素 x 插入散列表以前,已經 6 個元素插入到散列表中。x 通過 Hash 算法以後,被散列到位置下標爲 7 的位置,可是這個位置已經有數據了,因此就產生了衝突。因而咱們就順序地日後一個一個找,看有沒有空閒的位置,遍歷到尾部都沒有找到空閒的位置,因而咱們再從表頭開始找,直到找到空閒位置 2,因而將其插入到這個位置。

這是其插入,那麼如何查找吶?

咱們經過散列函數求出要查找元素的鍵值對應的散列值,而後比較數組中下標爲散列值的元素和要查找的元素。若是相等,則說明就是咱們要找的元素;不然就順序日後依次查找。若是遍歷到數組中的空閒位置,尚未找到,就說明要查找的元素並無在散列表中。

這是其查找,那麼如何刪除一個元素吶?

將刪除的元素,特殊標記爲 deleted。當線性探測查找的時候,遇到標記爲 deleted 的空間,並非停下來,而是繼續往下探測。

提問:爲何不把要刪除的元素設置爲空吶?

由於在查找的時候,一旦咱們經過線性探測方法,找到一個空閒位置,咱們就能夠認定散列表中不存在這個數據。可是,若是這個空閒位置是咱們後來刪除的,就會致使原來的查找算法失效。原本存在的數據,會被認定爲不存在

在這裏插入圖片描述

散列表的缺點

當散列表中插入的數據愈來愈多時,散列衝突發生的可能性就會愈來愈大,空閒位置會愈來愈少,線性探測的時間就會愈來愈久。極端狀況下,咱們可能須要探測整個散列表,因此最壞狀況下的時間複雜度爲 O(n)。同理,在刪除和查找時,也有可能會線性探測整張散列表,才能找到要查找或者刪除的數據。

(2) 二次探測(Quadratic probing)

二次探測與線性探測很像,線性探測每次探測的步長是 1,那它探測的下標序列就是 hash(key)+0,hash(key)+1,hash(key)+2……二次探測探測的步長就變成了原來的「二次方」,也就是說,它探測的下標序列就是 hash(key)+0,hash(key)+1^2,hash(key)+2^2……

(3) 雙重散列(Double hashing)

所謂雙重散列,意思就是不只要使用一個散列函數。咱們使用一組散列函數 hash1(key),hash2(key),hash3(key)……咱們先用第一個散列函數,若是計算獲得的存儲位置已經被佔用,再用第二個散列函數,依次類推,直到找到空閒的存儲位置。

以上三種方法,在遇到插入的數據愈來愈多時,散列衝突發生的可能性也會愈來愈大,通常狀況下,咱們都會在散列表中維持必定比例的空閒槽位,來保證效率.咱們用裝載因子來表示空位的多少.

散列表的裝載因子 = 填入表中的元素個數 / 散列表的長度

裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會降低。

2. 鏈表法

鏈表法是一種更加經常使用的散列衝突解決辦法,相比開放尋址法,它要簡單不少。咱們來看這個圖,在散列表中,每一個「桶(bucket)」或者「槽(slot)」會對應一條鏈表(其實就是相似於鄰接表,不是嗎?),全部散列值相同的元素咱們都放到相同槽位對應的鏈表中。

在這裏插入圖片描述

當插入的時候,直接找到對應的槽位,頭插鏈表便可,時間複雜度爲O(1).查找與刪除天然須要遍歷對應的整個鏈表完成,時間複雜度是O(k),k是指鏈表長度.對於散列比較均勻的散列函數來講,理論上講,k=n/m,其中 n 表示散列中數據的個數,m 表示散列表中「槽」的個數。

一些思考題:

  1. Word文檔中的單詞拼寫檢查功能是如何實現的?

日常所用的單詞就20萬個,而一個單詞假設有10個字母,那麼就是2百萬的字母,即2百萬字節,也就是2MB,再大點也就是200MB,計算機內存徹底能夠放得下,因此咱們使用散列表來存儲整個英文詞典

當用戶輸入某個英文單詞時,咱們拿用戶輸入的單詞去散列表中查找。若是查到,則說明拼寫正確;若是沒有查到,則說明拼寫可能有誤,給予提示。藉助散列表這種數據結構,咱們就能夠輕鬆實現快速判斷是否存在拼寫錯誤。


  1. 假設咱們有 10 萬條 URL 訪問日誌,如何按照訪問次數給 URL 排序?

答:這道題剛開始我不明白是什麼個意思,後來看了答案,品了幾天,算是終於明白了.

首先咱們要搞清楚排序的對象是什麼?其實很明顯,就是URL,只不過得按照訪問次數進行排序.好比下面這個的例子:

www.1111.xxxxx
www.2222.xxx
www.1111.xxxxx

那麼結果是什麼吶?

www.1111.xxxxx
www.2222.xxx

就是上面這樣就好了

由例子可知,咱們必須遍歷一遍,找到對應的URL的訪問次數和去掉重複的URL
而後咱們去按照訪問次數排序去掉重複的URLURL集合便可.

sort(set.bagin(),set.end(),[](string lhs,string rhs){
	return HashTable[hashfun(lhs)] < HashTable[hashfun(rhs)]; 
	// 按照訪問次數排序`URL`集合
}

  1. 有兩個字符串數組,每一個數組大約有 10 萬條字符串,如何快速找出兩個數組中相同的字符串?

答:以第一個字符串數組爲基準創建散列表.具體以字符串爲 key ,以出現次數爲value ,默認爲0.而後遍歷第二個字符串數組,用每個字符串去找,若是value != 0 ,就說明找到一個兩個數組中相同的字符串.


由以上可知,散列表的查詢效率與散列函數,裝載因子等有莫大的關係.若是使用的是鏈表,就有可能從O(1)的查詢效率退化爲O(n),這是咱們不容許的.那麼如何解決這個問題吶?或者說是咱們如何設計一個能夠應對各類異常狀況的工業級散列表,來避免在散列衝突的狀況下,散列表性能的急劇降低,而且能抵抗散列碰撞攻擊?這就是咱們這一節要談論的問題了

如何設計散列函數?

一個好的散列函數應該具備如下幾點:

  • 散列函數的設計不能太複雜。(過於複雜的散列函數,勢必會消耗不少計算時間,也就間接的影響到散列表的性能 )
  • 散列函數生成的值要儘量隨機而且均勻分佈.(這樣才能避免或者最小化散列衝突,並且即使出現衝突,散列到每一個槽裏的數據也會比較平均,不會出現某個槽內數據特別多的狀況。)

散列函數主要有:直接尋址法、平方取中法、摺疊法、隨機數法,數據分析法等(這些只要瞭解就好了)

提問:散列函數的做用究竟是什麼?

答:將key轉換爲數組的索引 hashfun(key)

裝載因子過大了怎麼辦?

散列表基於數組設計,在申請時就固定了大小.因此對於動態頻繁的散列表來說,裝載因子也必定會變得愈來愈大.一個能夠採起的方法就是:動態擴容(其實就和vector同樣)

從新申請一個更大的散列表,將數據搬移到這個新散列表中。假設每次擴容咱們都申請一個原來散列表大小兩倍的空間。若是原來散列表的裝載因子是 0.8,那通過擴容以後,新散列表的裝載因子就降低爲原來的一半,變成了 0.4。

針對數組的擴容,數據搬移操做比較簡單。可是,針對散列表的擴容,數據搬移操做要複雜不少。由於散列表的大小變了,數據的存儲位置也變了,因此咱們須要經過散列函數從新計算每一個數據的存儲位置。

你能夠看我圖裏這個例子。在原來的散列表中,21 這個元素原來存儲在下標爲 0 的位置,搬移到新的散列表中,存儲在下標爲 7 的位置。

在這裏插入圖片描述

插入時間複雜度:

插入一個數據,最好狀況下,不須要擴容,最好時間複雜度是 O(1)。最壞狀況下,散列表裝載因子太高,啓動擴容,咱們須要從新申請內存空間,從新計算哈希位置,而且搬移數據,因此時間複雜度是 O(n)。用攤還分析法,均攤狀況下,時間複雜度接近最好狀況,就是 O(1)。

若是隨着刪除的元素愈來愈多,散列表能夠選擇縮容(和擴容相似),另外,裝載因子的閥值要更具實際狀況肯定.

如何避免低效地擴容?

何之爲低效:插入致使擴容,假設數據1GB,操做過程爲 **申請空間->將舊數據搬移到新的空間->從新計算哈希值->插入.**用腦子想,這確定會很慢.

解決方法:

將擴容操做穿插在插入操做的過程當中,分批完成當裝載因子觸達閾值以後,咱們只申請新空間,但並不將老的數據搬移到新散列表中。

當有新數據要插入時,咱們將新數據插入新散列表中,而且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據(其實不必定一個數據啦)到散列表,咱們都重複上面的過程。通過屢次插入操做以後,老的散列表中的數據就一點一點所有搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操做就都變得很快了。

在這裏插入圖片描述

這種實現方式,任何狀況下,插入一個數據的時間複雜度都是 O(1)。不知道vector是否是這樣作的吶?(我下來研究一下)

如何查詢:先重新散列表中查找,若是沒有找到,再去老的散列表中查找。

如何選擇衝突解決方法?

1.開放尋址法

  • 優勢

    • 數據都存儲在數組中,能夠有效地利用 CPU 緩存加快查詢速度
    • 序列化起來比較簡單
  • 缺點

    • 刪除數據的時候比較麻煩
    • 衝突的代價更高
  • 適用場景

    當數據量比較小、裝載因子小(< 1)的時候,適合採用開放尋址法。這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列衝突的緣由。

2.鏈表法的適用場景

  • 優勢

    • 不會大量浪費內存(由於是鏈表嘛,對吧)
    • 對大裝載因子的容忍度更高(只要散列函數隨即均勻,他是多少幾乎都可以接受)
  • 缺點

    • 對 CPU 緩存是不友好的(由於是鏈表嘛,對吧)
    • 比較小的對象的存儲,是比較消耗內存的(有可能一個指針的大小都會比存儲對象要大哦)
  • 適用場景

    基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表,並且,比起開放尋址法,它更加靈活,支持更多的優化策略,好比用紅黑樹代替鏈表。

在這裏插入圖片描述

極端狀況下,全部的數據都散列到同一個桶內,那最終退化成的散列表的查找時間也只不過是 O(logn)。這樣也就有效避免了前面講到的散列碰撞攻擊。

工業級散列表舉例分析 (Java 中的 HashMap)

1. 初始大小

HashMap 默認的初始大小是 16,固然這個默認值是能夠設置的,若是事先知道大概的數據量有多大,能夠經過修改默認初始大小,減小動態擴容的次數,這樣會大大提升 HashMap 的性能。

2. 裝載因子和動態擴容

最大裝載因子默認是 0.75,當 HashMap 中元素個數超過 0.75*capacity(capacity 表示散列表的容量)的時候,就會啓動擴容,每次擴容都會擴容爲原來的兩倍大小。

3. 散列衝突解決方法

HashMap 底層採用鏈表法來解決衝突。即便負載因子和散列函數設計得再合理,也免不了會出現拉鍊過長的狀況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的性能。

因而,在 JDK1.8 版本中,爲了對 HashMap 作進一步優化,咱們引入了紅黑樹。而當鏈表長度太長(默認超過 8)時,鏈表就轉換爲紅黑樹。咱們能夠利用紅黑樹快速增刪改查的特色,提升 HashMap 的性能。當紅黑樹結點個數少於 8 個的時候,又會將紅黑樹轉化爲鏈表。由於在數據量較小的狀況下,紅黑樹要維護平衡,比起鏈表來,性能上的優點並不明顯。

4. 散列函數

int hash(Object key) {
    int h = key.hashCode()return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
}

5.如何設計一個工業級別的散列表?

何爲一個工業級的散列表?工業級的散列表應該具備哪些特性?
結合已經學習過的散列知識,我以爲應該有這樣幾點要求:

  • 支持快速的查詢、插入、刪除操做;
  • 內存佔用合理,不能浪費過多的內存空間;
  • 性能穩定,極端狀況下,散列表的性能也不會退化到沒法接受的狀況。 **

如何實現這樣一個散列表呢**?根據前面講到的知識,我會從這三個方面來考慮設計思路:

  • 設計一個合適的散列函數;
  • 定義裝載因子閾值,而且設計動態擴容策略;
  • 選擇合適的散列衝突解決方法。

只要咱們朝這三個方向努力,就離設計出工業級的散列表不遠了。

hash表是必需要存儲在內存中才能發揮功效,要注意這一點

相關文章
相關標籤/搜索