深度解析C#中LinkedList<T>的存储结构
本文承接前面的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缓存的实现逻辑。