上一篇结尾说:把「基址 + 偏移」放大到 n 个元素,就是数组。这一篇兑现它,并把 Go 日常真正用的东西——切片——拆到头。连续内存这份承诺买到两样红利:一步到达的随机访问,和 cache 喂到饱的扫描速度;代价也明码标价:中间动一格,后面全搬家。

下标就是算术.

数组的全部秘密就一条:元素挨着放。于是第 i 格在哪根本不用找,是算出来的:

五个相邻格子存着 3、7、11、23、42,上方是下标 0 到 4,下方是地址 0xD000 到 0xD020;橙色公式指着第 3 格:0xD000 加 3 乘 8 等于 0xD018 DATA STRUCTURES · 数组 下标就是算术. [0] 3 0xD000 [1] 7 0xD008 [2] 11 0xD010 [3] 23 0xD018 [4] 42 0xD020 arr[3] = 0xD000 + 3×8 = 0xD018 第 i 格的地址 = 基址 + i × 元素宽。一步算出,一步到达——O(1) 随机访问。
arr := [5]int64{3, 7, 11, 23, 42}
_ = arr[3] // 编译成一条地址算术:基址 + 3×8,一步到达

对比上一篇的伏笔:这就是 O(1) 随机访问的物理来源——不是数组「很快」,是地址可以直接算。也立刻能看到代价:想在中间插入一个元素,第 i 格之后的所有人都得往后挪一格,删除同理往前挪——中间增删是 O(n) 的搬家。这笔账记住,下一篇链表就是冲着它来的。

切片:数组的租约.

Go 里几乎没人直接用数组,用的是切片。切片不是另一种容器,是压在数组上的一张三格租约

切片头是三格记录体:ptr 格里的绿点指向底层数组的第 1 格,len 是 3,cap 是 4;下方五格数组标着下标与地址,len 和 cap 的括线标出租约范围 DATA STRUCTURES · 切片 切片是数组的租约. s := arr[1:4] 3 4 ptr len cap [0] 3 0xD000 [1] 7 0xD008 [2] 11 0xD010 [3] 23 0xD018 [4] 42 0xD020 len 3:现在住到哪 cap 4:最多能住到哪
s := arr[1:4]——ptr 记起点,len 记现在住到哪,cap 记最多能住到哪(到底层数组末尾为止)。
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 无格可加

扩容前:切片头 ptr、len 4、cap 4,绿点箭头指向装满 1、2、3、4 的四格底层数组——append 第五个元素时没有空格子 DATA STRUCTURES · 扩容前 len == cap,没有空格了. 4 4 ptr len cap 1 0xD000 2 0xD008 3 0xD010 4 0xD018 append(s, 5)?租约住满了——要么原地无格可加,要么整体搬家。

搬家:新数组翻倍,旧居等回收

扩容后:新切片头 ptr、len 5、cap 8,橙色箭头指向 0xE000 起的八格新数组,前五格是 1 到 5,后三格空着;旧四格数组虚线灰置于下方等待回收 DATA STRUCTURES · 扩容后 搬一次家,买来一段免费. 5 8 ptr len cap 1 E000 2 E008 3 E010 4 E018 5 E020 E028 E030 E038 旧居 0xD000,无人再指 1 2 3 4 整批拷贝 O(n) 一次,换来 cap 内多次免费 append——这就是摊还 O(1) 的账。

搬家是整批拷贝,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)——复杂度篇那条「每步砍半」的曲线,第一次落地:

二分查找 42 的三行推进:八个有序格子上 lo、mid、hi 三个绿色下标记号逐步夹逼;第一行探中 23 弃左半,第二行探中 56 弃右半,第三行命中 42,值为橙色 DATA STRUCTURES · 二分 每步砍半,三步夹逼. 3 7 11 23 42 56 71 88 lo mid hi 42 > 23,弃左半 3 7 11 23 42 56 71 88 lo mid hi 42 < 56,弃右半 3 7 11 23 42 56 71 88 命中:第 3 步 lo = mid = hi 8 个元素最坏 3 步;一百万个,20 步——log 在有序的连续内存上兑现。
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.05ms~1.35×
链表·节点乱序分布(常态)~19ms~25×

三个都是 O(n)。差距来自 cache:CPU 按整条 cache line 预取内存,扫连续数组时下一个元素几乎总在手边;顺着指针跳的链表,每跳都可能落在冷内存上——乱序那行就是日常链表用久之后的样子。数字换机器会变,方向不会;你可以用 go test -bench 十分钟复现。

红利与代价都摊开了:随机访问 O(1)、扫描喂饱 cache、二分白送 log——换来的是中间增删 O(n) 的搬家。下一篇轮到把这笔账反过来的结构:链表——绕过,而不是抹除