標籤:字元 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)——散列表