使用C#的同學對List應該並不陌生,我們不需要初始化它的大小,並且可以方便的使用Add和Remove方法執行新增和刪除操作,但卻可以使用下標來訪問它的資料,它是我們常說的連結串列嗎?
List<int> ls = new List<int>();
ls.Add(1);
Console.WriteLine(ls[0]); //輸出 1
先簡單回顧一下連結串列的概念。
什麼是連結串列
連結串列是一種線性表資料結構,在記憶體中它可以是非連續的,通過在每個結點中使用指標儲存下一個結點的地址來實現邏輯順序。一個結點由兩部分組成:一個是儲存資料元素的資料域,另一個是儲存下一個結點地址的指標域。
連結串列由很多種類,常見的有:單鏈表、雙鏈表和迴圈連結串列,它們實現的原理差別不大,相對於單鏈表只是多添加了一些特定的功能,所以今天主要講解最簡單、最常用的單鏈表。
單鏈表新增、刪除結點
由於連結串列是通過指標來指向下一個結點,所以新增和刪除操作需要改變指標的指向即可。並且它們的時間複雜度都是O(1).
單鏈表查詢指定結點
陣列可以通過下標和定址公式使用O(1)的時間複雜度來訪問指定結點,但是由於連結串列結點在記憶體中可以是非連續的,無法通過定址公式計算對應的記憶體地址,所以要查詢一個結點就只能依次遍歷,時間複雜度為O(n).
C#中的List
既然連結串列不能通過下標訪問,那上面例子中ls[0]為什麼會輸出1呢?
檢視原始碼,首先從它的Add方法開始,在vs中點選f12進入,發現跳轉到List
類內部的SynchronizedList
類中,Add函式定義如下
public void Add(T item)
{
lock (_root)
{
_list.Add(item);
}
}
目前還沒有看出什麼問題,繼續檢視 _list.Add
方法
public void Add(T item)
{
if (_size == _items.Length)
{
//確保不超出容量,否則會執行擴容操作
EnsureCapacity(_size + 1);
}
_items[_size++] = item;
_version++;
}
_items[_size++] = item
這句看起來是不是很眼熟,不就是向陣列中新增一個元素嘛。為了嚴謹,我們再檢視_items是什麼
private const int _defaultCapacity = 4;
private T[] _items;
private int _size;
private int _version;
果不其然,_items
就是一個泛型陣列,並且還有_size
、_version
等其他欄位
原來List其實並不是連結串列,在記憶體中它也是使用陣列來進行儲存的,只是對新增、刪除等操作進行了封裝,並且使其能夠“自動擴容”。
LinkedList連結串列
這才是C#中真正的連結串列,不過它不是最簡單的單鏈表,而是一個雙鏈表。單鏈表只有一個指標結點指向它的下一個元素,而雙鏈表中每個結點有兩個指標結點,一個指向它的下一個元素,另一個指向它的上一個元素。
下面是LinkedListNode的部分原始碼,可以看到它包含一個next指標和一個prev指標。
internal LinkedList<T> list;
internal LinkedListNode<T> next;
internal LinkedListNode<T> prev;
具體怎麼使用,這裡就不具體講解了,看官方文件也比較容易理解。
連結串列和陣列的區別
ok,現在我們對陣列和連結串列都有了一定程度上的瞭解,那能不能歸納出它們之間有哪些區別呢?對陣列有不清楚的地方,可以檢視文章為什麼陣列從0開始編號。
從邏輯結構上看,它們都是屬於線性表這個資料結構
從記憶體上來看,陣列是順序儲存,佔用一塊連續的記憶體,大小固定,擴容成本大;連結串列是鏈式儲存,也就是非連續的,並且因為它多了一個指標,所以不存在大小限制,天然支援動態擴容,但佔用的記憶體也更大。
訪問操作:陣列可以支援“隨機訪問”,O(1)的時間複雜度;連結串列需要按順序逐個訪問,O(n)的時間複雜度.
新增和刪除操作:陣列的時間複雜度為O(n); 連結串列的時間複雜度為O(1)
作業系統記憶體管理方面,藉助CPU的快取機制,可以預讀陣列的內容,所以訪問效率更高,而連結串列則不行。