經常使用數據結構[轉]

 

原文:http://www.cnblogs.com/gaochundong/p/3813252.html

經常使用數據結構的時間複雜度

Data Structurehtml

Addgit

Find編程

Delete數組

GetByIndex
安全

 Array (T[])

O(n)數據結構

O(n)編程語言

O(n)函數

O(1)性能

 Linked list (LinkedList<T>)

O(1)spa

O(n)

O(n)

O(n)

 Resizable array list (List<T>)

O(1)

O(n)

O(n)

O(1)

 Stack (Stack<T>)

O(1)

-

O(1)

-

 Queue (Queue<T>)

O(1)

-

O(1)

-

 Hash table (Dictionary<K,T>)

O(1)

O(1)

O(1)

-

 Tree-based dictionary 

 (SortedDictionary<K,T>)

  O(log n)  

  O(log n)  

  O(log n)  

-

 Hash table based set 

 (HashSet<T>)

O(1)

O(1)

O(1)

-

 Tree based set 

 (SortedSet<T>)

O(log n)

O(log n)

O(log n)

-

如何選擇數據結構

Array (T[])

  • 當元素的數量是固定的,而且須要使用下標時。

Linked list (LinkedList<T>)

  • 當元素須要可以在列表的兩端添加時。不然使用 List<T>。

Resizable array list (List<T>)

  • 當元素的數量不是固定的,而且須要使用下標時。

Stack (Stack<T>)

  • 當須要實現 LIFO(Last In First Out)時。

Queue (Queue<T>)

  • 當須要實現 FIFO(First In First Out)時。

Hash table (Dictionary<K,T>)

  • 當須要使用鍵值對(Key-Value)來快速添加和查找,而且元素沒有特定的順序時。

Tree-based dictionary (SortedDictionary<K,T>)

  • 當須要使用價值對(Key-Value)來快速添加和查找,而且元素根據 Key 來排序時。

Hash table based set (HashSet<T>)

  • 當須要保存一組惟一的值,而且元素沒有特定順序時。

Tree based set (SortedSet<T>)

  • 當須要保存一組惟一的值,而且元素須要排序時。

Array

在計算機程序設計中,數組(Array)是最簡單的並且應用最普遍的數據結構之一。在任何編程語言中,數組都有一些共性:

  • 數組中的內容是使用連續的內存(Contiguous Memory)來存儲的。
  • 數組中的全部元素必須是相同的類型,或者類型的衍生類型。所以數組又被認爲是同質數據結構(Homegeneous Data Structures)。
  • 數組的元素能夠直接被訪問。好比你須要訪問數組的第 i 個元素,則能夠直接使用 arrayName[i] 來訪問。

對於數組的常規操做包括:

  • 分配空間(Allocation)
  • 數據訪問(Accessing)

在 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),所以對數組的訪問時間是恆定的。也就是說,與數組中包含的元素數量沒有直接關係,訪問一個元素的時間是相同的。

ArrayList

因爲數組是固定長度的,而且數組中只能存儲同一種類型或類型的衍生類型。這在使用中會受到一些限制。.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 時會產生額外的開銷,致使性能降低。

List<T>

當 .NET 中引入泛型功能後,上面 ArrayList 所帶來的性能代價可使用泛型來消除。.NET 提供了新的數組類型 List<T>。

泛型容許開發人員在建立數據結構時推遲數據類型的選擇,直到使用時才肯定選擇哪一種類型。泛型(Generics)的主要優勢包括:

  • 類型安全(Type Safety):使用泛型定義的類型,在使用時僅能使用指定的類型或類型的衍生類型。
  • 性能(Performance):泛型移除了運行時類型檢測,消除了裝箱和拆箱的開銷。
  • 可重用(Reusability):泛型打破了數據結構與存儲數據類型之間的緊耦合。這提升了數據結構的可重用性。

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 是相同的。

LinkedList<T>

在鏈表(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)。

Queue<T>

當咱們須要使用先進先出順序(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 的值。

Stack<T>

當須要使用後進先出順序(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)。

Hashtable

如今假設咱們要使用員工的社保號做爲惟一標識進行存儲。社保號的格式爲 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),步驟以下:

  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)
  • Edward (111-00-1235)

則插入後的哈希表可能以下:

元素的插入過程:

  • Alice 的社保號被哈希爲 1234,所以存放在位置 1234。
  • Bob 的社保號被哈希爲 1234,但因爲位置 1234 處已經存放 Alice 的信息,則檢查下一個位置 1235,1235 爲空,則 Bob 的信息就被放到 1235。
  • Cal 的社保號被哈希爲 1237,1237 位置爲空,因此 Cal 就放到 1237 處。
  • Danny 的社保號被哈希爲 1235,1235 已被佔用,則檢查 1236 位置是否爲空,1236 爲空,因此 Danny 就被放到 1236。
  • Edward 的社保號被哈希爲 1235,1235 已被佔用,檢查1236,也被佔用,再檢查1237,直到檢查到 1238時,該位置爲空,因而 Edward 被放到了1238 位置。

線性探查(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 之間。

當在哈希表中添加或獲取一個元素時,會發生哈希衝突。前面咱們簡單地介紹了兩種衝突解決策略:

  • 線性探查(Linear Probing)
  • 二次探查(Quadratic Probing)

在 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 中添加新元素時,須要檢查以保證元素與空間大小的比例不會超過最大比例。若是超過了,哈希表空間將被擴充。步驟以下:

  • 哈希表的位置空間幾乎被翻倍。準確地說,位置空間值從當前的素數值增長到下一個最大的素數值。
  • 由於二度哈希時,哈希表中的全部元素值將依賴於哈希表的位置空間值,因此表中全部值也須要從新二度哈希。

由此看出,對哈希表的擴充將是以性能損耗爲代價。所以,咱們應該預先估計哈希表中最有可能容納的元素數量,在初始化哈希表時給予合適的值進行構造,以免沒必要要的擴充。

Dictionary<K,T>

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 發表自博客園,任何未經做者贊成的爬蟲或人爲轉載均爲耍流氓。

相關文章
相關標籤/搜索