1. 为什么“算法快不快”不能靠感觉
1.1 先抛一个问题:两段代码谁更快
如果你写过一段循环,肯定听过一个说法:算法快慢不看秒表,看时间复杂度和空间复杂度。这句话说起来容易,真让你解释什么是 O(n)、什么是 O(n^2)、两者差多少,很多人又卡住了。
假设你要从一个长度为 n 的数组里查一个数。第一段是线性循环,第二段是二分查找。很多初学者会说“线性循环简单,一定更快”,可一旦 n 变成 1000 万,线性循环可能要跑几秒钟,二分查找几乎瞬间返回。问题出在哪?出在我们把“快到肉眼没感觉”当成了“快”,而没有用一个随数据规模变化的标准去衡量。
这就是时间复杂度和空间复杂度存在的意义。它们不是面试八股文里用来背的术语,而是描述一段算法“消耗资源如何随着数据规模增长”的语言。说得再直白一点:不讨论数据规模就聊快慢,等于不讨论距离就聊油耗。O(n)、O(logn) 这样的记号,就是给你写出的每段循环、每个递归、每层缓存做一次“体检”,告诉你在数据变大时,代码是跑成一条平线,还是跑成一条陡峭的上坡路。
1.2 复杂度到底“度量”了什么
时间复杂度的度量对象不是秒,也不是毫秒。真实运行时间受到机器 CPU、内存、语言、编译器的影响,同一个递归在 Python 里慢到爆,换 C 语言后又快得惊人。复杂度分析划掉了这些噪声,只保留最核心的关系:计算步骤的数量 T(n) 和输入规模 n 之间是什么关系。
比如一个简单循环要执行 n 次,T(n) 就是 n 的一次方;双重循环要执行 n^2 次,T(n) 就是 n 的二次方。空间复杂度同理,度量的是算法在运行过程中额外申请的内存单元数量,和输入规模之间的关系。这个“额外”很关键,通常我不把输入本身占用的空间算进去,只算为了完成任务额外开辟的那部分内存,比如辅助数组、递归调用栈、中间变量。明白这一点,后续才不会把空间复杂度算错。
我在实际工作中还发现,很多人学复杂度时只记住了“O(n)”“O(n^2)”的写法,却不知道它背后是一套“渐进分析”的思想。等你真正理解了这套思想,看一段代码脑子里就能浮现出它在大数据量下的表现,而不是靠实验去猜。
1.3 我在刷题和项目里为什么反复强调这个概念
我自己的体会是,很多代码在样例数据上运行没问题,一上生产环境就超时或者内存告警,绝大多数不是某个语法写错了,而是复杂度选错了。举个很常见的例子:字符串拼接。如果在一个循环里用 Python 的字符串加法做累积,比如 result += s[i],表面上是 O(n) 的循环,但字符串不可变,每次加法都要重新申请内存并复制旧内容,整体退化成 O(n^2)。改成列表收集再 join,复杂度就回到 O(n)。这种问题靠肉眼 debug 根本看不出来,只有心里时刻带着复杂度分析,才能在生产环境“爆雷”之前把它拦下来。
所以这篇文章我想带着你把时间复杂度和空间复杂度从头捋一遍,包括它们怎么定义、怎么计算、常见误区是什么,以及一些可以拿进项目里直接用的判断技巧。适合刚开始学数据结构的同学,也适合正在复习算法、准备面试或想系统地给存量代码做一次体检的开发者。
2. 时间复杂度:别再看秒表,看增长趋势
2.1 渐近分析:为什么只看最高阶项
时间复杂度用的是“渐进分析”,英文叫 asymptotic analysis。意思是,当 n 足够大时,T(n) 中影响最大的部分就代表了整个算法的趋势。比如 T(n) = 3n + 10,n 从 10 涨到 1000,常数项 10 基本无感;n 再涨到 100 万,3n 才是决定资源消耗的主角。于是我们记作 O(n)。
严格的数学定义涉及极限和常数倍数:存在正常数 c 和 n0,使得当 n >= n0 时,f(n) <= c*g(n),那么 f(n) = O(g(n))。听起来像数学分析,但你可以把它理解成一个“上界”:算法的实际消耗最多不会超过某个数量级的若干倍。工程里我们常说某个算法是 O(n),其实就是在表达它的运行时间大致随 n 线性增长。
为什么不纠结常数?因为当 n 足够大时,常数项和低阶项对增长的“形状”几乎没有影响。但这里有个前提:n 足够大。如果数据集永远只有几百条,O(n^2) 甚至可能比 O(n) 跑得更快,因为常数更小。这一点后面第 5 章会展开,它是很多人误用复杂度的地方。
2.2 一张表看懂常见复杂度
| 复杂度 | 增长趋势 | 典型场景 |
|---|---|---|
| O(1) | 不随 n 变化 | 数组按下标访问、哈希表读写 |
| O(logn) | 缓慢增长 | 二分查找、平衡二叉搜索树 |
| O(n) | 线性增长 | 单层循环遍历数组 |
| O(nlogn) | 略超线性 | 归并排序、快排平均情况 |
| O(n^2) | 平方增长 | 两层嵌套循环、冒泡排序 |
| O(2^n) | 爆炸式增长 | 递归枚举子集 |
| O(n!) | 极难承受 | 全排列枚举 |
这张表建议记在脑海里。遇到一段代码,先估计它属于哪一档,再判断能不能接受。我面试时常看到有人把 O(nlogn) 说成 O(logn),虽然只是一字之差,但 n 到 10 万时两者差了约 10 万倍的计算量。这个错不只是笔试扣分,在系统设计里真会造成灾难。
2.3 手把手推导:循环、嵌套、顺序结构
算代码的时间复杂度,我有三个固定套路。
第一,只看最深层那个循环的基本操作。如果循环体是 O(1),那整层循环就是 O(循环次数)。例如:
for i in range(n): print(i)print 是 O(1),循环 n 次,结果是 O(n)。
第二,嵌套循环要把层数相乘。外层 n 次,内层 n 次,那就是 n*n = n^2:
for i in range(n): for j in range(n): print(i + j)如果内层循环次数和外层变量有关,比如 for j in range(i),总次数是 0+1+...+n-1 = n(n-1)/2,取最高阶还是 O(n^2)。这一点很多人算错,他们以为“内层不是 n 次所以不是 O(n^2)”,实际等差求和之后仍然是二次方。
第三,顺序结构取最大的那部分。如果一段代码先做 O(n) 的循环,再做 O(n^2) 的嵌套循环,总的复杂度是 O(n + n^2) = O(n^2)。低阶的那一部分被高阶“吞并”,没必要单独写出来。
递归的情况稍微特殊,它要写出递推式。比如斐波那契数列的朴素递归:T(n) = T(n-1) + T(n-2) + O(1),这个递推式的解是 O(2^n)。如果用了记忆化,每个子问题只算一次,变成 O(n)。这也能看出来,数据结构记不记中间结果,往往是“能用”和“根本跑不完”的区别。
2.4 最坏情况、平均情况和均摊情况
O 记号可以用于最坏、平均、最好任何一种情况,只是工程里默认说“复杂度”时,一般指最坏情况。因为最坏情况是你能承诺的上限,系统设计必须保障最坏情况下也不崩。比如哈希表查找平均是 O(1),但在大量冲突时最坏可能退化成 O(n)。如果只按平均复杂度设计接口,有一天数据被恶意制造冲突,整个服务就挂了。
所以很多哈希表的实现会在冲突过多时触发 rehash,这就是用均摊复杂度来保证整体性能。均摊分析指的是在一系列连续操作中,把偶尔的高成本操作平摊到每次操作上,典型例子是动态数组 append:大部分时候 O(1),触发扩容时 O(n),但均摊下来每次追加还是 O(1)。这三个“情况”要分清,面试里十有八九会被问。
3. 空间复杂度:内存不是让你随便造的
3.1 空间复杂度到底在统计什么
很多初学者以为空间复杂度就是“变量个数”,这是片面的。空间复杂度统计的是算法运行过程中额外需要的内存单元,增长趋势和输入规模 n 的关系。这里要抓住两个词:“额外”和“趋势”。
输入数据本身占的内存不计入,或者更准确地说,如果输入是数组,数组本身确实占用 O(n) 内存,但那是题目给你的,不是你算法消耗的额外资源。分析算法的时候,我们只关心你为了完成计算,额外创建了哪些结构,比如辅助数组、哈希表、递归栈。如果一份代码里输入数组是 O(n),你新建了一个同样大小的 result 数组,那额外空间就是 O(n),总空间是 O(n);如果你只用了几个临时变量,哪怕输入数组本身是 O(n),额外空间也是 O(1)。
我见过很多人在算法题解析里把这两者混在一起,导致明明空间 O(1) 的算法被当成 O(n),或者反过来,把输入数组自带的 O(n) 算成算法额外开销。搞清楚口径,再评判代码。
3.2 常见空间复杂度档位,附典型例子
| 空间复杂度 | 典型情况 |
|---|---|
| O(1) | 只使用有限几个临时变量:交换、计数器 |
| O(logn) | 递归深度为 logn:二分查找递归 |
| O(n) | 新建一个长度 n 的辅助数组、哈希表存 n 个元素 |
| O(n^2) | 二维矩阵、邻接矩阵存图 |
最需要留意的是递归调用栈。递归每调用一层,系统栈帧就要保存当前参数、局部变量和返回地址。递归深度是 d,那调用栈空间就是 O(d)。和循环不同,循环不额外占用调用栈。比如快速排序如果写成递归,平均递归深度是 O(logn),空间复杂度 O(logn);如果每次选的基准都很差,递归深度退化成 O(n),空间可能变 O(n)。同样一个算法,实现方式不同,空间复杂度可能完全不同。
3.3 递归的调用栈空间为什么是隐形成本
递归的空间成本往往被忽视,因为你看不到那个“栈”,它不像数组一样直观。但一旦递归深度变大,它是最容易导致内存溢出的部分。
举个例子:计算 1 加到 n,循环写法只用一个累加变量,空间 O(1);递归写法每层保存一个中间状态,深度 n,空间 O(n)。函数功能一样,但底层资源占用完全不同。
我踩过一个坑:用递归遍历一颗深度很大的 JSON 树,Python 默认递归深度限制是 1000 左右,数据一深就直接 RecursionError。后来我改成显式栈的迭代写法,虽然代码看起来啰嗦一点,但再也不用担心栈溢出。这就是空间复杂度分析在真实问题里的价值:它能提前告诉你某个写法撑不撑得住。
3.4 空间换时间到底值不值
复杂度分析里最常用的策略就是空间换时间。比如把递归改成迭代并显式使用栈,或者用哈希表预计算,能够减少时间,但多占了内存。一个非常经典的例子是“两数之和”问题:暴力解双重循环是 O(n^2) 时间、O(1) 空间;先用哈希表遍历一次,时间复杂度降到 O(n),但空间变成 O(n)。
实际业务中我也常这么干,比如接口里需要频繁查询某个映射关系,我会提前把数据加载到一个字典,而不是每次循环扫描。这多出来的几十 MB 内存,换来的是低延迟接口,值不值,取决于业务并发量和数据规模。分析能力在这儿就派上用场了:你要能清晰地算出,到底换来了多少时间,代价又是多少空间,才好做这个取舍。
4. 复杂度分析实操:四个常见代码模板逐行拆解
4.1 从一个数组求和开始:确认 O(n) 时间、O(1) 空间
def sum_array(arr): total = 0 for x in arr: total += x return total对于输入数组长度 n,这个代码只做两件事:一个变量 total 初始化和累加。每次累加是 O(1),循环 n 次,时间 T(n) = n,取最高阶是 O(n)。额外空间只有一个 total 变量,不随 n 变化,空间复杂度 O(1)。
如果有人说数组本身占了 O(n),所以要算 O(n) 空间,那要看口径。题目给你 arr,arr 本身不算算法额外空间。但如果你在函数里复制了一份 arr,比如 arr2 = arr[:],那额外空间就是 O(n),因为这份复制是你自己开的。这道题想考的就是你能不能在遍历时不开辅助结构。
4.2 嵌套循环但内层次数减半:为什么还是 O(n^2)
for i in range(n): for j in range(i + 1, n): process(i, j)内层循环平均次数是 n/2,所以总次数约为 n*(n - 1)/2,也就是二分之一 n 平方再减去二分之一 n。渐进复杂度只保留最高阶,常数二分之一不影响,所以仍然是 O(n^2)。
很多人在这里会犯迷糊:既然内层不是满的 n 次,是不是就算 O(nlogn)?不对,要想到等差求和。1 + 2 + ... + n-1 = n(n-1)/2,求和之后最高项是 n^2。同样的逻辑也适用于很多三角形遍历。真正能从 O(n^2) 降下来的关键,是让内层循环的规模不是 n,而是 log n,比如二分搜索的嵌套。
4.3 二分查找:时间和空间怎么同时算
def binary_search(nums, target): left, right = 0, len(nums) - 1 while left <= right: mid = (left + right) // 2 if nums[mid] == target: return mid elif nums[mid] < target: left = mid + 1 else: right = mid - 1 return -1每循环一次,搜索区间缩小一半,最多需要 log2(n) 次循环,所以时间复杂度 O(logn)。空间上只用了 left、right、mid 三个变量,都是常数个额外存储,空间复杂度 O(1)。
要注意,这个 O(logn) 的对数底数并不重要,因为换底公式只差一个常数,O(log2 n) 和 O(log10 n) 是同一个复杂度。工程里,如果你想在每次循环时打印当前区间所有值,那多占的空间就不再是 O(1) 了。所以“只改一行”也可能改变复杂度。
4.4 递归求和:调用栈是隐形空间
def sum_recursive(arr, idx): if idx == len(arr): return 0 return arr[idx] + sum_recursive(arr, idx + 1)这段递归的时间复杂度是 O(n),因为每个元素被访问一次。空间复杂度是多少?递归深度是 n,系统调用栈要保存 n 层帧,所以额外空间是 O(n),而不是 O(1)。
同一个求和逻辑,用循环写只要 O(1) 空间,用递归写会多占 O(n) 栈空间。这就是为什么在一些嵌入式或内存受限环境里,不推荐无脑递归。如果你用尾递归且编译器支持优化,可能栈空间降到 O(1),但 Python 默认不支持尾递归优化,所以别把理论当实现。
5. 常见误区与踩坑实录:为什么你分析的和实际不符
5.1 误区一:忽略常数,直接在项目里套公式
理论复杂度告诉我们 O(n) 一定比 O(n^2) 好吗?不一定。如果 O(n) 的常数是 1000,而 O(n^2) 的常数是 1,那么 n 小于 1000 时,O(n^2) 可能反而更快。
举个例子,单个循环里做了很重的正则匹配,另一个只有双层循环但做的都是简单位运算,小数据集上可能是后者赢。复杂度分析判断的是“渐近行为”,不是“绝对速度”。所以做性能优化时要结合 n 的实际数量级。我一般在搜索引擎耗时、数据库连接这类场景里,先估算 n 的量级,再决定要不要动代码结构。
5.2 误区二:把 O(logn) 记成 O(1)
二分查找确实很快,但它是 O(logn),不是 O(1)。当 n 从 10 亿变成 20 亿,二分查找大约多跑一次。这仍然是随 n 变化的。在很多实时系统里,log n 的增长往往可以接受,但你不能把它当成常数一次次随意叠加。如果循环里套了二分,复杂度是 O(nlogn),而不是 O(n)。面试时你如果把 O(nlogn) 说成 O(n),那后面系统设计题基本就崩了。
5.3 误区三:空间复杂度只数变量
前面说过,空间复杂度要统计“额外占用的内存”随 n 变化的量级。递归调用栈、动态数组扩容、字符串拼接中间结果、日志数组等都会产生额外空间,但很多人统计时只想着“我定义了三个变量”,于是把 O(n) 写成 O(1)。
我调试过一段日志系统,它把每条日志先存入内存列表,再批量写库,n 涨上去之后,内存先撑不住。这个内存消耗就是列表的 O(n)。所以分析空间时,要把所有临时对象过一遍,尤其是那些在循环里不断增长的容器。
5.4 误区四:把平均复杂度和最坏复杂度混为一谈
哈希表、快排的平均复杂度都很漂亮,但实际可能被输入数据“打爆”。快排如果选第一个元素当基准,而输入恰好是近乎有序的数组,递归深度会退化到 O(n),时间复杂度退到 O(n^2)。解决方式包括引入随机化基准或三数取中。
这类问题在面试中经常被问“快排什么时候最坏”,但到了真实项目里,很多线上问题就是“数据分布刚好命中最坏情况”导致的。因此,复杂度分析不能只报平均,还要把最坏情况作为兜底。
5.5 排查性能问题时我是这么定位的
实际排查线上超时,我不会一上来就用性能分析工具。先在大脑里过一遍核心路径:数据量级多大,循环层数多少,有没有隐藏的字符串复制、哈希冲突、递归深度。
比如一段 Python 代码莫名其妙慢,我会先怀疑 list 的 insert(0, x),因为它要把后面所有元素后移,单次 O(n),循环 n 次就成了 O(n^2)。改成 collections.deque 的 appendleft 后就变成 O(1)。这种问题通过复杂度分析就能立刻定位,再上 cProfile 只是验证。所以,把复杂度分析练成“肌肉记忆”,其实是排查和优化代码的第一道防线。
6. 复杂度分析在真实项目里的使用手册
6.1 先估量级,再选算法:一份可用清单
- n 大概多少?100、1 万、100 万、还是 10 亿?不同量级,策略完全不同。
- 单次操作重不重?如果循环体里有 IO、网络请求、正则、加解密,即使复杂度是 O(n),也未必比一个复杂度 O(n^2) 但循环体极轻的算法快。
- 有没有隐藏的复制或容量问题?字符串拼接、列表头部插入、slice 复制、正则回溯,都会把表面复杂度放大。
- 能不能用哈希表、排序、双指针、前缀和等技巧来降维度?降复杂度之前,先想清楚数据是否满足这些技巧的使用前提。
- 有没有必要优化?如果数据量只有几百,与其调算法不如先把逻辑写清楚。先做能工作的版本,再按复杂度分析决定要不要优化。
这份清单可以贴在工位旁边。遇到性能问题,按顺序过一遍,比乱试优化方式更高效。
6.2 用复杂度思维做架构取舍:空间换时间的实际案例
我做过一个活动推荐接口,原来每次请求都要在内存里遍历几千个候选商品,再逐个算分取 Top K。因为候选数量不大,单次是 O(nlogn),问题不大。后来候选涨到几十万,接口毛刺越来越多。
我直接改成在 Redis 里维护一个有序集合,每次写入或更新商品时就把分数更新进去,查询时直接取 Top K,把接口耗时的复杂度从 O(nlogn) 变成了接近 O(k)。代价是写入链路多一次 Redis 操作,还要定期清理冷数据。
这就是一个非常典型的空间换时间:把计算提前到写入阶段,把数据结构多占的那一点存储视为折旧成本。如果你不会算复杂度,就很难评估这样的改造到底划不划算。
6.3 我在实际工作中最常用到的三个小技巧
第一个技巧是“只看最深的循环和最大的空间申请”。分析时间,先看内层循环体是否是 O(1),再看总次数;分析空间,先看有没有长度随 n 增长的容器或递归深度。这样能快速抓住主要矛盾。
第二个技巧是“用双指针代替嵌套循环”。很多数组类问题,比如有序数组求两数之和,一个外层循环加一个内层循环是 O(n^2),用双指针可以把内层扫描摊掉,变成 O(n)。
第三个技巧是“不要迷信大 O,要配合常数和实际数据分布”。我在项目里经常写注释,简单记录“这里 O(nlogn),n 约 10000,当前方案可以接受”,这能让后来接手的人快速理解你的取舍,而不是盲目把算法换成更炫但常数大的版本。
最后再分享一个我个人的体会:复杂度分析的真正价值不在于考试满分,而在于它给了你一把尺子,让你面对任何一段代码、任何一个方案,都能快速判断“它能不能撑住下一个数量级”。在真实业务里,数据量从一万变成一百万往往就是一次市场活动的事,代码还是那套代码,能不能撑住,区别往往就是你是不是在写第一版时就做好了复杂度上的判断。只要你愿意在平时刷题、写项目时多问一句“这段代码的时间复杂度和空间复杂度各是多少?为什么?”,这门技能就会越来越熟练。