常見基本數據結構——散列

散列表的實現一般叫作散列。散列是一種用於以常數平均時間執行插入、刪除和查找的技術。可是任何排序的信息都不會獲得有效的支持。因此FindMax(),FindMin(),以及以線性時間打印的操做都是散列所不支持的。前端

理想的散列表數據結構值不過是一個包含有關鍵字的具備固定大小的數組。git

關鍵字映射的函數叫作散列函數,一般散列函數應該運算簡單而且保證任何兩個不一樣的關鍵字映射到不一樣的單元。不過這是不可能的,由於單元的數目是有限的,然而關鍵字是用不完的。所以,咱們尋找一個散列函數,該函數要在單元之間均勻的分配的關鍵字。對於兩個關鍵字映射到同一個值的時候,咱們稱之爲衝突,須要設定一個函數來進行處理。算法

 

散列函數數組

對於關鍵字是整數,則通常合理的方法就是直接返回"Key mod TableSize"的結果,除非Key具備某些不理想的性質。例如:表的大小是10,可是關鍵字的大小都是0爲個位。好的大小一般是保證表的大小是一個素數。數據結構

一般,關鍵字是字符串,在這種狀況下,散列函數須要仔細的選擇。函數

一種比較簡單的方法是把字符串中的字符的ASCLL碼值加起來。下面是這種方式的代碼實現:性能

Indexx Hash(const char *Key, int TableSize){
    unsigned int HashVal = 0;
    while(*Key != '\0'){
        HashVal += *Key++;    
    } 
    return HashVal % TableSize;
}

上述的散列函數實現起來簡單並且很快地算出答案。不過,若是表很大的話,函數將不會很好的分配關鍵字。假設TableSize=10007,而且假設全部的關鍵字最多有8個字符長,127*8=1016,顯然這是不均勻的分配。spa

 

另外一種散列函數有下面的代碼所示,假設關鍵字key至少有兩個字符加上NULL結束,729=27^2指針

假設它們是隨機的,而表仍是10007的大小,咱們就會獲得一個合理的均勻分配,雖然3個字符有26^3=17576種可能的組合,可是實際的詞彙量卻揭示了:3和字母不一樣的組合數實際上面只有2851種,也只不過有28%的空間被利用上。當表足夠大的時候,它們仍是不合適的code

Index Hash(const char *Key, int TableSize){
    return (Key[0] + 27 * Key[1] + 729 * Key[2]) % TableSize;
}

下面的散列函數,涉及到關鍵字中的全部字符,而且通常能夠分佈的很好,程序根據Horner法則計算一個(32的)多項式。

Index Hash(const char *Key, int TableSize){
    unsigned int HashVal=0;
    while(*Key != '\0'){
        HashVal = (HashVal<<5) + *Key++;
    }
    return HashVal % TableSize;
}

之因此使用32是由於可使用位運算來加速,而且還可使用按位異或來代替。上述的散列函數的優勢是簡單且容許溢出。當關鍵字長的時候,能夠選用部分的關鍵字。有些程序人員經過只使用奇數位置上的字符來實現他們的散列函數。這裏的一層想法是:用計算散列函數節省下來的時間來補償由此產生的對均勻分佈的函數的輕微干擾。

剩下的主要問題是解決衝突的消除問題,當一個元素被插入時,另外一個元素已經存在(散列值相同),那麼產生一個衝突,這個衝突須要消除。解決衝突的方法有不少種,下面介紹的是最簡單的兩種:分離連接法和開放定址法。

分離連接法

解決衝突的第一種方法一般叫作分離連接法,其作法是將散列到同一個值的全部元素保留到一個表中。爲了方便起見,這些表都有表頭。

爲執行Find,咱們使用散列函數來肯定究竟考查那個表。此時咱們以一般的方式遍歷該表並返回所找到的被考查項所在位置。爲了執行Insert,咱們遍歷一個相應的表以檢查該元素是否已經處在適當的位置(若是要插入重複元素,那麼一般要留出一個額外的域,這個域當重複元出現時增長1)。若是元素是一個新的元素,那麼它或者被插入到表的前端,或者被插入到表的末端,那個容易就執行那個。新的元素插入到表的前端,不只是由於方便,並且還由於新插入的元素最有可能最早被訪問到。

下面是具體的實現:

struct ListNode;
typedef struct ListNode *Position;
struct HashTbl;
typedef struct HashTbl *HashTable;

struct ListNode{
    ElementType Element;
    Position Next;  
};
typedef Position List;
struct HashTbl{
    int TableSize;
    List *TheLists;
};

下面是初始化例程:

HashTable InitializeTable(int TableSize){
    HashTable H;
    int i;
    if(TableSize < MinTableSize){
        Error("Table size too");
        return NULL;
    }  
    H = malloc(sizeof(struct HashTbl));
    if(H == NULL){
        FatalError("out of space");
    }
    H->TableSize = NextPrime(TableSize);
    H->TheLists = malloc(sizeof(List)*H->TableSize);
    if(H->TheLists == NULL){
        FatalError("out of space");
    }
    for(int i=0; i < H->TableSize; i++){
        H->TheLists[i] = malloc(sizeof(struct ListNode));
        if(H->TheLists[i] == NULL){
            FatalError("Out of space");
        }else{
            H->TheLists[i]->Next = NULL;
        }
    }
    return H;
}

上面的代碼須要注意的是:TheLists是一個數組,它的每一個值都是一個指向單元鏈表的指針。

對Find(Key,H)的調用將返回一個指針,該指針指向包含Key的那個單元。下面是具體的代碼實現:

Position Find(ElementType Key, HashTable H){
    Position P;
    List L;
    L = H->TheLists[Hash(Key, H->TableSize)];
    P = L->Next;
    while(P != NULL && P->Element != Key){
        P = P->Next;
    }
    reutrn P;
}

下一個是插入例程。若是要插入項已經存在,那麼咱們什麼也不作,不然咱們就放在表的最前端。下面是插入的代碼實現:

void Insert(ElementType Key, HashTable H){
    Position Pos, NewCell;
    List L;
    Pos = Find(Key, H);
    if(Pos == NULL){
        NewCell = malloc(sizeof(struct ListNode));
        if(NewCell == NULL){
            FatalError("Out of space");
        }else{
            L = H->TheLists[Hash(Key, H->TableSize)];
            NewCell->Next = L->Next;
            NewCell->Element = Key;
            L->Next = NewCell;
        }
    }  
}

除鏈表外,任何的方案都有可能用來解決衝突現象,一顆二叉樹甚至是另一個散列。咱們定義散列表的裝填因子λ爲散列表的元素個數與散列表的大小的比例。在上面的例子中,λ=1.0。表的平均長度爲λ。執行一次查找所須要的時間是執行散列函數的常數時間加上鍊表遍歷的時間。成功的查找則須要遍歷大約1+(λ/2)個鏈表,咱們指望沿着一個表中途就能找到匹配的元素。表的大小是一個素數能夠保證一個好的分佈。

 

開放定址法

分離鏈表法的缺點是須要使用指針,因爲給新的單元分配地址須要時間,所以就致使了算法的速度有些減慢,同時算法實際上面還要使用另一種數據結構的實現。除了分離連接法以外,開放定址散列法是另一種不用鏈表解決衝突的方法。在開放定址散列算法中,若是沒有算法衝突,那麼就要嘗試另外的單元,直到找到空的單元。更通常的,單元h0(X),h1(X),h2(X),等等,其中hi(X) = (Hash(X) + F(i) ) mod TableSize,且F(0)=0。函數F是衝突解決函數方法。由於全部的數據都要置入表內,因此開放定址散列法所須要的表要比分裂連接散列的表大。通常對於開放定址散列算法來講,裝填因子應該低於λ=0.5,下面是具體的分析:

 

線性探測法

在線性探測法中,函數F是i的線性函數,典型狀況是F(i)=i。這至關於逐個探測每一個單元,以查找一個空單元。

 

平方探測法

消除線性探測中一次彙集問題的衝突解決方法。平方探測就是衝突函數爲二次函數的探測方法,流行的選擇是F(i) = i^2,對於線性探測,讓元素幾乎填滿列表並非個好主意,由於此時列表的性能會下降,對於平方探測來講狀況更糟:一旦表被填滿超過一半,當表的大小不是素數時甚至在表被填充滿一半以前,就不能保證一次找到一個空單元了。這是由於最多有表的一半能夠做爲衝突解決的備選位置。

開放定址散列表的例程:

typedef unsigned int Index;
typedef Index Position;

struct HashEntry{
    ElementType Element;
    enum KindOfEntry Info;
}
typedef struct HashEntry Cell;

struct HashTbl{
    int TableSize;
    Cell *TheCell;      
}

如同分離連接散列法同樣,Find(Key, H)將返回Key在散列表中的位置。若是Key不出現,那麼Find將返回最後的單元。該單元就是當須要時,Key將被插入的地方。此外,由於被標記了Empty,因此表達式Find失敗很容易。下面是使用平方探測散列法的Find例程:

Position Find(ElementType Key, HashTable H){
    Position CurrentPos;
    int CollisionNum;
    CollisionNum = 0;
    CurrentPos = Hash(Key, H->TableSize);
    while(H->TheCells[CurrentPos].Info != Empty && H->TheCells[CurrentPos].Element != Key){
        CurrentPos +=2 * ++CollisionNum - 1;
        if(CurrentPos >= H->TableSize)
            CurrentPos -= H->TableSize;
    }
    return CurrentPos;
} 

使用平方探測散列表的插入例程:

void Insert(ElementType Key, HashTable H){
    Position Pos;
    Pos = Find(Key, H);
    if(H->TheCells[Pos].Info != Legitimate){
        H->TheCells[Pos].Info = Legitimate;
        H->TheCells[Pos].Element = Key;
    }
}

雖然平方探測排除了一次彙集,可是散列到同一位置上的那些元素將探測相同的備選單元。這叫作二次彙集。二次彙集是理論上的一個小遺憾。

 

雙散列

對於雙散列,一種流行的選擇是F(i) = i·hash2(X)。這個公式是說,咱們將第二個散列函數應用到X並在舉例hash2(X),2hash2(X)等處探測。hash2(X)選擇得很差將會是災難性的。函數須要保證全部的單元都能探測到也是很重要的。例如:hash2(X) = R - (X mod R) 這樣的函數將起到良好的做用。

 

再散列

對於使用平方探測的開放定址散列法,若是表的元素填得太滿,那麼操做的運行時間將開始消耗過長,且Insert操做可能失敗。這可能發生在有多太多的移動和插入混合的場合。此時,另外的一種解決方法是創建另一個大約兩倍的表,掃描整個原始散列表,計算每一個元素的新散列值並將其插入到新表中。

顯然這是一種昂貴的操做,其運行時間是O(N),由於有N個元素要再散列而表的大小大約是2N,不過因爲不是常常發生,所以實際效果根本沒有這麼差。

在散列能夠用平方探測以多種方法實現,一種作法是隻要表滿到一半就再散列,另一種極端的方式是隻有插入失敗了才進行再散列,第三種方式是途中策略,當表到達某一個裝填因子時進行再散列。因爲隨着裝填因子的增長表的性能會有所降低,所以以好的手段實現第三種策略,是一種好的方法。

下面是在再散列的開放定址散列表的實現:

HashTable ReHash(HashTable H){
    int i, OldSize;
    Cell *OldCells;
    OldCells = H->THeCells;
    OldSize = H->TableSize;
    H = InitializeTable(2 * OldSize);
    for(i=0; i<OldSize; i++){
        if(OldCells[i].Info == Legitimate)
            Insert(OldCells[i].Element, H);
    }
    free(OldCells);
    return H;
}

可擴散列

當數據處理量太大以致於不能裝進主存的時候,咱們就須要使用可擴散列,此時主要考慮的是檢索數據所須要的磁盤存取次數。

在B樹中,B樹的深度隨着M的增大而減少,理論上,咱們可使用足夠大的M,使得樹的深度是1。這樣全部的Find操做只須要查找一次的磁盤,可是因爲分支的數量太大,須要花費大量的時間肯定分支。若是運行這一步的時間能夠大大縮減,那麼這將是一個實際可行的方案。

 

總結

散列表能夠用來以常數平均時間實現Insert和Find操做。當使用散列表時,須要注意裝填因子這樣的細節是特別重要的,不然時間界將再也不奏效。當關鍵值不是短串或整數時,仔細選擇散列函數也是很是重要的。

對於分離連接法,雖然裝填因子不是很大時性能並不明顯下降,但裝填因子仍是應該接近1,對於開放定址法,除非徹底不可避免,不然裝填因子不該該超過0.5。若是使用線性探測,那麼性能隨着裝填因子接近1將急速降低。再散列經過使表增長或者收縮來實現,這樣就可以保證裝填因子在合理範圍。

二叉查找樹的Insert和Find運算時間的界是O(logN),可是二叉查找樹支持須要序的例程而更增強大。使用散列表不可能找出最小的元素,而且O(logN)的時間界也不比O(1)大太多。

在另外一方面,散列的最壞狀況通常來自於實現的缺憾,而有序的輸入卻多是二叉樹運行的不好。平衡查找樹實現代價至關高,所以,若是不須要序的信息及排序的話,散列是一種比較好的選擇。

散列的使用很是的多,編譯器使用散列表跟蹤源代碼中聲明的變量。這種數據結構叫作符號表,散列表是這種問題的理想應用,由於只須要Insert 和 Find操做。

散列表對於節點是實際的名字而不是數字的任何圖論問題都是有用的。

散列表的第三種運用是在遊戲編制中,當程序搜索遊戲的不一樣的運行時,它跟蹤經過計算基於位置的散列函數而看到一些位置。若是位置再出現,程序一般經過簡單變換來避免昂貴的計算,在遊戲程序中,叫作變換表。

散列的另外一個用途是在線拼寫檢驗程序,若是錯拼檢驗更重要,那麼整個詞典均可以預先被散列,單詞就能在常數時間內被校驗。散列表很合適作這項工做,由於排列排列單詞並不重要。

相關文章
相關標籤/搜索