第 2 章 算法复杂度
2.1 为什么要学习算法复杂度
在数据结构与算法的学习过程中,人们往往首先关注下列问题:
- 这段程序能不能运行?
- 能不能得到正确答案?
- 代码写出来是不是够短?
这些问题固然重要,但并不足以用来评价一个算法的优劣。随着输入规模增大,两段都能够正确运行的程序,可能表现出截然不同的效率。
例如,下面两个函数都能判断列表中是否有重复元素:
def has_duplicate_v1(arr):
n = len(arr)
for i in range(n):
for j in range(i + 1, n):
if arr[i] == arr[j]:
return True
return Falsedef has_duplicate_v2(arr):
seen = set()
for x in arr:
if x in seen:
return True
seen.add(x)
return False从功能上看,这两个函数完成的是同一项任务;但当数据规模增大时,第一种写法可能迅速变慢,而第二种通常更为高效。这说明,算法的优劣不仅取决于“能否得到正确结果”,还取决于“代价如何随规模增长”。
这正是算法复杂度分析所要回答的核心问题。
算法复杂度研究的不是程序某一次运行耗费了多少秒,而是当输入规模不断增大时,算法所需时间与空间将呈现怎样的增长规律。它帮助我们回答以下更具普遍性的问题:
- 两个都正确的算法,哪个更适合大规模数据?
- 一个算法慢,是因为实现细节不好,还是因为思路本身就低效?
- 在内存有限的设备上,哪种写法更合适?
- 当 AI 生成多种代码方案时,怎样判断哪一种更优?
因此,复杂度分析并非附属内容,而是理解算法、比较算法以及审查程序质量的重要工具。
2.2 输入规模与增长趋势
在讨论复杂度之前,首先需要明确什么是输入规模。
许多复杂度结论写成 O(n)、O(log n)、O(n^2)。其中的 n 并不是一个固定含义的符号,而是用来表示“问题规模”。
不同问题中,n 的含义不同:
- 对列表问题,
n可以表示列表长度 - 对字符串问题,
n可以表示字符串长度 - 对树问题,
n可以表示结点数 - 对图问题,
n常表示顶点数,m表示边数
例如:
- 在“遍历数组求和”中,
n是数组中元素个数 - 在“二分查找”中,
n是有序数组的长度 - 在“归并排序”中,
n是待排序元素个数
只有先明确 n 的含义,复杂度讨论才具有确定的对象。
复杂度分析关注的是增长趋势。设某算法的运行步数大致为:
5n + 3另一个算法运行步数大致是:
n^2 + 10n + 7当 n 较小时,它们之间的差距未必明显;但随着 n 增大,平方项的增长速度会显著快于一次项。复杂度分析所关心的,正是这种“问题规模扩大之后,何者增长更快”的结构性差异。
2.3 Big O 记号
为了刻画增长趋势,算法分析中通常使用 Big O 记号。
如果一个算法在输入规模为 n 时,运行代价随着 n 增长,大致不超过某个函数 f(n) 的数量级,就记作:
O(f(n))在初学阶段,可以将 Big O 理解为:
当
n足够大时,算法增长速度主要由最高阶的那部分决定。
例如:
5n + 3记作O(n)2n^2 + 7n + 1记作O(n^2)3log n + 8记作O(log n)
这说明 Big O 主要忽略以下两类因素:
- 常数因子
- 低阶项
因此,Big O 并不告诉我们程序精确运行了多少秒,而是刻画:当问题规模增大时,算法的增长趋势属于何种数量级。
2.3.1 Big O 不是精确时间
某个函数在一台计算机上运行了 0.01 秒,并不能据此断言它的复杂度一定更优,因为实际运行时间还会受到以下因素影响:
- 机器性能
- 编程语言
- 编译器或解释器实现
- 测试数据分布
- 常数因子
复杂度分析的目的,正是在于剥离这些偶然因素,提炼出更稳定的结构性结论。
2.3.2 常见复杂度的大小关系
在本章中,最常见的复杂度从低到高大致可排列为:
O(1) < O(log n) < O(n) < O(n log n) < O(n^2) < O(2^n)这一顺序十分重要,它提供了复杂度比较的基本尺度:
- 常数和对数级通常较快
- 线性级是很多算法的“基准水平”
O(n log n)往往已经是高效排序算法的典型级别- 平方级在数据规模稍大时就可能明显变慢
- 指数级通常只适合很小规模的问题
需要注意的是,这里比较的只是增长趋势,并不意味着任意一个 O(n) 算法都必然快于任意一个 O(n log n) 算法。在实际规模较小时,常数因子和实现细节仍可能显著影响实际表现。
2.4 时间复杂度
时间复杂度描述的是:当输入规模 n 增长时,算法执行所需步骤数将如何增长。
这里的“步骤数”不必理解为 CPU 的真实指令条数,而可以理解为程序中主要操作的执行次数。
2.4.1 例:顺序查找
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1当目标元素位于第一个位置时,只需一次比较即可返回。
当目标元素位于最后一个位置,或目标根本不存在时,算法需要扫描完整个数组。
因此:
- 最好情况:
O(1) - 最坏情况:
O(n)
若无特别说明,许多教材在给出“该算法的时间复杂度是 O(n)”这类表述时,通常默认指的是最坏情况时间复杂度。
2.4.2 例:二分查找
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1二分查找在每一步都将搜索区间缩小一半,因此其执行次数并不是与 n 成正比增长,而是取决于“区间能够被连续对半缩小多少次”。
这类增长趋势记作:
O(log n)这种增长速度低于 O(n)。这也说明“有序”这一前提的重要性:一旦数据有序,许多查找问题便可以从线性级下降到对数级。
2.5 常见时间复杂度及其来源
2.5.1 O(1):常数级
若某个操作的执行次数不随输入规模变化,则称其为常数级。
例如:
first = arr[0]
last = arr[-1]无论数组有 10 个元素还是 10 万个元素,取首元素和尾元素所需的步骤数都不随 n 增长,因此可视为 O(1)。
2.5.2 O(n):线性级
如果算法需要将输入整体扫描一遍,则其复杂度通常表现为线性级。
def sum_list(arr):
total = 0
for x in arr:
total += x
return total循环次数与数组长度成正比,所以时间复杂度是 O(n)。
2.5.3 O(n^2):平方级
平方级通常来源于双重嵌套循环。
def print_pairs(arr):
for x in arr:
for y in arr:
print(x, y)外层循环执行 n 次,内层每次又执行 n 次,总次数约为 n × n,因此时间复杂度是 O(n^2)。
2.5.4 O(n log n):线性对数级
这类复杂度常见于“每一层处理全部元素,而层数为对数级”的算法中,例如归并排序。
归并排序的基本思路是:
- 把数组不断对半拆分
- 分别排好左右两部分
- 再把两部分合并起来
若拆分层数约为 log n,且每一层处理的元素总数约为 n,则总复杂度为:
O(n log n)2.5.5 O(2^n):指数级
指数级通常意味着算法在持续分叉,并且存在大量重复子问题。
朴素递归版斐波那契数列就是一个典型例子:
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)该算法会反复计算 fib(3)、fib(2) 这类相同子问题,因此运行代价增长极快,通常记作 O(2^n)。
2.6 如何分析循环程序的时间复杂度
对初学者而言,复杂度分析常显得较为抽象。对于循环程序,可以先从以下问题入手:
- 循环执行多少次?
- 每次循环内部做了什么?
- 是否存在嵌套?
- 是否调用了本身就不便宜的函数?
2.6.1 顺序结构
def example(arr):
total = 0
for x in arr:
total += x
for x in arr:
print(x)
return total这里包含两个顺序执行的循环:
- 第一个循环:
O(n) - 第二个循环:
O(n)
因此总复杂度为:
O(n) + O(n) = O(n)2.6.2 嵌套结构
def example(arr):
count = 0
for i in range(len(arr)):
for j in range(len(arr)):
count += 1
return count外层循环执行 n 次,内层循环也执行 n 次,因此总复杂度为 O(n^2)。
2.6.3 分支结构
def example(arr):
if len(arr) < 100:
for x in arr:
print(x)
else:
for x in arr:
print(x * 2)
return len(arr)虽然程序包含 if-else 分支,但一次运行只会进入其中一条路径;由于两条路径的复杂度均为 O(n),因此整体复杂度仍为 O(n)。
2.6.4 不要忽略函数调用
def example(arr):
arr.sort()
return arr[0]该程序表面上只有两行代码,但 arr.sort() 本身并不是常数级操作。分析复杂度时,必须将函数调用的内部代价纳入考虑,而不能仅凭代码行数作出判断。
2.7 空间复杂度
算法分析不仅关注“需要多少时间”,还关注“需要多少空间”。
空间复杂度描述的是:当输入规模 n 增长时,算法运行过程中需要的额外存储空间如何增长。
本书如无特别说明,空间复杂度默认指额外空间复杂度,即不计入输入本身占用的空间,而只考察算法额外申请了多少存储空间。
2.7.1 为什么通常不把输入算进去
例如,一个函数接收列表 arr 作为输入。该列表在算法开始之前已经存在,算法只是利用这一输入继续计算。复杂度分析更关心的是:
- 是否新建了辅助数组?
- 是否创建了哈希表、集合或矩阵?
- 递归是否带来了额外调用栈?
因此,空间复杂度所关注的是“除输入本身之外,算法还额外占用了什么”。
2.7.2 O(1) 额外空间
def max_value(arr):
current_max = arr[0]
for x in arr:
if x > current_max:
current_max = x
return current_max该算法虽然遍历了整个数组,但额外只使用了少量变量,且变量个数不随 n 增长,因此空间复杂度为 O(1)。
2.7.3 O(n) 额外空间
def copy_positive(arr):
result = []
for x in arr:
if x > 0:
result.append(x)
return result在最坏情况下,所有元素都满足条件,result 的长度与 n 同阶,因此空间复杂度为 O(n)。
2.7.4 原地算法
若一个算法主要在原有数据上直接修改,而不额外开辟与 n 同阶的新存储空间,通常称其为原地算法。
例如:
def reverse_in_place(arr):
left, right = 0, len(arr) - 1
while left < right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1该算法直接修改原数组,额外变量的数量固定,因此空间复杂度为 O(1)。
需要注意的是,“空间复杂度更低”并不意味着算法必然更优。原地算法可能会改变原数据,而某些应用场景恰恰要求保留原始输入。因此,在分析算法时,既要考察复杂度,也要考察操作语义。
2.8 Python 中的隐藏成本
在实际编程中,许多复杂度判断错误并非来自整体框架,而是来自被忽略的局部操作。Python 尤其值得注意,因为某些写法表面上十分简洁,但其背后可能隐含复制与额外分配。
2.8.1 sorted(arr) 与 arr.sort()
这两种写法都用于排序,时间复杂度通常都可视为 O(n log n),但在语义和空间行为上并不相同。
arr = [3, 1, 4, 1, 5]
b = sorted(arr)这里:
b是一个新列表arr保持不变- 通常需要额外空间来存放排序后的结果
而下面这种写法:
arr = [3, 1, 4, 1, 5]
arr.sort()这里:
- 排序结果仍存放在
arr中 - 原列表被修改
- 不再返回一个独立的新结果列表
因此,不能只笼统地说“它们的时间复杂度相近”,还应进一步考察:
- 是否修改原数据
- 是否返回新对象
- 空间代价是否不同
2.8.2 切片不是免费的
arr = [1, 2, 3, 4, 5]
b = arr[1:]arr[1:] 表面上看只是“从第二个元素开始取值”,但实际上 Python 会创建一个新的列表。若切片长度为 k,则该操作的时间与额外空间通常都与 k 成正比。
因此,切片不能默认当成 O(1)。
2.8.3 一个常见误区:递归里反复切片
def list_sum(arr):
if not arr:
return 0
return arr[0] + list_sum(arr[1:])这段代码形式简洁,但至少包含以下两个主要成本来源:
- 每次递归都要创建新的切片
- 每次递归都会增加一层调用栈
如果输入长度为 n:
- 调用层数约为
n - 第一次复制
n - 1个元素 - 第二次复制
n - 2个元素 - 依此类推
复制总工作量大约是:
(n - 1) + (n - 2) + ... + 1 = O(n^2)因此,这段代码虽然表面上似乎只是“每层处理一个元素”,但在 Python 中并不高效。
2.8.4 新建列表与原地修改
下面比较两种反转写法:
def reverse_new(arr):
result = []
for i in range(len(arr) - 1, -1, -1):
result.append(arr[i])
return resultdef reverse_in_place(arr):
left, right = 0, len(arr) - 1
while left < right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1二者的时间复杂度都为 O(n),但空间语义不同:
reverse_new返回一个新列表,空间复杂度为O(n)reverse_in_place直接修改原列表,空间复杂度为O(1)
这一例子说明,即使时间复杂度相同,算法的空间表现与数据语义也可能显著不同。
2.9 最好情况、最坏情况与平均情况
许多算法的代价不仅与输入规模有关,也与输入分布有关。
以线性查找为例:
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1若目标恰好位于第一个位置,则只需一次比较,时间复杂度可视为 O(1)。
若目标位于最后一个位置,或根本不存在,则需要扫描完整个数组,时间复杂度为 O(n)。
因此:
- 最好情况:输入最有利时的复杂度
- 最坏情况:输入最不利时的复杂度
- 平均情况:在某种输入分布假设下,平均代价的复杂度
平均情况分析通常更为复杂,因为它依赖于对输入分布的概率假设。在初学阶段,最坏情况分析往往更常用,也更稳定。
2.10 递归复杂度
递归是算法课程中的关键主题。如果不能分析递归复杂度,后续的树、分治、动态规划等内容就很难获得扎实理解。
分析递归复杂度时,可以先从两个问题出发:
- 递归树有多深?
- 每一层总共做多少工作?
2.10.1 例:简单递归求和
def sum_recursive(arr, i):
if i == len(arr):
return 0
return arr[i] + sum_recursive(arr, i + 1)该函数在每次递归中仅将索引加 1,而不进行切片,因此:
- 递归深度约为
n - 每层只做
O(1)工作
由此可得:
- 时间复杂度:
O(n) - 空间复杂度:
O(n),来自调用栈
2.10.2 例:递归二分查找
def binary_search_recursive(arr, target, left, right):
if left > right:
return -1
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search_recursive(arr, target, mid + 1, right)
else:
return binary_search_recursive(arr, target, left, mid - 1)每次递归都会把问题规模减半,因此:
- 递归深度约为
log n - 每层只做常数级工作
所以:
- 时间复杂度:
O(log n) - 空间复杂度:
O(log n)
若改为迭代版,二分查找的空间复杂度可以降为 O(1)。这再次表明,在相同算法思想下,递归实现与迭代实现的空间表现可能不同。
2.10.3 例:归并排序
归并排序的典型递归结构如下:
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)分析该算法时,可以从“层数”与“每层工作量”两个角度入手:
- 数组不断对半拆分,因此层数约为
log n - 每一层所有子问题加起来,处理的元素总数约为
n
因此,其总时间复杂度为:
O(n log n)该算法的空间复杂度通常为什么写作 O(n)?其原因在于:
- 递归调用栈本身大约是
O(log n) - 但切片和合并过程会创建新列表
- 这些额外数组的主导规模通常是
O(n)
因此,总的额外空间通常写作:
O(n)2.10.4 例:朴素递归斐波那契
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)这一递归形式容易误导初学者,因为它表面上只有一行递归表达式,但其背后对应的是一棵不断分叉的调用树。
以 fib(5) 为例,其调用关系可以简化表示为:
fib(5)
├── fib(4)
│ ├── fib(3)
│ │ ├── fib(2)
│ │ └── fib(1)
│ └── fib(2)
└── fib(3)
├── fib(2)
└── fib(1)由此可以看到:
fib(3)被重复计算fib(2)也被重复计算
随着 n 增大,这种重复计算会迅速膨胀,因此时间复杂度可记为:
O(2^n)但其空间复杂度并不是 O(2^n)。原因在于,程序运行时同时保留在调用栈中的,只是一条从根到叶子的活动路径。该路径深度与 n 成正比,因此:
空间复杂度 = O(n)这一例子说明:
- 时间复杂度和空间复杂度不一定同步增长
- 分支数很多时,时间可能指数爆炸
- 即使空间只是线性,时间也可能已经不可接受
2.11 常见误区
2.11.1 把代码行数当复杂度
复杂度取决于执行次数和程序结构,而不取决于代码书写了多少行。两行代码也可能隐藏昂贵操作;几十行代码也可能仍然保持在线性级。
2.11.2 把一次运行时间当复杂度
程序在某台机器上运行较快,并不意味着其复杂度一定更低。复杂度讨论的是输入规模增长时的趋势,而不是一次测试所测得的秒数。
2.11.3 只看时间,不看空间
有些算法的时间复杂度相同,但空间代价差异很大。若计算环境受到内存限制,这种差异可能十分关键。
2.11.4 忽略语言细节
理论分析不能完全脱离实现环境。尤其在 Python 中,切片、字符串拼接以及新建列表等写法都可能引入额外成本。
2.11.5 只背结论,不分析来源
真正重要的并不是记住“某算法是 O(n log n)”,而是能够解释:
- 为什么层数是
log n - 为什么每层工作量是
n - 为什么空间不是
O(1)
只有做到这一点,复杂度知识才能迁移到新的问题情境之中。
2.12 AI Coding 时代为什么更要学复杂度
在今天的 AI Coding 环境中,大量代码可以通过自然语言直接生成。这使得“写出代码”本身不再是最稀缺的能力;相反,以下能力变得更为重要:
- 判断 AI 生成的代码是否高效
- 比较多个方案的代价差异
- 发现看起来简洁、实际上低效的写法
- 把复杂度约束写进提示词
例如,针对“删除重复元素”这个问题,AI 可能给出下面两种写法:
def dedup_v1(arr):
result = []
for x in arr:
if x not in result:
result.append(x)
return resultdef dedup_v2(arr):
seen = set()
result = []
for x in arr:
if x not in seen:
seen.add(x)
result.append(x)
return result如果缺乏复杂度分析能力,学习者可能只会停留在“两个版本都能工作”的判断上;而一旦进行复杂度审查,就会注意到:
x not in result在列表中查找,可能是O(n)- 外层循环本身又是
O(n) - 因此第一种写法整体可能达到
O(n^2)
而第二种写法借助集合,通常可以把查重降到更高效的级别。
因此,在 AI Coding 时代,复杂度分析的重要性并没有下降,反而更加突出。它使学习者能够从“接受输出”转向“判断输出”。
2.13 复杂度分析检查清单
学完本章后,面对任何一段算法代码,都可以先依据下面这份清单进行检查:
- 输入规模
n指的是什么? - 时间复杂度是什么?
- 空间复杂度是什么?
- 这里的空间复杂度是否指额外空间?
- 是否存在最好、最坏、平均情况的区别?
- 是否有隐藏成本,如切片、复制、排序、拼接?
- 如果是递归,递归深度多大?
- 每一层总共做了多少工作?
- 算法是否修改原数据?
- 如果这段代码由 AI 生成,我最应该怀疑哪一部分?
这份检查清单并不是应试技巧,而是训练算法判断能力的日常工具。
2.14 本章小结
本章建立了算法复杂度分析的基本框架。通过本章的学习,应当把握以下要点:
- 复杂度分析关注的是输入规模增大时的增长趋势
- Big O 不是精确秒数,而是数量级描述
- 时间复杂度看“要做多久”,空间复杂度看“要占多少额外空间”
- 复杂度分析必须先说清
n的含义 - 同一算法可能有最好、最坏、平均情况
- 递归复杂度要从“递归深度”和“每层工作量”两个角度分析
- Python 中存在切片、复制、新建列表等隐藏成本
- 在 AI Coding 时代,复杂度分析是审查代码质量的重要能力
最后需要再次强调:
学习复杂度,并不是为了机械记忆一张 Big O 速查表,而是为了形成一种识别算法代价、比较算法方案的分析能力。
2.15 练习
练习 1
分析下面代码的时间复杂度:
def f(arr):
s = 0
for x in arr:
s += x
return s练习 2
分析下面代码的时间复杂度:
def f(arr):
for i in range(len(arr)):
for j in range(i + 1, len(arr)):
print(arr[i], arr[j])练习 3
比较 sorted(arr) 和 arr.sort() 在以下三个方面的差别:
- 是否修改原数据
- 是否返回新列表
- 空间语义有什么不同
练习 4
解释为什么下面这段代码在 Python 中通常不是 O(n):
def list_sum(arr):
if not arr:
return 0
return arr[0] + list_sum(arr[1:])练习 5
说明递归版二分查找为什么时间复杂度是 O(log n),空间复杂度也是 O(log n)。
练习 6
朴素递归斐波那契为什么时间复杂度是指数级,而空间复杂度只是线性级?请结合调用树解释。
2.16 配套交互演示
本章的主体仍然是可连续阅读的教材正文,交互页面则作为关键概念的增强层。
如果希望帮助学生直观看到“规模减半”和“递归分层”的过程,可以配合下面两个页面使用。