Java9-数据结构与算法-全-

Java9 数据结构与算法(全)

原文:zh.annas-archive.org/md5/3d77ee4a60cc89bd50001299319b598a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Java 几十年来一直是企业系统中最受欢迎的编程语言之一。Java 受欢迎的原因之一是其平台独立性,这使得人们可以在任何系统上编写和编译代码,并在任何其他系统上运行,无论硬件和操作系统如何。Java 受欢迎的另一个原因是该语言由一个行业参与者社区标准化。后者使得 Java 能够跟上最新的编程思想,而不会因为过多的无用特性而负担过重。

由于 Java 的流行,有许多开发者积极参与 Java 开发。当涉及到学习算法时,最好使用自己最熟悉的语言。这意味着编写一本用 Java 编写的算法书是非常有意义的。这本书涵盖了最常用的数据结构和算法。它旨在为已经了解 Java 但不太熟悉算法的人提供帮助。这本书应该是学习该主题的第一块垫脚石。

本书涵盖内容

第一章, 为什么费事? – 基础,通过示例介绍了学习算法和数据结构的目的。在这个过程中,它向您介绍了渐近复杂度、大 O 符号和其他符号的概念。

第二章, 齿轮和滑轮 – 构成块,向您介绍了数组和不同类型的链表,以及它们的优缺点。这些数据结构将在后面的章节中用于实现抽象数据结构。

第三章, 协议 – 抽象数据类型,向您介绍了抽象数据类型的概念,并介绍了栈、队列和双端队列。它还涵盖了使用前一章中描述的数据结构的不同实现。

第四章, 转折 – 函数式编程,向您介绍适合 Java 程序员的函数式编程思想。本章还介绍了 Java 8 中可用的 lambda 特性,并帮助读者习惯以函数式方式实现算法。本章还向您介绍了 monads 的概念。

第五章, 高效搜索 – 二分搜索和排序,介绍了使用排序列表上的二分搜索进行高效搜索。然后,它继续描述用于获取排序数组的基本算法,以便可以进行二分搜索。

第六章, 高效排序 – 快速排序和归并排序,介绍了两种最流行且高效的排序算法。本章还提供了分析,说明了为什么这比基于比较的排序算法可以达到最优。

第七章, 树的概念,介绍了树的概念。它特别介绍了二叉树,并涵盖了树的不同遍历方式:广度优先和深度优先,以及二叉树的前序、后序和中序遍历。

第八章, 更多关于搜索 – 平衡二叉搜索树和哈希表,涵盖了使用平衡二叉搜索树(即 AVL 树)和红黑树以及哈希表进行搜索。

第九章, 高级通用数据结构,介绍了优先队列及其使用堆和二叉森林的实现。最后,本章介绍了使用优先队列进行排序。

第十章, 图的概念,介绍了有向和无向图的概念。然后,它讨论了在内存中表示图的方法。涵盖了深度优先和广度优先遍历,介绍了最小生成树的概念,并讨论了循环检测。

第十一章, 响应式编程,向读者介绍了 Java 中的响应式编程概念。这包括基于观察者模式响应式编程框架的实现以及在其之上的功能 API。示例展示了与传统的命令式风格相比,响应式框架的性能提升和使用便捷性。

您需要为本书准备的内容

要运行本书中的示例,您需要一个配备任何现代流行操作系统的计算机,例如 Windows、Linux 或 Macintosh 的某个版本。您需要在您的计算机上安装 Java 9,以便可以从命令提示符调用javac

本书面向的对象

本书是为想要了解数据结构和算法的 Java 开发者编写的。假设您对 Java 有基本的了解。

习惯用法

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将如下显示:“我们可以通过使用include指令来包含其他上下文。”

代码块应如下设置:

public static void printAllElements(int[] anIntArray){ 
        for(int i=0;i<anIntArray.length;i++){ 
            System.out.println(anIntArray[i]); 
        } 
    }

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

public static void printAllElements(int[] anIntArray){ 
        for(int i=0;i<anIntArray.length;i++){ 
            System.out.println(anIntArray[i]); 
        } 
    }

任何命令行输入或输出都应如下编写:

# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
 /etc/asterisk/cdr_mysql.conf

新术语重要词汇将以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,将以如下方式显示:“点击下一步按钮将您带到下一屏幕。”

注意

警告或重要注意事项将以这样的框显示。

小贴士

技巧和窍门将以这样的形式显示。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发您真正能从中获得最大收益的标题。

要向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

您还可以通过点击 Packt Publishing 网站书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录您的 Packt 账户。

文件下载完成后,请确保您使用最新版本的软件解压缩或提取文件夹:

  • Windows 上的 WinRAR / 7-Zip

  • Mac 上的 Zipeg / iZip / UnRarX

  • Linux 上的 7-Zip / PeaZip

本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Java-9-Data-Structures-and-Algorithms。我们还有其他来自我们丰富图书和视频目录的代码包可供使用,网址为 github.com/PacktPublishing/Java9DataStructuresandAlgorithm。查看它们吧!

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从 www.packtpub.com/sites/default/fles/downloads/Java9DataStructuresandAlgorithms_ColorImages.pdf 下载此文件。

错误更正

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详情来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误部分下的现有错误列表中。

查看之前提交的错误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误部分下。

盗版

互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面提供的帮助。

问答

如果您在这本书的任何方面遇到问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一章. 为何要费心? – 基础

由于你已经了解了 Java,你当然已经编写了一些程序,这意味着你已经编写了算法。“那么,那是什么?”你可能会问。算法是一系列明确的步骤,处理器可以机械地遵循,或者不涉及任何形式的智能,在有限的时间内产生期望的输出。嗯,这是一个很长的句子。用更简单的话说,算法就是完成某事的明确步骤列表。它听起来就像我们正在谈论一个程序。程序不也是我们给计算机的一串指令,以便得到期望的结果吗?是的,这意味着算法实际上就是一个程序。嗯,其实不是,但几乎如此。算法是一个没有特定编程语言细节的程序;它是程序的基本思想;把它想象成一个程序抽象,你不需要担心程序的语法细节。

嗯,既然我们已经了解了编程,而且算法只是一个程序,我们就完成了,对吧?其实不是。关于程序和算法有很多东西要学习,也就是说,如何编写一个算法来实现特定的目标。当然,通常有解决特定问题的许多方法,并不一定都是平等的。一种方法可能比另一种方法快,这是算法的一个重要特性。当我们研究算法时,执行所需的时间是最重要的。事实上,这是它们的第二重要特性,第一重要的是它们的正确性。

在本章中,我们将更深入地探讨以下概念:

  • 测量算法的性能

  • 渐近复杂度

  • 为什么渐近复杂度很重要

  • 为什么算法的显式研究很重要

算法的性能

没有人愿意永远等待某件事情完成。让程序运行得更快当然很重要,但我们如何知道程序是否运行得快呢?第一个逻辑步骤就是测量程序运行所需的时间。假设我们有一个程序,给定三个数字,abc,确定 ab 次幂除以 c 的余数。

例如,假设 a=2b=10c = 7ab 次幂等于 2¹⁰ = 10241024 % 7 = 2*。所以,给定这些值,程序需要输出 2。下面的代码片段展示了实现这一点的简单且明显的方法:

public static long computeRemainder(long base, long power, long divisor){ 
  long baseRaisedToPower = 1;
  for(long i=1;i<=power;i++){ 
    baseRaisedToPower *= base;
  }
  return baseRaisedToPower % divisor;
}

我们现在可以通过运行程序十亿次并检查它运行所需的时间来估计所需的时间,如下面的代码所示:

public static void main(String [] args){
  long startTime = System.currentTimeMillis();
  for(int i=0;i<1_000_000_000;i++){
    computeRemainder(2, 10, 7);
  }
  long endTime = System.currentTimeMillis();
  System.out.println(endTime - startTime);
}

在我的计算机上,它需要 4,393 毫秒。所以每次调用的耗时是 4,393 除以十亿,即大约 4.4 纳秒。看起来做任何计算都是一个合理的时间。但如果输入不同呢?如果我传递 power = 1000会怎样?让我们检查一下。现在运行一亿次需要大约 420,000 毫秒,或者每次运行大约 420 纳秒。显然,做这个计算所需的时间依赖于输入,这意味着任何关于程序性能的合理讨论都需要考虑程序的输入。

好吧,所以我们可以说,我们的程序运行所需的时间大约是 0.42 X 次幂纳秒。

如果你用输入(210007)运行程序,你会得到一个输出为0的结果,这是不正确的。正确的输出应该是2。那么这里发生了什么?答案是,长整型变量可以持有的最大值是 2 的 63 次幂减 1,即 9223372036854775807L。2 的 1000 次幂当然比这个大得多,导致值溢出,这把我们带到了下一个问题:程序运行需要多少空间?

通常,运行程序所需的内存空间可以用程序操作所需的字节数来衡量。当然,它需要至少存储输入和输出的空间。它可能还需要额外的空间来运行,这被称为辅助空间。很明显,就像时间一样,运行程序所需的内存空间在一般情况下也会依赖于输入。

在时间的情况下,除了时间依赖于输入之外,它还取决于你在哪台计算机上运行它。在我的计算机上运行需要 4 秒钟的程序,在 90 年代的一台非常旧的计算机上可能需要 40 秒钟,而在你的计算机上可能只需要 2 秒钟。然而,你实际运行的计算机只通过一个常数倍数来提高时间。为了避免过多地详细说明程序运行的硬件细节,我们不说程序大约需要 0.42 X 次幂毫秒,而可以说所需时间是常数乘以次幂,或者简单地说它是与次幂成比例的。

说计算时间与次幂成比例实际上使得它与硬件无关,甚至与程序编写的语言也无关,我们可以仅通过查看程序并分析它来估计这种关系。当然,运行时间在某种程度上与次幂成比例,因为有一个循环执行了次幂次,除非,当然,次幂非常小,以至于循环外的其他一次操作实际上开始变得重要。

最佳情况、最坏情况和平均情况复杂度

通常,算法处理特定输入所需的时间或空间不仅取决于输入的大小,还取决于输入的实际值。例如,如果输入已经排序,则用于按升序排列值列表的某些算法可能花费的时间要少得多,而如果它是一个任意无序列表,则花费的时间要多。这就是为什么,通常,我们必须有不同的函数来表示不同情况下所需的时间或空间。然而,最佳情况是,对于特定大小的输入所需资源最少。也会有最坏情况,其中算法需要为特定大小的输入最多资源。平均情况是对给定大小输入的资源消耗的估计,通过对具有该大小的所有输入值进行加权平均,权重为它们发生的概率。

渐近复杂性的分析

我们似乎已经找到了一个想法,一个关于运行时间的抽象概念。让我们把它讲清楚。以抽象的方式,我们通过使用所谓的渐近复杂性来分析程序的运行时间和所需空间。

我们只关心当输入非常大时会发生什么,因为处理小输入所需的时间实际上并不重要;无论如何它都会很小。所以,如果我们有 + x²,并且如果 x 非常大,它几乎等同于 ^(x3)。我们也不考虑函数的常数因子,因为我们之前已经指出,它依赖于我们运行程序的特定硬件和特定语言。用 Java 实现的算法将比用 C 语言编写的相同算法慢一个常数倍。处理这些抽象定义算法复杂性的正式方法称为渐近界。严格来说,渐近界是针对函数的,而不是针对算法。其想法是首先将给定算法处理输入所需的时间或空间表示为输入大小的函数,然后寻找该函数的渐近界。

我们将考虑三种类型的渐近界——上界、下界和紧界。我们将在以下章节中讨论这些内容。

函数的渐近上界

如其名所示,上界为函数的增长设定了一个上限。上界是至少与原始函数一样快速增长的另一个函数。谈论一个函数代替另一个函数有什么意义呢?我们使用的函数通常比实际用于计算处理特定大小输入所需运行时间或空间的函数要简单得多。比较简化函数比比较复杂函数容易得多。

对于一个函数 f,我们以下列方式定义符号 O,称为大 O

  1. f(x) = O(f(x)).

    • 例如, = O(x³**)
  2. 如果 f(x) = O(g(x)),那么对于任何非零常数 kk f(x) = O(g(x))

    • 例如,5x³ = O(x³**)2 log x = O(log x)-x³ = O(x³**)(取 k= -1)。
  3. 如果 f(x) = O(g(x)) 并且对于所有足够大的 x|h(x)|<|f(x)|,那么 f(x) + h(x) = O(g(x))

    • 例如,5x³ - 25x² + 1 = O(x³**),因为对于足够大的 x|- 25x² + 1| = 25x² - 1 远小于 | 5x³**| = 5x³。所以,f(x) + g(x) = 5x³ - 25x² + 1 = O(x³**),因为 f(x) = 5x³ = O(x³**)

    • 我们可以用类似的逻辑证明 = O( 5x³ - 25x² + 1)

  4. 如果 f(x) = O(g(x)) 并且对于所有足够大的 x|h(x)| > |g(x)|,那么 f(x) = O(h(x))

    • 例如, = O(x⁴**),因为如果 x 足够大,x⁴ >

注意,每当函数上有不等式时,我们只对 x 大时发生的情况感兴趣;我们不会关心 x 小时发生的情况。

注意

为了总结上述定义,你可以省略常数乘数(规则 2)并忽略低阶项(规则 3)。你也可以进行高估(规则 4)。你也可以对这些组合进行所有操作,因为规则可以多次应用。

我们必须考虑函数的绝对值,以适应值可能为负的情况,这在运行时永远不会发生,但我们仍然保留它以示完整。

注意

关于符号 = 有一点不寻常。仅仅因为 f(x) = O(g(x)),并不意味着 O(g(x)) = f(x)。事实上,后者甚至没有任何意义。

对于所有目的来说,只需要知道前面的大 O 符号的定义就足够了。如果你感兴趣,可以阅读以下正式定义。否则,你可以跳过本小节的其余部分。

上述想法可以用一种正式的方式总结。我们说表达式 f(x) = O(g(x)) 的意思是存在正的常数 Mx⁰,使得当 x > x⁰ 时,|f(x)| < M|g(x)|。记住,你只需要找到一个满足条件的 Mx⁰ 的例子,就可以断言 f(x) = O(g(x))

例如,图 1 展示了一个函数 T(x) = 100x² +2000x+200 的例子。这个函数是 O(x² ),其中 x⁰ = 11M = 300。当 x=11 时,300x² 的图像超过了 T(x) 的图像,然后一直保持在 T(x) 之上直到无穷大。注意,对于较小的 x 值,300x² 的图像低于 T(x),但这并不影响我们的结论。

函数的渐近上界

图 1. 渐近上界

为了证明它与前面的四点相同,首先考虑 x⁰ 是确保 x 足够大的方式。我把它留给你去证明上述四个条件来自形式定义。

我将展示一些使用形式定义的例子:

  • 5x² = O(x²**),因为我们可以说,例如,x⁰ = 10M = 10,因此当 x > x⁰ 时,f(x) < Mg(x),即 5x² < 10x²x > 10

  • 同样,5x² = O(x³**) 也是正确的,因为我们可以说,例如,x⁰ = 10M = 10,因此 f(x) < Mg(x)x > x⁰,也就是说,5x² < 10x³x > 10。这强调了这样一个观点:如果 f(x) = O(g(x)),那么当 h(x) 是至少以与 f(x) 一样快的速度增长的函数时,f(x) = O(h(x)) 也是正确的。

  • 那么函数 f(x) = 5x² - 10x + 3 呢?我们可以很容易地看出,当 x 足够大时,5x² 将远远超过项 10x。为了证明我的观点,我可以说 x>5, 5x²**> 10x。每次我们将 x 增加 1,5x² 的增量是 10x + 1,而 10x 的增量只是一个常数,10。对于所有正的 x10x+1 > 10,所以很容易看出为什么 5x² 总是会高于 10x,随着 x 的增加而增加。

一般而言,任何形式为 a[n] x^n + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] = O(x^n) 的多项式。为了证明这一点,我们首先会看到 a[0] = O(1)。这是真的,因为我们可以有 x⁰ = 1M = 2|a[0]|,并且当 x > 1 时,我们将有 |a[0]**| < 2|a[0] |

现在,让我们假设对于某个 n 是正确的。因此,a[n] x^n + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] = O(x^n)。当然,这意味着存在某个 M[n]x⁰,使得当 x>x⁰ 时,|a[n] x^n + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] | < M[n] x^n。我们可以安全地假设 x⁰ >2,因为如果不是这样,我们只需将其增加 2 以得到一个新的 x⁰,这个新的 x⁰ 至少是 2

现在,|a[n] x^n + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] | < M[n] x^n 蕴含 |a[n+1] x^(n+1) + a[n] x^n + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] | ≤ |a[n+1] x^(n+1) | + |a[nxn] + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] | < |a[n+1] x^(n+1) | + M[n] x^n.

这意味着 |a[n+1] x^(n+1) | + M[n] x^n > |a[n] x^n + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] |.

如果我们取 M[n+1] = |a[n+1] | + M[n],我们可以看到 M[n+1] x[n+1] = |a[n+1] | x[n+1] + M[n] x^(n+1) =|a[n+1] x^(n+1) | + M[n] x^(n+1) > |a[n+1] x^(n+1) | + M[n] x^n > |a[n+1] x^(n+1) + a[n] x^n + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] |.

也就是说,|a[n+1] x^(n+1) + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] |< M[n+1] x^(n+1) 对于所有 x > x[0],也就是说,a[n+1] x^(n+1) + a[n] x^n + a[n-1] x^(n-1) + a[n-2] x^(n-2) + … + a[0] = O(x^(n+1) ).

现在,我们已经证明了对于 n=0 是正确的,即 a0 = O(1)。这意味着,根据我们最后的结论,a 1[x] + a[0] = O(x)。这意味着,按照同样的逻辑,a[2] + a[1] x + a[0] = O(x² ),以此类推。我们可以很容易地看出,这意味着对于所有正整数次的多项式都是正确的。

算法的渐近上界

好的,所以我们找到了一种抽象地指定一个单参数函数上界的方法。当我们谈论程序的运行时间时,这个参数必须包含有关输入的信息。例如,在我们的算法中,我们可以说,执行时间等于 O(幂)。这种直接指定输入的方法对于所有解决相同问题的程序或算法都是完美的,因为它们的输入都是相同的。然而,我们可能还想用同样的技术来衡量问题的复杂性:这是解决该问题的最有效程序或算法的复杂性。如果我们试图比较不同问题的复杂性,那么我们会遇到障碍,因为不同的问题会有不同的输入。我们必须用所有问题共有的某个东西来指定运行时间,那就是输入的大小,以比特或字节为单位。当我们需要表达足够大的参数,幂时,我们需要多少比特?大约 log[2] (幂)。因此,在指定运行时间时,我们的函数需要一个大小为 log[2] (幂)lg (幂) 的输入。我们已经看到,我们算法的运行时间与幂成正比,即常数乘以幂,这是常数乘以 2 lg(power) = O(2x),其中 x= lg(power),这是输入的大小。

函数的渐近下界

有时候,我们不想赞扬一个算法,我们想避开它;例如,当算法是由我们不喜欢的某人编写的,或者当某个算法表现真的很差时。当我们想因为它糟糕的性能而避开它时,我们可能想谈谈它在最佳输入下表现得多糟糕。渐近下界可以定义得就像大于等于可以用小于等于来定义一样。

一个函数 f(x) = Ω(g(x)) 当且仅当 g(x) = O(f(x)). 以下是一些例子:

  • 由于 = O(x³)x³* = Ω(x³)*

  • 由于 = O(x³)5x³* = Ω(x³)*

  • 由于 = O(5x³ - 25x² + 1),5x³ - 25x² + 1 = Ω(x³)

  • 由于 = O(x⁴)x⁴* = O(x³)*

再次,对于那些感兴趣的人,我们说表达式 f(x) = Ω(g(x)) 意味着存在正的常数 Mx[0],使得当 x > x[0] 时,|f(x)| > M|g(x)|,这等同于说当 x > x[0] 时,|g(x)| < (1/M)|f(x)|,也就是说,g(x) = O(f(x))

上述定义是由唐纳德·克努特提出的,这是一个更强、更实用的定义,用于计算机科学。在此之前,有一个更复杂、覆盖更多边缘情况的下界 Ω 的不同定义。我们在这里不会讨论边缘情况。

当谈论一个算法有多糟糕时,我们可以使用最佳情况的渐近下界来真正地表达我们的观点。然而,即使是对算法最坏情况的批评也是一个相当有说服力的论点。当我们不想找出渐近紧界时,我们也可以使用最坏情况的渐近下界。一般来说,渐近下界可以用来显示当输入足够大时函数的最小增长率。

函数的渐近紧界

还有一种界限,在渐近复杂性的意义上相当于相等。theta 界被指定为f(x) = ω(g(x))当且仅当f(x) = O(g(x))f(x) = Ω(g(x)). 让我们通过一些例子来更好地理解这一点:

  • 由于5x³ = O(x³)5x³ = Ω(x³),因此5x³ = ω(x³)

  • 由于5x³ + 4x² = O(x³)5x³ + 4x² = Ω(x³),因此5x³ + 4x² = O(x³)

  • 然而,尽管5x³ + 4x² = O(x⁴),因为它不是Ω(x⁴),所以它也不是ω**(x⁴)

  • 同样,5x³ + 4x²不是ω**(x²),因为它不是O(x²)

简而言之,在确定紧界时,你可以忽略常数乘数和低阶项,但不能选择一个比给定函数增长更快或更慢的函数。检查界是否正确最好的方法是分别检查O和条件,只有当它们相同时,才可以说它有一个 theta 界。

注意,由于算法的复杂性取决于特定的输入,一般来说,当复杂性不受输入性质的影响时,使用紧界。

在某些情况下,我们试图找到平均情况复杂度,特别是在上限仅在极端病态输入的情况下发生时。但由于平均必须根据输入的概率分布来取,它不仅仅依赖于算法本身。界限本身只是特定函数的界限,而不是算法的界限。然而,算法的总运行时间可以表示为一个根据输入改变公式的广义函数,该函数可能具有不同的上界和下界。讨论渐近平均界是没有意义的,因为我们已经讨论过,平均情况不仅仅依赖于算法本身,还依赖于输入的概率分布。因此,平均情况被表述为一个函数,它将是所有输入的概率平均运行时间,并且通常报告该平均函数的渐近上界。

我们算法的优化

在我们深入优化算法之前,我们首先需要纠正大幂次算法的错误。我们将使用一些技巧来实现这一点,如下所述。

解决大幂次问题

配备了所有渐近分析的工具箱,我们将开始优化我们的算法。然而,既然我们已经看到我们的程序对于即使是适度的大的幂值也无法正常工作,那么让我们首先解决这个问题。有两种方法可以解决这个问题;一种是为存储所有中间产品所需的空间量实际给出一个数值,另一种是通过一个技巧将所有中间步骤限制在long数据类型可以支持的值范围内。我们将使用二项式定理来完成这部分工作。

作为提醒,二项式定理说,对于正整数n,有(x+y)^n = x^n + ^n* C[1] x^(n-1) y + ^n C[2] x^(n-2) + ^n* C[3] x^(n-3) + ^n* C[4] x^(n-4) y⁴ + … ^n* C[n-1] y^(n-1) + y^n。这里的重要点是所有系数都是整数。假设r是除以b后 a 的余数。这使得a = kb + r对于某个正整数k成立。这意味着r = a-kb,并且r^n = (a-kb)^n*。

如果我们使用二项式定理展开这个式子,我们得到r^n = a^n - ^n* C[1] a^(n-1) .kb + ^n C[2] a^(n-2) .(kb)² - ^n C[3] a^(n-3) .(kb)³ + ^n C[4] a^(n-4) .(kb)⁴ + … ^n C[n-1] .(kb)^(n-1) ± (kb)^n*。

注意,除了第一项之外,所有其他项都有b作为因子。这意味着我们可以写出r^n = a^n + bM,其中M是一个整数。如果我们现在将等式两边都除以b并取余数,我们得到r^n % b = a^n % b,其中%是 Java 中用于找到余数的运算符。

现在的想法是在每次求幂时都取除数的余数。这样,我们永远不需要存储比余数范围更大的值:

public static long computeRemainderCorrected(long base, long power, long divisor){
  long baseRaisedToPower = 1;
  for(long i=1;i<=power;i++){
    baseRaisedToPower *= base;
    baseRaisedToPower %= divisor;
  }
  return baseRaisedToPower;
}

这个程序显然不会改变程序的时间复杂度;它只是解决了大幂值的问题。程序还保持常数空间复杂度。

提高时间复杂度

当前运行时间复杂度是O(2^x),其中x*是我们已经计算过的输入大小。我们能做得比这更好吗?让我们看看。

我们需要计算的是(base^(power) ) % divisor。这当然等同于(base²)^(power/2) * % divisor。如果我们有一个偶数power,我们就将操作次数减半。如果我们能一直这样做,我们就可以在n步内将basepower提高到2n*,这意味着我们的循环只需要运行*lg(power)*次,因此,复杂度是*O(lg(2x* )) = O(x),其中x是存储power所需的位数。这对于计算大幂值的步骤数量是一个实质性的减少。

然而,有一个陷阱。如果power不能被2整除会发生什么?嗯,那么我们可以写成(base^(power) % divisor) = (base ((base^(power-1) % divisor) = (base ((base²)^(power-1) % divisor*),其中power-1当然是偶数,计算可以继续。我们将把这个代码写在一个程序中。想法是从最高有效位开始,逐渐向不那么重要的比特移动。如果一个带有1的比特后面有n个比特,它表示将结果乘以基数,然后在此比特之后平方n`次。我们通过平方后续步骤来累积这个平方。如果我们找到一个零,我们为了累积之前比特的平方而继续平方:

public static long computeRemainderUsingEBS(long base, long power, long divisor){
  long baseRaisedToPower = 1;
  long powerBitsReversed = 0;
  int numBits=0;

首先反转我们的power比特,使其更容易从最不重要的侧面访问,这更容易访问。我们还计算比特的数量以供以后使用:

  while(power>0){
    powerBitsReversed <<= 1;
    powerBitsReversed += power & 1;
    power >>>= 1;
    numBits++;
  }

现在我们一次提取一个比特。由于我们已经反转了比特的顺序,所以我们首先得到的是最高有效位。为了对顺序有一个直观的认识,我们收集的第一个比特最终会被平方最多的次数,因此它将像最高有效位一样起作用:

  while (numBits-->0){
    if(powerBitsReversed%2==1){
      baseRaisedToPower *= baseRaisedToPower * base;
    }else{
      baseRaisedToPower *= baseRaisedToPower;
    }
    baseRaisedToPower %= divisor;
    powerBitsReversed>>>=1;
  }
  return baseRaisedToPower;
}

我们测试算法的性能;我们通过以下代码比较相同计算与早期和最终算法所需的时间:

public static void main(String [] args){
  System.out.println(computeRemainderUsingEBS(13, 10_000_000, 7));

  long startTime = System.currentTimeMillis();
  for(int i=0;i<1000;i++){
    computeRemainderCorrected(13, 10_000_000, 7);
  } 
  long endTime = System.currentTimeMillis();
  System.out.println(endTime - startTime);

  startTime = System.currentTimeMillis();
  for(int i=0;i<1000;i++){
    computeRemainderUsingEBS(13, 10_000_000, 7);
  }
  endTime = System.currentTimeMillis();
  System.out.println(endTime - startTime);
}

第一个算法在我的电脑上完成所有 1,000 次执行需要 130,190 毫秒,而第二个算法只需 2 毫秒就能完成同样的任务。这清楚地显示了对于像 1000 万这样的大幂次,性能的巨大提升。我们反复平方项以实现像我们那样进行指数运算的算法被称为...嗯,平方乘法。这个例子应该能够激励你研究算法,因为它可以明显地提高计算机程序的性能。

摘要

在本章中,你看到了我们如何分别以秒和字节为单位考虑算法的运行时间和所需的内存。由于这取决于特定的实现、编程平台和硬件,我们需要一个关于以抽象方式讨论运行时间的概念。渐进复杂度是当输入非常大时函数增长的度量。我们可以用它来抽象我们对运行时间的讨论。这并不是说程序员不应该花时间使程序运行速度提高一倍,但这只有在程序已经以最小渐进复杂度运行之后才会发生。

我们还看到,渐近复杂度不仅是我们试图解决的特定问题的属性,而且是我们解决它的特定方式(即我们使用的特定算法)的属性。我们还看到,两个解决相同问题但在运行时使用不同算法和不同渐近复杂度的程序,对于大输入可以执行得非常不同。这应该足以激发我们专门研究算法的动机。

在接下来的章节中,我们将研究在日常生活中最常用的算法技巧和概念。我们将从非常简单的那些开始,这些也是更高级技术的基础。当然,这本书绝不是全面的;目标是提供足够的背景知识,让你对基本概念感到舒适,然后你可以继续阅读。

第二章.齿轮和滑轮 - 构建块

我们在上一章讨论了算法,但本书的标题也包括了“数据结构”这个术语。那么什么是数据结构呢?数据结构是数据在内存中的组织形式,通常是经过优化的,以便特定算法可以使用。我们已经看到,算法是一系列导致期望结果的步骤。在程序的情况下,总有一些输入和输出。输入和输出都包含数据,因此必须以某种方式组织。因此,算法的输入和输出都是数据结构。实际上,算法必须经过的所有中间状态也必须以某种形式存储在数据结构中。没有算法来操作它们,数据结构就没有任何用途,没有数据结构,算法也无法工作。这是因为它们就是这样获取输入、发出输出或存储它们的中间状态的。数据可以组织的有很多种方式。简单的数据结构也是不同类型的变量。例如,int 是一个存储一个 4 字节整数值的数据结构。我们甚至可以有存储一组特定类型值的类。然而,我们还需要考虑如何存储大量相同类型值的集合。在这本书中,我们将用剩下的时间讨论同一类型值的集合,因为存储集合的方式决定了哪些算法可以对其工作。一些存储值集合的最常见方式有自己的名称;我们将在本章中讨论它们。它们如下:

  • 数组

  • 链表

  • 双向链表

  • 循环链表

这些是我们将用来构建更复杂的数据结构的基本构建块。即使我们不直接使用它们,我们也会使用它们的概念。

数组

如果你是一名 Java 程序员,你一定使用过数组。数组是为一系列数据提供的最基本存储机制。数组最好的地方是数组的元素是顺序存储的,并且可以通过单条指令完全随机地访问。

通过元素逐个遍历数组元素非常简单。由于任何元素都可以随机访问,你只需不断递增索引并保持访问此索引处的元素。以下代码展示了数组的遍历和随机访问:

    public static void printAllElements(int[] anIntArray){ 
        for(int i=0;i<anIntArray.length;i++){ 
            System.out.println(anIntArray[i]); 
        } 
    }

数组中元素的插入

数组中的所有元素都存储在连续的内存中。这使得可以在常数时间内访问任何元素。程序只需计算与索引相对应的偏移量,然后直接读取信息。但这意味着它们也是有限的,并且具有固定的大小。如果你想在数组中插入一个新元素,你需要创建一个包含一个更多元素的新数组,并将整个原始数据以及新值一起复制。为了避免所有这些复杂性,我们将从将现有元素移动到新位置开始。我们想要做的是取出一个元素,将所有元素向上移动到目标位置以腾出空间,并将提取的值插入到相同的位置。

数组中元素的插入

图 1:将现有数组元素插入新位置

前面的图解释了我们所说的这种操作。细黑箭头显示了正在重新插入的元素的运动,粗白箭头显示了数组元素的变化。在每个情况下,底部图显示了重新插入完成后的数组。请注意,移动要么向左要么向右,这取决于起始索引和结束索引。让我们用代码来实现这一点:

       public static void insertElementAtIndex(int[] array, int startIndex, int targetIndex){ 
         int value = array[startIndex]; 
         if(startIndex==targetIndex){ 
            return; 
         }else if(startIndex < tarGetIndex){ 
            for(int i=startIndex+1;i<=targetIndex;i++){ 
                array[i-1]=array[i]; 
            } 
            array[targetIndex]=value; 
         }else{ 
            for(int i=startIndex-1;i>=targetIndex;i--){ 
                array[i+1]=array[i]; 
            } 
            array[targetIndex]=value; 
         } 
       }

前述算法的运行时间复杂度是什么?对于所有我们的情况,我们只考虑最坏的情况。算法何时表现最差?为了理解这一点,让我们看看算法中最频繁的操作是什么。当然是在循环中发生的移动。当startIndex位于数组的开始而targetIndex位于末尾或相反时,移动次数达到最大。在这种情况下,必须逐个移动除一个元素之外的所有元素。在这种情况下,运行时间必须是数组元素数量的一些常数倍加上一些其他常数,以考虑非重复操作。因此,它是T(n) = K(n-1)+C,其中KC是常数,n是数组中的元素数量,T(n)是算法的运行时间。这可以表示如下:

T(n) = K(n-1)+C = Kn + (C-K)

以下步骤解释了表达式的含义:

  1. 根据大 O 定义的规则 1,T(n) = O(Kn + (C-K))

  2. 根据定义的规则 3,T(n) = O(Kn)

  3. 我们知道对于足够大的n|-(C-K)| < |Kn + (C-K)|是正确的。因此,根据规则 3,由于T(n) = O(Kn + (C-K)),这意味着T(n) = O(Kn + (C-K) + (-(C-K))),即T(n) = O(Kn)

  4. 最后,根据规则 2,T(n) = O(n)

现在由于数组是算法的主要输入,输入的大小用n表示。所以我们将说,算法的运行时间是O(n),其中n是输入的大小。

插入新元素及其附加过程

接下来,我们继续到插入新元素的过程。由于数组的大小是固定的,插入操作需要我们创建一个新的数组并将所有之前的元素复制到其中。以下图解展示了在新的数组中进行插入的概念:

插入新元素及其附加过程

图 2:将新元素插入到数组中

以下代码正是这样做的:

    public static int [] insertExtraElementAtIndex(int[] array, int index, int value){ 
        int [] newArray = new int[array.length+1]; 

首先,你只需像在原始数组中一样复制目标位置之前的所有元素:

        for(int i=0;i<index;i++){ 
            newArray[i] = array[i]; 
        } 

然后,新值必须放置在正确的位置:

        newArray[index]=value;

最后,通过将它们的位移动一位来复制数组中的其余元素:

        for(int i=index+1;i<newArray.length;i++){ 
            newArray[i]=array[i-1]; 
        } 
        return newArray; 
    }

当代码准备好后,附加操作就意味着只需将其插入到末尾,如下面的代码所示:

    public static int[] appendElement(int[] array, int value){ 
        return insertExtraElementAtIndex(array, array.length, value); 
    }

前述算法的运行时间复杂度是多少?好吧,无论我们做什么,我们都必须将原始数组中的所有元素复制到新数组中,这是循环中的操作。因此,运行时间是 T(n) = Kn + C,其中 KC 是一些常数,而 n 是数组的大小,也就是输入的大小。我将这个验证步骤留给你,以便找出这个:T(n) = O(n)

链表

数组非常适合存储数据。我们也看到,数组的任何元素都可以在 O(1) 时间内读取。但是数组的大小是固定的。改变数组的大小意味着创建一个新的数组并将所有元素复制到原始数组中。对于调整大小问题的最简单解决办法是将每个元素存储在不同的对象中,然后在每个元素中持有对下一个元素的引用。这样,添加新元素的过程只需创建该元素并将其附加到原始链表的最后一个元素上。在另一种变体中,新元素可以被添加到现有链表的开始位置:

链表

图 3:链表的示例

图 3 展示了一个链表的示例。箭头代表引用。每个元素都存储在一个包装对象中,该对象还持有对下一个元素包装器的引用。还有两个额外的引用指向第一个和最后一个元素,这对于任何操作开始都是必需的。最后一个引用是可选的,但它极大地提高了向末尾附加的性能,正如我们将看到的。

为了开始讨论,让我们以下面的方式创建一个链表节点:

public class LinkedList<E> implements Iterable<E>, Visualizable { 

首先,我们在LinkedList类内部创建一个Node类,它将作为元素的包装器,并持有对下一个节点的引用:

  protected static class Node<E> { 
    protected E value; 
    protected Node next; 

    public String toString(){ 
        return value.toString(); 
    } 
  } 

  int length = 0; 
  Node<E>[] lastModifiedNode;    

然后,我们必须为第一个和最后一个元素设置引用:

    Node<E> first; 
    Node<E> last; 

最后,我们创建一个名为getNewNode()的方法,该方法创建一个新的空节点。如果我们想在任何子类中使用不同的节点类,我们将需要这个方法:

    protected Node<E> getNewNode() { 
        Node<E> node = new Node<>(); 
        lastModifiedNode = new Node[]{node}; 
        return node; 
    }
}

在这个阶段,未完成的类 LinkedList 将无法存储任何元素;不过,让我们看看如何实现这一点。请注意,我们已经实现了 Iterable 接口。这将允许我们通过高级 for 循环遍历所有元素。

在末尾追加

在末尾追加是通过简单地从原始链表的最后一个元素创建一个指向正在追加的新元素的链接,然后重新分配最后一个元素的引用来实现的。第二步是必需的,因为新元素是新的最后一个元素。这将在以下图中展示:

在末尾追加

图 4:在链表末尾追加

当你向一开始就是空的链表追加元素时,会有一个小的区别。在这种情况下,第一个和最后一个引用都是 null,这种情况必须单独处理。以下图解释了这种情况:

在末尾追加

图 5:向空链表追加

我们将通过以下简单代码实现这一点。我们返回刚刚添加的节点。这对任何扩展此类的类都很有帮助。我们将在所有情况下都这样做,我们将在讨论双链表时看到这一点:

    public Node<E> appendLast(E value) { 
        Node node = getNewNode(); 
        node.value = value; 

我们只尝试更新当前最后一个节点的引用,如果列表不为空:

        if (last != null) 
            last.next = node;

然后,我们必须更新最后一个引用,因为新元素不会是最后一个元素:

        last = node;

最后,如果列表为空,新元素也必须是第一个新元素,我们必须相应地更新第一个引用,如图所示:

        if (first == null) { 
            first = node; 
        } 
        length++; 
        return node;
    }

注意,我们还跟踪列表的当前长度。这并不是必需的,但如果这样做,我们就不必遍历整个列表来计算列表中有多少元素。

现在,当然,有一个重要的问题:向链表追加元素的时间复杂度是多少?好吧,如果我们像之前那样做——也就是说,通过有一个指向最后一个元素的特别引用——我们不需要任何循环,就像我们在代码中看到的那样。如果程序没有任何循环,所有操作都是一次性操作,因此所有操作都在常数时间内完成。你可以验证一个常数函数具有这种复杂度:O(1)。将此与在数组末尾追加的内容进行比较。它需要创建一个新的数组,并且具有 O(n) 的复杂度,其中 n 是数组的大小。

在起始位置插入

在列表的起始位置插入元素与在末尾追加它非常相似。唯一的区别是您需要更新第一个引用而不是最后一个引用:

    public Node<E> appendFirst(E value) { 
        Node node = getNewNode(); 
        node.value = value; 
        node.next = first; 
        first = node; 
        if (length == 0) 
            last = node; 
        length++; 
        return node;
    }

在任意位置插入

在任意位置插入可以通过与我们在第一个元素中执行插入相同的方式进行,只是我们需要更新前一个元素的引用而不是第一个引用。然而,有一个问题;我们需要找到插入元素的位置。除了从开始处开始并一直走到正确的位置同时计数我们经过的每个节点外,没有其他方法可以找到它。

在任意位置插入

图 6:在链表中插入任意元素

我们可以如下实现这个想法:

    public Node<E> insert(int index, E value) { 
        Node<E> node = getNewNode(); 

首先,我们处理特殊情况:

        if (index < 0 || index > length) { 
            throw new IllegalArgumentException("Invalid index for insertion"); 
        } else if (index == length) { 
            return appendLast(value); 
        } else if (index == 0) { 
            return appendFirst(value); 
        } else { 

如前所述,我们在计数节点的同时走到期望的位置,或者在这种情况下,在相反方向上计数索引:

            Node<E> result = first; 
            while (index > 1) { 
                index--; 
                result = result.next; 
            } 

最后,我们更新引用:

            node.value = value; 
            node.next = result.next; 
            result.next = node; 
            length++; 
            return node;
        } 
    }

这个算法的复杂度是多少?有一个循环必须运行与索引相同的次数。这个算法似乎运行时间依赖于输入的值,而不仅仅是其大小。在这种情况下,我们只对最坏情况感兴趣。那么最坏情况是什么?它是指我们需要走过列表中的所有元素,也就是说,当我们必须将元素插入列表的末尾时,除了最后一个元素。在这种情况下,我们必须走过n-1个元素才能到达那里并做一些常数工作。因此,步数将是T(n) = C(n-1)+K,其中CK是某些常数。所以,T(n) = O(n)

查找任意元素

查找任意元素的值有两种不同的情况。对于第一个和最后一个元素,这很简单。由于我们直接引用了第一个和最后一个元素,我们只需遍历那个引用并读取其中的值。我将这个留给你去观察它是如何实现的。

然而,如何读取任意元素?由于我们只有前向引用,我们必须从开始处开始并一直走到,在遍历引用的同时计数步数,直到我们到达我们想要的元素。

让我们看看我们如何做到这一点:

    public E findAtIndex(int index) { 

我们从第一个元素开始:

        Node<E> result = first; 
        while (index >= 0) { 
            if (result == null) { 
                throw new NoSuchElementException(); 
            } else if (index == 0) { 

当索引为0时,我们最终到达了期望的位置,因此我们返回:

                return result.value; 
            } else { 

如果我们还没有到达那里,我们必须移动到下一个元素并继续计数:

                index--; 
                result = result.next; 
            } 
        } 
        return null; 
    }

这里,我们也有一个循环,它需要运行多次。最坏的情况是你只需要删除一个元素,但它不是最后一个;最后一个可以直接找到。很容易看出,就像你在任意位置插入一样,这个算法的运行时间复杂度也是O(n)

查找任意元素

图 7:删除开始位置的元素

在开始位置删除元素意味着简单地更新第一个元素的引用为下一个元素的引用。请注意,我们不会更新刚刚删除的元素的引用,因为该元素以及引用无论如何都会被垃圾回收:

    public Node<E> removeFirst() { 
        if (length == 0) { 
            throw new NoSuchElementException(); 
        } 

将引用分配给下一个元素:

        Node<E> origFirst = first;        
        first = first.next; 
        length--; 

如果没有更多的元素,我们还必须更新最后一个引用:

        if (length == 0) { 
            last = null; 
        } 
        return origFirst;
    }

移除任意元素

移除任意元素与从开始移除元素非常相似,只是你更新前一个元素持有的引用,而不是名为 first 的特殊引用。以下图示展示了这一点:

移除任意元素

图 8:移除任意元素

注意,只有链表中的链接需要重新分配给下一个元素。以下代码执行了前面图示中的操作:

    protected Node<E> removeAtIndex(int index) { 
        if (index >= length || index < 0) { 
            throw new NoSuchElementException(); 
        } 

当然,移除第一个元素是一个特殊情况:

        if (index == 0) { 
            Node<E> nodeRemoved = first; 
            removeFirst(); 
            return nodeRemoved; 
        } 

首先,找出需要移除的元素之前的元素,因为这个元素需要更新其引用:

        Node justBeforeIt = first; 
        while (--index > 0) { 
            justBeforeIt = justBeforeIt.next; 
        } 

如果最后一个元素是被移除的元素,则更新最后一个引用:

        Node<E> nodeRemoved = justBeforeIt.next; 
        if (justBeforeIt.next == last) { 
            last = justBeforeIt.next.next; 
        } 

更新前一个元素持有的引用:

        justBeforeIt.next = justBeforeIt.next.next; 
        length--; 
        return nodeRemoved; 
    }

很容易看出,该算法的最坏情况时间复杂度是O(n)——这与查找任意元素相似——因为这是在移除之前需要做的事情。实际移除过程本身只需要常数步数。

迭代

由于我们在 Java 中工作,我们更喜欢实现Iterable接口。它让我们能够以简化的 for 循环语法遍历列表。为此,我们首先必须创建一个迭代器,它将允许我们逐个获取元素:

    protected class ListIterator implements Iterator<E> { 
        protected Node<E> nextNode = first; 

        @Override 
        public boolean hasNext() { 
            return nextNode != null; 
        } 

        @Override 
        public E next() { 
            if (!hasNext()) { 
                throw new IllegalStateException(); 
            } 
            Node<E> nodeToReturn = nextNode; 
            nextNode = nextNode.next; 
            return nodeToReturn.value; 
        } 
    }

代码是自我解释的。每次调用它时,我们都移动到下一个元素并返回当前元素的值。现在我们实现Iterable接口的iterator方法,使我们的列表成为一个可迭代的:

    @Override 
    public Iterator<E> iterator() { 
        return new ListIterator(); 
    }

这使我们能够使用以下代码:

        for(Integer x:linkedList){ 
            System.out.println(x); 
        }

上述代码假设变量linkedListLinkedList<Integer>。任何扩展此类的列表也将自动获得此属性。

双向链表

你有没有注意到没有快速的方法从链表的末尾移除元素?这是因为即使有快速的方法找到最后一个元素,也没有快速的方法找到它之前需要更新引用的元素。我们必须从头开始遍历以找到前一个元素。那么,为什么不保留另一个引用来存储倒数第二个元素的位置呢?这是因为在你移除元素之后,你将如何更新引用呢?将没有指向那个元素的引用。看起来为了实现这一点,我们必须存储从开始到前一个元素的所有元素的引用。最好的方法是在每个元素或节点中存储前一个元素的引用以及指向下一个元素的引用。这样的链表被称为双向链表,因为元素是双向链接的:

双向链表

图 9:双向链表

我们将通过扩展我们的原始链表来实现双向链表,因为许多操作都是相似的。我们可以以下面的方式创建基本类:

public class DoublyLinkedList<E> extends LinkedList<E> { 

我们创建一个新的 Node 类,它扩展了原始类并添加了对前一个节点的引用:

    protected static class DoublyLinkedNode<E> extends Node<E> { 
        protected DoublyLinkedNode<E> prev; 
    }

当然,我们需要重写 getNode() 方法来使用此节点:

    @Override 
    protected Node<E> getNewNode() { 
        return new DoublyLinkedNode<E>(); 
    } 
}

在开头或结尾进行插入

在开头进行插入与单链表的插入非常相似,除了我们现在必须更新下一个节点的引用以指向其前一个节点。在这种情况下,被插入的节点没有前一个节点,因此不需要做任何事情:

    public Node<E> appendFirst(E value) { 
        Node<E> node = super.appendFirst(value); 
        if (first.next != null) 
            ((DoublyLinkedNode<E>) first.next).prev = (DoublyLinkedNode<E>) first; 
        return node; 
    }

图像上,它可以如下所示可视化:

在开头或结尾插入

图 10:双向链表的开头插入

在末尾的插入非常相似,如下所示:

    public Node<E> appendLast(E value) { 
        DoublyLinkedNode<E> origLast = (DoublyLinkedNode<E>) this.last; 
        Node<E> node = super.appendLast(value); 

如果原始列表为空,原始的最后一个引用将是 null

        if (origLast == null) { 
            origLast = (DoublyLinkedNode<E>) first; 
        } 
        ((DoublyLinkedNode<E>) this.last).prev = origLast; 
        return node; 
    }

插入的复杂性与单链表相同。实际上,双向链表上的所有操作都具有与单链表相同的运行时间复杂度,除了移除最后一个元素的过程。因此,我们将在此讨论移除最后一个元素之前不再重复这一点。你应该验证在其他所有情况下,复杂度与单链表相同。

在任意位置插入

与其他所有操作一样,这个操作与在单链表的任意位置进行插入的过程非常相似,除了你需要更新前一个节点的引用。

在任意位置插入

图 11:双向链表的任意位置插入

以下代码为我们完成了这项工作:

    public Node<E> insert(int index, E value) { 
        DoublyLinkedNode<E> inserted = (DoublyLinkedNode<E>) super.insert(index, value); 

在第一个和最后一个元素的情况下,我们的重写方法仍然会被调用。因此,没有必要再次考虑它们:

        if(index!=0 && index!=length) { 
            if (inserted.next != null) { 

这一部分需要稍作解释。在 图 11 中,被插入的节点是 13。其前一个节点应该是 4,它原本是下一个节点 3 的前一个节点:

                inserted.prev = ((DoublyLinkedNode<E>) inserted.next).prev; 

下一个节点 3prev 引用现在必须持有新插入的节点 13

                ((DoublyLinkedNode<E>) inserted.next).prev = inserted; 
            } 
        } 
        return inserted; 
    }

移除第一个元素

移除第一个元素几乎与单链表相同。唯一的额外步骤是将下一个节点的 prev 引用设置为 null。以下代码执行此操作:

    public Node<E> removeFirst() { 
        super.removeFirst(); 
        if (first != null) { 
            ((DoublyLinkedNode<E>) first).prev = null; 
        } 
        return first; 
    }

下面的图显示了发生的情况。此外,请注意,查找元素实际上并不需要更新:

移除第一个元素

图 12:从双向链表中移除第一个元素

如果我们寻找的索引更接近链表末尾,可以有一个优化来从最后一个元素遍历到第一个元素;然而,这并不会改变查找操作的渐进复杂度。所以我们就保持在这个阶段。如果你感兴趣,可以很容易地找出如何进行这种优化。

移除任意元素

就像其他操作一样,移除操作在单链表中的元素移除情况下非常相似,只是我们需要更新prev引用:

移除任意元素

图 13:从双链表中移除任意元素

以下代码将帮助我们实现这一点:

    public Node<E> removeAtIndex(int index) { 
        if(index<0||index>=length){ 
            throw new NoSuchElementException(); 
        }

这是一个需要额外注意的特殊情况。双链表在移除最后一个元素时表现得尤为出色。我们将在下一节讨论removeLast()方法:

        if(index==length-1){ 
            return removeLast(); 
        } 

其余的代码相对容易理解:

        DoublyLinkedNode<E> nodeRemoved 
               = (DoublyLinkedNode<E>) super.removeAtIndex(index); 
        if ((DoublyLinkedNode<E>) nodeRemoved.next != null) 
            ((DoublyLinkedNode<E>) nodeRemoved.next).prev 
                 = nodeRemoved.prev; 
        return nodeRemoved; 
    } 

移除最后一个元素

这正是双链表真正发光的地方。这也是我们一开始选择使用双链表的原因。而且代码量并不多。看看这个:

    public Node<E> removeLast() { 
        Node<E> origLast = last; 
        if(last==null){ 
            throw new IllegalStateException
                          ("Removing element from an empty list"); 
        } 

只需利用我们可以访问前一个节点的引用,并且可以很容易地更新最后一个引用的事实:

        last = ((DoublyLinkedNode<E>)last).prev; 

如果移除后列表不为空,将新最后一个元素的下一个引用设置为 null。如果新列表为空,则更新第一个元素:

        if(last!=null){ 
            last.next = null; 
        } else{ 
            first = null; 
        } 

不要忘记更新长度:

        length--; 
        return origLast;
    }

我们不需要新的图来理解引用的更新,因为它们实际上与移除第一个元素的过程非常相似。唯一的区别在于,在单链表中,我们需要走到链表的末尾才能找到列表的前一个元素。然而,在双链表中,我们可以一步更新它,因为我们始终可以访问前一个节点的引用。这极大地减少了运行时间,从单链表中的O(n)降低到双链表中的O(1)

循环链表

循环链表是一个普通的链表,除了最后一个元素持有对第一个元素的引用作为其下一个元素。这当然也解释了它的名字。例如,当你持有一个玩家列表,他们轮流进行循环赛时,这会很有用。如果你使用循环链表,并且随着玩家完成他们的回合而不断旋转,实现会变得更加简单:

循环链表

图 14:循环链表

循环链表的基本结构与简单链表相同;不需要更多的字段或方法:

public class CircularLinkedList<E> extends LinkedList<E>{ 
}

插入

这与简单链表的插入操作相同,只是你需要将最后一个引用的下一个元素赋值为第一个:

    @Override 
    public Node<E> appendFirst(E value) { 
        Node<E> newNode = super.appendFirst(value); 
        last.next = first; 
        return newNode; 
    }

从这个,不难猜出在末尾添加元素的方式:

    @Override 
    public Node<E> appendLast(E value) { 
        Node<E> newNode =  super.appendLast(value); 
        last.next = first; 
        return newNode; 
    } 

在任何其他索引处插入,当然,与简单链表的插入相同;不需要更多更改。这意味着插入的复杂性与简单链表相同。

删除

删除操作也只有在删除第一个或最后一个元素时才会改变。在任何情况下,只需更新最后一个元素的下一个引用即可解决问题。唯一需要更改这个引用的地方是在删除第一个元素时。这是因为我们用于简单链表的相同操作不会更新前一个元素的下一个引用,而这正是我们需要做的:

    @Override 
    public Node<E> removeFirst() { 
        Node<E> newNode =  super.removeFirst(); 
        last.next = first; 
        return newNode; 
    }

在删除操作中,不需要做其他任何事情。

旋转

我们在这里所做的是将第一个元素的下一个元素移动到第一个位置。这正是“旋转”这个名字所暗示的:

    public void rotate(){ 
        last = first; 
        first = first.next; 
    }

旋转

图 15:循环链表的旋转

在简单链表中执行相同的操作只需要分配一个额外的引用。你应该能够弄清楚如何在简单链表中做到这一点。但这个操作对于循环链表来说看起来更自然,因为从概念上讲,没有第一个元素。

循环链表真正的力量在于迭代器,它永远不会结束。如果列表非空,迭代器将具有hasNext()方法,它总是返回 true。这意味着你可以简单地不断调用迭代器的next()方法,并按轮询方式持续处理元素。以下代码应该能清楚地说明我的意思:

        for(int i=0;i<30;i++){ 
            System.out.print(" "+ linkedList.first); 
            linkedList.rotate(); 
        }

注意

注意,如果你尝试使用增强型 for 循环与循环链表一起使用,你将遇到无限循环。

摘要

我们介绍了一些基本数据结构和操作它们的算法。除此之外,我们还发现了它们的运行时间复杂度。为了总结这一点,数组提供了最快的随机访问,其时间复杂度为O(1)。但是数组不能改变大小;它们唯一允许的修改是更改元素的值。链表允许以O(1)时间在末尾快速追加和在开头插入。然而,O(1)的删除操作仅适用于删除第一个元素。这通过一个双向链表得到解决,它也允许从末尾以O(1)删除。循环链表在最后一个元素的下一个引用中保存对第一个元素的引用。这使得列表成为一个循环结构,允许无限循环。

在接下来的章节中,我们将讨论数据结构抽象称为抽象数据类型。我们将使用本章中看到的数据结构来实现抽象数据类型,这些抽象数据类型随后将在后面的章节中使用。

第三章。协议 - 抽象数据类型

在上一章中,我们看到了一些基本的数据结构和一些操作它们的算法。然而,有时我们可能想要隐藏数据结构的实现细节,只想知道它们如何与其他算法交互。我们可能只想指定它们必须允许的一些操作,并忘记它们是如何实现的。这与任何大型软件应用中程序部分抽象并不太不同。例如,在 Java 中,我们创建接口,只定义一个对象类必须实现的方法,然后我们使用这种接口类型,有信心它们将被正确实现。我们不想考虑实现类会如何提供它们的实现。这种数据结构的接口被称为抽象数据类型。换句话说,一个抽象数据类型(ADT)是对数据结构应该为用户做什么的描述。它是一系列任何实现都必须支持的运算,以及这些运算的完整描述。其中一些使用非常频繁,并且被赋予了名称。我们将在下面讨论一些这些。

在本章中,你将学习以下概念:

  • 一些常见 ADT 及其操作的定义

  • 如何使用简单的数组和上一章中学习的数据结构来实现这些 ADT

是一个非常常用的抽象数据类型(ADT)。它之所以被这样命名,是因为它类似于餐厅里用来堆叠的盘子。在这样的堆叠中,最后被清洗并放置的盘子会保持在顶部。当需要盘子时,这个盘子将是第一个被取出的。最先放入的盘子将位于堆叠的底部,将是最后一个被取出的。因此,最后放入堆叠的盘子是第一个被取出的,我们也可以称之为后进先出(LIFO)。

类似地,栈 ADT 有一个协议,其中最后放入的值必须在第一次尝试获取值时返回,而最先放入的值必须最后出来。下面的图将使它更清晰:

栈

将新值放入栈的操作称为 push,从栈中检索值的操作称为 pop。最后被推入的元素必须首先弹出。允许一个人看到下一个弹出将返回什么的操作称为 peek。peek 操作返回栈顶元素而不修改栈。我们期望所有栈实现都具有所有操作在时间复杂度为O(1)的情况下实现。这也是我们栈协议的一部分。

栈 ADT 有以下操作:

  • Push:这个操作在栈顶添加一个元素

  • Pop:这个操作移除栈顶的元素

  • Peek:这个操作检查将要弹出的下一个值

由于我们知道 ADT 是数据结构对类的作用,就像接口对类的作用一样,我们将用接口编写 ADT。以下是我们为栈编写的接口:

public interface Stack<E> {
  void push(E value);
  E pop();
  E peek();
}

当然,我们不会就此止步。我们将看到如何实际实现一个栈。为此,我们将看到使用数组存储数据的固定大小栈,以及使用链表存储数据的增长栈。我们首先从第一个开始。

使用数组的固定大小栈

固定大小的栈使用预分配的数组来存储值,也就是说,当这个栈用完了整个数组,它就不能再接受新的值,直到旧的值被弹出。这与实际的盘子栈没有太大区别,盘子栈肯定有一个它能处理的最大高度。

和往常一样,我们首先从类的基本结构开始,如下所示:

public class StackImplArray<E> implements Stack<E> {

我们需要一个数组来存储元素,并且我们需要记住栈顶在那个数组中的位置。栈顶总是标记下一个将被弹出的元素的索引。当没有更多元素要弹出时,它被设置为-1。为什么是-1?因为这是自然的选择,当第一个元素被插入时不需要任何特殊处理:

protected E[] array;
  int top=-1;

  public StackImplArray(int size){
    array = (E[])new Object[size];
  }
}

栈中的push操作可以是简单地将值放入数组中当前top旁边,然后将top设置为新的位置,如下面的代码所示:

@Override
public void push(E value) {

我们首先检查栈是否已经满了,或者当前的top是否等于可能的最大索引,如下所示:

  if(top == array.length-1){
    throw new NoSpaceException("No more space in stack");
  }

现在,我们将top设置为新的位置,并将我们需要存储的值放入其中,如下所示:

  top++;
  array[top] = value;
}

我们使用的异常是为此目的定制的异常。异常的代码很简单,如下面的代码所示:

public class NoSpaceException extends RuntimeException{
  public NoSpaceException(String message) {
    super(message);
  }
}

pop操作正好相反。我们需要首先获取当前top的值,然后将top更新到新的位置,这个位置比当前的位置少一个,如下面的代码所示:

@Override
public E pop() {

我们首先检查栈是否已经为空,如果是,我们返回一个特殊值null。如下面的代码所示:

  if(top==-1){
    return null;
  }

然后我们更新top并返回当前top的值,如下所示:

  top--;
  return array[top+1];
}

peek操作不会改变栈的状态,因此它甚至更简单:

@Override
public E peek() {

就像pop操作一样,如果栈为空,我们返回null

  if(top==-1){
    return null;
  }

否则,我们返回top元素,如下所示:

  return array[top];
}

实际上,可以有一个没有上限的栈,它由数组支持。我们真正需要做的是,每次我们用完空间时,我们可以调整数组的大小。实际上,数组是不能调整大小的,所以操作就是创建一个具有更高大小的新的数组(可能比原始大小大一倍),并将所有旧元素复制到这个数组中。由于这个操作涉及到将所有n个元素逐个复制到新数组中,这个操作的复杂度是O(n)

使用链表的变长栈

基于数组的实现的问题在于,由于数组的大小是固定的,栈不能超过固定的大小。为了解决这个问题,我们必须做我们为解决数组相同问题所做的事情,即使用链表。我们从一个以下这样的裸骨类开始这样的实现。链表将存储值。我们不是给它分配一个新的链表,而是使用可重写的getNewLinkedList()方法来这样做。这将在从该类扩展的类中很有用:

public class StackImplLinkedList<E> implements Stack<E> {
  protected LinkedList<E> list = getNewLinkedList();

  protected LinkedList<E> getNewLinkedList(){
    return new LinkedList<>();
  }
}

要看到哪个链表端必须用作栈顶,我们需要记住我们的栈协议期望操作是O(1),因此我们必须选择一个允许在O(1)时间内插入和删除的端。当然,这个端就是列表的前端,正如我们在上一章所看到的。这使得以下push操作的代码变得不言自明:

@Override
public void push(E value) {
  list.appendFirst(value);
}

注意这次,我们没有检查栈是否已满,因为这种栈的实现永远不会满,它会根据需要增长,而底层的链表会处理这一点。

然而,pop操作确实需要检查栈是否为空,并在该点返回null。以下pop操作的代码也是相当直观的:

@Override
public E pop() {
  if(list.getLength()==0){
    return null;
  }
  E value = list.getFirst();
  list.removeFirst();
  return value;
}

peek操作当然是一样的,只不过它不会移除顶部元素:

@Override
public E peek() {
  if(list.getLength()==0){
    return null;
  }
  return list.getFirst();
}

这就结束了我们的基于链表的栈实现。在下一节,我们将检查另一个称为队列的 ADT。

队列

栈的反面是什么?这可能是一个奇怪的问题。然而,栈遵循 LIFO(后进先出)。其对立面是先进先出FIFO)。因此,在某种意义上,FIFO ADT 可以被认为是栈的对立面。这与人们排队等公交车或医生诊所的情况没有太大区别。第一个到达的人将首先有机会上公交车或看医生。第二个人将获得第二个机会。难怪,这样一个抽象数据类型被称为队列。将元素添加到队列的末尾称为入队,从队列中移除称为出队。当然,合同是,首先入队的值将是第一个出队的值。以下图示说明了这个操作:

队列

队列抽象数据类型(ADT)具有以下操作:

  • 入队: 这将在队列的末尾添加一个元素

  • 出队: 这将从队列的前端移除一个元素

  • 查看: 这将检查下一个出队的元素

队列将由以下接口表示:

public interface Queue<E> {
  void enqueue(E value);
  E dequeue();
  E peek();
}

使用数组实现的固定大小队列

就像栈一样,我们有一个基于数组的队列实现。然而,由于队列从相对的两端接收新值和移除旧值,队列的主体也会移动。以下图示将说明这一点:

使用数组实现的固定大小队列

这意味着在一系列这样的操作之后,队列的末尾将到达数组的末尾,数组开头将留下空间。在这个时候,我们不想停止接收新值,因为还有空间,所以我们回滚到数组的开头。也就是说,我们继续在数组的开头添加新值。

为了进行所有这些操作,我们必须有单独的变量来存储队列起始和结束的索引。此外,由于回滚,有时末尾的索引可能小于起始索引,因此我们单独存储长度以避免混淆。我们像之前一样从类的基本实现开始。起始表示下一个将被出队的元素的索引,而结束表示下一个将要入队的值的索引。这在下述代码中说明:

public class QueueImplArray<E>  implements Queue<E>{
  protected E[] array;
  protected int start=0;
  protected int end=0;
  protected int length=0;
  public QueueImplArray(int size){
    array = (E[]) new Object[size];
  }
}

enqueue 操作不会改变起始位置。新值被放置在数组的末尾,然后末尾增加一。当然,如果末尾超过了数组的最大索引,需要将其回滚,如下述代码所示:

@Override
public void enqueue(E value) {
  if(length>=array.length){
    throw new NoSpaceException("No more space to add an element");
  }
  array[end] = value;

模运算符将确保当索引到达数组的 end 时,它会回到数组的开头,如下所示:

  end = (end+1) % array.length;
  length++;
}

dequeue 操作不会改变末尾位置。我们从起始索引读取,然后带回滚增加起始索引,如下所示:

@Override
public E dequeue() {
  if(length<=0){
    return null;
  }
  E value = array[start];
  start = (start+1) % array.length;
  length--;
  return value;
}

peek 操作允许我们查看下一个将被出队的元素,而不移除它。当然,这很简单。我们只需返回下一个将被出队的元素。这在下述代码中显示:

@Override
public E peek() {
  if(length<=0){
    return null;
  }
  return array[start];
}

由数组支持的队列可以以类似于描述栈的方式调整大小,这同样将是 O(n),因为我们必须逐个将所有旧元素复制到新分配的数组中。

使用链表的变量大小队列

就像栈一样,我们希望使用链表来实现一个队列。我们需要记住,所有操作都必须在运行时达到 O(1)。如果我们通过在链表开头添加新元素来入队,那么在出队时我们需要从链表末尾移除元素。这行不通,因为从链表末尾移除元素是 O(n)。但是,在链表末尾添加元素是 O(1),同样从链表开头移除也是 O(1)。因此,队列的末尾,即新元素入队的地方,将是链表的末尾。而队列的起始处,即元素出队的地方,将是链表的开头。

基于此,使用链表实现队列的实现是直接的。再次强调,我们通过仅使用 getNewLinkedList() 方法创建列表的实例,这个方法可以被子类覆盖以使用不同的链表,如下所示:

public class QueueImplLinkedList<E> implements Queue<E>{
  protected LinkedList<E> list = getNewLinkedList();

  protected LinkedList<E> getNewLinkedList(){
    return new LinkedList<>();
  }

enqueue 操作简单地如下在列表末尾添加元素:

  @Override
  public void enqueue(E value) {
    list.appendLast(value);
  }

dequeue操作首先检查列表是否为空,以便它可以返回null,然后它简单地从列表中移除第一个元素。它还必须返回它刚刚移除的元素:

  @Override
  public E dequeue() {
    if(list.getLength()==0){
      return null;
    }
    E value = list.getFirst();
    list.removeFirst();
    return value;
  }

就像dequeue操作一样,peek操作首先需要检查列表是否为空,在这种情况下,它必须返回一个null值,否则它简单地返回列表中下一个dequeue操作将要出队的元素,如下面的代码所示:

  @Override
  public E peek() {
    if(list.getLength()==0){
      return null;
    }
    return list.getFirst();
  }
}

双端队列

双端队列是栈和队列的组合。想法是,你可以在队列的两端插入和移除元素。如果你从插入的一侧移除元素,它将表现得像栈。另一方面,如果你在相对的两端插入和移除,它将表现得像队列。你可以混合这些操作,并以你喜欢的任何顺序使用它们。以下图示显示了几个操作以阐明这个想法:

双端队列

双端队列有以下操作,所有操作的复杂度都是O(n)

  • Push:这从开始插入一个元素

  • Pop:这从开始移除一个元素

  • Inject:这在末尾插入一个元素

  • Eject:这从末尾移除一个元素

  • Peek:这检查第一个元素

  • PeekLast:这检查最后一个元素

双端队列将由以下接口表示:

public interface DoubleEndedQueue<E> extends Stack<E> {
  void inject(E value);
  E eject();
  E peekLast();
}

注意,由于双端队列具有与栈相同的pushpop操作,并且它保留了相同的意义,我们创建了这个接口,它扩展了Stack接口。

使用数组实现的固定长度双端队列

由于我们将双端队列视为栈的扩展,因此人们可能会预期其实现也会扩展栈的实现。然而,请记住,双端队列既是栈也是队列。由于索引的回滚,基于数组的队列实现比栈的实现更复杂。我们不希望重新编程这些,因此我们选择扩展队列实现而不是栈实现,如下面的代码所示:

public class DoubleEndedQueueImplArray<E> extends QueueImplArray<E> implements DoubleEndedQueue<E> {

我们将队列初始化为固定长度,如下所示:

  public DoubleEndedQueueImplArray(int size) {
    super(size);
  }

这被添加到双端队列的末尾,这与队列的enqueue操作相同:

  @Override
  public void inject(E value) {
    enqueue(value);
  }

eject操作是从双端队列的末尾移除一个元素。在简单的队列中没有等效的操作。因此,我们必须像以下这样为其编写代码:

  @Override
  public E eject() {
    if (length <= 0) {
      return null;
    }

end必须减一,并考虑到回滚。但是,如果end已经为零,它将变成负数,这不会很好地与模运算符一起工作,因为它将返回一个负值。为了始终保持其正值,我们将其添加到数组的长度。请注意,这不会改变除以数组长度时的余数。以下代码显示了这一点:

    end = (end + array.length - 1) % array.length;
    E value = array[end];
    length--;
    return value;
  }

peekLast 操作只需简单地返回 eject 操作将返回的元素,而不做任何修改,如下面的代码所示:

  @Override
  public E peekLast() {
    if (length <= 0) {
      return null;
    }
    return array[(end + array.length - 1) % array.length];
  }

push 操作是在双端队列的开头插入一个元素。在简单队列中没有等效的操作。因此,我们需要如下编写代码:

  @Override
  public void push(E value) {
    if (length >= array.length) {
      throw new NoSpaceException("No more space to add an element");
    }

这个操作与更新末尾索引的 eject 操作非常相似,如下面的代码所示:

    start = (start + array.length - 1) % array.length;
    array[start] = value;
    length++;
  }

pop 操作是移除队列开头的元素,这与普通队列的 dequeue 操作相同。如下面的代码所示:

  @Override
  public E pop() {
    return dequeue();
  }
}

注意,我们不需要为 peek 操作编写任何代码,因为这个操作应该返回双端队列开头的元素,因为它与简单队列的 peek 操作相同。

基于数组的实现当然是固定大小的,不能容纳比其固定大小更多的元素。接下来,我们开发基于链表的实现。

使用链表的变量大小双端队列

我们之前使用简单的链表实现了队列和栈。然而,请再次记住,所有操作都必须是O(1)。现在,我们必须在底层链表的两侧添加和移除元素。我们知道从单链表的末尾移除是O(n),我们不能使用它。因此,我们必须使用双链表。

这次我们不必担心回滚,因此我们将扩展栈的链表实现,这是自然的选择。我们将通过重写 getLinkedList()方法将其单链表替换为双链表,如下所示:

public class DoubleEndedQueueImplLinkedList<E> extends StackImplLinkedList<E> implements DoubleEndedQueue<E> {

  @Override
  protected LinkedList<E> getNewLinkedList() {
    return new DoublyLinkedList<E>();
  }

inject 操作将新元素插入到列表的末尾,如下面的代码所示:

  @Override
  public void inject(E value) {
    list.appendLast(value);
  }

eject 操作必须移除并返回列表的最后一个元素。如下面的代码所示:

  @Override 
  public E eject() {
    if(list.getLength()==0){
      return null;
    }
    E value = list.getLast();
    list.removeLast();
    return value;
  }

最后,peekLast()方法将简单地返回双链表的最后一个元素,如下所示:

  @Override
  public E peekLast() {
    if(list.getLength()==0){
      return null;
    }
    return list.getLast();
  }
}

我们只需要实现 inject()、eject()和 peekLast()方法,因为其他方法已经被我们扩展的栈实现了。

摘要

在本章中,我们了解到抽象数据类型或 ADT 是数据结构的抽象。它是一个约定,即底层数据结构应该遵守的。该约定涉及数据结构上的不同操作及其特定行为。然后我们看到了几个简单的 ADT 作为示例。然而,这些 ADT 非常实用,正如我们在本书的其余部分遇到其他算法时将看到的那样。抽象允许不同的结构实现。我们还将在本书中看到更多的 ADT 及其实现。

在下一章中,我们将转向一个新的算法领域,称为函数式编程。请记住,算法是一系列可能遵循以实现所需处理的步骤;事实证明,还有另一种看待它的方式,我们将在下一章中探讨。

第四章。绕道——函数式编程

在本书的开头,我们看到了算法是一系列步骤以实现结果。通过遵循一系列指令来解决问题的这种方式被称为命令式编程。程序中的每个语句都可以被视为一个命令句,要求计算机做某事。然而,这并不是看待问题的唯一方式。函数式编程将算法视为组件的组合,而不是步骤的序列。要解决的问题被视为更小问题的组合。我们不是使用循环,而是将相同问题的较小版本组合起来。函数式编程使用递归作为基本组件。递归不过是针对较小规模的问题进行求解,然后将结果与其它东西组合起来以获得给定规模问题的解决方案。这在如何轻松阅读和理解程序方面有着深远的影响。这使得研究它变得非常重要。

在编程范式中有两个世界。命令式编程风格受到 C 语言族,如 C、C++和 Java 的青睐。在纯函数式方面,有 Lisp、Haskell 和 Scheme 等语言。除此之外,一些语言试图兼得两者之优,如 Python 或 Scala。这说起来容易做起来难;试图在语言中混合这两种思想意味着你需要有支持两者的特性,但有效地使用它们确实是一种艺术。

那么,如果 Java 本质上是命令式的,为什么我们在这本书中要讨论函数式编程呢?好吧,正如我指出的,有时候,混合这两种概念会更好,取其精华。最近,Java 社区已经注意到这一点,并在 Java 8 版本中引入了 lambda 特性,以提供一定程度的函数式编程支持。因此,我们的意图并不是完全以函数式风格编程,因为那不是 Java 首选的编程风格,但我们会做到足以使我们的程序更加美观,并有助于我们理解算法。

本章将向您介绍这个相对陌生的概念,并提供一些在函数式编程中常用的一些基本工具。您将学习以下概念:

  • 递归算法和变量的不可变性

  • 单子

  • 单子上的聚合

  • Java 对函数式编程的支持,即 lambda。

递归算法

正如我已经指出的,递归算法是解决问题的一种不同思维方式。例如,假设我们的问题是编写一个程序,给定一个正整数 n,返回从零到 n 的数字之和。已知的过程式编写方式很简单:

public int sum_upto(int n){
  int sum=0;
  for(int i=0;i<=n;i++){
    sum+=i;
  }
  return sum;
}

以下将是该问题的函数式版本:

public int sum_upto_functional(int n){
  return n==0?0:n+sum_upto_functional(n-1);
}

就这样——只是一行代码!这或许对 Java 程序员来说并不新鲜,因为他们确实理解递归函数。然而,命令式程序员只有在其他方法都不奏效时才会使用递归。但这是一种不同的思维方式。我们如何证明这种方法等同于先解决一个较小输入的问题,然后再将其与其他东西组合起来?嗯,我们当然首先计算一个比输入小一的相同函数,然后仅仅给它加上n。这里还有一点需要注意:在命令式版本中,我们为循环变量i的每个值更新名为sum的变量。然而,在函数式版本中,我们并没有更新任何变量。我们通过这样做达到了什么效果?当一个变量在程序中被多次更新时,理解或调试它都很困难,因为你需要跟踪最新的值。当这种情况没有发生时,理解正在发生的事情就简单得多。事实上,它使事情变得如此简单,以至于我们甚至可以完全形式化地证明程序的正确性,这对于变量值变化的命令式程序来说是非常困难的。

让我们来看另一个例子。这个例子是关于从n个对象中选择r个对象,其中顺序并不重要。让我们有一个包含有限数量对象的集合A。设这个集合中的对象数量为n。这个集合A有多少个子集B恰好有r个元素?当然,任何子集A的最大元素数量是n;因此r ≤ n。我们将这个函数称为choose。所以,我们写成choose(n,r)。现在,唯一与A具有相同元素数量的子集就是A本身。所以,choose(n,n)等于 1。

我们如何将这个问题分解成具有相似性质但输入更小的子问题?为了找出有多少个包含 r 个元素的子集 B,我们首先将集合 A 视为包含一个具有 n-1 个元素的子集 C 和一个特定元素 a 的组合。因此,我们可以表示 A = C {a}. 现在,我们考虑两种不相交的情况:元素 a 是子集 B 的成员,以及当它不是子集 B 的成员时。当 a 不是子集 B 的成员时,B 也是子集 C 的一个子集。具有恰好 r 个元素的这种子集 B 的数量是 choose(n-1,r),因为 Cn-1 个元素。另一方面,当 a 是集合 B 的成员时,那么 B 可以被视为两个集合的并集——一个是除了 a 以外包含 B 所有元素的集合 D,另一个是仅包含 {a} 的集合。因此,B = D {a}. 现在,你可以看到 D 是我们定义的 C 的一个子集。C 中有多少个这样的子集 D?有 choose(n-1,r-1) 个子集,因为 Cn-1 个元素,而 Dr-1 个元素。因此,这种子集 B 的数量是 choose(n-1,r-1)。这意味着包含或不包含 a 作为元素的子集 B 的总数是 choose(n-1,r) + choose(n-1,r-1)。如果我们将其放入递归调用中,rn 将会减少,直到 r 等于零或 n 等于 r。我们已经考虑了 n 等于 r 的情况,即 choose(n,n)。当 r 等于零时,这意味着 B 是空集。由于只有一个空集,choose(n,0) 等于 1。现在,我们将这个结果放入代码中:

public long choose(long n, long r){
  if(n<r){
    return 0;
  }else if(r==0){
    return 1;
  }else if(n==r){
    return 1;
  }else{
    return choose(n-1,r) + choose(n-1,r-1);
  }
}

这有点复杂,但请注意,我们不仅计算了 choose 函数的值,我们还证明了它将如何工作。choose 函数是整数指数的二项式系数函数。

上述实现并不高效。为了了解原因,只需考虑当 choose 函数的每次递归调用都落在最终的 else 情况时,它们将评估为什么:choose(n-1,r) = choose(n-2,r) + choose(n-2,r-1)choose(n-1,r-1) = choose(n-2,r-1) + choose(n-2,r-2)。现在请注意,choose(n-2,r-1) 在两种情况下都被评估,这将导致它自己的递归调用。这实际上显著增加了渐近复杂度。我们将把对这种复杂度的分析推迟到本章的末尾。

Java 中的 Lambda 表达式

在继续之前,我们需要了解 Java 中一个名为 Lambda 的特性。你们中的许多人可能已经了解它了。然而,由于该特性仅在版本 8 中引入,如果你还不熟悉它,最好了解一下。它允许你将一个称为 lambda 表达式的代码块作为参数传递给另一个函数。为了讨论 lambda,我们首先必须了解什么是函数式接口。

函数式接口

功能接口是一个只有一个未实现方法的接口,也就是说,实现它的类需要恰好实现一个方法。功能接口可以声明或继承多个方法,但只要我们可以通过实现一个方法来实现它,它就是一个功能接口。以下示例显示了一个这样的接口:

@FunctionalInterface
public interface SampleFunctionalInterface {
  int modify(int x);
}

注意,我们还将它标记为一个带有注解的功能接口,但这并不是必需的。标记它确保如果接口没有恰好一个需要实现的方法,Java 将在编译时显示错误。以下示例显示了另一个有效的功能接口:

public interface AnotherFunctionalInterface{
  public void doSomething(int x);
  public String toString();
}

它中有两个方法。然而,由于 toString() 方法已经在对象类中实现,你只需要实现一个方法。

同样,如果一个接口有多个方法,但除了一个之外都有默认实现,那么它也可以是一个功能接口。例如,看看以下接口。

@FunctionalInterface
public interface FunctionalInterfaceWithDefaultMethod {
    int modify(int x);
    default int modifyTwice(int x){return modify(modify(x));}
}

尽管这个接口有两个方法,但任何实现只需要实现一个。这使得它成为一个功能接口。

使用 lambda 实现功能接口

那么,如果我们有一个功能接口会发生什么呢?我们可以使用一个称为 lambda 的酷语法来提供它的内联实现,如下所示:

SampleFunctionalInterface sfi = (x)->x+1;
int y = sfi.modify(1);

注意括号和箭头符号。括号包含所有参数。参数的类型没有指定,因为它们已经在接口方法中指定了。可以有零个或多个参数。

有两种类型的 lambda 语法——一种是以表达式作为主体,另一种是以一个或多个步骤作为主体。这些 lambda 表达式看起来略有不同。作为一行代码实现的 lambda 表达式看起来就像我们刚才看到的。这被称为表达式语法。如果 lambda 表达式是一行代码,则可以使用表达式语法。对于多行代码,我们使用如下所示的块语法:

Thread t = new Thread(()->{for(int i=0;i<500;i++) System.out.println(i);});

对于返回值的函数,也可以使用块语法,尤其是在使用多行代码时。在这种情况下,只需要使用一个返回语句来返回值。

注意

由于在函数式程序中所有变量都不应该被重新赋值,我们应该将它们声明为 final 以避免意外修改它们。然而,由于为每个变量键入 final 会使代码略显杂乱,我们避免这样做。在纯函数式语言中,变量默认是不可变的。即使在半函数式语言,如 Scala 中,如果它通常鼓励函数式风格,也是如此。然而,由于 Java 主要偏好命令式风格,final 关键字是必要的,这导致了一点点杂乱。

现在我们已经了解了 lambda,我们可以开始学习功能数据结构。

功能数据结构和单子

函数式数据结构是遵循不可变性和归纳(或递归)定义原则的数据结构。不可变性意味着对数据结构的任何修改都会导致一个新的数据结构,并且任何对原始版本的旧引用仍然可以访问原始版本。归纳定义意味着结构的定义被定义为相同数据结构较小版本的组合。以我们的链表为例。当我们向列表的起始位置添加一个元素或从列表的起始位置删除一个元素时,它会修改链表。这意味着对链表的任何引用现在都将持有对修改后链表的引用。这不符合不可变性的原则。一个函数式链表将确保旧引用仍然引用未修改的版本。我们将在下一节讨论如何实现它。

函数式链表

要创建一个不可变的链表,我们考虑链表由两部分组成:

  • 包含列表第一个元素的头部

  • 包含另一个包含剩余元素的链表的尾部

注意,我们现在已经递归地定义了链表,忠实于我们的函数式设计。这种递归表明,链表是:

  • 一个空列表

  • 或者是一组两个对象,如下所示:

    • 包含其元素类型的一个元素的头部

    • 包含另一个相同类型链表的尾部

这个定义版本与之前的简化版本相同,只是我们现在已经指定了如何表示列表的终止位置。列表在元素不再存在时终止,即当尾部是一个空列表时。让我们将这些全部放入代码中。

首先,我们根据简化的定义定义一个版本:

public class LinkedList<E> {
  private E head;
  private LinkedList<E> tail;

  private LinkedList(){

}

  private LinkedList(E head, LinkedList<E> tail){
    this.head = head;
    this.tail = tail;
  }

  public E head(){
    return head;
  }
  public LinkedList<E> tail(){
    return tail;
  }

这是我们的链表不可变性的核心。请注意,每次我们向链表添加新值时,我们都会创建一个新的链表,这样旧引用仍然持有对未修改列表的引用:

  public LinkedList<E> add(E value){
    return new LinkedList<E>(value,this);
  }
}

代码现在已经很直观了,因为我们已经知道我们是如何思考我们的链表的。但请注意,我们已经将构造函数设为私有。我们不希望人们创建不一致的链表版本,比如一个空的tail或类似的东西。我们坚持要求每个人都通过首先创建一个空链表然后向其中添加元素来创建我们的链表。因此,我们添加以下EmptyList类和add()方法:

public static final class EmptyList<E> extends LinkedList<E>{
  @Override
  public E head() {
    throw new NoValueException("head() invoked on empty list"); 
  }

  @Override
  public LinkedList<E> tail() { 
    throw new NoValueException("tail() invoked on empty list"); 
  }
}

public static <E> LinkedList<E> emptyList(){
  return new EmptyList<>();
}

现在,我们可以这样使用链表:

LinkedList<Integer> linkedList = LinkedList.<Integer>emptyList()
.add(5).add(3).add(0);
while(!(linkedList instanceof LinkedList.EmptyList)){
  System.out.println(linkedList.head());
  linkedList = linkedList.tail();
}

但等等,我们在while循环中是否修改了linkedList变量?是的,但那并不符合不可变性的原则。为了解决这个问题,让我们看看我们通常会对列表做什么。一般来说,我们想要执行以下操作:

  • 对列表的每个元素执行某些操作。例如,将所有元素打印到控制台。

  • 获取一个新列表,其中每个元素都使用提供的函数进行转换。

  • 计算列表中所有元素的函数。这是元素的总和。例如,找出所有元素的总和。

  • 创建一个只包含列表中选定元素的新列表。这被称为过滤

我们将逐个处理它们。在下一节的末尾,你将准备好学习关于单子(monads)的内容。

链表的forEach方法

链表上的forEach()方法将对列表的每个元素执行某些操作。这个操作将作为 lambda 传递。为此,我们首先创建一个功能接口,它消耗一个参数但不返回任何内容:

@FunctionalInterface
public interface OneArgumentStatement<E> {
  void doSomething(E argument);
}

使用此接口,我们将为列表定义forEach()方法,如下所示:

public class LinkedList<E> {
…

  public static class EmptyList<E> extends LinkedList<E>{
  …

  @Override
  public void forEach(OneArgumentStatement<E> processor) {}
  }

  …

  public void forEach(OneArgumentStatement<E> processor){
    processor.doSomething(head());
    tail().forEach(processor);
  }
}

省略号表示我们已讨论过的更多代码,无需重复。forEach()方法简单地处理头部,然后递归地对自己调用尾部。再次强调,根据我们的递归哲学,我们使用递归实现了forEach()方法。当然,这不会在空列表上工作,因为头部和尾部都是 null。空列表表示当方法需要停止调用自身时的情况。我们通过在EmptyList类中重写forEach()方法来不执行任何操作来实现这一点。

现在我们可以使用以下代码打印所有元素:

linkedList.forEach((x) -> {System.out.println(x);});

我们传递一个 lambda,它对任何元素x调用System.out.println。但是,如果你看到,这个 lambda 只是作为对已经具有所需 lambda 形式的System.out.println方法的代理。Java 允许你使用以下语法将方法用作 lambda:::运算符用于告诉编译器你正在寻找的不是具有该名称的字段,而是具有该名称的方法:

linkedList.forEach(System.out::println);

注意,这次我们在打印元素时甚至没有修改列表,与上次不同,上次我们是使用循环来做的。

链表的映射

现在我们继续进行下一项我们想要对列表做的事情,那就是创建一个新列表,其中所有元素都根据提供的 lambda 表达式进行了转换。我的意思是,我们想要做以下事情:

LinkedList<Integer> tranformedList = linkedList.map((x)->x*2);

我们需要以这种方式实现map()方法,即transformedList包含linkedList中所有元素乘以2,顺序保持不变。以下是map()方法的实现:

public class LinkedList<E> {
…
  public static class EmptyList<E> extends LinkedList<E>{
  …

  @Override
  public <R> LinkedList<R> map(OneArgumentExpression<E, R> transformer) {

  return LinkedList.emptyList();
  }
}
…

  public <R> LinkedList<R> map(OneArgumentExpression<E,R> transformer){
    return new LinkedList<>(transformer.compute(head()),
    tail.map(transformer));
  }
}

如同往常,方法是通过递归定义的。转换后的列表只是头部转换后跟尾部转换。我们还在EmptyList类中重写了该方法,以返回一个空列表,因为转换后的空列表只是另一个可能不同类型的空列表。有了这种实现,我们可以做以下事情:

LinkedList<Integer> tranformedList = linkedList.map((x)->x*2);
tranformedList.forEach(System.out::println);

这应该会打印出一个所有值都乘以2的列表。你甚至可以通过转换来更改元素的类型,如下所示:

LinkedList<String> tranformedListString
 = linkedList.map((x)->"x*2 = "+(x*2));
tranformedListString.forEach(System.out::println);

tranformedListString列表是一个字符串列表,打印每个元素到下一行显示了获得的字符串。

现在我们继续进行下一件事,我们想要对列表做的,那就是计算使用列表中所有值的函数。这被称为聚合操作。但在看一般情况之前,我们将专注于一个特定的操作,称为折叠操作。

列表的折叠操作

列表的折叠操作是一种可以逐个元素进行的聚合操作。例如,如果我们想要计算列表中所有元素的总和,我们可以通过将列表的每个元素加到一个移动的总和中来实现,这样当我们处理完所有元素后,我们将得到所有元素的总和。

有两个操作适合这个目的:foldLeftfoldRightfoldLeft操作首先聚合头部,然后移动到尾部。foldRight方法首先聚合尾部,然后移动到头部。让我们从foldLeft开始。但在做任何事情之前,我们需要一个表示两个参数表达式的函数接口:

@FunctionalInterface
public interface TwoArgumentExpression<A,B,R> {
  R compute(A lhs, B rhs);
}

使用这个接口,我们以下这种方式定义了foldLeft方法:

public class LinkedList<E> {
  …
  …

  public static class EmptyList<E> extends LinkedList<E>{

    …

    @Override
    public <R> R foldLeft(R initialValue, TwoArgumentExpression<R, E, R> computer) {
      return initialValue; 
    }
  }

  …

  public <R> R foldLeft(R initialValue, TwoArgumentExpression<R,E,R> computer){
    R newInitialValue = computer.compute(initialValue, head());
    return tail().foldLeft(newInitialValue, computer);
  }
}

我们使用传递的 lambda 从initialValue和头部计算一个新的值,然后我们使用这个更新的值来计算尾部的foldLeft。空列表覆盖了这个方法,只返回initialValue本身,因为它只是标记了列表的结束。现在我们可以如下计算所有元素的总和:

int sum = linkedList.foldLeft(0,(a,b)->a+b);
System.out.println(sum);

我们将0作为初始值,并将求和的 lambda 传递给这个值。这个看起来很复杂,直到你习惯了这个想法,但一旦习惯了,它就非常简单。让我们一步一步地看看发生了什么;从headtail的列表是{0,3,5}

  1. 在第一次调用中,我们传递了初始值0。计算出的newInitialValue0+0 = 0。现在,我们将这个newInitialValue传递给尾部进行foldLeft,即{3,5}

  2. {3,5}有一个head 3tail {5}3被加到initialValue 0上得到newInitialValue 0+3=3。现在,这个新值3被传递到尾部进行foldLeft

  3. {5}有一个head 5和空tail5被加到initialValue 3上得到8。现在这个8被作为initialValue传递给尾部,它是一个空列表。

  4. 当然,空列表在foldLeft操作中只返回初始值。因此它返回8,我们得到了sum

我们甚至可以计算一个列表作为结果,而不是计算一个值。以下代码反转了一个列表:

LinkedList<Integer> reversedList = linkedList.foldLeft(LinkedList.emptyList(),(l,b)->l.add(b) );
reversedList.forEach(System.out::println);

我们只是传递了一个空列表作为初始操作,然后我们的操作简单地添加一个新元素到列表中。在foldLeft的情况下,头部将添加到尾部之前,导致它在新构造的列表中更靠近尾部。

如果我们想要首先处理最右侧的端点(或远离头部)并移动到左侧,这个操作被称为 foldRight。这可以通过非常相似的方式实现,如下所示:

public class LinkedList<E> {
  …

  public static class EmptyList<E> extends LinkedList<E>{
    …

    @Override
    public <R> R foldRight(TwoArgumentExpression<E, R, R> computer, R initialValue) {
      return initialValue;
    }
  }

  …

  public <R> R foldRight(TwoArgumentExpression<E,R,R> computer, R initialValue){
    R computedValue = tail().foldRight(computer, initialValue);
    return computer.compute(head(), computedValue);
  }
}

我们交换了参数的顺序,使其直观地表明 initialValue 是从列表的右侧开始组合的。与 foldLeft 的区别在于我们首先在尾部计算值,然后调用 foldRight。然后我们返回从尾部组合到头部以获得结果的计算值的结果。在计算总和的情况下,调用哪个折叠没有区别,因为总和是交换的,即 a+b 总是等于 b+a。我们可以以下方式调用 foldRight 操作来计算总和,这将给出相同的总和:

int sum2 = linkedList.foldRight((a,b)->a+b, 0);
System.out.println(sum2);

然而,如果我们使用一个非交换的运算符,我们将得到不同的结果。例如,如果我们尝试使用 foldRight 方法反转列表,它将给出相同的列表而不是反转:

LinkedList<Integer> sameList = linkedList.foldRight((b,l)->l.add(b), LinkedList.emptyList());
sameList.forEach(System.out::println);

我们想要对列表做的最后一件事是过滤。你将在下一小节中学习它。

链表过滤操作

过滤是一个操作,它接受一个 lambda 作为条件,并创建一个新的列表,其中只包含满足条件的元素。为了演示这一点,我们将创建一个实用方法,用于创建一系列元素的列表。

首先,我们创建一个辅助方法,将一系列数字追加到现有列表的头部。此方法可以递归地调用自己:

private static LinkedList<Integer> ofRange(int start, int end, LinkedList<Integer> tailList){
  if(start>=end){
    return tailList;
  }else{
    return ofRange(start+1, end, tailList).add(start);
  }
}

然后,我们使用辅助方法生成一系列数字的列表:

public static LinkedList<Integer> ofRange(int start, int end){
  return ofRange(start,end, LinkedList.emptyList());
}

这将使我们能够创建一个整数范围的列表。范围包括起始值,但不包括结束值。例如,以下代码将创建一个从 1 到 99 的数字列表,然后打印该列表:

LinkedList<Integer> rangeList = LinkedList.ofRange(1,100);
rangeList.forEach(System.out::println);

现在,我们想要创建一个包含所有偶数的列表,比如说。为此,我们在 LinkedList 类中创建一个 filter 方法:

public class LinkedList<E> {

  …

    public static class EmptyList<E> extends LinkedList<E>{

    …

    @Override
    public LinkedList<E> filter(OneArgumentExpression<E, Boolean> selector) {
      return this;
    }
  }

  …

  public LinkedList<E> filter(OneArgumentExpression<E, Boolean> selector){
    if(selector.compute(head())){
      return new LinkedList<E>(head(), tail().filter(selector));
    }else{
      return tail().filter(selector);
    }
  }
}

filter() 方法检查条件是否满足。如果是,则包括 head 并在 tail 上调用 filter() 方法。如果不是,则仅调用 tail 上的 filter() 方法。当然,EmptyList 需要重写此方法以仅返回自身,因为我们只需要一个空列表。现在,我们可以做以下操作:

LinkedList<Integer> evenList = LinkedList.ofRange(1,100).filter((a)->a%2==0);
evenList.forEach(System.out::println);

这将打印出 1 到 99 之间的所有偶数。让我们通过一些更多示例来熟悉所有这些内容。我们如何计算从 1 到 100 的所有数字之和?以下代码将完成这个任务:

int sumOfRange = LinkedList.ofRange(1,101).foldLeft(0, (a,b)->a+b);
System.out.println(sumOfRange);

注意,我们使用了 (1,101) 的范围,因为生成的链表不包括结束数字。

我们如何使用这个方法来计算一个数的阶乘?我们定义一个 factorial 方法如下:

public static BigInteger factorial(int x){
  return LinkedList.ofRange(1,x+1)
  .map((a)->BigInteger.valueOf(a))
  .foldLeft(BigInteger.valueOf(1),(a,b)->a.multiply(b));
}

我们使用了 Java 的BigInteger类,因为阶乘增长得太快,intlong无法容纳很多。此代码演示了我们在使用foldLeft方法之前,如何使用map方法将整数列表转换为BigIntegers列表。现在我们可以使用以下代码计算100的阶乘:

System.out.println(factorial(100));

这个例子也展示了我们可以将我们开发的方法结合起来解决更复杂的问题的想法。一旦你习惯了这种方法,阅读一个函数式程序并理解它所做的工作,要比阅读它们的命令式版本简单得多。我们甚至使用了单字符变量名。实际上,我们可以使用有意义的名称,在某些情况下,我们应该这样做。但在这里,程序如此简单,所使用的变量又如此接近它们的定义位置,以至于甚至没有必要用描述性的名称来命名它们。

假设我们想要重复一个字符串。给定一个整数n和一个字符串,我们想要的结果字符串是原始字符串重复n次。例如,给定整数5和字符串Hello,我们想要输出为HelloHello HelloHello Hello。我们可以使用以下函数来完成:

public static String repeatString(final String seed, int count){
  return LinkedList.ofRange(1,count+1)
  .map((a)->seed)
  .foldLeft("",(a,b)->a+b);
}

我们在这里所做的是首先创建一个长度为count的列表,然后将所有元素替换为seed。这给我们一个新的列表,其中所有元素都等于seed。这可以通过折叠得到所需的重复字符串。这很容易理解,因为它非常类似于sum方法,只不过我们添加的是字符串而不是整数,这导致了字符串的重复。但我们甚至不需要这样做。我们甚至可以在不创建一个所有元素都被替换的新列表的情况下完成它。以下是如何做到这一点的:

public static String repeatString2(final String seed, int count){
  return LinkedList.ofRange(1,count+1)
  .foldLeft("",(a,b)->a+seed);
}

在这里,我们只是忽略列表中的整数,并添加seed。在第一次迭代中,a将被设置为初始值,即一个空字符串。每次,我们只是忽略内容,而不是添加seed到这个字符串中。请注意,在这种情况下,变量aString类型,而变量bInteger类型。

因此,我们可以使用链表做很多事情,使用它的特殊方法和带有 lambda 参数的 lambda 表达式。这是函数式编程的力量。然而,我们使用 lambda 所做的是,我们将接口的实现作为可插入的代码传递。这并不是面向对象语言中的新概念。然而,没有 lambda 语法,定义一个匿名类来完成等效操作将需要大量的代码,这会极大地增加代码的复杂性,从而破坏了简洁性。但改变的是不可变性,导致了方法链和其他概念的出现。我们在分析程序时并没有考虑状态;我们只是将其视为一系列转换。变量更像是代数中的变量,其中x的值在整个公式中保持不变。

链接列表上的追加

我们已经完成了我们想要做的列表中的所有事情。可能还有一些。一个重要的事情,例如,是append操作。这个操作将一个列表粘接到另一个列表上。这可以使用我们已定义的foldRight方法来完成:

public LinkedList<E> append(LinkedList<E> rhs){
  return this.foldRight((x,l)->l.add(x),rhs);
}

现在,我们执行以下操作:

LinkedList<Integer> linkedList = 
LinkedList.<Integer>emptyList().add(5).add(3).add(0);
LinkedList<Integer> linkedList2 =
 LinkedList.<Integer>emptyList().add(6).add(8).add(9);
linkedList.append(linkedList2).forEach(System.out::print);

这将输出035986,这是第一个列表被附加到第二个列表的前面。

要理解它是如何工作的,首先记住foldRight操作的作用。它从一个初始值开始——在这个例子中,是右侧RHS)。然后它逐个从列表的尾部取一个元素,并使用提供的操作与初始列表进行操作。在我们的例子中,操作只是简单地将一个元素添加到初始列表的头部。所以,最终我们得到整个列表附加到 RHS 的开始处。

我们还想对列表做另一件事,但我们直到现在还没有讨论过。这个概念需要理解前面的概念。这被称为flatMap操作,我们将在下一小节中探讨它。

链表上的 flatMap 方法

flatMap操作就像map操作一样,只不过我们期望传递的操作返回一个列表而不是一个值。flatMap操作的任务是将获得的列表展平并一个接一个地粘合在一起。例如,以下代码:

LinkedList<Integer> funnyList 
=LinkedList.ofRange(1,10)
.flatMap((x)->LinkedList.ofRange(0,x));

传递的操作返回从0x-1的数字范围。由于我们从 1 到 9 的数字列表开始flatMapx将获得从 1 到 9 的值。然后我们的操作将为每个x值返回一个包含 0 和 x-1 的列表。flatMap操作的任务是将所有这些列表展平并一个接一个地粘合在一起。看看以下打印funnyList的代码行:

funnyList.forEach(System.out::print);

它将在输出上打印001012012301234012345012345601234567012345678

那么,我们如何实现flatMap操作呢?让我们看看:

public class LinkedList<E> {

  public static class EmptyList<E> extends LinkedList<E>{

    …

    @Override
    public <R> LinkedList<R> flatMap(OneArgumentExpression<E, LinkedList<R>> transformer) {
      return LinkedList.emptyList();
    }
  }

  …

  public <R> LinkedList<R> flatMap(OneArgumentExpression<E, LinkedList<R>> transformer){
    return transformer.compute(head())
    append(tail().flatMap(transformer));
  }
}

这里发生了什么?首先,我们计算由headtail上的flatMap操作得到的结果列表。然后我们将操作在列表的head上的结果append到由tail上的flatMap得到的结果列表前面。在空列表的情况下,flatMap操作只返回一个空列表,因为没有东西可以调用转换。

单子概念

在上一节中,我们看到了许多针对链表的运算。其中一些,比如mapflatMap,在函数式编程中的许多对象中都是一个常见的主题。它们的意义不仅仅局限于列表。mapflatMap方法,以及从值构造单子的方法,使得这样的包装对象成为单子。单子是函数式编程中遵循的一种常见设计模式。它是一种容器,某种存储其他类对象的容器。它可以直接包含一个对象,正如我们将要看到的;它可以包含多个对象,正如我们在链表案例中看到的;它可以包含在调用某些函数后才会可用的对象,等等。单子有一个正式的定义,不同的语言对其方法的命名也不同。我们只考虑 Java 定义的方法。单子必须有两个方法,称为map()flatMap()map()方法接受一个 lambda,作为单子所有内容的转换。flatMap方法也接受一个方法,但它返回的不是转换后的值,而是另一个单子。然后flatMap()方法从单子中提取输出并创建一个转换后的单子。我们已经看到了一个以链表形式存在的单子例子。但直到你看到几个例子而不是一个,这个一般主题才变得清晰。在下一节中,我们将看到另一种类型的单子:选项单子。

选项单子

选项单子是一个包含单个值的单子。这个概念的全部意义在于避免在我们的代码中处理空指针,这会掩盖实际的逻辑。选项单子的目的是能够以一种方式持有空值,这样在每一步中就不需要空值检查。在某种程度上,选项单子可以被视为零个或一个对象的列表。如果它只包含零个对象,那么它代表一个空值。如果它包含一个对象,那么它作为该对象的包装器工作。mapflatMap方法的行为将完全像在只有一个参数的列表中的行为一样。表示空选项的类称为None。首先,我们为选项单子创建一个抽象类。然后,我们创建两个内部类,分别称为SomeNone,分别表示包含值和不包含值的Option。这是一个更通用的模式,可以满足非空Option必须存储值的实际情况。我们也可以用列表来做这件事。让我们首先看看我们的抽象类:

public abstract class Option<E> {
  public abstract E get();
  public abstract <R> Option<R> map(OneArgumentExpression<E,R> transformer);
  public abstract <R> Option<R> flatMap(OneArgumentExpression<E,Option<R>> transformer);
  public abstract void forEach(OneArgumentStatement<E> statement);

  …
}

一个静态方法optionOf返回Option类的适当实例:

public static <X> Option<X>  optionOf(X value){
  if(value == null){
    return new None<>();
  }else{
    return new Some<>(value);
  }
}

我们现在定义一个内部类,称为None

public static class None<E> extends Option<E>{

  @Override
  public <R> Option<R> flatMap(OneArgumentExpression<E, Option<R>> transformer) {
    return new None<>();
  }

  @Override
  public E get() {
    throw new NoValueException("get() invoked on None");
  }

  @Override
  public <R> Option<R> map(OneArgumentExpression<E, R> transformer) {
    return new None<>();
  }

  @Override
  public void forEach(OneArgumentStatement<E> statement) {
  }
}

我们创建另一个类,Some,来表示非空列表。我们在Some类中将值存储为一个单独的对象,并且没有递归尾:

public static class Some<E> extends Option<E>{
  E value;
  public Some(E value){
    this.value = value;
  }
  public E get(){
    return value;
  }
  …
}

mapflatMap方法相当直观。map方法接受一个转换器并返回一个新的Option,其值被转换。flatMap方法做的是相同的,但它期望转换器将返回的值包装在另一个Option中。这在转换器有时会返回 null 值时很有用,在这种情况下,map方法将返回一个不一致的Option。相反,转换器应该将其包装在Option中,我们需要使用flatMap操作来实现这一点。看看以下代码:

public static class Some<E> extends Option<E>{
  …

  public <R> Option<R> map(OneArgumentExpression<E,R> transformer){
    return Option.optionOf(transformer.compute(value));
  }
  public <R> Option<R> flatMap(OneArgumentExpression<E,Option<R>> transformer){
    return transformer.compute(value);
  }
  public void forEach(OneArgumentStatement<E> statement){
    statement.doSomething(value);
  }
}

要理解Option单子的用法,我们首先创建一个JavaBean。JavaBean 是一个专门用于存储数据的对象。它在 C 语言中的等价物是结构体。然而,由于封装是 Java 的一个基本原则,JavaBean 的成员不能直接访问。相反,它们通过特殊的方法(getter 和 setter)来访问。然而,我们的函数式风格规定 bean 是不可变的,所以不会有任何 setter 方法。以下一系列类给出了一些 JavaBean 的示例:

public class Country {
  private String name;
  private String countryCode;

  public Country(String countryCode, String name) {
    this.countryCode = countryCode;
    this.name = name;
  }

  public String getCountryCode() {
    return countryCode;
  }

  public String getName() {
    return name;
  }
}
public class City {
  private String name;
  private Country country;

  public City(Country country, String name) {
    this.country = country;
    this.name = name;
  }

  public Country getCountry() {
    return country;
  }

  public String getName() {
    return name;
  }

}
public class Address {
  private String street;
  private City city;

  public Address(City city, String street) {
    this.city = city;
    this.street = street;
  }

  public City getCity() {
    return city;
  }

  public String getStreet() {
    return street;
  }
}
public class Person {
  private String name;
  private Address address;

  public Person(Address address, String name) {
    this.address = address;
    this.name = name;
  }

  public Address getAddress() {
    return address;
  }

  public String getName() {
    return name;
  }
}

这四个类中没有什么难以理解的。它们的存在是为了存储一个人的数据。在 Java 中,遇到一个非常类似的对象的情况并不少见。

现在,假设我们有一个类型为Person的变量person,我们想要打印他/她居住的国家名称。如果状态变量中的任何一个可以是 null,那么使用所有 null 检查的正确方式将如下所示:

if(person!=null
 && person.getAddress()!=null
 && person.getAddress().getCity()!=null
 && person.getAddress().getCity().getCountry()!=null){
  System.out.println(person.getAddress().getCity().getCountry());
}

这段代码可以工作,但坦白说——它包含了很多 null 检查。我们可以通过使用我们的Options类简单地获取地址,如下所示:

String countryName = Option.optionOf(person)
.map(Person::getAddress)
.map(Address::getCity)
.map(City::getCountry)
.map(Country::getName).get();

注意,如果我们只是打印这个地址,有可能打印出 null。但这不会导致空指针异常。如果我们不想打印 null,我们需要一个类似于我们链表中的forEach方法:

public class Option<E> {
  public static class None<E> extends Option<E>{

  …

    @Override
    public void forEach(OneArgumentStatement<E> statement) {
    }
  }

…

  public void forEach(OneArgumentStatement<E> statement){
    statement.doSomething(value);
  }
}

forEach方法只是调用传递给它的 lambda 表达式,None类重写了它以执行无操作。现在,我们可以这样做:

Option.optionOf(person)
.map(Person::getAddress)
.map(Address::getCity)
.map(City::getCountry)
.map(Country::getName)
.forEach(System.out::println);

如果country中的名称为 null,这段代码现在将不会打印任何内容。

现在,如果Person类本身是函数式感知的,并返回Options以避免返回 null 值,会发生什么呢?这就是我们需要flatMap的地方。让我们制作一个Person类中所有类的新的版本。为了简洁起见,我将只展示Person类的修改以及它是如何工作的。然后你可以检查其他类的修改。以下是代码:

public class Person {
  private String name;
  private Address address;

  public Person(Address address, String name) {
    this.address = address;
    this.name = name;
  }

  public Option<Address> getAddress() {
    return Option.optionOf(address);
  }

  public Option<String> getName() {
    return Option.optionOf(name);
  }
}

现在,代码将修改为使用flatMap而不是map

Option.optionOf(person)
.flatMap(Person::getAddress)
.flatMap(Address::getCity)
.flatMap(City::getCountry)
.flatMap(Country::getName)
.forEach(System.out::println);

现在代码完全使用了Option单子。

尝试 monad

我们还可以讨论另一个我们可以讨论的 monad 是 Try monad。这个 monad 的目的是使异常处理更加紧凑,并避免隐藏实际程序逻辑的细节。mapflatMap 方法的语义是显而易见的。同样,我们再次创建了两个子类,一个用于成功,一个用于失败。Success 类持有计算出的值,而 Failure 类持有抛出的异常。像往常一样,Try 在这里是一个抽象类,包含一个静态方法来返回适当的子类:

public abstract class Try<E> {
  public abstract <R> Try<R> map(
OneArgumentExpressionWithException<E, R> expression);

  public abstract <R> Try<R> flatMap(
OneArgumentExpression<E, Try<R>> expression);

  public abstract E get();

  public abstract void forEach(
OneArgumentStatement<E> statement);

  public abstract Try<E> processException(
OneArgumentStatement<Exception> statement);
  …
  public static <E> Try<E> of(
NoArgumentExpressionWithException<E> expression) {
    try {
      return new Success<>(expression.evaluate());
    } catch (Exception ex) {
      return new Failure<>(ex);
    }
  }
  …
}

我们需要一个名为 NoArgumentExpressionWithException 的新类和一个允许其体内出现异常的 OneArgumentExpressionWithException 类。它们如下所示:

@FunctionalInterface
public interface NoArgumentExpressionWithException<R> {
  R evaluate() throws Exception;
}

@FunctionalInterface
public interface OneArgumentExpressionWithException<A,R> {
  R compute(A a) throws Exception;
}

Success 类存储传递给 of() 方法的表达式的值。请注意,of() 方法已经执行了表达式以提取值。

protected static class Success<E> extends Try<E> {
  protected E value;

  public Success(E value) {
    this.value = value;
  }

事实上,这是一个代表早期表达式成功的一个类;flatMap 只需处理以下表达式中出现的异常,而以下传递给它的 Try 会自己处理这些异常,所以我们只需返回那个 Try 实例本身:

  @Override
  public <R> Try<R> flatMap(
    OneArgumentExpression<E, Try<R>> expression) {
      return expression.compute(value);
  }

然而,map() 方法必须执行传递的表达式。如果有异常,它返回一个 Failure;否则返回一个 Success

  @Override
  public <R> Try<R> map(
    OneArgumentExpressionWithException<E, R> expression) {
    try {
      return new Success<>(
        expression.compute(value));
    } catch (Exception ex) {
      return new Failure<>(ex); 
    }
  }

get() 方法返回预期的值:

  @Override
  public E get() {
    return value;
  }

forEach() 方法允许你在值上运行另一段代码,而不返回任何内容:

  @Override 
  public void forEach(
    OneArgumentStatement<E> statement) {
      statement.doSomething(value);
  }

这个方法不做任何事情。Failure 类上的相同方法会在异常上运行一些代码:

  @Override
  public Try<E> processException(
    OneArgumentStatement<Exception> statement) {
      return this;
  }
}

现在,让我们看看 Failure 类:

protected static class Failure<E> extends Try<E> {
  protected Exception exception;

  public Failure(Exception exception) {
    this.exception = exception;
  }

在这里,在 flatMap()map() 方法中,我们只是改变了 Failure 的类型,但返回了一个具有相同异常的实例:

  @Override
  public <R> Try<R> flatMap(
    OneArgumentExpression<E, Try<R>> expression) {
      return new Failure<>(exception);
  }

  @Override
  public <R> Try<R> map(
    OneArgumentExpressionWithException<E, R> expression) {
      return new Failure<>(exception);
  }

Failure 的情况下没有值需要返回:

  @Override
  public E get() {
    throw new NoValueException("get method invoked on Failure");
  }

forEach() 方法中我们不做任何事情,因为没有值需要处理,如下所示:

  @Override
  public void forEach(
    OneArgumentStatement<E> statement) {
    …
  }

以下方法在 Failure 实例中包含的异常上运行一些代码:

  @Override
  public Try<E> processException(
    OneArgumentStatement<Exception> statement) {
      statement.doSomething(exception);
      return this;
  }
}

通过这个 Try monad 的实现,我们现在可以继续编写涉及处理异常的代码。以下代码将打印文件 demo 的第一行,如果它存在的话。否则,它将打印异常。它还会打印任何其他异常:

Try.of(() -> new FileInputStream("demo"))
.map((in)->new InputStreamReader(in))
.map((in)->new BufferedReader(in))
.map((in)->in.readLine())
.processException(System.err::println)
.forEach(System.out::println);

注意它如何处理异常中的杂乱。在这个阶段,你应该能够看到正在发生的事情。每个 map() 方法,像往常一样,转换之前获得的一个值,但在这个情况下,map() 方法中的代码可能会抛出异常,并且会被优雅地包含。前两个 map() 方法从一个 FileInputStream 创建一个 BufferedReader,而最后的 map() 方法从 Reader 中读取一行。

通过这个例子,我总结了 monad 部分。monadic 设计模式在函数式编程中无处不在,理解这个概念非常重要。我们将在下一章看到更多 monad 和一些相关概念。

递归算法复杂度的分析

在本章中,我方便地跳过了我所讨论的算法的复杂度分析。这是为了确保你在被其他事情分散注意力之前先掌握函数式编程的概念。现在是时候回到这个话题上了。

分析递归算法的复杂度首先需要创建一个方程。这是自然而然的,因为函数是以较小输入的形式定义的,复杂度也是以较小输入计算自身函数的形式表达的。

例如,假设我们正在尝试找到foldLeft操作的复杂度。foldLeft操作实际上是两个操作,第一个操作是对当前初始值和列表头部的固定操作,然后是对列表尾部的foldLeft操作。假设T(n)代表在长度为n的列表上运行foldLeft操作所需的时间。现在,假设固定操作需要时间A。那么,foldLeft操作的定义表明T(n) = A + T(n-1)。现在,我们将尝试找到一个解决这个方程的函数。在这种情况下,这非常简单:

T(n) = A + T(n-1)

=> T(n) – T(n-1) = A

这意味着T(n)是一个等差数列,因此可以表示为T(n) = An + C,其中C是初始起点,或T(0)

这意味着T(n) = O(n)。我们已经看到了foldLeft操作如何在线性时间内工作。当然,我们假设涉及的运算与时间无关。更复杂的运算将导致不同的复杂度。

建议您尝试计算其他算法的复杂度,这些算法与这个算法差别不大。然而,我还会提供一些类似的算法。

在本章的早期,我们是这样实现choose函数的:

choose(n,r) = choose(n-1,r) + choose(n-1, r-1)

如果我们假设所需的时间由函数T(n,r)给出,那么T(n,r) = T(n-1,r) + T(n-1,r-1) + C,其中C是一个常数。现在我们可以做以下操作:

 T(n,r) = T(n-1,r) + T(n-1,r-1) + C
=>T(n,r) -  T(n-1,r) = T(n-1,r-1) + C

同样,T(n-1,r) - T(n-2,r) = T(n-2,r-1) + C,只需将n替换为n-1。通过堆叠这样的值,我们得到以下结果:

T(n,r) -  T(n-1,r) = T(n-1,r-1) + C
T(n-1,r) -  T(n-2,r) = T(n-2,r-1) + C
T(n-2,r) -  T(n-3,r) = T(n-3,r-1) + C
…
T(r+1,r) -  T(r,r) = T(r,r-1) + C

前面的等式考虑了总共n-r个步骤。如果我们对栈的两边进行求和,我们得到以下结果:

递归算法复杂度分析

当然,T(r,r)是常数时间。让我们称它为B。因此,我们有以下结果:

递归算法复杂度分析

注意,我们也可以将相同的公式应用于T(i,r-1)。这将给我们以下结果:

递归算法复杂度分析

简化后得到以下结果:

递归算法复杂度分析

我们可以继续这样下去,最终我们会得到一个包含多个嵌套求和的表达式,如下所示:

递归算法复杂度分析

在这里,A 和 D 也是常数。当我们谈论渐近复杂度时,我们需要假设一个变量足够大。在这种情况下,有两个变量,条件是r总是小于或等于n。因此,首先我们考虑r固定而n正在增加并变得足够大的情况。在这种情况下,会有总共r个嵌套的求和。T(t,0)是常数时间。求和有r层深度,每层最多有(n-r)个元素,所以它是O((n-r)r)。其他项也是O((n-r)r)。因此,我们可以得出以下结论:

T(n,r) = O((n-r)r) = O(nr)

输入的大小当然不是n;它是log n = u (即)。然后,我们有T(n,r) = O(2sr)的计算复杂度。

另一个有趣的案例是当我们同时增加rn,并且增加它们之间的差异时。为了做到这一点,我们可能想要两个之间的特定比率,我们假设r/n= k, k<1始终。然后我们可以看到函数T(n, kn)的渐近增长。但计算这个需要微积分,并且超出了本书的范围。

这表明,尽管函数形式的算法分析可能更容易,但时间复杂度的分析可能相当困难。很容易理解为什么计算函数算法的复杂度更困难。最终,计算复杂度涉及到计算所需步骤的数量。在命令式风格中,计算步骤是直接的,所以很容易计数。另一方面,递归风格是一种更高层次的抽象,因此,计数步骤更困难。在接下来的章节中,我们将看到更多这些分析。

函数式编程的性能

如果我们仔细思考,函数式编程的整个要点就是拥有不可变性和程序的递归定义(或归纳定义),这样它们就可以被轻易分析。一般来说,在程序上添加额外的约束会使分析变得更加简单,但会减少你可以用它做的事情。当然,函数式编程通过不可变性这种形式,在命令式编程上添加了额外的约束,也就是说,你不再被允许重新分配变量。这样做是为了使程序的分析,即理解程序是如何工作的,现在变得更加简单。证明关于程序的理论也更加简单。然而,我们也失去了一些没有这些限制时可以做的事情。结果发现,任何程序都可以以产生相同结果的方式重写为函数式风格。然而,对于它们的性能或复杂性并没有任何保证。所以,一个函数式版本的程序可能比它的命令式对应版本要低效得多。实际上,在现实生活中,我们确实面临许多这样的场景。因此,这实际上是在性能和简单性之间的一种权衡。那么,一般方向应该是,当处理大量输入数据时,最好去除限制,以便能够进行更多的优化。另一方面,当输入数据量较小时,坚持函数式风格是有意义的,因为性能可能不会受到太大影响。

尽管如此,也有一些情况下,函数式版本的运行时间复杂性与命令式版本相同。在这种情况下,由于它的简单性,可能会更倾向于选择函数式版本。需要注意的是,由于 Java 没有提供任何显式的垃圾回收方式,实际上,它是由偶然或程序员无法控制的情况发生的,因此,由于不可变性,函数式编程风格会非常快地填满堆,因为一旦创建就会被丢弃。所以,在性能真正成为问题的地方,不建议使用它们。

这似乎与许多大型数据处理系统,如 Spark,使用函数式编程风格的事实相矛盾。然而,这些系统只拥有一种专门的语言,给人一种函数式编程风格的外观;在它们被执行之前,它们几乎被转换成了一种几乎非函数式的形式。为了更详细地说明,一个在单子中的映射方法可能根本不会评估任何东西;相反,它可能只是创建一个新的对象,包含这个操作符。然后,一个通用程序可以分析这些结构,并构建一个执行相同工作的命令式程序。这为框架的使用者提供了一个简单的接口,同时保持资源使用在可控范围内。在下一章中,我们将探讨一些这些想法。

摘要

在本章中,我们学习了一种新的看待算法的方法。以函数式风格编写程序可以简化对其正确性的分析,也就是说,你可以轻松理解为什么程序会产生正确的输出。我们看到了函数式编程中的一些模式,特别是单子(monads)。我们还看到了 Java 如何通过 lambda 语法提供对函数式编程风格的支持,这种语法从 Java 9 版本开始就存在了。最后,我们看到了如何有效地使用 lambda 进行函数式编程。

函数式程序通常更容易验证其正确性,但计算其复杂度则较为困难。它们通常的运行速度要么与命令式程序相同,要么更慢。这是开发努力和计算效率之间的权衡。对于较小的输入,采用函数式编程风格是理想的,而对于处理大量输入,则可能更倾向于命令式风格。

第五章. 高效搜索 – 二分搜索和排序

什么是搜索?搜索是在一组值中定位给定值的过程。例如,你被给了一个整数数组,你的问题是检查整数 5 是否在该数组中。这是一个搜索问题。除了决定整数 5 是否在数组中之外,我们还可能对其位置感兴趣,当找到它时。这也是一个搜索问题。

对此的一个有趣的看法是想象一个字典,即值和关联值的数组。例如,你有一个包含学生名字和分数的数组,如下表所示:

姓名 分数
汤姆 63
哈里 70
梅里 65
阿伊莎 85
阿卜杜拉 72
...

列表继续。假设,我们的系统允许学生查看自己的分数。他们会输入他们的名字,系统会显示他们的分数。为了简单起见,让我们假设没有重复的名字。现在,我们必须搜索提供的名字并返回相应的值。因此,这又是一个搜索问题。正如我们将看到的,搜索问题在编程中相当普遍。

在本章中,你将学习以下内容:

  • 搜索算法

  • 在有序列表中进行高效搜索

  • 一些排序算法

搜索算法

假设你被给了一个值数组,你需要检查特定的值是否在该数组中,最自然的方法是逐个检查每个元素是否与给定的值匹配。如果任何一个匹配,我们就找到了那个元素,我们可以返回索引;如果没有,我们可以在处理完所有元素后返回特殊值 -1 来报告找不到这样的元素。这就是我们所说的线性搜索。以下演示了在数组中的线性搜索算法:

    public static <E, F extends E> int linearSearch(E[] values, 
    valueToLookup) { 
        for (int i = 0; i < values.length; i++) { 
            if (values[i].equals(valueToLookup)) { 
                return i; 
            } 
        } 
        return -1; 
    }

函数 linearSearch 接收一个值数组和要搜索的值,如果找到该值则返回索引。如果没有找到,它返回一个特殊值 -1。程序简单地遍历每个元素并检查当前元素是否与要查找的值匹配;如果匹配,则返回当前索引,否则继续查找。数组的末尾返回特殊值 -1。现在以下代码块应该在第一种情况下返回 -1,在第二种情况下返回值 5

        Integer[] integers = new Integer[]{232,54,1,213,654,23,6,72,21}; 
        System.out.println(ArraySearcher.linearSearch(integers,5)); 
        System.out.println(ArraySearcher.linearSearch(integers,23));

现在,如果我们想解决本章引言中描述的学生分数问题,我们只需要将学生的分数存储在同一个顺序的不同数组中,如下所示:

    static String[] students = new String[]{"Tom","Harry","Merry","Aisha", "Abdullah"}; 
    static int[] marks = new int[]{63,70, 65, 85, 72}; 

现在我们可以编写一个函数来搜索一个名字:

    public static Integer marksForName(String name){ 
        int index = linearSearch(students, name); 
        if(index>=0){ 
            return marks[index]; 
        }else{ 
            return null; 
        } 
    }

首先,我们在学生名单中查找学生的名字。如果找到名字,相应的索引将被分配给变量 index,并且值将大于或等于零。在这种情况下,我们返回与分数数组相同索引存储的值。如果没有找到,我们返回 null。例如,要查找 Merry 的分数,我们可以像下面这样调用:

        System.out.println(marksForName("Merry"));

我们正确地获得了她的分数,即65

线性搜索的复杂度是多少?我们有一个for循环,它遍历长度为n(比如说)的数组中的每个元素;在最坏的情况下,我们会遍历所有元素,所以最坏情况复杂度是θ(n)。即使在平均情况下,我们也会在找到正确元素之前访问一半的元素,所以平均情况复杂度是θ(n/2) = θ(n)

二分查找

线性搜索就是我们能做的最好的吗?好吧,结果证明,如果我们正在查看一个任意数组,这就是我们必须做的事情。毕竟,在一个任意数组中,没有方法可以知道一个元素是否存在,而不可能查看所有元素。更具体地说,我们无法确定某个元素不存在,除非验证所有元素。原因是单个元素的价值对其他元素的价值没有任何说明。

但是,一个元素能有多少信息可以告诉我们数组中的其他元素?让元素具有关于其他元素信息的一种方法是将数组排序,而不是仅仅是一个任意数组。什么是排序数组?排序数组是一个将所有元素按其值排序的数组。当一个数组被排序时,每个元素都包含有关左侧一切小于该特定元素的信息,以及右侧一切更大的信息(或者如果元素的顺序相反,但我们将考虑从左到右递增排序的数组)。这种信息惊人地使这个搜索变得更快。以下是我们要做的事情:

  • 检查数组的中间元素。如果它与我们要查找的元素匹配,我们就完成了。

  • 如果中间元素小于我们要查找的值,则在当前数组的右侧子数组中进行搜索。这是因为左侧的一切都更小。

  • 如果中间元素大于我们要查找的值,则只搜索左侧子数组。

为了避免在创建子数组时创建数组的副本,我们只需传递整个数组,但我们要记住我们正在查看的起始和结束位置。起始位置包含在范围内,结束位置不包含。因此,只有位于起始位置右侧和结束位置左侧的元素包含在正在搜索的子数组中。以下图示给出了二分查找的直观理解:

二分查找

图 1:二分查找。

一个表示在搜索过程中将一个元素移动到另一个位置的箭头。

但是,在实现这个算法之前,我们需要理解 Comparable 的概念。Comparable 是 Java 标准库中的一个接口,其形式如下:

package java.lang; 
public interface Comparable<T> { 
    public int compareTo(T o); 
} 

实现此接口的任何类都必须与自己比较不同的对象。必须要求类型参数 T 使用实现它的相同类实例化,如下所示:

public class Integer implements Comparable<Integer>{
    public int compareTo(Integer o){
        …
    } 
}

compareTo 方法旨在比较相同类型的对象。如果当前对象(this 引用指向的对象)小于传入的对象,compareTo 必须返回一个负值。如果传入的对象较小,该方法必须返回一个正值。否则,如果它们相等,它必须返回 0compareTo 方法必须满足以下条件:

  • 如果 a.compareTo(b) == 0,那么 a.equals(b) 必须为 true

  • 如果 a.compareTo(b) < 0b.compareTo(c) < 0,那么 a.compareTo(c) <0

  • 如果 a.compareTo(b) <0,那么 b.compareTo(a) > 0

  • 如果 b.equals(c) 为真且 a.compareTo(b) <0,那么 a.compareTo(c) <0

  • 如果 b.equals(c) 为真且 a.compareTo(b) >0,那么 a.compareTo(c) >0

基本上,对于表示相等性的等价关系,条件是相同的。它基本上概括了 <<= 操作符的概念,这些操作符用于数字。当然,Wrapper 对象的 compareTo 方法实现与它们内部的原始类型上的 <<= 操作符完全相同。

现在,我们按照前面的步骤编写搜索函数来执行搜索:

    private static <E extends Comparable<E>, 
      F extends E> int binarySearch( E[] sortedValues, 
      F valueToSearch, int start, int end) { 
        if(start>=end){ 
            return -1; 
        } 
        int midIndex = (end+start)/2; 
        int comparison = sortedValues[midIndex].compareTo(valueToSearch); 
        if(comparison==0){ 
            return midIndex; 
        }else if(comparison>0){ 
            return binarySearch(sortedValues, valueToSearch, start, midIndex); 
        }else{ 
            return binarySearch(sortedValues, valueToSearch, midIndex+1, end); 
        } 
    }

注意,在这种情况下,我们规定数组中的对象必须是可比较的,这样我们才能知道一个对象是否大于或小于另一个对象。这个关系是如何确定的并不重要;数组必须使用相同的比较方式排序——即两个连续元素之间的比较将确保左边的元素小于右边的元素,正如 Comparable 所提供的。

第一个 if 条件检查传入的数组是否为空,如果是,那么显然要搜索的元素未找到,我们返回 -1 来表示这一点。然后,我们找到 midIndex 并递归地在左子数组或右子数组中搜索元素。一旦我们有了这个函数,我们再创建另一个包装函数来运行搜索,而不必提及起始和结束位置:

    public static <E extends Comparable<E>, F extends E> int binarySearch( 
            E[] sortedValues, F valueToSearch) { 
        return binarySearch(sortedValues, valueToSearch, 0, sortedValues.length); 
    }

二分查找算法的复杂度

在每一步中,我们都在将整个数组划分为两个部分,除了我们正在比较的那个元素。在最坏的情况下,即搜索的元素不在数组中,我们将不得不一直下降到我们正在处理空数组的位置,在这种情况下,我们返回 -1 并停止递归。在前一步中,我们必须只有一个元素的数组。为了我们的分析,我们将这一步视为最后一步。所以,让我们有一个包含 n 个元素的已排序数组,T(.) 是在数组中搜索所需的时间。因此,我们有以下:

T(n) = T((n-1)/2) + C, where C is a constant.

通常,每一步中的两个搜索分支的大小会有所不同,一个可能比另一个部分小一个。但我们将忽略这些小的差异,这对大的 n 几乎没有影响。因此,我们将使用以下方程:

T(n) = T(n/2) + C

现在,让我们假设 n2 的整数次幂,即 n = 2m,其中 m 是某个整数。因此,我们有以下:

T(2m) = T(2m-1) + C

现在我们取另一个函数 S(.),使得 S(m) = T(2m) 对所有 m 都成立。然后,我们有:

S(m) = S(m-1) + C

这是等差数列的公式。因此,我们有:

S(m) = mC + D, where D is also a constant.
=> T(2m) = mC + D
=> T(n) = C lg(n) + D

因此,我们得到 T(n) 的渐近复杂度:

T(n) = O(lg(n))

函数 T(n) 的增长速度仅与传递给数组的数组大小对数相同,这非常慢。这使得二分搜索成为一个极其高效的算法。

为了对算法进行某种程度的现场测试,我们使用以下代码在包含一亿个元素的数组上运行线性搜索和二分搜索算法:

        int arraySize = 100000000; 
        Long array[] = new Long[arraySize]; 
        array[0] = (long)(Math.random()*100); 
        for(int i=1;i<array.length;i++){ 
            array[i] = array[i-1] + (long)(Math.random()*100); 
        } 

        //let us look for an element using linear and binary search 
        long start = System.currentTimeMillis(); 
        linearSearch(array, 31232L); 
        long linEnd = System.currentTimeMillis(); 
        binarySearch(array, 31232L); 
        long binEnd = System.currentTimeMillis(); 

        System.out.println("linear search time :=" + (linEnd -start)); 
        System.out.println("binary search time :=" + (binEnd -linEnd));

在我的电脑上,线性搜索耗时 282 毫秒,二分搜索耗时 0 毫秒。此外,请注意,我们正在寻找的值预计将非常接近数组的开始部分;在中间附近的值,二分搜索将具有更高的优势。

排序

好的,所以我们确信,如果我们有一个已排序的数组,在其中查找一个元素所需的时间会少得多。但我们是怎样得到一个已排序的数组呢?从一个任意数组中获取一个已排序的数组,同时保持所有元素不变,即只重新排列输入数组的元素,这个过程被称为排序。有很多排序算法。但在这个章节中,我们将从一些不太高效的简单算法开始。在下一章中,我们将探讨高效的排序算法。

选择排序

这是排序的最自然算法。我们选择数组的每个位置,并找到属于该位置的数组元素。选择排序的功能定义如下:

  • 在数组中查找最小元素

  • 将此元素与数组的第一个元素交换

  • 递归地对第一个元素之后的数组其余部分进行排序

查找最小元素的函数结构如下:

  • 将数组视为第一个元素和数组其余部分的组合。

  • 在数组的其余部分中查找最小元素的索引。

  • 将此元素与第一个元素进行比较。如果此元素小于第一个元素,那么它是整个数组中的最小元素。否则,第一个元素是最小元素。

我们不是复制数组,而是通过简单地存储我们想要考虑的起始索引来表示子数组,然后我们递归地工作在索引上。

首先,我们编写一个函数来找到给定数组中从某个位置开始的最小元素的索引:

    public static <E extends Comparable<E>> int findMin(E[] array, int start){ 

然后,我们检查起始位置是否是数组的最后一个位置,如果是,我们只需简单地返回起始位置,因为没有更多元素:

        if(start==array.length-1){ 
            return start; 
        } 

我们找到当前起始位置右侧数组中最小元素的索引,并将其与当前起始元素进行比较。我们返回具有最小元素的任何一个,如下所示:

        int restMinIndex = findMin(array, start+1); 
        E restMin = array[restMinIndex]; 
        if(restMin.compareTo(array[start])<0){ 
            return restMinIndex; 
        }else { 
            return start; 
        } 
    }

swap函数在给定的位置交换或交换数组中的两个元素。这个函数相当直接:

    public static <E> void swap(E[] array, int i, int j){ 
        if(i==j) 
            return; 
        E temp = array[i]; 
        array[i]=array[j]; 
        array[j] = temp; 
    }

在我们的仓库中,有了findMinswap函数,我们最终可以放下selectionSort算法。我们首先通过将起始位置零作为起始参数的值开始:

    public static <E extends Comparable<E>> void selectionSort(
    E[] array, int start){ 

首先,如果数组为空,则不需要排序:

        if(start>=array.length){ 
            return; 
        } 

现在,我们只需找到最小元素的索引,并将当前位置与最小位置的索引进行交换。这将把最小元素放到当前位置:

        int minElement = findMin(array, start); 
        swap(array,start, minElement); 

然后,我们递归地对数组的其余部分进行排序:

        selectionSort(array, start+1); 
    }

现在,我们可以编写一个包装函数来仅执行selectionSort,而无需传递起始索引:

    public static <E extends Comparable<E>> void selectionSort( 
    E[] array) { 
        selectionSort(array, 0); 
    }

我们可以通过创建一个任意数组并使用我们的算法对其进行排序来测试我们的代码:

        Integer[] array = new Integer[]{10, 5, 2, 3, 78, 53, 3}; 
        selectionSort(array); 
        System.out.println(Arrays.toString(array));

输出如下:

[2, 3, 3, 5, 10, 53, 78]

注意看所有元素是如何按升序重新排列的,这意味着数组已经排序。

注意

所示的选择排序形式在严格意义上不是功能性的,因为我们正在修改数组的元素。一个真正的数组排序将在每次修改时复制数组。然而,这非常昂贵。另一方面,像我们这样做,从相同问题的较小版本来考虑算法,确实使算法更容易理解。我试图找到一个恰到好处的点,在这里我有递归算法的简单性,但不需要不断创建数组的副本。

选择排序算法的复杂度

要计算选择排序算法的复杂度,首先我们必须计算findMinswap函数的复杂度。让我们从findMin函数开始。与任何递归函数一样,我们首先假设对于一个长度为n(在这种情况下,数组的有效长度,从起始位置开始)的数组,计算findMin函数需要我们T(n)时间。在递归调用自身时,它传递一个长度为n-1的有效数组。因此,我们得到以下方程:

T(n) = T(n-1) + A where A is a constants
=> T(n) – T(n-1) = A, so it is an arithmetic progression
=> T(n) = An + B where B is a constant
=> T(n) = θ(n)

现在,让我们继续到 swap 函数。它没有递归也没有循环,因此其复杂度是常数或 θ(1)

最后,我们准备计算函数 selectionSort 的复杂度。假设,对于一个数组的有效长度 n,所需的时间是 T(n)。它以有效长度 n-1 调用自身,还调用了 findMinswap 函数,分别对应 θ(n)θ(1)。因此,我们有以下:

T(n) = T(n-1) + θ(n) + θ(1)

注意,一些表示为 θ (n) 的表达式已经被写成 θ (n) 本身。它应该读作,“n 的某个函数,其渐近复杂度为 θ (n)。”结果,对于计算 T(n) 的计算复杂度,我们实际上不必知道实际的表达式,我们可以简单地用 CnD 分别代表 θ (n)θ (1) 的函数,其中 CD 是常数。因此,我们得到以下方程:

T(n) = T(n-1) + Cn + D
=> T(n) – T(n-1) = Cn + D

同样,T(n-1) - T(n-2) = C(n-1) + D 等等。如果我们堆叠这些方程,我们得到以下:

T(n) – T(n-1) = Cn + D
 T(n-1) – T(n-2) = C(n-1) + D
 T(n-2) – T(n-3) = C(n-2) + D
 T(n-3) – T(n-4) = C(n-3) + D
…
 T(1) – T(0) = C(1) + D

将两边相加,我们得到如下:

选择排序算法的复杂度

因此,选择排序的复杂度为 θ(n²),其中 n 是正在排序的数组的大小。现在,我们将看到下一个排序算法,即插入排序。

插入排序

在选择排序中,我们首先选择一个位置,然后找到应该坐在那里的元素。在插入排序中,我们做相反的操作;我们首先选择一个元素,然后将该元素插入到它应该坐的位置。因此,对于每个元素,我们首先找出它应该在哪里,然后将其插入到正确的位置。因此,我们首先看看如何将一个元素插入到已排序数组中。想法是,我们被给了一个已排序元素的数组,并且我们需要将另一个元素插入到正确的位置,以便结果数组仍然保持排序。我们将考虑一个更简单的问题。我们被给了一个除了最后一个元素之外已排序的数组。我们的任务是插入最后一个元素到正确的位置。实现这种插入的递归方式如下:

  • 如果要插入的元素大于已排序数组中的最后一个元素,它应该位于末尾,插入完成。

  • 否则,我们将最后一个元素与要插入的元素交换,并递归地将此元素插入到它前面的较小数组中。

我们使用以下函数来完成这个操作。该函数接受一个数组和表示最后一个位置的索引:

    public static <E extends Comparable<E>> void insertElementSorted( 
    E[] array, int valueIndex) { 

        if (valueIndex > 0 && array[valueIndex].compareTo(array[valueIndex - 1]) < 0) { 
            swap(array, valueIndex, valueIndex - 1); 
            insertElementSorted(array, valueIndex - 1); 
        } 

    }

如果最后一个位置或valueIndex0,则不需要做任何事情,因为元素已经在正确的位置,即0。在这种情况下,valueIndex左侧没有数组。如果不是,我们比较最后一个元素和前一个元素。由于假设左侧的数组已经排序,前一个元素是数组排序部分中的最大元素。如果最后一个元素甚至比这个元素大,则不需要做更多的事情。如果不是,我们将最后一个元素与前一个元素交换,并在元素数量少一个的数组上递归地运行插入操作。最后一个元素已经移动到前一个位置,它现在必须与前一个元素比较,依此类推。

在有排序数组可用插入函数的情况下,我们现在可以编写插入排序的算法。在插入排序的每一步中,我们考虑数组中的一个边界。边界左侧的所有内容都已经排序。我们当前步骤的工作是将边界索引处的元素插入到左侧已排序的数组中,我们使用insertElementSorted函数来实现这一点。我们使用以下简单的策略来实现这种排序。在任何步骤中,我们执行以下操作:

  • 我们首先对边界左侧进行排序,以便实现我们对它已排序的假设

  • 然后我们调用insertElementSorted函数将当前边界元素插入到已排序的数组中

当然,当boundary为零时,这意味着没有要排序的数组,我们只需返回:

    public static <E extends Comparable<E>> void insertionSort( 
    E[] array, int boundary) { 
        if(boundary==0){ 
            return; 
        } 
        insertionSort(array, boundary-1); 
        insertElementSorted(array, boundary); 
    }

插入排序的复杂度

为了计算插入排序的复杂度,我们首先必须计算insertElementSorted函数的复杂度。设有效长度(即从0boundary-1)的数组所需的时间为T(n)。从那里,我们递归地使用n-1调用它。所以,我们有以下:

T(n) = T(n-1) + C where C is a constant 
=> T(n) = θ(n)

现在假设排序n个元素的数组所需的时间是S(n)。除了基本情况外,它使用一个更少的参数调用自己,然后使用有效长度n-1的数组调用insertElementSorted函数。因此,我们有以下:

S(n) = S(n-1) + T(n) + D where D is a constant.

再次,当n很大时,T(n) = θ(n);因此,它可以近似为An,其中A是一个常数。所以,我们有以下:

S(n)  = S(n-1) + An + D
=> S(n) – S(n-1) = An + D,

由于这对于所有n都成立,所以我们有:

 S(n) – S(n-1) = An + D
 S(n-1) – S(n-2) = A(n-1) + D
 S(n-2) – S(n-3) = A(n-2) + D
…
 S(1) – S(0) = A + D

将两边相加,我们得到以下:

插入排序的复杂度

因此,插入排序与选择排序具有相同的渐进复杂度。

冒泡排序

另一个有趣的排序算法是冒泡排序。与之前的算法不同,这个算法在非常局部层面上工作。策略如下:

  • 扫描数组,寻找顺序错误的连续元素对。然后找到j,使得array[j+1] < array[j]

  • 每当找到这样的对时,交换它们,并继续搜索直到数组的末尾,然后再从开始处再次搜索。

  • 当扫描整个数组甚至找不到一对时停止。

实现这一点的代码如下:

    public static <E extends Comparable<E>> void bubbleSort( 
    E[] array) { 
        boolean sorted = false; 
        while (!sorted) { 
            sorted = true; 
            for (int i = 0; i < array.length - 1; i++) { 
                if (array[i].compareTo(array[i + 1]) > 0) { 
                    swap(array, i, i + 1); 
                    sorted = false; 
                } 
            } 
        } 
    }

标志sorted跟踪在扫描过程中是否找到了任何逆序对。while循环的每次迭代都是对整个数组的扫描,这个扫描是在for循环内部完成的。在for循环中,我们当然是在检查每一对元素,如果找到一个逆序对,我们就交换它们。当sortedtrue时,即当我们整个数组中没有找到任何逆序对时,我们停止。

为了证明这个算法确实可以排序数组,我们必须检查两件事:

  • 当没有逆序对时,数组是有序的。这证明了我们的停止条件。

    注意

    这当然是正确的,因为当没有逆序对时,对于所有j< array.length-1,我们有array[j+1]>=array[j]。这是数组按递增顺序排列的定义,即数组是有序的。

  • 无论输入如何,程序最终都会在有限步数后达到前面的条件。也就是说,我们需要程序在有限步数内完成。为了理解这一点,我们需要了解逆序的概念。我们将在下一节中探讨它们。

逆序

数组中的逆序是一对顺序错误的元素。这对元素在数组中可能相邻,也可能相隔很远。例如,考虑以下数组:

Integer[] array = new Integer[]{10, 5, 2, 3, 78, 53, 3};

这个数组有多少个逆序?让我们来数一数:

10>5, 10>2, 10>3, 10<78,  10<53, 10>3
            5>2,    5>3,     5<78,    5<53,   5>3
                  ,    2<3,     2<78,    2<53,   2<3
                             ,        3<78,    3<53,   3=3
                                               , 78>53,  78>3
                                                          53>3

在这个列表中,每个元素都被与它后面的元素比较。当存在一个大于号时,表示逆序,用粗体字符突出显示。数一数粗体字符,我们看到有 10 个逆序。

对于任何输入数组,都有一个逆序的数量。在一个有序数组中,逆序的数量将是零。现在,考虑当进行交换时逆序数量会发生什么。交换交换了一对连续元素,从而打破了一个逆序(交换仅在连续元素之间存在逆序时发生)。为了更清楚地看到这一点,考虑以下在jj+1索引之间进行交换的例子:

 …......., j, j+1, …....

让我们先考虑j次元素。假设它与数组左边的部分有x个逆序。由于这些元素在左边,所有这种类型的逆序都是与大于j次元素的元素进行的。当j次元素移动到(j+1)次位置时,它们仍然保持在左边,并且j次元素左边添加的唯一元素是它与之交换的元素。因此,除了由于(j+1)次元素之外,j次元素的逆序数量不会发生变化。同样的逻辑可以应用于它与数组右边部分的逆序,以及数组的两边对于(j+1)次元素。由于交换,j次和(j+1)次元素之间断开了一个逆序。因此,每个逆序减少一个逆序。这意味着冒泡排序中的交换次数将正好等于输入数组中的逆序数量,这是有限的。由于每次扫描数组都需要在上一次扫描中进行交换,所以总的扫描次数最多比交换次数多一次;这也是有限的。这确保了算法总是能够完成。

冒泡排序算法的复杂性

要理解冒泡排序的复杂性,我们必须计算步骤的数量。步骤的数量是否等于交换的数量?答案是,并不完全是。在进行渐进分析时,我们必须始终计算发生次数最多的步骤。在这种情况下,这个步骤就是比较。每次扫描数组时有多少次比较?当然是n-1。因此,现在复杂性的分析就简化为排序数组所需的扫描次数。

让我们看看第一次扫描后最大元素发生了什么变化。假设最大元素位于索引j。因此,它将与j+1位置的元素进行比较。为了简化,我们假设所有元素都是不同的。现在,由于它是最大元素,j+1位置的元素将小于它,因此它将被交换。现在最大元素位于j+1位置,并与j+2位置的元素进行比较,同样的事情发生了。它将继续进行,直到最大元素位于数组的末尾。如果元素不是唯一的,右端的最大元素也会发生相同的事情。在下一轮中,最大元素已经位于数组的末尾,我们将在数组中的某个位置遇到第二个最大值(或另一个最大元素)。现在,由于一个最大元素位于数组的末尾,我们可以将除了最后一个元素之外的其他数组部分视为独立。在这个数组中,当前的最大值是最大值,它将在当前扫描的末尾到达当前部分的末尾。

这表明在每个扫描结束时,至少有一个元素达到了正确的最终位置,而没有改变在扫描之前已经到达那里的元素的正确位置,这意味着在n次扫描结束时,所有元素都会处于正确的位置,数组将被排序。也就是说,在最坏的情况下,冒泡排序最多需要n次扫描。在每个这样的扫描中,都有O(n)个操作。因此,冒泡排序的最坏情况复杂度是O(n²)

这并不是分析的终点;我们仍然需要证明存在需要这么多步骤的情况,然后我们才能对最坏情况有一个 theta 界限。我们考虑所有元素都按相反顺序排序的情况,即它们是递减顺序且都是不同的。在这种情况下,每个元素都与所有其他元素都有一个逆序。这意味着n个元素中的每一个都与n-1个其他元素有一个逆序,即总共有n(n-1)个逆序。但由于每个逆序都会被计算两次,一次是从逆序中的每个元素那里,所以实际上只有n(n-1)/2。现在,注意在一个扫描中可以进行的最大交换次数是n-1,这会在每次比较都导致交换的情况下发生,因为每个扫描有n-1次比较。所以,我们需要至少(n(n-1)/2)/(n-1) = n/2次扫描来完成所有交换,每次交换需要n-1次比较。因此,复杂度至少是n(n-1)/2 = Ω(n²)。当然,由于最坏情况是定义上最复杂的情况,所以最坏情况的复杂度至少是这个程度。

因此,最坏情况既是O(n²)也是Ω(n²),也就是说它是θ(n²)

递归调用的问题

递归调用的问题在于它们代价高昂;方法调用在处理器上涉及相当大的开销。一般来说,如果你想将性能提升到极致,最好避免调用方法。此外,函数调用的深度有一个限制,超过这个限制程序就会崩溃。这是因为程序有一个栈来启用方法调用语义,实际上会将包含所有变量和当前指令位置的元素推入栈中。这个栈不会无限增长,而是固定大小;通常,它可以存储几千个值,这意味着如果你的方法调用深度超过这个值,它就会崩溃,程序会因错误而退出。这意味着我们的插入排序会在包含超过几千个条目的数组上崩溃。另一方面,通常在函数形式中解释算法更容易。为了在这两个方面之间取得平衡,我们需要能够将同一算法的递归和非递归版本相互转换。我们将从最简单形式逐步到更复杂形式进行这一转换。

尾递归函数

如果函数中所有对自身的递归调用都是最后一个操作,则称该递归函数为尾递归函数。我这样说是因为可能有多个调用,并且所有这些调用都必须是最后一个操作。这是怎么可能的呢?我的意思是,可以从函数内部代码的不同条件分支中进行不同的调用。然而,无论何时函数调用自身,都必须是该函数条件分支中的最后一个操作。例如,再次考虑我们的二分搜索算法:

   private static <E extends Comparable<E>, F extends E> int binarySearch( 
        E[] sortedValues, F valueToSearch, int start, int end) { 
        if(start>=end){ 
            return -1; 
        } 
        int midIndex = (end+start)/2; 
        int comparison = sortedValues[midIndex].compareTo(valueToSearch); 
        if(comparison==0){ 
            return midIndex; 
        }else if(comparison>0){ 
            return binarySearch(sortedValues, valueToSearch, start, midIndex); 
        }else{ 
            return binarySearch(sortedValues, valueToSearch, midIndex+1, end); 
        } 
    }

注意,函数在两个不同的条件分支中调用自身。然而,在每个分支中,递归调用是最后一个操作。在调用自身之后没有其他操作要做。这是一个尾递归函数。

尾递归函数可以绝对机械地转换成循环。实际上,所有函数式语言编译器在编译优化期间都会自动执行这个转换。然而,Java 编译器并不这样做,因为 Java 通常更倾向于在代码中使用循环而不是递归,至少直到最近。但我们可以自己进行这种转换。

理念是,由于递归调用之后没有更多的操作,程序不需要记住调用函数的变量值。因此,它们可以被被调用函数的相同变量的值简单地覆盖,我们只需要再次处理函数的代码。所以,以下是实现这一点的机械步骤:

将整个内容包裹在一个无限while循环中。

将所有递归调用替换为更新参数的值,这些值是在递归调用中传递的。

以下是在二分搜索算法中展示了这种更新:

    private static <E extends Comparable<E>, F extends E> int binarySearchNonRecursive( 
        E[] sortedValues, F valueToSearch, int start, int end) { 
        while(true) { 
            if (start >= end) { 
                return -1; 
            } 
            int midIndex = (end + start) / 2; 
            int comparison = sortedValues[midIndex]
                               .compareTo(valueToSearch); 
            if (comparison == 0) { 
                return midIndex; 
            } else if (comparison > 0) { 
                end = midIndex; 
            } else { 
                start = midIndex + 1; 
            } 
       } 
    }

注意,我们只更新了那些改变的参数,在这种情况下,每个分支只有一个更新。这将产生与之前函数完全相同的结果,但现在它不会导致栈溢出。尽管在二分搜索的情况下这种转换并不是必需的,因为你只需要lg n步就能搜索长度为n的数组。所以,如果你的调用深度允许为1000,那么你可以搜索最大大小为2¹⁰⁰⁰的数组。这个数字远远超过整个宇宙中原子总数的总和,因此我们永远无法存储如此巨大的数组。但这个例子展示了将尾递归转换为循环的原则。

另一个例子是insertElementSorted函数,它用于我们的插入排序算法:

          public static <E extends Comparable<E>> void insertElementSorted( 
            E[] array, int valueIndex) { 

              if (valueIndex > 0 && array[valueIndex].compareTo(array[valueIndex - 1]) < 0) { 
                swap(array, valueIndex, valueIndex - 1); 
                insertElementSorted(array, valueIndex - 1); 
        } 

    }

注意,在递归调用自身之后没有待执行的操作。但在这里我们需要更加小心。注意,调用只发生在代码分支内部。这里的 else 情况是隐式的,即else { return; }。我们首先需要在代码中将其明确化,如下所示:

     public static <E extends Comparable<E>> void insertElementSorted( 
     E[] array, int valueIndex) { 

        if (valueIndex > 0 && array[valueIndex].compareTo(array[valueIndex - 1]) < 0) { 
            swap(array, valueIndex, valueIndex - 1); 
            insertElementSorted(array, valueIndex - 1); 
        } else{
 return;
 } 
     }

现在我们可以使用我们旧的技巧来使其非递归,也就是说,将其包裹在一个无限循环中,并用参数更新来替换递归调用:

   public static <E extends Comparable<E>> void insertElementSortedNonRecursive( 
        E[] array, int valueIndex) { 
        while(true) { 
            if (valueIndex > 0 && array[valueIndex].compareTo(array[valueIndex - 1]) < 0) { 
                swap(array, valueIndex, valueIndex - 1); 
                valueIndex =  valueIndex – 1; 
            }else{ 
                return; 
            } 
        } 

    }

这给出了与之前递归版本的函数完全相同的结果。因此,更正后的步骤如下:

  1. 首先,将所有隐式分支和所有隐式返回都明确化。

  2. 将整个内容包裹在一个无限 while 循环中。

  3. 将所有递归调用替换为更新参数值为递归调用中传递的值。

非尾单递归函数

单递归的意思是函数在每个函数条件分支中最多调用自身一次。它们可能是尾递归的,但并不总是这样。考虑我们插入排序算法的递归示例:

   public static <E extends Comparable<E>> void insertionSort( 
        E[] array, int boundary) { 
        if(boundary==0){ 
            return; 
        } 
        insertionSort(array, boundary-1); 
        insertElementSorted(array, boundary); 
    }

注意函数只调用自身一次,因此它是单递归。但由于我们在递归调用之后有对insertElementSorted的调用,所以它不是一个尾递归函数,这意味着我们不能使用之前的方法。在这样做之前,让我们考虑一个更简单的例子。考虑阶乘函数:

    public static BigInteger factorialRecursive(int x){ 
        if(x==0){ 
            return BigInteger.ONE; 
        }else{ 
            return factorialRecursive(x-1).multiply(BigInteger.valueOf(x)); 
        } 
    }

首先,注意函数是单递归的,因为代码的每个分支最多只有一个递归调用。此外,注意它不是尾递归,因为递归调用之后你必须进行乘法操作。

要将其转换为循环,我们首先必须确定正在乘以的数字的实际顺序。函数调用自身直到遇到0,此时返回1。因此,乘法实际上是从1开始的,然后累积较高的值。

由于它在上升过程中累积值,我们需要一个累加器(即存储一个值的变量)来在循环版本中收集这个值。步骤如下:

  1. 首先,将所有隐式分支和所有隐式返回都明确化。

  2. 创建一个与函数返回类型相同的累加器。这是为了存储中间返回值。累加器的起始值是递归基本情况下返回的值。

  3. 找到递归变量的起始值,即每次递归调用中逐渐减小的那个值。起始值是导致下一次递归调用进入基本情况的值。

  4. 递归变量的退出值与最初传递给函数的值相同。

  5. 创建一个循环,并将递归变量作为循环变量。从起始值到之前计算出的结束值,以表示值从递归的较高深度到较低深度的变化。较高深度的值在较低深度值之前。

  6. 移除递归调用。

累加器prod的初始值是多少?它与递归退出分支中返回的值相同,即1。正在乘以的最高值是什么?它是x。因此,我们现在可以将其转换为以下循环:

   public static BigInteger factorialRecursiveNonRecursive(int x){ 
        BigInteger prod = BigInteger.ONE; 
        for(int i=1;i<=x;i++){ 
            prod = prod.multiply(BigInteger.valueOf(x)); 
        } 
        return prod; 
    }

现在,让我们考虑insertionSort算法。什么是累加器?它就是最终输出,即排序后的元素数组。起始值是什么?它与递归版本中退出分支返回的值相同。这是一个长度为零的数组。最终值是什么?提供排序长度的排序后的元素数组。再次,就像我们的递归版本一样,我们用边界值简单地表示这些部分数组。因此,代码如下:

    public static <E extends Comparable<E>> void insertionSortNonRecursive( 
        E[] array) { 
        for(int boundary = 0;boundary<array.length;boundary++) { 
            insertElementSortedNonRecursive(array, boundary); 
        } 
    } 

注意,在这种情况下,函数的return类型是void。但我们的真正返回的是排序后的数组;我们只是重新排序了同一个数组,以避免创建重复的数组。

最一般的情况是多重递归,即函数在函数的同一条件分支中多次调用自身。这种情况没有栈是无法完成的。在递归调用的情况下,我们使用方法调用栈。否则,我们甚至可以使用外部栈。由于我们本章没有这样的例子,我们将将其解释推迟到下一章,届时我们将有一个例子。

摘要

在本章中,我们学习了如何在有序数组中进行高效搜索。这种搜索被称为二分搜索。你还学习了从无序数组中获得有序数组的一些方法。这个过程被称为排序。我们看到了三种基本的排序算法。虽然它们并不特别高效,但它们的概念很容易理解。你还学习了如何将递归算法转换为使用循环的非递归算法。在下一章中,我们将看到高效的排序算法。

第六章. 高效排序 – quicksort 和 mergesort

在上一章中,我们探索了几种简单的排序算法。这些算法的问题在于它们不够高效。在本章中,我们将介绍两种高效的排序算法,我们还将了解它们的效率。

在本章中,你将学习以下主题:

  • quicksort

  • mergesort

  • 排序算法效率的最优性

quicksort

我们希望开发一个能够高效排序元素数组的算法。我们的策略将很简单;我们将以某种方式尝试将数组分成两半,使得对每个半部分的排序将完成排序。如果我们能实现这一点,我们可以以这种方式递归地调用排序算法。我们已经知道递归调用的层数将是 lg n 的数量级,其中 lg m 是以 2 为底的对数 m。因此,如果我们能以 n 的数量级切割数组,我们仍然会有 O(n lg n) 的复杂度。这比我们在上一章中看到的 O(n²) 要好得多。但是,我们如何以这种方式切割数组呢?让我们尝试以下方式切割以下数组:

10, 5, 2, 3, 78, 53, 3,1,1,24,1,35,35,2,67,4,33,30

如果我们简单地切割这个数组,每个部分将包含各种值。对这些单独的部分进行排序不会导致整个数组被排序。相反,我们必须以某种方式切割数组,使得左边的所有元素都小于右边的所有元素。如果我们能实现这一点,对部分的排序将实现整个数组的排序。但是,当然,我们需要进行一些交换,以便我们能够以这种方式切割数组。因此,我们使用以下技巧:

quicksort

这是 quicksort 中枢轴定位的一个示例。指向箭头表示比较,双向箭头表示交换。

我们首先选择最后一个元素,并将其称为 枢轴。我们的目标是使所有小于枢轴的元素都位于其左侧,而所有大于枢轴的元素都位于其右侧。请注意,这意味着枢轴已经位于数组的正确位置。前面的图示展示了如何通过一个示例来完成这个过程。在图中,我们的枢轴是 30。我们开始与第一个元素进行比较,并继续进行。当我们找到逆序时,我们将枢轴与该元素交换,并保持相反方向的比较。我们继续这样做,每次发现逆序时都进行交换,并每次反转比较的方向。当我们比较完所有元素时停止。请注意,在此过程之后,所有小于枢轴的元素都位于其左侧,而所有大于枢轴的元素都位于其右侧。这是为什么?让我们更仔细地看看。

假设我们从左边开始比较。第一次交换后,枢轴位于找到的比它大的元素的位子上。它左边的所有元素都已经与枢轴比较过,并且发现它们都比枢轴小。前一个图显示了已经比较过的部分。现在它从其较早的位置开始向相反方向比较。第二次交换后,它坐在与它交换过的元素的位子上。它右边的所有元素都已经与它比较过,并且发现它们都比它大。我们继续以同样的方式进行,始终确保已经比较过的部分遵循以下规则:枢轴左边的部分只包含小于或等于它的元素,枢轴右边的部分只包含大于或等于它的元素。因此,当我们完成比较后,我们仍然保持这个条件,从而实现结果。一旦我们知道枢轴左边的所有元素都小于或等于其右边的所有元素,我们就可以分别对这些部分进行排序,从而整个数组将被排序。当然,我们以相同的方式递归地对这些部分进行排序。

然而,在我们深入代码之前,我想介绍一个不同的比较接口。它被称为java.util.Comparator,允许我们在排序时指定任何比较逻辑,从而提供更大的灵活性。以下是它的样子:

@FunctionalInterface 
public interface Comparator<T> { 
     int compare(T o1, T o2); 
}

这当然是一个非常简化的实际接口版本,但它包含了我们所有关心的内容。正如你所见,这是一个函数式接口,因此可以使用 lambda 表达式实现。它应该返回与o1.compareTo(o2)返回的概念上相似但不同的值,但不同的排序可以使用不同的比较 lambda 表达式。比较方法必须遵循我们在上一章中研究的java.util.Comparable接口中compareTo方法相同的规则。

现在让我们进入快速排序的代码。我们知道,当要处理的数组为空时,我们不需要再进行排序,这将是我们的基本情况。否则,我们创建两个索引 ij,一个存储左侧部分的当前末尾,另一个存储在任意给定时间点已经与枢轴比较过的右侧部分的当前开始,同时枢轴被放置在其正确的位置。这两个索引变量存储的是下一个要比较的索引。在任意给定时间点,这些变量中的一个持有枢轴的位置,另一个存储正在与之比较的当前值。当前存储枢轴位置的变量通过布尔变量 movingI 标记。如果它是真的,这意味着我们目前正在移动 i,因此 j 指向枢轴。我们更新位置变量并持续在循环中比较,直到两个索引都指向枢轴,当比较表明存在逆序时,我们交换并反转移动方向。我们反转移动方向是因为枢轴已经移动到由相反变量索引的位置,由 movingI 的值改变标记。否则,我们只是持续更新适当的位置变量。

movingI 为假时,这意味着 i 存储枢轴的位置。最后,当枢轴处于正确的位置,并且其左侧的所有元素都小于或等于其右侧的所有元素时,我们递归地对每个部分调用 quicksort

public static <E> void quicksort(E[] array, int start, int end,
Comparator<E> comparator) {

    if (end - start <= 0) {
        return;
    }

    int i = start;
    int j = end - 1;
    boolean movingI = true;

    while (i < j) {

    if (comparator.compare(array[i], array[j]) > 0) {
        swap(array, i, j);
        movingI = !movingI;
    } else {
        if (movingI) {
            i++;
        } else {
            j--;
            }
        }
    }

    quicksort(array, start, i, comparator);
    quicksort(array, i + 1, end, comparator);
}

我们可以将此方法包装起来,以避免需要传递起始和结束参数:

public static <E> void quicksort(E[] array, Comparator<E> comparator){
    quicksort(array, 0, array.length, comparator);
}

我们可以使用此方法来排序一个数组。让我们看看如何对整数数组进行排序:

Integer[] array =
new Integer[]{10, 5, 2, 3, 78, 53, 3, 1, 1, 24, 1, 35,
35, 2, 67, 4, 33, 30};

quicksort(array, (a, b) -> a - b);
System.out.println(Arrays.toString(array));

以下将是输出:

[1, 1, 1, 2, 2, 3, 3, 4, 5, 10, 24, 30, 33, 35, 35, 53, 67, 78]

注意我们是如何使用 lambda 传递简单的比较器的。如果我们传递一个 lambda (a,b)->b-a,我们将得到一个反转的数组。实际上,这种灵活性让我们可以根据任何比较来对包含复杂对象的数组进行排序。例如,使用 lambda (p1, p2)->p1.getAge() - p2.getAge(),很容易根据年龄对 Person 对象的数组进行排序。

快速排序的复杂度

像往常一样,我们将尝试找出快速排序的最坏情况。首先,我们注意到枢轴被正确放置后,它不会位于数组的中间。实际上,它的最终位置取决于它与数组中其他元素相比的值。由于它总是按照其排名来定位,其排名决定了最终位置。我们还注意到,快速排序的最坏情况是枢轴根本不切割数组,也就是说,所有其他元素要么在它的左边,要么在它的右边。这将在枢轴是最大或最小元素时发生。这将在最高或最低元素位于数组末尾时发生。因此,例如,如果数组已经排序,则每个步骤中最大的元素都会位于数组的末尾,我们将选择这个元素作为枢轴。这给出了一个反直觉的结论,即已经排序的数组将是快速排序算法的最坏情况。按相反方向排序的数组也是最坏情况之一。

那么,如果发生最坏情况,其复杂度是多少呢?由于最坏情况是每一步都由两个递归调用组成,其中一个是对空数组的调用,因此需要常数时间来处理,另一个是少一个元素的数组。此外,在每一步中,枢轴都与每个其他元素进行比较,因此对于包含 n 个元素的步骤,所需时间与 (n-1) 成正比。因此,我们得到时间 T(n) 的递归方程如下:

T(n) = T(n-1) + a(n-1) + b where a and b are some constants.
=> T(n) – T(n-1) = a(n-1) + b

由于这对于所有 n 的值都成立,我们有:

T(n) – T(n-1) = a(n-1) + b
T(n-1) – T(n-2) = a(n-2) + b
T(n-2) – T(n-3) = a(n-3) + b
...
T(2) – T(1) = a(1) + b

将两边相加,我们得到以下结果:

T(n) – T(1) = a (1+2+3+...+(n-1)) + (n-1)b
=> T(n) – T(1) = an(n-1)/2 + (n-1)b
=> T(n) = an(n-1)/2 + (n-1)b + T(1)
=> T(n) = O(n2)

这并不好。它仍然是 O(n²)。这真的是一个高效的算法吗?好吧,为了回答这个问题,我们需要考虑平均情况。平均情况是所有可能输入的复杂性的概率加权平均值。这相当复杂。因此,我们将使用我们可以称之为典型情况的东西,这有点像是通常情况的复杂度。那么,在一个典型的随机未排序数组中会发生什么呢?也就是说,输入数组被相当随机地排列?枢轴的排名将等可能地是 1n 之间的任何值,其中 n 是数组的长度。因此,它通常会在中间附近分割数组。那么,如果我们成功将数组分成两半,其复杂度是多少呢?让我们来看看:

T(n) = 2T((n-1)/2) + a(n-1) + b

这一点有点难以解决,所以我们取 n/2 而不是 (n-1)/2,这只会增加复杂度的估计。因此,我们有以下结果:

T(n) = 2T(n/2) + a(n-1) + b

m = lg nS(m) = T(n),因此 n = 2m。所以我们有如下结果:

S(m) = 2S(m-1) + a 2m + (b-a)

由于这对于所有 m 都成立,我们可以将相同的公式应用于 S(m-1)。因此,我们有以下结果:

S(m) = 2(2S(m-2) + a 2m-1 + (b-a)) + a 2m + (b-a)
=> S(m) = 4 S(m-2) + a (2m + 2m) + (b-a)(2+1)

以类似的方式继续,我们得到如下结果:

S(m) = 8 S(m-3) + a (2m + 2m  + 2m) + (b-a)(4+2+1)
…
S(m) = 2m S(0) + a (2m+ 2m  + 2m+ 2m) + (b-a)(2m-1+ 2m-2+ … + 2+1)
=>S(m) = 2m S(0) + a m . 2m+ (b-a) (2m – 1)
=> T(n) = nT(1) + a . (lg n) . n + (b-a) (n-1)
=> T(n) =  θ(n lg n)

这相当不错。事实上,这比我们在上一章中看到的二次复杂度要好得多。实际上,n lg n增长得非常慢,以至于对于任何大于1an lg n = O(na)。这意味着函数n1.000000001增长速度比n lg n快。因此,我们找到了一个在大多数情况下表现相当好的算法。记住,快速排序的最坏情况仍然是O(n²)。我们将在下一小节中尝试解决这个问题。

快速排序中的随机枢轴选择

快速排序的问题在于,如果数组已经排序或反向排序,它的性能会非常糟糕。这是因为我们总是会选择数组的最大或最小元素作为枢轴。如果我们能避免这种情况,我们也可以避免最坏情况的时间复杂度。理想情况下,我们希望选择数组的所有元素的中位数作为枢轴,即在数组排序时的中间元素。但是,无法高效地计算中位数。一个技巧是在所有元素中随机选择一个元素并将其用作枢轴。因此,在每一步中,我们随机选择一个元素并将其与末尾元素交换。之后,我们可以像之前一样执行快速排序。因此,我们将快速排序方法更新如下:

public static <E> void quicksort(E[] array, int start, int end,
Comparator<E> comparator) {
    if (end - start <= 0) {
        return;
    }
 int pivotIndex = (int)((end-start)*Math.random()) + start;
 swap(array, pivotIndex, end-1);
    //let's find the pivot.
    int i = start;
    int j = end - 1;
    boolean movingI = true;
    while (i < j) {
        if (comparator.compare(array[i], array[j]) > 0) {
            swap(array, i, j);
            movingI = !movingI;
        } else {
            if (movingI) {
                i++;
            } else {
                j--;
            }
        }
    }
    quicksort(array, start, i, comparator);
    quicksort(array, i + 1, end, comparator);
}

即使现在我们每次都非常不幸地选择末尾元素,但这非常不可能发生。在这种情况下,我们几乎总是能得到期望的n lg n复杂度。

归并排序

在上一节中,我们试图以这种方式分割数组,即当我们对每个部分进行排序时,整个数组也会被排序。我们遇到了一个问题,当我们尝试这样做时,两个部分的大小并不相等,导致算法有时需要二次时间复杂度。如果我们不尝试以排序部分来排序整个数组的方式分割数组,而是将数组分成两个大小相等的部分呢?当然,然后,排序部分不会排序整个数组。然而,如果我们有两个已各自排序的数组部分,我们能否将它们合并在一起以产生一个整体排序的数组?如果我们能足够高效地做到这一点,我们就会有一个保证高效的算法。实际上,这是可能的。但是,我们需要考虑合并数组将存储在哪里。由于值是从源数组复制的,结果需要存储在另一个地方。因此,我们需要为归并排序提供另一个大小相等的存储空间。

.

归并排序

排序数组的合并

前面的图显示了合并操作的一部分。我们保持每个数组的当前位置。在每一步中,我们比较两个输入排序数组中当前位置的值。

我们将较小的那个复制到目标位置,并递增相应的当前位置。我们一直这样做,直到完成一个数组,之后另一个数组的元素可以直接复制过来。以下显示了归并操作的代码。需要注意的是,由于归并将用于归并排序,它假定输入数组是同一个数组,具有不同的索引,目标数组具有相同的大小。源数组有三个索引:startmidend。假设源部分在源数组中相邻。变量start指向第一部分的开始。整数mid存储第二部分开始的索引,也作为第一部分的结束,因为部分是连续的。最后,end变量存储第二数组的结束:

private static <E> void merge(E[] array, int start, int mid, int end, E[] targetArray, Comparator<E> comparator) {
    int i = start;
    int j = mid;
    int k = start;
    while (k < end) {

前两个情况是当其中一个源数组耗尽时的情况:

        if (i == mid) {
            targetArray[k] = array[j];
            j++;
        } else if (j == end) {
            targetArray[k] = array[i];
            i++;
        } 

如果没有任何数组耗尽,从正确的数组复制:

        else if (comparator.compare(array[i], array[j]) > 0) {
            targetArray[k] = array[j];
            j++;
        } else {
            targetArray[k] = array[i];
            i++;
        }

最后,目标位置也必须递增:

        k++;
    }
}

现在我们有了这个merge函数,我们可以继续进行归并排序。它包括以下步骤:

  1. 将数组分成两等份。

  2. 对部分进行归并排序。

  3. 将排序好的部分合并成一个完整的排序数组。

当然,对于零个或一个元素的数组,我们不需要做任何事情。所以,那将是我们的退出情况:

public static <E> void mergeSort(E[] sourceArray, int start,
int end, E[] tempArray, Comparator<E> comparator) {

对于零个或一个元素的数组,直接返回调用函数。这是我们的基本情况:

    if (start >= end - 1) {
        return;
    }

对于任何大于1的数组,将其分成两半——从开始到中间和从中间到结束。然后分别对它们进行归并排序,然后将两个排序好的子数组合并到tempArray中,这是一个辅助空间,我们正在使用:

    int mid = (start + end) / 2;
    mergeSort(sourceArray, start, mid, tempArray, comparator);
    mergeSort(sourceArray, mid, end, tempArray, comparator);
    merge(sourceArray, start, mid, end, tempArray, comparator);

最后,将tempArray的内容复制到sourceArray中,这样源数组现在就是排序好的:

    System.arraycopy(tempArray, start, sourceArray, start,
        end - start);
}

归并排序的复杂度

让我们从归并操作的复杂度开始。归并操作不是递归的。在每一步中,它要么增加i,要么增加j。当这两个变量都达到各自数组的末尾时,归并结束。比较最多在每个增量发生一次。这意味着比较的次数最多与两个子数组中元素的总数相同。当然,将tempArray的内容复制到sourceArray的操作也与tempArray中的元素数量成比例,这与sourceArray中的元素数量相同。因此,每一步的操作数量与n成比例,除了递归调用。递归调用作用于数组的两部分,这两部分本身是整个数组大小的一半。因此,如果T(n)是所需时间,我们有以下结果:

T(n) = 2T(n/2) + an + b

在这里,ab是常数。

这与快速排序算法典型情况得到的方程式相同,我们知道这个解给出了T(n) = θ(n lg n)。这是对平均情况和最坏情况的估计,因为在两种情况下,数组都将始终被分成两个大小相等的部分,无论数组的内容如何。实际上,最坏的情况是所有的复制也需要比较,这正是我们考虑的情况。

在最佳情况下,其中一个数组在复制第二个数组的第一个元素之前就已经复制了所有元素,因此只需要一半的比较次数。但这种情况给出了相同的复杂度T(n) = θ(n lg n)。所以,无论我们开始时的数组实际内容如何,归并排序都将具有相同的渐进复杂度。

避免复制tempArray

在我们相当简单的例子中,我们首先将子数组合并到tempArray中,然后将其复制回sourceArray。能否避免复制?我们能否将tempArray本身用作合并的结果?结果是我们可以这样做。在这种情况下,sourceArraytempArray将被相当对称地使用,唯一的区别是sourceArray持有原始输入数组。否则,它们是大小相同的预分配数组。然而,代码将变得稍微复杂一些。

让我们先考虑如果我们没有将tempArray的内容复制到sourceArray,而是试图直接使用tempArray作为排序数组的内容会发生什么。然后,在每一步中,sourceArraytempArray都需要进行交换,也就是说tempArray会变成sourceArray,反之亦然。由于在每一步中,tempArraysourceArray都在进行交换,所以实际持有结果的数组取决于排序数组所需的步数是奇数还是偶数。

现在,如果开始时的数组元素数量等于 2 的精确整数次幂,源数组可以始终被分成两个大小完全相同的子数组。这意味着,排序每个子数组所需的步数将完全相同。这意味着在排序任一子数组后,实际持有排序结果的数组将是相同的。然而,在现实中,数组中的元素数量大多数情况下不是 2 的精确整数次幂,因此,一个子数组会比另一个稍微大一点。这导致排序任一子数组所需的步数不同,它们可能会将结果排序数组存储在不同的数组中。我们必须考虑这些情况。所以,当排序任一子数组的结果存储在同一个数组中时,我们将合并的输出存储在另一个数组中。如果不是,我们总是将合并的输出存储在持有数组第二部分排序结果的那个数组中。

首先,我们将merge函数修改为处理包含两个不同输入内容的两个不同数组:

    private static <E> void merge(E[] arrayL, E[] arrayR, 
    int start, int mid, int end, E[] targetArray, 
    Comparator<E> comparator) { 
        int i = start; 
        int j = mid; 
        int k = start; 
        while (k < end) { 
            if (i == mid) { 
                targetArray[k] = arrayR[j]; 
                j++; 
            } else if (j == end) { 
                targetArray[k] = arrayL[i]; 
                i++; 
            } else if (comparator.compare(arrayL[i], arrayR[j]) > 0) { 
                targetArray[k] = arrayR[j]; 
                j++; 
            } else { 
                targetArray[k] = arrayL[i]; 
                i++; 
            } 
            k++; 
        } 
}

有了这个merge函数可用后,我们以以下方式编写我们的高效归并排序。注意,我们需要某种方式来通知调用函数哪个预分配的数组包含结果,因此我们返回那个数组:

public static <E> E[] mergeSortNoCopy(E[] sourceArray, int start,
int end, E[] tempArray, Comparator<E> comparator) {
    if (start >= end - 1) {
        return sourceArray;
    }

首先,像往常一样分割并归并排序子数组:

    int mid = (start + end) / 2;
    E[] sortedPart1 =
    mergeSortNoCopy(sourceArray, start, mid, tempArray,
                    comparator);
    E[] sortedPart2 =
    mergeSortNoCopy(sourceArray, mid, end, tempArray,
                    comparator);

如果两个排序后的子数组都存储在同一个预分配的数组中,则使用另一个预分配的数组来存储归并的结果:

    if (sortedPart2 == sortedPart1) {
        if (sortedPart1 == sourceArray) {
            merge(sortedPart1, sortedPart2, start, mid, end,
                  tempArray, comparator);
            return tempArray;
        } else {
            merge(sortedPart1, sortedPart2, start, mid, end,
            sourceArray, comparator);
            return sourceArray;
        }
    } else {

在这种情况下,我们将结果存储在sortedPart2中,因为它有第一部分是空的:

        merge(sortedPart1, sortedPart2, start, mid, end,
              sortedPart2, comparator);
        return sortedPart2;
    }
}

现在我们可以这样使用这个归并排序:

Integer[] anotherArray = new Integer[array.length];
array = mergeSortNoCopy(array, 0, array.length, anotherArray,
(a, b)->a-b);
System.out.println(Arrays.toString(array));

这里是输出:

[1, 1, 1, 2, 2, 3, 3, 4, 5, 10, 24, 30, 33, 35, 35, 53, 67, 78]

注意这次,我们必须确保我们使用方法返回的输出,因为在某些情况下,anotherArray可能包含最终的排序值。归并排序的高效无复制版本并没有任何渐近性能提升,但它通过常数提高了时间。这是值得做的事情。

任何基于比较的排序的复杂度

现在我们已经看到了两种比上一章描述的算法更高效的排序算法,我们如何知道它们是排序可能达到的最高效率呢?我们能否创造出更快算法?在本节中,我们将看到我们已经达到了效率的渐近极限,也就是说,基于比较的排序将具有最小的时间复杂度为θ(m lg m),其中m是元素的数量。

假设我们从一个包含 m 个元素的数组开始。暂时,让我们假设它们都是不同的。毕竟,如果这样的数组是可能的输入,我们需要考虑这种情况。这些元素可能的不同排列数量是 m!。其中一种排列是正确的排序。任何使用比较来排序这个数组的算法都必须能够仅通过元素对之间的比较来区分这个特定的排列和其他所有排列。任何比较都将排列分为两组——一组在比较这两个确切值时会导致反转,另一组则不会。这意味着,给定数组中的任何两个值 ab,返回 a<b 的比较将排列集分为两个部分;第一组将包含所有 ba 之前的所有排列,而第二组将包含所有 ab 之前的所有排列。排序排列当然是第二组的一个成员。任何基于比较的排序算法都必须进行足够的比较,以确定单个正确的排列,即排序排列。基本上,它将首先执行一个比较,选择正确的子集,然后执行另一个比较并选择子集的正确子集,依此类推,直到它达到只包含一个排列的排列集。这个特定的排列是数组的排序版本。找到 m! 个排列中的特定排列所需的最小比较次数是多少?这等同于询问你需要将 m! 个元素的集合减半多少次才能达到只包含一个元素的集合。当然,是 lg (m!)。这是一个粗略估计;实际上,所需的比较次数会略多于这个数字,因为每个比较创建的两个子集可能大小不等。但我们知道,所需的比较次数至少是 lg (m!)

现在,lg m! 是多少呢?嗯,它是 (ln (m!)) (lg e),其中 ln (x)x 的自然对数。我们将找到函数 ln(m!) 的一个更简单的渐近复杂度。这需要一点微积分知识。

让我们看看以下图:

任何基于比较的排序的复杂度

曲线 y = ln x 下的面积。

该图显示了某些图表。我们知道积分衡量的是函数曲线下的面积。现在,曲线 y=ln bab 之间的面积是 (b-a)ln b,而曲线 y=ln a 下的面积是 (b-a) ln a。在相同区间内,曲线 y=lg x 下的面积如下:

任何基于比较的排序的复杂度

从前一个图中的图形,以下内容是清晰的:

任何基于比较的排序的复杂度

特别是,当 b=a+1 时,我们得到以下结果:

任何基于比较的排序的复杂度

因此,我们设 a = 1 并逐步增加到 a = m-1,得到以下一系列不等式:

任何比较排序的复杂度

将相应的两边相加,我们得到以下结果:

任何比较排序的复杂度

现在,当然我们知道以下内容:

任何比较排序的复杂度

因此,我们得到以下不等式:

任何比较排序的复杂度

在左边的不等式中,如果我们用 m 代替 m-1,我们将得到以下结果:

任何比较排序的复杂度

这与右边的不等式相结合,给出了以下关系:

任何比较排序的复杂度

这给出了对 ln(m!) 值的一个很好的上界和下界。上界和下界都是 θ(m ln m)。因此,我们可以得出结论,ln(m!) = θ(m ln m)。这意味着 lg(m!) = (ln (m!))(lg e) 也是 θ(m ln m) = θ(m lg m),因为 lg(m) = (ln m )(lg e)

因此,基于比较的排序算法的最小时间复杂度至少必须是 θ(m lg m),仅仅是因为完成这个任务所需的比较的最小数量。因此,归并排序和快速排序的典型情况在渐近上是最佳的。

排序算法的稳定性

排序算法的稳定性是指比较相等的元素在排序后保持其原始顺序的性质。例如,如果我们有一个包含某些人的 ID 号和年龄的对象数组,并且我们想要按年龄递增的顺序对它们进行排序,一个稳定的排序算法将保持具有相同年龄的人的原始顺序。如果我们尝试多次排序,这可能会很有帮助。例如,如果我们想将具有相同年龄的人的 ID 按递增顺序排列,我们首先可以按 ID 对数组进行排序,然后再次按年龄排序。如果排序算法是稳定的,它将确保最终排序数组按年龄递增,对于相同年龄,则是按 ID 递增。当然,这种效果也可以通过在单个排序操作中进行更复杂的比较来实现。快速排序不是稳定的,但归并排序是。很容易看出为什么归并排序是稳定的。在合并过程中,我们保持顺序,即当它们比较相等时,左半部分的值先于右半部分的值。

摘要

在本章中,我们探讨了两种高效的排序算法。在两种情况下,基本原理都是将数组分割,并分别对部分进行排序。如果我们确保对部分进行排序会导致通过重新调整元素使整个数组排序,那么这就是快速排序。如果我们首先将数组分成两个相等的部分,然后对每个部分进行排序,最后合并结果以使整个数组排序,那么这就是归并排序。这种将输入分割成更小部分,对较小部分解决问题,然后合并结果以找到整个问题的解决方案的方法是解决计算问题中的一种常见模式,称为分而治之模式。

我们也看到了任何使用比较进行排序的排序算法的渐近下界。快速排序和归并排序都达到了这个下界,因此它们在渐近意义上是最佳的。在下一章中,我们将转向另一种称为树的数据结构。

第七章:树的概念

我们已经看到了像链表和数组这样的数据结构。它们以线性方式表示存储的数据。在本章中,我们将讨论一种新的数据结构,称为树。树是链表的泛化。虽然链表节点有一个指向下一个节点的引用,但树节点有指向可能超过一个下一个节点的引用。这些下一个节点被称为节点的子节点,持有子节点引用的节点称为父节点。在本章中,我们将探讨以下主题:

  • 树作为数据结构的概念

  • 树作为 ADT 的概念

  • 二叉树

  • 不同的树遍历方式

  • 树搜索算法

所以,让我们立即开始吧。

树数据结构

树数据结构看起来非常像一棵真正的树,就像你在花园或路边看到的那种。如果我们观察一棵树,我们会看到它有一个根,使茎从地面外露出来。茎分成树枝,在树枝的末端,我们找到叶子。在我们的树数据结构中,我们从根开始。根是没有父节点的节点。子节点可以想象成通过线连接到茎上,就像真实树上的树枝一样。最后,我们找到一些没有子节点的节点,因此被称为叶子。以下图显示了树的示例:

树数据结构

一个示例树

注意,树是倒置的。根在顶部,叶子在下面。这只是大多数人喜欢的一种惯例。把这想象成树在水中的倒影。

树可以用多种方式表示,但我们将从链表的泛化概念开始。在链表的情况下,一个节点存储一个指向下一个节点的单一引用。在树中,一个节点需要存储所有子节点的引用。多个子节点可以存储在数组中,但由于我们有对LinkedList类的访问,我们将使用它。我们将使用我们非功能的链表版本,因为我们的第一个树将是非功能的,并允许修改。

我们从以下类开始:

public class Tree<E> {
    public static class Node<E>{
        private E value;
        private LinkedList<Node<E>> children;
        private Tree<E> hostTree;
        private Node<E> parent;

        public LinkedList<Node<E>> getChildren() {
            return children;
        }

        public E getValue() {
            return value;
        }

        private Node(LinkedList<Node<E>> children, Tree<E> hostTree, 
        E value, Node<E> parent) {
            this.children = children;
            this.hostTree = hostTree;
            this.value = value;
            this.parent = parent;
        }
    }

...
}

我们将我们的Node类定义为内部类。除了记住它内部存储的值和子节点列表外,它还存储其父节点以及它是其成员的树。一旦我们创建了一个树的实例,我们必须能够将一个节点存储在其中。没有父节点的节点被称为树的根。因此,我们添加了一个addRoot方法来向树中添加一个根。树本身只需要存储根节点的引用,因为所有其他节点都可以通过遍历引用从这个节点访问:

    private Node<E> root;

    public void addRoot(E value){
        if(root == null){
            root = new Node<>(new LinkedList<>(), this, value, null );
        }else{
            throw new IllegalStateException(
                "Trying to add new node to a non empty tree");
        }
    }

注意,我们测试树是否已经有一个根节点,如果有,则抛出异常。

好的,现在我们已经有了添加根节点的方法,我们需要有一个方法来添加我们想要的节点。这个方法接受一个父节点和一个值以添加一个新节点。这个方法将返回新添加的节点,这样我们就可以继续添加更多节点作为其子节点:

    public Node<E> addNode(Node<E> parent, E value){
        if(parent==null){
            throw new NullPointerException("Cannot add child to null parent");
        }else if(parent.hostTree != this){
            throw new IllegalArgumentException(
                "Parent node not a part of this tree");
        }else{
            Node<E> newNode = new Node<>(new LinkedList<>(), this, value, parent);
            parent.getChildren().appendLast(newNode);
            return newNode;
        }
    }

在前面的代码中,我们首先检查父节点是否为 null,或者父节点是否是不同树实例的节点。在任何情况下,都必须抛出异常。否则,我们只需将新节点添加为作为参数传递的父节点的子节点。

但等等!如果我们没有在调用代码中获取根节点的引用,我们如何传递父节点呢?因此,我们添加了一个名为getRoot的方法来访问树的根节点:

    public Node<E> getRoot() {
        return root;
    }

好的,现在让我们创建一个Tree实例:

    public static void main(String [] args){
        Tree<Integer> tree = new Tree<>();
        tree.addRoot(1);
        Node<Integer> node1 = tree.getRoot();
        Node<Integer> node2 = tree.addNode(node1, 5);
        Node<Integer> node3 = tree.addNode(node1, 1);
        Node<Integer> node4 = tree.addNode(node2, 2);
        Node<Integer> node5 = tree.addNode(node2, 5);
        Node<Integer> node6 = tree.addNode(node2, 9);
        Node<Integer> node7 = tree.addNode(node3, 6);
        Node<Integer> node8 = tree.addNode(node3, 2);
        Node<Integer> node9 = tree.addNode(node5, 5);
        Node<Integer> node10 = tree.addNode(node6, 9);
        Node<Integer> node11 = tree.addNode(node6, 6);
    }

代码是自我解释的。我们只是通过逐个添加节点来创建树。但我们如何看到树的样子呢?为此,我们将学习树的遍历。前面的代码将创建以下图中所示的树:

树数据结构

示例树

树的遍历

树遍历是一种算法,用于恰好访问或处理树的所有节点一次。这显然涉及到递归地查看节点的子节点。子节点处理的顺序取决于我们使用的特定算法。遍历树的简单算法是深度优先遍历。

深度优先遍历

在深度优先遍历中,我们递归地处理一个节点的每个子节点,并在继续处理下一个子节点之前等待它及其所有后代的完成。为了理解深度优先搜索,我们必须理解什么是子树。子树是一个节点及其所有后代的集合,直到叶子节点。以下图显示了子树的一些示例。

深度优先遍历

子树示例

现在,如果你仔细想想,每个节点不仅存储了对子节点的引用,而且似乎还持有对以子节点为根的整个子树的引用。因此,深度优先遍历算法实际上就是以下步骤:

  1. 处理当前节点中的值。

  2. 对于当前节点的每个子节点,递归遍历以子节点为根的整个子树。

以下方法正是这样做的:

protected void traverseDepthFirst(OneArgumentStatement<E> processor, Node<E> current){
    processor.doSomething(current.value);
    current.children.forEach((n)-> traverseDepthFirst(processor, n));
}

这个方法接受一个 lambda 表达式和一个节点以进行遍历。这个方法所做的只是首先在当前值上运行 lambda 表达式,然后递归地对其每个子树调用自身。现在,我们可以编写一个不带父节点参数的包装方法:

public void traverseDepthFirst(OneArgumentStatement<E> processor){
    traverseDepthFirst(processor, getRoot());
}

尽管如此,这种遍历方式为什么被称为深度优先遍历仍然不清楚。如果你考虑节点处理的顺序,你可以看到,由于任何子节点的完整子树必须在处理下一个子节点之前完全处理,因此树的深度将在宽度之前被覆盖。

我们已经使用递归函数来进行深度优先搜索。或者,我们也可以使用栈来完成这个任务:

public void traverseDepthFirstUsingStack(
    OneArgumentStatement<E> processor){

    Stack<Node<E>> stack = new StackImplLinkedList<>();
    stack.push(getRoot());
    while(stack.peek()!=null){
        Node<E> current = stack.pop();
        processor.doSomething(current.value);
        current.children.forEach((n)->stack.push(n));
    }
}

让我们看看前面那段代码中发生了什么。我们首先将根节点推入栈,然后进入一个循环,直到栈中的所有元素都被清除。每次弹出节点时,我们处理它并将所有子节点推入栈。现在,由于栈是后进先出(LIFO),所有这些子节点都会被弹出并处理,在处理任何其他节点之前。然而,当第一个子节点被弹出时,其子节点将被推入栈,并且会在处理其他任何内容之前被处理。这将一直持续到我们到达叶节点,这些叶节点将没有更多的子节点。实际上,这几乎与递归版本相同。

此代码的输出与递归版本之间略有不同,尽管两者实际上都是深度优先。然而,请注意,在递归版本的情况下,靠近链表头部的子节点首先被处理。在栈版本的情况下,我们以相同的顺序压入子节点,但由于栈是后进先出(LIFO),我们以相反的顺序弹出子节点。为了反转这个顺序,我们可以在将子节点推入栈之前,将子节点的列表以相反的顺序存储在一个临时列表中,如下面的代码所示:

public void traverseDepthFirstUsingStack(
    OneArgumentStatement<E> processor){

    Stack<Node<E>> stack = new StackImplLinkedList<>();
    stack.push(getRoot());
    while(stack.peek()!=null){
        Node<E> current = stack.pop();
        processor.doSomething(current.value);
 LinkedList<Node<E>> reverseList = new LinkedList<>();
 current.children.forEach((n)->reverseList.appendFirst(n));
 reverseList.forEach((n)->stack.push(n));
    }
}

通过将元素追加到其开头,我们通过一个名为reverseList的临时列表来反转列表。然后,元素从reverseList中推入栈。

广度优先遍历

广度优先遍历与深度优先遍历相反,其含义是深度优先遍历先处理子节点再处理兄弟节点,而广度优先遍历则先处理同一层的节点再处理下一层的节点。换句话说,在广度优先遍历中,节点是按层处理的。这可以通过将深度优先遍历的栈版本中的栈替换为队列来实现。这就是它所需要的全部:

public void traverseBreadthFirst(OneArgumentStatement<E> processor){
    Queue<Node<E>> queue = new QueueImplLinkedList<>();
    queue.enqueue(getRoot());
    while(queue.peek()!=null){
        Node<E> current = queue.dequeue();
        processor.doSomething(current.value);
        current.children.forEach((n)->queue.enqueue(n));
    }
}

注意,其他所有内容都与深度优先遍历完全相同。我们仍然从队列中取出一个元素,处理其值,然后将其子节点入队。

要理解为什么使用队列可以让我们按层处理节点,我们需要以下分析:

  • 根节点最初被压入,因此根节点首先出队并处理。

  • 当根节点被处理时,根节点的子节点,即第 1 层的节点,被入队。这意味着第 1 层的节点会在任何其他层的节点之前出队。

  • 当从第 1 级中下一个节点出队时,其子节点,即第 2 级的节点,都将被入队。然而,由于第 1 级中的所有节点在之前步骤中都已入队,因此第 2 级的节点在未从第 1 级节点出队之前不会出队。当第 1 级的所有节点都出队并处理完毕后,所有第 2 级节点都会被入队,因为它们都是第 1 级节点的子节点。

  • 这意味着所有第 2 级节点都会在处理任何更高级别的节点之前出队并处理。当所有第 2 级节点都已处理完毕后,所有第 3 级节点都会被入队。

  • 以类似的方式,在所有后续级别中,特定级别的所有节点都会在处理下一级别的所有节点之前被处理。换句话说,节点将按级别处理。

树的抽象数据类型

现在我们对树有了些了解,我们可以定义树的 ADT。树的 ADT 可以以多种方式定义。我们将检查两种。在命令式设置中,即当树是可变的时,我们可以将树 ADT 定义为具有以下操作:

  • 获取根节点

  • 给定一个节点,获取其子节点

这就是创建树模型所需的所有内容。我们还可以包括一些适当的变异方法。

树的 ADT 的递归定义可以如下所示:

  • 树是一个包含以下内容的有序对:

    • 一个值

    • 其他树的一个列表,这些树是它的子树

我们可以以与在函数式树 ADT 中定义的完全相同的方式开发树实现:

public class FunctionalTree<E> {
    private E value;
    private LinkedList<FunctionalTree<E>> children;

如 ADT 定义,树是一个值和另一个树列表的有序对,如下所示:

    public FunctionalTree(E value, LinkedList<FunctionalTree<E>> children) {
        this.children = children;
        this.value = value;
    }

    public  LinkedList<FunctionalTree<E>> getChildren() {
        return children;
    }

    public E getValue() {
        return value;
    }

    public void traverseDepthFirst(OneArgumentStatement<E> processor){
        processor.doSomething(value);
        children.forEach((n)-> n.traverseDepthFirst(processor));
    }

}

实现相当简单。可以通过对子节点的递归调用来实现深度优先遍历,这些子节点实际上是子树。没有子节点的树需要有一个空的子节点列表。有了这个,我们可以创建与命令式版本相同的相同树的函数式版本:

public static void main(String [] args){
    LinkedList<FunctionalTree<Integer>> emptyList = LinkedList.emptyList();

    FunctionalTree<Integer> t1 = new FunctionalTree<>(5, emptyList);
    FunctionalTree<Integer> t2 = new FunctionalTree<>(9, emptyList);
    FunctionalTree<Integer> t3 = new FunctionalTree<>(6, emptyList);

    FunctionalTree<Integer> t4 = new FunctionalTree<>(2, emptyList);
    FunctionalTree<Integer> t5 = new FunctionalTree<>(5, emptyList.add(t1));
    FunctionalTree<Integer> t6 = new FunctionalTree<>(9, 
         emptyList.add(t3).add(t2));
    FunctionalTree<Integer> t7 = new FunctionalTree<>(6, emptyList);
    FunctionalTree<Integer> t8 = new FunctionalTree<>(2, emptyList);

    FunctionalTree<Integer> t9 = new FunctionalTree<>(5,
         emptyList.add(t6).add(t5).add(t4));
    FunctionalTree<Integer> t10 = new FunctionalTree<>(1,
         emptyList.add(t8).add(t7));

    FunctionalTree<Integer> tree = new FunctionalTree<>(1,
         emptyList.add(t10).add(t9));

最后,我们可以进行深度优先遍历来查看它是否输出与之前相同的树:

    tree.traverseDepthFirst(System.out::print);
}

二叉树

二叉树是一种每个节点最多有两个子节点的树。这两个子节点可以称为节点的左子节点和右子节点。以下图显示了二叉树的一个示例:

二叉树

示例二叉树

这个特定的树之所以重要,主要是因为它的简单性。我们可以通过继承通用树类来创建一个BinaryTree类。然而,阻止某人添加超过两个节点将非常困难,并且仅为了执行检查就需要大量的代码。因此,我们将从头开始创建一个BinaryTree类:

public class BinaryTree<E>  {

Node的实现与通用树非常明显:

    public static class Node<E>{
        private E value;
        private Node<E> left;
        private Node<E> right;
        private Node<E> parent;
        private BinaryTree<E> containerTree;

        protected Node(Node<E> parent,
        BinaryTree<E> containerTree, E value) {
            this.value = value;
            this.parent = parent;
            this.containerTree = containerTree;
        }

        public E getValue(){
            return value;
        }
    }

添加根节点与通用树相同,只是我们不检查根的存在。这只是为了节省空间;您可以按需实现:

    private Node<E> root;

    public void addRoot(E value){
        root = new Node<>(null, this,  value);
    }

    public Node<E> getRoot(){
        return root;
    }

以下方法让我们添加一个子节点。它接受一个布尔参数,当要添加的子节点是左子节点时为true,否则为false

    public Node<E> addChild(Node<E> parent, E value, boolean left){
        if(parent == null){
            throw new NullPointerException("Cannot add node to null parent");
        }else if(parent.containerTree != this){
            throw new IllegalArgumentException
                   ("Parent does not belong to this tree");
        }else {
            Node<E> child = new Node<E>(parent, this, value);
            if(left){
                parent.left = child;
            }else{
                parent.right = child;
            }
            return child;
        }
    }

我们现在创建了两个包装方法,专门用于添加左子节点或右子节点:

    public Node<E> addChildLeft(Node<E> parent, E value){
        return addChild(parent, value, true);
    }

    public Node<E> addChildRight(Node<E> parent, E value){
        return addChild(parent, value, false);
    }

}

当然,通用树的遍历算法也适用于这个特殊情况。然而,对于二叉树,深度优先遍历可以是三种不同类型之一。

深度优先遍历的类型

根据父节点相对于子树处理的时间,二叉树的深度优先遍历可以分为三种类型。顺序可以总结如下:

  • 先序遍历:

    1. 处理父节点。

    2. 处理左子树。

    3. 处理右子树。

  • 中序遍历:

    1. 处理左子树。

    2. 处理父节点。

    3. 处理右子树。

  • 后序遍历:

    1. 处理左子树。

    2. 处理右子树。

    3. 处理父节点。

这些不同的遍历类型在遍历时会产生略微不同的顺序:

public static enum DepthFirstTraversalType{
    PREORDER, INORDER, POSTORDER
}

public void traverseDepthFirst(OneArgumentStatement<E> processor,
                      Node<E> current, DepthFirstTraversalType tOrder){
    if(current==null){
        return;
    }
    if(tOrder == DepthFirstTraversalType.PREORDER){
        processor.doSomething(current.value);
    }
    traverseDepthFirst(processor, current.left, tOrder);
    if(tOrder == DepthFirstTraversalType.INORDER){
        processor.doSomething(current.value);
    }
    traverseDepthFirst(processor, current.right, tOrder);
    if(tOrder == DepthFirstTraversalType.POSTORDER){
        processor.doSomething(current.value);
    }
}

我们创建了一个enum DepthFirstTraversalType,将其传递给traverseDepthFirst方法。我们根据其值处理当前节点。请注意,唯一改变的是调用处理器处理节点的时间。让我们创建一个二叉树,看看在每种排序情况下结果有何不同:

public static void main(String [] args){
    BinaryTree<Integer> tree = new BinaryTree<>();
    tree.addRoot(1);
    Node<Integer> n1 = tree.getRoot();
    Node<Integer> n2 = tree.addChild(n1, 2, true);
    Node<Integer> n3 = tree.addChild(n1, 3, false);
    Node<Integer> n4 = tree.addChild(n2, 4, true);
    Node<Integer> n5 = tree.addChild(n2, 5, false);
    Node<Integer> n6 = tree.addChild(n3, 6, true);
    Node<Integer> n7 = tree.addChild(n3, 7, false);
    Node<Integer> n8 = tree.addChild(n4, 8, true);
    Node<Integer> n9 = tree.addChild(n4, 9, false);
    Node<Integer> n10 = tree.addChild(n5, 10, true);

    tree.traverseDepthFirst(System.out::print, tree.getRoot(),
     DepthFirstTraversalType.PREORDER);
    System.out.println();

    tree.traverseDepthFirst(System.out::print, tree.getRoot(),
     DepthFirstTraversalType.INORDER);
    System.out.println();

    tree.traverseDepthFirst(System.out::print, tree.getRoot(),
     DepthFirstTraversalType.POSTORDER);
    System.out.println();
} 

我们创建了与之前图示相同的二叉树。以下是程序的输出。尝试关联位置是如何受到影响:

1 2 4 8 9 5 10 3 6 7
8 4 9 2 10 5 1 6 3 7
8 9 4 10 5 2 6 7 3 1

在匹配程序输出时,你可以注意以下要点:

  • 在先序遍历的情况下,从根到任何叶子的任何路径中,父节点总是会先于任何子节点被打印。

  • 在中序遍历的情况下,如果我们观察从根到特定叶子的任何路径,每当我们从父节点移动到左子节点时,父节点的处理会被推迟。但每当我们从父节点移动到右子节点时,父节点会立即被处理。

  • 在后序遍历的情况下,所有子节点都会在处理任何父节点之前被处理。

非递归深度优先搜索

我们之前看到的通用树的深度优先搜索是先序的,因为父节点是在处理任何子节点之前被处理的。因此,我们可以使用相同的实现来处理二叉树的先序遍历:

public void traversePreOrderNonRecursive(
    OneArgumentStatement<E> processor) {
    Stack<Node<E>> stack = new StackImplLinkedList<>();
    stack.push(getRoot());
    while (stack.peek()!=null){
        Node<E> current = stack.pop();
        processor.doSomething(current.value);
        if(current.right!=null)
            stack.push(current.right);
        if(current.left!=null)
            stack.push(current.left);
    }
}

注意

我们必须检查子节点是否为空。这是因为子节点的缺失用空引用表示,而不是像通用树那样用空列表表示。

中序和后序遍历的实现有点复杂。即使在子节点被展开并推入栈中时,我们也需要暂停对父节点的处理。我们可以通过将每个节点推入两次来实现这一点。第一次,我们在由于父节点被展开而首次发现该节点时将其推入,下一次是在其自己的子节点被展开时。因此,我们必须记住这些推入中哪一个导致节点在弹出时位于栈中。这是通过使用一个额外的标志来实现的,然后将其封装在一个名为StackFrame的类中。中序算法如下:

public void traverseInOrderNonRecursive(
  OneArgumentStatement<E> processor) {
    class StackFame{
        Node<E> node;
        boolean childrenPushed = false;

        public StackFame(Node<E> node, boolean childrenPushed) {
            this.node = node;
            this.childrenPushed = childrenPushed;
        }
    }
    Stack<StackFame> stack = new StackImplLinkedList<>();
    stack.push(new StackFame(getRoot(), false));
    while (stack.peek()!=null){
       StackFame current = stack.pop();
        if(current.childrenPushed){
            processor.doSomething(current.node.value);
        }else{
            if(current.node.right!=null)
                stack.push(new StackFame(current.node.right, false));
            stack.push(new StackFame(current.node, true));
            if(current.node.left!=null)
                stack.push(new StackFame(current.node.left, false));
        }
    }
}

注意,栈是后进先出(LIFO),所以需要稍后弹出的元素必须先被推入。后序版本极其相似:

public void traversePostOrderNonRecursive(OneArgumentStatement<E> processor) {
    class StackFame{
        Node<E> node;
        boolean childrenPushed = false;

        public StackFame(Node<E> node, boolean childrenPushed) {
            this.node = node;
            this.childrenPushed = childrenPushed;
        }
    }
    Stack<StackFame> stack = new StackImplLinkedList<>();
    stack.push(new StackFame(getRoot(), false));
    while (stack.peek()!=null){
        StackFame current = stack.pop();
        if(current.childrenPushed){
            processor.doSomething(current.node.value);
        }else{
 stack.push(new StackFame(current.node, true));
 if(current.node.right!=null)
 stack.push(new StackFame(current.node.right, false));

            if(current.node.left!=null)
                stack.push(new StackFame(current.node.left, false));
            }
    }
}

注意,唯一改变的是子节点和父节点的推送顺序。现在我们编写以下代码来测试这些方法:

public static void main(String [] args){
    BinaryTree<Integer> tree = new BinaryTree<>();
    tree.addRoot(1);
    Node<Integer> n1 = tree.getRoot();
    Node<Integer> n2 = tree.addChild(n1, 2, true);
    Node<Integer> n3 = tree.addChild(n1, 3, false);
    Node<Integer> n4 = tree.addChild(n2, 4, true);
    Node<Integer> n5 = tree.addChild(n2, 5, false);
    Node<Integer> n6 = tree.addChild(n3, 6, true);
    Node<Integer> n7 = tree.addChild(n3, 7, false);
    Node<Integer> n8 = tree.addChild(n4, 8, true);
    Node<Integer> n9 = tree.addChild(n4, 9, false);
    Node<Integer> n10 = tree.addChild(n5, 10, true);

    tree.traverseDepthFirst((x)->System.out.print(""+x), tree.getRoot(), DepthFirstTraversalType.PREORDER);
    System.out.println();
    tree.traverseDepthFirst((x)->System.out.print(""+x), tree.getRoot(), DepthFirstTraversalType.INORDER);
    System.out.println();
    tree.traverseDepthFirst((x)->System.out.print(""+x), tree.getRoot(), DepthFirstTraversalType.POSTORDER);
    System.out.println();

    System.out.println();
 tree.traversePreOrderNonRecursive((x)->System.out.print(""+x));
 System.out.println();
 tree.traverseInOrderNonRecursive((x)->System.out.print(""+x));
 System.out.println();
 tree.traversePostOrderNonRecursive((x)->System.out.print(""+x));
 System.out.println();

}

我们也保留了递归版本,以便我们可以比较输出,如下所示:

1 2 4 8 9 5 10 3 6 7
8 4 9 2 10 5 1 6 3 7
8 9 4 10 5 2 6 7 3 1

1 2 4 8 9 5 10 3 6 7
8 4 9 2 10 5 1 6 3 7
8 9 4 10 5 2 6 7 3 1

前三行与最后三行相同,表明它们产生相同的结果。

摘要

在本章中,你学习了什么是树。我们从实际实现开始,然后从中设计了一个 ADT。你还了解到了二叉树,它就是一个每个节点最多有两个子节点的树。我们还看到了针对通用树的不同的遍历算法。它们是深度优先遍历和广度优先遍历。在二叉树的情况下,深度优先遍历可以以三种不同的方式进行:前序、中序和后序。即使在通用树的情况下,我们也可以找到深度优先遍历的前序和后序遍历的等价形式。然而,指出中序遍历的任何特定等价形式是困难的,因为可能存在超过两个子节点的情况。

在下一章中,我们将看到二叉树在搜索中的应用,同时也会介绍一些其他的搜索方法。

第八章. 更多关于搜索 – 搜索树和哈希表

在前面的章节中,我们探讨了二分查找和树。在本章中,我们将了解它们之间的关系以及这种关系如何帮助我们创建更灵活、可搜索的数据结构。我们还将探讨一种称为哈希表的不同类型的可搜索结构。使用这些结构的原因是它们允许数据结构发生变异,同时仍然可搜索。基本上,我们需要能够轻松地从数据结构中插入和删除元素,同时仍然能够高效地进行搜索。这些结构相对复杂,因此我们需要逐步理解它们。

在本章中,我们将涵盖以下主题:

  • 二叉搜索树

  • 平衡二叉搜索树

  • 哈希表

二叉搜索树

你已经知道什么是二分查找。让我们回到之前章节中的排序数组,再次研究它。如果你考虑二分查找,你知道你需要从排序数组的中间开始。根据要搜索的值,要么如果中间元素是搜索项,就返回,要么根据搜索值是大于还是小于中间值,向左或向右移动。之后,我们继续以相同的方式进行递归。这意味着每一步的着陆点相当固定;它们是中间值。我们可以像在下一张图中那样绘制所有搜索路径。在每一步中,箭头连接到右半部和左半部的中间点,考虑到当前位置。在底部部分,我们分解数组并展开元素,同时保持箭头的源和目标相似。正如人们可以看到的,这给了我们一个二叉树。由于这个树中的每条边都是从二分查找中一步的中间点到下一步的中间点的移动,因此可以通过简单地跟随其边缘在树中执行相同的搜索。这个树非常恰当地被称为二叉搜索树。这个树的每一层代表二分查找中的一步:

二叉搜索树

二叉搜索树

假设我们想要搜索编号为23的项目。我们从树的原始中点开始,即树的根。根的值是5023小于50,所以我们必须检查左侧;在我们的树中,跟随左侧边缘。我们到达值1723大于17,所以我们必须跟随右侧边缘,到达值23。我们刚刚找到了我们一直在寻找的元素。这个算法可以总结如下:

  1. 从根开始。

  2. 如果当前元素等于搜索元素,我们就完成了。

  3. 如果搜索元素小于当前元素,我们跟随左侧边缘并从 2 开始再次进行。

  4. 如果搜索元素大于当前元素,我们跟随右侧边缘并从 2 开始再次进行。

要编写此算法,我们首先必须创建一个二叉搜索树。创建一个扩展BinaryTree类的BinarySearchTree类,然后将你的算法放入其中:

public class BinarySearchTree<E extends Comparable<E>> extends BinaryTree<E> {

    protected Node<E> searchValue(E value, Node<E> root){
        if(root==null){
            return null;
        }
        int comp = root.getValue().compareTo(value);
        if(comp == 0){
            return root;
        }else if(comp>0){
            return searchValue(value, root.getLeft());
        }else{
            return  searchValue(value, root.getRight());
        }
    }

现在将方法封装起来,这样你就不需要传递根节点。此方法还检查树是否为空树,如果是,则搜索失败:

    public Node<E> searchValue(E value){
        if(getRoot()==null){
            return null;
        }else{
            return searchValue(value, getRoot());
        }
    }
    …

}

那么,修改二叉树中的数组究竟有什么意义呢?毕竟,我们不是还在做完全相同的搜索吗?嗯,关键是当我们以树的形式拥有它时,我们可以轻松地在树中插入新值或删除一些值。在数组的情况下,插入和删除的时间复杂度是线性的,并且不能超过预分配的数组大小。

在二叉搜索树中插入

在二叉搜索树中插入是通过首先搜索要插入的值来完成的。这要么找到元素,要么在新的值应该所在的位置上搜索失败。一旦我们到达这个位置,我们就可以简单地在该位置添加元素。在下面的代码中,我们再次重写搜索,因为我们找到空位插入元素后需要访问父节点:

    protected Node<E> insertValue(E value, Node<E> node){
        int comp = node.getValue().compareTo(value);
        Node<E> child;
        if(comp<=0){
            child = node.getRight();
            if(child==null){
                return addChild(node,value,false);
            }else{
                return insertValue(value, child);
            }
        }else if(comp>0){
            child = node.getLeft();
            if(child==null){
                return addChild(node,value,true);
            }else{
                return insertValue(value, child);
            }
        }else{
            return null;
        }
    }

我们可以将这个方法封装起来,使其不需要起始节点。这也确保了当我们向空树插入时,我们只需添加一个根节点:

    public Node<E> insertValue(E value){
        if(getRoot()==null){
            addRoot(value);
            return getRoot();
        }else{
            return insertValue(value, getRoot());
        }
    }

假设在我们之前的树中,我们想要插入值21。以下图显示了使用箭头的搜索路径以及新值是如何插入的:

在二叉搜索树中插入

在二叉树中插入新值

现在我们有了在树中插入元素的手段,我们可以通过连续插入来简单地构建树。以下代码创建了一个包含 20 个元素的随机树,然后对其进行中序遍历:

BinarySearchTree<Integer> tree = new BinarySearchTree<>();
for(int i=0;i<20;i++){
    int value = (int) (100*Math.random());
    tree.insertValue(value);
}
tree.traverseDepthFirst((x)->System.out.print(""+x), tree.getRoot(), DepthFirstTraversalType.INORDER);

如果你运行前面的代码,你总会发现元素是排序的。为什么是这样呢?我们将在下一节中看到这一点。

如果插入的元素与搜索树中已存在的元素相同,应该怎么办?这取决于特定的应用。通常,由于我们是按值搜索,我们不希望有相同值的重复副本。为了简单起见,如果值已经存在,我们将不会插入该值。

二叉搜索树的不变量

不变量是无论与其相关的结构如何修改,其属性都保持不变的性质。二叉搜索树的顺序遍历将始终以排序顺序遍历元素。为了理解为什么会这样,让我们考虑二叉树的另一个不变量:一个节点的左子树的所有后代都具有小于或等于该节点值的值,而一个节点的右子树的所有后代都具有大于该节点值的值。如果你考虑我们如何使用二分搜索算法形成二叉搜索树,这就可以理解为什么这是正确的。这就是为什么当我们看到比我们的搜索值大的元素时,我们总是移动到左子树。这是因为所有右子树的后代值都大于左子树,所以没有必要浪费时间检查它们。我们将利用这一点来证明二叉搜索树的顺序遍历将以节点值的排序顺序遍历元素。

我们将使用归纳法来论证这一点。假设我们有一个只有一个节点的树。在这种情况下,任何遍历都可以很容易地排序。现在让我们考虑一个只有三个元素的树,如图所示:

二叉搜索树的不变量

具有三个节点的二叉搜索树

对此树的顺序遍历将首先处理左子树,然后是父节点,最后是右子树。由于搜索树保证了左子树具有小于或等于父节点的值,而右子树具有大于或等于父节点值的值,因此遍历是排序的。

现在让我们考虑我们的通用情况。假设我们讨论的这个不变量对于具有最大h层级的树是正确的。我们将证明,在这种情况下,对于具有最大h+1层级的树也是正确的。我们将考虑一个通用的搜索树,如图所示:

二叉搜索树的不变量

通用二叉搜索树

三角形代表具有最大 n 层的子树。我们假设对于子树,这个不变量是成立的。现在,中序遍历会首先以排序顺序遍历左子树,然后是父节点,最后是同样顺序的右子树。子树的排序顺序遍历是由假设这些子树的不变量成立所隐含的。这将导致顺序 [按排序顺序遍历左子代][遍历父节点][按排序顺序遍历右子代]。由于左子代都小于或等于父节点,而右子代都大于或等于父节点,所以提到的顺序实际上是一个排序顺序。因此,可以绘制一个最大层 h+1 的树,如图所示,每个子树的最大层为 n。如果这种情况成立,并且对于所有层 h 的树,这个不变量都是成立的,那么对于层 h+1 的树也必须成立。

我们已经知道,对于最大层为 1 和 2 的树,这个不变量是成立的。然而,对于最大层为 3 的树也必须成立。这暗示它对于最大层为 4 的树也必须成立,以此类推,直到无穷大。这证明了对于所有 h,这个不变量是成立的,并且是普遍成立的。

从二叉搜索树中删除一个元素

我们对二叉搜索树的所有修改都感兴趣,其中结果树仍然是一个有效的二叉搜索树。除了插入之外,我们还需要能够执行删除操作。也就是说,我们需要能够从树中删除一个现有的值:

从二叉搜索树中删除一个元素

节点删除的三个简单情况

主要关注的是知道如何处理被删除节点的子节点。我们不希望从树中丢失这些值,同时我们还想确保树仍然是一个搜索树。我们需要考虑四种不同的情况。相对容易的三个情况已在前面图中展示。以下是这些情况的简要描述:

  • 第一种情况是没有子节点的情况。这是最简单的情况;我们只需删除该节点。

  • 第二种情况是只有一个右子树的情况。在这种情况下,子树可以取代被删除的节点。

  • 第三种情况与第二种情况非常相似,只是它关于左子树。

第四种情况当然是当要删除的节点有两个子节点时。在这种情况下,没有一个子节点可以取代要删除的节点,因为另一个子节点也需要被附加到某个地方。我们通过用另一个节点替换需要删除的节点来解决这个问题,这个节点可以是两个子节点的有效父节点。这个节点是右子树的最小节点。为什么是这样呢?这是因为如果我们从这个右子树中删除这个节点,右子树的剩余节点将大于或等于这个节点。当然,这个节点也大于左子树中的所有节点。这使得这个节点成为一个有效的父节点。

接下来要问的问题是:右子树中的最小节点是什么?记住,当我们移动到一个节点的左子节点时,我们总是得到一个小于或等于当前节点的值。因此,我们必须继续向左遍历,直到没有更多的左子节点。如果我们这样做,我们最终会到达最小节点。任何子树的最小节点不能有任何左子节点,因此可以使用删除的第一个或第二个情况来删除它。因此,第四种情况的删除操作用于:

  • 将右子树中最小节点的值复制到要删除的节点

  • 删除右子树中的最小节点

要编写删除代码,我们首先需要向我们的 BinaryTree 类添加几个方法,这个类是用来删除节点和重写节点值的。deleteNodeWithSubtree 方法简单地删除一个节点及其所有后代。它简单地忘记了所有后代。它也有一定的检查来确认输入的有效性。通常,根的删除必须单独处理:

    public void deleteNodeWithSubtree(Node<E> node){
        if(node == null){
            throw new NullPointerException("Cannot delete to null parent");
        }else if(node.containerTree != this){
            throw new IllegalArgumentException(
                "Node does not belong to this tree");
        }else {
            if(node==getRoot()){
                root=null;
                return;
            }else{
                Node<E> partent = node.getParent();
                if(partent.getLeft()==node){
                    partent.left = null;
            }else{
                partent.right = null;
            }
        }
    }
}

现在我们向 BinaryTree 类添加另一个方法来重写节点中的值。我们不允许这个类使用 node 类的公共方法来保持封装:

    public void setValue(Node<E> node, E value){
        if(node == null){
            throw new NullPointerException("Cannot add node to null parent");
        }else if(node.containerTree != this){
            throw new IllegalArgumentException(
                     "Parent does not belong to this tree");
        }else {
            node.value = value;
        }
    }

上述代码是自我解释的。最后,我们编写一个方法来用一个来自同一棵树的另一个节点替换节点的子节点。这在情况 2 和 3 中很有用:

    public Node<E> setChild(Node<E> parent, Node<E> child, boolean left){
        if(parent == null){
            throw new NullPointerException("Cannot set node to null parent");
        }else if(parent.containerTree != this){
            throw new IllegalArgumentException(
                "Parent does not belong to this tree");
        }else {
            if(left){
                parent.left = child;
            }else{
                parent.right = child;
            }
            if(child!=null) {
                child.parent = parent;
            }
            return child;
        }
    }

最后,我们在 BinarySearchTree 中添加一个方法来找到子树中的最小节点。我们继续向左走,直到没有更多的左子节点:

    protected Node<E> getLeftMost(Node<E> node){
        if(node==null){
            return null;
        }else if(node.getLeft()==null){
            return node;
        }else{
            return getLeftMost(node.getLeft());
        }
    }

现在我们可以实现我们的删除算法。首先,我们创建一个 deleteNode 方法来删除一个节点。然后我们可以使用这个方法来删除一个值:

    private Node<E> deleteNode(Node<E> nodeToBeDeleted) {

        boolean direction;
        if(nodeToBeDeleted.getParent()!=null
           && nodeToBeDeleted.getParent().getLeft()==nodeToBeDeleted){
            direction = true;
        }else{
            direction = false;
        }

情况 1:没有子节点。在这种情况下,我们可以简单地删除节点:

        if(nodeToBeDeleted.getLeft()==null &&
            nodeToBeDeleted.getRight()==null){
            deleteNodeWithSubtree(nodeToBeDeleted);
            return nodeToBeDeleted;
        }

情况 2:只有一个右子节点。右子节点可以取代被删除的节点:

        else if(nodeToBeDeleted.getLeft()==null){
            if(nodeToBeDeleted.getParent() == null){
                root = nodeToBeDeleted.getRight();
            }else {
                setChild(nodeToBeDeleted.getParent(),
                nodeToBeDeleted.getRight(), direction);
            }
             return nodeToBeDeleted;
         }

情况 3:只有一个左子节点。左子节点可以取代被删除的节点:

        else if(nodeToBeDeleted.getRight()==null){
            if(nodeToBeDeleted.getParent() == null){
                root = nodeToBeDeleted.getLeft();
            }else {
                setChild(nodeToBeDeleted.getParent(),
                nodeToBeDeleted.getLeft(), direction);
            }
            return nodeToBeDeleted;
        }

情况 4:左子节点和右子节点都存在。在这种情况下,首先我们将右子树中最左边的子节点的值(或后继)复制到要删除的节点。一旦这样做,我们就删除右子树中最左边的子节点:

        else{
         Node<E> nodeToBeReplaced = getLeftMost(nodeToBeDeleted.getRight());
            setValue(nodeToBeDeleted, nodeToBeReplaced.getValue());
            deleteNode(nodeToBeReplaced);
            return nodeToBeReplaced;
        }
    }

删除一个节点的过程证明要复杂一些,但并不困难。在下一节中,我们将讨论二叉搜索树操作的复杂度。

二叉搜索树操作的复杂度

我们首先考虑的操作是搜索操作。它从根开始,每次从一个节点移动到其子节点之一时,就向下移动一个级别。在搜索操作期间必须遍历的最大边数必须等同于树的最大高度——即任何节点与根之间的最大距离。如果树的高度是 h,那么搜索的复杂度是 O(h)

现在树中节点数 n 和树的高度 h 之间有什么关系?这实际上取决于树是如何构建的。任何级别至少需要有一个节点,所以在最坏的情况下,h = n,搜索复杂度为 O(n)。我们的最佳情况是什么?或者更确切地说,我们希望 hn 之间保持什么关系?换句话说,给定一个特定的 n,最小 h 是多少。为了回答这个问题,我们首先问,在高度 h 的树中我们可以容纳的最大 n 是多少?

根只是一个单一元素。根的子节点构成一个完整的级别,为高度为 2 的树添加两个节点。在下一级,我们将为这一级中的任何节点有两个子节点。所以下一级或第三级总共有 2X2=4 个节点。可以很容易地看出,树的级别 h 总共有 2^((h-1)) 个节点。高度为 h 的树可以拥有的节点总数如下:

n = 1 + 2 + 4+ … + 2(h-1) = 2h – 1
=> 2h = (n+1) 
=> h = lg (n+ 1)

这是我们的理想情况,其中搜索的复杂度是 O(lg n)。所有级别都满的二叉树称为平衡二叉树。我们的目标是即使在插入或删除操作时也要保持树的平衡性质。然而,在一般情况下,如果元素插入的顺序是任意的,树就不会保持平衡。

插入只需要搜索元素;一旦完成,添加一个新节点就是一个常数时间操作。因此,它的复杂度与搜索相同。删除实际上最多需要两次搜索(在第四种情况下),因此它的复杂度也与搜索相同。

自平衡二叉搜索树

当进行插入和删除操作时,在一定程度上保持平衡的二叉搜索树被称为自平衡二叉搜索树。为了创建一个平衡的不平衡树的版本,我们使用一种称为旋转的特殊操作。我们将在下一节中讨论旋转:

自平衡二叉搜索树

二叉搜索树的旋转

此图展示了节点 AB 上的旋转操作。对 A 进行左旋转会生成右侧的图像,而对 B 进行右旋转会生成左侧的图像。为了可视化旋转,首先考虑将子树 D 拔出。这个子树位于中间某个位置。现在节点向左或向右旋转。在左旋转的情况下,右子节点变为父节点,而父节点变为原始子节点的左子节点。一旦完成这个旋转,D 子树就被添加到原始父节点的右子节点位置。右旋转与左旋转完全相同,但方向相反。

它如何帮助平衡一棵树?注意图中的左侧。你会意识到右侧看起来更重,然而,一旦你执行左旋转,左侧看起来会更重。实际上,左旋转将右子树的深度减少一个,并将左子树的深度增加一个。即使最初,与左侧相比,右侧的深度为 2,你也可以通过左旋转来修复它。唯一的例外是子树 D,因为 D 的根保持在同一级别;其最大深度没有变化。对于右旋转,类似的论点同样成立。

旋转保持树的搜索树属性不变。如果我们打算用它来平衡搜索树,这一点非常重要。让我们考虑左旋转。从位置上,我们可以得出以下不等式:

  • C 中的每个节点 ≤ A

  • A ≤ B

  • AD 中的每个节点 ≤ B

  • BE 中的每个节点

执行旋转后,我们以相同的方式检查不等式,发现它们完全相同。这证明了旋转保持搜索树属性不变的事实。旋转算法的想法很简单:首先取出中间子树,进行旋转,然后重新连接中间子树。以下是在我们的 BinaryTree 类中的实现:

    protected void rotate(Node<E> node, boolean left){

首先,让我们做一些参数值检查:

        if(node == null){
            throw new IllegalArgumentException("Cannot rotate null node");
        }else if(node.containerTree != this){
            throw  new IllegalArgumentException(
                "Node does not belong to the current tree");
        }
        Node<E> child = null;
        Node<E> grandchild = null;
        Node<E> parent = node.getParent();
        boolean parentDirection;

我们想要移动的子节点和孙节点取决于旋转的方向:

        if(left){
            child = node.getRight();
            if(child!=null){
                grandchild = child.getLeft();
            }
        }else{
            child = node.getLeft();
            if(child!=null){
                grandchild = child.getRight();
            }
        }

根节点需要像往常一样进行特殊处理:

        if(node != getRoot()){
            if(parent.getLeft()==node){
                parentDirection = true;
            }else{
                parentDirection = false;
            }
            if(grandchild!=null)
                deleteNodeWithSubtree(grandchild);
            if(child!=null)
                deleteNodeWithSubtree(child);
                deleteNodeWithSubtree(node);
            if(child!=null) {
                setChild(parent, child, parentDirection);
                setChild(child, node, left);
            }
            if(grandchild!=null)
                setChild(node, grandchild, !left);
        }else{
            if(grandchild!=null)
                deleteNodeWithSubtree(grandchild);
            if(child!=null)
                deleteNodeWithSubtree(child);
                deleteNodeWithSubtree(node);
            if(child!=null) {
                root = child;
                setChild(child, node, left);
            }
            if(grandchild!=null)
                setChild(node, grandchild, !left);
                root.parent = null;
        }
    }

现在,我们可以看看我们的第一个自平衡二叉树,称为 AVL 树。

AVL 树

AVL 树是我们第一个自平衡二叉搜索树。想法很简单:尽可能保持每个子树平衡。理想的情况是,从每个节点开始,左右子树的高度完全相同。然而,由于节点数量不是2^p -1的形式,其中p是正整数,我们无法总是实现这一点。相反,我们允许有一点灵活性。重要的是,左子树和右子树的高度差不能超过一。如果在任何插入或删除操作中意外破坏了这个条件,我们将应用旋转来修复它。我们只需要担心高度差为两个,因为我们一次只考虑一个元素的插入和删除,插入一个元素或删除它不能使高度变化超过一个。因此,我们的最坏情况是已经有一个差异,新的添加或删除又产生了一个差异,需要旋转来修复。

最简单的旋转类型在以下图中展示。三角形代表等高的子树。请注意,左子树的高度比右子树的高度少两个:

AVL 树

AVL 树 – 简单旋转

因此,我们进行左旋转以生成结构子树,如图所示。您可以看到子树的高度符合我们的条件。简单的右旋转情况完全相同,只是方向相反。我们必须对所有节点的前辈进行此操作,这些节点要么被插入,要么被删除,因为只有这些节点的子树高度受到影响。由于旋转也会导致高度变化,我们必须从底部开始,在旋转的同时向上走到根节点。

还有一种称为双重旋转的情况。请注意,由于旋转,中间孙子的根的子树的高度没有改变。因此,如果这是不平衡的原因,简单的旋转无法解决这个问题。这也在以下图中展示:

AVL 树

简单旋转无法修复这种不平衡

在这里,接收插入的子树以D为根,或者从子树C中删除了一个节点。在插入的情况下,请注意,由于B的左子树的高度只比其右子树高一个,所以B上不会进行旋转。然而,A是不平衡的。A的左子树的高度比其右子树低两个。但是,如果我们对A进行旋转,如前图所示,这并不能解决问题;只是将左重条件转换成了右重条件。为了解决这个问题,我们需要进行双重旋转,如下一图所示。首先,我们对中间的孙子节点进行相反方向的旋转,使其在相反方向上不会失衡。之后的简单旋转将修复不平衡。

AVL 树

AVL 树双重旋转

因此,我们创建了一个 AVL 树类,并在Node类中添加了一个额外的字段来存储其根的子树的高度:

public class AVLTree<E extends Comparable<E>> 
          extends BinarySearchTree<E>{
    public static class Node<E extends Comparable<E>> 
              extends BinaryTree.Node{
        protected int height = 0;
        public Node(BinaryTree.Node parent,
                    BinaryTree containerTree, E value) {
            super(parent, containerTree, value);
        }
    }

我们必须重写newNode方法以返回我们的扩展节点:

    @Override
    protected BinaryTree.Node<E> newNode(
      BinaryTree.Node<E> parent, BinaryTree<E> containerTree, E value) {
        return new Node(parent, containerTree, value);
    }

我们使用一个实用方法来检索子树的高度,并进行空检查。空子树的高度为零:

    private int nullSafeHeight(Node<E> node){
        if(node==null){
            return 0;
        }else{
            return node.height;
        }
    }

首先,我们包括一个方法来计算和更新节点根的子树的高度。高度是其孩子最大高度加一:

    private void nullSafeComputeHeight(Node<E> node){
        Node<E> left = (Node<E>) node.getLeft();
        Node<E> right = (Node<E>) node.getRight();
        int leftHeight = left==null? 0 : left.height;
        int rightHeight = right==null? 0 :right.height;
        node.height =  Math.max(leftHeight, rightHeight)+1;
    }

我们还在BinaryTree中重写了rotate方法,以便在旋转后更新子树的高度:

    @Override
    protected void rotate(BinaryTree.Node<E> node, boolean left) {
        Node<E> n = (Node<E>) node;
        Node<E> child;
        if(left){
            child = (Node<E>) n.getRight();
        }else{
            child = (Node<E>) n.getLeft();
        }
        super.rotate(node, left);
        if(node!=null){
            nullSafeComputeHeight(n);
        }
        if(child!=null){
            nullSafeComputeHeight(child);
        }
    }

通过这些方法,我们实现了从节点到根节点的整个平衡过程,如前述代码所示。平衡位是通过检查左右子树高度差来完成的。如果差值为 0、1 或-1,则不需要做任何事情。我们只需递归地向上移动树。当高度差为 2 或-2 时,这就是我们需要进行平衡的时候:

    protected void rebalance(Node<E> node){
        if(node==null){
            return;
        }
        nullSafeComputeHeight(node);
        int leftHeight = nullSafeHeight((Node<E>) node.getLeft());
        int rightHeight = nullSafeHeight((Node<E>) node.getRight());
        switch (leftHeight-rightHeight){
            case -1:
            case 0:
            case 1:
                rebalance((Node<E>) node.getParent());
                break;
            case 2:
                int childLeftHeight = nullSafeHeight(
                        (Node<E>) node.getLeft().getLeft());
                int childRightHeight = nullSafeHeight(
                        (Node<E>) node.getLeft().getRight());
                if(childRightHeight > childLeftHeight){
                    rotate(node.getLeft(), true);
                }
                Node<E> oldParent = (Node<E>) node.getParent();
                rotate(node, false);
                rebalance(oldParent);
                break;
            case -2:
                childLeftHeight = nullSafeHeight(
                        (Node<E>) node.getRight().getLeft());
                childRightHeight = nullSafeHeight(
                        (Node<E>) node.getRight().getRight());
                if(childLeftHeight > childRightHeight){
                    rotate(node.getRight(), false);
                }
                oldParent = (Node<E>) node.getParent();
                rotate(node, true);
                rebalance(oldParent);
                break;
        }
    }

一旦旋转实现,实现插入和删除操作就非常简单。我们首先进行常规的插入或删除,然后进行平衡。一个简单的插入操作如下:

  @Override
    public BinaryTree.Node<E> insertValue(E value) {
        Node<E> node = (Node<E>) super.insertValue(value);
        if(node!=null)
            rebalance(node);
            return node;
    }

删除操作也非常相似。它只需要一个额外的检查来确认节点确实被找到并删除:

@Override
    public BinaryTree.Node<E> deleteValue(E value) {
        Node<E> node = (Node<E>) super.deleteValue(value);
        if(node==null){
            return null;
        }
        Node<E> parentNode = (Node<E>) node.getParent();
        rebalance(parentNode);
        return node;
    }

AVL 树中搜索、插入和删除的复杂度

AVL 树的最坏情况是它具有最大不平衡。换句话说,当给定节点数达到最大高度时,树是最差的。为了找出这是多少,我们需要以不同的方式提出问题,给定高度 h:能够实现这一点的最小节点数(n)是多少?设实现这一点的最小节点数为f(h)。高度为h的树将有两个子树,并且不失一般性,我们可以假设左子树比右子树高。我们希望这两个子树也具有最小节点数。因此,左子树的高度将是f(h-1)。我们希望右子树的高度最小,因为这不会影响整个树的高度。然而,在 AVL 树中,同一级别的两个子树的高度差最大为 1。这个子树的高度是h-2。因此,右子树的节点数是f(h-2)。整个树也必须有一个根节点,因此总节点数:

f(h) = f(h-1) + f(h-2) + 1

它几乎看起来像是斐波那契数列的公式,除了+1部分。我们的起始值是 1 和 2,因为f(1) = 1(只有根节点)和f(2) = 2(只有一个孩子)。这大于斐波那契数列的起始值,斐波那契数列的起始值是 1 和 1。当然,节点数将大于相应的斐波那契数。所以,以下就是情况:

f(h) ≥ Fh where Fh is the hth Fibonacci number.

我们知道对于足够大的hFh* ≈ φFh-1成立;这里φ是黄金比例(1 + √5)/2。这意味着Fh = C φ*h,其中 C 是某个常数。所以,我们得到以下:

f(h) ≥ C φ h
=>n ≥ C  φ h
=> log φn ≥  h +  log φ C
=> h = O(  log φn) = O(lg n)

这意味着即使是 AVL 树的最坏高度也是对数级的,这正是我们想要的。由于插入过程在每个级别处理一个节点,直到达到插入位置,因此插入的复杂度是O(lg n);执行搜索和删除操作也是一样,原因相同。

红黑树

AVL 树保证了对数级的插入、删除和搜索。但它会进行很多旋转。在大多数应用中,插入和删除都是随机排序的。因此,这些树最终会平衡。然而,由于 AVL 树旋转得太快,它可能会在不需要的情况下频繁地在相反方向上进行旋转。这可以通过不同的方法来避免:知道何时旋转子树。这种方法被称为红黑树。

在红黑树中,节点有一个颜色,要么是黑色,要么是红色。在节点操作期间可以切换颜色,但它们必须遵循以下条件:

  • 根节点必须是黑色的

  • 红色节点不能有黑色子节点

  • 任何以任何节点为根的子树的黑色高度等于以兄弟节点为根的子树的黑色高度

现在什么是子树的黑高?它是从根到叶子的黑色节点的数量。当我们说时,我们实际上是指空孩子,它们被认为是黑色的,并且允许父节点为红色而不违反规则 2。这无论我们走哪条路径都是一样的。这是因为第三个条件。所以第三个条件也可以重新表述为:从任何子树的根到其任何叶子节点的路径上的黑色节点数量是相同的,无论我们选择哪个叶子。

为了便于操作,叶子的空孩子也被视为某种半节点;空孩子总是被认为是黑色的,并且是唯一真正被视为叶子的节点。所以叶子不包含任何值。但它们与其他红黑树中的传统叶子不同。新节点可以添加到叶子,但不能添加到红黑树中;这是因为这里的叶子是空节点。因此,我们不会明确地绘制它们或把它们放入代码中。它们只对计算和匹配黑高有帮助:

红黑树

红黑树的例子

在我们高度为 4 的红黑树例子中,空节点是黑色的,这些节点没有显示(在打印副本中,浅色或灰色节点是红色节点,深色节点是黑色节点)。

插入和删除都比 AVL 树复杂,因为我们需要处理更多的情况。我们将在以下章节中讨论这个问题。

插入

插入是以我们处理 BST(二叉搜索树)相同的方式进行。插入完成后,新节点被着色为红色。这保留了黑高,但它可能导致一个红色节点成为另一个红色节点的子节点,这将违反条件 2。因此,我们需要进行一些操作来修复这个问题。以下两个图显示了四种插入情况:

插入

红黑树插入的 1 和 2 情况

插入

红黑树插入的 3 和 4 情况

让我们逐个讨论插入的情况。注意,图中的树看起来是黑色和不平衡的。但这仅仅是因为我们没有绘制整个树;这只是我们感兴趣的树的一部分。重要的是,无论我们做什么,任何节点的黑高都不会改变。如果必须增加黑高以适应新节点,它必须在顶层;所以我们只需将其移动到父节点。以下四种情况如下:

  1. 父节点是黑色的。在这种情况下,不需要做任何事情,因为它没有违反任何约束。

  2. 父节点和叔叔节点都是红色的。在这种情况下,我们需要重新着色父节点、叔叔节点和祖父节点,并且黑高保持不变。注意现在没有违反任何约束。然而,如果祖父节点是根节点,则保持它为黑色。这样,整个树的黑高就增加了 1。

  3. 父节点是红色,叔叔节点是黑色。新添加的节点位于父节点与祖父节点相同的侧。在这种情况下,我们进行旋转并重新着色。我们首先重新着色父节点和祖父节点,然后旋转祖父节点。

  4. 这与情况 3 类似,但新添加的节点位于父节点与祖父节点相反的一侧。由于这样做会改变新添加节点的黑色高度,所以不能应用情况 3。在这种情况下,我们旋转父节点,使其与情况 3 相同。

  5. 注意,所有这些情况都可以以相反的方向发生,即镜像。我们将以相同的方式处理这两种情况。

让我们创建一个扩展BinarySearchTree类的RedBlackTree类。我们必须再次扩展Node类,并包括一个标志以知道节点是否为黑色:

public class RedBlackTree<E extends Comparable<E>> extends BinarySearchTree<E>{
    public static class Node<E> extends BinaryTree.Node<E>{
        protected int blackHeight = 0;
        protected boolean black = false;
        public Node(BinaryTree.Node parent,
                    BinaryTree containerTree, E value) {
            super(parent, containerTree, value);
        }
    }

    @Override
    protected  BinaryTree.Node<E> newNode(
      BinaryTree.Node<E> parent, BinaryTree<E> containerTree, E value) {
        return new Node(parent, containerTree, value);
    }
...
}

我们现在添加一个实用方法,该方法返回一个节点是否为黑色。如前所述,空节点被认为是黑色:

    protected boolean nullSafeBlack(Node<E> node){
        if(node == null){
            return true;
        }else{
            return node.black;
        }
    }

现在我们已经准备好在插入后定义重新平衡的方法。这种方法与前面描述的四个情况相同。我们维护一个nodeLeftGrandChild标志,用于存储父节点是否是祖父节点的左子节点或其右子节点。这有助于我们找到叔叔节点,并在正确的方向上进行旋转:

    protected void rebalanceForInsert(Node<E> node){
        if(node.getParent() == null){
            node.black = true;
        }else{
            Node<E> parent = (Node<E>) node.getParent();
            if(parent.black){
                return;
            }else{
                Node<E> grandParent = (Node<E>) parent.getParent();
                boolean nodeLeftGrandChild = grandParent.getLeft()== parent;

                Node<E> uncle = nodeLeftGrandChild?
                  (Node<E>) grandParent.getRight()
                  : (Node<E>) grandParent.getLeft();
                if(!nullSafeBlack(uncle)){
                    if(grandParent!=root)
                        grandParent.black = false;
                        uncle.black = true;
                        parent.black = true;
                        rebalanceForInsert(grandParent);
                }else{
                    boolean middleChild = nodeLeftGrandChild?
                      parent.getRight() == node:parent.getLeft() == node;
                    if (middleChild){
                        rotate(parent, nodeLeftGrandChild);
                        node = parent;
                        parent = (Node<E>) node.getParent();
                    }
                    parent.black = true;
                    grandParent.black = false;
                    rotate(grandParent, !nodeLeftGrandChild);
                }
            }

        }
    }

插入现在是按照以下方式进行的:

    @Override
    public BinaryTree.Node<E> insertValue(E value) {
        Node<E> node = (Node<E>) super.insertValue(value);
        if(node!=null)
            rebalanceForInsert(node);
        return node;
    }

删除

删除操作从正常的二叉搜索树删除操作开始。如果你还记得,这通常涉及删除最多只有一个子节点的节点。删除内部节点是通过首先复制右子树最左节点的值然后删除它来完成的。因此,我们只考虑这种情况:

删除

红黑树删除操作的 1、2 和 3 种情况

删除完成后,被删除节点的父节点要么没有子节点,要么有一个子节点,这个子节点原本是其祖父节点。在插入过程中,我们需要解决的问题是一个红色父节点的红色子节点。在删除过程中,这种情况不会发生。但它可以导致黑色高度发生变化。

一个简单的情况是,如果我们删除一个红色节点,黑色高度不会发生变化,所以我们不需要做任何事情。另一个简单的情况是,如果被删除的节点是黑色且子节点是红色,我们可以简单地重新着色子节点为黑色,以恢复黑色高度。

黑色子节点实际上是不可能发生的,因为这意味着原始树是黑色且不平衡的,因为被删除的节点有一个单独的黑色子节点。但由于递归的存在,在递归重新平衡的过程中向上移动路径时,实际上可以出现黑色子节点。在以下讨论中,我们只考虑被删除的节点是黑色且子节点也是黑色(或空子节点,被认为是黑色)的情况。删除操作按照以下情况执行,如图所示红黑树删除操作的 1、2 和 3 种情况从红黑树删除的 4、5 和 6 种情况

  1. 我们遇到的第一种情况是父节点、兄弟节点以及两个侄子节点都是黑色。在这种情况下,我们可以简单地重新绘制兄弟节点为红色,这将使父节点变为黑色并保持平衡。然而,整个子树的黑色高度将减少一个;因此,我们必须从父节点开始继续重新平衡。

  2. 这是父节点和兄弟节点为黑色,但远侄子节点为红色的情况。在这种情况下,我们不能重新绘制兄弟节点,因为这会使红色兄弟节点有一个红色子节点,违反了约束 2。因此,我们首先将红色侄子节点重新着色为黑色,然后旋转以修复侄子节点的黑色高度,同时修复子节点的黑色高度。

  3. 当近侄子节点为红色而不是远侄子节点时,旋转不会恢复被重新着色的近侄子节点的黑色高度。因此,我们重新着色 NN,但进行双重旋转。

  4. 现在考虑当兄弟节点为红色时会发生什么。我们首先使用相反的颜色重新绘制父节点和兄弟节点,并旋转 P。但这并不会改变任何节点的黑色高度;它将情况简化为 5 或 6,我们将在下面讨论。因此,我们只需再次递归地调用重新平衡代码。

  5. 我们现在完成了所有父节点为黑色的情况。这是一个父节点为红色的情况。在这种情况下,我们将近侄子节点视为黑色。简单地旋转父节点就可以修复黑色高度。

  6. 我们最后的案例是当父节点为红色且近侄子节点为红色时。在这种情况下,我们重新着色父节点并进行双重旋转。注意,顶部节点仍然为红色。这不是问题,因为原始的顶部节点,即父节点,也是红色,因此它的父节点必须是黑色。删除

    从红黑树中删除的 4、5 和 6 种情况

现在我们可以定义rebalanceForDelete方法,编码所有前面的情况:

    protected void rebalanceForDelete(Node<E> parent, boolean nodeDirectionLeft){
        if(parent==null){
            return;
        }
        Node<E> node = (Node<E>) (nodeDirectionLeft? parent.getLeft(): parent.getRight());
        if(!nullSafeBlack(node)){
            node.black = true;
            return;
        }

        Node<E> sibling = (Node<E>) (nodeDirectionLeft? parent.getRight(): parent.getLeft());

        Node<E> nearNephew = (Node<E>) (nodeDirectionLeft?sibling.getLeft():sibling.getRight());

        Node<E> awayNephew = (Node<E>) (nodeDirectionLeft?sibling.getRight():sibling.getLeft());

        if(parent.black){
            if(sibling.black){
                if(nullSafeBlack(nearNephew) && nullSafeBlack(awayNephew)){
                    sibling.black = false;
                    if(parent.getParent()!=null){
                        rebalanceForDelete (
                          (Node<E>) parent.getParent(),
                          parent.getParent().getLeft() == parent);
                    }
                }else if(!nullSafeBlack(awayNephew)){
                    awayNephew.black = true;
                    rotate(parent, nodeDirectionLeft);
                }else{
                    nearNephew.black = true;
                    rotate(sibling, !nodeDirectionLeft);
                    rotate(parent, nodeDirectionLeft);
                }

            }else{
                parent.black = false;
                sibling.black = true;
                rotate(parent, nodeDirectionLeft);
                rebalanceForDelete(parent, nodeDirectionLeft);
            }
        }else{

            if(nullSafeBlack(nearNephew)){
                rotate(parent, nodeDirectionLeft);
            }else{
                parent.black = true;
                rotate(sibling, !nodeDirectionLeft);
                rotate(parent, nodeDirectionLeft);
            }
        }

    }

现在我们覆盖了deleteValue方法,在删除后调用重新平衡。我们只需要在删除的节点是黑色时进行重新平衡。我们首先检查这一点。然后,我们需要确定被删除的子节点是父节点的左子节点还是右子节点。之后,我们可以调用rebalanceForDelete方法:

    @Override
    public BinaryTree.Node<E> deleteValue(E value) {
        Node<E> node = (Node<E>) super.deleteValue(value);

        if(node !=null && node.black && node.getParent()!=null){
            Node<E> parentsCurrentChild = (Node<E>) (node.getLeft() == null ? node.getRight(): node.getLeft());
            if(parentsCurrentChild!=null){
                boolean isLeftChild = parentsCurrentChild.getParent().getLeft() == parentsCurrentChild;
                rebalanceForDelete(
                        (Node<E>) node.getParent(), isLeftChild);
            }else{
                boolean isLeftChild = node.getParent().getRight()!=null;
                rebalanceForDelete(
                        (Node<E>) node.getParent(), isLeftChild);
            }

        }
        return node;
    }

红黑树的最坏情况

最坏可能的红黑树是什么?我们试图以与 AVL 树案例相同的方式找出答案。虽然这个案例稍微复杂一些。要使最小节点数 n 适应高度 h,我们首先需要选择一个黑高。现在我们希望尽可能少地有黑节点,这样我们就不必在试图拉伸高度的节点兄弟中包含黑节点来平衡黑高。由于红节点不能成为另一个节点的父节点,我们必须有交替的黑节点。我们考虑高度 h 和一个偶数,这样黑高就是 h/2 = l。为了简单起见,我们既不计算高度也不计算黑高的黑空节点。下一个图显示了最坏树的一些示例:

红黑树的最坏情况

最坏的红黑树

当然,一般想法是,有一条路径具有最大可能的高度。这条路径应该填充尽可能多的红节点,而其他路径则填充尽可能少的节点,即只填充黑节点。一般想法在下一个图中显示。

高度为 l-1 的满黑树中的节点数当然是 2 ^(l-1) – 1。所以,如果高度为 h = 2l 的节点数为 f(l),那么我们就有递归公式:

f(l) = f(l-1) + 2 ( 2l-1 – 1) + 2
=> f(l) = f(l-1) + 2l

现在,从前面的图中,我们还可以看到 f(1) = 2f(2) = 6,和 f(3) = 14。看起来公式应该是 f(l) = 2 ^(l-1) -2。我们已经有了基本案例。如果我们能证明如果公式对 l 成立,那么它对 l+1 也成立,我们就能通过归纳法证明所有 l 的公式。这正是我们将要尝试做的:

红黑树的最坏情况

最坏红黑树的一般想法

我们已经有了 f(l+1) = f(l) + 2l+1,并且我们也假设 f(l) = 2l+1-2。所以这是这种情况:f(l+1) = 2l+1-2 + 2l+1 = 2l+2-2。因此,如果公式对 l 成立,它对 l+1 也成立;因此,通过归纳法证明了这一点。

所以,最小节点数如下:

n = f(l) =  2l+2-2\. 
=> lg n = lg ( 2l+2-2)   
=> lg n >  lg ( 2l+1)
=> lg n > l+1
=> l + 1< lg n
=> l < lg n
=> l = O (lg n)

因此,红黑树有一个保证的对数高度;从这个事实可以推导出,搜索、插入和删除操作都是对数的。

哈希表

哈希表是一种完全不同的可搜索结构。这个想法始于所谓的哈希函数。这是一个函数,为任何所需类型的值提供一个整数。例如,字符串的哈希函数必须为每个字符串返回一个整数。Java 要求每个类都必须有一个 hashcode() 方法。对象类默认实现了一个方法,但每当我们要重写 equals 方法时,我们必须重写默认实现。哈希函数具有以下属性:

  • 相同的值必须始终返回相同的哈希值。这被称为哈希的一致性。在 Java 中,这意味着如果xy是两个对象,并且x.equals(y)true,那么x.hashcode() == y.hashcode()

  • 不同的值可能返回相同的哈希值,但最好是它们不返回相同的值。

  • 哈希函数可以在常数时间内计算。

一个完美的哈希函数将为不同的值提供不同的哈希值。然而,通常这种哈希函数不能在常数时间内计算。因此,我们通常求助于生成看似随机但实际上是值的复杂函数的哈希值。例如,String类的hashcode看起来像这样:

public int hashCode() {
    int h = hash;
     if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
         hash = h;
    }
    return h;
}

注意,这是一个由构成字符计算出的复杂函数。

哈希表保持一个由哈希码索引的桶数组。桶可以有多种数据结构,但在这里,我们将使用链表。这使得能够在常数时间内跳转到某个桶,并且桶保持足够小,以至于桶内的搜索,即使是线性搜索,也不会花费太多。

让我们为我们的哈希表创建一个骨架类:

public class HashTable<E> {
    protected LinkedList<E> [] buckets;
    protected double maximumLoadFactor;
    protected int totalValues;
    public HashTable(int initialSize, double maximumLoadFactor){
        buckets = new LinkedList[initialSize];
        this.maximumLoadFactor = maximumLoadFactor;
    }
    …
}

我们接受两个参数。InitialSize是我们想要开始的初始桶数量,我们的第二个参数是最大负载因子。

什么是负载因子?负载因子是每个桶的平均值。如果桶的数量是k,并且其中的总值为n,那么负载因子是n/k

插入

插入操作首先计算哈希值,然后选择该索引处的桶。现在首先,线性搜索该桶以查找值。如果找到值,则不执行插入;否则,将新值添加到桶的末尾。

首先,我们创建一个用于在给定的桶数组中插入的函数,然后使用它来执行插入操作。这在您动态扩展哈希表时非常有用:

    protected boolean insert(E value, int arrayLength,
                             LinkedList<E>[] array) {
        int hashCode = value.hashCode();
        int arrayIndex = hashCode % arrayLength;
        LinkedList<E> bucket = array[arrayIndex];
        if(bucket == null){
            bucket = new LinkedList<>();
            array[arrayIndex] = bucket;
        }
        for(E element: bucket){
            if(element.equals(value)){
                return false;
            }
        }
        bucket.appendLast(value);
        totalValues++;
        return true;
    }

注意,有效的哈希码是通过将实际哈希码除以桶的数量得到的余数来计算的。这样做是为了限制哈希码的数量。

这里还有一件事要做,那就是重新哈希。重新哈希是在哈希表超过预定义的负载因子(或在某些情况下由于其他条件,但我们将使用负载因子)时动态扩展哈希表的过程。重新哈希是通过创建一个更大的桶数组的第二个桶数组并复制每个元素到新的桶集合中完成的。现在,旧的桶数组被丢弃。我们创建此函数如下:

    protected void rehash(){
        double loadFactor = ((double)(totalValues))/buckets.length;
        if(loadFactor>maximumLoadFactor){
            LinkedList<E> [] newBuckets = new LinkedList[buckets.length*2];
            totalValues = 0;
            for(LinkedList<E> bucket:buckets){
                if(bucket!=null) {
                    for (E element : bucket) {
                        insert(element, newBuckets.length, newBuckets);
                    }
                }
            }
            this.buckets = newBuckets;
        }
    }

现在我们可以有一个为值完成的insert函数:

    public boolean insert(E value){
        int arrayLength = buckets.length;
        LinkedList<E>[] array = buckets;
        boolean inserted = insert(value, arrayLength, array);
        if(inserted)
            rehash();
        return inserted;
    }

插入的复杂度

很容易看出,除非我们不得不重新散列,否则插入操作几乎是常数时间;在这种情况下,它是 O(n)。那么我们得重新散列多少次?假设负载因子为 l,桶的数量为 b。假设我们从 initialSize B 开始。由于我们每次重新散列时都会加倍,所以桶的数量将是 b = B.2 ^R;这里 R 是我们重新散列的次数。因此,元素的总数可以表示为:n = bl = Bl. 2 R。看看这个:

lg n = R + lg(Bl) .
=> R = ln n – lg (Bl) = O(lg n)

大约有 lg n 次重新散列操作,每次操作的复杂度为 O(n)。因此,插入 n 个元素的平均复杂度为 O(n lg n)。因此,插入每个元素的平均复杂度为 O(lg n)。当然,如果所有值都聚集在我们插入的单个桶中,这就不适用了。那么,每次插入的复杂度将是 O(n),这是插入操作的最坏情况复杂度。

删除与插入非常相似;它涉及在搜索后从桶中删除元素。

搜索

搜索很简单。我们计算哈希码,转到适当的桶,并在桶中进行线性搜索:

public E search(E value){
    int hash = value.hashCode();
    int index = hash % buckets.length;
    LinkedList<E> bucket = buckets[index];
    if(bucket==null){
        return null;
    }else{
        for(E element: bucket){
            if(element.equals(value)){
                return element;
            }
        }
        return null;
    }
}

搜索的复杂度

如果值分布均匀,搜索操作的复杂度是常数时间。这是因为在这种情况下,每个桶中的元素数量将小于或等于负载因子。然而,如果所有值都在同一个桶中,搜索将简化为线性搜索,其复杂度为 O(n)。所以最坏情况是线性的。在大多数情况下,搜索的平均复杂度是常数时间,这比二叉搜索树的复杂度要好。

负载因子选择

如果负载因子太大,每个桶将包含大量值,这将导致线性搜索性能不佳。但如果负载因子太小,将会有大量的未使用桶,导致空间浪费。这实际上是在搜索时间和空间之间的一种折衷。可以证明,对于均匀分布的哈希码,空桶的分数可以近似表示为 e^(-l),其中 l 是负载因子,e 是自然对数的底数。如果我们使用负载因子为 3,那么空桶的分数将大约是 e^(-3) = 0.0497 或 5%,这并不坏。在非均匀分布的哈希码(即,具有不同宽度的值的不同范围的概率不等)的情况下,空桶的分数总是更大的。空桶占用数组空间,但它们不会提高搜索时间。因此,它们是不受欢迎的。

摘要

在本章中,我们看到了一系列可搜索和可修改的数据结构。所有这些结构都允许你在插入新元素或删除元素的同时保持可搜索性,而且这种可搜索性还相当优化。我们看到了二叉搜索树,其中搜索遵循从根到树的路径。如果这些二叉搜索树是自平衡类型,那么它们在保持可搜索性的同时可以进行最优化的修改。我们研究了两种不同的自平衡树:AVL 树和红黑树。红黑树比 AVL 树平衡性较差,但它们所需的旋转次数比 AVL 树少。最后,我们探讨了哈希表,这是一种不同类型的可搜索结构。尽管在最坏情况下的搜索或插入复杂度为O(n),但在大多数情况下,哈希表提供常数时间的搜索和平均时间的插入(O(lg n))。如果一个哈希表不持续增长,那么平均插入和删除操作也将是常数时间。

在下一章中,我们将看到一些更重要的通用数据结构。

第九章. 高级通用数据结构

在本章中,我们将探讨一些常用的有趣的数据结构。我们将从优先队列的概念开始。我们将看到一些优先队列的高效实现。简而言之,本章将涵盖以下主题:

  • 优先队列抽象数据类型(ADT)

  • 二项式森林

  • 使用优先队列和堆进行排序

优先队列 ADT

优先队列就像一个队列,你可以入队和出队元素。然而,出队的元素是具有最小特征值的元素,称为其优先级。我们将使用比较器来比较元素并学习哪个具有最低优先级。我们将使用以下接口来实现优先队列:

public interface PriorityQueue<E> {
    E checkMinimum();
    E dequeueMinimum();
    void enqueue(E value);
}

我们需要从方法中获取以下行为集:

  • checkMinimum:这个方法必须返回下一个要移除的值,而不移除它。如果队列空,它必须返回 null。

  • dequeueMinimum:这个操作必须移除具有最小优先级的元素并返回它。当队列空时,它应该返回 null。

  • enqueue:这个操作应该在优先队列中插入一个新元素。

我们也希望尽可能高效地执行这些操作。我们将看到两种不同的实现方式。

堆是一个遵循仅两个约束的平衡二叉树:

  • 任何节点中的值都小于其子节点中的值。这个特性也称为堆属性。

  • 树尽可能平衡——即在下一级插入单个节点之前,任何一级都是完全填满的。

以下图显示了示例堆:

堆

图 1. 一个示例堆

直到我们实际讨论如何插入元素和移除最小元素之前,这并不真正清楚。所以让我们直接进入正题。

插入

插入的第一步是将元素插入到下一个可用的位置。下一个可用的位置是同一级别的另一个位置或下一级别的第一个位置;当然,这适用于现有级别没有空位的情况。

第二步是迭代比较元素与其父节点,并持续交换,直到元素大于父节点,从而恢复约束。以下图显示了插入的步骤:

插入

图 2. 堆插入

灰色框代表当前节点,黄色框代表父节点,其值大于当前节点。首先,新元素被插入到下一个可用的位置。它必须交换,直到满足约束。父节点是6,比2大,所以它被交换。如果父节点是3,也比2大,它也被交换。如果父节点是1,比2小,我们停止并完成插入。

最小元素的移除

父节点总是小于或等于子节点的约束保证了根节点是具有最小值的元素。这意味着移除最小元素只会导致移除顶部元素。然而,根节点的空位必须被填充,并且为了保持约束2,元素只能从最后一层删除。为了确保这一点,首先将最后一个元素复制到根节点,然后将其删除。现在必须迭代地将新根节点向下移动,直到满足约束1。以下图显示了删除操作的示例:

最小元素的移除

堆删除

然而,有一个问题,由于任何父节点都可以有两个子节点:我们应该比较和交换哪一个?答案是简单的。我们需要父节点小于两个子节点;这意味着我们必须比较和交换子节点的最小值。

复杂度分析

首先,让我们检查给定节点数量的堆的高度。第一层只包含根节点。第二层最多包含两个节点。第三层包含四个。确实,如果任何一层包含m个元素,下一层将包含这些m个元素的所有子节点。由于每个节点可以有最多两个子节点,下一层的最大元素数将是2m。这表明第l层的元素数是2^(l-1)。因此,高度为h的满堆将有总节点数1+2+4+...+ 2^(h-1) = 2^h-1。因此,高度为h的堆可以有最大2 h+1 -1个节点。那么,高度为h的堆的最小节点数是多少呢?由于只有最后一层可以有未填充的位置,堆除了最后一层必须满。最后一层至少有一个节点。因此,高度为h的堆的最小节点数是(2^(h-1) -1) + 1 = 2^(h-1)。因此,如果节点数为n,那么我们有以下内容:

2h-1 ≤ n ≤ 2h –1
=>  h-1 ≤ lg n ≤ lg(2h –1) <lg( 2h)
=> h-1 ≤ lg n < h

我们还有以下内容:

2h-1 ≤ n ≤ 2h –1
=> 2h≤ n ≤ 2h+1 –1
=>h ≤ lg (2n)< h+1

结合前面的两个表达式,我们得到以下内容:

lg n < h ≤ lg (2n)
=> h = θ(lg n)

现在,让我们假设向堆的末尾添加一个新元素是一个常数时间操作或θ(lg n)。我们将看到这个操作可以变得如此高效。现在我们来处理上滤操作的复杂度。由于在每次比较和交换操作中,我们只与父节点比较,而从不回溯,上滤操作中可能发生的最大交换次数等于堆的高度h。因此,插入操作是O(lg n)。这意味着插入操作本身也是O(lg n)

同样,对于下滤操作,我们只能进行与堆高度相同的交换次数。因此,下滤操作也是O(lg n)。现在如果我们假设移除根节点并将最后一个元素复制到根节点的操作最多是O(lg n),我们可以得出结论,删除操作也是O(lg n)

序列化表示

堆可以表示为一个没有中间空白的数字列表。技巧是在每级之后按顺序列出元素。对于具有 n 个元素的堆,位置从 1 到 n 采取以下约定:

  • 对于索引为 j 的任何元素,其父元素在索引 j/2,其中 '/' 表示整数除法。这意味着将 j 除以二并忽略任何余数。

  • 对于索引为 j 的任何元素,其子元素是 j2 和 j2+1。可以验证这和之前写的第一个公式相反。

我们示例树的表示如图所示。我们只是将树的一级一级展开。我们保留了树边,可以看到父子和关系正如之前所描述的那样:

序列化表示

堆的数组表示

在了解堆的基于数组的存储函数后,我们可以继续实现我们的堆。

数组支持的堆

数组支持的堆是一个固定大小的堆实现。我们从一个部分实现类开始:

public class ArrayHeap<E> implements PriorityQueue<E>{

    protected E[] store;

    protected Comparator<E> comparator;
    int numElements = 0;
    public ArrayHeap(int size, Comparator<E> comparator){
        store = (E[]) new Object[size];
        this.comparator = comparator;
}

对于数组的任何索引(从 0 开始),找到父元素索引。这涉及到将索引转换为基于 1 的形式(因此加 1),除以 2,然后将其转换回基于 0 的形式(因此减 1):

    protected int parentIndex(int nodeIndex){
        return ((nodeIndex+1)/2)-1;
    }

使用以下方法找到左子元素的索引:

    protected int leftChildIndex(int nodeIndex){
        return (nodeIndex+1)*2 -1;
    }

使用以下方法交换两个索引处的元素:

    protected void swap(int index1, int index2){
        E temp = store[index1];
        store[index1] = store[index2];
        store[index2] = temp;
    }
    …
}

要实现插入,首先实现一个方法,将值逐级上移直到满足约束 1。我们比较当前节点和父节点,如果父节点的值更大,则进行交换。我们继续递归向上移动:

    protected void trickleUp(int position){
        int parentIndex = parentIndex(position);

        if(position> 0 && comparator.compare(store[parentIndex], store[position])>0){
            swap(position, parentIndex);
            trickleUp(parentIndex);
        }
    }

现在我们可以实现插入。新元素总是添加到当前列表的末尾。进行检查以确保当堆满时,抛出适当的异常:

    public void insert(E value){
        if(numElements == store.length){
            throw new NoSpaceException("Insertion in a full heap");
        }
        store[numElements] = value;
        numElements++;
        trickleUp(numElements-1);
    }

类似地,对于删除,我们首先实现一个逐级下移方法,该方法比较一个元素与其子元素,并做出适当的交换,直到恢复约束 1。如果存在右子元素,则必须也存在左子元素。这是因为堆的平衡性质。在这种情况下,我们只需要与最少两个子元素进行比较,并在必要时进行交换。当左子元素存在但右子元素不存在时,我们只需要与一个元素进行比较:

    protected void trickleDown(int position){
        int leftChild = leftChildIndex(position);
        int rightChild = leftChild+1;
        if(rightChild<numElements) {
            if (comparator.compare(store[leftChild], store[rightChild]) < 0) {
                if (comparator.compare(store[leftChild], store[position]) < 0) {
                    swap(position, leftChild);
                    trickleDown(leftChild);
                }
            } else {
                if (comparator.compare(store[rightChild], store[position]) < 0) {
                    swap(position, rightChild);
                    trickleDown(rightChild);
                }
            }
        }else if(leftChild<numElements){
            if (comparator.compare(store[leftChild], store[position]) < 0) {
                  swap(position, leftChild);
                  trickleDown(leftChild);
            }
        }
    }

trickleDown 方法可用后,删除最小元素变得简单。我们首先保存当前根节点作为最小元素,然后将最后一个元素复制到根位置。然后调用 trickleDown 方法来恢复约束 1:

    public E removeMin(){
        if(numElements==0){
            return null;
        }else{
            E value  = store[0];
            store[0] = store[numElements-1];
            numElements--;
            trickleDown(0);
            return value;
        }
    }

现在我们可以将其用作优先队列的实现。因此,我们使用我们的 insertremovemin 方法实现相关方法:

@Override
public E checkMinimum() {
if(numElements==0){
return null;
}else{
return store[0];
}
}

    @Override
    public E dequeueMinimum() {
        return removeMin();
    }

    @Override
    public void enqueue(E value) {
        insert(value);
    }

这就完成了我们的基于数组的堆实现。它和我们的基于数组的队列实现有相同的问题,即我们需要事先知道其最大大小。接下来,我们将有一个具有链式二叉树形式的堆实现。

链式堆

链式堆是一个实际的二叉树,其中每个节点都持有对其子节点的引用。我们首先为我们的堆创建一个骨架结构:

public class LinkedHeap<E> implements PriorityQueue<E>{

    protected static class Node<E>{
        protected E value;
        protected Node<E> left;
        protected Node<E> right;
        protected Node<E> parent;
        public Node(E value, Node<E> parent){
            this.value = value;
            this.parent = parent;
        }
    }
    …
}

为了追踪下一个位置,每个位置都被赋予一个数字,就像我们在基于数组的表示中做的那样。我们对父节点和子节点的索引也有相同的计算。但是,在这种情况下,查找特定索引的值需要从根节点遍历到该节点。我们创建一个方法来完成这个操作。请注意,由于我们不是使用数组,位置从 1 开始。我们首先通过递归查找父节点。父节点当然是子节点位置的一半。除以 2 的余数是告诉我们节点是在父节点的左边还是右边的位。我们相应地返回节点:

    protected Node<E> findNodeAtPostion(int position){
        if(position == 1){
            return root;
        }else{
            int side = position % 2;
            int parentPosition = position / 2;
            Node<E> parent = findNodeAtPostion(parentPosition);
            switch (side){
                case 0:
                    return parent.left;
                case 1:
                    return parent.right;
            }
        }
        return null;
    }

接下来,我们转向交换。在基于数组的堆的情况下,我们可以在任何两个索引之间交换值。然而,这个通用实现在这种情况下需要多次遍历。我们只需要在节点和其父节点之间交换值。swapWithParent方法接受父节点作为参数。另一个参数是知道当前节点是父节点的左子节点还是右子节点,并相应地切换引用:

    protected void swapWithParent(Node<E> parent, boolean left){
        Node<E> node = left? parent.left:parent.right;
        Node<E> leftChild = node.left;
        Node<E> rightChild = node.right;
        Node<E> sibling = left? parent.right:parent.left;
        Node<E> grandParent = parent.parent;
        parent.left = leftChild;
        if(leftChild!=null){
            leftChild.parent = parent;
        }
        parent.right = rightChild;
        if(rightChild!=null){
            rightChild.parent = parent;
        }
        parent.parent = node;
        if(left){
            node.right = sibling;
            node.left = parent;
        }else{
            node.left = sibling;
            node.right = parent;
        }
        node.parent = grandParent;
        if(sibling!=null)
            sibling.parent = node;

        if(parent == root){
            root = node;
        }else{
            boolean parentLeft = grandParent.left==parent;
            if(parentLeft){
                grandParent.left = node;
            }else{
                grandParent.right = node;
            }
        }
    }

插入

插入涉及首先在末尾插入一个新元素,然后向上渗透。首先,我们创建一个向上渗透的方法,类似于ArrayHeap类中的方法:

protected void trickleUp(Node<E> node){
    if(node==root){
        return;
    }else if(comparator.compare(node.value, node.parent.value)<0){
        swapWithParent(node.parent, node.parent.left == node);
        trickleUp(node);
    }
}

现在,我们实现插入方法。如果树为空,我们只需添加一个根节点。否则,新元素的位子是(numElements+1)。在这种情况下,它的父节点必须是((numElements+1)/2)。它是否应该是其父节点的左子节点还是右子节点由( (numElements+1)%2)的值决定。然后创建一个新的节点并将其添加为父节点的子节点。最后,将numElements递增以跟踪元素数量:

    public void insert(E value){
        if(root==null){
            root = new Node<>(value, null);
        }else{
            Node<E> parent = findNodeAtPostion((numElements+1)/2);
            int side = (numElements+1)%2;
            Node<E> newNode = new Node<>(value, parent);
            switch (side){
                case 0:
                    parent.left = newNode;
                    break;
                case 1:
                    parent.right = newNode;
                    break;
            }
            trickleUp(newNode);
        }
        numElements++;
    }

最小元素的移除

与基于数组的堆类似,我们需要实现一个向下渗透的方法。由于如果右子节点存在,则左子节点也必须存在,如果左子节点为空,则节点没有子节点。但是,如果右子节点为空且左子节点不为空,我们只需要比较当前节点的值与左子节点的值。否则,与具有最小值的子节点进行比较和交换:

    protected void trickleDown(Node<E> node){
        if(node==null){
            return;
        }
        if(node.left == null){
            return;
        }else if(node.right == null){
            if(comparator.compare(node.left.value, node.value)<0){
                swapWithParent(node, true);
                trickleDown(node);
            }
        }else{
            if(comparator.compare(node.left.value, node.right.value)<0){
                if(comparator.compare(node.left.value, node.value)<0){
                    swapWithParent(node, true);
                    trickleDown(node);
                }
            }else{
                if(comparator.compare(node.right.value, node.value)<0){
                    swapWithParent(node, false);
                    trickleDown(node);
                }
            }
        }

    }

现在我们可以实现移除最小元素的方法。如果根为空,这意味着队列是空的。如果最后一个元素是根,只有一个元素,我们只需移除并返回它。否则,我们将根的值复制到一个临时变量中,然后将最后一个元素的值复制到根中,最后,将根向下传递:

    public E removeMin(){
        if(root==null){
            return null;
        }
        Node<E> lastElement = findNodeAtPostion(numElements);
        if(lastElement==root){
            root = null;
            numElements--;
            return lastElement.value;
        }
        E value = root.value;
        root.value = lastElement.value;
        Node<E> parent = lastElement.parent;
        if(parent.left==lastElement){
            parent.left = null;
        }else{
            parent.right=null;
        }
        numElements--;
        trickleDown(root);
        return value;
    }

最后,我们实现使它成为一个有效优先队列所需的方法:

    @Override
    public E checkMinimum() {
        return root==null? null : root.value;
    }

    @Override
    public E dequeueMinimum() {
        return removeMin();
    }

    @Override
    public void enqueue(E value) {
        insert(value);
    }

这就完成了我们使用堆实现的优先队列。现在我们将介绍实现优先队列的另一种方式。它被称为二项式森林,这是下一节的内容。

ArrayHeap 和 LinkedHeap 中操作的复杂度

我们已经看到,如果我们能在堆的末尾最多以O(lg n)的时间复杂度添加一个元素,其中 n 是堆中已有的元素数量,我们就可以以θ(lg n)的时间复杂度执行插入和删除最小元素的操作。在ArrayHeap的情况下,插入新元素意味着只需设置数组中已知索引的元素值。这是一个常数时间操作。因此,在ArrayHeap中,插入和删除最小元素的操作都是θ(lg n)。检查最小元素只是检查数组索引 0 的值,因此是常数时间。

LinkedHeap的情况下,在末尾插入新元素需要遍历树到末尾位置。由于树的高度是θ(lg n),这个操作也是θ(lg n)。这意味着,在LinkedHeap中的插入和删除操作也是θ(lg n)。检查最小元素只是检查根的值,这个操作是常数时间。

二项式森林

二项式森林是一种非常有趣的数据结构。但是,要讨论它,我们首先需要从二项式树开始。二项式树是一种树,其中两个相同大小的较小的二项式树以特定方式组合在一起:

二项式森林

二项式树

前面的图显示了二项式树如何组合成更大的二项式树。在第一行,两个高度为 1 的二项式树组合成一个新的高度为 2 的二项式树。在第二行,两个高度为 2 的二项式树组合成一个新的高度为 3 的二项式树。在最后的例子中,两个高度为 3 的二项式树组合成一个高度为 4 的二项式树,以此类推。组合在一起的两个树不是对称处理的。相反,一个树的根成为另一个树的父节点。下一图显示了序列中的另一个步骤,然后展示了看待二项式树的不同方式。在最后一行,我以不同的方式突出了子树。注意:

二项式森林

图 6.看待二项式树的另一种方式

每个子树都是一个二项树。不仅如此,第一个子树是一个高度为 1 的二项树,第二个子树的高度为 2,第三个子树的高度为 3,以此类推。所以,另一种思考二项树的方式是,它是一个根和一系列子树,这些子树是连续高度的二项树,直到比整个树的高度少一个。这两种观点在我们的讨论中都是必要的。分析想法时需要第一种观点,实现时需要第二种观点。

为什么叫它二项树?

记得我们在第四章中讨论的选择函数吗?旁路 - 函数式编程?在那里,我指出它也被称为二项式系数。让我们看看它与二项树有何关联。假设我们有一个高度为 h 的二项树,我们想要找出第 l 层的节点数量。让我们假设对于高度为 h 的树,节点数量是f(t,r),其中t=h-1r = l-1。取比高度和级别小一的变量的原因稍后将会变得清晰。基本上,t是从0而不是1开始的树的高度,r是从零开始的级别。现在这棵树显然是只有一个元素的树,或者它是由两个高度为h-1 = t的树组成的:

为什么叫它二项树?

图 7. 将其命名为二项树的理由

我们将称这两个子树为:红色子树和绿色子树。这是因为它们在前面的图中被这样着色。使用虚线突出显示级别。从图中可以看出,完整树中级别r的节点要么在红色树的级别r,要么在绿色树的级别r-1。红色和绿色树都是高度h-1的树。这意味着以下内容:f(t,r) = f(t-1,r) + f(t-1,r-1)。这个方程与我们已讨论的选择函数相同。我们唯一要检查的是边界条件。顶层(即t=0)总是只有一个节点,所以f(t,0) = 1。我们还知道树中的级别数量必须小于或等于树的高度,所以当t <r时,我们有f(t,r) = 0。因此,f(t,t) = f(t-1,t) + f(t-1,t-1) = 0 + f(t-1,t-1) = f(t-1,t-1)对于任何t都成立。同样,f(t-1,t-1) = f(t-2,t-2) = f(t-3,t-3) = … = f(0,0) = 1(因为f(t,0) = 1)。因此,所有choose函数的条件都得到了满足;因此我们可以看到f(t,r) = choose(t,r) = choose(h-1, l-1)。由于choose函数也被称为二项式系数,这为二项树提供了它的名字。

节点数量

在高度为h的二叉树中,节点数n是多少?当h=1时,我们只有一个节点。高度为 2 的树由两个高度为 1 的树组成,高度为 3 的树由两个高度为 2 的树组成,以此类推。因此,当高度增加 1 时,节点数必须是原始数的两倍。也就是说,当h=1, n=1; h=2, n=2; h=3, n=4,...。一般来说,情况应该是这样的:n = 2^(h-1)= 2t。这里的t是从零开始的树的高度。

注意,我们还可以说,树中的节点数n是每层节点数的总和,即choose(t, r),其中r是从0开始的层。这两个公式必须相等,所以总和choose(t, 0) + choose(t, 1) + choose(t, 2) + … + choose(t, t)等于2t。这是这个关系的证明。还有其他证明,但这也是一个有效的证明。

堆属性

当然,仅凭这种结构,我们无法确保有一种简单的方法来找出最小元素。因此,我们还在二叉树上强制执行堆属性:

堆属性

图 8. 合并时保持堆属性。

这是这个属性:任何节点的值都小于其每个子节点的值。当合并两个树以形成一个树时,为了保持堆属性,我们只需要做的一件事就是使用顶部节点值较小的堆作为顶级子树,而顶部节点值较大的堆作为从属子树。这在前面的图中有所展示。红色树在根节点上的值恰好高于绿色树。因此,红色树必须是从属树。堆属性确保任何二叉树的根节点包含最小元素。

二叉森林

那么,我们如何从这构建一个队列呢?首先,请注意,任何树都将有至少 2 的幂次方的节点。但是,在队列中,我们希望有任意数量的元素。因此,我们在多个树中存储这些元素。任何数字都可以表示为 2 的幂次方的和,因为任何数字都可以用二进制形式表示。假设我们需要存储 20 个节点。数字 20 的二进制是 10100。因此,我们需要两个二叉树:一个高度为 5,有 16 个节点,另一个高度为 3,有 4 个节点。队列是通过使用一组二叉树来存储节点来构建的。因此,它被称为二叉森林。

在我们讨论如何插入新元素和移除最小元素之前,我们需要了解如何合并两个二项森林。我们已经看到,元素的数量是按照二进制形式表示的。只需将数字写成二进制形式,如果存在 1,则表示有一个高度等于从右到左位置加一的树。当我们合并两个森林时,合并后森林的元素数量是需要合并的森林中元素数量的总和。这意味着结果将具有大小为二进制表示中存在 1 的树。我们可以通过执行节点源数的二进制表示的二进制加法来找到这个二进制表示。例如,让我们合并两个森林:一个有 12 个元素,另一个有 10 个元素。它们的二进制表示分别是 1100 和 1010。如果我们进行二进制加法,我们有 1100 + 1010 = 10110。这意味着原始树有高度为 3、5 和 4、5 的树,结果必须有高度为 3、4 和 6 的树。合并的方式与进行二进制加法相同。树按顺序存储,我们有空位代表二进制表示中的 0。在合并过程中,每棵树代表一个比特,它有代表该比特的节点数。我们从每个森林中取出相应的比特,并考虑进位。所有这些树必须要么为空,要么有恰好相同数量的节点。然后,我们将它们合并以创建结果比特。

要进行任何二进制加法,我们需要为每个位输入三个比特:一个来自输入,一个来自进位。我们需要计算输出位和下一个进位。同样,在合并两棵树时,我们需要从给定的输入树(两个)和一个进位树中计算输出树和进位树。一旦合并完成,插入和移除min就变得简单了。

insert操作简单地将一棵树与一个森林合并,使用一个节点即可。移除最小元素的操作稍微复杂一些。第一步是找出最小元素。我们知道每个树的最小元素都在其根节点上。因此,我们需要遍历所有树,同时比较根节点以找到最小元素及其所在的树。移除它就是移除根节点,留下一个由连续树高度组成的森林的子树列表。因此,我们可以将主森林中的子树合并以完成移除过程。

让我们看看实现。首先,我们创建一个骨架类。二项树有一个根节点,包含一个值和一系列子树。子树列表就像一个密集的森林。森林中的树列表存储在一个链表中。我们使用DoublyLinkedList是因为我们需要移除最后一个元素:

public class BinomialForest<E> implements PriorityQueue<E>{

    protected Comparator<E> comparator;
    protected static class BinomialTree<E>{
        E value;
        LinkedList<BinomialTree<E>> subTrees = new LinkedList<>();
        public BinomialTree(E value){
            this.value = value;
        }
    }

    public BinomialForest(Comparator<E> comparator){
        this.comparator = comparator;
    }

    DoublyLinkedList<BinomialTree<E>> allTrees = new DoublyLinkedList<>();
    …
}

如前所述,我们现在将开始合并操作。首先,我们需要合并两棵树,我们将使用它们来合并两个森林。合并两棵树是一个简单的常数时间操作。与空树合并不会改变树。正在合并的两棵树应该具有相同的大小。我们需要简单地比较根元素。根值较小的树将作为子树获得:

    protected BinomialTree<E> merge(BinomialTree<E> left, 
      BinomialTree<E> right){

        if(left==null){
            return right;
        }else if(right==null){
            return left;
        }
        if(left.subTrees.getLength() != right.subTrees.getLength()){
            throw new IllegalArgumentException(
                  "Trying to merge two unequal trees of sizes " +
                    left.subTrees.getLength() + " and " + right.subTrees.getLength());
        }
        if(comparator.compare(left.value, right.value)<0){
            left.subTrees.appendLast(right);
            return left;
        }else{
            right.subTrees.appendLast(left);
            return right;
        }
    }

由于我们希望在常数时间内检查最小元素,就像在堆中一样,我们将最小元素存储在一个状态变量中。我们还将存储它在allTrees列表中的位置:

    BinomialTree<E> minTree = null;
    int minTreeIndex = -1;

我们将定义一个方法来找出和更新变量。由于任何树中最小的元素是根,我们只需要遍历根来找到最小元素:

    protected void updateMinTree(){
        if(allTrees.getLength()==0){
            minTree = null;
            minTreeIndex = -1;
        }
        E min = null;
        int index = 0;
        for(BinomialTree<E> tree:allTrees){
            if(tree==null){
                index++;
                continue;
            }
            if(min == null || comparator.compare(min, tree.value)>0){
                min = tree.value;
                minTree = tree;
                minTreeIndex = index;
            }
            index++;
        }
    }

要实现两个森林的合并,我们首先需要实现如何从两个输入树和一个携带树中计算输出并将树携带出来。这些方法相当简单。我们需要理解,如果它们不为空,输入和携带必须具有相同的大小。输出的高度必须与输出的高度相同,携带的高度必须比输入的高度多一个:

    protected BinomialTree<E> computeOutputWithoutCarry(BinomialTree<E> lhs, BinomialTree<E> rhs, BinomialTree<E> carry){
        if(carry==null){
            if(lhs==null){
                return rhs;
            }else if(rhs==null){
                return lhs;
            }else{
                return null;
            }
        }else{
            if(lhs==null && rhs==null){
                return carry;
            }else if(lhs == null){
                return null;
            }else if(rhs == null){
                return null;
            }else{
                return carry;
            }
        }
    }
    protected BinomialTree<E>  computeCarry(
      BinomialTree<E> lhs, BinomialTree<E> rhs, BinomialTree<E> carry){
        if(carry==null){
            if(lhs!=null && rhs!=null){
                return merge(lhs, rhs);
            }else{
                return null;
            }
        }else{
            if(lhs==null && rhs==null){
                return null;
            }else if(lhs == null){
                return merge(carry, rhs);
            }else if(rhs == null){
                return merge(carry, lhs);
            }else{
                return merge(lhs, rhs);
            }
        }
    }

我们还需要增强我们命令式LinkedList实现中的ListIterator类,以便在遍历过程中修改任何节点的值。我们使用以下实现来完成这个任务:

    public class ListIterator implements Iterator<E> {
        protected Node<E> nextNode = first;
        protected Node<E> currentNode = null;
        protected Node<E> prevNode = null;

        @Override
        public boolean hasNext() {
            return nextNode != null;
        }

        @Override
        public E next() {
            if (!hasNext()) {
                throw new IllegalStateException();
            }
            prevNode = currentNode;
            currentNode = nextNode;
            nextNode = nextNode.next;
            return currentNode.value;
        }

        @Override
        public void remove() {
            if(currentNode==null || currentNode == prevNode){
                throw new IllegalStateException();
            }
            if(currentNode==first){
                first = nextNode;
            }else{
                prevNode.next = nextNode;
            }
            currentNode=prevNode;

        }

        public void setValue(E value){
            currentNode.value = value;
        }

    }

有这些方法可用,我们可以实现两个森林或两个树列表的合并:

    protected void merge(LinkedList<BinomialTree<E>> rhs){
        LinkedList<BinomialTree<E>>.ListIterator lhsIter
          = (LinkedList<BinomialTree<E>>.ListIterator)allTrees.iterator();
        Iterator<BinomialTree<E>> rhsIter = rhs.iterator();
        BinomialTree<E> carry = null;
        while(lhsIter.hasNext() || rhsIter.hasNext()){
            boolean lhsHasValue = lhsIter.hasNext();
            BinomialTree<E> lhsTree = lhsHasValue? lhsIter.next():null;
            BinomialTree<E> rhsTree = rhsIter.hasNext()? rhsIter.next():null;
            BinomialTree<E> entry = computeOutputWithoutCarry(lhsTree, rhsTree, carry);
            carry = computeCarry(lhsTree, rhsTree, carry);
            if(lhsHasValue) {
                lhsIter.setValue(entry);
            }else{
                this.allTrees.appendLast(entry);
            }
        }
        if(carry!=null){
            this.allTrees.appendLast(carry);
        }
        updateMinTree();
    }

Insert方法在合并可用时实现起来非常简单。只需合并一个包含值为 1 的树的列表:

    public void insert(E value){
        BinomialTree<E> newTree = new BinomialTree<E>(value);
        DoublyLinkedList<BinomialTree<E>> newList 
               = new DoublyLinkedList<>();
        newList.appendLast(newTree);
        merge(newList);
    }

移除最小元素稍微复杂一些。它涉及到移除具有最小值的树,然后将其根视为最小元素。一旦完成,子树需要与原始森林合并。如果正在移除最后一棵树,我们必须实际上从列表中移除它。这就像在二进制表示中不写前导零一样。否则,我们只设置值为null,这样我们知道它是一个零位:

    public E removeMin(){
        if(allTrees.getLength()==0){
            return null;
        }
        E min = minTree.value;
        if(minTreeIndex==allTrees.getLength()-1){
            allTrees.removeLast();
        }else {
            allTrees.setValueAtIndex(minTreeIndex, null);
        }
        merge(minTree.subTrees);
        return min;
    }

最后,我们可以实现所需的方法,以便将其用作优先队列:

    @Override
    public E dequeueMinimum() {
        return removeMin();
    }

    @Override
    public void enqueue(E value) {
        insert(value);
    }

    @Override
    public Iterator<E> iterator() {
        return null;
    }

这完成了我们对二项队列的实现。

二项森林中操作复杂度

我们已经知道,高度为h的二项树中的节点数是2h-1。问题是,如果我们想存储 n 个元素,森林中树的最高高度应该是多少?我们已经看到,我们需要的树是按照整数n的二进制表示来确定的。n的二进制表示中最显著的位是(lg n)的 floor 值,即小于或等于lg n的最大整数。我们将这个值写成lg n。表示这个位的树的长度是1 + lg n。持有森林中树的列表的长度也是1 + lg n= θ(lg n)。在插入和删除新元素的情况下,都涉及到合并操作。合并操作对于每一对输入树和一位进位来说是常数时间。所以,两个森林合并操作的次数是这个:常数乘以最大森林中的树的数量 = θ(lg n),其中n是最大森林中树的数量。

在插入时,我们只是合并一个只包含一棵树和一个元素的新的森林。所以这个操作是θ(lg n),其中 n 是原始森林中元素的数量。

删除过程涉及两个步骤。第一步是删除最小元素。这涉及到一个常数时间操作,用于删除包含最小元素的树,以及一个合并操作,如之前所见,其复杂度为θ(lg n)。第二步/操作是更新包含最小元素的树。这涉及到扫描所有树的根,因此,其复杂度也是θ(lg n),就像合并操作一样。所以,整体上,删除过程也是θ(lg n)

检查最小元素当然是常数时间,因为我们已经引用了它。

使用优先队列进行排序

由于优先队列总是返回最小元素,如果我们插入所有输入元素然后不断出队,它们就会按顺序出队。这可以用来对元素列表进行排序。在我们的例子中,我们将添加一个名为LinkedList实现的新方法。这个实现使用PriorityQueue对元素进行排序。首先将所有元素插入到优先队列中。然后,出队元素并将它们重新连接到链表中:

public void sort(Comparator<E> comparator){
    PriorityQueue<E> priorityQueue = new LinkedHeap<E>(comparator);

    while (first!=null){
        priorityQueue.enqueue(getFirst());
        removeFirst();
    }

    while (priorityQueue.checkMinimum()!=null){
        appendLast(priorityQueue.dequeueMinimum());
    }
}

入队和出队都具有θ(lg n)的复杂度,我们必须对每个元素进行入队和出队操作。我们已经看到了这一点:lg 1 + lg 2 + … + lg n = θ(n lg n)。所以,元素的入队和出队是θ(n lg n),这意味着排序是θ(n lg n),这是渐近最优的。

堆排序的就地实现

我们可以使用基于数组的堆实现来对数组的元素进行原地排序。技巧是使用相同的数组来支持堆。一开始,我们只需从数组的开始处将元素插入堆中。我们通过替换堆中的数组来实现这一点,除了传递的那个。由于堆也使用从开始的空间,它不会覆盖我们尚未插入的元素。在出队元素时,我们从数组的末尾开始保存它们,因为这是堆正在释放的部分。这意味着我们希望首先出队的是最大的元素。这可以通过简单地使用一个与传递的相反的比较器来实现。我们将这个静态方法添加到我们的 ArrayHeap 类中:

public static <E> void heapSort(E[] array, Comparator<E> comparator){

    ArrayHeap<E> arrayHeap = new ArrayHeap<E>(0, (a,b) -> comparator.compare(b,a));

    arrayHeap.store = array;

    for(int i=0;i<array.length;i++){
        arrayHeap.insert(array[i]);
    }

    for(int i=array.length-1;i>=0;i--){
        array[i] = arrayHeap.removeMin();
    }
}

这实际上是一种使用先前所示优先队列的排序方法,只不过在这里我们与优先队列共享数组。因此,这种排序也是 θ(n lg n),就像之前一样。

摘要

在本章中,我们讨论了优先队列及其实现。优先队列是重要的数据结构,在许多问题中使用。我们看到了优先队列的两种实现,一个是堆,另一个是二叉森林。我们还看到了如何使用优先队列进行排序,这是渐近最优的。这种变化的变体允许我们使用基于数组的堆原地排序数组。

在下一章中,我们将讨论图的概念,这是一种非常有用、几乎无处不在的 ADT,以及用于许多实际应用的数据结构。

第十章:图的概念

图是树的推广。在树中,每个节点只有一个父节点。在图中,一个节点可以有多个父节点。最常见的方式来思考图是将它视为顶点和边的集合。顶点就像点,边就像连接点的线。在图的一般概念中,没有限制哪些顶点可以通过边连接。这允许图模拟多种现实生活中的概念。例如,互联网是一个图,其中顶点是网页,边是页面之间的超链接。一个社交网络网站,如 Facebook,有一个包含个人资料的图,其中顶点是个人资料,边是个人资料之间的友谊。每个软件都有一个依赖图,称为依赖关系图,其中顶点是使用的不同软件库,边是软件库之间的依赖关系。图的例子无穷无尽。在本章中,我们将讨论以下主题:

  • 不同类型的图

  • 图的 ADT(抽象数据类型)

  • 图在内存中的表示

  • 图的遍历

  • 循环检测

  • 拓扑树

  • 最小生成树

什么是图?

图是由顶点和连接顶点的边组成的一组。图 1展示了图的一个示例的视觉表示。这里有几个需要注意的特点,我们将在下一节讨论:

什么是图?

图 1:无向图的示例

  • 无向图:无向图是一种边没有方向的图,如图图 1所示。

  • 有向图:这是一种边有方向的图。

  • 路径:路径是一系列连接一组彼此不同的顶点的边,除了第一个和最后一个顶点可能相同之外。例如,在图 1中,边ABBDDE代表一个路径。它也可以描述为ABDE路径,该路径不重复其顶点。在有向图中,边必须仅按指定的方向遍历,才能形成构成路径所需的边的序列。

  • :环是一个至少涉及两个顶点的路径;它从同一个顶点开始和结束。例如,在图 1中,路径DCED是一个环。

  • :环是一个连接节点到自身的边。在图 1中,顶点A有一个环。

  • 子图:图的子图是另一种类型的图,其中所有的边和顶点都与原始图的边和顶点相同。例如,在图 1中,节点ABC以及边ABBC代表一个子图。

  • 连通图:连通图是一个存在从任意顶点开始并结束在任意但不同的顶点的路径的图。图 1中的图不是连通的。但是,顶点HIJ以及边HIIJJH表示一个连通子图:什么是图?

    图 2. 有向图的例子

  • :树是一个连通但无向图,没有环或循环。图 3展示了树的例子。请注意,这与我们之前研究过的树略有不同。这棵树没有特定的根。这棵树中的节点没有特定的父节点,任何节点都可以作为根:什么是图?

    图 3. 树的例子

  • 森林:森林是一个无连接、无向图,没有环或循环。你可以把森林想象成树集合。单独一棵树也是一个森林。换句话说,森林是零棵或多棵树的集合。

  • 完全图:完全图是在给定顶点数的情况下具有最大边数的无向图。它也有约束,即两个给定顶点之间只能有一条边,没有环。图 4展示了完全图的例子。对于一个顶点集V和边集E的完全图,|E| = |V| ( |V| - 1) / 2。很容易看出为什么会这样。每个顶点都会与其他|V| - 1个节点之间有一条边。这总共是|V| ( |V| - 1)条边。然而,在这种方法中,每条边都被计算了两次,一次为它的两个顶点之一。因此,完全图中的实际边数是|V| ( |V| - 1) / 2什么是图?

    图 4. 完全图

图形抽象数据类型

我们现在将定义表示图的图形抽象数据类型应该做什么。稍后,我们将讨论这个 ADT 的不同实现。一个图必须支持以下操作:

  • 添加顶点:这添加一个新顶点

  • 移除顶点:这移除一个顶点

  • 添加边:这添加一条新边;在我们的图中,为了简单起见,我们将在两个顶点之间允许最多一条边

  • 移除边:这移除一条边

  • 相邻:这检查两个给定的顶点是否相邻,即是否存在给定节点之间的边

  • 邻居:这返回与给定顶点相邻的顶点列表

  • 获取顶点值:这获取存储在顶点中的值

  • 设置顶点值:这存储一个值在顶点中

  • 获取边值:这获取存储在边中的值

  • 设置边值:这设置存储在边中的值

  • 无向图:这返回图是否为无向图

  • 获取所有顶点:这返回包含所有顶点的自平衡二叉搜索树

  • 最大顶点 ID:这返回顶点的最高 ID

我们算法的依赖性将取决于上述操作在图数据结构中可用。以下 Java 接口是这个 ADT 的实现:

public interface Graph<V, E> {
    int addVertex();
    void removeVertex(int id);
    void addEdge(int source, int target);
    void removeEdge(int source, int target);
    boolean isAdjacent(int source, int target);
    LinkedList getNeighbors(int source);
    void setVertexValue(int vertex, V value);
    V getVertexValue(int vertex);
    void setEdgeValue(int source, int target, E value);
    E getEdgeValue(int source, int target);
    boolean isUndirected();
    BinarySearchTree<Integer> getAllVertices();
    int maxVertexID();
}

我们通过 ID 标识每个顶点;边通过源顶点和目标顶点来标识。在无向图的情况下,源点和目标点可以互换。但在有向图的情况下,它们是不可互换的。

现在我们有了 ADT,我们希望有一个实现。为了实现图数据结构,我们需要在内存中选择一种表示方式。

图在内存中的表示

图可以通过三种主要方式表示:邻接矩阵、邻接表和关联矩阵。

邻接矩阵

邻接矩阵是一个矩阵,一个值表,其中每个值代表一条边,行和列都代表顶点。矩阵中的值可以是条目成员。边的值可以存储在矩阵本身中。也可以有一个特殊值来表示边的不存在。以下图像显示了图 1的邻接矩阵,其中边的值表示对应顶点之间的边数:

邻接矩阵

关于邻接矩阵可以注意以下事项:

  • 行用于表示边的源点,列用于表示边的目标点

  • 在无向图的情况下,源点和目标点是不可区分的,因此邻接矩阵是对称的

以下代码提供了一个使用邻接矩阵实现的图 ADT。我们使用二维数组来存储矩阵。任何顶点的 ID 直接用作数组的索引。这适用于存储顶点内部值的数组以及存储在边中的值,甚至边的存在性。当我们删除一个顶点时,我们不释放其空间;我们这样做是为了确保新顶点的 ID 不会移动。这提高了查找性能,但在资源方面是浪费的:

public class AdjacencyMatrixGraphWithSparseVertex<V,E> implements Graph<V, E> {

    private static class NullEdgeValue{};

我们创建了两个特殊对象来表示边和顶点;这些对象尚未持有值。一个空引用指向不存在边或顶点:

    private NullEdgeValue nullEdge = new NullEdgeValue();
    private NullEdgeValue nullVertex = new NullEdgeValue();

    Object [][] adjacencyMatrix = new Object[0][];
    Object[] vertexValues = new Object[0];

一个标志位用于确定图是否无向:

    boolean undirected;

    public AdjacencyMatrixGraphWithSparseVertex(boolean undirected){
        this.undirected = undirected;
    }

添加顶点涉及创建一个新的矩阵和一个顶点值数组,并将所有旧值复制到其中:

    @Override
    public int addVertex() {
        int numVertices = adjacencyMatrix.length;
        Object [][] newAdjacencyMatrix = new Object[numVertices+1][];
        for(int i=0;i<numVertices;i++){
            newAdjacencyMatrix[i] = new Object[numVertices+1];
            System.arraycopy(adjacencyMatrix[i],0, newAdjacencyMatrix[i], 0, numVertices);
        }
        newAdjacencyMatrix[numVertices] = new Object[numVertices+1];
        adjacencyMatrix = newAdjacencyMatrix;
        Object [] vertexValuesNew = new Object[vertexValues.length+1];
        System.arraycopy(vertexValues,0, vertexValuesNew, 0, vertexValues.length);
        vertexValuesNew[vertexValues.length] = nullVertex;
        vertexValues = vertexValuesNew;
        return numVertices;
    }

由于我们没有释放任何空间,删除顶点只需将值设置为 null。请注意,删除顶点必须伴随着所有相关边的删除,这是通过循环完成的:

    @Override
    public void removeVertex(int id) {
        vertexValues[id] = null;
        for(int i=0;i<adjacencyMatrix.length;i++){
            adjacencyMatrix[id][i] = null;
            adjacencyMatrix[i][id] = null;
        }
    }

添加边涉及在邻接矩阵中设置特定位置。如果图是无向的,将会有两次更新。这是因为源点和目标点可以互换,而邻接矩阵始终是对称的:

    @Override
    public void addEdge(int source, int target) {
        if(adjacencyMatrix[source][target] == null){
            adjacencyMatrix[source][target] = nullEdge;
            if(undirected){
                adjacencyMatrix[target][source] = nullEdge;
            }
        }else{
            throw new IllegalArgumentException("Edge already exists");
        }
    }

以下操作是最简单的,因为它只涉及将一个边设置为 null。在无向图的情况下,会有一个相应的更新来交换源和目标:

    @Override
    public void removeEdge(int source, int target) {
        adjacencyMatrix[source][target] = null;
        if(undirected){
            adjacencyMatrix[target][source] = null;
        }
    }

以下是一个检查邻接矩阵的简单操作:

    @Override
    public boolean isAdjacent(int source, int target) {
        return adjacencyMatrix[source][target] != null;
    }

对于任何给定的源,找到矩阵中同一行的所有边并将它们添加到一个我们可以返回的链表中。请注意,在有向图中,它只按正向遍历边:

    @Override
    public LinkedList getNeighbors(int source) {
        LinkedList<Integer> neighborList = new LinkedList<>();
        for(int i=0;i<adjacencyMatrix.length;i++){
            if(adjacencyMatrix[source][i]!=null){
                neighborList.appendLast(i);
            }
        }
        return neighborList;
    }

我们将顶点的所有值存储在不同的数组中:

    @Override
    public void setVertexValue(int vertex, V value) {
        vertexValues[vertex] = value;
    }

    @Override
    public V getVertexValue(int vertex) {
        if(vertexValues[vertex]!=nullVertex)
            return (V)vertexValues[vertex];
        else
            throw new IllegalArgumentException("Vertex "+vertex
                 +" does not exist");
    }

存储在边中的值可以存储在邻接矩阵本身中:

    @Override
    public void setEdgeValue(int source, int target, E value) {
        adjacencyMatrix[source][target] = value;
        if(undirected){
            adjacencyMatrix[target][source] = value;
        }
    }

    @Override
    public E getEdgeValue(int source, int target) {
        if(adjacencyMatrix[source][target] != nullEdge) {
            return (E) adjacencyMatrix[source][target];
        }else {
            return null;
        }
    }

@Override
    public boolean isUndirected() {
        return undirected;
    }

    @Override
    public BinarySearchTree<Integer> getAllVertices() {
        BinarySearchTree<Integer> allVertices = new RedBlackTree<>();
        for(int i=0;i<vertexValues.length;i++){
            if(vertexValues[i]!=null){
                allVertices.insertValue(i);
            }
        }
        return allVertices;
    }

    @Override
    public int maxVertexID() {
        return vertexValues.length-1;
    }
}

稀疏邻接矩阵图操作的复杂度

现在我们来分析我们已讨论的操作的复杂度:

  • 添加顶点: 添加顶点需要我们创建一个新的二维数组,其长度和 w 的复杂度为 idth |V|,然后将整个旧内容复制到新数组中。在这里,|V| 代表顶点集合 V 的基数。那么邻接矩阵的大小是多少呢?它是一个长度或宽度等于 |V| 的方阵,因此其大小是 |V|²。因此,添加新边的复杂度是 θ(|V|²)

  • 移除顶点: 移除顶点涉及到移除与给定顶点相对应的所有边。一个顶点可以关联的最大边数是 |V|,这是邻接矩阵中行或列的长度。我们必须设置包含要删除的顶点的行和列中的所有值,因此需要更改的值的数量计算为 2|V| - 1。减一的这部分来自于这样一个事实,即行和列有一个共同的边,代表正在删除的节点上的环。这个共同的边在行和列中都计算了两次。因此,其中之一必须停止。因此,这个操作的复杂度是 θ(2|V| - 1) = θ(|V|)

  • 添加边和移除边: 添加边就像在邻接矩阵的单个条目中设置一个特殊值一样简单。它具有这种复杂度:θ(1)。移除边只是在同一位置设置 null。

  • 相邻: 这个操作涉及检查给定源和目标之间是否存在边。它检查邻接矩阵中的一个条目,因此具有这种复杂度:θ(1)

  • 邻居: 这个操作需要读取邻接矩阵行的所有值。因此,它需要读取 |V| 个值,并可能将它们添加到一个链表中。因此,这个操作的复杂度是 θ( |V| )

  • 在顶点和边上设置和获取值: 这些操作需要将单个值读入或读出邻接矩阵。这些操作都是 θ(1)

  • 获取所有顶点: 这涉及到遍历所有顶点并将它们插入到一个二叉搜索树中。因此,这个操作的复杂度是 θ( |V| lg |V|)

更高效的空间邻接矩阵图

上述图实现的问题在于,当我们删除顶点时无法恢复任何空间。恢复空间的问题在于它会改变后来添加的顶点的索引。为了避免这种情况,我们可以选择顶点的 ID 与其在数组中的索引位置分开。如果我们这样做,我们需要能够使用给定的 ID 搜索顶点的索引。这种映射可以通过一个自平衡的二分搜索树来完成,这正是我们在这里要做的。

首先,我们创建一个代表图顶点的单独类。想法是允许对顶点的 ID 进行比较。不同的图实现可以扩展这个类以适应图顶点中的额外数据:

public class GraphVertex<V> implements Comparable<GraphVertex<V>>{
    int id;
    V value;

    public GraphVertex(int id, V value) {
        this.id = id;
        this.value = value;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public V getValue() {
        return value;
    }

    public void setValue(V value) {
        this.value = value;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        GraphVertex<?> that = (GraphVertex<?>) o;
        return id == that.id;
    }

    @Override
    public int hashCode() {
        return id;
    }

    @Override
    public int compareTo(GraphVertex<V> o) {
        return id - o.id;
    }
}

有了这个类,我们可以实现基于邻接矩阵的图实现,具有密集的顶点和边表示:

public class AdjacencyMatrixGraphWithDenseVertex<V,E> implements Graph<V, E> {

首先,我们扩展 GraphVertex 类以包括一个 addition 字段,该字段存储顶点在邻接矩阵和用于存储顶点值的数组中的索引:

    class Vertex extends GraphVertex<V>{
        int internalIndex;

        public Vertex(int id, V value, int internalIndex) {
            super(id, value);
            this.internalIndex = internalIndex;
        }

        public int getInternalIndex() {
            return internalIndex;
        }

        public void setInternalIndex(int internalIndex) {
            this.internalIndex = internalIndex;
        }
    }

nextId 变量用于存储将要使用的下一个 ID:

    private int nextId;

用特殊值来表示空顶点和边:

    private static class NullValue {};
    private NullValue nullEdge = new NullValue();

    Object [][] adjacencyMatrix = new Object[0][];

下面的二分搜索树存储了顶点及其在数组中的索引:

    RedBlackTree<GraphVertex<V>> vertices = new RedBlackTree<>();
    boolean undirected;

    public AdjacencyMatrixGraphWithDenseVertex(boolean undirected){
        this.undirected = undirected;
    }

添加过程涉及与之前相同的操作,除了额外的生成新 ID 并在搜索树中存储条目的操作:

    @Override
    public int addVertex() {
        int id = nextId++;
        int numVertices = adjacencyMatrix.length;
        Object [][] newAdjacencyMatrix = new Object[numVertices+1][];
        for(int i=0;i<numVertices;i++){
            newAdjacencyMatrix[i] = new Object[numVertices+1];
            System.arraycopy(adjacencyMatrix[i],0, newAdjacencyMatrix[i], 0, numVertices);
        }
        newAdjacencyMatrix[numVertices] = new Object[numVertices+1];

        vertices.insertValue(new Vertex(id, null, adjacencyMatrix.length));
        adjacencyMatrix = newAdjacencyMatrix;
        return numVertices;
    }

现在删除顶点实际上涉及创建一个较小的邻接矩阵并复制所有边,除了与被删除顶点关联的边:

    @Override
    public void removeVertex(int id) {
        BinaryTree.Node<GraphVertex<V>> node = vertices.searchValue(new GraphVertex<V>(id, null));
        if(node!=null){
            int internalId = ((Vertex)(node.getValue())).getInternalIndex();
            int numVertices = adjacencyMatrix.length;
            Object [][] newAdjacencyMatrix = new Object[numVertices-1][];

首先,复制被删除顶点所在行之前的全部行:

            for(int i=0;i<internalId;i++){
                newAdjacencyMatrix[i] = new Object[numVertices-1];
                System.arraycopy(adjacencyMatrix[i],0, newAdjacencyMatrix[i], 0, internalId);
                System.arraycopy(adjacencyMatrix[i],internalId+1, newAdjacencyMatrix[i], internalId, numVertices-internalId-1);
            }

然后,复制被删除顶点所在行之后的全部行:

            for(int i=internalId+1;i<numVertices;i++){
                newAdjacencyMatrix[i-1] = new Object[numVertices-1];
                System.arraycopy(adjacencyMatrix[i],0, newAdjacencyMatrix[i-1], 0, internalId);
                System.arraycopy(adjacencyMatrix[i],internalId+1, newAdjacencyMatrix[i-1], internalId, numVertices-internalId-1);
            }
            adjacencyMatrix = newAdjacencyMatrix;

现在,调整被删除顶点之后添加的所有顶点的索引。我们通过先序遍历树并在适当的时候更新索引来完成此操作:

            vertices.traverseDepthFirstNonRecursive((gv)->{
                if(((Vertex)gv).getInternalIndex()>internalId)
                    ((Vertex)gv).setInternalIndex(((Vertex)gv).getInternalIndex()-1);
            }, BinaryTree.DepthFirstTraversalType.PREORDER);
            vertices.deleteValue(new GraphVertex<>(id, null));
        }else{
            throw new IllegalArgumentException("Vertex with id "+id
            +" does not exist");
        }
    }

添加边涉及在邻接矩阵中设置一个条目。然而,在这样做之前,我们需要查找顶点的索引:

    @Override
    public void addEdge(int source, int target) {
        BinaryTree.Node<GraphVertex<V>> sNode = vertices.searchValue(
                new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode = vertices.searchValue(
                new GraphVertex<V>(target, null));
        if(sNode!=null && tNode!=null) {
            int s = ((Vertex)(sNode.getValue())).getInternalIndex();
            int t = ((Vertex)(tNode.getValue())).getInternalIndex();
            if(adjacencyMatrix[s][t] == null){
                adjacencyMatrix[s][t] = nullEdge;
                if(undirected){
                    adjacencyMatrix[t][s] = nullEdge;
                }
            }else{
                throw new IllegalArgumentException("Edge already exists");
            }
        }else{
            throw new IllegalArgumentException("Non-existent ID");
        }

    }

这与添加边相同,只是我们将邻接矩阵中相应的条目改为 null:

    @Override
    public void removeEdge(int source, int target) {
        BinaryTree.Node<GraphVertex<V>> sNode = vertices.searchValue(
                new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode = vertices.searchValue(
                new GraphVertex<V>(target, null));
        if(sNode!=null && tNode!=null) {
            int s = ((Vertex)(sNode.getValue())).getInternalIndex();
            int t = ((Vertex)(tNode.getValue())).getInternalIndex();
            adjacencyMatrix[s][t] = null;
        }else{
            throw new IllegalArgumentException("Non-existent ID");
        }

    }

检查两个顶点是否相邻涉及像之前一样在邻接矩阵中查找一个值。但同样,我们首先必须查找顶点的索引:

    @Override
    public boolean isAdjacent(int source, int target) {
        BinaryTree.Node<GraphVertex<V>> sNode = vertices.searchValue(
                new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode = vertices.searchValue(
                new GraphVertex<V>(target, null));
        if(sNode!=null && tNode!=null) {
            int s = ((Vertex)(sNode.getValue())).getInternalIndex();
            int t = ((Vertex)(tNode.getValue())).getInternalIndex();
            return adjacencyMatrix[s][t] != null;
        }else{
            throw new IllegalArgumentException("Non-existent ID");
        }

    }

获取邻居列表稍微复杂一些。我们没有一种搜索机制允许我们通过索引来搜索 ID。因此,我们不是读取邻接矩阵中的一行,而是简单地先序遍历搜索树并检查邻接矩阵中是否存在与顶点相关的边。我们只在源顶点和问题顶点之间存在边时添加顶点:

    @Override
    public LinkedList<Integer> getNeighbors(int source) {
        BinaryTree.Node<GraphVertex<V>> node = vertices.searchValue(
                        new GraphVertex<V>(source, null));
        if(node!=null){
            LinkedList<Integer> neighborsList = new LinkedList<>();
            int sourceInternalIndex = ((Vertex) node.getValue()).getInternalIndex();
            vertices.traverseDepthFirstNonRecursive((gv)->{
                int targetInternalIndex = ((Vertex) gv).getInternalIndex();
                if(adjacencyMatrix[sourceInternalIndex][targetInternalIndex]!=null)
                    neighborsList.appendLast(gv.getId());
            }, BinaryTree.DepthFirstTraversalType.INORDER);
            return neighborsList;
        }else{
            throw new IllegalArgumentException("Vertex with id "+source+" does not exist");
        }

    }

将值设置到边和顶点中或从边和顶点中获取值的过程与之前相同,除了在使用之前需要从顶点的 ID 中查找索引:

    @Override
    public void setVertexValue(int vertex, V value) {
        BinaryTree.Node<GraphVertex<V>> node =
                vertices.searchValue(
                        new GraphVertex<V>(vertex, null));
        if(node!=null){
            node.getValue().setValue(value);
        }else{
            throw new IllegalArgumentException("Vertex with id "+vertex+" does not exist");
        }
    }

    @Override
    public V getVertexValue(int vertex) {
        BinaryTree.Node<GraphVertex<V>> node =
                vertices.searchValue(
                        new GraphVertex<V>(vertex, null));
        if(node!=null){
            return node.getValue().getValue();
        }else{
            throw new IllegalArgumentException("Vertex with id "+vertex+" does not exist");
        }
    }

    @Override
    public void setEdgeValue(int source, int target, E value) {
        BinaryTree.Node<GraphVertex<V>> sNode = vertices.searchValue(
                new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode = vertices.searchValue(
                new GraphVertex<V>(target, null));
        if(sNode!=null && tNode!=null) {
            int s = ((Vertex)(sNode.getValue())).getInternalIndex();
            int t = ((Vertex)(tNode.getValue())).getInternalIndex();
            adjacencyMatrix[s][t] = value;
            if (undirected) {
                adjacencyMatrix[t][s] = value;
            }
        }else{
            throw new IllegalArgumentException("Non-existent ID");
        }
    }

    @Override
    public E getEdgeValue(int source, int target) {
        BinaryTree.Node<GraphVertex<V>> sNode = vertices.searchValue(
                new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode = vertices.searchValue(
                new GraphVertex<V>(target, null));
        if(sNode!=null && tNode!=null) {
            int s = ((Vertex)(sNode.getValue())).getInternalIndex();
            int t = ((Vertex)(tNode.getValue())).getInternalIndex();
            return (E) adjacencyMatrix[s][t];
        }else{
            throw new IllegalArgumentException("Non-existent ID");
        }
    }

@Override
    public boolean isUndirected() {
        return undirected;
    }

    @Override
    public BinarySearchTree<Integer> getAllVertices() {
        BinarySearchTree<Integer> allVertices = new RedBlackTree<>();
        vertices.traverseDepthFirstNonRecursive(
          (v) -> allVertices.insertValue(v.getId()),
           BinaryTree.DepthFirstTraversalType.PREORDER);
        return allVertices;
    }
    @Override
    public int maxVertexID() {
        return nextId-1;
    }
}

基于密集邻接矩阵的图的运算复杂度

以下是我们刚刚在基于密集邻接矩阵的图中讨论的运算的复杂度:

  • 添加顶点:添加顶点的操作仍然具有相同的 θ(|V|²)* 操作,用于创建一个新的邻接矩阵并复制所有旧值。在搜索树中插入新顶点的附加操作是 θ(lg |V|)。因此,整个操作仍然是 θ(|V|²)

  • 删除顶点:在这里删除顶点的操作遵循重新创建邻接矩阵并复制所有旧值的相同操作,这是 θ(|V|²)。从搜索树中删除顶点的操作是 θ(lg |V|)。因此,整个操作是 θ(|V|²)

  • 添加边和删除边:更新邻接矩阵中的条目的操作仍然是 θ(1)。然而,现在我们需要在搜索树中进行两次查找,以确定源和目标索引。这两次查找都是 θ(lg |V|)。因此,整个操作是 θ(lg |V|)

  • 相邻:这也同样是 θ(lg |V|),原因与前面提到的相同点。

  • 邻居:遍历搜索树是 θ(|V|),并且对于遍历的每个顶点,我们创建一个固定数量的操作。查找源顶点的索引是 θ(lg |V|)。因此,整个操作仍然是 θ(|V|)

  • 在顶点和边设置和获取值:这些操作需要固定数量的查找(一个或两个),然后进行常数时间的设置或获取适当值的操作。查找是 θ(lg |V|),因此整个操作也是 θ(lg |V|)

  • 获取所有顶点:与之前的实现一样,此操作是 θ( |V| lg |V|)

邻接表

邻接表是稀疏图的一种更节省空间的图表示。稀疏图是与具有相同顶点数的完全图相比边数非常少的图。完全图有 |V| (|V| - 1) / 2 = θ(|V|²) 条边,将图作为邻接矩阵存储所需的内存空间也是 θ(|V|²)。因此,在密集(几乎完全)图的情况下,将其存储为邻接矩阵是有意义的。然而,对于稀疏图来说,这并不成立。

在邻接表表示中,顶点存储在数组或某种其他数据结构中,边与顶点一起存储在某些列表或某种其他结构中。首先,我们将考虑一个基于邻接表的表示,其中顶点按其 ID 存储在数组中,就像稀疏邻接矩阵表示的情况一样。它存在相同的问题:当删除顶点时,我们无法减小顶点数组的大小。然而,在这种情况下,边列表被删除,这使得它比我们在邻接矩阵中遇到的情况要节省空间得多:

public class AdjacencyListGraphWithSparseVertex<V,E> implements Graph<V,E> {
    boolean undirected;

    public AdjacencyListGraphWithSparseVertex(boolean undirected) {
        this.undirected = undirected;
    }

Edge 类存储了从顶点起源的边的目标和值。顶点存储了与之关联的边的集合。我们根据目标边的 ID 使边可比较,这样我们就可以将它们存储在二叉搜索树中,以便根据 ID 容易查找:

class Edge implements Comparable<Edge>{
        E value;
        int target;

为了提高 getNeighbors 操作的性能,我们在节点中存储邻居列表。我们在节点中存储一个指针,该指针对应于 targetNode 状态变量中此节点的目标:

        DoublyLinkedList.DoublyLinkedNode<Integer> targetNode;

        public Edge(int target) {
            this.target = target;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            Edge edge = (Edge) o;

            return target == edge.target;

        }

        @Override
        public int hashCode() {
            return target;
        }

        @Override
        public int compareTo(Edge o) {
            return target - o.target;
        }
    }

Vertex 类用于存储顶点及其关联的边。边存储在红黑树中:

    class Vertex extends GraphVertex<V>{
        RedBlackTree<Edge>
                edges = new RedBlackTree<>();
        DoublyLinkedList<Integer> neighbors = new DoublyLinkedList<>();
        public Vertex(int id, V value) {
            super(id, value);
        }
    }

然后将顶点存储在数组中:

    Object[] vertices = new Object[0];

添加顶点不需要我们复制任何边;它只是确保顶点被复制到新创建的更大大小的数组中:

    @Override
    public int addVertex() {
        Object[] newVertices = new Object[vertices.length+1];
        System.arraycopy(vertices, 0, newVertices, 0, vertices.length);
        newVertices[vertices.length] = new Vertex(vertices.length, null);
        vertices=newVertices;
        return newVertices.length-1;
    }

删除顶点需要首先将其位置上的顶点设置为 null。然而,您还必须删除所有其他顶点中与被删除顶点作为目标的边:

    @Override
    public void removeVertex(int id) {
        Vertex sVertex = (Vertex) vertices[id];
        if(sVertex==null){
            throw new IllegalArgumentException("Vertex "+ id +" does not exist");
        }
        LinkedList<Integer> neighbors = getNeighbors(id);
        Edge dummyEdgeForId = new Edge(id);

我们必须删除与被删除顶点相关联的所有边:

        for(int t:neighbors){
            Edge e = ((Vertex)vertices[t]).edges.deleteValue(dummyEdgeForId).getValue();
            ((Vertex)vertices[t]).neighbors.removeNode(e.targetNode);
        }
        vertices[id] = null;
    }

添加边需要在其关联的顶点中创建相应的条目:

    @Override
    public void addEdge(int source, int target) {
        Vertex sVertex = (Vertex) vertices[source];
        Edge sEdge = sVertex.edges.insertValue(new Edge(target)).getValue();
        sEdge.targetNode = (DoublyLinkedList.DoublyLinkedNode<Integer>)
        sVertex.neighbors.appendLast(sEdge.target);
        if(undirected){
            Vertex tVertex = (Vertex) vertices[target];
            Edge tEdge = tVertex.edges.insertValue(new Edge(source)).getValue();
            tEdge.targetNode = (DoublyLinkedList.DoublyLinkedNode<Integer>)
            tVertex.neighbors.appendLast(tEdge.target);
        }
    }

删除边需要从关联的顶点中删除相应的条目:

    @Override
    public void removeEdge(int source, int target) {
        Vertex sVertex = (Vertex) vertices[source];
        Edge deletedEdge = sVertex.edges.deleteValue(new Edge(target)).getValue();
        sVertex.neighbors.removeNode(deletedEdge.targetNode);
        if(undirected){
            Vertex tVertex = (Vertex) vertices[target];
            deletedEdge = tVertex.edges.deleteValue(new Edge(source)).getValue();
            tVertex.neighbors.removeNode(deletedEdge.targetNode);
        }
    }

检查邻接关系涉及首先查找源顶点,然后在相应的红黑树中查找目标边的边:

    @Override
    public boolean isAdjacent(int source, int target) {
        Vertex sVertex = (Vertex) vertices[source];
        return sVertex.edges.searchValue(new Edge(target))!=null;
    }

我们预先计算了邻居列表,所以我们只需简单地返回此列表:

    @Override
    public LinkedList<Integer> getNeighbors(int source) {s
        Vertex sVertex = (Vertex) vertices[source];
        return sVertex.neighbors;
    }

设置和获取顶点或边的值的过程是显而易见的:

    @Override
    public void setVertexValue(int vertex, V value) {
        Vertex sVertex = (Vertex) vertices[vertex];
        if(sVertex==null){
            throw new IllegalArgumentException("Vertex "+ vertex 
            + "does not exist");
        }else{
            sVertex.setValue(value);
        }
    }

    @Override
    public V getVertexValue(int vertex) {
        Vertex sVertex = (Vertex) vertices[vertex];
        if(sVertex==null){
            throw new IllegalArgumentException("Vertex "+ vertex 
              + "does not exist");
        }else{
            return sVertex.getValue();
        }
    }

    @Override
    public void setEdgeValue(int source, int target, E value) {
        Vertex sVertex = (Vertex) vertices[source];
        Vertex tVertex = (Vertex) vertices[target];
        if(sVertex==null){
            throw new IllegalArgumentException("Vertex "+ source 
              + "does not exist");
        }else if(tVertex==null){
            throw new IllegalArgumentException("Vertex "+ target 
               + "does not exist");
        }else{
            BinaryTree.Node<Edge> node = sVertex.edges.searchValue(new Edge(target));
            if(node==null){
                throw new IllegalArgumentException("Edge between "+ source + "and" + target + "does not exist");

            }else{
                node.getValue().value = value;
            }
        }
    }

    @Override
    public E getEdgeValue(int source, int target) {
        Vertex sVertex = (Vertex) vertices[source];
        Vertex tVertex = (Vertex) vertices[target];
        if(sVertex==null){
            throw new IllegalArgumentException("Vertex "+ source 
                 + "does not exist");
        }else if(tVertex==null){
            throw new IllegalArgumentException("Vertex "+ target 
                  + "does not exist");
        }else{
            BinaryTree.Node<Edge> node =
                    sVertex.edges.searchValue(new Edge(target));
            if(node==null){
                throw new IllegalArgumentException("Edge between "+ source + "and" + target + "does not exist");
            }else{
                return node.getValue().value;
            }
        }
    }

    @Override
    public boolean isUndirected() {
        return undirected;
    }

    @Override
    public BinarySearchTree<Integer> getAllVertices() {
        BinarySearchTree<Integer> allVertices = new RedBlackTree<>();
        for(int i=0;i<vertices.length;i++){
            if(vertices[i]!=null){
                allVertices.insertValue(i);
            }
        }
        return allVertices;
    }

    @Override
    public int maxVertexID() {
        return vertices.length-1;
    }
}

基于邻接表的图的操作复杂度

以下列出了我们在基于邻接表的图中讨论的操作的复杂度:

  • 添加顶点:添加顶点需要首先创建一个新的数组,然后将所有顶点复制到其中。所以它是 θ(|V|)

  • 删除顶点:删除过程不会改变顶点的数组。然而,此操作涉及检查每个顶点以删除具有被删除顶点作为目标的边。因此,此操作也是 θ(|V|)

  • 添加边和删除边:此操作的第一个步骤是查找源顶点,这是常数时间。第二个步骤是在红黑树中添加或删除边,所以它是 θ(lg |V|)。因此,添加/删除边的整个操作是 θ(lg |V|)

  • 相邻:此操作的第一个步骤是查找源顶点,这是常数时间。第二个步骤是在红黑树中查找边,所以它是 θ(lg |V|)。因此,添加/删除边的整个操作是 θ(lg |V|)

  • 邻居:由于邻居列表是预先计算的,其复杂度与查找顶点相同,即常数时间。

  • 在顶点设置和获取值:这些操作需要首先查找顶点,这是常数时间。第二步是设置/获取值。这些操作是 θ(1)

  • 在边处设置和获取值:这些操作需要首先查找源顶点,然后查找特定的边。第一个操作是 θ(1),第二个是 θ(lg |V|)。最后,设置或获取边的值是 θ(l)。因此,总操作是 θ(lg |V|)

  • 获取所有顶点:此操作是 θ( |V| lg |V| ),就像之前的实现一样。

基于邻接表且顶点密集存储的图

就像基于邻接矩阵的图一样,可以使用搜索树而不是数组来密集存储顶点。这允许我们在删除顶点时不影响其他顶点的 ID 来恢复空间。其他所有内容都与基于数组的顶点存储相同:

public class AdjacencyListGraphWithDenseVertex<V,E> implements Graph<V,E> {

nextId 变量存储下一个要插入的顶点的 ID 值:

    int nextId;
    boolean undirected;

我们仍然有 EdgeVertex 类:

    class Edge implements Comparable<Edge>{
        E value;
        int target;

        DoublyLinkedList.DoublyLinkedNode<Integer> targetNode;
        public Edge(int target) {
            this.target = target;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) 
                 return false;
            Edge edge = (Edge) o;
            return target == edge.target;
        }

        @Override
        public int hashCode() {
            return target;
        }

        @Override
        public int compareTo(Edge o) {
            return target - o.target;
        }
    }
    class Vertex extends GraphVertex<V>{
        RedBlackTree<Edge> edges = new RedBlackTree<Edge>();

        DoublyLinkedList<Integer> neighbors 
                           = new DoublyLinkedList<>();
        public Vertex(int id, V value) {
            super(id, value);
        }
    }

    public AdjacencyListGraphWithDenseVertex(boolean undirected) {
        this.undirected = undirected;
    }

现在,我们不再使用数组来存储顶点,而是使用红黑树:

    RedBlackTree<GraphVertex<V>> vertices = new RedBlackTree<>();

添加新顶点意味着在红黑树中插入一个新的顶点:

    @Override
    public int addVertex() {
        vertices.insertValue(new Vertex(nextId++, null));
        return nextId;
    }

与之前一样,删除过程不仅包括删除顶点,还要遍历所有其他顶点,删除所有以被删除顶点为目标顶点的边:

    @Override
    public void removeVertex(int id) {
        vertices.deleteValue(new GraphVertex<V>(id, null));
        vertices.traverseDepthFirstNonRecursive((gv)->{
                BinaryTree.Node<Edge> edgeNode = ((Vertex) gv).edges.deleteValue(new Edge(id));
                if(edgeNode!=null){
                    Edge edge = edgeNode.getValue();
                    ((Vertex) gv)
                        .neighbors.removeNode(edge.targetNode);
                }
        },
                BinaryTree.DepthFirstTraversalType.INORDER);
    }

第一步是找到源节点和目标节点以确认它们的存在。之后,将边添加到源节点的边集合中。如果图是无向的,也将边添加到目标节点的边集合中:

    @Override
    public void addEdge(int source, int target) {
        BinaryTree.Node<GraphVertex<V>> sNode =
          vertices.searchValue(new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode =
                vertices.searchValue(
                        new GraphVertex<V>(target, null));
        if(sNode == null){
            throw new IllegalArgumentException("Vertex ID "+source+" does not exist");
        }else if(tNode == null){
            throw new IllegalArgumentException("Vertex ID "+target+" does not exist");
        }else{
            Vertex sVertex = (Vertex) sNode.getValue();
            Vertex tVertex = (Vertex) tNode.getValue();
            Edge tEdge = new Edge(target);
            sVertex.edges.insertValue(tEdge);
            tEdge.targetNode = (DoublyLinkedList.DoublyLinkedNode<Integer>) sVertex.neighbors
              .appendLast(tVertex.getId());
            if(undirected) {
                Edge sEdge = new Edge(source);
                tVertex.edges.insertValue(sEdge);
                sEdge.targetNode = (DoublyLinkedList.DoublyLinkedNode<Integer>) tVertex.neighbors
                  .appendLast(sVertex.getId());
            }
        }
    }

第一步与之前相同。之后,从源节点的边集合中删除边。如果图是无向的,也从目标节点的边集合中删除边:

    @Override
    public void removeEdge(int source, int target) {
        BinaryTree.Node<GraphVertex<V>> sNode =
                vertices.searchValue(
                        new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode =
                vertices.searchValue(
                        new GraphVertex<V>(target, null));
        if(sNode == null){
            throw new IllegalArgumentException("Vertex ID "+source+" does not exist");
        }else if(tNode == null){
            throw new IllegalArgumentException("Vertex ID "+target+" does not exist");
        }else{
            Vertex sVertex = (Vertex) sNode.getValue();
            Edge deletedEdge = sVertex.edges.deleteValue(new Edge(target)).getValue();
            sVertex.neighbors.removeNode(deletedEdge.targetNode);
            if(undirected) {
                Vertex tVertex = (Vertex) tNode.getValue();
                deletedEdge = tVertex.edges.deleteValue(new Edge(source)).getValue();
              tVertex.neighbors.removeNode(deletedEdge.targetNode);
            }
        }
    }

第一步与之前相同。之后,查找具有正确目标顶点的边。如果找到边,则顶点相邻:

    @Override
    public boolean isAdjacent(int source, int target) {
        BinaryTree.Node<GraphVertex<V>> sNode =
                vertices.searchValue(
                        new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode =
                vertices.searchValue(
                        new GraphVertex<V>(target, null));
        if(sNode == null){
            throw new IllegalArgumentException("Vertex ID "
                                   +source+" does not exist");
        }else if(tNode == null){
            throw new IllegalArgumentException("Vertex ID "
                                   +target+" does not exist");
        }else{
            Vertex sVertex = (Vertex) sNode.getValue();
            return sVertex.edges.searchValue(
                                 new Edge(target)) != null;

        }
    }

我们只需查找顶点,然后返回预先计算好的邻居列表:

    @Override
    public LinkedList<Integer> getNeighbors(int source) {
        BinaryTree.Node<GraphVertex<V>> sNode =
                vertices.searchValue(
                        new GraphVertex<V>(source, null));
        if(sNode == null){
            throw new IllegalArgumentException(
                    "Vertex ID "+source+" does not exist");
        }else{
            Vertex sVertex = (Vertex) sNode.getValue();
            return  sVertex.neighbors;
        }
    }

设置和获取值的流程与之前相同,只是在设置值之前,我们需要在红黑树中查找顶点/顶点而不是在数组中:

    @Override
    public void setVertexValue(int vertex, V value) {
        BinaryTree.Node<GraphVertex<V>> sNode =
                vertices.searchValue(
                        new GraphVertex<V>(vertex, null));
        if(sNode == null){
            throw new IllegalArgumentException("Vertex ID "
                               +vertex+" does not exist");
        }else{
            Vertex sVertex = (Vertex) sNode.getValue();
            sVertex.setValue(value);
        }
    }

    @Override
    public V getVertexValue(int vertex) {
        BinaryTree.Node<GraphVertex<V>> sNode =
                vertices.searchValue(
                        new GraphVertex<V>(vertex, null));
        if(sNode == null){
            throw new IllegalArgumentException("Vertex ID "
                               +vertex+" does not exist");
        }else{
            Vertex sVertex = (Vertex) sNode.getValue();
            return sVertex.getValue();
        }
    }

    @Override
    public void setEdgeValue(int source, int target, E value) {
        BinaryTree.Node<GraphVertex<V>> sNode =
                vertices.searchValue(
                        new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode =
                vertices.searchValue(
                        new GraphVertex<V>(target, null));
        if(sNode == null){
            throw new IllegalArgumentException("Vertex ID
                           "+source+" does not exist");
        }else if(tNode == null){
            throw new IllegalArgumentException("Vertex ID
                           "+target+" does not exist");
        }else{
            Vertex sVertex = (Vertex) sNode.getValue();
            BinaryTree.Node<Edge> edgeNode =
                    sVertex.edges.searchValue(new Edge(target));
            if(edgeNode!=null) {
                edgeNode.getValue().value = value;
                if (undirected) {
                    Vertex tVertex = (Vertex) tNode.getValue();
                    edgeNode = tVertex.edges
                            .searchValue(new Edge(source));
                    edgeNode.getValue().value = value;
                }
            }else{
                throw new IllegalArgumentException(
                          "No edge exists between the vertices "
                          + source + " and " + target);
            }
        }
    }

    @Override
    public E getEdgeValue(int source, int target) {
        BinaryTree.Node<GraphVertex<V>> sNode =
                vertices.searchValue(
                        new GraphVertex<V>(source, null));
        BinaryTree.Node<GraphVertex<V>> tNode =
                vertices.searchValue(
                        new GraphVertex<V>(target, null));
        if(sNode == null){
            throw new IllegalArgumentException("Vertex ID
                               "+source+" does not exist");
        }else if(tNode == null){
            throw new IllegalArgumentException("Vertex ID
                               "+target+" does not exist");
        }else{
            Vertex sVertex = (Vertex) sNode.getValue();
            BinaryTree.Node<Edge> edgeNode =
                    sVertex.edges.searchValue(new Edge(target));
            if(edgeNode!=null) {
                return edgeNode.getValue().value;

            }else{
                throw new IllegalArgumentException(
                           "No edge exists between the vertices "
                           + source + " and " + target);
            }
        }
    }

    @Override
    public boolean isUndirected() {
        return undirected;
    }

    @Override
    public BinarySearchTree<Integer> getAllVertices() {
        BinarySearchTree<Integer> allVertices 
                                = new RedBlackTree<>();
        vertices.traverseDepthFirstNonRecursive(
                  (v) -> allVertices.insertValue(v.getId()),
                BinaryTree.DepthFirstTraversalType.PREORDER);
        return allVertices;
    }

    @Override
    public int maxVertexID() {
        return nextId -1;
    }
}

基于邻接表且顶点密集存储的图的运算复杂度

基于邻接表的图的运算复杂度如下:

  • 添加顶点:添加顶点需要在红黑树中插入一个顶点。因此,此操作是 θ(lg |V|)

  • 删除顶点:删除过程需要从红黑树中删除顶点,其复杂度是 θ(lg |V|)。然而,此操作涉及检查每个顶点以删除具有被删除顶点作为目标顶点的边。因此,此操作也是 θ(|V|)

  • 添加边和删除边:这个操作的第一个步骤是查找源顶点,这是θ(lg |V|)。第二步是向/从红黑树添加或删除边,所以这也是θ(lg |V|)。因此,添加/删除边的整个操作是θ(lg |V|)

  • 相邻:这个操作的第一个步骤是查找源顶点,这是θ(lg |V|)。第二步是在红黑树中查找边,这也是θ(lg |V|)。因此,添加/删除边的整个操作是θ(lg |V|)

  • 邻居:邻居列表是预先计算的,所以这个操作的复杂度与搜索顶点的复杂度相同,即θ(lg |V|)

  • 在顶点上设置和获取值:这些操作需要你首先查找顶点,这是θ(lg |V|)。第二步是设置/获取值。这些操作是θ(lg |V|)

  • 在边上的设置和获取值:这些操作需要你首先查找源顶点,然后是特定的边。这两个操作都是θ(lg |V|)。最后,设置或获取边的值是θ(l)。因此,总操作是θ(lg |V|)

  • 获取所有顶点:这里,这个操作也是θ( |V| lg |V|)

图的遍历

图的遍历是图遍历的等价物,正如在前面章节中讨论的那样。就像在树的情况下,我们可以进行广度优先或深度优先遍历。然而,与树不同,图可以通过不经过边来访问所有顶点。这使得必须单独考虑遍历所有边和顶点。另一件事是,图没有指定的根,因此我们可以从任何特定的顶点开始。最后,由于图可能不是连通的,我们可能无法从单个顶点开始遍历所有顶点/边。这是通过重复执行遍历来实现的,每次从尚未访问的任何顶点开始。这是对基本广度优先或深度优先遍历的简单扩展,我们将在下面讨论。

首先,让我们讨论使用广度优先搜索和深度优先搜索来访问顶点。这涉及到维护两个顶点集合:一个存储所有已发现但尚未访问/探索的顶点,另一个存储一个布尔数组,用于检查一个顶点是否已经被探索/访问。

已发现但尚未探索的顶点集合可以是两种类型之一:如果是栈,我们就有深度优先遍历;如果是队列,我们就有广度优先遍历。

要在单个方法中实现深度优先和广度优先搜索,我们需要创建我们的StackQueue接口的超接口。我们需要在其中定义三个方法:

public interface OrderedStore<E> {
    void insert(E value);
    E pickFirst();
    E checkFirst();
}

现在将这些方法实现为StackQueue接口的默认方法,以便委托给它们适当的方法:

public interface Stack<E> extends OrderedStore<E>{
    void push(E value);
    E pop();
    E peek();
    @Override
    default E checkFirst(){
        return peek();
    }

    @Override
    default void insert(E value){
        push(value);
    }

    @Override
    default E pickFirst(){
        return pop();
    }
}

public interface Queue<E> extends OrderedStore<E>{
    void enqueue(E value);
    E dequeue();
    E peek();

    @Override
    default E checkFirst(){
        return peek();
    }

    @Override
    default void insert(E value){
        enqueue(value);
    }

    @Override
    default E pickFirst(){
        return dequeue();
    }
}

这允许我们使用OrderedStore接口来同时持有栈和队列。我们还创建了一个新的函数式接口,它代表一个接受两个参数且不返回任何内容的 lambda 表达式:

public interface TwoArgumentStatement<E,F> {
    void doSomething(E e, F f);
}

我们将此搜索作为 Graph 接口本身的默认方法实现。

enum TraversalType{
    DFT, BFT
}

在开始时,我们只将起始顶点插入到尚未探索的顶点集合中。然后,我们循环直到处理完所有在搜索中可以发现的顶点,并且顶点集合中没有更多元素。我们避免处理已经处理过的顶点集合中的每个顶点。否则,我们将其标记为“正在处理”并对其调用访问者。最后,我们通过将所有邻居插入到必须处理的元素集合中来扩展此顶点:

default void visitAllConnectedVertices(int startingNode, TwoArgumentStatement<Integer,  V> visitor, TraversalType type) {
        OrderedStore<Integer> toBeProcessed = null;
        boolean doneProcessing[] = new boolean[maxVertexID()+1];
        switch (type){
            case BFT: 
                toBeProcessed = new QueueImplLinkedList<Integer>(); 
                break;
            case DFT: 
                toBeProcessed = new StackImplLinkedList<Integer>(); 
                break;
        }

        toBeProcessed.insert(startingNode);

        while(toBeProcessed.checkFirst()!=null){

            int currentVertex = toBeProcessed.pickFirst();
            if(doneProcessing[currentVertex]){
                continue;
            }

            doneProcessing[currentVertex] = true;
            visitor.doSomething(currentVertex,
                           getVertexValue(currentVertex));  
            for(int neighbor:getNeighbors(currentVertex)){
                if(doneProcessing[neighbor]==false){
                    toBeProcessed.insert(neighbor);
                }
            }
        }
    }

边的遍历过程也非常相似;我们可以遵循广度优先或深度优先遍历。在这种情况下,访问者需要访问边的源和目标,这使得有必要将它们都存储在我们使用的栈或队列中。为此,我们创建了一个名为Edge的类。该类是可比较的,以便边可以存储在二叉搜索树中以实现易于搜索的能力:

class Edge implements Comparable<Edge>{
        int source;
        int target;

        public Edge(int source, int target) {
            this.source = source;
            this.target = target;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass())
                return false;

            Edge edge = (Edge) o;

            if (source != edge.source) return false;
            return target == edge.target;

        }

        @Override
        public int hashCode() {
            int result = source;
            result = 31 * result + target;
            return result;
        }

        @Override
        public int compareTo(Edge o) {
            if(source!=o.source){
                return source - o.source;
            }else {
                return target - o.target;
            }
        }
    }

现在,我们可以使用广度优先和深度优先遍历来实现边的遍历过程:

default void visitAllConnectedEdges(int startingNode, ThreeArgumentStatement<Integer, Integer, E> visitor,
                                        TraversalType type){

        OrderedStore<Edge> toBeProcessed = null;
        boolean doneProcessing[] = new boolean[maxVertexID()+1];
        switch (type){
            case BFT: toBeProcessed = new QueueImplLinkedList<Edge>(); break;
            case DFT: toBeProcessed = new StackImplLinkedList<Edge>(); break;
        }
        toBeProcessed.insert(new Edge(-1, startingNode));
        while (toBeProcessed.checkFirst()!=null){
            Edge edge = toBeProcessed.pickFirst();
            LinkedList<Integer> neighbors = getNeighbors(edge.target);
            if(edge.source>=0) {
                visitor.doSomething(edge.source, edge.target,
                  getEdgeValue(edge.source, edge.target));
            }
            if(doneProcessing[edge.target]){
                continue;
            }

            for(int target: neighbors){
                if(isUndirected() && doneProcessing[target]){
                    continue;
                }
                Edge nextEdge = new Edge(edge.target, target);
                if(nextEdge.target!=edge.source)
                    toBeProcessed.insert(nextEdge);
            }

            doneProcessing[edge.target] = true;
        }
    }

遍历的复杂性

对于每次遍历,无论是所有顶点还是边,都必须遍历所有边。即使你只想访问顶点,这也是正确的。实际复杂度取决于特定的映射实现,因此我们将使用操作getNeighbors方法的复杂度是θ(g(|V|))

如果你正在访问边或顶点,确保每个顶点只扩展一次。这个操作执行了|V|次,每次都是θ(g(|V|))。因此,由于顶点的扩展,找到邻居的复杂度是θ(|V|g(|V|))。当扩展时,它们被访问一次,对于每条边,我们有一个邻居。其中一些邻居之前已经被访问过;然而,我们需要执行常数时间来验证这一点。所以每个顶点只被访问一次,每个邻居只被检查一次。这改变了复杂度到θ(|V|g(|V|) + |E|)。由于我们已经看到了一个具有常数时间getNeighbors方法的图实现,我们可以有一个θ(|V| + |E|)的遍历。

循环检测

遍历的一个用途是循环检测。没有任何循环的连通无向图是一个树。没有任何循环的有向图称为有向无环图(DAG)。在图中进行循环检测可以非常相似地进行。在无向图的情况下,如果我们进行 DFS 并且同一个节点作为边的目标被访问两次,则存在循环。由于边是无向的,如果源或目标之前未见过,我们就满意了。

在有向图的情况下,如果你想知道是否存在循环,仅仅访问相同的节点两次是不够的;我们还应该考虑边的方向。这意味着在遍历边时,我们需要知道我们是否可以到达我们开始的同一个节点。这要求我们在进行 DFS 时记住整个路径。这就是为什么我们使用递归辅助方法在有向图中检测循环。我们首先创建有向循环的辅助方法。checkDirectedCycleFromVertex方法接受路径和顶点的二叉搜索树。顶点列表是存储所有顶点的列表,已访问的顶点必须被移除,这样它们就不会在后续的循环检测中作为起点使用。整数列表是从起点在深度优先遍历中的路径。如果一个顶点在相同的路径中重复出现,这意味着存在一个循环:

default void checkDirectedCycleFromVertex(
            com.example.functional.LinkedList<Integer> path, BinarySearchTree<Integer> allVertices){

列表头部是路径中最深的顶点;我们需要扩展这个:

        int top = path.head();
        allVertices.deleteValue(top);
        LinkedList<Integer> neighbors = getNeighbors(top);

现在如果任何邻居已经存在于路径中,则存在一个循环,这正是我们要检查的。如果找到循环,我们抛出一个自定义异常的实例:

        for(int n:neighbors){
            com.example.functional.LinkedList<Integer> pathPart = path;
            while (!pathPart.isEmpty()){
                int head = pathPart.head();
                if(head == n){
                    throw new CycleDetectedException("Cycle detected");
                }
                pathPart = pathPart.tail();
            }
            checkDirectedCycleFromVertex(path.add(n), allVertices);
        }
    }

现在我们为两种类型的图创建检测循环的方法。遍历所有顶点,因为图中可能有未连接的部分。有向图可能有一些顶点由于边的方向性,无法从特定的起始顶点到达。然而,一旦在任何遍历中访问了一个顶点,它就不需要再次访问。这是通过将所有顶点放在二叉搜索树中并删除已访问的顶点来实现的:

default boolean detectCycle(){

首先,我们获取所有顶点的列表。我们直接通过理解顶点在图中的存储方式来获取它:

        BinarySearchTree<Integer> allVertices = getAllVertices();
        try {
            if (isUndirected()) {
                while (allVertices.getRoot() != null) {
                    int start = allVertices.getRoot().getValue();
                    RedBlackTree<Integer> metAlready 
                                     = new RedBlackTree<>();
                    metAlready.insertValue(start);
                    allVertices.deleteValue(start);
                    visitAllConnectedEdges(start, (s, t, v) -> {
                            if(metAlready.searchValue(t) == null) {
                                metAlready.insertValue(t);
                                allVertices.deleteValue(t);
                            }else if(metAlready.searchValue(s)== null){
                                metAlready.insertValue(s);
                                allVertices.deleteValue(s);
                            }else{
                                throw new CycleDetectedException(
                                  "found "+t);
                            }
                        }, TraversalType.DFT);
                }
            } else {
                while (allVertices.getRoot() != null) {
                    checkDirectedCycleFromVertex(
                      com.example.functional.LinkedList
                      .<Integer>emptyList().add(allVertices.getRoot().getValue()), allVertices);
                }
            }
        }catch (CycleDetectedException ex){
            return true;
        }
        return false;
    }

循环检测算法的复杂度

首先,让我们检查无向图中循环检测的复杂度。getAllVertices的复杂度是Ө (|V| lg |V|)。在已访问顶点的搜索树中查找一个顶点的复杂度是Ө ( lg |V| )。我们为每条边做两次这样的查找。我们还需要在metAlready搜索树中插入一个新的顶点,并在allVertices搜索树中删除一个顶点;这些操作对于每条边的复杂度是Ө ( lg |V| )。因此,总复杂度是Ө (|E| lg |V|)

现在让我们考虑在有向图中进行循环检测的复杂度。在这里,我们遍历每条边一次。然而,对于每条边,我们必须查看整个路径以确定当前顶点是否在路径中。路径的长度可能是这样的:|V| - 1。因此,当我们为每条边进行检查时,复杂度是O(|E||V|);这比O(|E|lg |V|)要高得多。

生成树和最小生成树

在一个连通图中,生成树是一个由所有顶点和一些边组成的子图。因此,子图是一个树;它是一个没有环或循环的连通图。图 5展示了图中生成树的一个例子:

连通图和最小生成树

图 5. 图的生成树(用红色表示)

树具有保持顶点连接所需的最少边数。从树中移除任何边都会使图断开。这在连接不同地点且道路数量最少的地图中可能很有用。有了这个动机,我们会对具有最小总道路长度的生成树非常感兴趣。这可能很重要,因为建设道路是一笔昂贵的开销。或者,我们可以为城市设计一条公交线路图,将所有重要地点连接起来,而不需要创建太多路线;此外,较短的路线更好。这样的生成树被称为最小生成树。找到最小生成树是一个重要问题。但在我们讨论算法之前,让我们看看最小生成树的一些性质。

对于任何具有顶点集 V 和边集 E 的树,|V| = |E| + 1

首先,让我们考虑这个命题:从树中移除任何一条边将创建两个没有连接的树。让我们假设相反的情况。我们有一个包含两个顶点 A 和 B 以及它们之间的一条边 X 的图。假设即使我们移除 X,树仍然保持连通。这意味着即使删除 X,A 和 B 之间仍然存在一条路径 P。这意味着在原始图中,我们可以通过 P 从 A 走到 B,并使用 X 回到 A。这意味着原始图有一个环。但是原始图被假设是一个树,所以这是不可能的。因此,我最初关于从树中移除任何边将创建两个没有连接的树的命题是正确的。

现在,让我们从一个具有边集 E 和顶点集 V 的图 G 开始。如果我们移除所有边,我们当然只会剩下 V。这些没有边的顶点集实际上是单顶点树,它们之间没有连接。

我们从一个树开始,比如说 G,移除一条边。现在我们有两个树。现在我们可以移除另一条边,这将把其中一个树分成两个,所以我们有三个树。这样,在移除每条边之后,我们都会多出一个树。因此,在移除所有边之后,我们将有|E| + 1个树(因为移除任何边之前有一个树)。这些必须是|V|个单顶点树。所以,要么|V| = |E|+1,要么|E| = |V| - 1

任何连通的无向图都有一个生成树

让我们考虑一个无向图。如果有环和环。首先,我们简单地删除所有环。考虑 A 和 B 作为两个相邻并构成环的顶点。这意味着如果我们从 A 到 B 走边,我们可以使用另一条路径:从 B 到 A(这就是它成为环的原因)。所以如果我们删除从 B 到 A 的边,我们仍然会有一个连通图。我们对每个环执行这个操作。在这些操作结束时,我们将有一个没有环或环的连通图;这是一个树。这个树连接了所有顶点,所以它是一个生成树。

任何具有属性|V| = |E| + 1 的无向连通图是一个树

现在,让我们检查前面定理的逆命题。假设存在一个连通图,其中|V| = |E| + 1。我们假设它不是一个树。这个图必须有一个生成树,它是一个子图,边数更少。这个生成树将有相同的顶点数(因为我们从未删除任何顶点),但边数更少。因此,如果生成树有边集E ¹,那么我们有| E ¹|| < |E| => | E ¹|| + 1 < |E| + 1 => | E ¹|| + 1 < |V|。但是这是不可能的,因为新的图是一个树。所以,原始命题是任何具有属性|V| = |E| +1的无向连通图是一个树。

分割属性

分割指的是一个最小边集,当移除这些边时,会将一个连通的无向图分割成两个没有连接的单独连通图。给定图中可以有多个分割。

分割属性可以这样定义:如果一个边是分割的一个元素,并且它在分割中具有最小的成本,那么它是图的最小生成树的一部分。为了验证这一点,首先注意,对于任何无向连通图的分割,生成树将始终恰好包含分割中的一个成员。

让我们有一个分割 X,它将图 G 分割成子图 H 和 J。让 G 有一个称为 S 的生成树。由于 G 是一个连通图且 X 是一个分割,这意味着 H 和 J 彼此之间是连通的。如果 X 为空,则意味着 G 不是连通的;这是不可能的。现在我们有了 X 的子集 Y,其中 Y 的所有成员都是生成树 S 的一部分。如果 Y 为空,则 H 和 J 的顶点在 S 中将是不连通的,所以这是不可能的。现在,作为对|Y| = 1的矛盾,我们假设|Y| > 1。这意味着在 S 中有超过一条边在 H 和 J 之间。让我们从中选择两条。第一条是在 H 中的顶点 A 和 J 中的顶点 B 之间,第二条是在 H 中的顶点 C 和 J 中的顶点 D 之间。现在,由于生成树 S 将 H 和 J 的所有顶点都连接起来,在 Y 之外 S 中存在从 A 到 C 和从 D 到 B 的路径。因此,我们使用我们选择的一条边从 A 到 C 和 C 到 D,并使用另一条边从 D 到 B 和 B 到 A。这意味着 S 有一个环,因此 S 不是一个树,这是矛盾的。因此,|Y| = 1。因此,生成树在无向连通图中的任何分割中恰好有一个成员。

如果 S 是 G 的最小生成树,且 X 是 G 中的一个分割,将 G 划分为 H 和 J,S 对于 X 有且仅有一个成员,正如前一小节所证明的。让它是除了具有最低成本的那条边之外的任何边。由于 S 是生成树,如果我们移除 X 中的边,我们将有两个不连通的子树,它们是 H 和 J 的生成树。如果我们现在插入 X 中的任何其他边,这条新边将把这些子树重新连接到单个生成树。这是因为 X 的所有边都在 H 中的一个顶点和 J 中的另一个顶点之间。因此,我们可以用 X 中的边和具有最低成本的边替换 S 中的 X 成员,从而创建 G 的另一个生成树。但是新树中的边将与 S 中的边相同,除了成本低于 S 中的那条边。因此,新生成树边的总成本必须低于 S,这与 S 是最小生成树的事实相矛盾。因此,最小生成树 S 必须在分割 X 中具有成本最低的边。

对于所有边的成本都不同的图,最小生成树是唯一的。

假设我们有一个连通的无向图 G,它有两个不同的最小生成树,即 S 和 T。由于 S 和 T 是不同的,并且具有相同数量的边(因为对于生成树,计算是 |V| = |E| + 1),在 S 中存在一条边 E,它不在 T 中。让这条边在顶点 A 和 B 之间。现在让我们将 G 的顶点集划分为两个部分:Y 和 Z。创建这样的划分,使得 A 属于 Y,B 属于 Z,并且 Y 和 Z 是不相交的,并且它们一起包含所有顶点。让 X 是将 G 划分为包含在 Y 和 Z 中的两个子图的分割。由于 A 属于 Y 且 B 属于 Z,边 E 在 A 和 B 之间属于 X。由于 X 是一个分割,S 是一个生成树,X 中必须恰好有一条边是 S 的部分;在这种情况下,它必须是边 E。现在,由于 T 也是生成树且 E 不是 T 的成员,X 中必须还有另一个成员在 T 中;让我们称它为 f

如果我们从 S 中移除边 E,我们将有两个不同的树,如果插入 f,它们将再次连接。这是因为 f 是 Y 和 Z 之间顶点的边,并且两个部分已经是树。所以现在我们剩下另一个生成树。

所有成本都是不同的;边 E 的成本与边 f 的成本不同。如果 f 的成本低于 E,新的生成树的总成本将低于 S。尽管如此,这是不可能的,因为 S 是最小生成树。因此,f 的成本必须高于 E。

我们可以对 S 中不在 T 中的每条边都这样做;当没有更多边可用时,S 将转换为 T。在这个过程中,每一步边的总成本都会增加。这意味着 T 中边的总成本必须高于 S 中的总成本。然而,这是不可能的,因为 S 和 T 都是最小生成树。因此,我们最初的假设,即可能存在两个不同的最小生成树是错误的。因此,在所有边的成本都不同的图中,每个最小生成树都是唯一的。

寻找最小生成树

根据我们刚才讨论的性质,我们现在可以定义一个寻找图的最小生成树的算法。假设已经给出了一组边 F,并且它们是最小生成树 G 的成员。现在我们正在尝试找到另一条也是最小生成树成员的边。首先,我们选择一条边 e,其成本与 E 和 F 中的其他边相比是最小的。由于一些边已经给出,一些顶点已经连接。如果选择的边 e 位于两个已经连接的顶点之间,我们简单地拒绝这条边,并找到下一条成本最小的边。我们这样做,直到找到一条连接两个尚未连接的顶点的边 f。我们的断言是 f 是最小生成树的新成员。为了确认这一点,让我们假设 f 位于顶点 A 和 B 之间。根据我们的程序描述,A 和 B 之间没有连接。让我们将顶点分为两个部分:H 和 J。让所有连接到 A 的顶点,包括 A 本身,都在 H 中,所有连接到 B 的顶点,包括 B 本身,都在 J 中。其余的顶点分配到集合 H 中。由于 H 和 J 是原始图 G 中顶点的划分,我们在原始图 G 中有一个分割 X,它以这种方式分割图 G,使得 H 中的所有顶点都放在一个子图中,而 J 中的所有顶点都放在另一个子图中。我们知道 X 中的最小成本成员是最小生成树 G 的成员。现在,当然,f 是 X 的成员,因为它连接了 A 和 B。它也是 X 中所有边中成本最低的边。这是因为 X 中的所有边都在剩余的边中(否则 H 和 J 中的一些顶点会连接,这是不可能的,因为我们创建了两个集合的方式),而 f 是所有剩余边中成本最低的。这意味着 f 是生成树的新成员。因此,我们使用以下步骤来构建最小生成树:

  1. 从一个空的边集作为生成树开始。

  2. 如果还有剩余的边,并且所有顶点都没有连接,则选择成本最低的一条。

  3. 如果边位于两个已连接的顶点之间,则丢弃它并回到步骤 2。

  4. 否则,将边添加到最小生成树的边集中。

  5. 从步骤 1 重复。

现在的问题是,如何有效地知道两个顶点是否连接。解决方案是一个称为并查集森林的数据结构。

并查集

并查集的目的是能够判断两个给定的对象是否属于同一集合。此数据结构允许你首先指定全集的所有成员,然后指定哪些成员属于同一分区,从而将两个分区合并为一个分区。它表示全集的分区集合,并允许我们查询全集的两个成员是否属于同一分区。

树以相反的指针形式保持,即子节点知道其父节点;然而,父节点没有指向子节点的任何指针。其想法是在同一棵树中保持连接的值。森林中的每一棵树都有一个代表节点,即其根节点。如果两个节点有相同的代表根节点,它们在同一分区中;否则,它们不在同一分区中。

此数据结构有三个重要的操作:

  • 向全集添加一个新对象。

  • Union 两个对象:这将导致这些对象所属的分区合并为一个分区。

  • Find:这将返回对象所属分区的代表对象。如果两个不同对象的 find 操作的结果相同,则对象将属于同一分区。

以下是一个可以存储可比较对象的Union并查集的实现:

public class UnionFind<E extends Comparable<E>> {

每个节点都持有对其父节点的引用。如果没有父节点,即如果节点是其树的根,则父节点为 null。所有节点还存储其秩,即以自身为根的树的身高:

    private class Node implements Comparable<Node>{
        Node parent;
        E object;
        int rank;

        public Node(E  object) {
            this.object = object;
            rank = 0;
        }

        @Override
        public int compareTo(Node o) {
            return object.compareTo(o.object);
        }
    }

所有节点都存储在红黑树中,因此它们具有对数搜索:

    BinarySearchTree<Node> allNodes = new RedBlackTree<>();

此外,我们还想保持可用分区的数量计数:

    int partitionCount;

add操作向全集添加一个新对象,该操作使用红黑树实现:

    public void add(E object){
        Node n = new Node(object);
        allNodes.insertValue(n);
        partitionCount++;
    }

这是一个内部方法,逐个遍历父节点,直到找到对象所属的树的根:

    Node findRoot(Node n){
        if(n.parent==null){
            return n;
        }else{
            return findRoot(n.parent);
        }
    }

并查集

图 6. 并查集森林中的并操作。它所表示的分区在旁边显示。

并操作合并两棵树。这是通过将两棵树的其中一个根设置为另一棵树的根的父节点来实现的。当合并高度不等的两棵树时,较矮树的根被设置为较高树根的父节点;否则,可以选择两个中的任何一个作为根。当合并两个高度相等的树时,根的秩只增加。当合并不等高的树时,合并树的身高与较高树的身高相同。图 6显示了并操作:

    public void union(E o1, E o2){
        BinaryTree.Node<Node> node1 = allNodes.searchValue(new Node(o1));
        BinaryTree.Node<Node> node2 = allNodes.searchValue(new Node(o2));
        if(node1==null || node2==null){
            throw new IllegalArgumentException("Objects not found");
        }
        Node n1 = node1.getValue();
        Node n2 = node2.getValue();
        Node p1 = findRoot(n1);
        Node p2 = findRoot(n2);
        if(p1==p2){
            return;
        }
        int r1 = n1.rank;
        int r2 = n2.rank;
        if(r1>r2){
            p2.parent = p1;
        }else if(r2>r1){
            p1.parent = p2;
        }else{
            p2.parent = p1;
            p1.rank++;
        }
        partitionCount--;
    }

find操作首先查找与对象相关的节点,然后找到根节点。返回根节点中包含的对象:

    public E find(E object){
        BinaryTree.Node<Node> node1 = allNodes.searchValue(new Node(object));
        if(node1==null){
            throw new IllegalArgumentException("Objects not found");
        }
        Node n = node1.getValue();
        return findRoot(n).object;
    }
}

并查集操作的复杂度

首先,让我们考虑找到任何节点的根节点的复杂度;这是一个内部操作。这个操作的复杂度是 θ(h),其中 h 是树的高度。现在,树的高度有什么上界?设 f(h) 为高度为 h 的树中的最小节点数。树总是通过合并两个较小的树来创建的。如果被合并的树的高度不相等,合并后的树的高度与两个初始树中较高的那个相同,并且现在它比原始较高的树有更多的节点。这不是创建最坏树的方法,最坏树必须通过合并两个具有相同高度的最小节点数的树来创建。合并后,合并后的树的高度比被合并的两个树中的任何一个都要高。所以,为了创建高度为 h+1 的最坏树,我们必须合并两个高度为 h 的最坏树。执行这个操作的公式是 f(h+1) = 2 f(h)。因此,如果 f(0) = C,其中 C 是某个常数,f(1) = 2C, f(2)=4C, …, f(h) = 2 ^h C。因此,如果节点数为 n,那么 n ≥ f(h) = 2 ^h C => lg n ≥ lg (2 ^h C) = h + lg C => h ≤ lg n – lg C => h = O(lg n)。这意味着找到给定节点根的复杂度也是 O(lg n)

  • 添加新对象涉及在红黑树中插入新节点,因此复杂度是 θ(lg n)

  • 查找:这个操作首先涉及查找对应于对象的节点。这是在红黑树中的搜索操作;因此它是 θ(lg n)。之后,它涉及查找这个节点的根,这是 O(lg n)。最后,我们返回节点中的对象,这是常数时间。因此,整个查找操作的复杂度是 θ(lg n)

  • 并查集涉及三个操作。第一个是搜索节点中的对象,这是 θ(lg n)。第二个是找到与每个节点关联的树的根,这也是 O(lg n)。最后,它涉及到树的合并,这是一个常数时间操作。因此,整个操作的复杂度是 θ(lg n)

最小生成树算法的实现

现在我们可以实现我们的最小生成树算法。首先,我们创建一个我们将用于实现的类。CostEdge 类表示一个边及其成本。compareTo 方法被重写以比较成本而不是 ID:

class CostEdge extends Edge{
        Integer cost;

        public CostEdge(int source, int target, int cost) {
            super(source, target);
            this.cost = cost;
        }

        @Override
        public int compareTo(Edge o) {
            return cost - ((CostEdge)o).cost;
        }
    }

costFinder 参数是一个返回边成本的 lambda 函数。edgeQueue 是一个优先队列,允许我们按照边的成本顺序考虑它们。我们可以每次都出队成本最低的边,正如我们的算法所要求的。unionFind 的目的是跟踪在已经选择了一些边之后哪些顶点是连通的。首先,我们遍历所有边并将它们入队到优先队列,然后我们遍历所有顶点并将它们添加到 unionFind 中。之后,正如我们的算法所描述的,我们按照边的成本顺序选择边,并且只有在它们不在已经连通的顶点之间时才添加它们。unionFind 跟踪哪些顶点是连通的。生成树的边以链表的形式返回:

default LinkedList<Edge> minimumSpanningTree(OneArgumentExpression<E,Integer> costFinder){
        if(!isUndirected()){
            throw new IllegalStateException(
              "Spanning tree only applicable to undirected trees");
        }
        LinkedList<Edge> subGraph = new LinkedList<>();

        PriorityQueue<CostEdge> edgeQueue = new LinkedHeap<>((x, y)->x.compareTo(y));

        UnionFind<Integer> unionFind = new UnionFind<>();

        this.visitAllConnectedEdges(getAllVertices().getRoot().getValue(), (s,t,v)-> edgeQueue.enqueue(
            new CostEdge(s,t,costFinder.compute(v))), TraversalType.DFT);

        this.getAllVertices().traverseDepthFirstNonRecursive(
          (x)->unionFind.add(x),
                BinaryTree.DepthFirstTraversalType.PREORDER);

        while((unionFind.getPartitionCount()>1 
                               && edgeQueue.checkMinimum()!=null){
            Edge e = edgeQueue.dequeueMinimum();
            int sGroup = unionFind.find(e.source);
            int tGroup = unionFind.find(e.target);
            if(sGroup!=tGroup){
                subGraph.appendLast(e);
                unionFind.union(e.source, e.target);
            }
        }
        return subGraph;
    }

最小生成树算法的复杂度

访问所有边并将它们添加到优先队列的复杂度可以低至 Ө (|V| + |E| + |E| lg |E|),因为遍历边的复杂度是 Ө (|V| + |E|),将所有边添加到优先队列的复杂度是 Ө (|E| lg |E|)。由于连通图有 |E| ≥|V| -1Ө (|V| + |E| + |E| lg |E|) = Ө (|E| lg |E|)。将所有顶点插入并查集的操作通过 Ө (|V| lg |V|) 完成,因为添加每个顶点的复杂度是 Ө (lg |V|)

现在我们来考虑算法的核心。对于每条边,出队最小边的复杂度是 Ө (lg |E|),在 unionFind 中查找每个源点和目标点的复杂度是 Ө (lg |V|),添加到链表是常数时间,在 unionFind 上执行并集操作是 Ө (lg |V|)。因此,对于每条边,复杂度是 Ө (lg |V| + lg |E|)。由于 |E| ≥|V| -1,这相当于每条边的 Ө (lg |E|)。因此,核心部分的复杂度是 Ө (|V| lg |E|),因为我们停止在添加了 |V| - 1 条边之后,并且所有顶点都已经连通。

将所有先前步骤的复杂度相加,我们得到最小生成树算法的总复杂度为 Ө (|E| lg |E|) + Ө (lg |V|) + Ө (|V| lg |E|) = Ө (|E| lg |E| + |V| lg |V|) = Ө (|E| lg |E|),因为 |E| ≥|V| -1

这个算法被称为克鲁斯卡尔算法,由约瑟夫·克鲁斯卡尔发明。如果已经有一个排序好的边列表,克鲁斯卡尔算法的复杂度是 Ө (|V| lg |E|)。由于我们已经检查到所有边都处理完毕,如果传递一个不连通的图,它将给出一个最小生成树的集合,每个连通子图一个。

摘要

在本章中,我们看到了什么是图,以及它们在现实世界中的适用场景。我们看到了在内存中实现图数据结构的一些方法。然后我们研究了在 BFT 和 DFT 中遍历图的方法。我们使用遍历来检测图中的环。最后,我们看到了什么是生成树,什么是最小生成树,以及如何在图中找到它们。

在下一章中,我们将稍微偏离一下,探索一种简单而优雅的实现某些并发编程的方法,称为响应式编程。

第十一章. 响应式编程

本章是对响应式编程的一个小插曲。它让我们在某些情况下处理应用程序的并发需求。它提供了一个处理并发的抽象。尽管这些概念很古老,但由于大数据流量的开始,近年来它引起了人们的兴趣。在现代社会,每天都有数十亿设备生成数据。挖掘这些数据对于业务增长至关重要;在某些情况下,对数据进行统计分析或将数据输入到某些机器学习算法中可能是整个业务的全部。这使得支持处理这种大量流入的数据、提供快速响应和具有容错能力变得至关重要。当然,即使使用传统的或命令式编程范式也可以做这些事情,就像理论上可以使用汇编语言构建任何应用程序一样。然而,这使得应用程序的维护变得极其复杂,并且无法根据业务需求进行修改。在本章中,我们将讨论以下主题:

  • 响应式编程的基本思想

  • 构建一个示例响应式框架

  • 使用我们的框架构建示例程序

什么是响应式编程?

假设我们有一个允许我们查询或保存数据的 Web 服务器。这个 Web 服务器可以同时处理多个请求,每个请求都是一个涉及一些计算的短暂任务。通常如何实现这一点呢?好吧,天真的方式是为每个请求创建一个新的线程。但人们很容易意识到这会导致应用程序中线程数量的激增。此外,线程的创建和删除是重量级活动;它们会减慢整个应用程序的速度。

接下来,你可以使用线程池,这样相同的线程可以反复使用,以避免创建和删除线程的开销。然而,如果你想要同时处理数千个请求,这将需要一个拥有数千个线程的线程池。操作系统的线程调度非常复杂,涉及许多逻辑,包括优先级等。操作系统不期望线程只是运行短时间的计算;它们不是为此优化的。因此,解决方案是使用相同的线程来处理多个同时请求。一般来说,如果我们停止阻塞 IO,并在一个任务等待 I/O 时使用同一线程执行另一个任务,就可以这样做。然而,管理这些事情是非常复杂的。因此,我们需要一个框架来为我们执行这些活动。这样的框架可以被称为响应式框架。

响应式编程负责以下内容:

  • 可扩展性:这个属性是指应用程序能够随着可用资源的增加而提供成比例数量的请求的能力。如果一个处理器每秒处理 500 个请求,那么两个处理器应该处理 1,000 个。

  • 响应性:我们希望应用程序具有响应性;例如,当它在计算某个结果或从其他地方获取它时,应该显示状态。

  • 弹性:由于我们使用相同的线程执行多个任务,错误处理比通常情况下更复杂。我们如何让用户知道错误信息呢?因此,我们不是将异常传播回调用栈,而是向前移动并显式处理错误情况。

使用响应式编程有不同的技术;这取决于我们试图解决的实际问题。我们不会讨论所有这些技术,但会关注常用的那些。

生产者-消费者模型

生产者-消费者模型是一种将处理过程划分为向其他组件发送消息的小组件的设计。一个生产消息,另一个消费并对其采取行动。它提供了一个抽象,可以轻松实现一个优化以利用所有资源的应用程序。生产者-消费者模型从消息队列开始。生产者在队列中发布消息,消费者接收它们。这个队列与我们之前研究过的队列在几个方面不同。我们希望这个队列是线程安全的,这对于队列在多线程环境中的正确工作来说是必需的。我们不需要担心消息出队的确切顺序。毕竟,当它们被不同线程接收时,消息的顺序并不重要。在这些条件下,我们优化了消息的传递。在实现这个队列之前,让我们讨论一些使用 synchronized 关键字之外的一些线程同步技术。这些技术对于在保持程序正确性的同时更优化地使用资源是必需的。

信号量

信号量是一种特殊的变量,它允许我们限制可以访问特定资源的线程数量。以下代码展示了信号量的一个示例,它提供了一个线程安全的计数器:

public class SemaphoreExample {
    volatile int threadSafeInt = 0;
    Semaphore semaphore = new Semaphore(1);
    public int incremementAndGet() throws InterruptedException{
        semaphore.acquire();
        int previousValue = threadSafeInt++;
        semaphore.release();
        return previousValue;
    }
}

在这里,信号量已被初始化为1,这意味着它只允许一个线程获取它。其他线程在它释放之前无法获取它。与同步不同,这里没有要求必须由获取它的同一个线程调用释放方法,这使得它特别灵活。

对信号量的acquire方法的调用将被阻塞,直到它成功获取。这意味着调用线程将被从线程调度器中移除并放置在一边,这样操作系统的线程调度器就无法看到它。一旦信号量准备好被获取,这个线程将被放回原位,以便线程调度器可以看到它。

比较并设置

比较和设置是一个原子操作,它允许你仅在现有值匹配特定值时更新变量的值。这使我们能够根据变量的先前值更新变量。CAS 操作返回一个布尔值。如果比较匹配,这意味着设置操作成功,它返回true;否则,它返回false。想法是持续尝试,直到设置操作成功。以下图表显示了基本策略:

比较和设置

图 1:使用比较和设置操作进行原子更新

图 1中,我们试图增加共享变量var的值。这个操作需要我们在线程特定的临时位置读取值,然后增加临时值并将其重新分配给共享变量。然而,如果有多个线程同时尝试执行更新,这可能会引起问题。可能会发生的情况是,这两个线程同时读取值以获取相同的临时值。这两个线程都可以使用增加的值更新共享变量。这将只增加一次值,但实际上应该增加两次。为了避免这种情况,我们检查var的值是否仍然相同,并且只有在它是这样的时候才更新;否则,我们再次读取var的值并重复该过程。由于这个比较和设置操作是原子的,它保证了不会丢失任何增加。以下是与之相同的 Java 代码:

public class ThreadSafeCounter {
    AtomicInteger counter;
    public int incrementAndGet(){
        while (true){
            int value = counter.get();
            if(counter.compareAndSet(value, value+1)){
                return value;
            }
        }
    }
}

要使用任何原子操作,我们需要使用java.util.concurrent.atomic包中的类。AtomicInteger是一个封装整数的类,它允许在该整数上执行compareAndSet操作。还有其他一些实用方法。特别是,它有执行原子增加和减少的方法,就像我们在这里实现的那样。

Volatile 字段

假设我们有一个字段,它被多个线程写入和读取。如果所有线程都在同一个单 CPU 上运行,写入可以直接在 CPU 缓存中发生;它们不需要经常同步到主内存。这不会成为问题,因为值也可以从相同的缓存中读取。然而,多个 CPU 可以有自己的缓存,在这种情况下,一个 CPU 对缓存的写入对在另一个 CPU 上运行的线程是不可见的。大多数程序接受这一点并相应地工作。例如,Java 为每个线程维护共享变量的单独副本,这些副本偶尔会同步。如果我们想强制一个线程的写入对另一个线程可见,我们需要声明该字段为 volatile。所有涉及原子操作的字段都被声明为 volatile。

线程安全的阻塞队列

现在我们已经准备好实现我们的线程安全的阻塞队列。线程安全意味着多个线程可以共享同一个队列;阻塞意味着如果一个线程尝试出队一个元素而队列当前为空,出队调用将被阻塞,直到其他线程入队一个元素。同样,如果一个线程尝试入队一个新元素而队列已满,对队列的调用将被阻塞,直到另一个线程出队一个元素并释放一些空间。

我们的队列将在一个固定长度的数组中存储元素,并维护两个计数器,它们将存储入队和出队的下一个索引。两个信号量在队列空或满时阻塞线程。此外,每个数组位置都提供了两个信号量,确保入队和出队操作不会覆盖或重复任何元素。它是通过确保一旦新元素被入队到特定位置,在它被出队之前不会被覆盖来实现的。同样,一旦特定数组索引被出队,它将永远不会在另一个入队操作存储另一个元素之前再次被出队:

public class ThreadSafeFixedLengthBlockingQueue<E> {

underflowSemaphore 确保当队列空时出队操作被阻塞,而 overflowSemaphore 确保当队列满时入队操作被阻塞:

    Semaphore underflowSemaphore;
    Semaphore overflowSemaphore;

    AtomicInteger nextEnqueueIndex;
    AtomicInteger nextDequeueIndex;

数组存储是持有元素的空间:

    E[] store;

enqueueLocksdequeueLocks 都是基于位置的独立锁,它们只允许在入队之后进行出队,反之亦然:

    Semaphore [] enqueueLocks;
    Semaphore [] dequeueLocks;

    int length;

alive 标志可以被出队线程用来知道何时停止运行,并且不再期望有更多元素。这个标志需要由入队线程设置:

    boolean alive = true;

所有初始化基本上都是显而易见的:

    public ThreadSafeFixedLengthBlockingQueue(int length){
        this.length = length;
        store = (E[]) new Object[length];
        nextEnqueueIndex = new AtomicInteger();
        nextDequeueIndex = new AtomicInteger();
        underflowSemaphore = new Semaphore(length);
        overflowSemaphore = new Semaphore(length);
        underflowSemaphore.acquireUninterruptibly(length);
        enqueueLocks = new Semaphore[length];
        dequeueLocks = new Semaphore[length];
        for(int i=0;i<length;i++){
            enqueueLocks[i] = new Semaphore(1);
            dequeueLocks[i] = new Semaphore(1);
            dequeueLocks[i].acquireUninterruptibly();
        }
    }

入队操作首先确保队列不满,通过获取 overflowSemaphore

    public void enqueue(E value) throws InterruptedException {
        overflowSemaphore.acquire();

然后 nextEnqueueIndex 被增加,并返回其前一个值,这个值随后被用来计算元素将被存储在数组中的索引。这个看似复杂的表达式确保了即使在 nextEnqueueIndex 整数溢出之后,索引也能正确地回滚,前提是队列的长度是 2 的整数次幂:

        int index = (length + nextEnqueueIndex.getAndIncrement() % length) 
          % length;

一旦选择了索引,我们必须在位置上获取一个入队锁,存储值,然后释放出队锁以标记这个位置为准备出队。最后,我们释放 underflowSemaphore 上的一个计数,以标记队列中有一个更多元素待出队:

        enqueueLocks[index].acquire();
        store[index] = value;
        dequeueLocks[index].release();
        underflowSemaphore.release();
    }

出队操作与入队操作非常相似,只是信号量的角色被反转。在实际操作开始之前有一些稍微复杂的代码。这是为了使出队线程在没有更多元素可用时能够退出:

    public E dequeue() throws InterruptedException {

我们不是直接获取 underflowSemaphore,而是使用 tryAcquire,如果没有任何元素可供脱队,它将在 1 秒后唤醒线程。这给了我们检查 alive 布尔标志值的机会,并在它不再活跃的情况下退出脱队操作。如果队列不再活跃,我们中断当前线程并退出。否则,我们计算索引并以类似的方式从入队操作中脱队元素:

        while (alive && !underflowSemaphore.tryAcquire(1, TimeUnit.SECONDS));
        if(!alive){
            Thread.currentThread().interrupt();
        }
        int index = (length + nextDequeueIndex.getAndIncrement() % length) 
                 % length;
        dequeueLocks[index].acquire();
        E value = store[index];
        enqueueLocks[index].release();
        overflowSemaphore.release();
        return value;
    }

这是一个实用方法,用于返回队列中的当前元素数量。这在知道何时终止队列(将 alive 标志设置为 false)在生产者-消费者设置中很有用:

    public int currentElementCount(){
        return underflowSemaphore.availablePermits();
    }

    public void killDequeuers(){
        alive = false;
    }

}

生产者-消费者实现

现在我们可以使用我们创建的队列实现生产者-消费者设置。简单来说,生产者-消费者队列是生产者生产并由消费者消费的事件队列。有三种类型的事件。INVOCATION 类型指的是传播处理的常规事件。当需要传播异常时,会引发 ERROR 类型的事件。当需要终止脱队线程并关闭队列时,会产生 COMPLETION 事件。ProcerConsumer 队列接受 Consumer 作为输入:

public interface Consumer<E> {
    void onMessage(E message);
    default void onError(Exception error){
        error.printStackTrace();
    }
    default void onComplete(){

    }
}

public class ProducerConsumerQueue<E> {
    enum EventType{
        INVOCATION, ERROR, COMPLETION
    }

Event 类表示单个事件。根据类型,它可以有一个值或异常:

    class Event{
        E value;
        Exception error;
        EventType eventType;
    }
    ThreadSafeFixedLengthBlockingQueue<Event> queue;
    boolean alive = true;
    Thread [] threads;

ProducerConsumerQueue 构造函数创建消费者线程。它还接受消费者代码作为输入。消费者必须实现 Consumer 接口:

    public ProducerConsumerQueue(int bufferSize, int threadCount, 
                 Consumer<E> consumer){
        queue = new ThreadSafeFixedLengthBlockingQueue<>(bufferSize);
        threads = new Thread[threadCount];

消费者线程运行代码,脱队事件并按照事件类型在循环中调用 consumerCode 的方法。循环在接收到终止事件且队列中没有更多事件需要处理时结束:

        Runnable consumerCode = ()->{
            try{
                while(alive || queue.currentElementCount()>0){
                    Event e = queue.dequeue();
                    switch (e.eventType) {
                        case INVOCATION:
                            consumer.onMessage(e.value);
                            break;
                        case ERROR:
                            consumer.onError(e.error);
                            break;
                        case COMPLETION:
                            alive = false;
                            consumer.onComplete();
                    }
                }

            } catch (InterruptedException e) {

            } finally{

            }
        };

创建消费者线程:

        for(int i=0;i<threadCount;i++) {
            threads[i] = new Thread(consumerCode);
            threads[i].start();
        }
    }

produce 方法由生产者线程调用。请注意,队列不管理生产者线程;它们需要单独管理:

    public void produce(E value) throws InterruptedException {
        Event event = new Event();
        event.value = value;
        event.eventType = EventType.INVOCATION;
        queue.enqueue(event);
    }

一旦生产者线程标记事件流已完成,就不会再生成新事件,并且脱队线程在处理完所有事件后将被终止:

    public void markCompleted() throws InterruptedException {
        Event event = new Event();
        event.eventType = EventType.COMPLETION;
        queue.enqueue(event);
    }

这是为了传播一个异常:

    public void sendError(Exception ex) throws InterruptedException {
        Event event = new Event();
        event.error = ex;
        event.eventType = EventType.ERROR;
        queue.enqueue(event);
    }

如果我们需要等待所有脱队线程终止,我们使用这个:

    public void joinThreads() throws InterruptedException {
        for(Thread t: threads){
            t.join();
        }
    }
}

要了解如何使用这个生产者-消费者队列实际解决问题,我们将考虑一个示例问题。我们将处理一个文件——com-orkut.ungraph.txt——它是公开的,包含过去社交网站 Orkut 中用户之间的所有友谊。该文件可以从snap.stanford.edu/data/bigdata/communities/com-orkut.ungraph.txt.gz下载。为了保护隐私,所有用户都通过一些任意的 ID 进行引用,并且没有共享与实际用户的映射。我们还将使用另一个名为ulist的文件,其中将包含我们感兴趣的 ID 列表。我们的任务是找出第二个文件中每个用户的朋友数量。以下命令显示了这两个文件的外观:

$ head com-orkut.ungraph.txt 
1	2 
1	3 
1	4 
1	5 
1	6 
1	7 
1	8 
1	9 
1	10 
1	11 
$ head ulist 
2508972 
1081826 
2022585 
141678 
709419 
877187 
1592426 
1013109 
1490560 
623595 

com-orkut.ungraph.txt中的每一行都有两个通过空格分隔的 ID。这意味着这两个用户之间存在友谊。已知文件中每个友谊只被提及一次,并且是无向的。请注意,这意味着每一行应增加两个 ID 的朋友数量。ulist中的每一行只有一个 ID。所有 ID 都是唯一的,我们必须找到这些 ID 中每个的朋友数量。请注意,其中一些没有朋友,因此在com-orkut.ungraph.txt中没有被提及。

我们将首先创建一个实用程序类,它将使我们能够从文件中读取整数 ID。这个类的作用是从任何文本文件中读取整数值,以便在过程中不会创建太多的对象。这只是为了在一定程度上减少垃圾收集。在这种情况下,我们使用了基于文件通道的逻辑,该逻辑使用ByteBuffer作为缓冲区:

public class FileReader {
    ByteBuffer buf= ByteBuffer.allocate(65536);
    FileChannel channel;

readCount变量跟踪缓冲区中剩余的字节数:

    int readCount = 0;

    public FileReader(String filename) throws FileNotFoundException {
        channel = new FileInputStream(filename).getChannel();
        buf.clear();
    }

要读取一个int,在一个循环中继续读取字节,直到遇到一个不是数字的字节。在此期间,继续计算字符串表示的整数:

    public int readIntFromText() throws IOException {
        int value = 0;
        while(true){

首先检查缓冲区是否为空;如果是,则通过从文件中读取来重新填充它:

            if(readCount<=0){
                buf.clear();
                readCount = channel.read(buf);

如果文件中没有更多的字节可用,不必关心翻转缓冲区:

                if(readCount<0){
                    break;
                }
                buf.flip();
            }

我们读取一个字节并减少readCount,因为现在缓冲区少了一个字节:

            byte nextChar = buf.get();
            readCount--;

如果字符是数字,继续计算整数;否则,中断循环并返回计算出的整数值:

            if(nextChar>='0' && nextChar<='9') {
                value = value * 10 + (nextChar - '0');
            }else{
                break;
            }

        }
        return value;
    }
}

在此帮助下,我们将创建一个程序来创建一个文件输出,其中将包含ulist中提供的用户 ID 以及相应的朋友数量。想法是通过计算朋友数量来异步读取文件。由于计数涉及二分搜索,我们希望有两个线程来完成这项工作而不是一个:

public class FriendCountProblem {
    private static final String USER_LIST_FILE = "ulist";
    private static final String EDGES_PATH = "com-orkut.ungraph.txt";
    private static final String OUTPUT_FILE_PATH = "output";

    public static void main(String [] args)
      throws Exception {
        FileReader userListReader = new FileReader(USER_LIST_FILE);

首先,我们简单地计算ulist中存在的行数。这将使我们能够创建正确大小的数组:

        int count = 0;

        while(true){

            int lineValue = userListReader.readIntFromText();
            if(lineValue==0){
                break;
            }
            count++;
        }

我们创建了两个数组:一个包含键,另一个包含每个键的朋友计数。计数存储在AtomicInteger对象中,以便可以从多个线程中递增:

        Integer [] keys = new Integer[count];
        AtomicInteger [] values = new AtomicInteger[count];

我们从ulist中读取userIDs到一个数组中:

        userListReader = new FileReader(USER_LIST_FILE);

        int index = 0;

        while(true){

            int uid = userListReader.readIntFromText();
            if(uid==0){
                break;
            }
            keys[index] = uid;
            values[index] =  new AtomicInteger(0);
            index++;

        }

现在我们对userID数组进行排序,以便我们可以对其执行二分搜索:

        ArraySorter.quicksort(keys,(a,b)->a-b);

我们消费者的任务是搜索在com-orkut.ungraph.txt中遇到的每个用户,并在数组 values 中递增相应的计数。请注意,创建ProducerConsumerQueue不会启动任何处理;只有通过这种方式创建消费者线程。处理只有在产生事件后才会开始,我们将在读取com-orkut.ungraph.txt后进行:

        ProducerConsumerQueue<Integer> queue 
                = new ProducerConsumerQueue<>(4092, 2, (v)->{
            int pos  = ArraySearcher.binarySearch(keys,v);
            if(pos<0){
                return;
            }
            values[pos].incrementAndGet();
        });

我们使用主线程来产生事件。我们使用相同的FileReader类来单独读取每个用户 ID。这是因为com-orkut.ungraph.txt中每一行的用户都有一个朋友(即同一行中的另一个用户),所以我们可以简单地读取用户并将它们作为事件发布,以便消费者可以处理它们:

        FileReader edgeListFileReader = new FileReader(EDGES_PATH);
        while(true){
            int val = edgeListFileReader.readIntFromText();
            if(val == 0){
                break;
            }
            queue.produce(val);
        }

一旦我们处理完整个com-orkut.ungraph.txt文件,我们只需将队列标记为完成并等待消费者线程终止:

        queue.markCompleted();
        queue.joinThreads();

现在必须更新值数组中的所有计数。所以我们逐个读取它们,并将它们输出到文件 output 中:

        PrintStream out = new PrintStream(OUTPUT_FILE_PATH);
        for(int i=0;i<count;i++){
            out.println(keys[i] +" : "+values[i].get());
        }
        out.flush();
    }
}

上述示例演示了如何使用生产者-消费者的响应式技术解决实际问题。现在我们将讨论实现我们的事件队列的另一种方式;它不涉及在信号量上阻塞。

自旋锁和忙等待

信号量通常在线程获取它之前阻塞线程。操作系统通过从准备在 CPU 上分配处理时间的线程列表中移除线程来实现这种阻塞。准备就绪的线程列表被称为运行线程。每个信号量都有一个等待在其上的线程列表,这些线程被从运行线程列表中移除。一旦信号量被释放,附加到信号量的线程列表中的线程将被移除并放回运行线程列表。这个操作相当重量级,需要处理时间。另一种阻止线程访问共享资源的方法是使用自旋锁。自旋锁通常使用原子变量和比较和设置操作实现。自旋锁中的线程简单地尝试在循环中执行变量的比较和设置操作;它一直这样做,直到成功。对于操作系统来说,这个线程就像一个运行线程一样,会被像任何其他线程一样调度。然而,线程本身会不断尝试比较和设置操作,消耗处理器时间。这就是为什么它被称为忙等待。一旦比较和设置操作成功,线程就可以继续做有意义的事情。自旋锁在资源不会长时间不可用时很有用。如果资源只是短暂不可用,就没有必要进行所有重负载操作,比如从运行线程列表中移除线程并在信号量上阻塞。

我们可以使用自旋锁而不是信号量来实现我们的线程安全队列,如下面的代码所示。每个用于存储队列元素的数组位置都由两个AtomicBoolean变量保护,这些变量存储在enqueueLocksdequeueLocks数组中。我们唯一想要确保的是,每次出队后,只有一个入队,每次入队后,特定数组位置只有一个出队。不同的数组位置应该相互独立:

public class ThreadSafeFixedLengthSpinlockQueue<E> {
    int nextEnqueueIndex;
    int nextDequeueIndex;
    E[] store;
    AtomicBoolean[] enqueueLocks;
    AtomicBoolean[] dequeueLocks;
    AtomicInteger currentElementCount = new AtomicInteger(0);
    int length;
    volatile boolean alive = true;
    public ThreadSafeFixedLengthSpinlockQueue(int length){
        this.length = length;
        store = (E[]) new Object[length];
        enqueueLocks = new AtomicBoolean[length];
        dequeueLocks = new AtomicBoolean[length];

enqueueLocks[i]false时,意味着位置i没有存储元素。当dequeueLock[i]true时,意味着相同的事情。我们需要两者的原因是在元素正在入队或出队的过程中进行保护:

        for(int i=0;i<length;i++){
            enqueueLocks[i] = new AtomicBoolean(false);
            dequeueLocks[i] = new AtomicBoolean(true);
        }
    }

这里是锁的核心。我们简单地取下一个索引进行入队,并尝试获取enqueueLock。如果它是false,这意味着还没有任何元素入队,它会被原子性地设置为true,并开始入队过程;否则,我们会在忙循环中重复做同样的事情,直到比较和设置操作成功。一旦过程完成,我们只需将dequeueLock设置为false来释放它。比较和设置操作在这里不是必要的,因为它保证是true。元素的数量使用另一个原子变量来维护:

    public void enqueue(E value) throws InterruptedException {

        while (true) {
            int index = nextEnqueueIndex;
            nextEnqueueIndex = (nextEnqueueIndex+1) % length;
            if(enqueueLocks[index].compareAndSet(false,true)){
                currentElementCount.incrementAndGet();
                store[index] = value;
                dequeueLocks[index].set(false);
                return;
            }
        }
    }

出队操作非常相似,只是入队和出队锁的位置互换了:

    public E dequeue() throws InterruptedException {
        while(alive) {
            int index = nextDequeueIndex;
            nextDequeueIndex = (nextDequeueIndex+1) % length;
            if(dequeueLocks[index].compareAndSet(false,true)){
                currentElementCount.decrementAndGet();
                E value = store[index];
                enqueueLocks[index].set(false);
                return value;
            }
        }
        throw new InterruptedException("");
    }

代码的其余部分是显而易见的:

    public int currentElementCount(){
        return currentElementCount.get();
    }

    public void killDequeuers(){
        alive = false;
    }

}

我们可以将ProducerConsumerQueue类中的队列简单地替换为使用基于自旋锁的队列。在我们的示例问题中,队列的自旋锁版本性能更好。

让我们使用ProducerConsumerQueue解决另一个问题。我们的问题是找出 2 到 500,000 之间的所有完美数。什么是完美数?完美数是指除了它本身之外,所有除数的和等于该数的数。第一个完美数是 6。6 有三个除数(不包括它本身),即 1、2 和 3,且 6=1+2+3。这就是 6 成为完美数的原因。为了找出 2 到 500,000 之间的所有完美数,我们将检查该范围内的每个数是否是完美数。我们可以编写以下代码来判断一个给定的数是否是完美数。对于每个除数div,我们检查数x是否能被div整除;如果是,我们将其加到和中。在这种情况下,如果我们用div除以x,我们当然会得到另一个作为结果存储在变量quotient中的x的除数。这也必须加到和中,除非它等于div。当我们通过x的平方根时,即当我们通过用div除以x得到的商时,我们停止这个过程。由于我们最初排除了1作为除数以避免添加该数本身,我们在最后将1加到和中并检查它是否等于x;如果是,则x是完美数:

public static boolean isPerfect(long x){
        long div = 2;
        long sum=0;
        while(true){
            long quotient = x/div;
            if(quotient<div){
                break;
            }
            if(x%div==0){
                sum+=div;
                if(quotient!=div){
                    sum+=quotient;
                }
            }
            div++;
        }
        return 1+sum==x;
    }

如您所见,检查一个给定的数是否是完美数是一个计算密集型操作,这使得使用所有 CPU 来计算它变得很有吸引力。我们将使用我们的生产者-消费者框架来完成这项工作。代码是自解释的。我们的消费者代码简单地检查一个给定的数是否是完美数,如果是,就打印该数。生产者简单地生成并排队所有数字。由于消费者在多个线程中运行,并且是计算密集型的部分,它应该比单线程版本运行得更快:

public static void findPerfectNumberWithProducerConsumer() throws InterruptedException{
        long start = System.currentTimeMillis();
        ProducerConsumerQueue<Long> queue 
                 = new ProducerConsumerQueue<>(4096, 4, (x)->{
            if(isPerfect(x)){
                System.out.println(x);
            }
        });

        for(long i=2;i<5_00_000;i++){
            queue.produce(i);
        }
        queue.markCompleted();
        queue.joinThreads();
        System.out.println("Time in ms: "+(System.currentTimeMillis()-start));
    }

由于我的计算机有四个 CPU 核心,我使用了四个线程来处理重负载。在我的计算机上,这个程序耗时 1,596 毫秒,而单线程程序耗时 4,002 毫秒,如下面的代码所示:

public static void findPerfectNumberWithSingleThread(){
        long start = System.currentTimeMillis();
        for(long i=2;i<5_00_000;i++){
            if(isPerfect(i)){
                System.out.println(i);
            }
        }
        System.out.println("Time in ms: "+(System.currentTimeMillis()-start));
    }

反应式编程的函数式方法

大多数反应式编程框架都提供了用于反应式编程的功能性 API,这使得使用起来更加方便。在本节中,我们将构建一个功能性反应式 API,并使用它来解决一个问题。思路是使用流的概念。流是一个数据生成器或源,可以在请求时提供输入。功能性 API 在流上提供了 map、filter 和 consume 操作。map 和 filter 操作创建一个新的流,而 consume 操作则返回一个EventConsumer实例。思路是当EventConsumer被要求开始处理时,它会启动自己的生产者线程和消费者线程,并将每个 map、filter 或 consume 操作视为生产者-消费者队列中的单独调度操作。这只是为了强调我们真正试图实现的目标。

例如,我将放置使用功能性 API 解决相同完美数问题的代码。我们将用实际创建流的代码替换伪方法someWayCreateAStream。重点是展示如何使用 map、filter 和 consume 方法来操作事件流。处理实际上是在调用 process 方法时开始的,在 map、filter 和 consume 的每个步骤中,处理步骤是解耦的,并且可能在不同的线程中运行:

  public static void findPerfectNumbersWithFunctionalAPI(){
        EventStream<Long> stream = someWayCreateAStream();
        stream.filter((x)->x>1)
                .filter(EventStream::isPerfect)
                .consume((x)->{System.out.println(x);})
                .onError((x)->System.out.println(x))
                .process(4096,1,4);

    }

当我们创建EventStreamEventConsumer的实例时,不会发生任何处理;只会创建元数据。只有在调用 process 方法时,处理才会开始。这是通过 process 方法启动生产者和消费者线程来完成的。生产者线程创建并排队包含初始值和处理代码(如 map、filter 或 consume 操作)的事件。一个去队列运行第一部分处理,并为下一级处理排队另一个事件;它对 map 和 filter 操作都这样做。consume 操作是处理链的终点,它不返回任何值。这是没有更多事件被排队的时刻。

这要求去队列线程也必须执行一些入队操作。这可能会出现什么问题?有两种类型的线程进行入队操作。其中一种线程还负责出队。当队列满时,这些线程在尝试执行入队操作时可能会被阻塞。但这也意味着它们将无法执行任何出队操作;这是因为如果它们这样做,队列将再也没有空间了。这种情况是一个死锁;所有线程都被阻塞,并期待其他线程做些什么来解锁它们。

为了了解为什么会出现这种死锁,让我们想象一个长度为 4 的队列。假设有两个出队线程,在某些情况下也会执行一次入队操作。我们再添加一个入队线程。由于线程可以以任何顺序运行,所以可能的情况是入队线程首先运行,并将四个新元素入队,使队列满。现在假设有两个出队线程运行,每个线程出队一个元素。在这些线程再次入队之前,入队线程再次运行,这次它又入队了两个新元素,使队列满。现在运行出队线程,但它们被阻塞了,因为队列已满。它们甚至不能出队任何元素,因为它们自己被阻止了入队更多元素。这是一个死锁情况。图 2展示了这种情况:

响应式编程的函数式方法

我们真正想要的是线程不仅能够执行入队操作,而且在队列完全满之前将其阻塞。这样,出队线程就可以利用一些空间来持续地进行出队和入队操作,直到它们达到一个点,它们将不再需要入队(因为它们已经到达了处理链的最后一个步骤)。最终,队列会变为空,入队线程可以再次被解除阻塞。为了做到这一点,我们需要有两种不同的入队操作。一种是在队列满之前不会阻塞的操作,另一种是在队列半满或更满时阻塞的操作。我们可以在ThreadSafeFixedLengthSpinlockQueue类中使用以下代码来实现第二种类型。enqueueProducerOnly方法与enqueue方法类似,但它执行的是对currentElementCount变量的原子检查,而不是简单地增加它。如果在入队过程中发现队列已经满了,我们就释放入队锁并重新开始。只执行入队操作而不执行出队操作的线程必须使用这个方法而不是常规的enqueue方法:

public void enqueueProducerOnly(E value ) throws InterruptedException{
        int halfLength = length/2;
        while (true) {

            int index = nextEnqueueIndex;
            nextEnqueueIndex = (nextEnqueueIndex+1) % length;
            if(enqueueLocks[index].compareAndSet(false,true)){
                int numberOfElements = currentElementCount.get();
                if(numberOfElements>=halfLength
                   || (!currentElementCount.compareAndSet(numberOfElements, numberOfElements+1))){
                    enqueueLocks[index].set(false);
                    continue;
                }
                store[index] = value;
                dequeueLocks[index].set(false);
                return;
            }
        }
    }

现在,我们可以使用这个方法在ProducerConsumerQueue类中实现相应的方法。这个方法与生产方法完全相同,只是在这里,入队调用被替换为对enqueueProducerOnly方法的调用:

public void produceExternal(E value) throws InterruptedException {
        Event event = new Event();
        event.value = value;
        event.eventType = EventType.INVOCATION;
        queue.enqueueProducerOnly(event);
 }

现在让我们看看EventStream类。EventStream类的全部目的是以功能方式创建元数据。它是一个只有read()这个抽象方法的抽象类。对read方法的调用应该返回下一个需要处理的对象。该类维护一个指向之前EventStream的指针,这个EventStream将在此工作。这意味着EventStream表示的操作将在所有之前的EventStream处理完数据后工作。它实际上是一个EventStream的链表。根据当前EventStream表示的操作类型,它可能有一个映射器、一个过滤器或什么都没有。read方法仅适用于生成数据的第一个EventStream。映射和过滤方法都返回另一个EventStream,它表示相应的处理。在所有映射和过滤调用之后,由EventStream链接的列表将存储从最后一个到第一个的所有操作:

public abstract class EventStream<E> {
    EventStream previous;
    OneArgumentExpressionWithException mapper;
    OneArgumentExpressionWithException filter;
    public <R> EventStream<R> map(OneArgumentExpressionWithException<E,R> mapper){
        EventStream<R> mapped = new EventStream<R>() {

            @Override
            public R read() {
                return null;
            }
        };
        mapped.mapper = mapper;
        mapped.previous = this;
        return mapped;
    }
    public EventStream<E> filter(OneArgumentExpressionWithException<E, Boolean> filter){
        EventStream<E> mapped = new EventStream<E>() {

            @Override
            public E read() {
                return null;
            }
        };
        mapped.filter = filter;
        mapped.previous = this;
        return mapped;
    }

然而,consume方法返回一个EventConsumer实例。这是任何不计算新值的链的终端处理。稍后将会展示的EventConsumer类包含启动处理的全部逻辑:

    public EventConsumer<E> consume(
      OneArgumentStatementWithException<E> consumer){
            EventConsumer eventConsumer = new EventConsumer(consumer, this) {
        };
        return eventConsumer;
    }
    public abstract E read();
}

由于我们需要在EventConsumer实例内部存储处理的详细信息,我们将首先创建几个类来存储这些信息。第一个是一个Task接口,它代表任何映射、过滤或消费操作:

public interface Task {
}

该接口由三个类实现,分别代表每种操作。为了存储代码,我们需要两个额外的功能接口,它们代表一个表达式和一个语句,这将允许你抛出异常:

@FunctionalInterface
public interface OneArgumentExpressionWithException<A,R> {
    R compute(A a) throws Exception;
}
@FunctionalInterface
public interface OneArgumentStatementWithException<E> {
    void doSomething(E input) throws Exception;
}

以下类实现了Task接口:

public class MapperTask implements Task {
    OneArgumentExpressionWithException mapper;
    Task nextTask;

    public MapperTask(
            OneArgumentExpressionWithException mapper,
            Task nextTask) {
        this.mapper = mapper;
        this.nextTask = nextTask;
    }

}

public class FilterTask implements Task{
    OneArgumentExpressionWithException filter;
    Task nextTask;

    public FilterTask(
            OneArgumentExpressionWithException filter,
            Task nextTask) {
        this.filter = filter;
        this.nextTask = nextTask;
    }
}

由于MapperTaskFilterTask是中间操作,它们都有一个指向下一个任务的指针。它们还存储与处理相关的代码片段。ProcessorTask代表终端操作,因此它没有指向下一个任务的指针:

public class ProcessorTask<E> implements Task{
    OneArgumentStatementWithException<E> processor;

    public ProcessorTask(
            OneArgumentStatementWithException<E> processor) {
        this.processor = processor;
    }
}

我们现在将创建一个EventConsumer类,该类将创建一个任务链并运行它:

public abstract class EventConsumer<E> {
    OneArgumentStatementWithException consumptionCode;
    EventStream<E> eventStream;
    Task taskList = null;
    private ProducerConsumerQueue<StreamEvent> queue;
    private OneArgumentStatement<Exception> errorHandler = (ex)->ex.printStackTrace();

StreamEvent是一个处理请求,它是生产者-消费者队列的一个元素。它将value存储为Objecttasktask可以通过其下一个引用指向更多任务:

    class StreamEvent{
        Object value;
        Task task;
    }

EventStream存储其上一个操作——也就是说,如果我们读取列表的头部,那将是最后一个操作。当然,我们需要按照执行顺序而不是反向顺序来安排操作。这正是eventStreamToTask方法所做的。MapperTaskFilterTask存储下一个操作,因此列表的头部是第一个要执行的操作:

        private Task eventStreamToTask(EventStream stream){
        Task t = new ProcessorTask(consumptionCode);
        EventStream s = stream;
        while(s.previous !=null){
            if(s.mapper!=null)
                t = new MapperTask(s.mapper, t);
            else if(s.filter!=null){
                t = new FilterTask(s.filter, t);
            }
            s = s.previous;
        }
        return t;
    }

构造函数是包访问权限的;它打算只从EventStreamconsume方法内部进行初始化:

    EventConsumer(
            OneArgumentStatementWithException consumptionCode,
            EventStream<E> eventStream) {
        this.consumptionCode = consumptionCode;
        this.eventStream = eventStream;
        taskList = eventStreamToTask(eventStream);
    }

以下代码块负责实际执行操作。ConsumerCodeContainer 类实现了 Consumer 并作为生产者-消费者队列的事件消费者:

    class ConsumerCodeContainer implements Consumer<StreamEvent>{
        @Override
        public void onError(Exception error) {
            errorHandler.doSomething(error);
        }

onMessage 方法在生产者-消费者队列中的每个事件上都会被调用。根据实际的任务,它采取相应的行动。请注意,对于 MapperTaskFilterTask,会入队一个新的带有下一个操作的事件:

        @Override
        public void onMessage(StreamEvent evt) {

ProcessorTask 总是处理链的终点。操作简单地在一个值上调用,并且不会排队新的事件:

            if(evt.task instanceof ProcessorTask){
                try {
                    ((ProcessorTask) evt.task).processor
                            .doSomething(evt.value);
                } catch (Exception e) {
                    queue.sendError(e);
                }
            }

对于 FilterTask,只有当条件满足时,带有下一个任务的事件才会入队:

            else if(evt.task instanceof FilterTask){
                StreamEvent nextEvent = new StreamEvent();
                try {
                    if((Boolean)((FilterTask) evt.task).filter.compute(evt.value)) {
                        nextEvent.task =
                                ((FilterTask) evt.task).nextTask;
                        nextEvent.value = evt.value;
                        queue.produce(nextEvent);
                    }
                } catch (Exception e) {
                    queue.sendError(e);
                }
            }

对于 MapperTask,下一个任务使用当前映射操作计算出的值入队:

             else if(evt.task instanceof MapperTask){
                StreamEvent nextEvent = new StreamEvent();
                try {
                    nextEvent.value = ((MapperTask) evt.task).mapper.compute(evt.value);
                    nextEvent.task = ((MapperTask) evt.task).nextTask;
                    queue.produce(nextEvent);
                } catch (Exception e) {
                    queue.sendError(e);
                }
            }
        }
    }

process 方法负责启动实际的任务处理。它使用 ProducerConsumerQueue 来安排由之前讨论的消费者处理的事件:

    public void process(int bufferSize, int numberOfProducerThreads, int numberOfConsumerThreads) {
      queue = new ProducerConsumerQueue<>(bufferSize,
      numberOfConsumerThreads, new ConsumerCodeContainer());

只有在调用映射和过滤的原始 EventStream 上实现了 read 方法。所以我们只需获取原始 EventStream 的引用:

        EventStream s = eventStream;
        while(s.previous !=null){
            s = s.previous;
        }

startingStream 变量指向原始的 EventStream

        EventStream startingStream = s;

生产者代码也在单独的线程中运行。Runnable producerRunnable 包含生产者代码。它简单地不断调用 EventStreamread 方法,直到返回 null(这标志着流的结束),并使用 eventStreamToTask 方法创建的值和任务链将一个 StreamEvent 入队:

        Runnable producerRunnable = ()->{
            while(true){
                Object value = startingStream.read();
                if(value==null){
                    break;
                }
                StreamEvent nextEvent = new StreamEvent();
                try {
                    nextEvent.value = value;
                    nextEvent.task = taskList;
                    queue.produceExternal(nextEvent);
                } catch (Exception e) {
                    queue.sendError(e);
                }
            }
            try {
                queue.markCompleted();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

现在我们启动 producer 线程,并使用 join 调用等待它们完成:

        Thread [] producerThreads = new Thread[numberOfProducerThreads];
        for(int i=0;i<numberOfProducerThreads;i++){
            producerThreads[i] = new Thread(producerRunnable);
            producerThreads[i].start();
        }
        for(int i=0;i<numberOfProducerThreads;i++){
            try {
                producerThreads[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }

这是一个注册自定义错误处理器并返回新的 EventConsumer 的方法:

    public EventConsumer<E> onError(
               OneArgumentStatement<Exception> errorHandler){
        EventConsumer<E> consumer 
         = new EventConsumer<E>(consumptionCode, eventStream) {};
        consumer.taskList = taskList;
        consumer.errorHandler = errorHandler;
        return consumer;
    }
}

回到我们最初关于完美数的问题,我们现在需要做的是定义一个带有读取方法的 EventStream,该方法生成所有数字,然后按照以下方式对它们进行映射和过滤。请注意,如果使用多个生产者线程,EventStream.read() 方法可能会被多个线程同时调用,因此它最好是线程安全的。

read 方法简单地增加一个 AtomicLong 并返回前一个值,除非前一个值大于 5_00_000L;在这种情况下,它返回 null,标志着流的结束。我们已经看到了其余的代码:

  public static void findPerfectNumbersWithFunctionalAPI(){
        long start = System.currentTimeMillis();
        EventStream<Long> stream = new EventStream<Long>() {
            AtomicLong next = new AtomicLong(0L);
            @Override
            public Long read() {
                Long ret = next.incrementAndGet();
                if(ret<=5_00_000L){
                    return ret;
                }
                return null;
            }
        };
        stream.filter((x)->x>1)
                .filter(EventStream::isPerfect)
                .consume((x)->{System.out.println(x);})
                .onError((x)->System.out.println(x))
                .process(4096,1,4);

        System.out.println("Time in ms: "+(System.currentTimeMillis()-start));
    }

这段代码运行时间几乎与之前没有功能 API 的响应式版本相同。我将把它留给你,使用功能 API 来实现朋友计数解决方案,因为它相当简单,一旦掌握了它就很容易。你需要考虑的只是如何实现 read 方法以从文件中返回整数。

摘要

在本章中,我们学习了如何使用可变字段、原子操作和信号量进行高级线程同步。我们利用这些技术创建了自己的响应式编程框架,并为响应式编程创建了一个功能 API。我们使用我们的框架来解决示例问题,并看到了如何使用响应式框架轻松编写多线程可扩展应用程序。

可用的响应式编程框架有很多,例如 RxJava、Akka 等。它们在实现和功能上略有不同。它们都提供了比我们使用的更多功能。本章只是对该主题的介绍;感兴趣的读者可以从专门针对该主题的书籍中了解更多关于响应式编程的信息。

在这本书中,我尝试通过 Java 实现让您在算法的世界中领先一步。算法是一个广泛的研究领域。每个计算问题都需要通过算法来解决。进一步的研究将包括算法的复杂度类别、算法的等价性和针对高度复杂问题的近似算法。复杂问题是指任何解决它的算法都必须具有一定复杂性的问题。这导致了问题复杂度类别的概念。还有正式/数学的方法来证明算法的正确性。所有这些领域都可以由您追求。

本书还简要介绍了函数式和响应式编程。这应该可以作为这些领域的入门;您可以在专门针对这些主题的书籍中了解更多关于它们的信息。

posted @ 2025-09-10 15:06  绝不原创的飞龙  阅读(18)  评论(0)    收藏  举报