经典计算机科学问题-Java-描述-全-
经典计算机科学问题 Java 描述(全)
原文:Classic Computer Science Problems in Java
译者:飞龙
前置内容
致谢
我想感谢所有在 Manning 出版社帮助这本书开发的所有人。我特别想感谢开发编辑 Jenny Stout,她的善良以及在三部书中最困难时期给予我的支持;技术发展编辑 Frances Buontempo,她对细节的关注;采购编辑 Brian Sawyer,他相信经典计算机科学问题系列,并且始终是理性的声音;校对编辑 Andy Carroll,他比我更擅长捕捉这些年来我自己的错误;Radmila Ercegovac,她帮助我在全球范围内推广这个系列;以及技术审稿人 Jean-François Morin,他找到了使代码更加整洁和现代的方法。此外,我还要感谢 Deirdre Hiam,我的项目编辑;Katie Tennant,我的校对员;以及 Aleks Dragosavljevic´,我的审稿编辑。在 Manning 出版社还有至少一打人在管理、图形、排版、财务、营销、审稿和生产等各个阶段参与了这本书的开发,我可能没有那么熟悉他们,但我感谢他们的贡献。
感谢 Brian Goetz,您慷慨地分享了您的时间,并提供了读者一定会感到愉快和有所收获的访谈。能够采访您是一种荣幸。
感谢我的妻子 Rebecca 和我的母亲 Sylvia,在这样一个不愉快的年份里,你们始终如一的支持。
感谢所有审稿人:Andres Sacco、Ezra Simeloff、Jan van Nimwegen、Kelum Prabath Senanayake、Kimberly Winston-Jackson、Raffaella Ventaglio、Raushan Jha、Samantha Berk、Simon Tschöke、Víctor Durán 和 William Wheeler。你们的建议帮助使这本书变得更好。我非常感激你们在审稿过程中所投入的细心和时间。
最重要的是,感谢支持经典计算机科学问题系列的所有读者。如果你喜欢这本书,请留下评论。这真的很有帮助。
关于这本书
liveBook 讨论论坛
购买《Java 经典计算机科学问题》包括免费访问由 Manning 出版社运行的私人网络论坛,您可以在论坛上对本书发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问livebook.manning.com/#!/book/classic-computer-science-problems-in-java/discussion。您还可以在livebook.manning.com/#!/discussion了解更多关于 Manning 论坛和行为准则的信息。
曼宁对我们读者的承诺是提供一个场所,在这里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向他提出一些挑战性的问题,以免他的兴趣转移!只要这本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。
关于作者
![]() |
大卫·科佩克是佛蒙特州伯灵顿查姆 plain 学院计算机科学与创新助理教授。他是《Python 经典计算机科学问题》(Manning,2019 年)、《Swift 经典计算机科学问题》(Manning,2018 年)和《Dart 绝对入门》(Apress,2014 年)的作者。他也是一名软件开发者和播客主持人。 |
|---|
关于封面插图
《Java 经典计算机科学问题》封面上的插图标题为“Côte de Barbarie 的女士在 1700 年的全套礼服”,或“1700 年巴巴里地区品质女士的全套礼服。”这幅插图取自托马斯·杰弗里斯的《不同国家古今服饰集》(四卷),伦敦出版,时间介于 1757 年至 1772 年之间。扉页声明这些是手工上色的铜版雕刻,用阿拉伯树胶增强。托马斯·杰弗里斯(1719-1771)被称为“乔治三世国王的地理学家。”他是当时的领先地图供应商,为政府和其它官方机构雕刻和印刷地图,并制作了广泛的商业地图和地图集,尤其是北美地区的。他作为地图制作者的工作激发了他对其所调查和绘制的土地的地方服饰习俗的兴趣,这些习俗在这本收藏中得到了精彩的展示。
对遥远土地的迷恋以及为了娱乐而旅行在 18 世纪末是相对较新的现象,像这样的收藏品在当时很受欢迎,它们向游客和沙发旅行者介绍了其他国家的居民。杰弗里斯卷中的绘画多样性生动地描绘了大约 200 年前世界各国独特性和个性的多样性。自那时以来,着装规范已经改变,当时丰富多样的地区和国家多样性已经逐渐消失。现在,往往很难区分一个大陆的居民与另一个大陆的居民。也许,从乐观的角度来看,我们用文化和视觉多样性换取了更加丰富多彩的个人生活——或者更加丰富多彩和有趣的智力和技术生活。
在难以区分一本计算机书与另一本计算机书的时期,Manning 通过基于两百年前丰富多样的地区生活的书封面,庆祝计算机行业的创新和主动性,这些生活被杰弗里斯的图画重新带回生命。
引言
感谢您购买《Java 经典计算机科学问题》。Java 已经是最受欢迎的编程语言之一,大约有二十年的时间。它可以说是企业空间、高等教育和 Android 应用开发中的主导语言。在这本书中,我希望将您带入一个不仅仅把 Java 当作达到目的的手段的地方。相反,我希望您能将 Java 视为一种用于计算问题解决的工具。这本书中的问题将帮助经验丰富的程序员在了解语言的一些高级特性的同时,重温他们计算机科学教育中的想法。在学校使用 Java 的学生和自学程序员通过学习通用的解决问题的技术,可以加速他们的计算机科学教育。这本书涵盖了如此多样化的问题,真正是适合每个人。
这本书不是 Java 的入门书。在 Manning 和其他出版社中,有大量关于这个领域的优秀书籍。相反,这本书假设你已经是一个中级或高级的 Java 程序员。尽管这本书使用了 Java 11 版本的一些特性,但并不假设读者掌握了 Java 最新版本的每一个方面。实际上,这本书的内容是在假设它将作为学习材料帮助读者达到这样的掌握水平。另一方面,这本书并不适合完全对 Java 陌生的读者。
有人说,计算机对于计算机科学来说就像望远镜对于天文学一样。如果是这样,那么编程语言可能就像望远镜的镜头。无论如何,这里使用的“经典计算机科学问题”一词是指“在本科计算机科学课程中通常教授的编程问题”。
有一些编程问题被分配给新程序员解决,并且已经变得足够普遍,以至于被认为是经典的,无论是在追求学士学位(如计算机科学、软件工程等)的课程设置中,还是在中级编程教科书中(例如,关于人工智能或算法的第一本书)。这本书中你会找到这样一些问题的选择。
这些问题从简单的,只需几行代码就能解决,到复杂的,需要跨多个章节构建系统。一些问题涉及到人工智能,而另一些则只需要常识。一些问题是实用的,而另一些则是富有想象力的。
适合阅读这本书的人
Java 被用于各种追求,如移动应用开发、企业级 Web 开发、计算机科学教育、金融软件等等。Java 有时因其冗长和缺乏一些现代特性而受到批评,但自其诞生以来,可能比任何其他编程语言都触及了更多人的生活。它的流行肯定有原因。Java 最初是由其创造者詹姆斯·高斯林想象为更好的 C++:一种提供面向对象编程的强大功能,同时引入安全特性并简化 C++中一些令人沮丧的边缘的语言。在我看来,在这方面 Java 做得非常成功。
Java 是一种伟大的通用面向对象语言。然而,许多人陷入了困境,无论是 Android 开发者还是企业级 Web 开发者,他们的大部分时间都感觉像“API 混合”。他们不是在解决有趣的问题,而是在学习 SDK 或库的每一个角落。这本书旨在为这些程序员提供休息。还有程序员从未接受过教授他们所有强大问题解决技术的计算机科学教育。如果你是那些知道 Java 但不知道 CS 的程序员之一,这本书就是为你准备的。
其他程序员在长时间从事软件开发工作后,将 Java 作为第二、第三、第四或第五种语言学习。对他们来说,看到他们在另一种语言中已经见过的旧问题将有助于他们加速 Java 的学习。对他们来说,这本书可能在面试前是一个很好的复习资料,或者它可能会让他们接触到一些他们以前没有想过在工作中利用的问题解决技术。我鼓励他们浏览目录,看看这本书中是否有让他们兴奋的主题。
这本书适合中级和高级程序员。想要深化他们 Java 知识的高级程序员会发现,这本书中的问题从他们的计算机科学或编程教育中非常熟悉。中级程序员将用他们选择的 Java 语言介绍这些经典问题。准备编码面试的开发者可能会发现这本书是宝贵的准备材料。
除了专业程序员外,对 Java 感兴趣的计算机科学本科课程的学生可能会发现这本书很有帮助。这本书并不试图成为数据结构和算法的严谨介绍。这不是一本数据结构和算法教科书。你不会在书中找到证明或大 O 符号的广泛使用。相反,它定位为一个易于接近、动手的教程,介绍应该成为数据结构、算法和人工智能课程最终成果的问题解决技术。
再次强调,假设读者对 Java 的语法和语义有所了解。一个编程经验为零的读者将从这个书中获益甚少,而一个没有 Java 经验的程序员几乎肯定会遇到困难。换句话说,《Java 中的经典计算机科学问题》是一本为工作 Java 程序员和计算机科学学生所著的书。
本书是如何组织的:一个路线图
第一章介绍了可能对大多数读者来说都很熟悉的问题解决技巧。诸如递归、记忆化和位操作等是后面章节中探索的其他技巧的基本构建块。
在这个温和的介绍之后,第二章专注于搜索问题。搜索是一个如此大的主题,以至于你可以有理由将书中大多数问题都归入其范畴。第二章介绍了最基本搜索算法,包括二分搜索、深度优先搜索、广度优先搜索和 A*。搜索算法在本书的其余部分都有应用。
在第三章中,你将构建一个框架来解决一系列可以通过有限域的变量及其之间的约束来抽象定义的问题。这包括诸如八皇后问题、澳大利亚地图着色问题和密码算术 SEND+MORE=MONEY 等经典问题。
第四章探讨了图算法的世界,这对初学者来说出人意料地广泛。在这一章中,你将构建一个图数据结构,然后使用它来解决几个经典的优化问题。
第五章探讨了遗传算法,这是一种比书中大多数其他技术更不确定的技术,但有时可以在合理的时间内解决传统算法无法解决的问题。
第六章涵盖了 k-means 聚类,可能是书中算法性最具体的章节。这种聚类技术易于实现,易于理解,并且应用广泛。
第七章旨在解释什么是神经网络,并让读者尝尝一个非常简单的神经网络的滋味。它并不旨在全面覆盖这个令人兴奋且不断发展的领域。在这一章中,你将从头开始构建一个神经网络,不使用任何外部库,这样你才能真正看到神经网络是如何工作的。
第八章是关于两人完美信息游戏中的对抗性搜索。你将学习一种称为最小-最大搜索算法,它可以用来开发能够很好地玩国际象棋、跳棋和四子棋等游戏的虚拟对手。
第九章涵盖了有趣(且有趣)的问题,这些问题在其他地方不太适合。
最后,第十章是 Oracle 的 Java 语言架构师布莱恩·戈茨的访谈,他指导了语言的发展。布莱恩为读者提供了一些关于编程和计算机科学的宝贵建议。
关于代码
本书中的源代码是为了遵循 Java 语言的第 11 版而编写的。它使用了仅在 Java 11 中才可用的 Java 特性,因此一些代码在 Java 的早期版本上可能无法运行。请不要在早期版本上挣扎并尝试使示例运行,请在开始本书之前下载最新的 Java 版本。我选择版本 11 是因为它是在写作时发布的最新 LTS(长期支持)版本的 Java。所有代码都应在更近的(以及未来的)Java 版本上运行。事实上,相当一部分代码可以在回溯到 Java 8 的版本上运行。我知道许多程序员由于各种原因(比如 Android)仍然停留在 Java 8,但我希望使用一个更近版本的 Java,通过教授语言的一些新特性来提供额外的价值。
本书仅使用 Java 标准库,因此本书中的所有代码都应在任何支持 Java 的平台上运行(macOS、Windows、GNU/Linux 等)。本书中的代码仅针对 OpenJDK(从 openjdk.java.net 可获取的主要 Java 实现)进行了测试,尽管不太可能任何代码在 Java 的其他实现中会有问题。
这本书没有解释如何使用 Java 工具,如编辑器、IDE 和调试器。本书的源代码可在 GitHub 仓库中在线获取:github.com/davecom/ClassicComputerScienceProblemsInJava。源代码按章节组织到文件夹中。当你阅读每一章时,你会在每个代码列表的标题中看到源文件的名称。你可以在仓库中相应文件夹中找到那个源文件。
注意,该仓库按 Eclipse 工作空间组织。Eclipse 是一个流行的免费 Java IDE,适用于所有三个主要操作系统,并可在 eclipse.org 获取。使用源代码仓库的最简单方法是下载后将其作为 Eclipse 工作空间打开。然后你可以展开 src 目录,展开代表章节的包,右键单击(或在 Mac 上控制单击)包含 main() 方法的文件,并在弹出菜单中选择 Run As > Java Application 来运行示例问题的解决方案。我不会提供 Eclipse 的教程,因为我认为对于大多数中级程序员来说,这会显得有些多余,他们应该会发现开始使用它非常简单。此外,我预计许多程序员会选择使用本书与替代的 Java 环境。
由于这完全是标准的 Java,你还可以在你的 IDE(无论是 NetBeans、IntelliJ 还是你感到舒适的任何其他环境)中运行本书中的任何源代码。如果你选择这样做,请注意,我无法提供将项目导入你选择的环境的支持,尽管这应该相当简单。大多数 IDE 都可以从 Eclipse 导入。
简而言之,如果你是从零开始,那么将计算机设置为包含本书源代码的最简单方法如下:
-
从openjdk.java.net下载并安装 Java 11 或更高版本。
-
从eclipse.org下载并安装 Eclipse。
-
从
github.com/davecom/ClassicComputerScienceProblemsInJava的仓库下载本书的源代码。 -
在 Eclipse 中将整个仓库作为工作空间打开。
-
右键单击你想要运行的源代码文件,然后选择运行方式 > Java 应用程序。
本书没有产生图形输出的示例,也没有使用图形用户界面(GUI)。为什么?目标是使用尽可能简洁和易读的解决方案来解决提出的问题。通常,做图形会妨碍或使解决方案比实际需要来展示技术或算法更复杂。
此外,由于没有使用任何 GUI 框架,本书中的所有代码都极具可移植性。它可以在 Linux 命令行界面下运行的 Java 嵌入式分布上运行,也可以在运行 Windows 的桌面电脑上运行。此外,还做出了一个有意识的决策,即只使用 Java 标准库中的包,而不是任何外部库,就像许多高级 Java 书籍所做的那样。为什么?目标是教授从第一原理出发的问题解决技术,而不是“安装一个解决方案”。通过必须从头开始解决每个问题,你可能会对流行的库在幕后是如何工作的有一个理解。至少,只使用标准库使得本书中的代码更具可移植性,更容易运行。
这并不是说图形解决方案在某些情况下不如基于文本的解决方案更具说明性。这仅仅是因为这不是本书的重点。它只会增加不必要的复杂性层次。
其他在线资源
这是 Manning 出版的《经典计算机科学问题》系列中的第三本书。该系列的第一本书是《Swift 经典计算机科学问题》,于 2018 年出版,随后是《Python 经典计算机科学问题》,于 2019 年出版。在系列中的每一本书中,我们都旨在提供特定语言的见解,同时通过相同的(主要是)计算机科学问题进行教学。
如果你喜欢这本书,并计划学习系列中涵盖的其他语言,你会发现从一本书过渡到另一本书是一种提高对该语言掌握的简单方法。目前,该系列涵盖了 Swift、Python 和 Java。我亲自写了前三本书,因为我在这三种语言中都有丰富的经验,但我们已经在讨论由其他语言专家共同撰写该系列未来书籍的计划。我鼓励你在喜欢这本书的情况下关注它们。有关该系列的更多信息,请访问classicproblems.com/。
1 小问题
为了开始,我们将探索一些可以用几个相对简短的功能解决的问题。尽管这些问题很小,但它们仍然允许我们探索一些有趣的解决问题的技术。把它们看作是一个良好的热身。
1.1 斐波那契数列
斐波那契数列是一个数列,其中除了第一个和第二个数之外,任何数都是前两个数的和:
0, 1, 1, 2, 3, 5, 8, 13, 21...
序列中第一个斐波那契数的值是 0。第四个斐波那契数的值是 2。因此,要得到序列中任何斐波那契数 n 的值,可以使用以下公式
fib(n) = fib(n - 1) + fib(n - 2)
1.1.1 第一次递归尝试
计算斐波那契数列中一个数的公式(如图 1.1 所示)是一种伪代码,可以轻易地转换成递归的 Java 方法。(递归方法是一种调用自身的方法。)这种机械的转换将作为我们编写一个返回斐波那契数列给定值的第一个方法的尝试。

图 1.1 每个小人的身高是前两个小人的身高之和。
列表 1.1 Fib1.java
package chapter1;
public class Fib1 {
// This method will cause a java.lang.StackOverflowError
private static int fib1(int n) {
return *fib1*(n - 1) + *fib1*(n - 2);
}
让我们尝试通过传递一个值来调用这个方法来运行它。
列表 1.2 Fib1.java 继续显示
public static void main(String[] args) {
// Don't run this!
System.out.println(*fib1*(5));
}
}
哎呀!如果我们尝试运行 Fib1.java,我们会生成一个异常:
Exception in thread "main" java.lang.StackOverflowError
问题在于 fib1() 将无限期地运行而不会返回最终结果。每次调用 fib1() 都会导致另外两个没有终点的 fib1() 调用。我们称这种情形为无限递归(见图 1.2),它类似于无限循环。

图 1.2 递归函数 fib(n) 使用参数 n-1 和 n-2 调用自身。
1.1.2 利用基本案例
注意,直到你运行 fib1(),你的 Java 环境都没有任何错误的指示。避免无限递归是程序员的职责,而不是编译器的职责。无限递归的原因是我们从未指定一个基本案例。在递归函数中,基本案例充当一个停止点。
在斐波那契数列的情况下,我们有自然的基本案例,形式为特殊的前两个序列值,0 和 1。0 和 1 都不是序列中前两个数的和。相反,它们是特殊的前两个值。让我们尝试将它们指定为基本案例。
列表 1.3 Fib2.java
package chapter1;
public class Fib2 {
private static int fib2(int n) {
if (n < 2) { return n; }
return *fib2*(n - 1) + *fib2*(n - 2);
}
注意:斐波那契方法的 fib2() 版本将 0 作为零阶数(fib2(0))返回,而不是我们原始命题中的第一个数。在编程环境中,这种做法是有意义的,因为我们习惯于以零阶元素开始的序列。
fib2() 可以成功调用并返回正确的结果。尝试用一些小的值调用它。
列表 1.4 Fib2.java 继续显示
public static void main(String[] args) {
System.out.println(*fib2*(5));
System.out.println(*fib2*(10));
}
}
不要尝试调用 fib2(40)。它可能需要非常长的时间才能完成执行!为什么?每次对 fib2() 的调用都会通过递归调用 fib2(n - 1) 和 fib2(n - 2) 导致对 fib2() 的两次额外调用(见图 1.3)。换句话说,调用树呈指数增长。例如,对 fib2(4) 的调用会导致以下整个调用集:
fib2(4) -> fib2(3), fib2(2)
fib2(3) -> fib2(2), fib2(1)
fib2(2) -> fib2(1), fib2(0)
fib2(2) -> fib2(1), fib2(0)
fib2(1) -> 1
fib2(1) -> 1
fib2(1) -> 1
fib2(0) -> 0
fib2(0) -> 0

图 1.3 每个非基础情况的 fib2() 调用都会导致对 fib2() 的两次额外调用。
如果你数一数(并且正如你将看到的,如果你添加一些打印调用),仅为了计算第 4 个元素就需要对 fib2() 进行 9 次调用!情况变得更糟。计算第 5 个元素需要 15 次调用,计算第 10 个元素需要 177 次调用,计算第 20 个元素需要 21,891 次调用。我们可以做得更好。
1.1.3 缓存技术救命
缓存技术是一种在计算任务完成后存储其结果的技术,这样当再次需要这些结果时,你可以查找它们而不是需要再次计算(或第百万次计算)(见图 1.4)。1

图 1.4 人类缓存机制
让我们创建一个新的斐波那契方法版本,该方法利用 Java 映射进行缓存。
列表 1.5 Fib3.java
package chapter1;
import java.util.HashMap;
import java.util.Map;
public class Fib3 {
// Map.of() was introduced in Java 9 but returns
// an immutable Map
// This creates a map with 0->0 and 1->1
// which represent our base cases
static Map<Integer, Integer> *memo* = new HashMap<>(Map.*of*(0, 0, 1, 1));
private static int fib3(int n) {
if (!*memo*.containsKey(n)) {
// memoization step
*memo*.put(n, *fib3*(n - 1) + *fib3*(n - 2));
}
return *memo*.get(n);
}
你现在可以安全地调用 fib3(40)。
列表 1.6 Fib3.java 续
public static void main(String[] args) {
System.out.println(*fib3*(5));
System.out.println(*fib3*(40));
}
}
对 fib3(20) 的调用将仅产生 39 次对 fib3() 的调用,而 fib2(20) 的调用将产生 21,891 次对 fib2() 的调用。memo 已经预先填充了早期的基础情况 0 和 1,从而避免了 fib3() 的另一个 if 语句的复杂性。
1.1.4 简单的斐波那契
有一个性能更高的选项。我们可以用传统的迭代方法解决斐波那契问题。
列表 1.7 Fib4.java
package chapter1;
public class Fib4 {
private static int fib4(int n) {
int last = 0, next = 1; // fib(0), fib(1)
for (int i = 0; i < n; i++) {
int oldLast = last;
last = next;
next = oldLast + next;
}
return last;
}
public static void main(String[] args) {
System.out.println(*fib4*(20));
System.out.println(*fib4*(40));
}
}
核心思想是,last 被设置为 next 的前一个值,而 next 被设置为 last 的前一个值加上 next 的前一个值。一个临时变量 oldLast 促进了这种交换。
采用这种方法,for 循环的主体将运行 n - 1 次。换句话说,这是迄今为止最有效的一个版本。比较 for 循环主体的 19 次运行与计算第 20 个斐波那契数的 fib2() 的 21,891 次递归调用。这在实际应用中可能造成重大差异!
在递归解决方案中,我们向回工作。在这个迭代解决方案中,我们向前工作。有时递归是解决问题的最直观方式。例如,fib1() 和 fib2() 的核心几乎是对原始斐波那契公式的机械翻译。然而,简单的递归解决方案也可能带来显著的性能成本。记住,任何可以用递归解决的问题也可以用迭代解决。
1.1.5 使用流生成斐波那契数
到目前为止,我们已经编写了输出斐波那契数列中单个值的函数。如果我们想输出到某个值的整个序列呢?将 fib4()转换为 Java 流使用生成器模式很容易。当生成器被迭代时,每次迭代都会使用一个返回下一个数字的 lambda 函数从斐波那契数列中输出一个值。
列表 1.8 Fib5.java
package chapter1;
import java.util.stream.IntStream;
public class Fib5 {
private int last = 0, next = 1; // fib(0), fib(1)
public IntStream stream() {
return IntStream.*generate*(() -> {
int oldLast = last;
last = next;
next = oldLast + next;
return oldLast;
});
}
public static void main(String[] args) {
Fib5 fib5 = new Fib5();
fib5.stream().limit(41).forEachOrdered(System.out::println);
}
}
如果你运行 Fib5.java,你将看到斐波那契数列中打印出 41 个数字。对于序列中的每个数字,Fib5 都会运行一次 generate() lambda,这会操作维护状态的最后一个和下一个实例变量。limit()调用确保在达到第 41 项时,可能无限长的流停止输出数字。
1.2 简单压缩
节省空间(虚拟或实际)通常很重要。使用更少的空间更有效率,并且可以节省金钱。如果你租的公寓比你存放物品和家庭所需的大,你可以“缩小规模”到一个更小的、更便宜的地方。如果你按字节支付在服务器上存储数据,你可能想压缩它,这样它的存储成本就会更低。压缩是将数据编码(改变其形式)以使其占用更少空间的行为。解压缩是逆过程,将数据恢复到其原始形式。
如果压缩数据更节省存储空间,那么为什么所有数据都没有被压缩呢?时间和空间之间存在权衡。压缩一块数据并将其解压缩回原始形式需要时间。因此,只有在小尺寸比快速执行更受重视的情况下,数据压缩才有意义。想想在互联网上传输的大文件。压缩它们是有意义的,因为传输文件需要的时间比解压缩文件接收后需要的时间更长。此外,为在原始服务器上存储文件而压缩文件所需的时间只需要计算一次。
当你意识到数据存储类型使用的位数比其内容严格所需的位数更多时,最简单的数据压缩效果就会出现。例如,从底层思考,如果一个永远不会超过 32,767 的整数被存储为内存中的 64 位长整型,那么它的存储是不高效的。它可以改为存储为 16 位短整型。这将实际数字的空间消耗减少 75%(16 位而不是 64 位)。如果数百万这样的数字被不高效地存储,它可能会累积到兆字节的浪费空间。
在 Java 编程中,有时为了简单起见(这当然是一个合法的目标),开发者被屏蔽了在位级别思考的需要。野外的大多数 Java 代码使用 32 位的 int 类型来存储整数。对于绝大多数应用程序来说,这实际上并没有什么问题。然而,如果你正在存储数百万个整数,或者你需要具有特定精度的整数,那么考虑它们适当的类型可能是有价值的。
注意:如果你对二进制有些生疏,请回忆一下,位是一个单一的值,要么是 1 要么是 0。一串 1 和 0 以二进制为基础读取来表示一个数字。在本节的目的上,你不需要进行二进制数学运算,但你确实需要理解一个类型存储的位数决定了它可以表示的不同值的数量。例如,1 位可以表示两个值(0 或 1),2 位可以表示四个值(00、01、10、11),3 位可以表示八个值,依此类推。
如果一个类型可以表示的不同值的数量小于存储它的位可以表示的值的数量,那么它可能可以更有效地存储。考虑构成 DNA 中基因的核苷酸。每个核苷酸只能有四种值之一:A、C、G 或 T。然而,如果基因以 Java String 的形式存储,这可以被视为 Unicode 字符的集合,每个核苷酸将由一个字符表示,这通常在 Java 中需要 16 位的存储空间(Java 默认使用 UTF-16 编码)。在二进制中,只需要 2 位来存储具有四种可能值的类型:00、01、10 和 11 是 2 位可以表示的四种不同值。如果 A 被分配为 00,C 被分配为 01,G 被分配为 10,T 被分配为 11,那么核苷酸字符串所需的存储空间可以减少 87.5%(从 16 位减少到每核苷酸 2 位)。

图 1.5 将表示基因的字符串压缩成每核苷酸 2 位的位字符串
我们可以将我们的核苷酸存储为位字符串,而不是存储为字符串(见图 1.5)。位字符串正是其名称的含义:任意长度的 1 和 0 的序列。幸运的是,Java 标准库包含了一个现成的结构,用于处理任意长度的位字符串,称为 BitSet。以下代码将由 A、C、G 和 T 组成的字符串转换为位字符串,然后再转换回来。位字符串通过 compress()方法存储在 BitSet 中。我们还将实现一个 decompress()方法,以将其转换回字符串。
列表 1.9 CompressedGene.java
package chapter1;
import java.util.BitSet;
public class CompressedGene {
private BitSet bitSet;
private int length;
public CompressedGene(String gene) {
compress(gene);
}
压缩基因(CompressedGene)提供了一个表示基因中核苷酸的字符序列的字符串,并且它内部以 BitSet 的形式存储核苷酸序列。构造函数的主要责任是用适当的数据初始化 BitSet 构造。构造函数调用 compress()来完成将提供的核苷酸字符串实际转换为 BitSet 的脏活。
接下来,让我们看看我们如何实际执行压缩。
列表 1.10 CompressedGene.java 继续
private void compress(String gene) {
length = gene.length();
// reserve enough capacity for all of the bits
bitSet = new BitSet(length * 2);
// convert to upper case for consistency
final String upperGene = gene.toUpperCase();
// convert String to bit representation
for (int i = 0; i < length; i++) {
final int firstLocation = 2 * i;
final int secondLocation = 2 * i + 1;
switch (upperGene.charAt(i)) {
case 'A': // 00 are next two bits
bitSet.set(firstLocation, false);
bitSet.set(secondLocation, false);
break;
case 'C': // 01 are next two bits
bitSet.set(firstLocation, false);
bitSet.set(secondLocation, true);
break;
case 'G': // 10 are next two bits
bitSet.set(firstLocation, true);
bitSet.set(secondLocation, false);
break;
case 'T': // 11 are next two bits
bitSet.set(firstLocation, true);
bitSet.set(secondLocation, true);
break;
default:
throw new IllegalArgumentException("The provided gene String contains characters other than ACGT");
}
}
}
compress()方法按顺序查看核苷酸字符串中的每个字符。当它看到 A 时,就在位字符串中添加 00。当它看到 C 时,就添加 01,依此类推。对于 BitSet 类,布尔值 true 和 false 分别作为 1 和 0 的标记。
每个核苷酸都是通过两次调用 set()方法添加的。换句话说,我们不断地在位字符串的末尾添加两个新位。添加的两个位由核苷酸的类型决定。
最后,我们将实现解压缩。
列表 1.11 CompressedGene.java 继续
public String decompress() {
if (bitSet == null) {
return "";
}
// create a mutable place for characters with the right capacity
StringBuilder builder = new StringBuilder(length);
for (int i = 0; i < (length * 2); i += 2) {
final int firstBit = (bitSet.get(i) ? 1 : 0);
final int secondBit = (bitSet.get(i + 1) ? 1 : 0);
final int lastBits = firstBit << 1 | secondBit;
switch (lastBits) {
case 0b00: // 00 is 'A'
builder.append('A');
break;
case 0b01: // 01 is 'C'
builder.append('C');
break;
case 0b10: // 10 is 'G'
builder.append('G');
break;
case 0b11: // 11 is 'T'
builder.append('T');
break;
}
}
return builder.toString();
}
decompress()一次从位字符串中读取两个位,并使用这两个位来确定要将哪个字符添加到基因的字符串表示的末尾,该表示是通过 StringBuilder 构建的。这两个位组合在一起在变量 lastBits 中。lastBits 是通过将第一个位向后移动一位,然后使用 OR 操作符(|运算符)将结果与第二个位进行或操作来创建的。当使用<<运算符移动值时,留下的空间被 0s 替换。OR 操作表示,“如果这两个位中的任何一个为 1,则放置一个 1。”因此,将 secondBit 与 0 进行 OR 操作将始终只产生 secondBit 的值。让我们来测试一下。
列表 1.12 CompressedGene.java 继续
public static void main(String[] args) {
final String original = "TAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATATAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATA";
CompressedGene compressed = new CompressedGene(original);
final String decompressed = compressed.decompress();
System.out.println(decompressed);
System.out.println("original is the same as decompressed: " + original.equalsIgnoreCase(decompressed));
}
}
main()方法执行压缩和解压缩。它使用 equalsIgnoreCase()检查最终结果是否与原始字符串相同。
列表 1.13 CompressedGene.java 输出
TAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATATAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATA
original is the same as decompressed: true
1.3 不可破译的加密
一次性密码是一种通过将数据与无意义的随机虚拟数据结合来加密数据的方法,这样原始数据在没有访问产品和虚拟数据的情况下无法重新构成。本质上,这给加密者留下了一对密钥。一个密钥是产品,另一个是随机虚拟数据。单独的密钥是无用的;只有两个密钥的组合才能解锁原始数据。当正确执行时,一次性密码是一种不可破译的加密形式。图 1.6 显示了该过程。

图 1.6 一次性密码产生两个可以分离并重新组合以重新创建原始数据的密钥。
1.3.1 按顺序获取数据
在这个例子中,我们将使用一次密码加密一个字符串。一种思考 Java 字符串的方式是将其视为一系列 UTF-16 字符(UTF-16 是一种 Unicode 字符编码)。每个 UTF-16 字符都是 16 位(因此是 16),可以进一步细分为 2 个字节(每个 8 位)。可以通过 getBytes() 方法将字符串转换为字节数组,表示为字节数组。同样,可以使用 String 类型内置的构造函数之一将字节数组转换回字符串。我们需要一个中间形式来存储密钥对,它将包含两个字节数组。这就是 KeyPair 类的目的。
列表 1.14 KeyPair.java
package chapter1;
public final class KeyPair {
public final byte[] key1;
public final byte[] key2;
KeyPair(byte[] key1, byte[] key2) {
this.key1 = key1;
this.key2 = key2;
}
}
在一次密码加密操作中,用于加密操作的虚拟数据必须满足三个标准,以确保结果产品是不可破译的。虚拟数据必须与原始数据长度相同,真正随机,并且完全保密。第一个和第三个标准是常识。如果虚拟数据因为太短而重复,可能会观察到某种模式。如果其中一个密钥不是真正保密的(可能它在其他地方被重复使用或部分泄露),那么攻击者就有线索了。第二个标准本身就是一个问题:我们能否生成真正随机的数据?对于大多数计算机来说,答案是不了。
在这个例子中,我们将使用标准库中的 Random 类的伪随机数据生成函数 nextBytes()。我们的数据不会是真正随机的,因为 Random 类在幕后使用伪随机数生成器,但对我们来说已经足够接近了。让我们生成一个随机密钥作为虚拟数据使用。
列表 1.15 UnbreakableEncryption.java
package chapter1;
import java.util.Random;
public class UnbreakableEncryption {
// Generate *length* random bytes
private static byte[] randomKey(int length) {
byte[] dummy = new byte[length];
Random random = new Random();
random.nextBytes(dummy);
return dummy;
}
此方法创建了一个填充了随机字节的字节数组。最终,这些字节将作为我们的密钥对中的“虚拟”密钥。
1.3.2 加密和解密
我们将如何将虚拟数据与我们要加密的原始数据结合?XOR 运算将完成这个任务。XOR 是一种逻辑位运算(在位级别上操作),当其中一个操作数为真时返回真,当两个都为真或都不是真时返回假。正如你可能猜到的,XOR 代表的是“异或”。
在 Java 中,XOR 运算符是 ^。在二进制数的位上下文中,XOR 对于 0 ^ 1 和 1 ^ 0 返回 1,但对于 0 ^ 0 和 1 ^ 1 返回 0。如果使用 XOR 将两个数的位组合起来,一个有用的性质是,结果可以与任一操作数重新组合以产生另一个操作数:
C = A ^ B
A = C ^ B
B = C ^ A
这个关键洞察构成了一次密码加密的基础。为了形成我们的产品,我们将简单地使用 XOR 运算将原始字符串中的字节与随机生成的相同长度的字节(由 randomKey() 生成)进行异或。我们的返回密钥对将是虚拟密钥和产品密钥,如图 1.6 所示。
列表 1.16 UnbreakableEncryption.java 继续阅读
public static KeyPair encrypt(String original) {
byte[] originalBytes = original.getBytes();
byte[] dummyKey = *randomKey*(originalBytes.length);
byte[] encryptedKey = new byte[originalBytes.length];
for (int i = 0; i < originalBytes.length; i++) {
// XOR every byte
encryptedKey[i] = (byte) (originalBytes[i] ^ dummyKey[i]);
}
return new KeyPair(dummyKey, encryptedKey);
}
解密只是重新组合我们用 encrypt()生成的密钥对。这再次通过在两个密钥的每个和每个位之间进行 XOR 操作来实现。最终输出必须转换回 String。这是通过 String 类的构造函数完成的,该构造函数以字节数组作为其唯一的参数。
列表 1.17 UnbreakableEncryption.java 继续阅读
public static String decrypt(KeyPair kp) {
byte[] decrypted = new byte[kp.key1.length];
for (int i = 0; i < kp.key1.length; i++) {
// XOR every byte
decrypted[i] = (byte) (kp.key1[i] ^ kp.key2[i]);
}
return new String(decrypted);
}
如果我们的一次性密码加密真正有效,我们应该能够无问题地加密和解密相同的 Unicode 字符串。
列表 1.18 UnbreakableEncryption.java 继续阅读
public static void main(String[] args) {
KeyPair kp = *encrypt*("One Time Pad!");
String result = *decrypt*(kp);
System.out.println(result);
}
}
如果你的控制台输出 One Time Pad!,则一切正常。用你自己的句子试一试。
1.4 计算π
数学上有意义的数字π(π或 3.14159...)可以通过许多公式推导出来。其中最简单的一个是莱布尼茨公式。它提出以下无穷级数的收敛等于π:
π = 4/1 - 4/3 + 4/5 - 4/7 + 4/9 - 4/11...
你会注意到无穷级数的分子保持为 4,而分母每次增加 2,并且项的操作在加法和减法之间交替。
我们可以通过将公式的部分转换为函数中的变量来直接建模这个级数。分子可以是一个常数 4。分母可以是一个变量,从 1 开始,每次增加 2。操作可以根据我们是加法还是减法表示为-1 或 1。最后,变量π在列表 1.19 中用于收集级数的和,随着 for 循环的进行。
列表 1.19 PiCalculator.java
package chapter1;
public class PiCalculator {
public static double calculatePi(int nTerms) {
final double numerator = 4.0;
double denominator = 1.0;
double operation = 1.0;
double pi = 0.0;
for (int i = 0; i < nTerms; i++) {
pi += operation * (numerator / denominator);
denominator += 2.0;
operation *= -1.0;
}
return pi;
}
public static void main(String[] args) {
System.out.println(*calculatePi*(1000000));
}
}
提示 Java 的 double 是 64 位浮点数,它们比 32 位类型 float 提供更多的精度。
这个函数是公式和程序代码之间机械转换的一个例子,这种转换在建模或模拟有趣的概念时既简单又有效。机械转换是一个有用的工具,但我们必须记住,它不一定是最有效的解决方案。当然,π的莱布尼茨公式可以用更高效或更紧凑的代码实现。
注意 无穷级数的项数越多(当调用 calculatePi()时 nTerms 的值越高),π的最终计算将越准确。
1.5 汉诺塔
三根垂直的柱子(以下称为“塔”)高高矗立。我们将它们标记为 A、B 和 C。环形圆盘围绕塔 A。最宽的圆盘在底部,我们将其称为圆盘 1。圆盘 1 以上的其他圆盘用递增的数字标记,并逐渐变窄。例如,如果我们处理三个圆盘,最宽的圆盘,底部的圆盘,将是 1。下一个最宽的圆盘,圆盘 2,将坐在圆盘 1 的顶部。最后,最窄的圆盘,圆盘 3,将坐在圆盘 2 的顶部。我们的目标是根据以下约束条件将所有圆盘从塔 A 移动到塔 C:
-
一次只能移动一个圆盘。
-
任何塔顶的圆盘是唯一可以移动的。
-
较大的圆盘永远不会放在较小的圆盘之上。
图 1.7 总结了这个问题。
1.5.1 建模塔
栈是一种基于后进先出(LIFO)概念的数据结构。最后放入的是第一个出来的。想象一下老师批改一摞试卷。放在堆顶的最后一张纸是老师首先从堆中取出批改的第一张纸。栈上最基本的两项操作是 push 和 pop。Java 标准库包括一个内置的 Stack 类,它包含 push() 和 pop() 方法。
栈是汉诺塔中的塔的完美替代品。当我们想要将一个圆盘放到一个塔上时,我们只需将其推入。当我们想要将一个圆盘从一个塔移动到另一个塔时,我们可以从第一个塔中弹出并推入第二个塔。
让我们定义我们的塔为栈,并将第一个塔填充上圆盘。
列表 1.20 Hanoi.java
package chapter1;
import java.util.Stack;
public class Hanoi {
private final int numDiscs;
public final Stack<Integer> towerA = new Stack<>();
public final Stack<Integer> towerB = new Stack<>();
public final Stack<Integer> towerC = new Stack<>();
public Hanoi(int discs) {
numDiscs = discs;
for (int i = 1; i <= discs; i++) {
towerA.push(i);
}
}
1.5.2 解决汉诺塔
如何解决汉诺塔问题?想象我们只是在尝试移动一个圆盘。我们会知道如何做,对吧?实际上,移动一个圆盘是解决汉诺塔递归解决方案的基例。递归情况是移动多个圆盘。因此,关键洞察是我们本质上需要编码两个场景:移动一个圆盘(基例)和移动多个圆盘(递归情况)。

图 1.7 挑战是将三个圆盘一个接一个地从塔 A 移动到塔 C。较大的圆盘不能放在较小的圆盘之上。
让我们看看一个具体的例子来理解递归情况。假设我们有三个圆盘(顶部、中间和底部)在塔 A 上,我们想要将其移动到塔 C 上。(在跟随时画出问题可能有助于。)我们首先可以将顶部圆盘移动到塔 C 上。然后我们可以将中间圆盘移动到塔 B 上。然后我们可以将顶部圆盘从塔 C 移动到塔 B 上。现在底部圆盘仍然在塔 A 上,上面的两个圆盘在塔 B 上。本质上,我们现在已经成功地将两个圆盘从一个塔(A)移动到另一个塔(B)。将底部圆盘从 A 移动到 C 是我们的基例(移动单个圆盘)。现在我们可以使用与从 A 到 B 相同的程序将上面的两个圆盘从 B 移动到 C。我们将顶部圆盘移动到 A,中间圆盘移动到 C,最后将顶部圆盘从 A 移动到 C。
小贴士 在计算机科学课堂上,用木棒和塑料甜甜圈搭建的塔的小模型并不少见。你可以用三支铅笔和三张纸来搭建自己的模型。这可能有助于你可视化解决方案。
在我们的三个圆盘示例中,我们有一个简单的基例,即移动单个圆盘,以及一个递归情况,即移动所有其他圆盘(在这种情况下是两个),暂时使用第三个塔。我们可以将递归情况分解为三个步骤:
-
将塔 A 上的上 n-1 个圆盘移动到 B(临时塔),使用 C 作为中间步骤。
-
将 A 塔上单个最低的圆盘移动到 C。
-
将塔 B 上的 n-1 个圆盘移动到 C,使用 A 作为中间步骤。
令人惊讶的是,这个递归算法不仅适用于三个圆盘,而且适用于任意数量的圆盘。我们将将其编码为一个名为 move()的方法,该方法负责在给定第三个临时塔的情况下,将圆盘从一个塔移动到另一个塔。
列表 1.21 Hanoi.java 继续
private void move(Stack<Integer> begin, Stack<Integer> end, Stack<Integer> temp, int n) {
if (n == 1) {
end.push(begin.pop());
} else {
move(begin, temp, end, n - 1);
move(begin, end, temp, 1);
move(temp, end, begin, n - 1);
}
}
最后,辅助方法 solve()将调用 move()来移动塔 A 上的所有圆盘到塔 C。在调用 solve()之后,你应该检查塔 A、B 和 C,以验证圆盘是否成功移动。
列表 1.22 Hanoi.java 继续
public void solve() {
move(towerA, towerC, towerB, numDiscs);
}
public static void main(String[] args) {
Hanoi hanoi = new Hanoi(3);
hanoi.solve();
System.out.println(hanoi.towerA);
System.out.println(hanoi.towerB);
System.out.println(hanoi.towerC);
}
}
你会发现它们确实移动了。在将汉诺塔问题的解决方案编码化时,我们并不一定需要理解将多个圆盘从塔 A 移动到塔 C 所需的每一个步骤。但我们理解了移动任意数量圆盘的一般递归算法,并将其编码化,让计算机完成剩余的工作。这就是将问题的递归解决方案公式化的力量:我们常常可以以抽象的方式思考解决方案,而无需在脑海中逐一考虑每一个具体行动。
顺便提一下,move()方法将根据圆盘的数量以指数级执行,这使得解决 64 个圆盘的问题变得不可行。你可以通过将不同数量的圆盘传递给 Hanoi 构造函数来尝试使用其他数量的圆盘。随着圆盘数量的增加,所需步骤数量的指数级增加是汉诺塔传说的来源;你可以在任何数量的来源中了解更多关于它的信息。你可能还对了解其递归解决方案背后的数学感兴趣;请参阅 Carl Burch 在“关于汉诺塔”中的解释mng.bz/c1i2。
1.6 现实世界应用
本章中介绍的各种技术(递归、记忆化、压缩和位级操作)在现代软件开发中如此普遍,以至于无法想象没有它们计算的世界。尽管没有它们也可以解决问题,但通常使用它们解决问题更合理或更高效。
递归,特别是,不仅是许多算法的核心,甚至是整个编程语言的核心。在一些函数式编程语言中,如 Scheme 和 Haskell,递归取代了命令式语言中使用的循环。然而,值得注意的是,任何可以用递归技术完成的事情也可以用迭代技术完成。
缓存(memoization)已被成功应用于加速解析器(解释语言程序的程序)的工作。它在所有可能需要再次询问最近计算结果的问题中都有用。缓存的一个应用是在语言运行时中。一些语言运行时(例如 Prolog 的版本)会自动存储函数调用的结果(自动缓存),这样函数就不必在下次调用相同的调用时执行。
压缩技术使得带宽受限的互联网连接世界变得更加可忍受。在第 1.2 节中考察的位串技术适用于现实世界中具有有限可能值的简单数据类型,对于这些类型来说,即使是字节也显得过于冗余。然而,大多数压缩算法通过在数据集中寻找模式或结构来工作,这些模式或结构允许消除重复信息。它们比第 1.2 节中介绍的内容要复杂得多。
一次性密码对于通用加密来说并不实用。它们要求加密者和解密者都拥有原始数据的其中一个密钥(在我们例子中的虚拟数据),以便重建原始数据,这既繁琐又违背了大多数加密方案的目标(保持密钥的秘密性)。但你可能感兴趣的是,一次性密码这个名字来源于间谍在冷战期间使用带有虚拟数据的真实纸张垫来创建加密通信。
这些技术是程序性构建块,其他算法都是建立在它们之上的。在未来的章节中,你将看到它们被广泛地应用。
1.7 练习
-
编写另一个函数,使用你自己的设计技术求解斐波那契数列的第 n 个元素。编写单元测试来评估其正确性和相对于本章其他版本的性能。
-
Java 标准库中的 BitSet 类存在一个缺陷:虽然它跟踪了多少位被设置为 true,但它并没有跟踪总共设置了多少位,包括被设置为 false 的位(这就是为什么我们需要长度实例变量)。编写一个符合人体工程学的 BitSet 子类,该子类可以精确跟踪被设置为 true 或 false 的位的数量。使用这个子类重新实现 CompressedGene。
-
编写一个适用于任何数量塔的汉诺塔求解器。
-
使用一次性密码来加密和解密图像。
- 英国著名计算机科学家唐纳德·米契(Donald Michie)提出了 memoization 这个术语。唐纳德·米契,《Memo Functions: A Language Feature with “rote-learning” Properties》(爱丁堡大学,机器智能与感知系,1967 年)。
2 搜索问题
“搜索”是一个如此广泛的概念,以至于整本书都可以被称为 Java 经典搜索问题。本章是关于每个程序员都应该知道的核心理解算法。尽管标题声称是全面的,但它并不全面。
2.1 DNA 搜索
基因在计算机软件中通常表示为字符 A、C、G 和 T 的序列。每个字母代表一个核苷酸,三个核苷酸的组合称为密码子。这如图 2.1 所示。密码子编码特定的氨基酸,这些氨基酸与其他氨基酸一起可以形成蛋白质。生物信息学软件中的经典任务是在基因中找到特定的密码子。
2.1.1 存储 DNA
我们可以将核苷酸表示为一个简单的枚举,有四个情况。
列表 2.1 Gene.java
package chapter2;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
public class Gene {
public enum Nucleotide {
A, C, G, T
}

图 2.1 核苷酸由字母 A、C、G 和 T 中的一个表示。密码子由三个核苷酸组成,基因由多个密码子组成。
密码子可以定义为三个核苷酸的组合。密码子类的构造函数将三个字母的字符串转换为密码子。为了实现搜索方法,我们需要能够比较一个密码子与另一个密码子。Java 有一个接口用于此,Comparable。
实现 Comparable 接口需要构建一个方法,compareTo()。compareTo() 应该在问题项小于比较项时返回一个负数,在两个项相等时返回零,在问题项大于比较项时返回一个正数。在实践中,你通常可以避免手动实现它,而是使用内置的 Java 标准库接口 Comparator,就像我们在以下示例中所做的那样。在这个例子中,密码子将首先根据其第一个核苷酸与另一个密码子进行比较,然后如果第一个核苷酸相同,则根据第二个核苷酸进行比较,最后如果第二个核苷酸相同,则根据第三个核苷酸进行比较。它们使用 thenComparing() 链接。
列表 2.2 Gene.java 续
public static class Codon implements Comparable<Codon> {
public final Nucleotide first, second, third;
private final Comparator<Codon> comparator = Comparator.*comparing*((Codon c) -> c.first)
.thenComparing((Codon c) -> c.second)
.thenComparing((Codon c) -> c.third);
public Codon(String codonStr) {
first = Nucleotide.*valueOf*(codonStr.substring(0, 1));
second = Nucleotide.*valueOf*(codonStr.substring(1, 2));
third = Nucleotide.*valueOf*(codonStr.substring(2, 3));
}
@Override
public int compareTo(Codon other) {
// first is compared first, then second, etc.
// IOW first takes precedence over second
// and second over third
return comparator.compare(this, other);
}
}
注意:密码子是一个静态类。标记为静态的嵌套类可以在不考虑其封装类的情况下实例化(你不需要封装类的实例来创建静态嵌套类的实例),但它们不能引用其封装类的任何实例变量。这对于主要为了组织目的而不是物流目的而定义的嵌套类是有意义的。
通常,互联网上的基因将以包含基因序列中所有核苷酸的巨大字符串的文件格式表示。下一个列表显示了基因字符串可能的样子。
列表 2.3 基因字符串示例
String geneStr = "ACGTGGCTCTCTAACGTACGTACGTACGGGGTTTATATATACCCTAGGACTCCCTTT";
在基因中,唯一的状态将是一个密码子(Codons)的 ArrayList。我们还将有一个构造函数,它可以接受一个基因字符串并将其转换为基因(将字符串转换为密码子的 ArrayList)。
列表 2.4 Gene.java 续
private ArrayList<Codon> codons = new ArrayList<>();
public Gene(String geneStr) {
for (int i = 0; i < geneStr.length() - 3; i += 3) {
// Take every 3 characters in the String and form a Codon
codons.add(new Codon(geneStr.substring(i, i + 3)));
}
}
这个构造函数不断地遍历提供的 String,并将其下一个三个字符转换为密码子,然后将它们添加到新基因的末尾。它依赖于密码子的构造函数,该构造函数知道如何将三个字母的 String 转换为密码子。
2.1.2 线性搜索
我们可能想在基因上执行的一种基本操作是搜索特定的密码子。科学家可能想这样做以查看它是否编码了特定的氨基酸。目标是简单地找出密码子是否存在于基因中。
线性搜索会遍历搜索空间中的每个元素,按照原始数据结构的顺序,直到找到所寻求的项目或达到数据结构的末尾。实际上,线性搜索是搜索某物最简单、最自然、最明显的方式。在最坏的情况下,线性搜索需要遍历数据结构中的每个元素,因此它的复杂度为 O(n),其中 n 是结构中的元素数量。这如图 2.2 所示。

图 2.2 在线性搜索的最坏情况下,您将按顺序查看数组中的每个元素。
定义一个执行线性搜索的函数是微不足道的。它只需遍历数据结构中的每个元素,并检查其是否与所寻求的项目等效。您可以在 main()中使用以下代码进行测试。
列表 2.5 Gene.java 继续
public boolean linearContains(Codon key) {
for (Codon codon : codons) {
if (codon.compareTo(key) == 0) {
return true; // found a match
}
}
return false;
}
public static void main(String[] args) {
String geneStr = "ACGTGGCTCTCTAACGTACGTACGTACGGGGTTTATATATACCCTAGGACTCCCTTT";
Gene myGene = new Gene(geneStr);
Codon acg = new Codon("ACG");
Codon gat = new Codon("GAT");
System.out.println(myGene.linearContains(acg)); // true
System.out.println(myGene.linearContains(gat)); // false
}
}
注意:此函数仅用于说明目的。Java 标准库中实现 Collection 接口的所有类(如 ArrayList 和 LinkedList)都有一个 contains()方法,其优化可能比我们编写的任何内容都要好。
2.1.3 二分查找
与查看每个元素相比,有一种更快的方法来搜索,但这要求我们提前知道数据结构的顺序。如果我们知道结构是排序的,并且我们可以通过索引立即访问其中的任何项目,我们就可以执行二分查找。
二分查找通过查看排序元素范围内的中间元素,将其与所寻求的元素进行比较,根据该比较结果将范围减半,然后再次开始这个过程。让我们看看一个具体的例子。
假设我们有一个按字母顺序排序的单词列表,如["cat", "dog", "kangaroo", "llama", "rabbit", "rat", "zebra"],并且我们正在搜索单词“rat”:
-
我们可以确定这个七个单词列表的中间元素是“llama”。
-
我们可以确定“rat”在字母表中排在“llama”之后,所以它必须在“llama”之后的(大约)列表的一半中。(如果我们在这个步骤中找到了“rat”,我们可以返回它的位置;如果我们发现我们的单词在我们检查的中间单词之前,我们可以确信它在“llama”之前的列表的一半中。)
-
我们可以为已知“rat”可能仍在其中的列表的一半重新运行步骤 1 和 2。实际上,这个一半变成了我们的新基本列表。这些步骤会一直运行,直到找到“rat”或者我们正在查找的范围不再包含任何要搜索的元素,这意味着“rat”不在单词列表中。
图 2.3 阐述了二分查找。请注意,它不涉及搜索每个元素,这与线性搜索不同。

图 2.3 在二分查找的最坏情况下,你只需查看列表中的 lg(n) 个元素。
二分查找通过每次将搜索空间减半来不断减少搜索空间,因此其最坏情况运行时间为 O(lg n)。然而,有一个问题。与线性搜索不同,二分搜索需要一个已排序的数据结构来搜索,而排序需要时间。实际上,对于最佳排序算法,排序需要 O(n lg n) 的时间。如果我们只运行一次搜索,并且我们的原始数据结构未排序,那么进行线性搜索可能是有意义的。但是,如果搜索将多次执行,那么排序的时间成本是值得的,以获得每次单独搜索大大减少的时间成本的好处。
为基因和密码子编写二分查找函数并不像为其他类型的数据编写那样,因为密码子类型可以与其同类型的其他密码子进行比较,而基因类型只包含一个密码子 ArrayList。请注意,在下面的例子中,我们首先对密码子进行排序——这消除了进行二分查找的所有优势,因为如前一段所述,排序所需的时间将比搜索所需的时间更长。然而,为了说明目的,排序是必要的,因为我们不知道在运行此示例时密码子 ArrayList 是否已排序。
列表 2.6 Gene.java 继续如下
public boolean binaryContains(Codon key) {
// binary search only works on sorted collections
ArrayList<Codon> sortedCodons = new ArrayList<>(codons);
Collections.*sort*(sortedCodons);
int low = 0;
int high = sortedCodons.size() - 1;
while (low <= high) { // while there is still a search space
int middle = (low + high) / 2;
.get(middle).compareTo(key);
if (comparison < 0) { // middle codon is less than key
low = middle + 1;
} else if (comparison > 0) { // middle codon is > key
high = middle - 1;
} else { // middle codon is equal to key
return true;
}
}
return false;
}
让我们逐行分析这个函数。
int low = 0;
int high = sortedCodons.size() - 1;
我们首先查看一个包含整个列表(基因)的范围。
while (low <= high) {
只要我们还在搜索范围内,我们就继续搜索。当 low 大于 high 时,这意味着列表中不再有任何槽位可供查看。
int middle = (low + high) / 2;
我们通过整数除法和你在小学学到的简单平均公式来计算中间值。
int comparison = codons.get(middle).compareTo(key);
if (comparison < 0) { // middle codon is less than key
low = middle + 1;
如果我们正在寻找的元素在我们正在查看的范围的中间元素之后,我们通过将 low 移到当前中间元素之后的一个位置来修改我们在下一次循环迭代中将查看的范围。这就是我们在下一次迭代中减半范围的地方。
} else if (comparison > 0) { // middle codon is greater than key
high = middle - 1;
类似地,当我们寻找的元素小于中间元素时,我们在另一个方向上减半。
} else { // middle codon is equal to key
return true;
}
如果所讨论的元素既不小于也不大于中间元素,这意味着我们找到了它!当然,如果循环迭代结束,我们返回 false(此处未展示),表示它从未被找到。
我们现在可以尝试使用相同的基因和密码子运行我们的二分查找方法。我们可以修改 main() 来测试它。
列表 2.7 Gene.java 续
public static void main(String[] args) {
String geneStr = "ACGTGGCTCTCTAACGTACGTACGTACGGGGTTTATATATACCCTAGGACTCCCTTT";
Gene myGene = new Gene(geneStr);
Codon acg = new Codon("ACG");
Codon gat = new Codon("GAT");
System.out.println(myGene.linearContains(acg)); // true
System.out.println(myGene.linearContains(gat)); // false
System.out.println(myGene.binaryContains(acg)); // true
System.out.println(myGene.binaryContains(gat)); // false
}
小贴士:再次提醒,就像线性搜索一样,您永远不需要自己实现二分搜索,因为 Java 标准库中有一个实现。Collections.binarySearch() 可以搜索任何排序的集合(如排序后的 ArrayList)。
2.1.4 一个通用示例
linearContains() 和 binaryContains() 方法可以推广到几乎任何 Java List。以下推广版本几乎与您之前看到的版本相同,只是名称和类型有所改变。
注意:以下代码列表中有许多导入的类型。我们将在这个章节的许多其他通用搜索算法中重用 GenericSearch.java 文件,这样可以避免导入。
注意:T extends Comparable
列表 2.8 GenericSearch.java
package chapter2;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.Set;
import java.util.Stack;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.ToDoubleFunction;
public class GenericSearch {
public static <T extends Comparable<T>> boolean linearContains(List<T> list, T key) {
for (T item : list) {
if (item.compareTo(key) == 0) {
return true; // found a match
}
}
return false;
}
// assumes *list* is already sorted
public static <T extends Comparable<T>> boolean binaryContains(List<T> list, T key) {
int low = 0;
int high = list.size() - 1;
while (low <= high) { // while there is still a search space
int middle = (low + high) / 2;
int comparison = list.get(middle).compareTo(key);
if (comparison < 0) { // middle codon is < key
low = middle + 1;
} else if (comparison > 0) { // middle codon is > key
high = middle - 1;
} else { // middle codon is equal to key
return true;
}
}
return false;
}
public static void main(String[] args) {
System.out.println(*linearContains*(List.*of*(1, 5, 15, 15, 15, 15, 20), 5)); // true
System.out.println(*binaryContains*(List.*of*("a", "d", "e", "f", "z"), "f")); // true
System.out.println(*binaryContains*(List.*of*("john", "mark", "ronald", "sarah"), "sheila")); // false
}
}
现在,您可以尝试对其他类型的数据进行搜索。这些方法将适用于任何 Comparables 的 List。这就是编写通用代码的力量。
2.2 迷宫求解
在迷宫中找到一条路径与计算机科学中许多常见的搜索问题类似。那么,为什么不直接找到一条迷宫路径来展示广度优先搜索、深度优先搜索和 A* 算法呢?
我们的迷宫将是一个由单元格组成的二维网格。单元格是一个枚举,它知道如何将自己转换为字符串。例如," " 将表示一个空格,而 "X" 将表示一个被阻塞的空间。在打印迷宫时,还有一些其他的情况用于说明。
列表 2.9 Maze.java
package chapter2;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import chapter2.GenericSearch.Node;
public class Maze {
public enum Cell {
EMPTY(" "),
BLOCKED("X"),
START("S"),
GOAL("G"),
PATH("*");
private final String code;
private Cell(String c) {
code = c;
}
@Override
public String toString() {
return code;
}
}
再次提醒,我们正在处理一些导入。请注意,最后一个导入(来自 GenericSearch)是一个我们尚未定义的类。它在这里是为了方便,但您可能希望在需要之前将其注释掉。
我们需要一种方法来引用迷宫中的单个位置。这将是一个简单的类,MazeLocation,具有表示所讨论位置行和列的属性。然而,该类还需要一种方法,以便其实例可以与其他相同类型的实例进行比较以确定相等性。在 Java 中,这是在集合框架中正确使用多个类(如 HashSet 和 HashMap)所必需的。它们使用 equals() 和 hashCode() 方法来避免插入重复项,因为它们只允许唯一的实例。
幸运的是,IDE 可以为我们做这项艰苦的工作。在下一条列表中构造函数之后的两个方法是由 Eclipse 自动生成的。它们将确保具有相同行和列的两个 MazeLocation 实例被视为彼此等价。在 Eclipse 中,您可以通过右键单击并选择 Source > Generate hashCode() 和 equals() 来创建这些方法。您需要在对话框中指定哪些实例变量用于评估相等性。
列表 2.10 Maze.java 续
public static class MazeLocation {
public final int row;
public final int column;
public MazeLocation(int row, int column) {
this.row = row;
this.column = column;
}
// auto-generated by Eclipse
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + column;
result = prime * result + row;
return result;
}
// auto-generated by Eclipse
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
MazeLocation other = (MazeLocation) obj;
if (column != other.column) {
return false;
}
if (row != other.row) {
return false;
}
return true;
}
}
2.2.1 生成随机迷宫
我们的迷宫类将内部跟踪一个表示其状态的网格(一个二维数组)。它还将有行数、列数、起始位置和目标位置的实例变量。它的网格将以随机方式填充障碍单元格。
生成的迷宫应该相对稀疏,以便几乎总是可以从给定的起始位置到达给定的目标位置。(这毕竟是为了测试我们的算法。)我们将让新迷宫的调用者决定确切的稀疏度,但我们将提供一个默认值为 20%的阻塞值。当随机数超过稀疏度参数的阈值时,我们将简单地用一个墙壁替换空格。如果我们对迷宫中的每个可能位置都这样做,从统计上讲,迷宫的整体稀疏度将接近提供的稀疏度参数。
列表 2.11 Maze.java 继续内容
private final int rows, columns;
private final MazeLocation start, goal;
private Cell[][] grid;
public Maze(int rows, int columns, MazeLocation start, MazeLocation goal, double sparseness) {
// initialize basic instance variables
this.rows = rows;
this.columns = columns;
this.start = start;
this.goal = goal;
// fill the grid with empty cells
grid = new Cell[rows][columns];
for (Cell[] row : grid) {
Arrays.*fill*(row, Cell.EMPTY);
}
// populate the grid with blocked cells
randomlyFill(sparseness);
// fill the start and goal locations
grid[start.row][start.column] = Cell.START;
grid[goal.row][goal.column] = Cell.GOAL;
}
public Maze() {
this(10, 10, new MazeLocation(0, 0), new MazeLocation(9, 9), 0.2);
}
private void randomlyFill(double sparseness) {
for (int row = 0; row < rows; row++) {
for (int column = 0; column < columns; column++) {
if (Math.*random*() < sparseness) {
grid[row][column] = Cell.BLOCKED;
}
}
}
}
现在我们有了迷宫,我们也希望有一种方法可以简洁地将其打印到控制台。我们希望它的字符紧密排列,看起来像真正的迷宫。
列表 2.12 Maze.java 继续内容
// return a nicely formatted version of the maze for printing
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (Cell[] row : grid) {
for (Cell cell : row) {
sb.append(cell.toString());
}
sb.append(System.*lineSeparator*());
}
return sb.toString();
}
如果愿意,可以继续在 main()中测试这些迷宫函数。
列表 2.13 Maze.java 继续内容
public static void main(String[] args) {
Maze m = new Maze();
System.out.println(m);
}
}
2.2.2 其他迷宫细节
在稍后,有一个函数来检查我们在搜索过程中是否达到了目标会很有用。换句话说,我们想要检查搜索到达的特定迷宫位置是否是目标。我们可以在迷宫中添加一个方法。
列表 2.14 Maze.java 继续内容
public boolean goalTest(MazeLocation ml) {
return goal.equals(ml);
}
我们如何在迷宫中移动?假设我们可以从迷宫中的给定空间一次水平或垂直移动一个空间。使用这些标准,successors()函数可以找到从给定迷宫位置的可能下一个位置。然而,successors()函数对于每个迷宫都是不同的,因为每个迷宫的大小和墙壁集合都不同。因此,我们将它定义为迷宫上的一个方法。
列表 2.15 Maze.java 继续内容
public List<MazeLocation> successors(MazeLocation ml) {
List<MazeLocation> locations = new ArrayList<>();
if (ml.row + 1 < rows && grid[ml.row + 1][ml.column] != Cell.BLOCKED) {
locations.add(new MazeLocation(ml.row + 1, ml.column));
}
if (ml.row - 1 >= 0 && grid[ml.row - 1][ml.column] != Cell.BLOCKED) {
locations.add(new MazeLocation(ml.row - 1, ml.column));
}
if (ml.column + 1 < columns && grid[ml.row][ml.column + 1] != Cell.BLOCKED) {
locations.add(new MazeLocation(ml.row, ml.column + 1));
}
if (ml.column - 1 >= 0 && grid[ml.row][ml.column - 1] != Cell.BLOCKED) {
locations.add(new MazeLocation(ml.row, ml.column - 1));
}
return locations;
}
successors()简单地检查迷宫中迷宫位置的上方、下方、右侧和左侧,看是否可以找到可以从该位置到达的空格。它还避免了检查迷宫边缘之外的位置。它将找到的每个可能的迷宫位置放入一个列表中,最终返回给调用者。我们将在搜索算法中使用前两种方法。
2.2.3 深度优先搜索
深度优先搜索(DFS)正如其名所示:在回溯到上一个决策点之前,它会尽可能深入地搜索,如果遇到死胡同。我们将实现一个通用的深度优先搜索,它可以解决我们的迷宫问题。它也可以用于其他问题。图 2.4 展示了迷宫的深度优先搜索过程。
栈
深度优先搜索算法依赖于一种称为栈的数据结构。(我们第一次在第一章中看到栈。)栈是一种遵循后进先出(LIFO)原则的数据结构。想象一下一叠纸张。最后放在栈顶的纸张是第一个从栈中取出的纸张。通常,栈是通过在链表等更原始的数据结构的一端添加项目并在同一端移除它们来实现的。我们可以轻松地自己实现一个栈,但 Java 标准库包括了方便的 Stack 类。
栈通常至少有两个操作:
-
push()--将一个项目放置在栈顶
-
pop()--从栈顶移除项目并返回它

图 2.4 在深度优先搜索中,搜索沿着一条不断深入的路径进行,直到遇到障碍并必须回溯到最后一个决策点。
换句话说,栈是一种强制对列表上的移除顺序进行排序的元结构。放入栈中的最后一个项目必须是下一个从栈中移除的项目。
DFS 算法
在我们能够实现 DFS 之前,我们还需要一个小技巧。我们需要一个 Node 类,我们将使用它来跟踪我们在搜索过程中从一个状态到另一个状态(或从一个位置到另一个位置)的路径。你可以将 Node 视为围绕状态的包装器。在我们的迷宫解决问题的关键中,这些状态是 MazeLocation 类型。我们将从该状态来的 Node 称为其父节点。我们还将定义我们的 Node 类具有成本和启发式属性,这样我们可以在 A*算法中稍后重用它。现在不必担心它们。Node 可以通过比较其成本和启发式值的组合来进行比较。
列表 2.16 GenericSearch.java 继续
public static class Node<T> implements Comparable<Node<T>> {
final T state;
Node<T> parent;
double cost;
double heuristic;
// for dfs and bfs we won't use cost and heuristic
Node(T state, Node<T> parent) {
this.state = state;
this.parent = parent;
}
// for astar we will use cost and heuristic
Node(T state, Node<T> parent, double cost, double heuristic) {
this.state = state;
this.parent = parent;
this.cost = cost;
this.heuristic = heuristic;
}
@Override
public int compareTo(Node<T> other) {
Double mine = cost + heuristic;
Double theirs = other.cost + other.heuristic;
return mine.compareTo(theirs);
}
}
提示:在这里,compareTo()通过在另一个类型上调用 compareTo()来实现。这是一个常见的模式。
注意:如果一个节点没有父节点,我们将使用 null 作为哨兵来表示这种情况。
进行中的深度优先搜索需要跟踪两种数据结构:我们正在考虑搜索的状态(或“位置”)的栈,我们将其称为前沿,以及我们已经搜索过的状态的集合,我们将其称为已探索。只要前沿中还有更多要访问的状态,DFS 将继续检查它们是否是目标(如果状态是目标,DFS 将停止并返回它),并将它们的后续状态添加到前沿。它还将标记每个已经搜索过的状态为已探索,这样搜索就不会陷入循环,达到有先前访问状态作为后续状态的状态。如果前沿为空,这意味着没有其他地方可以搜索。
列表 2.17 GenericSearch.java 继续
public static <T> Node<T> dfs(T initial, Predicate<T> goalTest,
Function<T, List<T>> successors) {
// frontier is where we've yet to go
Stack<Node<T>> frontier = new Stack<>();
frontier.push(new Node<>(initial, null));
// explored is where we've been
Set<T> explored = new HashSet<>();
explored.add(initial);
// keep going while there is more to explore
while (!frontier.isEmpty()) {
Node<T> currentNode = frontier.pop();
T currentState = currentNode.state;
// if we found the goal, we're done
if (goalTest.test(currentState)) {
return currentNode;
}
// check where we can go next and haven't explored
for (T child : successors.apply(currentState)) {
if (explored.contains(child)) {
continue; // skip children we already explored
}
explored.add(child);
frontier.push(new Node<>(child, currentNode));
}
}
return null; // went through everything and never found goal
}
注意目标测试和后继函数引用。这些允许将不同的函数插入 dfs() 以适应不同的应用。这使得 dfs() 可以用于比迷宫更多的场景。这是解决问题通用性的另一个例子。目标测试是一个 Predicate
如果 dfs() 成功,它将返回封装目标状态的节点。可以通过从这个节点及其父节点使用父属性反向工作来重建从起点到目标的路径。
列表 2.18 GenericSearch.java 继续部分
public static <T> List<T> nodeToPath(Node<T> node) {
List<T> path = new ArrayList<>();
path.add(node.state);
// work backwards from end to front
while (node.parent != null) {
node = node.parent;
path.add(0, node.state); // add to front
}
return path;
}
为了显示目的,用成功路径、起始状态和目标状态标记迷宫将是有用的。同时,能够移除路径以便我们可以尝试不同的搜索算法在同一个迷宫上也是很有用的。以下两个方法应该添加到 Maze.java 中的 Maze 类中。
列表 2.19 Maze.java 继续部分
public void mark(List<MazeLocation> path) {
for (MazeLocation ml : path) {
grid[ml.row][ml.column] = Cell.PATH;
}
grid[start.row][start.column] = Cell.START;
grid[goal.row][goal.column] = Cell.GOAL;
}
public void clear(List<MazeLocation> path) {
for (MazeLocation ml : path) {
grid[ml.row][ml.column] = Cell.EMPTY;
}
grid[start.row][start.column] = Cell.START;
grid[goal.row][goal.column] = Cell.GOAL;
}
这已经是一次漫长的旅程,但我们终于准备好解决迷宫了。
列表 2.20 Maze.java 继续部分
public static void main(String[] args) {
Maze m = new Maze();
System.out.println(m);
Node<MazeLocation> solution1 = GenericSearch.*dfs*(m.start, m::goalTest, m::successors);
if (solution1 == null) {
System.out.println("No solution found using depth-first search!");
} else {
List<MazeLocation> path1 = GenericSearch.*nodeToPath*(solution1);
m.mark(path1);
System.out.println(m);
m.clear(path1);
}
}
}
一个成功的解决方案看起来可能像这样:
S****X X
X *****
X*
XX******X
X*
X**X
X *****
*
X *X
*G
星号代表从起点到目标我们深度优先搜索函数找到的路径。S 是起点位置,G 是目标位置。记住,因为每个迷宫都是随机生成的,并不是每个迷宫都有解。
2.2.4 广度优先搜索
你可能会注意到,深度优先遍历找到的迷宫的解决方案路径看起来不太自然。它们通常不是最短路径。广度优先搜索(BFS)通过在搜索的每次迭代中系统地查找离起始状态更远的一层节点,总是找到最短路径。在某些特定的问题中,深度优先搜索可能比广度优先搜索更快地找到解决方案,反之亦然。因此,在这两者之间进行选择有时是在快速找到解决方案的可能性和找到到目标的最短路径的确定性(如果存在的话)之间的一种权衡。图 2.5 展示了一个迷宫的广度优先搜索过程。

图 2.5 在广度优先搜索中,首先搜索离起始位置最近的元素。
为了理解为什么深度优先搜索有时比广度优先搜索更快地返回结果,想象一下在一个洋葱的特定层上寻找标记。使用深度优先策略的搜索者可能会将刀子插入洋葱的中心,并随意检查切出的块。如果标记层恰好靠近切出的块,那么搜索者可能会比使用广度优先策略的搜索者更快地找到它,后者痛苦地一层层剥洋葱。
为了更好地理解为什么广度优先搜索总是能在存在的情况下找到最短解路径,可以考虑尝试通过火车在波士顿和纽约之间找到停站次数最少的路径。如果你一直朝同一个方向前进,并在遇到死胡同时回溯(就像深度优先搜索那样),你可能会首先找到一个直达西雅图的路线,然后再连接回纽约。然而,在广度优先搜索中,你首先会检查所有距离波士顿一站的车站。然后你会检查所有距离波士顿两站的车站。然后你会检查所有距离波士顿三站的车站。这个过程会一直持续到找到纽约。因此,当你找到纽约时,你会知道你已经找到了停站次数最少的路线,因为你已经检查了所有比波士顿更少的站点的车站,而纽约不在其中。
队列
要实现广度优先搜索,需要一个称为队列的数据结构。与栈是后进先出(LIFO)不同,队列是先进先出(FIFO)。队列就像一个上厕所的队伍。第一个排队的人先上厕所。至少,队列具有与栈相同的 push() 和 pop() 方法。在实现层面,栈和队列之间唯一不同的是,队列从与插入相反的一端从列表中移除项目。这确保了最老的元素(等待时间最长的元素)总是首先被移除。
注意:令人困惑的是,Java 标准库没有 Queue 类,尽管它有一个 Stack 类。相反,它有一个 Queue 接口,该接口由几个 Java 标准库类实现(包括 LinkedList)。更令人困惑的是,在 Java 标准库的 Queue 接口中,push() 被称为 offer(),而 pop() 被称为 poll()。
广度优先搜索算法
令人惊讶的是,广度优先搜索的算法与深度优先搜索的算法相同,只是前沿从栈改为队列。将前沿从栈改为队列改变了搜索状态顺序,并确保首先搜索距离起始状态最近的州。
在以下实现中,我们也必须将一些从 push() 和 pop() 到 offer() 和 poll() 的调用进行更改,因为 Java 标准库的 Stack 类和 Queue 接口之间的命名方案不同(参见前面的说明)。但请花一点时间回顾一下 dfs()(在列表 2.17 中)并欣赏 dfs() 和 bfs() 之间的相似性,只是前沿数据结构发生了变化。
列表 2.21 GenericSearch.java 继续内容
public static <T> Node<T> bfs(T initial, Predicate<T> goalTest,
Function<T, List<T>> successors) {
// frontier is where we've yet to go
Queue<Node<T>> frontier = new LinkedList<>();
frontier.offer(new Node<>(initial, null));
// explored is where we've been
Set<T> explored = new HashSet<>();
explored.add(initial);
// keep going while there is more to explore
while (!frontier.isEmpty()) {
Node<T> currentNode = frontier.poll();
T currentState = currentNode.state;
// if we found the goal, we're done
if (goalTest.test(currentState)) {
return currentNode;
}
// check where we can go next and haven't explored
for (T child : successors.apply(currentState)) {
if (explored.contains(child)) {
continue; // skip children we already explored
}
explored.add(child);
frontier.offer(new Node<>(child, currentNode));
}
}
return null; // went through everything and never found goal
}
如果你尝试运行 bfs(),你会看到它总是找到迷宫问题的最短解。现在 Maze.java 中的 main() 方法可以修改为尝试解决同一迷宫的两种不同方法,以便进行比较。
列表 2.22 Maze.java 继续内容
public static void main(String[] args) {
Maze m = new Maze();
System.out.println(m);
Node<MazeLocation> solution1 = GenericSearch.*dfs*(m.start, m::goalTest, m::successors);
if (solution1 == null) {
System.out.println("No solution found using depth-first search!");
} else {
List<MazeLocation> path1 = GenericSearch.*nodeToPath*(solution1);
m.mark(path1);
System.out.println(m);
m.clear(path1);
}
Node<MazeLocation> solution2 = GenericSearch.*bfs*(m.start, m::goalTest, m::successors);
if (solution2 == null) {
System.out.println("No solution found using breadth-first search!");
} else {
List<MazeLocation> path2 = GenericSearch.*nodeToPath*(solution2);
m.mark(path2);
System.out.println(m);
m.clear(path2);
}
}
真是令人惊讶,你可以在保持算法不变的情况下,仅仅改变它访问的数据结构,就能得到截然不同的结果。以下是在我们之前用 dfs()调用的相同迷宫上调用 bfs()的结果。注意,用星号标记的路径比先前的例子中从起点到目标更直接。
S X X
*X
* X
*XX X
* X
* X X
*X
*
* X X
*********G
2.2.5 A*搜索
逐层剥洋葱,就像广度优先搜索那样,可能会非常耗时。与 BFS 一样,A搜索旨在找到从起始状态到目标状态的最短路径。但与先前的 BFS 实现不同,A搜索使用成本函数和启发式函数的组合来集中搜索最有可能快速到达目标路径。
成本函数 g(n)检查到达特定状态的成本。在我们的迷宫案例中,这将是到达所讨论单元格之前我们需要通过多少个单元格。启发式函数 h(n)给出了从所讨论状态到目标状态的成本估计。可以证明,如果 h(n)是可接受的启发式方法,那么找到的最终路径将是最优的。可接受的启发式方法是那种永远不会高估到达目标成本的方法。在二维平面上,一个例子是直线距离启发式方法,因为直线总是最短路径。1
考虑任何状态的总成本是 f(n),这仅仅是 g(n)和 h(n)的组合。实际上,f(n) = g(n) + h(n)。在从边界中选择下一个要探索的状态时,A*搜索会选择具有最低 f(n)的那个。这就是它与 BFS 和 DFS 的区别。
优先队列
为了选择具有最低 f(n)的前沿状态,A*搜索使用优先队列作为其前沿的数据结构。优先队列保持其元素在一个内部顺序中,使得第一个弹出的元素总是最高优先级的元素。(在我们的情况下,最高优先级的项是具有最低 f(n)的项。)通常这意味着内部使用二叉堆,这导致 O(lg n)的推入和 O(lg n)的弹出操作。
Java 的标准库包含 PriorityQueue 类,它具有与 Queue 接口相同的 offer()和 poll()方法。任何放入 PriorityQueue 中的内容都必须是 Comparable 的。为了确定特定元素与其同类元素之间的优先级,PriorityQueue 通过使用 compareTo()方法来比较它们。这就是为什么我们之前需要实现它的原因。一个节点通过查看其各自的 f(n)来与另一个节点进行比较,f(n)仅仅是成本和启发式属性的总和。
启发式方法
启发式是一种关于解决问题方式的本能。2 在解决迷宫问题时,启发式旨在选择下一个要搜索的最佳迷宫位置,以到达目标。换句话说,它是对前沿节点中哪些节点最接近目标的理性猜测。正如之前提到的,如果与 A* 搜索一起使用的启发式能够产生准确的相对结果并且是可接受的(永远不会高估距离),那么 A* 将提供最短路径。计算较小值的启发式最终会导致搜索更多状态,而接近实际真实距离(但不能超过它,这会使它们不可接受)的启发式会导致搜索较少的状态。因此,理想的启发式应该尽可能接近真实距离,但永远不要超过它。
欧几里得距离
正如我们在几何学中学到的,两点之间的最短路径是一条直线。因此,对于迷宫解决问题,直线启发式始终是可接受的。欧几里得距离,根据勾股定理得出,表示距离 = √((x 差异)² + (y 差异)²)。对于我们的迷宫,x 差异相当于两个迷宫位置之间的列差异,y 差异相当于行差异。请注意,我们实际上是在 Maze.java 中实现这个功能的。
列表 2.23 Maze.java 继续显示
public double euclideanDistance(MazeLocation ml) {
int xdist = ml.column - goal.column;
int ydist = ml.row - goal.row;
return Math.*sqrt*((xdist * xdist) + (ydist * ydist));
}
euclideanDistance() 是一个函数,它接受一个迷宫位置并返回其到目标的直线距离。这个函数“知道”目标,因为它实际上是 Maze 的一个方法,而 Maze 有一个作为实例变量的目标。
图 2.6 在网格的背景下说明了欧几里得距离,就像曼哈顿的街道一样。

图 2.6 欧几里得距离是从起点到目标的直线长度。
曼哈顿距离
欧几里得距离很好,但针对我们特定的问题(一个只能沿四个方向移动的迷宫),我们可以做得更好。曼哈顿距离是从曼哈顿街道得出的,这是纽约市最著名的行政区之一,其布局呈网格状。要从任何地方走到曼哈顿的任何地方,需要走一定数量的水平街区和一个垂直街区。(曼哈顿几乎没有对角线街道。)曼哈顿距离是通过简单地找到两个迷宫位置之间的行差异并将其与列差异相加得到的。图 2.7 阐述了曼哈顿距离。
列表 2.24 Maze.java 继续显示
public double manhattanDistance(MazeLocation ml) {
int xdist = Math.*abs*(ml.column - goal.column);
int ydist = Math.*abs*(ml.row - goal.row);
return (xdist + ydist);
}
由于这个启发式更准确地遵循我们在迷宫中导航的实际性(垂直和水平移动而不是对角线直线),它比欧几里得距离更接近任何迷宫位置和目标之间的实际距离。因此,当 A搜索与曼哈顿距离结合时,它将比 A搜索与欧几里得距离结合时的搜索状态更少。解决方案路径仍然是最优的,因为曼哈顿距离对于只允许四个方向移动的迷宫是可接受的(不会高估距离)。

图 2.7 在曼哈顿距离中,没有对角线。路径必须沿着平行或垂直的线。
A*算法
要从 BFS 转换为 A*搜索,我们需要进行几个小的修改。第一个是将边界从队列更改为优先队列。这样,边界将弹出具有最低 f(n)值的节点。第二个是将已探索集更改为 HashMap。HashMap 将允许我们跟踪我们可能访问的每个节点的最低成本(g(n))。由于现在启发性函数正在发挥作用,如果启发式函数不一致,某些节点可能会被访问两次。如果通过新方向找到的节点到达的成本比我们上次访问它时更低,我们将选择新的路线。
为了简化,astar()方法不将成本计算函数作为参数。相反,我们只是将迷宫中的每一步视为成本 1。每个新的节点都会根据这个简单的公式分配一个成本,以及使用传递给搜索函数的新函数计算出的启发式分数,该函数称为启发式。除了这些变化之外,astar()与 bfs()非常相似。为了比较,请将它们并排查看。
列表 2.25 GenericSearch.java 继续
public static <T> Node<T> astar(T initial, Predicate<T> goalTest,
Function<T, List<T>> successors, ToDoubleFunction<T> heuristic) {
// frontier is where we've yet to go
PriorityQueue<Node<T>> frontier = new PriorityQueue<>();
frontier.offer(new Node<>(initial, null, 0.0, heuristic.applyAsDouble(initial)));
// explored is where we've been
Map<T, Double> explored = new HashMap<>();
explored.put(initial, 0.0);
// keep going while there is more to explore
while (!frontier.isEmpty()) {
Node<T> currentNode = frontier.poll();
T currentState = currentNode.state;
// if we found the goal, we're done
if (goalTest.test(currentState)) {
return currentNode;
}
// check where we can go next and haven't explored
for (T child : successors.apply(currentState)) {
// 1 here assumes a grid, need a cost function for more sophisticated apps
double newCost = currentNode.cost + 1;
if (!explored.containsKey(child) || explored.get(child) > newCost) {
explored.put(child, newCost);
frontier.offer(new Node<>(child, currentNode, newCost, heuristic.applyAsDouble(child)));
}
}
}
return null; // went through everything and never found goal
}
恭喜。如果你一直跟到这里,你已经不仅学会了如何解决迷宫,还学到了一些通用的搜索函数,你可以在许多不同的搜索应用中使用。DFS 和 BFS 适用于许多较小的数据集和状态空间,其中性能不是关键。在某些情况下,DFS 可能会优于 BFS,但 BFS 的优势是始终提供最优路径。有趣的是,BFS 和 DFS 具有相同的实现,只是通过使用队列而不是栈来区分边界。稍微复杂一点的 A*搜索,结合一个好的、一致的、可接受的启发式函数,不仅提供最优路径,而且远优于 BFS。而且因为这三个函数都是通用实现的,所以使用它们在几乎任何搜索空间中只需一个导入即可。
在 Maze.java 的测试部分尝试使用相同的迷宫运行 astar()。
列表 2.26 Maze.java 继续
public static void main(String[] args) {
Maze m = new Maze();
System.out.println(m);
Node<MazeLocation> solution1 = GenericSearch.*dfs*(m.start, m::goalTest, m::successors);
if (solution1 == null) {
System.out.println("No solution found using depth-first search!");
} else {
List<MazeLocation> path1 = GenericSearch.*nodeToPath*(solution1);
m.mark(path1);
System.out.println(m);
m.clear(path1);
}
Node<MazeLocation> solution2 = GenericSearch.*bfs*(m.start, m::goalTest, m::successors);
if (solution2 == null) {
System.out.println("No solution found using breadth-first search!");
} else {
List<MazeLocation> path2 = GenericSearch.*nodeToPath*(solution2);
m.mark(path2);
System.out.println(m);
m.clear(path2);
}
Node<MazeLocation> solution3 = GenericSearch.*astar*(m.start, m::goalTest, m::successors, m::manhattanDistance);
if (solution3 == null) {
System.out.println("No solution found using A*!");
} else {
List<MazeLocation> path3 = GenericSearch.*nodeToPath*(solution3);
m.mark(path3);
System.out.println(m);
m.clear(path3);
}
}
输出将会有趣地与 bfs()略有不同,尽管 bfs()和 astar()都在寻找最优路径(长度等效)。如果它使用曼哈顿距离启发式,astar()会立即通过对角线向目标方向行驶。它最终将搜索比 bfs()更少的州,从而获得更好的性能。如果您想证明这一点,可以在每个函数中添加一个状态计数。
S** X X
X**
* X
XX* X
X*
X**X
X ****
*
X * X
**G
2.3 传教士和食人族
三个传教士和三个食人族人在河的西岸。他们有一只可以载两个人的独木舟,他们所有人都必须过河到东岸。在任何一边,食人族人数都不能超过传教士人数,否则食人族会吃掉传教士。此外,独木舟过河时必须至少有一个人在船上。什么过河顺序能成功地将所有人带到河对岸?图 2.8 展示了这个问题。

图 2.8 传教士和食人族必须使用他们的独木舟将所有人从西岸运送到东岸。如果食人族人数超过传教士,他们就会吃掉他们。
2.3.1 问题表示
我们将通过一个结构来表示这个问题,这个结构会跟踪西岸的情况。西岸有多少传教士和食人族人?独木舟在西岸吗?一旦我们有了这些知识,我们就可以推断出东岸有什么,因为不在西岸的任何东西都在东岸。
首先,我们将创建一个便利变量来跟踪传教士或食人族的最大数量。然后我们将定义主类。
列表 2.27 Missionaries.java
package chapter2;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import chapter2.GenericSearch.Node;
public class MCState {
private static final int MAX_NUM = 3;
private final int wm; // west bank missionaries
private final int wc; // west bank cannibals
private final int em; // east bank missionaries
private final int ec; // east bank cannibals
private final boolean boat; // is boat on west bank?
public MCState(int missionaries, int cannibals, boolean boat) {
wm = missionaries;
wc = cannibals;
em = MAX_NUM - wm;
ec = MAX_NUM - wc;
this.boat = boat;
}
@Override
public String toString() {
return String.*format*(
"On the west bank there are %d missionaries and %d cannibals.%n"
+ "On the east bank there are %d missionaries and %d cannibals.%n"
+ "The boat is on the %s bank.","
wm, wc, em, ec,
boat ? "west" : "east");
}
MCState 类根据西岸的传教士和食人族人数以及独木舟的位置初始化自己。它还知道如何以美观的方式打印自己,这在稍后显示问题的解决方案时将非常有价值。
在我们现有的搜索函数限制下工作意味着我们必须定义一个函数来测试一个状态是否是目标状态,以及一个函数来从任何状态找到后续状态。目标测试函数,就像在迷宫求解问题中一样,非常简单。目标就是简单地到达一个合法状态,其中所有传教士和食人族都在东岸。我们将它作为一个方法添加到 MCState 中。
列表 2.28 Missionaries.java 继续
public boolean goalTest() {
return isLegal() && em == MAX_NUM && ec == MAX_NUM;
}
要创建一个后续状态函数,必须遍历从一边到另一边可以做出的所有可能的移动,然后检查这些移动中的每一个是否会导致一个合法状态。回想一下,合法状态是指任何一边的食人族人数都不超过传教士人数的状态。为了确定这一点,我们可以在 MCState 上定义一个便利方法来检查状态是否合法。
列表 2.29 Missionaries.java 继续
public boolean isLegal() {
if (wm < wc && wm > 0) {
return false;
}
if (em < ec && em > 0) {
return false;
}
return true;
}
实际的后继函数有点冗长,但为了清晰起见。它尝试添加从独木舟所在的河岸出发的每个人或两个人的所有可能的组合移动。一旦添加了所有可能的移动,它将通过 removeIf() 在一个临时的潜在状态列表和一个检查 isLegal() 的否定谓词来过滤出实际合法的移动。Predicate.not() 是在 Java 11 中添加的。再次强调,这是一个 MCState 上的方法。
列表 2.30 Missionaries.java 继续列出
public static List<MCState> successors(MCState mcs) {
List<MCState> sucs = new ArrayList<>();
if (mcs.boat) { // boat on west bank
if (mcs.wm > 1) {
sucs.add(new MCState(mcs.wm - 2, mcs.wc, !mcs.boat));
}
if (mcs.wm > 0) {
sucs.add(new MCState(mcs.wm - 1, mcs.wc, !mcs.boat));
}
if (mcs.wc > 1) {
sucs.add(new MCState(mcs.wm, mcs.wc - 2, !mcs.boat));
}
if (mcs.wc > 0) {
sucs.add(new MCState(mcs.wm, mcs.wc - 1, !mcs.boat));
}
if (mcs.wc > 0 && mcs.wm > 0) {
sucs.add(new MCState(mcs.wm - 1, mcs.wc - 1, !mcs.boat));
}
} else { // boat on east bank
if (mcs.em > 1) {
sucs.add(new MCState(mcs.wm + 2, mcs.wc, !mcs.boat));
}
if (mcs.em > 0) {
sucs.add(new MCState(mcs.wm + 1, mcs.wc, !mcs.boat));
}
if (mcs.ec > 1) {
sucs.add(new MCState(mcs.wm, mcs.wc + 2, !mcs.boat));
}
if (mcs.ec > 0) {
sucs.add(new MCState(mcs.wm, mcs.wc + 1, !mcs.boat));
}
if (mcs.ec > 0 && mcs.em > 0) {
sucs.add(new MCState(mcs.wm + 1, mcs.wc + 1, !mcs.boat));
}
}
sucs.removeIf(Predicate.*not*(MCState::isLegal));
return sucs;
}
2.3.2 解决方案
我们现在已经具备了解决问题的所有要素。回想一下,当我们使用 bfs()、dfs() 和 astar() 搜索函数解决问题时,我们得到一个节点,我们最终使用 nodeToPath() 将其转换为导致解决方案的状态列表。我们仍然需要一种方法将列表转换为可理解的打印步骤序列,以解决传教士和野人问题。
函数 displaySolution() 将解决方案路径转换为打印输出——即人类可读的解决方案。它通过遍历解决方案路径中的所有状态,同时跟踪最后一个状态来实现。它查看最后一个状态和当前遍历的状态之间的差异,以找出有多少传教士和野人横渡了河流以及方向。
列表 2.31 Missionaries.java 继续列出
public static void displaySolution(List<MCState> path) {
if (path.size() == 0) { // sanity check
return;
}
MCState oldState = path.get(0);
System.out.println(oldState);
for (MCState currentState : path.subList(1, path.size())) {
if (currentState.boat) {
System.out.printf("%d missionaries and %d cannibals moved from the east bank to the west bank.%n",
oldState.em - currentState.em,
oldState.ec - currentState.ec);
} else {
System.out.printf("%d missionaries and %d cannibals moved from the west bank to the east bank.%n",
oldState.wm - currentState.wm,
oldState.wc - currentState.wc);
}
System.out.println(currentState);
oldState = currentState;
}
}
displaySolution() 方法利用了 MCState 知道如何通过 toString() 方法打印出自身的一个漂亮的总结的事实。
我们最后需要做的事情就是实际解决传教士和野人问题。为此,我们可以方便地重用我们已经实现的一个搜索函数,因为我们以通用方式实现了它们。这个解决方案使用了 bfs()。为了与搜索函数正确地工作,回想一下,探索的数据结构需要状态能够轻松地进行相等性比较。因此,在 main() 中解决问题之前,我们再次让 Eclipse 自动生成 hashCode() 和 equals()。
列表 2.32 Missionaries.java 继续列出
// auto-generated by Eclipse
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (boat ? 1231 : 1237);
result = prime * result + ec;
result = prime * result + em;
result = prime * result + wc;
result = prime * result + wm;
return result;
}
// auto-generated by Eclipse
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
MCState other = (MCState) obj;
if (boat != other.boat) {
return false;
}
if (ec != other.ec) {
return false;
}
if (em != other.em) {
return false;
}
if (wc != other.wc) {
return false;
}
if (wm != other.wm) {
return false;
}
return true;
}
public static void main(String[] args) {
MCState start = new MCState(MAX_NUM, MAX_NUM, true);
Node<MCState> solution = GenericSearch.*bfs*(start, MCState::goalTest, MCState::*successors*);
if (solution == null) {
System.out.println("No solution found!");
} else {
List<MCState> path = GenericSearch.*nodeToPath*(solution);
*displaySolution*(path);
}
}
}
看到我们的通用搜索函数是多么灵活真是太好了。它们可以轻松地适应解决各种问题。你应该看到类似以下内容的输出(摘要):
On the west bank there are 3 missionaries and 3 cannibals.
On the east bank there are 0 missionaries and 0 cannibals.
The boast is on the west bank.
0 missionaries and 2 cannibals moved from the west bank to the east bank.
On the west bank there are 3 missionaries and 1 cannibals.
On the east bank there are 0 missionaries and 2 cannibals.
The boast is on the east bank.
0 missionaries and 1 cannibals moved from the east bank to the west bank.
...
On the west bank there are 0 missionaries and 0 cannibals.
On the east bank there are 3 missionaries and 3 cannibals.
The boast is on the east bank.
2.4 现实世界应用
搜索在所有有用的软件中都扮演着一定的角色。在某些情况下,它是核心元素(如 Google 搜索、Spotlight、Lucene);在其他情况下,它是使用底层数据存储结构的基础。知道将哪种搜索算法应用于数据结构对于性能至关重要。例如,在排序数据结构上使用线性搜索而不是二分搜索将非常昂贵。
A是最广泛部署的路径查找算法之一。它只被那些在搜索空间中进行预计算的算法所击败。对于盲搜索,A在所有场景中尚未被可靠地击败,这使得它成为从路线规划到解析编程语言最短路径的必要组成部分。大多数提供方向性地图软件(例如 Google Maps)使用 Dijkstra 算法(A是其变体)进行导航。(关于 Dijkstra 算法的更多信息请见第四章。)当游戏中的 AI 角色在没有人类干预的情况下从世界的这一端找到另一端的最短路径时,它很可能正在使用 A。
广度优先搜索和深度优先搜索通常是更复杂搜索算法(如一致代价搜索和回溯搜索,你将在下一章中看到)的基础。广度优先搜索通常在相当小的图中找到最短路径是足够的技术。但由于它与 A的相似性,如果存在一个好的启发式方法,则很容易将其替换为 A。
2.5 练习
-
通过创建一个包含一百万个数字的列表,并计时本章中定义的通用线性查找函数 linearContains()和二分查找函数 binaryContains()查找列表中各种数字所需的时间,来展示二分查找相对于线性查找的性能优势。
-
在 dfs()、bfs()和 astar()函数中添加一个计数器,以查看每个函数在相同迷宫中搜索了多少个状态。找到 100 个不同迷宫的计数,以获得具有统计意义的成果。
-
找到不同起始传教士和食人族数量的传教士和食人族问题的解决方案。
-
关于启发式方法的更多信息,请参阅 Stuart Russell 和 Peter Norvig 的《人工智能:现代方法》第 3 版(Pearson,2010),第 94 页。
-
关于 A*路径查找的启发式方法,请参阅 Amit Patel 的《Amit 关于路径查找的思考》中的“启发式”章节,http://mng.bz/z7O4。
3 约束满足问题
许多使用计算工具解决的问题可以广泛地归类为约束满足问题(CSPs)。CSPs 由具有可能值的变量组成,这些值落在称为域的范围之内。为了解决约束满足问题,变量之间的约束必须得到满足。这三个核心概念——变量、域和约束——易于理解,它们的通用性是约束满足问题求解广泛适用的基础。
让我们考虑一个示例问题。假设你正在尝试为 Joe、Mary 和 Sue 安排一个周五的会议。Sue 必须至少与另一个人一起参加会议。对于这个调度问题,三个人——Joe、Mary 和 Sue——可能是变量。每个变量的域可能是他们各自的可用时间。例如,变量 Mary 的域是下午 2 点、下午 3 点和下午 4 点。这个问题也有两个约束。一个是 Sue 必须参加会议。另一个是至少有两个人必须参加会议。约束满足问题求解器将提供三个变量、三个域和两个约束,然后它将解决问题,而不需要用户解释确切的方法。图 3.1 说明了这个例子。
类似于 Prolog 和 Picat 这样的编程语言内置了解决约束满足问题的功能。在其他语言中,通常的技术是构建一个框架,该框架包含回溯搜索和几个启发式算法来提高搜索性能。在本章中,我们将首先构建一个用于 CSPs 的框架,该框架使用简单的递归回溯搜索来解决问题。然后我们将使用该框架来解决几个不同的示例问题。

图 3.1 调度问题是约束满足框架的经典应用。
3.1 构建约束满足问题框架
约束将被定义为 Constraint 类的子类。每个约束由它约束的变量和一个检查它是否满足()的方法组成。约束是否满足的确定是定义特定约束满足问题的主要逻辑。
默认实现应该被覆盖。实际上,它必须被覆盖,因为我们正在将我们的 Constraint 类定义为抽象基类。抽象基类不应该被实例化。相反,只有它们的子类,这些子类覆盖并实现了它们的抽象方法,才是实际使用的。
列表 3.1 Constraint.java
package chapter3;
import java.util.List;
import java.util.Map;
// V is the variable type, and D is the domain type
public abstract class Constraint<V, D> {
// the variables that the constraint is between
protected List<V> variables;
public Constraint(List<V> variables) {
this.variables = variables;
}
public abstract boolean satisfied(Map<V, D> assignment);
}
TIP 在 Java 中,选择抽象类和接口可能会有困难。只有抽象类可以有实例变量。由于我们有实例变量,所以我们在这里选择了抽象类。
我们约束满足框架的核心将是一个名为 CSP 的类。CSP 是变量、域和约束的汇集点。它使用泛型使其足够灵活,可以与任何类型的变量和域值(V 键和 D 域值)一起工作。在 CSP 内部,变量、域和约束集合的类型是你所期望的。变量集合是一个变量列表,域是一个将变量映射到可能值列表(这些变量的域)的 Map,约束是一个将每个变量映射到对其施加的约束列表的 Map。
列表 3.2 CSP.java
package chapter3;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class CSP<V, D> {
private List<V> variables;
private Map<V, List<D>> domains;
private Map<V, List<Constraint<V, D>>> constraints = new HashMap<>();
public CSP(List<V> variables, Map<V, List<D>> domains) {
this.variables = variables;
this.domains = domains;
for (V variable : variables) {
constraints.put(variable, new ArrayList<>());
if (!domains.containsKey(variable)) {
throw new IllegalArgumentException("Every variable should have a domain assigned to it.");
}
}
}
public void addConstraint(Constraint<V, D> constraint) {
for (V variable : constraint.variables) {
if (!variables.contains(variable)) {
throw new IllegalArgumentException("Variable in constraint not in CSP");
}
constraints.get(variable).add(constraint);
}
}
构造函数创建约束 Map。addConstraint()方法遍历给定约束影响的每个变量,并将自身添加到每个变量的约束映射中。这两个方法都有基本的错误检查,当变量缺少域或约束在不存在变量上时,将引发异常。
我们如何知道给定的变量配置和选定的域值配置是否满足约束?我们将这样的给定配置称为赋值。换句话说,赋值是每个变量选定的特定域值。我们需要一个函数来检查给定变量的每个约束与赋值,以查看赋值中的变量值是否与约束一致。在这里,我们实现了一个 consistent()函数作为 CSP 的方法。
列表 3.3 CSP.java 继续
// Check if the value assignment is consistent by checking all
// constraints for the given variable against it
public boolean consistent(V variable, Map<V, D> assignment) {
for (Constraint<V, D> constraint : constraints.get(variable)) {
if (!constraint.satisfied(assignment)) {
return false;
}
}
return true;
}
consistent()遍历给定变量的每个约束(它将始终是刚刚添加到赋值中的变量)并检查在新的赋值下约束是否得到满足。如果赋值满足每个约束,则返回 true。如果对变量的任何约束没有得到满足,则返回 false。
这个约束满足框架将使用简单的回溯搜索来找到问题的解决方案。回溯的思想是,一旦你在搜索中遇到障碍,你就回到最后一个已知点,在那里你在障碍之前做出了决定,然后你选择一条不同的路径。如果你认为这听起来像第二章中的深度优先搜索,你很敏锐。在下面的 backtrackingSearch()方法中实现的回溯搜索是一种递归深度优先搜索,结合了你在第一章和第二章中看到的思想。我们还实现了一个辅助方法,它只是调用带有空初始 Map 的 backtrackingSearch()。这个辅助方法将有助于开始搜索。
列表 3.4 CSP.java 继续
public Map<V, D> backtrackingSearch(Map<V, D> assignment) {
// assignment is complete if every variable is assigned (base case)
if (assignment.size() == variables.size()) {
return assignment;
}
// get the first variable in the CSP but not in the assignment
V unassigned = variables.stream().filter(v ->
!assignment.containsKey(v)).findFirst().get();
// look through every domain value of the first unassigned variable
for (D value : domains.get(unassigned)) {
// shallow copy of assignment that we can change
Map<V, D> localAssignment = new HashMap<>(assignment);
localAssignment.put(unassigned, value);
// if we're still consistent, we recurse (continue)
if (consistent(unassigned, localAssignment)) {
Map<V, D> result = backtrackingSearch(localAssignment);
// if we didn't find the result, we end up backtracking
if (result != null) {
return result;
}
}
}
return null;
}
// helper for backtrackingSearch when nothing known yet
public Map<V, D> backtrackingSearch() {
return backtrackingSearch(new HashMap<>());
}
}
让我们逐行分析 backtrackingSearch():
if (assignment.size() == variables.size()) {
return assignment;
}
递归搜索的基本情况是找到了每个变量的有效赋值。一旦我们找到了,我们就返回第一个有效的解决方案实例。(我们不再继续搜索。)
V unassigned = variables.stream().filter(v ->
!assignment.containsKey(v)).findFirst().get();
为了选择一个我们将探索其域的新变量,我们只需遍历所有变量,找到第一个还没有分配的变量。为此,我们创建一个变量流,通过是否已分配来过滤变量,并使用 findFirst()提取第一个未分配的变量。filter()需要一个谓词。谓词是一个描述接受一个参数并返回一个布尔值的函数的功能接口。我们的谓词是一个 lambda 表达式(v -> !assignment.containsKey(v)),如果 assignment 不包含参数,它将返回 true,在这种情况下,它将是我们的 CSP 中的一个变量。
for (D value : domains.get(unassigned)) {
Map<V, D> localAssignment = new HashMap<>(assignment);
localAssignment.put(unassigned, value);
我们尝试为该变量分配所有可能的域值,一次一个。每个新分配的结果都存储在一个名为 localAssignment 的本地映射中。
if (consistent(unassigned, localAssignment)) {
Map<V, D> result = backtrackingSearch(localAssignment);
if (result != null) {
return result;
}
}
如果在 localAssignment 中的新分配与所有约束一致(这是 consistent()检查的内容),我们则继续递归搜索,使用新分配的值。如果新分配最终被证明是完整的(基本案例),我们将新分配返回到递归链。
return null;
最后,如果我们已经遍历了特定变量的所有可能的域值,并且没有利用现有分配集的解决方案,我们返回 null,表示没有解决方案。这将导致回溯到递归链的某个点,在那个点上可能做出了不同的先前分配。
3.2 澳大利亚地图着色问题
想象一下,你有一张澳大利亚地图,你想按州/领地(我们将它们统称为区域)进行着色。相邻的区域不应共享相同的颜色。你能只用三种不同的颜色来着色这些区域吗?
答案是肯定的。自己试一试。(最简单的方法是打印一张澳大利亚地图,背景为白色。)作为人类,我们可以通过检查和一点试错快速找到解决方案。这实际上是一个简单的问题,也是我们回溯约束满足求解器的第一个好问题。问题的一个解决方案如图 3.2 所示。

图 3.2 在澳大利亚地图着色问题的解决方案中,澳大利亚的相邻部分不能着色相同的颜色。
要将问题建模为 CSP,我们需要定义变量、域和约束。变量是澳大利亚的七个区域(至少是我们将限制自己的七个区域):西澳大利亚、北领地、南澳大利亚、昆士兰州、新南威尔士州、维多利亚州和塔斯马尼亚州。在我们的 CSP 中,它们可以用字符串来建模。每个变量的域是可以分配的三种不同颜色。(我们将使用红色、绿色和蓝色。)约束是难点。相邻的两个区域不能使用相同的颜色,因此我们的约束将取决于哪些区域相邻。我们可以使用所谓的二元约束(两个变量之间的约束)。共享边界的每个区域都将共享一个二元约束,表示它们不能分配相同的颜色。
要在代码中实现这些二元约束,我们需要对约束类进行子类化。MapColoringConstraint 子类将在其构造函数中接受两个变量:共享边界的两个区域。它重写的 satisfied()方法将首先检查这两个区域是否分配了域值(颜色);如果任何一个没有分配,则约束在它们分配之前 trivially 满足。(如果一个还没有颜色,则不可能存在冲突。)然后它将检查这两个区域是否分配了相同的颜色。显然,如果它们分配了相同的颜色,则存在冲突,这意味着约束不满足。
这里展示了类的全部内容,除了其 main()驱动程序。MapColoringConstraint 本身不是泛型的,但它子类化了一个泛型类 Constraint 的参数化版本,该版本指示变量和域都是 String 类型。
列表 3.5 MapColoringConstraint.java
package chapter3;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class MapColoringConstraint extends Constraint<String, String> {
private String place1, place2;
public MapColoringConstraint(String place1, String place2) {
super(List.*of*(place1, place2));
this.place1 = place1;
this.place2 = place2;
}
@Override
public boolean satisfied(Map<String, String> assignment) {
// if either place is not in the assignment, then it is not
// yet possible for their colors to be conflicting
if (!assignment.containsKey(place1) ||
!assignment.containsKey(place2)) {
return true;
}
// check the color assigned to place1 is not the same as the
// color assigned to place2
return !assignment.get(place1).equals(assignment.get(place2));
}
现在我们有了实现区域之间约束的方法,使用我们的 CSP 求解器完善澳大利亚地图着色问题只是一个填充域和变量,然后添加约束的问题。
列表 3.6 MapColoringConstraint.java 继续
public static void main(String[] args) {
List<String> variables = List.*of*("Western Australia", "Northern Territory", "South Australia", "Queensland", "New South Wales",
"Victoria", "Tasmania");
Map<String, List<String>> domains = new HashMap<>();
for (String variable : variables) {
domains.put(variable, List.*of*("red", "green", "blue"));
}
CSP<String, String> csp = new CSP<>(variables, domains);
csp.addConstraint(new MapColoringConstraint("Western Australia", "Northern Territory"));
csp.addConstraint(new MapColoringConstraint("Western Australia", "South Australia"));
csp.addConstraint(new MapColoringConstraint("South Australia", "Northern Territory"));
csp.addConstraint(new MapColoringConstraint("Queensland", "Northern Territory"));
csp.addConstraint(new MapColoringConstraint("Queensland", "South Australia"));
csp.addConstraint(new MapColoringConstraint("Queensland", "New South Wales"));
csp.addConstraint(new MapColoringConstraint("New South Wales", "South Australia"));
csp.addConstraint(new MapColoringConstraint("Victoria", "South Australia"));
csp.addConstraint(new MapColoringConstraint("Victoria", "New South Wales"));
csp.addConstraint(new MapColoringConstraint("Victoria", "Tasmania"));
最后,调用 backtrackingSearch()来寻找解决方案。
列表 3.7 MapColoringConstraint.java 继续
Map<String, String> solution = csp.backtrackingSearch();
if (solution == null) {
System.out.println("No solution found!");
} else {
System.out.println(solution);
}
}
}
一个正确的解决方案将包括为每个区域分配的颜色:
{Western Australia=red, New South Wales=green, Victoria=red, Tasmania=green, Northern Territory=green, South Australia=blue, Queensland=red}
3.3 八皇后问题
象棋盘是一个由八个方格组成的八乘八网格。皇后是一种可以在棋盘上沿任何行、列或对角线移动任意数量的方格的棋子。如果皇后在一次移动中可以移动到另一个棋子所在的方格,而不跳过任何其他棋子,则它攻击另一个棋子。(换句话说,如果另一个棋子在皇后的视线范围内,则它被攻击。)八皇后问题提出了如何在棋盘上放置八个皇后,使得没有皇后攻击另一个皇后的疑问。该问题的许多潜在解决方案之一如图 3.3 所示。
为了表示棋盘上的方格,我们将为每个方格分配一个整数行和一个整数列。我们可以通过简单地按顺序将列 1 到 8 分配给每个皇后来确保八个皇后不在同一列。我们的约束满足问题中的变量可以是所讨论的皇后的列。域可以是可能的行(再次强调,1 到 8)。列表 3.8 展示了我们的文件末尾,其中我们定义了这些变量和域。

图 3.3 在八皇后问题的解决方案中(有多个解决方案),没有两个皇后会相互威胁。
列表 3.8 QueensConstraint.java
public static void main(String[] args) {
List<Integer> columns = List.*of*(1, 2, 3, 4, 5, 6, 7, 8);
Map<Integer, List<Integer>> rows = new HashMap<>();
for (int column : columns) {
rows.put(column, List.*of*(1, 2, 3, 4, 5, 6, 7, 8));
}
CSP<Integer, Integer> csp = new CSP<>(columns, rows);
为了解决问题,我们需要一个约束来检查是否有任何两个皇后在同一行或对角线上。(它们最初都被分配了不同的连续列。)检查同一行是显而易见的,但检查同一对角线需要一点数学知识。如果任何两个皇后在同一对角线上,它们的行之间的差将等于它们的列之间的差。你能在 QueensConstraint 中看到这些检查在哪里进行吗?请注意,以下代码位于我们的源文件顶部。
列表 3.9 QueensConstraint.java 续
package chapter3;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
public class QueensConstraint extends Constraint<Integer, Integer> {
private List<Integer> columns;
public QueensConstraint(List<Integer> columns) {
super(columns);
this.columns = columns;
}
@Override
public boolean satisfied(Map<Integer, Integer> assignment) {
for (Entry<Integer, Integer> item : assignment.entrySet()) {
// q1c = queen 1 column, q1r = queen 1 row
int q1c = item.getKey();
int q1r = item.getValue();
// q2c = queen 2 column
for (int q2c = q1c + 1; q2c <= columns.size(); q2c++) {
if (assignment.containsKey(q2c)) {
// q2r = queen 2 row
int q2r = assignment.get(q2c);
// same row?
if (q1r == q2r) {
return false;
}
// same diagonal?
if (Math.*abs*(q1r - q2r) == Math.*abs*(q1c - q2c)) {
return false;
}
}
}
}
return true; // no conflict
}
剩下的只是添加约束并运行搜索。我们现在回到了文件底部的 main() 函数末尾。
列表 3.10 QueensConstraint.java 续
csp.addConstraint(new QueensConstraint(columns));
Map<Integer, Integer> solution = csp.backtrackingSearch();
if (solution == null) {
System.out.println("No solution found!");
} else {
System.out.println(solution);
}
}
}
注意,我们能够相当容易地重用我们为地图着色构建的约束满足问题解决框架来解决一个完全不同类型的问题。这就是编写通用代码的力量!除非特定应用需要性能优化而需要专业化,否则算法应该以尽可能广泛适用的方式实现。
一个正确的解决方案将为每个皇后分配一个列和行:
{1=1, 2=5, 3=8, 4=6, 5=3, 6=7, 7=2, 8=4}
3.4 单词搜索
单词搜索是一种字母网格,隐藏的单词沿行、列和对角线放置。一个单词搜索谜题的玩家试图通过仔细扫描网格来寻找隐藏的单词。找到放置单词的位置,使它们都能适应网格,是一种约束满足问题。变量是单词,域是这些单词的可能位置。本节的目标是生成一个单词搜索谜题,而不是解决一个。

图 3.4 一个经典的单词搜索,你可能在儿童谜题书中找到
为了方便起见,我们的单词搜索将不包括重叠的单词。你可以作为一个练习将其改进为允许重叠的单词。
这个单词搜索问题的网格与第二章中的迷宫并不完全不同。以下的一些数据类型应该看起来很熟悉。WordGrid 类似于 Maze,GridLocation 类似于 MazeLocation。
列表 3.11 WordGrid.java
package chapter3;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class WordGrid {
public static class GridLocation {
public final int row, column;
public GridLocation(int row, int column) {
this.row = row;
this.column = column;
}
// auto-generated by Eclipse
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + column;
result = prime * result + row;
return result;
}
// auto-generated by Eclipse
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
GridLocation other = (GridLocation) obj;
if (column != other.column) {
return false;
}
if (row != other.row) {
return false;
}
return true;
}
}
初始时,我们将使用随机英文字母(A-Z)填充网格。我们通过生成与字母在 ASCII 中的位置相对应的随机字符码(整数)来实现这一点。我们还需要一个方法来根据位置列表在网格上标记一个单词,以及一个用于显示网格的方法。
列表 3.12 WordGrid.java 继续内容
private final char ALPHABET_LENGTH = 26;
private final char FIRST_LETTER = 'A';
private final int rows, columns;
private char[][] grid;
public WordGrid(int rows, int columns) {
this.rows = rows;
this.columns = columns;
grid = new char[rows][columns];
// initialize grid with random letters
Random random = new Random();
for (int row = 0; row < rows; row++) {
for (int column = 0; column < columns; column++) {
char randomLetter = (char) (random.nextInt(ALPHABET_LENGTH) + FIRST_LETTER);
grid[row][column] = randomLetter;
}
}
}
public void mark(String word, List<GridLocation> locations) {
for (int i = 0; i < word.length(); i++) {
GridLocation location = locations.get(i);
grid[location.row][location.column] = word.charAt(i);
}
}
// get a pretty printed version of the grid
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (char[] rowArray : grid) {
sb.append(rowArray);
sb.append(System.*lineSeparator*());
}
return sb.toString();
}
为了确定单词在网格中的位置,我们将生成它们的域。一个单词的域是一个列表的列表,包含所有字母的可能位置(List<List
列表 3.13 WordGrid.java 继续内容
public List<List<GridLocation>> generateDomain(String word) {
List<List<GridLocation>> domain = new ArrayList<>();
int length = word.length();
for (int row = 0; row < rows; row++) {
for (int column = 0; column < columns; column++) {
if (column + length <= columns) {
// left to right
fillRight(domain, row, column, length);
// diagonal towards bottom right
if (row + length <= rows) {
fillDiagonalRight(domain, row, column, length);
}
}
if (row + length <= rows) {
// top to bottom
fillDown(domain, row, column, length);
// diagonal towards bottom left
if (column - length >= 0) {
fillDiagonalLeft(domain, row, column, length);
}
}
}
}
return domain;
}
private void fillRight(List<List<GridLocation>> domain, int row, int
column, int length) {
List<GridLocation> locations = new ArrayList<>();
for (int c = column; c < (column + length); c++) {
locations.add(new GridLocation(row, c));
}
domain.add(locations);
}
private void fillDiagonalRight(List<List<GridLocation>> domain, int row, int column, int length) {
List<GridLocation> locations = new ArrayList<>();
int r = row;
for (int c = column; c < (column + length); c++) {
locations.add(new GridLocation(r, c));
r++;
}
domain.add(locations);
}
private void fillDown(List<List<GridLocation>> domain, int row, int
column, int length) {
List<GridLocation> locations = new ArrayList<>();
for (int r = row; r < (row + length); r++) {
locations.add(new GridLocation(r, column));
}
domain.add(locations);
}
private void fillDiagonalLeft(List<List<GridLocation>> domain, int row, int column, int length) {
List<GridLocation> locations = new ArrayList<>();
int c = column;
for (int r = row; r < (row + length); r++) {
locations.add(new GridLocation(r, c));
c--;
}
domain.add(locations);
}
}
对于一个单词的可能位置范围(沿行、列或对角线),for 循环将范围转换成一系列 GridLocations。因为 generateDomain() 遍历每个单词从左上角到右下角的每个网格位置,所以它涉及大量的计算。你能想到一种更高效的方法吗?如果我们一次在循环中查看所有相同长度的单词会怎样?
要检查一个潜在解决方案是否有效,我们必须为单词搜索实现一个自定义约束。WordSearchConstraint 的 satisfied() 方法简单地检查一个单词提出的任何位置是否与另一个单词提出的任何位置相同。它是通过使用 Set 来实现的。将 List 转换为 Set 将删除所有重复项。如果从 List 转换为 Set 的项目比原始 List 中的项目少,这意味着原始 List 包含一些重复项。为了准备此检查的数据,我们将使用 flatMap() 将每个单词在任务中的多个位置子列表组合成一个更大的位置列表。
列表 3.14 WordSearchConstraint.java
package chapter3;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Random;
import java.util.Set;
import java.util.stream.Collectors;
import chapter3.WordGrid.GridLocation;
public class WordSearchConstraint extends Constraint<String, List<GridLocation>> {
public WordSearchConstraint(List<String> words) {
super(words);
}
@Override
public boolean satisfied(Map<String, List<GridLocation>> assignment) {
// combine all GridLocations into one giant List
List<GridLocation> allLocations = assignment.values().stream()
.flatMap(Collection::stream).collect(Collectors.*toList*());
// a set will eliminate duplicates using equals()
Set<GridLocation> allLocationsSet = new HashSet<>(allLocations);
// if there are any duplicate grid locations then there is an overlap
return allLocations.size() == allLocationsSet.size();
}
最后,我们准备运行它。对于此示例,我们有五个单词(在这个例子中是名字)在一个九乘九的网格中。我们得到的解决方案应该包含每个单词与其字母在网格中可以放置的位置之间的映射。
列表 3.15 WordSearchConstraint.java 继续内容
public static void main(String[] args) {
WordGrid grid = new WordGrid(9, 9);
List<String> words = List.*of*("MATTHEW", "JOE", "MARY", "SARAH", "SALLY");
// generate domains for all words
Map<String, List<List<GridLocation>>> domains = new HashMap<>();
for (String word : words) {
domains.put(word, grid.generateDomain(word));
}
CSP<String, List<GridLocation>> csp = new CSP<>(words, domains);
csp.addConstraint(new WordSearchConstraint(words));
Map<String, List<GridLocation>> solution = csp.backtrackingSearch();
if (solution == null) {
System.out.println("No solution found!");
} else {
Random random = new Random();
for (Entry<String, List<GridLocation>> item : solution.entrySet()) {
String word = item.getKey();
List<GridLocation> locations = item.getValue();
// random reverse half the time
if (random.nextBoolean()) {
Collections.*reverse*(locations);
}
grid.mark(word, locations);
}
System.out.println(grid);
}
}
}
在代码中有一个填充网格的单词的细节。一些单词被随机选择为反向。这是有效的,因为此示例不允许单词重叠。你的最终输出应该类似于以下内容。你能找到 Matthew、Joe、Mary、Sarah 和 Sally 吗?
LWEHTTAMJ
MARYLISGO
DKOJYHAYE
IAJYHALAG
GYZJWRLGM
LLOTCAYIX
PEUTUSLKO
AJZYGIKDU
HSLZOFNNR
3.5 SEND+MORE=MONEY
SEND+MORE=MONEY 是一个密码学难题,意味着它关于找到数字来替换字母,使得一个数学陈述成立。问题中的每个字母代表一个数字(0-9)。没有两个字母可以代表相同的数字。当一个字母重复时,意味着解决方案中的数字也重复。
要手动解决这个谜题,排列单词很有帮助。
SEND
+MORE
=MONEY
这完全可以通过手动解决,只需一点代数和直觉。但一个相当简单的计算机程序可以通过穷举许多可能的解决方案来更快地解决它。让我们将 SEND+MORE=MONEY 表示为一个约束满足问题。
列表 3.16 SendMoreMoneyConstraint.java
package chapter3;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
public class SendMoreMoneyConstraint extends Constraint<Character, Integer> {
private List<Character> letters;
public SendMoreMoneyConstraint(List<Character> letters) {
super(letters);
this.letters = letters;
}
@Override
public boolean satisfied(Map<Character, Integer> assignment) {
// if there are duplicate values then it's not a solution
if ((new HashSet<>(assignment.values())).size() < assignment.size()) {
return false;
}
// if all variables have been assigned, check if it adds correctly
if (assignment.size() == letters.size()) {
int s = assignment.get('S');
int e = assignment.get('E');
int n = assignment.get('N');
int d = assignment.get('D');
int m = assignment.get('M');
int o = assignment.get('O');
int r = assignment.get('R');
int y = assignment.get('Y');
int send = s * 1000 + e * 100 + n * 10 + d;
int more = m * 1000 + o * 100 + r * 10 + e;
int money = m * 10000 + o * 1000 + n * 100 + e * 10 + y;
return send + more == money;
}
return true; // no conflicts
}
SendMoreMoneyConstraint 的 satisfied() 方法做了几件事情。首先,它检查多个字母是否代表相同的数字。如果是,则这是一个无效的解决方案,并返回 false。接下来,它检查是否所有字母都已分配。如果已经分配,它检查给定的分配是否使公式(SEND+MORE=MONEY)正确。如果是,则找到了解决方案,并返回 true。否则,它返回 false。最后,如果所有字母尚未分配,它返回 true。这是为了确保部分解决方案继续被处理。
让我们尝试运行它。
列表 3.17 SendMoreMoneyConstraint.java 继续阅读
public static void main(String[] args) {
List<Character> letters = List.*of*('S', 'E', 'N', 'D', 'M', 'O', 'R', 'Y');
Map<Character, List<Integer>> possibleDigits = new HashMap<>();
for (Character letter : letters) {
possibleDigits.put(letter, List.*of*(0, 1, 2, 3, 4, 5, 6, 7, 8, 9));
}
// so we don't get answers starting with a 0
possibleDigits.replace('M', List.*of*(1));
CSP<Character, Integer> csp = new CSP<>(letters, possibleDigits);
csp.addConstraint(new SendMoreMoneyConstraint(letters));
Map<Character, Integer> solution = csp.backtrackingSearch();
if (solution == null) {
System.out.println("No solution found!");
} else {
System.out.println(solution);
}
}
}
你会注意到我们预先分配了字母 M 的答案。这是为了确保答案中不包含 M 的 0,因为如果你想想,我们的约束没有关于一个数字不能以零开头的概念。你可以自由尝试不使用预先分配的答案。
解决方案应该看起来像这样:
{R=8, S=9, D=7, E=5, Y=2, M=1, N=6, O=0}
3.6 电路板布局
制造商需要将某些矩形芯片安装到矩形电路板上。本质上,这个问题是询问,“如何将几个不同大小的矩形都紧密地放入另一个矩形中?”一个约束满足问题求解器可以找到解决方案。这个问题在图 3.5 中有说明。

图 3.5 电路板布局问题与单词搜索问题非常相似,但矩形宽度是可变的。
电路板布局问题类似于单词搜索问题。问题中不是 1 × N 的矩形(单词),而是 M × N 的矩形。就像在单词搜索问题中一样,矩形不能重叠。矩形不能放在对角线上,从这个意义上说,这个问题实际上比单词搜索问题简单。
亲自尝试将单词搜索解决方案重写以适应电路板布局。你可以重用大部分代码,包括网格的代码。
3.7 现实世界应用
如本章引言中提到的,约束满足问题求解器通常用于调度。几个人需要参加会议,他们是变量。域包括他们日历上的空闲时间。约束可能涉及会议所需的与会人员组合。
约束满足问题求解器也用于运动规划。想象一个需要放入管子内的机器人臂。它有约束(管子的墙壁),变量(关节),以及域(关节的可能运动)。
计算生物学中也有应用。你可以想象化学反应所需的分子之间的约束。当然,正如人工智能的常见应用一样,在游戏中也有应用。编写一个数独求解器是以下练习之一,但许多逻辑谜题都可以使用约束满足问题解决方法来解决。
在本章中,我们构建了一个简单的回溯、深度优先搜索、问题解决框架。但通过添加启发式方法(还记得 A*吗?)——这些可以辅助搜索过程的想法,它可以得到极大的改进。一种比回溯更新的技术,称为约束传播,也是现实应用中有效的方法之一。更多信息,请参阅 Stuart Russell 和 Peter Norvig 的《人工智能:现代方法》第三版(Pearson,2010 年)第六章。
本书构建的简单示例框架不适用于生产环境。如果你需要用 Java 解决更复杂的问题,你可以考虑使用 Choco 框架,可在 https://choco-solver.org 找到。
3.8 练习
-
修改 WordSearchConstraint,以便允许重叠字母。
-
如果还没有的话,构建第 3.6 节中描述的电路板布局问题求解器。
-
编写一个程序,使用本章的约束满足问题解决框架来解决数独问题。
4 图问题
图是一种抽象的数学结构,通过将问题划分为一组连接的节点来模拟现实世界的问题。我们称每个节点为一个顶点,每个连接为一个边。例如,地铁图可以被视为表示交通网络的图。每个点代表一个车站,每条线代表两个车站之间的路线。在图术语中,我们会称车站为“顶点”,路线为“边”。
这有什么用呢?图不仅帮助我们抽象地思考问题,而且让我们能够应用几种被广泛理解和性能良好的搜索和优化技术。例如,在地铁示例中,假设我们想知道从一个车站到另一个车站的最短路线。或者假设我们想知道连接所有车站所需的最小轨道量。本章中你将学习的图算法可以解决这两个问题。此外,图算法可以应用于任何类型的网络问题——不仅仅是交通网络。想想计算机网络、分销网络和公用事业网络。所有这些空间中的搜索和优化问题都可以使用图算法来解决。
4.1 地图作为图
在本章中,我们将处理一个图,不是地铁车站,而是美国的城市及其之间的潜在路线。图 4.1 是大陆美国的地图,以及美国人口普查局估计的该国前 15 个最大的都会统计区(MSA)。1

图 4.1 美国前 15 大都会统计区(MSA)的地图
著名企业家埃隆·马斯克曾建议建立一个由胶囊在加压管道中旅行的新的高速交通网络。据马斯克所说,这些胶囊将以每小时 700 英里的速度行驶,适合于 900 英里以内的城市之间的成本效益交通。2 他将这个新的交通系统称为“超级高铁”。在本章中,我们将探讨在构建这个交通网络背景下的经典图问题。
马斯克最初提出超级高铁的想法是为了连接洛杉矶和旧金山。如果建立一个全国性的超级高铁网络,那么在美洲最大的都会区之间进行建设是有意义的。在图 4.2 中,图 4.1 中的州轮廓被移除。此外,每个 MSA 都与一些邻居相连。为了使图更有趣,这些邻居不总是 MSA 最近的邻居。
图 4.2 是一个图,顶点代表美国的 15 个最大都会统计区,边代表城市之间的潜在超级高铁路线。这些路线是为了说明目的而选择的。当然,其他潜在的路线也可能成为新的超级高铁网络的一部分。

图 4.2 一个图,顶点代表美国最大的 15 个 MSA,边代表它们之间的潜在 Hyperloop 路线
这种对现实世界问题的抽象表示突出了图的力量。通过这种抽象,我们可以忽略美国的地理,只需在连接城市的背景下思考潜在的 Hyperloop 网络。事实上,只要我们保持边不变,我们就可以用不同外观的图来思考这个问题。例如,在图 4.3 中,迈阿密的地理位置已移动。图 4.3 作为一个抽象表示,可以解决与图 4.2 相同的根本计算问题,即使迈阿密不在我们预期的位置。但为了我们的理智,我们将坚持图 4.2 中的表示。

图 4.3 与图 4.2 中相同的图,但迈阿密的地理位置已移动
4.2 构建图框架
在本节中,我们将定义两种不同类型的图:无向图和有向图。有向图,我们将在本章后面讨论,将一个权重(读作数字,例如在我们的例子中是一个长度)与每条边关联。
在本质上,Java 是一种面向对象的编程语言。我们将利用 Java 面向对象类层次结构的基本继承模型,这样我们就不需要重复我们的努力。无向图和有向图的类都将从称为 Graph 的抽象基类派生。这将使它们继承大部分功能,并对使有向图与无向图区分开来的特性进行小的调整。
我们希望这个图框架尽可能灵活,以便它可以表示尽可能多的不同问题。为了实现这个目标,我们将使用泛型来抽象顶点的类型。每个顶点最终都将被分配一个整数索引,但它将以用户定义的泛型类型存储。
让我们从定义 Edge 类开始构建框架,这是我们的图框架中最简单的机制。
列表 4.1 Edge.java
package chapter4;
public class Edge {
public final int u; // the "from" vertex
public final int v; // the "to" vertex
public Edge(int u, int v) {
this.u = u;
this.v = v;
}
public Edge reversed() {
return new Edge(v, u);
}
@Override
public String toString() {
return u + " -> " + v;
}
}
边被定义为两个顶点之间的连接,每个顶点都由一个整数索引表示。按照惯例,u 用于指代第一个顶点,v 用于表示第二个顶点。你也可以把 u 看作“从”,v 看作“到”。在本章中,我们只处理无向图(允许双向旅行的边),但在有向图,也称为有向图(digraphs)中,边也可以是单向的。reversed()方法旨在返回一个与应用于其上的边相反方向的边。
图的抽象类关注图的基本角色:将顶点与边关联起来。再次强调,我们希望让框架的使用者能够定义顶点的实际类型。我们通过使顶点类型泛型(V)来实现这一点。这使得框架能够用于广泛的领域,而无需创建将所有内容粘合在一起的中介数据结构。例如,在 Hyperloop 路线的图中,我们可能会将顶点的类型定义为 String,因为我们可能会使用像“纽约”和“洛杉矶”这样的字符串作为顶点。图中边的类型(E)也是泛型的,因此子类可以将其设置为无权边或有权边类型。让我们开始介绍 Graph 类。
列表 4.2 Graph.java
package chapter4;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
// V is the type of the vertices in the Graph
// E is the type of the edges
public abstract class Graph<V, E extends Edge> {
private ArrayList<V> vertices = new ArrayList<>();
protected ArrayList<ArrayList<E>> edges = new ArrayList<>();
public Graph() {
}
public Graph(List<V> vertices) {
this.vertices.addAll(vertices);
for (V vertex : vertices) {
edges.add(new ArrayList<>());
}
}
顶点列表是图的核心。每个顶点都将存储在列表中,但我们将稍后通过列表中的整数索引来引用它们。顶点本身可能是一个复杂的数据类型,但它的索引始终是 int 类型,这很容易处理。在另一个层面上,通过在图算法和顶点列表之间放置这个索引,它允许我们在同一个图中有两个相等的顶点。(想象一个以一个国家的城市为顶点的图,这个国家有多个名为“斯普林菲尔德”的城市。)即使它们是相同的,它们也将有不同的整数索引。
实现图数据结构的方法有很多,但最常见的是使用顶点矩阵或邻接表。在顶点矩阵中,矩阵的每个单元格代表图中两个顶点的交集,该单元格的值表示它们之间的连接(或没有连接)。我们的图数据结构使用邻接表。在这种图表示中,每个顶点都有一个与它相连的顶点列表。我们特定的表示使用边列表的列表,因此对于每个顶点,都有一个通过它连接到其他顶点的边列表。这个列表就是边列表。
Graph 类的其余部分现在完整呈现。你会注意到使用了简短、大多为单行的方法,方法名称详细且清晰。这应该使得类的其余部分大部分可以自我解释,但仍然包含了简短的注释,以确保没有误解的空间。
列表 4.3 Graph.java 继续部分
// Number of vertices
public int getVertexCount() {
return vertices.size();
}
// Number of edges
public int getEdgeCount() {
return edges.stream().mapToInt(ArrayList::size).sum();
}
// Add a vertex to the graph and return its index
public int addVertex(V vertex) {
vertices.add(vertex);
edges.add(new ArrayList<>());
return getVertexCount() - 1;
}
// Find the vertex at a specific index
public V vertexAt(int index) {
return vertices.get(index);
}
// Find the index of a vertex in the graph
public int indexOf(V vertex) {
return vertices.indexOf(vertex);
}
// Find the vertices that a vertex at some index is connected to
public List<V> neighborsOf(int index) {
return edges.get(index).stream()
.map(edge -> vertexAt(edge.v))
.collect(Collectors.*toList*());
}
// Look up a vertex's index and find its neighbors (convenience method)
public List<V> neighborsOf(V vertex) {
return neighborsOf(indexOf(vertex));
}
// Return all of the edges associated with a vertex at some index
public List<E> edgesOf(int index) {
return edges.get(index);
}
// Look up the index of a vertex and return its edges (convenience method)
public List<E> edgesOf(V vertex) {
return edgesOf(indexOf(vertex));
}
// Make it easy to pretty-print a Graph
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < getVertexCount(); i++) {
sb.append(vertexAt(i));
sb.append(" -> ");
sb.append(Arrays.*toString*(neighborsOf(i).toArray()));
sb.append(System.*lineSeparator*());
}
return sb.toString();
}
}
让我们暂时退一步,考虑为什么这个类的大多数方法有两个版本。从类定义中我们知道,vertices 列表是类型 V 的元素列表,V 可以是任何 Java 类。因此,我们有存储在 vertices 列表中的类型为 V 的顶点。但如果我们想稍后检索或操作它们,我们需要知道它们在列表中的位置。因此,每个顶点在列表中都有一个与之关联的索引(一个整数)。如果我们不知道一个顶点的索引,我们需要通过在 vertices 中搜索来查找它。这就是为什么每个方法都有两个版本的原因。一个操作整数索引,另一个操作 V 本身。操作 V 的方法查找相关的索引并调用基于索引的函数。因此,它们可以被认为是便利方法。
大多数函数都是相当直观的,但 neighborsOf() 方法值得稍作解释。它返回一个顶点的邻居。一个顶点的邻居是所有通过边直接连接到它的其他顶点。例如,在图 4.2 中,纽约和华盛顿是费城的唯一邻居。我们通过查看从该顶点出发的所有边的末端(即 vs)来找到顶点的邻居:
public List<V> neighborsOf(int index) {
return edges.get(index).stream()
.map(edge -> vertexAt(edge.v))
.collect(Collectors.*toList*());
}
edges.get(index) 返回邻接表,即通过这些边连接到其他顶点的边的列表。在传递给 map() 调用的流中,edge 代表一条特定的边,而 edge.v 代表边连接到的邻居的索引。map() 将返回所有顶点(而不是仅仅它们的索引),因为 map() 在每个 edge.v 上应用 vertexAt() 方法。
现在我们已经在 Graph 抽象类中实现了图的基本功能,我们可以定义一个具体的子类。除了是无向或是有向之外,图还可以是无权或有权的。一个有权重的图是指其每条边都关联着某种可比较的值,通常是数值。我们可以将我们潜在的超环网络中的权重视为站点之间的距离。然而,目前我们将处理图的未加权版本。未加权边仅仅是两个顶点之间的连接;因此,Edge 类是无权重的。另一种说法是,在无权图中,我们知道哪些顶点是相连的,而在有权图中,我们知道哪些顶点是相连的,并且还知道一些关于这些连接的信息。UnweightedGraph 表示一个没有与边关联值的图。换句话说,它是 Graph 与我们定义的另一个类 Edge 的组合。
列表 4.4 UnweightedGraph.java
package chapter4;
import java.util.List;
import chapter2.GenericSearch;
import chapter2.GenericSearch.Node;
public class UnweightedGraph<V> extends Graph<V, Edge> {
public UnweightedGraph(List<V> vertices) {
super(vertices);
}
// This is an undirected graph, so we always add
// edges in both directions
public void addEdge(Edge edge) {
edges.get(edge.u).add(edge);
edges.get(edge.v).add(edge.reversed());
}
// Add an edge using vertex indices (convenience method)
public void addEdge(int u, int v) {
addEdge(new Edge(u, v));
}
// Add an edge by looking up vertex indices (convenience method)
public void addEdge(V first, V second) {
addEdge(new Edge(indexOf(first), indexOf(second)));
}
值得指出的一点是 addEdge()的工作方式。addEdge()首先将边添加到“from”顶点的邻接列表中,然后添加边的反向版本到“to”顶点的邻接列表中。第二步是必要的,因为此图是无向的。我们希望每条边都向两个方向添加;这意味着 u 将是 v 的邻居,就像 v 是 u 的邻居一样。如果你觉得这有助于记忆,你可以将无向图视为双向的。
public void addEdge(Edge edge) {
edges.get(edge.u).add(edge);
edges.get(edge.v).add(edge.reversed());
}
如前所述,我们本章只处理无向图。
4.2.1 使用 Edge 和 UnweightedGraph
现在我们已经有了 Edge 和 Graph 的具体实现,我们可以创建一个潜在 Hyperloop 网络的表示。cityGraph 中的顶点和边对应于图 4.2 中表示的顶点和边。使用泛型,我们可以指定顶点将是 String 类型(UnweightedGraph
列表 4.5 UnweightedGraph.java 的继续
public static void main(String[] args) {
// Represents the 15 largest MSAs in the United States
UnweightedGraph<String> cityGraph = new UnweightedGraph<>(
List.*of*("Seattle", "San Francisco", "Los Angeles", "Riverside", "Phoenix", "Chicago", "Boston", "New York", "Atlanta", "Miami", "Dallas", "Houston", "Detroit", "Philadelphia", "Washington"));
cityGraph.addEdge("Seattle", "Chicago");
cityGraph.addEdge("Seattle", "San Francisco");
cityGraph.addEdge("San Francisco", "Riverside");
cityGraph.addEdge("San Francisco", "Los Angeles");
cityGraph.addEdge("Los Angeles", "Riverside");
cityGraph.addEdge("Los Angeles", "Phoenix");
cityGraph.addEdge("Riverside", "Phoenix");
cityGraph.addEdge("Riverside", "Chicago");
cityGraph.addEdge("Phoenix", "Dallas");
cityGraph.addEdge("Phoenix", "Houston");
cityGraph.addEdge("Dallas", "Chicago");
cityGraph.addEdge("Dallas", "Atlanta");
cityGraph.addEdge("Dallas", "Houston");
cityGraph.addEdge("Houston", "Atlanta");
cityGraph.addEdge("Houston", "Miami");
cityGraph.addEdge("Atlanta", "Chicago");
cityGraph.addEdge("Atlanta", "Washington");
cityGraph.addEdge("Atlanta", "Miami");
cityGraph.addEdge("Miami", "Washington");
cityGraph.addEdge("Chicago", "Detroit");
cityGraph.addEdge("Detroit", "Boston");
cityGraph.addEdge("Detroit", "Washington");
cityGraph.addEdge("Detroit", "New York");
cityGraph.addEdge("Boston", "New York");
cityGraph.addEdge("New York", "Philadelphia");
cityGraph.addEdge("Philadelphia", "Washington");
System.out.println(cityGraph.toString());
}
}
cityGraph 的顶点是 String 类型,我们用代表该 MSA(大都市统计区)的名字来表示每个顶点。我们添加边到 cityGraph 的顺序无关紧要。因为我们实现了 toString(),它以一个漂亮的图形描述来打印,我们现在可以很好地打印(这是一个真正的术语!)图形。你应该得到类似以下内容的输出:
Seattle -> [Chicago, San Francisco]
San Francisco -> [Seattle, Riverside, Los Angeles]
Los Angeles -> [San Francisco, Riverside, Phoenix]
Riverside -> [San Francisco, Los Angeles, Phoenix, Chicago]
Phoenix -> [Los Angeles, Riverside, Dallas, Houston]
Chicago -> [Seattle, Riverside, Dallas, Atlanta, Detroit]
Boston -> [Detroit, New York]
New York -> [Detroit, Boston, Philadelphia]
Atlanta -> [Dallas, Houston, Chicago, Washington, Miami]
Miami -> [Houston, Atlanta, Washington]
Dallas -> [Phoenix, Chicago, Atlanta, Houston]
Houston -> [Phoenix, Dallas, Atlanta, Miami]
Detroit -> [Chicago, Boston, Washington, New York]
Philadelphia -> [New York, Washington]
Washington -> [Atlanta, Miami, Detroit, Philadelphia]
4.3 寻找最短路径
Hyperloop 的速度如此之快,以至于在优化从一个车站到另一个车站的旅行时间时,车站之间的距离可能不如需要多少跳(需要访问多少个车站)重要。每个车站可能涉及转机,所以就像航班一样,停靠站越少越好。
在图论中,连接两个顶点的边集被称为路径。换句话说,路径是从一个顶点到另一个顶点的一种方式。在 Hyperloop 网络的情况下,一组管道(边)代表从一个城市(顶点)到另一个(顶点)的路径。在顶点之间寻找最优路径是图应用中最常见的问题之一。
非正式地,我们也可以将通过边顺序连接的顶点列表视为一条路径。这种描述实际上只是同一枚硬币的另一面。这就像取一个边的列表,找出它们连接的顶点,保留这个顶点列表,然后丢弃边。在这个简短的例子中,我们将找到连接我们 Hyperloop 上两个城市的顶点列表。
4.3.1 重新审视广度优先搜索(BFS)
在无权图中,找到最短路径意味着找到起点顶点和目标顶点之间边数最少的路径。为了构建 Hyperloop 网络,首先连接高度人口密集的海岸线上的最远城市可能是有意义的。这提出了问题:“波士顿和迈阿密之间的最短路径是什么?”
提示:本节假设您已经阅读了第二章。在继续之前,请确保您对第二章中关于广度优先搜索的内容感到舒适。
幸运的是,我们已经有了一个用于找到最短路径的算法,并且我们可以重用它来回答这个问题。在第二章中引入的广度优先搜索(Breadth-first search)对于图来说,就像对于迷宫一样可行。实际上,我们在第二章中处理的迷宫实际上真的是图。顶点是迷宫中的位置,边是从一个位置移动到另一个位置的动作。在一个无权图中,广度优先搜索将找到任何两个顶点之间的最短路径。
我们可以重用第二章中的广度优先搜索实现来与图(Graph)一起工作。实际上,我们可以完全不变地重用它。这就是编写通用代码的力量!
回想一下,第二章中的 bfs()需要三个参数:一个初始状态、一个用于测试目标(即返回布尔值的函数)的谓词(Predicate),以及一个用于找到给定状态的后续状态的函数。初始状态将是表示字符串“波士顿”的顶点。目标测试将是一个检查顶点是否等同于“迈阿密”的 lambda 表达式。最后,可以通过 Graph 方法 neighborsOf()生成后续顶点。
在这个计划的基础上,我们可以在 UnweightedGraph.java 的主()方法末尾添加代码,以找到 cityGraph 上波士顿和迈阿密之间的最短路线。
注意:列表 4.4(本章中首次定义 UnweightedGraph 的地方)包含了支持本节(即,chapter2.GenericSearch,chapter2.genericSearch.Node)的导入。这些导入只有在 chapter2 包可以从 chapter4 包访问时才会工作。如果您没有以这种方式配置您的开发环境,您应该能够将 GenericSearch 类直接复制到 chapter4 包中,并消除导入。
列表 4.6 UnweightedGraph.java 续
Node<String> bfsResult = GenericSearch.*bfs*("Boston",
v -> v.equals("Miami"),
cityGraph::neighborsOf);
if (bfsResult == null) {
System.out.println("No solution found using breadth-first search!");
} else {
List<String> path = GenericSearch.*nodeToPath*(bfsResult);
System.out.println("Path from Boston to Miami:");
System.out.println(path);
}
输出应该看起来像这样:
Path from Boston to Miami:
[Boston, Detroit, Washington, Miami]
波士顿到底特律到华盛顿再到迈阿密,由三个边组成,这是从波士顿到迈阿密在边数意义上的最短路线。图 4.4 突出了这条路线。

图 4.4 以边数来衡量,波士顿和迈阿密之间的最短路线被突出显示。
4.4 最小化网络构建的成本
假设我们想要将所有 15 个最大的 MSA 连接到 Hyperloop 网络。我们的目标是使网络的推广成本最小化,这意味着使用最少的轨道。那么问题就是,“我们如何使用最少的轨道将所有 MSA 连接起来?”
4.4.1 处理权重
为了了解特定边可能需要的轨道数量,我们需要知道边所代表的距离。这是重新引入权重概念的机会。在 Hyperloop 网络中,边的权重是它连接的两个 MSA 之间的距离。图 4.5 与图 4.2 相同,只是它为每条边添加了一个权重,表示连接的两个顶点之间的距离(以英里为单位)。

图 4.5 展示了美国 15 个最大都市统计区(MSA)的加权图,其中每个权重代表两个 MSA 之间的距离(以英里为单位)
为了处理权重,我们需要 Edge 的子类(WeightedEdge)和 Graph 的子类(WeightedGraph)。每个 WeightedEdge 都将有一个与其关联的双精度浮点数,表示其权重。我们将在本章后面介绍的 Jarník 算法需要能够比较一条边与另一条边,以确定具有最低权重的边。使用数值权重来做这件事很容易。
列表 4.7 WeightedEdge.java
package chapter4;
public class WeightedEdge extends Edge implements Comparable<WeightedEdge> {
public final double weight;
public WeightedEdge(int u, int v, double weight) {
super(u, v);
this.weight = weight;
}
@Override
public WeightedEdge reversed() {
return new WeightedEdge(v, u, weight);
}
// so that we can order edges by weight to find the minimum weight edge
@Override
public int compareTo(WeightedEdge other) {
Double mine = weight;
Double theirs = other.weight;
return mine.compareTo(theirs);
}
@Override
public String toString() {
return u + " " + weight + "> " + v;
}
}
WeightedEdge 的实现与 Edge 的实现没有很大区别。它只是在添加了一个权重属性并通过 compareTo()方法实现 Comparable 接口,以便两个 WeightedEdge 可以进行比较。compareTo()方法只关注查看权重(而不是包括继承的属性 u 和 v),因为 Jarník 算法需要通过权重找到最小的边。
WeightedGraph 从 Graph 继承了大部分功能。除此之外,它有一个构造函数,它有添加 WeightedEdges 的便利方法,并实现了自己的 toString()版本。
列表 4.8 WeightedGraph.java
package chapter4;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.function.IntConsumer;
public class WeightedGraph<V> extends Graph<V, WeightedEdge> {
public WeightedGraph(List<V> vertices) {
super(vertices);
}
// This is an undirected graph, so we always add
// edges in both directions
public void addEdge(WeightedEdge edge) {
edges.get(edge.u).add(edge);
edges.get(edge.v).add(edge.reversed());
}
public void addEdge(int u, int v, float weight) {
addEdge(new WeightedEdge(u, v, weight));
}
public void addEdge(V first, V second, float weight) {
addEdge(indexOf(first), indexOf(second), weight);
}
// Make it easy to pretty-print a Graph
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < getVertexCount(); i++) {
sb.append(vertexAt(i));
sb.append(" -> ");
sb.append(Arrays.*toString*(edgesOf(i).stream()
.map(we -> "(" + vertexAt(we.v) + ", " + we.weight + ")").toArray()));
sb.append(System.*lineSeparator*());
}
return sb.toString();
}
现在可以实际定义加权图了。我们将要处理的加权图是图 4.5 的表示,称为 cityGraph2。
列表 4.9 WeightedGraph.java(续)
public static void main(String[] args) {
// Represents the 15 largest MSAs in the United States
WeightedGraph<String> cityGraph2 = new WeightedGraph<>(
List.*of*("Seattle", "San Francisco", "Los Angeles", "Riverside", "Phoenix", "Chicago", "Boston",
"New York", "Atlanta", "Miami", "Dallas", "Houston", "Detroit", "Philadelphia", "Washington"));
cityGraph2.addEdge("Seattle", "Chicago", 1737);
cityGraph2.addEdge("Seattle", "San Francisco", 678);
cityGraph2.addEdge("San Francisco", "Riverside", 386);
cityGraph2.addEdge("San Francisco", "Los Angeles", 348);
cityGraph2.addEdge("Los Angeles", "Riverside", 50);
cityGraph2.addEdge("Los Angeles", "Phoenix", 357);
cityGraph2.addEdge("Riverside", "Phoenix", 307);
cityGraph2.addEdge("Riverside", "Chicago", 1704);
cityGraph2.addEdge("Phoenix", "Dallas", 887);
cityGraph2.addEdge("Phoenix", "Houston", 1015);
cityGraph2.addEdge("Dallas", "Chicago", 805);
cityGraph2.addEdge("Dallas", "Atlanta", 721);
cityGraph2.addEdge("Dallas", "Houston", 225);
cityGraph2.addEdge("Houston", "Atlanta", 702);
cityGraph2.addEdge("Houston", "Miami", 968);
cityGraph2.addEdge("Atlanta", "Chicago", 588);
cityGraph2.addEdge("Atlanta", "Washington", 543);
cityGraph2.addEdge("Atlanta", "Miami", 604);
cityGraph2.addEdge("Miami", "Washington", 923);
cityGraph2.addEdge("Chicago", "Detroit", 238);
cityGraph2.addEdge("Detroit", "Boston", 613);
cityGraph2.addEdge("Detroit", "Washington", 396);
cityGraph2.addEdge("Detroit", "New York", 482);
cityGraph2.addEdge("Boston", "New York", 190);
cityGraph2.addEdge("New York", "Philadelphia", 81);
cityGraph2.addEdge("Philadelphia", "Washington", 123);
System.out.println(cityGraph2);
}
}
因为 WeightedGraph 实现了 toString(),所以我们可以格式化打印 cityGraph2。在输出中,您将看到每个顶点连接到的顶点以及这些连接的权重:
Seattle -> [(Chicago, 1737.0), (San Francisco, 678.0)]
San Francisco -> [(Seattle, 678.0), (Riverside, 386.0), (Los Angeles, 348.0)]
Los Angeles -> [(San Francisco, 348.0), (Riverside, 50.0), (Phoenix, 357.0)]
Riverside -> [(San Francisco, 386.0), (Los Angeles, 50.0), (Phoenix, 307.0), (Chicago, 1704.0)]
Phoenix -> [(Los Angeles, 357.0), (Riverside, 307.0), (Dallas, 887.0), (Houston, 1015.0)]
Chicago -> [(Seattle, 1737.0), (Riverside, 1704.0), (Dallas, 805.0), (Atlanta, 588.0), (Detroit, 238.0)]
Boston -> [(Detroit, 613.0), (New York, 190.0)]
New York -> [(Detroit, 482.0), (Boston, 190.0), (Philadelphia, 81.0)]
Atlanta -> [(Dallas, 721.0), (Houston, 702.0), (Chicago, 588.0), (Washington, 543.0), (Miami, 604.0)]
Miami -> [(Houston, 968.0), (Atlanta, 604.0), (Washington, 923.0)]
Dallas -> [(Phoenix, 887.0), (Chicago, 805.0), (Atlanta, 721.0), (Houston, 225.0)]
Houston -> [(Phoenix, 1015.0), (Dallas, 225.0), (Atlanta, 702.0), (Miami, 968.0)]
Detroit -> [(Chicago, 238.0), (Boston, 613.0), (Washington, 396.0), (New York, 482.0)]
Philadelphia -> [(New York, 81.0), (Washington, 123.0)]
Washington -> [(Atlanta, 543.0), (Miami, 923.0), (Detroit, 396.0), (Philadelphia, 123.0)]
4.4.2 寻找最小生成树
树是一种特殊的图,它在任何两个顶点之间只有一个路径。这意味着树中没有环(有时也称为无环)。环可以被视为一个循环:如果可以从一个起始顶点遍历图,不重复任何边,并回到相同的起始顶点,那么它就有环。任何不是树的连通图都可以通过剪枝变成树。图 4.6 说明了剪枝将图变成树的过程。

图 4.6 在左图中,顶点 B、C 和 D 之间存在一个循环,因此它不是一个树。在右图中,连接 C 和 D 的边已被修剪,因此该图是一个树。
一个连通图是一个有从任何顶点到任何其他顶点的方法的图。(我们本章中查看的所有图都是连通的。)一个生成树是一个连接图中每个顶点的树。一个最小生成树是一个连接加权图中每个顶点且总权重最小的树(与其他生成树相比)。对于每个连通加权图,都可以高效地找到其最小生成树。
呼——这有很多术语!重点是,寻找最小生成树与找到一种以最小权重连接加权图中每个顶点的方法相同。这对于任何设计网络(交通网络、计算机网络等)的人来说都是一个重要且实际的问题:如何以最低成本连接网络中的每个节点?这种成本可能是线缆、轨道、道路或其他任何东西。例如,对于电话网络,提出问题的另一种方式是,“连接每个电话需要多少最小长度的电缆?”
计算加权路径的总权重
在我们开发寻找最小生成树的方法之前,我们将开发一个函数,我们可以用它来测试解决方案的总权重。最小生成树问题的解决方案将包括构成树的加权边列表。为了我们的目的,我们将加权路径视为加权边列表。我们将定义一个 totalWeight() 方法,它接受一个加权边列表并找出所有边的权重相加后的总权重。请注意,这个方法以及本章中的其他方法将被添加到现有的 WeightedGraph 类中。
列表 4.10 WeightedGraph.java 续
public static double totalWeight(List<WeightedEdge> path) {
return path.stream().mapToDouble(we -> we.weight).sum();
}
Jarník 算法
Jarník 寻找最小生成树的算法通过将图分为两部分来实现:正在组装的最小生成树中的顶点和尚未包含在最小生成树中的顶点。它采取以下步骤:
-
选择一个任意顶点包含在最小生成树中。
-
找到连接最小生成树到尚未包含在最小生成树中的顶点的最低权重边。
-
将该最小边的末尾顶点添加到最小生成树中。
-
重复步骤 2 和 3,直到图中的每个顶点都在最小生成树中。
注意,Jarník 算法通常被称为 Prim 算法。两位捷克数学家,Otakar Boruška 和 Vojtěch Jarník,在 20 世纪 20 年代后期对铺设电线的成本最小化感兴趣,提出了求解最小生成树问题的算法。他们的算法在几十年后被其他人“重新发现”。3
为了高效运行 Jarník 算法,使用了一个优先队列(有关优先队列的更多信息,请参阅第二章)。每次将新顶点添加到最小生成树时,都会将其所有指向树外顶点的出边添加到优先队列中。总是从优先队列中弹出权重最低的边,算法持续执行,直到优先队列为空。这确保了权重最低的边总是首先添加到树中。当弹出时,连接到树中已存在顶点的边将被忽略。
以下 mst() 的代码是 Jarník 算法的完整实现,4 以及一个用于打印加权路径的实用函数。
警告:Jarník 算法在具有有向边的图中不一定能正确工作。它也不适用于不连通的图。
列表 4.11 WeightedGraph.java 继续如下
public List<WeightedEdge> mst(int start) {
LinkedList<WeightedEdge> result = new LinkedList<>(); // mst
if (start < 0 || start > (getVertexCount() - 1)) {
return result;
}
PriorityQueue<WeightedEdge> pq = new PriorityQueue<>();
boolean[] visited = new boolean[getVertexCount()]; // seen it
// this is like a "visit" inner function
IntConsumer visit = index -> {
visited[index] = true; // mark as visited
for (WeightedEdge edge : edgesOf(index)) {
// add all edges coming from here to pq
if (!visited[edge.v]) {
pq.offer(edge);
}
}
};
visit.accept(start); // the start vertex is where we begin
while (!pq.isEmpty()) { // keep going while there are edges
WeightedEdge edge = pq.poll();
if (visited[edge.v]) {
continue; // don't ever revisit
}
// this is the current smallest, so add it to solution
result.add(edge);
visit.accept(edge.v); // visit where this connects
}
return result;
}
public void printWeightedPath(List<WeightedEdge> wp) {
for (WeightedEdge edge : wp) {
System.out.println(vertexAt(edge.u) + " "
+ edge.weight + "> " + vertexAt(edge.v));
}
System.out.println("Total Weight: " + *totalWeight*(wp));
}
让我们逐行分析 mst():
public List<WeightedEdge> mst(int start) {
LinkedList<WeightedEdge> result = new LinkedList<>(); // mst
if (start < 0 || start > (getVertexCount() - 1)) {
return result;
}
算法返回一个表示最小生成树的加权路径(Listmst() 将返回一个空列表作为其结果。结果最终将包含包含最小生成树的加权路径。这就是我们将添加 WeightedEdges 的地方,因为权重最低的边被弹出并带我们到图的另一个部分。
PriorityQueue<WeightedEdge> pq = new PriorityQueue<>();
boolean[] visited = new boolean[getVertexCount()]; // seen it
Jarník 算法被认为是一种贪婪算法,因为它总是选择权重最低的边。pq 是存储新发现的边的地方,并弹出下一个最低权重的边。visited 跟踪我们已访问过的顶点索引。这也可以通过类似于 bfs() 中的 explored 使用 Set 来完成。
IntConsumer visit = index -> {
visited[index] = true; // mark as visited
for (WeightedEdge edge : edgesOf(index)) {
// add all edges coming from here to pq
if (!visited[edge.v]) {
pq.offer(edge);
}
}
};
visit 是一个内部便利函数,它将顶点标记为已访问,并将所有连接到尚未访问顶点的边添加到 pq 中。visit 被实现为 IntConsumer,它只是一个接受 int 作为唯一参数的 Function。在这种情况下,那个 int 将是待访问顶点的索引。注意邻接表模型如何简化找到特定顶点的边。
visit.accept(start); // the start vertex is where we begin
accept() 是 IntConsumer 方法,它会导致其关联的函数在提供 int 参数的情况下运行。除非图不连通,否则访问哪个顶点首先并不重要。如果图不连通,而是由不连通的组件组成,mst() 将返回一个包含起始顶点所属特定组件的树的树。
while (!pq.isEmpty()) { // keep going while there are edges
WeightedEdge edge = pq.poll();
if (visited[edge.v]) {
continue; // don't ever revisit
}
// this is the current smallest, so add it to solution
result.add(edge);
visit.accept(edge.v); // visit where this connects
}
return result;
当优先队列中仍有边缘时,我们将它们弹出并检查它们是否指向树中尚未出现的顶点。因为优先队列是升序的,所以它首先弹出权重最低的边缘。这确保了结果确实是总权重最小的。任何没有指向未探索顶点的弹出边缘都被忽略。否则,因为边缘是迄今为止看到的最低的,所以它被添加到结果集中,并且它指向的新顶点被探索。当没有更多边缘可探索时,返回结果。
正如承诺的那样,让我们回到通过 Hyperloop 连接美国最大的 15 个 MSA 的问题,使用最少的轨道。完成这一目标的路线仅仅是 cityGraph2 的最小生成树。让我们通过在 main()中添加代码来尝试在 cityGraph2 上运行 mst()。
列表 4.12 WeightedGraph.java 继续
List<WeightedEdge> mst = cityGraph2.mst(0);
cityGraph2.printWeightedPath(mst);
多亏了漂亮的打印方法 printWeightedPath(),最小生成树很容易阅读:
Seattle 678.0> San Francisco
San Francisco 348.0> Los Angeles
Los Angeles 50.0> Riverside
Riverside 307.0> Phoenix
Phoenix 887.0> Dallas
Dallas 225.0> Houston
Houston 702.0> Atlanta
Atlanta 543.0> Washington
Washington 123.0> Philadelphia
Philadelphia 81.0> New York
New York 190.0> Boston
Washington 396.0> Detroit
Detroit 238.0> Chicago
Atlanta 604.0> Miami
Total Weight: 5372.0
换句话说,这是连接加权图中所有 MSA 的累积最短边缘集合。连接所有这些 MSA 所需的轨道最小长度是 5,372 英里。图 4.7 说明了最小生成树。

图 4.7 高亮显示的边缘代表了一个连接所有 15 个 MSA 的最小生成树。
4.5 在加权图中寻找最短路径
随着 Hyperloop 网络的建造,建造者不太可能一开始就有连接整个国家的雄心。相反,建造者可能希望最小化铺设关键城市之间轨道的成本。将网络扩展到特定城市的成本显然取决于建造者的起始点。
从某个起始城市找到任何城市的成本是“单源最短路径”问题的一个版本。该问题询问,“在加权图中,从某个顶点到每个其他顶点的最短路径(以总边权重计)是什么?”
4.5.1 Dijkstra 算法
Dijkstra 算法解决单源最短路径问题。它提供了一个起始顶点,并返回加权图中任何其他顶点的最低权重路径。它还返回从起始顶点到每个其他顶点的最小总权重。Dijkstra 算法从单源顶点开始,然后不断探索距离起始顶点最近的顶点。因此,像 Jarník 算法一样,Dijkstra 算法是贪婪的。当 Dijkstra 算法遇到一个新顶点时,它会跟踪它距离起始顶点的距离,并在找到更短的路径时更新此值。它还会跟踪将每个顶点带到每个顶点的边。
这里是算法的所有步骤:
-
将起始顶点添加到优先队列中。
-
从优先队列中弹出最近的顶点(一开始这只是起始顶点);我们将它称为当前顶点。
-
查看连接到当前顶点的所有邻居。如果它们之前没有被记录,或者如果边提供了到达它们的更短路径,那么对于它们中的每一个,记录其从起点到距离,记录产生这个距离的边,并将新顶点添加到优先队列中。
-
重复步骤 2 和 3,直到优先队列为空。
-
返回从起始顶点到每个顶点的最短距离以及到达每个顶点的路径。
我们为 Dijkstra 算法编写的代码包括 DijkstraNode,这是一个简单的数据结构,用于跟踪到目前为止每个探索的顶点相关的成本,以及用于比较。这与第二章中的 Node 类类似。它还包括 DijkstraResult,这是一个用于配对算法计算的距离和路径的类。最后,它还包括将返回的距离数组转换为更容易通过顶点查找以及从 dijkstra() 返回的路径字典计算特定目标顶点最短路径的实用函数。
不再拖延,以下是 Dijkstra 算法的代码。我们将在之后逐行讲解。所有这些代码都再次在 WeightedGraph 中进行。
列表 4.13 WeightedGraph.java 继续显示
public static final class DijkstraNode implements Comparable<DijkstraNode> {
public final int vertex;
public final double distance;
public DijkstraNode(int vertex, double distance) {
this.vertex = vertex;
this.distance = distance;
}
@Override
public int compareTo(DijkstraNode other) {
Double mine = distance;
Double theirs = other.distance;
return mine.compareTo(theirs);
}
}
public static final class DijkstraResult {
public final double[] distances;
public final Map<Integer, WeightedEdge> pathMap;
public DijkstraResult(double[] distances, Map<Integer, WeightedEdge> pathMap) {
this.distances = distances;
this.pathMap = pathMap;
}
}
public DijkstraResult dijkstra(V root) {
int first = indexOf(root); // find starting index
// distances are unknown at first
double[] distances = new double[getVertexCount()];
distances[first] = 0; // root's distance to root is 0
boolean[] visited = new boolean[getVertexCount()];
visited[first] = true;
// how we got to each vertex
HashMap<Integer, WeightedEdge> pathMap = new HashMap<>();
PriorityQueue<DijkstraNode> pq = new PriorityQueue<>();
pq.offer(new DijkstraNode(first, 0));
while (!pq.isEmpty()) {
int u = pq.poll().vertex; // explore next closest vertex
double distU = distances[u]; // should already have seen
// look at every edge/vertex from the vertex in question
for (WeightedEdge we : edgesOf(u)) {
// the old distance to this vertex
double distV = distances[we.v];
// the new distance to this vertex
double pathWeight = we.weight + distU;
// new vertex or found shorter path?
if (!visited[we.v] || (distV > pathWeight)) {
visited[we.v] = true;
// update the distance to this vertex
distances[we.v] = pathWeight;
// update the edge on the shortest path
pathMap.put(we.v, we);
// explore it in the future
pq.offer(new DijkstraNode(we.v, pathWeight));
}
}
}
return new DijkstraResult(distances, pathMap);
}
// Helper function to get easier access to dijkstra results
public Map<V, Double> distanceArrayToDistanceMap(double[] distances) {
HashMap<V, Double> distanceMap = new HashMap<>();
for (int i = 0; i < distances.length; i++) {
distanceMap.put(vertexAt(i), distances[i]);
}
return distanceMap;
}
// Takes a map of edges to reach each node and returns a list of
// edges that goes from *start* to *end*
public static List<WeightedEdge> pathMapToPath(int start, int end, Map<Integer, WeightedEdge> pathMap) {
if (pathMap.size() == 0) {
return List.*of*();
}
LinkedList<WeightedEdge> path = new LinkedList<>();
WeightedEdge edge = pathMap.get(end);
path.add(edge);
while (edge.u != start) {
edge = pathMap.get(edge.u);
path.add(edge);
}
Collections.*reverse*(path);
return path;
}
dijkstra() 的前几行使用了您已经熟悉的数据结构,除了距离,它是从根到图中每个顶点的距离的占位符。最初,这些距离都是 0,因为我们还不知道它们有多远;这正是我们使用 Dijkstra 算法要弄清楚的事情!
public DijkstraResult dijkstra(V root) {
int first = indexOf(root); // find starting index
// distances are unknown at first
double[] distances = new double[getVertexCount()];
distances[first] = 0; // root's distance to root is 0
boolean[] visited = new boolean[getVertexCount()];
visited[first] = true;
// how we got to each vertex
HashMap<Integer, WeightedEdge> pathMap = new HashMap<>();
PriorityQueue<DijkstraNode> pq = new PriorityQueue<>();
pq.offer(new DijkstraNode(first, 0));
推送到优先队列的第一个节点包含根顶点。
while (!pq.isEmpty()) {
int u = pq.poll().vertex; // explore next closest vertex
double distU = distances[u]; // should already have seen
我们会一直运行 Dijkstra 算法,直到优先队列为空。u 是我们当前正在搜索的顶点,distU 是通过已知路线到达 u 的存储距离。在这个阶段探索的每个顶点都已经找到,因此它必须有一个已知的距离。
// look at every edge/vertex from the vertex in question
for (WeightedEdge we : edgesOf(u)) {
// the old distance to this vertex
double distV = distances[we.v];
// the new distance to this vertex
double pathWeight = we.weight + distU;
接下来,探索与 u 相连的每条边。distV 是通过从 u 连接的边到达的任何已知顶点的距离。pathWeight 是使用正在探索的新路线的距离。
// new vertex or found shorter path?
if (!visited[we.v] || (distV > pathWeight)) {
visited[we.v] = true;
// update the distance to this vertex
distances[we.v] = pathWeight;
// update the edge on the shortest path to this vertex
pathMap.put(we.v, we);
// explore it in the future
pq.offer(new DijkstraNode(we.v, pathWeight));
}
如果我们找到一个尚未探索的顶点 (!visited[we.v]),或者我们找到了到达该顶点的更短路径 (distV > pathWeight),我们将记录到达 v 的新最短距离以及到达那里的边。最后,我们将任何具有新路径的顶点推送到优先队列中。
return new DijkstraResult(distances, pathMap);
dijkstra() 返回从根顶点到加权图中每个顶点的距离以及可以解锁到达它们的最短路径的 pathMap。
现在运行 Dijkstra 算法是安全的。我们首先将找到洛杉矶到图中每个其他 MSA 的距离。然后我们将找到洛杉矶和波士顿之间的最短路径。最后,我们将使用 printWeightedPath() 来格式化打印结果。以下可以放入 main() 中。
列表 4.14 WeightedGraph.java 继续显示
System.out.println(); // spacing
DijkstraResult dijkstraResult = cityGraph2.dijkstra("Los Angeles");
Map<String, Double> nameDistance = cityGraph2.distanceArrayToDistanceMap(dijkstraResult.distances);
System.out.println("Distances from Los Angeles:");
nameDistance.forEach((name, distance) -> System.out.println(name + " : " + distance));
System.out.println(); // spacing
System.out.println("Shortest path from Los Angeles to Boston:");
List<WeightedEdge> path = *pathMapToPath*(cityGraph2.indexOf("Los Angeles"), cityGraph2.indexOf("Boston"), dijkstraResult.pathMap);
cityGraph2.printWeightedPath(path);
你的输出应该看起来像这样:
Distances from Los Angeles:
New York : 2474.0
Detroit : 1992.0
Seattle : 1026.0
Chicago : 1754.0
Washington : 2388.0
Miami : 2340.0
San Francisco : 348.0
Atlanta : 1965.0
Phoenix : 357.0
Los Angeles : 0.0
Dallas : 1244.0
Philadelphia : 2511.0
Riverside : 50.0
Boston : 2605.0
Houston : 1372.0
Shortest path from Los Angeles to Boston:
Los Angeles 50.0> Riverside
Riverside 1704.0> Chicago
Chicago 238.0> Detroit
Detroit 613.0> Boston
Total Weight: 2605.0
你可能已经注意到迪杰斯特拉算法与贾尼克算法有相似之处。它们都是贪婪的,如果一个人足够有动力,就可以使用相当相似的代码来实现。另一个与迪杰斯特拉算法相似的算法是第二章中的 A算法。A可以被视为迪杰斯特拉算法的一种修改。添加启发式方法并将迪杰斯特拉算法限制在寻找单个目的地,这两个算法就相同了。
注意:迪杰斯特拉算法是为具有正权重的图设计的。具有负权边重的图可能会对迪杰斯特拉算法构成挑战,需要修改或使用替代算法。
4.6 现实世界应用
我们的世界中很大一部分可以用图来表示。在本章中,你已经看到了它们在处理交通网络方面的有效性,但许多其他类型的网络也有相同的本质优化问题:电话网络、计算机网络、公用事业网络(电力、管道等)。因此,图算法对于电信、航运、交通和公用事业行业的效率至关重要。
零售商必须处理复杂的分配问题。商店和仓库可以被视为顶点,它们之间的距离作为边。算法是相同的。互联网本身就是一个巨大的图,每个连接的设备都是一个顶点,每个有线或无线连接都是一个边。无论是节省燃料还是节省电线,最小生成树和最短路径问题求解对于游戏以外的用途也是很有用的。世界上一些最著名的品牌通过优化图问题而取得成功:想想沃尔玛构建高效的分配网络,谷歌索引网络(一个巨大的图),以及联邦快递找到连接世界各地地址的正确的一组枢纽。
图算法的一些明显应用是社交网络和地图应用。在社交网络中,人们是顶点,连接(例如 Facebook 上的友谊)是边。事实上,Facebook 最著名的开发者工具之一就是名为 Graph API 的(developers.facebook.com/docs/graph-api)。在像 Apple Maps 和 Google Maps 这样的地图应用中,图算法用于提供路线和计算行程时间。
几种流行的视频游戏也明确使用了图算法。Mini-Metro 和 Ticket to Ride 是两个模仿本章所解决问题的高仿真游戏例子。
4.7 练习
-
为图框架添加移除边和顶点的支持。
-
为图框架添加对有向图(有向图)的支持。
-
使用本章的图框架来证明或反驳维基百科上描述的经典七桥问题,
en.wikipedia.org/wiki/Seven_Bridges_of_K%C3%83%C6%92%C3%82%C2%B6nigsberg。
-
数据来自美国人口普查局,
www.census.gov/. -
埃隆·马斯克,“Hyperloop Alpha”,
www.tesla.com/sites/default/files/blog_images/hyperloop-alpha.pdf. -
Helena Durnová,“奥塔卡·博鲁夫卡(1899-1995)和最小生成树”(捷克科学院数学研究所,2006 年),
mng.bz/O2vj. -
受罗伯特·赛德威克和凯文·韦恩解决方案的启发,算法,第 4 版(Addison-Wesley Professional,2011 年),第 619 页。
5 遗传算法
遗传算法不用于日常编程问题。当传统算法方法不足以在合理的时间内解决问题时,才会被调用。换句话说,遗传算法通常保留用于复杂问题,这些问题没有简单的解决方案。如果您需要了解一些可能存在的复杂问题,请在继续之前,自由地阅读第 5.7 节。然而,有一个有趣的例子,那就是蛋白质-配体对接和药物设计。计算生物学家需要设计能够与受体结合以递送药物的分子。可能没有明显的算法来设计特定的分子,但正如您将看到的,有时遗传算法可以在没有太多方向性指导的情况下提供答案,除了定义问题的目标。
5.1 生物背景
在生物学中,进化论是对遗传突变与环境约束相结合,如何导致生物体随时间变化(包括物种形成——新物种的创造)的解释。适应良好的生物体成功而适应不良的生物体失败这一机制被称为自然选择。每个物种的每一代都会包括具有不同(有时是新的)特征的个人,这些特征是通过遗传突变产生的。所有个体为了生存而争夺有限的资源,由于个体数量多于资源,因此一些个体必须死亡。
一个具有使其在环境中更好地适应生存的突变的个体,将有更高的生存和繁殖概率。随着时间的推移,环境中适应良好的个体将会有更多的孩子,并通过遗传将这些突变传递给后代。因此,有利于生存的突变很可能最终在种群中传播。
例如,如果细菌正在被一种特定的抗生素杀死,并且种群中有一个细菌个体在某个基因上发生了突变,使其对这种抗生素的抵抗力更强,那么它更有可能存活并繁殖。如果抗生素持续使用,那么继承了抗生素耐药基因的后代也更有可能繁殖并有自己的后代。最终,整个种群可能会获得这种突变,因为抗生素的持续攻击杀死了没有突变的个体。抗生素不会导致突变的发展,但它确实导致了具有突变的个体的增殖。
自然选择已应用于生物学之外的领域。社会达尔文主义是将自然选择应用于社会理论领域。在计算机科学中,遗传算法是对自然选择的模拟,用于解决计算挑战。
遗传算法包括一个称为染色体的个体群体(组)。染色体由基因组成,这些基因指定了它们的特征,它们为了解决某个问题而竞争。染色体解决问题的好坏由适应度函数定义。
遗传算法会经历多个世代。在每一代中,适应性更强的染色体更有可能被选中进行繁殖。在每一代中,也有两个染色体合并其基因的概率。这被称为交叉。最后,在每一代中,染色体中的一个基因可能会发生变异(随机改变)。
当种群中某个个体的适应度函数超过某个特定的阈值,或者算法运行了指定的最大代数时,返回最佳个体(在适应度函数中得分最高的个体)。
遗传算法并不是所有问题的良好解决方案。它们依赖于三个部分或全部随机的(随机确定的)操作:选择、交叉和变异。因此,它们可能无法在合理的时间内找到最优解。对于大多数问题,存在更多确定性的算法,并提供了更好的保证。但是,对于某些问题,尚未知道存在快速确定性算法。在这些情况下,遗传算法是一个好的选择。
5.2 一个通用的遗传算法
遗传算法通常高度专业化,针对特定应用进行了调整。在本章中,我们将定义一个通用的遗传算法,它可以用于多个问题,而不会对任何问题特别优化。它将包括一些可配置的选项,但目标是展示算法的基本原理,而不是其可调整性。
我们将首先定义一个接口,该接口是通用算法可以操作的个人。抽象类 Chromosome 定义了五个基本特征。一条染色体必须能够执行以下操作:
-
确定自己的适应度
-
实现交叉(将自己与另一个相同类型的染色体结合以创建子代)——换句话说,与另一个染色体混合
-
变异——对自己进行小而相对随机的改变
-
复制自身
-
将自己与其他相同类型的染色体进行比较
这里是 Chromosome 的代码,编码了这五个需求。
列表 5.1 Chromosome.java
package chapter5;
import java.util.List;
public abstract class Chromosome<T extends Chromosome<T>> implements Comparable<T> {
public abstract double fitness();
public abstract List<T> crossover(T other);
public abstract void mutate();
public abstract T copy();
@Override
public int compareTo(T other) {
Double mine = this.fitness();
Double theirs = other.fitness();
return mine.compareTo(theirs);
}
}
注意:你会注意到 Chromosome 的泛型类型 T 被绑定到 Chromosome 本身(Chromosome<T extends Chromosome
我们将实现算法本身(将操作染色体的代码)作为一个通用的类,该类可以用于未来专门应用的子类化。但在这样做之前,让我们重新回顾一下本章开头对遗传算法的描述,并明确定义遗传算法采取的步骤:
-
为算法的第一代创建一个随机的染色体初始种群。
-
测量种群中每个染色体的适应性。如果任何染色体超过阈值,则返回它,算法结束。
-
选择一些个体进行繁殖,以更高的概率选择适应性最高的个体。
-
以一定的概率进行交叉(组合),将一些选中的染色体组合成代表下一代种群的孩子。
-
以低概率变异一些染色体。新的一代种群现在完成,并取代上一代的种群。
-
返回到步骤 2,除非达到最大代数。如果是这种情况,则返回迄今为止找到的最佳染色体。
这个遗传算法的一般概述(如图 5.1 所示)缺少很多重要细节。种群中应该有多少个染色体?停止算法的阈值是多少?如何选择染色体进行繁殖?如何组合(交叉)以及以什么概率?以什么概率发生变异?应该运行多少代?

图 5.1 遗传算法的一般概述
所有这些点都将可在我们的 GeneticAlgorithm 类中进行配置。我们将逐步定义它,这样我们就可以单独讨论每个部分。
列表 5.2 GeneticAlgorithm.java
package chapter5;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
public class GeneticAlgorithm<C extends Chromosome<C>> {
public enum SelectionType {
ROULETTE, TOURNAMENT;
}
GeneticAlgorithm 接受一个符合 Chromosome 泛型的泛型类型,其名称为 C。枚举 SelectionType 是一个内部类型,用于指定算法使用的选择方法。两种最常用的遗传算法选择方法被称为轮盘赌选择(有时称为适应性比例选择)和锦标赛选择。前者给每个染色体一个被选中的机会,与它的适应性成比例。在锦标赛选择中,一定数量的随机染色体相互竞争,具有最佳适应性的染色体被选中。
列表 5.3 GeneticAlgorithm.java 继续
private ArrayList<C> population;
private double mutationChance;
private double crossoverChance;
private SelectionType selectionType;
private Random random;
public GeneticAlgorithm(List<C> initialPopulation, double mutationChance, double crossoverChance, SelectionType selectionType) {
this.population = new ArrayList<>(initialPopulation);
this.mutationChance = mutationChance;
this.crossoverChance = crossoverChance;
this.selectionType = selectionType;
this.random = new Random();
}
前一个构造函数定义了遗传算法在创建时将配置的几个属性。initialPopulation 是算法第一代的染色体。mutationChance 是每一代中每个染色体的突变概率。crossoverChance 是两个被选中繁殖的父母产生混合了他们基因的孩子的概率;否则,孩子只是父母的复制品。最后,selectionType 是要使用的选择方法的类型,如 SelectionType 枚举所定义的。
在本章后面的示例问题中,种群以一组随机染色体初始化。换句话说,染色体的第一代只是由随机个体组成。这是更复杂的遗传算法的一个潜在的优化点。不是从完全随机的个体开始,第一代可以包含更接近解决方案的个体,这需要一些对问题的了解。这被称为播种。
现在我们将检查我们的类支持的两个选择方法。
列表 5.4 GeneticAlgorithm.java 续
// Use the probability distribution wheel to pick numPicks individuals
private List<C> pickRoulette(double[] wheel, int numPicks) {
List<C> picks = new ArrayList<>();
for (int i = 0; i < numPicks; i++) {
double pick = random.nextDouble();
for (int j = 0; j < wheel.length; j++) {
pick -= wheel[j];
if (pick <= 0) { // went “over”, leads to a pick
picks.add(population.get(j));
break;
}
}
}
return picks;
}
轮盘赌选择基于每个染色体在一代中相对于所有适应度值的比例。适应度最高的染色体有更大的机会被选中。代表每个染色体总适应度百分比的价值由 wheel 参数提供。这些百分比由介于 0 和 1 之间的浮点数表示。使用介于 0 和 1 之间的随机数(pick)来确定选择哪个染色体。算法通过按顺序减少 pick 的每个染色体的比例适应度值来工作。当它穿过 0 时,那就是要选择的染色体。
你能理解为什么这个过程会导致每个染色体按其比例可被选中吗?如果不能,用铅笔和纸思考一下。考虑绘制一个如图 5.2 所示的成比例的轮盘赌。
轮盘赌选择的最基本形式比轮盘赌选择简单。我们不是计算比例,而是简单地从整个种群中随机选择 numParticipants 个染色体。在随机选择的群体中,适应度最好的 numPicks 个染色体获胜。
列表 5.5 GeneticAlgorithm.java 续
// Pick a certain number of individuals via a tournament
private List<C> pickTournament(int numParticipants, int numPicks) {
// Find numParticipants random participants to be in the tournament
Collections.*shuffle*(population);
List<C> tournament = population.subList(0, numParticipants);
// Find the numPicks highest fitnesses in the tournament
Collections.*sort*(tournament, Collections.*reverseOrder*());
return tournament.subList(0, numPicks);
}

图 5.2 轮盘赌选择动作的示例
pickTournament() 代码首先使用 shuffle() 随机化种群顺序,然后从种群中取出前 numParticipants。这只是获取 numParticipants 随机染色体的简单方法。接下来,它按适应度对参与染色体进行排序,并返回最适应的 numPicks 个参与者。
numParticipants 的正确数量是多少?与遗传算法中的许多参数一样,试错可能是确定它的最佳方法。需要注意的是,锦标赛中参与者数量越多,种群中的多样性就越少,因为具有较差适应度的染色体更有可能在配对中被淘汰。1 更复杂的锦标赛选择形式可能会选择不是最好的,而是第二或第三好的个体,基于某种递减概率模型。
这两个方法,pickRoulette() 和 pickTournament(),用于选择,在繁殖过程中发生。繁殖在 reproduceAndReplace() 中实现,并确保用数量相等的染色体新种群替换上一代的染色体。
列表 5.6 GeneticAlgorithm.java 继续部分
// Replace the population with a new generation of individuals
private void reproduceAndReplace() {
ArrayList<C> nextPopulation = new ArrayList<>();
// keep going until we've filled the new generation
while (nextPopulation.size() < population.size()) {
// pick the two parents
List<C> parents;
if (selectionType == SelectionType.ROULETTE) {
// create the probability distribution wheel
double totalFitness = population.stream()
.mapToDouble(C::fitness).sum();
double[] wheel = population.stream()
.mapToDouble(C -> C.fitness()
/ totalFitness).toArray();
parents = pickRoulette(wheel, 2);
} else { // tournament
parents = pickTournament(population.size() / 2, 2);
}
// potentially crossover the 2 parents
if (random.nextDouble() < crossoverChance) {
C parent1 = parents.get(0);
C parent2 = parents.get(1);
nextPopulation.addAll(parent1.crossover(parent2));
} else { // just add the two parents
nextPopulation.addAll(parents);
}
}
// if we have an odd number, we'll have 1 extra, so we remove it
if (nextPopulation.size() > population.size()) {
nextPopulation.remove(0);
}
// replace the reference/generation
population = nextPopulation;
}
在 reproduceAndReplace() 方法中,大致发生以下步骤:
-
使用两种选择方法之一选择两个染色体作为繁殖对象。对于锦标赛选择,我们总是在总种群的一半中运行锦标赛,但这也可以是一个配置选项。
-
有 crossoverChance 的概率,两个父母将结合产生两个新的染色体,在这种情况下,它们将被添加到 nextPopulation 中。如果没有子代,则直接将两个父母添加到 nextPopulation。
-
如果 nextPopulation 中的染色体数量与 population 相同,则替换它。否则,我们返回到步骤 1。
实现变异的 mutate() 方法非常简单,如何执行变异的细节留给单个染色体自行处理。我们每个染色体的实现都将知道如何变异自身。
列表 5.7 GeneticAlgorithm.java 继续部分
// With mutationChance probability, mutate each individual
private void mutate() {
for (C individual : population) {
if (random.nextDouble() < mutationChance) {
individual.mutate();
}
}
}
现在我们已经有了运行遗传算法所需的所有构建块。run() 协调测量、繁殖(包括选择)和变异步骤,这些步骤将种群从一个代带到下一个代。它还跟踪搜索过程中找到的任何最佳(最适应)染色体。
列表 5.8 GeneticAlgorithm.java 继续部分
// Run the genetic algorithm for maxGenerations iterations
// and return the best individual found
public C run(int maxGenerations, double threshold) {
C best = Collections.*max*(population).copy();
for (int generation = 0; generation < maxGenerations; generation++) {
// early exit if we beat threshold
if (best.fitness() >= threshold) {
return best;
}
// Debug printout
System.out.println("Generation " + generation +
" Best " + best.fitness() +
" Avg " + population.stream()
.mapToDouble(C::fitness).average().orElse(0.0));
reproduceAndReplace();
mutate();
C highest = Collections.*max*(population);
if (highest.fitness() > best.fitness()) {
best = highest.copy();
}
}
return best;
}
}
best 跟踪迄今为止找到的最佳染色体。主循环执行 maxGenerations 次。如果任何染色体在适应度上达到或超过阈值,则返回,并提前结束循环。否则,它将调用 reproduceAndReplace() 和 mutate() 来创建下一代并再次运行循环。如果达到 maxGenerations,则返回迄今为止找到的最佳染色体。
5.3 一个简单的测试
通用遗传算法 GeneticAlgorithm 可以与实现 Chromosome 的任何类型一起工作。作为一个测试,我们将从一个可以用传统方法轻松解决的问题开始实现。我们将尝试最大化方程 6x - x² + 4y - y²。换句话说,方程中的 x 和 y 的哪些值会产生最大的数?
通过求偏导数并将每个等于零,可以使用微积分找到最大化值。结果是 x = 3 和 y = 2。我们的遗传算法能否在不使用微积分的情况下达到相同的结果?让我们深入探讨。
列表 5.9 SimpleEquation.java
package chapter5;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class SimpleEquation extends Chromosome<SimpleEquation> {
private int x, y;
private static final int MAX_START = 100;
public SimpleEquation(int x, int y) {
this.x = x;
this.y = y;
}
public static SimpleEquation randomInstance() {
Random random = new Random();
return new SimpleEquation(random.nextInt(MAX_START), random.nextInt(MAX_START));
}
@Override
public double fitness() {
return 6 * x - x * x + 4 * y - y * y;
}
@Override
public List<SimpleEquation> crossover(SimpleEquation other) {
SimpleEquation child1 = new SimpleEquation(x, other.y);
SimpleEquation child2 = new SimpleEquation(other.x, y);
return List.*of*(child1, child2);
}
@Override
public void mutate() {
Random random = new Random();
if (random.nextDouble() > 0.5) { // mutate x
if (random.nextDouble() > 0.5) {
x += 1;
} else {
x -= 1;
}
} else { // otherwise mutate y
if (random.nextDouble() > 0.5) {
y += 1;
} else {
y -= 1;
}
}
}
@Override
public SimpleEquation copy() {
return new SimpleEquation(x, y);
}
@Override
public String toString() {
return "X: " + x + " Y: " + y + " Fitness: " + fitness();
}
SimpleEquation 符合染色体结构,并且正如其名,它尽可能地简单。SimpleEquation 染色体的基因可以被视为 x 和 y。fitness() 方法使用方程 6x - x² + 4y - y² 来评估 x 和 y。根据遗传算法,值越高,个体染色体的适应性越强。在随机实例的情况下,x 和 y 初始设置为介于 0 和 100 之间的随机整数,因此 randomInstance() 除了实例化一个具有这些值的新的 SimpleEquation 外,不需要做任何事情。在 crossover() 中将一个 SimpleEquation 与另一个结合时,两个实例的 y 值简单地交换以创建两个子代。mutate() 随机增加或减少 x 或 y。就是这样。
由于 SimpleEquation 符合染色体结构,我们已将其插入到 GeneticAlgorithm 中。
列表 5.10 SimpleEquation.java 继续阅读
public static void main(String[] args) {
ArrayList<SimpleEquation> initialPopulation = new ArrayList<>();
final int POPULATION_SIZE = 20;
final int GENERATIONS = 100;
final double THRESHOLD = 13.0;
for (int i = 0; i < POPULATION_SIZE; i++) {
initialPopulation.add(SimpleEquation.*randomInstance*());
}
GeneticAlgorithm<SimpleEquation> ga = new GeneticAlgorithm<>(
initialPopulation,
0.1, 0.7, GeneticAlgorithm.SelectionType.TOURNAMENT);
SimpleEquation result = ga.run(100, 13.0);
System.out.println(GENERATIONS, THRESHOLD);
}
}
这里使用的参数是通过猜测和检查得出的。您可以尝试其他参数。阈值设置为 13.0,因为我们已经知道正确答案。当 x = 3 和 y = 2 时,方程的值为 13。
如果您之前不知道答案,您可能想看到在特定代数内能找到的最佳结果。在这种情况下,您可以将阈值设置为任意大的数。记住,由于遗传算法是随机的,每次运行都会有所不同。
这里是从遗传算法在七代内解决方程的运行中获取的一些示例输出:
Generation 0 Best -72.0 Avg -4436.95
Generation 1 Best 9.0 Avg -579.0
Generation 2 Best 9.0 Avg -38.15
Generation 3 Best 12.0 Avg 9.0
Generation 4 Best 12.0 Avg 9.2
Generation 5 Best 12.0 Avg 11.25
Generation 6 Best 12.0 Avg 11.95
X: 3 Y: 2 Fitness: 13.0
如您所见,它得到了之前通过微积分得出的正确解,x = 3 和 y = 2。您也可能注意到,每一代都比前一代更接近正确答案。
考虑到遗传算法在找到解决方案时比其他方法需要更多的计算能力。在现实世界中,这样一个简单的最大化问题并不适合使用遗传算法。但它的简单实现至少足以证明我们的遗传算法是有效的。
5.4 再次审视 SEND+MORE=MONEY
在第三章中,我们使用约束满足框架解决了经典数独问题 SEND+MORE=MONEY。(如果您想回顾一下问题的具体描述,请参阅第三章的描述。)该问题也可以使用遗传算法在合理的时间内解决。
在为遗传算法解决方案制定问题时,最大的困难之一是确定如何表示它。对于密码学问题,使用列表索引作为数字是一种方便的表示方法。2 因此,为了表示 10 个可能的数字(0,1,2,3,4,5,6,7,8,9),需要一个 10 个元素的列表。问题中要搜索的字符可以依次移动到各个位置。例如,如果怀疑问题的解决方案包括代表数字 4 的字符“E”,那么列表中的第 4 个位置将持有“E”。SEND+MORE=MONEY 有八个不同的字母(S,E,N,D,M,O,R,Y),留下两个数组空位。它们可以用空格填充,表示没有字母。
代表 SEND+MORE=MONEY 问题的染色体在 SendMoreMoney2 中表示。注意 fitness()方法与第三章中的 SendMoreMoneyConstraint 的 satisfied()方法惊人地相似。
列表 5.11 SendMoreMoney2.java
package chapter5;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
public class SendMoreMoney2 extends Chromosome<SendMoreMoney2> {
private List<Character> letters;
private Random random;
public SendMoreMoney2(List<Character> letters) {
this.letters = letters;
random = new Random();
}
public static SendMoreMoney2 randomInstance() {
List<Character> letters = new ArrayList<>(
List.*of*('S', 'E', 'N', 'D', 'M', 'O', 'R', 'Y', ' ', ' '));
Collections.*shuffle*(letters);
return new SendMoreMoney2(letters);
}
@Override
public double fitness() {
int s = letters.indexOf('S');
int e = letters.indexOf('E');
int n = letters.indexOf('N');
int d = letters.indexOf('D');
int m = letters.indexOf('M');
int o = letters.indexOf('O');
int r = letters.indexOf('R');
int y = letters.indexOf('Y');
int send = s * 1000 + e * 100 + n * 10 + d;
int more = m * 1000 + o * 100 + r * 10 + e;
int money = m * 10000 + o * 1000 + n * 100 + e * 10 + y;
int difference = Math.*abs*(money - (send + more));
return 1.0 / (difference + 1.0);
}
@Override
public List<SendMoreMoney2> crossover(SendMoreMoney2 other) {
SendMoreMoney2 child1 = new SendMoreMoney2(new ArrayList<>(letters));
SendMoreMoney2 child2 = new SendMoreMoney2(new ArrayList<>(other.letters));
int idx1 = random.nextInt(letters.size());
int idx2 = random.nextInt(other.letters.size());
Character l1 = letters.get(idx1);
Character l2 = other.letters.get(idx2);
int idx3 = letters.indexOf(l2);
int idx4 = other.letters.indexOf(l1);
Collections.*swap*(child1.letters, idx1, idx3);
Collections.*swap*(child2.letters, idx2, idx4);
return List.*of*(child1, child2);
}
@Override
public void mutate() {
int idx1 = random.nextInt(letters.size());
int idx2 = random.nextInt(letters.size());
Collections.*swap*(letters, idx1, idx2);
}
@Override
public SendMoreMoney2 copy() {
return new SendMoreMoney2(new ArrayList<>(letters));
}
@Override
public String toString() {
int s = letters.indexOf('S');
int e = letters.indexOf('E');
int n = letters.indexOf('N');
int d = letters.indexOf('D');
int m = letters.indexOf('M');
int o = letters.indexOf('O');
int r = letters.indexOf('R');
int y = letters.indexOf('Y');
int send = s * 1000 + e * 100 + n * 10 + d;
int more = m * 1000 + o * 100 + r * 10 + e;
int money = m * 10000 + o * 1000 + n * 100 + e * 10 + y;
int difference = Math.*abs*(money - (send + more));
return (send + " + " + more + " = " + money + " Difference: " + difference);
}
然而,第三章中的 satisfied()和 fitness()之间存在一个主要区别。在这里,我们返回 1 / (difference + 1)。difference 是 MONEY 和 SEND+MORE 之间差异的绝对值。这表示染色体离解决问题有多远。如果我们试图最小化 fitness(),只返回 difference 本身就可以了。但是,因为 GeneticAlgorithm 试图最大化 fitness()的值,所以它需要反转(使得较小的值看起来像较大的值),这就是为什么 1 被除以 difference 的原因。首先,1 被加到 difference 上,这样 0 的差异就不会产生 fitness()为 0,而是为 1。表 5.1 说明了这是如何工作的。
方程式 1 / (difference + 1)如何产生用于最大化的适应度值
| difference | difference + 1 | fitness (1/(difference + 1)) |
|---|---|---|
| 0 | 1 | 1 |
| 1 | 2 | 0.5 |
| 2 | 3 | 0.33 |
| 3 | 4 | 0.25 |
记住,差异越小越好,适应度值越高越好。因为这个公式使得这两个事实对齐,所以它工作得很好。将 1 除以适应度值是将最小化问题转换为最大化问题的一种简单方法。尽管如此,它确实引入了一些偏差,所以并不是万无一失的。3
randomInstance()使用了 Collections 类中的 shuffle()函数。crossover()在两个染色体的字母列表中选择两个随机索引,并交换字母,以便我们最终在第二个染色体中与第一个染色体相同的位置得到一个字母,反之亦然。它在子代中执行这些交换,以便两个子代中字母的位置是父母的组合。mutate()交换字母列表中的两个随机位置。
我们可以像插入 SimpleEquation 一样轻松地将 SendMoreMoney2 插入到遗传算法中。但请提前警告:这是一个相当困难的问题,如果参数没有很好地调整,执行时间会很长。即使参数调整正确,仍然存在一些随机性!问题可能在几秒钟或几分钟内解决。不幸的是,这就是遗传算法的本质。
列表 5.12 SendMoreMoney2.java 继续
public static void main(String[] args) {
ArrayList<SendMoreMoney2> initialPopulation = new ArrayList<>();
final int POPULATION_SIZE = 1000;
final int GENERATIONS = 1000;
final double THRESHOLD = 1.0;
for (int i = 0; i < POPULATION_SIZE; i++) {
initialPopulation.add(SendMoreMoney2.*randomInstance*());
}
GeneticAlgorithm<SendMoreMoney2> ga = new GeneticAlgorithm<>(
initialPopulation,
0.2, 0.7, GeneticAlgorithm.SelectionType.ROULETTE);
SendMoreMoney2 result = ga.run(GENERATIONS, THRESHOLD);
System.out.println(result);
}
}
以下输出是从一次运行中得到的,该运行在每一代使用 1,000 个个体(如上所述创建)的情况下,在三代内解决了问题。看看你是否可以调整遗传算法的可配置参数,以更少的个体获得类似的结果。它是否比锦标赛选择更适合轮盘赌选择?
Generation 0 Best 0.07142857142857142 Avg 2.588160841027962E-4
Generation 1 Best 0.16666666666666666 Avg 0.005418719421172926
Generation 2 Best 0.5 Avg 0.022271971406414452
8324 + 913 = 9237 Difference: 0
这个解决方案表明 SEND = 8324,MORE = 913,MONEY = 9237。这是怎么可能的?看起来解决方案中缺少字母。实际上,如果 M = 0,那么在第三章版本中不可能解决的问题有几种解决方案。在这里 MORE 实际上是 0913,MONEY 是 09237。0 只是被忽略。
5.5 优化列表压缩
假设我们有一些想要压缩的信息。假设它是一个物品列表,我们不在乎物品的顺序,只要它们都是完整的。什么顺序的物品将最大化压缩比率?你是否知道物品的顺序会影响大多数压缩算法的压缩比率?
答案将取决于所使用的压缩算法。在这个例子中,我们将使用 java.util.zip 包中的 GZIPOutputStream 类。解决方案在这里完整展示,针对 12 个名字的列表。如果我们不运行遗传算法,只是按照原始顺序对 12 个名字运行 compress(),生成的压缩数据将是 164 字节。
列表 5.13 ListCompression.java
package chapter5;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.zip.GZIPOutputStream;
public class ListCompression extends Chromosome<ListCompression> {
private static final List<String> ORIGINAL_LIST = List.*of*("Michael", "Sarah", "Joshua", "Narine", "David", "Sajid", "Melanie", "Daniel", "Wei", "Dean", "Brian", "Murat", "Lisa");
private List<String> myList;
private Random random;
public ListCompression(List<String> list) {
myList = new ArrayList<>(list);
random = new Random();
}
public static ListCompression randomInstance() {
ArrayList<String> tempList = new ArrayList<>(ORIGINAL_LIST);
Collections.*shuffle*(tempList);
return new ListCompression(tempList);
}
private int bytesCompressed() {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
GZIPOutputStream gos = new GZIPOutputStream(baos);
ObjectOutputStream oos = new ObjectOutputStream(gos);
oos.writeObject(myList);
oos.close();
return baos.size();
} catch (IOException ioe) {
System.out.println("Could not compress list!");
ioe.printStackTrace();
return 0;
}
}
@Override
public double fitness() {
return 1.0 / bytesCompressed();
}
@Override
public List<ListCompression> crossover(ListCompression other) {
ListCompression child1 = new ListCompression(new ArrayList<>(myList));
ListCompression child2 = new ListCompression(new ArrayList<>(myList));
int idx1 = random.nextInt(myList.size());
int idx2 = random.nextInt(other.myList.size());
String s1 = myList.get(idx1);
String s2 = other.myList.get(idx2);
int idx3 = myList.indexOf(s2);
int idx4 = other.myList.indexOf(s1);
Collections.*swap*(child1.myList, idx1, idx3);
Collections.*swap*(child2.myList, idx2, idx4);
return List.*of*(child1, child2);
}
@Override
public void mutate() {
int idx1 = random.nextInt(myList.size());
int idx2 = random.nextInt(myList.size());
Collections.*swap*(myList, idx1, idx2);
}
@Override
public ListCompression copy() {
return new ListCompression(new ArrayList<>(myList));
}
@Override
public String toString() {
return "Order: " + myList + " Bytes: " + bytesCompressed();
}
public static void main(String[] args) {
ListCompression originalOrder = new ListCompression(ORIGINAL_LIST);
System.out.println(originalOrder);
ArrayList<ListCompression> initialPopulation = new ArrayList<>();
final int POPULATION_SIZE = 100;
final int GENERATIONS = 100;
final double THRESHOLD = 1.0;
for (int i = 0; i < POPULATION_SIZE; i++) {
initialPopulation.add(ListCompression.*randomInstance*());
}
GeneticAlgorithm<ListCompression> ga = new GeneticAlgorithm<>(
initialPopulation,
0.2, 0.7, GeneticAlgorithm.SelectionType.TOURNAMENT);
ListCompression result = ga.run(GENERATIONS, THRESHOLD);
System.out.println(result);
}
}
注意这个实现与第 5.4 节中 SEND+MORE= MONEY 的实现是多么相似。crossover()和 mutate()函数基本上是相同的。在这两个问题的解决方案中,我们取一个物品列表,并不断重新排列它们,并测试这些排列。可以为这两个问题的解决方案编写一个通用的超类,它可以与各种问题一起工作。任何可以用物品列表表示并需要找到其最优顺序的问题都可以以相同的方式解决。子类唯一的真正定制点就是它们各自的适应度函数。
如果我们运行 ListCompression.java,它可能需要非常长的时间才能完成。这是因为与前面两个问题不同,我们事先不知道“正确”的答案是什么,所以我们没有真正的工作阈值。相反,我们将代数数和每一代的个体数设置为一个任意高的数字,并寄希望于最好的结果。重新排列 12 个名字将产生多少字节的压缩?坦白说,我们不知道答案。在我的最佳运行中,使用前面解决方案中的配置,在 100 代之后,遗传算法找到了 12 个名字的顺序,产生了 158 字节的压缩。
这只是在原始顺序上节省了 6 个字节——大约节省了 4%。有人可能会说 4%微不足道,但如果这是一个远大于列表,并且多次在网络中传输的列表,那么这可能会累积起来。想象一下,如果这是一个最终将在互联网上传输 10,000,000 次的 1 MB 列表。如果遗传算法能够优化列表的顺序以节省 4%,那么每次传输将节省约 40 千字节,并且在所有传输中最终节省 400 GB 的带宽。这并不是一个巨大的数字,但也许它足够重要,以至于值得运行一次算法来找到一个接近最优的顺序以进行压缩。
虽然如此——我们并不真正知道我们是否找到了 12 个名字的最优顺序,更不用说假设的 1 MB 列表了。我们如何知道我们做到了呢?除非我们对压缩算法有深入的了解,否则我们必须尝试压缩列表的每一种可能的顺序。仅对于 12 个项目的列表,就有相当不切实际的 479,001,600 种可能的顺序(12!,其中!表示阶乘)。使用尝试找到最优性的遗传算法可能是可行的,即使我们不知道其最终解决方案是否真正最优。
5.6 遗传算法的挑战
遗传算法并非万能。实际上,它们并不适合大多数问题。对于任何存在快速确定性算法的问题,使用遗传算法的方法是没有意义的。它们固有的随机性使得它们的运行时间不可预测。为了解决这个问题,它们可以在一定代数后停止。但这样,就不知道是否找到了真正的最优解。
算法方面最受欢迎的教材之一《算法导论》的作者史蒂文·斯基尼亚甚至写道:
我从未遇到过任何问题,让我觉得遗传算法是解决它的正确方法。此外,我也从未看到过任何使用遗传算法报告的计算结果给我留下深刻印象。4
Skiena 的观点有些极端,但它表明遗传算法只有在你有合理信心认为不存在更好的解决方案,或者你正在探索未知的问题空间时才应该选择。遗传算法的另一个问题是确定如何将问题的潜在解决方案表示为染色体。传统的做法是将大多数问题表示为二进制字符串(1s 和 0s 的序列,原始比特)。这在空间使用方面通常是最佳的,并且有利于简单的交叉函数。但大多数复杂问题不容易表示为可分割的二进制字符串。
另一个值得提及的更具体的问题是本章中描述的轮盘赌选择方法的相关挑战。轮盘赌选择,有时也称为适应度比例选择,由于每次选择时相对适应度较高的个体占主导地位,可能会导致种群中缺乏多样性。另一方面,如果适应度值彼此接近,轮盘赌选择可能会导致缺乏选择压力。5 此外,本章中构建的轮盘赌选择对于可以用负值衡量的适应度问题不适用,例如在第 5.3 节中的简单方程示例。
简而言之,对于大多数足够大以至于需要使用它们的问题,遗传算法不能保证在可预测的时间内发现最优解。因此,它们最好用于不需要最优解,而是需要“足够好”的解决方案的情况。它们相对容易实现,但调整它们的可配置参数可能需要大量的尝试和错误。
5.7 实际应用
尽管 Skiena 这样写了,但遗传算法在众多问题空间中被频繁且有效地应用。它们通常用于不需要完美最优解的难题,例如传统方法难以解决的约束满足问题。一个例子是复杂的调度问题。
遗传算法在计算生物学中找到了许多应用。它们在蛋白质-配体对接方面取得了成功,这是寻找小分子与受体结合时的配置。这用于药物研究和更好地理解自然界的机制。
旅行商问题,我们将在第九章中再次讨论,是计算机科学中最著名的问题之一。旅行商希望找到地图上最短的路线,该路线访问每个城市一次,并带他回到起点。这听起来可能像第四章中的最小生成树,但它不同。在旅行商中,解决方案是一个巨大的循环,它最小化了穿越它的成本,而最小生成树最小化了连接每个城市的成本。旅行一个最小生成树的人可能需要访问同一个城市两次才能到达每个城市。尽管它们听起来很相似,但还没有合理的算法可以在合理的时间内找到任意数量城市的旅行商问题的解决方案。遗传算法已被证明可以在短时间内找到次优但相当好的解决方案。该问题广泛应用于货物的有效分配。例如,FedEx 和 UPS 的调度员每天使用软件来解决旅行商问题。帮助解决问题算法可以在许多行业中降低成本。
在计算机生成的艺术中,遗传算法有时被用来使用随机方法模拟照片。想象一下,在屏幕上随机放置 50 个多边形,并逐渐扭曲、旋转、移动、调整大小和改变颜色,直到它们尽可能接近照片。结果看起来像抽象艺术家的作品,或者如果使用更尖锐的形状,则像彩色玻璃窗。
遗传算法是更大领域进化计算的一部分。进化计算的一个与遗传算法密切相关的研究领域是遗传编程,其中程序使用选择、交叉和变异操作来修改自身,以找到编程问题的非显而易见的解决方案。遗传编程不是一种广泛使用的技巧,但想象一下,在未来,程序将能够自己编写。
遗传算法的一个好处是它们很容易并行化。在最明显的形式中,每个种群可以在一个单独的处理器上模拟。在最细粒度的形式中,每个个体可以变异和交叉,并在单独的线程中计算其适应度。还有许多介于两者之间的可能性。
5.8 练习
-
为 GeneticAlgorithm 添加对一种高级锦标赛选择的支持,该选择有时可能会根据递减的概率选择第二或第三好的染色体。
-
将第三章的约束满足框架添加一个新功能,该功能使用遗传算法解决任何任意的 CSP。一个可能的适应度度量是染色体解决的约束数量。
-
创建一个名为 BitString 的类,该类实现了 Chromosome。回想一下第一章中提到的位串是什么。然后使用您的新类来解决第 5.3 节中的简单方程问题。这个问题如何编码为位串?
-
Artem Sokolov 和 Darrell Whitley,“无偏锦标赛选择,”GECCO’05(2005 年 6 月 25 日至 29 日,美国华盛顿特区),
mng.bz/S7l6。 -
Reza Abbasian 和 Masoud Mazloom,“使用并行遗传算法解决密码学问题,”2009 年第二届计算机与电气工程国际会议,
mng.bz/RQ7V。 -
例如,如果我们简单地用整数均匀分布除以 1,可能会得到比接近 1 时更多的接近 0 的数字,这在典型微处理器解释浮点数的细微差别下可能会导致一些意想不到的结果。将最小化问题转换为最大化问题的一种替代方法是将符号翻转(使其变为负数而不是正数)。然而,这只有在所有值最初都是正数的情况下才会有效。
-
Steven Skiena,《算法设计手册》,第 2 版(Springer,2009 年),第 267 页。
-
A. E. Eiben 和 J. E. Smith,《进化计算导论》,第 2 版(Springer,2015 年),第 80 页。
6 K-means 聚类
与今天相比,人类从未拥有过更多关于社会各个方面的数据。计算机非常适合存储数据集,但这些数据集在人类分析之前对社会几乎没有价值。计算技术可以帮助人类在从数据集中提取意义的过程中找到方向。
聚类是一种计算技术,它将数据集中的点划分为组。成功的聚类结果会产生包含相互关联点的组。这些关系是否有意义通常需要人类验证。
在聚类中,数据点所属的组(即聚类)不是预先确定的,而是在聚类算法运行过程中决定的。事实上,算法没有通过预设信息引导将任何特定的数据点放置在任何特定的聚类中。因此,聚类被认为是机器学习领域中的无监督方法。你可以将无监督理解为没有先验知识的指导。
当你想了解数据集的结构但事先不知道其组成部分时,聚类是一种有用的技术。例如,想象你拥有一家杂货店,你收集了有关客户及其交易的数据。你希望在相关的时间段运行移动广告,以吸引顾客到你的店里。你可以尝试通过一周中的某一天和人口统计信息来聚类你的数据。也许你会发现一个表明年轻购物者更喜欢在周二购物的组,你可以利用这个信息在那天专门针对他们投放广告。
6.1 前言
我们的聚类算法将需要一些统计原语(均值、标准差等)。自 Java 8 版本以来,Java 标准库通过 util 包中的 DoubleSummaryStatistics 类提供了几个有用的统计原语。我们将使用这些原语来开发更复杂的统计方法。需要注意的是,尽管我们在本书中坚持使用标准库,但还有许多有用的第三方 Java 统计库,应在性能关键的应用中使用——特别是那些处理大数据的应用。一个经验丰富、经过实战考验的库在性能和能力方面几乎总是比自行开发要好。然而,在本书中,我们致力于通过自行开发来学习。
为了简化起见,本章中我们将使用双精度类型或其对象等价物 Double 来表示我们将要处理的所有数据集。在接下来的 Statistics 类中,统计原语 sum()、mean()、max() 和 min() 是通过 DoubleSummaryStatistics 实现的。variance()、std()(标准差)和 zscored() 是在这些原语之上构建的。它们的定义直接来自你可以在统计学教科书中找到的公式。
列表 6.1 Statistics.java
package chapter6;
import java.util.DoubleSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;
public final class Statistics {
private List<Double> list;
private DoubleSummaryStatistics dss;
public Statistics(List<Double> list) {
this.list = list;
dss = list.stream().collect(Collectors.*summarizingDouble*(d -> d));
}
public double sum() {
return dss.getSum();
}
// Find the average (mean)
public double mean() {
return dss.getAverage();
}
// Find the variance sum((Xi - mean)²) / N
public double variance() {
double mean = mean();
return list.stream().mapToDouble(x -> Math.*pow*((x - mean), 2))
.average().getAsDouble();
}
// Find the standard deviation sqrt(variance)
public double std() {
return Math.*sqrt*(variance());
}
// Convert elements to respective z-scores (formula z-score =
// (x - mean) / std)
public List<Double> zscored() {
double mean = mean();
double std = std();
return list.stream()
.map(x -> std != 0 ? ((x - mean) / std) : 0.0)
.collect(Collectors.*toList*());
}
public double max() {
return dss.getMax();
}
public double min() {
return dss.getMin();
}
}
variance() 函数用于计算总体的方差。一个略有不同的公式,我们目前没有使用,用于计算样本的方差。我们总是会在一次评估整个数据点的总体。
zscored() 函数将列表中的每个项目转换为它的 z 分数,即原始值与数据集平均值的标准差数。关于 z 分数的更多内容将在本章后面介绍。
注意:本书的范围不包括教授基础统计学,但你不需要对均值和标准差有超过初步的了解就能理解本章的其余内容。如果你已经有一段时间没有接触过这些内容,或者你以前从未学习过这些术语,快速浏览一下解释这两个基本概念的统计学资源可能是有益的。
所有聚类算法都使用数据点,我们的 k-means 实现也不例外。我们将定义一个名为 DataPoint 的通用基类。
列表 6.2 DataPoint.java
package chapter6;
import java.util.ArrayList;
import java.util.List;
public class DataPoint {
public final int numDimensions;
private List<Double> originals;
public List<Double> dimensions;
public DataPoint(List<Double> initials) {
originals = initials;
dimensions = new ArrayList<>(initials);
numDimensions = dimensions.size();
}
public double distance(DataPoint other) {
double differences = 0.0;
for (int i = 0; i < numDimensions; i++) {
double difference = dimensions.get(i) - other.dimensions.get(i);
differences += Math.*pow*(difference, 2);
}
return Math.*sqrt*(differences);
}
@Override
public String toString() {
return originals.toString();
}
}
每个数据点都必须是可读的,以便进行调试打印(toString())。每个数据点类型都有一定数量的维度(numDimensions)。dimensions 列表存储了这些维度实际值的列表,作为双精度浮点数。构造函数接受一个初始值的列表。这些维度可能后来会被 k-means 替换为 z 分数,因此我们也保留了一份初始数据的副本,以便稍后打印。
在我们深入研究 k-means 之前,我们还需要一个计算相同类型任意两个数据点之间距离的方法。有许多计算距离的方法,但与 k-means 最常使用的形式是欧几里得距离。这是大多数人在几何课程中学到的距离公式,可以从勾股定理推导出来。实际上,我们在第二章中已经讨论了该公式,并推导出适用于二维空间的一个版本,我们用它来找到迷宫中任意两个位置之间的距离。我们为 DataPoint 的版本需要稍微复杂一些,因为 DataPoint 可以涉及任意数量的维度。每个差值的平方被求和,distance() 函数返回的最终值是这个和的平方根。
6.2 K-means 聚类算法
K-means 是一种聚类算法,试图将数据点分组到预定义的特定数量的簇中。在 k-means 的每一轮中,计算每个数据点与每个簇中心(称为质心)之间的距离。点被分配到它们最近的质心所在的簇。然后算法重新计算所有质心,找到每个簇分配点的平均值,并用新的平均值替换旧的质心。分配点和重新计算质心的过程会继续进行,直到质心停止移动或发生一定数量的迭代。
提供给 k-means 的初始点的每个维度需要具有可比的幅度。如果不具有可比性,k-means 将偏向于基于具有最大差异的维度进行聚类。将不同类型的数据(在我们的案例中,是不同的维度)进行比较的过程称为归一化。归一化数据的一种常见方法是基于其 z 分数(也称为标准分数)评估每个值相对于同一类型其他值的相对值。z 分数是通过取一个值,从中减去所有值的平均值,然后将该结果除以所有值的方差来计算的。上一节开头设计的 zscored() 函数正是对列表中的每个双精度值执行此操作。
k-means 的主要困难在于选择如何分配初始质心。在算法的最基本形式中,这是我们将要实现的,初始质心被放置在数据范围内的随机位置。另一个困难是决定将数据分成多少个簇(k-means 中的“k”)。在经典算法中,这个数字由用户确定,但用户可能不知道正确的数字,这需要一些实验。我们将允许用户定义“k”。
将所有这些步骤和考虑因素综合起来,以下是我们的 k-means 聚类算法:
-
初始化所有数据点和“k”个空簇。
-
归一化所有数据点。
-
为每个簇创建随机质心。
-
将每个数据点分配到与其最近的质心的簇中。
-
重新计算每个质心,使其成为其关联簇的中心(均值)。
-
重复步骤 4 和 5,直到达到最大迭代次数或质心停止移动(收敛)。
从概念上讲,k-means 实际上非常简单:在每次迭代中,每个数据点都与它最接近的簇的中心相关联。随着新点与簇相关联,这个中心会移动。这如图 6.1 所示。
我们将实现一个用于维护状态和运行算法的类,类似于第五章中的 GeneticAlgorithm。我们将从一个内部类开始,用于表示簇。
列表 6.3 KMeans.java
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
public class KMeans<Point extends DataPoint> {
public class Cluster {
public List<Point> points;
public DataPoint centroid;
public Cluster(List<Point> points, DataPoint randPoint) {
this.points = points;
this.centroid = randPoint;
}
}

图 6.1 k-means 在任意数据集上运行三个代次的示例。星号表示质心。颜色和形状表示当前簇成员资格(这会变化)。
KMeans 是一个泛型类。它与 DataPoint 或任何 DataPoint 的子类一起工作,如 Point 类型边界所定义的(Point extends DataPoint)。它有一个内部类 Cluster,用于跟踪操作中的各个簇。每个 Cluster 都与其关联的数据点和质心相关。
注意:在本章中,为了压缩代码大小并使其更易于阅读,我们允许一些实例变量是公共的,而通常这些变量是通过 getters/setters 访问的。
现在我们将继续外部类的构造函数。
列表 6.4 KMeans.java 继续
private List<Point> points;
private List<Cluster> clusters;
public KMeans(int k, List<Point> points) {
if (k < 1) { // can't have negative or zero clusters
throw new IllegalArgumentException("k must be >= 1");
}
this.points = points;
zScoreNormalize();
// initialize empty clusters with random centroids
clusters = new ArrayList<>();
for (int i = 0; i < k; i++) {
DataPoint randPoint = randomPoint();
Cluster cluster = new Cluster(new ArrayList<Point>(), randPoint);
clusters.add(cluster);
}
}
private List<DataPoint> centroids() {
return clusters.stream().map(cluster -> cluster.centroid)
.collect(Collectors.*toList*());
}
KMeans 有一个与之关联的数组,points。这是数据集中的所有点。这些点进一步分为簇,存储在名为 clusters 的相应变量中。当 KMeans 实例化时,它需要知道要创建多少个簇(k)。每个簇最初都有一个随机中心点。算法中将使用的所有数据点都通过 z-score 进行归一化。centroids 方法返回与算法关联的簇的所有中心点。
列表 6.5 KMeans.java 继续
private List<Double> dimensionSlice(int dimension) {
return points.stream().map(x -> x.dimensions.get(dimension))
.collect(Collectors.*toList*());
}
dimensionSlice()是一个便利方法,可以将其视为返回数据的一列。它将返回一个列表,包含每个数据点中特定索引处的每个值。例如,如果数据点是 DataPoint 类型,那么 dimensionSlice(0)将返回每个数据点第一维值的列表。这在以下归一化方法中很有帮助。
列表 6.6 KMeans.java 继续
private void zScoreNormalize() {
List<List<Double>> zscored = new ArrayList<>();
for (Point point : points) {
zscored.add(new ArrayList<Double>());
}
for (int dimension = 0; dimension <
points.get(0).numDimensions; dimension++) {
List<Double> dimensionSlice = dimensionSlice(dimension);
Statistics stats = new Statistics(dimensionSlice);
List<Double> zscores = stats.zscored();
for (int index = 0; index < zscores.size(); index++) {
zscored.get(index).add(zscores.get(index));
}
}
for (int i = 0; i < points.size(); i++) {
points.get(i).dimensions = zscored.get(i);
}
}
zScoreNormalize()将每个数据点的维度列表中的值替换为其 z-score 等价值。这使用了我们在之前为 double 列表定义的 zscored()函数。尽管维度列表中的值被替换,但 DataPoint 中的原始列表 originals 不会被替换。这很有用;算法的用户在算法运行后,如果它们存储在两个地方,仍然可以检索归一化之前的原始维度值。
列表 6.7 KMeans.java 继续
private DataPoint randomPoint() {
List<Double> randDimensions = new ArrayList<>();
Random random = new Random();
for (int dimension = 0; dimension < points.get(0).numDimensions; dimension++) {
List<Double> values = dimensionSlice(dimension);
Statistics stats = new Statistics(values);
Double randValue = random.doubles(stats.min(), stats.max()).findFirst().getAsDouble();
randDimensions.add(randValue);
}
return new DataPoint(randDimensions);
}
前面的 randomPoint()方法在构造函数中使用,为每个簇创建初始的随机中心点。它将每个点的随机值限制在现有数据点值范围内。它使用我们之前在 DataPoint 中指定的构造函数,从一个值列表中创建一个新的点。
现在我们将查看我们的方法,该方法用于找到数据点所属的适当簇。
列表 6.8 KMeans.java 继续
// Find the closest cluster centroid to each point and assign the point
// to that cluster
private void assignClusters() {
for (Point point : points) {
double lowestDistance = Double.MAX_VALUE;
Cluster closestCluster = clusters.get(0);
for (Cluster cluster : clusters) {
double centroidDistance =
point.distance(cluster.centroid);
if (centroidDistance < lowestDistance) {
lowestDistance = centroidDistance;
closestCluster = cluster;
}
}
closestCluster.points.add(point);
}
}
在整本书中,我们创建了几个函数,用于在列表中查找最小值或最大值。这个函数与此类似。在这种情况下,我们正在寻找与每个单独的点距离最小的簇中心点。然后,该点被分配到那个簇。
列表 6.9 KMeans.java 继续
// Find the center of each cluster and move the centroid to there
private void generateCentroids() {
for (Cluster cluster : clusters) {
// Ignore if the cluster is empty
if (cluster.points.isEmpty()) {
continue;
}
List<Double> means = new ArrayList<>();
for (int i = 0; i < cluster.points.get(0).numDimensions; i++) {
// needed to use in scope of closure
int dimension = i;
Double dimensionMean = cluster.points.stream()
.mapToDouble(x ->
x.dimensions.get(dimension)).average().getAsDouble();
means.add(dimensionMean);
}
cluster.centroid = new DataPoint(means);
}
}
在每个点被分配到簇之后,将计算新的中心点。这涉及到计算簇中每个点的每个维度的平均值。这些维度的平均值然后组合起来,找到簇中的“平均点”,这成为新的中心点。请注意,我们在这里不能使用 dimensionSlice(),因为所讨论的点只是所有点的一个子集(仅属于特定簇)。dimensionSlice()应该如何重写以使其更通用?我们将把这个思考留给读者作为练习。
现在我们来看看实际执行算法的方法(以及一个辅助函数)。
列表 6.10 KMeans.java 继续
// Check if two Lists of DataPoints are of equivalent DataPoints
private boolean listsEqual(List<DataPoint> first, List<DataPoint> second) {
if (first.size() != second.size()) {
return false;
}
for (int i = 0; i < first.size(); i++) {
for (int j = 0; j < first.get(0).numDimensions; j++) {
if (first.get(i).dimensions.get(j).doubleValue() !=
second.get(i).dimensions.get(j).doubleValue()) {
return false;
}
}
}
return true;
}
public List<Cluster> run(int maxIterations) {
for (int iteration = 0; iteration < maxIterations; iteration++) {
for (Cluster cluster : clusters) { // clear all clusters
cluster.points.clear();
}
assignClusters();
List<DataPoint> oldCentroids = new ArrayList<>(centroids());
generateCentroids(); // find new centroids
if (listsEqual(oldCentroids, centroids())) {
System.out.println("Converged after " + iteration + " iterations.");
return clusters;
}
}
return clusters;
}
run()是原始算法最纯粹的表达。你可能发现的唯一意外的算法更改是在每次迭代的开始移除所有点。如果不这样做,按照目前的写法,assignClusters()方法最终会在每个聚类中放入重复的点。listsEqual()是一个辅助函数,用于检查两个 DataPoints 列表是否持有相同的点。这对于检查在代之间没有变化到质心(表明移动已停止,算法应该停止)非常有用。
你可以使用测试数据点和 k 设置为 2 进行快速测试。
列表 6.11 KMeans.java 继续
public static void main(String[] args) {
DataPoint point1 = new DataPoint(List.*of*(2.0, 1.0, 1.0));
DataPoint point2 = new DataPoint(List.*of*(2.0, 2.0, 5.0));
DataPoint point3 = new DataPoint(List.*of*(3.0, 1.5, 2.5));
KMeans<DataPoint> kmeansTest = new KMeans<>(2, List.*of*(point1, point2, point3));
List<KMeans<DataPoint>.Cluster> testClusters = kmeansTest.run(100);
for (int clusterIndex = 0; clusterIndex < testClusters.size();
clusterIndex++) {
System.out.println("Cluster " + clusterIndex + ": "
+ testClusters.get(clusterIndex).points);
}
}
}
因为涉及随机性,你的结果可能会有所不同。预期结果大致如下:
Converged after 1 iterations.
Cluster 0: [[2.0, 1.0, 1.0], [3.0, 1.5, 2.5]]
Cluster 1: [[2.0, 2.0, 5.0]]
6.3 按年龄和经度聚类州长
每个美国州都有一个州长。截至 2017 年 6 月,这些州长的年龄从 42 岁到 79 岁不等。如果我们从东到西看美国,按每个州的经度来看,也许我们可以找到具有相似经度和相似年龄州长的州集群。图 6.2 是所有 50 个州长的散点图。x 轴是州经度,y 轴是州长年龄。
图 6.2 中是否有任何明显的聚类?在这个图中,轴没有归一化。相反,我们正在查看原始数据。如果聚类总是明显的,那么聚类算法就没有必要了。

图 6.2 2017 年 6 月的州长,按州经度和州长年龄绘制
让我们尝试运行这个数据集通过 k-means。首先,我们需要一种表示单个数据点的方法。
列表 6.12 Governor.java
package chapter6;
import java.util.ArrayList;
import java.util.List;
public class Governor extends DataPoint {
private double longitude;
private double age;
private String state;
public Governor(double longitude, double age, String state) {
super(List.*of*(longitude, age));
this.longitude = longitude;
this.age = age;
this.state = state;
}
@Override
public String toString() {
return state + ": (longitude: " + longitude + ", age: " + age + ")";
}
州长有两个命名并存储的维度:经度和年龄。除此之外,州长对其超类 DataPoint 的机制没有进行任何修改,除了重写 toString()方法以实现美观打印。手动输入以下数据显然是不合理的,因此请查看本书附带的源代码仓库。
列表 6.13 Governor.java 继续
public static void main(String[] args) {
List<Governor> governors = new ArrayList<>();
governors.add(new Governor(-86.79113, 72, "Alabama"));
governors.add(new Governor(-152.404419, 66, "Alaska"));
governors.add(new Governor(-111.431221, 53, "Arizona"));
governors.add(new Governor(-92.373123, 66, "Arkansas"));
governors.add(new Governor(-119.681564, 79, "California"));
governors.add(new Governor(-105.311104, 65, "Colorado"));
governors.add(new Governor(-72.755371, 61, "Connecticut"));
governors.add(new Governor(-75.507141, 61, "Delaware"));
governors.add(new Governor(-81.686783, 64, "Florida"));
governors.add(new Governor(-83.643074, 74, "Georgia"));
governors.add(new Governor(-157.498337, 60, "Hawaii"));
governors.add(new Governor(-114.478828, 75, "Idaho"));
governors.add(new Governor(-88.986137, 60, "Illinois"));
governors.add(new Governor(-86.258278, 49, "Indiana"));
governors.add(new Governor(-93.210526, 57, "Iowa"));
governors.add(new Governor(-96.726486, 60, "Kansas"));
governors.add(new Governor(-84.670067, 50, "Kentucky"));
governors.add(new Governor(-91.867805, 50, "Louisiana"));
governors.add(new Governor(-69.381927, 68, "Maine"));
governors.add(new Governor(-76.802101, 61, "Maryland"));
governors.add(new Governor(-71.530106, 60, "Massachusetts"));
governors.add(new Governor(-84.536095, 58, "Michigan"));
governors.add(new Governor(-93.900192, 70, "Minnesota"));
governors.add(new Governor(-89.678696, 62, "Mississippi"));
governors.add(new Governor(-92.288368, 43, "Missouri"));
governors.add(new Governor(-110.454353, 51, "Montana"));
governors.add(new Governor(-98.268082, 52, "Nebraska"));
governors.add(new Governor(-117.055374, 53, "Nevada"));
governors.add(new Governor(-71.563896, 42, "New Hampshire"));
governors.add(new Governor(-74.521011, 54, "New Jersey"));
governors.add(new Governor(-106.248482, 57, "New Mexico"));
governors.add(new Governor(-74.948051, 59, "New York"));
governors.add(new Governor(-79.806419, 60, "North Carolina"));
governors.add(new Governor(-99.784012, 60, "North Dakota"));
governors.add(new Governor(-82.764915, 65, "Ohio"));
governors.add(new Governor(-96.928917, 62, "Oklahoma"));
governors.add(new Governor(-122.070938, 56, "Oregon"));
governors.add(new Governor(-77.209755, 68, "Pennsylvania"));
governors.add(new Governor(-71.51178, 46, "Rhode Island"));
governors.add(new Governor(-80.945007, 70, "South Carolina"));
governors.add(new Governor(-99.438828, 64, "South Dakota"));
governors.add(new Governor(-86.692345, 58, "Tennessee"));
governors.add(new Governor(-97.563461, 59, "Texas"));
governors.add(new Governor(-111.862434, 70, "Utah"));
governors.add(new Governor(-72.710686, 58, "Vermont"));
governors.add(new Governor(-78.169968, 60, "Virginia"));
governors.add(new Governor(-121.490494, 66, "Washington"));
governors.add(new Governor(-80.954453, 66, "West Virginia"));
governors.add(new Governor(-89.616508, 49, "Wisconsin"));
governors.add(new Governor(-107.30249, 55, "Wyoming"));
我们将使用 k 设置为 2 来运行 k-means。
列表 6.14 Governor.java 继续
KMeans<Governor> kmeans = new KMeans<>(2, governors);
List<KMeans<Governor>.Cluster> govClusters = kmeans.run(100);
for (int clusterIndex = 0; clusterIndex < govClusters.size();
clusterIndex++) {
System.out.printf("Cluster %d: %s%n", clusterIndex,
govClusters.get(clusterIndex).points);
}
}
}
因为它从随机的质心开始,所以每次运行 KMeans 都可能产生不同的聚类。需要一些人为分析才能看到聚类是否真正相关。以下结果是从一个确实有有趣聚类的运行中得到的:
Converged after 3 iterations.
Cluster 0: [Alabama: (longitude: -86.79113, age: 72.0), Arizona: (longitude: -111.431221, age: 53.0), Arkansas: (longitude: -92.373123, age: 66.0), Colorado: (longitude: -105.311104, age: 65.0), Connecticut: (longitude:
-72.755371, age: 61.0), Delaware: (longitude: -75.507141, age: 61.0), Florida: (longitude: -81.686783, age: 64.0), Georgia: (longitude: -83.643074, age: 74.0), Illinois: (longitude: -88.986137, age: 60.0), Indiana: (longitude: -86.258278, age: 49.0), Iowa: (longitude: -93.210526, age: 57.0), Kansas: (longitude: -96.726486, age: 60.0), Kentucky: (longitude: -84.670067, age: 50.0), Louisiana: (longitude: -91.867805, age: 50.0), Maine: (longitude: -69.381927, age: 68.0), Maryland: (longitude: -76.802101, age: 61.0), Massachusetts: (longitude: -71.530106, age: 60.0), Michigan: (longitude:
-84.536095, age: 58.0), Minnesota: (longitude: -93.900192, age: 70.0), Mississippi: (longitude: -89.678696, age: 62.0), Missouri: (longitude:
-92.288368, age: 43.0), Montana: (longitude: -110.454353, age: 51.0), Nebraska: (longitude: -98.268082, age: 52.0), Nevada: (longitude:
-117.055374, age: 53.0), New Hampshire: (longitude: -71.563896, age: 42.0), New Jersey: (longitude: -74.521011, age: 54.0), New Mexico: (longitude:
-106.248482, age: 57.0), New York: (longitude: -74.948051, age: 59.0), North Carolina: (longitude: -79.806419, age: 60.0), North Dakota: (longitude:
-99.784012, age: 60.0), Ohio: (longitude: -82.764915, age: 65.0), Oklahoma: (longitude: -96.928917, age: 62.0), Pennsylvania: (longitude: -77.209755, age: 68.0), Rhode Island: (longitude: -71.51178, age: 46.0), South Carolina: (longitude: -80.945007, age: 70.0), South Dakota: (longitude: -99.438828, age: 64.0), Tennessee: (longitude: -86.692345, age: 58.0), Texas: (longitude: -97.563461, age: 59.0), Vermont: (longitude: -72.710686, age: 58.0), Virginia: (longitude: -78.169968, age: 60.0), West Virginia: (longitude:
-80.954453, age: 66.0), Wisconsin: (longitude: -89.616508, age: 49.0), Wyoming: (longitude: -107.30249, age: 55.0)]
Cluster 1: [Alaska: (longitude: -152.404419, age: 66.0), California: (longitude: -119.681564, age: 79.0), Hawaii: (longitude: -157.498337, age: 60.0), Idaho: (longitude: -114.478828, age: 75.0), Oregon: (longitude:
-122.070938, age: 56.0), Utah: (longitude: -111.862434, age: 70.0), Washington: (longitude: -121.490494, age: 66.0)]
第 1 个聚类代表极端的西部州,它们在地理上相邻(如果你认为阿拉斯加和夏威夷与太平洋沿岸州相邻)。它们都有相对较老的州长,因此形成了一个有趣的聚类。太平洋沿岸的人们是否喜欢较老的州长?我们不能从这些聚类中得出任何有结论性的东西,而只是相关性。图 6.3 展示了结果。正方形代表第 1 个聚类,圆圈代表第 0 个聚类。

图 6.3 集群 0 的数据点用圆圈表示,集群 1 的数据点用方块表示。
提示:使用随机初始化质心进行 k-means 聚类时,结果可能会有很大的变化。请确保对任何数据集运行 k-means 多次。
6.4 按长度聚类迈克尔·杰克逊的专辑
迈克尔·杰克逊发行了 10 张个人录音室专辑。在以下示例中,我们将通过查看两个维度:专辑长度(以分钟计)和曲目数量来对这些专辑进行聚类。这个示例与前面的州长示例形成了一个很好的对比,因为它甚至在没有运行 kmeans 的情况下也能很容易地看到原始数据集中的聚类。这样的示例可以是一个很好的调试聚类算法实现的方法。
注意:本章中的两个示例都使用了二维数据点,但 k-means 可以与任何维度的数据点一起工作。
以下示例以一个代码列表的形式完整呈现。在运行示例之前,如果你查看以下代码列表中的专辑数据,很明显迈克尔·杰克逊在其职业生涯的后期制作了较长的专辑。因此,这些专辑的两个聚类可能应该分别分为早期专辑和后期专辑。HIStory: Past, Present, and Future, Book I 是一个异常值,也可以逻辑上结束在自己的个人专辑聚类中。异常值是位于数据集正常范围之外的数据点。
列表 6.15 Album.java
package chapter6;
import java.util.ArrayList;
import java.util.List;
public class Album extends DataPoint {
private String name;
private int year;
public Album(String name, int year, double length, double tracks) {
super(List.*of*(length, tracks));
this.name = name;
this.year = year;
}
@Override
public String toString() {
return "(" + name + ", " + year + ")";
}
public static void main(String[] args) {
List<Album> albums = new ArrayList<>();
albums.add(new Album("Got to Be There", 1972, 35.45, 10));
albums.add(new Album("Ben", 1972, 31.31, 10));
albums.add(new Album("Music & Me", 1973, 32.09, 10));
albums.add(new Album("Forever, Michael", 1975, 33.36, 10));
albums.add(new Album("Off the Wall", 1979, 42.28, 10));
albums.add(new Album("Thriller", 1982, 42.19, 9));
albums.add(new Album("Bad", 1987, 48.16, 10));
albums.add(new Album("Dangerous", 1991, 77.03, 14));
albums.add(new Album("HIStory: Past, Present and Future, Book I", 1995, 148.58, 30));
albums.add(new Album("Invincible", 2001, 77.05, 16));
KMeans<Album> kmeans = new KMeans<>(2, albums);
List<KMeans<Album>.Cluster> clusters = kmeans.run(100);
for (int clusterIndex = 0; clusterIndex < clusters.size();
clusterIndex++) {
System.out.printf("Cluster %d Avg Length %f Avg Tracks %f: %s%n",
clusterIndex, clusters.get(clusterIndex).centroid.dimensions.get(0),
clusters.get(clusterIndex).centroid.dimensions.get(1),
clusters.get(clusterIndex).points);
}
}
}
注意,属性名称和年份仅用于标记目的,不包括在实际的聚类中。以下是一个示例输出:
Converged after 1 iterations.
Cluster 0 Avg Length -0.5458820039179509 Avg Tracks -0.5009878988684237: [(Got to Be There, 1972), (Ben, 1972), (Music & Me, 1973), (Forever, Michael, 1975), (Off the Wall, 1979), (Thriller, 1982), (Bad, 1987)]
Cluster 1 Avg Length 1.2737246758085525 Avg Tracks 1.168971764026322: [(Dangerous, 1991), (HIStory: Past, Present and Future, Book I, 1995), (Invincible, 2001)]
报告的聚类平均值很有趣。请注意,平均值是 z 分数。聚类 1 的三个专辑,迈克尔·杰克逊的最后三个专辑,比他所有十张个人专辑的平均值长了一个标准差。
6.5 K-means 聚类问题及其扩展
当使用随机起始点实现 k-means 聚类时,它可能会完全错过数据中的有用分割点。这通常会导致操作员进行大量的试错。如果操作员没有对应该存在多少数据组有良好的洞察力,那么确定“k”的正确值(集群数量)也是困难且容易出错的。
存在着更复杂的 k-means 版本,它们可以尝试对这些有问题的变量做出有根据的猜测或进行自动的试错。一个流行的变体是 k-means++,它通过根据每个点到所有点的距离的概率分布来选择质心,而不是完全随机,从而尝试解决初始化问题。对于许多应用来说,根据事先已知的数据信息为每个质心选择良好的起始区域是一个更好的选择——换句话说,这是一种 k-means 版本,其中算法的用户选择初始质心。
k-means 聚类的运行时间与数据点的数量、聚类的数量以及数据点的维度数成正比。当有大量具有大量维度的点时,其基本形式可能变得无法使用。有一些扩展尝试通过评估在计算之前一个点是否真的有可能移动到另一个聚类,来减少每一点和每个中心之间的计算量。对于大量点或高维数据集的另一个选项是仅对数据点进行抽样,通过 k-means。这将近似于完整 k-means 算法可能找到的聚类。
数据集中的异常值可能会导致 k-means 出现奇怪的结果。如果初始质心恰好位于异常值附近,它可能会形成一个只有一个点的聚类(正如在迈克尔·杰克逊的 HIStory 专辑例子中可能发生的那样)。移除异常值后,k-means 可能会运行得更好。
最后,均值并不总是被认为是衡量中心的好方法。K-medians 考虑每个维度的中位数,而 k-medoids 则使用数据集中的实际点作为每个聚类的中心。选择这些中心方法背后的统计原因超出了本书的范围,但常识告诉我们,对于复杂的问题,尝试每种方法并采样结果可能是有价值的。每种方法的实现并没有太大的不同。
6.6 现实世界应用
聚类通常是数据科学家和统计分析师的领域。它在各个领域广泛用作解释数据的方式。特别是,当对数据集的结构知之甚少时,k-means 聚类是一种有用的技术。
在数据分析中,聚类是一种基本技术。想象一个警察局想知道在哪里部署警察巡逻。想象一个快餐连锁店想要找出其最佳顾客在哪里,以便发送促销活动。想象一个船只租赁运营商通过分析事故发生的时间和原因来最小化事故。现在想象他们如何使用聚类来解决他们的问题。
聚类有助于模式识别。聚类算法可能会检测到人眼忽略的模式。例如,聚类有时在生物学中用于识别不一致的细胞群。
在图像识别中,聚类有助于识别非明显特征。单个像素可以被当作数据点,它们之间的关系由距离和颜色差异定义。
在政治学中,聚类有时用于寻找目标选民。政党能否找到一个选民不满的单一区域,他们应该在这个区域集中他们的竞选资金?相似选民可能关注哪些问题?
6.7 练习
-
创建一个函数,可以将数据从 CSV 文件导入到 DataPoints 中。
-
使用 GUI 框架(如 AWT、Swing 或 JavaFX)或绘图库创建一个函数,该函数可以创建一个对 KMeans 在二维数据集上任何运行结果的彩色散点图。
-
为 KMeans 创建一个新的初始化器,它接受初始质心位置而不是随机分配。
-
研究并实现 k-means++算法
7. 相对简单的神经网络
当我们今天听到人工智能的进步时,它们通常涉及一个被称为机器学习(计算机在没有明确告知的情况下学习一些新信息)的特定子领域。更常见的是,这些进步是由一种称为神经网络的特定机器学习技术驱动的。尽管它们几十年前就被发明了,但神经网络正经历一种复兴,因为改进的硬件和新的研究驱动的软件技术使得一种称为深度学习的新范式成为可能。
深度学习已经证明是一种广泛适用的技术。它被发现对从对冲基金算法到生物信息学等各个方面都很有用。消费者已经熟悉的两个深度学习应用是图像识别和语音识别。如果你曾经询问你的数字助手天气如何,或者有一个照片程序识别你的脸,那么可能就有一些深度学习在发挥作用。
深度学习技术利用与简单神经网络相同的构建块。在本章中,我们将通过构建一个简单的神经网络来探索这些块。它可能不是最先进的,但它将为你理解深度学习(它基于比我们构建的更复杂的神经网络)打下基础。大多数机器学习实践者不会从头开始构建神经网络。相反,他们使用流行的、高度优化的现成框架,这些框架承担了繁重的工作。尽管本章不会帮助你学习如何使用任何特定的框架,而且我们将构建的网络对于实际应用来说可能没有用,但它将帮助你理解这些框架在底层是如何工作的。
7.1 生物基础?
人类大脑是现存最令人难以置信的计算设备。它不能像微处理器那样快速处理数字,但它的适应新情况、学习新技能和创造力的能力是任何已知机器所无法比拟的。自从计算机诞生以来,科学家们就对模拟大脑的机制感兴趣。大脑中的每个神经细胞都被称为神经元。大脑中的神经元通过称为突触的连接相互连接。电信号通过突触传递,为这些神经元网络(也称为神经网络)提供动力。
注意:为了类比,前面关于生物神经元的描述是一种粗略的简化。实际上,生物神经元具有像轴突、树突和核这样的部分,这些你可能从高中生物学中记得。而突触实际上是神经元之间的间隙,神经递质在这里被分泌出来,以使这些电信号得以传递。
尽管科学家们已经确定了神经元的部分和功能,但生物神经网络如何形成复杂思维模式的细节仍然了解不多。它们是如何处理信息的?它们是如何形成原始思维的?我们对大脑如何工作的了解大部分来自于宏观层面的观察。功能性磁共振成像(fMRI)扫描显示,当人类进行特定活动或思考特定思维时,血液流动到大脑的哪些区域(如图 7.1 所示)。这些和其他宏观技术可以导致对各个部分如何连接的推断,但它们并不能解释单个神经元如何帮助形成新思维的奥秘。

图 7.1 研究人员正在研究大脑的 fMRI 图像。fMRI 图像并没有告诉我们太多关于单个神经元的功能或神经网络是如何组织的。(来源:美国国立精神健康研究所)
全球各地的科学家团队正在竞相解开大脑的秘密,但请考虑这一点:人脑大约有 1000 亿个神经元,每个神经元可能与其他多达数万个神经元相连。即使对于拥有数十亿个逻辑门和数太字节内存的计算机来说,使用今天的技术也无法对人脑进行建模。在可预见的未来,人类仍然可能是最先进的多用途学习实体。
注意:与人类在能力上相当的多用途学习机器是所谓的强人工智能(也称为人工通用智能)的目标。在当前的历史时刻,这仍然是科幻小说的内容。"弱人工智能"是你每天都能看到的类型:计算机智能地解决它们预先配置好的特定任务。
如果生物神经网络没有被完全理解,那么建模它们是如何成为一种有效的计算技术的呢?尽管被称为人工神经网络的数字神经网络受到了生物神经网络的启发,但相似之处到此为止。现代人工神经网络并不声称它们的工作方式与生物对应物相同。实际上,这是不可能的,因为我们一开始就没有完全理解生物神经网络是如何工作的。
7.2 人工神经网络
在本节中,我们将探讨可能是最常见的人工神经网络类型,即具有反向传播的前馈网络——这是我们稍后将要开发的同一种类型。前馈意味着信号通常在网络中单向移动。反向传播意味着我们将确定每个信号通过网络传输结束时产生的错误,并尝试将这些错误的修复分布回网络中,特别是影响这些错误的神经元。还有许多其他类型的人工神经网络,也许这一章会激发你对进一步探索的兴趣。
7.2.1 神经元
人工神经网络中最小的单元是神经元。它包含一个权重向量,这些权重只是浮点数。一个输入向量(也是浮点数)被传递给神经元。它使用点积将那些输入与其权重结合起来。然后,它对那个乘积运行一个激活函数,并将结果作为其输出输出。这个动作可以被认为与真实神经元的放电相似。
激活函数是神经元输出的转换器。激活函数几乎总是非线性的,这使得神经网络能够表示非线性问题的解决方案。如果没有激活函数,整个神经网络将只是一个线性变换。图 7.2 显示了单个神经元及其操作。

图 7.2 一个神经元将其权重与输入信号结合以产生一个由激活函数修改的输出信号。
注意:本节中有些数学术语你可能自预微积分或线性代数课程以来就没有见过。解释向量或点积是什么超出了本章的范围,但通过跟随本章的内容,即使你不理解所有的数学,你也很可能会对神经网络的作用有一个直观的理解。在本章的后面部分,将会有一些微积分的内容,包括导数和偏导数的应用,但即使你不理解所有的数学,你也应该能够理解代码。实际上,本章不会解释如何使用微积分推导公式。相反,它将专注于使用推导。
7.2.2 层
在典型的前馈人工神经网络中,神经元被组织成层。每一层由一行或一列(取决于图示;两者是等价的)中排列的一定数量的神经元组成。在前馈网络中,这是我们将要构建的,信号总是从一层单向传递到下一层。每一层的神经元将它们的输出信号发送给下一层的神经元作为输入。每一层的每个神经元都与下一层的每个神经元相连。
第一层被称为输入层,它从某个外部实体接收信号。最后一层被称为输出层,其输出通常必须由外部行为者解释才能得到智能结果。输入层和输出层之间的层被称为隐藏层。在我们将在本章中构建的简单神经网络中,只有一个隐藏层,但深度学习网络有很多。图 7.3 显示了简单网络中层的协同工作。注意,一个层的输出被用作下一层每个神经元的输入。

图 7.3 一个简单的神经网络,包含一个输入层两个神经元,一个隐藏层四个神经元,以及一个输出层三个神经元。图中每一层的神经元数量是任意的。
这些层只是操作浮点数。输入层的输入是浮点数,输出层的输出也是浮点数。
显然,这些数字必须代表一些有意义的含义。想象一下,这个网络被设计用来分类小型的黑白动物图像。也许输入层有 100 个神经元,代表 10×10 像素动物图像中每个像素的灰度强度,而输出层有 5 个神经元,代表图像是哺乳动物、爬行动物、两栖动物、鱼类或鸟类的可能性。最终的分类可以通过具有最高浮点输出的输出神经元来确定。如果输出数字分别是 0.24、0.65、0.70、0.12 和 0.21,那么图像将被判定为两栖动物。
7.2.3 反向传播
最后一个拼图,也是本质上最复杂的一部分,是反向传播。反向传播找到神经网络输出的错误,并使用它来修改神经元的权重,以减少后续运行中的错误。最负责错误的神经元被最严重地修改。但错误从何而来?我们如何知道错误?错误来自神经网络使用过程中的一个阶段,称为训练。
提示:本节中(用英语)详细说明了几个数学公式的步骤。伪公式(不使用正确的符号)见附图。这种方法将使那些不熟悉(或对数学符号不熟练)的人也能读懂公式。如果你对更正式的符号(以及公式的推导)感兴趣,请参阅诺维格和拉塞尔的《人工智能》第十八章。1
在可以使用之前,大多数神经网络必须经过训练。我们必须知道某些输入的正确输出,以便我们可以使用预期输出和实际输出之间的差异来找到误差并修改权重。换句话说,神经网络在被告知一组特定输入的正确答案之前,一无所知,这样它们就可以为其他输入做好准备。反向传播仅在训练期间发生。
注意:由于大多数神经网络都需要训练,因此它们被视为一种监督学习。回想第六章,k-means 算法和其他聚类算法被视为一种无监督学习,因为一旦启动,就不需要外部干预。除了本章描述的神经网络之外,还有其他类型的神经网络不需要预训练,被视为一种无监督学习。
反向传播的第一步是计算神经网络对于某些输入的输出与预期输出之间的误差。这个误差会分布到输出层中的所有神经元上。(每个神经元都有一个预期输出和实际输出。)然后,输出神经元激活函数的导数被应用于激活函数应用之前的神经元输出。(我们缓存了其预激活函数输出。)这个结果乘以神经元的误差以找到其delta。这个用于找到 delta 的公式使用的是偏导数,其微积分推导超出了本书的范围,但我们的基本思路是确定每个输出神经元对误差的贡献有多大。参见图 7.4 了解这个计算的示意图。

图 7.4 展示了在训练的反向传播阶段计算输出神经元 delta 的机制。
然后,必须计算网络中隐藏层(s)中每个神经元的 delta。我们必须确定每个神经元对输出层中不正确输出的责任有多大。输出层的 delta 用于计算前一个隐藏层的 delta。对于每个前一层,通过取下一层的权重与特定神经元以及下一层已计算的 delta 的点积来计算 delta。这个点积乘以应用于神经元最后输出的激活函数的导数(在激活函数应用之前缓存)以得到神经元的 delta。再次强调,这个公式是使用偏导数推导出来的,你可以在更数学化的文本中了解更多。
图 7.5 显示了隐藏层中神经元的实际 delta 计算。在一个具有多个隐藏层的网络中,O1、O2 和 O3 可以是下一隐藏层的神经元,而不是输出层的神经元。

图 7.5 每个隐藏层和输出层神经元的权重都使用前一步计算出的 delta、先前权重、先前输入以及用户确定的学习率进行更新。
最后,但同样重要的是,网络中每个神经元的所有权重都必须更新。它们可以通过将每个单独权重的最后输入与神经元的 delta 以及一个称为学习率的值相乘,并将这个值加到现有权重上来更新。这种修改神经元权重的方 法被称为梯度下降。它就像沿着表示神经元误差函数的斜坡向下爬,朝着最小误差点前进。delta 代表我们想要爬的方向,学习率影响我们爬的速度。在没有试错的情况下,很难确定一个未知问题的良好学习率。图 7.6 显示了隐藏层和输出层中每个权重的更新方式。

图 7.6 如何计算隐藏层中神经元的 delta
一旦权重被更新,神经网络就准备好再次使用另一个输入和预期输出进行训练。这个过程会重复进行,直到神经网络的用户认为网络已经很好地训练好了。这可以通过测试它对具有已知正确输出的输入来确定。
反向传播是复杂的。如果你还没有完全掌握所有细节,请不要担心。本节中的解释可能不足以说明问题。理想情况下,实现反向传播将使你的理解达到新的水平。当我们实现神经网络和反向传播时,请记住这个总体主题:反向传播是一种根据其对错误输出的责任来调整网络中每个单独权重的方 法。
7.2.4 整体图景
在本节中,我们涵盖了大量的内容。即使细节仍然有些模糊,但对于具有反向传播的前馈网络,保持主要主题在心中是很重要的:
-
信号(浮点数)在一个方向上通过分层组织的神经元移动。每个层的每个神经元都与下一层的每个神经元相连。
-
每个神经元(除了输入层)通过将接收到的信号与权重(也是浮点数)结合并应用激活函数来处理这些信号。
-
在一个称为训练的过程中,网络输出与预期输出进行比较,以计算误差。
-
误差通过网络反向传播(返回到它们来源的地方)以修改权重,使它们更有可能产生正确的输出。
训练神经网络的方 法比这里解释的要多。还有许多其他信号在神经网络中移动的方式。这里解释的,以及我们将要实现的,只是特别常见的一种形式,它作为相当不错的入门介绍。附录 B 列出了更多关于学习神经网络(包括其他类型)和数学的资源。
7.3 前置知识
神经网络利用需要大量浮点运算的数学机制。在我们开发简单的神经网络的实际结构之前,我们需要一些数学原语。这些简单的原语在接下来的代码中得到了广泛的应用,所以如果你能找到加速它们的方法,这将真正提高你神经网络的性能。
警告 本章中的代码复杂度可能比书中任何其他章节都要高。有很多准备工作,实际结果只有在最后才能看到。有很多关于神经网络资源可以帮助你在极少的代码行中构建一个神经网络,但这个例子旨在探索机制以及不同组件如何以可读和可扩展的方式协同工作。这就是我们的目标,即使代码稍微长一些,表达也更丰富。
7.3.1 点积
如你所回忆,点积在正向传播阶段和反向传播阶段都是必需的。我们将保持我们的静态实用函数在 Util 类中。像本章中所有为了说明目的而编写的代码一样,这是一个非常简单的实现,没有考虑性能。在生产库中,将使用在第 7.6 节中讨论的向量指令。
列表 7.1 Util.java
package chapter7;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public final class Util {
public static double dotProduct(double[] xs, double[] ys) {
double sum = 0.0;
for (int i = 0; i < xs.length; i++) {
sum += xs[i] * ys[i];
}
return sum;
}
7.3.2 激活函数
回想一下,激活函数在信号传递到下一层之前会转换神经元的输出(见图 7.2)。激活函数有两个目的:它允许神经网络表示不仅仅是线性变换的解(只要激活函数本身不是仅仅是线性变换),并且它可以保持每个神经元的输出在某个范围内。激活函数应该有一个可计算的导数,以便它可以用于反向传播。
Sigmoid 函数是一组流行的激活函数。一个特别受欢迎的 sigmoid 函数(通常简称为“sigmoid 函数”)如图 7.7 所示(图中称为 S(x )),以及其方程和导数(S'(x ))。sigmoid 函数的结果始终是一个介于 0 和 1 之间的值,这对网络来说很有用,你很快就会看到。你很快就会看到图中的公式在代码中的实现。
有其他激活函数,但我们将使用 sigmoid 函数。它及其导数很容易实现。以下是将图 7.7 中的公式直接转换为代码的简单方法。
列表 7.2 Util.java 续
// the classic sigmoid activation function
public static double sigmoid(double x) {
return 1.0 / (1.0 + Math.*exp*(-x));
}
public static double derivativeSigmoid(double x) {
double sig = *sigmoid*(x);
return sig * (1.0 - sig);
}

图 7.7 sigmoid 激活函数(S(x))将始终返回一个介于 0 和 1 之间的值。请注意,其导数也容易计算(S' (x))。
7.4 构建网络
我们将创建类来模拟网络中的所有三个组织单元:神经元、层和网络本身。为了简化,我们将从最小的(神经元)开始,过渡到中央组织组件(层),然后构建到最大的(整个网络)。当我们从最小组件过渡到最大组件时,我们将封装前一个级别。神经元只知道自身。层知道它们包含的神经元和其他层。网络知道所有层。
注意:本章有许多长代码行,它们无法整齐地适应印刷书的列宽限制。我强烈建议您从本书的源代码仓库下载本章的源代码,并在阅读时在您的计算机屏幕上跟随:github.com/davecom/ClassicComputerScienceProblemsInJava。
7.4.1 实现神经元
让我们从神经元开始。一个单独的神经元将存储许多状态信息,包括其权重、delta、学习率、其最后输出的缓存以及其激活函数,以及该激活函数的导数。其中一些元素可能更有效地存储在更高一层(未来 Layer 类),但为了说明目的,它们包含在下面的 Neuron 类中。
列表 7.3 Neuron.java
package chapter7;
import java.util.function.DoubleUnaryOperator;
public class Neuron {
public double[] weights;
public final double learningRate;
public double outputCache;
public double delta;
public final DoubleUnaryOperator activationFunction;
public final DoubleUnaryOperator derivativeActivationFunction;
public Neuron(double[] weights, double learningRate, DoubleUnaryOperator activationFunction, DoubleUnaryOperator derivativeActivationFunction) {
this.weights = weights;
this.learningRate = learningRate;
outputCache = 0.0;
delta = 0.0;
this.activationFunction = activationFunction;
this.derivativeActivationFunction = derivativeActivationFunction;
}
public double output(double[] inputs) {
outputCache = Util.*dotProduct*(inputs, weights);
return activationFunction.applyAsDouble(outputCache);
}
}
大多数这些参数都在构造函数中初始化。因为当 Neuron 首次创建时,delta 和 outputCache 是未知的,所以它们只是初始化为 0.0。这些变量中的几个(学习率、激活函数、导数激活函数)看起来是预设的,那么为什么我们在神经元级别使它们可配置呢?如果这个 Neuron 类要与其他类型的神经网络一起使用,那么这些值可能因神经元而异,因此它们是可配置的,以实现最大的灵活性。甚至有神经网络在解决方案接近时改变学习率,并自动尝试不同的激活函数。由于我们的变量是 final 的,它们在流过程中不能改变,但将它们改为非 final 是一个简单的代码更改。
除了构造函数之外,还有一个方法是 output()。output() 方法接收传入神经元的输入信号(输入),并应用本章前面讨论过的公式(见图 7.2)。输入信号通过点积与权重结合,并将结果缓存到 outputCache 中。回想一下关于反向传播的部分,这个在应用激活函数之前获得的价值用于计算 delta。最后,在信号被发送到下一层(通过从 output() 返回)之前,对该信号应用激活函数。
就这样!这个网络中的单个神经元相当简单。它不能做很多,除了接收输入信号,对其进行转换,并将其发送出去进一步处理。它维护了其他类使用的几个状态元素。
7.4.2 实现层
我们的网络中的每一层都需要维护三块状态:其神经元、它之前的一层,以及一个输出缓存。输出缓存类似于神经元的缓存,但高一个层级。它缓存了该层中每个神经元的输出(在应用激活函数之后)。
在创建时,层的主要责任是初始化其神经元。因此,我们的 Layer 类的构造函数需要知道它应该初始化多少个神经元,它们的激活函数是什么,以及它们的学习率是什么。在这个简单的网络中,层的每个神经元都有相同的激活函数和学习率。
列表 7.4 Layer.java
package chapter7;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.function.DoubleUnaryOperator;
public class Layer {
public Optional<Layer> previousLayer;
public List<Neuron> neurons = new ArrayList<>();
public double[] outputCache;
public Layer(Optional<Layer> previousLayer, int numNeurons, double
learningRate, DoubleUnaryOperator activationFunction, DoubleUnaryOperator derivativeActivationFunction) {
this.previousLayer = previousLayer;
Random random = new Random();
for (int i = 0; i < numNeurons; i++) {
double[] randomWeights = null;
if (previousLayer.isPresent()) {
randomWeights = random.doubles(previousLayer.get().neurons.size()).toArray();
}
Neuron neuron = new Neuron(randomWeights, learningRate,
activationFunction, derivativeActivationFunction);
neurons.add(neuron);
}
outputCache = new double[numNeurons];
}
当信号通过网络前向传递时,层必须通过每个神经元处理它们。(记住,层中的每个神经元都接收来自前一层的每个神经元的信号。)outputs() 正是做这件事。outputs() 还返回处理后的结果(将被网络传递给下一层)并缓存输出。如果没有前一层,这表明该层是输入层,它只需将信号传递给下一层。
列表 7.5 Layer.java 继续内容
public double[] outputs(double[] inputs) {
if (previousLayer.isPresent()) {
outputCache = neurons.stream().mapToDouble(n -> n.output(inputs)).toArray();
} else {
outputCache = inputs;
}
return outputCache;
}
在反向传播中需要计算两种不同类型的 delta:输出层神经元的 delta 和隐藏层神经元的 delta。公式在图 7.4 和 7.5 中描述,以下两个方法是这些公式的直接翻译。这些方法将在反向传播期间由网络调用。
列表 7.6 Layer.java 继续内容
// should only be called on output layer
public void calculateDeltasForOutputLayer(double[] expected) {
for (int n = 0; n < neurons.size(); n++) {
neurons.get(n).delta = neurons.get(n).derivativeActivationFunction.applyAsDouble(neurons.get(n)
.outputCache)
* (expected[n] - outputCache[n]);
}
}
// should not be called on output layer
public void calculateDeltasForHiddenLayer(Layer nextLayer) {
for (int i = 0; i < neurons.size(); i++) {
int index = i;
double[] nextWeights = nextLayer.neurons.stream().mapToDouble(n -> n.weights[index]).toArray();
double[] nextDeltas = nextLayer.neurons.stream().mapToDouble(n -> n.delta).toArray();
double sumWeightsAndDeltas = Util.*dotProduct*(nextWeights,
nextDeltas);
neurons.get(i).delta = neurons.get(i).derivativeActivationFunction
.applyAsDouble(neurons.get(i).outputCache) * sumWeightsAndDeltas;
}
}
}
7.4.3 实现网络
网络本身只有一块状态:它管理的层。Network 类负责初始化其组成部分层。
构造函数接收一个 int 数组,描述网络的架构。例如,数组 {2, 4, 3} 描述了一个输入层有 2 个神经元、隐藏层有 4 个神经元、输出层有 3 个神经元的网络。在这个简单的网络中,我们将假设网络中的所有层都将使用相同的激活函数和相同的学习率来为其神经元服务。
列表 7.7 Network.java
package chapter7;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.DoubleUnaryOperator;
import java.util.function.Function;
public class Network<T> {
private List<Layer> layers = new ArrayList<>();
public Network(int[] layerStructure, double learningRate,
DoubleUnaryOperator activationFunction, DoubleUnaryOperator derivativeActivationFunction) {
if (layerStructure.length < 3) {
throw new IllegalArgumentException("Error: Should be at least 3 layers (1 input, 1 hidden, 1 output).");
}
// input layer
Layer inputLayer = new Layer(Optional.*empty*(), layerStructure[0], learningRate, activationFunction, derivativeActivationFunction);
layers.add(inputLayer);
// hidden layers and output layer
for (int i = 1; i < layerStructure.length; i++) {
Layer nextLayer = new Layer(Optional.*of*(layers.get(i - 1)),
layerStructure[i], learningRate, activationFunction, derivativeActivationFunction);
layers.add(nextLayer);
}
}
注意:泛型类型 T 将网络与数据集最终分类类别类型链接起来。它仅在类的最终方法 validate() 中使用。
神经网络的输出是信号依次通过所有层的结果。
列表 7.8 Network.java 继续内容
// Pushes input data to the first layer, then output from the first
// as input to the second, second to the third, etc.
private double[] outputs(double[] input) {
double[] result = input;
for (Layer layer : layers) {
result = layer.outputs(result);
}
return result;
}
backpropagate() 方法负责计算网络中每个神经元的 delta 值。它按顺序使用 Layer 方法的 calculateDeltasForOutputLayer() 和 calculateDeltasForHiddenLayer()。 (回想一下,在反向传播中,delta 是向后计算的。)它传递给定输入集的输出期望值以计算 calculateDeltasForOutputLayer()。该方法使用期望值来找到用于 delta 计算的错误。
列表 7.9 Network.java 继续部分
// Figure out each neuron's changes based on the errors of the output
// versus the expected outcome
private void backpropagate(double[] expected) {
// calculate delta for output layer neurons
int lastLayer = layers.size() - 1;
layers.get(lastLayer).calculateDeltasForOutputLayer(expected);
// calculate delta for hidden layers in reverse order
for (int i = lastLayer - 1; i >= 0; i--) {
layers.get(i).calculateDeltasForHiddenLayer(layers.get(i + 1));
}
}
backpropagate() 负责计算所有 delta 值,但它实际上并不修改网络中的任何权重。必须在 backpropagate() 之后调用 updateWeights(),因为权重修改依赖于 delta。此方法直接来源于图 7.6 中的公式。
列表 7.10 Network.java 继续部分
// backpropagate() doesn't actually change any weights
// This function uses the deltas calculated in backpropagate() to
// actually make changes to the weights
private void updateWeights() {
for (Layer layer : layers.subList(1, layers.size())) {
for (Neuron neuron : layer.neurons) {
for (int w = 0; w < neuron.weights.length; w++) {
neuron.weights[w] = neuron.weights[w] + (neuron.learningRate * layer.previousLayer.get().outputCache[w] * neuron.delta);
}
}
}
}
神经元的权重在每个训练回合结束时进行修改。必须向网络提供训练集(输入与期望输出的组合)。train() 方法接收输入的双精度浮点数数组列表和期望输出的双精度浮点数数组列表。它将每个输入通过网络运行,然后通过调用带有期望输出的 backpropagate()(之后调用 updateWeights())来更新其权重。尝试在以下训练函数中添加代码以打印出错误率,以查看网络如何在梯度下降过程中逐渐降低其错误率,就像它沿着山丘滚动一样。
列表 7.11 Network.java 继续部分
// train() uses the results of outputs() run over many inputs and compared
// against expecteds to feed backpropagate() and updateWeights()
public void train(List<double[]> inputs, List<double[]> expecteds) {
for (int i = 0; i < inputs.size(); i++) {
double[] xs = inputs.get(i);
double[] ys = expecteds.get(i);
outputs(xs);
backpropagate(ys);
updateWeights();
}
}
最后,在训练好一个网络之后,我们需要对其进行测试。validate() 函数接收输入和期望输出(与 train() 函数类似),但它使用它们来计算准确率百分比而不是执行训练。假设网络已经训练好。validate() 还接收一个函数,interpretOutput(),用于解释神经网络的输出并将其与期望输出进行比较。也许期望输出是一个像 "Amphibian" 这样的字符串,而不是一组浮点数。interpretOutput() 必须接收网络输出的浮点数并将其转换为与期望输出可比较的东西。这是一个针对特定数据集的定制函数。validate() 返回正确分类的数量、测试的总样本数和正确分类的百分比。这三个值被封装在内部 Results 类型中。
列表 7.12 Network.java 继续部分
public class Results {
public final int correct;
public final int trials;
public final double percentage;
public Results(int correct, int trials, double percentage) {
this.correct = correct;
this.trials = trials;
this.percentage = percentage;
}
}
// this function will return the correct number of trials
// and the percentage correct out of the total
public Results validate(List<double[]> inputs, List<T> expecteds, Function<double[], T> interpret) {
int correct = 0;
for (int i = 0; i < inputs.size(); i++) {
double[] input = inputs.get(i);
T expected = expecteds.get(i);
T result = interpret.apply(outputs(input));
if (result.equals(expected)) {
correct++;
}
}
double percentage = (double) correct / (double) inputs.size();
return new Results(correct, inputs.size(), percentage);
}
}
神经网络已完成!它准备好用一些实际问题进行测试。尽管我们构建的架构足够通用,可以用于各种问题,但我们将专注于一种流行的问题类型:分类。
7.5 分类问题
在第六章中,我们使用 k-means 聚类对数据集进行了分类,没有预先设定关于每个数据点所属位置的想法。在聚类中,我们知道我们想要找到数据类别,但我们事先不知道这些类别是什么。在分类问题中,我们也在尝试对数据集进行分类,但存在预设的类别。例如,如果我们试图对一组动物图片进行分类,我们可能会提前决定类别,如哺乳动物、爬行动物、两栖动物、鱼类和鸟类。
有许多机器学习技术可以用于分类问题。你可能听说过支持向量机、决策树或朴素贝叶斯分类器。最近,神经网络在分类领域得到了广泛的应用。与一些其他分类算法相比,它们在计算上更为密集,但它们对看似任意类型数据的分类能力使它们成为一种强大的技术。神经网络分类器是许多现代照片软件中推动有趣图像分类的幕后力量。
为什么对使用神经网络进行分类问题再次产生了兴趣?硬件已经足够快,与其他算法相比,额外的计算量使得这些好处值得。
7.5.1 数据归一化
我们想要处理的数据集通常在输入我们的算法之前需要一些“清理”。清理可能包括删除多余的字符、删除重复项、修复错误和其他琐事。对于我们将要处理的两个数据集,我们需要执行的清理方面是归一化。在第六章中,我们通过 KMeans 类中的 zScoreNormalize()方法来完成这项工作。归一化是将记录在不同刻度上的属性转换为公共刻度的过程。
由于使用了 Sigmoid 激活函数,我们网络中的每个神经元输出的值都在 0 到 1 之间。对于输入数据集中的属性,一个介于 0 到 1 之间的刻度似乎是有意义的。将某个范围转换为 0 到 1 的范围并不具有挑战性。对于特定属性范围内的任何值 V,其最大值(max)和最小值(min),公式只是 newV = (oldV - min) / (max - min)。这种操作被称为特征缩放。以下是一个添加到 Util 类的 Java 实现,以及两个用于从 CSV 文件加载数据和查找数组中最大数的实用方法,这将使本章的其余部分更加方便。
列表 7.13 Util.java 继续
// Assume all rows are of equal length
// and feature scale each column to be in the range 0 - 1
public static void normalizeByFeatureScaling(List<double[]> dataset) {
for (int colNum = 0; colNum < dataset.get(0).length; colNum++) {
List<Double> column = new ArrayList<>();
for (double[] row : dataset) {
column.add(row[colNum]);
}
double maximum = Collections.*max*(column);
double minimum = Collections.*min*(column);
double difference = maximum - minimum;
for (double[] row : dataset) {
row[colNum] = (row[colNum] - minimum) / difference;
}
}
}
// Load a CSV file into a List of String arrays
public static List<String[]> loadCSV(String filename) {
try (InputStream inputStream = Util.class.getResourceAsStream(filename)) {
InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
return bufferedReader.lines().map(line -> line.split(","))
.collect(Collectors.*toList*());
}
catch (IOException e) {
e.printStackTrace();
throw new RuntimeException(e.getMessage(), e);
}
}
// Find the maximum in an array of doubles
public static double max(double[] numbers) {
return Arrays.*stream*(numbers)
.max()
.orElse(Double.MIN_VALUE);
}
}
看一下 normalizeByFeatureScaling()函数中的数据集参数。它是对一个双精度数组列表的引用,该列表将在原地被修改。换句话说,normalizeByFeatureScaling()函数不接收数据集的副本。它接收原始数据集的引用。这是一种我们想要修改一个值而不是接收转换后的副本的情况。Java 是按值传递的,但在这个实例中,我们按值传递了一个引用,因此得到了对同一列表的引用的副本。
还要注意,我们的程序假设数据集是二维浮点数列表,排列为双精度数组列表。
7.5.2 经典的鸢尾花数据集
正如存在经典的计算机科学问题一样,机器学习中也有经典的数据集。这些数据集用于验证新技术并将其与现有技术进行比较。它们还作为学习机器学习的新手的良好起点。最著名的是鸢尾花数据集。最初在 20 世纪 30 年代收集,该数据集包含 150 个鸢尾花植物(漂亮的花)样本,分为三个不同的物种(每种 50 个)。每株植物在四个不同的属性上进行了测量:花萼长度、花萼宽度、花瓣长度和花瓣宽度。
值得注意的是,神经网络并不关心各种属性代表什么。其训练模型在重要性方面对花萼长度和花瓣长度没有区别。如果应该做出这样的区分,那么神经网络的使用者需要做出适当的调整。
伴随本书的源代码库包含一个逗号分隔值(CSV)文件,其中包含鸢尾花数据集。2 鸢尾花数据集来自加州大学伯克利分校的 UCI 机器学习库。3 CSV 文件只是一个以逗号分隔值的文本文件。它是表格数据的常见交换格式,包括电子表格。
这里是来自 iris.csv 的几行内容:
5.1,3.5,1.4,0.2,Iris-setosa
4.9,3.0,1.4,0.2,Iris-setosa
4.7,3.2,1.3,0.2,Iris-setosa
4.6,3.1,1.5,0.2,Iris-setosa
5.0,3.6,1.4,0.2,Iris-setosa
每一行代表一个数据点。这四个数字代表四个属性(花萼长度、花萼宽度、花瓣长度和花瓣宽度),就它们实际代表的内容而言,对我们来说是任意的。每行末尾的名称代表特定的鸢尾花种类。这五行都是同一物种,因为样本是从文件顶部取出的,而且三种物种聚集在一起,每种有五十行。
要从磁盘读取 CSV 文件,我们将使用 Java 标准库中的几个函数。这些函数被封装在我们之前在 Util 类中定义的 loadCSV()方法中。除了这几行之外,IrisTest 类的构造函数的其余部分,即我们实际运行分类的类,只是将 CSV 文件中的数据重新排列,以便准备由我们的网络进行训练和验证。
列表 7.14 IrisTest.java
package chapter7;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class IrisTest {
public static final String IRIS_SETOSA = "Iris-setosa";
public static final String IRIS_VERSICOLOR = "Iris-versicolor";
public static final String IRIS_VIRGINICA = "Iris-virginica";
private List<double[]> irisParameters = new ArrayList<>();
private List<double[]> irisClassifications = new ArrayList<>();
private List<String> irisSpecies = new ArrayList<>();
public IrisTest() {
// make sure iris.csv is in the right place in your path
List<String[]> irisDataset = Util.*loadCSV*("/chapter7/data/iris.csv");
// get our lines of data in random order
Collections.*shuffle*(irisDataset);
for (String[] iris : irisDataset) {
// first four items are parameters (doubles)
double[] parameters = Arrays.*stream*(iris)
.limit(4)
.mapToDouble(Double::*parseDouble*)
.toArray();
irisParameters.add(parameters);
// last item is species
String species = iris[4];
switch (species) {
case IRIS_SETOSA :
irisClassifications.add(new double[] { 1.0, 0.0, 0.0 });
break;
case IRIS_VERSICOLOR :
irisClassifications.add(new double[] { 0.0, 1.0, 0.0 });
break;
default :
irisClassifications.add(new double[] { 0.0, 0.0, 1.0 });
break;
}
irisSpecies.add(species);
}
Util.*normalizeByFeatureScaling*(irisParameters);
}
irisParameters 代表每个样本所使用的四个属性的集合,我们用这些属性来对每个鸢尾花进行分类。irisClassifications 是每个样本的实际分类。我们的神经网络将有三个人工神经元,每个神经元代表一种可能的物种。例如,最终输出集合 {0.9, 0.3, 0.1} 将代表鸢尾花-setosa 的分类,因为第一个神经元代表这种物种,并且它是最大的数值。
对于训练,我们已经知道正确的答案,因此每个鸢尾花都有一个预先标记的答案。对于一个应该是 iris-setosa 的花,irisClassifications 中的条目将是 {1.0, 0.0, 0.0}。这些值将在每次训练步骤后用于计算误差。IrisSpecies 直接对应于每朵花在英语中应该被分类为什么。一个 iris-setosa 将在数据集中标记为 "Iris-setosa"。
警告 缺乏错误检查使此代码相当危险。它不适合直接用于生产,但适用于测试。
列表 7.15 IrisTest.java 继续部分
public String irisInterpretOutput(double[] output) {
double max = Util.*max*(output);
if (max == output[0]) {
return IRIS_SETOSA;
} else if (max == output[1]) {
return IRIS_VERSICOLOR;
} else {
return IRIS_VIRGINICA;
}
}
irisInterpretOutput() 是一个实用函数,它将被传递给网络的 validate() 方法,以帮助识别正确的分类。
网络最终准备就绪,可以创建。让我们定义一个 classify() 方法,该方法将设置网络、训练它并运行它。
列表 7.16 IrisTest.java 继续部分
public Network<String>.Results classify() {
// 4, 6, 3 layer structure; 0.3 learning rate; sigmoid activation function
Network<String> irisNetwork = new Network<>(new int[] { 4, 6, 3 }, 0.3, Util::*sigmoid*, Util::*derivativeSigmoid*);
网络构造函数的 layerStructure 参数指定了一个具有三个层(一个输入层、一个隐藏层和一个输出层)的网络,其配置为 {4, 6, 3}。输入层有四个神经元,隐藏层有六个神经元,输出层有三个神经元。输入层的四个神经元直接映射到用于对每个标本进行分类的四个参数。输出层的三个神经元直接映射到我们试图对每个输入进行分类的三个不同物种。隐藏层的六个神经元更多的是基于试错的结果,而不是某个公式。学习率也是这样。这两个值(隐藏层中的神经元数量和学习率)如果网络的准确性不佳,可以进行实验。
列表 7.17 IrisTest.java 继续部分
// train over the first 140 irises in the data set 50 times
List<double[]> irisTrainers = irisParameters.subList(0, 140);
List<double[]> irisTrainersCorrects = irisClassifications.subList(0, 140);
int trainingIterations = 50;
for (int i = 0; i < trainingIterations; i++) {
irisNetwork.train(irisTrainers, irisTrainersCorrects);
}
我们在数据集中的前 140 个鸢尾花上进行训练,共有 150 个鸢尾花。回想一下,从 CSV 文件中读取的行是随机排序的。这确保了每次运行程序时,我们都会在不同的数据集子集上进行训练。请注意,我们对 140 个鸢尾花进行了 50 次训练。修改这个值将对您的神经网络训练所需的时间产生重大影响。一般来说,训练越多,神经网络的性能越准确,尽管存在所谓的 过拟合 的风险。最终的测试将是验证数据集中最后 10 个鸢尾花的正确分类。我们在 classify() 的末尾做这件事,并在 main() 中运行网络。
列表 7.18 IrisTest.java 继续部分
// test over the last 10 of the irises in the data set
List<double[]> irisTesters = irisParameters.subList(140, 150);
List<String> irisTestersCorrects = irisSpecies.subList(140, 150);
return irisNetwork.validate(irisTesters, irisTestersCorrects, this::irisInterpretOutput);
}
public static void main(String[] args) {
IrisTest irisTest = new IrisTest();
Network<String>.Results results = irisTest.classify();
System.out.println(results.correct + " correct of " + results.trials + " = " + results.percentage * 100 + "%");
}
}
所有这些工作都指向最终的问题:从数据集中随机选择的 10 个鸢尾花中,我们的神经网络能正确分类多少个?由于每个神经元的起始权重中存在随机性,不同的运行可能会给出不同的结果。你可以尝试调整学习率、隐藏神经元的数量和训练迭代次数,以提高网络的准确性。
最终,你应该看到接近以下结果:
9 correct of 10 = 90.0%
7.5.3 分类葡萄酒
我们将使用另一个数据集来测试我们的神经网络,这个数据集基于意大利葡萄酒品种的化学分析。4 数据集中有 178 个样本。处理它的机制将与鸢尾花数据集类似,但 CSV 文件的布局略有不同。以下是一个示例:
1,14.23,1.71,2.43,15.6,127,2.8,3.06,.28,2.29,5.64,1.04,3.92,1065
1,13.2,1.78,2.14,11.2,100,2.65,2.76,.26,1.28,4.38,1.05,3.4,1050
1,13.16,2.36,2.67,18.6,101,2.8,3.24,.3,2.81,5.68,1.03,3.17,1185
1,14.37,1.95,2.5,16.8,113,3.85,3.49,.24,2.18,7.8,.86,3.45,1480
1,13.24,2.59,2.87,21,118,2.8,2.69,.39,1.82,4.32,1.04,2.93,735
每行的第一个值将始终是一个整数,从 1 到 3,代表样本可能属于的三种品种之一。但请注意,用于分类的参数数量要多得多。在鸢尾花数据集中,只有 4 个参数。在这个葡萄酒数据集中,有 13 个。
我们的神经网络模型将能够很好地扩展。我们只需要增加输入神经元的数量。WineTest.java 与 IrisTest.java 类似,但对相应文件的布局进行了一些小的修改。
列表 7.19 WineTest.java
package chapter7;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class WineTest {
private List<double[]> wineParameters = new ArrayList<>();
private List<double[]> wineClassifications = new ArrayList<>();
private List<Integer> wineSpecies = new ArrayList<>();
public WineTest() {
// make sure wine.csv is in the right place in your path
List<String[]> wineDataset = Util.*loadCSV*("/chapter7/data/wine.csv");
// get our lines of data in random order
Collections.*shuffle*(wineDataset);
for (String[] wine : wineDataset) {
// last thirteen items are parameters (doubles)
double[] parameters = Arrays.*stream*(wine)
.skip(1)
.mapToDouble(Double::*parseDouble*)
.toArray();
wineParameters.add(parameters);
// first item is species
int species = Integer.*parseInt*(wine[0]);
switch (species) {
case 1 :
wineClassifications.add(new double[] { 1.0, 0.0, 0.0 });
break;
case 2 :
wineClassifications.add(new double[] { 0.0, 1.0, 0.0 });
break;
default :
wineClassifications.add(new double[] { 0.0, 0.0, 1.0 });
break;
}
wineSpecies.add(species);
}
Util.*normalizeByFeatureScaling*(wineParameters);
}
wineInterpretOutput()与 irisInterpretOutput()类似。因为我们没有葡萄酒品种的名称,所以我们只是在原始数据集中使用整数分配。
列表 7.20 WineTest.java 继续显示
public Integer wineInterpretOutput(double[] output) {
double max = Util.*max*(output);
if (max == output[0]) {
return 1;
} else if (max == output[1]) {
return 2;
} else {
return 3;
}
}
对于葡萄酒分类网络,层配置需要 13 个输入神经元,正如之前提到的(每个参数一个)。它还需要 3 个输出神经元。正如有三种鸢尾花品种一样,也有三种葡萄酒品种。有趣的是,网络在隐藏层中的神经元数量少于输入层时也能很好地工作。一个可能的直观解释是,一些输入参数实际上对分类没有帮助,并且在处理过程中删除它们是有用的。实际上,隐藏层中神经元数量较少的工作方式并不完全是这样,但这是一个有趣的直观想法。
列表 7.21 WineTest.java 继续显示
public Network<Integer>.Results classify() {
// 13, 7, 3 layer structure; 0.9 learning rate; sigmoid activation func
Network<Integer> wineNetwork = new Network<>(new int[] { 13, 7, 3 }, 0.9, Util::*sigmoid*, Util::*derivativeSigmoid*);
再次强调,尝试不同的隐藏层神经元数量或不同的学习率是有趣的。
列表 7.22 WineTest.java 继续显示
// train over the first 150 wines in the data set 50 times
List<double[]> wineTrainers = wineParameters.subList(0, 150);
List<double[]> wineTrainersCorrects = wineClassifications.subList(0, 150);
int trainingIterations = 10;
for (int i = 0; i < trainingIterations; i++) {
wineNetwork.train(wineTrainers, wineTrainersCorrects);
}
我们将在数据集的前 150 个样本上进行训练,留下最后的 28 个样本进行验证。我们将对样本进行 10 次训练,这比鸢尾花数据集的 50 次要少得多。无论什么原因(可能是数据集的内在特性或学习率、隐藏神经元数量等参数的调整),这个数据集比鸢尾花数据集需要更少的训练就能达到显著的准确性。
列表 7.23 WineTest.java 继续显示
// test over the last 28 of the wines in the data set
List<double[]> wineTesters = wineParameters.subList(150, 178);
List<Integer> wineTestersCorrects = wineSpecies.subList(150, 178);
return wineNetwork.validate(wineTesters, wineTestersCorrects, this::wineInterpretOutput);
}
public static void main(String[] args) {
WineTest wineTest = new WineTest();
Network<Integer>.Results results = wineTest.classify();
System.out.println(results.correct + " correct of " + results.trials + " = " + results.percentage * 100 + "%");
}
}
如果有点运气,你的神经网络应该能够非常准确地分类这 28 个样本:
27 correct of 28 = 96.42857142857143%
7.6 加速神经网络
神经网络需要大量的向量/矩阵数学。本质上,这意味着对一系列数字进行一次操作。随着机器学习继续渗透到我们的社会中,优化、高性能的向量/矩阵数学库变得越来越重要。许多这些库利用 GPU,因为 GPU 为此角色进行了优化。(向量/矩阵是计算机图形学的核心。)你可能听说过的较老的库规范是 BLAS(基本线性代数子程序)。许多数值库,包括 Java 库 ND4J,都基于 BLAS 实现。
除了 GPU 之外,CPU 也有可以加速向量/矩阵处理的扩展。BLAS 实现通常包括使用单指令,多数据(SIMD)指令的函数。SIMD 指令是特殊的微处理器指令,允许同时处理多个数据。它们有时也被称为向量指令。
不同的微处理器包括不同的 SIMD 指令。例如,G4(一种在 2000 年代初期 Mac 中发现的 PowerPC 架构处理器)的 SIMD 扩展被称为 AltiVec。iPhone 中发现的 ARM 微处理器有一个名为 NEON 的扩展。现代英特尔微处理器包括名为 MMX、SSE、SSE2 和 SSE3 的 SIMD 扩展。幸运的是,你不需要知道这些差异。一个精心设计的数值库会自动选择适合在程序运行的底层架构上高效计算的指令。
因此,现实世界的神经网络库(与本章中的玩具库不同)使用专用类型作为其基本数据结构,而不是 Java 标准库列表或数组,这并不令人惊讶。但它们做得更远。像 TensorFlow 和 PyTorch 这样的流行神经网络库不仅使用 SIMD 指令,而且还大量使用 GPU 计算。因为 GPU 是专门为快速向量计算设计的,所以与仅使用 CPU 相比,这可以加速神经网络一个数量级。
让我们明确一点:你绝对不希望像本章中那样,仅仅使用 Java 标准库天真地实现用于生产的神经网络。相反,你应该使用像 TensorFlow 这样的经过良好优化、支持 SIMD 和 GPU 的库。唯一的例外可能是为教育设计的神经网络库,或者必须在没有 SIMD 指令或 GPU 的嵌入式设备上运行的神经网络库。
7.7 神经网络问题与扩展
神经网络目前非常流行,这得益于深度学习的进步,但它们存在一些显著的缺点。最大的问题是,神经网络对问题的解决方案有点像黑箱。即使神经网络工作得很好,它们也不会给用户太多关于如何解决问题的洞察。例如,我们在本章中工作的鸢尾花数据集分类器并没有清楚地显示输入中的四个参数中的每一个对输出的影响程度。是花瓣长度比花瓣宽度对分类每个样本更重要吗?
可能对训练网络的最终权重进行仔细分析可以提供一些洞察,但这种分析并不简单,并且不会提供类似于线性回归在模型中每个变量的意义方面的洞察。换句话说,神经网络可以解决问题,但它并不解释问题是如何解决的。
神经网络的另一个问题是,为了变得准确,它们通常需要非常大的数据集。想象一下用于户外景观的图像分类器。它可能需要分类成千上万种不同的图像类型(森林、山谷、山脉、溪流、草原等等)。它可能需要数百万个训练图像。不仅这样的大型数据集很难获得,而且对于某些应用,它们可能根本不存在。通常是大公司和政府拥有收集和存储这样庞大数据集的数据仓库和技术设施。
最后,神经网络在计算上非常昂贵。仅仅在中等规模的数据集上进行训练就可以让你的电脑不堪重负。这不仅仅是简单的神经网络实现——在任何使用神经网络的计算平台上,训练网络时必须执行的计算数量,而不是其他任何因素,才是花费如此多的时间的主要原因。有许多技巧可以使神经网络更高效(比如使用 SIMD 指令或 GPU),但最终,训练神经网络需要大量的浮点运算。
一个令人欣慰的认识是,训练的计算成本远高于实际使用网络。有些应用不需要持续的训练。在这些情况下,一个训练好的网络可以直接应用到应用中解决问题。例如,苹果公司 Core ML 框架的第一个版本甚至不支持训练。它只支持帮助应用开发者在其应用中运行预训练的神经网络模型。一个创建照片应用的应用开发者可以下载一个免费许可的图像分类模型,将其放入 Core ML 中,并立即在应用中使用高效的机器学习。
在本章中,我们只使用了一种类型的神经网络:具有反向传播的前馈网络。正如已经提到的,存在许多其他类型的神经网络。卷积神经网络也是前馈的,但它们有多个不同类型的隐藏层,不同的权重分配机制,以及其他有趣的特性,使它们特别适合图像分类。在循环神经网络中,信号不仅在一个方向上传播。它们允许反馈循环,并且已被证明对于连续输入应用,如手写识别和语音识别,非常有用。
对我们的神经网络的一个简单扩展,使其性能更佳,就是包含偏差神经元。偏差神经元就像一层中的虚拟神经元,它允许下一层的输出通过提供一个常数输入(仍然由权重修改)来表示更多函数。即使是用于现实世界问题的简单神经网络通常也包含偏差神经元。如果你向我们的现有网络添加偏差神经元,你可能会发现它需要更少的训练就能达到相似的准确度水平。
7.8 真实世界的应用
尽管它们是在 20 世纪中叶被想象出来的,但人工神经网络直到最近十年才变得普遍。它们广泛的应用受到了缺乏足够性能的硬件的限制。今天,由于它们有效,人工神经网络已经成为机器学习中最爆炸性的增长领域。
人工神经网络使得几十年来最激动人心的面向用户的计算应用成为可能。这包括实用的语音识别(在足够准确性的意义上是实用的)、图像识别和手写识别。语音识别存在于像 Dragon NaturallySpeaking 这样的打字辅助工具和像 Siri、Alexa 和 Cortana 这样的数字助手中。图像识别的一个具体例子是 Facebook 使用面部识别自动标记照片中的人。在 iOS 的最新版本中,你可以通过使用手写识别来搜索你的笔记中的单词,即使它们是手写的。
一种可以由神经网络驱动的较老识别技术是 OCR(光学字符识别)。每次你扫描文档并返回为可选择的文本而不是图像时,都会使用 OCR。OCR 使收费站能够读取车牌,并使邮局能够快速对信封进行分类。
在本章中,您已经看到神经网络在分类问题上的成功应用。神经网络在推荐系统中也表现出良好的应用效果。想想 Netflix 推荐您可能想看的电影或 Amazon 推荐您可能想读的书。还有其他机器学习技术也适用于推荐系统(Amazon 和 Netflix 不一定使用神经网络来完成这些任务;他们系统的细节可能是专有的),因此只有在探索了所有选项之后,才应选择神经网络。
神经网络可以用于任何需要近似未知函数的情况。这使得它们在预测方面非常有用。神经网络可以用来预测体育赛事、选举或股市的结果(它们确实可以)。当然,它们的准确性是它们训练得有多好的产物,这涉及到与未知结果事件相关的数据集有多大,神经网络参数调整得有多好,以及运行了多少次训练迭代。在预测方面,像大多数神经网络应用一样,最难的部分之一是决定网络的自身结构,这通常最终是通过试错来确定的。
7.9 练习
-
使用本章开发的神经网络框架对另一个数据集中的项目进行分类。
-
尝试使用不同的激活函数运行示例。(记得也要找到它的导数。)激活函数的变化如何影响网络的准确性?是否需要更多或更少的训练?
-
将本章中的问题重新创建解决方案,使用流行的神经网络框架,如 TensorFlow。
-
使用第三方 Java 数值库重写网络、层和神经元类,以加速本章开发的神经网络执行。
-
Stuart Russell 和 Peter Norvig,《人工智能:现代方法》,第 3 版(培生,2010 年)。
-
仓库可在 GitHub 上获取,
github.com/davecom/ClassicComputerScienceProblemsInJava。 -
M. Lichman,UCI 机器学习仓库(加州欧文,加州大学信息与计算机科学学院,2013 年),
archive.ics.uci.edu/ml。 -
参见脚注 3。
8 对抗搜索
双人零和完全信息游戏是指两个对手都有关于游戏状态的全部信息,并且任何一方优势的增加都会导致另一方优势的减少。这类游戏包括井字棋、四子棋、跳棋和国际象棋。在本章中,我们将研究如何创建一个能够以高超技艺玩这类游戏的人工对手。实际上,本章讨论的技术,结合现代计算能力,可以创建出能够完美玩这类简单游戏的人工对手,并且能够玩超越任何人类对手能力的复杂游戏。
8.1 基本棋盘游戏组件
就像本书中我们遇到的大多数更复杂的问题一样,我们将尝试使我们的解决方案尽可能通用。在对抗搜索的情况下,这意味着使我们的搜索算法非游戏特定。让我们首先定义一些简单的接口,这些接口定义了我们的搜索算法将需要访问状态的所有方式。稍后,我们可以为我们要开发的特定游戏(井字棋和四子棋)实现这些接口,并将实现输入到搜索算法中,使它们“玩”这些游戏。以下是这些接口。
列表 8.1 Piece.java
package chapter8;
public interface Piece {
Piece opposite();
}
Piece 是游戏棋盘上棋子的接口。它也将作为我们的回合指示器。这就是为什么需要相反的方法。我们需要知道给定回合之后是哪个玩家的回合。
提示:由于井字棋和四子棋只有一种棋子,所以在这个章节中,单个 Piece 实现可以同时作为回合指示器。对于更复杂的游戏,如国际象棋,回合可以通过整数或布尔值来指示。或者,可以使用更复杂的 Piece 类型的“颜色”属性来指示回合。
列表 8.2 Board.java
package chapter8;
import java.util.List;
public interface Board<Move> {
Piece getTurn();
Board<Move> move(Move location);
List<Move> getLegalMoves();
boolean isWin();
default boolean isDraw() {
return !isWin() && getLegalMoves().isEmpty();
}
double evaluate(Piece player);
}
Board 描述了一个实际维护位置状态的类的接口。对于我们的搜索算法将计算的任何给定游戏,我们需要能够回答四个问题:
-
谁的回合?
-
在当前局面中可以采取哪些合法的走法?
-
游戏是否已经获胜?
-
游戏是否平局?
那最后一个问题,关于平局,实际上是将前两个问题结合在一起,对于许多游戏来说。如果游戏没有获胜,但没有合法的走法,那么就是平局。这就是为什么我们的接口 Board 已经可以有一个具体的默认实现 isDraw() 方法。此外,还有一些我们需要能够执行的操作:
-
进行一步棋,从当前位置移动到新的位置。
-
评估位置,以查看哪个玩家有优势。
Board 中的每个方法和属性都是对前面提出的问题或动作的一个代理。在游戏术语中,Board 接口也可以称为 Position,但我们将使用这个术语来表示每个子类中更具体的东西。
Board 有一个通用类型,Move。Move 类型将表示游戏中的移动。在本章中,它可以是整数。在跳棋和四子棋等游戏中,一个整数可以通过指示放置棋子的方格或列来表示一个移动。在更复杂的游戏中,可能需要比整数更多的信息来描述一个移动。使 Move 通用允许 Board 表示更广泛的游戏类型。
8.2 跳棋
跳棋是一个简单的游戏,但它可以用来说明可以应用于四子棋、国际象棋等高级策略游戏的相同最小-最大算法。我们将构建一个使用最小-最大算法完美玩跳棋的 AI。
注意:本节假设您熟悉跳棋及其标准规则。如果不熟悉,网上快速搜索应该能帮助您了解情况。
8.2.1 管理跳棋状态
让我们开发一些结构来跟踪跳棋游戏的状态,随着游戏的进行。
首先,我们需要一种表示跳棋棋盘上每个方格的方法。我们将使用一个名为 TTTPiece 的枚举,它是 Piece 的实现者。跳棋棋子可以是 X、O 或空(在枚举中用 E 表示)。
列表 8.3 TTTPiece.java
package chapter8;
public enum TTTPiece implements Piece {
X, O, E; // E is Empty
@Override
public TTTPiece opposite() {
switch (this) {
case X:
return TTTPiece.O;
case O:
return TTTPiece.X;
default: // E, empty
return TTTPiece.E;
}
}
@Override
public String toString() {
switch (this) {
case X:
;
case O:
return "O";
default: // E, empty
return " ";
}
}
}
枚举类型 TTTPiece 有一个名为 opposite 的方法,它返回另一个 TTTPiece 对象。这在跳棋移动后从一位玩家的回合切换到另一位玩家的回合时将非常有用。为了表示移动,我们将仅使用一个整数,该整数对应于放置棋子的棋盘上的一个方格。如您所回忆的,Move 是 Board 的通用类型。当我们定义 TTTBoard 时,我们将指定 Move 是整数。
跳棋棋盘有九个位置,组织成三行三列。为了简单起见,这九个位置可以使用一维数组表示。哪些方格接收哪些数字标识(即数组的索引)是任意的,但我们将遵循图 8.1 中概述的方案。

图 8.1 跳棋棋盘上每个方格对应的一维数组索引
状态的主要持有者是类 TTTBoard。TTTBoard 跟踪两个不同的状态:位置(由上述一维列表表示)和当前轮到哪个玩家。
列表 8.4 TTTBoard.java
package chapter8;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class TTTBoard implements Board<Integer> {
private static final int NUM_SQUARES = 9;
private TTTPiece[] position;
private TTTPiece turn;
public TTTBoard(TTTPiece[] position, TTTPiece turn) {
this.position = position;
this.turn = turn;
}
public TTTBoard() {
// by default start with blank board
position = new TTTPiece[NUM_SQUARES];
Arrays.*fill*(position, TTTPiece.E);
// X goes first
turn = TTTPiece.X;
}
@Override
public Piece getTurn() {
return turn;
}
默认棋盘是指尚未进行任何移动的棋盘(一个空棋盘)。TTTBoard 的无参数构造函数初始化这样的位置,X 轮到移动(跳棋中的通常第一个玩家)。getTurn()指示当前位置轮到哪个玩家,X 或 O。
TTTBoard 是一个非正式不可变的数据结构;TTTBoards 不应该被修改。相反,每次需要执行移动时,都会生成一个新的 TTTBoard,其位置已更改以适应移动。这将在我们的搜索算法中很有帮助。当搜索分支时,我们不会无意中更改仍在分析潜在移动的棋盘的位置。
列表 8.5 TTTBoard.java 继续内容
@Override
public TTTBoard move(Integer location) {
TTTPiece[] tempPosition = Arrays.*copyOf*(position, position.length);
tempPosition[location] = turn;
return new TTTBoard(tempPosition, turn.opposite());
}
井字棋的一个合法走法是任何空方格。getLegalMoves() 方法在棋盘上寻找任何空方格,并返回它们的列表。
列表 8.6 TTTBoard.java 继续内容
@Override
public List<Integer> getLegalMoves() {
ArrayList<Integer> legalMoves = new ArrayList<>();
for (int i = 0; i < NUM_SQUARES; i++) {
// empty squares are legal moves
if (position[i] == TTTPiece.E) {
legalMoves.add(i);
}
}
return legalMoves;
}
有许多方法可以扫描井字棋板的行、列和对角线以检查胜利。以下方法 isWin() 的实现及其辅助方法 checkPos() 以硬编码的、看似无尽的 &&、|| 和 == 的组合来完成。这不是最漂亮的代码,但它以直接的方式完成了工作。
列表 8.7 TTTBoard.java 继续内容
@Override
public boolean isWin() {
// three row, three column, and then two diagonal checks
return
checkPos(0, 1, 2) || checkPos(3, 4, 5) || checkPos(6, 7, 8)
|| checkPos(0, 3, 6) || checkPos(1, 4, 7) || checkPos(2, 5, 8)
|| checkPos(0, 4, 8) || checkPos(2, 4, 6);
}
private boolean checkPos(int p0, int p1, int p2) {
return position[p0] == position[p1] && position[p0] == position[p2]
&& position[p0] != TTTPiece.E;
}
如果一行、一列或对角线上的所有方格都不为空,并且它们包含相同的棋子,那么游戏已经获胜。
如果游戏没有获胜且没有剩余的合法走法,则游戏是平局;这个属性已经被 Board 接口的默认方法 isDraw() 所涵盖。最后,我们需要一种评估特定位置和美化打印棋盘的方法。
列表 8.8 TTTBoard.java 继续内容
@Override
public double evaluate(Piece player) {
if (isWin() && turn == player) {
return -1;
} else if (isWin() && turn != player) {
return 1;
} else {
return 0.0;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 3; col++) {
sb.append(position[row * 3 + col].toString());
if (col != 2) {
sb.append("|");
}
}
sb.append(System.*lineSeparator*());
if (row != 2) {
sb.append("-----");
sb.append(System.*lineSeparator*());
}
}
return sb.toString();
}
}
对于大多数游戏,对位置的评估需要是一个近似值,因为我们无法搜索整个游戏以确定谁赢谁输,这取决于所采取的走法。但是,井字棋的搜索空间足够小,我们可以从任何位置搜索到游戏结束。因此,evaluate() 方法可以简单地返回一个数字,如果玩家获胜,则返回一个更差的数字表示平局,如果玩家失败,则返回一个更差的数字。
8.2.2 Minimax
Minimax 是一个经典的算法,用于在两人零和游戏中找到最佳走法,如井字棋、跳棋或国际象棋。它已经被扩展和修改用于其他类型的游戏。Minimax 通常使用递归函数实现,其中每个玩家被指定为最大化玩家或最小化玩家。
最大化的玩家旨在找到能带来最大收益的走法。然而,最大化玩家必须考虑到最小化玩家的走法。在尝试最大化最大化玩家的收益之后,minimax 递归调用以找到对手的回应,该回应将最小化最大化玩家的收益。这个过程来回进行(最大化、最小化、最大化,等等),直到递归函数中的基本案例被达到。基本案例是一个终端位置(胜利或平局)或最大搜索深度。
Minimax 将返回最大化玩家的起始位置的评估。对于 TTTBoard 类的 evaluate() 方法,如果双方的最佳走法将导致最大化玩家获胜,则返回分数 1。如果最佳走法将导致失败,则返回 -1。如果最佳走法是平局,则返回 0。
当达到基本案例时,会返回这些数字。然后,这些数字会通过所有导致基本案例的递归调用向上冒泡。对于每个最大化递归调用,下一级最佳评价会向上冒泡。对于每个最小化递归调用,下一级最差评价会向上冒泡。通过这种方式,构建了一个决策树。图 8.2 展示了这样一个树,它有助于在只剩两步棋的情况下进行冒泡。
对于搜索空间太深而无法达到终端位置的游戏(如跳棋和国际象棋),在达到一定深度后(搜索的步数深度,有时称为ply)minimax 会停止。然后,评估函数开始工作,使用启发式方法对游戏状态进行评分。游戏对原始玩家越有利,所获得的分数就越高。我们将在四子棋中回到这个概念,它的搜索空间比井字棋大得多。

图 8.2 一个剩余两步棋的井字游戏 minimax 决策树。为了最大化获胜的可能性,初始玩家 O 会选择在底部中央放置 O。箭头指示决策的位置。
下面是整个 minimax()函数。
列表 8.9 Minimax.java
package chapter8;
public class Minimax {
// Find the best possible outcome for originalPlayer
public static <Move> double minimax(Board<Move> board, boolean
maximizing, Piece originalPlayer, int maxDepth) {
// Base case-terminal position or maximum depth reached
if (board.isWin() || board.isDraw() || maxDepth == 0) {
return board.evaluate(originalPlayer);
}
// Recursive case-maximize your gains or minimize opponent's gains
if (maximizing) {
double bestEval = Double.NEGATIVE_INFINITY; // result above
for (Move move : board.getLegalMoves()) {
double result = *minimax*(board.move(move), false,
originalPlayer, maxDepth - 1);
bestEval = Math.*max*(result, bestEval);
}
return bestEval;
} else { // minimizing
double worstEval = Double.POSITIVE_INFINITY; // result below
for (Move move : board.getLegalMoves()) {
double result = *minimax*(board.move(move), true,
originalPlayer, maxDepth - 1);
worstEval = Math.*min*(result, worstEval);
}
return worstEval;
}
}
在每个递归调用中,我们需要跟踪棋盘位置、我们是在最大化还是最小化,以及我们试图为谁评估位置(originalPlayer)。minimax()的前几行处理基本案例:终端节点(胜利、失败或平局)或达到最大深度。函数的其余部分是递归情况。
递归情况之一是最大化。在这种情况下,我们正在寻找一个能产生最高评价的走法。另一个递归情况是最小化,我们正在寻找导致最低可能评价的走法。无论哪种情况,这两个情况会交替进行,直到我们达到终端状态或最大深度(基本案例)。
不幸的是,我们不能直接使用我们的 minimax()实现来找到给定位置的最佳走法。它返回一个评估(一个双精度值)。它不会告诉我们是什么最佳第一步导致了那个评估。
因此,我们将创建一个辅助函数 findBestMove(),该函数会遍历对位置中每个合法走法的 minimax()调用,以找到评估为最高值的走法。你可以将 findBestMove()视为对 minimax()的第一个最大化调用,但我们要跟踪那些初始走法。
列表 8.10 Minimax.java 续
// Find the best possible move in the current position
// looking up to maxDepth ahead
public static <Move> Move findBestMove(Board<Move> board, int maxDepth) {
double bestEval = Double.NEGATIVE_INFINITY;
Move bestMove = null; // won't stay null for sure
for (Move move : board.getLegalMoves()) {
double result = *minimax*(board.move(move), false, board.getTurn(), maxDepth);
if (result > bestEval) {
bestEval = result;
bestMove = move;
}
}
return bestMove;
}
}
现在我们已经准备好找到任何井字棋位置的最佳可能走法。
8.2.3 使用井字棋测试 minimax
井字棋如此简单,以至于我们作为人类很容易在给定位置中找出正确的下一步。这使得我们能够轻松地开发单元测试。在下面的代码片段中,我们将挑战我们的最小-最大算法在三个不同的井字棋位置中找到正确的下一步。第一个很简单,只需要考虑下一步就能获胜。第二个需要阻止;AI 必须阻止对手得分。最后一个稍微有点挑战性,需要 AI 考虑两步之内的未来。
WARNING 在本书的开头,我承诺所有示例都将仅使用 Java 标准库。不幸的是,对于接下来的代码片段,我坚持了我的承诺。实际上,单元测试最好使用成熟的框架,如 JUnit,而不是像我们在这里所做的那样自己构建。但这个例子因其对反射的说明而很有趣。
列表 8.11 TTTMinimaxTests.java
package chapter8;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Method;
// Annotation for unit tests
@Retention(RetentionPolicy.RUNTIME)
@interface UnitTest {
String name() default "";
}
public class TTTMinimaxTests {
// Check if two values are equal and report back
public static <T> void assertEquality(T actual, T expected) {
if (actual.equals(expected)) {
System.out.println("Passed!");
} else {
System.out.println("Failed!");
System.out.println("Actual: " + actual.toString());
System.out.println("Expected: " + expected.toString());
}
}
@UnitTest(name = "Easy Position")
public void easyPosition() {
TTTPiece[] toWinEasyPosition = new TTTPiece[] {
TTTPiece.X, TTTPiece.O, TTTPiece.X,
TTTPiece.X, TTTPiece.E, TTTPiece.O,
TTTPiece.E, TTTPiece.E, TTTPiece.O };
TTTBoard testBoard1 = new TTTBoard(toWinEasyPosition, TTTPiece.X);
Integer answer1 = Minimax.*findBestMove*(testBoard1, 8);
*assertEquality*(answer1, 6);
}
@UnitTest(name = "Block Position")
public void blockPosition() {
TTTPiece[] toBlockPosition = new TTTPiece[] {
TTTPiece.X, TTTPiece.E, TTTPiece.E,
TTTPiece.E, TTTPiece.E, TTTPiece.O,
TTTPiece.E, TTTPiece.X, TTTPiece.O };
TTTBoard testBoard2 = new TTTBoard(toBlockPosition, TTTPiece.X);
Integer answer2 = Minimax.*findBestMove*(testBoard2, 8);
*assertEquality*(answer2, 2);
}
@UnitTest(name = "Hard Position")
public void hardPosition() {
TTTPiece[] toWinHardPosition = new TTTPiece[] {
TTTPiece.X, TTTPiece.E, TTTPiece.E,
TTTPiece.E, TTTPiece.E, TTTPiece.O,
TTTPiece.O, TTTPiece.X, TTTPiece.E };
TTTBoard testBoard3 = new TTTBoard(toWinHardPosition, TTTPiece.X);
Integer answer3 = Minimax.*findBestMove*(testBoard3, 8);
*assertEquality*(answer3, 1);
}
// Run all methods marked with the UnitTest annotation
public void runAllTests() {
for (Method method : this.getClass().getMethods()) {
for (UnitTest annotation : method.getAnnotationsByType(UnitTest.class)) {
System.out.println("Running Test " + annotation.name());
try {
method.invoke(this);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("____________________");
}
}
}
public static void main(String[] args) {
new TTTMinimaxTests().runAllTests();
}
}
正如前面提到的,自己构建单元测试框架而不是使用 JUnit 等工具可能不是一个好主意。但话说回来,这也不难,多亏了 Java 的反射能力。代表测试的每个方法都使用在文件顶部定义的自定义注解 UnitTest 进行注解。runAllTests()方法查找所有带有该注解的方法,并运行它们,同时还有一些有用的打印输出。assertEquality()检查两个项目是否相等,如果不相等,则打印它们。虽然定义自己的单元测试框架可能不是一个好主意,但了解它是如何工作的很有趣。为了将我们的框架提升到下一个层次,我们可能会定义一个基类,该类包括 runAllTests()和 assertEquality(),其他测试类可以扩展它。
当你运行 TTTMinimaxTests.java 时,所有三个测试都应该通过。
TIP 实现最小-最大算法并不需要很多代码,它适用于比井字棋更多的游戏。如果你计划为另一款游戏实现最小-最大算法,重要的是通过创建适合最小-最大算法设计的数据结构来为自己设定成功的基础,例如 Board 类。学生在学习最小-最大算法时常见的错误是使用可修改的数据结构,该数据结构会被最小-最大算法的递归调用所更改,然后无法回滚到其原始状态以进行额外的调用。
8.2.4 开发井字棋 AI
在所有这些成分都到位的情况下,下一步开发一个完整的井字棋人工智能对手是微不足道的。AI 不会评估测试位置,而是评估每个对手移动生成的位置。在下面的简短代码片段中,井字棋 AI 与先手的人类对手进行对弈。
列表 8.12 TicTacToe.java
package chapter8;
import java.util.Scanner;
public class TicTacToe {
private TTTBoard board = new TTTBoard();
private Scanner scanner = new Scanner(System.in);
private Integer getPlayerMove() {
Integer playerMove = -1;
while (!board.getLegalMoves().contains(playerMove)) {
System.out.println("Enter a legal square (0-8):");
Integer play = scanner.nextInt();
playerMove = play;
}
return playerMove;
}
private void runGame() {
// main game loop
while (true) {
Integer humanMove = getPlayerMove();
board = board.move(humanMove);
if (board.isWin()) {
System.out.println("Human wins!");
break;
} else if (board.isDraw()) {
System.out.println("Draw!");
break;
}
Integer computerMove = Minimax.*findBestMove*(board, 9);
System.out.println("Computer move is " + computerMove);
board = board.move(computerMove);
System.out.println(board);
if (board.isWin()) {
System.out.println("Computer wins!");
break;
} else if (board.isDraw()) {
System.out.println("Draw!");
break;
}
}
}
public static void main(String[] args) {
new TicTacToe().runGame();
}
}
通过将 findBestMove() 方法的 maxDepth 参数设置为 9(实际上可以是 8),这个井字棋 AI 将始终看到游戏的尽头。(井字棋的最大移动次数是九次,AI 是第二位玩家。)因此,它应该每次都玩得完美。完美游戏是指双方在每个回合都做出最佳可能的移动。井字棋完美游戏的结果是平局。考虑到这一点,你永远不应该能够击败井字棋 AI。如果你发挥出最佳水平,结果将是平局。如果你犯了一个错误,AI 将获胜。试试看吧。你不应该能够击败它。以下是我们程序的一个示例运行:
Enter a legal square (0-8):
4
Computer move is 0
O| |
-----
|X|
-----
| |
Enter a legal square (0-8):
2
Computer move is 6
O| |X
-----
|X|
-----
O| |
Enter a legal square (0-8):
3
Computer move is 5
O| |X
-----
X|X|O
-----
O| |
Enter a legal square (0-8):
1
Computer move is 7
O|X|X
-----
X|X|O
-----
O|O|
Enter a legal square (0-8):
8
Draw!
8.3 四子棋
在四子棋游戏中,1 两位玩家轮流在七列六行的垂直网格中投放不同颜色的棋子。棋子从网格顶部落下到底部,直到碰到底部或另一个棋子。本质上,玩家每轮唯一的决定就是将棋子投放到哪一列。玩家不能将棋子投放到已满的列中。第一个将四颗同色棋子连成一线(无间断的行、列或对角线)的玩家获胜。如果没有任何玩家达到这个条件,且网格完全填满,则游戏为平局。
8.3.1 四子棋游戏机制
在许多方面,四子棋与井字棋相似。两款游戏都在网格上进行,要求玩家排列棋子以获胜。但由于四子棋的网格更大,获胜的方式更多,评估每个位置要复杂得多。
以下的一些代码看起来非常熟悉,但数据结构和评估方法与井字棋有很大不同。两款游戏都是使用在章节开头看到的相同基类 Piece 和 Board 接口实现的类构建的,这使得 minimax() 方法可以用于两款游戏。
列表 8.13 C4Piece.java
package chapter8;
public enum C4Piece implements Piece {
B, R, E; // E is Empty
@Override
public C4Piece opposite() {
switch (this) {
case B:
return C4Piece.R;
case R:
return C4Piece.B;
default: // E, empty
return C4Piece.E;
}
}
@Override
public String toString() {
switch (this) {
case B:
return "B";
case R:
return "R";
default: // E, empty
return " ";
}
}
}
C4Piece 类几乎与 TTTPiece 类相同。我们还将有一个便利类,C4Location,用于跟踪棋盘网格上的位置(一列/一行对)。四子棋是一种以列为导向的游戏,因此我们以列优先的不寻常格式实现了其所有网格代码。
列表 8.14 C4Location.java
package chapter8;
public final class C4Location {
public final int column, row;
public C4Location(int column, int row) {
this.column = column;
this.row = row;
}
}
接下来,我们转向四子棋实现的精髓,即 C4Board 类。这个类定义了一些静态常量和一种静态方法。静态方法 generateSegments() 返回一个包含网格位置数组(C4Locations)的列表。列表中的每个数组包含四个网格位置。我们称这些四个网格位置的数组为 段。如果棋盘上的任何段都是同一种颜色,那么这种颜色就赢得了游戏。
能够快速搜索棋盘上的所有段对于检查游戏是否结束(有人获胜)以及评估位置都很有用。因此,你会在下一个代码片段中注意到,我们在 C4Board 类中将棋盘的段缓存为类变量 SEGMENTS。
列表 8.15 C4Board.java
package chapter8;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class C4Board implements Board<Integer> {
public static final int NUM_COLUMNS = 7;
public static final int NUM_ROWS = 6;
public static final int SEGMENT_LENGTH = 4;
public static final ArrayList<C4Location[]> SEGMENTS = *generateSegments*();
// generate all of the segments for a given board
// this static method is only run once
private static ArrayList<C4Location[]> generateSegments() {
ArrayList<C4Location[]> segments = new ArrayList<>();
// vertical
for (int c = 0; c < NUM_COLUMNS; c++) {
for (int r = 0; r <= NUM_ROWS - SEGMENT_LENGTH; r++) {
C4Location[] bl = new C4Location[SEGMENT_LENGTH];
for (int i = 0; i < SEGMENT_LENGTH; i++) {
bl[i] = new C4Location(c, r + i);
}
segments.add(bl);
}
}
// horizontal
for (int c = 0; c <= NUM_COLUMNS - SEGMENT_LENGTH; c++) {
for (int r = 0; r < NUM_ROWS; r++) {
C4Location[] bl = new C4Location[SEGMENT_LENGTH];
for (int i = 0; i < SEGMENT_LENGTH; i++) {
bl[i] = new C4Location(c + i, r);
}
segments.add(bl);
}
}
// diagonal from bottom left to top right
for (int c = 0; c <= NUM_COLUMNS - SEGMENT_LENGTH; c++) {
for (int r = 0; r <= NUM_ROWS - SEGMENT_LENGTH; r++) {
C4Location[] bl = new C4Location[SEGMENT_LENGTH];
for (int i = 0; i < SEGMENT_LENGTH; i++) {
bl[i] = new C4Location(c + i, r + i);
}
segments.add(bl);
}
}
// diagonal from bottom right to top left
for (int c = NUM_COLUMNS - SEGMENT_LENGTH; c >= 0; c--) {
for (int r = SEGMENT_LENGTH - 1; r < NUM_ROWS; r++) {
C4Location[] bl = new C4Location[SEGMENT_LENGTH];
for (int i = 0; i < SEGMENT_LENGTH; i++) {
bl[i] = new C4Location(c + i, r - i);
}
segments.add(bl);
}
}
return segments;
}
我们将当前位置存储在一个名为 position 的二维 C4Piece 数组中。在大多数情况下,二维数组是按行索引的。但将四子棋棋盘视为一组七个列在概念上非常强大,这使得编写 C4Board 类的其余部分稍微容易一些。例如,伴随的数组 columnCount 跟踪任何给定时间任何列中的棋子数量。这使得生成合法走法变得容易,因为每一步本质上是一个非填充列的选择。
接下来的四个方法与井字棋的对应方法相当相似。
列表 8.16 C4Board.java 继续内容
private C4Piece[][] position; // column first, then row
private int[] columnCount; // number of pieces in each column
private C4Piece turn;
public C4Board() {
// note that we're doing columns first
position = new C4Piece[NUM_COLUMNS][NUM_ROWS];
for (C4Piece[] col : position) {
Arrays.*fill*(col, C4Piece.E);
}
// ints by default are initialized to 0
columnCount = new int[NUM_COLUMNS];
turn = C4Piece.B; // black goes first
}
public C4Board(C4Piece[][] position, C4Piece turn) {
this.position = position;
columnCount = new int[NUM_COLUMNS];
for (int c = 0; c < NUM_COLUMNS; c++) {
int piecesInColumn = 0;
for (int r = 0; r < NUM_ROWS; r++) {
if (position[c][r] != C4Piece.E) {
piecesInColumn++;
}
}
columnCount[c] = piecesInColumn;
}
this.turn = turn;
}
@Override
public Piece getTurn() {
return turn;
}
@Override
public C4Board move(Integer location) {
C4Piece[][] tempPosition = Arrays.*copyOf*(position, position.length);
for (int col = 0; col < NUM_COLUMNS; col++) {
tempPosition[col] = Arrays.*copyOf*(position[col], position[col].length);
}
tempPosition[location][columnCount[location]] = turn;
return new C4Board(tempPosition, turn.opposite());
}
@Override
public List<Integer> getLegalMoves() {
List<Integer> legalMoves = new ArrayList<>();
for (int i = 0; i < NUM_COLUMNS; i++) {
if (columnCount[i] < NUM_ROWS) {
legalMoves.add(i);
}
}
return legalMoves;
}
一个私有辅助方法,countSegment(),返回特定片段中黑色或红色棋子的数量。它后面是 win-checking 方法,isWin(),它检查棋盘上的所有片段,并使用 countSegment() 来确定是否有任何片段有四种同色棋子。
列表 8.17 C4Board.java 继续内容
private int countSegment(C4Location[] segment, C4Piece color) {
int count = 0;
for (C4Location location : segment) {
if (position[location.column][location.row] == color) {
count++;
}
}
return count;
}
@Override
public boolean isWin() {
for (C4Location[] segment : SEGMENTS) {
int blackCount = countSegment(segment, C4Piece.B);
int redCount = countSegment(segment, C4Piece.R);
if (blackCount == SEGMENT_LENGTH || redCount == SEGMENT_LENGTH) {
return true;
}
}
return false;
}
与 TTTBoard 一样,C4Board 可以不修改地使用 Board 接口的 isDraw() 默认方法。
最后,为了评估一个位置,我们将评估其所有代表性片段,一次评估一个片段,并将这些评估相加以返回一个结果。同时包含红色和黑色棋子的片段将被视为毫无价值。包含两种颜色各两个棋子和两个空位的片段将被视为得分为 1。包含三种同色棋子的片段得分为 100。最后,包含四种同色棋子(胜利)的片段得分为 1,000,000。这些评估数字在绝对意义上是任意的,但它们的重要性在于它们相互之间的相对权重。如果片段是对手的片段,我们将取其分数的相反数。evaluateSegment() 是一个私有辅助方法,它使用前面的公式评估单个片段。使用 evaluateSegment() 生成所有片段的复合分数由 evaluate() 生成。
列表 8.18 C4Board.java 继续内容
private double evaluateSegment(C4Location[] segment, Piece player) {
int blackCount = countSegment(segment, C4Piece.B);
int redCount = countSegment(segment, C4Piece.R);
if (redCount > 0 && blackCount > 0) {
return 0.0; // mixed segments are neutral
}
int count = Math.*max*(blackCount, redCount);
double score = 0.0;
if (count == 2) {
score = 1.0;
} else if (count == 3) {
score = 100.0;
} else if (count == 4) {
score = 1000000.0;
}
C4Piece color = (redCount > blackCount) ? C4Piece.R : C4Piece.B;
if (color != player) {
return -score;
}
return score;
}
@Override
public double evaluate(Piece player) {
double total = 0.0;
for (C4Location[] segment : SEGMENTS) {
total += evaluateSegment(segment, player);
}
return total;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int r = NUM_ROWS - 1; r >= 0; r--) {
sb.append("|");
for (int c = 0; c < NUM_COLUMNS; c++) {
sb.append(position[c][r].toString());
sb.append("|");
}
sb.append(System.*lineSeparator*());
}
return sb.toString();
}
}
8.3.2 四子棋 AI
令人惊讶的是,我们为井字棋开发的 minimax() 和 findBestMove() 函数可以无需修改地直接用于我们的四子棋实现。在下面的代码片段中,与我们的井字棋 AI 代码相比,只有几个改动。最大的不同是 maxDepth 现在设置为 5。这使得计算机每步的思考时间变得合理。换句话说,我们的四子棋 AI 会评估未来五步的位置。
列表 8.19 ConnectFour.java
package chapter8;
import java.util.Scanner;
public class ConnectFour {
private C4Board board = new C4Board();
private Scanner scanner = new Scanner(System.in);
private Integer getPlayerMove() {
Integer playerMove = -1;
while (!board.getLegalMoves().contains(playerMove)) {
System.out.println("Enter a legal column (0-6):");
Integer play = scanner.nextInt();
playerMove = play;
}
return playerMove;
}
private void runGame() {
// main game loop
while (true) {
Integer humanMove = getPlayerMove();
board = board.move(humanMove);
if (board.isWin()) {
System.out.println("Human wins!");
break;
} else if (board.isDraw()) {
System.out.println("Draw!");
break;
}
Integer computerMove = Minimax.*findBestMove*(board, 5);
System.out.println("Computer move is " + computerMove);
board = board.move(computerMove);
System.out.println(board);
if (board.isWin()) {
System.out.println("Computer wins!");
break;
} else if (board.isDraw()) {
System.out.println("Draw!");
break;
}
}
}
public static void main(String[] args) {
new ConnectFour().runGame();
}
}
尝试玩四子棋 AI。你会注意到它生成每一步需要几秒钟,这与井字棋 AI 不同。除非你仔细思考你的走法,否则它可能仍然会打败你。至少它不会犯任何完全明显的错误。我们可以通过增加它搜索的深度来提高其表现,但每一步的计算机计算时间将呈指数增长。以下是我们与 AI 对战的前几步棋:
Enter a legal column (0-6):
3
Computer move is 3
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | |R| | | |
| | | |B| | | |
Enter a legal column (0-6):
4
Computer move is 5
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | |R| | | |
| | | |B|B|R| |
Enter a legal column (0-6):
4
Computer move is 4
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | |R| | |
| | | |R|B| | |
| | | |B|B|R| |
小贴士:你知道四子棋已经被计算机科学家“解决”了吗?解决一个游戏意味着知道在任何位置下最佳走法。四子棋的最佳第一步是将你的棋子放在中间列。
8.3.3 使用 alpha-beta 剪枝改进 minimax
Minimax 算法效果良好,但目前我们并没有进行非常深入的搜索。有一种对 minimax 的扩展,称为 alpha-beta 剪枝,可以通过排除搜索中不会比已搜索位置产生改进的位置来提高搜索深度。这种神奇的效果是通过在递归 minimax 调用之间跟踪两个值来实现的:alpha 和 beta。Alpha 代表在搜索树中找到的最佳最大化走法的评估,而 beta 代表迄今为止找到的最佳最小化走法的评估。如果 beta 低于或等于 alpha,那么进一步探索这个搜索分支就不再值得,因为已经找到了比在这个分支下更优或等效的走法。这种启发式方法可以显著减少搜索空间。
这里是刚刚描述的 alphabeta()。它应该放入我们现有的 Minimax.java 文件中。
列表 8.20 Minimax.java 继续部分
// Helper that sets alpha and beta for the first call
public static <Move> double alphabeta(Board<Move> board, boolean
maximizing, Piece originalPlayer, int maxDepth) {
return *alphabeta*(board, maximizing, originalPlayer, maxDepth, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
}
// Evaluates a Board b
private static <Move> double alphabeta(Board<Move> board, boolean
maximizing, Piece originalPlayer, int maxDepth,
double alpha,
double beta) {
// Base case - terminal position or maximum depth reached
if (board.isWin() || board.isDraw() || maxDepth == 0) {
return board.evaluate(originalPlayer);
}
// Recursive case - maximize your gains or minimize the opponent's
if (maximizing) {
for (Move m : board.getLegalMoves()) {
alpha = Math.*max*(alpha, *alphabeta*(board.move(m), false,
originalPlayer, maxDepth - 1, alpha, beta));
if (beta <= alpha) { // check cutoff
break;
}
}
return alpha;
} else { // minimizing
for (Move m : board.getLegalMoves()) {
beta = Math.*min*(beta, *alphabeta*(board.move(m), true,
originalPlayer, maxDepth - 1, alpha, beta));
if (beta <= alpha) { // check cutoff
break;
}
}
return beta;
}
}
现在你可以进行两个非常小的改动来利用我们新的函数。将 Minimax.java 中的 findBestMove() 改为使用 alphabeta() 而不是 minimax(),并将 ConnectFour.java 中的搜索深度从 5 改为 7。有了这些改动,你的平均四子棋玩家将无法击败我们的 AI。在我的电脑上,使用 minimax() 在深度为 7 时,我们的四子棋 AI 每步大约需要 20 秒,而使用 alphabeta() 在相同深度下只需几秒钟。这几乎是十分之一的时间!这是一个相当惊人的改进。
8.4 超过 alpha-beta 剪枝的 minimax 改进
本章中提出的算法已经经过了深入研究,多年来发现了许多改进。其中一些改进是针对特定游戏的,例如在棋类游戏中使用“位图”来减少生成合法走法的耗时,但大多数改进是通用的技术,可以用于任何游戏。
一种常见的技术是迭代加深。在迭代加深中,首先将搜索函数运行到最大深度为 1。然后运行到最大深度为 2。然后运行到最大深度为 3,以此类推。当达到指定的时间限制时,搜索停止。返回最后完成深度的结果。
本章中的示例被硬编码到一定的深度。如果游戏没有游戏时钟和时间限制,或者我们不在乎电脑思考的时间长短,这样做是可以的。迭代加深使 AI 能够在固定时间内找到其下一步走法,而不是在固定搜索深度下,完成它需要可变的时间。
另一种潜在的改进是静默搜索。在这种技术中,最小-最大搜索树将沿着导致位置发生重大变化(例如,象棋中的捕获)的路径进一步扩展,而不是沿着具有相对“安静”位置的路径。以这种方式,理想情况下,搜索将不会在不太可能为玩家带来显著优势的乏味位置上浪费计算时间。
要改进最小-最大搜索,有两种最好的方法:在分配的时间内搜索更深的层次,或者改进用于评估位置的评估函数。在相同的时间内搜索更多位置需要在每个位置上花费更少的时间。这可以通过找到代码效率或使用更快的硬件来实现,但也可能以牺牲后者改进技术——改进每个位置的评估为代价。使用更多参数或启发式方法来评估位置可能需要更多时间,但最终可能导致需要更少搜索深度来找到好走的引擎。
用于象棋中 alpha-beta 剪枝的最小-最大搜索的一些评估函数有数十种启发式方法。甚至已经使用遗传算法来调整这些启发式方法。在象棋游戏中,骑士的捕获应该值多少?它应该和主教一样值吗?这些启发式方法可能是区分优秀象棋引擎和一般象棋引擎的秘密成分。
8.5 真实世界的应用
最小-最大算法,结合如 alpha-beta 剪枝等进一步的扩展,是大多数现代象棋引擎的基础。它已经成功应用于各种策略游戏。事实上,你可能在电脑上玩的大部分棋类游戏的人工对手可能都使用了某种形式的最小-最大算法。
最小-最大算法(及其扩展,如 alpha-beta 剪枝)在象棋中的效果如此显著,以至于它导致了 1997 年 IBM 制造的象棋计算机 Deep Blue 击败人类象棋世界冠军加里·卡斯帕罗夫的著名事件。这场比赛是一个高度期待且具有变革性的事件。象棋被视为最高智力水平的领域。计算机在象棋中超越人类能力的事实,对某些人来说意味着人工智能应该被认真对待。
二十年后,绝大多数象棋引擎仍然基于最小-最大算法。今天基于最小-最大算法的象棋引擎远远超过了世界上最好的人类象棋选手。新的机器学习技术开始挑战纯最小-最大算法(及其扩展)的象棋引擎,但它们还没有在象棋中明确证明其优越性。
游戏的分支因子越高,最小-最大算法的效果就越差。分支因子是指某些游戏中某个位置的平均潜在移动数。这就是为什么最近在计算机玩围棋方面的进步需要探索其他领域,如机器学习。基于机器学习的围棋 AI 现在已经击败了最好的围棋人类选手。围棋的分支因子(因此是搜索空间)对于尝试生成包含未来位置的树的基于最小-最大算法来说,是压倒性的。但围棋是个例外,而不是规则。大多数传统棋盘游戏(国际象棋、围棋、四子棋、拼字游戏等)的搜索空间足够小,使得基于最小-最大技术的算法可以很好地工作。
如果你正在实现一个新的棋盘游戏人工智能对手,甚至是一个针对回合制纯计算机游戏的 AI,最小-最大算法可能是你应该首先尝试的算法。最小-最大算法也可以用于经济和政治模拟,以及博弈论实验。Alpha-beta 剪枝应该与任何形式的最小-最大算法兼容。
8.6 练习
-
为井字棋添加单元测试,以确保 getLegalMoves()、isWin() 和 isDraw() 方法正确工作。
-
为四子棋创建最小-最大单元测试。
-
TicTacToe.java 和 ConnectFour.java 中的代码几乎相同。将其重构为两个方法,这两个方法可以用于任何一种游戏。
-
将 ConnectFour.java 修改为让计算机与自身对弈。是先手玩家还是后手玩家获胜?每次都是同一个玩家吗?
-
你能否找到一种方法(通过分析现有代码或其他方式)来优化 ConnectFour.java 中的评估方法,以便在相同的时间内实现更高的搜索深度?
-
使用本章开发的 alphabeta() 函数,结合一个用于合法棋步生成和维护棋局状态的 Java 库,来开发一个棋类人工智能。
- 四子棋是 Hasbro, Inc. 的商标。在这里,它仅用于描述性和积极的用途。
9. 其他问题
在本书中,我们已经涵盖了与现代软件开发任务相关的众多问题解决技术。为了研究每种技术,我们探讨了著名的计算机科学问题。但并非每个著名问题都适合前几章的模式。本章是那些不太适合其他章节的著名问题的汇集点。将这些问题视为额外奖励:更有趣的问题,周围的支持结构较少。
9.1 背包问题
背包问题是一个优化问题,它将一个常见的计算需求——在有限的使用选项中找到有限资源的最佳使用方式——转化为一个有趣的故事。一个小偷带着偷东西的意图进入一栋房子。他有一个背包,他可以偷的东西受到背包容量的限制。他是如何决定把什么放进背包的呢?这个问题在图 9.1 中得到了说明。

图 9.1 小偷必须决定要偷哪些物品,因为背包的容量是有限的。
如果小偷可以拿走任何数量的任何物品,他可以简单地通过将每个物品的价值除以它的重量来找出在可用容量下最有价值的物品。但为了使场景更现实,让我们假设小偷不能拿走物品的一半(例如 2.5 台电视)。相反,我们将想出一个方法来解决 0/1 变体的问题,之所以称为 0/1 变体,是因为它强制执行另一条规则:小偷只能拿走每个物品的一个或零个。
首先,让我们定义一个内部类,Item,来存储我们的物品。
列表 9.1 Knapsack.java
package chapter9;
import java.util.ArrayList;
import java.util.List;
public final class Knapsack {
public static final class Item {
public final String name;
public final int weight;
public final double value;
public Item(String name, int weight, double value) {
this.name = name;
this.weight = weight;
this.value = value;
}
}
如果我们尝试使用暴力方法解决这个问题,我们将查看所有可能的物品组合,这些物品可以放入背包中。对于数学爱好者来说,这被称为幂集,一个集合(在我们的情况下,是物品集合)的幂集有 2^N个不同的可能子集,其中N是物品的数量。因此,我们需要分析 2*N*种组合(O(2N))。对于少量物品来说,这还可以接受,但对于大量物品来说,这是不可行的。任何使用指数级步骤解决问题的方法都是我们想要避免的方法。
相反,我们将使用一种称为动态规划的技术,它在概念上与记忆化(第一章)相似。在动态规划中,不是直接使用暴力方法解决问题,而是解决构成更大问题的子问题,存储这些结果,并利用这些存储的结果来解决更大的问题。只要背包的容量被考虑为离散的步骤,问题就可以用动态规划来解决。
例如,为了解决一个容量为 3 磅且有三件物品的背包问题,我们首先可以解决一个容量为 1 磅且有一件可能物品、2 磅容量且有一件可能物品、以及 3 磅容量且有一件可能物品的问题。然后,我们可以使用这个解决方案的结果来解决容量为 1 磅且有两件可能物品、2 磅容量且有两件可能物品、以及 3 磅容量且有两件可能物品的问题。最后,我们可以解决所有三件可能物品的问题。
在整个过程中,我们将填写一个表格,告诉我们每种物品组合和容量的最佳解决方案。我们的函数首先填写表格,然后根据表格找出解决方案。1
列表 9.2 Knapsack.java 继续列出
public static List<Item> knapsack(List<Item> items, int maxCapacity) {
// build up dynamic programming table
double[][] table = new double[items.size() + 1][maxCapacity + 1];
for (int i = 0; i < items.size(); i++) {
Item item = items.get(i);
for (int capacity = 1; capacity <= maxCapacity; capacity++) {
double prevItemValue = table[i][capacity];
if (capacity >= item.weight) { // item fits in knapsack
double valueFreeingWeightForItem = table[i][capacity - item.weight];
// only take if more valuable than previous item
table[i + 1][capacity] = Math.*max*(valueFreeingWeightForItem + item.value, prevItemValue);
} else { // no room for this item
table[i + 1][capacity] = prevItemValue;
}
}
}
// figure out solution from table
List<Item> solution = new ArrayList<>();
int capacity = maxCapacity;
for (int i = items.size(); i > 0; i--) { // work backwards
// was this item used?
if (table[i - 1][capacity] != table[i][capacity]) {
solution.add(items.get(i - 1));
// if the item was used, remove its weight
capacity -= items.get(i - 1).weight;
}
}
return solution;
}
该函数第一部分的内循环将执行 N * C 次,其中 N 是物品的数量,C 是背包的最大容量。因此,该算法的时间复杂度为 O(N * C),对于大量物品来说,这是一个显著的改进。例如,对于接下来的 11 件物品,暴力算法需要检查 2¹¹,即 2,048 种组合。前面的动态规划函数将执行 825 次,因为所讨论的背包的最大容量是 75 个任意单位(11 * 75)。随着物品数量的增加,这种差异将以指数级增长。
让我们看看解决方案的实际应用。
列表 9.3 Knapsack.java 继续列出
public static void main(String[] args) {
List<Item> items = new ArrayList<>();
items.add(new Item("television", 50, 500));
, 2, 300));
items.add(new Item("stereo", 35, 400));
items.add(new Item("laptop", 3, 1000));
items.add(new Item("food", 15, 50));
items.add(new Item("clothing", 20, 800));
items.add(new Item("jewelry", 1, 4000));
items.add(new Item("books", 100, 300));
items.add(new Item("printer", 18, 30));
items.add(new Item("refrigerator", 200, 700));
items.add(new Item("painting", 10, 1000));
List<Item> toSteal = *knapsack*(items, 75);
System.out.println("The best items for the thief to steal are:");
System.out.printf("%-15.15s %-15.15s %-15.15s%n", "Name", "Weight", "Value");
for (Item item : toSteal) {
System.out.printf("%-15.15s %-15.15s %-15.15s%n", item.name, item.weight, item.value);
}
}
}
如果你检查打印到控制台的结果,你会看到最佳选择物品是画作、珠宝、服装、笔记本电脑、立体声和烛台。以下是一些示例输出,显示了小偷在有限容量的背包中可以偷取的最有价值物品:
The best items for the thief to steal are:
Name Weight Value
painting 10 1000.0
jewelry 1 4000.0
clothing 20 800.0
laptop 3 1000.0
stereo 35 400.0
candlesticks 2 300.0
为了更好地理解这一切是如何工作的,让我们看看 knapsack() 方法的某些具体细节:
for (int i = 0; i < items.size(); i++) {
Item item = items.get(i);
for (int capacity = 1; capacity <= maxCapacity; capacity++) {
对于每种可能的物品数量,我们以线性方式遍历所有容量,直到背包的最大容量。请注意,我说的是“每种可能的物品数量”,而不是每个物品。当 i 等于 2 时,它不仅仅代表第 2 件物品。它代表在每种探索的容量下前两件物品的可能组合。item 是我们正在考虑偷取的下一件物品:
double prevItemValue = table[i][capacity];
if (capacity >= item.weight) { // item fits in knapsack
prevItemValue 是当前探索的容量下最后一种物品组合的值。对于每种可能的物品组合,我们考虑是否添加最新的“新”物品是可能的。
如果物品的重量超过我们考虑的背包容量,我们只需简单地复制我们考虑的最后一个物品组合的值:
else { // no room for this item
table[i + 1][capacity] = prevItemValue;
}
否则,我们考虑添加“新”物品是否会比我们之前考虑的该容量下最后一种物品组合的价值更高。我们通过将物品的价值添加到表格中已经计算的前一个组合的价值,并从当前考虑的容量中减去物品的重量来实现这一点。如果这个值高于当前容量下的最后一种物品组合,我们就插入它;否则,我们插入最后一个值:
double valueFreeingWeightForItem = table[i][capacity - item.weight];
// only take if more valuable than previous item
table[i + 1][capacity] = Math.*max*(valueFreeingWeightForItem + item.value, prevItemValue);
这就完成了表格的构建。然而,要实际上找到解决方案中的物品,我们需要从最高容量和最终探索的物品组合开始反向工作:
for (int i = items.size(); i > 0; i--) { // work backwards
// was this item used?
if (table[i - 1][capacity] != table[i][capacity]) {
我们从末尾开始,从右到左遍历我们的表格,检查在每个停止点是否插入了表中的值发生了变化。如果有变化,这意味着我们添加了在特定组合中考虑的新物品,因为该组合比先前的组合更有价值。因此,我们将该物品添加到解决方案中。同时,容量减少到物品的重量,这可以被视为向上移动表格:
solution.add(items.get(i - 1));
// if the item was used, remove its weight
capacity -= items.get(i - 1).weight;
注意:在整个表格构建和解决方案搜索过程中,你可能已经注意到了对迭代器和表格大小的 1 次操作。这是从程序化角度考虑的便利性。想想问题是如何从底部构建的。当问题开始时,我们处理的是一个零容量的背包。如果你从底部向上在表格中工作,就会清楚地知道为什么我们需要额外的行和列。
你还是感到困惑吗?表 9.1 是 knapsack()函数构建的表格。对于前面的问题,这将是一个相当大的表格,所以让我们看看一个容量为 3 磅、包含三个物品(火柴 1 磅、手电筒 2 磅和书 1 磅)的背包表格:假设这些物品的价值分别是$5、$10 和$15。
三个物品的背包问题的例子
| 0 磅 | 1 磅 | 2 磅 | 3 磅 | |
|---|---|---|---|---|
| 火柴(1 磅,$5) | 0 | 05 | 05 | 05 |
| 手电筒(2 磅,$10) | 0 | 05 | 10 | 15 |
| 书(1 磅,$15) | 0 | 15 | 20 | 25 |
当你从左到右查看表格时,重量在增加(你试图放入背包中的物品重量)。当你从上到下查看表格时,你试图放入的物品数量在增加。在第一行,你只尝试放入火柴。在第二行,你放入背包能容纳的火柴和手电筒中最有价值的组合。在第三行,你放入所有三个物品中最有价值的组合。
作为一项练习,以帮助您更好地理解,请尝试自己填写这个表格的空白版本,使用 knapsack()方法中描述的算法,并使用这三个相同的物品。然后使用函数末尾的算法从表中读取正确的物品。此表对应于函数中的 table 变量。
9.2 旅行商问题
旅行商问题是计算领域中最为经典和广为人知的难题之一。销售人员必须访问地图上的所有城市恰好一次,并在旅程结束时返回他的起始城市。每个城市都有直接连接到其他所有城市的连接,销售人员可以按任何顺序访问城市。销售人员的最短路径是什么?
可以将这个问题视为一个图问题(第四章),其中城市是顶点,它们之间的连接是边。您的第一反应可能是找到第四章中描述的最小生成树。不幸的是,旅行商问题的解决方案并不那么简单。最小生成树是连接所有城市所需道路最少的方法,但它并不提供访问所有城市恰好一次的最短路径。
尽管这个问题看起来相当简单,但没有任何算法可以快速解决任意数量的城市。我说的“快速”是什么意思?我的意思是这个问题是所谓的NP 难题。一个 NP 难题(非确定性多项式难题)是对于该问题没有已知的多项式时间算法的问题。(所需时间是输入大小的多项式函数。)随着销售人员需要访问的城市数量的增加,解决问题的难度会异常迅速增长。对于 20 个城市来说,解决问题比 10 个城市要困难得多。根据目前的知识,在合理的时间内,不可能完美(最优)地解决数百万城市的问题。
注意:旅行商问题的直观方法是 O(n!)。为什么是这样,将在 9.2.2 节中讨论。尽管如此,我们建议在阅读 9.2.2 节之前先阅读 9.2.1 节,因为对问题的直观解决方案的实现将使其复杂性显而易见。
9.2.1 直观方法
对于该问题的直观方法是简单地尝试所有可能的城市的组合。尝试直观方法将说明问题的难度以及这种方法在更大规模上的不适用性。
我们的样本数据
在我们版本的旅行商问题中,销售人员有兴趣访问佛蒙特州的主要城市中的五个。我们不会指定一个起始城市(因此也是结束城市)。图 9.2 展示了这五个城市以及它们之间的驾驶距离。请注意,每对城市之间都列出了一个距离。
也许你之前已经见过以表格形式呈现的驾驶距离。在驾驶距离表中,可以轻松查找任何两个城市之间的距离。表 9.2 列出了问题中五个城市的驾驶距离。
佛蒙特州城市之间的驾驶距离
| Rutland | Burlington | White River Junction | Bennington | Brattleboro | |
|---|---|---|---|---|---|
| Rutland | 00 | 067 | 46 | 055 | 075 |
| Burlington | 67 | 000 | 91 | 122 | 153 |
| White River Junction | 46 | 091 | 00 | 098 | 065 |
| Bennington | 55 | 122 | 98 | 000 | 040 |
| Brattleboro | 75 | 153 | 65 | 040 | 000 |

图 9.2 佛蒙特州的五个城市及其之间的驾驶距离
我们需要将城市及其之间的距离编码化,以解决我们的问题。为了使城市之间的距离易于查找,我们将使用一个地图的地图,外层键表示一对中的第一个,内层键表示第二个。这将是一个类型为 Map<String, Map<String, Integer>>的地图,它将允许进行如 vtDistances.get("Rutland").get("Burlington")这样的查找,它应该返回 67。当我们实际解决佛蒙特州的问题时,我们将使用 vtDistances 地图,但首先,让我们做一些设置。我们的类包含这个地图,并有一个我们将稍后用于在数组中两个位置之间交换项的实用方法。
列表 9.4 TSP.java
package chapter9;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
public class TSP {
private final Map<String, Map<String, Integer>> distances;
public TSP(Map<String, Map<String, Integer>> distances) {
this.distances = distances;
}
public static <T> void swap(T[] array, int first, int second) {
T temp = array[first];
array[first] = array[second];
array[second] = temp;
}
寻找所有排列
解决旅行商问题的直观方法需要生成所有可能的城市排列。有许多排列生成算法;它们足够简单,以至于你几乎可以自己想出一个。
一个常见的方法是回溯。你第一次在第三章中看到回溯是在解决约束满足问题的上下文中。在约束满足问题解决中,在找到一个不满足问题约束的局部解决方案后,使用回溯。在这种情况下,你回到一个较早的状态,并沿着不同于导致错误局部解决方案的路径继续搜索。
为了找到数组中(最终是我们的城市)的所有排列,我们也将使用回溯法。在交换元素并进入进一步排列的路径之后,我们将回溯到交换之前的状态,以便我们可以进行不同的交换并沿着不同的路径前进。
列表 9.5 TSP.java 继续
private static <T> void allPermutationsHelper(T[] permutation, List<T[]> permutations, int n) {
// Base case - we found a new permutation, add it and return
if (n <= 0) {
permutations.add(permutation);
return;
}
// Recursive case - find more permutations by doing swaps
T[] tempPermutation = Arrays.*copyOf*(permutation, permutation.length);
for (int i = 0; i < n; i++) {
*swap*(tempPermutation, i, n - 1); // move element at i to end
// move everything else around, holding the end constant
*allPermutationsHelper*(tempPermutation, permutations, n - 1);
*swap*(tempPermutation, i, n - 1); // backtrack
}
}
这个递归函数被标记为“辅助”函数,因为它实际上将由另一个参数较少的函数调用。allPermutationsHelper()的参数是我们正在处理的起始排列,到目前为止生成的排列,以及剩余要交换的项的数量。
对于需要跨调用保持多个状态项的递归函数,一个常见的模式是有一个具有较少参数的独立面向外部的函数,更容易使用。allPermutations()就是那个更简单的函数。
列表 9.6 TSP.java 继续
private static <T> List<T[]> permutations(T[] original) {
List<T[]> permutations = new ArrayList<>();
*allPermutationsHelper*(original, permutations, original.length);
return permutations;
}
allPermutations() 只接受一个参数:需要生成排列的数组。它调用 allPermutationsHelper() 来找到这些排列。这避免了 allPermutations() 的用户需要向 allPermutationsHelper() 提供排列和 n 的参数。
这里提出的回溯法寻找所有排列相当高效。找到每个排列只需要在数组中进行两次交换。然而,只需每个排列进行一次交换,就可以找到数组的所有排列。一个完成这个任务的效率高的算法是堆算法(不要与堆数据结构混淆——这里的堆是算法发明者的名字)。2 这种效率差异对于非常大的数据集可能很重要(我们这里处理的数据集不是这样的)。
暴力搜索
我们现在可以生成城市列表的所有排列,但这并不完全等同于旅行商问题的路径。回想一下,在旅行商问题中,销售员必须最终返回他开始的城市。当我们计算哪条路径是最短的时候,我们可以轻松地加上销售员访问的最后一个城市到第一个访问的城市之间的距离,我们很快就会这么做。
我们现在可以尝试测试我们排列的路径。暴力搜索方法痛苦地查看路径列表中的每一条路径,并使用查找表(distances)中两个城市之间的距离来计算每条路径的总距离。它打印出最短路径以及该路径的总距离。
列表 9.7 TSP.java 继续
public int pathDistance(String[] path) {
String last = path[0];
int distance = 0;
for (String next : Arrays.*copyOfRange*(path, 1, path.length)) {
distance += distances.get(last).get(next);
// distance to get back from last city to first city
last = next;
}
return distance;
}
public String[] findShortestPath() {
String[] cities = distances.keySet().toArray(String[]::new);
List<String[]> paths = *permutations*(cities);
String[] shortestPath = null;
int minDistance = Integer.MAX_VALUE; // arbitrarily high
for (String[] path : paths) {
int distance = pathDistance(path);
// distance from last to first must be added
distance += distances.get(path[path.length - 1]).get(path[0]);
if (distance < minDistance) {
minDistance = distance;
shortestPath = path;
}
}
// add first city on to end and return
shortestPath = Arrays.*copyOf*(shortestPath, shortestPath.length + 1);
shortestPath[shortestPath.length - 1] = shortestPath[0];
return shortestPath;
}
public static void main(String[] args) {
Map<String, Map<String, Integer>> vtDistances = Map.*of*(
"Rutland", Map.*of*(
"Burlington", 67,
"White River Junction", 46,
"Bennington", 55,
"Brattleboro", 75),
"Burlington", Map.*of*(
"Rutland", 67,
"White River Junction", 91,
"Bennington", 122,
"Brattleboro", 153),
"White River Junction", Map.*of*(
"Rutland", 46,
"Burlington", 91,
"Bennington", 98,
"Brattleboro", 65),
"Bennington", Map.*of*(
"Rutland", 55,
"Burlington", 122,
"White River Junction", 98,
"Brattleboro", 40),
"Brattleboro", Map.*of*(
"Rutland", 75,
"Burlington", 153,
"White River Junction", 65,
"Bennington", 40));
TSP tsp = new TSP(vtDistances);
String[] shortestPath = tsp.findShortestPath();
int distance = tsp.pathDistance(shortestPath);
System.out.println("The shortest path is " + Arrays.*toString*(shortestPath) + " in " +
distance + " miles.");
}
}
我们最终可以暴力搜索佛蒙特州的城市,找到到达所有五个城市的最短路径。输出应该看起来像以下这样,最佳路径在图 9.3 中展示。
The shortest path is [White River Junction, Burlington, Rutland, Bennington, Brattleboro, White River Junction] in 318 miles.

图 9.3 展示了销售员访问佛蒙特州所有五个城市的最短路径。
9.2.2 提升到下一个层次
旅行商问题没有简单的答案。我们直观的方法很快变得不可行。生成的排列数是 n 的阶乘 (n!),其中 n 是问题中的城市数量。如果我们只增加一个城市(六个而不是五个),评估的路径数量将增加六倍。然后,在增加一个城市之后,解决问题将变得困难七倍。这不是一个可扩展的方法!
在现实世界中,对旅行商问题的直观方法很少被使用。大多数用于具有大量城市的问题实例的算法都是近似解。它们试图为近最优解解决问题。近最优解可能位于完美解的小范围内。(例如,可能不会比完美解低 5%。)
本书已经介绍过的两种技术已被用于在大数据集上尝试旅行商问题。动态规划,我们在本章早些时候的背包问题中使用过,是一种方法。另一种是遗传算法,如第五章所述。许多期刊文章已经发表,将遗传算法归因于具有大量城市的旅行商问题的近似最优解。
9.3 电话号码助记符
在智能手机内置地址簿出现之前,电话的数字键盘上每个键都标有字母。这些字母的原因是为了提供易于记忆的助记符,以便记住电话号码。在美国,通常 1 键没有字母,2 键有 ABC,3 键有 DEF,4 键有 GHI,5 键有 JKL,6 键有 MNO,7 键有 PQRS,8 键有 TUV,9 键有 WXYZ,而 0 键没有字母。例如,1-800-MY-APPLE 对应电话号码 1-800-69-27753。偶尔你还会在广告中找到这些助记符,因此键盘上的数字已经进入了现代智能手机应用,如图 9.4 所示。

图 9.4 iOS 中的电话应用保留了其电话前辈所包含的字母键。
如何为电话号码想出一个新的助记符?在 20 世纪 90 年代,有流行的共享软件来帮助这项工作。这些软件会生成电话号码字母的每个有序组合,然后通过查找字典来寻找包含在这些组合中的单词。然后,它们会将包含最完整单词的组合显示给用户。我们将完成问题的前半部分。字典查找将留作练习。
在上一个问题中,当我们查看排列生成时,我们通过交换现有排列来生成不同的排列来得到答案。你可以把它想象成从一个成品开始,然后倒着工作。对于这个问题,我们不会通过交换现有解决方案中的位置来生成新的解决方案,而是从零开始生成每个解决方案,从一个空字符串开始。我们将通过查看可能匹配电话号码中每个数字的字母,并在我们到达每个后续数字时不断添加更多选项到末尾来实现这一点。这是一种笛卡尔积。
什么是笛卡尔积?在集合论中,笛卡尔积是来自一个集合的每个成员与另一个集合中的每个成员的所有组合的集合。例如,如果一个集合包含字母“A”和“B”,另一个集合包含字母“C”和“D”,那么笛卡尔积将是集合“AC”、“AD”、“BC”和“BD”。“A”与第二个集合中它能结合的每个字母结合,而“B”与第二个集合中它能结合的每个字母结合。如果我们的电话号码是 234,我们需要找到“ABC”与“DEF”的笛卡尔积。一旦我们找到了这个结果,我们还需要将其与“GHI”的笛卡尔积结合起来。这个积的积就是我们的答案。
我们将不会使用集合。我们将使用数组。这对我们的数据表示来说更方便。
首先,我们将定义一个数字到潜在字母的映射和一个构造函数。
列表 9.8 PhoneNumberMnemonics.java
package chapter9;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Map;
import java.util.Scanner;
public class PhoneNumberMnemonics {
Map<Character, String[]> phoneMapping = Map.*of*(
'1', new String[] { "1" },
'2', new String[] { "a", "b", "c" },
'3', new String[] { "d", "e", "f" },
'4', new String[] { "g", "h", "i" },
'5', new String[] { "j", "k", "l" },
'6', new String[] { "m", "n", "o" },
'7', new String[] { "p", "q", "r", "s" },
'8', new String[] { "t", "u", "v" },
'9', new String[] { "w", "x", "y", "z" },
'0', new String[] { "0", });
private final String phoneNumber;
public PhoneNumberMnemonics(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
下一个方法通过简单地将第一个数组中的每个项目添加到第二个数组中的每个项目,并汇总这些结果,执行两个字符串数组的笛卡尔积。
列表 9.9 PhoneNumberMnemonics.java 继续显示
public static String[] cartesianProduct(String[] first, String[] second) {
ArrayList<String> product = new ArrayList<>(first.length * second.length);
for (String item1 : first) {
for (String item2 : second) {
product.add(item1 + item2);
}
}
return product.toArray(String[]::new);
}
现在,我们可以找到电话号码的所有可能的助记符。getMnemonics() 通过逐次取每个先前积的笛卡尔积(从包含一个空字符串的数组开始)和表示下一个数字的字母数组来实现这一点。main() 为用户提供的任何电话号码运行 getMnemonics()。
列表 9.10 PhoneNumberMnemonics.java 继续显示
public String[] getMnemonics() {
String[] mnemonics = { "" };
for (Character digit : phoneNumber.toCharArray()) {
String[] combo = phoneMapping.get(digit);
if (combo != null) {
mnemonics = *cartesianProduct*(mnemonics, combo);
}
}
return mnemonics;
}
public static void main(String[] args) {
System.out.println("Enter a phone number:");
Scanner scanner = new Scanner(System.in);
String phoneNumber = scanner.nextLine();
scanner.close();
System.out.println("The possible mnemonics are:");
PhoneNumberMnemonics pnm = new PhoneNumberMnemonics(phoneNumber);
System.out.println(Arrays.*toString*(pnm.getMnemonics()));
}
}
结果表明,电话号码 1440787 也可以写成 1GH0STS。这样更容易记住。
9.4 现实世界应用
动态规划,如与背包问题一起使用,是一种广泛适用的技术,可以通过将问题分解为构成的小问题并从这些部分构建解决方案来解决看似难以解决的问题。背包问题本身与涉及有限资源(背包的容量)必须在有限但详尽的选项(要偷的物品)之间分配的其他优化问题相关。想象一下,一所大学需要分配其体育预算。它没有足够的钱来资助每个团队,并且对每个团队将带来的校友捐款有一定的期望。它可以运行类似背包的问题来优化预算的分配。这类问题在现实世界中很常见。
旅行商问题(Traveling Salesman Problem)对于像 UPS 和 FedEx 这样的运输和分销公司来说是日常发生的事情。包裹递送公司希望他们的司机走尽可能短的路线。这不仅使司机的任务更加愉快,而且还能节省燃料和维护成本。我们为了工作或娱乐而旅行,在访问多个目的地时找到最佳路线可以节省资源。但旅行商问题不仅仅是用于路线规划;它几乎出现在任何需要单一访问节点的路由场景中。尽管最小生成树(第四章)可以最小化连接社区所需的最小电线量,但它并不能告诉我们如果每个房子都必须作为巨型回路的一部分连接到另一座房子时,最优的电线量是多少。旅行商问题可以。
与用于旅行商问题(Traveling Salesman Problem)和电话号码助记程序的朴素方法中使用的排列和组合生成技术一样,这些技术对于测试各种穷举算法非常有用。例如,如果你试图破解一个短密码,并且知道它的长度,你可以生成密码中可能包含的所有可能的字符排列。从事此类大规模排列生成任务的实践者明智地使用像堆算法(Heap’s algorithm)这样的特别高效的排列生成算法。
9.5 练习
-
使用第四章中的图框架重新编程旅行商问题的朴素方法。
-
实现第五章中描述的遗传算法来解决旅行商问题。从本章描述的佛蒙特州城市简单数据集开始。你能否让遗传算法在短时间内找到最优解?然后尝试使用越来越多城市的这个问题。遗传算法的表现如何?你可以在网上搜索到大量专门为旅行商问题制作的数据集。为检查你方法的有效性开发一个测试框架。
-
使用带有电话号码助记程序的字典,并仅返回包含有效字典单词的排列。
-
我研究了多个资源来编写这个解决方案,其中最权威的是罗伯特·赛杰威克(Robert Sedgewick)的《算法》(Addison-Wesley,1988 年),第 2 版(p. 596)。我查看了 Rosetta Code 上的几个 0/1 背包问题示例,最值得注意的是 Python 动态规划解决方案(
mng.bz/kx8C),这个函数在很大程度上是移植自书的 Swift 版本。(它从 Python 到 Swift,再回到 Python,然后到 Java。) -
罗伯特·赛杰威克(Robert Sedgewick),“排列生成方法”(普林斯顿大学),
mng.bz/87Te.
10 访谈布莱恩·戈茨
布莱恩·戈茨是 Java 界的重要人物之一。作为 Oracle 的 Java 语言架构师,他帮助引导语言的发展方向及其支持库。他领导语言经历了多次重要的现代化,包括 Lambda 项目。布莱恩在软件工程领域有着漫长的职业生涯,并且是畅销书《Java 并发实践》(Addison-Wesley Professional,2006 年)的作者。
这次访谈是在 2020 年 8 月 25 日,在佛蒙特州威利斯顿布莱恩的家中进行的。为了清晰起见,访谈记录已被编辑和缩减。
你是如何开始接触计算机的?
我大约在 1978 年开始作为爱好者接触计算机,当时我 13 或 14 岁。我通过当地学校可以访问到分时计算系统,我比我大几岁的哥哥在我之前参加了同样的项目,他给我带来了书和其他阅读材料,我完全着迷了。我对这个由复杂但可理解的规则所支配的系统感到无比着迷。因此,我尽可能地利用放学后的时间在学校计算机房学习我能学到的所有东西。当时,编程是一个多语言的时代。并没有像过去 25 年那样有一个主导的语言。每个人都期望知道多种编程语言。我自学了 BASIC、Fortran、COBOL、APL 和汇编语言。我看到了每种语言都是解决不同问题的不同工具。我完全是自学成才,因为当时并没有真正的正规教育。我的学位不是计算机科学,而是数学,因为当时很多学校甚至没有计算机科学系。我认为这种数学倾向对我非常有帮助。
在你最初学习编程时,有没有一种编程语言对你产生了非常大的影响?
我最初学习时,并没有特别突出的语言。我只是交换着学习。当时占主导地位的语言是 Fortran、COBOL 和 BASIC,用于解决不同类别的问题。但后来,当我成为研究生时,我有机会在麻省理工学院修读《计算机程序的结构与解释》课程,在那里我学习了 Scheme 语言,这也是我所有灵光一现的地方。那时,我已经编程近 10 年了,已经遇到了很多有趣的问题。这是我第一次看到有一个贯穿的理论可以连接我观察到的很多现象。对我来说,我很幸运能作为研究生而不是新生上这门课,因为新生们完全被抛向他们的材料量压倒了。更多的经验让我能够看到背后的美丽和结构,而没有被细节所分散。如果我要选择一个让我真正意识到计算机之美的时候,那就是那门课。
你是如何在大学之后发展你的软件工程和计算机科学技能集的?
我认为我几乎和所有人一样思考——主要是通过实践。在典型的专业工程情况下,你往往会被直接扔进深水区,面对一个需要解决的问题,你必须自己解决它。你有一系列工具可供使用,但并不总是明显知道应该使用哪个,而且有一个试错的过程,你尝试各种方法。你看到哪些方法是有效的——以及当问题达到一定复杂度阈值时,哪些方法不再有效。希望在这个过程中,有一些归纳推理的过程在进行,通过它你可以弄清楚为什么某些方法有效,以及它们何时可能再次有效,或者可能不会有效。在我职业生涯的早期,我有一些相当典型的软件工程工作;我在一个研究实验室工作过,也在一个制作网络软件的小型软件公司工作过。我通过实践和实验学习,就像今天的大多数开发者一样。
你的职业生涯是如何引导你成为 Java 语言架构师的?
通过一条相当奇怪且曲折的道路!在我职业生涯的前半段,我主要是一名普通程序员。在某个时候,我过渡到了介于编程和教育之间的一种状态,做演讲、写文章,最终写了一本书。我总是试图选择那些曾经让我感到困惑的主题——基于它们可能也会让其他人感到困惑的理论——并且我试图以一个可理解的方式呈现它们。我发现我有一种将技术细节和直观心理模型之间的差距连接起来的天赋,这最终导致了《Java 并发实践》一书的写作,这本书到现在已经快 15 年了!从那里,我去了 Sun 公司工作,这个角色更多地关于技术布道,而不是开发,向人们解释“JVM 是如何工作的?”“动态编译器做什么?”“为什么动态编译可能比静态编译更好?”我试图消除技术的神秘感,并驳斥围绕它的神话。一旦我进入其中,我就有机会以更实质性的方式做出贡献。如果我要为人们提供一条通往那里的路线图,我不知道该如何绘制它。这绝对不是一条直线。
成为 Java 语言架构师意味着什么?
简而言之,我必须决定 Java 编程模型的发展方向。当然,这里有很多很多选择——很多人都会乐意给我提建议!我相信这九百万 Java 开发者中每个人都有一个或两个关于语言特性的想法。当然,我们不可能做所有这些,甚至很多。所以我们必须非常谨慎地选择。我的工作是平衡继续前进的需要,使 Java 保持相关性,以及 Java 保持“Java”特性的需要。相关性有很多维度:与我们想要解决的问题相关;与我们在其上运行的硬件相关;与程序员的兴趣、取向甚至时尚相关。我们必须发展,但我们也不能走得如此之快,以至于失去了人们。如果我们一夜之间进行彻底的改变,人们会说:“这不是我认识的 Java 了,”然后他们会去做其他事情。因此,我们必须选择前进的方向和速度,这样我们才能保持与人们想要解决的问题的相关性,同时又不让人们感到不舒服。
对我来说,这意味着要站在 Java 开发者的角度,了解他们的痛点。然后我们尝试以与他们合作的方式推动语言的发展,消除他们正在经历的不便,但并不一定是他们想象中需要的那种方式。有一个古老的谚语归功于亨利·福特:“如果我问我的客户他们想要什么,他们会告诉我‘更快的马’。”程序员非常倾向于说“你应该添加这个功能”,而艺术在于倾听这个建议,理解他们为什么认为这是正确的解决方案。通过将这一点与其他开发者听到的事情进行比较,我们可能可以看到真正缺失的东西,这实际上会解决人们的痛点,提高他们的生产力,使程序更安全,更高效。
Java 语言的演变过程是如何工作的?是如何决定向语言中添加新功能的?
实际上,完全从头发明一个功能是非常罕见的。现实是,几乎每一个“新”想法在编程界已经存在了几十年。当有人向我提出一个功能想法时,我总能看到它与很久以前在某种其他语言中做过的事情有关联。这个过程的一个重要部分是等待合适的时机来展示一个新概念,并以与语言其他部分一致的方式融入其中。功能想法并不缺乏,在每种语言中你都能找到许多社区中人们喜欢的功能。真正的挑战是深入挖掘这些功能,问自己,“拥有这个功能能让你做什么,而其他方式做不到?它是如何使你的程序更安全的?它如何允许更好的类型检查?它是如何使你的程序更少出错、更易于表达等等?”
这是一个相当主观的过程;如果我们想减轻人们的痛苦,我们必须对哪些类型的痛苦现在需要缓解做出主观的判断。你可以看看过去添加到 Java 中的大型特性:在 2000 年代中期,我们看到了泛型,那是一个明显的差距。当时语言迫切需要参数多态性。他们想在 1995 年就有,但他们不知道如何以一种对语言有意义的方式实现它。他们也不想将 C++模板嫁接到 Java 上,那会非常糟糕。又花了近 10 年时间才想出如何在 Java 中自然地添加参数多态性和数据抽象。我认为他们做得非常出色。当我们最近做 lambda 表达式时,我们也做了同样的行为抽象。同样,那里的困难工作并不在理论。lambda 表达式的理论自 1930 年代以来就已经被很好地理解了。困难的部分是如何让它适合 Java,让它看起来不是随意添加的?成功的最终衡量标准是当你最终在三年、五年或七年后交付某物时,人们会说:“你们为什么花这么长时间?这太明显了。”嗯,我们第一年的版本看起来不会那么容易或明显。我们不想把这种东西强加于人,所以我们必须花时间。
在考虑一个特性时,你如何知道它不仅仅是时尚,而是开发者真正需要的重要特性?
这是一个非常好的问题,因为有一些真正的险些失之交臂。在 2000 年代初,有人强烈呼吁将 XML 字面量添加到 Java 语言中,我认为这是一个避开的子弹。并不是所有语言都避开了这个子弹。
我不能给你一个算法;通常,你只需要坐下来长时间地思考,看看它与语言其他部分的联系是什么。我们都见过一些语言在旁边添加一个特性来解决特定问题,但那个问题可能不是永恒的问题。如果你愿意坐下来耐心地反复思考,在做出决定之前,你通常可以感觉到什么时候某件事只是本周的风味。
在你担任 Java 语言架构师期间,你最自豪的 Java 语言添加了哪些特性?
我曾是负责将 lambda 表达式添加到 Java 中的规格制定负责人。这不仅是一个巨大的变化,而且标志着语言未来发展的一个转折点。在某种程度上,这是一个成败攸关的事情,因为在那时,Sun 公司正忙于缓慢地走向破产,而我们并没有能够以我们希望的速度发展平台。当时很明显,Java 正在落后,这是我们向世界证明 Java 仍然具有相关性和编程乐趣的大好机会;我们可以继续教会这只老狗一些新奇的技巧。
将 lambda 表达式添加到 Java 中的主要挑战是让它看起来不是随意添加的,而是像它一直就在那里一样,完美地融入整体。关于如何实现这一点有很多建议——几乎所有的建议都是“像其他语言那样做。”所有这些都会让人不满意。我们可能会快一两年到达那里,但结果可能不会那么好,而且我们可能会长期受制于它。我真正自豪的是我们如何设法在多个层面上将其整合到语言中,使其看起来就像它属于那里一样。它与泛型类型系统结合得非常干净利落。为了使其工作,我们必须对语言的许多其他方面进行彻底改革——我们必须改革类型推断及其与重载选择的关系。如果你问人们 Java 8 中添加了哪些特性,这绝对不会出现在任何人的列表上。但这是工作的巨大一部分——它是在水下进行的,但它是使您能够编写您想要编写的代码并自然工作的基础。
我们还必须开始解决兼容 API 进化的问题。我们在 Java 8 中面临的一个巨大风险是,使用 lambda 表达式编写库的方式与在无 lambda 表达式的语言中编写的方式非常不同。我们不想让语言变得更好,然后突然间,我们所有的库看起来都像 20 年前的一样。我们必须解决如何以兼容的方式进化库的问题,以便它们可以利用这些新的库设计惯例。这导致了向接口中兼容地添加方法的能力,这对于保持我们已有的库的相关性是至关重要的,这样在第一天,语言和库就为一种新的编程风格做好了准备。
我经常被很多学生问到的问题是他们应该使用 lambda 表达式到什么程度?他们是否会在代码中过度使用它们?
我可能比很多学生看待这个问题有不同视角,因为大部分我写的代码都是打算供很多人使用的库。编写这样一个库,比如流 API,门槛非常高,因为你必须第一次就做对。改变它的兼容性门槛也非常高。我倾向于思考如何通过抽象行为跨越用户代码和库代码之间的边界,lambda 的主要作用是设计出可以参数化不仅数据,还可以行为的 API,因为 lambda 让我们可以将行为视为数据。所以,我专注于客户端与这个库之间的交互以及控制流的自然流动。什么时候客户端拉所有弦是有意义的,而什么时候客户端将一些行为交给库,在适当的时候调用它是有意义的?我不确定我的经验是否可以直接转化为你学生的经验。但在这里,成功的关键肯定是一旦能够识别出代码中的边界,以及责任划分在哪里。这些边界是否严格地划分在单独编译的模块中,或者是否是精心文档化的 API,或者它们只是我们组织代码的惯例,这是我们希望保持意识到的。
我们在代码中使用这些边界是有原因的——这样我们才能通过分而治之来管理复杂性。每次你设计这些边界时,你就是在设计一个小型的协议交互,你应该在思考参与者的角色,他们交换的信息,以及这种交换看起来像什么。
你之前提到过 Java 的一段停滞期。那是在什么时候,为什么会发生这种情况?
我会说 Java 6-7 时期是 Java 的黑暗时代。不是巧合的是,这也是 Scala 开始获得一些影响力的时期,部分原因是因为我认为生态系统在说,“好吧,如果 Java 不能站起来并奔跑,我们可能需要找到另一匹马去支持。”幸运的是,它站起来了,并且一直奔跑到现在。
现在我们看到语言正在迅速进化。哲学是如何变化的?
从大局来看,它并没有发生太大的变化,但在细节上,它发生了相当大的变化。从 Java 9 开始,我们转向了六个月的时间框发布节奏,而不是多年的特性框发布节奏。这样做有很多很好的理由,但其中之一是,当我们在规划多年发布时,总是有很多好的、较小的想法被大的发布驱动因素所淹没。较短的发布节奏使我们能够更好地混合大特性和小特性。在这些六个月发布的版本中,很多都有较小的语言特性,比如局部变量类型推断。它们不一定只花了六个月来完成;它们可能仍然花了整整一年或两年,但现在我们有了更多机会,一旦准备好就可以交付。除了较小的特性外,你还会看到像模式匹配这样的大特性弧,这些特性可能在一个多年度的时期内逐步实现。早期部分可以让我们了解语言的发展方向。
同样也存在一些相关的功能簇,这些功能可以单独提供。例如,模式匹配、记录和密封类型共同作用,以支持更面向数据的编程模型。这并非偶然。这是基于观察人们在使用 Java 的静态类型系统来建模他们正在处理的数据时所遇到的痛苦。在过去 10 年里,程序又发生了怎样的变化呢?它们变得更小了。人们正在编写更小的功能单元,并将它们作为(比如说)微服务部署。因此,更多的代码更接近边界,将从某个合作伙伴那里获取数据,无论是通过套接字连接的 JSON、XML 还是 YAML,然后将其转换为某种 Java 数据模型,进行操作,然后再进行反向操作。由于人们越来越多地这样做,我们希望使数据建模更容易。因此,这个功能簇被设计成以这种方式协同工作。你可以在许多其他语言中看到类似的功能簇,只是名称不同。在机器学习(ML)中,你会称它们为代数数据类型,因为记录是积类型,密封类是和类型,你通过模式匹配在代数数据类型上实现多态。这些是 Java 开发者可能之前没有见过的单个功能,因为他们没有在 Scala、ML 或 Haskell 中编程。它们可能对 Java 来说是新的,但它们并不是新概念,并且已经被证明可以协同工作,以实现与人们今天解决的问题相关的编程风格。
我想知道在即将推出的 Java 特性中,你最兴奋的是哪一个。
我对模式匹配的更大图景感到非常兴奋,因为随着我对它的研究,我意识到这一直是 Java 对象模型中缺失的一块,而我们之前并没有注意到。Java 提供了很好的封装工具,但它只走了一步:你用一些数据调用构造函数,这样就得到了一个对象,然后这个对象在放弃其状态方面非常谨慎。而且它放弃状态的方式通常是通过一些难以程序化推理的特定 API。但是,存在一大类仅仅模拟普通数据的类。解构模式的观念实际上是我们从第一天起就有的一个概念的对立面,那就是构造函数。构造函数接受状态并将其转换为对象。那么它的反面是什么呢?你如何将对象解构为最初(或可以重新启动)的状态?这正是模式匹配让你做到的。结果发现,有很多问题,使用模式匹配的解决方案比临时解决方案要简单、优雅得多,最重要的是可组合性更强。
我提到这一点是因为尽管我们在过去 50 年中学习了关于编程语言理论的所有进步,但我对编程语言历史的总结只有一句话:“我们有一个有效的技巧。”这个技巧就是组合。这是唯一能够管理复杂性的方法。因此,作为一个语言设计者,你希望寻找允许开发者与组合而不是与之对抗的技术。
为什么了解来自计算机科学领域的解决问题的技巧很重要?
站在巨人的肩膀上!有那么多问题已经被别人解决了,通常需要巨大的努力和费用,并且伴随着许多尝试。如果你不知道如何识别你面前的问题可能已经被某人解决过,你可能会倾向于重新发明他们的解决方案——而且你很可能做得不如他们好。
我前几天看到了一个有趣的漫画,讲述数学是如何工作的。当有新发现时,起初没有人相信它是真的,需要多年时间来弄清楚细节。可能还需要更多年才能让数学界的其他成员同意这实际上是有意义的。然后在另一端,你在一个讲座上花 45 分钟讲解,当一个学生不理解时,教授会问,“我们昨天整个课都在讲那个,你怎么可能不懂?”我们在课堂上看到的大部分概念,作为理解的大单元,都是有人在多年时间里不断努力解决问题的结果。我们解决的问题足够困难,以至于我们需要得到我们能得到的每一分帮助。如果我们能将问题分解,使得某些部分可以通过现有技术解决,那将是非常解放的。这意味着你不必重新发明解决方案,尤其是不要发明一个糟糕的解决方案。你不必重新发现所有那些显而易见的解决方案并不完全正确的方式。你只需依靠现有的解决方案,专注于你问题中独特的那部分。
有时候,学生们会遇到困难,难以想象他们所学的数据结构和算法问题在实际软件开发中会如何出现。你能告诉我们计算机科学问题在软件工程中实际出现的频率是多少吗?
这让我想起了我毕业后大约 10-15 年回访我的论文导师时的一次对话。他问我两个问题。第一个问题是,“你在工作中是否使用了你在学校学到的数学?”我说,“嗯,说实话,不太经常。”第二个问题是,“但是你使用你在学习数学时学到的思维和分析技能了吗?”我说,“绝对如此,每天都是。”他带着完成工作的自豪微笑。
例如,以红黑树为例。它们是如何工作的?大多数时候,我并不需要关心。如果你需要,每种语言都有优秀的预写、经过良好测试、高性能的库,你可以直接使用。重要的技能不是能够重新创建这个库,而是知道何时能够有利地使用它来解决更大的问题,无论是选择合适的工具,还是考虑它将如何融入你整体解决方案的时间或空间复杂度,等等。这些是你经常使用的技能。当你身处数据结构课程中时,很难透过现象看到本质。你可能会在课堂上花费大量时间研究红黑树的机制,这可能是重要的,但你很可能永远不需要再次做这件事。而且希望你在面试中不会被要求这样做,因为我认为这是一个糟糕的面试问题!但你应该知道树查找的时间复杂度可能是什么,为了达到这种复杂度,键分布的条件应该是什么,等等。这就是现实世界开发者每天都需要应用的那种思维方式。
你能给我们举一个例子,说明你或另一位工程师如何能够将计算机科学的知识应用到解决工程问题中吗?
在我的工作中,这有点好笑,因为理论是我们所做很多事情的重要基础。但理论也未能解决现实世界语言设计中的问题。例如,Java 不是一个纯语言,所以在理论上,从单子(monads)那里学不到什么。但当然,从单子那里可以学到很多东西。所以当我审视一个可能的功能时,我可以依赖很多理论。这给了我直觉,但最后的一公里我必须自己填补。同样,对于类型系统也是如此。大多数类型理论不涉及像抛出异常这样的效果。嗯,Java 有异常。这并不意味着类型理论没有用。在发展 Java 语言的过程中,我可以依赖很多类型理论。但我必须认识到,理论只能带我去那么远,我必须自己铺平最后的一公里。
找到这种平衡是困难的。但这是至关重要的,因为很容易说,“哦,理论对我没有帮助,”然后你就重新发明了轮子。
计算机科学的哪些领域在语言开发中很重要?
类型理论是显而易见的。大多数语言都有类型系统,其中一些甚至有多个。例如,Java 在静态编译时有一个类型系统,在运行时有一个不同的类型系统。甚至还有一个用于验证时间的第三个类型系统。当然,这些类型系统必须是一致的,但它们的精确度和粒度不同。因此,类型理论当然是重要的。有很多关于程序语义的正式工作值得了解,但并不一定在日常语言设计中得到应用。但我不认为任何合理的项目可以不打开类型理论书籍并阅读数十篇论文。
如果有人对最终参与语言设计感兴趣,你推荐他们学习或在其职业生涯中做些什么,以便他们有一天能处于你的位置?
显然,为了参与语言设计,你必须理解语言开发者使用的工具。你必须理解编译器、类型系统以及计算理论的所有细节:有限自动机、上下文无关文法等等。这些都是理解所有这些内容的先决条件。同时,拥有多种不同语言的编程经验也非常重要,特别是不同类型的语言,这样你可以看到它们以不同的方式处理问题,它们做出的不同假设,它们为语言保留的不同工具,以及等等。我认为,你必须对编程有一个相当广阔的视角,才能在语言设计方面取得成功。你还需要有一个“系统思维”的视角。当你向一种语言添加一个特性时,它会改变人们使用该语言的方式,并改变你未来可以走的道路。你必须能够看到特性将被如何使用,以及如何被滥用,新的平衡是否实际上比旧的更好,或者它只是将问题转移到另一个地方。
实际上,我会给所有人提供一些建议——特别是,走出去学习不同类型的编程语言——无论他们是否对编程语言感兴趣。学习多种编程范式会使他们成为更好的程序员;当他们面对问题时,他们会更容易看到多种攻击方式。我特别推荐学习一种函数式语言,因为它会给你一个不同的、有用的视角来构建程序,并让你的大脑(以好的方式)得到锻炼。
你经常看到 Java 程序员犯哪些错误,他们或许可以通过更好地利用语言特性来避免这些错误?
我认为最大的变化可能是不去努力理解泛型的工作原理。泛型中存在一些不那么明显的概念,但并不多,一旦你下定决心,它们并不难理解。泛型是其他特性(如 lambda 表达式)的基础,也是理解许多库的关键。但是,许多开发者将其视为一个“我需要做什么才能让红色波浪线消失?”的练习,而不是作为一种杠杆。
你认为在接下来的 5 到 10 年内,对于在职程序员来说,最大的转变将是什么?
我怀疑它将是传统计算问题解决方法与机器学习的结合。目前,编程和机器学习是完全不同的领域。机器学习的当前技术似乎已经沉睡 40 年了。所有关于神经网络的工作都是在 60 年代和 70 年代完成的。但是,直到现在我们才拥有训练它们的计算能力和数据。现在我们有了这些,突然之间它变得相关了。你看到机器学习被应用于手写识别、语音识别、欺诈检测等所有这些我们以前试图用基于规则的系统或启发式方法解决的问题(效果并不很好)。但是问题是,我们用于机器学习的工具和我们应用于机器学习的思维方式与传统程序设计的完全不同。我认为这将是对未来 20 年程序员的一个重大挑战。他们将如何在这两种不同的工具集中架起这两类不同思维之间的桥梁,以解决越来越需要这两套技能的问题?
你认为在接下来的十年中,编程语言将经历哪些最大的进化变化?
我认为我们现在看到的是一个趋势的大致轮廓,那就是面向对象和函数式语言的融合。二十年前,语言被严格地分为函数式语言、过程式语言和面向对象语言,每种语言都有自己的世界观建模哲学。但是,由于这些模型只模拟了世界的一部分,所以每个模型都有其不足之处。在过去十年左右的时间里,从像 Scala 和 F#这样的语言开始,现在到 C#和 Java 这样的语言,我们看到许多最初在函数式编程中生根发芽的概念正在逐渐融入更广泛的语言范畴,我认为这种趋势只会继续下去。有些人喜欢开玩笑说,所有语言都在趋同于$MY_FAVORITE_LANGUAGE。这个笑话中确实有一些真实性,因为函数式语言正在获得更多数据封装的工具,而面向对象语言正在获得更多函数式组合的工具。这有一个明显的理由,那就是这些工具集都是很有用的。每种语言在解决某一类问题时都表现出色,而我们被要求解决既有面向对象又有函数式特点的问题。因此,我认为在接下来的十年里,我们将看到传统上被认为是面向对象的概念和传统上被认为是函数式概念之间的概念融合将更加紧密。
我认为有很多例子可以说明函数式编程世界对 Java 的影响。你能给我们举几个例子吗?
最明显的一个是 lambda 表达式。你知道,称它们为函数式编程概念并不公平,因为 lambda 演算在计算机出现之前就已经存在了几十年。它是描述和组合行为的一个自然模型。它在 Java 或 C#这样的语言中与在 Haskell 或 ML 这样的语言中一样合理。所以这显然是一个。另一个类似的概念是模式匹配,大多数人会将它与函数式语言联系起来,因为那可能是他们第一次看到它的地方,但实际上,模式匹配可以追溯到 20 世纪 70 年代的 SNOBOL 语言,它是一种文本处理语言。模式匹配在面向对象模型中非常干净利落。它不是一个纯粹的函数式概念。只是恰好函数式语言在我们之前注意到它是有用的。我们与函数式语言相关联的许多概念在面向对象语言中同样适用。
Java 在许多衡量标准上都是世界上最受欢迎的编程语言之一。你认为是什么让它如此成功,你认为它为什么会在未来继续成功?
与任何成功一样,都有一点运气,我认为你应该始终承认运气在你成功中所起的作用,因为否则就不是诚实的。我认为在许多方面,Java 正好赶上了合适的时间。当时,世界正处于决定是否从 C 跳跃到 C++ 的边缘。C 是当时的主导语言,无论是好是坏,而 C++ 一方面提供了比 C 更好的抽象能力,另一方面又具有难以置信的复杂性。所以你可以想象,世界正站在悬崖上,说:“我们真的想跳这个吗?”然后 Java 出现了,说:“我可以给你提供 C++ 承诺的大部分东西,但复杂性却少得多。”每个人都说了:“是的,请,我们想要那个!”这是正确的时间做正确的事情。它采纳了许多在计算机世界中流传多年的旧想法,包括垃圾回收和将并发构建到编程模型中,这在之前的严肃商业语言中尚未使用过。所有这些都与人们在 90 年代解决的问题相关。
James Gosling 有一个关于 Java 的引用,他把 Java 形容为“披着羊皮的狼”。人们需要垃圾回收,需要比 pthreads 更好的集成并发模型,但他们不想使用那些传统上带有这些功能的语言,因为这些语言带来了各种让他们感到恐惧的东西。另一方面,Java 看起来像 C。事实上,他们特意让语法看起来像 C。它很熟悉,然后他们可以偷偷地加入一些酷炫的东西,你只有在很久以后才会注意到。Java 的创造者做的一件事是,在设计整个语言运行时,他们预期即时编译即将到来,但还没有完全实现。1995 年的 Java 第一个版本是严格解释的。它很慢,但关于语言、类文件格式和运行时结构的每一个设计决策都是基于如何使其快速的知识。最终它变得足够快,在某些情况下,甚至比 C 还快(尽管有些人仍然不相信这是可能的)。所以,这有一些恰到好处的运气,以及对技术发展方向和人们真正需要的许多精彩洞察,这些都让 Java 起步。但那只是开始时发生的事情——为了保持 Java 的领先地位,面对渴望吃掉 Java 早餐的竞争对手,我们需要更多。我认为,即使在讨论的黑暗时期,我们也能坚持下去的原因是对兼容性的不懈承诺。
进行不兼容的更改就是打破你的承诺。它使你的客户在代码上的投资失效。每次你打破某人的代码时,你几乎是在给他们一个机会去用其他语言重写它,而 Java 从未这样做过。你 5 年前、10 年前、15 年前、20 年前、25 年前编写的 Java 代码仍然有效。这意味着我们发展得稍微慢一些。但这意味着你在代码以及你对语言工作方式的理解上所做的投资得到了保留。我们不打破我们的承诺,也不以这种方式伤害我们的用户。挑战是如何在前进的同时保持对兼容性的这种承诺。我认为这是我们秘密武器。我们在过去 25 年中找到了如何做到这一点的方法,并且我们在这方面做得相当不错。这就是我们能够添加泛型、lambda 表达式、模块、模式匹配以及其他可能对 Java 来说显得陌生的东西,而不会使它们看起来像是附加的——因为我们找到了如何做到这一点的方法。
Go 因其集成的并发模型而获得了许多赞誉,但 Java 在 1995 年就已经在语言中内置了同步原语、关键字和线程模型。你认为为什么它没有因此获得更多的赞誉呢?
我认为部分原因在于,很多真正的巧妙之处都隐藏在水线下,人们看不到。当某件事只是正常工作时,它往往不会得到应有的赞誉。所以这可能就是其中的一部分。我不太喜欢 Go,有几个原因。每个人都认为并发模型是 Go 的秘密武器,但我认为他们的并发模型实际上非常容易出错。也就是说,在通道的一侧或另一侧的东西都将有一些共享的可变状态,这些状态被锁所保护。这意味着你将消息传递中可能犯的错误和共享状态并发中可能犯的错误结合起来,再加上 Go 的共享状态并发原语在 Java 中明显较弱的事实。(例如,他们的锁不是可重入的,这意味着你不能组合任何使用锁的行为。这意味着你通常不得不写两个版本的同一样东西——一个是在持有锁的情况下调用,另一个是在不持有锁的情况下调用。)我认为人们会发现,Go 的并发模型,就像响应式编程一样,将是一种过渡技术,它在一段时间内看起来很有吸引力,但更好的东西将会出现,我认为人们会很快放弃它。(当然,这可能是我的偏见在作祟。)
作为 Java 语言架构师,你日常工作中的工作内容是什么样的?
实际上,事情遍布各个方向。在任何一个给定的一天,我可能会进行关于语言演变和遥远特性如何连接的纯研究。我可能会原型化某个实现的实施,看看各个部分是如何配合的。我可能会为团队撰写一个方向声明:“这是我认为我们在解决这个问题的过程中所处的位置,这是我认为我们已经弄清楚的事情,这是剩下的问题。”我可能会在会议上发言,与用户交谈,试图了解他们的痛点,并在一定程度上,推广我们未来走向的信息。任何一个给定的一天都可能是那些事情中的任何一个。其中一些事情非常贴近当下,一些是前瞻性的,一些是回顾性的,一些是面向社区的,一些是面向内部的。每一天都是不同的!
目前,我参与的一个项目,我们已经投入了几年时间,是对通用类型系统进行升级,以支持原语和类似原语的聚合。这是涉及到语言、编译器、翻译策略、类文件格式和 JVM 的某个方面。为了能够有信心地说我们在这里有一个故事,所有这些部分都必须对齐。所以,在任何一个给定的一天,我可能会在两件事的交叉点上工作,看看故事是否正确对齐。这是一个可能需要数年才能完成的过程!
对于那些试图提升技能集的自学者、学生,或者经验丰富的开发者,他们正在回顾材料以提升他们的计算机科学技能,你有什么建议?
理解一项技术的最有价值的方法之一是将它置于历史背景中。问自己,“这项技术与之前解决同样问题的技术有何关联?”因为大多数开发者并不总是能选择他们用来解决问题的技术。如果你是 2000 年的开发者,你接受了一份工作,他们会对你说,“我们使用这个数据库,我们使用这个应用容器,我们使用这个 IDE,我们使用这种语言。现在去编程。”所有这些选择都是为你预先做好的,你可能会被它们如何相互配合的复杂性所淹没。但你所使用的每一个组件都存在于一个历史背景中,它是某人为了解决我们昨天以不同方式解决的问题而提出的更好想法的产物。通常,通过了解之前技术迭代中哪些没有工作,是什么让某人说出“我们不要那样做。让我们这样做。”,你可以更好地理解特定技术的工作原理。因为计算机的历史非常紧凑,大部分材料仍然可用,你可以回过头去阅读关于 1.0 版本发布时写下的内容。设计师会告诉你他们为什么发明它,以及他们无法用昨天的技术解决的问题让他们感到沮丧。这种技术对于理解它的用途以及你将要遇到的局限性非常有用。
你可以在 Twitter 上关注 Brian @BrianGoetz。
附录 A 术语表
本附录定义了本书中的一些关键术语。
激活函数 一种将 人工神经网络 中 神经元 的输出进行转换的函数,通常是为了使其能够处理非线性转换,或者确保其输出值在某个范围内(第七章)。
无环的 没有环的 图(第四章)。
可接受启发式 A* 搜索算法的 启发式,它从不高估达到目标成本(第二章)。
人工神经网络 使用计算工具模拟生物 神经网络 以解决难以用传统算法方法简化的问题的模拟。请注意,人工神经网络 的操作通常与其生物对应物有显著差异(第七章)。
自动记忆化 在语言级别实现的 记忆化 版本,其中存储了没有副作用的功能调用的结果,以便在进一步的相同调用中进行查找(第一章)。
反向传播 一种用于根据一组具有已知正确输出的输入来训练 神经网络 权重的技术。使用偏导数来计算每个权重对实际结果和预期结果之间误差的“责任”。这些 delta 用于更新未来运行的权重(第七章)。
回溯法 在搜索问题中遇到障碍后,返回到较早的决策点(选择不同于上次追求的方向)(第三章)。
位串 一种数据结构,使用单个位存储一系列 1 和 0。这有时被称为 位向量 或 位数组(第一章)。
质心 在 簇 中的中心点。通常,该点的每个维度是该维度中其余点的平均值(第六章)。
染色体 在遗传算法中,种群 中的每个个体被称为 染色体(第五章)。
簇 见 聚类(第六章)。
聚类 一种 无监督学习 技术,将数据集划分为相关点的组,称为 簇(第六章)。
密码子 由三个 核苷酸 组成的组合,形成一种氨基酸(第二章)。
压缩 将数据(改变其形式)编码以占用更少的空间(第一章)。
连通的 一个图属性,表示从任何 顶点 到任何其他 顶点 都存在 路径(第四章)。
约束 为了解决约束满足问题,必须满足的要求(第三章)。
交叉 在遗传算法中,将 种群 中的个体结合以创建后代,这些后代是父母双方的混合体,并将成为下一 代 的一部分(第五章)。
CSV 一种文本交换格式,其中数据集的行值由逗号分隔,行本身通常由换行符分隔。CSV 代表 comma-separated values。CSV 是电子表格和数据库的常见导出格式(第七章)。
循环 在 graph 中访问相同 vertex 两次而不 backtracking 的 path(第四章)。
解压缩 反向过程 compression,将数据恢复到原始形式(第一章)。
深度学习 有点像流行语,深度学习可以指任何使用高级机器学习算法分析大数据的几种技术之一。最常见的是,深度学习指的是使用多层 artificial neural networks 来解决使用大数据集的问题(第七章)。
delta 代表一个 neural network 中权重预期值与其实际值之间差距的值。预期值是通过使用 training 数据和 backpropagation 确定的(第七章)。
digraph 看见 directed graph(第四章)。
有向图 也称为 digraph,有向图是一种 graph,其中 edges 只能单向遍历(第四章)。
范围 在约束满足问题中 variable 的可能值(第三章)。
动态规划 不是直接使用暴力方法一次性解决大问题,在动态规划中,问题被分解成更小的子问题,每个子问题更容易管理(第九章)。
边 在 graph 中两个 vertices(节点)之间的连接(第四章)。
独异或 看见 XOR(第一章)。
前馈 一种 neural network 类型,其中信号单向传播(第七章)。
适应度函数 评估问题潜在解决方案有效性的函数(第五章)。
生成 在遗传算法评估中的一轮;也用来指代一轮中活跃的 population(第五章)。
遗传编程 使用 selection、crossover 和 mutation 操作员修改自身以找到解决编程问题的程序,这些问题是非显而易见的(第五章)。
梯度下降 使用在 backpropagation 期间计算的 deltas 和 learning rate 修改 artificial neural network 权重的算法(第七章)。
图 一种抽象的数学结构,通过将问题划分为一组 connected 节点来模拟现实世界问题。节点被称为 vertices,连接被称为 edges(第四章)。
贪婪算法 一种算法,在任意决策点总是选择最佳即时选择,希望它能导致全局最优解(第四章)。
启发式 关于解决问题方式的直觉,指向正确的方向(第二章)。
隐藏层 在 feed-forward artificial neural network 中位于 input layer 和 output layer 之间的任何层(第七章)。
infinite loop 一个不会终止的循环(第一章)。
infinite recursion 一组递归调用,不会终止,而是继续进行额外的递归调用。类似于无限循环。通常是由于缺少基例(第一章)。
input layer 在前馈人工神经网络中的第一层,从某种外部实体接收其输入(第七章)。
learning rate 一个值,通常是一个常数,用于根据计算的delta调整在人工神经网络中修改权重的速率(第七章)。
memoization 一种技术,将计算任务的输出结果存储在内存中,以便稍后从内存中检索,从而节省重新创建相同结果所需的额外计算时间(第一章)。
minimum spanning tree 连接所有顶点的生成树,使用边的最小总权重(第四章)。
mutate 在遗传算法中,在个体被包含在下一代之前随机改变其某些属性(第五章)。
natural selection 通过适应良好的生物成功而适应不良的生物失败来实现的进化过程。在环境中有限的资源下,最适合利用这些资源的生物将生存并繁衍。在几代之后,这导致有助于生存的有益特征在种群中传播,因此被环境约束自然选择(第五章)。
neural network 由多个神经元组成的网络,它们协同处理信息。神经元通常被认为是组织在层中(第七章)。
neuron 一个单独的神经细胞,如人类大脑中的那些(第七章),或人工神经网络中最小的计算单元。
normalization 使不同类型的数据可比较的过程(第六章)。
NP-hard 属于一类问题,对于这类问题,没有已知的多项式时间算法可以解决(第九章)。
nucleotide DNA 四种碱基之一的一个实例:腺嘌呤(A)、胞嘧啶(C)、鸟嘌呤(G)和胸腺嘧啶(T)(第二章)。
output layer 在前馈人工神经网络中的最后一层,用于确定给定输入和问题的网络结果(第七章)。
path 在图中连接两个顶点的边集合(第四章)。
ply 在两人游戏中的一步(通常被认为是一次移动)(第八章)。
population 在遗传算法中,种群是代表问题潜在解决方案的个体的集合(每个个体都代表问题的一个潜在解决方案),它们竞争解决问题(第五章)。
priority queue 一种基于“优先级”排序的数据结构,根据优先级弹出项目。例如,可以使用优先队列与紧急呼叫集合一起使用,以便首先响应优先级最高的呼叫(第二章)。
队列 一种强制执行 FIFO(先进先出)顺序的抽象数据结构。队列实现至少提供 push 和 pop 操作,分别用于添加和删除元素(第二章)。
递归函数 一种调用自身的函数(第一章)。
选择 在遗传算法的 一代 中选择个体进行繁殖以创建下一代个体的过程(第五章)。
sigmoid 函数 在人工神经网络中常用的一组 激活函数 之一。同名的 sigmoid 函数总是返回介于 0 和 1 之间的值。它还有助于确保网络可以表示除了线性变换之外的结果(第七章)。
SIMD 指令 优化用于使用向量进行计算的微处理器指令,也有时称为向量指令。SIMD 代表 单指令,多数据(第七章)。
跨度树 连接图中每个 顶点 的 树(第四章)。
栈 一种强制执行 LIFO(后进先出)顺序的抽象数据结构。栈实现至少提供 push 和 pop 操作,分别用于添加和删除元素(第二章)。
监督学习 任何一种机器学习技术,其中算法通过使用外部资源以某种方式引导到正确的结果(第七章)。
突触 神经元之间的间隙,神经递质在此释放以允许电流传导。用通俗的话说,这些是神经元之间的连接(第七章)。
训练 一个阶段,其中 人工神经网络 通过使用已知正确输出的 反向传播 来调整其权重(第七章)。
树 任何两个顶点之间只有一条 路径 的 图。树是 无环的(第四章)。
无监督学习 任何一种不使用先验知识得出结论的机器学习技术——换句话说,一种不是受指导而是自行运行的技术(第六章)。
变量 在约束满足问题的上下文中,变量是作为问题解决方案的一部分必须解决的某些参数。变量的可能值是其 域。解决方案的要求是一或多个 约束(第三章)。
顶点 图中的一个单个节点(第四章)。
XOR 一种逻辑位运算,当其任一操作数为真时返回真,但两个操作数都为真或都为假时不返回真。该缩写代表 排他性 或。在 Java 中,^ 运算符用于 XOR(第一章)。
z 分数 数据点与数据集平均值的标准差数(第六章)。
附录 B 更多资源
你接下来应该去哪里?这本书涵盖了广泛的主题,这个附录将为你提供一些资源,帮助你进一步探索它们。
Java
如引言所述,《Java 中的经典计算机科学问题》 假定你至少具备 Java 语言的中级知识。Java 在过去几年中发展变化很大。这里有一个标题可以帮助你了解 Java 语言的最新发展,并帮助你将中级 Java 技能提升到下一个水平:
-
Raoul-Gabriel Urma, Mario Fusco, Alan Mycroft, 《现代 Java 实战》 (Manning, 2018), www.manning.com/books/modern-java-in-action.
-
涵盖了 Java 中的 lambda 表达式、流和现代函数式机制
-
示例使用 Java 的最新长期支持版本,Java 11
-
涵盖了广泛的现代 Java 主题,这将帮助许多在 Java 8 之前学习 Java 的开发者
-
数据结构和算法
引用本书的引言:“这不是一本数据结构和算法教科书。”本书中几乎不使用大 O 符号,也没有数学证明。这更像是一个关于重要计算问题解决技术的动手教程,拥有一本真正的教科书也是有价值的。这不仅会为你提供关于某些技术为何有效更正式的解释,而且还将作为有用的参考。在线资源很棒,但有时拥有经过学者和出版商仔细审查的信息也是好的。
-
Thomas Cormen, Charles Leiserson, Ronald Rivest 和 Clifford Stein, 《算法导论》,第 3 版 (MIT Press, 2009),
mitpress.mit.edu/books/introduction-algorithms-third-edition.-
这是计算机科学中最常引用的文本之一——如此确定,以至于它通常只以其作者的首字母缩写来引用:CLRS。
-
覆盖全面且严谨。
-
它的教学风格有时被认为不如其他文本易于接近,但它仍然是一本优秀的参考书。
-
大多数算法都提供了伪代码。
-
正在开发第四版,由于这本书价格昂贵,当第四版即将发布时,可能值得一看。
-
-
Robert Sedgewick 和 Kevin Wayne, 《算法》,第 4 版 (Addison-Wesley Professional, 2011),
algs4.cs.princeton.edu/home/.-
算法和数据结构的易于接近且全面的介绍
-
组织结构良好,包含所有算法的完整 Java 示例
-
在大学算法课程中很受欢迎
-
-
Steven Skiena, 《算法设计手册》,第 2 版 (Springer, 2011), www.algorist .com.
-
在其方法上与其他学科教科书不同
-
提供较少的代码,但更多关于每个算法适当使用的描述性讨论
-
提供类似“选择你的冒险”的指南,涵盖广泛的算法
-
-
阿迪亚·巴加瓦,掌握算法(Manning,2016),www.manning.com/books/grokking-algorithms.
-
一种图形化的教学方法,用于教授基本算法,并配有可爱的卡通
-
不仅仅是一本参考教科书,而是一本学习一些基本选定主题的入门指南
-
非常直观的类比和易于理解的文字
-
示例代码使用 Python 编写
-
人工智能
人工智能正在改变我们的世界。在这本书中,你不仅介绍了传统的搜索技术,如 A*和 minimax,还介绍了其令人兴奋的子领域机器学习的技术,如 k-means 和神经网络。学习更多关于人工智能不仅有趣,而且将确保你为下一波计算做好准备。
-
斯图尔特·罗素和彼得·诺维格,人工智能:现代方法,第 3 版(Pearson,2009),
aima.cs.berkeley.edu.-
人工智能的权威教科书,常用于大学课程
-
涵盖范围广泛
-
在线提供优秀的源代码存储库(书中伪代码的实现版本)
-
-
斯蒂芬·卢奇和丹尼·科佩克,21 世纪人工智能,第 2 版(Mercury Learning and Information,2015),
mng.bz/1N46.-
对于那些寻找比罗素和诺维格更接地气、更生动的指南的人来说,这是一本易于接近的文本
-
关于实践者的有趣片段以及许多对现实应用的参考
-
-
安德鲁·吴,斯坦福大学“机器学习”课程,www.coursera.org/learn/machine-learning/.
-
一门免费的在线课程,涵盖了机器学习中的许多基本算法
-
由世界知名专家授课
-
经常被从业者视为该领域的绝佳起点
-
函数式编程
Java 可以以函数式风格编程,但它并不是真正为此而设计的。在 Java 本身深入函数式编程是可能的,但工作在纯函数式语言中并从该经验中带回一些想法也可能有所帮助。
-
哈罗德·阿贝尔森和杰拉尔德·杰伊·苏斯曼与朱莉·苏斯曼合著,计算机程序的结构与解释(MIT 出版社,1996),
mitpress.mit.edu/sicp/.-
经常在计算机科学入门课程中使用的经典函数式编程介绍
-
使用 Scheme 语言教学,这是一种易于掌握的纯函数式语言
-
可免费在线获取
-
-
米哈伊尔·普拉赫塔,掌握函数式编程(Manning,2021),www.manning.com/books/grokking-functional-programming.
- 函数式编程的图形化友好介绍
-
皮埃尔-伊夫·索蒙,《Java 函数式编程》(Manning,2017),www.manning.com/books/functional-programming-in-java.
-
介绍了 Java 标准库中一些函数式编程工具的基本知识
-
展示了如何以函数式方式使用 Java
-



浙公网安备 33010602011771号