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

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

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

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

特点1:不能随机访问

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

特点2:增删高效

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

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

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 指针指向 20 的 prev 节点。
  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 指针指向 20 的 next 节点。
  2. 将 20 的 next 节点的 prev 指针指向 20 的 prev 节点。

操作前后对比:

删除前:

复制代码
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │    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 数组的奥秘。

相关推荐
蛋蛋的就会顺顺的40 分钟前
hot100——矩阵
java·数据结构·算法·leetcode·力扣
学代码的CJY2 小时前
从代码出发理解时间复杂度与空间复杂度
数据结构·算法
字节跳动的猫3 小时前
LikeShop 积分体系全链路改造:获取规则、订单抵扣与积分商品兑换二开
运维·数据结构
上位机妹子4 小时前
C 语言 数组删除指定元素(快慢指针法)
c语言·数据结构·算法
C++ 老炮儿的技术栈6 小时前
有符号变量与无符号变量的区别
服务器·开发语言·前端·数据结构·c++·算法·c
keyipatience7 小时前
跳表详细解析
开发语言·数据结构·c++·算法·跳表
念何架构之路8 小时前
go-grpc服务端调用
开发语言·后端·golang
小小、码农8 小时前
〖Linux文件系统〗:彻底打通文件 IO 全链路
linux·开发语言·数据结构·c++·系统
茉莉玫瑰花茶9 小时前
GO [ 单元测试 ]
golang·单元测试·log4j
INGNIGHT9 小时前
1897 · 会议室 3(扫描线)
数据结构·算法