《資料結構與演算法分析》學習筆記-第五章-雜湊

CrazyCatJack發表於2021-02-19


雜湊表只支援二叉查詢樹所允許的一部分操作。雜湊是一種用於以常數平均時間執行插入、刪除和查詢的技術。但是,那些需要元素間任何排序資訊的操作將不會得到有效的支援,例如FindMin、FindMax以及以線性時間將排過序的整個表進行列印的操作都是雜湊所不支援的

5.2 雜湊函式

  1. 關鍵字是整數:保證表的大小為素數。直接返回Key mod TableSize
  2. 關鍵字是字串:根據horner法則,計算一個(32的)多項式函式。
Index
Hash(const char *Key, int TableSize)
{
    unsigned int HashVal = 0;
    while (*Key != '\0')
        //HashVal = (HashVal << 5) + *Key++;
        HashVal = (HashVal << 5) ^ *Key++;
    
    return HashVal % TableSize;
}

如果關鍵字特別長,那麼雜湊函式計算起來會花過多的時間,而且前面的字元還會左移出最終的結果。因此這樣情況下,不使用所有的字元。此時關鍵字的長度和性質會影響選擇。例如只取奇數位置上的字元來實現雜湊函式。這裡的思想是用計算雜湊函式省下來的時間來補償由此產生的對均勻分佈函式的輕微干擾

  • 當一個元素插入的位置已經存在另一個元素的時候(雜湊值相同),就叫做衝突。下面介紹解決衝突的兩種方法:分離連結法和開放定址法。

5.3 分離連結法(separate chaining)

  • 將雜湊到同一個值的所有元素保留到一個表中。比如連結串列。為方便起見,這些表都有表頭;如果空間很緊的話,則可以不用表頭。
  • 執行Find:首先根據雜湊函式判斷該遍歷哪個表,然後遍歷連結串列返回元素位置
  • 執行Insert: 首先根據雜湊函式判斷該插入哪個表,然後插入元素到連結串列中。如果要插入重複元,那麼通常要留出一個額外的域,這個域當重複元出現時增1.通常將元素插入到表的前端,因為新元素最有可能被最先訪問

5.3.1 實現

  • 節點定義: 這裡使用了typedef,避免雙重指標的混亂
#define MINTABLESIZE 11
struct HashTbl;
typedef struct HashTbl *HashTable;

typedef Stack List;
struct HashTbl
{
    int TableSize;
    List *TheLists;
};
  • InitializeTable
HashTable
InitializeTable(int TableSize)
{
    if (TableSize < MINTABLESIZE) {
        printf("TableSize too small\n");
        return NULL;
    }
    
    HashTable H = NULL;
    H = (HashTable)malloc(sizeof(struct HashTbl));
    if (H == NULL) {
        printf("HashTable malloc failed\n");
        return NULL;
    }
    memset(H, 0, sizeof(struct HashTbl));
    
    H->TableSize = GetNextPrime(TableSize);
    H->TheLists = (List *)malloc(sizeof(List) * H->TableSize);
    if (H->TheLists == NULL) {
        printf("HashTable TheLists malloc failed\n");
        free(H);
        H = NULL;
        return NULL;
    }
    memset(H->TheLists, 0, sizeof(List) * H->TableSize);
    
    int cnt, cnt2;
    for (cnt = 0; cnt < H->TableSize; cnt++) {
        H->TheLists[cnt] = CreateStack();
        if (H->TheLists[cnt] == NULL) {
            printf("H->TheLists[%d]malloc failed\n", cnt);
            for (cnt2 = 0; cnt2 < cnt; cnt2++) {
                if (H->TheLists[cnt2] != NULL) {
                    DistroyStack(H->TheLists[cnt2]);
                    H->TheLists[cnt2] = NULL;
                }
            }
            if (H->TheLists != NULL) {
                free(H->TheLists);
                H->TheLists = NULL;
            }
            if (H != NULL) {
                free(H);
                H = NULL;
            }
            return NULL;
        }
    }
    
    return H;
}
  • Find
PtrToNode
Find(ElementType Key, HashTable H)
{
    if (H == NULL) {
        printf("ERROR: H is NULL\n");
        return NULL;
    }
    
    PtrToNode tmp = NULL;
	tmp = H->TheLists[GetHashSubmit(Key, H->TableSize)]->Next;
	while (tmp != NULL && tmp->Element != Key) {
		tmp = tmp->Next;
	}
    return tmp;
}
  • Insert
void
Insert(ElementType Key, HashTable H)
{
	if (H == NULL) {
		printf("HashTable is NULL\n");
		return;
	}
	
	if (0 != Push(Key, H->TheLists[GetHashSubmit(Key, H->TableSize)])) {
		printf("Insert Key failed\n");
	}
}
  • 雜湊表的裝填因子為雜湊表的元素個數與雜湊表大小的比值
  • 執行一次查詢所需時間是計算雜湊函式值所需要的常數事件加上遍歷表(list)所用的事件。不成功的查詢,也就是遍歷整個連結串列長度。成功的查詢則需要遍歷大約1+連結串列長度/2.
  • 裝填因子是最重要的。一般法則是使得表的大小盡量與預料的元素個數差不多,也就是讓裝填因子約等於1.
  • 同時,使表的大小是一個素數以保證一個好的分佈,這也是一個好的想法

5.4 開放定址法(Open addressing hashing)

  • 由於分離連結法插入時需要申請記憶體空間,因此演算法速度有些減慢
  • 如有衝突發生,那麼就要嘗試選擇另外的單元,直到找出空的單元為止。更一般的,單元h0(x), h1(x), h2(x),相繼被試選,其中hi(x) = (Hash(x) + F(i)) mod TableSize, 且F(0) = 0。函式F是衝突解決方法
  • 因為所有的資料都要置於表內,所以開放定址雜湊法所需要的表比分離連結雜湊表大。一般說來,對開放定址雜湊演算法來說,裝填因子應該低於0.5
  • 下面來考察三個通常的衝突解決方法

5.4.1 線性探測法

  • 典型情形:F(i) = i。只要想插入的單元已經有元素,就繼續遍歷到下一個單元,直到找到空的單元插入為止(解決衝突)。這樣花費的時間很多,而且即使表相對較空。這樣佔據的單元會開始形成一些區塊,其結果成為一次聚集。於是,雜湊到區塊中的任何關鍵字都需要多次試選單元才能解決衝突,然後該關鍵字被新增到相應的區塊中
  • 插入 & 不成功的查詢的預期探測次數大約都為1/2 (1 + 1/(1 - 裝填因子)^2);
  • 對於成功的查詢來說,則是1/2(1 + 1/(1 - 裝填因子))。可以看出成功查詢應該比不成功查詢平均花費較少的時間
  • 空單元所佔份額為1 - 裝填因子。因此預計要探測的單元數為1 / (1 - 裝填因子)
  • 一個元素被插入時,可以看成是一次不成功查詢的結果,因此可以使用一次不成查詢的開銷來計算一次成功查詢的平均開銷

5.4.2 平方探測法

  • 平方探測就是衝突函式為二次函式的探測方法。典型是F(i) = i2。產生衝突時,先尋找當前單元的下20 = 1個單元,如果還是衝突,則尋找當前單元的下2^2 = 4個單元,直到找到空單元為止。
  • 對於線性探測,讓元素幾乎填滿列表並不是個好主意,因為表的效能會下降的厲害。而對於平方探測法,一旦表被填滿超過一半,當表的大小不是素數時甚至在表被填滿一半之前,就不能保證一次找到一個空單元了。這是因為最多有表的一半可以用作解決衝突的被選位置
  • 定理5.1:如果使用平方探測,且表的大小是素數,那麼當表至少有一半是空的時候,總能夠插入一個新的元素。
證明:
令表的大小TableSize是一個大於3的素數。我們證明,前[TableSize / 2]個備選位置是互異的。
h(X) + i^2(mod TableSize)和h(X) + j^2(mod TableSize)是這些位置中的兩個,其中0 < i, j <= [TableSize / 2]。為推出矛盾,假設這兩個位置相同,但i != j,於是

1) h(X) + i^2 = h(X) + j^2 (mod TableSize)
2) i^2 - j^2 = 0
3) (i + j)(i - j) = 0

所以i = -j或者i = j,因為i != j,且i,j都大於0,所以前[TableSize / 2]個備選位置是互異的
  • 由於要被插入的元素,若無任何衝突發生,也可以放到經雜湊得到的單元,因此任何元素都有[TableSize / 2]個可能被放到的位置,如果最多有[TableSie / 2]個位置可以使用,那麼空單元總能夠找到
  • 哪怕表有比一半多一個的位置被填滿,那麼插入都有可能失敗
  • 表的大小是素數也非常重要,如果表的大小不是素數,則備選單元的個數也可能銳減
  • 在開放定址雜湊表中,標準的刪除操作不能實行。因為相應的單元可能已經引起過沖突,元素繞過了它存在了別處。因此,開放定址雜湊表需要懶惰刪除。
  • 雖然平方探測排除了一次聚集,但是雜湊到同一位置上的那些元素將探測相同的備選單元,這叫做二次聚集。對於每次查詢,它一般要引起另外的少於一半的探測,因此可以使用雙雜湊,通過一些額外的乘法和除法解決這個問題

5.4.3 雙雜湊

  • F(i) = i * hash2(X)。將第二個雜湊函式應用到X並在距離hash2(X),2hash2(X)等處探測。hash2(X)選擇的不好將會是災難性的
  • 保證所有的單元都能被探測到
  • hash2(X) = R - (X mod R)這樣的函式將起到良好的作用;R為小於TableSize的素數。舉例:hash2(49) = 7 - 0 = 7,如果位置9產生衝突,則9 + 7 - 10 = 6,看位置6是否產生衝突,如果仍然衝突,則 6 + 7 - 10 = 3,如果位置3沒有衝突則插入位置3.
  • 如果雜湊表的大小不是素數,那麼備選單元就有可能提前用完。如果雙雜湊正確實現,則預期的探測次數幾乎和隨機衝突解決方法的情形相同,這使得雙雜湊理論上很有吸引力,不過平方探測不需要使用第二個雜湊函式,從而在時間上可能更簡單並且更快

5.5 再雜湊

  • 對於使用平方探測的開放定址雜湊法,如果表的元素填的太慢,那麼操作時間將會消耗過長,且Insert操作可能失敗。一種解決辦法是建立另外一個大約兩倍大的表,而且使用一個相關的新雜湊函式。掃描整個原始雜湊表,計算每個未刪除的元素的新雜湊值並將其插入到新表中
  • 如果再雜湊是程式的一部分,那麼其效果是不顯著的,但是如果它作為互動系統的一部分執行,那麼其插入引起的再雜湊的使用者就會感到速度緩慢
  • 實現方法:
    1. 只要表填滿一半就再雜湊
    2. 只有插入失敗時才再雜湊
    3. 當表達到某一個裝填因子時就再雜湊
  • 再雜湊把程式設計師從表的大小的擔心中解放出來,再雜湊還能用在其他資料結構中,例如佇列變滿時,可以宣告一個雙倍大小的陣列,並將每一個成員拷貝過來同時釋放原來的佇列
HashTable
ReHash(HashTable H)
{
	if (H == NULL) {
		printf("H is NULL!\n");
		return NULL;
	}

	int cnt;
	
	int OldTableSize = H->TableSize;
	Cell *OldCells = H->TheCells;
	HashTable newTable = InitializeTable(2 * OldTableSize);
	for (cnt = 0; cnt < OldTableSize; cnt++) {
		if (OldCells[cnt].Info == Legitimate) {
			Insert(newTable, OldCells[cnt].Element);
		}
	}
	DestroyTable(H);
	return newTable;
}

5.6 可擴雜湊

  • 如果資料量太大以至於裝不進主存,可以考慮使用可擴雜湊。根據上一節的描述,如果表變得過滿就要執行再雜湊,這樣代價巨大,因為它需要O(N)次磁碟訪問。而可擴雜湊允許兩次磁碟訪問執行一次Find,插入操作也需要很少的磁碟訪問
  • 目錄中的項數為2^D,dL為樹葉L所有元素共有的最高位的位數,dL將依賴於特定的樹葉,因此dL <= D
  • 如果樹葉中的元素滿了,即 = M,這時再插入就會分裂成兩片樹葉,目錄也會更新大小。
  • 有可能一片樹葉中的元素有多餘D + 1個前導位相同時,需要多個目錄分裂
  • 存在重複關鍵字的可能性,若存在多於M個重複關鍵字,則該蘇納法根本無效,此時需要做出其他的安排
  • 這些位元完全隨機是相當重要的,可以通過把這些關鍵字雜湊到合理長的整數來完成
  • 可擴雜湊的特性:基於合理假設即“位模式是均勻分佈的”。
    1. 樹葉的期望個數為(N/M)log(2)e,因此平均樹葉滿的程度為ln2 = 0.69。這和B樹是一樣的
    2. 目錄的期望大小即2^D, 為O(N^(1+1/M)M),如果M很小,那麼目錄可能過分的大。這種情況下,我們可以讓樹葉包含指向記錄的指標而不是實際的記錄,這樣可以增加M的值,為了維持更小的目錄,可以把第二個磁碟訪問新增到每個Find操作中去,如果目錄太大裝不進主存,那麼第二個磁碟訪問怎麼說也還是需要的

總結

  • 雜湊表可以在常數平均時間實現Insert和Find操作
  • 使用雜湊表時,設定裝填因子特別重要,否則時間界將不再有效
  • 當關鍵字不是短串或是整數時,仔細選擇雜湊函式也是很重要的
  • 對於分離連線雜湊法,雖然裝填因子比較小時效能不明顯降低,但是裝填因子還是應該接近1
  • 對於開放定址雜湊法,除非完全不可避免,否則裝填因子不應該超過0.5。如果使用線性探測,那麼效能隨著裝填因子接近於1而急速下降。再雜湊運算可以通過使表增長或收縮來實現,這樣將會保持合理的裝填因子。對於空間緊缺並且不可能宣告巨大雜湊表的情況,這是很重要的
  • 二叉查詢樹可以用來實現Insert & Find。雖然平均時間界為O(logN),但是二叉查詢樹也支援那些需要序的例程從而更實用。使用雜湊表不可能找出最小元素。除非準確知道一個字串,否則雜湊表也不可能有效的查詢它。而二叉查詢樹可以迅速找到在一定範圍內的所有項,雜湊表是做不到的。不僅如此,O(logN)並不比O(1)大那麼多,特別是因為查詢樹不需要乘法和除法
  • 雜湊的最壞情況一般來自於實現的缺憾,而有序的輸入卻可能使二叉樹執行的很差。平衡查詢樹實現的代價很高。因此,如果不需要序的資訊以及對輸入是否被排序有懷疑,那麼就應該選擇雜湊這種資料結構
  • 雜湊的應用:
    1. 編譯器使用雜湊表跟蹤原始碼中宣告的變數,即符號表。識別符號一般都不長,因此其雜湊函式能夠迅速被算出
    2. 圖論問題中節點有實際的名字而不是數字。而且輸入很可能是一組一組依字母順序排列的項。如果使用查詢樹則在效率方面可能會很低
    3. 遊戲中的變換表
    4. 線上拼寫檢驗程式

參考文獻

  1. Mark Allen Weiss.資料結構與演算法分析[M].America, 2007

本文作者: CrazyCatJack

本文連結: https://www.cnblogs.com/CrazyCatJack/p/13340018.html

版權宣告:本部落格所有文章除特別宣告外,均採用 BY-NC-SA 許可協議。轉載請註明出處!

關注博主:如果您覺得該文章對您有幫助,可以點選文章右下角推薦一下,您的支援將成為我最大的動力!


相關文章