数据结构和算法 | 第一部分第三课:算法复杂度(上)
作者 谢恩铭,公众号「程序员联盟」。
转载请注明出处。
原文:https://www.jianshu.com/p/14982c42b2d8
《数据结构和算法》全系列
内容简介
- 算法的正确性
- 算法的复杂度
- “渐近”度量
- 第一部分第四课预告
1. 算法的正确性
上一课 数据结构和算法 | 第一部分第二课:小鸭子们去旅行 中,我们讲了一个有趣的小故事,就是为了引出算法复杂度。
算法复杂度非常重要,要讲的内容也很多,所以我们分为上下两课。
当程序员需要解决计算机科学相关的问题时,他们(通常)会编写一个程序。这个程序包含一个实现,也就是说需要把算法用一种编程语言来实现。
我们知道,算法只是对解决问题的步骤的一种精确描述,它并不依赖于程序员所用的编程语言或工作环境。
我们在 数据结构和算法 | 第一部分第一课:什么是数据结构和算法 里介绍了一个“煮方便面”的食谱,这份食谱虽然简单,但可以说是一个算法。
我们是用中文来描述这份食谱的,如果我现在把这份食谱翻译成一门外语(比如 英语),但食谱的内容还是不变,只不过换了一种语言来说明罢了。换个英国人来照着这份英文食谱煮方便面,跟我做出来的会是一样的。
那么,当程序员需要把算法用一种编程语言来实现时,需要做什么呢?
像农夫 Oscar 一样,他必须首先验证他的算法是正确的,也就是说它产生了预期的结果,解决了所涉及的问题。这非常重要(如果算法不正确,那我们根本没有选择和优化它的必要),有时验证算法正确性是非常难的一步。
算法正确性,英语是 Algorithm Correctness(algorithm 表示“算法”,correctness 表示“正确性”)。要证明算法的正确性,有许多方法,我们本课程就不详述了,因为这不是我们的重点。大家有兴趣可以去网上搜索一下。
当然,算法正确了,并不能保证实现这个算法的程序就没有错误(bug)。
一旦有了一个正确的算法,我们用编程语言去实现它(通过编写一个执行它的程序)。但我们可能会写出有很多小错误的程序,这些小错误也许和所使用的编程语言有关,可能在编写程序的过程中被引入。因为算法中一般不会描述如何避免犯错,例如如何管理程序的内存,如何检查段错误(Segmentation Fault),等等,但这些都是程序员实现算法时需要考虑的问题。
2. 算法的复杂度
一旦程序员确信他的算法是正确的,他将尝试评估其效率,比如他会想知道“这个算法快不快”。
有人可能会认为,要知道算法快不快的最佳方式是实现该算法并在电脑上进行测试。
有趣的是,通常情况并非如此。例如,如果两个程序员实现了两种不同的算法,并在各自的电脑上测试程序运行的快慢。那么拥有更快速度的电脑的那个程序员可能会错误地认为他的算法更快,但其实并不一定。
而且,要实现一个算法并测试,有时候并不容易。且不说有时候根据一个算法来写出实现的代码很难,如果要实现的算法涉及到一枚火箭的发射,难道每次用一枚真的火箭来发射一下去测试算法快不快吗?我们又不像钢铁侠那么有钱可以任性。
出于这些原因,计算机科学家们发明了一个非常方便而强大的概念,也就是我们接下来要学习的:算法的复杂度。
“复杂度”(Complexity,表示“复杂”,是一个名词。它对应的形容词是 complex,表示“复杂的”)这个词有点误导人,因为我们这里不是强调“理解起来有困难”(很复杂,很难),而是指效率(efficiency)。
“复杂度”并不意味着“复杂的程度”。有的算法理解起来很难(很复杂),它的复杂度却可以非常低。
如果要用一句话来简单说明算法复杂度,那可以是:
“如果给实现了这个算法的程序一个大小为 N 的输入,那么这个程序将执行的操作的数目的数量级是 N 的一个怎么样的函数( f(N) )呢?”
f(N) 中的 f 是 function(函数)的意思,相信以前数学课的时候,大家都学过(比如我们以前很常见的 y = f(x) )。f(N) 表示“N 的函数”。
上面那句话基于以下事实:解决问题的程序取决于问题的起始条件。如果起始条件改变,程序执行的时间也会变长或变短。
复杂度可以量化(通过数学公式)起始条件与算法执行的时间之间的关系。
上面这几句话乍看有点难以理解。什么是操作?什么是操作的数目,什么是操作的数目的数量级?
不用担心,我们慢慢讲解:
复杂度的学习中会涉及一些数学的概念。所以嘛,学好数学对编程还是很有帮助的,英语同样很重要。如果你英语和数学比较好,学起编程来会轻松很多。可以参看我以前的一篇文章:对于程序员, 为什么英语比数学更重要? 如何学习 。
数量级是指数量的尺度或大小的级别,每个级别之间保持固定的比例。通常采用的比例有 10,2,1000,1024, e(欧拉数,大约等于 2.71828182846 的超越数,即自然对数的底)。
-- 摘自 百度百科
为了“计算操作的数目”,我们必须首先定义什么是操作。操作其实不难理解,比如第一部分第一课中,我们讲了一个“煮方便面”的算法,其中的步骤是这样的:
- 在锅子里倒入适量水
- 在炉子上点起火来(如果是电磁炉就不用火)
- 把锅子放在炉子上
- 等待水开,转中火
- 把方便面饼放入锅中
- 煮半分钟
- 放入所有调料包
- 煮 1 分钟
- 出锅
上面这些步骤中的每一步你都可以看成一个操作(operation,“操作”的意思,这个英语单词也有“运算”的意思)。
但是要计算操作的数目的时候,我们该挑选哪些操作呢? 即使是绝顶聪明的科学家可能也无法非常明确地回答。因为挑选哪些操作来做计算取决于所考虑的问题(甚至是算法)。
我们必须选择算法经常执行的一些操作,并且将其作为度量算法复杂度的基准。
例如,要制作煎鸡蛋,可以考虑三种基本操作:
- 打破鸡蛋
- 铲碎正在煎的鸡蛋
- 煎熟鸡蛋
因此,我们可以统计每份煎鸡蛋的菜谱中的鸡蛋数目,从而了解菜谱(算法)的复杂度(煎鸡蛋是一个众所周知的菜,我们可以预期所有煎鸡蛋的菜谱都具有相同的复杂度:对于 N 个鸡蛋,我们进行 3N 个操作)。添加盐、葱、胡椒或其他佐料的操作非常快,不需要考虑在菜谱(算法)复杂度之内。
又例如,在上一课“小鸭子们去旅行”的故事中,装小鸭子的大箱子是 N 行 N 列,那么如果农夫 Oscar 采取第一种旧的算法,他须要在池塘和卡车之间进行 N2(N 的平方)趟来回;如果用第二种新的算法,却只须要 N 趟来回。
这是复杂度的一种很好的度量方式,因为农夫 Oscar 在池塘和卡车之间来回这个过程是算法的所有操作里耗时最长的。其他一些操作相对来说不那么耗时,比如 Oscar 从大箱子里挑出一只小鸭子,询问小鸭子要去哪个池塘,等等。
因此,我们可以说,此算法的总时间几乎主要花在池塘和卡车之间来回这个操作上了,所以它可以作为度量此算法的效率的标准。
如果复杂度的概念对你来说还是有点模糊,请不要担心,之后的实践课程应该会让你豁然开朗。
3. “渐近”度量
上面我们介绍了复杂度的基本概念,下面我们继续讲。
我们可以说:复杂度是算法的渐近行为的度量。
渐近的英语是 Asymptotic。那么,这个有点难以理解的词“渐近行为”(或只是“渐进”这个词)是什么意思呢?
这意味着“当输入变得非常大”(甚至“趋于无限”)时。这里的“输入”是算法的起始条件的一种量化。
在“小鸭子们去旅行”的故事中,这意味着“当大箱子里有很多行/列的小鸭子”时,例如 N 是 250。在计算机科学中,“很多”的含义却略有不同:例如搜索引擎会说“有很多网页”,1 万亿个网页...
“当输入变得非常大”(甚至“趋于无限”)时会有两种结果:
一方面,恒定的时间(是常量,constant quantity)不予考虑。因为“恒定的时间”不依赖于输入。
例如,在农夫 Oscar 开始将小鸭子们带到每一个池塘去之前,他会先打开卡车的后车门。打开卡车后车门的时间被认为是“恒定的”:不论他的大箱子里有 20 行 20 列小鸭子还是有 50 行 50 列小鸭子,开后车门都是花费相同的时间。由于我们要考虑在大箱子的行/列的数目非常大的时候算法的效率,因此与 Oscar 在池塘和卡车之间来回的时间相比,打开卡车后车门的时间可以忽略不计。
另一方面,“常数乘法因子”也不予考虑:复杂度的度量不区分执行 N、2N 或 250N 个操作的算法。为什么呢?我们考虑以下两种取决于 N 的算法:
第一种算法:
做 N 次(操作A)
第二种算法:
做 N 次(操作B 然后 操作C)
在第一种算法中,我们做 N 次 操作A;在第二种算法中我们做 N 次 操作B,N 次 操作C。假设这两种算法解决了同样的问题(所以都是正确的),并且所有操作都被考虑进复杂度的度量中:那么第一个算法做了 N 个操作,第二个算法做了 2N 个操作。
但我们可以说哪个算法更快吗?当然不行。因为这取决于 A、B、C 这 3 个操作所花费的时间:也许 操作B 和 操作C 都比 操作A 快 4 倍,那么总体来看,操作数为 2N 的第二种算法比操作数为 N 的第一种算法反而更快,因为 1/4 + 1/4 = 1/2(操作B 和 操作C 的耗时都是 操作A 的四分之一)。
因此,不一定对算法的效率具有影响的常数乘法因子在复杂度的度量中不予考虑。
这也使我们能够回答一开始的那个问题:如果两个程序员有两台计算机,一台比另一台速度快 5 倍。5 这个常数乘法因子将被忽略,因此两位程序员可以毫无问题地比较算法的复杂度。
可以看到,在算法的复杂度的度量中,我们忽略了很多东西,这使得我们能有一个相当简单和普遍的概念。这种普遍性使复杂度成为一个有用的工具,但它也有明显的缺点:在某些非常特殊的情况下,更复杂的算法居然可以用更少的时间来完成(例如,常数因子在实际中也许能扮演非常关键的角色:假设农夫 Oscar 的卡车后车门卡住了,他竟花了一整天的时间来打开后车门)。
然而,在绝大多数情况下,复杂度仍是算法性能的可靠指标。特别是当两个复杂度之间的差距因为输入的增大而变得越来越大时。
一种有 N 个比较耗时的操作的算法也许在 N 是 10 或 20 的时候比另一种有 N2(N 的平方)个不那么耗时的操作的算法运行时间更长;但在 N 是 2万 或 N 是 5 百万的时候,复杂度更低的算法将肯定是更快的。
4. 第一部分第四课预告
今天的课有点难度,不妨多读几遍。下一课我们继续研究算法的复杂度。一起加油吧!
下一课:数据结构和算法 | 第一部分第四课:算法复杂度(下)
我是 谢恩铭,公众号「程序员联盟」运营者,慕课网精英讲师 Oscar 老师,终生学习者。
热爱生活,喜欢游泳,略懂烹饪。
人生格言:「向着标杆直跑」