从代码出发理解时间复杂度与空间复杂度
算法分析不追求算出程序运行多少毫秒,而是判断:当输入规模 N 变大时,运行时间和额外内存以什么速度增长。时间复杂度看基本操作的执行次数,空间复杂度看算法额外占用的存储空间。
一、为什么不能只看运行时间
同一段程序在不同电脑、编译器和输入数据上耗时不同。用输入规模 N 的函数来描述增长趋势,比某次运行的秒数更稳定,也更适合比较算法。
递归求斐波那契数是常见例子。它会反复计算相同的子问题,N 小时看不出代价,N 一大,调用次数就迅速膨胀------这正是复杂度分析要提前揭示的风险。
二、用大 O 表示法抓住主要增长项
大 O 表示法只保留决定增长速度的部分:
- 常数统一记为 1;
- 只保留最高阶项;
- 去掉最高阶项前的常数系数。
若一个函数的基本操作次数为 N² + 2N + 10,当 N 足够大时,N² 决定整体增长趋势,因此时间复杂度记为 O(N²)。
三、从代码判断时间复杂度
1. 双层循环:O(N²)
c
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
// 基本操作
}
}
外层循环执行 N 次,每次又执行 N 次内层循环,核心操作约执行 N×N 次,所以是 O(N²)。循环前后的少量常数操作不改变结论。
2. 单层循环:O(N)
代码只从头到尾遍历一次长度为 N 的数据,核心操作次数与 N 成正比,时间复杂度就是 O(N)。若分别遍历长度为 N 和 M 的两组数据,应写成 O(N+M),不能在没有关系的情况下把 M 偷换成 N。
3. 固定次数循环:O(1)
c
for (int i = 0; i < 100; ++i) {
// 基本操作
}
循环次数固定为 100,不随输入规模变化,因此记为 O(1)。O(1) 并不表示只执行一次,而是执行次数是常数。
4. 二分查找:O(log N)
二分查找每比较一次,就把待查区间缩小一半。经过 k 次后,区间规模约为 N/2ᵏ;当区间缩小到 1 时,k≈log₂N,因此复杂度为 O(log N)。
5. 冒泡排序:最坏 O(N²)
冒泡排序要进行多轮相邻元素比较。最坏情况下,比较次数的数量级为 N²,因此通常记为 O(N²)。
6. 递归:既看调用次数,也看调用深度
递归阶乘会沿着 N、N-1、N-2......一直调用到终止条件,调用次数与 N 成正比,时间复杂度为 O(N)。朴素递归斐波那契会分裂出大量重复调用,时间复杂度可达 O(2ᴺ)。
四、最好、平均与最坏情况
同一算法可能因为输入不同而执行不同次数。以顺序查找为例:
- 第一个元素就命中:最好 O(1);
- 目标在中间:平均仍是 O(N);
- 目标在末尾或不存在:最坏 O(N)。
工程分析通常优先关注最坏情况,因为它给出了资源消耗的上界。
五、空间复杂度怎么看
空间复杂度衡量的是额外空间随 N 的增长趋势,不是程序本身占用的全部内存。
- 只使用少量局部变量:O(1);
- 动态申请 N 个元素的数组:O(N);
- 递归调用深度为 N:调用栈需要 O(N) 空间。
递归函数即使每一层只定义一个常量级变量,所有尚未返回的栈帧叠加后,仍可能产生线性空间开销。
六、常见复杂度的增长顺序
从增长较慢到较快:
O(1) < O(log N) < O(N) < O(N log N) < O(N²) < O(2ᴺ) < O(N!)
N 很大时,复杂度级别的差异会远远超过常数优化带来的收益。先选对算法级别,再讨论局部优化,通常更有效。
七、实用分析清单
拿到一段代码时,可以依次检查:
- 输入规模 N 代表什么;
- 最频繁的基本操作是什么;
- 循环是否嵌套,边界是否依赖 N;
- 每次迭代是否把问题规模减半;
- 递归产生多少次调用,最大调用深度是多少;
- 是否申请了随 N 增长的数组、链表或其他辅助空间;
- 结论描述的是最好、平均还是最坏情况。
复杂度分析的核心不是背公式,而是建立"代码结构---执行次数---增长趋势"之间的对应关系。