在日常写代码的实践中,我们经常用到的基础数据结构最多的就是 Slice(切片),但在 Go 的 API 中却存在另一个有趣的数据结构《链表》(container/list),但什么时候用链表呢?当时我抱着怀疑的心态网上找了一下资源,有些说是“频繁的插入和删除用 list,频繁的遍历查询选 slice”,还有“随机插入和随机删除方面的性能比数组好得多”,到底哪个对呢?为此我做了一些测试来证明以上的说法是否正确,也正好可以加深对链表的使用。
电脑配置信息如下:
Go 版本为:go1.18.1 darwin/amd64
CPU:cpu: Intel(R) Core(TM) i7-6820HQ CPU @ 2.70GHz
内存: 16GB
先验证频繁插入链表是否比 slice 快,测试代码如下:
func BenchmarkListAndSliceInsertA(b *testing.B) {
b.ResetTimer()
sliceList := make([]int, 0)
for j := 0; j < b.N; j++ {
for i := 0; i < 10000; i++ {
sliceList = append(sliceList, i)
}
}
b.StopTimer()
}
func BenchmarkListAndSliceInsertB(b *testing.B) {
b.ResetTimer()
containerList := list.New()
for j := 0; j < b.N; j++ {
for i := 0; i < 10000; i++ {
containerList.PushBack(i)
}
}
b.StopTimer()
}
复制代码
以上是对结构数据不断的添加,测试效果如下:
在 slice 频繁插入的过程中,Slice 还是比较有优势的
验证频繁删除是否比 Slice 快,测试代码如下:
func BenchmarkListAndSliceRemoveA(b *testing.B) {
num := 100
sliceList := make([]int, 0)
for i := 0; i < num; i++ {
sliceList = append(sliceList, i)
}
b.ResetTimer()
for j := 0; j < b.N; j++ {
a := sliceList[:]
for i := 0; i < num; i++ {
a = a[1:len(a)]
}
}
b.StopTimer()
}
func BenchmarkListAndSliceRemoveB(b *testing.B) {
num := 100
containerList := list.New()
for i := 0; i < num; i++ {
containerList.PushFront(i)
}
b.ResetTimer()
for j := 0; j < b.N; j++ {
copyContainerList := list.New()
copyContainerList.PushBackList(containerList)
var prev *list.Element
for e := copyContainerList.Back(); e != nil; e = prev {
prev = e.Prev()
copyContainerList.Remove(e)
}
}
b.StopTimer()
}
复制代码
以上是对结构数据不断的复制删除元素,测试效果如下:
在 slice 频繁删除的过程中,也是 Slice 还是比较有优势的
既然随机我就拿索引来做随机数吧,测试代码如下:
func BenchmarkListAndSliceRandomIndexInsertA(b *testing.B) {
num := 10000
sliceList := make([]int, 0)
for i := 0; i < num; i++ {
sliceList = append(sliceList, i)
}
b.ResetTimer()
for j := 0; j < b.N; j++ {
l := len(sliceList)
rand.Seed(time.Now().UnixNano())
randNum := rand.Intn(l) // random index
newSlice := make([]int, l+1)
copy(newSlice, sliceList[:randNum])
newSlice[randNum] = randNum
copy(newSlice[randNum+1:], sliceList[randNum:])
sliceList = newSlice
}
b.StopTimer()
}
func BenchmarkListAndSliceRandomIndexInsertB(b *testing.B) {
num := 10000
containerList := list.New()
for i := 0; i < num; i++ {
containerList.PushBack(i)
}
b.ResetTimer()
for j := 0; j < b.N; j++ {
l := containerList.Len()
rand.Seed(time.Now().UnixNano())
randNum := rand.Intn(l) // random index
index := 0
for e := containerList.Front(); e != nil; e = e.Next() {
if index == randNum {
ef := containerList.PushBack(randNum)
containerList.MoveBefore(ef, e)
break
} else {
index += 1
}
}
}
b.StopTimer()
}
复制代码
测试结果如下:
链表内存分配更少,但性能还是 Slice 比较有优势
继续拿索引来做随机数,测试代码如下:
func BenchmarkListAndSliceRandomIndexRemoveA(b *testing.B) {
num := 10000
sliceList := make([]int, 0)
for i := 0; i < num; i++ {
sliceList = append(sliceList, i)
}
b.ResetTimer()
for j := 0; j < b.N; j++ {
a := sliceList[:]
for i := 0; i < num/2; i++ {
l := len(a)
rand.Seed(time.Now().UnixNano())
randNum := rand.Intn(l) // random index
newSlice := make([]int, l-1)
copy(newSlice, a[:randNum])
copy(newSlice[randNum:], a[randNum+1:])
a = newSlice
}
}
b.StopTimer()
}
func BenchmarkListAndSliceRandomIndexRemoveB(b *testing.B) {
num := 10000
containerList := list.New()
for i := 0; i < num; i++ {
containerList.PushBack(i)
}
b.ResetTimer()
for j := 0; j < b.N; j++ {
copyContainerList := list.New()
copyContainerList.PushBackList(containerList)
for i := 0; i < num/2; i++ {
l := copyContainerList.Len()
rand.Seed(time.Now().UnixNano())
randNum := rand.Intn(l) // random index
index := 0
for e := copyContainerList.Front(); e != nil; e = e.Next() {
if index == randNum {
copyContainerList.Remove(e)
break
} else {
index += 1
}
}
}
}
b.StopTimer()
}
复制代码
测试结果如下:
链表内存分配更少,但性能还是 Slice 比较有优势
根据以上的数据,明显是 Slice 比较有优势呀,那链表有什么用?
为了证明链表的用处,既然 GO 语言找不到了,那就找找别的编程语言呗,你别说还真给我找到:https://dzone.com/articles/performance-of-array-vs-linked-list-on-modern-comp
根据以上文章的描述,当链表中的元素大于 32bytes 并且执行随机插入或随机删除才是正真发挥威力的时候
在 go 语言中 int 类型是根据电脑的 CPU 位数决定,所以我直接用 int64 占用 8 个字节,一个结构体中包含 5 个 int64 就大于 32bytes 了,测试代码如下:
type IntBig struct {
Num1 int64
Num2 int64
Num3 int64
Num4 int64
Num5 int64
}
func BenchmarkListAndSliceBigInsertA(b *testing.B) {
b.ResetTimer()
sliceList := make([]IntBig, 0)
for j := 0; j < b.N; j++ {
for i := 0; i < 1000; i++ {
sliceList = append(sliceList, IntBig{
Num1: int64(i),
Num2: int64(i),
Num3: int64(i),
Num4: int64(i),
Num5: int64(i),
})
}
}
b.StopTimer()
}
func BenchmarkListAndSliceBigInsertB(b *testing.B) {
b.ResetTimer()
containerList := list.New()
for j := 0; j < b.N; j++ {
for i := 0; i < 1000; i++ {
containerList.PushBack(IntBig{
Num1: int64(i),
Num2: int64(i),
Num3: int64(i),
Num4: int64(i),
Num5: int64(i),
})
}
}
b.StopTimer()
}
复制代码
测试结果:
连续插入,Slice 还是比较有优势,因为非常容易命中 CPU 缓存
复用上面的结构体,测试代码如下:
type IntBig struct {
Num1 int64
Num2 int64
Num3 int64
Num4 int64
Num5 int64
}
func BenchmarkListAndSliceBigRandomIndexInsertA(b *testing.B) {
num := 10000
sliceList := make([]IntBig, 0)
for i := 0; i < num; i++ {
sliceList = append(sliceList, IntBig{
Num1: int64(i),
Num2: int64(i),
Num3: int64(i),
Num4: int64(i),
Num5: int64(i),
})
}
b.ResetTimer()
for j := 0; j < b.N; j++ {
l := len(sliceList)
rand.Seed(time.Now().UnixNano())
randNum := int64(rand.Intn(l)) // random index
newSlice := make([]IntBig, l+1)
copy(newSlice, sliceList[:randNum])
newSlice[randNum] = IntBig{
Num1: randNum,
Num2: randNum,
Num3: randNum,
Num4: randNum,
Num5: randNum,
}
copy(newSlice[randNum+1:], sliceList[randNum:])
sliceList = newSlice
}
b.StopTimer()
}
func BenchmarkListAndSliceBigRandomIndexInsertB(b *testing.B) {
num := 10000
containerList := list.New()
for i := 0; i < num; i++ {
containerList.PushBack(IntBig{
Num1: int64(i),
Num2: int64(i),
Num3: int64(i),
Num4: int64(i),
Num5: int64(i),
})
}
b.ResetTimer()
for j := 0; j < b.N; j++ {
l := containerList.Len()
rand.Seed(time.Now().UnixNano())
randNum := int64(rand.Intn(l)) // random index
index := int64(0)
for e := containerList.Front(); e != nil; e = e.Next() {
if index == randNum {
ef := containerList.PushBack(IntBig{
Num1: randNum,
Num2: randNum,
Num3: randNum,
Num4: randNum,
Num5: randNum,
})
containerList.MoveBefore(ef, e)
break
} else {
index += 1
}
}
}
b.StopTimer()
}
复制代码
测试结果:
哇~~,链表的优势终于出来了,当插入的数据越多,链表优势越明显,切片移动元素的成本增长通常高于链表缓存未命中的成本,那么链表会比数组有更好的性能。
*还有连续删除与随机删除这两个例子,有兴趣的同学可以自己实现一下
总结
根据以上的测试结果来看,当链表元素大于 32bytes 并且执行随机插入或随机删除才会使用,通常情况下还是可以用切片实现的。
如果对以上的测试与数据有误,欢迎同学下方留言纠正,谢谢
评论