为什么需要复杂度分析
假设你写了两个排序程序,在自己的笔记本上测试,程序 A 跑了 2 秒,程序 B 跑了 3 秒。能说 A 更好吗?不一定——换一台更快的服务器,两者可能都不到 0.1 秒;数据量变成一百倍,程序 A 可能需要 200 秒,程序 B 只需要 30 秒。
直接测量运行时间存在三个根本性问题:
- 依赖硬件:CPU 频率、缓存大小、内存速度都影响结果
- 依赖输入:同一程序对不同数据的运行时间可能相差数百倍
- 难以比较:不同语言、编译器、操作系统下的数字没有可比性
核心思想复杂度分析关注的是:当输入规模 n 增大时,算法所需资源(时间或空间)的增长趋势,而不是具体数值。
大 O 表示法
大 O 表示法(Big-O Notation)给出算法的渐进上界——在最坏情况下,随着 n 趋向无穷大,算法资源消耗的增长速率。
正式定义
若存在正实数 c 和 n₀,使得对所有 n ≥ n₀,都有 f(n) ≤ c · g(n),则称 f(n) = O(g(n))。
化简规则
1. 忽略常数系数:O(2n) 写作 O(n)
2. 只保留最高阶:O(n² + n + 5) 写作 O(n²)
3. 不同量级相加取大者:O(n) + O(log n) = O(n)
常见复杂度级别
按增长速率从慢到快排列(对算法来说,增长越慢越好):
| 复杂度 | 名称 | 典型例子 | n=1000 时的操作数量级 |
|---|---|---|---|
O(1) | 常数 | 数组下标访问、哈希表查找 | 1 |
O(log n) | 对数 | 二分查找、平衡树操作 | ~10 |
O(n) | 线性 | 线性扫描、一次遍历 | 1,000 |
O(n log n) | 线性对数 | 归并排序、快速排序(平均) | ~10,000 |
O(n²) | 平方 | 冒泡/插入/选择排序、嵌套循环 | 1,000,000 |
O(2ⁿ) | 指数 | 暴力穷举子集、递归斐波那契 | ≈ 10³⁰⁰ |
O(n!) | 阶乘 | 全排列暴力枚举 | 无法想象 |
O(1) — 常数时间
执行时间与输入规模无关,无论 n 是 10 还是 10 亿,操作数量不变。
def get_first(arr):
return arr[0] # 无论数组多长,只取一个元素
def swap(arr, i, j):
arr[i], arr[j] = arr[j], arr[i] # 三步固定操作,O(1)
O(log n) — 对数时间
每次操作将问题规模减半(或缩减为固定比例)。对数增长极其缓慢,log₂(10⁹) ≈ 30。
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1 # 每次搜索空间减半
else:
right = mid - 1
return -1
O(n log n) — 线性对数时间
典型代表是归并排序:将数组不断二分(log n 层),每层合并时扫描所有元素(n),总计 O(n log n)。这是基于比较的排序算法的最优下界。
O(n²) — 平方时间
def has_duplicate(arr):
for i in range(len(arr)):
for j in range(i + 1, len(arr)): # 嵌套循环,O(n²)
if arr[i] == arr[j]:
return True
return False
O(2ⁿ) — 指数时间
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n-1) + fib_naive(n-2) # 大量重复计算!
# fib(50) 需要约 2^50 ≈ 10^15 次调用
如何计算时间复杂度
基本规则
- 顺序执行:取最大项。
O(n) + O(n²) = O(n²) - 单层循环:若循环
n次,内部O(1),则总计O(n) - 嵌套循环:复杂度相乘。外
O(n)内O(n)→O(n²) - 每次减半的循环:执行
log₂n次,复杂度O(log n)
# 示例分析
def example(n):
total = 0 # O(1)
for i in range(n): # O(n) 次
total += i # O(1)
for i in range(n): # O(n) 次
for j in range(n): # O(n) 次
total += i * j # O(1)
return total
# 总复杂度:O(1) + O(n) + O(n²) = O(n²)
递归与主定理(Master Theorem)
对于形如 T(n) = a · T(n/b) + f(n) 的递归关系(其中 a 是子问题个数,b 是规模缩减比例,f(n) 是合并代价),主定理给出三种情形:
| 条件 | 结论 | 例子 |
|---|---|---|
f(n) = O(n^(log_b a - ε)) | T(n) = Θ(n^log_b a) | 子问题支配 |
f(n) = Θ(n^log_b a) | T(n) = Θ(n^log_b a · log n) | 归并排序:T(n)=2T(n/2)+O(n) |
f(n) = Ω(n^(log_b a + ε)) | T(n) = Θ(f(n)) | 合并代价支配 |
归并排序:T(n) = 2T(n/2) + O(n),其中 a=2, b=2, f(n)=O(n)。n^log₂2 = n,符合第二种情形,故 T(n) = O(n log n)。
空间复杂度
空间复杂度衡量算法运行所需的额外内存(辅助空间),不包括输入数据本身。
- O(1) 原地算法:只用几个变量,如冒泡排序
- O(n) 线性空间:创建与输入等大的辅助数组,如归并排序
- O(n) 递归栈:递归深度为
n时,调用栈占用O(n)空间
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # 新建子数组 → O(n) 辅助空间
right = merge_sort(arr[mid:]) # 递归深度 log n → 栈空间 O(log n)
return merge(left, right) # 总空间复杂度 O(n)
递归栈空间不要忘记递归调用本身会消耗栈空间。一个简单的递归遍历链表,空间复杂度是 O(n) 而不是 O(1)。
最优 / 平均 / 最坏情况
同一算法对不同输入的性能可能差异巨大。以快速排序为例:
最坏情况 Ω
每次选到最小/最大值作为 pivot(如对已排序数组选第一个元素),每次只排除一个元素,退化为 O(n²)。
最坏O(n²)平均情况
随机 pivot 平均每次将数组对半分割,递归树深度 O(log n),每层处理 O(n)。
平均O(n log n)日常习惯平时说某算法的时间复杂度,若不加说明,通常指最坏情况(upper bound)。面试中要区分三种情况进行分析。
摊还分析
某些操作偶尔会很慢,但从长期平均来看仍然高效。摊还分析将昂贵操作的代价"摊还"到多次廉价操作上。
动态数组扩容示例
Python 的 list.append():通常只需 O(1),但当容量不足时需要扩容(申请 2 倍空间并复制所有元素,代价 O(n))。
# 模拟动态数组扩容
capacity = 1
size = 0
total_cost = 0
for i in range(1, n+1):
if size == capacity:
total_cost += capacity # 扩容:复制所有元素
capacity *= 2
total_cost += 1 # 追加:O(1)
size += 1
# 扩容发生在 size = 1, 2, 4, 8, ... 共 log n 次
# 扩容总代价 = 1 + 2 + 4 + ... + n/2 = n-1 = O(n)
# n 次 append 总代价 O(n),每次摊还 O(1)
结论动态数组的 append 操作,摊还时间复杂度为 O(1),尽管偶尔会触发 O(n) 的扩容。
实战:分析代码复杂度
例题 1:找数组中的第二大值
def second_max(arr):
first = second = float('-inf')
for x in arr: # 一次遍历 → O(n)
if x > first:
second = first
first = x
elif x > second:
second = x
return second
# 时间 O(n),空间 O(1)
例题 2:判断是否含有重复元素(两种解法对比)
# 解法一:暴力枚举 O(n²)
def contains_duplicate_v1(nums):
for i in range(len(nums)):
for j in range(i+1, len(nums)):
if nums[i] == nums[j]:
return True
return False
# 解法二:哈希集合 O(n) 时间,O(n) 空间
def contains_duplicate_v2(nums):
seen = set()
for x in nums:
if x in seen: # 哈希查找 O(1)
return True
seen.add(x)
return False
例题 3:矩阵对角线求和
def diagonal_sum(matrix):
n = len(matrix) # n×n 矩阵
total = 0
for i in range(n): # 循环 n 次,每次 O(1)
total += matrix[i][i] # 主对角线
total += matrix[i][n-1-i] # 副对角线
if n % 2 == 1:
total -= matrix[n//2][n//2] # 减去中心重复计算的元素
return total
# 时间 O(n),空间 O(1) ← 注意:n 是矩阵边长,矩阵共 n² 个元素
复杂度速查