.NET面試題系列[10] - IEnumerable的派生類


.NET面試題系列目錄

IEnumerable分為兩個版本:泛型的和非泛型的。IEnumerable只有一個方法GetEnumerator。如果你只需要數據而不打算修改它,不打算為集合插入或刪除任何成員(例如從遠端拿回數據顯示),則你不需要任何比IEnumerable更復雜的接口。

ICollection繼承IEnumerable。可以使用Count方法統計集合的大小。(注意非泛型版本的ICollection並沒有Add,Remove等方法)但在實際情況中,我們通常使用ICollection的繼承類而不是ICollection本身(不能初始化一個接口)。ICollection的繼承類有Stack,Queue,IDictionary和IList。

IList本身實現了索引器。可以使用[x]來尋找對應元素,還有Add,Remove等方法,可以在任意位置插入和刪除成員。

Stack和Queue的使用場景非常典型,就是模擬棧和隊列。這兩個數據結構繼承自ICollection(如果是繼承自更下面的例如IList的話,就可以隨心所欲的插入和刪除成員了),同時實現了特殊的插入刪除方法,不需要索引器。對於棧我們只能從最頂拿或者放入數據。對於隊列,我們只能從隊尾加入數據,從隊頭取出數據。不過通常,我們都使用棧和隊列的泛型版本。

Hashtable

IDictionary繼承ICollection,同時,其增加了Add,Remove等方法。可以修改集合的內容。IDictionary的其中一個繼承類Hashtable是一個非泛型的集合。其儲存着一系列的key Value鍵值對。這些數據都是Object類型的。

            Hashtable h = new Hashtable {{123, "abc"}, {456, "xyz"}};
            Console.WriteLine(h[123]); //打印abc
            Console.ReadKey();

哈希表查找,插入,刪除速度均為O(1)

哈希(散列)表,哈希函數簡介

哈希(散列)表是僅支持插入,刪除和查找功能的集合結構。由於實現方式的特殊性,每個哈希表上的元素僅有一個可能出現的位置,就是其哈希函數的值加上沖突之后的調整偏移量,無法移動哈希表上的元素。

哈希表是一種鍵值對類型的數據結構。它的特點是查找速度飛快,可以達到O(1)的水平。假設你查詢的鍵為x,你可以通過計算一個函數f(x),獲得其值,然后到表中的對應位置獲得查詢結果。和順序儲存相比,哈希表查找速度快,而順序儲存理論上最快的速度是O(log(n))或O(n)。當數據不連續時,哈希表還能節省空間(相比大數組)。

假設我們有一個全域U={0,1,…,m-1},假設某應用要用到一個動態集合,其中每個元素都有一個取自全域U的關鍵字,且沒有兩個元素具有相同的關鍵字,那么我們可以建立一個直接尋址表,其中每個位置對應全域的一個關鍵字。所以這個表將會占有M個位置。一個典型的例子就是員工ID和姓名。我們知道員工的ID一般都是從最小的數字開始一路往上,且不可能有兩個員工有相同的ID。如果有10000名員工,我們將員工的姓名儲存在一個string[10000]中,就可以根據ID迅速的,以O(1)的速度查找到員工姓名了。

直接尋址表有一個明顯的問題:如果實際要存儲的關鍵字比可能的關鍵字總數小甚至小很多時,大部分表上的空間都浪費了。假設有一個奇葩公司,不停有人辭職,結果開了若干年,ID雖然編到了10000,實際只有100人,則表上9900個位子都是空的,對空間的利用率只有1%。此時,我們就可以考慮用哈希表,在不犧牲插入,刪除和查找的速度的同時提高空間利用率。

在直接尋址方式下,具有關鍵字k的元素被分配到表上的槽k中。在哈希表上具有關鍵字k的元素則被分配到表上的槽f(k)中,其中f是哈希函數。注意,函數的值和輸入變量不一定是一一對應的,例如模函數,19和99模10都是9如果兩個不同的x,卻有相同的f(x)值,則意味着當插入時會發生碰撞,這稱為哈希沖突。好的哈希函數必須有較少的哈希沖突發生。當然,如果你選擇的函數是普通意義上的函數(即一一映射),比如f(x)=x+1,那么永遠都不會有沖突發生(因為x是唯一的,沒有兩個關鍵字是相同的),但這樣一來,哈希表就不能節省空間了。

演示哈希沖突的一個簡單例子。我們有10個字符串,哈希函數是將每個字符串字符的ASCII碼加總,然后對100取模。我們看到結果只有8個字符串,這是因為發生了沖突,后面的字符串把前面的覆蓋掉了。

        static void Main()
        {
            string[] names = new string[99];
            string name;
            string[] someNames = new string[]{"David", "Jennifer", "Donnie", "Mayo", "Raymond", "Bernica", "Mike", "Clayton", "Beata", "Michael"};

            int hashVal;
            for (int i = 0; i < 10; i++)
            {
                name = someNames[i];
                hashVal = SimpleHash(name, names);
                names[hashVal] = name;
            }
            ShowDistrib(names);
            Console.ReadKey();
        }

        static int SimpleHash(string s, string[] arr)
        {
            int tot = 0;
            char[] cname;
            cname = s.ToCharArray();
            for (int i = 0; i <= cname.GetUpperBound(0); i++)
                tot += (int)cname[i];
            return tot % arr.GetUpperBound(0);
        }

        static void ShowDistrib(string[] arr)
        {
            for (int i = 0; i <= arr.GetUpperBound(0); i++)
                if (arr[i] != null)
                    Console.WriteLine(i + " " + arr[i]);
        }

C#中實現了哈希表數據結構的集合類有Hashtable以及它的泛型版本Dictionary<T,K>。Dictionary和Hashtable之間並非只是簡單的泛型和非泛型的區別,兩者使用了完全不同的哈希沖突解決辦法。

在建立哈希表時,確定哈希函數是非常重要的工作。它直接關系到哈希表的插入和查找速度。哈希函數的目標是盡量減少沖突,令元素盡量均勻的分布在哈希表中。但實際應用中沖突是無法避免的,所以在沖突發生時,必須有相應的解決方案。而發生沖突的可能性又跟以下兩個因素有關:

(1)裝填因子α:所謂裝填因子是指表中已存入的記錄數n與哈希地址空間大小m的比值,即 α=n / m ,α越小,沖突發生的可能性就越小;α越大(最大可取1),沖突發生的可能性就越大。這很容易理解,因為α越小,哈希表中空閑單元的比例就越大,所以待插入記錄同已插入的記錄發生沖突的可能性就越小;反之,α越大,哈希表中空閑單元的比例就越小,所以待插入記錄同已插入記錄沖突的可能性就越大;另一方面,α越小,空間的利用率就越低;反之,空間的利用率就越高。為了既兼顧減少沖突的發生,又兼顧提高存儲空間的利用率,通常把α控制在0.6~0.9的范圍之內,C#的HashTable類把α的最大值定為0.72,當HashTable中的被占用空間達到72%的時候就將該HashTable自動擴容。例如有一個HashTable的空間大小是100,當它需要添加第73個值的時候將會擴容此HashTable。這個自動擴容的大小是多少呢?答案是當前空間大小的兩倍最接近的素數,例如當前HashTable所占空間為素數71,如果擴容,則擴容大小為素數131。

(2)與所采用的哈希函數有關。若哈希函數選擇得當,就可使哈希地址盡可能均勻地分布在哈希地址空間上,從而減少沖突的發生;否則,就可能使哈希地址集中於某些區域,從而加大沖突發生的可能性。

沖突解決技術可分為兩大類:開散列法(又稱為鏈地址法)和閉散列法(又稱為開放地址法)。哈希表是用數組實現的一片連續的地址空間,兩種沖突解決技術的區別在於發生沖突的元素是存儲在這片數組的空間之外還是空間之內:

(1)開散列法發生沖突的元素存儲於數組空間之外。通常會置一鏈表,然后將元素加到鏈表中,掛接在原表相應的位置。如果發生沖突,則將鏈表長度加一,然后將元素放在對應鏈表的尾端。可以把“開”字理解為需要另外“開辟”空間存儲發生沖突的元素。此時如果我們在檢索時,計算出關鍵字的哈希函數值,到相應的表中檢查,如果發現表上的關鍵字和要檢索的關鍵字不同,我們可以順着后面的鏈表一路檢查下去直到匹配為止。Dictionary<K,T>使用的是這種方式。

(圖片來自算法導論)

(2)閉散列法發生沖突的元素存儲於數組空間之內。可以把“閉”字理解為所有元素,不管是否有沖突,都“關閉”於數組之中。閉散列法又稱開放尋址法,意指數組空間對所有元素,不管是否沖突都是開放的。此時如果我們在檢索時,計算出關鍵字的哈希函數值,到相應的表中檢查,如果發現表上的關鍵字和要檢索的關鍵字不同,我們可以根據調整策略找到下一個目標位置。當然如果欲插入的元素大於哈希表的大小,則哈希表需要擴容。Hashtable使用的是這種方式。

開放尋址法(閉散列法)

開放尋址法中最自然的方法當然就是看一下相鄰的下N個地址是否被占據(N為已經發生沖突的次數),如果沒有就存在那里,如果有就繼續探測,直到找到一個空地址為止。這稱為線性探測。

線性探測填裝一個哈希表的過程:

關鍵字為{89,18,49,58,69}插入到一個哈希表中的情況。假定取關鍵字除以10的余數為哈希函數。

散列地址

空表

插入89

插入18

插入49

插入58

插入69

0

     

49

49

49

1

       

58

58

2

         

69

3

           

4

           

5

           

6

           

7

           

8

   

18

18

18

18

9

 

89

89

89

89

89

 

第一次沖突發生在填裝49的時候。地址為9的單元已經填裝了89這個關鍵字,所以往下查找一個單位,發現為空,所以將49填裝在地址為0的空單元。第二次沖突則發生在58上,往下查找兩個單位,將58填裝在地址為1的空單元。69同理。

表的大小選取至關重要,此處選取10作為大小,發生沖突的幾率就比選擇質數11作為大小的可能性大。越是質數,mod取余就越可能均勻分布在表的各處。

此時如果我們在檢索時,計算出關鍵字的哈希函數值,到相應的表中檢查,如果發現表上的關鍵字和要檢索的關鍵字不同,我們會根據線性探查的特點,查找其后第1,2,3(等等)個數據,直到找到我們要檢索的關鍵字為止。

除了線性探測之外,還有平方探測,它的尋址序列為1,-1,4,-4,9,-9,等等,負數代表向前尋址,不同於線性探測的1,2,3這種嘗試數列。

雙重哈希法(閉散列法)

HashTable采用開放尋址法中的雙重哈希法。這意味着,為哈希表插入元素之后,元素一定會位於表上。所以當插入的元素較多時(例如長度為100的表插入72個元素),插入第73個元素必定會導致擴容。而字典使用的是開散列法,和哈希表不同。

雙重哈希法意味着如果出現碰撞,則將本次哈希函數的輸出f(x)作為輸入再計算一次哈希值y = f(f(x)),如果還有沖突,則采用2y,4y,8y這種嘗試數列。顯然,f(x)不能為0,否則將導致無限循環,這意味着對於任意的可能輸入,哈希函數不能輸出0。

哈希表源碼:http://referencesource.microsoft.com/#mscorlib/system/collections/hashtable.cs

源碼分析:http://blog.csdn.net/exiaojiu/article/details/51206024

自己實現一個哈希表:http://www.cnblogs.com/abatei/archive/2009/06/23/1509790.html#3382887

有興趣的朋友可以自行研究。

開散列法+使用模函數

Dictionary<K,T>使用這種方式。它的哈希函數是模函數,其中模的底為字典的長度,一般為質數,如果你指定了一個合數作為初始容量則會尋找離他最近的質數作為容量。

若選定的散列表長度為質數m,則可將散列表定義為一個由m個頭指針組成的指針數 組T[0..m-1]。凡是散列地址為i的結點,均插入到以T[i]為頭指針的單鏈表中。T中各分量的初值均為空指針。

鏈表的長度不算在字典的空間內。上圖如果只有兩個位置被占據(例如0和3),則即使它們后面的鏈表有一萬個元素,字典也不會擴容。只有頭指針被占據的數目過多才會擴容。如果字典的頭指針被占用空間達到72%的時候就自動擴容。其擴容后的新容量為最接近原來容量2倍的質數。

C#字典的源碼:http://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs

源碼分析:http://blog.csdn.net/exiaojiu/article/details/51252515

有興趣的朋友可以自行研究。

模函數底的取值

如果哈希函數是形如n (mod m)的模函數,則m的取值有以下幾個注意事項。

M不能為2的冪。對於任何二進制數字,他們對2的冪取模造成了信息的丟棄。算法導論是這樣解釋的:對一個數除以2^p取余數相當於只取這個數的最低的p位,高於p位的信息就被丟棄了。

這個原理很容易理解:假設m=8,則p=3。15的二進制表示1111,8的二進制表示1000,取模之后余數為7,二進制表示是111,即k的最低3位。23的二進制表示是10111,對8的余數仍然為7,這樣相當於不管k的除去最后3位取什么值,結果都是不變的(只取這個數的最低的p位)。

M最好取一個素數。理論上,可以在輸入並非均勻分布時降低碰撞的發生次數。(如果輸入是均勻分布的則M取什么數都可以)理由我沒有看懂,敬請大牛指導:http://thuhak.blog.51cto.com/2891595/1352903

ArrayList

數組是C#中最基礎的一種數據類型,它代表一塊連續的內存,一旦初始化之后,容量便已經確定。若想要動態擴充容量,那么動態數組可以滿足這點需求。ArrayList是C#最不常用(我想不出任何用它的理由)也是最基礎的一個動態數組。

通常我們在說ArrayList時,總是和List<T>和普通的數組(無法擴容)進行比較。ArrayList派生自IList,所以其是一個非泛型的集合。IList繼承ICollection,同時,其增加了Add,Remove等方法。可以修改集合的內容。它的缺點在於里面的成員都是Object類型的,故會影響性能,還造成類型不安全。

ArrayList的容量不定。如果元素超過容量,則通過倍增的方式擴容。

ArrayList內部是通過數組實現的。查找速度為O(N),插入刪除速度為O(N)。ArrayList操作可能會導致裝箱和拆箱,幾乎永遠不會被使用。

IEnumerable的派生類:小結

 

訪問特定位置的成員方式

繼承自

特點

IEnumerable

通過ElementAt

有泛型版本
提供遍歷(通過GetEnumerator)

不能實例化(所有接口都是如此)

ICollection

通過ElementAt

IEnumerable

有泛型版本
提供Count方法
提供轉換為IQueryable方法

ArrayList

索引器

IList

dynamic sized,大小倍增
弱類型(所有的成員被視作object)

對應的泛型版本為List<T>

不使用

HashTable

鍵值對

IDictionary

dynamic sized,擴容通過尋找倍增之后最近的質數確定容量
弱類型(所有的成員被視作object),

對應的泛型版本為Dictionary<T,K>

Stack

ICollection

棧的實現,不使用

Queue

ICollection

隊列的實現,不使用

 


免責聲明!

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



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