前言
我记得曾经有一次参加面试时,在答题过程中非常嘴欠地说了一句:“我之所以代码这么写,因为在 golang 中没有内置的无限长度 queue 的实现……”,当时说完我就后悔了,面试我的人,前几个问题本就有那么几分刻薄,一听这一句,立马就来劲了:“谁说没有?谁说没有?”
好在我连连改口,巧舌如簧,搪塞了过去。我明白,在大牛们的手里,只需最简单的数组,稍微加几个“简单的”函数,摇身一变,就可以成为“现成的”队列、栈、环、二叉树、图……
我不仅不是大牛,我还是个懒汉,我希望的内置的队列,是拿过来就能 dequeue、enqueue 的工具,但 golang 的标准库里并没有名字叫 queue 的内置对象,只用 channel 可以在某些时候当队列来用,但是一个 channel 是有长度限制的,超过 channel 的长度了还继续入队会触发阻塞,举个例子:二叉树的逐层遍历,第一想到的是用队列去做吧?但你不知道二叉树的尺寸,你敢用 channel 怼上去吗?
网上有很对文章展示了如何自己实现一个队列,看起来似乎也没啥问题,但我说了我很懒,我不想自己手写一个队列。你说可以把网上的代码复制粘贴过来?那我们猜个谜,问:什么实现最有可能需要实现逐层遍历二叉树?
答案是:面试的时候。那你是要我面试的时候把把网上的代码复制粘贴过来吗?
……
虽然 golang 没有内置的 queue,但它还是提供了很多强大的数据结构,那有没有可以直接拿过来当 queue 来使的呢?有的,且至少有两个选项。强调一句,我们这里并不讨论线程安全队列,仅是普通的无容量限制队列。
拿 slice 当 queue
我敢打赌,slice 是你在 golang 中用到的最频繁的数据结构了,它基于底层数组实现的,但又比数组要强大的多。拿来当队列用,slice 是完全能胜任的。
初始化一个队里:
var queue []int
入队一个元素:
queue = append(queue, 1)
出队一个元素:
if len(queue) > 1 {
queue = queue[1:]
}
看吧,简单明了,易学易用。
但你会不会有一点觉得 queue = queue[1:] 这样的写法有一点挫?白白浪费掉了 slice 前端元素的内存,和队列的假溢出问题有点像,当然 slice 会自动扩容,并不会发生假溢出,且每次扩容时会重新开辟新数组,拷贝元素到新数组时并不会拷贝被弃用的元素,所以内存的浪费还是在可控的范围内的。我们可以跑一个例子加以说明:
// 一个空的 cap 为 5 的 slice
queue := make([]int, 0, 5) // _ _ _ _ _
fmt.Printf("len(queue): %v, cap(queue): %v, queue: %v\n", len(queue), cap(queue), queue)
// 输出 len(queue): 0, cap(queue): 5, queue: []
// 入队一个元素
queue = append(queue, 1) // [1] _ _ _ _
fmt.Printf("len(queue): %v, cap(queue): %v, queue: %v\n", len(queue), cap(queue), queue)
// 输出 len(queue): 1, cap(queue): 5, queue: [1]
// 再入队一个元素
queue = append(queue, 2) // [1 2] _ _ _
fmt.Printf("len(queue): %v, cap(queue): %v, queue: %v\n", len(queue), cap(queue), queue)
// 输出 len(queue): 2, cap(queue): 5, queue: [1 2]
// 出队一个元素
queue = queue[1:] // 1 [2] _ _ _
fmt.Printf("len(queue): %v, cap(queue): %v, queue: %v\n", len(queue), cap(queue), queue)
// 输出 len(queue): 1, cap(queue): 4, queue: [2]
// 再入队三个元素
queue = append(queue, 3, 4, 5) // 1 [2 3 4 5]
fmt.Printf("len(queue): %v, cap(queue): %v, queue: %v\n", len(queue), cap(queue), queue)
// 输出 len(queue): 4, cap(queue): 4, queue: [2 3 4 5]
// 出队二个元素
queue = queue[2:] // 1 2 3 [4 5]
fmt.Printf("len(queue): %v, cap(queue): %v, queue: %v\n", len(queue), cap(queue), queue)
// 输出 len(queue): 2, cap(queue): 2, queue: [4 5]
// 再入队一个元素,触发 slise 的扩容,根据扩容策略,此时 slice cap 为 2,翻倍为 4
queue = append(queue, 6) // // [4 5 6] _
fmt.Printf("len(queue): %v, cap(queue): %v, queue: %v\n", len(queue), cap(queue), queue)
// 输出 len(queue): 3, cap(queue): 4, queue: [4 5 6]
不过你可以看到了,当不断地入队出队,slice 的需要反复扩容,这也可能造成一定的 CPU 消耗。且 queue = queue[1:] 这样的写法未免也太简单粗暴了一点,有没有更高大上的做法呢,我们且看另一种可以拿来当队列用的数据结构。
拿 list 当 queue
这里的 list 指的是 golang 内置的包 container/list,是这是一个双向链表的实现,如果你不了解它,可以花几分钟看一下这篇 《container — 容器数据类型:heap、list和ring》,这里复制粘贴一下,把 list 对应的方法列举一下:
type Element
func (e *Element) Next() *Element
func (e *Element) Prev() *Element
type List
func New() *List
func (l *List) Back() *Element // 最后一个元素
func (l *List) Front() *Element // 第一个元素
func (l *List) Init() *List // 链表初始化
func (l *List) InsertAfter(v interface{}, mark *Element) *Element // 在某个元素后插入
func (l *List) InsertBefore(v interface{}, mark *Element) *Element // 在某个元素前插入
func (l *List) Len() int // 在链表长度
func (l *List) MoveAfter(e, mark *Element) // 把e元素移动到mark之后
func (l *List) MoveBefore(e, mark *Element) // 把e元素移动到mark之前
func (l *List) MoveToBack(e *Element) // 把e元素移动到队列最后
func (l *List) MoveToFront(e *Element) // 把e元素移动到队列最头部
func (l *List) PushBack(v interface{}) *Element // 在队列最后插入元素
func (l *List) PushBackList(other *List) // 在队列最后插入接上新队列
func (l *List) PushFront(v interface{}) *Element // 在队列头部插入元素
func (l *List) PushFrontList(other *List) // 在队列头部插入接上新队列
func (l *List) Remove(e *Element) interface{} // 删除某个元素
当然我们是不用关心这么多方法的,拿来当队列用的话,还是很简单的:
初始化一个队里:
queue := list.New()
入队一个元素:
queue.PushBack(1)
出队一个元素:
if queue.Len() > 1 {
queue.Remove(queue.Front())
}
queue.Remove(queue.Front()) 比 queue = queue[1:] 看起来高大上多了有没有?由于是基于链表的,自然也没有内存浪费或假溢出的情况,你是不是已经决定好了,以后要用队列就用它了。
由于不需要像 slice 那样在扩容时大量拷贝元素,list 作为队列一定比 slice 性能好得多,难道不是吗?
性能对比
且慢,口说无凭,我们做个性能测试吧。
测试代码我已经写好了,测试逻辑是这样的,每次往队列里入队两个元素,再出队一个元素,保持队列的中元素数量持续增长。且看代码:
import (
"container/list"
"testing"
)
func BenchmarkListQueue(b *testing.B) {
q := list.New()
for i := 0; i < b.N; i++ {
q.PushBack(1)
q.PushBack(1)
q.Remove(q.Front())
}
}
func BenchmarkSliceQueue(b *testing.B) {
var q []int
for i := 0; i < b.N; i++ {
q = append(q, 1)
q = append(q, 1)
q = q[1:]
}
}
运行结果:
$ go test -bench=. -benchmem -count=3
goos: darwin
goarch: amd64
pkg: tinylib/queue/queue_test
BenchmarkListQueue-12 10000000 144 ns/op 96 B/op 2 allocs/op
BenchmarkListQueue-12 10000000 208 ns/op 96 B/op 2 allocs/op
BenchmarkListQueue-12 10000000 169 ns/op 96 B/op 2 allocs/op
BenchmarkSliceQueue-12 100000000 25.8 ns/op 82 B/op 0 allocs/op
BenchmarkSliceQueue-12 200000000 12.0 ns/op 83 B/op 0 allocs/op
BenchmarkSliceQueue-12 100000000 10.5 ns/op 82 B/op 0 allocs/op
PASS
ok tinylib/queue/queue_test 13.337s
没想到吧,slice 作为队列,性能要比 list 好得多,无论是 CPU 耗时、内存消耗、内存申请次数,slice 到要低,甚至单次操作的 CPU 耗时仅为 list 的 1⁄20 到 1⁄10 。
你是不是缓过神来了,由于双向链表的维护本身就是需要成本的(维护前驱指针和后继指针),且链式结构的内存寻址也肯定不如线性结构来得快的。相比于 list “出栈时就释放内存,入栈时才申请内存”,slice 的扩容策略实际上是“攒满了才释放,避免频繁 GC,申请时申请多一点作为预留,避免频繁 allocs”,这简直成是一个性能调优的典范呀。
那是不是可以下定论了,用 slice 当队列比用 list 当队列性能得多呢?
且慢 again。
换一个场景再对比
要知道,性能调优是要考虑具体场景的,在不同的场景,无谓的性能调优可能会适得其反。我们换一个场景测试上述的例子,这个场景是:队列元素的尺寸比较大。
还是之前的代码,只是这次队列元素不再是一个 int,而是一个长度为 1024 的数组:
import (
"container/list"
"testing"
)
func BenchmarkListQueue(b *testing.B) {
q := list.New()
for i := 0; i < b.N; i++ {
q.PushBack([1024]byte{})
q.PushBack([1024]byte{})
q.Remove(q.Front())
}
}
func BenchmarkSliceQueue(b *testing.B) {
var q [][1024]byte
for i := 0; i < b.N; i++ {
q = append(q, [1024]byte{})
q = append(q, [1024]byte{})
q = q[1:]
}
}
再次运行:
$ go test -bench=. -benchmem -count=3
goos: darwin
goarch: amd64
pkg: tinylib/queue/queue_test
BenchmarkListQueue-12 2000000 638 ns/op 2144 B/op 4 allocs/op
BenchmarkListQueue-12 3000000 705 ns/op 2144 B/op 4 allocs/op
BenchmarkListQueue-12 3000000 521 ns/op 2144 B/op 4 allocs/op
BenchmarkSliceQueue-12 2000000 4048 ns/op 11158 B/op 0 allocs/op
BenchmarkSliceQueue-12 1000000 3380 ns/op 11003 B/op 0 allocs/op
BenchmarkSliceQueue-12 1000000 2045 ns/op 11003 B/op 0 allocs/op
PASS
ok tinylib/queue/queue_test 23.784s
可以看到,这一次 slice 的性能反过来被 list 碾压了,单次操作所用的 CPU 耗时是 slice 的三四倍,内存使用更是高达五六倍。
由于元素尺寸比较大,slice 扩容时的元素拷贝操作变成了其性能瓶颈,由于在测试代码中,队列的长度会越来越长,所以每次扩容需要拷贝的元素也越来也越多,使得性能越来越差。所以如果我们再调整一下代码,让队列中的元素数量总是保持在一个较低的数字,slice 未必就那么容易输。
总结
综上所述,在常规情况下,slice 因为其结构的简单,维护成本低,作为队列,性能是胜于 list 的。但如果元素的尺寸越来越大,队列中存储的元素越来越多,slice 扩容成本也会随之越来越大,当超过一个临界值后,便会在性能上输给 list。
但事实上,本文展示了一个错误的使用方法,上文中将 [1024]byte 作为队列元素时,你应该会反应过来,如果换成 *[1024]byte,岂不是会省去大量的值拷贝操作,大大提升性能?确实是这样,如果元素的尺寸确实需要比较大,可以换成指针类型作为队列中的元素,这时候 slice 就没那么容易输了。
为了证明一下这个结论,最后我们祭出 LeetCode 的题:二叉树的层次遍历,一模一样的解题思路,一个用 slice,一个用 list,运行结果如下:
slice:
list:
这里好像 slice 比 list 快了 4 ms,但事实时上这么小的时间差距已经没有什么可比性的了,稍微抖一抖,多跑几次,结果可能就一会儿 8 ms 一会儿 4 ms,毕竟测试用例没有真实地逼出两种写法的性能差异,这里只是想说明,slice 作队列并不会比 list 挫。
附代码,写得不好,将就看哈。
func levelOrder(root *TreeNode) [][]int {
var ret [][]int
if root == nil {
return ret
}
var queue []*TreeNode
queue = append(queue, root)
queue = append(queue, nil)
layer := make([]int, 0)
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
if node == nil {
ret = append(ret, layer)
layer = make([]int, 0)
if len(queue) > 0 {
queue = append(queue, nil)
}
continue
}
layer = append(layer, node.Val)
if node.Left != nil {
queue = append(queue, node.Left)
}
if node.Right != nil {
queue = append(queue, node.Right)
}
}
return ret
}
func levelOrder(root *TreeNode) [][]int {
var ret [][]int
if root == nil {
return ret
}
queue := list.New()
queue.PushBack(root)
queue.PushBack(nil)
layer := make([]int, 0)
for queue.Len() > 0 {
node, ok := queue.Front().Value.(*TreeNode)
queue.Remove(queue.Front())
if !ok {
ret = append(ret, layer)
layer = make([]int, 0)
if queue.Len() > 0 {
queue.PushBack(nil)
}
continue
}
layer = append(layer, node.Val)
if node.Left != nil {
queue.PushBack(node.Left)
}
if node.Right != nil {
queue.PushBack(node.Right)
}
}
return ret
}
转自:https://blog.wolfogre.com/posts/slice-queue-vs-list-queue/