时间复杂度与空间复杂度分析
刷题时每道题都要写复杂度,但很多人只是背结论,不知道怎么推出来。这篇把分析方法说清楚,后续题解里的复杂度分析都以此为基础。
一、复杂度是什么
复杂度衡量的是算法随输入规模 nnn 增长时,资源消耗的增长趋势,而不是具体的运行时间或内存字节数。
- 时间复杂度 :操作次数随 nnn 的增长趋势
- 空间复杂度 :额外内存占用随 nnn 的增长趋势
用大 O 表示法 O(f(n))O(f(n))O(f(n)) 描述,只保留增长最快的项,忽略常数系数。
O(3n2+5n+100)=O(n2)O(3n^2 + 5n + 100) = O(n^2)O(3n2+5n+100)=O(n2)
二、常见复杂度从低到高
O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(2n)<O(n!)O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!)O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(2n)<O(n!)
| 复杂度 | n=10n=10n=10 | n=100n=100n=100 | n=106n=10^6n=106 | 典型场景 |
|---|---|---|---|---|
| O(1)O(1)O(1) | 1 | 1 | 1 | 哈希表查询 |
| O(logn)O(\log n)O(logn) | 3 | 7 | 20 | 二分查找 |
| O(n)O(n)O(n) | 10 | 100 | 10610^6106 | 线性扫描 |
| O(nlogn)O(n \log n)O(nlogn) | 33 | 664 | 2×1072×10^72×107 | 排序 |
| O(n2)O(n^2)O(n2) | 100 | 10410^4104 | 101210^{12}1012 | 暴力双层循环 |
| O(2n)O(2^n)O(2n) | 1024 | 103010^{30}1030 | 爆炸 | 暴力枚举子集 |
力扣题目 nnn 通常在 104∼10510^4 \sim 10^5104∼105,O(n2)O(n^2)O(n2) 一般会超时,O(nlogn)O(n \log n)O(nlogn) 及以下基本没问题。
三、时间复杂度:怎么数操作次数
3.1 单层循环 → O(n)O(n)O(n)
cpp
for (int i = 0; i < n; i++) {
// 常数次操作
}
循环体执行 nnn 次,每次 O(1)O(1)O(1),总计 O(n)O(n)O(n)。
3.2 嵌套循环 → O(n2)O(n^2)O(n2)
cpp
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 常数次操作
}
}
外层 nnn 次,内层每次也 nnn 次,总计 n×n=O(n2)n \times n = O(n^2)n×n=O(n2)。
如果内层只跑到 iii:
cpp
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) { ... }
}
总操作数 =0+1+2+⋯+(n−1)=n(n−1)2= 0 + 1 + 2 + \cdots + (n-1) = \dfrac{n(n-1)}{2}=0+1+2+⋯+(n−1)=2n(n−1),忽略常数仍是 O(n2)O(n^2)O(n2)。
3.3 每次折半 → O(logn)O(\log n)O(logn)
cpp
int x = n;
while (x > 1) {
x /= 2;
}
每次 xxx 减半,执行次数为 log2n\log_2 nlog2n,即 O(logn)O(\log n)O(logn)。
二分查找的复杂度由此而来:每次排除一半区间,最多执行 log2n\log_2 nlog2n 次。
3.4 排序的代价
调用 sort() 的时间复杂度固定是 O(nlogn)O(n \log n)O(nlogn),这是基于比较的排序的理论下界。
如果你的算法里有一步排序,整体至少是 O(nlogn)O(n \log n)O(nlogn),不可能更低。
3.5 递归:看递归树
递归的复杂度取决于递归次数 × 每次的工作量。
以二分查找为例:
cpp
int binarySearch(vector<int>& nums, int left, int right, int target) {
if (left > right) return -1;
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
else if (nums[mid] < target) return binarySearch(nums, mid + 1, right, target);
else return binarySearch(nums, left, mid - 1, target);
}
每次递归规模减半,递归深度 O(logn)O(\log n)O(logn),每层工作量 O(1)O(1)O(1),总计 O(logn)O(\log n)O(logn)。
以归并排序为例:每次分成两半,递归深度 O(logn)O(\log n)O(logn),每层合并工作量 O(n)O(n)O(n),总计 O(nlogn)O(n \log n)O(nlogn)。
3.6 摊还分析:push_back 为什么是 O(1)O(1)O(1)
vector 的 push_back 偶尔会触发扩容(申请新内存 + 拷贝所有元素),单次最坏 O(n)O(n)O(n)。但扩容是按倍数增长的(1→2→4→8→...),nnn 次 push_back 触发扩容的总拷贝次数不超过 2n2n2n,平均每次 O(1)O(1)O(1)。
这种"单次最坏但均摊下来是常数"的分析叫摊还分析 ,结论就是 push_back 均摊 O(1)O(1)O(1)。
四、空间复杂度:只算额外空间
空间复杂度只统计算法额外申请的空间,输入本身占用的空间不算在内。
4.1 开了一个和 nnn 等长的数组 → O(n)O(n)O(n)
cpp
vector<int> dp(n, 0); // 额外申请 n 个元素
4.2 只用了几个变量 → O(1)O(1)O(1)
cpp
int left = 0, right = n - 1; // 无论 n 多大,额外空间恒定
4.3 递归调用栈 → O(递归深度)O(递归深度)O(递归深度)
递归每深入一层就占用一帧栈空间。
- 二分查找递归深度 O(logn)O(\log n)O(logn),空间 O(logn)O(\log n)O(logn)
- 遍历一棵高度为 hhh 的二叉树,空间 O(h)O(h)O(h),最坏(链状树)O(n)O(n)O(n)
这也是为什么能用迭代替代递归时,迭代的空间复杂度更优(O(1)O(1)O(1) vs O(logn)O(\log n)O(logn))。
五、快速判断模板
拿到一段代码,按下面的顺序判断:
1. 有没有循环?
- 单层循环 n 次 → O(n)
- 双层嵌套 → O(n²),注意内层是否真的跑 n 次
- 循环变量每次折半 → O(log n)
2. 有没有调用 sort?
- 直接加一个 O(n log n)
3. 有没有递归?
- 估算递归深度 × 每层工作量
4. 取所有部分里增长最快的那个作为最终答案
六、例子:两数之和(Hot100 第一题)
cpp
unordered_map<int, int> mp;
for (int i = 0; i < nums.size(); i++) {
int complement = target - nums[i];
if (mp.count(complement)) return {mp[complement], i};
mp[i] = nums[i]; // 注意这里存的是下标
}
- 时间复杂度 :单层循环 nnn 次,每次哈希查询 O(1)O(1)O(1),总计 O(n)O(n)O(n)
- 空间复杂度 :哈希表最多存 nnn 个元素,O(n)O(n)O(n)
对比暴力双层循环:时间 O(n2)O(n^2)O(n2),空间 O(1)O(1)O(1)。用空间换时间,这是哈希表的核心思路。
小结
复杂度分析不需要精确计算操作次数,只需要判断增长趋势。记住几个核心规律:
- 折半操作 → logn\log nlogn
- 线性扫描 → nnn
- 嵌套循环 → n2n^2n2
- 排序 → nlognn \log nnlogn
- 递归 → 深度 × 每层代价