深度解析C#中LinkedList<T>的存儲結構

  本文承接前面的3篇有關C#的數據結構分析的文章,對於C#有關數據結構分析還有一篇就要暫時結束了,這個系列主要從Array、List、Dictionary、LinkedList、 SortedSet等5中不同類型進行介紹和分析。廢話不多說,接下來我們來最後看一下這個系列的最後一種數據類型"鏈表"。

  提到鏈表這個數據結構可能大部分同學都不會感到陌生,但是在.NET中使用LinkedList  這個集合的同學可能就不會很多,因爲絕大部分的場景中大部分同學會直接使用List、Dictionary數據結構,這次我們就來藉助本文對.NET的LinkedList集合進行一個全面的瞭解。

  本文將從鏈表的基礎特性、C#中LinkedList的底層實現邏輯,.NET的不同版本對於Queue的不同實現方式的原因分析等幾個視角進行簡單的解讀。

一、鏈表的基礎特性

   數組需要一塊連續的內存空間來存儲,對內存的要求比較高。鏈表並不需要一塊連續的內存空間,通過“指針”將一組零散的內存塊串聯起來使用。鏈表的節點可以動態分配內存,使得鏈表的大小可以根據需要動態變化,而不受固定的內存大小的限制。特別是在需要頻繁的插入和刪除操作時,鏈表相比於數組具有更好的性能。最常見的鏈表結構分別是:單鏈表、雙向鏈表和循環鏈表。
    1、鏈表的基本單元是節點,每個節點包含兩個部分:
      (1)、數據(Data):存儲節點所包含的信息。
      (2)、引用(Next):指向下一個節點的引用,在雙向鏈表中,包含指向前一個節點的引用。
    2、鏈表的基本類型,主要包含三種類型:
      (1)、單鏈表(Singly Linked List):每個節點只包含一個指向下一個節點的引用。
        (a)、【時間複雜度】頭部插入/刪除:O(1);尾部插入:O(n) ;中間插入/刪除:O(n) 。
        (b)、【時間複雜度】按值查找:O(n) (需要遍歷整個鏈表);按索引查找:O(n) 。
        (c)、【空間複雜度】插入和刪除:O(1);查找:O(1)。
      (2)、雙鏈表(Doubly Linked List):每個節點包含兩個引用,一個指向下一個節點,一個指向前一個節點。
        (a)、【時間複雜度】頭部插入/刪除:O(1);尾部插入/刪除:O(1);中間插入/刪除:O(n) 。
        (b)、【時間複雜度】按值查找:O(n) ;按索引查找:O(n) 。
        (c)、【空間複雜度】O(n)。
      (3)、循環鏈表: 尾節點的引用指向頭節點,形成一個閉環。
        (a)、【時間複雜度】頭部插入/刪除:O(1);尾部插入/刪除:O(1);中間插入/刪除:O(n) 。
        (b)、【時間複雜度】按值查找:O(n) ;按索引查找:O(n) 。
        (c)、【空間複雜度】O(n)。

   以上簡單的介紹了鏈表的基礎特性、分類、對應的時間複雜度和空間複雜度,雙鏈表雖然比較耗費內存,但是其在插入、刪除、有序鏈表查詢方面相對於單鏈表有明顯的優先,這一點充分的體現了算法上的"用空間換時間"的設計思想。

二、LinkedList數據存儲

   LinkedList 是 C# 中提供的一個雙向鏈表(doubly linked list)實現,用於存儲元素。雙向鏈表的每個節點都包含對前一個節點和後一個節點的引用,這種結構使得在鏈表中的兩個方向上進行遍歷和操作更爲方便。

   1、節點結構

 1     public sealed class LinkedListNode<T>
 2     {
 3         internal LinkedList<T>? list;
 4         internal LinkedListNode<T>? next;
 5         internal LinkedListNode<T>? prev;
 6         internal T item;
 7         ...
 8         public LinkedListNode(T value)
 9         {
10             Value = value;
11             Previous = null;
12             Next = null;
13         }
14     }

  以上的代碼展示了在C#的 LinkedList的節點的存儲結構,表示雙向鏈表中的一個節點。 LinkedList 中的每個節點都是一個包含元素值和兩個引用的對象。list是一個對包含該節點的 LinkedList 的引用。這個引用使得節點能夠訪問鏈表的一些信息,例如頭節點、尾節點等。next是一個對下一個節點的引用。prev是一個對前一個節點的引用。item存儲節點的值。

  其實看到這個地方,可能有部分同學會產生疑問,爲什麼這個節點的數據結構不設計爲"結構體",而是設計爲一個類,結構體在內存佔用方面更有優勢。在這裏爲什麼設計爲,可能有以下幾種綜合考慮。

    1、引用語義:類型的實例具有引用語義,當傳遞或賦值對象時,傳遞或賦值的是對象的引用,同一對象的修改在所有引用該對象都是可見的。

    2、複雜性和生命週期:如果類型具有較複雜的生命週期或包含對其他資源(如其他對象、文件句柄等)的引用,通常會選擇類而不是結構體。結構體適用於輕量級、簡單的值類型,而類則更適合處理更復雜、具有引用語義的情況。

    3、可空性:類可以使用 null 表示空引用,結構體不能。

    4、性能和拷貝開銷:結構體通常會被複制,類則是通過引用傳遞。

  對於以上的結構設計複雜度並不高,我們從整體的設計視角考慮這個結構設計爲"結構體"和"類",哪一種更加有優勢,我們在以後的系統開發過程中,也需要綜合去思考,沒有一種結構是完美的,每一種結構都有其針對性的優勢。

  2、鏈表頭和尾

1 public class LinkedList<T> : ICollection<T>, ...
2 {
3     public LinkedListNode<T> First { get; }
4     public LinkedListNode<T> Last { get; }
5     ...
6 }
  LinkedList 本身維護了對鏈表頭和尾的引用,分別指向第一個節點(頭節點)和最後一個節點(尾節點)。通過將鏈表的節點(LinkedListNode)作爲LinkedList 類的私有成員,可以隱藏鏈表節點的實現細節,提供更好的封裝性。外部用戶只需關注鏈表的公共接口而不需要了解節點的具體結構。並且可以更容易地擴展和維護鏈表的功能、可以控制對節點的訪問權限、對鏈表的操作會影響到同一個鏈表的所有引用、可以表示空鏈表等優勢。

三、LinkedList數據讀寫

  上文中我看分析了鏈表的存儲結構LinkedListNode和LinkedList。接下來,我們再來看一下鏈表LinkedList元素的維護和查詢等基礎操作的實現邏輯。首先我們來看一下元素的添加操作,Add()方法用於將一個元素添加到集合中,其內部的核心實現方法爲AddLast(),我們接下來具體看一下這個方法的內部實現。【源碼進行了部分刪減】。

 1         public LinkedListNode<T> AddLast(T value)
 2         {
 3             LinkedListNode<T> result = new LinkedListNode<T>(this, value);
 4             
 5             //區分鏈表爲空和非空的場景
 6             if (head == null)
 7             {
 8                 InternalInsertNodeToEmptyList(result);
 9             }
10             else
11             {
12                 InternalInsertNodeBefore(head, result);
13             }
14             return result;
15         }

  以上代碼展示了AddLast()的實現代碼,這個方法是在雙向鏈表的末尾添加一個新節點的操作,並根據鏈表是否爲空採取不同的插入策略,確保插入操作的有效性,並返回了對新插入節點的引用。這裏做爲空和非空的場景區分是因爲在雙向鏈表中,頭節點 head 的前一個節點是尾節點,而尾節點的下一個節點是頭節點。因此,在鏈表爲空的情況下,頭節點即是尾節點,直接插入新節點即可。而在鏈表不爲空的情況下,需要在頭節點之前插入新節點,以保持鏈表的首尾相連。接下來我們分別來看一下InternalInsertNodeToEmptyList()和InternalInsertNodeBefore()方法。

 1         private void InternalInsertNodeToEmptyList(LinkedListNode<T> newNode)
 2         {
 3             //用於確保在調用此方法時鏈表必須爲空。
 4             Debug.Assert(head == null && count == 0, "LinkedList must be empty when this method is called!");
 5             
 6             //將新節點的 next 指向自身
 7             newNode.next = newNode;  
 8             
 9             //將新節點的 prev 指向自身
10             newNode.prev = newNode; 
11              
12             //將鏈表的頭節點指向新節點
13             head = newNode; 
14               
15             //增加鏈表的版本號       
16             version++;
17                   
18             //增加鏈表中節點的數量         
19             count++;                 
20         }

  InternalInsertNodeToEmptyList()實現了在空鏈表中插入新節點的邏輯。在空鏈表中,新節點是唯一的節點,因此它的 next和prev都指向自身。新節點同時是頭節點和尾節點。

 1         private void InternalInsertNodeBefore(LinkedListNode<T> node, LinkedListNode<T> newNode)
 2         {
 3             //新節點newNode的next引用指向目標節點node,
 4             //確保新節點newNode的next指向原來在鏈表中的位置。
 5             newNode.next = node;
 6             
 7             //新節點newNode的prev引用指向目標節點node的前一個節點,
 8             //在插入操作中保持鏈表的連接關係,確保newNode的前一個節點正確。
 9             newNode.prev = node.prev;
10             
11             //目標節點node前一個節點的next引用指向新節點newNode,新節點newNode插入完成
12             node.prev!.next = newNode;
13             
14             //目標節點node的prev引用指向新節點newNode,
15             //鏈表中目標節點node的前一個節點變成了新插入的節點newNode。
16             node.prev = newNode;
17             
18             //用於追蹤鏈表的結構變化,通過每次修改鏈表時增加 
19             //version的值,可以在迭代過程中檢測到對鏈表的併發修改。
20             version++;
21             count++;
22         }

  InternalInsertNodeBefore()用於實現鏈表中在指定節點前插入新節點,保證了插入操作的正確性和一致性,確保鏈表的連接關係和節點計數正確地維護。上面的代碼已經做了邏輯說明。node.prev!.next = newNode;中的!確保在鏈表中插入新節點時,前一個節點不爲 null,以防止潛在的空引用異常。版本號的增加是爲了在併發操作中提供一種機制,使得在迭代過程中能夠檢測到鏈表的結構變化。這對於多線程環境下的鏈表操作是一種常見的實踐,以避免潛在的併發問題。

  上面我們介紹了LinkedList 的InternalInsertNodeToEmptyList()和InternalInsertNodeBefore()方法,用於向鏈表插入元素。接下來,我們再來具體看看鏈表的元素查詢的實現邏輯,LinkedList 實現元素的方法是Find()。

 1         public LinkedListNode<T>? Find(T value)
 2         {
 3             LinkedListNode<T>? node = head;
 4             EqualityComparer<T> c = EqualityComparer<T>.Default;
 5             if (node != null)
 6             {
 7                 if (value != null)
 8                 {
 9                      // 查找非空值的節點
10                     do
11                     {
12                         if (c.Equals(node!.item, value))
13                         {
14                             return node;
15                         }
16                         node = node.next;
17                     } while (node != head);
18                 }
19                 else
20                 {
21                     // 查找空值的節點
22                     do
23                     {
24                         if (node!.item == null)
25                         {
26                             return node;
27                         }
28                         node = node.next;
29                     } while (node != head);
30                 }
31             }
32             // 未找到節點
33             return null;
34         }

  通過循環遍歷鏈表中的每個節點,根據節點的值與目標值的比較,找到匹配的節點並返回。在鏈表中可能存在包含 null 值的節點,也可能存在包含非空值的節點,而這兩種情況需要採用不同的比較方式。LinkedListNode? node = head; 初始化一個節點引用 node,開始時指向鏈表的頭節點head。使用了do-while 循環確保至少執行一次,即使鏈表爲空。爲了防止潛在的空引用異常,使用了! 操作符來斷言節點 node 不爲 null。Find()方法對於鏈表中值的查詢的時間複雜度是O(n)。

  上面介紹了鏈表元素的查詢實現邏輯,接下來我們看一下鏈表元素的移除操作,在InternalRemoveNode()方法中實現。

 1         internal void InternalRemoveNode(LinkedListNode<T> node)
 2         {
 3             if (node.next == node)
 4             {
 5                 //將鏈表頭head 設爲null,表示鏈表爲空。
 6                 head = null;
 7             }
 8             else
 9             {
10                 //將目標節點node後一個節點的prev引用指向目標節點node的前一個節點。
11                 node.next!.prev = node.prev;
12                 
13                 //將目標節點node前一個節點的next引用指向目標節點node的後一個節點。
14                 node.prev!.next = node.next;
15                 
16                 if (head == node)
17                 {
18                     //如果目標節點node是鏈表頭節點head,則將鏈表頭head設爲目標節點node的下一個節點。
19                     head = node.next;
20                 }
21             }
22             node.Invalidate();
23             count--;
24             version++;
25         }

  在雙向鏈表中刪除指定節點node,首先判斷鏈表中是否只有一個節點。如果鏈表只有一個節點,那麼刪除這個節點後鏈表就爲空。調用 Invalidate 方法,用於清除節點的 list、prev 和 next 引用,使節點脫離鏈表。version++增加鏈表的版本號,用於在併發迭代過程中檢測鏈表結構的變化。

  本節中主要介紹了鏈表的元素插入、元素的查詢、元素的移除等操作,在不同的場景中,其實現的方式都存在着不同,在C#內部維護的鏈表結構相對簡化,沒有對其內部進行很強的優化,因此我們在實際的項目中對於鏈表的應用時,需要充分的分析使用的場景訴求進行調整優化。

四、Queue中鏈表與數組的實現對比

  在整個.NET Core的數據結構體系中,數組佔據了絕大部分的應用場景,對於鏈表的應用場景相對較少,但是鏈表也有其獨特的結構,適用於對應的場景中。其實在 .NET Framework版本中,Queue 的底層實現確實使用了鏈表,而 Stack 的實現通常使用了動態數組。在當前.NET Core版本中,Queue 底層實現已經修改爲基於Array數組來實現。對於Queue選擇鏈表還是數組的底層實現方案,各有優劣勢。我們藉助一下.NET在對Queue的實現方式上的不同,來對比一下鏈表與數組的選擇上的優劣勢分析。

  1、Queue使用鏈表的優劣勢

    1、使用鏈表的好處:
      (1)、高效的插入和刪除操作:在隊尾和隊頭進行插入和刪除操作更爲高效,符合隊列的典型操作。
      (2)、不需要連續內存:鏈表不要求元素在內存中是連續存儲的,這使得隊列可以更靈活地分配和釋放內存。
      (3)、適用於頻繁的入隊和出隊操作:鏈表在動態增長和縮減時的性能表現更好,適用於隊列中頻繁進行入隊和出隊操作的場景。
    2、使用鏈表的劣勢:
      (1)、內存開銷較大:每個節點需要額外的內存空間存儲指向下一個節點的引用,可能會導致相對較大的內存開銷。
      (2)、隨機訪問性能差:鏈表不支持直接通過索引進行隨機訪問。

  2、Queue使用數組的優劣勢

    1、使用數組的優勢:
      (1)、隨機訪問性能:數組提供了O(1)時間複雜度的隨機訪問,鏈表需要按順序遍歷到目標位置。
      (2)、緩存友好性:數組在內存中是連續存儲的,鏈表節點的存儲是分散的。
      (3)、空間效率:數組不需要額外的指向下一個節點的引用,具有更小的內存開銷。
      (4)、適用於特定訪問模式:對於隨機訪問而非插入/刪除操作,選擇數組作爲底層實現可能更合適。
    2、使用數組的劣勢:
      (1)、插入和刪除性能較差:數組在中間插入或刪除元素的性能較差,因爲需要移動元素以保持數組的順序。
      (2)、動態擴展的開銷:如果隊列的大小會動態變化,數組在動態擴展時可能會涉及到重新分配內存、複製元素的開銷影響性能。
      (3)、大隊列的管理:對於大的隊列,如果需要頻繁進行動態擴展,可能會面臨內存管理的挑戰。
      (4)、不適用於特定插入模式:如果主要操作是頻繁的插入和刪除而不是隨機訪問,選擇數組作爲底層實現可能不是最佳選擇。

五、場景應用

  文章開頭介紹了鏈表的基礎特性,基於鏈表的基礎特性來展開分析C#的LinkedList結構,重點說明了LinkedList的元素插入、查詢、移除和存儲對象。鏈表在實際的應用中比較廣泛,尤其是在緩存的處理方面。緩存是一種提高數據讀取性能的技術,在硬件設計、軟件開發中都有着非常廣泛的應用,比如常見的 CPU 緩存、數據庫緩存、瀏覽器緩存等等。緩存的大小有限,當緩存被用滿時,哪些數據應該被清理出去,哪些數據應該被保留?這就需要緩存淘汰策略來決定。常見的策略有三種:先進先出策略 FIFO(First In,FirstOut)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(LeastRecently Used)。

  這裏我們以簡單實現方式說明一下LRU緩存的實現邏輯。

    1、 如果此數據之前已經被緩存在鏈表中了,則遍歷得到這個數據對應的結點,並將其從原來的位置刪除,然後再插入到鏈表的頭部。
    2.、如果此數據沒有在緩存鏈表中,則分爲兩種情況:
      (1)、如果此時緩存未滿,則將此結點直接插入到鏈表的頭部;
      (2)、如果此時緩存已滿,則鏈表尾結點刪除,將新的數據結點插入鏈表的頭部。
  對於鏈表的基礎應用場景中如:單鏈表反轉;鏈表中環的檢測;有序的鏈表合併等較爲常用的算法。
     以上內容是對C#中LinkedList的存儲結構的簡單介紹,如錯漏的地方,還望指正。
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章