使用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開始編號

  1. 從邏輯結構上看,它們都是屬於線性表這個資料結構

  2. 從記憶體上來看,陣列是順序儲存,佔用一塊連續的記憶體,大小固定,擴容成本大;連結串列是鏈式儲存,也就是非連續的,並且因為它多了一個指標,所以不存在大小限制,天然支援動態擴容,但佔用的記憶體也更大。

  3. 訪問操作:陣列可以支援“隨機訪問”,O(1)的時間複雜度;連結串列需要按順序逐個訪問,O(n)的時間複雜度.

  4. 新增和刪除操作:陣列的時間複雜度為O(n); 連結串列的時間複雜度為O(1)

  5. 作業系統記憶體管理方面,藉助CPU的快取機制,可以預讀陣列的內容,所以訪問效率更高,而連結串列則不行。

總結