双向链表
在 Go 语言的 SDK 中其实已经帮助我们实现了双向链表,该包位于 container/list 中。我们可以看看官方的大佬是怎么写双向链表的:
type Element struct {
next, prev *Element
// The list to which this element belongs.
list *List
// The value stored with this element.
Value interface{}
}首先是定义链表中的节点, 他包含了指向前后两个元素的指针、存放的数据(Value)还有一个指向链表自身的指针。
我们再看看链表自身是个什么样子:
type List struct {
root Element
len int
}
func (l *List) Init() *List {
l.root.next = &l.root
l.root.prev = &l.root
l.len = 0
return l
}
// 判断初始化函数,如果没有被初始化就立即初始化
func (l *List) lazyInit() {
if l.root.next == nil {
l.Init()
}
}
func New() *List { return new(List).Init() }
func (l *List) Len() int { return l.len }
// 获取链表表头元素
func (l *List) Front() *Element {
if l.len == 0 {
return nil
}
return l.root.next
}
// 获取链表表尾元素
func (l *List) Back() *Element {
if l.len == 0 {
return nil
}
return l.root.prev
}我们可以发现 List 这个链表结构其实内部只有两个东西,一个 “前哨” (root)存放了对应的元素和整个链表的长度值。在链表调用初始化函数的时候,我们会发现,它把链表前哨元素的前后指针指向自己,作为一个初始化的流程。这里比较有意思的一点是,这个"前哨"的功能。
你会发现,前哨(root)在初始化的时候并没有给对应的 Value 赋值,开发者的本意其实是利用前哨来存储整个链表的表头和表尾(只用 root 的 prev 和 next 指针)。
前哨的前驱其实是链表的表头,前哨的后继其实是链表的表尾,root 的 prev 存放表尾, next 存放表头。
我们再回头看看之前的节点的两个方法实现:
我们就明白他这里的处理是为什么了,还是要加层 p != &e.list.root 判断,因为List在初始化的时候会把前哨的前后指针指向自己。也就是说,如果当前元素的前后指针指向了 List 的前哨了,也就表示当前节点不存在前驱元素或者是后继元素。
Push 操作
在此之前我们先看两个插入函数:
两个 Push 操作分别表示在头部插入和在尾部插入:
比如在表头插入元素, root本身的后继就是链表头,所以直接对 root 操作就可以了。
这里还会使用 lazyInit 来判断链表有没有被初始化过,如果没有就立即初始化。
其他设计的插入操作也大多殊途同归,都是对 insertValue 方法的封装。
Move 操作
删除操作
总结
通过上面的几种方法,大致对官方写的 List 有了一定的了解,其实很多地方都能看出官方开发者的语言功底,比如 Front 方法和 Back 方法,一旦发现链表的长度为 0, 直接返回 nil 。
在用于删除元素、移动元素,以及一些用于插入元素的方法中,只要判断一下传入的元素中指向所属链表的指针,是否与当前链表的指针相等就可以了。这样做的可以快速判断当前操作节点是否在链表内部,且是否被初始化过(lazyInit)。
而且很多地方做了 lazyInit(很多地方也叫:延迟初始化),即在必要时才初始化链表(申请内存资源),因为 Go 本身也不知道你存的数据到底是个什么东西,如果存放的值很大,那么一锅脑初始化可能会花费很多时间,而且为了避免声明出来没人使用的尴尬局面,所以通过 lazyInit去分散初始化的操作带来的计算量和存储空间消耗。
比如集中声明非常多的大容量切片, CPU 和内存空间的使用量肯定都会一个激增,并且只有设法让其中的切片及其底层数组被回收,内存使用量才会有所降低。所以把初始化分散再配合 GC 可以更好的利用内存资源同时减少CPU压力。
其实,Go SDK 实现的这个链表不仅仅是双向链表,也可以看成是循环链表 List 的 root 元素恰好把链表的对头和队尾元素连起来,但是 root 本身并不存储元素,它仅仅是个“前哨”。
最后更新于