C#中常用的數據結構(轉)

前言:
可能去過羅小黑博客的盆油們讀過這篇對于數據結構的總結,但是羅小黑當時寫那篇文章的時候略有匆忙,所以今天進行了一些增改,重新發表在蠻牛。作為程序猿,對于常見的數據結構的掌握是非常必要的,也許這篇文章略顯樸實,沒有那么花哨的東西,但是羅小黑也希望各位程序向的U3D從業者能喜歡。
前段時間羅小黑讀過一份代碼,對其中各種數據結構靈活的使用贊不絕口,同時也大大激發了羅小黑對各種數據結構進行梳理和總結的欲望。正好最近也拜讀了若干大神的文章,覺得總結下常用的數據結構以供自己也能靈活的使用變得刻不容緩。那么還是從羅小黑的工作內容入手,就談談在平時使用U3D時經常用到的數據結構和各種數據結構的應用場景吧。
1.幾種常見的數據結構 
這里主要總結下羅小黑在工作中常碰到的幾種數據結構:Array,ArrayList,List<T>,LinkedList<T>,Queue<T>,Stack<T>,Dictionary<K,T>
數組Array:  
數組是最簡單的數據結構。其具有如下特點:

  1. 數組存儲在連續的內存上。
  2. 數組的內容都是相同類型。
  3. 數組可以直接通過下標訪問。
    數組Array的創建:
int size = 5;
int[] test = new int[size];

創建一個新的數組時將在 CLR 托管堆中分配一塊連續的內存空間,來盛放數量為size,類型為所聲明類型的數組元素。如果類型為值類型,則將會有size個未裝箱的該類型的值被創建。如果類型為引用類型,則將會有size個相應類型的引用被創建。由于是在連續內存上存儲的,所以它的索引速度非常快,訪問一個元素的時間是恒定的也就是說與數組的元素數量無關,而且賦值與修改元素也很簡單。

string[] test2 = new string[3];
//賦值
test2[0] = "chen";
test2[1] = "j";
test2[2] = "d";
//修改
test2[0] = "chenjd";

但是有優點,那么就一定會伴隨著缺點。由于是連續存儲,所以在兩個元素之間插入新的元素就變得不方便。而且就像上面的代碼所顯示的那樣,聲明一個新的數組時,必須指定其長度,這就會存在一個潛在的問題,那就是當我們聲明的長度過長時,顯然會浪費內存,當我們聲明長度過短的時候,則面臨這溢出的風險。這就使得寫代碼像是投機,羅小黑很厭惡這樣的行為!針對這種缺點,下面隆重推出ArrayList。
ArrayList:  
為了解決數組創建時必須指定長度以及只能存放相同類型的缺點而推出的數據結構。ArrayList是System.Collections命名空間下的一部分,所以若要使用則必須引入System.Collections。正如上文所說,ArrayList解決了數組的一些缺點。

  1. 不必在聲明ArrayList時指定它的長度,這是由于ArrayList對象的長度是按照其中存儲的數據來動態增長與縮減的。
  2. ArrayList可以存儲不同類型的元素。這是由于ArrayList會把它的元素都當做Object來處理。因而,加入不同類型的元素是允許的。
    ArrayList的操作:
ArrayList test3 = newArrayList();
//新增數據
test3.Add("chen");
test3.Add("j");
test3.Add("d");
test3.Add("is");
test3.Add(25);
//修改數據
test3[4] = 26;
//刪除數據
test3.RemoveAt(4);

說了那么一堆”優點“,也該說說缺點了吧。為什么要給”優點”打上引號呢?那是因為ArrayList可以存儲不同類型數據的原因是由于把所有的類型都當做Object來做處理,也就是說ArrayList的元素其實都是Object類型的,辣么問題就來了。

  1. ArrayList不是類型安全的。因為把不同的類型都當做Object來做處理,很有可能會在使用ArrayList時發生類型不匹配的情況。
  2. 如上文所訴,數組存儲值類型時并未發生裝箱,但是ArrayList由于把所有類型都當做了Object,所以不可避免的當插入值類型時會發生裝箱操作,在索引取值時會發生拆箱操作。這能忍嗎?

注:為何說頻繁的沒有必要的裝箱和拆箱不能忍呢?且聽羅小黑慢慢道來:所謂裝箱 (boxing):就是值類型實例到對象的轉換(百度百科)。那么拆箱:就是將引用類型轉換為值類型咯(還是來自百度百科)。下面舉個栗子~

//裝箱,將String類型的值FanyoyChenjd賦值給對象。
String info = ”FanyoyChenjd”;  
object obj=(object)info; 
//拆箱,從Obj中提取值給info
object obj = "FanyoyChenjd";
String info = (String)obj;

那么結論呢?好吧,請允許羅小黑很low再次引用百度百科。顯然,從原理上可以看出,裝箱時,生成的是全新的引用對象,這會有時間損耗,也就是造成效率降低。

List<T>泛型List  
為了解決ArrayList不安全類型與裝箱拆箱的缺點,所以出現了泛型的概念,作為一種新的數組類型引入。也是工作中經常用到的數組類型。和ArrayList很相似,長度都可以靈活的改變,最大的不同在于在聲明List集合時,我們同時需要為其聲明List集合內數據的對象類型,這點又和Array很相似,其實List<T>內部使用了Array來實現。

List<string> test4 = new List<string>(); 
//新增數據 
test4.Add(“Fanyoy”); 
test4.Add(“Chenjd”); 
//修改數據 
test4[1] = “murongxiaopifu”;  
//移除數據
test4.RemoveAt(0);

這么做最大的好處就是

  1. 即確保了類型安全。
  2. 也取消了裝箱和拆箱的操作。
  3. 它融合了Array可以快速訪問的優點以及ArrayList長度可以靈活變化的優點。

假設各位和羅小黑一樣,在工作中最常使用的一種數據結構就是它。那么我們是否能再多一點好奇心呢?那就是探究一下,如果我們自己實現一個類似的數據結構,該從何處下手呢?
下面羅小黑就拋磚引玉了。
剛才說過了,List<T>的內部其實也是一個Array,且是強類型的,所以我們的簡單實現(暫且稱之為EggArray<T>)也秉承這個特點,內部通過一個Array來實現,且需要聲明類型。但是同時我們也看到List<T>繼承和實現了很多接口,比如IEnumerable接口等,而且值類型和引用類型通吃。這里為了EggArray<T>實現起來輕裝簡行,我們不繼承List<T>繼承的各種接口,同時我們的EggArray只服務于引用類型。
那么首先明確了,它是一個處理引用類型,且實現了泛型的。那么定義就出來了:

//EggArray類
//定義
public class  EggArray<T> where T : class
{

}

那么下一步呢?該確定它的內部成員了,就先從字段和屬性開始吧。
屬性&變量

屬性 說明
Capacity EggArray的容量
Count EggArray中的元素個數
items T[],一個Array,因為上一篇文章說過List<T>的內部其實還是Array,所以內部我們也使用Array
//EggArray<T>的屬性&&變量
private int capacity;
private int count;
private T[] items;
public int Count
{
    get
    {
        return this.count;
    }
}
public int Capacity
{
    get
    {
        return this.capacity;
    }
}

之后呢?好像是需要一個構造函數了。上文也說了,貌似new的時候不需要指定容量呀。那么我們就把構造函數做成這樣吧。
構造函數:

構造函數 說明
EggArray() 初始化 EggArray<T> 類的新實例,該實例為空并且具有默認初始容量。
EggArray(int32) 初始化 EggArray<T> 類的新實例,該實例為空并且具有指定的初始容量。
//EggArray的構造函數,默認容量為8
public EggArray() : this(8)
{

}
public EggArray(int capacity)
{
    this.capacity = capacity;
    this.items  = new T[capacity];
}

好了,構造函數也說完了,那么就介紹一下私有方法,因為運行機制全部是有私有方法來運籌的,公共方法只不過是開放給我們的使用的罷了。羅小黑對公共方法的實現沒有興趣,這里就不做演示了。
剛剛也說了,List<T>是無所謂初始長度的,可以用Add()方法往里面添加元素,同時也不可能是有一個無限大的空間讓它來存儲,那么究竟它究竟為何能做到這一點呢?因為有一個能動態調整內部數組大小的方法存在,且調整大小是按照原有長度成倍增長的。我們姑且稱之為Resize。
那么在進行下面的內容之前,羅小黑還想先問各位一個問題:

List<int> test = new List<int>(){0,1,2,3,4,5,6,7,8,9};
int count = 0;
for(int i = 0; i < test.Count; i++)
{
    if(i == 1)
      {
          test.Remove(test[i]);
          count++;
      }
 }
 Debug.Log(count);

上面這段代碼會輸出什么呢?答案是9。可能有的盆油會感到奇怪,test進去時長度明明是10啊。就算你中間Remove了一個元素,可為什么會影響后面的元素呢?(比如把index為1的元素remove掉,原來index為2的元素現在的index就成1了。)感覺亂套有木有?其實這里List<T>在執行remove的同時,也把內部的數組壓縮了。所以也肯定有一個方法用來壓縮咯。我們姑且稱為Compact。
私有方法

私有方法 說明
Resize 當數組元素個數大于或等于數組的容量時,調用該方法進行擴容,會創建一個新的Array存放數據,“增長因子”為2
Compact 壓縮數組,在Remove時候默認調用
//當數組元素個數不小于數組容量時,需要擴容,增長因子growthFactor為2
private void Resize()
{
   int capacity = this.capacity
  * growthFactor;
   if(this.count > capacity)
   {
       this.count = capacity;
   }
   T[] destinationArray = new T[capacity];
   Array.Copy(this.items,destinationArray, this.count);
   this.items = destinationArray;
   this.capacity = capacity;
}
private void Compact()
{
    int num = 0;
           for(int i = 0; i < this.count; i++)
           {
               if(this.items[i] == null)
               {
                   num++;
               }
               else if (num > 0)
               {
                   this.items[i - num] = this.items[i];
                   this.items[i] = null;
               }
           }
           this.count -= num;
}[i][i][i]

LinkedList<T>  
也就是鏈表了。和上述的數組最大的不同之處就是在于鏈表在內存存儲的排序上可能是不連續的。這是由于鏈表是通過上一個元素指向下一個元素來排列的,所以可能不能通過下標來訪問。如圖

鏈表示意圖.jpg

既然鏈表最大的特點就是存儲在內存的空間不一定連續,那么鏈表相對于數組最大優勢和劣勢就顯而易見了。

  1. 向鏈表中插入或刪除節點無需調整結構的容量。因為本身不是連續存儲而是靠各對象的指針所決定,所以添加元素和刪除元素都要比數組要有優勢。
  2. 鏈表適合在需要有序的排序的情境下增加新的元素,這里還拿數組做對比,例如要在數組中間某個位置增加新的元素,則可能需要移動移動很多元素,而對于鏈表而言可能只是若干元素的指向發生變化而已。
  3. 有優點就有缺點,由于其在內存空間中不一定是連續排列,所以訪問時候無法利用下標,而是必須從頭結點開始,逐次遍歷下一個節點直到尋找到目標。所以當需要快速訪問對象時,數組無疑更有優勢。

綜上,鏈表適合元素數量不固定,需要兩端存取且經常增減節點的情況。
  關于鏈表的使用,MSDN上有詳細的例子。
Queue<T>  
在Queue<T>這種數據結構中,最先插入在元素將是最先被刪除;反之最后插入的元素將最后被刪除,因此隊列又稱為“先進先出”(FIFO—first in first out)的線性表。通過使用Enqueue和Dequeue這兩個方法來實現對 Queue<T> 的存取。

隊列示意圖.jpg

 一些需要注意的地方:

  1. 先進先出的情景。
  2. 默認情況下,Queue<T>的初始容量為32, 增長因子為2.0。
  3. 當使用Enqueue時,會判斷隊列的長度是否足夠,若不足,則依據增長因子來增加容量,例如當為初始的2.0時,則隊列容量增長2倍。
  4. 乏善可陳。
    關于Queue<T>的使用方法,MSDN上也有相應的例子。
    Stack<T>
隊列進出示意圖.png

 與Queue<T>相對,當需要使用后進先出順序(LIFO)的數據結構時,我們就需要用到Stack<T>了。
  一些需要注意的地方:

  1. 后進先出的情景。
  2. 默認容量為10。
  3. 使用pop和push來操作。
  4. 乏善可陳。

同樣,在MSDN你也可以看到大量Stack<T>的例子。
Dictionary<K,T>  
字典這東西,羅小黑可是喜歡的不得了。看官們自己也可以想想字典是不是很招人喜歡,創建一個字典之后就可以往里面扔東西,增加、刪除、訪問那叫一個快字了得。但是直到羅小黑日前看了一個大神的文章,才又想起了那句話“啥好事咋能讓你都占了呢”。那么字典背后到底隱藏著什么迷霧,撥開重重迷霧之后,是否才是真相?且聽下回分。。。等等,應該是下面就讓我們來分析一下字典吧。
  提到字典就不得不說Hashtable哈希表以及Hashing(哈希,也有叫散列的),因為字典的實現方式就是哈希表的實現方式,只不過字典是類型安全的,也就是說當創建字典時,必須聲明key和item的類型,這是第一條字典與哈希表的區別。關于哈希表的內容推薦看下這篇博客哈希表。關于哈希,簡單的說就是一種將任意長度的消息壓縮到某一固定長度,比如某學校的學生學號范圍從0000099999,總共5位數字,若每個數字都對應一個索引的話,那么就是100000個索引,但是如果我們使用后3位作為索引,那么索引的范圍就變成了000999了,當然會沖突的情況,這種情況就是哈希沖突(Hash Collisions)了。扯遠了,關于具體的實現原理還是去看羅小黑推薦的那篇博客吧,當然那篇博客上面那個大大的轉字也是蠻刺眼的。。。
  回到Dictionary<K,T>,我們在對字典的操作中各種時間上的優勢都享受到了,那么它的劣勢到底在哪呢?對嘞,就是空間。以空間換時間,通過更多的內存開銷來滿足我們對速度的追求。在創建字典時,我們可以傳入一個容量值,但實際使用的容量并非該值。而是使用“不小于該值的最小質數來作為它使用的實際容量,最小是3。”(老趙),當有了實際容量之后,并非直接實現索引,而是通過創建額外的2個數組來實現間接的索引,即int[] buckets和Entry[] entries兩個數組(即buckets中保存的其實是entries數組的下標),這里就是第二條字典與哈希表的區別,還記得哈希沖突嗎?對,第二個區別就是處理哈希沖突的策略是不同的!字典會采用額外的數據結構來處理哈希沖突,這就是剛才提到的數組之一buckets桶了,buckets的長度就是字典的真實長度,因為buckets就是字典每個位置的映射,然后buckets中的每個元素都是一個鏈表,用來存儲相同哈希的元素,然后再分配存儲空間。

字典hash示意圖.png

因此,我們面臨的情況就是,即便我們新建了一個空的字典,那么伴隨而來的是2個長度為3的數組。所以當處理的數據不多時,還是慎重使用字典為好,很多情況下使用數組也是可以接受的。

2.幾種常見數據結構的使用情景

數據結構 使用場景
Array 需要處理的元素數量確定并且需要使用下標時可以考慮,不過建議使用List<T>
ArrayList 不推薦使用,建議用List<T>
List<T> 泛型List 需要處理的元素數量不確定時 通常建議使用
LinkedList<T> 鏈表適合元素數量不固定,需要經常增減節點的情況,2端都可以增減
Queue<T> 先進先出的情況
Stack<T> 后進先出的情況
Dictionary<K,T> 需要鍵值對,快速操作
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容