引言
O(n)、O(log n)、O(n log n)——这些记号人人会读,但真正能在工程里用起来的却不多。本文不讲竞赛题,而是把复杂度变成工程直觉:先厘清 Big-O 记号的语言(上界、下界、Theta),再给一张"数据结构 × 操作"的速查表(这是面试与设计的地基),接着讲递归复杂度怎么算(主定理)、摊还分析是什么(ArrayList 扩容、双端队列)、以及最容易被忽略的一点——复杂度是渐进的,常数才决定现实性能。最后给"复杂度预算"的方法:上线前估算、跑分验证,而不是靠感觉。
前置:/regex-deep-dive/(算法复杂度在正则引擎中的体现)、/dsl-design/(解析器的复杂度权衡)。语言与结构基础见 [[cs-fundamentals]]、[[algorithm-interview]]。
目录
- 1. 复杂度记号:Big-O、Big-Ω 与 Big-Θ
- 2. 速查表:常见数据结构的时间复杂度
- 3. 排序与查找:稳定记忆
- 4. 递归复杂度:主定理与分治
- 5. 摊还分析:扩容、双端队列与均摊真相
- 6. 空间复杂度:别只算时间
- 7. 复杂度 vs 常数:渐进记号的两面性
- 8. 常见陷阱:把 O(n²) 写成 O(n)
- 9. 工程实践:复杂度预算与基准验证
- 10. 速查表与一句话记忆
- 延伸阅读
1. 复杂度记号:Big-O、Big-Ω 与 Big-Θ
三个记号描述同一个函数的不同侧面,但工程里 99% 的时间只用 Big-O(上界):
| 记号 | 含义 | 口语 |
|---|---|---|
O(f(n)) | 渐近上界 | 最坏不超过这个量级 |
Ω(f(n)) | 渐近下界 | 至少是这个量级 |
Θ(f(n)) | 紧界(上=下) | 就是这个量级 |
关键点:说"这个算法是 O(n)“在严格意义上只说它是”≤ n 量级",但工程口语里大家都默认是最坏情况上界。所以别抠字眼,记住:
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)
为什么常数被丢掉:3n + 100 与 n 的增长率完全相同——n 翻一倍,都约翻一倍。Big-O 只看增长曲线,不看系数。
怎么从代码读复杂度:
def find_all(nums, target):
result = []
for x in nums: # 外层循环 → O(n)
if x == target:
result.append(x) # 常数操作
return result # 总 O(n)
def has_duplicate(nums):
seen = set()
for x in nums: # 每元素 O(1)(哈希)
if x in seen:
return True
seen.add(x)
return False # 总 O(n),不是 O(n²)!
记忆:循环嵌套相乘、顺序相加、哈希/数组是 O(1) 替身——读代码先找循环层数与每层内部的复杂度。
2. 速查表:常见数据结构的时间复杂度
这是面试与设计的地基,值得背下来:
| 数据结构 | 查找 | 插入 | 删除 | 取下标 | 备注 |
|---|---|---|---|---|---|
| 数组(动态) | O(n) | O(n) 摊还 O(1) | O(n) | O(1) | 缓存友好 |
| 有序数组 | O(log n) | O(n) | O(n) | O(1) | 二分查找 |
| 链表 | O(n) | O(1)(头部) | O(1)(已知节点) | O(n) | 顺序访问差 |
| 哈希表 | O(1) 平均 | O(1) 平均 | O(1) 平均 | — | 无序 |
| 平衡树 | O(log n) | O(log n) | O(log n) | — | 有序 |
| 堆 | O(n) | O(log n) | O(log n) | — | 取最值 O(1) |
| 跳表 | O(log n) | O(log n) | O(log n) | — | 有序、易并发 |
| 栈 / 队列 | O(n) | O(1) | O(1) | — | 受限接口 |
| 前缀树 | O(L) | O(L) | O(L) | — | L=串长 |
三个"反直觉但重要"的点:
- 数组的插入是摊还 O(1)——尾部追加平均 O(1),头部插入永远 O(n)(要搬移)。
- 哈希表平均 O(1)、最坏 O(n)——冲突时退化成链表;好的实现会用红黑树兜底(Java 8 HashMap)。
- “有序"是有代价的——需要排序访问就要平衡树/跳表,别拿数组排序硬撑。
# 用对结构,复杂度天差地别
nums = [3, 1, 4, 1, 5, 9, 2, 6]
# 查 5 次:数组线性 → O(5n)
# 建一次集合再查:O(n) 建 + O(5) 查
seen = set(nums)
print(5 in seen) # O(1)
记忆:哈希换"无序的 O(1)"、树换"有序的 O(log n)"、数组换"下标 O(1) + 缓存”——选结构就是选访问模式。
3. 排序与查找:稳定记忆
排序复杂度一句话版:
| 算法 | 平均 | 最坏 | 稳定 | 原地 | 何时用 |
|---|---|---|---|---|---|
| 快速排序 | O(n log n) | O(n²) | 否 | 是 | 通用首选 |
| 归并排序 | O(n log n) | O(n log n) | 是 | 否 | 稳定性/外排 |
| 堆排序 | O(n log n) | O(n log n) | 否 | 是 | 空间受限 |
| 插入排序 | O(n²) | O(n²) | 是 | 是 | 近乎有序的小数据 |
| 计数排序 | O(n+k) | O(n+k) | 是 | 否 | 小范围整数 |
| 基数排序 | O(d(n+k)) | O(d(n+k)) | 是 | 否 | 定长整数/串 |
记忆锚点:快排平均最快但最坏退化(可用随机化/三取样规避);归并永远 n log n 且稳定(代价是额外空间);插入排序对"近乎有序"数据是 O(n)——这是工程里比堆排序更常用的真相。
查找:
线性查找 O(n) 无序数组
二分查找 O(log n) 有序数组(前提:有序!)
哈希查找 O(1) 平均 无序但无范围查询
一个工程直觉:在 n 只有几百时,O(n²) 的插入排序跑得可能比 O(n log n) 的快排还快——因为常数小、缓存好。复杂度决定"增长",常数决定"当下"。
# 近乎有序数组,插入排序几乎 O(n)
data = sorted([i * 3 % 97 for i in range(1000)])
for i in range(1, len(data)):
key = data[i]
j = i - 1
while j >= 0 and data[j] > key:
data[j + 1] = data[j]
j -= 1
data[j + 1] = key
4. 递归复杂度:主定理与分治
递归算法的复杂度不能用"数循环"读出来,需要主定理(Master Theorem):
形如 T(n) = a·T(n/b) + O(n^d)(把规模 n 分成 a 个规模 n/b 的子问题,合并成本 O(n^d)):
若 log_b(a) > d → T(n) = O(n^log_b(a)) 分治主导(如矩阵乘)
若 log_b(a) = d → T(n) = O(n^d · log n) 分层主导(如归并、快排平均)
若 log_b(a) < d → T(n) = O(n^d) 合并主导(如基于扫描的分治)
对照常见算法:
| 递归式 | log_b(a) vs d | 结果 | 例 |
|---|---|---|---|
T(n)=2T(n/2)+O(n) | log₂2=1 = d=1 | O(n log n) | 归并、快排平均 |
T(n)=T(n/2)+O(1) | log₂1=0 < d=0 | O(log n) | 二分查找 |
T(n)=2T(n/2)+O(1) | log₂2=1 > d=0 | O(n) | 树遍历 |
T(n)=2T(n/2)+O(n²) | 1 < d=2 | O(n²) | 某些几何分治 |
T(n)=T(n-1)+O(1) | 非分治 | O(n) | 线性递归 |
当主定理不适用(子问题不等分、合并成本非多项式),用递归树目测:每层总工作量 × 层数。
# 二分查找递归式:T(n)=T(n/2)+O(1) → O(log n)
def bsearch(arr, lo, hi, target):
if lo > hi:
return -1
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
if arr[mid] < target:
return bsearch(arr, mid + 1, hi, target)
return bsearch(arr, lo, mid - 1, target)
记忆:主定理三选一——分治主导(>)、分层主导(=)、合并主导(<);等号情形最常见的产物就是
O(n log n)。
5. 摊还分析:扩容、双端队列与均摊真相
有些操作"单次很贵,但平均便宜"——**摊还分析(Amortized Analysis)**算的就是这个平均。
经典案例:动态数组扩容
# ArrayList 式扩容:满了就翻倍
class DynArray:
def __init__(self):
self.arr = [None] * 1
self.n = 0
def append(self, x):
if self.n == len(self.arr): # 满了 → 翻倍复制
self.arr = self.arr + [None] * len(self.arr)
self.arr[self.n] = x
self.n += 1
单次 append 最坏 O(n)(扩容复制),但平摊下来是 O(1):
翻倍策略下,复制总代价 = 1 + 2 + 4 + ... + n = 2n - 1
n 次 append 总代价 ≈ 3n → 每次均摊 O(1)
双端队列(Deque):两端插入都是均摊 O(1)——头部插入不需要搬移(预留空间 + 环形)。这正是"用 Deque 代替在 List 头部 insert"的原因。
什么时候用摊还:接口承诺"均摊 O(1)“的数据结构(Java ArrayList、ArrayDeque、Go slice 扩容、std::vector)——单次慢可以接受,只要长期平均快。实时性要求高的场景(金融、硬实时)才在乎最坏 O(n),此时用"每插入都保证 O(1)“的结构(如预分配 + 分块链表)。
from collections import deque
# 头部插入:deque 是 O(1),list 是 O(n)
dq = deque(range(100000))
dq.appendleft(999) # 快
lst = list(range(100000))
lst.insert(0, 999) # 慢:全部右移
记忆:“均摊 O(1)“是扩容型结构对并发/实时的免责声明——普通场景放心用,实时场景看最坏。
6. 空间复杂度:别只算时间
面试和工程都只盯时间,但空间会反过来吃掉时间(缓存未命中、GC 压力)。
规则:
- 输入规模 n 的数据 → 本身占
O(n)(不算额外空间) - 递归深度 → 栈空间
O(深度),快排最坏O(n)栈深,递归二分O(log n) - 哈希表/集合 →
O(n)空间换时间 - 原地算法 →
O(1)额外空间
一个典型权衡:去重
# 方案 A:用集合 → 时间 O(n),空间 O(n)
def dedup_a(nums):
return list(set(nums))
# 方案 B:先排序再去重 → 时间 O(n log n),空间 O(1) 原地
def dedup_b(nums):
nums.sort()
return [x for i, x in enumerate(nums) if i == 0 or x != nums[i-1]]
工程里的空间真相:
- 缓存(CPU 缓存行 64B):连续内存访问比跳着访问快 10-100x
- GC:创建大量短命对象 → 停顿;用原地/池化减少分配
- 大输入:O(n²) 空间会直接 OOM——优先流式/分块
空间复杂度速查:
| 算法 | 额外空间 | 说明 |
|---|---|---|
| 原地快排 | O(log n) 平均 | 递归栈 |
| 归并排序 | O(n) | 辅助数组 |
| 计数排序 | O(k) | 值域数组 |
| 图 DFS/BFS | O(V) | 栈/队列 + 标记 |
| 动态规划(朴素) | O(n²) | 可用滚动数组降维 |
记忆:空间换时间要算总账——GC 停顿、缓存未命中、OOM 都是"空间超支"的利息。
7. 复杂度 vs 常数:渐进记号的两面性
Big-O 回答”规模放大后谁更快”,常数回答”眼前的数据谁更快”。两者可能矛盾:
算法 A:O(n) 但每步做 100 次操作 → 实际 100n
算法 B:O(n²) 但每步极简、缓存友好 → 实际 0.01n²
交叉点:n = 10000
n < 10000 → B 更快
n > 10000 → A 更快
工程决策三步:
1. 估规模:n 到底多大?(100?10万?1亿?)
2. 算增长:规模翻倍后差距多大?
3. 测真相:用基准(见第 9 节)验证,别猜
一个真实例子:str 拼接
# O(n²):每次拼接复制整个字符串
s = ""
for i in range(100000):
s += "x"
# O(n):一次性 join
s = "".join(["x"] * 100000)
+= 在小 n 时没问题,n 到十万级就成了灾难——复杂度预测灾难,常数决定什么时候到灾点。
记忆:渐进复杂度是"增长趋势",常熟决定"当前快慢";小数据看常数、大数据看复杂度、上生产看基准。
8. 常见陷阱:把 O(n²) 写成 O(n)
几个高频"复杂度幻觉":
| 陷阱 | 例子 | 真相 |
|---|---|---|
| 哈希查询忘冲突 | x in set | 平均 O(1)、最坏 O(n) |
| 字符串拼接 | 循环 s += c | O(n²),用 join/Builder |
| 循环内排序 | 外层 n 次、内层 sort | O(n² log n) |
| 递归深度堆栈 | 深递归 | 栈溢出 O(n) 空间 |
| 位运算当 O(1) | 大整数 x & (1<<k) | 大整数按位数算 |
| 正则回溯 | 嵌套量词 | 指数级灾难(见 /regex-deep-dive/) |
代码示例——循环内排序:
# 陷阱:每次循环都排序 → O(n² log n)
for i in range(n):
window = sorted(arr[i:i+100]) # 100 个元素排序,常数
# 若窗口随 i 增长 → O(n² log n)
# 正确:一次排序或滑动窗口维护有序结构
data = sorted(arr)
写代码时的自检清单:
□ 外层循环每层做什么?层层相乘了吗?
□ 内部有没有排序/查找/字符串拼接?
□ 用的集合操作是平均 O(1) 吗?退化条件是什么?
□ 递归的每层工作量 × 深度 = ?
记忆:复杂度 bug 不在"循环嵌套"这种明处,而在"循环里藏着排序/拼接/哈希退化"这种暗处。
9. 工程实践:复杂度预算与基准验证
复杂度预算:上线前把"输入规模 × 复杂度"换算成预算,超过红线就优化。
红线示例(单请求 CPU 预算 100ms):
n = 1,000,000,目标 O(n) → 约 1-10ms ✓
同样的 n,若写成 O(n²) → 10¹² 步 → 秒级 ✗
用基准验证,不靠感觉:
# 简单基准:python 里 timeit
import timeit
def linear(n):
return sum(range(n))
def quad(n):
return sum(x * y for x in range(n) for y in range(n))
for n in [1000, 2000, 4000, 8000]:
t_lin = timeit.timeit(f"linear({n})", globals=globals(), number=10)
t_quad = timeit.timeit(f"quad({n})", globals=globals(), number=3)
print(f"n={n}: linear={t_lin:.4f}s quad={t_quad:.4f}s")
# 观察 linear 随 n 线性增长、quad 随 n 平方增长 → 验证复杂度假设
验证复杂度的正规方法:把 n 翻倍,看耗时倍数。
O(1) → 耗时不变
O(log n) → 耗时微增
O(n) → 耗时翻倍
O(n log n) → 耗时约 2.1 倍
O(n²) → 耗时约 4 倍
什么时候值得优化:先测量——O(n²) 在 n=100 时毫无问题,别为理论复杂度引入缓存复杂度。优化要满足"数据规模会放大"的前提。
记忆:复杂度预算是"设计期的刹车",基准是"上线前的证据"——先算预算、再跑分、再决定动不动刀。
10. 速查表与一句话记忆
全篇速查:
| 主题 | 结论 |
|---|---|
| 记号 | Big-O 是上界,工程默认最坏 |
| 增长序 | 1 < log n < n < n log n < n² < 2ⁿ |
| 结构选型 | 哈希无序 O(1)、树有序 O(log n)、数组下标 O(1) |
| 排序 | 快排通用、归并稳定、插入近似有序 O(n) |
| 递归 | 主定理三分法,等号 → O(n log n) |
| 摊还 | 扩容结构均摊 O(1),实时场景看最坏 |
| 空间 | 集合/栈/GC 都在吃掉空间,别只算时间 |
| 常数 | 渐进决定趋势,常数决定当下 |
| 陷阱 | 循环内排序/拼接/哈希退化是暗处 |
| 验证 | 预算 + 跑分(翻倍法) |
一句话记忆:Big-O 是增长曲线不是绝对值——结构选型哈希/树/数组三件套、排序稳定记归并、递归走主定理、扩容看摊还;复杂度决定"规模放大后"的生死,常数决定"眼前数据"的快慢;上线前做复杂度预算,翻倍跑分验证,别让 O(n²) 藏在循环里的排序和字符串拼接里。
延伸阅读
- /regex-deep-dive/ — 正则引擎复杂度:从线性到灾难性回溯
- /dsl-design/ — 解析器的复杂度权衡(LL 与 PEG)
- /time-timezone-handling/ — 时间算法里的复杂度细节
- [[algorithm-interview]] — 复杂度分析是面试的第一问
- [[cs-fundamentals]] — 数据结构的复杂度底层
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。