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

來源:互聯網
上載者:User

標籤:字元   ble   div   http   https   har   sizeof   結構   image   

  我們知道,由於二叉樹的特性(完美情況下每次比較可以排除一半資料),對其進行尋找算是比較快的了,時間複雜度為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 20struct 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)——散列表

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.