当前位置: 首页 > news >正文

吃透 Golang 基础:数据结构之切片

文章目录

  • 切片
    • 数据结构
    • 初始化
    • 访问元素
    • 追加和扩容
    • 使用切片实现复杂数据结构
    • 拷贝切片
    • 切片传值调用的注意事项
    • 小结

切片

在这里插入图片描述
在 Golang 当中,更常用的数据结构是切片(slice),它是动态的数组,长度不固定,可以向切片中追加元素,它会在容量不足时自动扩容。

在 Go 当中,切片的声明方式与数组类似。由于切片的长度是动态的,所以不需要指定长度。

数据结构

编译期间的切片是cmd/compile/internal/types.Slice类型的,但运行时的切片可以由一个reflect.SliceHeader结构体表示,其中:

  • Data是指向底层数组的切片;
  • Len是当前切片的长度;
  • Cap是当前切片的容量,即底层Data数组的长度。
type SliceHeader struct {Data uintptrLen intCap int
}

Data是连续的内存空间,所以我们可以将切片理解为一片连续的内存加上LenCap的标识。

切片与数组关系密切。具体来说,切片在数组的基础上引入了一个抽象层,由上述的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

访问元素

可以通过内置的lencap函数获取切片的长度以及容量,比如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)

现在我们来研究一下,切片追加元素但切片底层数组空间不足需要扩容时,新的容量应该如何确定。在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

  1. 如果期望容量大于当前容量的两倍,就直接使用期望容量(append函数从第二个参数开始后续的参数也是可变长度的,因此一次追加行为有可能直接导致期望容量非常大);
  2. 如果当前切片的长度小于 1024 就会将容量翻倍;
  3. 如果当前切片的长度大于 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)的行为规则:

  1. copy(dst, src)返回实际复制的元素个数;
  2. 不会自动扩容目标切片,如果dst长度不够,那么只会将srclen(dst)个数据复制到dst。因此如果希望完整复制src,需要确保dst的长度至少不小于src
  3. 不会修改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}}
}

小结

切片的很多功能都是由运行时实现的,无论是切片初始化,还是对切片进行追加/扩容都需要运行时的支持。需要注意的是,在遇到大切片扩容或复制时,可能会发生大规模的内存拷贝,一定要减少类似操作避免影响程序性能。

http://www.xdnf.cn/news/588259.html

相关文章:

  • 实现了TCP的单向通信
  • 【数据库】-2 mysql基础语句(上)
  • 旋转编码器计次 红外对射传感器计次小实验及其相关库函数详解 (江协科技)
  • 第四章:YOLOv11 实战应用与开发指南
  • LeetCode 404.左叶子之和的迭代求解:栈结构与父节点定位的深度解析
  • 力扣.H指数力扣.字母异位词力扣.289生命游戏力扣452.用最小数量的箭引爆气球力扣.86分隔链表力扣.轮转数组
  • 高等数学-常微分方程
  • 国产三维CAD皇冠CAD(CrownCAD)建模教程:交流发电机
  • 推荐一个Excel与实体映射导入导出的C#开源库
  • 手写简单的tomcat
  • (泛函分析)线性算子连续必有界的证明
  • GraphRAG使用
  • 动态规划(七)——子数组系列(求和问题)
  • labview实现将百分制分数转换为等级制分数
  • Vue 3 官方 Hooks 的用法与实现原理
  • ai外呼平台:AnKo打造高效多模型服务体验!
  • labview实现LED流水灯的第二种方法
  • 每日算法刷题计划day13 5.22:leetcode不定长滑动窗口最短/最小1道题+求子数组个数越长越合法2道题,用时1h
  • 学习vue3:跨组件通信(provide+inject)
  • vscode include总是报错
  • Ubuntu24.04 LTS安装java8、mysql8.0
  • 【VScode】python初学者的有力工具
  • Labview使用报表工具
  • linux二进制安装mysql:
  • 遥控器处理器与光纤通信技术解析
  • 深入理解指针part1
  • 【Django ORM】三万字了解Django ORM的基本概念和基本使用
  • 并发编程之并发协同工具类
  • ollama+open-webui搭建可视化大模型聊天
  • 【计算机网络】TCP如何保障传输可靠性_笔记