集合是.NET FCL(Framework Class Library)的重要組成部分,我們平常擼C#程式碼時免不了和集合打交道,FCL提供了豐富易用的集合型別,給我們擼碼提供了極大的便利。正是因為這種與生俱來的便利性,使得我們對集合既熟悉又陌生。很多同學可能一直還是停留在使用的層面上,那麼今天我們一起來深入學習一下C#語言中的各種集合。
首先我們看一下 FCL 給我們提供的集合介面:
FCL提供了泛型和非泛型兩大類集合型別。因為非泛型集合裝箱和拆箱帶來的效能開銷問題,和泛型集合相比,已經變得越來越雞肋。所以我們也側重於泛型集合的分析,但是兩者差別不大。
IEnumerable和IEnumerator
IEnumerable介面是所有集合型別的祖宗介面,其作用相當於Object型別之於其它型別。如果某個型別實現了IEnumerable介面,就意味著它可以被迭代訪問,也就可以稱之為集合型別(可列舉)。IEnumerable介面定義非常簡單,只有一個GetEnumerator()方法用於獲取IEnumerator型別的迭代器。
我們可以將迭代器想象成資料庫的遊標,即序列(集合)中的某個位置,迭代器只能在序列(集合)中向前移動。每呼叫一次MoveNext(),如果序列(集合)中還有下一個元素,則迭代器移動到下一個元素;Current用於獲取序列(集合)中的當前元素;因為迭代器呼叫一次程式碼只需要獲取一個元素,這意味著我們需要確定訪問到了序列(集合)中的哪個位置。Reset()用於重置這種狀態,但是基本上不會使用Reset()重置狀態。
同一個序列(集合)可能同時存在多個迭代器操作,相當於同時對一個集合進行多個遍歷。這種情況下可能會出現迭代彼此交錯。那麼如何解決呢?
集合類不直接支援 IEnumerator 和 IEnumerator 介面。而是直接支援 **IEnumerable**介面,其唯一方法是 GetEnumerator,此方法用於返回支援 IEnumerator 的物件。每次呼叫GetEnumerator()方法時都需要建立一個新的物件,同時迭代器必須儲存自身的狀態,記錄此時已經迭代到哪一個元素。這樣列舉器就像是序列中的遊標。可以有多個遊標,移動其中任何一個都可以列舉集合,與其他列舉器互不影響。
foreach是怎麼實現的?
for依賴對 Length 屬性和索引運算子 ([]) 的支援。藉助 Length 屬性,C# 編譯器可以使用 for 語句迭代陣列中的每個元素。for適用於長度固定且始終支援索引運算子的陣列,但並不是所有型別集合的元素數量都是已知的。此外,許多集合類(包括 Stack、Queue 和 Dictionary<TKey ,TValue>)都不支援按索引檢索元素。因此,需要使用一種更為通用的方法來迭代元素集合。假設可以確定第一個、第二個和最後一個元素,那麼就沒有必要知道元素數量,也沒有必要支援按索引檢索元素。foreach在這種背景下應運而生。實際上,foreach內部使用迭代器的MoveNext和Current完成元素的遍歷。
List<int> list = new List<int>();
List<int>.Enumerator enumerator = list.GetEnumerator();
try
{
int number;
while (enumerator.MoveNext())
{
number = enumerator.Current;
Console.WriteLine(number);
}
}
finally
{
enumerator.Dispose();
}
複製程式碼
實現自定義集合
我們可以自己實現IEnumerable介面和IEnumerator介面實現自定義集合。
實現自定義可列舉型別:
public class MySet : IEnumerable
{
internal object[] values;
public MySet(object[] values)
{
this.values = values;
}
public IEnumerator GetEnumerator()
{
return new MySetIterator(this);
}
}
複製程式碼
手寫實現自定義迭代器:
public class MySetIterator : IEnumerator
{
MySet set;
/// <summary>
/// 儲存迭代到的位置
/// </summary>
int position;
internal MySetIterator(MySet set)
{
this.set = set;
position = -1;
}
public object Current
{
get
{
if(position==-1||position==set.values.Length)
{
throw new InvalidOperationException();
}
int index = position;
return set.values[index];
}
}
public bool MoveNext()
{
if(position!=set.values.Length)
{
position++;
}
return position < set.values.Length;
}
public void Reset()
{
position = -1;
}
}
複製程式碼
測試程式:
object[] values = { "a", "b", "c", "d", "e" };
MySet mySet = new MySet(values);
foreach (var item in mySet)
{
Console.WriteLine(item);
}
複製程式碼
這個例子也證明了foreach內部使用迭代器的MoveNext和Current完成遍歷。
上面的例子中手寫實現迭代器是十分麻煩的,在c#1.0中這是唯一的方式。在c#2.0中,我們可以使用yield語法糖簡化迭代器。
public IEnumerator GetEnumerator()
{
for (int i = 0; i < values.Length; i++)
{
yield return values[i];
}
}
複製程式碼
IEnumerable和IEnumerator雖然實現簡單,只有簡單的幾個成員,但是卻支撐起了C#語言中集合這座高樓大廈。
ICollection和ICollection
從第一張圖中,我們可以得知**ICollection繼承於IEnumerable介面,並且擴充套件了IEnumerable**介面。
主要擴充套件的功能有:
- 新增了屬性Count,用於記錄集合元素個數
- 支援新增元素和移除元素
- 支援是否包含某元素
- 支援清空集合等等
對於任何實現了**ICollection**介面的集合,我們都可以通過第1條Count屬性獲取當前集合的元素數,所以這些集合也被稱為計數集合。
IList和IList
IList介面直接繼承於ICollection介面和IEnumerable介面,並且擴充套件了通過索引操作集合的功能。
主要擴充套件的功能有:
- 通過索引獲取集合中某個元素
- 通過元素獲取元素在集合中的索引值
- 通過索引插入元素到集合指定位置
- 移除集合指定索引處的元素
##IDictionary<TKey, TValue>和IDictionary
IDictionary介面直接繼承於ICollection介面和IEnumerable介面,儲存的元素是鍵值對,擴充套件了通過鍵操作鍵值對集合的功能。
主要擴充套件的功能有:
- 通過鍵KEY獲取值VALUE
- 插入新的鍵值對{KEY:VALUE}
- 是否包含KEY
- 通過KEY移除鍵值對元素
主要的集合的介面介紹完了,下面我們來看一下具體的集合型別。
關聯性泛型集合類
1.Dictionary<TKey,TValue>
**Dictionary<TKey,TValue>**的查詢資料所花費的時間是所有集合類裡面最快的,因為其內部使用了雜湊函式加雙陣列來實現,所以其查詢資料操作的時間複雜度可以認為是O(1)。Dictionary<TKey,TValue>的實現是一種典型的犧牲空間換取時間(雙陣列)的做法。
Dictionary<TKey,TValue>新增新元素的實現:
Dictionary<TKey,TValue>內部有兩個陣列,一個陣列名為buckets,用於存放由多個同義片語成的靜態連結串列頭指標(連結串列的第一個元素在陣列中的索引號,當它的值為-1時表示此雜湊地址不存在元素);另一個陣列為entries,它用於存放雜湊表中的實際資料,同時這些資料通過next指標構成多個單連結串列。entries陣列中所存放的是Entry結構體,Entry結構體由4個部分組成,如下所示:
**Dictionary<TKey,TValue>**計算key的雜湊值使用的是取餘法,這種方式可能會產生衝突,所以進行衝突解決。**Dictionary<TKey,TValue>**解決衝突的方式是連結法。
我們可以根據原始碼來模擬推導一下這個過程:
當新增第一個元素時,此時會分配雜湊表buckets陣列和entries陣列的空間和初始大小,預設為3,關於初始陣列的大小有大學問。對key=1進行雜湊求值,假設第一個元素的雜湊值=9,然後targetBucket = 9%buckets.Length(3)的值為0,所以第一個元素應該放在entries陣列的第一位。最後對雜湊表buckets陣列賦值,陣列索引為0,值為0。此時內部結構如圖所示:
然後插入第二個元素,對key=2進行雜湊求值,假設第二個元素的雜湊值=3,然後targetBucket = 3%buckets.Length(3)的值為0,所以第二個元素應該放在entries陣列的第一位。但是entries陣列的第一位已經存在元素了,這就發生了衝突。Dictionary<TKey,TValue>解決衝突的方式是連結法,把發生衝突的元素連結之前元素的後面,通過next屬性來指定衝突關係,最後更新雜湊表buckets陣列。此時內部結構如圖所示:
我們可以通過**Dictionary<TKey,TValue>**查詢元素的實現來證明我們上面的分析是正確的。
Dictionary<TKey,TValue>查詢元素的實現:
**Dictionary<TKey,TValue>**之所以能實現快速查詢元素,其內部使用雜湊表來儲存元素對應的位置,我們可以通過雜湊值快速地從雜湊表中定位元素所在的位置索引,從而快速獲取到key對應的Value值。物極必反,**Dictionary<TKey,TValue>**的缺點也很明顯,就是裡面的資料是無序排列的,所以按照一定順序遍歷查詢資料效率是非常低的。
2.SortedDictionary<TKey,TValue>
**SortedDictionary<TKey,TValue>和Dictionary<TKey,TValue>**類似,至於區別我們從名稱上就可以看出來,**Dictionary<TKey,TValue>**是無序的,**SortedDictionary<TKey,TValue>**則是有序的。key要保證唯一,而且還要有序排列,這讓我們很自然的就想到了搜尋二叉樹。**SortedDictionary<TKey,TValue>使用一種平衡搜尋二叉樹——紅黑樹,作為儲存結構。因為基於二分查詢,所以新增、查詢、刪除元素的時間複雜度是O(log n)。相對於下面提到的SortedList<TKey,TValue>**來說,SortedDictionary<TKey,TValue>在新增和刪除元素時更快一些。如果想要快速查詢的同時又能很好的支援排序的話,並且新增和刪除元素也比較頻繁,可以使用SortedDictionary<TKey,TValue>。
SortedDictionary<TKey,TValue>新增新元素的實現:
3.SortedList<TKey,TValue>
在既需要快速查詢又需要順序排列的場景下,**Dictionary<TKey,TValue>就無能為力了,因為Dictionary<TKey,TValue>使用了雜湊函式,並不支援線性排序。我們可以使用SortedList<TKey,TValue>**集合類來應對這種場景。
**SortedList<TKey,TValue>集合內部是使用陣列實現的,新增和刪除元素的時間複雜度是O(n),查詢元素利用了二分查詢,所以查詢元素的時間複雜度是O(log n)。所以SortedList<TKey,TValue>**雖然支援了有序排列,但是卻是以犧牲查詢效率為代價的。
**SortedList<TKey,TValue>和SortedDictionary<TKey,TValue>同時支援快速查詢和排序,SortedList<TKey,TValue> 優勢在於使用的記憶體比 SortedDictionary<TKey,TValue> 少;但是SortedDictionary<TKey,TValue>可對未排序的資料執行更快的插入和移除操作:它的時間複雜度為 O(log n),而 SortedList<TKey,TValue> 為 O(n)。所以SortedList<TKey,TValue>**適用於既需要快速查詢又需要順序排列但是新增和刪除元素較少的場景。
內部實現結構:
根據Key獲取Value的實現:
IndexOfKey實現:
新增新元素:
新增操作:
#非關聯性泛型集合類
1.List
泛型的List 類提供了不限制長度的集合型別,List內部實現使用資料結構是陣列。我們都知道陣列是長度固定的,那麼List不限制長度必定需要維護這個陣列。實際上List維護了一定長度的陣列(預設為4),當插入元素的個數超過4或初始長度時,會去重新建立一個新的陣列,這個新陣列的長度是初始長度的2倍,然後將原來的陣列賦值到新的陣列中。
我們可以通過ILSpy看一下List原始碼證明我們上面所說的:
List內部重要變數:
新增元素操作:
新增元素確認陣列容量:
真正的陣列擴容操作:
陣列擴容的場景涉及到物件的建立和賦值,是比較消耗效能的。所以如果能指定一個合適的初始長度,能避免頻繁的物件建立和賦值。再者,因為內部的資料結構是陣列,插入和刪除操作需要移動元素位置,所以不適合頻繁的進行插入和刪除操作;但是可以通過陣列下標查詢元素。所以List適合讀多寫少的場景。
2.LinkedList
上面我們提到List適合讀多寫少的場景,那麼必定有一個List適合寫多讀少的場景,就是這貨了——LinkedList。至於為什麼適合寫多讀少,熟悉資料結構的同學應該已經猜到了。因為**LinkedList**的內部實現使用的是連結串列結構,而且還是雙向連結串列。直接看原始碼:
因為內部實現結構是連結串列,所以可以在某一個節點前或節點後插入新的元素。
連結串列節點定義:
我們以在某個節點前插入新元素為例:
具體的插入操作,注意操作步驟不能顛倒:
3.HashSet
HashSet是一個無序的能夠保持唯一性的集合。我們可以將HashSet看作是簡化的Dictionary<TKey,TValue>,只不過Dictionary<TKey,TValue>儲存的鍵值對物件,而HashSet儲存的是普通物件。其內部實現也和Dictionary<TKey,TValue>基本一致,也是雜湊函式加雙陣列實現的,區別是儲存的Slot結構體不再有key。
內部實現資料結構:
m_slots中所存放的是Slot結構體,Slot結構體由3個部分組成,如下所示:
新增新元素的具體實現:
和**Dictionary<TKey,TValue>**新增新元素的實現基本一致。
4.SortedSet
SortedSet和HashSet,就像**SortedDictionary<TKey,TValue>和Dictionary<TKey,TValue>**一樣。**SortedSet支援元素按順序排列,內部實現也是紅黑樹,並且SortedSet對於紅黑樹的操作方法和SortedDictionary<TKey,TValue>**完全相同。所以不再做過多的分析。
5.Stack
棧是一種後進先出的結構,C#的棧是藉助陣列實現的,考慮到棧後進先出的特性,使用陣列來實現貌似是水到渠成的事。
入棧操作:
彈棧操作:
6.Queue
佇列是一種先進先出的結構,C#的佇列也是藉助陣列實現的,有了前面的經驗,藉助陣列實現必然會有陣列擴容。C#的佇列實現其實是迴圈佇列的方式,可以簡單的理解為將佇列的頭尾相接。至於為什麼要這麼做?為了節省儲存空間和減少元素的移動。因為元素出佇列時後面的元素跟著前移是非常消耗效能的,但是不跟著向前移動的話,前面就會一直存在空閒的空間浪費記憶體。所以使用迴圈佇列來解決這種問題。
入隊操作:
出隊操作:
執行緒安全的集合類
需要我們注意的是,上面我們所介紹的集合並不是執行緒安全的,在多執行緒環境下,可能會出現執行緒安全問題。在多執行緒讀的情況下,我們使用普通集合即可。在多執行緒新增/更新/刪除時,我們可以採用手動鎖定的方式確保執行緒安全,但是應該注意加鎖的範圍和粒度,加鎖不當可能會導致程式效能低下甚至產生死鎖。
更好的選擇的是使用的C#提供的執行緒安全集合(名稱空間:System.Collections.Concurrent)。執行緒安全集合使用幾種演算法來最小化執行緒阻塞。
- ConcurrentQueue: 執行緒安全版本的Queue
- ConcurrentStack:執行緒安全版本的Stack
- ConcurrentBag:執行緒安全的物件集合
- ConcurrentDictionary:執行緒安全的Dictionary
總結
寫著寫著突然發現跑到資料結構上來了。程式=資料結構+演算法。上面提到的集合型別,我們需要在不同的場景進行合適的選擇,其實本質上就是選擇合適的資料結構。
參考:
www.c-sharpcorner.com/article/con…