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

文章目录

切片

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

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

数据结构

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

  • Data是指向底层数组的切片;
  • Len是当前切片的长度;
  • Cap是当前切片的容量,即底层Data数组的长度。
go 复制代码
type SliceHeader struct {
    Data uintptr
    Len int
    Cap int
}

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

切片与数组关系密切。具体来说,切片在数组的基础上引入了一个抽象层,由上述的Data/Len/Cap三者构成,提供了对底层数组部分连续片段的引用。当切片底层的数组长度不足时,就会触发扩容,切片指向的底层数组可能会发生变化,但对于切片的使用者而言是感受不到的。

初始化

Go 提供了三种切片初始化的方法:

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 当中实现栈和队列这样的数据结构。具体来说,以队列为例,下例建立了一个队列,并实现二叉树的层序遍历:

go 复制代码
/**
 * 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 作为 queue
    if root != nil {
        q = append(q, root)
    }
    ans := [][]int{}    // 保存答案的二维 int 型 slice
    for 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
}

通过上例,我们实现了一个使用切片实现了一个先进先出的队列并解决了二叉树层序遍历问题。

如果想要使用切片建立栈这种数据结构,只需要在切片尾部弹出元素即可。下例通过切片实现了一个单调栈,用于解决「滑动窗口当中的最大值」问题:

go 复制代码
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()函数:
go 复制代码
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
go 复制代码
original := []string{"a", "b", "c"}
copied := append([]string{}, original...)

对于多维切片需要单独拷贝每一层

go 复制代码
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
}

切片传值调用的注意事项

切片的传值调用确实是一个值得注意的坑,另一个我切实遇到的例子是在「数组的全排列」这个问题上。当我们使用切片对全排列数组进行记录时,如果当前排列的数字长度达到原数组的长度,就应该记录一次答案,也就是将这次全排列的结果追加到一个二维切片当中。即:

go 复制代码
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] = true
            solve(nums, curr, visited, p + 1, n, ans)
            visited[i] = false
        }
    }
}

上述代码段当中curr这个切片保存一次排列的结果,需要将一次结果保存到答案ans当中。但是由于curr本身是一个切片,它的底层结构由三部分组成,分别是Data(指向底层数组的指针)/len/cap,将curr保存到ans实际上是把底层数组的指针保存到了ans当中。由于我们没有新建切片,所以对于每一次排列而言,底层数组都是相同的,相当于ans每一次保存的curr都是底层数组的浅拷贝,它们将会在后续的排列中不断变化,最后保持相同(因为每一次排列操作的都是相同的底层数组)。所以上述代码无法得到正确的答案。

正确的做法是至少新建一个切片(重新分配一次地址),然后每一次都将curr当中的元素拷贝到新的切片当中:

go 复制代码
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] = true
            solve(nums, curr, visited, p + 1, n, ans)
            visited[i] = false
        }
    }
}

小结

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

相关推荐
Musennn1 小时前
leetcode98.验证二叉搜索树:递归法中序遍历的递增性验证之道
java·数据结构·算法·leetcode
reduceanxiety1 小时前
机试 | vector/array Minimum Glutton C++
数据结构·c++·算法
2301_794461572 小时前
力扣-最大连续一的个数
数据结构·算法·leetcode
MonKingWD2 小时前
【redis原理篇】底层数据结构
数据结构·数据库·redis
小黄人软件3 小时前
OpenSSL 与 C++ 搭建一个支持 TLS 1.3 的服务器
服务器·开发语言·c++
清心歌3 小时前
二叉树遍历
数据结构·算法
武昌库里写JAVA3 小时前
Vue3编译器:静态提升原理
java·开发语言·spring boot·学习·课程设计
日晞4 小时前
深浅拷贝?
开发语言·前端·javascript
大模型铲屎官4 小时前
【深度学习-Day 16】梯度下降法 - 如何让模型自动变聪明?
开发语言·人工智能·pytorch·python·深度学习·llm·梯度下降
明月看潮生4 小时前
青少年编程与数学 02-020 C#程序设计基础 05课题、数据类型
开发语言·青少年编程·c#·编程与数学