• 深度解析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
     2     {
     3         internal LinkedList? list;
     4         internal LinkedListNode? next;
     5         internal LinkedListNode? 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 : ICollection, ...
    2 {
    3     public LinkedListNode First { get; }
    4     public LinkedListNode Last { get; }
    5     ...
    6 }
    复制代码
      LinkedList 本身维护了对链表头和尾的引用,分别指向第一个节点(头节点)和最后一个节点(尾节点)。通过将链表的节点(LinkedListNode)作为LinkedList 类的私有成员,可以隐藏链表节点的实现细节,提供更好的封装性。外部用户只需关注链表的公共接口而不需要了解节点的具体结构。并且可以更容易地扩展和维护链表的功能、可以控制对节点的访问权限、对链表的操作会影响到同一个链表的所有引用、可以表示空链表等优势。

    三、LinkedList数据读写

      上文中我看分析了链表的存储结构LinkedListNode和LinkedList。接下来,我们再来看一下链表LinkedList元素的维护和查询等基础操作的实现逻辑。首先我们来看一下元素的添加操作,Add()方法用于将一个元素添加到集合中,其内部的核心实现方法为AddLast(),我们接下来具体看一下这个方法的内部实现。【源码进行了部分删减】。

    复制代码
     1         public LinkedListNode AddLast(T value)
     2         {
     3             LinkedListNode result = new LinkedListNode(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 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 node, LinkedListNode 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? Find(T value)
     2         {
     3             LinkedListNode? node = head;
     4             EqualityComparer c = EqualityComparer.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 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的存储结构的简单介绍,如错漏的地方,还望指正。
     


    感谢您的阅读,如果您觉得阅读本文对您有帮助,请点一下“推荐”按钮。本文欢迎各位转载,但是转载文章之后必须在文章页面中给出作者和原文连接。如果需要获取最新的优秀.NET博文,请关注微信公众号“DotNet技术分享”。 微信公众号二维码
  • 相关阅读:
    代码随想录训练营二刷 总结 | 完结撒花
    Vue中父组件向子组件传值,子组件没有接收到
    157条超实用Python代码实例。问题+实例解答+原理解析+补充知识
    甲方不让用开源【监控软件】?大不了我自己写一个
    [iOS]-pthread、NSThread
    LVS负载均衡集群
    ValueError: could not determine the shape of object type ‘Series’.
    hcie数通和云计算选哪个好?
    CEC2015:动态多目标野狗优化算法求解CEC2015(提供完整MATLAB代码,含GD、IGD、HV和SP评价指标)
    算法题:SOJ1092: 欧几里得算法
  • 原文地址:https://www.cnblogs.com/pengze0902/p/17872923.html