01. 算法复杂度分析

彻底理解 Big-O 记号、时间复杂度与空间复杂度,掌握渐进分析的核心思想,为数据结构与算法学习打下基础。

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ᵦaT(n) = Θ(n^(logᵦa))
f(n) = Θ(n^(logᵦa))T(n) = Θ(n^(logᵦa) log n)
f(n) = Ω(nᶜ), c > logᵦaT(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-Of(n) ≤ c·g(n)(当 n → ∞)“最多不超过”最坏情况上界,最常用
Ω(g(n))Big-Omegaf(n) ≥ c·g(n)(当 n → ∞)“至少不低于”最好情况下界,证明下限
Θ(g(n))Big-Thetac₁·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 亿次操作/秒):

nO(log n)O(n)O(n log n)O(n²)O(2ⁿ)O(n!)
100.03 μs0.1 μs0.3 μs1 μs10 μs3.6 ms
1000.07 μs1 μs6.6 μs100 μs40,000 年更长
1,0000.1 μs10 μs100 μs10 ms——
10,0000.13 μs100 μs1.3 ms1 秒——
100,0000.17 μs1 ms17 ms100 秒——
1,000,0000.2 μs10 ms200 ms2.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  = 处理器核心数
可并行比例 PN=4 核心加速N=∞ 理论上限
50%1.6x2x
75%2.3x4x
90%3.1x10x
95%3.5x20x
99%3.9x100x

关键洞察:即使代码只有 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) 代表指数级搜索空间压缩,是最高效的查找复杂度。


参考文章

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机基础」更多文章

  1. 46. 排队论与容量估算:利特尔法则与尾延迟
  2. 45. 编译器优化与中间表示:SSA、内联与循环优化
  3. 44. 并发模型对比:Actor、CSP 与数据并行