深度解析C#中LinkedList<T>的存储结构

本文承接前面的3篇有关C#的数据结构分析的文章,对于C#有关数据结构分析就暂时结束了,这个系列主要从Array、List、Dictionary、LinkedList等4中不同类型进行介绍和分析,对于C#其他的数据结构绝大部分都是基于以上4中类型进行展开,并且实现的逻辑大同小异。废话不多说,接下来我们来最后看一下这个系列的最后一种数据类型"链表"。

提到链表这个数据结构可能大部分同学都不会感到陌生,但是在.NET中使用LinkedList 这个集合的同学可能就不会很多,因为绝大部分的场景中大部分同学会直接使用List、Dictionary数据结构,这次我们就来借助本文对.NET的LinkedList集合进行一个全面的了解。

本文将从链表的基础特性、C#中LinkedList的底层实现逻辑,.NET的不同版本对于Queue的不同实现方式的原因分析等几个视角进行简单的解读。

一、链表的基础特性

数组需要一块连续的内存空间来存储,对内存的要求比较高。链表并不需要一块连续的内存空间,通过"指针"将一组零散的内存块串联起来使用。链表的节点可以动态分配内存,使得链表的大小可以根据需要动态变化,而不受固定的内存大小的限制。特别是在需要频繁的插入和删除操作时,链表相比于数组具有更好的性能。最常见的链表结构分别是:单链表、双向链表和循环链表。

scss 复制代码
 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、节点结构

ini 复制代码
 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存储节点的值。

其实看到这个地方,可能有部分同学会产生疑问,为什么这个节点的数据结构不设计为"结构体",而是设计为一个类,结构体在内存占用方面更有优势。在这里为什么设计为,可能有以下几种综合考虑。

csharp 复制代码
    1、引用语义:类型的实例具有引用语义,当传递或赋值对象时,传递或赋值的是对象的引用,同一对象的修改在所有引用该对象都是可见的。

    2、复杂性和生命周期:如果类型具有较复杂的生命周期或包含对其他资源(如其他对象、文件句柄等)的引用,通常会选择类而不是结构体。
    		 结构体适用于轻量级、简单的值类型,而类则更适合处理更复杂、具有引用语义的情况。

    3、可空性:类可以使用 null 表示空引用,结构体不能。

    4、性能和拷贝开销:结构体通常会被复制,类则是通过引用传递。

对于以上的结构设计复杂度并不高,我们从整体的设计视角考虑这个结构设计为"结构体"和"类",哪一种更加有优势,我们在以后的系统开发过程中,也需要综合去思考,没有一种结构是完美的,每一种结构都有其针对性的优势。

2、链表头和尾

kotlin 复制代码
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(),我们接下来具体看一下这个方法的内部实现。【源码进行了部分删减】。

scss 复制代码
 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()方法。

ini 复制代码
 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都指向自身。新节点同时是头节点和尾节点。

ini 复制代码
 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()。

ini 复制代码
 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()方法中实现。

ini 复制代码
 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的实现方式上的不同,来对比一下链表与数组的选择上的优劣势分析。

scss 复制代码
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缓存的实现逻辑。

scss 复制代码
1、如果此数据之前已经被缓存在链表中了,则遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
2、如果此数据没有在缓存链表中,则分为两种情况:
   (1)、如果此时缓存未满,则将此结点直接插入到链表的头部;
	  (2)、如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

对于链表的基础应用场景中如:单链表反转;链表中环的检测;有序的链表合并等较为常用的算法。

以上内容是对C#中LinkedList的存储结构的简单介绍,如错漏的地方,还望指正。

相关推荐
WZTTMoon6 分钟前
Spring Boot 4.0 迁移核心注意点总结
java·spring boot·后端
寻kiki7 分钟前
scala 函数类?
后端
疯狂的程序猴17 分钟前
iOS App 混淆的真实世界指南,从构建到成品 IPA 的安全链路重塑
后端
bcbnb29 分钟前
iOS 性能测试的工程化方法,构建从底层诊断到真机监控的多工具测试体系
后端
开心就好202532 分钟前
iOS 上架 TestFlight 的真实流程复盘 从构建、上传到审核的团队协作方式
后端
小周在成长40 分钟前
Java 泛型支持的类型
后端
aiopencode41 分钟前
Charles 抓不到包怎么办?HTTPS 抓包失败、TCP 数据流异常与底层补抓方案全解析
后端
稚辉君.MCA_P8_Java1 小时前
Gemini永久会员 C++返回最长有效子串长度
开发语言·数据结构·c++·后端·算法
Penge6661 小时前
Redis-bgsave浅析
redis·后端
阿白的白日梦1 小时前
Windows下c/c++编译器MinGW-w64下载和安装
c语言·后端