时间复杂度与空间复杂度计算指南
目录
基本概念
复杂度衡量的是算法性能随数据量 n 增长的趋势,不是真实时间或内存。
用大 O 表示法(Big O)描述上界:
scss
O(1) < O(logn) < O(n) < O(nlogn) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
化简规则
| 规则 | 示例 |
|---|---|
| 去掉系数 | O(3n) → O(n) |
| 只保留最高次项 | O(n² + n) → O(n²) |
| 常数统一为 O(1) | O(100) → O(1) |
时间复杂度
O(1):常数时间
执行次数固定,不随 n 变化。
java
int a = arr[0]; // 直接访问
int b = arr[n - 1]; // 下标访问
map.get(key); // 哈希表查找
O(logn):对数时间
每次问题规模折半(或缩小为固定比例)。
java
// 二分查找
while (left <= right) {
int mid = (left + right) / 2;
if (arr[mid] == target) return mid;
else if (arr[mid] < target) left = mid + 1;
else right = mid - 1;
}
// n → n/2 → n/4 → ... → 1,共 log₂n 次
java
// 求整数位数
while (n > 0) {
n /= 10; // 每次除以10,共 log₁₀n 次
}
O(n):线性时间
遍历一次数据。
java
// 单层循环
for (int i = 0; i < n; i++) { }
// 链表遍历
while (node != null) { node = node.next; }
// 双指针(两个指针各走一次)
int left = 0, right = n - 1;
while (left < right) { left++; right--; }
O(nlogn):线性对数时间
外层 n 次,内层 logn 次,或分治每层 O(n)。
java
// 归并排序:logn 层,每层处理 n 个元素
void mergeSort(int[] arr, int l, int r) {
mergeSort(arr, l, mid);
mergeSort(arr, mid+1, r);
merge(arr, l, mid, r); // O(n)
}
// 堆排序:n 次堆化,每次 O(logn)
for (int i = n-1; i > 0; i--) {
swap(arr, 0, i);
heapify(arr, i, 0); // O(logn)
}
O(n²):平方时间
两层嵌套循环。
java
// 冒泡排序
for (int i = 0; i < n; i++)
for (int j = 0; j < n-i-1; j++)
if (arr[j] > arr[j+1]) swap(arr, j, j+1);
// 注意:内层不从0开始,仍是 O(n²)
// n + (n-1) + (n-2) + ... + 1 = n(n+1)/2 = O(n²)
O(n³):立方时间
三层嵌套循环。
java
// Floyd 最短路径
for (int k = 0; k < n; k++)
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]);
O(2ⁿ):指数时间
每次分裂为两个子问题,递归树节点数翻倍。
java
// 斐波那契朴素递归
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}
// 子集枚举
void subsets(int[] nums, int idx, List<Integer> path) {
result.add(new ArrayList<>(path));
for (int i = idx; i < nums.length; i++) {
path.add(nums[i]);
subsets(nums, i+1, path);
path.remove(path.size()-1);
}
}
O(n!):阶乘时间
全排列、旅行商问题暴力解。
java
// 全排列
void permute(int[] nums, int start) {
if (start == nums.length) { result.add(...); return; }
for (int i = start; i < nums.length; i++) {
swap(nums, start, i);
permute(nums, start+1);
swap(nums, start, i);
}
}
// 共 n! 种排列
特殊情况
两段独立循环:O(n + m)
java
for (int i = 0; i < n; i++) { } // O(n)
for (int i = 0; i < m; i++) { } // O(m)
// 总复杂度 O(n + m),若 m >> n 则 O(m)
循环次数非显而易见
java
// 看起来是 O(n²),实际是 O(nlogn)
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j += i) { // j 每次加 i
// 执行次数:n/1 + n/2 + n/3 + ... + n/n
// = n × (1 + 1/2 + 1/3 + ... + 1/n)
// = n × H(n) ≈ n × lnn = O(nlogn)
}
}
java
// 看起来两层,实际 O(n)
// 因为 i 和 j 合计只走 n 步
int i = 0, j = 0;
while (i < n && j < n) {
if (condition) i++;
else j++;
}
空间复杂度
计算原则
不算:输入数组本身(题目给定的)
算: 代码中额外申请的变量、数组、递归调用栈
O(1):常数空间
只用了固定数量的变量。
java
// 原地操作,没有额外申请数组
int sum = 0;
for (int num : arr) sum += num;
// 双指针原地反转
int left = 0, right = n - 1;
while (left < right) swap(arr, left++, right--);
O(n):线性空间
申请了和 n 相关的额外空间。
java
int[] temp = new int[n]; // 额外数组
Map<Integer, Integer> map = ...; // 哈希表存 n 个元素
List<Integer> result = ...; // 结果列表
O(n²):平方空间
二维数组或邻接矩阵。
java
int[][] dp = new int[n][n]; // DP 表
int[][] graph = new int[n][n]; // 邻接矩阵
O(logn):对数空间(递归栈)
递归深度为 logn。
java
// 二分查找递归版,深度 logn
int binarySearch(int[] arr, int l, int r, int target) {
int mid = (l + r) / 2;
return binarySearch(arr, l, mid-1, target);
}
// 快速排序平均情况,深度 logn
void quickSort(int[] arr, int l, int r) {
int p = partition(arr, l, r);
quickSort(arr, l, p-1);
quickSort(arr, p+1, r);
}
递归栈空间计算
scss
空间 = 递归深度 × 每帧大小
每帧存储:参数 + 局部变量 + 返回地址 → 通常是 O(1)
所以:空间复杂度 = O(递归深度)
| 场景 | 递归深度 | 空间复杂度 |
|---|---|---|
| 二分查找 | logn | O(logn) |
| 快速排序(平均) | logn | O(logn) |
| 快速排序(最坏) | n | O(n) |
| 归并排序 | logn(栈)+ n(数组) | O(n) |
| 斐波那契朴素递归 | n | O(n) |
| DFS 树遍历 | 树高 h | O(h) |
递归的复杂度
计算公式
时间 = 递归调用总次数 × 每次调用的工作量
空间 = 递归深度(树的高度)× 每帧大小
递归树分析法
画出递归调用树,计算:
- 树的节点总数 = 总调用次数
- 每个节点的工作量
- 树的高度 = 递归深度
scss
fib(4) 的递归树:
fib(4) 层0:1个节点
/ \
fib(3) fib(2) 层1:2个节点
/ \ / \
fib(2) fib(1) fib(1) fib(0) 层2:4个节点
总节点数 ≈ 2⁴ = 16 → 时间 O(2ⁿ)
树高 = n → 空间 O(n)
主定理(Master Theorem)
适用于形如 T(n) = aT(n/b) + f(n) 的递归:
scss
a = 子问题个数
b = 每次规模缩小比例
f(n) = 每层额外工作量
情况1:f(n) < n^(log_b a) → T(n) = O(n^(log_b a))
情况2:f(n) = n^(log_b a) → T(n) = O(n^(log_b a) × logn)
情况3:f(n) > n^(log_b a) → T(n) = O(f(n))
实例:
scss
归并排序:T(n) = 2T(n/2) + O(n)
a=2, b=2, f(n)=n, n^(log₂2)=n¹=n
f(n) = n^(log_b a) → 情况2
T(n) = O(nlogn) ✅
二分查找:T(n) = T(n/2) + O(1)
a=1, b=2, f(n)=1, n^(log₂1)=n⁰=1
f(n) = n^(log_b a) → 情况2
T(n) = O(logn) ✅
三路归并:T(n) = 3T(n/3) + O(n)
a=3, b=3, f(n)=n, n^(log₃3)=n
f(n) = n^(log_b a) → 情况2
T(n) = O(nlogn) ✅
常见算法复杂度速查
排序算法
| 算法 | 平均时间 | 最坏时间 | 最好时间 | 空间 | 稳定 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(n) | O(1) | 是 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 否 |
| 插入排序 | O(n²) | O(n²) | O(n) | O(1) | 是 |
| 希尔排序 | O(n^1.3) | O(n²) | O(n) | O(1) | 否 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 是 |
| 快速排序 | O(nlogn) | O(n²) | O(nlogn) | O(logn) | 否 |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 否 |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 是 |
| 基数排序 | O(d×n) | O(d×n) | O(d×n) | O(n) | 是 |
| 桶排序 | O(n+k) | O(n²) | O(n) | O(n) | 是 |
数据结构操作
| 数据结构 | 访问 | 查找 | 插入 | 删除 |
|---|---|---|---|---|
| 数组 | O(1) | O(n) | O(n) | O(n) |
| 链表 | O(n) | O(n) | O(1) | O(1) |
| 栈 / 队列 | O(n) | O(n) | O(1) | O(1) |
| 哈希表 | --- | O(1) 均摊 | O(1) 均摊 | O(1) 均摊 |
| 二叉搜索树(平衡) | O(logn) | O(logn) | O(logn) | O(logn) |
| 二叉搜索树(退化) | O(n) | O(n) | O(n) | O(n) |
| 堆 | O(1) 取顶 | O(n) | O(logn) | O(logn) |
| 前缀树 Trie | O(m) | O(m) | O(m) | O(m) |
m 为字符串长度
图算法
| 算法 | 时间 | 空间 |
|---|---|---|
| BFS / DFS | O(V+E) | O(V) |
| Dijkstra(堆优化) | O((V+E)logV) | O(V) |
| Bellman-Ford | O(V×E) | O(V) |
| Floyd-Warshall | O(V³) | O(V²) |
| Prim(堆优化) | O((V+E)logV) | O(V) |
| Kruskal | O(ElogE) | O(V) |
| 拓扑排序 | O(V+E) | O(V) |
动态规划常见模式
| 问题类型 | 时间 | 空间 | 滚动数组优化后 |
|---|---|---|---|
| 线性 DP(爬楼梯) | O(n) | O(n) | O(1) |
| 0-1 背包 | O(n×W) | O(n×W) | O(W) |
| 完全背包 | O(n×W) | O(n×W) | O(W) |
| 最长公共子序列 LCS | O(m×n) | O(m×n) | O(min(m,n)) |
| 最长递增子序列 LIS | O(n²) / O(nlogn) | O(n) | --- |
| 区间 DP | O(n³) | O(n²) | --- |
| 树形 DP | O(n) | O(n) | --- |
复杂度判断口诀
scss
无循环 → O(1)
一层循环 → O(n)
两层嵌套循环 → O(n²)
每次折半 → O(logn)
一层循环 + 每次折半 → O(nlogn)
三层嵌套循环 → O(n³)
递归看树:
总节点数 × 每节点工作量 → 时间复杂度
树的高度 → 空间复杂度
分治看主定理:
T(n) = aT(n/b) + f(n)
比较 f(n) 和 n^(log_b a) 的大小决定结果
陷阱:
看起来两层但指针不重叠 → 实际 O(n)
看起来一层但步长非1 → 实际 O(nlogn) 或更低
递归不一定是 O(n) → 要画递归树分析