晕,我到底该用哪种方法从切片中删除元素呀?

37 阅读5分钟

从切片中删除某个元素

你可能遇到过这样的需求: 从一个有序的数组/切片中删除某个元素,同时保持剩下的元素顺序不变。

如果你看过上一篇关于切片的文章,那你一定知道,切片的底层其实是一个数组。而数组是一块连续的内存空间,只能整块申请,整块释放。即使我们只使用了数组中的某一个元素,也会导致整个数组的内存空间无法被释放。

// NewSlice 创建一个长度为1的slice
func NewSlice() []int {
    s := make([]int, 10000) // len(s):10000, cap(s):10000
    s1 := s[:1]             // len(s1):1     cap(s1):10000
    return s1
}

在上面这段代码中,NewSlice创建了一个长度为1的切片,但因为这个切片是从一个长度为10000的数组中截取的,它们两个共享同一个底层数组,s所分配的这10000个内存空间也无法被回收,除非你的程序不再使用ss1以及所有引用它们底层数组的元素。

此时s、s1以及底层数组的情况 如果你对这个机制还不太了解,你可以去看下之前切片剖析来帮助理解这部分内容。

由于切片的底层内存空间无法像链表那样灵活申请,灵活释放。因此如果我们要删除切片中某部分的内容时,只能将该元素后面的所有元素向前"挪动",覆盖掉要删除的内容,来实现删除的效果。

常用的方法有下面几种:

// 下面几个函数用来删除切片s中第i位的元素
func DeleteAtUseIteration(s []int, i int) []int {
    for j := i; j < len(s)-1; j++ {
        s[j] = s[j+1]
    }
    return s[:len(s)-1]
}

func DeleteAtUseAppend(s []int, i int) []int {
    return append(s[:i], s[i+1:]...)
}

func DeleteAtUseCopy(s []int, i int) []int {
    copy(s[i:], s[i+1:])
    return s[:len(s)-1]
}

这三个函数中,DeleteAtUseIteration可能是初学者最容易想到和实现的方法了。其余两个函数稍稍花点心思也比较容易理解。

不同实现之间的性能差异

现在方法有了,那么这些不同实现之间的性能差异究竟有多大呢?我们使用几组不同规模的数据进行测试:

// 通过调整length来测试三种删除方法
// 在不同规模数据上的效果
var length = 5

// NewTestSlice 创建一个指定长度的切片,同时初始化切片中的元素
func NewTestSlice(length int) []int {
    s := make([]int, length)
    for i := 0; i < length; i++ {
        s[i] = i
    }
    return s
}

func BenchmarkDeleteAtUseAppend(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := NewTestSlice(length)
        DeleteAtUseAppend(s, 2)
    }
}

func BenchmarkDeleteAtUseCopy(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := NewTestSlice(length)
        DeleteAtUseCopy(s, 2)
    }
}

func BenchmarkDeleteAtUseIteration(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := NewTestSlice(length)
        DeleteAtUseIteration(s, 2)
    }
}

我们通过上面这段代码进行基准测试,通过调整length来测试三种删除方法在不同规模的数据集上的性能。

如果你了解操作系统的内存分配机制,你可能知道,我们在程序中申请分配内存空间后,操作系统并不会立即为该内存分配(映射)实际的物理内存,而是将我们申请到的虚拟内存从"未分配"状态更改为"已分配"状态,只有在对这部分内存进行实际读写时,操作系统才会为它分配实际的物理内存。

因此我在NewTestSlice中为新创建的切片中每个元素都做了初始化,确保它们被分配了实际的物理内存,也更加贴合实际生产中的使用场景。

我没有使用b.StopTimer()b.StartTimer()来将NewTestSlice创建切片的时间排除在外,我尝试过,这样数据的差异会看起来更明显,但这样的话执行基准测试所耗费的时间太长了...

考虑到每个基准测试都会以相同参数来调用NewTestSlice,也还算公平,索性就不做调整了。

我们从10开始运行这些基准测试,从小到大逐渐增加数据规模,查看结果会如何:

length为10:

length为10


length为100:

length为100


length为1000:

length为1000


length为10000:

length为10000


length为100000:

length为100000

通过这些数据不难发现,不管数据量如何扩大,使用appendcopy实现的函数,性能总是在一个规模上,而使用for循环的实现,在数据规模变大之后,性能越来越差,在数据规模来到100000时,每次操作所耗费的时间已经比appendcopy多了100%了

我们使用之前文章中提到的方法来查看它们之间的区别,你会发现DeleteAtUseAppendDeleteAtUseCopy两个函数生成的汇编中都使用了runtime.memmove来直接拷贝整块内存。而DeleteAtUseIteration则是"老老实实"的把后面的元素搬运到前面。

因此,数据规模越大,它们之间的性能差异就越明显。

标准库的做法

从Go1.21开始, 标准库提供了一个泛型版本的slice工具库:slices,其中包括了一个用来删除切片元素的slices.Delete函数,内部正是用append来实现的:

func Delete[S ~[]E, E any](s S, i, j int) S {
    _ = s[i:j] // bounds check

    return append(s[:i], s[j:]...)
}

结语

通常情况下,如果频繁对列表进行插入或删除操作,那么数组和切片一定不是个好的选择。本篇仅作为切片知识点的额外扩充。

本文首发自微信公众平台: 比特要塞, 欢迎关注