Python-算法教程-全-
Python 算法教程(全)
零、前言
这本书融合了我的三大爱好:算法、Python 编程和解释事物。对我来说,这三者都是关于美学的——找到做事情的正确方式,寻找直到你发现一丝优雅,然后打磨它直到它闪闪发光(或者至少直到它更闪亮)。当然,当有很多材料要覆盖时,你可能无法像你想要的那样润色。幸运的是,这本书的大部分内容都是预先润色过的,因为我写的是非常漂亮的算法和证明,以及最可爱的编程语言之一。至于第三部分,我已经尽力去寻找能让事情看起来尽可能明显的解释。即便如此,我确信我在很多方面都失败了,如果你有改进这本书的建议,我很乐意收到你的来信。谁知道呢,也许你的一些想法可以在未来的版本中出现。不过,现在,我希望你对这里的内容感兴趣,并带着任何新发现的见解去做。如果可以的话,用它来让世界变得更美好,无论用哪种看起来正确的方式。
一、简介
- 写下问题。
- 好好想想。
- 写下解决方案。
——默里·盖尔·曼描述的“费曼算法”
考虑下面的问题:你要参观所有的城市、城镇和村庄,比如说,瑞典,然后回到你的出发点。这可能需要一段时间(毕竟要访问 24,978 个地点),所以您希望最小化您的路径。你计划沿着尽可能最短的路线,对每个地点只访问一次。作为一个程序员,你当然不希望手工绘制路线。相反,你试着写一些代码来为你计划你的旅行。然而,出于某种原因,你似乎不能把它做好。一个简单的程序适用于少数城镇和城市,但似乎永远适用于实际问题,并且改进该程序变得异常困难。怎么会这样
事实上,在 2004 年,一个由五名研究人员组成的团队发现了这样一条瑞典之旅,此前许多其他研究团队都尝试过,但都失败了。这个五人团队使用了先进的软件,其中包含许多聪明的优化和行业技巧,在 96 个至强 2.6GHz 工作站的集群上运行。他们的软件从 2003 年 3 月运行到 2004 年 5 月,最后才打印出最优解。考虑到各种各样的中断,该团队估计总的 CPU 时间大约为 85 年!
考虑一个类似的问题:你想从中国最西部的喀什到东海岸的宁波,走最短的路线。现在,中国有 3,583,715 公里的公路和 77,834 公里的铁路,有数百万个十字路口要考虑,还有数不清的路线可供选择。这个问题看起来与前一个问题有关,然而这个最短路径问题是一个通过 GPS 软件和在线地图服务常规解决的问题,没有明显的延迟。如果你把这两个城市给你最喜欢的地图服务,你应该在短短的时间内得到最短的路线。这是怎么回事?
在本书的后面,你会学到更多关于这两个问题的知识;第一个问题叫做 旅行推销员(或销售代表 ) 问题并在第十一章中涉及,而所谓的最短路径问题主要在第九章中处理。我还希望你能深入了解为什么一个问题看起来如此难以解决,而另一个问题却有几个众所周知的有效解决方案。更重要的是,你将学到一些关于如何处理算法和计算问题的知识,要么使用本书中遇到的几种技术和算法中的一种有效地解决它们,要么表明它们太难了,近似的解决方案可能是你所能希望的。这一章简要描述了这本书的内容——你可以期待什么,以及对你的期望是什么。它还概述了各个章节的具体内容,以防你想跳过。
那这是什么?
这是一本为 Python 程序员编写的关于算法问题解决的书。就像关于面向对象模式的书籍一样,它处理的问题是一般性的——解决方案也是如此。然而,对于算法专家来说,这项工作不仅仅是简单地实现或执行现有的算法。人们期望你提出新的算法——新的通用解决方案来解决迄今为止未见过的通用问题。在这本书里,你将学习构建这种解决方案的原则。
不过,这不是你的典型算法书 。大多数关于这个主题的权威书籍(如 Knuth 的经典著作或 Cormen 等人的行业标准教科书)都有严重的形式和理论倾向,即使其中一些(如 Kleinberg 和 Tardos 的)更倾向于可读性。我不想取代这些优秀的书籍,我想用来补充它们。基于我教授算法的经验,我试图尽可能清晰地解释算法是如何工作的,以及其中许多算法背后的共同原理。对于一个程序员来说,这些解释大概就够了。你可能会理解为什么算法是正确的,以及如何将它们应用到你可能面临的新问题中。然而,如果你需要形式主义和百科全书式的教科书的全部深度,我希望你在本书中获得的基础将帮助你理解你在那里遇到的定理和证明。
注这本书和其他算法教科书的一个区别是,我采用了一种相当对话式的语气。虽然我希望这至少能吸引我的一些读者,但它可能不合你的胃口。对此我很抱歉——但现在你至少得到了警告。
还有另一种类型的算法书籍:在空白中的“(数据结构和)算法”,其中空白是作者最喜欢的编程语言。其中有不少(特别是对于 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 = []
>>> for i in range(count):
... nums.append(i)
...
>>> nums.reverse()
也许不是最有用的代码。它只是将一串数字附加到(最初的)空列表中,然后反转该列表。在更现实的情况下,这些号码可能来自外部来源(例如,它们可能是到服务器的传入连接),您希望以相反的顺序将它们添加到您的列表中,也许是为了优先考虑最近的号码。现在你有了一个想法:不要在末尾颠倒列表,难道你不能在开头插入数字吗?下面是一个简化代码的尝试(在同一个解释器窗口中继续):
>>> nums = []
>>> for i in range(count):
... nums.insert(0, i)
除非您以前遇到过这种情况,否则新代码可能看起来很有希望,但是请尝试运行它。你可能会注意到明显的减速。在我的电脑上,第二段代码完成的时间大约是第一段的 200 倍。 5 不仅速度慢,而且问题大小越大,速度越快。例如,尝试将count
从10**5
增加到10**6
。正如预期的那样,这将第一段代码的运行时间增加了大约 10 倍……但是第二个版本大约慢了两个数量级,比第一个版本慢了两千倍!正如您可能猜到的那样,随着问题变得越来越大,两个版本之间的差异只会越来越大,这使得在它们之间做出选择变得更加重要。
注这是一个线性与二次增长的例子,一个在第三章中详细讨论的话题。二次增长背后的具体问题在第二章的中
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 列表并不是传统计算机科学意义上的真正列表,这解释了为什么append
比insert
更有效。一个经典的列表——所谓的链表——被实现为一系列的节点,每个节点(除了最后一个)都保存着对下一个节点的引用。一个简单的实现可能是这样的:
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 的程序。
一个重要的问题随即产生:这里的单位是什么?无论我们用秒或毫秒来度量运行时间,还是用比特或兆字节来表示问题的大小,这似乎都无关紧要。然而,有些令人惊讶的答案是,它不仅微不足道,而且实际上根本不会影响我们的结果。我们可以用木星年来度量时间,用千克来度量问题的大小(大概是所用存储介质的质量),而这并不重要。这是因为我们忽略实现细节的初衷也适用于这些因素:渐近符号忽略了所有这些因素!(不过,我们通常假设问题大小是一个正整数。)
我们最终经常做的是让运行时间成为执行某个基本操作的次数,而问题大小要么是处理的项目数(例如要排序的整数数),要么在某些情况下,是以某种合理的编码方式对问题实例进行编码所需的位数。
遗忘。 当然,这个断言是行不通的。( http://xkcd.com/379
)
注意只要你是合理的,你如何将你的问题和解决方案编码成位模式通常对渐近运行时间没有什么影响。例如,避免用一元数字系统表示数字(1=1,2=11,3 = 111……)。
渐近符号由一串操作符组成,写为希腊字母。最重要的,也是我们将使用的,是 O (最初是一个 omicron,但现在通常称为“大 Oh”)、ω(ω)和θ(θ)。 O 操作符的定义可以作为其他两个操作符的基础。表达式 O ( g ),对于某个函数 g ( n ),表示一个函数的集合,一个函数 f ( n )如果满足以下条件则在这个集合中:存在一个自然数 n 0 和一个正常数 c 使得
f(n)≤??【CG】(n
对于所有 n ≥ n 0 。换句话说,如果我们被允许调整常数 c (例如,通过在不同速度的机器上运行算法),函数 g 将最终(即,在n0 处)变得比 f 大。示例见图 2-1 。
图 2-1 。对于 n 大于 n 0 的值,T(n)小于 cn 2 ,所以 T(n)为 O(n 2
这是一个相当简单易懂的定义,尽管乍一看可能有点陌生。基本上, O ( g )就是增长速度不超过 g 的函数集合。例如,函数n2 在集合O(n2)中,或者,在集合符号中,n2∈O(n2)。我们经常简单的说n2 就是O(n2)。
n 2 并不比自身增长得快,这个事实并不特别有趣。也许更有用的是,无论是 2.4n2+7 还是线性函数 n 都不会。也就是说,我们两者都有
2.4n【2】+7”或(
*和
n∈O(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
对于所有 n ≥ n 0 。所以,其中 O 形成所谓的渐近上界,ω形成渐近下界。
注我们的前两个渐近算子 O 和ω是彼此的逆:如果 f 是 O ( g ,那么 g 是ω(f)。练习 2-3 要求你展示这个。
θ形成的集合只是另外两个的交集,即θ(g)=O(g)∩ω(g)。换句话说,如果满足以下条件,函数 f 在θ(g)中:存在自然数 n 0 和两个正常数 c 1 和 c 2 使得
【1】()≤【f】()≤****
对于所有 n ≥ n 0 。这意味着 f 和 g 具有相同的渐近增长。例如,3 n 2 + 2 是θ(n2),但是我们也可以写成 n 2 是θ(3n*2+2)。通过同时提供一个上界和一个下界,θ操作符是三个中信息最丰富的,我将尽可能使用它。
交通规则
虽然渐近算子的定义可能有点难以直接使用,但它们实际上导致了一些有史以来最简单的数学。您可以删除所有乘法和加法常数,以及函数中的所有其他“小部分”,这大大简化了事情。
作为处理这些渐近表达式的第一步,让我们看看一些典型的渐近类,或阶。表 2-1 列出了其中的一些,以及它们的名字和一些具有这些渐近运行时间的典型算法,有时也被称为运行时间复杂性。(如果你的数学有点生疏,你可以看看本章后面的边栏“快速数学复习”。)该表的一个重要特征是复杂性已经排序,因此每一行支配前一行:如果在表中发现 f 比 g 高,那么 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 个值的每个排序。 |
注其实关系更严格: f 是 o ( 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 ,对于一些(未知的)函数 f 和 g ,其中 f 为θ(n2和 g 即使我们找不到精确的和 f + g ,因为我们不知道精确的函数,我们可以找到渐近表达式来覆盖它,如下面两个“奖励规则”所示
- θ(f)+θ(g)=θ(f+g
- θ(f)θ(g)=θ(f**g
练习 2-8 要求你证明这些是正确的。
带着渐近线兜一圈
让我们看一些简单的程序,看看我们是否能确定它们的渐近运行时间。首先,让我们考虑这样的程序,其中(渐近)运行时间只随问题大小而变化,而不随问题实例的具体情况而变化。(下一节讨论如果实例的实际内容影响运行时间会发生什么。)这意味着,例如,if
语句现在是相当不相关的。除了简单的代码块之外,重要的是循环。函数调用不会使事情变得复杂;只需计算调用的复杂度,并将其插入正确的位置。
注意有一种情况下函数调用会让我们出错:当函数是递归的。该案例在第三章和第四章中处理。
无循环的情况很简单:我们在一个语句之前执行另一个语句,因此增加了它们的复杂性。比方说,我们知道对于一个大小为 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
for x in seq:
s += x
这是sum
函数的一个简单实现:它遍历seq
并将元素添加到s
中的初始值。这为seq
的 n 个元素的每一个执行一个单一的恒定时间操作(s += x
),这意味着它的运行时间是线性的,或者θ(n)。注意,恒定时间初始化(s = 0
)由这里的循环控制。
例如,同样的逻辑适用于我们在列表(或集合或字典)理解和生成器表达式中发现的“伪装的”循环。下面的列表理解也具有线性运行时复杂性:
squares = [x**2 for x in seq]
几个内置函数和方法中也有“隐藏的”循环。这通常适用于处理容器中每个元素的任何函数或方法,例如sum
或map
。
当我们开始嵌套循环时,事情变得有点(但不是很多)棘手。假设我们要总结seq
中元素所有可能的乘积;这里有一个例子:
s = 0
for x in seq:
for y in seq:
s += x*y
关于这个实现值得注意的一点是,每个产品将被添加两次。例如,如果42
和333
都在seq
,我们就把42*333
和333*42
都加上。那其实不影响运行时间;这只是一个不变的因素。
现在的运行时间是多少?基本规则很简单:一个接一个执行的代码块的复杂性只是增加了。嵌套循环的复杂性是乘以。原因很简单:对于外部循环的每一轮,内部循环都被完全执行。在这种情况下,这意味着“线性乘以线性”,这是二次的。换句话说,运行时间是θ(nn)=θ(n2)。实际上,这个乘法规则意味着对于更高层次的嵌套,我们将只增加幂(即指数)。三个嵌套的线性循环给我们θ(n3),四个给我们θ(n4),以此类推。
当然,顺序和嵌套案例可以混合使用。考虑以下轻微的扩展:
s = 0
for x in seq:
for y in seq:
s += x*y
for z in seq:
for w in seq:
s += x-w
可能不完全清楚我们在这里计算什么(我当然不知道),但是我们应该仍然能够使用我们的规则找到运行时间。z
-循环运行线性次数的迭代,它包含一个线性循环,所以总复杂度是二次的,即θ(n2)。y
-回路明明是θ(n)。这意味着 x 循环内部的代码块是θ(n+n2)。对于每一轮运行 n 次的x
循环,执行整个程序块。我们用我们的乘法法则,得到θ(n(n+n2)=θ(n2+n3)=θ(n3),也就是三次。通过注意到y
循环受z
循环支配,可以忽略不计,给内部块一个二次运行时间,我们可以更容易地得出这个结论。“二次乘以线性”得出的是立方。
当然,这些循环不需要都重复θ(n)次。假设我们有两个序列,seq1
和seq2
,其中seq1
包含 n 元素,seq2
包含 m 元素。下面的代码运行时间为θ(nm)。
s = 0
for x in seq1:
for y in seq2:
s += x*y
事实上,对于外部循环的每次迭代,内部循环甚至不需要执行相同的次数。这就是事情变得有点复杂的地方。在前面的例子中,例如 n 和 m 不是仅仅乘以两个迭代计数,我们现在必须求和内循环的迭代计数。下面的例子应该很清楚这意味着什么:
seq1 = [[0, 1], [2], [3, 4, 5]]
s = 0
for seq2 in seq1:
for x in seq2:
s += x
语句s += x
现在被执行 2 + 1 + 3 = 6 次。seq2
的长度给了我们内循环的运行时间,但是因为它是变化的,所以不能简单的乘以外循环的迭代次数。下面是一个更现实的例子,它重温了我们最初的例子——将序列中的每个元素组合相乘:
s = 0
n = len(seq)
for i in range(n-1):
for j in range(i+1, n):
s += seq[i] * seq[j]
为了避免将对象与自身相乘或者将相同的乘积相加两次,外部循环现在避免最后一项,内部循环只迭代外部循环当前考虑的之后的项。这实际上没有看起来那么令人困惑,但是发现这里的复杂性需要多一点小心。这是计数的重要情况之一,将在下一章讨论。 9
三个重要案例
到目前为止,我们一直假设运行时间是完全确定的,并且只取决于输入大小,而不取决于输入的实际内容。然而,这并不太现实。例如,如果您要构建一个排序算法,您可以这样开始:
def sort_w_check(seq):
n = len(seq)
for i in range(n-1):
if seq[i] > seq[i+1]:
break
else:
return
...
在进入实际排序之前,会执行一个检查:如果序列已经排序,函数会简单地返回。
注意如果循环没有被
break
语句提前结束,Python 中循环的可选else
子句将被执行。
这意味着,无论我们的主排序是多么低效,如果序列已经排序,运行时间将总是线性的。一般来说,没有任何排序算法可以实现线性运行时间,这意味着这种“最佳情况”是一种异常——突然间,我们再也无法可靠地预测运行时间。解决这一难题的方法是更加具体。我们可以更精确地指定输入,而不是笼统地谈论一个问题,我们经常谈论三种重要情况中的一种:
- 最好的情况。 这是当输入最适合你的算法时你得到的运行时间。例如,如果对
sort_w_check
的输入序列进行排序,我们将得到最好的运行时间,这将是线性的。 - 最坏的情况。 这通常是最有用的情况——可能的最坏运行时间。这是有用的,因为我们通常希望能够对我们的算法的效率给出一些保证,并且这是我们通常能够给出的最好的保证。
- 一般情况下。 这是一个棘手的问题,大部分时间我会避开它,但在某些情况下它会很有用。简单地说,它是运行时间的期望值,对于随机输入,具有给定的概率分布。
在我们将要使用的许多算法中,这三种情况具有相同的复杂性。当他们不这样做时,我们通常会遇到最糟糕的情况。然而,除非明确说明,否则无法对正在研究的是哪种情况做出假设。事实上,我们可能根本不会把自己限制在单一种类的输入上*。例如,如果我们想用一般的来描述sort_w_check
的运行时间会怎样?这仍然是可能的,但是我们不能相当精确。*
假设我们在检查后使用的主要排序算法是对数线性的;即它的运行时间为θ(nLGn*)。这是典型的,事实上,在排序算法的一般情况下是最佳的。我们算法的最好情况运行时间是θ(n),当检查发现一个排序序列时,最坏情况运行时间是θ(nLGn)。然而,如果我们想要给出运行时间的一般描述——对于任何类型的输入——我们根本不能使用θ符号。没有单一的函数描述运行时间;不同类型的输入有不同的运行时间函数,这些函数有不同的渐近复杂性,这意味着我们不能用一个θ表达式来概括它们。
解决方案?我们用 O 或ω来代替θ的“双界限”,只提供一个上限或下限。我们可以比如说sort_w_check
的运行时间为 O ( n lg n )。这涵盖了最好和最坏的情况。同样,我们可以说它的运行时间为ω(n)。请注意,这些限制是我们尽可能严格的。
注意使用我们的渐近算子来描述这里讨论的三种情况中的任何一种都是完全可以接受的。我们完全可以说
sort_w_check
的最坏情况运行时间是ω(nLGn),或者最好情况是 O ( n )。
算法的经验评估
本书主要关注的是算法设计及其近亲算法分析。然而,在构建现实世界的系统时,还有另一个重要的算法学科可能至关重要,那就是算法工程,高效实现算法的艺术。在某种程度上,算法设计可以被视为通过设计高效的算法来实现低渐进运行时间的一种方式,而算法工程则专注于减少渐进复杂性中的隐藏常数。
虽然我可能会在这里或那里提供一些关于 Python 算法工程的技巧,但是很难准确预测哪些调整和改进将为您正在处理的特定问题提供最佳性能——或者,实际上,为您的硬件或 Python 版本提供最佳性能。这些正是渐近线设计用来避免的怪癖。在某些情况下,这样的调整和修改可能根本不需要,因为你的程序可能已经足够快了。在许多情况下,你能做的最有用的事情就是去尝试和观察。如果你有一个你认为会改进你的程序的调整,那就试试吧!实现这个调整,并运行一些实验。有改善吗?如果这种调整使你的代码可读性更差,并且改进很小,那么这真的值得吗?
注意这部分是关于评估你的程序,而不是工程本身。关于加速 Python 程序的一些提示,参见附录 A 。
虽然所谓的实验算法学的理论方面——也就是说,实验评估算法及其实现——超出了本书的范围,但我会给你一些实用的入门技巧,应该可以让你走得很远。
小贴士 1 如果可能的话,不要担心。
担心渐近复杂性可能很重要。有时候,这就是解决方案和实际上的非解决方案之间的区别。然而,运行时间中的常数因素往往并不那么关键。首先尝试你的算法的简单实现,看看是否足够好。实际上,你甚至可以先尝试一个简单的算法;引用编程大师 Ken Thompson 的话,“当有疑问时,使用蛮力。”在算法学中,蛮力通常指的是一种直接的方法,尝试每一种可能的解决方案,运行时间见鬼去吧!如果成功了,就成功了。
小贴士 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
提示 3 要找到瓶颈,使用分析器。
猜测程序的哪个部分需要优化是一种常见的做法。这种猜测往往是错误的。与其胡乱猜测,不如让一个侧写师帮你找出答案!Python 附带了一些分析器变体,但是推荐使用 cProfile。它和timeit
一样容易使用,但是给出了更多关于执行时间花费在哪里的详细信息。如果您的主函数是main
,您可以使用 profiler 运行您的程序,如下所示:
import cProfile
cProfile.run('main()')
这将打印出程序中各种函数的计时结果。如果您的系统上没有 cProfile 模块,请使用profile
来代替。同样,更多信息可在图书馆参考中找到。如果你对你的实现的细节不太感兴趣,而只是想在给定的问题实例上凭经验检验你的算法的行为,那么标准库中的trace
模块会很有用——它可以用来计算每条语句被执行的次数。您甚至可以使用诸如 Python 调用图 之类的工具来可视化您代码的调用。 11
提示 4 绘制你的结果。
在解决问题时,视觉化是一个很好的工具。查看性能的两个常见图表是图、图 12 图,例如问题大小与运行时间的关系,以及箱线图,显示运行时间的分布。参见 1 图 2-2 中的示例。用 Python 绘图的一个很棒的包是matplotlib
(可从http://matplotlib.org
获得)。
图 2-2 。可视化程序 A、B 和 C 的运行时间和问题大小 10-50
小贴士 5 根据时间对比得出结论时要小心。
这个技巧有点模糊,但那是因为当根据计时实验得出哪种方式更好的结论时,有太多的陷阱。首先,你观察到的任何差异都可能是随机变化造成的。如果您使用的是诸如timeit
之类的工具,这种风险会小一些,因为它会多次重复要计时的语句(甚至会多次运行整个实验,保持最佳运行)。尽管如此,仍然会有随机的变化,如果两个实现之间的差异不大于这种随机性的预期,您就不能真正得出它们不同的结论。(你也不能断定他们不是。)
注意如果需要在千钧一发的时候得出结论,可以使用假设检验的统计技术。然而,出于实用的目的,如果差别很小,您不确定,那么您选择哪个实现可能并不重要,所以选择您最喜欢的实现。
如果要比较两个以上的实现,这个问题就更复杂了。正如《??》第三章、中所解释的那样,要比较的配对数量随着版本数量的增加而成二次方增加*,极大地增加了至少两个版本出现异常不同的可能性,这只是偶然的。(这就是所谓的多重比较的问题*。)这个问题有个统计解决方案,但是最简单实用的方法是用这两个有问题的实现重复这个实验。甚至有几次。他们看起来还是不一样吗?
第二,在比较平均值时存在问题。至少,您应该坚持比较实际计时的平均值。在进行计时实验时,为了得到更有意义的数字,通常的做法是将每个程序的运行时间归一化,用某个标准的简单算法的运行时间来除它。这确实很有用,但是在某些情况下会使你的结果没有意义。请参阅 Fleming 和 Wallace 的文章“如何避免用统计数据撒谎:总结基准测试结果的正确方法”,以获得一些提示。对于其他一些观点,你可以阅读巴斯特和韦伯的《不要比较平均值》,或者香橼等人最近的论文《调和或几何平均值:真的重要吗?
第三,你的结论可能不能一概而论。例如,在其他问题实例或其他硬件上运行类似的实验可能会产生不同的结果。如果其他人要解释或复制你的实验,重要的是你彻底记录你是如何进行实验的。
提示 6 从实验中得出渐近性的结论时要小心。
如果你想对一个算法的渐近行为下结论,你需要分析它,就像本章前面所描述的。实验可以给你一些提示,但是它们本质上是有限的,渐近法处理任意大的数据量会发生什么。另一方面,除非你在理论计算机科学领域工作,渐近分析的目的是说明算法在实际问题实例上实现和运行时的行为,这意味着实验应该相关。
假设你怀疑一个算法有二次运行时间复杂度,但是你无法最终证明它。你能用实验来支持你的观点吗?如前所述,实验(和算法工程)主要处理常量因素,但有一种方法是*。主要问题是你的假设无法通过实验来验证。如果你声称算法是,比如说, O ( n 2 ),没有数据可以证实或反驳这一点。然而,如果你把你的假设变得更具体,它就变得可检验了。例如,根据一些初步结果,您可能认为在您的设置中运行时间永远不会超过 0.24n2+0.1n+0.03 秒。也许更实际的是,您的假设可能涉及给定操作执行的次数,您可以用跟踪模块来测试。这个是一个可检验的——或者更确切地说,可反驳的——假设。如果你做了很多实验,却找不到任何反例,这在某种程度上支持了你的假设。有趣的是,你也间接地支持了算法是O(n2)的说法。*
*实现图形和树
第一章中的第一个例子,我们想在瑞典和中国导航,是典型的可以在算法学中最强大的框架之一中表达的问题——即图。在许多情况下,如果你能把你正在做的事情公式化为一个图的问题,你至少已经成功了一半。如果你的问题实例以某种形式表现为树,你就很有可能得到一个真正有效的解决方案。
图形可以代表各种各样的结构和系统,从交通网络到通信网络,从细胞核中的蛋白质相互作用到在线的人类相互作用。您可以通过添加额外的数据来增加它们的表现力,如重量或距离,这使得尽可能充分利用他们的能力来表示诸如下棋或为一组人匹配尽可能多的工作等不同的问题成为可能。树只是一种特殊的图,所以大多数图的算法和表示也适用于树。然而,由于它们的特殊性质(它们是连通的,没有环),一些专门的和相当简单的表示和算法版本是可能的。有许多实用的结构,比如 XML 文档或目录层次结构,可以用树来表示,所以这种“特例”实际上是相当普遍的。
如果你对图形术语的记忆有些生疏,或者对你来说这是全新的,看一看附录 C 。以下是 ?? 最精彩的部分:
- 一个图 G = ( V , E )由一组节点, V ,以及它们之间的边, E 组成。如果边有方向,我们说图是有向的。
- 中间有边的节点是与相邻的*。那边儿是接着事件来的俩人。与 v 相邻的节点是 v 的邻居。节点的度是与其关联的边的数量。*
- G = ( V , E )的一个子图由 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),除非我们事先知道这些值,并且可以编写一个定制的散列函数。尽管如此,哈希在实践中非常有效。
这对我们来说意味着访问一个dict
或set
的元素可以被假定为花费恒定的预期时间,这使得它们对于更复杂的结构和算法来说是非常有用的构建块。
注意,hash
函数专门用于哈希表。对于散列的其他用途,比如在密码学中,有标准的库模块hashlib
。
邻接表等等
实现图的最直观的方法之一是使用邻接表。基本上,对于每个节点,我们可以访问它的邻居列表(或者集合或者其他容器或者可迭代的)。让我们以最简单的方式实现它,假设我们有 n 个节点,编号为 0。。。n–1。
注意节点当然可以是任何对象,或者有任意的标签或名称。使用 0 范围内的整数。。。n–1 可以使许多实现变得更容易,因为节点编号可以很容易地用作索引。
每个邻接(或邻居)列表就是一个这样的数字列表,我们可以将列表本身放入一个大小为 n 的主列表中,通过节点号进行索引。通常,这些列表的排序是任意的,所以我们真正讨论的是使用列表来实现邻接集。术语列表在这个上下文中主要是历史性的。在 Python 中,我们很幸运有一个单独的集合类型,这在很多情况下是更自然的选择。
关于将用于说明各种图形表示的示例,参见图 2-3 。
图 2-3 。用于说明各种图形表示的示例图
提示关于帮助你可视化你自己的图形的工具,参见本章后面的边栏“图形库”。
首先,假设我们已经对节点进行了编号,即 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
]
注意在 Python 2.7(或 3.0)之前的版本中,你会把 set 文字写成
set([1, 2, 3])
而不是{1, 2, 3}
。注意,空集仍然被写成set()
,因为{}
是一个空字典。
名称N
在这里被用来对应前面讨论的 N 函数。在图论中, N ( v )代表 v 的邻居的集合。类似地,在我们的代码中,N[v]
现在是一组v
的邻居。假设您已经在前面的交互式解释器中定义了N
,那么现在您就可以摆弄这个图形了:
>>> b in N[a] # Neighborhood membership
True
>>> len(N[f]) # Degree
3
提示如果你在一个源文件中有一些代码,比如清单 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 实现的列表结构。
处理图表时一个反复出现的主题是,最佳表现取决于您需要对图表做什么。例如,使用邻接表(或数组)可以保持较低的开销,并让您有效地迭代任何节点 v 的 N ( v )。然而,检查 u 和 v 是否是邻居在其度的最小值上是线性的,如果图是密集的,也就是说,如果它有许多边,这可能是有问题的。在这种情况下,邻接集可能是正确的方法。
提示我们也看到了从 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
]
邻接字典版本可以像其他版本一样使用,具有额外的边权重功能:
>>> b 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')
}
注意如果你删除了清单 2-4 中的
set
构造函数,你最终会得到邻接字符串,它也可以作为不可变的字符邻接表工作,并且开销稍微低一些。这似乎是一个愚蠢的表示,但是正如我之前所说的,它取决于你程序的其余部分。你从哪里得到的图表数据?例如,它已经是文本的形式了吗?你打算如何使用它?
邻接矩阵
另一种常见的图形表示形式是邻接矩阵。主要区别如下:不是列出每个节点的所有邻居,我们有一个行(一个数组),其中每个可能的邻居有一个位置(也就是说,图中的每个节点有一个位置),并存储一个值,如True
或False
,指示该节点是否确实是邻居。同样,最简单的实现是使用嵌套列表,如清单 2-5 所示。请注意,这同样要求节点编号从 0 到V–1。使用的真值是 1 和 0(而不是True
和False
,只是为了让矩阵更易读。
清单 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
邻接矩阵有一些有用的性质值得了解。首先,只要我们不允许自循环(也就是说,我们不使用伪码),对角线就是假的。此外,我们经常通过在表示中添加两个方向的边来实现无向图。这意味着无向图的邻接矩阵将是对称的。
扩展邻接矩阵以允许边权重是微不足道的:不存储真值,只存储权重。对于一条边( u 、 v ,设N[u][v]
为边权重 w ( u 、 v )而不是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 for w in 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 中所示的简单树。
图 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.next = next
这里单独的val
属性只是为了在提供值时有一个更具描述性的名称,比如'c'
,而不是一个子节点。当然,你可以随意调整。以下是如何访问此结构的示例:
>>> t = Tree(Tree("a", Tree("b", Tree("c", Tree("d")))))
>>> t.kids.next.next.val
'c'
这是那棵树的样子:
kids
和next
属性被绘制成虚线箭头,而树的隐含边被绘制成实线。请注意,我做了一点手脚,没有为字符串"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 指出的,这些陈述在几个方面存在问题。首先,有许多有趣的表示图形的方式,不仅仅是这里列出的两种。比如有边列表(或边集),简单来说就是包含所有边作为节点对(甚至是特殊边对象)的列表;有关联矩阵,表示哪些边关联在哪些节点上(对多图有用);对于诸如树(前面描述过)和区间图(这里不讨论)之类的图形类型,有专门的方法。看看斯平拉德的书,你可能会需要更多的表述。第二,空间/时间权衡的想法很容易让人误解:有些问题用邻接表解决比用邻接数组更快,对于随机图,邻接表实际上比邻接矩阵使用更多的空间。
你应该考虑你的问题的具体情况,而不是依赖于简单的,笼统的陈述,比如前面的稻草人的评论。主要标准可能是你正在做的事情的渐近表现。比如在一个邻接矩阵中查找边( u , v )是θ(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) for i in 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 for i in 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 for i in range(10)), 1.0)
True
如果您需要精确的十进制浮点数,也可以使用一些工具,例如 decimal 模块。
>>> from decimal import *
>>> sum(Decimal("0.1") for i in range(10)) == Decimal("1.0")
True
例如,如果您正在处理金融数据,需要精确计算特定的小数位数,则此模块可能是必不可少的。在某些数学或科学应用中,你可能会发现 Sage 这样的工具很有用: 20
sage: 3/5 * 11/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 ( x 的 y 次方),基本上是 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 程序中表示这些结构,主要使用邻接表和邻接矩阵的变体,用list
、dict
和set
的各种组合实现。
最后,有一部分是关于黑匣子的危险。你应该四处寻找潜在的陷阱——你在不知道它们如何工作的情况下使用的东西。例如,内置 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 和ω是彼此的倒数;即如果 f 是 O ( 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(f,g)=θ(f+g)。
2-9.在附录 C 中,你会找到一个关于树的陈述列表。证明它们是等价的。
2-10.设 T 是至少有三个节点的任意根树,其中每个内部节点正好有两个子节点。如果 T 有 n 片叶子,那么它有多少个内部节点?
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 for y in S)
用数学符号,你可以这样写:
你能看出为什么这个等式是正确的吗?如果你以前没有使用过这个资本 sigma,它可能看起来有点吓人。然而,它并不比 Python 中的sum
函数更可怕;语法只是有点不同。西格玛本身表明我们正在做一个总和,我们把关于什么总和的信息放在它的上面、下面和右边。我们放在右边的(在前面的例子中, y 和 xy )是要求和的值,而我们把要迭代的项的描述放在 sigma 下面。
除了迭代一个集合(或其他集合)中的对象,我们可以对总和进行限制,就像使用range
(除了两个限制都包含在内)。一般表达式“sumf(I)forI=mton是这样写的:
Python 的对等用法如下:
sum(f(i) for i in range(m, n+1))
对于许多程序员来说,将这些总和视为编写循环的数学方式可能更容易:
s = 0
for i in range(m, n+1):
s += f(i)
更简洁的数学符号的优势在于,它能让我们更好地了解正在发生的事情。
使用总和
上一节中的示例等式中,因子 x 被移到了总和中,这只是在处理总和时允许使用的几个有用的“操作规则”之一。这里总结了其中最重要的两个(为了我们的目的):
乘法常数 可以移入或移出总和。这也是上一节中的初始示例所说明的。这就是你在更简单的求和中多次看到的 分配性的相同规则:c(f(m)+...+f(n)=cf(m)+...+ 比照 ( n )。
不是将两个和相加,而是可以将它们相加的内容相加。这只是意味着,如果你要总结一堆东西,你怎么做并不重要;也就是说,
sum(f(i) for i in S) + sum(g(i) for i in S)
和sum(f(i) + g(i) for i in S)
一模一样。 1 这只是 结合律的一个实例。如果你想减去两个和,你可以使用相同的技巧。如果您愿意,可以假装将常数因子-1 移动到第二个和中。
两场比赛的故事
你可能会发现大量的算术题对你的工作有用,一本好的数学参考书可能会给你大多数算术题的答案。然而,有两个和,或者说组合问题,涵盖了你在本书中会遇到的大多数情况——或者说,实际上,是最基本的算法工作。
这些年来,我一直在反复解释这两个想法,使用了许多不同的例子和比喻,但我认为一种相当令人难忘(我希望可以理解)的方式是将它们作为两种形式的锦标赛。
注意实际上,在图论(一个完整的图,其中每条边都被指定了一个方向)中,锦标赛这个词有一个技术含义。我这里说的不是这个,虽然概念是相关的。
锦标赛有很多种,但让我们考虑两种非常常见的锦标赛,它们的名字都很吸引人。这就是 循环赛和 淘汰赛。
在循环锦标赛(或者,具体来说,是一场单人循环锦标赛)中,每位参赛选手依次与其他选手相遇。那么问题就变成了,例如,如果我们有 n 骑士比武,我们需要多少场比赛或比赛?(如果你愿意,在这里替换你最喜欢的竞技活动。)在淘汰赛中,参赛者被安排成对,只有每对中的获胜者才能进入下一轮比赛。这里有更多的问题要问:对于 n 骑士,我们需要多少回合,总共会有多少场比赛?
握手
循环赛问题完全等同于另一个众所周知的难题:如果你让 n 个算法专家在一个会议上碰面,他们都握手,你能握多少次手?或者,等价地,一个有个节点的完整图有多少条边(见图 3-1 )?这和你在任何“所有人对抗所有人”的情况下得到的计数是一样的。例如,如果你在地图上有 n 个和位置,并且想要找到彼此最接近的两个,简单的(强力)方法是将所有点与所有其他点进行比较。为了找到这个算法的运行时间,你需要解决循环问题。(第六章中的给出了这个 最接近对问题的更有效的解决方案。)
图 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))
。我们只对每场比赛计数一次,所以总和必须产生和以前一样的计数:
我当然可以直接给你这个等式。我希望额外的包装对你来说更有意义。当然,你可以想出解释这个等式的其他方法(或者整本书中的其他方法)。例如,在本章开篇的故事中,高斯的洞见是,从 1 到 100 的总和可以“从外部”计算,将 1 与 100 配对,2 与 99 配对,以此类推,产生 50 对,总和为 101。如果你将其推广到从 0 到 n 的求和情况,你会得到和之前一样的公式。你能看出这一切与邻接矩阵对角线下方的左下半部分有什么关系吗?
提示 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 中,锦标赛结构被图示为一棵有根的树,其中每片叶子是一个骑士,每个内部节点代表一场比赛。换句话说:
图 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 的图示。
图 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
如果这看起来很平凡,让它沉淀一分钟。通过只问是/否的问题,你可以在大约五分钟内确定可见宇宙中的任何粒子!这是为什么对数算法如此超级可爱的一个经典例子。(现在试着说十遍“对数算法”,快。)
注这是 二等分,或者说二分搜索法的一个例子,最重要也是最知名的对数算法之一。这将在第六章的模块的“黑盒”侧栏中进一步讨论。
现在让我们转向对数的伪反面,思考同样怪异的指数。任何一个例子都会自动成为另一个的例子——如果我让你从一个粒子开始,然后重复加倍,你会很快填满整个可观测的宇宙。(正如我们所见,这需要大约 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 提示中的冰淇淋甜筒。)
图 3-4 。通过二叉树的各级向下传递 n 个令牌
提示一 几何(或指数 ) 级数是 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:
没有多少算法的运行时间涉及 n !(尽管我们会在第六章的中讨论排序的极限时再讨论这个计数)。一个愚蠢的例子,预期运行时间为θ(nn!)是排序算法 bogosort ,由反复将输入序列混洗成随机顺序,并检查结果是否排序组成。
组合是排列和子集的近亲。从一组 n 中抽取的 k 元素的组合,有时被写成 C ( n , k ),或者,对于那些数学爱好者来说:
这也被称为 二项式系数(或者有时是选择函数),读作“ n 选择 k ”虽然阶乘公式背后的直觉相当直观,但如何计算二项式系数就不那么明显了。 6
想象一下(再一次)你有 n 个人排队看电影,但是电影院只剩下 k 个座位。有多少个大小为 k 的子集有可能进入 ???那正是 C ( n , k ),当然,这个比喻在这里可能会为我们做一些工作。我们已经知道我们有 n !整条生产线的可能订单。如果我们只计算所有这些可能性,并输入第一个 k 呢?唯一的问题是我们已经对子集计数太多次了。在许多排列中,某一群朋友可能站在队伍的最前面;事实上,我们可以允许这些朋友站在他们的任何一个 k !可能的排列,这一行的其余部分可以站在他们的任何(n–k)!可能的排列而不影响谁能进去。这给了我们答案!
这个公式只是计算了这条线( n )所有可能的排列!)并除以我们计算每个“获胜子集”的次数,如前所述。
注关于计算二项式系数的不同观点将在第八章关于动态规划中给出。
注意,我们在这里选择了大小为 k 的子集,这意味着选择而不替换。如果我们只是抽签 k 次,我们可能会不止一次地抽取同一个人,实际上是在候选人名单中“替换”他们。那么可能结果的数量将简单地是 nk 。事实上, C ( n , k )计算大小为 k 的可能子集的数量,而 2 n 计算可能子集的总数,这给了我们以下美丽的等式:
这些组合物体就是这样。是时候做一个稍微有点令人费解的展望了:求解引用自身的方程!
提示对于大多数数学来说,交互式 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。这似乎应该在总体上成立:
>>> for n in range(100):
... seq = range(n)
... assert T(seq) == n+1
没有错误,所以这个假设看起来似乎有点道理。
我们现在要做的是如何找到函数的非递归版本,比如T
,给我们递归算法明确的运行时间复杂度。
用手做
为了从数学上描述递归算法的运行时间,我们使用递归方程,称为递归关系。如果我们的递归算法像上一节的S
,那么递归关系的定义有点像T
。因为我们正朝着一个渐近的答案努力,我们不关心常数部分,我们隐含地假设T(k)=θ(1),对于某个常数 k 。这意味着我们可以在建立方程时忽略基本情况(除非它们不花费恒定的时间量),对于S
,我们的 T 可以定义如下:
这意味着计算S(seq, i)
所需的时间,也就是 T ( n ),等于递归调用S(seq, i+1)
所需的时间,也就是T(n–1),加上访问所需的时间seq[i]
,这个时间是常数,或者θ(1)。换句话说,我们可以在恒定时间内将问题简化为更小的版本,从大小 n 到n–1,然后解决更小的子问题。总时间是这两个操作的总和。
注如你所见,对于递归之外的额外工作(即时间),我用 1 而不是θ(1)。我也可以用θ。只要我渐近地描述结果,就没多大关系。在这种情况下,使用θ(1)可能有风险,因为我将建立一个和(1 + 1 + 1...),如果它包含渐近符号(即θ(1)+θ(1)+θ(1),就很容易错误地将这个和简化为常数...).
现在,我们如何求解这样一个方程?线索在于我们将 T 实现为一个可执行函数。我们可以自己模拟递归,而不是让 Python 运行它。这整个方法的关键是下面的等式:
我放在盒子里的两个子公式是相同的,这是关键。我声称这两个盒子是相同的基本原理在于我们最初的递归,因为如果...
...然后:
我只是简单的把原来方程中的 n 换成了n–1(当然,T((n–1)=T(n–2)),而 voilà ,我们看到盒子是相等的。我们在这里所做的是使用带有一个更小参数的 T 的定义,本质上,这就是递归调用求值时发生的事情。因此,将递归调用从第一个框T(n–1)扩展到第二个框T(n–2)+1,本质上是模拟或“解开”递归的一个层次。我们还有递归调用T(n–2)要处理,但是我们可以用同样的方式处理它!
事实上,T(n–2)=T(n–3)+1(两个加框的表达式)也是由原来的递推关系得出的。在这一点上,我们应该看到一个模式:每次我们减少一个参数,我们已经解开的工作(或时间)的总和(在递归调用之外)就增加1。如果我们递归地解开T*(n)I*步骤,我们得到如下结果:
这正是我们正在寻找的表达式——递归的层次被表示为一个变量 i 。因为所有这些未分解的表达式都是相等的(我们每一步都有方程),我们可以自由地将 i 设置为我们想要的任何值,只要我们不超过基本情况(例如, T (1)),在基本情况下,原始的递归关系不再有效。我们所做的是将直接上升到的基本情况,并尝试将T(n–I)转化为 T (1),因为我们知道,或者隐含地假设 T (1)是θ(1),这意味着我们已经解决了整个问题。我们可以通过设置I=n–1:
我们现在可能付出了更多的努力,发现S
有一个线性运行时间,正如我们所怀疑的。在下一节中,我将向您展示如何使用这种方法来处理一些不太直接的递归。
小心这种方法,叫做 重复换人法(或者有时是 迭代法),是完全有效的,如果你小心的话。然而,很容易做出一两个不必要的假设,尤其是在更复杂的递归中。这意味着你可能应该将结果视为一个假设,然后使用本章后面“猜测和检查”一节中描述的技术检查你的答案。
一些重要的例子
你通常会遇到的递归的一般形式是T(n)=a T(g(n)+f(n),其中 a 代表递归调用的次数, g ( n 是每个子问题的大小
提示当然有可能制定出不符合这种模式的递归算法,例如,如果子问题大小不同。这种情况不会在本书中讨论,但是在“如果你好奇”一节中给出了一些关于更多信息的提示...,“这一章快结束了。
表 3-1 总结了一些重要的递归——对大小为n–1 或 n /2 的问题进行一到两次递归调用,每次调用都有常量或线性的额外工作。在上一节中,您已经看到了第一个循环。在下文中,我将向你展示如何用重复替换法解决最后四个问题,剩下的三个(2 到 4)留给练习 3-7 到 3-9。
表 3-1 。一些基本的递归和解决方案,以及一些样本应用
在我们开始处理最后四个递归之前(它们都是 分治递归的例子,在本章后面和第六章中有更详细的解释),你可能想用图 3-5 来刷新你的记忆。它总结了迄今为止我所讨论的关于二叉树的结果;正如你将在下面的文本中看到的,我已经悄悄地给了你需要的所有工具。
图 3-5 。完全平衡二叉树的一些重要性质综述
注我已经提到过基例有常数时间的假设(T(k)=T0,k≤n0,对于某些常数 t 0 , n 0 )。在递归中, T 的参数是 n / b ,对于某个常数 b ,我们会遇到另一个技术问题:参数实际上应该是一个整数。我们可以通过舍入来实现这一点(到处使用
floor
和ceil
),但是简单地忽略这个细节是很常见的(实际上假设 n 是 b 的幂)。为了纠正这种马虎,你应该用本章后面的“猜测和检查”中描述的方法来检查你的答案。
看递归 5。只有一个递归调用,解决了一半的问题,此外还有固定的工作量。如果我们把完整的递归看做一棵树(一棵 递归树),这个额外的工作( f ( n ))是在每个节点执行的,而递归调用的结构是用边来表示的。总工作量( T ( n ))是所有节点(或涉及的节点)的 f ( n )之和。在这种情况下,每个节点的功是不变的,所以我们只需要计算节点的个数。而且,我们只有一个递归调用,所以全部工作相当于一条从根到叶子的路径。很明显, T ( n )是对数,但是如果我们尝试一步一步地揭开递归,让我们看看这是什么样子:
花括号括起了相当于递归调用的部分( T (...))在前一行。这种逐步解开(或反复替换)只是我们求解方法的第一步。一般方法如下:
- 解开循环,直到你看到一个模式。
- 用一个行号变量, i 来表示模式(通常涉及一个和)。
- 选择 i 以便递归到达其基本情况(并求解总和)。
第一步是我们已经做的。让我们从第 2 步开始:
我希望你同意这种一般形式抓住了我们解开的模式:对于每一个解开(每一行进一步向下),我们将问题大小减半(即除数加倍)并增加另一个工作单位(另一个 1)。最后的总和有点傻。我们知道我们有 i 个 1,所以总和显然就是 i 。我已经把它写成了一个总和,以显示这里的方法的一般模式。
为了达到递归的基本情况,我们必须让T(n/2I)变成,比如说, T (1)。这只是意味着我们必须将从 n 到 1 的路径减半,这一点现在应该很熟悉了:递归高度是对数的,或者说 i = lg n 。将它插入到模式中,你会得到 T ( n )确实是θ(LGn)。
递归 6 的分解非常相似,但是这里的和稍微有趣一些:
如果您不明白我是如何得出大致模式的,您可能需要思考一下。基本上,我只是使用了 sigma 符号来表示总和 n + n /2 +...+n/(2I–1),你可以在早期的解开步骤中看到这些。在担心解和之前,我们再次设置 i = lg n 。假设 T (1) = 1,我们得到如下结果:
还有最后一步就是因为n/2LGn= 1,所以我们可以把孤独的 1 纳入求和。
现在:这个数字看起来熟悉吗?再一次,看一下图 3-5 :如果 k 是一个高度,那么n/2/k是该高度的节点数(我们将从叶子到根分成两半)。这意味着总和等于节点数,即θ(n)。
递归 7 和 8 引入了一个难题:多次递归调用。递归 7 类似于递归 5:不是计数从根到叶的一条路径上的个节点,我们现在从每个节点开始跟随的两个子边,所以计数等于节点的数量,或θ(n)。你能看出递归 6 和递归 7 是如何以两种不同的方式计算同一个节点的吗?我将在递归 8 上使用我们的求解方法;第 7 项的程序非常相似,但值得一查:
如你所见,两者在前面不断堆积,产生了 2 i 的因子。括号内的情况看起来有点混乱,但幸运的是,减半和加倍完美地平衡了:第一个括号内是 n /2,乘以 2; n /4 乘以 4,一般来说, n /2 i 乘以 2 i ,也就是说我们剩下的是 n 的 i 重复的总和,或者简称为 n i 。再次,为了得到基本情况,我们选择 i = lg n :
换句话说,运行时间是θ(nLGn)。在图 3-5 中也能看到这样的结果吗?没错。递归树根节点中的工作是n;在两个递归调用(子节点)的每一个中,这是减半的。换句话说,每个节点的功等于图 3-5 中的标号。我们知道每一行的总和为 n ,并且我们知道有 lg n + 1 行节点,给我们一个总和nLGn+n,或者θ(nLGn)。
猜测和检查
递归和归纳将在第四章中深入讨论。我的一个主要论点是,它们就像彼此的镜像;一种观点认为归纳法向你展示了为什么递归有效。在这一节中,我将讨论限制在展示我们对递归的解决方案是正确的(而不是讨论递归算法本身),但它仍然应该让您对这些事情是如何联系的有所了解。
正如我在本章前面说过的,解开一个循环和“发现”一个模式的过程在某种程度上受制于不必要的假设。例如,我们经常假设 n 是 2 的整数次幂,这样就可以获得精确到 lg n 的递归深度。在大多数情况下,这些假设工作得很好,但为了确保解决方案是正确的,您应该检查它。能够检查解决方案的好处在于,你可以通过猜测或直觉想象出一个解决方案,然后(理想情况下)证明它是正确的。
注意为了使事情简单,我将坚持下面的大 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 ):
我在这里用方框突出了归纳假设的用法:我用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)。让我们试一试:
如前所述,假设我们已经展示了较小参数的结果,我们展示了它也适用于 T ( n )。
注意警惕递归中的渐近符号,尤其是递归部分。考虑下面这个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) = 2
T(
n1/2
) + lg
n
怎样才能得到T(n1/2)=S(m/2)?直觉可能会告诉我们,要从幂到积,我们需要用到对数。这里的技巧是设置 m = lg n ,这又让我们在递归中插入 2 m 而不是 n :
T(2
m) = 2
T((2
m)
1/2
) +
m= 2
T(2
m/2
) +
m
通过设置S(m)=T(2m),我们可以隐藏那种力量,然后就好了!我们在仙境:
S(
m) = 2
S(
m/2) +
m
这个到现在应该很容易解决了:T(n)=S(m)是θ(mLGm)=θ(LGnLGn)。
在这个侧边栏的第一次循环中,常数 a 和 b 可能有其他值,当然(和 f 肯定可能不太合作),留给我们的是 S ( m ) = 为(m/b)+g(m)(其中您可以使用重复替换来解决这个问题,或者您可以使用下一节中给出的千篇一律的解决方案,因为它们特别适合这种递归。**
主定理:千篇一律的解决方案
对应于许多所谓的分治算法(在第六章的中讨论)的递归具有以下形式(其中 a ≥ 1 并且 b > 1):
这个想法是,你有 a 递归调用,每一个都在数据集的给定百分比(1/ b )上。除了递归调用,该算法还执行 f ( n )个工作单元。看一下图 3-6 ,图中说明了这样一个算法。在我们早期的树中,数字 2 是最重要的,但是现在我们有了两个重要的常数*, a 和 b 。分配给每个节点的问题大小除以我们每下降一级的b;这意味着为了达到问题大小 1(在树叶中),我们需要 logbn*的高度。记住,这是为了得到 n 而必须提高的 b 的幂。
图 3-6 。一个完美平衡的,规则的多路(一路)树,展示了分治递归
然而,每个内部节点都有 a 子节点,所以从一级到另一级节点数量的增加不一定抵消问题规模的减少。这意味着叶节点的数量不一定是 n 。相反,对于每个级别,节点的数量增加一个因子 a ,并且对于 log b n 的高度,我们得到 logabn的宽度。然而,由于对数的计算规则相当方便,我们可以交换 a 和 n ,得到nlogb aleaves。练习 3-10 要求你证明这是正确的。
本节的目标 是建立三个千篇一律的解决方案,它们一起形成所谓的主定理。解决方案对应于三种可能的场景:要么大部分工作在根节点中执行(也就是说,大部分时间花费在上面),要么主要在叶节点中执行,要么在递归树的各行之间平均分配。让我们逐一考虑这三种情况。
在第一个场景中,大部分工作是在根中执行的,我说的“大部分”是指它渐近地支配着运行时间,给我们一个总的运行时间θ(f(n))。但是我们怎么知道根占优势呢?如果功从一级到另一级缩小(至少)一个常数,并且根比叶做更多的功(渐近),就会发生这种情况。更正式地说:
对于一些c??【1】和大型 n ,以及
对于某常数ε0。这仅仅意味着 f ( n )的增长比叶子的数量更快(这就是为什么我在叶子计数公式的指数中添加了 ε )。以下面的例子为例:
这里, 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 是表达式的主要部分。你知道为什么吗?
我们也可以颠倒根叶关系:
现在树叶主宰了画面。你认为这会导致多少总运行时间?没错:
以下面的循环为例:
这里 a = b ,所以我们得到了一个 n 的叶子数,它显然比f(n)= LGn增长得更快。这意味着最终运行时间渐进地等于叶子数,或θ(n)。
注意为了建立根的优势,我们需要额外的需求af(n/b)≤cf*(n),对于一些 c < 1。建立叶优势,没有类似的要求。*
最后一种情况是根和叶中的功具有相同的渐近增长:
这就变成了树的每一层的总和(从根到叶既不增加也不减少),这意味着我们可以用它乘以对数高度来得到总和:
以下面的循环为例:
平方根可能看起来令人生畏,但它只是另一种力量,即, n 0.5 。我们有 a = 2 和 b = 4,给我们 logb a= log42 = 0.5。你知道吗—根和叶的功都是θ(n0.5),因此在树的每一行中,总运行时间如下:
表 3-2 总结了主定理的三种情况,按照习惯上给出的顺序:情况 1 是树叶占优;情况 2 是“死竞争”,其中所有行都有相同的(渐近)和;在第三种情况下,根占优势。
表 3-2 。三例 掌握定理
那么,到底是怎么回事呢?
好的,这里有这里有很多数学,但是到目前为止没有很多编码。这些公式有什么意义?考虑一下清单 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
循环和一个从0
到len(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 排序,但一般来说,我们可能会更好地放弃它以支持合并排序。
注意 Python 的排序算法 timsort 是一个自然适应版本的归并排序。它设法实现线性最佳情况运行时间,同时保持对数线性最坏情况。你可以在第六章的 timsort 上的“黑盒”侧栏中找到更多细节。
摘要
第 n 个整数的和是二次的,第 lg n 个 2 的一次幂的和是线性的。这些身份中的第一个可以被说明为循环锦标赛,具有所有可能的 n 元素的配对;第二个与淘汰赛有关,lg n 轮,除了获胜者之外,所有人都必须被淘汰。 n 的排列数是 n !,而来自 n 的k-组合(大小为 k 的子集)的个数,写成 C ( n , k ),则为 n !/( k !(n–k)!).这也被称为二项式系数。
如果一个函数调用自己(直接或通过其他函数),那么它就是递归的。递推关系是将一个函数与其自身以递归的方式联系起来的方程(如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 ( n , k ) = C ( n ,n–k)。
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) for i in range(100)]
>>> dd = float("inf")
>>> for x in seq:
... for y in 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")
>>> for i in 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
将其简化为“在一个排序的序列中找到两个最接近的数字”。在这种情况下,我们的归约(排序)不会影响我们得到的答案。一般来说,我们可能需要转换答案,使其符合原始问题。
注在某种程度上,我们只是把问题分成两部分,排序和扫描排序后的序列。你也可以说扫描是将原始问题简化为排序序列问题的一种方式。这完全是视角的问题。
把 A 化简为 B 有点像说“你想解 A?哦,那很简单,只要你能解决 b。”见图 4-1 关于归约如何工作的图解。
图 4-1 。用一个从 A 到 B 的归约用一个 B 的算法来解 A . B 的算法(中心,内圆)可以把输入的 B 进行变换?到输出 B!,而归约由两个转换(较小的圆)组成,从 A?to B?还有从 B!敬 A!,共同构成主算法,它将输入 A?到输出 A!
一个,两个,许多
我已经用归纳法解决了第三章中的一些问题,但是让我们回顾一下,看几个例子。当抽象地描述归纳时,我们说我们有一个命题,或者说陈述 P ( n ),我们想证明对于任意自然数 n 都成立。例如,假设我们正在调查第一个 n 个奇数的总和; P ( n )则可以是以下语句:
这非常熟悉——它几乎与我们在上一章中使用的握手和相同。你可以很容易地通过调整握手公式得到这个新结果,但是让我们看看如何用归纳法来证明它。归纳法的思想是让我们的证明“横扫”所有自然数,有点像一排多米诺骨牌倒下。我们从建立 P (1)开始,这在本例中非常明显,然后我们需要表明,如果每块多米诺骨牌倒下,将会推倒下一块。换句话说,我们必须证明如果陈述P(n–1)为真,那么随之而来的是 P ( n )也为真。
如果我们能表现出这个蕴涵,即P(n–1)P(n),那么结果将会扫过 n 的所有值,从 P (1)开始,用P(1)üP(2)建立 P (2),然后继续进行换句话说,关键的事情是建立让我们更进一步的暗示。我们称之为归纳步骤*。在我们的例子中,这意味着我们假设如下(P(n–1)):*
我们可以认为这是理所当然的,我们只要把它拼接到原来的公式中,看看能不能推导出 P ( n ):
给你。归纳步骤成立,我们现在知道该公式适用于所有自然数 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
图 4-2 。一个不完整的棋盘,被 L 形瓷砖覆盖。拼贴可以旋转,但不能重叠
放置一个瓷砖,并假设我们可以解决其余的问题,或者假设我们已经解决了除一个以外的所有问题,然后放置最后一个——这当然是一种减少。我们已经把问题从一个转化到另一个,但是问题是我们没有解决新的问题和的方法,所以它没有真正的帮助。为了使用归纳法(或递归),归约必须(通常)在相同问题或不同大小的实例之间进行。目前,我们的问题仅针对图 4-2 中的特定电路板,但将其推广到其他尺寸应该不会有太大问题。给定这个概括,你看到任何有用的减少吗?**
问题是我们怎样才能把这块木板分割成形状相同的小块。它是二次的,所以自然的起点可能是把它分成四个更小的正方形。在那一点上,我们和完整解决方案之间唯一的障碍是,四个板部件中只有一个与原来的形状相同,缺了一个角。其他三个是完整的(四分之一大小)棋盘。然而,这很容易补救。只需放置一个瓷砖,使其覆盖这三个子板的一个角,就像变魔术一样,我们现在有四个子问题,每个都相当于(但小于)完整的问题!
为了澄清这里的归纳,假设您实际上还没有放置瓷砖。你只需要注意哪三个角是敞开的。通过归纳假设,你可以覆盖三个子板(其中基础案例是四个正方形的板),一旦你完成,还剩下三个正方形可以覆盖,呈 L 形。3归纳步骤然后放置这个棋子,隐含地组合四个子解。因为归纳,我们不仅解决了八乘八的问题。这个解决方案适用于任何这种类型的棋盘,只要它的边是 2 的(相等)幂。
注意在这里,我们还没有真正在所有电路板尺寸或所有边长上使用感应。我们已经隐含地假设边长为 2 k ,对于某个正整数 k ,并在 k 上使用归纳法。结果是完全有效的,但重要的是要注意我们已经证明了什么。例如,对于奇数边电路板,解决方案不成立。
这个设计实际上更多的是一个证明,而不是一个实际的算法。不过,把它变成一个算法并不难。你首先需要考虑由四个正方形组成的所有子问题,确保它们的开角正确对齐。然后,你将这些问题组合成由 16 个正方形组成的子问题,仍然要确保放置开放的角,以便它们可以用 L 形片连接。虽然你当然可以用一个循环把它设置成一个迭代程序,但是用递归会简单得多,在下一节你会看到。
镜子,镜子
在他出色的网络视频节目中,泽·弗兰克曾经说过这样的话:“‘你知道没有什么可害怕的,除了害怕本身。’是的,这叫做递归,这会导致无限的恐惧,所以谢谢你。 4 另一个常见的建议是,“为了理解递归,首先必须理解递归。"
确实如此。递归可能很难理解——尽管无限递归是一种相当病态的情况。 5 在某种程度上,递归只有作为归纳的镜像才有意义(参见图 4-3 )。在归纳法中,我们(从概念上)从一个基本案例开始,展示归纳步骤如何带我们走得更远,直到整个问题的规模。对于弱归纳, 6 我们假设(归纳假设)我们的解决方案对n–1 有效,并由此推断对 n 有效。递归通常看起来更像是分解事物。你从一个完整的问题开始,大小为 n 。您将大小为n–1 的子问题委托给一个递归调用,等待结果,并将得到的子解扩展为完整解。我相信你可以看到这只是一个视角的问题。在某种程度上,归纳法向我们展示了为什么递归有效,而递归给了我们一个简单的方法(直接)实现我们的归纳思想。
图 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
,抱怨您已经超过了最大递归深度。
注很多所谓的函数式编程语言都实现了一种叫做尾部递归优化 的东西。类似前面的函数(唯一的递归调用是函数的最后一条语句)被修改,这样它们就不会耗尽堆栈。通常,递归调用在内部被重写为循环。
幸运的是,任何递归函数都可以重写为迭代函数,反之亦然。然而,在某些情况下,递归是非常自然的,你可能需要在迭代程序中使用你自己的堆栈来伪装它(如在非递归的深度优先搜索,在第五章中解释)。
让我们来看几个基本算法,通过递归思想可以很容易地理解算法思想,但是实现非常适合迭代。 7 考虑排序问题(计算机科学教学中的最爱)。像以前一样,问问自己,减在哪里?有很多方法可以减少这个问题(在第六章我们将减少一半),但是考虑一下我们通过一个元素减少这个问题的情况。我们可以假设(归纳)前n–1 个元素已经排序,并将元素 n 插入正确的位置,或者我们可以找到最大的元素,将其放在位置 n 处,然后递归排序剩余的元素。前者给我们插入排序,后者给选择排序。
注意这些算法并不那么有用,但它们通常被教授,因为它们是很好的例子。此外,它们是经典,所以任何算法专家都应该知道它们是如何工作的。
清单 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):
for i in 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
for j in 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):
for i in range(len(seq)-1,0,-1): # n..i+1 sorted so far
max_j = i # Idx. of largest value so far
for j in 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_j
的for
循环)并将其交换到所考虑的序列前缀的末尾来工作的。请注意,您也可以从头开始运行本节中的所有四种排序算法,而不是从结尾开始(在插入排序中将所有对象排序到右侧,或者在选择排序中寻找最小的元素)。
但哪里的 是 的归约?
找到一个有用的约简通常是解决算法问题的关键步骤。如果你不知道从哪里开始,问问自己,减少在哪里?
然而,可能不完全清楚本节中的想法如何与图 4-1 中呈现的减少情况相吻合。如前所述,归约将问题 A 的实例转换为问题 B 的实例,然后将 B 的输出转换为 A 的有效输出。但是在归纳和归约中,我们只是缩小了问题的规模。哪里是的归约,真的吗?
哦,它就在那里——只是我们正在从 A 减少到 A。尽管有些转变正在发生。这种减少确保了我们将减少到的实例比原来的实例小(这是归纳工作的原因),当转换输出时,我们再次增加大小。
这是缩减的两个主要变化:缩减到不同的问题和缩减到相同问题的缩小版本。如果你把子问题想成顶点,把归约想成边,你会得到在第二章的中讨论的子问题图,这个概念我会多次重温。(在第八章中尤为重要。)
使用归纳法(和递归)进行设计
在这一部分,我将带你完成三个问题的算法解决方案的设计。我正在构建的问题,拓扑排序,是一个在实践中经常出现的问题,如果你的软件管理任何类型的依赖,有一天你很可能需要自己实现它。前两个问题可能没那么有用,但是很有趣,它们是归纳(和递归)的好例子。
寻找最大排列
八个品味独特的人买了电影票。他们中的一些人对自己的座位很满意,但大多数人并不满意,在第三章排队之后,他们变得有点暴躁。假设他们每个人都有一个最喜欢的座位,你想找到一种方法让他们交换座位,让尽可能多的人对结果感到满意(忽略其他观众,他们最终可能会对我们的观众的滑稽动作感到有点厌倦)。但是因为都比较暴躁,如果拿不到自己喜欢的,都拒绝挪到别的座位。
这是匹配问题的一种形式。你会在第十章中遇到其他几个。我们可以把问题(实例)建模成一个图,就像图 4-4 中的那个。边缘从人们现在坐的位置指向他们想要坐的位置。(这个图有点不寻常,因为节点没有唯一的标签;每个人或每个席位代表两次。)
图 4-4 。集合{ a 中的映射...h}自身
注意这是所谓的二分图图的一个例子,这意味着节点可以被分成两个集合,其中所有的边都在集合之间*(并且它们都不在内)。换句话说,您可以只使用两种颜色来给节点着色,这样相邻节点就不会有相同的颜色。*
在我们尝试设计算法之前,我们需要将问题形式化。真正理解问题总是解决问题的关键的第一步。在这种情况下,我们希望让尽可能多的人得到他们“指向”的座位其他人需要留在座位上。另一种看待这个问题的方式是,我们在寻找一个人(或指指点点的人)的子集,形成一对一的映射,或 ?? 排列。这意味着集合中没有人指向它之外,每个座位(在集合中)只被指向一次。这样,排列中的每个人都可以根据自己的意愿自由排列或交换座位。我们希望找到一个尽可能大的排列*(以减少落在它之外并且他们的愿望被拒绝的人的数量)。*
同样,我们的第一步是问,减少在哪里?我们怎样才能把问题缩小呢?我们可以委托(递归地)或假设(归纳地)已经解决的子问题是什么?让我们用简单(弱)归纳法,看看我们是否能把问题从 n 缩小到n–1。这里, n 为人数(或座位数),即 n = 8 为图 4-4 。归纳假设来自我们的一般方法。我们简单假设可以解决n–1 人的问题(即找到形成排列的最大子集)。唯一需要创造性解决问题的是安全地移除一个人,这样剩下的子问题就是我们可以建立的(也就是说,是整个解决方案的一部分)。
如果每个人指向一个不同的座位,整个集合就形成了一个排列,这个排列肯定是尽可能大的——不需要移除任何人,因为我们已经完成了。基本情况也是微不足道的。对于 n = 1,无处可动。所以,让我们假设n1,并且至少有两个人指向同一个座位(这是打破排列的唯一方法)。以图 4-4 中的 a 和 b 为例。它们都指向 c ,我们可以有把握地说其中一个必须被淘汰。然而,我们选择哪一个是至关重要的。比方说,我们选择移除 a (人和座位)。然后我们注意到 c 指向 a ,这意味着 c 也必须被消除。最后, b 指向 c 并且也必须被删除——这意味着我们可以简单地从一开始就删除 b ,保留 a 和 c (它们只是想互相交换座位)。
当寻找这样的归纳步骤时,寻找突出的东西通常是个好主意。例如,一个没人想坐的座位(即图 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
提示如果可能的话,试着用一种尽可能具体的方式来表达你的问题。更一般的表示会导致更多的簿记和复杂的代码;如果你使用一个隐含问题约束的表示,那么找到和实现一个解决方案都会容易得多。
*如果我们愿意,我们现在可以直接实现递归算法的思想,用一些强力代码来寻找要消除的元素。它不会非常高效,但是低效的实现有时可以是一个有指导意义的起点。参见清单 4-5 中相对直接的实现。
清单 4-5 。寻找最大排列的递归算法思想的简单实现
def naive_max_perm(M, A=None):
if A is 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] for i in 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 中的元素,它就删除这个元素,然后递归地解决剩下的问题。让我们使用我们的例子中的实现,M
。 8
>>> naive_max_perm(M)
{0, 2, 5}
所以, a , c , f 可以参与排列。其他人将不得不坐在不受欢迎的座位上。
实现并不太差。便捷的集合类型让我们可以用现成的高级操作来操作集合,而不必自己实现它们。不过,还是有一些问题。首先,我们可能需要一个迭代的解决方案。这很容易补救——递归可以很简单地用循环代替(就像我们对插入排序和选择排序所做的那样)。然而,更糟糕的问题是算法是二次的!(练习 4-10 要求你展示这个。)
最浪费的操作是重复创建集合 b。如果我们可以跟踪哪些椅子不再被指向,我们就可以完全消除这个操作。这样做的一个方法是为每个元素保存一个计数。当指向 x 的人被消灭时,我们可以减少椅子 x 的计数,如果 x 的计数为零,人和椅子 x 都将退出游戏。
提示这种参考计数 的思路一般都能有用。例如,它是许多垃圾收集系统中的基本组件(一种自动释放不再有用的对象的内存管理形式)。在拓扑排序的讨论中,您将再次看到这种技术。
在任何时候都可能有不止一个元素需要被删除,但是我们可以把我们遇到的任何新的元素放到一个“待办事项”列表中,以后再处理它们。如果我们需要确保元素按照我们发现它们不再有用的顺序被删除,我们将需要使用一个先入、先出队列,比如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
for i in M: # All that are "pointed to"
count[i] += 1 # Increment "point count"
Q = [i for i in A if 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.
提示在最近版本的 Python 中,
collections
模块包含了Counter
类,可以为你统计(可散列)对象。有了它,清单 4-7 中的for
循环可以被赋值count = Counter(M)
所取代。这可能会有一些额外的开销,但它会有相同的渐进运行时间。
清单 4-7 。对名人问题的天真解决方案
def naive_celeb(G):
n = len(G)
for u in range(n): # For every candidate...
for v in 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]
的任何节点u
和v
,我们可以排除u
或v
!如果G[u][v]
为真,我们剔除u
;否则,我们淘汰v
。如果我们保证是一个名人,这就是我们所需要的。否则,我们仍然可以排除所有候选人,只留下一个,但我们需要检查他们是否是名人,就像我们在naive_celeb
中所做的那样。您可以在清单 4-8 中找到基于这种简化的算法实现。(您可以使用集合更直接地实现算法思想;你明白了吗?)
清单 4-8 。名人问题的解决方案
def celeb(G):
n = len(G)
u, v = 0, 1 # The first two
for c in 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
for v in 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) for i in range(n)] for i in range(n)]
现在确保有一个名人在那里,并运行两个函数:
>>> c = randrange(n)
>>> for i in range(n):
... G[i][c] = True
... G[c][i] = False
...
>>> naive_celeb(G)
57
>>> celeb(G)
57
请注意,虽然一个是二次的,一个是线性的,但构建图的时间(无论是随机的还是来自其他来源)在这里是二次的。这可以避免(对于一个稀疏图,其中平均边数小于θ(n)),用一些其他的图表示;参见第二章中的获取建议。
拓扑排序
几乎在任何项目中,要承担的任务都有部分限制其顺序的依赖关系。比如,除非你有非常前卫的时尚感,否则你需要先穿袜子再穿靴子,但你是否先戴帽子再穿短裤就没那么重要了。这种依赖性(如第二章中提到的那样)很容易用有向无环图(DAG)来表示,寻找一种尊重依赖性的排序(这样所有的边在排序中都指向前面)被称为拓扑排序。
图 4-5 说明了这个概念。在这种情况下,有一个唯一有效的排序,但是考虑一下如果你移除边 ab 会发生什么——然后 a 可以放置在顺序中的任何位置,只要它在 f 之前。
图 4-5 。按拓扑排序的有向无环图(DAG)及其节点
在任何中等复杂的计算机系统中,拓扑排序问题在许多情况下都会出现。事情需要去做,并且依赖于其他事情...从哪里开始?一个相当明显的例子是安装软件。大多数现代操作系统至少有一个自动安装软件组件(如应用或库)的系统,这些系统可以自动检测某些依赖项何时丢失,然后下载并安装它。为此,必须按照拓扑排序的顺序安装组件。 12
还有一些算法(例如用于在 DAG 中寻找最短路径的算法,以及在某种意义上,大多数基于动态规划的算法)是基于 DAG 被拓扑排序作为初始步骤。然而,虽然标准排序算法很容易封装在标准库之类的东西中,但是抽象出图算法以便它们可以处理任何类型的依赖结构就有点困难了...因此,您需要在某个时候实现它的可能性并不太大。
提示如果您正在使用某种 Unix 系统,您可以使用
tsort
命令对纯文本文件中描述的图形进行拓扑排序。
在我们的问题中,我们已经有了一个很好的结构表示(DAG)。下一步是寻找一些有用的减少。和以前一样,我们的第一直觉可能应该是删除一个节点,并为剩余的n–1 解决问题(或者假设问题已经解决)。这个相当明显的缩减可以用类似于插入排序的方式来实现,如清单 4-9 所示。(我这里假设是邻接集或者邻接字典之类的;详见第二章。)
清单 4-9 。拓扑排序的一种简单算法
def naive_topsort(G, S=None):
if S is 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):
if v in 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,可以用同样的方式排序。
提示如果一个问题让你想起一个你已经知道的问题或算法,那可能是一个好迹象。事实上,建立一个问题和算法的心理档案是可以让你成为一个熟练的算法专家的事情之一。如果你面临一个问题,而你没有直接的关联,你可以系统地考虑你知道的任何相关(或半相关)的技术,并寻找减少的可能性。
就像在最大排列问题中,我们可以通过计数找到没有 in-edges 的节点。通过从一个步骤到下一个步骤保持我们的计数,我们不需要每次都重新开始,这将线性步骤成本降低到一个常数(产生一个线性运行总时间,如表 3-1 中的递归 1)。清单 4-10 展示了这种基于计数的拓扑排序的迭代实现。(你能看出迭代结构是如何体现递归思想的吗?)关于图形表示的唯一假设是我们可以迭代遍历节点及其邻居。
清单 4-10 。有向无环图的拓扑排序
def topsort(G):
count = dict((u, 0) for u in G) # The in-degree for each node
for u in G:
for v in G[u]:
count[v] += 1 # Count every in-edge
Q = [u for u in G if 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
for v in 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
如果你想从A
和B
中派生出一个新的类C
,那你就有麻烦了。你不知道是在X
还是Y
中寻找方法。
一般来说,继承关系形成了一个 DAG(你不能在一个循环中继承),为了弄清楚在哪里寻找方法,大多数语言创建了一个类的线性化,这只是 DAG 的一个拓扑排序。Python 的最新版本使用了一种称为 C3 的方法解析顺序(或 MRO )(更多信息请参见参考资料),这种方法除了以尽可能有意义的方式对类进行线性化之外,还禁止出现像前面示例中那样的问题情况。
属地。 软件包管理类 CPSC 357 的先决条件是 CPSC 432、CPSC 357 和 glibc2.5 或更高版本( http://xkcd.com/754
)
更强的假设
设计算法时默认的归纳假设是“我们可以用它来解决更小的实例”,但有时这不足以实际执行归纳步骤或有效地执行它。选择子问题的顺序可能很重要(例如在拓扑排序中),但有时我们必须实际做出一个更强的*?? 假设,以在我们的归纳中附带一些额外的信息。虽然一个更强的假设看起来会让证明变得更难, 14 它实际上只是给了我们更多的来处理来推导从n–1(或 n /2,或其他大小)到 n 的步长。*
考虑平衡因素的想法。这些被用在一些类型的平衡树中(在第六章的中讨论),并且是一个树或者子树平衡(或者不平衡)程度的度量。为简单起见,我们假设每个内部节点都有两个子节点。(在实际实现中,一些叶子可能仅仅是None
等。)为每个内部节点定义平衡因子,并将其设置为左右子树的高度之差,其中高度是从节点(向下)到叶子的最大距离。例如,图 4-6 中根的左子树的平衡因子为–2,因为它的左子树是一片叶子(高度为 0),而它的右子树的高度为 2。
图 4-6 。二叉树的平衡因子。只为内部节点(突出显示)定义了平衡因子,但是对于叶节点,可以将其设置为零
计算平衡因子并不是一个非常具有挑战性的算法设计问题,但它确实说明了一点。考虑明显的(分治)缩减。为了找到根的平衡因子,递归地求解每个子树的问题,然后将部分解扩展/组合成完整的解。很简单。除...之外...没用的。我们可以解决更小的子问题的归纳假设在这里对我们没有帮助,因为我们的子问题的解决方案(即平衡因子)没有包含足够的信息来进行归纳步骤!平衡系数不是根据孩子的平衡系数定义的,而是根据他们的身高定义的。只要加强我们的假设,我们就能轻松解决这个问题。我们假设可以找到任意一棵有 k < n 个节点的树的平衡因子和高度。我们现在可以在归纳步骤中使用高度,在归纳步骤中找到尺寸 n 的平衡系数(左高减去右高)和高度(左高和右高的最大值加 1)。问题解决了!练习 4-20 要求你解决这里的细节问题。
注意树上的递归算法与深度优先搜索密切相关,在第五章中讨论。
正式思考加强归纳假设有时会有点混乱。相反,你可以想一想,为了建立一个更大的解决方案,你需要在归纳步骤中“附带”哪些额外的信息。例如,在前面使用拓扑排序时,很明显,在我们逐步通过部分解决方案时,捎带(和维护)入度使得更有效地执行归纳步骤成为可能。
更多强化归纳假设的例子,参见第六章中的最近点问题和练习 4-21 中的区间包含问题。
反向感应和二次幂
有时候限制我们正在处理的问题的大小是很有用的,比如只处理 2 的幂。例如,这经常发生在分治算法中(分别参见第三章和第六章的递归和示例)。在许多情况下,无论我们发现什么样的算法或复杂性,对任何值的 n 都仍然有效,但有时,对于本章前面描述的棋盘覆盖问题,情况并非如此。可以肯定的是,我们可能需要证明 n 的任何值都是安全的。对于复发,可以使用第三章中的归纳方法。为了显示正确性,可以使用反向归纳。假设算法对于 n 是正确的,并表明这意味着对于n–1 是正确的。这通常可以通过简单地引入一个“虚拟”元素来实现,该元素不会影响解决方案,但是会将大小增加到 n 。如果你知道这个算法对于无限大小的集合(比如 2 的所有幂)是正确的,反向归纳将让你证明它对于所有的尺寸都是正确的。
不变量和正确性
本章的主要焦点是设计算法,其中正确性来自设计过程。也许在计算机科学中,归纳的一个更普遍的观点是正确性证明。这和我在这一章中讨论的基本上是一样的,只是角度略有不同。你面前是一个完成的算法,你需要证明它是可行的。对于递归算法,我已经展示给你的思想可以直接使用。对于循环,也可以递归思考,但是有一个概念更直接的适用于迭代的归纳证明:循环不变量。给定一些前提条件,循环不变量是在循环的每次迭代后为真的东西;它被称为不变量,因为它不会变化——从头到尾都是真的。
通常,最终解是不变量在最后一次迭代后得到的特例,所以如果不变量总是成立(给定算法的前提条件),并且你可以证明循环终止,你就证明了算法是正确的。让我们用插入排序来试试这种方法(清单 4-2 )。循环的不变量是元素 0... i 被排序(如代码中第一个注释所暗示的)。如果我们想用这个不变量来证明正确性,我们需要做以下事情:
- 使用归纳法来证明它在每次迭代后都是真实的。
- 表明如果算法终止,我们会得到正确的答案。
- 表明算法终止。
步骤 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]
:
>>> for v in range(n):
... C[v] = float('inf')
>>> for i in 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)。对于更复杂的问题,你可能需要更微妙的方法。例如,你可以表明你的解的值在每次迭代中增加一个整数;如果算法只在你达到最优(整数)值时终止,那么它一定是正确的。(这类似于最大流算法的情况。)或者,您可能需要展示正确的估计如何在问题实例的元素之间传播,例如图中的节点。如果目前这看起来有点笼统,不要担心——当我们遇到使用该技术的算法时,我会得到足够具体的信息。
提示用放松来设计算法可以像一个游戏。每一次放松都是一个“动作”,你试图用尽可能少的动作获得最优解。你总是可以通过全身放松来达到目的,但关键在于以正确的顺序表演你的动作。当我们在 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.你可以在平面上画出没有任何边互相交叉的图叫做平面。这样的绘图将会有许多的区域,由图的边所包围的区域,以及围绕图的(无限大的)区域*。如果图分别有 V 、 E 和 F 个节点、边和区域,那么平面连通图的欧拉公式说V–E+F= 2。用归纳法证明这是正确的。*
4-2.考虑一盘巧克力,由矩形排列的 n 个正方形组成。你想把它分成独立的方块,你要使用的唯一操作是把当前的一个矩形(一旦你开始打破,会有更多的矩形)分成两块。做这件事最有效的方法是什么?
4-3.假设你要邀请一些人参加一个聚会。你在考虑交朋友,但是你知道只有当他们每个人都至少认识聚会上的其他人时,他们才会玩得开心。(假设如果 A 知道 B,那么 B 自动知道 A。)通过设计一个算法来寻找你的朋友的最大可能子集来解决你的问题,其中每个人都知道至少其他人的 k ,如果这样的子集存在的话。
加分题:如果你的朋友平均认识 d 组中的其他人并且至少有一个的人认识至少一个的其他人,说明你总能找到 k ≤ d /2 的(非空)解。
4-4.如果从一个节点到同一图中任何其他节点的最大(未加权)距离是最小的,则该节点称为中心。也就是说,如果您按照节点到任何其他节点的最大距离对节点进行排序,中心节点将位于开头。解释为什么无根树有一个或两个中心节点,并描述寻找它们的算法。
4-5.还记得第三章里的骑士吗?在他们的第一次锦标赛(循环锦标赛,每个骑士互相比武)之后,工作人员想要创建一个排名。他们意识到可能无法创建一个独特的等级,甚至无法进行适当的拓扑排序(因为可能会有骑士互相击败的循环),但他们决定采用以下解决方案:按顺序排列骑士K1,K2,...、 Kn ,其中 K 1 败 K 2 、 K 2 败 K 3 以此类推(Ki–1败 Ki ,为 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)。假设您有一个这样的区间列表(其中没有相同的区间),并且您想知道哪些区间落在其他区间内。当 x ≤ u 和 v ≤ y 时,一个音程( u , v )落在( x , y )内。你如何有效地做到这一点?
4-22.在“放松和逐步改进”一节中,你将如何改进飞机+火车问题的基于放松的算法,从而保证你在多项式时间内得到答案?
4-23.考虑三个问题, foo , bar , baz 。你知道杠很难,而杠很容易。你会如何表现出 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”中讨论过。***
五、遍历:算法学的万能钥匙
你在一条狭窄的走廊里。这种情况持续了几米,在一个门口结束。沿着走廊走了一半,你可以看到一个拱门,那里有几级台阶向下延伸。你会走向门口(转到 5),还是蹑手蹑脚地走下台阶(转到 344)?
—史蒂夫·杰克逊,混乱的城堡
一般来说,图形是一种强大的心理(和数学)结构模型;如果你能把一个问题表述成一个处理图形的问题,即使它看起来不像图形问题,你可能离解决它又近了一步。碰巧还有一个非常有用的思维模型用于图形算法——一个万能钥匙,如果你愿意的话。 1 该骨架关键是遍历:发现并随后访问图中的所有节点。这不仅仅是关于明显的图。例如,想想 GIMP 或 Adobe Photoshop 等绘画应用如何用单一颜色填充一个区域,即所谓的泛色填充。这是您在这里学到的知识的应用(见练习 5-4)。或者您想序列化一些复杂的数据结构,并需要确保检查其所有组成对象?这就是遍历。列出文件系统一部分中的所有文件和目录?管理软件包之间的依赖关系?更多遍历。
但是遍历不仅仅是直接有用;这是许多其他算法的关键组成部分和潜在原则,比如《??》第九章和《??》第十章中的算法。例如,在第十章的中,我们将尝试将 n 人与 n 份工作相匹配,其中每个人的技能仅与部分工作相匹配。该算法的工作原理是,先暂时给人们分配工作,然后在需要其他人接替时再重新分配。这种重新分配然后可以触发另一个重新分配,可能导致级联。正如你将看到的,这种级联包括在人和工作之间来回移动,以一种之字形模式,从一个闲散的人开始,到一个可用的工作结束。这是怎么回事?你猜对了:遍历。
我将从几个角度介绍这个想法,并在几个版本中,尽可能地将各个部分联系起来。这意味着涵盖了两个最著名的基本遍历策略, 深度优先搜索和广度优先搜索,建立了一个稍微复杂一点的基于遍历的算法,用于查找所谓的强连通分量。
遍历是有用的,因为它让我们在一些基本归纳的基础上构建一个抽象层。考虑寻找一个图的连通分量的问题(见图 5-1 举例)。正如你在《??》第二章中所回忆的,一个图是连通的,如果从每个节点到其他每个节点都有一条路径,并且如果连通分量是(单独)连通的最大子图。寻找连通分量的一种方法是从图中的某个地方开始,逐渐增长到一个更大的连通子图,直到我们不能再前进为止。我们如何确定我们已经重建了一个完整的组件?
图 5-1 。有三个连通分量的无向图
我们来看下面这个相关的问题。说明可以对连通图中的节点进行排序, v 1 , v 2 ,。。。, v n ,这样对于任何一个 i = 1。。。 n ,超过v1 的子图。。。,vI 相连。如果我们可以展示这一点,并且我们可以弄清楚如何进行排序,我们就可以遍历一个连接组件中的所有节点,并知道它们何时用完。
我们如何做到这一点?归纳思考,我们需要从I–1 到 i 。我们知道在I-1 首节点上的子图是连通的。接下来呢?因为任何一对节点之间都有路径,所以考虑第一个I-1 节点中的节点 u 和其余节点中的节点 v 。在从 u 到 v 的路径上,考虑到目前为止我们已经构建的组件中的最后一个节点*,以及之外的第一个节点。让我们称它们为 x 和 y 。很明显,它们之间肯定有一条边,所以将 y 添加到我们不断增长的组件的节点中,可以保持它的连接,我们已经展示了我们想要展示的内容。*
我希望您能看到最终的过程实际上是多么简单。这只是添加连接到组件的节点的问题,我们通过跟踪一条边来发现这样的节点。有趣的一点是,只要我们继续以这种方式将新节点连接到我们的组件,我们就在构建一棵树。这棵树叫做遍历树,是我们正在遍历的组件的生成树。(当然,对于有向图,它只跨越我们可以到达的节点。)
为了实现这一过程,我们需要跟踪这些“边缘”或“前沿”节点,它们仅在一条边之外。如果我们从单个节点开始,边界将只是它的邻居。当我们开始探索时,新访问的节点的邻居将形成新的边缘,而我们现在访问的那些节点将落入其中。换句话说,我们需要将边缘作为某种集合来维护,在这里我们可以删除我们访问的节点并添加它们的邻居,除非它们已经在列表中或者我们已经访问过它们。它变成了我们想要访问但还没有抽出时间去做的节点列表。你可以认为我们已经访问过的那些已经被检查过了。
对于那些玩过像龙与地下城(Dungeons & Dragons)这样的老派角色扮演游戏的人来说,图 5-2 可能有助于澄清这些想法。它显示了一个典型的地牢地图。 2 把房间(和走廊)想象成节点,把它们之间的门想象成边。这里有一些多重边缘(门),但这真的不是问题。我还在地图上添加了一个“你在这里”的标记,以及一些指示你如何到达那里的轨迹。
图 5-2 。一个典型角色扮演地牢的局部遍历。把房间想象成节点,把门想象成边缘。遍历树是由你的轨迹定义的;边缘(遍历队列)包括相邻的房间,没有足迹的浅色房间。剩余的(黑暗的)房间还没有被发现
请注意,有三种房间:你实际参观过的房间(有轨道穿过的房间),你因为看到他们的门而知道的房间,以及你还不知道的房间(变暗的)。未知房间(当然)通过已知但未被访问的房间的边界与被访问的房间分开,就像在任何类型的遍历中一样。清单 5-1 给出了这个通用遍历策略的一个简单实现(注释指的是图而不是地牢)。 3
清单 5-1 。遍历用邻接集表示的图的连通分量
def walk(G, s, S=set()): # Walk the graph from node s
P, Q = dict(), set() # Predecessors + "to do" queue
P[s] = None # s has no predecessor
Q.add(s) # We plan on starting with s
while Q: # Still nodes to visit
u = Q.pop() # Pick one, arbitrarily
for v in G[u].difference(P, S): # New nodes?
Q.add(v) # We plan to visit them!
P[v] = u # Remember where we came from
return P # The traversal tree
提示
set
类型的对象可以让你在其他类型上执行集合操作!例如,在清单 5-1 中,我在difference
方法中使用字典P
,就好像它是一个(它的键的)集合。这也适用于其他的可重复项,例如list
或deque
,以及其他的 set 方法,例如update
。
关于这个新代码的一些事情可能不会立即显现出来。例如,S
参数是什么,为什么我要使用字典来记录我们访问过的节点(而不是一个集合)?S
参数现在并不那么有用,但是当我们试图找到强连接的组件时(接近本章末尾),我们将需要它。基本上,它代表了一个“禁区”——一组我们在遍历过程中可能没有访问过但被告知要避开的节点。至于字典P
,我用它来代表前辈。每次我们添加一个新的节点到队列中,我设置它的前任;也就是说,当我找到它的时候,我确定我记得我是从哪里来的。这些前辈将一起形成遍历树。如果您不关心树,您当然可以使用一组访问过的节点(我将在本章后面的一些实现中这样做)。
注意无论是在将节点添加到队列的同时将它们添加到这种“已访问”集合中,还是稍后将它们从队列中弹出,通常都不重要。这确实会影响到你需要在哪里添加“如果被访问过…”不过,检查一下。在这一章中,你会看到通用遍历策略的几个版本。
walk
函数将遍历单个连通分量(假设图是无向的)。为了找到所有的组件,你需要在节点上将它包装成一个循环,就像清单 5-2 中的一样。
清单 5-2 。查找连接的组件
def components(G): # The connected components
comp = []
seen = set() # Nodes we've already seen
for u in G: # Try every starting point
if u in seen: continue # Seen? Ignore it
C = walk(G, u) # Traverse component
seen.update(C) # Add keys of C to seen
comp.append(C) # Collect the components
return comp
walk
函数返回它访问过的节点的前任映射(遍历树),我在comp
列表(连接组件)中收集这些。我使用seen
集合来确保我不会从一个先前已经访问过的组件中的节点开始遍历。请注意,即使操作seen.update(C)
在C
的大小上是线性的,对walk
的调用已经完成了同样多的工作,所以从渐近线来看,它不会花费我们任何东西。总而言之,像这样寻找组件是θ(E+V)因为每个边和节点都要探索。 4
walk
函数实际上并没有做那么多。尽管如此,在许多方面,这段简单的代码是本章的主干,也是理解你将要学习的许多其他算法的万能钥匙。或许值得研究一下。试着在你选择的图上手动执行算法(例如图 5-1 中的那个)。你看到如何保证探索整个连接组件了吗?需要注意的是,节点从Q.pop
返回的顺序与无关。无论如何,整个组件都将被探索。然而,这个顺序是定义行走行为的关键元素,通过调整它,我们可以得到一些现成的有用算法。
要遍历其他几个图形,参见图 5-3 和图 5-4 。(有关这些示例的更多信息,请参见附近的侧栏。)
图 5-3 。1759 年,柯尼斯堡(今天的加里宁格勒)的桥梁。插图摘自《数学研究》第一卷(卢卡斯,1891 年,第 22 页)
图 5-4 。一个十二面体, ,目标是追踪边,这样你就可以精确地访问每个顶点一次。该插图摘自《数学研究》第二卷(卢卡斯,1896 年,第 205 页)
在加里宁格勒跳岛
听说过柯尼斯堡七桥(现称加里宁格勒)?1736 年,瑞士数学家莱昂哈德·欧拉遇到了一个处理这些问题的难题,许多居民很长时间以来一直试图解决这个难题。问题是,你能从镇上的任何地方开始,穿过所有七座桥一次,然后回到你开始的地方吗?(你可以在图 5-3 中找到桥梁的布局。)为了解决这个难题,欧拉决定抽象出细节。。。发明了图论。似乎是个好的开始,不是吗?
正如你可能注意到的,图 5-3 中的河岸和岛屿的结构是一个多重图;比如 A 和 B 之间,A 和 c 之间有两条边,那其实并不影响问题。(我们可以很容易地在这些边的中间虚构一些岛来得到一个普通的图。)
欧拉最终证明了,当且仅当一个(多)图是连通的,并且每个节点都有偶数度时,访问该图的每条边恰好一次并到达起点是可能的。由此产生的封闭行走(粗略地说,可以不止一次访问节点的路径)被称为欧拉游,或者欧拉回路,这样的图就是欧拉。(你很容易看出柯尼斯堡不是欧拉;它的所有顶点都是奇数次。)
不难看出,连通性和偶数度节点是必要条件(不连通性显然是一个障碍,奇数度节点必然会在某个时候阻止你的旅程)。不太明显的是,它们是充分条件。我们可以用归纳法证明这一点(大惊喜,嗯?),但是我们需要对我们的诱导参数小心一点。如果我们开始移除节点或边,简化的问题可能不再是欧拉问题,我们的归纳假设将不再适用。让我们不要担心连通性。如果简化的图是不连通的,我们可以将假设应用于每个连通的部分。但是偶数度呢?
我们被允许按我们想要的频率访问节点,所以我们将移除(或“用完”)的是一组边。如果我们从访问的每个节点中去掉偶数条边,我们的假设将适用。这样做的一种方法是删除一些封闭遍历的边(当然,不一定要访问所有节点)。问题是这样的封闭行走是否会一直存在于欧拉图中。如果我们只是从某个节点开始走, u ,我们进入的每一个节点都会从偶数度到奇数度,所以我们可以放心的再次离开它。只要我们从不两次访问一个边缘,我们最终会回到 u 。
现在,假设归纳假设是,任何具有偶数度节点且边少于 E 条的连通图都有一条包含每条边恰好一次的闭行走。我们从 E 边开始,去掉任意封闭行走的边。我们现在有一个或多个欧拉分量,每个分量都包含在我们的假设中。最后一步是在这些组件中组合欧拉旅行。我们的原始图是连通的,所以我们移除的封闭行走必然会连接组件。最终的解决方案由这种组合行走组成,每个组件都有一个“迂回”的欧拉游。
换句话说,决定一个图是否是欧拉的是很容易的,找到一个欧拉之旅也不是很难(见练习 5-2)。然而,欧拉之旅有一个更成问题的亲戚:汉密尔顿循环。
哈密尔顿循环是以爱尔兰数学家威廉·罗恩·汉密尔顿爵士的名字命名的(除了别的以外),他提出它是一个游戏(称为阿科斯游戏 ),目标是访问十二面体(一个 12 面的柏拉图立体,或 d12)的每个顶点正好一次,然后回到你的原点(见图 5-4 )。更一般地说,哈密尔顿圈是包含整个图的所有节点的子图(恰好一次,因为它是真圈)。我相信你可以看到,柯尼希斯堡是哈密顿的(也就是说,它有一个哈密顿圈)。证明十二面体是哈密顿的有点难。事实上,在一般的图中寻找哈密尔顿路径是一个困难的问题——一个没有有效算法的问题(在第十一章中有更多关于这个的内容)。考虑到问题是如此相似,这有点奇怪,你不觉得吗?
轻而易举的事
1887 年的深秋,一位法国电信工程师正在精心打理的花园迷宫中漫步,看着树叶开始变黄。当他走过迷宫的通道和十字路口时,他认出了一些绿色植物,并意识到他一直在转圈。作为一个有创造力的人,他开始思考如何避免这个错误,如何找到最好的出路。他记得小时候有人告诉他,如果他在每个十字路口都向左拐,他最终会找到出路,但他很容易看到这样一个简单的策略是行不通的。如果在他到达出口之前,他的左转把他带回到他开始的地方,他就被困在一个无限的循环中。不,他需要另想办法。当他最终摸索着走出迷宫时,他灵光一现。他冲回家拿起笔记本,准备开始勾画他的解决方案。
好吧,事实可能不是这样。我承认,这都是我编的,甚至是年份。然而,19 世纪 80 年代末,一位名叫 Trémaux 的法国电信工程师发明了一种穿越迷宫的算法。我一会儿就会谈到这个问题,但首先让我们探索一下“继续左转”策略(也称为左手法则*),看看它是如何工作的,以及什么时候不工作。*
*不允许循环
考虑图 5-5 中的迷宫。如你所见,其中没有循环;它的底层结构是一棵树,如右图所示。在这种情况下,“把一只手放在墙上”的策略会很有效。 6 了解其工作原理的一种方法是观察迷宫实际上只有一面内壁(或者,换句话说,如果你在里面放墙纸,你可以使用一个连续的长条)。看外面的方块。只要你不被允许创建循环,你画的任何障碍都必须在一个确切的地方连接到它,这不会给左手定则带来任何问题。按照这种遍历策略,您将发现所有节点,并对每个通道遍历两次(每个方向一次)。
图 5-5 。一棵树,被画成一个迷宫和一个更传统的图表,叠加在迷宫上
左手规则被设计为由实际行走迷宫的个体执行,仅使用局部信息。为了牢牢把握到底发生了什么,我们可以放弃这种观点,递归地制定同样的策略*。 7 一旦你熟悉了递归思想,这样的公式可以更容易地看出一个算法是正确的,这是最简单的递归算法之一。对于一个基本的实现(假设树的一个标准图形表示),见清单 5-3 。*
*清单 5-3 。递归树遍历
def tree_walk(T, r): # Traverse T from root r
for u in T[r]: # For each child. . .
tree_walk(T, u) # ... traverse its subtree
就迷宫的比喻而言,如果你站在一个十字路口,你可以向左或向右走,你首先穿过迷宫向左的部分,然后是向右的部分。就这样。很明显(也许借助于一点归纳),这个策略将遍历整个迷宫。请注意,这里只明确描述了在每个通道中向前行走的动作。当你遍历以节点 u 为根的子树时,你向前走到 u 并从那里开始工作新的段落。最终还是要回归根本, r 。像这样回溯,越过你自己的轨迹,叫做回溯 ,隐含在递归算法中。每次递归调用返回时,您会自动回溯到发起调用的节点。(你看到这个回溯行为是如何符合左手定则的吗?)
想象一下,有人在迷宫的一面墙上戳了一个洞,相应的图形突然有了一个循环。也许他们在节点 e 打破了死胡同北边的墙。如果你从 e 开始向北走,你可以一直向左走,但你永远不会穿过整个迷宫——你会一直绕圈子。 8 这是我们在遍历一般图时面临的问题。 9 清单 5-1 中的总体思路给了我们一个解决这个问题的方法,但是在我开始之前,让我们看看我们的法国电报工程师想出了什么。
如何停止兜圈子
douard Lucas 于 1891 年在他的数学记录的第一卷中描述了 Tremaux 穿越迷宫的算法。卢卡斯在他的介绍中写道: 10
要从任何一个起点完整地穿过迷宫的所有通道两次,只需遵循 Trémaux 提出的规则,在每个十字路口的入口或出口做上标记。这些规则可以总结如下:尽可能避免经过你已经走过的十字路口,避免走你已经走过的通道。这难道不是一种同样适用于日常生活的谨慎做法吗?
在本书的后面,他继续更详细地描述了这个方法,但是它真的很简单,前面的引用很好地涵盖了主要思想。而不是标记每个入口或出口(比如说,用一支粉笔),让我们只说你有泥泞的靴子,这样你就可以看到我们自己的足迹(就像在图 5-2 中)。然后,Trémaux 会告诉你开始朝任何方向走,每当你走到死胡同或你已经走过的十字路口时,就往回走(以避免循环)。你不能穿越一个通道超过两次(一次向前,一次向后),所以如果你在原路返回到一个十字路口,你会向前走进一个未被探索的通道,如果有的话。如果没有*,你就继续原路返回(进入另一个只有一组脚印的通道)。 11*
这就是算法。一个有趣的观察是,尽管您可以选择几个段落进行向前遍历,但总是只有一个可用于回溯。你知道为什么吗?可能有两个*(或更多)的唯一方法是,如果你从一个十字路口向另一个方向出发,然后没有原路返回。不过,在这种情况下,规则规定你应该而不是进入十字路口,而是立即原路返回。(这也是为什么你永远不会在同一个方向上穿越两次的原因。)*
*我在这里使用“泥泞的靴子”描述的原因是为了使回溯真正清晰;这与递归树遍历中的规则完全一样(同样,它相当于左手规则)。事实上,如果递归地表述,Trémaux 的算法就像树遍历一样,只是增加了一点内存。我们知道我们已经访问了哪些节点,并假装有一堵墙阻止我们进入它们,实际上模拟了一个树形结构(这就是我们的遍历树)。
参见清单 5-4 中 Trémaux 算法的递归版本。在这个公式中,俗称深度优先搜索,是最基本(也是最重要)的遍历算法之一。 12
清单 5-4 。递归深度优先搜索
def rec_dfs(G, s, S=None):
if S is None: S = set() # Initialize the history
S.add(s) # We've visited s
for u in G[s]: # Explore neighbors
if u in S: continue # Already visited: Skip
rec_dfs(G, u, S) # New: Explore recursively
注意与清单 5-1 中的
walk
函数相反,在这里的循环中对G[s]
使用difference
方法是错误的,因为S
在递归调用中可能会改变,你很容易多次访问一些节点。
深入!
深度优先搜索(DFS)从其递归结构中获得一些最重要的属性。一旦我们开始处理一个节点,我们就要确保在继续处理之前,遍历我们可以从它到达的所有其他节点。然而,正如在第四章中提到的,递归函数总是可以被重写为迭代函数,可能用我们自己的栈来模拟调用栈。DFS 的这种迭代公式可能是有用的,既可以避免填满调用堆栈,也可以使算法的某些属性更加清晰。幸运的是,为了模拟递归遍历,我们需要做的是在算法中使用一个堆栈而不是集合,就像清单 5-1 中的中的walk
。清单 5-5 显示了这种迭代 DFS。
清单 5-5 。迭代深度优先搜索
def iter_dfs(G, s):
S, Q = set(), [] # Visited-set and queue
Q.append(s) # We plan on visiting s
while Q: # Planned nodes left?
u = Q.pop() # Get one
if u in S: continue # Already visited? Skip it
S.add(u) # We've visited it now
Q.extend(G[u]) # Schedule all neighbors
yield u # Report u as visited
除了使用堆栈(一个后进先出,或者 LIFO 队列,在本例中使用append
和pop
由一个列表实现),这里还有一些调整。例如,在我最初的walk
函数中,队列是一个集合,,所以我们绝不会冒险让同一个节点被安排多次访问。一旦我们开始使用其他队列结构,情况就不一样了。我已经通过在添加它的邻居之前检查一个节点在S
中的成员资格(也就是说,我们是否已经访问过该节点)解决了这个问题。
为了使遍历更加有用,我还添加了一个yield
语句,它将允许您按照 DFS 顺序遍历图节点。例如,如果你在变量G
中有来自图 2-3 的图形,你可以尝试如下:
>>> list(iter_dfs(G, 0))
[0, 5, 7, 6, 2, 3, 4, 1]
值得注意的一点是,我刚刚在一个有向图上运行了 DFS,而我只讨论了它如何在无向图上工作。实际上,DFS 和其他遍历算法对有向图同样有效。然而,如果你在一个有向图上使用 DFS,你不能期望它探索整个连通的部分。例如,对于图 2-3 中的图,从 a 之外的任何其他开始节点遍历将意味着 a 永远不会被看到,因为它没有入边。
提示要在一个有向图中找到连通的部分,第一步可以很容易地构建底层无向图。或者你可以简单地浏览图表并添加所有的反向边。这对其他算法也很有用。有时,你甚至可能不构建无向图;当使用有向图时,简单地考虑两个方向上的每个边就足够了。
你也可以用 Trémaux 的算法来思考这个问题。你仍然可以双向穿越每个(定向)通道,但是你只能沿着边缘方向向前前进,并且你必须逆着边缘方向返回*。*
事实上,iter_dfs
函数的结构非常接近我们可能实现的一般遍历算法——其中只需要替换队列。让我们加强walk
到更成熟的traverse
( 清单 5-6 )。
清单 5-6 。一个通用的图遍历函数
def traverse(G, s, qtype=set):
S, Q = set(), qtype()
Q.add(s)
while Q:
u = Q.pop()
if u in S: continue
S.add(u)
for v in G[u]:
Q.add(v)
yield u
这里默认的队列类型是set
,使其类似于原来的(任意)walk
。您可以很容易地定义一个堆栈类型(使用我们通用队列协议的适当的add
和pop
方法),可能如下所示:
class stack(list):
add = list.append
先前的深度优先测试可以重复如下:
>>> list(traverse(G, 0, stack))
[0, 5, 7, 6, 2, 3, 4, 1]
当然,实现各种遍历算法的特殊用途版本也是很好的,即使它们可以用几乎相同的形式表达。
深度优先时间戳和拓扑排序(再次)
如前所述,记住并避免以前访问过的节点是防止我们绕圈子(或者更确切地说,循环)的原因,没有循环的遍历自然会形成一棵树。这种遍历树根据它们的构造方式有不同的名称;对于 DFS,它们被恰当地命名为深度优先树(或 DFS 树)。与任何遍历树一样,DFS 树的结构是由访问节点的顺序决定的。DFS 树特有的一点是,节点 u 的所有后代在从发现 u 到我们回溯它的时间间隔内被处理。
为了利用这个属性,我们需要知道算法何时回溯,这在迭代版本中可能有点困难。尽管你可以从清单 5-5 中扩展迭代 DFS 来跟踪回溯(见练习 5-7),我将在这里扩展递归版本(清单 5-4 )。参见清单 5-7 中的版本,该版本为每个节点添加了时间戳:一个用于它被发现的时间(发现时间,或d
),一个用于我们回溯它的时间(完成时间,或f
)。
清单 5-7 。带时间戳的深度优先搜索
def dfs(G, s, d, f, S=None, t=0):
if S is None: S = set() # Initialize the history
d[s] = t; t += 1 # Set discover time
S.add(s) # We've visited s
for u in G[s]: # Explore neighbors
if u in S: continue # Already visited. Skip
t = dfs(G, u, d, f, S, t) # Recurse; update timestamp
f[s] = t; t += 1 # Set finish time
return t # Return timestamp
参数d
和f
应该是映射(例如字典)。DFS 属性然后声明:( 1)每个节点在 DFS 树中其后代的之前被发现,并且(2)每个节点在 DFS 中其后代的之后结束。这直接来自算法的递归公式,但是你可以很容易地做一个归纳证明来说服自己这是真的。
这个性质的一个直接结果是,我们可以使用 DFS 进行拓扑排序,这已经在第四章中讨论过了。如果我们在 DAG 上执行 DFS,我们可以简单地根据它们的完成时间降序排列节点,并且它们将被拓扑排序。然后,每个节点 u 将在 DFS 树中其所有后代之前,这些后代将是从 u 可到达的任何节点,即依赖于 u 的节点。在这种情况下,了解算法如何工作是有好处的。我们可以简单地在定制 DFS 的过程中执行拓扑排序*,而不是首先调用我们的时间戳 DFS,在回溯时追加节点,如清单 5-8 所示。 13*
清单 5-8 。基于深度优先搜索的拓扑排序
def dfs_topsort(G):
S, res = set(), [] # History and result
def recurse(u): # Traversal subroutine
if u in S: return # Ignore visited nodes
S.add(u) # Otherwise: Add to history
for v in G[u]:
recurse(v) # Recurse through neighbors
res.append(u) # Finished with u: Append it
for u in G:
recurse(u) # Cover entire graph
res.reverse() # It's all backward so far
return res
在这个新的拓扑排序算法中,有几件事情值得注意。首先,我显式地在所有节点上包含了一个for
循环,以确保遍历了整个图。(练习 5-8 要求你证明这是可行的。)检查一个节点是否已经在历史集合中(S
)现在正好放在recurse
中,所以我们不需要把它放在两个for
循环中。另外,因为recurse
是一个内部函数,可以访问周围的作用域(特别是S
和res
),所以唯一需要的参数是我们要遍历的节点。最后,记住我们希望节点根据它们的完成时间以反向排序。这就是为什么res
列表在返回之前是反转的。
这个 topsort 在回溯每个节点时对它们执行一些处理(它将它们附加到结果列表中)。DFS 在节点上回溯的顺序(也就是它们结束时间的顺序)被称为后序,而它首先访问它们的顺序被称为前序。这些时候的加工被称为前序或后序加工。(练习 5-9 要求你在 DFS 中为这种处理添加通用钩子。)
节点颜色和边缘类型
在描述遍历时,我区分了三种节点:我们不知道的节点、我们队列中的节点和我们已经访问过的节点(其邻居现在在队列中)。有些书(如第一章中提到的 Cormen 等人的算法简介)介绍了一种节点着色的形式,这在 DFS 中尤为重要。每个节点一开始都被认为是白色的;它们在发现时间和结束时间之间是灰色的,之后是黑色的。为了实现 DFS,你并不真的需要这种分类,但是这对于理解它是有用的(或者,至少,如果你要阅读使用颜色的文本,了解它是有用的)。
根据 Trémaux 的算法,灰色交叉点是我们已经看到但已经避开的;黑色的十字路口是我们被迫第二次进入的路口(在原路返回时)。
这些颜色也可以用来对 DFS 树中的边进行分类。如果一条边 uv 被探索并且节点 v 是白色的,那么这条边就是一条树边——也就是说,它是遍历树的一部分。如果 v 是灰色的,那就是所谓的后沿,它可以追溯到 DFS 树中的一个祖先。最后,如果 v 为黑色,则边缘为所谓的前边缘或横边缘。前向边是遍历树中到后代的边,而交叉边是任何其他边(即,不是树、后向边或前向边)。
请注意,您可以在不使用任何显式颜色标签的情况下对边进行分类。假设一个节点的时间跨度是从它的发现时间到它的结束时间的间隔。后代的时间跨度将包含在其祖先的时间跨度中,而与祖先无关的节点将具有不重叠的时间间隔。因此,您可以使用时间戳来判断某个东西是后沿还是前沿。即使使用颜色标签,您也需要参考时间戳来区分前向边缘和交叉边缘。
你可能不太需要这个分类,尽管它有一个重要的用途。如果你找到了一个后沿,这个图就包含了一个循环,如果你没有找到,它就没有。(练习 5-10 要求你展示这个。)换句话说,您可以使用 DFS 来检查一个图是否是 DAG(或者,对于无向图,是树)。练习 5-11 要求你考虑其他的遍历算法如何实现这个目的。
无限迷宫和最短(未加权)路径
到目前为止,DFS 过于急切的行为还不是问题。我们让它在迷宫(图)中自由活动,在它开始原路返回之前,它尽可能向某个方向偏离。但是,如果迷宫非常大,这可能会有问题。也许我们在寻找的东西,比如一个出口,就在我们出发的地方附近;如果 DFS 向不同的方向出发,它可能在年内不会返回。如果迷宫是无限的,它将永远不会回来,即使不同的遍历可能在几分钟内找到出口。无限迷宫听起来可能有些牵强,但它们实际上非常类似于一种重要的遍历问题——在状态空间中寻找解决方案。
但是,像 DFS 一样,由于过于急切而迷失方向,不仅仅是大型图表中的问题。如果我们寻找从我们的开始节点到所有其他节点的最短路径*(暂时不考虑边权重),DFS 很可能会给我们错误的答案。看看图 5-6 中的例子。所发生的是,DFS,在它的渴望中,继续前进,直到通过一个弯路到达 c ,可以这么说。如果我们想要找到到所有其他节点的最短路径(如右图所示),我们需要更加保守。为了避免走弯路并“从后面”到达一个节点,我们需要一次一步地推进我们的遍历“边缘”。首先访问一步之外的所有节点,然后访问两步之外的所有节点,依此类推。*
*
图 5-6 。大小为四的循环的两次遍历。深度优先树(左侧突出显示)不一定包含最小路径,这与最短路径树(右侧突出显示)相反
为了与迷宫隐喻保持一致,让我们简单地看一下另一种迷宫探索算法,它是由 ystein(又名 Oystein) Ore 在 1959 年描述的。就像 Trémaux 一样,Ore 要求您在通道入口和出口处做标记。假设你从十字路口 a 开始。首先,你访问一个通道之外的所有十字路口,每次都返回到你的起点。如果你跟踪的任何一个通道都是死胡同,一旦你返回,你就把它们标记为关闭。任何带你去你已经去过的十字路口的通道也被标记为关闭(在两端)。
在这一点上,你想要开始探索两个步骤(也就是通道)之外的所有交叉点*。标记并浏览来自 a 的开放通道之一;它现在应该有两个标记。假设你最终到达十字路口 b 。现在,遍历(并标记)从 b 开始的所有开放通道,如果它们通向你已经看到的死胡同或交叉路口,确保关闭它们。完成后,返回到 a 。一旦你回到 a ,你就继续其他开放段落的过程,直到它们都得了两个分数。(这两个标记意味着你已经在通道中两步之外穿过了十字路口。)*
让我们跳到第步第步。 14 您已经访问了所有距离n–1 步远的十字路口,所以从 a 开始的所有开放通道现在都有n1 标记。在 a 旁边的任何路口的开放通道,比如你之前去过的 b ,上面都会有n—2 的标记,以此类推。要访问距离您的起点 n 的所有路口,您只需移动到 a 的所有邻居(例如 b ),在这样做的同时向通道添加标记,并按照相同的程序访问距离它们n*–1 的所有路口(根据归纳假设,这将有效)。*
同样,像这样只使用本地信息可能会使簿记有点乏味(并且解释有点混乱)。然而,就像 Trémaux 的算法在递归 DFS 中有一个非常接近的亲戚一样,Ore 的方法可以用一种可能更适合我们计算机科学大脑的方式来表述。结果是所谓的迭代深化深度优先搜索,或 IDDFS 、 15 ,它简单地包括运行具有迭代递增深度限制的深度约束 DFS。
清单 5-9 给出了一个相当简单的 IDDFS 实现。它保存了一个名为yielded
的全局集合,由第一次发现并因此产生的节点组成。内部函数recurse
基本上是一个具有深度限制的递归 DFSd
。如果限制为零,则不会递归地探索更多的边。否则,递归调用会受到限制d-1
。iddfs
函数中的主for
循环遍历从 0(只访问并产生开始节点)到len(G)-1
(最大可能深度)的每个深度限制。但是,如果在达到这样的深度之前已经发现了所有节点,那么循环就中断了。
清单 5-9 。迭代深化深度优先搜索
def iddfs(G, s):
yielded = set() # Visited for the first time
def recurse(G, s, d, S=None): # Depth-limited DFS
if s not in yielded:
yield s
yielded.add(s)
if d == 0: return # Max depth zero: Backtrack
if S is None: S = set()
S.add(s)
for u in G[s]:
if u in S: continue
for v in recurse(G, u, d-1, S): # Recurse with depth-1
yield v
n = len(G)
for d in range(n): # Try all depths 0..V-1
if len(yielded) == n: break # All nodes seen?
for u in recurse(G, s, d):
yield u
注意如果我们在探索一个无界图(比如一个无限状态空间),寻找一个特定的节点(或者一种节点),我们可能只是不断尝试更大的深度限制,直到找到我们想要的节点。
IDDFS 的运行时间并不完全清楚。与 DFS 不同,它通常会多次遍历许多边和节点,因此线性运行时间远远不能保证。比如你的图是一条路径,你从一端开始 IDDFS,运行时间将是二次。然而,这个例子是相当病态的;如果遍历树向外分支一点,它的大部分节点将在底层(就像在第三章的淘汰赛中一样),所以对于许多图来说,运行时间将是线性的或接近线性的。
试着在一个简单的图上运行iddfs
,您将看到节点将从离开始节点最近到最远的顺序产生。返回所有距离为 k 的,然后返回所有距离为 k + 1 的,以此类推。如果我们想要找到实际的距离,我们可以很容易地在iddfs
函数中执行一些额外的簿记,并产生距离和节点。另一种方法是维护一个距离表(类似于我们前面使用的 DFS 的发现和完成时间)。事实上,我们可以有一个距离字典和一个遍历树中的父字典。这样,我们可以检索实际的最短路径,以及距离。现在让我们专注于路径,而不是修改iddfs
来包含额外的信息,我们将把它构建到另一个遍历算法 : 广度优先搜索 (BFS) 。
事实上,使用 BFS 进行遍历比使用 IDDFS 要容易得多。您只需使用带有先进先出队列的通用遍历框架(清单 5-6 )。 事实上,这是与 DFS 唯一显著的区别:我们用 FIFO 代替了 LIFO(见清单 5-10 )。结果是,较早发现的节点将被较早地访问,我们将一层一层地探索图,就像在 IDDFS 中一样。不过,这样做的好处是,我们不需要多次访问任何节点或边,所以我们回到了有保证的线性性能。 16
清单 5-10 。广度优先搜索
def bfs(G, s):
P, Q = {s: None}, deque([s]) # Parents and FIFO queue
while Q:
u = Q.popleft() # Constant-time for deque
for v in G[u]:
if v in P: continue # Already has parent
P[v] = u # Reached from u: u is parent
Q.append(v)
return P
如你所见,bfs
函数类似于iter_dfs
,来自清单 5-5 。我用一个 deque 替换了这个列表,我跟踪遍历树中哪些节点已经接收了一个父节点(也就是说,它们在P
中),而不是记住我们访问过哪些节点(S
)。要提取到节点u
的路径,您可以简单地在P
中“向后走”:
>>> path = [u]
>>> while P[u] is not None:
... path.append(P[u])
... u = P[u]
...
>>> path.reverse()
当然,您也可以在 DFS 中自由使用这种父字典,或者使用yield
来迭代 BFS 中的节点。练习 5-13 要求你修改代码来寻找距离(而不是路径)。
提示将 BFS 和 DFS 形象化的一种方式是浏览网页。如果你一直跟随链接,然后在完成一个页面后使用后退按钮,你就会得到 DFS。回溯有点像“撤销”BFS 更像是在一个新窗口(或标签页)中打开你已经打开的链接,然后在你完成每一页后关闭窗口。
实际上,只有一种情况下 IDDFS 比 BFS 更好:当搜索一棵大树(或者一些“形状”像树的状态空间)时。因为没有循环,所以我们不需要记住我们访问过哪些节点,这意味着 IDDFS 只需要存储返回起始节点的路径。 17 换句话说,在这些情况下,IDDFS 可以节省大量内存,几乎没有或根本没有渐进减速。
黑盒:DEQUE
正如已经几次简要提到的,Python 列表产生了很好的堆栈(LIFO 队列),但是很差(FIFO 队列)。附加到它们需要恒定的时间(至少在许多这样的附加平均时),但是从前面弹出(或插入)需要线性时间。对于 BFS 这样的算法,我们想要的是一个双端队列、或双端队列。这样的队列通常被实现为链表(其中追加/前置和两端的弹出是常数时间操作),或者所谓的循环缓冲区— 数组,其中我们跟踪第一个元素(头部)和最后一个元素(尾部)的位置。如果头部或尾部移动超出了数组的末端,我们就让它“绕”到另一边,我们使用 mod ( %
)操作符来计算实际的索引(因此有了术语循环)。如果我们完全填满数组,我们可以将内容重新分配到一个更大的数组,就像动态数组一样(参见第二章中list
的“黑盒”侧栏)。
幸运的是,Python 在标准库中的collections
模块中有一个 deque 类。除了在右侧执行的append
、extend
、pop
等方法外,还有左等效,称为appendleft
、extendleft
、popleft
。在内部,deque 被实现为一个由块组成的双向链表,每个块都是一个单独元素的数组。虽然在渐近上等同于使用单个元素的链表,但这减少了开销,并使其在实践中更有效。例如,如果表达式d[k]
是一个普通的列表,那么它需要遍历队列d
的第一个k
元素。如果每个块都包含b
元素,那么你只需要遍历k//b
块。
强连通分量
虽然像 DFS、IDDFS 和 BFS 这样的遍历算法本身就很有用,但是我在前面提到过遍历作为底层结构在其他算法中的作用。您将在接下来的许多章节中看到这一点,但是我将用一个经典的例子来结束这一章——这是一个相当棘手的问题,只要对基本遍历有所了解就可以很好地解决。
问题是找到强连通分量 (SCCs) ,有时简称为强分量。SCC 是连接组件的直接模拟,我在本章开始时向您展示了如何找到它。连通分量是一个极大子图,其中如果忽略边方向(或者如果图是无向的),所有节点都可以到达彼此。然而,为了得到强连接的组件,你需要遵循边的方向;因此,SCCs 是从任意节点到任意其他节点有向路径的极大子图。例如,在现代优化编译器中,寻找 SCC 和类似结构是数据流分析的重要部分。
考虑图 5-7 中的图表。和我们开始用的那个(图 5-1 )挺像的;虽然有一些额外的边,这个新图形的 SCCs 由与无向原始图形的连接组件相同的节点组成。正如您所看到的,在(突出显示的)强组件中,任何节点都可以到达任何其他节点,但是如果您尝试向其中任何节点添加其他节点,该属性就会失效。
图 5-7 。有三个 SCC(突出显示)的有向图:A、B 和 C
想象一下在这个图上执行 DFS(可能从几个起点开始遍历,以确保覆盖整个图)。现在考虑强组件 A 和 B 中节点的完成时间。如您所见,从 A 到 B 有一条边,但没有办法从 B 到 A。这对完成时间有影响。你可以确定 A 会晚于 b 完成,也就是说 A 中的最后完成时间会晚于 b 中的最后完成时间,看看图 5-7 ,应该很明显为什么会这样。如果你从 B 开始,你永远不能进入 A,所以 B 会在你甚至开始(更不用说完成)遍历 A 之前完全结束。然而,如果你从 A 开始,你知道你永远不会卡在那里(每个节点都可以到达其他节点),所以在完成遍历之前,你将最终迁移到 B,你必须在回溯到 A 之前完全完成那个(在这种情况下,还有 C)
事实上,一般来说,如果从任意一个强分量 X 到另一个强分量 Y 有一条边,那么 X 中的最后完成时间将晚于 Y 中的最晚完成时间,其推理与我们的例子相同(参见练习 5-16)。我的结论是基于这样一个事实,即你不能从 B 到达 A——事实上,这是 SCC 通常的工作方式,因为 SCC 形成了 DAG!因此,如果从 X 到 Y 有一条边,那么从 Y 到 X 就不会有任何路径。
考虑图 5-7 中突出显示的组件。如果您将它们收缩为单个“超级节点”(将边保留在原来有边的地方),您最终会得到一个图,我们称之为 SCC 图,如下所示:
这显然是一个 DAG,但是为什么这样的 SCC 图总是是非循环的呢?假设 SCC 图中有一个循环。这意味着你可以从一个 SCC 到另一个 SCC,然后再回来。你觉得有问题吗?是的,完全正确:第一个 SCC 中的每个节点都可以到达第二个 SCC 中的每个节点,反之亦然;事实上,这样一个周期中的所有 SCC 将组合成一个单个 SCC ,这与我们最初认为它们是独立的假设相矛盾。
现在,假设你翻转了图中的所有边。这不会影响 SCC 中哪些节点属于同一个节点(见练习 5-15),但它会影响 SCC 图。在我们的例子中,你不能再走出 A,如果你穿越了 A,在 B 开始了新一轮,你不能从中逃脱,只剩下 c 和...等一下...我只是在那里找到了强组件,不是吗?为了在一般情况下应用这个想法,我们总是需要在原始图中没有任何入边的 SCC 中开始(也就是说,在翻转后没有出边)。基本上,我们在 SCC 图的拓扑排序中寻找第一个 SCC。(然后我们会继续第二个,以此类推。)回顾我们最初的 DFS 推理,如果我们从具有最晚完成时间的节点开始遍历,那就是我们要去的地方。事实上,如果我们通过减少结束时间来选择最终遍历的起点,我们就能保证一次完全探索一个 SCC,因为反向边会阻止我们移动到下一个 SCC。
这种推理可能有点难以理解,但主要思想并不难理解。如果从 A 到 B 有一条边,A 将比 B 有更晚的(最终)完成时间。如果我们根据减少的完成时间选择(第二次)遍历的起点,这意味着我们将在 B 之前访问 A。现在,如果我们反转所有的边,我们仍然可以探索整个 A,但我们不能继续到 B,这使我们一次只能探索一个 SCC。
下面是算法的概要。请注意,我没有“手动”使用 DFS 并按照完成时间对节点进行反向排序,而是简单地使用了dfs_topsort
函数,为我完成了这项工作。 18
- 在图上运行
dfs_topsort
,产生一个序列seq
。 - 反转所有边缘。
- 运行一次完整的遍历,从
seq
开始选择起点(按顺序)。
关于这一点的实现,见清单 5-11 。
清单 5-11 。寻找强连通分量的 Kosaraju 算法
def tr(G): # Transpose (rev. edges of) G
GT = {}
for u in G: GT[u] = set() # Get all the nodes in there
for u in G:
for v in G[u]:
GT[v].add(u) # Add all reverse edges
return GT
def scc(G):
GT = tr(G) # Get the transposed graph
sccs, seen = [], set()
for u in dfs_topsort(G): # DFS starting points
if u in seen: continue # Ignore covered nodes
C = walk(GT, u, seen) # Don't go "backward" (seen)
seen.update(C) # We've now seen C
sccs.append(C) # Another SCC found
return sccs
如果你试着在图 5-7 中的图上运行scc
,你应该得到三组{ a 、 b 、 c 、d};{ e , f ,g};还有{ i , h }。 19 注意,在调用walk
时,我现在已经提供了S
参数,使其避开之前的 SCCs。因为所有的边都指向后面,所以除非明确禁止,否则很容易开始遍历这些边。
然而,这是行不通的(如练习 5-17 所要求的)。
目标和修剪
本章讨论的遍历算法将访问它们能到达的每一个节点。然而,有时您正在寻找一个特定的节点(或一种节点),并且您希望尽可能忽略图中的大部分内容。这种搜索称为目标导向的、和,忽略遍历的潜在子树的行为称为修剪。例如,如果您知道您正在寻找的节点在起始节点的 k 步内,那么运行深度限制为 k 的遍历将是一种修剪形式。二分法搜索或在搜索树中搜索(在第六章的中讨论)也涉及修剪。您不必遍历整个搜索树,只需访问可能包含您要查找的值的子树。树的构造使得你通常可以在每一步丢弃大多数子树,从而产生高效的算法。
知道你要去哪里也可以让你先选择最有希望的方向(所谓的最佳优先搜索)。 这是 A算法的一个例子,在第九章中讨论过。如果你正在搜索一个可能解决方案的空间,你也可以评估一个给定方向的前景*(也就是说,我们沿着这条边能找到多好的最佳解决方案?).通过忽略那些不会帮助你提高到目前为止发现的最好水平的边缘,你可以大大加快速度。这种方法称为分支和绑定 ,在第十一章的中讨论。
摘要
在这一章中,我已经向你展示了在图中移动的基本原理,不管它们是否有方向。这种遍历的思想直接或从概念上构成了本书后面将要学习的许多算法以及后面可能会遇到的其他算法的基础。我使用了迷宫遍历算法的例子(比如 Trémaux 和 Ore ),尽管它们主要是作为更计算机友好的方法的起点。遍历一个图的一般过程包括维护一个您已经发现的节点的概念性待办事项列表(一个队列),在这里您可以检查那些您实际访问过的节点。列表最初只包含开始节点,在每一步中,您访问(并检查)其中一个节点,同时将它的邻居添加到列表中。列表中项目的排序(时间表)在很大程度上决定了你正在进行的遍历类型:例如,使用 LIFO 队列(堆栈)进行深度优先搜索(DFS),而使用 FIFO 队列进行广度优先搜索(BFS)。DFS 相当于一种相对直接的递归遍历,它允许您找到每个节点的发现和完成时间,后代节点的发现和完成时间间隔将在祖先节点的发现和完成时间间隔之内。BFS 有一个有用的特性,可以用来寻找从一个节点到另一个节点的最短(未加权)路径。DFS 的一个变种,叫做迭代深化 DFS ,也有这个属性,但是它对于在大树中搜索更有用,比如在第十一章中讨论的状态空间。
如果一个图由几个相连的部分组成,你需要为每个部分重新开始一次遍历。为此,您可以遍历所有节点,跳过已经访问过的节点,然后从其他节点开始遍历。在有向图中,这种方法可能是必要的,即使图是连通的,因为边方向可能会阻止您到达所有节点。为了找到一个有向图的强连通部分——图中所有节点可以互相到达的部分——需要一个稍微复杂一点的过程。这里讨论的算法,Kosaraju 的算法,首先找到所有节点的完成时间,然后在转置图(所有边都反转的图)中运行遍历,使用递减的完成时间来选择起始点。
如果你好奇的话...
如果你喜欢遍历,不用担心。我们很快会做更多这样的事情。你也可以找到关于 DFS、BFS 和 SCC 算法的细节,例如,在 Cormen 等人的书中讨论的(见“参考文献”,第一章)。如果你对寻找强分量感兴趣,在本章的“参考”部分有关于 Tarjan 和 Gabow(或者更确切地说,Cheriyan-Mehlhorn/Gabow)算法的参考。
练习
5-1.在清单 5-2 中的components
函数中,一次用整个组件更新可见节点集。另一种选择是在walk
中逐个添加节点。那会有什么不同(或者,也许,没有那么不同)?
5-2.如果你面对一个图,其中每个节点的度数都是偶数,你会如何寻找欧拉之旅?
5-3.如果有向图中的每个节点都有相同的入度和出度,你可以找到一个有向欧拉之旅。为什么会这样?你会怎么做,这和 Trémaux 的算法有什么关系?
5-4.图像处理中的一个基本操作是所谓的泛色填充,其中图像中的一个区域用单一颜色填充。在绘画应用(如 GIMP 或 Adobe Photoshop)中,这通常是通过油漆桶工具来完成的。你如何实现这种填充?
5-5.在希腊神话中,当阿里阿德涅帮助忒修斯战胜牛头怪并逃离迷宫时,她给了他一团羊毛线,让他可以重新找到出路。但是,如果忒修斯在进来的时候忘记系好外面的线,并且只有在彻底迷路的时候才想起那个球,那该怎么办呢?
5-6.在递归 DFS 中,当您从一个递归调用返回时,会发生回溯。但是在迭代版本中回溯去了哪里?
5-7.写一个 DFS 的非递归版本,它可以决定完成时间。
5-8.在dfs_topsort
( 清单 5-8 )中,递归 DFS 从每个节点开始(尽管如果节点已经被访问过,它会立即终止)。即使起始节点的顺序完全是任意的,我们如何确定我们将得到一个有效的拓扑排序?
5-9.编写一个 DFS 版本,其中有钩子(可重写函数),允许用户按照前后顺序执行定制处理。
5-10.证明当(且仅当)DFS 找不到后边缘时,被遍历的图是非循环的。
5-11.如果你想使用除 DFS 之外的其他遍历算法在有向图中寻找环,你会面临什么挑战?为什么不用无向图面对这些挑战?
5-12.如果您在无向图中运行 DFS,您将不会有任何前向或交叉边。为什么会这样?
5-13.编写一个 BFS 版本,找出从起始节点到其他每个节点的距离,而不是实际路径。
5-14.正如在第四章中提到的,如果你能把节点分成两个集合,使得没有邻居在同一个集合中,那么这个图就叫做二部图。另一种思考方式是将每个节点涂成黑色或白色(例如),这样相邻节点就不会有相同的颜色。展示对于任何无向图,如果存在这样的二分图(或双色图),你将如何找到它。
5-15.如果你反转一个有向图的所有边,强连通分量保持不变。这是为什么呢?
5-16.设 X 和 Y 是同一个图的两个强连通分量, G 。假设从 X 到 y 至少有一条边,如果在 G 上运行 DFS(根据需要重新启动,直到所有节点都被访问过),那么 X 中最晚的结束时间将总是晚于 y 中最晚的,这是为什么呢?
5-17.在 Kosaraju 的算法中,我们通过从初始 DFS 开始递减完成时间来找到最终遍历的开始节点,并且我们在转置图中执行遍历(即,所有边都反转)。为什么我们不能只使用原始图中的上升完成时间?
参考
Cheriyan,j .和 Mehlhorn,K. (1996 年)。随机存取计算机上的稠密图和网络算法。 Algorithmica ,15(6):521-549。
利特伍德高等教育学院(1949 年)。数学的万能钥匙:复杂代数理论的简单叙述。哈钦森&有限公司。
卢卡斯,是的。(1891 年)。数学娱乐第 1 卷,第二版。gau thier-villers 和 son,打印机-图书管理员。http://archive.org
线上可用。
卢卡斯,是的。(1896 年)。数学娱乐第 2 卷,第二版。gau thier-villers 和 son,打印机-图书管理员。http://archive.org
线上可用。
俄勒冈州,1959 年。迷宫之旅。数学老师,52:367-370。
塔尔詹河(1972 年)。深度优先搜索和线性图算法。 SIAM 计算学报,1(2): 146-160。
1 我从达德利·欧内斯特·利特伍德的数学万能钥匙中“偷”出了这一章的副标题。
如果你不是游戏玩家,请随意把这里想象成你的办公大楼、梦想家园或任何你喜欢的地方。
3 我将在下面使用带有邻接集的字典作为默认表示,尽管许多算法也可以很好地与第二章中的其他表示一起工作。通常,重写一个算法来使用不同的表示也不会太难。
4 这是本章所有遍历算法的运行时间,除了(有时)IDDFS。
5 嘿,连牛顿和苹果的故事都是杜撰的。
6 从 a 开始追溯你的行程,你应该以节点顺序 a 、 b 、 c 、 d 、 e 、 f 、 g 、 h 、 d 、 c 结束 c , b , l , b , a 。
当然,如果你真的面对现实生活中的迷宫,这个递归版本会更难使用。
就这样,一个洞穴探险者可以变成洞穴人。
9 人们在野外漫步时似乎也最终会绕圈子。美国陆军的研究表明,出于某种原因,人们更喜欢去南方(只要他们有自己的方向)。当然,如果您的目标是完全遍历,这两种策略都不是特别有用。
10 我的翻译。
即使你的靴子没有沾上泥,你也可以进行同样的程序。只要确保清楚地标记入口和出口(比如用粉笔)。在这种情况下,当你来到一个旧的十字路口时,做两个标记并立即开始原路返回是很重要的。
12 事实上,在某些上下文中,术语回溯被用作递归遍历,或者深度优先搜索的同义词。
13dfs_topsort
函数也可用于通过减少完成时间来对一般图的节点进行排序,这在寻找强连通组件时是需要的,这将在本章稍后讨论。
14 换句话说,让我们进行归纳思考。
15 添加那种标记当然是可能的,并且是一种修剪的形式,这将在本章后面讨论。
另一方面,我们将从一个节点跳到另一个节点,这在现实生活的迷宫中是不可能实现的。
17 要想有任何内存储蓄,你就得去掉S
设置。因为您将遍历一棵树,这不会引起任何麻烦(即遍历循环)。
18 这看起来像是作弊,因为我在非 DAG 上使用拓扑排序。这个想法只是通过减少完成时间来对节点进行排序,这正是dfs_topsort
在线性时间中所做的。
19 其实,walk
会为每个强分量返回一个遍历树。****
六、分裂、结合和征服
分而治之,一个健全的座右铭;
团结带领,更好的自己。
——约翰·沃尔夫冈·冯·歌德,诗
这一章是三章中的第一章,讲述众所周知的设计策略。本章讨论的策略,分而治之(或简称为 D & C),是基于以一种提高性能的方式分解你的问题。您划分问题实例,递归地解决子问题,组合结果,从而征服问题——这种模式反映在章节标题中。?? 1
树形问题:关于平衡的一切
我之前提到过子问题图的概念:我们将子问题视为节点,将依赖关系(或归约)视为边。这种子问题图的最简单结构是一棵树。每个子问题可能依赖于一个或多个其他问题,但是我们可以独立地解决这些其他子问题。(当我们去除这种独立性时,我们最终会遇到第八章中提到的那种重叠和纠缠。)这种直接的结构意味着,只要我们能找到适当的约简,就可以直接实现我们算法的递归公式。
你已经有了理解分治算法所需的所有拼图。我已经讨论过的三个想法涵盖了要点:
- 分治循环,在第三章
- 强感应,在第四章
- 递归遍历,在第五章
递归告诉您一些关于所涉及的性能的信息,归纳为您提供了理解算法如何工作的工具,递归遍历(树中的 DFS)是算法的原始框架。
直接实现归纳步骤的递归公式并不新鲜。例如,在第四章中,我向你展示了一些简单的排序算法是如何实现的。在分而治之的设计方法中,一个重要的增加是平衡。这就是强归纳的用武之地:我们不想递归地实现从 n -1 到 n 的步骤,而是想从 n /2 到 n 。也就是说,我们采用大小为 n /2 的解决方案,并构建大小为 n 的解决方案。不是(归纳地)假设我们可以解决尺寸为 n -1 的子问题,而是假设我们可以处理尺寸小于 n 的所有子问题。
你会问,这和平衡有什么关系?想想弱诱导的例子。我们基本上将问题分成两部分:一部分大小为 n -1,另一部分大小为 1。假设归纳步骤的成本是线性的(这种情况很常见)。那么这就给了我们递归T(n)=T(n-1)+T(1)+n。这两个递归调用非常不平衡,我们基本上以握手循环结束,结果运行时间是二次的。如果我们设法在两个递归调用中更均匀地分配工作会怎么样?也就是说,我们能把问题简化成两个大小相似的子问题吗?在这种情况下,递归变为T(n)= 2T(n/2)+n。这也应该很熟悉:这是典型的分治递归,它产生一个对数线性(θ(nLGn))运行时间——一个巨大的改进。
图 6-1 和 6-2 以递归树的形式展示了这两种方法的区别。注意,节点的数量是相同的——主要的影响来自于工作在这些节点上的分布。这看起来像是魔术师的把戏;工作去哪了?重要的认识是,对于简单的非平衡逐步方法(图 6-1 ,许多节点被分配了高工作负荷,而对于平衡的分治方法(图 6-2 ),大多数节点只有很少的工作要做。例如,在非平衡递归中,总会有大约四分之一的调用的成本至少为 n /2,而在平衡递归中,无论 n 的值是多少,都只有三个*。这是一个非常显著的差异。*
图 6-1 。非平衡分解,具有线性除法/组合成本和二次运行时间总计
图 6-2 。分而治之:一种平衡的分解,具有线性的划分/组合成本和总的对数线性运行时间
让我们试着在实际问题中认识这种模式。天际线问题 2 就是一个相当简单的例子。给你一个三元组的排序序列( L , H , R ),其中 L 是建筑物的左侧x-坐标, H 是其高度, R 是其右侧x-坐标。换句话说,从一个给定的有利位置看,每一个三元组代表一个建筑的(矩形)轮廓。你的任务是从这些单独的建筑轮廓构建一个天际线。
图 6-3 和 6-4 说明了这个问题。在图 6-4 中,一座建筑正被添加到现有的天际线上。如果天际线被存储为指示水平线段的三元组列表,则可以通过以下方式在线性时间内添加新建筑物:( 1)在天际线序列中寻找建筑物的左侧坐标,( 2)提升所有低于该建筑物的坐标,直到(3)找到建筑物的右侧坐标。如果新建筑的左右坐标在一些水平线段的中间,那么它们需要被一分为二。为简单起见,我们可以假设从覆盖整个天际线的零高度线段开始。
图 6-3 。一组建筑轮廓和由此产生的天际线
图 6-4 。将建筑物(虚线)添加到天际线(实线)
这种合并的细节在这里并不那么重要。重点是我们可以在线性时间内给天际线增加一个建筑。使用简单(弱)归纳,我们现在有了我们的算法:我们从一个单一的建筑开始,并不断增加新的建筑,直到我们完成。当然,这个算法的运行时间是二次的。为了改善这一点,我们想改用强诱导——分而治之。我们可以通过注意到合并两个天际线并不比合并一个建筑和一个天际线更困难来做到这一点:我们只是以“锁步”的方式遍历两个天际线,只要一个比另一个值高,我们就使用最大值,在需要的地方分割水平线段。利用这种洞察力,我们有了第二个改进的算法:为所有建筑创建天际线,首先(递归地)基于一半的建筑创建两条天际线,然后将它们合并。这个算法,我相信你可以看到,有一个对数线性运行时间。练习 6-1 要求你实际实现这个算法。
标准 D&C 算法
上一节提到的递归 skyline 算法举例说明了分治算法的典型工作方式。输入是一组(也许是一个序列)元素;在至多线性时间内,将元素划分成大小大致相等的两组,在每一半上递归运行算法,并且也在至多线性时间内组合结果。当然可以修改这种标准形式(在下一节中您将看到一个重要的变化),但是这种模式包含了核心思想。
清单 6-1 勾画了一个通用的分治功能。您可能会为每个算法实现一个定制版本,而不是使用这样的通用函数,但是它确实说明了这些算法是如何工作的。我在这里假设在基本情况下简单地返回S
是可以的;当然,这取决于combine
函数如何工作。 3
清单 6-1 。分治方案的一般实现
def divide_and_conquer(S, divide, combine):
if len(S) == 1: return S
L, R = divide(S)
A = divide_and_conquer(L, divide, combine)
B = divide_and_conquer(R, divide, combine)
return combine(A, B)
图 6-5 是同一模式的另一个例子。图的上半部分表示递归调用,而下半部分表示返回值的组合方式。一些算法(如快速排序,在本章后面描述)在的上半部分完成大部分工作(除法),而一些算法在的下半部分(组合)更加活跃。关注组合的算法中最著名的例子可能是合并排序(在本章的后面会有描述),它也是分治算法的一个典型例子。
图 6-5 。分治算法中的划分、递归和组合
对半搜索
在研究更多符合通用模式的例子之前,让我们看一个与相关的模式,它丢弃了一个递归调用。你已经在我之前提到的二分搜索法(二分法)中看到了这一点:它将问题分成相等的两半,然后只在这两半的一个上重现。这里的核心原则还是平衡。考虑一下在完全不平衡的搜索中会发生什么。如果你还记得《??》第三章中的“想一个粒子”游戏,不平衡解就相当于问“这是你的粒子吗?”对于宇宙中的每一个粒子。不同之处仍然包含在图 6-1 和图 6-2 中,只是每个节点中的工作(对于这个问题)是不变的,我们实际上只是沿着从根到叶的路径执行工作。
二分搜索法似乎并不那么有趣。当然,这很有效率,但是搜索一个有序的序列...这难道不是一个有限的应用领域吗?嗯,不,不是真的。首先,该操作本身作为其他算法的一个组成部分可能很重要。其次,也许同样重要的是,二分搜索法可以成为寻找事物的一种更普遍的方法。例如,这种想法可以用于数值优化,如牛顿法,或在调试你的代码。尽管手动进行“二分法调试”可能足够有效(“代码在到达这个print
语句之前崩溃了吗?”),在一些修订控制系统(RCS)中也有使用,比如 Mercurial 和 Git。
它是这样工作的:你使用一个 RCS 来跟踪你代码中的变化。它存储了许多不同的版本,可以说你可以“回到过去”,随时检查旧代码。现在,假设你遇到了一个新的 bug,你很想找到它,这是可以理解的。你的 RCS 能帮上什么忙?首先,您为您的测试套件编写一个测试——如果有 bug,它会检测出来。(调试时,这总是一个很好的第一步。)您确保设置了测试,以便 RCS 可以访问它。然后,您要求 RCS 在您的历史记录中查找错误出现的位置。它是怎么做到的?大惊喜:二分搜索法。假设您知道该错误出现在修订版 349 和 574 之间。RCS 将首先将您的代码恢复到修订版 461(在两者之间)并运行您的测试。窃丨听丨器在吗?如果是这样,你知道它出现在 349 年到 461 年之间。如果不是,出现在 462 年到 574 年之间。起泡,冲洗,重复。
这不仅仅是二分法用途的一个简单例子;它还很好地说明了其他几点。首先,它表明您不能总是使用已知算法的常规实现,即使您并没有真正修改它们。在这种情况下,RCS 背后的实现者很可能必须自己实现二分搜索法。其次,这是一个很好的例子,说明减少基本操作的数量可能是至关重要的——比高效地实现事情更重要。编译您的代码和运行测试套件无论如何都很慢,所以您希望尽可能少地这样做。
黑盒:平分
二分搜索法可以应用在许多设置中,但是在标准库中的bisect
模块中有直接的“在排序序列中搜索值”版本。它包含了bisect
函数,该函数按预期工作:
>>> from bisect import bisect
>>> a = [0, 2, 3, 5, 6, 8, 8, 9]
>>> bisect(a, 5)
嗯,这有点像你所期待的...它不会返回已经存在的 5 的位置。相反,它报告插入新的 5 的位置,确保它被放置在具有相同值的所有现有项目的之后。事实上,bisect
是bisect_right
的别称,还有一个bisect_left
:
>>> from bisect import bisect_left
>>> bisect_left(a, 5)
为了提高速度,bisect
模块是用 C 实现的,但在早期版本(Python 2.4 之前)中,它实际上是一个普通的 Python 模块,而bisect_right
的代码如下(加上我的注释):
def bisect_right(a, x, lo=0, hi=None):
if hi is None: # Searching to the end
hi = len(a)
while lo < hi: # More than one possibility
mid = (lo+hi)//2 # Bisect (find midpoint)
if x < a[mid]: hi = mid # Value < middle? Go left
else: lo = mid+1 # Otherwise: go right
return lo
如您所见,实现是迭代的,但它完全等同于递归版本。
在这个模块中还有另外一对有用的函数:insort
(?? 的别名)和insort_left
。这些函数找到正确的位置,就像它们的bisect
对应函数一样,然后实际插入元素。虽然插入仍然是线性操作,但至少搜索是对数的(并且实际的插入代码实现得相当高效)。
遗憾的是,bisect
库的各种函数不支持key
参数,例如在list.sort
中使用的参数。您可以使用所谓的装饰、排序、取消装饰(或者,在本例中,装饰、搜索、取消装饰)模式,或者简称为 DSU,来实现类似的功能:
>>> seq = "I aim to misbehave".split()
>>> dec = sorted((len(x), x) for x in seq)
>>> keys = [k for (k, v) in dec]
>>> vals = [v for (k, v) in dec]
>>> vals[bisect_left(keys, 3)]
或者,你可以做得更简洁:
>>> seq = "I aim to misbehave".split()
>>> dec = sorted((len(x), x) for x in seq)
>>> dec[bisect_left(dec, (3, ""))][1]
如您所见,这涉及到创建一个新的修饰列表,这是一个线性操作。显然,如果我们在每次搜索之前都这样做,那么使用bisect
就没有意义了。但是,如果我们可以在搜索之间保留修饰列表,那么这个模式可能会有用。如果序列一开始就没有排序,我们可以像前面的例子一样,将 DSU 作为排序的一部分。
遍历搜索树...带修剪
二分搜索法是最棒的。这是最简单的算法之一,但它真的很强大。不过,有一个问题:要使用它,必须对值进行排序。现在,如果我们能把它们保存在一个链表中,那就不成问题了。对于我们想要插入的任何对象,我们只需用二分法(对数)找到位置,然后插入它(常数)。问题是——那行不通。二分搜索法需要能够在常数时间内检查中间值,这是我们用链表做不到的。当然,使用数组(比如 Python 的列表)也无济于事。这有助于分割,但会破坏插入。
如果我们想要一个对搜索有效的可修改的结构,我们需要某种中间地带。我们需要一个类似于链表的结构(这样我们就可以在常量时间内插入元素),但仍然允许我们执行二分搜索法。根据这一节的标题,你可能已经想通了整件事,但是请耐心听我说。我们在搜索时首先需要的是在常量时间内访问中间项。所以,假设我们保持一个直接的链接。从那里,我们可以向左或向右,我们需要访问左半部分或右半部分的中间元素。因此...我们可以只保留从第一项到这两项的直接链接,一个“左”引用和一个“右”引用。
换句话说,我们可以将二分搜索法的结构表示为一个显式的树形结构!这样的树很容易修改,我们可以在对数时间内从根到叶遍历它。因此,搜索实际上是我们的老朋友遍历——但是有修剪。我们不想遍历整个树(导致所谓的线性扫描)。除非我们是从有序的值序列中构建树,否则“左半部分的中间元素”这一术语可能并不那么有用。相反,我们可以考虑我们需要什么来实现我们的修剪。当我们查看根时,我们需要能够修剪其中一个子树。(如果我们在一个内部节点中找到了我们想要的值,并且该树不包含重复的值,我们当然不会继续在或者子树中继续。)
我们需要的一样东西就是所谓的搜索树属性:对于一个根在 r 的子树,左边子树中的所有值都是小于*(或等于)r* 的值,而右边子树中的值都是大于*。换句话说,子树根处的值将子树一分为二。具有该属性的示例树如图 6-6 所示,其中节点标签表示我们正在搜索的值。像这样的树结构在实现集合时会很有用;也就是说,我们可以检查给定的值是否存在。然而,为了实现一个映射*,每个节点都将包含一个我们所寻找的键和一个我们想要的值。
图 6-6 。一个(完美平衡的)二叉查找树,突出显示了 11 的搜索路径
通常,你不会批量构建一个树(尽管有时这很有用);使用树的主要动机是它们是动态的,您可以一个接一个地添加节点。要添加一个节点,你需要搜索它应该在哪里,然后在那里添加一个新的叶子。例如,图 6-6 中的树可能是通过最初添加 8,然后添加 12、14、4 和 6 而构建的。不同的排序可能会得到不同的树。
清单 6-2 给出了一个二叉查找树的简单实现,以及一个包装器,让它看起来有点像字典。你可以这样使用它,例如:
>>> tree = Tree()
>>> tree["a"] = 42
>>> tree["a"]
42
>>> "b" in tree
False
如您所见,我已经将插入和搜索实现为独立的函数,而不是方法。这样它们也可以在None
节点上工作。(当然不一定要那样做。)
清单 6-2 。插入并在二叉查找树中搜索
class Node:
lft = None
rgt = None
def __init__(self, key, val):
self.key = key
self.val = val
def insert(node, key, val):
if node is None: return Node(key, val) # Empty leaf: add node here
if node.key == key: node.val = val # Found key: replace val
elif key < node.key: # Less than the key?
node.lft = insert(node.lft, key, val) # Go left
else: # Otherwise...
node.rgt = insert(node.rgt, key, val) # Go right
return node
def search(node, key):
if node is None: raise KeyError # Empty leaf: it's not here
if node.key == key: return node.val # Found key: return val
elif key < node.key: # Less than the key?
return search(node.lft, key) # Go left
else: # Otherwise...
return search(node.rgt, key) # Go right
class Tree: # Simple wrapper
root = None
def __setitem__(self, key, val):
self.root = insert(self.root, key, val)
def __getitem__(self, key):
return search(self.root, key)
def __contains__(self, key):
try: search(self.root, key)
except KeyError: return False
return True
注意清单 6-2 中的实现不允许树包含重复的键。如果使用现有键插入新值,旧值将被覆盖。这很容易改变,因为树结构本身并不排除重复。
排序数组、树和字典:选择、选择
二分法(在排序数组上)、二分搜索法树和 dicts(也就是散列表)都实现了相同的基本功能:它们让您可以高效地搜索。尽管如此,还是有一些重要的区别。二分法速度很快,开销很小,但是只适用于排序数组(比如 Python 列表)。并且排序后的数组很难维护;添加元素需要线性时间。搜索树的开销更大,但它是动态的,允许您插入和删除元素。然而,在许多情况下,散列表以dict
的形式成为了明显的赢家。它的平均渐近运行时间是常数(与二分法和搜索树的对数运行时间相反),与实际接近,开销很小。
散列要求你能够为你的对象计算一个散列值。在实践中,你几乎总是可以这样做,但在理论上,二分法和搜索树在这里更灵活一些——它们只需要比较对象,并找出哪个更小。 4 这种对排序的关注也意味着搜索树将允许你以排序的顺序访问你的值——要么全部,要么只是一部分。树也可以扩展到多维工作(搜索超矩形区域内的点),或者扩展到更奇怪的搜索标准形式,其中散列可能很难实现。还有更常见的情况,散列法不能立即适用。例如,如果您想要最接近您的查找关键字的条目,那么搜索树将是一个不错的选择。
选择
我将用一个你在实践中可能不会经常用到的算法来结束这一节的“对半搜索”,但这将二分法的思想引向了一个有趣的方向。此外,它为快速排序(下一节)设置了阶段,这是经典之一。
问题是在线性时间中,找到无序序列中的第 k 个最大数。最重要的情况可能是找到中间值——如果序列被排序,那么将会是位于中间位置的(即(n+1)//2
)的元素。有趣的是,作为该算法如何工作的副作用,它还允许我们识别哪些对象比我们寻找的对象小。这意味着我们将能够找到运行时间为θ(n)的最小的 k (同时也是最大的 n - k )元素,这意味着 k 的值无关紧要!
这可能比乍看起来更奇怪。运行时间限制排除了排序(除非我们可以计数出现次数并使用计数排序,如第四章中所讨论的)。任何其他明显的算法寻找 k 最小的对象将使用一些数据结构来跟踪它们。例如,您可以使用一种类似于插入排序的方法:在序列的开头或者在一个单独的序列中保留到目前为止找到的最小的对象。
如果你跟踪其中哪个最大,检查主序列中的每个大的对象会很快(只是一个常量时间检查)。但是,如果你需要添加一个对象,并且你已经有了 k ,你就必须删除一个。当然,你会去掉最大的,但是你必须找出哪一个现在是最大的。你可以对它们进行排序(也就是说,接近插入排序),但是运行时间无论如何都是θ(NK)。
从这个(渐进地)上一步将是使用一个堆,本质上将我们的“部分插入排序”转换为“部分堆排序”,确保堆中的元素永远不会超过 k 个。(有关更多信息,请参见关于二进制堆的“黑盒”侧栏、heapq
和 heapsort。)这会给你一个运行时间θ(nLGk),对于一个相当小的 k ,这几乎与θ(n)相同,并且它让你迭代主序列而不用在内存中跳跃,所以实际上它可能是选择的解决方案。
提示如果你在 Python 中寻找 iterable 中的 k 最小(或最大)的对象,如果你的 k 相对于对象总数来说很小,你可能会使用
heapq
模块中的nsmallest
(或nlargest
)函数。如果 k 很大,你应该对序列进行排序(或者使用sort
方法或者使用sorted
函数)并挑选出第 k 个对象。对您的结果进行计时,看看什么效果最好——或者只选择能让您的代码尽可能清晰的版本。
那么,我们如何才能采取下一步,渐进地,完全消除对 k 的依赖呢?事实证明,保证线性最坏情况有点棘手,所以让我们把重点放在平均情况上。现在,如果我告诉你尝试应用分而治之的想法,你会怎么做?第一个线索可能是我们的目标是一个线性运行时间;什么样的“除以二”循环会这样做?就是单次递归调用的那种(相当于淘汰赛总和):T(n)=T(n/2)+n。换句话说,我们通过执行线性工作将问题分成两半(或者,现在,平均分成两半),就像更规范的分治法一样,但我们设法消除了一半,使我们更接近二分搜索法。为了设计这个算法,我们需要弄清楚的是,如何在线性时间内划分数据,以便我们最终将所有对象分成两半。
和往常一样,系统地浏览我们所掌握的工具,并尽可能清晰地描述问题,会让我们更容易找到解决方案。我们已经到达了这样一个点,我们需要将一个序列分成两半,一个由小的值组成,另一个由大的值组成。我们不需要保证这一半是相等的——只需要保证它们平均起来是相等的。一个简单的方法是选择其中一个值作为所谓的枢轴,并用它来划分其他值:所有比枢轴小的都在左半部分结束,而那些比枢轴大的在右半部分结束。清单 6-3 给出了分区和选择的一种可能实现。注意,这个版本的分区主要是可读的;练习 6-11 让你看看你是否能去掉一些开销。这里写道 select,它返回第 k 个最小的元素;如果您想拥有所有的 k 最小元素,您可以简单地重写它以返回lo
而不是pi
。
清单 6-3 。分区和选择的简单实现
def partition(seq):
pi, seq = seq[0], seq[1:] # Pick and remove the pivot
lo = [x for x in seq if x <= pi] # All the small elements
hi = [x for x in seq if x > pi] # All the large ones
return lo, pi, hi # pi is "in the right place"
def select(seq, k):
lo, pi, hi = partition(seq) # [<= pi], pi, [>pi]
m = len(lo)
if m == k: return pi # We found the kth smallest
elif m < k: # Too far to the left
return select(hi, k-m-1) # Remember to adjust k
else: # Too far to the right
return select(lo, k) # Just use original k here
线性时间选择,保证!
本节实现的选择算法被称为随机选择(尽管随机版本通常比这里更随机地选择枢轴;参见练习 6-13)。它允许您在线性的预期的时间内进行选择(例如,找到中间值),但是如果在每一步中枢选择都很糟糕,那么您最终会遇到握手循环(线性工作,但是大小只减少 1),从而导致二次运行时间。虽然这种极端的结果在实践中不太可能发生(尽管,再次参见练习 6-13),但你事实上也可以在最坏的情况下避免它。
事实证明,保证支点在序列中只占很小的百分比(也就是说,不在任何一端,或者距离它恒定的步数)就足以保证运行时间是线性的。1973 年,一群算法专家(Blum、Floyd、Pratt、Rivest 和 Tarjan)提出了一个版本的算法,给出了这种保证。
算法有点复杂,但核心思想足够简单:首先将序列分成五个一组,或者其他一些小常数。例如,使用简单的排序算法,找出每个中值。到目前为止,我们只使用了线性时间。现在,递归地使用线性选择算法,在这些中间值中找到中间值*。这是可行的,因为中间值的数量小于原始序列的大小——这仍然有点令人费解。得到的值是一个保证足够好以避免退化递归的枢轴—在您的选择中使用它作为枢轴。*
换句话说,该算法以两种方式递归使用:第一,在中间值序列上,找到一个好的枢轴,第二,在原始序列上,使用这个枢轴。
由于理论上的原因,了解这种算法是很重要的,因为它意味着选择可以在有保证的线性时间内完成,但你可能永远不会在实践中使用它。
对半排序
最后,我们到达了与分治策略最相关的主题:排序。我不打算深入研究这个问题,因为 Python 已经有了有史以来最好的排序算法之一(参见本节后面关于 timsort 的“黑盒”侧栏),并且它的实现非常高效。事实上,list.sort
是如此的高效,你可能会认为它是替代其他渐近线稍微好一点的算法的第一选择(例如,对于选择)。尽管如此,本节中的排序算法是最著名的算法之一,所以您应该了解它们是如何工作的。此外,它们是分而治之用于设计算法的一个很好的例子。
我们先来考虑算法设计的名人之一:C. A. R. Hoare 的快速排序。它与上一节的选择算法密切相关,这也是由于 Hoare(有时也被称为快速选择)。扩展很简单:如果 quickselect 表示带有修剪的遍历——在递归树中找到一条向下到第 k 个最小元素的路径——那么 quicksort 表示完全遍历,这意味着每 k 找到一个的解决方案。哪个是最小的元素?第二小?诸如此类。通过将它们都放入它们的位置,序列被排序。清单 6-4 显示了快速排序的一个版本。
清单 6-4 。快速排序
def quicksort(seq):
if len(seq) <= 1: return seq # Base case
lo, pi, hi = partition(seq) # pi is in its place
return quicksort(lo) + [pi] + quicksort(hi) # Sort lo and hi separately
正如你所看到的,算法很简单,只要你有分区。(练习 6-11 和 6-12 要求你重写快速排序和分区,以产生一个就地排序算法。)首先,它将序列分成我们知道必须在pi
左边的序列和必须在右边的序列。然后这两半被递归排序(通过归纳假设是正确的)。将两部分连接起来,枢轴在中间,保证会产生一个排序的序列。因为我们不能保证分区会适当地平衡递归,我们只知道快速排序在平均 ?? 的情况下是对数线性的——在最坏的情况下是二次的。 6
Quicksort 是分而治之算法的一个例子,它在递归调用的之前的做它的主要工作,在中分割它的数据(使用分区)。组合部分比较琐碎。不过,我们可以反过来做:简单地将我们的数据一分为二,保证一个平衡的递归(和一个不错的最坏情况运行时间),然后努力合并,或者说合并结果。这正是合并排序所做的。就像我们从本章开始的天际线算法从插入单个建筑到合并两个天际线,合并排序从在排序序列中插入单个元素(插入排序)到合并两个排序序列。
你已经在第三章 ( 清单 3-2 )中看到了合并排序的代码,但是我在这里再重复一遍,加上一些注释(清单 6-5 )。
清单 6-5 。合并排序
def mergesort(seq):
mid = len(seq)//2 # Midpoint for division
lft, rgt = seq[:mid], seq[mid:]
if len(lft) > 1: lft = mergesort(lft) # Sort by halves
if len(rgt) > 1: rgt = mergesort(rgt)
res = []
while lft and rgt: # Neither half is empty
if lft[-1] >=rgt[-1]: # lft has greatest last value
res.append(lft.pop()) # Append it
else: # rgt has greatest last value
res.append(rgt.pop()) # Append it
res.reverse() # Result is backward
return (lft or rgt) + res # Also add the remainder
理解这是如何工作的现在应该比在第三章中更容易一点。注意,编写合并部分是为了说明这里发生了什么。如果您在 Python 中实际使用合并排序(或类似的算法),您可能会使用heapq.merge
来进行合并。
黑盒:TIMSORT
隐藏在list.sort
中的算法是由 Tim Peters 发明(并实现)的,他是 Python 社区中的知名人士之一。 7 该算法被恰当地命名为 timsort ,取代了早期的算法,该算法进行了大量调整以处理特殊情况,如升序和降序值段等。在 timsort 中,这些情况由通用机制处理,因此性能仍然存在(在某些情况下,性能有了很大提高),但算法更干净、更简单。算法还是有点太复杂,这里就不详细解释了;我会试着给你一个快速的概述。更多详情,请看出处。 8
Timsort 是归并排序的近亲。这是一个就地算法,因为它合并段并将结果留在原始数组中(尽管在合并期间它使用了一些辅助内存)。然而,它不是简单地将数组对半排序,然后合并它们,而是从头开始,寻找已经排序的段*(可能相反),称为运行。在随机数组中,不会有很多,但在许多种真实数据中,可能会有很多——这使算法明显优于普通合并排序和最好情况下的线性运行时间(这涵盖了除了简单获得已经排序的序列之外的许多情况)。*
*当 timsort 遍历序列,识别游程并将它们的边界推送到堆栈上时,它使用一些启发式方法来决定何时合并哪些游程。这种想法是为了避免合并不平衡,这种不平衡会给你一个二次运行时间,同时仍然利用数据中的结构(即运行)。首先,任何真正短的游程都被人为地扩展和排序(使用稳定的插入排序)。第二,为栈上最顶端的三个游程维护以下不变量:A
、B
和C
(其中A
在顶端):len(A) > len(B) + len(C)
和len(B) > len(C)
。如果违反了第一个不变量,则将A
和C
中较小的一个与B
合并,结果替换堆栈中合并的游程。第二个不变量可能仍然不成立,并且合并继续,直到两个不变量都成立。
该算法还使用了一些其他技巧,以获得尽可能快的速度。如果你感兴趣的话,我建议你查看一下来源。如果你不想读 C 代码,你也可以看看 timsort 的纯 Python 版本,它是 PyPy 项目的一部分。 10 他们的实现有极好的注释,写得很清楚。(PyPy 项目在附录 A 中讨论。)
我们排序能有多快?
关于排序的一个重要结果是,合并排序等分治算法是最优;对于任意值(我们可以计算出哪个更大),在最坏的情况下,不可能比ω(nLGn)做得更好。一个重要的例子是当我们对任意实数进行排序时。 11
注计数排序及其亲属(在第四章中讨论)似乎打破了这一规则。请注意,我们不能对任意值进行排序——我们需要能够计算出现的次数,这意味着对象必须是可散列的,并且我们需要能够在线性时间内对值范围进行迭代。
我们是怎么知道的?道理其实挺简单的。第一个观点:因为值是任意的,我们假设我们只能计算出其中一个是否大于另一个,所以每个对象的比较都可以归结为是/否的问题。第二个洞见: n 元素的排序数为 n !我们要找的正是其中之一。那会给我们带来什么?我们又回到了“想象一个粒子”,或者,在这种情况下,“想象一个排列。”这意味着我们最多只能使用ω(LGn!)是/否问题(比较),以获得正确的排列(即,对数字进行排序)。而且刚好 lg n !渐近等价于 n lg n 。 12 换句话说,最坏情况下的运行时间是ω(LGn!)=ω(nLGn)。
你说,我们如何达到这种等价?最简单的方法就是只使用斯特林近似 ,它表示 n !就是θ(n??n??)。取对数,鲍勃就是你的叔叔。 13 现在,我们推导出最坏情况的界限;使用信息论(我不会在这里深入讨论),事实上,有可能表明这个界限在平均情况下也成立。换句话说,在一个非常真实的意义上*,*除非我们对数据的取值范围或分布有实质性的了解,否则对数线性是我们能做的最好的事情。
还有三个例子
在用稍微高级(可选)的部分结束本章之前,这里有三个例子。前两个涉及计算几何(分治策略经常有用),而最后一个是一个相对简单的数列问题(有一些有趣的变化)。我只是勾画了解决方案,因为重点主要是为了说明设计原则。
最接近对
问题:你在平面上有一组点,你想找到彼此最接近的两个点。第一个浮现在脑海中的想法可能是使用蛮力:对于每一个点,检查所有其他的点,或者至少是我们还没有看到的点。当然,根据握手和,这是一个二次算法。通过分而治之,我们可以得到对数线性。
这是一个相当有趣的问题,所以如果你喜欢解谜,在阅读我的解释之前,你可能想试着自己解决它。您应该使用分而治之(并且得到的算法是对数线性的)的事实是一个强烈的暗示,但是解决方案决不是显而易见的。
该算法的结构几乎直接遵循(类似合并排序的)对数线性分治模式:我们将把点分成两个子集,递归地找到每个子集中最近的一对,然后在线性时间内合并结果。借助归纳/递归(和分治模式)的力量,我们现在已经将问题简化为这种合并操作。但是在发挥我们的创造力之前,我们可以再剥离一点:合并的结果必须是(1)左边最近的一对,(2)右边最近的一对,或者(3)两边各有一个点组成的一对。换句话说,我们需要做的是找到“跨越”分割线的最接近的一对。在这样做的时候,我们也有一个涉及到的距离的上限(从左侧和右侧最接近的对的最小值)。
深入问题的本质后,让我们看看事情会变得多糟糕。让我们假设,目前,我们已经按照它们的 y 坐标对中间区域(宽度为 2 d )中的所有点进行了排序。然后我们想按顺序浏览它们,考虑其他点,看看我们是否能找到比 d (目前发现的最小距离)更近的点。对于每一点,我们必须考虑多少其他的“邻居”?
这就是解决方案的关键之处:在中线的任一侧,我们知道所有点至少相距 d 的距离。因为我们要寻找的是一对在相距最的距离,横跨中线,我们需要考虑的只是在任何时候高度 d (和宽度 2 d )的垂直切片。这个区域能容纳多少个点?
图 6-7 说明了这种情况。我们对左右之间的距离没有下限,所以在最坏的情况下,我们可能在中间线上有重合点(突出显示)。除此之外,很容易证明,在一个 d × d 正方形内最多可以容纳四个最小距离为 d 的点,我们在正方形的两边都有;参见练习 6-15。这意味着在这样的切片中,我们总共最多需要考虑八个点,这意味着我们当前的点最多需要与其下七个邻居进行比较。(其实考虑一下五下邻居就够了;参见练习 6-16。)
图 6-7 。最坏的情况:在中间区域的垂直切片中有八个点。切片的大小为 d×2d,两个中间点(高亮显示)代表一对重合点
我们快完成了;唯一剩下的问题是按照 x -和y-坐标排序。我们需要 x 排序能够在每一步将问题分成两半,我们需要 y 排序在合并时进行线性遍历。我们可以保留两个数组,每个数组对应一个排序顺序。我们将在 x 数组上做递归除法,所以这很简单。对 y 的处理不是很直接,但仍然很简单:当用 x 划分数据集时,我们基于 x 坐标划分 y 数组。当组合数据时,我们合并它们,就像在合并排序中一样,从而在只使用线性时间的同时保持排序。
注意为了让算法工作,我们从每个递归调用中返回点的整个子集,排序。必须在副本上过滤离中线太远的点。
你可以把这看作是加强归纳假设的一种方式(正如在第四章第一节中所讨论的),以获得期望的运行时间:我们不仅仅假设我们可以在更小的点集中找到最近的点,我们还假设我们可以把点重新排序*。*
*凸包
这里还有另一个几何问题:想象一下把 n 个钉子钉在一块木板上,然后用橡皮筋捆住它们;橡皮筋的形状是钉子代表的点的所谓凸包。它是包含这些点的最小凸 14 区域,即在这些点的“最外层”之间有线的凸多边形。参见图 6-8 中的示例。
图 6-8 。点集及其凸包
到目前为止,我肯定你在怀疑我们将如何解决这个问题:沿着 x 轴将点集分成相等的两半,并递归地求解它们。唯一剩下的部分是两个解的线性时间组合。图 6-9 提示我们需要什么:我们必须找到上下公切线。(它们是切线基本上意味着它们与前面和后面的线段形成的角度应该向内弯曲。)
图 6-9 。通过寻找上下公切线来组合两个较小的凸包(虚线)
在不涉及实现细节的情况下,假设您可以检查一条线是否是任一半的上切线。(下半部分的工作方式类似。)然后你可以从左半的最右边的点和右半的最左边的点开始。只要您的点之间的线不是左边部分的上切线,您就沿着子壳逆时针移动到下一个点。然后你对右半边做同样的动作。您可能需要多次这样做。顶部固定后,对下部切线重复该过程。最后,删除切线之间的线段,就完成了。
多快能找到一个凸包?
各个击破的方案运行时间为 O ( n lg n )。寻找凸包的算法有很多,有些渐进地更快,运行时间低至 O ( n lg h ),其中 h 是凸包上的点数。最糟糕的情况当然是所有物体都落在船体上,我们又回到θ(nLGn)。事实上,在最坏的情况下,这可能是最好的时机——但是我们怎么知道呢?
我们可以使用第四章中的想法,通过减少来显示硬度。从本章前面的讨论中我们已经知道,在最坏的情况下,对实数进行排序是ω(nLGn)。这与您使用的算法无关;你简直不能做得更好。这不可能。
现在,观察排序可以简化为凸包问题。如果你想对 n 个实数进行排序,你只需将这些数作为坐标 x ,并添加坐标 y ,使它们位于一条平缓的曲线上。例如,你可以有 y = x 2 。如果你为这个点集找到一个凸包,那么这些值将按照排序的顺序排列在凸包上,你可以通过遍历它的边来找到排序。这种减少本身只需要线性时间。
想象一下,你有一个比对数线性更好的凸包算法。通过使用线性归约,您随后会有一个比对数线性更好的排序算法。但那是不可能的!换句话说,因为存在从排序到寻找凸壳的简单(这里是线性)简化,所以后一个问题至少和前一个问题一样困难。因此...对数线性是我们能做的最好的。
最大切片
这里是最后一个例子:你有一个包含实数的序列A
,你想找到一个片(或段)A[i:j]
,以便sum(A[i:j])
被最大化。你不能只选择整个序列,因为其中也可能有负数。 15 这个问题有时会出现在股票交易的情境中——序列中包含了股票价格的变化,你想找到能给你带来最大利润的区间。当然,这个演示有点缺陷,因为它要求你事先知道股票的所有运动。
一个显而易见的解决方案如下所示(where n=len(A)
):
result = max((A[i:j] for i in range(n) for j in range(i+1,n+1)), key=sum)
生成器表达式中的两个for
子句简单地遍历每个合法的起点和终点,然后我们取最大值,使用A[i:j]
的总和作为标准(key
)。这个解决方案可能因其简洁而获得“聪明”的分数,但它并不真的那么聪明。这是一个很幼稚的蛮力解法,它的运行时间是立方(也就是θ(n3)!换句话说,是真的烂。
我们如何避免这两个显式的for
循环可能并不明显,但是让我们从避免隐藏在 sum 中的循环开始。一种方法是在一次迭代中考虑所有长度为 k 的区间,然后转移到 k +1,依此类推。这仍然会给我们一个二次方数量的间隔来检查,但是我们可以使用一个技巧来使扫描成本线性:我们正常地计算第一个间隔的总和,但是每次间隔被向右移动一个位置,我们简单地减去现在落在它之外的元素,并且我们添加新元素:
best = A[0]
for size in range(1,n+1):
cur = sum(A[:size])
for i in range(n-size):
cur += A[i+size] - A[i]
best = max(best, cur)
这也好不了多少,但至少现在我们减少到了运行时间的二次方。尽管如此,我们没有理由放弃这里。
让我们看看分而治之能给我们带来什么。当你知道要寻找什么时,算法——或者至少是一个粗略的轮廓——几乎是自己写出来的:将序列一分为二,在每一半中找到最大的切片(递归地),然后查看是否有更大的切片横跨中间(如最近点的例子)。换句话说,唯一需要创造性解决问题的是找到跨越中间的最大部分。我们可以进一步减少,该切片将必然包括从中间延伸到左侧的最大切片和从中间延伸到右侧的最大切片。我们可以在线性时间内,通过简单地从中间向任一方向遍历和求和,分别找到这些。
因此,我们有了这个问题的对数线性解。不过,在完全离开之前,我要指出这里的是,事实上,也是一个线性解;参见练习 6-18。
真正的分工:多重处理
分治设计方法的目的是平衡工作负载,使每个递归调用花费尽可能少的时间。不过,你可以更进一步,将工作分配给个独立的处理器(或内核)。如果您有大量的处理器可以使用,那么理论上,您可以做一些漂亮的事情,比如在对数时间内找到一个序列的最大值或和。(你看怎么样?)
在一个更现实的场景中,您可能没有无限的处理器供您使用,但是如果您想利用现有处理器的能力,multiprocessing
模块可以成为您的朋友。并行编程通常使用并行(操作系统)线程来完成。虽然 Python 有线程机制,但它不支持真正的并行执行。不过,你能做的是使用并行进程,这在现代操作系统中非常有效。multiprocessing
模块为您提供了一个接口,使处理并行进程看起来有点像线程。
树木平衡...以及平衡 16
如果我们将随机值插入二分搜索法树,平均来说,它将会非常平衡。然而,如果我们运气不好,我们可能最终得到一个完全不平衡的树,基本上是一个链表,就像图 6-1 中的那样。搜索树的大多数真实用途包括某种形式的平衡,即一组重新组织树的操作,以确保它是平衡的(当然,不破坏它的搜索树属性)。
有大量不同的树结构和平衡方法,但它们通常基于两个基本操作:
- **节点分裂(和合并)。**节点允许有两个以上的子节点(和一个以上的键),在某些情况下,一个节点会变成过满。然后它被分成两个节点(可能会使它的父节点溢出)。
- **节点旋转。**这里我们还是用二叉树,但是我们交换边。如果 x 是 y 的父代,我们现在让 y 成为 x 的父代。为此, x 必须接管 y 的一个子节点。
这在理论上可能有点令人困惑,但是我会更详细地介绍一下,我相信您会看到它是如何工作的。让我们首先考虑一个叫做 2-3 树的结构。在普通二叉树中,每个节点最多可以有两个子节点,并且每个子节点都有一个键。不过,在 2-3 树中,我们允许一个节点有一个或两个键,最多有三个子节点。左子树中的任何内容现在都必须小于键中最小的子树,右子树中的任何内容都必须大于键中最大的子树,中间子树中的任何内容都必须介于两者之间。图 6-10 显示了一个 2-3 树的两种节点类型的例子。**
图 6-10 。2-3 树中的节点类型
注2-3-树是 B-树的一个特例,B-树构成了几乎所有数据库系统的基础,基于磁盘的树被用于地理信息系统和图像检索等不同的领域。重要的扩展是 B 树可以有成千上万个键(和子树),每个节点通常作为一个连续的块存储在磁盘上。使用大数据块的主要动机是最大限度地减少磁盘访问次数。
搜索一个 2-3 节点非常简单——只是一个带有修剪的递归遍历,就像普通的二叉查找树一样。但是插入需要一点额外的注意。就像在二叉查找树中一样,您首先搜索可以插入新值的适当叶。但是,在二叉查找树中,这将始终是一个 None 引用(即一个空的子节点),您可以将新节点“附加”为现有节点的子节点。但是,在 2-3 树中,您总是会尝试将新值添加到一个现有的叶子中。(但是,添加到树中的第一个值必然需要创建一个新节点;对任何树来说都一样。)如果节点中有空间(也就是说,它是一个 2 节点),您只需添加值。如果没有,你有三把钥匙要考虑(已经有两把和你的新钥匙)。
解决方案是分割节点,将三个值中间的移动到父节点。(如果你正在分裂根,你将不得不制造一个新的根。)如果父现在已经满了,你就需要拆分和*,以此类推。这种分裂行为的重要结果是所有的叶子都在同一层,这意味着树是完全平衡的。*
现在,虽然节点分裂的概念相对容易理解,但现在让我们继续使用更简单的二叉树。你看,可以使用 2-3 树的思想,而不是真正的将实现为 2-3 树。我们可以只用二进制节点来模拟整个事情!这样做有两个好处:第一,结构更简单、更一致;第二,你可以学习旋转(一般来说是一项重要的技术),而不必担心全新的平衡方案!
我将向你们展示的“模拟”被称为 AA 树,以它的创造者阿恩·安德森命名。在众多基于旋转的平衡方案中,AA 树确实以其简单性脱颖而出(尽管如果你是这类事物的新手,还有很多东西需要你去琢磨)。AA 树是一棵二叉树,所以我们需要看看如何模拟 3 节点来达到平衡。你可以在图 6-11 中看到这是如何工作的。
图 6-11 。AA 树中的两个模拟 3 节点(突出显示)。注意,左边的是反的,必须修理
这个图同时向你展示了几件事情。首先,您将了解如何模拟一个 3 节点:您只需将两个节点连接起来,作为一个伪节点(如突出显示的)。第二,图中说明了级的想法。每个节点被分配一个级别(一个数字),所有叶子的级别为 1。当我们假设两个节点形成一个 3 节点时,我们简单地给它们相同的级别,如图中的垂直位置所示。第三,3 节点“内部”的边(称为水平边)可以只指向右边。这意味着最左边的子图说明了一个非法的节点,必须使用一个右旋转进行修复:使 c 成为 d 的左子节点, d 成为 b 的右子节点,最后,使 d 的旧父节点成为 b 的父节点。转眼间。你得到了最右边的子图(这是有效的)。换句话说,中间子的边缘和水平边缘交换位置。这个操作叫做歪斜。
还有另一种形式的非法情况可能发生,并且必须通过循环来解决:过满的伪节点(即 4 节点)。这在图 6-12 中显示。这里我们有三个链接在同一层的节点( c 、 e 和 f )。我们想要模拟一个拆分,其中中间的键( e )将被向上移动到父键( a ),就像在 2-3 树中一样。在这种情况下,只需旋转 c 和 e ,使用左旋即可。这基本上与我们在图 6-11 中所做的正好相反。换句话说,我们将 c 的子指针从 e 下移至 d ,并将 e 的子指针从 d 上移至 c 。最后,我们将 a 的子指针从 c 移动到 e 。为了以后记住 a 和 e 现在形成一个新的 3 节点,我们增加了 e 的等级(见图 6-12 )。这个操作叫做*(自然够了)。*
*
图 6-12 。一个过满的伪节点,以及修复左旋转的结果(交换边(e,d)和(c,e)),以及使 e 成为一个的新子节点
就像在标准的不平衡二叉树中一样,将一个节点插入到 AA 树中;唯一的区别是您在之后执行一些清理工作(使用skew
和split
)。完整的代码可以在清单 6-6 中找到。如您所见,清理(一个对skew
的调用和一个对split
的调用)是作为递归中回溯的一部分执行的——因此节点在回溯到根的路径上被修复。这到底是怎么回事?
沿着路径往下的操作实际上只能做一件影响我们的事情:它们可以将另一个节点放到“我们的”当前模拟节点中。在叶级别,每当我们添加一个节点时都会发生这种情况,因为它们都有 1 级。如果当前节点在树中处于更高的位置,我们可以在当前(模拟的)节点中获得另一个节点,如果一个节点在拆分过程中被上移的话。无论哪种方式,现在突然出现在我们级别上的这个节点可以是左子节点或右子节点。如果是一个左的孩子,我们倾斜(做一个右旋转),我们已经摆脱了这个问题。如果是右的孩子,一开始就不是问题。然而,如果它是一个右孙,我们有一个过满的节点,所以我们做一个分割(左旋转)并将我们模拟的 4 节点的中间节点提升到父节点的级别。
这很难用语言来描述——我希望代码足够清晰,让你明白发生了什么。(不过,这可能需要一些时间和令人挠头的事情。)
清单 6-6 。二叉查找树,现在有了 AA 树平衡
class Node:
lft = None
rgt = None
lvl = 1 # We've added a level...
def __init__(self, key, val):
self.key = key
self.val = val
def skew(node): # Basically a right rotation
if None in [node, node.lft]: return node # No need for a skew
if node.lft.lvl != node.lvl: return node # Still no need
lft = node.lft # The 3 steps of the rotation
node.lft = lft.rgt
lft.rgt = node
return lft # Switch pointer from parent
def split(node): # Left rotation & level incr.
if None in [node, node.rgt, node.rgt.rgt]: return node
if node.rgt.rgt.lvl != node.lvl: return node
rgt = node.rgt
node.rgt = rgt.lft
rgt.lft = node
rgt.lvl += 1 # This has moved up
return rgt # This should be pointed to
def insert(node, key, val):
if node is None: return Node(key, val)
if node.key == key: node.val = val
elif key < node.key:
node.lft = insert(node.lft, key, val)
else:
node.rgt = insert(node.rgt, key, val)
node = skew(node) # In case it's backward
node = split(node) # In case it's overfull
return node
我们能确定 AA 树是平衡的吗?事实上我们可以,因为它忠实地模拟了 2-3 树(用 level 属性表示 2-3 树中的实际树级)。在模拟的 3 节点内有一个额外的边的事实不会超过任何搜索路径的两倍,所以渐近搜索时间仍然是对数的。
黑盒:二进制堆、堆质量和堆排序
一个优先级队列 是在第五章的中讨论的 LIFO 和 FIFO 队列的概括。不是只根据添加的项目来排序,而是每个项目都有一个优先级,并且您总是检索剩余的优先级最低的项目。(您也可以使用最大优先级,但是通常不能在同一个结构中同时使用这两种优先级。)这种功能作为几个算法的组成部分是很重要的,比如 Prim 的,用于寻找最小生成树(第七章),或者 Dijkstra 的,用于寻找最短路径(第九章)。实现优先级队列的方法有很多,但可能最常用的数据结构是二进制堆。(还有其他种类的堆,但是非限定术语堆通常指的是二进制堆。)
二进制堆是完整的二叉树。这意味着它们尽可能地平衡,树的每一层都被填满,除了(可能)最低的一层,它尽可能从左边填满。不过,可以说它们结构中最重要的方面是所谓的堆属性:每个父节点的值都小于两个子节点的值。(这适用于最小堆;对于最大堆,每个父堆都更大。)因此,根在堆中具有最小的值。该属性类似于搜索树,但又不完全相同,事实证明,在不牺牲树的平衡的情况下,堆属性更容易维护。您永远不会通过拆分或旋转堆中的节点来修改树的结构。您只需要交换父节点和子节点来恢复堆属性。例如,要“修复”一个子树的根(它太大了),只需将其与其最小的子树交换,然后递归地修复该子树(如果需要的话)。
heapq
模块包含了一个有效的堆实现,它使用一个通用的“编码”在列表中表示它的堆:如果a
是一个堆,那么a[i]
的子代可以在a[2*i+1]
和a[2*i+2]
中找到。这意味着根(最小的元素)总是在a[0]
中找到。您可以使用heappush
和heappop
函数从头开始构建一个堆。你也可以从一个包含很多值的列表开始,你想把它做成一个堆。在这种情况下,您可以使用heapify
功能。 18 它基本上修复了每一个子树根,从右下方开始,向左上方移动。(事实上,通过跳过叶子,它只需要在数组的左半部分工作。)得到的运行时间是线性的(见练习 6-9)。如果你的列表已经排序,那么它已经是一个有效的堆了,所以你可以不去管它。
下面是一个逐块构建堆的示例:
>>> from heapq import heappush, heappop
>>> from random import randrange
>>> Q = []
>>> for i in range(10):
... heappush(Q, randrange(100))
...
>>> Q
[15, 20, 56, 21, 62, 87, 67, 74, 50, 74]
>>> [heappop(Q) for i in range(10)]
[15, 20, 21, 50, 56, 62, 67, 74, 74, 87]
就像bisect
一样,heapq
模块是用 C 实现的,但是它用做了一个普通的 Python 模块。例如,下面是一个函数的代码(来自 Python 2.3),该函数将一个对象向下移动,直到它比它的两个子对象都小(再次引用我的注释):
def sift_up(heap, startpos, pos):
newitem = heap[pos] # The item we're sifting up
while pos > startpos: # Don't go beyond the root
parentpos = (pos - 1) >>1 # The same as (pos - 1) // 2
parent = heap[parentpos] # Who's your daddy?
if parent <= newitem: break # Valid parent found
heap[pos] = parent # Otherwise: copy parent down
pos = parentpos # Next candidate position
heap[pos] = newitem # Place the item in its spot
注意,原来的函数被称为_siftdown
,因为它在列表中向下筛选值*。不过,我更愿意把它看作是在堆的隐式树结构中向上筛选*。还要注意,就像bisect_right
一样,实现使用了循环而不是递归。**
**除了heappop
,还有heapreplace
,它会弹出最小的项,同时插入一个新元素,比一个heappop
后面跟着一个heappush
要高效一点。heappop
操作返回根(第一个元素)。为了保持堆的形状,最后一个项目被移动到根位置,并从那里向下交换(在每个步骤中,与其最小的子级交换),直到它小于它的两个子级。heappush
操作正好相反:新元素被添加到列表中,并与其父元素重复交换,直到它大于其父元素。这两个操作都是对数的(也是在最坏的情况下,因为堆保证是平衡的)。
最后,该模块(从 2.6 版开始)有实用函数merge
、nlargest
和nsmallest
,分别用于合并排序后的输入和查找 iterable 中的 n 个最大和最小的项。与模块中的其他函数不同,后两个函数采用与list.sort
相同的key
参数。您可以用 DSU 模式在其他函数中模拟这一点,如bisect
侧栏中所述。
尽管在 Python 中您可能永远不会以这种方式使用它们,但是堆操作也可以形成一种简单、高效、渐进最优的排序算法,称为堆排序 。它通常使用 max-heap 实现,首先对序列执行heapify
,然后重复弹出根(如在heappop
中),最后将它放入现在为空的最后一个槽。渐渐地,随着堆的缩小,原始数组从右边开始填充最大的元素,第二大的元素,依此类推。换句话说,堆排序基本上是选择排序,其中堆用于实现选择。因为初始化是线性的,并且每个 n 选择是对数的,所以运行时间是对数线性的,即最优的。
摘要
分而治之的算法设计策略包括将一个问题分解成大小大致相等的子问题,求解子问题(通常通过递归),然后组合结果。这很有用的主要原因是工作负载是平衡的,通常从二次到对数线性运行时间。这种行为的重要例子包括合并排序和快速排序,以及寻找最接近的对或点集的凸包的算法。在某些情况下(例如当搜索排序序列或选择中间元素时),除了一个子问题之外,所有的子问题都可以被修剪,从而在子问题图中产生从根到叶的遍历,产生甚至更有效的算法。
子问题结构也可以显式表示,就像在二分搜索法树中一样。搜索树中的每个节点都大于其左子树中的后代,但小于其右子树中的后代。这意味着二分搜索法可以被实现为从根开始的遍历。平均而言,简单地随意插入随机值将产生足够平衡的树(导致对数搜索时间),但也有可能使用节点分裂或旋转来平衡树,以保证在最坏情况下的对数运行时间。
如果你好奇的话...
如果你喜欢二分法,你应该查一下插值搜索,对于均匀分布的数据,它的平均用例运行时间为 O (lg lg n )。为了实现除排序序列、搜索树和哈希表之外的集合(即有效的成员检查),你可以看看 Bloom filters 。如果你喜欢搜索树和相关的结构,那里有很多。你可以找到大量不同的平衡机制(红黑树、 AVL 树、八字树),其中一些是随机的(树状图,还有一些只是抽象地表示树(跳过列表)。还有专门的树结构的整个家族,用于索引多维坐标(所谓的空间访问方法)和距离(度量访问方法)。其他要检查的树结构有间隔树、四叉树和八叉树。
练习
6-1.编写一个 Python 程序,实现天际线问题的解决方案。
6-2.在每个递归步骤中,二分搜索法将序列分成大约相等的两部分。考虑三元搜索,将序列分成三个部分。它的渐近复杂度是多少?关于二进制和三进制搜索中的比较次数,你能说些什么?
6-3.与二分搜索法树相比,多路搜索树的意义是什么?
6-4.如何在线性时间内按排序顺序从二叉查找树中提取所有键?
6-5.如何从二叉查找树中删除节点?
6-6.假设您将 n 个随机值插入一个最初为空的二叉查找树。最左边(也就是最小的)节点的平均深度是多少?
6-7.在最小堆中,当向下移动一个大节点时,你总是与最小的孩子交换位置。为什么这很重要?
6-8.堆编码是如何(或为什么)工作的?
6-9.为什么建堆的操作是线性的?
6-10.为什么不用一个平衡的二叉查找树来代替堆呢?
6-11.编写一个 partition 版本,将元素就地分区(也就是说,按照原始顺序移动它们)。你能让它比清单 6-3 中的那个更快吗?
6-12.使用练习 6-11 中的就地分区,重写快速排序以就地排序元素。
6-13.比如说,您使用random.choice
重写了 select 以选择枢轴。那会有什么不同呢?(注意,同样的策略可以用来创建一个随机快速排序。)
6-14.实现一个使用关键函数的 quicksort 版本,就像list.sort
一样。
6-15.证明边长为 d 的正方形最多可以容纳四个点,这四个点至少相距 d 的距离。
6-16.在最接近对问题的分治解决方案中,您最多可以检查中间区域点中的接下来的七个点,这些点按 y 坐标排序。展示如何轻松地将这个数字减少到 5。
6-17.元素唯一性问题是确定一个序列的所有元素是否唯一。这个问题在实数的最坏情况下有一个被证明的对数线性下界。表明这意味着最接近的配对问题在最坏的情况下也具有对数线性下界。
6-18.你如何在线性时间内解决最大切片问题?
参考
安德森(1993 年)。平衡搜索树变得简单。在算法和数据结构研讨会会议录 (WADS),第 60-71 页。
拜耳公司(1971 年)。虚拟内存的二进制 B 树。在 ACM SIGFIDET 研讨会关于数据描述、访问和控制的会议记录中,第 219-235 页。
布卢姆,m .,弗洛伊德,R. W .,普拉特,v .,里维斯特,R. L .,和塔尔詹,R. E. (1973)。选择的时间限制。计算机与系统科学学报,7(4):448-461。
de Berg,m .,Cheong,o .,van Kreveld,m .,和 Overmars,M. (2008)。计算几何:算法与应用,第三版。斯普林格。
1 注意,一些作者使用征服项作为递归的基本情况,产生稍微不同的排序:划分、征服和组合。
2Udi Manber 在他的算法简介中描述的(参见第四章中的“参考文献”)。
3 例如,在 skyline 问题中,您可能希望将基本 case 元素( L 、 H 、 R )拆分成两对( L 、 H )和( R 、 H ),因此combine
函数可以构建一个点序列。
4 其实,更灵活的说法未必完全正确。有许多对象(如复数)可以被散列,但不能比较大小。
5 在统计学中,中位数也定义为偶数长度的序列。然后是两个中间元素的平均值。这不是我们担心的问题。
6 理论上,我们可以使用 select 的保证线性版本来找到中间值,并以此为支点。不过,这在实践中不太可能发生。
7 Timsort 实际上也是 Java SE 7 中使用的,用于数组排序。
8 参见例如源代码中的文件listsort.txt
(或者在线,http://svn.python.org/projects/python/
trunk/Objects/listsort.txt
)。
9 你可以在http://svn.python.org/projects/python/trunk/Objects/listobject.c
找到实际的 C 代码。
10 见https://bitbucket.org/pypy/pypy/src/default/rpython/rlib/listsort.py
。
当然,实数通常并不那么随意。只要你的数字使用固定的位数,你就可以使用基数排序(在第四章中有提到)在线性时间内对数值进行排序。
12 我觉得太酷了,想在句子后面加个感叹号...但是考虑到主题,我想这可能有点令人困惑。
13 实际上,这种近似在本质上并不是渐近的。如果你想知道细节,你可以在任何好的数学参考书中找到。
14
15 我仍然假设我们想要一个非空的间隔。如果结果是一个负数,你可以用一个空的区间来代替。
这一节有点难,但对于理解这本书的其余部分并不重要。随意浏览,甚至完全跳过。不过,在本节的后面,您可能希望阅读关于二进制堆、 heapq 和 heapsort 的“黑盒”侧栏。
在某种程度上,AA 树是 BB 树的一个版本,或者是由鲁道夫·拜尔在 1971 年提出的作为 2-3 树的二进制表示的二进制 B 树。
18 将这个操作称为构建堆并为修复单个节点的操作保留名称堆是很常见的。因此, build-heap 在除了叶子之外的所有节点上运行 heapify 。*****
七、贪婪是好事?证明一下!
伙计,这不是够不够的问题。
——戈登·盖柯,华尔街
所谓的贪婪算法是短视的,因为它们孤立地做出每个选择,做此时此地看起来好的事情。在许多方面,急切的或不耐烦的可能是它们更好的名字,因为其他算法通常也试图找到尽可能好的答案;只是贪婪的人拿走了此刻能得到的,而不是担心未来。设计和实现一个贪婪的算法通常很容易,当他们工作时,往往是非常高效的。主要问题是展示他们做了工作——如果他们真的做了。这就是“证明它”的原因章节标题的一部分。
本章讨论给出正确(最优)答案的贪婪算法;我将在第十一章中重温设计策略,在那里我将把这个要求放宽到“几乎正确(最优)”。
一步一步保持安全
贪婪算法的常见设置是一系列选择(正如您将看到的动态编程)。贪婪包括根据当地信息做出每个选择,做看起来最有希望的事情,而不考虑背景或未来的后果,然后,一旦做出选择,就永远不要回头。如果这能带来一个解决方案,我们必须确保每个选择都是安全的——不会破坏我们未来的前景。您将会看到许多关于我们如何确保这种安全性的例子(或者说,我们如何证明一个算法是安全的),但是让我们从“一步一步”的部分开始。
用贪婪算法解决的这类问题通常会逐步建立一个解决方案。它有一组“解决方案片段”,可以组合成部分的、最终完整的解决方案。这些部分可以以复杂的方式组合在一起;可能有许多方法来组合它们,并且一旦我们使用了某些其他的,一些部分可能不再适合。你可以把这想象成一个有许多可能解决方案的拼图游戏(见图 7-1 )。拼图图片是空白的,拼图块比较规整,可以在几个位置组合使用。
图 7-1 。部分解决方案,和一些贪婪地排序的块(从左到右考虑),下一个贪婪的选择被突出显示
现在给每个拼图块添加一个值。这是您将该特定部分融入完整解决方案所获得的奖励金额。接下来的目标是找到一种方式来铺设拼图,让你获得最高的总价值——也就是说,我们有一个优化问题。一般来说,解决这样的组合优化问题根本不是一件简单的任务。您可能需要考虑放置这些片段的每一种可能方式,从而产生指数级(可能是阶乘)运行时间。
假设你从顶部开始一行一行地填充拼图,那么你总是知道下一块拼图应该放在哪里。在这种情况下,贪婪的方法非常简单,至少对于选择要使用的棋子来说是如此。只需按价值递减排序,逐个考虑。如果一块不合适,你就扔掉它。如果合适,你就用它,不用考虑以后的作品。
即使不考虑正确性(或最优性)的问题,很明显这种算法需要几样东西才能运行:
- 一组候选元素,或个片段,附带一些值
- 检查部分解决方案是否有效或可行的一种方式
因此,部分解决方案被构建为解决方案片段的集合。我们依次检查每一部分,从最有价值的部分开始,然后添加每一部分,得到一个更大的、仍然有效的解决方案。当然,还可以添加一些微妙的东西(例如,总值不必是元素值的总和,我们可能想知道什么时候完成,而不必穷尽元素集),但这只是一个原型描述。
这类问题的一个简单例子是找零——试图用尽可能少的硬币和钞票凑成一个给定的总数。比如说,有人欠你 43.68 美元,给你一张百元大钞。你是做什么的?这个问题之所以是一个很好的例子,是因为我们都本能地知道在这里做什么是正确的 1 :我们从最大的面额开始,然后一路向下。每一张钞票或硬币都是一块拼图,我们正试图准确地覆盖 56.32 美元这个数字。我们可以考虑对一堆钞票和硬币进行分类,而不是对它们进行分类,因为每种钞票和硬币都有很多。我们按降序对这些堆栈进行排序,并开始分发最大面额的,如以下代码所示(使用美分,以避免浮点问题):
>>> denom = [10000, 5000, 2000, 1000, 500, 200, 100, 50, 25, 10, 5, 1]
>>> owed = 5632
>>> payed = []
>>> for d in denom:
... while owed >=d:
... owed -= d
... payed.append(d)
...
>>> sum(payed)
5632
>>> payed
[5000, 500, 100, 25, 5, 1, 1]
大多数人可能很少怀疑这是可行的;这似乎是显而易见的事情。事实上,它是可行的,但是这个解决方案在某些方面非常脆弱。即使稍微改变可用面额的列表也会破坏它(见练习 7-1)。计算出贪婪算法将对哪些货币起作用并不简单(尽管已经有了算法),而且一般问题本身还没有解决。事实上,它与背包问题密切相关,背包问题将在下一节讨论。
让我们转向一个不同类型的问题,与我们在第四章中处理的匹配相关。电影结束了(许多人认为电视剧明显更好),小组决定出去跳探戈,他们再次面临匹配问题。每对人都有一定的相容性,他们用数字表示,他们希望所有对的相容性之和尽可能高。同性舞伴在探戈中并不少见,所以我们不必局限于双方的情况——我们最终会遇到最大重量匹配问题。在这种情况下(或就此而言,在双边情况下),贪婪一般不会起作用。然而,由于某种奇怪的巧合,所有兼容数字恰好是两个的的不同幂。现在,发生了什么? 2
让我们首先考虑一下贪婪算法是什么样子,然后看看为什么它会产生最佳结果。我们将一点一点地构建一个解决方案——让每一部分都是所有可能的配对,而部分解决方案是一组配对。只有当每个人最多参与其中一对时,这样的部分解才是有效的。算法大致如下:
- 列出可能的配对,按兼容性降序排列。
- 从列表中选择第一个未使用的配对。
- 这对中有人已经被占用了吗?如果有,丢弃它;否则,使用它。
- 单子上还有别的对子吗?如果是,请转到 2。
正如您稍后将看到的,这与 Kruskal 的最小生成树算法非常相似(尽管认为不管边权重如何都有效)。这也是一个相当典型的贪婪算法。其正确性另当别论。使用不同的 2 的幂是一种欺骗,因为它会让几乎所有贪婪的算法都起作用;也就是说,只要你能得到一个有效的解,你就会得到一个最优的结果(见练习 7-3)。尽管这是欺骗,但它说明了这里的中心思想:做出贪婪的选择是安全的。使用剩余夫妇中最合适的一对将 永远】至少和其他选择一样好。 3
在接下来的几节中,我将向您展示一些众所周知的问题,这些问题可以使用贪婪算法来解决。对于每个算法,你会看到它是如何工作的,为什么贪婪是正确的。在本章快结束时,我将总结一些证明正确性的通用方法,你可以用它们来解决其他问题。
渴望的追求者和稳定的婚姻
事实上,有一个经典的匹配问题可以被贪婪地解决:稳定的婚姻问题 。这个想法是,一个群体中的每个人都有他或她想和谁结婚的偏好。我们希望看到每个人都结婚,我们希望婚姻稳定*,这意味着没有男人喜欢婚外也喜欢他的女人。(为了简单起见,我们在这里忽略同性婚姻和一夫多妻制。)*
大卫·盖尔和劳埃德·沙普利设计了一个简单的算法来解决这个问题。这种提法在性别上相当保守,但如果性别角色颠倒过来,肯定也行得通。该算法运行多个轮*,直到没有未订婚的男人。每一轮包括两个步骤:
- 每个没有订婚的男人都向他还没有邀请的女人中他最喜欢的一个求婚。
- 每个女人都(暂时)与她最喜欢的追求者订婚,并拒绝其他人。
这可以被视为贪婪,因为我们现在只考虑可用的最爱(男性和女性)。你可能会反对说,这只是有点贪婪,因为我们没有锁定目标,直接走向婚姻;如果有更感兴趣的求婚者出现,女性可以解除婚约。即便如此,一旦一个男人被拒绝,他就永远被拒绝了,这意味着我们保证了进步和二次最坏情况运行时间。
为了证明这是一个最优且正确的算法,我们需要知道每个人都会结婚,而且婚姻是稳定的。一旦一个女人订婚,她就保持订婚状态(尽管她可能会取代她的未婚夫)。我们不可能被一对未婚情侣困住,因为在某个时候,男方会向女方求婚,而女方会(暂时)接受他的求婚。
我们怎么知道婚姻是稳定的?假设斯佳丽和斯图尔特都结婚了,但不是彼此。有没有可能他们暗地里更喜欢对方而不是现在的配偶?不。如果是这样,斯图尔特早就向她求婚了。如果她接受了那个提议,她一定后来找到了她更喜欢的人;如果她拒绝了,她已经有了一个更好的伴侣。
虽然这个问题看起来很傻很琐碎,但其实不然。例如,它被用于一些大学的录取和分配医学生到医院工作。事实上,有整本书(如唐纳德·克努特、丹·古斯菲尔德和罗伯特·w·欧文的书)专门讨论这个问题及其变种。
所有的女孩。 你知道我永远不会离开你。只要她和别人在一起就不会。(http://xkcd.com/770
)
背包问题
在某种程度上,这个问题是前面讨论过的变革问题的概括。在那个问题中,我们使用硬币面额来确定部分/全部解决方案是否有效(不要给太多/给准确的数量),硬币的数量衡量最终解决方案的质量。背包问题用不同的术语来描述:我们有一组想要随身携带的物品,每一个都有一定的重量和 ?? 值;然而,我们的背包有一个最大容量(总重量的上限),我们希望最大化我们得到的总价值。
背包问题涵盖了很多应用。每当你要选择一组有价值的对象(内存块、文本片段、项目、人),其中每个对象都有一个单独的值(可能与金钱、概率、新近性、能力、相关性或用户偏好相关),但你受到一些资源的约束(无论是时间、内存、屏幕空间、重量、体积还是其他任何东西),你很可能正在解决背包问题的一个版本。还有一些特殊情况和密切相关的问题,如子集和问题、第十一章中讨论的,以及前面讨论的找零问题。这种广泛的适用性也是它的弱点——这使得它成为一个如此难以解决的问题。一般来说,问题越有表现力,就越难找到有效的算法。幸运的是,有一些特殊的情况我们可以用不同的方式来解决,正如你将在接下来的章节中看到的。
分数背包
这是背包问题中最简单的一个。在这里,我们不需要包括或排除整个对象;例如,我们可能会在背包里塞满豆腐、威士忌和金粉(为一次有点奇怪的野餐做准备)。然而,我们不需要允许任意分数。例如,我们可以使用克或盎司的分辨率。(我们可以更加灵活;参见练习 7-6。)你将如何处理这个问题?
这里最重要的是找到价值与重量的比率。例如,大多数人会同意金粉每克价值最高(尽管这可能取决于你用它做什么);假设威士忌介于两者之间(尽管我肯定有人会对此提出异议)。在这种情况下,为了充分利用我们的背包,我们会把它装满金粉——或者至少是我们现有的金粉。如果用完了,我们就开始加威士忌。如果我们喝完威士忌后还有剩余的空间,我们就用豆腐把它全部填满(并开始害怕打开包装收拾这一团乱)。
这是贪婪算法的一个典型例子。我们直奔好的(或者至少是昂贵的)东西。如果我们使用一个离散的重量测量,这可能会更容易看到;也就是说,我们不需要担心比率。我们基本上有一套单独的金粉、威士忌和豆腐,我们根据它们的价值对它们进行分类。然后,我们(从概念上)把克一个一个的打包。
整数背包
假设我们放弃了片段,现在需要包含整个对象——这种情况在现实生活中更有可能发生,无论您是在编程还是打包行李。然后问题突然变得更加难以解决了。现在,假设我们仍然在处理对象的类别,那么我们可以从每个类别中添加一个整数(即对象的数量)。每个类别都有一个固定的权重和值,适用于所有对象。比如所有的金条重量一样,价值一样;这同样适用于瓶装威士忌(我们坚持单一品牌)和袋装豆腐。现在,我们该怎么办?
整数背包问题有两种重要情况——有界和无界情况。有界的情况假设我们在每个类别中有固定数量的对象,【4】,无界的情况让我们想用多少就用多少。可悲的是,贪婪在这两种情况下都行不通。事实上,这两个都是未解决的问题,在某种意义上,没有已知的多项式算法来解决它们。然而,?? 还是有希望的。正如你将在下一章看到的,我们可以使用动态编程在伪多项式时间内解决问题,这在许多重要情况下可能已经足够好了。此外,对于无界的情况,事实证明贪婪的方法并不坏!或者,更确切地说,它至少有一半好,这意味着我们永远不会得到少于一半的最佳值。稍加修改,有界版本也能得到同样好的结果。贪婪近似的概念将在第十一章的中详细讨论。
注这主要是背包问题的一个初步“尝试”。我会在第八章的中更彻底地处理整数背包问题的解决方案。
霍夫曼算法
霍夫曼算法是贪婪的另一个经典。假设你在某个紧急中心工作,人们在那里寻求帮助。你试图将一些简单的是/否问题放在一起,以帮助来电者诊断急性医疗问题,并决定适当的行动方案。您有一个应该涵盖的条件列表,以及一组诊断标准、严重程度和发生频率。您首先想到的是构建一个平衡的二叉树,在每个节点中构造一个问题,将可能条件的列表(或子列表)分成两半。不过,这似乎太简单了;这个清单很长,包括许多非临界条件。不知何故,你需要考虑严重程度和发生频率。
开始简化任何问题通常是个好主意,所以你决定把重点放在频率上。你意识到平衡二叉树是基于 均匀概率的假设——如果某些项目更有可能,将列表分成两半是不行的。例如,如果病人有一半的机会失去知觉,这就是要问的事情——即使“病人有皮疹吗?”可能会把列表从中间分开。换句话说,你想要一个加权平衡:你想要预期的问题数量尽可能低。您希望最小化从根到叶的遍历的预期深度。
你会发现这个想法也可以用来解释严重性。您可能希望对最危险的情况进行优先排序,以便快速识别(“患者有呼吸吗?”),代价是让病情不太严重的患者等待几个额外的问题。在一些健康专家的帮助下,你可以通过结合频率(概率)和所涉及的健康风险,给每种情况一个成本或权重来做到这一点。你对树形结构的目标还是一样的。如何最小化所有叶子的深度 ( u ) × 重量 ( u )之和 u ?
这个问题当然也有其他的应用。事实上,最初的(也是最常见的)应用是通过可变长度编码来压缩——更紧凑地表示文本。文本中的每个字符都有出现的频率,您希望利用这些信息给出不同长度的字符编码,以便最小化任何文本的预期长度。同样,对于任何字符,您都希望最小化其编码的预期长度。
你看出这和前面的问题有什么相似之处了吗?考虑一下你只关注给定医疗条件的可能性的版本。现在,我们不是最小化识别某种疾病所需的是/否问题的数量,而是最小化识别一个字符所需的比特数。是/否答案和位唯一地标识了二叉树中叶子的路径(例如,零= 否 = 左和一= 是 = 右)。 5 例如,考虑字符 a 到 f 。图 7-2 给出了对它们进行编码的一种方式(暂时忽略节点中的数字)。例如, g (由突出显示的路径给出)的代码将是 101。因为所有的字符都在树叶中,所以当解码一个用这种方法压缩过的文本时,不会有歧义(见练习 7-7)。没有有效代码是另一个代码的前缀,这一特性产生了术语前缀代码。
图 7-2 。a–I 的霍夫曼树,频率/权重为 4、5、6、9、11、12、15、16 和 20,代码 101 表示的路径(右、左、右)突出显示
该算法
让我们先设计一个贪婪算法来解决这个问题,然后证明它是正确的(这当然是关键的一步)。最明显的贪婪策略可能是从出现频率最高的字符开始,一个接一个地添加字符(叶子)。但是我们应该在哪里添加它们呢?另一种方法(稍后你会在克鲁斯卡尔的算法中再次看到)是让部分解决方案由几个树片段组成,然后重复地由 ?? 组合 ??。当我们合并两棵树时,我们添加一个新的共享根,并赋予它一个等于其子树之和的权重,也就是先前的根。这正是图 7-2 中节点内数字的含义。
清单 7-1 显示了一种实现霍夫曼算法的方法。它将部分解决方案维护为一个森林,每棵树都表示为嵌套列表。只要森林中至少有两棵独立的树,就会挑出两棵最轻的树(根部重量最低的树),合并,然后放回原处,并赋予新的根部重量。
清单 7-1 。霍夫曼算法
from heapq import heapify, heappush, heappop
from itertools import count
def huffman(seq, frq):
num = count()
trees = list(zip(frq, num, seq)) # num ensures valid ordering
heapify(trees) # A min-heap based on frq
while len(trees) > 1: # Until all are combined
fa, _, a = heappop(trees) # Get the two smallest trees
fb, _, b = heappop(trees)
n = next(num)
heappush(trees, (fa+fb, n, [a, b])) # Combine and re-add them
return trees[0][-1]
下面是一个如何使用代码的示例:
>>> seq = "abcdefghi"
>>> frq = [4, 5, 6, 9, 11, 12, 15, 16, 20]
>>> huffman(seq, frq)
[['i', [['a', 'b'], 'e']], [['f', 'g'], [['c', 'd'], 'h']]]
在实现中有几个细节值得注意。它的主要特性之一是使用堆(来自heapq
)。重复选择和组合未排序列表的两个最小元素会给我们一个二次运行时间(线性选择时间,线性迭代次数),而使用堆会将其减少到对数线性(对数选择和重新添加)。但是,我们不能直接将树添加到堆中;我们需要确保它们按频率分类。我们可以简单地添加一个元组(freq, tree)
,只要所有频率(即权重)都不同,这就可以工作。然而,一旦森林中的两棵树具有相同的频率,堆代码就必须比较这两棵树,看哪一棵树更小——然后我们很快就会遇到未定义的比较。
注意在 Python 3 中,不允许比较
["a", ["b", "c"]]
和"d"
这样不兼容的对象,会引发一个TypeError
。在早期版本中,这是允许的,但排序通常没有太大意义;无论如何,实施更可预测的键可能都是一件好事。
一个解决方案是在两者之间添加一个字段,这个字段保证对所有对象都不同。在这种情况下,我简单地使用了一个计数器,产生了(freq, num, tree)
,其中使用任意的num
打破了频率关系,避免了直接比较(可能无法比较的)树。 6
如你所见,生成的树结构相当于图 7-2 中的所示。
当然,要使用这种技术压缩和解压缩文本,您需要一些预处理和后处理。首先,您需要计算字符数以获得频率(例如,使用来自collections
模块的Counter
类)。然后,一旦你有了霍夫曼树,你必须找到所有字符的代码。你可以用一个简单的递归遍历来实现,如清单 7-2 所示。
清单 7-2 。从霍夫曼树中提取霍夫曼码
def codes(tree, prefix=""):
if len(tree) == 1:
yield (tree, prefix) # A leaf with its code
return
for bit, child in zip("01", tree): # Left (0) and right (1)
for pair in codes(child, prefix + bit): # Get codes recursively
yield pair
例如,codes
函数产生适合在dict
构造函数中使用的(char, code)
对。要使用这样的字典来压缩代码,您只需遍历文本并查找每个字符。为了解压缩文本,您宁愿直接使用霍夫曼树,使用输入中的位来遍历它(即,确定您应该向左还是向右);我将把细节留给读者作为练习。
第一个贪婪的选择
我相信你可以看到霍夫曼代码将让你忠实地编码一个文本,然后再次解码——但是它怎么可能是最优的(在我们正在考虑的代码类别中)?也就是说,为什么使用这个简单、贪婪的过程,任何叶子的预期深度都被最小化了?
正如我们通常所做的,我们现在转向归纳法:我们需要证明我们从头到尾都是安全的——贪婪的选择不会给我们带来麻烦。我们常常可以将这个证明拆分成两部分,也就是通常所说的( i ) 贪婪选择性质和( ii ) 最优子结构(例如,参见 Cormen 等人在第一章的“参考文献”部分)。贪婪选择属性意味着贪婪选择给了我们一个新的部分解,它是最优解的一部分。最优子结构与第八章的材料非常密切相关,它意味着问题的剩余部分,在我们做出选择之后,是否能够也像原始问题一样得到解决——如果我们能够找到子问题的最优解,我们可以将它与我们的贪婪选择结合起来,从而得到整个问题的解决方案。换句话说,最优解是从最优子解构建的。
为了展示 Huffman 算法的贪婪选择属性,我们可以使用一个交换参数(例如,参见第一章的“参考”部分中的 Kleinberg 和 Tardos)。这是一种通用技术,用来表明我们的解决方案至少和最优方案一样好(因此是最优的),或者在这种情况下,存在和一个我们贪婪选择的解决方案,至少有这么好。“至少一样好”的部分是通过采用一个假设的(完全未知的)最优解,然后逐渐将其变为我们的解(或者,在这种情况下,包含我们感兴趣的位的解)而不使它变得更糟来证明的。
Huffman 算法的贪婪选择包括将两个最轻的元素作为同级叶子放置在树的最低层。(注意,我们担心的只是第首个贪婪的选择;最优子结构将处理其余的归纳。)我们需要证明这是安全的——存在一个最优解,其中两个最轻的元素实际上是底层的兄弟树叶。通过定位另一个最优树开始交换论证,其中这两个元素是而不是最低层的兄弟。让 a 和 b 是最低频率的元素,并且假设这个假设的最优树具有 c 和 d 作为最大深度处的兄弟树叶。我们假设 a 比 b 轻(具有较低的权重/频率),并且 c 比 d 轻。 7 在这种情况下,我们也知道 a 比 c 轻, b 比 d 轻。为简单起见,让我们假设 a 和 d 的频率不同,因为否则证明是简单的(见练习 7-8)。
如果我们交换 a 和 c 会发生什么?然后互换 b 和 d ?首先,我们现在有了作为底层兄弟的 a 和 b ,这是我们想要的,但是预期的叶子深度发生了什么变化呢?您可以在这里修改加权和的完整表达式,但简单的想法是:我们在树中上移了一些重节点和下移了一些轻节点和*。这意味着一些短路径现在在总和中被给予较高的权重,而一些长路径被给予较低的权重。总而言之,总成本不可能增加。(事实上,如果深度和权重都不同,我们的树会更好,我们有一个矛盾的证明,因为我们假设的替代最优方案不存在——贪婪的方法是最好的。)*
走完剩下的路
这是证明的前半部分。我们知道做出第一个贪婪选择是可以的(贪婪选择属性),但是我们需要知道使用贪婪选择(最优子结构)来保持是可以的。不过,我们需要先处理剩下的子问题是什么*。更好的是,我们希望它有和原来一样的结构,这样感应机制就能正常工作。换句话说,我们希望将事情简化为一个新的、更小的元素集,我们可以为其构建一个最佳树,然后展示如何在此基础上进行构建。*
这个想法是将前两个组合的叶子视为一个新元素,忽略它是一棵树的事实。我们只担心它的根源。然后,子问题就变成了为这组新元素找到一棵最优树——通过归纳,我们可以假设这是正确的。剩下的唯一问题是,一旦我们通过再次包括它的叶子节点,将这个节点扩展回三节点子树,这个树是否是最优的;这是给我们引导步骤的关键部分。
假设我们的两片叶子是 a 和 b ,频率为 f ( a )和 f ( b )。我们将它们聚集成一个单个节点,频率为f(a)+f(b),并构建一个最优树。让我们假设这个组合节点在深度 D 结束。那么它对总树成本的贡献就是D×(f(a)+f(b)。如果我们现在展开这两个子节点,它们的父节点不再对成本有贡献,但是叶子(现在在深度 D + 1)的总贡献将是(D+1)×(f(a)+f(b)。换句话说,全解的成本超过最优子解f(a)+f(b)。我们能确定这是最优的吗?
是的,我们可以,我们可以用矛盾来证明,假设它是而不是最优。我们变出另一棵更好的树——假设它也有 a 和 b 作为底层兄弟。(根据上一节的讨论,我们知道存在这样的最优树。)再一次,我们可以折叠 a 和 b ,我们最终得到子问题的一个解决方案,这个解决方案比我们得到的解决方案更好…但是我们得到的解决方案根据假设是最优的!换句话说,我们找不到比包含最优子解更好的全局解。
最佳合并
虽然霍夫曼算法通常用于构造最佳前缀码,但还有其他方式来解释霍夫曼树的属性。正如最初解释的那样,人们可以把它看作一棵决策树,其中期望的遍历深度是最小的。不过,我们也可以在解释中使用内部节点的权重,从而产生一个相当不同的应用。
我们可以将霍夫曼树视为一种微调的分治树,其中我们不像第六章中的那样进行平面平衡,而是将叶子权重考虑在内。然后,我们可以将叶权重解释为子问题的大小,如果我们假设组合(合并)子问题的成本是线性的(分而治之中经常出现这种情况),则所有内部节点权重的总和代表所执行的总工作量。
例如,这方面的一个实际例子是合并已排序的文件。合并大小为 n 和 m 的两个文件需要在 n + m 中线性花费时间。(这类似于关系数据库中的连接问题或 timsort 等算法中的序列合并问题。)换句话说,如果你把图 7-2 中的叶子想象成文件,把它们的权重想象成文件大小,那么内部节点就代表了整个合并的成本。如果我们能够最小化内部节点的总和(或者,等价地,所有节点的总和),我们将找到最佳的合并时间表。(练习 7-9 要求你证明这真的很重要。)
我们现在需要证明霍夫曼树确实可以最小化节点权重。幸运的是,我们可以根据前面的讨论来证明这一点。我们知道,在霍夫曼树中,所有叶子的深度乘以权重之和是最小的。现在,考虑每个叶子如何对所有节点的总和做出贡献:叶子权重作为被加数在其每个祖先节点中出现一次——这意味着总和完全相同!即sum(weight(node) for node in nodes)
与sum(depth(leaf)*weight(leaf) for leaf in leaves)
相同。换句话说,霍夫曼算法正是我们进行最佳合并所需要的。
提示Python 标准库有几个处理压缩的模块,包括
zlib
、gzip
、bz2
、zipfile
、tar
。zipfile
模块处理 ZIP 文件,它使用基于霍夫曼码的压缩。 8
最小生成树
现在让我们看看贪婪问题最广为人知的例子:寻找最小生成树。这个问题由来已久——至少从 20 世纪初就存在了。1926 年,捷克数学家奥塔卡尔·borůvka 首次解决了这个问题,试图为摩拉维亚建造一个廉价的电网。从那以后,他的算法被重新发现了很多次,它仍然是今天已知的一些最快的算法的基础。我将在本节中讨论的算法(Prim 的和 Kruskal 的)在某种程度上更简单,但具有相同的渐近运行时间复杂度( O ( m lg n ,对于 n 节点和 m 边)。 9 如果你对这个问题的历史感兴趣,包括经典算法的反复重新发现,可以看看 Graham 和 Hell 的论文《关于最小生成树问题的历史》。(例如,你会看到 Prim 和 Kruskal 并不是唯一声称拥有其同名算法的人。)
我们基本上是在寻找连接一个加权图的所有节点的最便宜的方法,因为我们只能使用它的边的子集来完成这项工作。解决方案的成本就是我们使用的边的加权和。这在建设电网、构建公路或铁路网络的核心、设计电路,甚至是执行某种形式的集群(在这种情况下,我们只需要几乎连接所有节点)时会很有用。最小生成树也可以用作第一章中介绍的旅行销售代表问题的近似解决方案的基础(见第十一章对此的讨论)。
连通无向图 G 的生成树 T 具有与 G 相同的节点集和边的子集。如果我们将一个边权重函数与 G 相关联,那么边 e 具有权重 w ( e ,那么生成树的权重 w ( T ),就是 T 中每条边 e 的 w ( e 之和。在最小生成树问题中,我们想在 G 上找到一棵具有最小权重的生成树。(注意可能不止一个。)还要注意,如果 G 断开,它将没有也没有生成树,所以在下面,通常假设我们正在处理的图是连通的。
在第五章中,你看到了如何使用遍历构建生成树;构建最小生成树也可以像这样一步一步来构建,这就是贪婪的来源:我们逐渐通过一次添加一条边来构建树。在每一步,我们都在我们的建造程序允许的范围内选择最便宜的(或最轻的)边。这个选择就是局部最优(也就是贪心的)不可撤销。这个问题的主要任务,或者任何其他贪婪问题,变成显示这些局部最优选择导致全局最优解。
最短的边
考虑图 7-3 。让边权重对应于绘制时节点之间的欧几里德距离(即实际边长)。如果你要为这个图构造一个生成树,你会从哪里开始?你能确定其中有某种优势吗?或者至少包含某个边缘是安全的?当然( e , i )看起来很有希望。它太小了!事实上,它是所有边中最短的一条,也是权重最低的一条。但这就够了吗?
图 7-3 。欧几里得图及其最小生成树(突出显示)
事实证明,的确如此。考虑任何没有的生成树的最小重量边( e , i )。生成树必须包括 e 和 i (根据定义),因此它还将包括从 e 到 i 的单一路径。如果我们现在将( e , i )添加到混合中,我们将得到一个循环*,并且为了回到一个合适的生成树,我们将不得不删除这个循环的一条边——哪条都没关系。因为( e , i )是最小的,移除任何其他的边会产生比我们开始时更小的树。正确换句话说,任何不包括最短边的树都可以变小,所以最小生成树必须*包括最短边。(正如你将看到的,这是克鲁斯卡尔算法背后的基本思想。)**
如果我们考虑所有的边都发生在一个节点上,会怎么样呢——我们能得出什么结论吗?比如看一下 b 。根据生成树的定义,我们必须以某种方式将 b 连接到其余部分,这意味着我们必须包括或者 ( b , d ) 或者 ( b , a )。同样,选择两者中最短的一个似乎很有诱惑力。再一次,贪婪的选择被证明是非常明智的。我们再一次用反证法证明了选择是次等的:假设用( b , a )更好。我们将构建包含( b , a )的最小生成树。然后,为了好玩,我们会添加( b , d ),创建一个循环。但是,嘿——如果我们去掉( b , a ),我们就有了另一个生成树,因为我们把一条边换成了一条更短的边,所以这个新树肯定更小。换句话说,我们有一个矛盾,没有( b , d )的那个一开始就不可能最小。而这个是 Prim 算法背后的基本思想,我们将在 Kruskal 的算法之后再看。
事实上,这两个想法都是涉及削减的更普遍原则的特例。切割只是将图节点划分为两个集合,在这种情况下,我们感兴趣的是在这两个节点集合之间通过的边。我们说这些边缘穿过切口。例如,想象在图 7-3 中画一条垂直线,正好在 d 和 g 之间;这将产生由五条边交叉的切口。到现在为止,我相信你已经明白了:我们可以确定包含穿过切割的最短边是安全的,在这种情况下( d , j )。争论再次完全相同:我们建立一个替代树,它必须包括至少一个穿过切割的其他边(为了保持图的连接)。如果我们然后添加( d , j ),则至少穿过切割的其他较长边中的一条将是与( d , j )相同循环的一部分,这意味着移除另一条边将是安全的,从而给出更小的生成树。
您可以看到前两个想法是这种“穿过切割的最短边”原则的特殊情况:选择图中的最短边将是安全的,因为它在它参与的每个切割中都是最短的,选择与任何节点相关的最短边将是安全的,因为它是切割上的最短边,将该节点与图的其余部分分开。在下文中,我对这些想法进行了扩展,将它们变成了两个用于寻找最小生成树的成熟的贪婪算法。第一个(Kruskal 的)接近典型的贪婪算法,而第二个(Prim 的)使用遍历原则,并在顶部添加了贪婪选择。
其余的呢?
表现出第一个贪婪的选择是可以的是不够的。我们需要证明剩下的问题是同一问题的一个较小的实例——我们的归约可以安全地用于归纳。换句话说,我们需要建立最优的子结构。这并不太难(练习 7-12),但这里有另一种方法可能更简单:我们证明我们的解是最小生成树的一部分(一个子图)的不变量。只要解决方案不是生成树,我们就不断添加边(也就是说,只要还有边不会形成循环),所以如果这个不变量为真,算法必须以完整的最小生成树终止。
那么,不变量成立吗?最初,我们的部分解决方案是空的,这显然是一个部分的最小生成树。现在,归纳地假设我们已经建立了一些部分的最小生成树 T ,并且我们添加了一条安全边(也就是说,一条不产生循环并且是穿过一些切割的最短的边)。显然,新的结构仍然是一个森林(因为我们小心翼翼地避免创建循环)。同样,上一节中的推理仍然适用:在包含 T 的生成树中,包含该安全边的生成树将比不包含该安全边的生成树小。因为(根据假设),包含 T 的树中至少有一棵是最小生成树,包含 T 和安全边的树中至少有一棵也会是最小生成树。
克鲁斯卡尔算法
这种算法接近于本章开始时概述的一般贪婪方法:对边进行排序并开始挑选。因为我们在寻找短边,所以我们按照长度(或重量)的增加对它们进行排序。唯一的问题是如何检测会导致无效解决方案的边缘。使我们的解决方案无效的唯一方法是添加一个循环,但是我们如何检查呢?一个简单的解决方案是使用遍历;每当我们考虑一条边( u 、 v ),我们就从 u 开始遍历我们的树,看看是否有路径到达 v 。如果有,我们就丢弃它。不过,这似乎有点浪费;在最坏的情况下,遍历检查将花费我们部分解决方案的线性时间。
我们还能做什么?我们可以维护到目前为止我们的树中的一组节点,然后对于一个预期的边( u , v ),我们将查看两者是否都在解决方案中。这将意味着排序边缘是主导;检查每个边缘可以在恒定的时间内完成。这个计划只有一个致命的缺陷:行不通。如果我们能保证部分解在每一步都是连接的,那么将会起作用(这就是我们在 Prim 算法中要做的),但是我们不能。因此,即使两个节点是我们目前解决方案的一部分,它们可能在不同的树中,连接它们将是完全有效的。我们需要知道的是它们不在同一个树中。**
让我们通过让解决方案中的每个节点知道它属于哪个组件(树)来尝试解决这个问题。我们可以让组件中的一个节点作为代表、,然后组件中的所有节点都可以指向这个代表。这就留下了组合组件的问题。如果合并组件的所有节点都必须指向同一个代表,那么这个组合(或联合)将是一个线性操作。我们能做得更好吗?我们可以试试;例如,我们可以让每个节点指向另一个节点,我们将沿着这个链直到到达代表(它将指向它自己)。然后,加入只是一个代表点指向另一个代表点的问题(恒定时间)。没有直接的保证证明链会有多长,但至少这是第一步。
这就是我在清单 7-3 中所做的,使用地图C
来实现“指向”正如你所看到的,每个节点最初是它自己的组件的代表,然后我重复地用新的边连接组件,按照排序的顺序。请注意,我实现的方式是,我期望一个无向图,其中每条边只表示一次(也就是说,使用它的一个方向,任意选择)。和往常一样,我假设图中的每个节点都是一个键,尽管可能有一个空的权重图(也就是说,如果u
没有外边缘,那么就是G[u] = {}
)。
清单 7-3 。克鲁斯卡尔算法的简单实现
def naive_find(C, u): # Find component rep.
while C[u] != u: # Rep. would point to itself
u = C[u]
return u
def naive_union(C, u, v):
u = naive_find(C, u) # Find both reps
v = naive_find(C, v)
C[u] = v # Make one refer to the other
def naive_kruskal(G):
E = [(G[u][v],u,v) for u in G for v in G[u]]
T = set() # Empty partial solution
C = {u:u for u in G} # Component reps
for _, u, v in sorted(E): # Edges, sorted by weight
if naive_find(C, u) != naive_find(C, v):
T.add((u, v)) # Different reps? Use it!
naive_union(C, u, v) # Combine components
return T
天真的克鲁斯卡尔很管用,但并不那么好。(什么,名字泄露了?)在最坏的情况下,我们需要在naive_find
中遵循的引用链可能是线性的。一个相当明显的想法可能是总是让naive_union
中两个组件的较小的指向较大的,给我们一些平衡。或者我们可以从平衡树的角度考虑更多,给每个节点一个等级或高度。如果我们总是让最低位的代表点指向最高位的代表点,那么调用naive_find
和naive_
联合的总运行时间为O(mLGn)(见练习 7-16)。**
这实际上没什么问题,因为排序操作的起点是θ(mLGn*)。 12 不过,这个算法中还有一个常用的技巧,叫做路径压缩。它需要在执行find
时“拉动指针”,确保我们在途中检查的所有节点现在都直接指向代表。直接指向代表的节点越多,后面的find
s 中的事情应该进行得越快,对吗?可悲的是,这到底如何以及为什么会有帮助背后的原因对我来说太复杂了,我无法在这里深入讨论(尽管我会推荐 Sect。21.4 在 Cormen 等人的算法简介中,如果你感兴趣的话)。不过最终的结果是,union
s 和find
s 最坏情况下的总运行时间是O(mα(n),其中 α ( n )是几乎一个常数。事实上,你可以假设 α ( n ) ≤ 4,对于 n 的任何一个看似遥远的值。关于find
和union
的改进实现,参见清单 7-4 。
清单 7-4 。克鲁斯卡尔算法
def find(C, u):
if C[u] != u:
C[u] = find(C, C[u]) # Path compression
return C[u]
def union(C, R, u, v):a
u, v = find(C, u), find(C, v)
if R[u] > R[v]: # Union by rank
C[v] = u
else:
C[u] = v
if R[u] == R[v]: # A tie: Move v up a level
R[v] += 1
def kruskal(G):
E = [(G[u][v],u,v) for u in G for v in G[u]]
T = set()
C, R = {u:u for u in G}, {u:0 for u in G} # Comp. reps and ranks
for _, u, v in sorted(E):
if find(C, u) != find(C, v):
T.add((u, v))
union(C, R, u, v)
return T
总而言之,克鲁斯卡尔算法的运行时间是θ(mLGn),这个时间来自于排序。
请注意,您可能希望以不同的方式表示生成树(即,不是边的集合)。在这方面,算法应该很容易修改——或者你可以基于边集T
构建你想要的结构。
注意克鲁斯卡尔算法中使用的子问题结构是一个拟阵的例子,其中可行的部分解是简单的集合——在这种情况下,是无圈边集。对于拟阵来说,贪婪是有用的。规则如下:可行集的所有子集也必须是可行的,较大的集合必须有可以扩展较小集合的元素。
普里姆算法
克鲁斯卡尔的算法在概念层面上很简单——它是对生成树问题的贪婪方法的直接翻译。正如您刚才看到的,有效性检查有些复杂。在这方面,Prim 的算法要简单一点。13Prim 算法的主要思想是从一个起始节点开始遍历图,总是添加连接到树的最短边。这是安全的,因为如前所述,该边将是穿过我们的部分解决方案的切口的最短的一条边。
这意味着 Prim 的算法只是另一种遍历算法,如果你读过《??》第五章,这应该是一个熟悉的概念。正如在那一章中所讨论的,遍历算法之间的主要区别是我们的“待办事项”列表的排序——在我们发现的未访问节点中,我们将遍历树扩展到下一个节点?在广度优先搜索中,我们使用了一个简单的队列(即一个deque
);在 Prim 的算法中,我们简单地用一个用堆实现的优先级队列、替换这个队列,使用heapq
库(在第六章的的“黑盒”侧栏中讨论)。
然而,这里有一个重要的问题:最有可能的是,我们将发现指向已经在我们的队列中的节点的新边。如果我们发现的新边比前一个边短,我们应该根据这个新边调整优先级*。然而,这可能相当麻烦。我们需要在堆中找到给定的节点,改变优先级,然后重新构造堆,使其仍然正确。您可以通过从每个节点到它在堆中的位置的映射来做到这一点,但是在执行堆操作时您必须更新该映射,并且您不能再使用heapq
库。*
不过,事实证明还有另一种方法。一个非常好的解决方案,也适用于其他基于优先级的遍历(如 Dijkstra 算法和 A*,在第九章的中讨论),就是简单地多次添加节点*。每次你找到一个节点的边,你就以适当的权重将该节点添加到堆(或其他优先级队列)中,并且你不关心它是否已经在那里。为什么这可能行得通?*
** 我们使用的是优先级队列,所以如果一个节点被添加了多次,当我们删除它的一个条目时,它将是权重最低的那个(那时),也就是我们想要的那个。
- 我们确保不会将同一个节点多次添加到我们的遍历树中。这可以通过恒定时间的成员检查来确保。因此,任何给定节点的所有队列条目都将被丢弃,只有一个除外。
- 多次加法不会影响渐近运行时间(见练习 7-17)。
对实际运行时间也有重要的影响。(更)简单的代码不仅更容易理解和维护;它的开销也少了很多。因为我们可以使用超快的heapq
库,最终结果很可能是性能的大幅提升。(如果你想尝试更复杂的版本,这在许多算法书籍中都有使用,当然欢迎你。)
注重新添加一个权重较低的节点相当于一个松弛,如第四章所述。正如您将看到的,我还将 predecessor 节点添加到队列中,使得任何显式的放松都是不必要的。然而在第九章实现 Dijkstra 的算法时,我使用了一个单独的
relax
函数。这两种方法是可以互换的(所以你可以让 Prim 的带??,Dijkstra 的不带??)。
你可以在清单 7-5 的中看到我版本的 Prim 算法。因为heapq
还不像list.sort
和 friends 那样支持排序键,所以我在堆中使用(weight, node)
对,当节点弹出时丢弃权重。除了使用堆之外,代码类似于清单 5-10 中广度优先搜索的实现。这意味着这里的许多理解应该是免费的。
清单 7-5 。普里姆算法
from heapq import heappop, heappush
def prim(G, s):
P, Q = {}, [(0, None, s)]
while Q:
_, p, u = heappop(Q)
if u in P: continue
P[u] = p
for v, w in G[u].items():
heappush(Q, (w, u, v))
return P
注意,与kruskal
不同,在清单 7-4 中,清单 7-5 中的prim
函数假设图G
是一个无向图,其中和两个方向都被显式表示,因此我们可以很容易地在两个方向上遍历每条边。 14
与 Kruskal 的算法一样,您可能希望用不同于我在这里所做的方式来表示生成树。重写这部分应该很容易。
注意Prim 算法中使用的子问题结构是 greedoid 的一个例子,它是拟阵的简化和概括,其中我们不再要求可行集的所有子集都是可行的。可悲的是,拥有一个 greedoid 本身并不能保证贪婪会奏效——尽管这是朝着正确方向迈出的一步。
略有不同的视角
在他们对最小生成树算法的历史概述中,Ronald L. Graham 和 Pavol Hell 概述了三种他们认为特别重要并且在该问题的历史中起到核心作用的算法。前两种算法通常归属于 Kruskal 和 Prim(尽管第二种算法最初是由 vojtch jarník 在 1930 年制定的),而第三种算法最初是由 Boru˚ vka 描述的。格雷厄姆和赫尔简明扼要地解释了算法如下。部分解决方案是一个生成森林,由一组片段(组件,树)组成。最初,每个节点都是一个片段。在每一次迭代中,边被添加,连接片段,直到我们有一个生成树。
算法 1: 添加连接两个不同片段的最短边。
算法 2: 添加一条最短的边,将包含根的片段连接到另一个片段。
算法 3: 对于每一个片段,添加连接它和另一个片段的最短边。
对于算法 2,在开始时任意选择根。对于算法 3,假设所有的边权重都不同,以确保不会出现循环。如您所见,所有三种算法都基于相同的基本事实,即切割的最短边是安全的。此外,为了有效地实现它们,您需要能够找到最短的边,检测两个节点是否属于同一个片段,等等(如正文中对算法 1 和 2 的解释)。尽管如此,这些简短的解释还是有助于记忆,或者有助于鸟瞰正在发生的事情。
贪婪起作用。但是什么时候?
虽然归纳法通常被用来证明贪婪算法是正确的,但是还有一些额外的“技巧”可以使用。我已经在这一章中使用了一些,但在这里我将尝试给你一个概述,使用一些涉及时间间隔的简单问题。事实证明,有很多这种类型的问题可以通过贪婪算法来解决。我不包括这些的代码;实现非常简单(尽管实际实现它们可能是一个有用的练习)。
跟上最好的
这就是 Kleinberg 和 Tardos(在算法设计中)所说的保持领先。这个想法是为了表明,当你一步一步地构建你的解决方案时,贪婪算法将总是得到至少与假设的最优算法会得到的一样远。一旦你到达终点,你就证明了贪婪是最理想的。这种技术在解决一个常见的贪婪问题时很有用:资源调度。
该问题涉及选择一组兼容的间隔。通常,我们认为这些间隔是时间间隔(见图 7-4 )。兼容性仅仅意味着它们不应该重叠,因此这可以用于对在特定时间段内使用资源(如演讲厅)的请求进行建模。另一个例子是让 you 成为“资源”,让时间间隔成为你想参加的各种活动。无论哪种方式,我们的优化任务是选择尽可能多的相互兼容(不重叠)的区间。为了简单起见,我们可以假设没有起点或终点是相同的。处理相同的值并不困难。
图 7-4 。一组随机区间,其中最多可以找到四个相互兼容的区间(例如 a、c、e 和 g)
这里有两个明显的贪婪选择的候选:如果我们在时间轴上从左到右,我们可能想要从首先开始的间隔或者首先结束的间隔开始,消除任何其他重叠的间隔。我希望很清楚,第一个选择是行不通的(练习 7-18),这就让我们来证明另一个行不通。
算法(大致)如下:
- 在解决方案中包括完成时间最短的间隔。
- 移除与步骤 1 中的间隔重叠的所有剩余间隔。
- 还有剩余的间隔吗?转到步骤 1。
在图 7-4 的中设置的间隔上运行该算法,得到高亮显示的一组间隔( a 、 c 、 e 和 g )。由此产生的解决方案显然是有效的;也就是说,其中没有任何重叠的音程。这将是一般情况;我们只需要证明它是最优的,也就是说,我们有尽可能多的区间。让我们尝试应用保持领先的理念。
假设我们的区间按照相加的顺序是:I1…Ik,假设最优解给出了区间j1…jm。我们想表明,k = m 。假设最佳间隔按结束(和开始)时间排序。 15 为了说明我们的算法保持在最优算法的前面,我们需要说明对于任意的 r ≤ k , i r 的结束时间至少早于 j r 的结束时间,我们可以用归纳法证明这一点。
对于 r = 1,显然是正确的:贪婪算法选择 i 1 ,这是完成时间最短的元素。现在,让 r > 1,并假设我们的假设对 r - 1 成立。那么问题就变成了贪婪算法在这一步是否有可能“落后”。也就是说, i r 的完成时间现在有可能大于 j r 的完成时间吗?答案显然是否定的,因为贪婪算法也可以选择 j r (它与jr-1兼容,因此也与Ir-1兼容,后者至少完成得更早)。
所以,贪婪算法跟上最好,一直到最后。然而,这种“保持”只涉及结束时间,而不是间隔的数量。我们需要证明跟上会产生最优解,我们可以通过矛盾来做到:如果贪婪算法是不是最优,那么 m > k 。对于每一个 r ,包括 r = k ,我们知道 i r 至少早于 j r 完成。因为 m > k 一定有一个区间jr+1我们没有用。这必须在 j r 之后开始,因此在 i r 之后开始,这意味着我们可以拥有——事实上,会拥有 — 包含它。换句话说,我们有一个矛盾。
不比完美差
这是我在展示霍夫曼算法的贪婪选择属性时使用的一种技术。它包括展示你可以将一个假设的最优解转化为贪婪的解,而不会降低质量。克莱恩伯格和塔多斯称之为交换论点。让我们来扭转一下音程问题。不再有固定的开始和结束时间,我们现在有一个持续时间和一个截止时间,你可以自由安排时间间隔——让我们称之为任务——只要它们不重叠。当然,你也有一个给定的开始时间。
然而,任何超过期限的任务都会招致与其延迟相等的惩罚,并且您希望最小化这些延迟的最大值。从表面上看,这似乎是一个相当复杂的调度问题(事实上,许多调度问题真的很难解决)。然而,令人惊讶的是,你可以通过一个超级简单的贪婪策略找到最佳时间表:总是执行最紧急的任务。对于贪婪算法来说,正确性证明比算法本身更难。
贪婪的解决方案没有漏洞。我们一完成一项任务,就开始下一项。还会有至少一个没有缺口的最优解——如果我们有一个有缺口的最优解*,我们总是可以将它们封闭起来,导致后面的任务提前完成。此外,贪婪解决方案将没有反转(在其他具有更早截止时间的作业之前调度的作业)。我们可以证明,所有没有间隙或反转的解都具有相同的最大延迟。这两种解决方案的区别仅在于具有相同期限的任务的顺序,并且这些任务必须被连续安排。在这样的连续块中的任务中,最大延迟仅取决于最后一个任务,并且该延迟不取决于任务的顺序。*
唯一有待证明的是,存在一个没有间隙或反演的最优解,因为它与贪婪解是等价的。这个证明有三个部分:
- 如果最优解有反转,则有两个连续的任务,其中第一个任务的截止日期比第二个晚。
- 切换这两个可以消除一个反转。
- 消除这种反转不会增加最大延迟。
第一点应该够明显了。在两个反向任务之间,一定有某个时间点,截止日期开始减少,给我们两个连续的反向任务。至于第二点,交换任务显然去除了一个倒置,并且没有新的倒置产生。第三点需要一点小心。交换任务 i 和 j (所以 j 现在先来)可能会潜在地增加只有 i 的迟到;所有其他任务都是安全的。在新的时间表中, i 完成之前 j 完成的地方。因为(假设)i 的截止日期比 j 的截止日期晚晚,所以延迟不可能增加。这样,证明的第三部分就完成了。
应该清楚的是,这些部分一起示出了贪婪调度最小化最大延迟。
保持安全
这是我们开始的地方:为了确保贪婪算法是正确的,我们必须确保过程中的每个贪婪步骤都是安全的。这样做的一种方式是两部分方法,显示(1)贪婪选择属性,即贪婪选择与最优性兼容,以及(2)最优子结构,即剩余的子问题是一个较小的实例,也必须以最优方式解决。例如,贪婪选择属性可以使用交换参数来显示(就像对霍夫曼算法所做的那样)。
另一种可能性是将安全视为不变量。或者,用 Michael Soltys 的话说(参见第四章的“参考资料”部分),我们需要证明,如果我们有一个有希望的部分解决方案,贪婪的选择将产生一个新的、更大的解决方案,也就是有希望的。如果部分解决方案可以扩展为最优解决方案,则它是有希望的。这是我在“其余的怎么办?”一节中采用的方法本章前面;在那里,一个解决方案是有希望的,如果它包含在(因此,可以扩展到)一个最小生成树中。显示“当前的部分解决方案是有希望的”是贪婪算法的一个不变量,因为你一直在做出贪婪的选择,这是你真正需要的。
让我们考虑最后一个涉及时间间隔的问题。问题很简单,算法也很简单,但正确性证明相当复杂。 16 它可以作为一个例子来说明一个相对简单的贪婪算法是正确的。
这一次,我们再次有了一组有截止日期的任务,以及一个开始时间(比如现在)。然而,这一次,这些都是很难的最后期限——如果我们不能在最后期限前完成任务,我们就根本不能接受它。此外,每个任务都有一个给定的利润与之关联。像以前一样,我们一次只能执行一项任务,我们不能把它们分成几部分,所以我们在寻找一组我们实际上能做的工作,这给我们带来尽可能大的总利润。然而,为了简单起见,这一次所有的任务花费相同的时间——一个时间步长。如果 d 是最晚的截止日期,以从起点开始的时间步长来衡量,我们可以从一个空的调度开始,空出 d 个时间段,然后用任务填充这些时间段。
这个问题的解决方案在某种程度上是加倍贪婪。首先,我们从利润最大的任务开始,考虑利润递减的任务;这是第一个贪婪的部分。接下来是第二部分:我们根据任务的截止日期,将每个任务放在它能占用的最晚的空闲位置。如果没有空闲的、有效的槽,我们就放弃这个任务。一旦我们完成了,如果我们还没有填满所有的空位,我们当然可以提前执行任务,以便消除差距——这不会影响利润或允许我们执行任何更多的任务。为了感受这个解决方案,你可能想实际实现它(练习 7-20)。
这个解决方案听起来很有吸引力;我们优先考虑有利可图的任务,并通过尽可能将它们推向截止日期,确保它们使用最少的我们宝贵的“早期时间”。但是,再说一次,我们不会依赖直觉。我们将使用一点归纳法,表明当我们以这种贪婪的方式添加任务时,我们的时间表仍然是有希望的。
注意下面的演示不涉及任何深奥的数学或火箭科学,更多的是非正式的解释,而不是完整的技术证明。尽管如此,这有点复杂,可能会伤害你的大脑。如果你觉得不能胜任,可以直接跳到章节摘要。
一如既往,最初的空解决方案是有希望的。在超越基本情况的过程中,重要的是要记住,只有使用剩余的任务、将时间表扩展为最佳时间表*,时间表才是真正有希望的,因为这是我们被允许扩展时间表的唯一方式。现在,假设我们有一个有希望的部分时间表 p,它的一些位置被填满,一些没有。P 是有希望的这一事实意味着它可以扩展到一个最优的时间表—让我们称之为 s。另外,让我们假设 T 是正在考虑的下一个任务。*
我们现在有四种情况要考虑:
- t 放不下 P,因为截止日期前没房了。在这种情况下,T 影响不了什么,所以一旦 T 被丢弃,P 还是有希望的。
- t 将会放入 P 中,它的最终位置与 S 中的位置相同,在这种情况下,我们实际上是向 S 延伸,所以 P 仍然是有希望的。
- t 会合适,但最终会到别的地方。这似乎有些麻烦。
- t 会适合,但是 S 不包含。也许更麻烦的是。
很明显,我们需要解决最后两种情况,因为它们似乎离最优调度 s 越来越远。事实是,可能有不止一个最优调度——我们只需要表明,在添加 T 之后,我们仍然可以到达其中的一个*。*
首先,让我们考虑这样一种情况,我们贪婪地添加 T,它并不在 S 中的相同位置,然后我们可以建立一个几乎像 S 的调度,除了 T 已经与另一个任务 T '交换了位置。让我们称这另一个时间表为 S。根据构造,T 尽可能晚地放置在 S '中的,这意味着它必须在 S '中早放置,相反,T '必须在 S 中晚放置,因此在 S '中早*。这意味着我们不能在构造 S '的时候破坏 T '的期限,所以这是一个有效的解决方案。此外,因为 S 和 S '包含相同的任务,利润必须相同。*
剩下的唯一情况是 T 是最优调度 S 中调度的而不是*。同样,让 S '几乎像 S 一样*。唯一的区别是我们已经用我们的算法调度 T,有效地“覆盖”了 S 中的一些其他任务 T'。我们没有违反任何截止日期,所以 S '是有效的。我们还知道,我们可以从 P 到 S '(通过几乎遵循到达 S 所需的步骤,只是用 T 代替 T ')。*
*最后一个问题就变成了,S '和 S 有一样的利润吗?我们可以通过矛盾来证明这一点。假设 T '比 T 有更大的利润,这是 S 能有更高利润的唯一方法。如果是这种情况,贪婪算法将在 T 之前考虑 T’。由于在 T’的截止日期之前至少有一个空闲时隙,贪婪算法将调度它,必然在与 T 不同的位置,因此在与 S 不同的位置。但是我们假设我们可以将 P 扩展到 S,如果它在不同的位置有任务,我们就有矛盾。
摘要
贪婪算法的特点是如何做决定。在逐步构建解决方案的过程中,每个添加的元素都是在添加时看起来最好的*,而不考虑之前发生了什么或之后会发生什么。这种算法通常很容易设计和实现,但是要证明它们是正确的(也就是最优的)通常是具有挑战性的。一般来说,你需要证明做出贪婪的选择是安全的——如果你的解决方案是有希望的,也就是说,它可以扩展到一个最优方案,那么贪婪选择后的方案是也是有希望的。总的原则,一如既往,是归纳法,虽然有一些更专业的想法可能是有用的。例如,如果你可以证明一个假设的最优解可以被修改成贪婪解而不损失质量,那么贪婪解就是最优的。或者,如果你能证明在解决方案构建过程中,贪婪的部分解决方案在某种意义上能跟上一个假设的最优解决方案序列,一直到最终的解决方案,你可以(稍微小心一点)用它来证明最优。*
*本章讨论的重要贪婪问题和算法包括背包问题(选择具有最大值的项目的重量有界子集),其中分数版本可以被贪婪地解决;霍夫曼树,可用于创建最佳前缀码,并通过组合部分解决方案中的最小树来贪婪地构建;以及最小生成树,可以使用 Kruskal 的算法(保持添加最小的有效边)或 Prim 的算法(保持连接离你的树最近的节点)来构建。
如果你好奇的话…
关于贪婪算法有一个很深的理论,我在这一章中还没有真正触及,它涉及到拟阵、拟阵和所谓的拟阵嵌入。虽然拟阵的东西有点难,而且拟阵嵌入的东西会很快变得令人困惑,但拟阵并不真的那么复杂,它们对一些贪婪的问题提供了一个优雅的视角。(拟阵更一般,拟阵嵌入是三者中最一般的,实际上涵盖了所有贪心问题。)关于拟阵的更多信息,你可以看看 Cormen 等人的书(参见第一章的“参考”部分)。
如果你对为什么做出改变的问题通常很难感兴趣,你应该看看第十一章的材料。如前所述,对于许多货币系统,贪婪算法工作得很好。David Pearson 设计了一种算法,用于检查任何给定货币的是否是这种情况;如果你感兴趣,你应该看看他的论文(参见“参考文献”)。
如果你发现你需要建立最小有向生成树,从某个开始节点分支,你不能使用 Prim 的算法。关于将用于寻找这些所谓的最小成本树状结构的算法的讨论可以在 Kleinberg 和 Tardos 的书中找到(参见第一章的“参考”部分)。
练习
7-1.举一组面额的例子,会打破给零钱的贪心算法。
7-2.假设你有面值是某个整数的幂的硬币 k > 1。为什么你能确定贪婪算法在这种情况下会起作用?
7-3.如果某个选择问题中的权重是 2 的唯一幂,贪婪算法通常会最大化权重和。为什么呢?
7-4.在稳定婚姻问题中,我们说两个人之间的婚姻,比如说杰克和吉尔,是可行的如果在杰克和吉尔结婚的地方存在稳定的配对。显示盖尔-沙普利算法将匹配每个男人与他的最高排名可行的妻子。
7-5.吉尔是杰克最合适的妻子。表明杰克是吉尔的最坏的可行的丈夫。
7-6.假设你想装进背包的各种东西是可以部分分割的。也就是说,你可以把它们在某些间隔均匀的点上分开(比如一块糖分成正方形)。不同的项目在它们的断裂点之间具有不同的间距。贪婪算法还能工作吗?
7-7.证明你从霍夫曼代码中得到的代码是没有歧义的。也就是说,当解码一个霍夫曼编码的文本时,你总是可以确定符号边界在哪里,哪些符号在哪里。
7-8.在霍夫曼树的贪婪选择性质的证明中,假设 a 和 d 的频率不同。如果他们不是呢?
7-9.表明一个坏的合并时间表可以给出一个更差的运行时间,渐近地,比一个好的,这真的取决于频率。
7-10.(连通)图在什么情况下可以有多棵最小生成树?
7-11.你将如何构建一棵最大生成树(也就是边权重和最大的树)?
7-12.证明最小生成树问题有最优子结构。
7-13.如果图不连通,克鲁斯卡尔的算法会发现什么?你如何修改 Prim 的算法来做同样的事情?
7-14.如果你在一个有向图上运行 Prim 的算法会发生什么?
7-15.对于平面上的 n 个点,没有算法能在最坏的情况下比 loglinear 更快地找到最小生成树(使用欧氏距离)。怎么会这样
7-16.展示如果使用 union by rank,对union
或find
的 m 调用的运行时间将为 O ( m lg n )。
7-17.展示当在遍历过程中使用二进制堆作为优先级队列时,每次遇到节点时添加一次不会影响渐进运行时间。
7-18.在从左到右选择一组区间的最大非重叠子集时,为什么不能使用基于开始次的贪婪算法?
7-19.寻找最大非重叠区间集的算法的运行时间是多少?
7-20.实现调度问题的贪婪解决方案,其中每个任务都有成本和严格的截止日期,并且所有任务都需要相同的时间来执行。
参考
盖尔和沙普利(1962)。大学录取和婚姻的稳定性。美国数学月刊,69(1):9-15。
格雷厄姆,R. L .和地狱,P. (1985 年)。最小生成树问题的历史。 IEEE 计算史上的年鉴,7(1)。
古斯菲尔德和欧文(1989 年)。稳定的婚姻问题:结构和算法。麻省理工学院出版社。
Helman,p .,Moret,B. M. E .和 Shapiro,H. D. (1993 年)。贪婪结构的精确刻画。 SIAM 离散数学杂志,6(2):274-283。
Knuth,D. E. (1996 年)。稳定婚姻及其与其他组合问题的关系:算法数学分析导论。美国数学学会。
Korte,B. H .,洛瓦斯,l .,和施拉德,r .(1991)希腊人。斯普林格出版社。
Nešetřil、米尔科瓦和 nešetřilová(2001 年)。奥塔卡尔·borůvka 论最小生成树问题:1926 年论文、评论、历史的翻译。离散数学,233(1-3):3-36。
皮尔逊博士(2005 年)。变更问题的多项式时间算法。运筹学快报,33(3):231-234。
1 不,不是跑去买漫画书。
2 这个版本问题的创意来自迈克尔·索尔提斯(参见第四章中的参考文献)。
3 为了安全起见,让我强调一下,这种贪婪的解决方案在一般情况下会而不是起作用,使用任意的一组权重。二的不同力量是这里的关键。
4 如果我们单独查看每个对象,这通常被称为 0-1 背包,因为我们可以从每个对象中取 0 或 1。
5 不仅零表示左还是右并不重要,哪些子树在左边,哪些在右边也不重要。打乱它们对解决方案的最优性没有影响。
6 如果heapq
库的未来版本允许你使用一个关键函数,比如在list.sort
中,你当然就不再需要这个元组包装了。
7 他们也可能有等于的权重/频率;这并不影响争论。
顺便问一下,你知道得克萨斯州霍夫曼的邮政编码是 77336 吗?
9 实际上,你可以把 Borůvka's 算法和普里姆的结合起来,得到一个更快的算法。
10 只要我们假设边权重为正,你明白为什么结果不能包含任何循环了吗?
在这种表示和一种两边都有边的表示之间来回穿梭并不困难,但我会把细节留给读者作为练习。
12 我们在排序 m 边,但是我们也知道 m 是 O ( n 2 ),而且(因为图是连通的), m 是ω(n)。因为θ(LGn2)=θ(2 . LGn)=θ(LGn)所以我们得到结果。
13 其实,差异是骗人的。Prim 的算法基于遍历和堆——我们已经讨论过这些概念——而 Kruskal 的算法引入了一种新的不相交集机制。换句话说,简单性的差异主要是视角和抽象的问题。
14 正如我在讨论克鲁斯卡尔算法时提到的,添加和删除这样的冗余反向边是相当容易的,如果你需要这样做的话。
15 因为区间不重叠,所以按起止时间排序是等价的。
16 这个问题的版本可以在 Soltys 的书里找到(见第四章的“参考文献”)和 Cormen 等人的书里找到(见第一章的“参考文献”)。我的证明严格遵循 Soltys 的,而 Cormen 等人选择证明问题形成一个拟阵,这意味着贪婪算法将对它起作用。******
八、纠结的依赖和记忆
两次 ,adv .一次过于频繁。
——比尔斯,A·,魔鬼的字典
你们中的许多人可能知道 1957 年是编程语言的诞生年。对于算法学家来说,今年发生了一件可能更有意义的事情:理查德·贝尔曼出版了他的开创性著作动态编程。虽然贝尔曼的书本质上主要是数学的,根本不是真正针对程序员的(考虑到时间,也许可以理解),但他的技术背后的核心思想为许多非常强大的算法奠定了基础,它们形成了任何算法设计者都需要掌握的坚实的设计方法。
术语动态编程(或者简称为 DP)对于新手来说可能有点混乱。这两个词的用法与大多数人想象的不同。这里的编程指的是做出一系列选择(如“线性编程”),因此与这个术语在电视上的用法比在编写计算机程序上更为相似。动态仅仅意味着事物会随着时间而变化——在这种情况下,每个选择都取决于前一个选择。换句话说,这种“动态主义”与您将要编写的程序没有什么关系,只是对问题类的描述。用贝尔曼自己的话说,“我认为动态编程是一个好名字。这是连国会议员都不会反对的事情。所以我把它当成了我活动的保护伞。” 2
当应用于算法设计时,DP 的核心技术是缓存。你像以前一样递归地/归纳地分解你的问题——但是你允许子问题之间的重叠。这意味着一个简单的递归解决方案可以很容易地达到每个基本情况的指数次数;然而,通过缓存这些结果,这种指数级的浪费可以被削减掉,并且结果通常是令人印象深刻的高效算法和对问题更深入的洞察。
通常,DP 算法颠倒递归公式,使其迭代并逐步填充一些数据结构(如多维数组)。另一种选择——我认为特别适合 Python 等高级语言——是直接实现递归公式,但缓存返回值。如果使用相同的参数进行多次调用,结果将直接从缓存中返回。这被称为记忆化。
注意尽管我认为记忆化使 DP 的基本原理变得清晰,但我在整章中始终如一地重写迭代程序的记忆化版本。虽然记忆化是很好的第一步,可以让您获得更多的洞察力和原型解决方案,但是有些因素(例如有限的堆栈深度和函数调用开销)在某些情况下可能会使迭代解决方案更可取。
DP 的基本思想非常简单,但是需要一点时间来适应。根据另一位这方面的权威埃里克·v·德纳多的说法,“大多数初学者觉得它们都很奇怪,很陌生。”我会尽最大努力坚持核心思想,不迷失在形式主义中。此外,通过将重点放在递归分解和记忆化上,而不是迭代 DP 上,我希望到目前为止我们在本书中所做的所有工作的联系应该非常清楚。
在进入本章之前,这里有一个小难题:假设你有一个数字序列,你想找到它的最长的递增(或者,更确切地说是非递减 ) 子序列——或者其中的一个,如果有更多的话。子序列由按原始顺序排列的元素子集组成。因此,例如,在序列[3, 1, 0, 2, 4]
中,一个解将是[1, 2, 4]
。在清单 8-1 中,你可以看到这个问题的一个相当紧凑的解决方案。它使用高效的内置函数来完成工作,比如来自itertools
和sorted
的combinations
,所以开销应该很低。然而,该算法是一个简单的强力解决方案:生成每个子序列,并逐个检查它们是否已经排序。在最坏的情况下,这里的运行时间显然是指数级的。
写一个强力解决方案对理解问题是有用的,甚至可能有助于获得一些更好算法的想法;如果你能找到几种改进的方法,我不会感到惊讶。然而,实质性的改进可能有点困难。比如,你能找到一个二次算法吗(有点挑战性)?对数线性的呢(相当难)?一会儿我会告诉你怎么做。
清单 8-1 。最长增长子序列问题的一个简单解法
from itertools import combinations
def naive_lis(seq):
for length in range(len(seq), 0, -1): # n, n-1, ... , 1
for sub in combinations(seq, length): # Subsequences of given length
if list(sub) == sorted(sub): # An increasing subsequence?
return sub # Return it!
不要重复你自己
你可能听说过干原则:不要重复自己。它主要用于你的代码,意思是你应该避免多次编写相同(或几乎相同)的代码,依靠各种形式的抽象来避免剪切粘贴编码。这当然是编程最重要的基本原则之一,但这不是我在这里谈论的内容。本章的基本思想是避免你的算法重复出现。这个原理非常简单,甚至很容易实现(至少在 Python 中是这样),但是随着我们的进展,您将会看到这里的魔力非常深刻。
但是让我们从几个经典开始:斐波那契数和帕斯卡三角。你可能以前遇到过这些,但是“每个人”都使用它们的原因是它们很有教育意义。不要害怕——我将在这里对解决方案进行扭曲,我希望这对大多数人来说是新的。
Fibonacci 数列被递归地定义为从两个 1 开始,随后的每一个数都是前两个数的和。这很容易实现为一个 Python 函数 3 :
>>> def fib(i):
... if i < 2: return 1
... return fib(i-1) + fib(i-2)
让我们试一试:
>>> fib(10)
89
似乎是正确的。让我们大胆一点:
>>> fib(100)
啊哦。好像挂了。显然出了问题。我将会给你一个解决方案,对于这个特殊的问题来说,这个解决方案绝对是多余的,但是实际上你可以用它来解决本章中的所有问题。它是清单 8-2 中的简洁的小memo
函数。这个实现使用嵌套的作用域来给包装的函数提供内存——如果你愿意,你可以很容易地使用带有cache
和func
属性的类来代替。
注意在 Python 标准库的
functools
模块中其实有一个等价的 decorator,叫做lru_cache
(从 Python 3.2 开始可用,或者在 Python 2.7 4 的functools32
包中)。如果将它的maxsize
参数设置为None
,它将作为一个完整的记忆装饰器工作。它还提供了一个cache_clear
方法,您可以在算法的两次使用之间调用它。
清单 8-2 。纪念装饰家
from functools import wraps
def memo(func):
cache = {} # Stored subproblem solutions
@wraps(func) # Make wrap look like func
def wrap(*args): # The memoized wrapper
if args not in cache: # Not already computed?
cache[args] = func(*args) # Compute & cache the solution
return cache[args] # Return the cached solution
return wrap # Return the wrapper
在进入memo
实际做什么之前,让我们试着使用它:
>>> fib = memo(fib)
>>> fib(100)
573147844013817084101
嘿,成功了!但是……为什么呢?
一个记忆的函数 5 的想法是缓存它的返回值。如果您用相同的参数再次调用它,它将简单地返回缓存的值。您当然可以将这种缓存逻辑放在您的函数中,但是memo
函数是一个更可重用的解决方案。它甚至被设计成用作装饰器 6 :
>>> @memo
... def fib(i):
... if i < 2: return 1
... return fib(i-1) + fib(i-2)
...
>>> fib(100)
573147844013817084101
正如你所看到的,简单地用@memo
标记fib
就可以大大减少的运行时间。我仍然没有真正解释如何或为什么。
事情是,斐波纳契数列的递归公式有两个子问题,它有点像看起来像一个分治的事情。主要区别在于子问题有纠缠不清的依赖关系。或者,换句话说,我们面临和重叠的子问题。这在斐波纳契数的这个相当愚蠢的相对关系中可能更加清楚:2 的幂的递归公式:
>>> def two_pow(i):
... if i == 0: return 1
... return two_pow(i-1) + two_pow(i-1)
...
>>> two_pow(10)
1024
>>> two_pow(100)
还是很恐怖。试试加@memo
,瞬间得到答案。或,你可以尝试做如下的改变,这实际上相当于的:
>>> def two_pow(i):
... if i == 0: return 1
... return 2*two_pow(i-1)
...
>>> print(two_pow(10))
1024
>>> print(two_pow(100))
1267650600228229401496703205376
我已经将递归调用的数量从两个减少到一个,从指数运行时间减少到线性运行时间(分别对应于递归 3 和 1,来自表 3-1 )。神奇的是,这相当于记忆化版本的功能。第一个递归调用将正常执行,一直到底部(i == 0
)。然而,在此之后的任何调用都将直接进入缓存,只给出恒定量的额外工作。图 8-1 说明了不同之处。如你所见,当多个层次上存在重叠的子问题(即相同数量的节点)时,冗余计算会迅速变成指数级。
图 8-1 。递归树显示记忆化的影响。节点标签是子问题参数
我们来解决一个稍微有用一点的问题 7 :计算二项式系数(见第三章)。 C ( n , k )的组合意义是你可以从一组大小为 n 的集合中得到的 k 大小的子集的个数。第一步,几乎总是,是寻找某种形式的简化或递归分解。在这种情况下,我们可以使用一个你在使用动态编程 8 时会多次看到的想法:我们通过以某个元素是否被包含为条件来分解问题。也就是说,如果元素包含在中,我们得到一个递归调用,如果元素不包含在中,我们得到另一个递归调用。(你知道如何用这种方式解读two_pow
吗?参见练习 8-2。)
为了做到这一点,我们通常认为元素是有序的,因此对 C ( n , k )的单个评估只会担心是否应该包括元素编号 n 。如果包含在中,我们就要统计剩余 n -1 个元素的 k -1 个大小的子集,简单来说就是 C ( n -1, k -1)。如果不包括,我们就必须寻找大小为 k 或 C ( n -1, k )的子集。换句话说:
此外,我们还有以下基本情况: C ( n ,0) = 1 用于单个空集, C (0, k ) = 0, k > 0 用于空集的非空集。
这种递归公式对应于通常被称为“??”的帕斯卡三角(??)(以其发现者之一布莱士·帕斯卡的名字命名),尽管它是由伟大的中国数学家朱世杰在 1303 年首次发表的,他声称它是在第二个千年早期被发现的。图 8-2 展示了如何将二项式系数放置在一个三角形图案中,使得每个数字都是上面两个数字的和。这意味着行(从零开始计数)对应于 n ,而列(该行左边从零开始计数的单元格编号)对应于 k 。例如,值 6 对应于 C (4,2),可以计算为 C (3,1) + C (3,2) = 3 + 3 = 6。
图 8-2 。帕斯卡三角形
解释该模式的另一种方式(如图所示)是路径计数。如果你只向下走,越过虚线,从最上面的单元格到其他每个单元格,有多少条路径?这将我们引向相同的循环——我们可以从上面左边的单元格或者从上面右边的单元格。因此,路径的数量是两者之和。这意味着,如果你在向下的路上随机选择左/右,这些数字与通过它们的概率成正比。这正是在日本游戏弹球或 Plinko 中发生的事情价格合适。在那里,一个球从顶部落下,落在一些规则网格(例如图 8-2 中的六边形网格的交叉点)中的球钉之间。我将在下一节回到这个路径计数——它实际上比现在看起来更重要。
C ( n , k )的代码很琐碎:
>>> @memo
>>> def C(n,k):
... if k == 0: return 1
... if n == 0: return 0
... return C(n-1,k-1) + C(n-1,k)
>>> C(4,2)
6
>>> C(10,7)
120
>>> C(100,50)
100891344545564193334812497256
不过,你应该在有和没有@memo
的情况下都尝试一下,让自己相信这两个版本之间的巨大差异。通常,我们将缓存与一些常数因子加速联系起来,但这完全是另一个大概。对于我们将要考虑的大多数问题,记忆化意味着指数和多项式运行时间的不同。
注意本章中的一些记忆算法(特别是背包问题的算法,以及本节中的算法)是伪多项式,因为我们得到的多项式运行时间是输入中的数之一的函数,而不仅仅是它的大小。请记住,这些数字的范围与其编码大小(即用于编码的位数)呈指数关系。
事实上,在大多数动态编程的演示中,没有使用记忆函数。递归分解是算法设计的一个重要步骤,但它通常被视为一个数学工具,而实际实现是“颠倒的”——一个迭代版本。正如你所看到的,有了像@memo
decorator 这样的简单帮助,记忆化的解决方案可以变得非常简单,我认为你不应该回避它们。它们将帮助你摆脱讨厌的指数爆炸,而不会妨碍你漂亮的递归设计。
然而,正如之前所讨论的(在第四章,你可能有时想要重写你的代码以使它迭代。这可以使它更快,并且避免在递归深度过大时耗尽堆栈。还有另一个原因:迭代版本通常基于特殊构造的缓存,而不是我的@memo
中使用的通用“参数元组键化的字典”。这意味着您有时可以使用更有效的结构,比如 NumPy 的多维数组,可能与 Cython 结合使用(参见附录 A),或者甚至只是嵌套列表。这种定制的缓存设计使得在更低级的语言中使用 DP 成为可能,而像我们的@memo
装饰器这样的通用抽象解决方案通常是不可行的。请注意,尽管这两种技术经常一起使用,但是您当然可以自由地使用带有更通用缓存的迭代解决方案,或者为您的子问题解决方案使用带有定制结构的递归解决方案。
让我们把算法反过来,直接填写帕斯卡三角形。为了简单起见,我将使用一个defaultdict
作为缓存;例如,可以随意使用嵌套列表。(另请参见练习 8-4。)
>>> from collections import defaultdict
>>> n, k = 10, 7
>>> C = defaultdict(int)
>>> for row in range(n+1):
... C[row,0] = 1
... for col in range(1,k+1):
... C[row,col] = C[row-1,col-1] + C[row-1,col]
...
>>> C[n,k]
120
基本上同样的事情正在发生。主要的区别是,我们需要找出缓存中哪些单元格需要填充,我们需要找到一个安全的顺序来完成它,这样当我们将要计算C[row,col]
时,单元格C[row-1,col-1]
和C[row-1,col]
已经被计算了。有了记忆化的函数,我们就不用担心这两个问题:它会递归地计算任何需要的东西。
提示可视化带有一两个子问题参数的动态规划算法(比如这里的 n 和 k )的一个有用方法是使用一个(真实的或想象的)电子表格。例如,尝试在电子表格中计算二项式系数,方法是用 1 填充第一列,用 0 填充第一行的其余部分。将公式=A1+B1 放入单元格 B2,并将其复制到其余单元格。
有向无环图中的最短路径
动态规划的核心是顺序决策问题的思想。你做的每一个选择都会导致一个新的局面,你需要找到让你达到你想要的局面的最佳选择顺序。这类似于贪婪算法的工作方式——只是它们依赖于哪个选择现在看起来最好*,而一般来说,你必须不那么短视,并考虑未来的影响。*
*典型的顺序决策问题是在有向无环图中找到从一个节点到另一个节点的路径。我们将决策过程的可能状态表示为单个节点。外边缘代表我们在每种状态下可能做出的选择。边是有权重的,找到一组最优选择就相当于找到一条最短路径。图 8-3 给出了一个 DAG 的例子,其中从节点 a 到节点 f 的最短路径被突出显示。我们应该如何着手寻找这条道路呢?
图 8-3 。拓扑排序的有向无环图。边标有权重,从 a 到 f 的最短路径已经突出显示
应该清楚这是一个连续的决策过程。你从节点 a 开始,你可以选择沿着边到 b 或者沿着边到 f 。一方面,edge to b 看起来很有希望,因为它太便宜了,而 one to f 很诱人,因为它直奔目标。然而,我们不能采用这样简单的策略。例如,已经构建了该图,以便沿着我们访问的每个节点的最短边,我们将沿着最长的路径。
和前面几章一样,我们需要归纳思考。让我们假设我们已经知道我们可以移动到的所有节点的答案。假设从一个节点 v 到我们的终点节点的距离是 d ( v )。设 edge ( u , v )的边缘权重为 w ( u , v )。然后,如果我们在节点 u 中,我们已经(通过归纳假设)知道了每个邻居 v 的 d ( v ),所以我们只需要沿着边到邻居 v ,这最小化了表达式 w ( u ,v+d( 换句话说,我们最小化第一步和从那里开始的最短路径的总和。
当然,我们并不真的知道所有邻居的 d ( v )的值,但是对于任何电感设计来说,它会通过递归的魔力自行处理。唯一的问题是重叠的子问题。例如,在图 8-3 中,寻找从 b 到 f 的距离需要寻找从例如 d 到 f 的最短路径。但是找到从 c 到 f 的最短路径也是如此。我们的情况与斐波那契数列、two_pow
或帕斯卡三角形完全相同。如果我们直接实现递归求解,一些子问题将被求解指数倍。而对于那些问题,记忆化的魔力去除了所有冗余,我们最终得到了一个线性时间算法(也就是说,对于 n 节点和 m 边,运行时间为θ(n+m)。
在清单 8-3 的中可以找到一个直接的实现(使用类似边权重函数的 dicts 表示的 dicts)。如果您从代码中删除@memo
,您最终会得到一个指数算法(对于相对较小的几乎没有边的图来说,它可能仍然工作得很好)。
清单 8-3 。递归、记忆的 DAG 最短路径
def rec_dag_sp(W, s, t): # Shortest path from s to t
@memo # Memoize f
def d(u): # Distance from u to t
if u == t: return 0 # We're there!
return min(W[u][v]+d(v) for v in W[u]) # Best of every first step
return d(s) # Apply f to actual start node
在我看来,清单 8-3 中的实现相当优雅。它直接表达了算法的归纳思想,同时抽象出记忆。然而,这不是表示该算法的经典方式。像在许多其他 DP 算法中一样,这里通常做的是将算法“颠倒”并使其迭代。
DAG 最短路径算法的迭代版本通过一步一步地传播部分解决方案来工作,使用在第四章的中介绍的松弛思想。 9 由于我们表示图的方式(也就是说,我们通常通过出边而不是入边来访问节点),它可以有助于反转归纳设计:与其考虑我们想要去哪里,不如考虑我们想要来自哪里。然后,我们希望确保一旦到达节点 v ,我们已经从所有 v 的前任传播了正确答案。也就是说,我们已经放松了它的内边缘。这就提出了一个问题——我们如何确定我们已经做到了?
知道的方法是按拓扑排列节点,如图 8-3 中的所示。关于递归版本(在清单 8-3 中)的巧妙之处在于不需要单独的拓扑排序。递归隐式执行 DFS,并按照拓扑排序顺序自动执行所有更新*。但是,对于我们的迭代解决方案,我们需要执行单独的拓扑排序。如果你想完全摆脱递归,你可以使用清单 4-10 中的topsort
;如果你不介意,你可以使用清单 5-7 中的dfs_topsort
(尽管你已经非常接近记忆化递归解决方案)。清单 8-4 中的函数dag_sp
向你展示了这个更常见的迭代解决方案。*
*清单 8-4 。DAG 最短路径
def dag_sp(W, s, t): # Shortest path from s to t
d = {u:float('inf') for u in W} # Distance estimates
d[s] = 0 # Start node: Zero distance
for u in topsort(W): # In top-sorted order...
if u == t: break # Have we arrived?
for v in W[u]: # For each out-edge ...
d[v] = min(d[v], d[u] + W[u][v]) # Relax the edge
return d[t] # Distance to t (from s)
迭代算法的思想是,只要我们已经从你的每一个可能的前任(也就是那些在拓扑排序中较早的)中放松了每一条边出,我们必然已经放松了中的所有-边到你*。利用这一点,我们可以归纳地表明,当我们在外for
循环中到达它时,每个节点接收到正确的距离估计。这意味着一旦我们到达目标节点,我们将找到正确的距离。*
找到与这个距离相对应的实际路径也并不难(见练习 8-5)。你甚至可以从开始节点构建整个最短路径树,就像第五章中的遍历树一样。(但是,您必须删除break
语句,并一直进行到最后。)注意,一些节点,包括那些在拓扑排序顺序中早于开始节点的节点,可能根本不能到达,并且将保持它们的无限距离。
注意在这一章的大部分时间里,我专注于寻找一个解决方案的最优值,而不需要额外的簿记来重建产生那个值的解决方案。这种方法使演示更简单,但实际上可能不是您想要的。一些练习要求你扩展算法以找到实际的解;你可以在背包问题的最后找到一个例子。
各种 DAG 最短路径
虽然基本算法是相同的,但是有许多方法可以在 DAG 中找到最短路径,并且通过扩展,可以解决大多数 DP 问题。你可以递归地做,用记忆化,或者你可以迭代地做,用放松。对于递归,您可以从第一个节点开始,尝试各种“后续步骤”,然后对剩余部分进行递归,或者如果您的图形表示允许,您可以查看最后一个节点,尝试“之前的步骤”,然后对初始部分进行递归。前者通常更自然,而后者更接近迭代版本中发生的情况。
现在,如果你使用迭代版本,你也有两个选择:你可以从每个节点的中松弛出边*(按照拓扑排序的顺序),或者你可以将所有的边松弛到每个节点的中。后者更明显地产生正确的结果,但是需要通过向后跟随边来访问节点。当你用一个隐式 DAG 处理一些非图形问题时,这并不像看起来那么牵强。(例如,在本章后面讨论的最长增长子序列问题中,查看所有向后的“边”可能是一个有用的视角。)*
向外放松,称为达到,完全等同于你放松所有的边缘。如前所述,一旦你到达一个节点,它所有的内边都会被放松。然而,有了 reaching,你可以做一些递归版本中很难的事情(或者放松 in-edges):修剪。例如,如果您只对查找距离 r 以内的所有节点感兴趣,您可以跳过距离估计值大于 r 的任何节点。你仍然需要访问每一个节点,但是在放松的时候你可能会忽略很多边。不过,这不会影响渐进运行时间(练习 8-6)。
请注意,在 DAG 中查找最短的路径与查找最长的路径非常相似,甚至可以计算 DAG 中两个节点之间的路径的数量。后一个问题正是我们之前对帕斯卡三角所做的;同样的方法也适用于任意的 DAG。不过,对于一般的图形来说,这些事情就不那么容易了。在一般的图中寻找最短的路径有点困难(事实上,第九章专门讨论了这个话题),而寻找最长的路径是一个未解决的问题*(参见第十一章了解更多关于这个的信息)。*
最长增长子序列
尽管在 DAG 中寻找最短路径是典型的 DP 问题,但是您将遇到的许多 DP 问题(可能是大多数)与(显式)图没有任何关系。在这些情况下,您必须自己找出 DAG 或顺序决策过程。或者可能更容易从递归分解的角度考虑它,忽略整个 DAG 结构。在这一节中,我将遵循这两种方法来解决本章开头介绍的问题:寻找最长的非减子序列。(这个问题通常被称为“最长增长子序列”,但是我在这里允许结果中有多个相同的值。)
我们直接进行归纳,以后可以更多的用图的方式来思考。为了进行归纳(或递归分解),我们需要定义我们的子问题——许多 DP 问题的主要挑战之一。在许多与序列相关的问题中,从前缀的角度考虑可能是有用的——我们已经弄清楚了关于前缀我们需要知道的一切,归纳步骤是为另一个元素弄清楚事情。在这种情况下,这可能意味着我们已经为每个前缀找到了最长的递增子序列,但这还不够。我们需要加强我们的归纳假设,这样我们就可以实际执行归纳步骤。相反,让我们试着找出在每个给定位置结束于的最长的递增子序列。
如果我们已经知道如何为第一个 k 位置*,找到它,那么我们如何为位置 k + 1 找到它呢?一旦我们走到这一步,答案就非常简单了:我们只需查看以前的位置,并查看那些元素比当前位置小的位置。在这些序列中,我们选择位于最长子序列末尾的那个。直接递归实现会给我们带来指数级的运行时间,但是记忆化又一次摆脱了指数级的冗余,如清单 8-5 所示。再一次,我已经集中精力寻找解决方案的长度*;扩展代码以找到实际的子序列并不难(练习 8-10)。
清单 8-5 。最长增长子序列问题的记忆递归解法
def rec_lis(seq): # Longest increasing subseq.
@memo
def L(cur): # Longest ending at seq[cur]
res = 1 # Length is at least 1
for pre in range(cur): # Potential predecessors
if seq[pre] <= seq[cur]: # A valid (smaller) predec.
res = max(res, 1 + L(pre)) # Can we improve the solution?
return res
return max(L(i) for i in range(len(seq))) # The longest of them all
让我们也做一个迭代版本。在这种情况下,差别真的很小——很像图 4-3 中的镜像图。由于递归的工作方式,rec_lis
将按顺序(0,1,2 …)解决每个位置的问题。在迭代版本中,我们所需要做的就是用查找来切换递归调用,并将整个事情封装在一个循环中。参见清单 8-6 中的实现。
清单 8-6 。最长增长子序列问题的基本迭代解法
def basic_lis(seq):
L = [1] * len(seq)
for cur, val in enumerate(seq):
for pre in range(cur):
if seq[pre] <= val:
L[cur] = max(L[cur], 1 + L[pre])
return max(L)
我希望你能看到递归版本的相似之处。在这种情况下,迭代版本可能和递归版本一样容易理解。
现在,把它想象成一个 DAG:每个序列元素都是一个节点,并且从每个元素到后面每个更大的元素都有一条隐含的边——也就是说,到一个递增子序列中允许的后继元素(见图 8-4 )。瞧啊!我们现在正在解决 DAG 最长路径问题。这在basic_lis
函数中非常清楚。我们没有显式表示的边,所以它必须查看每个先前的元素,看它是否是有效的前置元素,但是如果是,它只是放松 in-edge(这就是带有max
表达式的行所做的)。我们是否可以通过在决策过程中使用这个“前一步”(即这个 in-edge 或这个有效的前任)来改进当前位置的解决方案? 10
图 8-4 。一个数字序列和隐含的 DAG,其中每条路径是一个递增的子序列。突出显示了一个最长的递增子序列
正如你所看到的,有不止一种方法来看待大多数 DP 问题。有时你想把重点放在递归分解和归纳上;有时你宁愿尝试嗅出一些 DAG 结构;有时候,再一次,看看你面前的东西是值得的。在这种情况下,这将是序列。这个算法仍然是二次的,你可能已经注意到了,我把它叫做基本 _lis
…那是因为我还有另一个锦囊妙计。
该算法中的主要时间消耗是检查先前的元素,以在那些有效的前置元素中找到最佳的。你会发现在一些 DP 算法中就是这种情况——内部循环致力于线性搜索。如果是这种情况,可能值得尝试用一个二进制搜索来代替它。在这种情况下,这怎么可能一点都不明显,但是简单地知道我们在寻找什么——我们试图做什么——有时会有所帮助。我们试图做某种形式的簿记,这将让我们在寻找最佳前任时执行二分法。
一个至关重要的观点是,如果不止一个前导终止长度为 m 的子序列,我们使用哪一个都没关系——它们都会给我们一个最佳答案。比如说,我们想只保留其中的一个人;我们应该保留哪一个?唯一安全的选择是保留其中最小的一个,因为这不会错误地阻止任何后来的元素在其上构建。让我们归纳地说,在某一点,我们有一个端点序列end
,其中end[idx]
是我们看到的长度idx+1
递增的子序列中最小的端点(我们从 0 开始索引)。因为我们是在序列上迭代,所以这些都会比我们当前的值val
早发生。我们现在需要的是一个扩展end
的归纳步骤,找出如何给它加上val
。如果我们能做到这一点,在算法的最后len(end)
会给我们最终的答案——最长递增子序列的长度。
end
序列必然不会减少(练习 8-8)。我们想找到最大的idx
这样的end[idx-1] <= val
。这将为我们提供val
所能贡献的最长序列,因此在end[idx]
处添加val
将会改善当前结果(如果我们需要添加的话)或者减少该位置处的当前端点值。添加之后,end
序列仍然具有之前的属性,因此感应是安全的。好消息是——我们可以使用(超快的)bisect
函数找到idx
! 11 你可以在清单 8-7 中找到最终代码。如果你愿意,你可以去掉一些对bisect
的调用(练习 8-9)。如果你想提取实际的序列,而不仅仅是长度,你需要增加一些额外的簿记(练习 8-10)。
清单 8-7 。最长增长子序列
from bisect import bisect
def lis(seq): # Longest increasing subseq.
end = [] # End-values for all lengths
for val in seq: # Try every value, in order
idx = bisect(end, val) # Can we build on an end val?
if idx == len(end): end.append(val) # Longest seq. extended
else: end[idx] = val # Prev. endpoint reduced
return len(end) # The longest we found
这就是最长的增长子序列问题。在我们深入一些众所周知的动态编程的例子之前,先回顾一下我们到目前为止所看到的内容。用 DP 解题的时候,还是用递归分解或者归纳思维。你仍然需要证明一个最优或正确的全局解依赖于你的子问题的最优或正确的解(最优子结构,或最优性原理)。与分而治之的主要区别在于,你可以有重叠的子问题。事实上,这种重叠是发展伙伴关系存在的理由。你甚至可以说你应该寻找一个有重叠的分解*,因为消除重叠(有记忆)会给你一个有效的解决方案。除了“带重叠的递归分解”的观点之外,您通常可以将 DP 问题视为顺序决策问题,或者视为在 DAG 中寻找特殊(例如,最短或最长)路径。这些视角都是等价的,但是可以不同的拟合各种问题。*
序列比较
比较序列的相似性是许多分子生物学和生物信息学中的一个关键问题,其中涉及的序列通常是 DNA、RNA 或蛋白质序列。除了其他用途之外,它还被用来构建系统发育(即进化)树——哪个物种是哪个物种的后代?它还可以用来寻找患有某种疾病的人或对某种特定药物敏感的人共有的基因。不同种类的序列或字符串比较也与许多种类的信息检索相关。例如,你可能搜索“空间之外的颜色”,并期望找到“空间之外的颜色”——为了实现这一点,你使用的搜索技术需要以某种方式知道这两个序列足够相似。
有几种比较序列的方法,其中许多比人们想象的更相似。例如,考虑寻找两个序列之间的最长公共子序列 (LCS)以及寻找它们之间的编辑距离的问题。LCS 问题类似于最长递增子序列问题——除了我们不再寻找递增子序列。我们正在寻找也出现在第二个序列中的子序列。(比如星际行者 12 和星巴克的 LCS 就是斯塔克。编辑距离(也称为 Levenshtein 距离)是将一个序列变成另一个序列所需的最小编辑操作(插入、删除或替换)次数。(例如,企业与次棱镜的编辑距离为 4。)如果我们不允许替换,这两者实际上是等价的。最长的公共子序列是当以尽可能少的编辑将一个序列编辑到另一个序列中时保持不变的部分。在任一序列中,每隔一个字符必须插入或删除。因此,如果序列的长度是 m 和 n 并且最长公共子序列的长度是 k ,则没有替换的编辑距离是 m+n- 2 k 。
这里我将重点讨论 LCS,把编辑距离留给一个练习(练习 8-11)。同样,和以前一样,我将把自己限制在解决方案的成本上(即 LCS 的长度)。按照标准模式增加一些额外的簿记,让你找到底层结构(练习 8-12)。对于一些相关的序列比较问题,请参见本章末尾附近的“如果你好奇…”一节。
尽管如果你没有接触过本书中的任何技术,设计一个多项式算法来寻找最长的公共子序列会非常困难,但是使用我在本章中讨论的工具却非常简单。至于所有的 DP 问题,关键是设计一组我们可以相互关联的子问题(也就是一个纠缠依赖的递归分解)。将子问题集合想象成由一组索引等参数化的通常会有所帮助。这些就是我们的归纳变量。 13 在这种情况下,我们可以使用序列的前缀*(就像我们在最长递增子序列问题中使用单个序列的前缀一样)。任何一对前缀(由它们的长度标识)都会产生一个子问题,我们希望在一个子问题图(即依赖 DAG)中把它们联系起来。*
假设我们的序列是 a 和 b 。如同一般的归纳思维一样,我们从两个任意的前缀开始,通过它们的长度 i 和 j 来识别。我们需要做的是将这个问题的解决方案与其他一些问题联系起来,其中至少有一个前缀更小。直觉上,我们想要从任一序列的末尾临时砍掉一些元素,通过我们的归纳假设解决由此产生的问题,然后将这些元素粘回去。如果我们沿着任一序列坚持弱归纳(减一),我们会得到三种情况:从 a 、从 b 或从两者中截取最后一个元素。如果我们只从一个序列中删除一个元素,它将被排除在 LCS 之外。然而,如果我们从两个中去掉最后一个,会发生什么取决于这两个元素是否等于*。如果是的话,我们可以用它们来扩展 LCS 一个!(如果不是,他们对我们就没用了。)
事实上,这给了我们整个算法(除了几个细节)。我们可以将 a 和 b 的 LCS 长度表示为前缀长度 i 和 j 的函数,如下所示:
换句话说,如果任一前缀为空,则 LCS 为空。如果最后的元素相等,则该元素是 LCS 的最后一个元素,我们递归地找到剩余部分(即前面的部分)的长度。如果最后的元素不等于,我们只有两个选择:要么切断 a 要么切断 b 的元素。因为可以自由选择,所以取两个结果中最好的。清单 8-8 给出了这个递归解决方案的一个简单的记忆实现。
清单 8-8 。LCS 问题的记忆递归解法
def rec_lcs(a,b): # Longest common subsequence
@memo # L is memoized
def L(i,j): # Prefixes a[:i] and b[:j]
if min(i,j) < 0: return 0 # One prefix is empty
if a[i] == b[j]: return 1 + L(i-1,j-1) # Match! Move diagonally
return max(L(i-1,j), L(i,j-1)) # Chop off either a[i] or b[j]
return L(len(a)-1,len(b)-1) # Run L on entire sequences
这种递归分解可以很容易地被看作是一个动态决策过程(我们是从第一个序列、第二个序列还是两者中砍掉一个元素?),它可以表示为一个 DAG(见图 8-5 )。我们从由完整序列表示的节点开始,并尝试找到返回到表示两个空前缀的节点的最长路径。重要的是要清楚这里的“最长路径”是什么,也就是说,边权重是多少。我们可以扩展 LCS(这是我们的目标)的唯一时间是当我们砍掉两个相同的元素时,当节点被放置在网格中时,由对角线的 DAG 边表示,如图图 8-5 。这些边的权重为 1,而其他边的权重为零。
图 8-5 。LCS 问题的基本有向无环图,其中水平边和垂直边的成本为零。从一个角到另一个角的最长路径(即对角线最多的路径)会高亮显示,其中对角线代表 LCS
出于通常的原因,您可能想要反转解决方案并使其迭代。清单 8-9 给出了一个版本,通过只保存 DP 矩阵的当前行和前一行来节省内存。(不过,你可以多存一点;参见练习 8-13。)注意cur[i-1]
对应递归版本中的L(i-1,j)
,而pre[i]
和pre[i-1]
分别对应L(i,j-1)
和L(i-1,j-1)
。
清单 8-9 。最长公共子序列(LCS)的迭代解法
def lcs(a,b):
n, m = len(a), len(b)
pre, cur = [0]*(n+1), [0]*(n+1) # Previous/current row
for j in range(1,m+1): # Iterate over b
pre, cur = cur, pre # Keep prev., overwrite cur.
for i in range(1,n+1): # Iterate over a
if a[i-1] == b[j-1]: # Last elts. of pref. equal?
cur[i] = pre[i-1] + 1 # L(i,j) = L(i-1,j-1) + 1
else: # Otherwise...
cur[i] = max(pre[i], cur[i-1]) # max(L(i,j-1),L(i-1,j))
return cur[n] # L(n,m)
背包反击
在第七章中,我承诺给你一个整数背包问题的解决方案,有有界和无界两个版本。是时候兑现这个承诺了。
回想一下,背包问题涉及一组对象,每个对象都有一个权重和一个值。我们的背包还有一个容量。我们想在背包里装满物品,这样(1)总重量小于或等于容量,并且(2)总价值最大化。假设物体 i 有重量 w [ i ]和值 v [ i ]。让我们先做无界的——这稍微简单一点。这意味着每个对象都可以使用任意多次。
我希望你开始从本章的例子中看到一种模式。这个问题正好符合这个模式:我们需要以某种方式定义子问题,将它们递归地相互关联,然后确保每个子问题只计算一次(通过使用记忆,隐式或显式)。问题的“无界性”意味着使用常见的“in 或 out”思想(尽管我们将在有界版本中使用它)来限制我们可以使用的对象有点困难。相反,我们可以简单地用背包容量参数化我们的子问题,也就是说,使用归纳法。
如果我们说 m ( r )是我们用一个(剩余)容量 r 所能得到的最大值,那么 r 的每一个值都给了我们一个子问题。递归分解基于使用或不使用最后一个单位的能力。如果我们不用,我们有m(r)=m(r-1)。如果我们真的使用它,我们必须选择正确的对象来使用。如果我们选择对象 i (假设它将适合剩余容量),我们将有m(r)=v[I]+m(r-w**I),因为我们将添加 i ,的值
我们可以(再一次)把这看作一个决策过程:我们可以选择是否使用最后一个容量单位,如果我们确实使用了它,我们可以选择添加哪个对象。因为我们可以选择任何我们想要的方式,我们只是在所有的可能性中取最大值。记忆化处理了递归定义中的指数冗余,如清单 8-10 所示。
[清单 8-10 。无界整数背包问题的记忆递归解法
def rec_unbounded_knapsack(w, v, c): # Weights, values and capacity
@memo # m is memoized
def m(r): # Max val. w/remaining cap. r
if r == 0: return 0 # No capacity? No value
val = m(r-1) # Ignore the last cap. unit?
for i, wi in enumerate(w): # Try every object
if wi > r: continue # Too heavy? Ignore it
val = max(val, v[i] + m(r-wi)) # Add value, remove weight
return val # Max over all last objects
return m(c) # Full capacity available
这里的运行时间取决于容量和对象的数量。每个记忆调用m(r)
只计算一次,这意味着对于容量 c ,我们有θ(c)个调用。每个调用都经过所有的 n 对象,所以得到的运行时间是θ(cn)。(这可能会在接下来的等价迭代版本中更容易看到。另请参见练习 8-14,了解提高运行时间常数的方法。)注意,这是而不是多项式运行时间,因为 c 可以随着实际问题大小(位数)呈指数增长。如前所述,这种运行时间被称为伪多项式,对于合理大小的容量,该解决方案实际上非常有效。
清单 8-11 显示了该算法的迭代版本。正如您所看到的,这两个实现实际上是相同的,除了递归被替换为一个for
循环,缓存现在是一个列表。 14
清单 8-11 。无界整数背包问题的迭代解法
def unbounded_knapsack(w, v, c):
m = [0]
for r in range(1,c+1):
val = m[r-1]
for i, wi in enumerate(w):
if wi > r: continue
val = max(val, v[i] + m[r-wi])
m.append(val)
return m[c]
现在让我们来看看可能更著名的背包问题——0-1 背包问题。在这里,每个对象最多只能使用一次。(您可以很容易地将它扩展到多次,要么稍微调整一下算法,要么在问题实例中多次包含同一个对象。)这是一个在实际情况中经常出现的问题,在第七章中讨论过。如果你玩过有库存系统的电脑游戏,我肯定你知道这有多令人沮丧。你刚刚杀死了一些强大的怪物,并找到了一堆战利品。你试着捡起来,但是发现你的负担太重了。现在怎么办?哪些物品你应该保留,哪些应该留下?
这个版本的问题和无界的很像。主要的区别是,我们现在为子问题添加了另一个参数:除了限制容量,我们还添加了“in 或 out”的概念,并限制了允许使用的对象数量。或者,更确切地说,我们指定哪个对象(按顺序)是“当前正在考虑的”,并且我们使用强归纳,假设所有子问题,其中我们或者考虑较早的对象,具有较低的容量,或者两者都可以递归地解决。
现在我们需要将这些子问题联系起来,并从子解决方案中构建一个解决方案。设 m ( k , r )是我们用前 k 个对象和剩余容量 r 所能拥有的最大值。那么,很明显,如果 k = 0 或者 r = 0,我们就会得到 m ( k , r ) = 0。对于其他情况,我们必须再次审视我们的决定是什么。对于这个问题,决策比无界问题简单;我们只需要考虑是否要包含最后一个对象, i = k -1。如果我们没有,我们就会有 m ( k ,r)=m(k-1, r )。实际上,我们只是“继承”了尚未考虑 i 的情况下的最优值。注意,如果w[I>r,我们别无选择,只能放下物体。
但是,如果对象足够小,我们可以包括它,这意味着 m ( k ,r)=v[I]+m(k-1,r-w**I),这与无界情况非常相似,除了额外的情况因为我们可以自由选择是否包含对象,所以我们尝试了两个选项,并使用两个结果值中的最大值。同样,记忆消除了指数冗余,我们最终得到类似于清单 8-12 中的代码。
[清单 8-12 。0-1 背包问题的记忆递归解法
def rec_knapsack(w, v, c): # Weights, values and capacity
@memo # m is memoized
def m(k, r): # Max val., k objs and cap r
if k == 0 or r == 0: return 0 # No objects/no capacity
i = k-1 # Object under consideration
drop = m(k-1, r) # What if we drop the object?
if w[i] > r: return drop # Too heavy: Must drop it
return max(drop, v[i] + m(k-1, r-w[i])) # Include it? Max of in/out
return m(len(w), c) # All objects, all capacity
在像 LCS 这样的问题中,简单地寻找一个解的值可能是有用的。对 LCS 来说,最长公共子序列的长度给了我们两个序列有多相似的概念。然而,在许多情况下,您希望找到产生最佳成本的实际解决方案。清单 8-13 中的迭代背包版本构造了一个额外的表,称为P
,因为它的工作有点像遍历(第五章)和最短路径算法(第九章)中使用的前任表。0-1 背包解的两个版本与无界解具有相同的(伪多项式)运行时间,即θ(cn)。
清单 8-13 。0-1 背包问题的迭代解法
def knapsack(w, v, c): # Returns solution matrices
n = len(w) # Number of available items
m = [[0]*(c+1) for i in range(n+1)] # Empty max-value matrix
P = [[False]*(c+1) for i in range(n+1)] # Empty keep/drop matrix
for k in range(1,n+1): # We can use k first objects
i = k-1 # Object under consideration
for r in range(1,c+1): # Every positive capacity
m[k][r] = drop = m[k-1][r] # By default: drop the object
if w[i] > r: continue # Too heavy? Ignore it
keep = v[i] + m[k-1][r-w[i]] # Value of keeping it
m[k][r] = max(drop, keep) # Best of dropping and keeping
P[k][r] = keep > drop # Did we keep it?
return m, P # Return full results
既然背包函数返回了更多的信息,我们可以用它来提取实际包含在最优解中的对象集。例如,您可以这样做:
>>> m, P = knapsack(w, v, c)
>>> k, r, items = len(w), c, set()
>>> while k > 0 and r > 0:
... i = k-1
... if P[k][r]:
... items.add(i)
... r -= w[i]
... k -= 1
换句话说,通过简单地保留一些关于所做选择的信息(在这种情况下,保留或删除考虑中的元素),我们可以逐渐将自己从最终状态追溯到初始条件。在这种情况下,我从最后一个对象开始,检查P[k][r]
看它是否包含在内。如果是,我从r
中减去它的重量;如果不是,我就不去管r
(因为我们仍有全部可用容量)。在这两种情况下,我都减少了k
,因为我们已经看完了最后一个元素,现在想看看倒数第二个元素(具有更新的容量)。你可能想说服自己,这个回溯操作有一个线性的运行时间。
同样的基本思想可以用在本章的所有例子中。除了给出的核心算法(通常只计算最优的值,您可以跟踪每一步做出了什么选择,然后在找到最优值后返回。
二元序列分割
在结束本章之前,让我们看一下另一种典型的 DP 问题,其中一些序列以某种方式被递归划分。你可以认为这是给序列加上括号,这样我们就可以从,比如,ABCDE 到((AB)((CD)E))。这有几个应用,例如下面的:
- 矩阵链乘法:我们有一个矩阵序列,我们想把它们全部相乘得到一个单一的矩阵。我们不能交换它们(矩阵乘法是不可交换的),但我们可以把括号放在我们想要的地方,这可能会影响所需的运算数量。我们的目标是找到括号(唷!)给出最低数量的操作。
- 解析任意上下文无关语言 : 16 任何上下文无关语言的语法都可以重写为乔姆斯基范式,其中每个产生式规则要么产生一个终结符、空字符串,要么产生一对非终结符 A 和 B 的 AB 。解析一个字符串基本上等同于设置括号,就像矩阵例子一样。每个带括号的基团代表一个非末端基团。
- 最优搜索树 : 这是霍夫曼问题的一个更难的版本。目标是相同的——最小化预期的遍历深度——但是因为它是一个搜索树,我们不能改变叶子的顺序,贪婪算法不再有效。同样,我们需要的是一个括号,对应于树形结构。 17
这三个应用非常不同,但问题本质上是相同的:我们希望分层分割序列,以便每个片段包含两个其他片段,我们希望找到这样一种分区,它可以优化一些成本或值(在解析的情况下,值只是“有效”/“无效”)。递归分解就像分治算法一样工作,如图 8-6 所示。在当前间隔内选择分裂点,产生两个子间隔,这两个子间隔被递归地划分。如果我们要创造一个基于排序序列的平衡的二叉查找树,那就是全部了。使用中间的元素(或者两个中间元素中的一个,对于偶数长度的区间)作为分割点(即根),递归地创建平衡的左右子树。
图 8-6 。递归序列分割应用于最佳搜索树。区间中的每个根产生两个子树,对应于左右子区间的最佳划分
现在,我们将不得不加强我们的游戏,虽然,因为分裂点没有给出,就像平衡分治的例子。不,现在我们需要尝试多个分割点,选择最好的一个。事实上,在一般情况下,我们需要尝试每一个可能的分裂点。这是一个典型的 DP 问题——在某些方面就像在 Dag 中寻找最短路径一样。DAG 最短路径问题封装了 DP 的顺序决策视角;这个序列分解问题体现了“带重叠的递归分解”的观点。子问题是各种各样的区间,除非我们记住我们的递归,否则它们将被解决指数倍。还要注意,我们已经得到了最优子结构:如果我们最初在最优(或正确)点分割序列,那么两个新片段必须被最优分割,这样我们才能得到最优(正确)解。 18
作为一个具体的例子,让我们用最优搜索树。 19 正如我们在第七章中构建霍夫曼树时,每个元素都有一个频率,我们想要最小化一个二叉查找树的期望遍历深度(或搜索时间)。但是在这种情况下,输入是排序的,我们不能改变它的顺序。为了简单起见,让我们假设每个查询都是针对树中实际存在的一个元素。(参见练习 8-19 了解解决方法。)归纳思考,我们只需要找到正确的根节点,两个子树(在更小的区间上)就会自己搞定(见图 8-6 )。再一次,为了简单起见,让我们只考虑最优成本的计算。如果您想要提取实际的树,您需要记住哪些子树根产生了最优子树成本(例如,将它存储在root[i,j]
)。
现在我们需要弄清楚递归关系;假设我们知道子树的成本,我们如何计算给定根的成本?单个节点的贡献类似于霍夫曼树中的贡献。然而,在那里,我们只处理树叶,成本是预期的深度。对于最优搜索树,我们可以以任何节点结束。此外,为了不使根的成本为零,让我们计算一下预期访问的节点数(即预期深度+ 1)。节点 v 的贡献则是p(v)×(d(v)+1,其中 p ( v )是其相对频率, d ( v )是其深度,我们对所有节点求和得到总成本。(这正好是p(v)×d(v)的 1 +总和,因为 p ( v )总和为 1。)
设e(i,j)
为区间[i:j]
的期望搜索成本。如果我们选择r
作为我们的根,我们可以将成本分解为e(i,j) = e(i,r) + e(r+1,j) + something
。对e
的两次递归调用代表了在每个子树中继续搜索的预期成本。但是,缺少的something
是什么呢?我们必须加上p[r]
,寻找根的概率,因为这将是它的预期成本。但是我们如何解释这两个子树的额外边呢?这些边将增加子树中每个节点的深度,这意味着除了根之外的每个节点 v 的每个概率p[v]
都必须添加到结果中。但是,嘿——正如所讨论的,我们也将增加p[r]
!换句话说,我们需要将区间中所有节点的概率相加。给定根r
的一个相对简单的递归表达式可能如下:
e(i,j) = e(i,r) + e(r+1,j) + sum(p[v] for v in range(i, j))
当然,在最终的解决方案中,我们会尝试range(i, j)
中的所有r
并选择最大值。尽管还有更大的改进空间:表达式的sum
部分将对二次方数量的重叠区间求和(每个可能的i
和j
对应一个区间),并且每个和具有线性运行时间。本着 DP 的精神,我们找出重叠部分:我们引入表示总和的记忆函数s(i,j)
,如清单 8-14 所示。如您所见,s
是在常量时间内计算的,假设递归调用已经被缓存(这意味着计算每个 sum s(i,j)
花费的时间是常量)。代码的其余部分直接来自前面的讨论。
清单 8-14 。期望最优搜索成本的记忆递归函数
def rec_opt_tree(p):
@memo
def s(i,j):
if i == j: return 0
return s(i,j-1) + p[j-1]
@memo
def e(i,j):
if i == j: return 0
sub = min(e(i,r) + e(r+1,j) for r in range(i,j))
return sub + s(i,j)
return e(0,len(p))
总而言之,这个算法的运行时间是立方的。渐近上限很简单:有二次方数量的子问题(即区间),我们对每个子问题内部的最佳根进行线性扫描。其实下界也是三次的(这个展示起来有点棘手),所以运行时间是θ(n3)。
至于以前的 DP 算法,迭代版本(清单 8-15 )在许多方面与记忆版本相似。为了以安全的(即拓扑排序的)顺序解决问题,它先解决一定长度的所有区间k
,然后再解决更大的区间。为了简单起见,我使用了一个 dict(或者更具体地说,一个自动提供零的defaultdict
)。你可以很容易地重写实现来使用,比方说,一个列表的列表。(不过,注意,只需要一个三角半矩阵,而不是完整的 n 乘 n 。)
清单 8-15 。最优搜索树问题的迭代解法
from collections import defaultdict
def opt_tree(p):
n = len(p)
s, e = defaultdict(int), defaultdict(int)
for k in range(1,n+1):
for i in range(n-k+1):
j = i + k
s[i,j] = s[i,j-1] + p[j-1]
e[i,j] = min(e[i,r] + e[r+1,j] for r in range(i,j))
e[i,j] += s[i,j]
return e[0,n]
摘要
本章讨论一种称为动态编程(DP)的技术,当子问题的依赖关系纠缠在一起时(也就是说,我们有重叠的子问题),直接的分治解决方案会产生指数级的运行时间。术语动态规划最初应用于一类顺序决策问题,但现在主要用于解决技术,其中执行某种形式的缓存,以便每个子问题只需要计算一次。实现的一种方法是直接在体现算法设计的递归分解(即归纳步骤)的递归函数中加入缓存;这叫做记忆化。不过,反转记忆化的递归实现,将它们变成迭代的实现,通常是有用的。在本章中使用 DP 解决的问题包括计算二项式系数、在 Dag 中寻找最短路径、寻找给定序列的最长递增子序列、寻找两个给定序列的最长公共子序列、利用有限和无限的不可分物品从背包中获得最大价值,以及构建最小化预期查找时间的二分搜索法树。
如果你好奇的话…
好奇?关于动态编程?你很幸运——有很多关于 DP 的 rad 资料。网络搜索应该会出现很多很酷的东西,比如竞争问题。如果您对语音处理或一般的隐马尔可夫模型感兴趣,您可以寻找维特比算法,它是许多种 DP 的一个很好的心理模型。在图像处理领域,可变形轮廓(也称为蛇)是一个很好的例子。
如果你认为序列比较听起来很酷,你可以看看 Gusfield 和 Smyth 的书(参见参考资料)。关于动态时间弯曲和加权编辑距离(本章没有讨论的两个重要变化)的简要介绍,以及对齐的概念,你可以看看 Christian Charras 和 Thierry Lecroq 的优秀教程“序列比较”。 20 对于 Python 标准库中的一些序列比较优度,可以查看difflib
模块。如果你安装了 Sage,你可以看看它的背包模块(http://sage.numerical.knapsack
)。
关于动态编程最初是如何出现的,请看 Stuart Dreyfus 的论文“理查德·贝尔曼论动态编程的诞生”对于 DP 问题的例子,你真的打不过 Lew 和 Mauch 他们关于这个主题的书讨论了大约 50 个问题。(不过,他们书中的大部分内容都偏重于理论。)
练习
8-1.重写@memo
,这样就可以减少一次字典查找。
8-2.如何将two_pow
视为采用了“进或出”的理念?“进还是出”对应的是什么?
8-3.写 fib 和 two_pow 的迭代版本。这应该允许您使用恒定的内存量,同时保留伪线性时间(即参数 n 中的时间线性)。
8-4.本章计算帕斯卡三角形的代码实际上填充了一个矩形,其中不相关的部分就是简单的零。重写代码以避免这种冗余。
8-5.扩展递归或迭代代码,以查找 DAG 中最短路径的长度,从而返回实际的最佳路径。
8-6.为什么边栏“各种 DAG 最短路径”中讨论的修剪不会对渐近运行时间有任何影响,即使在最好的情况下?
8-7.在面向对象的观察者模式中,几个观察者可以注册一个可观察对象。当可观察值发生变化时,这些观察者就会得到通知。这个想法如何被用来实现 DAG 最短路径问题的 DP 解决方案?它与本章讨论的方法有何相似或不同之处?
8-8.在 lis 函数中,我们如何知道 end 不减?
8-9.你如何减少在 lis 中平分的调用次数?
8-10.将递归或迭代解扩展到最长递增子序列问题,使其返回实际的子序列。
8-11.实现一个函数来计算两个序列之间的编辑距离,要么使用记忆,要么使用迭代 DP。
8-12.如何找到 LCS 的底层结构(即实际的共享子序列)或编辑距离(编辑操作的序列)?
8-13.如果在lcs
中比较的两个序列有不同的长度,你如何利用它来减少函数的内存使用?
8-14.你如何修改 w 和 c 来(潜在地)减少无界背包问题的运行时间?
8-15.清单 8-13 中的背包解决方案让你找到最佳解决方案中包含的实际元素。以类似的方式扩展其他背包解决方案之一。
8-16.当整数背包问题被认为是困难的、未解决的问题时,我们怎么能开发出有效的解决方案呢?
8-17.子集和的问题你也会在第十一章中看到。简而言之,它要求你从一组整数中挑选一个子集,使得这个子集的和等于一个给定的常数, k 。基于动态编程实现这个问题的解决方案。
8-18.与寻找最优二分搜索法树密切相关的一个问题是矩阵链乘法问题,在正文中简要提及。如果矩阵 A 和 B 分别具有维度 n × m 和 m × p ,那么它们的乘积 AB 将具有维度 n × p ,我们用乘积 nmp 来近似计算这个乘法的成本(元素乘法的次数)。设计并实现一个算法,寻找一个矩阵序列的括号,使得执行所有矩阵乘法的总成本尽可能低。
8-19.我们构建的最优搜索树仅仅基于元素的频率。我们可能还想考虑搜索树中不是的各种查询的频率。例如,我们可以获得一种语言中所有单词的频率,但只在树中存储一些单词。你如何考虑这些信息?
参考
Bather,J. (2000 年)。决策理论:对动态规划和顺序决策的介绍。约翰·威利&儿子有限公司
贝尔曼,R. (2003 年)。动态编程。多佛出版公司。
德纳多(2003 年)。动态规划:模型与应用。多佛出版公司。
德雷福斯,S. (2002 年)。理查德·贝尔曼论动态规划的诞生。运筹学,50(1):48-51。
弗雷德曼法学博士(1975 年)。关于最长增长子序列长度的计算。离散数学,11(1):29-35。
古斯菲尔德博士(1997 年)。字符串、树和序列的算法:计算机科学和计算生物学。剑桥大学出版社。
Lew a .和 Mauch h .(2007 年)。动态编程:一种计算工具。斯普林格。
史密斯,B. (2003 年)。字符串中的计算模式。艾迪森-韦斯利。
这一年,约翰·巴科斯的团队发布了第一个 FORTRAN 编译器。许多人认为这是第一个完整的编译器,尽管第一个编译器是在 1942 年由格蕾丝·赫柏编写的。
2 参见理查德·贝尔曼关于动态编程的诞生中的参考文献。
3 有些定义以零和一开头。如果你想那样,就用return i
代替return 1
。唯一的区别是将序列索引移动一位。
4
5 那是记的,不是记的。
6 使用functools
模块中的wraps
装饰器不会影响功能。它只是让被修饰的函数(比如fib
)在包装后保留它的属性(比如它的名字)。有关详细信息,请参见 Python 文档。
7 这仍然只是说明基本原理的一个例子。
8 比如,这个“进还是不进?”方法用于解决背包问题,在这一章的后面。
9 这种方法也与 Prim 的和 Dijkstra 的算法密切相关,还有 Bellman-Ford 算法(见第七章和 9 )。
10 实际上,对于最长增长子序列问题,我们寻找所有路径中最长的*,而不仅仅是任意两个给定点之间的最长路径。*
11 这个极其聪明的小算法是由迈克尔·l·弗雷德曼在 1975 年首次描述的。
12 使用天行者这里给出了稍微没那么有趣的 LCS Sar 。
13 当然,通常情况下归纳法只对一个整数变量起作用,比如问题大小。该技术可以很容易地扩展到多个变量,但是,归纳假设适用于至少有一个变量更小的情况。
14 你可以用m = [0]*(c+1)
预分配列表,如果你愿意,然后用m[r] = val
代替append
。
15 对象索引 i = k -1 只是个方便。我们不妨把 m ( k ,r)=v**k-1】+m(k-1,r-w[k-1))。
[16 如果解析对你来说完全陌生,可以随意跳过这个要点。或者调查一下?
17 你可以在 Cormen 等人的算法简介的第 15.5 节和 Donald E. Knuth 的计算机编程艺术第 3 卷“排序和搜索”的第 6.2.2 节中找到关于最优搜索树的更多信息(参见第一章的“参考资料”部分)。
18 你当然可以设计某种成本函数,所以这个不是的情况,但是我们不能再使用动态编程(或者,实际上,递归分解)了。感应不起作用。
你应该自己尝试一下矩阵链(练习 8-18),如果你愿意的话,甚至可以尝试一下解析。
20***
九、与 Edsger 和朋友从 A 到 B
两点之间的最短距离正在建设中。
noelie altito
是时候从简介回到第二个问题: 1 喀什到宁波的最短路线怎么找?如果你向任何地图软件提出这个问题,你可能会在不到一秒钟内得到答案。到目前为止,这似乎没有最初那么神秘了,你甚至有工具可以帮助你编写这样的程序。您知道,如果所有路段的长度相同,BFS 会找到最短路径,只要您的图中没有任何环,您就可以使用 DAG 最短路径算法。可悲的是,中国的路线图既包含自行车,也包含长度不等的道路。然而,幸运的是,这一章会给你有效解决这个问题所需的算法!
以免你认为这一章对编写地图软件有好处,考虑一下最短路径的抽象在其他什么情况下可能有用。例如,您可以在任何想要有效浏览网络的情况下使用它,这将包括互联网上所有类型的数据包路由。事实上,网络中充满了这样的路由算法,它们都在幕后工作。但是这种算法也用于不太明显的图形导航,比如让角色在电脑游戏中智能地移动。或者,也许你正试图找到最少的移动次数来解决某种形式的难题?这相当于在它的状态空间中找到最短的路径——这个抽象的图形代表了谜题的状态(节点)和移动(边)。还是在寻找利用货币汇率差异赚钱的方法?本章中的一个算法至少会带你走一段路(见练习 9-1)。
寻找最短路径也是其他算法中的一个重要子例程,这些算法不需要非常像图形。例如,在 n 人和 n 工作 2 之间寻找最佳可能匹配的一个通用算法需要反复解决这个问题。有一次,我开发了一个程序,试图修复 XML 文件,根据需要插入开始和结束标记,以满足一些简单的 XML 模式(规则如“列表项需要包装在列表标记中”)。事实证明,这可以通过使用本章中的一种算法来轻松解决。在运筹学、集成电路制造、机器人学等领域都有应用——只要你能说出来的。这绝对是你想了解的问题。幸运的是,尽管有些算法可能有点困难,但在前面的章节中,你已经完成了许多(如果不是大部分的话)具有挑战性的部分。
最短路径问题有几种类型。例如,您可以在有向和无向图中找到最短路径(就像任何其他类型的路径一样)。然而,最重要的区别来自于你的出发点和目的地。您是否希望找到从一个节点到所有其他节点的最短路径(单源)?从一个节点到另一个节点(单对、一对一、点对点)?从所有节点到一个(单一目的地)?从所有节点到所有其他节点(所有对)?其中两个——单源和所有线对——可能是最重要的。尽管我们有一些解决单对问题的技巧(参见后面的“在中间相遇”和“知道你要去哪里”),但没有保证能让我们比一般的单源问题更快地解决那个问题。当然,单目的地问题等价于单源版本(只需翻转有向情况的边)。所有对的问题可以通过将每个节点作为一个单独的源来解决(我们将会研究这个问题),但是也有专门的算法来解决这个问题。
传播知识
在第四章中,我介绍了放松和逐步提高的思想。在第八章中,你看到了在 Dag 中寻找最短路径的想法。事实上,DAGs 的迭代最短路径算法(清单 8-4 )不仅仅是动态规划的一个原型例子;本章还说明了算法的基本结构:我们在图的边上使用松弛来传播关于最短路径的知识。
让我们回顾一下这是什么样子的。我将使用 dicts 图的 dicts 表示,并使用 dict D
来维护距离估计(上限),就像第八章中的一样。另外,我会添加一个前任字典,P
,至于第五章中的很多遍历算法。这些前任指针将形成所谓的最短路径树,并允许我们重建与D
中的距离相对应的实际路径。然后可以在清单 9-1 中的relax
函数中排除松弛。请注意,我将D
中不存在的条目视为无限。(当然,我也可以在主算法中将它们都初始化为无穷大。)
清单 9-1 。放松操作
inf = float('inf')
def relax(W, u, v, D, P):
d = D.get(u,inf) + W[u][v] # Possible shortcut estimate
if d < D.get(v,inf): # Is it really a shortcut?
D[v], P[v] = d, u # Update estimate and parent
return True # There was a change!
我们的想法是,通过尝试走捷径穿过u
,来改善目前已知的到v
的距离。如果这不是一条捷径,没关系。我们只是忽略它。如果是捷径,我们记录新的距离并记住我们来自哪里(通过将P[v]
设置为u
)。我还增加了一点额外的功能:返回值表明是否实际发生了任何变化;这将在以后派上用场(尽管你不会在所有的算法中都需要它)。
下面看看它是如何工作的:
>>> D[u]
7
>>> D[v]
13
>>> W[u][v]
3
>>> relax(W, u, v, D, P)
True
>>> D[v]
10
>>> D[v] = 8
>>> relax(W, u, v, D, P)
>>> D[v]
8
正如你所看到的,对relax
的第一次调用将D[v]
从 13 提高到 10,因为我通过u
找到了一条捷径,我已经(大概)使用 7 的距离到达了这条捷径,而这条捷径距离v
只有 3。现在我不知何故发现我可以通过一条长度为 8 的路径到达v
。我再次运行relax
,但是这一次没有找到快捷方式,所以什么也没有发生。
正如你可能猜测的那样,如果我现在将D[u]
设置为 4,并再次运行相同的relax
,D[v]
,将会提高,这次提高到 7,将改进的估计从u
传播到v
。这种传播就是relax
的意义所在。如果你随机放松边,距离(和它们相应的路径)的任何改进将最终在整个图中传播——所以如果你永远保持随机放松,你知道你会有正确的答案。然而,永远是一段很长的时间...
d(s,v) <= d(s,u) + W[u,v]
。这是一个三角形不等式的例子。d(s,v) <= D[v]
。对于除了s
之外的v
,D[v]
最初是无限的,只有当我们找到实际的捷径时,我们才减少它。我们从不“作弊”,所以它仍然是一个上限。- 如果没有到节点
v
的路径,那么放松永远不会使D[v]
低于无穷大。那是因为我们永远找不到改善D[v]
的捷径。 - 假设到
v
的最短路径由从s
到u
的路径和从u
到v
的边组成。现在,如果在将边缘从u
放松到v
之前的任何时候D[u]
是正确的(即D[u] == d(s,u)
,那么D[v]
在之后的任何时候都是正确的。由P[v]
定义的路径也是正确的。 - 设
[s, a, b, ... , z, v]
是从s
到v
的最短路径。假设所有的边(s,a)
、(a,b)
、...,(z,v)
中的路径已经被放宽了顺序。然后D[v]
和P[v]
就正确了。如果在此期间执行了其他放松操作,则没有关系。
在继续之前,你应该确保你理解为什么这些陈述是正确的。这可能会使这一章的其余部分更容易理解。
疯狂放松
随意放松有点疯狂。然而,疯狂放松可能不是。假设你放松了所有的边缘。如果你愿意,你可以随意地做这件事——没关系。只要确保你看完了所有的。然后你再做一次——也许是以另一种顺序——但是你又一次穿过了所有的边。一次又一次。直到一切都没有改变。
提示想象一下,每个节点根据自己目前获得的最短路径,不断大声叫价,向其外部邻居提供最短路径。如果任何一个节点得到一个比它已经得到的更好的报价,它就转换它的路径供应商并相应地降低它的出价。
至少对于第一次尝试来说,这似乎不是一个不合理的方法。不过,有两个问题摆在面前:要多久才会有任何改变(如果我们真的到了那一步的话),以及当这一切发生的时候,你能确定你已经得到了正确的答案吗?
我们先考虑一个简单的案例。假设所有的边权重都是相同且非负的。这意味着relax
操作只有在找到由较少边组成的路径时才能找到捷径。那么,当我们放松了所有的边缘之后,会发生什么呢?至少,s
的所有邻居都有正确答案,并且在最短路径树中将s
设置为它们的父节点。根据我们放松边缘的顺序,树可能会蔓延得更远,但我们不能保证这一点。我们再放松一下怎么样?好吧,如果没有别的,这棵树至少会再延伸一层。事实上,在最坏的情况下,最短路径树会一层一层地蔓延,就好像我们在执行一些极其低效的 BFS 一样。对于一个有 n 个节点的图,任何路径的最大边数是 n -1,所以我们知道 n -1 是我们需要的最大迭代次数。
不过,一般来说,我们不能对我们的优势做这么多假设(或者如果可以,我们应该只使用 BFS,它会做得很好)。因为边可以具有不同的(甚至可能是负的)权重,所以后面几轮的relax
操作可能会修改前面几轮中的前趋指针集。例如,在一轮之后,s
的邻居v
将P[v]
设置为s
,但是我们不能确定这是正确的!也许我们会通过其他节点找到一条到v
的更短的路径,然后P[v]
会被覆盖。那么,在一轮放松所有边缘之后,我们能知道什么?
回想一下上一节列出的最后一个原则:如果我们沿着从s
到节点v
的最短路径放松所有的边,那么我们的答案(由D
和P
组成)将是正确的。具体来说,在这种情况下,我们将放松所有最短路径上的所有边...由一条边组成的。我们不知道这些路径在哪里,请注意,因为我们(还)不知道有多少条边进入各种最优路径。尽管连接s
和它的邻居的一些P
边很可能不是最终的,但是我们知道和正确的边肯定已经存在了。
故事是这样的。在 k 轮放松图中的每条边之后,我们知道由 k 条边组成的所有最短路径都已经完成。按照我们之前的推理,对于一个有 n 个节点和 m 条边的图,它最多需要 n -1 轮直到我们完成,给我们一个运行时间θ(nm)。当然,这只需要是最坏情况下的运行时间,如果我们添加一个检查:在上一轮中有什么变化吗?如果什么都没有改变,继续下去就没有意义了。我们甚至可能会放弃整个 n -1 计数,而只有依赖于该检查。毕竟,我们刚刚推理出,我们永远不会需要超过 n -1 轮,所以检查将最终停止算法。正确没有吗?不。有一个问题:消极循环。
你看,负循环是最短路径算法的敌人。如果我们没有负循环,那么“没有变化”的条件将会很好,但是加入一个负循环,我们的估计可以永远保持改进。因此...只要我们允许负面边缘(为什么我们不能?),我们需要迭代计数作为保障。关于这一点的好消息是,我们可以使用计数来检测负周期:不是运行 n -1 轮,而是运行 n 轮,看看在最后一次迭代中是否有任何变化。如果我们确实得到了改善(这是我们本不应该得到的),我们会立即得出结论“是一个负面循环造成的!”我们宣布我们的答案无效并放弃。
注别误会。即使存在负循环,也完全有可能找到最短路径。答案不允许包含循环,所以负循环不会影响答案。只是在允许负循环的情况下找到最短路径是一个未解决的问题(见第十一章)。
我们现在已经到达了这一章的第一个合适的算法:贝尔曼-福特(见清单 9-2 )。这是一个单源最短路径算法,允许任意有向或无向图。如果图中包含一个负循环,算法将报告这一事实并放弃。
清单 9-2 。贝尔曼-福特算法
def bellman_ford(G, s):
D, P = {s:0}, {} # Zero-dist to s; no parents
for rnd in G: # n = len(G) rounds
changed = False # No changes in round so far
for u in G: # For every from-node...
for v in G[u]: # ... and its to-nodes...
if relax(G, u, v, D, P): # Shortcut to v from u?
changed = True # Yes! So something changed
if not changed: break # No change in round: Done
else: # Not done before round n?
raise ValueError('negative cycle') # Negative cycle detected
return D, P # Otherwise: D and P correct
请注意,贝尔曼-福特算法的这个实现与许多演示的不同之处在于它包含了changed
检查。那张支票给了我们两个好处。首先,如果我们不需要所有的迭代,它让我们提前终止;第二,它让我们检测在最后一次“多余的”迭代中是否发生了任何变化,这表明了一个负循环。(没有这种检查的更常见的方法是添加一段单独的代码来实现最后一次迭代,并带有自己的变更检查。)
因为这个算法是其他几个算法的基础,所以我们要确保清楚它是如何工作的。考虑第二章中的加权图示例。我们可以将其指定为字典中的字典,如下所示:
a, b, c, d, e, f, g, h = range(8)
G = {
a: {b:2, c:1, d:3, e:9, f:4},
b: {c:4, e:3},
c: {d:8},
d: {e:7},
e: {f:5},
f: {c:2, g:2, h:2},
g: {f:1, h:6},
h: {f:9, g:8}
}
图表的直观展示见图 9-1 。假设我们调用bellman_ford(G, a)
。会发生什么?如果我们想找出更多的细节,我们可以使用调试器,或者也许是trace
或logging
包。为了简单起见,假设我们添加了几个print
语句,向我们显示放松的边界,以及对D
的赋值,如果有的话。假设我们也按照排序的顺序迭代节点和邻居(使用sorted
),以获得确定性的结果。
图 9-1 。一个加权图的例子
然后,我们得到一个打印输出,开始如下所示:
(a,b) D[b] = 2
(a,c) D[c] = 1
(a,d) D[d] = 3
(a,e) D[e] = 9
(a,f) D[f] = 4
(b,c)
(b,e) D[e] = 5
(c,d)
(d,e)
(e,f)
(f,c)
(f,g) D[g] = 6
(f,h) D[h] = 6
(g,f)
(g,h)
(h,f)
(h,g)
这是第一轮贝尔曼-福特;如你所见,它一次穿过了所有的边。打印输出将继续下一轮,但不会给D
赋值,因此函数返回。这里有些草率:距离估计值D[e]
首先被设置为 9,这是从a
直接到e
的距离。只有先放松(a,b)
,再放松(b,e)
,我们才会发现一个更好的选择,即长度为 5 的路径a
、b
、e
。然而,我们已经相当幸运,因为我们只需要一次通过边缘。让我们看看是否可以让事情变得更有趣,并迫使算法在稳定下来之前再做一轮。有什么办法吗?一种方法是:
G[a][b] = 3
G[a][c] = 7
G[c][d] = -4
现在我们有了一条通过f
到d
的好路线,但是我们在第一轮中找不到:
(a,b) D[b] = 3
(a,c) D[c] = 7
(a,d) D[d] = 3
(a,e) D[e] = 9
(a,f) D[f] = 4
(b,c)
(b,e) D[e] = 6
(c,d)
(d,e)
(e,f)
(f,c) D[c] = 6
(f,g) D[g] = 6
(f,h) D[h] = 6
(g,f)
(g,h)
(h,f)
(h,g)
我们已经在第一轮将D[c]
降到了 6,但是当我们到达那个点的时候,我们已经放松了(c,d)
,在那个优势不能给我们任何改善的时候,因为D[c]
是 7,D[d]
已经是 3。然而,在第二轮,你会看到
(c,d) D[d] = 2
到了第三轮,事情就会稳定下来。
在离开例子之前,让我们试着引入一个负循环。让我们使用原始权重,并做如下修改:
G[g][h] = -9
让我们去掉不改变D
的松弛,让我们在打印输出中加入一些整数。然后我们得到以下结果:
# Round 1:
(a,b) D[b] = 2
(a,c) D[c] = 1
(a,d) D[d] = 3
(a,e) D[e] = 9
(a,f) D[f] = 4
(b,e) D[e] = 5
(f,g) D[g] = 6
(f,h) D[h] = 6
(g,h) D[h] = -3
(h,g) D[g] = 5
# Round 2:
(g,h) D[h] = -4
(h,g) D[g] = 4
# Round 3:
(g,h) D[h] = -5
(h,g) D[g] = 3
# Round 4:
(g,h) D[h] = -6
(h,f) D[f] = 3
(h,g) D[g] = 2
...
# Round 8:
(g,h) D[h] = -10
(h,f) D[f] = -1
(h,g) D[g] = -2
Traceback (most recent call last):
...
ValueError: negative cycle
我已经删除了一些回合,但我相信你可以看到这种模式:在第三轮之后,g
、h
和f
的距离估计值反复减少一。鉴于只有 8 个节点,他们甚至在第 8 轮中也这样做,这一事实提醒我们存在负循环。这并不意味着没有解决方案——只是意味着持续放松不会为我们找到它,所以我们提出了一个例外。
当然,只有当我们真的能够到达时,负循环才是一个问题。让我们尝试消除边缘(f,g)
,例如通过使用del G[f][g]
。现在至少f
不会参与这个循环,但是我们还有g
和h
来改进彼此的估计,使之超出正确的范围。然而,如果我们也去掉(f,h)
,我们的问题就消失了!
(a,b) D[b] = 2
(a,c) D[c] = 1
(a,d) D[d] = 3
(a,e) D[e] = 9
(a,f) D[f] = 4
(b,e) D[e] = 5
图还是连通的,负循环还在,只是我们的遍历从来没有到达。如果这让你不舒服,请放心:到g
和h
的距离是正确的。它们都是无限的,这是理所应当的。然而,如果你试图调用bellman_ford(G, g)
或bellman_ford(G, h)
,这个循环又可以到达了,所以你会得到一系列的动作,每一轮都有几次更新,最后是负循环异常。
枕边细语。 也许我应该试试韦克斯勒?( http://xkcd.com/69
寻找隐藏的匕首
贝尔曼-福特算法很棒。在许多方面,这是本章中最容易理解的算法:放松所有的边,直到我们知道一切都必须是正确的。对于任意图,这是一个很好的算法,但是如果我们可以做一些假设,我们就可以(通常情况下)做得更好。您应该还记得,对于 Dag,单源最短路径问题可以在线性时间内解决。在这一节中,我将处理一个不同的约束。我们仍然可以有周期,但是没有负边权重。(事实上,这是在大量实际应用中出现的情况,例如在引言中讨论的那些。)这不仅意味着我们可以忘记消极的周期忧郁;这将让我们得出某些结论,当不同的距离是正确的,导致运行时间的实质性改善。
我在这里建立的算法,是由算法超级大师 Edsger W. Dijkstra 在 1959 年设计的,可以有几种解释,理解它为什么正确可能有点棘手。我认为把它看作 DAG 最短路径算法的近亲是有用的,重要的区别是它必须发现隐藏的 DAG 。
你看,即使我们正在处理的图可以有任何它想要的结构,我们可以认为一些边是不相关的。为了开始,我们可以想象我们已经知道从开始节点到其他每个节点的距离。我们当然不知道,但是这种假想的情况可以帮助我们推理。想象一下,根据节点之间的距离,从左到右对节点进行排序。会发生什么?对于一般情况来说——不多。然而,我们假设我们没有负的边权重,这就有所不同了。
因为所有的边都是正的,所以在我们假设的排序中,能够对节点的解做出贡献的唯一节点将位于其左边的*。不可能在右边找到一个节点来帮助我们找到一条捷径,因为这个节点离我们更远,只有当它有一个负后沿时才能给我们一条捷径。正后沿对我们来说完全没用,也不是问题结构的一部分。剩下的就是一个 DAG,我们想要使用的拓扑排序正是我们开始时假设的排序:节点按照它们的实际距离排序。该结构的图示见图 9-2 。(我一会儿再回到问号。)*
图 9-2 。逐渐揭开隐藏的匕首。节点标有它们的最终距离。因为权重为正,所以后向边(虚线)不会影响结果,因此是不相关的
不出所料,我们现在碰到了解决方案中的主要缺口:它完全是循环的。在揭示基本的问题结构(分解成子问题或找到隐藏的 DAG)时,我们假设我们已经解决了问题。不过,这个推理仍然是有用的,因为我们现在有了特定的东西可以寻找。我们想要找到排序——我们可以用我们可靠的工具——归纳法来找到它!
再次考虑图 9-2 。假设突出显示的节点是我们在归纳步骤中试图识别的节点(意味着较早的节点已经被识别,并且已经有了正确的距离估计)。就像在普通的 DAG 最短路径问题中一样,我们将放松每个节点的所有外边缘,只要我们已经识别出它并确定了它的正确距离。这意味着我们已经放松了所有早期节点的边缘。我们还没有放宽后面的节点的外边缘,但是正如所讨论的,它们无关紧要:这些后面的节点的距离估计是上界,后边缘具有正的权重,所以它们不可能对捷径有贡献。
这意味着(通过前面的松弛特性或第八章中对 DAG 最短路径算法的讨论)下一个节点必须具有正确的距离估计。也就是说,图 9-2 中高亮显示的节点现在肯定已经得到了正确的距离估计,因为我们已经放松了前三个节点的所有边。这是一个非常好的消息,剩下的就是找出是哪个节点。我们还是不知道顺序是什么,记得吗?我们一步一步地进行拓扑排序。
当然,只有一个节点可能是下一个节点: 3 具有最低距离估计的节点。我们知道它是排序中的下一个,我们知道它有一个正确的估计。因为这些估计值是上限,所以后面的节点不可能有更低的估计值。很酷,不是吗?现在,通过归纳,我们解决了这个问题。我们只是按照距离顺序放松每个节点的所有外边缘——这意味着总是接下来选择估计值最低的一个。
这种结构与 Prim 的算法非常相似:带优先级队列的遍历。就像 Prim 的一样,我们知道在我们的遍历中没有发现的节点不会被放松,所以我们(还)对它们不感兴趣。在我们已经发现(并放松)的那些中,我们总是想要优先级最低的那个。在 Prim 的算法中,优先级是链接回遍历树的边的权重;在 Dijkstra 的,优先是距离估计。当然,当我们找到快捷方式时,优先级可以改变(就像新的可能的生成树边可以降低 Prim 的优先级一样),但是就像在清单 7-5 中一样,我们可以简单地将同一个节点多次添加到我们的堆中(而不是试图修改堆条目的优先级),而不会损害正确性或运行时间。结果可以在清单 9-3 中找到。它的运行时间是对数线性的,或者更具体地说,是θ((m+n)LGn),其中 m 是边数, n 是节点数。这里的理由是,您需要一个(对数)堆操作,用于(1)从队列中提取每个节点和(2)释放每个边。 4 只要你有ω(n)条边,对于你从开始节点可以到达θ(n个节点的图,运行时间可以简化为θ(mLGn)。
清单 9-3 。迪杰斯特拉算法
from heapq import heappush, heappop
def dijkstra(G, s):
D, P, Q, S = {s:0}, {}, [(0,s)], set() # Est., tree, queue, visited
while Q: # Still unprocessed nodes?
_, u = heappop(Q) # Node with lowest estimate
if u in S: continue # Already visited? Skip it
S.add(u) # We've visited it now
for v in G[u]: # Go through all its neighbors
relax(G, u, v, D, P) # Relax the out-edge
heappush(Q, (D[v], v)) # Add to queue, w/est. as pri
return D, P # Final D and P returned
Dijkstra 的算法可能类似于 Prim 的算法(为队列设置了另一组优先级),但它也与另一个老宠儿密切相关:BFS 。考虑边权重是正整数的情况。现在,用 w -1 条未加权的边替换一条权重为 w 的边,连接一条虚拟节点路径(见图 9-3 )。我们正在毁掉我们得到一个高效解决方案的机会(见练习 9-3),但是我们知道 BFS 会找到一个正确的解决方案。事实上,它将以与 Dijkstra 算法非常相似的方式完成:它将在每条(原始)边上花费与其权重成比例的时间,因此它将按照与起始节点的距离顺序到达每个(原始)节点。
图 9-3 。虚拟节点模拟的边的重量或长度
这有点像沿着每条边设置了一系列多米诺骨牌(多米诺骨牌的数量与重量成比例),然后在开始节点倾斜第一个多米诺骨牌。一个节点可以从多个方向到达,但是我们可以通过观察哪些多米诺骨牌位于其他方向之下来判断哪个方向获胜。
如果我们用这种方法开始,我们可以将 Dijkstra 的算法视为通过“模拟”BFS 或多米诺骨牌(或流动的水或传播的声波,或...),而不必费心单独处理每个虚拟节点(或 domino)。相反,我们可以把我们的优先级队列想象成一条时间轴,在这条时间轴上,我们标记了通过不同的路径到达节点的不同时间。我们向下看一条新发现的边的长度,然后想,“多米诺骨牌什么时候能沿着这条边到达那个节点?”我们将边将花费的时间(边权重)加到当前时间(到当前节点的距离)上,并将结果放在时间轴(我们的堆)上。我们对第一次到达的每个节点都这样做(毕竟,我们只对最短的路径感兴趣),并且我们继续沿着时间轴移动到达其他节点。当我们再次到达同一个节点时,在时间线的后面,我们简单地忽略它。??
我已经清楚了 Dijkstra 算法与 DAG 最短路径算法的相似之处。这在很大程度上是动态编程的应用,尽管递归分解不像 DAG 那样明显。为了得到一个解,它也使用贪婪,因为它总是移动到当前具有最低距离估计的节点。将二进制堆作为优先级队列,甚至有点分而治之的意思;总而言之,这是一个很好的算法,使用了你到目前为止学到的很多东西。花些时间完全理解它是很值得的。
所有人对抗所有人
在下一节中,您将看到一个非常酷的算法,用于查找所有节点对之间的最短距离。这是一种特殊用途的算法,即使图形有很多边也是有效的。不过,在这一节中,我将快速介绍一种方法,将之前的两种算法——Bellman-Ford 和 Dijkstra 的算法——结合成一种真正在稀疏图(即边相对较少的图)中闪耀的算法。这是约翰逊的算法,它似乎在许多算法设计的课程和书籍中被忽略了,但它真的很聪明,而且鉴于你已经知道的东西,你几乎可以免费得到它。
Johnson 算法的动机如下:当解决稀疏图的所有对最短路径问题时,简单地从每个节点使用 Dijkstra 算法实际上是一个非常好的解决方案。这本身并没有激发出新的 ?? 算法...但问题是 Dijkstra 的算法不允许负边缘。对于单源最短路径问题,除了使用 Bellman-Ford 之外,我们没有太多办法。然而,对于所有对的问题,我们可以允许自己做一些初始的预处理,使所有的权重为正。
这个想法是添加一个新的节点 s ,零权重边到所有现有节点,然后从 s 运行贝尔曼-福特。这将给我们一个距离——让我们称之为h(v)——从 s 到我们图中的每个节点 v 。然后我们可以使用 h 来调整每条边的权重:我们定义新的权重如下: w '( u ,v)=w(u,v)+h(u)-h(这个定义有两个非常有用的性质。首先,它向我们保证了每个新的权重w*’(u, v )都是非负的(这是根据三角形不等式得出的,正如本章前面所讨论的;另请参见练习 9-5)。第二,我们没有把我们的问题搞砸!也就是说,如果我们用这些新的权重找到最短路径,那些路径也将是用原始的权重的最短路径(尽管有其他长度)。这是为什么呢?*
这可以用一个叫做伸缩总和 的好主意来解释:一个像(A-b)+(b-c)+的总和...+ ( y - z )会像望远镜一样坍缩,给我们一个–z。原因是,每隔一个被加数,前面加一次加号,后面加一次减号,所以它们的和都是零。在约翰逊的算法中,同样的事情会发生在每一条修改了边的路径上。对于这样一条路径中的任何一条边( u 、 v ),除了第一条或最后一条,都会通过加上 h ( u )减去 h ( v )来修改权重。下一个边缘将 v 作为其第一个节点,并将加上 h* ( v ),将其从总和中移除。类似地,前一条边将减去 h ( u ),移除该值。*
唯一有点不同的两条边(在任何路径中)是第一条和最后一条。第一个不是问题,因为 h ( s )将为零,并且 w ( s , v )对于所有节点 v 被设置为零。但是最后一个呢?没问题。是的,我们将以最后一个节点 v 减去 h ( v )而结束,但是所有在该节点结束的路径都是如此——最短路径仍然是最短的。
转换也不会丢弃任何信息,所以一旦我们使用 Dijkstra 算法找到了最短路径,我们就可以反向转换所有的路径长度。使用类似的伸缩论证,我们可以看到,通过基于转换后的权重从我们的答案中加上 h ( v )并减去 h ( u ),我们可以获得从 u 到 v 的最短路径的实际长度。这给了我们在清单 9-4 中实现的算法。 6
清单 9-4 。约翰逊算法
from copy import deepcopy
def johnson(G): # All pairs shortest paths
G = deepcopy(G) # Don't want to break original
s = object() # Guaranteed unused node
G[s] = {v:0 for v in G} # Edges from s have zero wgt
h, _ = bellman_ford(G, s) # h[v]: Shortest dist from s
del G[s] # No more need for s
for u in G: # The weight from u ...
for v in G[u]: # ... to v ...
G[u][v] += h[u] - h[v] # ... is adjusted (nonneg.)
D, P = {}, {} # D[u][v] and P[u][v]
for u in G: # From every u ...
D[u], P[u] = dijkstra(G, u) # ... find the shortest paths
for v in G: # For each destination ...
D[u][v] += h[v] - h[u] # ... readjust the distance
return D, P # These are two-dimensional
注意不需要检查对
bellman_ford
的调用是否成功或者是否发现了负循环(在这种情况下 Johnson 的算法不起作用),因为如果图中有是的负循环,bellman_ford
就会引发异常。
假设 Dijkstra 算法的θ(mLGn)运行时间,Johnson 的只是慢了 n 的一个因子,给我们θ(MnLGn),这比 Floyd-Warshall 的三次运行时间(稍作讨论)要快,对于稀疏图(即边相对较少的图)。 7
约翰逊算法中使用的变换与 A*算法的潜在功能密切相关(参见本章后面的“知道你要去哪里”),它类似于第十章中的最小成本二分匹配问题中使用的变换。这里的目标也是确保正的边权重,但是情况略有不同(边权重在迭代之间不断变化)。
牵强附会的子问题
虽然 Dijkstra 的算法肯定是基于动态编程的原则,但由于需要随时发现子问题的顺序(或子问题之间的依赖关系),这一事实在一定程度上被掩盖了。我在这一节中讨论的算法是由 Roy、Floyd 和 Warshall 独立发现的,是 DP 的一个典型例子。它基于记忆递归分解,并且在其普通实现中是迭代的。它的形式看似简单,但设计极其巧妙。在某些方面,它是基于第八章讨论的“进或出”原则,但由此产生的子问题,至少乍一看,似乎是高度人为和牵强的。
在许多动态规划问题中,我们可能需要寻找一组递归相关的子问题,但是一旦我们找到它们,它们通常看起来很自然。例如,想想 DAG 最短路径中的节点,或者最长公共子序列问题的前缀对。后者说明了一个有用的原则,可以扩展到不太明显的结构:限制我们可以使用的元素。例如,在 LCS 问题中,我们限制前缀的长度。在背包问题中,这稍微人工一些:我们为对象发明了一个排序,并将自己限制在第 k 个对象上。然后,子问题由这个“允许集”和背包容量的一部分来参数化。
在所有对最短路径问题中,我们可以使用这种形式的限制,以及“输入或输出”原则,来设计一组非显而易见的子问题:我们对节点进行任意排序,并限制我们可以使用多少个节点——也就是说,首先是k—作为形成路径的中间节点。现在,我们已经使用三个参数对我们的子问题进行了参数化:
- 起始节点
- 结束节点
- 我们被允许通过的最高节点数
除非你对我们的进展有所了解,否则增加第三项可能看起来完全没有意义——它怎么能帮助我们限制我们被允许做的事情呢?我相信你可以看到,这个想法是分割解决方案空间,将问题分解成子问题,然后将这些子问题连接成一个子问题图。链接是通过基于“输入或输出”的思想创建递归依赖来实现的:节点 k ,输入还是输出?
设 d ( u 、 v 、 k )为从节点 u 到节点 v 的最短路径的长度,如果只允许使用第 k 个第一节点作为中间节点。我们可以将问题分解如下:
d ( u , v ,k)= min(d(u, v ,k1), d ( u , k ,k1)+d
就像在背包问题中,我们正在考虑是否包括 k 。如果我们不包括它,我们简单地使用现有的解决方案,我们可以使用 k 找到没有的最短路径,这是 d ( u , v ,k—1)。如果包含了,我们必须使用从到 k 的最短路径(即 d ( u , k ,k—1))以及从 k* (即 d ( k , v ,请注意,在所有这三个子问题中,我们使用的是第k1 个节点,因为要么我们排除了第 k 个节点,要么我们明确地将它用作端点而不是中间节点。这保证了我们对子问题的大小排序(即拓扑排序)——没有循环。
你可以在清单 9-5 中看到结果算法。(实现使用第八章中的memo
装饰器。)注意,我假设节点是范围 1 内的整数... n 这里。如果你使用其他节点对象,你可以有一个包含任意顺序节点的列表V
,然后在min
部分使用V[k-1]
和V[k-2]
代替k
和k-1
。还要注意返回的D
图的形式是D[u,v]
而不是D[u][v]
。我还假设这是一个全权重矩阵,所以从u
到v
没有边的话D[u][v]
就是inf
。如果你愿意,你可以很容易地修改这一切。
清单 9-5 。Floyd-Warshall 算法的记忆递归实现
def rec_floyd_warshall(G): # All shortest paths
@memo # Store subsolutions
def d(u,v,k): # u to v via 1..k
if k==0: return G[u][v] # Assumes v in G[u]
return min(d(u,v,k-1), d(u,k,k-1) + d(k,v,k-1)) # Use k or not?
return {(u,v): d(u,v,len(G)) for u in G for v in G} # D[u,v] = d(u,v,n)
让我们试试迭代版本。假设我们有三个子问题参数( u 、 v 和 k ),我们将需要三个for
循环来迭代处理所有子问题。似乎有理由认为我们需要存储所有的子解,这导致了立方内存的使用,但就像 LCS 问题一样,我们可以减少这种情况。 8 我们的递归分解只将阶段 k 中的问题与阶段k1 中的问题联系起来。这意味着我们只需要两张距离图——一张用于当前迭代,一张用于前一次迭代。但是我们可以做得更好...
就像使用relax
时一样,我们在这里寻找快捷方式。阶段 k 的问题是“与我们现有的相比,通过节点 k 会提供捷径吗?”如果D
是我们当前的距离图,而C
是之前的距离图,我们得到了:
D[u][v] = min(D[u][v], C[u][k] + C[k][v])
现在考虑一下,如果我们始终使用单一距离图,会发生什么情况:
D[u][v] = min(D[u][v], D[u][k] + D[k][v])
意思现在稍微不太清楚,看起来有点绕,但是真的没有问题。我们在寻找捷径,对吗?值D[u][k]
和D[k][v]
将是真实路径的长度(因此是最短距离的上限),所以我们没有作弊。此外,它们不会大于C[u][k]
和C[k][v]
,因为我们从不增加地图中的值。因此,唯一可能发生的事情就是D[u][v]
更快地找到正确答案——这当然没问题。结果是我们只需要一个单一的二维距离图(也就是说,二次方内存与三次方内存相反),我们将通过寻找快捷方式来不断更新它。在许多方面,结果非常(尽管不完全)像贝尔曼-福特算法的二维版本(见清单 9-6)。
清单 9-6 。弗洛伊德-沃肖尔算法,仅距离
def floyd_warshall(G):
D = deepcopy(G) # No intermediates yet
for k in G: # Look for shortcuts with k
for u in G:
for v in G:
D[u][v] = min(D[u][v], D[u][k] + D[k][v])
return D
您会注意到,我开始使用图形本身的副本作为候选距离图。这是因为我们还没有尝试通过任何中间节点,所以唯一的可能性是直接边,由原始权重给出。还要注意,关于顶点是数字的假设完全消失了,因为我们不再需要明确地参数化我们所处的阶段。只要我们在先前结果的基础上,尝试为每个可能的中间节点创建快捷方式,解决方案将是相同的。我希望你会同意最终的算法是超级简单的,尽管它背后的推理可能并不简单。
不过,如果有一个P
矩阵也不错,就像约翰逊的算法一样。正如在许多 DP 算法中一样,构建实际的解决方案很好地依赖于计算最优值——您只需要记录做出了哪些选择。在这种情况下,如果我们通过k
找到一个快捷方式,那么P[u][v]
中记录的前任必须替换为P[k][v]
,也就是属于快捷方式最后“一半”的前任。最终算法可以在清单 9-7 中找到。原始的P
获得由边链接的任何不同节点对的前任。此后,每当D
更新时,P
就会更新。
清单 9-7 。弗洛伊德-沃肖尔算法
def floyd_warshall(G):
D, P = deepcopy(G), {}
for u in G:
for v in G:
if u == v or G[u][v] == inf:
P[u,v] = None
else:
P[u,v] = u
for k in G:
for u in G:
for v in G:
shortcut = D[u][k] + D[k][v]
if shortcut < D[u][v]:
D[u][v] = shortcut
P[u,v] = P[k,v]
return D, P
注意这里使用shortcut < D[u][v]
而不是shortcut <= D[u][v]
很重要。尽管后者仍然会给出正确的距离,但您可能会遇到最后一步是D[v][v]
,这将导致P[u,v] = None
的情况。
Floyd-Warshall 算法可以很容易地被修改来计算图的传递闭包(Warshall 算法)。请参见练习 9-9。
在中间相遇
Dijkstra 算法的子问题解决方案——及其未加权特例 BFS 的子问题解决方案——在图上向外扩散,就像池塘里的涟漪。如果你想要的只是从 A 到 B,或者使用习惯的节点名称,从 s 到 t ,这意味着“波纹”必须经过许多你并不真正感兴趣的节点,如图 9-4 中的左图所示。另一方面,如果你同时从起点和终点开始遍历(假设你可以反向遍历边),在某些情况下,两个波纹可以在中间相遇,这样可以节省很多工作,如右图所示。
图 9-4 。单向和双向“波纹”,表示通过遍历找到从 s 到 t 的路径所需的工作
请注意,尽管图 9-4 的“图形证据”可能令人信服,但它当然不是一个正式的论证,也没有给出任何保证。事实上,尽管本节和下一节的算法为单源、单目的地最短路径提供了实际的改进,但没有哪种点对点算法比普通的单源问题具有更好的渐近最坏情况行为。当然,两个半径为原半径一半的圆将有一半的总面积,但是图形的行为不一定像欧几里得平面。我们当然希望在运行时间上有所改进,但这就是所谓的启发式算法。这种算法是基于有根据的猜测,并且通常根据经验进行评估。我们可以确信它不会比 Dijkstra 的算法更差,渐进地说——这都是为了提高实际运行时间。
为了实现 Dijkstra 算法的这个双向版本,让我们首先稍微修改一下原始版本,使其成为一个生成器,这样我们就可以只提取“meetup”所需的子解。这类似于第五章中的一些遍历函数,比如iter_dfs
( 清单 5-5 )。这种迭代行为意味着我们可以完全丢弃距离表,只依赖优先级队列中保存的距离。为了简单起见,我不会在这里包含前置信息,但是您可以通过向堆中的元组添加前置来轻松扩展解决方案。要获得距离表(就像最初的dijkstra
),你可以简单地调用dict(idijkstra(G, s))
。代码见清单 9-8 。
清单 9-8 。作为生成器实现的 Dijkstra 算法
from heapq import heappush, heappop
def idijkstra(G, s):
Q, S = [(0,s)], set() # Queue w/dists, visited
while Q: # Still unprocessed nodes?
d, u = heappop(Q) # Node with lowest estimate
if u in S: continue # Already visited? Skip it
S.add(u) # We've visited it now
yield u, d # Yield a subsolution/node
for v in G[u]: # Go through all its neighbors
heappush(Q, (d+G[u][v], v)) # Add to queue, w/est. as pri
注意,我已经完全放弃了使用relax
——它现在隐含在堆中。或者说,heappush
是新的relax
。重新添加具有更好估计的节点意味着它将优先于旧条目,这相当于用 relax 操作覆盖旧条目。这类似于第七章中 Prim 算法的实现。
既然我们已经一步一步地接触到了 Dijkstra 的算法,构建一个双向版本就不是太难了。我们在原始算法的 to 和 from 实例之间交替,扩展每个波纹,一次扩展一个节点。如果我们继续下去,这会给我们两个完整的答案——从 s 到 t 的距离,以及从 t 到 s 的距离,如果我们沿着边向后走。当然,这两个答案是一样的,这使得整个练习毫无意义。想法是一旦涟漪相遇就停下来。一旦idijkstra
的两个实例产生了同一个节点,跳出循环似乎是个好主意。
这就是算法中唯一真正的问题所在:你从 s 和 t 开始遍历,一直移动到下一个最近的节点,所以一旦两个算法都移动到(也就是说,产生了)同一个节点,那么这两个算法沿着最短路径相遇似乎是合理的,对吗?毕竟,如果您只是从 s 开始遍历,那么您可以在到达 t 时立即终止(即idijkstra
让步)。可悲的是,正如很容易发生的那样,我们的直觉(或者至少是我的直觉)在这里让我们失望了。图 9-5 中的简单例子应该可以澄清这种可能的误解;但是哪里的是最短路径呢?我们怎么知道停下来是安全的?
图 9-5 。第一个会合点(突出显示的节点)不一定沿着最短路径(突出显示的边)
事实上,一旦两个实例相遇就结束遍历是没问题的。然而,为了找到最短路径,打个比方,当算法执行时,我们需要保持警惕。我们需要保持到目前为止找到的最佳距离,每当一条边( u , v )放松,并且我们已经有了从 s 到 u 的距离(通过向前遍历)和从 v 到 t 的距离(通过向后遍历),我们需要检查用( u , v )连接路径是否会改进我们的最佳解决方案
事实上,我们可以把停止标准收紧一点(见练习 9-10)。我们不需要等待两个实例都访问同一个节点,我们只需要查看它们已经走了多远——也就是它们已经到达的最近距离。这些不能减少,所以如果他们的总和至少和我们目前找到的最佳路径一样大,我们找不到更好的,我们就完了。
尽管如此,仍有一个挥之不去的疑问。前面的论点可能会让你相信我们不可能通过继续下去找到任何更好的路径,但是我们怎么能确定我们没有错过任何路径呢?假设我们找到的最佳路径长度为米。导致终止的两个距离是 l 和 r ,所以我们知道 l + r ≥ m (停止判据)。现在,假设有一条从 s 到 t 的路径,该路径比 m 短*。为此,路径必须包含一条边( u , v ),使得 d ( s , u ) < l 和 d ( v , t ) < r (见练习 9-11)。这意味着 u 和 v 分别比当前节点更靠近 s 和 t ,所以这两个节点肯定都已经被访问过(产生过)。在两者都被放弃的时候,我们对迄今为止的最佳解决方案的维护应该已经找到了这条道路——一个矛盾。换句话说,算法是正确的。*
到目前为止,这整个跟踪最佳路径的业务需要我们访问 Dijkstra 算法的内部。我更喜欢idijkstra
给我的抽象,所以我将坚持使用这种算法的最简单版本:一旦我从两次遍历中接收到相同的节点就停止,然后在之后扫描最佳路径*,检查连接两半的所有边。如果您的数据集是可以从双向搜索中获益的那种类型,那么这种扫描不太可能成为太大的瓶颈,但是当然,您可以随意使用分析器并进行调整。完成的代码可以在清单 9-9 中找到。来自itertools
的cycle
函数给了我们一个迭代器,它将从其他迭代器重复地给我们值,从头到尾重复地产生它的值。在这种情况下,这意味着我们在向前和向后方向之间循环。*
清单 9-9 。Dijkstra 算法的双向版本
from itertools import cycle
def bidir_dijkstra(G, s, t):
Ds, Dt = {}, {} # D from s and t, respectively
forw, back = idijkstra(G,s), idijkstra(G,t) # The "two Dijkstras"
dirs = (Ds, Dt, forw), (Dt, Ds, back) # Alternating situations
try: # Until one of forw/back ends
for D, other, step in cycle(dirs): # Switch between the two
v, d = next(step) # Next node/distance for one
D[v] = d # Remember the distance
if v in other: break # Also visited by the other?
except StopIteration: return inf # One ran out before they met
m = inf # They met; now find the path
for u in Ds: # For every visited forw-node
for v in G[u]: # ... go through its neighbors
if not v in Dt: continue # Is it also back-visited?
m = min(m, Ds[u] + G[u][v] + Dt[v]) # Is this path better?
return m # Return the best path
注意,这段代码假设G
是无向的(也就是说,所有的边在两个方向上都是可用的),并且所有的节点u
都是G[u][u] = 0
。你可以很容易地扩展算法,这样就不需要那些假设了(练习 9-12)。
知道你要去哪里
到目前为止,您已经看到了遍历的基本思想是非常通用的,通过简单地使用不同的队列,您可以得到几种有用的算法。例如,对于 FIFO 和 LIFO 队列,您可以获得 BFS 和 DFS,通过适当的优先级,您可以获得 Prim 和 Dijkstra 算法的核心。本节描述的算法称为 A*,通过再次调整优先级来扩展 Dijkstra 的算法。
如前所述,A算法使用了类似于 Johnson 算法的思想,尽管目的不同。Johnson 的算法转换所有边权重以确保它们是正的,同时确保最短路径仍然是最短的。在 A中,我们希望以类似的方式修改边,但这一次的目标不是使边为正——我们假设它们已经为正(因为我们是在 Dijkstra 算法的基础上构建的)。不,我们想要的是通过使用我们要去的地方的信息来引导遍历到正确的方向:我们想要使远离我们的目标节点的边比那些使我们更接近它的边更昂贵。
注意这类似于第十一章中讨论的分支定界策略中使用的最佳优先搜索。
当然,如果我们真的知道哪些边缘会让我们走得更近,我们可以通过贪婪来解决整个问题。我们只是沿着最短的路径前进,不走任何旁道。A算法的好处在于,它填补了 Dijkstra 算法和这种假设的理想情况之间的空白,在 Dijkstra 算法中,我们不知道我们要去哪里,而在这种假设的理想情况下,我们知道我们要去哪里的确切位置。它引入了一个势函数* ,或者说启发式 h ( v ),这是我们对剩余距离的最佳猜测, d ( v , t )。一分钟后你会看到,Dijkstra 的算法作为特例“掉出”A*,当 h ( v ) = 0 时。同样,如果我们可以用魔法设置h(v)=d(v, t ),那么算法将直接从 s 前进到 t 。
那么,它是如何工作的呢?我们定义修改后的边缘权重来获得伸缩和,就像我们在约翰逊算法中所做的那样(尽管你应该注意到这里符号被调换了): w '( u , v ) = w ( u ,v)-h(u)+h(v伸缩和保证了最短路径仍然是最短的(就像在 Johnson 的例子中一样),因为所有路径长度都改变了相同的量,h(t)-h(s)。如您所见,如果我们将启发式规则设置为零(或者,实际上,任何常数),权重都不会改变。
显而易见,这种调整反映了我们奖励方向正确的优势、惩罚方向错误的优势的意图。对于每个边的权重,我们加上潜在的下降*(启发式),这类似于重力的工作方式。如果你把一个弹球放在一个凹凸不平的桌子上,它将开始向一个降低其势能的方向运动。在我们的例子中,算法将被引导到导致剩余距离下降的方向——这正是我们想要的。*
A算法等价于修改图上的 Dijkstra 算法,所以如果 h 是可行就是正确的,意味着 w '( u , v )对于所有节点 u 和 v 都是非负的。按照D*[v]-h(s)+h(v)的递增顺序扫描节点,而不是简单的 D v 。因为 h ( s )是一个常见的常量,所以我们可以忽略它,只需将 h ( v )添加到我们现有的优先级中。这个总和是我们对从 s 经由 v 到 t 的最短路径的最佳估计。如果 w '( u , v )可行, h ( v )也将是 d ( v , t )上的一个下界(见练习 9-14)。
实现所有这些的一个(非常常见的)方法是使用类似于原始的dijkstra
的东西,并在将节点推到堆上时简单地将 h ( v )添加到优先级中。最初的距离估计在D
中仍然可用。然而,如果我们想简化事情,只使用堆(如在idijkstra
中),我们需要实际使用权重调整,以便对于一个边( u , v ),我们也减去 h ( u )。这是我在清单 9-10 中采用的方法。如你所见,在返回距离之前,我已经确保删除了多余的 h ( t )。(考虑到a_star
函数正在打包的算法 punch,它相当简短而甜蜜,你说呢?)
[清单 9-10 。A*算法
from heapq import heappush, heappop
inf = float('inf')
def a_star(G, s, t, h):
P, Q = {}, [(h(s), None, s)] # Preds and queue w/heuristic
while Q: # Still unprocessed nodes?
d, p, u = heappop(Q) # Node with lowest heuristic
if u in P: continue # Already visited? Skip it
P[u] = p # Set path predecessor
if u == t: return d - h(t), P # Arrived! Ret. dist and preds
for v in G[u]: # Go through all neighbors
w = G[u][v] - h(u) + h(v) # Modify weight wrt heuristic
heappush(Q, (d + w, u, v)) # Add to queue, w/heur as pri
return inf, None # Didn't get to t
正如你所看到的,除了对u == t
增加的检查之外,与 Dijkstra 算法的唯一不同之处实际上是对权重的调整。换句话说,如果你愿意,你可以在修改了权重的图上使用直接点对点版本的 Dijkstra 算法(也就是说,包括u == t
检查的算法),而不是为 A*使用单独的算法。
当然,为了从 A*算法中获得任何好处,您需要一个好的启发式算法。当然,这个函数应该是什么在很大程度上取决于你试图解决的确切问题。例如,如果您正在导航一个路线图,您会知道从一个给定节点到您的目的地的直线欧几里得距离必须是一个有效的启发式(下限)。事实上,这对于平面上的任何运动都是一个有用的启发,比如怪物在电脑游戏世界里走来走去。但是,如果有很多死胡同和曲折,这个下限可能不是很准确。(参见“如果你好奇……”部分寻找替代方案。)
A算法也用于搜索解空间,我们可以将其视为抽象(或隐含)图。例如,我们可能想要解决魔方 9 或者刘易斯·卡罗尔所谓的字梯*谜题。事实上,让我们试一试后一个难题(没有双关语的意思)。
单词阶梯是从一个起始单词开始构建的,比如 lead ,而你想以另一个单词结束,比如 gold 。你逐步建立阶梯,每一步都使用实际的单词。要从一个单词转到另一个单词,您可以替换单个字母。(还有其他版本,允许你添加或删除字母,或者允许你交换字母。)所以,举例来说,你可以通过单词 load 和 goad 从 lead 到 gold 。如果我们把某个字典中的每个单词解释为我们图中的一个节点,我们可以在相差一个字母的所有单词之间添加边。我们可能不想显式地构建这样的结构,但是我们可以“伪造”它,如清单 9-11 所示。
清单 9-11 。带有单词阶梯路径的隐式图
from string import ascii_lowercase as chars
def variants(wd, words): # Yield all word variants
wasl = list(wd) # The word as a list
for i, c in enumerate(wasl): # Each position and character
for oc in chars: # Every possible character
if c == oc: continue # Don't replace with the same
wasl[i] = oc # Replace the character
ow = ''.join(wasl) # Make a string of the word
if ow in words: # Is it a valid word?
yield ow # Then we yield it
wasl[i] = c # Reset the character
class WordSpace: # An implicit graph w/utils
def __init__(self, words): # Create graph over the words
self.words = words
self.M = dict() # Reachable words
def __getitem__(self, wd): # The adjacency map interface
if wd not in self.M: # Cache the neighbors
self.M[wd] = dict.fromkeys(self.variants(wd, self.words), 1)
return self.M[wd]
def heuristic(self, u, v): # The default heuristic
return sum(a!=b for a, b in zip(u, v)) # How many characters differ?
def ladder(self, s, t, h=None): # Utility wrapper for a_star
if h is None: # Allows other heuristics
def h(v):
return self.heuristic(v, t)
_, P = a_star(self, s, t, h) # Get the predecessor map
if P is None:
return [s, None, t] # When no path exists
u, p = t, []
while u is not None: # Walk backward from t
p.append(u) # Append every predecessor
u = P[u] # Take another step
p.reverse() # The path is backward
return p
WordSpace
类的主要思想是它作为一个加权图工作,这样它可以与我们的a_star
实现一起使用。如果G
是一个WordSpace
,G['lead']
将是一个字典,其他单词(如'load'
和'mead'
)作为关键字,1 作为每个边的权重。我使用的默认启发式算法只是简单地计算单词不同的位置。
使用WordSpace
类很容易,只要你有某种单词表。许多 UNIX 系统都有一个名为/usr/share/dict/words
或/usr/dict/words
的文件,每行一个单词。如果你没有这样的文件,你可以从http://ftp.gnu.org/gnu/aspell/dict/en
那里得到一个。如果你没有这个文件,你可以在网上找到它(或类似的东西)。例如,您可以像这样构造一个WordSpace
(删除空白并将所有内容规范化为小写):
>>> words = set(line.strip().lower() for line in open("/usr/share/dict/words"))
>>> G = WordSpace(words)
当然,如果你得到了你不喜欢的单词阶梯,你可以随意地从其中删除一些单词。 10 一旦你有了自己的WordSpace
,该出发了:
>>> G.ladder('lead', 'gold')
['lead', 'load', 'goad', 'gold']
很整洁,但也许不是那么令人印象深刻。现在尝试以下方法:
>>> G.ladder('lead', 'gold', h=lambda v: 0)
我只是简单地用一个完全没有信息的方法代替了启发式方法,基本上是把我们的 A* 变成了 BFS(或者,更确切地说,是在一个未加权图上运行的 Dijkstra 算法)。在我的电脑上(和我的单词表),运行时间的差异是相当明显的。事实上,使用第一种(默认)启发式算法时的加速因子接近 100!11
摘要
与前几章相比,这一章更集中于在网络状的结构和空间中寻找最佳路径,换句话说,就是图中的最短路径。本章算法中使用的一些基本思想和机制在本书前面已经介绍过了,所以我们可以逐步构建我们的解决方案。所有最短路径算法共有的一个基本策略是寻找条捷径,或者通过使用relax
函数或类似函数(大多数算法都这样做),通过沿着路径的一个新的可能的倒数第二个节点,或者通过考虑一条由两个子路径组成的捷径,往返于某个中间节点(Floyd-Warshall 的策略)。基于松弛的算法以不同的方式处理事物,基于它们对图形的假设。贝尔曼-福特算法简单地尝试依次构建每个边的捷径,并重复这个过程至多 n -1 次迭代(如果仍有改进的潜力,则报告负循环)。
你在第八章中看到,有可能比这更有效率;对于 Dag,只要我们按照拓扑排序的顺序访问节点,就可以只放松每条边一次。对于一般的图来说,topsort 是不可能的,但是如果我们不允许负边,我们可以找到一种拓扑排序,这种排序尊重那些重要的边——也就是说,根据节点与起始节点的距离对节点进行排序。当然,我们不知道这种排序是如何开始的,但我们可以通过始终选取剩余的距离估计值最低的节点来逐步构建它,就像 Dijkstra 的算法一样。我们知道这是要做的事情,因为我们已经放松了所有可能的前一个的外边缘,所以排序顺序中的下一个现在必须有正确的估计——唯一可能的是具有最低上限的那个。
当查找所有节点对之间的距离时,我们有几个选项。例如,我们可以从每个可能的开始节点运行 Dijkstra 算法。这对于相当稀疏的图来说非常好,事实上,即使边不都是正的,我们也可以使用这种方法!我们首先运行 Bellman-Ford,然后调整所有的边,这样我们(1)保持路径的长度等级(最短的仍然是最短的),并且(2)使边权重为正。另一种选择是使用动态规划,就像在 Floyd-Warshall 算法中一样,其中每个子问题都由它的起始节点、结束节点和允许我们通过的其他节点的数量(以某种预定的顺序)来定义。
没有已知的方法可以找到从一个节点到另一个节点的最短路径,渐近地,比找到从开始节点到所有其他节点的最短路径更好。尽管如此,还是有一些启发性的方法可以在实践中给予改进。其中之一是双向搜索*,从开始节点和结束节点“同时”执行遍历,然后在两者相遇时终止,从而减少需要访问的节点数量(或者我们希望如此)。另一种方法是使用启发式“最佳优先”方法,使用启发式函数引导我们在不太有希望的节点之前找到更有希望的节点,如 A算法。
*如果你好奇的话...
大多数算法书都会给你寻找最短路径的基本算法的解释和描述。不过,一些更高级的启发式算法,比如 A*,通常会在人工智能书籍中讨论。在那里,您还可以找到关于如何使用这种算法(以及其他相关算法)来搜索复杂的解决方案空间的全面解释,这些解决方案空间看起来一点也不像我们一直在使用的显式图结构。对于人工智能这些方面的坚实基础,我衷心推荐罗素和诺维格的精彩著作。对于 A*算法的启发,你可以尝试在网上搜索“最短路径”以及“地标”或“ALT”
如果你想在渐近前沿推动 Dijkstra 算法,你可以研究 Fibonacci 堆。如果您将二进制堆替换为 Fibonacci 堆,Dijkstra 的算法将获得改进的渐进运行时间,但您的性能仍有可能受到影响,除非您正在处理非常大的实例,因为 Python 的堆实现非常快,而用 Python 实现的 Fibonacci 堆(相当复杂的事情)可能不会如此。但仍然值得一看。
最后,您可能希望将 Dijkstra 算法的双向版本与 A*的启发式机制结合起来。不过,在此之前,您应该对这个问题进行一些研究——这里有一些陷阱,可能会使您的算法无效。Nannicini 等人的论文(见“参考文献”)提供了一个(稍微先进的)关于这一点和使用基于界标的试探法(以及随时间变化的图形的挑战)的信息来源。
练习
9-1.在某些情况下,货币之间的汇率差异使得从一种货币兑换到另一种货币成为可能,这种情况会持续下去,直到一种货币回到原来的货币,从而获得利润。你如何使用贝尔曼-福特算法来检测这种情况的存在?
9-2.如果多个节点与起始节点的距离相同,在 Dijkstra 算法中会发生什么?现在还正确吗?
9-3.为什么像图 9-3 中的那样用虚拟节点来表示边长是一个非常糟糕的主意?
9-4.如果用一个未排序的列表而不是二进制堆来实现 Dijkstra 算法,它的运行时间会是多少?
9-5.为什么我们能确定约翰逊算法中调整后的权重是非负的?有可能出错的情况吗?
9-6.在约翰逊的算法中, h 函数基于贝尔曼-福特算法。为什么我们不能用一个任意的函数呢?它会消失在伸缩总和中吗?
9-7.实现 Floyd-Warshall 的记忆化版本,这样它可以像迭代一样节省内存。
9-8.扩展 Floyd-Warshall 的记忆版本来计算一个P
表,就像迭代一样。
9-9.你将如何修改 Floyd-Warshall 算法,使其检测路径的存在,而不是寻找最短路径(Warshall 算法)?
9-10.为什么双向 Dijkstra 算法的更严格停止标准的正确性意味着原始算法的正确性?
9-11.在 Dijkstra 算法的双向版本的正确性证明中,我假设了一条比我们迄今为止发现的最佳路径更短的假设路径,并声明它必须包含一条边( u , v ),使得 d ( s , u ) < l 和 d ( v ,为什么会这样呢?
9-12.重写bidir_dijkstra
,这样就不需要输入图是对称的,有零权重的自边。
9-13.实现 BFS 的双向版本。
9-14.为什么在 w 可行的情况下, h ( v )是 d ( v , t )上的一个下界?
参考
迪杰斯特拉,E. W. (1959)。关于图的两个问题的注记。数字数学,1(1):269-271。
Nannicini,g .,Delling,d .,Liberti,l .,和 Schultes,D. (2008)。时间相关快速路径的双向 A搜索。在第七届国际实验算法会议记录*中,计算机科学讲义,334-346 页。
Russell,s .和 Norvig,P. (2009 年)。人工智能:现代方法,第三版。普伦蒂斯霍尔。
1 别急,我会在第十一章里重温“瑞典游”的问题。
2 最小费用二部匹配问题,在第十章中讨论。
好吧,我在这里假设不同的距离。如果多个节点具有相同的距离,则可能有多个候选节点。练习 9-2 要求你展示接下来会发生什么。
4 你可能会注意到,为了保持代码简单,回溯到S
的边在这里也被放松了。这对正确性或渐进运行时间没有影响,但是如果您愿意,您可以自由地重写代码来跳过这些节点。
*在 Dijkstra 算法的一个更传统的版本中,每个节点只被添加一次,但是它的估计在堆内被修改,如果一些更好的估计出现并覆盖它,你可以说这个路径被忽略。
6 如你所见,我只是实例化object
来创建节点s
。每个这样的实例都是唯一的(也就是说,它们在==
下不相等),这使得它们对于添加的虚拟节点以及其他形式的 sentinel 对象非常有用,这些对象需要与所有合法值不同。
7 称一个图稀疏的一个常见标准是,例如 m 是 O ( n )。不过,在这种情况下,只要 m 是O(n2/LGn,约翰逊的意志(渐近地)与弗洛伊德-沃肖尔的意志相匹配,这就允许了相当多的优势。另一方面,Floyd-Warshall 具有非常低的恒定开销。
你也可以在内存化版本中做同样的内存节省。参见练习 9-7。
9 实际上,当我为第一版写这一章时,已经证明(使用 35 年的 CPU 时间)魔方最难的位置需要 20 步(见www.cube20.org
)。
10 举个例子,在处理我的炼金术例子时,我去掉了像阿尔盖多和多拉这样的词。
11 那个数是 100,不是 100 的阶乘。(当然也不是 100 的 11 次方。)***
十、匹配、切割和流动
快乐的生活是个人的创造,无法从食谱中复制。
—米哈里·契克森米哈,心流:最佳体验心理学
虽然上一章给出了一个问题的几种算法,但这一章描述的是一种具有多种变化和应用的算法。核心问题是寻找网络中的最大流,我将使用的主要解决策略是 Ford 和 Fulkerson 的增广路径法。在解决整个问题之前,我将引导您解决两个更简单的问题,它们基本上是特例(它们很容易被简化为最大流)。这些问题,即二分匹配和不相交路径,本身有许多应用,可以通过更专门的算法来解决。您还会看到最大流问题有一个对偶,即最小割问题,这意味着您将同时自动解决这两个问题。最小割问题有几个有趣的应用,看起来与最大流问题非常不同,即使它们真的密切相关。最后,我会给你一些扩展最大流问题的方法,通过增加成本,寻找最大流的最便宜的,为最小成本二分匹配等应用铺平道路。
最大流问题及其变种几乎有无穷的应用。Douglas B. West 在他的书中(见第二章中的“参考文献”)给出了一些相当明显的例子,比如确定道路和通信网络的总容量,甚至是研究电路中的电流。Kleinberg 和 Tardos(参见第一章中的“参考资料”)解释了如何将形式主义应用于调查设计、航班调度、图像分割、项目选择、棒球淘汰以及分配医生休假。Ahuja、Magnanti 和 Orlin 已经写了关于这个主题的最全面的书籍之一,并且涵盖了工程、制造、调度、管理、医学、国防、通信、公共政策、数学和运输等不同领域的 100 多个应用。虽然算法适用于图形,但这些应用不需要完全像图形一样。例如,谁会认为图像分割是一个图形问题?在本章后面的“一些应用”一节中,我将带您浏览这些应用。如果您对如何使用这些技术感到好奇,您可能想在继续阅读之前快速浏览一下该部分。
贯穿本章的总体思想是,我们试图最大限度地利用网络,从一端移动到另一端,尽可能多地推动某种物质——无论是二分匹配的边、边不相交的路径还是流的单元。这和上一章谨慎的图形探索有点不同。尽管如此,增量改进的基本方法仍然存在。我们反复寻找方法来稍微改进我们的解决方案,直到它不能变得更好。你会看到取消*的想法是关键——我们可能需要删除以前解决方案的部分内容,以使其整体更好。
注意我在本章的实现中使用了福特和富尔克森的标记方法。另一个关于增加路径搜索的观点是,我们正在穿越一个剩余网络。这个想法将在本章后面的边栏“剩余网络”中解释。
二分匹配
我已经向你展示了双方匹配的想法,在第四章第一节中的暴躁的电影观众和第七章第三节中的稳定的婚姻问题中。一般来说,一个图的匹配 是边的节点不相交子集。也就是说,我们选择一些边,使得没有两条边共享一个节点。这意味着每条边匹配两对——因此得名。一种特殊的匹配适用于二分图,这种图可以分成两个独立的节点集(没有边的子图),如图 10-1 中的图。这正是我们在电影观众和婚姻问题中一直在处理的那种匹配,比一般的那种要容易处理得多。当我们谈论二分匹配时,我们通常想要一个最大的匹配,一个包含最大数量的边的匹配。这意味着,如果可能的话,我们想要一个完全匹配的*,一个所有节点都匹配的节点。这是一个简单的问题,但在现实生活中很容易发生。比方说,你正在给项目分配人员,图表显示了谁想做什么。完美的搭配会让每个人都满意。 1*
*
图 10-1 。一个二部图,有一个(非最大)匹配(粗边)和一条从 b 到 f 的增广路径(高亮)
我们可以继续使用稳定婚姻问题的比喻——我们将放弃稳定,努力让每个人都找到他们可以接受的对象。为了想象发生了什么,假设每个男人都有一枚订婚戒指。我们想要的是让每个男人把他的戒指给其中一个女人,这样就没有一个女人有一个以上的戒指。或者,如果这是不可能的,我们想把尽可能多的戒指从男人身上转移到女人身上,仍然禁止任何女人拥有一个以上的戒指。一如既往,为了解决这个问题,我们开始寻找某种形式的归约或归纳步骤。一个显而易见的想法是找出一对注定在一起的恋人,从而减少我们需要担心的情侣数量。然而,要保证任何一对都是最大匹配的一部分并不容易,除非,例如,它是完全隔离的,像图 10-1 中的 d 和 h 。
更适合这种情况的方法是迭代改进,,如第四章中所讨论的。这与第九章中放松的使用密切相关,因为我们将一步一步地改进我们的解决方案,直到我们无法再改进为止。我们还必须确保改进停止的唯一原因是解决方案是最优的——但我会回到这一点。让我们从寻找一些循序渐进的改进方案开始。让我们说,在每一轮中,我们试图将一枚额外的戒指从男子手中转移到女子手中。如果我们幸运的话,这将立刻给我们答案——也就是说,如果每个男人都把戒指给那个他认为最合适的女人。但是,我们不能让任何浪漫的倾向蒙蔽了我们的视线。这种方法很可能不会那么顺利。再次考虑图 10-1 中的图表。假设在我们的前两次迭代中, a 给 e 一个环, c 给 g 一个环。这给了我们一个由两对组成的试探性匹配(用黑色的粗边表示)。现在我们转向 b 。他要做什么?
让我们遵循一个有点类似于第七章中提到的盖尔-沙普利算法的策略,当有新的追求者接近时,女性可以改变主意。事实上,让我们命令他们总是做。所以当 b 询问 g 时,她将当前戒指归还给 c ,接受来自 b 的戒指。换句话说,她取消了与 c 的婚约。(这种取消的思想对于本章所有的算法都是至关重要的。)但是现在 c 是单一的,如果我们要确保迭代确实带来改进,我们就不能接受这种新情况。我们立即四处寻找 c 的新伴侣,在这个例子中是 e 。但是如果 c 将他归还的戒指交给 e ,她必须取消与 a 的婚约,归还他的戒指。他又把这个传递给 f ,我们就完成了。在这一次之字形交换之后,戒指沿着高亮显示的边缘来回传递。此外,我们现在已经将夫妇的数量从两个增加到三个( a + f,b + g ,以及 c + e )。
事实上,我们可以从这个特别的程序中提取出一个通用的方法。首先,我们需要找到一个不匹配的人。(如果不能,我们就完了。)然后,我们需要找到一些约定和取消的交替序列,以便我们以约定结束。如果我们能发现这一点,我们就知道肯定有一个约会比取消的多,增加了一对。我们只是尽可能长时间地寻找这样的曲折。
我们正在寻找的之字形是从左侧一个不匹配的节点到右侧一个不匹配的节点的路径。按照接合环的逻辑,我们看到路径只能移动到右边穿过已经在匹配中的而不是的边(建议),并且它只能移动左边穿过在匹配中是的边(取消)。这样的路径(如图 10-1 中突出显示的路径)被称为扩充路径,,因为它扩充了我们的解决方案(也就是说,它增加了参与计数),我们可以通过遍历找到扩充路径。我们只需要确保我们遵循规则——我们不能遵循右边匹配的边或左边不匹配的边。
剩下的就是确保我们确实能够找到这样的扩充路径,只要还有改进的空间。虽然这看起来似乎很合理,但是为什么一定是这样的还不是很明显。我们想表明的是,如果有改进的空间,我们可以找到一条增强的途径。这意味着我们有一个当前的匹配 M ,还有一些我们还没有找到的更大的匹配 M 。现在考虑这两者之间的对称差中的边——也就是说,这些边在其中一个对称差中,但不在两个对称差中。让我们称 M 中的边为红色,称M’中的边为绿色。
这种混乱的红绿边缘实际上会有一些有用的结构。例如,我们知道每个节点最多关联两条边,每种颜色一条边(因为它不可能有两条来自相同匹配的边)。这意味着我们有一个或多个相连的组件,每个组件都是曲折的路径或交替颜色的循环。因为 M 比 M 大,我们必须至少有一个组件的绿色边比红色边多,唯一可能发生的方式是在一条路径中——一条以绿色边开始和结束的奇数长度的路径。
你看到了吗?没错。这条绿-红-绿的道路将会是一条增广的道路。它的长度是奇数,所以一端在男性一侧,一端在女性一侧。第一条和最后一条边是绿色的,这意味着它们不是我们原始匹配的一部分,所以我们可以开始增加。(这基本上是我对所谓的伯奇引理的理解。)
在实施这一战略时,有很大的创造性空间。一个可能的实现如清单 10-1 所示。tr
函数的代码可以在清单 5-10 中找到。参数X
和Y
是节点的集合(可迭代对象),代表图G
的二分。运行时间可能不明显,因为边在执行期间被打开和关闭,但是我们知道在每次迭代中有一对被添加到匹配中,所以迭代次数是 O ( n ),对于 n 节点。假设 m 条边,寻找增广路径基本上就是遍历一个连通分支,也就是 O ( m )。那么,总共运行时间是 O ( nm )。
清单 10-1 。使用扩充路径寻找最大二部匹配
from itertools import chain
def match(G, X, Y): # Maximum bipartite matching
H = tr(G) # The transposed graph
S, T, M = set(X), set(Y), set() # Unmatched left/right + match
while S: # Still unmatched on the left?
s = S.pop() # Get one
Q, P = {s}, {} # Start a traversal from it
while Q: # Discovered, unvisited
u = Q.pop() # Visit one
if u in T: # Finished augmenting path?
T.remove(u) # u is now matched
break # and our traversal is done
forw = (v for v in G[u] if (u,v) not in M) # Possible new edges
back = (v for v in H[u] if (v,u) in M) # Cancellations
for v in chain(forw, back): # Along out- and in-edges
if v in P: continue # Already visited? Ignore
P[v] = u # Traversal predecessor
Q.add(v) # New node discovered
while u != s: # Augment: Backtrack to s
u, v = P[u], u # Shift one step
if v in G[u]: # Forward edge?
M.add((u,v)) # New edge
else: # Backward edge?
M.remove((v,u)) # Cancellation
return M # Matching -- a set of edges
注柯尼希定理陈述了对于二部图,最大匹配问题的对偶就是最小顶点覆盖问题。换句话说,这些问题是等价的。
不相交的路径
寻找匹配的增广路径法也可以用于更一般的问题。最简单的概括可能是计算条边不相交的路径 而不是条边。 2 边不相交的路径可以共享节点,但不能共享边。在这种更一般的情况下,我们不再需要把自己局限于二分图。然而,当我们允许一般的有向图时,我们可以自由地指定路径的起点和终点。最简单(也是最常见)的解决方案是指定两个特殊的节点, s 和 t ,称为源和接收器。(这样的图通常被称为 s - t 图,或 s - t 网络。)然后,我们要求所有路径从 s 开始,到 t 结束(隐含地允许路径共享这两个节点)。这个问题的一个重要应用是确定一个网络的边连通性——在该图断开之前(或者,在这种情况下,在 s 不能到达 t 之前)可以删除(或者“失败”)多少条边?
另一个应用是在多核 CPU 上寻找通信路径。您可能有许多二维布局的内核,并且由于通信的工作方式,不可能通过相同的交换点路由两个通信通道。在这些情况下,找到一组不相交的路径至关重要。注意,这些路径可能更自然地被建模为顶点不相交,而不是边不相交。详见练习 10-2。此外,只要您需要将每个源核心与一个特定的接收核心配对,您就有了一个被称为 多商品流问题的版本,这里不讨论这个问题。(参见“如果你好奇…”以获得一些提示。)
*你可以在算法中直接处理多个源和汇,就像清单 10-1 中的一样。如果这些源和汇中的每一个都只能包含在一条路径中,并且您不关心哪个源与哪个汇配对,那么将问题简化为单源、单汇的情况会更容易。你可以通过添加 s 和 t 作为新节点,并引入从 s 到你的所有源和从你的所有汇到 t 的边。路径的数量将是相同的,重建您正在寻找的路径只需要再次剪掉 s 和 t 。事实上,这种减少使得最大匹配问题成为不相交路径问题的特例。如你所见,解决问题的算法也非常相似。
不要考虑完整的路径,能够孤立地看待问题的较小部分将是有用的。我们可以通过引入两个规则来做到这一点:
- 除了 s 或 t 之外,进入任何节点的路径数必须等于从该节点出去的路径数。
- 至多条路径可以通过任何给定的边。
考虑到这些限制,我们可以使用遍历来找到从 s 到 t 的路径。在某种程度上,我们无法找到更多的路径,而不与我们已经拥有的一些路径重叠。不过,我们可以再次使用上一节中的增加路径的思想。参见,例如,图 10-2 。第一轮遍历建立了一条从 s 经由 c 和 b 到 t 的路径。现在,任何进一步的进展似乎都被这条路径阻碍了——但是增加路径的想法让我们通过取消从 c 到 b 的边来改进解决方案。
图 10-2 。一个发现了一条路径(粗边)和一条增广路径(高亮显示)的 s-t 网络
取消的原理就像二分匹配一样。当我们寻找一条增加的路径时,我们从 s 移动到 a ,然后到 b 。在那里,我们被边缘 bt 挡住了。此时的问题是 b 有来自 a 和 c 的两条输入路径,但只有一条输出路径。通过取消边缘 cb ,我们已经解决了 b 的问题,但是现在在 c 处有一个问题。这和我们在二分匹配中看到的级联效应是一样的。在这种情况下, c 有一条来自 s 的传入路径,但是没有传出路径——我们需要为该路径找到一个路径。我们通过继续我们的路径通过 d 到 t 来做到这一点,如图 10-2 中的突出显示所示。
如果你在某个节点 u 上增加一个输入边沿或者取消一个输出边沿,那么那个节点就会过度拥挤。它将有更多的路径进入而不是离开,这是不允许的。你可以通过增加一个输出边缘或者取消一个输入边缘来解决这个问题。总而言之,这解决了从 s 开始寻找路径的问题,沿着未使用的边的方向,使用过的边逆着的方向。任何时候你能找到这样一条增加的路径,你也会发现一条额外的不相交的路径。
清单 10-2 展示了实现这个算法的代码。和以前一样,tr
函数的代码可以在清单 5-10 中找到。
清单 10-2 。使用标记遍历来计数边不相交路径以寻找扩充路径
from itertools import chain
def paths(G, s, t): # Edge-disjoint path count
H, M, count = tr(G), set(), 0 # Transpose, matching, result
while True: # Until the function returns
Q, P = {s}, {} # Traversal queue + tree
while Q: # Discovered, unvisited
u = Q.pop() # Get one
if u == t: # Augmenting path!
count += 1 # That means one more path
break # End the traversal
forw = (v for v in G[u] if (u,v) not in M) # Possible new edges
back = (v for v in H[u] if (v,u) in M) # Cancellations
for v in chain(forw, back): # Along out- and in-edges
if v in P: continue # Already visited? Ignore
P[v] = u # Traversal predecessor
Q.add(v) # New node discovered
else: # Didn't reach t?
return count # We're done
while u != s: # Augment: Backtrack to s
u, v = P[u], u # Shift one step
if v in G[u]: # Forward edge?
M.add((u,v)) # New edge
else: # Backward edge?
M.remove((v,u)) # Cancellation
为了确保我们已经解决了这个问题,我们仍然需要证明相反的情况——只要还有改进的空间,就总会有增加的途径。展示这一点最简单的方法是使用连通性的概念:我们必须去掉多少条边才能将 s 与 t 分开(这样就没有路径从 s 到 t )?任何这样的集合都代表一个 s - t 割,一个划分为两个集合 S 和 T ,其中 S 包含 s 而 T 包含 t 。我们称从 S 到 T 的边为定向边分隔符*。然后,我们可以证明以下三个语句是等价的:*
- 我们已经发现了 k 条不相交的路径,并且有一个大小为 k 的边分隔符。
- 我们已经找到了不相交路径的最大数量。
- 没有增加的路径。
我们主要想表明的是,最后两个语句是等价的,但有时通过第三个语句更容易,比如本例中的第一个语句。
很容易看出第一个暗示着第二个。姑且称分隔符 F 。任何 s - t 路径在 F 中必须至少有一条边,这意味着 F 的大小至少与不相交的 s - t 路径的数量一样大。如果分隔符的大小与我们找到的不相交路径的数量相同,显然我们已经达到了最大值。
证明第二个陈述暗示第三个陈述很容易通过矛盾来完成。假设没有改进的空间,但我们仍然有一个增强的路径。如前所述,这条增加的路径可以用来改进解决方案,所以我们有一个矛盾。
唯一需要证明的是,最后一个陈述隐含了第一个陈述,这就是整个连通性思想作为垫脚石的好处。想象你已经执行了算法,直到你用完了增加的路径。让 S 是你在最后一次遍历中到达的节点集,让 T 是剩余的节点。很明显,这是一个 s - t 切。考虑这个切口的边缘。从 S 到 T 的任何前向边都必须是您发现的不相交路径之一的一部分。如果不是,您应该在遍历过程中跟随它。出于同样的原因,从 T 到 S 的任何边都不能是其中一条路径的一部分,因为你可以取消它,从而到达 T 。换句话说,从 S 到 T 的所有边都属于你的不相交路径,并且因为其他方向的边都不属于你的不相交路径,所以前向边必须都属于它们自己的路径,这意味着你有 k 条不相交路径和一个大小为 k 的分隔符。
这可能有点复杂,但是直觉告诉我们,如果我们找不到一条增加的路径,那么在某个地方一定有一个瓶颈,我们一定已经填补了它。无论我们怎么做,都无法获得更多路径通过这个瓶颈,所以算法一定找到了答案。(这个结果是门格尔定理的一个版本,,它是最大流最小割定理的一个特例,稍后你会看到。)
那么这一切要持续多长时间?每次迭代由从 s 开始的相对直接的遍历组成,对于 m 边,其运行时间为 O ( m )。每一轮都给了我们另一条不相交的路径,而且明明最多有 O ( m ),意思是运行时间是O(m2)。练习 10-3 要求你证明在最坏的情况下这是一个紧界。
注门格尔定理是对偶的另一个例子:从 s 到 t 的边不相交路径的最大数等于 s 和 t 之间的最小割。这是最大流最小割定理的一个特例,稍后讨论。
最大流量
这是这一章的中心问题。它形成了二分匹配和不相交路径的一般化,并且是最小割问题的镜像(下一节)。与不相交路径情况的唯一区别在于,我们没有将每条边的容量 设置为 1,而是将其设为任意正数。如果容量是一个正整数,您可以把它看作是可以通过它的路径的数量。更一般地说,这里的比喻是某种形式的物质在网络中流动,从源头到汇点,容量代表了有多少单元可以流过给定边的限制。(你可以把这看作是配对中来回传递的订婚戒指的概括。)一般来说,流本身就是若干个流单元对每个单元的赋值(即从边到数的函数或映射),而流的大小或大小则是通过网络推送的总量。(例如,这可以通过找到流出源头的净流量来找到。)注意,尽管流网络通常被定义为有向网络,你也可以在无向网络中找到最大流(练习 10-4)。
让我们看看如何解决这个更普遍的情况。一个天真的方法是简单地分割边缘,就像第九章中 BFS 的天真延伸(图 9-3)。不过现在,我们想把它们纵向分开,如图 10-3 中的所示。就像带有串行虚拟节点的 BFS 给你一个 Dijkstra 算法如何工作的好主意一样,我们带有并行虚拟节点的扩充路径算法非常接近用于寻找最大流的完整福特-富尔克森算法的工作方式。不过,与 Dijkstra 的情况一样,实际算法可以一次处理更多的流,这意味着虚拟节点方法(一次只能饱和一个单位的容量)的效率低得令人绝望。
图 10-3 。由虚拟节点模拟的边缘容量
让我们看一下技术细节。就像在 0-1 的情况下,我们有两条规则来确定我们的流如何与边和节点交互。如您所见,它们与不相交的路径规则非常相似:
- 除了 s 或 t 之外,进入任何节点的流量必须等于从该节点流出的流量。**
- 最多有 c ( e )个单位的流量可以通过任意给定的边。
这里, c ( e )是 edge e 的容量。就像对于不相交的路径,我们需要沿着边的方向,所以沿着边的流返回总是零。一个遵守我们两个规则的流程被称为可行。
不过,这可能是你需要放松和集中注意力的地方。我接下来要说的其实并不复杂,但可能会有点混乱。我被允许逆着一条边的方向推动水流,只要已经有一些水流向正确的方向。你知道这是怎么回事吗?我希望前两节已经为你做好了准备——这完全是取消流量的问题。如果我有一个单位的流量从 a 流向 b ,我可以取消那个单位,实际上是把一个单位推向另一个方向。最终结果为零,因此没有实际流向错误的方向(这是完全禁止的)。
这个想法让我们创建增加的路径,就像以前一样:如果你在某个节点 u 沿输入边增加 k 个单位的流量,或者取消输出边上的 k 个单位的流量,那个节点就会溢出。流入的流量会比流出的多,这是不允许的。您可以通过沿着输出边添加 k 个流单位或者通过在输入边上取消 k 个流单位来解决这个问题。这正是你在 0-1 的情况下所做的,除了 k 总是 1。
在图 10-4 中,显示了同一流程网络的两种状态。在第一状态中,流已经沿着路径s-c-b-t被推动,给出总流值 2。这种流动阻碍了沿前边缘的任何进一步改进。正如你所看到的,增加的路径包括一个向后的边缘。通过取消从 c 到 b 的一个流量单位,我们可以从 c 经由 d 到 t 发送一个额外的单位,达到最大值。
图 10-4 。通过增广路径增广前后的流量网络(突出显示)
如本节所解释的,一般的福特-富尔克森方法 不给出任何运行时间保证。事实上,如果无理数容量(包含平方根等)被允许,迭代增加可能永远不会终止。对于实际应用来说,使用无理数可能不太现实,但即使我们把自己限制在有限精度的浮点数,甚至是整数,我们还是会遇到麻烦。考虑一个非常简单的网络,有源、宿和另外两个节点, u 和 v 。两个节点都有从源到宿的边,都具有容量 k 。我们还有一个从 u 到 v 的单位产能优势。如果我们继续选择增加通过边缘 uv 的路径,在每次迭代中增加和取消一个单位的流,这将在终止之前给我们 2 k 次迭代。
这个运行时间有什么问题?它是伪多项式——实际问题规模的指数。我们可以很容易地提高容量,从而增加运行时间,而不需要占用太多的空间。令人恼火的是,如果我们更聪明地选择了增补路线(例如,完全避开边缘 uv ,我们将在两轮中完成,而不管容量 k 。
幸运的是,这个问题有一个解决方案,它给我们一个多项式的运行时间,不管容量是多少(甚至是无理数!).事实是,Ford-Fulkerson 并不是一个完全指定的算法,因为它的遍历是完全任意的。如果我们选择 BFS 作为遍历顺序(从而总是选择最短的扩充路径),我们最终会得到所谓的埃德蒙兹-卡普算法, ,这正是我们正在寻找的解决方案。对于 n 节点和 m 边,埃德蒙兹-卡普在 O ( nm 2 )时间内运行。然而,这种情况并不完全明显。为了得到彻底的证明,我推荐在 Cormen 等人的书中查找该算法(参见第一章中的“参考文献”)。大致思路是:每条最短增广路径在 O ( m )时间内找到,当我们沿着它增广流量时,至少有一条边是饱和的(流量达到容量)。每次边缘饱和时,离源的距离(沿着增加的路径)必须增加,这个距离最多是 O ( n )。因为每个边沿最多可以饱和 O ( n )次,所以我们得到的是 O ( nm )次迭代,总运行时间为O(nm2)。
对于一般的福特-富尔克森方法(因此也是埃德蒙兹-卡普算法)的正确性证明,参见下一节,最小切割。但是,正确性证明确实假设了终止性,如果您避免不合理的容量或者如果您简单地使用 Edmonds-Karp 算法(它具有确定的运行时间),这是有保证的。
清单 10-3 中给出了一个基于 BFS 的扩充遍历。清单 10-4 中的显示了完整的福特-富尔克森方法的实现。为简单起见,假设 s 和 t 是不同的节点。默认情况下,该实现使用基于 BFS 的增强遍历,这为我们提供了 Edmonds-Karp 算法。主函数(ford_fulkerson
)非常简单,与本章前面的两个算法非常相似。主while
循环继续下去,直到不可能找到一条增加的路径,然后返回流程。每当发现一条增加的路径时,它被回溯到s
,将路径的容量加到每个前向边上,并从每个反向边上减去(消除)它。
清单 10-3 中的bfs_aug
函数类似于前面算法中的遍历。它使用一个deque
来获得 BFS,并使用P
图构建遍历树。如果有一些剩余容量(G[u][v]-f[u,v] > 0
,它只遍历前向边缘,如果有一些流量要取消(f[v,u] > 0
),它只遍历后向边缘。标记 包括设置遍历前趋(在P
中)和记住有多少流可以传输到这个节点(存储在F
中)。这个流量值是(1)我们设法传输到前一个的流量和(2)连接边上的剩余容量(或反向流量)的最小值。这意味着一旦我们到达t
,路径的总松弛度(我们可以推动通过它的额外流量)是F[t]
。
注意如果你的能力是整数,增量也总是整数,导致一个整数流。这是使最大流问题(以及解决它的大多数算法)得到如此广泛应用的性质之一。
清单 10-3 。用 BFS 和标号寻找增广路径
from collections import deque
inf = float('inf')
def bfs_aug(G, H, s, t, f):
P, Q, F = {s: None}, deque([s]), {s: inf} # Tree, queue, flow label
def label(inc): # Flow increase at v from u?
if v in P or inc <= 0: return # Seen? Unreachable? Ignore
F[v], P[v] = min(F[u], inc), u # Max flow here? From where?
Q.append(v) # Discovered -- visit later
while Q: # Discovered, unvisited
u = Q.popleft() # Get one (FIFO)
if u == t: return P, F[t] # Reached t? Augmenting path!
for v in G[u]: label(G[u][v]-f[u,v]) # Label along out-edges
for v in H[u]: label(f[v,u]) # Label along in-edges
return None, 0 # No augmenting path found
清单 10-4 。福特-富尔克森法(默认为埃德蒙兹-卡普算法)
from collections import defaultdict
def ford_fulkerson(G, s, t, aug=bfs_aug): # Max flow from s to t
H, f = tr(G), defaultdict(int) # Transpose and flow
while True: # While we can improve things
P, c = aug(G, H, s, t, f) # Aug. path and capacity/slack
if c == 0: return f # No augm. path found? Done!
u = t # Start augmentation
while u != s: # Backtrack to s
u, v = P[u], u # Shift one step
if v in G[u]: f[u,v] += c # Forward edge? Add slack
else: f[v,u] -= c # Backward edge? Cancel slack
剩余网络
一个经常用来解释福特-富尔克森方法及其相关方法的抽象概念是剩余网络。剩余网络Gf是相对于原始流网络 G 以及流 f 定义的,并且是在寻找扩充路径时使用的表示遍历规则的方式。在 G
换句话说,我们在 G 中的特殊增广遍历现在变成了在 G f 中的完全正常的遍历。当在剩余网络中不再有从源到宿的路径时,该算法终止。虽然这个想法主要是形式上的,使得使用普通的图论来推理增强成为可能,但是如果你愿意(练习 10-5),你也可以显式地实现它,作为实际图形的动态视图。这将允许您直接在剩余网络上使用 BFS 的现有实现,以及(稍后您将看到)贝尔曼-福特和迪克斯特拉。
最小切割
就像零一流产生了门格尔定理一样,更一般的流问题给了我们福特和富尔克森的最大流最小截定理,我们可以用类似的方式证明它。
- 我们已经找到了一个大小为 k 的流,并且有一个容量为 k 的流*。*
** 我们已经找到了最大流量。* 没有增加的路径。*
*证明这一点将给我们两件事:它将表明福特-富尔克森方法是正确的,它意味着我们可以用它来寻找最小割,这本身就是一个有用的问题。(我会回到这个话题。)
和零一的情况一样,第一个明显意味着第二个。每个流量单位都必须通过任何一个 s - t 切割,所以如果我们有一个容量切割 k ,那就是流量的上限。如果我们有一个流量等于一个切割的容量,那么这个流量一定是最大的,而这个切割一定是最小的。这就是所谓的二元性的一个例子。
从第二个陈述(我们已经达到最大值)到第三个陈述(没有增加的路径)的含义再次被矛盾证明。假设我们已经达到了最大值,但是仍然有一个增加的路径。然后我们可以用这条路来增加我们的流量,这是一个矛盾。
最后一步(没有增加路径意味着我们有一个等于流的切割)再次使用遍历来构造一个切割。也就是说,我们让 S 是我们在最后一次迭代中可以到达的节点集, T 是余数。任何穿过切口的前沿都必须是饱和的,否则我们就会穿过它。同样,任何后向边都必须是空的。这意味着通过切口的流量正好等于其容量,这就是我们想要展示的。
最小割有几个看起来不像最大流问题的应用。例如,考虑以最小化两个处理器之间通信的方式将进程分配给两个处理器的问题。假设其中一个处理器是 GPU,进程在两个处理器上有不同的运行时间。有些更适合 CPU,而有些应该在 GPU 上运行。然而,可能会有这样的情况,一个安装在 CPU 上,一个安装在 GPU 上,但是两者之间会进行大量的通信。在这种情况下,我们可能希望将它们放在同一个处理器上,只是为了降低通信成本。
我们如何解决这个问题?例如,我们可以建立一个无向流网络,将 CPU 作为源,将 GPU 作为宿。每个进程都有一条通向源和接收器的边,其容量等于在该处理器上运行所需的时间。我们还在进行通信的进程之间添加了边,其容量代表了它们在不同处理器上的通信开销(额外的计算时间)。然后,最小割会以总成本尽可能小的方式在两个处理器上分配进程——如果我们不能归结为最小割问题,这是一个不小的任务。
总的来说,你可以把全流网络形式主义看作是一种特殊的算法机器,你可以用它通过归约来解决其他问题。任务变成了构建某种形式的流网络,其中最大流或最小割代表原始问题的解决方案。
二元性
这一章有几个对偶的例子:最大二部匹配是最小二部顶点覆盖的对偶,最大流是最小割的对偶。还有几个类似的例子,比如最大张力问题,它是最短路径问题的对偶。一般来说,对偶涉及两个优化问题,原始的和对偶的,其中两个有相同的优化成本,解决一个将解决另一个。更具体地说,对于一个最大化问题 A 和一个最小化问题 B,如果 A 的最优解小于或等于 B 的最优解,我们有弱对偶*,如果它们相等(对于最大流最小割的情况),我们有强对偶。如果你想知道更多关于对偶性的知识(包括一些更高级的内容),看看 Go 和 Yang 的《最优化中的对偶性与变分不等式》。*
*最便宜的流和指派问题 4
在离开流量这个话题之前,我们先来看看一个重要的、相当明显的延伸;我们来找最便宜的最大流量。也就是说,我们仍然希望找到最大流量,但是如果有多种方法可以达到相同的流量大小,我们希望找到最便宜的方法。我们通过向边添加成本来形式化这一点,并将总成本定义为所有边 e 上的w(e)f(e)之和,其中 w 和 f 分别是成本和流函数。也就是说,成本是给定边缘上每单位流量的*。*
一个直接的应用是二分匹配问题的扩展。我们可以继续使用零一流公式,但会增加每个边的成本。然后,我们有了最小成本二分匹配(或分配)问题的解决方案,在引言中暗示:通过找到最大流,我们知道我们有一个最大匹配,通过最小化成本,我们得到我们正在寻找的匹配。
这个问题通常简称为最小费用流。这意味着,我们不是寻找最便宜的最大流量,而是简单地寻找给定数量的最便宜的流量。例如,问题可能是“给我一个大小为 k 的流,如果这样的流存在的话,确保你尽可能便宜地构建它。”例如,你可以构造一个尽可能大的流,直到 k 的值。这样,找到最大流量(或最小成本最大流量)只需将 k 设置为一个足够大的值。事实证明,仅仅关注最大流量就足够了;我们可以通过简单的简化优化到一个特定的流量值,而不需要修改算法(见练习 10-6)。
Busacker 和 Gowen 提出的解决最小费用流问题的思想是:寻找 最便宜的增广路径。也就是说,在遍历步骤中,对加权图使用最短路径算法,而不仅仅是 BFS。唯一的问题是,为了找到最短路径,向后遍历的边的成本是无效的。(毕竟它们是用来取消流量的。)
如果我们可以假设成本函数是正的,我们可以使用 Dijkstra 的算法来找到我们的扩充路径。问题是,一旦你把一些流从 u 推到 v ,我们就可以突然遍历(虚构的)反向边 vu ,它有一个负成本。换句话说,Dijkstra 的算法在第一次迭代中会工作得很好,但是在那之后,我们就完蛋了。幸运的是,埃德蒙兹和卡普想到了一个巧妙的方法来解决这个问题——一个与约翰逊算法中使用的方法非常相似的方法(见第九章)。我们可以通过以下方式调整所有权重:( 1)使它们都为正,以及(2)沿着所有遍历路径形成伸缩和,确保最短路径仍然是最短的。
假设我们正在执行算法,并且我们已经建立了一些可行的流程。设 w ( u , v )为边权重,根据增加路径遍历的规则进行调整(即沿着有剩余容量的边不变,沿着正向流的后向边取反)。让我们再次(即,就像在约翰逊的算法中一样)设置h(v)=d(s, v ),其中距离是相对于 w 计算的。然后我们可以定义一个调整后的权重,我们可以用它来寻找下一个增强路径: w '( u ,v)=w(u,v)+h(u)-h(v使用与第九章中相同的推理,我们看到该调整将保留所有最短路径,特别是从 s 到 t 的最短增补路径。**
实现基本的 Busacker-Gowen 算法基本上是一个在bfs_aug
( 清单 10-3 )的代码中用例如 Bellman-Ford(见清单 9-2 )替换 BFS 的问题。如果你想使用 Dijkstra 的算法,你只需使用修改后的权重,如前所述(练习 10-7)。关于基于贝尔曼-福特的实现,见清单 10-5 。(该实现假设边权重由单独的地图给出,因此W[u,v]
是从u
到v
的边的权重或成本。)注意,来自福特-富尔克森标记方法的流标记已经与贝尔曼-福特的 relax 操作合并——两者都在label
函数中执行。要做任何事情,你们都必须找到一条更好的路径,并且在新的边缘有一些空闲容量。如果是这种情况,距离估计和流标签都被更新。
Busacker-Gowen 方法的运行时间取决于您选择的最短路径算法。我们不再使用埃德蒙兹-卡普方法,所以我们失去了它的运行时间保证,但是如果我们使用整数容量并寻找价值流 k ,我们保证最多 k 次迭代。5 假设 Dijkstra 的算法,总运行时间变成O(kmLGn)。对于最小代价二分匹配, k 将是 O ( n ),因此我们将得到O(nmLGn)。
从某种意义上来说,这是一个贪婪的算法,我们逐步建立流量,但在每一步中增加尽可能少的成本。凭直觉,这似乎应该行得通,事实上也确实如此,但证明同样多可能有点挑战性——事实上,以至于我在这里不打算详细说明。如果你想阅读证明(以及关于运行时间的更多细节),可以看一下 Dieter Jungnickel 在图、网络和算法中关于循环的章节。 6 你可以在 Kleinberg 和 Tardos 的算法设计中找到最小代价二分匹配特例的更简单的证明(参见第一章“参考文献”)。
清单 10-5 。布萨克-戈恩算法,使用贝尔曼-福特进行增强
def busacker_gowen(G, W, s, t): # Min-cost max-flow
def sp_aug(G, H, s, t, f): # Shortest path (Bellman-Ford)
D, P, F = {s:0}, {s:None}, {s:inf,t:0} # Dist, preds and flow
def label(inc, cst): # Label + relax, really
if inc <= 0: return False # No flow increase? Skip it
d = D.get(u,inf) + cst # New possible aug. distance
if d >= D.get(v,inf): return False # No improvement? Skip it
D[v], P[v] = d, u # Update dist and pred
F[v] = min(F[u], inc) # Update flow label
return True # We changed things!
for _ in G: # n = len(G) rounds
changed = False # No changes in round so far
for u in G: # Every from-node
for v in G[u]: # Every forward to-node
changed |= label(G[u][v]-f[u,v], W[u,v])
for v in H[u]: # Every backward to-node
changed |= label(f[v,u], -W[v,u])
if not changed: break # No change in round: Done
else: # Not done before round n?
raise ValueError('negative cycle') # Negative cycle detected
return P, F[t] # Preds and flow reaching t
return ford_fulkerson(G, s, t, sp_aug) # Max-flow with Bellman-Ford
一些应用
正如最初所承诺的,我现在将概述本章中一些技术的一些应用。我不会给你所有的细节或实际代码——如果你想对这些材料有更多的体验,你可以在 尝试实现这些解决方案。
棒球淘汰赛。这个问题的解决方案由 Benjamin L. Schwartz 于 1966 年首次发表。如果你像我一样,你可以放弃棒球的背景,想象这是一场骑士比武的循环赛(如第四章中所讨论的)。无论如何,想法是这样的:你有一个部分完成的锦标赛(棒球相关或其他),你想知道某个队,比如火星绿皮肤队,是否有可能赢得锦标赛。也就是说,如果他们总共最多能赢 W 场比赛(如果他们赢下剩下的每一场比赛),有没有可能达到其他球队都没有超过 W 场胜利的局面?
这个问题如何通过减少到最大流来解决并不明显,但是让我们试一试。我们将建立一个具有完整流的网络,其中每个流单元代表一个剩余的游戏。我们创建节点 x 1 ,…, x n 来表示其他团队,以及节点pij来表示每对节点 x i 和 x j 。除此之外,当然我们还有源 s 和宿 t 。从 s 到每个组节点添加一条边,从每个对节点到 t 添加一条边。对于一对节点pij,加上来自 x i 和 x j 的边,容量为无穷大。从对节点pij到 t 的边得到一个容量,该容量等于在 x i 和 x j 之间剩余的游戏数。如果团队 x i 已经赢了 w i 游戏,那么从 s 到xI的边得到一个 W - w**
我说过,每个流量单位代表一个游戏。想象一下从 s 到 t 跟踪单个单元。首先,我们来到一个团队节点,代表赢得这场比赛的团队。然后我们来到一个 pair 节点,代表我们面对的是哪一队。最后,沿着一条边移动到 t ,我们吞噬了一个代表所讨论的两支球队之间的一场比赛的容量单位。我们可以将所有优势饱和到 t 中的唯一方法是,如果所有的剩余的比赛都可以在这些条件下进行——也就是说,没有一支队伍赢得的比赛总数超过 W 场。因此,找到最大流量就给了我们答案。要获得更详细的正确性证明,请参见 Douglas B. West 的图论介绍的第 4.3 节(参见第二章的参考资料)或 B. L. Schwartz 的原始资料部分完成的比赛中可能的赢家。
选择代表。 Ahuja 等人描述了这个有趣的小问题。一个小镇,有 n 居民, x 1 ,…, x n 。还有 m 社、c1、…、 c m 和 k 政党、 p 1 、…、 p k 。每个居民至少是一个俱乐部的成员,并且只能属于一个政党。每个俱乐部必须提名一名成员代表它参加市议会。不过,有一个问题:属于党派 p i 的代表人数最多只能是 u i 。有可能找到这样一组代表?再次,我们减少到最大流量。通常情况下,我们将问题的对象表示为节点,并将它们之间的约束表示为边和容量。在这种情况下,每个居民、俱乐部和聚会都有一个节点,还有源 s 和接收器 t 。
流动的单位代表了代表。因此,我们给每个俱乐部一个来自 s 的优势,容量为 1,代表他们可以提名的单个人。从每个俱乐部中,我们给属于那个俱乐部的每个人增加一个优势,因为他们形成了候选人。(这些边上的容量并不重要,只要它至少是 1。)注意,每个人可以有多个 in-edge(即属于多个俱乐部)。现在,将居民的优势添加到他们的政党中(每人一个)。这些边的容量也是 1(这个人只能代表一个俱乐部)。最后,将来自各方的边添加到 t 中,这样来自各方的边 p i 的容量为 u i ,限制了理事会中的代表人数。找到一个最大流量将会给我们带来一组有效的提名。
当然,这个最大流解决方案只给了一个有效的提名集,不一定是我们想要的。我们可以假设政党能力 u i 是基于民主原则(某种形式的投票);代表的选择不应该同样基于俱乐部的偏好吗?也许他们可以举行投票,以表明他们有多希望每个成员代表他们,所以成员得到的分数,比如说,等于他们的投票百分比。然后,我们可以尝试最大化这些分数的总和,同时仍然确保提名在全球范围内有效。明白我的意思了吗?完全正确:我们可以扩展 Ahuja 等人的问题,在从俱乐部到居民的边上增加一个成本(例如,等于 100 分),然后我们解决最小成本最大流问题。事实上,我们得到了最大的流量,这将保证提名的有效性,而成本最小化将根据俱乐部的偏好给我们最好的妥协。
休假中的医生。克莱恩伯格和塔尔多斯(见第一章中的“参考文献”)描述了一个有点类似的问题。不同的对象和约束,但想法仍然有些相似。问题是给医生分配假期。每个假期必须至少指派一名医生,但如何做到这一点是有限制的。首先,每个医生只在一些假期有空。第二,每个医生最多只能工作 c 天。第三,在每个假期中,每个医生只能在一天工作。你知道如何将流量降到最大吗?
同样,我们有一组相互之间有约束的对象。除了接收器 s 和源 t 之外,我们至少需要每个医生一个节点,每个假期一个节点。我们从 s 给每个医生一个容量为 c 的 in-edge,代表每个医生可以工作的天数。现在我们可以开始将医生与日期直接联系起来,但是我们如何表达假期期间的想法呢?我们可以为每个医生添加一个节点,但是每个医生在每个时期都有单独的约束,所以我们需要更多的节点。每个医生每个假期都有一个节点,每个节点都有一个出边。例如,每个医生都有一个圣诞节节点。如果我们将这些外部边缘上的能力设置为 1,则医生在每个周期中不能工作超过一天。最后,我们将这些新的周期节点与医生有空的日子联系起来。因此,如果 Zoidberg 博士在圣诞节期间只能在平安夜和圣诞节工作,我们就在这两个日期加上他的圣诞节节点的外边缘。
最后,每一个假期都有一个优势。我们在这些方面设置的容量取决于我们是否希望找到多少医生,或者我们是否希望每个假期只有一名医生。无论哪种方式,找到最大流量会给我们我们正在寻找的答案。就像我们扩展前面的问题一样,我们可以再次考虑偏好,通过添加成本,例如在从每个医生的假期节点到个人假期的边上。然后,通过寻找最小成本流,我们不仅会找到一个可能的解决方案,我们还会找到一个引起最少总体不满的方案。
**供给与需求。**想象一下你正在管理某种形式的行星递送服务(或者,如果你喜欢一个不那么奇特的例子,一家运输公司)。你正试图计划一些商品的分销,例如波普勒。每个星球(或海港)都有一定的供给或需求(以每月的 popplers 来衡量),而你在这些星球之间的航线有一定的运力。我们如何对此建模?
事实上,这个问题的解决方案给了我们一个非常好的工具。不仅仅是解决这个特定的问题(无论如何,这只是对潜在的流问题的一个不加掩饰的描述),让我们更一般地描述一些事情。你有一个类似于我们到目前为止看到的网络,除了我们不再有一个源或一个汇。相反,每个节点 v 都有一个电源 b ( v )。该值也可以是负的,代表需求。为了简单起见,我们可以假设供给和需求的总和为零。我们现在想知道的不是找到最大流量,而是使用可用的供给是否能满足需求。我们称之为相对于 b 的可行流程。
我们需要一个新的算法吗?幸运的是,没有。归约又一次拯救了我们。给定一个有供给和需求的网络,我们可以构建一个简单的流量网络,如下所示。首先,我们添加一个源 s 和一个宿 t 。然后,具有供应的每个节点 v 从 s 获得一个输入边,其供应作为容量,而具有需求的每个节点获得一个输出边到 t ,其需求作为容量。我们现在解决这个新网络的最大流问题。如果流浸透了到汇点的所有边(就此而言,还有来自源点的边),我们就找到了一个可行的流(我们可以通过忽略 s 和 t 及其边来提取它)。
一致矩阵 舍入**。**你有一个浮点数矩阵,你想把所有的数都四舍五入成整数。每一行和每一列都有一个总和,你也要对这些总和进行四舍五入。您可以自由选择在每种情况下是向上舍入还是向下舍入(也就是说,是使用math.floor
还是math.ceil
),但是您必须确保每行和每列中舍入数字的总和与舍入的列或行总和相同。(您可以将此视为一个标准,该标准寻求在舍入后保留原始矩阵的一些重要属性。)我们称这样的舍入方案为一致舍入。
这看起来很数字,对吧?您可能不会立即想到图表或网络流。实际上,如果除了容量(这是一个上限)之外,我们首先在每个边中引入流的下界*,这个问题会更容易解决。这给了我们一个新的初始障碍:找到一个关于边界的可行流。一旦我们有了一个可行的流量,只要稍微修改一下福特-富尔克森方法就可以找到最大流量,但是我们如何找到这个可行的初始流量呢?这远不如找到一个可行的供给和需求流程那么容易。我将在这里简单描述一下主要观点——详情请参考 Douglas B. West 的《图论介绍》中的第 4.3 节,或者 Ahuja 等人的《网络流》中的第 6.7 节。*
第一步是添加一条从 t 到 s 的边,其容量为无穷大(下界为零)。我们现在不再有一个流量网络,但我们可以寻找一个 循环,而不是寻找一个流量。一个循环就像一个流,除了它在每个节点都有流量守恒。换句话说,没有任何源头或汇点可以免于保护。循环不会在某个地方出现,在另一个地方消失;它只是在网络中“四处移动”。我们仍然有上限和下限,所以我们现在的任务是找到一个可行循环*(这将给出原始图中的可行流)。
如果一条边 e 分别有上下限 l ( e )和 u ( e ),我们定义c(e)=u(e)-l(e)。(此处的命名选择反映了我们稍后将使用它作为一种能力。)现在,对于每个节点 v ,设l??—(v)为其入边的下界之和,而l+(v)为其出边的下界之和。基于这些值,我们定义b(v)=l–(v)–l+(v)。因为每个下限对其源节点和目标节点都有贡献,所以 b 值的总和为零。
现在,足够神奇的是,如果我们找到一个关于容量 c 和供给与需求 b 的可行流程(如前一个问题所讨论的),我们也将找到一个关于下限和上限 l 和 u 的可行循环。为什么会这样?可行的循环必须遵守 l 和 u ,并且流入每个节点的流量与流出的流量相当。如果我们能找到任何具有这些属性的循环,我们就完成了。现在,让f'(e)=f(e)–l(e)。然后我们可以通过简单地要求 0≤f'(e)≤c(e)来强制执行 f 的上下限,对吗?
现在考虑流动和循环守恒。我们要确保进入一个节点的环流等于从该节点流出的环流。假设进入节点的总流量fv 减去流出节点的流量 v 等于b(v)——这正是我们的供给/需求问题的守恒要求。 f 会怎么样?假设 v 有一个输入边和一个输出边。现在,假设入边的下界为 3,出边的下界为 2。这意味着 b ( v ) = 1。我们需要比流入多一个单位的流出量。假设流入为 0,流出为 1。当我们将这些流动转换回环流时,我们必须加上下限,使内环流和外环流都为 3,所以总和为零。(如果这看起来令人困惑,试着改变一下想法,我相信它们会“一拍即合”)
现在我们知道了如何找到一个有下界的可行流(首先简化为可行循环,然后再简化为有供给和需求的可行流)。这和矩阵舍入有什么关系?设x1,…, x n 表示矩阵的行,设 y 1 ,…, y m 表示列。还要添加一个源 s 和一个宿 s 。给每一行一个来自 s 的 in-edge,代表行总和,给每一列一个到 t 的 out-edge,代表列总和。另外,从每一行到每一列添加一条边,表示矩阵元素。每个边沿 e 代表一个真实值r
。设置 l ( e ) = floor(r)
和 u ( e ) = ceil(r)
。相对于 l 和 u 而言,从 s 到 t 的可行流程将给出我们所需要的——一致的矩阵舍入。(你看怎么样?)
摘要
本章处理单个核心问题,寻找流网络中的最大流,以及专门版本,如最大二部匹配和寻找边不相交路径。您还看到了最小割问题是最大流问题的对偶,以一个解决方案的价格为我们提供了两个解决方案。解决最小费用流问题也是密切相关的,只需要我们切换遍历方法,使用最短路径算法来寻找最便宜的扩充路径。所有解决方案的基本思想都是迭代改进,反复寻找一条增加的路径,让我们改进解决方案。这是一般的 Ford-Fulkerson 方法,它通常不保证多项式运行时间(或者甚至是终止,如果你使用无理的容量)。使用 BFS 寻找边数最少的扩充路径被称为 Edmonds-Karp 算法,它很好地解决了这个问题。(注意,这种方法不能用于最小成本的情况,因为我们必须找到相对于容量的最短路径,而不是边数。)最大流问题及其相关问题是灵活的,适用于相当多的问题。面临的挑战是找到合适的减排方案。
如果你好奇的话…
关于各种流算法,确实有大量的资料。例如,有 Dinic 的算法,它是 Edmonds-Karp 算法的近亲(它实际上比它更早,并且使用相同的基本原理),有一些技巧可以稍微提高运行时间。或者你有 push-relabel 算法,在大多数情况下(除了稀疏图)比 Edmonds-Karp 快。对于二分匹配的情况,您有 Hopcroft-Karp 算法,它通过执行多个同时遍历来改进运行时间。对于最小成本二分匹配,也有众所周知的匈牙利算法,以及更近一些的真正会飞的启发式算法,比如 Goldberg 和 Kennedy 的成本缩放算法(CSA) 。如果你想深入挖掘增加路径的基础,也许你想读读 Berge 的原始论文,“图论中的两个定理”?
还有更高级的流动问题,包括边缘流动的下限,或所谓的环流,没有源或汇。还有多商品流问题,对此没有有效的专用算法(你需要用一种叫做线性规划的技术来解决)。对于一般的图形,还有匹配问题——即使是最小成本版本。这方面的算法比本章中的要复杂得多。
关于流的一些血淋淋的细节的第一站可能是教科书,如 Cormen 等人的算法简介(见第一章中的“参考”部分),但是如果你想要更多的广度,以及大量的示例应用,我推荐由 Ahuja,Magnanti 和 Orlin 的网络流:理论,算法和应用。你可能还想看看福特和富尔克森的开创性著作《网络中的流动》。
练习
10-1.在一些应用中,例如当通过交换点路由通信时,让节点具有容量,而不是(或除了)边缘,可能是有用的。你如何将这种问题简化为标准的最大流问题?
10-2.如何找到顶点不相交的路径?
10-3.证明用于寻找不相交路径的扩充路径算法的最坏情况运行时间是θ(m2),其中 m 是图中的边数。
10-4.你如何在一个无向网络中找到流量?
10-5.实现一个包装器对象,它看起来像一个图,但是动态地反映了一个底层流网络的剩余网络。使用遍历算法的简单实现来实现本章中的一些流算法,以找到增加的路径。
10-6.你如何将流量问题(寻找一个给定大小的流量)简化为最大流量问题?
10-7.使用 Dijkstra 算法和权重调整实现最小成本流问题的解决方案。
10-8.在练习 4-3 中,你邀请朋友参加聚会,并希望确保每个客人至少认识在场的其他人。你已经意识到事情有点复杂。你比其他人更喜欢一些朋友,用真实值兼容性来表示,可能是负面的。你也知道,只有在某些其他客人参加的情况下,许多客人才会参加(尽管这种感觉不一定是相互的)。你将如何选择一个可行的潜在客人子集,最大化你与他们的兼容性总和?(你可能还想考虑那些其他人来了而不来的客人。不过,这有点难——请看练习 11-19。)
10-9.在第四章第一节,四个脾气暴躁的电影观众试图找出他们的座位安排。问题的一部分是,除非能买到自己喜欢的,否则他们谁也不愿意换座位。假设他们的脾气稍微好一点,并且愿意根据需要交换位置以获得最佳解决方案。现在,一个最佳的解决方案可以通过在免费座位上添加边来找到,直到你用完为止。使用本章中的二分匹配算法的简化来说明这一点。
10-10.你正在为 n 人开一个团队建设研讨会,你正在做两个练习。在这两个练习中,您希望将人群划分为由 k 组成的组,并且您希望确保在第二轮中没有人与他们在第一轮中所在的组属于同一组。你如何用最大流量解决这个问题?(假设 n 能被 k 整除。)
10-11.你被一家星际客运服务公司(或者,不那么想象的话,一家航空公司)雇佣去分析它的一次飞行。宇宙飞船按顺序降落在行星 1… n 上,并且可以在每一站搭载或放下乘客。你知道有多少乘客想从每个 i 星球到其他每个 j 星球,以及每次旅行的费用。设计一个算法,使整个行程的利润最大化。(该问题基于 Ahuja 等人的网络流中的应用 9.4。)
参考
阿胡贾,R. K .,马格南蒂,T. L .,和奥林,J. B. (1993 年)。网络流:理论、算法和应用。普伦蒂斯霍尔。
贝尔热,C. (1957)。图论中的两个定理。美国国家科学院院刊 43(9):842–844。http://www.pnas.org/content/43/9/842.full.pdf
。
布萨克,R. G .科芬,S. A .和戈恩,P. J. (1962)。三种常见的网络流问题及其解决方法。工作人员文件 RAC-SP-183,研究分析公司,作战后勤处。http://handle.dtic.mil/100.2/AD296365
。
福特和富尔克森(1957 年)。求最大网络流的简单算法及其在希区柯克问题中的应用。加拿大数学杂志,9:210–218。http://smc.math.ca/cjm/v9/p210
。
福特和富尔克森(1962 年)。网络中的流量。兰德公司 R-375-PR 技术报告。http://www.rand.org/pubs/reports/R375
。
Jungnickel 博士(2007 年)。图、网络和算法,第三版。斯普林格。
吴俊杰、杨晓清(2002)。最优化和变分不等式中的对偶。最优化理论与应用。泰勒&弗朗西斯。
戈德堡和肯尼迪(1995 年)。指派问题的有效成本比例算法。数学编程,71:153–178。http://theory.stanford.edu/~robert/papers/csa.ps
。
施瓦茨,B. L. (1966 年)。部分完成的锦标赛中可能的赢家。暹罗评论,8(3):302–308。http://jstor.org/pss/2028206
。
1 如果你允许他们指定一个偏好度,这就变成了更一般的最小成本二分匹配,或者指派问题。虽然这是一个非常有用的问题,但解决起来有点困难——我稍后会谈到这一点。
2 这个问题在某些方面类似于第八章中的路径计数。然而,主要的区别在于,在这种情况下,我们计算了所有可能的路径(如帕斯卡三角形),这通常会导致大量的重叠,否则记忆将毫无意义。这种重叠在这里是不允许的。
3 门格尔定理的证明也不依赖于流动的概念。
这一节有点难,但对于理解这本书的其余部分并不重要。随意浏览,甚至完全跳过。不过,你可能想读一下前几段,了解一下这个问题。
这当然是伪多项式,所以明智地选择你的容量。
6 也可在线:http://books.google.com/books?id=NvuFAglxaJkC&pg=PA299
7 注意,这里的总和是内边缘下限减去外边缘下限——与我们对流量求和的方式相反。这正是问题的关键。******
十一、难题和(有限的)马虎
最好是好的敌人。
—伏尔泰
这本书显然是关于算法问题解决的。到目前为止,重点一直是算法设计的基本原则,以及许多问题领域中重要算法的例子。现在,我给你看一下算法的另一面:硬度。尽管为许多重要而有趣的问题找到高效的算法肯定是可能的,但令人悲伤的事实是,大多数问题真的很难。事实上,大多数问题都很难,试图解决它们几乎没有意义。然后,重要的是要认识到困难,表明问题是难以解决的(或者至少很有可能如此),并且知道除了简单地放弃之外还有什么选择。
这一章有三个部分。首先,我将解释世界上最大的未解问题之一的潜在思想——以及它如何适用于你。第二,我将在这些想法的基础上,向你们展示一些极其困难的问题,你们很可能会以这样或那样的形式遇到。最后,鉴于本章前两部分的令人沮丧的消息,我将向你展示如何遵循伏尔泰的智慧,并稍微放松你的要求,可以让你比看起来可能的更接近你的目标。
当您阅读下面的内容时,您可能想知道所有的代码都到哪里去了。需要明确的是,这一章的大部分内容都是关于那种太难的问题。这也是关于你如何发现一个给定问题的困难。这很重要,因为它探索了我们的程序实际上可以做什么的外部边界,但它并没有真正导致任何编程。只有在本章的最后三分之一,我才会关注(并给出一些代码)近似法和试探法。这些方法将允许你找到可用的解决方案来解决那些太难的问题,这些问题很难得到最优的、有效的、普遍的解决。他们通过利用一个漏洞实现了这一点——事实上,在现实生活中,我们可能满足于在这三个轴中的一些或所有轴上“足够好”的解决方案。
提示跳到这一章看似更有内容的部分,具体的问题和算法可能很有诱惑力。如果你想理解这一点,我强烈建议尝试一下更抽象的部分,至少从头开始略读这一章以获得一个概述。
归约归约
从第四章开始,我一直在时不时地讨论削减。大部分时间,我一直在谈论把问题简化成你知道如何解决的问题——要么是你正在处理的问题的较小实例,要么是一个完全不同的问题。这样,你也就有了这个新的未知问题的解决方案,实际上证明了它很简单(或者,至少,你可以解决它)。在《??》第四章快结束的时候,我引入了一个不同的想法:向另一个方向减少来证明的硬度。在第六章的中,我用这个想法给出了解决凸包问题的任何算法的最坏情况运行时间的下界。现在我们终于到达了这种技术完全在家的点。事实上,定义复杂性类别(和问题难度)是大多数教科书中通常使用的简化方法。不过,在深入讨论之前,我想从基础层面上彻底说明一下这种硬度证明是如何工作的。这个概念非常简单(虽然证明本身当然不需要),但是出于某种原因,许多人(包括我自己)总是把它搞反了。也许——仅仅是也许——下面这个小故事可以在你试图回忆它是如何工作的时候帮到你。
假设你来到一个小镇,这里的主要景点之一是一对双峰。当地人亲切地称这两个兄弟为 Castor 和 Pollux,以希腊和罗马神话中的孪生兄弟命名。有传言说在 Pollux 山顶上有一个被遗忘已久的金矿,但是许多冒险者已经迷失在这座危险的山中。事实上,已经有太多不成功的尝试试图到达金矿,以至于当地人开始相信这是不可能的。你决定出去走走,亲自看看。
在当地的路边小店买了甜甜圈和咖啡后,你出发了。走了一小段路后,你到了一个可以相对清晰地看到群山的有利位置。从你站的地方,你可以看到 Pollux 看起来真的像一个地狱般的攀登——陡峭的表面,深深的峡谷,周围布满荆棘。另一方面,蓖麻看起来像登山者的梦想。两边坡度平缓,似乎有许多扶手一直通到顶部。你不能确定,但看起来这可能是一次不错的攀登。可惜金矿不在上面。
你决定仔细看看,拿出双筒望远镜。这时你会发现一些奇怪的事情。在 Castor 的顶部似乎有一个小塔,上面有一条滑索一直延伸到 Pollux 的顶峰。立刻,你放弃了任何爬蓖麻的计划。为什么呢?(如果你没有立即看到它,它可能值得思考一下。) 1
当然,在第四章和第六章关于硬度的讨论中,我们已经看到了确切的情况。滑索使得从 Castor 到 Pollux 变得很容易,所以如果 Castor 很容易的话,早就有人发现金矿了。 2 这是一个简单的反命题:如果 Castor 很容易,Pollux 也会很容易;Pollux 不容易,Castor 也不容易。当我们想证明一个问题(Castor)很难的时候,这正是我们要做的。我们拿一些我们知道很难的东西(Pollux)来说明使用我们新的未知的东西很容易解决这个难题(我们发现了一条从 Castor 到 Pollux 的拉链线)。
正如我之前提到的,这本身并不令人困惑。然而,当我们开始谈论减排时,很容易混淆。例如,我们在这里将 Pollux 简化为 Castor,这对您来说是不是很明显?减少的部分是 zip line,它让我们可以像使用 Pollux 的解决方案一样使用 Castor 的解决方案。换句话说,如果你想证明问题 X 是难的,那就找一些难的问题 Y,化归到 X。
注意滑索在与减速相反的方向。至关重要的是你不能混淆,否则整个想法就会瓦解。归约这个词在这里的意思基本上是“哦,那很简单,你只要……”换句话说,如果你把 A 归约为 B,你就是在说“你想解 A?这很简单,你只需解决 b。”或者在这种情况下:“你想缩放 Pollux?这很简单,只需缩放 Castor(并采取滑索)。”换句话说,我们将 Pollux 的伸缩性降低到 Castor 的伸缩性(而不是反过来)。
这里有几件事值得注意。首先,我们假设 zip line易于使用。如果不是滑索而是一条你必须平衡的水平线呢?这真的很难——所以它不会给我们任何信息。就我们所知,人们可能很容易到达卡斯特的顶峰;他们可能无论如何也到不了 Pollux 上的金矿,所以我们知道些什么?另一个是相反方向的减少也没有告诉我们什么。从 Pollux 到 Castor 的一条滑索不会影响我们对 Castor 的估计。那么,如果你能从 Pollux 到 Castor 呢?你无论如何也无法到达波利克斯的顶峰!
考虑图 11-1 的图。节点代表问题,,边代表简单的减少(也就是说,渐进地,它们无关紧要)。底部的粗线是为了说明“地面”,从某种意义上说,未解决的问题是“天上的”,而解决它们就相当于将它们减少到零,或将其接地。第一幅图展示了未知问题 u 被简化为已知的简单问题 e 的情况。从 e 到地面有一个简单的减少,这代表了 e 容易的事实。因此,将 u 连接到 e 为我们提供了一条从 u 到地面的路径——一个解决方案。
图 11-1 。归约的两种用途:将未知问题归约为简单问题,或将困难问题归约为未知问题。在后一种情况下,未知问题一定和已知问题一样难
现在看第二张图片。在这里,一个已知的难题被简化为未知问题 u 。我们能有一条从 u 到地面的边吗(就像图中的灰色边)?那会给我们一条从 h 到地面的路径——但是这样的路径不可能存在,否则 h 不会很难!
在下文中,我将使用这一基本思想,不仅表明问题是困难的,而且还将定义一些困难的概念。正如你可能(也可能没有)注意到的,这里的术语 hard 有些模糊。它基本上可以有两种不同的含义:
- 这个问题很棘手——任何解决它的算法都必须是指数级的。
- 我们不知道这个问题是否棘手,但从来没有人能够找到它的多项式算法。
第一个意思是这个问题对计算机来说很难解决,而第二个意思是对人来说也很难(也许对计算机来说也是如此)。再看一下图 11-1 中最右边的图像。“努力”的两种含义在这里是如何起作用的?让我们举第一个例子:我们知道这个 h 很难处理。高效解决是不可能的。对 u 的解决方案(即降低到地)意味着对 h 的解决方案,因此不存在这样的解决方案。所以, u 也一定是顽固性的。
第二种情况有点不同——这里的困难在于缺乏知识。我们不知道问题 h 是否棘手,尽管我们知道似乎很难找到解决方案。核心见解仍然是,如果我们把 h 减少到 u ,那么 u 至少和 h 一样硬。如果说 h 难对付,那么 u 也难对付。此外,许多人试图找到解决问题的方法,这一事实使得我们成功的可能性变得更小,这也意味着我们不太可能是易驾驭的。为解决 h 问题付出的努力越多,如果 u 变得容易控制(因为那样的话 h 也会变得容易控制)就会越令人吃惊。事实上,这正是一系列实际重要问题的情况:我们不知道这些问题是否棘手,但大多数人仍然坚信它们是棘手的。让我们仔细看看这些流氓问题。
子问题归约
虽然通过使用归约来显示困难的想法可能有点抽象和奇怪,但是有一个特例(或者在某些方面,一个不同的视角)可能很容易理解:如果你的问题有一个困难的子问题,那么这个问题作为一个整体(显然)是困难的。换句话说,如果解决你的问题意味着你也必须解决一个众所周知的难题,那么你基本上就不走运了。例如,如果你的老板让你制作一个反重力悬浮滑板,你可能会做很多工作,比如制作滑板本身或者在一个漂亮的图案上绘画。然而,实际上解决绕过重力的问题使得整个努力从一开始就注定失败。
那么,这是如何减少的呢?这是一种简化,因为你仍然可以用你的问题来解决困难的子问题。换句话说,如果你能够建造一个反重力悬浮滑板,那么你的解决方案就可以(再次,非常明显)用于规避重力。和大多数削减一样,困难的问题甚至没有真正转化;只是嵌入了一个(相当不相关的)语境。或者考虑一般排序的最坏情况运行时间的对数线性下限。如果您要编写一个程序,它接收一组对象,对它们执行一些操作,然后按排序顺序输出关于对象的信息,那么在最坏的情况下,您可能不会比对数线性更好。
但为什么是“可能”?因为这取决于是否有真正的减少。你的程序可以被想象成“分类机器”吗?如果我可以随心所欲地使用你的程序,有没有可能给它输入一些对象,让我对任何实数进行排序?如果是,那么界限成立。如果没有,那么也许没有。例如,也许排序是基于可以使用计数排序的整数?或者也许你实际上自己创建了排序键,所以对象可以按你喜欢的任何顺序输出?你的问题是否足够表达的问题——是否能表达一般的排序问题。事实上,这是本章的关键观点之一:问题的难度是一个表达的问题。
已经不在堪萨斯了?
当我为第一版写下这一章时,一篇科学论文在网上发表后,兴奋才刚刚开始在互联网上消退,该论文声称已证明解决了所谓的 P 对 NP 问题,并得出结论说 P 不等于 NP。尽管新出现的共识是证明是有缺陷的,但这篇论文引起了极大的兴趣——至少在计算机科学界。此外,具有类似主张(或者相反,P 等于 NP)的不太可信的论文不断定期出现。自 20 世纪 70 年代以来,计算机科学家和数学家一直在研究这个问题,这个解决方案甚至获得了一百万美元的奖金。尽管在理解这个问题上已经取得了很大的进展,但似乎还没有真正的解决方案。为什么这么难?为什么它如此重要?而 P 和 NP 到底是什么?
问题是,我们真的不知道我们生活在一个什么样的世界。用绿野仙踪来打个比方——我们可能认为我们生活在堪萨斯州,但是如果有人证明 P = NP,我们肯定已经不在堪萨斯州了。相反,我们会置身于某种类似于奥兹的仙境,一个拉塞尔·因帕利亚佐命名为算法的世界。 5 你说 Algorithmica 有什么了不起?在 Algorithmica,引用一首众所周知的歌曲,“你永远不会换袜子,小股酒精流从岩石上流淌下来。”更严重的是,生活会少很多问题。如果你能陈述一个数学问题,你也能自动解决它。事实上,程序员不再需要告诉计算机做什么——他们只需要给出想要的输出的清晰描述。几乎任何一种优化都是微不足道的。另一方面,密码学现在会变得非常困难,因为破解密码会变得非常非常容易。
问题是,P 和 NP 看起来是非常不同的野兽,尽管它们都是问题的类别。事实上,它们是一类决策问题,可以用是或否来回答的问题。这可能是一个问题,例如“是否存在一条从 s 到 t 的路径,其权重至多为 w ?”或者“有没有一种方法可以把物品装进这个背包,让我得到至少 v 的价值?”第一类,P,被定义为由那些我们可以在多项式时间**(在最坏的情况下)内解决的问题组成。换句话说,如果你把我们到目前为止看到的几乎所有问题都变成决策问题,结果将属于 p。**
NP 似乎有一个更宽松的定义 6 :它包括任何可以在多项式时间内被称为 非确定性图灵机或 NTM 的“神奇计算机”解决的决策问题。这就是 NP 中的 N 的来源——NP 代表“非确定性多项式”。据我们所知,这些非决定论的机器超级强大。基本上,在他们需要做出选择的任何时候,他们都可以猜,而且通过魔法*,他们总是猜对。听起来很棒,对吧?
例如,考虑在图中找到从 s 到 t 的最短路径的问题。你已经知道很多关于如何用更…非数学类的算法做到这一点。但是如果你有一个 NTM 呢?你可以从 s 开始,看看邻居。你应该走哪条路?谁知道呢——猜猜看。因为你正在使用的机器,你将永远是正确的,所以你将神奇地沿着最短的路径行走,不走弯路。例如,对于 DAG 中的最短路径这样的问题,这可能看起来不像是一个巨大的胜利。这是一个可爱的聚会把戏,当然,但运行时间将是线性的。
但是考虑一下第一章中的第一个问题:尽可能高效地游览一次瑞典的所有城镇。还记得几年前我说过用最先进的技术解决这个问题花了大约 85 个 CPU 年吗?如果有一个 NTM,每个城镇只需要一个计算步骤。即使你的机器是带手摇曲柄的机械,它也应该在几秒钟内完成计算。这个看起来很强大吧?而且神奇?
描述 NP(或者,就此而言,非确定性计算机)的另一种方式是看解决问题和检查解决方案之间的区别。我们已经知道解决问题意味着什么。如果我们要检查一个决策问题的解决方案,我们需要的不仅仅是“是”或“否”——我们还需要某种证明,或者证书*(这个证书需要是多项式大小)。例如,如果我们想知道是否存在从 s 到 t 的路径,证书可能就是实际的路径。换句话说,如果你解决了问题,发现答案是“是”,你可以用这个证明来说服我这是真的。换句话说,如果你设法证明了某个数学陈述,你的证明可能就是证明。
那么,一个属于 NP 的问题的要求是,我能够在多项式时间内检查任何“是”答案的证书。非确定性图灵机可以通过简单地猜测证书来解决任何这样的问题。魔法,对吧?
嗯,也许…你看,这就是问题所在。我们知道 P 是而不是神奇的——它充满了我们非常清楚如何解决的问题。NP 看起来像是一大类问题,任何能解决所有这些问题的机器都将超越这个世界。问题是,在 Algorithmica 中,有一种叫做 NTM 的东西。或者,更确切地说,我们非常普通、单调的计算机(确定性图灵机)将被证明是一样强大的。他们一直都有魔力!如果 P = NP,我们可以解决任何有实际(可验证)解决方案的(决策)问题。
同时,回到堪萨斯…
好吧,Algorithmica 是一个神奇的世界,如果我们真的生活在其中,那将会非常棒——但很有可能,我们不是。十有八九,发现证据和检查证据之间有着非常真实的区别——在解决问题和每次简单地猜测正确的解决方案之间。所以如果我们还在堪萨斯,我们为什么要关心这些?
因为它给了我们一个非常有用的硬度概念。你看,我们有一群卑鄙的小动物组成了一个叫 NPC 的班级。这代表“NP-complete”,这些是所有 NP 中最难的个问题。更准确地说,NPC 的每个问题至少和 NP 中的其他问题一样难。我们不知道这些问题是否棘手,但是如果你要解决其中一个棘手的问题,你会自动把我们都送到 Algorithmica!虽然世界人口可能会为不用再换袜子而高兴,但这不太可能发生(我希望上一节强调了这一点)。这将是非常惊人的,但似乎完全不可行。
这不仅会非常奇怪,而且考虑到巨大的优势和巨大的努力,仅仅是为了打破其中一个生物,四十年的失败(到目前为止)似乎会增强我们的信心,相信你不会是成功的那个。至少短期内不会。换句话说,NP 完全问题可能很棘手(对计算机来说很难),但迄今为止,它们肯定对人类来说很难。
NP-完成。 一般方案给你 50%的小费。( http://xkcd.com/287
)
但是这一切是如何运作的呢?为什么杀死一个 NPC 怪物会让所有的 NP 完全崩溃,让我们陷入算法世界?让我们回到我们的简化图。看一下图 11-2 。现在,假设所有的节点都代表 NP 中的问题(也就是说,目前我们把 NP 视为“整个世界的问题”)。左图说明了完整性的概念。在一类问题中,一个问题 c 是完备如果该类中的所有问题都可以“容易地”化为 c 。 7 在这种情况下,我们讨论的类是 NP,如果它们是多项式,那么归约就“容易”了。换句话说,一个问题 c 是 NP 完全的,如果(1) c 本身在 NP 中,并且(2)NP 中的每一个问题都可以在多项式时间内化简为 c 。
图 11-2 。NP 完全问题是 NP 中至少和其他问题一样难的问题。即 NP 中的所有问题都可以归结为它
(NP 中的)每一个问题都可以归结为这些棘手的问题,这意味着它们是核心——如果你能解决它们,你就能解决 NP 中的任何问题(突然间,我们不再是在堪萨斯了)。该图应该有助于澄清这一点:解决 c 意味着添加一个从 c 到地面的实心箭头(将其化为零),这立即给我们提供了一条从 NP 中的每个其他问题到地面的路径,经由 c 。
我们现在用归约来定义 NP 中最困难的问题,但是我们可以稍微扩展这个概念。在图 11-2 中的右图说明了我们如何使用缩减过渡,用于硬度校样,例如我们之前讨论过的那些(例如,像在图 11-1 中右边的那个)。我们知道 c 是硬的,所以简化为 u 证明 u 是硬的。我们已经知道这是如何工作的,但是这个图说明了为什么在这个例子中它是正确的一个稍微更技术性的原因。通过将 c 减少为 u ,我们现在已经将 u 放置在 c 原来所在的相同位置。我们已经知道 NP 中的每个问题都可以归结为 c (意味着它是 NP 完全的)。现在我们也知道,每一个问题都可以通过 c 归结为 u 。换句话说, u 也满足 NP-完全性的定义——如图所示,如果我们能在多项式时间内解决它,我们将建立 P = NP。
到目前为止,我只讨论了决策问题。这样做的主要原因是,它使形式推理中的许多事情(其中大部分我不会在这里介绍)变得更加容易。即便如此,这些想法也适用于其他类型的问题,例如我们在本书中处理的许多优化问题(将在本章后面处理)。
例如,考虑寻找最短的瑞典之旅的问题。因为不是决策问题,不在 NP。即便如此,这也是一个非常困难的问题(从“人类很难解决”和“很可能难以解决”的意义上来说),就像 NP 中的任何事情一样,如果我们发现自己在 Algorithmica 中,它会突然变得很容易。这两点我们分开考虑。
术语完全性是为一个类中最难的问题保留的,所以 NP 完全问题是 NP 的类霸王。不过,我们也可以对可能不属于这个类别的问题使用相同的硬度标准。也就是说,任何问题至少与 NP 中的任何问题一样困难(由多项式时间缩减确定),但不需要本身在 NP 中。这样的问题叫做 NP 难。这意味着 NP 完全问题的 NPC 类的另一个定义是,它包括 NP 中所有的 NP 困难问题。是的,通过一个图寻找最短的路线(比如通过瑞典的城镇)是一个 NP-hard 问题,称为旅行推销员(或销售代表)问题,或者通常只是 TSP。我稍后将回到那个问题。**
关于另一点:如果 P = NP,为什么像这样的优化问题会很容易?关于如何使用证书找到实际的路线等等,还有一些技术细节,但是让我们只关注 NP 的是-否性质和我们在 TSP 问题中寻找的数字长度之间的区别。为了简单起见,我们假设所有的边权重都是整数。此外,因为 P = NP,我们可以在多项式时间内解决决策问题的是和否实例(参见侧栏“不对称、共 NP 和算法的奇迹”)。一种方法是将决策问题作为一个黑箱,对最优答案进行二分搜索法。
例如,我们可以对所有的边权重求和,我们得到 TSP 旅行的成本的上限 C ,下限为 0。然后我们初步猜测最小值是 C /2,并解决决策问题“是否存在长度最多为 C /2 的游览?”我们在多项式时间内得到“是”或“否”,然后可以继续平分值范围的上半部分或下半部分。练习 11-1 要求你展示产生的算法是多项式的。
提示这种用黑盒一分为二的策略也可以用在其他情况下,甚至在复杂性类的上下文之外。如果您有一个算法可以让您确定一个参数是否足够大,您可以一分为二,以对数因子为代价找到正确/最佳的值。相当便宜,真的。
换句话说,尽管复杂性理论主要关注决策问题,但优化问题并没有什么不同。在许多情况下,你可能会听到人们使用术语 NP-完全,而他们真正的意思是 NP-难。当然,你应该小心把事情做对,但是你是否表明一个问题是 NP 难的还是 NP 完全的,对于争论它的困难性的实际目的来说并不那么重要。(只要确保你的削减是在正确的方向上!)
不对称、共 NP 和算法的奇迹
NP 的类别是不对称定义的。它由所有决策问题组成,这些问题的 yes 实例可以用 NTM 在多项式时间内解决。但是,请注意,我们没有提到任何关于 no 实例的内容。因此,举例来说,很明显,如果有一个旅游团恰好游览瑞典的每个城镇一次,一个 NTM 会在合理的时间内回答“是”。然而,如果答案是“不”,它可能需要一段甜蜜的时间。
这种不对称背后的直觉很容易理解,真的。这个想法是,为了回答“是”,NTM 只需要(通过“魔法”)找到一个单一的选择集合,导致这个答案的计算。然而,为了回答“否”,它需要确定不存在这样的计算。虽然这看起来非常不同,但是我们并不知道它是否是 ??。你看,这里我们有另一个复杂性理论中的“相对问题”: NP 对 co-NP。
co-NP 类是 NP 问题的补集类。对于每一个“是”的回答,我们现在都想要“不是”,反之亦然。如果 NP 是真正不对称的,那么这两个类是不同的,尽管它们之间有重叠。例如,所有的 P 都位于它们的交叉点上,因为 P 中的 yes 和 no 实例都可以用 NTM 在多项式时间内解决(就此而言,还可以用确定性图灵机解决)。
现在考虑如果在 NP 和 co-NP 的交集中发现 NP 完全问题 FOO 会发生什么。首先,NP 中的所有问题都归结为 NPC,所以这将意味着 NP 的所有将在 co-NP 中(因为我们现在可以通过 FOO 处理它们的补码)。NP 之外的 co-NP 还会有问题吗?考虑这样一个假设的问题吧。它的补语 co-BAR 应该在 NP 中,对吗?但是因为 NP 在 co-NP 里面,所以 co-BAR也会在 co-NP 里面。这意味着它的补码 BAR 应该在 NP 中。但是,但是,但是……我们假设它是 NP 的外的*——矛盾!*
换句话说,如果我们在 NP 和 co-NP 的交集中找到单个 NP-完全问题,我们将证明 NP = co-NP,并且不对称已经消失。如上所述,所有的 P 都在这个交点上,所以如果 P = NP,我们也有 NP = co-NP。这意味着在 Algorithmica 中,NP 是令人愉快的对称的。
注意,这个结论经常被用来论证在 NP 和 co-NP 的交集中是的问题很可能不是 NP 完全,因为(强烈)认为 NP 和 co-NP 是不同的。例如,没有人找到分解数字问题的多项式解,这就形成了许多密码学的基础。然而问题存在于 NP 和 co-NP 中,所以大多数计算机科学家认为这是而不是 NP 完全。
但是你从哪里开始呢?你从那里去哪里?
我希望现在基本的想法已经很清楚了:NP 类包括所有的决策问题,这些问题的“是”答案可以在多项式时间内得到验证。NPC 类由 NP 中最难的问题组成;NP 中的所有问题都可以在多项式时间内归结为这些。p 是我们可以在多项式时间内解决的 NP 问题的集合。由于类的定义方式,如果 P 和 NPC 之间有一点重叠,我们就有 P = NP = NPC。我们还建立了,如果我们有一个从 NP 完全问题到 NP 中其他问题的多项式时间约简,那么第二个问题也一定是 NP 完全的。(自然,所有的 NP 完全问题都可以在多项式时间内相互约化;参见练习 11-2。)
这给了我们看起来有用的硬度概念——但是到目前为止,我们甚至还没有确定存在NP 完全问题,更不用说发现了一个。我们该怎么做?库克和莱文来救援了!
在 20 世纪 70 年代初,史蒂文·库克证明了确实存在这样的问题,不久之后,列昂尼德·莱文独立地证明了同样的事情。他们都证明了一个叫做 布尔可满足性或 SAT 的问题是 NP 完全的。这个结果以他们两个的名字命名,现在被称为库克-莱文定理。这个定理给了我们起点,它相当高级,我在这里无法给你一个完整的证明,但我会试着勾勒出主要思想。(例如,Garey 和 Johnson 给出了充分的证明;请参见“参考资料”部分。)
SAT 问题取一个逻辑公式,比如(A or not B) and (B or C)
,问有没有办法让它成真(也就是满足它)。在这种情况下,当然有。例如,我们可以设置A = B = True
。为了证明这是 NP 完全的,考虑 NP 中的任意问题 FOO,以及如何将其简化为 SAT。这个想法是首先构造一个 NTM,它将在多项式时间内求解 FOO。从定义上来说这是可能的(因为 FOO 在 NP 中)。然后,对于 FOO 的给定实例 bar (也就是说,对于机器的给定输入),您将(在多项式时间内)构造一个逻辑公式(多项式大小),表示如下:
- 机器的输入是条。
- 这台机器工作正常。
- 机器停下来回答“是”
棘手的部分是你如何用布尔代数来表达它,但是一旦你这么做了,很明显 NTM 实际上是由这个逻辑公式给出的 SAT 问题模拟的。如果公式是可满足的——也就是说,如果(且仅当)我们可以通过为各种变量(代表机器做出的神奇选择等)赋予真值来实现它,那么原始问题的答案应该是“是”。
概括一下,库克-莱文定理说 SAT 是 NP 完全的,这个证明基本上给了你一个用 SAT 问题模拟 ntm 的方法。这适用于基本 SAT 问题及其近亲电路 SAT,其中我们使用逻辑(数字)电路,而不是逻辑公式。
这里的一个重要思想是,所有逻辑公式都可以写成所谓的 合取范式 (CNF),也就是说,作为子句的合取(一系列and
s),其中每个子句都是一系列or
s。变量的每次出现可以是形式A
或其否定形式not A
。这些公式可能一开始就不在 CNF 中,但是它们可以被自动(高效地)转换。例如,考虑公式A and (B or (C and D))
。与 CNF 的另一个公式完全等价:A and (B or C) and (B or D)
。
因为任何公式都可以被有效地改写成(不太大的)CNF 版本,所以 CNF SAT 是 NP 完全的就不足为奇了。有意思的是,即使我们将每个子句的变量个数限制为 k ,得到所谓的 k -CNF-SAT(或者简单的说 k -SAT)问题,只要 k > 2,仍然可以表现出 NP-完全性。你会看到很多 NP 完全性证明都是基于 3-SAT 是 NP 完全的事实。
2-SAT NP 完全吗?谁知道…
当处理复杂类时,您需要注意特殊情况。例如,背包问题的变体(或者子集和,稍后您会遇到)被用于加密。事实是,背包问题的许多情况都很容易解决。事实上,如果背包容量以多项式为界(作为物品数量的函数),问题就在 P(见练习 11-3)。如果在构造问题实例时不小心,加密很容易被破解。
我们和 k -SAT 也有类似的情况。对于 k ≥3,这个问题是 NP 完全的。然而,对于 k =2,它可以在多项式时间内求解。或者考虑最长路径问题。一般来说这是 NP 难的,但是如果你碰巧知道你的图是一个 DAG,你可以在线性时间内求解。事实上,在一般情况下,即使是最短路径问题也是 NP 难的。这里的解决方案是假设不存在负循环。
如果您不使用加密,这种现象是个好消息。这意味着,即使你遇到了一个问题,它的一般形式是 NP 完全的,你需要处理的具体实例可能是在 p 中,这是一个你可能称之为硬度不稳定性的例子。稍微调整一下你的问题的需求会有很大的不同,让一个棘手的问题变得容易处理,甚至让一个无法决定的问题(比如停机问题)变得可以决定。这就是近似算法(稍后讨论)如此有用的原因。
这是否意味着 2-SAT 不是 NP 完全的?事实上,没有。得出这个结论是一个容易陷入的陷阱。只有当 P ≠ NP 时才成立,否则 P 中的所有问题都是 NP 完全的。换句话说,我们的 NP 完全性证明对于 2-SAT 是失败的,我们可以证明它在 P 中,但是我们不知道它在 NPC 是 ?? 而不是 ??。
现在我们有了一个起点:SAT 和它的好朋友,Circuit SAT 和 3-SAT。然而,仍然有许多问题需要研究,复制库克和莱文的壮举似乎有点令人生畏。例如,你如何证明 NP 中的每一个问题都可以通过寻找一个城镇之旅来解决?
这是我们(最终)开始着手削减的地方。让我们来看一个相当简单的 NP 完全问题,即寻找哈密尔顿圈。我已经在第五章中提到了这个问题(在侧栏“在加里宁格勒跳岛”)。问题是确定一个有 n 个节点的图是否有一个长度为 n 的圈;也就是说,你能准确地访问每个节点一次,然后沿着图的边回到你的起点吗?
这看起来并不像 SAT 问题那样具有表现力——毕竟,在 SAT 问题中,我们可以使用命题逻辑的完整语言——所以对 ntm 进行编码似乎有点多。如你所见,事实并非如此。汉密尔顿循环问题和 SAT 问题一样具有表现力。我的意思是,从 SAT 到哈密尔顿圈问题有一个多项式时间的缩减。换句话说,我们可以使用汉密尔顿循环问题的机制来创建一个 SAT 解决机器!
我将带你了解细节,但在此之前,我想请你记住你脑海中的大图:我们正在做的事情的总体想法是,我们将一个问题作为一种机器来处理,我们几乎要给那台机器编程来解决一个不同的问题。这种简化就是隐喻编程。考虑到这一点,让我们看看如何将布尔公式编码为图形,以便用一个哈密尔顿循环来表示满意度…
为了简单起见,让我们假设我们想要满足的公式是 CNF 形式的。我们甚至可以假设 3-SAT(虽然这并不是真正必要的)。这意味着我们需要满足一系列子句,在每一个子句中,我们需要满足至少一个元素,这些元素可以是变量(如A
)或它们的否定(not A
)。真值需要用路径和循环来表示,那么假设我们将每个变量的真值编码为一个路径的方向。
这个想法在图 11-3 中有说明。每个变量都由单行节点表示,这些节点用反平行边链接在一起,这样我们就可以从左到右或者从右到左移动。一个方向(比如从左到右)表示变量被设置为真,而另一个方向表示假。只要我们有足够的节点,节点的数量并不重要。 8
图 11-3 。一个“行”,代表我们试图满足的布尔表达式的变量。如果循环从左向右通过,变量为真;否则,它就是假的
在我们开始尝试对实际公式进行编码之前,我们希望强制我们的机器将每个变量设置为两个可能的逻辑值中的一个。也就是说,我们要确保任何哈密尔顿循环都会经过每一行(方向给我们真值)。我们还必须确保循环在从一行到下一行时可以自由转换方向,这样变量就可以彼此独立地赋值。我们可以通过用两条边将每一行连接到下一行,在两端的锚点处(在图 11-3 中突出显示),如图图 11-4 所示。
图 11-4 。这些行是链接在一起的,所以当从一个变量到下一个变量时,哈密尔顿循环可以保持或改变它的方向,让 A 和 B 彼此独立地为真或为假
如果我们只有如图图 11-4 所示的一组连接的行,那么图中就没有哈密尔顿圈。我们只能从一排走到下一排,没有办法再站起来。然后,对基本行结构的最后一点修改是在顶部添加一个源节点 s (具有到第一行的左右锚点的边)和在底部添加一个接收器节点 t (具有来自最后一行的左右锚点的边),然后添加从 t 到 s 的边。
在继续之前,你应该说服自己,这个结构确实做了我们想要它做的事情。对于 k 变量,我们到目前为止构建的图将具有 2 个 k 不同的哈密尔顿循环,一个用于变量的真值的每个可能赋值,真值由给定行中向左或向右的循环表示。
既然我们已经在汉密尔顿机器中编码了将真值赋给一组逻辑变量的想法,我们只需要一种方法来编码涉及这些变量的实际公式。我们可以通过为每个子句引入一个节点来做到这一点。一个哈密尔顿循环将必须精确地访问这些中的每一个一次。诀窍是将这些子句节点挂接到我们现有的行上,以利用这些行已经编码了真值这一事实。我们进行设置,以便循环可以通过子句节点从路径中绕道,但是只有在正确的方向上才是*。因此,例如,如果我们有子句(A or not B)
,我们将向 A 行添加一个迂回,要求循环从左到右,并且我们向 B 行添加另一个迂回(通过相同的子句节点),但是这次是从右到左(因为not
)。我们需要注意的唯一一件事是,没有两条弯路可以链接到相同位置的行——这就是为什么我们需要在每一行中有多个节点,这样我们就有足够多的节点用于所有的子句。你可以在图 11-5 中看到我们的例子是如何工作的。*
图 11-5 。使用子句节点(突出显示)对子句(A 或 not B)进行编码,并添加要求 A 为真(从左到右)和 B 为假(从右到左)的迂回路径,以满足子句(即访问节点)
以这种方式对子句进行编码后,只要每个子句的变量中至少有一个具有正确的真值,就可以满足每个子句,让它绕过子句节点。因为一个哈密尔顿循环必须访问每个节点(包括每个子句节点),所以满足公式的和-部分。换句话说,逻辑公式是可满足的,当且仅当在我们构建的图中存在哈密尔顿圈。这意味着我们已经成功地将 SAT(或者更具体地说,CNF-SAT)简化为 Hamilton 圈问题,从而证明后者是 NP 完全的!这有那么难吗?
好吧,确实有点难。至少你自己想这样的事情会很有挑战性。幸运的是,许多 NP 完全问题比 SAT 和汉密尔顿圈问题更相似,正如你将在下文中看到的。
没完没了的故事
这个故事还有更多。事实上,这个故事还有很多,你不会相信的。复杂性理论是一个独立的领域,有吨的结果,更不用说复杂性类了。(为了一瞥正在被研究的类的多样性,你可以去复杂性动物园。)
这个领域的一个形成性例子是一个比 NP 完全问题难得多的问题:艾伦·图灵的停顿问题(在第四章中提到)。它只是要求你确定一个给定的算法是否会终止于一个给定的输入。为了理解为什么这实际上是不可能的,假设您有一个函数halt
,它将一个函数和一个输入作为其参数,这样如果A(X)
终止,那么halt(A, X)
将返回 true,否则返回 false。现在,考虑以下函数:
def trouble(A):
调用halt(A, A)
确定A
在应用于自身时是否暂停。这样还舒服吗?如果你评价trouble(trouble)
会怎么样?基本上,它停了就不停,不停就停……我们有一个悖论(或者矛盾),意思是halt
不可能存在。停顿问题是无法决定的。换句话说,解决它是不可能的。
但你认为不可能很难?正如一位伟大的拳击手曾经说过的,不可能就是一切。事实上,有一种东西叫做高度不可判定,或者“非常不可能”对于这些有趣的介绍,我推荐大卫·哈雷尔的电脑有限公司:他们真的不能做什么。
怪兽之家
在这一节中,我将简要介绍几千个已知的 NP 完全问题中的几个。请注意,这里的描述同时服务于两个目的。第一个,也是最明显的,目的是给你一个大量困难问题的概述,这样你就可以更容易地认识到(并证明)你在编程中可能遇到的任何困难。我可以通过简单地列出(并简要描述)问题来给你一个概述。然而,我也想给你一些硬度证明如何工作的例子,所以我将在这一节描述相关的减少。
背包的回归
这一节的问题大多是关于选择子集的。这是一种你在很多场合都会遇到的问题。也许你正试图选择在一定的预算内完成哪些项目?还是把不同大小的箱子装进尽可能少的卡车里?或者,也许你正试图用一组箱子装满一组固定的卡车,这将给你带来尽可能多的利润?幸运的是,这些问题中的许多在实践中有相当有效的解决方案(例如第八章中的背包问题的伪多项式解决方案和本章稍后讨论的近似),但是如果你想要一个多项式算法,你可能就不走运了。 9
注伪多项式解只为一些 NP-hard 问题所知。事实上,对于很多 NP 难的问题,你找不到的伪多项式解,除非 P = NP。Garey 和 Johnson 称这些为强意义上的 NP 完全。(更多细节,请参见他们的书《计算机和棘手问题》中的第 4.2 节。)
背包问题现在应该很熟悉了。我在第七章的中重点讨论了分数版本,在第八章的中,我们使用动态编程构造了一个伪多项式解。在这一节中,我将研究背包问题本身和它的几个朋友。
先说看似简单的事情, 10 所谓的分区问题。这看起来真的很无辜——这只是公平分配的问题。最简单的形式是,划分问题要求你获取一个数字列表(比如说整数),并将其划分为两个和相等的列表。将 SAT 简化为分区问题有点复杂,所以我只想请你在这一点上相信我(或者,更确切地说,参见 Garey 和 Johnson 的解释)。
不过,从分区问题转移到其他问题要容易得多。因为看起来复杂性很低,所以使用其他问题来模拟分区问题会很容易。就拿箱包装的问题来说吧。这里我们有一组大小在 0 到 k 范围内的物品,我们想把它们装进大小为 k 的箱子里。从划分问题中进行简化相当容易:我们只需将 k 设置为数字总和的一半。现在,如果装箱问题设法将数字塞进两个箱子,那么划分问题的答案是肯定的;否则,答案是否定的。这意味着装箱问题是 NP 难的。
另一个众所周知的简单陈述的问题是所谓的子集和问题。这里你又一次有了一组数字,你想找到一个子集,它的总和等于某个给定的常数, k 。再次,找到一个减少是足够容易的。例如,我们可以通过(再次)将 k 设置为数字之和的一半来减少划分问题。子集和问题的一个版本将 k 锁定为零——尽管这个问题仍然是 NP 完全的(练习 11-4)。
现在,让我们看看实际的(整数的,非分数的)背包问题。先处理 0-1 版本。如果我们愿意,我们可以从划分问题中再次约简,但我认为从子集和中约简更容易。背包问题也可以被公式化为一个决策问题,但是假设我们正在使用我们以前见过的相同优化版本:我们希望最大化项目值的总和,同时保持项目大小的总和低于我们的容量。让每一项都是子集和问题中的一个数,让价值和重量都等于那个数。
现在,我们能得到的最好的可能答案是我们与背包容量完全匹配。只要把容量设为 k ,背包问题就会给出我们所寻求的答案:能否把背包装满,等价于能否找到一笔 k 。
为了总结这一节,我将简单地触及一个最明显的问题: 整数编程。这是线性规划技术的一个版本,其中线性函数在一组线性约束下被优化。然而,在整数编程中,你还要求变量只能取整数值——这打破了所有现有的算法。这也意味着你可以减少各种各样的问题,这些背包式的问题就是一个明显的例子。事实上,我们可以证明 0-1 整数规划这种特殊情况是 NP 难的。假设背包问题的每一项都是一个变量,可以取值为 0 或 1。然后在这些基础上建立两个线性函数,分别以值和权重作为系数。您可以根据值优化一个,并根据权重将一个限制在容量以下。结果会给你背包问题的最优解。 11
无界积分背包呢?在第八章中,我算出了一个伪多项式解,但是真的是 NP 难吗?当然,它看起来确实与 0-1 背包关系密切,但这种对应关系并不十分密切,因此减少是明显的。事实上,这是一个很好的机会来试着做一个缩减——所以我将指导你做练习 11-5。
派系和色彩
让我们从数字子集转移到寻找图形中的结构。这些问题中有许多是关于冲突的。例如,您可能正在为一所大学编写一个日程安排软件,并且您正试图最小化涉及教师、学生、班级和礼堂的时间冲突。祝你好运。或者你正在编写一个编译器,你想通过找出哪些变量可以共享一个寄存器来最大限度地减少使用的寄存器数量?和以前一样,您可能在实践中找到可接受的解决方案,但是您可能无法在一般情况下最优地解决大型实例。
我已经多次讨论过二分图——其节点可以分成两个集合的图,这样所有的边都在集合之间(也就是说,没有边连接同一集合中的节点)。另一种方式可以看作是双色,其中你将每个节点涂成黑色或白色(举例来说),但是你要确保没有邻居有相同的颜色。如果这是可能的,那么这个图就是二部图。
现在,如果你想看看一个图是否是三分的,也就是说,你是否能管理一个三色的?事实证明,这并不容易。(当然,一个k-给 k > 3 上色也不容易;参见练习 11-6。)把 3-SAT 简化为三色,其实也没那么难。然而,这有点复杂(就像本章前面的哈密尔顿循环证明一样),所以我只告诉你它是如何工作的。
基本上,您构建一些专门的组件或小部件,就像 Hamilton 循环证明中使用的行一样。这里的想法是首先创建一个三角形(三个相连的节点),其中一个代表真,一个代表假,一个是所谓的基节点。对于一个变量A
,然后创建一个三角形,其中包含一个节点A
,一个节点not A
,第三个节点是基节点。这样,如果A
获得与真实节点相同的颜色,那么not A
将获得虚假节点的颜色,反之亦然。
此时,为每个子句构建一个小部件,将A
或not A
的节点链接到其他节点,包括真节点和假节点,因此找到三色的唯一方法是变量节点之一(形式为A
或not A
)获得与真节点相同的颜色。(如果你尝试一下,你可能会找到这样做的方法。如果你想要完整的证明,可以在几本算法书里找到,比如 Kleinberg 和 Tardos 的那本;参见第一章中的“参考资料”。)
现在,假设k-着色是 NP-完全的(对于 k > 2),那么寻找一个图的色数-你需要多少种颜色。如果色数小于等于 k ,那么k-着色问题的答案是肯定的;否则,答案是否定的。这种问题可能看起来很抽象,很没用,但事实远非如此。这对于需要确定某些类型的资源需求的情况来说是一个基本问题,例如,对于编译器和并行处理都是如此。
让我们来看看确定一个代码段需要多少寄存器(某种有效的内存槽)的问题。要做到这一点,你需要弄清楚哪些变量将被同时使用。变量是节点,任何冲突都用边来表示。冲突仅仅意味着两个变量同时被使用,因此不能共享一个寄存器。现在,找到可以使用的最小数量的寄存器相当于确定这个图的色数。
k 的近亲——着色就是所谓的 小团体掩护问题(又称分派系)。正如你可能记得的,团就是一个完全的图,尽管这个术语通常用于指一个完全的子图。在这种情况下,我们希望将一个图分成几个集团。换句话说,我们希望将节点分成几个(不重叠的)集合,这样在每个集合中,每个节点都相互连接。一会儿我会告诉你为什么这是 NP-hard,但是首先,让我们仔细看看集团。
简单地确定一个图是否有一个给定大小的团是 NP 完全的。假设你正在分析一个社交网络,你想看看是否有一群 k 人,其中每个人都是彼此的朋友。没那么容易…优化版本,max-clique,当然至少一样难。从 3-SAT 到 clique 问题的简化再次涉及到创建逻辑变量和子句的模拟。这里的想法是为每个子句使用三个节点(每个字面量一个节点,无论它是变量还是它的否定),然后在所有节点之间添加边,这些节点代表与兼容的字面量,也就是那些可以同时为真的节点。(换句话说,你在除了变量和它的否定之间的所有节点之间添加边,比如A
和not A
。)
你做不是,但是,在一个子句里面加边*。这样,如果您有 k 子句,并且您正在寻找一个大小为 k 的小团体,那么您就是在强制每个子句中的至少有一个节点在这个小团体中。这样一个集团将代表一个有效的赋值给变量的真值,你将通过找到一个集团解决 3-SAT 问题。(科尔曼等人给出了详细的证明;参见第一章中的“参考资料”。)*
小团体问题有一个非常相近的亲戚——一阴一阳,如果你愿意的话——叫做 独立组问题。这里的挑战是找到一组 k 的独立节点(即彼此没有任何边的节点)。优化版本是寻找图中最大的独立集。这个问题可以应用于资源调度,就像图着色一样。例如,在某种形式的交通系统中,如果一个十字路口的不同车道不能同时使用,那么它们就是冲突的。你用表示冲突的边拼凑一个图,最大的独立集将给出在任何时候都可以使用的最大数量的车道。(在这种情况下,更有用的当然是找到一个将划分为独立集合的;我会回来的。)
你看到家族和小团体的相似之处了吗?没错。这是完全一样的,除了我们现在想要的不是边,而是没有边。为了解决独立集问题,我们可以简单地解决图的补图上的团问题——其中每个边都已被移除,每个缺失的边都已被添加。(换句话说,邻接矩阵中的每个真值都被求逆了。)类似地,我们可以使用独立集问题来解决小团体问题——因此我们已经简化了两种方法。
现在让我们回到小团体掩护的想法。我确信你可以看到,我们也可以在补图中寻找一个独立集覆盖(也就是说,将节点划分成独立集)。问题的重点是找到一个由 k 个小集团(或独立集)组成的封面,优化版试图最小化 k 。请注意,在独立的集合中没有冲突(边),因此同一集合中的所有节点都可以接收相同的颜色。换句话说,寻找一个k-团划分本质上等价于寻找一个k-着色,我们知道这是 NP-完全的。等价地,两个优化版本都是 NP 难的。
另一种覆盖是顶点(或节点)覆盖,它由图中节点的子集组成,并覆盖边。也就是说,图中的每条边都与封面中的至少一个节点相关联。决策问题要求你找到一个最多由 k 个节点组成的顶点覆盖。我们马上就会看到,当图有一个至少由 n - k 个节点组成的独立集合时,这种情况就会发生,其中 n 是图中节点的总数。这给了我们一个双向的归约,就像集团和独立集之间的归约一样。
这种简化非常简单。基本上,一个节点集是一个顶点覆盖当且仅当其余的节点形成一个独立的集合。考虑不在顶点覆盖中的任意一对节点。如果他们之间有一个优势,它不会被覆盖(一个矛盾),所以他们之间不可能有优势。因为这适用于封面外的任何一对节点,所以这些节点形成一个独立的集合。(当然,单个节点可以独立工作。)
这种暗示也是反过来的。假设你有一个独立的集合——你明白为什么剩下的节点必须形成一个顶点覆盖了吗?当然,任何没有连接到独立集的边都将被剩余的节点覆盖。但是如果一条边连接到你的一个独立节点呢?嗯,它的另一端不可能在独立集合中(那些节点没有连接),这意味着边被外部节点覆盖。换句话说,顶点覆盖问题是 NP 完全的(或者在其优化版本中是 NP 困难的)。
最后,我们有集合覆盖问题,它要求你找到一个所谓的大小至多为 k 的集合覆盖(或者,在优化版本中,找到最小的一个)。基本上,你有一个集合 S 和另一个集合 F ,由 S 的子集组成。 F 中所有集合的并集等于 S 。你试图找到一个覆盖了所有元素的子集。要对此有一个直观的理解,可以从节点和边的角度来考虑。如果 S 是一个图的节点, F 是边(也就是节点对),你将试图找到覆盖(关联)所有节点的最小数量的边。
小心这里用的例子就是所谓的边缘覆盖问题。虽然它是集合覆盖问题的一个有用的例子,但是你不应该得出结论说边覆盖问题是 NP 完全的。事实上,它可以在多项式时间内解决。
应该很容易看出集合覆盖问题是 NP 难的,因为顶点覆盖问题基本上是一个特例。只要让 S 是一个图的边, F 由每个节点的邻居集组成,你就大功告成了。
路径和电路
这是我们最后一组动物——我们正在接近这本书开头的那个问题。这种材料主要与有效导航有关,当对您必须经过的位置(或州)有要求时。例如,你可能试图为一个工业机器人设计出运动模式,或者一些电子电路的布局。你可能不得不再次满足于近似值或特殊情况。我已经展示了寻找一个哈密顿循环是多么令人畏惧的前景。现在,让我们看看是否可以从这些知识中找出一些其他硬路径和电路相关的问题。
首先,我们来考虑方向的问题。我给出的对哈密尔顿圈的检查是 NP-完全的证明是基于使用一个有向图(并且,因此,找到一个有向圈)。无向的情况呢?看起来我们丢失了一些信息,早期的证据在这里不成立。然而,通过一些 widgetry,我们可以用无向图模拟方向!
这个想法是将有向图中的每个节点分成三个,基本上用长度为 2 的路径来代替。想象一下给节点着色:将原始节点着色为蓝色,但是添加了红色的入节点和绿色的出节点。所有有向入边现在都变成了链接到红色入节点的无向边,出边链接到绿色出节点。显然,如果原来的图有一个哈密尔顿圈,那么新的图也会有。挑战在于从另一个方面得到暗示——我们需要“如果且仅如果”来使缩减有效。
想象我们的新图有汉密尔顿圈。这个循环的节点颜色可以是“…红色、蓝色、绿色、红色、蓝色、绿色…”或者“…绿色、蓝色、红色、绿色、蓝色、红色…”在第一种情况下,蓝色节点将表示原始图中的有向哈密尔顿循环,因为它们仅通过它们的入节点(表示原始的入边)进入,并通过出节点离开。在第二种情况下,蓝色节点将代表一个反向定向汉密尔顿循环——这也告诉我们需要知道什么(也就是说,我们在另一个方向上有一个可用的定向汉密尔顿循环)。
所以,现在我们知道有向和无向哈密尔顿圈基本上是等价的(见练习 11-8)。所谓的哈密尔顿路径问题呢?这类似于循环问题,除了你不再需要结束在你开始的地方。看起来可能会容易一点?不好意思。没有骰子。如果你能找到一条哈密尔顿路径,你可以用它来找到一个哈密尔顿圈。让我们考虑一下有向情况(无向情况见练习 11-9)。取任意一个节点 v 的内外边。(如果没有这样的节点,就不可能有哈密顿圈。)将其分成两个节点, v 和 v ,保持所有的入边指向 v ,所有的出边从 v 开始。如果原始图有一个哈密尔顿循环,转换后的图将有一条哈密尔顿路径,从 v 开始,到 v 结束(我们基本上只是在 v 处剪短了循环,形成了一条路径)。相反,如果新图有哈密尔顿路径,那么它必须从 v 开始(因为它没有内边),同样,它必须在 v 结束。通过将这些节点合并在一起,我们在原始图中得到一个有效的哈密尔顿圈。
注意上一段的“相反地……”部分确保我们在两个方向都有暗示。这一点很重要,这样在使用归约时“是”和“否”的答案都是正确的。然而,这并不而不是意味着我在两个方向上都减少了。
现在,也许你开始看到最长路径问题的问题,我已经提到过几次了。事情是,找到两个节点之间的最长路径将让您检查汉密尔顿路径的存在!您可能不得不使用每一对节点作为搜索的终点,但这只是一个二次因素,缩减仍然是多项式的。正如我们所见,图是否有向并不重要,增加权重只是将问题一般化。(非循环情况见练习 11-11。)
最短路径呢?在一般情况下,寻找最短路径完全等价于寻找最长路径。你只需要否定所有的边权重。然而,当我们在最短路径问题中不允许负循环时,就像在最长路径问题中不允许正循环一样。在这两种情况下,我们的归约都失败了(练习 11-12),我们不再知道这些问题是否是 NP 难的。(事实上,我们坚信它们是而不是,因为我们可以在多项式时间内解决它们。)
注意当我说我们不允许负循环时,我指的是图中的*。对于路径本身中的负循环没有特别的禁令,因为它们被假定为简单路径*,因此根本不能包含任何循环,无论是负循环还是其他循环。**
**现在,终于,我开始了解为什么很难找到一个最佳的瑞典之旅这个伟大的(或者,到现在为止,也许不是那么伟大的)谜团。如上所述,我们正在处理旅行推销员问题,或 TSP。这个问题有几个变种(大多数也是 NP 难的),但我将从最简单的一个开始,其中你有一个加权无向图,你想找到一条通过所有节点的路线,使路线的加权和尽可能小。实际上,我们试图做的是找到最便宜的汉密尔顿循环——如果我们能够找到,我们也已经确定那里是汉密尔顿循环。换句话说,TSP 也一样辛苦。
旅行推销员问题。 最佳线性规划割平面技术的复杂度是多少?我到处都找不到它。男人,加菲猫的家伙没有这些问题… ( http://xkcd.com/399
)
但是还有另一个 TSP 的常见版本,其中图被假设为完成。在一个完整的图中,总会有一个哈密尔顿圈(如果我们至少有三个节点),所以归约实际上不再起作用了。现在怎么办?实际上,这并不像看起来那样有问题。通过将多余边的边权重设置为某个非常大的值,我们可以将以前的 TSP 版本简化为图必须完整的情况。如果它足够大(大于其他权重的总和),我们将找到一条穿过原始边的路径,如果可能的话。
但是,对于许多实际应用来说,TSP 问题似乎过于普遍。它允许完全任意的边权重,而许多路线规划任务不需要这种灵活性。例如,规划穿过地理位置的路线或机器人手臂的移动只需要我们能够在欧几里得空间中表示距离。这给了我们更多关于这个问题的信息,这应该会使它更容易解决——对吗?再次抱歉。不。显示欧几里德 TSP 是 NP-hard 有点复杂,但是让我们看一个更一般的版本,它仍然比一般的 TSP 具体得多:度量 TSP 问题。
A 公制 是一个距离函数 d ( a , b ),度量两点 a 和 b 之间的距离。然而,这不一定是直线的欧几里德距离。例如,在制定飞行路线时,您可能想要测量沿测地线(沿地球表面的曲线)的距离,在布置电路板时,您可能想要分别测量水平和垂直距离,将两者相加(产生所谓的曼哈顿距离或出租车距离)。有许多其他的距离(或类似距离的函数)可以作为度量标准。要求是它们是对称的、非负的实值函数,仅从一个点到其自身的距离为零。还有,他们需要遵循三角不等式 : d ( a ,c)≤d(a,b)+d(b, c )。这只是意味着两点之间的最短距离直接由度量给出——你不能通过穿过一些其他点找到捷径。
证明这仍然是 NP 难的并不太难。我们可以从哈密尔顿循环问题中减少。因为三角不等式,我们的图必须是完整的。 13 仍然,我们可以让原来的边得到一个权值,而增加的边,一个权值,比如说,两个(仍然不打破东西)。度量 TSP 问题将给出度量图的最小权 Hamilton 圈。因为这样的圈总是由相同数量的边(每个节点一条)组成,当且仅当在原始的任意图中存在哈密尔顿圈时,它将由原始的(单位权)边组成。
尽管度量 TSP 问题也是 NP 难的,但在下一节中,您将看到它在一个非常重要的方面不同于一般的 TSP 问题:对于度量情况,我们有多项式近似算法,而近似一般的 TSP 本身就是 NP 难的问题。
当事情变得艰难时,聪明人会变得草率
正如我所承诺的,在向你展示了许多看起来相当无辜的问题实际上难以想象的困难之后,我将向你展示一条出路:草率。我在前面提到了“硬度的不稳定性”的概念,即使是对问题需求的微小调整也能让你从完全糟糕变得非常好。您可以进行多种调整——我将只介绍两种。在这一节中,我将向你展示如果你在寻找最优时允许一定比例的草率会发生什么;在下一节中,我们将看看算法设计的“手指交叉”学派。
让我首先阐明近似的概念。基本上,我们将允许算法找到一个可能不是最优的解决方案,但其值最多是一个给定的百分比。更常见的是,这个百分比被作为一个因子,或近似比率给出。例如,对于比率 2,最小化算法将保证我们的解至多是最优解的两倍,而最大化问题将给出我们的解至少是最优解的一半。 14 让我们回到我在第七章中所做的承诺,来看看这是如何运作的。
我说的是,无界整数背包问题可以用贪婪来近似到两倍以内。至于精确的贪婪算法,在这里设计解决方案是琐碎的(只需使用与分数背包相同的贪婪方法);问题是证明它是正确的。如果我们不断添加具有最高单位价值(即价值-重量比)的物品类型,我们怎么能保证达到至少一半的最佳价值呢?当我们不知道最佳值是什么时,我们怎么能知道这个呢?
这是近似算法的关键点。我们不知道近似值与最佳值的确切比例——我们只是保证它会变得多糟糕。这意味着,如果我们在上得到最优能得到有多好的估计,我们可以用它来代替实际的最优,我们的答案仍然有效。让我们考虑最大化的情况。如果我们知道最优值永远不会比 A 大*,并且我们知道我们的近似值永远不会比 B 小*,我们就可以确定这两者的比值永远不会大于 A/B**
**对于无界背包,你能想出你能达到的价值的某个上限吗?好吧,我们没有比用具有最高单位价值的项目类型填满背包更好的了(有点像无限分数解决方案)。这样的解决方案很可能是不可能的,但是我们肯定不能做得更好。设这个乐观界限为 a。
能不能给我们的近似值一个下界 B,或者至少说一下 A/B 的比值?考虑您添加的第一个项目。假设它使用了一半以上的容量。这意味着我们不能添加更多的这种类型,所以我们已经比假设的 A 更糟了。但是我们用最好的物品类型填充了至少一半背包,所以即使我们现在停止,我们知道 A/B 最多是 2。如果我们设法增加更多项目,情况只会有所改善。
如果第一项没有使用超过一半的容量怎么办? 16 好消息,各位:我们又可以增加一项同类了!事实上,我们可以继续添加这类项目,直到我们使用了至少一半的容量,确保近似比率的界限仍然成立。
有许许多多的近似算法——仅关于这个主题的书就有很多。如果你想更多地了解这个话题,我建议你去买一本(Williamson 和 Shmoys 的《近似算法的设计》和 Vijay V. Vazirany 的《?? 近似算法》都是很好的选择)。不过,我将向您展示一个特别漂亮的算法,用于近似度量 TSP 问题。
我们要做的是,再次找到某种无效的、乐观的解决方案,然后调整它,直到我们得到一个有效的(但可能不是最优的)解决方案。更具体地说,我们的目标是某个东西(不一定是有效的哈密尔顿循环),它的权重至多是最优解的两倍,然后使用捷径(三角形不等式保证不会使事情变得更糟)调整和修复那个东西,直到我们实际上得到一个哈密尔顿循环。那么这个周期也将至多是最佳长度的两倍。听起来像个计划,不是吗?
然而,有什么东西离汉密尔顿圈只有几条捷径,而长度至多是最优解的两倍?我们可以从更简单的开始:什么能保证重量不大于最短的汉密尔顿圈?我们知道怎么找到的东西?最小生成树!好好想想。汉密尔顿圈连接所有节点,而连接所有节点的绝对最便宜的方法是使用最小生成树。
然而,一棵树不是一个循环。TSP 问题的思想是,我们将访问每个节点,从一个节点走到下一个节点。我们当然也可以沿着树的边缘访问每个节点。如果特里莫是一名推销员,他可能会这么做(见第五章)。换句话说,我们可以以深度优先的方式沿着边,回溯到其他节点。这给了我们一个图的封闭行走,而不是一个循环(因为我们正在重新访问节点和边)。不过,想想这段封闭路程的重量吧。我们沿着每条边走两次,所以它是生成树重量的两倍。让这成为我们乐观(但无效)的解决方案。
度量案例的伟大之处在于,我们可以跳过回溯,走捷径。我们不用沿着已经看到的边往回走,访问已经经过的节点,我们可以直接去下一个未访问的节点。由于三角不等式,我们保证这不会降低我们的解决方案,所以我们最终得到一个近似比率界限 2!(这种算法通常被称为“绕树两圈”算法,尽管你可能会认为这个名字没有多大意义,因为我们只绕树一圈。)
实现这个算法可能看起来并不完全简单。实际上,有点像。一旦我们有了生成树,我们需要做的就是遍历它,避免多次访问节点。仅仅报告在 DFS 期间发现的节点实际上会给我们提供我们想要的解决方案。你可以在清单 11-1 中找到这个算法的实现。你可以在清单 7-5 的中找到prim
的实现。
清单 11-1 。“绕树两圈”算法,度量 TSP 的 2-近似
from collections import defaultdict
def mtsp(G, r): # 2-approx for metric TSP
T, C = defaultdict(list), [] # Tree and cycle
for c, p in prim(G, r).items(): # Build a traversable MSP
T[p].append(c) # Child is parent's neighbor
def walk(r): # Recursive DFS
C.append(r) # Preorder node collection
for v in T[r]: walk(v) # Visit subtrees recursively
walk(r) # Traverse from the root
return C # At least half-optimal cycle
有一种方法可以改进这种近似算法,这种方法在概念上很简单,但在实践中相当复杂。它被称为克里斯托菲德斯算法,其思想是在生成树的奇数度节点之间创建一个最小成本匹配,而不是遍历树的边缘两次。我们已经知道生成树并不比最优圈差。还可以看出,最小匹配的权重不大于最优周期的一半(练习 11-15),所以总的来说,这给了我们一个 1.5 的近似值,这是迄今为止该问题已知的最佳界限。问题是,寻找最小成本匹配的算法相当复杂(它肯定比寻找最小成本二分匹配差得多,如第十章中的所讨论的那样),所以我不打算在这里详述。
假设我们可以找到一个距离最优值 1.5 倍的度量 TSP 问题的解决方案,即使该问题是 NP-hard 的,但可能有点令人惊讶的是,找到这样一个近似算法——或在最优值的固定因子内的任何近似——本身就是 TSP 的 NP-hard 问题(即使 TSP 图是完整的)。事实上,这是几个问题的情况,这意味着我们不一定依赖近似作为所有 NP-hard 优化问题的实际解决方案。
为了了解为什么近似 TSP 是 NP 难的,我们从哈密尔顿循环问题简化到近似。你有一个图,你想知道它是否有哈密尔顿圈。为了得到 TSP 问题的完整图形,我们添加任何丢失的边,但是我们确保给它们巨大的边权重。如果我们的近似比是 k ,我们确保这些边权重大于 km ,其中 m 是原始图中的边数。那么,如果我们能找到原图的哈密尔顿之旅,那么新图的最佳之旅将至多是 m ,如果我们包括甚至一条新边,我们将打破我们的近似保证。这意味着,如果(且仅当)原始图中存在哈密尔顿圈,新图的近似算法将找到它——这意味着近似至少是一样困难的(即,NP 困难)。
拼命寻求解决方案
我们已经看到了硬度不稳定的一种方式——有时找到接近最优的解决方案比找到最优的解决方案要容易得多。不过,还有另一种马虎的方式。你可以创建一个算法,基本上是一个蛮力解决方案,但使用猜测来尽量避免计算。如果运气好的话,如果你要解决的问题不是很难,你可能会很快找到解决方案!换句话说,这里的草率不是关于解决方案的质量,而是关于运行时间的保证。
这有点像快速排序,它有二次最坏情况运行时间,但在平均情况下是对数线性的,常数因子非常低。对困难问题的大部分推理都是关于我们能对最坏情况下的性能给出什么样的保证,但实际上,这可能不是我们所关心的全部。事实上,即使我们不在 Russel Impagliazzo 的幻想世界 Algorithmica 中,我们也可能在他的另一个世界中,他称之为 Heuristica。这里,NP-hard 问题在最坏的情况下仍然是棘手的,但是在平均的情况下它们是易处理的。即使情况不是这样,它肯定是通过使用启发式方法,我们可以经常解决看起来不可能的问题。
这方面有很多方法。例如,在第九章的中讨论的 A*算法可用于搜索整个解决方案空间,以便找到一个正确或最优的解决方案。还有人工进化和模拟退火这样的启发式搜索技术(见本章后面的“如果你好奇……”)。不过,在这一节中,我将向您展示一个非常酷而且实际上非常简单的想法,它可以应用于本章中讨论的那些难题,但也可以作为解决任何类型的算法问题的快捷方式,甚至是那些有多项式解的问题。这可能是有用的,因为你想不出自定义算法,或者因为你的自定义算法太慢。
这项技术被称为 分支定界,在人工智能领域尤为知名。甚至有一个特殊版本(称为 alpha-beta 修剪 )用于玩游戏的程序。(例如,如果你有一个国际象棋程序,很可能其中会有一些分支和界限。)实际上,分支定界是解决 NP 难问题的主要工具之一,包括整数规划这样的一般性和表达性问题。尽管这种令人敬畏的技术遵循非常简单的模式,但很难以完全通用的方式实现。如果您要使用它,您可能需要实现一个针对您的问题定制的版本。
分支定界法,或称 B&B,是基于逐步构建解决方案,有点像许多贪婪算法(见第七章)。事实上,考虑哪个新的积木块往往是贪婪地选择的,导致所谓的最佳先分支定界。然而,不是完全致力于这个新的构建块(或这种扩展解决方案的方式),而是考虑所有的可能性。从本质上来说,我们正在处理一个蛮力解决方案。不过,能让这一切都起作用的是,通过推理探索的前景如何(或者更确切地说,前景如何),整个探索的途径都可以被修剪掉。
为了更具体,让我们考虑一个具体的例子。事实上,让我们再来看一个我们以前用几种方法处理过的问题,0-1 背包问题。1967 年,Peter J. Kolesar 发表了论文“背包问题的分支定界算法”,他在其中准确地描述了这种方法。正如他所说,“分支定界算法通过反复将所有可行解分成越来越小的子类,最终获得最优解。”这些“类”是我们通过构造部分解决方案得到的。
例如,如果我们决定将项目 x 包含在背包中,我们已经隐式地构造了包含 x 的所有解的类。当然,还有这个类的补充,所有做不做的解都包括 x 。我们将需要检查这两个类,除非我们能以某种方式得出结论,其中一个不能包含最优。你可以把这想象成一个树形的状态空间,这是第五章中提到的概念。每个节点由两个集合定义:背包中包含的物品和背包中不包含的物品。任何剩余的项目尚未确定。
在这个(抽象的,隐含的)树结构的根中,没有对象被包含或排除,所以所有的都是不确定的。为了将一个节点扩展成两个子节点(分支部分,,我们决定其中一个未决定的对象,并通过 include 得到一个子节点,通过 exclude 得到另一个子节点。如果一个节点没有未决定的项目,它就是一片叶子,我们不能继续下去。
应该清楚的是,如果我们完全探索这个树,我们将检查包含和排除的对象的每一种可能的组合(一种蛮力解决方案)。分支定界算法的整体思想是将修剪添加到我们的遍历中(就像在二分法和搜索树中一样),因此我们尽可能少地访问搜索空间。对于近似算法,我们引入了上界和下界。对于一个最大化问题,我们在最优解上使用一个下限(基于我们目前所发现的),在任何给定子树的解上使用一个上限(基于一些启发)。换句话说,我们在比较对最优值的保守估计和对给定子树的乐观估计。如果子树包含的保守界限比乐观界限更好,则该子树不能保持最优,因此它被修剪掉(包围部分的*)。*
在基本情况下,最优值的保守界限就是我们迄今为止发现的最佳值。当 B&B 开始运行时,让这个界限尽可能的高是非常有益的,所以我们可能想先在这上面花些时间。(例如,如果我们正在寻找一个度量 TSP 旅行,这是一个最小化问题,我们可以将初始上限设置为我们的近似算法的结果。)然而,为了使我们的背包例子简单,让我们只跟踪最佳解决方案,从零值开始。(练习 11-16 要求你对此进行改进。)
剩下的唯一难题是如何找到部分解的上限(表示搜索空间的子树)。如果我们不想失去实际解,这个界必须是一个真实的上界;我们不想排除基于过于悲观预测的子树。话又说回来,我们不应该太乐观(“这可能有无穷大的值!耶!”)因为那样我们就永远不能排除任何东西。换句话说,我们需要找到一个尽可能紧(低)的上限。一种可能性(也是 Kolesar 使用的一种)是假装我们正在处理分数背包问题,然后对其使用贪婪算法。这个解不会比我们寻找的实际最优解更差(练习 11-17),事实证明这是一个非常接近实际目的的解。
你可以在清单 11-2 的中看到 0-1 背包 B&B 的一个可能的实现。为了简单起见,代码只计算最优解的值。如果您想要实际的解决方案结构(包括哪些项目),您将需要添加一些额外的簿记。正如您所看到的,不是为每个节点显式地管理两个集合(包括和排除的项目),而是只使用到目前为止包括的项目的权重和值,用一个计数器( m )指示哪些项目已经被考虑(按顺序)。每个节点都是一个生成器,它将(在提示时)生成任何有希望的子节点。
注意在清单 11-2 中使用的
nonlocal
关键字让你修改周围范围内的变量,就像global
让你修改全局范围一样。然而,这个特性是 Python 3.0 中的新特性。如果您想在早期的 Pythons 中获得类似的功能,只需用sol = [0]
替换最初的sol = 0
,然后使用表达式sol[0]
而不仅仅是sol
来访问该值。(有关更多信息,请参见 PEP 3104,可从http://legacy.python.org/dev/peps/pep-3104
获得。)
这个故事的寓意是…
好吧。这一章可能不是书中最容易的一章,而且在日常编码中如何使用这里的一些主题可能也不完全清楚。为了阐明这一章的要点,我想我会试着给你一些建议,告诉你当你遇到一个棘手的问题时该怎么做。
- 首先,遵循第四章中的前两条解题建议。你确定你真的了解这个问题吗?你是否已经在到处寻找简化(例如,你是否知道任何看起来有一点相关的算法)?
- 如果你被难住了,再次寻找缩减,但是这次是从一些已知的 NP-hard 问题,而不是从到你知道如何解决的问题。如果你找到一个,至少你知道这个问题很难,所以没有理由自责。
- 考虑一下第四章的最后一点解决问题的建议:有没有什么额外的假设可以让问题不那么严重?最长路径问题通常是 NP 难的,但是在 DAG 中,你可以很容易地解决它。
- 你能介绍一些松弛吗?如果你的解决方案不需要 100%最优,也许有一个近似算法可以使用?你可以设计一个或者研究这个主题的文献。如果你不需要多项式最坏情况的保证,也许像分支定界这样的东西可以工作?
清单 11-2 。用分枝定界策略求解背包问题
from __future__ import division
from heapq import heappush, heappop
from itertools import count
def bb_knapsack(w, v, c):
sol = 0 # Solution so far
n = len(w) # Item count
idxs = list(range(n))
idxs.sort(key=lambda i: v[i]/w[i], # Sort by descending unit cost
reverse=True)
def bound(sw, sv, m): # Greedy knapsack bound
if m == n: return sv # No more items?
objs = ((v[i], w[i]) for i in idxs[m:]) # Descending unit cost order
for av, aw in objs: # Added value and weight
if sw + aw > c: break # Still room?
sw += aw # Add wt to sum of wts
sv += av # Add val to sum of vals
return sv + (av/aw)*(c-sw) # Add fraction of last item
def node(sw, sv, m): # A node (generates children)
nonlocal sol # "Global" inside bb_knapsack
if sw > c: return # Weight sum too large? Done
sol = max(sol, sv) # Otherwise: Update solution
if m == n: return # No more objects? Return
i = idxs[m] # Get the right index
ch = [(sw, sv), (sw+w[i], sv+v[i])] # Children: without/with m
for sw, sv in ch: # Try both possibilities
b = bound(sw, sv, m+1) # Bound for m+1 items
if b > sol: # Is the branch promising?
yield b, node(sw, sv, m+1) # Yield child w/bound
num = count() # Helps avoid heap collisions
Q = [(0, next(num), node(0, 0, 0))] # Start with just the root
while Q: # Any nodes left?
_, _, r = heappop(Q) # Get one
for b, u in r: # Expand it ...
heappush(Q, (b, next(num), u)) # ... and push the children
return sol # Return the solution
如果其他方法都失败了,你可以实现一个看起来合理的算法,然后用实验来看看结果是否足够好。例如,如果你在安排讲课以尽量减少学生的课程冲突(这是一种很容易 NP 难的问题),你可能不需要保证结果是最优的,只要结果足够好。 20
摘要
这一章是关于难题和一些你可以做的事情来处理它们。有许多类(看起来)困难的问题,但是在这一章中最重要的一个是 NPC,NP 完全问题的类。NPC 构成了 NP 的核心,这类决策问题的解可以在多项式时间内得到验证——基本上是任何实际应用中的每一个决策问题。NP 中的每个问题都可以在多项式时间内归结为 NPC 中的每个问题(或任何所谓的 NP 难问题),这意味着如果任何 NP 完全问题都可以在多项式时间内解决,那么 NP 中的每个问题也可以在多项式时间内解决。大多数计算机科学家认为这种情况不太可能发生,尽管还没有证据证明这两种情况。
NP-完全和 NP-困难问题举不胜举,它们在许多情况下都会突然出现。这一章让你体验了这些问题,包括它们的硬度的简要证明草图。这种证明的基本思想是依靠 Cook-Levin 定理,该定理认为 SAT 问题是 NP 完全的,然后减少多项式时间,或者从其他一些我们已经证明是 NP 完全或 NP 困难的问题。
为实际处理这些难题而暗示的策略是基于受控的马虎。近似算法可让您精确控制您的答案与最优解的差距,而分支定界等启发式搜索方法可保证您获得最优解,但可能需要不确定的时间来完成。
如果你好奇的话…
有很多书讨论计算复杂性、近似算法和启发式算法;请参阅“参考资料”部分了解一些想法。
有一个领域我还没有涉及到,那就是所谓的 元启发式,一种启发式搜索的形式,它给出的保证很少,但却惊人的强大。例如,有人工进化,所谓的遗传程序设计,或 GP,作为其最著名的技术之一。在 GP 中,你维护一个虚拟的结构群体,通常被解释为小的计算机程序(尽管它们可能是 TSP 问题中的哈密尔顿循环,或者任何你想要构建的结构)。在每一代中,你评估这些个体(例如,在解决 TSP 问题时计算它们的长度)。最有希望的被允许有后代——在下一代中的新结构,基于父母,但有一些随机的修改(简单的突变,甚至几个父母结构的组合)。其他元启发式方法基于熔化的材料在缓慢冷却时的行为(模拟退火),当避开你最近寻找的区域时你可能如何搜索东西(禁忌搜索),甚至一群昆虫样的解决方案可能如何在状态空间中移动(粒子群优化)。
练习
11-1.我们已经看到了几种情况,其中算法的运行时间取决于输入中的一个值,而不是输入的实际大小(例如,0-1 背包问题的动态编程解决方案)。在这些情况下,运行时间被称为伪多项式 ,它是问题大小的指数函数。为什么对一个特定的整数值进行二等分是一个例外?
11-2.为什么每一个 NP 完全问题都可以化简为每隔一个?
11-3.如果背包问题的容量被一个项目数为多项式的函数所有界,那么问题就在 p 中,为什么?
11-4.证明即使目标和 k 固定为零,子集和问题也是 NP 完全的。
11-5.描述从正整数子集和问题到无界背包问题的多项式时间简化。(这可能有点挑战性。)
11-6.为什么一个四色或者任何一个 k > 3 的 k 色并不比一个三色容易?
11-7.同构的一般问题,找出两个图是否具有相同的结构(即,如果你忽略节点的标签或身份,它们是否相等),不知道是 NP 完全的。子图同构的相关问题是,虽然。这个问题要求你确定一个图是否有与另一个图同构的子图。说明这个问题是 NP 完全的。
11-8.你如何用有向版本模拟无向的汉密尔顿圈问题?
11-9.你如何将无向汉密尔顿圈问题(有向或无向)简化为无向汉密尔顿路径问题?
11-10.你如何将汉密尔顿路径问题简化为汉密尔顿圈问题?
11-11.为什么本节给出的证明不能让我们得出在 DAG 中寻找最长路径是 NP 完全的结论?削减在哪里分解?
11-12.为什么我们没有证明没有正圈的最长路问题是 NP 完全的?
11-13.在无界背包问题的贪婪 2 近似中,为什么我们可以确定我们可以填满超过一半的背包(假设至少有一些对象可以放进去)?
11-14.假设你有一个有向图,你想找到最大的没有圈的子图(可以说是最大的子 DAG)。您将通过相关边的数量来测量大小。不过,你认为这个问题似乎有点挑战性,所以你决定满足于 2-近似。描述这样一个近似值。
11-15.在克里斯托菲德斯的算法中,为什么会有总权重至多等于最佳哈密尔顿圈的一半的奇数度节点的匹配?
11-16.在 0-1 背包问题的分枝定界解中,对最优下界的起始值做了一些改进。
11-17.为什么贪婪分数解永远不会比 0-1 背包中的实际最优差?
11-18.考虑最优化问题 MAX-3-SAT(或 MAX-3-CNF-SAT),其中你试图使 3-CNF 公式中的尽可能多的子句为真。这显然是 NP 难的(因为它可以用来解决 3-SAT),但有一个奇怪的有效和奇怪的简单随机近似算法:只需为每个变量抛硬币。表明在一般情况下,这是一个 8/7-近似(假设没有子句同时包含一个变量及其否定)。
11-19.在练习 4-3 和 10-8 中,你开始建立一个选择邀请朋友参加聚会的系统。您对每个来宾都有一个数字兼容性,并且您希望选择一个子集来提供最高的兼容性总和。有些客人只有在其他人在场的情况下才会来,而你设法适应了这一限制。然而,你意识到如果其他人在场,一些客人会拒绝来。表明解决问题突然变得困难了很多。
11-20.您正在编写一个并行处理系统,它将批处理作业分配给不同的处理器,以便尽可能快地完成所有工作。你有 n 个任务的处理时间,你要在 m 个相同的处理器之间分配这些时间,这样最终的完成时间是最小的。说明这是 NP 难的,描述并实现一个近似比为 2 的算法求解。
11-21.使用分支定界策略,编写一个程序,找出练习 11-20 中调度问题的最优解。
参考
阿罗拉和巴拉克(2009 年)。计算复杂性:一种现代方法。剑桥大学出版社。
Crescenzi,G. A .,Gambosi,g .,Kann,v .,Marchetti-Spaccamela,a .,和 Protasi,M. (1999 年)。复杂性与逼近:组合优化问题及其逼近性质。斯普林格。附录在线:ftp://ftp.nada.kth.se/Theory/Viggo-Kann/compendium.pdf
。
Garey,M. R .和 Johnson,D. S. (2003 年)。计算机和难处理性:NP 完全理论指南。弗里曼公司。
o . gold Reich(2010 年)。P、NP 和 NP-完全性:计算复杂性的基础。剑桥大学出版社。
Harel 博士(2000 年)。计算机有限公司:他们真正不能做的事情。牛津大学出版社。
Hemaspaandra,L. A .和 Ogihara,M. (2002 年)。复杂性理论伴侣。斯普林格。
霍赫鲍姆博士,编辑(1997 年)。NP 难问题的近似算法。PWS 出版公司。
因帕利亚佐(1995 年)。个人对一般情况复杂性的看法。在第十届复杂性结构理论年会 (SCT '95),第 134–147 页。http://cseweb.ucsd.edu/~russell/average.ps
。
Kolesar,P. J. (1967 年)。背包问题的分枝定界算法。管理学,13(9):723–735。http://www.jstor.org/pss/2628089
。
Vazirani,V. V. (2010 年)。近似算法。斯普林格。
Williamson 博士和 Shmoys 博士(2011 年)。近似算法的设计。剑桥大学出版社。http://www.designofapproxalgs.com
。
你可以假设从 Pollux 上取下很容易。也许有水滑道?所有这些都是在 Pollux 变得坚不可摧之前建造的。也许有一个岩石滑坡?
一个经济学教授和一个学生漫步在校园里。“看,”学生喊道,“路上有一张 100 美元的钞票!”“不,你错了,”聪明的脑袋回答道。这不可能。如果真的有一张 100 美元的钞票,肯定会有人捡起来的。”(摘自补偿*,作者 G. T .米尔科维奇和 J. M .纽曼。)
3Vinay Deolalikar。 P 不等于 NP 。2010 年 8 月 6 日。
4
5 实际上,Impagliazzo 对 Algorithmica 的定义也允许一些稍微不同的场景。
⑥注“似乎”我们真的不知道 P = NP,所以这个定义实际上可能是等价的。
7
8 我们当然需要坚持用多项式的节点数。
对于这一节和接下来的两节,你可能想试着展示在最初段落中的例子实际上是 NP-hard。
为了更容易理解这些章节中的论点,我通常会从看似简单的问题进展到更有表现力的问题(使用简化)。当然,在现实中,它们都一样具有表现力(也很难)——但有些问题比其他问题更好地隐藏了这一点。
11 如果你没有完全理解,不要担心,这不是很重要。
12 除非我们要把相对论或者地球曲率考虑进去...
13 任何无限的距离都会打破它,除非它完全没有边或者只由两个节点组成。
14 注意,我们总是用两者(最优和近似)中较大的除以较小的。
15
16 注意这里“以例证明”的用法。这是一个非常有用的技术。
不过,我猜他会想到更好的办法。
18 你可能想自己验证一下,任何图中奇数度节点的个数都是偶数。
如果你最小化,边界当然会被交换。
如果你想变得有趣,你可以研究一些源于人工智能领域的启发式搜索方法,比如遗传编程和禁忌搜索。见“如果你好奇……”部分了解更多信息。*******
十二、附录 A:加大油门:加速 Python
让它工作,让它正确,让它快速。
-肯特贝克
这个附录是对调整您的实现的常量因素的一些选项的一个小小的窥视。尽管这种优化在很多情况下不会取代正确的算法设计——尤其是当你的问题变得很大时——让你的程序运行速度提高十倍确实是有用的。
在寻求外部帮助之前,您应该确保您正在充分利用 Python 的内置工具。我在整本书中给了你一些提示,包括list
和deque
的正确用法,以及bisect
和heapq
如何在适当的情况下给你带来巨大的性能提升。作为一名 Python 程序员,您也足够幸运,能够轻松使用最先进、最高效(也是最有效实现的)的排序算法之一(list.sort
),以及一个真正通用的快速哈希表(dict
)。您甚至会发现itertools
和functools
可以提升代码的性能。 1
此外,在选择技术时,确保只优化必须优化的内容。优化确实会使您的代码或工具设置变得更加复杂,所以要确保这是值得的。如果你的算法“足够好”并且你的代码“足够快”,用另一种语言比如 C 引入扩展模块可能不值得。当然,什么是足够的由你来决定。(有关计时和分析代码的一些提示,请参见第二章。)
请注意,本附录中讨论的包和扩展主要是关于优化单处理器代码,或者通过提供高效实现的功能,或者通过让您创建或包装扩展模块,或者通过简单地加速您的 Python 解释器。将处理任务分配给多个内核和处理器无疑也是一个很大的帮助。multiprocessing
模块可以是一个起点。如果您想探索这种方法,您也应该能够找到许多用于分布式计算的第三方工具。例如,您可以查看 Python Wiki 中的并行处理页面。
在接下来的几页中,我将描述一些加速工具。在这方面有几个努力,当然景观是一个不断变化的:新的项目不时出现,一些旧的褪色和死亡。如果你认为这些解决方案听起来很有趣,你应该去看看它的网站,并考虑它的社区的规模和活动——当然,还有你自己的需求。有关网站 URL,请参见附录后面的表 A-1 。
熊猫、松鼠、鼠尾草和熊猫。NumPy 是一个有着悠久历史的包。它基于旧的项目,如 Numeric 和 numarray,其核心是实现一个多维数字数组。除了这个数据结构之外,NumPy 还有几个有效实现的函数和操作符,它们作用于整个数组,因此当您从 Python 中使用它们时,函数调用的数量被最小化,让您无需编译任何自定义扩展就可以编写高效的数值计算。作为 NumPy 的补充,Theano 可以优化数值数组上的数学表达式。 SciPy 和 Sage 是更加雄心勃勃的项目(尽管 NumPy 是他们的构建模块之一),收集了一些用于科学、数学和高性能计算的工具(包括本附录后面提到的一些工具)。Pandas 更适合于数据分析,但是如果它的数据模型适合您的问题实例,那么它既强大又快速。一个相关的工具包是 Blaze,如果您正在处理大量半结构化数据,它会有所帮助。
PyPy,Pyston,长尾小鹦鹉,Psyco 和空载燕子。加速你的代码的一个最少干扰的方法是使用实时(JIT)编译器。在过去,您可以将 Psyco 与 Python 安装一起使用。安装 Psyco 后,您只需导入psyco
模块并调用psyco.full()
就可以获得潜在的显著加速。Psyco 会在程序运行时将 Python 程序的一部分编译成机器码。因为它可以观察你的程序在运行时发生了什么,所以它可以进行静态编译器不能进行的优化。例如,Python 列表可以包含任意值。但是,如果 Psyco 注意到您的给定列表似乎只包含整数,它可以假设将来也会是这种情况,并编译这部分代码,就好像您的列表是一个整数数组一样。可悲的是,就像几个 Python 加速解决方案一样,Psyco,引用其网站上的话来说,“无人维护,死气沉沉。”然而,它的遗产在皮比依然存在。
PyPy 是一个更加雄心勃勃的项目:用 Python 重新实现 Python 。当然,这并不能直接提高速度,但是平台背后的想法是为分析、优化和翻译代码提供大量的基础设施。基于这个框架,就有可能进行 JIT 编译(Psyco 中使用的技术正被移植到 PyPy 上),甚至翻译成某种高性能语言,如 c。用于实现 PyPy 的 Python 的核心子集被称为 RPython (代表限制的 Python* ),并且已经有工具将这种语言静态编译成高效的机器代码。*
在某种程度上,unload Swallow 也是 Python 的 JIT 编译器。更准确地说,它是 Python 解释器的一个版本,使用了所谓的低级虚拟机(LLVM)。与标准解释器相比,该项目的目标是加速 5 倍。然而,这一目标尚未实现,该项目的活动似乎已经停止。
Pyston 是 Dropbox 开发的一个类似的、更新的基于 LLVM 的 Python JIT 编译器。在撰写本文时,Pyston 仍然是一个年轻的项目,只支持该语言的一个子集,而且还不支持 Python 3。然而,它已经在许多情况下击败了标准 Python 实现,并且正在积极开发中。Parakeet 也是一个相当年轻的项目,引用网页上的话来说,“使用类型推断、数据并行数组操作符和许多黑魔法来使您的代码运行得更快。”
GPULib、PyStream、PyCUDA 和 PyOpenCL。这四个包让你使用图形处理器(GPU)来加速你的代码。它们不提供像 Psyco 这样的 JIT 编译器会提供的那种插入式加速,但是如果你有一个强大的 GPU,为什么不使用它呢?项目中, PyStream 较老,Tech-X Corporation 的努力已经转移到较新的 GPULib 项目。它为使用 GPU 的各种形式的数值计算提供了一个高级接口。如果你想用 GPU 加速你的代码,你可能也想试试 PyCUDA 或者 PyOpenCL。
派热克斯、西顿、农巴和谢德金。这四个项目让你把 Python 代码翻译成 C、C或者 LLVM 代码。 Shedskin 将普通的 Python 代码编译成 C,而 Pyrex 和 Cython(Pyrex 的一个分支)主要面向 C。在 cy thon(及其前身 Pyrex)中,您可以向代码中添加可选的类型声明,例如声明变量是(并将永远是)整数。在 Cython 中,也有对 NumPy 数组的互操作性支持,允许你编写低级代码来有效地访问数组内容。我在自己的代码中使用了这种方法,对于合适的代码,加速因子高达 300–400。由 Pyrex 和 Cython 生成的代码可以直接编译成一个可以导入 Python 的扩展模块。如果您想从 Python 中生成 C 代码,Cython 是一个安全的选择。如果您只是寻求加速,特别是对于面向数组和数学密集型代码,您应该考虑 Numba ,它在导入时生成 LLVM 代码。有了 NumbaPro 中可用的高级功能,甚至还有 GPU 支持。
SWIG、F2PY 和 Boost.Python. 这些 工具让你分别包装 C/C++、Fortran、C代码。虽然您可以编写自己的包装器代码来访问您的扩展模块,但是使用这样的工具可以消除工作中的许多烦琐——并且使结果更有可能是正确的。例如,当使用 SWIG 时,在 C(或 C)头文件上运行命令行工具,就会生成包装器代码。使用 SWIG 的一个好处是,除了 Python 之外,它还可以为许多其他语言生成包装器,例如,您的扩展也可以用于 Java 或 PHP。
ctypes、llvm-py 和 CorePy2。这些是 模块,让你在你的 Python 代码中操作低级代码对象。ctypes 模块允许您在内存中构建 C 对象,并使用这些对象作为参数调用共享库中的 C 函数(如 dll)。llvm-py 包为您提供了前面提到的 llvm 的 Python API,它允许您构建代码,然后高效地编译它。如果你愿意,你可以用它来构建你自己的编译器(也许是你自己的语言?)在 Python 中。CorePy2 还允许您操纵和有效地运行代码对象,尽管它是在汇编级别上工作的。(注意,ctypes 是 Python 标准库的一部分。)
**编织、缠绕和嵌接。**这些 三个包让你直接在你的 Python 代码中使用 C(或者其他一些语言)。这是通过将 C 代码保存在多行 Python 字符串中,然后动态编译来完成的。然后,使用 ctypes 这样的接口工具,Python 代码就可以使用生成的代码对象了。
**其他工具。**显然,还有很多其他工具,根据您的需要,它们可能比这些工具更有用。例如,如果你想减少内存使用而不是时间,那么 JIT 就不适合你——JIT 通常需要大量内存。相反,您可能想看看 Micro Python,它被设计为具有最小的内存占用,并且适合在微控制器和嵌入式设备上使用 Python。而且,谁知道呢,也许你甚至不需要使用 Python。也许您正在 Python 环境中工作,并且您想要一种高级语言,但是您希望您的所有代码都非常快。虽然这可能是 Pythonic 式的异端邪说,但我建议看看朱莉娅。虽然它是一种不同的语言,但它的语法应该为任何 Python 程序员所熟悉。它还支持调用 Python 库,这意味着 Julia 团队正在与 IPython、 2 等 Python 项目合作,它甚至已经成为 SciPy 会议讲座的主题。 3
表 A-1 加速工具网站的 URLs】
|
工具
|
网站
火焰 | http://blaze.pydata.org |
助推。计算机编程语言 | http://boost.org |
辛迪 | http://www.cs.tut.fi/~ask/cinpy |
CorePy2 | https://code.google.com/p/corepy2 |
ctypes(类型) | http://docs.python.org/library/ctypes.html |
西通 | http://cython.org |
CyToolz | https://github.com/pytoolz/cytoolz |
F2PY | http://cens.ioc.ee/projects/f2py2e |
GPULib | http://txcorp.com/products/GPULib |
朱莉娅 | http://julialang.org |
llvm py | http://mdevan.org/llvm-py |
微型 Python | http://micropython.org |
努姆巴 | http://numba.pydata.org |
NumPy | http://www.numpy.org |
熊猫 | http://pandas.pydata.org |
长尾小鹦鹉 | http://www.parakeetpython.com |
并行处理 | https://wiki.python.org/moin/ParallelProcessing |
普西科 | http://psyco.sf.net |
皮库达 | http://mathema.tician.de/software/pycuda |
pinline | http://pyinline.sf.net |
PyOpenCL | http://mathema.tician.de/software/pyopencl |
PyPy | http://pypy.org |
派莱克斯耐热硬质玻璃 | http://www.cosc.canterbury.ac.nz/greg.ewing/python/Pyrex |
网络电视 | http://code.google.com/p/pystream |
python 直译器 | https://github.com/dropbox/pyston |
明智的 | http://sagemath.org |
我的天啊 | http://scipy.org |
谢德金 | http://code.google.com/p/shedskin |
大喝 | http://swig.org |
提亚诺 | http://deeplearning.net/software/theano |
空载燕子 | http://code.google.com/p/unladen-swallow |
织法 | http://docs.scipy.org/doc/scipy/reference/weave.html |
然而,如果你正在编写充满迭代器的函数式代码,并且你确实想要一个外部的提升,你可能想要看看 CyToolz。
2 例如参见http://jupyter.org
。
3。
十三、附录 B:问题和算法列表
如果你有船体问题,我为你感到难过,孩子;我有 99 个问题,但违规不是一个。
—匿名 1
本附录没有列出书中提到的每个问题和算法,因为讨论一些算法只是为了说明一个原理,而一些问题只是作为某些算法的例子。然而,最重要的问题和算法在这里用一些对正文的参考被勾画出来。如果你不能通过查阅这个附录找到你要找的东西,那就看看索引。
在本附录的大部分描述中, n 指的是问题大小(比如一个序列中元素的个数)。对于图的特殊情况,虽然, n 是指节点数, m 是指边数。
问题
**小集团和独立集团。**一个集团 是一个图,其中每对节点之间都有一条边。这里感兴趣的主要问题是在一个更大的图中寻找一个团(即把一个团识别为一个子图)。图中的独立集是指没有一对节点通过边相连的节点集。换句话说,找一个独立集相当于取边集的补集,找一个小团体。寻找一个k-团(由 k 个节点组成的团)或者寻找一个图中最大的团(最大团问题)是 NP-hard。(更多信息,参见第十一章。)
**最亲密的一对。**给定欧几里得平面上的一组,找出彼此最接近的两个点。这可以使用分治策略在线性时间内解决(见第六章)。
**压缩和最优决策树。**一棵 霍夫曼树是一棵的树,它的叶子有权重(频率),它们的权重乘以深度的和尽可能小。这使得这种树对于构造压缩码是有用的,并且当结果的概率分布已知时,这种树可以作为决策树。霍夫曼树可以使用霍夫曼算法来构建,在第七章 ( 清单 7-1 )中有描述。
连通和强连通分量。无向图是连通的,如果有一条路径从每个节点到其他节点。有向图是连通的,如果它的底层无向图是连通的。连通分量是连通的最大子图。举例来说,可以使用诸如 DFS ( 清单 5-5 )或 BFS ( 清单 5-9 )之类的遍历算法来找到连接的组件。如果在一个有向图中有一条从每个节点到其他节点的(有向)路径,称之为强连通。强连通分量(SCC) 是强连通的极大子图。可以使用 Kosaraju 的算法找到 SCC(清单 5-10 )。
**凸包。**一个凸包是欧几里得平面中包含一组点的最小凸区域。使用分治策略可以在对数线性时间内找到凸包(见第六章)。
**寻找最小值/最大值/中值。**寻找一个序列的最小值和最大值可以通过简单的扫描在线性时间内找到。给定线性时间准备,可以使用二进制堆在恒定时间内重复查找和提取最大值或最小值。使用选择或随机选择,也可以在线性(或预期线性)时间内找到序列的第 k 个最小元素(kk=n/2 的中值)。(更多信息,参见第六章。)
流与切问题 **。**在边上有流量容量的网络中,可以推送多少个单位的流量?那就是最大流量问题。一个等价的问题是找到最能限制流量的边容量集;这就是 min-cut 问题。这些问题有几种版本。例如,您可以将成本添加到边中,并找到最大流量中最便宜的流量。你可以在每条边上加一个下界,寻找一个可行的流。您甚至可以在每个节点中添加单独的供应和需求。这些问题将在第十章中详细讨论。
图形着色。 尝试给图的节点上色,这样就不会有邻居共享一种颜色。现在尝试用给定数量的颜色来做这件事,或者甚至找到最低的数量(图中的色数)。一般来说这是一个 NP 难问题。然而,如果要求您查看一个图是否是二色的(或二分的),这个问题可以使用简单遍历在线性时间内解决。寻找团覆盖的问题等价于寻找独立集覆盖,这是一个与图着色相同的问题。(参见第十一章了解更多关于图形着色的信息。)
磕磕绊绊的问题。确定给定的算法是否会随着给定的输入而终止。问题在一般情况下是不可判定的(即不可解的)(见第十一章)。
汉密尔顿循环/路径和 TSP …以及欧拉旅行。几个 路径和子图问题都可以高效解决。但是,如果您想对每个节点只访问一次,那就麻烦了。任何涉及这个约束的问题都是 NP 难的,包括寻找一个哈密尔顿圈(访问每个节点一次并返回),一个哈密尔顿路径(访问每个节点一次,不返回),或者一个完整图的最短旅行(旅行推销员/销售代表问题)。无论是有向还是无向的情况,问题都是 NP 难的(见第十一章)。然而,访问每条边恰好一次的相关问题——找到所谓的欧拉之旅——可以在多项式时间内解决(参见第五章)。TSP 问题是 NP 难的,即使对于特殊情况,例如使用平面中的欧几里德距离,但是对于这种情况,以及对于任何其他度量距离,它可以有效地近似到 1.5 倍以内。然而,一般来说,近似 TSP 问题是 NP 难的。(更多信息见第十一章。)
**背包问题和整数规划。**背包问题涉及在一定的约束条件下,选择一组物品中有价值的子集。在(有界)分数的情况下,你有一定量的一些物质,每种物质都有一个单位值(单位重量的值)。你也有一个可以承载一定最大重量的背包。(贪婪的)解决方案是尽可能多地获取每种物质,从单位价值最高的物质开始。对于整数背包问题,你只能拿整个项目,不允许分数。每样东西都有重量和价值。对于有界情况(也称为 0-1 背包),每种类型的对象数量有限。(另一种观点是,你有一套固定的物品,你要么拿,要么不拿。)在无界的情况下,您可以从一组对象类型中的每一个中获取您想要的数量(当然,仍然考虑您的承载能力)。称为子集和问题的特殊情况涉及选择一组数的子集,使得该子集具有给定的和。这些问题都是 NP 难的(见第十一章,但是承认基于动态规划的伪多项式解(见第八章)。如前所述,分数背包问题甚至可以使用贪婪策略在多项式时间内解决(参见第七章)。在某种程度上,整数规划是背包问题的推广(因此显然是 NP 难的)。它是简单的线性规划,其中变量被约束为整数。
**最长递增子序列。**寻找给定序列中元素按升序排列的最长子序列。这可以使用动态规划在对数线性时间内解决(见第八章的)。
**匹配。**有许多匹配问题,都涉及到将一些对象链接到其他对象。本书讨论的问题是二部匹配和最小代价二部匹配(第十章)和稳定婚姻问题(第七章)。二分匹配(或最大二分匹配)涉及在二分图中寻找边的最大子集,使得子集中没有两条边共享一个节点。最小成本版本做同样的事情,但是最小化这个子集上的边成本的总和。稳定的婚姻问题有点不一样;在那里,所有的男人和女人都有对异性成员的偏好排名。一套稳定的婚姻的特点是,你找不到一对愿意拥有对方而不是现在的另一半。
**最小生成树。**生成树是一个子图,它的边在原图的所有节点上形成一棵树。最小生成树是最小化边成本总和的树。例如,可以使用克鲁斯卡尔算法(列表 7-4 )或普里姆算法(列表 7-5 )找到最小生成树。因为边的数量是固定的,所以可以通过简单地否定边权重来找到最大生成树。
**分割和装箱。**划分 涉及将一组数分成两个和相等的集合,而装箱问题涉及将一组数打包成一组“箱”,使得每个箱中的和低于某个限制,并且箱的数量尽可能少。这两个问题都是 NP 难的。(参见第十一章。)
**SAT,巡回赛-SAT,k-CNF-SAT。**这些都是满意度问题(SAT)的变种,它要求你确定一个给定的逻辑(布尔)公式是否为真,如果你被允许将变量设置为你想要的任何真值。circuit-SAT 问题简单地使用逻辑电路而不是公式, k -CNF-SAT 涉及合取范式的公式,其中每个子句由 k 文字组成。对于 k = 2,后者可以在多项式时间内求解。其他的问题,以及 k -CNF-SAT 对于k2,都是 NP 完全的。(参见第十一章。)
**搜索。**这个是一个非常普遍又极其重要的问题。您有一个键,并希望找到一个相关的值。例如,这就是 Python 等动态语言中变量的工作方式。这也是如今你在网上几乎能找到任何东西的方式。两个重要的解决方案是哈希表(见第二章的和二分搜索法或搜索树(见第六章的)。给定数据集中对象的概率分布,可使用动态规划构建最佳搜索树(见第八章)。
**序列比较。**你可能想要比较两个序列,以了解它们有多相似(或不相似)。一种方法是找到两者共同的最长子序列(最长公共子序列),或者找到从一个序列到另一个序列的基本编辑操作的最小数量(所谓的编辑距离,或 Levenshtein 距离)。这两个问题或多或少是等价的;更多信息参见第八章。
**序列修改。**在链表中间插入一个元素代价很小(常数时间),但是寻找给定位置代价很大(线性时间);对于数组,情况正好相反(常量查找、线性插入,因为所有后面的元素都必须移位)。不过,对于这两种结构来说,附加都可以很便宜地完成(见第二章中list
的“黑盒”边栏)。
**集合和顶点覆盖。**一个一个顶点覆盖是覆盖(即相邻)图的所有边的顶点的集合。集合覆盖是这一思想的推广,其中节点被子集代替,并且您想要覆盖整个集合。问题在于限制或最小化节点/子集的数量。这两个问题都是 NP 难的(见第十一章)。
**最短路径。**这个问题涉及到寻找从一个节点到另一个节点,从一个节点到所有其他节点(反之亦然),或者从所有节点到所有其他节点的最短路径。一对一、一对一和一对一的情况以同样的方式解决,通常对未加权图使用 BFS,对 DAG 使用 DAG 最短路径,对非负边权重使用 Dijkstra 算法,在一般情况下使用 Bellman–Ford。为了在实践中加快速度(虽然不影响最坏情况下的运行时间),还可以使用双向 Dijkstra,或 A算法。对于所有对最短路径问题,选择的算法可能是 Floyd-Warshall 或(对于稀疏图)Johnson 算法。如果边是非负的,Johnson 算法(渐近地)等价于从每个节点运行 Dijkstra 算法(可能更有效)。(关于最短路径算法的更多信息,参见第五章和第九章。)注意最长*路问题(对于一般图)可以用来求哈密尔顿路,也就是说它是 NP 难的。这实际上意味着最短路径问题在一般情况下也是 NP 难的。然而,如果我们不允许图中有负循环,我们的多项式算法将会起作用。
**排序和元素唯一性。**排序 是一个重要的操作,也是其他几个算法必不可少的子程序。在 Python 中,通常使用list.sort
方法或sorted
函数进行排序,这两种方法都使用 timsort 算法的高效实现。其他算法包括插入排序、选择排序和 gnome 排序(所有这些都有二次运行时间),以及堆排序、合并排序和快速排序(它们是对数线性的,尽管这仅适用于快速排序的一般情况)。关于二次排序算法的信息,见第五章;关于对数线性(分治)算法,见第六章。判定一组实数是否包含重复项不能(在最坏的情况下)用比对数线性更好的运行时间来解决。通过归约,排序也不能。
拓扑排序。 对一个 DAG 的节点进行排序,使所有的边都指向同一个方向。如果边表示依赖性,则拓扑排序表示尊重依赖性的排序。这个问题可以通过引用计数的形式来解决(参见第四章)或者使用 DFS(参见第五章)。
**遍历。**这里的问题是访问一些连通结构中的所有对象,通常表示为图或树中的节点。这个想法可以是要么访问每个节点,要么只访问那些需要解决某个问题的节点。忽略图或树的一部分的后一种策略被称为修剪 ,并且被用于(例如)搜索树和分支定界策略。关于遍历的更多内容,请参见第五章。
算法和数据结构
**2-3 棵树。**平衡的树形结构,允许在最坏情况下θ(LGn时间内进行插入、删除和搜索。内部节点可以有两到三个子节点,在插入过程中,根据需要通过拆分节点来平衡树。(参见第六章。)
*A。**启发式引导的单源最短路径算法。适合大搜索空间。不是选择具有最低距离估计值的节点(如 Dijkstra 的),而是使用具有最低启发值(距离估计值和剩余距离猜测值之和)的节点。最坏情况下的运行时间与 Dijkstra 算法相同。(参见清单 9-10 。)
AA 树。 2-3-trees 使用节点旋转模拟了一个节点级别编号的二叉树。插入、删除和搜索的最坏情况运行时间为θ(LGn)。(参见清单 6-6 。)
贝尔曼-福特。加权图中从一个节点到所有其他节点的最短路径。沿着每条边寻找捷径 n 次。没有负周期,在n–1 次迭代后保证正确答案。如果最后一轮有所改善,则检测到负循环,算法放弃。运行时间θ(nm)。(参见清单 9-2 。)
双向 Dijkstra。 Dijkstra 的算法从开始和结束节点同时运行,交替迭代到两个算法中的每一个。当两者在中间相遇时,找到最短的路径(尽管在这一点上必须小心)。最坏情况下的运行时间就像 Dijkstra 算法一样。(参见列表 9-8 和列表 9-9 。)
**二分搜索法树。**一个二叉树结构,其中每个节点都有一个键(通常还有一个关联值)。后代键由节点键划分:较小的键放在左边的子树中,较大的键放在右边。平均来说,任何节点的深度都是对数的,给出了期望的插入和搜索时间θ(LGn)。但是,如果没有额外的平衡(比如在 AA 树中),树会变得不平衡,给出线性运行时间。(参见清单 6-2 。)
**平分,二分搜索法。**一种搜索程序,其工作方式类似于搜索树,通过重复将排序序列中感兴趣的区间减半。通过检查中间元素并决定所寻找的值必须位于左侧还是右侧来执行减半。运行时间θ(LGn)。一个非常有效的实现可以在bisect
模块中找到。(参见第六章。)
**分支和捆绑。**一种通用算法设计方法。通过构建和评估部分解决方案,以深度优先或最佳优先的顺序搜索解决方案空间。为最佳值保留保守估计,而为部分解决方案计算乐观估计。如果乐观估计比保守估计差,则不扩展部分解,并且算法回溯。常用于解决 NP 难问题。(参见清单 11-2 中的获得 0-1 背包问题的分支定界解决方案。)
广度优先搜索(BFS)。 逐层遍历一个图(可能是一棵树),从而也识别(未加权)最短路径。通过使用 FIFO 队列跟踪发现的节点来实现。运行时间θ(n+m)。(参见清单 5-9 。)
**桶排序。**对给定区间内均匀分布的数值进行排序,方法是将区间分成 n 个大小相等的桶,并将值放入其中。预期的存储桶大小是恒定的,因此可以使用(例如)插入排序对它们进行排序。总运行时间θ(n)。(参见第四章。)
**Busacker–go Wen。**通过使用福特-富尔克森方法中最便宜的扩充路径,找到网络中最便宜的最大流(或给定流值的最便宜的流)。这些路径是使用贝尔曼-福特或(经过一些权重调整)Dijkstra 算法找到的。运行时间通常取决于最大流量值,伪多项式也是如此。对于最大流量 k ,运行时间为 O ( km lg n )。(参见清单 10-5 。)
**克里斯托菲德斯算法。**度量 TSP 问题的近似算法(近似比界限为 1.5)。找到一个最小生成树,然后在树的奇数度节点中找到一个最小匹配 2 ,根据需要进行短路以对图进行有效浏览。(参见第十一章。)
**计数排序。**在θ(n)时间内对取值范围小(最多有θ(n个连续值)的整数进行排序。其工作原理是对出现次数进行计数,并使用累积计数将数字直接放入结果中,并在进行过程中更新计数。(参见第四章。)
**DAG 最短路径。**寻找从一个节点到 DAG 中所有其他节点的最短路径。工作原理是找到节点的拓扑排序,然后从左到右放松每个节点的所有出边(或者所有入边)。也可以(因为缺少循环)用来寻找最长的路径。运行时间θ(n+m)。(参见清单 8-4 。)
深度优先搜索。 通过深入然后回溯来遍历图(可能是树)。通过使用 LIFO 队列跟踪发现的节点来实现。通过跟踪发现时间和结束时间,DFS 还可以用作其他算法(如拓扑排序或 Kosaraju 算法)中的子例程。运行时间θ(n+m)。(参见清单 5-4 、 5-5 和 5-6 )。)
**迪杰斯特拉的算法。**在赋权图中找出从一个节点到所有其他节点的最短路径,只要没有负的边权重。遍历图,使用优先级队列(堆)重复选择下一个节点。优先级是节点的当前距离估计。每当从被访问的节点发现快捷方式时,这些估计被更新。运行时间是θ((m+n)LGn),如果图是连通的,简单来说就是θ(mLGn)。
双头队列。 FIFO 使用链表(或数组链表)实现的队列,这样在两端插入和提取对象可以在恒定的时间内完成。一个有效的实现可以在collections.deque
类中找到。(参见第五章中关于该主题的“黑盒”侧栏。)
**动态数组,向量。**在数组中有额外的容量,所以追加是有效的。通过将内容重新定位到一个更大的数组,以常数因子增长它,当它填满时,追加可以在平均(摊销)时间内保持不变。(参见第二章。)
埃德蒙兹-卡普。Floyd–war shall 方法的具体实例,其中使用 BFS 执行遍历。在θ(nm2 时间内求最小费用流。(参见清单 10-4 。)
弗洛伊德-沃肖尔。寻找从每个节点到所有其他节点的最短路径。在迭代 k 中,只有第一个 k 节点(按某种顺序)被允许作为路径上的中间节点。从k–1 扩展包括检查通过第一个k–1 节点往返于 k 的最短路径是否比直接通过这些节点更短。(即,对于每个最短路径,节点 k 要么被使用,要么不被使用。)运行时间为θ(n3)。(参见清单 9-6 。)
福特-富尔克森。一个解决最大流问题的通用方法。该方法包括重复遍历该图以找到所谓的增加路径,即流量可以增加(增加)的路径。如果有额外的容量,可以沿着边缘增加流量,或者如果沿着边缘有流量,可以沿着边缘向后增加(即取消)。因此,遍历可以沿着有向边向前和向后移动,这取决于穿过它们的流。运行时间取决于使用的遍历策略。(参见清单 10-4 。)
**盖尔-沙普利。**发现一组稳定的婚姻给出了一组男性和女性的偏好排名。任何没有订婚的男人都会向他们还没有求婚的最喜欢的女人求婚。每个女人都会在目前的追求者中选择自己喜欢的(可能会和未婚夫在一起)。可以用二次运行时间来实现。(参见第七章中的侧栏“热切的追求者和稳定的婚姻”。)
**侏儒排序。**一个简单的二次运行时间排序算法。可能不是你在实践中会用到的算法。(参见清单 3-1 。)
**哈希,散列表。**查找 向上一个键得到相应的值,就像在一个搜索树中一样。条目存储在一个数组中,它们的位置通过计算关键字的(伪随机的,类似于)哈希值找到。给定一个好的散列函数和数组中足够的空间,插入、删除和查找的预期运行时间是θ(1)。(参见第二章。)
**成堆,堆积如山。**堆是高效的优先级队列。使用线性时间预处理,min- (max-)堆将让您在常数时间内找到最小(最大)的元素,并在对数时间内提取或替换它。添加一个元素也可以在对数时间内完成。从概念上讲,堆是一个完整的二叉树,其中每个节点都比其子节点小(大)。修改时,可以用θ(LGn运算修复该属性。实际上,堆通常使用数组实现(节点编码为数组条目)。一个非常有效的实现可以在heapq
模块中找到。Heapsort 类似于 selection sort,只是未排序的区域是一个堆,因此查找最大元素 n 次的总运行时间为θ(nLGn)。(参见第六章中关于堆、heapq
和堆排序的“黑盒”侧栏。)
**霍夫曼算法。**构建霍夫曼树,例如,可用于构建最佳前缀码。最初,每个元素(例如,字母表中的字符)被制成单节点树,权重等于其频率。在每次迭代中,选取两个最轻的树,将它们与一个新的根合并,并赋予新树一个等于原来两个树权重之和的权重。这可以在对数线性时间内完成(或者,实际上,如果频率被预先分类,则可以在线性时间内完成)。(参见清单 7-1 。)
**插入排序。**一个简单的二次运行时间排序算法。它的工作方式是在数组的初始排序段中重复插入下一个未排序的元素。对于小数据集,它实际上比更高级(和最优)的算法更可取,如合并排序或快速排序。(但是,在 Python 中,如果可能的话,应该使用list.sort
或sorted
。)(见清单 4-3 。)
**插值搜索。**类似于普通的二分搜索法,但是使用区间端点之间的线性插值来猜测正确的位置,而不是简单地查看中间元素。最坏情况下的运行时间仍然是θ(LGn,但是对于均匀分布的数据,平均情况下的运行时间是 O (lg lg n )。(在第六章的部分的“如果你好奇……”中提到。)
**迭代深化 DFS。**DFS 的重复运行,其中每一次运行都有一个它可以遍历的距离的限制。对于具有一些扇出的结构,运行时间将与 DFS 或 BFS 相同(即θ(n+m))。关键是它具有 BFS 的优点(它找到最短的路径,保守地探索大的状态空间),具有 DFS 较小的内存占用。(参见清单 5-8 。)
**约翰逊算法。**寻找从每个节点到所有其他节点的最短路径。基本上从每个节点运行 Dijkstra 的。然而,它使用了一个技巧,以便它也可以处理负边权重:它首先从新的开始节点运行 Bellman-Ford(向所有现有节点添加边),然后使用结果距离来修改图的边权重。修改后的权重都是非负的,但是被设置为使得原始图中的最短路径也将是修改后的图中的最短路径。运行时间θ(MnLGn)。(参见清单 9-4 。)
**Kosaraju 的算法。**使用 DFS 找到强连接的组件。首先,节点按其完成时间排序。然后反转边,运行另一个 DFS,使用第一个排序选择开始节点。运行时间θ(n+m)。(参见清单 5-11 。)
**克鲁斯卡尔的算法。**通过重复添加不产生循环的最小剩余边来寻找最小生成树。这种循环检查可以(用一些小技巧)非常有效地执行,所以运行时间主要由边的排序决定。总而言之,运行时间为θ(mLGn)。(参见清单 7-4 。)
**链表。**代表序列的数组的另一种选择。尽管一旦找到正确的条目,修改链表是很便宜的(常数时间),但是找到这些条目通常需要线性时间。链表的实现有点像路径,每个节点指向下一个节点。注意 Python 的list
类型实现为数组,而不是链表。(参见第二章。)
**合并排序。**原型分治算法。它把要排序的序列分成两半,递归排序两半,然后线性时间合并排序后的两半。总运行时间为θ(nLGn)。(参见清单 6-5 。)
**矿氏算法。**一种算法,通过标记通道入口和出口来亲自穿越实际的迷宫。在许多方面类似于迭代深化 DFS 或 BFS。(参见第五章。)
**普里姆的算法。**通过重复添加最靠近树的节点来增长最小生成树。它本质上是一种遍历算法,使用优先级队列,就像 Dijkstra 的算法一样。(参见清单 7-5 。)
**基数排序。**按数字(元素)对数字(或其他序列)进行排序,从最低有效位开始。只要数字的数量是恒定的,并且可以在线性时间内对数字进行排序(例如,使用计数排序),总运行时间就是线性的。重要的是,对数字使用的排序算法是稳定的。(参见第四章。)
**随机选择。**找到中值,或者一般来说,第 k 阶统计量(第 k 阶最小元素)。有点像“半快速排序”它随机(或任意)选择一个 pivot 元素,并将其他元素划分到 pivot 的左边(较小的元素)或右边(较大的元素)。然后搜索继续在右边部分进行,有点像二分搜索法。不能保证完全平分,但是期望的运行时间仍然是线性的。(参见清单 6-3 。)
**选择。**相当不现实,但保证线性,随机选择的同胞。它的工作方式如下:将序列分成五个一组。使用插入排序找到每个中值。使用 select 递归查找这些中间值的中间值。用这个中线作为支点,分割元素。现在在正确的一半上运行 select。换句话说,它类似于随机选择——不同之处在于,它可以保证某个百分比最终会位于中枢的任一侧,从而避免完全不平衡的情况。实际上,这不是一个你可能会在实践中用到的算法,但是了解它是很重要的。(参见第六章。)
**选择排序。**一个简单的二次运行时间排序算法。非常类似于插入排序,但不是重复地将下一个元素插入排序的部分,而是重复地在未排序的区域中查找(即选择)最大的元素(并与最后一个未排序的元素交换)。(参见清单 4-4 。)
**蒂姆索特。**一种基于归并排序的超副本原地排序算法。在没有任何处理特殊情况的显式条件的情况下,它能够考虑部分排序的序列,包括反向排序的片段,因此可以比看起来可能的速度更快地对许多真实世界的序列进行排序。在list.sort
和sorted
中的实现也真的很快,所以如果你需要排序,那就是你应该使用的。(参见第六章中 timsort 上的“黑盒”侧栏。)
**通过引用计数进行拓扑排序。**排序一个 DAG 的节点,使所有的边从左到右。这是通过计算每个节点的入边数来实现的。入度为零的节点保持在队列中(可能只是一个集合;顺序无所谓)。从队列中取出节点,并按拓扑排序顺序放置。当您这样做时,您会减少该节点有边的节点的计数。如果它们中的任何一个达到零,它们就被放入队列中。(参见第四章。)
**用 DFS 进行拓扑排序。**对 DAG 节点进行拓扑排序的另一个算法。这个想法很简单:执行一个 DFS 并按照逆完成时间对节点进行排序。要轻松获得线性运行时间,您可以简单地将节点添加到您的订单中,因为它们在 DFS 中接收它们的完成时间。(参见清单 5-7 。)
**特雷莫算法。**和 Ore 的算法一样,这种算法被设计成在走迷宫时由人来执行。一个执行 Tremaux 算法的人所追踪到的模式,本质上和 DFS 是一样的。(参见第五章。)
**绕树两圈。**度量 TSP 问题的近似算法,保证产生的解的成本至多是最优解的两倍。首先,它构建一个最小生成树(小于最优值),然后它“绕过”该树,走捷径以避免两次访问同一条边。由于公制,这保证比走每边两次更便宜。这最后一次遍历可以通过前序 DFS 来实现。(参见清单 11-1 。)
1 戏谑地归于 Lt. Cdr。《星际迷航:下一代》的乔治·拉·福吉。
2 注意,在一般(可能是非二元)图中寻找匹配不在本书的讨论范围之内。
十四、附录 C:图术语
他打了一个赌,我们可以从地球上 15 亿居民中找出任何一个人,通过最多五个熟人,其中一个是他认识的,他可以找到那个被选中的人。
-脆脆脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆
下面的介绍大致基于赖因哈德·迪斯特尔的图论和邦-詹森和古丁的有向图 的第一章,以及科尔曼等人的算法简介的附录(注意,不同书籍之间的术语和符号可能不同;它不是完全标准化的。)如果你认为似乎有很多东西需要记住和理解,你可能不必担心。是的,前面可能会有很多新单词,但大部分概念都很直观和直接,它们的名字通常有意义,更容易记住。
所以……一个图 是一个抽象的网络,由节点(或顶点),通过边(或弧)连接而成。更正式地说,我们将一个图定义为一对集合, G = ( V , E ),其中节点集 V 是任意有限集,边集 E 是一组(无序的)节点对。 2 我们把这个叫做 V 上的图*。我们有时也会写 V ( G )和 E ( G ),来表示集合属于哪个图。 3 图通常用网络图来说明,像图 C-1 中的那些(暂时忽略灰色高亮)。例如图 C-1 中称为G1 的图,可以用节点集 V = { a 、 b 、 c 、 d 、 e 、 f }和边集E来表示***
*你不必总是严格区分图和它的节点和边集。例如,我们可能会谈到图 G 中的一个节点 u ,实际上意味着在 V ( G ),或者等价地,一条边{ u , v }在 G 中,意味着在 E ( G )。
注在渐近表达式中直接使用集合 V 和 E 是很常见的,比如θ(V+E),用来表示图形大小的线性度。在这些情况下,集合应该被解释为它们的基数(即大小),更正确的表达应该是θ(|V|+|E|),其中| |是基数运算符。
图 C-1 。各种类型的图和有向图
基本的图形定义给出了我们通常所说的无向图,它有一个近亲:有向图,或有向图。唯一不同的是,边不再是无序的对,而是有序的对:节点 u 和 v 之间的边现在要么是从 u 到 v 的边( u 、 v )要么是从到v、 u )换句话说,在有向图 G 中, E ( G )是关于 V ( G )的关系。图 C-1 中的图形G3 和 G4 是边缘方向用箭头表示的有向图。注意G3 在 a 和 d 之间有所谓的反平行边,也就是说边是双向的。这是可以的,因为( a 、 d )和( d 、 a )是不一样的。但是,平行边(即相同的边,重复的边)是不允许的,无论是在图中还是在有向图中。(这是因为边形成一个集合。)还要注意,一个无向图不能在一个节点和它自己之间有一条边,尽管这在一个有向图中是可能的(所谓的自循环),惯例是不允许的。**
注意所做的允许诸如平行边和自循环之类的事情。如果我们构建我们的网络结构,使得我们可以有多条边(也就是说,这些边现在形成了一个多重集),并且自循环,我们称之为(可能有向)伪图。一个没有自循环的伪图仅仅是一个多重图。还有更奇特的版本,比如超图,每条边可以有多个节点。
尽管图和有向图是完全不同的东西,但我们处理的许多原理和算法在这两种情况下都同样适用。因此,有时在更一般的意义上使用术语图是很常见的,包括有向图和无向图。还要注意,在许多情况下(比如当穿过或者在图中“四处移动”时),无向图可以用有向图来模拟,用一对反向平行的有向边来代替每个无向边。这通常是在将图作为数据结构实际实现时完成的(在第二章中有更详细的讨论)。如果很清楚一条边是有向还是无向的,或者关系不大,我有时会写 uv 而不是{ u , v }或( u , v )。
一条边的事件发生在它的两个节点上,称为它的端节点。即 uv 入射在 u 和 v 上。如果边是有向的,我们说它离开(或者事件从 ) u 并且它进入(或者事件到 ) v 。我们分别称 u 和 v 为其尾和头。如果无向图中有一条边 uv ,则节点 u 和 v 是的邻居,称为邻居。一个节点 v 的邻居集合,也称为 v 的邻域,有时写成 N ( v )。比如G1 中 b 的邻域 N ( b )是{ a , c , d }。如果所有节点都是两两相邻的,那么这个图叫做完全(参见图 C-1 中的 G 2 )。对于一个有向图,边 uv 意味着 v 与 u 相邻*,但是只有当我们也有一条反平行边 vu 时,反过来才成立。(换句话说,与 u 相邻的节点是那些我们可以从 u 沿着从它开始的正确方向的边“到达”的节点。)*
入射在一个节点 v 上的(无向)边数(即 N ( v )称为其度,常写成 d ( v )。比如在G1(图 C-1 )中,节点 b 的度为 3,而 f 的度为 0。(零度节点称为孤立。)对于有向图,我们可以将这个数字分成入度(输入边的数量)和出度(输出边的数量)。我们还可以将节点的邻域划分为 - 邻域中的*,有时称为父节点,以及外邻域,或子节点。*
一个图可以是另一个图的一部分。我们说一个图 H = ( W , F )是 G = ( V , E )的子图或者反过来说 G 是 H 的 ,如果 W 也就是说,我们可以通过(可能)去掉一些节点和边,从 G 得到 H 。在图 C-1 中,突出显示的节点和边表示一些示例子图,这些子图将在下文中详细讨论。如果 H 是 G 的子图,我们常说 G 包含 H 。我们说如果 W = V 则 H 跨越 G 。也就是说,一个生成子图是一个覆盖原图所有节点的子图(比如图G4 图 C-1 中的子图)。
路径是一种特殊的图,当它们以子图的形式出现时,人们主要感兴趣。一条路径是,通常由一系列(不同的)节点标识,例如 v 1 、 v 2 、…、 v n ,连续节点对之间有边(仅):??【E】= {v1v注意,在有向图中,路径必须沿着边的方向;也就是说,路径中的所有边都指向前方。路径的长度就是它的边数。我们说这是一条 v 1 到vn之间的路径(或者,在导演的情况下,从 v 1 到 v n )。在示例图G2 中,高亮显示的子图是 b 和 e 之间的一条路径,例如长度为 3。如果一条路径 P 1 是另一条路径 P 2 的子图,我们说 P 1 是 P 2 的子路径。例如, G 2 中的路径 b 、 a 、da*、 d 、 e 都是 b 、 a 、d的子路径*****
该路径的近亲是周期。通过将路径的最后一个节点连接到第一个节点来构建一个循环,如G3(图 C-1 )中通过 a 、 b 和 c 的(有向)循环所示。一个圈的长度也是它包含的边的数量。就像路径一样,循环必须遵循边的方向。
注意这些定义不允许路径自身交叉,也就是说,包含循环作为子图。一个更一般的类似路径的概念,通常被称为行走,仅仅是节点和边的交替序列(也就是说,它本身不是一个图),这将允许节点和边被多次访问,特别是,将允许我们“循环行走”相当于一个循环的是一个封闭行走,它在同一个节点开始和结束。为了区分没有循环的路径和普通的步行,有时使用术语简单路径。
到目前为止所讨论的概念的一个普遍概括是引入了边权重(或成本或长度)。每个边 e = uv 被分配一个实数, w ( e ),有时写成 w ( u , v ),通常表示与该边相关联的某种形式的成本。例如,如果节点是地理位置,则权重可以表示道路网络中的行驶距离。一个图 G 的权重 w ( G )简单来说就是 G 中所有边 e 的 w ( e )之和。然后,我们可以将路径和循环长度的概念分别推广到路径 P 和循环 C 的 w ( P )和 w ( C )。最初的定义对应于每条边的权重为 1 的情况。两个节点之间的距离是它们之间最短路径的长度。(寻找这样的最短路径在书中有广泛的论述,主要是在第九章。)
如果一个图包含每对节点之间的一条路径,那么它就是连通的。如果所谓的底层无向图 (即忽略边方向后得到的图)是连通的,那么我们说这个图是连通的。在图 C-1 中,唯一没有连接的图是G1。一个图的与连通的最大子图称为它的连通分量。在图 C-1 中,G1 有两个连通分量,而其他只有一个(每个),因为图本身是连通的。
注这里使用的术语极大是指某物不能被扩展而仍具有给定的性质。例如,在某种意义上,连通分量是最大的,因为它不是一个更大的图(有更多节点或边的图)的子图,也是连通的。
在计算机科学和其他领域,有一类图特别受到关注:不含圈的图,或无圈图。 非循环图有向和无向两种变体,并且这两种变体具有相当不同的性质。先重点说一下无向的那种。
无向无环图的另一个术语是森林,森林的相连部分被称为 树。换句话说,一棵树就是一个连通的森林(即由单个连通分量组成的森林)。比如G1 是一片有两棵树的森林。在一棵树中,度数为 1 的节点称为叶子(或外部节点)、 4 ,而所有其他节点称为内部节点。例如,G1 中较大的树有三片叶子和两个内部节点。较小的树只包含一个内部节点,尽管在少于三个节点的情况下谈论叶子和内部节点可能没有太大意义。
注意有 0 或 1 个节点的图被称为琐碎的,往往会使定义比必要的更复杂。在很多情况下,我们只是忽略了这些情况,但有时记住它们可能很重要。例如,作为归纳的起点,它们可能非常有用(在第四章的中有详细介绍)。
树有几个有趣和重要的属性,其中一些与整本书的特定主题有关。不过,我会在这里给你一些。设 T 是一个无向图,有 n 个节点。那么下面的陈述是等价的(练习 2-9 要求你证明事实确实如此):
- T 是树(即无环且连通)。
- T 是非循环的,有n–1 条边。
- T 是连通的,有n–1 条边。
- 任何两个节点都由一条路径连接。
- T 是无循环的,但是给它添加任何新的边都会产生一个循环。
- T 是连通的,但是移除任何一条边都会产生两个连通的分量。
换句话说,这些关于 T 的陈述中的任何一个,就其本身而言,都和其他任何一个一样具有特征。例如,如果有人告诉你在 T 中的任何一对节点之间正好有一条路径,你马上就知道它是连通的,有n–1 条边,并且它没有圈。
通常,我们通过选择一个根节点(或者简单地说根节点)来锚定我们的树。结果被称为根树、,与我们目前看到的自由树相对。(如果从上下文可以清楚一棵树是否有根,我将简单地在有根和自由两种情况下使用非限定术语 tree 。)像这样挑出一个节点让我们定义向上和向下的概念。矛盾的是,计算机科学家(以及一般的图形理论家)倾向于将根放在顶部,将叶放在底部。(我们或许应该多出去走走……)。对于任何节点, up 都是在根的方向上(沿着节点和根之间的单一路径)。向下则是任何其他方向(自动向树叶方向)。注意,在一个有根的树中,根被认为是一个内部节点,而不是一片叶子,,即使它碰巧有一个度。
正确定位后,我们现在定义一个节点的深度为它到根的距离,而它的高度是到任何叶子的最长向下路径的长度。树的高度就是根的高度。例如,考虑图 C-1 中G1 中较大的树,让 a (高亮显示)为根。树的高度是 3,而深度,比如说, c 和 d 是 2。一个级别由具有相同深度的所有节点组成。(本例中,0 级由 a ,1 级 b ,2 级 c 和 d ,3 级e组成。)
这些方向也允许我们定义其他关系,使用家谱中相当直观的术语(奇怪的是,我们只有单亲)。你的上一级邻居(也就是更接近根的邻居)是你的父,而你的下一级邻居是你的子。 5 (根当然没有父,叶子也没有子。)更一般来说,你向上走能到达的任何节点都是祖先,而你向下走能到达的任何节点都是后代。跨越节点 v 及其所有后代的树被称为以 v 为根的子树。
注意与一般的子图相反,术语子树通常不而适用于所有碰巧是树的子图——尤其是当我们谈论有根树的时候。
其他类似的术语一般都有其明显的含义。例如,兄弟节点是具有共同父节点的节点。有时候,兄弟姐妹是按排序的,这样我们就可以谈论节点的“第一个孩子”或“下一个兄弟姐妹”。在这种情况下,该树被称为一棵有序树。
正如第五章中所解释的,很多算法都是基于遍历,系统地探索图,从某个初始起点(一个起始节点)开始。尽管探索图形的方式可能不同,但它们有一些共同点。只要它们遍历整个图,它们都会产生生成树。 6 (生成树就是恰好是树的简单生成子图。)遍历产生的生成树 ,称为遍历树 ,以起始节点为根。在处理单个算法时,将重新讨论其工作原理的细节,但是图 C-1 中的图G4 说明了这个概念。突出显示的子图就是这样一个遍历树,根在 a 。注意,从 a 到树中其他节点的所有路径都遵循边方向;有向图中的遍历树总是这样。
注意一个有向图,它的底层图是一棵有根树,并且所有的有向边都指向远离根的方向(也就是说,所有的节点都可以通过从根开始的有向路径到达),这个有向图被称为树形图,尽管我将主要把这样的图简单地称为树。换句话说,有向图中的遍历确实给了你一个遍历树形图。术语定向树既用于有根(无方向)树,也用于树状树,因为有根树的边具有远离根的隐含方向。
术语疲劳设置了吗?振作起来——只剩下一个图形概念了。如上所述,有向图可以是无环的,就像无向图一样。有趣的是,这些图形通常看起来不太像有向树的森林。因为基本的无向图可以是任意循环的,一个有向无环 图 ,或 DAG 可以有任意的结构(见练习 2-11),只要边指向正确的方向——也就是说,它们指向不存在有向循环。在样本图G4 中可以看到这样的例子。
Dag 作为依赖性的表示是非常自然的,因为循环依赖性通常是不可能的(或者至少是不期望的)。例如,节点可能是大学课程,一条边( u , v )将表明课程 u 是 v 的先决条件。理清这种依赖关系是第五章中拓扑排序部分的主题。Dag 也是动态编程技术的基础,在第八章中讨论。
阿尔伯特-拉斯洛·巴拉巴希在他的书 链接:网络的新科学(基础书籍,2002)中引用的话。
2 你可能根本没想到这是个问题,但你可以假设 V 和 E 不重叠。
3 即使我们给集合取了其他的名字,这些函数仍然被称为 V 和 E 。例如,对于一个图形 H = ( W , F ),我们会得到V(H)=W,E(H)=F。
4 不过正如后面解释的,根不被认为是叶。此外,对于只包含两个相连节点的图,将它们都称为叶子有时没有意义。
5 注意,这与有向图中的内外邻域是同一个术语。一旦我们开始确定树边的方向,这两个概念就一致了。
6 只有从起始节点可以到达所有节点时才成立。否则,遍历可能不得不在几个地方重新开始,导致一个跨越森林。生成林的每个组件都有自己的根。*
十五、附录 D:练习提示
要解决任何问题,有三个问题要问自己:首先,我能做什么?第二,我能读什么?第三,我能问谁呢?
—吉米·罗恩
第一章
1-1.随着机器速度越来越快,内存越来越大,它们可以处理更大的输入。对于糟糕的算法来说,这最终会导致灾难。
1-2.一个简单且相当可扩展的解决方案是对每个字符串中的字符进行排序并比较结果。(理论上,计算字符频率,可能使用collections.Counter
,会更好。)一个真正糟糕的解决方案是比较一个字符串和另一个字符串的所有可能排序。我不能夸大这个解决方案有多差;其实算法不会比这个差太多。随意编码,看看你能检查多大的字谜。我打赌你不会走远的。
第二章
2-1.你将会十次使用同一个列表。绝对不是个好主意。(比如试着运行a[0][0] = 23; print(a)
。)
2-2.一种可能性是使用大小为 n 的三个数组;让我们称它们为A
、B
和C
,以及已经分配了多少条目的计数,m. A
是实际的数组,B
、C
和m
形成了用于检查的额外结构。仅使用C
中的第一个m
条目,它们都是B
中的索引。当你执行A[i] = x
时,你也设置B[i] = m
和C[m] = i
然后递增m
(也就是m += 1
)。你能看到这如何给你你需要的信息吗?将此扩展到二维邻接数组应该非常简单。
2-3.如果 f 是 O ( g ,那么就有一个常数 c 这样对于n>n0,f(n)≤CG(n)。这意味着使用常数 1/ c 满足 g 为ω(f)的条件。同样的逻辑反过来也成立。
2-4.让我们看看它是如何工作的。根据定义,blogb n=n。这是一个等式,所以我们可以两边取对数(底数 a )得到 loga(blogbn)= logan。因为 logxy=ylogx(标准对数法则),我们可以把这个写成(logab)(logbn= loga从这个结果得出的结论是,logan和 logbn之间的差别只是一个常数因子(logab),当我们使用渐近符号时,这个常数就消失了。
2-5.我们想弄清楚,随着 n 的增加,不等式kn≥c**nj是否最终成立,对于某些常数 c 。为了简单起见,我们可以设置 c = 1。我们可以取两边的对数(底数 k )(它不会翻转不等式,因为它是一个增函数),留给我们的是找出n≥jlogkn是否在某个点,这是由(增)线性函数支配对数的事实给出的。(你应该自己验证一下。)
2-6.这可以用一个叫做变量替换 的小技巧轻松解决。像练习 1-5 中一样,我们设了一个试探性的不等式,nk≥LGn,想说明它对大的 n 成立。再次,我们取两边的对数,得到kLGn≥LG(LGn)。双重对数可能看起来很可怕,但我们可以非常优雅地避开它。我们不关心指数如何超越多项式,只关心它在某个时刻发生。这意味着我们可以替换我们的变量——我们设置 m = lg n 。如果增加 m 就能得到我们想要的结果,那么增加 n 就能得到。这给了我们 km ≥ lg m ,这和练习 2-5 中的一样!
2-7.在 Python 列表中,任何涉及到查找或修改某个位置的事情通常都要花费恒定的时间,因为它们的底层实现是数组。你必须遍历一个链表来做到这一点(平均一半的列表),给出一个线性运行时间。一旦你知道了位置,交换东西在两者中都是不变的。(看能不能实现线性时间链表反转。)修改列表结构(通过插入或删除元素,除了在末尾)对于数组(和 Python 列表)通常是线性的,但是在许多情况下对于链表可以在恒定时间内完成。
2-8.对于第一个结果,我将坚持这里的上半部分,并使用 O 符号。下半部分(ω)完全等效。总和O(F)+O(G)是两个函数之和,比如说 F 和 G ,这样(对于足够大的 n ,还有一些c)F(n)≤cf*((你明白为什么两者可以使用相同的 c 了吗?)也就是说,对于足够大的 n ,我们会有F*(n)+G(n)≤c(F(n)+G(n), 也就是简单的说F(n)+G(n)是O(F(n)+G(n),这就是我们想要证明的。 f g 案大部分相当(有一点皱纹与 c 有关)。表明 max(θ(f),θ(g)=θ(max(f, g ))遵循类似的逻辑。最令人惊讶的事实可能是 f + g 是 O (max( f , g ),或者 max( f , g )是ω(f+g)——也就是说最大值至少增长了这很容易解释,因为f+g≤2 max(f, g )。
2-9.当像这样显示语句的等价性时,通常通过列表从一个到下一个显示蕴涵,然后从最后一个到第一个。(你可能也想直接展示一些其他的含义;有 30 种可供选择。)这里有几个提示让你开始。1②:想象树是定向的。然后每条边代表一个父子关系,除了根以外的每个节点都有一条父边,给出n–1 条边。2③:通过逐个添加n–1 条边,逐渐构建 T 。不允许连接已经在 T 中的节点(它是无环的),所以每条边必须用来连接一个新节点到 T ,这意味着它将被连接。
2-10.这是第三章中计数的开胃菜,你可以通过归纳法证明结果(《??》第四章中深入讨论的一种技术)。不过,有一个简单的解决方案(与《??》第二章中的演示非常相似)。给每个父节点(内部节点)两个虚拟的冰淇淋甜筒。现在,每位家长都给孩子每人一个蛋卷冰淇淋。唯一没有冰淇淋卡住的是根。所以,如果我们有了 n 叶子和 m 内部节点,我们现在可以看到 2 m (最初指定的冰淇淋甜筒数量)等于m+n–1(除了根之外的所有节点,每个节点有一个甜筒),这意味着m=n–1。这就是我们正在寻找的答案。很整洁,是吧?(这是一个很好的计数技术的例子,我们用两种不同的方法来计数同一事物,并利用了两个计数必须相等的事实——在这个例子中,是冰淇淋甜筒的数量。)
2-11.给节点编号(任意)。从低到高调整所有边的方向。
2-12.优点和缺点取决于你用它做什么。例如,它可以有效地查找边权重,但不太适合迭代图的节点或节点的邻居。您可以通过使用一些额外的结构来改进这一部分(例如,一个全局节点列表,如果您需要的话,或者一个简单的邻接表结构,如果需要的话)。
第三章
3-1.你可以尝试用归纳法甚至递归来做这件事!
3-2.从重写到(n2–n)/2 开始。然后先去掉常数因子,剩下n2–n。之后就可以把 n 掉了,因为是以n2 为主。
3-3.二进制编码向我们展示了 2 的哪些次方包含在一个和中,并且每个都只包含一次。假设第一个 k 次方(或二进制数)让我们表示任意数直到 2k–1(我们的归纳假设;对于 k = 1)明显成立。现在,尝试使用练习中提到的属性来显示另一个数字(即,允许添加 2 的下一个幂)将让您表示最多 2k+1–1 的任何数字。
3-4.其中一个基本上是在可能的值上的for
循环。另一个是二分法,在第六章中有更详细的讨论。
3-5.这一点从分子式的对称性来看是相当明显的。另一种理解方式是,有多少种留下 k 个元素,就有多少种移除 k 个元素的方式。
3-6.提取sec[1:]
的动作需要复制n–1 个元素,这意味着算法的运行时间变成了握手和。
3-7.这很快产生握手和。
3-8.在解开递归时,得到 2 { 2T(n–2)+1 }+1 = 22T(n–2)+2+1,最终变成一个加倍和,1 + 2 + … + 2 i 。要得到基本情况,你需要设置 i = n ,这样你得到的幂的总和最多为 2 n ,也就是θ(2n)。
3-9.类似于练习 3-8,但是这里的解开给你 2 { 2T(n–2)+(n–1)}+n= 22T(n–2)+2(n–1)+n。过一会儿,你得到一个相当复杂的和,它有 2 个IT(n–I)作为它的支配被加数。设置 i = n 给你 2I。(我希望这个粗略的推理没有完全说服你;你应该用归纳法检查一下。)
3-10.这是一个工整的:取两边的对数,得出 logxlogy= logylogx。现在,只需注意这两者都等于 log x log y 。(看为什么?)
3-11.递归调用之外发生的事情基本上是序列的两个部分合并成一个序列。首先,我们只假设递归调用返回的序列与参数的长度相同(即lft
和rgt
不改变长度)。while
循环遍历这些元素,弹出元素直到其中一个为空;这最多是线性的。reverse
也最多是线性的。res
列表现在由从lft
和rgt
弹出的元素组成,最后,剩余的元素(在lft
或rgt
中)被组合(线性时间)。剩下的唯一一件事就是显示从mergesort
返回的序列的长度与其参数的长度相同。你可以用长度为seq
的归纳来做这件事。(如果这仍然有点挑战性,也许你可以在第四章中学到一些技巧?)
3-12.这将为我们提供在 f ( n )内的握手和,意味着递归现在是T(n)= 2T(n/2)+θ(n2)。即使是对主定理基本熟悉的人也应该会告诉你,二次部分占优势,意味着 T ( n )现在是θ(n2)——比原来大幅度的差!
第四章
4-1.在 E 上尝试归纳,并“向后”进行归纳步骤,如内部节点计数示例所示。基本情况( E = 0 或 E = 1)是琐碎的。假设公式对于E–1 成立,并且考虑具有 E 边的任意连通平面图。尝试移除一条边,并假设(目前)较小的图仍然是连通的。那么边缘的移除已经减少了一个边缘计数,并且它一定已经合并了两个区域,减少了一个区域计数。公式对此成立,意思是V–(E–1)+(F–1)= 2,相当于我们要证明的公式。现在看看你是否能处理移除一条边断开图的情况。(提示:您可以将归纳假设应用于每个连接的组件,但这会将无限区域计算两次,因此您必须对此进行补偿。)也试着在 V 和 F 上使用感应。哪个版本适合你的口味?
4-2.这实际上是一个棘手的问题,因为任何一系列的休息都会给你相同的“运行时间”n–1。你可以用归纳法展示这一点,如下所示(用 n =1 给出一个平凡的基本情况):第一次中断会给你一个有 k 个正方形的矩形和一个有n–k的矩形(其中 k 取决于你在哪里中断)。这两个都比 n 小,所以通过强归纳,我们假设它们每个的断点个数分别为k–1 和n–k–1。加上这些,加上第一次休息,我们得到整个巧克力的n–1。
4-3.你可以把这表示成一个图的问题,其中一条边 uv 意味着 u 和 v 相互认识。您试图找到最大的子图(即具有最多的节点数),其中每个节点 v 都有一个度d(v)≥k。归纳又一次拯救了我们。基础案例是 n = k + 1,这里只有当图是完整的,你才能解决问题。归约(归纳假设)就是,你可能已经猜到了,你可以解决n–1 的问题,解决 n 的方法是要么(1)看到所有节点的度数都大于等于 k (大功告成!)或(2)找到单个节点移除并解决其余的(通过归纳假设)。事实证明,你可以删除任何你喜欢的度数小于 k 的节点,因为它永远不会成为解的一部分。(这有点像排列问题——如果需要删除一个节点,就直接删除它。)
加分题提示:注意 d /2 是边与节点的比值(在全图中),只要你删除度小于等于 d /2 的节点,那个比值(对于剩余子图)就不会减少。只要不断删除,直到你达到这个极限。剩下的图有一个非零的边节点比(因为它至少和原始图一样大),所以它必须是非空的。此外,因为我们不能删除更多的节点,每个节点的度数都大于 d /2(也就是说,我们已经删除了所有度数较小的节点)。
4-4.虽然有许多方法可以表明只有两个中心节点,但最简单的方法可能是首先构建算法(使用归纳法),然后使用它来完成证明。 V = 0、1 或 2 的基本情况非常简单——可用的节点都在中心。除此之外,我们希望将问题从 V 简化为V–1。事实证明,我们可以通过移除一个叶节点来做到这一点。对于 V > 2,没有一个叶节点可以是中心的(它的邻居将总是“更中心”,因为它的最长距离会更低),所以我们可以只移除它并忘记它。该算法直接遵循:继续移除叶子(可能通过保持度数/计数再次实现),直到所有剩余节点都是同等中心的。现在应该很明显,这发生在 V 最大为 2 时。
4-5.这是一个位像拓扑排序, 除了我们可能有循环,所以不能保证我们会有入度为零的节点。这实际上相当于寻找一条有向的哈密尔顿路径,而这条路径在一般的图中可能根本就不存在(而且找出来真的很难;见第十一章),但是对于一个有向边的完整图(图论中实际上叫做锦标赛),这样的路径(即沿着边的方向访问每个节点一次的路径)将一直存在。我们可以直接做单元素约简——我们去掉一个节点,把剩下的排序(用归纳假设是可以的;基本情况是琐碎的)。现在的问题变成了我们是否(以及如何)能够插入这最后一个节点,或者骑士。要看出这是可能的,最简单的方法就是简单地在他(或她)击败的第一个对手之前插入骑士(如果有这样的对手;否则,将他放在最后)。因为我们选择了第一个,之前的骑士一定打败了他,所以我们保留了想要的排序类型。
4-6.由此可见,在做归纳的时候,注意细节是多么重要。对于 n =2,这个论证就不成立了。即使归纳假设对于n–1 为真(基本情况, n =1),在这种情况下,两个集合之间没有重叠,因此归纳步骤中断!注意,如果你能以某种方式表明任何两匹马的颜色相同(也就是说,将基本情况设置为 n =2),那么归纳将(显然)有效。
4-7.关键不在于它应该适用于任何有 n 片叶子的树,因为我们已经假设了这种情况。重要的是,这个论点对任何有 n 叶子的树都成立,事实也确实如此。无论你选择哪一棵有 n 片叶子的树,你都可以删除一片叶子和它的父节点,构造一棵有效的有n–1 片叶子和n–2 个内部节点的二叉树。
4-8.这只是一个直接应用规则的问题。
4-9.一旦我们找到一个人(如果我们曾经找到过的话),我们知道这个人不可能指向其他任何人,或者那个人不会被移除。因此,他(或她)一定是指着自己(或者说,是指着自己的椅子)。
4-10.快速浏览一下代码应该会告诉你这是握手循环(B 的构造在每次调用中占用线性时间)。
4-11.尝试排序序列(的“数字”)。使用计数排序作为一个子程序,用一个参数告诉你哪个数字排序。然后从最后一个数字到第一个数字循环,对每个数字排序一次。(注意:您可以对数字使用归纳法来证明基数排序是正确的。)
4-12.算出每个区间(值域)必须有多大。然后,您可以将每个值除以这个数字,向下舍入,以找出将其放在哪个桶中。
4-13.我们假设(如第二章中所讨论的那样)我们可以对足够大的数使用常数时间运算来处理整个数据集,这包括dI。因此,首先,找到所有字符串的这些计数,将它们作为一个单独的“数字”添加然后,您可以使用计数排序按这个新数字对数字进行排序,到目前为止的总运行时间为θ(≘dI+n)=θ(≘dI)。具有相同位数长度的每个数字“块”现在可以单独排序(使用基数排序)。(你看到这如何仍然给出总运行时间θ(≘dI)以及我们如何实际上最终得到所有正确排序的数字了吗?)
4-14.将它们表示为两位数,其中每一位的取值范围为 1… n 。(你看这个怎么做?)然后你可以使用基数排序,给你一个总的线性运行时间。
4-15.列表理解具有二次运行时间复杂度。
4-16.关于运行实验的一些提示,见第二章。
4-17.它不能放在这个点之前,只要我们不把它放在后面,它就不能在任何依赖它的东西之后结束(因为没有循环)。
4-18.例如,您可以通过随机排列节点,并为每个节点添加随机数量的前向边来生成 Dag。
4-19.这个和原著挺像的。现在,您必须维护剩余节点的出度,并将每个节点插入到已经找到的节点之前。(记住不要在列表的开头插入任何东西;相反,追加,然后在最后反转,以避免二次运行时间。)
4-20.这是算法思想的直接递归实现。
4-21.一个简单的归纳解决方案是删除一个间隔,解决其余的问题,然后检查初始间隔是否应该加回来。问题是,你必须将这个时间间隔与所有其他时间间隔进行比较,给出一个二次运行时间。但是,您可以改进这个运行时间。首先,将区间按其左端点排序,使用归纳假设,你可以解决第n–1 个第一区间的问题。现在,扩展假设:假设您也可以在n–1 个区间中找到最大的右端点。你看到归纳步骤是如何在恒定时间内完成的了吗?
4-22.不是随机选择配对 u 、 v ,而是简单地遍历每一个可能的配对,给出一个二次运行时间。(您是否看到这必然会为您提供每个城镇的正确答案?)
4-23.为了表明 foo 很难,你必须将条减少为 foo 。为了展示 foo 是容易的,你必须将 foo 减少到 baz 。
第五章
5-1.渐近运行时间将是相同的,但是您可能会获得更多的开销(也就是说,一个更高的常数因子),因为您不是通过内置操作添加大量对象,而是为每个对象运行更慢的自定义 Python 代码。
5-2.试试把归纳证明变成递归算法。(你可能还想看看弗勒里的算法。)
5-3.尝试重建用于无向图的归纳论证(和递归算法)——它实际上是相同的。与 Trémaux 算法的链接如下:因为你被允许在每个方向上遍历每个迷宫通道一次,所以你可以将通道视为两个方向相反的有向边。这意味着所有交叉点(结点)将具有相等的入度和出度,并且您可以保证找到沿每条边行走两次的路线,每个方向一次。(请注意,在本练习给出的更一般的情况下,您不能使用 Trémaux 的算法。)
5-4.这只是遍历由像素组成的网格的简单问题,相邻的像素充当邻居。为此通常使用 DFS ,但是任何遍历都可以。
5-5.我相信有很多方法可以使用这个线程,但是如果你不能做任何其他类型的标记,一种可能是像 DFS(或 IDDFS)堆栈一样使用它。你可能会多次造访同一个房间,但至少你不会骑自行车。
5-6.在迭代版本中根本没有真正表现出来。一旦从堆栈中弹出所有的“遍历后代”,它就会隐式地发生。
5-7.正如练习 5-6 中所解释的,在迭代 DFS 中,代码中有回溯发生的点,所以我们不能只在某个特定的地方设置结束时间(就像在递归中一样)。相反,我们需要在堆栈中添加一个标记。例如,我们可以添加表单(u, v)
的边,而不是将u
的邻居添加到堆栈中,在所有这些边之前,我们会推送(u, None)
,指示u
的回溯点。
5-8.假设在拓扑排序中,节点 u 必须在 v 之前。如果我们首先从(或通过) v 运行 DFS,我们将永远无法到达 u ,因此 v 将在我们(在稍后的某个时间点)开始一个从或通过 u 运行的新 DFS 之前结束。目前为止,我们是安全的。另一方面,如果我们先通过 u 。然后,因为 u 和 v 之间存在(直接或间接)依赖关系(即路径),所以我们会到达 v ,它会(再次)在 u 之前结束。
5-9.例如,你可以在这里提供一些函数作为可选参数。
5-10.如果有一个循环,DFS 将总是尽可能地遍历该循环(可能是在从一些弯路返回之后)。这意味着它最终会回到进入循环的地方,形成一个后沿。(当然,它可能已经沿着某个另一个周期穿过了这个边缘,但这仍然会使它成为后边缘。)所以如果没有后沿,就不可能有任何循环。
5-11.其他遍历算法也能够通过在遍历树中找到从被访问节点到其祖先之一的边(后边)来检测循环。然而,确定这种情况何时发生(也就是说,区分后边缘和交叉边缘)不一定那么容易。然而,在无向图中,为了找到一个循环,你所需要的是到达一个节点两次,并且检测它是容易的,不管你使用什么遍历算法。
5-12.假设你找到了到某个节点 u 的前向和交叉边。因为没有方向限制,DFS 不会在没有探索其所有外边缘的情况下回溯超过 u ,这意味着它已经在另一个方向上遍历了假设的前向/交叉边缘!
*5-13.这只是记录每个节点的距离,而不是它的前一个节点的距离,起始节点从零开始。你只需在前任的距离上加 1,而不是记住前任。(当然,你可以两者兼而有之。)
5-14.这个问题的好处是,对于一个边缘 uv ,如果你把 u 涂成白色, v 一定是黑色(反之亦然)。这是一个我们以前见过的想法:如果问题的约束迫使你做某事,那么在构建解决方案时,这一定是一个安全的步骤。因此,你可以简单地遍历图形,确保你用不同的颜色给邻居着色;如果,在某些时候,你不能,没有解决办法。否则,你已经成功地创建了一个两党。
5-15.在强组件中,每个节点都可以到达其他节点,因此每个方向上至少有一条路径。如果边缘颠倒了,还是会有。另一方面,任何像这样由两条路径连接的而不是的对也不会在反转之后,所以也不会有新的节点被添加到强分量中。
5-16.假设 DFS 从 X 中的某个地方开始。然后,在某个时候,它将迁移到 Y。我们已经知道,如果没有回溯,它就无法返回 SCC 图是非循环的),所以在我们返回 X 之前,Y 中的每个节点都必须接收一个结束时间。换句话说,在 Y 中的所有节点都结束之后,X 中至少有一个节点将结束。
5-17.试着找一个简单的例子,这个例子会给出错误的答案。(你可以用一个非常小的图来做。)
第六章
6-2.渐近运行时间将是相同的。然而,比较的次数从上升到。要了解这一点,请分别考虑二进制和三进制搜索的递归B(n)=B(n/2)+1 和T(n)=T(n/3)+2(基本情况为 B (1) = T (1 你可以(通过归纳)表示出B(n)<LGn+1<T(n)。
6-3.如练习 6-2 所示,比较的次数不会下降;然而,还可以有其他优势。例如,在 2-3 树中,3 节点帮助我们平衡。在更一般的 B 树中,大节点有助于减少磁盘访问次数。请注意,在 B 树的每个节点的中使用二分搜索法是很常见的。
6-4.您可以遍历树,在对左右子树的递归调用之间打印或产生每个节点键(以便遍历)。
6-5.首先你找到它的节点;姑且称之为 v 。如果是叶子,去掉就好了。如果是只有一个子节点的内部节点,就用它的子节点替换它。如果节点有两个子节点*,在左子树中找到最大的(最右边的)节点,或者在右子树中找到最小的(最左边的)节点——这是您的选择。现在用这个后代的键和值替换 v 中的键和值,然后删除这个后代。(为了避免使树不必要的不平衡,你应该在左版本和右版本之间切换。)*
6-6.我们正在插入 n 个随机值,所以我们每插入一个值,它在目前为止插入的 k 中最小的概率(包括这个值)是 1/ k 。如果是,最左边节点的深度增加 1。(为了简单起见,我们假设根的深度是 1,而不是习惯上的 0。)这意味着节点深度是 1 + 1/2 + 1/3 + … + 1/ n ,一个和称为 n 次谐波数,或 H n 。有趣的是,这个和是θ(LGn)。
6-7.假设你和你左边的孩子交换位置,结果是比你右边的孩子大。您刚刚破坏了堆属性。
6-8.每个父母都有两个孩子,所以你需要移动两步才能到达下一个的孩子;因此,节点 i 的子节点位于 2I+1 和 2 i + 2。如果你看不出这是如何工作的,试着把节点按顺序画出来,就像它们被放在数组中一样,树的边在父节点和子节点之间形成弧形。
6-9.当考虑标准实现时,要看到构建一个堆是如何线性的可能有点棘手,标准实现从叶子的上面开始,一层一层地遍历节点,在每个节点上执行对数运算。这看起来几乎是对数线性的。然而,我们可以将它重新表述为一个等价的分治算法,这是我们更熟悉的一种算法:首先从左子树开始堆,然后从右子树开始堆,然后修复根。递推成为T(n)= 2T(n/2)+θ(LGn),我们知道(比如通过主定理)是线性的。
6-10.首先,堆让您可以直接访问最小(或最大)节点。当然,这也可以通过维护指向搜索树中最左边(或最右边)节点的直接指针来实现。其次,堆允许您轻松地维护平衡,并且因为它是完全平衡的,所以它可以被简洁地表示,从而导致非常低的开销(例如,您为每个节点保存一个引用,并且您可以将值保存在相同的内存区域中)。最后,构建(平衡的)搜索树需要对数线性时间,而构建堆需要线性时间。
6-13.对于随机输入,实际上不会有什么不同(除了额外函数调用的成本)。然而,总的来说,这意味着没有一个单一的输入能保证总是引出最坏情况的行为。
6-15.这里你可以使用鸽笼原理(如果你试图把超过 n 只鸽子放进 n 个鸽笼,那么至少有一个鸽笼可以容纳至少两只鸽子)。将正方形分成边长为 nn/2 的四块。如果你有四个以上的点,其中一个必须包含至少两个点。通过简单的几何,这些方块的对角线小于 d ,所以这是不可能的。
6-16.在开始之前,只需对数据进行一遍检查,去掉同位置的点。它们已经被排序了,所以寻找重复项只是一个线性时间的操作。现在运行算法时,沿中线的切片最多能装下六个点(看出为什么了吗?),所以你现在最多需要比较 y 序列中的五个点。
6-17.这类似于如何使用排序的下限来证明凸包问题的下限:您可以将实数的元素唯一性简化为最近对问题。只需将你的数字绘制成x-轴上的点(线性时间,渐近小于手头的界限)并找到最接近的一对。如果这两点是相同的,那么元素不是唯一的;否则,他们就是。因为唯一性不能在小于对数线性的时间内确定,所以最接近的配对问题不可能更有效。
6-18.关键的观察是,包含总和为零或负值的切片的初始部分是没有意义的(您总是可以丢弃它并获得相同或更高的总和)。同样,丢弃一个总和为正的初始部分也没有任何意义(包含它将给出更高的总和)。因此,我们可以从左侧开始求和,始终保持迄今为止的最佳和(以及相应的区间)。一旦总和为负,我们将 I(起始索引)移动到下一个位置,并从那里重新开始总和。(你要说服自己这真的管用;或许用归纳法证明?)
第七章
7-1.这里有很多种可能(比如从美制里掉几个硬币)。一个重要的例子是旧的英国体系(1,2,6,12,24,48,60)。
7-2.这只是观察一个基数为 k 的数字系统如何工作的一种方式。这一点在 k = 10 的情况下特别容易看出来。
7-3.当你考虑是否包含最大的剩余元素时,包含它总是值得的,因为如果你不这样做,剩余元素的总和无法弥补损失的价值。
7-4.假设杰克是第一个被他最合适的妻子吉尔拒绝的人,她为了亚当拒绝了他。据推测,亚当还没有被他最合适的妻子爱丽丝拒绝,这意味着他至少和她一样喜欢吉尔。考虑一个稳定的配对,杰克和吉尔在一起。(这肯定是存在的,因为吉尔是杰克可行的妻子。)在这种配对中,吉尔当然还是更喜欢亚当。然而,我们知道亚当更喜欢吉尔而不是爱丽丝——或者任何其他可行的妻子——所以这种匹配终究是不稳定的!换句话说,我们有一个矛盾,否定了我们的假设,即某个男人没有和他最合适的妻子配对。
7-5.假设杰克和爱丽丝结婚了,吉尔和亚当结婚了。因为吉尔是杰克最合适的妻子,他会更喜欢她而不是爱丽丝。因为配对稳定,吉尔肯定更喜欢亚当。这适用于吉尔有另一个丈夫的任何稳定的配对——这意味着她更喜欢任何其他可行的丈夫而不是杰克。
7-6.如果你背包的容量能被所有不同的增量整除,那么贪婪算法肯定会起作用。例如,如果一件物品易碎的增量为 2.3,另一件物品易碎的增量为 3.6,而你的背包容量可以被 8.28 整除,那么你就没问题,因为你有一个足够好的“分辨率”。(你认为我们还能允许什么变化吗?这个想法的其他含义?)
7-7.这相当直接地来自于树形结构。因为这些代码都给了我们独特的、确定性的指令,告诉我们如何从树根导航到树叶,所以当我们到达时,或者我们到达了哪里时,从来没有任何疑问。
7-8.我们知道 a 和 b 是出现频率最低的两项;这意味着 a 的频率低于(或等于)c 的频率,同样适用于 b 和 d 。如果 a 和 d 具有相等的频率,我们将把所有的不等式(包括 a ≤ b 和 c ≤ d )夹在中间,所有四个频率都相等。
7-9.以所有文件大小相等、不变的情况为例。那么平衡的合并树会给我们一个对数线性合并时间(典型的分治)。然而,如果我们使合并树完全不平衡,我们将得到二次运行时间(例如,就像插入排序一样)。现在考虑一组文件,其大小是 2 的幂,最大为 n /2。最后一个文件的大小是线性的,在一个平衡的合并树中,它将包含对数数量的合并,这意味着我们将得到(至少)对数线性时间。现在考虑霍夫曼算法会做什么:它总是合并两个最小的文件,它们的总和大约是下一个文件的大小(也就是说,比下一个文件小一个)。我们得到一个幂的和,最终得到一个线性合并时间。
7-10.作为解决方案的一部分,您需要至少具有相同权重的两条边。例如,如果在两个不同的边上使用了两次最低权重,则(至少)有两种解决方案。
7-11.因为所有生成树中的边的数量是相同的,我们可以通过简单地否定权重来做到这一点(也就是说,如果一条边的权重为 w ,我们会将其改为——w,并找到最小生成树。
7-12.我们需要在一般情况下展示这一点,在这种情况下,我们有一组我们知道将进入解决方案的边。子问题是剩下的图,我们想证明在剩下的图中找到一个最小生成树,并且这个最小生成树与我们所拥有的(没有循环)相兼容,将会给我们一个全局最优解。像往常一样,我们用矛盾的方式展示这一点,假设我们可以找到这个子问题的非最优解,这个解会给我们一个更好的全局解。这两个子解决方案都与我们现有的方案兼容,因此它们可以互换。显然,用最优解替换非最优解会提高全局和,这给了我们矛盾。
7-13.克鲁斯卡尔的算法总是找到一个最小生成森林,在连通图的情况下,它变成了一个最小生成树。Prim 的算法可以用一个循环来扩展,比如深度优先搜索,这样它就可以在所有组件中重新开始。
7-14.它仍然会运行,但不一定会找到最便宜的遍历(或 min-cost arborescence )。
7-15.因为你可以用这个来排序实数,它有一个对数线性的下界。(这与凸包的情况类似。)你只需使用这些数字作为 x 坐标,并使用相同的 y 坐标。最小生成树将是从第一个数字到最后一个数字的路径,给你排序。
7-16.我们需要证明的是,分支树具有(至多)对数高度。组件树的高度等于组件中的最高等级。仅当两个相同高度的组件树被合并时,该等级才增加,然后增加 1。在每个联合中增加某些等级的唯一方法是以平衡的方式合并组件,给出对数最终等级(和高度)。在不增加任何等级的情况下进行一些循环不会有帮助,因为我们只是在树中“隐藏”节点而不改变它们的等级,给了我们更少的工作。换句话说,没有办法获得比组件树的对数高度更高的高度。
7-17.都被堆的对数运算隐藏了。在最坏的情况下,如果每个节点只添加一次,这些操作在节点数量上会是对数的。现在,它们在边数上可能是对数的,但是由于边数在节点数上是多项式的(二次),所以这只是一个常数差:θ(LGm)=θ(LGn2)=θ(LGn)。
7-18.具有最早开始时间的间隔可能覆盖该组的整个剩余时间,这可能都是不重叠的。如果我们想用最早的开始时间,我们同样注定会失败,因为我们总是只能得到一个元素。
7-19.我们必须对它们进行分类,但是在这之后,扫描和排除可以在线性时间内完成(你知道怎么做吗?).换句话说,总的运行时间由排序决定,通常是对数线性的。
第八章
8-1.不用检查参数元组是否已经在缓存中,只需检索它并捕捉可能发生的KeyError
,如果它是而不是的话。使用一些不存在的值(比如None
)和get
可能会带来更好的性能。
8-2.看待这个问题的一种方式可能是计算子集。每个元素要么在子集中,要么不在子集中。
8-3.对于fib
,你只需要每一步之前的两个值,而对于two_pow
,你只需要保持你已经有的值翻倍。
8-5.只需使用第五章中的“前任指针”思想。如果您正在执行向前版本,请在每个节点中存储您所做的选择(也就是说,您遵循的是哪个出边)。如果你正在做相反的版本,存储你从哪里到每个节点。
8-6.因为拓扑排序还是要访问每条边。
8-7.您可以让每个节点观察它的前一个节点,然后在开始节点中显式地触发估计值的更新(给它一个零值)。观察员将被告知变化,并可以相应地更新他们自己的估计,从而触发他们的观察员的新的更新。这在许多方面与本章中基于松弛的解决方案非常相似。不过,这个解决方案可能有点“操之过急”。因为级联更新是即时触发的(而不是让每个节点一次完成它的输出或输入更新),该解决方案实际上可能具有指数级的运行时间。(你看怎么样?)
8-8.这可以通过多种方式表现出来——但其中一种方式是简单地看看这个列表是如何构建的。使用bisect
添加每个对象(或者追加或者覆盖一个旧元素),它会按照排序顺序找到合适的位置来放置它。通过归纳,end
会被排序。(你能想出其他方法来看这个列表必须排序吗?)
8-9.当新元素大于最后一个元素或者end
为空时,不需要bisect
。您可以添加一个if
语句来检查这一点。它可能会让代码更快,但它可能会让代码可读性稍差。
8-10.就像在 DAG 最短路径问题中一样,这将涉及到记住“你从哪里来”,也就是说,保持对前辈的跟踪。对于二次版本,您可以不使用前置指针,只需在每一步复制前置指针列表。它不会影响渐近运行时间(复制所有那些列表将是二次的,但那是你已经有的),并且对实际运行时间和内存占用的影响应该是可以忽略的。
8-11.这在许多方面与 LCS 电码非常相似。如果你需要更多的帮助,你可以在网上搜索 levenshtein 距离 python 。
8-12.就像其他算法一样,你可以跟踪做出了哪些选择,对应于你在“子问题 DAG”中遵循的边
8-13.你可以交换序列和它们的长度。
8-14.你可以用最大公约数除 c 和 w 中的所有元素。
8-16.运行时间是伪多项式,这意味着它仍然是指数形式。您可以轻松地提高背包容量,使运行时间变得不可接受,同时保持实际问题实例的规模较小。
8-19.您可以添加一组虚拟叶节点来表示失败的搜索。每个叶节点将代表树中实际存在的两个节点之间的所有不存在的元素。你必须在总数中分别处理这些。
第九章
9-1.您必须以某种方式修改算法或图形,以便可以使用负加法周期的检测机制来找到汇率乘积最终大于 1 的乘法周期。最简单的解决方案是简单地通过取它们的对数并求反来转换所有的权重。然后你可以使用标准版的贝尔曼-福特,一个 负循环会给你你所需要的。(你看怎么样?)当然,为了实际上将用于任何事情,您应该计算出如何输出这个循环中涉及的节点。
9-2.这不是问题,不会比 DAG 最短路径问题更严重。哪一个在排序中先结束并不重要,因为另一个(随后出现的)无论如何都不能用于创建快捷方式。
9-3.它突然给你一个伪多项式运行时间(相对于最初的问题实例)。你知道为什么吗?
9-4.这要看你怎么做了。多次添加节点不再是一个好主意,您可能应该进行一些设置,以便在运行relax
时可以直接访问和修改队列中的条目。然后,您可以在恒定时间内完成这一部分,而从队列中的提取现在将是线性的,您将得到二次运行时间。对于一个稠密的图,这实际上是很好的。
9-5.如果存在负周期,事情可能会出错——但在这种情况下,贝尔曼-福特算法会引发一个例外。除此之外,我们可以转向三角不等式。我们知道h(v)≤h(u)+w(u, v )对于所有节点 u 和 v 。这意味着 w '( u ,v)=w(u,v)+h(u)–h(v)≥0,根据需要。
9-6.我们可能会保留最短路径,但我们不一定能保证权重是非负的。
9-9.这需要很少的改变。你可以使用一个(二进制,布尔型)邻接矩阵来代替权重矩阵。当看到你是否能改善一条路径时,你不会使用加法和最小值;相反,你会看到那里是否有一条新的道路。换句话说,你可以使用A[u, v] = A[u, v] or A[u, k] and A[k, v]
。
9-10.更严格的停止标准告诉我们,一旦 l + r 大于我们目前找到的最短路径,就停止,我们已经证明这是正确的。当两个方向都产生(并因此访问)同一个节点时,我们知道已经探索了通过该节点的最短路径;因为它本身就是我们探索过的那些中的一个,所以它一定大于或等于我们探索过的那些中最小的一个。
9-11.无论你选择哪条边,我们都知道 d ( s , u ) + w ( u ,v)+d(v, t )小于目前为止找到的最短路径的长度,也就是小于或等于 l +这意味着 l 和 r 都将经过路径的中点,无论它在哪里。如果中点在一条边内,就选择这条边;如果它正好在一个节点上,选择路径上的任一相邻边就可以了。
9-14.考虑从 v 到 t 的最短路径。修改后的成本可以用两种方式表示。第一种为 d ( v ,t)–h(v)+h(t),与 d ( v ,t)–h(v)相同表达该修改成本的另一种方式是作为各个修改的边权重的总和;通过假设,这些都是非负的(即 h 可行)。所以我们得到 d ( v ,t)—h(v)≥0,或者 d ( v ,t)≥h(v)。
第十章
10-1.只需将每个节点 v 拆分成两个节点 v 和v’,并添加所需容量的边vv’。然后,所有的内边缘将留在 v 处,而来自 v 的所有外边缘将被移动到 v 。
10-2.你可以修改算法,或者修改数据。例如,您可以将每个节点一分为二,在它们之间有一条单位容量的边,并赋予所有剩余的边无限的容量。那么最大流将允许您识别顶点不相交的路径。
10-3.我们知道运行时间是 O ( m 2 ),那么我们要做的就是构造一个二次运行时间发生的情境。一种可能性是除了 s 和 t 之外还有 m /2 个节点,每个节点都有一个从 s 到 t 的边。在最坏的情况下,遍历将访问来自 s 的所有不饱和外边缘,这(通过握手和)给了我们二次运行时间。
10-4.只需将每个边沿 uv 换成 uv 和 vu ,两者容量相同。当然,在这种情况下,你可能会同时结束双向流动。这真的不是问题——要找出通过无向边的实际流量,只需从另一个中减去一个。结果的符号将指示流动的方向。(有些书为了简化剩余网络的使用,避免节点之间两个方向都有边。这可以通过用虚拟节点将两个有向边中的一个一分为二来实现。)
10-6.例如,你可以给水源一个容量(如练习 10-1 中所述),等于所需的流量值。如果可行,最大流量将具有该值。
10-8.您可以通过找到最小切割来解决这个问题,如下所示。如果只有在 B 也参加的情况下,客人 A 才会参加,则将具有无限容量的边(A,B)添加到您的网络中。如果可以避免的话,该边将永远不会穿过切口(向前)。你邀请的朋友会在 cut 的源端,而其他人会在 sink 端。您的兼容性可以建模如下:任何正兼容性被用作来自源的边的容量,而任何负兼容性被用作到源的边的容量,否定。然后,该算法将最小化穿过切割的这些边的总和,将您喜欢的边保留在源端,将您不喜欢的边保留在接收端(尽可能)。
10-9.因为每个人都有一个最喜欢的座位,所以左侧的每个节点都有一条到右侧的边。这意味着增补路径都由单个不饱和边组成-因此所描述的行为等同于增补路径算法,我们知道该算法将给出最佳答案(也就是说,它会将尽可能多的人配对到他们最喜欢的座位)。
10-10.将两轮的组表示为节点。给来自源的第一组 in-edge,容量为 k 。类似地,给第二组出边缘到接收器,同样具有容量 k 。然后将所有第一组的边添加到所有第二组,所有第二组的容量都为 1。每个流动单元就是一个人,如果你能够从源头(或者到汇点)饱和边缘,你就成功了。然后每组将有 k 人,第二组的每个人最多有一个来自第一组的人。
10-11.该解决方案将供应/需求思想与最小成本流程相结合。用一个节点代表每个星球。还要为每个乘客类型添加一个节点(也就是说,为乘客出发地和目的地的每个有效组合添加一个节点)。将每个行星链接到 i < n 到行星 i +1,容量等于飞船的实际载重量。乘客类型节点被给予与该类型乘客数量相等的供给(即,想要从 i 到 j 的乘客数量)。考虑节点 v ,代表想要从行星 i 前往行星 j 的乘客。这些要么能去,要么不能去。我们通过添加一条从 v 到 i 的边和另一条到 j 的边来表示这个事实。然后,我们向节点 j 添加一个需求,等于 v 处的供应。(换句话说,我们确保每个星球都有一个需求,考虑到所有想去那里的乘客。)最后,我们在( v , i )上添加一个成本,该成本等于从 i 到 j 的旅行费用,除了它是负数。这代表我们在 v 处为每位乘客赚取的金额。我们现在找到了一个关于这些供给和需求的可行的最小成本流。这种流动将确保每个乘客要么被路由到他们想要的起点(意味着他们将进行旅行),然后通过行星到行星的边缘到达他们的目的地,增加我们的收入,要么他们沿着零成本边缘被直接路由到他们的目的地(意味着他们不会进行旅行)。
第十一章
11-1.因为对分的运行时间是对数的。即使所讨论的值域的大小是问题大小的指数函数,实际运行时间也只会是线性的。(你看出为什么了吗?)
11-2.因为它们都在 NP 中,而 NP 中的所有问题都可以归结为任意 NP-完全问题(由 NP-完全性的定义)。
11-3.因为运行时间是 O ( nW ),其中 W 是容量。如果 W 在 n 中是多项式,那么运行时间也是多项式。
11-4.从带有任意一个 k 的版本中得到的简化非常简单:简单地添加*–k*作为一个元素。
11-5.应该清楚的是,我们可以将子集和问题的无界版本简化为无界背包(只需设置与所讨论的数字相等的权重和值)。挑战是从无界到有界的情况。这基本上是一个数字杂耍的问题,这个杂耍有几个要素。我们仍然希望保持权重,以便优化最大化它们。然而,除此之外,我们需要添加某种约束,以确保每个数字最多使用一个。我们分开来看这个约束的事情。对于 n 个数字,我们可以尝试使用 2 的幂来创建 n 个“槽位”,用 2 个I来表示数字 i 。然后,我们可以拥有 21+…+2+n的容量,并运行我们的最大化。然而,这还不够。最大化不会在意我们有一个 2 n 的实例还是两个 2n–1的实例。我们可以添加另一个约束:我们用 2I+2n+1来表示编号 i ,并将产能设置为 21+…+2n+n2n+1。为了最大化,填充从 1 到 n 的每个槽仍然是值得的,但是现在它只能包括 2nn+1的 n 个出现,因此 2 n 的单个实例将优于 2n–1的两个实例。但是我们还没有完成…这只是让我们强制最大化每个数字中的一个,这并不是我们真正想要的。相反,我们希望每个项目有两个版本,一个表示包含该数字,另一个表示不包含该数字。如果包含了编号 i 我们就加 w i ,如果排除了就加 0。我们还会有一个原始容量, k 。这些约束从属于“每个插槽一个项目”的东西,所以我们真的希望在我们的表示中有两个“数字”。我们可以通过将时隙约束数乘以一个巨大的常数来实现。如果我们的数中最大的是 B ,我们可以用 nB 乘以约束,应该是万无一失的。于是,产生的方案是用下面两个新数字来表示原始问题中的数字 w i ,分别表示包含和排除:(2n+1+2InB+wI容量变成(n2n+1+2n+…+21)nB+k。
11-6.很容易将三色简化为任意k-着色为k3;你只是把两种或两种以上的颜色混在一起。
11-7.在这里,你可以减少任何数量的东西。一个简单的例子是使用子图同构来检测集团。
11-8.您可以通过在两个方向上添加有向边(反平行边)来简单地模拟无向边。
11-9.您仍然可以使用红绿蓝方案来模拟方向,然后使用之前从有向汉密尔顿循环到有向汉密尔顿路径的简化(您应该验证这如何以及为什么仍然有效)。不过,还有一个选择。考虑如何将无向哈密尔顿圈问题化为无向哈密尔顿路问题。选择某个节点 u ,添加三个新节点, u '、 v 和 v ',以及(无向)边( v 、 v ')和( u 、 u ')。现在在 v 和 u 的每个邻居之间添加一条边。如果原来的图有一个哈密尔顿圈,这个新的图显然会有一个哈密尔顿路径(只需断开 u 与它在圈里的一个邻居的连接,在两端加上 u 和v’)。更重要的是,这种暗示是双向的:新图中的哈密尔顿路径必须有 u 和 v 作为它的端点。如果我们去掉 u '、 v 和 v ',我们就剩下一条从 u 到相邻的 u 的哈密尔顿路径,我们可以把它们连接起来,得到一个哈密尔顿循环。
11-10.这(不足为奇)与另一个方向的减少正好相反。您可以添加一个新节点,而不是拆分现有节点。将此节点连接到其他节点。当且仅当原始图中存在哈密尔顿路径时,新图中才会有哈密尔顿圈。
11-11.我们可以顺藤摸瓜。Dag 中的最长路径可用于查找哈密尔顿路径,但仅在 Dag 中。这将再次让我们找到有向图中的有向哈密尔顿圈,当我们在单个节点上分割它们时,这些有向哈密尔顿圈变成了有向无环图(或者,通过摆弄归约,非常接近于此的东西)。然而,我们用来将 3-SAT 简化为有向哈密尔顿圈的有向图与此完全不同。诚然,我们可以在 s 和 t 节点中看到这种结构的暗示,以及从 s 到 t 的边的大致向下方向,但是每一行都是满的反平行边,并且能够沿任一方向前进对证明是至关重要的。因此,如果我们进一步假设非循环性,事情就会在这里分解。
11-12.这里的推理与练习 11-11 中的很相似。
11-13.正如正文中所讨论的,如果物体大于背包的一半,我们就完成了。如果它稍微少一点(但不到背包的四分之一),我们可以包括两个,再次填满了一半以上。唯一剩下的情况是它是否更小。无论哪种情况,我们都可以继续前进,直到越过中线——因为这些物体非常小,它不会延伸到中线的另一端而给我们带来麻烦。
11-14.这个其实很简单。首先,随机排列节点。这会给你两个 Dag,由从左到右的边和从右到左的边组成。其中最大的一个必须包含至少一半的边,给你一个 2-近似值。
11-15.假设所有的节点都是奇数度的(这将给予匹配尽可能大的权重)。这意味着循环将只由这些节点组成,并且循环的每第二条边将是匹配的一部分。因为我们选择了最小值匹配,我们当然选择了两个可能的交替序列中最小的一个,确保权重最多是整个周期的一半。因为我们知道三角形不等式成立,放松我们的假设和删除一些节点不会使循环或匹配更昂贵。
11-16.在这里尽情发挥创造力吧。也许,你可以试着单独添加每个对象,或者你可以添加一些随机的对象?或者你可以一开始就运行贪婪边界——尽管这已经在第一次扩张中发生了…
11-17.直觉上,你从这些物品中获得了最大可能的价值。不过,看看你是否能拿出更有说服力的证据。
11-18.这需要一些概率论的知识,但也没那么难。让我们看一个单个子句,其中每个文字(要么是变量,要么是它的否定)要么是真,要么是假,两种结果的概率都是 1/2。这意味着整个子句为真的概率是 1-(1/2)3= 7/8。如果我们只有一个子句,这也是预期为真的子句数。如果我们有 m 子句,我们可以预期有 7 m /8 个真子句。我们知道 m 是最优上界,那么我们的近似比就变成了 m /(7 m /8) = 8/7。很漂亮,你不觉得吗?
11-19.这个问题现在足够表达解决(例如)最大独立集问题,这是 NP 难的。所以,你的问题也是 NP 难的。一种简化如下:将每个客人的兼容性设置为 1,并为原始图中的每条边添加冲突。如果你现在可以在不邀请彼此不喜欢的客人的情况下最大化相容和,你就找到了最大独立集。
11-20.NP-硬度可以很容易地建立,甚至对于 m = 2,通过从分割问题中减少。如果我们可以分配作业,使机器同时完成,这显然将最小化完成时间——如果我们可以最小化完成时间,我们也将知道它们是否可以同时完成(即,值是否可以被划分)。近似算法也很简单。我们依次考虑每个作业(以某种任意的顺序),并将它分配给当前具有最早完成时间(即最低工作负载)的机器。换句话说,这是一种直截了当的贪婪方法。证明它是一个 2-近似有点困难。设 t 为最佳完成时间。首先,我们知道没有作业持续时间大于 t 。其次,我们知道平均完成时间不能超过 t ,因为完全平均的分布是我们能得到的最好结果。让 M 成为我们贪婪方案中最后完成的机器,让 j 成为该机器上的最后一个作业。由于我们的贪婪策略,我们知道在 j 的开始时间,所有其他机器都在忙,所以这个开始时间在平均完成时间之前,因此在 t 之前。 j 的持续时间必须也小于 t ,所以将这个持续时间加到它的开始时间,我们得到一个小于 2 t 的值……这个值就是我们的完成时间。
11-21.如果你愿意,你可以重用清单 11-2 的基本结构。一种简单的方法是依次考虑每个作业,并尝试将它分配给每台机器。也就是说,你的搜索树的分支因子将是 m 。(请注意,机器内作业的顺序并不重要。)在下一级搜索中,您可以尝试放置第二个职务。该状态可以由一列 m 台机器的结束时间来表示。当您尝试将一个作业添加到一台机器上时,您只需将它的持续时间添加到完成时间上;当你回溯时,你可以再次减去持续时间。现在你需要一个边界。给定一个部分解决方案(一些计划作业),您需要给出最终解决方案的乐观值。例如,在部分解决方案中,我们永远不能在最晚完成时间之前完成,所以这是一个可能的界限。(也许你能想到更好的界限?)在开始之前,您必须将您的解值初始化为最优解的上限(因为我们正在最小化)。越紧越好(因为它增加了你的修剪能力)。这里你可以使用练习 11-20 中的近似算法。*