本文承接前面的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的存储结构的简单介绍,如错漏的地方,还望指正。
 

深度解析C#中LinkedList<T>的存储结构的更多相关文章

  1. 深度解析javascript中的浅复制和深复制

    原文:深度解析javascript中的浅复制和深复制 在谈javascript的浅复制和深复制之前,我们有必要在来讨论下js的数据类型.我们都知道有Number,Boolean,String,Null ...

  2. 深度解析VC中的消息(转发)

    http://blog.csdn.net/chenlycly/article/details/7586067 这篇转发的文章总结的比较好,但是没有告诉我为什么ON_MESSAGE的返回值必须是LRES ...

  3. LinkedList实现队列存储结构

    package com.tercher.demo; import java.util.LinkedList; public class Queue { //用LinkedList 实现队列的数据存储结 ...

  4. Java中Map<Key, Value>存储结构根据值排序(sort by values)

    需求:Map<key, value>中可以根据key, value 进行排序,由于 key 都是唯一的,可以很方便的进行比较操作,但是每个key 对应的value不是唯一的,有可能出现多个 ...

  5. 【转载】深度解析Android中字体设置

    原文:http://mobile.51cto.com/android-265238.htm 1.在Android XML文件中设置字体 可以采用Android:typeface,例如android:t ...

  6. 深度解析Java中的那把锁

    锁的本质 我们先来讨论锁的出现是为了解决什么问题,锁要保证的事情其实很好理解,同一件事(一个代码块)在同一时刻只能由一个人(线程)操作. 这里所说的锁为排他锁,暂不考虑读写锁的情况 我们在这里打个比方 ...

  7. 深度解析VC中的消息传递机制

    摘要:Windows编程和Dos编程,一个很大的区别就是,Windows编程是事件驱动,消息传递的.所以,要学好Windows编程,必须 对消息机制有一个清楚的认识,本文希望能够对消息的传递做一个全面 ...

  8. 深度解析Struts2中ValueStack

    1.什么是ValueStack 对于每一个action的调用,Struts在执行相应的动作方法之前会先创建一个名为ValueStack的对象.Value Stack用来保存该动作对象或者对象.由于最终 ...

  9. 深度解析VC中的消息

    消息是指什么? 消息系统对于一个win32程序来说十分重要,它是一个程序运行的动力源泉.一个消息,是系统定义的一个32位的值,他唯一的定义了一个事件,向Windows发出一个通知,告诉应用程序某个事情 ...

  10. 深度解析 Qt 中动态链接库

    本文介绍的是Qt 中动态链接库,现在有些软件有自动升级功能,有些就是下载新的DLL文件,替换原来的动态链接库,MFC好象也有类似机制,Qt还有一种方式,就是把一个QWidget子类,编译成动态链接库. ...

随机推荐

  1. 问题排查:nginx的反向代理感觉失效了一样

    背景 最近,负责基础设施的同事,要对一批测试环境机器进行回收,回收就涉及到应用迁移,问题是整个过程一团乱.比如服务器A上一堆应用要调用服务器B上一堆服务,结果服务器B被回收了,然后服务器A上一堆应用报 ...

  2. iOS越狱后必装软件

    iOS越狱后就跟ubuntu没两样了,很多ubuntu下常用的软件都要装一下 openssh 这个软件可以让我们能够登录iphone Apt-get 用这个软件可以安装很多软件,主要是一些工具调试类软 ...

  3. LeetCode297:hard级别中最简单的存在,java版,用时击败98%,内存击败百分之九十九

    本篇概览 因为欣宸个人水平有限,在刷题时一直不敢面对hard级别的题目,生怕出现一杯茶一包烟,一道hard做一天的窘境 这种恐惧心理一直在,直到遇见了它:LeetCode297,建议不敢做hard题的 ...

  4. Solution -「洛谷 P6287」「COCI 2016-2017」Mag

    Description Link. 定义一条链的价值为链上点权乘积除以节链上点数,求一条价值最小的链. Solution 结论:答案链上最多包含一个 \(2\)(其余全为 \(1\)),并且不在链的两 ...

  5. 详解RecyclerView的预布局

    概述 RecyclerView 的预布局用于 Item 动画中,也叫做预测动画.其用于当 Item 项进行变化时执行的一次布局过程(如添加或删除 Item 项),使 ItemAnimator 体验更加 ...

  6. 快速启动Stable Diffusion WebUI

    快速启动Stable Diffusion WebUI详情    产品文档   输入文档关键字查找   机器学习PAI 产品概述 快速入门 操作指南 准备工作 开通PAI并创建默认工作空间 开通并授权依 ...

  7. VS Code SSH

    VS Code SSH 连接需要下载 VS Code Server,这是因为 VS Code Server 是在远程服务器上运行的,而不是在本地计算机上运行的.每次连接到不同的远程服务器时,都需要下载 ...

  8. android 尺寸适配相关

    Android上常见度量单位 px(像素):屏幕上的点,绝对长度,与硬件相关. in(英寸):长度单位. mm(毫米):长度单位. pt(磅):1/72英寸,point. dp(与密度无关的像素):一 ...

  9. jmeter不用工具获取随机值的几种方法

    第一种:直接获取 "vcContent": "${__time(yyyyMMddHHmmss)}${__RandomString(8,QWERTYUIOPASDFGHJK ...

  10. [第五空间 2021]yet_another_mysql_injection

    随便输入进去,发现只有账号是admin可以进入 使用弱密码admin admin,报错为hacker 就没啥办法了,想着F12看一下源码 发现有一个source,打开看看 可以发现username是固 ...