编码 · 算法
大 O 表示法详解(附示例)
简短回答
大 O 表示法描述算法的运行时间或内存占用如何随输入规模 n 的增大而增长。它只保留增长最快的一项并舍去常数,因此 3n² + 5n + 2 步写作 O(n²)。从快到慢,常见的复杂度类别有 O(1)、O(log n)、O(n)、O(n log n)、O(n²) 和 O(2ⁿ)。借助它,无需在具体机器上运行就能比较不同算法。
大 O 表示法解决什么问题
用秒数给程序计时,反映的只是某一台机器、某一种语言和某一组输入的情况。大 O 回答的是一个更有用的问题:输入变成 10 倍时,算法的工作量会增加多少?工作量增加 10 倍的算法,与增加 100 倍的算法,伸缩性截然不同。
形式化的定义是:如果存在常数 c 和 n₀,使得对所有 n ≥ n₀ 都有 f(n) ≤ c · g(n),则称 f(n) 是 O(g(n))。通俗地说,当 n 足够大时,g 是 f 增长速度的上界,且忽略常数因子。
- n 表示输入规模:列表的长度、字符串的字符数、图中的节点数。
- 时间复杂度统计基本步骤的数量(比较、赋值、算术运算)。
- 空间复杂度统计额外使用的内存,不包括输入本身。
如何化简表达式
先把步数写成关于 n 的函数,再应用两条规则:舍去常数系数,只保留主导项。
| 精确步数 | 应用的规则 | 大 O |
|---|---|---|
5 | 常数 | O(1) |
3n + 10 | 舍去 3 和 10 | O(n) |
n²/2 + n/2 | 保留 n²,舍去 1/2 | O(n²) |
2n log n + 100n | n log n 比 n 增长更快 | O(n log n) |
n³ + 2ⁿ | 2ⁿ 比 n 的任何次幂增长都快 | O(2ⁿ) |
当算法有两个独立的输入时,两者都要保留:先遍历长度为 n 的列表,再遍历长度为 m 的列表,复杂度是 O(n + m);循环嵌套循环则是 O(n · m)。对数的底数在大 O 中无关紧要,因为 log₂ n 与 log₁₀ n 只相差一个常数因子。
常见的复杂度类别
| 类别 | 名称 | n = 1,000 时的步数 | 典型例子 |
|---|---|---|---|
| O(1) | 常数 | 1 | 读取 items[i],在 dict 中按键查找(平均情况) |
| O(log n) | 对数 | 约 10 | 在有序列表中做二分查找 |
| O(n) | 线性 | 1,000 | 求列表的最大值 |
| O(n log n) | 线性对数 | 约 10,000 | 归并排序、Python 的 sorted() |
| O(n²) | 平方 | 1,000,000 | 比较每一对元素、冒泡排序 |
| O(2ⁿ) | 指数 | 一个 302 位的数 | 尝试每一个子集 |
| O(n!) | 阶乘 | 还要大得多 | 尝试每一种排列 |
粗略的经验:在现代计算机上,Python 的简单循环每秒可执行数千万次基本操作,编译型语言则快得多。因此 O(n log n) 处理上百万条数据没有问题,O(n²) 在数万条左右就会变慢,而 O(2ⁿ) 超过 40 到 60 个元素就基本无法运行。
从 Python 代码读出复杂度
看循环。对输入的单层循环是 O(n);在同一输入上嵌套的两层循环是 O(n²);每次把剩余范围减半的循环是 O(log n)。
def first(items): # O(1): one step, whatever the size
return items[0]
def total(items): # O(n): touches each item once
s = 0
for x in items:
s += x
return s
def has_duplicate(items): # O(n^2): compares every pair
for i in range(len(items)):
for j in range(i + 1, len(items)):
if items[i] == items[j]:
return True
return False
def halvings(n): # O(log n): n is halved each step
steps = 0
while n > 1:
n //= 2
steps += 1
return steps
print(first([7, 8]), total([1, 2, 3]), has_duplicate([1, 2, 1]), halvings(1024))
# 7 6 True 10库函数调用也有自己的开销。x in some_list 是 O(n),x in some_set 平均为 O(1),list.insert(0, x) 是 O(n),因为每个元素都要后移,sorted() 是 O(n log n)。如果循环里调用了 n 次 O(n) 的操作,即使看起来只有一层循环,整体也是 O(n²)。
def has_duplicate_fast(items): # O(n) time, O(n) extra space
seen = set()
for x in items:
if x in seen: # average O(1) set lookup
return True
seen.add(x)
return False
print(has_duplicate_fast([3, 1, 4, 1, 5])) # True
print(has_duplicate_fast(list(range(10)))) # False最好、平均和最坏情况
同一个算法在相同规模的不同输入上,工作量可能不同,所以复杂度通常按三种情况来说明。
| 算法 | 最好 | 平均 | 最坏 |
|---|---|---|---|
| 线性查找 | O(1),目标在第一个位置 | O(n) | O(n),目标不存在 |
| 插入排序 | O(n),已经有序 | O(n²) | O(n²),逆序 |
| 快速排序 | O(n log n) | O(n log n) | O(n²),基准选得差 |
| 哈希表查找 | O(1) | O(1) | O(n),冲突很多 |
人们只说「X 的复杂度」时,通常指最坏情况;对哈希表和快速排序则往往指平均情况。均摊代价是第四个概念:向 Python 列表追加元素时,列表扩容的那一次是 O(n),但在多次追加中平均下来是 O(1)。
大 O、大 Ω 与大 Θ
| 记号 | 含义 | 示例 |
|---|---|---|
| O(g) | 上界:增长不快于 g | 线性查找是 O(n) |
| Ω(g) | 下界:增长至少和 g 一样快 | 任何基于比较的排序,最坏情况下都是 Ω(n log n) |
| Θ(g) | 紧确界:既是 O(g) 又是 Ω(g) | 归并排序是 Θ(n log n) |
严格来说,O(n) 的算法同时也是 O(n²),因为 O 只是上界。日常使用中,人们写的是大 O,想表达的却是自己所知的最紧的界,也就是 Θ 所表示的含义。
常见误区
- 忽略内置操作的开销,例如对列表使用
in,或切片items[1:](它会复制 k 个元素,开销为 O(k))。 - 递归调用次数算错:一个函数对 n − 1 调用自身两次,复杂度是 O(2ⁿ),而不是 O(n)。
- 忽略空间:新建大小为 n 的列表,或者递归深度达到 n,都要占用 O(n) 内存。
- 写成 O(2n) 或 O(n + 5)。两者都应化简为 O(n)。
常见问题
O(1) 是什么意思?
O(1) 表示常数时间:工作量不随输入规模增长。无论列表有 10 个还是 1000 万个元素,按下标读取一个元素都是 O(1)。
O(log n) 比 O(n) 快吗?
是的,n 很大时尤其如此。输入翻倍,O(log n) 算法只多执行一步,而 O(n) 算法的工作量翻倍。对一百万个元素,log₂ n 约为 20。
Python 的 sorted() 时间复杂度是多少?
Python 的 sorted() 和 list.sort() 最坏情况下是 O(n log n),对已经有序的输入是 O(n),并且使用 O(n) 的额外内存。
大 O 是用来衡量秒数的吗?
不是。它衡量的是工作量如何随输入规模增长。实际耗时取决于机器、语言,以及大 O 所忽略的常数因子。
时间复杂度和空间复杂度有什么区别?
时间复杂度统计算法执行的步数,空间复杂度统计所需的额外内存。两者都用大 O 表示,而且常常需要在两者之间取舍,比如用集合来换取更快的速度。