大 O 表示法与时间复杂度分析
本节目标
- 理解大 O 表示法的数学含义与工程意义
- 掌握常见时间复杂度的"长相"与判断方法
- 学会分析循环、递归、嵌套结构的复杂度
为什么需要复杂度:同一问题往往有多种解法,我们需要一把"与机器快慢无关"的尺子来比较算法优劣。大 O 描述的是当数据规模 n 增长时,运行时间增长的"阶",它会忽略常数与低阶项。换句话说,大 O 不关心"跑 2 毫秒还是 3 毫秒",只关心"n 变成 100 倍时,时间变成多少倍"。
常见复杂度(从快到慢):
O(1) 常数级 哈希查找、数组按下标访问
O(log n) 对数级 二分查找、平衡树查找
O(n) 线性级 单层循环遍历
O(n log n) 线性对数 快排 / 归并 / 堆排序(排序的"天花板")
O(n²) 平方级 双层循环(冒泡 / 选择 / 插入)
O(2ⁿ) 指数级 无剪枝回溯(n 一大会爆炸)
O(n!) 阶乘级 全排列枚举判断技巧:数"执行次数随 n 变化的循环"。下面这段代码把五种复杂度都跑了一遍,建议保存下来边看边想:
// 运行环境:Node.js 14+
// 保存为 dsa-l1.js,执行:node dsa-l1.js
// O(1):和 n 无关
function getFirst(arr) { return arr[0] }
// O(n):循环正好执行 n 次
function sum(arr) {
let s = 0
for (let i = 0; i < arr.length; i++) s += arr[i]
return s
}
// O(n²):两层循环各 n 次
function bubbleBad(arr) {
for (let i = 0; i < arr.length; i++)
for (let j = 0; j < arr.length - 1; j++)
if (arr[j] > arr[j + 1]) [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
}
// O(log n):规模每次减半
function binarySearch(arr, target) {
let lo = 0, hi = arr.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1 // 中间下标
if (arr[mid] === target) return mid
if (arr[mid] < target) lo = mid + 1 // 去右半边
else hi = mid - 1 // 去左半边
}
return -1
}
// O(n log n):外层减半 × 内层线性(归并/快排主循环)
function mergeSort(arr) {
if (arr.length <= 1) return arr
const mid = arr.length >> 1
const L = mergeSort(arr.slice(0, mid)) // 左半递归
const R = mergeSort(arr.slice(mid)) // 右半递归
return merge(L, R) // 合并两个有序数组 O(n)
}
function merge(L, R) {
const out = []
let i = 0, j = 0
while (i < L.length && j < R.length) out.push(L[i] <= R[j] ? L[i++] : R[j++])
return out.concat(L.slice(i), R.slice(j))
}
// 调用示例
console.log(sum([1, 2, 3, 4])) // 10
console.log(binarySearch([1, 3, 5, 7], 5)) // 2
console.log(mergeSort([3, 1, 4, 2])) // [1, 2, 3, 4]递归复杂度两公式(主定理简化版):
① T(n) = T(n/2) + O(1) → O(log n) 二分递归,如二分查找
② T(n) = 2T(n/2) + O(n) → O(n log n) 分治,如归并/快排
③ T(n) = T(n-1) + O(1) → O(n) 线性递归,如阶乘
④ T(n) = 2T(n-1) + O(1) → O(2ⁿ) 斐波那契朴素递归,指数级!斐波那契的教训:朴素递归 fib(n)=fib(n-1)+fib(n-2) 是 O(2ⁿ);加记忆化(memo)后变成 O(n)——这正是动态规划的雏形(第七章细讲)。
工程注意:分析复杂度先找"最内层循环",再看它随 n 怎么变;多个循环相加取最大,嵌套相乘。
名词解释
- 大 O 表示法(Big O Notation):描述算法复杂度随数据规模 n 增长的"上界阶数"。它不是精确耗时,而是"n 很大时谁更快"的比较尺。常见等级 O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ)。方法:忽略常数、低阶项与底数的常数倍。
- 时间复杂度(Time Complexity):算法运行时间随输入规模增长的趋势。类比:O(1) 像"无论房间多大,你都能瞬间拿到门口的伞";O(n) 像"要逐个数完 n 个人";O(n²) 像"每两个人都要握一次手"。
课后练习
- 手写二分查找
binarySearch,并说明它的复杂度为什么是 O(log n)。- 答案:每次把搜索区间砍掉一半,区间长度从 n → n/2 → n/4 … 直到 1,需要约 log₂n 步,所以是 O(log n)。参考上面代码。
- 为什么朴素斐波那契是 O(2ⁿ)?如何降到 O(n)?
- 答案:
fib(n)会展开成两棵子树fib(n-1)和fib(n-2),大量子问题被重复计算,节点总数呈指数。加记忆化(用一个数组/对象缓存已算过的值)后每个数只算一次,变成 O(n)。
- 答案:
总结
复杂度分析不是背公式,而是一种"先看增长趋势、再谈常数优化"的工程直觉。很多人学算法卡在"能写出代码但说不清快慢"——其实只要养成习惯:写完一段循环就问自己"它随 n 怎么变",就能甩开大多数初学者。O(1) 到 O(n log n) 是我们日常追求的目标区间,一旦掉到 O(n²) 就要警惕(除非 n 很小),而 O(2ⁿ)、O(n!) 基本只在没有更好办法的暴力枚举里出现。真正写好算法的人,脑子里永远有一把"复杂度尺子",在写代码之前就先丈量了一遍。