跳至主要內容

数据结构深入

逸尘.Lycodx大约 7 分钟数据结构goland基础

数据结构深入

一、 slice

1.1 什么是 slice?

slice 是 Go 中基于数组的一个轻量级封装。 本质上,slice 是对底层数组的一个“窗口”

slice 的三个属性

ptr:指向底层数组的指针

len:当前 slice 长度(元素个数)

cap:底层数组容量(从 ptr 开始算到结尾)

1.2 cap 的计算

slice 的 cap 是怎么计算的?

从起始位置到底层数组末尾算。不是 len。

举个栗子:

arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:3] // s = [20, 30]

s[0] == 20
s[1] == 30

len(s) == 2 // 只切了 2 个元素(1~2位置)

cap(s) == 4 // 从下标1(20)到数组末尾下标4(50),一共有4个位置。

底层示意图:

索引   0   1   2   3   4
数组 [10][20][30][40][50]

s起点 -> [20][30][40][50]
         len=2,cap=4

1.3 slice 扩容机制

追加元素(append)时,如果超出当前 cap,Go 会创建一个新的底层数组,并拷贝数据。

扩容策略(大概规律)

小于 1024 个元素时,一般是每次翻倍扩容

超过 1024 元素后,增长比例变为每次增加 25%

举个栗子

s := make([]int, 0, 2)
s = append(s, 1)       // cap = 2
s = append(s, 2)       // cap = 2
s = append(s, 3)       // cap = 4 (翻倍)
s = append(s, 4, 5, 6) // cap = 8 (再次翻倍)

扩容后,旧的 slice 仍然指向原数组,新 slice 指向新数组

package main

import "fmt"

func main() {
    s1 := make([]int, 2, 2) // len=2, cap=2
    s1[0] = 1
    s1[1] = 2

    s2 := append(s1, 3) // cap 不够,触发扩容,新建数组
    s2[0] = 100 // 修改数据,观察变化

    fmt.Println("s1:", s1) // s1: [1 2]
    fmt.Println("s2:", s2) // s2: [100 2 3]
}

s1 还是指向原来的底层数组 [1 2]

s2 指向新建的底层数组 [100 2 3]

s1 没被修改,s2 单独用了新的内存。

slice 扩容时发生了什么?

重新申请更大数组,复制旧数据,更新指针。

1.4 slice 引用关系

slice 是引用类型。多个 slice 可以共享同一个底层数组。

示例

arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[1:3] // [2 3]
s2 := arr[2:4] // [3 4]
s1[1] = 99     // 修改 s1 的第二个元素

fmt.Println(arr) // [1 2 99 4 5]
fmt.Println(s2)  // [99 4]

因为 s1 和 s2 都引用了 arr,所以修改 arr 也影响了 s2!

slice 之间如何避免互相影响?

可以使用 copy(),主动复制一份数据。

newS := make([]int, len(oldS))
copy(newS, oldS)

1.5 空 slice 与 nil 的区别

var s1 []int          // s1 == nil
s2 := make([]int, 0)  // s2 != nil,但长度为0
nil slice空 slice
定义var s []ints := make([]int, 0)
len00
cap00
是否为 nil

二、map

2.1 map 是什么?

基础概念:

  • map 是哈希表(Hash Table)实现的。
  • 它是无序的键值对集合(key-value pairs)。
  • 通过 key 查找 value,时间复杂度接近 O(1)。
  • 内部依靠哈希算法定位存储位置。

简单栗子

m := map[string]int{
    "apple":  5,
    "banana": 3,
}
fmt.Println(m["apple"]) // 5

2.2 map 的内部结构(底层)

组成部分说明
hmapmap 的整体结构体(元数据)
buckets底层的桶数组,每个桶存多个 key-value 对
overflow buckets桶装满了以后,链式增加溢出桶
哈希函数通过 key 计算 hash 值

简单图示

hmap
 ├── buckets(数组)
 │    ├── bucket[0] -> [k1,v1], [k2,v2], ...
 │    ├── bucket[1] -> [k3,v3], ...
 │    └── ...
 └── overflow(扩展链表)

2.3 map的特点

1. 自动扩容

  • 当负载因子(load factor,大约是 6.5)太高时,map 会自动扩容。
  • 扩容时会重新分配更大的 bucket 数组,并且 逐步搬迁数据(不是一瞬间搬完,叫“渐进式扩容”)。

负载因子

每个桶(bucket)中存放元素的平均数量。

负载因子 = 元素总数 / 桶总数

在Go里,负载因子接近 6.5 左右时,Go 决定扩容。

渐进式扩容流程

  1. 新建两倍大小的新桶数组。

  2. 每次对 map 做 读、写、删除 操作时,顺便 挪一小部分桶的数据

  3. 直到慢慢把所有老数据都搬迁完毕。

搬迁完成后,旧桶会被垃圾回收(GC)回收,内存释放 。

简单图示:

扩容中:
hmap
 ├── buckets -> 新桶数组
 └── oldbuckets -> 旧桶数组(正在搬迁)

搬完后:
hmap
 ├── buckets -> 新桶数组
 └── oldbuckets = nil(旧桶丢弃)

GC 发现没有人引用 oldbuckets -> 回收内存

2. 无序性

  • map 元素的遍历顺序是随机的,每次运行程序顺序可能不同。

  • 目的是防止依赖遍历顺序写代码(安全性考虑)。

3. key 必须是可比较的类型

  • 支持 == 运算符的类型才能作为 key,比如 int、string、struct(字段可比较)。
  • 切片(slice)、map、函数(func)因为不可比较,不能当 key。

4. value 可以是任意类型

  • 甚至可以是另一个 map 或 slice。

2.4 常用操作

1. 创建 map

make 创建

m := make(map[string]int)

字面量创建

m := map[string]int{"one": 1, "two": 2}

2. 访问元素

v := m["key"]

若 key 不存在,返回零值

// 通过第二个值判断是否存在
v, ok := m["key"]
if ok {
    fmt.Println("found:", v)
} else {
    fmt.Println("not found")
}

3. 删除元素

delete(m, "key")

即使删除不存在的 key 也不会报错

4. 遍历 map

for k, v := range m {
    fmt.Println(k, v)
}

注意:遍历顺序是随机的。

2.5 map 并发安全处理

当多个goroutine并发读写同一个 map 时,容易出现:崩溃、数据不一致等问题。

1. 加锁保护

最经典、最常见的方式:

type SafeMap struct {
	mu sync.RWMutex
	m  map[string]int
}

// 写操作(加写锁)
func (s *SafeMap) Set(key string, value int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.m[key] = value
}

// 读操作(加读锁)
func (s *SafeMap) Get(key string) (int, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	val, ok := s.m[key]
	return val, ok
}

2. 使用 sync.Map

从 Go 1.9 开始,标准库里专门增加了一个「并发安全」的 map:sync.Map

var m sync.Map

// 存数据
m.Store("foo", 42)

// 取数据
value, ok := m.Load("foo")

// 删除数据
m.Delete("foo")

3. 对比表

方法特点使用场景
手动加锁(sync.Mutex / sync.RWMutex)最灵活,适合读写都频繁的场景多写多读,复杂场景
sync.Map内置优化,适合读多写少;写特别频繁的话,没原生 map + 手动锁快。缓存、共享配置

三、struct

3.1 struct 的定义和基本使用

最基本的用法 :

type Person struct {
	Name string
	Age  int
}

创建对象的方式:

p1 := Person{"Tom", 30}        // 顺序赋值
p2 := Person{Name: "Jerry"}    // 指定字段赋值,Age = 0
p3 := new(Person)              // 返回 *Person,指针
p4 := &Person{Name: "Spike"}   // 直接取地址

四、string

4.1 string 的本质

string 本质是一个 只读的字节数组[]byte

4.2 string 的特点

特性说明
只读不能修改,任何修改操作都要生成新字符串
内存管理分配在堆上,受 GC 管理
不可变修改时必须重新拷贝
支持 UTF-8默认支持 UTF-8 编码,多字节字符处理

4.3 修改字符串

Go 中 string只读的,不能直接修改,比如:

s := "hello"
s[0] = 'H'  // 编译错误!

正确的修改的方法是:转成 []byte[]rune 再改 ,比如:

s := "hello"
b := []byte(s)
b[0] = 'H'
s2 := string(b)
fmt.Println(s2)  // "Hello"

为什么 string 是只读的

  • 安全性:避免意外篡改共享数据
  • 性能优化:可以放心做引用共享,减少拷贝
  • GC友好:只读数据生命周期简单,容易管理

4.4 len 计算的是什么?

len(s) 返回的是 字节数,不是字符数!

如果要按字符数算,要转成 []rune

s := "你好"
fmt.Println(len(s))  // 输出 6

r := []rune(s)
fmt.Println(len(r))  // 输出 2

4.5 字符串拼接

4.6 复制 string 和浅拷贝

在 Go 里,赋值 string 不会复制底层数据,只是拷贝指针和长度!

s1 := "hello"
s2 := s1

s1 和 s2 共用一块底层内存(只读的,所以安全)。

上次编辑于: