1. 程式人生 > >深入淺出數據結構C語言版(14)——散列表

深入淺出數據結構C語言版(14)——散列表

type unsigned size 表示 發現 blog 情況 減少 orb

  我們知道,由於二叉樹的特性(完美情況下每次比較可以排除一半數據),對其進行查找算是比較快的了,時間復雜度為O(logN)。但是,是否存在支持時間復雜度為常數級別的查找的數據結構呢?答案是存在,那就是散列表(hash table,又叫哈希表)。散列表可以支持O(1)的插入,理想情況下可以支持O(1)的查找與刪除。

  散列表的基本思想很簡單:

  1.設計一個散列函數,其輸入為數據的關鍵字,輸出為散列值n(正整數),不同數據關鍵字必得出不同散列值n(即要求散列函數符合單射條件)

  2.創建一個數組HashTable(即散列表),插入的數據存儲在HashTable[n]中,n為數據的散列值且一定小於散列表的最大下標

  這樣一來,插入數據只需要計算出數據的散列值n,而後將數據存至HashTable[n]。查找數據則根據數據計算出散列值n,而後檢查HashTable[n]是否存有數據(完美情況下HashTable甚至可以是bool型的)即可,刪除同理。這些操作都是O(1)。

  但是稍加思索就會發現,上述思想是不可能在任意情況下都實現的。一來,不可能任意情況下都有單射的散列函數,比如數據關鍵字為任意整數時,關鍵字-a與a該如何映射?二來,即使散列函數是單射,散列表的大小也不可能總是保證大於所有可能的散列值,比如數據關鍵字為正整數,那麽散列函數只需要令散列值等於數據關鍵字即可保證單射,但是如果數據總量為1000,而數據的可能最大值為10000000,難道我們創建一個大小為10000000的散列表嗎?

  也就是說,我們實際實現散列表時,必須面對這兩個問題:

  1.如何實現一個盡可能“接近”單射的散列函數

  2.當不同數據關鍵字散列值相同時,如何處理這種沖突

  第一個問題顯然是因情而異的,只有給定了數據類型和一定的數據特性,才能寫出對應的、好的散列函數。比如數據的key為隨機正整數時,簡單的散列函數是直接返回key%tableSize,這樣做也沒有多大問題。但是如果知道tableSize為100,且數據的key個位和十位必然為0,那麽這樣的散列函數就是不行的,必須修改。

  也就是說,第一個問題是不存在普適性解法的,實現一個良好的散列函數本身又是另一件算法設計的事情,所以我們對於第一個問題不進行深入討論。接下來的討論假定這樣的情形:輸入的數據(關鍵字)為長度不超過20的字符串,且散列函數如下:

//簡單的散列函數,將字符串中字符的ASCII碼值相加,然後返回其與tableSize求余後的結果
unsigned int Hash(const char *target,unsigned int tableSize)
{
    unsigned int HashVal = 0;
    while (*target != \0)
        HashVal += *target++;
    
    return HashVal%tableSize;
}

  那麽第二個問題呢?當不同數據映射到相同散列值時的沖突,是否存在普適性的解法?答案是存在,並且解法有很多種(但是此處只給出一種的代碼,其他解法只提出思路)

  常見的處理散列沖突的解法有三種:分離鏈接,開放定址,雙散列。我們將給出分離鏈接法的代碼,其他兩種則略做討論。

  分離鏈接法的思想很簡單:如果多個數據都映射到了n,那就讓這多個數據都待在HashTable[n]。

  顯然,要讓多個數據都待在HashTable[n]處,那麽HashTable的元素類型必然不是與數據相同的類型(如果是的話,HashTable[n]處只可能存下一個數據),而應該是一個鏈表。

  舉例來說,假定tableSize為7,根據已給的散列函數,關鍵字"ac"和"bb"的散列值均為0,則散列表在插入"ac"和"bb"後應如下:

  技術分享

  那麽使用分離鏈接的散列表的查找方法也就是:計算出給定數據的散列值n,找到HashTable[n](一個鏈表),在HashTable[n]這個鏈表中遍歷查找是否存在給定數據。刪除的實現則是在查找的基礎上實施鏈表的刪除方法即可。

  不難看出,良好的散列函數是極其重要的,假設散列函數總是給出相同的散列值,那麽使用分離鏈接法的散列表最終就成了一個鏈表(所有數據都映射散列值n,於是所有數據都存儲在了鏈表HashTable[n]中)

  現在,我們可以開始一步步實現一個散列表了,其散列函數我們在上面已經給出,其處理沖突的方法為分離鏈接。

  首先,HashTable的元素是鏈表,所以必須給出鏈表結點的定義

#define STRSIZE 20

struct ListNode {
    char str[STRSIZE];
    struct ListNode *next;
};
typedef struct ListNode *List;
typedef List Position;   //Position用於查找和刪除

  接下來是設計HashTable本身,即確定HashTable的元素類型,最簡單的辦法是令struct ListNode作為HashTable的類型

struct ListNode HashTable[TABLESIZE];

  但這樣將帶來一個問題:如何判斷HashTable[n]中是空的還是只有一個元素?所以我們令List作為HashTable的元素類型,即令HashTable的元素為指向鏈表第一個元素的指針。這樣一來,如果HashTable[n]處的鏈表為空,則HashTable[n]就等於NULL。

List HashTable[TABLESIZE];

  但是為了使我們的散列表更有適應性,我們希望令tableSize作為一個變量,即散列表的大小可以根據編程需要來給定,於是我們將散列表設計成如下結構,並在程序中使用指針來訪問散列表。同時,我們給出初始化散列表的代碼

struct HashTbl {
    unsigned int size;
    List *table;  //table才是真正的那個散列表
};
typedef struct HashTbl *HashTable;  //我們訪問散列表將通過指針,因為例如查找這樣的函數需要散列表作為參數,如果傳入一個struct HashTbl,不如傳入一個struct HashTbl *


//根據給定大小創建散列表頭
HashTable Initialize(unsigned int tableSize)
{
    //創建散列表頭,並根據給定大小tableSize創建頭中的散列表
    HashTable h = (HashTable)malloc(sizeof(struct HashTbl));
    h->size = tableSize;
    h->table = (List *)malloc(sizeof(List)*tableSize);

    //將散列表的每個元素(指向鏈表第一個元素的指針)初始化為NULL
    for (int i = 0;i < tableSize;++i)
        h->table[i] = NULL;

    return h;
}

  接下來是插入操作的代碼

//將字符串source插入到h中的散列表
void Insert(HashTable h, const char *source)
{
    //此處實質為Find()操作,但為了順便求出source的散列值,我們不直接使用Find()
    //若source已在散列表中,我們直接返回
    unsigned int HashVal = Hash(source, h->size);
    Position p = h->table[HashVal];
    while (p != NULL && strcmp(p->str, source))
    {
        p = p->next;
    }
    if (p != NULL)
        return;
    
    //若source不在散列表中,我們計算source的散列值,並將source插入到散列表的對應位置
    Position newNode = (Position)malloc(sizeof(struct ListNode));
    strcpy_s(newNode->str, STRSIZE, source);
    newNode->next = h->table[HashVal];
    h->table[HashVal] = newNode;
}

  查找和刪除操作都不難(查找的代碼在插入中已經實現了),此處不予贅述。

  接下來我們談談什麽是開放定址法。

  首先,根據散列表的基本思想,如果一個數據散列值為n,那它就應該“定址”於HashTable[n]處,這也可以說是分離鏈接法的根本(既然你們散列值為n,那你們就都得待在HashTable[n])

  而開放定址法就顧名思義了,數據不再是“定址”的,一個數據關鍵字散列值為n,但其不一定位於HashTable[n]處。

  開放定址法是這麽做的:如果數據關鍵字散列值為n,則將其插入到HashTable[n]處,如果HashTable[n]處已有數據,則插入到HashTable[(n+1)%tableSize]處,如果該處亦有數據,則插入到HashTable[(n+2)%tableSize]處,以此類推,直至遇到某處為空,插入數據至該處,或者走遍散列表依然沒有空處,則插入失敗。這樣的插入稱為“線性探測”

  查找操作則是:計算散列值n,比較HashTable[n]與數據,若相同則找到,否則比較HashTable[(n+1)%tableSize]與數據,直至到了某個空結點,則說明沒找到

  刪除操作則必須是懶惰刪除,因為若實質刪除,則開放定址法的插入和查找都將亂套,也就是說HashTable的元素類型必然是一個包含數據類型的新結構體,其存在frequency域用於表示數據是否存在或相同數據存在多少個。

  

  開放定址法相比於分離鏈接法可以節省指針空間,但也帶來了兩個問題:

  1.如果插入數據時,總是按照n=n+1的形式去找一個空的HashTable[n],那麽數據很容易出現“集中”現象。(比如插入三個散列值為80的數據,再插入兩個散列值為81和83的數據,那麽它們都將“擠在”HashTable[80]到HashTable[84]間)

  2.設裝填因子Ω=已插入數據個數/tableSize,那麽Ω越接近於1,開放定址法的各項操作就越慢,而且很可能出現插入失敗

  對於第一個問題,有兩種改善的辦法,一種是采用“平方探測”形式的插入,即令n+=2*++n-1,而不是n=n+1,這樣可以減少一次集中,但相同散列值的數據依然可能出現“二次集中”現象。另一種辦法則是雙散列,即出現沖突時令n=n*hash2(key),本質上來說,平方探測、線性探測和雙散列是相似的,都是在出現沖突時另尋一處存放數據,當然,這個另尋一處必須是可重現的。

  對於第二個問題,解決辦法是再散列,即當Ω大於一定程度後,重新創建新的、更大的散列表,而後將數據移至新散列表。也就是“再次散列”。

  使用分離鏈接法的散列表的示例程序代碼:

  https://github.com/nchuXieWei/ForBlog-----HashTable

  

深入淺出數據結構C語言版(14)——散列表