时间复杂度与空间复杂度:Big-O 到底怎么算
评价一个算法好不好,不能靠”感觉快慢”,得有一个统一的标尺——这就是复杂度分析。
而复杂度分析里最重要的工具,就是 Big-O(大 O 记号)。
为什么需要 Big-O
假设你写了一个查找函数,怎么评价它快不快?
有人会说”在测试机器上跑了 10ms”。但换个机器、换个数据量,结果就变了。
Big-O 解决的就是这个问题:它描述的是”随着数据量增大,运行时间怎么增长”的趋势,而不是具体的运行时间。
比如:
- O(1):数据量翻倍,时间不变
- O(n):数据量翻倍,时间也翻倍
- O(n²):数据量翻倍,时间变 4 倍
Big-O 到底怎么算
算 Big-O,核心就一句话:
忽略常数和低阶项,只保留增长最快的项。
举例:
1 | 3n² + 2n + 100 → O(n²) |
为什么可以忽略?因为当 n 足够大时,n² 那一项会完全主导,2n 和 100 都微不足道。
所以:
3n→ O(n)n² + n→ O(n²)2^n + n²→ O(2^n)
常见复杂度量级(从小到大)
| 量级 | 名称 | 例子 |
|---|---|---|
| O(1) | 常数 | 数组按下标访问、哈希表查找 |
| O(log n) | 对数 | 二分查找、平衡树查找 |
| O(n) | 线性 | 遍历数组、链表 |
| O(n log n) | 线性对数 | 快排、归并排序、堆排序 |
| O(n²) | 平方 | 冒泡、选择排序、双重循环 |
| O(2^n) | 指数 | 子集枚举、斐波那契递归 |
| O(n!) | 阶乘 | 全排列 |
记住一个经验法则:能到 O(n log n) 就别 O(n²),能到 O(log n) 就别 O(n)。
怎么估算一段代码的复杂度
1. 单层循环
1 | for (int i = 0; i < n; i++) { ... } |
循环 n 次,O(n)。
2. 嵌套循环
1 | for (int i = 0; i < n; i++) |
外层 n 次,内层 n 次,O(n²)。
3. 每次循环规模减半
1 | while (n > 1) { n = n / 2; } |
每次减半,需要 log₂(n) 次,O(log n)。二分查找就是这个量级。
4. 递归
1 | int fib(int n) { |
每次调用分成两个子调用,形成一棵二叉树,总节点数约 2^n,所以是 O(2^n)。这就是为什么递归求斐波那契慢得吓人。
空间复杂度
空间复杂度同理,衡量的是额外占用内存随数据量增长的趋势。
- 原地算法(in-place):O(1) 额外空间,比如快排、堆排
- 需要辅助数组:O(n),比如归并排序
时间和空间常常是权衡关系:用空间换时间(比如缓存、哈希表),或者用时间换空间(比如原地排序)。
均摊复杂度(摊还分析)
有一种特殊的情况:大部分时候操作很快,偶尔会很慢(比如动态扩容)。
比如 ArrayList 的 add:大部分时候直接加到末尾是 O(1),但偶尔满了要扩容、整体拷贝一次是 O(n)。
如果把扩容的成本平均分摊到每一次 add 上,均摊下来还是 O(1)。这就是”均摊复杂度”。
所以别看到”偶尔 O(n)”就说 add 是 O(n),要说”均摊 O(1)”。
写在最后
复杂度分析是基本功里的基本功。它的意义不只是应付面试,更在于:
写代码之前,先能判断”这个方案大概是什么量级、能不能撑住目标数据量”。
这是一种工程直觉。有了它,你才能在写代码前就淘汰掉那些”数据量一大必挂”的方案。
下一篇讲 SOLID 原则,把视角从”算法”拉回到”代码设计”。
