『Go核心36讲』| 03 - Container 包中的那些容器
Contents
Six interesting containers in Golang
- From pkg/container
- container/list
- container/ring
- container/heap
- Built in
- map
- slice
- Channels as quenues
Container
Container — 容器数据类型:该包实现了三个复杂的数据结构:堆、链表、环
List
Go 语言标准库的 container/list 代码包提供的对 链表 的实现。 这个代码包中有两个公开的程序实体: List
和 Element
, List 实现了一个双向链表,而 Element 则代表了链表中元素的结构。
源码定义
|
|
Demo Code:
|
|
方法集
List 对应的方法:
|
|
问题:可以把自己生成的 Element 类型的值传给链表吗?
- 这里给出一个 典型回答:不会接受,这些上述的方法将不会对链表做出任何改动。 因为我们自己生成的 Element 值并不在链表中,所以也就谈不上 “在链表中移动元素”。更何况链表不允许我们把自己生成的 Element 值插入其中。
- 问题解析:在 List 包包含的方法中,用于插入新元素的那些方法都只接受 interface{} 类型的值。这些方法在内部会使用 Element 值,包装接收到的新元素。 这样做正是为了避免直接说使用我们自己生成的元素,主要原因是避免链表的内部关联,遭到外界的破坏,这对于链表本身以及我们这些使用者来说都是有益的。
扩展阅读:
- Wiki: 链表(Linked list):是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而顺序表相应的时间复杂度分别是O(logn)和O(1)。
- Wiki: 双向链表:又称为双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。一般我们都构造双向循环链表。
知识扩展
问题1:为什么链表可以做到开箱即用?
List 和 Element 都是结构体类型。结构体类型有一个特点,那就是它们的零值都会拥有特定结构,但是没有任何定制化内容的值,相当于一个空壳。值中的字段也都会被分别赋予各自类型的零值。
那么,经过语句
var l list.List
声明的变量 l 的值将会是什么呢?
- 这个零值将会是一个长度为 0 的链表,这个链表持有的根元素也将会是一个空壳,其中只会包含缺省的内容。那,这样的链表我们可以直接拿来使用吗?
- 答案是:可以的。这被称为
开箱即用
。 这种通过语句var l list.List
声明的链表 l 可以直接使用的原因就是在于它的延迟初始化
机制。
延迟初始化
所谓的 延迟初始化:你可以理解为把初始化操作延后,仅在实际需要的时候才进行。延迟初始化的优点在于 “延后”,它可以分散初始化操作带来的计算量和存储空间消耗。
延迟初始化的缺点恰恰也在于“延后”。你可以想象一下,如果我在调用链表的每个方法的时候,它们都需要先去判断链表是否已经被初始化,那这也会是一个计算量上的浪费。在这些方法被非常频繁地调用的情况下,这种浪费的影响就开始显现了,程序的性能将会降低。
Some links:
- Go 标准库中文文档: container/list
- golang使用sync保证container/list链表线程安全
- Go源码学习之双向链表
- Golang container list Example (Linked List)
Ring
container/ring 包中的 Ring 类型实现的是一个循环链表,也就是我们俗称的环。其实 List 在内部就是一个循环链表。它的根元素永远不会持有任何实际的元素值,而该元素的存在就是为了连接这个循环链表的首尾两端。
所以,也可以说:List 的零值是一个只包含了根元素,但不包含任何实际元素值的空链表。那么,既然 Ring 和 List 的本质上都是循环链表,它们到底有什么不同呢?
Ring 和 List 的不同有以下几种:
- Ring 类型的数据结构仅由它自身即可代表,而 List 类型则需要由它以及 Element 类型联合表示。这是表示方式上的不同,也是结构复杂度上的不同。
- 一个 Ring 类型的值严格来讲,只代表了其所属的循环链表中的一个元素,而一个 List 类型的值则代表了一个完整的链表。这是表示维度上的不同。
- 在创建并初始化一个 Ring 值得时候,我们可以指定它包含的元素数量,但是对于一个 List 值来说却不能这样做(也没必要这样做)。循环链表一旦被创建,其长度是不可变的。这是两个代码包中 New 函数在功能上的不同,也是两个类型在初始化值方面的第一个不同
- 仅通过 var r ring.Ring 语句声明的 r 将会是一个长度为 1 的循环链表,而 List 类型的零值则是一个长度为 0 的链表。别忘了,List 中的根元素不会持有实际元素的值,因此计算长度时不会包含它。这是两个类型在初始化值方面的第二个不同。
- Ring 值得 Len 方法的算法复杂度是 O(N) 的,而 List 值得 Len 方法的算法复杂度是 O(1)的。这是两者在性能方面最显而易见的差别。
源码定义
|
|
我们初始化环的时候,需要定义好环的大小,然后对环的每个元素进行赋值。环还提供了一个 Do 方法,能遍历一边环,对每个元素执行一次 func。一个 Demo 如下:
|
|
方法集
Ring 包含的方法:
|
|
通过 Next & Prev 方法也可以对一个 ring 进行便利,首先保存当前节点,然后依次访问下一个节点,直到回到其实节点,Demo 如下:
|
|
Heap
container/heap 中堆使用的数据结构是最小二叉树,即根节点比左边子树和右边子树的所有值都要小。 Go 的堆包只是实现了一个接口,其定义如下:
|
|
可以看出,这个堆结构继承自 sort.Interface,回顾下 sort.Interface, 它需要实现三个方法
- Len() int
- Less(i, j int) bool
- Swap(i, j int)
加上堆接口定义的两个方法
- Push(x interface{})
- Pop() interface{}
就是说你定义了一个堆,就要实现 5 个方法,package doc 的 example 例子:
思考题
- container/ring 包中的循环链表的适用场景都有哪些?
- 你使用过 container/heap 包中的堆吗? 它的适用场景又有那些呢?
精选留言:
Somebody A:
- list可以作为queue和 stack 的基础数据结构
- ring可以用来保存固定数量的元素,例如保存最近100条日志,用户最近10次操作
- heap可以用来排序。游戏编程中是一种高效的定时器实现方案
Somebody B:
- list的一个典型应用场景是构造FIFO队列;
- ring的一个典型应用场景是构造定长环回队列,比如网页上的轮播;
- heap的一个典型应用场景是构造优先级队列
See Also
Thanks to the authors 🙂