跳转至

📖 数组与切片

学习时间: 约 3-4 小时 | 难度: ⭐⭐ 初级 | 前置知识: Go基础语法、变量与数据类型

📚 章节概述

数组和切片是 Go 语言中最基础也最重要的数据结构。数组是定长的值类型,切片是对数组的动态抽象。理解二者的底层实现原理和使用差异,是写出高性能 Go 代码的关键。本章将从底层内存模型出发,深入讲解数组与切片的核心机制。

Go切片底层三元组示意

上图展示了切片的 ptr/len/cap 三元组与底层数组之间的对应关系,便于理解切片扩容与共享底层数组行为。

🎯 学习目标

  • 掌握数组的定义、初始化和值类型特性
  • 理解切片的底层三元组(指针、长度、容量)
  • 精通 make、append、copy 的使用与性能特征
  • 了解切片扩容策略与内存分配
  • 掌握多维切片与常见陷阱

📌 概念:数组(Array)

1.1 数组的定义与初始化

数组在 Go 中是值类型,长度是类型的一部分。[3]int[5]int 是不同的类型。

Go
package main

import "fmt"

func main() {
    // 方式1:声明后赋值
    var arr1 [5]int
    arr1[0] = 10
    arr1[4] = 50
    fmt.Println("arr1:", arr1) // [10 0 0 0 50]

    // 方式2:短变量声明 + 字面量初始化
    arr2 := [5]int{1, 2, 3, 4, 5}
    fmt.Println("arr2:", arr2) // [1 2 3 4 5]

    // 方式3:省略号自动推断长度
    arr3 := [...]int{10, 20, 30}
    fmt.Println("arr3:", arr3)         // [10 20 30]
    fmt.Println("arr3 长度:", len(arr3)) // 3

    // 方式4:指定索引初始化
    arr4 := [5]int{1: 100, 3: 300}
    fmt.Println("arr4:", arr4) // [0 100 0 300 0]
}

1.2 数组是值类型

这是 Go 数组与其他语言最大的区别之一。赋值和传参会完整复制整个数组。

Go
func modifyArray(arr [3]int) {
    arr[0] = 999
    fmt.Println("函数内:", arr) // [999 2 3]
}

func main() {
    original := [3]int{1, 2, 3}
    modifyArray(original)
    fmt.Println("函数外:", original) // [1 2 3] — 未被修改
}

⚠️ 注意: 大数组作为参数传递时会有性能开销,建议使用切片或指针。

1.3 数组的遍历

Go
arr := [5]int{10, 20, 30, 40, 50}

// 方式1:经典 for 循环
for i := 0; i < len(arr); i++ {
    fmt.Printf("arr[%d] = %d\n", i, arr[i])
}

// 方式2:range 遍历(推荐)
for index, value := range arr {
    fmt.Printf("索引: %d, 值: %d\n", index, value)
}

// 方式3:只要值,忽略索引
for _, value := range arr {
    fmt.Println(value)
}

// 方式4:只要索引
for index := range arr {
    fmt.Println(index)
}

1.4 多维数组

Go
// 二维数组:3行4列
var matrix [3][4]int
matrix[0][0] = 1
matrix[2][3] = 12

// 字面量初始化
grid := [2][3]int{
    {1, 2, 3},
    {4, 5, 6},
}

// 遍历二维数组
for i, row := range grid {
    for j, val := range row {
        fmt.Printf("grid[%d][%d] = %d  ", i, j, val)
    }
    fmt.Println()
}

1.5 数组的比较

Go 中同类型数组可以直接用 ==!= 比较:

Go
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
c := [3]int{1, 2, 4}

fmt.Println(a == b) // true
fmt.Println(a == c) // false
// 注意: [3]int 和 [4]int 不能比较,类型不同

📌 概念:切片(Slice)

2.1 切片的底层结构

切片在运行时由三个字段组成(reflect.SliceHeader):

Go
type SliceHeader struct {
    Data uintptr  // 指向底层数组的指针
    Len  int      // 切片的长度
    Cap  int      // 切片的容量
}
Go
package main

import "fmt"

func main() {
    // 从数组创建切片
    arr := [5]int{10, 20, 30, 40, 50}
    slice := arr[1:4] // 包含索引1,2,3

    fmt.Println("切片:", slice)       // [20 30 40]
    fmt.Println("长度:", len(slice))  // 3
    fmt.Println("容量:", cap(slice))  // 4(从索引1到数组末尾)
}

2.2 切片的创建方式

Go
// 方式1:字面量
s1 := []int{1, 2, 3, 4, 5}

// 方式2:make 创建
s2 := make([]int, 5)      // 长度5,容量5,零值填充
s3 := make([]int, 3, 10)  // 长度3,容量10

// 方式3:从数组切割
arr := [5]int{10, 20, 30, 40, 50}
s4 := arr[1:3]  // [20, 30]
s5 := arr[:3]   // [10, 20, 30]
s6 := arr[2:]   // [30, 40, 50]
s7 := arr[:]    // [10, 20, 30, 40, 50]

// 方式4:从切片再切片
s8 := s1[1:3]   // [2, 3]

// 方式5:nil 切片 vs 空切片
var s9 []int              // nil 切片,len=0, cap=0
s10 := []int{}            // 空切片,len=0, cap=0
s11 := make([]int, 0)     // 空切片,len=0, cap=0
fmt.Println(s9 == nil)    // true
fmt.Println(s10 == nil)   // false

2.3 append 操作详解

Go
func main() {
    s := []int{1, 2, 3}
    fmt.Printf("追加前: len=%d, cap=%d, %v\n", len(s), cap(s), s)

    // 追加单个元素
    s = append(s, 4)  // append 向切片追加元素,返回新切片
    fmt.Printf("追加1个: len=%d, cap=%d, %v\n", len(s), cap(s), s)

    // 追加多个元素
    s = append(s, 5, 6, 7)
    fmt.Printf("追加3个: len=%d, cap=%d, %v\n", len(s), cap(s), s)

    // 追加另一个切片(展开操作符)
    extra := []int{8, 9, 10}
    s = append(s, extra...)
    fmt.Printf("追加切片: len=%d, cap=%d, %v\n", len(s), cap(s), s)
}

2.4 切片扩容策略

Go 1.18+ 的扩容策略: - 当前容量 < 256 时,新容量 = 旧容量 × 2 - 当前容量 ≥ 256 时,新容量 = 旧容量 + (旧容量 + 3×256) / 4

Go
func main() {
    var s []int
    for i := 0; i < 20; i++ {
        s = append(s, i)
        fmt.Printf("len=%2d, cap=%2d, ptr=%p\n", len(s), cap(s), s)
    }
}
// 输出示意:可以观察到容量变化 1->2->4->8->16->...
// 注意每次扩容时 ptr 会变化(底层数组被重新分配)

2.5 copy 函数

Go
// copy 返回实际复制的元素数量,等于 min(len(dst), len(src))
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println(n, dst) // 3 [1 2 3]

// 完整复制
fullCopy := make([]int, len(src))
copy(fullCopy, src)
fmt.Println(fullCopy) // [1 2 3 4 5]

// 切片之间可以有重叠
s := []int{1, 2, 3, 4, 5}
copy(s[1:], s[2:]) // 向前移动
fmt.Println(s)      // [1 3 4 5 5]

2.6 切片的删除操作

Go 没有内置的 delete 操作,需手动实现:

Go
// 删除索引 i 处的元素(保持顺序)
func removeOrdered(s []int, i int) []int {
    return append(s[:i], s[i+1:]...)
}

// 删除索引 i 处的元素(不保持顺序,更高效)
func removeUnordered(s []int, i int) []int {
    s[i] = s[len(s)-1]
    return s[:len(s)-1]
}

func main() {
    s := []int{10, 20, 30, 40, 50}
    s = removeOrdered(s, 2)
    fmt.Println(s) // [10 20 40 50]

    s2 := []int{10, 20, 30, 40, 50}
    s2 = removeUnordered(s2, 2)
    fmt.Println(s2) // [10 20 50 40]
}

2.7 切片作为函数参数

切片是引用类型,传参不会复制底层数组,但 append 可能导致底层数组更换:

Go
func modifySlice(s []int) {
    s[0] = 999 // 修改会影响外部
}

func appendSlice(s []int) []int {
    s = append(s, 100) // 可能不影响外部(底层数组可能被替换)
    return s
}

func main() {
    s := []int{1, 2, 3}
    modifySlice(s)
    fmt.Println(s) // [999 2 3]

    s2 := []int{1, 2, 3}
    _ = appendSlice(s2)
    fmt.Println(s2) // [1 2 3] — append 可能未影响原切片
}

📌 概念:切片的常见陷阱

3.1 切片共享底层数组

Go
func main() {
    arr := [5]int{1, 2, 3, 4, 5}
    s1 := arr[1:3] // [2, 3]
    s2 := arr[2:5] // [3, 4, 5]

    s1[1] = 999 // 修改 s1[1] 就是修改 arr[2]
    fmt.Println(arr) // [1 2 999 4 5]
    fmt.Println(s2)  // [999 4 5] — s2 也被影响了!
}

3.2 用三索引切片限制容量

Go
// 用 s[low:high:max] 限制切片容量,防止 append 覆盖原数组
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:3:3] // len=2, cap=2(而不是4)
fmt.Println(len(s), cap(s)) // 2 2

s = append(s, 100) // 容量不足,会分配新数组,不影响 arr
fmt.Println(arr) // [1 2 3 4 5] — 未被修改

3.3 内存泄漏:大切片的小引用

Go
// 不好:data 的底层大数组无法被 GC 回收
func getHeader(data []byte) []byte {
    return data[:10]
}

// 好:复制需要的部分,释放大数组的引用
func getHeaderSafe(data []byte) []byte {
    header := make([]byte, 10)
    copy(header, data[:10])
    return header
}

💻 代码示例:实战应用

示例1:实现一个简单的栈

Go
type IntStack struct {
    data []int
}

func NewIntStack() *IntStack {
    return &IntStack{data: make([]int, 0, 16)}
}

func (s *IntStack) Push(v int) {
    s.data = append(s.data, v)
}

func (s *IntStack) Pop() (int, bool) {
    if len(s.data) == 0 {
        return 0, false
    }
    n := len(s.data) - 1
    val := s.data[n]
    s.data = s.data[:n]
    return val, true
}

func (s *IntStack) Len() int {
    return len(s.data)
}

func main() {
    stack := NewIntStack()
    stack.Push(10)
    stack.Push(20)
    stack.Push(30)

    for stack.Len() > 0 {
        val, _ := stack.Pop()
        fmt.Println(val) // 30, 20, 10
    }
}

示例2:切片去重

Go
func unique(s []int) []int {
    seen := make(map[int]bool)
    result := make([]int, 0, len(s))
    for _, v := range s {
        if !seen[v] {
            seen[v] = true
            result = append(result, v)
        }
    }
    return result
}

func main() {
    nums := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3}
    fmt.Println(unique(nums)) // [3 1 4 5 9 2 6]
}

示例3:矩阵转置

Go
func transpose(matrix [][]int) [][]int {
    if len(matrix) == 0 {
        return nil
    }
    rows, cols := len(matrix), len(matrix[0])
    result := make([][]int, cols)
    for i := range result {
        result[i] = make([]int, rows)
    }
    for i := 0; i < rows; i++ {
        for j := 0; j < cols; j++ {
            result[j][i] = matrix[i][j]
        }
    }
    return result
}

func main() {
    m := [][]int{
        {1, 2, 3},
        {4, 5, 6},
    }
    t := transpose(m)
    for _, row := range t {
        fmt.Println(row)
    }
    // [1 4]
    // [2 5]
    // [3 6]
}

示例4:滑动窗口最大值

Go
func maxSlidingWindow(nums []int, k int) []int {
    if len(nums) == 0 || k == 0 {
        return nil
    }
    result := make([]int, 0, len(nums)-k+1)
    deque := make([]int, 0) // 存储索引

    for i := 0; i < len(nums); i++ {
        // 移除超出窗口的元素
        for len(deque) > 0 && deque[0] < i-k+1 {
            deque = deque[1:]
        }
        // 保持递减队列
        for len(deque) > 0 && nums[deque[len(deque)-1]] < nums[i] {
            deque = deque[:len(deque)-1]
        }
        deque = append(deque, i)
        if i >= k-1 {
            result = append(result, nums[deque[0]])
        }
    }
    return result
}

✅ 最佳实践

1. 优先使用切片而非数组

Go
// ✅ 推荐
func process(data []int) { ... }

// ❌ 避免(除非确实需要固定长度)
func process(data [100]int) { ... }

2. 预分配容量避免频繁扩容

Go
// ✅ 已知大小时预分配
result := make([]string, 0, len(input))
for _, item := range input {
    result = append(result, transform(item))
}

// ❌ 零容量逐步追加
var result []string
for _, item := range input {
    result = append(result, transform(item))
}

3. 使用 copy 而非 append 进行安全复制

Go
// ✅ 安全复制,与原切片完全独立
clone := make([]int, len(original))
copy(clone, original)

// ⚠️ 这种方式也行,但语义不如 copy 直观
clone := append([]int(nil), original...)

4. 注意切片作为 map 值时不能取地址

Go
// ❌ 不能直接修改 map 中切片的元素
m := map[string][]int{"a": {1, 2, 3}}
// m["a"][0] = 10 // 这是允许的,因为切片本身是引用

// 但要追加需要重新赋值
m["a"] = append(m["a"], 4)

5. 函数返回切片时注意文档说明

Go
// ✅ 明确说明返回的切片是否可以安全修改
// SortedCopy 返回 s 的有序副本,调用方可以安全修改返回值
func SortedCopy(s []int) []int {
    result := make([]int, len(s))
    copy(result, s)
    sort.Ints(result)
    return result
}

🎯 面试题

Q1: 数组和切片的区别是什么?

A: 核心区别: 1. 长度:数组长度固定且是类型的一部分([3]int ≠ [5]int),切片长度动态可变 2. 值类型 vs 引用类型:数组赋值和传参是完整复制,切片传参只复制三元组(指针、长度、容量) 3. 比较:数组可用 == 比较,切片不能(只能与 nil 比较) 4. 底层:切片是对数组的引用抽象,包含指向底层数组的指针

Q2: 切片的 append 操作在什么时候会分配新的底层数组?

A: 当 append 后的元素总数超过当前容量(cap)时,Go 会分配一个更大的底层数组,并将已有元素复制过去。扩容策略(Go 1.18+):容量 < 256 时翻倍,≥ 256 时按 oldcap + (oldcap + 3*256) / 4 增长。扩容后,新切片和旧切片不再共享底层数组。

Q3: 以下代码输出什么?为什么?

Go
s := make([]int, 0, 5)
s = append(s, 1, 2, 3)
a := append(s, 4)
b := append(s, 5)
fmt.Println(a, b)

A: 输出 [1 2 3 5] [1 2 3 5]。因为 s 的 cap=5,s 长度为3,a 和 b 的 append 都在同一底层数组的第4个位置写入。b 的写入覆盖了 a 的写入。这是切片共享底层数组的经典陷阱。

Q4: 如何安全地从切片中删除元素?

A: 两种方式: 1. 保持顺序s = append(s[:i], s[i+1:]...) — O(n) 需要移动元素 2. 不保持顺序s[i] = s[len(s)-1]; s = s[:len(s)-1] — O(1) 将末尾元素移到删除位置

Q5: nil 切片和空切片有什么区别?

A: var s []int 是 nil 切片(底层指针为 nil),s := []int{} 是空切片(底层指针非 nil 但长度为0)。它们的 lencap 都是0,可以安全地用 appendrange 等操作。区别在于 JSON 序列化时,nil 切片序列化为 null,空切片序列化为 []。在大多数场景下二者行为一致。

Q6: 以下代码有什么问题?如何修复?

Go
func getFirstN(data []byte, n int) []byte {
    return data[:n]
}

A: 返回的切片与 data 共享底层数组。如果 data 是一个很大的切片(如读取整个文件),返回的小切片会阻止整个大数组被 GC 回收,导致内存泄漏。修复方式:用 copy 创建独立副本:

Go
func getFirstN(data []byte, n int) []byte {
    result := make([]byte, n)
    copy(result, data[:n])
    return result
}

📋 学习检查清单

  • 能区分数组和切片的类型和行为差异
  • 理解切片的底层三元组结构(指针、长度、容量)
  • 掌握 make、append、copy 的使用及返回值
  • 理解切片扩容策略对性能的影响
  • 能识别切片共享底层数组的陷阱
  • 会使用三索引切片 s[low:high:max] 限制容量
  • 了解 nil 切片与空切片的区别(含 JSON 序列化场景)
  • 能实现常见的切片操作(去重、删除、栈、滑动窗口)

上一章: Go基础语法 | 下一章: 映射与结构体