1. 程式人生 > >資料結構——佇列、堆疊和雜湊表

資料結構——佇列、堆疊和雜湊表

目錄:

簡介

“排隊順序”的工作程序

“反排隊順序”——堆疊資料結構

序數索引限制

System.Collections.Hashtable類

結論

簡介

在第一部分中,我們瞭解了什麼是資料結構,評估了它們各自的效能,並瞭解了選擇何種資料結構對特定演算法的影響。另外我們還了解並分析了資料結構的基礎知識,介紹了一種最常用的資料結構:陣列。

陣列儲存了同一型別的資料,並通過序數進行索引。陣列實際的值是儲存在一段連續的記憶體空間中,因此讀寫陣列中特定的元素非常迅速。

因其具有的同構性及定長性,.Net Framework基類庫提供了ArrayList資料結構,它可以儲存不同型別的資料,並且不需要顯式地指定長度。前文所述,ArrayList本質上是儲存object型別的陣列,每次呼叫Add()方法增加元素,內部的object陣列都要檢查邊界,如果超出,陣列會自動以倍數增加其長度。

第二部分,我們將繼續考察兩種類陣列結構:Queue和Stack。和ArrayList相似,他們也是一段相鄰的記憶體塊以儲存不同型別的元素,然而在訪問資料時,會受到一定的限制。

之後,我們還將深入瞭解Hashtable資料結構。有時侯,我們可以把Hashtable看作殺一種關聯陣列(associative array),它同樣是儲存不同型別元素的集合,但它可通過任意物件(例如string)來進行索引,而非固定的序數。

“排隊順序”的工作程序

如果你要建立不同的服務,這種服務也就是通過多種資源以響應多種請求的程式;那麼當處理這些請求時,如何決定其響應的順序就成了建立服務的一大難題。通常解決的方案有兩種:

“排隊順序”原則

“基於優先等級”的處理原則

當你在商店購物、銀行取款的時候,你需要排隊等待服務。“排隊順序”原則規定排在前面的比後面的更早享受服務。而“基於優先等級”原則,則根據其優先等級的高低決定服務順序。例如在醫院的急診室,生命垂危的病人會比病情輕的更先接受醫生的診斷,而不用管是誰先到的。

設想你需要構建一個服務來處理計算機所接受到的請求,由於收到的請求遠遠超過計算機處理的速度,因此你需要將這些請求按照他們遞交的順序依此放入到緩衝區中。

一種方案是使用ArrayList,通過稱為nextJobPos的整型變數來指定將要執行的任務在陣列中的位置。當新的工作請求進入,我們就簡單使用ArrayList的Add()方法將其新增到ArrayList的末端。當你準備處理緩衝區的任務時,就通過nextJobPos得到該任務在ArrayList的位置值以獲取該任務,同時將nextJobPos累加1。下面的程式實現該演算法:

using System;
using System.Collections;
public class JobProcessing

{

   private static ArrayList jobs = new ArrayList();
   private static int nextJobPos = 0;
   public static void AddJob(string jobName)

   {
      jobs.Add(jobName);

   }  

   public static string GetNextJob()

   {

      if (nextJobPos > jobs.Count - 1)

         return "NO JOBS IN BUFFER";

      else

      {

         string jobName = (string) jobs[nextJobPos];

         nextJobPos++;

         return jobName;

      }

   }

   public static void Main()

   {

      AddJob("1");

      AddJob("2");

      Console.WriteLine(GetNextJob());

      AddJob("3");

Console.WriteLine(GetNextJob());

      Console.WriteLine(GetNextJob());

      Console.WriteLine(GetNextJob());

      Console.WriteLine(GetNextJob());

      AddJob("4");

      AddJob("5");

      Console.WriteLine(GetNextJob());

   }

}

輸出結果如下:

1

2

3

NO JOBS IN BUFFER

NO JOBS IN BUFFER

4

這種方法簡單易懂,但效率卻可怕得難以接受。因為,即使是任務被新增到buffer中後立即被處理,ArrayList的長度仍然會隨著新增到buffer中的任務而不斷增加。假設我們從緩衝區新增並移除一個任務需要一秒鐘,這意味一秒鐘內每呼叫AddJob()方法,就要呼叫一次ArrayList的Add()方法。隨著Add()方法持續不斷的被呼叫,ArrayList內部陣列長度就會根據需求持續不斷的成倍增長。五分鐘後,ArrayList的內部陣列增加到了512個元素的長度,這時緩衝區中卻只有不到一個任務而已。照這樣的趨勢發展,只要程式繼續執行,工作任務繼續進入,ArrayList的長度自然會繼續增長。

出現如此荒謬可笑的結果,原因是已被處理過的舊任務在緩衝區中的空間沒有被回收。也即是說,當第一個任務被新增到緩衝區並被處理後,此時ArrayList的第一元素空間應該被再利用。想想上述程式碼的工作流程,當插入兩個工作——AddJob("1")和AddJob("2")後——ArrayList的空間如圖一所示:
圖一:執行前兩行程式碼後的ArrayList

注意這裡的ArrayList共有16個元素,因為ArrayList初始化時預設的長度為16。接下來,呼叫GetNextJob()方法,移走第一個任務,結果如圖二:


圖二:呼叫GetNextJob()方法後的ArrayList

當執行AddJob(“3”)時,我們需要新增新任務到緩衝區。顯然,ArrayList的第一元素空間(索引為0)被重新使用,此時在0索引處放入了第三個任務。不過別忘了,當我們執行了AddJob(“3”)後還執行了AddJob(“4”),緊接著用呼叫了兩次GetNextJob()方法。如果我們把第三個任務放到0索引處,則第四個任務會被放到索引2處,問題發生了。如圖三:
圖三:將任務放到0索引時,問題發生

現在呼叫GetNextJob(),第二個任務從緩衝中移走,nextJobPos指標指向索引2。因此,當再一次呼叫GetNextJob()時,第四個任務會先於第三個被移走,這就有悖於與我們的“排序順序”原則。

問題發生的癥結在於ArrayList是以線形順序體現任務列表的。因此我們需要將新任務新增到就任務的右惻以保證當前的處理順序是正確的。不管何時到達ArrayList的末端,ArrayList都會成倍增長。如果產生產生未被使用的元素,則是因為呼叫了GetNextJob()方法。

解決之道是使我們的ArrayList成環形。環形陣列沒有固定的起點和終點。在陣列中,我們用變數來維護陣列的起止點。環形陣列如圖四所示:


圖四:環形陣列圖示

在環形陣列中,AddJob()方法新增新任務到索引endPos處(譯註:endPos一般稱為尾指標),之後“遞增”endPos值。GetNextJob()方法則根據頭指標startPos獲取任務,並將頭指標指向null,且“遞增”startPos值。我之所以把“遞增”兩字加上引號,是因為這裡所說的“遞增”不僅僅是將變數值加1那麼簡單。為什麼我們不能簡單地加1呢?請考慮這個例子:當endPos等於15時,如果endPos加1,則endPos等於16。此時呼叫AddJob(),它試圖去訪問索引為16的元素,結果出現異常IndexOutofRangeException。

事實上,當endPos等於15時,應將endPos重置為0。通過遞增(increment)功能檢查如果傳遞的變數值等於陣列長度,則重置為0。解決方案是將變數值對陣列長度值求模(取餘),increment()方法的程式碼如下:

int increment(int variable)

{

  return (variable + 1) % theArray.Length;

}

注:取模操作符,如x % y,得到的是x 除以 y後的餘數。餘數總是在0 到 y-1之間。

這種方法好處就是緩衝區永遠不會超過16個元素空間。但是如果我們要新增超過16個元素空間的新任務呢?就象ArrayList的Add()方法一樣,我們需要提供環形陣列自增長的能力,以倍數增長陣列的長度。

System.Collection.Queue類

就象我們剛才描述的那樣,我們需要提供一種資料結構,能夠按照“排隊順序”的原則插入和移除元素項,並能最大化的利用記憶體空間,答案就是使用資料結構Queue。在.Net Framework基類庫中已經內建了該類——System.Collections.Queue類。就象我們程式碼中的AddJob()和GetNextJob()方法,Queue類提供了Enqueue()和Dequeue()方法分別實現同樣的功能。

Queue類在內部建立了一個存放object物件的環形陣列,並通過head和tail變數指想該陣列的頭和尾。預設狀態下,Queue初始化的容量為32,我們也可以通過其建構函式自定義容量。既然Queue內建的是object陣列,因此可以將任何型別的元素放入佇列中。

Enqueue()方法首先判斷queue中是否有足夠容量存放新元素。如果有,則直接新增元素,並使索引tail遞增。在這裡tail使用求模操作以保證tail不會超過陣列長度。如果空間不夠,則queue根據特定的增長因子擴充陣列容量。增長因子預設值為2.0,所以內部陣列的長度會增加一倍。當然你也可以在建構函式中自定義該增長因子。

Dequeue()方法根據head索引返回當前元素。之後將head索引指向null,再“遞增”head的值。也許你只想知道當前頭元素的值,而不使其輸出佇列(dequeue,出列),則Queue類提供了Peek()方法。

Queue並不象ArrayList那樣可以隨機訪問,這一點非常重要。也就是說,在沒有使前兩個元素出列之前,我們不能直接訪問第三個元素。(當然,Queue類提供了Contains()方法,它可以使你判斷特定的值是否存在佇列中。)如果你想隨機的訪問資料,那麼你就不能使用Queue這種資料結構,而只能用ArrayList。Queue最適合這種情況,就是你只需要處理按照接收時的準確順序存放的元素項。

注:你可以將Queues稱為FIFO資料結構。FIFO意為先進先出(First In, First Out),其意等同於“排隊順序(First come, first served)”。

譯註:在資料結構中,我們通常稱佇列為先進先出資料結構,而堆疊則為先進後出資料結構。然而本文沒有使用First in ,first out的概念,而是first come ,first served。如果翻譯為先進先服務,或先處理都不是很適合。聯想到本文在介紹該概念時,以商場購物時需要排隊為例,索性將其譯為“排隊順序”。我想,有排隊意識的人應該能明白其中的含義吧。那麼與之對應的,對於堆疊,只有名為“反排隊順序”,來代表(First Come, Last Served)。希望各位朋友能有更好地翻譯來取代我這個拙劣的詞語。為什麼不翻譯為“先進先出”,“先進後出”呢?我主要考慮到這裡的英文served,它所包含的含義很廣,至少我們可以將其認為是對資料的處理,因而就不是簡單地輸出那麼簡單。所以我乾脆避開這個詞語的含義。

“反排隊順序”——堆疊資料結構

Queue資料結構通過使用內部儲存object型別的環形陣列以實現“排隊順序”的機制。Queue提供了Enqueue()和Dequeue()方法實現資料訪問。“排隊順序”在處理現實問題時經常用到,尤其是提供服務的程式,例如web伺服器,列印佇列,以及其他處理多請求的程式。

在程式設計中另外一個經常使用的方式是“反排隊順序(first come,last served)”。堆疊就是這樣一種資料結構。在.Net Framework基類庫中包含了System.Collection.Stack類,和Queue一樣,Stack也是通過儲存object型別資料物件的內部環形陣列來實現。Stack通過兩種方法訪問資料——Push(item),將資料壓入堆疊;Pop()則是將資料彈出堆疊,並返回其值。

一個Stack可以通過一個垂直的資料元素集合來形象地表示。當元素壓入堆疊時,新元素被放到所有其他元素的頂端,彈出時則從堆疊頂端移除該項。下面兩幅圖演示了堆疊的壓棧和出棧過程。首先按照順序將資料1、2、3壓入堆疊,然後彈出:
 
圖五:向堆疊壓入三個元素
 
圖六:彈出所有元素後的Stack

注意Stack類的預設容量是10個元素,而非Queue的32個元素。和Queue和ArrayList一樣,Stack的容量也可以根據建構函式定製。如同ArrayList,Stack的容量也是自動成倍增長。(回憶一下:Queue可以根據建構函式的可選項設定增長因子。)

注:Stack通常被稱為“LIFO先進後出”或“LIFO後進先出”資料結構。
堆疊:電腦科學中常見的隱喻
現實生活中有很多同Queue相似的例子:DMV(譯註:不知道其縮寫,恕我孤陋寡聞,不知其意)、列印任務處理等。然而在現實生活很難找到和Stack近似的範例,但它在各種應用程式中卻是一種非常重要的資料結構。

設想一下我們用以程式設計的計算機語言,例如:C#。當執行C#程式時,CLR(公共語言執行時)將呼叫Stack以跟蹤功能模組(譯註:這裡原文為function,我理解作者的含義不僅僅代表函式,事實上很多編譯器都會呼叫堆疊以確定其地址)的執行情況。每當呼叫一個功能模組,相關資訊就會壓入堆疊。呼叫結束則彈出堆疊。堆疊頂端資料為當前呼叫功能的資訊。(如要檢視功能呼叫堆疊的執行情況,可以在Visual Studio.Net下建立一個專案,設定斷點(breakpoint),在執行除錯。當執行到斷點時,會在除錯視窗(Debug/Windows/Call Stack)下顯示堆疊資訊。

序數索引的限制

我們在第一部分中講到陣列的特點是同種型別資料的集合,並通過序數進行索引。即:訪問第i個元素的時間為定值。(請記住此種定量時間被標記為O(1)。)

也許我們並沒有意識到,其實我們對有序資料總是“情有獨鍾”。例如員工資料庫。每個員工以社保號(social security number)為其唯一標識。社保號的格式為DDD-DD-DDDD(D的範圍為數字0——9)。如果我們有一個隨機排列儲存所有員工資訊的陣列,要查詢社保號為111-22-3333的員工,可能會遍歷陣列的所有元素——即執行O(n)次操作。更好的辦法是根據社保號進行排序,可將其查詢時間縮減為O(log n)。

理想狀態下,我們更願意執行O(1)次時間就能查詢到某員工的資訊。一種方案是建立一個巨型的陣列,以實際的社保號值為其入口。這樣陣列的起止點為000-00-0000到999-99-9999,如下圖所示:
 
圖七:儲存所有9位數數字的巨型陣列

如圖所示,每個員工的資訊都包括姓名、電話、薪水等,並以其社保號為索引。在這種方式下,訪問任意一個員工資訊的時間均為定值。這種方案的缺點就是空間極度的浪費——共有109,即10億個不同的社保號。如果公司只有1000名員工,那麼這個陣列只利用了0.0001%的空間。(換個角度來看,如果你要讓這個陣列充分利用,也許你的公司不得不僱傭全世界人口的六分之一。)

用雜湊函式壓縮序數索引

顯而易見,建立10億個元素陣列來儲存1000名員工的資訊是無法接受的。然而我們又迫切需要提高資料訪問速度以達到一個常量時間。一種選擇是使用員工社保號的最後四位來減少社保號的跨度。這樣一來,陣列的跨度只需要從0000到9999。圖八顯示了壓縮後的陣列。
 
圖八:壓縮後的陣列

此方案既保證了訪問耗時為常量值,又充分利用了儲存空間。選擇社保號的後四位是隨機的,我們也可以任意的使用中間四位,或者選擇第1、3、8、9位。

在數學上將這種9位數轉換為4位數成為雜湊轉換(hashing)。雜湊轉換可以將一個索引器空間(indexers space)轉換為雜湊表(hash table)。

雜湊函式實現雜湊轉換。以社保號的例子來說,雜湊函式H()表示為:
H(x) = x 的後四位

雜湊函式的輸入可以是任意的九位社保號,而結果則是社保號的後四位數字。數學術語中,這種將九位數轉換為四位數的方法稱為雜湊元素對映,如圖九所示:
 
圖九:雜湊函式圖示

圖九闡明瞭在雜湊函式中會出現的一種行為——衝突(collisions)。即我們將一個相對大的集合的元素對映到相對小的集中時時,可能會出現相同的值。例如社保號中所有後四位為0000的均被對映為0000。那麼000-99-0000,113-14-0000,933-66-0000,還有其他的很多都將是0000。

看看之前的例子,如果我們要新增一個社保號為123-00-0191的新員工,會發生什麼情況?顯然試圖新增該員工會發生衝突,因為在0191位置上已經存在一個員工。

數學標註:雜湊函式在數學術語上更多地被描述為f:A->B。其中|A|>|B|,函式f不是一一對映關係,所以之間會有衝突。

顯然衝突的發生會產生一些問題。在下一節,我們會看看雜湊函式與衝突發生之間的關係,然後簡單地犯下處理衝突的幾種機制。接下來,我們會將注意力放在System.Collection.Hashtable類,並提供一個雜湊表的實現。我們會了解有關Hashtable類的雜湊函式,衝突解決機制,以及一些使用Hashtable的例子。

避免和解決衝突

當我們新增資料到雜湊表中,衝突是導致整個操作被破壞的一個因素。如果沒有衝突,則插入元素操作成功,如果發生了衝突,就需要判斷髮生的原因。由於衝突產生提高了代價,我們的目標就是要儘可能將衝突壓至最低。

雜湊函式中衝突發生的頻率與傳遞到雜湊函式中的資料分佈有關。在我們的例子中,假定社保號是隨機分配的,那麼使用最後四位數字是一個不錯的選擇。但如果社保號是以員工的出生年份或出生地址來分配,因為員工的出生年份和地址顯然都不是均勻分配的,那麼選用後四位數就會因為大量的重複而導致更大的衝突。

注:對於雜湊函式值的分析需要具備一定的統計學知識,這超出了本文討論的範圍。必要地,我們可以使用K維(k slots)的雜湊表來保證避免衝突,它可以將一個隨機值從雜湊函式的域中對映到任意一個特定元素,並限定在1/k的範圍內。(如果這讓你更加的糊塗,千萬別擔心!)

我們將選擇合適的雜湊函式的方法成為衝突避免機制(collision avoidance),已有許多研究設計這一領域,因為雜湊函式的選擇直接影響了雜湊表的整體效能。在下一節,我們會介紹在.Net Framework的Hashtable類中對雜湊函式的使用。

有很多方法處理衝突問題。最直接的方法,我們稱為“衝突解決機制”(collision resolution),是將要插入到雜湊表中的物件放到另外一塊空間中,因為實際的空間已經被佔用了。其中一種最簡單的方法稱為“線性挖掘”(linear probing),實現步驟如下:
1. 當要插入一個新的元素時,用雜湊函式在雜湊表中定位;
2. 檢查表中該位置是否已經存在元素,如果該位置內容為空,則插入並返回,否則轉向步驟3。
3. 如果該地址為i,則檢查i+1是否為空,如果已被佔用,則檢查i+2,依此類推,知道找到一個內容為空的位置。

例如:如果我們要將五個員工的資訊插入到雜湊表中:Alice(333-33-1234),Bob(444-44-1234), Cal (555-55-1237), Danny (000-00-1235), and Edward (111-00-1235)。當新增完資訊後,如圖十所示:
 
圖十:有相似社保號的五位員工

Alice的社保號被“雜湊(這裡做動詞用,譯註)”為1234,因此存放位置為1234。接下來來,Bob的社保號也被“雜湊”為1234,但由於位置1234處已經存在Alice的資訊,所以Bob的資訊就被放到下一個位置——1235。之後,新增Cal,雜湊值為1237,1237位置為空,所以Cal就放到1237處。下一個是Danny,雜湊值為1235。1235已被佔用,則檢查1236位置是否為空。既然為空,Danny就被放到那兒。最後,新增Edward的資訊。同樣他的雜湊好為1235。1235已被佔用,檢查1236,也被佔用了,再檢查1237,直到檢查到1238時,該位置為空,於是Edward被放到了1238位置。

搜尋雜湊表時,衝突仍然存在。例如,如上所示的雜湊表,我們要訪問Edward的資訊。因此我們將Edward的社保號111-00-1235雜湊為1235,並開始搜尋。然而我們在1235位置找到的是Bob,而非Edward。所以我們再搜尋1236,找到的卻是Danny。我們的線性搜尋繼續查詢知道找到Edward或找到內容為空的位置。結果我們可能會得出結果是社保號為111-00-1235的員工並不存在。

線性挖掘雖然簡單,但並是解決衝突的好的策略,因為它會導致同類聚合(clustering)。如果我們要新增10個員工,他們的社保號後四位均為3344。那麼有10個連續空間,從3344到3353均被佔用。查詢這10個員工中的任一員工都要搜尋這一簇位置空間。而且,新增任何一個雜湊值在3344到3353範圍內的員工都將增加這一簇空間的長度。要快速查詢,我們應該讓資料均勻分佈,而不是集中某幾個地方形成一簇。

更好的挖掘技術是“二次挖掘”(quadratic probing),每次檢查位置空間的步長以平方倍增加。也就是說,如果位置s被佔用,則首先檢查s+12處,然後檢查s-12,s+22,s-22,s+32 依此類推,而不是象線性挖掘那樣從s+1,s+2……線性增長。當然二次挖掘同樣會導致同類聚合。

下一節我們將介紹第三種衝突解決機制——二度雜湊,它被應用在.Net Framework的雜湊表類中。

System.Collections.Hashtable 類
.Net Framework 基類庫包括了Hashtable類的實現。當我們要新增元素到雜湊表中時,我們不僅要提供元素(item),還要為該元素提供關鍵字(key)。Key和item可以是任意型別。在員工例子中,key為員工的社保號,item則通過Add()方法被新增到雜湊表中。

要獲得雜湊表中的元素(item),你可以通過key作為索引訪問,就象在陣列中用序數作為索引那樣。下面的C#小程式演示了這一概念。它以字串值作為key添加了一些元素到雜湊表中。並通過key訪問特定的元素。

using System;
using System.Collections;

public class HashtableDemo
{
   private static Hashtable ages = new Hashtable();

   public static void Main()
   {
        // Add some values to the Hashtable, indexed by a string key
        ages.Add("Scott", 25);
        ages.Add("Sam", 6);
        ages.Add("Jisun", 25);
       
        // Access a particular key
        if (ages.ContainsKey("Scott"))
        {
            int scottsAge = (int) ages["Scott"];
            Console.WriteLine("Scott is " + scottsAge.ToString());
        }
        else
            Console.WriteLine("Scott is not in the hash table...");
   }
}
程式中的ContainsKey()方法,是根據特定的key判斷是否存在符合條件的元素,返回布林值。Hashtable類中包含keys屬性(property),返回雜湊表中使用的所有關鍵字的集合。這個屬性可以通過遍歷訪問,如下:

// Step through all items in the Hashtable
foreach(string key in ages.Keys)
Console.WriteLine("Value at ages[/"" + key + "/"] = " + ages[key].ToString());

要認識到插入元素的順序和關鍵字集合中key的順序並不一定相同。關鍵字集合是以儲存的關鍵字對應的元素為基礎,上面的程式的執行結果是:

Value at ages["Jisun"] = 25
Value at ages["Scott"] = 25
Value at ages["Sam"] = 6

即使插入到雜湊表中的順序是:Scott,Sam, Jisun。

Hashtable類的雜湊函式

Hashtable類中的雜湊函式比我們前面介紹的社保號的雜湊值更加複雜。首先,要記住的是雜湊函式返回的值是序數。對於社保號的例子來說很容易辦到,因為社保號本身就是數字。我們只需要擷取其最後四位數,就可以得到合適的雜湊值。然而Hashtable類中可以接受任何型別的值作為key。就象上面的例子,key是字串型別,如“Scott”或“Sam”。在這樣一個例子中,我們自然想明白雜湊函式是怎樣將string轉換為數字的。

這種奇妙的轉換應該歸功於GetHashCode()方法,它定義在System.Object類中。Object類中GetHashCode()預設的實現是返回一個唯一的整數值以保證在object的生命期中不被修改。既然每種型別都是直接或間接從Object派生的,因此所以object都可以訪問該方法。自然,字串或其他型別都能以唯一的數字值來表示。

Hashtable類中的對於雜湊函式的定義如下:

H(key) = [GetHash(key) + 1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1))] % hashsize

這裡的GetHash(key),預設為對key呼叫GetHashCode()方法的返回值(雖然在使用Hashtable時,你可以自定義GetHash()函式)。GetHash(key)>>5表示將得到key的雜湊值,向右移動5位,相當於將雜湊值除以32。%操作符就是之前介紹的求模運算子。Hashsize指的是雜湊表的長度。因為要進行求模,因此最後的結果H(k)在0到hashsize-1之間。既然hashsize為雜湊表的長度,因此結果總是在可以接受的範圍內。

Hashtable類中的衝突解決方案

當我們在雜湊表中新增或獲取一個元素時,會發生衝突。插入元素時,必須查詢內容為空的位置,而獲取元素時,即使不在預期的位置處,也必須找到該元素。前面我們簡單地介紹了兩種解決衝突的機制——線性和二次挖掘。在Hashtable類中使用的是一種完全不同的技術,成為二度雜湊(rehasing)(有的資料也將其稱為雙精度雜湊double hashing)。

二度雜湊的工作原理如下:有一個包含多個雜湊函式(H1……Hn)的集合。當我們要從雜湊表中新增或獲取元素時,首先使用雜湊函式H1。如果導致衝突,則嘗試使用H2,一直到Hn。各個雜湊函式極其相似,不同的是它們選用的乘法因子。通常,雜湊函式Hk的定義如下:
Hk(key) = [GetHash(key) + k * (1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1)))] % hashsize

注:運用二度雜湊重要的是在執行了hashsize次挖掘後,雜湊表中的每一個位置都確切地被有且僅有一次訪問。也就是說,對於給定的key,對雜湊表中的同一位置不會同時使用Hi和Hj。在Hashtable類中使用二度雜湊公式,其保證為:(1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1))與hashsize兩者互為素數。(兩數互為素數表示兩者沒有共同的質因子。)如果hashsize是一個素數,則保證這兩個數互為素數。

二度雜湊較前兩種機制較好地避免了衝突。

呼叫因子(load factors)和擴充雜湊表

Hashtable類中包含一個私有成員變數loadFactor,它指定了雜湊表中元素個數與表位置總數之間的最大比例。例如:loadFactor等於0.5,則說明雜湊表中只有一半的空間存放了元素值,其餘一半皆為空。

雜湊表的建構函式以過載的方式,允許使用者指定loadFactor值,定義範圍為0.1到1.0。要注意的是,不管你提供的值是多少,範圍都不超過72%。即使你傳遞的值為1.0,Hashtable類的loadFactor值還是0.72。微軟認為loadFactor的最佳值為0.72,因此雖然預設的loadFactor為1.0,但系統內部卻自動地將其改變為0.72。所以,建議你使用預設值1.0(事實上是0.72,有些迷惑,不是嗎?)

注:我花了好幾天時間去諮詢微軟的開發人員為什麼要使用自動轉換?我弄不明白,為什麼他們不直接規定值為0.072到0.72之間。最後我從編寫Hashtable類的開發團隊的到了答案,他們非常將問題的緣由公諸於眾。事實上,這個團隊經過測試發現如果loadFactor超過了0.72,將會嚴重的影響雜湊表的效能。他們希望開發人員能夠更好地使用雜湊表,但卻可能記不住0.72這個無規律數,相反如果規定1.0為最佳值,開發者會更容易記住。於是,就形成現在的結果,雖然在功能上有少許犧牲,但卻使我們能更加方便地使用資料結構,而不用感到頭疼。

向Hashtable類新增新元素時,都要進行檢查以保證元素與空間大小的比例不會超過最大比例。如果超過了,雜湊表空間將被擴充。步驟如下:
1. 雜湊表的位置空間近似地成倍增加。準確地說,位置空間值從當前的素數值增加到下一個最大的素數值。(回想一下前面講到的二度雜湊的工作原理,雜湊表的位置空間值必須是素數。)
2. 既然二度雜湊時,雜湊表中的所有元素值將依賴於雜湊表的位置空間值,所以表中所有值也需要二度雜湊(因為在第一步中位置空間值增加了)。

幸運的是,Hashtable類中的Add()方法隱藏了這些複雜的步驟,你不需要關心它的實現細節。

呼叫因子(load factor)對衝突的影響決定於雜湊表的總體長度和進行挖掘操作的次數。Load factor越大,雜湊表越密集,空間就越少,比較於相對稀疏的雜湊表,進行挖掘操作的次數就越多。如果不作精確地分析,當衝突發生時挖掘操作的預期次數大約為1/(1-lf),這裡lf指的是load factor。

如前所述,微軟將雜湊表的預設呼叫因子設定為0.72。因此對於每次衝突,平均挖掘次數為3.5次。既然該數字與雜湊表中實際元素個數無關,因此雜湊表的漸進訪問時間為O(1),顯然遠遠好於陣列的O(n)。

最後,我們要認識到對雜湊表的擴充將以效能損耗為代價。因此,你應該預先估計你的雜湊表中最後可能會容納的元素總數,在初始化雜湊表時以合適的值進行構造,以避免不必要的擴充。