复杂度分析

196 阅读14分钟

二.复杂度分析

www.hello-algo.com/chapter_com…

在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。

  • 时间效率:算法运行时间的长短。
  • 空间效率:算法占用内存空间的大小。

复杂度分析能够体现算法运行所需时间和空间资源与输入数据大小之间的关系。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势。我们可以将其分为三个重点来理解。

  • “时间和空间资源”分别对应时间复杂度(time complexity)和空间复杂度(space complexity)。
  • “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
  • “时间和空间的增长趋势”表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的“快慢”。

复杂度分析为我们提供了一把评估算法效率的“标尺”,使我们可以衡量执行某个算法所需的时间和空间资源,对比不同算法之间的效率。

1. 时间复杂度

运行时间可以直观且准确地反映算法的效率。若想准确预估一段代码的运行时间,则有以下步骤。

  1. 确定运行平台:包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。
  2. 评估各种计算操作所需的运行时间
  3. 统计代码中所有的计算操作:并将所有操作的执行时间求和,从而得到运行时间。

但实际上,统计算法的运行时间既不合理也不现实

  1. 我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。
  2. 我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。

因此,时间复杂度分析统计的是算法运行时间随数据量变大时的增长趋势。

1.1. 统计时间增长趋势

相较于直接统计算法的运行时间,时间复杂度分析的特点:

举例:

# 算法 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)
  • 时间复杂度能够有效评估算法效率:例如,算法 B 的运行时间呈线性增长,在 n>1 时比算法 A 更慢,在 n>1000000 时比算法 C 更慢。事实上,只要输入数据大小 n 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势的含义。
  • 时间复杂度的推算方法更简便:显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。
  • 时间复杂度也存在一定的局限性:例如,尽管算法 A 和 C 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 B 的时间复杂度比 C 高,但在输入数据大小 n 较小时,算法 B 明显优于算法 C 。对于此类情况,我们时常难以仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。

1.2. 函数渐进上界

假设某一算法的操作数量是一个关于输入数据大小 n 的函数,记为 T(n) ,则记函数的操作数量为 T(n)T(n)

我们将线性阶的时间复杂度记为 O(n)O(n) ,这个数学符号称为大 OO 记号(big-O notation),表示函数 T(n)T(n) 的渐近上界(asymptotic upper bound)。 时间复杂度分析本质上是计算操作数量 T(n)T(n)的渐近上界,它具有明确的数学定义。

若存在正实数 c 和实数 n0,使得对于所有的 n>n0 ,均有 T(n)cf(n)T(n)≤c⋅f(n) ,则可认为 f(n)f(n) 给出了 T(n)T(n) 的一个渐近上界,记为 T(n)=O(f(n))T(n)=O(f(n)) 。

image.png

计算渐近上界就是寻找一个函数 f(n)f(n) ,使得当 n 趋向于无穷大时,T(n)T(n) 和 f(n)f(n) 处于相同的增长级别,仅相差一个常数项 c 的倍数。

1.3. 计算方法

1.3.1. 统计操作数量

针对代码,逐行从上到下计算即可。然而,由于上述 cf(n)c⋅f(n) 中的常数项 c 可以取任意大小,因此操作数量 T(n) 中的各种系数、常数项都可以忽略。根据此原则,可以总结出以下计数简化技巧。

  1. 忽略 T(n) 中的常数项。因为它们都与 n 无关,所以对时间复杂度不产生影响。
  2. 省略所有系数。例如,循环 2n 次、5n+1 次等,都可以简化记为 n 次,因为 n 前面的系数对时间复杂度没有影响。
  3. 循环嵌套时使用乘法。循环嵌套总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第 1 点和第 2 点的技巧。
示例:

def algorithm(n: int):
a = 1      # +0(技巧 1)
a = a + n  # +0(技巧 1)

# +n(技巧 2)
for i in range(5 * n + 1):
    print(0)
    
# +n*n(技巧 3)
for i in range(2 * n):
    for j in range(n + 1):
        print(0)
1.3.2. 判断渐进上界

在判断渐进上界时时间复杂度由 T(n) 中最高阶的项来决定。这是因为在 n 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。 表中展示了一些例子,系数无法撼动阶数,当 n 趋于无穷大时,这些常数变得无足轻重。

操作数量 T(n)时间复杂度 O(f(n))
1000000O(1)
3n+2O(n)
2n2+3n+2O(n2)
n3+3n2+2nO(n3)
2n+3n10000000O(2n)

1.4. 常见类型

设输入数据大小为 n ,常见的时间复杂度类型如图所示(按照从低到高的顺序排列) image.png

  1. 常数阶 O(1): 常数阶的操作数量与输入数据大小 n (这里的 n 是一个常数)无关,即不随着 n 的变化而变化。

  2. 线性阶 O(n): 线性阶的操作数量相对于输入数据大小 n 以线性级别增长。 在单层循环,尤其是在遍历数组和遍历链表等操作时复杂度均为线性阶,即 O(n)。值得注意的是,输入数据大小 n 需根据输入数据的类型来具体确定。

  3. 平方阶 O(n2): 平方阶的操作数量相对于输入数据大小 n 以平方级别增长。平方阶通常出现在嵌套循环中,此时的外层循环和内层循环的时间复杂度都为 O(n) ,因此总体的时间复杂度为 O(n2) ,比较有代表性的即是冒泡排序算法。

  4. 指数阶 O(2n): 生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为 1 个细胞,分裂一轮后变为 2 个,分裂两轮后变为 4 个,以此类推,分裂 n 轮后有 2n 个细胞。 在实际算法中,指数阶常出现于递归函数中,这里的递归,数值是会随着不断地递归而变大。 指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。

  5. 对数阶 O(log⁡n): 与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 n ,由于每轮缩减到一半,因此循环次数是 log2⁡n ,即 2n 的反函数。 对数阶时间复杂度为 O(log2⁡n),简记为 O(log⁡n) image.png 对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。 准确来说,“一分为 m”对应的时间复杂度是 O(logm⁡n) 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度:O(logm⁡n) = O(logk⁡n/logk⁡m) =O(logk⁡n) 也就是说,底数 m 可以在不影响复杂度的前提下转换。因此我们通常会省略底数 m ,将对数阶直接记为 O(log⁡n) 。

  6. 线性对数阶 O(nlog⁡n): 线性对数阶常出现于嵌套循环中,在这里,两层循环的时间复杂度分别为 O(log⁡n) 和 O(n) 。 主流排序算法的时间复杂度通常为 O(nlog⁡n) ,例如快速排序、归并排序、堆排序等。

  7. 阶乘阶 O(n!): 阶乘阶对应数学上的“全排列”问题。阶乘通常使用递归实现。注意,在 n 大于一定数值时,阶乘阶比指数阶增长还要快,是同样不被认可的复杂度。 image.png

1.5. 最差最佳平均时间复杂度

算法的时间效率往往不是固定的,而是与输入数据的分布有关

假设一个算法的任务时遍历一个元素为 n 的数组(遍历一个数据结构)然后返回某一值的索引,则最差时间复杂度指需要完整遍历整个数组(数据结构)才能找到该元素;最佳时间复杂度指当遍历到数组(数据结构)的第一个元素就找到了目标元素。

最差时间复杂度对应函数渐近上界,使用大 O 记号表示。 最佳时间复杂度对应函数渐近下界,用 Ω 记号表示,在实际中很少使用最佳时间复杂度,因为其出现的概率过小。

由于最差时间复杂度和最佳时间复杂度的出现都依赖于‘特殊的数据分布’,因此两者均不能真实地反映算法的运行效率,而平均时间复杂度可以体现算法在随机输入数据下的运行效率,其符号主要用 Θ来表示,但是实际中仍常常使用 O 来进行表示。

对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此目标元素出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 n/2 ,平均时间复杂度为 Θ(n/2)=Θ(n) 。 但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。

也就是说,在能够推算出随机数据分布的平均情况时,采用平均时间复杂度作为评判标准,而在大部分时间内,主要采用最差时间复杂度作为评判标准

2. 空间复杂度

空间复杂度用于衡量算法占用内存空间随着数据量变大时的增长趋势。

2.1. 算法空间

image.png 算法在运行过程中使用的内存空间的类别如图所示,其中:

  • 输入空间:用于存储算法的输入数据。
  • 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
    • 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
    • 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
    • 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
  • 输出空间:用于存储算法的输出数据。

2.2. 推算方式

在进行推算时,将统计对象由操作数量转化为使用空间的大小。 在空间复杂度的计算时,只关注最差空间复杂度,因为必须确保在所有输入数据下都有足够的内存空间预留。 其中“最差共有两个标准”,即以最差输入数据为准以算法运行中的峰值内存为准

2.3. 常见类型

设输入数据大小为 n  image.png

  1. 常数阶 O(1): 常数阶常见于数量与输入数据大小 n 无关的常量、变量、对象,(也会有一些函数)。 需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 O(1) 

  2. 线性阶 O(n): 线性阶常见于元素数量与 n 成正比的数组、链表、栈、队列等。

  3. 平方阶 O(n2): 平方阶常见于矩阵和图,元素数量与 n 成平方关系,如二维列表占用 O(n^2) 空间。

  4. 指数阶 O(2n): 指数阶常见于二叉树。层数为 n 的“满二叉树”的节点数量为 2n−1 ,占用 O(2n) 空间: image.png

  5. 对数阶 O(log⁡n): 对数阶常见于分治算法。例如归并排序,输入长度为 n 的数组,每轮递归将数组从中点处划分为两半,形成高度为 log⁡n 的递归树,使用 O(log⁡n) 栈帧空间。 再例如将数字转化为字符串,输入一个正整数 n ,它的位数为 (log10⁡n)+1 ,即对应字符串长度为(log10⁡n)+1 ,因此空间复杂度为 O((log10⁡n)+1)=O(log⁡n) 。

3. 小结

  • 时间复杂度用于衡量算法运行时间随数据量增长的趋势,可以有效评估算法效率,但在某些情况下可能失效,如在输入的数据量较小或时间复杂度相同时,无法精确对比算法效率的优劣。时间复杂度分为最差、最佳、平均时间复杂度,平均时间复杂度反映算法在随机数据输入下的运行效率,最接近实际应用中的算法性能。
  • 空间复杂度的作用类似于时间复杂度,用于衡量算法占用内存空间随数据量增长的趋势。通常只关注最差空间复杂度。
  • 理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常非常困难。
  • 降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。
  • 选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。