深度解析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数据读写
上文中我看分析了链表的存储结构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缓存的实现逻辑。
深度解析C#中LinkedList<T>的存储结构的更多相关文章
- 深度解析javascript中的浅复制和深复制
原文:深度解析javascript中的浅复制和深复制 在谈javascript的浅复制和深复制之前,我们有必要在来讨论下js的数据类型.我们都知道有Number,Boolean,String,Null ...
- 深度解析VC中的消息(转发)
http://blog.csdn.net/chenlycly/article/details/7586067 这篇转发的文章总结的比较好,但是没有告诉我为什么ON_MESSAGE的返回值必须是LRES ...
- LinkedList实现队列存储结构
package com.tercher.demo; import java.util.LinkedList; public class Queue { //用LinkedList 实现队列的数据存储结 ...
- Java中Map<Key, Value>存储结构根据值排序(sort by values)
需求:Map<key, value>中可以根据key, value 进行排序,由于 key 都是唯一的,可以很方便的进行比较操作,但是每个key 对应的value不是唯一的,有可能出现多个 ...
- 【转载】深度解析Android中字体设置
原文:http://mobile.51cto.com/android-265238.htm 1.在Android XML文件中设置字体 可以采用Android:typeface,例如android:t ...
- 深度解析Java中的那把锁
锁的本质 我们先来讨论锁的出现是为了解决什么问题,锁要保证的事情其实很好理解,同一件事(一个代码块)在同一时刻只能由一个人(线程)操作. 这里所说的锁为排他锁,暂不考虑读写锁的情况 我们在这里打个比方 ...
- 深度解析VC中的消息传递机制
摘要:Windows编程和Dos编程,一个很大的区别就是,Windows编程是事件驱动,消息传递的.所以,要学好Windows编程,必须 对消息机制有一个清楚的认识,本文希望能够对消息的传递做一个全面 ...
- 深度解析Struts2中ValueStack
1.什么是ValueStack 对于每一个action的调用,Struts在执行相应的动作方法之前会先创建一个名为ValueStack的对象.Value Stack用来保存该动作对象或者对象.由于最终 ...
- 深度解析VC中的消息
消息是指什么? 消息系统对于一个win32程序来说十分重要,它是一个程序运行的动力源泉.一个消息,是系统定义的一个32位的值,他唯一的定义了一个事件,向Windows发出一个通知,告诉应用程序某个事情 ...
- 深度解析 Qt 中动态链接库
本文介绍的是Qt 中动态链接库,现在有些软件有自动升级功能,有些就是下载新的DLL文件,替换原来的动态链接库,MFC好象也有类似机制,Qt还有一种方式,就是把一个QWidget子类,编译成动态链接库. ...
随机推荐
- Weblogic T3协议反序列化命令执行漏洞(CVE-2018- 2628)复现
好久没发新文章了,躲懒了,是时候该更新一篇了,这一篇是关于WebLogic T3协议反序列化命令执行漏洞(CVE-2018- 2628)的复现,该漏洞是weblogic的经典漏洞,而网上有关的复现稍显 ...
- java入门2..0
java的运行原理 1.在本地磁盘中创建一个文本文件为Demo.java的源文件 2.在源文件中编写java代码如下: public class Demo public static void ,ma ...
- C# 使用openxml解析PPTX中的文本内容
前言 本文讨论的仅针对微软Office 2007以后的(OOXML定义)PowerPoint文档,Office 2007以前的用二进制格式定义的(ppt格式)文档不在本文讨论范围. 一.依赖类库 本文 ...
- Unity 编辑器资源导入处理函数 OnPreprocessTexture:深入解析与实用案例
Unity 编辑器资源导入处理函数 OnPreprocessTexture 用法 点击封面跳转下载页面 简介 在Unity中,我们可以使用编辑器资源导入处理函数(OnPreprocessTexture ...
- AI绘画:StableDiffusion制作AI赛博机车图保姆级教程
本教程收集于:AIGC从入门到精通教程汇总 如果你具备Stable Diffusion的基础知识,那制作AI赛博机车图会更为顺畅.即便没有这个基础,只要严格按照教程的步骤执行,也能成功制作出来. 本教 ...
- 使用API接口获取淘宝商品数据的详细指南
在电商行业中,淘宝作为中国最大的在线购物平台,每天有数以百万计的商品被发布和交易.作为程序员,如果需要获取淘宝商品的详细数据,可以通过调用API接口来实现.本文将详细介绍如何使用淘宝API接口获取 ...
- css面试题一
1.继承 css的继承:就是给父级设置一些属性,子级继承了父级的该属性,这就是我们css中的继承.官方的解释,继承是一种规则,它允许样式不仅应用于特定的html标签元素,而且应用于其后代元素. a.有 ...
- 一些H5对接微信JSSDK的问题记录
这里给大家分享我在实际生活中总结出来的一些知识,希望对大家有所帮助 一.SDK引入 这里提供两套引入流程,一套是vue2.0及其他h5项目,一套是vue3.0的引入流程 不懂的也可以看我之前的一篇详细 ...
- 采用ResNet网络+TSNE降维算法对自建图像数据集进行二维可视化显示
起因:某一天下午,我在"玩"的时候,突然接到了老板的电话,说是要对图像做可视化降维.因此,我拿到了一批图像的数据. 数据的特点: 1.数据集的图像分为4类,并且每一种类的图像多少不 ...
- D 算法模板(Boruvka's Algorithm)
Description 为了方便你测试,本题为D题简化版. You are given a complete undirected graph with nn vertices. A number a ...