时间复杂度和空间复杂度怎么分析?常见操作的复杂度是多少?
一句话回答
复杂度描述的是输入规模 n 变大时,耗时和内存的增长趋势,大 O 只保留最高阶项,忽略常数和低阶项。分析时间就是数基本操作执行了多少次:循环看嵌套层数和每层次数,递归写出递推式或画递归树,偶尔很贵的操作(如动态数组扩容)用均摊分析。空间复杂度算额外占用的内存,递归调用栈的深度也要算进去。面试时先说时间、再说空间,并讲清 n 指什么、瓶颈在哪一步。
详细解析
大 O 的含义和数据规模的直觉
大 O 描述上界:3n² + 100n + 7 记作 O(n²),n 足够大时,常数系数和低阶项对增长趋势的影响可以忽略。对数不写底数,log₂n 和 log₁₀n 只差常数倍。
| 复杂度 | 典型场景 | 1 秒内大致能处理的 n |
|---|---|---|
| O(1)、O(log n) | 下标访问、哈希表查找、二分查找 | 不受限 |
| O(n) | 遍历一次数组 | 10⁸ 左右 |
| O(n log n) | 排序、n 次堆操作 | 10⁶ 左右 |
| O(n²) | 两层循环枚举所有数对 | 10⁴ 左右 |
| O(2ⁿ) | 枚举所有子集 | 20 多 |
| O(n!) | 枚举全排列 | 10 左右 |
最后一列是按"每秒执行约 10⁸ 次简单操作"这个粗略假设倒推的,只用来建立直觉,不同语言和机器差别很大。按这个假设,n = 10⁵ 时 O(n²) 是 10¹⁰ 次操作,要 100 秒左右,肯定超时;O(n log n) 只有约 1.7 × 10⁶ 次。所以题目给的 n 是 10⁵ 量级时,就该往 O(n log n) 或 O(n) 去想。
循环、递归和递归栈怎么算
- 循环:顺序执行的部分相加取最大项,嵌套的部分相乘。内层次数不一定是 n:
for (let i = 1; i < n; i *= 2)只执行约 log n 次;j从i + 1开始的两层循环执行 n(n-1)/2 次,仍是 O(n²) - 递归:写出递推式,或者画递归树,总耗时 = 各层工作量之和
| 递推式 | 例子 | 结果 |
|---|---|---|
| T(n) = T(n/2) + O(1) | 二分查找 | O(log n) |
| T(n) = 2T(n/2) + O(n) | 归并排序:递归树 log n 层,每层合并共 O(n) | O(n log n) |
| T(n) = T(n-1) + O(n) | 快排每次都选到最大或最小值当基准 | O(n²) |
| T(n) = T(n-1) + T(n-2) + O(1) | 朴素递归求斐波那契数 | O(2ⁿ),更紧的界约为 O(1.618ⁿ) |
空间复杂度一般只算额外空间,输入本身不算。递归的每层调用占一个栈帧,空间至少是 O(最大递归深度):朴素递归求斐波那契数总共调用指数级次,但同一时刻栈上最多 n 层,空间是 O(n)。ES2015 规范要求严格模式下做尾调用优化,但 V8(Chrome、Node.js)只在早期放在实验开关后面试过,没有正式上线,递归深度到 10⁴ 量级就可能报 RangeError: Maximum call stack size exceeded,深度可能很大时要改成循环或手动维护栈。
均摊分析:push 为什么是 O(1)
动态数组底层预留了容量,满了就申请一块更大的内存,把旧元素搬过去。单次扩容是 O(n),但容量按比例放大,越往后扩容越少。以每次翻倍为例,n 次 push 搬运的元素总数是 1 + 2 + 4 + … < 2n,平摊到每次 push 是 O(1),这叫均摊 O(1)。实际扩容倍数由引擎决定,只要按比例扩容结论就成立;如果每次只多加固定的 10 个位置,总搬运量是 O(n²),均摊就变成了 O(n)。
// 模拟 n 次 push,统计扩容时一共搬运了多少个元素
function countCopies(n, grow) {
let capacity = 1
let copies = 0
for (let size = 0; size < n; size++) {
if (size === capacity) {
copies += size // 已有的 size 个元素全部搬到新内存
capacity = grow(capacity)
}
}
return copies
}
console.log(countCopies(1000, (c) => c * 2)) // 1023:小于 2n
console.log(countCopies(1000, (c) => c + 10)) // 49600:约 n² / 20
JS 常见操作的复杂度
规范对大多数操作没有规定复杂度,下表是主流引擎的典型情况:
| 操作 | 复杂度 | 说明 |
|---|---|---|
arr[i]、arr.length |
O(1) | |
push、pop |
均摊 O(1) | 只动末尾 |
shift、unshift、splice |
O(n) | 要挪动后面的元素;引擎对个别情况有优化,但不能依赖 |
indexOf、includes、find |
O(n) | 线性查找 |
slice、concat、[...arr]、join |
O(n) | 要拷贝或遍历全部元素 |
Map / Set 的 get、set、has、delete |
平均 O(1) | 规范只要求平均访问时间低于线性,主流引擎用哈希表实现 |
sort |
O(n log n) | V8 用 TimSort;比较函数本身的开销要乘上去 |
循环里用 += 拼接字符串 |
理论上最坏 O(n²) | 字符串不可变,按语义每次都生成新字符串;V8 用 rope 结构推迟拼接,实际很快 |
面试时怎么说复杂度
- 先给结论并说明变量含义:"时间 O(n),空间 O(n),n 是数组长度"
- 再给依据:"只遍历一遍,每次 Set 操作平均 O(1);最坏情况 Set 里存 n 个元素"
- 多个输入规模分开写,如 O(m + n)、O(n · k log k),不要随手合并成 O(n)
- 有最好、最坏、均摊之分的主动说明,比如"快排平均 O(n log n),最坏 O(n²),随机选基准可以避免"
- 从暴力解讲起,指出瓶颈再优化。比如判断数组有没有重复元素,两层循环是 O(n²),瓶颈是"查之前有没有出现过"要 O(n);用 Set 记录见过的值,查找降到 O(1),整体 O(n),代价是 O(n) 的额外空间。更多以空间换时间的例子见哈希表的典型应用
代码示例:斐波那契数的三种写法
同一道题,写法不同,复杂度差别很大。朴素递归大量重复计算同一个子问题;记忆化让每个 n 只算一次;只依赖前两项,所以可以只保留两个变量。
// 朴素递归:时间 O(2ⁿ),空间 O(n)(递归深度)
function fibNaive(n) {
return n < 2 ? n : fibNaive(n - 1) + fibNaive(n - 2)
}
// 记忆化递归:时间 O(n),空间 O(n)(缓存 + 递归栈)
function fibMemo(n, memo = new Map()) {
if (n < 2) return n
if (!memo.has(n)) memo.set(n, fibMemo(n - 1, memo) + fibMemo(n - 2, memo))
return memo.get(n)
}
// 迭代:时间 O(n),空间 O(1)
function fib(n) {
let prev = 0
let curr = 1
for (let i = 0; i < n; i++) [prev, curr] = [curr, prev + curr]
return prev
}
console.log([fibNaive(10), fibMemo(10), fib(10)]) // [55, 55, 55]
面试官可能追问
均摊 O(1) 和平均 O(1) 有什么区别?
均摊是对一串操作算总代价再平分,不涉及概率:任意 n 次 push 的总代价都是 O(n)。平均是对输入或随机性取期望:哈希表查找平均 O(1),但大量键冲突时单次会退化成 O(n);快排随机选基准,期望 O(n log n),运气极差时仍可能 O(n²)。
递推式有没有通用的求法?
分治形式 T(n) = aT(n/b) + f(n) 可以用主定理:比较 f(n) 和 n^(log_b a) 谁增长得快。f(n) 更小(差一个多项式因子,如 n^0.5),结果是 O(n^(log_b a));两者同阶,结果多乘一个 log n;f(n) 更大(同样要差多项式因子,且满足正则条件),结果是 O(f(n))。归并排序 a = 2、b = 2、f(n) = n,同阶,得 O(n log n)。T(n-1) 这类每次减一的形式不适用主定理,直接展开或画递归树。
为什么 O(n log n) 的排序在小数据上可能比 O(n²) 的插入排序慢?
大 O 忽略了常数。n 很小时,插入排序代码简单、访问内存连续、没有递归开销,实际更快。所以工程上的排序常常是混合的,比如 TimSort 会先用插入排序处理较短的片段,再做归并。复杂度说明的是规模变大时的趋势,不直接等于某个规模下的快慢。
返回结果占的空间算不算空间复杂度?
一般不算,习惯说"除输出外额外 O(1)"。原地修改输入(比如原地排序)额外空间是 O(1),但改动了调用方的数据,面试时最好说明一句,或先问清楚是否允许修改输入。
易错点
- 循环里调用
includes、indexOf、shift、splice,看起来只有一层循环,实际是 O(n²) - 递归只算了变量占的空间,漏了调用栈;记忆化递归是 O(n) 的缓存加 O(n) 的栈
- 多个输入规模混成一个 n,或者忽略字符串长度 k,比如对 n 个字符串分别排序是 O(n · k log k)
- 把 Map 的 O(1) 当成最坏情况的保证,它是平均复杂度
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。