Resizable array list (List<T>)
Tree-based dictionary (SortedDictionary<K,T>)
Hash table based set (HashSet<T>)
Tree based set (SortedSet<T>)
在計算機程序設計中,數組(Array)是最簡單的並且應用最普遍的數據結構之一。在任何編程語言中,數組都有一些共性:
對於數組的常規操做包括:
在 C# 中,能夠經過以下的方式聲明數組變量。
1 int allocationSize = 10; 2 bool[] booleanArray = new bool[allocationSize]; 3 FileInfo[] fileInfoArray = new FileInfo[allocationSize];
上面的代碼將在 CLR 託管堆中分配一塊連續的內存空間,用以容納數量爲 allocationSize ,類型爲 arrayType 的數組元素。若是 arrayType 爲值類型,則將會有 allocationSize 個未封箱(unboxed)的 arrayType 值被建立。若是 arrayType 爲引用類型,則將會有 allocationSize 個 arrayType 類型的引用被建立。
若是咱們爲 FileInfo[] 數組中的一些位置賦上值,則引用關係爲下圖所示。
.NET 中的數組都支持對元素的直接讀寫操做。語法以下:
1 // 讀數組元素 2 bool b = booleanArray[7]; 3 4 // 寫數組元素 5 booleanArray[0] = false;
訪問一個數組元素的時間複雜度爲 O(1),所以對數組的訪問時間是恆定的。也就是說,與數組中包含的元素數量沒有直接關係,訪問一個元素的時間是相同的。
因爲數組是固定長度的,而且數組中只能存儲同一種類型或類型的衍生類型。這在使用中會受到一些限制。.NET 提供了一種數據結構 ArrayList 來解決這些問題。
1 ArrayList countDown = new ArrayList(); 2 countDown.Add(3); 3 countDown.Add(2); 4 countDown.Add(1); 5 countDown.Add("blast off!"); 6 countDown.Add(new ArrayList());
ArrayList 是長度可變的數組,而且它能夠存儲不一樣類型的元素。
但這些靈活性是以犧牲性能爲代價的。在上面 Array 的描述中,咱們知道 Array 在存儲值類型時是採用未裝箱(unboxed)的方式。因爲 ArrayList 的 Add 方法接受 object 類型的參數,致使若是添加值類型的值會發生裝箱(boxing)操做。這在頻繁讀寫 ArrayList 時會產生額外的開銷,致使性能降低。
當 .NET 中引入泛型功能後,上面 ArrayList 所帶來的性能代價可使用泛型來消除。.NET 提供了新的數組類型 List<T>。
泛型容許開發人員在建立數據結構時推遲數據類型的選擇,直到使用時才肯定選擇哪一種類型。泛型(Generics)的主要優勢包括:
List<T> 等同於同質的一維數組(Homogeneous self-redimensioning array)。它像 Array 同樣能夠快速的讀取元素,還能夠保持長度可變的靈活性。
1 // 建立 int 類型列表 2 List<int> myFavoriteIntegers = new List<int>(); 3 4 // 建立 string 類型列表 5 List<string> friendsNames = new List<string>();
List<T> 內部一樣使用 Array 來實現,但它隱藏了這些實現的複雜性。當建立 List<T> 時無需指定初始長度,當添加元素到 List<T> 中時,也無需關心數組大小的調整(resize)問題。
1 List<int> powersOf2 = new List<int>(); 2 3 powersOf2.Add(1); 4 powersOf2.Add(2); 5 6 powersOf2[1] = 10; 7 8 int sum = powersOf2[1] + powersOf2[2];
List<T> 的漸進運行時(Asymptotic Running Time)複雜度與 Array 是相同的。
在鏈表(Linked List)中,每個元素都指向下一個元素,以此來造成了一個鏈(chain)。
在建立一個鏈表時,咱們僅需持有頭節點 head 的引用,這樣經過逐個遍歷下一個節點 next 便可找到全部的節點。
鏈表與數組有着一樣的線性運行時間 O(n)。例如在上圖中,若是咱們要查找 Sam 節點,則必須從頭節點 Scott 開始查找,逐個遍歷下一個節點直到找到 Sam。
一樣,從鏈表中刪除一個節點的漸進時間也是線性的O(n)。由於在刪除以前咱們仍然須要從 head 開始遍歷以找到須要被刪除的節點。而刪除操做自己則變得簡單,即讓被刪除節點的左節點的 next 指針指向其右節點。下圖展現瞭如何刪除一個節點。
向鏈表中插入一個新的節點的漸進時間取決於鏈表是不是有序的。若是鏈表不須要保持順序,則插入操做就是常量時間O(1),能夠在鏈表的頭部或尾部添加新的節點。而若是須要保持鏈表的順序結構,則須要查找到新節點被插入的位置,這使得須要從鏈表的頭部 head 開始逐個遍歷,結果就是操做變成了O(n)。下圖展現了插入節點的示例。
鏈表與數組的不一樣之處在於,數組的中的內容在內存中時連續排列的,能夠經過下標來訪問,而鏈表中內容的順序則是由各對象的指針所決定,這就決定了其內容的排列不必定是連續的,因此不能經過下標來訪問。若是須要更快速的查找操做,使用數組多是更好的選擇。
使用鏈表的最主要的優點就是,向鏈表中插入或刪除節點無需調整結構的容量。而相反,對於數組來講容量始終是固定的,若是須要存放更多的數據,則須要調整數組的容量,這就會發生新建數組、數據拷貝等一系列複雜且影響效率的操做。即便是 List<T> 類,雖然其隱藏了容量調整的複雜性,但仍然難逃性能損耗的懲罰。
鏈表的另外一個優勢就是特別適合以排序的順序動態的添加新元素。若是要在數組的中間的某個位置添加新元素,不只要移動全部其他的元素,甚至還有可能須要從新調整容量。
因此總結來講,數組適合數據的數量是有上限的狀況,而鏈表適合元素數量不固定的狀況。
在 .NET 中已經內置了 LinkedList<T> 類,該類實現了雙向鏈表(doubly-linked list)功能,也就是節點同時持有其左右節點的引用。而對於刪除操做,若是使用 Remove(T),則運算複雜度爲 O(n),其中 n 爲鏈表的長度。而若是使用 Remove(LinkedListNode<T>), 則運算複雜度爲 O(1)。
當咱們須要使用先進先出順序(FIFO)的數據結構時,.NET 爲咱們提供了 Queue<T>。Queue<T> 類提供了 Enqueue 和 Dequeue 方法來實現對 Queue<T> 的存取。
Queue<T> 內部創建了一個存放 T 對象的環形數組,並經過 head 和 tail 變量來指向該數組的頭和尾。
默認狀況下,Queue<T> 的初始化容量是 32,也能夠經過構造函數指定容量。
Enqueue 方法會判斷 Queue<T> 中是否有足夠容量存放新元素。若是有,則直接添加元素,並使索引 tail 遞增。在這裏的 tail 使用求模操做以保證 tail 不會超過數組長度。若是容量不夠,則 Queue<T> 根據特定的增加因子擴充數組容量。
默認狀況下,增加因子(growth factor)的值爲 2.0,因此內部數組的長度會增長一倍。也能夠經過構造函數中指定增加因子。Queue<T> 的容量也能夠經過 TrimExcess 方法來減小。
Dequeue 方法根據 head 索引返回當前元素,以後將 head 索引指向 null,再遞增 head 的值。
當須要使用後進先出順序(LIFO)的數據結構時,.NET 爲咱們提供了 Stack<T>。Stack<T> 類提供了 Push 和 Pop 方法來實現對 Stack<T> 的存取。
Stack<T> 中存儲的元素能夠經過一個垂直的集合來形象的表示。當新的元素壓入棧中(Push)時,新元素被放到全部其餘元素的頂端。當須要彈出棧(Pop)時,元素則被從頂端移除。
Stack<T> 的默認容量是 10。和 Queue<T> 相似,Stack<T> 的初始容量也能夠在構造函數中指定。Stack<T> 的容量能夠根據實際的使用自動的擴展,而且能夠經過 TrimExcess 方法來減小容量。
若是 Stack<T> 中元素的數量 Count 小於其容量,則 Push 操做的複雜度爲 O(1)。若是容量須要被擴展,則 Push 操做的複雜度變爲 O(n)。Pop 操做的複雜度始終爲 O(1)。
如今假設咱們要使用員工的社保號做爲惟一標識進行存儲。社保號的格式爲 DDD-DD-DDDD(D 的範圍爲數字 0-9)。
若是使用 Array 存儲員工信息,要查詢社保號爲 111-22-3333 的員工,則將會嘗試遍歷數組的全部位置,即執行漸進時間爲 O(n) 的查詢操做。好一些的辦法是將社保號排序,以使查詢漸進時間下降到 O(log(n))。但理想狀況下,咱們更但願查詢漸進時間爲 O(1)。
一種方案是創建一個大數組,範圍從 000-00-0000 到 999-99-9999 。
這種方案的缺點是浪費空間。若是咱們僅須要存儲 1000 個員工的信息,那麼僅利用了 0.0001% 的空間。
第二種方案就是用哈希函數(Hash Function)壓縮序列。
咱們選擇使用社保號的後四位做爲索引,以減小區間的跨度。這樣範圍將從 0000 到 9999。
在數學上,將這種從 9 位數轉換爲 4 位數的方式稱爲哈希轉換(Hashing)。能夠將一個數組的索引空間(indexers space)壓縮至相應的哈希表(Hash Table)。
在上面的例子中,哈希函數的輸入爲 9 位數的社保號,輸出結果爲後 4 位。
H(x) = last four digits of x
上圖中也說明在哈希函數計算中常見的一種行爲:哈希衝突(Hash Collisions)。即有可能兩個社保號的後 4 位均爲 0000。
當要添加新元素到 Hashtable 中時,哈希衝突是致使操做被破壞的一個因素。若是沒有衝突發生,則元素被成功插入。若是發生了衝突,則須要判斷衝突的緣由。所以,哈希衝突提升了操做的代價,Hashtable 的設計目標就是要儘量減低衝突的發生。
處理哈希衝突的方式有兩種:避免和解決,即衝突避免機制(Collision Avoidance)和衝突解決機制(Collision Resolution)。
避免哈希衝突的一個方法就是選擇合適的哈希函數。哈希函數中的衝突發生的概率與數據的分佈有關。例如,若是社保號的後 4 位是隨即分佈的,則使用後 4 位數字比較合適。但若是後 4 位是以員工的出生年份來分配的,則顯然出生年份不是均勻分佈的,則選擇後 4 位會形成大量的衝突。咱們將這種選擇合適的哈希函數的方法稱爲衝突避免機制(Collision Avoidance)。
在處理衝突時,有不少策略能夠實施,這些策略稱爲衝突解決機制(Collision Resolution)。其中一種方法就是將要插入的元素放到另一個塊空間中,由於相同的哈希位置已經被佔用。
一般採用的衝突解決策略爲開放尋址法(Open Addressing),全部的元素仍然都存放在哈希表內的數組中。
開放尋址法的最簡單的一種實現就是線性探查(Linear Probing),步驟以下:
如今若是咱們要將五個員工的信息插入到哈希表中:
則插入後的哈希表可能以下:
元素的插入過程:
線性探查(Linear Probing)方式雖然簡單,但並非解決衝突的最好的策略,由於它會致使同類哈希的彙集(Primary Clustering)。這致使搜索哈希表時,衝突依然存在。例如上面例子中的哈希表,若是咱們要訪問 Edward 的信息,由於 Edward 的社保號 111-00-1235 哈希爲 1235,然而咱們在 1235 位置找到的是 Bob,因此再搜索 1236,找到的倒是 Danny,以此類推直到找到 Edward。
一種改進的方式爲二次探查(Quadratic Probing),即每次檢查位置空間的步長爲平方倍數。也就是說,若是位置 s 被佔用,則首先檢查 s + 12 處,而後檢查s - 12,s + 22,s - 22,s + 32 依此類推,而不是象線性探查那樣以 s + 1,s + 2 ... 方式增加。儘管如此,二次探查一樣也會致使同類哈希彙集問題(Secondary Clustering)。
.NET 中的 Hashtable 類的實現,要求添加元素時不只要提供元素(Item),還要爲該元素提供一個鍵(Key)。例如,Key 爲員工社保號,Item 爲員工信息對象。能夠經過 Key 做爲索引來查找 Item。
1 Hashtable employees = new Hashtable(); 2 3 // Add some values to the Hashtable, indexed by a string key 4 employees.Add("111-22-3333", "Scott"); 5 employees.Add("222-33-4444", "Sam"); 6 employees.Add("333-44-55555", "Jisun"); 7 8 // Access a particular key 9 if (employees.ContainsKey("111-22-3333")) 10 { 11 string empName = (string)employees["111-22-3333"]; 12 Console.WriteLine("Employee 111-22-3333's name is: " + empName); 13 } 14 else 15 Console.WriteLine("Employee 111-22-3333 is not in the hash table...");
Hashtable 類中的哈希函數比前面介紹的社保號的實現要更爲複雜。哈希函數必須返回一個序數(Ordinal Value)。對於社保號的例子,經過截取後四位就能夠實現。但實際上 Hashtable 類能夠接受任意類型的值做爲 Key,這都要歸功於 GetHashCode 方法,一個定義在 System.Object 中的方法。GetHashCode 的默認實現將返回一個惟一的整數,而且保證在對象的生命週期內保持不變。
Hashtable 類中的哈希函數定義以下:
H(key) = [GetHash(key) + 1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1))] % hashsize
這裏的 GetHash(key) 默認是調用 key 的 GetHashCode 方法以獲取返回的哈希值。hashsize 指的是哈希表的長度。由於要進行求模,因此最後的結果 H(key) 的範圍在 0 至 hashsize - 1 之間。
當在哈希表中添加或獲取一個元素時,會發生哈希衝突。前面咱們簡單地介紹了兩種衝突解決策略:
在 Hashtable 類中則使用的是一種徹底不一樣的技術,稱爲二度哈希(rehashing)(有些資料中也將其稱爲雙重哈希(double hashing))。
二度哈希的工做原理以下:
有一個包含一組哈希函數 H1...Hn 的集合。當須要從哈希表中添加或獲取元素時,首先使用哈希函數 H1。若是致使衝突,則嘗試使用 H2,以此類推,直到 Hn。全部的哈希函數都與 H1 十分類似,不一樣的是它們選用的乘法因子(multiplicative factor)。
一般,哈希函數 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 互爲素數(兩數互爲素數表示二者沒有共同的質因子)。
二度哈希使用了 Θ(m2) 種探查序列,而線性探查(Linear Probing)和二次探查(Quadratic Probing)使用了Θ(m) 種探查序列,故二度哈希提供了更好的避免衝突的策略。
Hashtable 類中包含一個私有成員變量 loadFactor,loadFactor 指定了哈希表中元素數量與位置(slot)數量之間的最大比例。例如:若是 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)。
向 Hashtable 中添加新元素時,須要檢查以保證元素與空間大小的比例不會超過最大比例。若是超過了,哈希表空間將被擴充。步驟以下:
由此看出,對哈希表的擴充將是以性能損耗爲代價。所以,咱們應該預先估計哈希表中最有可能容納的元素數量,在初始化哈希表時給予合適的值進行構造,以免沒必要要的擴充。
Hashtable 類是一個類型鬆耦合的數據結構,開發人員能夠指定任意的類型做爲 Key 或 Item。當 .NET 引入泛型支持後,類型安全的 Dictionary<K,T> 類出現。Dictionary<K,T> 使用強類型來限制 Key 和 Item,當建立 Dictionary<K,T> 實例時,必須指定 Key 和 Item 的類型。
Dictionary<keyType, valueType> variableName = new Dictionary<keyType, valueType>();
若是繼續使用上面描述的社保號和員工的示例,咱們能夠建立一個 Dictionary<K,T> 的實例:
Dictionary<int, Employee> employeeData = new Dictionary<int, Employee>();
這樣咱們就能夠添加和刪除員工信息了。
1 // Add some employees 2 employeeData.Add(455110189) = new Employee("Scott Mitchell"); 3 employeeData.Add(455110191) = new Employee("Jisun Lee"); 4 5 // See if employee with SSN 123-45-6789 works here 6 if (employeeData.ContainsKey(123456789))
Dictionary<K,T> 與 Hashtable 的不一樣之處還不止一處。除了支持強類型外,Dictionary<K,T> 還採用了不一樣的衝突解決策略(Collision Resolution Strategy),這種技術稱爲連接技術(chaining)。
前面使用的探查技術(probing),若是發生衝突,則將嘗試列表中的下一個位置。若是使用二度哈希(rehashing),則將致使全部的哈希被從新計算。而連接技術(chaining)將採用額外的數據結構來處理衝突。Dictionary<K,T> 中的每一個位置(slot)都映射到了一個鏈表。當衝突發生時,衝突的元素將被添加到桶(bucket)列表中。
下面的示意圖中描述了 Dictionary<K,T> 中的每一個桶(bucket)都包含了一個鏈表以存儲相同哈希的元素。
上圖中,該 Dictionary 包含了 8 個桶,也就是自頂向下的黃色背景的位置。必定數量的 Employee 對象已經被添加至 Dictionary 中。若是一個新的 Employee 要被添加至 Dictionary 中,將會被添加至其 Key 的哈希所對應的桶中。若是在相同位置已經有一個 Employee 存在了,則將會將新元素添加到列表的前面。
向 Dictionary 中添加元素的操做涉及到哈希計算和鏈表操做,但其仍爲常量,漸進時間爲 O(1)。
對 Dictionary 進行查詢和刪除操做時,其平均時間取決於 Dictionary 中元素的數量和桶(bucket)的數量。具體的說就是運行時間爲 O(n/m),這裏 n 爲元素的總數量,m 是桶的數量。但 Dictionary 幾乎老是被實現爲 n = O(m),也就是說,元素的總數毫不會超過桶的總數,因此 O(n/m) 也變成了常量 O(1)。
參考資料
本篇文章《經常使用數據結構及複雜度》由 Dennis Gao 發表自博客園,任何未經做者贊成的爬蟲或人爲轉載均爲耍流氓。