深色模式
01 · 复杂度与数组、链表
复杂度是这一模块的"度量尺",数组和链表是"最基础的两个极端"。看懂它们的取舍,后面的所有结构都只是"在这两端之间做混合"。
一句话本质
- 复杂度:不看秒数,看"数据量翻倍时,耗时怎么变"。
- 数组 vs 链表:一个把数据摆得紧凑、靠位置直接算地址;一个把数据撒开、靠指针一个个跳。
生活类比:连排座位 vs 寻宝纸条
数组 = 电影院的连排座位
[A][B][C][D][E][F]
0 1 2 3 4 5- 知道第 3 排在哪,走几步就到 > 随机访问 O(1)
- 中间要插一个人 > 后面所有人往后挪 > 插入 O(n)
- 座位是连着的,一排人坐一起,服务员一次能服务好几个 > 缓存友好
链表 = 寻宝游戏
[A|>] [B|>] [C|>] [D|>] [E|∅]
^ ^
每张纸条写着下一张在哪- 想找第 4 张 > 必须从第 1 张开始,一张张跟 > 查找 O(n)
- 想在 B 后面插一张 > 只需改两个指针 > 插入 O(1)(前提是你已经站在 B 那里)
- 每张纸条藏在不同地方,跑来跑去很费腿 > 缓存不友好
核心机制
一、复杂度:怎么"看出来"
O(1) 常数 哈希查找、数组按下标访问
O(log n) 对数 二分、平衡树查找 < 数据翻倍,只多一步
O(n) 线性 遍历、链表查找
O(n log n) 快排、归并 < 排序的下界
O(n²) 平方 双重循环、冒泡
O(2ⁿ) 指数 暴力枚举子集看出来的三个口诀:
- 看到"每次砍一半"> log n(二分、树)
- 看到"单层循环"> n,"嵌套两层"> n²
- 看到"递归里每次分成两半各做一遍"> n log n(归并、快排)
只保留最高阶,忽略常数:3n² + 100n + 5 > O(n²)。因为 n 足够大时,低阶项和常数可以忽略。
但工程里常数很重要:同样是 O(n log n),快排通常比归并快 2~3 倍(缓存友好、常数小)。所以面试答"复杂度相同"之后,补一句"实际快排更快,因为内存访问模式更好",会很加分。
二、数组的地址计算(为什么是 O(1))
数组起始地址 base = 0x1000,每个元素 4 字节
访问 a[3]:
地址 = base + 3 × 4 = 0x1000 + 12 = 0x100C一次乘加就出地址,不需要遍历。这就是 O(1) 的本质——不是"快",是"计算量不随 n 增长"。
二维数组:a[i][j] 的地址 = base + (i × 列数 + j) × 元素大小。所以按行遍历(i 在外层)比按列遍历快得多——因为按列遍历会不断跳过整行,缓存命中率极低。
三、链表的几种形态
单链表: A > B > C > ∅ 只能前进
双链表: A ⇄ B ⇄ C > ∅ 能前进能后退(多一个指针,空间翻倍)
循环链表: A > B > C > A 尾接回头哨兵节点(dummy head):在头部加一个空节点,让"在头部插入"和"在中间插入"的代码统一,不用特判。这是写链表题的实用技巧。
四、数组 vs 链表:完整对比
| 操作 | 数组 | 链表 | 说明 |
|---|---|---|---|
| 按下标访问 | O(1) | O(n) | 数组的核心优势 |
| 头部插入 | O(n) | O(1) | 数组要整体后移 |
| 尾部插入 | O(1) 摊销 | O(1) | 数组扩容时是 O(n),但摊销后 O(1) |
| 中间插入 | O(n) | O(1)* | *前提是已定位到位置,找位置本身是 O(n) |
| 删除 | O(n) | O(1)* | 同上 |
| 查找值 | O(n) | O(n) | 都要遍历(除非有序) |
| 内存开销 | 小(无指针) | 大(每节点 1~2 个指针) | |
| 缓存友好 | 好 | 差 | 这是实测差距的主要来源 |
| 扩容 | 需重新分配 + 拷贝 | 天然支持 |
"中间插入 O(1)"是个陷阱:你只知道"改指针是 O(1)",但找到那个位置要 O(n)。所以链表插入的完整复杂度是 O(n)。面试常在这里挖坑。
五、为什么数组实测快这么多(接 OS 篇)
CPU 读内存不是按字节读,是按"缓存行"(通常 64 字节)读
> 读 a[0] 时,a[1]~a[15] 也一起进了缓存
> 遍历数组时,后面 15 次访问全部命中缓存
链表节点散落在堆上
> 读 A 节点时,B 节点在完全不同的地址
> 每次跳转都是一次缓存未命中 > 等内存(约 100 个时钟周期)
差距量化:
数组遍历 1 亿个元素:约 0.1 秒
链表遍历 1 亿个元素:约 1~2 秒(10 倍以上差距)
虽然都是 O(n)这就是"复杂度相同但实际差 10 倍"的经典案例。也是为什么 Java 的 ArrayList 在大多数场景下比 LinkedList 快——尽管教科书说链表插入快。
为什么这么设计
反事实 1:如果数组不连续
那就不叫数组了。"连续"是数组能 O(1) 访问的唯一前提——因为只有连续,才能用"基址 + 偏移"算出地址。一旦允许分散,就必须额外存映射关系(那就是哈希表或页表了)。
反事实 2:如果链表节点连续
那它就成了数组(或者说"用数组实现的链表")。链表的全部价值就在于"插入删除不影响其他元素的位置"——而这就要求节点不能连续(否则挪动一个会影响后面所有)。
反事实 3:如果数组不预留空间
每次插入都重新分配 + 拷贝,插入 n 次就是 O(n²)。所以 ArrayList 会按 1.5 倍扩容,用"浪费一点空间"换"摊销 O(1)"。
摊销分析:扩容一次 O(n),但平均到 n 次插入上,每次是 O(1)。这就是"摊销 O(1)"的含义。
面试怎么问
Q:数组和链表的区别?
别只答"一个连续一个不连续"。要按操作答:随机访问数组 O(1)、链表 O(n);插入删除链表理论上快但定位仍 O(n);再加一句实测差距来自缓存局部性——这句是区分度所在。
Q:为什么数组随机访问是 O(1)?
因为地址可以算出来:
base + index × size,一次乘加,与 n 无关。链表必须从头遍历,因为节点地址不连续、无法计算。
Q:链表插入是 O(1) 吗?
分两种说法:"已知前驱节点时,插入本身 O(1)";"从头找位置再插入是 O(n)"。完整答案要区分这两层,否则会被追问打脸。
Q:ArrayList 扩容为什么是 1.5 倍而不是 2 倍?
1.5 倍(Java)比 2 倍(C++ vector)更"温和":2 倍容易导致"上一次扩容释放的空间 + 这次需要的空间"都装不下(因为总和超过可用内存),1.5 倍能让释放的空间被复用。这是经验性的工程取舍,不是理论最优。
追问链:
数组和链表区别?
> 数组为什么 O(1)?(地址可计算)
> 那链表为什么不行?(不连续,地址不可算)
> 链表插入真的 O(1) 吗?(定位仍 O(n))
> 既然链表插入快,为什么实际更慢?(缓存不友好 + 指针开销)
> 那什么时候真的该用链表?(频繁在已知位置插入删除,如 LRU 的双向链表)和其他知识的连接
- < 来自 虚拟内存:让每个程序都以为自己独占一台机器:缓存行、TLB、内存布局,是"数组实测更快"的底层原因
- > 影响 栈、队列与哈希表:栈和队列都可以用数组或链表实现,选哪个取决于操作模式
- > 影响 树与堆:完全二叉树用数组存储(下标 i 的孩子是 2i+1 和 2i+2),这就是堆的实现方式
- > 影响 索引与 B+ 树:B+ 树的叶子节点用链表串联,就是为了范围查询能顺序扫
- > 影响 工程实践:Java
ArrayListvsLinkedList、Redis 的 quicklist(链表套数组)、Kafka 的日志段(顺序写 + 稀疏索引)
自测 3 问
Q1:遍历 1 亿个元素,数组和链表都是 O(n),为什么实测差 10 倍?
点开看答案
因为CPU 缓存。
CPU 读内存按"缓存行"(64 字节)为单位。数组连续,读 a[0] 时会把 a[0] 到 a[15](假设 int)一起载入缓存,后面 15 次访问全部命中,几乎不花时间。
链表节点分散在堆的各个角落,每次 node = node->next 都跳到新地址,大概率缓存未命中,必须去主存取(约 100 个时钟周期)。而且 CPU 的硬件预取器能识别"顺序访问"提前加载,对随机跳转完全无能为力。
所以:复杂度衡量的是"操作次数",不衡量"每次操作多贵"。缓存未命中的代价,是复杂度分析里被省略的那个常数。
Q2:ArrayList 的 add 是 O(1) 还是 O(n)?
点开看答案
摊销 O(1)。
- 大多数情况下直接放到末尾,O(1)。
- 容量满了要扩容:申请 1.5 倍的新数组 + 拷贝全部元素,这一次是 O(n)。
但扩容不是每次都发生。假设从容量 1 开始翻倍,n 次插入总共拷贝约 2n 次,平均到每次是 O(1)。这叫摊销分析。
注意:摊销 O(1) 不代表"每次都很快"。偶尔会有一次明显的卡顿(扩容),这对延迟敏感的系统是问题——所以有时会预分配容量(new ArrayList<>(10000))。
Q3:什么场景下链表真的比数组好?
点开看答案
必须同时满足两个条件:① 频繁在已知位置插入/删除;② 不需要随机访问。
典型场景:
- LRU 缓存:哈希表 + 双向链表。哈希表 O(1) 定位节点,链表 O(1) 把节点移到头部/删除。
- 操作系统的进程就绪队列:进程频繁进出队列,不需要按位置访问。
- 大对象的容器:元素本身很大(拷贝代价高),链表只移动指针。
反例(常见误用):LinkedList 做"频繁随机插入"——每次插入前都要遍历找位置,实际比 ArrayList 慢得多。Java 官方文档自己都说 LinkedList 很少是最佳选择。
类比失效处 / 易错点
- "电影院座位"会让人以为数组插入永远慢。尾部追加是摊销 O(1),只有中间插入才 O(n)。
- "链表插入 O(1)"漏掉了"定位"这一步。完整代价是 O(n),除非你已经持有前驱指针。
- 复杂度不是"实际耗时"。同样的 O(n log n),快排和归并可能差 2~3 倍;同样的 O(n),数组和链表差 10 倍。
ArrayList扩容 1.5 倍 vsvector2 倍:没有绝对优劣,1.5 倍更容易复用已释放的内存,2 倍扩容次数更少。别把某一个说成"标准答案"。- 链表不一定省内存。每个节点多一个指针(8 字节),如果存的是 int(4 字节),空间开销直接翻 3 倍。
✅ 验收清单
- 能说出 O(1) / O(log n) / O(n) / O(n log n) / O(n²) 各自的典型场景
- 能"看出来"复杂度:给一段代码能说出是 O(n) 还是 O(n²) 还是 O(n log n)
- 能写出数组地址计算公式,并解释"为什么随机访问是 O(1)"
- 能完整对比数组和链表的 6 种操作复杂度,并指出"链表插入 O(1)"的陷阱
- 能用缓存行解释"为什么数组实测比链表快 10 倍"
- 能解释摊销 O(1) 的含义,并说出预分配容量的意义
- 能举出一个"链表真的比数组好"的场景,并说清原因
- 合上书,用自己的话讲 3 分钟,中途不卡壳