哈希表—超簡單的理解思路

一、啥是哈希表

哈希表又稱散列表,其實就是和數組、鏈表一樣的是一種數據結構,在你從來沒有接觸過這個概念的時候,覺得神祕而不可探測,其實就是一紙老虎,人狠話不多,先上一個相對官方的概念定義:

散列技術是在記錄的存儲位置和它的關鍵字之間建立一個確定的對應關係f,使得每個關鍵字key對應一個存儲位置f(key)。建立了關鍵字與存儲位置的映射關係,公式如下:

存儲位置 = f(關鍵字)

這裏把這種對應關係f稱爲散列函數,又稱爲哈希(Hash)函數。

採用散列技術將記錄存在在一塊連續的存儲空間中,這塊連續存儲空間稱爲散列表或哈希表。那麼,關鍵字對應的記錄存儲位置稱爲散列地址。

散列技術既是一種存儲方法也是一種查找方法。散列技術的記錄之間不存在什麼邏輯關係,它只與關鍵字有關,因此,散列主要是面向查找的存儲結構。

人狠話不多,直接例子:

例1:現在我要你存儲4個元素 13 7 14 11,你將如何存儲???


答:顯然,我們可以用數組來存。也就是:a[1] = 13; a[2] = 7; a[3] = 14; a[4] = 11;

當然,我們也可以用Hash來存。下面給出一個簡單的Hash存儲:


先來確定那個函數。我們就用h(key) = key%5;(這個函數不用糾結,我們現在的目的是瞭解爲什麼要有這麼一個函數)。那麼

對於第一個元素 h(13) = 13%5 = 3; 也就是說13的下標爲3;即Hash[3] = 13;
對於第二個元素 h(7) = 7 % 5 = 2; 也就是說7的下標爲2; 即Hash[2] = 7;
同理,Hash[4] = 14; Hash[1] = 11;

好了,存現在是存好了。但是,這並沒有體現出Hash的妙處,現在我要你查找11這個元素是否存在。你會怎麼做呢?
當然,對於數組來說,那是相當的簡單,一個for循環就可以了。也就是說我們要找4次。這是很笨的辦法,因爲爲了找一個數需要把整個序列循環一遍才行,太慢!

下面我們來用Hash找一下:

首先,我們將要找的元素11代入剛纔的函數中來映射出它所在的地址單元。也就是h(11) = 11%5 = 1 了。下面我們來比較一下Hash[1]?=11, 這個問題就很簡單了。

也就是說我們就找了1次。我咧個去, 這個就是Hash的妙處了。

那麼,怎麼設計哈希函數呢,上邊的mod 5,我還mod 8 呢,是不是很氣?接下來咱們就看看哈希函數的設計

二、哈希函數的設計

1 直接定址法
  取關鍵字或者關鍵字的某個線性函數爲Hash地址,即address(key)=a*key+b;如知道學生的學號從2000開始,最大爲4000,則可以將address(key)=key-2000作爲Hash地址。

2 平方取中法
  對關鍵字進行平方運算,然後取結果的中間幾位作爲Hash地址。假如有以下關鍵字序列{421,423,436},平方之後的結果爲{177241,178929,190096},那麼可以取中間的兩位數{72,89,00}作爲Hash地址。

3 摺疊法
  將關鍵字拆分成幾部分,然後將這幾部分組合在一起,以特定的方式進行轉化形成Hash地址。假如知道圖書的ISBN號爲8903-241-23,可以將address(key)=89+03+24+12+3作爲Hash地址。

4 除留取餘法
  如果知道Hash表的最大長度爲m,可以取不大於m的最大質數p,然後對關鍵字進行取餘運算,address(key)=key%p。

在這裏p的選取非常關鍵,p選擇的好的話,能夠最大程度地減少衝突,p一般取不大於m的最大質數。

三、Hash表大小的確定

Hash表大小的確定也非常關鍵,如果Hash表的空間遠遠大於最後實際存儲的記錄個數,則造成了很大的空間浪費,如果選取小了的話,則容易造成衝突。在實際情況中,一般需要根據最終記錄存儲個數和關鍵字的分佈特點來確定Hash表的大小。還有一種情況時可能事先不知道最終需要存儲的記錄個數,則需要動態維護Hash表的容量,此時可能需要重新計算Hash地址。

四、衝突的解決

衝突的發生:比如有一組關鍵字{12,13,25,23,38,34,6,84,91},Hash表長爲14,Hash函數爲address(key)=key%11,當插入12,13,25時可以直接插入,而當插入23時,地址1被佔用了,發生了衝突現象,因此需要辦法來解決,否則記錄無法進行正確的存儲。通常情況下有2種解決辦法:

1 開放定址法
  即當一個關鍵字和另一個關鍵字發生衝突時,使用某種探測技術在Hash表中形成一個探測序列,然後沿着這個探測序列依次查找下去,當碰到一個空的單元時,則插入其中。比較常用的探測方法有線性探測法,比如有一組關鍵字{12,13,25,23,38,34,6,84,91},Hash表長爲14,Hash函數爲address(key)=key%11,當插入12,13,25時可以直接插入,而當插入23時,地址1被佔用了,因此沿着地址1依次往下探測(探測步長可以根據情況而定),直到探測到地址4,發現爲空,則將23插入其中。

2 鏈地址法
  採用數組和鏈表相結合的辦法,將Hash地址相同的記錄存儲在一張線性表中,而每張表的表頭的序號即爲計算得到的Hash地址。如上述例子中,採用鏈地址法形成的Hash表存儲表示爲:

hash.jpg

雖然能夠採用一些辦法去減少衝突,但是衝突是無法完全避免的。因此需要根據實際情況選取解決衝突的辦法。

五、Hash表的平均查找長

Hash表的平均查找長度包括查找成功時的平均查找長度和查找失敗時的平均查找長度。
查找成功時的平均查找長度=表中每個元素查找成功時的比較次數之和/表中元素個數;
查找不成功時的平均查找長度相當於在表中查找元素不成功時的平均比較次數,可以理解爲向表中插入某個元素,該元素在每個位置都有可能,然後計算出在每個位置能夠插入時需要比較的次數,再除以表長即爲查找不成功時的平均查找長度。

下面的例子有助於理解。

例1

將關鍵字序列{7, 8, 30, 11, 18, 9, 14}散列存儲到散列表中。散列表的存儲空間是一個下標從0開始的一維數組,長度爲10,即{0, 1,2, 3, 4, 5, 6, 7, 8, 9}。散列函數爲: H(key) = (key * 3) % 7,處理衝突採用線性探測再散列法。

求等概率情況下查找成功和查找不成功的平均查找長度。

解:

1 求散列表

H(7) = (7 * 3) % 7 = 0
H(8) = (8 * 3) % 7 = 3
H(30) = 6
H(11) = 5
H(18) = 5
H(9) = 6
H(14) = 0

按關鍵字序列順序依次向哈希表中填入,發生衝突後按照“線性探測”探測到第一個空位置填入。
H(7) = 0,key = 7應插在第0個位置,因爲第0個位置爲空,可以直接插入。
H(8) = 3,key = 8應插在第3個位置,因爲第3個位置爲空,可以直接插入。
H(30) = 6,key = 30應插在第6個位置,因爲第6個位置爲空,可以直接插入。
H(11) = 5,key = 11應插在第5個位置,因爲第5個位置爲空,可以直接插入。
H(18) = 5,key = 18應插在第5個位置,但是第5個位置已經被key=11佔據了,所以往後挪一位到第6個位置,但是第6個位置被key=30佔據了,再往後挪一位到第7個位置,這個位置是空的,所以key=18就插到這個位置
H(9) = 6,key = 9應插在第6個位置,但是第6個位置已經被key = 30佔據,所以需要往後挪一位到第7個位置,但是第7個位置已經被key = 18佔據,所以再往後挪移到第8個位置,這個位置是空的,所以key = 9就插到這個位置。
H(14) = 0,key = 14應插在第0個位置,但第0個位置已被key=7佔據,所以往後挪移一位到第1個位置,這個位置是空的,所以key=14就插到這個位置。

最終的插入結果如下表所示:

address 0 1 2 3 4 5 6 7 8 9
key 7 14   8   11 30 18 9  

2 求查找成功的平均查找長度

查找7,H(7) = 0,在0的位置,一下子就找到了7,查找長度爲1。
查找8,H(8) = 3,在3的位置,一下子就找到了8,查找長度爲1。
查找30,H(30) = 6,在6的位置,一下子就找到了30,查找長度爲1。
查找11,H(11) = 5,在5的位置,一下子就找到了11,查找長度爲1。
查找18,H(18) = 5,第一次在5的位置沒有找到18,第二次往後挪移一位到6的位置,仍沒有找到,第三次再往後挪移一位到7的位置,找到了,查找長度爲3。
查找9,H(9) = 6,第一次在6的位置沒找到9,第二次往後挪移一位到7的位置,仍沒有找到,第三次再往後挪移一位到8的位置,找到了,查找長度爲3.
查找14,H(14) = 0,第一次在0的位置沒找到14,第二次往後挪移一位到1的位置,找到了,查找長度爲2。

所以,查找成功的平均查找長度爲(1 + 1 + 1 + 1 + 3 + 3 + 2) / 7 = 12 / 7。

3 求查找不成功的平均查找長度

查找不成功,說明要查找的數字肯定不在上述的散列表中。
因爲這裏哈希函數的模爲7,所以要查找的數的初始地址只可能位於0~6的位置上。
地址0,到第一個關鍵字爲空的地址2需要比較3次,因此查找不成功的次數爲3。比如要查找的數爲28,H(28) = (28 * 3) % 7 = 0。即28對應的地址是0,由於存放在0位置的數是7,所以往後挪移一位,發現在1位置存放的數是14,繼續往後挪一位,發現位置2上沒有數。至此就知道28不在這個哈希表裏,即查找28失敗。
地址1,到第一個關鍵字爲空的地址2需要比較2次,因此查找不成功的次數爲2。
地址2,到第一個關鍵字爲空的地址2需要比較1次,因此查找不成功的次數爲1。
地址3,到第一個關鍵字爲空的地址4需要比較2次,因此查找不成功的次數爲2。
地址4,到第一個關鍵字爲空的地址4需要比較1次,因此查找不成功的次數爲1。
地址5,到第一個關鍵字爲空的地址9需要比較5次,因此查找不成功的次數爲5。
比如要查找的數爲4,H(4) = (4 * 3) % 7 = 5,所以從地址5開始查找,最終發現地址5、地址6、地址7、地址8上存放的數都不是5,並且地址9的位置上沒放數據,至此可知5不在這個哈希表裏。
地址6,到第一個關鍵字爲空的地址9需要比較4次,因此查找不成功的次數爲4。
所以,查找不成功的平均查找長度爲(3 + 2 + 1 + 2 + 1 + 5 + 4)/ 7 = 18 / 7。

六、C語言的實現

/*採用數組實現哈希表*/ 

#include<stdio.h>
#define DataType int
#define Len 10
 
typedef struct HashNode    
{
    DataType data;    //存儲值 
    int isNull;       //標誌該位置是否已被填充 
}HashTable;

HashTable hashTable[Len];

void initHashTable()     //對hash表進行初始化 
{
    int i;
    for(i = 0; i<Len; i++)
    {
        hashTable[i].isNull = 1;    //初始狀態爲空 
    }
}

int getHashAddress(DataType key)    //Hash函數 
{
    return key * 3 % 7;      
}

int insert(DataType key)    
{
    int address = getHashAddress(key);       
    if(hashTable[address].isNull == 1)  //沒有發生衝突 
    {
        hashTable[address].data = key;
        hashTable[address].isNull = 0;
    }
    else    //當發生衝突的時候 
    {
        while(hashTable[address].isNull == 0 && address<Len)
        {
            address++;          //採用線性探測法,步長爲1 
        }
        if(address == Len)      //Hash表發生溢出 
            return -1;
        hashTable[address].data = key;
        hashTable[address].isNull = 0;
    }
    
    return 0;
}

int find(DataType key)       
{
    int address = getHashAddress(key);
    while( !(hashTable[address].isNull == 0 && hashTable[address].data == key && address<Len))
    {
        address++;
    } 
    
    if( address == Len)
    {
        address = -1;
    }
    
    return address;
}


int main(int argc, char *argv[])
{
    int key[]={7,8,30,11,18,9,14};
    int i;
    initHashTable();
    
    for(i = 0; i<7; i++)
    {
        insert(key[i]);
    }
    
    for(i = 0; i<7; i++)
    {
        int address;
        address = find(key[i]);
        printf("key:%d\t address:%d\n", key[i],address);
    }
    
    return 0;
}
//運行結果
key:7    address:0
key:8    address:3
key:30   address:6
key:11   address:5
key:18   address:7
key:9    address:8
key:14   address:1

最後在加上一個用哈希表的LeetCode的例子:

 

例:給定一個贖金信 (ransom) 字符串和一個雜誌(magazine)字符串,判斷第一個字符串ransom能不能由第二個字符串magazines裏面的字符構成。如果可以構成,返回 true ;否則返回 false。

(題目說明:爲了不暴露贖金信字跡,要從雜誌上搜索各個需要的字母,組成單詞來表達意思。)

注意:

你可以假設兩個字符串均只含有小寫字母。

canConstruct("a", "b") -> false
canConstruct("aa", "ab") -> false
canConstruct("aa", "aab") -> true

用C語言加哈希表實現如下:

bool canConstruct(char * ransomNote, char * magazine)
{

    int hash[26] = {0};
    for (int i = 0; i < strlen(magazine); i++)
    {
        hash[magazine[i] - 'a'] += 1;
    }

    for (int i = 0; i < strlen(ransomNote); i++)
    {
        hash[ransomNote[i] - 'a'] -= 1;
        if (hash[ransomNote[i] - 'a'] < 0)
        {
            return false;
        }
    }
    return true;
}


用C++沒用哈希表實現如下:

#include <string>
class Solution {
public:
    bool canConstruct(string ransomNote, string magazine);
};

bool Solution::canConstruct(string ransomNote, string magazine)
{
    if (ransomNote.empty() )
    {
        return true;
    }
    if ( magazine.empty() || (ransomNote.size() > magazine.size()) )
    {
        return false;
    }
    for (int i = 0; i < ransomNote.size(); i++)
    {
        std::string::size_type pos = magazine.find(ransomNote[i]);
        if (pos != -1)
        {
            magazine.erase(pos, 1);
            continue;
        }
        else 
        {
            return false;
        }
    }
    return true;
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章