Golang 切片数据结构

GolangBeginner
立即练习

介绍

在上一节中,我们讨论了 Go 语言中的数组。然而,数组存在局限性:一旦声明并初始化,其长度就无法更改。因此,数组在日常编程中并没有被广泛使用。相比之下,切片(Slice)的使用频率更高,它提供了一种更加灵活的数据结构。

知识点:

  • 定义切片
  • 初始化切片
  • 切片操作:增、删、改、查
  • 扩容切片
  • 切片截取
  • 多维切片

什么是切片

切片与数组类似,都是存放相同数据类型元素的容器。但是,数组有局限性:一旦声明并初始化,其长度就固定了。虽然数组有其应用场景,但灵活性不足。因此,在日常编程中,切片更为常用。

在 Go 语言中,切片是基于数组实现的。切片本质上是一个可以改变长度的动态数组。我们可以对切片执行添加、删除、修改和查找元素等操作,而数组则无法做到这一点。

定义切片

切片的初始化语法与数组非常相似。主要的区别在于不需要指定元素的长度。让我们看看以下代码:

// 声明一个长度为 5 的数组
var a1 [5]byte
// 声明一个切片
var s1 []byte

切片是对数组的引用

如果我们声明一个 int 类型的数组,数组中每个元素的零值将是 0。

然而,如果我们声明一个切片,切片的零值将是 nil。让我们创建一个名为 slice.go 的文件来验证这一点:

touch ~/project/slice.go

输入以下代码:

package main

import "fmt"

func main() {
    var a [3]int
    var s []int

    fmt.Println(a[0] == 0) // true
    fmt.Println(s == nil)  // true
}
go run slice.go

运行代码后,将显示以下输出:

true
true

我们创建了一个数组 a 和一个切片 s。我们将数组 a 的第一个元素与零进行比较,并检查切片 s 是否为 nil

如你所见,当我们声明一个切片时,它的零值是 nil。这是因为切片本身并不存储任何数据,它们只是对数组的引用。切片指向底层数组结构。

切片的数据结构

切片是一种复合数据类型,也被称为结构体(struct)。它是由不同类型的字段组成的复合类型。切片的内部结构由三个元素组成:指针、长度和容量。

type slice struct {
    elem *type
    len  int
    cap  int
}

如前所述,该结构引用了底层数组。elem 指针指向数组的第一个元素,而 type 是所引用数组元素的类型。

lencap 分别代表切片的长度和容量。你可以使用 len()cap() 函数来获取切片的长度和容量。

下图显示了一个切片引用了 int 类型的底层数组,其长度为 5,容量为 5:

切片引用 int 数组

当你定义一个新切片时,elem 指针被初始化为零值(即 nil)。指针的概念将在后续实验中介绍。目前,请记住指针指向一个值的内存地址。在上图中,elem 指针指向底层数组第一个元素的地址。

切片操作:增、删、改、查

截取数组或切片

由于切片的底层结构是数组,我们可以提取数组的指定长度作为切片的引用。以下代码段演示了这一点:

package main

import "fmt"

func main() {
    // 定义一个长度为 10 的整型数组
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // 声明一个空切片
    var s1 []int

    fmt.Println("切片 s1 是否为空:", s1 == nil)

    // 使用数组截取获得切片
    s1 = a[1:5]
    s2 := a[2:5]
    s3 := a[:]

    fmt.Println("切片 s1, s2, s3 是否为空:", s1 == nil, s2 == nil, s3 == nil)
    fmt.Println("数组 a 中的元素:", a)
    fmt.Println("切片 s1 中的元素:", s1)
    fmt.Println("切片 s2 中的元素:", s2)
    fmt.Println("切片 s3 中的元素:", s3)
}

输出如下:

切片 s1 是否为空: true
切片 s1, s2, s3 是否为空: false false false
数组 a 中的元素: [0 1 2 3 4 5 6 7 8 9]
切片 s1 中的元素: [1 2 3 4]
切片 s2 中的元素: [2 3 4]
切片 s3 中的元素: [0 1 2 3 4 5 6 7 8 9]

在这个程序中,我们首先声明并初始化了数组 a,然后使用数组截取将数组的一部分赋值给空切片 s1。通过这种方式,我们创建了一个新的切片。

s1[1:5] 表示创建数组的一个切片。切片的范围是从数组 a 的索引 1 到索引 5,但不包括第五个元素(即索引为 5 的元素)。

注意: 在编程语言中,第一个元素的索引是 0,而不是 1。同样,数组中的第二个元素的索引是 1。

我们使用 := 运算符将截取的数组直接赋值给切片 s2s3 也是同理,但没有指定范围,因此它截取了数组的所有元素。

下图展示了截取操作。请注意,切片的绿色部分代表对蓝色数组的引用。换句话说,它们共享同一个底层数组 a

切片截取可视化

切片的截取语法如下:

[开始索引:结束索引]

开始索引结束索引 都是可选参数。当我们想要获取数组的所有元素时,可以同时省略这两个参数。这在之前的程序中通过 s3 := a[:] 得到了体现。

如果我们想获取某个索引之后的所有元素,可以省略 结束索引 参数。例如,a1[3:] 将获取从索引 3 开始的所有元素。

要获取某个索引之前的所有元素,可以省略 开始索引 参数。例如,a1[:4] 将提取从索引 0 到索引 4 的所有元素,但不包括索引 4 处的元素。

除了从数组中提取切片外,我们还可以从现有切片中提取新切片。操作方法与数组相同。以下是一个简单的例子:

package main

import "fmt"

func main() {
    // 定义一个长度为 10 的整型数组
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // 创建初始切片 s1
    var s1 []int
    s1 = a[1:7]
    fmt.Printf("切片 s1: %d\t长度:%d\t容量:%d\n", s1, len(s1), cap(s1))

    // 从初始切片 s1 中提取新切片 s2
    s2 := s1[2:4]
    fmt.Printf("切片 s2: %d\t长度:%d\t容量:%d\n", s2, len(s2), cap(s2))
}

输出如下:

切片 s1: [1 2 3 4 5 6] 长度: 6 容量: 9
切片 s2: [3 4] 长度: 2 容量: 7

在这个程序中,我们通过截取数组 a 获得了切片 s1s1 的范围是从索引 1 到索引 7。通过使用 :=,我们从 s1 中提取了新切片 s2。由于 s1 的截取是连续的,新切片 s2 也将是连续的。

我们注意到切片的容量在截取时会发生变化。规则如下:

如果我们截取一个容量为 c 的切片,那么 s[i:j] 的长度将是 j-i,而容量将是 c-i

对于 s1,底层数组是 aa[1:7] 的容量是 9(即 10-1)。

对于 s2,底层数组与 s1 相同。由于在上一步中截取部分的容量为 9,因此 s1[2:4] 的容量变为 7(即 9-2)。

修改切片值会同时影响底层数组元素的值

由于切片不存储数据,而只是引用数组,因此修改切片的值也会同时改变底层数组的值。让我们演示一下:

package main

import "fmt"

func main() {
    // 定义一个长度为 10 的整型数组
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[2:5]
    s2 := a[:]

    fmt.Println("修改前:")
    fmt.Println("数组 a 中的元素:", a)
    fmt.Println("切片 s1 中的元素:", s1)
    fmt.Println("切片 s2 中的元素:", s2)

    // 将切片 s1 索引 2 处的值修改为 23
    s1[2] = 23

    fmt.Println("修改后:")
    fmt.Println("数组 a 中的元素:", a)
    fmt.Println("切片 s1 中的元素:", s1)
    fmt.Println("切片 s2 中的元素:", s2)
}

输出如下:

修改前:
数组 a 中的元素: [0 1 2 3 4 5 6 7 8 9]
切片 s1 中的元素: [2 3 4]
切片 s2 中的元素: [0 1 2 3 4 5 6 7 8 9]
修改后:
数组 a 中的元素: [0 1 2 3 23 5 6 7 8 9]
切片 s1 中的元素: [2 3 23]
切片 s2 中的元素: [0 1 2 3 23 5 6 7 8 9]

在这个程序中,切片 s1s2 都引用了数组 a。当切片 s1 将索引 2 处的值修改为 23 时,数组 a 和切片 s2 的值也会更新。

我们可以看到数组索引 4 处的值被修改为 23(因为 s1 := a[2:5] 意味着 s1[0] 引用 a[2]s1[1] 引用 a[3],而 s1[2] 引用 a[4])。这导致数组 a 和切片 s2 在索引 4 处的值都被修改了。

这在程序开发中可能会导致难以调试的错误。因此,在日常编程中,我们应尽可能避免多个切片引用同一个底层数组。

向切片追加元素

在本节中,我们将介绍 append 函数,它用于向切片添加元素。语法如下:

func append(slice []Type, elems ...Type) []Type

第一个参数是切片 slice,其余参数是要添加到切片中的元素。末尾的 []Type 表示 append 函数将返回一个与 slice 数据类型相同的新切片。

... 之后的 elems 表示这是一个变长参数,意味着可以输入一个或多个参数。

以下是使用 append 函数的示例:

package main

import "fmt"

func main() {
    // 定义一个长度为 10 的整型数组
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[1:7]
    fmt.Printf("初始 s1 值:%d\t长度:%d\t容量:%d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 12)
    fmt.Printf("修改后 s1 值:%d\t长度:%d\t容量:%d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 14, 14)
    fmt.Printf("再次修改后 s1 值:%d\t长度:%d\t容量:%d\n", s1, len(s1), cap(s1))
}

输出如下:

初始 s1 值: [1 2 3 4 5 6] 长度: 6 容量: 9
修改后 s1 值: [1 2 3 4 5 6 12] 长度: 7 容量: 9
再次修改后 s1 值: [1 2 3 4 5 6 12 14 14] 长度: 9 容量: 9

在这个程序中,我们首先通过截取数组 a 创建了切片 s1s1 的范围是从索引 1 到索引 7。我们使用 := 运算符将 append 的返回值赋给 s1。当我们使用 append 添加元素时,切片的容量可能会发生变化。如果元素数量超过容量,s1 的容量将会翻倍。

切片的扩容规则如下:

  • 如果切片的底层数组可以容纳新元素,则切片的容量不会改变。
  • 如果切片的底层数组无法容纳新元素,Go 将创建一个更大的数组,将原切片的值复制到新数组中,然后将追加的值添加到新数组中。

注意: 切片扩容并不总是翻倍;它取决于元素的大小、扩容的元素数量以及计算机硬件等因素。更多信息请参考切片的高级章节。

删除切片中的元素

Go 语言没有提供删除切片元素的关键字或函数,但我们可以利用数组截取来实现相同的功能,甚至更强大的功能。

以下代码删除了切片 s 中索引为 5 的元素,并将其赋值给新切片 s1

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    // 删除前打印切片 s
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // 删除索引 5 处的元素后打印切片 s 和 s1
    fmt.Printf("%d\n%d\n", s, s1)
}

输出如下:

[0 1 2 3 4 5 6 7 8 9]
[0 1 2 3 4 6 7 8 9 9]
[0 1 2 3 4 6 7 8 9]

删除特定索引的关键在于 append 语句。它将索引 6 之后的元素追加到索引 5 之前的元素之后。

这个操作相当于提前覆盖。例如,元素 5 被元素 6 覆盖,元素 6 被元素 7 覆盖,依此类推,直到元素 9 被添加到新切片中。因此,此时底层数组的值为 [0 1 2 3 4 6 7 8 9 9]

此时,如果我们检查 s1 的长度和容量,可以看到长度为 9,但容量为 10。这是因为 s1 是对 s 截取后的引用。它们共享同一个底层数组。

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // 检查 s1 的长度和容量
    fmt.Println(len(s1), cap(s1))
    fmt.Printf("\n%d\n%d\n\n", s, s1)

    // 修改切片 s
    s[3] = 22
    fmt.Printf("%d\n%d\n", s, s1)
}

输出如下:

[0 1 2 3 4 5 6 7 8 9]
9 10

[0 1 2 3 4 6 7 8 9 9]
[0 1 2 3 4 6 7 8 9]

[0 1 2 22 4 6 7 8 9 9]
[0 1 2 22 4 6 7 8 9]

练习

除了删除特定位置的元素外,我们还可以使用截取从切片中删除特定范围的元素。操作方法与删除特定索引处的元素相同。让我们做一个小练习。

创建一个文件 slice1.go。创建一个切片 a 并按如下方式初始化。然后使用截取创建一个不包含大于 3 且小于 7 的元素的新切片 s。最后,打印该新切片。

a := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0}

输出:

[9 8 7 3 2 1 0]
  • 提示: 注意切片的起始索引(记住,索引从 0 开始)。
  • 要求: slice1.go 文件需要放置在 ~/project 目录下。

扩容切片

切片有两个属性:lencaplen 代表切片中当前的元素数量,而 cap 代表切片可以容纳的最大元素数量。

当添加到切片中的元素数量超过其容量时会发生什么?让我们一起探索:

package main

import "fmt"

func main() {
    s1 := make([]int, 3)
    s2 := make([]int, 3, 5)
    fmt.Println("s1 追加前:", s1, "长度:", len(s1), "容量:", cap(s1))
    fmt.Println("s2 追加前:", s2, "长度:", len(s2), "容量:", cap(s2))

    s1 = append(s1, 12)
    s2 = append(s2, 22)
    fmt.Println("s1 追加后:", s1, "长度:", len(s1), "容量:", cap(s1))
    fmt.Println("s2 追加后:", s2, "长度:", len(s2), "容量:", cap(s2))
}

要运行该程序,请执行以下命令:

go run slice.go

输出如下:

s1 追加前: [0 0 0] 长度: 3 容量: 3
s2 追加前: [0 0 0] 长度: 3 容量: 5
s1 追加后: [0 0 0 12] 长度: 4 容量: 6
s2 追加后: [0 0 0 22] 长度: 4 容量: 5

正如我们在程序中看到的,当我们向切片添加元素且元素数量超过其原始容量时,切片的容量会自动增加。

切片扩容的规则如下:

  • 如果切片的底层数组可以容纳新元素,则切片的容量不会改变。
  • 如果切片的底层数组无法容纳新元素,Go 将创建一个更大的数组,将原切片的值复制到新数组中,然后将追加的值添加到新数组中。

注意: 切片容量翻倍并不总是必然的;它取决于元素的大小、元素数量以及计算机的硬件。更多信息请参考切片的高级章节。

复制切片

我们可以使用 copy 函数将一个切片复制到另一个切片。语法如下:

func copy(dst, src []Type) int

dst 是目标切片,src 是源切片,最后的 int 表示复制的元素数量,它是 len(dst)len(src) 中的最小值。

注意: copy 函数不会添加新元素。

示例如下:

package main

import "fmt"

func main() {
    s1 := []int{0, 1, 2, 3}
    s2 := []int{8, 9}

    s3 := []int{0, 1, 2, 3}
    s4 := []int{8, 9}

    // 将 s1 复制到 s2
    n1 := copy(s2, s1)
    // 将 s4 复制到 s3
    n2 := copy(s3, s4)

    fmt.Println(n1, s1, s2)
    fmt.Println(n2, s3, s4)
}

要运行该程序,请执行以下命令:

go run slice.go

输出如下:

2 [0 1 2 3] [0 1]
2 [8 9 2 3] [8 9]

在这个程序中,我们将切片 s1 的值复制到切片 s2,并将切片 s4 的值复制到切片 s3copy 函数返回复制的元素数量。

我们注意到 s1s2 的值发生了变化,s3s4 也是如此。第一个 copy 函数将 s1[0, 1, 2, 3] 复制到 s2[8, 9]。由于 s1s2 的最小长度是 2,因此它复制了 2 个值。目标切片 s2 被修改为 [0, 1]

第二个 copy 函数将 s4[8, 9] 复制到 s3[0, 1, 2, 3]。由于 s3s4 的最小长度是 2,它复制了 2 个值。结果,s3 被修改为 [8, 9, 2, 3]

遍历切片

遍历切片与遍历数组类似。所有数组遍历方法也同样适用于切片。

练习

在这个练习中,我们将测试并巩固对遍历切片和数组的理解。

创建一个文件 slice2.go。声明一个数组 a1 和一个切片 s1 并按如下方式初始化。然后,遍历数组 a1 和切片 s1 中的元素,并打印它们的索引和值。

a1 := [5]int{1, 2, 3, 9, 7}
s1 := []int{1, 8, 12, 1, 3}

输出:

Element a1 at index 0 is 1
Element a1 at index 1 is 2
Element a1 at index 2 is 3
Element a1 at index 3 is 9
Element a1 at index 4 is 7
Element s1 at index 0 is 1
Element s1 at index 1 is 8
Element s1 at index 2 is 12
Element s1 at index 3 is 1
Element s1 at index 4 is 3
  • 要求: slice2.go 文件需要放置在 ~/project 目录下。
  • 提示: 你可以使用 range 格式或索引格式来遍历元素。

总结

在本节中,我们学习了切片及其用法。相比之下,切片比数组更灵活、用途更广。在处理多个切片时,我们需要谨慎操作,以避免意外的切片行为。

✨ 查看解决方案并练习✨ 查看解决方案并练习