Go 的设计很务实:内建的数据结构不算多,但组合能力很强。实际开发通常以内建类型(array、slice、map、struct)打底,再按场景配合 container/* 包和自定义结构。
速览
| 结构 | Go 里常见实现 | 核心特点 | 常见复杂度(平均) |
|---|---|---|---|
| 定长数组 | [N]T | 长度写在类型里,值语义 | 索引 O(1) |
| 动态数组 | []T | 最常用,自动扩容 | 末尾追加均摊 O(1),索引 O(1) |
| 哈希表 | map[K]V | key 查找快,遍历无序 | 查找/插入/删除 O(1) |
| 结构体 | struct | 聚合字段,可组合方法 | 字段访问 O(1) |
| 栈 | []T | LIFO,最简单高效 | push/pop 末尾 O(1) |
| 队列 | []T 或 container/list | FIFO | 入队/出队 O(1)(实现得当) |
| 集合 | map[T]struct{} | 用 map 模拟 set | 增删查 O(1) |
| 堆 | container/heap | 优先队列 | push/pop O(log n) |
| 链表 | container/list | 插入删除灵活 | 已定位节点后插删 O(1) |
内建结构
Array:定长数组
var a [3]int = [3]int{10, 20, 30}
fmt.Println(a[1]) // 20
- 长度是类型的一部分,
[3]int和[4]int是不同类型。 - 数组赋值会拷贝整个数组(值语义)。
- 使用频率不如
slice高,但固定长度场景表达清晰。
Slice:最常用的动态序列
slice 本质是一个描述符(指向底层数组 + len + cap)。
s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println(len(s), cap(s), s) // 4 ... [1 2 3 4]
t := s[1:3] // [2 3]
append可能触发扩容并更换底层数组,因此函数内修改时建议接收并返回新slice。- 切片共享底层数组,子切片可能“牵连”原数据。
Map:哈希表
scores := map[string]int{
"alice": 90,
"bob": 82,
}
scores["cathy"] = 95
delete(scores, "bob")
v, ok := scores["alice"]
fmt.Println(v, ok)
- 读取不存在的 key 会返回 value 类型零值。
- 用
v, ok := m[k]判断是否存在。 map不是并发安全的;并发读写要加锁或使用sync.Map。- 遍历顺序不保证稳定。
Struct:业务建模核心
type User struct {
ID int
Name string
Email string
}
u := User{ID: 1, Name: "Calvin", Email: "a@b.com"}
fmt.Println(u.Name)
struct 常与方法、接口配合使用,是 Go 建模的基础。通过组合(embedding)可以实现轻量复用。
常见容器模式
Stack 与 Queue:通常用 Slice 就够了
栈(LIFO):
stack := []int{}
stack = append(stack, 10)
stack = append(stack, 20)
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
fmt.Println(top) // 20
队列(FIFO,简单写法):
queue := []int{}
queue = append(queue, 1, 2, 3)
head := queue[0]
queue = queue[1:]
fmt.Println(head) // 1
注意:长期大量出队时,简单切片队列可能导致底层数组无法及时释放。高频队列建议实现环形缓冲区,或使用 container/list。
Set:用 Map 模拟
Go 没有内建 set,最常见写法如下:
set := map[string]struct{}{}
set["go"] = struct{}{}
set["rust"] = struct{}{}
_, exists := set["go"]
delete(set, "rust")
fmt.Println(exists)
值用 struct{} 可以做到几乎零额外空间占用(相较 bool,常见于性能敏感场景)。
Heap:优先队列
标准库提供 container/heap,实现一组接口即可使用。
type IntHeap []int
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
典型场景:Top K、任务调度、Dijkstra、合并多个有序流。
List:双向链表
container/list 是双向链表,适合“已知节点位置时高频插删”。
l := list.New()
e1 := l.PushBack("A")
l.PushBack("B")
l.InsertAfter("C", e1)
for e := l.Front(); e != nil; e = e.Next() {
fmt.Println(e.Value)
}
如果主要是顺序访问,slice 往往更快(缓存友好),优先考虑 slice。
哈希冲突与 Go 1.24 map
Open Hashing vs Closed Hashing
术语容易混淆,先统一:
- Open Hashing:通常指
separate chaining(拉链法) - Closed Hashing:通常指
open addressing(开放寻址)
也就是名字有点“反直觉”:closed hashing 反而是 open addressing。
Open Hashing (拉链法 / Separate Chaining)
index: 0 1 2 3
[ ] ---> [A]->[B] [ ] ---> [C] [D]->[E]->[F]
(冲突后挂链表) (冲突后继续挂在链上)
特点:冲突元素在桶外(额外节点)通过指针连接。
Closed Hashing (开放寻址 / Open Addressing)
slots: [A] [ ] [B] [C] [ ] [D] [ ] [ ]
hash(K) -> 2
若 2 被占用:探测 3、4、5... 直到找到空槽
特点:所有元素都放在主表里,通过探测序列解决冲突。
如果 Hugo 环境支持 Mermaid,可用下图展示:
为什么 Go 1.24 转向 Swiss Table 风格
Go 1.24 重写了 map 底层实现,核心方向是从“桶 + 溢出桶链”转向“更紧凑的开放寻址探测”。
- 减少指针跳跃,提升缓存命中:查找路径更连续,对现代 CPU 更友好。
- 利用元数据批量筛选候选槽位:支持 SIMD 时可并行化比对。
- 在更高装载因子下维持性能:减少溢出结构带来的额外开销。
- 扩容更平滑:降低单次大搬迁造成的长尾抖动。
一句话总结:从“冲突后挂链表(指针追逐)”升级为“冲突后主表探测(连续访问 + 元数据筛选)”,在现代硬件上换取更高吞吐与更稳延迟。
对业务代码的影响
- API 兼容:
map用法不变,业务代码通常无需调整。 - 性能收益:查找/插入在很多场景更快,尤其是大 map 或高冲突。
- 并发约束不变:
map仍非并发安全,读写并发仍需同步控制。
选型建议
- 顺序存储、随机访问:优先
slice - 键值检索:
map - 强业务建模:
struct+ 方法 + 接口 - Top K / 动态最值:
heap - 高频中间插删且持有节点引用:
list - 并发 key-value:
map + sync.RWMutex或sync.Map
小结
Go 的数据结构哲学是“少而精”:
- 内建类型承担大多数工作
- 标准库补齐少量通用容器
- 复杂场景通过组合实现
把 slice、map、struct 用扎实,再掌握 heap 和 list,基本能覆盖大部分工程和算法题场景。