大 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 怎么变;多个循环相加取最大,嵌套相乘。

名词解释

课后练习

  1. 手写二分查找 binarySearch,并说明它的复杂度为什么是 O(log n)。
    • 答案:每次把搜索区间砍掉一半,区间长度从 n → n/2 → n/4 … 直到 1,需要约 log₂n 步,所以是 O(log n)。参考上面代码。
  2. 为什么朴素斐波那契是 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!) 基本只在没有更好办法的暴力枚举里出现。真正写好算法的人,脑子里永远有一把"复杂度尺子",在写代码之前就先丈量了一遍。