看動畫學演算法之:hashtable

flydean發表於2021-11-22

簡介

java中和hash相關並且常用的有兩個類hashTable和hashMap,兩個類的底層儲存都是陣列,這個陣列不是普通的陣列,而是被稱為雜湊表的東西。

雜湊表是一種將鍵對映到值的資料結構。它用雜湊函式來將鍵對映到小範圍的指數(一般為[0..雜湊表大小-1])。同時需要提供衝突和對衝突的解決方案。

今天我們來學習一下雜湊表的特性和作用。

文末有程式碼地址,歡迎下載。

雜湊表的關鍵概念

雜湊表中比較關鍵的三個概念就是雜湊表,hash函式,和衝突解決。

雜湊是一種演算法(通過雜湊函式),將大型可變長度資料集對映為固定長度的較小整數資料集。

雜湊表是一種資料結構,它使用雜湊函式有效地將鍵對映到值,以便進行高效的搜尋/檢索,插入和/或刪除。

雜湊表廣泛應用於多種計算機軟體中,特別是關聯陣列,資料庫索引,快取和集合。

雜湊表必須至少支援以下三種操作,並且儘可能高效:

搜尋(v) - 確定v是否存在於雜湊表中,
插入(v) - 將v插入雜湊表,
刪除(v) - 從雜湊表中刪除v。

因為使用了雜湊演算法,將長資料集對映成了短資料集,所以在插入的時候就可能產生衝突,根據衝突的解決辦法的不同又可以分為線性探測,二次探測,雙倍雜湊和分離連結等衝突解決方法。

陣列和雜湊表

考慮這樣一個問題:找到給定的字串中第一次重複出現的的字元。

怎麼解決這個問題呢?最簡單的辦法就是進行n次遍歷,第一次遍歷找出字串中是否有和第一個字元相等的字元,第二次遍歷找出字串中是否有和第二個字元相等的字元,以此類推。

因為進行了n*n的遍歷,所以時間複雜度是O(n²)。

有沒有簡單點的辦法呢?

考慮一下字串中的字符集合其實是有限的,假如都是使用的ASCII字元,那麼我們可以構建一個256長度的陣列一次遍歷即可。

具體的做法就是遍歷一個字元就將相對於的陣列中的相應index中的值+1,當我們發現某個index的值已經是1的時候,就知道這個字元重複了。

陣列的問題

那麼陣列的實現有什麼問題呢?

陣列的問題所在:

鍵的範圍必須很小。 如果我們有(非常)大範圍的話,記憶體使用量會(非常的)很大。
鍵必須密集,即鍵值中沒有太多空白。 否則陣列中將包含太多的空單元。

我們可以使用雜湊函式來解決這個問題。

通過使用雜湊函式,我們可以:

將一些非整數鍵對映成整數鍵,
將大整數對映成較小的整數。

通過使用雜湊函式,我們可以有效的減少儲存陣列的大小。

hash的問題

有利就有弊,雖然使用雜湊函式可以將大資料集對映成為小資料集,但是雜湊函式可能且很可能將不同的鍵對映到同一個整數槽中,即多對一對映而不是一對一對映。

尤其是在雜湊表的密度非常高的情況下,這種衝突會經常發生。

這裡介紹一個概念:影響雜湊表的密度或負載因子α= N / M,其中N是鍵的數量,M是雜湊表的大小。

其實這個衝突的概率要比我們想象的更大,舉一個生日悖論的問題:

一個班級裡面有多少個學生會使至少有兩人生日相同的概率大於 50%?

我們來計算一下上面的問題。

假設Q(n)是班級中n個人生日不同的概率。

Q(n)= 365/365×364/365×363/365×...×(365-n + 1)/ 365,即第一人的生日可以是365天中的任何一天,第二人的生日可以是除第一人的生日之外的任何365天,等等。

設P(n)為班級中 n 個人的相同生日的概率,則P(n)= 1-Q(n)。

計算可得,當n=23的時候P(23) = 0.507> 0.5(50%)。

也就是說當班級擁有23個人的時候,班級至少有兩個人的生日相同的概率已經超過了50%。 這個悖論告訴我們:個人覺得罕見的事情在集體中卻是常見的。

好了,回到我們的hash衝突,我們需要構建一個好的hash函式來儘量減少資料的衝突。

什麼是一個好的雜湊函式呢?

能夠快速計算,即其時間複雜度是O(1)。
儘可能使用最小容量的雜湊表,
儘可能均勻地將鍵分散到不同的基地址∈[0..M-1],
儘可能減少碰撞。

在討論雜湊函式的實現之前,讓我們討論理想的情況:完美的雜湊函式。

完美的雜湊函式是鍵和雜湊值之間的一對一對映,即根本不存在衝突。 當然這種情況是非常少見的,如果我們事先知道了雜湊函式中要儲存的key,還是可以辦到的。

好了,接下來我們討論一下hash中解決衝突的幾種常見的方法。

線性探測

先給出線性探測的公式:i描述為i =(base + step * 1)%M,其中base是鍵v的雜湊值,即h(v),step是從1開始的線性探測步驟。

線性探測的探測序列可以正式描述如下:

h(v)//基地址
(h(v)+ 1 * 1)%M //第一個探測步驟,如果發生碰撞
(h(v)+ 2 * 1)%M //第二次探測步驟,如果仍有碰撞
(h(v)+ 3 * 1)%M //第三次探測步驟,如果仍有衝突
...
(h(v)+ k * 1)%M //第k個探測步驟等...

先看個例子,上面的陣列中,我們的基數是9,陣列中已經有1,3,5這三個元素。

現在我們需要插入10和12,根據計算10和12的hash值是1和3,但是1和3現在已經有資料了,那麼需要線性向前探測一位,最終插入在1和3的後面。

上面是刪除10的例子,同樣的先計算10的hash值=1,然後判斷1的位置元素是不是10,不是10的話,向前線性探測。

看下線性探測的關鍵程式碼:

    //插入節點
    void insertNode(int key, int value)
    {
        HashNode temp = new HashNode(key, value);

        //獲取key的hashcode
        int hashIndex = hashCode(key);

        //find next free space
        while(hashNodes[hashIndex] != null && hashNodes[hashIndex].key != key
            && hashNodes[hashIndex].key != -1)
        {
            hashIndex++;
            hashIndex %= capacity;
        }
        //插入新節點,size+1
        if(hashNodes[hashIndex] == null || hashNodes[hashIndex].key == -1) {
            size++;
        }
        //將新節點插入陣列
        hashNodes[hashIndex] = temp;
    }

如果我們把具有相同h(v)地址的連續儲存空間叫做clusters的話,線性探測有很大的可能會建立大型主clusters,這會增加搜尋(v)/插入(v)/刪除(v)操作的執行時間。

為了解決這個問題,我們引入了二次探測。

二次探測

先給出二次探測的公式:i描述為i =(base + step * step)%M,其中base是鍵v的雜湊值,即h(v),step是從1開始的線性探測步驟。

h(v)//基地址
(h(v)+ 1 * 1)%M //第一個探測步驟,如果發生碰撞
(h(v)+ 2 * 2)%M //第2次探測步驟,如果仍有衝突
(h(v)+ 3 * 3)%M //第三次探測步驟,如果仍有衝突
...
(h(v)+ k * k)%M //第k個探測步驟等...

就是這樣,探針按照二次方跳轉,根據需要環繞雜湊表。

看一個二次探測的例子,上面的例子中我們已經有38,3和18這三個元素了。現在要向裡面插入10和12。大家可以自行研究下探測的路徑。

再看一個二次探索刪除節點的例子。

看下二次探測的關鍵程式碼:

    //插入節點
    void insertNode(int key, int value)
    {
        HashNode temp = new HashNode(key, value);

        //獲取key的hashcode
        int hashIndex = hashCode(key);

        //find next free space
        int i=1;
        while(hashNodes[hashIndex] != null && hashNodes[hashIndex].key != key
            && hashNodes[hashIndex].key != -1)
        {
            hashIndex=hashIndex+i*i;
            hashIndex %= capacity;
            i++;
        }

        //插入新節點,size+1
        if(hashNodes[hashIndex] == null || hashNodes[hashIndex].key == -1) {
            size++;
        }
        //將新節點插入陣列
        hashNodes[hashIndex] = temp;
    }

在二次探測中,群集(clusters)沿著探測路徑形成,而不是像線性探測那樣圍繞基地址形成。 這些群集稱為次級群集(Secondary Clusters)。
由於在所有金鑰的探測中使用相同的模式,所以形成次級群集。

二次探測中的次級群集不如線性探測中的主群集那樣糟糕,因為理論上雜湊函式理論上應該首先將鍵分散到不同的基地址∈[0..M-1]中。

為了減少主要和次要clusters,我們引入了雙倍雜湊。

雙倍雜湊

先給出雙倍雜湊的公式:i描述為i =(base + step * h2(v))%M,其中base是鍵v的雜湊值,即h(v),step是從1開始的線性探測步驟。

h(v)//基地址
(h(v)+ 1 * h2(v))%M //第一個探測步驟,如果有碰撞
(h(v)+ 2 * h2(v))%M //第2次探測步驟,如果仍有衝突
(h(v)+ 3 * h2(v))%M //第三次探測步驟,如果仍有衝突
...
(h(v)+ k * h2(v))%M //第k個探測步驟等...

就是這樣,探測器根據第二個雜湊函式h2(v)的值跳轉,根據需要環繞雜湊表。

看下雙倍雜湊的關鍵程式碼:

    //插入節點
    void insertNode(int key, int value)
    {
        HashNode temp = new HashNode(key, value);

        //獲取key的hashcode
        int hashIndex = hash1(key);

        //find next free space
        int i=1;
        while(hashNodes[hashIndex] != null && hashNodes[hashIndex].key != key
            && hashNodes[hashIndex].key != -1)
        {
            hashIndex=hashIndex+i*hash2(key);
            hashIndex %= capacity;
            i++;
        }

        //插入新節點,size+1
        if(hashNodes[hashIndex] == null || hashNodes[hashIndex].key == -1) {
            size++;
        }
        //將新節點插入陣列
        hashNodes[hashIndex] = temp;
    }

如果h2(v)= 1,則雙雜湊(Double Hashing)的工作方式與線性探測(Linear Probing)完全相同。 所以我們通常希望h2(v)> 1來避免主聚類。

如果h2(v)= 0,那麼Double Hashing將會不起作用。

通常對於整數鍵,h2(v)= M' - v%M'其中M'是一個小於M的質數。這使得h2(v)∈[1..M']。

二次雜湊函式的使用使得理論上難以產生主要或次要群集問題。

分離連結

分離連結法(SC)衝突解決技術很簡單。如果兩個鍵 a 和 b 都具有相同的雜湊值 i,那麼這兩個鍵會以連結串列的形式附加在要插入的位置。

因為鍵(keys)將被插入的地方完全依賴於雜湊函式本身,因此我們也稱分離連結法為封閉定址衝突解決技術。

上面是分離連結插入的例子,向現有的hashMap中插入12和3這兩個元素。

上面是分離連結刪除的例子,從連結中刪除10這個元素。

看下分離連結的關鍵程式碼:

 //新增元素
    public void add(int key,int value)
    {

        int index=hashCode(key);
        HashNode head=hashNodes[index];
        HashNode toAdd=new HashNode(key,value);
        if(head==null)
        {
            hashNodes[index]= toAdd;
            size++;
        }
        else
        {
            while(head!=null)
            {
                if(head.key == key )
                {
                    head.value=value;
                    size++;
                    break;
                }
                head=head.next;
            }
            if(head==null)
            {
                head=hashNodes[index];
                toAdd.next=head;
                hashNodes[index]= toAdd;
                size++;
            }
        }
        //動態擴容
        if((1.0*size)/capacity>0.7)
        {
            HashNode[] tmp=hashNodes;
            hashNodes=new HashNode[capacity*2];
            capacity=2*capacity;
            for(HashNode headNode:tmp)
            {
                while(headNode!=null)
                {
                    add(headNode.key, headNode.value);
                    headNode=headNode.next;
                }
            }
        }

rehash

當負載因子α變高時,雜湊表的效能會降低。 對於(標準)二次探測衝突解決方法,當雜湊表的α> 0.5時,插入可能失敗。
如果發生這種情況,我們可以重新雜湊(rehash)。 我們用一個新的雜湊函式構建另一個大約兩倍的雜湊表。 我們遍歷原始雜湊表中的所有鍵,重新計算新的雜湊值,然後將鍵值重新插入新的更大的雜湊表中,最後刪除較早的較小雜湊表。

本文的程式碼地址:

learn-algorithm

本文已收錄於 http://www.flydean.com/14-algorithm-hashtable/

最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!

歡迎關注我的公眾號:「程式那些事」,懂技術,更懂你!

相關文章