系列文章第 1 篇。本篇是整个算法系列的地基:我们会从「为什么需要复杂度分析」讲起,推导常见复杂度的来源,给出可直接套用的分析法则与实战案例,并在文末附上面试高频问答。建议先收藏再阅读。
一、前言
很多刚接触算法的同学写代码只关心两件事:功能对不对、报错有没有。至于程序处理 10 条数据和 1000 万条数据的表现差异,往往跑起来卡了才意识到问题。
测量程序耗时当然可行,但计时法有致命缺陷:结果依赖机器性能、编程语言、编译器优化、甚至当天的 CPU 负载。同一个冒泡排序,在你的笔记本和服务器上运行时间可能差 50 倍------但你不能因此说它们是「两个不同的算法」。
我们需要一种独立于机器、只描述算法本身性质 的度量方式,这就是复杂度分析 (Complexity Analysis),而它的数学表达工具就是大 O 表示法(Big O Notation)。
本文你将学到:
-
大 O 到底在度量什么,为什么可以「忽略常数」;
-
七种常见复杂度的来源与量级感受;
-
三条通用分析法则 + 四段实战代码的完整推导;
-
均摊复杂度、递归复杂度两个进阶话题;
-
面试中关于复杂度的典型追问及标准答法。
二、大 O 到底在度量什么
2.1 一个直观的例子
假设我们要在长度为 n 的无序数组中查找某个值,有两种写法:
# 方案 A:线性扫描
def linear_search(nums, target):
for i, x in enumerate(nums):
if x == target:
return i
return -1
# 方案 B:二分查找(前提是数组有序)
def binary_search(nums, target):
l, r = 0, len(nums) - 1
while l <= r:
mid = (l + r) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
l = mid + 1
else:
r = mid - 1
return -1
当 n = 100 万时:
-
方案 A 最多需要 100 万次比较;
-
方案 B 最多需要 约 20 次比较(2²⁰ ≈ 100 万)。
即便方案 A 的单次比较比方案 B 快 100 倍,数据量一大,方案 B 依然碾压。这说明:决定算法可扩展性的是「操作次数随数据规模 n 的增长曲线」,而不是单次操作的速度。大 O 表示法描述的正是这条曲线。
2.2 数学定义(降维理解版)
严格定义:若存在常数 c 和 n₀,使得当 n ≥ n₀ 时,f(n) ≤ c·g(n) 恒成立,则称 f(n) = O(g(n))。
不需要背定义,抓住三个操作要点即可:
-
只保留最高阶项 :
3n² + 5n + 2→ 保留3n²。因为当 n 足够大时,n² 项远远甩开其余项; -
去掉常数系数 :
3n²→n²。因为系数是 3 还是 300,只影响「差几倍」,不影响「增长趋势」;倍数差距可以用更快的硬件抹平,而 n² 与 n 的差距永远抹不平; -
结论只描述上界与趋势:大 O 回答的问题是「当 n 翻倍时,开销大约变成几倍」。
2.3 n 翻倍,开销变几倍?(速查表)
| 复杂度 | 名称 | n 翻倍后的开销 | 典型代表 |
|---|---|---|---|
| O(1) | 常数 | 不变 | 数组按下标取值、哈希查找 |
| O(log n) | 对数 | +1 次基本操作 | 二分查找 |
| O(n) | 线性 | ×2 | 单层循环遍历 |
| O(n log n) | 线性对数 | ×2 略多一点 | 归并排序、快速排序(平均) |
| O(n²) | 平方 | ×4 | 双层循环、冒泡排序 |
| O(2ⁿ) | 指数 | 平方倍 | 无记忆化的子集/组合枚举 |
| O(n!) | 阶乘 | 爆炸 | 全排列枚举 |
记住最后一列的「感觉」比记住公式更重要:面试拿到一道题,先看数据规模 n 的范围,就能反推出题人期望的复杂度------这是刷题的重要直觉:
| n 的规模 | 可接受的最大复杂度 |
|---|---|
| n ≤ 20 | O(2ⁿ) 也能过 |
| n ≤ 500 | O(n³) |
| n ≤ 5000 | O(n²) |
| n ≤ 10⁵ ~ 10⁶ | O(n log n) 或 O(n) |
| n ≥ 10⁷ | O(n),且要小心常数因子 |
为什么 O(log n) 中的对数底数无所谓?因为换底公式 log₂n = log₁₀n / log₁₀2,换底只改变常数倍------而大 O 恰好忽略常数。所以工程上说「对数复杂度」从不指明底数。
三、三条通用分析法则
法则一:顺序代码,取最大项
def process(nums):
total = sum(nums) # O(n)
peak = max(nums) # O(n)
nums.sort() # O(n log n)
总复杂度 O(n + n + n log n) = O(n log n)。加法取最大阶。
法则二:嵌套代码,相乘
def match(a, b):
for x in a: # O(m)
for y in b: # O(n)
if x == y:
return True
总复杂度 O(m·n)。乘法规则------这也是为什么「循环里调用函数」必须看清函数自身的复杂度再乘。
法则三:变量依赖时,先求和再定阶(高频陷阱)
def f(n):
for i in range(n):
for j in range(i): # 内层次数不是 n,是 i!
pass
内层总执行次数 = 1 + 2 + 3 + ... + (n-1) = n(n-1)/2,是 n² 量级,所以整体是 O(n²) 而不是「不知道多少」。遇到不规则嵌套,把总操作次数显式求和,再看量级。
四、实战推导:四段代码的完整分析
案例 1:对数从哪里来
def f(n):
j = 1
count = 0
while j < n:
j *= 2
count += 1
return count
变量 j 的轨迹是 1, 2, 4, 8, ..., 2^k。循环在 j ≥ n 时停止,即 2^k ≥ n,解得 k = log₂n。所以循环体执行 O(log n) 次。
凡是「每一步把问题规模乘 2(或除 2)」的代码,复杂度都是对数级。二分查找、倍增、快速幂都符合这个特征。
案例 2:对数藏在内层
def f(n):
for i in range(n): # n 次
j = 1
while j < n: # 每次 log n 次
j *= 2
外层 n × 内层 log n = O(n log n)。这恰好是归并排序、堆排序的复杂度来源。
案例 3:递归的复杂度 ------ 调用次数 × 单次开销
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
画出调用树:fib(n) 分裂成 fib(n-1) 和 fib(n-2),层层分裂,树的节点数是 O(2ⁿ) 量级(严格说是 O(φⁿ),φ≈1.618,黄金分割率,同为指数级)。单次调用开销 O(1),总复杂度 O(2ⁿ)。
记忆化改造(把算过的结果存起来):
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
每个 n 只真正计算一次,复杂度骤降为 O(n)。这一个例子同时预演了两件事:递归树分析法、以及第 9 篇动态规划的核心思想。
案例 4:字符串拼接的隐藏平方
# 危险写法
s = ""
for word in words: # n 个词
s = s + word # 每次拼接都要复制整个已有字符串
第 i 次拼接要复制长度约 i·L 的字符串(L 为词长),总复制量 ≈ L·n²/2,整体 O(n²)。
# 正确写法
s = "".join(words) # 只复制一遍,O(n)
Java 同理:循环中用 String 拼接应换成 StringBuilder.append()。这类「单步 O(n) 藏在循环里」的问题在真实业务代码(比如拼 SQL、拼 JSON)中非常常见。
五、进阶话题
5.1 最好、最坏与均摊复杂度
以 Python 的 list.append 为例:底层是定长数组,未满时追加是 O(1);数组满时触发扩容------申请一块约 2 倍大的新内存,把所有元素搬过去,单次代价 O(n)。
那 append 到底是 O(1) 还是 O(n)?用**均摊分析(Amortized Analysis)**回答:
从空数组开始连续 append n 次,扩容发生在容量达到 1, 2, 4, 8, ... 时,搬运总次数为 1 + 2 + 4 + ... + n ≤ 2n。加上 n 次本身的写入,总开销 ≤ 3n,均摊到每次操作是 O(1)。
「均摊 O(1)」的含义:偶尔一次贵,但连续操作序列的平均代价是常数。这是动态数组、哈希表扩容分析的标准方法,面试中「HashMap 的 put 为什么说它是 O(1)」考的就是这个。
5.2 空间复杂度同样重要
-
额外变量、辅助数组、递归调用栈都计入空间复杂度;
-
递归深度为 d、每层用 O(1) 空间,则空间复杂度 O(d)------链表用递归遍历,空间就是 O(n),不是 O(1);
-
经典权衡:哈希表用 O(n) 空间把查找从 O(n) 降到 O(1)(第 4 篇详述);归并排序 O(n) 空间 vs 堆排序 O(1) 空间。
5.3 大 O、大 Ω、大 Θ 的区别(面试加分项)
-
O(上界) :算法不会比 g(n) 更慢。「快排最坏 O(n²)」✅
-
Ω(下界) :算法至少需要 f(n)。「快排最好 Ω(n log n)」
-
Θ(紧确界):上下界重合。「归并排序是 Θ(n log n)」
面试默认语境是「说最坏情况的 O」;能主动区分三者,是超出大多数候选人的细节。
六、面试高频问答
Q1:O(2n) 和 O(n) 有区别吗?O(n) 和 O(n²) 呢? 前者无本质区别(常数倍,大 O 忽略);后者有本质区别(增长趋势不同,n 大到一定程度差距不可弥补)。
Q2:为什么快排平均 O(n log n) 却说最坏 O(n²)?什么时候触发? 每次划分如果极不平衡(如已有序数组固定取首元素为基准),递归深度退化为 n,整体 O(n²)。工程实现用随机选取基准或三数取中规避。
Q3:某算法时间 O(n log n) 空间 O(1),另一个 O(n) 空间 O(n),怎么选? 看约束:数据量巨大或内存受限选前者;反之 O(n) 时间通常更快。体现「时空权衡」意识即可。
Q4:怎么分析一段没见过的代码? 套路:① 找出所有循环与递归;② 顺序取最大、嵌套相乘、依赖求和;③ 递归画调用树:复杂度 = 节点数 × 单节点开销;④ 有扩容/搬家的操作改用均摊分析。
七、总结
-
大 O 描述操作次数随数据规模 n 的增长趋势,忽略常数与低阶项;
-
三条法则:顺序取最大、嵌套相乘、依赖求和;
-
「规模每步乘/除 2」→ 对数级;「调用树每层分裂」→ 递归画树分析;
-
均摊分析解释动态数组/哈希表扩容为什么不影响整体 O(1);
-
看到 n 的范围能反推出题人期望的复杂度,是刷题的第一直觉。
复杂度分析是「看懂算法」的尺子,下一篇开始正式造轮子:数组与链表------最底层的两种存储形态,以及面试 Top 3 的链表反转模板。
如果本文对你有帮助,欢迎点赞、收藏、评论区交流。系列目录见首篇文章,更新不掉队。