快乐方法学数据结构-全-

快乐方法学数据结构(全)

原文:zh.annas-archive.org/md5/c571a675ce440c08fc53300879391270

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

这是一本通过数据结构的视角探讨计算思维的书,数据结构是用于组织和存储数据的构造。它不仅仅是一本实用数据结构的手册,更是探讨这些结构背后的思维方式及其在解决复杂问题中的根本影响,利用现实世界的类比使抽象的计算概念变得直观易懂。本书的目标是提供新的见解,帮助你利用数据中现有的结构发挥优势,或者创造新的结构来高效地解决问题。

除此之外,我讨论了数组和链表的区别、指针的复杂性与强大功能、数据结构对算法行为的影响、基于树的数据结构的分支、哈希表中的数学映射以及随机化的有用性。简而言之,你将通过调查组织数据的不同方式来思考算法。你还将把这些计算方法应用到现实问题中,令人惊讶的是,许多问题都集中在如何获得一杯好咖啡。

了解数据结构的运作方式对于有效使用它们至关重要。就像一个经验丰富的木匠不会用锤子敲入螺丝,或者用砂纸切割一块两乘四的木板一样,经验丰富的程序员也需要为每项任务选择合适的工具。正如我们将在接下来的章节中反复看到的那样,每种数据结构都有其权衡利弊。锯子比砂纸更有效地切割木材,但会产生粗糙的边缘。没有任何一种数据结构是适用于所有可能用例的完美选择,但这正是计算机科学和算法开发如此有趣的原因。一个优秀的计算机科学家必须理解不同数据结构的行为,以便确定它们在哪些场景下可以发挥最佳作用。

本书聚焦于几种经典数据结构,并以它们为基础探索计算思维中的基本主题。每种数据结构都是更一般类别的数据结构和概念方法的有用示范。例如,B 树展示了解决保持搜索树平衡和优化昂贵内存访问问题的一种方法。我会讨论布隆过滤器中内存使用与准确性之间的权衡;跳表中随机化的使用;以及如何通过网格、四叉树或 k-d 树捕捉多维结构。因此,本书既不是编程入门,也不是数据结构的全面选集,更不是咖啡酿造的详细分析(尽管我们会反复讨论这一重要话题)。我们的目标不同——开发能应用于一系列特定问题和编程语言的思维工具。

目标读者

本书适合任何想要深入了解计算机科学核心数据结构背后思维的人。我假设读者至少具备一定的编程基础,通常可以通过参加入门课程、参加训练营或学习初学者编程书籍来获得这些基础。读者应该熟悉基本的编程概念,如变量、循环和条件语句。一些更有冒险精神的读者甚至可能已经编写过本书中涉及的一些数据结构或算法,或者在阅读过程中会亲自编写。然而,读者无需了解特定编程语言或算法的细节。

我希望本书能吸引广泛的读者群体。通过入门课程学习过基本编程的从业人员,将发现一种计算思维的介绍,为未来的深入探索打下基础。学生将找到一种理解特别困难或棘手课题的新方法。数学家将学到一些新的术语和行话,这些概念早在计算机科学存在之前就已经被他们使用过了。而经验丰富的计算机科学家将发现一些有趣的新类比,来解释他们日常使用的概念。

与语言无关

本书旨在适用于多种编程语言。虽然这可能会让一些有强烈偏好的读者感到失望,他们希望要么 (a) 在书中看到自己喜爱的编程语言,要么 (b) 争论作者的编程语言偏好,认为这反映了不佳的生活选择(因为编程语言就像体育队伍一样,总是热议的对象),但书中呈现的概念在多种语言中通常都是适用的。例如,几乎任何编程语言都可以实现二叉搜索树。事实上,大多数编程语言已经将许多这些基本数据结构作为核心语言的一部分或标准库提供。

本书使用的伪代码示例主要基于 Python 的通用语法,因为 Python 是一种广泛使用且易于阅读的编程语言。我通过缩进表示代码块,使用标准的等式符号(== 表示相等,!= 表示不相等),使用 TrueFalse 来表示布尔值,用以 # 符号开头的行来表示注释,并且以引用的方式传递复合数据结构。数组是从零开始索引的,索引 i 位置的值通过 arr[i] 来引用。

然而,我也会在有助于提高可读性的地方偏离 Python 语法。我将所有变量指定为 Type: Name 以明确类型,并使用值 null 来表示空指针。我常常使用 WHILE 循环而非 FOR 循环或其他紧凑的形式,以清晰地展示循环是如何迭代的及其终止条件。

我故意将本书中的例子保持简单,以便专注于它们背后的计算思想。这意味着个别实现可能没有完全优化,通常会比严格必要的更加冗长。在整本书中,我会分解不同的条件,以阐明方法背后的思维过程。有时,为了使代码结构更符合解释,我的实现会与编程最佳实践有所不同。此外,为了保持例子的简洁,我经常省略那些在生产程序中至关重要的基本有效性检查,例如检查我们的数组访问是否越界。不言而喻,应该把这些例子当作概念的说明,而不是直接在自己的项目中使用它们。一般来说,这是一个很好的原则:永远不要将伪代码当作完整的实现。自己实现算法时,务必加入相关的测试、有效性检查和其他最佳实践。

关于类比和冲泡咖啡

本书广泛使用隐喻和类比,通过与(有时荒谬的)现实世界情境的比较来阐明复杂的技术概念。比喻就像蓝莓散布在松饼里一样,遍布全书。每一章都用从整理厨房到判断你是否尝试过某种特定咖啡饮品等例子,来解释数据结构和算法的复杂运作,邀请你以不同于计算机代码的方式思考这些计算概念是如何工作的。

这些例子常常会打破现实的规则,过于简化,甚至有些荒谬。例如,我们反复探讨存储和排序大量咖啡的收集问题,却忽视了一个悲惨的事实——咖啡确实会变陈旧。虽然这意味着本书并非一本严格现实的终极咖啡制作指南,但这些荒诞的类比让事情变得有趣,并应当鼓励你跳出常规思维方式。简化类比让我们可以专注于那些对计算概念至关重要的方面。例如,在讨论使用最近邻搜索来寻找一杯接近的咖啡时,我专注于距离(核心计算概念),忽略了诸如栅栏或河流等复杂因素。我的目标是将类比调整到问题的核心。

我使用类比来增强正式的描述和精确的代码。就个人而言,我发现当以一种生动的叙述性背景来看待数据结构的操作时,比起固守FOR循环和变量计数器的术语,我更容易摆脱技术细节和琐碎事务。想象一场通过胡同迷宫的疯狂追逐,从而提供了与正式的图算法背景不同的视角,后者是围绕抽象节点和边的迭代。我鼓励读者将这些类比映射到他们自己广泛的概念范围内,无论是日常生活的一部分,还是荒诞的幻想。

如何使用本书

这本书是按进阶结构编排的。也就是说,尽管每一章都集中讨论一个不同的计算概念——无论是数据结构还是问题的动机——每一章也都是建立在前面章节的基础上。例如,后面的章节大多依赖于第三章中介绍的内存关联数据结构和指针的使用。当我们审视分支数据结构的变种时,会一再回到第五章介绍的基本二叉搜索树结构。因此,我建议你按照章节顺序阅读。

在我们探索不同的数据结构及其如何应用于各种问题时,我们会看到一些持续出现的主题,包括:

  • 数据结构对访问它的算法的影响

  • 如何考虑最坏情况的性能

  • 允许数据集动态变化的重要性,以及如何高效地启用这些变化

  • 内存、运行时间、代码复杂性和准确性之间的权衡

  • 我们可能需要如何调整数据结构以适应问题,并且需要考虑哪些权衡

  • 我们如何调整数据结构以应对新问题

这些主题提供了思考数据结构的框架,以及在面对新问题时应提出的一组问题。选择数据结构的关键在于理解为什么它会以这种方式执行,以及它如何应用于新数据。

最重要的是,贯穿全书你应始终牢记的两个问题是:“如何?”和“为什么?”如何一个给定的数据结构能实现计算?如何我们组织数据,以在特定上下文中最大化效率?为什么一个给定的结构能实现这些计算?如何在不同的上下文中这个数据结构会崩溃?为什么作者使用那个荒谬的类比?为什么作者如此痴迷于咖啡?理解这些问题的答案(除了最后一个)将为你有效使用现有数据结构并在未来开发新技术奠定基础。

第一章:内存中的信息

任何稍微有趣的计算机程序都需要能够从内存中存储和访问数据。这些数据可能是文档中的文本、网页上的信息,或是我们在数据库中存储的所有曾经品尝过的咖啡种类的详细信息。在每一种情况下,数据都是程序执行其预期功能的基础。

这些例子仅代表了用户看到和思考的数据。程序还必须在后台追踪许多数据,例如我们通过循环的次数、游戏中角色的当前位置或当前的系统时间。如果没有这些数据,程序就无法表示其内部状态的变化。

本章我们将探讨如何将数据存储在内存中的基础知识。我们将查看最简单的数据结构——普通变量、复合数据结构和数组——如何存储数据。我们还将介绍本书的伪代码约定。对于有编程经验的读者来说,本章的关键概念可能已经很熟悉了。尽管如此,这些概念仍然是我们学习的关键起点,值得复习,因为它们为我们构建更强大和更有趣的数据结构提供了基础。

变量

单独的数据通常存储在变量中,变量本质上是表示数据在计算机内存中位置(或地址)的名称。即使是初学编程的读者也已经对变量有所了解:它们是计算机科学中的基础概念,是即使是最简单的程序也必不可少的。变量使程序能够追踪在程序运行过程中变化的信息。需要统计你在FOR循环中执行了多少次?追踪游戏中玩家的得分?统计你在编写关于变量的引言章节时犯了多少个拼写错误?使用变量吧。

没有变量,程序员无法跟踪、评估或更新程序的内部状态。当你创建一个变量时,系统会在后台分配并为其指定一个位置。然后,你可以使用自己选择的变量名向该位置写入数据,并使用相同的名称查找数据。只要你知道变量的名称,就不需要知道数据的内存位置。我们可以将计算机的内存想象成一列长长的储物箱。每个变量占据一个或多个连续的储物箱,具体取决于变量的大小,如图图 1-1 所示,其中有三个变量:LevelScoreAveScore。在这个示例中,平均分数(AveScore)是一个浮动小数(带有小数的数字),它使用了两个内存储物箱。

一张图示,显示了值为 20 的 level 变量占据一个储物箱,值为 109 的 score 变量占据一个储物箱,以及值为 100.111111 的 average score 变量占据两个储物箱。

图 1-1:计算机内存被描述为一列箱子

在某些方面,变量就像文件夹上的小纸标签,类似于图 1-2 中的标签:一旦我们附上了标签,就不需要记住文件夹的顺序或它们具体存储了什么内容。我们只需要通过标签查找文件夹——但这也意味着使用具有信息量的名称非常重要。作者自己的文件柜里塞满了名称模糊的文件夹,如StuffMiscImportantOther Stuff,这使得很难知道里面存储的是什么。同样,模糊的变量名也使得很难猜测它们代表的具体值。

带有标签页的文件,标签方便文件的快速查找。标签从前到后依次为 Level、Top Score、Score 和 Other Stuff。

图 1-2:变量就像文件夹上的标签,提供了便捷的方式来查找和访问存储的值。

在许多编程语言中,变量有一个关联的类型,表示它们存储的确切数据类型,例如整数、浮点值(“floats”)或布尔值(true 或 false)。这些类型告诉程序变量占用多少内存以及如何使用这些内存。例如,一个布尔变量存储的是有限范围的值,通常只需要少量内存。一个双精度浮点数可能存储一个更大且更精确的数字,因此需要多个内存单元。定义类型的语法,以及是否需要显式定义类型,在编程语言之间有所不同。

在本书中,我们将使用语言无关的<type>: <name>伪代码格式来指定示例中的变量。例如:

Integer: coffee_count = 5
Float: percentage_words_spelled_correctly = 21.0
Boolean: had_enough_coffee = False

有时,变量的类型会是通用的Type,表示它可以根据实现的不同而具有多种类型。我们将使用大多数编程语言中的典型语法来操作这些变量,包括使用=进行赋值:

coffee_count = coffee_count + 1

对于数字类型,包括整数和浮点数,我们将使用标准的算术运算,如+-*/。对于布尔数据类型,我们将使用布尔运算,如ANDORNOT。你在程序中需要使用的语法将根据编程语言的不同而有所不同(这也是不同编程语言之间争论的一个常见焦点)。

复合数据结构

许多编程语言提供了创建复合数据结构的能力,如结构体或对象,它们将多个独立的变量聚合成一个整体。复合数据结构提供了一种便捷的方式来收集相关数据并将它们一起传递。例如,我们可以定义一个CoffeeRecord来跟踪我们品尝的咖啡种类的一些信息:

CoffeeRecord {
    String: Name
    String: Brand
    Integer: Rating
    Float: Cost_Per_Pound
    Boolean: Is_Dark_Roast
    String: Other_Notes
}

我们不会维护六个单独的变量来追踪一杯咖啡的属性,而是将所有信息存储在一个单独的复合数据结构 CoffeeRecord 中。当然,一个真正的咖啡爱好者可能会追踪几百个额外的属性,以及与咖啡消费相关的日期、时间、地点和天气条件的精确资料。毕竟,咖啡是一个复杂的话题,值得详细记录。每个附加的属性都进一步强调了使用复合数据结构的重要性:传递几百个相关变量的替代方法不仅繁琐,而且还增加了程序员犯错的概率,比如将变量错误地传递给函数。

名片提供了一个现实世界中复合数据结构的示例。每张名片都是一个数据包,包含多个信息片段,比如姓名、电话号码和电子邮件地址。将这些信息打包成一张名片可以提高跟踪和传递的效率。想象一下,如果你给同事五张包含单一数据点的纸条,会有多么混乱和困惑。

在许多编程语言中,包括 Java 和 Python,数据复合体可以采取对象的形式,这些对象包含了操作其自身数据的函数和数据。对象的函数使用特殊的语法来访问该对象的数据,例如 Python 中的 self 引用。对象还可以提供不同的可见性规则,指定其内部数据是否可以在对象的函数外部公开访问,或者仅限于私密访问。

为了尽可能通用,我们将以最一般的形式处理复合数据结构:作为一组数据。虽然本书及其他地方的示例代码片段可能将复合数据结构实现为对象,但算法也可以调整为使用非对象表示。在使用复合数据结构或对象的代码中,我们使用 composite.field 语法来表示访问复合数据结构的特定字段。例如,使用以下代码:

latest_record.name = "Sublime Blend"

我们将咖啡日志中 latest_record 记录的 name 字段设置为 Sublime Blend

数组

数组通常用于存储多个相关的值。例如,我们可能想要追踪一年内每天消耗的咖啡量。我们可以通过创建 365 个单独的变量来强行存储,例如 AmountDay1AmountDay2AmountDay3 等等,但这既繁琐又不能为数据提供任何结构。AmountDay2 只是一个文本标签,因此程序并不知道 AmountDay1 存储的是前一天的信息,而 AmountDay3 存储的是后一天的信息;只有程序员自己知道这一点。

数组提供了一种简单的机制,用于将多个值存储在相邻且可索引的容器中。数组实际上是一个变量的排列——在计算机内存中是一个连续的相同大小的容器块,如图 1-3 所示。像单独的变量一样,数组占用一块内存,并可以与任意其他信息相邻。数组的每个容器都可以存储给定类型的值,如数字、字符、指针,甚至其他(固定大小的)数据结构。

一列 12 个容器。数组占据中间的 5 个容器。数组下方的 4 个容器和上方的 3 个容器标记为“其他内容”。

图 1-3:数组作为计算机内存中的容器

数组在我们的日常生活中也随处可见。例如,高中走廊里一排排储物柜就是一个物理数组,用于存储学生的外套和书本。我们只需打开相应的储物柜,就可以访问任何单独的存储容器。

数组的结构使得我们可以通过指定其位置或索引来访问数组中的任何值,也称为元素。这些容器在计算机的内存中占据相邻的位置,因此我们可以通过计算它们相对于第一个元素的偏移量并读取该位置的内存来访问单个容器。无论我们访问哪个容器,这都只需要一次加法运算和一次内存查找。这种结构使得数组在存储具有顺序关系的项目时尤为方便,例如我们的日常咖啡摄入量跟踪器。

正式地,我们通过A[i]来引用数组A中索引i位置的值。在我们的储物柜示例中,索引将是储物柜前面显示的数字。大多数编程语言使用零索引数组,这意味着数组的第一个值位于索引 0,第二个位于索引 1,依此类推,如图 1-4 所示。

一张图示,展示一系列存储在九个容器中的值。下方显示它们的索引值。最左侧的第一个索引值是零。索引值逐一增加,右侧为 8。

图 1-4:零索引数组

本书将全程使用零索引数组,以遵循通用的计算机约定。图 1-5 展示了零索引数组在计算机内存中的表现,其中白色区域是数组的元素。

一列 12 个容器。数组占据中间的 5 个容器。标记为 A 零至 A 四。

图 1-5:计算机内存中排列的零索引数组

零索引便于我们通过计算数组在内存中起始位置的偏移量来确定元素在内存中的位置。数组中第i个元素的位置可以通过以下方式计算:

位置(项目 i) = 位置(数组起始位置) + 每个元素的大小 × i

索引零位置的元素是数组的起始位置。例如,图 1-5 中的示例数组A的第五个元素将是A[4],并且根据图 1-4 中的索引值,包含的值为 9。

在大多数编程语言中,我们通过数组的名称和索引的组合来获取和设置数组中的值。例如,我们可能会将索引为 5 的箱子的值设置为 16:

A[5] = 16

对于我们的咖啡追踪示例,我们可以定义一个数组Amount来存储一天内消耗的杯数,并将相应的计数存储在Amount[0]Amount[364]中。这个单一数组允许我们通过一个变量名有序地访问 365 个不同的值。我们从一系列类似命名但独立的变量,转变为一个数学偏移量的单一位置。为了理解这一点的强大之处,可以考虑我们的学校储物柜。如果将每个储物柜命名为“Jeremy 的储物柜”或“第三个姓氏以 K 开头的学生的储物柜”,那么它们几乎无法迅速找到。学生们不仅仅是访问特定的索引,而是必须检查大量储物柜,比较标签,直到找到正确的匹配。使用数组索引,学生们只需使用它的偏移量来确定储物柜的位置并直接访问它。

尽管我们经常将数组可视化并讨论为整个数据结构,但需要记住的是,每个箱子像一个独立的变量。当我们想对数组进行全局更改,例如将元素向前移动一个位置时,我们需要像图 1-6 所示一样,逐个对每个箱子应用这个更改。

一个显示 8 个箱子的图示。箭头表示箱子中的值都向左移动了一个箱子。

图 1-6:在数组中按箱逐个向前移动元素

数组不像书架上的书籍。我们不能一次性将整个集合推到一边,为最新版本的咖啡爱好者指南:最佳公平贸易咖啡腾出空间。数组更像是一排商店铺面。我们不能仅仅在我们最喜欢的街区书店和理发店之间塞进一家新的咖啡店。为了腾出空间,我们需要一个一个地将商店铺面向后挪动,先清空每个商店并将其内容转移到邻近的店铺。

实际上,我们必须在数组中交换两个值时,进行值的调度。例如,要交换索引ij的值,我们需要首先将其中一个值赋给一个临时变量:

Temp = A[i]
A[i] = A[j]
A[j] = Temp

否则,我们可能会覆盖某个槽位中的值,导致两个槽位拥有相同的值。同样,如果我们要交换咖啡店和书店的位置,我们首先需要将书店的内容移动到一个空的第三位置,以腾出空间给咖啡店的内容。只有等我们移动了咖啡店的位置后,才能把书店的内容从临时的第三位置移动到咖啡店原来的位置。

插入排序

理解数组的结构对其使用影响的最佳方法是将其置于实际算法的上下文中。插入排序是一种对数组中的值进行排序的算法。它适用于任何可以排序的值类型。我们可以排序整数、字符串,甚至按照过期日期排序我们储藏室里的咖啡。

插入排序通过对数组的一个子集进行排序,并不断扩展已排序范围,直到整个数组有序。该算法通过遍历未排序数组中的每个元素,将其移动到已排序部分的正确位置。在第i次迭代开始时,索引0i − 1 的元素已经是有序的。然后,算法取出索引i处的元素,在已排序的前缀中找到正确的位置并将其插入,同时将需要的元素下移以腾出空间。已排序的前缀现在增长了一项——索引0i的元素已经是有序的。我们可以从i = 1 开始,将第一个元素声明为我们的初始有序前缀。

假设我们想按新鲜度对咖啡收藏进行排序——毕竟,如果把一袋优质咖啡放在储藏室的最里面,等它变陈旧,那可真是太遗憾了。我们需要将最早的最佳食用日期移到架子的左侧,以便它们能随时取用。

我们开始进行咖啡插入排序时,首先宣布最前面的一个咖啡袋为已排序,并使用这个范围作为我们的已排序前缀。然后,我们查看架子上的第二袋咖啡,比较日期以决定它是否应该排在第一袋之前。交换顺序后,或确定不需要交换时,我们可以自信地宣布前两项已排序。我们已经有了一个完全排序的子集。接下来,我们继续处理第三袋咖啡,确定它相对于前两袋的位置,可能会进行一些交换。这个过程继续进行,直到我们实现完美的咖啡整理。

我们可以通过一对嵌套循环来实现插入排序,如列表 1-1 所示。

InsertionSort(array: A):
    Integer: N = length(A)
    Integer: i = 1
  ❶ WHILE i < N:
        Type: current = A[i]
        Integer: j = i - 1  
      ❷ WHILE j >= 0 AND A[j] > current:
            A[j + 1] = A[j]
            j = j - 1
        A[j + 1] = current
        i = i + 1

列表 1-1:通过嵌套循环实现插入排序

外层循环使用迭代器i,从第一个未排序的元素开始,i = 1,并依次遍历未排序范围中的每个值❶。内层循环使用迭代器j将当前值移入已排序前缀❷。在每一步中,我们通过将current值与前一个位置的值进行比较,检查在已排序前缀中的位置,索引j。如果j处的元素较大,说明两个值的顺序错误,需要交换。由于我们将当前值存储在一个单独的变量current中,所以可以直接从前一个位置复制数据,无需完全交换。内层循环会继续进行,直到将当前值移至数组前端,或找到一个较小的前置值,这表示当前值已经在已排序前缀的正确位置。只有当当前值处于正确位置时,我们才在循环结束时将其写入数组。然后,外层循环继续处理下一个未排序的值。

我们可以通过图 1-7 来可视化算法的行为。每一行展示了迭代开始时数组的状态。阴影框表示当前正在移动到正确位置的元素,箭头表示相应的移动。

一张图示,展示了通过 8 次迭代处理 8 个元素的过程。开始时,索引零处的值是 61,假定已经排序。第二个值是 82,保持不动。第三个值是 67,它与 82 交换。第四个值是 4,它被移到索引零处,前三个值向前移动一个位置以腾出空间。依此类推,直到所有 8 个值按从小到大的顺序排列。

图 1-7:插入排序算法的可视化

插入排序效率并不高。在向数组中插入元素时,我们可能需要移动数组的较大部分。在最坏情况下,算法的时间复杂度与元素数量的平方成正比——对于列表中的每个元素,我们都需要移动它前面的所有元素。如果数组的大小翻倍,最坏情况下的时间复杂度将增加四倍。虽然在我们的咖啡室中这可能不是很大的开销,因为我们可能只存放少量的咖啡,能够在咖啡变陈之前消耗掉,但在许多应用中,算法的二次时间复杂度将急剧上升。

然而,插入排序提供了一个重要的见解,帮助我们理解数组的运作。通过这个简单的算法,我们展示了数组的几个属性,包括能够通过索引访问元素的能力、在插入新元素时交换值的能力,以及遍历数组项的有价值的能力。

字符串

字符串是字符的有序列表,通常可以被视为一种特殊类型的数组。字符串中的每个槽位包含一个字符,无论是字母、数字、符号、空格,还是有限的一组特殊标记。特殊字符通常用于表示字符串的结束,如图 1-8 中的最后一个槽位所示的/。字符串中的字符通常可以通过其索引直接访问。

一张图显示 13 个字符,索引从零到 12。字符拼写为 Hello World,感叹号,最后一个字符是斜杠。

图 1-8:一个拼写为“Hello world!”的字符串

在某些编程语言中,字符串直接实现为简单的字符数组。在其他语言中,字符串可能是对象,字符串类作为包装器封装了字符数组或其他持有字符的数据结构。字符串的包装类提供了额外的功能,例如动态调整字符串大小或搜索子字符串的能力。无论哪种情况,考虑数组般的结构如何影响字符串的操作都是有用的。当我们在计算机屏幕上显示字符串时,实际上是在逐个遍历其字符并逐一显示它们。

比较相等性的常见测试更值得关注。与可以通过单一操作直接比较的整数不同,字符串必须通过逐个字符进行比较。程序会逐个字符地将其与另一个字符串进行比较,并返回是否发现不匹配的结果。

列表 1-2 展示了检查两个字符串相等性的算法。算法首先比较字符串的大小。如果它们的长度不同,算法就会停止。如果它们的长度相同,算法会逐个位置进行迭代,并比较每个字符串中的相应字母。只要我们发现一个不匹配的字符,就可以终止循环。只有当我们遍历完整个字符串而没有发现不匹配时,才能声明这两个字符串相等。

StringEqual(String: str1, String: str2):
    IF length(str1) != length(str2):
        return False
    Integer: N = length(str1)
    Integer: i = 0
    WHILE i < N AND str1[i] == str2[i]:
        i = i + 1
    return i == N

列表 1-2:检查两个字符串相等性的算法

图 1-9 演示了该算法如何对两个字符串进行操作。等号表示比较时匹配的字符对,X 表示第一个不匹配的字符对,测试在此处终止。

两串字符串,“Hello world”和“Hello friend”。等号表示前六个字符相同。位置 7 的 X 表示 W 和 F 不同。

图 1-9:两个字符串的比较

字符串比较的最坏情况下的计算成本与字符串的长度成正比。虽然比较两个小字符串所需的工作可能微不足道,但对两个长字符串执行相同的操作时可能会非常耗时。为了作对比,可以想象一下逐字扫描两本相同书籍的两个版本,寻找它们之间的每个文字差异。在最好的情况下,我们可以早早发现不匹配。在最坏的情况下,我们需要检查大部分内容。

许多编程语言,如 Python,都提供了一个字符串类,允许直接比较,因此我们不需要在列表 1-2 中直接实现比较代码。然而,在这个简单的比较函数背后,实际上有一个循环,它会遍历所有的字母。如果没有理解这一关键细节,就可能大大低估字符串比较的成本。

为什么这很重要

变量和数组是编程入门课程的基础内容,因此它们看起来可能不那么令人兴奋,但它们非常重要,因为它们为计算机编程和数据结构提供了基础。这些概念也为评估动态数据结构及其对算法的影响提供了基准。在后面的章节中,我们将看到动态数据结构如何在效率、灵活性和复杂性之间提供不同的权衡。

第二章:二分查找

二分查找 是一种高效查找已排序列表的算法。它通过不断将列表一分为二,确定哪一半可能包含目标值,并丢弃另一半,从而在已排序的列表中查找目标值。这个算法逻辑简单且易于实现,是计算机科学的完美入门主题,因此二分查找算法几乎在所有计算机科学课程和教材中都有介绍。

怀疑的读者可能会想,“我到底有多大概率需要查找一个已排序的列表?”或者更准确地说,“我有多大可能需要实现一个查找已排序列表的函数?难道几百万个人没做过这个吗?不是已经有现成的库了吗?”虽然你不应该排除某天需要自己实现二分查找的可能性,但它真正的重要性远超其实现。

二分查找展示了聪明的算法如何利用数据存储的结构来实现显著的计算节省,即使这种结构简单到只有已排序的数据。二分查找的正确性和效率都容易分析,提供了速度和正确性的保证,并展示了数据和算法之间的基本交互。它是检视数据存储技术差异的一个极好视角,比如链表和数组的区别,或许多基于树的算法背后的动机。它甚至可以用来制作一杯更好的咖啡。

问题

在定义任何新算法之前,我们必须定义算法要解决的问题。在本章中,我们的目标是找到一个列表中的单个项,匹配给定的目标值;我们需要一个可以高效执行这种搜索的算法。我们可以正式地将这个搜索定义为:

给定一组数据点 NX = {x[1], x[2], ... , x[N]} 和一个目标值 x',找到一个点 x[i]∈ X 使得 x' = x[i],或者指出不存在这样的点。

在我们日常生活中,我们可能会将这个任务描述为“找到这个特定的东西”。这个搜索问题是我们每天都会遇到的。我们可能在字典中找一个词,在联系人列表中找一个名字,在历史事件列表中找一个特定的日期,或者在拥挤的超市货架上找我们喜欢的咖啡品牌。我们所需要的只是候选列表和一种检查我们是否找到匹配项的方法。

线性扫描

为了了解二分查找的优势,我们首先从一个更简单的算法——线性扫描开始,以提供一个比较基准。线性扫描通过逐个检查列表中的每个值是否与目标值匹配,直到找到目标值或遍历完整个列表为止。这就像作者在超市货架上寻找商品的方式——一边沿着五光十色的咖啡包装逐个用手指滑过,一边自言自语地抱怨需要更好的索引方案。

假设我们正在查找数组A中的目标值。这里我们设定target = 21。我们依次遍历数组中的每个元素,并检查它是否等于 21,如图 2-1 所示。

清单 2-1 显示了线性扫描的代码。如果找到了匹配的元素,代码会返回该元素的索引;如果搜索失败,说明该元素不在数组中,代码将返回索引-1

LinearScan(Array: A, Integer: target):
    Integer: i = 0
    WHILE i < length(A):
        IF A[i] == target:
            return i
        i = i + 1
    return -1

清单 2-1:线性扫描的代码

一个图示,展示如何对包含 11 个无序数字的数组进行线性扫描。我们检查第一个元素是否等于 21;如果不是,就检查下一个元素,依此类推,直到找到匹配项。

图 2-1:对整数数组进行线性扫描

一个单独的WHILE循环遍历数组中的每个元素,内部的IF语句将该元素与目标值进行比较。只要找到与目标匹配的元素,就返回相应的索引。如果遍历到数组末尾还没有找到匹配项,则返回-1

线性扫描既不华丽也不聪明。它是一个蛮力测试,能够确保找到感兴趣的项目(如果该项目在数据中存在),因为它会检查每个可能的元素,直到找到匹配项或确认该项不存在。这种方法虽彻底,但效率低,尤其对于大型列表而言。如果我们对A中的数据结构一无所知,就无法通过任何方式优化该过程。目标值可能在任何一个位置,所以我们可能需要检查所有的元素。

为了说明线性扫描的局限性,假设我们正在对一排物理排列的物品进行扫描,比如一排站在教室外面的计算机科学入门学生。老师想要将某个学生的作业还给他,于是走到每个学生面前,询问“你的名字是 Jeremy 吗?”,然后可能会继续询问下一个学生。搜索在老师找到正确的学生或走到队伍末尾时结束。学生们(正确地)翻了翻白眼,低声抱怨他们低效的老师。

有时可以通过每次比较使线性搜索更快。例如,我们可以通过在比较字符串时在第一个不匹配的字母处停止来优化比较时间,如第一章所述。同样,在超市的情况中,我们可以提前消耗大量咖啡,这样我们的指尖在货架上滑动得更快。然而,这种方法只有在某些程度上才有效。我们仍然限制于逐一检查每个物品。

在接下来的部分,我们将看到数据中少量结构如何改变一切。

二分查找算法

二分查找是一种在已排序列表中查找目标值v的算法,仅适用于已排序的数据。该算法可以写成适用于按递增或递减顺序排序的数据,但现在我们假设数据是按递增顺序排序的——从最低到最高。该算法通过将列表分成两半,并确定v必须位于哪一半来进行操作。然后它会丢弃v不在的那一半,并仅对可能仍包含v的那一半重复此过程,直到只剩下一个值。例如,如果我们在图 2-2 所示的排序列表中查找值 7,我们会在中点找到 5,并排除列表的前半部分。中点前的任何元素不可能大于 5,而由于 5 小于 7,5 之前的所有元素也都小于 7。

一份从 1 到 9 的整数的排序列表。箭头指向值为 5 的中点。

图 2-2:从 1 到 9 的排序整数列表,其中 5 是中点

高效算法的关键在于利用数据中的信息或结构。对于二分查找,我们利用数组按递增顺序排序这一事实。更正式地说,考虑一个已排序的数组A

A[i] ≤ A[j] 对于任何索引对ij,其中 i < j

虽然这看起来似乎信息不多,但足以让我们排除数组的整个部分。这类似于我们在寻找咖啡时避免走冰激凌通道的逻辑。一旦我们知道某个物品不在某个区域,我们就可以排除该区域内的所有物品,而不需要逐一检查它们。

二分查找通过两个边界跟踪当前的查找空间:上边界IndexHigh标记数组中活跃查找空间的最高索引,下边界IndexLow标记最低索引。在整个算法过程中,如果目标值在数组中,我们可以保证以下几点:

A[IndexLow] ≤ v ≤ A[IndexHigh]

二分查找在每次迭代时,首先选择当前查找空间的中点:

IndexMid = Floor((IndexHigh + IndexLow) / 2)

其中,Floor 是一个数学函数,用来将数字向下舍入到整数。然后,我们将中间位置的值 A[IndexMid] 与目标值 v 进行比较。如果中点小于目标值,即 A[IndexMid] < v,我们知道目标值一定在中间索引之后。这样,我们可以通过设置 IndexLow = IndexMid + 1 来将搜索空间缩小一半。或者,如果中点大于目标值,即 A[IndexMid] > v,我们知道目标值一定在中间索引之前,这样我们可以通过设置 IndexHigh = IndexMid - 1 来将搜索空间缩小一半。当然,如果我们发现 A[IndexMid] == v,我们就立刻结束搜索:我们找到了目标。热烈的庆祝活动可以自行决定。

图 2-3 中的每一行都代表了在一个已排序数组上执行二分查找过程的一步。我们正在查找行 (a) 中的数组,目标值是 15。在开始时,我们的搜索范围包括整个数组:IndexLow = 0IndexHigh = 11

在行 (b) 中,我们计算中点(向下舍入)为 IndexMid = 5。将中点的值与目标值进行比较,我们看到 A[5] = 11,小于目标值 15。因此,在行 (c) 中,我们通过调整下限 IndexLow = 6,排除了数组中所有索引为 5 及之前的元素——即所有阴影部分——从而消除了几乎一半的搜索空间!该算法在剩余范围内重复此过程,计算新的中点为 IndexMid = 8,并与目标值进行比较(A[8] = 30,大于 v = 15),将范围调整为 IndexHigh = 7。在行 (d) 中,我们再次以相同的方式排除剩余搜索空间的一半。在行 (e) 中,我们再次计算中点为 IndexMid = 6 并与目标值进行比较(A[6] == v)。我们找到了目标!

注意,尽管下限的索引在几次迭代中指向了目标值 (v = 15),我们仍然继续搜索,直到中点指向目标值。这是因为我们的搜索只检查中点的值与目标的匹配情况,而不是检查下限或上限索引的值。

回到我们刚才提到的计算机科学入门课程的学生,我们可以想象,在学期结束时,老师要求学生按字母顺序排队。然后,老师通过询问中间的学生“你的名字是什么?”来进行二分查找,并利用学生的回答将队列的一半剔除。教授随后在脑海中修正范围,移动到新的中点,并重复这个过程。这样,教授就能将布置作业的过程转变为二分查找的演示——同时掩盖了他从未真正记住学生名字的事实。

这张图展示了在一个排序数组中执行二分查找寻找值 15 的过程,其中数组的最小值为-5,最大值为 54。每一行(从 a 到 e)展示了搜索过程中的不同阶段。已被排除的数组部分被阴影标记,剩余的部分则标记为白色。

图 2-3:在排序数组中执行值 15 的二分查找

缺失值

接下来,我们需要考虑如果目标值不在列表中会发生什么,并且二分查找如何确认值的缺失。在线性扫描的情况下,一旦到达列表的末尾,我们就知道某个元素不在列表中。对于二分查找,我们可以通过测试边界来得出目标元素不存在的结论。随着搜索的进行,左右边界会越来越接近,直到它们之间没有未探索的值为止。由于我们总是将其中一个边界超越中点索引,当IndexHigh < IndexLow时,我们可以停止搜索。此时,我们可以确保目标值不在列表中。图 2-4 展示了在排序数组中查找v = 10的示例搜索,其中10在数组中不存在。

这张图展示了在一个排序数组中执行二分查找寻找缺失的值 10 的过程,其中数组的最小值为-5,最大值为 54。每一行(从 a 到 f)展示了搜索过程中的不同阶段。

图 2-4:在数组中进行二分查找以查找值(10),但该值不存在

理论上,我们可以比第(f)行更早地停止搜索:一旦我们的高边界的值小于目标值(IndexHigh = 4),我们就知道目标值不可能在数组中。然而,正如我们在图 2-3 中的搜索一样,算法只会检查中点位置的值与目标值是否匹配。它跟踪高低边界的索引,但并没有显式地检查这些位置的值。尽管我们可以添加逻辑来捕获这种情况,以及下边界大于目标值的情况,但目前我们会保持逻辑的简洁。

实现二分查找

我们可以通过一个简单的WHILE循环来实现二分查找,如清单 2-2 所示。与清单 2-1 中的线性查找代码类似,二分查找算法如果目标元素存在于数组中,则返回该元素的索引。如果数组中没有匹配的元素,算法返回−1

BinarySearch(Array: A, Integer: target):
    Integer: IndexHigh = length(A) - 1
    Integer: IndexLow = 0
  ❶ WHILE IndexLow <= IndexHigh:
      ❷ Integer: IndexMid = Floor((IndexHigh+IndexLow) / 2)

        IF A[IndexMid] == target:
            return IndexMid
        IF A[IndexMid] < target:
          ❸ IndexLow = IndexMid + 1
        ELSE:
          ❹ IndexHigh = IndexMid - 1
    return -1

清单 2-2:通过单个循环实现二分查找

在高低索引尚未交叉时,我们继续搜索❶。在每次迭代中,我们计算一个新的中点❷,并将中点值与目标值进行比较。如果完全匹配,我们就找到了目标,可以直接返回对应的索引。如果中点的值太小,我们会调整下界❸。如果值太大,我们会调整上界❹。如果IndexHigh < IndexLow,说明元素不在数组中,因此返回-1

根据编程语言的不同,我们可以使用除返回-1以外的其他方式来表示失败,例如抛出异常。无论实际机制如何,你的代码和文档应该始终清楚地说明如果元素不在数组中会发生什么,以便函数的调用者能够正确使用它。

二分查找的应用

到目前为止,我们已经在列表和数组的背景下考虑了二分查找——即一组固定的离散项。很容易看出,我们可以通过将这个算法应用于排序的书架、电话簿中的名字或按尺码排序的衣架,将其引入到现实世界中。但我们也可以将这一方法应用于连续数据,在这种情况下,我们不再从一组独立的项或索引开始,而是直接使用值本身的上下界。

假设你打算调制一杯完美的咖啡。在经过数月艰苦的研究后,你已经确认了最佳的温度和水量。然而,还有一个谜团未解:应该使用多少咖啡粉?此时,各方意见不一。强咖啡派建议使用大量的 5 汤匙咖啡粉,而清淡咖啡派则建议使用仅 0.5 汤匙的咖啡粉。

确定自己理想的咖啡粉量问题非常适合使用二分查找,如图 2-5 所示。我们从合理的上下界开始,如图 2-5(a)所示。

  1. LowerBound = 0 汤匙 咖啡是杯温水。

  2. UpperBound = 5 汤匙 咖啡太浓了。

真实的理想值必定介于两者之间。注意,现在我们的界限是这些值本身,而不是物品的索引。

一个显示在 0 到 5 汤匙咖啡之间进行二分查找的示意图。在每一步(a)到(d)中,我们定义新的中点,直到找到理想的中点值,2。

图 2-5:一种改进的二分查找方法可以用来搜索实数范围。

与在数组中进行二分查找类似,我们可以定义中点为 2.5 汤匙,并进行测试(图 2-5(b))。同样,2.5 汤匙只是一个值,它并不对应数组中的一个元素或架子上的一件物品。我们没有一个预设的值数组,而是一个从 0.0 到 5.0 的所有实数的无限范围,每个独立的测量实际上对应着该范围中的一个索引。

我们发现用 2.5 汤匙做的咖啡对我们的口味来说稍微有点太浓,这让我们能够进一步精确边界。我们的最佳咖啡量现在被限定在 0 汤匙和 2.5 汤匙之间(c)。我们的搜索继续,新的中点是 1.25 汤匙,这个量的咖啡口感较淡。我们需要进一步缩小下限(d)。

早晨的快乐就这样在不断的搜索中继续,直到我们足够精确地缩小了范围。与离散的数值数组不同,我们可能永远找不到完全满足条件的精确点。毕竟,实数值是无限的。如果我们的最佳咖啡量是 2.0 汤匙,我们可能会尝试 2.50、1.25、1.875、2.1875 和 2.03125 等值,然后才会得出我们已经足够接近的结论。因此,当我们的范围足够小的时候,我们就停止搜索:

UpperBound – LowerBound < threshold

将这种搜索与选项的线性扫描进行对比。为了科学,我们可能决定尝试每一个可能的 0.05 汤匙增量,直到找到最优的冲泡量。毕竟,这可是咖啡,我们必须彻底检查。从低索引(0.0 汤匙——也叫一杯温水)开始,我们不断增加 0.05 并重新测试。我们依次测试 0.05、0.10、0.15,...,1.00,才开始接近合理的浓度。为了找到正确的量,我们需要做很多次尝试,其中至少 20 次的量太淡,甚至不能算作咖啡。这将浪费大量精力和咖啡豆。

使用二分查找还可以提高精度。通过在我们的线性扫描过程中仅采样 0.05 的增量,我们只能限制在目标值附近的精度。二分查找会不断缩小范围,直到停止。我们选择UpperBound – LowerBound的值足以停止搜索,从而将结果缩小到 0.0001 汤匙或更小的范围。

这种二分查找方法的变体构成了重要数学技术的基础,例如二分搜索。二分搜索用于找到一个函数的零点,或者找到* x 使得 fx)= 0。二分搜索不是评估咖啡是否过强或过弱,而是追踪函数在零值以上和零值以下的区间。通过不断地将区间在中点处分割,算法能够精确地找到使得函数值为零的 x *。

运行时

直观地看,我们可以发现二分查找通常比线性扫描数据要快。让我们来看看二分查找到底快多少,看看它是否值得增加额外的代码复杂度。

当然,这两种算法的相对速度取决于数据本身。如果我们正在查找的值总是出现在列表的开头,线性扫描会更快。同样,对于小列表来说,二分查找可能没有必要。如果列表只有两个元素,我们不需要把它分成两半,只需直接查看这两个元素即可。

我们通常根据数据大小N的增长,分析算法的平均性能和最坏情况性能。计算机科学家通常使用像大 O 表示法这样的度量方法来更正式地捕捉这些概念。尽管我们在本书中不会正式分析算法或使用大 O 表示法,但我们会在每个算法中考虑以下两个方面:

  • 随着数据规模增长,算法的平均运行时间

  • 随着数据规模增长,算法的最坏情况运行时间

现在,让我们比较线性扫描和二分查找的最坏情况性能。对于线性扫描,最坏情况发生在目标值位于列表末尾或根本不在列表中。在这些情况下,算法必须检查每一个值。如果数组有N个值,它将需要N次比较。它的最坏情况运行时间与数据大小呈线性关系。

相比之下,即使是最坏情况的二分查找也会在每一步丢弃一半的数据,因此比较次数与数据集的大小呈对数关系。它的规模与 log[2]N成正比,其中N是以 2 为底的对数。诚然,每一步的工作量更多:我们不仅要检查一个值,还必须移动边界并计算新的中点。然而,对于足够大的列表,仅需对数级别的比较次数的优势将远远超过每步的额外开销。

为什么这很重要

在计算机科学入门课程中对二分查找的过度关注,并不是二分查找倡导运动、粉丝俱乐部或秘密社团的结果(虽然这些也都能理解)。相反,正是二分查找的简单性使其成为一个完美的入门话题。它是计算思维最基本概念之一的清晰且有效的示例:通过利用问题本身中的结构设计算法,帮助我们构建高效的解决方案。通过利用数据的排序特性,我们能够将最坏情况的运行时间从与值的数量成线性关系减少到对数级别——随着数据增长,这一差异变得更加显著。

在本书的其余部分,我们将继续关注问题结构(包括数据中的结构)与如何创建高效解决方案之间的紧密关系。

第三章:动态数据结构

本章介绍了动态数据结构,这些数据结构会随着数据的变化而改变其结构。这些结构的适应性可能包括根据需求扩展数据结构的大小、在不同值之间创建动态的可变链接等。动态数据结构是几乎所有计算机程序的核心,并且是计算机科学中一些最令人兴奋、有趣和强大的算法的基础。

之前章节中介绍的基本数据结构就像停车场——它们为我们提供了存储信息的地方,但没有太多适应变化的能力。的确,我们可以对数组中的值(或者说停车场中的车)进行排序,并使用这种结构使二分查找变得高效。但我们仅仅是改变了数组内数据的顺序。数据结构本身既没有变化,也没有响应数据的变化。如果我们稍后更改已排序数组中的数据,比如修改某个元素的值,我们需要重新排序数组。更糟糕的是,当我们需要改变数据结构本身——例如通过扩展或缩小数组——时,简单的静态数据结构无法提供任何帮助。

本章通过将第一章介绍的静态数据结构——数组,与一种简单的动态数据结构——链表进行比较,来展示后者的优势。在某些方面,这两种数据结构是相似的:它们都允许程序员通过单一的引用来存储和访问多个值,不论是数组还是链表的头部。然而,数组在创建时就固定了结构,就像是停放车辆的停车位。相比之下,链表可以在程序的内存中扩展。它们更像是一队长长短短的队伍,可以随时加入或移除元素。理解这些差异为理解本书接下来介绍的更高级数据结构打下了基础。

数组的局限性

虽然数组是存储多个值的优秀数据结构,但它们存在一个重要的局限性:它们的大小和内存中的布局在创建时是固定的。如果我们想存储比数组能够容纳更多的值,就需要创建一个新的、更大的数组,并将旧数组中的数据复制过来。这种固定大小的内存适用于我们需要存储的项目数量有明确上限的情况。如果我们有足够的空间来容纳数据,就可以不断地插入单独的元素,而不必担心数组在内存中的静态布局。然而,许多应用程序需要能够随着程序变化而增长和变化的动态数据结构。

为了满足动态数据结构的需求,许多现代编程语言提供了动态“数组”,这些数组会随着元素的添加而增长或缩小。然而,这些数组实际上是静态数组或其他数据结构的封装,隐藏了与其动态特性相关的复杂性和成本。虽然这样对程序员很方便,但也可能导致隐藏的低效。当我们在数组末尾添加元素时,程序仍然需要增加使用的内存,只是这一过程发生在幕后。为了理解为什么动态数据结构如此重要,我们需要讨论静态数据结构的局限性。在本书中,我们将使用 数组 一词来指代一个简单的静态数组。

为了说明数组的限制,假设你花费了一整周的时间,掌握了最新的复古视频游戏现象——《太空青蛙 2000》。每次主屏幕显示你的五个最高分时,你都会欣喜地笑出声。这些伟大的成就代表了数小时的汗水、泪水、喊叫,和更多的泪水。然而,第二天,你那(即将成为前)最好的朋友来访,并连续五次打破了你的最高分。把那个背叛的前朋友赶出家门后,你回到游戏中,盯着新显示的高分,见图 3-1,忍不住喊道:“为什么游戏不能存储更多的分数?保存一个前十名列表难道就那么难吗?或者至少在最末尾加一个?”

显示一个索引,包含五个插槽记录视频游戏的最佳分数。最高分 1025 排在索引 0,最低分 949 排在索引 4。

图 3-1:一个包含视频游戏高分的五个元素的数组。遗憾的是,里面没有你的分数。

这是任何固定大小的数据结构及其在内存中固定布局的基本限制之一——它无法随数据增长。如下面所示,这一限制使得某些常见操作变得昂贵。更实际地说,想象一下,如果文字处理软件只能容纳有限数量的字符,电子表格只能有固定行数,照片存储程序只能存储有限数量的图片,或者一个咖啡日志只能记录一千条条目,这些限制带来的影响。

由于数组的大小在创建时是固定的,如果我们想扩展数组以存储更多数据,就必须创建一个新的、更大的内存块。考虑向数组末尾添加一个元素的最简单情况。由于数组是一个单一的固定大小的内存块,我们不能直接把另一个值插入到末尾。因为内存中可能已经有其他变量占据了那个位置。为了避免覆盖那个变量的值,我们必须分配一个新的(更大的)内存块,将原数组的所有值复制到新块中,并把新值写入末尾。对于一个单一的添加操作来说,这会带来很大的开销,如 图 3-2 所示。

图示:显示一个包含六个数据元素的数组被复制,然后第七个数据元素被添加到复制的数组末尾。

图 3-2:将元素添加到满的数组末尾。

可以把数组想象成酒店自助餐台上那些有固定位置的热菜盘。很容易把空的炒蛋盘拿出来,换上一个新的。但你不能随便把一个新盘子放到末尾,因为没有位置。如果厨师决定添加煎饼到菜单中,那么必须移走其他的菜肴。

如果你知道需要插入很多新值,你可能会将成本分摊到多个更新中,摊销这些成本。你可能会采取类似于数组倍增的策略,在数组扩展时,其大小会翻倍。例如,如果我们尝试向一个大小为 128 的数组添加第 129 个元素,我们首先会分配一个新的大小为 256 的数组,并将原来的 128 个元素复制过去。这使得我们在下一次需要分配新空间之前,可以继续扩展数组。然而,代价是可能会浪费空间。如果我们只需要 129 个元素,我们就会多分配了 127 个空间。

数组倍增提供了一种合理的平衡,既避免了昂贵的数组复制,又避免了浪费内存。随着数组的增长,倍增的频率会越来越低。同时,通过在数组满时倍增,我们可以保证浪费的空间少于一半。然而,即使采用这种平衡的方式,我们也能明显看到使用固定大小数组的成本,无论是复制成本还是内存使用。

ArrayDouble(Array: old_array):
    Integer: length = length of old_array
    Array: new_array = empty array of size length * 2

    Integer: j = 0
    WHILE j < length:
        new_array[j] = old_array[j]
        j = j + 1
    return new_array

数组倍增的代码首先分配一个新数组,大小是当前数组的两倍。一个 WHILE 循环遍历当前数组的元素,将它们的值复制到新数组中。然后返回新数组。

想象一下将这一策略应用到货架空间上。我们在一个地方开设了一家书店《数据结构与更多》,并安装了五个简陋的书架。开业当天,需求出乎意料地大,人们要求更多种类的书籍:我们需要扩充库存。慌乱之下,我们搬到了一个有 10 个书架的新地点,并将书籍迁移过去。需求暂时得到了满足。由于缺乏一个综合的数据结构书店,零售图书市场存在这一明显的空缺,我们的书店大获成功,需求持续增长。我们可能会多次升级店铺,搬到有 20、40、甚至 80 个书架的地方。每次我们都需要支付费用,来确保新位置并迁移书籍。

数组值在内存中的固定位置带来了另一个限制。我们无法轻松地将新元素插入数组的中间部分。即使在原数组的末尾有足够的空位来容纳新元素,因此我们不需要将整个数组移动到新的内存块中,我们仍然需要将每个现有元素依次移动,以为新值腾出空间。不同于书架上的书籍,我们不能仅凭一次推力将所有元素推开。如果我们有 10,000 个元素,想在第二个位置添加一个元素,那么我们就得移动 9,999 个元素。这是插入一个元素所需要付出的巨大努力。

当我们尝试将新值插入到一个已经满的数组的中间时,问题会变得更加复杂。我们不仅需要分配一个新的内存块并复制旧值,还需要将新值后面的元素依次向下移动一个位置,为新值腾出空间。例如,假设我们想将值 23 插入到一个已有六个元素的数组中的第四个位置,如图 3-3 所示。

一个包含六个盒子的数组被分成两部分。每一部分的值被复制并移动到一个新的包含七个盒子的数组中,中间插入了一个新值。

图 3-3:向满数组中间添加一个元素

为了解决数组的不足,我们需要转向更灵活的数据结构,这些结构可以在添加新数据时扩展:动态数据结构。在深入细节之前,让我们先介绍一下指针,这是一种关键的变量类型,用于重新配置和扩展数据结构。

指针与引用

有一种变量类型,在其强大功能和可能使新程序员感到困惑的能力方面,超越了其他所有类型:指针。指针是一个只存储计算机内存中地址的变量。因此,指针指向内存中的另一个位置,该位置存储着实际数据,如图 3-4 所示。

八个内存地址,每个都有对应的盒子。第四个盒子有地址 2103 和值 109,并标记为“指针所指向的地址”。第七个盒子有地址 2106 和值 2103,并标记为“指针”。

图 3-4:指针指向计算机内存中的一个地址

聪明的读者可能会问:“一个变量只是指向内存中的另一个位置,那它有什么意义?我以为变量的名字已经完成了这个功能。为什么不把数据直接存储在变量中,像普通人一样?为什么总是要把事情弄得这么复杂?”不要听那些怀疑者的声音。指针是动态数据结构的核心组成部分,稍后我们会看到这一点。

假设我们正在办公室做一个大型的建筑项目,并且已经整理了一些示例图纸,准备与团队共享。很快,项目文件夹中包含了大量的平面图、成本估算和艺术效果图。为了避免复制这个庞大的文件并随意放置,我们留下了一张便条,告诉同事们去三楼的档案室,文件柜#3,第二个抽屉,下方第五个文件夹里查找文件。这个便条充当了指针的角色。它没有详细列出文件中的所有信息,而是让我们的同事能够找到并提取这些信息。更重要的是,我们可以将这个“地址”分享给每位同事,而无需为他们复制整个文件。他们每个人都可以利用这些信息查找并在需要时修改文件夹。我们甚至可以在每位团队成员的桌上留下一个单独的便签,提供 10 个指向相同信息的变量。

除了存储一块内存的位置外,指针还可以为空值(在某些编程语言中表示为 None、Nil 或 0)。空值仅表示指针当前没有指向一个有效的内存位置。换句话说,它表示指针实际上还没有指向任何东西。

不同的编程语言提供不同的机制来实现指针任务,并非所有语言都会向程序员提供原始内存地址。像 C 和 C++这样的低级语言提供原始指针,并允许你直接访问它们存储的内存位置。其他编程语言,如 Python,使用引用,它们的语法与普通变量相似,但仍然允许你引用另一个变量。这些不同的变体有不同的行为和使用方式(解除引用、指针运算、空值的形式等)。为了简单起见,在本书中我们将使用指针一词来涵盖所有由指针、引用或索引实现的变量,这些变量指向预分配的内存块。我们不会担心访问内存块所需的复杂语法(这让不少编程爱好者哭泣过)。我们还将在伪代码中定义指针变量时使用最终数据的类型(而不是更通用的类型指针)。对我们来说,关键概念是指针提供了一种机制,用于链接到内存块,就像我们第一个动态数据结构:链表中的实现一样。

链表

链表是最简单的动态数据结构示例,且与数组有着密切的关系。像数组一样,链表也是用来存储多个值的数据结构。与数组不同的是,链表由一系列由指针连接的节点组成。链表中的基本节点是一个复合数据结构,包含两个部分:一个值(可以是任何类型)和指向链表中下一个节点的指针:

LinkedListNode {
    Type: value
    LinkedListNode: next
}

我们可以将链表想象为一系列相互链接的箱子,如图 3-5 所示。每个箱子存储一个单一的值,并包含指向下一个箱子的指针。

六个节点,每个节点包含一个数字值。右指向的箭头连接这些节点。最右边的箭头指向一个斜杠,表示链表的结束。

图 3-5:通过指针链接的一系列节点表示的链表

链表末尾的斜杠表示一个空值,指示链表的结束。实际上我们是在说,最后一个节点的next指针并不指向有效的节点。

链表就像是我们最喜欢的咖啡店门口排队的长队伍。人们通常不知道自己在队伍中的绝对位置——“我在距离柜台五十三级砖的位置。”他们关注的是自己在队伍中的相对位置,也就是他们前面那一个人,而这个位置我们存储在指针中。即使队伍在店内(甚至停车场)绕来绕去,形成复杂的循环,我们仍然可以通过询问每个人面前是谁,来重新构建队伍的顺序。我们可以通过询问每个人前面的人,沿着队伍向柜台走去。

因为链表包含指针和数值,链表需要比数组更多的内存来存储相同的元素。如果我们有一个大小为K的数组,每个元素为N字节,我们只需要K × N字节。而如果每个指针需要额外的M字节,那么我们的数据结构的内存开销就变为K × (M + N)字节。除非指针的大小远小于值的大小,否则这个开销是显著的。然而,增加的内存使用通常是值得的,因为指针提供的灵活性。

虽然教科书中通常将链表表示为整齐、有序的结构(如图 3-5 所示,或在我们的人物排队示例中所暗示),但我们的链表实际上可能分散在程序的内存中。如图 3-6 所示,链表的节点仅通过它们的指针连接。

这就是指针和动态数据结构的真正强大之处。我们不需要将整个链表保存在单一的连续内存块中。我们可以自由地在任何有空间的地方为新节点分配内存。

与图 3-5 中的六个节点和表示链表结束的斜杠类似,但顺序不同,且节点之间有多个空箱子。箭头指示节点链接的顺序。

图 3-6:计算机内存中的链表。节点不一定是相邻的。

程序通常通过保持一个指向链表头部的单一指针来存储链表。程序可以通过从头开始,并通过指针迭代节点,来访问链表中的任何元素:

LinkedListLookUp(LinkedListNode: head, Integer: element_number):
  ❶ LinkedListNode: current = head
    Integer: count = 0

  ❷ WHILE count < element_number AND current != null:
        current = current.next
        count = count + 1
    return current

代码从列表的头节点 ❶ 开始。我们维护一个第二个变量 count 来跟踪当前节点的索引。然后 WHILE 循环遍历链表中的每个节点,直到找到正确的数字,count == element_number,或遍历到链表的末尾,current == null ❷。在任何一种情况下,代码都会返回 current。如果循环因遍历到链表末尾而终止,那么索引不在列表中,代码返回 null

例如,如果我们想访问链表中的第四个元素,程序将首先访问头节点,然后依次访问第二、第三和第四个元素,以找到正确的内存位置。图 3-7 展示了这个过程,其中值为 3 的节点是链表的头节点。

六个节点的链表,程序首先访问链表的头节点,然后访问直接指向它的下一个节点,依此类推。

图 3-7:遍历链表需要沿着指针链逐个访问节点。

然而,值得注意的是,这里有一个权衡:链表的计算开销比数组大。访问数组中的元素时,我们只需要计算一个偏移量并查找正确的内存地址。无论选择哪个索引,数组访问只需要进行一次数学计算和一次内存查找。而链表则需要我们从列表的开头开始迭代,直到找到感兴趣的元素。对于较长的链表,缺乏直接访问会增加显著的开销。

初看之下,这种受限的访问模式似乎是链表的一个缺点。我们大大增加了查找任意元素的成本!想想这对二分查找意味着什么。一次查找需要遍历许多元素,失去了排序列表的优势。

然而,尽管有这些成本,链表在实际程序中仍然可以成为真正的资产。数据结构几乎总是涉及到复杂性、效率和使用模式之间的权衡。那些使数据结构无法用于某种应用的行为,可能会让它成为支持其他算法的完美选择。理解这些权衡是有效结合算法和数据结构的关键。在链表的例子中,增加元素访问开销的权衡是显著增加了整个数据结构的灵活性,正如我们在下一节中将看到的那样。

链表操作

尽管有人 lament 认为链表比起紧凑的数组来说显得杂乱无章,正是这种能够在不同内存块之间建立链接的能力,使得链表这一数据结构如此强大,从而让我们能够动态地重新排列数据结构。让我们比较一下向数组插入新值与向链表中添加值的区别。

向链表插入元素

正如我们所见,将新元素插入数组中可能需要我们分配一个新的(更大的)内存块,并将原数组的所有值复制到新的内存块中。此外,插入操作本身可能还需要我们遍历数组并移动元素。

然而,链表不需要保持在一个连续的内存块中——它可能一开始就不在一个单独的内存块里。我们只需要知道新节点的位置,更新前一个节点的 next 指针指向新节点,并让新节点的 next 指针指向正确的节点。如果我们想在图 3-5 中将值为 23 的节点添加到链表的前端,我们只需要将新节点的 next 指针指向原来链表的起始节点(值 = 3)。这个过程在图 3-8 中展示了。任何之前指向链表起始节点(第一个节点)的变量也需要更新,指向新的第一个节点。

来自图 3-5 的链表,添加了一个新节点到前端。一个指向右的箭头连接了新的第一个节点与现在成为第二个节点的节点。

图 3-8:通过在前端添加新节点来扩展链表

类似地,我们可以将一个节点添加到链表的末尾,正如在图 3-9 中所示,方法是遍历链表直到末尾,更新最后一个节点(值 = 8)的 next 指针,使其指向新节点,并将新节点的 next 指针设置为 null。这种做法直接的话需要遍历整个链表才能到达末尾,但正如我们将在下一章看到的那样,有一些方法可以避免这种额外的开销。

来自图 3-5 的链表,添加了一个新节点到末尾。一个指向右的箭头连接了原本的最后一个节点与新加入的最后一个节点,而一个指向右的箭头连接了新最后节点与表示链表末尾的斜杠。

图 3-9:通过在末尾添加一个新节点来扩展链表

如果我们想在中间插入一个值,我们需要更新两个指针:前一个节点和插入的节点。例如,要将节点 N 插入到 XY 之间,我们需要进行两个步骤:

  1. Nnext 指针设置为指向 Y(即 Xnext 指针当前指向的地方)。

  2. Xnext 指针设置为指向 N

这两个步骤的顺序很重要。指针和其他变量一样,只能存储单一的值——在此情况下是内存中的一个地址。如果我们先设置 Xnext 指针,那么就会丢失 Y 的地址信息。

一旦完成,X 指向 N,而 N 指向 Y。图 3-10 展示了这个过程。

插入新节点 N 到链表中,位于节点 X 和 Y 之间的过程图。在插入之前,X 指向 Y。在插入过程中,N 和 X 都指向 Y。插入后,X 指向 N,N 指向 Y。

图 3-10:将新节点 N 插入到链表中,位于节点 XY 之间的过程

尽管指针的顺序发生了变化,但这种操作的代码相对简单:

LinkedListInsertAfter(LinkedListNode: previous,
                      LinkedListNode: new_node):
    new_node.next = previous.next
    previous.next = new_node

假设我们想在当前链表中的节点 9 和 37 之间插入一个值为 23 的节点。结果指针链将如图 3-11 所示。

图 3-5 中的链表,节点 9 之前指向节点 37。现在,节点 9 指向新插入的节点 23,节点 23 指向节点 37。

图 3-11:将节点 23 插入到链表中,需要更新前一个节点(9)和下一个节点(37)的指针。

同样,当顾客让他们的朋友站在自己前面加入队伍时,两个指针发生了变化。回想一下,在这个类比中,每个人都“指向”或追踪自己前面的人。过于慷慨的顾客现在指向站在自己前面的跳队朋友。与此同时,开心的跳队者指向原本在他们的朋友前面的人。队伍中其他人都投来了不满的目光,并低声抱怨。

再次强调,图示和咖啡馆排队的类比掩盖了插入过程的真正复杂性。虽然我们没有在最后一个节点旁边的内存位置插入新节点,但从逻辑上讲,我们是将其插入到队伍的下一个位置。节点本身可能位于计算机内存的另一端,靠近记录我们拼写错误或日常咖啡杯数的变量。只要我们保持列表中的指针是最新的,我们就可以把它们以及它们指向的节点当作一个整体列表来处理。

当然,在插入节点到头节点前(index == 0)或插入到超出列表末尾的索引时,我们必须格外小心。如果我们要在头节点前插入节点,我们需要更新头指针本身;否则,头指针将继续指向旧的列表开头,我们将失去访问新第一个元素的能力。如果我们尝试插入节点到超出列表末尾的索引,则在index - 1处没有有效的前一个节点。在这种情况下,我们可以使插入失败,返回错误,或将元素附加到列表的末尾(使用较小的索引)。无论选择哪种方法,都必须清楚地记录代码。我们可以将这些额外的逻辑打包成一个辅助函数,将我们的线性查找代码与在给定位置插入新节点的逻辑结合起来:

LinkedListInsert(LinkedListNode: head, Integer: index,
                 Type: value):
    # Special case inserting a new head node.
  ❶ IF index == 0:
        LinkedListNode: new_head = LinkedListNode(value)
        new_head.next = head
        return new_head

    LinkedListNode: current = head
    LinkedListNode: previous = null
    Integer: count = 0
  ❷ WHILE count < index AND current != null:
        previous = current
        current = current.next
        count = count + 1

    # Check if we've run off the end of the list before
    # getting to the necessary index.
  ❸ IF count < index:
        Produce an invalid index error.

  ❹ LinkedListNode: new_node = LinkedListNode(value)
    new_node.next = previous.next
    previous.next = new_node

  ❺ return head

插入的代码从插入新节点到index = 0,即列表的开头❶开始。它创建一个新的头节点,将新头节点的next指针指向列表的原头节点,并返回新的头节点。由于新头节点前面没有节点,我们在这种情况下无需更新前一个节点的next指针。

对于位于链表中间的元素,代码需要遍历链表,找到正确的位置❷。这类似于LinkedListLookUp的查找:代码跟随每个节点的next指针,同时跟踪current节点和已查看的count,直到到达链表末尾或找到正确的位置。代码还会跟踪一个额外的信息,即previous,它是指向当前节点之前的节点的指针。跟踪previous使我们能够更新指向插入节点的指针。

然后,代码检查是否已到达期望的插入索引❸。通过将检查条件设置为count < index,我们仍然允许在链表的末尾插入。只有在尝试插入超过链表末尾的位置时,我们才会发生错误。

如果代码已经找到了插入节点的正确位置,它会将新节点插入到previouscurrent之间。代码通过创建一个新节点来执行插入,将该节点的next指针设置为previous.next指向的地址,然后将previous.next设置为指向新节点❹。这种逻辑同样适用于将新节点立即追加到链表末尾的情况。由于在这种情况下previous.next == null,新节点的next指针将被赋值为null,正确地指示链表的新末尾。

通过返回链表的头节点❺,我们可以处理在头节点之前插入的情况。或者,我们可以将头节点包装在一个LinkedList复合数据结构中,直接操作它。我们将在本书后面使用这种替代方法来处理二叉搜索树。

从链表中删除

要在链表中的任意位置删除一个元素,我们只需要删除该节点并调整前一个节点的指针,如图 3-12 所示。

图 3-9 中的七节点链表。节点 37 在中间,前面是节点 9,后面是节点 7。节点 37 被划掉,节点 9 现在指向节点 7。

图 3-12:从链表中移除一个节点(37)需要更新前一个节点(9)中的指针,使其跳过当前节点,指向下一个节点(7)。

这类似于某人做出一个值得怀疑的决定,认为排队等咖啡不值得。他们看看手表,嘟囔着自己家里有速溶咖啡,然后离开。只要离开顾客后面的人知道自己现在排在谁后面,队伍的完整性就得以保持。

在数组的情况下,删除一个元素的成本要高得多,因为我们需要将包含 37 的节点后面的所有元素向前移动一个位置,以便填补空隙。这可能需要遍历整个数组。

再次强调,在删除链表的第一个元素或删除超过链表末尾的元素时,必须特别小心。当删除第一个节点时,我们将更新列表的头指针,指向新头节点的地址,从而使该节点成为链表的新头。删除超出链表末尾的节点时,我们可以采取类似于插入时的处理方式:我们可以跳过删除操作或返回错误。以下代码执行了后者:

LinkedListDelete(LinkedListNode: head, Integer: index):
  ❶ IF head == null:
        return null

  ❷ IF index == 0:
        new_head = head.next
        head.next = null
        return new_head

    LinkedListNode: current = head
    LinkedListNode: previous = null
    Integer: count = 0
  ❸ WHILE count < index AND current != null:
        previous = current
        current = current.next
        count = count + 1

  ❹ IF current != null:
      ❺ previous.next = current.next
      ❻ current.next = null
    ELSE:
        Produce an invalid index error.
  ❼ return head

这段代码与插入方法遵循相同的思路。这次我们增加了一个额外的检查 ❶。如果列表为空,无法删除任何元素,我们可以返回值null来表示列表仍然为空。否则,我们检查是否正在删除第一个节点 ❷,如果是,移除列表中的第一个节点,并返回新头节点的地址。

为了删除任何后续节点(index > 0),代码必须遍历到列表中的正确位置。使用与插入时相同的逻辑,代码在遍历节点时跟踪currentcountprevious,直到找到正确的位置或遇到链表末尾 ❸。如果代码找到了正确索引的节点 ❹,它通过将previous.next指向当前节点之后的一个节点 ❺ 来将该节点从链表中移除。然而,如果WHILE循环越过了链表末尾且currentnull,则没有节点可删除,因此代码会抛出错误。该函数还将已删除节点的next指针设置为null,既为了确保一致性(因为该节点在链表中不再有next节点),又为了允许具有内存管理功能的编程语言正确释放不再使用的内存 ❻。最后,函数返回链表头节点的地址 ❼。

我们可以调整这段代码,使用节点的其他信息进行删除。如果我们有待删除节点的值,可以更新循环条件 ❸,以删除第一个具有该值的节点:

 WHILE current != null AND current.value != value:

在这种情况下,我们需要颠倒比较的顺序,首先检查current是否为null,然后再访问它的值。类似地,如果我们需要通过指针删除某个节点,可以将该指针存储的地址与当前节点的地址进行比较。

py`The strength of linked lists is that they allow us to insert or remove elements without shifting those elements around in the computer’s memory. We can leave the nodes where they are and just update the pointers to indicate their movement. ## Doubly Linked Lists There are many additional ways we can add structure with pointers, many of which we’ll examine in later chapters. For now, we’ll discuss just one simple extension of the linked list: the *doubly linked list*, which includes backward as well as forward pointers, as shown in Figure 3-13. ![The list from Figure 3‐5, now with right‐pointing and left‐pointing arrows between each node. ](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/dast-fun-way/img/f03013.png) Figure 3-13: A doubly linked list contains pointers to both the next and previous entries. For algorithms that need to iterate lists in both directions, or just for adventurous programmers looking to expand the number of pointers in their data structures, it is easy to adapt a linked list to a doubly linked one: 双向链表节点 { 类型: 值 双向链表节点: 下一个 双向链表节点: 上一个 } py The code for operating on doubly linked lists is similar to the code for singly linked lists. Lookups, insertions, and deletions often require traversing the list to find the correct element. Updating the appropriate nodes’ `previous` pointers in addition to their `next` pointers requires additional logic. Yet this small amount of additional information can enable shortcuts to some of the operations. Given the pointer to any node in a doubly linked list, we can also access the node before it without having to traverse the entire list from the beginning, as we would have to do for a singly linked list. ## Arrays and Linked Lists of Items So far, we have primarily used arrays to store individual (numeric) values. We might be storing a list of top scores, a list of reminder times for a smart alarm clock, or a log of how much coffee we consume each day. This is useful in a variety of applications but is only the most basic way to use an array. We can use the concept of pointers to store more complex and differently sized items. Suppose you’re planning a party. We will make the generous assumption that, unlike many parties thrown by the author, your gathering is popular enough to require an RSVP list. As you begin to receive responses to your invitations, you write a new program using an array to keep track of the guests. You’d like to store at least a single string in each entry, indicating the name of the person who has responded. However, you immediately run into the problem that strings might not be fixed size, so you can’t guarantee they will fit in the array’s fixed-size bin. You could expand the bin size to fit all possible strings. But how much is enough? Can you reliably say all your invitees will have fewer than 1,000 characters in their name? And if we allow for 1,000 characters, what about the waste? If we are reserving space for 1,000 characters per invitee, then entries for “John Smith” are using only a tiny fraction of their bins. What if we want to include even more dynamic data with each record, such as a list of each guest’s music preferences or nicknames? The natural solution is to combine arrays and pointers, as shown in Figure 3-14. Each bin in the array stores a single pointer to the data of interest. In this case, each bin stores a pointer to a string located somewhere else in memory. This allows the data for each entry to vary in size. We can allocate as much space as we need for each string and point to those strings from the array. We could even create a detailed composite data structure for our RSVP records and link those from the array. ![Array in which each value points to an ellipse (three dots) outside of the array, indicating that the values link to larger data structures. ](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/dast-fun-way/img/f03014.png) Figure 3-14: Arrays can store a series of pointers, allowing them to link to larger data structures. The RSVP records don’t need to fit into the array bins, because their data lives somewhere else in memory. The array bins only hold (fixed-size) pointers. Similarly, a linked list’s nodes can contain pointers to other data. Unlike the `next` pointers in a linked list, which are pointing to other nodes, these pointers can point to arbitrary other blocks of data. The rest of the book includes many cases where individual “values” are actually pointers to complex and even dynamic data structures. ## Why This Matters Linked lists and arrays are only the simplest example of how we can trade off among complexity, efficiency, and flexibility in our data structures. By using a pointer, a variable that stores addresses in memory, we can link across blocks of memory. A single fixed-size array bin can point to complex data records or strings of different lengths. Further, we can use pointers to create dynamically linked structures through the computer’s memory. By changing a pointer’s value to point to a new address, we can change this structure as needed at any time. Over the remaining chapters, we will see numerous examples of how dynamic data structures can be used to both improve organization of the data and make certain computations more efficient. However, it is important to keep the relative tradeoffs in mind. As we saw with arrays and linked lists, each data structure comes with its own advantages and disadvantages in terms of flexibility, space requirements, efficiency of operations, and complexity. In the next chapter, we will show how can build on these fundamental concepts to create two data structures, stacks and queues, that enable different behavior.

第四章:栈和队列

本章介绍了栈和队列,这两种数据结构根据数据插入的顺序来检索存储的数据。栈和队列非常相似,只有细微的实现差异。然而,栈返回最新插入的数据,而队列返回最早插入的数据,这一简单的事实彻底改变了算法的行为以及我们访问数据的效率。

栈是深度优先搜索的核心,深度优先搜索沿着单个路径不断深入,直到遇到死胡同。队列则启用广度优先搜索,广度优先搜索先探索相邻的路径,然后再深入。正如我们稍后所看到的,这一变化可以极大地影响现实世界中的行为,例如我们如何浏览网页或进行咖啡研究。

是一种后进先出(LIFO)的数据结构,操作方式类似于一堆纸张:我们将新元素添加到栈顶,并从栈顶开始移除元素。正式地,栈支持两个操作:

  1. Push 将新元素添加到栈顶。

  2. Pop 从栈顶移除元素并返回该元素。

由于元素是从栈顶提取的,所以下一个被移除的元素将始终是最后添加的元素。如果我们将元素 1、2、3、4 和 5 插入栈中,那么我们将按 5、4、3、2、1 的顺序取出它们。

你可以将栈想象成一个沙拉吧的生菜箱,沙拉吧的生菜箱运行不佳,每隔几年就会清理一次。服务员不断地将新鲜的生菜倒入箱子的顶部,完全忽视堆积在底部的越来越软烂的生菜。食客们只看到顶部的新鲜生菜,将其舀到盘子里,完全不在意底部几层堆积的“恐怖”。

我们可以使用数组或链表来实现栈。

栈作为数组

当将栈实现为数组时,我们使用数组来保存栈中的值,并使用一个额外的变量来跟踪对应栈顶索引——即我们数组中的最后一个元素:

Stack {
    Integer: array_size
    Integer: top
    Array of values: array
}

最初,我们将top索引设置为−1,表示栈中没有任何元素。

当我们将新元素推送到栈时,我们将top索引增加到下一个位置,并将新值添加到该位置。因此,我们按从底到顶的顺序排列数组,如图 4-1 所示。如果数组的最后一个元素是新鲜脆嫩的生菜,那么数组的第一个元素代表的是堆积在栈底的元素。

一个九个箱子的数组,前五个箱子中有值。最右侧的箱子有值,并标记为“top”。它的值是 5。一个新值 9 被插入到空的箱子里,成为新的“top”。

图 4-1:将一个元素推送到表示为数组的栈顶

当向一个固定大小的数组中添加元素时,我们必须小心避免添加超过可用空间的元素。如果空间用尽,我们可以使用数组扩展技术(如数组加倍,参见第三章)来扩展数组,正如以下代码所示。这样可以让栈随着数据的添加而增长,但需要注意的是,这会为某些插入操作带来额外的成本。

Push(Stack: s, Type: value):
    IF s.top == s.array_size – 1:
        Expand the size of the array
    s.top = s.top + 1
    s.array[s.top] = value

将元素压入作为数组实现的栈的代码首先检查是否有足够的空间插入新元素。如果没有,代码会扩展数组。接着,代码增加栈顶元素的索引,并在新索引处插入该值。

当我们从栈中弹出一个元素时,我们再次使用top索引来找到正确的元素。我们将这个元素从数组中移除,并递减top索引,如图 4-2 所示。换句话说,我们从容器中取出最新的生菜,逐步接近下面的、更旧的层次。

一个九格数组,前五个格子有值。最右边有值的格子标记为“top”。它存储值 5。这个格子被从栈中弹出,因此现在栈顶元素是第四个格子。

图 4-2:从作为数组实现的栈中弹出元素

从数组实现的栈中弹出元素的代码比插入代码更简单:

Pop(Stack: s):
    Type: value = null
    IF s.top > -1:
        value = s.array[s.top]
        s.top = s.top – 1
    return value

这段代码首先检查栈是否为空。如果栈不为空,代码将数组的最后一个元素复制到value中,然后递减指向最后一个元素的指针。代码返回栈顶元素的值,如果栈为空则返回null。由于我们只是在数组的末端进行添加或移除操作,因此不需要移动其他元素。

只要栈中有足够的空间,我们就可以以恒定的代价执行添加和移除操作。无论我们有 10 个元素还是 10,000 个元素,添加或移除一个元素所需的操作次数是相同的。然而,在插入时扩展数组的大小会带来额外的成本,因此提前分配足够大的栈空间以应对使用场景是有帮助的。

作为链表实现的栈

另外,我们也可以将栈实现为链表或双向链表,如图 4-3 所示。在这里,链表按从左到右的顺序绘制,与前几章中的链表顺序相反,目的是展示与数组表示相同的顺序。我们指向链表头部的标准指针也充当了指向栈顶的指针。

Stack {
    LinkedListNode: head
}

与填充新数组格子并更新索引不同,链表实现要求我们在链表中创建和移除节点,更新相应的节点指针,并更新指向栈顶的指针。

链表实现的栈的图示。一个指针同时表示链表的头部和栈的顶部。

图 4-3:作为链表实现的栈

我们通过将元素添加到链表的前端来将项推送到栈中:

Push(Stack: s, Type: value):
    LinkedListNode: node = LinkedListNode(value)
    node.next = s.head
    s.head = node

推送的代码首先创建一个新的链表节点。然后它通过更新新节点的next指针和栈的head指针,将该节点插入到链表的前端。

类似地,当我们从栈中弹出一个项目时,我们返回头节点中的值,并将头节点指针移动到链表中的下一个元素:

Pop(Stack: s):
    Type: value = null
 IF s.head != null:
        value = s.head.value
        s.head = s.head.next
    return value

代码从默认返回值null开始。如果栈不为空(s.head != null),代码会将返回值更新为头节点的值,然后将头指针更新到栈中的下一个节点。最后,它返回value

除了存储额外指针的内存开销外,指针赋值还为推送和弹出操作增加了一些小的、常数级的成本。我们不再是设置一个单一的数组值并递增一个索引。然而,和所有动态数据结构一样,权衡之处在于更高的灵活性:链表可以随着数据的变化而增长或缩小。我们不再需要担心数组被填满,或者为增加数组的大小而支付额外的成本。

队列

队列是一个先进先出(FIFO)的数据结构,操作方式像你最喜欢的咖啡店排队:我们将新元素添加到队列的后端,并从前端移除旧元素。形式上,队列支持两个操作:

  1. 入队 将一个新元素添加到队列的后端。

  2. 出队 从队列的前端移除元素并返回它。

如果我们按照顺序 1, 2, 3, 4, 5 入队五个元素,我们将按相同的顺序取出它们:1, 2, 3, 4, 5。

队列保持元素添加的顺序,从而允许像按顺序处理项目这样的有用行为。例如,FIFO 属性使得我们最喜欢的咖啡馆能够有序地为顾客提供服务。由于其精彩的菜单,这家店总是有一群兴奋的顾客排队等着喝早晨的咖啡。新顾客进入商店并在队列的后端入队。下一个要服务的顾客是队列前端的人。他们下单,出队并热切地期待着完美的早晨开始。

像栈一样,队列也可以采用数组和链表两种形式。

队列作为数组

要用数组实现队列,我们跟踪两个索引:队列的第一个元素和最后一个元素。当我们入队一个新元素时,我们将其添加到当前最后一个元素之后,并递增后端索引,如图 4-4 所示。

 一个包含九个格子的数组,前五个格子里有值。最左边的格子标记为“前端”。最右边有值的格子标记为“后端”,它的值是 5。一个新的值 9 被插入到 5 旁边的空格子中,成为新的“后端”。前端元素保持不变。

图 4-4:在表示为数组的队列中入队一个元素

当我们出队一个元素时,我们移除前端元素并相应地增加前端索引,如图 4-5 所示。

一个九个容器的数组,前六个容器有值。最左侧的容器标记为“front”(前端)。最右侧有值的容器标记为“back”(后端),它的值是 9。前端的容器已从数组中出队,现在前端元素是第二个容器。原本存放已移除元素的容器现在为空。

图 4-5:从表示为数组的队列中出队一个元素

从固定数组中出队时,我们会迅速遇到一个问题:一块空白区域会积累在数组的前端。为了解决这个问题,我们可以选择将队列绕到数组的末尾,或者将元素下移以填补空白区域。如同我们在第一章所看到的,移动元素是昂贵的,因为每次出队操作都需要移动剩余的所有元素。而绕回则是更好的解决方案,尽管它要求我们在入队和出队时都要小心处理索引越过数组末尾的情况,如图 4-6 所示。

一个九个容器的数组,前四个容器为空,后面五个容器包含值。第一个有值的容器标记为“front”,最右侧的容器标记为“back”。当一个新值被添加到第一个空容器时,这个容器现在被标记为“back”。列表的前端不变。

图 4-6:使用数组表示队列可能导致元素绕回。

尽管绕回增加了我们实现的复杂性,但它避免了移动元素的高昂成本。

链表形式的队列

更好的做法是将队列实现为链表或双向链表。除了维护一个指向列表头部(队列前端)的特殊指针外,我们还维护一个指向列表最后一个元素的指针,即尾部或后端:

Queue {
    LinkedListNode: front
    LinkedListNode: back
}

如图 4-7 所示的这个列表类似于我们在图 4-3 中用于栈的链表。队列中的每个元素都链接到它后面的元素,允许我们从队列的前端遍历next指针到后端。

一个表示为链表的队列。指向右侧的箭头连接每个元素,额外的指针表示前端和后端元素

图 4-7:一个表示为链表的队列,带有额外的前端和后端元素指针

一如既往,插入和删除操作要求我们同时更新链表中的节点和我们的特殊指针节点:

Enqueue(Queue: q, Type: value):
    LinkedListNode: node = LinkedListNode(value)
  ❶ IF q.back == null:
        q.front = node
        q.back = node
    ELSE:
      ❷ q.back.next = node
      ❸ q.back = node

当我们向队列添加新元素时,我们使用队列的back指针来找到插入的位置。代码首先为插入的值创建一个新的节点,然后检查队列是否为空 ❶。如果队列为空,代码通过将队列的frontback指针都指向新节点来添加新节点。两个指针都需要更新,否则它们将不再指向有效的节点。

如果队列不为空,代码通过修改当前最后一个节点的next指针,使其指向新节点,从而将新节点追加到列表末尾 ❷。最后,代码更新队列的back指针,使其指向新节点,表示它是队列中的新最后一个节点 ❸。除非队列之前为空,否则front指针不会改变。

删除操作主要更新队列前端的指针:

Dequeue(Queue: q):
  ❶ IF q.front == null:
        return null

  ❷ Type: value = q.front.value
  ❸ q.front = q.front.next
    IF q.front == null:
        q.back = null
    return value

代码首先通过检查队列的front指针是否指向有效元素或为null ❶,来确认队列中是否有元素。如果队列为空(q.front == null),代码会立即返回null。如果队列中至少有一个元素,代码会保存该值以便稍后返回 ❷。然后,代码更新q.front,使其指向队列中的下一个元素 ❸。当出队最后一个元素时,我们必须小心更新back指针。如果front指针不再指向有效元素,那么队列为空,我们也需要将back指针设置为null

无论队列的大小如何,入队和出队操作都需要固定数量的操作。每个操作都要求我们调整一些指针。我们不关心数据结构中其他的内容;我们可以把元素添加到一条贯穿计算机内存的链表的末端。

顺序的重要性

我们插入或移除元素的顺序可能对算法的行为产生巨大影响(在沙拉吧的情况下,甚至影响顾客的健康)。队列在我们需要存储时保留插入顺序时效果最佳。例如,在处理网络请求时,我们希望先处理早期的请求。相反,我们在需要处理最新项目时使用栈。例如,编程语言可能使用栈来处理函数调用。当调用一个新函数时,当前状态会被压入栈中,然后执行跳转到新函数。当一个函数完成时,栈顶的状态会被弹出,程序返回到调用该函数的位置。

我们可以根据选择的数据结构来改变搜索算法的整个行为。想象一下,我们正在探索我们最喜欢的在线百科全书来研究咖啡研磨方法。当我们浏览“磨豆机”页面时,我们看到指向其他有趣选项的链接。如果我们跟随其中一个链接,我们会到达另一个信息页面,并伴随出现一系列新的潜在主题需要探索。无论我们使用堆栈还是队列来跟踪稍后要追踪的主题,都将影响我们咖啡探索的性质,正如我们将在接下来的两节中看到的那样。

深度优先搜索

深度优先搜索 是一种算法,它沿着一条单一路径不断深入,直到遇到死胡同。然后,算法会回溯到上一个分支点并检查其他选项。它使用堆栈来维护待探索的未来状态,始终选择最近插入的选项作为下一个尝试的目标。

在咖啡研究示例中,假设我们从“磨豆机”页面开始进行深度优先搜索,并迅速找到三个额外的主题可以继续探讨。我们将这些主题压入堆栈,如图 4-8(1)所示。大多数搜索会按照遇到主题的顺序添加选项。在这个示例中,为了一致性,我们按字母逆序添加元素,因此我们从 A 开始研究,一直进行到 Z(或最终的那个字母主题)。

为了简化起见,图 4-8 将每个主题(网页)表示为一个单独的字母。它们之间绘制的线条代表网页链接。这种结构被称为,我们将在第十五章详细讨论。阴影节点表示我们已探索的主题,圈住的主题表示当前迭代中我们正在查看的主题。存储未来待探索选项的堆栈数据表示为图右侧的Next数组。

一旦我们完成了关于磨豆机(A)的所有阅读,我们就转到下一个主题:刀片磨豆机(B)。此时,我们的搜索正在等待探索 B、F 和 H 主题。我们从堆栈顶部取出 B 主题,打开该页面,开始阅读,并发现更多感兴趣的主题(C)。显然,咖啡是一个深奥而复杂的主题;我们可以花一生时间研究它的细节。我们将新主题 C 压入堆栈顶部以备将来探讨,如图 4-8(2)所示。因此,更新的主题将成为下一个待探索的领域。

搜索以这种方式继续,优先考虑最近添加的主题,这使得这种搜索方法非常适合那些总是想探索他们刚看到的最新主题的人。我们不断深入每个主题线程,直到遇到死胡同,然后返回堆栈中的早期项。对于这个示例,已访问的节点不会重新访问或添加到堆栈中,但堆栈可能包含重复项。图 4-8 中的其余子图展示了这一过程。

深度优先搜索的图示,采取九个步骤搜索九个在分支图中的话题。话题用字母 A 到 I 表示。每一步我们都前进到一个新的话题。接下来的话题列在右侧的栏中。最上方的话题是我们正在探索的分支中的下一个话题,除非我们已经到达该分支的尽头。

图 4-8:深度优先搜索:使用栈追踪下一个要探索的话题,探索话题图

广度优先搜索

广度优先搜索使用与深度优先搜索相似的逻辑来探索话题,但通过队列存储未来的状态。在每一步,搜索会探索等待时间最长的选项,实际上是在深入之前向不同的方向分支。图 4-9 展示了上节中关于咖啡研磨机相关网站的广度优先搜索。圆圈代表话题,线条是它们之间的链接。阴影部分代表已被探索的话题,圆圈中的话题是我们在当前迭代中研究的话题。

在图 4-9(1)中,我们阅读了一篇关于 burr 研磨机(A)的页面,并以反向字母顺序记录了三个未来感兴趣的话题(B、F 和 H)。队列中的第一个话题与栈中的最后一个话题相同——这就是两个数据结构之间的关键排序区别。

我们继续研究,从队列前面取出项目(H),阅读该页面,并将任何新感兴趣的项目添加到队列的末尾。在图 4-9(2)中,我们探索下一个页面,但很快就碰到了死胡同。

在图 4-9(3)中,搜索继续前进,进入队列中的下一个话题(F)。在这里我们发现了新话题(I 和 G),并将这些新链接添加到队列中。

我们没有继续跟进这些话题,而是从队列的前面取出下一个项目(B),如图 4-9(4)所示,探索初始页面的最终链接。再次提醒,搜索只会将未被探索且不在队列中的节点添加进去。

正在进行中的广度优先搜索图示,采取九个步骤搜索九个在分支图中的话题。话题用字母 A 到 I 表示。每一步我们都前进到一个新的话题。接下来的话题列在右侧的栏中。最上方的话题总是当前分支中距离头部一层的下一个话题,直到该层次被探索完毕。

图 4-9:广度优先搜索:使用队列追踪下一个要探索的话题,探索话题图

随着搜索的推进,其优势变得显而易见:我们不是在返回之前深入探索每一条路径,而是沿着一个话题的边界进行探索,优先考虑广度而非深度。我们可能会在深入了解每种研磨机制及其各自发明者的历史之前,先浏览五种不同类型的研磨机。这种搜索非常适合那些不希望让旧话题拖延的人,他们更倾向于在开始新话题之前先把旧话题处理掉。

由于深度优先搜索和广度优先搜索每次只探索一个选项,它们的工作速率是相同的:深度优先搜索在广度优先搜索浅尝辄止地探索许多路径的时间内,深入探索了少数路径。然而,搜索的实际行为是截然不同的。

为什么这很重要

不同的数据结构既能让程序员以不同的方式使用数据,又能极大地影响与这些数据一起工作的算法的行为。栈和队列都存储对象,且都可以使用数组或链表来实现,且都能高效地处理插入和移除操作。从单纯存储数据的角度来看,任意一种都足够。然而,它们处理数据的方式,特别是返回数据项的顺序,使得这两种相似的数据结构表现出截然不同的行为。栈返回的是它们存储的最新数据,因此非常适合优先处理最新的项。与此相反,队列总是返回它们存储的最旧数据,非常适合处理按到达顺序处理项的情况。

在我们努力有效使用数据结构时,效率并不是唯一的考虑因素。在为特定算法设计或选择数据结构时,我们必须考虑该数据结构的属性如何影响算法的行为。正如本章中的搜索示例所示,我们仅通过交换数据结构,就可以从广度优先行为转变为深度优先行为。后续章节将详细阐述这一逻辑,并利用这一点设计其他有助于算法行为和性能的数据结构。

第五章:二叉搜索树

二叉搜索树利用二分查找算法的基本概念,创建了一种动态数据结构。这里的关键字是动态。与排序数组不同,二叉搜索树不仅支持高效的查找,还支持元素的高效添加和删除,使它们成为二分查找算法效率与动态数据结构适应性的完美结合。它们还可以作为任何房间中美丽的装饰性移动物件。

本章除了介绍二叉搜索树外,还讨论了查找值、添加新值和删除值的算法。它展示了如何利用指针创建比前几章基于列表的结构更强大的分支结构。你将学习到,通过仔细构建值之间的关系,我们可以将二分查找的方法编码到数据的结构中。

二叉搜索树结构

树是由节点组成的分支链条的层次结构数据结构。它们是链表的自然扩展,每个树节点允许有两个next指针,指向分离链表中的后续节点。图 5-1 展示了一个示例二叉搜索树。

一个二叉搜索树,图中有一个圆圈表示一个节点(一个值),指向另外两个节点。这些节点又指向其他节点(其他值)。

图 5-1:一个示例二叉搜索树

一个节点包含一个值(某种类型的值)和最多两个指向树中下层节点的指针,如图 5-2 所示。我们称至少有一个子节点的节点为内部节点,没有子节点的节点为叶节点

二叉搜索树节点的组成部分:一个圆圈表示一个值,一个指向左子节点的指针和一个指向右子节点的指针

图 5-2:二叉搜索树节点的必要组成部分

树节点可能包含其他信息,具体取决于它们的用途。我们通常会存储一个指向节点父节点的指针。例如,这一额外的信息使得我们可以从树的底部向上遍历,也可以从顶部向下遍历,这在我们考虑删除节点时非常有用。

从形式上讲,我们将二叉搜索树节点定义为具有以下最小信息的数据结构:一个值(或键),指向两个子节点的指针(如果没有相应的子节点,可以将其中一个指针设为null),以及一个指向父节点的可选指针。

TreeNode {
    Type: value
    TreeNode: left
    TreeNode: right
    TreeNode: parent
}

我们也可能希望存储辅助数据。存储和搜索单个值非常有用,但将这些值作为键来查找更详细的信息,大大增强了数据结构的功能。例如,我们可以将最喜欢的咖啡的名字作为节点的值,这样就可以高效地查找任何咖啡的记录。在这种情况下,我们的辅助数据将是关于那种咖啡的详细记录。或者,我们的值可以是时间戳,节点可以包含在那个时间点我们泡制了哪种咖啡的信息,从而让我们能够高效地查询我们的历史咖啡消费记录。树节点数据结构可以直接存储这些辅助数据,或者包含指向位于内存其他位置的复合数据结构的指针。

二叉搜索树从树顶的单一节点开始,并在下降时分支成多个路径,如图 5-3 所示。这个结构允许程序通过一个单一的指针访问二叉搜索树——即根节点的位置。

图 5-1 中的二叉搜索树,最上面的节点标记为“根节点”。

图 5-3:根节点表示二叉搜索树的顶部,是操作的起始位置。

植物学的纯粹主义者可能会将树画成根节点位于树的底部,节点向上分支,而不是像图 5-3 中那样从顶部开始。然而,这两种表示方式是等效的。实际上,无论是从上到下的图示,还是从下到上的图示,都隐藏了二叉搜索树的实际复杂性。就像链表一样,搜索树的各个节点可以分散存储在计算机的内存中。每个节点仅通过指针的强大功能和灵活性与其子节点和父节点连接。

二叉搜索树的强大功能来源于树中值的组织方式。二叉搜索树的属性规定:

对于任何节点NN左子树中任何节点的值都小于N的值,而N右子树中任何节点的值都大于N的值。

换句话说,树是根据每个节点的值来组织的,如图 5-4 所示。左节点及其以下所有节点的数据值都小于当前节点的值。同样,右节点及其以下所有节点的数据值都大于当前节点的值。因此,这些值有两个作用。首先,也是最明显的,它们表示存储在该节点的值。其次,它们通过将子树划分为两个子集来定义该节点以下的树结构。

上述定义隐含地限制了二分查找树只能包含唯一值。通过相应地修改二分查找树的属性,也可以定义允许重复值的二分查找树。其他文献可能会有所不同,取决于它们是否允许重复值,以及如何处理二分查找树属性中的相等性。本章专注于不包含重复值的情况,以与我们将在本书中探索的其他索引数据结构保持一致,如跳表和哈希表,尽管所呈现的算法可以调整以处理重复值。

一棵二分查找树,其中根节点的值为 52. 它的左指针指向一个值为 32 的节点,该值小于 52. 它的右指针指向一个值为 70 的节点,该值大于 52. 所有子树也根据二分查找树属性进行组织。

图 5-4:二分查找树中节点的值按照二分查找树的属性进行排序。

我们可以将二分查找树的结构与一个按幽默感级别组织的公共关系部门进行比较。每个员工通过一个单一的数值来衡量自己的幽默感级别,即在 30 分钟演示中的幽默插图数量。得分为 0 表示严肃的演讲者,只包括技术图表。得分为 100 或更高表示有抱负的喜剧演员,在每一张幻灯片上都加入多个笑话。整个部门围绕这个唯一的标准来组织。内部节点表示拥有一名或两名直接下属的经理。每个经理会根据自己的幽默感级别来划分他们的子团队。包含更多笑话的团队成员(较高的幽默感级别)进入右侧子团队。包含较少笑话的团队成员(较低的幽默感级别)进入左侧子团队。因此,每位经理既提供了一个分区功能,又在两个子团队之间提供了一个中间立场。

虽然节点的这种排序可能看起来没有太多结构,但请记住,通过使用类似的属性在二分查找中所获得的强大功能。二分查找树的属性实际上是保持树内的数据根据其在树中的位置进行排序。正如我们将看到的,这不仅允许我们高效地查找树中的值,还能高效地添加和删除节点。

搜索二分查找树

我们通过从根节点开始向下遍历二叉搜索树来进行查找。在每一步中,我们通过将当前节点的值与目标值进行比较,来决定是探索左子树还是右子树。如果目标值小于当前值,搜索就会向左进行。如果目标值大于当前值,搜索就会向右进行。因此,节点的值就像酒店里那些有用的指示牌,告诉我们 500-519 号房间在左边,520-590 号房间在右边。通过一次快速检查,我们可以做出适当的转弯,忽略另一个方向的房间。当找到目标值或者达到没有子节点的节点时,搜索结束。在后一种情况下,我们可以断定目标值不在树中。

迭代和递归搜索

我们可以通过迭代或递归的方式实现该搜索。以下代码使用递归方法,其中搜索函数调用自身,传入树中的下一个节点,最初调用的是树的根节点。代码返回指向包含该值的节点的指针,从而允许我们从节点中检索任何辅助信息。

FindValue(TreeNode: current, Type: target):
  ❶ IF current == null:
        return null
  ❷ IF current.value == target:
        return current
  ❸ IF target < current.value AND current.left != null:
        return FindValue(current.left, target)
  ❹ IF target > current.value AND current.right != null:
        return FindValue(current.right, target)
  ❺ return null

该算法在每个节点只进行少量测试;如果任何测试通过,函数就返回一个值并结束。首先,代码检查current节点是否为null,这可能发生在搜索空树时。如果是null,则树为空,并且根据定义,不包含感兴趣的值❶。其次,如果当前节点的值等于我们的目标值,代码已经找到了感兴趣的值,并返回该节点❷。第三,代码检查是否需要探索左子树,如果需要,则返回从该探索中找到的任何内容❸。第四,代码检查是否需要探索右子树,如果需要,则返回从该探索中找到的任何内容❹。请注意,在左右两种情况下,代码还会检查对应的子节点是否存在。如果没有任何测试触发,代码就到达了一个与目标值不匹配的节点,并且在正确方向上没有子节点。它已经走到死胡同,必须通过返回失败值(如null)来承认失败❺。死胡同发生在没有正确方向子节点时,因此,即使是一个只有一个子节点的内部节点,在搜索中也可能是一个死胡同。

假设我们使用这种策略在图 5-5 中查找值为 63 的节点。我们从根节点开始,并将其值(50)与目标值进行比较。由于 50 小于 63,我们知道目标值不在左分支中,因为该分支的每个节点的值都小于 50。这个简单的事实让我们能够剪枝掉整个左子树,从而避免检查树中 22 个节点中的 11 个节点。这个测试实际上与我们在第二章中的二叉搜索算法中的剪枝类似:我们通过将单个元素与目标值进行比较,使用这个测试来剪除搜索空间的大部分区域。

一个根节点值为 50 的二叉搜索树。根节点被阴影标示,表示搜索从这里开始。左边的第一个节点的值为 23,右边的第一个节点的值为 67。

图 5-5:二叉搜索树查找的第一步。搜索从根节点开始。

我们的搜索继续沿着右子树向下,直到值为 67 的节点,如图 5-6 所示。我们再次利用二叉搜索树的性质,排除掉剩余搜索空间的一半。在这种情况下,63 小于 67,因此我们选择左子树。67 右子树中的任何内容都必须大于 67,因此不能包含 63。我们又剪枝了 5 个节点。

图 5-5 中的二叉搜索树。这一次,根节点(值为 50)下方的右节点(值为 67)被高亮显示。

图 5-6:二叉搜索树查找的第二步

此时,我们可以对当前节点下方剩余的搜索空间做出明确的判断。由于我们在 50 处选择了右分支,在 67 处选择了左分支,我们知道新子树中的所有节点的值都大于 50 且小于 67。实际上,每次选择右分支时,我们都在收紧剩余搜索空间的下界;而每次选择左分支时,我们都在收紧上界。

搜索继续沿树向下遍历,每个阴影节点都被访问,如图 5-7 所示。在找到目标值之前,搜索经过了 22 个节点中的 4 个节点。

图 5-5 中的二叉搜索树。这一次,节点 50、67、60 和 63 都被高亮显示,表示我们已经找到了目标值 63。

图 5-7:二叉搜索树查找值为 63 的完整路径

考虑在幽默度量组织的公共关系部门中的搜索。假设部门主管需要为一个行业会议的非正式演讲寻找一位发言人。经过一些考虑,他们确定每 30 分钟 63 个笑话的幽默水平对于这个观众最为合适。部门主管(根节点)考虑自己的幽默水平,意识到自己太严肃,因此要求他们的得力助手在助手的组织中找到合适的人选。右侧子树中的每个人都比部门主管更有幽默感。那位经理重复相同的步骤,将自己的幽默水平(67)与目标值进行比较,并将任务委派给合适的下属。

当然,搜索不需要一直进行到叶子节点。如图 5-8 所示,相关节点可能位于树的中间。如果我们搜索相同的树以查找值 14,我们会走两次左支路并最终到达合适的内部节点。这个中间层的经理完全符合我们的幽默标准,可以进行演讲。因此,在树的下降过程中,我们需要检查当前节点是否等于我们的目标值,并在找到匹配时提前终止搜索。

图 5-5 中的二叉搜索树。这次,节点 50、23 和 14 都被高亮显示,表示我们找到了目标值 14。节点 14 是一个有两个子节点的内部节点。

图 5-8:二叉搜索树的搜索可以在一个内部节点结束,其中该节点的值与我们的目标值匹配。

迭代方法搜索二叉搜索树,用WHILE循环代替递归,沿树向下迭代。搜索仍然从树的根开始。

FindValueItr(TreeNode: root, Type: target):
  ❶ TreeNode: current = root
  ❷ WHILE current != null AND current.value != target:
      ❸ IF target < current.value:
            current = current.left
        ELSE:
            current = current.right
  ❹ return current

代码首先创建一个本地变量current,用于指向当前的搜索节点❶。最初,这将是根节点,在空树中可能为null。然后,WHILE循环不断遍历树,直到它遇到死胡同(current == null)或找到正确的值(current.value == target)❷。在循环中,代码检查下一个子节点应为左侧还是右侧❸,并重新将current指向相应的子节点。函数最终返回current,它可能是找到的节点,或者如果树为空或未找到值,则返回null❹。

递归和迭代搜索的计算成本都与目标值在树中的深度成正比。我们从树的顶部开始,沿着单一路径向下。树越深,所需进行的比较就越多。因此,结构化树以最小化其深度从而提高搜索效率。

树的搜索与有序数组的搜索

持怀疑态度的读者可能会抗议:“第二章已经教过我们如何在排序数据上进行高效搜索。二分查找随着数据量的增加按对数级别扩展。你们已经举过例子,为什么要把数据放在树结构里,而不是排序数组里?这样是不是增加了不必要的复杂性和开销,增加了所有这些指针?”

这些顾虑是合理的。然而,考虑数据结构和搜索如何在更广泛的上下文中使用是很重要的。如果我们的数据已经是一个排序过的数组,并且我们只需要进行一次搜索,那么构建一棵树而不是直接进行二分查找并不会带来帮助。实际上,构建树本身比进行一次线性扫描更为昂贵。同样,如果数据不发生变化,那么对数据进行一次排序并使用已排序的数组可能更为合适。这样可以避免树结构本身的内存开销。当数据变得更加动态时,权衡因素会发生变化。

假设有员工加入或离开公关部门。除了正常的文书工作,部门还需要更新其幽默水平的数据结构。每个新员工代表幽默水平列表的新增项,每个离职代表删除项。部门可以通过使用办公室分配来根据幽默水平对员工进行排序,而不是使用汇报层级。幽默感最差的人在办公室 1,幽默感最强的人在办公室 100。经理仍然可以高效地搜索到正确的发言人。然而,他们现在需要在每次新增或离职时修正办公室分配。对于一个大型部门或高频次的变动,开销会增加。在高度动态的环境中,比如待处理的餐厅订单列表,成本可能会变得相当可观。

二叉搜索树以及动态数据结构的强大之处,体现在数据发生变化的情况下。正如我们在接下来的章节中将看到的,二叉搜索树使得我们可以高效地添加和删除数据点。在一个排序过的数组中,每次添加或删除数据时,我们都需要不断更新数组,这会非常耗费资源。相反,二叉搜索树在数据本身发生变化时,能够保持数据处于一种易于搜索的结构。如果我们在一个动态数据集上进行大量的搜索,这种效率的结合就变得至关重要。

修改二叉搜索树

在使用或修改二叉搜索树时,根节点总是需要特别关注。当在树中搜索一个节点时,我们总是从根节点开始。当将第一个节点插入到树中时,比如第一个加入公关部门的人,我们将该节点作为新的根节点。正如我们将在本章稍后看到的,当从二叉树中删除一个节点时,我们必须把根节点当作特殊情况处理。

我们可以通过将整个树结构封装在一个包含根节点的轻量级数据结构中,简化使用二叉搜索树的逻辑:

BinarySearchTree {
    TreeNode: root
}

虽然这看起来可能是浪费(更多的复杂性和额外的数据结构),但它为树提供了一个易于使用的接口,并大大简化了我们对根节点的处理。当使用包装数据结构(或类)来表示我们的二叉查找树时,我们还需要提供顶层函数来添加或查找节点。这些函数是相对简单的包装器,并且处理没有节点的树时有一个特殊情况。

为了搜索树,代码再次从检查树是否为空(tree.root == null)开始:

FindTreeNode(BinarySearchTree: tree, Type: target):
    IF tree.root == null:
        return null
    return FindValue(tree.root, target)

如果树为空,它会立即返回null,表示搜索未找到匹配项。否则,代码会使用FindValue递归搜索树。这里进行null检查甚至可以替代FindValue开始时的检查,从而只需对整个树进行一次检查,而不是每个节点都检查一次。

添加节点

我们使用与搜索树时相同的基本算法来添加值。我们从根节点开始,像搜索新值一样向下遍历树,一旦遇到死胡同就终止:无论是叶节点还是具有单个子节点且方向错误的内部节点。我们搜索和插入算法的主要区别出现在遇到死胡同后,插入算法会在当前节点下创建一个新的子节点:如果新值小于当前节点的值,则创建一个左子节点,否则创建右子节点。

在这里,我们可以清楚地看到允许重复值的树和不允许重复值的树在行为上的区别。如果我们的树允许重复值,我们会继续向下遍历直到遇到死胡同,然后在树中插入该值的一个新副本。如果树不允许重复值,我们可能会替换或扩展匹配节点中存储的数据。例如,我们可以跟踪的一个简单辅助数据是计数器——记录该值被添加到树中的次数。下面我们重点讨论覆盖数据的情况,以便与本书中将要探讨的其他索引数据结构保持一致。

与我们的搜索函数一样,我们从一个处理空树情况的添加包装函数开始:

InsertTreeNode(BinarySearchTree: tree, Type: new_value):
    IF tree.root == null:
        tree.root = TreeNode(new_value)
    ELSE:
        InsertNode(tree.root, new_value)

首先,代码检查树是否为空(tree.root == null)。如果是,它会使用该值创建一个新的根节点。否则,它会在根节点上调用InsertNode,从下面开始启动递归过程。这样,我们可以确保InsertNode是在有效(非空)节点上调用的。

下面是InsertNode的代码:

InsertNode(TreeNode: current, Type: new_value):
  ❶ IF new_value == current.value:
        Update node as needed
        return
  ❷ IF new_value < current.value:
      ❸ IF current.left != null:
            InsertNode(current.left, new_value)
        ELSE:
            current.left = TreeNode(new_value)
            current.left.parent = current 
    ELSE:
      ❹ IF current.right != null:
            InsertNode(current.right, new_value)
        ELSE:
            current.right = TreeNode(new_value)
            current.right.parent = current

InsertNode代码首先检查是否遇到了一个值匹配的节点,如果是,则根据需要更新该节点的数据 ❶。否则,代码通过比较新值和当前节点值,按照左路径或右路径的方式搜索插入新值的正确位置 ❷。在任何情况下,代码都会检查沿着路径的下一个节点是否存在 ❸ ❹。如果下一个节点存在,代码会沿着路径继续前进,深入树中。否则,代码找到了一个死胡同,表示可以插入新节点的正确位置。算法通过创建一个新节点,链接父节点的相应子指针(leftright),并设置parent链接来插入节点。

例如,如果我们要将数字 77 添加到图 5-9 中的二叉搜索树中,我们会沿着节点 50、67、81 和 78 向下移动,直到在值为 78 的节点处遇到死胡同。此时,我们发现在正确方向上没有有效的子节点。我们的搜索遇到死胡同。我们创建一个值为 77 的新节点,并将其设置为节点 78 的左子节点。

图 5-5 中的二叉搜索树。我们要插入值 77。一系列的左右分叉导致在 78 下面遇到死胡同。我们将新节点插入为 78 的左子节点。

图 5-9:将值 77 插入到我们的二叉搜索树中

向树中插入新节点的成本再次与我们插入新节点的分支深度成正比。我们会在路径上每个节点进行一次比较,直到到达死胡同,像搜索操作一样,我们会忽略其他分支中的所有节点。因此,插入操作的最坏情况成本将与树的深度成线性关系。

删除节点

从二叉搜索树中删除节点比添加节点更复杂。删除节点有三种情况需要考虑:删除叶子节点(没有子节点)、删除一个只有一个子节点的内部节点、删除一个有两个子节点的内部节点。正如你所预期的,随着子节点数量的增加,任务变得更加复杂。

要删除叶子节点,我们只需删除该节点,并更新其父节点的子指针,以反映该节点不再存在的事实。这可能会使父节点变成叶子节点。例如,要删除图 5-10 中的节点 58,我们只需删除节点 58,并将其父节点的左子指针设置为null

图 5-5 中的二叉搜索树。被删除的叶子节点值为 55,被划去。来自其父节点的指针也被划去。

图 5-10:通过删除叶子节点并更新其父节点的指针,来从二叉搜索树中移除叶子节点。

删除叶节点展示了存储父节点指针的价值:它使我们能够查找要删除的节点,沿父指针回溯到该节点的父节点,并将相应的子指针设置为 null。存储这一个额外的数据,使得删除操作变得更简单。

在我们公关部门的示例中,被删除的叶节点对应的是一位没有直接下属的员工离职。告别派对和蛋糕过后,组织的其余部分继续工作。层级结构唯一的变化是,前员工的上司少了一个团队成员。实际上,他们现在可能没有下属了。

如果目标节点有一个子节点,我们通过将该唯一子节点提升为被删除节点父节点的子节点来移除它。这就像是从我们的汇报层级中移除一位经理而不调整其他人。当经理离开时,他们的上司接管了该前员工的唯一直接下属。例如,如果我们想从示例树中删除节点 17,我们可以简单地将节点 21 上移,取而代之,如图 5-11 所示。现在,节点 14 直接连接到节点 21。

图 5-5 中二叉搜索树的两个示意图。左侧,节点 17 被删除,节点 14 直接指向 21。右侧,节点 21 被上移到原本属于节点 17 的位置。

图 5-11:通过改变指针(左侧)并将该子节点上移(右侧)来删除具有单个子节点的内部节点。

这种删除单子节点的方式即使我们上移的节点本身有子树也有效。由于被上移的节点已经在父节点的子树中,它的所有后代将继续遵循二叉搜索树的特性。

当我们尝试删除具有两个子节点的内部节点时,复杂性显著增加。单纯删除节点或将一个子节点上移已经不够了。在我们公关部门,一个节点的两个子节点代表了两个性格迥异、幽默感不同的员工。我们不能只是选择一个子节点进行提升,而让另一个不小心从层级中消失,失去通过脆弱的指针链条与根节点的连接。我们必须保持树的完整性,并确保它继续遵循二叉搜索树的特性。

要删除一个有两个子节点的节点,我们用树中的另一个节点替换该节点,以保持二叉搜索树的性质。我们通过找到要删除节点的后继来做到这一点——这是我们按排序顺序遍历节点时会遇到的下一个节点。我们将后继节点交换到删除节点的位置。这个交换的节点可能也有一个子节点,当它从原位置移除时需要处理。为了在不破坏任何指针的情况下从二叉树中移除后继节点,我们对要交换的节点重新使用删除程序。我们找到后继节点,保存指向该节点的指针,然后将其从树中移除。

例如,如果我们想删除图 5-12 中的值 81,我们需要首先交换值为 91 的节点。我们通过保存指向要删除节点和后继节点的指针来做到这一点(见图 5-12(1))。然后,我们将后继节点设置为被删除节点父节点的子节点(见图 5-12(2))。最后,我们将后继节点的子节点更新为被删除节点的子节点,实际上就是将其交换到该位置(见图 5-12(3))。

为了执行删除操作,我们需要能够高效地找到节点的后继。虽然这看起来是一个艰巨的任务,但我们有一个关键的优势。由于我们只考虑节点有两个子节点的情况,我们始终可以在节点的右子树中找到后继节点。具体来说,后继节点是右子树中最小的(或最左边的)节点。作为额外好处,后继节点保证最多只有一个(右侧)子节点。如果候选后继节点有一个左子节点,那么这个左子节点(或其左子树中的某个节点)才是实际的后继。

三张二叉搜索树的示意图,来自图 5-5,展示了删除值为 81 的节点的过程,81 是一个有两个子节点的内部节点。节点 81 有父节点 67 和指向子节点 78 和 92 的指针。67 的指针被改为指向 91。然后,91 被赋予指向子节点 78 和 92 的指针。

图 5-12:为了移除一个有两个子节点的内部节点,我们首先将节点的后继交换到该位置。

清单 5-1 提供了(虽然冗长)伪代码,演示了如何从二叉搜索树中删除我们刚刚讨论的三种类型的节点。也可以实现更简短的代码。然而,明确地分解这些情况有助于展示其中的复杂性。还需要注意的是,我们通过节点的指针而不是值来删除节点。因此,为了删除具有给定值的节点,我们首先使用 FindTreeNode 查找该节点的指针,然后用该指针调用删除操作。

RemoveTreeNode(BinarySearchTree: tree, TreeNode: node):
  ❶ IF tree.root == null OR node == null:
        return

    # Case A: Deleting a leaf node.
  ❷ IF node.left == null AND node.right == null:
        IF node.parent == null:
            tree.root = null
        ELSE IF node.parent.left == node:
            node.parent.left = null
        ELSE:
            node.parent.right = null
        return

    # Case B: Deleting a node with one child.
  ❸ IF node.left == null OR node.right == null:
      ❹ TreeNode: child = node.left
        IF node.left == null:
            child = node.right

      ❺ child.parent = node.parent
        IF node.parent == null:
            tree.root = child
        ELSE IF node.parent.left == node:
            node.parent.left = child
        ELSE:
            node.parent.right = child
        return

    # Case C: Deleting a node with two children.
    # Find the successor and splice it out of the tree.
  ❻ TreeNode: successor = node.right
    WHILE successor.left != null:
        successor = successor.left
    RemoveTreeNode(tree, successor)

    # Insert the successor in the deleted node's place.
  ❼ IF node.parent == null:
        tree.root = successor
    ELSE IF node.parent.left == node:
        node.parent.left = successor
    ELSE:
        node.parent.right = successor
  ❽ successor.parent = node.parent

  ❾ successor.left = node.left
    node.left.parent = successor

    successor.right = node.right
    IF node.right != null:
        node.right.parent = successor

清单 5-1:从二叉搜索树中移除一个节点

与插入和查找的包装函数一样,代码首先检查树是否为空 ❶,如果是,则返回null。它还检查是否有有效的节点可以删除(node != null),这在我们希望将查找和删除合并成一行时非常有用:

RemoveTreeNode(tree, FindTreeNode(tree, target))

由于 FindTreeNode 在找不到节点时返回 null,我们显式地处理此情况。

然后,代码按顺序考虑三种情况。在 A 情况下,当移除的是叶子节点 ❷ 时,代码只需要更改被删除节点父节点的正确子指针。首先,代码检查要删除的节点是否有父节点。如果没有,代码正在删除根节点本身,并将根节点指针修改为 null,从而有效地删除根节点。如果删除的节点是父节点的左子节点,代码将该指针设置为 null。同样,如果删除的节点是父节点的右子节点,代码也会将该指针设置为 null。然后,代码可以返回,成功地将目标叶子节点从树中删除。

在 B 情况下,删除一个只有一个子节点的节点 ❸,代码首先通过检查哪个子指针不是 null ❹ 来确定节点的哪个子指针指向该子节点。代码存储该子节点的指针以供后续使用。接下来,它修复新晋升节点与其新父节点之间的指针 ❺。代码将子节点的父指针设置为其之前的祖父节点,将被删除的节点从树中剪接出去,向上修复。最后,代码修复被删除节点父节点中的正确子指针,包括处理根节点变化的特殊情况。代码将先前指向被删除节点的指针重定向,指向该节点的唯一子节点。如果被删除的节点没有父节点,说明正在处理根节点,需要相应地修改指针。一旦代码完成了正确节点的剪接,它便返回。

在 C 情况下,当要删除的节点有两个子节点时,代码首先确定后继节点,并将其从树中移除 ❻。请注意,如上所述,递归调用 RemoveTreeNode 本身无法触发 C 情况,因为后继节点最多只有一个(右子)节点。即使在将后继节点从树中移除后,代码仍会保持对该后继节点的指针,因为它将使用该节点替换被删除的节点。接下来,代码通过以下一系列步骤将被删除的节点替换为后继节点:

  1. 修改被删除节点的父节点,将正确的子指针指向后继节点 ❼。

  2. 修改后继节点的父指针,使其指向新的父节点 ❽。

  3. 设置继承节点的左右子节点的链接❾。处理右子节点时,代码需要额外小心,因为它有可能已经通过上面的RemoveTreeNode操作删除了该子节点(当继承节点是node的直接右子节点时)。因此,在尝试分配右子节点的父指针之前,它需要检查右子节点是否为null

根据编程语言及代码的使用方式,我们也可能希望在删除过程中将node的出边指针设置为null。这样可以清理从已删除节点到树中其他节点的引用。我们可以通过在每个三种情况的末尾(在 A 和 B 情况中的return语句之前,以及 C 情况中的函数结束之前)添加以下几行代码来实现:

node.parent = null
node.right = null
node.left = null

与搜索和插入一样,删除操作最多需要沿一条路径从上到下遍历树。在 A 和 B 情况下,这一遍历发生在RemoveTreeNode函数之前(作为之前调用FindTreeNode获取节点指针的一部分)。C 情况则增加了从内部节点到其继承节点的额外遍历。因此,删除操作的最坏运行时间仍然与树的深度成正比。

不平衡树的危险

完全平衡的二叉搜索树上进行搜索、添加和删除操作的时间,在最坏情况下与树的深度成正比,这使得这些操作在树的深度不过大的情况下非常高效。完全平衡的树是指在每个节点处,右子树和左子树的节点数相等。在这种情况下,每次我们将树中的节点数加倍,树的深度都会增加 1。因此,在平衡树中,所有三种操作的最坏性能都与 log2 成正比,其中N是元素数量的对数。

二叉搜索树在树形大致平衡时仍然高效,即使不完全平衡。但如果树变得极度不平衡,树的深度可能会随元素数量线性增长。实际上,在极端情况下,我们的优秀二叉搜索树变成了一个排序链表——所有节点都有一个子节点,并且方向相同,如图 5-13 所示。

一个不平衡的二叉搜索树,其中每个节点只有一个指向单一子节点的右指针

图 5-13:一个不平衡的二叉搜索树示例

在许多现实世界的应用中,很容易出现高度不平衡的树。假设我们正在将咖啡日志存储在一个按时间戳索引的二叉搜索树中。每次喝一杯咖啡时,我们就将相关信息插入到树中。事情很快就变得糟糕。由于时间戳是单调递增的,我们按排序顺序插入每一条记录,结果仅使用右子节点指针创建了一个链表。

对于一个不平衡的树,操作可能会非常低效。考虑一个包含N个节点的树。如果我们的树是平衡的,那么操作的时间复杂度是N的对数级别。相反,在树变成链表的情况下,操作的时间复杂度将是与N成线性关系。

我们可以使用多种增强技术,如红黑树、2-3 树和 B 树,来保持树在动态插入和删除过程中保持平衡。这些方法的权衡是树操作的复杂性增加。我们将在第十二章中详细讨论 B 树,并展示它们的结构如何保持树的平衡。

下一节介绍了一种直接的构建平衡二叉搜索树的方法,利用批量构建,允许算法选择哪些节点来分割数据,以平衡每一侧的节点数。当我们手头有许多值,但需要小心未来的插入时,这是一个不错的选择,因为插入可能会导致树的不平衡。

二叉搜索树的批量构建

我们可以通过迭代地添加节点轻松构建一个二叉搜索树:首先创建一个新的节点并将其标记为根节点,然后对于每个剩余的值,创建一个新节点并将其添加到树中。这种方法的优点是简单并且复用了我们之前定义的算法。不幸的是,这种方法可能会导致树的不平衡。正如我们之前看到的,如果我们按排序顺序添加值,最终会得到一个排序好的链表。我们可以在从初始的数字集创建树时做得更好。

我们通过递归地将元素分割成更小的子集,从排序数组(如图 5-14 所示)中创建平衡的二叉搜索树。在每一层,我们选择中间值作为该层的节点。如果元素个数为偶数,我们可以选择两个中间元素中的任意一个。

一个包含从 1 到 12 的值的排序数组

图 5-14:用于批量构建二叉搜索树的排序数组

我们创建一个新节点,节点的值等于数组中的中间元素,并将剩余的元素分配到两个子节点中,如图 5-15 所示。我们使用相同的过程为每个子节点递归地创建子树。小于中间元素的值放到左侧,而较大的值放到右侧。

图 5-14 中的排序数组被拆分为两个部分。根节点值为 7,指向一个包含值 1 至 6 的数组,并指向一个包含值 8 至 12 的数组。

图 5-15:第一次拆分后,我们有一个单一的节点和两个独立的数组。

我们不需要在每次拆分时创建输入数组的新副本。相反,我们可以借鉴二叉搜索算法的思路,只需跟踪当前考虑中的数组范围,如图 5-16 所示。每次拆分将数组分成一致的两半,因此我们只需关注两个边界:当前分支中最高和最低值的索引。

图 5-15 中的数组,其中第 7 到 12 框被灰色标记。框 1 标记为低(Low),框 6 标记为高(High),表示左侧数组的范围。

图 5-16:可以使用高低索引来跟踪当前考虑中的数组子集。

一旦我们创建了新节点,就可以使用相同的方法独立构建左右子树。具体来说,我们选择中间值,从该值创建一个新节点,划分剩余的范围,并利用这些范围来创建子树。当我们的范围中只剩下一个值时,过程结束。在这种情况下,我们创建一个包含该值且没有子节点的新叶节点。

为什么这很重要

二叉搜索树展示了我们如何将动态数据结构适应特定问题。这些树使用分支结构来捕捉并维护数据值中的顺序信息,从而实现高效搜索。此外,二叉搜索树的基于指针的结构使其能够在新数据被添加时持续适应。这种数据、问题和计算的相互作用为我们解决越来越复杂的计算挑战提供了基础。

在后续章节中,我们将继续构建动态数据结构的概念,将数据结构根据问题本身进行适配,并使用分支数据结构来实现高效的剪枝。这些技术出现在多种不同的数据结构中。理解这些基础知识,特别是这些技术如何在动态二叉搜索树中实现高效搜索,对于理解如何处理更复杂的数据结构和算法至关重要。下一章将介绍字典树(trie),展示如何将二叉搜索树的基于树的概念扩展到多叉分支,以提高某些类型搜索的效率。

第六章:Trie 树与数据结构的调整

二叉搜索树虽然功能强大,但它只是使用树形结构更好地组织数据的一种方式。我们可以不再依赖“小于”或“大于”的比较,而是根据具体的搜索问题优化树的分裂方式。例如,在本章中,我们将解决在树中存储和搜索字符串的问题。通过扩展二叉搜索树的一般分支方法来捕捉数据中的额外结构,使我们能够在字符串集合中高效地搜索目标字符串。

我们首先讨论如何将二叉搜索树直接应用于字符串数据,但它比其他数据类型的成本更高。考虑到字符串的顺序特性,我们接着将调整我们的搜索树,以便更高效地存储字符串。最终,我们得到一种分支结构,可能有一天会激发出世界上最华丽的、过于奢华的文件柜:Trie 树(发音为“try”)。

Trie 树是一种数据结构,在每一层基于字符串的单个字符进行分支。这种分割策略极大地减少了每个节点进行比较的成本。通过这个视角,我们探讨了各种算法和数据结构的基础概念是如何被调整以适应一种全新的问题类型。

字符串的二叉搜索树

在考虑我们是否能改进一个算法时,我们应该首先了解当前方法的局限性——否则,就没有理由构建更复杂的数据结构。因此,在深入讨论特定于字符串的数据结构之前,我们将检查二叉搜索树在存储字符串时存在的不足。首先,让我们看看二叉搜索树是如何用于这种搜索的。

树中的字符串

二叉搜索树不仅可以存储数字,还可以存储任何可排序的事物,从鞋子(按大小或气味排序)到僵尸电影(按票房收入或恐怖程度排序),再到食物(按价格、辛辣程度或在接下来的 24 小时内引发呕吐的可能性排序)。从这个角度来看,二叉搜索树非常灵活。我们所需要的只是能够对这些项目进行排序的能力。

要在二叉搜索树中存储字符串,我们可以按字母顺序对元素进行排序。例如,图 6-1 中的每个二叉搜索树节点都是一个单独的字符串,它将其子树分割成字典中位于它之前和之后的字符串。我们复用了来自二叉搜索树的“大于”和“小于”符号,其中 X < Y 表示字符串 X 在字母表中排在字符串 Y 之前。

这棵二叉搜索树的根节点是 Laugh。它的左子节点是 Feet,右子节点是 Rock。各种子树也按照字母顺序进行组织。

图 6-1:通过单词构建的二叉搜索树

我们像对待数字一样搜索字符串的二叉搜索树。例如,为了在图 6-1 中找到字符串 LIGHT,我们从根节点开始。然后,我们将目标值与节点值进行比较,使用字母顺序,并沿着左分支或右分支继续:

  1. LIGHT > LAUGH:我们沿右分支继续搜索。

  2. LIGHT < ROCK:我们沿左分支继续搜索。

  3. LIGHT < MAIN:我们沿左分支继续搜索。

  4. LIGHT == LIGHT:我们已经找到了目标值,可以终止搜索。

在图 6-2 中,阴影椭圆标示了搜索过程中探索的 12 个节点中的 4 个节点。

图示标记了搜索字符串 LIGHT 的路径。搜索从 LAUGH 到 ROCK 再到 MAIN,最后到 LIGHT。包含这些字符串的节点被灰色标出。

图 6-2:搜索字符串 LIGHT 时遍历二叉搜索树的路径

一开始,二叉搜索树似乎提供了一种简单、高效的字符串数据搜索机制——无需修改。如果我们的树是平衡的,最坏情况下的搜索成本将与条目数量的对数成比例。在图 6-2 中,我们能够将搜索限制为仅检查 12 个节点中的 4 个节点。

然而,我们忽视了一个关键因素——每次比较的成本。正如我们在第一章中看到的,比较两个字符串比比较一对数字要更昂贵。事实上,在最坏情况下,字符串比较操作的成本与字符串的长度成正比。现在,我们的树形搜索成本不仅依赖于字符串的数量,还与它们的长度有关,这意味着我们增加了一个新的复杂度维度。

为了解决这个问题,并在二叉搜索树提供的基础上实现更大的计算节省,我们必须考虑字符串数据结构的两个重要方面:字符串的顺序性和涉及的字母或字符的数量有限性。

字符串比较的成本

到目前为止,我们在搜索字符串的过程中忽略了两个重要的信息。第一个是字符串比较的顺序性。为了确定字符串的字母顺序,我们从字符串的第一个字符开始,逐一比较字符,直到找到不同之处。这个不同之处决定了字符串在搜索树中的相对顺序——其余的字符不重要。

在图 6-3 中的例子中,ZOMBIE 排在 ZOOM 之前,是因为在第三个位置的字符:M 排在 O 之前。我们不关心剩余字符 BIE 和 M 之间的关系,可以忽略它们。

一张展示 ZOOM 和 ZOMBIE 两个字符串比较的图。前两个字符用等号标出,表示它们相同。第三个字符用大于号标出,表示 O 位于 M 之后。最后的字符则被灰色标出,表示它们被忽略。

图 6-3:两个字符串的比较按字符逐一进行,直到找到第一个不匹配的字符对。

正如我们在第一章中所看到的,二叉搜索树中对字符串进行顺序比较的代价,本质上比比较两个数字更高。即使是比较图 6-3 中那两个相对较短的字符串,也需要进行三次单独的比较:Z 对 Z,O 对 O,O 对 M。当我们处理更长的字符串时,比如我们最喜欢的电影台词,成本就可能变得很高。当字符串之间的重叠度较高时,情况会变得更加严峻。想象一下,一个按名称索引我们咖啡收藏的二叉搜索树。如果我们将几百种咖啡添加到品牌名为“Jeremy’s Gourmet High Caffeine Experience”下,我们就需要比较相当多的字符,才能判断“Jeremy’s Gourmet High Caffeine Experience: Medium Roast”是排在“Jeremy’s Gourmet High Caffeine Experience: City Roast”之前还是之后。我们的二叉搜索树算法在每个节点都会付出这一成本。

我们尚未考虑的第二个关键信息是,在许多语言中,每个位置只能包含少数几个字母。英文单词仅使用 26 个字母(忽略大小写)。即使我们包括数字和其他字符,实际有效字符的集合也是有限的。这意味着,在每个位置上,字符串的延续方式是有限的——下一步的选择有限。正如我们即将看到的,这一见解使我们能够定义一个划分函数,该函数可以根据字符串中的下一个字符进行多重分裂。

我们可以结合这些见解构建一种数据结构,其操作方式类似于现实中比较字符串的方式。由此产生的数据结构是一个字典树,它经过优化,能够考虑到字符串中的附加结构。

尝试

字典树是一种基于树的数据结构,它根据字符串的前缀在不同的分支上进行划分。计算机科学家 René de la Briandais 提出了字典树的通用方法,用于改进计算机中内存访问速度较慢时的文件搜索,而计算机科学家兼物理学家 Edward Fredkin 提出了它的名字。与其在每个节点将数据划分为两个集合,我们根据当前的前缀将树进行分支(顺序比较)。此外,我们允许树在每个节点上分裂成多个分支,而不仅仅是两个(有限字符数)。实际上,对于英文单词,我们可以在每个节点上让树分成 26 个分支——每个分支对应可能的下一个字母。因此,字典树中的每个节点都代表了到目前为止的前缀。

类似于二叉搜索树,trie 也从根节点开始,在这种情况下表示空的前缀。每个分支接着定义字符串中的下一个字符。自然地,这意味着每个节点可以有超过两个子节点,如图 6-4 所示。

一个 trie 节点,分支指向以 A、B、C 等字母开头的子节点。

图 6-4:trie 节点在当前字符位置上进行所有可能字符的分支。

我们可以通过使用指针数组来实现每个 trie 节点的分支,每个字符对应一个数组桶。在某些情况下,我们可能能够使用比数组更节省内存的表示方式。毕竟,即使是对于英语单词,大多数非平凡的前缀也会有显著少于 26 个有效选项来确定下一个字母。然而,出于简单性考虑,我们现在仍将使用数组实现。本章中的讨论和示例实现也主要集中在英语单词(26 个字母和一个 26 元素的子节点数组)上,尽管这些算法同样适用于其他字符集。

将 trie 的分支结构想象成一个大型活动的注册台。参加世界顶级的计算机科学咖啡类比大会时,我们会去注册台领取属于自己的个性化信息包和免费会议小礼品(希望是一个咖啡杯)。由于参与者众多,组织者将信息包分成可管理的小组,以防止排成一条长队绕到大会中心。在此,组织者没有采取一对一分割(比如“你的姓氏是在 Smith 之前还是之后?”),而是根据参与者姓氏的首字母将与会者分成 26 条不同的队伍。通过这一步骤,拥挤的与会者数量被缩减为 26 个更易管理的队伍。trie 在每个节点执行这种壮观的多路分支。

和二叉搜索树一样,我们不需要为树的空分支创建节点。图 6-5 展示了这种结构的一个例子,其中阴影部分的字母表示 trie 的空分支。我们不会为这些分支创建子节点。使用与二叉搜索树相同的术语,我们将至少有一个子节点的节点称为内部节点,而没有任何子节点的节点称为叶节点。因此,尽管我们每个节点可能最多分支 26 次(当仅使用英语字母时),但我们的 trie 会相对稀疏。后续节点可能只分支少数几次。

trie 的前三个层级。每个节点展示了可能的分支,指向以 A、B 或 C 开头的子节点。某些字符被灰掉,表示在各个分支中缺少子节点。

图 6-5:Trie 只为非空分支创建节点,从而避免在未使用的分支上浪费空间。

与二叉搜索树不同,字典树中的每个节点并不都代表一个有效条目。虽然每个叶子节点都是有效条目,但一些内部节点可能只是通往完整字符串的前缀。例如,包含字符串 COFFEE 的字典树会有一个表示前缀 COFFE 的中间节点。尽管这是作者在喝了太多咖啡时经常使用的拼写方式,但它实际上并不是一个有效的单词或条目。我们不希望我们的数据结构暗示因为有一个与该前缀对应的节点,就意味着 COFFE 是可以接受的。然而,在其他情况下,内部节点可能是完全有效的条目。如果字典树包含字符串 CAT 和 CATALOG,CAT 的节点将是内部节点,因为它至少有一个子节点,位于通向 CATALOG 节点的路径上。

为了解决这个歧义,我们在字典树节点中存储一个指示器,表示当前前缀是否代表一个有效条目。这个指示器可以像一个简单的布尔值is_entry,对于任何对应有效条目的节点,其值为True,否则为False

TrieNode {
    Boolean: is_entry
    Array of TrieNodes: children
}

在这个例子中,COFFE 的节点会有is_entry = False,而 COFFEE 的节点会有is_entry = True

另外,我们可以在字典树节点中存储更有用的信息,比如记录某个条目被插入多少次。或者,如果我们正在跟踪每个条目的辅助数据,例如一个单词的定义或一系列滑稽的双关语,我们可以利用这些数据的存在本身作为一个指示器。那些不代表有效条目的前缀可以指向null或空的数据结构。这看起来可能有些严格,但只有真实的单词才能成为我们最佳的双关语。

与二叉搜索树一样,我们可以通过将根节点包装在字典树对象中来清理字典树的接口:

Trie {
    TrieNode: root
}

与二叉搜索树不同,我们的字典树总是有一个(非空)根节点。我们在创建字典树数据结构的同时也会创建这个根节点。即使是完全空的字典树,我们也会分配一个根节点(is_entry = False),它表示字符串的起始点。Trie数据结构不仅会将根节点封装成一个便捷的容器,还能让我们隐藏一些进行各种操作时所需的额外管理信息。

一个有用的物理类比是字典树的最终现实世界文件系统。假设有一栋大楼,它作为一个存储系统,存放着世界上每个话题的详细文件——这是一个高效归档系统的纪念碑。我们根据话题的第一个字母来划分这些话题,就像百科全书中的书籍一样,所以我们的楼宇有 26 层。我们为每个字母保留一层楼,这些楼层提供了我们的第一层划分。然后,每层楼有 26 个房间,每个房间代表话题的第二个字母。每个房间里有 26 个文件柜,按第三个字母来划分;每个文件柜有 26 个抽屉(第四个字母),每个抽屉有 26 个隔层(第五个字母),以此类推。在每一层,我们根据共同的前缀将条目分组。只要我们有高速电梯,我们就可以相对轻松地找到任何话题。

查找字典树

查找字典树与查找二叉搜索树类似,都是从树的顶部,即根节点开始,逐步向下,选择通向搜索目标的分支。然而,在字典树的情况下,我们选择与字符串中下一个字母对应的分支。我们不需要比较完整的字符串,甚至不需要比较前缀的开始部分。这些已经在之前的节点中完成了。我们只需考虑下一个字符——在每一层进行一次简单的比较。

回到文件楼宇的类比,假设你正在搜索关于你最喜欢的作者的信息。到达 K 楼后,你面前有 26 个标有 A 到 Z 的房间,它们代表着前缀 KA 到 KZ。你接下来的步骤只取决于作者名字的第二个字母。你不需要浪费时间再次考虑第一个字母——那已经在电梯里完成了。这个楼层的每个房间都以 K 开头。你自信地朝着标有 U 的房间走去。

实现这种方法的一个复杂之处在于我们在每一层的比较方式都会发生变化。在第一层,我们检查第一个字符是否匹配——但在第二层,我们需要检查第二个字符。我们的搜索不再将整个目标与节点的值进行比较。我们需要额外的信息,即在这一层我们正在检查的字符的位置。我们可以通过将要检查的索引传递给递归搜索函数,并在每一层递归时递增它,来跟踪这一额外的状态。

字典树封装器允许我们隐藏根节点的引用和递归函数所需的初始计数器,从而简化字典树用户看到的代码:

TrieSearch(Trie: trie, String: target):
    return TrieNodeSearch(tr.root, target, 0)

这个封装器确保后续的搜索函数在调用时传递一个非空节点和正确的初始索引。

递归查找字典树的代码比查找二叉搜索树的代码要复杂一些,因为我们必须处理不同长度的目标值:

TrieNodeSearch(TrieNode: current, String: target, Integer: index):
  ❶ IF index == length(target):
        IF current.is_entry:
            return current
        ELSE:
            return null

  ❷ Character: next_letter = target[index]
  ❸ Integer: next_index = LetterToIndex(next_letter)
    TrieNode: next_child = current.children[next_index]
  ❹ IF next_child == null:
        return null
    ELSE:
        return TrieNodeSearch(next_child, target, index+1)

这段代码首先通过检查目标字符串的长度与当前深度进行比较,以确定目标是否应位于此层级 ❶。如果索引等于字符串的长度(即超出了字符串的最后一个字符),代码会检查当前节点是否本身是一个有效条目。当搜索在一个内部节点终止时,这一检查尤为必要,因为我们需要确认该节点是否表示一个有效条目,而不仅仅是另一个条目的前缀。

如果代码尚未到达目标字符串的末尾,它将继续通过检查目标中的下一个字符来进行搜索 ❷。我们可以定义一个辅助函数,将字符映射到数组中的正确索引 ❸。然后,代码检查是否存在相应的子节点 ❹。如果没有相应的子节点,代码返回 null,确定 target 不在字典树中。如果存在相应的子节点,代码则沿着该分支继续。

以最近一集我们最喜欢的周六早晨卡通节目中的感叹词 YIKES 和 ZOUNDS 为例,如图 6-6 所示,考虑一下这种搜索过程。我们可以记录辅助数据,例如该词的频率以及谁说的,这样我们就能在聚会中纠正他人的引用。毕竟,如果数据结构不能帮助我们在啰嗦的争论中获胜,那它们还有什么用呢?

一个字典树,包含字符串 EGADS、YIKES、YIP、YIPPEE、ZONK 和 ZOUNDS。

图 6-6:由卡通短语构建的字典树

为了检查本周的剧集是否包含我们最喜欢的卡通词汇 ZONK,我们可以直接搜索字典树。从字典树的顶部开始,按每个字符选择相应的分支,如图 6-7 所示。

图 6-6 中的字典树,突出显示以下节点:根节点、Z、ZO、ZON 和 ZONK。突出显示的节点显示了从根节点到包含 ZONK 的节点的路径。

图 6-7:对卡通短语字典树进行 ZONK 搜索的过程。阴影节点表示搜索过程中经过的路径。

由于字典树仅包含有数据的节点,我们可以通过观察死胡同来确定某个字符串是否不在字典树中。例如,我们知道 ZIPPY 并没有出现在剧集中,因为我们在前缀 Z 后遇到了死胡同。前缀 ZI 没有分支。如果某个自以为是的专家声称他们最喜欢的台词中包含感叹词 ZIPPY,我们可以通过简单的搜索证明他们是错的。

乍一看,似乎添加大量的内部节点增加了搜索的成本。然而,这种新结构实际上大大提高了搜索效率。在我们目标字符串的每个字符处,我们只需在当前节点进行一次查找,检查该字符是否有现有的子节点,然后继续访问合适的子节点;因此,查找和比较的次数与目标字符串的长度成正比。与二叉搜索树不同,成功的 trie 搜索比较次数与 trie 中存储的字符串数量无关。即使我们将整个词典填充进 trie,我们仍然只需要访问六个节点来检查字符串 EGADS。

当然,和计算机科学中的一切一样,这种效率并非无代价。我们在内存使用上付出了显著的代价。我们不再为每个字符串存储一个节点和指向两个子节点的指针,而是为字符串中的每个字符存储一个节点,并存储大量指向潜在子节点的指针。重叠的前缀有助于减少每个字符串的内存开销。如果多个条目共享相同的前缀,例如在图 6-6 中,ZOUNDS 和 ZONK 共享前缀 ZO,那么这些条目会共享这些初始重叠字符的节点。

添加和删除节点

像二叉搜索树一样,trie 是动态数据结构,随着节点的添加或删除会自动调整,能够准确表示数据的变化。向 trie 中添加字符串的过程与向二叉搜索树添加数据类似。我们沿着树向下遍历,就像是在搜索字符串一样。一旦遇到死胡同,我们就可以在该节点下创建一个子树来存储该字符串剩余的字符。与二叉搜索树的插入不同,在插入单个条目时,我们可能会添加多个新的内部节点。

顶层的Trie函数通过调用递归搜索函数,并传入(非空的)根节点和正确的初始深度来设置插入操作:

TrieInsert(Trie: tr, String: new_value):
    TrieNodeInsert(tr.root, new_value, 0)

我们不需要将根节点的创建视为特殊情况,因为在创建 trie 时,我们会分配一个初始的根节点。

插入的代码与搜索函数类似:

TrieNodeInsert(TrieNode: current, String: new_value, Integer: index):
  ❶ IF index == length(new_value):
        current.is_entry = True
    ELSE:
        Character: next_letter = new_value[index]
        Integer: next_index = LetterToIndex(next_letter)
        TrieNode: next_child = current.children[next_index]
      ❷ IF next_child == null:
            current.children[next_index] = TrieNode()
          ❸ TrieNodeInsert(current.children[next_index], 
                           new_value, index + 1)
        ELSE:
          ❹ TrieNodeInsert(next_child, new_value, index + 1)

这段代码首先通过检查当前的位置与插入字符串的长度进行比较❶。当到达字符串末尾时,它会将当前节点标记为有效条目。根据使用场景,代码可能还需要更新节点的辅助数据。若代码还未到达字符串末尾,它会查找下一个字符并检查是否存在相应的子节点❷。如果不存在,代码会创建一个新的子节点。然后,它会递归调用TrieNodeInsert来处理正确的子节点(❸或❹)。

例如,如果我们想将字符串 EEK 添加到我们的卡通感叹词列表中,我们将添加两个节点:一个用于前缀 EE 的内部节点和一个用于完整字符串 EEK 的叶节点。图 6-8 演示了这个添加过程,带阴影的节点表示在插入过程中创建的 trie 节点。

删除节点遵循类似的过程,但顺序相反:从最后一个字符的节点开始,我们向上遍历树,删除不再需要的节点。我们停止删除节点,一旦遇到一个内部节点,该节点要么至少有一个非空子分支,从而表示 trie 中其他字符串的有效前缀,要么本身就是树中存储的字符串,表示一个有效的叶节点。

与搜索和插入类似,我们从包装器代码开始,在根节点处启动删除操作,并使用正确的索引:

TrieDelete(Trie: tr, String: target):
    TrieNodeDelete(tr.root, target, 0)

该函数不返回任何值。

删除节点的代码基于搜索和插入的代码,最初会沿着树向下遍历,直到找到要删除的条目。当它返回到 trie 时,附加的逻辑会修剪空的分支。代码返回一个布尔值,指示当前节点是否可以安全删除,从而允许父节点修剪该分支。

图 6-6 中的 trie,已添加节点来存储字符串 EE 和 EEK。新增节点位于 E 前缀节点下的分支中。

图 6-8:将 EEK 添加到卡通短语的 trie 中。新增的节点已加阴影。

TrieNodeDelete(TrieNode: current, String: target, Integer: index):
  ❶ IF index == length(target):
        IF current.is_entry:
            current.is_entry = False
    ELSE:
      ❷ Character: next_letter = target[index]
        Integer: next_index = LetterToIndex(next_letter)
        TrieNode: next_child = current.children[next_index]
        IF next_child != null:
          ❸ IF TrieNodeDelete(next_child, target, index+1):
               current.children[next_index] = null

    # Do not delete this node if it has either an entry or a child.
  ❹ IF current.is_entry:
        return False
  ❺ FOR EACH ptr IN current.children:
        IF ptr != null:
            return False
    return True

这段代码首先通过比较删除字符串的长度与当前层级,若当前节点正在被删除,则更改 is_entry 的值 ❶。否则,算法会递归地沿树向下遍历,使用与我们搜索和插入函数相同的逻辑 ❷。它查找下一个字符,找到相应的节点,检查节点是否存在,如果存在,则递归下降到该节点。如果节点不存在,则目标字符串不在 trie 中,代码将停止向下继续。然后,代码从父节点删除空的分支。每次调用 TrieNodeDelete 都会返回一个布尔值,指示是否可以安全删除对应的节点。如果 TrieNodeDelete 返回 True,则父节点会立即删除该子节点 ❸。

函数的最后通过逻辑判断父节点是否可以删除当前节点。如果is_entry == True ❹,即表示是有效条目,或者当前节点至少有一个非空子节点 ❺,则返回False。它通过一个FOR循环来遍历每个子节点并检查其是否为null。如果有任何子节点不是null,代码会立即返回False,因为它是一个必要的内部节点。请注意,代码会在目标字符串不在 trie 中时返回False,因为代码从未将is_entry设置为False,因此不会出现新的修剪机会。

考虑从我们的示例字典树中删除字符串 YIPPEE。如果字典树中也包含单词 YIP 作为条目,我们将删除从 YIP 开始的所有后续节点,如图 6-9 所示。YIPPEE 节点本身被标记为安全删除,因为它是一个叶节点,并且is_entry已被标记为False,作为删除过程的一部分。当函数返回到 YIPPE 节点时,它立即删除其唯一子节点(分支 E)。此时,YIPPE 节点变成一个叶节点,is_entry == False,可以由其父节点删除。该过程会继续向上遍历树,直到我们到达 YIP 节点,它的is_entry == True,因为字符串 YIP 存在于字典树中。

由于删除操作需要从根节点到单个叶节点的往返,成本再次与目标字符串的长度成正比。与搜索和插入操作一样,删除操作的成本与字典树中存储的字符串总数无关。

图 6-8 中的字典树,标有 YIPP、YIPPE 和 YIPPEE 节点,用虚线和灰色标出,表示它们已被删除。

图 6-9:从包含字符串 YIP 的卡通短语的字典树中删除字符串 YIPPEE。被删除的节点用虚线标出,并且灰色显示。

为什么这很重要

我们现在可以看到字典树如何解决我们之前提出的二叉搜索树问题:它们的搜索成本既取决于单词的数量,也取决于单词的长度。在本章的简短示例中,字典树并没有提供太大的优势。实际上,额外的分支开销可能使它们比二叉搜索树或排序列表效率更低。然而,当我们添加越来越多的字符串,并且具有相似前缀的字符串数量增加时,字典树变得越来越具有成本效益。这有两个原因:首先,字典树中的查找成本与条目的数量无关;其次,字符串比较本身可能很昂贵。在二叉搜索树中,这两个因素相互叠加,因为我们在每个节点都需要比较两个字符串,从而增加了成本。

在实际应用中,例如,我们可能会在文字处理器中使用字典树来跟踪字典中的单词。每个节点的辅助数据可能包括定义或常见拼写错误。当用户输入和编辑时,程序可以高效地检查每个单词是否在字典中,如果不在,则高亮显示。该程序极大地受益于自然语言中共享的前缀和有限的字符数量。

例如,如果我们正在撰写一篇关于百科全书历史的深入文章,我们不希望在比较encyclopedia与相邻的单词encyclopediasencyclopedicencyclopedist时付出过高的成本。记住,正如图 6-10 所示,比较两个字符串的字母顺序的算法是通过遍历字符串,逐个比较字符。尽管程序一旦找到不同的字符就会停止,但比较相似前缀的成本是累积的。在一个活跃的文字处理文档中,我们可能需要不断修改词汇集。每次插入或编辑都需要在我们的数据结构中进行查找。

比较单词“encyclopedic”和“encyclopedias”的字符串。前 11 个字符被标记为相同。

图 6-10:一个昂贵的字符串比较示例

一个更好的应用场景可能是用来跟踪结构化标签的数据结构——例如序列号、型号或 SKU 码——这些通常格式化为简短的字母数字字符串。例如,我们可以创建一个简单的 Trie 来存储按序列号索引的产品注册信息。即使销售了数十亿个产品,所有操作的成本也与序列号的长度成线性关系。如果我们的序列号包含结构,比如表示设备型号的前缀,我们可以通过限制初始节点的分支因子来进一步节省开销(因为许多字符串会使用相同的前缀)。辅助数据可能包括设备购买的时间或地点信息。

比任何具体应用更重要的是,Trie 展示了如何在数据中使用更多结构来优化操作成本。我们将二叉搜索树的分支结构改编为使用字符串的顺序特性。这一改进再次体现了本书的一个核心主题:我们可以通过利用数据中固有的结构来提高算法效率。

第七章:优先队列与堆

优先队列是一类根据每个项的给定分数来检索排序项的数据结构。而在第四章中,栈和队列仅依赖于数据插入的顺序,优先队列则使用额外的信息来确定检索顺序——项的优先级。正如我们将看到的,这一新信息使我们能够进一步适应数据,并且在许多有用的应用中,比如优先处理紧急请求,发挥重要作用。

举个例子,假设你所在的社区开了一家新的咖啡店——动态选择咖啡店。兴奋不已的你走进店里,看到有 10 款你从未尝试过的咖啡豆。在投入尝试之前,你花了一个小时仔细研究每个新品牌的相对优缺点,根据它们那份极其简陋的菜单描述,列出了一份排名咖啡清单。你从榜单顶部选择了最有前景的品牌,购买了它,并带回家享受这次体验。

第二天,你又回到动态选择咖啡店,准备尝试你列表中的第二款咖啡,却发现他们在菜单上又新增了两款咖啡。当你问咖啡师为什么做出这个改变时,他们指向店里的招牌,解释说动态选择咖啡店提供的是一个不断扩展的咖啡选择,目标是最终提供超过一千种咖啡。你既兴奋又害怕,每天你都需要优先排序这些新咖啡,并将它们插入到你的列表中,这样你就知道下一次该尝试哪款咖啡。

从优先列表中检索项的任务在计算机程序中经常出现:给定一个项列表及其相关的优先级,如何高效地按优先顺序检索下一个项?通常,我们需要在一个动态的环境中进行检索,因为新的项不断加入。我们可能需要根据优先级选择处理哪个网络数据包,基于常见拼写错误提供拼写检查的最佳建议,或者在最佳优先搜索中选择下一个选项。在现实生活中,我们可能会使用自己的心理优先队列来决定下一个要执行的紧急任务、观看哪部电影,或者在拥挤的急诊室中首先看哪位病人。一旦你开始留意,优先检索随处可见。

在本章中,我们介绍了优先队列,这是一类用于从集合中检索优先项的数据结构,然后讨论了实现这一有用工具的最常见数据结构:堆。堆使得优先队列的核心操作变得非常高效。

优先队列

优先级队列存储一组项目,并使用户能够轻松检索具有最高优先级的项目。它们是动态的,允许插入和检索操作交替进行。我们需要能够从我们的优先任务列表中添加和删除项目。如果我们被迫使用固定的数据结构,作者可能会花一整天查看他那静态的列表,并一遍又一遍地执行“去拿早晨的咖啡”这一最高优先级任务。如果在完成任务后无法将其移除,它将一直留在作者的列表顶部。虽然这可能让人享受一天的时光,但不太可能是高效的一天。

在最基本的形式下,优先级队列支持几个主要操作:

  • 添加一个项目及其相关的优先级分数。

  • 查找具有最高优先级的项目(如果队列为空,则为 null)。

  • 移除具有最高优先级的项目(如果队列为空,则为 null)。

我们还可以添加其他有用的功能,允许我们检查优先级队列是否为空,或者返回当前存储的项目数量。

我们根据当前的问题设定项目的优先级。在某些情况下,优先级值可能很明显,或者由算法决定。例如,在处理网络请求时,每个数据包可能带有显式的优先级,或者我们可能选择首先处理最旧的请求。然而,决定优先处理什么并不总是一个简单的任务。当我们优先考虑尝试哪种品牌的咖啡时,我们可能想根据价格、可用性或咖啡因含量来创建优先级——这取决于我们打算如何使用我们的优先级队列。

使用像排序链表或排序数组这样的原始数据结构实现优先级队列是可能的,但并不理想,我们需要根据优先级将新项目添加到列表中。图 7-1 展示了将值 21 添加到排序链表中的示例。

一个链表,在向列表中间添加第八个元素后,列表的值按降序排列。列表的头部值为 50,列表的尾部值为 9。值 21 插入在值 28 和值 15 之间。

图 7-1:将元素(21)添加到表示优先级队列的排序链表中

排序链表将最高优先级的项目保持在列表的前端,以便于查找。实际上,在这种情况下,查找只需要常数时间,无论优先级队列的长度如何——我们只需查看第一个元素。不幸的是,添加新元素可能是昂贵的。每次我们添加一个新项目时,可能需要遍历整个列表,这会花费与优先级队列长度成正比的时间。

作者使用这种排序列表的方法来整理冰箱,将物品按过期日期从前到后存储。最靠近的物品始终具有最高的优先级,即将要最先过期的物品。取出正确的物品非常简单——只需拿出最前面的那个。这种方案对于存储牛奶或咖啡用的奶油特别有利:没有人愿意在早晨昏昏欲睡时查看过期日期。然而,将新物品放在旧物品后面可能需要时间,并且需要烦人的移动。

类似地,我们可以在 未排序 的链表或数组中维护优先队列。新的元素添加非常简单——只需将元素标记到列表的末尾,如图 7-2 所示。

未排序数组在添加第八个元素之前和之后的状态。值 21 被添加到值 39 后面,成为未排序数组的末尾。

图 7-2:将元素(21)添加到表示优先队列的未排序数组中

不幸的是,我们现在在查找下一个元素时需要付出很高的代价。我们必须扫描整个列表,以确定哪个元素具有最高的优先级。如果我们要删除该元素,还需要将其他元素移动,以填补空缺。这种方法就像作者在冰箱里扫描所有物品,以找到哪个最接近过期。这对于冰箱里只有几盒牛奶的情况或许有效,但想象一下在大型超市里逐一检查每个食物或饮料的成本。

将优先队列实现为排序列表可能比使用未排序列表更有效,反之亦然,这取决于我们打算如何使用优先队列。如果添加操作比查找操作更常见,我们会偏好使用未排序列表。如果查找操作更常见,我们则需要承受保持元素排序的成本。在冰箱的例子中,查找操作要常见得多——我们取出一盒牛奶使用的频率远高于购买新盒牛奶的频率,因此保持牛奶的排序是有益的。问题出现在当添加操作和查找操作都很常见时;优先处理一个操作会导致整体效率低下,因此我们需要一种平衡两者成本的方法。一个巧妙的数据结构——堆,可以帮助我们解决这个问题。

最大堆

最大堆 是二叉树的一种变体,它在节点和其子节点之间保持一种特殊的有序关系。具体来说,最大堆根据 最大堆性质 存储元素,该性质规定树中任何节点的值都大于或等于其子节点的值。为了简便起见,接下来的章节中我们将经常使用更一般的术语 堆性质 来指代最大堆和最大堆性质。

图 7-3 显示了一个按照最大堆性质组织的二叉树的表示。

堆的二叉树从根节点(99)开始,根节点有两个子节点 67 和 97。对于任何节点,左右子节点的值都小于或等于节点本身的值。

图 7-3:堆作为二叉树的表示

左右子节点之间没有特殊的优先级或顺序,除了它们的优先级低于父节点。为了做个比较,想象一个精英咖啡爱好者的导师计划——咖啡相关知识提升协会。每个成员(节点)同意指导最多两个其他咖啡爱好者(子节点)。唯一的条件是,每个受导师指导的人必须比导师了解的咖啡知识少——否则,导师计划将毫无意义。

计算机科学家 J. W. J. Williams 最初发明堆作为新排序算法堆排序的一部分,我们将在本章后面讨论。然而,他意识到堆作为数据结构在其他任务中也很有用。最大堆的简单结构使得它能够高效地支持优先队列所需的操作:(1)允许用户高效查找最大元素,(2)删除最大元素,以及(3)添加任意元素。

堆通常被可视化为树形结构,但为了提高效率,它们通常使用数组实现。在本章中,我们并行展示这两种表示方法,以帮助读者在它们之间建立心理联系。然而,使用数组进行实现并不是必须的。

在基于数组的实现中,数组中的每个元素对应树中的一个节点,根节点位于索引 1(我们跳过索引 0,以保持与堆的常见约定一致)。子节点的索引相对于其父节点的索引定义,因此位于索引i的节点的子节点位于索引 2i和 2i + 1。例如,索引为 2 的节点有两个子节点,分别位于索引 2 × 2 = 4 和索引 2 × 2 + 1 = 5,如图 7-4 所示。

堆同时以数组和树的形式表示,箭头指示每个节点在数组中的位置。根节点 98 对应数组中的第一个元素。节点的两个子节点,95 和 50,是数组中的第二个和第三个元素。

图 7-4:堆的位置对应于索引位置。

类似地,我们将一个节点的父节点的索引计算为Floor(i/2)。这种索引方案使得算法可以轻松地根据父节点的索引计算子节点的索引,反之亦然。

根节点始终对应最大堆中的最大值。由于我们将根节点存储在数组中的固定位置(索引 = 1),我们可以始终在常数时间内找到这个最大值。这只是一个数组查找。数据本身的布局因此解决了优先队列所需的一个操作。

由于我们将在优先队列中添加和移除任意元素,为了避免不断调整数组大小,我们希望预先分配一个足够大的数组来容纳我们预计要添加的元素数量。记住在第三章中提到的,动态调整数组大小可能非常昂贵,它迫使我们创建一个新的数组并复制数据,这样做会浪费堆的宝贵效率。相反,我们可以最初分配一个较大的数组,跟踪数组中最后一个填充元素的索引,并将该索引称为数组的虚拟末尾。这使得我们只需要通过更新最后一个元素的索引来添加新元素。

Heap {
    Array: array
    Integer: array_size
    Integer: last_index
}

当然,这种整体分配的代价是,如果我们的堆没有像预期那样增长,就可能会有一部分未使用的内存。

使用数组来表示基于树的数据结构本身就是一个有趣的步骤。我们可以使用打包数组和数学映射来表示堆,而不是依赖指针,这样可以减少堆的内存占用。通过维护节点索引与其子节点之间的映射关系,我们可以在不使用指针的情况下重建树形数据结构。正如我们下面将看到的那样,基于数组的表示对于堆来说是可行的,因为数据结构始终保持着几乎完整和平衡的树结构。这就产生了一个没有空隙的打包数组。虽然我们也可以使用相同的数组表示其他类型的树,如二叉搜索树,但这些数据结构通常会在整个结构中产生空隙,因此需要非常大的(并且可能大部分是空的)数组来存储深分支的树。

向堆中添加元素

当向堆中添加新元素时,我们必须确保结构保持堆的性质。就像你不会让一位装饰性的将军去报告给一位新任的中尉一样,你也不会把一个高优先级的堆节点放在低优先级的节点下方。我们必须将新元素添加到堆的树结构中,以确保所有新增元素下方的节点优先级都小于或等于新节点的优先级。同样,所有新节点上方的节点应当具有大于或等于新节点优先级的优先级。

堆的数组实现的亮点之一是,它在以打包数组存储节点的同时保持了这个特性。在之前的章节中,将节点添加到数组的中间是昂贵的,需要我们将后面的元素向后移动。幸运的是,每次向堆中添加新元素时,我们不需要付出这种线性代价。相反,我们通过首先破坏堆的性质,再沿着树的单一路径交换元素来恢复堆的性质。

换句话说,向堆中添加一个新元素时,我们将它添加到树底层的第一个空位置。如果这个新值大于其父节点的值,我们就将其上浮,直到它小于或等于其父节点,从而恢复堆的性质。堆本身的结构使我们能够高效地完成这个操作。在堆的数组实现中,这对应于将新元素添加到数组的末尾,并将其向前交换。

考虑 图 7-5 中的示例,该图展示了堆的结构,分别以数组和树的形式展示了每个步骤。 图 7-5(a) 显示了新元素添加之前的堆结构。在 图 7-5(b) 中,我们将新元素 85 添加到数组的末尾,相当于将其插入到树的底部。在 图 7-5(c) 中的第一次比较之后,由于 85 大于 50,我们交换了新元素与其父节点的位置。交换操作如 图 7-5(d) 所示。第二次比较发生在 图 7-5(e) 中,此时新节点已处于正确的位置:98 大于 85,因此不需要再与父节点交换。 图 7-5(f) 显示了添加操作完成后的堆结构。

我们从图 7-4 中的数组开始。节点 85 被添加到数组的末尾,然后上浮到正确的位置。我们首先将 85 与它的父节点 50 进行比较,并交换它们。在下一步中,我们将 85 与它的新父节点 98 进行比较,并保持顺序。

图 7-5:向堆中添加一个元素(85)

实现此添加操作的代码使用了一个单一的WHILE循环,逐层向上遍历堆,直到到达根节点或找到一个父节点的值大于或等于新节点的值:

HeapInsert(Heap: heap, Type: value):
  ❶ IF heap.last_index == heap.array_size - 1:
        Increase Heap size.

 ❷ heap.last_index = heap.last_index + 1
    heap.array[heap.last_index] = value

    # Swap the new node up the heap.
  ❸ Integer: current = heap.last_index
    Integer: parent = Floor(current / 2)
  ❹ WHILE parent >= 1 AND (heap.array[parent] < 
                           heap.array[current]):
      ❺ Type: temp = heap.array[parent]
        heap.array[parent] = heap.array[current]
        heap.array[current] = temp
        current = parent
        parent = Floor(current / 2)

由于我们使用数组来存储堆,代码首先会检查数组中是否还有空间来添加新元素 ❶。如果没有空间,它会增加堆的大小,可能通过应用第三章中描述的数组扩展技术。接下来,代码将新元素添加到数组的末尾,并更新最后一个元素的位置 ❷。WHILE 循环从新添加的元素 ❸ 开始,逐层向上遍历堆,通过将当前值与其父节点的值进行比较 ❹,并在必要时交换 ❺。当我们到达堆的顶部(parent == 0)或找到一个大于或等于子节点的父节点时,循环终止。

我们可以将这一过程比作一个奇特设计但高效的包裹分配中心,如图 7-6 所示。员工们将包裹按照堆的属性整齐地排列在地板上:最前排是一个具有最高优先级的包裹,即下一个要发货的包裹。在它后面坐着两个优先级较低的包裹。在这两个包裹后面分别是两个更多的包裹(这一排总共四个),每一对包裹的优先级都小于或等于其前面的包裹。每增加一排,包裹的数量就翻倍,因此,随着你向仓库后部移动,排列越来越宽广。每个包裹最多有两个优先级较低或相等的包裹坐在它的后面,最多有一个优先级较高或相等的包裹在它前面。仓库地板上的矩形标记帮助指示每一排中包裹的可能位置。

一张图显示了仓库地板上的一排排包裹,第一排一个包裹,第二排两个,第三排四个,第四排八个。第五排是最后一排,但没有完全填满。

图 7-6:一个组织成堆的仓库地板

新的包裹从仓库后部运入。每个送货员都会嘀咕一些关于奇怪排序方案的话,松了一口气,因为至少他们不用将包裹搬到最前面,便将包裹放在最前面可用的位置,然后尽可能快地离开。随后,仓库员工开始行动,将新包裹与前面紧挨着的包裹进行优先级比较(忽略这一排中的其他包裹)。如果他们发现优先级倒置,就交换这两个包裹。否则,包裹就保持原位。这一过程持续进行,直到新包裹占据了正确的层级位置,而其前方的包裹具有更高或相等的优先级。由于包裹又重又分散,员工们通过每排最多进行一次比较和交换来最小化工作量。他们绝不会在一排内重新排列包裹。毕竟,没人愿意不必要地搬运箱子。

直观地,我们可以看到堆的新增操作并不非常昂贵。在最坏的情况下,我们可能需要将新节点交换到树的根部,但这只意味着交换数组中一小部分的值。根据设计,堆是平衡的二叉树:我们在插入下一个节点之前,会先填满树的每一层。由于满二叉树中每一层的节点数都是上一层的两倍,因此,新增操作在最坏情况下需要 log2 次交换。这比维护一个排序列表所需的最坏情况的 N 次交换要好得多。

从堆中移除最高优先级的元素

从优先队列中查找并移除最高优先级元素是一个核心操作,它允许我们按优先级顺序处理项目。也许我们正在存储一组待处理的网络请求,想要处理其中优先级最高的一个。或者我们可能在急诊室工作,想查看最紧急的病人。在这两种情况下,我们都希望从优先队列中移除该元素,以便提取下一个最高优先级的元素。

要移除最高优先级节点,我们必须首先打破,然后恢复堆的性质。请参阅图 7-7 中的示例。我们首先将最高优先级节点与树的最低层最后一个节点交换位置(见图 7-7(b)),有效地将最后一个元素作为新的根节点。然而,这种对新根节点的“史诗般的提升”几乎可以保证无法经得起检验。在数组实现中,这对应于交换数组中的第一个和最后一个元素。这次交换填补了通过移除第一个元素而在数组前部创建的空缺,从而保持了数组的紧凑结构。

接下来,原始的最大值 98(当前是树中的最后一个元素)被删除,如图 7-7(c)所示。我们现在已删除正确的节点,但在此过程中可能已经破坏了堆的性质。我们可能将一个低优先级的包裹移到了仓库的前端。

节点 98 通过先与最后一个元素(23)交换位置被从堆中移除。新的根节点通过与其两个子节点中较大的一个交换位置,向下冒泡。第一步中,节点 23 与 95 和 50 进行比较,然后与 95 交换位置。

图 7-7:从堆中移除最高优先级元素

为了修复堆的性质,我们从新的(不正确的)根节点 23 开始,沿着树向下走,恢复每一层的堆性质。被错误放置的包裹会逐层向仓库后端移动,与新包裹逐层向前移动的过程相反。诚然,这个遍历过程没有像将紧急包裹移到队列前端那么令人激动,但这是为了堆结构的健康。在每一层,我们将正在移动的包裹的优先级与其两个子节点(即下一层的两个包裹)进行比较(见图 7-7(d))。如果它的优先级小于其中任何一个子节点的优先级,我们就将新的根节点向后移动,通过与两个子节点中较大的一个交换位置来恢复堆的性质(见图 7-7(e))。这代表着包裹的高优先级继任者向前移动,取而代之。

向下交换操作会在没有更大的子节点时终止。图 7-7(f)展示了当当前节点处于正确位置时的比较结果。堆的性质已经恢复,所有节点都对其相对位置感到满意。图 7-7(g)展示了删除操作完成后最终的堆。

在固定根节点的位置时,我们沿着树的单一路径向下遍历,只检查并恢复我们进行交换的后代分支的堆属性。无需检查其他分支。因为我们没有做任何事情破坏堆属性,所以其他分支已经保证保持堆属性。这意味着,在最坏的情况下,我们需要进行 log2 次交换。

下面是删除最大元素的代码:

HeapRemoveMax(Heap: heap):
  ❶ IF heap.last_index == 0:
        return null

    # Swap out the root for the last element and shrink heap.  
  ❷ Type: result = heap.array[1]
    heap.array[1] = heap.array[heap.last_index]
    heap.array[heap.last_index] = null
    heap.last_index = heap.last_index - 1

    # Bubble the new root down.
    Integer: i = 1
  ❸ WHILE i <= heap.last_index:
        Integer: swap = i
      ❹ IF 2*i <= heap.last_index AND (heap.array[swap] < 
                                       heap.array[2*i]):
            swap = 2*i
      ❺ IF 2*i+1 <= heap.last_index AND (heap.array[swap] <
                                         heap.array[2*i+1]):
            swap = 2*i+1

      ❻ IF i != swap:
            Type: temp = heap.array[i]
            heap.array[i] = heap.array[swap]
            heap.array[swap] = temp
            i = swap
        ELSE:
            break
    return result

这段代码首先检查堆是否为空❶。如果堆为空,则什么也不返回。然后,代码将第一个元素(index == 1)与最后一个元素(index == heap.last_index)交换,破坏堆的属性,为移除最大元素做准备❷。接着,代码使用WHILE循环通过一系列比较遍历堆,修复堆的属性❸。在每次迭代中,它将当前值与两个子节点进行比较,并在必要时与较大的子节点交换❻。我们必须添加额外的检查❹❺,确保代码只将当前值与现有的子节点进行比较。我们不希望它尝试与数组最后一个有效索引之外的元素进行比较。循环在到达堆底部或没有交换的迭代后终止(通过break语句)。

存储辅助信息

很多时候,我们需要堆为每个条目存储额外的信息。例如,在我们的任务列表中,我们不仅需要存储任务的优先级,还需要存储待办任务的其他信息。如果我们不知道该任务是什么,那么仅仅知道下一个任务的优先级是 99 是没有意义的。我们不如直接手动扫描原始列表。

扩展堆以存储复合数据结构或对象(如TaskRecord)很简单:

TaskRecord {
    Float: Priority
    String: TaskName
    String: Instructions
    String: PersonWhoWillYellIfThisIsNotDone
    Boolean: Completed
}

我们通过修改之前的代码来处理基于这个复合记录的优先级字段的比较。我们可以通过直接修改代码来实现(例如在HeapInsert函数中):

 WHILE parent >= 1 AND (heap.array[parent].priority < 
                             heap.array[current].priority):

然而,这需要我们将堆实现专门化为特定的复合数据结构。更干净的方法是添加一个特定于复合数据结构的辅助函数,例如:

IsLessThan(Type: a, Type: b):
  return a.priority < b.priority

我们将在HeapInsert函数的代码中使用这个函数,代替数学中的小于符号:

 WHILE parent >= 1 AND IsLessThan(heap.array[parent], 
                                     heap.array[current]):

类似地,我们会修改HeapRemoveMax函数中的比较操作,使用辅助函数。

 IF 2*i <= heap.last_index AND IsLessThan(heap.array[swap],
                                                 heap.array[2*i]):
            swap = 2*i
        IF 2*i+1 <= heap.last_index AND IsLessThan(heap.array[swap],
                                                   heap.array[2*i+1]):
            swap = 2*i+1

这些小的改动使我们能够从复合数据结构构建堆。只要我们能定义一个IsLessThan函数来排序元素,我们就可以为它们构建一个高效的优先队列。

更新优先级

有些使用场景可能需要另一种动态行为模式:允许算法更新优先队列中元素的优先级。考虑一个书店数据库,根据每本书的请求人数来确定哪些书籍应该补货。系统首先根据初始的书单构建堆,并用它来确定接下来应该订购哪本书。但在一篇热门博客文章指出数据结构在计算思维中的重要性后,书店突然迎来了急剧—虽然完全可以理解—的增长,更多顾客请求有关数据结构的书籍。它的优先队列必须能够处理这种突如其来的流量。

为了满足这个需求,我们使用了与添加和删除相同的方法。当我们改变一项的值时,我们会检查是提高还是降低优先级。如果我们提高了该项的值,我们需要将该项“冒泡”到最大堆中,以恢复堆的性质。类似地,如果我们降低了该项的值,我们让它下沉到最大堆中,直到到达正确的位置。

UpdateValue(Heap: heap, Integer: index, Float: value):
    Type: old_value = heap.array[index]
    heap.array[index] = value

    IF old_value < value:
        Bubble the element up the heap using the
        procedure from inserting new elements 
        (swapping with parent).
    ELSE:
        Drop the element down the heap using the
        procedure from removing the max element 
        (swapping with the larger child).

我们甚至可以将让元素“冒泡”或“下沉”的代码提取出来,这样相同的代码可以用于更新、添加以及删除最大值操作。

我们如何找到我们想要更新的元素呢?如前所述,堆并不适合查找特定的元素。如果我们对目标元素只有它的值而没有其他信息,我们可能需要遍历数组的大部分内容来找到它。通常,我们可以通过使用二级数据结构来解决这个问题,例如哈希表(第十章讨论过),将项的键映射到堆中的元素。在本节的示例中,我们假设程序已经有了该项的当前索引。

最小堆

到目前为止,我们专注于最大堆,它利用树中任何节点的值都大于(或等于)其子节点的值的性质。最小堆是堆的一种版本,它有助于找到值最小的元素。对于最小堆,树的根节点是最小值,从而使我们可以轻松找到得分最低的元素。例如,我们可能不仅仅根据优先级来排序网络数据包,而是根据到达时间来排序,先处理到达时间较早的包,而不是最近到达的包。更重要的是,如果我们的咖啡架空间用完了,我们需要移除最不喜欢的品牌。经过一番令人心痛的内心挣扎,我们决定放弃一架架子的盘子或碗,而不是舍弃我们珍贵的咖啡粉,最终我们决定丢掉得分最低的咖啡。我们查阅了每种咖啡的享受度评分,并选择了得分最低的那一款。

理论上,我们可以通过仅仅将值取反,继续使用最大堆。然而,更简洁的策略是对我们的堆性质进行小小的调整,直接解决问题。最小堆性质是树中任何节点的值都小于(或等于)其子节点的值。一个最小堆的例子见图 7-8。当我们插入新元素时,值最小的元素会上浮到树的上层。同样地,我们始终会提取并返回值最小的元素。

一个最小堆的表示,既以数组的形式也以树的形式呈现,箭头表示每个节点在数组中的位置。根节点 10 对应数组中的第一个元素,节点的两个子节点 23 和 17 分别是数组中的第二和第三个元素。

图 7-8:最小堆的位置对应索引位置。

当然,最小堆中添加和删除元素的算法需要相应地进行修改。对于插入操作,我们需要在WHILE循环中改变比较函数,以检查父节点的值是否大于当前节点的值:

MinHeapInsert(MinHeap: heap, Type: value):
    IF heap.last_index == heap.array_size - 1:
 Increase Heap size.

    heap.last_index = heap.last_index + 1
    heap.array[heap.last_index] = value

    # Swap the new node up the heap.
    Integer: current = heap.last_index
    Integer: parent = Floor(current / 2)
  ❶ WHILE parent >= 1 AND (heap.array[parent] > 
                           heap.array[current]):
        Type: temp = heap.array[parent]
        heap.array[parent] = heap.array[current]
        heap.array[current] = temp
        current = parent
        parent = Floor(current / 2)

代码的绝大部分与插入最大堆的代码相同,唯一的变化是在比较节点与其父节点时将<替换为> ❶。

我们在删除操作中的两个比较步骤也做了类似的修改:

MinHeapRemoveMin(Heap: heap):
    IF heap.last_index == 0:
        return null

    # Swap out the root for the last element and shrink heap.  
    Type: result = heap.array[1]
    heap.array[1] = heap.array[heap.last_index]
    heap.array[heap.last_index] = null
    heap.last_index = heap.last_index - 1

    # Bubble the new root down.
    Integer: i = 1
    WHILE i <= heap.last_index:
        Integer: swap = i
      ❶ IF 2*i <= heap.last_index AND (heap.array[swap] > 
                                       heap.array[2*i]):
            swap = 2*i
      ❷ IF 2*i+1 <= heap.last_index AND (heap.array[swap] >
                                         heap.array[2*i+1]):
            swap = 2*i+1

        IF i != swap:
            Type: temp = heap.array[i]
            heap.array[i] = heap.array[swap]
            heap.array[swap] = temp
            i = swap
        ELSE:
            break
    return result

在这里,唯一的变化是,在决定是否交换节点时,将<替换为> ❶ ❷。

堆排序

堆是一种强大的数据结构,广泛应用于计算机科学的多种任务,不仅限于实现优先队列和高效返回优先级列表中的下一个元素。通过堆以及数据结构来看待问题的另一个有趣角度是它们所能启用的新算法。J. W. J. Williams 最初在提出一种新算法排序数组的背景下提出了堆:堆排序

顾名思义,堆排序是一种使用堆数据结构对一组元素进行排序的算法。输入是一个无序数组,输出是一个包含相同元素的数组,但按降序排序(对于最大堆)。堆排序的核心包含两个阶段:

  1. 从所有元素中构建最大堆

  2. 从堆中提取所有元素并按降序排列,存储在一个数组中

就这么简单。

这是堆排序的代码:

Heapsort(Array: unsorted):
    Integer: N = length(unsorted)
    Heap: tmp_heap = Heap of size N
    Array: result = array of size N      

    Integer: j = 0
  ❶ WHILE j < N:
        HeapInsert(tmp_heap, unsorted[j])
        j = j + 1

    j = 0
  ❷ WHILE j < N:
        result[j] = HeapRemoveMax(tmp_heap)
        j = j + 1
    return result

这段代码由两个WHILE循环组成。第一个循环将每个元素插入到一个临时堆中 ❶。第二个循环使用HeapRemoveMax函数移除最大元素,并将其添加到数组中的下一个位置 ❷。或者,我们也可以通过使用最小堆和HeapRemoveMin来实现堆排序,从而得到一个升序排列的结果。

假设我们想将以下数组 [46, 35, 9, 28, 61, 8, 38, 40] 按照递减顺序进行排序。我们首先将这些值逐一插入到堆中。图 7-9 显示了插入后的数组最终排列(以及其等效的树形表示)。请记住,我们总是从数组的末尾插入新元素,然后将它们交换到前面,直到恢复堆的性质。在 图 7-9 中,箭头表示新元素通过数组到达其最终位置的路径。树形表示也一并展示,其中阴影部分表示已被修改的节点。

这张插图展示了堆排序第一阶段中的每一个插入过程,既包括数组中的插入,也包括树中的插入。数字 46、35、9 和 28 都是在没有交换的情况下插入的。当 61 被插入到树的顶部和数组的前面时,它首先与 35 交换位置,然后与 46 交换,成为新的根节点。

图 7-9:堆排序的第一阶段,其中未排序的数组中的元素逐一添加到堆中

我们通过单次插入的最坏情况运行时间来限制构建堆的运行时间。正如我们在本章前面所看到的,最坏情况下,将一个新元素插入到一个包含 N 个元素的堆中,其时间复杂度是与 log2 成正比的。因此,要构建一个包含 N 个元素的堆,我们将最坏情况的运行时间限定为与 Nlog2 成正比。

现在我们已经构建好了堆,接下来进入第二阶段,按照 图 7-10 所示提取每个元素。

这张插图展示了堆排序第二阶段中的每一个移除过程,既包括数组中的移除,也包括树中的移除。在第一次移除时,节点 61 被移除,46 被交换到它的位置,40 被交换到 46 原来的位置,而 28 被提升到 40 原来的位置。

图 7-10:堆排序的第二阶段,在该阶段,最大元素会在每次迭代中从堆中移除

我们按照优先级递减的顺序逐一从堆中移除元素。这就产生了递减排序的结果。在每一步中,根节点被提取,堆中的最后一个元素交换到根节点的位置,然后新的根节点下沉到一个位置,恢复堆的性质。图中展示了每次迭代结束时数组(以及对应的树形表示)的状态。图中的箭头和阴影节点展示了过度提升的节点在堆中向下移动的过程。当我们提取元素时,它们会直接添加到存储结果的数组中。一旦堆被清空,我们就丢弃它,它已经完成了它的任务。

与插入操作类似,我们可以将最坏情况下的运行时间限制为与 Nlog2 成正比。每次删除操作最多需要 log2 的时间来恢复堆的性质,我们需要提取所有 N 个项目来形成排序列表。因此,堆排序算法的总最坏情况运行时间与 Nlog2 成正比。

为什么这很重要

堆是二叉树的一种简单变体,它允许一组不同的计算高效操作。通过将二叉搜索树的性质转换为堆的性质,我们可以改变数据结构的行为,并支持一组不同的操作。

新元素的添加和最大元素的删除都要求我们最多走一条从树顶到底部的路径。由于我们可以在不增加树底部的新层次的情况下,几乎将堆中节点数量翻倍,因此即使是大型堆也能保持高效的操作。以这种方式翻倍节点数量仅增加了插入和删除操作的一次额外迭代!此外,这两种操作都能确保树保持平衡,以便未来的操作仍然高效。

然而,总是存在权衡:通过从二叉搜索树性质转变为堆性质,我们不再能有效地搜索特定值。优化某一组操作通常会妨碍我们优化其他操作。我们需要仔细思考如何使用数据,并相应地设计其结构。

第八章:网格

在本章中,我们将探讨在考虑多维值和目标时会发生什么。到目前为止,我们所检查的数据结构都具有一个共同的约束——它们基于单一值组织数据。许多现实世界的问题涉及多个重要维度,我们需要扩展数据结构以处理这种数据的搜索。

本章首先介绍最近邻搜索,它将作为我们多维数据的激励用例。正如我们所看到的,最近邻搜索的通用性使得它非常灵活,适用于广泛的空间和非空间问题。它可以帮助我们找到离当前位置最近的咖啡,或是最适合我们口味的品牌。

我们接着介绍网格数据结构,并展示它如何通过使用数据中的空间关系修剪掉不可行的搜索空间区域,来促进二维的最近邻搜索。我们简要讨论如何将这些方法扩展到二维以上的情况。我们还将看到这些数据结构的不足之处,这为进一步的空间数据结构提供了动机。

引入最近邻搜索

顾名思义,最近邻搜索就是找到离给定搜索目标最近的特定数据点——例如,离我们当前位置最近的咖啡店。正式地,我们将最近邻搜索定义如下:

给定一组 N 个数据点 X = {x[1], x[2], … , x[N]},一个目标值 x’,以及一个距离函数 dist(x,y),找出数据集 X 中的点 x[i] ∈ X,使得 dist(x’,x[i]) 最小。

最近邻搜索与我们在第二章中用于激励二分查找的目标值搜索密切相关。这两种算法都在数据集中搜索特定的数据点。关键的区别在于成功标准。二分查找测试数据集中是否存在精确匹配,而最近邻搜索只关心找到最接近的匹配。

这种框架使得最近邻搜索在处理多维数据时非常有用。我们可能在地图上寻找附近的咖啡店,在历史气温列表中寻找与当前日期相似的天数,或在给定单词的“近似”拼写中进行搜索。只要我们能定义搜索目标与其他值之间的距离,就能找到最近邻。

在前面的章节中,我们主要考虑了那些作为单一数值的目标,例如存储在二叉搜索树和堆中的数据。虽然有时会包含辅助数据,但目标本身仍然保持简单。相比之下,最近邻搜索在处理多维数据时最为有趣,这些数据可能存储在各种其他数据结构中,如数组、元组或复合数据结构。在本章后续内容中,我们将讨论一些二维搜索问题及其目标。不过,现在让我们先介绍一个基本的搜索算法。

使用线性扫描的最近邻搜索

作为最近邻搜索的基准算法,我们从第二章中的线性扫描算法的修改版本开始。线性扫描算法并不特别引人注目;你可以通过在大多数编程语言中写一个简单的循环来实现它。然而,由于它的简洁性,线性扫描为我们提供了一个很好的起点,能够从中检视更复杂和高效的算法。

设想一个使用绝对距离进行最近邻搜索的问题:dist(x,y) = |xy|。给定一个数字列表和一个搜索目标,我们希望找到列表中最接近的数字。也许我们在一个陌生的城市醒来,早晨需要找第一杯咖啡。酒店的礼宾部提供了同一条街道上的咖啡店列表,并附带了一张有用的地图。由于我们对这些商店并不熟悉,因此决定优先选择便利,去酒店附近的咖啡店。

我们可以通过数轴来可视化这个搜索过程,如图 8-1 所示。这些点代表不同的咖啡店及其相对于地图起点的位置,而 X 则表示我们的酒店,位于街道上 2.2 英里处。

一个数轴,展示了七个候选邻居和一个目标点。目标点位于 2.2,最近的邻居位于 2.6。

图 8-1:一维最近邻搜索表示为数轴

在程序中,图 8-1 中的点可能表示数组中未排序的值。然而,在最近邻搜索的背景下,将这些值可视化为实数轴上的值有两个优势。首先,它阐明了距离的重要性:我们可以看到目标值与每个数据点之间的间隔。其次,它帮助我们将技术推广到多维空间,正如我们将在下一节看到的那样。

目前,线性扫描算法按顺序遍历每个数据点,如图 8-2 所示,计算当前数据点的距离,并与迄今为止找到的最小距离进行比较。这里我们考虑的是按排序顺序排列的点,因为它们已经沿着数轴排列,但线性扫描不要求特定的排序。它使用的是列表中数据的排列顺序。

线性扫描计算每个数据点到目标点的距离。一系列数字线展示了每一对点及其对应的距离。

图 8-2:一维最近邻搜索中数据点的线性扫描

在图 8-2 的第一次比较中,我们发现一个距离为 1.8 的点。这成为了我们迄今为止的最佳选择,我们的候选最近邻。它可能不是一个邻居——1.8 英里步行去喝早上的咖啡有点远——但它是我们看到的最好的。接下来的两步发现了分别位于 1.2 和 0.4 距离处的更好候选点。遗憾的是,剩下的四次比较并没有找到更好的候选点;距离 0.4 的点仍然是我们找到的最接近的点。最终,算法返回了数字线上的第三个点作为最近邻。我们信心满满地走向咖啡店,确信我们正朝着街上最近的一家咖啡店走去。

清单 8-1 展示了使用任意距离函数的线性扫描代码。我们使用浮动值进行一维情况,但可以通过使用复合数据结构或其他表示方法将其扩展到多维。

LinearScanClosestNeighbor(Array: A, Float: target, Function: dist):
    Integer: N = length(A)
  ❶ IF N == 0:
        return null

  ❷ Float: candidate = A[0]
    Float: closest_distance = dist(target, candidate)

    Integer: i = 1
  ❸ WHILE i < N:
        Float: current_distance = dist(target, A[i])
      ❹ IF current_distance < closest_distance:
            closest_distance = current_distance
            candidate = A[i]
        i = i + 1
  ❺ return candidate

清单 8-1:线性扫描最近邻算法的代码

代码首先检查数组是否为空,如果为空,则返回null ❶,因为没有最近的点。然后,代码选择数组中的第一个项作为初始候选最近邻,并计算该点到目标的距离 ❷。这些信息为我们的搜索提供了起点:我们将所有后续点与目前为止的最佳候选点和距离进行比较。剩余的代码使用WHILE循环遍历数组中的其余元素 ❸,计算到目标的距离,并将其与目前找到的最佳距离进行比较。每当找到一个更接近的候选点时,代码都会更新最佳候选点和最佳距离 ❹,然后返回最近的邻居 ❺。

除了提供最近邻搜索的简单实现外,线性扫描算法还轻松支持不同的距离函数,甚至是高维数据点。首先,让我们来看一些在二维空间中的示例问题。

搜索空间数据

想象一下,您已经开车好几个小时,正在进行一场跨国公路旅行,急需加一次咖啡。突然间,当您意识到自己没有规划沿途的最佳咖啡店时,慌乱涌上心头。您深吸一口气,拿出图 8-3 中显示的地图,找到了几个已知有咖啡店的城镇。优先考虑快捷而非质量,您下定决心要找到最近的咖啡馆。

一个二维的城镇地图。目标点位于地图中左中部,靠近 Gridville、Cartesian 和 Fort Fortran 城镇。

图 8-3:作为二维数据示例的地图

数据由二维点组成——带有xy坐标的城镇。这些数据点可以存储为有序元组(xy)、一个小的固定大小数组[xy],甚至可以使用一个复合数据结构来表示二维空间点:

Point {
    Float: x
    Float: y
}

在确定哪个城镇最接近时,我们将仅关注到咖啡馆的直线距离。在任何实际的导航任务中,我们还需要考虑我们与咖啡之间的障碍物。然而现在,让我们仅考虑到咖啡馆的欧几里得距离。如果我们当前的位置是(x[1],y[1]),而咖啡馆位于(x[2],y[2]),则距离为:

G08001

我们可以使用清单 8-1 中的线性扫描算法。该算法计算从目标点到每个候选点的距离,如图 8-4 所示。

图示展示了从目标点到地图上每个 11 个城镇的距离计算。距离由从目标点到各城镇的虚线表示。

图 8-4:线性扫描最近邻搜索计算从目标点到每个候选点的距离。

距离目标最近的点,如图 8-5 所示,就是目标的最近邻。虚线表示到最近点的距离,虚圆圈显示的是我们地图上比最近点更近(或等于)的区域。没有其他点比最近邻更靠近目标。

图中展示了从目标点到最近邻点的虚线,以及一个表示在该半径内的区域的虚圆圈。

图 8-5:距离目标最近的点即为该目标的最近邻。

正如我们多次看到的那样,随着点的数量增加,这种线性扫描搜索很快就变得低效。如果《咖啡爱好者的路边咖啡指南》当前列出了 100,000 家咖啡馆,那么检查每一家就会变得不必要地耗时。

我们不需要查看二维空间中的每个数据点。有些点距离太远,根本不重要。当我们开车穿越佛罗里达时,我们绝不会考虑阿拉斯加的咖啡馆。这并不是要贬低阿拉斯加的咖啡馆——我确信其中有很多在口味和质量上与佛罗里达的同行相当。这仅仅是出于实用考虑。我们不能在没有咖啡的情况下生存一小时,更不用说长时间的车程了。如果我们正在穿越佛罗里达北部,我们需要关注的是佛罗里达北部的咖啡馆。

正如我们在二分查找中看到的,我们通常可以利用数据中的结构来帮助消除大量候选项。我们甚至可以将二分查找方法应用于一维空间中寻找最近邻。不幸的是,在二维情况下,简单的排序并不会有所帮助。如果我们对 X 或 Y 维度进行排序并搜索,如图 8-6 所示,我们会得到错误的答案——一维空间中最近的邻居与二维空间中最近的邻居并不相同。

我们需要利用所有相关维度的信息来做出准确的剪枝决策。在某一维度上接近目标的点,在其他维度上可能相距极远。如果我们按照纬度对咖啡店进行排序,我们在佛罗里达北部寻找接近当前纬度的位置时,可能会返回一些来自休斯顿的“接近”结果。同样,如果我们按经度排序,可能会被克利夫兰的条目淹没。我们需要探索新的方法,这些方法源自我们在一维数据上的经验,同时也利用了高维数据固有的结构。

左侧是图 8-3 中地图点的投影到 Y 轴,右侧是投影到 X 轴。在这两种情况下,一维中最接近的邻居与二维中最接近的邻居并不相同。

图 8-6:将数据投影到 Y 轴(左)或 X 轴(右)上的一维空间,会丧失关于另一个维度的重要空间信息。

网格

网格是用于存储二维数据的数据结构。像数组一样,网格由一组固定的单元格组成。由于我们最初是处理二维数据,我们使用二维排列的单元格,并通过两个数字 xbinybin 来索引每个单元格,分别表示沿 X 轴和 Y 轴的单元格编号。图 8-7 展示了一个网格的示例。

一个二维的网格覆盖在地图上。网格的 X 轴和 Y 轴都标记为 0 和 1,共形成四个象限。

图 8-7:一个 2×2 的空间数据点网格

与数组不同,我们不能限制每个单元格只存储一个值。网格单元是由空间边界定义的——每个维度上有高低边界。无论我们如何细化网格,多个点可能落入同一个单元格,因此我们需要让每个单元格存储多个元素。每个单元格存储一个包含该单元格范围内所有数据点的列表。

我们可以将网格和数组的区别比作不同类型的冰箱存储方式。蛋盒是一个数组,每个鸡蛋都有一个独立的空间。相比之下,蔬菜抽屉就像一个网格容器。它包含多个相同类型的物品,全部是蔬菜。我们可能会把一个抽屉塞满二十五个洋葱。而蛋盒则只包含固定数量的鸡蛋,每个鸡蛋都有其指定的位置。虽然蔬菜抽屉可能引发关于番茄或黄瓜应该正确存放在哪里的激烈争论,但网格单元的边界是通过数学精确定义的。

网格使用点的坐标来决定它们的存储方式,这使得我们可以利用数据的空间结构来限制搜索范围。为了理解这是如何实现的,我们首先需要考虑网格结构的细节。

网格结构

我们网格的顶层数据结构包含了一些额外的记录信息。如图 8-8 所示,我们需要在每个维度上包含多项信息。除了沿 x 轴和 y 轴的容器数量外,我们还必须跟踪每个维度的空间边界。我们使用x_startx_end来指示网格中包含的 x 值的最小值和最大值。类似地,y_starty_end用于捕捉 y 维度的空间边界。

该网格从 x_start 到 x_end 跨越 x 轴,从 y_start 到 y_end 跨越 y 轴。网格在每个维度上有 6 个容器,且结果显示了容器的宽度。

图 8-8:一个在每个维度上都有指定起始值和结束值的网格

我们可以从容器的数量和空间边界中推导出一些顶层信息,但为了方便起见,我们通常希望存储有关网格的额外信息。预先计算每个维度上容器的宽度可以简化后续的代码:

x_bin_width = (x_end – x_start) / num_x_bins
y_bin_width = (y_end – y_start) / num_y_bins

其他有用的信息可能包括网格中存储的总点数或空的容器数量。我们可以通过一个复合数据结构来跟踪所有这些信息。对于二维数据,我们的典型数据结构大致如下所示:

Grid {
    Integer: num_x_bins
    Integer: num_y_bins
    Float: x_start
    Float: x_end
 Float: x_bin_width
    Float: y_start
    Float: y_end
    Float: y_bin_width
    Matrix of GridPoints: bins
}

对于一个固定大小的网格,我们可以通过一个简单的数学计算将点的空间坐标映射到网格的容器中:

xbin = Floor((x – x_start) / x_bin_width)
ybin = Floor((y – y_start) / y_bin_width)

从“一个容器,一个值”到“空间分区”的转变具有重要的后果,超出了索引映射的范畴。这意味着我们不能再将数据作为一组固定的容器存储在计算机内存中。每个方格可以包含任意数量的数据点。每个网格方格需要拥有自己的内部数据结构来存储其点。存储点的一个常见且有效的数据结构是链表,如图 8-9 所示。

一个网格显示为四个容器的列表(一个扁平化的矩阵),每个容器指向一个包含点的链表的起始位置。前几个容器包含三个点,而最后一个容器(xbin = 1,ybin = 1)包含两个点。

图 8-9:用于存储网格中点的数据显示结构

每个桶存储指向链表头部的指针,链表中包含该桶的所有点。我们通过另一个内部数据结构来存储单个点来实现这一点:

GridPoint {
    Float: x
    Float: y
    GridPoint: next
}

或者,我们可以使用第三章中的LinkedListNode数据结构,并存储一对表示 xy 坐标的值。

构建网格并插入点

我们通过分配一个空的网格数据结构并使用一个单一的FOR循环遍历数据点,迭代地插入点来构建数据集的网格。网格本身的高级结构(空间范围和每个维度的桶数量)在创建时是固定的,并且不会随着添加的数据变化。

如清单 8-2 所示,插入点的过程包括找到正确的桶并将新点添加到该桶对应链表的开头。

GridInsert(Grid: g, Float: x, Float: y):
  ❶ Integer: xbin = Floor((x - g.x_start) / g.x_bin_width)
    Integer: ybin = Floor((y - g.y_start) / g.y_bin_width)

    # Check that the point is within the grid.
  ❷ IF xbin < 0 OR xbin >= g.num_x_bins:
        return False
    IF ybin < 0 OR ybin >= g.num_y_bins:
        return False

    # Add the point to the front of the list. 
  ❸ GridPoint: next_point = g.bins[xbin][ybin]
    g.bins[xbin][ybin] = GridPoint(x, y)
    g.bins[xbin][ybin].next = next_point

  ❹ return True

清单 8-2:一个将新点插入网格的函数

代码首先计算新点的 x 和 y 桶 ❶,并确认新点落在有效的桶中 ❷。虽然在使用数组时,始终要确保访问的是有效的数组索引,但空间数据结构带来了额外的关注点。我们可能无法预定义一个适用于每个未来可能点的固定、有限的网格。因此,考虑到之前未见过的点可能会落在空间数据结构所覆盖范围之外,显得尤为重要。在这个例子中,我们返回一个布尔值来指示点是否能被插入 ❹。然而,根据编程语言的不同,您可能更倾向于使用其他机制,比如抛出异常。

一旦我们确定该点适合放入网格中,代码会找到合适的桶。代码将新点添加到列表的前端,如果桶之前为空,则创建一个新的列表 ❸。函数最终返回True ❹。

删除点

我们可以使用类似的插入方法来删除网格中的点。一个额外的难点是确定要删除桶中哪一个点。在许多使用场景中,用户可能会在网格中插入任意接近甚至重复的点。例如,如果我们要存储可购买的咖啡列表,可能会为同一家咖啡店插入多个点。理想情况下,我们使用其他标识信息,如咖啡的名称或 ID 编号,来确定要删除哪个点。在本节中,我们介绍了删除链表中第一个匹配点的简单通用方法。

由于浮点数变量的精度限制,我们可能无法使用直接的相等性测试。在清单 8-3 中,我们使用一个辅助函数来找到一个足够接近的点。approx_equal函数会在两个点在两个维度上都在阈值距离内时返回True

approx_equal(Float: x1, Float: y1, Float: x2, Float: y2):
    IF abs(x1 – x2) > threshold:
        return False
    IF abs(y1 – y2) > threshold:
        return False
    return True

Listing 8-3: 检查两个数据点(表示为一对浮点数)是否相等的代码

代码独立检查每个维度,并将距离与阈值进行比较。阈值将取决于具体的应用场景和编程语言的数值精度。通常,我们希望这些阈值足够大,以便考虑到浮点数的数值精度。

删除操作包括找到正确的桶,遍历链表直到找到匹配项,然后通过从链表中切除匹配项来完成删除。我们的删除函数如果找到并删除了一个点,则返回True,否则返回False

GridDelete(Grid: g, Float: x, Float: y):
  ❶ Integer: xbin = Floor((x - g.x_start) / g.x_bin_width)
    Integer: ybin = Floor((y - g.y_start) / g.y_bin_width)

    # Check that the point is within the grid.
  ❷ IF xbin < 0 OR xbin >= g.num_x_bins:
        return False
    IF ybin < 0 OR ybin >= g.num_y_bins:
        return False

    # Check if the bin is empty.
  ❸ IF g.bins[xbin][ybin] == null:
        return False

    # Find the first matching point and remove it.
  ❹ GridPoint: current = g.bins[xbin][ybin]
    GridPoint: previous = null
    WHILE current != null:
      ❺ IF approx_equal(x, y, current.x, current.y):
          ❻ IF previous == null:
                g.bins[xbin][ybin] = current.next
            ELSE:
                previous.next = current.next
 return True
      ❼ previous = current
        current = current.next
    return False

代码首先计算新点的 x 和 y 桶❶,并确认新点是否落在一个有效的桶内❷。接下来,它检查目标桶是否为空❸,如果为空则返回False

如果有点需要检查,代码会遍历列表❹。与插入操作的代码不同,我们同时追踪当前节点和前一个节点,以便能够从链表中切除目标节点。代码使用 Listing 8-3 中的approx_equal辅助函数来测试每个点❺。当找到匹配的点时,它会将其从链表中切除,特别处理链表中第一个节点的特殊情况❻,然后返回True。因此,只有列表中第一个匹配的点会被删除。如果当前点不匹配,搜索将继续到下一个节点❼。如果搜索完成整个列表,函数将返回False,表示没有匹配的节点被删除。

网格上的搜索

现在我们已经学会了如何构建网格数据结构,让我们利用它们来改进最近邻搜索。首先,我们检查如何修剪距离过远的网格单元,这将帮助我们避免在网格单元内进行不必要的计算。接着,我们考虑两种基本的搜索方式:对所有桶进行线性扫描和扩展搜索。

修剪桶

网格的空间结构使我们能够限制需要检查的点数,排除那些位于我们不感兴趣的范围之外的点(如北佛罗里达)。一旦我们有了一个候选邻居及其相关的距离,我们就可以利用这个距离来修剪桶。在检查一个桶中的点之前,我们会问是否有任何点在该桶的空间范围内可能比当前最好的距离更近。如果没有,我们可以忽略这个桶。*

确定桶内的任意*点是否在距离目标点给定的范围内听起来可能是一项艰巨的任务。然而,如果我们使用欧几里得距离 i08001,我们可以将其封装在这个简单的辅助函数中:

euclidean_dist(Float: x1, Float: y1, Float: x2, Float: y2):
    return sqrt((x1-x2)*(x1-x2) + (y1-y2)*(y1-y2))

那么测试简化为简单的数学运算。我们首先找出网格单元中最接近的可能点,并用它进行剪枝测试。具体来说,如果网格单元中最接近的可能点距离当前最优候选点更远,那么就没有必要检查网格单元中存储的任何实际点。它们的距离肯定都更远。如果目标点位于该单元内——也就是说,如果它的 x 和 y 值分别位于单元格的 x 和 y 范围内——则该单元的距离(即最接近的可能点)为零。

如果点位于单元外,则单元内最接近的点必须位于单元的边缘。图 8-10 显示了网格单元外的各种点及其对应的单元内最接近点。对于网格单元外的点,我们需要计算到最接近的边缘点的距离。

显示八个目标点(灰色圆圈)及其对应的单元内最接近点。单元外目标点的最接近点位于单元的边缘。

图 8-10:网格单元外的点(灰色圆圈)及其对应的最接近点(实心圆圈)

我们可以通过分别考虑每个维度来计算点与网格单元最近边缘之间的欧几里得距离。我们找出将 x 值调整到单元范围内所需的最小距离,以及将 y 值调整到单元范围内所需的最小距离。对于网格单元 (xbin, ybin),最小和最大 x 及 y 维度为:

x_min = x_start + xbin * x_bin_width
x_max = x_start + (xbin + 1) * x_bin_width
y_min = y_start + ybin * y_bin_width
y_max = y_start + (ybin + 1) * y_bin_width

我们可以按如下方式计算距离(在欧几里得距离的情况下):

g08002

其中

如果 x < xmin 那么 x[dist] *= xminx

如果 xminxxmax 那么 x[dist] = 0

如果 x > xmax 那么 x[dist] = xxmax

如果 y < y_min 那么 y[dist] = y_miny

如果 y_minyy_max 那么 y[dist] = 0

如果 y > y_max 那么 y[dist] = yy_max

如果某个可能的点到当前最近点的最小距离大于当前最近点的距离,那么该网格单元中的任何点都无法替代当前的最近点。我们可以忽略整个网格单元!

计算从点到网格单元的最小距离的代码可以封装到以下帮助函数中。该函数实现了上述数学逻辑。

MinDistToBin(Grid: g, Integer: xbin, Integer: ybin, Float: x, Float: y):
    # Check that the bin is valid.
  ❶ IF xbin < 0 OR xbin >= g.num_x_bins:
        return Inf
    IF ybin < 0 OR ybin >= g.num_y_bins:
        return Inf

  ❷ Float: x_min = g.x_start + xbin * g.x_bin_width
    Float: x_max = g.x_start + (xbin + 1) * g.x_bin_width
    Float: x_dist = 0
    IF x < x_min:
      x_dist = x_min - x
    IF x > x_max:
      x_dist = x - x_max

  ❸ Float: y_min = g.y_start + ybin * g.y_bin_width
    Float: y_max = g.y_start + (ybin + 1) * g.y_bin_width
    Float: y_dist = 0
    IF y < y_min:
      y_dist = y_min - y
    IF y > y_max:
      y_dist = y - y_max
    return sqrt(x_dist*x_dist + y_dist*y_dist)

列表 8-4:一个帮助函数,用于计算目标点与给定网格单元的最短距离

代码首先检查单元索引是否有效 ❶。在这个例子中,我们使用无限大距离来表示函数调用者引用了一个无效的单元。这个逻辑使我们能够在可能查询无效单元的剪枝计算中使用此查找函数。然而,这可能会导致混淆:为什么函数会为无效的单元返回一个距离?根据使用情况,最好抛出一个错误,指出单元索引无效。无论如何,函数的行为应该清楚地记录下来,供用户参考。

其余的代码按照上面的距离逻辑,依次处理 x 和 y 维度(分别是 ❷ 和 ❸)。代码计算该单元的最小值和最大值,比较它们与该维度上点的值,并计算距离。

为了可视化这个距离测试,想象一个喧闹的接球游戏把球扔过了我们的篱笆,落入了我们友好但极其懒散的邻居家院子里。当然,他们会把球扔回来,但不会付出比绝对必要的更多努力。他们需要投掷球的最短距离是多少,才能(勉强)把球投回我们的院子?如果他们的经度已经在我们院子的范围内,他们将朝正北或正南方向投掷,以避免增加不必要的东西/西方距离。最终,他们的投掷总是精确地落在篱笆上,使得球重新落回我们的院子。我们的邻居可能懒惰,但他们有一些令人印象深刻的投掷技巧。

对单元进行线性扫描

搜索网格的最简单方法是通过线性扫描遍历所有网格单元,并仅检查那些可能包含潜在最近邻的单元。这不是一个特别好的算法,但它为使用和剪枝单元提供了一个简单的入门。

线性搜索算法简单地在检查每个单元内容之前,应用前述的最小距离测试:

GridLinearScanNN(Grid: g, Float: x, Float: y): 
  ❶ Float: best_dist = Inf
    GridPoint: best_candidate = null

    Integer: xbin = 0
  ❷ WHILE xbin < g.num_x_bins:
        Integer: ybin = 0
        WHILE ybin < g.num_y_bins:

            # Check if we need to process the bin.
          ❸ IF MinDistToBin(g, xbin, ybin, x, y) < best_dist:

                # Check every point in the bin's linked list.
                GridPoint: current = g.bins[xbin][ybin]
              ❹ WHILE current != null:
                    Float: dist = euclidean_dist(x, y, current.x, current.y)
                  ❺ IF dist < best_dist:
                        best_dist = dist
                        best_candidate = current
                    current = current.next
            ybin = ybin + 1
        xbin = xbin + 1
  ❻ return best_candidate

列表 8-5:一种使用线性扫描遍历网格单元并对每个单元进行剪枝测试的最近邻搜索方法。

代码首先将最佳距离设置为无限大,表示到目前为止还没有找到最佳点 ❶。然后,算法使用一对嵌套的WHILE循环,扫描 x 和 y 单元 ❷。在检查单元中的每个点之前,代码执行最小距离测试,检查单元中的任何点是否可能是更好的邻居 ❸。如果单元可能包含更好的邻居,代码使用第三个 WHILE 循环遍历单元中的链表 ❹。它测试每个点到当前最佳距离的距离,并与已找到的最佳距离进行比较 ❺。函数最后通过返回找到的最佳候选点来完成,如果网格为空,则可能返回null ❻。

清单 8-5 中的算法允许我们在确定某个箱子内任何点的最小距离大于到目前为止找到的最佳点的距离时,剪除整个箱子以及其中包含的所有点。如果每个箱子内的点数很大,这可以节省大量计算。然而,如果网格稀疏,我们可能会发现检查每个箱子所需的成本比逐个检查每个点更高。

与清单 8-2 中的GridInsert函数不同,我们的线性扫描方法适用于位于网格空间边界内外的目标点。GridLinearScanNN不需要将目标点映射到箱子,因此不关心目标是否位于网格本身上。它仍然会返回网格中最近的邻居(如果网格为空,则返回null)。这为我们的最近邻搜索提供了额外的灵活性,尤其是在遇到新的、非典型目标时。

理想的扩展搜索在箱子中的应用

虽然线性扫描算法允许我们根据目标点的最小距离来剪枝掉整个箱子,但我们仍然没有充分利用所有的空间信息。我们通过测试远离目标点的箱子,浪费了大量计算。我们可以做得更好,通过优先考虑与目标点接近的箱子,首先搜索离目标点最近的箱子,当剩余的箱子距离我们已找到的最近邻更远时,就停止搜索。我们称这种方法为扩展搜索,因为我们实际上是从包含目标点的箱子开始扩展,直到找到最近的邻居为止。

为了可视化这种改进的扫描方法,想象一下早晨我们焦急寻找车钥匙的情形。我们从车钥匙应该存放的地方开始(这与一个网格单元类似),如果我们正确存放的话。我们检查厨房台面上的每一寸地方,直到承认我们一定是把钥匙放错地方了。接着,我们将视野扩展到屋子的其他部分(即其他箱子),检查附近的地方,比如咖啡桌和地板,然后才会进一步寻找。这个搜索会继续,探索那些越来越不可能的地方,直到我们发现钥匙神奇地出现在袜子抽屉里。

以一个扩展扫描为例,考虑我们覆盖有四乘四网格的地图,如图 8-11 所示。我们通过询问“我们的目标点落入哪个箱子?”并使用网格索引映射方程来找到离目标点最近的箱子。由于目标点可能落在网格外部,我们可能还需要将计算出的箱子索引调整到有效范围内。在图 8-11 中,目标点位于最左列第三个箱子内(在我们的符号中,xbin = 0 和 ybin = 2)。

图 8-3 中的地图点被放置在一个四乘四的网格中。

图 8-11:二维点的 4×4 网格

我们可以从目标点所在的区域开始搜索,并测试该区域中的每个点。只要该区域不为空,我们就能保证找到第一个候选最近邻,如图 8-12 所示。不幸的是,由于我们没有对每个区域内的点进行组织或排序,因此在这种情况下我们只能进行线性扫描。自然地,如果初始区域为空,我们必须逐步向外推进,搜索相邻区域,直到找到一个包含数据点的区域,作为我们的候选最近邻。

图 8-11 中的四乘四地图点网格,虚线到候选最近邻。第一个候选点位于同一区域内。

图 8-12:一个在与目标点相同区域内找到的初始候选最近邻

一旦我们获得了这个初始的最近邻候选点,我们仍然没有完成。候选点只是一个候选点。可能在相邻的区域内有更近的点。如果我们的目标点接近某个区域的边缘,这种情况更为可能。在图 8-13 中,虚线圆圈代表所有距离当前候选点相等或更近的点的空间。任何落在圆圈内的点都可能是真正的最近邻。阴影网格单元就是那些与该区域重叠的单元。

图 8-11 中的四乘四地图点网格,虚线圆圈显示距离当前候选邻居距离相等或更近的空间区域。四个与该圆重叠的网格单元被阴影标示,表示它们可能包含更近的邻居。

图 8-13:一个候选最近邻和可能包含更接近目标点的网格单元

为了形象化我们为何需要继续检查其他区域,想象你想要在户外派对上找出离你最近的人。你正在讲述一个特别尴尬的故事,涉及到在咖啡中使用了变质的牛奶,并且想确保只有预定的听众听到你的故事。你旁边站着的最好的朋友可能看起来离你最近,但是,如果你靠近围栏,你还需要考虑对面的人。如果你的邻居正在围栏旁边种花,他们实际上可能更近,能听到所有的羞辱细节。你不能因为有围栏在中间就忽略他们。这就是为什么我们总是检查相邻的区域——也就是为什么你在公开场合讲尴尬故事时一定要小心。

我们继续扩展搜索范围,包含所有邻近的格子,直到我们能够保证没有任何剩余格子中的点比我们候选的最近邻点更近。一旦检查完所有在候选最近邻半径范围内的格子,我们可以忽略更远的格子,甚至不需要检查它们的距离。

这种改进的网格搜索所带来的权衡是算法复杂度。与扫描每一个格子(我们可以通过嵌套的FOR循环实现这种算法)相比,优化后的搜索从单一格子开始,逐步扩展,直到我们能证明没有未探索的格子包含更好的邻居。这需要在搜索顺序(向外螺旋)、边界检查(避免测试网格边缘外的格子)和终止条件(知道何时停止)中加入额外的逻辑。下一节将展示一个简化扩展搜索的简单示例,供说明用途。

简化扩展搜索

假设我们考虑一个简化的(非优化的)扩展搜索版本,它以菱形模式向外扩展。为了简单实现,搜索使用从初始格子开始的增大距离,而不是执行一个完美的螺旋。为了实现的简便,我们将在网格索引上使用曼哈顿距离,计算网格单元之间的步数:

d = |xbin[1] − xbin[2]| + |ybin[1] − ybin[2]|

尽管这种搜索模式对于每个维度中格子宽度差异很大的网格可能效率不高,但它提供了一个易于理解的示例。

图 8-14 展示了搜索的前四次迭代。在图 8-14(a)的第一次迭代中,我们搜索包含目标点的格子(距离为零)。在接下来的图 8-14(b)迭代中,我们搜索所有距离一步的格子。在随后的每次迭代中,我们搜索所有进一步的格子。

四次迭代的扩展搜索示例。在第一次迭代中,包含目标点的单一格子被标记。在第二次迭代中,四个格子(每个距离一步)被标记。在第三次迭代中,八个格子(每个距离两步)被标记。

图 8-14:在网格上执行简化扩展搜索的前四次迭代

我们从一个辅助函数开始,该函数检查指定格子内是否有任何点比目标点(x, y)距离更近,且小于给定的threshold。该函数执行对格子内点的线性扫描。如果存在至少一个比阈值更近的点,函数将返回距离最近的点。使用阈值可以让我们将该辅助函数用于将格子内的点与其他格子的最佳候选点进行比较。

GridCheckBin(Grid: g, Integer: xbin, Integer: ybin, 
             Float: x, Float: y, Float: threshold):
    # Check that it is a valid bin and within the pruning threshold.
  ❶ IF xbin < 0 OR xbin >= g.num_x_bins:
        return null
    IF ybin < 0 OR ybin >= g.num_y_bins:
        return null

 # Check each of the points in the bin one by one.
    GridPoint: best_candidate = null
  ❷ Float: best_dist = threshold
    GridPoint: current = g.bins[xbin][ybin]
  ❸ WHILE current != null:
      ❹ Float: dist = euclidean_dist(x, y, current.x, current.y)
        IF dist < best_dist:
            best_dist = dist
            best_candidate = current
        current = current.next
  ❺ return best_candidate

清单 8-6:一个辅助函数,用于返回与目标点距离小于给定阈值的格子中的最近点

代码首先进行安全检查,确保我们正在访问一个有效的格子 ❶。如果不是,它会返回null以表示没有有效的点。然后,代码使用WHILE循环遍历格子中的每个点 ❸,计算其与目标点的距离,将其与当前已知的最佳距离进行比较,如果更近,则将其保存为新的最佳候选点 ❹。最后,代码返回最接近的点 ❺。由于代码在检查任何点之前,已经将best_dist设置为threshold值 ❷,因此只有距离小于threshold的点才会被标记为新的候选点。如果格子中的点都没有比threshold更近,则函数返回null

执行扩展搜索的代码通过迭代不同步数的步骤,检查所有在这些步骤内能到达的格子。与之前的搜索一样,我们追踪到目前为止看到的最佳候选项。搜索在第d次迭代后结束,如果在d步的范围内没有包含更近邻居的有效网格单元。

GridSearchExpanding(Grid: g, Float: x, Float: y):
    Float: best_d = Inf
    GridPoint: best_pt = null

  ❶ # Find the starting x and y bins for our search.
    Integer: xb = Floor((x - g.x_start) / g.x_bin_width)
    IF xb < 0:
        xb = 0
    IF xb >= g.num_x_bins:
        xb = g.num_x_bins - 1

    Integer: yb = Floor((y - g.y_start) / g.y_bin_width)
    IF yb < 0:
        yb = 0
    IF yb >= g.num_y_bins:
        yb = g.num_y_bins - 1

 Integer: steps = 0
    Boolean: explore = True
  ❷ WHILE explore:
        explore = False

      ❸ Integer: xoff = -steps
        WHILE xoff <= steps:
          ❹ Integer: yoff = steps - abs(xoff)
          ❺ IF MinDistToBin(g, xb + xoff, yb - yoff, x, y) < best_d:
              ❻ GridPoint: pt = GridCheckBin(g, xb + xoff, yb - yoff, 
                                             x, y, best_d)
                IF pt != null:
                    best_d = euclidean_dist(x, y, pt.x, pt.y)
                    best_pt = pt
              ❼ explore = True

          ❽ IF (MinDistToBin(g, xb + xoff, yb + yoff, x, y) < best_d
                AND yoff != 0):
                GridPoint: pt = GridCheckBin(g, xb + xoff, yb + yoff, 
                                             x, y, best_d)
                IF pt != null:
                    best_d = euclidean_dist(x, y, pt.x, pt.y)
                    best_pt = pt
              ❾ explore = True

            xoff = xoff + 1
        steps = steps + 1
    return best_pt

这段代码首先通过查找网格中离目标点最近的格子开始,确保将网格外的目标映射到其最近的网格格子 ❶。最终得到的格子(xbyb)将作为搜索的起点。通过将网格外的格子映射到有效的格子,函数可以返回位于网格外的目标点的最近邻。

代码随后使用一个WHILE循环,从这个初始格子开始向外扩展,步长逐渐增加 ❷。变量steps记录当前迭代使用的距离。WHILE循环的条件是变量explore,它表示下一次迭代可能包含一个有效的格子,因此我们应该在下一步继续探索。正如我们稍后会看到的那样,WHILE循环在完成一次完整迭代后终止,此时没有任何访问过的格子能包含更近的邻居。

在主WHILE循环内,代码按从-stepssteps的不同 x 索引偏移量进行迭代,就像在网格上水平扫描一样❸。x 方向和 y 方向的总步数由steps固定,因此代码可以程序化地计算剩余的步数,使用(正或负)y 方向的步数❹。从负 y 方向开始,代码使用清单 8-4 中的MinDistToBin检查箱子索引是否有效,如果有效,则计算到该箱子的距离❺。它可以跳过任何无效的或距离过远的箱子。如果该箱子可能包含比当前候选点更近的点,代码将使用清单 8-6 中的GridCheckBin检查是否存在这样的点❻。每当找到更近的点时,代码会将其保存为新的最近候选点,并更新其对最近距离的估计值。第二个IF块在正 y 方向执行相同的检查,只要 y 偏移量不为零(在这种情况下,我们已经检查了负 y 方向的箱子)❽。

在外部WHILE循环的每次迭代中❷,代码将explore重置为False。如果任何一次对MinDistToBin的调用表明某个箱子可能包含更近的邻居(❼和❾),则代码稍后将explore更新为True。因此,外部循环会继续,直到达到一个步骤数,在该步骤中每个箱子都要么比best_d更远,要么位于网格外(因此无效)。虽然其他终止标准可能提供更精确的测试并更早终止,但由于其简单性,我们在代码中使用了这个规则。

网格大小的重要性

我们网格箱子的大小对搜索效率有巨大影响。我们的箱子越大,可能需要检查的每个箱子里的点就越多。记住,我们的网格搜索仍然会对每个访问到的箱子中的点进行线性扫描。然而,将网格划分为更精细的箱子在内存和我们可能遇到的空箱子数量上都有权衡。当我们缩小网格箱子的大小时,我们通常需要搜索更多的单独箱子才能找到第一个候选最近邻,而且检查箱子的成本也增加了。

图 8-15 显示了一个极端案例,其中网格太精细。

图 8-3 中的地图点被放置在一个 17x17 的网格中。现在有 36 个箱子比最近邻更近。

图 8-15:一个精细的网格,其中大多数箱子为空

在图 8-15 中,我们必须搜索 36 个箱子才能找到最近邻。显然,这比图 8-13 中的示例更昂贵,在后者中我们只需要检查四个箱子和两个单独的点。遗憾的是,这甚至可能比线性扫描搜索还要贵,因为线性扫描检查了所有 11 个数据点。

在我们寻找咖啡店的情境下考虑这个问题。如果我们将空间划分得过细,比如 1 米×1 米的方格,我们将面临一个大多数空桶的网格。如果我们将空间划分得过粗,比如 5 公里×5 公里的方格,我们可能会把整个城市及其众多的咖啡店归入一个桶中,同时仍然(让我们极为震惊地发现)留下大量几乎完全为空的桶。

最优的网格大小通常取决于多个因素,包括点的数量及其分布。更复杂的技术,如非均匀网格,可以用于动态地适应数据。在下一章中,我们将讨论几种基于树的数据结构,它们能够动态地实现这种适应。

超越二维

为二维开发的基于网格的技术也可以扩展到更高维的数据。我们可能需要在一栋多层的办公楼中搜索最接近的可用会议室。我们可以通过将z坐标纳入距离计算来在三维数据中搜索最近邻:

g08003

或者,更一般地,我们可以定义d维数据上的欧几里得距离为:

g08004

其中 x[i][d]是第i个数据点的第d维。

高维数据为我们在本章中考虑的基于网格的方法带来了另一个挑战:它要求我们沿更多的维度划分空间。随着我们考虑更高维度,存储这些数据结构所需的空间会迅速膨胀。对于具有D维和每维K个桶的数据,我们需要K^(D)个独立的桶!这可能需要大量内存。图 8-16 展示了一个三维示例,即一个 5×5×5 的网格,已经包含了大量的独立桶。

更糟的是,随着我们增加网格桶的数量,我们很可能增加空桶的比例。检查这些空桶是浪费的工作。因此,网格对于高维问题来说并不是理想的。在下一章中,我们将介绍一种更好的方法来扩展到高维数据——k-d 树。

虽然很难用超过三维的空间来思考日常的空间问题,但我们可以将最近邻方法应用于超越空间点的数据。在下一节中,我们将看到如何使用最近邻搜索帮助我们找到相似的咖啡店或天气条件相似的日子。

一个五乘五乘五的三维点网格。

图 8-16:三维点的网格

超越空间数据

空间数据,比如地图上的位置,为最近邻搜索和网格提供了一个简单的视觉示例。我们习惯于从邻近的角度思考位置,因为我们经常会问自己像“哪里是最近的加油站?”或“离会议中心最近的酒店在哪里?”这样的问句。然而,最近邻问题并不仅仅局限于空间数据。

让我们考虑一个关键问题:当我们最喜欢的咖啡品牌缺货时,如何选择下一个最佳的咖啡品牌。为了找到与我们喜欢的咖啡类似的品牌,我们可能会考虑我们喜欢的咖啡特性,比如强度或酸度水平,然后寻找具有相似特征的其他咖啡。我们可以扩展最近邻搜索来找到这些“接近”的咖啡。为此,我们首先在咖啡日志中记录下我们曾经品尝过的每一款咖啡,标注出像强度和酸度这样的属性,如图 8-17 所示。

多年来,我们构建了一个全面的咖啡地理图谱。对这些数据执行最近邻搜索可以帮助我们找到与目标值相似的咖啡品种。想找一款强烈且低酸度的咖啡来提神,赶在紧迫的截止日期前完成工作吗?我们可以准确地想象出我们想要的那款咖啡,就是我们曾经在夏威夷喝过的那款绝妙咖啡。不幸的是,眼前的截止日期让我们没有足够的时间去夏威夷。但别担心!我们可以利用对咖啡属性的全面分析,这些分析记录在我们的咖啡日志中,来定义一个搜索目标,然后寻找本地品牌,看看有没有足够相似的。

左侧是一个二维数据点图,x 轴标注为强度,y 轴标注为酸度。右侧是相同的点,上面覆盖了一个网格。

图 8-17:咖啡属性作为二维数据的示例(左)以及这些点在网格中的位置(右)

要进行这个搜索,我们只需要一种方法来计算像咖啡强度或酸度这样的属性的距离。最近邻算法依赖于我们区分“近”与“远”邻居的能力。虽然我们可以为其他类型的数据定义距离度量,例如字符串,但为了保持一致性,本章将限制讨论实值属性。

对于空间数据点,我们有简单的标准方法来测量两个点之间的距离,如(x[1], y[1]) 和 (x[2], y[2]) 之间的欧几里得距离(之前已经使用过)。但任何问题的最优距离度量方法都依赖于问题本身。在评估咖啡品牌时,我们可能希望在不同的情境下对属性赋予不同的权重。比如,在临近的截止日期前,咖啡因含量可能比酸度等其他因素更为重要。

一种常见的非空间数据距离度量方法是加权欧几里得距离:

g08005

其中,x[i][d]是第i个数据点的第d维,w[d]是第d维的权重。这种公式让我们可以加权不同维度的影响。在这种情况下,我们可能会将咖啡因含量的权重设置为酸度的两倍,从而使搜索偏向于咖啡因含量相似的咖啡。我们甚至可以在每次搜索时调整权重。

当然,我们的搜索并不保证咖啡的其他方面是否适合。我们只是在测量指定维度上的接近度。如果我们只通过匹配强度和酸度来寻找日常咖啡,那么我们不会考虑烘焙程度、批次大小、种植条件、咖啡因含量,甚至土壤中的营养成分浓度。如果最近邻是去咖啡因咖啡,我们的搜索将无法解释这个灾难。我们最终会得到低质量的咖啡和失望的泪水。确保你的距离计算考虑所有相关维度是很重要的。

为什么这很重要

最近邻搜索允许我们找到“接近”某个目标值的点,无论是空间的还是非空间的。从算法的角度来看,最近邻搜索使我们从寻找精确目标转向基于距离度量的搜索。随着我们从一维数据集进入多维数据领域,搜索的细节变得更加复杂。正如我们从数组到网格的转变所看到的,这一扩展打开了一系列新的问题,涉及如何组织和搜索数据。我们不再能够像在一维数据的二分搜索中那样考虑简单的排序。我们需要将我们的数据结构适应新的多维结构类型。网格提供了一种新的数据结构方式,将位于相同空间区域的点聚合到同一个桶中。

与我们在数组中看到的“一桶一个值”结构不同,网格展示了另一种结构。网格使用链表或其他内部数据结构来存储每个桶中的多个值,这种技术我们将在后续章节中重复使用。通过使用这种结构,网格还引入了一个新的权衡因素——桶的大小。通过增大桶的大小,我们可以将成本从评估许多小桶转移到每个桶扫描大量数据点上。选择正确的桶数是调整我们数据结构以适应当前问题的一个典型例子。

在下一章,我们将通过将树的自适应特性与网格的空间特性相结合,进一步推进空间分区。这样做,我们将解决网格的一些主要缺点——并使寻找一杯好咖啡的过程更高效。

第九章:空间树

上一章展示了最近邻搜索如何帮助我们找到附近或接近的数据点,拓宽了我们回答与咖啡相关的问题的能力,比如找到最近的物理位置或找到具有相似属性的项目。在本章中,我们基于树形数据结构和空间划分的概念,进一步改进我们的最近邻搜索。

第八章讨论了如何将寻找特定值的算法概念适应到更一般的寻找最近邻问题中。我们还看到,当从一维过渡到多维时,操作变得更加复杂。也许我们想要在二维空间中找到附近的咖啡店,或者寻找相似的朋友(基于同理心、愿意倾听和永恒重要的酷感)。网格取代了简单的数组,单纯沿着一个维度排序已经不再足够。

本章介绍了两种基于树的数据结构:统一四叉树和 k-d 树。四叉树这个术语通常用来描述一类二维数据结构,基于计算机科学家 Raphael Finkel 和 Jon Bentley 提出的原始四叉树,该树将每个二维节点划分为四个子象限。我们关注的是统一四叉树,如研究员和发明家 David P. Anderson 提出的结构。该结构具有与网格结构相对应的大小相等的子区域,从而建立在上一章的讨论基础上。相比之下,k-d 树由 Jon Bentley 发明,采用一种更灵活的二叉划分方案,可以进一步适应数据,并允许我们扩展到更高维度。通过研究四叉树和 k-d 树,我们学会了如何推广和修改基于树的数据结构,并通过与城市规划项目的对比来考察这些数据结构。

四叉树

虽然网格为存储二维数据提供了一个便捷的数据结构,但它们也有自己的一系列复杂性。正如我们在上一章所看到的,网格的开销和实用性很大程度上取决于我们如何划分空间。使用大量网格箱子(通过创建细粒度网格)需要大量内存,并且可能需要我们搜索多个箱子。另一方面,粗略的划分可能导致每个箱子内有大量数据点,如图 9-1 所示,这类似于对大量单个点进行简单线性扫描。

一个二维网格,包含 11 个数据点和一个目标点。

图 9-1:具有少量箱子的网格

我们可以从不同的家庭组织方法来思考这些网格。如果我们把所有厨房用具都扔进一个巨大的抽屉里,找到某个物品就需要花费很长时间。这相当于使用一个巨大的网格箱。为了改善这种情况,假设我们将餐具分开存放在不同的抽屉里,并根据用途将炊具存放在不同的橱柜中。我们甚至将麦片放在一层架子上,将香料放在另一层架子上。这相当于使用更细粒度的网格。突然之间,情况变得好转了——我们不再需要在煎锅下方寻找肉桂粉。稍加额外的结构就能大大简化我们的烹饪流程。

然而,我们可能会将这一概念推得过头。也许我们拥有太多的餐具,且整理的抽屉过于拥挤,搜索起来非常耗时。我们或许可以通过将餐具分为一个专门放铲子、另一个放非铲子的抽屉来提高效率。但试想一下,将每个餐具都单独存放在一个抽屉里,甚至为每一个可能购买的餐具分配一个抽屉——这种做法的开销可想而知。很快,我们将面对一整面墙的抽屉,心里想着到底要打开多少个抽屉,才能找到一个打蛋器。这就是当我们使用过于精细的网格时的情况。

解决这一难题的方法是根据数据动态划分空间。只有在需要时,我们才引入额外的结构及其相应的开销。我们从对空间进行粗粒度划分开始。也许我们等到至少拥有五个铲子时,再为它们分配一个单独的抽屉。在此之前,我们可以将它们和勺子、搅拌器一起存放。当我们需要更精细的粒度时,我们进一步对子空间进行划分。为了提供这种动态性,我们可以借助均匀四叉树

一个均匀的四叉树将树的分支结构引入网格。树中的每个节点代表一个空间区域。根节点代表树所覆盖的整个空间以及该空间内的所有点。每个节点被划分为四个大小相等的象限,每个非空象限都有一个子节点。术语均匀指的是节点被划分为大小相等的空间区域,因此在节点内均匀地划分了空间。图 9-2 展示了四叉树的分割。

如图所示,四叉树的根节点包含整个空间并包含 11 个点。每个四个子节点包含根节点空间中的一个均匀区域,并包含该区域内的所有点。

图 9-2:一个四叉树节点最多可以有四个子节点,分别代表该空间的四个大小相等的象限。

在讨论四个子树时,通常将它们标记为西北(NorthWest)、东北(NorthEast)、西南(SouthWest)和东南(SouthEast),以反映它们在原始区域中的空间位置。

内部四叉树节点存储指向最多四个子节点的指针,并附带相关的元数据,如分支中点的数量或区域的空间边界(该节点在 x 维和 y 维的最小值和最大值)。叶节点存储一个包含该区域内点的列表,以及任何所需的元数据。我们可以通过保持一个 2×2 的子节点指针网格(对于内部节点)和一个点数组(对于叶节点)来使用单一的数据结构表示内部节点和叶节点。我们要么将叶节点中的子条目设置为null,要么在内部节点中使用一个空数组。

以下是QuadTreeNode的复合数据结构示例:

QuadTreeNode {
    Boolean: is_leaf
    Integer: num_points
    Float: x_min
    Float: x_max
    Float: y_min
    Float: y_max
    Matrix of QuadTreeNodes: children
    Array of Points: points
}

我们为点使用了一个简单的复合数据结构:

Point {
    Float: x
    Float: y
}

如同前一章所述,我们也可以将点存储在数组或有序元组中。

从技术上讲,我们不需要显式地存储节点的空间边界。我们可以通过根节点的边界和分割序列推导出每个节点的边界,因为每个节点在每个维度的中间对其点进行分区,将空间切割成四个可预测大小的子区域。给定根节点的原始边界和一系列分支,我们可以精确计算出任何子节点的边界。然而,预计算并存储边界有一个明显的优势:在任何给定的节点,我们可以简单地查找边界,而不是推导它们,这使得实现搜索算法变得更加容易。我常常发现,存储这种类型的额外空间信息的价值远远超过了额外的内存成本。

四叉树的优势在于,每个级别的分支(当有足够多的点时)实际上创建了一个自适应的层次网格。图 9-3 展示了一个四叉树空间分区的示例。

四级四叉树的可视化图。线条表示每个级别的分割。

图 9-3:四叉树创建的空间分区

想象四叉树的连续分区以及我们如何像科幻惊悚片中的互动地理搜索软件那样搜索它们。主角们挤满指挥室,盯着代表整个城市的大屏幕。紧张的音乐响起。随着新信息的涌入,操作员从屏幕中选择一个象限。有人说:“放大那里并增强,”操作员照做了。无论对话如何,这个操作相当于在四叉树中向下一级。刹那间,指挥室的屏幕显示了城市的一个子集。屏幕下的整个范围是上一级的一个象限。每个人都专注地盯着新的地理子集,然后选择一个更小的子象限再次放大。当我们的英雄们找到了离目标点最近的发射器时,搜索结束。

与其他树结构一样,我们可以在统一的四叉树上添加一个包装数据结构,以简化账务管理:

QuadTree {
    QuadTreeNode: root
}

根节点作为一个空节点在树创建时以正确的尺寸创建,因此我们不需要担心它为空。

构建均匀四叉树

我们通过递归地将分配的空间划分为越来越小的子区域,构建这些神奇的四叉树。由于数据可能包含任意接近或甚至重复的点,我们需要额外的逻辑来确定何时停止细分,并将包含多个点的节点指定为叶节点。在每一层,我们检查是否需要将当前节点设为内部节点(具有子节点)或叶节点(包含点的列表)。我们可以使用不同的机制来进行此测试,但以下是最常见的几种:

  1. 是否有足够的点来证明需要分裂?如果点太少,检查与子节点的距离的成本将高于逐一检查每个点的成本。这样做是不值得的。

  2. 空间边界是否足够大,值得进行分裂?如果我们有 10 个点在完全相同的位置呢?我们可能会不停地分裂而从未真正划分这些点。这将浪费时间和内存。

  3. 我们是否已达到最大深度?这为我们提供了一个备用检查,防止我们在过度细分时浪费时间和内存,通过限制树的最大深度来实现。

我们可以将这个过程可视化为一个非常规城市规划师尝试划分土地,使每块土地上都有建筑物。这个规划师不熟悉现代地理划分技术,总是将区域划分为四个相等的象限。每次查看一块土地时,他们都会问:“这块土地上的建筑物是否太多?”以及“这块地大到足以继续划分吗?我不能卖一个 2 英尺 x 2 英尺的地块,人们会笑话的。”第三个标准(最大深度)代表规划师在放弃之前愿意进行多少次细分。经过四层,规划师可能会觉得“差不多了”并继续进行。如果停止的标准没有达到,规划师会叹气,嘟囔道“真的吗?又来?”然后继续细分这块地。

在划分某一层时,我们将当前空间分成四个相等的象限,按象限划分点,并递归地检查每个子区。如果我们将点的最小数量设置为 1,并将最大深度设置为 4(包括根节点),我们将在图 9-4 中构建该树。如图所示,我们可以通过仅存储每个节点的非空子节点来节省内存。如果某个象限没有子节点,我们可以将其指针设置为null

图示展示了一个四层的四叉树,每个叶节点包含一个单独的点。

图 9-4:一个四层的四叉树示例

批量构建四叉树的代码与下一节添加点的代码非常相似。事实上,将点迭代地添加到空的四叉树中是构建四叉树的一个好方法。

添加点

由于四叉树是动态数据结构,我们可以在保持树结构的同时高效地添加点。我们从QuadTree数据结构的包装函数开始:

QuadTreeInsert(QuadTree: tree, Float: x, Float: y):
    IF x < tree.root.x_min OR x > tree.root.x_max:
        return False
    IF y < tree.root.y_min OR y > tree.root.y_max:
        return False
    QuadTreeNodeInsert(tree.root, x, y)
    return True

这个包装器保证我们总是以非空节点调用QuadTreeNodeInsert。代码还会检查插入的点是否在四叉树的边界内。这一点至关重要,因为均匀四叉树使用相同大小的区域,不能动态调整大小。所有的点必须落在根节点的空间边界内。如果点超出范围,代码会返回False,但是根据实现方式,您可能希望使用其他机制,如返回错误或抛出异常。

如下列代码所示,将点添加到节点的过程包括遍历树以找到新点的位置。这个搜索可以以两种方式之一结束:在叶子节点处或在内部死胡同处。如果我们在叶子节点处终止搜索,我们可以将新点添加到该处。根据我们的拆分标准(空间边界、最大深度和点的数量),我们可能需要将节点拆分为子节点。如果我们停在了内部死胡同处,那么我们找到了一个先前没有包含任何点的路径。我们可以创建合适的节点。

QuadTreeNodeInsert(QuadTreeNode: node, Float: x, Float: y):
  ❶ node.num_points = node.num_points + 1

    # Determine into which child bin the point should go.
  ❷ Float: x_bin_size = (node.x_max - node.x_min) / 2.0
    Float: y_bin_size = (node.y_max - node.y_min) / 2.0
    Integer: xbin = Floor((x - node.x_min) / x_bin_size)
    Integer: ybin = Floor((y - node.y_min) / y_bin_size)

    # Add the point to the correct child.
  ❸ IF NOT node.is_leaf:
      ❹ IF node.children[xbin][ybin] == null:
            node.children[xbin][ybin] = QuadTreeNode(
                  node.x_min + xbin * x_bin_size,
                  node.x_min + (xbin + 1) * x_bin_size,
                  node.y_min + ybin * y_bin_size,
                  node.y_min + (ybin + 1) * y_bin_size)
        QuadTreeNodeInsert(node.children[xbin][ybin], x, y)
        return

 # Add the point to a leaf node and split if needed.
  ❺ node.points.append(Point(x, y))
  ❻ IF we satisfy the conditions to split:
        node.is_leaf = False
      ❼ FOR EACH pt IN node.points:
            QuadTreeNodeInsert(node, pt.x, pt.y)
      ❽ node.num_points = (node.num_points -
                           length(node.points))
        node.points = []

代码首先通过递增num_points来表示新的点❶。然后,函数通过计算各个区域的大小,确定新点属于四个区域中的哪一个,并使用这些信息将 x 和 y 索引映射到01❷。如果节点不是叶子节点,代码需要递归地将点添加到正确的子节点❸。它首先检查子节点是否存在。如果不存在(如果子节点的指针是null),则创建子节点❹。我们利用xbinybin都只有01这一事实,简化逻辑。我们可以通过算术运算计算出子节点的边界,而无需枚举所有四种情况。最后,在叶子节点上,代码直接将点插入到节点中❺。

然而,我们还没有完成。代码需要检查是否满足拆分条件❻;如果满足,则拆分当前的叶子节点。幸运的是,我们可以重复使用相同的插入函数来进行拆分,就像我们用来添加点的函数一样。代码将节点标记为非叶子节点(node.is_leaf = False),并使用FOR循环将点逐个重新插入❼。由于当前节点不再是叶子节点,重新插入的点现在会通过到正确的子节点,并根据需要创建新的子节点。然而,由于我们对每个点都使用了该函数两次,我们必须修正num_points以避免重新插入的点被重复计算❽(因为在❶时进行了计数器递增)。代码还会清空新内部节点的点列表。

图 9-5 展示了将两个点添加到树中的情况。插入的开口圆形点导致一个节点在触发最大深度条件之前就发生了分裂。结果叶子节点包含两个点。插入的开口方形点则为其父节点的西北象限添加了一个新的子节点。由于最小点数条件,代码不会再次分裂。

如前一节所述,我们可以使用这种方法从一组点构建一个统一的四叉树。首先,我们创建一个具有必要空间边界的空根节点。然后,我们逐步添加集合中的每个点。由于每个维度的分割总是基于中点,因此树的结构不会因我们插入点的顺序而改变。

一张图,展示了两个点被添加到统一四叉树中的过程。开口圆点被插入到根节点的西北子节点和该子节点的西南子节点中。之前的叶子节点现在有两个点并且再次分裂。两个点都在同一个结果节点中,由于深度约束,这个节点是叶子节点。

图 9-5:在四叉树中添加两个点的示例,分别用阴影圆圈和阴影方块表示

移除点

从节点中移除点的过程类似于插入点,但更复杂。我们从叶子节点的列表中删除该点。然后,我们可以沿着树向上操作,移除不再符合标准的分裂。这可能涉及递归地提取每个节点子节点(它们自己可能也有子节点)中的点,并将它们合并到下一个叶子节点的单一列表中。

另一个额外的难点是确定删除哪个点。与网格一样,用户可能会插入任意接近的或甚至是重复的点。在下面的代码中,我们删除叶子节点列表中的第一个匹配点。由于浮点数错误(由于浮点变量精度有限而发生的四舍五入),我们也不能使用直接的相等测试。因此,我们使用一个辅助函数来找到足够接近的点。我们可以重用清单 8-3 中的approx_equal函数来进行此测试。

我们还使用一个辅助函数来合并不再符合分割标准的节点。该代码会合并一个有子节点的节点,并返回一个包含所有子树中点的数组:

QuadTreeNodeCollapse(QuadTreeNode: node):
  ❶ IF node.is_leaf:
        return node.points

  ❷ FOR i IN [0, 1]:
        FOR j IN [0, 1]:
            IF node.children[i][j] != null:
                Array: sub_pts = QuadTreeNodeCollapse(node.children[i][j])
                FOR EACH pt IN sub_pts:
                    node.points.append(pt)
                node.children[i][j] = null
  ❸ node.is_leaf = True
  ❹ return node.points

该代码首先检查当前节点是否已经是叶子节点,如果是,则直接返回点的数组❶。否则,节点是内部节点,代码需要从每个子节点聚合数据点。代码会遍历每个子节点,检查它们是否为null,如果不是,则递归调用QuadTreeNodeCollapse来聚合这些点❷。该函数最终通过将当前节点设置为叶子节点❸,并返回点❹来结束。

有了那个辅助函数后,我们可以继续处理删除功能。我们从包装函数开始。

QuadTreeDelete(QuadTree: tree, Float: x, Float: y):
    IF x < tree.root.x_min OR x > tree.root.x_max:
        return False
    IF y < tree.root.y_min OR y > tree.root.y_max:
        return False
    return QuadTreeNodeDelete(tree.root, x, y)

包装函数首先检查点是否位于树的边界内,因此可能在树中。如果是,它会调用递归删除函数。

递归删除代码按照搜索点的方式向树下方继续。当它到达叶节点时,如果点存在,它会删除该点。然后它会返回到上层节点,根据需要合并节点。如果删除了点,函数返回True

QuadTreeNodeDelete(QuadTreeNode: node, Float: x, Float: y):
  ❶ IF node.is_leaf:
        Integer: i = 0
      ❷ WHILE i < length(node.points):
          ❸ IF approx_equal(node.points[i].x, node.points[i].y, x, y):
                remove point i from node.points
                node.num_points = node.num_points - 1
                return True
 i = i + 1
        return False

    # Determine into which child bin the point to be removed would go.
  ❹ Float: x_bin_size = (node.x_max - node.x_min) / 2.0
    Float: y_bin_size = (node.y_max - node.y_min) / 2.0
    Integer: xbin = Floor((x - node.x_min) / x_bin_size)
    Integer: ybin = Floor((y - node.y_min) / y_bin_size)

  ❺ IF node.children[xbin][ybin] == null:
        return False

  ❻ IF QuadTreeNodeDelete(node.children[xbin][ybin], x, y):
        node.num_points = node.num_points - 1

      ❼ IF node.children[xbin][ybin].num_points == 0:
            node.children[xbin][ybin] = null

      ❽ IF node no longer meets the split conditions
            node.points = QuadTreeNodeCollapse(node)
        return True
  ❾ return False

代码首先检查递归是否已经到达叶节点❶。如果是,它会遍历点数组❷,检查每个点是否与目标点匹配❸。如果代码找到了匹配的点,它将从数组中移除该点,减少该节点中的点数,并返回True表示删除成功。请注意,每次只删除一个匹配的点。如果代码在叶节点没有找到匹配的点,它将返回False

搜索然后继续进入目标点应分配的桶❹。代码检查是否存在正确的子节点,如果不存在,返回False,表示该点不在树中❺。否则,代码递归地在子节点上调用 QuadTreeNodeDelete ❻。

如果递归调用 QuadTreeNodeDelete 返回 True,则代码已从某个子节点删除了一个点。它更新点的数量,并检查该子节点是否为空❼。如果为空,它删除子节点。然后代码检查当前节点是否仍符合内部节点的标准❽。如果不符合,它将合并该节点。代码返回 True,表示删除成功。如果递归调用没有返回 True,则没有删除任何点。函数最终返回 False❾。

搜索均匀四叉树

我们从根节点开始搜索四叉树。在每个节点,我们首先问该节点是否可能包含比我们当前候选点更接近的点。如果是,对于内部节点我们递归地探索子节点,对于叶节点我们直接测试点。然而,如果我们确定当前节点不能包含最近邻点,我们可以通过忽略该节点及其整个子树来修剪搜索。

我们使用与第八章中对网格单元应用的相同测试来检查节点内点的兼容性。我们拥有类似的信息:点的位置和区域的边界框。如前一章所述,距离计算公式为:

g09001

哪里

如果 x < x[min] 那么 x[dist] = x[min] – x

如果 x[min] ≤ xx[max] 那么 x[dist] = 0

如果 x > x[max] 那么 x[dist] = xx[max]

如果 y < y[min] 那么 y[dist] = y[min] – y

如果 y[min] ≤ yy[max] 那么 y[dist] = 0

如果 y > y[max] 那么 y[dist] = yy[max]

简而言之,我们正在检查搜索目标到节点空间区域中可能存在的最近点的距离。回顾一下上一章的例子,我们再次问:我们的懒惰邻居需要扔多远的球才能刚好把它扔回我们的院子里?这个检查只测试一个点在此距离内是否可能存在于节点中。我们需要探索该节点,以查看其中存在的点。

考虑搜索标记为 X 的点在图 9-6 中的最近邻。该图显示了与我们在图 8-3 中的原始咖啡馆位置地图相同的点分布,其中 X 是我们当前位置,其他点是附近的咖啡馆。

我们从根节点开始,使用一个具有无限距离的虚拟候选点。我们还没有看到任何候选邻居,因此我们需要一些东西来开始我们的搜索。使用一个虚拟的无限远点使得算法能够接受它找到的第一个点作为新的候选点,而无需任何特殊的逻辑。我们找到的任何点都将比无限远的点更近,并且每个区域都会包含更近的点。在寻找咖啡馆的例子中,这相当于从一个想象中的无限远的咖啡馆开始。我们列表中的任何真实咖啡馆都保证比这个虚拟点更近。

一组二维点。图中左上角有 11 个数据点和 1 个查询点。

图 9-6:最近邻搜索的示例数据集

由于我们的(虚拟)候选点的距离是无限的,因此我们对根节点的兼容性测试通过了。根节点中的至少一个点可能距离不再是无限远的。尽管这个测试在数学上与我们对网格单元使用的测试是相同的,但它有一个大的实际差异:我们测试的单元格的大小在树的每一层都不同。在较高层级,每个节点覆盖的空间较大。随着层级下降,空间范围变得更加紧凑。

我们根据子节点与查询点的接近程度来优先决定首先搜索哪个子节点。毕竟,我们最终是想找到最近的点并尽可能地进行剪枝。因此,我们考虑 x 和 y 的划分,并问:“我们的目标点会落入哪一个象限?”在这种情况下,我们的查询点位于西北象限,如图 9-7 所示,所以我们从这里开始。

我们沿着指针进入西北子节点,发现自己专注于空间和点的子集,如图 9-8 所示。灰色区域的节点表示尚未探索的节点,灰色的点表示尚未检查的点。

该图显示了根节点,线条指示它被划分为四个象限。查询点在西北象限中以 X 标记。

图 9-7:查询点位于四叉树根节点的西北象限内。

该图显示了四叉树的前三个级别。与根节点西北象限对应的子节点被圈出,表示搜索正在探索该节点。

图 9-8:如图 9-6 所示的例子中的最近邻搜索,从搜索根节点的西北象限对应的子树开始。

再次,我们的空间兼容性测试表明这个节点可能包含我们的最近邻。该节点中任何点的最小距离都比当前(假定的)候选点距离更小。我们处于另一个内部节点,这意味着空间进一步被划分成四个子区域。

我们的搜索继续到相关内部节点的西南子节点,如图 9-9 所示。我们选择这个节点,因为它离查询点最近。第三次,兼容性测试通过。由于我们处于叶子节点,因此我们显式检查该节点中每个点到查询点的距离。在这个例子中,只有一个点,它比我们的假定候选点更近。

该图显示了四叉树的前三个级别。三个节点是实心的:根节点、根节点的西北子节点,以及该子节点的西南子节点。当前搜索的节点被圈出。

图 9-9:在四叉树的第二级,我们的搜索从离目标点最近的象限开始,即西南象限。

我们找到了第一个真正的最近邻候选!它的距离成为迄今为止的最小距离。我们可以在未来的所有点中更加挑剔。在我们寻找附近咖啡店的例子中,这个第一个邻居代表了目前为止找到的最近咖啡店。到这个点的距离是我们为了喝一杯咖啡而需要走的最远距离。我们可能在搜索过程中找到更近的咖啡店,但至少我们不必走无穷远。松了一口气。

一旦我们测试了叶子节点中的所有点,我们会返回到(内部)父节点并检查其余子节点。现在我们有了一个真实的候选点和距离,我们的剪枝测试便有了实际意义。我们检查所有剩余子象限的兼容性:西北、东北和东南。我们的距离测试显示我们可以跳过西北象限:如图 9-10 所示,其空间范围内的最近可能点比我们已有的候选点更远。它不可能包含更好的邻居。

一个示意图,显示根节点西北子节点中的四个象限。从查询点到当前最佳节点的线表示阈值距离。到当前节点的西北子节点的最小距离也由一条较长的线表示。

图 9-10:一个示意图,显示当前节点的西北象限与迄今为止最佳候选点之间的相对距离

我们还可以跳过空的东北和东南象限。由于它们没有任何点,因此也没有更好的邻居。我们的搜索可以在不进行距离测试的情况下丢弃这两个象限,因为它们的指针将是null,表示不存在这样的子节点。如图 9-11 所示,我们成功地修剪掉了该节点的四个象限中的三个,灰色的象限表示已被修剪。东北象限中的两个数据点也保持灰色,因为我们从未对它们进行测试。

搜索已经返回到根节点的西北子节点。该节点的三个象限已被修剪,表示它们已从搜索中去除。

图 9-11:最近邻搜索能够跳过节点的四个象限中的三个。

一旦我们检查完内部节点中的象限,我们就返回到其父节点并重复这一过程。下一个最接近的象限是西南象限,我们的修剪测试确认它足够接近,可能包含一个更好的邻居,如图 9-12 所示。

每当我们发现一个子节点,根据我们的简单距离测试,可能包含一个更近的点时,我们会继续沿着这条路径向下检查,看看是否真的存在更近的邻居。在这种情况下,我们的搜索进入了西南象限。

根节点的修剪示意图。查询点显示为 X,并且一条线显示了到目前为止最佳邻居的距离。第二条线显示了到根节点西南子节点的(更小的)距离。

图 9-12:修剪测试,其中候选象限可能包含比当前最佳点更接近的邻居

在下一级别,四个潜在的象限再次争夺我们的注意力。带着一个真正的候选点及其对应的距离,我们可以积极地修剪我们的搜索,如图 9-13 所示。我们检查西北象限(及其唯一的点),因为它在我们的距离阈值内。我们可以跳过其他三个象限;东北和西南象限都是空的,因此它们有空指针,我们使用距离测试确认东南象限距离太远,无法包含更好的邻居。

这一次,当我们返回根节点时,我们可以修剪掉剩余的两个子节点,如图 9-14 所示。

东北和东南象限的远端区域远远超出了我们的距离阈值。

搜索已经遍历了整个分支,对应于根节点的西南子节点。该子节点的四个象限中的三个已被修剪。

图 9-13:在检查根节点的西南象限时,最近邻搜索再次能够跳过四个子象限中的三个。

搜索已返回到根节点。根节点的两个象限已被灰色标出。剩余的 11 个数据点中有 9 个仍为灰色。

图 9-14:返回四叉树的根节点时,搜索可以跳过该节点的两个象限。

最近邻搜索代码

为了简化我们最近邻搜索代码的实现,我们首先编写一个辅助函数来计算目标点(xy)到一个节点的距离。检查节点最小距离的代码与上一章中为网格展示的最小距离代码类似:

MinDist(QuadTreeNode: node, Float: x, Float: y):
    Float: x_dist = 0.0
    IF x < node.x_min:
        x_dist = node.x_min – x
    IF x > node.x_max:
        x_dist = x – node.x_max

    Float: y_dist = 0.0
    IF y < node.y_min:
        y_dist = node.y_min – y
    IF y > node.y_max:
        y_dist = y – node.y_max

    return sqrt(x_dist*x_dist + y_dist*y_dist)

然而,在这种情况下,代码无需计算节点的最小和最大边界,因为每个节点都显式地存储了这些信息。

主要的搜索算法采用与其他基于树的方法相同的递归形式。我们对这个搜索算法的实现包括一个参数best_dist,表示当前的最小距离。通过将best_dist传递给我们的搜索函数,我们可以简化剪枝逻辑。如果当前节点的最小距离大于到目前为止的最佳距离,我们可以终止该分支的搜索。然后,如果找到了更接近的点,函数将返回一个更近的点,否则返回null。需要注意的是,在这个实现中,返回值为null意味着当前节点中没有比best_dist更近的点。

我们使用一个简单的包装函数,传递根节点和初始的无限距离:

QuadTreeNearestNeighbor(QuadTree: tree, Float: x, Float: y):
    return QuadTreeNodeNearestNeighbor(tree.root, x, y, Inf)

我们的最近邻搜索包装函数并不会检查目标点是否位于四叉树的边界内。这使得我们可以使用该代码在四叉树外部的目标点找到邻居,增加了代码的实用性。

这是递归搜索节点的代码:

QuadTreeNodeNearestNeighbor(QuadTreeNode: node, Float: x,
                            Float: y, Float: best_dist):
    # Prune if the node is too far away. 
  ❶ IF MinDist(node, x, y) >= best_dist:
        return null
    Point: best_candidate = null

 # If we are in a leaf, search the points.
  ❷ IF node.is_leaf:
        FOR EACH current IN node.points:
            Float: dist = euclidean_dist(x, y, current.x, current.y)

            IF dist < best_dist:
                best_dist = dist
                best_candidate = current
        return best_candidate

    # Recursively check all 4 children starting with the closest.
  ❸ Float: x_bin_size = (node.x_max - node.x_min) / 2.0 
    Float: y_bin_size = (node.y_max - node.y_min) / 2.0 
    Integer: xbin = Floor((x - node.x_min) / x_bin_size)
    IF xbin < 0:
        xbin = 0
    IF xbin > 1:
        xbin = 1

    Integer: ybin = Floor((y - node.y_min) / y_bin_size)
    IF ybin < 0:
        ybin = 0
    IF ybin > 1:
        ybin = 1

  ❹ FOR EACH i IN [xbin, (xbin + 1) % 2]:
        FOR EACH j IN [ybin, (ybin + 1) % 2]:
            IF node.children[i][j] != null:
                Point: quad_best = QuadTreeNodeNearestNeighbor(
                                       node.children[i][j], 
                                       x, y, best_dist)
              ❺ IF quad_best != null:
                    best_candidate = quad_best
                    best_dist = euclidean_dist(x, y, quad_best.x, 
                                               quad_best.y)
    return best_candidate

该函数首先进行剪枝测试,如果没有任何点比best_dist更接近,则跳过该节点并返回null ❶。然后,代码检查是否到达了叶子节点 ❷。如果已经到达叶子节点,它使用FOR循环检查每个点是否比best_dist更接近,如果是,则更新best_distbest_candidate。在这个循环结束时,我们返回best_candidate,如果没有找到更接近的点,它的值将是null

下一段代码处理内部节点的逻辑。只有在节点不是叶节点并且没有候选点时,我们才会到达这里。一些基本的数值测试和整数操作控制代码搜索子节点的顺序,允许代码先搜索最接近的子节点,然后扩展到其他子节点。代码首先计算候选点应该落入哪个 x 和 y 的区间 ❸,并调整值,使得xbinybin都落在[0, 1]范围内,从而指示最接近的子节点。这个调整是必要的,因为对于许多内部节点,我们的目标点完全可能位于当前节点表示的 2×2 网格之外。

然后,我们使用一对嵌套的FOR循环递归地探索非空子节点,迭代对 ❹。每次我们检查是否找到了更近的点(由quad_best != null表示),如果找到了,就更新best_candidatebest_dist ❺。在函数结束时,我们返回best_candidate。与叶节点的情况类似,如果我们没有找到比原始best_dist更近的点,best_candidate可能为null

k-d 树

我们已经解决了二维动态拆分的问题,现在是时候将注意力转向使用 k-d 树在三维或更高维度中搜索点或最近邻了。我们已经看到,如果我们没有考虑到咖啡中的所有相关属性,结果可能会令人失望。高维问题在我们寻找数据集中的相似点时非常常见,比如在天气数据集中寻找相似的条件(温度、气压、湿度)。

从理论上讲,我们可以通过沿着更多维度进行拆分来扩展四叉树。例如,八叉树是三维版本,每一层拆分成八个子节点。八路拆分看起来可能还不错,但这种方法显然在维度数增多时并不会优雅地扩展。如果我们想要在D维数据上构建一棵树,我们需要在所有D维度上同时拆分,这样每个内部节点就会有 2^(D)个子节点。如果我们正在构建一个包含温度、气压、湿度、降水量和风速的天气数据结构,那么我们就需要使用五维数据点,并在每一层拆分出 32 个子树!这种巨大的开销就是我们在将网格扩展到更高维度时遇到的相同问题。

为了有效地扩展到更高维度,我们需要限制分支因子。为此,已经设计了多种强大的数据结构,以实现高维度中的高效邻近搜索。其中一个例子是 k-d 树,它基于与四叉树类似的概念。

k-d 树结构

k-d 树 是一种空间数据结构,它将四叉树的空间划分与二叉搜索树的二叉分支因子结合在一起,为我们提供了两者的优点。k-d 树不是在每一层沿着每个维度进行划分,而是选择一个单一的维度,并沿着该维度对数据进行划分。因此,每个内部节点将数据划分为恰好两个子节点:一个左子节点,其数据点在划分值以下(或等于),另一个右子节点,其数据点在划分值以上。

在使用 k-d 树时,我们失去了四叉树那种规则的网格状结构,但作为交换,我们回到了二叉搜索树中我们熟悉并喜爱的二叉分支因子。这种在单一维度上进行划分的能力,反过来又使得 k-d 树能够扩展到更高维的数据集。我们不再需要在每一层进行 2^(D) 个子节点的划分。图 9-15 展示了一个 k-d 树的示例。

k-d 树的根节点沿着 y 维度进行划分,两个结果子节点沿着 x 维度划分。

图 9-15:k-d 树在每一层沿着单一维度进行划分

k-d 树比四叉树更能灵活地调整数据的结构。我们不再受到每次在每个维度上都必须在中点进行划分的限制。我们可以选择在每个节点上最适合数据的划分维度和值,而不是将空间划分为 2^(D) 个相等大小的网格单元。每个内部节点因此会存储划分维度(split_dim)和值(split_val),如果数据在该维度的值小于或等于节点的划分值,则将该点分配给左子节点:

pt[split_dim] <= split_val

我们不再像四叉树那样在交替的坐标轴上进行划分(如 图 9-15 所示),而是通过选择根据当前节点中数据点的组成来进行划分,从而量身定制我们的树结构,这样可以使得未来的搜索更加高效。我们可能会根据节点的整体范围来选择划分值,比如在最宽维度的中间进行划分。或者我们可以根据数据点的分布来选择划分值,比如在范围的中间或数据点值的中位数处进行划分。这两种选择的差异如 图 9-16 所示。

左边的框显示在 x 维度的中间对数据点进行划分,将空间一分为二。右边的框显示在 x 维度的中位数处划分, resulting in two boxes with an approximately equal number of points but different sizes.

图 9-16:节点在中间(左)和中位数(右)处划分

k-d 树的灵活结构意味着我们在处理节点的空间边界时需要额外小心。与统一的四叉树的方形网格单元不同,k-d 树的节点覆盖了整体搜索空间的多维矩形。每个维度的宽度可以完全不同。有些节点可能是接近正方形的,而其他节点可能是细长的。我们通过显式跟踪定义其空间边界的多维矩形来表示节点的区域——每个维度的最小值和最大值。由于我们可以有任意数量的维度,我们将边界存储在两个数组x_minx_max中,其中x_min[d]表示当前节点中维度d的最低值,x_max[d]表示最高值。节点内的所有点满足:

x_min[d] <= pt[d] <= x_max[d] FOR ALL d

由于其复杂性,每个 k-d 树节点存储了大量信息。虽然这初看起来可能像是昂贵的开销,但正如我们将在本节中看到的,这种成本是通过树本身的强大功能和灵活性来抵消的。与本书中的其他数据结构一样,我们在内存、数据结构复杂性和后续算法效率方面做出了明确的权衡。

这是一个KDTreeNode的复合数据结构示例:

KDTreeNode {
    Boolean: is_leaf
    Integer: num_dimensions
    Integer: num_points
    Array of Floats: x_min
    Array of Floats: x_max
    Integer: split_dim
    Float: split_val
    KDTreeNode: left
    KDTreeNode: right
    Array of Arrays: points
}

在这种情况下,我们使用一个数组来表示每个点,使其能够具有任意维度。

与本书中的其他树一样,我们可以将节点包装在一个外部数据结构中,比如KDTree

KDTree {
    Integer: num_dimensions
    KDTreeNode: root
}

在这个包装数据结构中存储维度数量对于检查一致性非常有帮助,尤其是在插入、删除或搜索等操作时。num_dimensions的值在 k-d 树创建时设置,并且在此树的整个生命周期中保持固定。

选择分割节点策略的灵活性展示了 k-d 树的真正强大之处:我们增强了四叉树的空间划分,以进一步适应数据。如果我们的点是集中的,我们选择通过关注这些区域来提供最多信息的分割。图 9-17 展示了这种动态划分。

一张 k-d 树的图示,水平或垂直线表示每个级别的不同划分。每个子划分具有不同的维度。

图 9-17:k-d 树创建的空间划分

考虑我们正在进行的任务——定位附近的咖啡店。如果我们沿着 95 号州际公路从佛罗里达到缅因州进行公路旅行,我们可以预处理数据,仅存储离高速公路 50 英里内的咖啡店。图 9-18 显示了这种形状的分布示例。

这种预过滤有助于将搜索空间限制在高速公路附近的咖啡店,但我们可以通过将这些位置存储在空间数据结构中,使搜索更加高效。我们还希望将搜索范围缩小到高速公路沿线的适当区域。毕竟,当我们还在南卡罗来纳州时,没有必要检查马萨诸塞州的咖啡店。我们很快发现,均匀的划分方案远非理想:我们的行程覆盖了超过 1,500 英里的大部分北行路程。由于我们已经筛选出仅沿高速公路的咖啡店,因此通过东西方向的均匀划分无法带来太多的剪枝效果。通过沿南北方向偏向划分,我们可以增加剪枝的数量,从而降低搜索成本。

另一个类比是,如果四叉树是城市规划师按照严格规定划分地图的日常工作,那么 k-d 树则代表了一个拥有更多工具选择的城市规划师。他们不再受限于将每个地块划分为完美的正方形,而是有更多灵活性,根据实际点的分布来划分空间。我们的城市规划师选择最宽的维度进行划分,以最小化狭长区域的出现。他们还使用此维度的中位点来提供平衡的树结构。结果可能不会像四叉树的正方形那样整齐,但它通常会更有效。

沿着 95 号公路形状分布的点的散点图。

图 9-18:咖啡店在主要高速公路上的分布示例

更紧密的空间边界

我们通常可以通过追踪给定节点内所有点的边界框,而不是节点的总空间边界,进一步提高空间树的剪枝能力。这将使我们的剪枝问题从“最近邻候选点是否可能存在于节点所覆盖的空间中?”转变为“最近邻候选点是否可能存在于节点内实际点的边界框中?”虽然这看似是一个小的变化,但根据我们用来分割节点的逻辑,可能会看到显著的节省,如图 9-19 所示。

k-d 树节点的紧密边界框显示为一个虚线框,框住节点内的所有点。

图 9-19:k-d 树节点内点的边界框

如果我们在节点构建过程中使用这些更紧密的边界框,就能更好地适应数据。我们不再基于整个可能的空间区域进行划分,而是根据实际点占据的区域进行划分。由此生成的 k-d 树结构如图 9-20 所示。黑色框表示每个节点的紧密边界框。灰色的点和线则显示了节点边界与其余数据集的关系。

k-d 树中的每个节点显示为一组点,紧致的边界框覆盖在灰色显示的完整数据集上。

图 9-20:一个三层 k-d 树以及每个节点的边界框

在搜索过程中,我们使用黑框(紧致边界框)进行剪枝。正如你从图中看到的那样,最终的区域可以显著变小,这使得我们能够更加积极地进行剪枝。由于紧致边界框较小,它通常会与查询点之间有更大的最小距离。

我们可以使用一个简单的辅助函数从一组以数组表示的点计算紧致的边界框(示例 9-1):

ComputeBoundingBox(Array of Arrays: pts):
  ❶ Integer: num_points = length(pts)
    IF num_points == 0:
        return Error
    Integer: num_dims = length(pts[0])

  ❷ Array: L = Array of length num_dims
    Array: H = Array of length num_dims
    Integer: d = 0
  ❸ WHILE d < num_dims:
        L[d] = pts[0][d]
        H[d] = pts[0][d]
        d = d + 1

    Integer: i = 1
  ❹ WHILE i < num_points:
        d = 0
        WHILE d < num_dims:
            IF L[d] > pts[i][d]:
                L[d] = pts[i][d]
            IF H[d] < pts[i][d]:
                H[d] = pts[i][d]
            d = d + 1
        i = i + 1
  ❺ return (L, H)

示例 9-1:一个辅助函数,用于计算节点中点的紧致边界框

这段代码提取输入数据中的点数和维度数❶。然后,它创建新的数组 LH 来分别存储低和高边界❷,并用数组中第一个点的坐标初始化这些边界❸。接着,代码遍历数组中剩余的点,检查它们是否超出了边界❹。如果是,它会扩展边界。最后,代码返回这两个数组❺。

这个辅助函数还展示了在 KDTree 的包装函数中预先检查所有点是否包含正确的维度数的好处。这个检查确保我们不会尝试访问无效的数组条目来获取数据点。

当然,跟踪这些附加的边界会增加相当大的复杂性,特别是在我们开始处理动态变化时。向 k-d 树中添加点可能会增加给定节点的边界。类似地,移除节点可能会缩小边界。

构建 k-d 树

构建 k-d 树采用递归过程,这个过程与二叉搜索树类似,但有几个主要的区别。我们从所有数据点开始,通过选择一个划分维度和值将它们分成两个子集。这个过程在每个层级上重复,直到满足终止条件:节点上的最小点数、最小宽度或最大深度。通常,我们使用两个测试条件,最小点数和最小宽度,但至少使用最后两个条件中的一个是非常重要的,这样可以避免在数据点重复时导致无限递归。

我们从一个包装函数开始,检查我们的数据是否有效:

BuildKDTree(KDTree: tree, Array of Arrays: pts):
  FOR EACH pt IN pts:
      IF length(pt) != tree.num_dimensions:
          Return an error.
  IF length(pts) > 0:
      tree.root = KDTreeNode()
      RecursiveBuildKDTree(tree.root, tree.num_dimensions, pts)
  ELSE:
      tree.root = null

这段代码首先检查所有点是否具有正确的维度。然后,它检查是否有点可用于构建树。如果有,它会分配一个新的根节点(覆盖先前的树)并使用下面的函数递归构建 k-d 树。否则,它会将根节点设置为 null,表示空树。

k-d 树与四叉树构建的主要区别在于,k-d 树要求我们在每一层选择一个单一的分割维度。如果我们使用紧密的边界框,还需要计算 D 维度的边界框。虽然这些变化使得代码稍微长一些(有额外的 D 循环),但它们并没有增加实质性的复杂度。构建 k-d 树的代码递归地将我们的点集分配到子节点,直到满足终止条件。

RecursiveBuildKDTree(KDTreeNode: node, Integer: num_dims,
                     Array of Arrays: pts):
  ❶ node.num_points = length(pts)
    node.num_dimensions = num_dims
    node.left = null
    node.right = null
    node.points = empty array
    node.split_dim = -1
    node.split_val = 0.0
    node.is_leaf = True

    # Compute the bounding box of the points.
  ❷ (node.x_min, node.x_max) = ComputeBoundingBox(pts)

    # Compute the width of the widest dimension.
  ❸ Float: max_width = 0.0
    Integer: d = 0
    WHILE d < node.num_dimensions:
        IF node.x_max[d] - node.x_min[d] > max_width:
            max_width = node.x_max[d] - node.x_min[d]
        d = d + 1
 # If we meet the conditions for a leaf, append the
    # remaining points to the node's point list.
  ❹ IF we do not satisfy the conditions to split:
        FOR EACH pt IN pts:
            node.points.append(pt)
        return

    # Choose split dimension and value.
  ❺ node.split_dim = chosen split dimension
    node.split_val = chosen split value along node.split_dim
    node.is_leaf = False

    # Partition the points into two sets based on
    # the split dimension and value.
    Array of Arrays: left_pts = []
    Array of Arrays: right_pts = []
  ❻ FOR EACH pt IN pts:
        IF pt[node.split_dim] <= node.split_val:
            left_pts.append(pt)
        ELSE:
            right_pts.append(pt)

    # Recursively build the child nodes.
  ❼ node.left = KDTreeNode()
    RecursiveBuildKDTree(node.left, num_dims, left_pts)

    node.right = KDTreeNode()
    RecursiveBuildKDTree(node.right, num_dims, right_pts)

这段代码首先进行簿记操作,填充每个节点所需的信息。我们记录下当前点集的基本信息,如点的数量和维度数量 ❶。然后,函数遍历所有点,使用清单 9-1 中的辅助函数计算该节点的紧密边界框 ❷。

一旦我们获得了边界,代码就会遍历每个维度来找到最宽的维度 ❸。我们使用这个循环作为递归的停止条件之一(如果一个节点太小则不再分割)。如果节点不满足继续分割的条件,代码将把所有点存储在叶节点的列表中 ❹。否则,代码会为该节点选择一个分割维度和分割值 ❺。然后,代码遍历当前点集,将其根据分割维度和值划分为两个数组left_ptsright_pts ❻。这两个数组会被用来递归地构建两个子节点 ❼。

选择split_dimsplit_val的一种方法是沿着最宽的维度进行分割。这个方法的代码相对简单,大部分代码可以整合到最初找出最宽维度的代码块中 ❸:

 Float: max_width = 0.0
    Integer: split_dim = 0
    Integer: d = 0
 WHILE d < node.num_dimensions:
        IF node.x_max[d] - node.x_min[d] > max_width:
            max_width = node.x_max[d] - node.x_min[d]
            split_dim = d
        d = d + 1

然后在 ❺ 处设置分割维度和值:

 node.split_dim = split_dim
    node.split_val = (node.x_min[node.split_dim] + 
                      node.x_max[node.split_dim]) / 2.0

批量构建 k-d 树相对于动态插入和删除点具有显著的优势。在构建过程中考虑所有数据点,我们可以更好地使树的结构适应数据。我们根据所有数据点选择分割,而不是仅根据已经插入的子集。

k-d 树操作

插入点、删除点和搜索 k-d 树的基本操作与四叉树类似。我们从树的顶部(根节点)开始所有操作,并使用分割值来导航到相应的分支。主要的不同之处在于,我们不再选择探索哪个四个象限,而是使用split_dim并根据split_val来选择两个子节点中的一个。由于这些高层次的概念与四叉树中呈现的相似,我们将不会详细讲解每一段代码。相反,我们来看看其中的一些不同之处。

  1. 插入操作 在将点插入 k-d 树节点时,我们使用 split_dimsplit_val 来决定选择哪个分支。如果叶节点满足我们的分割条件,我们就像在大规模构建时一样进行分割。最后,如果我们跟踪每个节点的紧密边界框,我们需要更新边界以考虑新添加的点。由于是添加点,这个更新会始终增加边界框的大小。

     Integer: d = 0
        WHILE d < node.num_dimensions:
            IF x[d] < node.x_min[d]:
                node.x_min[d] = x[d]
            IF x[d] > node.x_max[d]:
                node.x_max[d] = x[d]
            d = d + 1
    

    这段代码会遍历新点的每个维度,检查该维度的点是否超出了边界框,如果超出了,则更新边界框。

  2. 删除操作 在删除 k-d 树中的点时,我们使用 split_dimsplit_val 来决定在搜索点时应该选择哪个分支。删除节点后,我们返回到树的根节点。在路径中的每个节点上,我们检查是否可以缩小边界(使用叶节点中的点或内部节点的两个子节点的边界框)。我们还检查是否可以将内部节点合并。

  3. 搜索 操作之间的关键区别是,四叉树和 k-d 树在搜索操作中是否能够修剪节点。例如,我们可以使用扩展的公式 i09001 来计算点 x 和节点(非均匀,D维度)边界框中最接近点的欧几里得距离,这与我们用于四叉树和网格的公式类似。我们首先计算从点到节点的空间边界在每个维度上的最小距离:

如果 x[d] < x[min][d],则 dist[d] = x[min][d] − x[d]

如果 x[min][d] ≤ xx[max][d],则 dist[d] = 0

如果 x[d] > x[max][d],则 dist[d] = x[d] − x[max][d]

  1. 其中 x[d] 表示查询点的第 d 维度,x[min][d] 和 x[max][d] 分别表示节点在第 d 维度上的低和高边界。然后我们计算每个维度上的平方距离之和,并取平方根。我们可以通过在各维度上执行 WHILE 循环来实现这个计算过程:

    KDTreeNodeMinDist(KDTreeNode: node, Point: pt):
        Float: dist_sum = 0.0
        Integer: d = 0
        WHILE d < node.num_dimensions:
            Float: diff = 0.0
            IF pt[d] < node.x_min[d]:
                diff = node.x_min[d] - pt[d]
            IF pt[d] > node.x_max[d]:
                diff = pt[d] - node.x_max[d]
            dist_sum = dist_sum + diff * diff
            d = d + 1
        return sqrt(dist_sum)
    

请注意,k-d 树比四叉树对增加和删除操作更敏感。虽然两种树由于分割规则和点的分布可能会变得不平衡,但 k-d 树的分割是基于当时的数据来选择的。如果我们显著改变点的分布,原来的分割值可能不再适用。在大规模构建时,我们可以根据当前的数据来调整分割,考虑如树的深度、是否平衡以及节点空间边界的紧凑性等因素。这揭示了数据结构的另一个权衡问题——随着数据的变化,结构的性能可能会下降。

为什么这很重要

四叉树和 k-d 树是我们如何将动态数据结构的力量与空间结构结合以解决搜索问题的例子。通过同时在多个维度上分支,四叉树使我们能够根据局部区域数据点的密度调整网格的分辨率。高密度区域会导致更深的分支,从而形成更精细的网格。同时,保持规则网格结构会带来更高维度的额外开销。研究四叉树、八叉树及其变种如何在不同数据集上扩展,为我们如何利用空间结构提供了一个重要的视角。

k-d 树代表了我们在过去几章中构建的概念的结合,用于解决最近邻搜索问题。它通过回归到二叉搜索树的核心概念,并选择一个维度在每个节点进行划分,从而解决了高分支因子的问题。此外,它还允许更多的灵活性以适应数据的结构,增强了剪枝能力。

四叉树和 k-d 树并不是唯一能促进最近邻搜索的数据结构。还有许多其他基于树和非树的方案。空间数据结构这一话题足以写成多本书。就像计算机科学中的几乎所有事物一样,每种方法在程序复杂度、计算成本和内存使用方面都有各自的权衡。对于本书的目的,四叉树和 k-d 树是如何将最近邻搜索的空间剪枝与基于树的结构相结合的优秀例子,从而使空间树能够适应手头的数据。

第十章:哈希表

本章介绍了哈希表,这是一种针对插入和查找进行了高度优化的动态数据结构。哈希表使用数学函数指引我们到数据的位置。在纯存储的情况下,它们特别有用,目标是快速找到和检索信息。

这是我们在咖啡储藏室中可能需要做出的权衡。忘记试图按过期日期或口感排序咖啡吧——我们是真正的咖啡爱好者,能够轻松记住我们储藏室中每一颗咖啡豆的最小细节。对于任何给定的属性(或属性组合),我们都能立即记住咖啡的名称。当我们走到咖啡储藏室时,我们已经决定了要喝哪种咖啡。将咖啡按这些其他维度排序存放只会拖慢我们的速度。我们需要的是高效的检索:只要给出我们想喝的咖啡的名字,我们就希望能够最小化努力地找到这些咖啡豆。哈希表正是通过名称实现这种快速检索的。

数组提供了一种紧凑的结构,用于存储单个数据项,并提供了一种高效的检索机制——但前提是我们知道该项的索引。有了索引,我们可以在常数时间内查找任何元素。正如我们在第二章中看到的,如果没有索引,查找数组中的项目就变得更加复杂。如果我们只有项的值,那么我们需要遍历数组来找到它的正确位置。只有通过二分查找,我们才能高效地查找项目,但前提是保持数组的排序顺序,这会导致插入和删除操作效率低下。

在前几章中,我们探讨了数据结构和算法,以高效地搜索目标值。想象一下,如果我们能构建一个神奇的函数,将目标值直接映射到它的索引(当然有一些前提条件)。这就是哈希表的核心思想。哈希表使用数学函数来计算数据结构中值的索引,从而使我们能够直接从值映射到数组的桶中。缺点是没有任何映射是完美的。我们将看到不同的值如何映射到同一个位置,导致碰撞。然后我们将探讨解决碰撞的两种方法。

与所有数据结构一样,哈希表并不是解决所有问题的完美方案——我们将探讨它的优缺点,包括内存使用和最坏情况性能。在这个过程中,我们将研究一种通过使用数学映射来组织数据的新方式。

使用键的存储和搜索

在我们深入了解哈希表的工作原理之前,让我们考虑一个理想化的索引方案,以高效地检索整数值——为每个可能的值维护一个独立的数组槽,并用该值本身来索引该槽。这个结构如图 10-1 所示。为了插入值 9,我们只需将其放入索引为 9 的槽中。在这种安排下,我们可以以常数时间插入或检索项目。

一个包含十个槽的数组,编号从 0 到 9。槽 1、5 和 7 指向数组外的数据。其余槽有斜线表示它们是空的。

图 10-1:一个为每个潜在条目分配槽的大型数组

我们理想化的数据结构的明显缺点是维护每个可能键的数组的荒谬成本。考虑存储所有可能的 16 位数字信用卡号的情况。我们需要几个千万亿个槽,准确地说是 10¹⁶个。这是一个庞大的内存需求。更糟糕的是,我们甚至不太可能使用这么多槽。如果我们在编写一个程序来跟踪一个 1000 人公司的企业信用卡,我们只需要一个很小的部分槽——我们分配的 10¹³个槽中的一个。其余的都是浪费。它们空着,希望某天能存储数据。同样,我们也不希望为每一本可能的书籍保留一个图书馆的位置,为每位可能的顾客保留一个酒店房间,或者为每种已知的咖啡在咖啡储藏室中预留一个位置。这太荒谬了(也许咖啡例外)。

但是,作为一种思维实验,让我们考虑这个理想化的数据结构如何适用于其他类型的数据。我们立刻遇到的问题是,如何为更复杂的数据类型(如字符串或甚至复合数据类型)选择值。假设我们要创建一个简单的咖啡记录数据库。第三章展示了如何使用指针数组来存储此类动态大小的数据,如以下代码所示:

CoffeeRecord {
    String: Name
    String: Brand
    Integer: Rating
    FloatingPoint: Cost_Per_Pound
    Boolean: Is_Dark_Roast
    String: Other_Notes
    Image: Barcode
}

我们仍然可以将所有项目放入一个单一的大型数组中,为每个可能的条目设置一个槽。在这种情况下,槽中不仅包含单个值,还包含指向更复杂数据结构的指针,如图 10-2 中的指针数组。

一个包含十个槽的数组,编号从 0 到 9。每个槽包含一个指向数组外部数据的箭头。

图 10-2:一个指针数组

然而,这仍然留下了如何进行实际查找的问题。如果我们想要查找我们为“Jeremy’s Gourmet High-Caffeine Experience: Medium Roast”——第六章介绍的一款咖啡——给出的评分,我们无法将整个复合数据结构作为值。我们并没有手头上所有这些信息。即使我们手头有完整的信息,也不清楚如何使用复合数据结构甚至是字符串作为索引。

计算机程序通常使用键来标识记录。 是与数据本身一起存储或从数据中派生出来的单一值,可以用来标识一条记录。在 RSVP 列表中,键可能是包含受邀者姓名的字符串;在我们的咖啡记录中,键可以是咖啡的名称或条形码。在许多数据结构中,从排序数组到字典树,我们使用键来组织数据。对于本书早期的数字示例,键就是数值本身。对排序数组或二叉查找树进行的每一次搜索,都是通过查找匹配的数字键来获取记录。类似地,第六章介绍的字典树使用字符串作为键。

然而,这并没有解决索引问题。除非我们有整数键,否则我们仍然无法索引数组箱子。数组中并没有一个箱子,其索引为“Jeremy’s Gourmet High-Caffeine Experience: Medium Roast”。我们可以在数据结构中进行搜索,寻找一个具有匹配键的记录。线性扫描和二分查找都可以这样工作,使用目标值作为键。然而,我们失去了理想化数据结构的魔力。我们又回到了搜索匹配键的过程。

在某些情况下,我们可能能够找到一个自然的数字键来表示我们的记录。在咖啡的例子中,我们可以按照第一次品尝的时间顺序列出我们品尝过的每一款咖啡,并将相应的日期作为键。如果“Jeremy’s Gourmet High-Caffeine Experience: Medium Roast”是在 2020 年 1 月 1 日首次品尝的(假设我们记得这一点),我们可以通过二分查找来检索该记录。或者,我们也可以使用咖啡的条形码或它在《世界咖啡、品牌与制造商大全》中的页码。

更一般地,我们希望有一个函数能够根据我们的键生成一个索引。在接下来的章节中,我们将介绍哈希函数,它正是解决这个问题的。

哈希表

哈希表使用数学映射来压缩键空间。它们通过使用哈希函数将原始键映射到表中的位置(也叫做哈希值),将大的键空间压缩到一个较小的范围内。我们用哈希函数 f(k) 表示将键 k 映射到包含 b 个箱子的表中,范围为[0, b − 1]*。这个映射解决了前一部分的两个问题。我们不再需要无限数量的箱子,只需要 b 个箱子。正如我们将看到的,函数也可以将非整数映射到数字范围,从而解决非整数键的问题。

一个简单的哈希函数示例是使用除法法则,根据数字键计算哈希值。我们将键除以箱子的数量并取余数:

f(k) = k % b

其中 % 是取模操作。每一个可能的(整数)键都被映射到正确范围[0, b − 1]*内的单一箱子中。例如,对于一个包含 20 个箱子的哈希表,这个函数会生成如表 10-1 所示的映射。

表 10-1:使用 20 个桶的除法法哈希映射示例

k f(k)**
5 5
20 0
21 1
34 14
41 1

考虑将所有信用卡号映射到 100 个桶的问题。除法法将键空间从 16 位数字压缩到 2 位数字,方法是使用卡号的最后两位数字。当然,这种简化的映射对于某些键分布可能并不会产生最佳效果。如果我们有许多以 10 结尾的信用卡,它们都会映射到同一个桶。然而,这也解决了我们的核心问题之一:通过一次(且高效的)数学运算,我们将一个大范围的键压缩到有限数量的索引中。

怀疑的读者可能会对上述描述产生疑问:“我们不能将两个不同的项目存储在数组的同一个元素中。你在第一章中就告诉过我们这一点。而哈希函数显然可以将两个不同的值映射到同一个桶。看看表 10-1。21 和 41 都映射到了桶 1。”这就是前面提到的警告。遗憾的是,哈希函数并非真正的神奇法宝。正如我们将在下一节看到的那样,这种复杂性正是哈希表其他结构的作用所在——用来处理冲突。目前,我们可以注意到哈希函数将我们的键分成不相交的集合,我们只需要担心集合内部的冲突。

哈希函数不仅限于数字。我们也可以定义一个哈希函数,将咖啡的名称映射到一个桶。这允许我们通过两步直接访问任何条目的咖啡记录,如图 10-3 所示。首先,我们使用咖啡的名称计算其哈希值。键“House Blend”映射到值 6;我们将在本章稍后描述一种简单的字符串哈希方法。其次,我们通过使用哈希值作为索引,查找表中的哈希值。我们甚至可以使用这种方案将我们庞大的现实世界咖啡收藏映射到咖啡储藏室的固定数量的架子上。

左侧显示了一列咖啡名称,如“House Blend”和“Morning Shock”。中间列包含哈希函数,将字符串映射到数字值。House Blend 映射到 6。箭头指向右侧列中对应桶的哈希函数。

图 10-3:哈希函数将字符串映射到数组中的索引

哈希表的一个现实世界例子就是我们生活中经常看到的注册表,无论是在夏令营的第一天,比赛的早晨,还是学术会议的开始。要存储的项目(注册包)根据其键(姓名)分配到唯一的桶中。人们可以通过应用一个哈希函数找到正确的桶,这个哈希函数通常很简单,比如将字母范围映射到某一行。以 A−D 开头的名字放到第 1 行,E−G 开头的名字放到第 2 行,以此类推。

冲突

即使是世界上最好的哈希函数,也无法提供完美的一一映射,将键映射到桶中。为了做到这一点,我们需要回到那个巨大的数组及其过度使用的内存。任何将大量键映射到较小值集合的数学函数,都可能会遇到偶尔的冲突——即两个键映射到相同的哈希值。想象一下,如果我们通过提取车牌上的第一个数字将车牌映射到 10 个停车位上。我们并不需要同事的车牌与自己的车牌完全匹配,就能因为停车位争执起来。假设你去注册你的车,车牌号是“Binary Search Trees Are #1”,结果发现同事已经用车牌“100,000 Data Structures and Counting”占用了那个车位。两者的哈希值可能恰好都为 1,因此它们会被分配到同一个位置。

我们在会议的注册桌前排队,就是因为有了冲突。考虑一个基于姓氏首字母将人分配到八个注册台的情况。姓氏首字母为 A−D 的人去第 1 台,姓氏首字母为 E−G 的人去第 2 台,以此类推。如果与会者人数超过几位,我们几乎可以保证会发生冲突。如果没有冲突,每个人都会有自己检查的地方。相反,姓氏为 A−D 的与会者将站在同一队伍里,因为他们的键(姓氏)发生了冲突。

我们可以通过增大哈希表的大小或选择更好的哈希函数来缓解一些冲突。然而,只要我们的键空间大于桶的数量,就不可能完全消除冲突。我们需要一种优雅的方式来处理两个数据项争抢同一位置的情况。如果这是一个幼儿园班级,我们可能会采用“安妮先坐在那里”或者“你需要学会分享”之类的策略。但这些方法在数据结构的上下文中并不适用。我们不能忽视新的键或覆盖旧的数据。数据结构的目的就是存储所有必需的数据。在接下来的两个章节中,我们将讨论链式法和线性探测法,这两种常见的处理哈希表冲突的方法。

链接法

链接法是一种通过在桶内使用额外结构来处理哈希表中冲突的方法。我们可以将指向链表头的指针存储在每个桶中,而不是在每个桶中存储固定的数据(或指向单一数据的指针):

HashTable {
    Integer: size
    Array of ListNodes: bins
}

其中

ListNode {
    Type: key
 Type: value
    ListNode: next
}

这些列表就像我们的会议注册队伍。队伍中的每个人都是独一无二的个体,但都对应同一个注册桌。

如图 10-4 所示,每个桶的链表包含所有映射到该桶的数据。这使得我们可以在每个桶中存储多个元素。链表中的每个项目对应一个插入该桶的元素。

一个包含 5 个桶的数组垂直排列在左侧。每个桶上方有一根箭头指向一个链表。第一个桶的箭头指向一个包含三个节点的链表的起始位置。

图 10-4:使用链表存储同一桶中的条目的哈希表

向哈希表插入新项的代码相对简单:

HashTableInsert(HashTable: ht, Type: key, Type: value):
  ❶ Integer: hash_value = HashFunction(key, ht.size)

    # If the bin is empty, create a new linked list.
  ❷ IF ht.bins[hash_value] == null:
        ht.bins[hash_value] = ListNode(key, value)
        return

    # Check if the key already exists in the table.
  ❸ ListNode: current = ht.bins[hash_value]
 WHILE current.key != key AND current.next != null:
        current = current.next
    IF current.key == key:
      ❹ current.value = value
    ELSE:
      ❺ current.next = ListNode(key, value)
    return

我们首先计算key的哈希值❶,并检查相应的桶。如果桶为空(即指针为null),则创建一个新的链表节点,保存插入的keyvalue❷。否则,我们需要扫描桶的链表,并检查每个元素是否匹配键❸。WHILE循环检查是否既没有找到正确的键(current.key != key),也没有扫描到链表末尾(current.next != null)。如果链表已经包含匹配的键,我们更新与该键相关联的值❹。否则,我们将新键和其对应的值附加到链表的末尾❺。

查找过程采用类似的方法。但是,因为我们不再需要插入新节点,逻辑更加简单:

HashTableLookup(HashTable: ht, Type: key):
  ❶ Integer: hash_value = HashFunction(key, ht.size)
  ❷ IF ht.bins[hash_value] == null:
        return null

    ListNode: current = ht.bins[hash_value]
  ❸ WHILE current.key != key AND current.next != null:
        current = current.next
    IF current.key == key:
      ❹ return current.value
  ❺ return null

查找的代码首先计算key的哈希值❶,检查相应的桶,如果桶为空则返回null❷。否则,它使用WHILE循环扫描链表中的每个元素❸,并返回匹配键的值❹。如果我们遍历完链表仍未找到匹配的键,代码返回null,表示该键不在表中❺。

最后,在删除项时,我们需要在列表中找到该项,并在找到后将其删除。以下代码既删除项又返回与目标键匹配的链表节点:

HashTableRemove(HashTable: ht, Type: key):
  ❶ Integer: hash_value = HashFunction(key, ht.size)
    IF ht.bins[hash_value] == null:
        return null

    ListNode: current = ht.bins[hash_value]
    ListNode: last = null
  ❷ WHILE current.key != key AND current.next != null:
 last = current
        current = current.next
  ❸ IF current.key == key:
        IF last != null:
          ❹ last.next = current.next
        ELSE:
          ❺ ht.bins[hash_value] = current.next
        return current
    return null

这段代码首先计算key的哈希值,检查相应的桶,如果桶为空则返回null❶。如果桶不为空,我们使用WHILE循环扫描其中的元素,寻找匹配的键❷。为了正确删除元素,我们需要跟踪一条额外的信息:当前节点之前的最后一个链表节点。如果找到了匹配项❸,我们需要检查是否要删除链表中的第一个元素(即lastnull)。如果不是,我们可以修改last节点的next指针,跳过正在删除的节点❹。否则,我们需要修改哈希桶中指向链表的指针,跳过该节点❺。最后,如果没有找到匹配的节点,返回null

py`The skeptical reader might pause here and ask, “How does this help? We still must scan through a bunch of elements of a linked list. We have lost the ability to directly map to a single entry. We’re back where we started.” However, the primary advantage to this new approach is that we are no longer scanning through a linked list of *all* the entries. We only scan through those entries whose hash values match. Instead of searching through a giant list, we search through a single tiny list for this bin. In our coffee pantry, where our hash function maps the coffee’s name to its corresponding shelf, we might be able to cull our search from 1,000 varieties to the 20 varieties on that one shelf. Back in the computational realm, if we maintain enough bins in our hash table, we can keep the size of these lists small, perhaps with only one or two elements. Of course, the worst-case time for a lookup can be linear in the number of elements. If we choose a terrible hash function, such as *f*(*k*) = 1, we’re basically implementing a single linked list with extra overhead. It’s vital to be careful when selecting a hash function and sizing the hash table, as we’ll discuss later. ### Linear Probing An alternate approach to handling collisions is to make use of adjacent bins. If we are trying to insert data into a bin that already contains another key, we simply move on and check the next bin. If that neighbor is also full, we move onto the next. We keep going until we find an empty bin or a bin containing data with the same key. Linear probing extends this basic idea to the hash table’s operations. We start at the index corresponding to the key’s hash value and progress until we find what we are looking for or can conclude it is not in the hash table. Hash tables using linear probing need a slightly different structure. Because we are not using a linked list of nodes, we use a wrapper data structure to store the combination of keys and values: HashTableEntry { 类型: 键 类型: 值 } py We also include an additional piece of data in the hash table itself, the number of keys currently stored in the table: HashTable { 整数: 大小 整数: 键的数量 HashTableEntry 数组: 桶 } py This information is critical, because when the number of keys reaches the size of the hash table, there are no available bins left. Often hash tables will increase the array size when they start to get too full, although care must be taken here. Because we are using a hash function that maps keys onto the range of the current array, keys may map to different locations in a larger array. In this section, we will only consider a simplified implementation of a fixed size table for illustration purposes. Consider a hash table with linear probing where we have inserted a few of our favorite coffees, as shown in Figure 10-5. ![The left column shows a list of three keys: Morning Shock, Liquid Alarm Clock, and Pure Caffeine. Arrows map these keys to hash functions and then to the corresponding bins in the array. Morning Shock maps to bin 1, Liquid Alarm Clock to bin 2, and Pure Caffeine to Bin 5.](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/dast-fun-way/img/f10005.png) Figure 10-5: A hash table with three entries After we have inserted these initial three entries, we try to insert “Morning Zap” as shown in Figure 10-6. The insertion function finds another key, Morning Shock, in bin 1\. It proceeds to bin 2, where it finds Liquid Alarm Clock. The insertion function finally finds an opening at bin 3. ![The left column shows a single key, Morning Zap, which maps to a hash value of 1\. In the array on the right, bins 1 and 2 contain other keys. Arrows show our search through the array for an empty bin, found at bin 3.](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/dast-fun-way/img/f10006.png) Figure 10-6: Inserting the entry “Morning Zap” requires scanning through several bins in the hash table. The code for inserting items into a fixed-size hash table is shown below. As noted previously, it is possible to increase the size of the array when the hash table is full, but this adds complexity to ensure the items are mapped correctly to new bins. For now, we will return a Boolean to indicate whether the item could be inserted. HashTableInsert(HashTable: ht, 类型: 键, 类型: 值): ❶ 整数: 索引 = HashFunction(键, ht.size) ❷ 整数: 计数 = 0 HashTableEntry: 当前 = ht.bins[索引] ❸ 当 当前 != null 且 当前.key != 键 且 计数 != ht.size: 索引 = 索引 + 1 ❹ 如果 索引 >= ht.size: 索引 = 0 当前 = ht.bins[索引] 计数 = 计数 + 1 ❺ 如果 计数 == ht.size: 返回 False ❻ 如果 当前 == null: ht.bins[索引] = HashTableEntry(键, 值) ht.num_keys = ht.num_keys + 1 否则: ❼ ht.bins[索引].value = 值 返回 True py The code starts the search at the new key’s hash value ❶. The code also maintains a count of bins it has checked to prevent infinite loops if the table is full ❷. This count is not needed if we use resizing or otherwise ensure there is always at least one empty bin. The code then loops through each bin using a `WHILE` loop ❸. The loop tests three conditions: (1) whether it found an empty bin, (2) whether it found the target key, and (3) whether it has searched all the bins. The first and third conditions test whether the key is not in the table. After incrementing the index, the code checks whether the index should wrap back to the beginning of the table ❹, which allows the code to search the entire table. After the loop completes, the code first checks whether it has examined every bin in the table without finding the key. If so, the table is full and does not contain the key ❺, so the code returns `False`. The code then checks whether it has found an empty bin or matching key. If the bin is empty ❻, a new `HashTableEntry` is created and stored. Otherwise, the value of the entry with the matching key is updated ❼. The code returns `True` to indicate that it successfully inserted the key and value. Search follows the same pattern. We start at the index of the key’s hash value and iterate over bins from there. At each step, we check whether the current entry is empty (`null`), in which case the key is not in the table, or whether the current entry matches the target key. HashTableLookup(HashTable: ht, 类型: 键): ❶ 整数: 索引 = HashFunction(键, ht.size) ❷ 整数: 计数 = 0 HashTableEntry: 当前 = ht.bins[索引] ❸ 当 当前 != null 且 当前.key != 键 且 计数 != ht.size: 索引 = 索引 + 1 ❹ 如果 索引 >= ht.size: 索引 = 0 当前 = ht.bins[索引] 计数 = 计数 + 1 # 如果找到匹配项,则返回值。 ❺ 如果 当前 != null 且 当前.key == 键: 返回 当前.value ❻ 返回 null py The code starts by computing the hash value for the `key` to get the starting location of the search ❶. As with insertion, the code also maintains a count of bins it has checked to prevent infinite loops if the table is full ❷. The code then loops through each bin using a `WHILE` loop ❸. The loop tests three conditions: (1) whether it found an empty bin, (2) whether it found the target key, and (3) whether it has searched all the bins. After incrementing `index`, the code tests whether the search has run off the end of the table and, if so, wraps the search back to the start ❹. Once the loop terminates, the code checks whether it has found the matching key ❺. If so, it returns the corresponding value. Otherwise, the key is not in the table, and the code returns `null` ❻. In contrast to search and insertion, deletion with linear probing requires more than a simple scan. If we remove an arbitrary element such as “Liquid Alarm Clock,” shown in Figure 10-6, we might break the chain of comparisons needed for other elements. If we replace “Liquid Alarm Clock” with null in Figure 10-6, we can no longer find “Morning Zap.” Different implementations use different solutions to this problem, from scanning through the table and fixing later entries to inserting dummy values into the bin. The advantage of linear probing over chaining is that we make fuller use of the initial array bins and do not add the overhead of linked lists within the bins. The downside is that, as our table gets full, we might iterate over many entries during a search, and, unlike with chaining, these entries are not restricted to ones with matching keys. ## Hash Functions The difference between a good and bad hash function is effectively the difference between a hash table and a linked list. We’d want our hash function to map keys uniformly throughout the space of bins, rather than pile them into a few overloaded bins. A good hash function is like a well-run conference registration. Any clumping of attendees will lead to collisions, and collisions lead to more linear scanning (more time waiting in line). Similarly, in our conference registration example, bad hash functions, such as dividing the table into two lines for names starting with A−Y and names starting with Z, leads to long waits and annoyed attendees. In addition, any hash function must be meet a couple of key criteria. It must: 1. Be deterministic The hash function needs to map the same key to the same hash bin every time. Without this attribute, we might insert items into a hash table only to lose the ability to retrieve them. 2. Have a predefined range for a given size The hash function needs to map any key into a limited range, corresponding to the number of hash buckets. For a table with *b* buckets, we need the function to map onto the range [0, *b* − 1]. We’d also like to be able to vary the hash function’s range with the size of our hash table. If we need a larger table, we need a correspondingly larger range to address all the buckets. Without this ability to adjust the range, we may be stuck with a limited number of viable hash table sizes. In our conference registration example, these criteria correspond to people being able to find their packets (deterministic for all users) and having everyone map to a line (correct range). It would be wasteful to set up a conference check-in with lines for empty parts of the range (all names starting with Zzza through Zzzb), and it would be rude to map some people’s names to no line at all (no line for names starting with K). The best hash functions are like in-person organizers, holding clipboards and directing people to the correct lines. ### Handling Non-numeric Keys For numeric keys, we can often use a range of mathematical functions, such as the division method above. However, we may often need to handle non-numeric keys as well. Most importantly, we need to be able to compute the hash of a string containing the coffee name. The general approach to handling non-numeric keys is to use a function that first transforms the non-numeric input into a numeric value. For example, we could map each character to a numeric value (such as the ASCII value), compute the sum of values in a word, and modulo the resulting sum into the correct number of buckets. This approach, while straightforward and easy to implement, may not provide a good distribution of hash values due to how letters occur. For example, the hash function does not take into account the order of the letters, so words with the same letters, such as *act* and *cat*, will always be mapped to the same bin. A better approach to hashing strings is an approach often called *Horner’s method*. Instead of directly adding the letters’ values, we multiply the running sum of letters by a constant after each addition: StringHash(字符串: 键, 整数: 大小): 整数: 总计 = 0 对于 键中的每个字符: 总计 = CONST * 总计 + CharacterToNumber(字符) 返回 总计 % 大小 py where `CONST` is our multiplier, typically a prime number that is larger than the largest value for any character. Of course, by multiplying our running sum, we greatly increase the size of the value, so we need to be careful to handle overflow. There are a wide variety of hash functions, each with their own trade-offs. A full survey of the potential hash functions and their relative merits is a topic worthy of its own book; this chapter presents just a few simple functions for the point of illustration. The key takeaway is that we can use these mathematical functions to reduce the range of our key space. ### An Example Use Case Hash tables are particularly useful when tracking a set of items, which is why Python uses them to implement data structures such as dictionary and set. We can use them to aid in tracking metadata for the searches such as those seen in Chapter 4. In both the depth-first and breadth-first search, we maintained a list of future options to explore. We used a queue for breadth-first search and a stack for depth-first. However, there is an additional set of information we need to track: the set of items we have already visited. In both searches, we avoid adding items to the list that we have already seen, allowing us to avoid loops or, at least, wasted effort. Hash tables provide an excellent mechanism for storing this set. Consider the sixth step of our example breadth-first search from Chapter 4, as shown on the left side of Figure 10-7. The search has already visited the gray nodes (A, B, F, G, H, and I), and the circled node (G) is our current node. We do not want to re-add either of G’s two neighbors to our list of items to explore; we have already visited both F and I. ![Diagram of a breadth‐first search in progress on the left. A hash table on the right stores the visited nodes.](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/dast-fun-way/img/f10007.png) Figure 10-7: The visited nodes of a breadth-first search tracked in a hash table We could store the visited items in a hash table as shown on the right of Figure 10-7. This hash table uses a simple function: the division function maps the letter’s index in the alphabet into 5 bins. The letter *A* has index 0 and maps to bin 0\. The letter *G* has index 6 and maps to bin 1\. We insert a key for each item when we visit it. Before adding new items to our list of future topics, we check whether their key is in the hash table. If so, we skip the addition. Hash tables are particularly well suited to tracking this type of data because they are optimized for fast insertions and lookups. ## Why This Matters Hash tables provide a new way of organizing data by using a mathematical function. As opposed to tree-based structures, which structure the data around the keys themselves, hash tables introduce the intermediate step of mapping the keys to a reduced range. As with all things in computer science, this mapping comes with tradeoffs. Collisions mean that we can’t always access the desired data directly. We must add another level of indirection, such as a linked list, to store items that map to the same location. As such, hash tables provide another example of how we can creatively combine data structures (in this case, an array and linked lists). Understanding hash functions and how they map keys to bins provides a side benefit. We can use these types of mappings to partition items or spread out work, such as the lines at a conference registration or the coffees on our shelves; in the computational domain, we might use hashing to assign tasks to servers in a simple load balancer. In the next chapter, we see how hash tables can be used to create caches. Hash tables are used throughout computer science, since they provide a handy data structure with (on average) fast access time and reasonable memory tradeoffs. They are a vital tool for every computer scientist’s toolbox.

第十一章:缓存

本章介绍了缓存,一种旨在通过将部分数据存储在离计算更近的位置来降低高数据访问成本的数据结构。正如我们在前几章中所看到的,数据访问的成本是影响我们算法效率的关键因素。这不仅关系到我们如何组织存储中的数据,也关系到我们使用的存储类型。我们说数据是本地的,当它存储在离处理器更近的位置时,这样可以更快地进行处理。如果我们将一些数据从更昂贵、远离的地方复制到本地存储中,我们就能显著提高数据的读取速度。

例如,我们可以使用缓存来加速网页的访问。加载网页的过程包括查询服务器获取页面上的信息,将这些数据传输到本地计算机,并在视觉上呈现这些信息。如果网页包含大量元素,如图像或视频,我们可能需要传输大量数据。为了减少这种成本,浏览器会缓存常访问的数据。每次访问页面时,浏览器不会重新下载我们最喜欢网站的 logo,而是将该图像保存在计算机硬盘的本地缓存中,这样可以更快地从磁盘读取它。

我们应该如何选择哪些数据加载到缓存中呢?显然,我们无法存储所有的东西。如果我们能够存储所有数据,那我们就不需要缓存了——我们直接把整个数据集复制到最近的内存中就行了。缓存有多种策略,其有效性总是取决于具体的使用场景。在这一章中,我们结合之前探讨的数据结构,构建了一种策略——最少最近使用(LRU)缓存,它能极大地提高我们最喜欢的咖啡店的运营效率。我们还将简要讨论几种其他缓存策略以做对比。

引入缓存

到目前为止,我们对计算机的所有存储进行了等同处理,把它当作一个单一的书架,可以大致用相同的精力拿到任何我们需要的物品。然而,数据存储并非如此简单。我们可以把存储看作一个大型的多层图书馆。我们将受欢迎的书籍安排在靠近入口的书架上,以满足大多数读者的需求,而把发霉的计算机科学旧期刊堆放在地下室。我们甚至可能有一个外部仓库,存储那些很少使用的书籍,直到某位读者特别请求它们。想要查找关于 PILOT 编程语言的资料?很可能它不在受欢迎书籍区域。

除了关注我们在程序中如何组织数据外,我们还需要考虑数据存储的位置。并非所有数据存储都是平等的。实际上,对于不同的存储介质,通常存在存储容量、速度和成本之间的权衡。CPU 本身的内存(寄存器或本地缓存)速度极快,但只能存储非常有限的数据。计算机的随机存取内存(RAM)提供了更大的存储空间,但速度较慢。硬盘的容量显著更大,但比 RAM 慢得多。通过网络访问可以访问范围更广的存储,例如整个互联网,但也带来了相应的开销。在处理非常大的数据集时,可能无法将全部数据加载到内存中,这会对算法的性能产生巨大影响。理解这些权衡如何影响算法的性能非常重要。

将此与我们自己早晨制作咖啡的例程进行比较。虽然理想情况下我们可以随时得到成千上万种咖啡,但我们的公寓只能存储有限的咖啡。街道尽头有一家咖啡供应商,那里储备了成百上千种咖啡,但我们真的希望每次想喝新的一杯咖啡时都走到那儿去吗?相反,我们在公寓里存储了我们最喜欢的少量咖啡。这个本地存储加速了我们早晨咖啡的制作过程,让我们在踏入外界之前就能享受第一杯咖啡。

缓存是在访问昂贵数据之前的一步,如图 11-1 所示。在调用远程服务器或访问本地硬盘之前,我们会检查是否已经将数据存储在本地缓存中。如果有,我们直接从缓存中访问数据,这样既便宜又快捷。当我们在缓存中找到数据时,这就是一次缓存命中。我们可以停止查找,避免访问更昂贵的存储。如果数据不在缓存中,那就是缓存未命中。我们会叹气,然后继续进行更昂贵的访问。

缓存位于算法和昂贵的数据存储之间。标记为“查询”的箭头从算法指向缓存,再从缓存指向数据存储。标记为“回复”的箭头则指向相反方向。

图 11-1:缓存位于算法和慢速、昂贵的数据存储之间。

想象一下在一个繁忙的咖啡柜台的情景。每日菜单上有 10 种咖啡,从超受欢迎的招牌咖啡到很少有人购买的薄荷肉桂南瓜爆炸口味不等。在柜台的最远端是一个咖啡站,那里有 10 壶咖啡,每壶都放在自己的加热器上。收到订单后,咖啡师走到咖啡站,倒满相应的咖啡。每天走上几英里,咖啡师请求老板在收银台旁边安装另一个加热器,好让他们的疲惫双脚得到休息。老板同意了,但问道:“你打算把哪种咖啡放在收银台附近?”这个问题让咖啡师十分困扰,他们尝试了几种策略。

咖啡师很快意识到,这不仅仅是选择哪种咖啡的问题,还包括什么时候更换它。他们可以在一天中的不同时间点存储不同的咖啡。他们尝试了全天把招牌咖啡保持在缓存中,晚上把低咖啡因咖啡放得更近,在过去 10 分钟内没有顾客时将当前选择换成招牌咖啡。有些策略有效,而有些则失败,尤其是涉及到缓存薄荷肉桂南瓜爆炸口味时,结果更是惨不忍睹。如果咖啡师选择得当,大部分订单都能命中缓存,他们就能避免长时间的步行。如果选择不当,附近的咖啡对于大多数订单来说是无用的。

咖啡师可以通过在收银台旁安装第二个加热器进一步改善情况。他们现在可以不只存储一种咖啡,而是存储两种。然而,代价是这个第二个加热器占用了宝贵的柜台空间。无论是计算机还是咖啡店都有有限的本地空间。我们不能将每种咖啡都存储在收银台旁边,就像我们不能将整个互联网存储在计算机的 RAM 中一样。如果我们将缓存做得太大,就需要使用更慢的存储设备来容纳它。这就是缓存的基本权衡——使用的内存与带来的加速之间的平衡。

当缓存满时,我们需要决定保留哪些数据,替换哪些数据。我们说被替换的数据被驱逐出缓存。也许在一个秋天的早晨高峰期,三重浓缩咖啡会替代低咖啡因咖啡。通过将低咖啡因咖啡从附近的加热器上驱逐,咖啡师节省了数小时的步行时间来取用更流行的混合咖啡。不同的缓存方法采用不同的驱逐策略来决定哪些数据被替换,从统计数据访问的频率到预测这些数据是否会在不久的将来再次使用。

LRU 驱逐与缓存

最久未使用(LRU)缓存 存储最近使用的信息。LRU 缓存是一种简单且常见的方法,能够很好地说明我们在使用缓存时面临的各种权衡。这个缓存策略的直觉是,我们可能会重新访问最近需要的信息,这与我们上面提到的网页浏览示例非常契合。如果我们经常访问相同的页面集,那么将这些页面的某些(不变的)元素存储在本地就有意义。我们不需要每次访问网站时都重新请求该站点的徽标。LRU 缓存由一定量的存储组成,存储的是最近访问过的项目。当缓存满时,缓存会开始驱逐最老的项目——即那些最久未使用的。

如果我们的咖啡师决定将靠近收银台的加热器当作一个 LRU 缓存,他们会将最后点的咖啡放在那个加热器上。当他们收到一个不同的订单时,他们会把当前缓存的咖啡拿回咖啡站,将其放在那里的加热器上,然后取回新的咖啡。他们将这杯新的咖啡带回收银台,用它来完成订单,然后把它留在那里。

如果每个顾客点的都是不同的东西,那么这个策略只会增加开销。咖啡师不再是拿着一杯咖啡走到柜台的尽头,而是要拿着一壶咖啡走同样的路程——可能会低声抱怨柜台的长度,或者带着不满的情绪评判顾客的口味:“谁会点薄荷肉桂南瓜爆炸?”然而,如果大多数顾客点的是相似的咖啡,这个策略可以非常有效。三位顾客连续点了普通咖啡,就意味着节省了两趟来回的路程。随着顾客互动的影响,这个优势可以进一步放大。在看到前面的人点了南瓜混合饮料之后,下一个顾客做出了(值得怀疑的)决定,选择了复制前人的订单,说:“我要一样的。”

这正是我们在浏览自己最喜欢的网站时可能遇到的场景。我们从网站的一个页面跳转到同一网站的另一个页面。某些元素,如徽标,会在后续页面中重新出现,从而通过将这些项目保存在缓存中节省了大量时间。

构建一个 LRU 缓存

使用 LRU 驱逐策略的缓存要求我们支持两个操作:查找任意元素和找到最久未使用的元素(用于驱逐)。我们必须能够快速执行这两项操作。毕竟,缓存的目的是加速查找。如果我们必须扫描整个无结构的数据集来检查缓存命中,我们更可能增加开销而不是节省时间。

我们可以使用之前讨论过的两个组件构建一个 LRU 缓存:哈希表和队列。哈希表使我们能够快速查找,高效地检索缓存中的任何项。队列是一个 FIFO 数据结构,它帮助我们跟踪哪些项最近没有被使用。我们可以通过出队列中的最早项,而不是遍历每一项并检查时间戳,来确定要删除的数据,如下面的代码所示。这提供了一种高效的方式来确定删除哪些数据。

LRUCache {
    HashTable: ht
    Queue: q
    Integer: max_size
    Integer: current_size
 }

哈希表中的每个条目都存储一个复合数据结构,至少包含三个条目:键、相应的值(或该条目的数据)以及指向缓存队列中相应节点的指针。这最后一部分信息至关重要,因为我们需要一种方式来查找和修改队列中的条目。如清单 11-1 所示,我们将这些信息直接存储在哈希表节点的值条目中。

CacheEntry {
    Type: key
    Type: value
    QueueListNode: node
}

清单 11-1:包含数据的键、值以及指向其在队列中节点的链接的缓存项数据结构

图 11-2 展示了这些组件如何组合在一起的结果图。图看起来有点复杂,但当我们将其分解成两部分时,就会变得更容易理解。左侧是哈希表。如第十章所述,每个哈希值对应一个条目的链表。这些条目的值是清单 11-1 中的CacheEntry数据结构。右侧是队列数据结构,存储条目的键。一个指针跨越这些数据结构,指向CacheEntry数据结构并指向具有相应键的队列节点。

当然,在图 11-2 中,组件在计算机内存中的实际布局比图中所示的还要杂乱,因为节点实际上并不相邻。

左侧的哈希表包括每个缓存项的条目,并指向额外的 CacheEntry 节点。每个 CacheEntry 节点有一个单一指针,链接到右侧队列中的条目。

图 11-2:LRU 缓存的实现,结合了哈希表和队列

在清单 11-2 中,我们定义了一个查找函数CacheLookup,该函数返回给定查找键的值。如果是缓存命中,查找函数将直接返回值并更新该数据的最近访问时间。如果是缓存未命中,函数将通过耗时的查找获取数据,将其插入缓存,并在必要时删除最旧的数据。

CacheLookup(LRUCache: cache, Type: key):
  ❶ CacheEntry: entry = HashTableLookup(cache.ht, key)

    IF entry == null:
      ❷ IF cache.current_size >= cache.max_size:
            Type: key_to_remove = Dequeue(cache.q)
            HashTableRemove(cache.ht, key_to_remove)
            cache.current_size = cache.current_size - 1

      ❸ Type: data = retrieve data for the key from 
                     the slow data source.

      ❹ Enqueue(cache.q, key)
        entry = CacheEntry(key, data, cache.q.back)
      ❺ HashTableInsert(cache.ht, key, entry)
        cache.current_size = cache.current_size + 1
    ELSE:
        # Reset this key's location in the queue.
 ❻ RemoveNode(cache.q, entry.node)
      ❼ Enqueue(cache.q, key)

        # Update the CacheEntry's pointer.
      ❽ entry.node = cache.q.back
    return entry.value

清单 11-2:通过键查找项的代码

这段代码首先检查key是否出现在缓存表中❶。如果存在,则为缓存命中;否则,表示缓存未命中。

我们首先处理缓存未命中的情况。如果哈希表返回null,我们需要从更昂贵的数据存储中获取数据。我们将新获取的值存储到缓存中,同时驱逐队列最前面的(最旧的)项。我们分三步完成这项操作。首先,如果缓存已满 ❷,我们将队列中最旧项的键出队,并用该键删除哈希表中对应的条目。这样就完成了最旧项的驱逐。其次,我们检索新数据 ❸。第三,我们将(keydata)对插入缓存。我们将key入队到队列末尾 ❹。然后,我们创建一个新的哈希表条目,包含新的keydata和指向该键在队列中对应位置的指针(使用队列的back指针)。我们将该条目存储到哈希表中 ❺。

最后一段代码处理缓存命中的情况。当我们看到缓存命中时,我们希望将该键对应的元素从当前位置移到队列的末尾。毕竟,我们刚刚访问过它,它现在应该是我们希望丢弃的最后一个元素。我们通过两个步骤来移动该元素。首先,我们使用RemoveNode函数和指向节点的指针从队列中移除它 ❻。其次,我们将该键重新入队到队列末尾 ❼,并更新指向该队列节点的指针 ❽。

我们可以将此更新操作类比为咖啡店排队的顾客。如果一个顾客离开了队列,他们就失去了当前位置。当他们将来重新加入队列时,他们会排到队尾。

更新元素的最近访问时间

为了支持清单 11-2 中的RemoveNode操作,我们需要修改队列以支持更新元素的位置。我们需要将最近访问的项从队列中的当前位置移动到队尾,以表明它们是最近访问的。首先,我们将第四章中的队列实现修改为使用双向链表,这样可以高效地从队列中间移除项:

QueueListNode {
    Type: value
    QueueListNode: next
    QueueListNode: prev
}

如图 11-3 所示,next字段指向当前节点后面的节点(即下一个将被出队的节点),而prev字段指向当前节点前面的节点。

包含 31、41、5、92 和 65 的队列。每个节点包含一个数字,并链接到队列中下一个和前一个数字。

图 11-3:作为双向链表实现的队列

其次,我们相应地修改入队和出队操作。对于入队和出队,我们增加了额外的逻辑来更新前一个指针。与第四章的主要变化在于我们如何设置prev指针:

Enqueue(Queue: q, Type: value):
    QueueListNode: node = QueueListNode(value)
    IF q.back == null:
        q.front = node
        q.back = node
    ELSE:
        q.back.next = node
      ❶ node.prev = q.back
        q.back = node

在入队操作中,代码需要将新节点的prev指针设置为之前的最后一个元素q.back ❶。

Dequeue(Queue: q):
    IF q.front == null:
        return null

    Type: value = q.front.value
    q.front = q.front.next
    IF q.front == null:
        q.back = null
    ELSE:
      ❶ q.front.prev = null
    return value

出队操作将新前端节点的prev指针设置为null,以表示它前面没有节点 ❶。

最后,我们添加了RemoveNode操作,它提供了从队列中间移除节点的能力。此时双向链表的使用就发挥了作用。通过保持双向指针,我们无需扫描整个队列来找到当前节点之前的条目。RemoveNode的代码通过调整相邻节点(node.prevnode.next)、队列前端(q.front)和队列后端(q.back)的指针来移除节点。

RemoveNode(Queue: q, QueueListNode: node):
    IF node.prev != null:
        node.prev.next = node.next
    IF node.next != null:
        node.next.prev = node.prev
    IF node == q.front:
       q.front = q.front.next
    IF node == q.back:
       q.back = q.back.prev

这段代码包含多个IF语句,用于处理从队列两端添加或移除的特殊情况。

还有一个问题:我们如何高效地找到要移除并重新插入的元素?我们不能在寻找要更新的节点时扫描整个队列。支持缓存命中需要快速查找。在我们上面的缓存示例中,我们直接从缓存的条目维护一个指针,指向队列中的元素。这使我们能够通过跟踪指针,在常数时间内访问、移除和重新插入元素。

其他驱逐策略

让我们考虑三种与 LRU 驱逐策略比较的替代驱逐策略——最近最少使用、最不常用和预测驱逐。目标不是对这些方法进行深入分析,而是帮助你直观地理解选择缓存策略时可能出现的各种权衡。对于特定场景,最佳的缓存策略取决于该场景的具体情况。了解这些权衡将帮助你为你的用例选择最佳策略。

当我们看到某些缓存项在使用频率上出现突发性高峰,但又预计缓存项的分布会随时间变化时,LRU 是一种不错的驱逐策略。之前描述的注册台附近的咖啡壶就是一个很好的例子。当有人点了一种新类型的咖啡时,咖啡师走到柜台的另一端,取回旧的咖啡豆,拿出新的咖啡豆,并将咖啡壶放在收银台旁边。

让我们将其与驱逐最近最少使用(MRU)元素进行对比。你可以把它想象成一个电子书阅读器,它会预取并缓存你可能喜欢的书籍。由于我们不太可能连续两次阅读同一本书,因此将最近读完的书从缓存中移除,以腾出空间给新书,可能是合理的。虽然 MRU 对于不常重复出现的项来说是一种不错的策略,但同样的驱逐策略如果应用于我们的咖啡储藏室,将会带来不断的悲剧。试想一下,如果每次我们煮一杯咖啡时都必须驱逐掉最喜欢的常备咖啡豆,那该有多糟糕。

我们可以不考虑上次访问该项目的时间,而是追踪它被访问的总次数。最不常用(LFU)驱逐策略会丢弃访问次数最少的元素。这种方法在缓存的数据集保持稳定时特别有效,比如我们家里常备的咖啡种类。我们不会因为最近在本地咖啡馆尝试了季节限定的混合咖啡,就将我们家里常备的三种咖啡中的一种丢弃。缓存中的物品有着经过验证的使用记录,它们会一直存在——至少在我们找到新的最爱之前。遗憾的是,当口味变化时,新的流行物品可能需要一段时间才能积累足够的访问次数,进入我们的缓存。如果我们遇到了一款比家中所有咖啡更好的新咖啡,我们就需要多次品尝,做很多次咖啡店的回访,然后才会为自己购买这种咖啡豆。

预测驱逐(predictive eviction)策略提供了一种前瞻性的方式,试图预测未来哪些元素会被需要。我们不仅依赖简单的计数或时间戳,而是可以建立一个模型,预测哪些缓存项最可能在未来被访问。该缓存的有效性取决于模型的准确性。如果我们拥有一个非常准确的模型,例如预测我们将只在 10 月和 11 月将常备的咖啡豆换成季节性秋季咖啡豆,那么命中率会大大提高。然而,如果模型不准确,可能会将每个月与去年该月第一次喝到的咖啡联系起来,我们可能就会重蹈覆辙,在 8 月时喝下薄荷肉桂南瓜拿铁。预测驱逐的另一个缺点是,它增加了缓存本身的复杂性。单纯的计数或时间戳已经不够,缓存必须学习一个模型。

为什么这很重要

缓存可以减轻处理高成本存储介质时的访问开销。缓存并不是直接访问昂贵的存储,而是将一部分数据存储在更接近且更快速的地方。如果我们选择正确的缓存策略,就可以通过从缓存中提取数据而非从较慢的位置获取数据,节省大量时间。这使得缓存成为我们计算工具箱中常见且强大的工具。它们在现实世界中得到了广泛应用,从网页浏览器到图书馆的书架,再到咖啡馆。

缓存还展示了几个关键概念。首先,它们突出了在从不同介质访问数据时需要考虑的潜在权衡。如果我们能够将所有数据存储在最快的内存中,就不需要缓存了。不幸的是,这通常不可行;我们的算法将需要访问更大、更慢的数据存储,因此理解这一点可能带来的潜在成本是值得的。在下一章中,我们将介绍 B 树,一种基于树的数据结构,它减少了数据访问的次数。这种优化有助于减少当我们无法将数据存储在附近的快速内存中时的整体成本。

其次,缓存重新审视了我们在前几章中看到的数据结构调优问题。缓存的大小和驱逐策略都是可以对缓存性能产生巨大影响的参数。考虑选择缓存大小的问题。如果缓存太小,它可能存不下足够的数据来提供益处。我们咖啡馆里那唯一的附近咖啡壶只能帮到那么多。另一方面,缓存过大则会占用可能被算法其他部分所需的重要内存资源。

最后,最重要的是,对于本书的目的,缓存展示了我们如何结合基本数据结构,如哈希表和队列,来提供更复杂和更有影响力的行为。在这种情况下,通过使用指针将哈希表条目与队列中的节点关联起来,我们可以高效地追踪缓存中下一个应当被移除的条目。

第十二章:B 树

第十一章展示了内存访问成本如何在不同介质之间有所不同。在本章中,我们将讨论这个问题如何超越访问单个值的成本,延伸到访问新数据块的成本,并引入一种新的数据结构来处理这种情况。

计算机科学中有很多实例,其中访问块内的数据是廉价的,但检索新块却相对昂贵。计算机可能会从硬盘读取整个信息块,称为页面,并将其存储在内存中。在软盘时代的电子游戏中,你可能会看到一条消息,指示你“插入第 5 张磁盘(共 7 张)”,或者等待游戏从 CD 中加载下一块数据。类似地,在线应用程序可能会从互联网服务器下载一致的数据块,使你可以在未下载完整个视频的情况下开始观看。

本章介绍了B 树,一种自平衡的基于树的数据结构,由计算机科学家鲁道夫·贝尔(Rudolf Bayer)和爱德华·麦克雷(Edward McCreight)设计,用于应对检索新数据块的成本。B 树将多个数据项存储在单个节点中,使我们只需为提取所有这些值支付一次昂贵的检索成本。一旦节点进入本地内存,我们可以迅速访问其中的值。其权衡是,在处理节点时增加了额外的复杂性。

在计算领域,我们在尝试为庞大的数据集建立索引时,可能会遇到这个问题。考虑一下一个字面上天文数字般的数据集的索引,它包含指向每颗星星、星系、星云、彗星、小行星和其他天体图像的指针。数据集仍然比索引大,但索引本身可能需要分布在多个慢速存储块中。B 树提供了一种创新的方法来结合索引和键,同时最小化检索成本。

B 树也是如何定义树的操作,以确保它们不会变得极度不平衡的一个例子。正如我们稍后将在本章中看到的,B 树始终保持完全平衡,所有叶节点都在相同的深度上。

B 树结构

B 树采用我们在字典树或四叉树中看到的多路分支结构来存储单个键。实际上,这意味着它们允许内部节点有超过两个的分支,因此它们实际上充满了指针。它们还需要在每个节点中存储多个键。B 树节点充满了键,使它们既能跟踪多路分区,又能最大限度地提高通过提取单个节点来检索数据的效率。

我们可以在日常的在线购物场景中看到将多个物品打包进一个节点的好处。每个包裹的运输都会产生一定费用,如果我们发送许多小包裹,费用会迅速累积。这就相当于从昂贵的存储中检索多个小的树节点。但是,如果我们将多个物品放进同一个包裹,就可以通过一起运输来降低费用。同样,B 树通过将多个键一起检索来减少检索成本。

在形式上,我们定义 B 树节点的大小参数为k,它提供了非根节点可以存储的元素数量的界限。所有非根节点存储介于k与 2k之间的排序键。根节点更加灵活,允许包含介于 0 到 2k之间的键。像二叉搜索树一样,内部节点使用这些键的值来定义分支的范围。内部节点为每个可能的分割点存储指针,分割点位于节点中每个键的前后,从而使得除根节点外的所有内部节点可以拥有介于k + 1 和 2k + 1 之间的子节点,而根节点可以有介于 0 到 2k + 1 之间的子节点。这些分割点的概念与我们在二叉搜索树中做的事情相同——它们将空间划分为分割点前后的键。

图 12-1 展示了这种结构的一个例子。包含键 12、31 和 45 的节点定义了四个不同的区间:在 12 之前的键、在 12 和 31 之间的键、在 31 和 45 之间的键、以及在 45 之后的键。包含 13、17 和 26 的子树由父节点中的两个分割点定义。该节点中的所有键都必须大于 12,因为它们的子节点指针指向键 12 的右边。同样,所有的键都必须小于 31,因为指针指向键 31 的左边。

该示例 B 树有 3 层。顶部节点有一个键(51)和指向 2 个子节点的指针。左子节点有键 12、31 和 45,并且指向 4 个子节点,其中一个是包含 13、17、26 的子树。

图 12-1:一个 B 树示例

设想这个结构应用于“全面有趣且富有信息的收藏品博物馆”的索引系统。为了实现动态的收藏,我们将每个物品的索引条目存储在一张小纸卡上,卡片上有物品名称、简短描述和在我们庞大收藏品仓库中的位置。我们可以将九百张卡片放入一个文件夹中,因此,对于我们一亿件物品的收藏,至少需要十万个文件夹来存储整个索引。

虽然我们希望将整个索引本地存储,但我们的办公室空间实在不足。一旦我们申请并取回了一个包含索引片段的文件夹,我们可以相对轻松地浏览其条目。然而,每次申请新的文件夹都需要前往档案馆并填写申请表。

在这种组织方案中,每张索引卡对应 B 树中的一个条目,其中名称字符串是键。绑定器对应于 B 树节点,每个节点有 900 个口袋,因此最多可容纳 900 个键。绑定器中的条目按排序顺序排列,使我们可以通过线性扫描或二分查找来搜索键。此外,我们在每个绑定器的口袋中存储一个额外的数据项,即指向另一个绑定器的指针,该绑定器包含当前索引卡的键和前一张索引卡的键之间的条目。如 图 12-2 所示,如果我们正在寻找目标“Caffeine Unlimited 咖啡杯”,我们首先会扫描经过“Caffeine Ten 咖啡杯”,它在字母顺序上排在目标之前,然后会遇到“Jeremy’s Gourmet High-Caffeine Experience”,它排在目标之后。此时,我们已经越过了目标键的潜在位置,并知道需要搜索当前条目前的绑定器。

我们的收藏品索引中的三项条目:Caffeine Ten 咖啡杯、Jeremy's Gourmet High‐Caffeine Experience 咖啡杯和 Morning Zap Brand 咖啡杯。每个条目都有两个指针,一个项目指针和一个绑定指针。Jeremy's Gourmet High‐Caffeine Experience 咖啡杯的绑定指针显示为箭头。

图 12-2:我们索引卡中的绑定指针指示应使用哪个绑定器来继续搜索。

我们在绑定器的最末尾存储一个额外的指针,该指针指向另一个绑定器,包含当前绑定器中最后一个键之后的所有键。总的来说,我们的绑定器最多可以包含 900 个键(以及它们指向相关收藏品的指针)和 901 个指向其他绑定器的指针。

与其他基于树的数据结构一样,我们通过一个顶级复合数据结构和一个节点特定数据结构来定义 B 树结构:

BTree {
    BTreeNode: root
    Integer: k
}

BTreeNode {
    Integer: k
    Integer: size
    Boolean: is_leaf
    Array of Type: keys
    Array of BTreeNodes: children
}

在此数据结构和以下示例中,我们存储和检索单个键以保持代码简洁。与我们之前介绍的其他数据结构一样,在大多数情况下,存储一个包含键和指向该键数据的指针的复合数据结构(例如 图 12-2 中的项目指针)会更有用。

B 树结构的一个复杂之处在于,我们将键和子节点存储在两个不同大小的数组中。这意味着我们需要定义如何将索引 i 处的键映射到其相邻的子节点指针。对于任何给定的索引 i,我们可以访问 keys[i] 处的键,但我们还需要能够访问该键之前和之后的节点指针。我们定义指针,使得 children[i] 处的所有键值都小于 keys[i],并且大于 keys[i 1](如果 i > 0),如 图 12-3 所示。

键数组包含 12、31 和 45。子数组有 4 个指针:小于 12 的键、小于 31 但大于 12 的键、小于 45 但大于 31 的键,以及大于 45 的键。

图 12-3:键数组条目与子数组对应元素的映射

根据定义,B 树是平衡的数据结构。每个叶子节点距离根节点的深度完全相同。在后面的章节中,我们将展示如何通过更新节点来保持这一结构,以便在插入和删除新键时进行维护。

搜索 B 树

我们使用与所有基于树的数据结构相同的一般程序来搜索 B 树:从树的顶部开始,逐层向下查找,直到找到感兴趣的键。B 树与二叉搜索树的主要区别在于,我们可能需要每个节点检查多个键。我们在每个节点中沿着键进行扫描,直到找到目标键或找到一个值大于目标键的键。在后一种情况下,如果我们处于内部节点,则会下降到适当的子节点并继续搜索,具体如下:

BTreeSearch(BTree: tree, Type: target):
    return BTreeNodeSearch(tree.root, target)

BTreeNodeSearch(BTreeNode: node, Type: target):
    # Search the node's key list for the target.
    Integer: i = 0
  ❶ WHILE i < node.size AND target >= node.keys[i]:
      ❷ IF target == node.keys[i]:
            return node.keys[i]
        i = i + 1

    # Descend to the correct child.
  ❸ IF node.is_leaf:
        return null
  ❹ return BTreeNodeSearch(node.children[i], target)

这段搜索代码首先通过使用WHILE循环 ❶扫描当前节点中存储的键。循环会持续直到遇到键列表的末尾(i == node.size)或遇到比目标值大的键(target < node.keys[i])。代码检查是否在当前节点中找到了匹配的键,如果找到了,就返回该键 ❷。虽然示例代码使用线性扫描来搜索节点以简化示例,但我们也可以使用二分搜索来提高效率。

如果代码在当前节点中没有找到匹配项且当前节点是叶子节点,则树中没有匹配项,代码返回null ❸。否则,代码会递归地探索正确的子节点。由于循环停止的条件是i表示最后一个子节点或key[i] > target,它可以通过循环迭代器i直接访问正确的子节点 ❹。

考虑之前在图 12-1 中展示的 B 树搜索键 17 的示例。在根节点,我们检查第一个键(51),发现它大于 17,因此使用第一个子节点指针下降一级。在下一层,我们检查两个键:12 小于目标键,因此继续跳过它;31 大于目标键,因此通过第二个子节点指针下降到键值小于 31 的子节点。这个过程在叶子节点继续进行。图 12-4 展示了这一搜索过程,用灰色标示出我们已访问并比较过的数组单元。

B 树的搜索访问了顶级节点中的键 51、第二级子节点中的键 12 和 31,以及第三级子节点中的键 13 和 17。

图 12-4:B 树的搜索示例。被阴影标记的单元格是算法检查过的单元格。

我们应该考虑搜索节点内的密钥如何影响运行时:与在单次比较后下移到下一层不同,我们现在可能需要在每个节点执行多个比较。这是一个可接受的权衡,原因有二。首先,记住 B 树是为了减少提取节点的数量而优化的。相比之下,节点内的数据访问预计是相对便宜的,因为它们发生在本地内存中,并不需要我们从昂贵的存储中提取另一个数据块。其次,同样重要的是,B 树的分支结构仍然提供了足够的修剪机会。每次比较仍然会消除整个子树。而且,与当前节点一样,每个被跳过的节点最多可以包含 2k 个密钥和 2k + 1 个子节点。

回到我们的收藏品示例,考虑查找某个特定的收藏品。我们从根目录文件夹开始。由于密钥按字母顺序存储,我们可以快速地扫过行,直到找到我们想要的密钥,或者经过它应该在的位置。如果我们没有看到目标密钥,我们就知道它不在这个文件夹里。我们嘟囔着对有限存储空间的不满,并注意到我们遇到的第一个紧跟目标密钥的密钥指针显示为“文件夹 #300”。我们又嘟囔了几句抱怨,并请求档案管理员提供文件夹 #300。

让我们将这种存储方法与如果我们将所有索引卡按排序顺序存储时的情况进行对比。文件夹 #1 包含从 AaAb 的第一组卡片,文件夹 #2 包含 AcAd 的卡片,以此类推。这对于静态数据集可能效果很好。我们可以对文件夹执行二分查找,每次请求当前范围内的中间文件夹,并将请求限制在对数数量的请求内。然而,随着我们添加或删除卡片,这种方法开始出现问题。文件夹变得过满,要求我们将卡片从一个文件夹转移到下一个文件夹。我们的收藏更新可能需要对许多文件夹进行级联更新,因为卡片必须被转移。最坏的情况下,如果我们将文件夹装得满满的,我们可能需要访问索引中的每个文件夹。正如我们接下来会看到的,B 树结构有助于数据集的动态变化。

添加密钥

向 B 树中添加密钥比向我们之前考虑的基于树的数据结构中添加密钥更为复杂。在这种情况下,我们需要保持结构的平衡,并限制每个节点中存储的密钥数量(在k到 2k之间)。有两种方法可以处理满节点。首先,我们可以在树的过程中进行分裂,确保我们永远不会在满节点上调用插入操作。其次,我们可以暂时插入到一个满节点中(允许它过满),然后在回溯时对其进行分裂。我们将探讨后者方法,它产生了一种两阶段算法来插入新密钥。

执行插入时,我们首先沿着树向下,寻找插入新键的位置。其次,我们沿树向上返回,拆分那些已满的节点。每次拆分都会增加节点的分支因子,但不一定增加树的高度。事实上,只有在拆分根节点时,树的高度才会增加,因为我们通过拆分根节点(同时将每个叶子深度增加 1)来增加高度,因此我们可以保证树始终保持平衡。

加法算法

在算法的第一阶段,我们递归地下降树,寻找插入新键的正确位置。如果在此过程中找到匹配的键,我们可以更新该键的数据。否则,我们继续向下直到叶子节点,在那里我们将键插入到数组中。

我们首先定义一个简单的辅助函数BTreeNodeAddKey,用于向一个未满的节点插入键。为了方便起见,我们还传入一个指向子节点的指针(表示新键之后的子节点),这样我们就可以在拆分节点时重用该函数。如果我们当前在叶子节点中(叶子节点不存储指向子节点的指针),则忽略这个next_child指针。

BTreeNodeAddKey(BTreeNode: node, Type: key, 
                BTreeNode: next_child):
  ❶ Integer: i = node.size - 1
    WHILE i >= 0 AND key < node.keys[i]:
        node.keys[i+1] = node.keys[i]
        IF NOT node.is_leaf:
            node.children[i+2] = node.children[i+1]
        i = i - 1

 # Insert both the key and the pointer to the child node.
  ❷ node.keys[i+1] = key
    IF NOT node.is_leaf:
        node.children[i+2] = next_child
  ❸ node.size = node.size + 1

代码从keys数组的末尾(索引node.size – 1)开始,使用WHILE循环向索引 0 方向推进❶。每一步,它检查新键是否应当插入此处,如果不插入,则将keyschildren数组中的当前元素都向后移动一个位置。循环在移动超过正确位置(可能是数组的起始位置)时终止。一旦找到新键的正确位置,我们已经将新键和后续元素移开了位置。我们可以直接插入新键和子元素❷。代码最后通过调整节点的大小来处理插入❸。

在这里,我们可能会因需要线性地将数组中的元素向下移动以为新元素腾出空间而感到失望,如图 12-5 所示。这就是我们在第三章中提到的所有问题。但请记住,我们是在用这些(有限的)线性成本来最小化节点访问。我们愿意忍受将卡片从文件夹中移开的麻烦,以最小化以后向其他文件夹请求数据的次数。

一个包含四个位置的数组。键值 26 被插入到数组的第二个位置,随后两个键值 31 和 45 分别被向后移动一个位置。

图 12-5:在BTreeNodeAddKey中移动元素以插入 26

我们需要一些额外的辅助函数来处理节点已满的情况。请记住,我们每个节点最多只能包含 2k个元素——超过这个数量就需要拆分节点。首先,我们定义一个简单的访问器函数BTreeNodeIsOverFull,该函数返回一个布尔值,指示节点是否包含超过 2k个元素:

BTreeNodeIsOverFull(BTreeNode: node):
    return node.size == (2 * node.k + 1)

这相当于检查我们是否已经用完了文件夹中的所有口袋。

我们还添加了一个第二个辅助函数BTreeNodeSplit,该函数接收一个节点和一个子节点的索引,并分裂该子节点。该索引之前的所有内容保留在原始子节点中。该索引之后的所有内容从子节点中清除并添加到新创建的兄弟节点中。索引处的键从子节点中清除并添加到当前(父级)节点中。

BTreeNodeSplit(BTreeNode: node, Integer: child_index): 
  ❶ BTreeNode: old_child = node.children[child_index]
    BTreeNode: new_child = BTreeNode(node.k)
    new_child.is_leaf = old_child.is_leaf

    # Get the index and key used for the split.
  ❷ Integer: split_index = Floor(old_child.size / 2.0)
    Type: split_key = old_child.keys[split_index]    

    # Copy the larger half of the keys (and their children) to 
    # new_child and erase them from old_child.
    Integer: new_index = 0
    Integer: old_index = split_index + 1
  ❸ WHILE old_index < old_child.size:
        new_child.keys[new_index] = old_child.keys[old_index]
        old_child.keys[old_index] = null

        IF NOT old_child.is_leaf:
            new_child.children[new_index] = old_child.children[old_index]
            old_child.children[old_index] = null
        new_index = new_index + 1
        old_index = old_index + 1

    # Copy the remaining child (after the last key).
  ❹ IF NOT old_child.is_leaf:
        new_child.children[new_index] = old_child.children[old_child.size]
        old_child.children[old_child.size] = null

    # Remove the key at index and add it to the current node.
  ❺ old_child.keys[split_index] = null
  ❻ BTreeNodeAddKey(node, split_key, new_child)

    # Update the sizes of the nodes.
  ❼ new_child.size = old_child.size - split_index - 1
    old_child.size = split_index

BTreeNodeSplit的代码首先查找需要分裂的节点(old_child),并创建一个新的(空的)兄弟节点(new_child)❶。该节点将与要分裂的子节点位于同一层级,因此我们复制is_leaf的值。接下来,代码确定用于old_child的分裂点的索引和值❷。然后,代码使用WHILE循环将old_childsplit_index之后的所有内容从keyschildren复制到new_child中的相应数组❸。代码使用一对索引来捕获旧位置的索引(old_index)和相应的新位置(new_index)。与此同时,代码通过将条目设置为null来从old_child的数组中删除这些元素。由于children数组比原来长一个元素,我们需要单独复制最后一个元素❹。最后,我们删除split_index处的键❺,将split_key和新的子节点指针添加到当前节点❻,并设置两个子节点的大小❼。

让我们在图 12-6 所示的收藏品存储索引的上下文中查看这个操作。当一个文件夹达到容量时,我们将其内容重新分配到两个文件夹中。首先,我们购买一个新的空文件夹。这个兄弟文件夹将存储约一半的超满文件夹内容。其次,我们小心地将超满文件夹的后半部分内容移动到新的文件夹中,同时保持已排序的顺序。第三,我们移除包含每个文件夹之间键的单个索引卡片,并将其插入到父级文件夹中,以指示两个子文件夹之间的划分。之前超满的子文件夹将包含键小于此分割的卡片,而新的文件夹将包含键大于此分割的卡片。

一排有三个条目的卡片:Caffeine Ten Coffee Mug、Jeremy’s Gourmet High‐Caffeine Experience Coffee Mug 和 Morning Zap Brand Coffee Mug。箭头表示左侧的卡片(以及所有之前的卡片)保留在文件夹中。第二个箭头表示中间的卡片移到父级文件夹中。第三个箭头表示右侧的卡片和所有后续的卡片将移到新的文件夹中。

图 12-6:我们通过在中间卡片的键上进行分割来重新分配文件夹。

有了这些辅助函数,我们现在可以定义一个插入函数,该函数执行递归搜索和随后的添加。我们在叶节点执行添加操作。随着递归从树中返回,我们检查最近访问的子节点是否已满,因此需要分裂。

BTreeNodeInsert(BTreeNode: node, Type: key):
    Integer: i = 0
  ❶ WHILE i < node.size AND key >= node.keys[i]:
      ❷ IF key == node.keys[i]:
            Update data.
            return
        i = i + 1

    IF node.is_leaf:
      ❸ BTreeNodeAddKey(node, key, null)
    ELSE:
      ❹ BTreeNodeInsert(node.children[i], key)
      ❺ IF BTreeNodeIsOverFull(node.children[i]):
          ❻ BTreeNodeSplit(node, i)

代码首先通过查找keys数组中key的正确位置来开始 ❶。WHILE循环遍历数组,直到到达键列表的末尾(i == node.size)或遇到一个比目标键大的键(key < node.keys[i])。如果代码找到精确匹配,它会更新该键的数据并返回 ❷。否则,它需要插入新数据。

如果密钥被插入到叶子节点中,代码会使用BTreeNodeAddKey函数 ❸,该函数会移动数组元素并将新密钥插入到正确的位置。如果密钥被插入到内部节点中,索引i提供了插入的正确子节点指针。代码会递归地将密钥插入到该子节点 ❹,然后检查插入操作是否破坏了 B 树的性质(特别是节点的大小是否在k和 2k之间) ❺。

如果代码在一个节点中插入太多元素,它就会破坏 B 树的性质。我们可以使用辅助函数BTreeNodeIsOverFull来检查最近修改的节点是否包含过多元素。代码从父节点开始进行这个检查,因此我们可以保持修复 B 树的逻辑简单。它使用BTreeNodeSplit将过满的子节点拆分为两个节点 ❻。在这个插入过程中,我们可能会在插入新的分隔键时破坏当前节点,但没关系;我们会在返回到该节点的父节点时处理它。

我们使用了稍微多一点的存储空间,以简化代码。代码允许一个节点暂时过满,存储 2k + 1 个键和 2k + 2 个子节点,同时等待其父节点调用BTreeNodeSplit。我们可以通过简单地为键和子节点分配足够大的数组来创建这个缓冲区。

我们可以将代码的第一阶段看作是为我们的收藏添加一个新的咖啡杯。我们为这个咖啡杯创建一个索引卡,并将其插入到我们的索引文件夹中。我们从根文件夹开始,寻找插入卡片的位置。在搜索过程中,我们跟随适当的指针进入子文件夹。一旦到达叶子文件夹,且索引卡上没有标明的子文件夹,我们就将新卡片添加到这里。我们检查文件夹是否已满,如果已满,则开始重新分配其内容。之后,我们将文件夹按请求的顺序反向返回存储。如果我们刚刚拆分了一个文件夹并将新卡片转移到其父文件夹中,我们还需要检查是否需要拆分父文件夹。这个过程会一直继续,直到我们返回到根文件夹。

我们需要为根节点定义一个额外的特殊情况。请记住,拆分根节点是唯一允许我们增加树的高度的方法。我们需要定义一个包装函数来完成这个操作。幸运的是,我们可以重用之前的辅助函数:

BTreeInsert(BTree: tree, Type: key): 
  ❶ BTreeNodeInsert(tree.root, key)

  ❷ IF BTreeNodeIsOverFull(tree.root):
      ❸ BTreeNode: new_root = BTreeNode(tree.k)
        new_root.is_leaf = False
        new_root.size = 0

      ❹ new_root.children[0] = tree.root
      ❺ BTreeNodeSplit(new_root, 0)
      ❻ tree.root = new_root

代码开始时通过BTreeNodeInsert ❶将key插入根节点。此函数递归地遍历树,找到正确的位置插入新键,并通过各层返回,修复除了根节点之外的所有节点的 B 树性质。然后,代码通过对根节点使用BTreeNodeIsOverFull ❷检查根节点是否有太多元素。如果根节点的元素太多,代码会通过创建一个新的空根节点来为树添加一层 ❸,并将旧根节点指定为新根的第一个子节点 ❹,分裂这个(过满的)子节点 ❺,并更新树的根 ❻。分裂后,新根节点将包含一个键和两个子节点。

在插入键的过程中,我们完成了一次从根节点到叶节点再返回的单一往返。我们需要访问(并修改)的节点数量与树的深度成正比。由于我们的 B 树始终保持平衡,所有叶节点都处于相同的深度,并且所有非根内部节点的分支因子至少为k + 1,因此节点的检索在N中呈对数增长。总的工作量还包括节点内部的线性操作,比如复制或移动键,因此所需的总工作量与k × logk成正比。

添加键的示例

让我们考虑几个例子,以更好地理解我们刚才介绍的功能。首先,拿最简单的情况来讲解,如图 12-7 所示,在一个不会过满的叶节点中添加键。假设k = 2,即我们的非根节点可以包含 2 到 2k = 4 个项目。如果我们将键 30 添加到图 12-7(a)中的子树,我们只需向下遍历到叶节点,并使用BTreeNodeAddKey辅助函数将新键添加到数组的正确位置。由于叶节点已有四个元素,我们不需要对其进行分裂。我们得到如图 12-7(b)所示的子树。

图 A 显示了一个包含一个内部节点和四个叶节点的子树。第二个叶节点包含键 13、17 和 26。图 B 显示了相同的树,但在第二个子节点的末尾添加了键 30。该节点现在包含键 13、17、26 和 30。

图 12-7:将键 30 插入一个非满的 B 树叶节点(a)会导致一个包含四个元素的叶节点(b)。

随着我们逐渐填充节点,逻辑变得更加复杂。考虑在图 12-8(a)中将键 29 添加到同一棵树的示例。在图 12-8(b)中插入新键后,叶节点变得过满。我们通过确定过满节点的分割点(键 = 26)并将其提升到父节点来处理这个问题。然后,我们使用辅助函数BTreeNodeSplit将叶节点分成两个兄弟节点,如图 12-8(c)所示。如果中间元素的提升使得内部节点也变得满了,我们还需要分裂它。

图 A 展示了一个子树,其中有一个内部节点和四个叶子节点。第二个叶子节点包含键值 13、17、26 和 30。在图 B 中,键值 29 已添加到第二个子节点中,导致节点过满,包含键值 13、17、26、29 和 30。在图 C 中,第二个子节点已分裂为两个节点(13、17)和(29、30),并且键值 26 已被提升到父节点。

图 12-8:将键值 29 插入已满的叶子节点(a)会使叶子节点的元素过多(b)。我们必须将过满的叶子节点分裂,以恢复 B 树的条件(c)。

最后,考虑一下如果我们的分裂传播到根节点会发生什么情况。假设在插入之后,根节点本身过满,如图 12-9(a)所示。我们在图 12-9(b)中通过分裂根节点并为树创建一个新层次来解决这个问题。新的根节点只包含一个元素,即原根节点的中间键值。请注意,与所有其他节点不同,根节点可以包含少于k个元素。实际上,每次我们分裂根节点时,都会创建一个只包含一个元素的新根节点。

如本节中的示例所示,对 B 树的修改仅限于在初始查找插入位置时探索过的那些节点。由于我们不需要更新或修复其他分支,因此修改的节点总数由树的深度限制,从而与 logk成比例地扩展。

图 A 展示了一个根节点过满的 B 树,其中包含键值 12、31、51、61 和 86。根节点有五个子节点,且都是叶子节点。图 B 展示了分裂后的树形结构,新的根节点的键值为 51,并且有两个子节点。根节点的左子节点包含键值 12 和 31,右子节点包含键值 61 和 86。树的五个叶子节点保持不变。

图 12-9:当 B 树的根节点过满时(a),我们将其分裂成两个兄弟节点,并将中间的元素提升为新的根节点(b)。

删除键值

删除键值的方法与添加键值类似。我们同样需要保持结构平衡,并限制每个节点中存储的键值数量(在k和 2k之间)。这导致了一个多阶段的删除算法。首先,我们沿着树向下查找,仿佛我们在查找该键值。一旦找到该键值,我们删除它。最后,我们向上返回树并检查并修复那些键值过少的节点。由于我们从不删除节点,除了空的根节点(它会使所有叶子节点的深度减少一),我们再次保证树始终保持平衡。

修复过少的节点

当我们从 B 树中删除键值时,存在节点键值少于k的风险。我们可以通过一个简单的辅助函数来检查这一条件:

BTreeNodeIsUnderFull(BTreeNode: node):
    return node.size < node.k

根据 B 树的结构,我们可能需要使用两种不同的方法来修复节点不足的情况,我将在本节中讨论这两种方法。每种方法都依赖于从相邻的兄弟节点中增加键来扩展当前节点。在第一种情况下,我们直接将两个小的兄弟节点合并成一个节点。在第二种情况下,我们将一个较大兄弟节点的键转移到不足的节点中。我们使用哪种方法取决于两个兄弟节点的总键数。以上这两个辅助函数都从父节点调用,且需要提供分隔相邻兄弟节点的索引。

合并操作接收两个相邻的兄弟节点以及分隔它们的键,并将它们连接成一个大的子节点。因此,它要求这两个兄弟节点中键的总数必须小于2k,以确保新的子节点是有效的。图 12-10 展示了这一过程,其中图 12-10(a)显示了合并操作前的子树,中间的子节点只有一个键。图 12-10(b)显示了合并后的子树。

图 A 显示了一个具有三个叶子节点和一个内部节点的节点。内部节点有 26 和 31 两个键。中间的叶子节点只有一个键 29,最右边的叶子节点有两个键 32 和 42。图 B 显示了一个具有两个叶子节点的节点。内部节点现在只有一个键 26。最右边的节点现在有键 29、31、32 和 42。

图 12-10:B 树节点的合并操作

清单 12-1 展示了合并两个相邻兄弟节点的代码。

BTreeNodeMerge(BTreeNode: node, Integer: index):
  ❶ BTreeNode: childL = node.children[index]
    BTreeNode: childR = node.children[index + 1]

    # Copy over the parent's key and the right child's first child pointer.
  ❷ Integer: loc = childL.size
    childL.keys[loc] = node.keys[index]
    IF NOT childL.is_leaf:
        childL.children[loc + 1] = childR.children[0]
    loc = loc + 1

  ❸ # Copy over the right child's keys and children.
    Integer: i = 0
    WHILE i < childR.size:
        childL.keys[loc + i] = childR.keys[i]
        IF NOT childL.is_leaf:
            childL.children[loc + i + 1] = childR.children[i + 1]
        i = i + 1
    childL.size = childL.size + childR.size + 1

    # Remove the key from the current node.
    i = index
  ❹ WHILE i < node.size - 1:
        node.keys[i] = node.keys[i + 1]
        node.children[i + 1] = node.children[i + 2]
        i = i + 1
    node.keys[i] = null
    node.children[i + 1] = null
    node.size = node.size – 1

清单 12-1:合并两个子节点的代码

代码将右子节点的键和父节点的分隔键追加到左子节点上。首先,它通过获取两个子节点来开始操作,我们分别称之为childLchildR,代表左子节点和右子节点❶。根据定义,childL中的任何键都小于分隔键,childR中的任何键都大于分隔键。接着,代码将父节点的分隔键和右子节点的第一个子指针追加到左子节点的末尾❷。它使用WHILE循环复制右子节点剩余的键和指针❸。同时,更新左子节点的大小。此时,已成功地将两个子节点合并成一个新的节点。合并后的子节点指针存储在node.children[index]中。

代码最后通过清理父节点来结束操作❹。它通过将后续的键和指针向前移动,删除之前的分隔键和指向右子节点的指针,将最终的空位设置为null,并更新当前节点的大小。

在合并两个节点的过程中,我们从它们的父节点获取一个键。这可能会导致父节点的键数量少于k,因此我们需要在树的下一个更高层次进行修复。

这个过程直接类似于我们在存储索引示例中合并文件夹的操作。如果一个索引文件夹包含的键太少,它将浪费空间并占用请求时间。我们不想请求一个只有一张索引卡的文件夹。合并文件夹的过程是将一个子文件夹中的卡片和父文件夹中的分隔卡一同取出,并按正确的顺序放入另一个子文件夹中。由于我们已经请求了父文件夹和一个子文件夹(因此它们已经在本地内存中),我们可以通过仅对另一个子文件夹进行一次额外的请求,快速完成合并操作。

修复不足节点的第二种方法是将一个键(及可能的子节点)从其相邻的兄弟节点移动过来。这种方法只有在兄弟节点能够失去一个键时才有效,因此适用于兄弟节点的键总数至少为 2k 的情况。虽然我们可以合并并最优地重新分配相邻兄弟节点,但为了说明问题,我们使用一种更简单的方法,只转移一个键。由于在删除或合并操作期间我们只会从节点中删除一个键,转移一个键就足以修复我们的不足节点。

然而,如图 12-11 所示,我们不能简单地从一个子节点取出一个键并给另一个子节点。父节点中的分隔键限制了拆分的范围。因此,我们进行两阶段的转移。首先,我们将当前的分隔键从父节点转移到不足节点。然后,我们用另一个兄弟节点中的一个键替换父节点中的分隔键。

图 A 显示一个有三个叶子节点和一个内部节点的节点。内部节点包含键 26 和 31。中间叶子节点只有一个键 29,而最右边的叶子节点有三个键 32、42 和 45。图 B 显示相同的子树。内部节点现在包含键 26 和 32。中间叶子节点现在有两个键 29 和 31,而最右边的节点有两个键 42 和 45。

图 12-11:B 树节点上的左转移操作

如清单 12-2 所示,我们将代码拆分成两个辅助函数,一个用于将键从右子文件夹转移到左子文件夹,另一个用于反向转移。将键从右子文件夹转移到左子文件夹的代码会转移两个键:一个从右子文件夹转移到父文件夹,另一个从父文件夹转移到左子文件夹。

BTreeNodeTransferLeft(BTreeNode: node, Integer: index):
  ❶ BTreeNode: childL = node.children[index]
    BTreeNode: childR = node.children[index + 1]
    Type: middle_key = node.keys[index]

  ❷ node.keys[index] = childR.keys[0]
  ❸ childL.keys[childL.size] = middle_key
    IF NOT childR.is_leaf:
        childL.children[childL.size + 1] = childR.children[0]
    childL.size = childL.size + 1

  ❹ Integer: i = 0
    WHILE i < childR.size - 1:
        childR.keys[i] = childR.keys[i + 1]
        IF NOT childR.is_leaf:
            childR.children[i] = childR.children[i + 1]
        i = i + 1

  ❺ childR.keys[i] = null
    IF NOT childR.is_leaf:
        childR.children[i] = childR.children[i + 1]
        childR.children[i + 1] = null
  ❻ childR.size = childR.size – 1

清单 12-2:将键和子指针从其右侧兄弟节点转移到不足节点的代码

代码首先通过检索两个相邻的兄弟节点和分隔键 ❶ 开始。它将第一个键从右侧子节点移到替换先前的分隔键 ❷。然后将来自父节点的先前分隔键(middle_key)和右侧子节点的第一个子节点指针添加到左侧子节点数组的末尾 ❸。此时,左侧子节点和父节点都已更新。接下来,代码清理右侧子节点。它使用 WHILE 循环将剩余的元素移到一边 ❹,将现在空的槽标记为 null ❺,并调整大小 ❻。

从左子节点向右子节点传输键的代码与 示例 12-3 中展示的类似。两键传输的方向相反:一个从左子节点到父节点,另一个从父节点到右子节点。

BTreeNodeTransferRight(BTreeNode: node, Integer: index):
  ❶ BTreeNode: childL = node.children[index]
    BTreeNode: childR = node.children[index + 1]
    Type: middle_key = node.keys[index]

    # Make space in childR for the new key and pointer.
  ❷ Integer: i = childR.size - 1
    WHILE i >= 0:
        childR.keys[i+1] = childR.keys[i]
 IF NOT childR.is_leaf:
            childR.children[i+2] = childR.children[i+1]
        i = i – 1
    IF NOT childR.is_leaf:
        childR.children[1] = childR.children[0]

  ❸ childR.keys[0] = middle_key
    IF NOT childR.is_leaf:
        childR.children[0] = childL.children[childL.size]
    childR.size = childR.size + 1

  ❹ node.keys[index] = childL.keys[childL.size – 1]

  ❺ childL.keys[childL.size - 1] = null
    IF NOT childL.is_leaf:
        childL.children[childL.size] = null
    childL.size = childL.size – 1

示例 12-3:将键和子节点指针从其左兄弟传输到欠满节点的代码

代码再次通过检索两个相邻的兄弟节点和分隔键 ❶ 开始。然后,代码将右侧节点中的键和子节点移动,以为新的元素腾出空间 ❷。它将来自父节点的先前分隔键(middle_key)和来自左子节点的最后一个子节点指针添加到右侧节点的开始位置 ❸,并将其大小增加 1。接下来,代码将左侧子节点中的最后一个键移动到父节点中的分隔键位置 ❹。代码最后通过将现在空的条目标记为 null 来清理左侧子节点,并更新大小 ❺。

与合并操作不同,传输操作不会减少父节点中的键数量。因此,我们不需要在树的更高层级执行修复操作。这些传输操作的物理对应是请求一个兄弟存储绑定器,并在两个子节点和父节点之间移动两张索引卡。我们从父节点取出介于两个绑定器之间的中间卡片,并将其添加到较不满的子节点绑定器中。我们用来自拥有更多卡片的子节点的适当卡片替换父节点中的这张卡片。

我们可以将这三种修复功能以及选择它们的逻辑封装成一个辅助函数,该函数接受当前节点和欠满子节点的索引:

BTreeNodeRepairUnderFull(BTreeNode: node, Integer: child):
  ❶ IF child == node.size:
        child = child - 1
  ❷ Integer: total = (node.children[child].size + 
                      node.children[child + 1].size)

    IF total < 2 * node.k:
      ❸ BTreeNodeMerge(node, child)
        return

 ❹ IF node.children[child].size < node.children[child + 1].size:
        BTreeNodeTransferLeft(node, child)
    ELSE:
        BTreeNodeTransferRight(node, child)

为了知道使用哪种修复策略,代码需要找到一个相邻的兄弟节点,并检查这两个子节点的总键数。在这里,为了说明,我们使用一种简单的策略,总是使用下一个子节点(child + 1)作为兄弟节点,除非我们正在修复数组中的最后一个子节点❶。如果我们正在修复数组中的最后一个子节点,则使用前一个子节点作为其兄弟节点。代码检查这两个子节点中键的总数❷。如果键的数量足够小(小于 2k),则将这两个节点与BTreeNodeMerge函数合并❸。否则,如果节点的键数为 2k或更多,代码使用BTreeNodeTransferLeftBTreeNodeTransferRight将一个键移动到较小的节点❹。

查找最小值键

我们在删除操作中使用了一个额外的辅助函数——用于查找并返回给定节点下或以下的最小键的代码。此代码在清单 12-4 中,也可以单独使用,例如计算 B 树中键的范围。

BTreeNodeFindMin(BTreeNode: node):
  ❶ IF node.size == 0:
        return null
  ❷ IF node.is_leaf:
        return node.keys[0]
    ELSE:
      ❸ return BTreeNodeFindMin(node.children[0])

清单 12-4:查找给定节点下或以下的最小键的代码

代码包含三种可能的情况。如果节点为空,代码返回null,表示那里没有最小键❶。这应该只发生在空的根节点中,因为所有其他节点至少会有k个键。如果节点是一个非空的叶子节点,代码返回节点数组中的第一个(即最小的)键❷。最后,如果节点是内部节点,代码递归检查第一个子节点❸。

删除算法

我们从顶层的包装函数开始描述删除算法。这个函数相对简单,它通过使用树的根节点调用递归删除函数。

BTreeDelete(BTree: tree, Type: key):
    BTreeNodeDelete(tree.root, key)

    IF tree.root.size == 0 AND NOT tree.root.is_leaf:
        tree.root = tree.root.children[0]

就像我们在拆分节点时只添加一个级别一样,只有当根节点变为空时,才会从树中移除一个级别。如果 B 树不完全为空,空的根节点仍然会在数组位置 0 处有一个有效的子节点。我们使用这个子节点来替代原来的根节点。

核心删除算法递归地下降树,寻找要删除的键。由于我们可能会将键的数量减少到低于所需的k,因此需要检查修改后的子节点是否现在已不足满,如果是,则进行修复。

BTreeNodeDelete(BTreeNode: node, Type: key):
  ❶ Integer: i = 0
    WHILE i < node.size AND key > node.keys[i]:
        i = i + 1

    # Deletion from a leaf node.
    IF node.is_leaf:
        IF i < node.size AND key == node.keys[i]:
          ❷ WHILE i < node.size - 1:
                node.keys[i] = node.keys[i + 1]
                i = i + 1
            node.keys[i] = null
            node.size = node.size - 1
        return

    # Deletion at an internal node.
    IF i < node.size AND key == node.keys[i]:
      ❸ Type: min_key = BTreeNodeFindMin(node.children[i+1])
        node.keys[i] = min_key

      ❹ BTreeNodeDelete(node.children[i+1], min_key)
        IF BTreeNodeIsUnderFull(node.children[i+1]):
            BTreeNodeRepairUnderFull(node, i+1)
    ELSE:
      ❺ BTreeNodeDelete(node.children[i], key)
        IF BTreeNodeIsUnderFull(node.children[i]):
            BTreeNodeRepairUnderFull(node, i)

代码通过扫描键数组来开始在当前节点中查找要删除的键❶。如果在此节点中找到匹配的键,WHILE循环终止,i将是匹配键的索引。

然后代码考虑叶子节点的情况。如果节点是叶子节点并且找到了键,代码通过移动键来删除它❷。代码还将最后一个元素设置为null并更新大小。由于叶子节点没有设置子指针,代码不需要更改子指针。如果节点是叶子节点但未找到键,代码则直接返回。

接下来,代码处理内部节点的情况。需要考虑两种情况:键在节点中,或者不在。如果代码在内部节点中找到了键,它会用在排序顺序中紧随目标键之后的键替换该键 ❸。代码通过调用BTreeNodeFindMin,从 Listing 12-4 中获取在目标键之后紧跟的子节点的最小键。代码然后通过调用BTreeNodeDelete ❹来从子树中删除这个紧随的键。接着,代码会检查子节点是否欠满,如果是,进行修复。

如果目标键不在内部节点中,那么代码会递归地在相应的子节点上调用BTreeNodeDelete ❺。再次需要检查该子节点是否现在是欠满的,如果是,进行修复。

与插入类似,我们的目标是限制在此操作中检索的节点数量。删除操作最多会从根节点到叶子节点进行一次遍历。即使我们从内部节点中删除,随后的替换和删除操作仍然只会继续遍历到一个单独的叶子节点。每当我们修复一个节点并检索欠满节点的兄弟节点时,我们就需要额外执行一次请求。

删除键的示例

让我们看几个刚才讨论过的删除例子。首先,考虑最简单的情况,展示在 Figure 12-12 中,即从包含超过k + 1 个键的叶子节点中删除键。假设k = 2,我们的非根节点可以包含 2 到 2k = 4 个元素。如果我们从 Figure 12-12(a)中的子树中移除键 5,我们只需继续向下到叶子节点并移除数组中的该键。由于结果叶子节点包含三个元素,我们无需修复它。我们得到 Figure 12-12(b)所示的子树。

图 A 显示一个包含一个内部节点和五个叶节点的子树。第一个叶节点包含键 1、3、5 和 6。图 B 显示与图 A 相同的树,键 5 已从第一个叶节点的中间删除。现在该节点包含键 1、3 和 6。

图 12-12:从 B 树叶节点中删除键 5(a)结果是一个包含三个元素的叶节点(b)。

接下来,我们考虑在不需要修复的情况下,从内部节点中移除一个键的情况,如 Figure 12-13 所示。如果我们从 Figure 12-13(a)中的子树中移除键 45,我们发现该键在一个内部节点中。为了移除它,我们用排序顺序中的下一个键替换它,即 47。由于结果节点至少有两个元素,因此我们不需要进行任何修复。我们得到 Figure 12-13(b)中所示的子树。

图 A 显示了一个子树,其中包含一个内部节点和五个叶子节点。内部节点包含键 12、26、31 和 45。第五个(最右边的)叶子节点包含键 47、48 和 49。图 B 显示了与图 A 相同的树,但删除了内部节点中的键 45。原本位于图 A 中最右边节点的键 47 替代了键 45。第五个叶子节点现在包含键 48 和 49。

图 12-13:从内部 B 树节点中删除键 45(a)会导致从子节点中获取一个键(b)。

最后,我们考虑删除键时需要修复不足元素节点的不同情况。图 12-14 展示了一个可以合并两个节点的情况。我们从删除 图 12-14(a) 中的键 32 开始。图 12-14(b) 显示了我们用于合并操作的键:不足节点中的键、其右侧相邻兄弟节点中的键,以及父节点中分隔这两个节点的键。图 12-14(c) 显示了修复后的树。新的子节点包含四个键,原父节点包含三个键。

图 A 显示了一个子树,其中包含一个内部节点和五个叶子节点。内部节点包含键 12、26、31 和 45。第四个叶子节点包含键 32 和 42。第五个叶子节点包含键 47 和 48。在图 A 中,键 32 从一个叶子节点中删除,留下了一个键 42。图 B 显示了右边两个子节点和分隔键 45 的虚线。图 C 显示了最终的树,合并后的子节点包含了四个子节点,内部节点包含键 12、26 和 31。最右边的叶子节点现在包含键 42、45、47 和 48。

图 12-14:从几乎为空的节点中删除键 32(a)会使叶子节点的元素过少(b)。我们必须与相邻的兄弟节点合并,以恢复 B 树的条件(c)。

图 12-15 展示了一个可以从更大的兄弟节点转移键的情况。我们从删除 图 12-15(a) 中的键 32 开始。图 12-15(b) 显示了我们用来恢复平衡的键:不足节点中的键、其右侧相邻兄弟节点中的键,以及父节点中分隔这两个节点的键。图 12-15(c) 显示了哪些键将被移动以及移动到哪里。修复后的树显示在 图 12-15(d) 中。

图 A 显示了一个子树,包含一个内部节点和五个叶子节点。内部节点包含键 12、26、31 和 45。第四个叶子节点包含键 32 和 42。第五个叶子节点包含键 47、48 和 49。在图 A 中,键 32 被从叶子节点中删除,剩下单一的键 42。图 B 显示了一条虚线,围绕着最右侧的两个子节点和分隔键 45。图 C 包括箭头,表明最右侧子节点的键 47 将移入父节点,而父节点的键 45 将移至不足的子节点。最终的树结构显示在图 D 中。内部节点有四个子节点,并包含键 12、26、31 和 47。第四个叶子节点包含键 42 和 45。第五个叶子节点包含键 48 和 49。

图 12-15:从几乎为空的节点中删除键 32(a)导致叶子节点元素过少(b)。我们可以通过从相邻的兄弟节点(c)获取一个键来修复这一问题,以恢复 B 树的条件(d)。

最终,图 12-16 展示了通过合并根节点下唯一的两个子节点来删除树中的一个层级的情况。图 12-16(b)显示,合并后,根节点为空,其唯一的键已被移至合并后的节点。我们在图 12-16(c)中通过删除旧根节点,并将该节点的唯一子节点提升为新的根节点来修复此问题。

与插入操作不同,删除操作可能会修改其他分支的节点。在插入过程中,B 树的修改仅限于初次搜索插入位置时访问的节点,而删除操作则可能修改其他分支的节点。合并节点和转移键值都使用同一级别的兄弟节点。然而,被修改的节点总数仍然受树的深度限制。最多,我们每一层只能访问一个兄弟节点,访问的节点数随 N 的对数增长。

在图 A 中,根节点有键 51,并且有两个子节点,左子节点包含键 12 和 31,右子节点包含键 61。图 B 显示了合并操作的结果,根节点为空,只有一个子节点,包含键 12、31、51 和 61。图 C 显示了最终的树结构,其中旧根节点已被移除,新的根节点是包含键 12、31、51 和 61 的节点。

图 12-16:合并根节点下唯一的两个子节点(a)导致根节点为空(b)。我们通过将根节点的唯一子节点提升为新的根节点(c)来修复这一问题。

为什么这很重要

B 树展示了几个重要的概念。首先,它们展示了如何调整先前数据结构的行为,以处理节点间内存访问比节点内访问更昂贵的情况。B 树以某种方式将索引和存储结合起来,从而最小化所需的访问次数。这对于可能将信息存储在磁盘或外部服务器上的大数据集至关重要。通过强制每个非根节点至少有 k 个键,我们确保每个节点的分支因子至少为 k + 1,从而将整个数据结构“扁平化”。这有助于限制树的整体深度,从而减少搜索、插入或删除所需的检索次数。我们还保证每个非根节点始终保持至少半满,这意味着我们不会浪费时间去检索只有少量元素的节点(除了可能是根节点的情况)。

将 B 树的方式与我们收藏品的更专门化索引方案进行对比是有用的。我们可以开发一个数据结构,最初按类别进行划分。顶级索引将收藏品的类别(例如与咖啡相关的收藏品)映射到该特定类别的文件夹。每个类别的文件夹再映射到所有子类别,如咖啡杯或咖啡海报,依此类推。这也是一种有效的方法,建立在本书中看到的分支结构的基础上。其权衡在于通用性与效率。在许多情况下,我们可以将数据结构进一步优化以适应当前任务,但这可能会失去将其应用于其他问题的能力。在某些情况下,这种权衡可能是值得的,但在其他情况下则可能不值得。与聚焦于类别的索引方案相比,B 树提供了一种更为通用的方法,适用于任何可排序的键集。

B 树展示的第二个概念是数据结构本身的第二级动态性。B 树会不断调整其结构,以适应存储的数据分布,从而保持平衡。正如我们在第五章看到的那样,如果树变得高度不平衡,我们就会失去基于树的结构所带来的优势。B 树通过限制每个节点中键的数量(k 到 2k)以及确保所有叶子节点具有完全相同的深度来程序化地防止这一问题。它们通过重新平衡来适应“不良”数据分布——修正那些包含过多或过少项目的节点。虽然树的平衡策略有很多种,B 树提供了一个简单明了的例子,展示了我们如何通过添加额外的结构(在这种情况下,每个节点多个键)来避免最坏情况的发生。

第十三章:布隆过滤器

正如我们在上一章中所看到的,我们经常需要意识到我们的数据结构如何适应本地内存,以及如何限制从较慢内存中的数据检索。随着数据结构的增长,它可以存储更多数据,但可能无法完全适应最快的内存。本章介绍了布隆过滤器,这是一种扩展哈希表核心概念的数据结构,用来限制过滤大范围键时所需的内存量。

布隆过滤器由计算机科学家伯顿·布隆(Burton Bloom)于 1970 年发明。布隆过滤器通过精心优化内存使用和执行时间,跟踪哪些键已被插入。它试图回答一个非常简单的是/否问题:我们之前是否见过这个键?例如,我们可能使用布隆过滤器来检查密码是否在已知弱密码的列表中。或者,我们可以在访问一个更大、更全面但较慢的数据结构之前,先使用布隆过滤器作为预筛选步骤。

在追求极致效率的过程中,布隆过滤器使用了一种可能导致假阳性的策略。这意味着布隆过滤器在某些非零的概率下,会错误地表示一个键已经插入,尽管实际上它并没有被插入。换句话说,一个用于筛选坏密码的布隆过滤器可能偶尔会拒绝一个好的密码。

正如我们将要看到的,低内存开销、快速执行时间以及保证没有假阴性(即实际存在的键在检查时未被识别)的组合,使得布隆过滤器在预筛选任务和访问更昂贵数据结构之前的初步检查中非常有用。这导致了类似于第十一章缓存方法的两阶段查找。为了检查记录是否在我们的数据集中,我们首先检查它是否已被插入布隆过滤器。由于布隆过滤器是一个紧凑的数据结构,存储在快速内存中,因此我们可以快速进行此检查。如果布隆过滤器返回假,说明该记录不在我们的数据集中,我们可以跳过在完整数据结构中的昂贵查找。如果布隆过滤器返回真,那么要么是我们遇到了假阳性,要么是该记录确实在我们的较大数据结构中,我们将执行完整的搜索。

想象一下,我们想要在进行更耗费计算的搜索之前,确定一个给定的记录是否存在于一个庞大的医疗记录数据库中。这个医疗数据库庞大,包含图像和视频,必须分布存储在多个大型硬盘上。此外,由于记录数以百万计,即使是索引也太大,无法完全适应本地内存。虽然它偶尔会返回一个假阳性,并让我们搜索一个不存在的记录,但布隆过滤器也会帮助我们避免许多无意义的搜索。每当布隆过滤器指示某个记录不在数据集中时,我们就可以立即停止搜索!

介绍布隆过滤器

从本质上讲,布隆过滤器是一个二进制值数组。每个桶跟踪我们是否曾见过任何与该哈希值相匹配的内容。值为 1 表示该桶之前已经被见过。值为 0 表示它之前没有被见过。

当我们想在大量值中轻松查找单个值时,布隆过滤器可以非常有用。可以想象,在一个大型、拥挤的舞厅里寻找朋友的情境中,这种过滤方式是如何工作的。我们可能需要花费数小时在舞池中徘徊、打量人群的面孔,最后才得出结论,发现我们的朋友并未出席。要简单得多的做法是,先询问一位知识渊博的活动组织者。我们向组织者描述我们的朋友,组织者记忆力非常好,可以确认是否有与描述相符的人在场。我们的描述和组织者的心理地图由一系列基本属性构成。我们的朋友个子高,穿着运动鞋,戴着眼镜。

活动组织者的回答可能仍然不是百分之百准确——我们使用的是一般属性,多个与之相符的人可能会共享这些描述。有时,组织者可能会给我们一个误报,说:“我见过一个有这些特征的人”,即便我们的朋友并不在场。但他们永远不会做出错误的否定,告诉我们朋友不在场,而实际上他们在。如果组织者没有见过任何与我们列出的三个特征相符的人,那么我们可以确保我们的朋友不在活动现场。这个回答不需要没有任何假阳性,就能在平均情况下帮助我们。如果组织者能帮我们节省每 10 次搜索中的 9 次,那将是一次巨大的胜利。

让我们看看如何将第十章中学到的哈希技术扩展到这个预过滤问题。我们从一个简单的指示器数组开始,并检查它的不足之处。然后我们展示如何使用多个哈希函数来提供更强大的过滤方案。

指示器哈希表

考虑最简单的过滤器,一个通过单一哈希函数映射到的二进制指示器数组。当我们插入一个键时,我们计算哈希值并用 1 标记相应的桶。当我们查找一个键时,我们计算哈希值并检查相应的桶。这很简单,也很优雅。遗憾的是,它在发生最轻微的哈希碰撞时就会失效。

假设我们为我们的千页咖啡日志实现了这个简单的单哈希函数过滤器。每当我们想在日志中查找某种咖啡时,我们首先询问过滤器一个简单的问题:“我以前尝试过这种咖啡吗?”如果我们知道以前没有尝试过这款咖啡,这个过滤器通常可以帮助我们避免在千页日志中进行二分查找。

在第一个月,它工作得非常完美。每当我们尝试一种新的咖啡时,我们将其添加到日志中,并将过滤器中相应的位从 0 翻转为 1,如图 13-1 所示。我们将咖啡名称作为哈希函数的输入,从而能够按名称检查未来的咖啡。

字符串“House Blend”被映射到槽 6,值为 1。其他 11 个槽的值为 0。

图 13-1:单个哈希函数可以将一个字符串映射到数组的索引。

对于前几个条目,单哈希函数布隆过滤器的行为类似于常规哈希表(没有冲突解决机制),它为每个条目存储二进制值。我们将 1 插入每个看到的键对应的槽中,0 表示我们没有看到任何哈希到该值的条目。当我们询问是否尝试过“House Blend”咖啡时,如果查找返回 1,我们会简单地得到“是”的回答。

然而,随着我们向过滤器中添加越来越多的值,问题开始显现。经过一天的咖啡饮用,我们的二进制数组开始填满。如图 13-2 所示,即使我们每天只喝几种不同的咖啡,我们也开始用 1 填充数组。

二进制数组中的 12 个槽中有 4 个值为 1,其余为 0。

图 13-2:一个开始填满的二进制数组

由于布隆过滤器与哈希表不同,它不使用链式或任何其他机制来解决冲突,因此两个不同的咖啡有时会映射到相同的条目。很快,我们就无法知道我们是否真的品尝过“烧焦豆深烘焙”,还是那个对应的 1 只是由于我们之前品尝过“生豆,未烤,苦涩爆发”,它恰好也映射到相同的条目。记住在第十章中提到的,当我们从一个较大的键空间(咖啡名称集)映射到一个较小的键空间(数组中的条目)时,我们会遇到冲突。随着我们向日志中添加更多条目,这个问题会变得越来越严重。

一年内,我们最初的过滤器实际上变得无用。我们丰富的咖啡体验虽然令人愉快,但也将数组填满了 1。现在,我们的数组几乎变得像图 13-3 所示。我们的一半以上查询都会导致哈希冲突,因此产生假阳性。这个过滤器不再是一个高效的预过滤器。相反,它几乎总是增加了一个无用的检查开销,影响了实际的搜索。在我们的聚会示例中,这类似于只用一个属性来描述我们的朋友。如果我们只提供朋友的发色,活动组织者几乎总是见过符合该描述的人。

二进制数组中的 12 个槽中有 8 个值为 1,只有 4 个为 0。

图 13-3:一个过于满的二进制数组,无法作为有效的咖啡追踪器

对于我们日益增加的碰撞,最简单的解决方法是增加哈希值的空间。我们可以扩大二进制数组的大小。我们可以尝试使用 1,000 个指示值,而不是 100 个。这减少了碰撞的可能性,但并没有完全消除它。单一的碰撞仍然会导致假阳性,正如我们在第十章中看到的那样,一些碰撞是不可避免的。然而,我们的哈希方法并非注定失败。通过采用一种减少每次碰撞影响的策略,我们可以做得更好。

Bloom 过滤器

Bloom 过滤器通过极端化哈希函数的思想来解决碰撞问题。它不是为每个键使用单一哈希函数,而是采用k个独立的哈希函数,这些哈希函数将键映射到相同的哈希值范围。例如,如图 13-4 所示,我们可能会为我们的咖啡列表使用三个哈希函数。哈希函数f1将此键映射到索引 2;第二个函数f2将其映射到索引 6;第三个函数f3将其映射到索引 9\。

字符串“House Blend”通过第一个哈希函数映射到桶 2,通过第二个哈希函数映射到桶 6,通过第三个哈希函数映射到桶 9。

图 13-4:使用三个哈希函数将字符串 HOUSE BLEND 插入到 Bloom 过滤器中

正式地,我们将 Bloom 过滤器的操作定义如下:

  1. 插入键 对于每个k哈希函数,将键映射到一个索引并将该索引处的值设为 1。

  2. 查找键 对于每个k哈希函数,将键映射到一个索引并检查该索引处的值是否为 1。仅当所有k数组值都为 1 时,才返回 true。

乍一看,我们所做的似乎只是让问题变得更糟。我们不仅没有填充每个样本一个桶,而是填充了三个桶。我们的数组几乎可以保证更快地填满,并更早遇到碰撞。如果我们添加第二个条目,如图 13-5 所示,我们会向数组中添加更多的 1。

字符串“Morning shock”通过第一个哈希函数映射到桶 5,通过第二个哈希函数映射到桶 2,通过第三个哈希函数映射到桶 8。

图 13-5:使用三个哈希函数将字符串 MORNING SHOCK 插入到 Bloom 过滤器中

相反,我们实际上改善了情况。Bloom 过滤器的强大功能使得我们能够高效地查找条目。与其在遇到单一碰撞时产生假阳性(如果我们看到一个 1),我们要求所有的桶都包含 1,正如图 13-6 所示。

字符串“Pure Caffeine”通过第一个哈希函数映射到桶 9,通过第二个哈希函数映射到桶 8,通过第三个哈希函数映射到桶 5。

图 13-6:在具有三个哈希函数的 Bloom 过滤器中查找字符串 PURE CAFFEINE

如果我们看到一个单独的 0,我们就知道这个条目没有被插入到数组中。在图 13-7 中,我们可以看到我们从未抽样过 Caffeine +10 烘焙咖啡。在舞厅中,活动组织者只需要知道我们的朋友戴着一顶圆顶礼帽,就能明确回答他们没有看到任何符合这种描述的人。他们见过符合身高和发色要求的人,但没有见过戴着这种帽子的人。我们可以安全地避免进行全面搜索。

字符串“Caffeine +10”通过第一个哈希函数映射到桶 9,通过第二个哈希函数映射到桶 8,通过第三个哈希函数映射到桶 4。前两个哈希函数映射到包含 1 的桶,而最后一个哈希函数映射到包含 0 的桶。

图 13-7:在具有三个哈希函数的布隆过滤器中查找字符串 CAFFEINE +10

为了注册假阳性,每个哈希值必须与之前的条目发生碰撞。如果我们平衡数组的大小与哈希函数的数量,我们可以降低假阳性的概率。

我们可以通过与一位知识渊博的咖啡师的对话,形象地展示布隆过滤器如何处理碰撞。想象一下,在最近的一次旅行中,一位朋友递给我们一杯令人难以置信的咖啡。在享受了丰富的风味和浓烈的咖啡因之后,我们询问朋友关于这款新发现的咖啡。令我们失望的是,我们即将失去的朋友耸耸肩,指向一个大致方向,声称他们是在“那个方向”的一间店铺购买的。他们记不起咖啡师的名字、店铺名称,甚至连咖啡品牌也忘了。不幸的是,我们没有时间自己去找这家店并盘问店主,我们需要赶飞机回家。忍住失落的泪水,我们在一张纸上写下了一些特征,并决心追查这款神秘的咖啡。

回到家后,我们拜访了我们认识的最有经验的咖啡师,并展示了我们的笔记。我们已经记录下了五个关于咖啡的关键特征,比如“主要气味是巧克力”。显然,这些信息不足以唯一地识别出这款咖啡,但我们可以请咖啡师看看是否有符合这些描述的咖啡。在翻阅他们自己那本包含 10,000 页的咖啡日志之前,咖啡师独立地考虑了这些特征并排除了匹配项。尽管我们争辩说这些风味的搭配出奇地好,但这位专家从未听说过任何包含“额外甜美泡泡糖味”的咖啡。他们投来一瞥,嘟囔着些什么,说自己有标准。

在这种情况下,我们的五个属性实际上就是哈希函数,它们将复杂的咖啡体验映射到一个低维空间——一个描述词的数组。咖啡师会单独使用这些属性,检查是否知道有任何咖啡可能匹配,利用他们自己咖啡日志的索引。索引中的一项意味着至少有一个匹配项。每个属性的测试可能会遇到假阳性,但这没关系。在最坏的情况下,我们会浪费一些时间查看旧的咖啡日志条目,然后确认没有完美匹配。至少我们知道永远不会出现假阴性。如果其中一个特性是唯一的,咖啡师可以自信地说“不”,我们也可以放心地知道,咖啡师广泛的咖啡目录中没有我们正在寻找的那款神秘混合咖啡。

我们只需使用布隆过滤器步骤,就能快速做出当下的决策,而不必随后的搜索更大的数据结构。想象一下,我们可信赖的咖啡师维护着一个秘密的咖啡避免列表,包含世界各地 500 种糟糕得令人呕吐的咖啡,它们会让我们停止喝咖啡整整一个月。出于各种责任原因,他们不公开这个列表。但如果我们问咖啡师某个特定咖啡是否在列表上,他们会礼貌地提醒我们,或许我们更喜欢喝无咖啡因的咖啡。在品尝任何新咖啡之前,我们最好检查一下它是否在他们的列表上。

当然,每次我们有机会尝试新咖啡时,我们不可能每次都去找咖啡师,所以我们需要一种快速的决策方式。咖啡师利用五个信息属性,包括主要气味和粘稠度,为这些咖啡构建一个布隆过滤器,针对每个咖啡列表中的五个属性标记 1。当我们有机会品尝一种新咖啡时,我们会检查这五个属性是否与列表匹配。如果其中任何一个属性标记为 0,我们就可以放心地喝这杯新咖啡,保证它不在咖啡师的列表上。但是,如果所有属性都是 1,或许我们应该点别的东西。作为额外的好处,咖啡师也无需分发他们的秘密列表。

我们可以以类似的方式将布隆过滤器应用于计算机科学中的应用程序。考虑一下检查密码是否在已知弱密码列表中的问题。每次有人提出新密码时,我们可以系统地搜索整个列表。或者,我们可以创建一个布隆过滤器,快速检查是否应该拒绝该密码。偶尔我们可能会拒绝一个合理的密码,但我们可以保证永远不会让一个坏密码通过。

布隆过滤器代码

在最简单的形式中,布隆过滤器可以存储为仅包含二进制值的数组。为了使代码更清晰,我们将布隆过滤器封装在一个简单的复合数据结构中,包含参数,如大小和哈希函数的数量:

BloomFilter {
    Integer: size
    Integer: k
    Array of bits: bins
    Array of hash functions: h
}

给定这个包装器,插入和查找功能的代码可以通过一个单一的WHILE循环来实现:

BloomFilterInsertKey(BloomFilter: filter, Type: key):
    Integer: i = 0
 WHILE i < filter.k:
        Integer: index = filter.hi
        filter.bins[index] = 1
        i = i + 1

BloomFilterLookup(BloomFilter: filter, Type: key):
    Integer: i = 0
    WHILE i < filter.k:
        Integer: index = filter.hi
        IF filter.bins[index] == 0:
            return False
        i = i + 1
    return True

在这段代码中,filter.hi表示应用于key的布隆过滤器第i个哈希函数。两个函数都使用循环遍历* k * 个哈希函数,计算key的哈希值并访问布隆过滤器数组中的相应桶。在插入的情况下,代码将桶的值设置为1。在查找的情况下,代码检查桶是否包含0,如果是,则返回False

在最坏的情况下,函数的成本随* k * 线性增长,因为我们需要对每个操作遍历每个哈希函数。查找结构提供了额外的潜在优势。查找可以在找到第一个 0 后立即终止,跳过任何进一步的哈希函数。重要的是,插入和查找的运行时间与布隆过滤器的大小(桶的数量)和插入的项数无关。

调整布隆过滤器参数

有多个参数会影响布隆过滤器的假阳性率,包括数组的大小和使用的哈希函数的数量。通过调整这些参数以适应当前问题,我们通常可以将假阳性率保持在非常低的水平,同时最小化使用的内存量。我们可以通过实际数据、模拟或者多种数学近似来进行调整。

一个常见且简单的近似公式是:

FalsePositiveRate = (1 – (1 – 1/m)(*nk*))(k)

其中,n 是插入到布隆过滤器中的项数,m 是数组的大小,k 是使用的哈希函数的数量。这个近似使用了简化的假设,但它能很好地展示各种参数如何相互作用:

  • 增加数组的大小(m)总是会降低假阳性率,因为有更多的桶可以存储信息。

  • 增加插入的项数(n)总是会增加假阳性率,因为我们将更多的桶设置为 1\。

  • 增加哈希函数的数量(k)可以根据其他参数的不同而增加或减少假阳性率。如果使用过多的哈希函数,每次插入时都会填充大量的数组。如果使用的哈希函数太少,少量的碰撞可能会产生假阳性。

表 13-1 提供了布隆过滤器的大小(m)和哈希函数数量(k)如何影响假阳性率的见解,前提是插入的项数固定为(n = 100)。

表 13-1:不同参数(mk)下的假阳性率示例(n=100)

m k = 1 k = 3 k = 5
200 0.3942 0.4704 0.6535
400 0.2214 0.1473 0.1855
600 0.1536 0.0610 0.0579
800 0.1176 0.0306 0.0217
1000 0.0952 0.0174 0.0094

最终,最佳的参数设置将取决于具体的问题。我们需要在假阳性率、计算成本和内存成本之间选择一个最适合我们应用的权衡。

Bloom 过滤器与哈希表

此时,怀疑的读者可能会再次举手抗议:“为什么不直接使用哈希表呢?每当一个键出现时,我们可以将它和一些微不足道的数据(如布尔值 True)一起添加到哈希表中。然后我们可以在这个表中搜索精确匹配的键。当然,我们可能会因为碰撞而进行一些链式处理,但我们会得到精确的答案。为什么你总是把事情弄得这么复杂?”

这是一个合理的观点。哈希表确实能回答与 Bloom 过滤器相同的问题,且答案更加准确。但正如我们怀疑的读者所指出的那样,它们是以额外的空间和潜在的运行时间为代价的。为了使哈希表能够完全解决碰撞问题,我们需要存储足够的信息来确定性地表示我们之前确实见过这个精确的键,这就意味着需要存储键本身。再加上链式哈希表中的指针开销,如图 13-8 所示,我们可能会使用显著更多的内存。

哈希表桶的链表由包含键、值数据和指向下一个节点的指针的节点组成。

图 13-8:具有链式结构的哈希表需要为每个键分配内存,并至少需要一个指针。

相比之下,Bloom 过滤器不需要存储键或指向后续节点的指针。它只为每个桶保留一个二进制值。我们可以使用恰好 m 位来存储 m 个桶。这种极端的空间效率因多种原因而显得尤为宝贵。首先,它允许我们在保持内存可管理的情况下,大幅增加桶的数量(即过滤器的大小)。我们可以以一个 32 位整数的代价存储 32 个独立的桶。

第二点,更重要的是,对于计算密集型的应用,它通常允许我们将 Bloom 过滤器保持在内存中,甚至是在内存缓存中,以便快速访问。考虑我们在上一章中探索的关于结构化 B 树节点以减少从较慢内存中检索的次数的权衡。Bloom 过滤器的作用是最大化直接有助于过滤的数据结构量,目的是将整个数据结构保持在非常快速的内存中。

为什么这很重要

Bloom 过滤器在需要对内存使用和准确性之间进行超优化权衡时,可以成为强大的工具。它们将第十章中介绍的数学映射与第十二章中关于将数据压缩成可以存储在本地内存中的形式的重点结合起来。像缓存一样,它们提供了一个中间步骤来执行昂贵的查找操作,从而有助于平均降低成本。

更重要的是,布隆过滤器(Bloom filter)提供了一个全新类型数据结构的初步了解——一种可能会偶尔返回假阳性的结构。查找操作的准确性无法保证,而是概率性地依赖于数据。如果我们幸运的话,可能在没有看到假阳性之前就能插入大量元素。然而,如果运气不好,可能会早早遇到碰撞。这种数据结构提供了一种不同的思考方式,让我们重新审视如何组织数据及其相关的权衡。如果我们愿意接受一些(希望是少量的)错误,我们可以把效率提升到什么程度呢?

在下一章中,我们将考虑一种依赖于不同类型随机性的 数据结构。跳表(skip list)并非提供概率上正确的答案,而是通过使用随机性来避免最坏情况的表现,并在平均情况下提供高效的操作。

第十四章:跳表

本章介绍了跳表,一种带有多个指针的排序链表,它允许我们在进行搜索、插入或删除等操作时,偶尔跳跃到链表中更远的元素。这种跳跃的可能性缓解了链表的一大主要问题——我们必须扫描所有元素才能找到一个目标。跳过一些元素节省了宝贵的时间。

为了理解跳表是如何工作的,可以考虑我每次在看书时丢失位置的策略。为了避免剧透,我不使用二分查找,因为它可能会跳到我还没读过的部分。相反,我从书的开头开始,每次跳过多页——跳得足够大,以免扫描每一页,但又足够小,避免跳得太远破坏了故事情节。我在搜索的开始使用较大的跳跃,但随着接近我停下的位置,我会逐渐改用更小的跳跃。跳表采用了类似的方式,显著改变了链表的行为,使其能够解决之前我们只会交给基于树的数据结构的问题。

跳表由计算机科学家威廉·普赫(William Pugh)提出,是一种概率性数据结构,可以显著提高插入、删除和搜索等操作在平均情况下的效率。跳表并不是存储一个单一的链表,而是有效地创建了一层层链表,每层只包含下层链表中的部分节点。这意味着我们从跳表的较高层开始搜索,在这些层中节点较少,并且可以大步跳过不必要的节点。随着我们逐渐接近目标并精细化搜索,我们会在多级层次结构中向下移动。在搜索书籍中的位置时,这就相当于在接近我们最近的位置时,逐渐使用越来越小的跳跃。

我在本书中加入跳表有两个原因。首先,和本书介绍的几乎所有其他数据结构一样,跳表展示了额外的信息或结构如何提供显著的算法优势。在这种情况下,多层链接降低了搜索的成本。第二,也是可能更令人兴奋的,跳表是随机化数据结构。与布隆过滤器(Bloom filters)不同,后者在数据给定的情况下是确定性的,跳表将随机性的使用更进一步:它们的结构本身是通过概率性的方法来决定的,以平衡平均情况下的性能。我们使用随机数生成器来选择每个节点的层级,从而决定它能够跳过多少远的元素。

随机化结构与确定性结构

从一个确定性生成的数据结构转变为一个随机化的数据结构引入了复杂性和益处。到目前为止,我们所研究的每个数据结构的结构都完全由我们插入的数据决定。例如,如果我们将相同的数据按相同的顺序插入二叉搜索树,我们总是会得到相同的结构。堆、前缀树、网格、四叉树等结构也一样。即使是两个哈希表或布隆过滤器,如果我们使用相同的哈希函数并插入相同的数据集,它们也将是相同的。

这种确定性可能会在最坏情况的数据面前引发问题。正如我们在第五章中看到的,如果我们从一个空的二叉搜索树开始,并按排序顺序插入元素,那么我们的树实际上会变成一个排序链表。每个节点将只拥有一个子节点,且方向相同。缓解这个问题的一种潜在方法是按随机顺序插入数据。虽然我们仍然可能选择一个不理想的顺序,但这种情况的概率显著降低。

我们可以将这种随机化方法扩展到构建数据结构本身,在每次插入时随机选择参数。我们不再变化数据的顺序,而是变化如何将数据链接到我们的结构中。

一开始,随机化方法可能显得不直观。如果我们不了解输入的分布,我们可能会轻易地为该分布做出糟糕的结构选择。我们可能担心总是会选择最坏的参数。然而,如果我们使用一个好的随机化策略,这种失败的可能性将极为罕见。另一方面,随机化设计可以防止我们做出持续的不优选择。虽然它可能不会导致最优解,但它通常会产生一个合理的解。随机性可以提供良好的平均情况表现,也有助于平滑数据以病态方式到达的情况。

引入跳表

正如我们在第三章中看到的,链表上的某些操作本质上受到链表结构的限制。我们不能高效地搜索链表,因为我们不能随机访问元素。这会产生悲惨的后果;即使我们知道节点是有序的,我们也不能使用二分查找。我们被迫沿着指针从一个节点爬行到另一个节点,直到找到目标节点。这一令人沮丧的限制让许多新计算机科学家抓狂,嘀咕着不太友善的话。

跳表通过提供一次跳跃多个条目的能力来缓解这种低效。跳表的核心就是一个带有多层节点的排序链表:

SkipList {
    Integer: top_level
    Integer: max_level
    SkipListNode: front
}

字段top_level表示当前使用的最高级别,而字段max_level表示允许的最高级别。为了简化,我们独立指定max_level,这样我们可以在列表开始时预分配一个指针数组。

跳跃列表的复杂性,也因此是其强大的地方,源于节点内部的指针结构。每个节点不仅存储指向列表中下一个节点的单个指针,还具有预定义的层级或高度,在该层级中,它存储指向下一个节点的多个指针。层级为 L 的节点维护 L + 1 个不同的前向指针,每个指针对应一个层级 [0, L]。关键的是,层级 L 处的指针将当前节点链接到同一高度的下一个节点,这意味着 next 中的指针通常会指向不同的跳跃列表节点。

SkipListNode {
    Type: key
    Type: value
    Integer: height
    Array of SkipListNodes: next
}

由于跳跃列表的高层包含的节点比低层少,这些高层的节点可以链接得比低层更远。这使得算法可以在较高的层级上跨越更多的节点,从而跳过许多中间节点。随着层级的逐步提高,节点的数量减少,这些链接也跳得越来越远。

想象一下,在跳跃列表中搜索的过程就像通过手电筒在建筑物之间传递消息一样。你能传递消息的距离取决于你所在的楼层以及路径中建筑物的高度。如果你被困在一楼,你只能将消息传递到邻近的建筑物。更远的建筑物会被邻近的建筑物本身阻挡。然而,如果你幸运地处在一座高楼里,你可以越过更近但较矮的建筑物传递消息,正如图 14-1 所示。或者,如果你需要将消息发送给紧邻的邻居,你可以简单地移到最低楼层。

五座不同高度的建筑。建筑物 2 是最高的,建筑物 5 紧随其后。箭头显示,第二座建筑中的人可以通过位于两座建筑之间上方楼层的方式,将消息传递到最后一座建筑。

图 14-1:在跳跃列表中节点之间的移动就像是在城市的建筑物之间通过手电筒传递消息一样。

跳跃列表通过概率性地创建这些链接。程序为每个节点分配一个随机高度,这与节点中存储的键无关,并将新节点插入到每个层级的对应列表中。因此,具有高度 0 的节点只会出现在最底层的列表中,而高度为 2 的节点将出现在层级 0、1 和 2 的列表中。图 14-2 展示了这一点。在上述的消息传递示例中,这相当于一座只有一层的建筑与一座有三层的建筑。那座有三层的建筑能够在三个不同的高度上传递消息,可能访问最多三个邻居。

跳跃列表包含键值 0、1、8、9、12 和 17。每个节点的高度在 0 到 2 之间。键值 1 的节点在高度 0 上指向键值 8 的节点,在高度 1 上指向键值 12 的节点,在高度 2 上指向键值 17 的节点。

图 14-2:一个跳跃列表的示例

由于较高层的节点提供了跳过更远的能力,越过较低层的节点,我们理想的做法是稀疏使用这些节点,并将它们分布在整个列表中。在消息传递的例子中,我们不希望我们的城市景观只包括相同高度的建筑。我们希望有很多单层建筑,配合一些中层建筑和几座较高的建筑,允许我们将消息跳过街道。通过选择具有正确概率分布的高度,我们可以在平均情况下平衡每一层的密度。L + 1 层的节点数量少于L层的节点数量。这有助于实现良好的平均性能,并且可以避免其他数据结构中可能出现的最坏情况。

如图 14-2 所示,这个跳表实现使用了一个虚拟节点front来存储每一层前面的指针。节点front是一个SkipListNode,但不包含键或值。将跳表的前端跟踪到一个SkipListNode中,使得插入和删除的代码大大简化,正如我们在本章稍后的内容中将看到的那样。

跳表搜索

要搜索一个跳表,我们从最上层的前端开始,遍历列表中的节点。根据图 14-2 中的插图,通俗地说,我们从左上角开始,然后向下并向右移动。在每次迭代时,我们检查当前层是否有另一个节点,如果有,检查该节点的键是否小于目标。如果这两个条件都满足,我们就继续移动到当前层的下一个节点。如果任一条件不成立(我们到达了该层的末尾,或者找到了一个键大于或等于目标的节点),我们就下降到下一层并从那里继续搜索。当我们尝试下降到最低层以下时,搜索终止。

SkipListSearch(SkipList: list, Type: target):
    Integer: level = list.top_level
  ❶ SkipListNode: current = list.front

  ❷ WHILE level >= 0:
        WHILE (current.next[level] != null AND
               current.next[level].key < target):
           current = current.next[level]
        level = level - 1

  ❸ SkipListNode: result = current.next[0]
  ❹ IF result != null AND result.key == target:
        return result.value
    ELSE:
        return null

跳表搜索的代码从顶层列表的current节点开始 ❶。两个嵌套的WHILE循环负责遍历。内循环遍历当前的链表,直到遇到链表的末尾(current.next[level] == null)或者一个键大于或等于目标的节点(current.next[level].key >= target)。外循环每次迭代时下降一级,直到到达列表的底部 ❷。如果目标存在于列表中,它将出现在列表中的下一个节点 ❸。但是,我们必须检查该节点是否存在并且具有正确的键 ❹。当搜索循环结束时,我们可以确保停留在列表中键值小于目标的最后一个节点。目标要么是列表中的下一个节点,要么不存在。

考虑在图 14-3 所示的列表中搜索目标 14。我们从第 3 层的前端开始。该层的第一个节点的键值为 13,小于我们的目标,因此我们继续到该节点。此时,我们已经到达了第 3 层的列表末端,无法在此高度进一步前进。下一个节点的指针为 null。

搜索然后下降到第 2 层并继续进行。在这里,我们发现列表中的下一个键值(14)小于我们的目标,因此我们继续下降到第 1 层。第 1 层和第 0 层的条件相同——列表中的下一个键值不小于我们的目标。搜索在完成第 0 层后终止。此时,我们当前节点(键值 = 13)的next指针指向目标节点。

搜索 14 从前端开始,沿着指针跳到第 3 层的节点 13,然后继续沿着该节点向下跳到更低的层。

图 14-3:在跳表中搜索目标 14。阴影部分的条目和虚线指针表示在搜索过程中遍历的节点。

请注意,尽管我们已经对目标节点进行了多次迭代(在第 2 层、第 1 层和第 0 层),但我们仍然继续搜索直到通过了最底层。这是由于我们在代码中的终止标准。我们可以添加额外的逻辑来更早地停止搜索,但为了简化逻辑,我们在这里保持与后续插入逻辑一致的简化方式。

相比之下,如果我们在相同的列表中搜索目标 12,如图 14-4 所示,我们会在搜索的早期就迅速降到最底层,并在底层继续搜索。

搜索 12 从前端开始,沿着指针跳到第 2 层的节点 2,接着沿着该节点向下跳到第 0 层,最后沿着指针跳到第 0 层的节点 9。

图 14-4:在跳表中搜索目标 12。阴影部分的条目和虚线指针表示在搜索过程中遍历的节点。

我们可以将这次遍历想象成一只松鼠在一排树木之间的导航。它在高处欣赏美景,跳跃着从一棵树到另一棵树,直到到达目标前的树木高度无法再继续跳跃为止。每当有可能时,它会在高大、古老的橡树之间跳跃,飞跃其间较矮的树苗。由于高大的橡树较为稀少,因此它们之间的距离较远,所以松鼠每次跳跃所覆盖的距离较长。从一棵大树的宽大树枝跳到另一棵大树的树枝所需的跳跃次数少于穿越中间的所有小树苗。

然而,松鼠不愿意浪费时间回溯,因此永远不会超越它的目标。最终,松鼠会到达一个地方,在这里,如果它跳到这个高度的下一个树上,它就会超过目标。或者,可能没有更多的同高度树。不管怎样,松鼠叹了口气,勉强降落到较低的树枝层级,然后继续向前。它在下一个层级前进,尽可能跳得更远,享受风景路线,直到它再次遇到需要下降的地方。

添加节点

我们用于选择新节点高度的分布方式对跳表的结构和性能有重要影响。如果所有节点都处于相同的层级,无论是最小层级还是最大层级,那么我们的跳表就会退化成一个排序链表,且附带额外的内存开销。更糟糕的是,如果我们将所有高度设置为最大值,我们就会创建多个并行列表,但并没有增加任何搜索效率。理想情况下,我们希望有较少的高节点,并且在每个下层级上节点数量逐渐增加。

William Pugh 最初选择高度的方法是不断使用一个常数概率 p 来添加另一个层级。所有节点从 0 层开始。我们不断抛一个加权硬币——选择一个从 0 到 1 的随机数,检查它是否小于 p——直到得到一个大于 p 的数字或达到最大允许高度。我们计算小于 p 的抛掷次数,并将其设为新的层级。例如,我们可以使用 p = 0.5,在这种情况下,我们期望大约一半的节点在 L 层被提升到 L + 1 层。我们可以通过调整 p 的值来平衡搜索效率和内存使用。较小的 p 值意味着较少的高节点,从而每个节点的指针也更少。我们将节点的高度限制在 max_level,以保持与 front 节点中预分配数组的一致性。

你可以通过一个不一致的父母回应孩子要求更多糖果的情境来形象化这种方法。当孩子得到糖果时,他们总是得到一块,而之后总是想要更多。每次孩子请求糖果时,父母都会随机(以概率p)决定是否满足请求。如果同意,他们就会再给孩子一块糖果。这相当于将节点的高度增加一。自然地,孩子看到自己获胜后,会立刻再次提出请求。这个过程会继续,直到父母最终感到恼火,以概率(1 − p)喊出“不给糖果了!”类似地,我们会继续增加节点的高度,直到我们的随机数生成器或最大阈值告诉我们要完全停止。

向跳表中添加节点的过程与搜索目标节点的流程相同:我们向下和向右移动,寻找插入新节点的位置。事实上,我们可以重用搜索的基本结构来进行插入。我们只需要跟踪一个额外的数据:每个层级中可以指向新节点的最后一个节点。

SkipListInsert(SkipList: list, Type: key, Type: value):
    Integer: level = list.top_level
  ❶ SkipListNode: current = list.front
  ❷ Array: last = a size list.max_level + 1 array of SkipListNode pointers 
                  initially set to list.front for all levels.

  ❸ WHILE level >= 0:
      ❹ WHILE (current.next[level] != null AND
               current.next[level].key < key):
           current = current.next[level]
      ❺ last[level] = current
        level = level - 1

    SkipListNode: result = current.next[0]
  ❻ IF result != null AND result.key == key:
       result.value = value
       return

  ❼ Integer: new_level = pick a random level
  ❽ IF new_level > list.top_level:
        list.top_level = new_level
    SkipListNode: new_node = SkipListNode(key, value, new_level)

    Integer: j = 0
  ❾ WHILE j <= new_level:
         new_node.next[j] = last[j].next[j]
         last[j].next[j] = new_node
         j = j + 1

我们从列表的左上角开始(list.frontlist.top_level) ❶。通过一对嵌套的 WHILE 循环,我们在寻找插入节点的正确位置时向下和向右移动。外层 WHILE 循环 ❸ 遍历列表的各个层级,保存每个层级看到的最后一个节点,然后下降到下一个层级。内层 WHILE 循环 ❹ 遍历跳表,每当当前层级的另一个节点的键值小于我们的目标时,就向前移动。

数组 last 中的每个条目一开始都指向 list.front,表示该节点被插入到列表的最前端 ❷。我们会在每个层级下降时更新 last ❺,因为我们发现该层级的下一个节点的键值大于或等于要插入的键值(或者下一个节点为 null),因此我们需要在该节点之前插入。如果在遍历跳表时恰好找到一个匹配的键值,我们只需更新该键的相关数据 ❻。这意味着,像我们的其他数据结构一样,我们的跳表实现将每个键视为唯一。

当我们找到正确的插入位置时,我们为该节点选择一个随机的层级 ❼。正如我们之前讨论的,选择这个高度的概率分布会对跳表的结构和性能产生重大影响。由于我们将新层级限制为小于 list.max_level,我们避免了对 last 数组的无效访问。我们检查所选层级是否代表列表的新顶部层级,如果是,则更新 list.top_level ❽。

最后,代码使用 WHILE 循环更新指针,将新节点的指针指向正确的下一个节点 ❾。然后,它更新 last 中列出的每个节点,将它们的指针指向我们的新节点。在这里,我们可以看到使用虚拟节点 front(具有最大高度)来存储指向列表开头的指针的好处。我们可以像更新任何其他节点一样跟踪并更新这个“列表前端”位置。这大大简化了代码。

图 14-5 显示了我们如何将键值 10 插入到一个示例跳表中。阴影节点表示在搜索过程中我们遍历的条目。

最后的数组在高度 3 处指向 front,在高度 2 处指向节点 1,在高度 1 处指向节点 1,在高度 0 处指向节点 9。

图 14-5:将键值 10 插入跳表。数组 last 跟踪插入节点前的节点。

通过追踪每一层级上位于目标节点之前的最后一个节点,我们实际上在追踪哪个节点需要指向新的节点。随着我们遍历列表,我们记录下需要插入新链接的位置。在每一层级,我们会到达一个位置,其中下一个键值大于或等于我们的新键值,然后我们就可以喊道:“我看到了需要插入新节点的位置,就在这个节点后面!”接着我们会下移到下一层继续工作。当搜索阶段到达底层时,我们已经完整记录了所有需要调整前向指针的节点。

删除节点

从跳表中删除节点几乎遵循与插入节点相同的算法。我们首先搜索跳表以找到删除目标,同时追踪每一层级上位于目标节点之前的最后一个节点。一旦搜索阶段完成,我们更新这些先前节点的列表以删除我们要删除的节点。

SkipListDelete(SkipList: list, Type: target):
    Integer: level = list.top_level
  ❶ SkipListNode: current = list.front
    Array: last = a size list.max_level + 1 array of SkipListNode pointers 
                  initially set to list.front for all levels.

  ❷ WHILE level >= 0:
        WHILE (current.next[level] != null AND
 current.next[level].key < target):
           current = current.next[level]
        last[level] = current
        level = level - 1

  ❸ SkipListNode: result = current.next[0]
    IF result == null OR result.key != target:
        return

    level = result.height
    Integer: j = 0
  ❹ WHILE j <= level:
         last[j].next[j] = result.next[j]
         result.next[j] = null
         j = j + 1

  ❺ IF level == list.top_level:
        Integer: top = list.top_level
        WHILE top > 0 AND list.front.next[top] == null:
             top = top - 1
        list.top_level = top

删除代码的初始块与插入代码相同。我们从列表的左上角开始 ❶。一对嵌套的WHILE循环 ❷ 会在每一层级上进行搜索,直到我们遇到一个键值大于或等于target的节点,或者遇到列表的末尾(null)。此时,我们记录下最后访问的节点,并下移到下一层继续搜索。在搜索结束时,我们检查是否找到一个keytarget匹配的节点 ❸。否则,跳表中没有匹配项,因此没有节点可供删除。

为了删除识别出的节点,我们使用一个WHILE循环 ❹ 来简单地链接跳表中每个节点的last节点的next指针,使其指向当前节点之后的节点:last[j].next[j] = result.next[j],对于所有层级j。这将把我们的节点从列表中剪切掉。我们还将result.next[j]设置为null,因为result不再在列表中。

最后,我们需要检查跳表的顶部层级是否仍然有效 ❺。如果我们删除了唯一一个高度为top_level的节点,那么top_level应该减小,以反映当前的最大高度。我们可以通过沿着front节点向下,并检查next指针,直到找到一个不为null的指针,来更新top_level。在我们的删除函数中的最后一段代码,会在需要时更新列表的顶部层级。它找到第一个层级,其中我们的虚拟前端节点指向一个有效的数据节点。如果列表为空,我们就默认将顶部层级设为零。

再次,我们可以通过我们如何查看下一个节点并维护需要更新的节点列表来形象化删除所需的初始搜索。在每一层,我们识别需要删除的节点(如果它存在),同时仍停留在该层的前一个节点。下一个节点的键值大于或等于我们需要删除的键值。我们暂停:“我最好标记下这个当前节点,因为我将需要修改它的指针来跳过被删除的节点。”我们将当前节点的指针记录在last中,并继续前往下一层。在我们的旅程结束时,我们已经收集了一个完整的需要更新指针的节点列表。

执行时间

搜索、插入和删除操作的成本将取决于节点的位置和高度分布。在理想情况下,L层的节点将包含L − 1 层的每隔一个节点。我们在每一层丢弃一半的节点,并将它们均匀地分布开来。在这种情况下,跳表的行为与二叉搜索相似。我们可以通过查看顶层的单个节点来修剪掉一半的搜索空间。然后,我们再下降一层,再次将空间减半。因此,在最好的情况下,我们的性能将在条目数量上呈对数增长。

跳表的最坏情况性能与标准链表相当——它与节点数量成线性比例。如果列表中的每个节点都具有相同的高度,那么我们的跳表就只是一个排序链表。我们被迫按顺序扫描每个节点,以找到给定的目标。

假设我们使用了良好的高度概率分布,比如之前描述的 Pugh 原始技术(其中p = 0.5),那么插入、删除和搜索的预期成本都会随着条目数量的对数级别增长。与最坏情况成本不同,预期成本提供了数据结构在平均情况下的性能估计。这使得跳表的平均性能与二叉搜索树相当。

为什么这很重要

跳表旨在作为平衡搜索树的简单替代方案,它是另一种动态数据结构,能够实现高效的搜索。然而,与我们为此任务应用的其他算法不同,包括排序数组和二叉搜索树,跳表依赖于随机化结构来提供良好的性能。我们常见操作——搜索、插入和删除的预期计算成本——是列表大小的对数级别。

这自然引出了一个问题:为什么我们要把算法的性能寄托于随机化行为上?我们很容易遇到高节点聚集的情况,或者高度分布过于平坦的情况。然而,二叉搜索树也有类似的问题。如果我们以次优顺序插入和删除节点,最终可能会得到一个链表结构的树节点。在更复杂的二叉搜索树扩展中,可以通过避免这种最坏情况的发生来解决问题,而跳表则依赖随机化来避免糟糕的表现。作为交换,跳表使用了更简单的代码。因此,跳表展示了随机化如何同时提供对坏数据的强大防御和数据结构实现的简洁性。

第十五章:图

图是计算机科学中最基本的数据结构之一。它们出现在许多问题和编程任务中。与本书中其他旨在优化特定计算的数据结构不同,的结构自然地来源于数据本身。换句话说,图反映了它们所表示的数据。研究图算法让我们深入了解如何定义算法以利用数据的固有结构。

前面的章节集中讨论了如何构造数据以辅助算法的实现;高层次的问题,如查找某个值,推动并驱动了相关数据结构的设计。本章讨论的是相反的问题:图展示了数据的结构如何推动新算法的发展。换句话说,给定以图的形式呈现的数据,我们将探讨如何创建能够使用这些数据的算法。本章将研究三种图算法,它们利用了图的不同结构特性:用于最短路径的迪杰斯特拉算法、用于最小成本生成树的普里姆算法,以及用于拓扑排序的卡恩算法。

引入图

图由一组节点和一组组成。如图 15-1 所示,每条边连接一对节点。这种结构与许多现实世界的系统相似,包括社交网络(节点是人,边是他们的连接)、交通网络(节点是城市,边代表路径)和计算机网络(节点是计算机,边表示它们之间的连接)。这种现实世界的类比使得图算法在可视化时充满乐趣,因为简单的搜索转变为对城堡的细致探索或在城市拥挤小巷中疯狂奔跑。

带有节点 A 到 H 标记和节点间连接线的图。

图 15-1:带有无向边的图

图的边可以具有额外的属性,以捕捉数据的现实复杂性,例如边是否是有向的。无向边,如图 15-1 中的图所示,表示双向关系,如大多数道路和美好的友谊。有向边,如图 15-2 中所示,类似单行道,表示单向流动。为了表示无向连接,我们使用一对有向边——每个方向一条——在节点之间连接。在社交背景下,有向边可以代表电视剧中的浪漫兴趣:从爱丽丝到鲍勃的边表示爱丽丝喜欢鲍勃,而鲍勃到爱丽丝的边缺失则表现出缺乏互惠的致命困境。

带有节点 A 到 H 标记和节点间箭头的图。某些节点之间由两个箭头连接,指向相反的方向,其他节点只有一个箭头。

图 15-2:一个带有有向边的图

除了让我们建模单行道或单相思外,有向边还允许我们建模更抽象的问题,如任务依赖关系。我们可以将一组任务指定为节点,并使用有向边表示任务之间的顺序依赖关系。通过这种方式,我们可以创建一个图来表示酿造完美咖啡所需的任务,如图 15-3 所示。节点包括加热水、称量咖啡豆、研磨咖啡豆和将水加入咖啡粉等步骤。边表示这些步骤之间的依赖关系。我们需要在“研磨咖啡豆”节点和“将咖啡粉放入滤纸”节点之间添加一条有向边,表示我们必须先研磨咖啡豆。这两个步骤的顺序至关重要,任何尝试过直接用未研磨咖啡豆酿咖啡的人都能证明这一点。然而,我们不需要在“加热水”和“研磨咖啡豆”之间建立边,两个步骤可以并行进行。

制作咖啡时涉及的六个任务,如称量咖啡豆、加水、加热水等。一个从“称量咖啡豆”指向“研磨咖啡豆”的箭头表示必要的顺序。

图 15-3:使用图表示任务的操作顺序

边的权重进一步增强了图的建模能力。加权边不仅捕捉了节点之间的连接,还捕捉了该连接的成本。例如,我们可以根据位置之间的距离给运输图中的边加权。我们还可以在社交网络中加入亲密度度量,比如计算过去一个月两个节点之间交流的次数。图 15-4 展示了我们带有加权边的示例图。

一个图包含标记为 A 到 H 的节点,节点之间有连线。每条线都有一个数字标签。例如,A 和 C 之间的边的权重为 0.5。

图 15-4:一个带有加权边的图

使用加权和有向边的组合可以帮助我们捕捉节点之间复杂的相互关系。通过一个精心构建的图,整个社会剧本可以通过节点和边来表示并演绎出来。

图的表示

虽然图的抽象结构相对简单,但有多种方法可以在计算机的内存中表示节点和边。两种最常见的表示方法是邻接矩阵邻接表。这两种表示方法都可以处理有向、无向、加权和非加权边。与本书中的其他数据结构一样,这些结构的差异在于数据如何存储在内存中,从而影响不同算法如何访问这些数据。

邻接表表示法为每个节点存储一个单独的邻居列表。我们可以在节点的复合数据结构中使用数组或链表来存储邻居:

Node {
    String: name
    Array of Nodes: neighbors
}

或者我们甚至可以创建一个单独的边数据结构来存储关于边的辅助信息,如它们的方向性或权重。对于下面的示例,我们还为每个节点提供了一个单一的数字 ID,表示该节点在父图数据结构中的索引。

Edge {
    Integer: to_node
    Integer: from_node
    Float: weight
}

Node {
    String: name
    Integer: id
    Array of Edges: edges
}

无论哪种情况,图本身都会包含一个节点数组:

Graph {
    Integer: num_nodes
    Array of Nodes: nodes
}

无论具体实现如何,我们都可以通过从节点本身链接的列表访问任何给定节点的邻居。图 15-5 展示了这种结构的一个示例。

在有向边的情况下,节点的边或邻接节点列表仅包含那些在离开该节点时可以访问的节点。例如,节点 A 可能包含指向节点 B 的边,而节点 B 则不包含指向 A 的边。

邻接列表提供了一种本地化的邻居关系视图,反映了像社交网络这样的现实世界情况。每个节点只跟踪它连接的节点。类似地,在社交网络中,每个人会确定谁是他们的朋友,从而保持自己的连接列表。我们不需要一个单独的中央存储库来告诉我们谁是我们的朋友,而且我们可能并不完全了解其他人的朋友。可以说,我们甚至不知道我们的哪些朋友(出边)实际上把我们当做朋友(入边)。我们只知道自己发出的连接。

左侧是一个图,节点表示为圆圈,边表示为线条。右侧是相同的图,以节点数组和边列表的形式表示。例如,节点 A 有一个邻居节点 B、C 和 D 的列表。

图 15-5:一个图(左)及其邻接列表表示(右)。每个节点存储一个邻接节点的列表。

相反,邻接矩阵将图表示为一个矩阵,如图 15-6 所示,每个节点都有一行和一列。第i行,第j列的值表示从节点i到节点j的边的权重。零值表示没有这样的边。此表示法允许我们直接查找是否存在任意两个节点之间的边,从单一的中央数据源中获取信息。

图 15-5 中的图表示为一个矩阵。节点 A 的行除了 B、C 和 D 列为 1 外,其他列均为 0。

图 15-6:图的邻接矩阵表示

这种图的全局视图出现在现实世界中,当一个单一的规划者需要查看整个网络时。例如,航空公司可能会使用航线的全局视图,其中节点是机场,边表示机场之间的航班,用于规划新服务。

虽然邻接图表示法在某些情况下非常有用,但本章剩余部分我们将重点讨论邻接列表表示法。列表表示法与我们在其他数据结构中使用的基于指针的方法自然契合。此外,使用独立的节点数据结构允许在存储辅助数据方面提供更多的灵活性。

搜索图

如果我们回顾一下第四章中的网页爬行示例,当时我们为寻找与咖啡研磨机相关的信息而探索我们最喜欢的在线百科全书,我们可以立刻看到我们最喜欢的在线百科全书中的链接如何形成一个话题图,每个页面代表一个节点,每个超链接代表一个有向边。我们可以通过迭代地探索每个节点,并将新的节点添加到未来要探索的话题列表中,从而逐渐探索话题,深入了解咖啡研磨机的世界。这种探索方式构成了图搜索的基础。

想象一下,我们希望在图中找到一个特定的节点。也许我们正在进行在线研究,寻找一个我们早已忘记名字的咖啡品牌。我们一次浏览相关网页(图节点),在阅读完一页的信息后才移动到下一页。正如我们在第四章中看到的,我们探索节点的顺序会极大地影响我们的搜索模式。通过使用栈数据结构来跟踪我们未来的探索选项,我们在图中执行深度优先搜索。我们深入追寻每一条路径,直到遇到死胡同。然后我们回溯并尝试在过程中跳过的其他选项。如果我们改用队列来跟踪未来的搜索状态,我们将对节点执行广度优先搜索。我们会先检查离起始位置较近的节点,然后再逐步深入图中。当然,还有许多其他方法可以排序我们的搜索。例如,最佳优先搜索根据排名函数对未来的节点进行排序,首先集中探索高分节点。在我们寻找新城市附近的咖啡店时,这种对节点的优先级排序可以帮助我们避免浪费几个小时在住宅区闲逛,而是集中在商业区。

无论顺序如何,通过一次探索一个节点来搜索图的概念都体现了数据结构对算法的影响。我们利用节点之间的连接(边)来限制并引导探索。在接下来的几节中,我们将介绍一些常用的有用算法,它们正是这样做的。

使用 Dijkstra 算法寻找最短路径

处理实际图形时,可能最常见的任务就是找到两个节点之间的最短距离。假设我们第一次访问一个新城市。清晨时分,我们从酒店房间里走出来,时差困扰着我们,正在寻找一处可以提神的地方。作为优秀的旅行者,我们做了大量关于城市咖啡文化的研究,并列出了四家咖啡店,计划在此期间品尝。随着电梯到达大堂,我们拿出了一张城市街道地图,地图上仔细标出了酒店和这些咖啡店的位置。现在是时候决定如何前往我们清单上的咖啡店了。

Dijkstra 算法由计算机科学家 Edsger W. Dijkstra 发明,用于找到从任何给定起始节点到图中所有其他节点的最短路径。它可以在有向图、无向图、加权图或无权图上运行。唯一的限制是所有的边权重必须是非负的。你无法通过添加一条边来减少总路径长度。在我们的咖啡主题观光示例中,我们在搜索从酒店到每个咖啡店的最短路径。如图 Figure 15-7 所示,节点表示街道交叉口或街道上的商店。加权的无向边表示这些点之间沿道路的距离。

顶部展示了一张包含一个酒店、四家咖啡店和四个街道交叉口的地图。底部的图形展示了图形表示。酒店(a)和第一个交叉口(b)之间的边权重为 11,表示距离。

图 15-7:地图上的各个点及其对应的距离(上图)可以表示为一个加权图(下图)。

我们的目标是找到从起始节点到每个咖啡店节点的最短路径。交叉口节点本身并不是目标,而是允许我们的路径在不同街道之间分支。

Dijkstra 算法通过维护一组未访问的节点,并不断更新每个未访问节点的暂定距离来进行操作。在每次迭代中,我们访问最近的未访问节点。这样做后,我们将此新节点从未访问节点集合中移除,并更新其未访问邻居的距离。具体来说,我们检查新节点的邻居,询问是否找到了通向每个邻居的更短路径。我们通过计算到当前节点的距离并加上到邻居的距离(边权重),来计算新提议路径的长度。如果这个新距离小于目前为止看到的最短距离,我们就更新该距离。

Dijkstras(Graph: G, Integer: from_node_index):
  ❶ Array: distance = inf for each node id in G
    Array: last = -1 for each node in G
    Set: unvisited = set of all node indices in G
    distance[from_node_index] = 0.0

  ❷ WHILE unvisited is NOT empty:
      ❸ Integer: next_index = the node index in unvisited
                              with the minimal distance
        Node: current = G.nodes[next_index]
        Remove next_index from unvisited

      ❹ FOR EACH edge IN current.edges:
          ❺ Float: new_dist = distance[edge.from_node] + 
                              edge.weight
          ❻ IF new_dist < distance[edge.to_node]:
                distance[edge.to_node] = new_dist
                last[edge.to_node] = edge.from_node

代码首先创建一系列辅助数据结构❶,包括到每个节点的距离数组(distance)、指示给定节点前一个访问节点的数组(last)和未访问节点的集合(unvisited)。然后,代码逐个处理未访问的节点。WHILE循环迭代,直到未访问的节点集合为空❷。在每次迭代中,代码选择具有最小距离的节点,并将其从未访问集合中移除❸。FOR循环迭代每个节点的邻居❹,计算通过当前节点到达该邻居的距离❺,并在代码发现更好的路径时更新distancelast数组❻。

图 15-8 展示了从图 15-4 中节点 A 出发的最短路径搜索示例。被圈出的节点是当前正在检查的节点。灰色的节点和列表条目表示已从未访问列表中移除的节点,因此不再考虑。

在图 15-8 中的搜索中,我们从所有距离初始化为无穷大开始执行 Dijkstra 算法,除了节点 A,它的距离设置为零(图 15-8(1))。这种初始配置对应了我们关于最佳路径的初步知识。我们已经处于节点 A,所以到达那里最短路径是显而易见的。由于我们尚未找到通往其他任何节点的路径,它们可能距离我们很远。我们还会维护每个节点的前驱节点信息。Last列表示前驱节点。这些信息使我们能够追溯路径。并非所有情况都需要重建路径,但我们的咖啡搜索显然需要。找到到达咖啡的最短距离是没有意义的,如果我们没有找到实际路径的话。为了构建到节点 F 的路径,我们沿着前驱指针向回追溯,直到到达节点 A。

我们的搜索开始,如图 15-8(2)所示,选择具有最小距离的节点(节点 A),将其从未访问列表中移除,并检查其邻居。对于 A 的每个邻居,我们测试通过 A 到达该邻居是否比目前为止发现的任何路径都要短。由于到节点 A 的距离为零,通过 A 到达每个邻居的距离将等于相应的边权重。每次我们更新到未访问节点的距离时,也会更新回指针,以反映到目前为止的最佳路径。目前有三个节点指向 A(图 15-8(2))。

搜索继续进行,选择下一个最接近的未访问节点。在这种情况下,它可以是 C 或 D。我们通过节点在列表中的顺序打破平局:节点 C 获胜!接着,我们再次考虑 C 的邻居,并更新它们的最佳距离(图 15-8(3))。请记住,这些距离表示从起始节点到达每个节点的最佳总距离。新的距离是从 A 到 C 的距离加上从 C 到每个邻居的距离。

九个子图展示了 Dijkstra 算法的每个步骤。在子图 2 中,节点 A 被灰色标记并圈出。每个图形右侧的表格显示了从每个节点到达当前最佳距离及沿该路径的最后一个节点。

图 15-8:加权图上 Dijkstra 算法的一个例子

搜索进展到节点 D——新的未访问节点,且其具有最小距离(图 15-8(4))。在检查节点 D 的邻居时,我们发现了通往节点 E 和 F 的新最短路径。节点 E 特别有趣,因为我们已经有了一条通过 C 到 E 的候选路径。我们可以从 A 到 C 再到 E,总距离为 1.0。然而,这并不是最佳路径。我们的搜索发现了一条新的路径,通过 D,比原路径稍短,总距离为 0.9。我们更新了潜在距离和回溯指针。现在我们通往 E 的最佳路径是通过 D。接下来,前往我们的下一个未访问节点 F!

搜索继续遍历剩余的节点,但没有发生其他有趣的事情。剩下的节点都位于最短路径的末尾,无法提供更短的路径。例如,当考虑节点 E 的邻居时(图 15-8(6)),我们检查了节点 C 和 D。从 E 路径到这两个节点的距离为 1.4,长于我们已发现的路径。事实上,C 和 D 都已经被访问过,所以我们根本不会再考虑它们。类似的逻辑适用于考虑节点 B、H 和 G,如 图 15-8(7)、15-8(8) 和 15-8(9) 所示。由于这些节点的邻居已经被访问过,因此我们不再考虑它们。

在检查 Dijkstra 算法如何遍历图形并找到最短路径时,我们可以清楚地看到数据结构与算法本身之间的紧密联系。像 Dijkstra 这样的最短路径算法之所以必要,是因为问题的结构本身。如果我们能轻松地从任何节点跳跃到其他节点,就不需要沿着边寻找路径。这就像是从酒店大堂瞬移到目标咖啡店——方便,但却不符合物理世界的结构。因此,在寻找最短路径时,我们需要遵循图本身的结构。

使用 Prim 算法寻找最小生成树

求解图的最小生成树问题提供了另一个例子,说明图数据的结构如何使我们能够提出新的问题,从而创造出适合回答这些问题的新算法。无向图的最小生成树是能够连接所有节点的最小边集(如果可能的话)。我们可以将这些树看作是一个精打细算的城市规划师,试图决定铺设哪些道路。为了确保每个人都能通过铺设的道路从一个地方(节点)到达任何其他地方(节点),需要铺设哪些最少的道路?如果边有权重,比如按距离或铺设道路的成本来计算,我们可以扩展这个概念,找到最小化总权重的边集:最小成本生成树是一个边集,其总权重最小,能够连接所有节点。

寻找最小生成树的一种方法是普里姆算法,该算法由包括计算机科学家 R.C.普里姆和数学家 Vojtˇech Jarník 在内的多位研究者独立提出。该算法的工作方式与前一节中的迪杰斯特拉算法非常相似,都是通过一个未访问的节点集,一次处理一个节点,逐步构建最小生成树。我们从一个包含所有节点的未访问集开始,随机选择一个节点进行访问。这个被访问的节点成为我们最小生成树的起点。然后,在每次迭代中,我们找到一个未访问的节点,其与我们已访问节点中任何一个的边权重最小。我们在问:“哪个节点离我们集合的边缘最近,因此可以以最小的成本加入?”我们将这个新节点从未访问集移除,并将对应的边添加到我们的最小成本生成树中。我们继续在每次迭代中添加一个节点和一条边,直到每个节点都被访问过。

我们可以把普里姆算法想象成一家公司,受雇建造连接群岛之间的桥梁。建设者从一个岛屿开始,逐渐向外扩展,连接更多的岛屿。在每一步,他们选择离当前已连接岛屿集最近的岛屿。桥的一端位于已连接岛屿集中的岛屿上,另一端位于未连接岛屿集中的岛屿上(将新岛屿加入已连接集)。通过始终从已连接集中的岛屿开始建造新桥,建设者能够利用现有的桥梁将设备运送到起始岛屿。并且通过始终将桥梁的另一端建在未连接集中的岛屿上,建设者能够在每一步扩大已连接集的覆盖范围。

我们可以通过跟踪更多信息来简化算法的代码。在每一步中,我们保持一份到每个节点的最佳边(包括权重)列表。每次从未访问集合中移除新节点时,我们检查该节点的未访问邻居,并检查是否有更好的(即,低成本的)边通向任何邻居。如果有,我们将该邻居的条目更新为新的边和权重。

Prims(Graph G):
  ❶ Array: distance = inf for each node in G
    Array: last = -1 for each node in G
    Set: unvisited = set of all node indices in G
    Set: mst_edges = empty set

  ❷ WHILE unvisited is NOT empty:
      ❸ Integer: next_id = the node index in unvisited with
                           the minimal distance
      ❹ IF last[next_id] != -1:
            Add the edge between last[next_id] and
            next_id to mst_edges
        Remove next_id from unvisited

        Node: current = G.nodes[next_id]
      ❺ FOR EACH edge IN current.edges:
            IF edge.to_node is in unvisited:
                IF edge.weight < distance[edge.to_node]:
                    distance[edge.to_node] = edge.weight 
                    last[edge.to_node] = current.id
    return mst_edges

代码首先创建一系列辅助数据结构 ❶,包括到每个节点的距离数组(distance),一个指示在访问给定节点之前访问的最后一个节点的数组(last),未访问节点的集合(unvisited)和最小生成树的最终边集合(mst_edges)。与 Dijkstra 算法一样,伪代码(以及我们稍后将讨论的图示)使用列表和集合的组合来进行说明。我们可以通过将未访问节点存储在一个以距离为键的最小堆中来更高效地实现算法。目前,为了明确展示发生的过程,我们将按顺序列出所有值。

然后,代码像 Dijkstra 算法一样,逐一处理未访问的节点。WHILE 循环迭代,直到未访问的节点集合为空 ❷。在每次迭代中,选择与任何已访问节点的距离最小的节点,并将其从未访问节点集合中移除 ❸。代码检查是否存在通往该节点的入边,这是必要的,因为第一个被访问的节点不会有入边 ❹,并将相应的边加入到最小生成树中。添加新节点后,FOR 循环遍历该节点的每个邻居 ❺,检查邻居是否未被访问,如果是,则检查其与当前节点的距离。在这种情况下,距离就是边的权重。代码最后返回构成最小生成树的边集合。

考虑当我们在加权图上运行 Prim 算法时会发生什么,参考图 15-4 和图 15-9 中的示例。我们从将所有最后的边设置为 null(我们还没有找到任何边)以及所有“最佳”距离设置为无穷大开始。为了简单起见,我们将按照节点的字母顺序打破平局。

首先,我们从未访问集合中移除第一个节点 A。然后,我们考虑 A 的所有邻居,并检查是否存在从 A 到该邻居的低成本边。由于我们当前所有的最佳距离都是无穷大,这一点并不难。我们找到所有 A 的邻居的低成本边: (A, B)、(A, C) 和 (A, D)。图 15-9(1) 显示了这种新状态。

在第二次迭代中,我们发现未访问集合中有两个潜在的节点可以使用:C 和 D。通过字母顺序来打破平局,我们选择了 C。我们将 C 从未访问集合中移除,并将边(A, C)添加到最小成本生成树中。检查 C 的未访问邻居时,我们发现连接节点 E 和 G 的更好候选边(图 15-9(2))。

下一个最近的节点是 D。我们将其从未访问集合中移除,并将边(A, D)添加到最小成本生成树中。当我们检查 D 的未访问邻居时,我们发现连接节点 E 和 F 的新低成本边(图 15-9(3))。我们现在从节点 D 而不是节点 C 获得指向节点 E 的最佳候选边。

算法通过我们未访问节点集合中的其余节点进行处理。接下来,我们访问节点 F,添加边(D, F),如图 15-9(4)所示。然后,如图 15-9(5)所示,我们添加节点 E 和边(D, E)。算法通过按顺序添加节点 H、B 和 G 来完成。在每一步中,我们添加迄今为止看到的对应最佳边:(F, H)、(F, B)和(C, G)。最后三个步骤分别显示在图 15-9(6)、图 15-9(7)和图 15-9(8)中。

八个子图展示了普里姆算法的每一步。在子图 1 中,节点 A 被灰色显示。每个图旁边的表格显示了到每个剩余节点的当前最佳距离及其对应的边。

图 15-9:普里姆算法在加权图上的一个示例

普里姆算法不关心从起始节点到最终节点的总路径长度。我们关心的只是将新节点添加到已连接集合中的成本——将该节点连接到已访问集合中任何其他节点的边的权重。我们并不是在优化节点之间的最终行驶时间,只是在最小化铺设道路或建造新桥梁的成本。

如果我们随机打破平局,而不是按字母顺序来处理会怎样?在图 15-9(2)之后,决定在未访问集合中选择节点 D 或 E 时,我们可以选择任何一个。如果我们选择了 E 而不是 D,我们会发现一个更低成本的边权,将 D 连接到图中。算法将通过 E 而不是通过 A 来连接节点 D。这意味着我们可以为同一图找到不同的最小成本生成树。多个不同的树可能有相同的成本。普里姆算法仅保证我们找到其中一个具有最小成本的树。

使用卡恩算法进行拓扑排序

我们的最后一个图算法示例使用有向无环图DAG)的边来排序节点。一个有向无环图是一个具有有向边的图,这些边的排列方式确保图中没有循环,即没有回到同一节点的路径,如图 15-10 所示。循环在现实世界的道路网络中至关重要。如果道路的建设方式是,我们可以从公寓到达最喜欢的咖啡店,但却永远无法返回,那将是非常糟糕的。然而,这正是无环图的特点——任何节点的出口路径都不会返回到该节点。

该图有标记为 A 到 F 的节点,节点之间有箭头连接。节点 A 连接到节点 C 和 D。

图 15-10:有向无环图

我们可以使用有向边表示节点的排序。如果图中有一条从 A 到 B 的边,则节点 A 必须在节点 B 之前。我们在本章开始时的咖啡冲泡示例中以这种方式对节点进行了排序:每个节点代表一个步骤,每条边表示一个步骤对下一个步骤的依赖。冲泡咖啡的人必须在执行任何后续步骤之前先完成某个特定步骤。这种依赖关系在计算机科学和现实生活中随处可见。将节点按边的顺序排序的算法称为拓扑排序

计算机科学家亚瑟·B·卡恩开发了一种方法,现在被称为卡恩算法,用于对表示事件的有向无环图执行拓扑排序。该算法通过找到没有入度的节点,移除它们在待处理节点列表中的位置,将它们添加到排序后的列表中,然后删除该节点的出边来工作。算法重复这个过程,直到所有节点都添加到排序列表中。从直觉上讲,这种排序类似于我们在现实世界中完成复杂任务的方式。我们从一个可以完成的子任务开始——一个没有依赖关系的任务。完成这个子任务后,我们选择下一个任务。任何要求我们先完成其他任务的子任务,必须等待,直到我们完成所有依赖关系。

在实现卡恩算法时,我们不需要实际从图中删除边。只需保持一个辅助数组,统计每个节点的入度,并修改这些计数即可。

Kahns(Graph G):
  ❶ Array: sorted = empty array to store result
    Array: count = 0 for each node in G
    Stack: next = empty stack for the next nodes to add

    # Count the incoming edges.
  ❷ FOR EACH node IN G.nodes:
         FOR EACH edge IN node.edges:
            count[edge.to_node] = count[edge.to_node] + 1

    # Find the initial nodes without incoming edges.
  ❸ FOR EACH node IN G.nodes:
        IF count[node.id] == 0:
            next.Push(node)

    # Iteratively process the remaining nodes without 
    # incoming connections.
  ❹ WHILE NOT next.IsEmpty():
        Node: current = next.Pop()
        Append current to the end of sorted
      ❺ FOR EACH edge IN current.edges:
            count[edge.to_node] = count[edge.to_node] - 1
          ❻ IF count[edge.to_node] == 0:
                next.Push(G.nodes[edge.to_node])

    return sorted

代码首先创建几个辅助数据结构 ❶,包括一个数组用于存储排序后的节点列表(sorted),一个数组用于存储每个节点的入度(count),以及一个栈用于存储下一个要添加到sorted的节点(next)。代码使用一对嵌套的FOR循环遍历节点(外循环)和每个节点的边(内循环),以计算每个节点的入度 ❷。然后,一个FOR循环遍历count数组,找到没有入度的节点并将它们插入到next中 ❸。

代码使用 WHILE 循环处理 next 栈,直到其为空 ❹。每次迭代时,代码从栈中弹出一个节点,并将其添加到 sorted 数组的末尾。FOR 循环遍历该节点的边,并减少每个邻居的入度计数(实际上是移除入边)❺。任何入度为零的邻居都会被加入到 next 中 ❻。最后,代码返回排序后的节点数组。

如果我们的图包含循环,排序列表将不完整。我们可能需要在函数的末尾增加一个额外的检查,确保排序列表中的元素数量等于图中节点的数量。

可以考虑在图 15-10 所示的图上运行该算法,如图 15-11 所示。我们首先统计每个节点的入边数(显示为每个节点旁边的数字),并确定节点 A 是唯一一个没有入边的节点(见图 15-11(1))。然后,Kahn 的算法将 A 添加到排序列表中,并移除它的出边(通过减少相应的计数),如图 15-11(2)所示。

七个子图展示了拓扑排序的每个步骤。在子图 2 中,节点 A 被灰色标记。下一个列表包含节点 C,而排序列表包含节点 A。

图 15-11:在有向无环图上的拓扑排序

我们继续处理节点 C(见图 15-11(3)),此时节点 C 已经没有任何入边。我们在处理节点 A 时已移除了它唯一的一条入边。我们将 C 从待处理节点列表(即栈next)中移除,去掉其边并将其添加到排序列表的末尾。在此过程中,节点 E 不再有任何入边,E 被加入到栈中。

排序过程继续进行,遍历列表中的其余节点。在处理节点 E 时,我们移除指向节点 D 的最后一条入边,使其成为算法接下来要处理的节点(见图 15-11(4))。然后,排序依次将 D、F 和 B 添加到排序列表中,如图 15-11(5)、图 15-11(6) 和 图 15-11(7)所示。

Kahn 的算法展示了有向边在图中的重要性,并且说明了我们如何设计一个算法来处理这些边。边的方向性进一步限制了我们如何遍历节点。

为什么这很重要

图(Graph)在计算机科学中无处不在。它们的结构使得它们能够映射许多现实世界的现象,从街道到社交网络或计算机网络,再到复杂任务的集合。图在路径规划和确定编译程序源代码顺序等任务中非常有用。为这些数据结构设计了大量的算法,执行诸如图的搜索、确定最小生成树或计算图的最大流等任务。我们可以为这个极具影响力的数据结构写一本书。

然而,在本章中,我们专注于数据结构与操作它的算法之间的紧密耦合。数据的图结构会驱动新问题的产生,例如寻找最小生成树,从而引发新的算法。反过来,这些算法利用数据的图结构,遍历边缘并从一个节点探索到另一个节点。这种相互作用展示了在定义问题和新解决方案时理解数据结构的重要性。

第十六章:结论

在本书中,我们考察了多种不同的数据结构,了解它们如何影响使用它们的算法,以及它们是否能帮助我们找到咖啡。我们展示了数据的组织如何导致计算成本的显著减少或算法行为的变化。我们审视了不同表示方式之间的权衡以及它们为何重要。在此过程中,我们尝试为如何思考数据结构提供一个直观的基础。

理解每种数据结构的动机、构造方式、用途和权衡取舍是至关重要的,这样才能在开发高效解决方案时正确使用它们。如果你随便选择一个看起来“足够好”的数据结构,可能会遇到最坏的情况,导致性能差到无法忍受。接下来,我们将回顾前面章节中的一些核心主题,以强调每个计算机科学从业者在选择数据结构时应该问的一些问题。

数据结构的影响是什么?

从第二章的二分查找开始,我们看到即使是对数据进行少量的结构化处理,也能大大提高算法的效率。数据中的结构使我们能够高效地访问值、聚合计算,或裁剪搜索空间的区域。就像二分查找的例子一样,这种结构可以简单到仅仅是将数据排序。这个简单的改变让我们能够将最坏情况下的运行时间缩短,并将其与值的数量的关系从线性转变为对数级别。类似地,整理我们的咖啡储藏室也能在不同的方面优化我们的咖啡制作体验——最常见的是减少制作第一杯咖啡所需的时间。

二叉查找树、字典树、四叉树和 k-d 树向我们展示了如何在搜索过程中进一步促进剪枝。基于树的数据结构提供了一种显式的分支组织,使我们能够通过简单的测试裁剪掉搜索空间中的大区域。我们将数据的边界编码到树的结构和节点本身中。此外,数据的分支性质使我们能够清晰地可视化每一层次我们提出的问题:“考虑到这个节点下方的点的边界,感兴趣的点是否可能在这个子树中?”

即使我们没有主动为当前算法优化数据的组织方式,它的排列也会深刻影响我们算法的行为和效率,正如栈和队列所展示的那样。例如,从栈切换到队列会将搜索从深度优先改为广度优先。在极端情况下,数据的结构要求开发全新的算法方法:图的连接结构驱动了一系列新的算法,用于搜索、排序和执行其他计算。

我们需要动态数据结构吗?

动态数据结构显著增强了我们方法的灵活性和适应性。使用这些结构意味着我们不再受限于预分配的内存块,这些内存块可能对当前任务来说太小。相反,我们可以通过指针将内存中的不同位置链接起来,使得我们的数据结构可以根据需要增长或缩小。最重要的是,动态数据结构让我们能够不断增加我们的咖啡记录,并在地理网格单元中存储多个咖啡店的位置。

动态数据结构为计算机科学中一些最激动人心、有趣且强大的算法提供了基础。几乎本书中描述的每一个数据结构都利用了指针(或相关的链接)来组织内存中不同块的数据。我们使用指针将二叉搜索树中的节点连接起来,在网格单元和哈希表桶中创建链表,并表示图的结构。

这种强大与灵活性的代价是,访问数据时会增加额外的复杂性。在数组中,我们可以根据索引查找任何项目。然而,一旦涉及指针,这种直接访问方式就不再适用。我们必须通过内存中的指针链条来查找特定的数据,无论是通过链表的节点、树的节点,还是图中的节点。根据这些指针的排列(链表与搜索树),我们可能会使操作对当前任务的效率更高或更低。我们始终需要理解算法如何使用数据结构。仅仅购买一台花哨的咖啡机是远远不够的;我们需要了解如何使用它。

什么是摊销成本?

在考虑是否使用某个数据结构时,重要的是要同时考虑构建数据结构的成本和它带来的节省。对数组进行排序或构建二叉搜索树的成本可能比扫描数据以查找单个值更高。几乎总是情况下,通过扫描每个数据点一次进行搜索,比构建一个辅助数据结构更为高效。然而,当我们进行多次搜索时,数学上的计算就会发生变化。

排序数组、二叉搜索树以及其他数据结构之所以有用,是因为它们减少了所有未来搜索的成本。如果我们付出一次性的N log2 成本来对一个整数数组进行排序,那么我们可以根据需要执行任意次数的 log2 二叉搜索。我们之所以能节省成本,是因为我们将排序数据的成本摊销到许多未来的搜索中。同样地,在冰箱里按过期日期对牛奶盒进行排序,可以在取用时节省宝贵的几秒钟。

我们如何将数据结构适应特定问题?

基本数据结构不仅提供了一组本身有用的工具,还为构建更具适应性和专门化的方法奠定了基础。通过前缀树(trie),我们研究了如何将二叉搜索树的分支结构扩展到更高的分支因子,从而实现对字符串的高效搜索。我们还看到了链表如何提供第二层灵活性,以处理哈希表中的碰撞或网格单元中的多个项。

空间数据结构很好地展示了我们适应、结合和优化数据结构的能力。将网格的空间划分与基于树的结构相结合,给我们带来了四叉树的适应性结构。然而,网格和四叉树在高维空间中都会出现问题。我们看到 k-d 树如何通过在每个分支沿着单一维度进行划分,帮助空间数据结构适应更高维度,不仅使得该结构能够扩展到更高维度,还提高了其剪枝能力。在考虑新的与咖啡相关的问题时,例如匹配标志或优化我们的冲泡设备的参数时,我们应该重新审视并可能根据问题的具体情况调整我们工具箱中的方法。

内存与运行时的权衡是什么?

内存和运行时间的权衡是计算机科学中的经典问题。我们通常可以通过预计算并存储额外的数据显著减少算法的成本。堆允许我们在列表中高效地查找和提取最小(或最大)元素,无论是在搜索算法中还是作为辅助数据结构。其权衡是堆本身的开销。我们使用的额外内存与我们想要存储的数据大小成线性关系。类似地,通过使用额外的内存来构建四叉树或 k-d 树,我们可以大幅减少未来最近邻搜索的运行时间。

即使在数据结构内部,这种权衡仍然存在。我们可以通过增加哈希表的大小来减少碰撞率。在链表中存储额外的信息使我们能够实现跳表,从而在搜索时获得更好的平均性能。类似地,预计算空间树节点的边界并将其存储在节点中,可能使我们能够更高效地测试是否可以剪枝该节点。

理解这些权衡并将其适应于特定项目的环境是至关重要的。你正在编写的视频游戏是要运行在个人电脑、移动设备,还是数据中心的大型服务器上?低内存环境可能需要与高内存环境不同的方法。我们的咖啡储藏室的大小不仅会影响我们能存储的咖啡总量,还会影响是否值得添加明亮的隔板。在一个大型储藏室中——例如可能将卧室改造成储物区——隔板可能帮助我们更快找到咖啡。而在一个小型储藏室中,比如厨房的橱柜,隔板可能只是占用了宝贵的货架空间。

如何调整我们的数据结构?

一些数据结构有影响操作性能的参数。网格在最近邻搜索中的性能高度依赖于网格单元的数量和粒度。类似地,B 树的大小参数k使我们能够根据本地内存调整每个节点的大小。这些参数几乎总是依赖于我们使用数据结构的上下文。没有一个完美的设置。

理解数据结构的参数如何影响性能以及它们如何依赖于问题的具体情况非常重要。在某些情况下,我们可以通过分析确定使用哪个参数。例如,我们可以根据运行代码的设备的内存块大小来选择 B 树的大小参数k。我们选择k的方式是让一个完整的 B 树节点恰好适应内存块,从而使我们能够通过一次访问获取最大量的数据。

有时,我们可能需要在真实数据上通过实证测试不同的参数。一种简单的方法是使用具有一系列参数设置的数据,并查看哪种设置的性能最佳。

随机化如何影响预期行为?

在检查二叉搜索树和哈希表时,我们注意到这两种数据结构的最坏情况性能可能会退化为线性时间。如果我们将排序的项插入到二叉搜索树中,或者为数据选择了不良的哈希函数,我们实际上会得到链表。我们的数据结构的性能在所有条件下并不总是最优的,而是依赖于数据本身。有时我们能做的最好的事就是提高预期的(或平均情况)运行时。

理解极端性能的可能性对于选择和调整最佳数据结构至关重要。在选择哈希表的参数时,我们希望选择一个足够大的表大小,以降低冲突的概率,同时避免浪费内存。更为关键的是哈希函数的选择,对于哈希表来说,这要求我们了解键的分布。如果键本身有结构,比如只包含偶数,我们需要选择一个对该结构有鲁棒性的哈希函数。类似地,如果我们正在为一个咖啡爱好者会议组织注册,而这些人的姓氏首字母都是K,那么注册表不应该根据他们姓氏的首字母来划分与会者。

我们可以通过随机化数据结构本身来在一定程度上缓解病态坏数据的影响。如果我们总是按排序顺序将数据插入到二叉搜索树中,实际上最终会得到一个链表。跳表提供了一种技术,通过故意在我们的列表节点层级中注入随机性,平均来说,可以实现对数时间复杂度的运行时间。然而,随机化并不是万能的。跳表可能会因运气不佳而选择错误的高度。在最坏的情况下,像链表一样,跳表的性能会退化到与数据大小成线性关系。然而,它们发生这种情况的概率很小,我们可以预期它们在平均情况下会表现良好,即使面对病态坏数据。

为什么这很重要

在计算机科学中没有一种完美的数据结构。如果我们能指着某一个数据结构说:“总是使用 X”,那该多好,但不幸的是,事情并没有那么简单。所有的数据结构都有其复杂性、性能、内存使用和准确性的权衡。

在本书中,我们考察了不同数据结构的样本,它们的权衡,以及它们如何影响算法。我们的讨论远非详尽无遗;还有很多数据结构专门为特定算法、问题或领域进行了优化。例如,红黑树提供了二叉搜索树的自平衡扩展,而度量树则为高维数据提供了不同的空间划分方法。这两种方法,以及其他成百上千种令人印象深刻的数据结构,都有自己的一套权衡和最佳使用场景。我们仅仅触及了数据结构这一丰富而复杂的世界的表面。

本书旨在鼓励你仔细思考如何存储和组织数据。像特定的编程语言或巧妙的算法一样,数据结构对程序的性能、准确性和复杂度有着实际的影响。所有计算机科学从业者不仅需要理解各个数据结构的具体内容,还要了解这些数据结构在解决问题的更广泛背景中的功能。

尤其是当涉及到咖啡时。

posted @ 2025-11-30 19:35  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报