时间复杂度:从"这代码怎么这么慢"到"原来如此"

用最直观的方式理解时间复杂度,告别死记硬背 O(n)、O(log n),真正明白算法快慢的本质

8 分钟阅读
小明

时间复杂度:从"这代码怎么这么慢"到"原来如此"

你可能听过这样的对话:

"这个算法是 O(n²),太慢了。"
"换成 O(n log n) 的排序吧。"

然后你点点头,假装听懂了。

别装了,我知道你没懂。因为当年的小明也是这样。

今天,我们不背公式,不看数学证明,只用最直观的方式,让你真正理解时间复杂度。


一个真实的场景

假设你在一个图书馆找一本书。

场景 A:书架是乱的

你只能一本一本地翻,从第一本看到最后一本。图书馆有 1000 本书,最坏情况下你要翻 1000 次。

场景 B:书架按字母排序

你可以用"二分法"——先看中间的书,如果要找的书在前面就往前找,在后面就往后找。每次排除一半的书。1000 本书,大约翻 10 次就能找到(因为 2¹⁰ = 1024)。

同样是找书,方法不同,效率天差地别。

时间复杂度,就是用来描述这种效率差异的工具。


时间复杂度到底在说什么?

时间复杂度回答的是一个问题:

当数据量增大时,算法的运行时间会以什么样的速度增长?

注意,它不是告诉你"这个算法要运行多少秒",而是告诉你"数据量翻倍时,时间会怎么变"。

这就像问:

  • "你跑 100 米要多少秒?" → 具体时间(取决于你的体能、天气、心情...)
  • "你跑的距离翻倍,时间大概翻几倍?" → 增长规律(这是时间复杂度关心的)

几种常见的复杂度,用人话说

O(1) —— 常数时间

特点:不管数据量多大,时间都一样。

比喻:你知道书在第 3 排第 5 本,直接走过去拿。书架上有 100 本还是 100 万本,对你来说没区别。

// 取数组的第一个元素
function getFirst(arr) {
  return arr[0]  // 不管数组多长,都是一步到位
}

现实中的例子

  • 用下标访问数组元素
  • 哈希表查找(理想情况)
  • 判断一个数是奇数还是偶数

O(n) —— 线性时间

特点:数据量翻倍,时间也翻倍。

比喻:书架是乱的,你要找的书可能在任何位置,只能一本本翻。书越多,翻的时间越长。

// 在数组中找某个值
function findValue(arr, target) {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === target) return i
  }
  return -1
}

为什么叫"线性"? 如果画个图,x 轴是数据量 n,y 轴是时间,你会得到一条直线。

现实中的例子

  • 遍历数组/链表
  • 找最大值/最小值
  • 简单的字符串匹配

O(n²) —— 平方时间

特点:数据量翻倍,时间变成 4 倍。数据量变成 10 倍,时间变成 100 倍。

比喻:你不仅要翻每一本书,还要把每本书和其他所有书比较一遍。这就是"双重循环"的代价。

// 冒泡排序
function bubbleSort(arr) {
  for (let i = 0; i < arr.length; i++) {          // 外层循环 n 次
    for (let j = 0; j < arr.length - 1; j++) {    // 内层循环也 n 次
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
      }
    }
  }
}

危险信号:当你写出两层嵌套的 for 循环时,停下来想一想,有没有更好的办法。

现实中的例子

  • 冒泡排序、选择排序
  • 简单的两两比较
  • 很多暴力解法

O(log n) —— 对数时间

特点:数据量翻倍,时间只增加一点点。

比喻:二分查找。每次排除一半,1000 本书只需要 10 次比较,100 万本书也只需要 20 次。

// 二分查找(数组必须有序)
function binarySearch(arr, target) {
  let left = 0
  let right = arr.length - 1
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (arr[mid] === target) return mid
    if (arr[mid] < target) left = mid + 1
    else right = mid - 1
  }
  return -1
}

为什么是 log? 因为你每次把问题规模砍半,砍几次能砍到 1?这就是对数的定义。

现实中的例子

  • 二分查找
  • 平衡二叉树的查找
  • 很多分治算法的"分"的过程

O(n log n) —— 线性对数时间

特点:比 O(n²) 快很多,是高效排序算法的标配。

比喻:你把书分成很多小堆(log n 次分割),然后每堆都整理一遍(每次整理 n 本)。

// 归并排序的思想
function mergeSort(arr) {
  if (arr.length <= 1) return arr
  
  const mid = Math.floor(arr.length / 2)
  const left = mergeSort(arr.slice(0, mid))   // 分
  const right = mergeSort(arr.slice(mid))     // 分
  
  return merge(left, right)                    // 合(这里是 O(n))
}

现实中的例子

  • 归并排序、快速排序、堆排序
  • 很多分治算法

直观对比:当 n = 1000 时

复杂度操作次数直观感受
O(1)1眨眼间
O(log n)~10数数就完事
O(n)1,000还行
O(n log n)~10,000可以接受
O(n²)1,000,000有点慢了
O(2ⁿ)数不清别想了

当 n = 1,000,000(一百万)时:

复杂度操作次数
O(1)1
O(log n)~20
O(n)1,000,000
O(n log n)~20,000,000
O(n²)1,000,000,000,000(一万亿)

看到 O(n²) 的可怕了吧?这就是为什么面试官总问你"有没有更好的解法"。


如何分析代码的时间复杂度?

记住三条规则:

规则 1:看循环

  • 一层循环 → 通常 O(n)
  • 两层嵌套循环 → 通常 O(n²)
  • 循环次数减半 → 通常 O(log n)

规则 2:只看最高阶

function example(n) {
  // 第一部分:O(n)
  for (let i = 0; i < n; i++) { /* ... */ }
  
  // 第二部分:O(n²)
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) { /* ... */ }
  }
}

总复杂度是 O(n + n²) = O(n²)。因为当 n 很大时,n² 远大于 n,n 可以忽略。

规则 3:忽略常数

O(2n) = O(n),O(100n²) = O(n²)

常数不影响增长趋势,所以忽略它。这也是为什么时间复杂度不能告诉你"具体多少秒"。


几个容易踩的坑

坑 1:看起来是 O(n),实际是 O(n²)

// 你以为这是 O(n)?
for (let i = 0; i < arr.length; i++) {
  arr.splice(i, 1)  // splice 本身是 O(n) 的!
}
// 实际是 O(n²)

坑 2:递归的复杂度不直观

// 斐波那契数列
function fib(n) {
  if (n <= 1) return n
  return fib(n - 1) + fib(n - 2)
}

看起来简洁优雅,实际是 O(2ⁿ),慢到离谱。n = 40 就能让你的电脑卡住。

坑 3:API 的隐藏复杂度

// indexOf 是 O(n)
if (arr.indexOf(x) !== -1) { /* ... */ }

// includes 也是 O(n)
if (arr.includes(x)) { /* ... */ }

// 想要 O(1)?用 Set
const set = new Set(arr)
if (set.has(x)) { /* ... */ }

实战:优化一道经典题

题目:找出数组中两个数,使它们的和等于目标值。

暴力解法 O(n²)

function twoSum(nums, target) {
  for (let i = 0; i < nums.length; i++) {
    for (let j = i + 1; j < nums.length; j++) {
      if (nums[i] + nums[j] === target) {
        return [i, j]
      }
    }
  }
}

两层循环,每个数都要和其他数比较一遍。

优化解法 O(n)

function twoSum(nums, target) {
  const map = new Map()  // 用哈希表记录"见过的数"
  
  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i]
    if (map.has(complement)) {
      return [map.get(complement), i]
    }
    map.set(nums[i], i)
  }
}

只需要遍历一次,每次查找都是 O(1)。

思路转变:从"两两比较"变成"查找差值",用空间换时间。


总结

时间复杂度不是用来背的,而是用来分析的。

当你能看着代码,自然而然地判断出它是 O(n) 还是 O(n²),能意识到"这里有两层循环,可能有性能问题",你就真正理解时间复杂度了。

记住:

  • O(1):直接存取,最快
  • O(log n):每次砍半,很快
  • O(n):遍历一遍,正常
  • O(n log n):高效排序的标配
  • O(n²):双重循环,要警惕
  • O(2ⁿ):指数爆炸,别用

下一篇,我们聊聊空间复杂度——代码不仅要跑得快,还要省内存。


「程序员的两大难题:缓存失效和命名。」
「还有第三个:分析递归的时间复杂度。」
—— 小明