本文承接前面的3篇有關C#的資料結構分析的文章,對於C#有關資料結構分析還有一篇就要暫時結束了,這個系列主要從Array、List、Dictionary、LinkedList、 SortedSet等5中不同類型進行介紹和分析。廢話不多說,接下來我們來最後看一下這個系列的最後一種資料型別"連結串列"。
提到連結串列這個資料結構可能大部分同學都不會感到陌生,但是在.NET中使用LinkedList 這個集合的同學可能就不會很多,因為絕大部分的場景中大部分同學會直接使用List、Dictionary資料結構,這次我們就來藉助本文對.NET的LinkedList集合進行一個全面的瞭解。
本文將從連結串列的基礎特性、C#中LinkedList的底層實現邏輯,.NET的不同版本對於Queue的不同實現方式的原因分析等幾個視角進行簡單的解讀。
一、連結串列的基礎特性
陣列需要一塊連續的記憶體空間來儲存,對記憶體的要求比較高。連結串列並不需要一塊連續的記憶體空間,透過“指標”將一組零散的記憶體塊串聯起來使用。連結串列的節點可以動態分配記憶體,使得連結串列的大小可以根據需要動態變化,而不受固定的記憶體大小的限制。特別是在需要頻繁的插入和刪除操作時,連結串列相比於陣列具有更好的性能。最常見的連結串列結構分別是:單連結串列、雙向連結串列和迴圈連結串列。以上簡單的介紹了連結串列的基礎特性、分類、對應的時間複雜度和空間複雜度,雙連結串列雖然比較耗費記憶體,但是其在插入、刪除、有序連結串列查詢方面相對於單鏈表有明顯的優先,這一點充分的體現了演算法上的"用空間換時間"的設計思想。
二、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元素的維護和查詢等基礎操作的實現邏輯。首先我們來看一下元素的新增操作,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使用連結串列的優劣勢
2、Queue使用陣列的優劣勢
五、場景應用
文章開頭介紹了連結串列的基礎特性,基於連結串列的基礎特性來展開分析C#的LinkedList結構,重點說明了LinkedList的元素插入、查詢、移除和儲存物件。連結串列在實際的應用中比較廣泛,尤其是在快取的處理方面。快取是一種提高資料讀取效能的技術,在硬體設計、軟體開發中都有著非常廣泛的應用,比如常見的 CPU 快取、資料庫快取、瀏覽器快取等等。快取的大小有限,當快取被用滿時,哪些資料應該被清理出去,哪些資料應該被保留?這就需要快取淘汰策略來決定。常見的策略有三種:先進先出策略 FIFO(First In,FirstOut)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(LeastRecently Used)。
這裡我們以簡單實現方式說明一下LRU快取的實現邏輯。