Java-数据结构与算法入门指南-全-
Java 数据结构与算法入门指南(全)
原文:
zh.annas-archive.org/md5/cbbea3eb3f57a42a2702b69d32abe2a6译者:飞龙
前言
数据结构是一种组织数据的方式,以便可以高效地访问和/或修改。了解数据结构和算法可以帮助你更好地洞察如何解决常见的编程问题。程序员每天面临的许多问题都已经得到解决、尝试和测试。了解这些解决方案的工作原理,确保在面临这些问题时选择正确的工具。
本书教你使用工具来构建高效的应用程序。它从算法和大数据符号的介绍开始,随后解释了冒泡排序、归并排序、快速排序和其他流行的编程模式。你还将了解二叉树、哈希表和图等数据结构。本书逐步深入到高级概念,如算法设计范式和图论。到本书结束时,你将知道如何在应用程序中正确实现常见的算法和数据结构。
本书面向对象
如果你希望通过 Java 代码示例更好地理解常见的数据结构和算法,并提高你的应用程序效率,那么这本书适合你。具备基本的 Java、数学和面向对象编程技术知识会有所帮助。
本书涵盖内容
第一章,算法与复杂度,涵盖了如何定义算法、衡量算法复杂度以及识别不同复杂度的算法。它还涵盖了如何评估具有不同运行时间复杂度的各种示例。
第二章,排序算法和基本数据结构,探讨了冒泡排序、快速排序和归并排序。我们还将介绍数据结构,并研究链表、队列和栈的各种实现和使用案例。我们还将看到一些数据结构如何用作构建更复杂结构的基石。
第三章,哈希表和二叉搜索树,讨论了实现数据字典操作的数据结构。此外,二叉树还使我们能够执行各种范围查询。我们还将看到这两种数据结构的示例,以及这些操作的实现。
第四章,算法设计范式,讨论了三种不同的算法设计范式以及示例问题,并讨论了如何识别问题是否可能由给定的范式之一解决。
第五章,字符串匹配算法,介绍了字符串匹配问题。本章还介绍了字符串匹配算法,从简单的搜索算法开始,并使用 Boyer 和 Moore 提出的规则进行改进。我们还将探索一些其他字符串匹配算法,而不会过多地深入细节。
第六章,图、素数和复杂度类,介绍了图,形式化它们是什么,并展示了在计算机程序中代表它们的两种不同方式。稍后,我们将探讨遍历图的方法,使用它们作为构建更复杂算法的构建块。我们还将探讨在图中找到最短路径的两种不同算法。
为了充分利用本书
为了成功完成本书,您需要一个至少配备 i3 处理器的计算机系统,4 GB RAM,10 GB 硬盘和互联网连接。此外,您还需要以下软件:
-
Java SE 开发工具包,JDK 8(或更高版本)
-
Git 客户端
-
Java IDE(IntelliJ 或 Eclipse)
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并按照屏幕上的说明操作。
文件下载后,请确保使用最新版本的软件解压缩或提取文件夹:
-
Windows 的 WinRAR/7-Zip
-
Mac 的 Zipeg/iZip/UnRarX
-
Linux 的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/TrainingByPackt/Data-Structures-and-Algorithms-in-Java。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有来自我们丰富图书和视频目录的其他代码包,可在github.com/PacktPublishing/找到。查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/BeginningJavaDataStructuresandAlgorithms_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“这是merge()函数的任务,该函数位于前一个部分中显示的伪代码的末尾。”
代码块设置为以下格式:
quickSort(array, start, end)
if(start < end)
p = partition(array, start, end)
quickSort(array, start, p - 1)
quickSort(array, p + 1, end)
任何命令行输入或输出都按以下方式编写:
gradlew test --tests com.packt.datastructuresandalg.lesson2.activity.selectionsort*
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。这里有一个例子:“选择动态 Web 项目,然后点击下一步以打开动态 Web 项目向导。”
活动: 这些是基于场景的活动,将让您在实际应用中应用您在整章中学到的知识。它们通常是在现实世界问题或情境的背景下。
警告或重要注意事项显示如下。
联系我们
我们欢迎读者的反馈。
一般反馈: 请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为什么不在您购买它的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问packtpub.com。
第一章:算法和复杂度
算法是一组执行特定任务的逻辑指令。如今,算法无处不在。作为一名软件开发者,理解算法和数据结构的核心原则将使你能够在如何解决特定问题方面做出明智的决定。这无论你是在银行编写会计软件还是在进行医学研究数据挖掘、遗传编码都是适用的。当存在多个问题的解决方案时,我们如何确定使用哪种算法?在本章中,我们将探讨不同类型的算法,并讨论每种算法的性能差异。我们将讨论什么使一个算法比另一个更高效,以及如何表达每个算法的复杂性。
算法的常见例子包括调节街道拥堵的交通信号灯、智能手机上的面部识别软件、推荐技术等等。
你需要理解的是,算法只是用于解决定义良好问题的应用程序的一个小部分。例如,对数字列表进行排序、找到最短路径或单词预测都是正确的。大型软件应用程序,如电子邮件客户端或操作系统,是不恰当的例子。
到本章结束时,你将能够:
-
用示例定义一个算法
-
测量算法复杂度
-
识别具有不同复杂度的算法
-
评估具有不同运行时复杂度的各种示例
开发我们的第一个算法
算法可以被视为一个路线图或一组完成定义良好任务的指令。在本节中,我们将构建一个这样的算法的简单示例,以帮助我们开始。
将二进制数转换为十进制的算法
数制有不同的基数。十进制数是我们大多数人熟悉的。然而,计算机只使用一和零(二进制)。让我们尝试编写一些将二进制数转换为十进制的代码。
具体来说,我们想要开发一个算法,该算法接受包含一和零的字符串,并返回一个整数。
我们可以通过以下步骤转换二进制字符串:
-
从字符串的末尾开始,一次处理一个字符。二进制字符串中每个数字的位置对应于序列中的一个十进制数。
-
要生成这个序列,你从一开始,每次乘以二,所以一、二、四、八,以此类推(参见表 1.1的转换序列行)。更正式地说,这个序列是一个以一为起点,以二为公比的几何级数。
-
然后,我们将二进制字符串作为掩码应用于这个序列(参见表 1.1的二进制字符串(掩码)行)。
-
结果是一个新序列,其中只保留二进制字符串中相应位置值为一的值(参见表 1.1的结果行)。
-
应用掩码后,我们只需将得到的数字相加。
| 转换序列 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|
| 二进制字符串(掩码) | 1 | 0 | 1 | 1 | 0 |
| 结果 | 16 | 0 | 4 | 2 | 0 |
表 1.1:二进制到十进制的掩码
在前面的例子(表 1.1)中,得到的总数是22。这是我们对应于二进制数 10110 的十进制数。
为了设计我们的算法,重要的是要意识到我们不需要存储整个转换序列。由于我们一次处理一个二进制位(从后向前),我们只需要使用我们正在处理的二进制位置的转换数字。
片段 1.1 展示了我们可以如何做到这一点。我们使用一个单一的转换变量而不是一个序列,并将此变量初始化为 1。然后我们使用一个循环从二进制字符串的末尾开始迭代其长度。在迭代过程中,如果当前位置的数字是 1,我们将当前的转换变量添加到最终结果中。然后我们简单地双倍当前的转换变量并重复。代码片段如下:
public int convertToDecimal(String binary) {
int conversion = 1;
int result = 0;
for (int i = 1; i <= binary.length(); i++) {
if (binary.charAt(binary.length() - i) == '1')
result += conversion;
conversion *= 2;
}
return result;
}
片段 1.1:二进制到十进制。源类名:BinaryToDecimal。
前往 goo.gl/rETLfq 访问代码。
活动:编写将八进制数转换为十进制数的算法
场景
在航空领域,飞机的应答器会发送一个代码,以便它们可以相互识别。这个代码使用八进制系统,这是一种基数为 8 的数制。我们被要求编写一个将八进制数字转换为十进制的方法。例如,八进制数字 17 在十进制系统中表示为 15。
目标
能够将上一节中显示的算法修改为用于不同的场景。
先决条件
- 确保您在以下路径上有可用的类:
- 您将找到以下需要实现的方法:
public int convertToDecimal (String octal)
- 如果您已经设置了项目,您可以通过运行以下命令来运行此活动的单元测试:
gradlew test --tests com.packt.datastructuresandalg.
lesson1.activity.octaltodecimal*
完成步骤
-
在前面的片段 1.1 中显示的算法可以修改为与八进制数而不是二进制数一起工作。
-
将基数从二改为八。这可以通过更改片段 1.1 中的转换乘数变量来实现。
-
将正在处理的数字解析为整数。然后,这个整数可以乘以转换变量或幂函数的结果。
在本节中,我们通过一个简单的示例介绍了算法的概念。需要注意的是,对于每个问题,都存在多种解决方案。选择正确的算法来解决你的问题将取决于几个指标,例如性能和内存需求。
使用大 O 符号衡量算法复杂度
算法复杂度是一种描述算法效率的方式,它是其输入的关系。它可以用来描述我们代码的各种属性,如运行速度或内存需求。它也是程序员应该理解以编写高效软件的重要工具。在本节中,我们将首先描述一个场景,介绍本节,然后深入探讨各种复杂类型及其不同测量技术的细节。
复杂度示例
假设我们被分配了一个编写用于空中交通控制的软件的任务。具体来说,我们被要求编写一个算法,在预定义的空间和高度内,如果任何两架飞机彼此过于接近,则发出警报。
在我们的实现中,我们通过计算我们空域中每一对之间的所有可能距离,并仅保留最小距离来解决该问题。如果这个最小距离小于某个特定阈值,我们的软件将发出警报。以下代码片段展示了这个解决方案:
public double minimumDistance(List<Point> allPlanes) {
double minDistance = Double.MAX_VALUE;
for (Point p1 : allPlanes) {
for (Point p2 : allPlanes) {
double d = p1.distanceTo(p2);
if (d != 0 && d < minDistance) minDistance = d;
}
}
return minDistance;
}
代码片段 1.2:最小距离。源类名:ClosestPlane 和 Point。
注意,前一段代码中的Point类没有显示。请访问goo.gl/iDHD5J以获取代码。
我们的小算法在几年内运行良好,控制器们对这种有用的警报功能感到满意。然而,随着时间的推移,空中交通量以很快的速度增长,我们不再需要监控几百架飞机,我们的算法必须处理成千上万的点。在繁忙时段,软件难以跟上增加的负载。
我们被召集来调查,并开始编写一些基准测试来测试算法的性能。我们获得了表 1.2中所示的计时结果。正如你所见,我们在每次运行中都加倍了负载;然而,我们的算法并没有以同样的方式扩展。我们的算法没有以与输入相同的速率减慢。
直观地讲,你可能期望如果你加倍飞机的数量,算法的工作量也将加倍,因此它应该需要两倍的时间。然而,情况并非如此。
当我们将飞机数量加倍时,所需的时间不仅仅是加倍,而是急剧上升。
例如,当我们的算法处理 16,000 架飞机时,它需要 2.6 秒(2,647 毫秒)来完成。然而,如果我们把飞机的数量加倍到 32,000,所需的时间增加到 10.4 秒(10,488 毫秒),增加了四倍!
| 飞机数量 | 耗时(毫秒) |
|---|---|
| 1000 | 27 |
| 2000 | 48 |
| 4000 | 190 |
| 8000 | 664 |
| 16000 | 2647 |
| 32000 | 10488 |
在下面的图表中,我们以图表的形式展示了基准测试结果。这里发生了什么?由于嵌套循环,我们的算法做了大量工作。对于输入中的每一个平面点,它都会计算到其他每个平面的距离。这导致了 n² 次计算,其中 n 是我们监控的平面数量。我们可以说,我们的算法具有 O(n²) 的运行性能,读作 大 O 的平方
。或者,我们也可以称之为二次运行性能。看看这个图表:

图 1.1:算法基准测试结果图
列在 片段 1.2 中的算法是解决最近对问题的一个慢速解决方案。存在一个更高效的解决方案,它涉及分而治之的技术。
本书第二部分详细探讨了这类算法,在 第四章,算法设计范式 中,我们提出了一个解决最近对问题的更快解决方案。
增加代码的输入负载并不总是意味着资源消耗会以直接成比例的方式增加。本节讨论的是问题的输入大小与资源使用(CPU 时间、内存等)之间的关系。
在下一节中,我们将看到不同类型的问题、输入大小和资源使用之间的关系。
理解复杂性
为了更好地理解算法复杂度,我们可以使用一个类比。想象一下,我们将不同类型的算法设置在赛道上相互竞争,但是有一个小小的转折:赛道上没有终点线。
由于比赛是无限的,比赛的目标是在一段时间内超越其他较慢的对手,而不是首先完成。在这个类比中,赛道距离是我们的算法输入。经过一段时间后,我们从起点出发的距离代表了我们的代码完成的工作量。
回想一下上一节中测量最近一对平面的二次方法。在我们的虚构比赛中,二次算法开始时相当快,能够在开始减速之前移动相当的距离,类似于一个开始感到疲劳并减速的跑步者。它离起跑线越远,速度越慢,尽管它从未停止移动。
不仅算法在比赛中以不同的速度前进,它们的移动方式也各不相同。我们已经提到,O(n²) 解决方案在比赛中会减速。这与其他算法相比如何?
在我们想象的比赛中,还有另一种类型的参赛者,那就是线性算法。线性算法用 O(n) 的符号来描述。它们在赛道上的速度是恒定的。把它们想象成一辆老而可靠的汽车,以固定的速度行驶。
在现实生活中,具有 O(n) 运行时间复杂度的解决方案,其运行性能与输入大小成正比。
这意味着,例如,如果你将线性算法的输入规模加倍,该算法完成所需的时间也会大约加倍。
每个算法的效率总是从长远来看进行评估的。给定足够大的输入,线性算法总是会比二次算法表现得更好。
我们可以比 O(n) 快得多。想象一下,我们的算法在轨道上不断加速,而不是持续移动。这与二次运行时间相反。给定足够的距离,这些解决方案可以变得非常快。我们说这类算法具有对数复杂度,表示为 O(log n)。
在现实生活中,这意味着算法在输入规模增加时不会慢很多。同样,如果算法在开始时对于小输入比线性算法慢,那也没有关系,因为对于足够大的输入,对数解决方案总是会优于线性解决方案。
我们能更快吗?结果证明,还有一种算法的复杂度类别表现得更好。
想象一下,在我们的比赛中有一个跑者,他有能力在恒定的时间内将任何位置传送到我们无限长的轨道上。即使传感能够花费很长时间,只要它是恒定的,并且不依赖于行进距离,这种类型的跑者总是会击败其他任何跑者。无论传感能够花费多长时间,只要给定足够的距离,算法总是会第一个到达那里。这就是所谓的常数运行时间复杂度,表示为 O(1)。属于这个复杂度类别的解决方案将具有与输入大小无关的运行时间。
在另一端,我们可以找到比二次算法慢得多的算法。例如,立方复杂度 O(n³) 或四次复杂度 O(n⁴)。到目前为止提到的所有这些复杂度都被认为是多项式复杂度。
多项式是一个用于表达式的数学术语。例如,3x⁵ + 2x³ + 6、2x – 3,甚至仅仅是 5,都是很好的例子。关键在于多项式的复杂度形式为 O(n^k),其中 k 是一个正的、非分数的常数。
并非所有解决方案都具有多项式时间行为。一类特定的算法在输入规模与运行时间性能成比例增长时表现非常糟糕,其运行时间为 O(k^n)。在这个类别中,效率随着输入规模的增加呈指数级下降。所有其他类型的多项式算法将很快超越任何指数算法。图 1.2 展示了这种类型的行为与之前提到的多项式算法的比较。
下面的图表也显示了指数算法随着输入规模的增加而退化的速度:

图 1.2:不同算法的运算次数与输入大小的关系
对数算法相对于二次算法快多少?让我们尝试选择一个特定的例子。一个特定的算法大约执行两次操作来解决一个问题;然而,它与输入的关系是 O(n²)。
假设每个操作都是一个慢操作(例如文件访问),并且具有大约 0.25 毫秒的恒定时间,执行这些操作所需的时间将如 表 1.3 所示。我们通过 时间 = 0.25 * 操作数 * n² 来计算时间,其中操作数是执行的操作次数(在这个例子中等于 2),n 是输入大小,0.25 是每次操作的时间:
| 输入大小 (n) | 时间:2 次操作 O(n²) | 时间:400 次操作 O(log n) |
|---|---|---|
| 10 | 50 毫秒 | 100 毫秒 |
| 100 | 5 秒 | 200 毫秒 |
| 1000 | 8.3 分钟 | 300 毫秒 |
| 10000 | 13.8 小时 | 400 毫秒 |
表 1.3:运行速度有多快?
我们的对数算法大约执行 400 次操作;然而,它与输入大小的关系是对数级的。尽管这个算法在较小的输入上较慢,但它很快就会超过二次算法。你可以注意到,对于足够大的输入,性能差异是巨大的。在这种情况下,我们使用 时间 = 0.25 * 操作数 * log n 来计算时间,其中 操作数 = 400。
活动内容:使用指数算法开发时间表
场景
我们被要求使用输入大小为 2、10、30 和 50 的指数算法开发一个时间表。假设操作时间为 0.5 毫秒,并且算法只执行一个操作。
目标
为了发现指数算法的规模如何变得糟糕。
完成步骤
-
0.5 x 2² = 2 毫秒
-
0.5 x 2¹⁰ = 512 毫秒
-
0.5 x 2³⁰ = 0.536 亿毫秒 = 6.2 天
-
0.5 x 2⁵⁰ = 5.629 和 10¹⁴毫秒 = 17838 年
输出
结果可能如下:
| 输入大小 (n) | 时间:1 次操作 O(2^n) |
|---|---|
| 2 | 2 毫秒 |
| 10 | 512 毫秒 |
| 30 | 6.2 天 |
| 50 | 17838 年 |
表 1.4:O(2^n)算法的时间表
在本节中,我们比较了不同类型的算法运行时间复杂度。我们看到了每种算法是如何与其他算法相比的,从理论上的最快O(1)到一些最慢的O(k^n)。理解理论与实践之间的差异也很重要。例如,在现实生活中,如果执行的操作较少,输入是固定大小且较小,二次算法可能比线性算法表现更好。
复杂度符号
在上一节中,我们看到了如何使用大 O 符号来衡量算法的运行性能,与输入大小的比例。我们既没有详细检查O(n)究竟意味着什么,也没有考虑算法相对于给定输入类型的性能。
考虑以下代码片段。该方法接受一个包含字符串的数组并搜索匹配项。如果找到匹配项,则返回数组的索引。我们将使用这个例子来尝试测量运行时间复杂度。代码如下:
public int search(String strToMatch, String[] strArray) {
for (int i = 0; i < strArray.length; i++) {
if (strArray[i].equals(strToMatch)) {
return i;
}
}
return -1;
}
Snippet 1.3:数组搜索。源类名:ArraySearch。
前往goo.gl/egw1Sn访问代码。
在循环内部发生了很多操作。最明显的操作是在i位置访问数组和字符串equals。然而,我们还有i的递增、将新的递增值赋给i以及比较i是否小于数组长度的操作。但是,这并不是故事的结束。equals()方法还在将字符串的每个字符与数组中i位置的元素进行匹配。
下表列出了所有这些操作:
| 操作名称 | 代码 | 计数 |
|---|---|---|
| 数组访问 | strArray[i] |
1 |
| 字符串相等 | .equals(strToMatch) |
字符串长度 |
| 数组指针递增和赋值 | i = i + 1 |
2 |
| 读取数组长度并与指针比较 | i < strArray.length |
2 |
表 1.5:ArraySearch 方法对每个项执行的操作
我们看到,对于搜索数组中每个处理的项,我们执行5 + m次操作,其中m是搜索字符串的长度。接下来要考虑的方面是如何确定我们执行这些操作的频率。执行表 1.5中提到的操作次数不仅依赖于输入的长度,还取决于我们在输入数组中找到匹配项的速度,也就是说,它取决于实际数组的实际内容。
算法最好的情况是输入导致算法以尽可能高效的方式进行操作。最坏的情况则相反,即特定输入使它以尽可能低效的方式进行操作。
如果我们很幸运,要搜索的项位于搜索数组的第一个元素,我们只需执行5 + m次操作。这是最佳情况,也是搜索可以计算的最快方式。
这个算法的最坏情况是当我们的项目在数组的末尾,或者当项目根本找不到。这两种情况都会让我们检查数组的全部内容。在最坏的情况下,我们最终执行 n(5 + m) 次操作,其中 n 是数组大小。
在这个例子中,我们可以说,我们算法的最坏情况运行时间复杂度是 O(mn),而我们的最佳情况,当我们的算法立即找到匹配项时,是 O(m)。我们将在以下子节中看到,我们是如何从 5 + m 和 n(5 + m) 得到这个结果的。
另一种常用的算法分析是平均情况性能。平均情况复杂度可以通过对所有可能的输入的平均性能来找到。这在某些情况下是有用的,因为最坏情况发生的可能性很低。
虽然我们有最佳、平均和最坏情况复杂度,但在测量和比较不同算法时,通常最常用的是最坏情况。除了运行时性能外,大 O 符号的最常见用途是测量内存需求。然而,它可以用于任何资源,例如磁盘空间或网络使用。
在检查数组中的重复项时,识别算法的最佳和最坏性能
我们希望通过考虑最佳和最坏情况性能来确定检查数组中重复项的算法的复杂度。找出 Snippet 1.4 中最坏和最佳情况下的操作次数。不需要计算出大 O 符号中的算法复杂度。假设内部循环每次执行都会产生八次操作。
对于外部循环,假设四个操作:
public boolean containsDuplicates(int[] numbers) {
for (int i=0; i<numbers.length; i++) {
for (int j=0; j<numbers.length; j++) {
if (i != j && numbers[i] == numbers[j]) return true;
}
}
return false;
}
Snippet 1.4:检查重复项。源类名:Duplicates。
前往 goo.gl/wEUqYk 访问代码。
要做到这一点,我们应该执行以下步骤:
-
在最坏的情况下,我们执行内部循环 n 次(数组长度)。
-
在最佳情况下,我们只执行内部循环两次。
-
最佳情况是重复数字在输入数组的开头。最坏的情况是数组不包含任何重复项。
-
最坏的情况是数组不包含重复项且大小为 n:
-
对于外部循环,我们有 4n* 次操作
-
对于内部循环,我们有 n(n8) 次操作
-
总共,我们有 4n + 8n² 次操作
-
-
在最佳情况下,重复项是数组的头两个项目:
-
对于外部循环,我们有 4 次操作
-
对于内部循环,我们有 28* 次操作,因为内部循环执行两次才能到达数组中的第二个项目,即重复项所在的位置
-
总共,我们有 20 次操作
-
我们已经看到了如何分析算法中执行的操作的数量,以及如何使用大 O 符号来描述最佳和最坏情况。我们还讨论了如何使用该符号来描述任何资源的使用。在下一节中,我们将描述在使用符号时使用的一些基本规则。
符号规则
当我们想要用大 O 符号表示一个算法时,有两个简单的规则需要遵循。在本节中,我们将了解如何将表达式从 4n + 8n² 转换为大 O 符号的等价形式。
遵循的第一个规则是丢弃任何常数。
例如,3n + 4 变为 n,单个常数如 5 变为 1。如果一个算法只有 4 个不依赖于输入的常数指令,我们说该算法是 O(1),也称为常数时间复杂度。
第二个规则是丢弃除了最高阶之外的所有内容。
要理解我们为什么采用第二个规则,重要的是要意识到对于足够大的 n,除了最高阶之外的所有内容都变得无关紧要。当我们有足够大的输入时,性能差异是可以忽略不计的。
考虑一个执行 n + n² + n³ 的算法。这个算法的最高阶变量是 n³ 部分。如果我们保留最高阶,我们最终得到的大 O 运行时复杂度为 O(n³)。
活动:将表达式转换为大 O 符号
场景
要将表达式 3mn + 5mn⁴ + 2n² + 6 转换为大 O 符号,首先我们从表达式中删除任何常数,留下 mn + mn⁴ + n²。然后,我们只需保留最高阶部分,这导致结果为 O(mn⁴)。
对于在 表 1.6 中找到的每个表达式,找到其大 O 符号的等价形式:
| 表达式 | 3mn | 5n + 44n² + 4 | 4 + 5 log n | 3^n + 5n² + 8 |
|---|
表 1.6:找到大 O 等价
目标
应用符号规则将表达式转换为大 O 符号。
完成步骤
-
识别并删除表达式中的常数:
-
3mn → 无常数 → 3mn
-
5n + 44n² + 4 → 4 → 5n + 44n²
-
4 + 5 log n → 4 → 5 log n
-
3^n + 5n² + 8 → 8 → 3^n + 5n²
-
-
丢弃除了最高阶部分之外的所有内容:
-
3mn → O(mn)
-
5n + 44n² → O(n²)
-
5 log n → O(log n)
-
3^n + 5n² → O(3^n)
-
输出
结果可能如下:
| 表达式 | 3mn | 5n + 44n² + 4 | 4 + 5 log n | 3^n + 5n² + 8 |
|---|---|---|---|---|
| 解决方案 | O(mn) | O(n²) | O(log n) | O(3^n) |
表 1.7:找到大 O 等价活动的解决方案
在本节中,我们探讨了用于将表达式转换为大 O 符号的两个简单规则。我们还学习了为什么我们只保留表达式的最高阶。在下一节中,我们将看到一些具有不同复杂度的算法的示例。
识别具有不同复杂度的算法
在本节中,我们将探讨不同复杂度的示例。这很重要,这样我们就可以学会识别属于不同复杂度类的算法,并可能尝试提高每个算法的性能。
确定某些算法的最坏情况复杂度可能相当困难。有时,这需要一些经验,并且最好是通过查看许多示例并熟悉不同类型的算法来学习。
线性复杂度
线性算法是那些工作量与输入大小成正比的算法,也就是说,如果你加倍输入大小,你也会加倍工作量。这些通常涉及对输入的单次遍历。
本例中提出的问题是计算字符串中特定字符的出现次数。想象一下,我们被给出了字符串“Sally sells sea shells on the seashore”,我们想找出字母a出现的次数。
片段 1.5中的以下代码遍历输入字符串中的每个字符,如果当前位置的字符与搜索字母匹配,则计数器增加。循环结束时,返回最终计数。代码如下:
public int countChars(char c, String str) {
int count = 0;
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) == c) count++;
}
return count;
}
片段 1.5:计算字符串中的字符数。源类名:CountChars。
访问goo.gl/M4Vy7Y以获取代码。
线性复杂度算法是最常见的算法类型。这些通常对输入进行单次遍历,因此与输入大小成比例。在本节中,我们已经看到了这样一个例子。
该算法是线性的,因为其运行时间与字符串长度成正比。如果我们取字符串长度为n,则此 Java 方法的运行时间复杂度为O(n)。注意,根据输入大小变化的单个循环。这非常典型于线性运行时间复杂度算法,其中对每个输入单元执行固定数量的操作。在这个例子中,输入单元是字符串中的每个字符。
二次复杂度
二次复杂度算法对于大输入大小来说性能不佳。随着我们增加输入大小,工作量按照二次比例增加。我们在片段 1.2中的最小距离解决方案中已经看到了一个O(n²)的例子。还有很多其他例子,比如冒泡排序和选择排序。本例中提出的问题是找出包含在两个数组中的公共元素(假设每个数组中都不存在重复值),产生两个输入的交集。这导致运行时间复杂度为O(mn),其中m和n是第一个和第二个输入数组的大小。如果输入数组的大小与n个元素相同,这将导致运行时间为O(n²)。这可以通过以下代码来证明:
public List<Integer> intersection(int[] a, int[] b) {
List<Integer> result = new ArrayList<>(a.length);
for (int x : a) {
for (int y : b) {
if (x == y) result.add(x);
}
}
return result;
}
片段 1.6:两个数组之间的交集。源类名:SimpleIntersection。
前往 goo.gl/uHuP5B 访问代码。存在一个更有效的交集问题实现。这涉及到首先对数组进行排序,从而实现总体运行时间为 O(n log n)。
在计算空间复杂度时,应该忽略输入参数所占用的内存。只有算法内部分配的内存才需要考虑。
我们使用的内存量由我们方法中列出的结果大小决定。这个列表越大,我们使用的内存就越多。
最佳情况是我们使用最少的内存。这是当列表为空时,即当我们两个数组之间没有共同元素时。因此,当没有交集时,这种方法的空间复杂度最佳情况为 O(1)。
最坏情况正好相反,当我们两个数组中都有所有元素时。当数组相等时,这种情况可能发生,尽管数字可能顺序不同。在这种情况下,内存大小等于我们输入数组的大小。简而言之,该方法的最坏空间复杂度为 O(n)。
在本节中,我们展示了二次算法的示例。还有许多其他示例。在下一章中,我们还将描述一个性能较差的排序算法,其复杂度为 O(n²),称为 冒泡排序。
对数复杂度
对数复杂度算法非常快,并且随着问题规模的增加,它们的性能几乎不会下降。这些类型的算法扩展性非常好。具有 O(log n) 时间复杂度的代码通常很容易识别,因为它会系统地分几个步骤来划分输入。在对数时间内运行的常见示例包括数据库索引和二叉树。如果我们想在列表中查找一个项目,如果输入列表按某种特定顺序排序,我们可以更有效地完成这项任务。然后我们可以通过跳到列表的特定位置并跳过一定数量的元素来使用这种排序。
片段 1.7 展示了 Java 中二分搜索的实现。该方法使用三个数组指针——开始、结束和中点。算法首先检查数组中的中间元素。如果元素未找到且小于中间的值,我们选择在下半部分进行搜索;否则,我们选择上半部分。图 1.3 展示了进行二分搜索时涉及的步骤。代码片段如下:
public boolean binarySearch(int x, int[] sortedNumbers) {
int end = sortedNumbers.length - 1;
int start = 0;
while (start <= end) {
int mid = (end - start) / 2 + start;
if (sortedNumbers[mid] == x) return true;
else if (sortedNumbers[mid] > x) end = mid - 1;
else start = mid + 1;
}
return false;
}
片段 1.7:二分搜索。源类名:BinarySearch。
前往 goo.gl/R9e31d 访问代码。
看一下以下图表:

图 1.3:二分搜索步骤
假设最坏情况,如果我们的二分搜索算法需要 95 次数组跳跃(如 图 1.3 所示),输入大小需要有多大?由于这是一个二分搜索,我们正在将搜索空间分成两半,因此我们应该使用以 2 为底的对数。
此外,对数的倒数是指数。因此,我们可以得出以下结论:
-
log[2] n = 95
-
2⁹⁵ = n
-
39614081257132168796771975168 = n
记录在案,2⁹⁵远远大于宇宙中的秒数。这个例子展示了这类算法的扩展性有多好。即使对于巨大的输入,执行步骤的数量仍然非常小。
对数算法与指数算法相反。随着输入值的增大,性能退化的速率会减小。这是一个非常理想化的特性,因为它意味着我们的问题可以扩展到非常大的规模,而几乎不会影响我们的性能。在本节中,我们给出了这类复杂度的一个例子。
指数复杂度
如我们之前所见,具有指数运行时间复杂度的算法扩展性非常差。对于许多问题,只知道O(k^n)的解决方案。在计算机科学中,改进这些算法是一个非常活跃的研究领域。这些例子包括旅行商问题和使用暴力破解方法破解密码。现在,让我们来看一个这样的问题示例。
一个质数只能被自身和 1 整除。我们在这里提出的例子问题被称为质因数分解问题。结果是,如果你有一组正确的质数,你可以通过将它们全部相乘来创建任何其他可能的数字。问题是要找出这些质数。更具体地说,给定一个整数输入,找出所有是输入的因数的质数(这些质数相乘给出输入)。
许多当前的加密技术依赖于这样一个事实:对于质因数分解,没有已知的多项式时间算法。然而,没有人证明过不存在这样的算法。因此,如果发现一种快速找到质因数的方法,许多当前的加密策略将需要重新设计。
Snippet 1.8展示了该问题的一个实现,称为试除法。如果我们取一个有n位数字的输入十进制数,这个算法在最坏情况下会以O(10^n)的复杂度运行。该算法通过使用一个计数器(在Snippet 1.8中称为factor)从 2 开始,检查它是否是输入的因数。这个检查是通过使用取模运算符来完成的。如果取模运算没有余数,则计数器的值是一个因数,并将其添加到因数列表中。然后,输入被除以这个因数。如果计数器不是因数(取模运算留下余数),则计数器增加 1。这个过程一直持续到x减少到 1。这可以通过以下代码片段来演示:
public List<Long> primeFactors(long x) {
ArrayList<Long> result = new ArrayList<>();
long factor = 2;
while (x > 1) {
if (x % factor == 0) {
result.add(factor);
x /= factor;
} else {
factor += 1;
}
}
return result;
}
Snippet 1.8:质因数。源类名:FindPrimeFactors。
访问goo.gl/xU4HBV以获取代码。
尝试执行以下两个数字的先前代码:
-
2100078578
-
2100078577
为什么当你尝试第二个数字时花费这么长时间?什么类型的输入会触发此代码的最坏情况运行时间?
算法的最坏情况发生在输入是质数时,此时它需要依次计数直到质数。这就是第二个输入发生的情况。
另一方面,第一个输入的最大质数因子仅为 10,973,因此算法只需要计数到这个数,它可以快速完成。
指数复杂度算法通常最好避免,应该调查其他解决方案。这是由于其与输入大小的糟糕扩展性。这并不是说这些类型的算法没有用。如果输入大小足够小或者保证不会触发最坏情况,它们可能是合适的。
常数复杂度
随着输入大小的增加,常数运行时间算法的效率保持不变。这些例子有很多。例如,考虑访问数组中的一个元素。访问性能不依赖于数组的大小,因此当我们增加数组大小时,访问速度保持不变。
考虑片段 1.9中的代码。执行的操作数量保持不变,无论输入半径的大小如何。这样的算法被称为具有O(1)的运行时间复杂度。代码片段如下:
private double circleCircumference(int radius) {
return 2.0 * Math.PI * radius;
}
片段 1.9:圆周长。源类名:CircleOperations。
前往goo.gl/Rp57PB访问代码。
常数复杂度算法是所有复杂度类别中最理想的,因为它们具有最佳的扩展性。许多简单的数学函数,例如找到两点之间的距离和将三维坐标映射到二维坐标,都属于这一类别。
活动:开发更快的交集算法
场景
我们已经在片段 1.6中看到了一个产生两个输入数组交集的算法。
我们已经展示了这个算法的运行时间复杂度是O(n²)。我们能写一个具有更快运行时间复杂度的算法吗?
要找到这个问题的解决方案,考虑你如何手动在两副扑克牌中找到交集。想象你从每副洗好的牌中取一个子集;你会使用什么技术来找到第一副和第二副牌中的共同牌?
目标
为了提高数组交集算法的性能并降低其运行时间复杂度。
先决条件
-
确保你有一个类可用:
-
你将找到两种改进交集的方法:
- 慢速交集:
public List<Integer> intersection(int[] a, int[] b)
-
- 空存根,返回 null:
public List<Integer> intersectionFast(int[] a, int[] b)
-
使用第二个、空的存根方法,为交集算法实现一个更快的替代方案。
-
假设每个数组都没有重复值。如果你已经设置了项目,可以通过运行以下命令来运行此活动的单元测试:
gradlew test --tests com.packt.datastructuresandalg.lesson1.activity.improveintersection*
完成步骤
- 假设我们有一种在 O(n log n) 时间内对输入进行排序的方法。以下方法提供了这种排序:
public void mergeSort(int[] input) {
Arrays.sort(input);
}
我们可以使用这种方法对一个或两个输入数组进行排序,使交集更容易。
-
要对一个输入数组进行排序,我们可以使用二分搜索。运行时间复杂度是归并排序的 O(n log n) 加上二分搜索的 O(n log n)。
每一项都在第一个列表中。这是 nlog+ nlog n,最终结果是 O(n log n)。
-
对两个数组进行排序,并有两个指针,每个数组一个。
-
以线性方式遍历输入数组。
-
如果另一个指针指向的值更大,则递增一个指针。
-
如果两个指针指向的值相等,则两个指针都递增。此算法的运行时间复杂度是 2 * (n log n),包括两个归并排序以及排序后的线性遍历的 n。这导致 2 * (n log n) + n,最终结果是 O(n log n)。
摘要
在本章中,我们介绍了算法复杂性和描述它的符号。我们向您展示了如何使用大 O 符号来描述算法在输入变大时的扩展情况。我们还看到了各种复杂性的例子,并向您展示了如何直观地区分它们。理解大 O 符号在您需要设计和实现新解决方案或诊断性能问题时非常有用。
第二章:排序算法和基本数据结构
在上一章中,我们看到了如何通过使用排序算法来改进交集问题。这在许多问题中都很常见。如果数据是有序的,就可以开发出更有效的算法。在本章中,我们将首先探讨三种排序技术,即冒泡排序、快速排序和归并排序。稍后,我们将学习使用基本数据结构组织数据的不同方法。
到本章结束时,你将能够:
-
描述冒泡排序的工作原理
-
使用快速排序实现更快的排序
-
描述归并排序
-
构建链表数据结构
-
实现队列
-
描述栈数据结构
介绍冒泡排序
冒泡排序是现有最简单的排序算法。该技术涉及多次遍历输入数组并交换相邻的无序元素。该技术被称为冒泡排序,因为排序后的列表“冒泡”从列表的尾部向上移动。
理解冒泡排序
所有排序算法都接受一个元素列表并返回它们按顺序排列。每种算法之间的主要区别在于排序的方式。冒泡排序通过交换相邻元素来实现,这会将排序后的元素推向列表的末尾。
Snippet 2.1 展示了冒泡排序的伪代码。该算法涉及三个简单的任务,包括反复遍历列表进行排序、比较相邻元素,并在第一个元素大于第二个元素时交换它们。
我们需要对数组进行多少次遍历才能使我们的列表排序?结果是,为了保证我们的列表排序,我们需要对列表进行 (n - 1) 次遍历,其中 n 是我们数组的长度。我们将在下一节中展示为什么需要 (n - 1) 次遍历,但这是冒泡排序具有 O(n²) 时间复杂性的主要原因,因为我们处理 n 个元素 n - 1 次。
冒泡排序的伪代码如下:
bubbleSort(array)
n = length(array)
for (k = 1 until n)
for (j = 0 until -1)
if(array[j] > array[j + 1])
swap(array, j, j + 1)
Snippet 2.1: 冒泡排序伪代码
Snippet 2.1 中的交换函数使用一个临时变量交换了两个数组指针 j 和 j+1 的值。
实现冒泡排序
要在 Java 中实现冒泡排序,请按照以下步骤操作:
- 在 Java 中将 Snippet 2.1 中显示的伪代码应用于。创建一个类和一个方法,接受一个要排序的数组,如下所示:
public void sort(int[] numbers)
- 该算法的稍微棘手的部分是交换逻辑。这通过将一个要交换的元素分配给一个临时变量来实现,如 Snippet 2.2 所示:
public void sort(int[] numbers) {
for (int i = 1; i < numbers.length; i++) {
for (int j = 0; j < numbers.length - 1; j++) {
if (numbers[j] > numbers[j + 1]) {
int temp = numbers[j];
numbers[j] = numbers[j + 1];
numbers[j + 1] = temp;
}
}
}
}
Snippet 2.2: 冒泡排序解决方案。源类名:BubbleSort
前往 goo.gl/7atHVR 访问代码。
虽然冒泡排序非常容易实现,但它也是现有最慢的排序方法之一。在下一节中,我们将探讨如何略微提高该算法的性能。
改进冒泡排序
我们可以采用两种主要技术来提高冒泡排序的性能。重要的是要认识到,尽管这两种策略在平均情况下都提高了冒泡排序的整体性能;在最坏的情况下,算法仍然具有相同的较差的运行时间复杂度 O(n²)。
我们可以对原始冒泡排序的第一个小改进是利用事实,即一个已排序的“气泡”正在列表的末尾构建。我们每进行一次遍历,就会在这个气泡的末尾部分添加另一个项目。这就是为什么需要 (n - 1) 次遍历的原因。
这也在 图 2.1 中显示。在这个图中,显示在虚线圆圈中的项目已经排在了正确的位置:

图 2.1:列表末尾形成气泡
我们可以利用这个事实,所以我们不会尝试对这个气泡内的元素进行排序。我们可以通过稍微修改我们的 Java 代码来实现这一点,如 代码片段 2.3 所示。在内循环中,我们可以在到达列表末尾之前停止处理,直到 numbers.length - i。为了简洁起见,在 代码片段 2.3 中,我们将交换逻辑替换为以下方法:
public void sortImprovement1(int[] numbers) {
for (int i = 1; i < numbers.length; i++) {
for (int j = 0; j < numbers.length - i; j++) {
if (numbers[j] > numbers[j + 1]) {
swap(numbers, j, j + 1);
}
}
}
}
代码片段 2.3:冒泡排序改进 1. 源类名:BubbleSort
前往 goo.gl/vj267K 访问代码。
如果我们给冒泡排序算法一个已排序的列表,我们仍然会在它上面进行多次遍历而不修改它。我们可以通过在数组内部列表完全排序时截断外循环来进一步改进算法。我们可以通过检查在上一次遍历过程中是否进行了任何交换来检查数组是否已排序。这样,如果我们给我们的方法一个已经排序的列表,我们只需要在数组上执行一次遍历,然后保持它不变。这意味着现在最佳情况是 O(n),尽管最坏情况保持不变。
实现冒泡排序改进
我们需要通过减少遍历次数来改进冒泡排序算法。
执行此操作的步骤如下:
-
修改冒泡排序方法,使其在内部循环遍历后数组未发生变化时停止排序。
-
如果将外部的 for 循环改为 while 循环,并保持一个标志来指示在遍历数组时是否交换了任何元素,那么解决方案可以很容易地开发出来。这在下述代码片段中显示:
public void sortImprovement2(int[] numbers) {
int i = 0;
boolean swapOccured = true;
while (swapOccured) {
swapOccured = false;
i++;
for (int j = 0; j < numbers.length - i; j++) {
if (numbers[j] > numbers[j + 1]) {
swap(numbers, j, j + 1);
swapOccured = true;
}
}
}
}
代码片段 2.4:冒泡排序改进 2. 源类名:BubbleSort
前往 goo.gl/HgVYfL 访问代码。
在本节中,我们看到了一些关于如何改进冒泡排序算法的简单技巧。在接下来的章节中,我们将探讨一些其他排序技术,它们的性能比冒泡排序快得多。
活动:在 Java 中实现选择排序
场景
通过想象你有两个列表,A 和 B,来更好地理解选择排序。最初,我们有一个包含所有未排序元素的列表 A,而列表 B 为空。想法是使用 B 来存储排序后的元素。算法通过从 A 中找到最小的元素并将其移动到 B 的末尾来工作。我们继续这样做,直到 A 为空而 B 为满。而不是使用两个单独的列表,我们可以只使用相同的输入数组,但保持一个指针来将数组分成两部分。
在现实生活中,这可以通过想象你如何排序一副牌来解释。使用洗好的牌,你可以逐张检查牌,直到找到最低的牌。你将这张牌放在一边作为新的第二堆。然后,你寻找下一张最低的牌,一旦找到,你就把它放在第二堆的底部。你重复这个过程,直到第一堆为空。
解决方案的一种方法是在先使用两个数组(A 和 B,在先前的描述中)编写伪代码。然后,通过使用交换方法将排序后的列表(数组 B)存储在相同的输入数组中。
目标
在 Java 中实现选择排序
先决条件
-
在以下类中实现排序方法,该类可在 GitHub 存储库的以下路径找到:
-
sort方法应该接受一个整数数组并对其进行排序
如果你已经设置了你的项目,你可以通过运行以下命令来运行此活动的单元测试:
gradlew test --tests com.packt.datastructuresandalg.lesson2.activity.selectionsort*
完成步骤
-
使用数组索引指针将输入数组分成两部分
-
sort方法应该接受一个整数数组并对其进行排序 -
遍历数组的未排序部分以找到最小值
-
然后将最小项交换,以便它可以添加到排序部分的末尾
理解快速排序
快速排序是冒泡排序的一个重大改进。这种排序技术是由英国计算机科学家托尼·豪尔开发的。算法主要分为三个步骤:
-
选择一个枢轴
-
对列表进行分区,使得枢轴左边的元素小于枢轴的值,而右边的元素大于枢轴的值
-
分别对左部和右部重复步骤 1 和 2
由于快速排序需要递归,我们将从这个部分开始,给出递归的一个例子。稍后,我们将看到快速排序算法中的分区是如何工作的,最后,我们将把递归技术用于最终部分。
理解递归
递归是算法设计者的一项非常有用的工具。它允许你通过解决相同问题的较小实例来解决大问题。递归函数通常具有以下组件的通用结构:
-
一个或多个停止条件:在特定条件下,它会停止函数再次调用自身
-
一个或多个递归调用:这是指一个函数(或方法)调用自身
在下一个示例中,我们将选择前一章中看到的二分查找问题,并将算法改为递归方式。考虑在 第一章 中讨论的二分查找问题,算法和复杂性,如 Snippet 1.7 所列。实现是迭代的,也就是说,它循环直到找到项或 end 参数等于或大于 start 变量。以下代码片段显示了如何将此方法转换为递归函数的伪代码:
binarySearch(x, array, start, end)
if(start <= end)
mid = (end - start) / 2 + start
if (array[mid] == x) return true
if (array[mid] > x) return binarySearch(x, array, start, mid - 1)
return binarySearch(x, array, mid + 1, end)
return false
Snippet 2.5: 递归二分查找伪代码
实际上,递归二分查找中有两个停止条件。如果函数在中间找到搜索项,或者起始数组指针大于结束指针,意味着未找到该项,则停止递归链。可以通过检查任何不涉及进一步递归调用的返回路径来轻松找到停止条件。
实现递归二分查找
在 Java 中实现递归二分查找,我们将遵循以下步骤:
-
使用如 Snippet 2.5 所示的伪代码实现递归二分查找函数。
-
提供另一个方法,其签名只包含搜索项和排序数组作为输入。然后,该方法将使用适当的值调用递归函数,如下所示:
public boolean binarySearch(int x, int[] sortedNumbers)
输出
以下代码显示了进行初始调用和递归函数的附加方法:
public boolean binarySearch(int x, int[] sortedNumbers, int start,
int end) {
if (start <= end) {
int mid = (end - start) / 2 + start;
if (sortedNumbers[mid] == x) return true;
if (sortedNumbers[mid] > x)
return binarySearch(x, sortedNumbers, start, mid - 1);
return binarySearch(x, sortedNumbers, mid + 1, end);
}
return false;}
Snippet 2.6: 递归二分查找。源类名:BinarySearchRecursive
访问代码请前往 goo.gl/pPaZVZ。
递归是任何开发者的基本工具,我们将在本书的许多部分中使用它。在本节中,我们实现了一个二分查找的示例。在下一节中,我们将探讨快速排序算法中的分区是如何工作的。
快速排序分区
分区是我们重新排列数组的进程,使得值小于我们的枢轴的元素被移动到枢轴的左侧,而值较大的元素被移动到右侧(见 图 2.2)。我们可以用多种方式来做这件事。在这里,我们将描述一个易于理解的方案,称为 Lomuto 分区。
看看这个图:

图 2.2:数组分区前后的情况
存在许多其他方案。Lomuto 方案的一个缺点是,当它用于已排序的列表时,性能并不很好。原始的 Hoare 分区方案性能更好,它通过从两端处理数组来实现。原始的 Hoare 方案由于交换次数较少,性能更好,尽管当使用已排序的列表作为输入时,它也可能会遭受性能缓慢的问题。Lomuto 和 Hoare 方案都会导致非稳定排序。稳定排序意味着如果两个或多个元素具有相同的键值,它们将按照输入顺序出现在排序输出中。还有其他方案可以使快速排序变得稳定,但它们会使用更多的内存。
为了更好地理解这种分区方案,最好将其简化为以下五个简单的步骤:
-
选择数组的最右侧元素作为枢轴。
-
从左侧开始,找到下一个大于枢轴的元素。
-
将此元素与下一个小于枢轴元素的元素交换。
-
重复步骤 2 和 3,直到不再可能进行交换。
-
将第一个大于枢轴值的元素与枢轴本身交换。
为了使用提到的步骤执行高效的分区,我们可以使用两个指针,一个指向第一个大于枢轴值的元素,另一个用于搜索小于枢轴值的值。
在以下代码中,这些是分别命名为 x 和 i 的整数指针。算法首先选择输入数组中的最后一个项目作为枢轴。然后它使用变量 i 从左到右单次遍历数组。如果当前正在处理的 i 位置的元素小于枢轴,则 x 增加,并交换。使用这种技术,变量 x 要么指向一个大于枢轴的值,要么 x 的值与 i 相同,在这种情况下,交换不会修改数组。一旦循环退出,我们执行最后一步,将第一个大于枢轴值的元素与枢轴本身交换。代码如下:
private int partition(int[] numbers, int start, int end) {
int pivot = numbers[end];
int x = start - 1;
for (int i = start; i < end; i++) {
if (numbers[i] < pivot) {
x++;
swap(numbers, x, i);
}
}
swap(numbers, x + 1, end);
return x + 1;
}
片段 2.7:快速排序的分区。源类名:QuickSort
前往 goo.gl/vrStai 访问代码。
活动:理解分区方法
场景
为了更好地理解 片段 2.7 中使用的分区方法,请一步一步地使用示例进行操作。
目标
为了理解 Lomuto 分区是如何工作的。
完成步骤
-
通过递增变量
x和i的值,为数组中的每个元素运行 片段 2.7 中提到的代码的干运行。 -
在假设列表的最后一个元素作为枢轴的情况下完成以下表格:
| i | 数组 | x |
|---|---|---|
- |
[4, 5, 33, 17, 3, 21, 1, 16] |
-1 |
0 |
[4, 5, 33, 17, 3, 21, 1, 16] |
0 |
1 |
||
2 |
[4, 5, 33, 17, 3, 21, 1, 16] |
1 |
3 |
||
4 |
[4, 5, 3, 17, 33, 21, 1, 16] |
2 |
5 |
||
6 |
||
7 |
||
| 最终 | [4, 5, 3, 1, 16, 21, 17, 33] |
3 |
表 2.1:划分方法的步骤
在本节中,我们了解了快速排序中的划分是如何工作的。在下一节中,我们将通过将其包含在完整的快速排序算法中来使用划分方法。
整合所有内容
快速排序属于一种称为分而治之的算法类别。在本书中,我们将看到这个类别中的许多其他示例,我们将在第四章算法设计范式中详细介绍分而治之。现在,重要的是要知道,分而治之算法持续将问题划分为更小的部分,直到问题足够小,变得容易解决。这种划分可以通过递归轻松实现。
在快速排序中,我们持续以这种方式递归地划分数组,直到问题足够小,我们可以轻松解决它。当数组只有一个元素时,解决方案很简单:数组保持完全不变,因为没有东西需要排序。这是我们的递归算法的停止条件。当数组包含一个以上的元素时,我们可以继续划分我们的数组,并使用我们在上一节中开发的划分方法。
此外,还有一种非递归的快速排序算法,它使用堆栈数据结构,尽管编写起来稍微复杂一些。我们将在本章的后面讨论堆栈和列表。
以下代码片段显示了完整快速排序的伪代码。就像大多数递归函数一样,代码首先检查停止条件。在这种情况下,我们通过确保起始数组指针小于结束来检查数组是否至少有两个元素。伪代码如下:
quickSort(array, start, end)
if(start < end)
p = partition(array, start, end)
quickSort(array, start, p - 1)
quickSort(array, p + 1, end)
片段 2.8:递归快速排序伪代码
当数组中至少有两个元素时,我们调用划分方法。然后,使用枢轴的最后一个位置(由划分方法返回),我们递归地对左部分进行快速排序,然后对右部分进行快速排序。
这是通过使用(start, p - 1)和
(p + 1, end),不包括p,即枢轴的位置。
理解快速排序工作原理的技巧是意识到,一旦我们对数组执行了划分调用,返回位置(枢轴)的元素就不再需要在数组中移动了。这是因为其右侧的所有元素都更大,左侧的所有元素都更小,所以枢轴处于正确的最终位置。
实现快速排序
要在 Java 中实现快速排序,请按照以下步骤操作:
-
在 Java 中实现片段 2.8中显示的伪代码,调用片段 2.7中显示的划分方法。
-
以下代码显示了 Java 中的递归实现,使用了上一节中开发的划分方法:
private void sort(int[] numbers, int start, int end) {
if (start < end) {
int p = partition(numbers, start, end);
sort(numbers, start, p - 1);
sort(numbers, p + 1, end);
}
}
代码片段 2.9:快速排序的解决方案。源类名:Quicksort
在本节中,我们描述了快速排序算法,它比我们在上一节中看到的冒泡排序算法快得多。平均而言,该算法的性能为 O(n log n),比冒泡排序的 O(n²) 有很大改进。然而,在最坏的情况下,该算法仍然以 O(n²) 的性能运行。快速排序的最坏情况输入取决于所使用的分区方案类型。在本节讨论的 Lomuto 方案中,最坏情况发生在输入已经排序的情况下。在下一节中,我们将考察另一种排序算法,其最坏运行时间复杂度为 O(n log n)。
使用归并排序
尽管快速排序平均来说非常快,但它仍然具有理论上的最坏时间复杂度 O(n²)。在本节中,我们将考察另一种排序算法,称为 归并排序,其最坏时间复杂度为 O(n log n)。与快速排序类似,归并排序属于分治算法类别。
归并排序可以总结为以下三个简单步骤:
-
在中间分割数组
-
分别递归排序每个部分
-
将两个已排序的部分合并在一起
在下一节中,我们将逐步开发前面的步骤,每次都慢慢构建我们对归并排序工作原理的理解。
尽管归并排序在理论上比快速排序快,但在实践中,一些快速排序的实现可能比归并排序更高效。此外,归并排序大约使用 O(n) 的内存,而快速排序是 O(log n)。
分解问题
在前一节中,我们看到了如何使用递归技术将问题分解成更小的多个问题,直到解决方案变得容易解决。归并排序使用了相同的方法。我们递归技术的基例与快速排序相同。这是当数组只有一个元素长时。当要排序的数组只包含一个项目时,数组已经是排序好的。
图 2.3 展示了归并排序数组分割的过程。在每一步中,我们找到数组的中间点并将数组分成两部分。然后我们分别递归地对分割数组的左右部分进行排序。一旦要排序的总元素数等于一,我们就可以停止递归调用,如下面的图所示:

图 2.3 展示归并排序算法的分割步骤
实现归并排序
我们需要完成归并排序算法的伪代码。
记住归并排序的递归部分与我们在前一节中看到的快速排序算法非常相似,完成以下代码中的伪代码:
mergeSort(array, start, end)
if(_____________)
midPoint = _________
mergeSort(array, _____, _____)
mergeSort(array, _____, _____)
merge(array, start, midPoint, end)
代码片段 2.10:递归归并排序伪代码练习
归并排序的伪代码可以完成如下:
mergeSort(array, start, end)
if(start < end)
midPoint = (end - start) / 2 + start
mergeSort(array, start, midPoint)
mergeSort(array, midPoint + 1, start)
merge(array, start, midPoint, end)
代码片段 2.11:递归归并排序伪代码解决方案
归并排序算法与快速排序算法属于同一类算法;然而,它们的运行时间和空间复杂度不同。归并排序不是从枢轴位置分割数组,而是始终在数组的中点进行分割。这与二分搜索的过程类似,导致log[2] n次数组分割。在下一节中,我们将介绍归并排序算法的合并部分,即将分割数组的两个不同部分合并成一个排序好的数组。
合并问题
你如何将两个排序列表合并成一个排序列表?这是merge()函数的任务,该函数位于前一个部分伪代码的末尾。这个过程在图 2.4中展示。合并两个排序列表比从头开始排序更容易。
这与我们在第一章,“算法与复杂度”中看到的交集问题类似。
我们可以使用两个指针和一个空数组在线性时间内进行合并,如下面的图所示:

图 2.4:合并两个排序数组前后的情况
由于分割数组的两部分都是排序好的,所以合并它们很容易。一个有用的类比是回想一下,当我们看到第一章,“算法与复杂度”中的交集问题时,一旦输入数组都是排序好的,问题就变得容易多了。这里可以使用类似的算法。
copyArray() function simply takes in a source array as a first argument and copies it to the target array, that is, the second argument. It makes use of the start variable as a pointer, indicating where to place the first element of the source array onto the target one. The pseudocode is as follows:
merge(array, start, middle, end)
i = start
j = middle + 1
arrayTemp = initArrayOfSize(end - start + 1)
for (k = 0 until end-start)
if (i <= middle && (j > end || array[i] <= array[j]))
arrayTemp[k] = array[i]
i++
else
arrayTemp[k] = array[j]
j++
copyArray(arrayTemp, array, start)
代码片段 2.12:归并排序的合并伪代码
在归并排序的合并部分,我们创建一个临时数组,其大小等于两个数组部分的总和。然后我们对这个数组进行单次遍历,通过从两个输入列表(由起始、中间和结束指针表示)中选择最小的项,一次填充临时数组中的一个项目。从其中一个列表中选取一个项目后,我们前进该列表的指针,并重复此过程,直到合并完成。
我们可以使用各种 Java 工具来实现代码片段 2.12末尾的copyArray()函数。我们可以简单地实现一个for循环并自己实现copy()函数。或者,我们可以利用 Java 的流并在一行中写入复制。可能最简单的方法是使用System.arrayCopy()函数。
归并排序在理论上是最快的排序算法之一。其速度的缺点是它消耗更多的内存,尽管一些实现可以在原地执行合并步骤以节省内存。
为了比较,我们在下表中展示了多种排序技术及其运行时间和内存性能:
| 算法名称 | 平均情况 | 最坏情况 | 内存 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 快速排序 | O(n log n) | O(n²) | O(1) | 不稳定 |
| 合并 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 堆 | O(n log n) | O(n log n) | O(1) | 不稳定 |
表 2.2:排序算法
活动:在 Java 中实现归并排序
场景
归并排序是一种最快的排序技术。它在许多库和 API 中使用。在这个活动中,我们将用 Java 编写一个算法来对数组进行归并排序。
目标
要使用本节中显示的伪代码在 Java 中实现完整的归并排序算法。
先决条件
要解决此活动,您必须实现以下类中找到的方法,该类可在以下 GitHub 存储库路径处的书籍中找到:
如果您已设置好项目,可以通过运行以下命令来运行此活动的单元测试:
gradlew test --tests com.packt.datastructuresandalg.lesson2.activity.mergesort*
完成步骤
-
从
mergeSort方法开始,该方法将数组分成两部分,递归地对两部分进行排序,然后合并结果 -
然后,实现合并方法,该方法将拆分数组的两端合并到另一个空间
-
合并完成后,将新数组复制到输入数组的位置
开始使用基本数据结构
数据结构是一种组织数据的方式,以便它对于您试图解决的问题可以高效地访问。选择正确的数据结构将取决于您试图解决的问题类型(决定了您访问数据的方式)、您需要组织的数据量以及您用于存储数据的介质(内存、磁盘等)。
我们已经看到并使用了一个数据结构的例子。在前面的章节中,我们已经广泛使用了数组。数组是最基本的数据结构。它们通过索引访问您的数据,并且大小固定(也称为静态)。这与其他动态数据结构形成对比,这些数据结构可以根据需要增长并腾出更多空间来存储数据。
数据结构介绍
更正式地说,数据结构是数据元素的组织,是一组可以应用于数据的功能(如添加、删除和搜索)以及不同数据元素之间的任何关系。以下表格显示了某些数据结构提供的常见操作:
| 操作 | 类型 | 描述 |
|---|---|---|
search(key) |
非修改 | 给定特定值的键,如果可以找到,将返回存储在数据结构中的值。 |
size() |
非修改 | 数据结构中存储的值的总数。 |
add(value) |
修改 | 在数据结构中插入一个值。 |
update(key, value) |
修改 | 使用提供的键和值更新现有条目。 |
delete(value) |
修改 | 从数据结构中删除数据项。 |
minimum() |
非修改 | 仅由有序数据结构支持的运算,将返回具有最小键的值。 |
maximum() |
非修改 | 仅由有序数据结构支持的运算,将返回具有最小键的值。 |
表 2.3:数据结构的一些常见操作
在本节中,我们将看到各种类型的动态数据结构。我们将从链表开始,链表优化了动态增长,但在搜索时较慢。然后,我们将使用这些链表在顶部实现其他数据结构,例如队列和栈。
链表结构
链表是一系列数据项的列表,其中每个项如果存在的话,只知道列表中的下一个项。图 2.5 展示了这样一个例子。图中的每个框代表一个用于存储所需数据项的容器。这个容器,称为节点,包含我们的数据值和指向列表中下一个节点的指针。如图所示,列表前面的节点称为列表的头部,而列表的最后一个项称为尾部。
为这些节点存储单独的指针,以便于访问数据结构:

图 2.5:链表示例
与数组相比,使用链表的优点是链表可以动态增长。当使用数组时,你在开始时分配空间,该空间保持固定。如果你分配了过多的空间而空间未被使用,你正在浪费资源。另一方面,如果你使数组太小,数据可能无法容纳。然而,在链表中,空间不是固定的。随着你添加更多数据,结构会动态增长,当你删除数据时,它会缩小,释放内存空间。
使用面向对象的语言,例如 Java,我们可以使用独立的节点实例来模拟链表,这些节点实例连接在一起以构建我们的链表。以下代码展示了我们如何在 Java 类中模拟链表节点。
该类包含一个自引用,因此我们可以像 图 2.5 中所示的那样以列表方式链接多个节点。
public class LinkedListNode<V> {
private V value;
private LinkedListNode<V> next;
public LinkedListNode(V value, LinkedListNode<V> next) {
this.value = value;
this.next = next;
}
public Optional<LinkedListNode<V>> getNext() {
return Optional.ofNullable(next);
}
}
碎片 2.13:链表节点类,为了简洁省略了获取器和设置器。源类名称:Linkedlistnode
前往 goo.gl/SAefic 访问代码。
注意我们如何使用 Java 的可选类(而不是返回 null 指针)来表示是否存在对下一个节点的链接。链表的尾节点将始终有一个空的 optional。我们还利用泛型来模拟我们想要存储的数据类型。这样,我们可以保持结构尽可能通用,以便它可以用于任何数据类型。
Optional类是在 Java 8 中引入的,以便能够表示可选值而不是使用 null。
将链表转换为双向链表结构
我们需要修改 Java 节点类以支持双向链表结构。
双向链表是一种链表,其中每个节点都包含对下一个和前一个节点的引用。将片段 2.13中的代码修改为支持这一点。
以下代码展示了这一解决方案:
public class DblLinkedListNode<V> {
private V value;
private DblLinkedListNode<V> next;
private DblLinkedListNode<V> previous;
public DblLinkedListNode(V value,
DblLinkedListNode<V> next,
DblLinkedListNode<V> previous) {
this.value = value;
this.next = next;
this.previous = previous;
}
}
片段 2.14:双向链表节点类,省略了 getters 和 setters 以节省空间。源类名:Dbllinkedlistnode
前往goo.gl/oJDQ8g访问代码。
在双向链表中,头节点将有一个 null 的前一个指针,而尾节点将有一个 null 的下一个指针。
在本节中,我们看到了如何使用类、泛型和可选引用来模拟链表节点。在下一节中,我们将看到如何实现一些链表操作。
链表操作
在我们能够使用任何链表操作之前,我们需要初始化数据结构并将其标记为空。从概念上讲,这是当列表头指向空时。我们可以在 Java 中通过在构造函数中添加此逻辑来实现这一点。
以下代码片段展示了这一点。注意,我们再次使用泛型来保存我们想要存储在链表中的项的类型:
public class LinkedList<V> {
private LinkedListNode<V> head;
public LinkedList() {
head = null;
}
}
片段 2.15:使用构造函数初始化链表数据结构。源类名:Linkedlist
前往goo.gl/vxpkRt访问代码。
我们如何从链表头部添加和删除项目?在链表中添加节点需要两个指针重新分配。在新节点上,你将下一个指针设置为指向头指针所分配的任何内容。然后,你将头指针设置为指向这个新创建的节点。这个过程在图 2.6中展示。从列表头部删除是相反的过程。你将头指针设置为指向旧头节点的前一个指针。为了完整性,你可以将这个下一个指针设置为指向空:

图 2.6:向列表头部添加节点
要在列表中定位一个项目,我们需要遍历整个列表,直到我们找到我们正在搜索的项目或到达列表的末尾。这可以通过从头指针开始,并始终跟随节点的下一个指针来实现,直到你找到你正在寻找的值的节点或没有更多的节点。例如,下一个指针是一个 null。
addFront() and deleteFront() operations for a linked list. For the addFront() method, we simply create a new node with its next pointer set as the current head pointer. Then, we assign the head to the new node. Notice in the delete method how we make use of Java's Optional objects. If the head pointer is null, it will stay null and we don't change anything. Otherwise, we flatten it to the next pointer. Finally, we set the first node's next pointer as null. This last step is not necessary since the orphaned node will be garbage collected; however, we're including it for completeness.
代码如下:
public void addFront(V item) {
this.head = new LinkedListNode<>(item, head);
}
public void deleteFront() {
Optional<LinkedListNode<V>> firstNode = Optional.
ofNullable(this.head);
this.head = firstNode.flatMap(LinkedListNode::getNext).
orElse(null);
firstNode.ifPresent(n -> n.setNext(null));
}
代码片段 2.16:从链表的前端添加和删除。源类名:Linkedlist
前往goo.gl/D5NAoT访问代码。
Optional methods. We start a while loop from the head pointer and keep on moving to the next node as long as there is a node present and that node doesn't contain the item we're looking for. We then return the last pointer, which can be an empty optional or a node containing a match:
public Optional<LinkedListNode<V>> find(V item) {
Optional<LinkedListNode<V>> node = Optional.ofNullable(this.head);
while (node.filter(n -> n.getValue() != item).isPresent()) {
node = node.flatMap(LinkedListNode::getNext);
}
return node;
}
代码片段 2.17:从链表的前端添加和删除。源类名:Linkedlist
前往goo.gl/6pQm3T访问代码。链表上的find()方法具有最差的运行时间复杂度O(n)。这种情况发生在匹配项位于列表末尾或者项根本不在列表中时。
在前面的例子中,我们展示了如何将项添加到列表的头部。我们如何将这个操作插入到链表的任意位置?图 2.7展示了如何分两步进行:

图 2.7:在列表的任意位置添加节点
代码片段 2.18展示了如何进行此操作。这是一个名为addAfter()的 Java 方法,接受一个节点和一个要插入的项。该方法在aNode参数之后添加一个包含项的节点。实现遵循图 2.7中显示的步骤。
public void addAfter(LinkedListNode<V> aNode, V item) {
aNode.setNext(new LinkedListNode<>(item, aNode.getNext().orElse(null)));
}
代码片段 2.18:addAfter操作的解决方案方法。源类名:Linkedlist
前往goo.gl/Sjxc6T访问此代码。
活动:遍历链表
场景
我们有一个包含一些元素的链表,我们需要构建一个形式为[3,6,4,2,4]的字符串。如果列表为空,则应输出[]。
目标
为遍历链表编写 Java 代码。
完成步骤
- 在
LinkedList类中编写一个toString()方法,如下所示:
public String toString() {
}
- 使用
while循环遍历链表。
在本节中,我们看到了如何实现链表中找到的各种操作。这种数据结构将成为我们将用来模拟队列和栈的基本工具。链表也将在本书更后面的更高级算法中得到广泛的应用。
队列
队列是抽象数据结构,旨在模拟现实生活中的队列工作方式。它们在各种应用中被广泛使用,例如资源分配、调度、排序等。它们通常使用双链表实现,尽管存在许多其他实现。队列通常包含两个操作;一个enqueue操作,其中项被添加到队列的尾部,以及一个相反的dequeue操作,其中项从队列的前端被移除。这两个操作使得这种数据结构的操作模式为先进先出(FIFO)。
我们可以使用双链表实现一个高效的队列。这使我们能够通过从链表头部移除项来实现dequeue操作。enqueue操作简单地将项添加到链表的尾部。图 2.8展示了这两个操作是如何执行的:

图 2.8:使用双链表进行入队和出队
要使用双链表作为基本数据结构来出队一个元素,我们只需将头部移动到列表中的下一个元素,并通过将前一个指针指向空来解除旧头部的链接。在尾部入队是一个三步过程。将新节点的先前指针指向当前尾部,然后将当前尾部的下一个指针指向新节点,最后将尾部移动到新节点。这两个操作的伪代码如下所示:
dequeue(head)
if (head != null)
node = head
head = head.next
if (head != null) head.previous = null
return node.value
return null
enqueue(tail, item)
node = new Node(item)
node.previous = tail
if (tail != null) tail.next = node
if (head == null) head = node
tail = node
代码片段 2.19:使用双链表进行入队和出队。源类名:Queue
从队列中添加和删除元素
要在 Java 中实现 enqueue() 和 dequeue() 方法,请按照以下步骤操作:
- 使用双链表实现前述代码中的 dequeue 和 enqueue 伪代码。遵循以下代码片段中的结构和方法签名:
public class Queue<V> {
private DblLinkedListNode<V> head;
private DblLinkedListNode<V> tail;
public void enqueue(V item)
public Optional<V> dequeue()
}
代码片段 2.20:练习类结构和方法签名
enqueue()方法可以按照以下代码所示实现:
public void enqueue(V item) {
DblLinkedListNode<V> node = new DblLinkedListNode<>(item, null, tail);
Optional.ofNullable(tail).ifPresent(n -> n.setNext(node));
tail = node;
if(head == null) head = node;
}
代码片段 2.21:练习类结构和方法签名。源类名:Queue
前往 goo.gl/FddeYu 访问 dequeue() 方法的代码。
队列是具有 FIFO 排序的动态数据结构。在下一节中,我们将探讨另一种具有不同排序的数据结构,称为栈。
栈
栈通常也使用链表实现,其工作方式与队列不同。它们不是 FIFO 排序,而是具有 后进先出(LIFO)排序(见 图 2.9)。它们有两个主要操作,称为 push,它将一个项添加到栈顶,以及 pop,它从栈顶移除并返回一个项。像队列一样,栈在许多算法中都有广泛的应用,例如深度优先搜索遍历、表达式评估等:

图 2.9:在纸张栈上的 push 和 pop 操作
要模拟一个栈,使用一个简单的链表就足够了。链表的头部可以用来引用栈顶。每次我们需要在栈顶添加某个元素时,我们都可以使用在前几节中开发的 addFront() 方法。实现的不同之处仅在于弹出操作返回栈顶的可选项。以下代码片段中的 Java 实现显示了 push 和 pop 操作。注意,弹出操作返回一个可选值,如果栈不为空,则该值被填充。
由于我们只需要从列表的一端进行操作,因此使用单向链表就足以模拟栈。对于队列,我们需要修改链表的头部和尾部,因此使用双链表更有效率。以下代码显示了 push() 和 pop() 方法的实现:
public void push(V item) {
head = new LinkedListNode<V>(item, head);
}
public Optional<V> pop() {
Optional<LinkedListNode<V>> node = Optional.ofNullable(head);
head = node.flatMap(LinkedListNode::getNext).orElse(null);
return node.map(LinkedListNode::getValue);
}
代码片段 2.22:Java 中的 push 和 pop 操作。源类名:Stack
访问goo.gl/uUhuqg以获取代码。
反转字符串
我们需要使用栈数据结构来反转字符串。
按照以下步骤进行:
- 要反转字符串,将输入字符串的每个字符推入栈中,然后逐个弹出所有字符,构建一个反转的字符串。方法签名可以如下所示:
public String reverse(String str)
- 以下代码展示了如何使用栈数据结构来反转字符串:
public String reverse(String str) {
StringBuilder result = new StringBuilder();
Stack<Character> stack = new Stack<>();
for (char c : str.toCharArray())
stack.push(c);
Optional<Character> optChar = stack.pop();
while (optChar.isPresent()) {
result.append(optChar.get());
optChar = stack.pop();
}
return result.toString();
}
Snippet 2.23: 反转字符串解决方案。源类名:StringReverse
访问goo.gl/UN2d5U以获取代码。
栈数据结构在计算机科学中广泛用于许多算法。在本节中,我们看到了如何使用链表动态实现它们。在下一节中,我们将看到如何使用数组以静态方式模拟栈和队列。
使用数组模拟栈和队列
栈和队列不一定需要是动态的。如果你知道你的数据需求是固定大小的,你可能希望有一个更简洁的实现。使用数组方法来模拟栈和队列可以保证你的数据结构只会增长到一定的大小。使用数组的另一个优点是,如果你可以接受静态数据结构,数组方法在内存效率上更高。静态数据结构的缺点是,队列或栈只能增长到最初分配的数组的最大固定大小。
使用数组实现栈首先需要初始化一个具有固定大小的空数组。然后,我们需要保持一个指向栈顶的索引指针,最初指向零。当我们向栈中推入项目时,我们将项目放置在这个索引处,并将指针递增一个。当我们需要弹出元素时,我们减少这个指针的值并读取值。这个过程在以下代码中展示:
public StackArray(int capacity) {
array = (V[]) new Object[capacity];
}
public void push(V item) {
array[headPtr++] = item;
}
public Optional<V> pop() {
if (headPtr > 0) return Optional.of(array[--headPtr]);
else return Optional.empty();
}
Snippet 2.24: 使用数组而不是链表实现栈。源类名:Stackarray
访问goo.gl/T61L33以获取代码
使用数组实现队列需要更多的思考。队列的困难在于结构从两端都被修改,因为它从尾部增长,从头部缩小。
当我们入队和出队队列的内容时,它似乎正在向数组的右侧移动。我们需要处理当内容达到数组末尾时会发生什么。为了使队列在数组中工作,我们只需让我们的数据在边缘环绕(想想吃豆人,图 2.10):

图 2.10:数组环绕类比
mod operator. When the pointer is larger or equal to the size of the array, it wraps around and starts again from zero. The same happens on the dequeue method, where we access and increment the head pointer in a similar fashion. The following code demonstrates it:
在Snippet 2.25中的实现没有在入队另一个项目之前检查循环缓冲区是否已满。在下一节中,将此检查作为练习给出。
public void enqueue(V item) {
array[tailPtr] = item;
tailPtr = (tailPtr + 1) % array.length;
}
public Optional<V> dequeue() {
if (headPtr != tailPtr) {
Optional<V> item = Optional.of(array[headPtr]);
headPtr = (headPtr + 1) % array.length;
return item;
} else return Optional.empty();
}
Snippet 2.25: 使用数组进行入队和出队。源类名:QueueArray
访问goo.gl/LJuYz9以获取此代码。
数组中的安全入队
我们需要编写一个安全的 enqueue() 方法,当队列满时将失败。
完成步骤:
-
修改前面代码中显示的入队和出队方法,以便入队返回一个布尔值,当队列满且无法接受更多元素时为
false。 -
按照以下方式实现方法签名:
public boolean enqueueSafe(V item)
public Optional<V> dequeueSafe()
- 以下 代码片段 2.26 提供了
enqueueSafe()方法的实现,当队列满时返回一个布尔值:
private boolean full = false;
public boolean enqueueSafe(V item) {
if (!full) {
array[tailPtr] = item;
tailPtr = (tailPtr + 1) % array.length;
this.full = tailPtr == headPtr;
return true;
}
return false;
}
代码片段 2.26:安全的入队和出队解决方案。源类名:QueueArray
前往 goo.gl/cBszQL 获取 dequeueSafe() 方法实现的代码。
我们已经看到如何使用静态数组结构而不是使用动态链表来实现队列和栈。这具有每个元素消耗更少内存的优点,因为链表必须存储指向其他节点的指针。然而,这以结构大小有限为代价。
活动:评估后缀表达式
场景
我们习惯于用 1 + 2 * 3 的形式编写数学表达式。这种表示法称为 中缀。使用中缀表示法,运算符始终位于两个运算符之间。还有一种不同的表示法称为后缀,其中运算符位于操作数之后。以下表格显示了此类表达式的示例:
| 中缀表达式 | 后缀表达式 |
|---|---|
| 1 + 2 | 1 2 + |
| 1 + 2 * 3 | 1 2 3 * + |
| (1 + 2) * 3 | 1 2 + 3 * |
| 5 + 4 / 2 * 3 | 5 4 2 / 3 * + |
目标
实现一个算法,该算法接受一个后缀字符串,评估它,并返回结果。
先决条件
-
在可用的类中实现以下方法
以下路径的 GitHub 书籍仓库:
public double evaluate(String postfix)
- 假设运算符和操作数总是由空格分隔,例如 "5 2 +"。输入字符串将类似于前面表格中显示的示例。
如果你已经设置了项目,可以通过运行以下命令来运行此活动的单元测试:
gradlew test --tests com.packt.datastructuresandalg.lesson2.activity.postfix*
如果你使用本节学习的一种数据结构,解决方案会变得简单得多。
完成步骤
-
使用栈数据结构来解决这个问题
-
从左到右开始处理表达式
-
如果遇到数字操作数,将其推入栈中
-
如果遇到运算符,从栈中弹出两个项目并相应地执行操作(加法、减法等),然后将结果推回栈中
-
一旦处理完整个表达式,结果应该位于栈顶
概述
在本章中,我们为即将到来的更复杂章节奠定了基础。在前几节中,我们看到了一个简单问题,如排序,可以有多个解决方案,它们都具有不同的性能特性。我们探索了三种主要实现,即冒泡排序、快速排序和归并排序。
在后面的章节中,我们介绍了数据结构,并研究了链表、队列和栈的各种实现和使用案例。我们还看到了一些数据结构如何作为构建块,在它们之上构建更复杂的数据结构。在下一章,我们将研究哈希表和二叉树,这两种重要且广泛使用的数据结构。
第三章:哈希表和二叉搜索树
在上一章中,我们通过查看数组、链表、队列和栈等数据结构来介绍数据结构的概念。在本章中,我们将使用一些这些基本结构来构建更复杂的数据结构。我们将从查看哈希表开始,这是一种用于快速键值查找的有用数据结构。在章节的第二部分,我们将学习一种支持范围查询的更复杂的数据结构,称为二叉树。
到本章结束时,你将能够:
-
描述哈希表的工作原理
-
实现两种处理哈希冲突的主要技术
-
描述不同的哈希选择
-
解释二叉树的术语、结构和操作
-
展示各种树遍历技术
-
定义平衡二叉搜索树
介绍哈希表
能够在集合中插入、搜索,并可选择性删除元素的数据结构称为数据字典。通常,使用的数据类型是键值对关联,我们插入键值对,但使用键来搜索以获取值。
哈希表为我们提供了一种快速的数据结构来组织这些键值对并实现我们的数据字典。由于快速查找和在内存数据存储中的易用性,它们在广泛的领域中非常有用。插入和搜索操作的平均运行时间复杂度为O(1)。
理解哈希表
让我们通过一个示例问题来帮助我们理解哈希表的需求。想象你是一名教师,负责一个最多容纳 30 名学生的班级。学生们每天坐在指定的课桌旁。为了使你的生活更轻松,你决定为每个课桌分配一个从 1 到 30 的连续编号。然后你使用这个编号来识别每个学生,并在输入课桌编号后使用你自行开发的程序调出学生的记录(见图 3.1)。这样,你可以快速查找学生的姓名、出生日期、笔记和考试历史等详细信息:

图 3.1:显示编号为八的课桌学生的记录的应用程序
在这个问题中,可以使用一个简单的数组来在内存中存储所有学生的记录。数组的每个位置可以包含一个学生记录。这将允许你使用index = deskNumber - 1的策略直接访问数组。如果在某一年你拥有的学生数量较少,并且不是所有的课桌都被占用,你将在相应的数组索引处放置 null。这种解决方案在图 3.2中显示。
这是一个直接寻址的示例,其中每个学生的记录都是通过一个键(课桌号)访问的。这种解决方案只能在可能的键范围足够小,可以放入直接在内存中的数组时使用:

图 3.2:直接寻址的示例
为了帮助我们确定我们使用内存的效率,我们可以测量负载因子。负载因子是一个简单的指标,显示我们的数据结构被充分利用的程度。当班级达到最大容量时,数组中的所有元素都将包含一个记录。我们说我们的数据结构的负载因子是 1(或 100%)。例如,如果只有 15 名学生中的 30 个空间注册了特定年份,则负载因子为 0.5(50%)。较低的负载因子值意味着我们正在低效使用并浪费内存。
现在,让我们将我们的例子扩展到包括一个班级,甚至整个学校,而不仅仅是教授一个班级,你现在已经被提升为整个学校的校长。在这个新的职位上,你想要为目前注册的每个人保存学生记录。你还想存储任何不再在学校的学生的历史记录。你决定使用国家身份证或护照号码作为唯一标识每个学生的键。假设这是一所美国或欧盟的学校,国家身份证或护照号码通常由九位或更多的数字组成。
由于我们的输入范围相当大,直接处理这个问题将非常不切实际。
由于美国护照号码(或国家身份证)通常是九位数字,我们不得不构建一个巨大的数组来存储任何可能的数字。对于一个九位数字的范围,数组的大小将是 1,000,000,000。假设每个指针是四字节,这个数组本身就会消耗近 4 GB!这个例子中的负载因子也会非常低。大多数数组将是空的,因为学校只有几千名现任和往届学生。
我们仍然可以将学生的记录存储在一个容量为几千的数组中。我们需要的只是找到一种方法将我们的输入键范围压缩到我们的数组索引范围内。本质上,这意味着将我们的九位数字护照映射到一个四位的数字。这项工作可以通过所谓的哈希函数来完成。哈希函数将接受一个键(我们的护照号码)并返回一个位于我们数组大小内的数组索引(见图 3.3)。
我们说哈希函数将我们的输入键宇宙映射到我们选择的哈希范围,在这个例子中是数组大小:

图 3.3:使用哈希函数
使用哈希函数使我们能够使用一个更小的数组,并节省大量内存。然而,有一个问题。由于我们正在将更大的键空间强制压缩到更小的空间中,存在多个键映射到同一个哈希数组索引的风险。这被称为冲突;我们有一个键哈希到已经填满的位置。如何处理冲突的策略以及哈希函数的选择构成了我们的哈希表。以下代码片段显示了一个 Java 接口,它定义了我们的哈希表 API。我们将在本章的后续部分逐步实现这个接口:
public interface HashTable<K,V> {
void put(K key,V value);
Optional<V> get(K key);
void remove(K key);
}
代码片段 3.1:Hashtable 接口。源类名:Hashtable
访问goo.gl/FK1q6k以获取此代码。
在 Java 中,类java.util.Hashtable和java.util.HashMap都实现了HashTable接口。这两个类的主要区别在于HashMap是非同步的,并允许 null 值。
在本节中,我们首先通过查看一个示例场景介绍了直接寻址。随后,我们将问题扩展到更大的键空间,展示了在这种情况下如何使用哈希表。在下一节中,我们将看到处理哈希表碰撞的两种常见解决方案。
处理链式碰撞
当两个键在我们的数组中哈希到同一个槽位时,我们该怎么办?覆盖数组中的元素不是一种选择,因为这会导致记录丢失。处理碰撞的一个常见技术是称为链式。在这个解决方案中,哈希表数据存储在实际数组之外。
链式背后的思想是,我们哈希数组中的每个条目都有一个指向其自己的链表的指针。我们添加到哈希表中的任何项目都存储在这些链表中。最初,数组中的每个条目都被初始化为包含一个空链表。每次我们在哈希表中插入特定数组槽位时,我们都会将其插入到与该位置关联的链表头部。这样,我们可以支持哈希碰撞。在已经占用的数组槽位上进行的另一个插入操作将导致其链表头部出现一个新项目。"图 3.4"展示了两个具有不同键的条目被哈希到同一个数组槽位的例子,导致这两个记录存储在链表中:

图 3.4:使用链表在单个哈希槽中链接多个条目
搜索特定键需要首先定位数组槽位,然后逐个遍历链表,寻找所需键,直到找到匹配项或到达链表末尾。"代码片段 3.2"展示了搜索(get)和插入(put)。删除(remove)操作可以通过代码片段后面的 URL 找到。我们使用 Java 的链表集合来实现这个哈希表。在构造函数中,数组使用给定的容量初始化,并且每个元素都填充了一个空链表。
使用 Java 的链表集合,我们可以在get(key)方法中搜索键时使用 Java 的 lambda 表达式。在搜索时,我们尝试将键与链表中的键匹配,并且只有在找到匹配项时才返回可选值。
使用 lambda 表达式还可以使我们通过仅调用带有键匹配谓词的removeif()方法(通过代码片段后面的 URL 可以找到删除操作)以干净的方式实现delete操作:
public void put(K key, V value) {
int hashValue = hashProvider.hashKey(key, array.length);
array[hashValue].addFirst(new Pair<>(key, value));
}
public Optional<V> get(K key) {
int hashValue = hashProvider.hashKey(key, array.length);
return array[hashValue].stream()
.filter(keyValue -> keyValue.getKey().equals(key))
.findFirst()
.map(Pair::getValue);
}
代码片段 3.2:链式哈希表。源类名:ChainedHashTable
访问此代码请前往 goo.gl/mrzQfY。
在 片段 3.2 中显示的搜索操作(get() 方法)的最佳运行复杂度对于包含 n 个项目的哈希表是当没有冲突时,结果是 O(1),而最坏的情况是有 n 个冲突,结果是 O(n)。
在 片段 3.2 中显示的 HashProvider 接口简单地提供了一个实现哈希函数的方法。当我们在下一节探索不同的哈希技术时,我们将实现此接口。链式哈希表的时间复杂度取决于我们的链表有多长。最佳情况是当我们插入哈希表中的每个项目都散列到不同的槽位时,也就是说,没有冲突。在最佳情况下,当每个链表只包含一个项目时,我们有 O(1) 的运行时间,并且可以直接访问任何项目。
最坏的情况是另一个极端,即每个项目都散列到相同的值,导致一个包含 n 个项目的链表。当这种情况发生时,性能下降到 O(n) 的时间来搜索所需的关键字。这是因为我们需要遍历 n 个节点的链表来搜索所需的关键字。
这种最坏的时间复杂度 O(n) 适用于所有哈希表,而不仅仅是链式哈希表。然而,平均而言,如果选择了正确的哈希函数,哈希表的运行性能可以接近 O(1)。
链式哈希表没有负载限制。即使在没有任何槽位为空的情况下,我们仍然可以通过继续向链表追加来向哈希表添加更多项目。这意味着链式哈希表的负载因子可以超过 1 的值。
链式哈希表是最流行的冲突解决实现方式。这是因为它们易于实现,提供了良好的性能,并且与一些其他技术不同,允许哈希表结构动态扩展,超过 1 的负载因子。在下一节中,我们将讨论另一种处理冲突的解决方案,称为 开放寻址。
使用开放寻址处理冲突
在前一节中,我们看到了如何使用每个数组位置的链表来处理冲突。链式哈希表将不断增长,没有任何负载限制。开放寻址只是处理哈希冲突的另一种方法。在开放寻址中,所有项目都存储在数组本身中,使得结构静态,最大负载因子限制为 1。这意味着一旦数组满了,就不能再添加任何项目。使用开放寻址的优点是,由于你不需要使用链表,你可以节省一点内存,因为你不需要存储任何指针引用。
然后,你可以使用这额外的内存来拥有一个更大的数组,并存储更多的键值对。要在开放地址哈希表中插入,我们首先对键进行哈希处理,然后将项目插入到哈希槽中,就像正常哈希表一样。如果槽位已被占用,我们搜索另一个空槽,并将项目插入其中。我们搜索另一个空槽的方式被称为探测序列。
一种简单的策略,如图 3.5所示,是通过查看下一个可用的槽位来搜索。这被称为线性探测,我们从哈希值处的数组索引开始,继续增加索引一个单位,直到找到一个空槽。在搜索键时也需要使用相同的探测技术。我们从哈希槽开始,继续前进,直到匹配到键或遇到一个空槽:

图 3.5:开放地址中的线性探测
下面的代码片段展示了线性探测插入的伪代码。在这个代码中,我们在找到哈希值后,通过增加指针一个单位,继续寻找一个空槽。
一旦我们到达数组的末尾,我们使用模数运算符将其绕回到开始。这种技术与我们在实现基于数组的栈时使用的技术类似。我们停止增加数组指针,要么当我们找到一个 null 值(空槽),要么当我们回到起点,这意味着哈希表已满。一旦我们退出循环,我们就存储键值对,但前提是哈希表没有满。
伪代码如下:
insert(key, value, array)
s = length(array)
hashValue = hash(key, s)
i = 0
while (i < s and array[(hashValue + i) mod s] != null)
i = i + 1
if (i < s) array[(hashValue + i) mod s] = (key, value)
3.3 节片段:使用线性探测插入的伪代码
搜索键的操作与插入操作类似。我们首先需要从键中找到哈希值,然后以线性方式搜索数组,直到我们遇到键、找到一个 null 值或遍历数组的长度。
如果我们要从我们的开放、地址哈希表中删除项,我们不能简单地从数组中删除条目并将其标记为 null。如果我们这样做,搜索操作将无法检查所有可能的位置,这些位置可能已经找到了键。这是因为搜索操作一旦找到 null 就会停止。
一种解决方案是在每个数组位置添加一个标志,表示已删除项目,但未将条目设置为 null。搜索操作可以修改为继续过去标记为已删除的条目。插入操作也需要更改,以便如果它遇到标记为已删除的条目,它将在该位置写入新项目。
线性探测有一个称为聚类的问题。这发生在一系列非空槽位连续出现时,会降低搜索和插入性能。一种改进的方法是使用称为二次探测的技术。这种策略与线性探测类似,但我们在使用二次公式h + (ai + bi²)探测下一个空槽位,其中h是初始哈希值,a和b是常数。图 3.6显示了使用a = 0和b = 1时线性探测和二次探测之间的差异。该图显示了两种技术探索数组的顺序。
在二次探测中,我们将3.3 节代码片段改为检查以下数组索引:
array[(hashValue + a*i + b*i²) mod s]

图 3.6 线性探测与二次探测的比较
虽然二次探测减少了聚类的效果,但它有一个称为次级聚类的问题。然而,这仍然会降低性能。此外,常数a和b以及数组大小需要仔细选择,以便探测可以探索整个数组。
在开放寻址哈希表中使用的另一种探测策略被称为双重哈希。这利用另一个哈希函数来确定从初始哈希值开始的步长偏移。在双重哈希中,我们使用表达式h + ih'(k)来探测数组,其中h是哈希值,h'(k)是对键应用的一个辅助哈希函数。探测机制与线性探测类似,我们从i为零开始,每次冲突时增加一。这样做会导致每h'(k)步探测数组。双重哈希的优势在于探测策略在每次键插入时都会改变,从而减少了聚类的可能性。
在双重哈希中,必须注意确保整个数组被探索。这可以通过各种技巧实现。例如,我们可以将数组大小设置为偶数,并确保辅助哈希函数只返回奇数。
执行线性探测搜索操作
这里的目的是为线性探测中的搜索操作开发伪代码。
执行以下步骤:
- 编写类似于3.3 节代码片段的伪代码以显示搜索操作。如果键在哈希表中找不到,则操作应返回 null。搜索函数的签名如下:
search(key, array)
- 可以按照以下方式开发伪代码:
search(key, array)
s = length(array)
hashValue = hash(key, s)
i = 0
while (i < s and array[(hashValue + i) mod s] != null
and array[(hashValue + i) mod s].key != key)
i = i + 1
keyValue = array[(hashValue + i) mod s]
if (keyValue != null && keyValue.key == key)
return keyValue.value
else return null
3.4 节代码片段:使用线性探测的搜索操作的伪代码
在本节中,我们看到了另一种处理哈希冲突的方法,即保持所有项目在数组本身中,节省内存,但限制了结构的静态性。在下一个小节中,我们将详细介绍一些可用的各种哈希函数。
余数和乘法哈希函数
对于哈希表,哈希函数将特定键的空间映射到一个更小的数字范围。更正式地说,哈希函数f将特定数据类型的键映射到固定区间[0,..., N - 1]中的整数。我们说f(x)对x的值进行哈希。
哈希函数只能接受数值数据类型。为了使我们能够在更复杂的数据类型上使用哈希表,我们通常需要将这些类型转换为数值表示。这种转换因数据类型的不同而不同。例如,一个字符可以转换为它的 UTF-8(或 ASCII)数值等效。转换整个字符串可以通过分别转换每个字符然后使用一种策略将字符组合成一个值来完成。
在 Java 中,hashCode()方法将对象转换为数值表示,这可以由哈希函数使用。它在对象类中存在,可以通过自定义实现来重写。
有许多技术可以帮助我们将广泛的键映射到更小的键集中。一个理想的哈希函数是那种将冲突减少到最小程度的函数。换句话说,当使用一个好的哈希函数时,每个键填充我们数组中任何槽位的概率都是相同的。在实践中,除非我们知道输入分布,否则找到理想的哈希函数是非常困难的。
实现哈希函数的一个简单技术被称为余数法。哈希函数简单地接受任何数值键,将其除以表大小(数组大小),并使用得到的余数作为哈希值。这个值然后可以用来作为数组的索引。
以下代码展示了如何使用取模运算符在 Java 中实现余数哈希方法:
public int hashKey(Integer key, int tableSize) {
return key % tableSize;
}
代码片段 3.5:余数法。源类名:RemainderHashing
访问goo.gl/wNyWWX以获取此代码。
如果在选择适当的表大小时不小心,余数方法可能会导致许多冲突。再次考虑本节开头给出的例子,我们使用学生的护照或国家身份证号来识别学校中的学生。为了演示这个问题,我们使用了一个大小为 1,000 个元素的基于数组的哈希表。碰巧的是,在该学校所在的国家,护照号码的最后四位代表护照持有人的出生年份。
在这个场景中使用余数方法时,所有同一年出生的学生都会哈希到相同的值,导致哈希表上发生大量冲突。
选择一个更好的表大小是使用一个素数,理想情况下不要太接近 2 的幂。例如,在我们的例子中,1,447 是一个不错的选择,因为它既不太接近 1,024 或 2,048(2 的 10 次方和 2 的 11 次方),也是素数。使用这个值作为我们的示例表大小将减少冲突。
使用余数方法限制了我们对哈希表大小的选择(以减少冲突的机会)。为了解决这个问题,我们可以使用不同的哈希技术,称为乘法方法。在这个方法中,我们将键乘以一个常数双值,k,在范围0 < k < 1内。然后我们从结果中提取分数部分,并将其乘以我们哈希表的大小。
然后哈希值是这个结果的下界:

其中:
-
k 是介于 0 和 1 之间的十进制数
-
s 是哈希表的大小
-
x 是键
实现哈希表的乘法方法
目标是开发一个 Java 代码,用于实现哈希表的乘法方法。
执行以下步骤:
- 实现一个类,其中包含一个方法,该方法接受一个整数并使用本节中所示的乘法方法返回哈希值。常数k作为类的构造函数传入。方法签名应该是:
int hashKey(int key, int tableSize)
- 以下代码显示了乘法哈希函数的实现:
private double k;
public MultiplicationHashing(double k) {
this.k = k;
}
public int hashKey(Integer key, int tableSize) {
return (int) (tableSize * (k * key % 1));
}
片段 3.6:乘法方法的解决方案。源类名:MultiplicationHashing。
前往 goo.gl/xJ7i1b 访问此代码。
在本节中,我们看到了两种基本的计算哈希值的技术,即余数方法和乘法方法。这两种策略在哈希表中都得到了广泛的应用。
在下一节中,我们将检查另一种机制,称为通用哈希。
通用哈希
乘法哈希和余数哈希方法都存在一个共同的弱点。如果攻击者知道我们哈希函数的细节(表大小和任何常数值),他/她可以设计一个输入键序列,导致每个项目都发生冲突,将我们的哈希表变成链表,并减慢我们的程序。为了解决这个问题,可以使用一种称为通用哈希的哈希技术。
通用哈希通过在执行开始时从通用哈希函数集中选择一个随机函数来工作。这使得攻击者难以猜测所使用的哈希技术的确切工作方式。通过使用这种技术,相同的键序列将在每次执行中产生不同的哈希值序列。
一组大小为n的哈希函数H,其中每个函数将键的集合∪映射到固定范围0, s),对于所有成对,其中a, b ∈ ∪,a ≠ b,且h(a) = h(b),h ∈ H的概率小于或等于n/s。
我们可以通过使用两个整数变量i在范围[1, p)内,和j在范围[0, p)内来构造我们的通用哈希函数集,其中p是大于输入键宇宙可能值的任何值的质数。然后我们可以使用以下方法从这个集合中生成任何哈希函数:

图 3.8:展示简单的二叉树关系
图 3.9展示了应用于二叉树的更多术语。在这个图中,我们还展示了二叉树节点可以通过显示存储不同形状的节点来持有数据项。顶级节点被称为根节点。在树结构中,根节点是唯一没有父节点的节点。没有子节点的节点被称为叶子节点。树的高度是从根节点到最远叶子节点所需跳跃的数量。该图展示了一个高度为 2 的树的示例。
树的高度是一个重要的度量,因为它会影响性能。树越浅(高度越小),树结构的表现力越强。

图 3.9:二叉树术语
与链表类似,二叉树结构是通过指针和节点对象来建模的。在链表节点中,我们只有一个指向下一个节点的指针。同样,在二叉树节点中,我们有两个指针,每个指针链接到一个子节点。这些是左子节点指针和右子节点指针。下面的代码片段展示了如何使用 Java 类来建模二叉树节点:
public class BinaryTreeNode<K,V> {
private BinaryTreeNode<K,V> left;
private BinaryTreeNode<K,V> right;
private K key;
private V value;
public BinaryTreeNode(K key, V value) {
this.key = key;
this.value = value;
}
代码片段 3.8:二叉树节点类。为了简洁,省略了一些 getter 和 setter 方法。源类名:BinaryTreeNode
前往 goo.gl/D6Jvo2 访问此代码。
因此,我们可以有一个表示二叉树本身的另一个类,其中将实现操作。这个类只需要持有根节点的指针,因为任何节点都可以从根节点开始,通过导航向下到达。在下面的代码片段中,我们展示了声明二叉树的接口:
public interface BinaryTree<K,V> {
void put(K key,V value);
Optional<V> get(K key);
}
代码片段 3.9:二叉树接口。源类名:BinaryTree。
前往 goo.gl/jRcLhu 访问此代码。
在本节中,我们介绍了二叉树的结构和术语。然后我们学习了如何使用 Java 类来建模每个节点。在下一节中,我们将通过介绍二叉搜索树并实现插入和搜索操作来继续构建这些概念。
二叉搜索树操作
二叉搜索树是数据以有序方式组织的普通二叉树。考虑我们在上一节中遇到的问题,即学校使用护照号码作为键来保存学生的记录。图 3.10展示了如何在二叉树中组织数据的示例。
注意在每个节点,左子节点的键总是小于它自己的键。另一方面,右子节点的键更大。如图所示,根节点有一个左子节点,其键的值小于根键。另一方面,右子节点的键的值大于根。
这条规则在整个树中重复出现。在二叉搜索树中,左子节点总是比父节点具有更小的键,而右子节点将具有更大的键。利用这个二叉搜索树的属性,我们可以在树结构上创建高效的操作:

图 3.10:二叉搜索树的示例
由于这个简单的规则,树表现出重要的属性。例如,注意所有是根节点左子节点后代的节点都具有比根节点更小的键。这个属性对树中的任何节点都有效。一个节点的左子树上的所有键总是具有更小的键,反之亦然。
在二叉搜索树中进行搜索需要我们遵循一些简单的指令。我们从根节点开始,在每一个节点,我们问自己:“我们要找的键是否等于、小于还是大于这个节点的键?
”如果键相等,我们就完成了,并且我们已经找到了我们的节点。如果键小于,我们跟随左子节点指针,否则我们跟随右子节点。我们重复这一步骤,直到找到我们的键或遇到一个null 子节点指针。
二叉搜索树的另一个重要属性是能够轻松地找到树中的最大和最小键。在二叉树中找到最大键很容易。概念上,这是最右边的节点。这可以通过从根节点开始,并且总是选择右子节点,直到没有更多的右子节点可以选择来实现。对于最小键,这个逻辑是相反的(选择左子节点)。
以下代码片段显示了搜索实现。在这个实现中,我们使用递归的力量来执行搜索。我们首先通过检查根节点是否为 null 来检查树是否为空。如果存在根节点,我们比较键并返回值或递归搜索子节点。为了比较键,我们假设提供的键实现了可比较接口。使用 Java 的可选扁平映射使我们的实现更加简洁:
public Optional<V> get(K key) {
return Optional.ofNullable(root).flatMap(n -> get(key, n));
}
private Optional<V> get(K key, BinaryTreeNode<K, V> node) {
if (((Comparable) key).compareTo(node.getKey()) == 0)
return Optional.of(node.getValue());
else if (((Comparable) key).compareTo(node.getKey()) < 0)
return node.getLeft().flatMap(n -> get(key, n));
else
return node.getRight().flatMap(n -> get(key, n));
}
碎片 3.10:二叉搜索树搜索操作。源类名:SimpleBinaryTree。
前往goo.gl/xE2GvH访问此代码。
Java 中,在可比较接口中的objectA.compareTo(objectB)方法返回一个负整数、零或正整数,这取决于objectA是小于、等于还是大于objectB。因此,以下语句:
((Comparable) key).compareTo(node.getKey()) < 0
概念上等同于以下内容:
key < node.getKey()
在二叉树中插入的逻辑与搜索操作相同。我们从根节点开始,继续寻找需要创建新节点的地方。这将在下一个代码片段中展示。像搜索操作一样,这个 Java 实现也是递归的。如果根节点不存在,我们只需创建一个新的,否则我们根据键值的值递归地通过选择左子节点或右子节点来插入键值对。
我们有三个递归调用的停止条件,如下所述:
-
当键值等于节点上的键值时,我们简单地覆盖条目
-
当左子节点不存在时,我们创建一个新的节点,包含键值对
-
当右子节点不存在时,我们创建一个新的节点,包含键值对
以下代码演示了二叉搜索树的插入操作:
if (((Comparable) key).compareTo(node.getKey()) == 0) {
node.setKey(key);
node.setValue(value);
} else if (((Comparable) key).compareTo(node.getKey()) <0) {
if (node.getLeft().isPresent())
put(key, value, node.getLeft().get());
else
node.setLeft(new BinaryTreeNode<>(key, value));
} else {
if (node.getRight().isPresent())
put(key, value, node.getRight().get());
else
node.setRight(new BinaryTreeNode<>(key, value));
}
代码片段 3.11:二叉搜索树插入操作。源类名:SimpleBinaryTree
前往 goo.gl/hHpeiP 访问此代码。
二叉树删除需要将子树结构与多种模式匹配,并对每种情况执行不同的操作。在某些情况下,它要求你将子树与被删除节点的父节点连接起来,这可能相当复杂。因此,删除算法超出了本书的范围。有关删除操作的信息,你可以参考以下来源:
-
《计算机程序设计艺术,第 3 卷:排序与搜索》,作者 Donald Knuth。
-
Paul E. Black, "binary search tree", in Dictionary of Algorithms and Data Structures [online], Vreda Pieterse and Paul E. Black, eds. January 26, 2015. 可在
www.nist.gov/dads/HTML/binarySearchTree.html获取。
在二叉树中搜索最小键值
目标是在 Java 中实现一个方法来搜索二叉树中的最小键值。
执行以下步骤:
- 向二叉树实现中添加以下签名的函数:
public Optional<K> minKey()
-
该方法需要找到树中的最小键值并返回它。如果树为空,它应返回一个空的可选对象。
-
在二叉搜索树中寻找最小值需要我们始终跟随左子节点,直到我们到达一个没有左子指针的节点。以下代码演示了这一点:
public Optional<K> minKey() {
return Optional.ofNullable(root).map(this::minKey);
}
private K minKey(BinaryTreeNode<K, V> node) {
return node.getLeft().map(this::minKey).orElse(node.getKey());
}
代码片段 3.12:最小键值操作。源类名:SimpleBinaryTree。
前往 goo.gl/YbZz6i 访问此代码。
在本节中,我们介绍了二叉搜索树,并探讨了如何使用它们来组织键值对。我们还看到了如何使用二叉搜索树进行简单的范围查询,例如查找最大和最小键值。在下一节中,我们将学习所有不同的方法来遍历二叉搜索树。
遍历二叉搜索树
遍历二叉树是逐个节点地遍历树并对其中的数据进行某种操作(如打印键值对)的过程。有两种主要的技术来执行树遍历:深度优先搜索和广度优先搜索,分别称为 DFS 和 BFS。
在深度优先搜索中,算法沿着树节点的路径向下搜索,直到无法再前进。一旦无法再前进,它就会回溯并发现任何剩余的未探索分支。以下代码展示了递归实现。在这个遍历方法中,根据动作在方法中执行的位置,会产生不同的输出序列。
在前序执行中,我们一旦发现新节点就立即执行操作。另一方面,后序执行是在一个节点的两个子节点都已被探索并且即将回溯时进行的。中序执行是在处理左子节点之后但在处理右子节点之前进行的。当使用中序遍历时,二叉搜索树中的键将按升序处理:
public void printDfs() {
Optional.ofNullable(root).ifPresent(this::printDfs);
}
private void printDfs(BinaryTreeNode<K, V> node) {
//System.out.println("PREORDER " + node.getKey());
node.getLeft().ifPresent(this::printDfs);
System.out.println("INORDER " + node.getKey());
node.getRight().ifPresent(this::printDfs);
//System.out.println("POSTORDER " + node.getKey());
}
代码片段 3.13:深度优先搜索。源类名:SimpleBinaryTree
前往goo.gl/xMzkbE访问此代码。
在广度优先搜索遍历中,算法逐层探索二叉树,从左到右。遍历从根节点开始,以叶节点结束。一个示例二叉树的输出显示在图 3.11中。为了实现二叉树的 BFS 遍历,我们可以使用初始化为包含根节点的队列。然后,当队列不为空时,我们读取队列上的第一个节点,处理它,并将左子节点首先然后是右子节点添加到队列中:

图 3.11:二叉树上的广度优先搜索
我们如下展示其伪代码:
breadthFirstSearch(root)
if (root != null)
queue = createQueue()
enqueue(queue, root)
while (not isEmpty(queue))
node = dequeue(queue)
process(node)
if (node.left != null) enqueue(queue, node.left)
if (node.right != null) enqueue(queue, node.right)
代码片段 3.14:广度优先搜索的伪代码
如果我们将队列替换为栈,代码片段 3.14中显示的算法将从广度优先搜索变为非递归的深度优先搜索。实际上,实现非递归 DFS 的方法是利用栈。
活动:在 Java 中实现 BFS
场景
我们被要求编写代码来实现一个算法,该算法逐层、从左到右搜索二叉树。遍历从根节点开始,以叶节点结束。
目标
在 Java 中应用 BFS 遍历。
完成步骤
-
在 Java 中实现前面代码中显示的算法。
-
使用 Java
LinkedList集合实现伪代码中显示的队列。方法签名应如下所示:
public void printBfs()
在本节中,我们学习了各种遍历二叉树的方法以及每种策略产生的不同排序。我们还看到了这些算法可以以递归和迭代的方式实现。在下一节中,我们将讨论一种更严格的二叉搜索树类型,确保我们的数据结构即使在最坏输入情况下也能保持良好的性能。
平衡二叉搜索树
二叉搜索树的性能与其高度成正比。这是因为搜索和插入操作从根节点开始,逐个节点向下进行,每个步骤都进行键比较。树越高,需要的步骤就越多。因此,如果我们确定二叉树相对于其输入的最大可能高度,我们就可以找出最坏情况下的运行复杂度。
如果我们在二叉树中插入键,总是添加到父节点的右子节点,最终得到的树类似于图 3.12 左侧所示的树。在这个图中,每个节点只使用了右子指针。我们得到一个高度为n的树,其中n是我们数据结构中添加的项目数量。当键输入模式有序时,我们会得到这种单侧树。
在图 3.12 所示的示例中,我们首先插入 5 作为根节点,然后添加 7 作为右子节点,接下来是 12 作为下一个右子节点,以此类推。总是插入递增的数字会导致下一个节点在右侧。这种输入模式使得我们的二叉搜索树操作(搜索、插入和删除)在最坏情况下的运行时间为O(n):

图 3.12:不平衡与平衡的二叉树
如果我们从一个大数字开始并每次都减小它,结果也会类似。我们最终得到图 3.12 左侧所示的树的镜像。
当键插入顺序为"1,2,3,4,5,6,7"时,在正常二叉搜索树中进行 BFS 遍历的输出将与输入顺序相同,即"1,2,3,4,5,6,7"。我们在每次插入时都会创建一个新的右子节点。由于 BFS 遍历是逐级处理的,从根节点开始,遍历输出与输入相同。
在图 3.12 的右侧,我们展示了另一个包含相同键的二叉树。这个二叉树已经通过重构而变得较短。请注意,这个树仍然是有效的,也就是说,左子节点总是有一个小于其父节点的键,反之亦然。一个平衡的二叉树的高度大约为log[2]n。
如果我们能够在每次插入时以O(log n)或更好的时间复杂度重新平衡二叉搜索树,那么插入和搜索的最坏情况运行时间性能也将是O(log n)。
幸运的是,存在各种算法可以在你执行插入操作时自动平衡树结构。以下是一些最常见的算法:
-
AVL 树
-
红黑树
-
AA 树
所有这些算法都会检查在关键插入时二叉树是否遵循特定的平衡规则。如果由于插入新节点,树变得不平衡,则自平衡算法启动并重新结构化一些节点以保持树平衡。重新平衡节点的技术依赖于树旋转,在特定条件下,一些父节点和子节点会旋转。重要的是,这些修改在最坏情况下也以 O(log n) 的复杂度执行,这意味着在二叉树上的插入和搜索的最坏运行时间复杂度都是 O(log n)。在本节中,我们将检查树旋转,因为它们是大多数自平衡树的基本操作。
更多关于自平衡树的信息,您可以参考以下资源:
计算机程序设计艺术,第 3 卷:排序与搜索,由唐纳德·克努特著。
Paul E. Black, "红黑树", 在 算法与数据结构词典 [在线], Vreda Pieterse 和 Paul E. Black 编著. 2015 年 4 月 13 日. 可在以下链接中找到:www.nist.gov/dads/HTML/redblack.html.
图 3.13 展示了左右旋转的示例。注意正在旋转的节点(右旋转中的节点 5 和左旋转中的节点 9)最终成为新的父节点。重要的是,子指针重新分配的数量是恒定的。在树旋转后,二叉搜索树的性质仍然有效,即,左子指针始终指向一个较小的键,该键指向其父节点,反之亦然。这意味着我们可以执行任意数量的这些树旋转,我们的二叉搜索树仍然有效:

图 3.13:左右树旋转
片段 3.15 展示了我们在 Java 中如何执行右旋转。该方法接受需要旋转的顶层节点(图 3.13 中的节点 5)及其父节点。该方法需要父节点,因为它必须重新分配其子指针。在树节点上执行相反的右旋转是以下方法的镜像:
public void leftRotate(BinaryTreeNode<K, V> nodeX,
BinaryTreeNode<K, V> parent) {
BinaryTreeNode<K, V> nodeY = nodeX.getRight().get();
nodeX.setRight(nodeY.getLeft().orElse(null));
if (parent == null)
this.root = nodeY;
else if (parent.getLeft().filter(n -> n == nodeX).isPresent())
parent.setLeft(nodeY);
else
parent.setRight(nodeY);
nodeY.setLeft(nodeX);
}
片段 3.15:左树旋转的 Java 实现。源类名:SimpleBinaryTree
访问goo.gl/Ts3JBu以获取此代码。
应用右树旋转
目标是在 Java 中实现右树旋转。
将 片段 3.15 修改为使方法执行右树旋转而不是左树旋转。以下代码显示了所需的修改:
public void rightRotate(BinaryTreeNode<K, V> nodeX,
BinaryTreeNode<K, V> parent) {
BinaryTreeNode<K, V> nodeY = nodeX.getLeft().get();
nodeX.setLeft(nodeY.getRight().orElse(null));
if (parent == null)
this.root = nodeY;
else if (parent.getRight().filter(n -> n == nodeX).isPresent())
parent.setRight(nodeY);
else
parent.setLeft(nodeY);
nodeY.setRight(nodeX);
}
片段 3.16:右树旋转的 Java 实现。源类名:SimpleBinaryTree
访问goo.gl/KKDWUa以获取此代码。
右旋是左旋的精确镜像。只需将所有左引用更改为右引用,反之亦然。
在本节中,我们看到了如何通过使用树旋转来平衡数据结构,从而提高二叉搜索树的性能。这使得树保持较短的长度,运行时间复杂度为O(log n)。
活动:在以中序遍历树时检索元素的后续节点
场景
我们需要编写一个方法,该方法接受一个键作为参数,返回在二叉搜索树中找到的下一个中序键。如果作为参数给出的键未找到,则该方法应返回下一个中序键。如果二叉树为空或所有存储的键都小于参数,则返回值应为空。例如,使用存储在二叉搜索树中的集合{10, 13, 52, 67, 68, 83}:
-
输入 13 的结果为 52
-
输入 67 的结果为 68
-
输入 55 的结果为 67
-
输入 5 的结果为 10
-
输入 83 的结果为
Optional.empty -
输入 100 的结果为
Optional.empty -
在空二叉树上输入任何值的结果为
Optional.empty
中序后继者和前驱者算法有许多应用。例如,假设你需要在某个体育赛事中保持一个排行榜,而你只想显示前三名运动员。如果你将数据存储在二叉搜索树中,你可以找到最大键,然后计算出下一个两个前驱节点。
解决方案需要具有O(log n)的运行时间复杂度。
目标
当树以中序遍历的方式检索一个元素的后续节点。
先决条件
实现以下方法,该方法在InOrderSuccessorBinaryTree类中提供,该类扩展了SimpleBinaryTree类,可在以下链接在 GitHub 上找到:
public Optional<K> inOrderSuccessorKey(K key)
如果你已经设置了你的项目,你可以通过运行以下命令来运行此活动的单元测试:
gradlew test --tests com.packt.datastructuresandalg.lesson3.activity.inordersuccessor*
完成步骤
-
首先使用非递归搜索操作找到键值等于或小于输入的第一个节点
-
意识到中序后继者只能位于两个地方之一,要么是这个节点的父节点,要么是这个节点右子树(如果有)的最小键(键值最小的节点)
摘要
在本章中,我们研究了实现数据字典操作中最常用的两种数据结构。哈希表提供了快速的内存插入和查找操作。此外,二叉树还赋予我们执行各种范围查询的能力,例如后继、前驱、最小值和最大值。在本章中,我们看到了这两种数据结构的示例以及这些操作的实现。
第四章:算法设计范式
在上一章中,我们学习了哈希表和二叉搜索树。在这一章中,我们将探讨算法设计范式。这些设计模式可以被视为激发一类算法设计的通用方法或方法。
正如算法是比计算机程序更高层次的抽象一样,算法设计范式是比算法更高层次的抽象。在设计算法时,选择算法范式是一个重要的决定。
本章将重点介绍以下三个算法范式:
-
贪心
-
分治
-
动态规划
通过熟悉这些更高层次的抽象,你可以在设计算法时做出更明智的决定。
在前一章中,我们遇到了归并排序和快速排序算法,它们是分治范式的例子。正如其名所示,这两个算法都将输入分割成更小的部分,然后递归地解决(征服)。
显然,还有更多的算法设计范式,但这三个已经涵盖了广泛的问题范围。本书中未讨论的其他范式包括回溯和剪枝搜索。甚至还有一些范式专注于计算机科学的特定分支。计算几何中的扫描线算法就是这类范式的例子。
到本章结束时,你将能够:
-
描述贪心、分治和动态规划算法范式
-
分析使用描述的范式解决的常见问题
-
列出每个范式要解决的问题的性质
-
解决一些解释每个范式适用性的知名问题
介绍贪心算法
算法通常经过一系列步骤,其中每个步骤你都有一个选择集。贪心算法,正如其名所示,在每个步骤都遵循局部最优的启发式方法,希望达到全局最优。为了更好地理解我们所说的这个,让我们引入一个问题。
活动选择问题
彼得是一个充满活力的人,通常在给定的一天里有很多事情要做。然而,由于他想做的事情很多,他通常无法在一天内完成所有的事情。他通常在醒来后写下他必须做的事情的清单,包括它们的时间范围。然后,看着这个清单,他制定了一天的计划,试图尽可能多地安排活动。
由于他是一个充满活力的人,他通常会快速完成这个过程,发现自己一天中做的活动比可能做的要少。你能帮助他根据他的日程表最大化他一天内能做的事情的数量吗?彼得的一个日程表示例如下表所示:
| ID | Activity | Time Span |
|---|---|---|
| 1 | 整理他的房间 | 10:00 - 12:00 |
| 2 | 去参加摇滚音乐会 | 20:00 - 23:00 |
| 3 | 在当地俱乐部下棋 | 17:00 - 19:00 |
| 4 | 洗澡 | 10:00 - 10:30 |
| 5 | 和朋友吃晚餐 | 19:00 - 20:30 |
| 6 | 玩文明 VI | 21:30 - 23:00 |
| 7 | 和朋友吃午餐 | 12:30 - 13:30 |
| 8 | 去电影院 | 20:00 - 22:00 |
| 9 | 在公园骑自行车 | 17:00 - 19:30 |
| 10 | 去海滩 | 16:00 - 19:00 |
| 11 | 去图书馆 | 15:00 - 17:00 |
表 4.1:彼得的时间表
这被称为活动选择问题。问题是要安排几个相互竞争的活动,这些活动需要使用一个共同的资源(在这种情况下是彼得),目标是选择一个最大数量的相互兼容活动集合。
换句话说,我们正在尝试找到彼得在一天内可以执行的最大活动集合。每个活动 (a[i]) 都有一个开始时间 (s[i]) 和一个结束时间 (f[i]). 如果两个活动 a[i] 和 a[j] 的区间 (s[i], f[i]) 和 (s[j], f[j]) 不重叠,例如,s[i] ≥ f[j] 或 s[j] ≥ f[i],则认为这两个活动是兼容的。
观察彼得的日程安排,并将开始和结束时间转换为一天开始以来的分钟数,我们可以得到以下表格。要将时间转换为一天开始以来的分钟数,我们将小时数乘以 60 并加上分钟数。例如,activity 1 从 10:00 到 12:00. 10:00 是从一天开始以来的 600 (1060 + 0)* 分钟,12:00 是从一天开始以来的 720 (1260 + 0)* 分钟:
| ai | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| si | 600 | 1200 | 1020 | 600 | 1140 | 1290 | 750 | 1200 | 1020 | 960 | 900 |
| fi | 720 | 1380 | 1140 | 630 | 1230 | 1380 | 810 | 1320 | 1170 | 1140 | 1020 |
表 4.2:彼得活动的开始和结束时间
对于这个例子,子集 {a[1], a[3], a[5], a[6]} 包含相互兼容的活动,因为它们的执行时间不重叠。它不是一个最大子集(也就是说,我们可以找到一个包含更多活动数量的集合),因为子集 {a[3], a[4], a[5], a[6], a[7], a[11]} 更大(注意活动的顺序是 a[4], a[7], a[11], a[3], a[5] 和 a[6])。实际上,它是一个更大的相互兼容活动子集。它不是唯一的:另一个可能更大的子集是 {a[1], a[7], a[11], a[3], a[5], a[6]}。
我们应该如何解决这个问题,以找到最大数量的相互兼容活动集合?结果是我们应该进行贪心选择。这意味着在算法的每一步中,从我们还可以执行的活动集合中,我们应该选择一个贪心选择。
贪心选择可能不是立即的,但你可以直观地认为我们应该选择尽可能让彼得可以参加更多其他活动的活动。
我们可以有两种方法来解决这个问题,如下所示:
-
总是选择开始时间最早的活动;然而,我们可以选择一个在所有其他活动之后结束的活动。
-
选择耗时最少的活动;然而,我们可以有一个小的活动与两个或更多非重叠活动重叠(例如,活动 [1, 4),[3, 5) 和 [4, 8))。
因此,这两种方法都不适用。
从我们能够选择的活动集合中,我们必须选择第一个完成的活动,因为那将是使彼得能够尽可能多地参与后续活动的活动。如果我们按完成时间对活动进行排序,那么我们就可以始终选择第一个与彼得最后选择的活动兼容的活动。
解决活动选择问题
如前所述,用 Java 实现贪心算法以解决活动选择问题。
解决活动选择问题的算法的一个可能的实现如下:
Collections.sort(sortedActivities, (o1, o2) -> Integer.signum(o1.finish - o2.finish));
if (sortedActivities.size() > 0)
selected.add(sortedActivities.get(0));
for (int i = 1; i < sortedActivities.size(); i++)
if (sortedActivities.get(i).start >= selected.get(selected.size() - 1).finish)
selected.add(sortedActivities.get(i));
导航至 goo.gl/xYT2Ho 以访问完整的代码。
在按完成时间对活动排序后,算法的选择部分运行在 O(n) 时间内。由于我们无法在 O(n) 时间内排序,因此该算法的整体复杂度受排序算法的复杂度限制。如前几章所见,我们可以以 O(nlog(n)) 的时间进行排序,因此这是我们为活动选择问题设计的算法的运行时间复杂度。算法看起来不错,但我们如何确保它总是到达最优解呢?
贪心算法的成分
每个贪心算法都有两个基本成分是共通的。它们如下:
-
最优子结构属性
-
贪心选择属性
最优子结构属性
使用贪心方法解决优化问题的第一步是描述最优解的结构。如果问题内的一个最优解包含子问题的最优解,则问题表现出最优子结构属性。
直观上,我们可以认为活动选择问题表现出最优子结构属性,即如果我们假设一个给定的活动属于一个最大尺寸的相互兼容活动集合,那么我们只需从在活动开始之前完成并且在该活动结束后开始的活动中选择最大尺寸的相互兼容活动集合。这两个集合也必须是兼容活动的最大集合,以便它们可以展示这个问题的最优子结构。
形式上,我们可以证明活动选择问题表现出最优子结构。假设我们按完成时间的单调递增顺序对活动集合进行排序,那么以下对于活动 i 和 j 可以成立:
f[i] ≤ f[j] if i ≤ j
而 f[i] 表示活动 i 的完成时间。
假设我们用 S[ij] 表示活动集合,这些活动在活动 a[i] 完成后开始,并在活动 a[j] 开始前完成。因此,我们希望找到 S[ij] 中相互兼容的最大活动集。
假设集合是 A[ij],并包括活动 a[k]。通过将 a[k] 包含在最优解中,我们剩下两个子问题,即在该集合 S[ik] 和集合 S[kj] 中找到相互兼容的最大活动子集,这可以表示如下:
A[ij] = A[ik] ∪ {a[k]} ∪ A[kj]
因此,在 S[ij] 中相互兼容的最大活动集的大小由以下公式给出:
|A[ij]| = |A[ik]| + |A[kj]| + 1
如果我们能在 S[kj] 中找到一个相互兼容的活动集 A'[kj],其中 |A'[kj]| > |A[kj]|,那么我们可以在 S[ij] 的子问题的最优解中使用 A'[kj] 来代替 A[kj]。但这样,我们就会得到与假设 A[ij] 是最优解相矛盾的东西:
|A[ik]| + |A'[kj]| + 1 > |A[ik]| + |A[kj]| + 1 = |A[ij]|
贪婪选择属性
在寻找问题的可能解时,我们通常考虑各种解,我们称之为解空间。
当试图找到问题的最佳解时,我们通常对全局最优解感兴趣,即从所有可能解的整个集合中的最优解。
然而,解空间可以表现出其他最优解。也就是说,我们可以有局部最优解,它们是在可能解的小邻域内的最优解。
贪婪选择属性表明,从一个局部最优解我们可以达到全局最优解,而不必重新考虑已经做出的决策。
在彼得的活动选择问题中,我们通过总是从可用活动集中选择最早完成时间的活动来应用贪婪选择。
直观上,我们可以认为这个问题在以下意义上表现出贪婪选择属性:如果我们有一个最大尺寸的子集,并且用完成时间更早的活动替换该子集中的活动,我们总是剩下最大尺寸的子集,这使得总是选择最早完成时间的安全。
可以证明贪婪选择总是某些最优解的一部分。
让我们尝试证明,对于任何非空子问题 S[k],如果 a[m] 是 S[k] 中完成时间最早的活动,那么 a[m] 包含在 S[k] 的某个相互兼容的最大尺寸活动子集中。
为了证明这一点,让我们假设以下情况:
-
A[k] 是 S[k] 中相互兼容的最大尺寸活动子集
-
a[j] 是 A[k] 中完成时间最早的活动
如果 a[j] = a[m],我们就完成了。如果 a[j] != a[m],我们可以尝试将 a[j] 在 A[k] 中替换为 a[m],产生集合 A'[k] = A[k] - {a[k]} ∪ {a[m]}。
我们可以安全地这样做,因为A[k]中的活动是互斥的。a[j]是A[k]中第一个完成的活动,且f[m] <= f[j]。
由于|A'[k]| = |A[k]|,我们得出结论,A'[k]是S[k]相互兼容活动的最大子集,并且它包括a[m]。
直觉通常帮助我们决定贪心算法是否产生最优解,而无需正式证明最优子结构和贪心选择属性。我们还将在本章中介绍算法设计的另一种范式,即动态规划,它也要求问题表现出最优子结构属性。如果你不确定贪心算法是否适用于给定的问题,因为贪心选择,你总是可以构建一个动态规划解决方案来获得一些见解。
Huffman 编码
为了更深入地了解贪心算法,让我们看看另一个可以用贪心算法解决的问题。
Huffman 码代表了一种有效地压缩数据的方法。数据被认为是字符序列。Huffman 的贪心算法使用一个表,其中包含每个字符的频率,以构建表示每个字符作为二进制字符串的最佳方式。
为了说明这一点,假设我们有一个包含 100,000 个字符的数据文件,我们希望以压缩的形式存储它。数据文件中每个字符的频率如下表所示:
| 字符 | a | b | c | d | e | f |
|---|---|---|---|---|---|---|
| 频率 | 45,000 | 13,000 | 12,000 | 16,000 | 9,000 | 5,000 |
表 4.3:数据文件中每个字符的频率
有多种方式来表示这些信息。为了本问题的目的,让我们假设我们想要设计一种二进制字符码,其中每个字符都由一个唯一的二进制字符串(我们将其称为码字)表示。一个选项是使用固定长度的码(例如,每个字符都由相同大小的码字表示)。如果我们选择这样做,我们需要三个位来表示每个六个字符,如下表所示:
| 字符 | a | b | c | d | e | f |
|---|---|---|---|---|---|---|
| 频率 | 45,000 | 13,000 | 12,000 | 16,000 | 9,000 | 5,000 |
| 码字 | 000 | 001 | 010 | 011 | 100 | 101 |
表 4.4:每个字符的码字
使用这种方法,我们需要 3,00,000 位来编码整个字符序列。我们能做得更好吗?
可变长度的码比固定长度的码要好得多。由于我们希望最小化压缩位序列的大小,我们希望给频率高的字符分配短的码字,给频率低的字符分配长的码字。以下表格显示了该字符序列的一个可能的码:
| 字符 | a | b | c | d | e | f |
|---|---|---|---|---|---|---|
| 频率 | 45,000 | 13,000 | 12,000 | 16,000 | 9,000 | 5,000 |
| 码字 | 0 | 101 | 100 | 111 | 1101 | 1100 |
表 4.5:字符序列的可能码
与 3,00,000 位相比,此码只需要 2,24,000 位来表示字符序列。使用此码,我们可以节省大约 28%的空间。我们提出的码也是此序列的最优字符码,正如我们将看到的。
构建霍夫曼码
在我们开始研究解决这个问题的算法之前,我们应该介绍一个叫做前缀码的概念。前缀码是一种码,其中没有码字也是其他码字的前缀。正如你所看到的提出的可变长度码,我们必须确保没有码字也是其他码字的前缀,因为我们想将码字连接起来,并在之后无歧义地解码。
例如,使用前面显示的代码,我们将字符串abc编码为0101100。由于没有码字是其他码字的前缀,解码大大简化了,因为我们能够识别初始码字,将其翻译,然后对编码序列的剩余部分重复此过程。
对于解码来说,一个方便的前缀码表示是一个二叉树,其叶子节点是原始数据序列中的字符。对于所提出的可变长度码,我们有以下二叉树:

图 4.1:前缀码的表示
一个字符的二进制码字是从根节点到该字符的路径,沿着每条边的二进制位进行。注意,每个节点还持有其子树下字符的频率。这样的树也有一些有趣的性质。一个最优前缀码的树恰好有|C|个叶子节点,C是从中抽取字符的字母表。内部节点的数量恰好是|C| - 1。我们还知道,在序列中对特定字符进行编码所需的位数等于该字符频率乘以持有该字符的叶子的深度。因此,对整个字符序列进行编码所需的位数仅仅是所有字母表中的字符这些值的总和。
如果我们可以构建这样的树,我们就可以计算出一个最优前缀码。大卫·A·霍夫曼发明了一个贪婪算法来构建最优前缀码,称为霍夫曼码。其基本思想是自底向上构建树。我们开始时只有一个叶子节点,每个字符一个。然后我们重复地将两个频率最低的节点合并,直到只剩下一个节点,这个节点就是树的根节点。
开发使用霍夫曼编码生成码字的算法
要实现一个算法,使用 Java 构建生成数据文件中字符二进制码字的树:
-
使用优先队列存储具有字符频率的节点。
-
重复地将两个频率最低的节点合并,直到只剩下一个节点。该算法的源代码如下:
for (int i = 0; i < N - 1; i++) {
Node n = new Node();
n.left = pq.remove();
n.right = pq.remove();
n.frequency = n.left.frequency + n.right.frequency;
pq.add(n);
}
代码片段 4.2:霍夫曼码。源类名:Huffman
前往 goo.gl/indhgT 访问完整的代码。
我们使用优先队列来存储我们的节点,这使得提取具有最少频率的节点非常高效 (O(logn))。优先队列类似于队列或栈,但每个元素都有一个与之相关的额外 优先级。具有更高优先级的元素先于具有较低优先级的元素服务。优先队列通常使用堆实现,通常提供 O(1) 的时间来找到具有更高优先级的元素,O(logn) 的时间来插入一个元素,以及 O(logn) 的时间来移除具有更高优先级的元素。
为了分析霍夫曼算法的运行时间,让我们将其分解为步骤。我们首先遍历频率映射中的每个字符,并构建一个节点,稍后将其插入到优先队列中。在优先队列中插入一个节点需要 O(logn) 的时间。由于我们遍历每个字符,创建和最初填充优先队列需要 O(nlogn) 的时间。第二个 for 循环恰好执行 n-1 次。每次,我们从优先队列中执行两次移除操作,每次移除都需要 O(logn) 的时间。整个 for 循环需要 O(nlogn) 的时间。因此,我们有两个步骤,每个步骤都需要 O(nlogn) 的时间,这使我们对于一组 n 个字符的总运行时间为 O(nlogn)。
活动:实现计算埃及分数的贪婪算法
场景
对于这个活动,我们将构建一个贪婪算法来计算埃及分数。每个正分数都可以表示为唯一单位分数的和。如果一个分数的分子是 1 且分母是正整数,那么这个分数就是一个单位分数。例如,1/3 就是一个单位分数。这种表示,例如,唯一单位分数的和,被称为埃及分数,因为古埃及人曾使用它。
例如,2/3 的埃及分数表示为 1/2 + 1/6。6/14 的埃及分数表示为 1/3 + 1/11 + 1/231。
目标
如前所述,实现一个计算埃及分数的贪婪算法。
先决条件
-
实现
EgyptianFractions类的build方法,该方法返回埃及分数表示的分母列表,按升序排列,可在 GitHub 上找到: -
假设分母总是大于分子,并且返回的分母总是适合一个 Long。
为了验证你的解决方案是否正确,请在命令行中运行 ./gradlew test。
完成步骤
-
检查分子是否能整除分母而不留下余数,并且我们只剩下一个分数。
-
如果不是,找到可能的最大单位分数,从原始分数中减去它,然后对剩余的分数进行递归。
在本节中,我们通过活动选择问题作为运行示例,介绍了算法设计中的贪婪范式。我们介绍了问题必须观察的两个属性,以便最优地通过贪婪算法解决:最优子结构和贪婪选择。为了获得对贪婪算法适用性的直观理解,我们后来探讨了两个可以用贪婪方法解决的问题:霍夫曼编码和埃及分数。
开始使用分而治之算法
在第二章,排序算法和基本数据结构中,我们介绍了其他排序算法,包括归并排序和快速排序。这两种算法的一个特点是它们将问题分解为子问题,这些子问题是相同问题的较小实例,递归地解决每个子问题,然后将子问题的解合并到原始问题的解中。这种策略构成了分而治之范式的基础。
分而治之方法
在分而治之方法中,我们递归地解决问题,在递归的每一级应用以下三个步骤:
-
分解问题为多个子问题,这些子问题是相同问题的较小实例。
-
解决子问题通过递归地解决它们。最终,子问题的大小足够小,可以以直接的方式解决。
-
合并子问题的解到原始问题的解中。
当一个子问题足够大,可以递归解决时,我们称之为递归情况。当一个子问题变得足够小,以至于递归不再必要,我们说我们已经达到了基本情况。通常,除了解决与原始问题不同的子问题外,还要解决主要问题的较小实例的子问题。解决这些问题被认为是合并步骤的一部分。
在第二章,排序算法和基本数据结构中,我们看到了归并排序的时间复杂度为O(nlogn)。我们还可以看到归并排序的最坏情况运行时间T(n)可以用以下递归关系来描述:

这种类型的递归经常出现,并表征分而治之算法。如果我们将递归推广到以下形式:

其中 a >= 1, b > 1 且 f(n) 是一个给定的函数,我们得到了创建子问题的分治算法的最坏情况运行时间的递归公式,每个子问题的规模是原始问题规模的 1/b,并且合并步骤总共需要 f(n) 的时间。
当涉及到分治算法时,提出这种递归通常更容易,但推导运行时间复杂度却更难。幸运的是,至少有三种方法可以提供这些递归的 O 界限:替换法、递归树法和主定理。对于本书的目的,我们将只关注主定理。
主定理
主定理提供了一种解决以下形式递归的方法:
T(n) = aT(n/b) + f(n)
其中:
a >= 1 和 b > 1 是常数,f(n) 是一个渐进正函数
主定理由三个情况组成,这将使你能够非常容易地解决这类递归。在我们深入探讨这三个情况之前,重要的是要注意,递归实际上并没有很好地定义,因为 n/b 可能不是一个整数。无论我们是用 n/b 除法的下取整还是上取整来替换它,都不会影响递归的渐进行为。
大 O 符号描述了一个函数增长率的渐进上界。也有其他符号用来描述函数增长率的界限。
对于主定理的目的,我们关注的是大-theta 符号 (Ө) 和大-omega 符号 (Ω)。
大-theta 符号描述了一个函数增长率的渐进紧界。它被称为紧界,因为运行时间被限制在常数因子之上和之下。它比 O(n) 的界限更紧。
当我们说一个算法是 O(f(n)) 时,我们的意思是随着 n 的增大,算法的运行时间最多与 f(n) 成正比。
当我们说一个算法是 Ө(f(n)) 时,我们的意思是随着 n 的增大,算法的运行时间与 f(n) 成正比。
大-omega 符号描述了一个函数增长率的渐进下界。当我们说一个算法是 Ω(f(n)) 时,我们的意思是随着 n 的增大,算法的运行时间至少与 f(n) 成正比。
在明确了所有必要的符号之后,我们可以按照以下方式从类型 T(n) = aT(n/b) + f(n) 的递归中推导出渐进界限:
-
*如果 f(n) = O(n(log[b])(a-∈)) 对于某个常数 ∈ > 0,那么 T(n) = θ(n(log[b])a) = O(n(log[b])a)
-
如果 f(n) = θ(n(log[b])a),那么 T(n) = θ(n(log[b])a log(n)) = O(n(log[b])a log(n))
-
如果对于某个常数 ∈ > 0,有 f(n) = Ω(n(log[b])(a+∈)), 并且对于某个常数 c < 1 和所有足够大的 n,有 a f(n/b) ≤ c f(n),那么 T(n) = T(n) = θ(f(n)) = O(f(n))
注意,在这三种情况下,我们都是在比较函数 f(n) 与函数 n(log[b])a。这个函数通常被称为 临界指数。
我们可以直观地看出,两个函数中较大的那个决定了递归的解。
在 case 2 中,函数的大小相同,所以我们乘以一个对数因子。
另一种看待这个问题的方式是,在 case 1 中,分割和重新组合问题的工怍被子问题所淹没;在 case 2 中,分割和重新组合问题的工怍与子问题相当;在 case 3 中,分割和重新组合问题的工怍支配了子问题。
为了练习使用主方法,让我们看看一些例子。
对于第一种情况,让我们考虑以下内容:

对于这个递归,我们有 a = 9,b = 3,f(n) = n,因此我们得到 n(log[b])a = n^(log[3])**⁹ = θ(n²)。由于 f(n) = Ω(n(log[4])**(3+∈)),其中 ∈ 为 1,我们可以应用主定理的 case 1 并得出结论 T(n) = O(n²)。
对于第二种情况,让我们考虑以下内容:

对于这个递归,我们有 a = 2,b = 2,f(n) = 10n,因此我们得到 n^(log[b])⁹ = n^(log[2])² = O(n)。由于 f(n) = O(n),我们可以应用主定理的 case 2 并得出结论 T(n) = O(nlogn)。
对于第三种和最后一种情况,让我们考虑以下内容:

对于这个递归,我们有 a = 3,b = 4,f(n) = nlog(n),因此我们得到 n(log[b])**a = n^(log[4])**³ = O(n^(0.793)). 由于 f(n) = Ω(n(log[4])**(3+∈)),其中 ∈ 大约为 0.2,只要 f(n) 满足条件,我们就可以应用 case 3。
对于足够大的 n,我们有 af(n/b) = 3(n/4)log(n/4) <= (3/4)nlogn (对于 c = 3/4)。因此,T(n) = O(nlogn)。
最接近的点对问题
既然我们已经知道了什么是划分和征服算法的特征,并且熟悉了从递归中推导界限的主方法,让我们看看一个可以通过划分和征服方法解决的问题。
我们将要研究的问题是平面上最接近点对的问题。我们给定平面上 n 个点的数组,并希望找出这个数组中最接近的点对。回想一下,两点 p 和 q 之间的距离由以下公式给出:

我们的第一种方法可能是计算每对之间的距离并返回最小的,这样运行时间复杂度为 O(n²)。以下代码片段实现了这个算法:
PointPair bruteForce(List<Point> points) {
PointPair best = new PointPair(points.get(0), points.get(1));
for (int i = 2; i < points.size(); i++) {
for (int j = i - 1; j >= 0; j--) {
PointPair candidate = new PointPair(points.get(i), points.get(j));
if (candidate.distance() < best.distance())
best = candidate;
}
}
return best;
}
代码片段 4.3:最接近点对的暴力算法。源类名:ClosestPairOfPoints
前往 goo.gl/FrRW3i 访问此代码。
提出的算法解决了这个问题,但我们可以通过使用划分和征服方法做得更好。该算法利用了一个预处理步骤,在这个步骤中,它按 x 坐标对输入数组进行排序。然后,它继续如下操作:
-
划分数组为两半
-
递归地在两个子数组中找到最小的距离(征服)
-
合并结果,通过取两半的最小距离,并额外考虑成对的情况,使得一对中的一个点来自左子数组,另一个点来自右子数组
方法看起来很简单,除了合并部分。在找到左子数组和右子数组的最近距离d后,我们有了此子问题的最小距离的上界。因此,我们只需要考虑x坐标比中间垂直线近于d的点。然后我们可以按y坐标对这些点进行排序,并在条带中找到最小的距离,如图所示:

图 4.2:计算最近点对的方法
以下代码片段计算条带中的最小距离,考虑到目前为止计算的最小距离:
Collections.sort(sortedPoints, (o1, o2) -> Integer.signum(o1.y - o2.y));
for (int i = 0; i < points.size(); i++) {
for (int j = i + 1; j < points.size() &&
(points.get(j).y - points.get(i).y) < best.distance(); j++) {
PointPair candidate = new PointPair(points.get(i), points.get(j));
if (candidate.distance() < best.distance())
best = candidate;
}
}
4.4 节代码片段:计算中间条带中点对之间的最小距离。源类名:ClosestPairOfPoints
访问goo.gl/PwUrTc以获取完整代码。
观察此代码,它似乎有O(n²)的时间复杂度,这并没有真正改进我们的暴力方法。然而,可以通过几何方法证明,对于条带中的每个点,最多需要检查其后的七个点。这由于排序步骤,将此步骤的运行时间降低到O(nlogn)。
您可以参考people.csail.mit.edu/indyk/6.838-old/handouts/lec17.pdf以获取先前问题的几何证明。
如果我们按y坐标对初始输入进行排序,并保持x排序数组上的点和y排序数组上的点之间的关系,我们可以在此步骤中将运行时间有效降低到O(n)。
bestWithStrip() method:
PointPair bestSoFar = bl;
if (br.distance() < bl.distance())
bestSoFar = br;
List<Point> strip = new ArrayList<>();
for (int i = 0; i < N; i++) {
if (Math.abs(points.get(i).x - midPoint.x) < bestSoFar.distance())
strip.add(points.get(i));
}
return bestWithStrip(strip, bestSoFar);
4.5 节代码片段:划分和征服算法寻找最近点对的合并步骤
在平面上。源类名:ClosestPairOfPoints
访问goo.gl/wyQkBc以获取此代码。
提出的算法将所有点分为两个集合,并递归地解决两个子问题。在划分后,它在O(n)时间内找到条带,并在O(n)时间内找到最近点(我们假设在此步骤中不需要排序的改进)。因此,T(n)可以表示为T(n) = 2T(n/2) + O(n) + O(n) = 2T(n/2) + O(n),这是一个O(nlogn)的上界,比暴力方法更好。
活动:解决最大子数组问题
场景
创建一个算法来解决最大子数组问题。找到输入数组中具有最大和的非空、连续子数组。您可以在以下图中看到一个带有最大子数组的示例数组:

测试子数组起始和结束索引所有组合的O(n²)暴力算法是显而易见的。尝试使用分治算法解决这个问题。
目标
为了设计并实现一个算法,以比O(n²)的暴力算法更好的运行时间来解决最大子数组问题,采用分治方法。
先决条件
-
你需要在源代码中实现
MaximumSubarray类的maxSubarray()方法,该方法返回输入数组最大子数组的值之和。代码可在以下路径找到: -
假设总和总是适合存储在 int 类型中,并且输入数组的最大大小为 100,000。
该源代码附带了一个用于此类的测试套件,因此为了验证你的解决方案是否正确,请在命令行中运行./gradlew test。
完成步骤
分治方法建议我们将子数组尽可能平均地分成两个子数组。这样做之后,我们知道最大子数组必须恰好位于以下三个位置之一:
-
完全位于左子数组中
-
完全位于右子数组中
-
跨越中点
由于那些子问题是原始问题的较小实例,因此左部和右部的最大子数组是递归给出的。
找到一个跨越中点的最大子数组。
存在一个运行时间为O(n)的甚至更快的动态规划算法来解决最大子数组问题。该算法被称为 Kadane 算法。动态规划将在下一节中探讨。
在第二部分,我们介绍了算法设计的分治范式。我们正式化了分治算法所经历的步骤,并展示了如何使用主定理将递归关系转换为运行时间复杂度界限。为了获得对分治算法适用性的直观理解,我们探讨了在平面上寻找最近点对的问题。
理解动态规划
在贪婪算法和分治之后,我们将把注意力转向动态规划。动态规划是一种算法设计范式,它也试图通过结合子问题的解决方案来解决优化问题。与分治不同,子问题必须表现出最优子结构,动态规划才能适用。
动态规划问题的要素
为了动态规划适用,一个优化问题必须有两个关键要素:最优子结构和重叠子问题。
最优子结构
最优子结构是我们介绍贪婪算法时已经讨论过的内容。回想一下,如果问题的最优解包含子问题的最优解,则问题表现出最优子结构。在尝试发现问题的最优子结构时,存在一个常见的模式,可以解释如下:
-
证明该问题的解包括做出一个选择,这个选择会留下一个或多个待解决的问题。这个选择可能并不明显,并且可能需要尝试许多选择(与贪婪方法相反,在贪婪方法中,只做出一个最优选择)。
-
假设你被给出了导致最优解的选择,确定后续的子问题。
-
证明问题最优解中使用的子问题的解本身必须是最优的
-
通常,这里使用 cut-and-paste 技术在这里。通过假设每个子问题的解不是最优的,如果从非最优解中 cut out 并 paste in 一个最优解,就会产生一个比原始问题更好的解决方案,这与假设原始问题的解决方案是最优的相矛盾。
重叠子问题
另一个优化问题必须具备的、使动态规划适用的条件是子问题的空间应该 小。因此,该问题的递归算法应该反复解决相同的子问题。通常,不同子问题的总数是输入大小的多项式。如果一个递归算法反复访问相同的问题,则称它具有重叠子问题。因此,动态规划算法通常缓存子问题的解以避免重复计算相同的解。
0-1 背包问题
为了展示动态规划解决方案,我们将探讨使该问题可以通过此技术解决的问题的性质,我们将查看 0-1 背包 问题。
你被给出了 n 个项目的权重和价值。你必须将这些项目放入容量为 W 的背包中,以获得背包的最大价值。你不能分割一个项目。你可以选择它或不选择它(因此具有 0-1 属性)。
换句话说,你被给出了两个数组,values[0...n-1] 和 weights[0...n-1],分别代表与 n 个项目相关的值和权重。你想要找到 values[] 的最大值子集,使得该子集的权重之和小于或等于 W。
解决这个问题的第一个直接方法是考虑所有项目的子集,并计算所有子集的总权重和总价值,只考虑那些总权重小于 W 的子集。为了考虑所有项目的子集,我们可以观察到每个项目有两种选择:要么它包含在最优子集中,要么它不包含。因此,从 n 个项目中可以获得的最大值是两个值中的最大值:
-
通过n-1个物品和W重量获得的最大值(即它们不包括最优解中的n^(th)项)
-
第n个物品的价值加上通过n-1个物品和W重量获得的最大值减去第n(th)*个物品的重量(例如,包括第*n(th)个物品)
基于之前的观察,我们已经展示了0-1 背包问题的最优子结构属性。
使用递归解决 0-1 背包问题
编写一个代码来解决通过实现递归方法来解决的0-1 背包问题。
记住,这是一个递归自顶向下的方法,因此它反复计算相同的子问题,导致指数级的运行时间复杂度(2^n)。以下代码片段使用递归方法解决这个问题:
public int recursiveAux(int W, int weights[], int values[], int n) {
if (n == 0 || W == 0)
return 0;
if (weights[n - 1] > W)
return recursiveAux(W, weights, values, n - 1);
return Math.max(values[n - 1] +
recursiveAux(W - weights[n - 1], weights, values, n - 1),
recursiveAux(W, weights, values, n - 1));
}
代码片段 4.6:0-1 背包问题的递归解决方案。源类名:Knapsack
前往goo.gl/RoNb5L访问此代码。
以下树形图显示了n和W的递归,输入values[] = {10, 20, 30}和weights[] = {1, 1, 1}:

图 4.3:显示 N 和 W 的递归树
由于问题再次被评估,这具有重叠子问题的属性。当我们对一个具有重叠子问题属性的问题采用递归自顶向下的方法时,我们可以通过修改程序来保存每个子问题的结果(在数组或哈希表中)来改进事情。程序现在首先检查是否已经解决了子问题。如果是,则返回保存的值;如果不是,则计算、存储并返回它。据说递归程序已经被记忆化,它记得之前计算过的结果。因此,这种方法通常被称为带有记忆化的自顶向下方法。以下代码片段将上一个代码片段调整为使用记忆化:
public int topDownWithMemoizationAux(int W, int weights[], int values[], int n, int[][] memo) {
if (n == 0 || W == 0)
return 0;
if (memo[n][W] == -1) {
if (weights[n - 1] > W)
memo[n][W] = topDownWithMemoizationAux(W, weights, values,
n - 1, memo);
else
memo[n][W] = Math.max(
values[n - 1] + topDownWithMemoizationAux(W - weights[n - 1],
weights, values, n - 1, memo),
topDownWithMemoizationAux(W, weights, values, n - 1, memo));
}
return memo[n][W];
}
代码片段 4.7:0-1 背包问题的自顶向下带记忆化方法。源类名:Knapsack
前往goo.gl/VDEZ1B访问此代码。
通过应用记忆化,我们将算法的运行时间复杂度从指数级(2^n)降低到二次级(nW*)。也可以使用自底向上的方法来解决这个问题。
自底向上方法的使用通常取决于子问题的某种自然的大小概念。我们必须按大小对子问题进行排序,并按顺序解决它们,从小到大,这样我们就可以确保在需要时已经计算了较小子问题的解决方案。
自底向上的方法通常与自顶向下的方法具有相同的渐进运行时间,但它通常具有更好的常数因子,因为它在过程调用中具有更少的开销。以下代码片段展示了解决0-1 背包问题的自底向上方法:
public int topDownWithMemoizationAux(int W, int weights[],
int values[], int n, int[][] memo) {
if (n == 0 || W == 0)
return 0;
if (memo[n][W] == -1) {
if (weights[n - 1] > W)
memo[n][W] = topDownWithMemoizationAux(W, weights,
values, n - 1, memo);
else
memo[n][W] = Math.max(
values[n - 1] +
topDownWithMemoizationAux(W - weights[n - 1],
weights, values, n - 1, memo),
topDownWithMemoizationAux(W, weights,
values, n - 1, memo));
}
return memo[n][W];
}
前往goo.gl/bYyTs8访问此代码。
最长公共子序列
现在,让我们看看另一个可以通过动态规划算法解决的问题。我们现在感兴趣的问题是最长公共子序列问题。
子序列和子字符串之间的区别在于,子字符串是一个连续的子序列。例如,[a, d, f]是[a, b, c, d, e, f]的子序列,但不是子字符串。[b, c, d]是子字符串,也是[a, b, c, d, e, f]的子序列。
我们感兴趣的是通过计算两个给定序列之间的最长公共子序列(LCS)来找出这两个序列之间的相似性。两个给定序列,S[1]和S[2],的公共子序列,S[3],是一个其元素在S和S[2]中出现的顺序相同,但不一定是连续的序列。这个问题通常适用于寻找不同生物体的 DNA 相似性。
例如,如果我们有两个链,S[1]和S[2],如下所示:
S[1] = ACCGGTCGAGTGCGCGGAGCCGGCCGAA
S[2] = GTCGTTCGGAATGCCGTTGCTCTGTAAAA
然后,那两个之间的最长公共链,让我们称它为 S[3],如下所示:
S[3] = GTCGTCGGAAGCCGGCCGAA

图 4.4:计算最长公共子序列
这个问题可以通过动态规划来解决。如果我们采取暴力方法,我们可以枚举S[1]的所有子序列,并检查每个子序列是否也是S[2]的子序列,同时跟踪我们找到的最长子序列。
然而,如果|S[1]| = m,那么S[1]有2m个子序列,这使得对于长序列来说不切实际。LCS展示了最优子结构属性。
使其变得明显的一种方式是从前缀的角度思考。让我们假设我们有两个以下序列:
X = {x[1], x[2]… x[m]}
Y = {y[1], y[2]… y[n]}
让Z是任何可以表示如下形式的X和Y的 LCS:
Z = {z[1], z[2]… z[k]}
然后,可能出现以下情况:
-
如果x[m] = y[n],那么z[k] = x[m] = y[n],因此Z[k]-1是X[m]-1和Y[n]-1的 LCS
-
如果x[m] != y[n],那么z[k] != x[m]意味着Z是X[m]-1和Y的 LCS
-
如果x[m] != y[n],那么z[k] != y[n]意味着Z是X和Yn-1的 LCS
这告诉我们,两个序列的最长公共子序列(LCS)包含了这两个序列的前缀的 LCS,展示了最优子结构属性。如果我们定义c[i][j]为序列X[i]和Y[j]的 LCS 的长度,那么我们可以得到以下递归公式,它指导我们找到以下问题的动态规划解决方案:

看到递归关系,重叠子问题的属性立即显现出来。
要找到X和Y的 LCS,我们可能需要找到X和Y[n-1]以及X[m-1]和Y的 LCS,但每个这些都有找到X[m-1]和Y[n-1]的 LCS 的问题。许多其他子问题共享子子问题。
使用这个递归关系,以自底向上的方式(我们已准备好子问题的解决方案),我们可以生成以下动态规划算法来计算两个字符串的最长公共子序列的长度:
public int length(String x, String y) {
int m = x.length();
int n = y.length();
int[][] c = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (x.charAt(i - 1) == y.charAt(j - 1))
c[i][j] = c[i - 1][j - 1] + 1;
else
c[i][j] = Math.max(c[i - 1][j], c[i][j - 1]);
}
}
return c[m][n];
}
代码片段 4.9:使用动态规划算法计算两个字符串的最长公共子序列的长度。源代码类名:LongestCommonSubsequence
前往 goo.gl/4TdNVQ 访问此代码。
如果我们跟踪在 c 矩阵上的每一步的方向(向上或向左),并且考虑到我们只有在 x[i] = y[j] 时才向最优子序列添加新字符,那么我们也可以计算最长公共子序列,而不仅仅是它的长度。
我们在这本书中不涵盖这个问题的解决方案。如果你对此感兴趣,LCS 问题的维基百科页面有关于其实施的详细说明:en.wikipedia.org/wiki/Longest_common_subsequence_problem。
活动内容:零钱兑换问题
场景
在这个活动中,我们将构建一个动态规划算法来解决零钱兑换问题。给定一个值,N,如果我们想将其拆分为硬币,并且我们有无限供应的每种面值的硬币,S={S[1], S[2], …, S[m]},我们有多少种方法可以做到?硬币的顺序不重要。对于 N = 4 和 S = {1, 2, 3},有四种解决方案:{1, 1, 1, 1}, {1, 1, 2}, {2, 2}, 和 {1, 3},所以结果应该是四个。
目标
使用动态规划算法解决前面描述的零钱兑换问题。
先决条件
你需要实现 CoinChange 类的 ways() 方法,该方法返回使用一组硬币产生给定兑换金额 N 的方法数。它可在以下路径找到:
该类的源代码附带了一个测试套件,因此为了验证你的解决方案是否正确,请在命令行中运行 ./gradlew test。
完成步骤
-
在遍历硬币 S[m] 时,为了计算解决方案的数量,我们可以将解决方案分为两个集合:
-
不包含任何硬币 S[m]
-
至少包含一个 S[m]
-
-
如果 w[i][j] 计算使用硬币 S[j] 及以下面值的硬币来兑换 i 的方法数,那么我们有以下递归关系:

在这个第三部分和最后一部分中,我们介绍了算法设计中的动态规划范式,以0-1背包问题和最长公共子序列问题为例。我们介绍了问题必须观察的两个属性,以便通过动态规划算法最优解决:最优子结构和重叠子问题,并展示了学生如何识别这些属性。我们还看到了动态规划算法中自顶向下(带有记忆化)和自底向上的方法之间的区别。
摘要
在本章中,我们讨论了三种不同的算法设计范式。我们看到了所有这些范式的示例问题,并讨论了如何识别问题是否可能通过给定的范式之一来解决。在下一章中,我们将重点关注一些使用这里介绍范式的字符串匹配算法。
第五章:字符串匹配算法
字符串匹配算法在文本编辑程序中相当常见。这类程序通常需要找到文本中所有模式的匹配项,其中文本通常是正在编辑的文档,而模式是用户提供的单词。由于文本编辑程序旨在响应,因此拥有高效的算法来解决字符串匹配问题是基本的。
到本章结束时,你将能够:
-
列出常见的字符串匹配算法
-
解决字符串匹配问题
-
设计一个简单的算法来解决字符串匹配问题
-
实现 Boyer-Moore 字符串搜索算法以在文献中进行字符串搜索
简单搜索算法
字符串匹配问题有两个输入,如下:
-
长度为n的数组T[1, 2, ...n]
-
长度为m (<= n)的数组P[1, 2, ...m]
T和P的元素来自相同的有限字母表(通常称为∑)。
例如,我们可能在二进制字符串中搜索,在这种情况下,我们的字母表是{0, 1},或者我们可能在小写字母的字符串中搜索,在这种情况下,我们的字母表是{a, b… z}。
以下图表表示了这个术语:

图 5.1:文本数组 T、模式数组 P 和有限字母表∑的表示
字符数组P和T通常被称为“字符字符串”。我们感兴趣的是在文本T中找到模式P的匹配项。
如果我们可以将模式P与文本T对齐,使得P中的所有字符都与T中的字符匹配,我们就说模式P在文本T中出现。在对齐时,我们需要将模式P向右移动零次或多次。
因此,在字符串匹配问题中,我们感兴趣的是模式P在文本T中出现的有效位移。我们说模式P在文本T中以位移s出现,如果模式P在文本T中的位置s + 1开始出现。换句话说,我们需要将P从文本T的起始位置向右移动s次,以找到匹配项。本质上,字符串匹配问题旨在找到模式P在给定文本T中出现的所有有效位移。
除了文本编辑程序之外,还有两个常见的例子,即寻找 DNA 序列中的模式和寻找与互联网搜索引擎查询相关的网页。
既然我们已经形式化了字符串匹配问题,让我们看看解决它的简单算法。
实现简单搜索
正如我们描述的字符串匹配问题,我们说我们感兴趣的是找到模式P在给定文本T中出现的所有有效位移。如果我们直接将这个概念转化为算法,我们就能得到简单的字符串匹配算法。
在 Java 中开发字符串匹配算法
目标是编写 Java 代码以应用简单的字符串匹配算法。
我们需要构建朴素字符串匹配算法。对于此算法,我们需要返回文本 T 中模式 P 发生的所有有效起始位置(或位移)。
执行以下步骤:
-
实现
NaiveStringMatching类的match()方法,在以下路径的 GitHub 上可用:
-
重复将模式
P沿文本T移动,匹配其中的所有字符与T中对齐的字符。 -
当发生匹配时,记录在
T中匹配的索引。
朴素字符串匹配算法的实现几乎是问题声明的直接翻译。我们想要遍历 P 的所有可能的位移,并通过比较 P 的每个元素与 T 中相应位移的元素来检查哪些是有效的。
该问题的可能解决方案如下片段所示:
for (int i = 0; i < n - m + 1; i++) {
boolean hasMatch = true;
for (int j = 0; j < m; j++) {
if (P.charAt(j) != T.charAt(i + j)) {
hasMatch = false;
break;
}
}
if (hasMatch)
shifts.add(i);
}
第 5.1 节片段:朴素字符串匹配问题的解决方案。源类名:solution.NaiveStringMatching
前往 goo.gl/PmEFws 访问此代码。
简化朴素搜索算法
朴素搜索算法需要 O((n - m + 1)m) 的时间,这是最坏情况下的紧界。我们可以想象一个朴素搜索算法的最坏情况,如果我们有一个字符 a 重复 n 次的文本字符串,即一个(例如 a5 = "aaaaa"),和模式 am(对于 m <= n)。在这种情况下,我们必须执行内循环 m 次来验证位移。
如果我们知道模式 P 中的所有字符都不同,则可以改进朴素搜索算法。在这种情况下,每当验证位移失败,因为 P[j] 不匹配 T[i + j] 时,我们不需要回溯。相反,我们可以在 (i + j) 上开始验证下一个位移,从而将算法的运行时间降低到 O(n)。
例如,如果 P = "abcd" 且 T = "abcaabcd",当 i = 0 和 j = 3 时,我们发现一个不匹配 ('a' != 'd')。我们不需要重复进行 i = 1 的比较,我们可以从 i = 3 开始,因为我们确信在 i = 0 和 i = 3 之间没有其他 a(记住 P 的所有字符都是不同的)。这些关于模式 P 的观察结果是 Boyer-Moore 算法的基础。
在本节中,我们介绍了字符串匹配问题,并使用朴素算法解决了它。在下一节中,我们将介绍一个更有效的算法来解决此问题——Boyer-Moore 算法。
开始使用 Boyer-Moore 字符串搜索算法
Boyer-Moore 字符串搜索算法由 Robert S. Boyer 和 J. Strother Moore 于 1977 年提出,并在朴素搜索算法的基础上,通过智能地跳过文本的某些部分来提高其运行时间。
算法的一个关键特性是它从右向左匹配模式,而不是从左向右匹配,利用其优势的几个位移规则来提高其运行时间。为了理解这些规则的效果,让我们从我们的朴素搜索算法构建 Boyer-Moore 算法。
我们首先修改模式上的匹配,使其从右向左操作。以下代码演示了这一点:
for (int j = m - 1; j >= 0; j--) {
if (P.charAt(j) != T.charAt(i + j)) {
hasMatch = false;
break;
}
}
段 5.2:修改段 5.1 的内循环以使算法从右向左操作 C
以朴素字符串匹配算法为基础,让我们看看一些规则,这些规则允许我们智能地跳过某些位移。
坏字符规则
坏字符规则的想法是识别模式中的字符和文本中的字符之间的不匹配,以便我们可以安全地跳过某些位移。为了识别坏字符的出现,让我们看看以下表格中的示例:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| T | H | C | B | B | A | H | C | C | A | B | A | H | A | H | B | C | C |
| P | A | B | A | H | A | H |
表 5.1:识别坏字符
在表 5.1 中提供的示例中,我们成功匹配了后缀AH,但随后遇到了一个坏字符,因为B != H*。每当这种情况发生时,我们都可以确定,只有从下一个解决这个不匹配的位移开始,才能找到有效的位移。这意味着我们可以将P位移,直到以下任一条件成立:
-
不匹配被转换为匹配
-
模式移动过不匹配的字符
当模式在不匹配字符的左侧有与文本中的字符匹配的字符时,我们可以将不匹配转换为匹配。否则,我们必须将模式移动到不匹配字符之后。在表 5.1 中提供的示例中,我们在P[1]处还有一个B,因此我们可以将P位移,直到P[1]与T[3]对齐,如下所示:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| T | H | C | B | B | A | H | C | C | A | B | A | H | A | H | B | C | C |
| P | A | B | A | H | A | H |
表 5.1.1:使用坏字符规则跳过位移
我们已经安全地跳过了对1位移的检查。现在,我们第一个字符就出现了不匹配。让我们尝试应用坏字符规则。首先,让我们看看是否可以将不匹配转换为匹配。
不幸的是,这是不可能的,因为字符C在P中不存在。在这种情况下,我们将模式位移到不匹配的字符之后,如下所示:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| T | H | C | B | B | A | H | C | C | A | B | A | H | A | H | B | C | C |
| P | A | B | A | H | A | H |
表 5.1.2:模式在错配字符之后移动
我们成功跳过了检查五个移位,并到达了一个有效的移位。
不良字符规则将帮助我们优化朴素字符串匹配算法,但前提是我们能够高效地找到正确的移位次数。假设我们能够访问一个二维数组 [1...m][1...e],其中 e 是我们字母表的大小。为了方便,让我们称这个数组为 left,并假设 left[i][j] 给出字符 j 在 P 中的最接近索引 k,使得 k < i,或者如果字符 j 在 P 中没有找到到 i 左边的索引,则为 -1。如果我们能够构建这样的数组,我们就可以通过考虑可能更大的跳过(由 left 中的信息给出)来改进我们的朴素字符串搜索算法。以下代码片段展示了我们如何使用 left 数组来改进我们的朴素字符串搜索算法:
int skip;
for (int i = 0; i < n - m + 1; i += skip) {
skip = 0;
for (int j = m - 1; j >= 0; j--) {
if (P.charAt(j) != T.charAt(i + j)) {
skip = Math.max(1, j - left[j][T.charAt(i + j)]);
break;
}
}
if (skip == 0) {
shifts.add(i);
skip = 1;
}
}
5.3 片段:使用不良字符规则改进跳过
前往 goo.gl/cCYnfp 访问此代码。
我们剩下填充 left 数组,这将在下一个活动中完成。
活动:实现不良字符规则
场景
我们必须预处理字符串 P 来构建允许我们高效使用不良字符规则的 left 数组。回想一下,left[i][j] 应该返回以下之一:
-
最大的索引 k,使得 k <= i 且 P[k] == j
-
-1,如果 j 在 P 中未找到
目标
构建一个允许我们高效使用不良字符规则的数组的步骤。
完成步骤
-
实现
BadCharacterRule类中match()方法的注释部分,该类可在 GitHub 上的以下路径找到: -
假设字符串 P 和 T 的字母表仅由英语字母表的小写字母组成。
良好后缀规则
良好后缀规则提供了一种补充方法来增强我们搜索有效移位的搜索。为了确定何时可以使用良好后缀规则,让我们看看以下表格中给出的示例:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| T | A | A | B | A | B | A | B | A | C | B | A | C | A | B | B | C | A | B |
| P | A | A | C | C | A | C | C | A | C |
表 5.2:良好后缀规则的说明
当我们在匹配了 P 的后缀但发现不匹配的情况下使用良好后缀规则,并将 t 作为匹配的后缀时,我们可以尝试通过执行以下任一情况来找到解决不匹配的下一个移动:
-
在 P 中找到左侧的另一个 t 出现
-
找到与 t 的后缀匹配的 P 的前缀
-
将 P 移动到 t 之后
考虑到情况 1,我们可以尝试将 P 向右移动三位,以对齐 P 中其他 t 的出现(从 P[4] 开始)。正如我们所看到的,t 出现左侧的字母(在 P[3])是 C,这与引起不匹配的字母完全相同。因此,我们应该始终尝试找到一个 t,它在左侧跟随的字符与引起不匹配的字符不同。忽略 t 左侧字符的良好的后缀规则变体被称为弱良好后缀规则。
良好后缀规则考虑了 t 左侧的字符,也称为强良好后缀规则。
如果我们无法在 P 中找到另一个 t 出现,那么我们可以用这个规则做的最好的事情就是找到一个与 t 的后缀匹配的 P 的前缀,进入案例 2。表 5.3 说明了这种情况:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| T | A | A | B | A | B | A | B | A | C | B | A |
| P | A | B | B | A | B |
表 5.3:寻找与 T 的后缀匹配的 P 的前缀
在这种情况下,我们在 P[1] 处找到了一个不匹配,但我们无法找到它左侧的另一个 BAB 出现。然而,我们可以找到一个与 t AB 的后缀匹配的 AB 的前缀,并将 P 移动,以便这些对齐。
每当我们既找不到另一个出现位置,也找不到 t 的前缀时,我们就只能将 P 在 T 中移动到 t 之后。
良好后缀规则的实现也需要对 P 进行一些预处理。为了理解必要的预处理,我们需要引入边界和正确前缀以及后缀的概念。字符串 S 的一个前缀是 S 的一个子串,它出现在 S 的开头。字符串 S 的一个正确前缀是不同于 S 的前缀(考虑到 S 总是 S 的前缀)。
字符串 S 的一个后缀是出现在 S 结尾的子串。字符串 S 的一个正确后缀是不同于 S 的后缀(考虑到 S 总是 S 的后缀)。边界是给定字符串的一个子串,它既是正确前缀也是正确后缀。例如,给定字符串 ccacc,有两个边界:c 和 cc。cca 不是一个边界。
良好后缀规则的预处理步骤分为两个步骤:一个用于规则的案例 1,另一个用于案例 2。
在情况 1 中,匹配后缀是模式后缀的边界。例如,如果 P = AACCACCAC 并且我们有 t = AC(P 的后缀),那么我们需要找到一个后缀 P,它以 AC 为前缀(构成后缀的边界)。字符串 ACCAC 是 P 的后缀,并且以 AC 为边界。
因此,我们需要找到模式后缀的边界。但是,即使找到了它们,我们还需要能够将给定的边界映射到具有该边界的最短后缀,这样我们才能相应地移动。此外,为了遵循强良好后缀规则,边界不能被与导致不匹配的相同符号向左扩展。
情况 1 的预处理算法如下所示:
int i = m, j = m + 1;
int[] f = new int[m + 1];
int[] s = new int[m + 1];
f[i] = j;
while (i > 0) {
while (j <= m && P.charAt(i - 1) != P.charAt(j - 1)) {
if (s[j] == 0)
s[j] = j - i;
j = f[j];
}
i--; j--;
f[i] = j;
}
代码片段 5.4:良好后缀规则情况 1 的预处理算法。源类名:GoodSuffixRule
访问 goo.gl/WzGuVG 以获取此代码。
为了更好地理解情况 1 的预处理算法,在算法的相关步骤上添加一些 println 语句,并使用一些示例输入运行它。您可以使用字符串 ABBABAB,其输出显示在 表 5.4 中。
f, whose entries *f[i]* contain the starting position of the widest border of the suffix of the pattern that starts at position *i*. *f[m]* is equal to *m + 1*, as the empty string has no border. The idea behind the previously shown preprocessing algorithm is to compute each border by checking whether a shorter border that is already known can be extended to the left by the same symbol. The array *s* is used to store shift distances; we can save entries in array *s* whenever we can't extend a border to the left (when *P[i - 1] != P[j - 1]*), provided that *s[j]* is not already occupied.
为了更好地理解这个算法产生的结果,让我们看看字符串 ABBABAB 的输出,如下表所示:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| P | A | B | B | A | B | A | B | |
| f | 5 | 6 | 4 | 5 | 6 | 7 | 7 | 8 |
| s | 0 | 0 | 0 | 0 | 2 | 0 | 4 | 1 |
表 5.4:对于字符串 ABBABAB 的良好后缀规则情况 1 的预处理算法输出
后缀 BABAB 的最宽边界从 2 开始,是 BAB,从 4 开始,因此 f[2] = 4。后缀 AB 的最宽边界从 5 开始,是空字符串,从 7 开始。因此,f[5] = 7。最宽边界为 BAB 的后缀 BABAB 不能向左扩展(因为 P[1] != P[3])。因此,BAB 的移位距离匹配后发生不匹配,即 s[4] = 4 - 2 = 2。后缀 BABAB 还有一个边界 B,也不能向左扩展,这确保了 s[6] = 6 - 2 = 4。从位置 6 开始的后缀 B 的边界为空字符串,从位置 7 开始;因此,s[7] = 7 - 6 = 1,这对应于如果没有匹配时的移位距离。
在情况 2 中,匹配后缀的模式后缀出现在模式的开头,这构成了模式的边界。因此,模式可以移动到其最宽边界允许的最大范围。对于情况 2 的预处理步骤,我们需要为每个后缀找到包含在该后缀中的模式的最宽边界。我们可以基于之前计算出的 f 数组来完成这项工作。以下代码片段说明了这一点:
j = f[0];
for (i = 0; i <= m; i++) {
if (s[i] == 0)
s[i] = j;
if (i == j)
j = f[j];
}
代码片段 5.5:良好后缀规则情况 2 的预处理算法。源类名:GoodSuffixRule
访问 goo.gl/ckoTu6 以获取此代码。
模式的最宽边界存储在f[0]中。对于情况 2 的预处理算法的想法是使用该值,直到模式短于f[0],在这种情况下,我们使用模式的下一个更宽的边界(f[j])。
将好后缀情况与朴素搜索算法集成可以让我们改进跳过的执行,如下面的代码所示:
for (i = 0; i < n - m + 1; i += skip) {
boolean hasMatch = true;
skip = 0;
for (j = m - 1; j >= 0; j--) {
if (P.charAt(j) != T.charAt (i + j)) {
skip = s[j + 1];
hasMatch = false;
break;
}
}
if (hasMatch) {
shifts.add(i);
skip = s[0];
}
}
Snippet 5.6: 仅使用好后缀规则的 Boyer-Moore 算法。源类名:Goodsuffixrule
前往goo.gl/1uCgeh访问此代码。
Boyer-Moore 算法的应用
Boyer-Moore 算法通常与一个或两个坏字符和好后缀规则一起使用。当与两个规则一起使用时,要发生的位移是规则产生的最大位移。Boyer-Moore 算法在平均情况下改进了朴素搜索算法,但在最坏情况下仍然是O(nm)(这种情况与上一节中描述的情况相同,即模式和文本中都有重复的字符组)。
实现 Boyer-Moore 算法
目标是用 Java 编写代码以实现 Boyer-Moore 算法。
我们需要将坏字符规则与好后缀规则集成以产生完整的 Boyer-Moore 算法。这里的想法是使用在每个情况下给出更好(或最大)位移的规则。
执行以下步骤:
-
实现
BoyerMoore类的match()方法,该方法可用在以下路径上:
-
将代码片段合并并更改跳过逻辑以选择两种规则的最好者。
以下代码片段展示了如何将组合匹配实现为一个解决方案:
for (i = 0; i < n - m + 1; i += skip) {
skip = 0;
boolean hasMatch = true;
for (j = m - 1; j >= 0; j--) {
if (P.charAt(j) != T.charAt(i + j)) {
hasMatch = false;
skip = Math.max(s[j + 1], j - left[j]
[T.charAt(i + j)]);
break;
}
}
if (hasMatch) {
shifts.add(i);
skip = s[0];
}
}
Snippet 5.7: Boyer-Moore 算法的实现。源类:BoyerMoore
前往goo.gl/71mXd6访问此代码。
在本节中,我们介绍了 Boyer-Moore 算法作为对朴素搜索算法的改进。通过预处理模式以跳过不必要的位移,我们可以降低字符串匹配算法的平均运行时间复杂度。在下一节中,我们将列出一些其他字符串匹配算法,列出它们的适用性,但不会深入其实现细节。
介绍其他字符串匹配算法
尽管 Boyer-Moore 字符串搜索算法是实际字符串搜索文献的标准基准,但还有其他字符串匹配算法也适合不同的目的。在本小节中,我们介绍了以下三个最著名的算法:
-
Rabin-Karp
-
Knuth-Morris-Pratt
-
Aho-Corasick
然而,仅提供 Rabin-Karp 的实现。
Rabin-Karp
在 1987 年,理查德·M·卡普和迈克尔·O·拉宾提出了一种字符串匹配算法,该算法在实际应用中表现良好,并能将字符串匹配推广到一组模式。Rabin-Karp 算法在其预处理阶段需要O(m)时间,其最坏情况运行时间是O((n - m + 1)m),与 Boyer-Moore 算法相似。
为了更好地介绍 Rabin-Karp 算法,让我们假设我们的字母表∑仅由十进制数字组成(∑ = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}),这样我们就可以将个字符的字符串视为长度为k的十进制数。因此,字符串 12345 对应于数字 12345。给定一个模式P[0...m]和文本中的子串T[i...i + m],如果我们将这两个字符串转换为它们对应的十进制数,那么我们就有数字p和t[i],那么p = t[i]仅当P[0...m] = T[i...i + m],因此i仅是一个有效的位移,如果p = t[i]*。
如果我们能在O(m)时间内计算 p,并在O(n - m + 1)时间内计算所有 t[i]值,那么我们就可以通过将 p 与每个 t[i]值进行比较,在O(n)时间内确定所有有效的位移。这个问题在于当p和t[i]太大而无法处理时。如果数字太大,那么我们可以使用模q来处理它们,对于合适的模数q。
让我们稍后再考虑选择合适的模数q。我们如何将此推广到处理其他字母表?例如,如果我们想使用不是十进制数字的字符怎么办?
考虑到,在我们的原始字母表中,要将字符串 12345 转换为数字,我们会执行操作10⁴1+10³2+10²3+10¹4+10⁰5。如果我们有一个 D 进制字母表,那么我们可以使用相同的策略,但将 10 替换为d。另一个需要考虑的是,当我们已经计算了t[i]并想要计算t[i+1]时,我们可以简单地移除最左边的数字,将所有内容向左移动,并添加最新的数字,即t[i+1] = ((t[i] - T[i]d[m-1])d + T[i + 1]) % q*。
最后一个需要考虑的是,使用模q并不完美。t[i] = p (mod q)并不一定意味着t[i] = p。但如果t[i] != p (mod q),那么t[i] != p。因此,我们可以将其用作快速启发式测试,以排除无效的位移。
应用 Rabin-Karp 算法
此处的目标是开发一个 Java 代码,以实现 Rabin-Karp 算法,用于匹配一组具有十进制数字的字母字符集中的字符串。
执行以下步骤:
-
将文本和模式字符串转换为数字。
-
使用 if 和 for 循环来计算匹配字符的数量。
-
将所有内容组合起来以实现 Rabin-Karp 算法。以下片段 5.9显示了算法的预计算部分:
long q = BigInteger.probablePrime(31, new Random()).longValue();
// Precompute d^(m-1) % q for use when removing leading digit
long dm = 1;
for (int i = 1; i <= m - 1; i++)
dm = (d * dm) % q;
// Precompute p and t0
long ph = 0;
long th = 0;
for (int i = 0; i < m; i++) {
ph = (d * ph + P.charAt(i)) % q;
th = (d * th + T.charAt(i)) % q;
}
片段 5.9:Rabin-Karp 算法的实现。源类名:RabinKarp
访问goo.gl/w7yzPA以获取此代码。
在之前的实现中,我们选择q为一个大的质数(使用BigInteger API)。我们这样做是为了有一个好的哈希函数,并避免p = t[i]比较中的大多数误报。这与我们在第三章中看到的哈希表余数方法中的类似技术。
尽管这超出了本书的范围,但 Rabin-Karp 算法很好地推广到在相同文本中查找一组模式。为此,它经常用于剽窃检测。
Knuth–Morris–Pratt
Knuth-Morris-Pratt(KMP)算法是由唐纳德·克努特和瓦奥恩·普拉特在 1970 年构思的单模式字符串搜索算法,由詹姆斯·H·莫里斯独立提出,三人于 1977 年共同发表。与 Boyer-Moore 算法相比,KMP 算法利用了这样一个观察:当发生不匹配时,模式包含足够的信息来确定下一次匹配可能开始的位置。
它与 Boyer-Moore 算法类似,因为它能够有效地跳过不必要的比较。KMP 算法的时间复杂度为O(n)。
Aho–Corasick
Aho-Corasick 算法是由阿尔弗雷德·V·阿霍和玛格丽特·J·科拉斯克发明的一种字符串搜索算法。类似于 Rabin-Karp 算法的扩展版本,它能够在输入文本中匹配字典(单词集合)中的元素。其背后的思想是构建一个有限状态机,能够同时匹配字典中的所有字符串。算法的时间复杂度是字符串长度、搜索文本长度以及输出匹配次数的总和。如果n是搜索文本的长度,m是字典中所有单词长度的总和,z是文本中单词的总出现次数。
因此,Aho-Corasick 算法的时间复杂度为O(n + m + z)。在本小节中,我们探讨了三种其他著名的字符串匹配算法。我们没有深入探讨它们的细节,但已经看到了它们在除了 Boyer-Moore 算法解决的问题之外的其他问题上的适用性。特别是,我们注意到有一些算法专门用于在文本中查找一组模式。
在 1979 年,Zvi Galil 引入了一个重要的优化,称为 Galil 规则,通过跳过已知匹配的部分来加快每次移位时的比较速度。使用 Galil 规则,Boyer-Moore 算法在最坏情况下实现了线性时间复杂度。
摘要
在本章中,我们介绍了字符串匹配问题。我们从朴素搜索算法开始,通过使用 Boyer 和 Moore 提出的规则对其进行了改进。我们看到了这些规则如何提高我们算法的平均运行时间复杂度。我们还探索了一些其他字符串匹配算法,而没有对这些算法的细节进行过多讨论。在下一章中,我们将探讨图及其应用。
第六章:图、素数和复杂度类
图问题在计算机科学中非常常见,它们的应用渗透到许多现实生活中的应用。所有可以用实体及其关系表示的事物最终都可以用图来建模。我们在社交媒体上如何与朋友互动,路线规划应用如何找到最短路径,以及电子商务网站如何提供推荐都是用图建模的问题示例。
图是由一组对象组成的结构,其中某些对象对之间存在关系。这些对象由顶点的数学抽象(有时也称为节点)来表示,而成对关系由边的数学抽象(有时也称为弧)来表示。
边可以是有向的或无向的。一个有向边是指与它相关联有方向的边。由有向边组成的图称为有向图。由无向边组成的图称为无向图。在有向边中,通常将边的起点称为头,将终点称为尾。在有向图中,一个顶点的出度是指与它相邻的头边的数量。一个顶点的入度是指与它相邻的尾边的数量。
图 6.1 给出了一个包含六个节点和八条边的有向图的示例,如下所示:

图 6.1:一个有向图
图 6.2 给出了一个包含五个节点和七条边的无向图的示例,如下所示:

图 6.2:一个无向图
在我们深入探讨如何在计算机程序中表示图之前,描述图算法的运行时间通常是如何表征的是非常重要的。如前所述,一个图,G,可以看作是一组顶点和边,即 G = (V, E)。因此,输入的大小通常以顶点数(|V|)和边数(|E|)来衡量。所以,而不是仅仅依赖于单个输入大小 N,图算法的运行时间通常指的是 |V| 和 |E|。在大的 O 表示法中,通常使用 V 来表示 |V|,使用 E 来表示 |E|。例如,运行时间与顶点数乘以边数成比例的算法被称为运行时间 O(VE)。
表示图
在计算机程序中通常有两种标准方式来表示图 G = (V, E):
-
作为邻接表的集合
-
作为邻接矩阵
你可以使用这两种方式来表示有向和无向图。我们将首先查看邻接表表示法。
邻接表表示法
图的邻接表表示由一个包含 |V| 个列表的数组组成,每个列表对应于 V 中的一个顶点。对于 V 中的每个顶点 u,都有一个包含所有顶点 v 的列表,使得在 E 中存在连接 u 和 v 的边。图 6.3 展示了 图 6.1 中有向图的邻接表表示:

图 6.3:图 6.1 中有向图的邻接表表示
对于无向图,我们遵循类似的策略,并构建邻接表,就像它是一个有向图,其中每对顶点 u 和 v 之间有两个边,分别是 (u, v) 和 (v, u)。
图 6.4 展示了 图 6.2 中的无向图的邻接表表示:

图 6.4:图 6.2 中无向图的邻接表表示
如果 G 是一个有向图,所有邻接表长度的总和是 |E|,因为每条边构成邻接表中的一个单独节点。
如果 G 是一个无向图,所有邻接表长度的总和是 2|E|*,因为每条边 (u, v) 出现两次,即一次在 u 的邻接表中,一次在 v 的邻接表中。
对于这两种类型的图,邻接表表示具有需要与 O(V + E) 相等的内存量的特性。
以下代码片段展示了如何使用 Java 中的邻接表表示创建图:
public class AdjacencyListGraph {
ArrayList<Integer>[] adj;
public AdjacencyListGraph(int nodes) {
this.adj = new ArrayList[nodes];
for (int i = 0; i < nodes; i++)
this.adj[i] = new ArrayList<>();
}
public void addEdge(int u, int v) {
adj[u].add(v);
}
}
片段 6.1:图的邻接表表示实现。源类名:Adjacencylistgraph
前往 goo.gl/Jrb2jH 访问此代码。
对于加权图,即每条边都有一个相关权重的图,这是很常见的。邻接表表示足够健壮,可以支持不同的图变体,因为我们可以在邻接表中存储不同的边表示。
我们可以在邻接表中存储不同的边表示,因为我们存储了边本身,从而允许自定义表示。
编写 Java 代码向有向图添加权重
目标是修改 AdjacencyListGraph 类的实现以支持边的权重。
步骤应该是以下这些:
-
理解 片段 6.1 展示了如何实现邻接表表示
-
修改实现方式,以便数组列表可以存储权重
ArrayList<Edge>[] adj;
public AdjacencyListWeightedGraph(int nodes) {
this.adj = new ArrayList[nodes];
for (int i = 0; i < nodes; i++)
this.adj[i] = new ArrayList<>();
}
public void addEdge(int u, int v, int weight) {
this.adj[u].add(new Edge(u, v, weight));
}
片段 6.2:加权图的邻接表表示实现。源类名:Adjacencylistweightedgraph
前往 goo.gl/uoazxy 访问此代码。
邻接矩阵表示
图的邻接表表示法提供了一种紧凑的方式来表示稀疏图,例如那些|E|远小于|V|²的图。尽管这种表示法对许多算法(我们将在后面讨论)很有用,但它不支持一些特性。例如,无法快速判断两个给定的顶点之间是否存在边。为了确定u和v是否相连,必须遍历u的邻接表以找到连接到v的边。由于u的邻接表最多有E条边,这个过程的时间复杂度为O(E)。一种替代的表示法是邻接矩阵表示法,它以使用更多内存的代价来弥补这一缺点。
加权图邻接表表示法的主要缺点是我们无法快速确定给定的边(u, v)是否存在于图中。
在这种表示法中,图G = (V, E)由一个|V| x |V|矩阵A = (a[ij])表示,其中a[ij]等于1如果存在边(i, j),否则为0。以下表格展示了图 6.1的有向图的邻接矩阵表示法:

表 6.1:图 6.1的有向图的邻接矩阵表示法
以下表格展示了图 6.2的无向图的邻接矩阵表示法:

表 6.2:图 6.2的无向图的邻接矩阵表示法
图的邻接矩阵表示法需要O(V²)的内存,与图中边的数量无关。在无向图的邻接矩阵表示法中需要注意的一点是,矩阵沿主对角线是对称的,因为(u, v)和(v, u)代表同一条边。因此,无向图的邻接矩阵是其自身的转置(A = A^T)。利用这种对称性,可以将存储图所需的内存几乎减半,因为不需要每个顶点的数组大小为V。如果i跟踪V中顶点的索引,那么随着i的增加,array[i]的大小可以减少一个。
以下代码展示了如何在 Java 中使用邻接矩阵表示法创建一个图:
public class AdjacencyMatrixGraph {
int[][] adj;
public AdjacencyMatrixGraph(int nodes) {
this.adj = new int[nodes][nodes];
}
public void addEdge(int u, int v) {
this.adj[u][v] = 1;
}
}
程序片段 6.3:有向图邻接矩阵表示法的实现。源代码类名:AdjacencyMatrixGraph
前往goo.gl/EGyZJj访问此代码。
邻接矩阵表示也足够健壮,可以支持不同的图变体。例如,为了支持加权图,可以在 a[ij] 中存储边的权重,而不仅仅是存储一个。邻接表表示在空间效率上至少与邻接矩阵表示一样,但邻接矩阵更简单,因此当图相对较小或密集时,它们可能更可取。如前所述,稀疏图是指 |E| 远小于 |V|² 的图,而密集图是指 |E| 接近 |V|² 的图。对于稀疏图,邻接表表示更节省内存。对于密集图,邻接矩阵表示更适合,因为它可能由于列表指针而占用更少的内存。
活动:构建加权无向图的邻接矩阵表示
场景
为社交网站创建一个用于加权无向图的邻接矩阵。
目标
编写一个 Java 代码来实现加权无向图的邻接矩阵表示。
先决条件
对于这个活动,你必须实现位于以下 URL 的 AdjacencyMatrixWeightedUndirected 类的 addEdge() 和 edgeWeight() 方法:
这些方法应该添加一条边并返回两个顶点之间的边权重。
完成步骤
-
开始在每个矩阵的单元格中存储边的权重。由于我们处理的是无向图,所以 (u, v) 和 (v, u) 都指代同一条边,因此我们需要相应地更新两者。
-
也可以不重复权重分配。我们只需小心,在引用那条边时始终选择 (u, v) 或 (v, u) 中的一个。一个可能的策略是始终使用 (min(u, v), max(u, v)). 使用该策略,我们也不需要存储完整的矩阵,从而节省一些空间。
在本节中,我们学习了两种不同的方式在计算机程序中表示图。我们简要地考察了每种表示的优点和缺点,并在接下来的章节中我们将探讨它们在实现图算法时的有用性。
图的遍历
图上的一个常见活动是以给定顺序访问图中的每个顶点。我们将首先介绍广度优先搜索,然后是深度优先搜索。这两种技术都是许多重要图算法的原型,我们将在后面的循环检测和单源最短路径的 Dijkstra 算法中看到。
广度优先搜索
给定一个图G = (V, E)和一个源顶点 s,广度优先搜索系统地探索G的边,以发现从s可达的每个顶点。在此过程中,它计算从s到每个可达顶点的最小边数,这使得它适合解决无权图或所有边都具有相同权重的图上的单源最短路径问题。
广度优先搜索(BFS)之所以命名为 BFS,是因为它在搜索前沿的宽度上均匀地扩展了已发现和未发现顶点之间的边界。从这个意义上说,算法首先探索距离s为k的顶点,然后再发现距离k + 1的顶点。为了跟踪进度,广度优先搜索将每个顶点标识为未发现、已发现或已扩展。所有顶点最初都是未发现的。顶点在第一次遇到搜索时被发现,当所有相邻的顶点都被发现时,它被扩展。
BFS 构建一个以源顶点s为根的广度优先树。每当搜索在已发现的顶点u的外向边扫描时发现一个未发现的顶点v,顶点v和边(u, v)将被添加到树中。因此,u成为广度优先树中v的父节点。由于一个顶点最多被发现一次,它最多有一个父节点。
为了说明这一点,让我们看一下以下表中从图 6.1的定向图开始的广度优先搜索的运行,起始节点为 2:

表 6.3:在图 6.1 中,从节点 2 开始的 BFS 运行
从广度优先树中可以得出很多见解。例如,从根到树中给定节点的路径是从这两个顶点之间的最短路径(以边数计)。另一个需要注意的事项是,不在广度优先树中的顶点(例如 0)是从根顶点不可达的。
我们之前看到了如何在树上执行广度优先搜索。图上的 BFS 与之类似,但我们需要跟踪已探索的节点,以免陷入循环。为了实现广度优先搜索,我们将假设我们的图使用邻接表表示。
我们将给图中的每个顶点附加某些属性,这将允许我们引导搜索并在以后构建广度优先树。我们还将使用一个先进先出队列(在第二章中介绍,排序算法和基本数据结构)来管理已发现的顶点集。以下代码片段说明了广度优先搜索的实现:
Queue<Integer> q = new LinkedList<>();
q.add(start);
while (!q.isEmpty()) {
int current = q.remove();
for (int i = 0; i < this.adj[current].size(); i++) {
int next = this.adj[current].get(i);
if (!visited[next]) {
visited[next] = true;
parent[next] = current;
q.add(next);
}
}
}
代码片段 6.4:广度优先搜索的实现。源类名:BFS.Graph
前往goo.gl/VqrQWM访问此代码。
让我们专注于 BFS 函数的实现。我们首先初始化几个辅助数组:parent 和 visited。第一个数组将保存 parent[i],表示在广度优先树中节点 i 的父节点。第二个数组将告诉我们 visited[i],表示顶点 i 是否已被发现。我们首先发现起始节点并将其添加到队列中。队列将保持那些已发现但尚未扩展的顶点。因此,当队列中仍有元素时,我们将取其第一个元素,遍历其相邻顶点,并发现那些尚未被发现的顶点,将它们添加到队列中。
当队列变为空时,我们确信已经扩展了从起始顶点可达的所有顶点。
在之前的实现中,我们在 bfs() 函数中返回了广度优先树的父节点数组,这使得我们可以重建路径。如果不需要,你只需返回路径的大小,或者从广度优先搜索遍历中提取的任何其他信息即可。
在 bfs() 方法中,我们确信每个顶点最多入队和出队一次。因此,队列操作的总时间复杂度是 O(V)。出队每个顶点后,我们扫描其邻接表。由于我们每个顶点最多出队一次,我们最多扫描每个邻接表一次。由于所有邻接表长度的总和是 O(E),扫描邻接表的总时间复杂度是 O(E)。因此,BFS 程序的初始化时间是 O(V),总运行时间是 O(V + E),其运行时间与 G 的邻接表表示的大小成线性关系。
正如我们将在后面的章节中看到的,BFS 程序是许多重要图算法的原型。
深度优先搜索
给定一个图 G = (V, E) 和一个源顶点 s,深度优先搜索通过尽可能地在图中“深入”来探索图的边。深度优先搜索(DFS)探索与最近发现的顶点 v 相邻且其头节点也相邻的未探索边。一旦 v 的所有边都被探索过,搜索将“回溯”以探索边,离开从 v 被发现的那个顶点。这个过程会一直持续到从原始源顶点可达的所有顶点都被发现。
如果还有未发现的顶点,那么 DFS 会从中选择一个作为新的源点,并从该源点重复搜索。虽然 BFS 限制自己只考虑从单个源点可达的顶点,而 DFS 考虑多个源点可能看起来有些奇怪,但背后的原因与这些搜索的应用有关。
BFS 通常用于查找最短路径距离,而 DFS 经常被用作其他算法的子程序,我们将在探索循环检测问题时看到这一点。
与 BFS 类似,当我们扫描已发现顶点的邻接表时发现顶点 v,我们记录其父属性。由于我们提到我们探索不同的来源,DFS 产生的父子图与广度优先树不同,它是一个森林(即一组树)。
为了说明这一点,让我们看看以下表格中从 node 2 开始的 DFS 对 Figure 6.1 的有向图的运行情况:


Table 6.4:从节点 2 开始对 Figure 6.1 的有向图执行 DFS 的运行情况
注意,DFS 的结果可能取决于顶点检查的顺序。在前面的例子中,我们从 2 开始,总是先访问顶点邻接表中的编号最小的顶点。如果我们从顶点 0 开始,我们将得到不同的森林。在实践中,我们通常可以使用任何具有等效结果的 DFS 结果。
我们之前看到了如何在树上执行 DFS。图上的 DFS 类似,但我们需要跟踪已探索的节点,以避免陷入循环。
为了实现 DFS,我们将假设我们的图使用邻接表表示。我们将为图中的每个顶点附加某些属性,这将允许我们引导搜索并在以后构建深度优先森林。以下代码片段说明了深度优先搜索的实现:
public void dfsVisit(int u, boolean[] visited, int[] parent) {
visited[u] = true;
for (int i = 0; i < adj[u].size(); i++) {
int next = adj[u].get(i);
if (!visited[next]) {
parent[next] = u;
dfsVisit(next, visited, parent);
}
}
}
Snippet 6.5: 深度优先搜索的实现。源类名:dfs.Graph。
前往 goo.gl/saZYQp 访问此代码。
DFS 过程通过将所有顶点初始化为未访问状态,并将它们的父设置为 -1(表示它们没有父节点)来工作。然后,我们找到第一个未发现的顶点并访问它。在每次访问中,我们首先记录顶点为已访问,然后遍历其邻接表。在那里,我们在寻找尚未发现的顶点。一旦找到,我们就访问它。查看前面的实现,我们看到 DFS 内部的循环时间复杂度为 O(V),因为它们为图中的每个顶点运行。我们还可以看到dfsVisit()方法对每个顶点恰好调用一次。在dfsVisit()执行过程中,扫描邻接表的循环执行时间与顶点邻接表的大小成比例。由于我们之前提到dfsVisit()对每个顶点恰好调用一次,循环中花费的总时间与所有邻接表大小的总和成比例,即 O(E)。因此,DFS 的总运行时间为 O(V + E)。
在 DFS 方法中,我们返回父数组,但此例程的返回类型通常根据使用 DFS 的更通用算法试图实现的大任务而调整。我们将在下一节中看到 DFS 如何适应我们的特定需求。
循环检测
DFS 的一个有用应用是确定一个图是否是无环的(即不包含环)。为了做到这一点,根据 DFS 生成的深度优先森林定义四种类型的边非常重要。它们如下:
-
树边:它们是深度优先森林中的边。一条边只有在首次发现一个顶点时被探索时才能成为树边。
-
回边:它们是连接深度优先树中一个顶点到其祖先的边。自环(可能出现在有向图中)是回边。
-
前向边:它们是不属于深度优先树但连接深度优先树中一个顶点及其后代的边。因此,前向边是在执行 DFS 时未使用的边,但它们在深度优先树中连接顶点 u 和 v,前提是 v 是树中 u 的后代。
-
交叉边:它们是所有其他边。它们可以在同一深度优先树中的顶点之间,也可以在不同深度优先树中的顶点之间。因此,它们是在执行深度优先搜索时未使用的边,但连接了在同一树中不共享祖先关系的顶点或不同树中的顶点。
在对边进行分类后,可以证明一个有向图是无环的当且仅当 DFS 不会产生回边。如果深度优先搜索产生回边 (u, v),则顶点 v 是深度优先森林中顶点 u 的祖先。因此,G 包含从 v 到 u 的路径,并且 (u, v) 完成了环。此算法可推广到无向图。在无向图中,如果我们找到一个回边 (u, v) 且 v 不是深度优先森林中 u 的父节点,那么我们就在一个环中。
活动:使用 BFS 寻找迷宫的出口最短路径
场景
我们的游戏迷宫是一个 H 乘以 W 的矩形,由一个大小为 H 的 W 大小字符串数组表示。字符串中的每个字符可以是 '#' 或 '.'。 '#' 代表墙壁,我们不能穿越,而 '.' 代表空地,我们可以穿越。迷宫的边缘总是填充着 '#',除了一个代表出口的方块。例如,以下是一个有效的迷宫:

当提供起始点 (i, j)(其中 (0, 0) 是左上角点,(H, W) 是右下角点)时,找到走出迷宫的总步数。
目标
使用 BFS 寻找给定迷宫的出口最短路径。
先决条件
-
在源代码中实现
Maze类的distToExit()方法,该方法返回从该点到迷宫出口的整数距离。它可在以下网址找到: -
假设提供给
distToExit()的点是有效的(也就是说,它们不在墙壁内) -
记住,我们只能沿四个基本方向(北、南、东、西)移动
东,和西)
完成步骤
-
将迷宫表示编码为图表示
-
应用前一小节中展示的 BFS 实现(对距离进行微小修改),或者你可以边走边构建图
-
由于你知道对于给定的顶点最多有四个外向边,因此边走边计算它们的位子
在本节中,我们介绍了两种不同的图遍历方法——广度优先搜索(BFS)和深度优先搜索(DFS)。我们看到了如何使用 BFS 在无权图中找到单源最短路径,以及如何使用 DFS 在图中找到环。在下一节中,我们将探讨两种不同的算法来找到图中的最短路径。
计算最短路径
最短路径是两个顶点之间的路径,使得构成该路径的边的权重之和最小化。最短路径问题在现实世界中有很多应用,从在地图应用中查找方向到最小化解决谜题的移动。
在本节中,我们将探讨两种不同的策略来计算最短路径:一种是从单个源点到图中每个其他顶点的最短路径,另一种是在图中找到所有顶点对之间的最短路径。
单源最短路径:Dijkstra 算法
当我们探索 BFS 时,我们看到它能够解决无权图或边具有相同(正)权重的图的最短路径问题。如果我们处理的是带权图呢?我们能做得更好吗?我们将看到 Dijkstra 算法在 BFS 中提出的思想上提供了改进,并且它是一个解决单源最短路径问题的有效算法。使用 Dijkstra 算法的一个限制是边的权重必须是正的。这通常不是一个大问题,因为大多数图用具有正权重的边来表示实体。尽管如此,还有一些算法能够解决负权值的问题。由于负边的使用场景较少见,这些算法超出了本书的范围。
Dijkstra 算法由 Edsger W. Dijkstra 在 1956 年构思,它维护一个集合 S,其中包含从源 s 到其最终最短路径权重的顶点已经确定。该算法反复选择具有最小最短路径估计的顶点 u,将其添加到集合 S 中,并使用该顶点的向外边缘来更新尚未在集合 S 中的顶点的估计。为了看到这一过程,让我们考虑 图 6.5 的有向图:

图 6.5:一个示例加权有向图
该图由五个顶点(A、B、C、D 和 E)和 10 条边组成。我们感兴趣的是找到从顶点 A 开始的最短路径。请注意,A 已经标记为 0,这意味着从 A 到 A 的当前距离为零。其他顶点还没有与它们关联的距离。通常使用无穷大作为尚未看到的节点距离的起始估计。以下表格显示了 Dijkstra 算法在 图 6.5 图上的运行情况,标识了当前被选中的顶点以及它如何更新尚未在集合 S 中的顶点的估计:
| 步骤 | 说明 |
|---|---|
![]() |
顶点 A 是具有最低估计权重的顶点,因此它被选中作为下一个顶点,其边缘将被考虑以改进当前的估计。 |
![]() |
我们使用从顶点 A 出发的向外边缘来更新顶点 B 和 D 的估计。之后,我们将 A 添加到集合 S 中,避免重复访问它。在尚未访问的边缘中,现在具有最低估计的是 D,它将被选中进行访问。请注意,我们也用粗体标记了属于我们估计最短路径的边缘。 |
![]() |
通过探索从顶点 D 出发的向外边缘,我们能够改进顶点 B 的估计,因此我们相应地更新它,并现在考虑不同的边缘来寻找最短路径。我们还能够发现顶点 C 和 E,它们成为下一个要访问的潜在候选者。由于 E 具有较短的估计权重,我们接下来访问它。 |
![]() |
使用从顶点 E 出发的向外边缘,我们能够改进顶点 C 的估计,现在将 B 作为下一个要访问的顶点。请注意,那些属于集合 S 的顶点(如图中黑色所示)已经计算了最短路径。它们内部的价值是从 A 到它们的短路径的权重,你可以跟随粗体边缘来构建最短路径。 |
![]() |
从顶点 B,我们能够再次改进到顶点 C 的最短路径估计。由于顶点 C 是唯一尚未在集合 S 中的顶点,因此它是下一个被访问的顶点。 |
![]() |
由于顶点 C 没有指向尚未在 S 中的顶点的出边,我们得出算法运行的结论,并成功计算了从 A 到图中每个其他顶点的最短路径。 |
表 6.5:Dijkstra 算法在图 6.5 的加权有向图上的运行
现在我们已经看到了 Dijkstra 算法的一次运行,让我们尝试将其放入代码中并分析其运行时间性能。我们将使用邻接表表示我们的图,因为它有助于我们探索给定顶点的出边。以下代码片段显示了 Dijkstra 算法的一种可能实现,如之前所述:
while (!notVisited.isEmpty()) {
Vertex v = getBestEstimate(notVisited);
notVisited.remove(v);
visited.add(v);
for (Edge e : adj[v.u]) {
if (!visited.contains(e.v)) {
Vertex next = vertices[e.v];
if (v.distance + e.weight < next.distance) {
next.distance = v.distance + e.weight;
parent[next.u] = v.u;
}
}
}
}
6.6 段:Dijkstra 算法的实现。源类名:Dijkstra
前往 goo.gl/P7p5Ce 访问此代码。
Dijkstra 方法首先初始化两个集合:
-
一个用于已访问顶点
-
一个用于未访问顶点
已访问顶点的集合对应于我们之前命名为 S 的集合,我们使用未访问顶点的集合来跟踪要探索的顶点。
我们然后初始化每个顶点的估计距离等于 Integer.MAX_VALUE,代表我们用例中的“无穷大”值。我们还使用一个父数组来跟踪最短路径中的父顶点,这样我们就可以稍后从源顶点重新创建路径。
主循环对每个顶点运行,直到我们还有未访问的顶点。对于每次运行,它选择要探索的“最佳”顶点。在这种情况下,“最佳”顶点是到目前为止未访问顶点中距离最小的顶点(getBestEstimate() 函数简单地扫描 notVisited() 集合中的所有顶点,以找到满足要求的顶点)。
然后,它将顶点添加到已访问顶点的集合中,并更新未访问顶点的估计距离。当我们没有更多顶点要访问时,我们通过递归访问父数组来构建我们的路径。
分析前一个实现的运行时间,我们可以看到有一个与图中顶点数量成正比的初始化步骤,因此以 O(V) 运行。
算法的主体循环对每个顶点运行一次,因此它至少被限制在 O(V)。在主体循环内部,我们确定下一个要访问的顶点,然后扫描其邻接表以更新估计距离。更新距离所需的时间与 O(1) 成正比,并且由于我们只扫描每个顶点的邻接表一次,所以我们花费的时间与 O(E) 成正比,更新估计距离。我们剩下选择下一个要访问的顶点所需的时间。不幸的是,getBestEstimate() 方法需要扫描所有未访问的顶点,因此它被限制在 O(V)。因此,我们实现 Dijkstra 算法的总运行时间是 O(V²+E)。
尽管我们实现中的某些部分看起来难以优化,但看起来在选择下一个要访问的顶点时,我们可以做得更好。如果我们能够访问一个能够按较低估计距离排序我们的顶点并且提供高效插入和删除操作的数据结构,那么我们就可以减少在 getBestEstimate 方法中花费的 O(V) 时间。
在第四章 算法设计范式 中,我们简要讨论了在霍夫曼编码(Huffman coding)中使用的名为优先队列(priority queue)的数据结构,这正是我们这项工作所需要的。以下代码片段实现了一个更高效的迪杰斯特拉算法版本,使用了优先队列:
PriorityQueue<Node> pq = new PriorityQueue<>();
pq.add(new Node(source, 0));
while (!pq.isEmpty()) {
Node v = pq.remove();
if (!vertices[v.u].visited) {
vertices[v.u].visited = true;
for (Edge e : adj[v.u]) {
Vertex next = vertices[e.v];
if (v.distance + e.weight < next.distance) {
next.distance = v.distance + e.weight;
parent[next.u] = v.u;
pq.add(new Node(next.u, next.distance));
}
}
}
}
代码片段 6.7:使用优先队列实现迪杰斯特拉算法。源类名:DijkstraWithPQ
前往 goo.gl/3rtZCQ 访问此代码。
在这个第二个实现中,我们不再保留已访问和未访问顶点的集合。相反,我们依赖于一个在运行算法时将存储我们的距离估计的优先队列。
因此,当我们从一个给定的顶点探索向外延伸的边时,如果我们能够改进我们的距离估计,我们就会向优先队列中添加一个新的条目。
从我们的优先队列中添加或删除一个元素需要 O(logN) 时间,其中 N 是队列中的元素数量。请注意,我们可以在优先队列中插入相同的顶点多次。这就是为什么我们在扩展其边之前检查我们是否已经访问过它。
由于我们将访问具有较短估计距离的顶点的实例,因此可以安全地忽略其后的那些。然而,这意味着我们的优先队列操作不受 O(logV) 的限制,而是 O(log E)(假设存在一个连通图)。
因此,这个实现的总体运行时间是 O((V + E)logE)。通过使用具有更好渐近界限的优先队列实现(例如斐波那契堆),我们仍然可以改进这个运行时间,但其实现超出了本书的范围。
关于迪杰斯特拉算法的最后一件事是它如何借鉴了 BFS(算法结构非常类似于迪杰斯特拉算法,但我们最终使用的是优先队列而不是普通队列)的想法,并且它是一个非常好的贪婪算法的例子:迪杰斯特拉算法通过做出局部最优的选择(例如,它选择具有最小估计距离的顶点)来达到全局最优。
所有对最短路径:弗洛伊德-沃舍尔算法(Floyd-Warshall Algorithm)
有时候,可能需要计算图中所有顶点对之间的最短路径。例如,我们可能对构建一个距离表感兴趣。实现这一目标的一种方法是对图中的每个顶点执行单源最短路径算法。如果你使用迪杰斯特拉算法(Dijkstra's algorithm)来做这件事,我们最终得到的运行时间是 O(V(V + E)logE)*。
在本小节中,我们将探讨一个能够在 O(V³) 时间内解决所有对最短路径问题的算法,其实施简单而优雅。
我们即将研究的一种算法,通常被称为 Floyd-Warshall 算法,由 Robert Floyd 在 1962 年以当前形式发表。然而,从本质上讲,它遵循 Bernard Roy 在 1959 年和 Stephen Warshall 在 1962 年发表的同种思想。
该算法使用邻接矩阵表示法,并遵循动态规划方法。其基本思想是,当我们试图计算顶点 i 和顶点 j 之间的最短路径距离时,我们尝试使用一个中间顶点,k。我们想要使用一个中间顶点,以便从 i 到 k 和从 k 到 j 的路径可以缩短当前计算出的 i 和 j 之间的最短路径。如果我们找到这样的顶点,那么我们迄今为止能够计算出的 i 和 j 之间的最佳路径必须经过 k。我们所需做的只是,对于每个 k,查看使用它是否可以改善 i 和 j 之间的最短路径,对于所有可能的 i 和 j。
为了在实践中看到这一点,让我们使用我们用来说明 Dijkstra 算法的 图 6.5 的有向图。图 6.5 的邻接矩阵表示如下表格:
| A | B | C | D | E | |
|---|---|---|---|---|---|
| A | 0 | 10 | ∞ | 5 | ∞ |
| B | ∞ | 0 | 1 | 2 | ∞ |
| C | ∞ | ∞ | 0 | ∞ | 4 |
| D | ∞ | 3 | 9 | 0 | 2 |
| E | ∞ | ∞ | 6 | ∞ | 0 |
表 6.6:图 6.5 的有向图的邻接矩阵表示
邻接矩阵表示法是 Floyd-Warshall 算法的起点,我们遍历它,直到我们只剩下一个矩阵,该矩阵表示每对顶点之间最短路径的权重。为了做到这一点,让我们从顶点 A 开始,将其视为最短路径的中间顶点。不幸的是,顶点 A 没有内向边,这意味着它不能用作最短路径的中间顶点。使用顶点 B,我们可以改善从 A 到 C 的距离(10 + 1 < ∞),并且我们可以用它从 A 到 D,但不会改善整体距离。我们还可以用它来改善从 D 到 C 的距离(3 + 1 < 9)。因此,在考虑 B 作为中间顶点后,我们剩下以下表格的距离矩阵:
| A | B | C | D | E | |
|---|---|---|---|---|---|
| A | 0 | 10 | 11 | 5 | ∞ |
| B | ∞ | 0 | 1 | 2 | ∞ |
| C | ∞ | ∞ | 0 | ∞ | 4 |
| D | ∞ | 3 | 4 | 0 | 2 |
| E | ∞ | ∞ | 6 | ∞ | 0 |
表 6.7:考虑 B 作为中间顶点后的距离矩阵
现在,我们将查看顶点 C。使用顶点 C,我们可以改善从 A 到 E(11 + 4 < ∞)和从 B 到 E(1 + 4 < ∞)的距离:
| A | B | C | D | E | |
|---|---|---|---|---|---|
| A | 0 | 10 | 11 | 5 | 15 |
| B | ∞ | 0 | 1 | 2 | 5 |
| C | ∞ | ∞ | 0 | ∞ | 4 |
| D | ∞ | 3 | 4 | 0 | 2 |
| E | ∞ | ∞ | 6 | ∞ | 0 |
表 6.8:考虑 C 作为中间顶点后的距离矩阵
使用顶点D,我们可以改善从A到B(5 + 3 < 10)、从A到C(5 + 4 < 11)、从A到E(5 + 2 < 15)以及从B到E(2 + 2 < 5)的距离:
| A | B | C | D | E | |
|---|---|---|---|---|---|
| A | 0 | 8 | 9 | 5 | 7 |
| B | ∞ | 0 | 1 | 2 | 4 |
| C | ∞ | ∞ | 0 | ∞ | 4 |
| D | ∞ | 3 | 4 | 0 | 2 |
| E | ∞ | ∞ | 6 | ∞ | 0 |
表 6.9:考虑 D 作为中间顶点后的距离矩阵
使用顶点E,我们无法改善任何距离,因此表 6.9已经包含了所有顶点对之间最短路径的权重。实现 Floyd-Warshall 算法非常简单,如下代码片段所示:
public void run() {
for (int k = 0; k < adj.length; k++) {
for (int i = 0; i < adj.length; i++) {
if (adj[i][k] >= Integer.MAX_VALUE)
continue;
for (int j = 0; j < adj.length; j++) {
if (adj[k][j] >= Integer.MAX_VALUE)
continue;
adj[i][j] = Math.min(adj[i][j], adj[i][k] + adj[k][j]);
}
}
}
}
碎片 6.8:Floyd-Warshall 算法的实现。源类名:FloydWarshall
前往goo.gl/SQxdL2访问此代码。
观察实现过程,O(V³)的时间复杂度变得明显。Floyd-Warshall 算法的一个替代方案是对图中的每个顶点运行 Dijkstra 算法(这样我们最终会得到所有成对的最短路径)。鉴于其复杂度接近于对稠密图多次应用 Dijkstra 算法,Floyd-Warshall 算法在实践中经常被使用。
活动:改进 Floyd-Warshall 算法以重建最短路径
场景
改进 Floyd-Warshall 算法,以便在运行算法后能够使用前驱矩阵重建两个给定节点之间的最短路径。
目标
使用前驱矩阵构建两个顶点之间的最短路径。
先决条件
前驱矩阵用于计算两个给定顶点之间的最短路径。前驱矩阵的每个单元格P[ij]应该是空的(表示i和j之间没有路径),或者等于某个索引k(表示顶点k是i和j之间最短路径中的前驱顶点)。因此,每当使用中间顶点时,我们需要更新我们的前驱矩阵。
实现 Floyd-Warshall 类的run()方法,该方法应计算当前图的最短路径并填充路径矩阵,该矩阵随后在path()方法中使用,以返回两个给定顶点之间的路径。该方法可在以下 URL 找到:
完成步骤
-
将 Floyd-Warshall 算法的碎片 6.8中的实现进行修改,以更新路径矩阵
-
使用它来重建路径,类似于我们在迪杰斯特拉算法实现中之前所展示的
在本节中,我们介绍了最短路径问题,并探讨了两种不同的算法来解决它:一个用于单源最短路径(迪杰斯特拉算法),另一个用于所有对最短路径(弗洛伊德-沃舍尔算法)。我们展示了不同的迪杰斯特拉算法实现如何影响其运行时间。对于这两个算法,我们也展示了如何分别使用父数组和中继矩阵重建最短路径。
算法中的素数
素数是一个大于 1 的自然数,其唯一的因数是 1 和它本身。
素数在算术基本定理中扮演着非常重要的角色:每个大于 1 的自然数要么是素数,要么是素数的乘积。如今,数论算法被广泛使用,主要归功于基于大素数的加密方案。大多数这些加密方案都是基于这样一个事实:我们可以高效地找到大素数,但我们不能高效地分解这些大素数的乘积。正如之前所看到的,素数在哈希表的实现中扮演着重要的角色。
埃拉托斯特尼筛法
埃拉托斯特尼筛法是一个简单而古老的算法,用于找到给定限制内的所有素数。如果我们想找到所有小于等于N的素数,我们首先创建一个从2到N(2,3,4,5…N)的连续整数列表,最初未标记。让我们用p来表示最小的未标记数。然后,我们选择比最后一个p更大的最小的未标记数p。在第一次迭代中,p将是 2。之后,通过p的增量,我们在列表中标记从2p到Mp的元素,使得Mp <= N.
我们重复此策略,直到无法在列表中标记更多数字为止。在运行结束时,所有未标记的数字都是素数。很容易看出,所有未标记的数字都是我们找不到除数(除了数字本身和 1)的数字,因此是素数。
素数分解
素数分解是确定给定数的素数因子。为这个计算上困难的问题构建一个通用算法是非常困难的。一个常用的通用算法,用于分解素数,在第一章,算法与复杂性中介绍。其基本思想是遍历可能的因数,尝试除以该数。
从 2 开始;当数字能被 2 整除时,继续除以它,并将 2 添加到因数列表中。之后,数字必须是奇数,因此开始一个循环,检查从 3 到数字的平方根的可能因数。
由于我们已经涵盖了偶数,你可以在循环中执行 2 的增量(一旦检查了 2,就没有必要检查 4、6、8 等等)。一旦找到合适的除数,将数字添加到因子列表中,并除以它直到可能为止。在这个步骤结束时,如果我们剩下大于 2 的数字,那么它是一个质数,因此也是它自己的质因数。
活动:实现埃拉托斯特尼筛法
场景
实现埃拉托斯特尼筛法算法以找到给定限制范围内的所有质数。
目标
开发一个 Java 代码来实现埃拉托斯特尼筛法。
先决条件
-
实现
SieveOfEratosthenes类的isPrime()方法,该方法应返回true如果数字是质数,否则返回false。它可在以下 URL 找到: -
考虑在类构造函数中构建筛子。
图中的其他概念
在本章中,我们介绍了表示和遍历图的方法,并探讨了最短路径算法。图也是解决一些尚未提到的某些问题的最优数据结构。本节旨在介绍其中的一些。
最小生成树
图的最小生成树是连接图中所有顶点的边集 E 的一个子集,它没有循环,并且具有最小的总边权重。因为它中的每两个顶点都恰好由一条路径连接,所以它是一个树。
为了理解最小生成树的应用,考虑一个电信公司进入新社区的问题。该公司希望连接所有房屋,但同时也希望最小化使用的电缆长度以降低成本。解决该问题的一种方法是通过计算一个图的最小生成树,该图的顶点是社区的房屋,房屋之间的边根据它们之间的距离进行加权。
有许多算法可以解决最小生成树问题。其中最著名的是普里姆(Prim)和克鲁斯卡尔(Kruskal)算法。
普里姆算法是一种贪心算法,它反复选择连接某些尚未包含在生成树中的边与某些已包含在生成树中的边的较小权重的边。其运行时间取决于实现方式,但可能实现 O(VlogE) 的运行时间。它可以类似于迪杰斯特拉算法实现。我们从一个任意选择的顶点开始,将其作为树的一部分。所有连接到这个“起始”顶点的边都被添加到一个按边权重排序的候选边集合中。当我们仍然需要向树中添加顶点时,我们从候选边列表中选择具有较小权重的边,该边连接到一个尚未包含在树中的顶点,然后对新顶点重复此过程。
克鲁斯卡尔算法也是一种使用并查集数据结构的贪心算法。并查集数据结构,或称为集合归并查找数据结构,是一种跟踪被划分为若干个非重叠子集的元素的数据结构。它提供了一种高效的方法来合并两个集合并检查两个元素是否属于同一个集合。
克鲁斯卡尔算法的思想是将森林(例如,一组树)减少到一棵树,使用并查集数据结构来跟踪树。我们从一个顶点开始,每个顶点有一棵树,只包含一个顶点。当我们有多个树时,我们选择连接两个不同树的具有最小权重的边(我们不希望产生循环),并将这两棵树合并在一起。最后,结果树将是总边权重最小化的树。克鲁斯卡尔算法的运行时间也是 O(VlogE)。
A* 搜索
A* 搜索算法是在解决路径查找问题时非常常见的一种算法。它也解决了最短路径问题,通过引入启发式方法来增强迪杰斯特拉算法。启发式是一种对给定成本的实用估计,不保证是最优或完美的,但对于立即目标或引导搜索是足够的。其基本思想是,当将此启发式添加到已计算出的节点估计距离时,可以引导搜索朝向目标,并避免访问某些顶点。
例如,如果我们使用欧几里得距离(例如,两点之间的直线距离)从我们的位置到给定迷宫的出口,我们可以引导搜索朝那个方向进行,避免访问某些不必要的位置。
最大流
一些有向加权图可以看作是流网络。在流网络中,边权重代表容量,每条边接收的流量不能超过边的容量。边上的标签代表边的已用容量和总容量。最大流试图在考虑单个源(初始流量开始的地方)和单个汇(流量结束的地方)的情况下,在网络中找到最大可行流。最大流问题允许解决相关问题,如成对分配。有各种算法可以解决最大流问题。其中最著名的三个是 Ford-Fulkerson 算法、Edmonds-Karp 算法和 Dinic 算法。
Ford-Fulkerson 算法背后的思想是在流网络中反复寻找增广路径。增广路径是仍然有可用容量的路径。虽然可以找到增广路径,但可以通过路径的容量添加流量,并重复此过程。Ford-Fulkerson 算法的运行时间是O(Ef),其中f是图的流的最大值。Edmonds-Karp 算法通过始终选择最短的增广路径来改进 Ford-Fulkerson 算法。Edmonds-Karp 算法的运行时间复杂度是O(VE²),与最大流值无关。Dinic 算法在O(V²E)时间内运行,也基于最短增广路径,但使用了一些使其更适合稀疏图的概念。
理解问题的复杂度类别
到目前为止介绍的所有算法几乎都在多项式时间内运行(例如,对于大小为n的输入,它们的最坏情况下运行时间是常数k的O(n^k))。然而,有些问题根本无法解决,或者还没有找到多项式时间算法。
一个无法解决的问题的例子是停机问题。停机问题是从计算机程序和输入的描述中确定程序是否会完成运行或继续无限运行的问题。艾伦·图灵证明了对于所有(程序,输入)对,不存在解决停机问题的通用算法。
通常将可以用多项式时间算法解决的问题(例如,那些最坏情况下运行时间为常数k的O(n^k))称为“可解的”或“简单的”,而需要超多项式时间算法解决的问题(例如,其运行时间不受任何多项式的上界限制)称为“不可解的”或“困难的”。
存在一类称为NP-完全(NPC)的问题,还没有人找到解决它们的算法。然而,还没有人能够证明对于它们中的任何一个,都不存在多项式时间算法。
另一类问题被称为NP问题,其解决方案可以在多项式时间内验证。这意味着,给定一个问题及其一个可能的解决方案,可以在多项式时间内验证该解决方案是否正确。
P 类中的所有问题也都在 NP 类中。NPC 包括属于 NP 类和 NP-hard 类的问题。一个问题是 NP-hard,如果解决它的算法可以被转换成解决 NP 问题的算法。
理论计算机科学中最深奥的未解研究问题之一是 P 是否真的与 NP 不同(例如,P != NP)。
NPC 问题的例子如下:
-
在图中找到最长路径
-
在图中找到一条路径,该路径访问所有顶点恰好一次(称为哈密顿路径)
一个常见的 NP-hard 问题示例是在图中找到一条路径,该路径访问所有顶点恰好一次并返回起点。这个问题包括找到最小权重的哈密顿回路,通常描述为旅行商问题,因为它模拟了需要访问所有城市并返回家乡的旅行商的问题。
摘要
在本章中,我们介绍了图,正式化了它们的定义,并展示了在计算机程序中用两种不同的方式表示它们。之后,我们探讨了遍历图的方法,将它们作为构建更复杂算法的基石。然后,我们研究了两种在图中找到最短路径的不同算法。
在本书的结尾,我们为好奇的学生提供了自学指南。数据结构和算法的世界是广阔的,需要一种数学推理能力,这需要一些学习和实践。然而,对于软件工程师来说,生活中最有成就感的事情之一就是想出巧妙的算法来解决复杂问题。








浙公网安备 33010602011771号