简单介绍算法复杂性、思考计算复杂性

283 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第18天,点击查看活动详情

简单介绍算法复杂性

在设计和实现程序时,要考虑的最重要的事情是它应该产生可以依赖的结果。我们希望我们的银行余额计算正确。我们希望汽车中的喷油器能够喷射适量的燃油。我们宁愿飞机和操作系统都不会崩溃。

有时,性能是正确性的一个重要方面。这对于需要实时运行的程序来说最为明显。警告飞机潜在障碍物的程序需要在遇到障碍物之前发出警告。性能还会影响许多非实时程序的实用性。在评估数据库系统的效用时,每分钟完成的事务数是一个重要的指标。用户关心在手机上启动应用程序所需的时间。生物学家关心他们的系统发育推理计算需要多长时间。

编写高效的程序并不容易。最直接的解决方案通常不是最有效的。计算效率高的算法通常采用微妙的技巧,使它们难以理解。因此,程序员经常增加程序的概念复杂性,以降低其计算复杂性。为了以合理的方式做到这一点,我们需要了解如何估计程序的计算复杂性。这就是本章的主题。

思考计算复杂性

应该如何回答“以下函数需要多长时间才能运行?

image.png

我们可以在一些输入上运行程序并对其进行计时。但这不会特别提供信息,因为结果将取决于

·运行它的计算机的速度

·该机器上 Python 实现的效率

·输入的值

我们通过使用更抽象的时间度量来绕过前两个问题。我们不是以微秒为单位测量时间,而是根据程序执行的基本步骤数量来测量时间。

为简单起见,我们将使用随机访问机器作为计算模型。在随机存取机器中,步骤是按顺序执行的,一次一个.66 步骤是需要固定时间量的操作,例如将变量绑定到对象、进行比较、执行算术运算或访问内存中的对象。

现在我们有一个更抽象的方式来思考时间的意义,我们转向依赖输入价值的问题。我们通过不再将时间复杂度表示为单个数字,而是将其与输入的大小相关联来处理这个问题。这使我们能够通过讨论每个算法的运行时间相对于输入大小的增长来比较两种算法的效率。

当然,算法的实际运行时间不仅取决于输入的大小,还取决于它们的值。例如,考虑一下线性搜索算法,该算法由

image.png

假设 L 是一个包含一百万个元素的列表,并考虑调用 linear_search(L, 3)。如果 L 中的第一个元素是 3,linear_search将几乎立即返回 True。另一方面,如果 3 不在 L 中,linear_search在返回 False 之前必须检查所有 100 万个元素。

一般来说,有三大类情况需要考虑:

最佳运行时间是算法在输入尽可能有利时的运行时间。也就是说,最佳运行时间是给定大小的所有可能输入的最小运行时间。对于linear_search,最佳运行时间与L的大小无关。

同样,最坏情况下的运行时间是给定大小的所有可能输入的最大运行时间。为

linear_search,最坏情况下的运行时间是线性的 L 大小。

通过类比最佳情况和最坏情况运行时间的定义,平均情况(也称为预期情况)运行时间是给定大小的所有可能输入的平均运行时间。或者,如果一个人有一些关于输入值分布的先验信息(例如,90%的时间x在L中),人们可以考虑这一点。

人们通常关注最坏的情况。所有工程师都有一个共同的信条,墨菲定律:如果有什么地方出错,它就会出错。最坏的情况提供了运行时间的上限。当计算可以花费多长时间存在时间限制时,这一点至关重要。知道“大多数时候”空中交通管制系统在发生即将发生的碰撞之前发出警告是不够的。

让我们看一下阶乘函数的迭代实现的最坏情况运行时间:

image.png

运行此程序所需的步骤数类似于 2(1 个用于初始赋值语句,1 个用于返回)+ 5n(在 while 中计算测试的 1 个步骤,while 循环中的第一个赋值语句计算 2 个步骤,在循环中计算第二个赋值语句的 2 个步骤)。因此,例如,如果 n 为 1000,则该函数将执行大约 5002 个步骤。

很明显,随着n变大,担心5n和5n + 2之间的差异有点愚蠢。出于这个原因,在推理运行时间时,我们通常会忽略加法常量。乘法常数更成问题。我们应该关心计算是需要1000步还是5000步?乘法因素可能很重要。搜索引擎是否需要半秒钟或2.5秒来为查询提供服务,这可能是人们使用该搜索引擎还是去竞争对手之间的区别。

另一方面,在比较两种不同的算法时,通常情况下,即使是乘法常数也是无关紧要的。回想一下,在第3章中,我们研究了两种算法,详尽枚举和平分搜索,用于查找浮点数平方根的近似值。基于这些算法的函数如艾格尔 11-1 和艾格 11-2 所示。

image.png

image.png

我们看到,详尽的枚举是如此缓慢,以至于对于 x 和 epsilon 的许多值组合都是不切实际的。例如,计算square_root_exhaustive(100, 0.0001)需要大约 10 亿次 while 循环迭代。相比之下,计算square_root_bi(100, 0.0001)大约需要 20 次迭代稍微复杂的 while 循环。当迭代次数的差异如此之大时,循环中有多少条指令并不重要。也就是说,乘法常量是无关紧要的。