本文承接前面的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緩存的實現邏輯。