文章目录
- 切片
- 数据结构
- 初始化
- 访问元素
- 追加和扩容
- 使用切片实现复杂数据结构
- 拷贝切片
- 切片传值调用的注意事项
- 小结
切片
在 Golang 当中,更常用的数据结构是切片(slice),它是动态的数组,长度不固定,可以向切片中追加元素,它会在容量不足时自动扩容。
在 Go 当中,切片的声明方式与数组类似。由于切片的长度是动态的,所以不需要指定长度。
数据结构
编译期间的切片是cmd/compile/internal/types.Slice
类型的,但运行时的切片可以由一个reflect.SliceHeader
结构体表示,其中:
Data
是指向底层数组的切片;Len
是当前切片的长度;Cap
是当前切片的容量,即底层Data
数组的长度。
type SliceHeader struct {Data uintptrLen intCap int
}
Data
是连续的内存空间,所以我们可以将切片理解为一片连续的内存加上Len
和Cap
的标识。
切片与数组关系密切。具体来说,切片在数组的基础上引入了一个抽象层,由上述的Data/Len/Cap
三者构成,提供了对底层数组部分连续片段的引用。当切片底层的数组长度不足时,就会触发扩容,切片指向的底层数组可能会发生变化,但对于切片的使用者而言是感受不到的。
初始化
Go 提供了三种切片初始化的方法:
// 1. 对数组取切片
slice1 := arr[0:3]
// 2. 使用 []int{}
slice2 := []int{1, 2, 3} // 初始值为 {1, 2, 3}, 如果不赋值就建立一个空切片
// 3. 使用 make 关键字, make 只能用于初始化 slice/map/channel
slice3 := make([]int, 10, 10) // 第二个参数是 len, 第三个参数是 cap
访问元素
可以通过内置的len
和cap
函数获取切片的长度以及容量,比如len(slice)
和cap(slice)
。
此外,在 for loop 当中遍历切片时可以使用range
关键字。
正常对于切片的访问和使用数组的方法是相同的,如果熟悉 Python 的序列,那么不难看出 Go 访问切片当中元素的方法,以及取子切片的方法与 Python 操作序列的方法非常相似。
追加和扩容
使用append
来向切片当中追加元素是最常用的切片操作。append
的第一个参数是原切片,后续的参数是可拓展的,所以的参数都是要向原切片添加的元素,append
的返回值是追加元素之后的新切片,这个切片的底层数组可能会改变,因为追加元素之后cap
可能达到上限需要扩容。
由于使用append
向切片添加元素可能会导致切片的容量扩增,即cap
发生变化,并且append
是传值调用,所以在使用append
向切片追加元素时,通常使用的行为是slice = append(slice, elem)
。
如果不使用slice = append(slice, elem)
,而是直接使用append(slice, elem)
,元素确实可以追加到slice
切片的底层数组当中,但由于append
传值调用,传入的slice
的底层数组指针以及cap
可能会被修改,但是对于外部而言,由于传入的是值,外部不知道slice
底层结构的变化,因此有可能会追加元素失败,所以在使用append
追加元素时,请总是为外部切片重新赋值,即slice = append(slice, elem)
。
现在我们来研究一下,切片追加元素但切片底层数组空间不足需要扩容时,新的容量应该如何确定。在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:
- 如果期望容量大于当前容量的两倍,就直接使用期望容量(
append
函数从第二个参数开始后续的参数也是可变长度的,因此一次追加行为有可能直接导致期望容量非常大); - 如果当前切片的长度小于 1024 就会将容量翻倍;
- 如果当前切片的长度大于 1024 就会每次增加 25%的容量,直到新容量大于期望容量。
使用切片实现复杂数据结构
基于切片的追加以及区间访问,可以在 Golang 当中实现栈和队列这样的数据结构。具体来说,以队列为例,下例建立了一个队列,并实现二叉树的层序遍历:
/*** Definition for a binary tree node.* type TreeNode struct {* Val int* Left *TreeNode* Right *TreeNode* }*/
func levelOrder(root *TreeNode) [][]int {q := []*TreeNode{} // 建立一个空的 slice, 保存的元素是 *TreeNode, 使用这个 slice 作为 queueif root != nil {q = append(q, root)}ans := [][]int{} // 保存答案的二维 int 型 slicefor len(q) > 0 {currLen := len(q)oneLayer := []int{} // 保存一层的答案for i := 0; i < currLen; i ++ {currNode := q[0] // 获取队首元素q = q[1:] // 队首元素出列oneLayer = append(oneLayer, currNode.Val)if currNode.Left != nil {q = append(q, currNode.Left)}if currNode.Right != nil {q = append(q, currNode.Right)}}ans = append(ans, oneLayer) // 保存二叉树某一层的值}return ans
}
通过上例,我们实现了一个使用切片实现了一个先进先出的队列并解决了二叉树层序遍历问题。
如果想要使用切片建立栈这种数据结构,只需要在切片尾部弹出元素即可。下例通过切片实现了一个单调栈,用于解决「滑动窗口当中的最大值」问题:
func maxSlidingWindow(nums []int, k int) []int {n := len(nums)dq := []int{} // 使用 slice 实现单调栈// 单调栈可以在栈底部弹出元素, 但是只能在栈顶部追加元素, 在栈顶也可以弹出元素ans := []int{} // 保存答案for i := 0; i < n; i ++ {for len(dq) > 0 && nums[i] > nums[dq[len(dq) - 1]] {// 在单调栈中记录的是数组元素的下标// 需要保证单调栈中元素下标对应的元素从栈底到栈顶单调递减dq = dq[:len(dq) - 1] // 弹出栈顶元素}for len(dq) > 0 && i - dq[0] >= k {// 根据题意, 由于我们需要记录的是滑动窗口当中的最大值// 因此需要保证单调栈中元素的下标在当前的滑动窗口内// 对于滑动窗口以外的元素下标, 应该从栈底弹出dq = dq[1:] // 弹出栈底元素}dq = append(dq, i)if len(dq) > 0 && i >= k - 1 {// 如果当前单调栈已经统计了 k 个元素, 那么就可以开始记录答案了// 记录的是滑动窗口的最大值ans = append(ans, nums[dq[0]])}}return ans
}
拷贝切片
对于单维切片,常见的拷贝方式有两种:
- 使用
copy()
函数:
original := []int{1, 2, 3, 4, 5}// 先创建新的切片, 再拷贝
copied := make([]int, len(original))
copy(copied, original)
copy(dst, src)
的行为规则:
copy(dst, src)
返回实际复制的元素个数;- 不会自动扩容目标切片,如果
dst
长度不够,那么只会将src
前len(dst)
个数据复制到dst
。因此如果希望完整复制src
,需要确保dst
的长度至少不小于src
; - 不会修改
src
切片,只是将数据复制到dst
。
- 使用
append
:
original := []string{"a", "b", "c"}
copied := append([]string{}, original...)
对于多维切片,需要单独拷贝每一层:
func deepCopy(src [][]int) [][]int {dst := make([][]int, len(src))for i := range src {dst[i] = make([]int, len(src[i])copy(dst[i], src[i])}return dst
}
切片传值调用的注意事项
切片的传值调用确实是一个值得注意的坑,另一个我切实遇到的例子是在「数组的全排列」这个问题上。当我们使用切片对全排列数组进行记录时,如果当前排列的数字长度达到原数组的长度,就应该记录一次答案,也就是将这次全排列的结果追加到一个二维切片当中。即:
func solve(nums, curr []int, visited []bool, p, n int, ans *[][]int) {if p == n {// 注意, 切片是引用类型, 所以保存的 curr 是最后一次的状态*ans = append(*ans, curr)return}for i := 0; i < n; i ++ {if !visited[i] {curr[p] = nums[i]visited[i] = truesolve(nums, curr, visited, p + 1, n, ans)visited[i] = false}}
}
上述代码段当中curr
这个切片保存一次排列的结果,需要将一次结果保存到答案ans
当中。但是由于curr
本身是一个切片,它的底层结构由三部分组成,分别是Data(指向底层数组的指针)/len/cap
,将curr
保存到ans
实际上是把底层数组的指针保存到了ans
当中。由于我们没有新建切片,所以对于每一次排列而言,底层数组都是相同的,相当于ans
每一次保存的curr
都是底层数组的浅拷贝,它们将会在后续的排列中不断变化,最后保持相同(因为每一次排列操作的都是相同的底层数组)。所以上述代码无法得到正确的答案。
正确的做法是至少新建一个切片(重新分配一次地址),然后每一次都将curr
当中的元素拷贝到新的切片当中:
func solve(nums, curr []int, visited []bool, p, n int, ans *[][]int) {if p == n {// 注意, 切片是引用类型, 所以保存的 curr 是最后一次的状态tmp := make([]int, n)for i := 0; i < n; i ++ {tmp[i] = curr[i]}*ans = append(*ans, tmp)return}for i := 0; i < n; i ++ {if !visited[i] {curr[p] = nums[i]visited[i] = truesolve(nums, curr, visited, p + 1, n, ans)visited[i] = false}}
}
小结
切片的很多功能都是由运行时实现的,无论是切片初始化,还是对切片进行追加/扩容都需要运行时的支持。需要注意的是,在遇到大切片扩容或复制时,可能会发生大规模的内存拷贝,一定要减少类似操作避免影响程序性能。