☰
逃不开的时空复杂度:理解大O记号才算入门算法
2026/10/20 12:26:00 网站建设 项目流程

算法第一课,为什么逃不开时空复杂度

如果你刚开始刷算法题,或者准备系统地补数据结构,十有八九会遇到这个开头。不管你看的是哪份教程,目录里总会有一个“0.”,后面跟着四个字:时空复杂度。很多人在这一节就卡住了,觉得又是数学符号又是函数曲线,还没走到排序和二分查找就先熄火了。

我个人的态度很直接:复杂度分析不是算法里最难的部分,但它是最值得先花半天搞明白的部分。因为后面所有数据结构和算法的取舍,本质都在回答同一个问题——这个方案在数据量变大时,时间和空间会怎样变化。搞懂复杂度,你就有了一把尺子;搞不懂,你看任何题解都只能背结论,换个数就不再理解了。

这篇文章我打算用比较实在的讲法,把时间复杂度和空间复杂度掰开揉碎。不堆数学证明,尽量用代码、例子和实际场景让你建立直觉;再穿插一些我实际分析代码时踩过的坑,希望能帮你少走点弯路。

1. 为什么学算法要先学复杂度:从“跑得太慢”说起

1.1 实测时间为什么不能当作评判标准

最开始我也干过这种事:写完两个算法,分别跑一遍,看哪个用时更短。比如对比冒泡排序和快速排序,拿一个只有几十个元素的数组去测,结果极其迷惑——有时候冒泡还更快。因为初始化那几毫秒、系统调度、CPU 缓存状态都在影响结果,你测的根本不是算法的能力,而是当前机器那一瞬间的心情。

但如果把数组规模从几十涨到几万、几十万,差距就出来了。冒泡排序开始以肉眼可见的速度变慢,快速排序依然从容。问题在于,你不能每次都靠“实际跑一把”来比较算法,因为你没法穷举所有输入规模,也没法保证两台机器的环境一致。这就需要一个脱离具体机器的度量方式。

1.2 复杂度衡量的是“增长趋势”而不是“时间秒数”

时间复杂度的本质,是算法执行时间随输入规模 n 增长时的变化趋势。我们不关心 n=100 的时候用了 0.1 毫秒还是 0.2 毫秒,我们关心的是 n 翻倍、变成 1000 倍、变成 1000000 倍的时候,执行时间大概会变成多少倍。

这有点像评估一个交通方案:不是看你今天出门开到公司花了 20 分钟还是 30 分钟,而是看路上车流量涨 10 倍以后,你还能不能在一个小时内到。有些方案在流量小的时候非常快,车一多就全线堵死;有些方案起步慢,但路况再差也只是线性地变慢。算法的复杂度分析,就是在交通规划阶段评估这种“抗压能力”。

空间复杂度同理,度量的是算法运行过程中额外内存随输入规模的增长趋势。两者合在一起,就是“时空复杂度”这个词的含义。

2. 大O记号:面向增长率的数学速记

2.1 大O到底在说什么

大O记号是复杂度分析的通用语言。它回答的是一个上界问题:当 n 足够大的时候,算法的操作次数不会超过某个数量级。注意是“不会超过”,也就是说它关心的是最坏情况下的增长上限。

我见过不少初学者纠结大O的精确定义,甚至被 epsilon-delta 语言吓退。实际上你不需要那么严谨——你用“忽略常数系数、忽略低阶项、只保留下最高阶项”这三条规则去理解就够了。

比如一个算法总共执行 (3n^2 + 5n + 8) 次操作。n=10 的时候,这三项分别是 300、50、8,低阶项还挺有存在感;但 n=10000 的时候,第一项是 300000000,后两项加起来才 50008,可以忽略不计。所以这个算法的时间复杂度就是 (O(n^2))。

2.2 常见复杂度量级横评

我把平时最常碰到的几个量级放在一起对比,方便你有个体感:

量级名称体感类比100个元素的操作次数参考
O(1)常数时间直接翻到书签那一页1
O(log n)对数时间电话簿里二分查找人名约7
O(n)线性时间一页页翻书找内容100
O(n log n)线性对数时间把一堆文件按姓名排序约700
O(n^2)平方时间两层循环暴力查重10000
O(2^n)指数时间枚举所有子集2的100次方,人类无法想象

这里有个特别容易误判的点:很多人看到 O(log n) 觉得“这不就是一条往下走的曲线嘛,应该很小”,但不少排序算法的下限就是 O(n log n),比 O(n) 大。对数的底数是多少不重要,因为换底公式只差一个常数;重要的是它增长得极其缓慢,哪怕是 n 等于 10 亿级别,log n 也就三十几。

2.3 为什么可以忽略常数项和低阶项

有人会质疑:真实工程里常数项差别可能非常大啊。同样是 O(n),一次循环和十次循环,系数差了十倍,难道没影响吗?

有影响,但这不是复杂度分析要解决的问题。复杂度的价值在于判断“随着 n 变大,谁先撑不住”。当 n 足够大时,一个常数系数大但复杂度低的算法,会反杀一个常数系数小但复杂度高的算法。比如 (100n) 和 (n^2),n=10 时前者还更大,但 n=200 时 100n 是 20000,n^2 是 40000,已经追上了;到 n=1000,差距就是 10 万对 100 万。

所以复杂度分析适合做“跨量级判断”:O(n) 和 O(log n) 有本质差别,O(n) 和 O(2n) 没有本质差别。把常数级别的优化留给实际 profiling 去做。

3. 时间复杂度的手撕过程:从代码到函数

3.1 顺序结构:关键看循环,而不是总行数

分析一段代码的时间复杂度,不需要一行行数执行行数,也不需要管声明变量的开销。核心是找那些会重复执行的结构,尤其是循环和递归。

判断循环的本质,是搞清楚循环变量从哪开始、到哪结束、每次怎么变化。最基础的三类:

# 情况一:固定次数循环,n次 def example1(n): total = 0 for i in range(n): total += i return total
# 情况二:循环次数随i减半,每次i翻倍 def example2(n): i = 1 count = 0 while i < n: i *= 2 count += 1 return count
# 情况三:循环次数和输入规模无关,永远100次 def example3(n): total = 0 for i in range(100): total += n return total

情况一是典型的 O(n),很好判断。情况二的执行次数是 log₂n 的量级,因为 i 从 1 出发,乘以 2 到超过 n,一共执行了约 log₂n 次——这是 O(log n) 最常见的来源,而它只出现在“规模每次折半/翻倍”的场景里。情况三看起来很吓人,但循环固定 100 次,和 n 没有任何关系,所以是 O(1)。

3.2 循环嵌套:相乘而不是相加

分析嵌套循环时有个口诀:外层循环复杂度 × 内层循环复杂度。但注意,这个“相乘”不是无脑套公式,你得先确认每一层循环的变化范围。

最经典的双层循环:

def nested_example(n): for i in range(n): for j in range(n): print(i, j)

外层执行 n 次,每次内层都执行 n 次,总操作量是 n × n = n²,所以是 O(n^2)。

另一种很常见的“优化版”双层循环:

def half_nested_example(n): for i in range(n): for j in range(i, n): print(i, j)

内层循环从 i 到 n,总执行次数是 (n + (n-1) + (n-2) + ... + 1 = n(n+1)/2),约等于 0.5n²。按规则去掉常数系数后,依然是 O(n^2)。这就是为什么很多题解里所谓的“剪枝一半”不会改变复杂度量级——它只优化了常数,没有改变增长趋势。

还有一种容易踩坑的嵌套:内外层都用同一个变量缩影。

def tricky_nested_example(n): i = 1 while i < n: j = i while j < n: j *= 2 i += 1

外层是 O(n),内层每次从 i 到 n 翻倍,是 O(log n)。所以整体是 O(n log n)。计算过程虽然不复杂,但你必须仔细看每一层的迭代步长,而不是只数一下有没有两层循环就草率定结论。

3.3 递归时间复杂度的直观拆解

递归的时间复杂度比循环难判断,因为循环是显式的重复,递归是隐式的重复。最简单也最实用的方法是写出递推关系,然后展开观察规律。注意,我不打算讲严格的主定理,先用最直观的“二叉树展开”来看。

拿最常见的二分查找举例:

def binary_search(nums, target, left, right): if left > right: return -1 mid = (left + right) // 2 if nums[mid] == target: return mid elif nums[mid] < target: return binary_search(nums, target, mid + 1, right) else: return binary_search(nums, target, left, mid - 1)

每次递归调用只会走一个分支,问题规模减半。递归层数约 log n,每层只做 O(1) 操作,所以时间复杂度 O(log n)。

再看一个容易吓倒人的例子——朴素递归斐波那契:

def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2)

它的递归树大致是一棵二叉树的形状,每个节点分裂出两个子节点,树的深度约等于 n,节点总数约等于 (2^n) 级别。所以朴素递归版的时间复杂度是指数级的 O(2^n),n 到 50 就已经慢到无法接受。这就是为什么面试里提到斐波那契,基本不可能让你写朴素的递归版本,而是会引导你用带备忘的递归或者动态规划。

3.4 分析工具:把循环次数统计出来

很多时候你不需要精确统计代码执行了多少步,但你至少要会“算账”。我的习惯是先数“核心操作重复执行的次数”,再写成一个关于 n 的式子,最后化简成大O。

举一个我实际在代码评审里遇到过的例子:“查找数组中的多数元素(出现次数超过一半)”。最简单的暴力法是两层循环,每层都遍历整个数组,这明显是 O(n^2)。后来用哈希表统计出现次数,遍历两次数组,每次 O(n),总共 O(n)。再往后用摩尔投票法,只遍历一次,常数空间,依然是 O(n)。同样是 O(n),后者的空间从 O(n) 降成了 O(1)。这就说明,复杂度只告诉你增长趋势一样,不告诉你系数和空间细节,真正落地时还得看额外条件。

4. 空间复杂度:除了O(1)还有什么

4.1 空间复杂度的计数口径

空间复杂度看的是:除了输入本身占用的内存之外,算法在运行过程中额外开辟的存储空间大小。注意两个容易被忽视的细节:

第一,输入数据本身不算。你处理一个长度为 n 的数组,数组本身已经存在了,这不叫额外空间;讨论的是你创建的辅助数据结构、递归调用栈、临时变量等。

第二,变量的个数单独数没有意义,要看它随 n 怎么变化。比如一个临时变量存中间结果,不管 n 多大都只有一个,这是 O(1);但如果 new 一个长度为 n 的辅助数组,这就是 O(n)。

4.2 同一个功能,两种空间写法的对比

拿“原地反转数组”来举例,这是最能说明空间复杂度差异的场景。

第一种:新建一个数组,从后往前填进去。

def reverse_array_copy(arr): n = len(arr) new_arr = [0] * n for i in range(n): new_arr[i] = arr[n - 1 - i] return new_arr

这里的辅助数组长度是 n,所以额外空间是 O(n)。时间上是单层循环,O(n)。

第二种:原地双指针交换。

def reverse_array_inplace(arr): left, right = 0, len(arr) - 1 while left < right: arr[left], arr[right] = arr[right], arr[left] left += 1 right -= 1

只有两个指针变量,没有随 n 增长的额外空间,额外的空间就是 O(1)。功能一模一样,但空间从 O(n) 降到 O(1)。这是面试里最常见的追问:“能不能空间换时间?”、“能不能原地操作?”

4.3 递归调用栈也是一种空间

很多初学者算递归的空间复杂度容易忽略一件事:每一次递归调用会在系统的调用栈上压一层栈帧,栈帧里保存了局部变量和返回地址。递归深度是多少,空间开销就是多少。

比如上面那个二分查找,递归深度 O(log n),空间复杂度就是 O(log n)——哪怕代码里没有显式创建任何数组,调用栈本身就是空间占用。再比如递归斐波那契,递归深度达到 n,空间复杂度是 O(n),注意这跟它的时间 O(2^n) 不是一回事。

这带来一个很实际的影响:当递归深度过深时,即使时间复杂度看着不高,也可能因为调用栈爆掉而直接崩溃。我之前处理过一个数据量并不大的多叉树遍历,因为树深度不理想,递归版本在极端数据下居然栈溢出了,后来改成显式栈迭代才解决问题。空间复杂度的意义,有时不只在“省内存”,还在于“稳得住”。

5. 复杂度分析的常见误判与实战建议

5.1 误判一:看到遍历就默认O(n)

不是所有遍历都是 O(n)。如果遍历过程中跳过了很多元素,或者每个元素只处理了一次,仍然是 O(n);但如果遍历步长是指数增长的,就可能变成 O(log n)。更隐蔽的是那种看似线性的“双指针”遍历,如果左右指针分别扫,每个指针最多移动 n 步,总体还是 O(n),不是 O(n²)——两个指针不是嵌套关系,是顺序关系。

我见过有人写滑动窗口的题,内外看起来像两层循环,直接判断 O(n²),结果被面试官追问“你确定吗”?实际上滑动窗口里的 left 和 right 各自最多移动 n 次,内部虽然用了 while,但总体执行次数是 2n 级别,复杂度是 O(n)。分析这种代码时,要算的是所有指针移动总次数,而不是简单地“有循环嵌套就是 O(n²)”。

5.2 误判二:忽略API调用的隐藏复杂度

最容易被坑的地方,往往不是你自己写的循环,而是别人封装好的函数。很多人写代码时数了层数,却没注意到某一行调用了标准库方法,这一行背后可能藏着一个 O(n) 甚至 O(n²) 的操作。

随手举个例子:

def remove_first_elements_bad(lst, k): for _ in range(k): lst.pop(0) return lst

在 Python 里,list.pop(0) 会把后面所有元素往前挪一位,复杂度是 O(n)。外层再循环 k 次,整体就是 O(kn) 量级。如果 k 接近 n,那就是 O(n²)。换成 deque.popleft() 或者直接截断切片,就能把它降下来。

再比如字符串拼接,在循环里用 += 逐字符拼接,每次拼接都可能触发一次字符串拷贝,循环 n 次就是 O(n²)。改用 list 收集再 “”.join(),一次性生成结果,就能回到 O(n)。

这类问题不是靠“背复杂度”能避免的,只能靠你对自己用的语言的数据结构实现有基本了解。

5.3 误判三:递归深度和时间复杂度混淆

有人看到递归就写 O(2^n),看到迭代就觉得是 O(n),这也不对。比如二分查找的递归版是 O(log n),它的迭代版也是 O(log n),差的只有空间上的调用栈。决定时间复杂度的不是“递归还是迭代”这个形态,而是“每层递归分裂出多少子问题、合并子问题的成本是多少、递归树有多深”。

判断递归复杂度的通用思路,是画递归树:

  • 每个节点代表一次函数调用
  • 树的高度由递归深度决定
  • 节点总数大致就是调用总次数
  • 每层/每次调用的 O(1) 操作再叠加

如果画完递归树仍然不确定,就找递推式展开。二分查找对应 (T(n) = T(n/2) + O(1));归并排序对应 (T(n) = 2T(n/2) + O(n))。后者每层合并要扫一遍子区间,整棵树有 log n 层,每层总工作量 O(n),所以总复杂度是 O(n log n)。这套“树高 × 每层工作量”的思考路径,比死记主定理实用得多。

5.4 只有一份代码时,怎么快速估算

如果没有现成的题解可以参考,我给你一个我自己常用的“三步估算”法:

第一步,找到主轴代码路径。也就是去掉上下文的初始化、清理、异常处理这部分,找到算法主体中会反复执行的代码片段。

第二步,为每个代码路径标注循环变量变化方式。具体说就是:循环跑几次?每次迭代步长是 1、2、倍增还是减半?内层和外层的变量之间有没有依赖?

第三步,把结果写成关于 n 的式子。先留常数,最后统一去掉低阶和系数,只保留最高阶量级。

这个方法不能保证你得到最精确的复杂度,但足够应对绝大多数算法题和代码评审场景。你要是真的遇到特别复杂的递归或嵌套结构,再用递推式或者递归树做深度分析,而不是一上来就在脑子里硬背答案。

我自己的体会是,复杂度分析这门功夫是越用越熟的。前面几次分析可能慢、可能错,但只要你把每个题解里的复杂度部分拆开重新算一遍,坚持十几道题之后,看到一段代码就能迅速脑内算出量级。到那时候,你去选数据结构、选算法策略,都会比单纯“记住结论”的人快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询