數據結構基礎溫故-6.查找(下):哈希表


哈希(散列)技術既是一種存儲方法,也是一種查找方法。然而它與線性表、樹、圖等結構不同的是,前面幾種結構,數據元素之間都存在某種邏輯關系,可以用連線圖示表示出來,而哈希技術的記錄之間不存在什么邏輯關系,它只與關鍵字有關聯。因此,哈希主要是面向查找的存儲結構。哈希技術最適合的求解問題是查找與給定值相等的記錄。

hash function

一、基本概念及原理

1.1 哈希定義的引入

  這里首先看一個場景:在大多數情況下,數組中的索引並不具有實際的意義,它僅僅表示一個元素在數組中的位置而已,當需要查找某個元素時,往往會使用有實際意義的字段。例如下面一段代碼,它使用學生的學號來查找學生的地址。

  (1)學生實體類定義

    public class StudentInfo
    {
        public string Number { get; set; }

        public string Address { get; set; }

        public StudentInfo(string number, string address)
        {
            Number = number;
            Address = address;
        }
    }

  (2)通過索引遍歷查找

        static StudentInfo[] InitialStudents()
        {
            StudentInfo[] arrStudent = {
                new StudentInfo("200807001","四川達州"),
                new StudentInfo("200807002","四川成都"),
                new StudentInfo("200807003","山東青島"),
                new StudentInfo("200807004","河南鄭州"),
                new StudentInfo("200807005","江蘇徐州")
            };

            return arrStudent;
        }

        static void NormalSearch(StudentInfo[] arrStudent, string searchNumber)
        {
            bool isFind = false;
            foreach (var student in arrStudent)
            {
                if (student.Number == searchNumber)
                {
                    isFind = true;
                    Console.WriteLine("Search successfully!{0} address:{1}", searchNumber, student.Address);
                }
            }

            if (!isFind)
            {
                Console.WriteLine("Search {0} failed!", searchNumber);
            }
        }

        static void Main(string[] args)
        {
            StudentInfo[] arrStudent = InitialStudents();
            // 01.普通數組遍歷查找
            NormalSearch(arrStudent, "200807005");

            Console.ReadKey();
        }

  運行結果如下圖所示,可以看到圓滿完成了查找任務。

  但是,如果查找的記錄位於數組的最后或者根本就不存在,仍然需要遍歷整個數組。當數組非常巨大時,還以這樣的方式查找將會消耗較多的時間。是否有一種方法可以通過學號關鍵字就能直接地定位到相應的記錄

  (3)改寫查找方式為哈希查找

  通過觀察學號記錄與索引的對應關系,學號的后三位數組恰好是一組有序數列,如果把每個學生的學號后三位數組抽取出來並減去1,結果剛好可以與數組的索引號一一對應。於是,我們可以將上例改寫為如下方式:

        static int GetHashCode(string number)
        {
            string index = number.Substring(6);
            return Convert.ToInt32(index) - 1;
        }

        static void HashSearch(StudentInfo[] arrStudent, string searchNumber)
        {
            Console.WriteLine("{0} address:{1}", searchNumber, arrStudent[GetHashCode(searchNumber)].Address);
        }

        static void Main(string[] args)
        {
            StudentInfo[] arrStudent = InitialStudents();

            HashSearch(arrStudent, "200807005");
            HashSearch(arrStudent, "200807001");

            Console.ReadKey();
        }

  可以看出,通過封裝GetHashCode()方法,實現了學號與數組索引的一一對應關系,在查找中直接定位到了索引號,避免了遍歷操作,從而提高了查詢效率,從原來的O(n)提高到了O(1),運行結果如下圖所示:

上例中的學號是不重復的,它可以唯一標識學生集合中的每一條記錄,這樣的字段就被稱為key關鍵字)。而在記錄存儲地址和它的關鍵字之間建立一個確定的對應關系h,使得每個關鍵字和一個唯一的存儲位置相對應。在查找時,只需要根據這個對應關系h,就可以找到所需關鍵字及其對應的記錄,這種查找方式就被稱為哈希查找,關鍵字和存儲位置的對應關系可以用函數表示為:

h(key)=存儲地址

1.2 構造哈希函數的方法

  構造哈希函數的目標在於使哈希地址盡可能均勻地分布在連續的內存單元地址上,以減少發生沖突的可能性,同時使計算盡可能簡單以達到盡可能高的時間效率,這里主要看看兩個構造哈希函數的方法。

  (1)直接地址法

  直接地址法取關鍵字的某個線性函數值為哈希地址,即h(key)=keyh(key)=a*key+b

  其中,a、b均為常數,這樣的散列函數優點就是簡單、均勻,也不會產生沖突,但問題是這需要事先知道關鍵字的分布情況,適合查找表較小且連續的情況。由於這樣的限制,在現實應用中,此方法雖然簡單,但卻並不常用

  (2)除留余數法

  除留余數法采用取模運算(%)把關鍵字除以某個不大於哈希表表長的整數得到的余數作為哈希地址,它也是最常用的構造哈希函數的方法,其形式為:h(key)=key%p

  本方法的關鍵就在於選擇合適的p,p如果選得不好,就可能會容易產生同義詞。

PS:根據前輩們的經驗,若哈希表表長為m,通常p為小於或等於表長(最好接近m)的最小質數或不包含小於20質因子的合數

1.3 解決哈希沖突的方法

  (1)閉散列法

  閉散列法時把所有的元素都存儲在哈希表數組中,當發生沖突時,在沖突位置的附近尋找可存放記錄的空單元。尋找“下一個”空位的過程則稱為探測。上述方法可用如下公式表示為:

  其中,h(key)為哈希函數,m為哈希表長度,di為遞增的序列。根據di的不同,又可以分為幾種探測方法:線性探測法、二次探測法以及雙重散列法。

  (2)開散列法

  開散列法的常見形式是將所有關鍵字為同義詞的記錄存儲在一個單鏈表中。我們稱這種表為同義詞子表,在散列表中只存儲所有同義詞子表的頭指針。對於關鍵字集合{12,67,56,16,25,37,22,29,15,47,48,34},我們用前面同樣的12為除數,進行除留余數法,可得到如下圖所示的結構,此時,已經不存在什么沖突換址的問題,無論有多少個沖突,都只是在當前位置給單鏈表增加結點的問題。

  該方法對於可能會造成很多沖突的散列函數來說,提供了絕不會出現找不到地址的保障。當然,這也就帶來了查找時需要遍歷單鏈表的性能損耗。在.NET中,鏈表的各個元素分散於托管堆各處,也會給GC垃圾回收帶來壓力,影響程序的性能

二、.NET中的Hashtable

2.1 Hashtable的用法

  在.NET中,實現了哈希表數據結構的集合類有兩個,其中一個就是Hashtable,另一個是泛型版本的Dictionary<TKey,TValue>。這里我們首先看看Hashtable的用法,由於Hashtable中key/value鍵值對均為object類型,所以Hashtable可以支持任何類型的key/value鍵值對。

        static void HashtableTest()
        {
            // 創建一個Hashtable實例
            Hashtable ht = new Hashtable();
            // 添加key/value鍵值對
            ht.Add("北京", "帝都");
            ht.Add("上海", "魔都");
            ht.Add("廣州", "省會");
            ht.Add("深圳", "特區");
            
            // 根據key獲取value
            string capital = (string)ht["北京"];
            Console.WriteLine("北京:{0}", capital);
            Console.WriteLine("--------------------");
            // 判斷哈希表是否包含特定鍵,其返回值為true或false
            Console.WriteLine("包含上海嗎?{0}",ht.Contains("上海"));
            Console.WriteLine("--------------------");
            // 移除一個key/value鍵值對
            ht.Remove("深圳");
            // 遍歷哈希表
            foreach (DictionaryEntry de in ht)
            {
                Console.WriteLine("{0}:{1}", de.Key, de.Value);
            }
            Console.WriteLine("--------------------");
            // 移除所有元素
            ht.Clear();
            // 遍歷哈希表
            foreach (DictionaryEntry de in ht)
            {
                Console.WriteLine("{0}:{1}", de.Key, de.Value);
            }
        }

  運行結果如下圖所示:

2.2 剖析Hashtable

  (1)閉散列法

  Hashtable內部使用了閉散列法來解決沖突,它通過一個結構體bucket來表示哈希表中的單個元素,這個結構體有三個成員:

private struct bucket
{
    public object key;
    public object val;
    public int hash_coll;
}

  兩個object類型(那么必然會涉及到裝箱和拆箱操作)的變量,其中key表示鍵,val表示值,而hash_coll則是一個int類型,它用於表示鍵所對應的哈希碼。眾所周知,一個int類型占4個字節(這里主要探討32位系統中),一個字節又是8位,那么4*8=32位。它的最高位是符號位,當最高位為“0”時,表示是一個正整數,而為“1”時則表示是一個負整數。hash_coll使用最高位表示當前位置是否發生沖突,“0”時也就是正數時,表示未發生沖突;為“1”時,則表示當前位置存在沖突。之所以專門使用一個標志位用於標注是否發生沖突,主要是為了提高哈希表的運行效率

  (2)雙重散列法

  Hashtable解決沖突使用了雙重散列法,但又與普通的雙重散列法不同。它探測地址的方法如下:

  其中,哈希函數h1和h2的公式有如下所示:

               

  由於使用了二度哈希,最終的h(key,i)的值有可能會大於hashsize,所以需要對h(key,i)進行取模運算,最終計算的哈希地址為:

  這里需要注意的是:在bucket結構體中,hash_coll變量存儲的是h(key,i)的值而不是最終的哈希地址。

  Hashtable通過關鍵字查找元素時,首先會計算出鍵的哈希地址,然后通過這個哈希地址直接訪問數組的相應位置並對比兩個鍵值,如果相同,則查找成功並返回;如果不同,則根據hash_coll的值來決定下一步操作。

  ①當hash_coll為0或整數時,表明沒有沖突,此時表明查找失敗;

  ②當hash_coll為負數時,表明存在沖突,此時需要通過二度哈希繼續計算哈希地址進行查找,如此反復直到找到相應的鍵值表明查找成功,如果在查找過程中遇到hash_coll為正數或計算二度哈希的次數等於哈希表長度則查找失敗。

  由此可知,將hash_coll的高位設為沖突檢測位主要是為了提高查找速度,避免無意義地多次計算二度哈希的情況

  (3)使用素數實現hashsize

  通過查看Hashtable的構造函數,我們可以發現調用了HashHelpers的GetPrime方法生成了一個素數來為hashsize賦值:

public Hashtable(int capacity, float loadFactor)
{
    .......
    int num2 = (num > 3.0) ? HashHelpers.GetPrime((int) num) : 3;
    this.buckets = new bucket[num2];
    this.loadsize = (int) (this.loadFactor * num2);
    .......
}

  Hashtable是可以自動擴容的,當以指定長度初始化哈希表或給哈希表擴容時都需要保證哈希表的長度為素數,GetPrime(int min)方法正是用於獲取這個素數,參數min表示初步確定的哈希表長度,它返回一個比min大的最合適的素數。

三、.NET中的Dictionary

3.1 Dictionary的用法

  Dictionary是泛型版本的哈希表,但和Hashtable之間並非只是簡單的泛型和非泛型的區別,兩者使用了完全不同的哈希沖突解決辦法,我們先來看看Dictionary如何使用。

        static void DictionaryTest()
        {
            Dictionary<string, StudentInfo> dict = new Dictionary<string, StudentInfo>();
            for (int i = 0; i < 10; i++)
            {
                StudentInfo stu = new StudentInfo()
                {
                    Number = "200807" + i.ToString().PadLeft(3, '0'),
                    Name = "Student" + i.ToString()
                };
                dict.Add(i.ToString(), stu);
            }

            // 判斷是否包含某個key
            if (dict.ContainsKey("1"))
            {
                Console.WriteLine("已經存在key為{0}的鍵值對了,它是{1}", 1, dict["1"].Name);
            }
            Console.WriteLine("--------------------------------");
            // 遍歷鍵值對
            foreach (var de in dict)
            {
                Console.WriteLine("Key:{0},Value:[Number:{1},Name:{2}]", de.Key, de.Value.Number, de.Value.Name);
            }
            // 移除一個鍵值對
            if(dict.ContainsKey("5"))
            {
                dict.Remove("5");
            }
            Console.WriteLine("--------------------------------");
            // 遍歷鍵值對
            foreach (var de in dict)
            {
                Console.WriteLine("Key:{0},Value:[Number:{1},Name:{2}]", de.Key, de.Value.Number, de.Value.Name);
            }
            // 清空鍵值對
            dict.Clear();
            Console.WriteLine("--------------------------------");
            // 遍歷鍵值對
            foreach (var de in dict)
            {
                Console.WriteLine("Key:{0},Value:[Number:{1},Name:{2}]", de.Key, de.Value.Number, de.Value.Name);
            }
        }

  運行結果如下圖所示:

3.2 剖析Dictionary

  (1)較Hashtable簡單的哈希地址公式

  Dictionary的哈希地址求解方式較Hashtable簡單了許多,其計算公式如下所示:

  通過查看其源碼,在Add方法中驗證是否是上面的地址計算方式:

int num = this.comparer.GetHashCode(key) & 0x7fffffff;
int index = num % this.buckets.Length;

  (2)內部兩個數組結合的存儲結構

  Dictionary內部有兩個數組,一個數組名為buckets,用於存放由多個同義詞組成的靜態鏈表頭指針(鏈表的第一個元素在數組中的索引號,當它的值為-1時表示此哈希地址不存在元素);另一個數組為entries,它用於存放哈希表中的實際數據,同時這些數據通過next指針構成多個單鏈表。entries中所存放的是Entry結構體,Entry結構體由4個部分組成,如下所示:

private struct Entry
{
    public int hashCode;
    public int next;
    public TKey key;
    public TValue value;
}

  Dictionary將多個鏈表繼承於一個順序表之中進行統一管理:

Hashtable 與 Dictionary 的區別:

①Hashtable使用閉散列法來解決沖突,而Dictionary使用開散列法解決沖突;

②Dictionary相對Hashtable來說需要更多的存儲空間,但它不會發生二次聚集的情況,並且使用了泛型,相對非泛型可能需要的裝箱和拆箱操作,Dictionary的速度更快;

③Hashtable使用了填充因子的概念,而Dictionary則不存在填充因子的概念;

④Hashtable在擴容時由於重新計算哈希地址,會消耗大量時間計算,而Dictionary的擴容相對Hashtable來說更快;

⑤單線程程序中推薦使用Dictionary,而多線程程序中則推薦使用Hashtable。默認的Hashtable允許單線程寫入,多線程讀取,對Hashtable進一步調用Synchronized()方法可以獲得完全線程安全的類型。相反,Dictionary不是線程安全的,必須人為使用lock語句進行保護,效率有所降低。

四、.NET中幾種查找表的對比

4.1 測試對比介紹

  在.NET中有三種主要的查找表的數據結構,分別是SortedDictionary(前面已經介紹過了,其內部是紅黑樹數據結構實現)、HashtableDictionary。本次測試會首先創建一個100萬個隨機排列整數的數組,然后將數組中的數字依次插入三種數據結構中,最后從三種數據結構中刪除所有數據,每個操作分別計算耗費時間(這里計算操作使用了老趙的CodeTimer類實現性能計數)。

  (1)准備工作:初始化一個100萬個隨機數的數組

            int length = 1000000;
            int[] arrNumber = new int[length];
            // 首先生成有序數組進行初始化
            for (int i = 0; i < length; i++)
            {
                arrNumber[i] = i;
            }
            Random rand = new Random();
            // 隨機將數組中的數字打亂順序
            for (int i = 0; i < length; i++)
            {
                int randIndex = rand.Next(i,length);
                // 交換兩個數字
                int temp = arrNumber[i];
                arrNumber[i] = arrNumber[randIndex];
                arrNumber[randIndex] = temp;
            }

  (2)測試SortedDictionary

    // Test1:SortedDictionary類型測試
    SortedDictionary<int, int> sd = new SortedDictionary<int, int>();
    Console.WriteLine("SortedDictionary插入測試開始:");
    CodeTimer.Time("SortedDictionary_Insert_Test", 1, () =>
    {
        for (int i = 0; i < length; i++)
        {
            sd.Add(arrNumber[i], arrNumber[i]);
        }
    });
    Console.WriteLine("SortedDictionary插入測試結束;");
    Console.WriteLine("-----------------------------");
    Console.WriteLine("SortedDictionary刪除測試開始:");
    CodeTimer.Time("SortedDictionary_Delete_Test", 1, () =>
    {
        for (int i = 0; i < length; i++)
        {
            sd.Remove(arrNumber[i]);
        }
    });
    Console.WriteLine("SortedDictionary刪除測試結束;");
    Console.WriteLine("-----------------------------");

  (3)測試Hashtable

    // Test2:Hashtable類型測試
    Hashtable ht = new Hashtable();
    Console.WriteLine("Hashtable插入測試開始:");
    CodeTimer.Time("Hashtable_Insert_Test", 1, () =>
    {
        for (int i = 0; i < length; i++)
        {
            ht.Add(arrNumber[i], arrNumber[i]);
        }
    });
    Console.WriteLine("Hashtable插入測試結束;");
    Console.WriteLine("-----------------------------");
    Console.WriteLine("Hashtable刪除測試開始:");
    CodeTimer.Time("Hashtable_Delete_Test", 1, () =>
    {
        for (int i = 0; i < length; i++)
        {
            ht.Remove(arrNumber[i]);
        }
    });
    Console.WriteLine("Hashtable刪除測試結束;");
    Console.WriteLine("-----------------------------");

  (4)測試Dictionary

    // Test3:Dictionary類型測試
    Dictionary<int, int> dict = new Dictionary<int, int>();
    Console.WriteLine("Dictionary插入測試開始:");
    CodeTimer.Time("Dictionary_Insert_Test", 1, () =>
    {
        for (int i = 0; i < length; i++)
        {
            dict.Add(arrNumber[i], arrNumber[i]);
        }
    });
    Console.WriteLine("Dictionary插入測試結束;");
    Console.WriteLine("-----------------------------");
    Console.WriteLine("Dictionary刪除測試開始:");
    CodeTimer.Time("Dictionary_Delete_Test", 1, () =>
    {
        for (int i = 0; i < length; i++)
        {
            dict.Remove(arrNumber[i]);
        }
    });
    Console.WriteLine("Dictionary刪除測試結束;");
    Console.WriteLine("-----------------------------");

4.2 測試對比結果

  (1)SortedDictionary測試結果:

  SortedDictionary內部是紅黑樹結構,在插入和刪除操作時需要經過大量的旋轉操作來維持平衡,因此耗時是三種類型中最多的。此外,在插入過程中,引起了GC大量的垃圾回收操作。

  (2)Hashtable測試結果:

  Hashtable插入操作的耗時和SortedDictionary相近,但刪除操作卻比SortedDictionary快了好幾倍。

  (3)Dictionary測試結果:

  Dictionary在插入和刪除操作上是三種類型中最快的,且對GC的友好程度上也較前兩種類型好很多。

參考資料

(1)陳廣,《數據結構(C#語言描述)》

(2)程傑,《大話數據結構》

(3)段恩澤,《數據結構(C#語言版)》

(4)馬語者,《C#中Hashtable的用法

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM