
复杂度分析
更新: 2025/2/24 字数: 0 字 时长: 0 分钟
前面我们介绍了算法(Algorithm),简单讲就是解决问题的方法。对于同一个问题,使用不同的算法,最终得到的结果一样,但在过程中消耗的资源和时间却会有很大的区别。在算法设计中,我们先后追求以下两个层面的目标:
- 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
- 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
简而言之,我们的目标是设计“既快又省”的数据结构与算法。因此有效地评估算法效率至关重要,因为只有这样,我们才能将各种算法进行对比,进而指导算法设计与优化过程。由于算法在实际测试中会受硬件配置、系统平台、编译器或解释器的优化、数据体量大小的影响进而展现出不同的效率,因此我们需要一个估算方法来评估算法的效率,该估算方法被称为“渐近复杂度分析(asymptotic complexity analysis)”,简称“复杂度分析”。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势,即算法运行所需的时间和空间资源与输入数据大小之间的关系。因此评估一个算法效率,主要包括以下两个维度:
- 时间复杂度:指算法运行过程中所消耗的时间长短。
- 空间复杂度:指算法运行过程中所占用的内存大小。
建议
复杂度分析独立测试环境,分析结果适用于所有运行平台,并且可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。可以说,复杂度分析为我们提供了一把评估算法效率的“标尺”,使我们可以衡量执行某个算法所需的时间和空间资源,对比不同算法之间的效率。
时间复杂度
时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势。这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 ,给定三个算法 A
、B
和 C
:
# 算法 A 的时间复杂度:常数阶
def algorithm_A(n: int):
print(0)
# 算法 B 的时间复杂度:线性阶
def algorithm_B(n: int):
for _ in range(n):
print(0)
# 算法 C 的时间复杂度:常数阶
def algorithm_C(n: int):
for _ in range(1000000):
print(0)
下图展示了以上三个算法函数的时间复杂度。
- 算法
A
只有 1 个打印操作,算法运行时间不随着 增大而增长。我们称此算法的时间复杂度为“常数阶”。 - 算法
B
中的打印操作需要循环 次,算法运行时间随着 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。 - 算法
C
中的打印操作需要循环 次,虽然运行时间很长,但它与输入数据大小 无关。因此C
的时间复杂度和A
相同,仍为“常数阶”。
相较于直接统计算法的运行时间,时间复杂度分析有哪些特点呢?
- 时间复杂度能够有效评估算法效率。例如,算法
B
的运行时间呈线性增长,在 时比算法A
更慢,在 时比算法C
更慢。事实上,只要输入数据大小 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势的含义。 - 时间复杂度的推算方法更简便。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。
- 时间复杂度也存在一定的局限性。例如,尽管算法
A
和C
的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法B
的时间复杂度比C
高,但在输入数据大小 较小时,算法B
明显优于算法C
。在这些情况下,我们很难仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
推算方法
统计操作数量
给定一个输入大小为 的函数:
def algorithm(n: int):
a = 1 # +1
a = a + 1 # +1
a = a * 2 # +1
# 循环 n 次
for i in range(n): # +1
print(0) # +1
设算法的操作数量是一个关于输入数据大小 的函数,记为 ,则以上函数的操作数量为:
是一次函数,说明其运行时间的增长趋势是线性的,因此它的时间复杂度是线性阶。
判断渐进上界
我们将上面一次函数 线性阶的时间复杂度记为 ,这个数学符号称为大 记号(big- notation),表示函数 的渐近上界(asymptotic upper bound)。稍微有点数学基础的人就能看出来,在 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略,因此时间复杂度由 中最高阶的项来决定。下表展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 趋于无穷大时,这些常数变得无足轻重。
建议
时间复杂度分析本质上是计算“操作数量 ”的渐近上界,它具有明确的数学定义。
常见类型
设输入数据大小为 ,常见的时间复杂度类型如下图所示(按照从低到高的顺序排列)。
建议
随着数据体量的不断增大,时间复杂度越高的算法,其计算操作数量的上升趋势就会显著。
常数阶
常数阶的操作数量与输入数据大小 无关,即不随着 的变化而变化。在以下函数中,尽管操作数量 size
可能很大,但由于其与输入数据大小 无关,因此时间复杂度仍为 :
def constant(n: int) -> int:
"""常数阶"""
count = 0
size = 100000
for _ in range(size):
count += 1
return count
建议
在我们设计算法的时候,最理想的时间复杂度是“常量时间复杂度”,因为“常量时间复杂度”算法的运行时间不会随着问题规模的增大而增加,最经典的例子就是哈希表,在 Python 中的 dict
字典、set
集合都有使用。
线性阶
线性阶的操作数量相对于输入数据大小 以线性级别增长。线性阶通常出现在单层循环中:
def linear(n: int) -> int:
"""线性阶"""
count = 0
for _ in range(n):
count += 1
return count
遍历数组和遍历链表等操作的时间复杂度均为 ,其中 为数组或链表的长度:
def array_traversal(nums: list[int]) -> int:
"""线性阶(遍历数组)"""
count = 0
# 循环次数与数组长度成正比
for num in nums:
count += 1
return count
建议
值得注意的是,输入数据大小 需根据输入数据的类型来具体确定。比如在第一个示例中,变量 为输入数据大小;在第二个示例中,数组长度 为数据大小。
平方阶
平方阶的操作数量相对于输入数据大小 𝑛 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 ,因此总体的时间复杂度为 :
def quadratic(n: int) -> int:
"""平方阶"""
count = 0
# 循环次数与数据大小 n 成平方关系
for i in range(n):
for j in range(n):
count += 1
return count
指数阶
生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为 个细胞,分裂一轮后变为 个,分裂两轮后变为 个,以此类推,分裂 轮后有 个细胞。以下代码模拟了细胞分裂的过程,时间复杂度为 :
def exponential(n: int) -> int:
"""指数阶(循环实现)"""
count = 0
base = 1
# 细胞每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1)
for _ in range(n):
for _ in range(base):
count += 1
base *= 2
# count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
return count
在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 次分裂后停止:
def exp_recur(n: int) -> int:
"""指数阶(递归实现)"""
if n == 1:
return 1
return exp_recur(n - 1) + exp_recur(n - 1) + 1
建议
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
对数阶
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 ,由于每轮缩减到一半,因此循环次数是 ,即 的反函数。以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 ,由于对数的底数 可以在不影响复杂度的前提下转换,因此通常会省略对数的底数,直接简记为 :
def logarithmic(n: int) -> int:
"""对数阶(循环实现)"""
count = 0
while n > 1:
n = n / 2
count += 1
return count
建议
对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。
线性对数阶
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 和 。相关代码如下:
def linear_log_recur(n: int) -> int:
"""线性对数阶"""
if n <= 1:
return 1
count: int = linear_log_recur(n // 2) + linear_log_recur(n // 2)
for _ in range(n):
count += 1
return count
下图展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为 ,树共有 层,因此时间复杂度为 。
建议
主流排序算法的时间复杂度通常为 O(nlogn) ,例如快速排序、归并排序、堆排序等。
阶乘阶
阶乘阶对应数学上的“全排列”问题。给定 个互不重复的元素,求其所有可能的排列方案,方案数量为:
阶乘通常使用递归实现。如下图和以下代码所示,第一层分裂出 个,第二层分裂出 n−1 个,以此类推,直至第 层时停止分裂:
def factorial_recur(n: int) -> int:
"""阶乘阶(递归实现)"""
if n == 0:
return 1
count = 0
# 从 1 个分裂出 n 个
for _ in range(n):
count += factorial_recur(n - 1)
return count
警告
请注意,因为当 时恒有 ,所以阶乘阶比指数阶增长得更快,在 较大时也是不可接受的。
平均效率
算法的时间效率往往不是固定的,而是与输入数据的分布有关。假设输入一个长度为 的数组 nums
,其中 nums
由从 至 的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 的索引。我们可以得出以下结论:
- 当
nums = [?, ?, ..., 1]
,即当末尾元素是 时,需要完整遍历数组,达到最差时间复杂度 。 - 当
nums = [1, ?, ?, ...]
,即当首个元素为 1 时,无论数组多长都不需要继续遍历,达到最佳时间复杂度 。
def random_numbers(n: int) -> list[int]:
"""生成一个数组,元素为: 1, 2, ..., n ,顺序被打乱"""
# 生成数组 nums =: 1, 2, 3, ..., n
nums = [i for i in range(1, n + 1)]
# 随机打乱数组元素
random.shuffle(nums)
return nums
def find_one(nums: list[int]) -> int:
"""查找数组 nums 中数字 1 所在索引"""
for i in range(len(nums)):
# 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
# 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
if nums[i] == 1:
return i
return -1
从上述示例可以看出,最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,平均时间复杂度可以体现算法在随机输入数据下的运行效率。上述示例中,由于输入数组是被打乱的,因此元素 1 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 ,平均时间复杂度为 。
不过对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。所以在实际中,我们通常使用最差时间复杂度作为算法效率的评判标准,因为它给出了一个效率安全值,让我们可以放心地使用算法。
空间复杂度
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。
算法在运行过程中使用的内存空间主要包括以下几种:
- 输入空间:用于存储算法的输入数据。
- 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
- 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
- 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
- 输出空间:用于存储算法的输出数据。
在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分,如下图所示:
相关代码如下:
class Node:
"""类"""
def __init__(self, x: int):
self.val: int = x # 节点值
self.next: Node | None = None # 指向下一节点的引用
def function() -> int:
"""函数"""
# 执行某些操作...
return 0
def algorithm(n) -> int: # 输入数据
A = 0 # 暂存数据(常量,一般用大写字母表示)
b = 0 # 暂存数据(变量)
node = Node(0) # 暂存数据(对象)
c = function() # 栈帧空间(调用函数)
return A + b + c # 输出数据
推算方法
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。观察以下代码,最差空间复杂度中的“最差”有两层含义:
- 以最差输入数据为准:当 时,空间复杂度为 ;但当 时,初始化的数组
nums
占用 空间,因此最差空间复杂度为 。 - 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 空间;当初始化数组
nums
时,程序占用 空间,因此最差空间复杂度为 。
def algorithm(n: int):
a = 0 # O(1)
b = [0] * 10000 # O(1)
if n > 10:
nums = [0] * n # O(n)
在递归函数中,需要注意统计栈帧空间。观察以下代码:
def function() -> int:
# 执行某些操作
return 0
def loop(n: int):
"""循环的空间复杂度为 O(1)"""
for _ in range(n):
function()
def recur(n: int):
"""递归的空间复杂度为 O(n)"""
if n == 1:
return
return recur(n - 1)
函数 loop()
和 recur()
的时间复杂度都为 ,但空间复杂度不同。
- 函数
loop()
在循环中调用了 次function()
,每轮中的function()
都返回并释放了栈帧空间,因此空间复杂度仍为 。 - 递归函数
recur()
在运行过程中会同时存在 个未返回的recur()
,从而占用 的栈帧空间。
常见类型
设输入数据大小为 ,图 2-16 展示了常见的空间复杂度类型(从低到高排列)。
常数阶
常数阶常见于数量与输入数据大小 无关的常量、变量、对象。需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 :
def function() -> int:
"""函数"""
# 执行某些操作
return 0
def constant(n: int):
"""常数阶"""
# 常量、变量、对象占用 O(1) 空间
a = 0
nums = [0] * 10000
node = ListNode(0)
# 循环中的变量占用 O(1) 空间
for _ in range(n):
c = 0
# 循环中的函数占用 O(1) 空间
for _ in range(n):
function()
线性阶
线性阶常见于元素数量与 成正比的数组、链表、栈、队列等:
def linear(n: int):
"""线性阶"""
# 长度为 n 的列表占用 O(n) 空间
nums = [0] * n
# 长度为 n 的哈希表占用 O(n) 空间
hmap = dict[int, str]()
for i in range(n):
hmap[i] = str(i)
如下图所示,此函数的递归深度为 ,即同时存在 个未返回的 linear_recur()
函数,使用 大小的栈帧空间:
def linear_recur(n: int):
"""线性阶(递归实现)"""
print("递归 n =", n)
if n == 1:
return
linear_recur(n - 1)
平方阶
平方阶常见于矩阵和图,元素数量与 成平方关系:
def quadratic(n: int):
"""平方阶"""
# 二维列表占用 O(n^2) 空间
num_matrix = [[0] * n for _ in range(n)]
如下图所示,该函数的递归深度为 ,在每个递归函数中都初始化了一个数组,长度分别为 、、…、、 ,平均长度为 ,因此总体占用 空间:
def quadratic_recur(n: int) -> int:
"""平方阶(递归实现)"""
if n <= 0:
return 0
# 数组 nums 长度为 n, n-1, ..., 2, 1
nums = [0] * n
return quadratic_recur(n - 1)
指数阶
指数阶常见于二叉树。观察下图,层数为 的“满二叉树”的节点数量为 ,占用 空间:
def build_tree(n: int) -> TreeNode | None:
"""指数阶(建立满二叉树)"""
if n == 0:
return None
root = TreeNode(0)
root.left = build_tree(n - 1)
root.right = build_tree(n - 1)
return root
对数阶
对数阶常见于分治算法。例如归并排序,输入长度为 的数组,每轮递归将数组从中点处划分为两半,形成高度为 的递归树,使用 栈帧空间。再例如将数字转化为字符串,输入一个正整数 ,它的位数为 ,即对应字符串长度为 ,因此空间复杂度为 。
权衡时间与空间
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常非常困难,它们之间更多通常是鱼和熊掌的关系。也就意味着,降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。因此在计算机中有“时间和空间通常是不可调和的矛盾”这一说法。
选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,而且现今的计算机硬件已经有了很大的进步,当空间不够的时候,我们可以用很低的成本去给计算机增加新的内存空间,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。但无论程序的算法多么的优秀,我们总是希望程序能尽快的给到我们结果,因此对时间的追求可能一直都是我们要孜孜不倦的去做的事情。