Golang-SliceTrick
前言
本篇文章是根据Go官网的一个有趣的Wiki,Golang一直都是以简单著称的,如果你是一个Java选手,那么一定会给眼花缭乱的库所征服,会封装一切你能用到的方法。
这个图十分有趣(Everything is 「for」)
尽管Golang并没有给开发者封装太多的方法,反而是通过一些Tricks可以实现你想要达到的效果
这种设计仅仅是一种选择而已,并没有绝对的正确!
下面就来看看官方给介绍的一些Tricks吧
Slice
大部分的时候使用slice都是和append
和copy
打交道,官方也是这样推荐的
基础使用
Copy复制
先来看看如果需要复制slice,可以使用什么方法
func main() { a := []int{1,2,3} b := make([]int, len(a)) copy(b, a) }
func main() { a := []int{1, 2, 3, 4, 5} b := append([]int(nil), a...) b1 := append(a[:0:0], a...) fmt.Println(b) fmt.Println(b1) }
这两种方式有什么区别吗?
make + copy
:容量在初始化的时候定义了len(a)
,所以说如果后续需要追加需要分配新的内存- 而后面两种方式:因为它们通常会预留更大的容量,减少内存重新分配的次数。
如果没有额外的追加操作,或者追加的元素较少,make + copy 的性能可能更好。
Cut剪切
a := []int{1, 2, 3, 4, 5} b := []int{1, 2, 3} c := append(a[:1], b[1:]...) // 1,2,3
Delete删除
万物皆可append+copy
a := []int{1, 2, 3, 4, 5} // 删除2 a = append(a[:1], a[2:]...) // append a = a[:1+copy(a[1:], a[2:])]
a = a[:i+copy(a[i:], a[i+1:])]
假设你需要删除的索引为i
a[i+1:]
表示从索引i+1开始到切片尾部的部分- 移动到
a[i:]
位置,也就是覆盖索引i
的值,所有后续的元素向前移动一个位置 a[:i+copy(...)]
相当于将slice的长度缩短到i + copy(...)
注意:如果元素的类型为指针或者是结构体,因为需要进行垃圾回收,在Cut和Delete可能会出现内存泄露问题
- 带有值的元素仍然被切片a的底层数组引用,代码层面上使用了delete方法进行删除,但是底层依旧被引用,如果底层数组的生命周期很长的话,那么表示出现了内存泄露
解决指针或者结构体的Cut/Delete
Cut
- 首先就是copy:将从索引j开始的元素移动到索引i到位置,以覆盖被删除的区间
- 清理冗余元素,显示复制为nil,确保这些位置上不再引用原来的值
- 缩短切片的长度
copy(a[i:], a[j:]) for k, n := len(a)-j+i, len(a); k < n; k++ { a[k] = nil // or the zero value of T } a = a[:len(a)-j+i] // ===================================== func main() { a := []*int{new(int), new(int), new(int), new(int), new(int)} *a[0], *a[1], *a[2], *a[3], *a[4] = 1, 2, 3, 4, 5 i, j := 1, 3 // 删除索引区间 [1, 3) 的元素 copy(a[i:], a[j:]) // 移动数据 for k, n := len(a)-j+i, len(a); k < n; k++ { a[k] = nil // 设置为 nil } a = a[:len(a)-j+i] // 调整切片长度 fmt.Println(a) // 输出: [1 4 5] }
Delete
和Cut同理
copy(a[i:], a[i+1:]) a[len(a)-1] = nil // or the zero value of T a = a[:len(a)-1]
其他
在位置i插入n个元素
a = append(a[:i], append(make([]T, n), a[i:]...)...)
func main() { a := []int{1, 2, 3, 4} i := 2 // 插入位置 n := 3 // 插入元素数量 a = append(a[:i], append(make([]int, n), a[i:]...)...) fmt.Println(a) // 输出: [1 2 0 0 0 3 4] }
如果插入的元素需要具体值,可以在make之后再赋值
b := make([]int, n) for i := range b { b[i] = 1013 } a = append(a[:i], append(b, a[i:]...)...)
添加n个元素
其实就是上面所用到的a = append(a, make([]T, n)...)
扩展容量
确保有足够的空间来附加n个元素,无需重新分配
if cap(a)-len(a) < n { a = append(make([]T, 0, len(a)+n), a...) } =========================================== a := []int{1, 2, 3} n := 5 // 需要插入的元素个数 if cap(a)-len(a) < n { a = append(make([]int, 0, len(a)+n), a...) } fmt.Println(a) // 输出: [1 2 3] fmt.Println(cap(a)) // 输出: len(a) + n,即 8
这样的好处就是避免多次扩容,在Go中当发生自动扩容的时候,通常是2倍
过滤原地
n := 0 for _, x := range a { if check(x) { a[n] = x n++ } } a = a[:n]
插入
在slice的位置i插入一个元素x,a = append(a[:i], append([]T{x}, a[i:]...)...)
func main() { a := []int{1, 2, 3, 4} i := 2 x := 99 a = append(a[:i], append([]int{x}, a[i:]...)...) fmt.Println(a) // 输出: [1 2 99 3 4] }
简单的性能分析:
内存分配:append([]T{x}, a[i:]...)
分配了一个新的切片,存储插入的元素和插入位置后的元素。append(a[:i], ...)
分配了一个新的切片。因此一共分配了两个新的切片
第二个append
创建的时候,有独自的底层存储的新slice,将a[i:]
的元素复制到该slice中,再复制到第一个中。
如何避免创建新的slice呢?
s = append(s, 0 /* use the zero value of the element type */) copy(s[i+1:], s[i:]) s[i] = x ================================================ func main() { a := []int{1, 2, 3, 4} i := 2 x := 99 a = append(a, 0) // 扩展切片容量 copy(a[i+1:], a[i:]) // 将索引 i 之后的元素右移 a[i] = x // 插入元素 fmt.Println(a) // 输出: [1 2 99 3 4] }
常用操作
Push And Push Front/Unshift 增加元素
a = append(a, x) a = append([]T{x}, a...) // 向slice头部添加元素
Pop And Pop Front/Shift 弹出元素
x, a = a[len(a)-1], a[:len(a)-1] x, a = a[0], a[1:] // 弹出头部
其他Tricks
不分配过滤
核心在于b := a[:0]
slice与原始slice共享相同的backing array和capacity。
看一个🌰,遍历出slice中偶数
func main() { a := []int{1, 2, 3, 4, 5, 6} b := a[:0] // 初始化 b,与 a 共享底层数组 for _, x := range a { if x%2 == 0 { // 保留偶数 b = append(b, x) } } fmt.Println("b:", b) // 输出: [2 4 6] fmt.Println("a:", a) // 输出: [2 4 6 4 5 6],注意 a 的后面部分未清理 }
优点在于:不需要额外的分配新的切片,直接复用原切片。
需要注意的是:
- 未清理的数组部分,过滤后「a的逻辑长度变短」但底层数组的其余部分保留了原始值
- 如果元素类型是指针或者结构体,可能会导致内存泄漏的问题
过滤后「a的逻辑长度变短」
- 逻辑长度指的是过滤后,切片中有效数据的个数。
- 虽然底层数组还包含所有原始数据,但你只会通过 b 访问过滤后的前 len(b) 个数据。
- 原始切片 a 的“逻辑长度”相当于 b 的长度,因为 b 保存的是有效数据。
「内存泄漏的问题」
过滤操作并没有清除 a 的多余部分(len(b) 到 len(a) 之间的数据),这可能会导致内存泄漏问题,特别是当切片元素是指针或带有指针的复杂类型时。
for i := len(b); i < len(a); i++ { a[i] = 0 // 或 nil,取决于类型 T 的零值 }
翻转
在Golang中翻转slice应该如何做呢?相当于一个小算法,只需要遍历slice一半的元素,然后进行「对称」opp := len(a) - i - 1
,opp是索引i
的对称
func main() { for i := len(a)/2-1; i >= 0; i-- { opp := len(a)-1-i a[i], a[opp] = a[opp], a[i] } }
还有最常见的双指针法
for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 { a[left], a[right] = a[right], a[left] }
Fisher–Yates(现代洗牌算法)
具有等概率随机性
for i := len(a) - 1; i > 0; i-- { j := rand.Intn(i + 1) a[i], a[j] = a[j], a[i] }
最小分配的批处理
当给了一个很大的slice数据,需要分批进行处理,那么这个算法就十分有用了
1.初始状态: actions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] batches = [] 2.第一次循环: 提取批次:actions[0:3] = [0, 1, 2] 更新: actions = [3, 4, 5, 6, 7, 8, 9] batches = [[0, 1, 2]] 3.第二次循环: 提取批次:actions[0:3] = [3, 4, 5] 更新: actions = [6, 7, 8, 9] batches = [[0, 1, 2], [3, 4, 5]]
func main() { actions := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} batchSize := 3 batches := make([][]int, 0, (len(actions)+batchSize-1)/batchSize) for batchSize < len(actions) { actions, batches = actions[batchSize:], append(batches, actions[0:batchSize:batchSize]) } batches = append(batches, actions) fmt.Println(batches) }
去重
看图!
in := []int{3, 2, 1, 4, 3, 2, 1, 4, 1} // any item can be sorted sort.Ints(in) j := 0 for i := 1; i < len(in); i++ { if in[j] == in[i] { continue } j++ in[j] = in[i] } fmt.Println(in) // [1 2 3 4] result := in[:j+1] fmt.Println(result) // [1 2 3 4]