Go 零基础数据结构:链表的增删改查(像串珠子一样简单)

文章目录

Go 零基础数据结构:链表的增删改查(像串珠子一样简单)

大家好!上一篇我们借助"抽屉"理解了顺序表,今天咱们用一种形象的图文方式,零基础搞懂链表的增删改查。链表与顺序表不同,它的元素在内存中并非连续存储,却通过指针巧妙连接,形成有序结构。接下来,让我们一同开启链表的探索之旅!

一、链表是什么?(独特的内存布局)

链表可以看作是由一系列节点组成的数据结构,每个节点通过指针相互连接。为了更好地理解,我们用下面这个图来展示:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘
      ↑ head           ↑               ↑ tail
      │  index 0       │  index 1       │  index 2

为了更直观体现链表在内存中的不连续性,咱们结合抽屉的例子来看:

plaintext 复制代码
┌───┬───┬───┬───┬───┐
│10 │   │20 │   │30 │  ← 元素分散在不同抽屉,中间有空隙
└───┴───┴───┴───┴───┘
  抽屉1 抽屉2 抽屉3 抽屉4 抽屉5

虽然元素分散在不同抽屉,但通过链表的指针(线),它们在逻辑上是有序连接的。

  • 不连续性 :与顺序表元素连续存储不同,链表的元素分散存储在内存空间中,就像上面图里,102030 并非紧挨着存放,它们之间存在空隙。
  • 前驱和后继 :每个节点除了存储数据,还包含指向前一个节点(前驱)和后一个节点(后继)的指针。比如 20 这个节点,它的前驱是 10,后继是 30。通过这些指针,节点在逻辑上形成有序序列。
  • 索引 :这里的索引(012 等)并非像顺序表那样能直接定位元素位置,而是用于辅助理解节点在链表中的顺序。实际访问链表元素时,需要从链表头部开始,顺着指针逐个查找。
  • 指针连接 :节点之间通过 next(指向下一节点)和 prev(指向上一节点)指针连接,构成链表的结构。这是链表实现元素逻辑顺序的关键。
  • 首元素和尾元素 :链表有一个头节点(head),它是链表的起始位置,通过 head 可以访问整个链表。尾节点(tail)则是链表的结束位置,其 next 指针通常指向 nil,表示链表结束。

二、链表的核心特点(对比顺序表理解)

特点1:不能随机访问

顺序表能通过索引直接定位元素,而链表不行。由于链表元素分散存储,要找到特定元素,必须从链表头开始,顺着指针逐个节点查找。例如要找值为 30 的节点,需从 head 开始,先找到 10,再根据 10next 指针找到 20,最后通过 20next 指针找到 30

特点2:增删高效

链表增删元素时,只需调整相关节点的指针,无需像顺序表那样移动大量元素。例如在 1020 之间插入新元素,只需修改 10next 指针和新元素的 nextprev 指针,即可完成插入操作,对其他节点无影响。

三、链表的增删改查操作(直观演示)

1. 查:顺序查找

需求 :查找值为 20 的节点。
操作 :从 head 开始,依次检查每个节点的值。当节点值等于 20 时,即找到目标节点。
Go 代码示例(不用懂代码,看效果)

go 复制代码
package main

import "fmt"

// 定义链表节点
type Node struct {
    Val   int
    Next  *Node
    Prev  *Node
}

func main() {
    // 创建链表 10 <-> 20 <-> 30
    n1 := &Node{Val: 10}
    n2 := &Node{Val: 20}
    n3 := &Node{Val: 30}
    n1.Next = n2
    n2.Next = n3
    n2.Prev = n1
    n3.Prev = n2

    current := n1
    for current != nil {
        if current.Val == 20 {
            fmt.Println("找到了 20")
            break
        }
        current = current.Next
    }
}

2. 增:分"头插"、"尾插"和"中间插"

头插:在链表头部插入元素

需求 :在链表头部插入值为 5 的节点。
操作

  1. 创建新节点 5
  2. 将新节点的 next 指针指向原 head 节点(10)。
  3. 更新原 head 节点的 prev 指针指向新节点。
  4. head 指针指向新节点。

操作前后对比:

插入前:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘
      ↑ head           ↑               ↑ tail
      │  index 0       │  index 1       │  index 2

插入后:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    5    │    │    10   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘    └─────────┘
      ↑ head                       ↑               ↑ tail
      │  index 0                   │  index 1       │  index 2

Go 代码示例

go 复制代码
newNode := &Node{Val: 5}
newNode.Next = n1
n1.Prev = newNode
n1 = newNode
尾插:在链表尾部插入元素

需求 :在链表尾部插入值为 35 的节点。
操作

  1. 创建新节点 35
  2. 将原 tail 节点的 next 指针指向新节点。
  3. 将新节点的 prev 指针指向原 tail 节点。
  4. 更新 tail 指针指向新节点。

操作前后对比:

插入前:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘
      ↑ head           ↑               ↑ tail
      │  index 0       │  index 1       │  index 2

插入后:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │    │    30   │    │    35   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘    └─────────┘
      ↑ head           ↑               ↑               ↑ tail
      │  index 0       │  index 1       │  index 2       │  index 3

Go 代码示例

go 复制代码
newTail := &Node{Val: 35}
lastNode := n1
for lastNode.Next != nil {
    lastNode = lastNode.Next
}
lastNode.Next = newTail
newTail.Prev = lastNode
中间插:在链表中间插入元素

需求 :在值为 20 的节点前插入值为 15 的节点。
操作

  1. 创建新节点 15
  2. 找到值为 20 的节点,将其 prev 节点的 next 指针指向新节点。
  3. 将新节点的 prev 指针指向 20prev 节点。
  4. 将新节点的 next 指针指向值为 20 的节点。
  5. 更新值为 20 的节点的 prev 指针指向新节点。

操作前后对比:

插入前:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘
      ↑ head           ↑               ↑ tail
      │  index 0       │  index 1       │  index 2

插入后:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    15   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘    └─────────┘
      ↑ head                       ↑               ↑ tail
      │  index 0                   │  index 1       │  index 2

Go 代码示例

go 复制代码
current := n1
for current != nil && current.Val != 20 {
    current = current.Next
}
if current != nil {
    newMid := &Node{Val: 15}
    newMid.Prev = current.Prev
    newMid.Next = current
    current.Prev.Next = newMid
    current.Prev = newMid
}

3. 删:分"头删"、"尾删"和"中间删"

头删:删除链表头部节点

需求 :删除链表头部节点(值为 10)。
操作

  1. head 指针指向原 head 节点的 next 节点。
  2. 更新新 head 节点的 prev 指针为 nil

操作前后对比:

删除前:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘
      ↑ head           ↑               ↑ tail
      │  index 0       │  index 1       │  index 2

删除后:

复制代码
    ┌─────────┐    ┌─────────┐
    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘
      ↑ head           ↑ tail
      │  index 0       │  index 1

Go 代码示例

go 复制代码
if n1 != nil {
    n1 = n1.Next
    if n1 != nil {
        n1.Prev = nil
    }
}
尾删:删除链表尾部节点

需求 :删除链表尾部节点(值为 30)。
操作

  1. 找到链表的倒数第二个节点,将其 next 指针指向 nil
  2. 更新 tail 指针指向倒数第二个节点。

操作前后对比:

删除前:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘
      ↑ head           ↑               ↑ tail
      │  index 0       │  index 1       │  index 2

删除后:

复制代码
    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │
    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘
      ↑ head           ↑ tail
      │  index 0       │  index 1

Go 代码示例

go 复制代码
current := n1
for current.Next != nil && current.Next.Next != nil {
    current = current.Next
}
if current.Next != nil {
    current.Next = nil
}
中间删:删除链表中间节点

需求 :删除值为 20 的节点。
操作

  1. 找到值为 20 的节点,将其 prev 节点的 next 指针指向 20next 节点。
  2. 20next 节点的 prev 指针指向 20prev 节点。

操作前后对比:

删除前:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    10   │    │    20   │    │    30   │
    │ next ──▶ │    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘    └─────────┘
      ↑ head           ↑               ↑ tail
      │  index 0       │  index 1       │  index 2

删除后:

复制代码
    ┌─────────┐    ┌─────────┐
    │    10   │    │    30   │
    │ next ──▶ │    │ next ──▶ nil
    │ prev ◀── │    │ prev ◀── │
    └─────────┘    └─────────┘
      ↑ head           ↑ tail
      │  index 0       │  index 1

Go 代码示例

go 复制代码
current := n1
for current != nil && current.Val != 20 {
    current = current.Next
}
if current != nil {
    if current.Prev != nil {
        current.Prev.Next = current.Next
    }
    if current.Next != nil {
        current.Next.Prev = current.Prev
    }
}

4. 改:直接修改节点值

需求 :将值为 20 的节点的值修改为 25
操作 :找到值为 20 的节点,直接修改其 Val 属性为 25

Go 代码示例

go 复制代码
current := n1
for current != nil && current.Val != 20 {
    current = current.Next
}
if current != nil {
    current.Val = 25
}

四、链表的优缺点(清晰对比)

优点
  • 增删高效:在链表中插入和删除节点时,仅需调整相关节点的指针,时间复杂度为 (O(1))(在已知插入或删除位置的前提下)。这使得链表在频繁进行增删操作的场景中表现出色,例如实现一个实时更新的任务列表,新任务的添加和完成任务的删除操作都能快速完成。
  • 内存利用灵活:链表不需要连续的内存空间,它可以充分利用内存中的碎片化空间。这对于内存资源紧张或者需要动态分配内存的场景非常有利。比如在嵌入式系统中,内存管理较为复杂,链表能够更有效地利用有限的内存。
缺点
  • 查找低效:链表只能通过顺序查找来定位特定元素,即从链表头开始,逐个节点比较,时间复杂度为 (O(n)),其中 (n) 是链表的长度。这与顺序表可以通过索引直接访问元素(时间复杂度 (O(1)))形成鲜明对比。例如在一个包含大量数据的链表中查找某个特定元素,可能需要遍历很长的节点序列,效率较低。
  • 额外空间开销:每个节点除了存储数据本身,还需要额外存储前驱和后继指针(对于双向链表),这增加了空间开销。在数据量较大时,这种额外的空间占用可能变得显著,对内存资源造成压力。

五、零基础总结(简洁记忆)

  1. 链表本质:链表是由节点通过指针连接而成的数据结构,节点在内存中分散存储,不具有顺序表那样的连续性 。
  2. 操作特性:增删操作通过修改指针迅速完成,效率高;而查找操作需从头遍历,效率低。
  3. 应用场景:适用于频繁进行增删操作,但对查找效率要求相对不高的场景;不适用于需要快速定位元素的场景。

六、链表与顺序表的深度对比

1. 内存布局

  • 顺序表 :顺序表在内存中是连续存储的,就像一排紧密相连的抽屉,每个抽屉存放一个元素。这种连续的存储方式使得顺序表可以通过数组下标直接访问元素,因为每个元素的内存地址可以通过首地址和偏移量快速计算得出。例如,若首地址为 base_addr,每个元素占用 size 字节,那么第 i 个元素的地址就是 base_addr + i * size
  • 链表:链表的节点在内存中是分散存储的,节点之间通过指针相连。每个节点不仅要存储数据元素本身,还要存储指向下一个节点(对于双向链表还包括指向前一个节点)的指针。这就好比是散落的珠子,通过线(指针)串在一起。由于节点的存储位置不连续,所以无法像顺序表那样通过索引直接定位元素。

2. 时间复杂度

  • 查找操作
    • 顺序表:对于顺序表的随机访问,时间复杂度为 (O(1)),因为可以直接通过索引计算出元素地址并访问。但如果是顺序查找特定元素,在最坏情况下,时间复杂度为 (O(n)),需要遍历整个顺序表。
    • 链表:链表只能进行顺序查找,从链表头开始逐个节点比较,所以查找特定元素的时间复杂度始终为 (O(n)),其中 (n) 是链表的长度。
  • 插入和删除操作
    • 顺序表:在顺序表中间插入或删除元素时,需要移动插入或删除位置之后的所有元素,以保持连续性。在最坏情况下,时间复杂度为 (O(n))。但如果是在顺序表末尾插入或删除元素,时间复杂度为 (O(1)),因为不需要移动其他元素。
    • 链表:在链表中插入或删除节点,只需修改相关节点的指针,时间复杂度为 (O(1))(前提是已经找到插入或删除位置的前驱节点)。如果要先查找插入或删除位置,则时间复杂度为 (O(n)),因为查找位置需要遍历链表。

3. 空间复杂度

  • 顺序表:顺序表的空间复杂度主要取决于元素个数 (n) 和每个元素占用的空间大小。如果预先分配了固定大小的空间,当元素个数未达到分配空间时,会存在一定的空间浪费。另外,在进行动态扩容时,也会有额外的空间开销(例如扩容时复制元素到新的更大空间)。总体空间复杂度为 (O(n))。
  • 链表:链表每个节点除了数据本身外,还需要额外存储指针,对于单向链表,每个节点需要额外存储一个指针,双向链表则需要额外存储两个指针。因此,链表的空间复杂度除了元素占用空间外,还包括指针占用的空间,也是 (O(n)),但相比顺序表,指针的额外空间开销在某些情况下可能不可忽视。

4. 实际应用场景

  • 顺序表
    • 适合需要频繁随机访问元素的场景,例如实现一个简单的学生成绩查询系统,已知学生的学号(类似索引),可以快速获取成绩。
    • 数据量相对固定,且对内存连续性有要求的场景,如一些图形处理算法中,需要对连续的像素点数据进行操作。
  • 链表
    • 频繁进行插入和删除操作的场景,如实现一个实时更新的任务队列,新任务不断加入(插入),已完成任务不断移除(删除)。
    • 内存资源有限且需要动态分配的场景,例如在嵌入式系统中,内存碎片化严重,链表能够更好地利用零散的内存空间。

题目加深理解

  1. 题目1 :设计一个音乐播放列表,用户可能随时添加新歌(插入操作),也可能随时删除已播放过的歌曲(删除操作),同时偶尔会根据歌曲编号查找特定歌曲。问使用顺序表还是链表更合适?
    • 答案:链表更合适。虽然偶尔需要根据编号查找歌曲,但插入和删除操作频繁,链表在插入和删除时具有 (O(1)) 的时间复杂度优势,能更好地满足需求。
  2. 题目2 :实现一个简单的图书管理系统,主要功能是根据图书编号快速查询图书信息(随机访问),并且图书数量基本固定。问使用顺序表还是链表更合适?
    • 答案:顺序表更合适。因为主要需求是根据编号快速查询图书信息,顺序表随机访问的时间复杂度为 (O(1)),能高效满足快速查询需求,且图书数量基本固定,不存在频繁的插入和删除操作。
  3. 题目3 :模拟一个实时的股票交易系统,不断有新的交易记录产生(插入),同时旧的交易记录会根据一定规则删除,并且很少会根据特定交易编号查询记录。问使用顺序表还是链表更合适?
    • 答案:链表更合适。系统特点是频繁的插入和删除操作,而查找操作很少,链表在插入和删除方面的高效性能够更好地适应这种场景。

七、总结

通过对链表顺序表的深度对比,我们详细探讨了两者在内存布局、时间复杂度、空间复杂度以及实际应用场景等多个维度的差异。

在内存布局上,顺序表连续存储,如同紧密排列的抽屉;链表则分散存储,类似散落却用线串起的珠子。时间复杂度方面,顺序表随机访问快,但插入删除在非末尾位置代价大;链表查找慢,插入删除操作若已知位置则效率高。空间复杂度上,两者虽都为O(n),但链表因指针存在额外开销。

这些差异决定了它们适用于不同场景:顺序表适合频繁随机访问与数据量固定场景;链表则在频繁增删与内存碎片化场景中表现出色。相信你已经对它们的差异和适用场景有了清晰的认识。

下一篇,我们将深入 Go 语言中的数组。在 Go 语言里,数组作为一种基础的数据结构,与我们前面讨论的顺序表和链表又有着怎样的联系与区别呢?Go 数组有哪些独特的特性和使用技巧?

关注我,点赞👍、收藏⭐本篇内容,我们一起在后续的博客中探索 Go 数组的奥秘。

相关推荐
yuweiade1 小时前
GO 快速升级Go版本
开发语言·redis·golang
深邃-3 小时前
【数据结构与算法】-二叉树(2):实现顺序结构二叉树(堆的实现),向上调整算法,向下调整算法,堆排序,TOP-K问题
数据结构·算法·二叉树·排序算法·堆排序··top-k
叼烟扛炮11 小时前
C++第二讲:类和对象(上)
数据结构·c++·算法·类和对象·struct·实例化
MegaDataFlowers14 小时前
206.反转链表
数据结构·链表
CN-Dust15 小时前
【C++】while语句例题专题
数据结构·c++·算法
geovindu16 小时前
go: Strategy Pattern
开发语言·设计模式·golang·策略模式
开发小程序的之朴17 小时前
基于Go语言的企业级CMS系统架构设计与性能分析——以AnQiCMS为例
开发语言·golang·系统架构
xieliyu.18 小时前
Java手搓数据结构:从零模拟实现无头双向非循环链表
java·数据结构·链表
初心未改HD19 小时前
Go语言net/http与Web开发:构建高性能HTTP服务
开发语言·golang