C# 双向链表介绍

1. 什么是双向链表?

双向链表(Doubly Linked List)是一种链式数据结构,由多个节点组成,每个节点包含三个部分:

数据域(Value):存储实际数据。

前向引用(Previous):指向链表中的前一个节点。

后向引用(Next):指向链表中的下一个节点。

双向链表的特殊之处在于,每个节点都同时有对前一个节点和后一个节点的引用。这使得双向链表可以在 O(1) 时间内从任何节点访问前一个或后一个节点,从而实现高效的插入、删除操作,尤其是在链表中间进行操作时。

双向链表的结构如下所示:

cs 复制代码
[Node1] <-> [Node2] <-> [Node3] <-> [Node4]

  ↑          ↑          ↑          ↑

  |          |          |          |

Previous   Previous   Previous   Previous

Next       Next       Next       Next
cs 复制代码
 public sealed class LinkedListNode<T>
 {
     public LinkedListNode(T value);

     public LinkedList<T> List { get; }
     public LinkedListNode<T> Next { get; }
     public LinkedListNode<T> Previous { get; }
     public T Value { get; set; }
 }

你可以清晰的看到这个节点含有Next和Previous属性 ,这分别引用着本节点的前后节点,拿到一个双向链表的其中一个节点,就可以不停地向前/后寻找.

2. 为什么使用双向链表?

与数组和其他线性数据结构(如 List<T>)相比,双向链表具有以下优势:

高效的插入和删除:在双向链表的任意位置插入或删除节点时,只需调整相邻节点的引用,不需要像数组那样移动大量元素,因此操作时间为 O(1)。

双向遍历:由于每个节点都有对前一个和后一个节点的引用,允许你既可以从头向尾遍历链表,也可以从尾向头遍历。

双向链表的缺点是,每个节点需要额外的存储空间来维护 Previous 和 Next 的引用,并且需要更复杂的操作来管理节点的链接。

下面举一个例子表现出List结构在需要频繁插入元素的情况下糟糕的表现

cs 复制代码
using System;

using System.Collections.Generic;

using System.Diagnostics;



class Program

{

    static void Main()

    {

        List<int> list = new List<int>();

        int numberOfElements = 100000;



        // 初始化 List<T>,添加大量元素

        for (int i = 0; i < numberOfElements; i++)

        {

            list.Add(i);

        }



        Stopwatch stopwatch = new Stopwatch();

        stopwatch.Start();



        // 在 List 的中间位置插入和删除元素

        for (int i = 0; i < 1000; i++)

        {

            list.Insert(numberOfElements / 2, i); // 插入元素到中间位置

            list.RemoveAt(numberOfElements / 2);  // 删除中间位置的元素

        }



        stopwatch.Stop();

        Console.WriteLine("List<T> 操作时间: " + stopwatch.ElapsedMilliseconds + " ms");

    }

}

实际上当在 List<T> 中间插入一个元素时,会先考虑容量是否够,不够会自动扩容扩容的操作涉及分配新的数组空间,并将旧数组的元素复制到新数组中,之后将后面的所有元素都需要向后移动,以腾出位置给新插入的元素。这是因为 List<T> 是基于数组实现的,而数组在内存中是连续存储的,因此在中间插入元素时,需要移动部分元素以保持连续性。

在这个代码中,我们在 List<int> 中间频繁插入和删除元素。由于 List<T> 是基于数组实现的,每次在中间插入或删除元素时,都需要移动大量元素,时间复杂度为 O(n)。

感兴趣可以自行测试,性能表现很糟糕.

3. C# 中的 LinkedList<T> 实现

在 C# 中,双向链表的标准实现由 LinkedList<T> 和 LinkedListNode<T> 提供。

LinkedList<T>:这是整个链表的数据结构,用于管理节点,并提供插入、删除、遍历等操作。

LinkedListNode<T>:表示链表中的一个节点,包含存储的数据,以及对前后节点的引用。

cs 复制代码
LinkedList<int> linkedList = new LinkedList<int>();



// 添加节点到链表

linkedList.AddLast(1);  // 添加到链表尾部

linkedList.AddLast(2);

linkedList.AddFirst(0); // 添加到链表头部



// 遍历链表

foreach (int value in linkedList)

{

    Console.WriteLine(value);  // 输出: 0, 1, 2

}



// 删除节点

linkedList.Remove(1);  // 删除第一个值为 1 的节点



// 检查链表是否包含特定值

bool contains = linkedList.Contains(2);  // 返回 true

在上面的代码中,LinkedList<int> 是一个存储整数的双向链表,AddFirst 和 AddLast 分别用于在链表头部和尾部插入节点,Remove 则用于根据值删除节点。

LinkedListNode<T> 的使用

cs 复制代码
namespace System.Collections.Generic
{
    public class LinkedList<T> : ICollection<T>, IEnumerable<T>, IEnumerable, IReadOnlyCollection<T>, ICollection, IDeserializationCallback, ISerializable
    {
        public LinkedList();
        public LinkedList(IEnumerable<T> collection);
        protected LinkedList(SerializationInfo info, StreamingContext context);

        public LinkedListNode<T> Last { get; }
        public LinkedListNode<T> First { get; }
        public int Count { get; }

        public void AddAfter(LinkedListNode<T> node, LinkedListNode<T> newNode);
        public LinkedListNode<T> AddAfter(LinkedListNode<T> node, T value);
        public void AddBefore(LinkedListNode<T> node, LinkedListNode<T> newNode);
        public LinkedListNode<T> AddBefore(LinkedListNode<T> node, T value);
        public void AddFirst(LinkedListNode<T> node);
        public LinkedListNode<T> AddFirst(T value);
        public void AddLast(LinkedListNode<T> node);
        public LinkedListNode<T> AddLast(T value);
        public void Clear();
        public bool Contains(T value);
        public void CopyTo(T[] array, int index);
        public LinkedListNode<T> Find(T value);
        public LinkedListNode<T> FindLast(T value);
        public Enumerator GetEnumerator();
        public virtual void GetObjectData(SerializationInfo info, StreamingContext context);
        public virtual void OnDeserialization(object sender);
        public void Remove(LinkedListNode<T> node);
        public bool Remove(T value);
        public void RemoveFirst();
        public void RemoveLast();

        public struct Enumerator : IEnumerator<T>, IEnumerator, IDisposable, IDeserializationCallback, ISerializable
        {
            public T Current { get; }

            public void Dispose();
            public bool MoveNext();
        }
    }
}

可以清晰地看到,双向链表本身只维护头结点和尾节点,只要维护好这两个关键节点,根据节点自身的性质(Next和Previous属性 )就可以找到链表上其他的节点.

你还可以通过 LinkedListNode<T> 来精确控制节点操作:

cs 复制代码
LinkedList<int> linkedList = new LinkedList<int>();



// 添加节点

linkedList.AddLast(1);

linkedList.AddLast(2);

linkedList.AddLast(3);



// 查找节点

LinkedListNode<int> node = linkedList.Find(2);



// 在特定节点之前或之后插入节点

linkedList.AddBefore(node, 1);  // 在 2 之前插入 1

linkedList.AddAfter(node, 4);   // 在 2 之后插入 4

LinkedListNode<T> 提供 Next 和 Previous 属性,可以访问节点的前后节点:

cs 复制代码
LinkedListNode<int> node = linkedList.Find(2);



Console.WriteLine(node.Previous.Value);  // 输出 1

Console.WriteLine(node.Next.Value);      // 输出 4

4. 常见操作

4.1 添加节点

C# 的 LinkedList<T> 提供多种方法用于在链表中添加节点:

AddFirst(T value):将节点添加到链表的头部。

AddLast(T value):将节点添加到链表的尾部。

AddBefore(LinkedListNode<T> node, T value):在指定节点之前添加节点。

AddAfter(LinkedListNode<T> node, T value):在指定节点之后添加节点。

4.2 删除节点

删除操作也有几种方式:

Remove(T value):删除链表中第一个值为 value 的节点。

Remove(LinkedListNode<T> node):删除指定节点。

RemoveFirst():删除头节点。

RemoveLast():删除尾节点。

Remove方法如果传入null会抛异常,但是如果不是null,即便链表不存在这个元素也不会报错,而是会返回false.如果使用Remove(T value)删除某个具体的值,但含有多个重复值,只会删除第一个对应值.

而RemoveFirst和RemoveLast,如果不存在头/尾节点,那么会抛异常.

4.3 遍历节点

可以使用 foreach 循环来遍历链表中的节点,或者使用 LinkedListNode<T> 的 Next 和 Previous 属性进行双向遍历:

cs 复制代码
LinkedList<int> linkedList = new LinkedList<int>();

linkedList.AddLast(1);

linkedList.AddLast(2);

linkedList.AddLast(3);



LinkedListNode<int> node = linkedList.First;

while (node != null)

{

    Console.WriteLine(node.Value);

    node = node.Next;  // 前进到下一个节点

}

5. 双向链表的应用场景

双向链表在需要频繁插入和删除的场景中非常有用,例如:

文本编辑器中的撤销和恢复操作:文本编辑器中的操作历史记录可以使用双向链表来记录,并支持向前和向后遍历操作。

任务调度器:一些调度器会使用双向链表来管理任务,并高效插入和删除任务。

还是刚才大量插入的元素的例子,对比List实现,我们使用LinkedList实现

cs 复制代码
using System;

using System.Collections.Generic;

using System.Diagnostics;



class Program

{

    static void Main()

    {

        LinkedList<int> linkedList = new LinkedList<int>();

        int numberOfElements = 100000;



        // 初始化 LinkedList<T>,添加大量元素

        for (int i = 0; i < numberOfElements; i++)

        {

            linkedList.AddLast(i);

        }



        LinkedListNode<int> middleNode = GetMiddleNode(linkedList);



        Stopwatch stopwatch = new Stopwatch();

        stopwatch.Start();



        // 在 LinkedList 的中间位置插入和删除元素

        for (int i = 0; i < 1000; i++)

        {

            linkedList.AddBefore(middleNode, i);  // 插入到中间节点之前

            linkedList.Remove(middleNode.Previous);  // 删除中间位置的前一个节点

        }



        stopwatch.Stop();

        Console.WriteLine("LinkedList<T> 操作时间: " + stopwatch.ElapsedMilliseconds + " ms");

    }



    static LinkedListNode<int> GetMiddleNode(LinkedList<int> linkedList)

    {

        LinkedListNode<int> slow = linkedList.First;

        LinkedListNode<int> fast = linkedList.First;



        // 使用快慢指针法找到链表的中间节点

        while (fast != null && fast.Next != null)

        {

            slow = slow.Next;

            fast = fast.Next.Next;

        }



        return slow;

    }

}

在这个代码中,LinkedList<int> 使用双向链表结构进行操作。每次在中间插入或删除元素时,只需要调整前后节点的引用即可,时间复杂度为 O(1)。

性能分析

List<T>:

每次插入或删除中间位置的元素时,都需要移动大量元素(后面的所有元素),时间复杂度为 O(n)。

当列表中的元素较多时,频繁的插入和删除操作会导致性能迅速下降。

LinkedList<T>:

由于每个节点都包含对前后节点的引用,插入或删除元素只需调整引用即可,不需要移动其他元素,时间复杂度为 O(1)。

因此,在这种频繁插入和删除的场景下,LinkedList<T> 能够提供更好的性能表现。

6. 总结

C# 中的 LinkedList<T> 提供了双向链表的功能,它通过节点(LinkedListNode<T>)的 Next 和 Previous 属性实现双向遍历。与数组或 List<T> 相比,双向链表在插入和删除操作上具有显著的性能优势,特别是在链表的中间位置。虽然它在遍历时性能不如数组,但在需要高效插入和删除的场景中,双向链表是一个非常好的选择。

相关推荐
小码编匠5 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#
Envyᥫᩣ9 小时前
C#语言:从入门到精通
开发语言·c#
小春熙子13 小时前
Unity图形学之Shader结构
unity·游戏引擎·技术美术
IT技术分享社区14 小时前
C#实战:使用腾讯云识别服务轻松提取火车票信息
开发语言·c#·云计算·腾讯云·共识算法
Sitarrrr15 小时前
【Unity】ScriptableObject的应用和3D物体跟随鼠标移动:鼠标放置物体在场景中
3d·unity
极梦网络无忧15 小时前
Unity中IK动画与布偶死亡动画切换的实现
unity·游戏引擎·lucene
△曉風殘月〆21 小时前
WPF MVVM入门系列教程(二、依赖属性)
c#·wpf·mvvm
逐·風1 天前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#
_oP_i1 天前
Unity Addressables 系统处理 WebGL 打包本地资源的一种高效方式
unity·游戏引擎·webgl