1. 为什么需要复杂度分析
算法复杂度分析是衡量算法效率的标准方法,它不依赖具体的机器环境,而是从问题规模增长的角度评估性能。
同一套代码在 i3 和 i9 上运行时间不同,但它们的增长趋势是一致的。
2. 时间复杂度
2.1 大 O 记号(Big-O Notation)
Big-O 描述的是算法执行时间随输入规模 n 增长的上界(最坏情况)。
常见复杂度等级(从优到劣):
| 复杂度 | 名称 | 示例 |
|---|---|---|
| O(1) | 常数 | 数组随机访问 |
| O(log n) | 对数 | 二分查找 |
| O(n) | 线性 | 遍历数组 |
| O(n log n) | 线性对数 | 快速排序、归并排序 |
| O(n²) | 平方 | 双重循环(冒泡排序) |
| O(n³) | 立方 | 三重循环(矩阵乘法基础) |
| O(2ⁿ) | 指数 | 递归求解子集 |
| O(n!) | 阶乘 | 全排列 |
2.2 如何计算时间复杂度
原则:关注最高阶项,忽略常数系数和低阶项。
# 示例 1:O(n)
def sum_array(arr):
total = 0
for x in arr: # n 次
total += x
return total
# 示例 2:O(n²)
def find_pairs(arr):
for i in range(len(arr)):
for j in range(len(arr)): # n * n
print(arr[i], arr[j])
# 示例 3:O(log n)
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
# 示例 4:O(n log n)
def efficient_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2 # O(1)
left = efficient_sort(arr[:mid]) # T(n/2)
right = efficient_sort(arr[mid:]) # T(n/2)
return merge(left, right) # O(n)
# 递推:T(n) = 2T(n/2) + O(n) → O(n log n)
2.3 最好、最坏、平均情况
| 情况 | 定义 | 示例 |
|---|---|---|
| 最好 | 最理想输入 | 冒泡排序已有序数组:O(n) |
| 最坏 | 最不利输入 | 快速排序每次选到最大/最小值:O(n²) |
| 平均 | 随机输入期望 | 快速排序平均:O(n log n) |
3. 空间复杂度
空间复杂度衡量算法执行过程中额外占用的存储空间随 n 的增长趋势。
# O(1) 额外空间
def reverse_inplace(arr):
left, right = 0, len(arr) - 1
while left < right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1
# O(n) 额外空间
def copy_double(arr):
result = [] # 额外创建 n 个元素
for x in arr:
result.append(x * 2)
return result
# O(n) 递归栈空间
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1) # 深度为 n 的调用栈
# O(log n) 递归栈空间(二分递归)
def fib_log_space(n):
if n <= 1:
return n
return fib_log_space(n - 1) + fib_log_space(n - 2) # 实际上为 O(n),这里需要更准确说明
4. 复杂度对比图
n=10 时的对比:
O(1) ████ 1
O(log n) ██████ 3.3
O(n) ████████████████████ 10
O(n log n) █████████████████████████████████████ 33
O(n²) ████████████████████████████████████████████████████████████████████████████████████████████ 100
O(2ⁿ) ... (极长,1024)
5. 主定理(Master Theorem)
用于快速求解分治算法的时间复杂度:
T(n) = aT(n/b) + f(n)
| 条件 | 结果 |
|---|---|
| f(n) = O(nᶜ), c < logᵦa | T(n) = Θ(n^(logᵦa)) |
| f(n) = Θ(n^(logᵦa)) | T(n) = Θ(n^(logᵦa) log n) |
| f(n) = Ω(nᶜ), c > logᵦa | T(n) = Θ(f(n)) |
示例: 归并排序 T(n) = 2T(n/2) + O(n)
- a = 2, b = 2, log₂2 = 1
- f(n) = O(n¹),符合 case 2
- 因此 T(n) = Θ(n log n)
6. 复杂度分析实战
6.1 面试高频题
# 求以下代码时间复杂度
def foo(n):
i = 1
while i < n:
i = i * 2
print(i)
# 答案:O(log n),因为 i 呈指数增长
# 求以下代码时间复杂度
def bar(n):
for i in range(n):
j = 1
while j < n:
j = j * 2
print(i, j)
# 答案:O(n log n),外层 n 次,内层 log n 次
6.2 空间换时间 vs 时间换空间
# 空间换时间:两数之和
# 暴力 O(n²) → 哈希表 O(n)
def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
7. 总结
| 概念 | 要点 |
|---|---|
| Big-O | 渐进上界,描述最坏情况 |
| Big-Ω | 渐进下界,描述最好情况 |
| Big-Θ | 紧致界,当上界=下界时使用 |
| 常见陷阱 | 递归注意栈空间、嵌套循环不一定 O(n²) |
核心原则:复杂度分析是算法选型的第一依据。在工程实践中,O(n²) 在 n > 10⁴ 时通常不可接受,O(n log n) 是大多场景的性能锚点。
8. 三种渐近记号的精确区分
理解 Big-O、Big-Ω 和 Big-Θ 之间的细微差别是算法分析能力的分水岭:
| 记号 | 读法 | 定义 | 类比 | 用途 |
|---|---|---|---|---|
| O(g(n)) | Big-O | f(n) ≤ c·g(n)(当 n → ∞) | “最多不超过” | 最坏情况上界,最常用 |
| Ω(g(n)) | Big-Omega | f(n) ≥ c·g(n)(当 n → ∞) | “至少不低于” | 最好情况下界,证明下限 |
| Θ(g(n)) | Big-Theta | c₁·g(n) ≤ f(n) ≤ c₂·g(n) | “恰好等于” | 紧致界,上下界夹在同一个 g(n) |
复杂度关系图(f(n) = 2n² + 3n + 5)
函数值
│ ╭────── 一个 Θ(n²) 的函数
│ ╱ ╲
│ ╱ ╲──── c₂·n² (上界)
│ ╱ ╲
│ ╱ f(n) ╲
│╱ ╲
│╲ ╱──── c₁·n² (下界)
│ ╲ ╱
│ ╲ ╱
│ ╲ ╱
└──┴──┴───────→ n
面试高频辨析题:
Q: 快速排序的时间复杂度是 O(n log n) 还是 Θ(n log n)?
A: 通常说 O(n log n),但实际是:
- 最坏情况:O(n²) — 每次 pivot 都是最小/最大值
- 最好情况:Ω(n log n) — 每次 pivot 都居中
- 平均情况:Θ(n log n) — 随机输入的期望
因此严格说,快速排序的平均时间复杂度是 Θ(n log n),
但我们习惯性地用 O(n log n) 来表示上界。
9. 实际运行时间估算
理论复杂度如何映射到实际毫秒?参考以下基准(假设 1 CPU 执行 1 亿次操作/秒):
| n | O(log n) | O(n) | O(n log n) | O(n²) | O(2ⁿ) | O(n!) |
|---|---|---|---|---|---|---|
| 10 | 0.03 μs | 0.1 μs | 0.3 μs | 1 μs | 10 μs | 3.6 ms |
| 100 | 0.07 μs | 1 μs | 6.6 μs | 100 μs | 40,000 年 | 更长 |
| 1,000 | 0.1 μs | 10 μs | 100 μs | 10 ms | — | — |
| 10,000 | 0.13 μs | 100 μs | 1.3 ms | 1 秒 | — | — |
| 100,000 | 0.17 μs | 1 ms | 17 ms | 100 秒 | — | — |
| 1,000,000 | 0.2 μs | 10 ms | 200 ms | 2.8 小时 | — | — |
从这张表可以直观理解:为什么 O(n²) 算法在 n > 10,000 时几乎不可用,而 O(n log n) 算法在百万级数据下依然游刃有余。
10. 空间复杂度的深入理解
10.1 辅助空间 vs 总空间
# 辅助空间 O(1):返回值不计入
# 但总空间 O(n)(如果考虑输入本身的大小)
def reverse_in_place(arr: list) -> list:
"""原地反转:辅助空间 O(1)"""
left, right = 0, len(arr) - 1
while left < right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1
return arr
# 辅助空间 O(n) + 返回结果 O(n) = 总空间 O(n)
def reverse_copy(arr: list) -> list:
"""非原地反转:需要额外 O(n) 空间"""
result = [0] * len(arr)
for i in range(len(arr)):
result[i] = arr[len(arr) - 1 - i]
return result
# 递归栈空间常被忽视
def factorial(n: int) -> int:
if n <= 1:
return 1
return n * factorial(n - 1)
# 空间复杂度:O(n) — n 层递归调用栈
10.2 动态规划的空间优化
# 经典斐波那契:DP 表 O(n) 空间
def fib_dp(n: int) -> int:
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# 空间优化:只保留最后两个值 → O(1) 空间
def fib_optimized(n: int) -> int:
if n <= 1:
return n
prev2, prev1 = 0, 1
for _ in range(2, n + 1):
curr = prev1 + prev2
prev2 = prev1
prev1 = curr
return prev1
11. 常见复杂度陷阱
11.1 隐藏 O(n²):字符串拼接
# ❌ 错误:Python 字符串不可变,每次拼接都是 O(n)
def build_string(n: int) -> str:
result = ""
for i in range(n):
result += str(i) # 每次重新分配,总 O(n²)
return result
# ✅ 正确:使用列表 + join,总 O(n)
def build_string_fast(n: int) -> str:
parts = []
for i in range(n):
parts.append(str(i))
return "".join(parts)
# ✅ 更简洁:生成器表达式
def build_string_pythonic(n: int) -> str:
return "".join(str(i) for i in range(n))
11.2 隐藏 O(n):误认为 O(1) 的 HashMap
# ❌ 假设 get 总是 O(1),忽略了最坏情况
def count_unique_bad(arr: list) -> dict:
counts = {}
for item in arr:
counts[item] = counts.get(item, 0) + 1
# 若所有键都有相同 hash(哈希攻击),退化为链表 O(n²)
return counts
# ✅ 使用 Python 3.7+ 的有序字典(压缩表实现,冲突概率极低)
from collections import defaultdict
def count_unique_safe(arr: list) -> dict:
counts = defaultdict(int)
for item in arr:
counts[item] += 1
return dict(counts)
11.3 递归深度陷阱
# Python 默认递归深度限制 1000
def factorial_recursive(n: int) -> int:
if n <= 1:
return 1
return n * factorial_recursive(n - 1)
# factorial_recursive(10000) → RecursionError
# ✅ 尾递归优化(或通过显式栈)
def factorial_iterative(n: int) -> int:
result = 1
for i in range(2, n + 1):
result *= i
return result
# O(1) 栈空间,可处理任意 n
12. 复杂度分析方法论
分析步骤:
┌─────────────────────────────────────┐
│ 1. 识别输入规模 n │
├─────────────────────────────────────┤
│ 2. 找出最内层的循环/递归 │
├─────────────────────────────────────┤
│ 3. 确定循环次数与 n 的关系 │
├─────────────────────────────────────┤
│ 4. 处理嵌套:相乘 │
│ 处理顺序:相加 │
├─────────────────────────────────────┤
│ 5. 保留最高阶项,忽略常数和低阶 │
├─────────────────────────────────────┤
│ 6. 检查是否有隐含调用 O(n) 的方法 │
│ (sort、set、join、copy 等) │
├─────────────────────────────────────┤
│ 7. 递归:画递归树或使用 Master 定理 │
└─────────────────────────────────────┘
快速判断口诀:
- 单层循环遍历 → O(n)
- 二分/折半 → O(log n)
- 嵌套双重循环 → O(n²)
- 递归折半处理 → O(n log n)
- DFS/BFS 图遍历 → O(V + E)
13. 空间换时间 vs 时间换空间
# 场景:两数之和
# 时间换空间:O(n²) 时间,O(1) 空间
def two_sum_brute(nums: list, target: int) -> list:
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] + nums[j] == target:
return [i, j]
return []
# 空间换时间:O(n) 时间,O(n) 空间
def two_sum_hash(nums: list, target: int) -> list:
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
选择权衡指南:
| 场景 | 倾向 | 理由 |
|---|---|---|
| 内存充裕、追求极致速度 | 空间换时间 | HashMap 缓存、DP 表 |
| 嵌入式/大数据流处理 | 时间换空间 | 单次遍历、滑动窗口 |
| 代码可读性优先 | 平衡 | 适度优化,不过度设计 |
14. 并行复杂度:阿姆达尔定律(Amdahl’s Law)
当引入并行化时,理论加速比受限于串行部分的比例:
S = 1 / [(1 - P) + P/N]
其中:
S = 加速比
P = 可并行化的比例(0-1)
N = 处理器核心数
| 可并行比例 P | N=4 核心加速 | N=∞ 理论上限 |
|---|---|---|
| 50% | 1.6x | 2x |
| 75% | 2.3x | 4x |
| 90% | 3.1x | 10x |
| 95% | 3.5x | 20x |
| 99% | 3.9x | 100x |
关键洞察:即使代码只有 10% 无法并行,最大加速比也受限于 10x。这解释了为什么盲目堆 CPU 核心并不能无限提升性能。
15. 面试高频误区与正确理解
❌ "O(n) 比 O(n²) 快" — 忽略常数因子
✅ "当 n → ∞ 时,O(n) 的增速慢于 O(n²)。在 n 较小或常数特别大时,O(n²) 可能更快。"
❌ "O(2n) 简化成 O(n),所以两次遍历和一次遍历一样快"
✅ "渐进复杂度忽略常数,但实践中常数 2x 是真实存在的延迟差距。"
❌ "哈希表所有操作都是 O(1)"
✅ "哈希表的平均 O(1) 依赖于良好的散列函数和低负载因子。最坏情况(哈希冲突)退化为 O(n)。"
❌ "递归的空间复杂度只算变量"
✅ "递归函数的空间复杂度 = 递归深度 × 每层的辅助空间。深度 n = O(n) 栈空间。"
❌ "时间复杂度和空间复杂度可以独立分析"
✅ "两者通常是 trade-off 关系。优化一个方向往往会影响另一个方向。"
16. 总结
| 概念 | 要点 |
|---|---|
| Big-O | 渐进上界,描述最坏情况;实际最常用 |
| Big-Ω | 渐近下界,描述最好情况;证明算法下限 |
| Big-Θ | 紧致界;当最好和最坏同阶时使用 |
| 常见陷阱 | 递归栈空间、隐藏 O(n²) 的字符串拼接、HashMap 最坏退化 |
| 空间优化 | DP 滚动数组、原地算法、 Tail Recursion(配合编译器) |
| 并行极限 | 阿姆达尔定律 — 串行部分决定加速上限 |
核心原则:复杂度分析是算法选型的第一依据。在工程实践中,渐进复杂度指导大方向,常数因子和内存特性(cache 友好性、局部性原理)决定实际性能。O(n²) 在 n > 10⁴ 时通常不可接受,O(n log n) 是大多场景的性能锚点。O(log n) 代表指数级搜索空间压缩,是最高效的查找复杂度。
参考文章
- 下一篇:数组、链表与线性表
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。