一、序列 ADT

序列(sequence) 关心元素的外在顺序——由使用者决定谁排第几,而不是由元素的值决定。(由值决定顺序的是集合/字典 ADT,见第 7 讲第 15 讲。)

操作 语义
Get(i) / Set(i, x) 按位置读写
InsertAt(i, x) / DeleteAt(i) 在位置 i 插入 / 删除
InsertFirst / InsertLast 头插 / 尾插
DeleteFirst / DeleteLast 头删 / 尾删
Len() 元素个数

⭐ 本讲的核心结论只有一句:没有一种实现在所有操作上都最优,选型取决于你的操作混合比例。


二、静态数组

一块连续内存,第 i 个元素的地址 = base + i × 元素大小

索引:    0     1     2     3     4     5
       ┌─────┬─────┬─────┬─────┬─────┬─────┐
       │  31 │  41 │  59 │  26 │  53 │  58 │
       └─────┴─────┴─────┴─────┴─────┴─────┘
地址:  1000  1008  1016  1024  1032  1040   (int64)
操作 代价 原因
Get(i) / Set(i, x) Θ(1) 一次地址计算
InsertAt(i, x) Θ(n) 要把 i 之后所有元素右移
DeleteAt(i) Θ(n) 左移
尾部插入 静态数组做不到(容量固定)

这里的取舍非常干净:连续存储换来了 O(1) 随机访问,代价是任何改变长度的操作都要搬数据。


三、动态数组

核心思想:容量满时新开一块几何增长的内存,把数据搬过去。第 4 讲已证明:倍增策略下,尾部追加的摊还代价是 Θ(1);线性增长则退化成 Θ(n)。

Go 的 slice 就是内建的动态数组,它的三元组是「指针 + 长度 + 容量」:

type Vector struct {
    data []int
}

func (v *Vector) Len() int      { return len(v.data) }
func (v *Vector) Get(i int) int { return v.data[i] } // Θ(1)

func (v *Vector) Append(x int) { // 摊还 Θ(1)
    v.data = append(v.data, x)
}

func (v *Vector) InsertAt(i, x int) { // Θ(n)
    v.data = append(v.data, 0)   // 先扩一格
    copy(v.data[i+1:], v.data[i:]) // 右移 [i, n) 段
    v.data[i] = x
}

func (v *Vector) DeleteAt(i int) { // Θ(n)
    copy(v.data[i:], v.data[i+1:]) // 左移
    v.data = v.data[:len(v.data)-1]
}

⚠️ Go 特有的两个坑

(1)删除引用类型元素会造成内存泄漏。 上面的 DeleteAt 缩短了长度,但底层数组最后一格仍持有旧引用,GC 无法回收它指向的对象。正确写法要显式清零:

func (s *Stack[T]) Pop() T {
    var zero T
    n := len(s.data) - 1
    x := s.data[n]
    s.data[n] = zero // 关键:断开引用,否则底层数组一直持有它
    s.data = s.data[:n]
    return x
}

(2)切片共享底层数组。 b := a[2:5] 不拷贝数据,对 b 的写入会改到 a。要独立副本必须显式 copy 或用三索引切片 a[2:5:5] 限制容量,让后续 append 触发复制。

预分配的价值

buf := make([]int, 0, n) // 已知规模时一次分配到位

这不改变渐近复杂度(本来就是 Θ(n)),但省掉全部 log n 次拷贝与内存分配。在热路径上这经常是 2–3 倍的实测差距——渐近分析看不见它,profiler 看得见。


四、链表

元素分散在堆上,用指针串起来。

单链表:
 head
  │
  ▼
┌────┬───┐   ┌────┬───┐   ┌────┬───┐
│ 31 │ ●─┼──▶│ 41 │ ●─┼──▶│ 59 │nil│
└────┴───┘   └────┴───┘   └────┴───┘

双链表(带头尾哨兵):
        ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐
 ┌─────▶│ 哨兵头 │◀───▶│  31   │◀───▶│  41   │◀───▶│ 哨兵尾 │◀─────┐
 └──────└───────┘     └───────┘     └───────┘     └───────┘──────┘
type Node[T any] struct {
    val        T
    prev, next *Node[T]
}

type List[T any] struct {
    head, tail *Node[T] // 哨兵节点,不存数据
    size       int
}

func NewList[T any]() *List[T] {
    h, t := &Node[T]{}, &Node[T]{}
    h.next, t.prev = t, h
    return &List[T]{head: h, tail: t}
}

// 在节点 p 之后插入 x,Θ(1)——不需要任何判空分支
func (l *List[T]) InsertAfter(p *Node[T], x T) *Node[T] {
    n := &Node[T]{val: x, prev: p, next: p.next}
    p.next.prev = n
    p.next = n
    l.size++
    return n
}

// 删除节点 p,Θ(1)——已经拿到节点指针时无需查找
func (l *List[T]) Remove(p *Node[T]) T {
    p.prev.next = p.next
    p.next.prev = p.prev
    p.prev, p.next = nil, nil // 帮 GC
    l.size--
    return p.val
}

func (l *List[T]) PushFront(x T) *Node[T] { return l.InsertAfter(l.head, x) }
func (l *List[T]) PushBack(x T) *Node[T]  { return l.InsertAfter(l.tail.prev, x) }

⭐ 哨兵节点(sentinel)

上面的 InsertAfterRemove 没有一个 if。这是因为哨兵头尾节点保证了"每个真实节点都有前驱和后继",把"插到表头"“删掉最后一个元素"这些边界情形变成了普通情形。

没有哨兵的版本要写四个分支(空表 / 头部 / 尾部 / 中间),而链表 bug 的绝大多数正好出在这四个分支上。

这是一条通用的数据结构设计原则:用一点空间消灭边界情形。 红黑树的 nil 哨兵(第 17 讲)、跳表的头节点(第 19 讲)用的是同一个技巧。


五、代价矩阵

操作 静态数组 动态数组 单链表 双链表(带尾指针)
Get(i) Θ(1) Θ(1) Θ(n) Θ(n)
InsertFirst Θ(n) Θ(n) Θ(1) Θ(1)
DeleteFirst Θ(n) Θ(n) Θ(1) Θ(1)
InsertLast Θ(1) 摊还 Θ(1) Θ(1)
DeleteLast Θ(1) Θ(1) Θ(n) Θ(1)
InsertAt(i)(已知位置 i) Θ(n) Θ(n) Θ(n) 找 + Θ(1) 改 Θ(n) 找 + Θ(1) 改
已持有节点指针时删除 Θ(n) Θ(n) Θ(n) Θ(1)
空间开销 0 ≤ n(未用容量) n 个指针 2n 个指针
缓存局部性

表里最重要的一行是倒数第三行:“已持有节点指针时的 Θ(1) 删除"是双链表唯一无可替代的能力。LRU 缓存正是靠它:散列表存 key → 节点指针,双链表维护访问顺序,两者结合得到 O(1) 的 get 与 put。


六、缓存局部性:渐近分析看不见的事

两段代码都是 Θ(n):

sum := 0
for _, x := range arr { sum += x }        // 数组遍历

sum := 0
for p := head; p != nil; p = p.next { sum += p.val }  // 链表遍历

实测差距通常在 5–10 倍。 原因在内存层级:

CPU 从内存取数据时,一次搬一整条缓存行(通常 64 字节)

数组:  [x0][x1][x2][x3][x4][x5][x6][x7] ← 一次缓存未命中,带回 8 个 int64
        └────────── 一条缓存行 ──────────┘
        之后 7 次访问全部命中

链表:  节点散落在堆的各处
        [节点A] ....... [节点C] ... [节点B] .....
        每次 p = p.next 都可能是一次缓存未命中(~100 ns)
        而且指针追逐(pointer chasing)无法被预取器预测

结论:在现代硬件上,遍历密集的场景下动态数组几乎总是赢,即使中间插入是 Θ(n)。C++ 社区有个著名的经验结论:vector 的 O(n) 插入在 n 小于几千时仍快于 list 的 O(1) 插入,因为 memmove 是顺序访问,而链表插入前必须先指针追逐找到位置。

Go 里这一点更明显:[]T 是值的连续存储(如果 T 不是指针),而链表节点每个都是独立的堆分配,还给 GC 增加扫描负担。

那链表什么时候真的该用

场景 为什么
LRU 缓存 / 侵入式链表 已持有节点指针的 O(1) 摘除,且不能有拷贝
需要稳定指针(迭代器不失效) 数组扩容会让所有指针失效,链表不会
无锁队列、内核调度队列 单个节点的原子指针操作
元素巨大且频繁移动 移动一个指针 vs 移动一个 4KB 结构体
需要 O(1) 拼接两个序列 改两个指针即可

Go 标准库的 container/list 就是双链表,但在实践中直接用 slice 的场合远多于它。


七、其他序列变体

结构 特点 代价
循环数组(ring buffer) 头尾都 O(1),见第 6 讲 两端 Θ(1),中间 Θ(n)
分块链表(unrolled list) 每个节点存 √n 个元素的数组 插入/删除/访问都 Θ(√n),缓存友好
跳表 概率式多级索引,见第 19 讲 期望 Θ(log n) 各操作
平衡树序列(rope) 按子树大小定位,见第 20 讲 全部 Θ(log n),文本编辑器用

⭐ 值得注意的是最后一行:如果你同时需要 O(log n) 的随机访问和 O(log n) 的任意位置插入,答案不是链表也不是数组,而是增强过的平衡树——这是第 20 讲的主题。


随堂自测

  1. 为什么动态数组必须几何增长?增长因子 2 与 1.5 各有什么工程理由?
  2. Go 中 s = append(s[:i], s[i+1:]...) 删除元素时,如果 s 的元素是指针类型,会有什么问题?怎么修?
  3. 双链表相对单链表多花一倍指针空间,换来了哪两个能力?
  4. 用哨兵节点重写一个"删除链表中所有值为 x 的节点"的函数,说明哨兵消灭了哪些分支。
  5. 两个都是 Θ(n) 的遍历为什么实测差 5–10 倍?请用缓存行解释。
  6. 设计一个 LRU 缓存,说明为什么必须是"散列表 + 双链表”,单用其中之一为什么不行。
  7. 如果一个序列要频繁在中间位置插入,且需要按下标访问,数组和链表都不好,应该用什么?