链表是动态内存分配中最常见的数据结构之一。它由一组有限的元素组成,每个元素(节点)至少占用两块内存:一块用于存放数据,另一块用于存放指向下一个节点的指针。本文教程将说明在 Go 语言中如何借助指针和结构体类型来实现各种链表
Go 中的数据结构
随机存取存储器(RAM)可以想象成一张由许多地址单元组成的表格、矩阵或网格。为了在这张表中存放数据,Go 程序员必须先把内存划分成可定位的结构,并为它们起一个便于识别的名字------变量名。需要注意的是,变量名只是为了方便程序员阅读;编译后,名字会被实际的内存引用(例如 0x78BA
这样的地址)替换
最简单的情况下,变量名只对应单个内存单元;复杂时,它可以代表一段连续空间,或是具有行和列的二维区域,也就是数组。数组可通过下标寻址,例如array_name[2][4]
表示第二行第四列的元素。
再复杂一些,数据元素之间的结构关系可能并非连续存储,而是随机分布,比如用来表示层级关系的树、分支结构,或含有多重连接的复杂网络结构。
因此,为了存储这些结构化关系,Go 开发者必须根据具体需求自行设计内存布局与访问策略。
静态 vs. 动态内存分配
在内存分配中,有两个关键特性:静态和动态。
- 静态数据结构的大小和存储位置在编译期就已确定;
- 动态数据结构的大小和位置则未预定义,而是在运行时决定。
举例来说,当 Go 开发者声明一个数组时,需要提前给出固定长度,这样编译器才能在你使用下标时准确地定位内存地址。而在动态数据结构(例如链表)中,下一个数据节点的地址只有在程序执行、节点被创建时才会确定,因此整个结构可在运行期间自由增长或收缩。由于静态结构存放在连续内存中,元素呈线性排列;动态结构则无此限制。

众多动态数据结构的基础------虽然动态分配并不限于此------就是链表。链表的各数据节点散布在内存的任意位置,通过指针相互连接。因此,一个链表节点至少包含两部分:
- 存放实际数据的元素
- 指向下一节点的链接

顺序存储与链式存储对比
与顺序存储结构(如数组)不同,链式存储除了保存数据本身,还需要额外的内存来存放指向下一节点的链接。这在某些场景下会增加开销,但链式存储带来的灵活性通常更具优势。比如,数组的内存大小在创建时就固定,因此可能出现大量未被利用的空间;而链表只有在需要时才创建节点,不会浪费内存。
在链表中删除元素非常容易,而顺序存储往往要移动大量数据才能完成删除。同样,链表插入元素也很高效。不过,如果要随机访问某个位置的元素,顺序存储则更快。
两种存储方式各有利弊,Go 程序员应根据具体需求选择合适的数据结构。
链表的 4 种基本形态
链表在内存中的组织方式主要有四种:单向(线性)、循环、双向以及双向循环。
- 单向(线性)链表:只有一个
next
指针指向下一个节点;最后一个节点的next
为nil
。遍历时一旦遇到nil
就表示到达链表末尾; - 循环链表:结构与单向链表相同,但最后一个节点的
next
指向头节点,因此尾部再向后访问就回到起点,可形成"环形"遍历; - 双向链表:每个节点同时拥有
prev
与next
两个指针,分别指向前驱和后继节点。这样即可正向也可反向遍历,查找元素更灵活; - 双向循环链表:在双向链表的基础上,让尾节点的
next
指向头节点,头节点的prev
指向尾节点,于是可以向前或向后进行环形遍历。
从单向到双向、从线性到循环,链表的灵活性依次增强。下面的示例将演示在 Go 中实现这几种链表(示例仅涵盖链表的创建与遍历,以保持简洁)。
一、单向链表示例
下面是一个在 Go 中创建单向链表的示例:
go
package main
import (
"fmt"
"math/rand"
)
type Node struct {
info interface{}
next *Node
}
type List struct {
head *Node
}
func (l *List) Insert(d interface{}) {
node := &Node{info: d}
if l.head == nil {
l.head = node
return
}
p := l.head
for p.next != nil {
p = p.next
}
p.next = node
}
func Show(l *List) {
for p := l.head; p != nil; p = p.next {
fmt.Printf("-> %v ", p.info)
}
}
func main() {
sl := List{}
for i := 0; i < 5; i++ {
sl.Insert(rand.Intn(100))
}
Show(&sl)
}
示例输出:
plain
-> 81 -> 87 -> 47 -> 59 -> 81
二、循环单向链表
我们可以轻松地把单向链表转换为循环链表。无需修改上述代码,只需再添加两个函数:ConvertSinglyToCircular
和 ShowCircular
,并在 main
函数中调用它们即可。以下是这两个函数:
go
func ConvertSinglyToCircular(l *List) {
if l.head == nil {
return
}
p := l.head
for p.next != nil {
p = p.next
}
p.next = l.head
}
func ShowCircular(l *List) {
p := l.head
for {
fmt.Printf("-> %v ", p.info)
if p.next == l.head {
break
}
p = p.next
}
}
注意:虽然假设该链表已经是循环的(即 p.next
最终会指回 l.head
),但如果 l.head
为 nil
(空链表),此函数将发生空指针解引用错误并崩溃。
现在,在 main
函数中按如下方式调用这两个函数:
go
func main() {
sl := List{}
for i := 0; i < 5; i++ {
sl.Insert(rand.Intn(100))
}
ConvertSinglyToCircular(&sl)
ShowCircular(&sl)
}
三、双向链表示例
下面是一个演示如何在 Go 中创建双向链表的代码示例:
go
package main
import (
"fmt"
"math/rand"
"time"
)
type Node struct {
info interface{}
prev *Node
next *Node
}
type List struct {
head *Node
tail *Node
}
func (l *List) Insert(d interface{}) {
node := &Node{info: d}
if l.head == nil {
l.head, l.tail = node, node
return
}
l.tail.next = node
node.prev = l.tail
l.tail = node
}
func Show(l *List) {
for p := l.head; p != nil; p = p.next {
fmt.Printf("-> %v ", p.info)
}
}
func ReverseShow(l *List) {
for r := l.tail; r != nil; r = r.prev {
fmt.Printf("-> %v ", r.info)
}
}
func main() {
sl := List{}
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < 10; i++ {
sl.Insert(rnd.Intn(100))
}
Show(&sl)
fmt.Println("\n----------------------------")
ReverseShow(&sl)
}
示例输出:
bash
-> 11 -> 17 -> 56 -> 71 -> 39 -> 44 -> 18 -> 78 -> 25 -> 19
----------------------------
-> 19 -> 25 -> 78 -> 18 -> 44 -> 39 -> 71 -> 56 -> 17 -> 11
四、双向循环链表
与循环链表类似,双向循环链表也可以很容易地由双向链表转换而来。我们只需在上述代码中再添加两个函数即可。其余代码保持不变,只需在 main 函数中进行轻微修改,就像在前面的循环链表示例中所做的那样:
go
func ConvertDoublyToDoublyCircular(l *List) {
if l.head == nil || l.tail == nil {
return
}
l.head.prev = l.tail
l.tail.next = l.head
}
func ShowDoublyCircular(l *List) {
p := l.head
for {
fmt.Printf("-> %v ", p.info)
if p.next == l.head {
break
}
p = p.next
}
}
func ReverseShowDoublyCircular(l *List) {
r := l.tail
for {
fmt.Printf("-> %v ", r.info)
if r.prev == l.tail {
break
}
r = r.prev
}
}
main
示例:
go
func main() {
sl := List{}
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < 10; i++ {
sl.Insert(rnd.Intn(100))
}
ConvertDoublyToDoublyCircular(&sl)
ShowDoublyCircular(&sl)
fmt.Println("\n----------------------------")
ReverseShowDoublyCircular(&sl)
}
关于在 Go 中实现链表的最后思考
正如我们所见,在 Go 语言中实现链表相当简单。链式分配可以用来表示各种类型的数据------无论是单个值,还是拥有众多字段的复杂数据结构。
当配合指针进行顺序查找时,访问速度非常快。与链表相关的优化技巧也有不少。与单向链表相比,双向链表效率更高,而且能够在两个方向上快速遍历。