Python-算法教程-一-

71 阅读1小时+

Python 算法教程(一)

原文:Python Algorithms

协议:CC BY-NC-SA 4.0

零、前言

这本书融合了我的三大爱好:算法、Python 编程和解释事物。对我来说,这三者都是关于美学的——找到做事情的正确方式,寻找直到你发现一丝优雅,然后打磨它直到它闪闪发光(或者至少直到它更闪亮)。当然,当有很多材料要覆盖时,你可能无法像你想要的那样润色。幸运的是,这本书的大部分内容都是预先润色过的,因为我写的是非常漂亮的算法和证明,以及最可爱的编程语言之一。至于第三部分,我已经尽力去寻找能让事情看起来尽可能明显的解释。即便如此,我确信我在很多方面都失败了,如果你有改进这本书的建议,我很乐意收到你的来信。谁知道呢,也许你的一些想法可以在未来的版本中出现。不过,现在,我希望你对这里的内容感兴趣,并带着任何新发现的见解去做。如果可以的话,用它来让世界变得更美好,无论用哪种看起来正确的方式。

一、简介

  1. 写下问题。
  2. 好好想想。
  3. 写下解决方案。

——默里·盖尔·曼描述的“费曼算法”

考虑下面的问题:你要参观所有的城市、城镇和村庄,比如说,瑞典,然后回到你的出发点。这可能需要一段时间(毕竟要访问 24,978 个地点),所以您希望最小化您的路径。你计划沿着尽可能最短的路线,对每个地点只访问一次。作为一个程序员,你当然不希望手工绘制路线。相反,你试着写一些代码来为你计划你的旅行。然而,出于某种原因,你似乎不能把它做好。一个简单的程序适用于少数城镇和城市,但似乎永远适用于实际问题,并且改进该程序变得异常困难。怎么会这样

事实上,在 2004 年,一个由五名研究人员组成的团队发现了这样一条瑞典之旅,此前许多其他研究团队都尝试过,但都失败了。这个五人团队使用了先进的软件,其中包含许多聪明的优化和行业技巧,在 96 个至强 2.6GHz 工作站的集群上运行。他们的软件从 2003 年 3 月运行到 2004 年 5 月,最后才打印出最优解。考虑到各种各样的中断,该团队估计总的 CPU 时间大约为 85 年

考虑一个类似的问题:你想从中国最西部的喀什到东海岸的宁波,走最短的路线。现在,中国有 3,583,715 公里的公路和 77,834 公里的铁路,有数百万个十字路口要考虑,还有数不清的路线可供选择。这个问题看起来与前一个问题有关,然而这个最短路径问题是一个通过 GPS 软件和在线地图服务常规解决的问题,没有明显的延迟。如果你把这两个城市给你最喜欢的地图服务,你应该在短短的时间内得到最短的路线。这是怎么回事?

在本书的后面,你会学到更多关于这两个问题的知识;第一个问题叫做 旅行推销员(或销售代表 ) 问题并在第十一章中涉及,而所谓的最短路径问题主要在第九章中处理。我还希望你能深入了解为什么一个问题看起来如此难以解决,而另一个问题却有几个众所周知的有效解决方案。更重要的是,你将学到一些关于如何处理算法和计算问题的知识,要么使用本书中遇到的几种技术和算法中的一种有效地解决它们,要么表明它们太难了,近似的解决方案可能是你所能希望的。这一章简要描述了这本书的内容——你可以期待什么,以及对你的期望是什么。它还概述了各个章节的具体内容,以防你想跳过。

那这是什么?

这是一本为 Python 程序员编写的关于算法问题解决的书。就像关于面向对象模式的书籍一样,它处理的问题是一般性的——解决方案也是如此。然而,对于算法专家来说,这项工作不仅仅是简单地实现或执行现有的算法。人们期望你提出新的算法——新的通用解决方案来解决迄今为止未见过的通用问题。在这本书里,你将学习构建这种解决方案的原则。

不过,这不是你的典型算法书 。大多数关于这个主题的权威书籍(如 Knuth 的经典著作或 Cormen 等人的行业标准教科书)都有严重的形式和理论倾向,即使其中一些(如 Kleinberg 和 Tardos 的)更倾向于可读性。我不想取代这些优秀的书籍,我想用来补充它们。基于我教授算法的经验,我试图尽可能清晰地解释算法是如何工作的,以及其中许多算法背后的共同原理。对于一个程序员来说,这些解释大概就够了。你可能会理解为什么算法是正确的,以及如何将它们应用到你可能面临的新问题中。然而,如果你需要形式主义和百科全书式的教科书的全部深度,我希望你在本书中获得的基础将帮助你理解你在那里遇到的定理和证明。

Image 这本书和其他算法教科书的一个区别是,我采用了一种相当对话式的语气。虽然我希望这至少能吸引我的一些读者,但它可能不合你的胃口。对此我很抱歉——但现在你至少得到了警告。

还有另一种类型的算法书籍:在空白中的“(数据结构和)算法”,其中空白是作者最喜欢的编程语言。其中有不少(特别是对于 blank = Java 来说,似乎),但是它们中的许多都关注于相对基础的数据结构,而忽略了更具体的内容。例如,如果这本书被设计用于数据结构的基础课程,这是可以理解的,但是对于一个 Python 程序员来说,学习单链表和双向链表可能并不那么令人兴奋(尽管你会在下一章听到一些关于它们的内容)。尽管哈希等技术非常重要,但您可以免费获得 Python 字典形式的哈希表;没有必要从头开始实现它们。相反,我专注于更高级的算法。许多重要的概念,无论是 Python 语言本身还是标准库中的黑盒实现(如排序、搜索和散列)都将在整篇文章的特殊“黑盒”侧栏中得到更简要的解释。

当然,还有一个因素将这本书与“Java/C/C++/C#中的算法”类型的书区分开来,即空白处是 Python。这使得这本书更接近与语言无关的书(例如 Knuth、 3 Cormen 等人的书,以及 Kleinberg 和 Tardos 的书),这些书经常使用 伪代码,这是一种旨在可读而非可执行的伪编程语言。Python 的一个显著特征是它的可读性;它或多或少是可执行的伪代码。即使你从来没有用 Python 编程过,你也能理解大多数基本 Python 程序的意思。本书中的代码被设计为完全以这种方式可读——您不需要成为 Python 专家来理解示例(尽管您可能需要查找一些内置函数等)。如果你想假装这些例子实际上是伪代码,请随意。综上...

这本书讲的是:

  • 算法分析,重点是渐近运行时间
  • 算法设计的基本原则
  • 如何用 Python 表示常用的数据结构
  • 如何用 Python 实现知名算法

这本书只简要或部分涉及的内容:

  • Python 中直接可用的算法,或者作为语言的一部分,或者通过标准库
  • 彻底而深刻的形式主义(尽管这本书有它自己的证明和类似证明的解释)

这本书无关:

  • 数值或数论算法(除了第二章中的一些浮点提示)

  • 并行算法和多核编程

正如您所看到的,“用 Python 实现事物”只是一部分。设计原则和理论基础包括在内,希望它们能帮助你设计你自己的算法和数据结构。

你为什么在这里?

当使用算法时,你试图高效地解决问题*。你的程序应该很快;等待解决方案的时间应该很短。但是,我所说的高效、快速和短暂到底是什么意思呢?在 Python 这样的语言中,你为什么要关心这些事情呢?Python 本来就不怎么快。为什么不转向 C 或 Java 呢?*

第一,Python 是一门可爱的语言,你可能不希望转行。或者你在这件事上别无选择。但是第二,或许也是最重要的,算法学家并不主要担心性能上的常数差异。 4 如果一个程序需要两次,甚至十次,只要另一个程序完成,它可能仍然足够快,而较慢的程序(或语言)可能有其他可取的属性,如更具可读性。调整和优化在许多方面都是昂贵的,并且不是一项可以轻易完成的任务。不管是什么语言,重要的是你的程序如何扩展。如果你把输入量增加一倍,会发生什么?你的程序会运行两倍的时间吗?四次?更多?即使你只给输入增加一个微不足道的位,运行时间会加倍吗?如果您的问题变得足够大,这些差异将很容易战胜语言或硬件选择。在某些情况下,“足够大”并不需要那么大。你减少运行时间增长的主要武器是——你猜对了——对算法设计的深刻理解。*

*我们来做个小实验。启动一个交互式 Python 解释器,并输入以下内容:

>>> count = 10**5
>>> nums = []
>>> forin range(count):
...     nums.append(i)
...
>>> nums.reverse()

也许不是最有用的代码。它只是将一串数字附加到(最初的)空列表中,然后反转该列表。在更现实的情况下,这些号码可能来自外部来源(例如,它们可能是到服务器的传入连接),您希望以相反的顺序将它们添加到您的列表中,也许是为了优先考虑最近的号码。现在你有了一个想法:不要在末尾颠倒列表,难道你不能在开头插入数字吗?下面是一个简化代码的尝试(在同一个解释器窗口中继续):

>>> nums = []
>>> forin range(count):
...     nums.insert(0, i)

除非您以前遇到过这种情况,否则新代码可能看起来很有希望,但是请尝试运行它。你可能会注意到明显的减速。在我的电脑上,第二段代码完成的时间大约是第一段的 200 倍。 5 不仅速度慢,而且问题大小越大,速度越快。例如,尝试将count10**5增加到10**6。正如预期的那样,这将第一段代码的运行时间增加了大约 10 倍……但是第二个版本大约慢了两个数量级,比第一个版本慢了两千倍!正如您可能猜到的那样,随着问题变得越来越大,两个版本之间的差异只会越来越大,这使得在它们之间做出选择变得更加重要。

Image 这是一个线性与二次增长的例子,一个在第三章中详细讨论的话题。二次增长背后的具体问题在第二章的中list的“黑盒”边栏中的向量(或动态数组)讨论中有所解释。

一些先决条件

这本书面向两类人群:Python 程序员,他们想要加强他们的算法,以及学习算法课程的学生,他们想要对他们简单的算法教科书进行补充。即使你属于后一类,我也假设你对编程有一定的了解,尤其是对 Python。如果你没有,也许我的书Python 入门能帮上忙?Python 网站也有很多有用的资料,而且 Python 是一门非常容易学习的语言。前面几页有一些数学知识,但你不必是数学天才也能跟上课文。你将会遇到一些简单的加法和漂亮的概念,比如多项式、指数和对数,但是我会在我们进行的过程中解释这一切。

在进入神秘和奇妙的计算机科学领域之前,你应该准备好你的设备。作为一名 Python 程序员,我假设您有自己喜欢的文本/代码编辑器或集成开发环境——我不会干涉这些。当谈到 Python 版本时,这本书被写得相当独立于版本,这意味着大部分代码应该适用于 Python 2 和 3 系列。在使用向后不兼容的 Python 3 特性的地方,也会有关于如何在 Python 2 中实现算法的解释。(如果出于某种原因,您仍然坚持使用 Python 1.5 系列,那么大部分代码应该仍然可以工作,只是在这里或那里做了一些调整。)

获得您需要的东西

在一些操作系统中,比如 Mac OS X 和几种 Linux,Python 应该已经安装了。如果不是,大多数 Linux 发行版会让你通过某种形式的包管理器安装你需要的软件。如果你想或者需要手动安装 Python,你可以在 Python 网站上找到你需要的一切,http://python.org

这本书里有什么

这本书的结构如下:

  • **第一章:引言。**你已经经历了大部分。它概述了这本书。
  • **第二章:基础知识。**这涵盖了基本概念和术语,以及一些基础数学知识。除此之外,你学会了如何比以前更随意地使用你的公式,并且仍然得到正确的结果,使用渐近符号。
  • 第三章:计数 101。更多的数学——但我保证这真的是有趣的数学!有一些分析算法运行时间的基本组合学,以及对递归和递归关系的简单介绍。
  • **第四章:归纳与递归……与归约。**题目中的三个术语至关重要,而且联系紧密。这里我们使用归纳和递归,它们实际上是彼此的镜像,既用于设计新的算法,也用于证明正确性。我们还将稍微简要地看一下归约的思想,它作为一个共同的线索贯穿于几乎所有的算法工作。
  • 第五章:遍历:算法学的万能钥匙。遍历可以用归纳和递归的思想来理解,但在很多方面它是一种更具体的技术。本书中的几个算法只是简单的增强遍历,所以掌握这个概念会给你一个真正的跳跃。
  • **第六章:分裂,合并,征服。**当问题可以分解成独立的子问题时,你可以递归地解决这些子问题,通常会得到有效、正确的算法。这个原则有几个应用,并不是所有的都是显而易见的,它是一个非常值得获得的智力工具。
  • **第七章:贪婪是好事吗?证明一下!**贪婪算法通常很容易构造。甚至有可能制定一个通用方案,大多数(如果不是全部的话)贪婪算法都遵循这个方案,从而产生一个即插即用的解决方案。它们不仅易于构建,而且通常非常高效。问题是,很难证明他们是正确的(而事实往往并非如此)。这一章涉及一些著名的例子和一些构造正确性证明的更一般的方法。
  • **第八章:纠结的依赖和记忆化。**这一章是关于被称为动态编程的设计方法(或者说,历史上的问题)。这是一种很难掌握的先进技术,但也产生了该领域一些最持久的见解和优雅的解决方案。
  • **第九章:和埃德格及朋友从 A 地到 B 地。**与前三章的设计方法不同,现在的重点是一个特定的问题,有许多应用:在网络或图形中寻找最短路径。这个问题有很多变体,有相应的(漂亮的)算法。
  • **第十章:匹配、切割、流动。**比如说,你如何将学生与大学相匹配,从而最大限度地提高总体满意度?在一个网络社区中,你如何知道该信任谁?你如何找到一个道路网络的总容量?这些问题,以及其他几个问题,可以用一小类密切相关的算法来解决,它们都是最大流问题的变体,这将在本章中讨论。
  • **第十一章:难题和(有限的)马虎。**正如简介开头提到的,有些问题我们不知道如何有效解决,我们有理由认为这些问题在很长一段时间内都不会得到解决,也许永远不会。在这一章中,你将学习如何以一种新的方式应用可靠的缩减工具:不是用解决问题,而是展示它们是的。此外,我们来看看优化标准中的一点点(严格限制的)马虎是如何使问题更容易解决的。
  • **附录 A :踩到底:加速蟒蛇。**这本书的主要焦点是渐近效率——让你的程序随着问题的大小而伸缩自如。然而,在某些情况下,这可能还不够。本附录为您提供了一些可以让您的 Python 程序运行得更快的工具。有时比 ?? 快很多倍。
  • **附录 B :问题和算法列表。**本附录向您概述了书中讨论的算法问题和算法,并提供了一些额外信息来帮助您为手头的问题选择正确的算法。
  • **附录 C :图形术语和符号。**无论是在描述现实世界的系统还是在演示各种算法如何工作时,图表都是一种非常有用的结构。如果你以前没有接触过图形,这一章将带你浏览基本概念和术语。
  • **附录 D :练习提示。**顾名思义。

摘要

编程不仅仅是关于软件架构和面向对象的设计;这也是关于解决算法问题,其中一些真的很难。对于更普通的问题(例如找到从 A 到 B 的最短路径),您使用或设计的算法会对您的代码完成时间产生巨大影响,对于困难的问题(例如找到通过 A-Z 的最短路径),甚至可能没有有效的算法,这意味着您需要接受近似的解决方案。

这本书将教你几个著名的算法,以及帮助你创建自己的算法的一般原则。理想情况下,这将让您解决一些更具挑战性的问题,并创建可随问题大小适度伸缩的程序。在下一章,我们从算法学的基本概念开始,处理整本书都会用到的术语。

如果你好奇的话…

这一节你会在后面的章节中看到。它的目的是给你一些在正文中被忽略或掩盖的细节、皱纹或高级主题的提示,并为你指明进一步信息的方向。现在,我将让你参考本章后面的“参考资料”部分,它给了你关于正文中提到的算法书籍的细节。

练习

与前一部分一样,这是一个你会一次又一次遇到的问题。解答练习的提示可以在附录 D 中找到。这些练习通常与正文相联系,涵盖了正文中没有明确讨论但可能是有趣的或值得思考的要点。如果你真的想提高你的算法设计技能,你可能还想看看无数编程难题的来源。例如,有许多编程竞赛(在网上搜索应该会找到很多),其中许多张贴的问题,你可以玩。许多大型软件公司也有基于此类问题的资格测试,并在网上发布一些测试结果。

因为简介没有涵盖太多内容,所以我在这里只给你几个练习,让你了解一下接下来会发生什么:

  • 1-1.考虑以下陈述:“随着机器变得更快,内存变得更便宜,算法变得不那么重要。”你怎么看;这是真的还是假的?为什么呢?
  • 1-2.想办法检查两个字符串是否是彼此的变位词(比如"debit card""bad credit")。您认为您的解决方案的可扩展性如何?你能想到一个简单的、扩展性差的解决方案吗?

参考

applegate d .、bix by r .、chvátal v .、Cook w .和 Helsgaun k .最佳瑞典之旅。www.math.uwaterloo.ca/tsp/sweden/。访问时间为 2014 年 4 月 6 日。

科尔曼,T. H .,莱瑟森,C. E .,里维斯特,R. L .,和斯坦,C. (2009 年)。算法简介,第二版。麻省理工学院出版社。

Dasgupta,s .,Papadimitriou,c .和 Vazirani,U. (2006 年)。算法。麦格劳-希尔。

m . t . goodrich 和 r . Tamassia(2001)。算法设计:基础、分析和互联网实例。约翰·威利&儿子有限公司

赫特兰德法学博士(2008 年)。初学 Python:从新手到专业,第二版。阿普瑞斯。

Kleinberg,j .和 Tardos,E. (2005 年)。算法设计。爱迪生-韦斯利·朗曼出版公司。

Knuth 博士(1968 年)。基本算法,计算机编程的艺术第一卷。艾迪森-韦斯利。

———.(1969).半数值算法,计算机编程的艺术第二卷。艾迪森-韦斯利。

———.(1973).整理与查找,计算机编程艺术第三册。艾迪森-韦斯利。

———.(2011).组合算法,第一部分卷 4A 计算机编程艺术。艾迪森-韦斯利。

米勒,B. N .和拉努姆,D. L. (2005 年)。使用 Python 解决算法和数据结构问题。富兰克林·比德尔&联合公司。


戴维·阿普尔盖特、罗伯特·比克斯比、瓦切克·奇瓦尔塔尔、威廉·库克和克尔德·海尔格尚

让我们假设飞行不是一个选项。

克努特还因使用汇编代码来设计自己的抽象计算机而闻名。

4 我这里说的是常数乘法因子,比如执行时间翻倍或者减半。

5 参见第二章了解更多关于算法的基准测试和实证评估。**

二、基础知识

特蕾西:我不知道你在那里。

佐伊:算是说到点子上了。隐身——你可能听说过。

特蕾西:我想他们在 basic 中没有涉及到这一点。

——摘自萤火虫第 14 集《讯息》

在继续讨论构成本书主要内容的数学技术、算法设计原则和经典算法之前,我们需要了解一些基本的原则和技术。当你开始阅读下面的章节时,你应该清楚诸如“没有负循环的有向加权图”和“θ(nLGn)的运行时间”等短语的含义您还应该知道如何用 Python 实现一些基本的结构。

幸运的是,这些基本概念一点也不难理解。本章的两个主要主题是渐近符号,它让你专注于运行时间的本质,以及用 Python 表示树和图的方式。此外,还有一些实用的建议,告诉你如何安排节目时间,避免一些基本的陷阱。不过,首先,让我们看看我们算法学家在描述我们算法的行为时倾向于使用的抽象机器。

计算中的一些核心思想

20 世纪 30 年代中期,英国数学家艾伦·图灵发表了一篇名为《论可计算数及其在 Entscheidungsproblem 中的应用》的论文,并在许多方面为现代计算机科学奠定了基础。他的抽象概念图灵机已经成为计算理论的核心概念,很大程度上是因为它直观上容易理解。图灵机是一个简单的抽象设备,可以读取、写入和移动无限长的纸条。机器的实际行为各不相同。每个都是所谓的有限状态机:它有一组有限的状态(其中一些表示它已经完成),它读取的每个符号都可能触发读取和/或写入并切换到不同的状态。你可以把这个机器想象成一套规则。(“如果我在状态 4,看到一个 X ,我向左移动一步,写一个 Y ,切换到状态 9。”)虽然这些机器看起来很简单,但令人惊讶的是,它们可以用来实现任何人迄今为止能够想象到的任何形式的计算,大多数计算机科学家认为它们包含了我们所认为的计算的本质。

一个算法 是一个过程,由一组有限的步骤组成,可能包括循环和条件,解决一个给定的问题。图灵机是对一个算法到底解决了什么问题的正式描述, 2 并且这种形式主义经常在讨论哪些问题可以被解决的时候使用(要么完全解决,要么在合理的时间内解决,就像在本章后面和第十一章中讨论的那样)。然而,对于更细粒度的算法效率分析,图灵机通常不是首选。我们使用一大块可以直接访问的内存*,而不是沿着纸带滚动。由此产生的机器通常被称为随机存取机。*

*虽然随机存取机器的形式可能会变得有点复杂,但我们只需要知道一些关于其能力的限制,这样我们就不会在算法分析中作弊。该机器是标准单处理器计算机的抽象简化版本,具有以下特性:

  • 我们无法访问任何形式的并发执行;机器只是一条接一条地执行指令。
  • 标准的基本操作,如算术、比较和内存访问,都需要恒定(尽管可能不同)的时间。没有排序等更复杂的基本操作。
  • 一个计算机字(我们可以在恒定时间内处理的值的大小)不是无限的,但它足够大,可以寻址所有用于表示我们问题的内存位置,再加上我们变量的额外百分比。

在某些情况下,我们可能需要更具体,但这个机器草图目前应该可以了。

我们现在对什么是算法,以及运行算法的抽象硬件有了一点直觉。拼图的最后一块是一个问题的概念。就我们的目的而言,问题是输入和输出之间的关系。事实上,这比听起来要精确得多:一个关系,在数学意义上的,是一组对——在我们的例子中,哪些输出对于哪些输入是可接受的——通过指定这个关系,我们已经确定了我们的问题。例如,排序问题可以指定为两个集合 A 和 B 之间的关系,每个集合都由序列组成。 3 无需描述如何执行排序(这将是算法),我们可以指定哪些输出序列(B 的元素)是可接受的,给定一个输入序列(A 的元素)。我们将要求结果序列由与输入序列相同的元素组成,并且结果序列的元素按升序排列(每个元素都大于或等于前一个元素)。这里 A 的元素——也就是输入——被称为问题实例;关系本身就是实际问题。

为了让我们的机器处理问题,我们需要将输入编码为 0 和 1。这里我们不会太担心细节,但是这个想法很重要,因为运行时间复杂性的概念(如下一节所述)是基于知道一个问题实例有多大和多大,而这个大小就是对它进行编码所需的内存量。正如您将看到的,这种编码的确切性质通常无关紧要。

渐近符号

还记得第一章中对insert的例子吗?不知何故,在列表末尾添加条目比在前面插入条目更能适应列表的大小;参见list上附近的“黑匣子”边栏获得解释。这些内置操作都是用 C 编写的,但是假设您用纯 Python 重新实现了list.append;姑且武断地说,新版本比原版慢 50 倍。让我们假设你在一台非常慢的机器上运行你缓慢的、基于纯 Python append的版本,而快速的、优化的、基于插件的版本在一台比快 1000 倍的计算机上运行。现在插入版的速度优势是 5 万倍。您通过插入 100,000 个数字来比较这两种实现。你认为会发生什么?

直觉上,速度快的解决方案显然应该胜出,但是它的“快”只是一个不变的因素,它的运行时间比“慢”的解决方案增长得更快。对于手头的例子,在较慢的机器上运行的 Python 编码版本实际上将在另一台机器的一半时间内完成。让我们把问题的规模扩大一点,比如增加到 1000 万个数字。现在慢机上的 Python 版本会比快机上的 C 版本快 2000 倍。这就像跑一分钟和跑一天半的区别!

随着问题规模的增加,常数因子(例如,与通用编程语言性能和硬件速度相关)和运行时间的增长之间的区别在算法研究中至关重要。我们关注的是全局——解决问题的给定方法的独立于实现的特性。我们希望摆脱分散注意力的细节,深入核心分歧,但为了做到这一点,我们需要一些形式主义。

黑匣子:列表

Python 列表并不是传统计算机科学意义上的真正列表,这解释了为什么appendinsert更有效。一个经典的列表——所谓的链表——被实现为一系列的节点,每个节点(除了最后一个)都保存着对下一个节点的引用。一个简单的实现可能是这样的:

class Node: def __init__(self, value, next=None): self.value = value

您可以通过指定所有节点来构建一个列表:

>>> L = Node("a", Node("b", Node("c", Node("d")))) >>> L.next.next.value

这就是所谓的单链表;双向链表中的每个节点也将保持对前一个节点的引用。

Python 的list类型的底层实现有点不同。一个list基本上是一个单一的、连续的内存块,而不是几个相互引用的独立节点——这通常被称为一个数组。这导致了与链表的一些重要区别。例如,虽然遍历列表的内容对这两种类型都同样有效(除了链表中的一些开销),但在数组中直接访问给定索引处的元素要有效得多。这是因为可以计算元素的位置,可以直接访问正确的内存位置。然而,在链表中,人们必须从头开始遍历链表。

不过,我们遇到的差异与插入有关。在链表中,一旦知道要在哪里插入东西,插入是廉价的;无论列表包含多少个元素,都需要大致相同的时间。数组则不是这样:插入必须移动插入点右侧的所有元素,如果需要,甚至可能将所有元素移动到一个更大的数组中。追加的的一个具体解决方案是使用通常所说的一个动态数组,或向量*。 4 想法是分配一个太大的数组,然后每当它溢出的时候线性时间重新分配。这似乎使得追加和插入一样糟糕。在这两种情况下,我们都有不得不移动大量元素的风险。主要的区别在于,使用 append 时,这种情况较少发生。事实上,如果我们能够确保我们总是移动到一个比上一个大一个固定百分比(比如 20%甚至 100%)的数组,那么平均*成本(分摊到许多追加中)是恒定的。**

我一窍不通!

自 19 世纪晚期以来,渐近符号一直在使用(有一些变化),并且是分析算法和数据结构的基本工具。核心思想是将我们正在分析的资源(通常是时间,但有时也是内存)表示为一个函数,以输入大小作为其参数。例如,我们可以有一个运行时间为T(n)= 2.4n+7 的程序。

一个重要的问题随即产生:这里的单位是什么?无论我们用秒或毫秒来度量运行时间,还是用比特或兆字节来表示问题的大小,这似乎都无关紧要。然而,有些令人惊讶的答案是,它不仅微不足道,而且实际上根本不会影响我们的结果。我们可以用木星年来度量时间,用千克来度量问题的大小(大概是所用存储介质的质量),而这并不重要。这是因为我们忽略实现细节的初衷也适用于这些因素:渐近符号忽略了所有这些因素!(不过,我们通常假设问题大小是一个正整数。)

我们最终经常做的是让运行时间成为执行某个基本操作的次数,而问题大小要么是处理的项目数(例如要排序的整数数),要么在某些情况下,是以某种合理的编码方式对问题实例进行编码所需的位数。

9781484200568_unFig02-01.jpg

遗忘。 当然,这个断言是行不通的。( http://xkcd.com/379 )

Image 注意只要你是合理的,你如何将你的问题和解决方案编码成位模式通常对渐近运行时间没有什么影响。例如,避免用一元数字系统表示数字(1=1,2=11,3 = 111……)。

渐近符号由一串操作符组成,写为希腊字母。最重要的,也是我们将使用的,是 O (最初是一个 omicron,但现在通常称为“大 Oh”)、ω(ω)和θ(θ)。 O 操作符的定义可以作为其他两个操作符的基础。表达式 O ( g ),对于某个函数 g ( n ),表示一个函数的集合,一个函数 f ( n )如果满足以下条件则在这个集合中:存在一个自然数 n 0 和一个正常数 c 使得

f(n)≤??【CG】(n

对于所有 nn 0 。换句话说,如果我们被允许调整常数 c (例如,通过在不同速度的机器上运行算法),函数 g 将最终(即,在n0 处)变得比 f 大。示例见图 2-1 。

9781484200568_Fig02-01.jpg

图 2-1 。对于 n 大于 n 0 的值,T(n)小于 cn 2 ,所以 T(n)为 O(n 2

这是一个相当简单易懂的定义,尽管乍一看可能有点陌生。基本上, O ( g )就是增长速度不超过 g 的函数集合。例如,函数n2 在集合O(n2)中,或者,在集合符号中,n2O(n2)。我们经常简单的说n2 就是O(n2)。

n 2 并不比自身增长得快,这个事实并不特别有趣。也许更有用的是,无论是 2.4n2+7 还是线性函数 n 都不会。也就是说,我们两者都有

2.4n【2】+7”(

*和

nO(n2)。

第一个例子告诉我们,我们现在能够不用花里胡哨地描述一个函数;我们可以去掉 2.4 和 7,简单地将函数表示为 O ( n 2 ),这就给出了我们需要的信息。第二个向我们展示了 O 也可以用来表示宽松的限制:任何比 g 更好(也就是没有增长得更快)的函数都可以在 O ( g )中找到。

这与我们最初的例子有什么关系?嗯,事情是这样的,即使我们不能确定细节(毕竟它们取决于 Python 版本和您使用的硬件),我们也可以近似地描述操作:将 n 个数字附加到 Python 列表的运行时间是 O ( n ),而在其开头插入 n 个数字的运行时间是 O ( n 2 )。

另外两个,ω和θ,只是 O 的变体。ω是它的完全反义词:一个函数 f 在ω(g中)如果满足以下条件:存在一个自然数n0 和一个正常数 c 使得

f(n)≥??【CG】(n

对于所有 nn 0 。所以,其中 O 形成所谓的渐近上界,ω形成渐近下界

Image 我们的前两个渐近算子 O 和ω是彼此的逆:如果 fO ( g ,那么 g 是ω(f)。练习 2-3 要求你展示这个。

θ形成的集合只是另外两个的交集,即θ(g)=O(g)∩ω(g)。换句话说,如果满足以下条件,函数 f 在θ(g)中:存在自然数 n 0两个正常数 c 1c 2 使得

【1】()≤【f】()≤****

对于所有 nn 0 。这意味着 fg 具有相同的渐近增长。例如,3 n 2 + 2 是θ(n2),但是我们也可以写成 n 2 是θ(3n*2+2)。通过同时提供一个上界和一个下界,θ操作符是三个中信息最丰富的,我将尽可能使用它。

交通规则

虽然渐近算子的定义可能有点难以直接使用,但它们实际上导致了一些有史以来最简单的数学。您可以删除所有乘法和加法常数,以及函数中的所有其他“小部分”,这大大简化了事情。

作为处理这些渐近表达式的第一步,让我们看看一些典型的渐近类,或。表 2-1 列出了其中的一些,以及它们的名字和一些具有这些渐近运行时间的典型算法,有时也被称为运行时间复杂性。(如果你的数学有点生疏,你可以看看本章后面的边栏“快速数学复习”。)该表的一个重要特征是复杂性已经排序,因此每一行支配前一行:如果在表中发现 fg 高,那么 f 就是 O ( g )。 5

表 2-1 。渐近运行时间的常见示例

|

复杂性

|

名字

|

例子,评论

| | --- | --- | --- | | I(1) | 常数 | 散列表查找和修改(参见dict的“黑盒”侧栏)。 | | θ(LGn | 对数的 | 二分搜索法(见第六章)。对数底数不重要。 7 | | θ(n | 线性的 | 遍历一个列表。 | | θ(nLGn | 对数线性 | 任意值的最优排序(见第六章)。同θ(LGn!). | | ο(n^2 | 二次的 | 将 n 个对象相互比较(参见第三章)。 | | θ(n3 | 立方体的 | 弗洛伊德和沃肖尔的算法(见第八章和第九章)。 | | O ( nk ) | 多项式 | k 嵌套在 n 上的 for 循环(如果 k 是正整数)。对于任意常数k0。 | | φ(kn | 指数的 | 产生每一个 n 项的子集(k= 2;参见第三章。任意k1。 | | θ(n!) | 阶乘 | 产生 n 个值的每个排序。 |

Image 其实关系更严格: fo ( g ),其中“小哦”如果是“大哦”就是更严格的版本凭直觉,它不是“增长速度不快于”,而是“增长速度慢于”从形式上来说,它声明随着 n 增长到无穷大,f(n)/g(n)收敛到零。不过,你真的不需要担心这个。

任意多项式(即任意次幂k0,甚至是分数次)支配任意对数(即任意底数)任意指数(任意底数k1)支配任意多项式(见习题 2-5 和 2-6)。实际上,所有的对数都是渐近等价的——它们只有常数因子不同(见练习 2-4)。然而,多项式和指数分别根据它们的指数或基底具有不同的渐近增长。所以,n5 比n4 长得快,5 n 比 4 n 长得快。

该表主要使用θ符号,但术语多项式指数有点特殊,因为它们在将易处理的(“可解”)问题与难处理的(“不可解”)问题分开时发挥了作用,正如在第十一章中所讨论的。基本上,具有多项式运行时间的算法被认为是可行的,而指数运行时间通常是无用的。虽然这在实践中并不完全正确,但θ(n100)并不比θ(2n)更实用;在许多情况下,这是一个有用的区别。 6 因为这种划分,任何运行时间在 O ( nk ),对于任何 k > 0,都称为多项式,即使极限可能不紧。例如,即使二分搜索法(在第六章中bisect的“黑盒”边栏中解释)的运行时间为θ(LGn),它仍然被认为是一个多项式时间(或仅仅是多项式)算法。相反,任何ω(kn)的运行时间—甚至是一个,比如说,θ(n)!)—据说是指数级的。

现在我们已经对一些重要的增长顺序有了一个总体的了解,我们可以制定两个简单的规则:

  • In a sum, only the dominating summand matters.

    比如θ(n2+n3+42)=θ(n3)。

  • In a product, constant factors don’t matter.

    比如θ(4.2nLGn)=θ(nLGn)。

总的来说,我们尽量保持渐近表达式尽可能简单,尽可能多地删除不必要的部分。对于 O 和ω,我们通常遵循第三个原则:

  • Keep your upper or lower limits tight.

    换句话说,我们试图使上限低,下限高。例如,虽然技术上来说 n 2 可能是O(n3),但我们通常更喜欢更严格的限制,O(n2)。然而,在大多数情况下,最好的方法是简单地使用θ。

在算术表达式中,使用渐近表达式而不是实际值会使渐近表达式更加有用。尽管这在技术上是不正确的(毕竟每个渐近表达式都产生一组函数),但这是很常见的。比如θ(n2)+θ(n3)简单来说就是 f + g ,对于一些(未知的)函数 fg ,其中 f 为θ(n2g 即使我们找不到精确的和 f + g ,因为我们不知道精确的函数,我们可以找到渐近表达式来覆盖它,如下面两个“奖励规则”所示

  • θ(f)+θ(g)=θ(f+g
  • θ(f)θ(g)=θ(f**g

练习 2-8 要求你证明这些是正确的。

带着渐近线兜一圈

让我们看一些简单的程序,看看我们是否能确定它们的渐近运行时间。首先,让我们考虑这样的程序,其中(渐近)运行时间只随问题大小而变化,而不随问题实例的具体情况而变化。(下一节讨论如果实例的实际内容影响运行时间会发生什么。)这意味着,例如,if语句现在是相当不相关的。除了简单的代码块之外,重要的是循环。函数调用不会使事情变得复杂;只需计算调用的复杂度,并将其插入正确的位置。

Image 注意有一种情况下函数调用会让我们出错:当函数是递归的。该案例在第三章和第四章中处理。

无循环的情况很简单:我们在一个语句之前执行另一个语句,因此增加了它们的复杂性。比方说,我们知道对于一个大小为 n 的列表,调用 append 是θ(1),而调用 insert 在位置 0 是θ(n)。考虑下面的两行小程序片段,其中nums是大小为 n 的列表:

nums.append(1)
nums.insert(0,2)

我们知道直线首先需要恒定的时间。当我们到达第二行时,列表大小已经改变,现在是 n + 1。这意味着第二行的复杂度为θ(n+1),与θ(n)相同。这样,总的运行时间就是两个复杂度之和,θ(1)+θ(n)=θ(n)。

现在,让我们考虑一些简单的循环。这里有一个简单的for循环,遍历一个有 n 个元素(比如数字;比如seq = range(n) ): 8

s = 0
forin seq:
    s += x

这是sum函数的一个简单实现:它遍历seq并将元素添加到s中的初始值。这为seqn 个元素的每一个执行一个单一的恒定时间操作(s += x),这意味着它的运行时间是线性的,或者θ(n)。注意,恒定时间初始化(s = 0)由这里的循环控制。

例如,同样的逻辑适用于我们在列表(或集合或字典)理解和生成器表达式中发现的“伪装的”循环。下面的列表理解也具有线性运行时复杂性:

squares = [x**2 forin seq]

几个内置函数和方法中也有“隐藏的”循环。这通常适用于处理容器中每个元素的任何函数或方法,例如summap

当我们开始嵌套循环时,事情变得有点(但不是很多)棘手。假设我们要总结seq中元素所有可能的乘积;这里有一个例子:

s = 0
forin seq:
    forin seq:
        s += x*y

关于这个实现值得注意的一点是,每个产品将被添加两次。例如,如果42333都在seq,我们就把42*333333*42都加上。那其实不影响运行时间;这只是一个不变的因素。

现在的运行时间是多少?基本规则很简单:一个接一个执行的代码块的复杂性只是增加了。嵌套循环的复杂性是乘以。原因很简单:对于外部循环的每一轮,内部循环都被完全执行。在这种情况下,这意味着“线性乘以线性”,这是二次的。换句话说,运行时间是θ(nn)=θ(n2)。实际上,这个乘法规则意味着对于更高层次的嵌套,我们将只增加幂(即指数)。三个嵌套的线性循环给我们θ(n3),四个给我们θ(n4),以此类推。

当然,顺序和嵌套案例可以混合使用。考虑以下轻微的扩展:

s = 0
forin seq:
    forin seq:
        s += x*y
    forin seq:
        forin seq:
            s += x-w

可能不完全清楚我们在这里计算什么(我当然不知道),但是我们应该仍然能够使用我们的规则找到运行时间。z-循环运行线性次数的迭代,它包含一个线性循环,所以总复杂度是二次的,即θ(n2)。y-回路明明是θ(n)。这意味着 x 循环内部的代码块是θ(n+n2)。对于每一轮运行 n 次的x循环,执行整个程序块。我们用我们的乘法法则,得到θ(n(n+n2)=θ(n2+n3)=θ(n3),也就是三次。通过注意到y循环受z循环支配,可以忽略不计,给内部块一个二次运行时间,我们可以更容易地得出这个结论。“二次乘以线性”得出的是立方。

当然,这些循环不需要都重复θ(n)次。假设我们有两个序列,seq1seq2,其中seq1包含 n 元素,seq2包含 m 元素。下面的代码运行时间为θ(nm)。

s = 0
forin seq1:
    forin seq2:
        s += x*y

事实上,对于外部循环的每次迭代,内部循环甚至不需要执行相同的次数。这就是事情变得有点复杂的地方。在前面的例子中,例如 nm 不是仅仅乘以两个迭代计数,我们现在必须求和内循环的迭代计数。下面的例子应该很清楚这意味着什么:

seq1 = [[0, 1], [2], [3, 4, 5]]
s = 0
for seq2 in seq1:
    forin seq2:
        s += x

语句s += x现在被执行 2 + 1 + 3 = 6 次。seq2的长度给了我们内循环的运行时间,但是因为它是变化的,所以不能简单的乘以外循环的迭代次数。下面是一个更现实的例子,它重温了我们最初的例子——将序列中的每个元素组合相乘:

s = 0
n = len(seq)
forin range(n-1):
    forin range(i+1, n):
        s += seq[i] * seq[j]

为了避免将对象与自身相乘或者将相同的乘积相加两次,外部循环现在避免最后一项,内部循环只迭代外部循环当前考虑的之后的项。这实际上没有看起来那么令人困惑,但是发现这里的复杂性需要多一点小心。这是计数的重要情况之一,将在下一章讨论。 9

三个重要案例

到目前为止,我们一直假设运行时间是完全确定的,并且只取决于输入大小,而不取决于输入的实际内容。然而,这并不太现实。例如,如果您要构建一个排序算法,您可以这样开始:

def sort_w_check(seq):
    n = len(seq)
    forin range(n-1):
        if seq[i] > seq[i+1]:
            break
    else:
        return
    ...

在进入实际排序之前,会执行一个检查:如果序列已经排序,函数会简单地返回。

Image 注意如果循环没有被break语句提前结束,Python 中循环的可选else子句将被执行。

这意味着,无论我们的主排序是多么低效,如果序列已经排序,运行时间将总是线性的。一般来说,没有任何排序算法可以实现线性运行时间,这意味着这种“最佳情况”是一种异常——突然间,我们再也无法可靠地预测运行时间。解决这一难题的方法是更加具体。我们可以更精确地指定输入,而不是笼统地谈论一个问题,我们经常谈论三种重要情况中的一种:

  • 最好的情况。 这是当输入最适合你的算法时你得到的运行时间。例如,如果对sort_w_check的输入序列进行排序,我们将得到最好的运行时间,这将是线性的。
  • 最坏的情况。 这通常是最有用的情况——可能的最坏运行时间。这是有用的,因为我们通常希望能够对我们的算法的效率给出一些保证,并且这是我们通常能够给出的最好的保证。
  • 一般情况下。 这是一个棘手的问题,大部分时间我会避开它,但在某些情况下它会很有用。简单地说,它是运行时间的期望值,对于随机输入,具有给定的概率分布。

在我们将要使用的许多算法中,这三种情况具有相同的复杂性。当他们不这样做时,我们通常会遇到最糟糕的情况。然而,除非明确说明,否则无法对正在研究的是哪种情况做出假设。事实上,我们可能根本不会把自己限制在单一种类的输入上*。例如,如果我们想用一般的来描述sort_w_check 的运行时间会怎样?这仍然是可能的,但是我们不能相当精确。*

假设我们在检查后使用的主要排序算法是对数线性的;即它的运行时间为θ(nLGn*)。这是典型的,事实上,在排序算法的一般情况下是最佳的。我们算法的最好情况运行时间是θ(n),当检查发现一个排序序列时,最坏情况运行时间是θ(nLGn)。然而,如果我们想要给出运行时间的一般描述——对于任何类型的输入——我们根本不能使用θ符号。没有单一的函数描述运行时间;不同类型的输入有不同的运行时间函数,这些函数有不同的渐近复杂性,这意味着我们不能用一个θ表达式来概括它们。

解决方案?我们用 O 或ω来代替θ的“双界限”,只提供一个上限或下限。我们可以比如说sort_w_check的运行时间为 O ( n lg n )。这涵盖了最好和最坏的情况。同样,我们可以说它的运行时间为ω(n)。请注意,这些限制是我们尽可能严格的。

Image 注意使用我们的渐近算子来描述这里讨论的三种情况中的任何一种都是完全可以接受的。我们完全可以说sort_w_check的最坏情况运行时间是ω(nLGn),或者最好情况是 O ( n )。

算法的经验评估

本书主要关注的是算法设计及其近亲算法分析。然而,在构建现实世界的系统时,还有另一个重要的算法学科可能至关重要,那就是算法工程,高效实现算法的艺术。在某种程度上,算法设计可以被视为通过设计高效的算法来实现低渐进运行时间的一种方式,而算法工程则专注于减少渐进复杂性中的隐藏常数。

虽然我可能会在这里或那里提供一些关于 Python 算法工程的技巧,但是很难准确预测哪些调整和改进将为您正在处理的特定问题提供最佳性能——或者,实际上,为您的硬件或 Python 版本提供最佳性能。这些正是渐近线设计用来避免的怪癖。在某些情况下,这样的调整和修改可能根本不需要,因为你的程序可能已经足够快了。在许多情况下,你能做的最有用的事情就是去尝试和观察。如果你有一个你认为会改进你的程序的调整,那就试试吧!实现这个调整,并运行一些实验。有改善吗?如果这种调整使你的代码可读性更差,并且改进很小,那么这真的值得吗?

注意这部分是关于评估你的程序,而不是工程本身。关于加速 Python 程序的一些提示,参见附录 A 。

虽然所谓的实验算法学的理论方面——也就是说,实验评估算法及其实现——超出了本书的范围,但我会给你一些实用的入门技巧,应该可以让你走得很远。

Image 小贴士 1 如果可能的话,不要担心。

担心渐近复杂性可能很重要。有时候,这就是解决方案和实际上的解决方案之间的区别。然而,运行时间中的常数因素往往并不那么关键。首先尝试你的算法的简单实现,看看是否足够好。实际上,你甚至可以先尝试一个简单的算法;引用编程大师 Ken Thompson 的话,“当有疑问时,使用蛮力。”在算法学中,蛮力通常指的是一种直接的方法,尝试每一种可能的解决方案,运行时间见鬼去吧!如果成功了,就成功了。

Image 小贴士 2 计时的事情,用timeit

timeit模块设计用于执行相对可靠的计时。虽然获得真正可信的结果,比如你要在科学论文上发表的那些结果,需要大量的工作,但是timeit可以帮助你轻松获得“实践中足够好的”计时。这里有一个例子:

>>> import timeit
>>> timeit.timeit("x = 2 + 2")
0.034976959228515625
>>> timeit.timeit("x = sum(range(10))")
0.92387008666992188

你得到的实际时间值肯定不会和我的完全一样。如果您想对一个函数计时(例如,它可能是一个包装了部分代码的测试函数),从 shell 命令行使用timeit可能更容易,使用-m开关:

$ python -m timeit -s"import mymodule as m" "m.myfunction()"

使用timeit时有一件事你要小心。避免会影响重复执行的副作用。为了提高精度,timeit函数将多次运行您的代码,如果先前的执行影响了后面的运行,您可能就有麻烦了。例如,如果您对类似于mylist.sort()、、的事情计时,列表将只在第时间排序。在语句运行的其他几千次中,列表已经被排序了,这使得您的计时低得不切实际。例如,同样的注意适用于任何涉及到可能耗尽的生成器或迭代器的东西。您可以在标准库文档中找到关于该模块及其工作原理的更多详细信息。 10

Image 提示 3 要找到瓶颈,使用分析器。

猜测程序的哪个部分需要优化是一种常见的做法。这种猜测往往是错误的。与其胡乱猜测,不如让一个侧写师帮你找出答案!Python 附带了一些分析器变体,但是推荐使用 cProfile。它和timeit一样容易使用,但是给出了更多关于执行时间花费在哪里的详细信息。如果您的主函数是main,您可以使用 profiler 运行您的程序,如下所示:

import cProfile
cProfile.run('main()')

这将打印出程序中各种函数的计时结果。如果您的系统上没有 cProfile 模块,请使用profile来代替。同样,更多信息可在图书馆参考中找到。如果你对你的实现的细节不太感兴趣,而只是想在给定的问题实例上凭经验检验你的算法的行为,那么标准库中的trace模块会很有用——它可以用来计算每条语句被执行的次数。您甚至可以使用诸如 Python 调用图 之类的工具来可视化您代码的调用。 11

Image 提示 4 绘制你的结果。

在解决问题时,视觉化是一个很好的工具。查看性能的两个常见图表是、图 12 图,例如问题大小与运行时间的关系,以及箱线图,显示运行时间的分布。参见 1 图 2-2 中的示例。用 Python 绘图的一个很棒的包是matplotlib(可从http://matplotlib.org获得)。

9781484200568_Fig02-02.jpg

图 2-2 。可视化程序 A、B 和 C 的运行时间和问题大小 10-50

Image 小贴士 5 根据时间对比得出结论时要小心。

这个技巧有点模糊,但那是因为当根据计时实验得出哪种方式更好的结论时,有太多的陷阱。首先,你观察到的任何差异都可能是随机变化造成的。如果您使用的是诸如timeit之类的工具,这种风险会小一些,因为它会多次重复要计时的语句(甚至会多次运行整个实验,保持最佳运行)。尽管如此,仍然会有随机的变化,如果两个实现之间的差异不大于这种随机性的预期,您就不能真正得出它们不同的结论。(你也不能断定他们不是。)

Image 注意如果需要在千钧一发的时候得出结论,可以使用假设检验的统计技术。然而,出于实用的目的,如果差别很小,您不确定,那么您选择哪个实现可能并不重要,所以选择您最喜欢的实现。

如果要比较两个以上的实现,这个问题就更复杂了。正如《??》第三章、中所解释的那样,要比较的配对数量随着版本数量的增加而成二次方增加*,极大地增加了至少两个版本出现异常不同的可能性,这只是偶然的。(这就是所谓的多重比较的问题*。)这个问题有个统计解决方案,但是最简单实用的方法是用这两个有问题的实现重复这个实验。甚至有几次。他们看起来还是不一样吗?

第二,在比较平均值时存在问题。至少,您应该坚持比较实际计时的平均值。在进行计时实验时,为了得到更有意义的数字,通常的做法是将每个程序的运行时间归一化,用某个标准的简单算法的运行时间来除它。这确实很有用,但是在某些情况下会使你的结果没有意义。请参阅 Fleming 和 Wallace 的文章“如何避免用统计数据撒谎:总结基准测试结果的正确方法”,以获得一些提示。对于其他一些观点,你可以阅读巴斯特和韦伯的《不要比较平均值》,或者香橼等人最近的论文《调和或几何平均值:真的重要吗?

第三,你的结论可能不能一概而论。例如,在其他问题实例或其他硬件上运行类似的实验可能会产生不同的结果。如果其他人要解释或复制你的实验,重要的是你彻底记录你是如何进行实验的

提示 6 从实验中得出渐近性的结论时要小心。

如果你想对一个算法的渐近行为下结论,你需要分析它,就像本章前面所描述的。实验可以给你一些提示,但是它们本质上是有限的,渐近法处理任意大的数据量会发生什么。另一方面,除非你在理论计算机科学领域工作,渐近分析的目的是说明算法在实际问题实例上实现和运行时的行为,这意味着实验应该相关。

假设你怀疑一个算法有二次运行时间复杂度,但是你无法最终证明它。你能用实验来支持你的观点吗?如前所述,实验(和算法工程)主要处理常量因素,但有一种方法是*。主要问题是你的假设无法通过实验来验证。如果你声称算法是,比如说, O ( n 2 ),没有数据可以证实或反驳这一点。然而,如果你把你的假设变得更具体,它就变得可检验了。例如,根据一些初步结果,您可能认为在您的设置中运行时间永远不会超过 0.24n2+0.1n+0.03 秒。也许更实际的是,您的假设可能涉及给定操作执行的次数,您可以用跟踪模块来测试。这个一个可检验的——或者更确切地说,可反驳的——假设。如果你做了很多实验,却找不到任何反例,这在某种程度上支持了你的假设。有趣的是,你也间接地支持了算法是O(n2)的说法。*

*实现图形和树

第一章中的第一个例子,我们想在瑞典和中国导航,是典型的可以在算法学中最强大的框架之一中表达的问题——即。在许多情况下,如果你能把你正在做的事情公式化为一个图的问题,你至少已经成功了一半。如果你的问题实例以某种形式表现为,你就很有可能得到一个真正有效的解决方案。

图形可以代表各种各样的结构和系统,从交通网络到通信网络,从细胞核中的蛋白质相互作用到在线的人类相互作用。您可以通过添加额外的数据来增加它们的表现力,如重量距离,这使得尽可能充分利用他们的能力来表示诸如下棋或为一组人匹配尽可能多的工作等不同的问题成为可能。树只是一种特殊的图,所以大多数图的算法和表示也适用于树。然而,由于它们的特殊性质(它们是连通的,没有环),一些专门的和相当简单的表示和算法版本是可能的。有许多实用的结构,比如 XML 文档或目录层次结构,可以用树来表示,所以这种“特例”实际上是相当普遍的。

如果你对图形术语的记忆有些生疏,或者对你来说这是全新的,看一看附录 C 。以下是 ?? 最精彩的部分:

  • 一个图 G = ( VE )由一组节点V ,以及它们之间的E 组成。如果边有方向,我们说图是有向的
  • 中间有边的节点是与相邻的*。那边儿是接着事件来的俩人。与 v 相邻的节点是 v邻居。节点的是与其关联的边的数量。*
  • G = ( VE )的一个子图V 的一个子集和 E 的一个子集组成。 G 中的路径是一个子图,其中边连接序列中的节点,而不需要重新访问任何节点。一个循环就像一条路径,除了最后一条边将最后一个节点链接到第一个节点。
  • 如果我们将一个权重G 中的每条边相关联,我们说 G 是一个加权图。一条路或一个圈的长度是它的边权重之和,或者,对于未加权的图,就是边的数量。
  • 一个森林是无圈图,一个连通森林是一棵。换句话说,一个森林由一棵树或多棵树组成。

虽然用图的术语来表达你的问题会让你走得更远,但是如果你想实现一个解决方案,你需要以某种方式将图表示为数据结构。事实上,即使你只是想设计一个算法,这也是适用的,因为你必须知道在你的图形表示上不同操作的运行时间。在某些情况下,图形已经存在于您的代码或数据中,不需要单独的结构。例如,如果你正在写一个网络爬虫,通过跟随链接自动收集关于网站的信息,图就是网络本身。如果您有一个带有friends属性的Person类,是其他Person实例的列表,那么您的对象模型本身就是一个图形,您可以在其上运行各种图形算法。然而,有专门的方法来实现图形。

抽象地说,我们通常在寻找一种实现邻居函数的方法, N ( v ),这样N[v]就是v的邻居的某种形式的容器(或者,在某些情况下,仅仅是一个可迭代的对象)。像许多其他关于这个主题的书一样,我将集中讨论两个最著名的表示法,邻接表邻接矩阵,因为它们非常有用和通用。有关备选方案的讨论,请参阅本章后面的“多种表示”一节。

黑盒:字典和设置

大多数算法书中详细介绍了一种技术,通常被 Python 程序员认为是理所当然的,那就是散列。散列涉及从任意对象中计算一些通常看似随机的整数值。例如,该值可以用作数组的索引(需要进行一些调整以适应索引范围)。

Python 中的标准散列机制可通过hash函数获得,该函数调用对象的__hash__方法:

>>> hash(42) 42 >>> hash("Hello, world!")

这是字典中使用的机制,使用所谓的哈希表来实现。集合使用相同的机制实现。重要的是哈希值可以在基本恒定的时间内构建。它相对于哈希表的大小是常数,但作为被哈希对象大小的函数是线性的。如果在后台使用的数组足够大,使用哈希值访问它在一般情况下也是θ(1)。最坏的情况是θ(n),除非我们事先知道这些值,并且可以编写一个定制的散列函数。尽管如此,哈希在实践中非常有效。

这对我们来说意味着访问一个dictset的元素可以被假定为花费恒定的预期时间,这使得它们对于更复杂的结构和算法来说是非常有用的构建块。

注意,hash函数专门用于哈希表。对于散列的其他用途,比如在密码学中,有标准的库模块hashlib

邻接表等等

实现图的最直观的方法之一是使用邻接表。基本上,对于每个节点,我们可以访问它的邻居列表(或者集合或者其他容器或者可迭代的)。让我们以最简单的方式实现它,假设我们有 n 个节点,编号为 0。。。n–1。

Image 注意节点当然可以是任何对象,或者有任意的标签或名称。使用 0 范围内的整数。。。n–1 可以使许多实现变得更容易,因为节点编号可以很容易地用作索引。

每个邻接(或邻居)列表就是一个这样的数字列表,我们可以将列表本身放入一个大小为 n 的主列表中,通过节点号进行索引。通常,这些列表的排序是任意的,所以我们真正讨论的是使用列表来实现邻接。术语列表在这个上下文中主要是历史性的。在 Python 中,我们很幸运有一个单独的集合类型,这在很多情况下是更自然的选择。

关于将用于说明各种图形表示的示例,参见图 2-3 。

9781484200568_Fig02-03.jpg

图 2-3 。用于说明各种图形表示的示例图

Image 提示关于帮助你可视化你自己的图形的工具,参见本章后面的边栏“图形库”。

首先,假设我们已经对节点进行了编号,即 a = 0, b = 1,以此类推。这个图可以用一种简单的方式来表示,如清单 2-1 所示。为了方便起见,我将节点编号分配给了与图中节点标签同名的变量。当然,你可以直接处理这些数字。注释指出了哪个邻接集属于哪个节点。如果你愿意的话,花一分钟来确认这个表示确实与这个数字相对应。

清单 2-1 。一种简单的邻接集表示法

a, b, c, d, e, f, g, h = range(8)
N = [
    {b, c, d, e, f},    # a
    {c, e},             # b
    {d},                # c
    {e},                # d
    {f},                # e
    {c, g, h},          # f
    {f, h},             # g
    {f, g}              # h
]

Image 注意在 Python 2.7(或 3.0)之前的版本中,你会把 set 文字写成set([1, 2, 3])而不是{1, 2, 3}。注意,空集仍然被写成set(),因为{}是一个空字典。

名称N在这里被用来对应前面讨论的 N 函数。在图论中, N ( v )代表 v 的邻居的集合。类似地,在我们的代码中,N[v]现在是一组v的邻居。假设您已经在前面的交互式解释器中定义了N,那么现在您就可以摆弄这个图形了:

>>> in N[a]  # Neighborhood membership
True
>>> len(N[f])  # Degree
3

Image 提示如果你在一个源文件中有一些代码,比如清单 2-1 中的图形定义,并且你想像前面的例子一样交互地探索它,你可以用-i开关运行python,就像这样:

python -i listing_2_1.py

这将运行源文件并启动一个交互式解释器,该解释器从源文件停止的地方继续运行,并带有任何可用于实验的全局定义。

另一种可能的表示方法是用实际的邻接列表替换邻接集,这在某些情况下开销会小一些。这方面的一个例子,见清单 2-2 。现在可以使用相同的操作,除了成员检查现在是θ(n)。这是一个显著的减速,但当然,只有当你真的需要它时,这才是一个问题。(如果你的算法只是迭代邻居,使用集合对象不仅没有意义;这种开销实际上会对您的实现的持续因素造成损害。)

清单 2-2 。邻接表

a, b, c, d, e, f, g, h = range(8)
N = [
    [b, c, d, e, f],    # a
    [c, e],             # b
    [d],                # c
    [e],                # d
    [f],                # e
    [c, g, h],          # f
    [f, h],             # g
    [f, g]              # h
]

有人可能会说,这种表示实际上是一组邻接数组,而不是传统意义上的邻接列表,因为 Python 的列表类型实际上是幕后的动态数组;参见前面关于list的“黑匣子”侧栏。如果您愿意,您可以实现一个链表类型并使用它,而不是 Python 列表。这将允许您在每个列表的任意点上渐进地插入更便宜的内容,但是这是一个您可能不需要的操作,因为您可以很容易地在末尾追加新的邻居。使用list的优势在于它是一种调优良好的快速数据结构,而不是任何可以用纯 Python 实现的列表结构。

处理图表时一个反复出现的主题是,最佳表现取决于您需要对图表做什么。例如,使用邻接表(或数组)可以保持较低的开销,并让您有效地迭代任何节点 vN ( v )。然而,检查 uv 是否是邻居在其度的最小值上是线性的,如果图是密集的,也就是说,如果它有许多边,这可能是有问题的。在这种情况下,邻接集可能是正确的方法。

Image 提示我们也看到了从 Python 中间删除对象list的代价很高。然而,从list删除需要恒定的时间。如果你不在乎邻居的顺序,在调用pop方法之前,你可以通过用邻接表中最后一个邻居覆盖它们来在常量时间内删除任意邻居。

一个微小的变化是将邻居集合表示为排序列表。如果你不经常修改列表,你可以对它们进行排序,并使用二分法(参见《??》第六章中bisect的“黑盒”边栏)来检查成员资格,这可能会导致内存使用和迭代时间方面的开销稍小,但会导致成员资格检查的复杂度为θ(LGk),其中 k 是给定节点的邻居数量。(这个还是很低的。然而实际上,使用内置的set类型要简单得多。)

然而这个想法的另一个小调整是使用字典而不是集合或列表。在这个字典中,邻居就是关键字,你可以自由地将每个邻居(或外边缘)与一些额外的值相关联,比如边缘权重。清单 2-3 中的显示了这种情况,添加了任意的边权重。

清单 2-3 。带边权的邻接字典

a, b, c, d, e, f, g, h = range(8)
N = [
    {b:2, c:1, d:3, e:9, f:4},    # a
    {c:4, e:3},                   # b
    {d:8},                        # c
    {e:7},                        # d
    {f:5},                        # e
    {c:2, g:2, h:2},              # f
    {f:1, h:6},                   # g
    {f:9, g:8}                    # h
]

邻接字典版本可以像其他版本一样使用,具有额外的边权重功能:

>>> in N[a]  # Neighborhood membership
True
>>> len(N[f])  # Degree
3
>>> N[a][b]    # Edge weight for (a, b)
2

如果你愿意,你可以使用邻接字典,即使你没有任何有用的边权重或类似的东西,当然(使用,也许,None,或其他占位符代替)。这将为您提供邻接集的主要优势,但它也适用于没有集合类型的非常非常旧的 Python 版本。 14

到目前为止,包含邻接结构的主要集合——无论是列表、集合还是字典——都是一个列表,由节点号索引。一种更灵活的方法是使用 dict 作为主结构,这种方法允许我们使用任意的、可散列的节点标签。 15 清单 2-4 显示了包含邻接集的字典看起来会是什么样子。注意,节点现在用字符表示。

清单 2-4 。有邻接集的字典

N = {
    'a': set('bcdef'),
    'b': set('ce'),
    'c': set('d'),
    'd': set('e'),
    'e': set('f'),
    'f': set('cgh'),
    'g': set('fh'),
    'h': set('fg')
}

Image 注意如果你删除了清单 2-4 中的set构造函数,你最终会得到邻接字符串,它也可以作为不可变的字符邻接表工作,并且开销稍微低一些。这似乎是一个愚蠢的表示,但是正如我之前所说的,它取决于你程序的其余部分。你从哪里得到的图表数据?例如,它已经是文本的形式了吗?你打算如何使用它?

邻接矩阵

另一种常见的图形表示形式是邻接矩阵。主要区别如下:不是列出每个节点的所有邻居,我们有一个行(一个数组),其中每个可能的邻居有一个位置(也就是说,图中的每个节点有一个位置),并存储一个值,如TrueFalse,指示该节点是否确实是邻居。同样,最简单的实现是使用嵌套列表,如清单 2-5 所示。请注意,这同样要求节点编号从 0 到V–1。使用的真值是 1 和 0(而不是TrueFalse,只是为了让矩阵更易读。

清单 2-5 。用嵌套列表实现的邻接矩阵

a, b, c, d, e, f, g, h = range(8)

#     a b c d e f g h

N = [[0,1,1,1,1,1,0,0], # a
     [0,0,1,0,1,0,0,0], # b
     [0,0,0,1,0,0,0,0], # c
     [0,0,0,0,1,0,0,0], # d
     [0,0,0,0,0,1,0,0], # e
     [0,0,1,0,0,0,1,1], # f
     [0,0,0,0,0,1,0,1], # g
     [0,0,0,0,0,1,1,0]] # h

我们使用它的方式与邻接表/集略有不同。不是检查b是否在N[a]中,而是检查矩阵单元N[a][b]是否为真。此外,您不能再使用len(N[a])来查找邻居的数量,因为所有的行都是等长的。相反,使用sum:

>>> N[a][b]    # Neighborhood membership
1
>>> sum(N[f])  # Degree
3

邻接矩阵有一些有用的性质值得了解。首先,只要我们不允许自循环(也就是说,我们不使用伪码),对角线就是假的。此外,我们经常通过在表示中添加两个方向的边来实现无向图。这意味着无向图的邻接矩阵将是对称的。

扩展邻接矩阵以允许边权重是微不足道的:不存储真值,只存储权重。对于一条边( uv ,设N[u][v]为边权重 w ( uv )而不是True。通常,出于实际原因,我们让不存在的边获得一个无限的权重。这是为了保证它们不会被包括在最短路径中,只要我们能沿着存在的边找到一条路径。如何表示无穷大不一定是显而易见的,但是我们确实有一些选择。

一种可能是使用非法的权重值,例如None,或者如果所有权重都已知为非负,则使用-1。在许多情况下,使用一个真正大的值可能更有用。对于整数权重,您可以使用sys.maxint,即使它不能保证是最大的可能值(长整型可以更大)。然而,有一个值被设计用来表示浮点数中的无穷大:inf。在 Python 中,不能直接在这个名称下获得它,但是可以通过表达式float('inf')获得它。 16

清单 2-6 展示了一个用嵌套列表实现的权重矩阵可能是什么样子。我使用了与清单 2-3 中相同的权重,并使用了inf = float('inf')。请注意,对角线仍然全为零,因为即使我们没有自循环,权重也经常被解释为距离的一种形式,节点到自身的距离通常为零。

清单 2-6 。缺失边的无限权权矩阵

a, b, c, d, e, f, g, h = range(8)
inf = float('inf')

#       a    b    c    d    e    f    g    h

W = [[  0,   2,   1,   3,   9,   4, inf, inf], # a
     [inf,   0,   4, inf,   3, inf, inf, inf], # b
     [inf, inf,   0,   8, inf, inf, inf, inf], # c
     [inf, inf, inf,   0,   7, inf, inf, inf], # d
     [inf, inf, inf, inf,   0,   5, inf, inf], # e
     [inf, inf,   2, inf, inf,   0,   2,   2], # f
     [inf, inf, inf, inf, inf,   1,   0,   6], # g
     [inf, inf, inf, inf, inf,   9,   8,   0]] # h

当然,权重矩阵使得访问边权重变得很容易,但是,例如,成员检查和查找节点的度数,或者甚至迭代邻居,现在必须以稍微不同的方式来完成。你需要考虑无穷大的值。这里有一个例子:

>>> W[a][b] < inf   # Neighborhood membership
True
>>> W[c][e] < inf   # Neighborhood membership
False
>>> sum(1 forin W[a] if w < inf) - 1  # Degree
5

注意度和减去 1 是因为我们不想算对角线。这里的度数计算是θ(n),而隶属度和度数都可以很容易地用适当的结构在常数时间内找到。同样,你应该时刻记住你将如何使用你的图表并相应地表现它。

带 NUMPY 的专用阵列

NumPy 库有很多与多维数组相关的功能。对于图形表示,我们实际上不需要太多,但是 NumPy 数组类型非常有用,例如,用于实现邻接矩阵或权重矩阵。

其中为 n 个节点创建一个空的基于列表的权重或邻接矩阵,例如,像这样

>>> N = [[0]*10 for i in range(10)]

在 NumPy 中,您可以使用zeros功能:

>>> import numpy as np

然后可以使用逗号分隔的索引来访问各个元素,如A[u,v]所示。要访问一个给定节点的邻居,可以使用一个索引,如A[u]所示。

如果你有一个相对稀疏的图,只有一小部分矩阵被填充,你可以通过使用一个更加特殊的形式的稀疏矩阵来节省相当多的内存,在scipy.sparse模块中可以作为 SciPy 发行版的一部分获得。

NumPy 包从http://www.numpy.org开始提供。从http://www.scipy.org可以得到 SciPy。

请注意,您需要获得一个与您的 Python 版本兼容的 NumPy 版本。如果 NumPy 的最新版本还没有“跟上”您想要使用的 Python 版本,那么您可以直接从源代码库中编译和安装。

您可以在网站上找到关于如何下载、编译和安装 NumPy 的更多信息,以及关于其使用的详细文档。

实现树

任何通用的图形表示都可以用来表示树,因为树只是一种特殊的图形。然而,树本身在算法中起着重要的作用,并且已经提出了许多特殊用途的树结构。大多数树算法(甚至是搜索树上的操作,在第六章中讨论过)可以用一般的图形概念来理解,但是特殊的树结构可以使它们更容易实现。

最简单的方法是专门化有根的树的表示,树的每条边都指向下方,远离根。这种树通常代表数据集的层次化划分,其中根代表所有对象(可能保存在叶节点中),而每个内部节点代表在以该节点为根的树中作为叶找到的对象。您甚至可以直接使用这种直觉,使每个子树成为包含其子树的列表。考虑图 2-4 中所示的简单树。

9781484200568_Fig02-04.jpg

图 2-4 。一个示例树,突出显示了从根到叶的路径

我们可以用列表的列表来表示这棵树,就像这样:

>>> T = [["a", "b"], ["c"], ["d", ["e", "f"]]]
>>> T[0][1]
'b'
>>> T[2][1][0]
'e'

在某种程度上,每个列表都是匿名内部节点的邻居(或子)列表。在第二个例子中,我们访问根的第三个孩子,那个孩子的第二个孩子,最后是那个孩子的第一个孩子(图中突出显示的路径)。

在某些情况下,我们可能知道任何内部节点中允许的最大子节点数。例如,二叉树 树中每个内部节点最多有两个子节点。然后我们可以使用其他表示,甚至是每个孩子都有一个属性的对象,如清单 2-7 所示。

清单 2-7 。二叉树类

class Tree:
    def __init__(self, left, right):
        self.left = left
        self.right = right

您可以像这样使用 Tree 类:

>>> t = Tree(Tree("a", "b"), Tree("c", "d"))
>>> t.right.left
'c'

例如,您可以使用None来指示缺少的子节点,比如当一个节点只有一个子节点时。当然,您可以自由地将这些技术组合到您的核心内容中(例如,在每个节点实例中使用子列表或子集合)。

实现树的一种常见方式,尤其是在没有内置列表的语言中,是“第一个孩子,下一个兄弟”表示。这里,每个树节点都有两个“指针”,或者引用其他节点的属性,就像二叉树的情况一样。然而,第一个引用节点的第一个子节点,而第二个引用它的下一个兄弟节点,顾名思义。换句话说,每个树节点引用一个兄弟(其子节点)的链表,每个兄弟引用一个自己的链表。(参见本章前面关于列表的“黑盒”边栏,了解链表的简要介绍。)因此,对清单 2-7 中的二叉树稍加修改,我们就得到一棵多向树 ,如清单 2-8 所示。

清单 2-8 。一个多路树类

class Tree:
    def __init__(self, kids, next=None):
        self.kids = self.val = kids
        self.nextnext

这里单独的val属性只是为了在提供值时有一个更具描述性的名称,比如'c',而不是一个子节点。当然,你可以随意调整。以下是如何访问此结构的示例:

>>> t = Tree(Tree("a", Tree("b", Tree("c", Tree("d")))))
>>> t.kids.next.next.val
'c'

这是那棵树的样子:

9781484200568_unFig02-02.jpg

kidsnext属性被绘制成虚线箭头,而树的隐含边被绘制成实线。请注意,我做了一点手脚,没有为字符串"a""b"等绘制单独的节点;相反,我将它们视为其父节点上的标签。在更复杂的树结构中,除了 kids 之外,您可能还有一个单独的值字段,而不是将一个属性用于两个目的。

通常,与本例中的硬编码路径相比,您可能会使用更复杂的代码(包括循环或递归)来遍历树结构。你会在第五章中找到更多相关信息。在第六章中,你也会看到一些关于多路树和树平衡的讨论。

束状图案

当原型化甚至最终确定数据结构(比如树)时,拥有一个灵活的类会很有用,它允许您在构造函数中指定任意属性。在这些情况下,集束模式(由 Alex Martelli 在 Python 食谱中命名)可以派上用场。有许多实现它的方法,但它的要点如下:

class Bunch(dict): def __init__(self, *args, **kwds): super(Bunch, self).__init__(*args, **kwds)

这种模式有几个有用的方面。首先,它允许您通过提供命令行参数来创建和设置任意属性:

>>> x = Bunch(name="Jayne Cobb", position="Public Relations") >>> x.name

第二,通过子类化dict,你可以免费获得许多功能,比如迭代键/属性或者轻松检查属性是否存在。这里有一个例子:

>>> T = Bunch >>> t = T(left=T(left="a", right="b"), right=T(left="c")) >>> t.left {'right': 'b', 'left': 'a'} >>> t.left.right 'b' >>> t['left']['right'] 'b' >>> "left" in t.right True >>> "right" in t.right

当然,这种模式不仅仅在构建树的时候有用。您可以在任何需要灵活对象的情况下使用它,您可以在构造函数中设置该对象的属性。

众多的陈述

尽管有大量的图形表示在使用,大多数学习算法的学生到目前为止只学习了本章所涉及的两种类型(有变化)。Jeremy P. Spinrad 在他的书高效的图形表示,中写道,作为图形的计算机表示的研究者,大多数介绍性的文本对他来说是“特别令人恼火的”。他们对最著名的表示(邻接矩阵和邻接表)的正式定义大多是适当的,但是更一般的解释经常是错误的。他根据几个文本中的错误陈述,提出了以下斯特劳曼对图表表示的 17 评论:

在计算机中有两种表示图的方法:邻接矩阵和邻接表。使用邻接矩阵更快,但是它们比邻接表占用更多的空间,所以你将根据哪个资源对你更重要来选择一个或另一个。

正如 Spinrad 指出的,这些陈述在几个方面存在问题。首先,有许多有趣的表示图形的方式,不仅仅是这里列出的两种。比如有边列表(或边集),简单来说就是包含所有边作为节点对(甚至是特殊边对象)的列表;有关联矩阵,表示哪些边关联在哪些节点上(对多图有用);对于诸如树(前面描述过)和区间图(这里不讨论)之类的图形类型,有专门的方法。看看斯平拉德的书,你可能会需要更多的表述。第二,空间/时间权衡的想法很容易让人误解:有些问题用邻接表解决比用邻接数组更快,对于随机图,邻接表实际上比邻接矩阵使用更多的空间。

你应该考虑你的问题的具体情况,而不是依赖于简单的,笼统的陈述,比如前面的稻草人的评论。主要标准可能是你正在做的事情的渐近表现。比如在一个邻接矩阵中查找边( uv )是θ(1),而迭代 u 的邻居是θ(n);在邻接表表示中,两种操作都是θ(d(u)),也就是说,按照节点拥有的邻居数量的顺序。如果你的算法的渐近复杂度是相同的,不管是什么表示,你可以执行一些经验测试,就像本章前面讨论的那样。或者,在许多情况下,您应该简单地选择使您的代码清晰且易于维护的表示。

到目前为止还没有讨论的一种重要的图形实现更多的是一种非表示:许多问题都有一个固有的图形结构——甚至可能是一个树结构——我们可以对它们应用图形(或树)算法,而不需要显式地构造一个表示。在某些情况下,这种情况发生在程序外部。例如,当解析 XML 文档或遍历文件系统中的目录时,树结构就在那里,带有现有的 API。在其他情况下,我们自己在构造图,但它是隐式的。例如,如果您想找到给定魔方配置的最有效的解决方案,您可以定义一个魔方状态,以及用于修改该状态的操作符。即使您没有显式地实例化和存储所有可能的配置,可能的状态形成了一个隐式的图(或节点集),以改变操作符作为边。然后你可以使用一种算法,比如 A*或双向 Dijkstra(两者都在第九章中讨论过)找到到达解决状态的最短路径。在这种情况下,邻居函数 N ( v )将动态计算邻居,可能将它们作为集合或某种其他形式的可迭代对象返回。

我将在这一章提到的最后一种图是子问题图。这是一个相当深奥的概念,在讨论不同的算法技术时,我将多次重温。简而言之,大多数问题可以分解成子问题 : 更小的问题,这些问题通常具有非常相似的结构。这些构成了子问题图的节点,依赖关系(即哪些子问题依赖于哪些子问题)构成了边。虽然我们很少将图算法直接应用于这种子问题图(它们更多的是一种概念或心理工具),但它们确实为分而治之(第六章)和动态编程(第八章)等技术提供了重要的见解。

图库

本章描述的基本表示技术对于大多数图形算法编码来说可能已经足够了,特别是在一些定制的情况下。然而,有一些高级操作和操纵可能很难实现,比如临时隐藏或合并节点。有一些第三方库负责这些事情,其中一些甚至是作为 C 扩展实现的,这可能会带来额外的性能提升。它们使用起来也很方便,其中一些有现成的图形算法。虽然快速的网络搜索可能会找到最受支持的图形库,但是这里有几个可以帮助你开始:

  • 【网络 x】:http://networkx.lanl.gov
  • python-graph : http://code.google.com/p/python-graph
  • : https://gitorious.org/graphine/pages/Home
  • 图形工具 : http://graph-tool.skewed.de

还有 Pygr,一个图形数据库(https://github.com/cjlee112/pygr);加托,一个图形动画工具箱(http://gato.sourceforge.net);还有 PADS,一个图形算法的集合(http://www.ics.uci.edu/~eppstein/PADS)。

当心黑盒

虽然算法学家通常在一个相当抽象的层次上工作,但实际实现你的算法需要一些小心。在编程时,你一定会依赖那些不是你自己写的组件,依赖这样的“黑盒”而不知道它们的内容是一件危险的事情。在本书中,你会发现标有“黑盒”的边栏,简要讨论了作为 Python 一部分的各种算法,这些算法要么内置于语言中,要么可以在标准库中找到。我把这些包括进来是因为我认为它们很有启发性;它们告诉您一些关于 Python 如何工作的信息,并让您对一些更基本的算法有所了解。

然而,这些并不是你会遇到的唯一的黑匣子。一点也不。Python 和它所依赖的机制都使用了许多机制,如果你不小心的话,它们可能会让你犯错。总的来说,你的程序越重要,你就越应该不信任这样的黑箱,并寻求发现隐藏在背后的东西。在接下来的几节中,我将向您展示需要注意的两个陷阱,但是如果您没有从这一节学到任何东西,请记住以下几点:

  • 当性能很重要时,依靠实际的分析而不是直觉。您可能有隐藏的瓶颈,它们可能不在您怀疑的地方。
  • 当正确性至关重要时,你能做的最好的事情就是使用单独的实现,最好是由单独的程序员编写,多次计算你的答案。

后一种冗余原则被用在许多性能关键的系统中,也是 Foreman S. Acton 在他的书Real Computing make Real中给出的关于防止科学和工程软件中的计算错误的关键建议之一。当然,在每个场景中,您都必须权衡正确性和性能的成本与它们的价值。举个例子,就像我之前说的,如果你的程序足够快*,就没有必要优化它。*

*以下两节讨论两个完全不同的主题。第一个是关于隐藏的性能陷阱:看起来足够无害,但是可以将线性操作变成二次操作的操作。第二个是关于一个在算法书上不常讨论的话题,但需要注意的很重要,那就是用浮点数计算的诸多陷阱。

隐藏的正方形

考虑以下两种在列表中查找元素的方法:

>>> from random import randrange
>>> L = [randrange(10000) forin range(1000)]
>>> 42 in L
False
>>> S = set(L)
>>> 42 in S
False

它们都非常快,从列表中创建一个集合似乎没有意义——不必要的工作,对吗?嗯,看情况。如果你要做多次成员资格检查,可能会有回报,因为列表的成员资格检查是线性的,集合的成员资格检查是常数的。例如,如果您要逐渐向一个集合中添加值,并且每一步都检查值是否已经被添加了呢?这是你在整本书中会反复遇到的情况。使用列表会给你二次方的运行时间,而使用集合会是线性的。这是一个巨大的差异。教训是,为工作选择正确的内置数据结构很重要。

前面讨论的例子也是如此,关于使用 deque 而不是在列表的开始插入对象。但也有一些不太明显的例子会导致同样多的问题。例如,从为我们提供片段的来源开始,采用以下“显而易见”的方式逐步构建一个字符串:

>>> s = ""
>>> for chunk in string_producer():
...     s += chunk

这是可行的,并且由于 Python 中一些非常聪明的优化,它实际上运行得非常好,直到某个大小——但是随后优化失败了,并且您会遇到二次增长。问题是(在没有优化的情况下)您需要为每个+=操作创建一个新的字符串,复制前一个字符串的内容。你会在下一章看到为什么这类事情是二次的详细讨论,但是现在,要知道这是有风险的事情。更好的解决方案如下:

>>> chunks = []
>>> for chunk in string_producer():
...     chunks.append(chunk)
...
>>> s = ''.join(chunks)

你甚至可以这样进一步简化:

>>> s = ''.join(string_producer())

这个版本是高效的,原因与前面的附加示例一样。追加允许您按百分比进行过度分配,以便可用空间呈指数增长,并且追加成本在所有操作中平均(分摊)后保持不变。

然而,有二次运行时间设法隐藏得比这更好。例如,考虑以下解决方案:

>>> s = sum(string_producer(), '')
Traceback (most recent call last):
   ...
TypeError: sum() can't sum strings [use ''.join(seq) instead]

Python 抱怨并要求您使用''.join()来代替(这样做是正确的)。但是如果你使用列表呢?

>>> lists = [[1, 2], [3, 4, 5], [6]]
>>> sum(lists, [])
[1, 2, 3, 4, 5, 6]

这很有效,甚至看起来很优雅,但实际上不是。你看,在幕后,sum 函数并不太了解你要求和的内容,它必须一个接一个地做加法。这样,您就回到了字符串的+=示例的二次运行时间。这里有一个更好的方法:

>>> res = []
>>> for lst in lists:
...    res.extend(lst)

试着给两个版本计时。只要lists挺短就不会有太大差别,但是用不了多久sum版本就彻底被打了。

浮动的问题是

大多数实数没有精确的有限表示。浮点数的惊人发明让它们看起来像是真的一样,尽管它们给了我们强大的计算能力,但它们也会让我们犯错。大时代。在《??:计算机编程的艺术》第二卷中,Knuth 说,“浮点计算本质上是不精确的,程序员很容易滥用它,以至于计算出的答案几乎完全是‘噪音’” 18

Python 非常擅长对您隐藏这些问题,如果您正在寻求保证,这可能是一件好事,但它可能无法帮助您弄清楚真正发生了什么。例如,在当前版本的 Python 中,您将获得以下合理的行为:

>>> 0.1
0.1

当然看起来像是数字 0.1 被精确地表示了。除非你更了解情况,否则你可能会惊讶地发现这是而不是。试试 Python 的早期版本(比如 2.6),其中的黑盒稍微透明一些:

>>> 0.1
0.10000000000000001

现在我们有进展了。让我们更进一步(这里可以随意使用最新的 Python):

>>> sum(0.1 forin range(10)) == 1.0
False

哎哟!如果没有之前对 floats 的了解,这不是你所期望的。

事实是,整数可以用任何数字系统精确表示,无论是二进制、十进制还是其他数字。然而,真实的数字有点棘手。官方的 Python 教程在这方面有很精彩的一节, 19 ,大卫·戈德堡也写了一篇很棒很透彻的教程论文。如果你考虑如何将 1/3 表示为一个十进制数,这个基本概念应该很容易理解。你不能完全做到,对吗?如果你使用的是三进制数字系统(基数为 3),那么它很容易被表示为 0.1。

这里的第一个教训是永远不要比较浮点数是否相等。一般没什么意义。尽管如此,在许多应用中,如计算几何,你非常想这样做。相反,你应该检查它们是否大约相等。例如,您可以采用来自unittest模块的assertAlmostEqual的方法:

>>> def almost_equal(x, y, places=7):
...     return round(abs(x-y), places) == 0
...
>>> almost_equal(sum(0.1 forin range(10)), 1.0)
True

如果您需要精确的十进制浮点数,也可以使用一些工具,例如 decimal 模块。

>>> from decimal import *
>>> sum(Decimal("0.1") forin range(10)) == Decimal("1.0")
True

例如,如果您正在处理金融数据,需要精确计算特定的小数位数,则此模块可能是必不可少的。在某些数学或科学应用中,你可能会发现 Sage 这样的工具很有用: 20

sage: 3/511/7 + sqrt(5239)
13*sqrt(31) + 33/35

如您所见,Sage 象征性地进行数学运算,因此您可以获得精确的答案,尽管如果需要,您也可以获得小数近似值。然而,这种符号数学(或十进制模块)远不如使用内置硬件功能进行浮点计算有效。

如果您发现自己正在进行精度非常关键的浮点计算(也就是说,您不只是对它们进行排序之类的),那么前面提到的 Acton 的书是一个很好的信息来源。让我们简单地看一下他的例子:如果你减去两个几乎相等的子表达式,你很容易丢失有效数字。为了达到更高的准确性,你需要重写你的表达式。例如,考虑表达式sqrt(x+1)-sqrt(x),这里我们假设x非常大。要做的事情是摆脱危险的减法。通过乘以并除以sqrt(x+1)+sqrt(x),我们最终得到一个表达式,它在数学上等同于原始表达式,但是我们去掉了减法:1.0/(sqrt(x+1)+sqrt(x))。让我们比较一下这两个版本:

>>> from math import sqrt
>>> x = 8762348761.13
>>> sqrt(x + 1) - sqrt(x)
5.341455107554793e-06
>>> 1.0/(sqrt(x + 1) + sqrt(x))
5.3414570026237696e-06

正如你所看到的,即使表达式在数学上是等价的,它们给出了不同的答案(后者更准确)。

快速数学复习课程

如果你对表 2-1 中使用的公式不完全满意,这里有一个它们含义的快速概括:一个,像 x y ( xy 次方),基本上是 x 乘以自身 y 的倍数。更准确地说, x 以因子 y 的形式出现了多次。这里, x 称为底数y(或者有时为)。所以,比如 3 2 = 9。嵌套异能只是把它们的指数相乘:(32)4= 38。在 Python 中,你把幂写成x**y

一个多项式 只是几个幂的和,每个都有自己的常数因子。比如 9x³ 5+2x²+x+3。

可以有小数幂,也可以,作为一种逆:(x y)1/y=x。这些有时被称为,例如平方根是平方的倒数。在 Python 中,你可以使用来自math模块的sqrt函数或者简单地使用x**0.5得到平方根。

根是相反的,因为它们“消除”了异能的效果。对数是另一种倒数。每个对数都有固定的底数;算法中最常见的是以 2 为底的对数,写 log 2 或简称 lg。(以 10 为底的对数习惯上简单写成 log,而所谓的自然对数,以 e 为底,写成 ln)。对数给出了我们需要的给定底数的指数,所以如果 n = 2 k ,那么 lg n = k 。在 Python 中,可以使用math模块的log函数来获取对数。

阶乘,或 n !,计算为n×(n–1)×(n–2)…1。它可以用来计算 n 元素的可能排序数。第一个位置有 n 种可能性,对于其中的每一种,第二个位置还有n–1,依此类推。

如果这仍然像泥浆一样清晰,不要担心。你会在整本书中反复遇到幂和对数,在相当具体的环境中,它们的含义应该是可以理解的。

摘要

这一章从一些重要的基础概念开始,稍微松散地定义了算法、抽象计算机和问题的概念。接下来是两个主题,渐近符号和图形。渐近符号用于描述函数的增长;它让我们忽略不相关的加法和乘法常数,专注于占主导地位的部分。这允许我们抽象地评估算法运行时的显著特征,而不用担心给定实现的细节。三个希腊字母 O 、ω和θ给出了上限、下限和组合渐近极限,每一个都可以用于算法的最佳情况、最差情况或平均情况行为。作为对这一理论分析的补充,我给了你一些测试你的程序的简单指南。

图是抽象的数学对象,用来表示各种网络结构。它们由一组通过边连接的节点组成,边可以具有方向和权重等属性。图论有着丰富的词汇,其中很多都在附录 C 中进行了总结。本章的第二部分处理在实际的 Python 程序中表示这些结构,主要使用邻接表和邻接矩阵的变体,用listdictset的各种组合实现。

最后,有一部分是关于黑匣子的危险。你应该四处寻找潜在的陷阱——你在不知道它们如何工作的情况下使用的东西。例如,内置 Python 函数的一些相当直接的用法可以给你二次运行时间,而不是线性运行时间。对您的程序进行概要分析也许可以发现这样的性能问题。还有与准确性相关的陷阱。例如,不小心使用浮点数会给你不准确的答案。如果得到一个准确的答案是至关重要的,那么最好的解决方案可能是用两个独立实现的程序来计算,比较结果。

如果你好奇的话…

如果你想了解更多关于图灵机和计算基础的知识,你可能会喜欢查尔斯·佩佐德的《图灵注释》。它的结构是图灵原始论文的注释版,但大部分内容是 Petzold 对主要概念的解释,并附有大量例子。这是一个很好的话题介绍。关于计算的基础教科书,你可以看看 Lewis 和 Papadimitriou 的《计算理论的 ?? 元素》。关于算法学基本概念的通俗易懂的介绍,我推荐 Juraj Hromkovi 的算法冒险:从知识到魔法。关于渐近分析的更多细节,一本扎实的教科书,比如第一章中讨论的那种,可能是个好主意。科尔曼等人的书被认为是这类事情的很好的参考书。你当然也可以在网上找到很多好的信息,比如在维基百科上,但是你应该在依赖这些信息做任何重要的事情之前仔细检查一下。如果你想了解一些历史背景,你可以阅读唐纳德·克努特 1976 年的论文《大欧米茄和大欧米茄和大西塔》。

关于算法实验的风险和实践的一些细节,有几篇很好的论文,如“走向实验算法学的一个学科”、“关于比较分类器”、“不要比较平均值”、“如何不用统计说谎”、“在算法学中呈现来自实验的数据”、“通过箱线图可视化呈现数据”和“使用有限实验研究渐近性能”(细节在“参考资料”部分)。对于可视化数据,请看 Shai Vaingast 的开始 Python 可视化

有许多关于图论的教科书——有些相当专业和先进(例如 Bang-Jensen 和 Gutin、Bondy 和 Murty 或 Diestel 的教科书),有些甚至对于数学家新手来说也很可读(例如 West 的那本)。甚至有专门的书籍,比如关于图的类型(brandstdt 等人,1999 年)或图的表示(Spinrad,2003 年)。如果这是一个你感兴趣的话题,你应该很容易找到大量的资料,无论是在书上还是网上。更多关于使用浮点数的最佳实践,请看 Foreman S. Acton 的Real Computing make Real:防止科学工程计算中的错误

练习

2-1.当使用 Python 列表构造多维数组时,需要使用for循环(或者类似的东西,比如列表理解)。为什么用表达式[[0]*10]*10创建一个 10×10 的数组会有问题?

2-2.假设分配一块内存需要恒定的时间,这可能有点不切实际,只要你不初始化它(也就是说,它包含上次使用时留在那里的任意“垃圾”)。您想要一个由 n 个整数组成的数组,并且您想要跟踪每个条目是否未被单位化,或者它是否包含您放在那里的一个数字。这是一个您希望能够在恒定时间内对任何条目进行的检查。如果只有恒定的初始化时间,你会怎么做呢?你如何用它在常量时间内初始化一个空的邻接数组,从而避免必须的二次最小运行时间?

2-3.表明 O 和ω是彼此的倒数;即如果 fO ( g ),那么 g 是ω(f),反之亦然。

2-4.对数可以有不同的基数,但算法学家通常不会在意。要知道为什么,考虑一下等式 logb n=(loga n)/(loga b)。首先,你能看出为什么这是真的吗?第二,为什么这意味着我们通常不担心碱基?

2-5.证明任意递增的指数(θ(kn)对 k > 1)渐近支配任意多项式(θ(n j)对j0)。

2-6.证明任意多项式(即θ(NK,对于任意常数 k > 0)渐近支配任意对数(即θ(LGn))。(注意,这里的多项式包括,例如, k = 0.5 的平方根。)

2-7.研究或推测 Python 列表上各种操作的渐近复杂性,例如索引、项分配、反转、追加和插入(后两者在list的“黑盒”侧栏中讨论)。这些在链表实现中有什么不同?比如说list.extend呢?

2-8.证明表达式θ(f)+θ(g)=θ(f+g)和θ(f)θ(g)=θ(f**g)是正确的。还有,在 max(θ(f),θ(g)=θ(max(fg)=θ(f+g)。

2-9.在附录 C 中,你会找到一个关于树的陈述列表。证明它们是等价的。

2-10.设 T 是至少有三个节点的任意根树,其中每个内部节点正好有两个子节点。如果 Tn 片叶子,那么它有多少个内部节点?

2-11.表明有向无环图(DAG)可以具有任何底层结构。换句话说,任何无向图都可以是 DAG 的底层图,或者,给定一个图,你总是可以确定它的边的方向,使得得到的有向图是 DAG。

2-12.考虑下面的图形表示:您使用一个字典,让每个键是两个节点的一对(元组),相应的值设置为边权重。比如W[u, v] = 42。这种表示法的优点和缺点是什么?你能补充它以减轻缺点吗?

参考

阿克顿,F. S. (2005 年)。真正的计算成为现实:防止科学和工程计算中的错误。多佛出版公司。

j . bang-Jensen 和 g . Gutin(2002 年)。有向图:理论、算法和应用。斯普林格。

巴斯特和韦伯(2005 年)。不要比较平均值。《计算机科学讲义》第 3503 卷,67-76 页。斯普林格。

Bondy,J. A .和 Murty,苏联(2008 年)。图论。斯普林格。

brandstdt,a .,Le,V. B .,和 Spinrad,J. P. (1999 年)。图类:综述。SIAM 离散数学及其应用专论。工业和应用数学学会。

citron d .,Hurani a .,和 GNA drey a .(2006 年)。调和或几何意义:真的重要吗?ACM SIGARCH 计算机体系结构新闻,34(4):18–25。

迪斯特尔,R. (2005 年)。图论,第三版。斯普林格。

弗莱明和华莱士(1986 年)。如何不对统计撒谎:总结基准测试结果的正确方法。社区。美国计算机学会,29(3):218–221。

戈德堡博士(1991 年)。每个计算机科学家都应该知道的浮点运算。 ACM 计算调查 (CSUR),23(1):5–48。http://docs.sun.com/source/806-3568/ncg_goldberg.html

Hromkovi,J. (2009 年)。算法冒险:从知识到魔法。斯普林格。

Knuth 博士(1976 年)。大欧米茄,大欧米茄和大西塔。ACM SIGACT 新闻,8(2):18–24。

刘易斯和帕帕迪米特里乌(1998 年)。计算理论的要素,第二版。普伦蒂斯霍尔公司。

Martelli,a .,Ravenscroft,a .,和 Ascher,d .,编辑(2005 年)。 Python 食谱,第二版。奥赖利&联合公司。

Massart,D. L .,Smeyers-Verbeke,j .,Capron,x .,和 Schlesier,K. (2005 年)。通过箱线图直观地展示数据。 LCGC 欧洲,18:215–218。

McGeoch,c .,Sanders,p .,Fleischer,r .,Cohen,P. R .,和 pre COPD,D. (2002 年)。用有限试验研究渐近性能。计算机科学讲义,2547:94–126。

莫雷特,B. M. E. (2002 年)。走向实验算法的学科。《数据结构、近邻搜索和方法论:第五和第六次 DIMACS 实施挑战》, DIMACS:离散数学和理论计算机科学系列第 59 卷,第 197-214 页。美国数学学会。

Petzold,C. (2008 年)。带注释的图灵:艾伦·图灵关于可计算性和图灵机的历史性论文的导游。威利出版公司。

萨尔茨伯格(1997 年)。比较分类器:要避免的陷阱和推荐的方法。数据挖掘与知识发现,1(3):317–328。

桑德斯,P. (2002 年)。展示算法实验的数据。计算机科学讲义,2547:181–196。

斯平拉德,J. P. (2003 年)。高效的图形表示。菲尔兹研究所专论。美国数学学会。

图灵,A. M. (1937)。可计算数及其在 Entscheidungsproblem 问题上的应用。《伦敦数学会学报》,S2-42(1):230–265。

Vaingast,S. (2009 年)。开始 Python 可视化:制作可视化转换脚本。阿普瑞斯。

韦斯特博士(2001 年)。图论入门,第二版。普伦蒂斯霍尔公司。


1Entscheidungsproblem是戴维·希尔伯特提出的一个问题,基本上是问是否存在一种算法,可以决定一个数学陈述大体上是真还是假。图灵(以及他之前的阿隆佐·邱奇)表明这样的算法不可能存在。

2 也有解决不了任何问题的图灵机——根本就不会停下来的机器。这些仍然代表我们可能称之为的程序,但是我们通常不称它们为算法。

3 因为输入和输出是同一类型,我们实际上可以只指定 A 和 A 之间的关系

4 关于在序列的开始处插入对象的“开箱即用”解决方案,请参见第五章中deque处的黑盒侧栏。

5 对于“三次”和“多项式”行,这仅在 k ≥ 3 时成立。

有趣的是,一旦一个问题被证明有多项式解,一个有效的多项式解通常也能被找到。

7 我这里用的是 lg 而不是 log,不过两者都可以。

8 如果元素是整数,则每个+=的运行时间是常数。然而,Python 也支持大整数或长整数,当你的整数足够大时,它们会自动出现。这意味着你可以通过使用非常大的数字来打破恒定时间的假设。如果你使用的是浮动,那就不会发生(但是请参阅本章末尾关于浮动问题的讨论)。

9 剧透:这个例子的复杂度还是θ(n2)。

10

11

12 不,不是网络的那种,这在本章后面讨论。另一种是某些参数的每个值的测量图。

13 分别用 IDREFs 和 symlinks,XML 文档和目录层次结构其实就是一般的图。

14 集合是在 Python 2.3 中以sets模块的形式引入的。从 Python 2.4 开始,内置的集合类型就可用了。

15 这是一本带有邻接表的字典,是吉多·范·罗苏姆在他的文章《Python 模式——实现图》中使用的,这篇文章可以在https://www.python.org/doc/essays/graphs/的网上找到。

16 这个表达式保证从 Python 2.6 开始就可以使用。在早期版本中,特殊的浮点值是依赖于平台的,尽管float('inf')float('Inf')应该可以在大多数平台上工作。

17

18 这种麻烦已经不止一次导致灾难了(比如见www.ima.umn.edu/~arnold/455.f96/disasters.html)。

19

20 Sage 是 Python 中用于数学计算的工具,从http://sagemath.org开始可用。

21********

三、计数 101

人类最大的缺点是我们无法理解指数函数。

世界人口平衡顾问委员会 Albert A. Bartlett 博士

有一次,当著名数学家卡尔·弗里德里希·高斯上小学的时候,他的老师让学生们把 1 到 100 之间的所有整数相加(或者,至少,这是这个故事最常见的版本)。毫无疑问,老师预计这将占用他的学生一段时间,但高斯几乎立即产生了结果。这似乎需要闪电般的心算,但事实是,实际需要的计算非常简单;诀窍是真正理解问题。

读完上一章后,你可能会对这些事情有点厌倦。“显然,答案是θ(1),”你说。嗯,是的...但是假设我们要对从 1 到 n 的整数求和?接下来的章节将讨论一些类似的重要问题,这些问题在算法分析中会反复出现。这一章有时可能有点挑战性,但提出的想法是至关重要的,非常值得努力。他们会让这本书的其余部分更容易理解。首先,我会给你一个简单的解释和一些基本的操作方法。接下来是这一章的两个主要部分:一个是关于两个基本和(或者组合问题,取决于你的观点),另一个是关于所谓的递归关系,稍后你将需要分析递归算法。在这两者之间有一小段是关于子集、组合和排列的。

提示这一章有相当多的数学内容。如果这不是你的东西,你可能会想现在浏览一下,然后在需要的时候再回来读这本书的其余部分。(不过,本章中的一些观点可能会让本书的其余部分更容易理解。)

算术上的细微差别

在第二章,中,我解释了当两个循环嵌套并且内层循环的复杂度随着外层循环的迭代而变化时,你需要开始求和。事实上,在算法中,求和到处都是,所以你最好习惯于思考它们。让我们从基本符号开始。

更像希腊人

在 Python 中,您可以编写以下代码:

x*sum(S) == sum(x*y forin S)

用数学符号,你可以这样写:

Eqn3-01.jpg

你能看出为什么这个等式是正确的吗?如果你以前没有使用过这个资本 sigma,它可能看起来有点吓人。然而,它并不比 Python 中的sum函数更可怕;语法只是有点不同。西格玛本身表明我们正在做一个总和,我们把关于什么总和的信息放在它的上面、下面和右边。我们放在右边的(在前面的例子中, yxy )是要求和的值,而我们把要迭代的项的描述放在 sigma 下面。

除了迭代一个集合(或其他集合)中的对象,我们可以对总和进行限制,就像使用range(除了两个限制都包含在内)。一般表达式“sumf(I)forI=mton是这样写的:

Eqn3-02.jpg

Python 的对等用法如下:

sum(f(i) forin range(m, n+1))

对于许多程序员来说,将这些总和视为编写循环的数学方式可能更容易:

s = 0
forin range(m, n+1):
    s += f(i)

更简洁的数学符号的优势在于,它能让我们更好地了解正在发生的事情。

使用总和

上一节中的示例等式中,因子 x 被移到了总和中,这只是在处理总和时允许使用的几个有用的“操作规则”之一。这里总结了其中最重要的两个(为了我们的目的):

Eqn3-03.jpg

乘法常数 可以移入或移出总和。这也是上一节中的初始示例所说明的。这就是你在更简单的求和中多次看到的 分配性的相同规则:c(f(m)+...+f(n)=cf(m)+...+ 比照 ( n )。

Eqn3-04.jpg

不是将两个和相加,而是可以将它们相加的内容相加。这只是意味着,如果你要总结一堆东西,你怎么做并不重要;也就是说,

sum(f(i) forin S) + sum(g(i) forin S)

sum(f(i) + g(i) for i in S)一模一样。 1 这只是 结合律的一个实例。如果你想减去两个和,你可以使用相同的技巧。如果您愿意,可以假装将常数因子-1 移动到第二个和中。

两场比赛的故事

你可能会发现大量的算术题对你的工作有用,一本好的数学参考书可能会给你大多数算术题的答案。然而,有两个和,或者说组合问题,涵盖了你在本书中会遇到的大多数情况——或者说,实际上,是最基本的算法工作。

这些年来,我一直在反复解释这两个想法,使用了许多不同的例子和比喻,但我认为一种相当令人难忘(我希望可以理解)的方式是将它们作为两种形式的锦标赛

Image 注意实际上,在图论(一个完整的图,其中每条边都被指定了一个方向)中,锦标赛这个词有一个技术含义。我这里说的不是这个,虽然概念是相关的。

锦标赛有很多种,但让我们考虑两种非常常见的锦标赛,它们的名字都很吸引人。这就是 循环赛淘汰赛

在循环锦标赛(或者,具体来说,是一场单人循环锦标赛)中,每位参赛选手依次与其他选手相遇。那么问题就变成了,例如,如果我们有 n 骑士比武,我们需要多少场比赛或比赛?(如果你愿意,在这里替换你最喜欢的竞技活动。)在淘汰赛中,参赛者被安排成对,只有每对中的获胜者才能进入下一轮比赛。这里有更多的问题要问:对于 n 骑士,我们需要多少回合,总共会有多少场比赛?

握手

循环赛问题完全等同于另一个众所周知的难题:如果你让 n 个算法专家在一个会议上碰面,他们都握手,你能握多少次手?或者,等价地,一个有节点的完整图有多少条边(见图 3-1 )?这和你在任何“所有人对抗所有人”的情况下得到的计数是一样的。例如,如果你在地图上有 n 个位置,并且想要找到彼此最接近的两个,简单的(强力)方法是将所有点与所有其他点进行比较。为了找到这个算法的运行时间,你需要解决循环问题。(第六章中的给出了这个 最接近对问题的更有效的解决方案。)

9781484200568_Fig03-01.jpg

图 3-1 。一个完整的图,说明了循环赛,或握手问题

你很可能已经猜到会有个二次方的匹配。“所有人反对所有人”听起来非常像“所有时间”,或者n2。虽然结果确实是二次的,但是n2 的精确形式并不完全正确。想想看——首先,只有渴望死亡的骑士才会和自己决斗。如果加拉哈德爵士和兰斯洛特爵士有过交锋,兰斯洛特爵士没有必要还手,因为他们肯定都打过仗,所以一场比赛就够了。一个简单的“ n 乘以 n ”的解决方案忽略了这两个因素,假设每个骑士与每个骑士(包括他们自己)进行单独的比赛。解决方法很简单:让每个骑士与所有其他骑士骑士进行一场比赛,得到n(n–1),然后,因为我们现在已经对每场比赛进行了两次计数(每个参与的骑士一次),我们除以 2,得到最终答案,n(n–1)/2,这确实是θ(n2)。

现在,我们已经用一种相对简单的方式统计了这些匹配(或握手或地图点比较)——答案可能已经很明显了。好吧,摆在面前的可能也不完全是火箭科学,但是请放心,所有这些都是有意义的。。。现在我们用不同的方式来计算它们,结果肯定是一样的。

另一种计算方式是这样的:第一个骑士和其他的n–1 人决斗。在剩下的人中,第二个骑士与n–2 决斗。这种情况一直持续到倒数第二名,他与最后一名骑士进行最后一场比赛(然后他与剩下的零名骑士进行零场比赛)。这给了我们总和n–1+n–2+...+ 1 + 0,或者说sum(i for i in range(n))。我们只对每场比赛计数一次,所以总和必须产生和以前一样的计数:

Eqn3-05.jpg

我当然可以直接给你这个等式。我希望额外的包装对你来说更有意义。当然,你可以想出解释这个等式的其他方法(或者整本书中的其他方法)。例如,在本章开篇的故事中,高斯的洞见是,从 1 到 100 的总和可以“从外部”计算,将 1 与 100 配对,2 与 99 配对,以此类推,产生 50 对,总和为 101。如果你将其推广到从 0 到 n 的求和情况,你会得到和之前一样的公式。你能看出这一切与邻接矩阵对角线下方的左下半部分有什么关系吗?

Image 提示 An 算术 数列是任意两个连续数字之差为常数的和。假设这个常数是正的,那么总和永远是二次。其实就是 i * k * 之和,其中 i = 1。。。 n ,对于某个正常数 k ,永远是θ(nk+1)。握手和只是一个特例。

兔子和乌龟

假设我们的骑士有 100 人,而锦标赛的工作人员仍然对去年的循环赛感到有些疲惫。这很容易理解,因为应该有 4950 场比赛。他们决定引入(更有效的)淘汰制,并想知道他们需要多少场比赛。找到解决方案可能有点棘手...或者很明显,取决于你如何看待它。先从稍微棘手的角度来看。在第一轮中,所有的骑士都是成对的,所以我们有 n /2 场比赛。只有一半人进入第二轮,所以我们有 n /4 场比赛。我们继续减半,直到最后一场比赛,给我们的总数是n/2+n/4+n/8+...+ 1,或者相当于 1 + 2 + 4 +...+ n /2。稍后你会看到,这个和有很多应用,但是答案是什么呢?

接下来是非常明显的部分:在每场比赛中,一名骑士被击倒。除了获胜者之外,所有人都被淘汰(他们只被淘汰一次),所以我们需要n–1 场比赛,只留下一个男人(或女人)站着。在图 3-2 中,锦标赛结构被图示为一棵有根的树,其中每片叶子是一个骑士,每个内部节点代表一场比赛。换句话说:

Eqn3-06.jpg

9781484200568_Fig03-02.jpg

图 3-2 。一棵完全平衡的有根二叉树,有 n 片叶子和 n–1 个内部节点(根高亮显示)。该树可能是无向的,但是可以认为其边隐式地指向下方,如图所示

上限,h–1,是轮数,或者说 h 二叉树的高度,所以 2 h = n 。在这种具体的环境下,结果可能看起来并不奇怪,但它确实有点奇怪。在某种程度上,它形成了一个神话的基础,即活着的人比所有死去的人都多。即使神话是错的,也没那么牵强!人口增长大致呈指数增长,目前大约每 50 年翻一番。假设历史上我们有一个固定的倍增时间。 这不是真的,2 而是将就着。或者,为了进一步简化,假设每一代人的人口是上一代人的两倍。 3 那么,如果当前这一代由 n 个个体组成,那么之前的所有世代的总和,正如我们所看到的,将只有n*–1(当然,他们中的一些人可能还活着)。*

为什么二进制行得通

我们刚刚看到,当对 2 的幂求和时,你总是比 2 的下一个幂少 1。例如,1+2+4 = 8–1,或 1+2+4+8 = 16–1,依此类推。从一个角度来看,这正是二进制计数有效的原因。一个二进制数是一串 0 和 1,每个 0 和 1 决定了一个给定的 2 的幂是否应该包含在一个和中(从最右边的 2 0 = 1 开始)。例如,11010 就是 2 + 8 + 16 = 26。将这些幂的第一个 h 相加相当于一个类似于 1111 的数,其中 h 为 1。这就是我们对这些 h 数字的了解,但幸运的是,如果这些数字的总和为n–1,那么下一次幂将正好是 n 。比如 1111 是 15,10000 是 16。(练习 3-3 要求你展示这个属性允许你用二进制数表示任何正整数。)

这是关于加倍的第一课:一棵完美平衡的二叉树(即一棵所有内部节点都有两个子节点且所有叶子深度相同的有根树)有n–1 个内部节点。然而,在这个问题上,还有几个教训在等着你。例如,我还没有提到标题中提到的兔子和乌龟。

兔子和乌龟分别代表树的宽度和高度。这个图有几个问题,不要太较真,但思路是,互相比较(其实是作为对方的函数),一个长得很慢,一个长得极快。我已经说过,n= 2h,但是我们也可以很容易地使用逆对数,它来自于二进制对数的定义:h= LGn;参见图 3-3 的图示。

9781484200568_Fig03-03.jpg

图 3-3 。完全平衡的二叉树的高度和宽度(叶子的数量)

这两者之间的差异究竟有多大很难理解。一种策略是简单地接受它们是极其不同的——这意味着对数时间算法是超级甜蜜的,而指数时间算法是完全虚假的——然后尽可能地找出这些差异的例子。让我给你举几个例子开始吧。首先让我们做一个我喜欢称之为“思考一个粒子”的游戏我想到可见宇宙中的一个粒子,你试着猜是哪一个,只用是/否的问题。好吗?开枪!

这个游戏可能看起来完全疯狂,但我向你保证,这与实用性(比如跟踪哪些粒子已经被排除在外)的关系要大于替代物的数量。为了稍微简化这些实际问题,让我们改为“想一个数字”。对于我们正在谈论的粒子数量,有许多估计,但是 10 个 90 个(也就是说,一个 1 后面跟着 90 个 0)可能会相当多。你甚至可以自己玩这个游戏,用 Python:

>>> from random import randrange
>>> n = 10**90
>>> p = randrange(10**90)

你现在有了一个未知粒子(粒子编号p),你可以用是/否问题来研究它(不要偷看!).例如,一个相当没用的问题可能如下:

>>> p == 52561927548332435090282755894003484804019842420331
False

如果你玩过“20 个问题”,你可能会发现这里的缺陷:我没有得到足够的“物有所值”对于一个是/否问题,我所能做的就是将剩下的选项减半。比如说:

>>> p < n/2
True

现在我们有进展了!事实上,如果你玩对了牌(抱歉混淆了隐喻——或者更确切地说,是游戏),并且一直将候选人的剩余区间减半,你实际上可以在不到 300 个问题中找到答案。你可以自己计算一下:

>>> from math import log
>>> log(n, 2) # base-two logarithm
298.97352853986263

如果这看起来很平凡,让它沉淀一分钟。通过只问是/否的问题,你可以在大约五分钟内确定可见宇宙中的任何粒子!这是为什么对数算法如此超级可爱的一个经典例子。(现在试着说十遍“对数算法”,快。)

Image 这是 二等分,或者说二分搜索法的一个例子,最重要也是最知名的对数算法之一。这将在第六章的模块的“黑盒”侧栏中进一步讨论。

现在让我们转向对数的伪反面,思考同样怪异的指数。任何一个例子都会自动成为另一个的例子——如果我让你从一个粒子开始,然后重复加倍,你会很快填满整个可观测的宇宙。(正如我们所见,这需要大约 299 倍。)这只是老 小麦和棋盘问题的一个稍微极端一点的版本。如果你在棋盘的第一格放一粒小麦,第二格放两粒,第三格放四粒,依此类推,你会得到多少粒小麦? 4 最后一个方块中的颗粒数将是 2 63 (我们从 2 0 = 1 开始计算)根据图 3-2 所示的总和,这意味着总数将是 264–1 = 18,446,744,073,709,551,615,或者,对于小麦来说,大约是 5 ^ 10 那是一大笔谷物——是世界年产量的几百倍!现在想象一下,我们不是在处理谷物,而是在处理时间。对于一个问题大小 n ,你的程序使用 2n 毫秒。对于 n = 64,程序将运行 584,542,046 年*!为了完成今天的工作,这个程序必须在脊椎动物编写代码之前运行很久。指数级增长可能会很可怕。*

*现在,我希望你开始明白指数和对数是如何互为倒数的。然而,在离开这一部分之前,我想谈一谈我们在处理龟兔赛跑时出现的另一个二元性:从 1 到 n 的倍增数当然与从 ?? 到 1 的减半数相同。这是显而易见的,但是当我们开始研究递归的时候,我会回到这个话题,这个想法会很有帮助。看看图 3-4 。该树表示从 1(根节点)到 n (第 n 叶)的加倍,但我也在节点下添加了一些标签,表示从 n 到 1 的减半。当处理递归时,这些量级将代表问题实例的一部分,以及一组递归调用所执行的相关工作量。当我们试图计算出总工作量时,我们将同时使用树的高度和每一层完成的工作量。我们可以将这些值视为沿树向下传递的固定数量的令牌。随着节点数量加倍,每个节点的令牌数量减半;每一关的代币数量保持为 n 。(这类似于练习 2-10 提示中的冰淇淋甜筒。)

9781484200568_Fig03-04.jpg

图 3-4 。通过二叉树的各级向下传递 n 个令牌

Image 提示一 几何(或指数 ) 级数ki 之和,其中 i = 0... n ,为某常数 k 。如果 k 大于 1,那么总和永远是θ(kn+1)。加倍和只是一个特例。

子集、排列和组合

如果你读过前一节,那么长度为 k 的二进制字符串的数量应该很容易计算。例如,你可以把字符串想象成一棵完美平衡的二叉树中从根到叶的方向。字符串长度 k 将是树的高度,可能的字符串数量将等于叶子的数量,2 k 。另一种更直接的方式是考虑每一步的可能性数量:第一位可以是 0 或 1,对于这些值中的每一个,第二个也有两种可能性,依此类推。就像 k 嵌套for循环,每个循环运行两次迭代;总计数还是 2k

伪多项式

好词,嗯?它是某些具有指数运行时间的算法的名称,这些算法“看起来”具有多项式运行时间,甚至在实践中可能也是如此。问题是,我们可以将运行时间描述为许多事情的函数,但我们为那些运行时间是输入大小的多项式的算法保留“多项式”标签,输入大小是给定实例在某种合理编码中所需的存储量。让我们考虑一下质数检查的问题,或者回答“这个数是质数吗?”这个问题有多项式解,但并不完全明显...攻击它的完全显而易见的方法实际上产生了一个非多项式解。

以下是我尝试的一个相对直接的解决方案:

def is_prime(n): for i in range(2,n): if n % i == 0: return False

这里的算法是遍历所有小于 n 的正整数,从 2 开始,检查它们是否除以 n 。如果其中一个有, n 不是素数;否则就是。这可能看起来像一个多项式算法,实际上它的运行时间是θ(n)。问题是 n 不是合法的问题大小!

n 中将运行时间描述为线性肯定是有用的,我们甚至可以说它是多项式...在 n 。但是这并没有给我们权利说它是多项式的...句号。由 n 组成的问题实例的大小不是 n ,而是编码 n 所需的比特数,如果 n 是 2 的幂,则大约是 lg n + 1。对于任意正整数,实际上是floor(log(n,2))+1

我们姑且称这个问题大小(位数) k 。于是我们大致有了n= 2k–1。我们宝贵的θ(n)运行时间,当改写为实际问题大小的函数时,就变成了θ(2k),显然是指数型的。 5 还有其他类似的算法,它们的运行时间只有在被解释为输入中数值的函数时才是多项式。(一个例子是第八章中讨论的背包问题的解决方案。)这些都叫做伪多项式

与子集的关系是非常直接的:如果每个位表示 size- k 集合中对象的存在或不存在,则每个位串表示 2 个 k 可能子集之一。也许最重要的结果是,任何需要检查输入对象的每个子集的算法都必然具有指数级的运行时间复杂度。

虽然子集对于算法学家来说是必不可少的,但是排列和组合可能更不重要。不过,你可能会碰到它们(没有它们就不会数到 101),所以这里有一个如何数它们的快速纲要。

排列是排序。如果人们排队买电影票,我们能排多少队?每一个都是队列的排列。如第二章所述, n 项的排列数是 n 的阶乘,或者说 n !(包括感叹号,读作“ n 阶乘”)。可以算出 n !通过将 n (第一位置的可能人数)乘以n–1(第二位置的剩余选项)和n–2(第三...),以此类推,直到 1:

Eqn3-07.jpg

没有多少算法的运行时间涉及 n !(尽管我们会在第六章的中讨论排序的极限时再讨论这个计数)。一个愚蠢的例子,预期运行时间为θ(nn!)是排序算法 bogosort ,由反复将输入序列混洗成随机顺序,并检查结果是否排序组成。

组合是排列和子集的近亲。从一组 n 中抽取的 k 元素的组合,有时被写成 C ( nk ),或者,对于那些数学爱好者来说:

Eqn3-08.jpg

这也被称为 二项式系数(或者有时是选择函数),读作“ n 选择 k ”虽然阶乘公式背后的直觉相当直观,但如何计算二项式系数就不那么明显了。 6

想象一下(再一次)你有 n 个人排队看电影,但是电影院只剩下 k 个座位。有多少个大小为 k 的子集有可能进入 ???那正是 C ( nk ),当然,这个比喻在这里可能会为我们做一些工作。我们已经知道我们有 n !整条生产线的可能订单。如果我们只计算所有这些可能性,并输入第一个 k 呢?唯一的问题是我们已经对子集计数太多次了。在许多排列中,某一群朋友可能站在队伍的最前面;事实上,我们可以允许这些朋友站在他们的任何一个 k !可能的排列,这一行的其余部分可以站在他们的任何(nk)!可能的排列而不影响谁能进去。这给了我们答案!

Eqn3-09.jpg

这个公式只是计算了这条线( n )所有可能的排列!)并除以我们计算每个“获胜子集”的次数,如前所述。

Image 关于计算二项式系数的不同观点将在第八章关于动态规划中给出。

注意,我们在这里选择了大小为 k子集,这意味着选择而不替换。如果我们只是抽签 k 次,我们可能会不止一次地抽取同一个人,实际上是在候选人名单中“替换”他们。那么可能结果的数量将简单地是 nk 。事实上, C ( nk )计算大小为 k 的可能子集的数量,而 2 n 计算可能子集的总数,这给了我们以下美丽的等式:

Eqn3-10.jpg

这些组合物体就是这样。是时候做一个稍微有点令人费解的展望了:求解引用自身的方程!

Image 提示对于大多数数学来说,交互式 Python 解释器作为计算器相当方便; math模块包含许多有用的数学函数。然而,对于像我们在本章中所做的符号操作来说,这并不是很有帮助。不过,Python 也有符号数学工具,比如 Sage(可从http://sagemath.org获得)。如果你只是需要一个快速工具来解决一个特别讨厌的求和或递归问题(见下一节),你可能想看看 Wolfram Alpha ( http://wolframalpha.com)。你只需输入总数或其他数学问题,答案就会跳出来了。

递归和递归

我打算 假设你至少有一些关于递归的经验,尽管我会在这一节给你一个简短的介绍,甚至在第四章给你更详细的介绍。如果对你来说这是一个完全陌生的概念,在网上或一些基础编程教科书中查找它可能是一个好主意。

递归的特点是函数直接或间接地调用自己。下面是一个简单的例子,说明如何递归地对一个序列求和:

def S(seq, i=0):
    if i == len(seq): return 0
    return S(seq, i+1) + seq[i]

理解这个函数如何工作和计算它的运行时间是两个密切相关的任务。功能非常简单:参数i表示求和从哪里开始。如果超出了序列的结尾(基本情况*,防止无限递归),函数简单地返回 0。否则,它将位置i的值加到剩余序列的总和上。除了递归调用之外,我们在每次执行S时都有固定的工作量,并且它对序列中的每一项都执行一次,所以很明显运行时间是线性的。不过,让我们来研究一下:*

def T(seq, i=0):
    if i == len(seq): return 1
    return T(seq, i+1) + 1

这个新的T函数实际上与S具有相同的结构,但是它处理的值是不同的。不像S返回子问题的解决方案,而是返回找到那个解决方案的成本。在这种情况下,我刚刚计算了执行if语句的次数。在一个更加数学化的设置中,您可以计算任何相关的操作,例如,使用θ(1)而不是 1。让我们来看看这两个函数:

>>> seq = range(1,101)
>>> s(seq)
5050

你知道吗,高斯是对的!我们来看看运行时间:

>>> T(seq)
101

看起来差不多。这里,大小 n 是 100,所以这是 n +1。这似乎应该在总体上成立:

>>> forin range(100):
...     seq = range(n)
...     assert T(seq) == n+1

没有错误,所以这个假设看起来似乎有点道理。

我们现在要做的是如何找到函数的非递归版本,比如T,给我们递归算法明确的运行时间复杂度。

用手做

为了从数学上描述递归算法的运行时间,我们使用递归方程,称为递归关系。如果我们的递归算法像上一节的S,那么递归关系的定义有点像T。因为我们正朝着一个渐近的答案努力,我们不关心常数部分,我们隐含地假设T(k)=θ(1),对于某个常数 k 。这意味着我们可以在建立方程时忽略基本情况(除非它们花费恒定的时间量),对于S,我们的 T 可以定义如下:

Eqn3-11.jpg

这意味着计算S(seq, i)所需的时间,也就是 T ( n ),等于递归调用S(seq, i+1)所需的时间,也就是T(n–1),加上访问所需的时间seq[i],这个时间是常数,或者θ(1)。换句话说,我们可以在恒定时间内将问题简化为更小的版本,从大小 nn–1,然后解决更小的子问题。总时间是这两个操作的总和。

Image 如你所见,对于递归之外的额外工作(即时间),我用 1 而不是θ(1)。我也可以用θ。只要我渐近地描述结果,就没多大关系。在这种情况下,使用θ(1)可能有风险,因为我将建立一个和(1 + 1 + 1...),如果它包含渐近符号(即θ(1)+θ(1)+θ(1),就很容易错误地将这个和简化为常数...).

现在,我们如何求解这样一个方程?线索在于我们将 T 实现为一个可执行函数。我们可以自己模拟递归,而不是让 Python 运行它。这整个方法的关键是下面的等式:

Eqn3-12.jpg

我放在盒子里的两个子公式是相同的,这是关键。我声称这两个盒子是相同的基本原理在于我们最初的递归,因为如果...

Eqn3-13.jpg

...然后:

Eqn3-14.jpg

我只是简单的把原来方程中的 n 换成了n–1(当然,T((n–1)=T(n–2)),而 voilà ,我们看到盒子是相等的。我们在这里所做的是使用带有一个更小参数的 T 的定义,本质上,这就是递归调用求值时发生的事情。因此,将递归调用从第一个框T(n–1)扩展到第二个框T(n–2)+1,本质上是模拟或“解开”递归的一个层次。我们还有递归调用T(n–2)要处理,但是我们可以用同样的方式处理它!

Eqn3-15.jpg

事实上,T(n–2)=T(n–3)+1(两个加框的表达式)也是由原来的递推关系得出的。在这一点上,我们应该看到一个模式:每次我们减少一个参数,我们已经解开的工作(或时间)的总和(在递归调用之外)就增加1。如果我们递归地解开T*(n)I*步骤,我们得到如下结果:

Eqn3-16.jpg

这正是我们正在寻找的表达式——递归的层次被表示为一个变量 i 。因为所有这些未分解的表达式都是相等的(我们每一步都有方程),我们可以自由地将 i 设置为我们想要的任何值,只要我们不超过基本情况(例如, T (1)),在基本情况下,原始的递归关系不再有效。我们所做的是将直接上升到的基本情况,并尝试将T(nI)转化为 T (1),因为我们知道,或者隐含地假设 T (1)是θ(1),这意味着我们已经解决了整个问题。我们可以通过设置I=n–1:

Eqn3-17.jpg

我们现在可能付出了更多的努力,发现S有一个线性运行时间,正如我们所怀疑的。在下一节中,我将向您展示如何使用这种方法来处理一些不太直接的递归。

Image 小心这种方法,叫做 重复换人法(或者有时是 迭代法),是完全有效的,如果你小心的话。然而,很容易做出一两个不必要的假设,尤其是在更复杂的递归中。这意味着你可能应该将结果视为一个假设,然后使用本章后面“猜测和检查”一节中描述的技术检查你的答案。

一些重要的例子

你通常会遇到的递归的一般形式是T(n)=a T(g(n)+f(n),其中 a 代表递归调用的次数, g ( n 是每个子问题的大小

Image 提示当然有可能制定出不符合这种模式的递归算法,例如,如果子问题大小不同。这种情况不会在本书中讨论,但是在“如果你好奇”一节中给出了一些关于更多信息的提示...,“这一章快结束了。

表 3-1 总结了一些重要的递归——对大小为n–1 或 n /2 的问题进行一到两次递归调用,每次调用都有常量或线性的额外工作。在上一节中,您已经看到了第一个循环。在下文中,我将向你展示如何用重复替换法解决最后四个问题,剩下的三个(2 到 4)留给练习 3-7 到 3-9。

表 3-1 。一些基本的递归和解决方案,以及一些样本应用

Table3-1.jpg

在我们开始处理最后四个递归之前(它们都是 分治递归的例子,在本章后面和第六章中有更详细的解释),你可能想用图 3-5 来刷新你的记忆。它总结了迄今为止我所讨论的关于二叉树的结果;正如你将在下面的文本中看到的,我已经悄悄地给了你需要的所有工具。

9781484200568_Fig03-05.jpg

图 3-5 。完全平衡二叉树的一些重要性质综述

Image 我已经提到过基例有常数时间的假设(T(k)=T0kn0,对于某些常数 t 0n 0 )。在递归中, T 的参数是 n / b ,对于某个常数 b ,我们会遇到另一个技术问题:参数实际上应该是一个整数。我们可以通过舍入来实现这一点(到处使用floorceil),但是简单地忽略这个细节是很常见的(实际上假设 nb 的幂)。为了纠正这种马虎,你应该用本章后面的“猜测和检查”中描述的方法来检查你的答案。

看递归 5。只有一个递归调用,解决了一半的问题,此外还有固定的工作量。如果我们把完整的递归看做一棵树(一棵 递归树),这个额外的工作( f ( n ))是在每个节点执行的,而递归调用的结构是用边来表示的。总工作量( T ( n ))是所有节点(或涉及的节点)的 f ( n )之和。在这种情况下,每个节点的功是不变的,所以我们只需要计算节点的个数。而且,我们只有一个递归调用,所以全部工作相当于一条从根到叶子的路径。很明显, T ( n )是对数,但是如果我们尝试一步一步地揭开递归,让我们看看这是什么样子:

Eqn3-18.jpg

花括号括起了相当于递归调用的部分( T (...))在前一行。这种逐步解开(或反复替换)只是我们求解方法的第一步。一般方法如下:

  1. 解开循环,直到你看到一个模式。
  2. 用一个行号变量, i 来表示模式(通常涉及一个和)。
  3. 选择 i 以便递归到达其基本情况(并求解总和)。

第一步是我们已经做的。让我们从第 2 步开始:

Eqn3-19.jpg

我希望你同意这种一般形式抓住了我们解开的模式:对于每一个解开(每一行进一步向下),我们将问题大小减半(即除数加倍)并增加另一个工作单位(另一个 1)。最后的总和有点傻。我们知道我们有 i 个 1,所以总和显然就是 i 。我已经把它写成了一个总和,以显示这里的方法的一般模式。

为了达到递归的基本情况,我们必须让T(n/2I)变成,比如说, T (1)。这只是意味着我们必须将从 n 到 1 的路径减半,这一点现在应该很熟悉了:递归高度是对数的,或者说 i = lg n 。将它插入到模式中,你会得到 T ( n )确实是θ(LGn)。

递归 6 的分解非常相似,但是这里的和稍微有趣一些:

Eqn3-20.jpg

如果您不明白我是如何得出大致模式的,您可能需要思考一下。基本上,我只是使用了 sigma 符号来表示总和 n + n /2 +...+n/(2I–1),你可以在早期的解开步骤中看到这些。在担心解和之前,我们再次设置 i = lg n 。假设 T (1) = 1,我们得到如下结果:

Eqn3-21.jpg

还有最后一步就是因为n/2LGn= 1,所以我们可以把孤独的 1 纳入求和。

现在:这个数字看起来熟悉吗?再一次,看一下图 3-5 :如果 k 是一个高度,那么n/2/k是该高度的节点数(我们将从叶子到根分成两半)。这意味着总和等于节点数,即θ(n)。

递归 7 和 8 引入了一个难题:多次递归调用。递归 7 类似于递归 5:不是计数从根到叶的一条路径上的个节点,我们现在从每个节点开始跟随的两个子边,所以计数等于节点的数量,或θ(n)。你能看出递归 6 和递归 7 是如何以两种不同的方式计算同一个节点的吗?我将在递归 8 上使用我们的求解方法;第 7 项的程序非常相似,但值得一查:

Eqn3-22.jpg

如你所见,两者在前面不断堆积,产生了 2 i 的因子。括号内的情况看起来有点混乱,但幸运的是,减半和加倍完美地平衡了:第一个括号内是 n /2,乘以 2; n /4 乘以 4,一般来说, n /2 i 乘以 2 i ,也就是说我们剩下的是 ni 重复的总和,或者简称为 n i 。再次,为了得到基本情况,我们选择 i = lg n :

Eqn3-23.jpg

换句话说,运行时间是θ(nLGn)。在图 3-5 中也能看到这样的结果吗?没错。递归树根节点中的工作是n;在两个递归调用(子节点)的每一个中,这是减半的。换句话说,每个节点的功等于图 3-5 中的标号。我们知道每一行的总和为 n ,并且我们知道有 lg n + 1 行节点,给我们一个总和nLGn+n,或者θ(nLGn)。

猜测和检查

递归和归纳将在第四章中深入讨论。我的一个主要论点是,它们就像彼此的镜像;一种观点认为归纳法向你展示了为什么递归有效。在这一节中,我将讨论限制在展示我们对递归的解决方案是正确的(而不是讨论递归算法本身),但它仍然应该让您对这些事情是如何联系的有所了解。

正如我在本章前面说过的,解开一个循环和“发现”一个模式的过程在某种程度上受制于不必要的假设。例如,我们经常假设 n 是 2 的整数次幂,这样就可以获得精确到 lg n 的递归深度。在大多数情况下,这些假设工作得很好,但为了确保解决方案是正确的,您应该检查它。能够检查解决方案的好处在于,你可以通过猜测或直觉想象出一个解决方案,然后(理想情况下)证明它是正确的。

Image 注意为了使事情简单,我将坚持下面的大 Oh,并使用上限。可以用类似的方式显示下限(并得到ω或θ)。

让我们来看看第一个循环,T(n)=T(n–1)+1。我们想检查一下 T ( n )是 O ( n )是否正确。和实验一样(在第一章中讨论过),我们不能用渐近符号真正得到我们想要的结果;我们必须更具体一些,插入一些常数,所以我们试图验证T(n)≤cn,对于一些任意的 c ≥ 1。根据我们的标准假设,我们设置 T (1) = 1。目前为止,一切顺利。但是对于更大的 n 值呢?

这就是归纳的作用所在。这个想法很简单:我们从 T (1)开始,这里我们知道我们的解决方案是正确的,然后我们试图证明它也适用于 T (2)、 T (3),等等。我们一般通过证明一个归纳步骤来做到这一点,表明如果我们的解决方案对于T(n–1)是正确的,那么对于 T ( n ),对于 n > 1 也是正确的。这一步会让我们从 T (1)到 T (2),从 T (2)到 T (3),等等,就像我们想要的那样。

证明归纳步骤的关键是假设(在这种情况下)我们对T(n–1)做了正确的假设。这正是我们用来得到 T ( n )的东西,它被称为归纳假设。在我们的例子中,归纳假设是T(n–1)≤c(n–1)(对于某些 c ),我们想表明这延续到 T ( n ):

Eqn3-24.jpg

我在这里用方框突出了归纳假设的用法:我用c(n–1)代替T(n–1),我知道(根据归纳假设)这是一个更大(或同样大)的值。这使得替换是安全的,只要我在第一行和第二行之间从等号切换到“小于或等于”。后面的一些基础代数,我已经说明了假设T(n–1)≤c(n–1)导致T(n)≤cn,从而导致T(n+1)≤c从我们的基本案例 T (1)开始,我们现在已经表明 T ( n )通常是 O ( n )。**

基本的分而治之的循环并不难。让我们做递归 8(来自表 3-1 )。这一次,让我们用一种叫做 强感应的东西。在前面的例子中,我只假设了一些关于前值的东西(n–1,所谓弱归纳);现在,我的归纳假设将是关于所有更小的数字。更具体地说,我将假设T(k)≤CKLGk对于所有正整数 k < n ,并表明这导致T(n)≤cnLGn。基本思想仍然是一样的——我们的解决方案仍然会从 T (1)到 T (2)等等——只是我们得到了更多一点的工作。特别是,我们现在假设一些关于 T ( n /2)的东西,而不仅仅是T(n–1)。让我们试一试:

Eqn3-25.jpg

如前所述,假设我们已经展示了较小参数的结果,我们展示了它也适用于 T ( n )。

Image 注意警惕递归中的渐近符号,尤其是递归部分。考虑下面这个T(n)= 2T(n/2)+n的“证明”是指 T ( n )是 O ( n ,直接用我们归纳假设中的大 Oh:

*T* (*n*) = 2 · *T* (*n*/2) + *n* = 2 · *O* (*n*/2) + *n* = *O* (*n*)

这有很多问题,但最突出的问题可能是,归纳假设需要特定于参数的单个值( k = 1,2)...),但渐近符号必然适用于整个函数。

掉进兔子洞(或改变我们的变量)

一句警告:侧边栏中的材料可能有点挑战性。如果你已经满脑子都是循环的概念,以后再来重温它可能是个好主意。

在某些(可能很少)情况下,您可能会遇到类似如下的重复现象:

T(n) =aT(n1/b) +f(n)

换句话说,子问题的大小是b-原问题的根。现在你做什么?实际上,我们可以进入“另一个世界”,在那里复发很容易!这个另一个世界当然必须是真实世界的某种反映,这样我们回来时就可以得到原始重现的解决方案。

我们的“兔子洞”采取的形式是所谓的变量变化。这实际上是一个协调的变化,我们替换了 T (比如说, S )和 n (到 m ),这样我们的循环就和以前一样了——我们只是用不同的方式写了它。我们要的是把T(n1/b)改成S(m/b),这样更好用。让我们尝试一个具体的例子,使用一个平方根:

T(n) = 2T(n1/2) + lgn

怎样才能得到T(n1/2)=S(m/2)?直觉可能会告诉我们,要从幂到积,我们需要用到对数。这里的技巧是设置 m = lg n ,这又让我们在递归中插入 2 m 而不是 n :

T(2m) = 2T((2m)1/2) +m= 2T(2m/2) +m

通过设置S(m)=T(2m),我们可以隐藏那种力量,然后就好了!我们在仙境:

S(m) = 2S(m/2) +m

这个到现在应该很容易解决了:T(n)=S(m)是θ(mLGm)=θ(LGnLGn)。

在这个侧边栏的第一次循环中,常数 ab 可能有其他值,当然(和 f 肯定可能不太合作),留给我们的是 S ( m ) = (m/b)+g(m)(其中您可以使用重复替换来解决这个问题,或者您可以使用下一节中给出的千篇一律的解决方案,因为它们特别适合这种递归。**

主定理:千篇一律的解决方案

对应于许多所谓的分治算法(在第六章的中讨论)的递归具有以下形式(其中 a ≥ 1 并且 b > 1):

Eqn3-26.jpg

这个想法是,你有 a 递归调用,每一个都在数据集的给定百分比(1/ b )上。除了递归调用,该算法还执行 f ( n )个工作单元。看一下图 3-6 ,图中说明了这样一个算法。在我们早期的树中,数字 2 是最重要的,但是现在我们有了两个重要的常数*, ab 。分配给每个节点的问题大小除以我们每下降一级的b;这意味着为了达到问题大小 1(在树叶中),我们需要 logbn*的高度。记住,这是为了得到 n 而必须提高的 b 的幂。

9781484200568_Fig03-06.jpg

图 3-6 。一个完美平衡的,规则的多路(一路)树,展示了分治递归

然而,每个内部节点都有 a 子节点,所以从一级到另一级节点数量的增加不一定抵消问题规模的减少。这意味着叶节点的数量不一定是 n 。相反,对于每个级别,节点的数量增加一个因子 a ,并且对于 log b n 的高度,我们得到 logabn的宽度。然而,由于对数的计算规则相当方便,我们可以交换 an ,得到nlogb aleaves。练习 3-10 要求你证明这是正确的。

本节的目标 是建立三个千篇一律的解决方案,它们一起形成所谓的主定理。解决方案对应于三种可能的场景:要么大部分工作在根节点中执行(也就是说,大部分时间花费在上面),要么主要在叶节点中执行,要么在递归树的各行之间平均分配。让我们逐一考虑这三种情况。

在第一个场景中,大部分工作是在根中执行的,我说的“大部分”是指它渐近地支配着运行时间,给我们一个总的运行时间θ(f(n))。但是我们怎么知道根占优势呢?如果功从一级到另一级缩小(至少)一个常数,并且根比叶做更多的功(渐近),就会发生这种情况。更正式地说:

Eqn3-27.jpg

对于一些c??【1】和大型 n ,以及

Eqn3-28.jpg

对于某常数ε0。这仅仅意味着 f ( n )的增长比叶子的数量更快(这就是为什么我在叶子计数公式的指数中添加了 ε )。以下面的例子为例:

Eqn3-29.jpg

这里, a = 2, b = 3,f(n)=n。为了找到叶子的数量,我们需要计算 log 3 2。我们可以通过在标准计算器上使用表达式 log 2/log 3 来做到这一点,但是在 Python 中我们可以使用来自math模块的log函数,我们发现log(2,3)比 0.631 小一点。换句话说,我们想知道f(n)=n是否为ω(n0.631),显然是,这就告诉我们 T ( n )为θ(f(n))=θ(n 这里的一个捷径是看到 b 大于 a ,这可以立即告诉我们 n 是表达式的主要部分。你知道为什么吗?

我们也可以颠倒根叶关系:

Eqn3-30.jpg

现在树叶主宰了画面。你认为这会导致多少总运行时间?没错:

Eqn3-31.jpg

以下面的循环为例:

Eqn3-32.jpg

这里 a = b ,所以我们得到了一个 n 的叶子数,它显然比f(n)= LGn增长得更快。这意味着最终运行时间渐进地等于叶子数,或θ(n)。

Image 注意为了建立根的优势,我们需要额外的需求af(n/b)≤cf*(n),对于一些 c < 1。建立叶优势,没有类似的要求。*

最后一种情况是根和叶中的功具有相同的渐近增长:

Eqn3-33.jpg

这就变成了树的每一层的总和(从根到叶既不增加也不减少),这意味着我们可以用它乘以对数高度来得到总和:

Eqn3-34.jpg

以下面的循环为例:

Eqn3-35.jpg

平方根可能看起来令人生畏,但它只是另一种力量,即, n 0.5 。我们有 a = 2 和 b = 4,给我们 logb a= log42 = 0.5。你知道吗—根和叶的功都是θ(n0.5),因此在树的每一行中,总运行时间如下:

Eqn3-36.jpg

表 3-2 总结了主定理的三种情况,按照习惯上给出的顺序:情况 1 是树叶占优;情况 2 是“死竞争”,其中所有行都有相同的(渐近)和;在第三种情况下,根占优势。

表 3-2 。三例 掌握定理

Table3-2.jpg

那么,到底是怎么回事呢?

好的,这里有这里有很多数学,但是到目前为止没有很多编码。这些公式有什么意义?考虑一下清单 3-1 和清单 3-2 中的 Python 程序。 7 (你可以在清单 6-6 中找到 mergesort函数的完整注释版本。)假设这些是新算法,所以你不能只在网上搜索它们的名字,你的任务是确定哪个算法具有更好的渐近运行时间复杂度。

清单 3-1 。Gnome Sort,一个示例排序算法

def gnomesort(seq):
    i = 0
    while i < len(seq):
        if i == 0 or seq[i-1] <= seq[i]:
            i += 1
        else:
            seq[i], seq[i-1] = seq[i-1], seq[i]
            i -= 1

清单 3-2 。合并排序,排序算法的另一个例子

def mergesort(seq):
    mid = len(seq)//2
    lft, rgt = seq[:mid], seq[mid:]
    if len(lft) > 1: lft = mergesort(lft)
    if len(rgt) > 1: rgt = mergesort(rgt)
    res = []
    while lft and rgt:
        if lft[-1] >=rgt[-1]:
            res.append(lft.pop())
        else:
            res.append(rgt.pop())
    res.reverse()
    return (lft or rgt) + res

Gnome sort 包含一个单独的while循环和一个从0len(seq)-1的索引变量,这可能会诱使我们得出结论,它有一个线性的运行时间,但是最后一行中的语句i -= 1可能会指出其他情况。为了弄清楚它运行了多长时间,你需要了解它是如何工作的。最初,它从左边的a开始扫描(重复递增i,寻找seq[i-1]大于seq[i]的位置i,也就是顺序错误的两个值。此时,else部分开始工作。

else条款交换seq[i]seq[i-1]并减少i。这种行为将继续,直到再次seq[i-1] <= seq[i](或者我们到达位置0)并且秩序被恢复。换句话说,该算法交替地在序列中向上扫描不合适(即太小)的元素,并通过重复交换将该元素向下移动到有效位置。这一切的代价是什么?让我们忽略一般情况,专注于最好和最坏的情况。当序列被排序时,最好的情况出现:gnomesort将只是扫描通过a而没有发现任何不合适的地方,然后终止,产生运行时间θ(n)。

最坏的情况稍微简单一点,但也不多。注意,一旦我们发现一个不合适的元素,该点之前的所有元素都已经排序了,将新元素移动到正确的位置不会打乱它们。这意味着每次我们发现一个错放的元素,排序后的元素的数量就会增加 1,下一个错放的元素将会更靠右。寻找和移动一个错放的元素的最坏的可能成本与它的位置成正比,所以最坏的运行时间可能是 1 + 2 +...+n–1,也就是θ(n2)。目前这只是一个假设——我已经证明情况不会比这更糟,但它真的会变得这么糟吗?

的确可以。考虑元素按降序排序的情况(即,与我们想要的相反)。那么每个元素都在错误的位置,必须一直移动到起点,给我们二次运行时间。所以,一般来说,gnome sort 的运行时间是ω(n)和 O ( n 2 ),这是分别代表最好和最坏情况的紧界。

现在,看看合并排序(清单 3-2 )。它比 gnome sort 稍微复杂一点,所以我会推迟到第六章再解释它是如何排序的。幸运的是,我们可以在不了解它如何工作的情况下分析它的运行时间!只看整体结构。输入(序列)的大小为 n 。这里有两个递归调用,每个调用都是针对一个子问题 n /2(或者是尽可能接近整数大小)。此外,在while循环和res.reverse()中执行一些工作;练习 3-11 要求你证明这个功是θ(n)。(练习 3-12 问你如果用pop(0)代替pop()会发生什么。)这就给了我们众所周知的递归数 8,T(n)= 2T(n/2)+θ(n),也就是说不管输入是什么,归并排序的运行时间都是θ(nLGn)。这意味着,如果我们期望数据几乎被排序,我们可能更喜欢 gnome 排序,但一般来说,我们可能会更好地放弃它以支持合并排序。

Image 注意 Python 的排序算法 timsort 是一个自然适应版本的归并排序。它设法实现线性最佳情况运行时间,同时保持对数线性最坏情况。你可以在第六章的 timsort 上的“黑盒”侧栏中找到更多细节。

摘要

n 个整数的和是二次的,第 lg n 个 2 的一次幂的和是线性的。这些身份中的第一个可以被说明为循环锦标赛,具有所有可能的 n 元素的配对;第二个与淘汰赛有关,lg n 轮,除了获胜者之外,所有人都必须被淘汰。 n 的排列数是 n !,而来自 nk-组合(大小为 k 的子集)的个数,写成 C ( nk ),则为 n !/( k !(nk)!).这也被称为二项式系数

如果一个函数调用自己(直接或通过其他函数),那么它就是递归的。递推关系是将一个函数与其自身以递归的方式联系起来的方程(如T(n)=T(n/2)+1)。这些方程经常被用来描述递归算法的运行时间,为了能够求解它们,我们需要假设一些关于递归的基本情况;通常,我们假设 T ( k )为θ(1),对于某个常数 k 。本章介绍了三种主要的求解递归的方法: (1)反复应用原方程来解开 T 的递归出现,直到你找到一个模式;(2)猜测一个解,并用归纳法证明其正确性;以及(3)对于符合主定理的情况之一的分治递归,简单地使用相应的解决方案。

如果你好奇的话...

本章的主题(以及之前的主题)通常被归类为所谓的 离散数学的一部分。关于这个话题有很多书,我看过的大多数都很酷。如果你喜欢这类东西,那就去图书馆、当地书店或网上书店。我相信你会找到足够让你忙上好一阵子的东西。

我喜欢的一本论述 计数和证明(但不是一般的离散数学)的书是本杰明和奎因的《真正计算的证明》。值得一看。如果你想要一本专门为计算机科学家编写的关于和、组合学、递归以及许多其他实质性内容的可靠参考,你应该去看看 Graham、Knuth 和 Patashnik 的经典具体数学*。(是啊,就是那个 Knuth,你就知道好了。)如果你只是需要一个地方来查找一个总数的解,你可以尝试 Wolfram Alpha ( http://wolframalpha.com),如前所述,或者找一本装满公式的袖珍参考资料(同样,可能可以在你最喜欢的书店买到)。*

如果你想要更多关于递归的细节,你可以在我在第一章中提到的算法教科书中查找标准方法,或者你可以研究一些更高级的方法,这些方法可以让你处理比我在这里处理的更多的递归类型。比如具体数学讲解如何使用所谓的 生成函数 。如果你在网上四处看看,你一定会发现很多有趣的东西,比如用 零化子或者使用阿克拉-宝宝定理来解决递归问题。

本章前面关于伪多项式的边栏使用了素性检查作为例子。许多(更老的)教科书声称这是一个未解决的问题(即没有已知的多项式算法来解决它)。正如你所知——这不再是真的了:2002 年,Agrawal、Kayal 和 Saxena 发表了他们的开创性论文“素数在 P 中”,描述了如何进行多项式素性检查。(奇怪的是,因式分解数字仍然是一个未解决的问题。)

练习

3-1.证明“使用求和”一节中描述的属性是正确的。

3-2.用第二章中的规则表示n(n–1)/2 为θ(n2)。

3-3.2 的前 k 个非负整数次幂之和为 2k+1–1。展示这个属性如何让你将任意正整数表示成二进制数。

3-4.在“龟兔赛跑”这一节中,我们简要介绍了两种求数的方法。将这些方法转化为数字猜测算法,并作为 Python 程序来实现。

3-5.表明 C ( nk ) = C ( nnk)。

3-6.在“递归和递归”一节前面的递归函数S中,假设函数没有使用位置参数i,而是简单地返回了sec[0] + S(seq[1:])。现在渐近运行时间是多少?

3-7.使用重复替换求解表 3-1 中的递归 2。

3-8.使用重复替换求解表 3-1 中的递归 3。

3-9.使用重复替换求解表 3-1 中的递归 4。

3-10.表明xlogy=ylogx,不管对数的底数。

3-11.对于清单 3-2 中归并排序的实现,表明 f ( n )为θ(n)。

3-12.在清单 3-2 的合并排序中,对象从序列的每一半的末尾弹出(用pop())。从一开始就用pop(0)弹出可能更直观,以避免之后不得不反转res(我在现实生活中见过这样做),但pop(0)就像insert(0)一样,是线性操作,与pop()相反,它是常数。这样的切换对总运行时间意味着什么?

参考

Agrawal、n . Kayal 和 n . sa xena(2004 年)。素数在 P 中。数学年鉴,160(2):781–793。

阿克拉和宝宝(1998 年)。关于线性递推方程组的解。计算优化与应用,10(2):195–210。

本杰明,A. T .和奎因,J. (2003)。真正重要的证明:组合证明的艺术。美国数学协会。

Graham,R. L .,Knuth,D. E .和 Patashnik,O. (1994 年)。具体数学:计算机科学的基础,第二版。艾迪森-韦斯利专业版。


1 只要函数没有任何副作用,也就是说,而是表现得像数学函数。

2

如果这是真的,那么在大约 32 代以前,人类人口将由一男一女组成...但是,就像我说的,配合一下。

4 ...尽管他被告知要清点他收到的每一粒谷物。我猜他改变主意了。

5 你看到指数中的–1 去哪了吗?记住,2a+b= 2a2b...

6 另一个不太明显的是“二项式系数”这个名字的由来。你可能想查一下。这是一种整洁。

7 归并排序是一个经典,由计算机科学传奇人物约翰·冯·诺依曼于 1945 年在 EDVAC 上首次实现。你会在第六章的中了解到更多关于那个和其他类似算法的内容。Gnome sort 是由 Hamid Sarbazi-Azad 在 2000 年发明的,名字叫做愚蠢排序。

8 如果你不确定离散离散的区别,你可能想查一下。**

四、归纳和递归...和归约

你绝不能马上想到整条街,明白吗?你必须只专注于下一步,下一次呼吸,扫帚的下一次挥动,下一次,再下一次。没别的了。

— Beppo Roadsweeper,米切尔·恩德的《Momo》

在这一章,我为你的算法设计技巧打下基础。算法设计可能很难教,因为没有明确的方法可以遵循。不过,有一些基本原则,其中一个反复出现的是抽象的原则。我敢打赌你已经非常熟悉几种抽象了——最重要的是,过程(或函数)抽象和面向对象。这两种方法都允许您隔离代码的各个部分,并最小化它们之间的交互,这样您就可以一次专注于几个概念。

本章的主要思想——归纳、递归和归约——也是抽象的原则。它们都是关于忽略大部分问题,专注于向解决方案迈出一步。伟大的事情是,这一步是你所需要的;剩下的自动跟上!这些原则通常是分开教授和使用的,但是如果你看得更深一点,你会发现它们是非常密切相关的:归纳和递归在某种意义上是彼此的镜像,两者都可以被看作是归约的例子。以下是这些术语实际含义的简要概述:

  • 归约就是把一个问题转化成另一个问题。我们通常把未知的问题简化成我们知道如何解决的问题。这种简化可能涉及到输入(因此它适用于新问题)和输出(因此它适用于原问题)的转换。
  • 归纳法,或数学归纳法,用于表明一个陈述对一大类对象(通常是自然数)成立。我们首先证明它对于一个基本情况(例如数字 1)是正确的,然后证明它从一个对象“延续”到下一个对象;例如,如果对n–1 成立,那么对 n 也成立。
  • 递归是函数调用自身时发生的事情。在这里,我们需要确保函数对于(非递归的)基本情况是正确的,并且它将递归调用的结果组合成一个有效的解。

归纳法和递归法都包括将一个问题简化(或分解)成更小的子问题,然后再向前一步,解决整个问题。

注意,虽然这一章中的视角可能与当前的一些教科书有点不同,但这绝不是唯一的。事实上,大部分材料的灵感来自于 Udi man ber 1988 年的精彩论文“使用归纳法设计算法”和他次年的书算法简介:一种创造性的方法

哦,那很简单!

简而言之,将一个问题 A 简化为另一个问题 B 涉及某种形式的转换,之后 B 的解决方案会(直接或经过一些处理)给你一个 A 的解决方案。一旦你学会了一系列标准算法(你会在本书中遇到很多),这就是你遇到新问题时通常会做的事情。你能以某种方式改变它,以便用你知道的方法之一解决它吗?从很多方面来说,这是所有问题解决的核心过程。

我们举个例子。你有一个数字列表,你想找出最接近的两个(不相同的)数字(即绝对差值最小的两个):

>>> from random import randrange
>>> seq = [randrange(10**10) forin range(100)]
>>> dd = float("inf")
>>> forin seq:
...     forin seq:
...         if x == y: continue
...         d = abs(x-y)
...         if d < dd:
...             xx, yy, dd = x, y, d
...
>>> xx, yy
(15743, 15774)

两个嵌套循环,都在seq之上;应该很明显这是二次的,一般不是好事。假设你对算法有所了解,你知道如果序列是按排序的,那么它们通常会更容易处理。你也知道排序通常是对数线性的,或者θ(nLGn)。看到这有什么帮助了吗?这里的见解是,两个最接近的数字在排序序列中必须是挨着:

>>> seq.sort()
>>> dd = float("inf")
>>> forin range(len(seq)-1):
...     x, y = seq[i], seq[i+1]
...     if x == y: continue
...     d = abs(x-y)
...     if d < dd:
...         xx, yy, dd = x, y, d
...
>>> xx, yy
(15743, 15774)

更快的算法,同样的解决方案。新的运行时间是对数线性的,由排序决定。我们最初的问题是“在一个序列中找到两个最接近的数字”,我们通过排序seq将其简化为“在一个排序的序列中找到两个最接近的数字”。在这种情况下,我们的归约(排序)不会影响我们得到的答案。一般来说,我们可能需要转换答案,使其符合原始问题。

Image 在某种程度上,我们只是把问题分成两部分,排序和扫描排序后的序列。你也可以说扫描是将原始问题简化为排序序列问题的一种方式。这完全是视角的问题。

把 A 化简为 B 有点像说“你想解 A?哦,那很简单,只要你能解决 b。”见图 4-1 关于归约如何工作的图解。

9781484200568_Fig04-01.jpg

图 4-1 。用一个从 A 到 B 的归约用一个 B 的算法来解 A . B 的算法(中心,内圆)可以把输入的 B 进行变换?到输出 B!,而归约由两个转换(较小的圆)组成,从 A?to B?还有从 B!敬 A!,共同构成主算法,它将输入 A?到输出 A!

一个,两个,许多

我已经用归纳法解决了第三章中的一些问题,但是让我们回顾一下,看几个例子。当抽象地描述归纳时,我们说我们有一个命题,或者说陈述 P ( n ),我们想证明对于任意自然数 n 都成立。例如,假设我们正在调查第一个 n 个奇数的总和; P ( n )则可以是以下语句:

Eqn69a.jpg

这非常熟悉——它几乎与我们在上一章中使用的握手和相同。你可以很容易地通过调整握手公式得到这个新结果,但是让我们看看如何用归纳法来证明它。归纳法的思想是让我们的证明“横扫”所有自然数,有点像一排多米诺骨牌倒下。我们从建立 P (1)开始,这在本例中非常明显,然后我们需要表明,如果每块多米诺骨牌倒下,将会推倒下一块。换句话说,我们必须证明如果陈述P(n–1)为真,那么随之而来的是 P ( n )也为真。

如果我们能表现出这个蕴涵,即P(n–1)P(n),那么结果将会扫过 n 的所有值,从 P (1)开始,用P(1)üP(2)建立 P (2),然后继续进行换句话说,关键的事情是建立让我们更进一步的暗示。我们称之为归纳步骤*。在我们的例子中,这意味着我们假设如下(P(n–1)):*

Eqn69b.jpg

我们可以认为这是理所当然的,我们只要把它拼接到原来的公式中,看看能不能推导出 P ( n ):

Eqn70.jpg

给你。归纳步骤成立,我们现在知道该公式适用于所有自然数 n

使我们能够执行这个归纳步骤的主要原因是,我们假设我们已经建立了P(n–1)。这意味着我们可以从我们所知道的(或者说,假设的)关于n–1 的东西开始,并在此基础上展示一些关于 n 的东西。让我们尝试一个稍微不那么有序的例子。考虑一个有根的二叉树,其中每个内部节点都有两个子节点(尽管它不需要平衡,所以叶子可能都有不同的深度)。如果树有 n 片叶子,它有多少个内部节点? 1

我们不再有一个好的自然数序列,但是归纳变量( n )的选择是非常明显的。解决方案(内部节点的数量)是n–1,但是现在我们需要证明这适用于所有的 n 。为了避免一些令人厌烦的技术细节,我们从 n = 3 开始,所以我们保证有一个内部节点和两个叶子(很明显 P (3)是正确的)。现在,假设对于n–1 个叶子,我们有n–2 个内部节点。我们如何进行至关重要的归纳步骤?

这更接近于构建算法时的工作方式。我们不再只是打乱数字和符号,而是在思考结构,逐步构建它们。在这种情况下,我们将为我们的树添加一片叶子。会发生什么?问题是,我们不能随意添加树叶而不违反我们对树木的限制。相反,我们可以反过来进行这个步骤,从第 n 片叶子到第n–1 片叶子。在有 n 片叶子的树中,连同它的(内部)父节点一起移除任何一片叶子,然后连接剩下的两片叶子,这样现在断开的节点就被插入到父节点所在的位置。这是一棵合法的树,有 n*–1 片叶子和(根据我们的归纳假设)n–2 个内部节点。原来的树多了一片叶子,多了一个内部节点,也就是 n 片叶子和n–1 个内部,这正是我们想要展示的。*

现在,考虑下面的经典难题。如图 4-2 中的图所示,你如何用 L 形瓷砖盖住一个缺了一个角的棋盘?这可能吗?你会从哪里开始?你可以*尝试一个强力解决方案,从第一个棋子开始,把它放在每一个可能的位置(和每一个可能的方向),对于每一个,尝试第二个的每一种可能性,以此类推。那不会很有效率。我们怎样才能减少这个问题?归约在哪里? 2

9781484200568_Fig04-02.jpg

图 4-2 。一个不完整的棋盘,被 L 形瓷砖覆盖。拼贴可以旋转,但不能重叠

放置一个瓷砖,并假设我们可以解决其余的问题,或者假设我们已经解决了除一个以外的所有问题,然后放置最后一个——这当然是一种减少。我们已经把问题从一个转化到另一个,但是问题是我们没有解决新的问题的方法,所以它没有真正的帮助。为了使用归纳法(或递归),归约必须(通常)在相同问题不同大小的实例之间进行。目前,我们的问题仅针对图 4-2 中的特定电路板,但将其推广到其他尺寸应该不会有太大问题。给定这个概括,你看到任何有用的减少吗?**

问题是我们怎样才能把这块木板分割成形状相同的小块。它是二次的,所以自然的起点可能是把它分成四个更小的正方形。在那一点上,我们和完整解决方案之间唯一的障碍是,四个板部件中只有一个与原来的形状相同,缺了一个角。其他三个是完整的(四分之一大小)棋盘。然而,这很容易补救。只需放置一个瓷砖,使其覆盖这三个子板的一个角,就像变魔术一样,我们现在有四个子问题,每个都相当于(但小于)完整的问题!

为了澄清这里的归纳,假设您实际上还没有放置瓷砖。你只需要注意哪三个角是敞开的。通过归纳假设,你可以覆盖三个子板(其中基础案例是四个正方形的板),一旦你完成,还剩下三个正方形可以覆盖,呈 L 形。3归纳步骤然后放置这个棋子,隐含地组合四个子解。因为归纳,我们不仅解决了八乘八的问题。这个解决方案适用于任何这种类型的棋盘,只要它的边是 2 的(相等)幂。

Image 注意在这里,我们还没有真正在所有电路板尺寸或所有边长上使用感应。我们已经隐含地假设边长为 2 k ,对于某个正整数 k ,并在 k 上使用归纳法。结果是完全有效的,但重要的是要注意我们已经证明了什么。例如,对于奇数边电路板,解决方案成立。

这个设计实际上更多的是一个证明,而不是一个实际的算法。不过,把它变成一个算法并不难。你首先需要考虑由四个正方形组成的所有子问题,确保它们的开角正确对齐。然后,你将这些问题组合成由 16 个正方形组成的子问题,仍然要确保放置开放的角,以便它们可以用 L 形片连接。虽然你当然可以用一个循环把它设置成一个迭代程序,但是用递归会简单得多,在下一节你会看到。

镜子,镜子

在他出色的网络视频节目中,泽·弗兰克曾经说过这样的话:“‘你知道没有什么可害怕的,除了害怕本身。’是的,这叫做递归,这会导致无限的恐惧,所以谢谢你。 4 另一个常见的建议是,“为了理解递归,首先必须理解递归。"

确实如此。递归可能很难理解——尽管无限递归是一种相当病态的情况。 5 在某种程度上,递归只有作为归纳的镜像才有意义(参见图 4-3 )。在归纳法中,我们(从概念上)从一个基本案例开始,展示归纳步骤如何带我们走得更远,直到整个问题的规模。对于弱归纳, 6 我们假设(归纳假设)我们的解决方案对n–1 有效,并由此推断对 n 有效。递归通常看起来更像是分解事物。你从一个完整的问题开始,大小为 n 。您将大小为n–1 的子问题委托给一个递归调用,等待结果,并将得到的子解扩展为完整解。我相信你可以看到这只是一个视角的问题。在某种程度上,归纳法向我们展示了为什么递归有效,而递归给了我们一个简单的方法(直接)实现我们的归纳思想。

9781484200568_Fig04-03.jpg

图 4-3 。归纳(左边)和递归(右边),互为镜像

以上一节的棋盘问题为例。最简单的解决方法(至少在我看来)是递归。你放置一个 L-piece,这样你得到四个等价的子问题,然后你递归地解决它们。通过归纳,解决方案将是正确的。

实现棋盘覆盖

尽管棋盘覆盖问题在概念上有一个非常简单的递归解决方案*,但是实现它可能需要一些思考。实现的细节对于这个例子的要点来说并不重要,所以如果你愿意的话,可以跳过这个边栏。实现解决方案的一种方式如下所示:*

*def cover(board, lab=1, top=0, left=0, side=None): if side is None: side = len(board)

# Side length of subboard: s = side // 2

# Offsets for outer/inner squares of subboards: offsets = (0, -1), (side-1, 0)

for dx_outer, dx_inner in offsets: # If the outer corner is not set... if not board[top+dy_outer][left+dx_outer]: # ... label the inner corner: board[top+s+dy_inner][left+s+dx_inner] = lab

# Next label:

if s > 1:

for dx in [0, s]: # Recursive calls, if s is at least 2: lab = cover(board, lab, top+dy, left+dx, s)

# Return the next available label:

虽然递归算法很简单,但是需要做一些簿记工作。每个调用都需要知道它正在哪个子板上工作,以及当前 I-tile 的编号(或标签)。该函数的主要工作是检查四个中心方块中的哪一个要用 L-tile 覆盖。我们只覆盖不对应于缺失(外部)角的三个角。最后,有四个递归调用,分别针对四个子问题。(返回下一个可用标签,因此可以在下一次递归调用中使用它。)下面是一个如何运行代码的示例:

>>> board = [[0]*8 for i in range(8)] # Eight by eight checkerboard >>> board[7][7] = -1                  # Missing corner >>> cover(board) 22 >>> for row in board: ...     print((" %2i"*8) % tuple(row)) 3  3  4  4  8  8  9  9 3  2  2  4  8  7  7  9 5  2  6  6 10 10  7 11 5  5  6  1  1 10 11 11 13 13 14  1 18 18 19 19 13 12 14 14 18 17 17 19``15 12 12 16 20 17 21 21 15 15 16 16 20 20 21 -1

正如你所看到的,所有的数字标签都形成了 L 形(除了-1,它代表缺角)。代码可能有点难以理解,但是想象一下,在没有归纳或递归的基础知识的情况下理解它,更不用说设计它了!

归纳和递归是密切相关的,因为直接递归地实现归纳思想通常是可能的。然而,有几个原因可以解释为什么迭代实现可能更好。使用循环的开销通常比递归小(所以速度更快),而且在大多数语言(包括 Python)中,递归的深度是有限制的(最大堆栈深度)。以下面的例子为例,它只遍历一个序列:

>>> def trav(seq, i=0):
...     if i==len(seq): return
...     trav(seq, i+1)
...
>>> trav(range(100))
>>>

它可以工作,但请尝试在range(1000)上运行它。您将得到一个RuntimeError,抱怨您已经超过了最大递归深度。

Image 很多所谓的函数式编程语言都实现了一种叫做尾部递归优化 的东西。类似前面的函数(唯一的递归调用是函数的最后一条语句)被修改,这样它们就不会耗尽堆栈。通常,递归调用在内部被重写为循环。

幸运的是,任何递归函数都可以重写为迭代函数,反之亦然。然而,在某些情况下,递归是非常自然的,你可能需要在迭代程序中使用你自己的堆栈来伪装它(如在非递归的深度优先搜索,在第五章中解释)。

让我们来看几个基本算法,通过递归思想可以很容易地理解算法思想,但是实现非常适合迭代。 7 考虑排序问题(计算机科学教学中的最爱)。像以前一样,问问自己,减在哪里?有很多方法可以减少这个问题(在第六章我们将减少一半),但是考虑一下我们通过一个元素减少这个问题的情况。我们可以假设(归纳)前n–1 个元素已经排序,并将元素 n 插入正确的位置,或者我们可以找到最大的元素,将其放在位置 n 处,然后递归排序剩余的元素。前者给我们插入排序,后者给选择排序

Image 注意这些算法并不那么有用,但它们通常被教授,因为它们是很好的例子。此外,它们是经典,所以任何算法专家都应该知道它们是如何工作的。

看看清单 4-1 中的递归插入排序。它巧妙地概括了算法思想。要将序列向上排序到位置 i ,首先将其递归向上排序到位置I–1(通过归纳假设进行校正),然后向下交换元素seq[i],直到它在已经排序的元素中到达正确的位置。基本情况是当 i = 0 时;单个元素被平凡地排序。如果您愿意,您可以添加一个默认案例,其中i被设置为len(seq)-1。如前所述,尽管这种实现允许我们在递归调用中封装归纳假设,但它有实际的限制(例如,它将处理的序列的长度)。

清单 4-1 。递归插入排序

def ins_sort_rec(seq, i):
    if i==0: return                             # Base case -- do nothing
    ins_sort_rec(seq, i-1)                      # Sort 0..i-1
    j = i                                       # Start "walking" down
    while j > 0 and seq[j-1] > seq[j]:          # Look for OK spot
        seq[j-1], seq[j] = seq[j], seq[j-1]     # Keep moving seq[j] down
        j -= 1                                  # Decrement j

清单 4-2 显示了迭代版本,通常称为插入排序 。不是向后递归,而是从第一个元素开始向前迭代。如果你仔细想想,这也正是递归版本所做的。虽然看起来是从末尾开始,但是在执行while循环之前,递归调用会一直返回到第一个元素。在那个递归调用返回之后,while循环在第二个元素上执行,依此类推,所以两个版本的行为是相同的。

清单 4-2 。插入排序

def ins_sort(seq):
    forin range(1,len(seq)):                 # 0..i-1 sorted so far
        j = i                                   # Start "walking" down
        while j > 0 and seq[j-1] > seq[j]:      # Look for OK spot
            seq[j-1], seq[j] = seq[j], seq[j-1] # Keep moving seq[j] down
            j -= 1                              # Decrement j

清单 4-3 和 4-4 分别包含选择排序的递归和迭代版本。

清单 4-3 。递归选择排序

def sel_sort_rec(seq, i):
    if i==0: return                             # Base case -- do nothing
    max_j = i                                   # Idx. of largest value so far
    forin range(i):                          # Look for a larger value
        if seq[j] > seq[max_j]: max_j = j       # Found one? Update max_j
    seq[i], seq[max_j] = seq[max_j], seq[i]     # Switch largest into place
    sel_sort_rec(seq, i-1)                      # Sort 0..i-1

清单 4-4 。选择排序

def sel_sort(seq):
    forin range(len(seq)-1,0,-1):            # n..i+1 sorted so far
        max_j = i                               # Idx. of largest value so far
        forin range(i):                      # Look for a larger value
            if seq[j] > seq[max_j]: max_j = j   # Found one? Update max_j
        seq[i], seq[max_j] = seq[max_j], seq[i] # Switch largest into place

再一次,你可以看到两者非常相似。递归实现明确表示归纳假设(作为递归调用),而迭代版本明确表示重复执行归纳步骤。两者都是通过找到最大的元素(寻找max_jfor循环)并将其交换到所考虑的序列前缀的末尾来工作的。请注意,您也可以从头开始运行本节中的所有四种排序算法,而不是从结尾开始(在插入排序中将所有对象排序到右侧,或者在选择排序中寻找最小的元素)。

但哪里的 的归约?

找到一个有用的约简通常是解决算法问题的关键步骤。如果你不知道从哪里开始,问问自己,减少在哪里?

然而,可能不完全清楚本节中的想法如何与图 4-1 中呈现的减少情况相吻合。如前所述,归约将问题 A 的实例转换为问题 B 的实例,然后将 B 的输出转换为 A 的有效输出。但是在归纳和归约中,我们只是缩小了问题的规模。哪里的归约,真的吗?

哦,它就在那里——只是我们正在从 A 减少到 A。尽管有些转变正在发生。这种减少确保了我们将减少到的实例比原来的实例(这是归纳工作的原因),当转换输出时,我们再次增加大小。

这是缩减的两个主要变化:缩减到不同的问题和缩减到相同问题的缩小版本。如果你把子问题想成顶点,把归约想成边,你会得到在第二章的中讨论的子问题图,这个概念我会多次重温。(在第八章中尤为重要。)

使用归纳法(和递归)进行设计

在这一部分,我将带你完成三个问题的算法解决方案的设计。我正在构建的问题,拓扑排序,是一个在实践中经常出现的问题,如果你的软件管理任何类型的依赖,有一天你很可能需要自己实现它。前两个问题可能没那么有用,但是很有趣,它们是归纳(和递归)的好例子。

寻找最大排列

八个品味独特的人买了电影票。他们中的一些人对自己的座位很满意,但大多数人并不满意,在第三章排队之后,他们变得有点暴躁。假设他们每个人都有一个最喜欢的座位,你想找到一种方法让他们交换座位,让尽可能多的人对结果感到满意(忽略其他观众,他们最终可能会对我们的观众的滑稽动作感到有点厌倦)。但是因为都比较暴躁,如果拿不到自己喜欢的,都拒绝挪到别的座位。

这是匹配问题的一种形式。你会在第十章中遇到其他几个。我们可以把问题(实例)建模成一个图,就像图 4-4 中的那个。边缘从人们现在坐的位置指向他们想要坐的位置。(这个图有点不寻常,因为节点没有唯一的标签;每个人或每个席位代表两次。)

9781484200568_Fig04-04.jpg

图 4-4 。集合{ a 中的映射...h}自身

Image 注意这是所谓的二分图图的一个例子,这意味着节点可以被分成两个集合,其中所有的边都在集合之间*(并且它们都不在)。换句话说,您可以只使用两种颜色来给节点着色,这样相邻节点就不会有相同的颜色。*

在我们尝试设计算法之前,我们需要将问题形式化。真正理解问题总是解决问题的关键的第一步。在这种情况下,我们希望让尽可能多的人得到他们“指向”的座位其他人需要留在座位上。另一种看待这个问题的方式是,我们在寻找一个人(或指指点点的人)的子集,形成一对一的映射,或 ?? 排列。这意味着集合中没有人指向它之外,每个座位(在集合中)只被指向一次。这样,排列中的每个人都可以根据自己的意愿自由排列或交换座位。我们希望找到一个尽可能大的排列*(以减少落在它之外并且他们的愿望被拒绝的人的数量)。*

同样,我们的第一步是问,减少在哪里?我们怎样才能把问题缩小呢?我们可以委托(递归地)或假设(归纳地)已经解决的子问题是什么?让我们用简单(弱)归纳法,看看我们是否能把问题从 n 缩小到n–1。这里, n 为人数(或座位数),即 n = 8 为图 4-4 。归纳假设来自我们的一般方法。我们简单假设可以解决n–1 人的问题(即找到形成排列的最大子集)。唯一需要创造性解决问题的是安全地移除一个人,这样剩下的子问题就是我们可以建立的(也就是说,是整个解决方案的一部分)。

如果每个人指向一个不同的座位,整个集合就形成了一个排列,这个排列肯定是尽可能大的——不需要移除任何人,因为我们已经完成了。基本情况也是微不足道的。对于 n = 1,无处可动。所以,让我们假设n1,并且至少有两个人指向同一个座位(这是打破排列的唯一方法)。以图 4-4 中的 ab 为例。它们都指向 c ,我们可以有把握地说其中一个必须被淘汰。然而,我们选择哪一个是至关重要的。比方说,我们选择移除 a (人和座位)。然后我们注意到 c 指向 a ,这意味着 c 也必须被消除。最后, b 指向 c 并且也必须被删除——这意味着我们可以简单地从一开始就删除 b ,保留 ac (它们只是想互相交换座位)。

当寻找这样的归纳步骤时,寻找突出的东西通常是个好主意。例如,一个没人想坐的座位(即图 4-4 中下一行没有 in-edges 的节点)怎么办?在一个有效的解决方案(排列)中,至多一个人(元素)可以被放置(映射到)任何给定的座位(位置)。这意味着没有空座位的空间,因为至少两个人会试图坐在同一个座位上。换句话说,不仅仅是去掉一个空座位(和对应的人)就 OK;这其实是必要的。例如,在图 4-4 中,标有 b 的节点不能是任何排列的一部分,当然不是最大尺寸的。因此,我们可以排除 b ,剩下的是同一个问题的一个更小的实例(具有 n = 7),通过归纳的魔力,我们完成了!

还是我们?我们总是需要确保我们已经考虑到了所有可能发生的事情。如果需要的话,我们能确定总会有一个空座位被取消吗?事实上我们可以。没有空座, n 人必须集体指向所有的 n 座,也就是说他们都指向不同的座,所以我们已经有了一个排列。

现在是将归纳/递归算法思想转化为实际实现的时候了。早期的决策总是如何表示问题实例中的对象。在这种情况下,我们可能会用一个图形或者一个在对象之间映射的函数来思考。然而,本质上,像这样的映射只是一个位置(0...n–1)与每个元素相关联(也是 0...n–1),我们可以使用一个简单的列表来实现它。比如图 4-4 中的例子(如果 a = 0, b = 1,...)可以表示如下:

>>> M = [2, 2, 0, 5, 3, 5, 7, 4]
>>> M[2] # c is mapped to a
0

Image 提示如果可能的话,试着用一种尽可能具体的方式来表达你的问题。更一般的表示会导致更多的簿记和复杂的代码;如果你使用一个隐含问题约束的表示,那么找到和实现一个解决方案都会容易得多。

*如果我们愿意,我们现在可以直接实现递归算法的思想,用一些强力代码来寻找要消除的元素。它不会非常高效,但是低效的实现有时可以是一个有指导意义的起点。参见清单 4-5 中相对直接的实现。

清单 4-5 。寻找最大排列的递归算法思想的简单实现

def naive_max_perm(M, A=None):
    ifis None:                               # The elt. set not supplied?
        A = set(range(len(M)))                  # A = {0, 1, ... , n-1}
    if len(A) == 1: return A                    # Base case -- single-elt. A
    B = set(M[i] forin A)                    # The "pointed to" elements
    C = A - B                                   # "Not pointed to" elements
    if C:                                       # Any useless elements?
        A.remove(C.pop())                       # Remove one of them
        return naive_max_perm(M, A)             # Solve remaining problem
    return A                                    # All useful -- return all

函数naive_max_perm接收一组剩余的人(A)并创建一组被指向的座位(B)。如果它在 A 中找到一个不在 B 中的元素,它就删除这个元素,然后递归地解决剩下的问题。让我们使用我们的例子中的实现,M8

>>> naive_max_perm(M)
{0, 2, 5}

所以, acf 可以参与排列。其他人将不得不坐在不受欢迎的座位上。

实现并不太差。便捷的集合类型让我们可以用现成的高级操作来操作集合,而不必自己实现它们。不过,还是有一些问题。首先,我们可能需要一个迭代的解决方案。这很容易补救——递归可以很简单地用循环代替(就像我们对插入排序和选择排序所做的那样)。然而,更糟糕的问题是算法是二次的!(练习 4-10 要求你展示这个。)

最浪费的操作是重复创建集合 b。如果我们可以跟踪哪些椅子不再被指向,我们就可以完全消除这个操作。这样做的一个方法是为每个元素保存一个计数。当指向 x 的人被消灭时,我们可以减少椅子 x 的计数,如果 x 的计数为零,人和椅子 x 都将退出游戏。

Image 提示这种参考计数 的思路一般都能有用。例如,它是许多垃圾收集系统中的基本组件(一种自动释放不再有用的对象的内存管理形式)。在拓扑排序的讨论中,您将再次看到这种技术。

在任何时候都可能有不止一个元素需要被删除,但是我们可以把我们遇到的任何新的元素放到一个“待办事项”列表中,以后再处理它们。如果我们需要确保元素按照我们发现它们不再有用的顺序被删除,我们将需要使用一个先入先出队列,比如deque类(在第五章中讨论)。例如,我们并不在乎,所以我们可以使用一个集合,但是仅仅追加到一个列表或者从一个列表中弹出可能会给我们带来更少的开销。当然,你可以随意试验。您可以在清单 4-6 中找到该算法的迭代线性时间版本的实现。

清单 4-6 。寻找最大排列

def max_perm(M):
    n = len(M)                                  # How many elements?
    A = set(range(n))                           # A = {0, 1, ... , n-1}
    count = [0]*n                               # C[i] == 0 for i in A
    forin M:                                 # All that are "pointed to"
        count[i] += 1                           # Increment "point count"
    Q = [i forinif count[i] == 0]         # Useless elements
    while Q:                                    # While useless elts. left...
        i = Q.pop()                             # Get one
        A.remove(i)                             # Remove it
        j = M[i]                                # Who's it pointing to?
        count[j] -= 1                           # Not anymore...
        if count[j] == 0:                       # Is j useless now?
            Q.append(j)                         # Then deal w/it next
    return A                                    # Return useful elts.

Image 提示在最近版本的 Python 中,collections模块包含了Counter类,可以为你统计(可散列)对象。有了它,清单 4-7 中的for循环可以被赋值count = Counter(M)所取代。这可能会有一些额外的开销,但它会有相同的渐进运行时间。

清单 4-7 。对名人问题的天真解决方案

def naive_celeb(G):
    n = len(G)
    forin range(n):                          # For every candidate...
        forin range(n):                      # For everyone else...
            if u == v: continue                 # Same person? Skip.
            if G[u][v]: break                   # Candidate knows other
            if not G[v][u]: break               # Other doesn't know candidate
        else:
            return u                            # No breaks? Celebrity!
    return None                                 # Couldn't find anyone

一些简单的实验(见第二章中的提示)应该会让你相信,即使对于相当小的问题实例,max_perm也比naive_max_perm快很多。但是,它们都非常快,如果您所做的只是解决一个中等大小的实例,您可能会对两者中更直接的一个感到满意。归纳思维在为你提供一个能够找到答案的解决方案时仍然是有用的。当然,你可以尝试每一种可能性,但是那个会导致一个完全无用的算法。然而,如果您必须解决这个问题的一些非常大的实例,或者即使您必须解决许多中等的实例,提出线性时间算法所涉及的额外思考可能会得到回报。

计数排序& FAM

如果你在某个问题中处理的元素是可散列的,或者更好的是,你可以直接使用整数作为索引(就像在置换的例子中),那么计数应该是你手边的一个工具。计数可以做什么的一个最著名的例子是计数排序。正如你将在第六章中看到的,如果你只知道你的值是大于还是小于彼此,那么你排序的速度(在最坏的情况下)是有(对数线性)限制的。

在许多情况下,这是您必须接受的现实,例如,如果您使用自定义比较方法对对象进行排序。对数线性比我们目前看到的二次排序算法要好得多。然而,如果你能你的元素,你就能做得更好。可以线性时间排序!更重要的是,计数排序算法非常简单。(我有提到它有多漂亮吗?)

from collections import defaultdict

def counting_sort(A, key=lambda x: x): B, C = [], defaultdict(list)                # Output and "counts" for x in A: C[key(x)].append(x)                     # "Count" key(x) for k in range(min(C), max(C)+1):           # For every key in the range B.extend(C[k])                          # Add values in sorted order

默认情况下,我只是根据对象的值对它们进行排序。通过提供一个关键函数,你可以根据你喜欢的任何东西进行排序。请注意,密钥必须是有限范围内的整数。如果这个范围是 0...k–1,则运行时间为θ(n+k)。(虽然常见的实现只是简单地用元素进行计数,然后计算出将它们放在B中的什么位置,但是 Python 使得为每个键构建值列表并连接它们变得很容易。)如果几个值有相同的键,它们会以彼此相关的原始顺序结束。具有这种性质的排序算法叫做稳定

举例来说,计数排序确实比快速排序等就地算法需要更多的空间,所以如果数据集和值的范围很大,可能会因为内存不足而导致速度变慢。这可以通过更有效地处理值域来部分解决。我们可以通过对单个数字上的数字(或者单个字符上的字符串或者固定大小块上的位向量)进行排序来做到这一点。如果首先按最低有效位排序,出于稳定性考虑,按第二低有效位排序不会破坏第一次运行时的内部排序。(这有点像在电子表格中逐列排序。)这意味着对于 d 位,你可以在θ(dn时间内对 n 位进行排序。这个算法叫做基数排序 ,练习 4-11 要求你实现。

另一个有点类似的线性时间排序算法是桶排序 。它假设您的值在一个区间内均匀分布,例如,实数在区间[0,1]内,并使用 n 个桶,或子区间,您可以将您的值直接放入其中。在某种程度上,您将每个值散列到其适当的槽中,每个桶的平均(预期)大小是θ(1)。因为桶是有序的,所以你可以遍历它们,在θ(n)时间内对随机数据进行排序。(练习 4-12 要求你实现桶排序。)

名人问题

在名人问题中,你要在人群中寻找一个名人。这有点牵强,尽管它也许可以用于分析社交网络,如脸书和推特。这个想法是这样的:名人不认识任何人,但是每个人都认识这个名人。 10 同一个问题的一个更实际的版本是检查一组依赖项并试图找到一个起点。例如,在一个多线程应用中,您可能有多个线程在相互等待,甚至有一些循环依赖(所谓的死锁),您正在寻找一个不等待任何其他线程但所有其他线程都依赖于它的线程。(一种更现实的处理依赖关系的方法——拓扑排序——将在下一节讨论。)

无论我们如何修饰这个问题,它的核心都可以用图表来表示。我们正在寻找一个节点,它有来自所有其他节点的输入边,但是没有来自 ?? 的输出边。在掌握了我们正在处理的结构之后,我们可以实现一个强力解决方案,看看它是否能帮助我们理解任何东西(参见清单 4-7 )。

naive_celeb函数正面解决问题。浏览所有的人,检查每个人是否是名人。这种检查要通过所有的其他人,确保他们都认识候选人,而候选人不认识他们中的任何一个。这个版本显然是二次型的,但是也有可能将运行时间降低到线性。

和以前一样,关键在于找到一个缩减——尽可能便宜地将问题从 n 人缩减到n–1 人。实际上,naive_celeb实现确实逐步减少了问题。在外部循环的迭代 k 中,我们知道 0...k–1 可能是名人,所以我们只需要解决剩余部分的问题,这正是剩余迭代要做的。这个归约显然是正确的,算法也是如此。这种情况下的新情况是,我们必须努力提高减速的效率。为了得到一个线性算法,我们需要在常数时间中进行缩减。如果我们能做到这一点,问题就等于解决了。正如你所看到的,这种归纳的思维方式真的可以帮助我们找到哪里需要运用我们创造性的解决问题的技巧。

一旦我们集中精力在我们需要做的事情上,问题就不那么难了。要把问题从 n 减少到n–1,我们必须找到一个非名人,这个人要么认识某个人,要么不为其他人所知。如果我们为检查G[u][v]的任何节点uv,我们可以排除uv!如果G[u][v]为真,我们剔除u;否则,我们淘汰v。如果我们保证是一个名人,这就是我们所需要的。否则,我们仍然可以排除所有候选人,只留下一个,但我们需要检查他们是否是名人,就像我们在naive_celeb中所做的那样。您可以在清单 4-8 中找到基于这种简化的算法实现。(您可以使用集合更直接地实现算法思想;你明白了吗?)

清单 4-8 。名人问题的解决方案

def celeb(G):
    n = len(G)
    u, v = 0, 1                                 # The first two
    forin range(2,n+1):                      # Others to check
        if G[u][v]: u = c                       # u knows v? Replace u
        else:       v = c                       # Otherwise, replace v
    if u == n:      c = v                       # u was replaced last; use v
    else:           c = u                       # Otherwise, u is a candidate
    forin range(n):                          # For everyone else...
        if c == v: continue                     # Same person? Skip.
        if G[c][v]: break                       # Candidate knows other
        if not G[v][c]: break                   # Other doesn't know candidate
    else:
        return c                                # No breaks? Celebrity!
    return None                                 # Couldn't find anyone

要尝试这些寻找名人的功能,你可以随便画一张图表。 11 让我们以相等的概率打开或关闭每个边缘:

>>> from random import randrange
>>> n = 100
>>> G = [[randrange(2) forin range(n)] forin range(n)]

现在确保有一个名人在那里,并运行两个函数:

>>> c = randrange(n)
>>> forin range(n):
...     G[i][c] = True
...     G[c][i] = False
...
>>> naive_celeb(G)
57
>>> celeb(G)
57

请注意,虽然一个是二次的,一个是线性的,但构建图的时间(无论是随机的还是来自其他来源)在这里是二次的。这可以避免(对于一个稀疏图,其中平均边数小于θ(n)),用一些其他的图表示;参见第二章中的获取建议。

拓扑排序

几乎在任何项目中,要承担的任务都有部分限制其顺序的依赖关系。比如,除非你有非常前卫的时尚感,否则你需要先穿袜子再穿靴子,但你是否先戴帽子再穿短裤就没那么重要了。这种依赖性(如第二章中提到的那样)很容易用有向无环图(DAG)来表示,寻找一种尊重依赖性的排序(这样所有的边在排序中都指向前面)被称为拓扑排序

图 4-5 说明了这个概念。在这种情况下,有一个唯一有效的排序,但是考虑一下如果你移除边 ab 会发生什么——然后 a 可以放置在顺序中的任何位置,只要它在 f 之前。

9781484200568_Fig04-05.jpg

图 4-5 。按拓扑排序的有向无环图(DAG)及其节点

在任何中等复杂的计算机系统中,拓扑排序问题在许多情况下都会出现。事情需要去做,并且依赖于其他事情...从哪里开始?一个相当明显的例子是安装软件。大多数现代操作系统至少有一个自动安装软件组件(如应用或库)的系统,这些系统可以自动检测某些依赖项何时丢失,然后下载并安装它。为此,必须按照拓扑排序的顺序安装组件。 12

还有一些算法(例如用于在 DAG 中寻找最短路径的算法,以及在某种意义上,大多数基于动态规划的算法)是基于 DAG 被拓扑排序作为初始步骤。然而,虽然标准排序算法很容易封装在标准库之类的东西中,但是抽象出图算法以便它们可以处理任何类型的依赖结构就有点困难了...因此,您需要在某个时候实现它的可能性并不太大。

Image 提示如果您正在使用某种 Unix 系统,您可以使用tsort命令对纯文本文件中描述的图形进行拓扑排序。

在我们的问题中,我们已经有了一个很好的结构表示(DAG)。下一步是寻找一些有用的减少。和以前一样,我们的第一直觉可能应该是删除一个节点,并为剩余的n–1 解决问题(或者假设问题已经解决)。这个相当明显的缩减可以用类似于插入排序的方式来实现,如清单 4-9 所示。(我这里假设是邻接集或者邻接字典之类的;详见第二章。)

清单 4-9 。拓扑排序的一种简单算法

def naive_topsort(G, S=None):
    ifis None: S = set(G)                    # Default: All nodes
    if len(S) == 1: return list(S)              # Base case, single node
    v = S.pop()                                 # Reduction: Remove a node
    seq = naive_topsort(G, S)                   # Recursion (assumption), n-1
    min_i = 0
    for i, u in enumerate(seq):
        ifin G[u]: min_i = i+1               # After all dependencies
    seq.insert(min_i, v)
    return seq

虽然我希望(通过归纳)清楚地知道naive_topsort正确的,但它显然也是二次的(通过表 3-1 中的递归 2)。问题是它在每一步选择一个任意的节点,这意味着它必须在递归调用(这给出了线性工作)之后查看节点适合的位置。我们可以反过来,更像选择排序那样工作。在递归调用之前找到要移除的正确节点。然而,这个新想法给我们留下了两个问题。首先,我们应该删除哪个节点?第二,我们如何高效地找到它? 十三

我们正在处理一个序列(或者至少我们正在一个序列)工作,这可能会给我们一个想法。我们可以做一些类似于选择排序的事情,挑选出应该放在第一个(或最后一个)的元素...其实并不重要;参见练习 4-19)。在这里,我们不能只是把它放在第一位—我们需要真正地把它从图中删除,所以剩下的仍然是一个 DAG(一个等价的但更小的问题)。幸运的是,我们可以在不直接改变图形表示的情况下做到这一点,您马上就会看到。

你会怎么找到一个可以先放的节点?可能有不止一个有效的选择,但选择哪一个并不重要。希望这能让你想起最大排列问题。同样,我们希望找到没有入边的节点。没有内边的节点可以安全地放在第一位,因为它不依赖于任何其他节点。如果我们(从概念上)移除它所有的外边缘,剩下的图,有n–1 个节点,也将是一个 DAG,可以用同样的方式排序。

Image 提示如果一个问题让你想起一个你已经知道的问题或算法,那可能是一个好迹象。事实上,建立一个问题和算法的心理档案是可以让你成为一个熟练的算法专家的事情之一。如果你面临一个问题,而你没有直接的关联,你可以系统地考虑你知道的任何相关(或半相关)的技术,并寻找减少的可能性。

就像在最大排列问题中,我们可以通过计数找到没有 in-edges 的节点。通过从一个步骤到下一个步骤保持我们的计数,我们不需要每次都重新开始,这将线性步骤成本降低到一个常数(产生一个线性运行总时间,如表 3-1 中的递归 1)。清单 4-10 展示了这种基于计数的拓扑排序的迭代实现。(你能看出迭代结构是如何体现递归思想的吗?)关于图形表示的唯一假设是我们可以迭代遍历节点及其邻居。

清单 4-10 。有向无环图的拓扑排序

def topsort(G):
    count = dict((u, 0) forin G)             # The in-degree for each node
    forin G:
        forin G[u]:
            count[v] += 1                       # Count every in-edge
    Q = [u forinif count[u] == 0]         # Valid initial nodes
    S = []                                      # The result
    while Q:                                    # While we have start nodes...
        u = Q.pop()                             # Pick one
        S.append(u)                             # Use it as first of the rest
        forin G[u]:
            count[v] -= 1                       # "Uncount" its out-edges
            if count[v] == 0:                   # New valid start nodes?
                Q.append(v)                     # Deal with them next
    return S

黑盒:拓扑排序和 PYTHON 的 MRO

我们在这一节中处理的这种结构排序实际上是 Python 面向对象继承语义的一个组成部分。对于单继承(每个类都是从单个超类派生的),选择正确的属性或方法很容易。简单地沿着“继承链”向上走,首先检查实例,然后是类,然后是超类,等等。第一个拥有我们要找的东西的类被使用。

然而,如果你有不止一个超类,事情就有点棘手了。考虑以下示例:

>>> class X: pass >>> class Y: pass >>> class A(X,Y): pass

如果你想从AB中派生出一个新的类C,那你就有麻烦了。你不知道是在X还是Y中寻找方法。

一般来说,继承关系形成了一个 DAG(你不能在一个循环中继承),为了弄清楚在哪里寻找方法,大多数语言创建了一个类的线性化,这只是 DAG 的一个拓扑排序。Python 的最新版本使用了一种称为 C3 的方法解析顺序(或 MRO )(更多信息请参见参考资料),这种方法除了以尽可能有意义的方式对类进行线性化之外,还禁止出现像前面示例中那样的问题情况。

9781484200568_unFig04-01.jpg

属地。 软件包管理类 CPSC 357 的先决条件是 CPSC 432、CPSC 357 和 glibc2.5 或更高版本( http://xkcd.com/754 )

更强的假设

设计算法时默认的归纳假设是“我们可以用它来解决更小的实例”,但有时这不足以实际执行归纳步骤或有效地执行它。选择子问题的顺序可能很重要(例如在拓扑排序中),但有时我们必须实际做出一个更强的*?? 假设,以在我们的归纳中附带一些额外的信息。虽然一个更强的假设看起来会让证明变得更难, 14 它实际上只是给了我们更多的来处理来推导从n–1(或 n /2,或其他大小)到 n 的步长。*

考虑平衡因素的想法。这些被用在一些类型的平衡树中(在第六章的中讨论),并且是一个树或者子树平衡(或者不平衡)程度的度量。为简单起见,我们假设每个内部节点都有两个子节点。(在实际实现中,一些叶子可能仅仅是None等。)为每个内部节点定义平衡因子,并将其设置为左右子树的高度之差,其中高度是从节点(向下)到叶子的最大距离。例如,图 4-6 中根的左子树的平衡因子为–2,因为它的左子树是一片叶子(高度为 0),而它的右子树的高度为 2。

9781484200568_Fig04-06.jpg

图 4-6 。二叉树的平衡因子。只为内部节点(突出显示)定义了平衡因子,但是对于叶节点,可以将其设置为零

计算平衡因子并不是一个非常具有挑战性的算法设计问题,但它确实说明了一点。考虑明显的(分治)缩减。为了找到根的平衡因子,递归地求解每个子树的问题,然后将部分解扩展/组合成完整的解。很简单。除...之外...没用的。我们可以解决更小的子问题的归纳假设在这里对我们没有帮助,因为我们的子问题的解决方案(即平衡因子)没有包含足够的信息来进行归纳步骤!平衡系数不是根据孩子的平衡系数定义的,而是根据他们的身高定义的。只要加强我们的假设,我们就能轻松解决这个问题。我们假设可以找到任意一棵有 k < n 个节点的树的平衡因子和高度。我们现在可以在归纳步骤中使用高度,在归纳步骤中找到尺寸 n 的平衡系数(左高减去右高)和高度(左高和右高的最大值加 1)。问题解决了!练习 4-20 要求你解决这里的细节问题。

Image 注意树上的递归算法与深度优先搜索密切相关,在第五章中讨论。

正式思考加强归纳假设有时会有点混乱。相反,你可以想一想,为了建立一个更大的解决方案,你需要在归纳步骤中“附带”哪些额外的信息。例如,在前面使用拓扑排序时,很明显,在我们逐步通过部分解决方案时,捎带(和维护)入度使得更有效地执行归纳步骤成为可能。

更多强化归纳假设的例子,参见第六章中的最近点问题和练习 4-21 中的区间包含问题。

反向感应和二次幂

有时候限制我们正在处理的问题的大小是很有用的,比如只处理 2 的幂。例如,这经常发生在分治算法中(分别参见第三章和第六章的递归和示例)。在许多情况下,无论我们发现什么样的算法或复杂性,对任何值的 n 都仍然有效,但有时,对于本章前面描述的棋盘覆盖问题,情况并非如此。可以肯定的是,我们可能需要证明 n 的任何值都是安全的。对于复发,可以使用第三章中的归纳方法。为了显示正确性,可以使用反向归纳。假设算法对于 n 是正确的,并表明这意味着对于n–1 是正确的。这通常可以通过简单地引入一个“虚拟”元素来实现,该元素不会影响解决方案,但是会将大小增加到 n 。如果你知道这个算法对于无限大小的集合(比如 2 的所有幂)是正确的,反向归纳将让你证明它对于所有的尺寸都是正确的。

不变量和正确性

本章的主要焦点是设计算法,其中正确性来自设计过程。也许在计算机科学中,归纳的一个更普遍的观点是正确性证明。这和我在这一章中讨论的基本上是一样的,只是角度略有不同。你面前是一个完成的算法,你需要证明它是可行的。对于递归算法,我已经展示给你的思想可以直接使用。对于循环,也可以递归思考,但是有一个概念更直接的适用于迭代的归纳证明:循环不变量。给定一些前提条件,循环不变量是在循环的每次迭代后为真的东西;它被称为不变量,因为它不会变化——从头到尾都是真的。

通常,最终解是不变量在最后一次迭代后得到的特例,所以如果不变量总是成立(给定算法的前提条件),并且你可以证明循环终止,你就证明了算法是正确的。让我们用插入排序来试试这种方法(清单 4-2 )。循环的不变量是元素 0... i 被排序(如代码中第一个注释所暗示的)。如果我们想用这个不变量来证明正确性,我们需要做以下事情:

  1. 使用归纳法来证明它在每次迭代后都是真实的。
  2. 表明如果算法终止,我们会得到正确的答案。
  3. 表明算法终止。

步骤 1 中的归纳包括显示一个基本情况(即第一次迭代的之前的*)和一个归纳步骤(循环的单次运行保持不变量)。第二步包括在终止点使用不变量。第三步通常很容易证明(也许通过展示你最终“用完”了某样东西)。 15*

对于插入排序,步骤 2 和 3 应该是显而易见的。for循环将在 n 次迭代后终止,其中I=n–1。不变量然后说元素 0...n–1 排序,表示问题解决。基本情况( i = 0)是微不足道的,因此剩下的就是归纳步骤——通过在排序值的正确位置插入下一个元素(不中断排序)来显示循环保持不变量。

放松和逐步改善

术语放松 取自数学,在数学中有几个意思。这个术语已经被算法学家使用,用来描述几种算法中的关键步骤,特别是基于动态规划的最短路径算法(在第八章和第八章第九章中讨论),在这些算法中,我们逐渐改进我们对最优解的逼近。以这种方式逐步改进解决方案的想法也是寻找最大流的算法的核心。我现在还不会深入这些算法是如何工作的,但是让我们来看一个简单的例子,它可能被称为放松

你在一个机场,坐飞机可以到达其他几个机场。从这些机场中的每一个,你可以乘火车到几个城镇和城市。假设你有一个航班时刻表,即A,那么A[u]就是你到达u机场的时间。同样,B[u][v]会给你坐火车从机场u到城镇v所需的时间。(B可以是列表的列表,也可以是字典的字典,例如;参见第二章。)考虑以下随机方法来估计你到达每个城镇所需的时间,C[v]:

>>> forin range(n):
...     C[v] = float('inf')
>>> forin range(N):
...     u, v = randrange(n), randrange(n)
...     C[v] = min(C[v], A[u] + B[u][v]) # Relax

这里的想法是反复看看我们是否可以通过选择另一条路线来提高我们对C[v]的估计。先坐飞机去u,然后坐火车去v。如果这给了我们一个更好的总时间,我们更新C。只要N真的很大,我们最终会得到每个城镇的正确答案。

对于实际上保证正确解的基于松弛的算法,我们需要做得比这更好。对于飞机+火车的问题,这相当容易(见练习 4-22)。对于更复杂的问题,你可能需要更微妙的方法。例如,你可以表明你的解的值在每次迭代中增加一个整数;如果算法只在你达到最优(整数)值时终止,那么它一定是正确的。(这类似于最大流算法的情况。)或者,您可能需要展示正确的估计如何在问题实例的元素之间传播,例如图中的节点。如果目前这看起来有点笼统,不要担心——当我们遇到使用该技术的算法时,我会得到足够具体的信息。

Image 提示用放松来设计算法可以像一个游戏。每一次放松都是一个“动作”,你试图用尽可能少的动作获得最优解。你总是可以通过全身放松来达到目的,但关键在于以正确的顺序表演你的动作。当我们在 DAGs ( 第八章)、Bellman-Ford 和 Dijkstra 的算法(第九章)中处理最短路径时,将进一步探讨这个想法。

归约+对位=硬度证明

这一节实际上只是你将在第十一章中遇到的一些铺垫。你看,虽然归约被用来解决问题,但大多数教科书讨论它们的唯一背景是问题复杂性,它们被用来表明你【可能】不能解决一个给定的问题。这个想法真的很简单,但我已经看到它绊倒了我的许多(甚至大多数)学生。

硬度证明是基于这样一个事实,即我们只允许简单(即快速)的减少。比方说,你能把问题 A 简化为 B(所以 B 的解也给你一个 A 的解;看看图 4-1 如果你需要刷新你的记忆关于这是如何工作的)。我们知道,如果 B 是简单的,那么 A 也一定是简单的。这直接源于这样一个事实,我们可以用 B,加上一个简单的简化,来求解 a。

例如,假设 A 正在寻找 DAG 中两个节点之间的最长路径*,而 B 正在寻找 DAG 中两个节点之间的最短路径。然后,你可以通过简单地将所有的边都视为负值来将 A 简化为 B。现在,如果你学会了在允许负边权重的 DAGs 中寻找最短路径的有效算法(你将在第八章中看到),你也自动拥有了寻找最长路径和边权重的有效算法。 17 换句话说,快速归约+快速解 B =快速解 a。*

现在让我们应用我们的朋友对位。我们已经建立了“如果 B 很容易,那么 A 也很容易。”反命题是“如果 A 是硬的,那么 B 也是硬的。” 18 这个应该还是挺容易理解的,直观上。如果我们知道 A 是困难的,无论我们如何接近它,我们都知道 B 不可能是容易的——因为如果它容易的,它将为我们提供一个解决 A 的简单方法,而 A 终究不会是困难的(一个矛盾)。

我希望这一节到目前为止是有意义的。现在推理还有最后一步。如果我遇到一个新的未知问题 X,我已经知道问题 Y 是难的,我怎么用一个归约来说明 X 是难的?

基本上有两种选择,所以几率应该是五五开左右。奇怪的是,在我询问的人中,似乎有超过一半的人在稍加思考之前就理解错了。答案是,把 Y 归约成 x,(你答对了吗?)如果你知道 Y 是硬的,你把它简化为 X,那么 X 一定是硬的,因为否则它可以很容易地用来解 Y——这是一个矛盾。

向另一个方向减少真的不会让你有任何进展。例如,修理一台被砸坏的电脑很难,但是如果你想知道修理你的(未被砸坏的)电脑是容易还是困难,砸坏它并不能证明什么。

因此,总结一下这里的推理:

  • 如果你能(轻易地)把 A 化简为 B,那么 B 至少和 A 一样努力。
  • 如果你想证明 X 是硬的,你知道 Y 是硬的,把 Y 简化成 X。

许多人对此感到困惑的原因之一是,我们通常认为减少是将一个问题转化为更容易的事情。就连“归约”这个名字也隐含了这一点。然而,如果我们通过将 A 简化为 B 来求解 A,那么只有似乎像 B 更容易,因为它是我们已经知道如何求解的东西。简化后,A 是一样简单——因为我们可以通过 B 解决它(加上一个简单快速的简化)。换句话说,只要你的减少没有带来任何沉重的负担,你就不能永远不要减少到更容易的事情,因为减少的行为会自动平衡事情。将 A 简化为 B,B 自动地至少和 A 一样硬。

让我们暂时就这样吧。我将在第十一章中详细介绍。

解决问题的建议

以下是一些解决算法问题和设计算法的建议,总结了本章的一些主要观点:

  • 确保你真的理解了这个问题。什么是输入?产量?两者的精确关系是什么?尝试将问题实例表示为熟悉的结构,例如序列或图形。直接、强力的解决方案有时有助于明确问题所在。

  • Look for a reduction. Can you transform the input so it works as input for another problem that you can solve? Can you transform the resulting output so that you can use it? Can you reduce an instance of size n to an instance of size k < n and extend the recursive solution (inductive hypothesis) back to n?

    这两者共同构成了算法设计的强大方法。我还将在这里添加第三个项目。这与其说是第三步,不如说是在完成前两步时需要记住的事情:

  • 你还有其他可以利用的假设吗?固定值范围内的整数可以比任意值更高效的排序。在 DAG 中寻找最短路径比在任意图中更容易,并且仅使用非负边权重通常比任意边权重更容易。

现在,你应该能够开始使用前两条建议来构建你的算法。第一点(理解和表现问题)可能看起来显而易见,但对问题结构的深刻理解可以使找到解决方案变得容易得多。考虑特例或简化,看看它们是否能给你灵感。一厢情愿的想法在这里可能是有用的,丢弃部分问题规范,这样你就可以一次考虑一个或几个方面。(“如果我们忽略边缘权重会怎么样?如果所有的数字都是 0 或 1 呢?如果所有的弦都是等长的呢?如果每个节点都有恰好 k 个邻居会怎么样?”)

第二项(寻求简化)在本章中已经讨论了很多,尤其是简化为(或分解为)子问题。这在设计你自己的全新算法时至关重要,但通常情况下,你更有可能找到一个几乎适合的算法。寻找你认识到的问题的模式或方面,扫描你的大脑档案,寻找可能相关的算法。你能不能不构造一个算法来解决问题,而是构造一个算法来转换实例,以便现有的算法可以解决它们?系统地处理你知道的问题和算法比等待灵感更有成效。

第三项更多的是一般性的观察。针对特定问题定制的算法通常比更通用的算法更有效。即使你知道一个通用的解决方案,也许你可以调整它来使用这个特殊问题的额外约束?如果你已经构建了一个蛮力解决方案来试图理解这个问题,也许你可以通过利用这个问题的这些怪癖把它发展成一个更有效的解决方案?考虑修改插入排序,使其成为桶排序,例如, 19 ,因为您知道一些值的分布。

摘要

这一章是关于通过某种方式将一个问题简化为你知道如何解决的问题来设计算法。如果你把它简化成一个完全不同的问题,你也许可以用现有的算法来解决它。如果你把它简化成一个或多个子问题(同一问题的更小的实例),你就可以归纳地解决它,归纳设计给你一个新的算法。本章中的大多数例子都是基于弱归纳法或对大小为n–1 的子问题的扩展解决方案。在后面的章节里,尤其是第六章,你会看到更多强归纳法的使用,这里的子问题可以是任意大小 k < n

这种规模缩减和归纳与递归密切相关。归纳是你用来证明递归是正确的,递归是实现大多数归纳算法思想的一种非常直接的方式。但是,将算法重写为迭代式可以避免大多数非函数式编程语言中递归函数的开销和限制。如果一个算法开始是迭代的,你仍然可以递归地考虑它,通过查看到目前为止解决的子问题,就好像它是通过递归调用计算的。另一种方法是定义一个循环不变量,它在每次迭代后都是正确的,并且你可以用归纳法来证明。如果表明算法终止,可以用不变量来表明正确性。

在本章的例子中,最重要的一个可能是拓扑排序:对 DAG 的节点进行排序,使所有的边都指向前方(也就是说,所有的依赖关系都得到考虑)。例如,这对于找到执行相互依赖的任务的有效顺序,或者对于在更复杂的算法中排序子问题是很重要的。这里提出的算法重复地删除没有入边的节点,将它们附加到排序中,并保持所有节点的入度,以保持解决方案的效率。第五章描述了这个问题的另一种算法。

在一些算法中,归纳思想不仅仅与子问题的大小有关。它们是基于一些估计的逐渐改进,使用一种叫做松弛的方法。例如,这在许多算法中用于寻找加权图中的最短路径。为了证明这些是正确的,您可能需要揭示评估如何改进的模式,或者正确的评估如何在您的问题实例的元素中传播。

虽然在这一章中使用了归约来表明一个问题是简单的,也就是说,你也可以用归约来表明一个问题至少和另一个问题一样难。如果你把问题 A 化简为问题 B,化简本身很容易,那么 B 至少要和 A 一样难(或者我们得到一个矛盾)。这个想法在第十一章中有更详细的探讨。

如果你好奇的话...

正如我在引言中所说,这一章在很大程度上受到了 Udi Manber 的论文“使用归纳法设计算法”的启发。关于那篇论文和他后来关于同一主题的书的信息可以在“参考资料”部分找到。我强烈建议你至少看一下这篇论文,你可以在网上找到。在本书的其余部分,你还会遇到这些原则的几个例子和应用。

如果你真的想理解递归是如何被用于几乎任何事情的,你可能想尝试一下函数式语言,比如 Haskell(见http://haskell.org)或 Clojure(见http://clojure.org)。仅仅浏览一些函数式编程的基础教程就可以加深你对递归的理解,从而大大加深对归纳法的理解,特别是如果你对这种思维方式有点陌生的话。你甚至可以看看 Rabhi 和 Lapalme 写的关于 Haskell 算法的书,以及 Okasaki 写的关于函数式语言数据结构的书。

虽然我在这里只关注递归的归纳性质,但是还有其他方式来展示递归是如何工作的。例如,存在一个所谓的递归不动点理论,它可以用来确定递归函数真正做什么。这是相当沉重的东西,我不会推荐它作为开始的地方,但是如果你想了解更多,你可以看看 Zohar Manna 的书,或者 Michael Soltys 的书(描述稍微简单一些,但是不太全面)。

如果你想要更多的解决问题的建议,Pólya 的如何解决是一本经典,不断被转载。值得一看。你可能还想得到史蒂文·斯基纳的算法设计手册*。这是一个相当全面的基本算法参考,以及设计原则的讨论。他甚至有一个非常有用的解决算法问题的清单。*

练习

4-1.你可以在平面上画出没有任何边互相交叉的图叫做平面。这样的绘图将会有许多的区域,由图的边所包围的区域,以及围绕图的(无限大的)区域*。如果图分别有 VEF 个节点、边和区域,那么平面连通图的欧拉公式说VE+F= 2。用归纳法证明这是正确的。*

4-2.考虑一盘巧克力,由矩形排列的 n 个正方形组成。你想把它分成独立的方块,你要使用的唯一操作是把当前的一个矩形(一旦你开始打破,会有更多的矩形)分成两块。做这件事最有效的方法是什么?

4-3.假设你要邀请一些人参加一个聚会。你在考虑交朋友,但是你知道只有当他们每个人都至少认识聚会上的其他人时,他们才会玩得开心。(假设如果 A 知道 B,那么 B 自动知道 A。)通过设计一个算法来寻找你的朋友的最大可能子集来解决你的问题,其中每个人都知道至少其他人的 k ,如果这样的子集存在的话。

加分题:如果你的朋友平均认识 d中的其他人并且至少有一个的人认识至少一个其他人,说明你总能找到 kd /2 的(非空)解。

4-4.如果从一个节点到同一图中任何其他节点的最大(未加权)距离是最小的,则该节点称为中心。也就是说,如果您按照节点到任何其他节点的最大距离对节点进行排序,中心节点将位于开头。解释为什么无根树有一个或两个中心节点,并描述寻找它们的算法。

4-5.还记得第三章里的骑士吗?在他们的第一次锦标赛(循环锦标赛,每个骑士互相比武)之后,工作人员想要创建一个排名。他们意识到可能无法创建一个独特的等级,甚至无法进行适当的拓扑排序(因为可能会有骑士互相击败的循环),但他们决定采用以下解决方案:按顺序排列骑士K1,K2,...、 Kn ,其中 K 1K 2K 2K 3 以此类推(Ki–1Ki ,为 i ...n)。证明总是可以通过设计一个构建序列的算法来构建这样一个序列。

4-6.乔治·波利亚(《如何解决的作者;见“参考资料”一节)提出了以下有趣的(和故意谬误的)“证明”,即所有的马都有相同的颜色。如果你只有一匹马,那么很明显只有一种颜色(基本情况)。现在我们要证明 n 匹马颜色相同,在归纳假设下n–1 匹马都是这样。考虑集合{1,2,...,n–1 }和{2,3,..., n }。这两件衣服的尺寸都是 1,所以每套衣服只有一种颜色。但是,因为集合重叠,所以对于{1,2,... n }。这个论点的错误在哪里?

4-7.在“一、二、多”一节前面的例子中,我们想要显示一棵有 n 片叶子的二叉树有多少个内部节点,而不是从n–1 到 n ,我们从 n 个节点开始,删除了一片叶子和一个内部节点。为什么没问题?

4-8.使用来自第二章的标准规则和来自第三章的递归,表明清单 4-1 到清单 4-4 中四种排序算法的运行时间都是二次的。

4-9.在递归寻找最大排列时(如清单 4-5 中的),我们如何确定我们最终得到的排列至少包含一个人呢?从理论上来说,不应该有可能移除所有人吗?

4-10.证明寻找最大排列的简单算法(列表 4-5 )是二次的。

4-11.实现基数排序。

4-12.实现桶排序。

4-13.对于固定位数(或字符或元素)的数字(或字符串或序列), d ,radix sort 的运行时间为θ(dn)。假设您正在对位数相差很大的数字进行排序。一个标准的基数排序会要求你将 d 设置为其中的最大值,用初始零填充其余部分。例如,如果一个数字的位数比其他数字多得多,这就不是很有效。如何修改算法使其运行时间为θ(≘di,其中 di 是第 i 个数字的位数?

4-14.如何对数值范围 1 中的 n 个数字进行排序...n2 在θ(n时间内?

4-15.在最大排列问题中求 in-degrees 时,为什么计数数组可以简单地设置为[M.count(i) for i in range(n)]

4-16.“使用归纳(和递归)进行设计”一节描述了三个问题的解决方案。通过实验比较算法的原始版本和最终版本。

4-17.解释为什么naive_topsort是正确的;为什么将最后一个节点直接插入其依赖项之后是正确的?

4-18.写一个生成随机 Dag 的函数。使用您的 DAG 生成器编写一个自动测试来检查topsort是否给出了有效的排序。

4-19.重新设计topsort,使其在每次迭代中选择最后一个节点*,而不是第一个节点。*

4-20.实现在二叉树中寻找平衡因子的算法。

4-21.例如,一个区间可以表示为一对数字,例如(3.2,4.9)。假设您有一个这样的区间列表(其中没有相同的区间),并且您想知道哪些区间落在其他区间内。当 xuvy 时,一个音程( uv )落在( xy )内。你如何有效地做到这一点?

4-22.在“放松和逐步改进”一节中,你将如何改进飞机+火车问题的基于放松的算法,从而保证你在多项式时间内得到答案?

4-23.考虑三个问题, foobarbaz 。你知道很难,而很容易。你会如何表现出 foo 很难?你如何证明这很容易?

参考

曼伯大学(1988 年)。用归纳法设计算法。ACM 的通信,31(11):1300–1313。

曼伯大学(1989 年)。算法介绍:一种创造性的方法。艾迪森-韦斯利。

曼纳,Z. (1974)。数学计算理论。麦格劳-希尔图书公司。

冈崎,C. (1999 年)。纯功能数据结构。剑桥大学出版社。

Pólya,G. (2009 年)。如何求解:数学方法的一个新方面。石井出版社。

Rabhi,F. A .和 Lapalme,G. (1999 年)。算法:一种功能方法。艾迪森-韦斯利。

西米奥纳托(2006 年)。Python 2.3 方法解析顺序。http://python.org/download/releases/2.3/mro

斯基埃纳,S. S. (2008 年)。算法设计手册,第二版。斯普林格。

索尔提斯博士(2010 年)。算法分析介绍。世界科学。


这实际上是练习 2-10,但是如果你愿意,你仍然可以试一试。尝试使用归纳法在没有的情况下解决*。*

2 我建议你自己验证一下。

3

4 与泽弗兰克的秀,2007 年 2 月 22 日。

5 有没有试过用谷歌搜索递归?你可能想试试。并关注搜索建议。

6 如第三章所述,在归纳中归纳假设适用于n–1,而在归纳中适用于所有正整数 k < n

这些算法并不那么有用,但它们通常被教授,因为它们是很好的例子。此外,它们是经典,所以任何算法专家都应该知道它们是如何工作的。

8 如果你使用的是 Python 2.6 或更早的版本,结果会是set([0, 2, 5])

9 在一个链表的开头插入或删除是一个线性时间的操作,还记得吗?通常不是一个好主意。

10 有谚语把这个名人换成小丑、傻瓜或猴子。或许有点合适。

11 事实上,有一个关于随机图的丰富理论。网上搜索应该会找到很多材料。

12 描述“检测到某些依赖项缺失时,下载并安装它”实际上几乎是对另一种算法拓扑排序的字面描述,这在第五章中讨论。

13 没有有效的选择,我们不会有任何收获。例如,我比较过的算法,插入和选择排序,都是二次的,因为在未排序的元素中选择最大或最小的元素并不比在排序的元素中插入它容易。

14 总的来说,你当然要小心不必要的假设。用亚历克·麦肯齐(Brian Tracy)的话说,“错误的假设是每一次失败的根源。”或者,正如大多数人所说,“假设是所有愚蠢行为之母。”归纳中的假设被证明,尽管是从基础案例一步一步来的。

15 详见第十一章中对停止问题的讨论。

16第十一章中最重要的情况是当“易”表示多项式。这个逻辑也适用于其他情况。

17 不过只在达格。在一般的图中寻找最长的路径是一个未解决的问题,正如在第十一章中所讨论的。

18 例如,“我思故我在”等同于“我不在,故我思不在。”然而,它是而不是等同于“我在,所以我想”

19 在本章前面的边栏“计数排序& Fam”中讨论过。***