数组与切片:连续内存的红利.
连续内存买到两样东西:O(1) 随机访问和 cache 友好;切片是数组的租约——长度、容量、扩容摊还,一次讲清。
上一篇结尾说:把「基址 + 偏移」放大到 n 个元素,就是数组。这一篇兑现它,并把 Go 日常真正用的东西——切片——拆到头。连续内存这份承诺买到两样红利:一步到达的随机访问,和 cache 喂到饱的扫描速度;代价也明码标价:中间动一格,后面全搬家。
下标就是算术.
数组的全部秘密就一条:元素挨着放。于是第 i 格在哪根本不用找,是算出来的:
arr := [5]int64{3, 7, 11, 23, 42}
_ = arr[3] // 编译成一条地址算术:基址 + 3×8,一步到达对比上一篇的伏笔:这就是 O(1) 随机访问的物理来源——不是数组「很快」,是地址可以直接算。也立刻能看到代价:想在中间插入一个元素,第 i 格之后的所有人都得往后挪一格,删除同理往前挪——中间增删是 O(n) 的搬家。这笔账记住,下一篇链表就是冲着它来的。
切片:数组的租约.
Go 里几乎没人直接用数组,用的是切片。切片不是另一种容器,是压在数组上的一张三格租约:
arr := [5]int64{3, 7, 11, 23, 42}
s := arr[1:4] // ptr→&arr[1], len=3, cap=4
s[0] = 99 // 改的就是 arr[1]——同一格内存三格租约解释了切片的一切「怪事」:s[0] = 99 改到 arr[1],因为 ptr 指的就是那格——两个切片共享底层数组时,一边写、两边见,这是上一篇值拷贝的直接推论(拷贝切片头 = 拷贝三个字段,ptr 抄的还是同一个地址)。函数传切片同理:头是副本,底层数组是同一片。
扩容:搬一次家,买一段免费.
租约住满(len == cap)还要 append,就只剩搬家一条路:
住满:append 无格可加
搬家:新数组翻倍,旧居等回收
搬家是整批拷贝,O(n);但换来 cap 翻倍,后面一整段 append 都免费——复杂度篇埋的摊还 O(1) 在这里兑现:偶尔一次大账摊到每次头上,还是常数。旧数组从此无人指着,等垃圾回收——和链表删除的孤儿是同一种命运。
Go 具体怎么扩?不用背,跑一遍就知道。下面是我在 go1.26.5 上的真实输出(cap 变化时打印):
var s []int
for i := 0; i < 2049; i++ {
s = append(s, i) // cap 变化时打印 len 和 cap
}len=1 cap=4
len=5 cap=8
len=9 cap=16
len=17 cap=32 ← 小时翻倍
len=513 cap=848
len=849 cap=1280 ← 大了以后 ~1.33×,省内存
len=1793 cap=2560策略是版本细节,会变;摊还的形状不变。这也是本系列的裁决顺序:结论自己跑,不背二手数字。
二分:有序 + 连续,log 的第一次兑现.
连续内存还有一张暗牌:配上有序,查找从 O(n) 掉到 O(log n)——复杂度篇那条「每步砍半」的曲线,第一次落地:
func binarySearch(nums []int64, target int64) (steps, index int) {
lo, hi := 0, len(nums)-1
for lo <= hi {
steps++
mid := (lo + hi) / 2
switch {
case nums[mid] == target:
return steps, mid
case nums[mid] < target:
lo = mid + 1 // 弃左半
default:
hi = mid - 1 // 弃右半
}
}
return steps, -1
}注意它为什么必须长在数组上:mid := (lo+hi)/2 之后要一步跳到第 mid 格——只有「地址可以算」的连续内存给得起这一步。链表给不起,这是下一篇的第一道对比题。
同为 O(n),不同速:cache 的账.
最后兑现复杂度篇那句丑话:Big O 丢掉的常数,机器要收。同样扫一遍 100 万个 int64 求和,我在这台 Apple M4 Pro(go1.26.5)上的实测:
| 布局 | 单次耗时 | 相对 |
|---|---|---|
| 切片(连续内存) | ~0.78ms | 1× |
| 链表·节点恰好连续分配 | ~1.05ms | ~1.35× |
| 链表·节点乱序分布(常态) | ~19ms | ~25× |
三个都是 O(n)。差距来自 cache:CPU 按整条 cache line 预取内存,扫连续数组时下一个元素几乎总在手边;顺着指针跳的链表,每跳都可能落在冷内存上——乱序那行就是日常链表用久之后的样子。数字换机器会变,方向不会;你可以用 go test -bench 十分钟复现。
红利与代价都摊开了:随机访问 O(1)、扫描喂饱 cache、二分白送 log——换来的是中间增删 O(n) 的搬家。下一篇轮到把这笔账反过来的结构:链表——绕过,而不是抹除。
— 讨论
GitHub评论.
评论由 GitHub Discussions 承载。请围绕文章内容交流,并保持克制、友善。
评论暂时没有加载出来。可以稍后刷新重试,或直接到 GitHub Discussions 参与讨论。