时间复杂度与空间复杂度全解析:从大O推导到排序算法实战
2026/9/18 12:05:57 网站建设 项目流程

“时间复杂度”和“空间复杂度”这两个词,几乎每个写过几行代码的人都听过,但真正能把它们算明白的人并不多。我带过几个刚入行的同事,让他们看一段双层循环,问“这段代码的时间复杂度是多少”,十有八九会愣一下,然后凭感觉报个答案。问题不在概念本身有多难,而在于大多数教程只给结论——冒泡排序是 O(n²),快速排序是 O(nlogn)——却从来不带人走一遍推导过程。这篇我想把这件事从头到尾捋一遍:复杂度到底在衡量什么,大 O 这种写法是怎么来的,拿到一段代码该怎么一步步算出它的时间复杂度,空间复杂度又要怎么统计,最后附上排序算法的复杂度对照表,以及一堆我在实际工作和代码审查中踩过的坑。不管你是刚学数据结构的学生、准备面试的求职者,还是工作几年想回头补基础的老手,都能从中拿到可以直接用的东西。

1. 复杂度分析到底在解决什么问题

1.1 机器跑一遍计时为什么靠不住

假如你想比较两个算法的优劣,最直觉的办法是各写一遍,跑一下,看谁耗时短。这个办法叫“事后统计法”,看着挺科学,实际上极不稳定,甚至可以说基本没用。

先说硬件。同一段代码,在一台十年前的笔记本上跑,和在一台最新的服务器上跑,耗时可能差出几十倍。你拿这个数据去比较算法,比的其实是机器,不是算法。再说数据规模,一个排序算法在小数据量下跑得飞快,数据量翻一千倍之后可能直接卡住,你要是只在小数据上测,得出的结论很可能是反的。编程语言和实现细节同样在捣乱,同一个算法用 Python 写和用 C 写,耗时能差两个数量级;同一个语言里,用列表推导式和写 for 循环,速度也不一样,这些差异跟算法本身没关系,但都会污染你的测量结果。最后是运行环境,测试的时候后台有没有别的进程抢 CPU,内存是不是吃紧,有没有被虚拟化层拖慢,这些都是你控制不了的变量,同一台机器连跑十次,结果都可能飘。

所以我们需要一种跟机器无关、跟语言无关、跟具体实现无关的衡量方式。它不告诉你“这段代码要跑几毫秒”,而是告诉你“随着数据规模增长,耗时是怎么涨的”。这才是复杂度分析要解决的核心问题——它衡量的是增长趋势,不是绝对速度。

1.2 复杂度分析的三个前提假设

复杂度分析本质上是一个数学建模过程,既然是建模,就一定做了简化。搞清楚这些简化,你才能在后面遇到“理论值和实测值对不上”的时候,知道是哪里出了偏差。

第一,假设每条基本语句的执行时间是一个固定常数。也就是说,我们把a = 1a = b + c * d都当成一个单位时间。这个假设在真实机器上显然不成立,乘法比赋值慢,内存访问比寄存器访问慢,但没关系,我们关心的是增长趋势,把这些细节抹平不影响结论。

第二,只关注最高阶项,忽略常数系数。3n² + 100n + 500在 n 足够大的时候是一回事,因为 n² 的增长速度远远盖过其他项。这也是为什么复杂度写成 O(n²),而不是 O(3n² + 100n + 500)。

第三,默认分析的是最坏情况,除非特别说明。最坏情况给出的是一个性能下界保证——“再差也不会比这个更差”。当然平均情况和最好情况也有用,后面会专门讲。

这三条假设合在一起,构成了所谓的 RAM 模型(随机访问机模型)。它不完美,但足够好用,而且是整个算法分析体系的地基。

2. 大 O 表示法:从数学定义到工程约定

2.1 数学定义与渐进上界

大 O 的严格定义是:若存在正常数 c 和 n₀,使得对所有 n ≥ n₀,都有 T(n) ≤ c·f(n),则记作 T(n) = O(f(n))。翻成人话就是——当数据量足够大之后,我的算法耗时被 f(n) 乘上一个固定倍数给“罩住”了,f(n) 是它的渐进上界。

举个具体例子。假设某段代码的实际执行次数是 T(n) = 3n + 5,我们想证明它是 O(n)。那就找 c 和 n₀:取 c = 4,需要 3n + 5 ≤ 4n,即 n ≥ 5;所以取 n₀ = 5,对所有 n ≥ 5 都成立,结论就是 T(n) = O(n)。整个过程没有任何“估算”成分,它是可以严格验证的。

顺带把它的两个兄弟也说清楚。Ω 表示渐进下界,即 T(n) ≥ c·f(n);Θ 表示紧确界,同时满足上界和下界,也就是“增长速度和 f(n) 一个量级”。纯理论上 O 只保证上界,比如 n 也满足 O(n²)。但工程语境里大家说“复杂度是 O(n²)”时,心里想的其实是 Θ(n²)。这个区别在面试里偶尔会被追问,日常写代码不用太较真,但得知道有这么回事。

2.2 推导大 O 的三条实战规则

实际动手时不需要每次都写不等式证明,掌握三条规则就够了,它们的数学来源分别是极限的加法法则和乘法法则。

规则一,加法法则,取最大项。O(f(n)) + O(g(n)) = O(max(f(n), g(n)))。两段代码先后执行,总复杂度看更慢的那段。

规则二,乘法法则,嵌套相乘。循环嵌套时,外层和内层的复杂度相乘。

规则三,去掉系数和低阶项。O(2n² + 3n + 7)直接写成O(n²)

拿一段代码练手:

def foo(n): a = 0 # O(1) for i in range(n): a += i # O(n) for i in range(n): for j in range(n): a += i * j # O(n^2) return a

按规则拆:赋值 O(1),第一个循环 O(n),双重循环 O(n²)。加法法则取最大,最终 T(n) = O(n²)。你看,整个过程不需要数每条语句几纳秒,只需要数“循环了几层、每层跟 n 是什么关系”。

注意:加法法则只在两段代码是“顺序执行”时成立。如果第二段在前一段的循环体里,那就是乘法而不是加法了,别搞混。

2.3 常见量级排序与增长速率对照

把所有常见量级按增长速度从慢到快排一遍:O(1) < O(logn) < O(n) < O(nlogn) < O(n²) < O(n³) < O(2ⁿ) < O(n!)。光看符号没感觉,代入具体数字才吓人。

复杂度n=10n=100n=1000n=10⁶
O(1)1111
O(logn)约 3约 7约 10约 20
O(n)10100100010⁶
O(nlogn)约 33约 664约 10⁴约 2×10⁷
O(n²)10010⁴10⁶10¹²
O(2ⁿ)1024约 10³⁰天文数字不可计算

看到最后一行没有,n 才到 100,2ⁿ 就已经是 10 的 30 次方了,而可观测宇宙的原子总数也就 10 的 80 次方左右。这就是为什么指数级算法只能处理很小的输入。

关于对数还有个常见疑问:log 的底数要不要写?答案是不用。log₂n 和 log₁₀n 之间只差一个常数倍(log₂n = log₁₀n / log₁₀2),而常数系数在大 O 里是要被丢掉的,所以统一写成 O(logn) 就行,底数无所谓。

3. 时间复杂度的完整计算流程

3.1 逐步分析法:从伪代码到 O()

拿到一段代码,最稳的办法是一行一行数执行次数,最后加总化简。这个过程我习惯叫“逐步分析法”,虽然笨,但不会漏。

先看一个线性例子:

def find_max(arr): max_val = arr[0] # 1 次 for i in range(1, len(arr)): # n-1 次比较 if arr[i] > max_val: # n-1 次 max_val = arr[i] # 最多 n-1 次 return max_val # 1 次

加总:T(n) = 1 + (n-1) + (n-1) + (n-1) + 1 = 3n - 1。去掉系数和低阶项,O(n)。

再看一个对数例子,这是新手最容易卡住的地方:

def log_loop(n): i = 1 count = 0 while i < n: i *= 2 count += 1 return count

关键是搞清楚循环体执行了多少次。i 的取值序列是 1, 2, 4, 8, ..., 2ᵏ,当 2ᵏ ≥ n 时退出,所以 k = log₂n,循环次数是 O(logn)。判断一个循环是不是对数级,就看循环变量是不是“乘除”变化的;如果是“加减”变化的,那就是线性级。

把两者组合起来就是最常见的 O(nlogn):

def nlogn_loop(n): for i in range(n): # 外层 n 次 j = 1 while j < n: # 内层 logn 次 j *= 2

外层 n 乘以内层 logn,结果 O(nlogn)。归并排序、快速排序的平均情况、堆排序都落在这个量级,它也是基于比较的排序算法能做到的最好水平。

3.2 最好、最坏、平均与均摊

最坏情况我们前面说过,是默认的分析口径。但有些场景下另外三种更有价值,得分开看。

拿线性查找举例,在数组里找一个目标值:

  • 最好情况:目标正好在第一个位置,一次命中,O(1)。
  • 最坏情况:目标在最后一个位置,或者压根不存在,要扫完整个数组,O(n)。
  • 平均情况:假设目标一定存在且等概率出现在每个位置,那么平均查找次数是 (1+2+...+n)/n = (n+1)/2,还是 O(n)。

插入排序也是典型例子:对已经有序的数组,每次插入都不用移动元素,最好情况是 O(n);对逆序数组,每次都要移到最前面,最坏情况是 O(n²)。这就是为什么很多排序库会在小数组或者“接近有序”时切换到插入排序——它在这种数据下的表现确实好。

再说均摊复杂度,这个概念被误解得最多。以动态数组(Python 的 list、Java 的 ArrayList、C++ 的 vector)的 append 为例:大多数时候往末尾加元素是 O(1),但数组满了要扩容,得把旧元素全部复制到新数组,这一步是 O(n)。那 append 到底是不是 O(1)?

用均摊分析算:假设从容量 1 开始翻倍扩容,扩容发生在第 1、2、4、8、... 次插入时,总的复制次数是 1+2+4+...+n/2 < n,也就是 n 次插入总共复制了不到 n 个元素。把总成本摊到每次操作上,平均每次不到 2 次复制,所以 append 的均摊复杂度是 O(1)。

注意:均摊复杂度不等于平均复杂度。平均复杂度是对“输入分布”求期望,均摊复杂度是对“一串连续操作”的总成本做除法,它给出的是有保证的结论——连续执行 n 次 append,总时间一定是 O(n),不存在运气差就退化的可能。这个区别面试常问。

3.3 递归代码的时间复杂度怎么算

递归没法直接数循环,得换方法。我常用两招:递归树和主定理。

递归树适合直观理解。以朴素斐波那契为例:

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

递推式是 T(n) = T(n-1) + T(n-2) + O(1)。画成树的话,每个节点分裂出两个子节点,树高约 n,节点总数约 2ⁿ,所以时间复杂度 O(2ⁿ)。用递归树看特别直观——你能亲眼看到那棵树有多“宽”。顺带说一句,这棵树的绝大部分节点是重复计算,加个记忆化数组就能把它压到 O(n),这是空间换时间的经典案例,后面还会提。

主定理适合能写成 T(n) = aT(n/b) + f(n) 形式的递推式。其中 a 是子问题个数,n/b 是子问题规模,f(n) 是分解和合并的开销。比较 n^(log_b a) 和 f(n) 的大小关系,落进三种情况之一:

情况条件结果典型算法
情况一f(n) 比 n^(log_b a) 小T(n) = Θ(n^(log_b a))二分查找(a=1,b=2)
情况二两者同阶T(n) = Θ(n^(log_b a) · logn)归并排序、快排平均
情况三f(n) 更大且满足正则条件T(n) = Θ(f(n))某些带线性预处理的算法

拿归并排序验证一下:T(n) = 2T(n/2) + O(n),a=2,b=2,n^(log₂2) = n,和 f(n) = n 同阶,落进情况二,所以 T(n) = O(nlogn)。二分查找:T(n) = T(n/2) + O(1),a=1,b=2,n^(log₂1) = n⁰ = 1,和 f(n) = 1 同阶,情况二,但这里要注意 logn 因子,实际是 O(logn)——因为每层只有一次比较,总共 logn 层。

4. 空间复杂度:被低估的那一半

4.1 空间复杂度的统计口径

空间复杂度的统计有个关键约定:只算“额外空间”,不算输入数据本身占的位置。因为输入是问题给的,不是你算法消耗的,算进去没意义。

用 S(n) 表示。几个例子对比:

  • 数组求和,只需要一个累加变量,S(n) = O(1)。
  • 复制一个数组,需要一个等长的新数组,S(n) = O(n)。
  • 二维矩阵转置,需要一个同样大小的新矩阵,S(n) = O(n²)。
  • 归并排序需要一个和原数组等长的辅助数组,S(n) = O(n)。

这个口径要记住,否则算出来的结果会很离谱。比如一个接收数组返回其最大值的函数,有人会算成 O(n),其实应该是 O(1),因为那个数组是输入,不是额外开销。

4.2 递归栈空间与原地算法

递归的空间复杂度最容易算错,因为它有两个来源:显式开辟的变量,以及隐式的调用栈。每个未返回的函数调用都占一个栈帧,栈帧里存着局部变量、参数和返回地址,栈的总深度就是递归的最大深度。

再回看斐波那契那个例子。有人看到“总共调用了约 2ⁿ 次”就说空间是 O(2ⁿ),这是错的。栈帧会在函数返回后被回收,同一时刻栈上最多只存在一条从根到叶的路径,深度是 n,所以空间复杂度是 O(n)。记住这个规律:递归的空间复杂度看的是“最大深度”,不是“总调用次数”。

再看快排。它的空间开销全部来自递归栈,因为排序本身是原地的(in-place),不需要额外数组。平均情况下每次划分把区间对半分,栈深度 logn,空间 O(logn);但如果每次选中的基准都是最值(比如对已经有序的数组取第一个元素当基准),划分极度不平衡,栈深度退化成 n,空间 O(n)。这也是为什么工程上的快排实现要做“三数取中”或者随机选基准——不只是为了优化时间,也是在控制栈空间。

原地算法(in-place algorithm)指的是额外空间为 O(1) 或 O(logn)(不计递归栈的情况下)的算法。冒泡、插入、选择、堆排序、快排都是原地排序;归并排序不是,因为它需要 O(n) 的辅助数组。

4.3 时间换空间与空间换时间的取舍

这两者是一对互相拉扯的指标,实际工程里的绝大多数优化,本质上都是在其中做取舍。几个真实场景:

空间换时间:哈希表是典型。用哈希表查找,时间复杂度从 O(n) 降到 O(1),代价是多存了一份索引结构。再比如记忆化搜索,斐波那契加个缓存数组,时间从 O(2ⁿ) 降到 O(n),空间从 O(n) 涨到 O(n)。数据库建索引也是这个逻辑,索引占磁盘,换来查询加速。

时间换空间:滚动数组是典型。0-1 背包的动态规划本来是二维表格,空间 O(n×m),但状态转移只依赖上一行,所以可以压成一维数组,空间降到 O(m)。代价是丢失了中间状态,想还原具体选了哪些物品就得另想办法。再比如一些嵌入式场景,宁可多算几遍也不愿多占内存。

我的经验是,选择哪一头看瓶颈在哪。现代服务器内存普遍宽裕,绝大多数业务代码的时间瓶颈比空间瓶颈更值钱,所以优先考虑时间。但在移动端、嵌入式、或者需要处理超大规模数据的场景里,内存往往是硬约束,这时候省空间就更重要。别盲目追求某一项,先量一下到底卡在哪。

5. 排序算法的复杂度全景与实战对照

5.1 经典排序算法复杂度对照表

这张表我建议直接背下来,面试和日常选型都用得上。

算法平均时间最好时间最坏时间空间稳定性
冒泡排序O(n²)O(n)O(n²)O(1)稳定
选择排序O(n²)O(n²)O(n²)O(1)不稳定
插入排序O(n²)O(n)O(n²)O(1)稳定
希尔排序O(n^1.3)O(n)O(n²)O(1)不稳定
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)稳定
快速排序O(nlogn)O(nlogn)O(n²)O(logn)不稳定
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)不稳定
计数排序O(n+k)O(n+k)O(n+k)O(k)稳定
桶排序O(n+k)O(n)O(n²)O(n+k)稳定
基数排序O(d(n+k))O(d(n+k))O(d(n+k))O(n+k)稳定

表里 k 是数值范围,d 是最大数的位数。计数、桶、基数这三种不是比较排序,所以能突破 O(nlogn) 的下界,但代价是对数据有要求——数据得是整数或者能映射成整数,而且范围不能太离谱。你要拿计数排序去排一群浮点数或者字符串,那就没法用了。

5.2 稳定性与常数因子

稳定性指的是:值相等的两个元素,排序后相对顺序是否保持不变。这在单关键字排序时看不出区别,但多关键字排序时很关键。比如先按成绩排、成绩相同的再按姓名排,如果你先按姓名排好,再用一个稳定排序按成绩排,那么成绩相同的部分会自动保持姓名的有序性;如果用的是不稳定排序,前面的工作就白做了。

看表能总结出规律:归并、冒泡、插入、计数、桶、基数稳定;快排、堆排、选择、希尔不稳定。归并排序之所以稳定,是因为合并两个有序子数组时,遇到相等元素优先取左边那个,相对顺序就保住了。

再说常数因子。理论上快排最坏是 O(n²),比堆排序的 O(nlogn) 差,为什么实际中快排反而更快?答案是常数因子和内存访问模式。快排是顺序访问内存,缓存命中率极高;堆排序虽然在数组上操作,但每次调整堆都是跳着访问(父节点和子节点索引差很远),缓存不友好,常数因子大得多。在大 O 相同的情况下,常数因子和访存模式就成了决定胜负的关键。这也解释了一个现象:理论分析只能帮你排除渐进复杂度更差的算法,具体选哪个还得看实测。

还有一个值得记住的结论:基于比较的排序算法,下界是 Ω(nlogn)。推导思路是,n 个元素有 n! 种排列,每次比较最多区分两种情况,所以要区分所有排列,决策树高度至少是 log(n!) = O(nlogn)。这就是为什么任何“比较排序”都不可能突破 nlogn,只有换赛道(计数、基数这类不用比较的)才行。

5.3 实测与理论值的差距

理论归理论,实际写代码时有个反直觉的现象:小数据量下,O(n²) 的插入排序经常比 O(nlogn) 的快排还快。原因很简单,当 n 只有十几个的时候,n² 和 nlogn 的差距微乎其微,而插入排序的代码简单、没有递归开销、没有额外的指针操作,常数因子小得多。

所以主流语言的标准库几乎都用了混合策略。Java 的Arrays.sort对基本类型用双轴快排,对对象数组用 TimSort;Python 的sorted用的是 TimSort;C++ 的std::sort用的是内省排序(introsort,快排 + 堆排 + 插入排序的组合)。这些实现里都有同一个套路:递归到小区间时切换成插入排序,快排递归太深时切换成堆排序防止最坏情况。

给你一个粗略的数量级感受:n=10 时插入排序可能反超;n 到几百以上,O(nlogn) 的优势才开始明显;n 到百万级别,O(n²) 基本就不可用了(10¹² 次操作,按每秒一亿次算也要跑好几个小时)。这些数字不是精确基准,但能帮你建立“什么规模该担心什么复杂度”的直觉。

6. 常见问题与排查技巧实录

6.1 高频踩坑清单

这些年我在代码审查里见过太多复杂度相关的错误,挑几个最典型的。

坑一:把大 O 当成精确耗时。O(n) 不意味着“跑得快”,它只说明增长趋势是线性的。一个常数因子是一万的 O(n) 算法,可能比常数因子是 0.001 的 O(n²) 算法在 n 小于某个值的时候慢得多。大 O 是比较增长趋势的工具,不是测速仪。

坑二:漏算隐藏循环。最典型的是循环里拼接字符串。Python 里s += x每次都会创建新字符串并复制全部内容,单次是 O(len(s)),循环 n 次就是 O(n²)。正确写法是parts.append(x)然后''.join(parts),整体 O(n)。类似的还有循环里做列表的in判断(O(n))、循环里反复list.insert(0, x)(O(n)),这些隐藏成本不数出来就会得出错误结论。

坑三:默认哈希表 O(1) 就高枕无忧。哈希表的 O(1) 是均摊和平均意义下的,遇到大量哈希冲突时会退化成 O(n)。如果数据可以被外部构造,还可能被恶意输入拖垮,这也是很多语言给哈希函数加随机种子的原因。对延迟敏感的场景,得考虑最坏情况。

坑四:递归只算时间不算栈空间。前面强调过,递归的空间看最大深度。一个时间复杂度 O(nlogn) 的递归算法,如果深度是 n,栈空间也可能是 O(n),在栈空间有限的场景(比如某些嵌入式环境或者深度很深的递归)会直接爆栈。

坑五:分不清均摊和平均。平均是对输入分布求期望,输入分布变了结论就变了;均摊是对一串操作求总成本,是有保证的。动态数组 append 是均摊 O(1),这个结论不依赖任何输入分布假设。

坑六:忽略内存访问模式。前面说过堆排序的例子,两个算法大 O 相同,实际性能可能差好几倍。做性能优化时,别只盯着复杂度,也得看数据是怎么被访问的。

6.2 复杂度估算速查表与常见追问

把我平时用得最多的判断逻辑整理成一张表,遇到代码可以直接对号入座。

代码形态复杂度
单条语句、固定次数循环O(1)
变量每次乘 2、除 2 的循环O(logn)
单层循环遍历 n 个元素O(n)
外层 n 层、内层 lognO(nlogn)
双层完整嵌套循环O(n²)
递归每层分裂成两个子问题且规模只减 1O(2ⁿ)
排列组合类暴力枚举O(n!)

面试里关于复杂度的高频追问,我列几个并给出回答思路:

“快排最坏什么时候发生,怎么避免?”数据已经有序或逆序,且基准取第一个或最后一个元素时,每次划分极度不平衡,退化成 O(n²)。避免办法是随机选基准或者三数取中,让划分尽可能均衡。

“归并排序为什么稳定,快排为什么不稳定?”归并合并时相等元素优先取左边子数组的,相对顺序保持;快排的划分过程中元素会被跨越式交换,相等元素的相对位置可能被打乱。

“为什么比较排序下界是 O(nlogn)?”用决策树模型,n 个元素的排列有 n! 种,每次比较最多把可能性分成两半,树高至少 log₂(n!) ≈ nlogn。

“动态数组扩容为什么是均摊 O(1)?”按倍增策略扩容,n 次插入的总复制成本小于 2n,摊到每次是常数。

我个人在实际项目里的体会是,复杂度分析真正的价值不在于背结论,而在于养成一种习惯:写完一段代码,顺手估一下它的复杂度,看看数据量涨十倍的时候会发生什么。很多线上事故,追根溯源就是某个不起眼的地方藏着 O(n²),平时数据少看不出来,量一上来就炸了。花十分钟估算,可能省下后面几天的排查时间,这笔账很划算。

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

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

立即咨询