扫二维码与项目经理沟通
我们在微信上24小时期待你的声音
解答本文疑问/技术咨询/运营咨询/技术建议/互联网交流
Go语言的链表实现在其标准库的container/list代码包中。
这个包包含了2个程序实体:
移动链表里的元素:
func (l *List) MoveBefore(e, mark *Element) // 把元素e移动到mark元素的前面
func (l *List) MoveAfter(e, mark *Element) // 把元素e移动到mark元素的后面
func (l *List) MoveToFront(e *Element) // 把元素e移动到链表的最前端
func (l *List) MoveToBack(e *Element) // 把元素e移动到链表的最后端
上面的方法都是调整链表l里元素的位置,e和mark原本都是链表里的元素,执行方法后,只是调整元素e在链表中的位置。所以操作前后链表里包含的元素并不会有差别,只是e元素的位置可能变化了。
添加元素
链表里的元素都是*Element类型。List包中那些用于插入新元素的方法都只接收interface{}类型的值。这个方法内部都会用Element包装接收到的新元素:
func (l *List) InsertBefore(v interface{}, mark *Element) *Element // 在mark元素之前插入行元素
func (l *List) InsertAfter(v interface{}, mark *Element) *Element // 在mark元素之后插入行元素
func (l *List) PushFront(v interface{} *Element) *Element // 在链表的最前端添加新元素
func (l *List) PushBack(v interface{} *Element) *Element // 在链表的最后端添加新元素
上面的方法都会返回一个指针*Element。也就是插入元素的*Element类型。
获取元素
通过链表,可以直接取到链表头尾的元素,这是一个双向链表。然后有了链表中的某个元素之后,就可以拿到该元素前一个或后一个元素了:
func (l *List) Front() *Element // 获取到链表中最前端的元素
func (l *List) Back() *Element // 获取到链表中最后端的元素
func (e *Element) Next() *Element // 获取当前元素的下一个元素
func (e *Element) Prev() *Element // 获取当前元素的前一个元素
下面是官方文档里的List类型的描述,隐藏了私有字段:
type List struct {
// contains filtered or unexported fields
}
List这个结构体类型有两个字段,一个是Element类型的字段root,代码链表的根元素;另一个是int类型的字段len,代表链表的长度。都是包级私有的,我们无法查看和修改它们。
下面是Element类型的描述,同样的隐藏了私有字段:
type Element struct {
// The value stored with this element.
Value interface{}
// contains filtered or unexported fields
}
Element类型里分别有一个用于存储前一个元素和后一个元素以及所属链表的指针值。另外还有一个公开字段Value,就是元素的值。
延迟初始化机制
所谓延迟初始化,你可以理解为把初始化操作延后,仅在实际需要的时候才进行。延迟初始化的优点在于“延后”,它可以分散初始化操作带来的计算量和存储空间消耗。
然而,延迟初始化的缺点恰恰也在于“延后”。如果在调用链表的每个方法的时候,都需要去判断链表是否已经被初始化的话,那么也是一个计算量上的浪费。
在这里的链表的实现中,一些方法是无需对是否初始化做判断的。比如:
Front和Back方法,一旦发现链表的长度为0,就可以直接返回nil。
删除、移动、删除链表元素时,判断一下传入元素中的所属链表的指针,是否与当前链表的指针相同。相等,就说明这个链表已经被初始化了,否则说明元素在不要操作的链表中,那么就直接返回。
上面的操作,应该都是要链表是已经完成初始化的,但是未初始化过的链表,通过上面的机制,也能正确返回。这样初始化的操作就可以只在必要的时候才进行,比如:
PushFront、PushBack、PushBackList、PushFrontList,这些方法,会先判断链表的动态。如果没有初始化,就进行初始化。
所以,List利用了自身,以及Element在结构上的特点,平衡了延迟初始化的优缺点。
在Go标准库的container/ring包中的Ring类型实现的是一个循环链表。
type Ring struct {
Value interface{} // for use by client; untouched by this library
// contains filtered or unexported fields
}
其实List在内部就是一个循环链表。它的根元素永远不会持有任何实际的元素值,而该元素的存在,就是为了连接这个循环链表的首尾两端。
所以,List的零值是一个只包含了根元素,但不包含任何实际元素值的空链表。
说List在内部就是一个循环链表,是它设计的逻辑,这个在最后我去源码里看了一下。这里并不是指List是通过这里的container/ring包实现的。而是List本身其实也是一个循环链表的结构,Ring是Go提供的一个实现循环链表的标准库,Ring本身当然也是一个循环链表的结构。
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)的。这是两者在性能方面最显而易见的差别。
关于上的len方法,因为List的结构体里直接就记了表示长度的私有字段len。而Ring不像List那样有一个表示整个链表的结构体。两个包里的len方法的源码如下:
// src/container/ring/ring.go
func (r *Ring) Len() int {
n := 0
if r != nil {
n = 1
for p := r.Next(); p != r; p = p.next {
n++
}
}
return n
}
// src/container/list/list.go
func (l *List) Len() int { return l.len }
上面先讲了链表,并且展开了链表的一些使用技巧和实现特点。由于链表本身内部就是一个循环链表。所以又和container/ring包中的循环链表做了一番比较。
另外,container一共有3个子包,上面讲到了2个,还有一个是container/heap,就是堆。
关于List内部就是一个循环链表的问题,自己又去源码里探究了一番。
下面是Init方法,把root元素的下一个元素和前一个元素都指向自己,形成了一个环。并且把长度字段len设置成0:
func (l *List) Init() *List {
l.root.next = &l.root
l.root.prev = &l.root
l.len = 0
return l
}
虽然List本质是个环,但是使用的时候,不是环而是有头和尾的一条链。在获取下一个元素的时候,如果到了最后端,那么next的下一个元素就是root元素。这时不返回root,而是返回nil。这就是根元素不持有任何元素,只是连接链表的首尾两端:
func (e *Element) Next() *Element {
if p := e.next; e.list != nil && p != &e.list.root {
return p
}
return nil
}
字典(map)里存储的是键值对。在Go语言了,为了避免歧义,换了一种称呼,叫“键-元素 对”。
Go语言的字典类型其实是一个哈希表(hash table)的特定实现。在这个实现中,键和元素的大不同在于,键的类型是受限的,而元素可以是任意的类型。
键的类型不可以是函数类型、字典类型和切片类型。
键类型的值之间必须可以施加操作符==和!=,就是支持判等操作。上述三种类型的值不支持判等操作。
如果键的类型是接口类型,那么键值(这里是键的值,就是会引起歧义的地方,好在我们已经把原来的值的叫法改成元素了)的实际类型也不能是上述三种类型。
package main
import "fmt"
func main() {
var badMap = map[interface{}]int{
"one": 1,
2: 2,
[1]int{3}: 3, // 数组是合法的键
// []int{4}: 4, // 切片不能作为键,加上这句会Panic
}
fmt.Println(badMap)
}
像上面这样,键的类型为空接口interface{},是合法的键。但是如果这个空接口实际的值类型是无法作为键的类型也是不行的。并且这种情况编译器无法检查到。或者说,通过这样的声明躲过了编译器的检查。最终在运行的时候是会崩溃的(Panic)。
由于会有上面这种可以躲过编译器检查的方法,最好不要把字典的key设定为任何借口类型。如果一定要这么做,那么就尽量确保代码可可控的范围内。
同样的道理,如果键的类型是数组类型,也要确保数组了的元素的类型不是函数、字典或切片。
结构体类型也可以作为键,同样要确保结构体中的所有的字段都是合法的类型。
下面的操作只是声明一个字典,并未做初始化:
var m1 map[string]string
这里要讨论一个字典未做初始化的问题。字典是引用类型,所以只做了声明而没有初始化的时候,它的值是nil。
在这样的一个值为nil的字典上,除了添加元素以外,做其他任何操作都不会引起错误。比如,可以查找字典里是否有某个键、删除某个键、或者获取长度。
如果要添加元素,就要先对字典做初始化,否则会抛出Panic。
另外有需要云服务器可以了解下创新互联scvps.cn,海内外云服务器15元起步,三天无理由+7*72小时售后在线,公司持有idc许可证,提供“云服务器、裸金属服务器、高防服务器、香港服务器、美国服务器、虚拟主机、免备案服务器”等云主机租用服务以及企业上云的综合解决方案,具有“安全稳定、简单易用、服务可用性高、性价比高”等特点与优势,专为企业上云打造定制,能够满足用户丰富、多元化的应用场景需求。
我们在微信上24小时期待你的声音
解答本文疑问/技术咨询/运营咨询/技术建议/互联网交流