如何在 Go 中实现各种类型的链表?

链表是动态内存分配中最常见的数据结构之一。它由一组有限的元素组成,每个元素(节点)至少占用两块内存:一块用于存放数据,另一块用于存放指向下一个节点的指针。本文教程将说明在 Go 语言中如何借助指针和结构体类型来实现各种链表

Go 中的数据结构

随机存取存储器(RAM)可以想象成一张由许多地址单元组成的表格、矩阵或网格。为了在这张表中存放数据,Go 程序员必须先把内存划分成可定位的结构,并为它们起一个便于识别的名字------变量名。需要注意的是,变量名只是为了方便程序员阅读;编译后,名字会被实际的内存引用(例如 0x78BA 这样的地址)替换

最简单的情况下,变量名只对应单个内存单元;复杂时,它可以代表一段连续空间,或是具有行和列的二维区域,也就是数组。数组可通过下标寻址,例如array_name[2][4]表示第二行第四列的元素。

再复杂一些,数据元素之间的结构关系可能并非连续存储,而是随机分布,比如用来表示层级关系的树、分支结构,或含有多重连接的复杂网络结构。

因此,为了存储这些结构化关系,Go 开发者必须根据具体需求自行设计内存布局与访问策略。

静态 vs. 动态内存分配

在内存分配中,有两个关键特性:静态和动态。

  • 静态数据结构的大小和存储位置在编译期就已确定;
  • 动态数据结构的大小和位置则未预定义,而是在运行时决定。

举例来说,当 Go 开发者声明一个数组时,需要提前给出固定长度,这样编译器才能在你使用下标时准确地定位内存地址。而在动态数据结构(例如链表)中,下一个数据节点的地址只有在程序执行、节点被创建时才会确定,因此整个结构可在运行期间自由增长或收缩。由于静态结构存放在连续内存中,元素呈线性排列;动态结构则无此限制。

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

  1. 存放实际数据的元素
  2. 指向下一节点的链接

顺序存储与链式存储对比

与顺序存储结构(如数组)不同,链式存储除了保存数据本身,还需要额外的内存来存放指向下一节点的链接。这在某些场景下会增加开销,但链式存储带来的灵活性通常更具优势。比如,数组的内存大小在创建时就固定,因此可能出现大量未被利用的空间;而链表只有在需要时才创建节点,不会浪费内存。

在链表中删除元素非常容易,而顺序存储往往要移动大量数据才能完成删除。同样,链表插入元素也很高效。不过,如果要随机访问某个位置的元素,顺序存储则更快。

两种存储方式各有利弊,Go 程序员应根据具体需求选择合适的数据结构。

链表的 4 种基本形态

链表在内存中的组织方式主要有四种:单向(线性)、循环、双向以及双向循环。

  • 单向(线性)链表:只有一个 next 指针指向下一个节点;最后一个节点的 nextnil。遍历时一旦遇到 nil 就表示到达链表末尾;
  • 循环链表:结构与单向链表相同,但最后一个节点的 next 指向头节点,因此尾部再向后访问就回到起点,可形成"环形"遍历;
  • 双向链表:每个节点同时拥有 prevnext 两个指针,分别指向前驱和后继节点。这样即可正向也可反向遍历,查找元素更灵活;
  • 双向循环链表:在双向链表的基础上,让尾节点的 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

二、循环单向链表

我们可以轻松地把单向链表转换为循环链表。无需修改上述代码,只需再添加两个函数:ConvertSinglyToCircularShowCircular,并在 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.headnil(空链表),此函数将发生空指针解引用错误并崩溃。

现在,在 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 语言中实现链表相当简单。链式分配可以用来表示各种类型的数据------无论是单个值,还是拥有众多字段的复杂数据结构。

当配合指针进行顺序查找时,访问速度非常快。与链表相关的优化技巧也有不少。与单向链表相比,双向链表效率更高,而且能够在两个方向上快速遍历。

  • 知识星球:云原生AI实战营。10+ 高质量体系课( Go、云原生、AI Infra)、15+ 实战项目,P8 技术专家助你提高技术天花板,入大厂拿高薪;
  • 公众号:令飞编程,分享 Go、云原生、AI Infra 相关技术。回复「资料」免费下载 Go、云原生、AI 等学习资料;
  • 哔哩哔哩:令飞编程 ,分享技术、职场、面经等,并有免费直播课「云原生AI高新就业课」,大厂级项目实战到大厂面试通关;
相关推荐
whaosoft-1439 分钟前
51c自动驾驶~合集37
人工智能
小技工丨16 分钟前
详解大语言模型生态系统概念:lama,llama.cpp,HuggingFace 模型 ,GGUF,MLX,lm-studio,ollama这都是什么?
人工智能·语言模型·llama
陈奕昆19 分钟前
大模型微调之LLaMA-Factory 系列教程大纲
人工智能·llama·大模型微调·llama-factory
上海云盾商务经理杨杨41 分钟前
AI如何重塑DDoS防护行业?六大变革与未来展望
人工智能·安全·web安全·ddos
AKAMAI1 小时前
迎难而上驾驭Kubernetes
云原生·kubernetes·云计算
一刀到底2111 小时前
ai agent(智能体)开发 python3基础8 网页抓取中 selenium 和 Playwright 区别和联系
人工智能·python
每天都要写算法(努力版)1 小时前
【神经网络与深度学习】改变随机种子可以提升模型性能?
人工智能·深度学习·神经网络
玄明Hanko1 小时前
从厨房到云端:从预制菜到云原生
云原生
阿里云云原生1 小时前
利用通义灵码和魔搭 Notebook 环境快速搭建一个 AIGC 应用 | 视频课
云原生·通义灵码
烟锁池塘柳01 小时前
【计算机视觉】三种图像质量评价指标详解:PSNR、SSIM与SAM
人工智能·深度学习·计算机视觉