文章目录
- [Go 零基础数据结构:链表的增删改查(像串珠子一样简单)](#Go 零基础数据结构:链表的增删改查(像串珠子一样简单))
-
- 一、链表是什么?(独特的内存布局)
- 二、链表的核心特点(对比顺序表理解)
- 三、链表的增删改查操作(直观演示)
-
- [1. 查:顺序查找](#1. 查:顺序查找)
- [2. 增:分"头插"、"尾插"和"中间插"](#2. 增:分“头插”、“尾插”和“中间插”)
- [3. 删:分"头删"、"尾删"和"中间删"](#3. 删:分“头删”、“尾删”和“中间删”)
- [4. 改:直接修改节点值](#4. 改:直接修改节点值)
- 四、链表的优缺点(清晰对比)
- 五、零基础总结(简洁记忆)
- 六、链表与顺序表的深度对比
-
- [1. 内存布局](#1. 内存布局)
- [2. 时间复杂度](#2. 时间复杂度)
- [3. 空间复杂度](#3. 空间复杂度)
- [4. 实际应用场景](#4. 实际应用场景)
- 题目加深理解
- 七、总结
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 的节点。
操作:
- 创建新节点
5。 - 将新节点的
next指针指向原head节点(10)。 - 更新原
head节点的prev指针指向新节点。 - 将
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 的节点。
操作:
- 创建新节点
35。 - 将原
tail节点的next指针指向新节点。 - 将新节点的
prev指针指向原tail节点。 - 更新
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 的节点。
操作:
- 创建新节点
15。 - 找到值为
20的节点,将其prev节点的next指针指向新节点。 - 将新节点的
prev指针指向20的prev节点。 - 将新节点的
next指针指向值为20的节点。 - 更新值为
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)。
操作:
- 将
head指针指向原head节点的next节点。 - 更新新
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)。
操作:
- 找到链表的倒数第二个节点,将其
next指针指向nil。 - 更新
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 的节点。
操作:
- 找到值为
20的节点,将其prev节点的next指针指向20的next节点。 - 将
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. 内存布局
- 顺序表 :顺序表在内存中是连续存储的,就像一排紧密相连的抽屉,每个抽屉存放一个元素。这种连续的存储方式使得顺序表可以通过数组下标直接访问元素,因为每个元素的内存地址可以通过首地址和偏移量快速计算得出。例如,若首地址为
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 :设计一个音乐播放列表,用户可能随时添加新歌(插入操作),也可能随时删除已播放过的歌曲(删除操作),同时偶尔会根据歌曲编号查找特定歌曲。问使用顺序表还是链表更合适?
- 答案:链表更合适。虽然偶尔需要根据编号查找歌曲,但插入和删除操作频繁,链表在插入和删除时具有 (O(1)) 的时间复杂度优势,能更好地满足需求。
- 题目2 :实现一个简单的图书管理系统,主要功能是根据图书编号快速查询图书信息(随机访问),并且图书数量基本固定。问使用顺序表还是链表更合适?
- 答案:顺序表更合适。因为主要需求是根据编号快速查询图书信息,顺序表随机访问的时间复杂度为 (O(1)),能高效满足快速查询需求,且图书数量基本固定,不存在频繁的插入和删除操作。
- 题目3 :模拟一个实时的股票交易系统,不断有新的交易记录产生(插入),同时旧的交易记录会根据一定规则删除,并且很少会根据特定交易编号查询记录。问使用顺序表还是链表更合适?
- 答案:链表更合适。系统特点是频繁的插入和删除操作,而查找操作很少,链表在插入和删除方面的高效性能够更好地适应这种场景。
七、总结
通过对链表 和顺序表的深度对比,我们详细探讨了两者在内存布局、时间复杂度、空间复杂度以及实际应用场景等多个维度的差异。
在内存布局上,顺序表连续存储,如同紧密排列的抽屉;链表则分散存储,类似散落却用线串起的珠子。时间复杂度方面,顺序表随机访问快,但插入删除在非末尾位置代价大;链表查找慢,插入删除操作若已知位置则效率高。空间复杂度上,两者虽都为O(n),但链表因指针存在额外开销。
这些差异决定了它们适用于不同场景:顺序表适合频繁随机访问与数据量固定场景;链表则在频繁增删与内存碎片化场景中表现出色。相信你已经对它们的差异和适用场景有了清晰的认识。
下一篇,我们将深入 Go 语言中的数组。在 Go 语言里,数组作为一种基础的数据结构,与我们前面讨论的顺序表和链表又有着怎样的联系与区别呢?Go 数组有哪些独特的特性和使用技巧?
关注我,点赞👍、收藏⭐本篇内容,我们一起在后续的博客中探索 Go 数组的奥秘。