深度解析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的存储结构的简单介绍,如错漏的地方,还望指正。
 
posted @ 2023-12-05 13:16  彭_泽  阅读(928)  评论(0编辑  收藏  举报