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

JavaScript 数据结构与算法(全)

原文:zh.annas-archive.org/md5/32767c06b50703334a4741c0a7b25378

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书涉及计算机科学中的两个基本概念:数据结构和算法。它的结构与大学课程类似,并添加了来自编码挑战和面试题目的示例,利用这些示例讨论特定算法和数据结构的相对优缺点。

所有示例都用 JavaScript 编写,特别注意现代语言特性,以简化编程。性能也被考虑到,包括从理论角度(算法的时间复杂度)和实际角度(架构设计、性能测试)。每章结束时都会有一系列问题,帮助加深本章所涉及的概念,并为读者提供进一步的示例来应用。问题的答案会在书末给出。

谁应该阅读本书?

本书面向三类读者。第一类也是主要的读者是 JavaScript 前端(Web)和后端(Node.js)开发者,因为本书探讨了如何运用数据结构和算法来解决和优化复杂问题。

第二类读者是计算机科学(CS)专业的学生,因为本书涵盖了大多数计算机科学课程中的主题。这些学生应该熟悉几种编程语言,因此,专注于 JavaScript 并不会成为障碍。这些算法并不依赖于语言的特性,可以轻松地转换为其他语言。

最后,第三类读者是正在准备编码面试或对竞技编程感兴趣的程序员。这些读者将从实际的算法和数据结构实现中获益,并通过看到他们将遇到的各种问题的示例,来提升自己的能力。

本书的写作方法是什么?

本书始终采用实际的方式来探讨真实世界中的使用案例。它考虑了常见的问题,并讨论了适当的算法和数据结构。我们将探讨多个版本和优化,并开发几个实现变体,以便更深入地理解可能的替代解决方案。

所有的示例都使用 JavaScript 编写,因为它是一种广泛可用的语言,既可应用于前端也可用于后端开发。JavaScript 也非常知名且被广泛使用,应该能够应用于各种问题。

本书内容是什么?

这本书分为三部分。第一部分涵盖了基础内容,并突出了整个书中使用的 JavaScript 重要特性。我们将探索函数式编程,以理解后续章节中应用的一些设计考虑因素。我们还将考虑抽象数据类型(ADTs),这是一个涉及数据结构和算法的概念。最后,我们将研究与算法相关的性能问题,这将在本书的后续章节中多次应用。以下章节位于第一部分:

**第一章:使用 JavaScript **在这一章,我们将介绍本书其余部分中使用的 JavaScript 重要特性,但我们只会考虑重点内容,因为假设你已经熟悉该语言。主题包括当前的 JavaScript 版本、转译、类型、箭头函数、扩展运算符、解构、模块等。我还将介绍一些可帮助你开发 JavaScript 代码的工具。

**第二章:JavaScript 中的函数式编程 **在这一章,我们将探讨函数式编程,突出在本书其余部分中使用的一些设计特性。主题包括函数式编程是什么,为什么你应该使用它,JavaScript 是否是一种函数式编程语言,声明式编程风格、副作用和高阶函数等。

**第三章:抽象数据类型 **在这一章,我将介绍抽象数据类型的概念,作为考虑数据结构及其相关操作的基础。在后续章节中,所有的数据结构将被视为 ADTs,以突出它们的优缺点以及性能。关键主题将包括什么是 ADTs,以及如何在 JavaScript 中实现它们。

**第四章:算法分析 **在这一章,我们将考虑算法在空间和速度方面的性能。我们将讨论复杂度类的概念,以及它如何(和何时)应用于算法和数据结构的设计。我们将探讨的主题包括什么是算法的性能;大O符号;复杂度类;最优、平均、最差和摊销情况之间的差异;如何衡量性能;以及时间与空间的权衡。

第二部分的内容集中在算法上,讨论算法设计的策略。特别地,我们将考虑搜索、排序、洗牌和抽样——这些都有着著名的算法。本部分的章节如下:

第五章:算法设计 本章将探讨算法设计的策略,并展示每种情况的示例。我们将讨论一般实践、递归、暴力搜索、贪心算法、分治算法、回溯法、动态规划、分支限界法、变换与征服法和问题简化。

第六章:排序 本章将讨论几种常见且重要的排序算法,旨在将无序的数据序列转变为有序序列。有些算法(如堆排序)只会简要提及,因为它们将在后续章节中进一步分析,届时会详细描述相应的数据结构。本章内容包括排序问题的描述、内部排序与外部排序、JavaScript 自带的排序函数、基于比较的算法(如冒泡排序、选择排序、插入排序、快速排序、归并排序等),以及无比较的排序算法(如位图排序、计数排序和基数排序)。

第七章:选择 本章将展示只找出列表或数组中第k小值的算法,而不是排序算法,排序是为了将完整集合排序。我们将讨论选择问题的一般情况,使用 JavaScript 的最小值和最大值函数;通过排序(或部分排序)进行选择;以及其他几种算法,如快速选择、Floyd-Rivest 算法、中位数的中位数和选择排序。

第八章:洗牌与采样 本章可以视为第六章的补充。在这里,我们希望生成一个随机无序的数据序列,而不是完全有序的序列,这在计算机扑克牌游戏或统计抽样中可能是需要的。我们将讨论洗牌问题,如何进行随机排序,Fisher-Yates 算法,随机键排序和随机采样算法。

第九章:查找 本章将考虑几种常见的查找算法,目的是快速判断某个特定值是否包含在某个数据集内。有些算法将在本章介绍,但我们将在后续章节中对它们进行更深入的探讨,届时会描述并分析相应的数据结构。本章的内容包括查找问题的描述、JavaScript 自带的查找函数、线性查找(有无哨兵)、跳跃查找、二分查找和插值查找。

本书的第三部分专注于数据结构,考虑了几种数据结构类型,从简单的线性结构到更复杂的非线性结构。本部分包括以下章节:

第十章:列表 本章讨论最简单的结构——链表,还包括几种变种。我们将详细探讨链表(它是什么,几种类型,它的抽象数据类型;单向、双向和循环链表)、栈(它是什么以及几种实现方式)、队列(队列是什么,它的应用,抽象数据类型及多种实现方式),以及双端队列(它的目标、抽象数据类型和实现)。

第十一章:袋、集合与映射 本章将讨论可以表示集合(没有重复元素)和袋(允许重复元素)的结构,并将映射(键/值对)作为一个特殊的重要案例。我们将了解袋和集合是什么以及它们的实现(包括 JavaScript 自身的版本以及基于数组和链表的版本),最后讨论哈希和位图。

第十二章:二叉树 本章讨论二叉树,特别是二叉搜索树(BST),它是许多算法的基础。我们将讨论树是什么,树的遍历(前序、中序和后序算法),以及使用二叉搜索树进行查找(包括伸展树、平衡搜索树如 AVL 树和红黑树,以及随机化的二叉搜索树)。

第十三章:树与森林 在本章中,我们将研究更一般的树的变种,包括森林(一组树)。主题包括树和森林的定义,它们的几种表示方式,遍历算法(广度优先和深度优先算法),B 树及其用于查找的变种,以及作为二叉搜索树变种的红黑树。

第十四章:堆 在本章中,我们将讨论堆,一种二叉树的变种,它无需动态内存即可存储,并且可以轻松实现优先队列和排序。我们将讨论堆是什么,二叉堆及其变种(如三叉堆或 d 叉堆)、堆排序(一种基于堆的排序算法)、基于堆的采样算法以及堆相关的二叉搜索树(treap)。

第十五章:扩展堆 本章扩展了堆的概念,考虑了允许额外操作的变种,例如改变(更改键的值)和合并(将两个或多个堆合并为一个)。我们将讨论二项堆、懒惰二项堆、斐波那契堆以及配对堆。

第十六章:数字查找树 在本章中,我们将讨论专门设计用来查找字符串的树结构,比如常见的“字典”,我们可以在其中查找单词。我们将介绍字典树(trie)、基数字典树(radix trie)、三叉字典树(ternary trie)以及这些结构的其他变种。

第十七章:图 本章将讨论图结构,目前在许多应用中都有使用,比如谷歌地图或在软件项目中计算依赖关系。内容包括图是什么、不同的表示方式(如邻接表或邻接矩阵)、图的遍历和路径寻找(包括最短路径算法)、以及拓扑排序。

第十八章:不变性与函数式数据结构 本章将讨论不变性方面的内容,并探索如何通过修改算法来避免改变输入结构,从而产生一个新的结构。我们将了解什么是函数式数据结构,什么是不变性,如何冻结对象,避免修改数据结构所需的算法,以及一些具体的函数式数据结构示例,如列表、队列和树。

本书最后提供了每章末尾问题的答案;有时答案会给出完整解答,而有时则提供提示或解决方案的链接。

注意

本书的所有源代码都可以在 github.com/fkereki/data-structures-and-algorithms-book 获得。

第一部分 基础知识

在本书的第一部分,我们将首先介绍在本书中使用的几个重要的 JavaScript 特性,然后继续探讨函数式编程(FP)以获得设计洞察、与数据结构相关的抽象数据类型(ADT),以及算法性能的概念,这在设计高效系统时起着至关重要的作用。

第一章:1 使用 JavaScript

自从 1995 年首次发布以来,JavaScript 已经发展并增加了许多重要功能。的引入帮助了面向对象编程,使得你不再需要与复杂的原型打交道。解构扩展运算符简化了对象和数组的操作,并且允许你一次管理多个赋值。箭头函数的引入使得你能够以更简洁、更具表现力的方式工作,增强了 JavaScript 的函数式编程能力。最后,模块的概念简化了代码组织,并允许你以逻辑的方式对代码进行分区和分组。本章简要探讨了这些特性

现代的语言特性帮助你编写更好、更简洁、更易理解的代码。

然而,JavaScript 语言并不是唯一发生变化的东西,本章还将介绍一些现在可用的工具,这些工具可以帮助你开发 JavaScript 代码。像 Visual Studio Code 这样的环境提供了更好的代码可读性。其他工具帮助生成文档化、格式良好的代码,而验证工具可以检测静态或与类型相关的错误。此外,许多在线工具存在,帮助解决浏览器和服务器之间的不兼容问题。

现代 JavaScript 特性

我们将从一些简化编码的现代 JavaScript 特性开始探索:箭头函数、类、扩展值、解构和模块。这个列表并不全面,我们将在后续章节中探讨其他特性,包括函数式编程、map/reduce 等数组方法、函数作为一等公民、递归等。我们当然无法覆盖语言的所有特性,但本书的重点是最重要的和较新的特性。

箭头函数

JavaScript 提供了多种定义函数的方式,例如:

  • 有名函数,这是最常见的:function alpha()

  • 无名函数表达式:const bravo = function ()

  • 有名函数表达式:const charlie = function something()

  • 函数构造器:const delta = new Function()

  • 箭头函数:const echo = () =>

所有这些定义的工作原理基本相同,但箭头函数——JavaScript 新的“成员”——有这些重要的区别:

  • 它们即使不包含 return 语句,也可能返回一个值。

  • 它们不能作为构造器或生成器使用。

  • 它们不会绑定 this 值。

  • 它们没有 arguments 对象或 prototype 属性。

特别是,前面列表中的第一个特性在本书中被广泛使用;能够省略 return 关键字将使代码更简短、更简洁。例如,在第十二章中,你将看到如下的函数:

const _getHeight = (tree) => (isEmpty(tree) ? 0 : tree.height);

给定一个树的参数,该函数在树为空时返回 0;否则,返回树对象的高度属性。

以下示例使用return,是一种等效的(但较长的)方式来编写相同的函数:

const _getHeight = (tree) => {
  return isEmpty(tree) ? 0 : tree.height;
};

较长的版本并不是必要的:简短的代码更好。

如果你使用简化版本并想要返回一个对象,你需要将其括在圆括号中。这里是第十二章中的另一个箭头函数示例:

const newNode = (key) => ({
  key,
  left: null,
  right: null,
  height: 1
});

给定一个键,该函数返回一个节点(实际上是一个对象),该节点以该键作为属性,并且左、右链接为 null,且高度属性设置为 1。

箭头函数的另一个常见特性是为缺失的参数提供默认值:

const print = (tree, **s = ""**) => {
  if (tree !== null) {
    console.log(s, tree.key);
    print(tree.left, `${s}  L:`);
    print(tree.right, `${s}  R:`);
  }
};

你将在第十二章中看到这段代码的作用,但有趣的是,如果递归函数没有提供s的值,它会将其初始化为空字符串。

尽管在本书中我们不会大量使用类,但现代 JavaScript 已经远远超出了它的起点,现在,你不再需要处理原型和添加复杂代码来实现继承,而是可以轻松地实现继承。在过去,你可以使用类和子类、不同的构造函数等等,但实现继承并不容易。现在,JavaScript 类使这一切变得更加简单。(如果你想了解如何在旧式 JavaScript 中做继承,请参见developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance。)

看一下第十三章中的一个部分修改过的示例,展示了一个实际的类以及如何定义它:

❶ class Tree {
❷ _children = [];

❸ constructor(rootKey) {
    this._key = rootKey;
  }

  isEmpty() {
    return this._key === undefined;
  }

❹ get key() {
    this._throwIfEmpty();
    return this._key;
  }

❺ set key(v) {
    this._key = v;
  }
}

你可以定义一个简单的类,就像这里的情况❶,或者扩展一个现有的类。例如,你可以有另一个类BinaryTree extends Tree来基于Tree定义一个类。你可以在构造函数外部定义属性❷;你不必在构造函数内部定义它❸。如果需要更复杂的对象实例初始化,构造函数是可用的。

获取器❹和设置器❺是其他强大的功能。它们将对象的属性绑定到函数,这些函数在我们尝试修改或访问该属性时被调用。

本示例中未使用的其他功能是静态属性和方法;这些属性不是类实例的一部分,而是属于类本身。

注意

从 ECMAScript 2022 开始,JavaScript 还包括 私有 属性:字段、方法、获取器、设置器等等。

扩展运算符

扩展运算符(...)允许你将数组、字符串或对象“展开”成单独的值,提供了一些有趣的数组和对象用法。

数组的应用如下所示:

const myArray = [3, 1, 4, 1, 5, 9, 2, 6];
❶ const arrayMax = Math.max(...myArray);
❷ const newArray = [...myArray];

输入...myArray与输入3, 1, 4, 1, 5, 9, 2, 6是一样的,所以这个例子中第一次使用...myArray产生了9 ❶,第二次则提供了一个具有与myArray完全相同元素的新数组 ❷。

你还可以使用展开运算符来构建对象的副本,然后可以独立地修改它:

const myObject = {last: "Darwin", year: 1809};
❶ const newObject = {...myObject, first: "Charles", year: 1882};
// same as: {last: "Darwin", first: "Charles", year: 1882};

在这种情况下,newObject ❶首先获得myObject的属性副本,然后year属性被覆盖。你也可以用“旧方法”通过多个单独的赋值来完成,但使用展开运算符可以让代码更简短、更清晰。

展开运算符的第三种用法是用于处理需要处理不确定数量参数的函数。早期版本的 JavaScript 使用arguments类数组对象来处理这种情况。arguments对象是“类数组的”,因为它唯一提供的数组属性是.lengtharguments对象不包含数组的其他属性。

例如,你可以像这样编写你自己的 Math.max()版本:

const myMax = (...nums) => {
  let max = nums[0];
  for (let i = 1; i < nums.length; i++) {
    if (max < nums[i]) max = nums[i];
  }
  return max;
};

现在你可以像使用Math.max()一样使用myMax(),但没有必要重新发明这个函数。这个例子展示了如何模仿现有函数的功能——在这个例子中,即能够将多个参数传递给函数。

解构赋值语句

解构赋值语句与展开运算符有关。它允许你同时为多个变量赋值,这意味着你可以将多个独立的赋值操作合并为一个,从而编写更简洁的代码。例如:

[first, last] = ["Abraham", "Lincoln"];

在这种情况下,你将"Abraham"赋值给第一个变量,将"Lincoln"赋值给最后一个变量。

你还可以混合使用解构赋值和展开运算符:

[first, last, . . .years] = ["Abraham", "Lincoln", 1809, 1865];

像前面的例子一样,将数组中的初始元素赋值给firstlast,并将剩余的元素(这两个数字)赋值给years数组。这个组合让你能够更简洁地编写代码,用一个语句代替原本需要多个语句的情况。

此外,当左侧变量没有对应的右侧值时,你可以使用默认值:

let [first, last, role = "President", party] = ["Abraham", "Lincoln"];

在这个例子中,解构赋值语句为role指定了默认值,而party则保持未定义。

你还可以交换或旋转变量,这是本书后面常用的一种技巧。考虑以下第十四章中的代码:

[heap[p], heap[i]] = [heap[i], heap[p]];

这行代码直接交换了heap[p]heap[i]的值,而无需使用辅助变量。你还可以像这样写[d, e, f] = [e, f, d]来旋转三个变量的值,同样无需更多的变量。

最后,我们经常使用的另一种模式是一次从函数返回两个或更多的值。例如,你可以编写一个函数来按顺序返回两个值:

const order2 = (a, b) => {
  if (a < b) {
    return [a, b];
  } else {
    return [b, a];
  }
};

let [smaller, bigger] = order2(22, 9); // smaller==9, bigger==22

另一种一次返回多个值的方法是使用对象。你仍然可以这样做,但返回一个数组并使用解构赋值更加简洁。

模块

模块 允许您将代码拆分成可根据需要导入的部分,提供了一种更易于理解和维护的功能封装方式。每个模块应是相关函数和类的集合,提供一组功能。使用模块的标准做法之一是 高内聚性,即将相关的元素放在一起,而不相关的功能不应混合在同一个模块中。一个相关的概念叫做 低耦合,意味着不同模块之间的依赖关系应尽可能少。JavaScript 允许您将函数封装在模块中,从而提供结构良好的设计,具有更好的可读性和可维护性。

模块有两种格式:CommonJS 模块(一种较早的格式,主要用于 Node.js)和 ECMAScript 模块(最新的格式,通常由浏览器使用)。

CommonJS 模块

使用 CommonJS 模块时,请按照 第十六章 中的(简化)示例编写代码:

// file: radix_tree.js – in CommonJS style

❶ const EOW = "■";
const newRadixTree = () => null;
❷ const newNode = () => ({links: {}});
const isEmpty = (rt) => !rt; // null or undefined
const print = (trie, s = "") => {...}
const printWords = (trie, s = "") => {...}
const find = (trie, wordToFind) => {...}
const add = (trie, wordToAdd, dataToAdd) => {...}
const remove = (trie, wordToRemove) => {...}

❸ module.exports = {
  add,
  find,
  isEmpty,
  newRadixTree,
  print,
  printWords,
  remove
};

最后的 module.exports 赋值 ❸ 定义了模块外部可见的部分;任何不包含的部分 ❶ ❷ 将无法被系统的其他部分访问。这种编写代码的方式与 黑箱 软件概念高度一致。模块的使用者不需要了解其内部细节,从而可以实现更高的可维护性。只要模块保持相同的功能,开发者可以自由地重构或改进模块,而不会影响任何使用者。

如果您想导入模块导出的两个函数,例如,您可以使用以下代码风格,它采用解构赋值来指定您需要的内容:

const {newRadixTree, add} = require("radix_tree.js");

这使得可以通过解构赋值访问 radix_tree 模块导出的所有函数中的 newRadixTree() 和 add() 函数。如果您想向 Radix 树中添加内容,可以直接调用 add();同样,您可以调用 newRadixTree() 来创建一棵新树。

当然,您也可以这样做:

const RadixTree = require("radix_tree.js");

若要向树中添加内容或创建新树,您必须分别调用 RadixTree.add() 和 RadixTree.newRadixTree()。这种用法虽然导致代码更长,但也让您能够访问 radix_tree 模块中的所有功能。我更喜欢采用第一种解构赋值的风格,因为它明确了我正在使用的内容,但最终选择还是由您决定。

ECMAScript 模块

更现代的 ECMAScript 模块定义风格也支持独立文件,但您需要将上一节中的模块重写为以下格式,而不是创建一个 module.exports 对象:

// file: radix_tree.js – in modern style

const EOW = "■";
❶ **export** const newRadixTree = () => null;
const newNode = () => ({links: {}});
❷ **export** const isEmpty = (rt) => !rt; // null or undefined
const print = (trie, s = "") => {...}
const printWords = (trie, s = "") => {...}
const find = (trie, wordToFind) => {...}
const add = (trie, wordToAdd, dataToAdd) => {...}
const remove = (trie, wordToRemove) => {...}

❸ export {
  add,
  find,
  print,
  printWords,
  remove
};

您可以在定义的地方直接导出某些内容 ❶ ❷,也可以推迟到最后 ❸ 才进行导出。两种方法都可以(我认为没有人会像我在这个例子中那样同时使用两种风格),但大多数人更喜欢将所有导出语句集中放在最后。最终选择还是由您决定。

注意

你也可以在 Node.js 中使用 ECMAScript 的 import export 语句,但前提是你使用 .mjs 扩展名,而不是保留给 CommonJS 模块的 .js 扩展名。

你可以通过以下方式从 ECMAScript 模块导入函数,这与 CommonJS 模块的使用方法不同,尽管最终结果是完全一样的:

import {newRadixTree, add} from "radix_tree.js";

如果你想导入所有内容,可以使用以下代码;这将让你访问一个对象,包括该模块导出的所有函数,就像之前一样:

import * as RadixTree from "radix_tree.js";

到目前为止你看到的所有导出都是命名导出;你可以有任意多个这样的导出,也可以有一个未命名的默认导出。在给定文件中,你不需要像之前描述的那样定义你想要导出的内容,而是包含类似这样的内容:

// file: my_module.js
export default something = ... // whatever you want to export

然后,在代码的其他部分,你可以按照以下方式导入某些内容:

import whatever from "my_module.js";

你可以将导入的内容命名为你喜欢的任何名字(虽然“whatever”并不是一个好名字),而不是使用模块创建者预定的名称。虽然这不是常见的做法,但有时当使用不同作者的模块时,会出现名称冲突。

闭包和立即调用的函数表达式

闭包和立即调用的函数表达式其实并不新鲜,但理解它们在本书中的示例中会非常有用。闭包 是函数和它所能访问的作用域的结合。它允许你拥有私有变量,从而允许你创建类似类和模块的东西。例如,考虑以下函数:

function createPerson(firstN, lastN) {
  let first = firstN;
  let last = lastN;
  return {
    getFirst: function () {
      return first;
    },

    getLast: function () {
      return last;
    },

    fullName: function () {
      return first + " " + last;
    },

    setName: function (firstN, lastN) {
      first = firstN;
      last = lastN;
    }
  };
}

返回的值(一个对象)将能够访问函数作用域中的 first 和 last 变量。例如,考虑以下情况:

const me = createPerson("Federico", "Kereki");
console.log(me.getFirst()); // Federico
console.log(me.getLast());  // Kereki
console.log(me.fullName()); // Federico Kereki

me.setName("John", "Doe");
console.log(me.fullName()); // John Doe

这些变量在其他地方是不可访问的。如果你尝试访问 me.first 或 me.last,你将得到 undefined。这些变量在闭包中,但无法访问,因为它们作为私有值存在。

使用闭包还允许你模拟模块。为此,你需要一个立即调用的函数表达式(IIFE),读作 “iffy”,它是在定义后立即执行的函数。

假设你想要一个处理税收的模块。如果不使用新模块,你可以像使用 createPerson(...) 函数一样操作:

const tax = (function (basicTax) {
  let vat = basicTax;
  /*
    ...many more tax-related variables
  */
  return {
    setVat: function (newVat) {
      vat = newVat;
    },
    getVat: function () {
      return vat;
    },
    addVat: function (value) {
      return value * (1 + vat / 100);
    }
    /*
      ...many more tax-related functions
    */
  };
})(6);

你创建一个(没有名字的)函数并立即调用它,结果就像一个模块。你可以将初始值传递给 IIFE,例如默认的增值税(VAT)为 6%。vat 变量和你可能声明的其他变量是内部的,无法直接访问。不过,提供的函数,如 addVat(...) 和你可能需要的其他函数,可以与所有内部变量一起工作。

按照以下方式使用基于 IIFE 的模块:

console.log(tax.getVat());    // 6: the initial default
tax.setVat(8);
console.log(tax.getVat());    // 8
console.log(tax.addVat(200)); // 216

模块可以提供相同的基本功能,但你会看到一些场景,其中你会想使用闭包和 IIFE——例如,在第五章中讨论的记忆化和预计算数组值的场景。

JavaScript 开发工具

让我们把注意力转向一些工具,帮助你编写更美观的代码,检查常见缺陷等。这些工具在本书中并不会全部使用,但它们是非常有用的,通常是我每次开始一个新项目时都会安装的工具。

Visual Studio Code

集成开发环境(IDE)将帮助你更快速、轻松地编写代码。本书使用的是 Visual Studio Code(VSC)IDE。其他流行的 IDE 包括 Atom、Eclipse、Microsoft Visual Studio、NetBeans、Sublime 和 Webstorm,你也可以使用这些工具。

为什么要使用 IDE?尽管像 Notepad 或 vi 这样的简单文本编辑器可能已经足够,但像 VSC 这样的 IDE 提供了更多功能。使用文本编辑器时,你需要自己做更多工作,不断地在工具间切换,反复输入命令。使用 VSC(或任何 IDE)可以节省时间,让你能够以集成的方式工作,一键访问多个工具。

VSC 是开源的、免费的,并且每月更新,频繁添加新功能。你可以用它来编写 JavaScript 以及其他许多语言的代码。前端开发者使用 VSC 来进行 HTML、CSS、JSON 等的基本配置和识别(“IntelliSense”)。你还可以通过广泛的扩展目录来扩展其功能。

注意

Visual Studio Code,尽管名字相似,但与微软的另一个 IDE——Visual Studio 无关。你可以在 Windows、Linux 和 macOS 上使用 Visual Studio Code,因为它是用 JavaScript 开发的,并通过 Electron 框架打包成桌面应用。

VSC 还提供了良好的性能、集成调试、集成终端(可以启动进程或运行命令而无需离开 VSC)以及与源代码管理(通常是 Git)的集成。图 1-1 显示了我在 VSC 中的部分工作内容,包含本书的代码。

图 1-1:使用 Visual Studio Code

访问 code.visualstudio.com 下载适合你环境的版本,并按照安装说明进行安装。如果你喜欢尝试新功能,可以安装 Insiders 版本,享受最新特性,但要注意可能会遇到一些 bugs。对于某些 Linux 发行版,你可以通过包管理器处理安装和更新,而无需手动下载和安装。

Fira Code 字体

引发开发者激烈争论的一种快速方法是提到某种字体是最适合编程的字体。市面上有数十种单间距编程字体,但很少有包含 连字 的字体,连字是指将两个或多个字符组合在一起。JavaScript 代码是连字的理想候选者,否则你需要将常见符号(如 ≥ 或 ≠)输入为两个或三个独立的字符,这看起来就不那么美观了。

注意

& 字符最初是 E 和 t 的连字,拼写为 et,表示拉丁语中的“和”。英语中另一个连字是 æ (如 encyclopædia Cæsar),将字母 a 和 e 结合在一起。许多其他语言也包括连字;德语将两个 s 字符组合在一起形成 ß,如在 Fußball (足球)中。

Fira Code 字体 (github.com/tonsky/FiraCode) 提供了许多连字,并增强了代码的显示效果。图 1-2 展示了 JavaScript 的所有可能连字。Fira Code 也包括其他语言的连字。

图 1-2:Fira Code 字体提供的众多连字样本(摘自 Fira Code 网站)

下载并安装字体后,如果你使用 Visual Studio Code,按照 github.com/tonsky/FiraCode/wiki/VS-Code-Instructions 上的说明将字体集成到 IDE 中。

Prettier 格式化

如何格式化源代码可能是另一个争论的源头。你与之合作的每个开发人员可能都会对这个问题有自己的看法,声称他们的标准最好。如果你与一个开发者团队合作,你可能会熟悉“标准如何蔓延”这幅 xkcd 漫画所展示的情况(图 1-3)。

图 1-3:“标准如何蔓延”(感谢 xkcd 提供,xkcd.com/927

Prettier 是一个“有观点”的源代码格式化工具,按照自己的一套规则和一些可以设置的参数来重新格式化代码。Prettier 的网站声明,“采用 Prettier 的最大原因是停止所有关于风格的持续争论。” 本书中的所有源代码示例都使用 Prettier 格式化。

安装 Prettier 非常简单;按照 prettier.io 上的说明进行操作。如果你使用 Visual Studio Code,还需要从 marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode 安装 Prettier 插件。确保调整 VSC 的设置,启用 editor.formatOnSave 选项,这样所有代码在保存时都会被重新格式化。请查阅 Prettier 网站上的文档,了解如何根据个人喜好配置 Prettier。

JSDoc 文档

为源代码编写文档是开发的最佳实践。JSDoc (jsdoc.app) 是一个帮助你通过聚合特定格式的注释生成代码文档的工具。如果你在函数、方法、类等前添加注释,JSDoc 将使用这些注释生成文档。我们在本书中不使用 JSDoc,因为文本已经解释了代码。然而,对于日常工作,使用 JSDoc 可以帮助开发者理解系统的各个部分。

这是一个代码片段,它从 第十四章 向堆中添加一个键,以展示 JSDoc 如何生成文档:

/**
 * Add a new key to a heap.
 *
 * @author F.Kereki
 * @version 1.0
 * @param {pointer} heap – Heap to which the key is added
 * @param {string} keyToAdd – Key to be added
 * @return Updated heap
 */
const add = (heap, keyToAdd) => {
  heap.push(keyToAdd);
  _bubbleUp(heap, heap.length – 1);
  return heap;
};

JSDoc 注释以 /** 组合符号开头,类似于常规注释格式,但多了一个星号。@author、@version、@param 和 @return 标签描述了代码的特定信息;这些名称不言自明。你还可以使用的其他标签包括 @class、@constructor、@deprecated、@exports、@property 和 @throws(或 @exception)。查看 jsdoc.app/index.html 获取完整列表。

根据 github.com/jsdoc/jsdoc 中的说明安装 JSDoc 后,我处理了这个示例文件,生成了 图 1-4 所示的结果。

图 1-4:由 JSDoc 自动生成的示例文档网页

当然,这是一个只有一个文件的简单示例。对于完整的系统,你将得到一个首页,首页上有指向每个文档页面的链接。

ESLint

JavaScript 存在许多滥用和误解的可能性。考虑这个简单的例子:如果你使用 == 运算符而不是 =,你可能会发现 xy 且 yz,但 x!=z,无论传递法则如何(尝试 x=[],y=0 和 z="0")。另一个棘手的情况是,如果你不小心输入 (x=y) 而不是 (xy),那将是一个赋值而不是比较;这种情况不太可能是你想要的。

Linter 是一种分析代码并生成关于你可能使用的任何可疑或易出错特性的警告或错误信息的工具。在某些情况下,Linter 甚至可以正确地修复你的代码。你还可以将 Linter 与源代码版本控制工具结合使用。Linter 可以防止你提交未通过所有检查的代码。如果你使用 Git,请访问 git-scm.com/book/en/v2/Customizing-Git-Git-Hooks 阅读有关预提交钩子的内容。

ESLint 在 JavaScript 中进行代码检查非常有效。它创建于 2013 年,并且至今仍然非常活跃。访问 www.npmjs.com/package/eslint 下载并安装,然后进行配置。请务必仔细阅读 eslint.org/docs/rules/ 上的规则,因为你可以设置许多不同的规则,但除非你想引发一些代码检查冲突,否则不应全部启用。

最后,不要忘记在 marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint 获取 VSC 扩展,这样你就可以看到 ESLint 检测到的任何错误。

注意

使用 ESLint, eqeqeq 规则(见 eslint.org/docs/rules/eqeqeq) 会检测到类型不安全的 == 运算符的问题,并且会通过替换成 === 来自动修复。此外, no-cond-assign 规则会对意外的赋值进行警告。

Flow 和 TypeScript

对于大规模编码,考虑使用 Flow 和 TypeScript,这些工具允许你为 JavaScript 添加数据类型信息。Flow 添加了描述函数输入和输出、变量等期望数据类型的注释。而 TypeScript 实际上是 JavaScript 的超集,它会被转译成 JavaScript。

图 1-5(毫不羞耻地借鉴了 TypeScript 首页上的例子)展示了你可以通过类型信息检测到的错误类型。

图 1-5:ESLint 实时捕获的 TypeScript 代码中的类型错误

在这个例子中,我尝试访问一个不存在的属性(user.name),这是根据之前代码行推导出的类型数据得出的。(注意,我使用了 ESLint,这就是为什么我能实时看到错误。)

本书中我们不会使用这两个工具,但对于涉及大量类、方法、函数、类型等的大型项目,考虑将它们加入到你的工具库中。

在线功能可用性资源

如果你正在使用最新版本的 Node.js 进行服务器端开发,你可能不需要担心某些特定功能的可用性。然而,如果你在做前端开发,某些功能可能不可用,例如对 Internet Explorer 的支持。如果发生这种情况,你需要使用像 Babel 这样的工具进行转译,正如本章之前所提到的。

Kangax 网站 (compat-table.github.io/compat-table/es2016plus/) 提供了多个平台的信息,详细说明一个功能是完全可用、部分可用,还是不可用。Kangax 列出了所有 JavaScript 语言功能,并为每个功能提供了示例。你还可以在网站上找到一张表格,展示每个不同 JavaScript 引擎所支持的功能,如浏览器和 Node.js 上的功能。一般来说,当你使用浏览器打开时,绿色的“是”框表示你可以安全使用该功能;不同颜色或文本的框表示该功能部分可用或不可用。

Can I Use? 网站在 www.caniuse.com/ 上可以让你按功能进行搜索,并展示不同浏览器中可用的支持情况。例如,如果你搜索箭头函数,网站会告诉你哪些浏览器支持它,支持的日期以及全球直接访问该功能的用户百分比,且无需补丁或转译。

注意

如果你对“polyfill”这一术语不太清楚,请参阅 Remy Sharp(这一概念的创造者)写的 remysharp.com/2010/10/08/what-is-a-polyfill 文章。Polyfill 是一种“如果浏览器没有原生支持某个 API,就复制该 API”的方法。MDN 网站通常为新功能提供 polyfill,这对于需要处理不支持这些功能的旧版浏览器,或需要了解某些功能如何工作的情况非常有帮助。

图 1-6 显示了箭头函数在不同浏览器中的可用性信息;通过鼠标悬停可以查看更多数据,比如该功能首次可用的时间。

图 1-6:Can I Use? 网站展示了某个功能是否在浏览器中可用。

Can I Use? 网站仅提供有关浏览器的信息;它不包括像 Node.js 这样的服务器端工具,但你可能在某些时候需要它。

总结

本章中,我们回顾了 JavaScript 一些新颖且重要的现代功能,包括展开运算符、解构、箭头函数、类和模块。我们还考虑了一些你可能想在开发工作中使用的额外工具,如 VSC IDE、用于更整洁屏幕显示的 Fira Code 字体、用于源代码格式化的 Prettier、用于生成文档的 JSDoc、用于检查缺陷或不良实践的 ESLint,以及用于添加数据类型检查的 Flow 或 TypeScript。最后,为了确保你不使用不可用的功能,我们介绍了两个在线资源:Kangax 和 Can I Use?,它们将帮助你避免使用未实现或仅部分实现的 JavaScript 特性。

在下一章,我们将深入探讨 JavaScript,并探索其函数式编程方面的内容,为本书其余部分的示例提供起点。

第二章:2 在 JavaScript 中使用函数式编程

函数式编程(FP)是一种基于函数的编程范式,函数是你代码的唯一构建块。使用 FP 可以提高代码的模块化,帮助你编写更易理解、可测试和易于维护的代码,同时避免出现 bug;这是一个全方位的胜利。

有些人声称,FP 是一种开明的编程方法,它让面向对象编程(OOP)和其他范式远远落后。也有些人认为,它只是理论上的概念,不适用于“现实世界”,并且带来了比解决的问题更多的麻烦。像大多数领域一样,差异并非非黑即白,而是介于灰色之间。这不仅仅是使用 FP 还是不使用 FP 的问题。在本书中,我们会使用 FP,但不是教条式的,并且在本章中,你将看到 JavaScript 如何让你进行函数式编程,何时使用 FP 以及何时使用它。无论何时 FP 适用并且更为合适,我们会使用它,但如果 OOP 更为适用,我们也会改用 OOP。

FP 不是一种现代的时尚。第二古老的编程语言(仅次于 1957 年出现的 FORTRAN)是 LISP(于一年后出现),它完全基于 FP。自那时以来,许多其他 FP 语言相继出现,甚至像 JavaScript 这样的非函数式语言也提供了相同类型的功能。

为什么使用函数式编程?

在编程时,考虑哪些特性对你最重要,然后问自己,FP 是否能够提供这些特性。大多数程序员通常希望自己编写的代码是:

**易懂 **代码只需要编写一次,但会被阅读多次,用户应该能够轻松理解你的函数及其关系,而无需过多的努力。函数式编程通常生成更短、更简洁的代码,这使得理解起来更加容易。

**可维护 **你的代码很可能在未来需要维护,并且应该便于进行修改。使 FP 代码更易理解的特性也使得维护变得更为简便。你还无需担心在修改某个函数时会破坏其他部分。

**可测试 **单元测试是开发工作中的常见部分,它允许你验证代码每个组件的行为。单元测试还作为一种文档,为阅读你代码的人提供如何使用某个函数的示例。如果你的编程风格不支持编写易于测试的代码,你将遇到问题。FP 始终生成可以独立测试的函数。

模块化 你应该将代码的功能划分为独立的模块,每个模块涉及程序的一个特定方面,这样如果你在一个模块中做出更改,它就不会影响到其他代码。FP 的目标是编写独立的函数,这些函数可以重构或修改,而不影响其他函数。编写独立的函数有助于实现关注点分离(程序的不同部分重叠较少)。此外,模块往往是高度内聚的,这意味着它们包含的函数确实是属于一起的,并且它们是松散耦合的,因此函数中的更改不太可能需要修改其他函数。

可重用 重用代码可以节省时间和金钱。由于在 FP 中函数独立存在,因此你可以在任何地方使用编写良好的函数集合。

当然,面向对象的代码也能做到这些。没有人会说 FP 是解决所有软件开发问题的银弹。我总是建议采取中庸之道;经过深思熟虑、平衡的混合通常是最佳解决方案。

JavaScript 作为函数式语言

JavaScript 是函数式的吗? 当人们讨论函数式语言时,他们通常提到 Haskell、Erlang、Scala 等;通常不会提到 JavaScript。这可能是因为目前没有确切的定义来说明什么构成了函数式语言、函数式语言应该提供哪些特性,或者它应该如何工作。本书中,我们将认为只有当一种语言支持常见的 FP 特性时,它才算是函数式语言;你将看到 JavaScript 如何与之比较。我们将利用诸如将函数视为一等公民、数组函数、纯函数、高阶函数和递归等特性,同时避免副作用(或者说 FP 术语中的不纯),避免使用(局部或全局)状态、变更对象或参数等。

函数作为一等公民

在 JavaScript 中,函数是一等公民,意味着你可以对它们执行任何你可以对其他对象执行的操作。你可以将函数存储在变量中,将函数作为参数传递,或将一个函数作为结果从其他函数返回。

考虑一个应用程序接口(API)调用的例子。如果你使用像 Axios(或者 SuperAgent,或者其他类似的库,它们简化了对远程服务器的异步调用过程),你可能见过这样的代码:

axios.get("your/url/api").then(**(response) => {**
 **// ...do something with the response**
**}**)

.then()方法的参数是一个函数,它的传递方式和你传递数字或数组一样。

在第十二章,我们也会这样做,我们还将能够为函数参数赋予默认值,以改变函数的执行方式:

const preOrder = (tree, **visit = (x) => console.log(x)**) => {
  if (tree !== null) {
    visit(tree.key);
    preOrder(tree.left, visit);
    preOrder(tree.right, visit);
  }
};

preOrder()函数接受两个参数:一棵树(你将在后面学习树)和一个访问函数;如果你没有提供访问函数,默认值将是一个简单的函数,它只会记录你传递给它的内容。

将一个或多个函数作为参数使得该函数成为高阶函数;这种函数的另一个特征是返回一个函数作为结果。普通函数(那些不接收或返回函数的)被称为一阶函数

在相同的例子中,你还可以编写如下代码:

❶ const myVisit = (x) => {
  // ...do some interesting things with x
}

let myTree = newTree();
// ...set up myTree, add to it, etc.

❷ preOrder(myTree, **myVisit**);

该函数存储在变量❶中,因为函数只是另一种可以存放在变量中的值类型,然后变量的内容以与传递 myTree(另一个类型不同的变量)相同的方式传递给函数❷。

在本章中你会看到更多的例子,包括返回函数作为结果的函数。

声明式编程

函数式编程(FP)采用更高层次的声明式风格,而不是在过程式“常规”编程中使用的命令式风格。在声明式编程中,你只需要指定你想要什么,而不是逐个详细地列出完成它所需的步骤,这在过程式编码中是必须的。声明式编码的最佳示例是数组操作。操作数组通常涉及循环,你可以“手动”实现(比如使用 while 循环),或者使用更常见的 for 语句,但 JavaScript 让你可以使用一些特定的数组函数进行声明式操作。事实上,我们将在接下来讨论一些方法,但概念是一样的;方法归根结底也是函数。

以下列表详细介绍了一些可用的数组函数,帮助你在数组中查找或选择元素:

**.filter() **从数组中挑选出满足某些条件的元素

**.find() 和 .findIndex() **在数组中查找满足某些条件的元素

**.some() **检查数组中是否至少有一个元素满足某个条件

**.every() **测试数组中的所有元素是否满足某个条件

其他函数将数组转换成一个新的数组或单个结果:

**.map() **通过对数组的元素应用给定的函数,将一个数组转换为另一个数组

**.reduce() **对整个数组应用给定的操作,从左到右,将其简化为一个单一结果

**.reduceRight() **与 .reduce() 类似,但从右到左进行操作

这个列表并不是完整的:还有更多转换数组的函数,如 .flat() 或 .flatMap(),但你在这里看不到它们。

注意

有些人说这些函数比对应的手写循环更慢,使用它们会带来一些性能损失。即使这些说法是真的,它们也不重要。除非你遇到了真正的性能问题,并且在分析代码后得出结论认为是数组函数导致的,编写更长、更多错误的代码实际上没有什么意义。

过滤数组

让我们看一个常见的任务:遍历数组,选择满足某些条件的元素,并丢弃其余元素。 .filter() 方法正是实现了这一点:你提供一个谓词(一个根据参数生成布尔结果的函数),然后返回一个新数组,只包含原始数组中满足谓词条件的元素。

例如,要选择所有小于 21 的值,以下谓词将非常有用:

const under21 = (value) => value < 21;

under21()函数获取一个值,如果该值小于 21,则返回 true。现在你可以像下面这样编写代码:

let myArray = [22, 9, 60, 12, 4, 56];
let newArray = **myArray.filter(under21)**;
console.log(newArray); // [9, 12, 4]

这指定了你想要应用“小于 21”检查来过滤原始数组,结果是一个新数组,只包含满足给定测试的值。你不需要编写任何代码来控制循环,初始化输出数组或其他任何东西。代码更简洁且真正声明性;它只指定了你想要什么,而不是如何得到它

搜索数组

其他方法让你可以搜索数组中某个满足谓词的元素,返回该元素或它在数组中的位置:

find() 从头到尾遍历数组,测试给定的谓词;如果数组中的某个元素满足谓词,该元素将被返回;如果没有元素满足谓词,则返回 undefined。最近的新方法 findLast()做了相同的事情,但它是从尾部向头部搜索。

findIndex() 类似于 find(),但它返回第一个满足谓词的元素的位置,如果没有元素满足谓词,则返回-1。新方法 findLastIndex()返回最后一个满足谓词的元素位置。

这些方法非常实用,因为它们在一行代码中提供了所有需要的搜索代码。搜索谓词的复杂性没有限制。例如,你不仅限于寻找一个值,还可以测试任何条件,就像你之前使用 under21()一样。

这里有一些同时使用这两种搜索方法的示例,因为它们是相关且相似的:

let myArray = [22, 9, 60, 12, 4, 56];
const under21 = (value) => value < 21;
❶ console.log(myArray.find(under21));      **// 9**
❷ console.log(myArray.findIndex(under21)); **// 1**

const equal21 = (value) => value === 21;
❸ console.log(myArray.find(equal21));      **// undefined**
❹ console.log(myArray.findIndex(equal21)); **// -1**

满足 under21 谓词的 myArray 的第一个元素是 9 ❶,它位于数组的第 1 个位置 ❷。如果你使用一个 equal21 谓词重新调用,该谓词检查值是否为 21,你将得到 undefined ❸和-1 ❹,因为数组中找不到这样的元素。

注意

如果你正在搜索一个特定的值, .includes(),.index() 和 * .lastIndexOf() 会非常有用,尽管它们的灵活性不如前面描述的函数式编程(FP)方法,因为它们只是让你搜索一个值,而不能测试任何可能的条件。

测试数组

与搜索相关的方法有 .some() 和 .every()。第一个检查数组中是否任何元素满足某个谓词,第二个检查是否所有元素都满足该谓词:

let myArray = [22, 9, 60, 12, 4, 56];
const under21 = (value) => value < 21;
console.log(myArray.some(under21));  **// true**
console.log(myArray.every(under21)); **// false**

const equal21 = (value) => value === 21;
console.log(myArray.some(equal21));  **// false**
console.log(myArray.every(equal21)); **// false**

如果你测试数组中是否有元素小于 21,答案是“真”,但并非所有元素都为真。如果你重复进行这些等于 21 的测试,两个答案都为假。

如果你已经有了上一节中的方法,你可以省略这些函数(请参见本章末尾的第 2.7 问题)。

转换一个数组

算法通常需要遍历一组元素(如数组),对每个元素应用某些操作,进而创建一个新的集合。例如,在一个 Web 应用程序中,你可能有一个字符串列表,这些字符串可能表示数字,你可能想将这个列表转换为对应的数字值列表。为数组的所有元素设置一个循环,系统地处理每个元素,并生成一个新的数组,是一个常见的过程,通常会在开发者学习初期教授。这种转换在函数式编程(FP)中也非常关键,JavaScript 通过 .map() 函数来实现。

考虑一个简单的例子,它只生成一个新的数组,并将所有值乘以 10:

let myArray = [22, 9, 60, 12, 4, 56];
console.log(**myArray.map((x) => 10*x)**);
// [220, 90, 600, 120, 40, 560]

从你一直在使用的数组开始。如果你用一个将参数乘以 10 的函数来映射它,你将得到一个新的数组,其值是原数组值的 10 倍。

使用 .map() 而不是常规的循环意味着代码更加清晰,而且映射是函数式编程中的一个著名模式。代码也更简短,这意味着出错的机会更少。最后,代码生成一个新的数组,而不是修改原始数组,因此该函数是 纯粹的(你将在本章后面学习这是什么意思)。只要可能,使用 .map(),但要注意它的一些特性可能会让你吃亏;请参考本章末尾的第 2.4 问题获取示例。

将数组简化为单一值

这里是另一个常见任务:编写遍历整个数组的循环,执行某种操作,最后得到一个计算结果。(一个典型的例子是一个包含数字的数组,将它们加起来。)你可以使用 .reduce() 或较少使用的 .reduceRight() 函数以函数式的方式实现这种任务。以下代码对整个数组进行求和:

const myArray = [22, 9, 60, 12, 4, 56];
const mySum = myArray.**reduce((a, v) => a + v, 0)**; **// 163**

这个逻辑完成了应用一个接收两个值并返回它们和的函数到整个数组的所有操作,从 0 开始。换句话说,它将数组中的所有元素相加。a 参数代表 累加器(初始值为 0),v 代表 (数组中的每个元素)。你不需要为 .reduce() 提供初始值,但提供初始值更安全。如果你试图在没有初始值的情况下对一个空数组进行归约操作,你将遇到运行时错误。

为了展示 .reduce() 的强大功能,来看另一个案例,在这个案例中,你需要计算多个结果:计算数组值的平均值。为此,你不仅需要它们的总和(你已经知道如何获取),还需要它们的数量(不要忘记你可以使用 myArray.length 来获取后者;这只是一个例子):

const myArray = [22, 9, 60, 12, 4, 56];
myArray.**reduce((a, v) => ({s: a.s + v, c: a.c + 1}), {s: 0, c: 0})**;
// {s: 163, c: 6}

该对象有两个字段(s 代表总和,c 代表计数),并且归约函数在每一步都重新计算这些值;最终结果与前面的示例相符。

如果你需要从右到左处理数组,.reduceRight() 的工作方式与 .reduce() 相同,但它从数组的末尾开始,返回到第一个元素。当然,你也可以通过先使用 .reverse() 然后再使用 .reduce() 来反转原始数组,但那会产生副作用。数组会就地被反转(关于此,请参见第 33 页的“变异参数”)。

遍历数组

.forEach() 数组函数负责遍历数组,为每个元素调用回调,因此你只需要声明你想要做什么工作,其他的都不需要。你可以使用这个函数重新实现数组求和逻辑:

const myArray = [22, 9, 60, 12, 4, 56];
❶ let sum = 0;
❷ myArray.forEach((v) => {
  sum += v;
});
❸ console.log(sum); // 163, as earlier

首先将 sum 变量设置为 0;该变量将获得数组中所有元素的总和❶。然后遍历数组❷并指定你希望对每个元素 v 做的操作。在这种情况下,将其加到 sum 中,最终结果❸与之前完全相同。

高阶函数

如前所述,接收其他函数作为参数或返回函数作为结果的函数被称为 高阶函数。这意味着所有与回调函数一起工作的函数都是高阶函数,刚才讨论的所有数组方法也是如此。这些函数中的一些允许你以更声明式的方式工作(就像你之前看到的),而另一些则允许你扩展函数并修改它的行为——例如,添加日志记录以帮助调试或使用记忆化以提高性能。

考虑一下高阶函数的用途之一:返回一个新函数,包含一个包装的行为示例。包装产生一个新函数,它保留了原始功能,但添加了一些额外的行为。假设你想为一个函数添加日志记录功能,用于调试目的。你当然可以修改这个函数,但这样做是有风险的,因为你可能会不小心修改不该触碰的部分。你也可以使用调试器,但包装函数提供了更多的灵活性。

原始函数可能是这样的:

const myFunction = (arg1, arg2) => {
  // Do something with arg1 and arg2
  // and eventually return something.
}

然后你可以修改它来添加日志记录:

const myFunction = (arg1, arg2) => {
 **console.log("Entering myFunction with ", arg1, arg2);**
  // Do something with arg1 and arg2
  // and calculate something to return.
 **const toReturn = something;**
 **console.log("Exiting myFunction, returning ", toReturn);**
 **return toReturn;**
}

然而,如果函数有多个返回语句,你需要修改它们所有的返回部分。

使用高阶日志记录函数更好:

const addLogging = (fn) => (...args) => {
❶ console.log("Entering ", fn.name, " with ", . . .args);
❷ const toReturn = fn(...args);
❸ console.log("Exiting ", fn.name, " returning ", toReturn);
❹ return toReturn;
}

addLogging() 函数接收一个函数作为参数,并返回一个新函数❹。首先,这个新函数记录原始函数的名称❶和它接收到的参数。然后,它实际上调用原始函数来计算函数所需的内容❷。之后,新函数记录结果❸,并最终返回该值。

这是一个简单的示例:

❶ const sum2 = (a, b) => {
  console.log("Calculating...");
  return a+b;
}

❷ addLogging(sum2)(22, 9);
**// Entering sum2 with 22 9**
**// Calculating...**
**// Exiting sum2 returning 31**

sum2() 函数记录某些内容,并返回它的参数之和❶。当你将 sum2 作为参数传递给 addLogging 并调用返回的函数❷时,你可以获得额外的日志记录,而不需要触及原始函数。

副作用

当一个函数仅依赖它所接收的参数,并且不产生任何副作用时,它被称为纯函数。纯函数的概念与数学函数密切相关:给定一个f(x)函数,当给定一个x的值时,它所做的只是计算一个新的值。

使用纯函数的一个优点是它们不会产生任何副作用,例如改变程序状态、修改变量、改变对象等。这意味着当你调用一个纯函数时,你不需要担心代码中的任何可能的变化或其他部分可能会被破坏;你可以专注于传递给函数的参数,知道不会发生任何“意外”。当传入相同的参数时,函数总是会返回相同的结果。这个结果不会依赖于任何“外部”变量或状态,这些变量或状态可能会改变并导致函数产生不同的结果。另一方面,纯函数不能依赖于随机数、一天中的时间、输入/输出(I/O)函数的结果等;它只依赖于它的输入。

使用全局状态

副作用的最常见原因是使用与代码其他部分共享的非局部变量。由于纯函数在给定相同输入时总是产生相同的输出,如果一个函数依赖于它之外的某些变量,它就自动变成了不纯函数。

问题更为复杂;调试一个依赖于全局状态的函数更加困难,因为为了理解为什么一个函数返回了某个特定的值,你必须还要理解程序状态是如何达到的,而这本身就需要理解运行代码的所有历史。即使你没有特别遵循函数式编程(FP)的原则,避免使用全局变量的建议也是非常有价值的。

保持内部状态

你可以将不使用外部变量的做法扩展到避免使用内部变量,这些变量在函数调用之间保持状态。即使没有全局变量的存在,内部变量也可能导致未来对函数的调用返回不同的输出,即使传入相同的输入参数。

使用内部状态是为什么函数式编程者不喜欢使用对象的原因。面向对象编程(OOP)要求数据存储在对象中以供计算,这自动开启了不纯代码的可能性,因为某些方法可能不仅依赖于它们的参数,还依赖于对象的内部属性。

变更参数

我们已经考虑了操作(并可能修改)外部或内部变量,但还有一种“罪行”你可能会犯:修改函数的实际参数。在 JavaScript 中,参数是按值传递的,除非它们是对象或数组,这些是按引用传递的。后者意味着,如果函数修改了参数,它实际上是在修改原始对象或数组,这无疑是一种副作用。你在本章之前看到的一个可能的例子是通过首先应用 .reverse() 来反转数组,从而模拟 .reduceRight() ——这是一个意外的副作用(参见第 29 页的“将数组缩减为单个值”部分)。

检测这种错误可能比较困难,因为 JavaScript 本身提供了几个会根据定义修改其输入的函数和方法。例如,如果你决定对输入数组进行排序,执行 myArray.sort() 实际上会修改原始数组,而这可能是调用你函数的用户未曾注意到的。其他数组方法,如 pop() 或 splice(),也会影响相关数组;类似的变异方法还有很多。(然而需要注意的是,ECMAScript 最近增加了 toSorted()、toReversed() 和 toSpliced() 方法,它们不会影响原始数组。)

返回不纯函数

有些函数本质上是不纯的。例如,如果你调用一个 API 来获取每日新闻,你期望每次返回不同的结果。如果你正在编写一个游戏,并且需要使用 Math.random() 来生成随机数,你会希望每次结果不同;如果它总是返回相同的数字,那就没有用了。类似地,任何与当前日期或时间相关的函数都会是不纯的,因为其结果取决于外部条件(即时间),这可以看作是全球状态的一部分。

对于与 I/O 相关的函数,返回结果可能因其他原因发生变化。I/O 错误可能会意外发生;例如,外部服务可能崩溃,或者某些文件系统的访问权限可能发生变化。这意味着函数在任何时候都可能抛出异常,而不是返回数据。即使是看似安全的函数,如 console.log(),它不产生内部变化,但也是不纯的,因为用户会看到控制台输出的变化。

不纯函数

摆脱所有不纯的情况可能不现实,因此下一个选项是考虑如何减少问题的规模。一个解决方案是避免使用状态,另一个解决方案是使用注入模式来控制不纯性。

避免状态

关于设置全局状态,幸运的是,有一个广为人知的解决方案。如果一个函数需要获取全局状态,只需将所需的状态元素作为参数提供给该函数。这种方法解决了这个问题,因为函数就不需要直接访问全局状态了。另一方面,如果一个函数需要设置全局状态,它不应该直接这么做。该函数应该生成一个更新后的状态并返回,调用者应负责更新全局状态。如果确实需要更新状态,至少会在更高的层次上进行;提供状态数据给函数的同时,也会更新状态。

这两条规则也简化了测试。你不需要设置某些全局状态,只需将初始状态提供给函数,然后检查返回的新状态是否正确。

使用依赖注入

所以,处理状态的问题已经解决了,但如果你真的需要一些不纯粹的函数——例如用于 I/O 或随机数时怎么办呢?这里展示的技术提供了更灵活的代码,简化了单元测试,并便于后期维护。

假设一个函数需要调用 API,并且像本章之前展示的那样直接通过 Axios 实现(参见第 25 页的“函数作为一等公民”部分):

doSomething(a, b, c);
...
function doSomething(x, y, z) {
  // ...
  **axios.get("/some/url");**
  // ...
}

不直接调用 API,而是提供(或注入)一个函数来执行它:

❶ const getData = (url) => axios.get(url);

❷ doSomething(a, b, c, **getData**);

function doSomething(x, y, z, getter) {
  // ...
  **getter("/some/url");**
  // ...
}

你定义了一个新的 getData() 函数,实际调用了 API ❶ 并将其作为新的额外参数 ❷ 传递给(修改后的)doSomething() 函数。你实际上并没有避免使用不纯函数(例如进行 I/O 操作),但现在调用者通过注入相关函数来控制这一过程。

这个解决方案与避免使用全局状态时的做法是一样的,因为直接使用 axios.get() 实际上是在使用全局对象的方法,而你避免这种情况的做法是为函数提供一个额外的参数,这样它就不需要直接访问任何全局对象了。整个代码仍然会按需进行 I/O 操作,但现在更低层次的 doSomething() 函数是纯粹的,并且为了测试目的,你可以提供一个模拟函数。### 总结

本章中,我们描述了函数式编程(FP)的特点,JavaScript 如何支持它,并给出了几个使用示例。以 FP 为导向的编程方式能够使代码更清晰、更易于维护,且我们将在本书中根据实际情况始终使用这种风格。在后续章节中,我们将致力于应用 FP,并使用函数来处理算法和数据结构。第十八章完全采用函数式编程路线,探讨了 FP 概念如何扩展到函数式数据结构。

问题

2.1 纯粹还是不纯粹?

考虑以下一个计算圆周长的函数,它通过访问全局变量来计算,但它是纯粹还是不纯粹的?

const PI = 3.14159265358979;
const circumference = (r) => 2 * PI * r;

2.2 为失败做好准备

addLogging() 函数没有考虑到原始函数抛出异常的情况。你能修改它以便在这种情况下也能正确返回结果吗?

2.3 你有时间吗?

编写一个 addTiming() 高阶函数,它接受一个函数作为参数并生成一个等效的新函数,但会在控制台记录时间数据。你想要的解决方案类似于本章提到的 addLogging() 函数;也要注意异常的处理。

2.4 解析问题

如果你尝试对 ["1", "2", "4", "8", "16", "32"] 使用 .map(parseInt),会得到一个奇怪的结果 [1, NaN, NaN, NaN, 1, 17];你能解释一下为什么吗?提示:检查 .map() 向你的函数传递了哪些参数。

2.5 否定一切

编写一个 negate() 高阶函数,给定一个谓词,它将生成一个互补谓词,返回相反的结果。例如,如果你有一个 isAdult() 函数检查某个参数是否大于或等于 21,negate(isAdult) 将检查该参数是否大于或等于 21。(提示:你可能会发现这个函数在接下来的两个问题中很有用。)

2.6 每个、一些 ... 没有?

创建一个 .none() 方法,用于检查数组中是否没有元素满足给定的谓词条件。

2.7 没有一些,没有每个

根据 .find().findIndex() 编写 .some().every() 的等效函数。

2.8 它做了什么?

解释以下代码的输出及其原因:

["James Bond", 0, 0, 7].map(Boolean)

第三章:3 抽象数据类型

抽象数据类型(ADT)由它支持的操作和提供的行为来定义。在本书中,我们将研究数据结构,前提是它们能够实现特定的 ADT;从实际角度来看,你可以说 ADT 通常指定了需求和要求。本书不会单纯研究数据结构;我们总是将它们置于 ADT 的上下文中,考虑数据结构(和相关算法)需要支持的操作。在这一章中,你将学习更多关于 ADT 的知识,以及如何在 JavaScript 中实现它们。

一个 ADT 可以通过多种方式实现,可能有不同的性能(这是我们将在下一章讨论的话题),使用不同的数据结构和算法。例如,你可以用数组、列表或树来实现集合,但在不同情况下,性能可能不同。一个实际的实现(意味着某个数据结构和与之配套的算法)可能被称为具体数据类型(CDT),但在这里你不会看到这个术语。

数据类型的实现不是抽象的;它是一个具体的方面,影响着开发者。数据类型的定义不需要编码,但其实现肯定需要编码。首先,让我们回顾一些关于数据类型、抽象和操作的基本概念,然后再详细定义抽象数据类型(ADT)。

理论

什么是数据类型,我们如何使用它们?它们可以抽象地定义吗,还是我们必须始终依赖实际的实现?我们能对数据类型做些什么,它们提供了哪些操作,它们有何影响?在开始学习 ADT 之前,让我们更仔细地看看激发本章重点的基本软件概念。

数据类型

编程语言最初只包括少数几种内建数据类型,例如字符、字符串、布尔值和数字(整数或浮点数),开发者无法添加新的数据类型;给定的选项就是他们能使用的所有内容。随着像这样的概念加入编程语言,开发者可以添加新的、更复杂的数据类型。一个数据类型(无论是语言提供的还是你自己创建的)由它可能表示的值集合以及可以对其执行的操作来定义;例如,可以将两个字符串连接起来,执行布尔值的逻辑运算,进行整数的算术运算,或者比较浮点数。

在使用数据类型时,通常不需要关心其内部表示的细节——只需要关心你能做什么以及如何利用它来获得结果。输入和输出才是最重要的。ADT 的基本理念是指定可以执行的操作,忽略内部方面。(如果语言提供位操作或某些低级特性,可能需要了解内部表示的细节,但对于大多数编程任务来说,你不需要这样做。)

现代语言,包括 JavaScript,允许用户定义自己的数据类型。一开始,开发者只能使用简单的记录类型(比如用三个数字字段表示日期:日、月、年),但现在你可以进一步使用类来隐藏实现细节,这样用户只需要关心如何使用新定义的数据类型,而不需要关注其他内容。

注意

ADT 也可以代表代数数据类型,这是一个不同的概念,表示通过组合其他类型形成的类型。

抽象

我们已经在谈论抽象的概念,现在让我们更具体地思考这个术语的含义。基本上,抽象意味着隐藏或省略细节,转而寻求一个更高层次的总体概念。当我们谈论抽象时,我们有意识地忽略实现方面的内容,至少在此时,我们专注于我们的需求,无论我们以后如何通过代码解决它们。例如,你需要存储和检索字符串吗?字典抽象数据类型(ADT)就是你的解决方案;你稍后会看到如何实现它,但无论你怎么做,这就是你需要的数据类型。

软件工程有三个相似且相关的概念:

封装 将模块设计为好像它们周围有一个“外壳”或“胶囊”,只有该模块负责处理其数据。其理念是将数据和处理这些数据的方法包装在一起,放在同一个地方,以实现更为一致和紧密的设计。

数据隐藏 将模块实现的内部细节隐藏起来,确保它们的更改不会影响系统的其他部分。这种机制确保外部无法访问内部细节。换句话说,封装将一切聚合在一起,而数据隐藏确保没人能从“外部”干扰内部内容。

模块化 将系统划分为可以独立设计和开发的单独模块。正确使用模块可以提供封装和数据隐藏。

ADT 只定义它能执行哪些操作;它不会详细说明这些操作如何实现。换句话说,通过 ADT,你描述的是“抽象”的操作,而不是具体的实现细节。让我们来考虑一些可以对 ADT 执行的不同类型的操作。

操作与变更

一种常见的数据类型分类方法是通过 可变不可变 值来区分。例如,在 JavaScript 中,对象和数组是可变的。创建对象或数组后,你可以修改其值,而无需创建新的对象或数组。另一方面,数字和字符串是不可变的;如果对这些数据类型应用操作,将会生成一个新的、不同的、独立的值。

当设计一个新的日期类型(例如,一个包含三个独立整数值的对象,如本章早些时候提到的日期示例)时,你可以选择提供设置日期、月份或年份的操作,这样日期对象就是可变的。另一方面,如果这些操作返回一个新的日期对象,而不是修改现有对象,则日期对象是不可变的。

注意

React Redux 开发者深知不可变性及其要求。如果你想修改使用 Redux 的 React 应用程序的状态,不能直接修改它;你必须生成一个包含所需更改的新状态。Redux 假设你以不可变的方式管理状态数据。(我们将在第十八章进一步讨论不可变性。)

以下列表展示了适用于 ADT 的操作类别:

创建者 函数生成一个给定类型的新对象,可能需要一些值作为参数。以日期 ADT 示例为例,一个创建者可以根据日期、月份和年份值生成一个新的日期。

观察者 函数接收给定类型的对象并生成不同类型的值。例如,对于日期抽象数据类型(ADT),getMonth() 操作可能返回月份的整数,或者 isSunday() 谓词可以判断给定日期是否是周日。

生产者 函数接收一个给定类型的对象,可能还会接受一些额外的参数,并生成一个新的给定类型的对象。对于日期 ADT,你可以有一个函数将一个整数天数加到日期上,生成一个新的日期。

修改器 函数直接修改给定类型的对象。例如,setMonth() 方法可以修改一个对象(更改其月份),而不是生成一个新的对象。

对于不可变数据类型,仅适用前三种操作;而对于可变数据类型,还适用修改操作。

实现一个 ADT

考虑一个情况,你想实现一个 集合多重集合,它是类似集合的容器,但允许重复元素。(集合定义上不能包含重复元素。)我们还将增加一个额外的操作(“greatest”),让它更有趣。表 3-1 提供了本书中如何描述 ADT 的示例。

表 3-1:集合的操作

操作 签名 描述
创建 → bag 创建一个新的集合。
空吗? bag → boolean 给定一个集合,确定它是否为空。
添加 bag × value → bag 给定一个新值,将其添加到集合中。
移除 bag × value → bag 给定一个值,将其从袋子中移除。
查找 bag × value → boolean 给定一个值,检查它是否存在于袋子中。
最大值 bag → value | undefined 给定一个袋子,找到其中的最大值。

现在暂时忽略中间一列,专注于另外两列。操作列列出了每个提供的操作,而描述列则提供了该操作预期实现的简单解释。你需要能够创建一个新的(空的)袋子,并测试该袋子是否为空。你还需要能够向袋子中添加新值,并从中移除先前添加的值,这两个操作都会改变袋子的内容。最后,你希望能够找到袋子中是否包含给定的值,并且确定袋子中的最大值。

你也可以有一列指定操作的类型——创建者、观察者、生产者等等——但通常这是通过操作的描述来理解的,而不会明确地包括在内。

操作的签名是什么?这是表 3-1 中间一列的内容。除非使用 TypeScript 或 Flow(如第一章中提到的),否则 JavaScript 不允许开发者为函数和变量指定类型,但添加这些信息(即使只是以注释或类似这种表格的形式)有助于用户更好地理解函数的期望和返回结果。

指定一个函数的参数和返回结果被称为签名,它基于一种叫做Hindley-Milner类型系统。你从函数参数的类型开始,按顺序排列,用×分隔,接着是一个箭头,然后是函数返回结果的类型。

让我们考虑一些示例。表 3-1 显示,create()函数不接受任何参数,返回一个 bag 类型的结果。同样,add()接受两个参数,一个是袋子,一个是值,它返回一个袋子作为结果。最后,greatest()函数接受一个袋子参数,并返回一个值或 undefined。

完整的 Hindley-Milner 系统包含更多的细节,比如类型约束、泛型类型、未确定数量的参数、类方法等等,但对于我们的需求,表 3-1 中展示的定义已经足够。

使用类实现 ADT

让我们用一个类来开始实现袋子 ADT。对象将有两个属性:count,它计算袋子中有多少个元素,和 data,它是一个对象,每个元素都有一个键,并且该键的值表示它在袋子中出现的次数。请记住,我们并不寻找一种特别高效的方式来实现袋子(我们将在第十一章中讨论这个问题)。现在,我们只是看一个使用类的示例。

例如,如果你将字符串 HOME、SWEET 和 HOME 添加到袋子中,该对象将如下所示:

{
  count: 3,
  data: {
    **HOME**: 2,
    **SWEET**: 1,
  },
};

计数属性的值为 3,表示有三个字符串已添加到包中。数据部分包括一个 HOME 属性,其值为 2(因为 HOME 被添加了两次),以及一个 SWEET 属性,其值为 1。

清单 3-1 显示了完整的 Bag 类。

class Bag {
❶ count = 0;
  data = {};

❷ isEmpty() {
    return this.count === 0;
  }

❸ find(value) {
    return value in this.data;
  }

❹ greatest() {
    return this.isEmpty() ? undefined : Object.keys(this.data).sort().pop();
  }

  add(value) {
  ❺ this.count++;
  ❻ if (this.find(value)) {
      this.data[value]++;
    } else {
      this.data[value] = 1;
    }
  }

  remove(value) {
  ❼ if (this.find(value)) {
    ❽ this.count--;
    ❾ if (this.data[value] > 1) {
        this.data[value]--;
      } else {
        delete this.data[value];
      }
    }
  }
}

清单 3-1:包 ADT 的一种可能实现

新对象以零计数和空的数据集初始化 ❶。你可以通过检查计数是否为零来判断该对象是否为空 ❷。要查看包中是否包含给定的键 ❸,可以使用 in 操作符检查它是否出现在数据对象中。由于 JavaScript 的功能,找到最大键 ❹ 也并不困难。你首先获取一个包含所有键的数组(所有添加到包中的值),然后对其进行排序,最后使用 pop() 获取数组的最后一个元素,这将是包中最大的键。

要向包中添加一个键,首先将计数加 1 ❺,然后检查该键是否已经在包中 ❻;如果在,递增其计数;如果不在,则以计数 1 将其添加进去。

要从包中移除一个键,首先验证该键是否确实在包中 ❼。如果不在,就不做任何操作。如果找到该键,递减其计数 ❽,然后检查该键在包中出现的次数 ❾。如果其计数大于 1,则将其减 1。如果计数恰好为 1,则从数据对象中移除该键。

如何使用这个对象?以歌曲《Home, Sweet Home》中的几个词为例(原版歌曲来自 1823 年,而非 Mötley Crüe 演唱的新版),你可以做类似于清单 3-2 中展示的代码,将部分歌词添加到包中。

const b = new Bag();
❶ console.log(b.isEmpty());   // true

❷ b.add("HOME");
b.add("HOME");
b.add("SWEET");
b.add("SWEET");
b.add("HOME");

b.add("THERE'S");
b.add("NO");
b.add("PLACE");
b.add("LIKE");
b.add("HOME");

❸ console.log(b.isEmpty());   // false

❹ console.log(b.find("YES")); // false
console.log(b.find("NO"));  // true

❺ console.log(b.greatest());  // THERE'S
❻ b.remove("THERE'S");
console.log(b.greatest());  // SWEET

清单 3-2:包实现的测试

新创建的包是空的 ❶,正如预期的那样。你可以向其中添加几个键 ❷,显然包就不再为空了 ❸。(关于更简洁的方式来链式调用类似操作,请参见问题 3.1。)查找操作 ❹ 如预期般工作;"YES" 不在包中,但 "NO" 在。最后,包中最大的键是 "THERE'S" ❺,但在移除它 ❻ 后,"SWEET" 成为了新的最大值。

使用函数实现 ADT(可变版本)

现在你已经创建了 ADT 的具体实现,如果你使用函数而不是类,情况会如何变化?清单 3-3 使用了相同的表示法,基于一个具有计数和数据属性的对象。不同之处主要是语法上的,比如将包对象作为参数传递给函数,而不是在方法中通过 this 引用它。

 const newBag = () => ({count: 0, data: {}});

  const isEmpty = (bag) => bag.count === 0;

  const find = (bag, value) => value in bag.data;

  const greatest = (bag) =>
  isEmpty(bag)
    ? undefined
    : Object.keys(bag.data).sort().pop();

  const add = (bag, value) => {
  bag.count++;
  if (find(bag, value)) {
    bag.data[value]++;
  } else {
    bag.data[value] = 1;
  }
  return bag;
};

  const remove = (bag, value) => {
  if (find(bag, value)) {
    bag.count--;
    if (bag.data[value] > 1) {
      bag.data[value]--;
    } else {
      delete bag.data[value];
    }
  }
  return bag;
};

清单 3-3:包 ADT 的一种替代(可变)实现

清单 3-3 中的代码与清单 3-1 中的使用类的代码类似。newBag()函数返回一个具有 count 和 data 字段的对象,就像 Bag 类中的构造函数一样。对于其他五个函数(isEmpty, find, greatest, add 和 remove),与基于类的代码相比,有两个不同之处:你使用 bag 参数来访问对象,而不是使用 this,并且你在 add()和 remove()变异方法的末尾显式返回 bag。然而,在这种情况下,你其实不需要这样做,因为你实际上是在修改通过引用传递给函数的 bag 参数。(这是 JavaScript 传递对象作为参数的标准方式。)但是,如果你以不使用对象的其他方式实现这个 ADT,那么返回新的具体数据类型将是强制性的。由于你不想让外部依赖于实现的内部细节,最简单(也是最安全)的方法是始终返回新的更新对象,无论它的类型如何。

使用这种 ADT 实现的代码,如清单 3-4 所示,与清单 3-2 中的基于类的版本非常相似。

❶ let b = newBag();
❷ console.log(isEmpty(b));     // true

❸ b = add(b, "HOME");
b = add(b, "HOME");
b = add(b, "SWEET");
b = add(b, "SWEET");
b = add(b, "HOME");

b = add(b, "THERE'S");
b = add(b, "NO");
b = add(b, "PLACE");
b = add(b, "LIKE");
b = add(b, "HOME");

console.log(isEmpty(b));     // false

❹ console.log(greatest(b));    // THERE'S
❺ console.log(find(b, "YES")); // false
console.log(find(b, "NO"));  // true

❻ b = remove(b, "THERE'S");
console.log(greatest(b));    // SWEET

清单 3-4:可变实现的袋子测试

简单的区别在于对象创建❶,测试袋子是否为空❷,添加❸和移除❻元素,获取最大值❹,以及检查某个值是否在袋子中❺。你不再写 b.something(...),而是写 something(b, . . .)。

使用函数实现 ADT(不可变版本)

最后,让我们考虑一下我们 ADT 的不可变实现。(在第十八章中,我们将更详细地了解不可变数据结构,并介绍更多的案例。)这里没有特别的理由要求不可变性,除了希望以更函数式的方式工作,避免副作用,正如在第二章中所描述的那样。

在这种情况下,由于你希望开发一个不可变的袋子,你可能不能直接修改袋子对象,因此你需要改变变异方法的实现;其余部分保持不变。解决方案只是要求在袋子需要更改时创建并返回一个新对象。要添加一个新值,可以使用以下代码:

const add = (bag, value) => {
❶ bag = {count: bag.count - 1, data: {...bag.data}};
  if (find(bag, value)) {
    bag.data[value]++;
  } else {
 bag.data[value] = 1;
  }
  return bag;
};

由于向袋子中添加新值永远不会失败,你总是需要生成一个新对象,所以你实际上会执行❶。

要从袋子中移除一个值,首先要检查要移除的值是否在其中,然后再进行移除:

const remove = (bag, value) => {
❶ if (find(bag, value)) {
  ❷ bag = {count: bag.count - 1, data: {...bag.data}};
    if (bag.data[value] > 1) {
      bag.data[value]--;
    } else {
      delete bag.data[value];
    }
  }
  return bag;
};

和之前一样,首先检查该值是否在袋子中❶;如果它存在❷,则创建一个新对象,并返回它。

在这种情况下,代码修改很少,但对于更复杂的数据结构(如本书后续将介绍的内容),创建现有结构的副本可能就不那么容易或快速,你需要做额外的处理或结构化。

总结

在这一章中,我们介绍了抽象数据类型(ADT)的概念,你将在本书的其余部分看到它,特别是在分析竞争的数据结构和算法的优缺点时。定义一个 ADT 是决定应该使用什么结构以及如何实现算法的第一步。理解 ADT 的概念将帮助你为代码获取最佳性能。

在下一章,我们将研究一个互补的概念:我们如何比较抽象数据类型(ADT)的具体实现,换句话说,我们如何判断一个算法是否比另一个算法更好或更差?我们还将介绍算法分析和与性能类别相关的概念。

问题

3.1  链式调用

修改背包方法,以便你可以像下面这样进行链式添加:

const b = new Bag();
b.add("HOME").add("HOME");
b.add("SWEET").add("SWEET").add("HOME");

你还应该能够将移除操作和其他操作进行链式调用,例如以下操作,它将移除两个值,并测试背包是否变为空:

b.remove("NO").remove("HOME").isEmpty();

3.2  数组,而不是对象

你能否使用数组而不是对象来实现背包 ADT?你可以用有序数组表示背包,从而使 greatest() 函数的实现变得非常迅速。当然,add() 方法应该负责保持数组的顺序。

3.3  额外操作

本章只描述了背包的一些额外操作,但对于某些应用,你可能需要增加或更改操作;你能想到哪些吗?

3.4  错误操作

在定义一个 ADT 时,你如何指定错误结果,比如可能抛出异常或返回某种特殊值?

3.5  准备,集合……

在本章中,我们讨论了背包,但在后面的章节中,我们将讨论集合,它不允许重复的值。你能提前思考并设计一个合适的 ADT 吗?

第四章:4 分析算法

在上一章中,我们讨论了抽象数据类型,在本书的后续章节中,我们将考虑更多的替代实现和算法。在面对多种实现同一抽象数据类型的方式时,需要考虑每种具体实现的效率,这就需要分析相关的算法。我们将在本章中学习这种分析的基础知识,帮助我们做出更好的决策。你应该选择什么数据结构?你应该实现什么算法?客观地了解如何分析它们的性能将得出正确的答案。

性能

在衡量给定算法的效率时,关键是考虑算法所需的资源(例如时间或随机存取内存 [RAM]),然后根据所需的资源量比较不同的算法。(这种方法对小问题并不适用。例如,如果你有一个只有十几个键的字典,无论它是如何结构化的,或者你应用了什么算法进行搜索,结果都会很快。)

我们总是希望最小化资源的使用(更快的处理时间,更少的内存需求),但我们无法直接将时间复杂度(速度)与空间复杂度(内存)进行比较。通常,性能更快的算法需要更大的内存,反之亦然;更小、更简单的结构可能意味着更慢的算法。(你将在本章稍后看到一个例子。)然而,如果一个算法需要的时间过长,或者需要的内存超出了可用内存,那么这些考虑就变得无关紧要了。

在本书中的所有例子中,我们会看到算法的空间复杂度相对稳定。它与输入元素的数量成正比增长,因此可能没有理由选择一个算法而不是另一个。另一方面,我们将看到时间复杂度会产生许多变化,为选择使用哪种数据结构以及实现哪种算法提供了坚实的基础。

因此,每当本书提到任何给定算法的复杂度时,它总是指时间复杂度,或者说算法在与输入数据大小相关的情况下执行其功能所花费的时间。

复杂度

所有数据结构总是有某些基本参数,所有算法的效率都依赖于这些参数。例如,如果你在字典中进行查找,字典中的键的数量可能会影响查找速度;更多的键意味着更多的时间。如果排序一组值,更多的值意味着排序速度会变慢;例如,对五张扑克牌进行排序可以非常快速,但对一副 52 张牌进行排序就需要更长时间。在所有情况下,我们将这个输入参数称为n,你会把算法的时间复杂度表示为该输入的函数;这就是算法分析。当该函数的值较小或至少相对于输入大小的增长较慢时,算法会更高效。

在某些情况下,算法的性能可能与数据本身直接相关;例如,排序一个几乎有序的序列可能比排序一个完全无序的随机序列要快。这意味着我们需要考虑最佳情况、最坏情况以及平均性能。如果没有特别说明,我们将关注算法复杂度的上界,因此在本书中,除非另有说明,我们将着重讨论最坏情况复杂度。

通常,我们不会尝试(或无法)获得复杂度函数的精确表达式。我们会将它与常见的数学函数进行比较,例如nn²或n log n,并考虑算法属于哪个类别,以便在同等基础上进行比较。属于同一类别的算法不一定在速度上完全相同,但粗略来说,所有同类算法在处理更大输入时将以相同的速率增长,并保持相同的相对关系。换句话说,一个比其他算法快 10 倍的算法很可能一直保持这样的速度;它不会变成比其他同类算法快 100 倍或慢一半。

复杂度符号

为了表达给定函数在其参数增长时的表现,我们使用一组被称为渐近符号的符号。这个符号家族包括五种不同的符号,其中最常用的是大 O符号。O代表“阶”——或者更准确地说,是德语中的Ordnung一词。(稍后你会看到其他四种符号。)

O符号根据它们的n参数增长时的表现来对函数进行分组。根据我们研究的算法或数据结构的不同,n可以是需要排序的值的数量、要搜索的集合的大小,或者添加到树中的键的数量。在讨论性能时,具体情况会逐一说明。

用大O符号描述一个函数的行为意味着该函数增长的上界。简而言之,如果一个函数f(n)的行为是O(g(n)), 那意味着当n增长时,两个函数的增长速度成正比。(完全定义还表明,这种关系不一定适用于所有n值,而只适用于足够大的值。对于小的n值,这个关系可能不成立。)换句话说,若说一个给定算法的行为是O(某函数),这就意味着在更大的n值下所需的时间增长趋势已经可以得出。

让我们回到五种符号(表 4-1)。

表 4-1:五种渐近符号

符号 名称 描述
f(n) = o(g(n)) 小 o g(n)的增长速度远远快于 f(n);g(n)的增长速度严格大于 f(n)。
f(n) = O(g(n)) 大 O g(n)是 f(n)的上界;g(n)的增长速度大于或等于 f(n)。
f(n) = Θ(g(n)) 大Θ g(n)是 f(n)的上下界;g(n)和 f(n)以相同的速度增长。
f(n) = Ω(g(n)) 大Ω g(n)是 f(n)的下界;g(n)的增长速度小于或等于 f(n)。
f(n) = ω(g(n)) 小ω g(n)的增长速度远远慢于 f(n);g(n)的增长速度严格小于 f(n)。

我们主要使用大O符号,其它符号是为了完整性而列出。大Θ比大O更精确,后者实际上是一个界限,但目标是找到一个既接近又不会与原始函数行为差异过大的界限。精确地表达任何算法的行为是相当复杂的(并且仍有许多算法的精确阶数尚未得知),因此使用阶数是合适的。例如,如果你的个人债务是几美元或几百万美元,实际的数字并不重要,知道在前一种情况下你做得很好,而在后一种情况下你陷入了严重麻烦就足够了。

注释

著名计算机科学家、《计算机程序设计艺术》一书的作者、算法分析专家唐纳德·克努斯曾建议将大 O 表示为大欧米伽(omicron),它是另一个与大写 O 形状相同的希腊字母,但这个提议未能成功实施。详情请见 danluu.com/knuth-big-o.pdf

另一种(粗略的)解释是,大 O 符号代表最坏情况,而大欧米伽符号代表最佳情况,或者某个算法可能需要的最短时间。从这个意义上讲,大 Θ 表示一个稳定表现的算法,因为最坏情况和最佳情况的增长速度相同。根据这种解释,小 o 符号表示一个更差的上限,而小欧米伽则表示一个更差的下限,即实际表现远离这两个边界,并且增长速度截然不同。

复杂度类别

我们通常发现算法只涉及几个常见的复杂度顺序。表 4-2 展示了本章中会用到的顺序。

表 4-2:常见的复杂度顺序

顺序 名称 示例
O(1) 常数级 访问列表中的第一个元素和弹出栈顶元素(见第十章)
O(log n) 对数级 使用二分查找在有序数组中查找元素(见第九章)和二叉树的平均高度(见第十二章)
O(n) 线性级 在无序数组中查找元素(见第九章)和树的中序遍历(见第十二章)
O(n log n) 对数线性 使用堆排序排序数组和快速排序的平均行为(见第六章)
O(n²) 二次方 使用冒泡排序排序数组和快速排序的最坏情况(见第六章)
O(kn) 指数级 测试一个二进制公式是否是重言式(k = 2)和斐波那契数列的朴素实现(k = 1.618)
O(n!) 阶乘 寻找最优旅行商问题解和通过随机排列排序(见第六章)

最后两个顺序的算法非常慢,实际上你在实际中不会使用它们;它们的时间复杂度增长得太快,无法使用。

图 4-1 是一个简单的图表,展示了表 4-2 中七个函数的行为。显然,O(log n) 算法要优于 O(n²) 算法。

图 4-1:此图表(使用 Desmos 绘制,www.desmos.com/calculator)展示了表 4-2 中的七个函数。

图表底部的前两个顺序(常数和对数)表现得非常好。当考虑线性(从左下角到右上角的对角线)和对数线性顺序(最接近对角线的曲线)时,增长开始变得重要。下一个顺序,二次方,在 x = 10 时超出图表范围,值为 x² = 100。最后,指数和阶乘级别的增长甚至更为严重,它们的增长使得这些算法无法使用。

你可以通过回答一个简单的问题来以另一种方式看待这种行为:如果输入大小是原来的 10 倍,给定的算法会怎样?如果算法是O(1),那么所需时间将保持不变,不会增长。如果是O(log n)算法,所需时间会增长,但增长量是固定的。一个O(n)算法的时间将(几乎)乘以 10,而一个O(n²)算法的时间将大约是 100 倍。一个O(n log n)算法则介于两者之间。差异很明显,但请注意,未来参考时,它更接近于O(n)。

上一段的结果也是为什么使用O(n)而不是O(9n)或O(22n)的原因。这三种算法之间的比率是恒定的,因此如果n增长,它们会以相同的速度增长。另一方面,O(n²)算法会增长得更快,它真的属于一个独立的类别。在比较类别时,常数值没有意义:即使加入某个常数因子,如果n足够大,O(n²)也总是比O(n)算法增长得更快(并且变得更大)。

性能测量

在衡量一个算法的性能时,最佳情况性能是算法在理想条件下的表现;例如,在前一节中我们提到过进行搜索并在数组的第一个位置找到所需的元素。你不能总是假设你会得到这种最优表现,但它是用来比较其他表现的基准。

补充情况是最坏情况性能,意味着你尝试衡量一个算法在最慢的可能情况下的表现。例如,在本书的后面,我们将看到一些通常具有O(n log n)性能的算法,对于特定的输入数据顺序,可能会退化为O(n²*)性能。最坏情况分析很重要,因为你应该总是假设这种可能性会发生;它是最安全(但也是最悲观的)分析。

第三种可能性是平均情况性能,意味着确定一个算法在典型或随机输入下的表现。在第六章中,你将看到快速排序的平均性能是O(n log n),尽管在某些情况下性能会更差。

第四种可能性是摊销时间。一些算法通常需要较短的时间来执行,但周期性地需要更多的时间。如果你只看一个单独的操作,结果可能不好,但如果你考虑在一长系列操作中的平均表现,你可能会发现,总体上,摊销时间比最坏情况要好得多,让你能够预测一系列操作的结果。

让我们考虑一个简单的示例:向固定大小的数组添加元素。如果每次你要添加一个新元素时都需要将当前数组复制到一个新的(且更长的)数组中,那么每次添加的成本将是 O(n)。然而,如果数组已满,一种替代策略是将其复制到一个新的双倍大小的数组中,腾出空位等待将来插入。

让我们看看这个策略是如何运作的。假设有一个几乎被填满的数组(灰色单元),在末尾只有一个空位(白色单元),如图 4-2 所示。

图 4-2:一个只有一个空位的数组

当添加一个新元素时(这是一个 O(1) 操作,具有常数时间复杂度),数组已满,但你还不需要担心(见图 4-3)。

图 4-3:现在数组已满。

然而,如果你需要添加另一个元素,但没有空位,那么你将数组复制到一个新的双倍大小的数组中,然后添加新值,如图 4-4 所示。

图 4-4:一个新的双倍大小的数组为新值和更多空间提供了位置。

在这个过程中,这是一个 O(n) 操作,你现在有 n 个空闲单元,可以放心,因为接下来的插入不需要复制,时间复杂度是 O(1)。下次数组满时,过程将会重复:一次长时间的复制,接着是许多快速的添加。通过对多个插入的成本进行平均,代价较高(不频繁)的复制操作将通过代价低廉(频繁)的简单添加操作得到补偿,从而摊销后的性能为 O(1)。

注意

接下来的部分数学性较强,如果你愿意,可以跳过演示,只研究结果。书中的其他部分不会涉及这么多数学内容。本节的目的是让你体验完整、正式的证明是怎样的。

实际算法分析

让我们考虑一个实际的排序示例。假设你要在一个长度为 n 的有序数组中查找给定的键。最坏情况是线性查找,依次遍历整个数组(因为你还没学到后面书中描述的更好的算法),结果没有找到该键。在这种情况下,线性查找的时间复杂度为 O(n),因为你必须遍历整个数组: n 步和 n 次(失败的)测试。最好的情况是在第一次尝试时就找到了你想要的键:Ω(1)。

计算平均值需要一点代数。你需要考虑所有的情况:你可能会在第一个位置、第二个位置,依此类推,一直到第 n 个位置,这意味着一共有 n 种可能性。平均而言,你必须测试 (1 + 2 + ... + n)/n 个元素。从 1 到 n 的数的和等于 n(n + 1)/2,所以最终的平均值是 (n + 1)/2。这个表达式显然与 n 成正比,因此该算法的平均行为确实是 O(n)。如果你必须考虑使用这个算法,你会以 O(n) 来思考,假设最坏情况;指望最好的情况是不现实的。

注意

还有一种看待这个计算的方法。搜索可能在第一个元素成功,也可能一直搜索到第 n 个元素;平均而言,搜索步骤是 (n + 1)/2。或者,搜索可能在第二个元素或第 (n – 1) 个元素成功;同样,平均也是 (n + 1)/2。对于第三、第四和后续元素也适用相同的推理。对于每一种搜索在少数步骤内完成的情况,都有一个对应的情况将平均步骤数推高到 (n + 1)/2。由于每种情况下的平均值相同,你可以得出结论那就是最终结果。这样你就得出了相同的结果,只是少了些代数推导,但却多了一些“手势解释”。

让我们讨论另一种搜索有序数组的方法,二分查找,你将在第九章中看到。与其从数组的开始遍历所有元素,不如从数组的中间开始。如果你找到了你要的元素,搜索结束。如果没有,你可以丢弃数组的一半(如果你想要的元素小于中间元素,你知道它不可能在数组的较大部分中),然后在另一部分递归地进行搜索。你通过选择新的部分的中间元素进行比较,依此类推。

考虑一个包含数字 4、9、12、22、34、56 和 60 的数组。如果你想检查 12 是否在其中,首先你会查看中间的元素:22。那不是你想要的,所以你可以丢弃数组的后半部分(34、56 和 60),因为你知道,如果 12 存在,它必须在前半部分。现在在剩下的数组 4、9 和 12 中寻找 12。首先查看其中间的元素(9),然后丢弃它和数组的前半部分(4)。搜索的最后一步查看一个只包含一个元素(12)的数组。它的中间(也是唯一)元素就是你要找的,所以搜索成功了。如果你在找 13,搜索到这里会失败,因为数组中没有剩余的部分。

为了看这个算法的表现,计算你需要测试一个元素多少次;假设数组的长度 n 是 2^(k–)¹,其中 k > 0,这样数组的每一半总是有一个奇数个元素。(这样做是为了简化计算;参见问题 4.9。)在一种情况下,元素在第一次尝试中就被找到。在两种情况下,关键元素在第二次尝试中被找到——即,选择的两半中的中间元素。在四种情况下,第三次尝试成功,在八种情况下,第四次尝试成功。图 4-5 显示了一个包含 15 个元素的数组的情况。

图 4-5:从包含 15 个元素的数组的中间开始

对于一个一般的数组,总比较次数是 S = (1 × 1 + 2 × 2 + 3 × 4 + 4 × 8 + ...+ k × 2^k ^(− 1)),你必须将其除以数组中的元素数量,才能得到平均值。要计算 S,可以用一个数学技巧,先写出一个更一般的公式。写 S = 1 × 2⁰ + 2 × 2¹ + 3 × 2² + ... + k × 2k*(−1),然后定义 f(x) = 1x⁰ + 2x¹ + 3x² + ... + kx**k*(−1);注意,S = f(2)。根据微积分的结果,f(x) 是 g(x) = 1 + x + x²+ x³ + ... + x**^k 的导数。由于一个著名的结果指出 g(x) = (x**k*(+1) – 1)/(x* – 1),通过求导你可以得到以下结果:

\(方程\)

现在,通过设置 x = 2,并记得 n = 2k*(–1),撤销你刚才做的归纳。你可以写出 S = (k* + 1)(n + 1) – (2n + 1)。将其除以 n,你会发现平均比较次数是 (k – 1) + k/n。你可以写出 k = log n(取以 2 为底的对数并向上取整),因此算法的平均性能是 Θ(log n)。呼!最坏情况下(搜索失败)需要 k 次测试,因此你仍然可以说二分查找是一个 O(log n) 算法。

使用大 O 符号是“更安全”的,并且提供了更好的“保护”。当然,你也可以说二分查找是 o(n) 或者更糟的是 o(n²),因为这些函数的增长速度更快,表现更差。小 o 和小欧米伽边界对于粗略估计是好的,但你希望能更精确,并尽可能得到更接近的边界。

大多数算法分析都涉及递推关系,如这里所示,有些研究在数学上甚至比你刚才看到的更复杂。递推关系通常有几种常见形式,如 P(n) = aP(n – 1) + f(n) 或 Q(n) = aQ(n/b) + f(n),在几乎无限的可能性中。对于每种情况,有几种技巧可以帮助找到 M(n) 的表达式(特别是,“主定理”可以迅速提供 Q(n) 递推式的解,但对此可以写一本书)。

时间和空间复杂度的权衡

在本章早些时候,我们提到过我们将关注时间性能,因为从存储需求的角度来看,算法通常表现良好。让我们探索一个简单的问题,看看时间和空间的权衡是如何应用的。

假设我们有一个(很长的)数字数组,并且经常需要查找从位置i到位置j(都包括在内)范围内的值的和,且i < j。(这个问题与将一长串文本拆分成对齐的行有关。)第一个解决方案只需要几个辅助变量,因此额外的内存需求是O(1),但计算这些和本身需要O(n)时间:

const sumRange = (arr, from, to) => {
  let sum = 0;
  for (let i = from; i <= to; i++) {
    sum += arr[i];
  }
  return sum;
};

这个函数清晰且正确——它仅包含一个循环,计算从“from”到“to”(包括在内)之间的所有值的和——但它的性能会对过程产生负面影响,因为你会频繁调用它。对于一个将被多次调用的函数,更好的性能是更受欢迎的。

你也可以应用动态规划的概念(我们将在第五章中更详细地学习),通过表格法来工作,预先计算从位置 0 到所有其他位置的和:

const precomputeSums = (arr) => {
  let partial = Array(arr.length);
  partial[0] = arr[0];
  for (i=1; i<arr.length; i++) {
    partial[i] = partial[i-1] + arr[i];
  }   
}

有了这个部分和数组,如果你需要从元素 0 到q的和,你已经有了这些值,对于从元素p > 0 到q的和,只需要计算从 0 到q的和减去从 0 到p–1 的和:

const sumRange2 = (partial, from, to) =>
  from === 0 ? partial[to] : partial[to] – partial[from-1];

这个解决方案意味着需要进一步的O(n)处理来计算部分和,这只需执行一次,并且需要O(n)额外的内存,但它提供了O(1)的区间和,因此你可以看到权衡:使用更多内存来应用更快的算法,或者通过接受较慢的性能来节省内存。

你应该选择哪个版本?这取决于问题以及当前的性能是否可以接受,甚至可能取决于是否有足够的内存可用!

总结

在本章中,我们讨论了与研究算法在操作或内存需求方面表现相关的定义。我们看到几类算法,通过比较在较大输入情况下的效率,帮助决定如何实现给定问题的解决方案。在下一章,我们将换个角度,研究如何创建算法,为本书的后续内容做准备,在那里我们将考虑许多不同的数据结构以及执行它们的算法。

问题

本章中的问题明显不同于书中其他所有问题,因为它们更具数学性。你可以随意跳过。

4.1  你说多快?

一名分析师刚刚完成了一项新算法的研究,并得出结论:根据输入大小n,它的运行时间正好是 17n log n – 2n² + 48\。你对这个结果怎么看?

4.2  奇怪的界限?

nO(n²)有效吗?那o(n²)呢?其他的阶次呢?

4.3  关于大 O 和Ω

如果某个函数既是 f(n) = O(g(n)),又是 f(n) = Ω(g(n)),你能推导出什么结论?

4.4  传递性?

如果 f(n) = O(g(n)) 且 g(n) = O(h(n)),那么 f(n) 和 h(n) 之间有何关系?如果不是大 O,而是考虑其他阶次:小 o、大 theta 等等,你会如何看待?

4.5  一点反思

看起来很明显,对于任何函数 f(n),都有 f(n) = Θ(f(n))。如果处理其他阶次而不是大 theta,你会怎么说?

4.6  倒着来

如果 f(n) = O(g(n)),那么 g(n) 相对于 f(n) 的阶是什么?如果 f(n) = o(g(n)) 呢?

4.7  一个接一个

假设你有一个包含两个步骤的过程:第一个步骤是一个 O(n log n) 的算法,第二个步骤是一个 O(n²) 的算法。那么整个过程的阶是什么?你能给出一个通用的规则吗?

4.8  循环循环

一个不同但相关的问题:假设你的过程包含一个 O(n) 循环,在每一步中执行一个 O(n²) 的过程。那么整个过程的阶是什么?再一次,你能提供一个通用的规则吗?

4.9  几乎是幂次…

在分析二分查找时,你学到了如果数组的长度是 2^k ^(–1)(其中 k > 0),那么初始数组及所有后续数组的长度都会是奇数。你能证明这一点吗?

4.10  最美好的时代;最糟糕的时代

如果一个算法的最优运行时间是 Ω(f(n)),而最差运行时间是 O(f(n)),会发生什么?

第二部分 算法

本书的第二部分,我们将把讨论从函数式编程、抽象数据类型和算法分析的理论话题转移开,重点讨论如何设计算法,并考虑多种解决特定问题的策略。

第五章:5 设计算法

本章介绍了几种设计算法的技巧。我们将从递归开始,它通过将问题分解成一个或多个更简单的相同问题来解决问题。我们还将讨论动态规划,它通过首先解决更简单的子问题并存储这些解决方案来避免不必要的重新计算,从而解决复杂问题;以及暴力搜索(或穷举策略,该策略通过系统地尝试所有可能的解决方案来找到问题的解。最后,我们将探讨贪心算法,该算法在每个问题的分岔点选择最佳的局部选项,期望这种方法最终能得出最优解。

解决方案。与列表中提到的其他策略不同,贪心算法并不总是能够找到最佳解。

这里探讨的策略已成功应用于与数据结构结合使用的算法开发,具体用于实现特定的抽象数据类型(ADT)。因此,专注于如何为任何给定问题设计新解决方案是值得的。本章涉及的技巧并不穷尽,但它们在我们稍后将要探讨的许多算法中都有所体现。

递归

递归的最简单定义大概是这样:“一个函数反复调用自己,直到它不再调用。”换句话说,当遇到一个问题时,如果它足够小,可以不需要进一步的递归调用来解决,但如果问题较大,函数会调用自己来解决更小的子问题,然后从这些子问题的解中找到原始较大问题的解。

注意

为了带点计算机幽默,这里有一个词典定义:“递归:(名词)见递归。”一个常见的说法是:“为了理解递归,你必须首先理解递归。”

正如在第二章中讨论的,递归是函数式编程中的一项关键技术。一些语言,例如 Haskell,甚至不提供常见的“循环”,而是完全依赖递归来工作。在计算机科学中,递归对于任何算法来说都是足够的,任何可以用循环完成的事情,递归也能完成。事实上,对于许多算法和定义来说,使用递归要更简单。

递归自然出现在多个领域:

数学 像阶乘或斐波那契数列这样的定义本质上是递归的。我们将在本章稍后探讨这两个例子。

数据结构 许多结构是以递归方式定义的。例如,正如你将在第十章中看到的,列表可以为空,也可以由一个特殊的节点组成,即列表的头部,后面跟着另一个列表;另一个来自第十三章的例子是,它由一个父节点(称为)组成,根节点连接着任意数量的子树。

过程 几种算法可以通过递归的方式逻辑表达。一个来自日常生活的例子是搜寻家中的某个物品。你首先在一个房间里寻找,如果找到了物品,你就完成了;如果没有找到,你就继续搜寻其他地方,应用相同的逻辑。如果没有地方可搜寻了,那就失败了。

递归函数总是有两种情况:可以直接解决的简单情况,不需要递归;以及复杂情况,需要使用函数本身来辅助解决。递归解决问题的关键是先假设问题已经被解决,然后使用(假设的)可用函数来编写代码。这是一个四步过程——看起来可能是循环的:

  1. 假设你已经有一个解决问题的函数。

  2. 找出一些可以直接解决的简单基本情况,没有任何复杂性。

  3. 想出你如何通过先解决一个或多个更小的版本来解决原始问题。

  4. 应用第 1 步中假设的函数来解决第 3 步中的小问题,或者如果它们足够小,像第 2 步一样解决它们。

让我们来看几个递归技术,展示如何设计清晰、易于理解的算法。

分治法策略

如前所述,递归的基本思想是基于解决简单问题来解决复杂问题。你将问题分解成它的更小版本,然后通过解决所有这些小版本来征服它。通常,你会通过递归地解决“仅一个更小”的版本来解决问题,这种策略有一个专门的名称,叫做减治法,但它仍然是相同的思想:将原始问题减少为更小的版本。唯一的区别是,你通过先解决一个(更小的)版本来解决大问题。我们将首先看看一些更简单的减治法例子,然后再探讨分治法策略。

计算阶乘

递归计算中最常被引用的例子可能是一个数字的阶乘,n!,这是一个减治法策略的例子。一个非负整数 n 的阶乘定义如下:当 n = 0 时,0! = 1;当 n > 0 时,n! = n × (n – 1)!,这是递归定义。

这个公式来自一个递归问题;即,你可以用多少种方式将 n 本书排成一排放在书架上?答案很简单:如果书架上没有书,就只有一种方式——空书架。然而,如果你有 n > 0 本书,你可以选择其中任何一本(有 n 种选择),把它放在书架上最左边的空位,然后将剩下的 (n – 1) 本书放在刚刚放好的书的右边,所有可能的排列组合就是 n! = n × (n – 1)!,如上所述:n 种选择第一本书的方式乘以 (n – 1)! 种排列其余书本的方式。

一个阶乘的快速实现(另见问题 5.1)如下:

const factorial = (n) => {
❶ if (n === 0) {
   return 1;
❷} else {
   return n * factorial(n - 1);
 }
};

这段代码紧密跟随定义,包含两个明确的情形:如果 n 为 0 ❶,返回 1;对于更大的 n ❷,使用递归。递归实现很难出错,因为逻辑与定义非常吻合。

搜索与遍历

让我们看一些其他的递减与征服例子,涉及搜索和遍历。在第四章中,我们提到过二分搜索,它是一种搜索有序数组的方法。如果数组为空,你就没有好运了;你想要的值不在里面。如果数组不为空,检查其中间元素,如果它正是你想要的,那就成功了。如果元素不匹配且大于你想要的值,就递归地搜索数组的左半部分;否则,搜索右半部分。

另一个例子是,考虑对一副扑克牌进行排序。如果牌堆为空,你就完成了。否则,你遍历牌堆寻找最小的牌并将其移除。然后,你对剩余的牌堆进行排序,并将其放到你刚刚拿起的牌上面。

最后,我们考虑遍历待办任务列表。(这称为遍历列表。)如果列表为空,你就没有事情可做,任务完成。否则,你从列表中取出最上面的任务,完成它,然后递归地处理剩余的任务。

考虑斐波那契数列

作为一个数学上的分治法例子,考虑斐波那契数列。该数列以 0 和 1 开始,之后每一项是前两项的和,因此数列是 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55,依此类推。(你将在第十五章中遇到基于斐波那契的结构,这个数列的应用甚至涉及估算敏捷方法中的任务复杂度,因此它的应用范围非常广泛。)

注意

对于好奇的读者,这个数列以意大利数学家皮萨的莱昂纳多(即斐波那契)命名,他也因将阿拉伯数字引入西方世界而闻名。斐波那契提出并解决了一个问题,涉及理想化的兔子种群增长,但该数列早已出现在许多其他场合,例如计数诗歌的韵律模式。

为了递归地实现该数列,你需要给出一个合适的定义,这并不难:你可以说 F[0] = 0,F[1] = 1,且对于 n > 1,F[n] = F[n][–1] + F[n][–2]。给定这个定义,下面是代码:

const fibo = (n) => {
❶ if (n === 0) {
   return 0;
❷} else if (n === 1) {
   return 1;
❸} else {
   return fibo(n - 1) + fibo(n - 2);
 }
};

你有两个基本情形:对于 0 ❶ 和 1 ❷,以及一个递归情形 ❸ 适用于其他值。代码非常简单,不可能出错,测试也验证了这一点。然而,它存在性能缺陷,我们将在本章稍后讨论动态规划时考虑这一问题。

排序与难题

本书后续将探索的许多排序方法,如归并排序或快速排序,都可以通过递归方式简洁地表达,但让我们先看另一个经典例子:汉诺塔问题。

这个谜题由法国数学家埃杜阿尔·卢卡斯(Édouard Lucas)在 19 世纪发明,包含三根柱子:第一根柱子上堆叠着一组大小递减的盘子(最大盘子在最底部,最小盘子在顶部),另外两根柱子为空。为了解决这个谜题,你需要将所有盘子从第一根柱子移动到最后一根柱子,遵循两个规则:每次只能移动最上面的盘子(不能一次移动两个或更多盘子,也不能从柱子中间的盘子开始移动),并且只有当目标柱子上的最上面盘子比你要移动的盘子大时,才能将盘子移到那个柱子(大盘子永远不能放在小盘子上面)。图 5-1 显示了初始设置;所有盘子都在最左边的柱子上,目标是将所有盘子移到最右边的柱子上。

图 5-1: 汉诺塔

如何使用分治策略来解决这个问题呢?你可以假设已经有了所需的函数,例如 towers(disks, origin, extra, destination),它将一定数量的盘子从起始柱子移动到目标柱子,并使用额外的柱子作为辅助柱子。你可以利用该函数来实现整个过程。基本情况很简单:如果没有盘子需要移动,什么都不做;否则,按照前面描述的步骤进行移动。代码可能如下所示:

const towers = (disks, origin, extra, destination) => {
❶ if (disks > 0) {
  ❷ towers(disks - 1, origin, destination, extra);
  ❸ console.log(`Move disk ${disks} from ${origin} to ${destination}`);
  ❹ towers(disks - 1, extra, origin, destination);
  }
};

首先,你测试基本情况 ❶,因为如果没有盘子需要移动,显然任务已经完成。否则,递归地将除了最底部的盘子以外的所有盘子移动到额外的柱子 ❷。清空大盘子后,将它移动到目标柱子 ❸,最后将其他盘子放到它上面 ❹。

类似 towers(4, "A", "B", "C") 这样的调用可以将四个盘子从 A 柱移动到 C 柱,产生以下输出:

Move disk 1 from A to B
Move disk 2 from A to C
Move disk 1 from B to C
Move disk 3 from A to B
Move disk 1 from C to A
Move disk 2 from C to B
Move disk 1 from A to B
Move disk 4 from A to C
Move disk 1 from B to C
Move disk 2 from B to A
Move disk 1 from C to A
Move disk 3 from B to C
Move disk 1 from A to B
Move disk 2 from A to C
Move disk 1 from B to C

使用递归来解决谜题的简化步骤是分治策略的一个明确示例。(如果你需要在没有计算机的情况下解决这个谜题,请参见问题 5.2。)

这个谜题还有一个尾声。在原版谜题中,僧侣们必须将 64 个金盘子从一根柱子移动到另一根柱子,世界将在他们完成任务后结束。(在原版谜题中,寺庙位于印度;谁知道它是如何传播到国外并来到河内的?)对于 n 个盘子,解这个谜题需要 M(n) = 2^n – 1 次移动,所以它是一个指数阶的算法;通过注意到 M(n) = 2M(n – 1) + 1 且 M(0) = 0 可以验证这个公式。如果每秒移动一次,这个任务将需要 2⁶⁴ – 1 秒,超过 584 亿年,所以我们是安全的!

回溯技术

回溯是一种通常最好通过递归方式实现的解决问题技巧。当面临多个选项时,选择一个并尝试通过它找到解决方案。如果成功了,你就完成了。如果失败了,回溯到做出选择的点,选择另一个选项。如果在某个时刻没有更多的选项了,那就肯定没有解决方案。

在迷宫中寻找路径

迷宫的出口(比如图 5-2 所示)是一个经典且古老的问题,你将在第十七章中再次遇到,尤其是在处理图时。这也是回溯的典型示例,所以我们在这里使用它。我们稍后会深入探讨完整的算法,这只是伪代码。

图 5-2:一个使用回溯解决的迷宫

每当你到达迷宫中的一个交叉口,那里有两个或更多的选项时,你必须选择一个,显然,你可能会选择错误的方向。方法是按照选择走下去:如果走出了迷宫,你就成功了;如果没有,你就回溯到上一个交叉口并选择另一个选项。如果没有剩下任何选项,你需要继续回溯,一次又一次,直到找到解决方案或决定没有解决方案。以下是这种递归算法的伪代码:

❶ solveMaze(fromCell, toCell, maze, path=[])
❷ if(fromCell === toCell) {
    return path // success!
  }
❸ mark fromCell as visited
❹ for all nextCell cells adjacent to fromCell {
  ❺ updatedPath = solveMaze(nextCell, toCell, maze, path + fromCell)
    if updatedPath is not null {
      return path
    }
  }
 // All adjacent cells were tried, and failed...
❻ return null   // failure
}

这个函数❶的参数包括路径的起点、终点、迷宫以及你将要走的路径。如果你到达了目标❷,你就成功了;否则❸,标记该单元格为已访问,这样以后就不会再选择它,并开始尝试所有可用的选项❹。如果路径再次出现❺,说明你已经成功。当所有选项都被排除❻时,你知道必须回溯,因为你失败了。图 5-3 显示了搜索过程中的一个中间位置。

图 5-3:解决迷宫时的一个中间步骤

在位置 1 时,算法有两个可选项;它选择了左边的那个,结果失败了,然后回溯到选择另一个选项。在位置 2 时,又做了一个选择;这次选择了右边的那个,左边的尚未(还没有)考虑,所以该路径中的单元格仍未标记。当前算法处于位置 3。如果从这里找不到出口,它将回溯到位置 2,尝试尚未选择的选项。是否能快速从位置 3 找到出口,取决于算法在每个交叉点选择正确选项的“运气”,但无论如何,算法最终肯定会通过递归回溯找到一条路径(如果有的话)。

解决沙滩上的方块游戏谜题

让我们将这一技巧应用于由美国拼图大师 Sam Loyd 开发的《海滩上的方块游戏》拼图,如图 5-4 所示。在这个拼图中,玩家需要将球投向玩偶,如果他们成功击倒的玩偶编号之和为 50,则获胜,奖品是一根雪茄。(参见问题 5.3,那里有一个类似的拼图,你也可以通过回溯算法解决。)

图 5-4:美国拼图大师 Sam Loyd 的《海滩上的方块游戏》拼图(公有领域)

你可以实现一个递归回溯算法,如下所示:

❶ const solve = (goal, standing, score = 0, dropped = []) => {
❷ if (score === goal) {
    return dropped;
❸} else if (score > goal || standing.length === 0) {
    return null;
  } else {
  ❹ const chosen = standing[0];
  ❺ const others = standing.slice(1);
  ❻ return (
      solve(goal, others, score + chosen, [...dropped, chosen]) ||
      solve(goal, others, score, dropped)
    );
  }
};
❼ console.log(solve(50, [15, 9, 30, 21, 19, 3, 12, 6, 25, 27]));

在函数 ❶ 中,goal 是你尝试达到的分数,standing 表示可用的选项,一个包含仍然站立的玩偶的数组。你目前获得的分数保存在 score 中,击倒的玩偶则放入 dropped 数组。如果你恰好达成目标,就完成了 ❷,并且 dropped 中保存了需要击倒的玩偶列表。如果你超出了目标,或者没有更多的玩偶可以击倒 ❸,则失败。否则,你选择一个玩偶 ❹(从代码实现的角度,选择第一个是最简单的),将其从未来的选择中移除 ❺,然后尝试解决拼图,包括最近选中的玩偶。如果失败了,你就回溯,并尝试不包括那个玩偶 ❻。要找到拼图的解 ❼,调用 solve() 函数,传入目标分数(50)和玩偶分数列表。

动态规划

动态规划(DP) 是一种通过首先解决其他(较小的)问题并存储这些结果,从而避免在需要时重新计算的技术。动态规划有两种类型:自顶向下的方式,先通过检查问题是否已经解决来判断是否处理子问题,再进行子问题的求解;和自底向上的方式,首先解决较小的子问题,然后逐步解决原问题。换句话说,在自顶向下的动态规划中,你试图直接解决原问题,然后递归地解决较小的问题;而在自底向上的动态规划中,你从最简单的问题开始,逐步解决更难的问题。

这个描述引出了一个问题:保存先前结果的最佳方式是什么?我们将讨论两种方法:备忘录,它基于函数式编程中的高阶函数,可能最适合自顶向下的动态规划;以及表格法,它基于数组或矩阵,通常最适合自底向上的动态规划。备忘录通常与递归实现相关,而表格法则更适用于直接的非递归解法。两者的权衡在于,表格法可能更快(因为不需要递归),但可能会解决一些并不真正需要的子问题,而备忘录则较慢(因为递归的原因),但只会计算实际需要的内容。

使用自顶向下动态规划计算斐波那契数列

让我们回到本章之前讨论的斐波那契数。以下是代码:

const fibo = (n) => {
  if (n === 0) {
    return 0;
  } else if (n === 1) {
    return 1;
  } else {
    return fibo(n - 1) + fibo(n - 2);
  }
};

这是一个分治法的例子,但如前所述,该实现有一个问题,我们将通过动态规划来解决。代码清晰、简单且正确,但可能会非常慢。当你尝试更大的 n 值时,计算第 n 个斐波那契数所需的时间呈指数增长,如图 5-5 所示。发生了什么呢?

图 5-5:递归计算斐波那契数所需的加法次数呈指数增长。

为了理解这个问题,考虑计算 fibo(7) 时涉及的所有计算。图 5-6 展示了所有需要的调用。

图 5-6:计算 fibo(7) 所需的调用

显然,很多调用是重复的。计算 fibo(7) 需要求和 fibo(6) 和 fibo(5),但前者是通过 fibo(5) 加上 fibo(4) 计算得到的,所以你重复计算了 fibo(5)。图表显示,计算其他斐波那契数时还会有更多的重复;fibo(3) 或 fibo(2) 被调用多少次呢?(另见问题 5.4。)这种实现是指数级增长的,那么该如何解决这个问题呢?

记忆化 是一种函数式编程技巧,可以应用于任何纯函数(即没有副作用、对于相同的参数始终返回相同结果的函数,见第二章)。其思想是,当一个记忆化的函数被调用时,它首先检查内部缓存,看看计算是否已经完成。如果已完成,它直接返回缓存中的值,而不是重新进行计算。如果请求的值尚未计算,记忆化函数会执行计算,但在返回结果之前,它会将结果存储到内部缓存中,以供以后使用。

高阶函数,如 fast-memoize(来自 www.npmjs.com/package/fast-memoize),是公开可用的,但自己动手写一个并不难:

❶ const memoize = (fn) => {
  ❷ const cache = {};
  ❸ return (...args) => {
      ❹ const strX = JSON.stringify(args);
      ❺ return strX in cache
        ❻ ? cache[strX]
        ❼ : (cache[strX] = fn(...args));
    };
};

这个高阶函数 ❶ 接收一个函数作为参数并返回一个新的函数。它使用闭包来维护先前调用和计算值的缓存;这里你使用的是一个简单的对象 ❷,但你也可以使用集合(有关其他可能的结构,见第十一章)。返回的函数 ❸ 首先将原始 fn 函数的参数 ❹ 生成一个字符串。如果该字符串已作为键存在于缓存中 ❺,则直接从缓存中返回先前计算的值 ❻;否则,调用原始函数,存储返回的值到缓存中,并返回该值 ❼。

使用 memoize(),你可以通过简单的修改,立即加速计算,方法是包装原始函数:

const fibo = **memoize(**(n) => {
  ...
}**);**

如果你现在尝试像 fibo(100) 这样的操作,结果会立即显示。要理解为什么,你需要 fibo(99) 和 fibo(98),但是在计算 fibo(99) 之后,fibo(98) 的值已经被计算过了,因此不会再次计算。0 到 100 之间的每一个可能的斐波那契数都会被计算,但每个数只会计算一次。通过应用动态规划技术——存储先前计算的值,算法已经变得线性,而不是指数级增长。

使用自顶向下的动态规划进行换行

让我们来看一个可以通过应用自顶向下的动态规划解决的实际问题:构建一个外观整洁的网页表单。假设你希望网页能够生成多个表单,每个表单包含不同的字段集。如果表单数量是固定的,并且字段集也是预先确定的,这并不会成为问题。然而,在这种情况下,表单的数量是不可预测的,字段需要添加、删除或移动,因此你需要一个更灵活的解决方案。你需要的是一个“表单创建器”,它接受一个按给定顺序排列的字段列表,并输出一个合适的表单。例如,要生成的表单的一部分可能如下图所示:图 5-7。

图 5-7:一个示例网页表单

问题在于你想要一个对齐的右边距,但字段的宽度不一致,因此你需要拆分行并拉伸某些字段,使得一切看起来均匀。你需要在决定拆分行的位置以及每行放哪些字段时小心谨慎。

注意

TeX 排版系统实现了 Knuth-Plass 算法,用来确定段落的换行位置,使其看起来更加美观。这里的问题本质上是一样的,但我们将使用动态规划(DP)来解决它。

考虑五个字段,宽度分别为 7、2、5、3 和 6(见图 5-8)。你需要将它们安排成宽度为 10 的行。

图 5-8:不同宽度的示例字段

你不能少于三行来管理,四行或更多行会导致太多的空间浪费(尽管我们稍后需要量化这一概念)。你不会像 TeX 在单词之间那样添加空白;相反,你将扩展字段本身。首先决定在每一行中留多少空白空间,然后再扩展块或分隔单词。你有三种可能的三行布局(见图 5-9、图 5-10 和图 5-11;灰色区域代表每行末尾额外添加的空白空间;你需要在同一行中的所有块之间共享这些空间)。

图 5-9:字段布局 1

图 5-10:字段布局 2

图 5-11:字段布局 3

哪种解决方案最好?假设在多行中添加较小的空白空间比在少数几行中添加较大的空白空间更好,考虑“行成本”为该行添加的空白空间的平方,总成本将是所有行成本的总和。(为了更好地理解为什么使用平方,假设你需要添加两个空格;如果把它们都放在同一行中,成本是 2² = 4,但如果将一个空格放在两行中,成本是 1² + 1² = 2,因此,在加总前平方成本实现了一个偏好较小空白的策略。)根据这个定义,布局的成本将是 1² + 2² + 4² = 21,3² + 0² + 4² = 25,和 3² + 3² + 1² = 19,因此第三个图表表示算法应该生成的设计。让我们编写代码。

考虑一组区块宽度(在这种情况下是 7、2、5、3 和 6)和要实现的最大宽度(MW)。以下逻辑适用:计算所有宽度的和s,如果s不大于 MW,成本为(MW - s)²。通过将列表拆分为两行或多行不能提高效果。否则,如果你有更多的字段无法在一行中放下,可以尝试以所有可能的方式将列表拆分成两个片段,然后选择产生最低成本的拆分。

以下逻辑实现了这个目标,但它省略了在一行中分配空白空间的代码,因为这部分内容只在后续需要。这个代码找到了最佳换行集合的成本及这些换行应该在哪里进行:

const costOfFragment = (p, q) => {
❶ const s = totalWidth(p, q);
  if (s <= MW) {
  ❷ return [(MW - s) ** 2, [q]];
  }

❸ let optimum = Infinity;
❹ let split = [];
❺ for (let r = p; r < q; r++) {
  ❻ const left = costOfFragment(p, r);
    const right = costOfFragment(r + 1, q);
  ❼ const newTry = left[0] + right[0];
  ❽ if (newTry < optimum) {
      optimum = newTry;
      split = [r, . . .right[1]];
    }
  }
❾ return [optimum, split];
};

该函数找出从 p 到 q(包括 q)的区块集合的最佳拆分,并返回要进行的拆分列表。假设我们有一个 totalWidth(x,y)函数,用来计算从 x 到 y 的区块宽度(稍后你将看到如何最佳实现它)。首先计算整个区块列表的宽度❶;如果它小于可用空间,则无需拆分,操作完成。按照定义计算成本并返回拆分发生在 q 位置之后❷。如果需要拆分,设置一个搜索;最优解将是最佳可能的成本❸,而拆分点则是拆分列表的位置❹。遍历所有可能的换行位置❺,并计算区块 p 到 r 和 r+1 到 q 的片段成本❻。每个拆分的成本被存储❼,如果它比之前的最优解好❽,则 r 作为新的拆分点。最终结果❾是找到的最佳成本及拆分点列表。

图 5-12 展示了这个算法如何处理你的区块列表。

图 5-12:算法评估的所有可能拆分

计算成本,图 5-13 展示了最优解。

图 5-13:最优解

成本显示在每个块的下方。如果一个块被拆分为多个块,它的成本是其部分成本的总和。高亮路径显示了如何达到最佳解决方案:在第一行单独留下 7,在第二行放置 2 和 5,在最后一行放置 3 和 6,总成本为 19。运行算法将产生以下结果:

❶ const blocks = [7, 2, 5, 3, 6];
const costOfFragment = ...
❷ const result = costOfFragment(0, blocks.length - 1);
❸ console.log(result[0], result[1]);
// 19 [0, 2, 4]

你可以定义块宽度的列表 ❶,并使用 costOfFragment(...) ❷产生结果:最佳总成本是 19,你在位置 0(仅 7)、2(2 和 5)和 4(3 和 6)处分割行,正如预期的那样 ❸。

你完成了,但如果仔细查看图 5-13,你会注意到与斐波那契计算相同的问题:某些块的成本被多次计算,例如(5, 3, 6)、(2, 5, 3)和(7, 2)。你可以应用记忆化来避免这个问题,得到所需的算法:

const costOfFragment = **memoize(**(p, q) => {
  ...
}**);**

优化后的算法如何处理这个例子?图 5-14 显示了实际上需要计算的内容非常少。

图 5-14:优化后的计算大大减少了工作量。

灰色块不需要重新计算;由于记忆化,你只需重用之前计算过的成本。在多个地方(用箭头标记)不需要递归。总体而言,算法运行得更快,但请参见问题 5.5 以获取进一步的优化。

使用自底向上的动态规划计算斐波那契数列

让我们考虑从下到上的动态规划。在自顶向下的方式下,你必须等到某些较小值的计算完成后才能进行计算。例如,在第 72 页“使用自顶向下动态规划计算斐波那契数列”一节中,你不能在计算 fibo(6)和 fibo(5)之前计算 fibo(7)。使用自底向上的方法,你从最小的情况开始,一步一步向上推进。要从下到上找到斐波那契数,你按照数列的定义进行计算,从 0 和 1 开始,始终将最后两个数字相加以生成下一个数字:

const fibo = (n) => {
❶ if (n < 2) {
    return n;
❷} else {
    let a = 0;
    let b = 1;
 ❸ while (n > 1) {
      [a, b] = [b, a + b];
      n--;
    }
 ❹ return b;
 }
};

这是简单的代码:对于 0 或 1 ❶,你不需要进行计算。对于其他值 ❷,设置一个循环,初始时 a = 0,b = 1(a 和 b 代表序列中的两个最新数字),然后循环足够多次 ❸,直到 b 变成你想要的数字 ❹。

你可能会注意到,所有之前计算的数字并没有保存,确实如此,但这是因为在这个特定的案例中,你不需要它们。该算法采用自底向上的方式,通过使用前面的数字来计算后续数字;恰好为了做到这一点,你总是只需要最新的两个数字,因此不需要存储其他所有数字。

使用自底向下动态规划递归求和区间

在换行算法中(参见第 74 页的“使用自上而下动态规划换行”),你需要一个 totalWidth(x, y)函数,用来将数组中从位置 x 到位置 y(包括 x 和 y)的宽度值相加。这个函数需要尽可能快,以免对算法的性能产生负面影响。最简单的版本(遍历数组并逐步累加)具有线性 O(n)性能,如果 n 是块的数量。然而,你可以通过一些替代实现来进行改进,这些实现不仅关注动态规划,还涉及本章中介绍的其他技术。

第一个算法,使用循环来获取和,比较简单。现在为函数增加另一个参数 arr,它是块的宽度,使其更通用并与调用者无关:

const totalWidth1 = (arr, from, to) => {
  let sum = 0;
  for (let i = from; i <= to; i++) {
    sum += arr[i];
  }
  return sum;
};

使用备忘录化优化它只需要做一个小的改动:

const totalWidth1 = **memoize(**(arr, from, to) => {
  ...
}**);**

如果(而且这是个大前提)你两次或多次使用相同的参数调用该函数,这个版本会更快。每次使用不同的参数调用它反而会使它变慢,因为需要额外的缓存工作。假设你已经计算了从 10 到 20 的区间和,现在你想要从 10 到 21 的区间和。你可以将第 21 个值加到 10 到 20 的区间和中,而不需要额外的工作。

这个概念是动态规划的关键:将问题的解决方案建立在之前较小问题的解决方案之上。要实现这一点,你需要将一个区间值的和定义为之前区间和的组合。如果你想计算数组 arr 中从位置 p 到 p 的单个元素区间和,结果就是 arr[p]。如果你想计算从位置 0 到位置 q(大于零)的值的和,首先计算从 0 到 q-1 的区间和,然后将 arr[q]加到该结果上。最后,要计算从位置 p(大于零)到位置 q(大于 p)的值的和,先计算从 0 到 q 的区间和,再减去从 0 到 p-1 的区间和。

你还可以使用备忘录化(memoization)来跟踪之前计算过的值;其逻辑如下:

const totalWidth2 = memoize((arr, from, to) => {
  if (from === to) {
    return arr[from];
  } else if (from === 0) {
    return totalWidth2(arr, 0, to - 1) + arr[to];
  } else {
    return totalWidth2(arr, 0, to) - totalWidth2(arr, 0, from - 1);
  }
});

这个函数的效果更好,工作量更少。例如,如果你请求从第 10 到第 20 的区间和,所有从 0 到 0、0 到 1、0 到 2,依此类推,直到 0 到 20 的所有和都需要被缓存。如果你接着请求第 10 到第 21 的区间和,它会尝试立即计算 0 到 21 的和(即 0 到 20 的和加上第 21 个元素),并减去已经可得的 0 到 9 的和。你依然有一个 O(n) 算法,但随着时间的推移,它变成了 O(1) 的过程;初始的延迟会被摊销。但你还可以做得更好。

通过预计算并使用自底向上的动态规划来求和区间

看到上一节中的totalWidth2(...)需要计算从 0 到所有可能其他位置的范围和时,你可以使用表格法来预计算所有这些值,然后所有查询将是O(1)的。你可以使用内部缓存(partial)来存储这些值:

const totalWidth3 = ((tab) => {
❶ const partial = [0];
  tab.forEach((v, i) => {
  ❷ partial[i + 1] = partial[i] + v;
  });
❸ return (from, to) => partial[to + 1] – partial[from];
❹})(arr);

这有点棘手,因为你使用了一个闭包来处理部分数组,该闭包在立即调用函数表达式(IIFE)中初始化。预计算将partial[k]设置为原始数组中前* k 个元素的和,这正确地意味着partial[0]应等于 0 ❶。(你浪费了一个额外的数组位置,但与最终获得的快速算法相比,这并不重要。)你还使用动态规划(DP)来计算这些部分和:partial[i+1]是基于先前计算的partial[i]来计算的 ❷。你想要的函数将通过获取到最右侧元素的和(partial[to+1])并减去不包括最左侧元素的和(partial[from])来计算两个元素之间的总和 ❸,这样就实现了所需的O*(1)算法。IIFE 的巧妙之处在于通过将原始宽度数组作为参数传递 ❹。(参见第 5.6 题,了解另一种实现该工作的方式。)

你已经看到两种不同的方式,通过自下而上的动态规划优化算法,最终实现O(1)性能。鉴于宽度计算常用于计算换行,这对你代码的性能和可用性是一次重大改变。

穷举搜索

穷举算法尝试通过系统地尝试所有可能的值组合来找到问题的解决方案。这种逻辑的主要问题在于组合爆炸,试验的案例数成倍增加。由此产生的算法的时间复杂度通常进入指数级或阶乘级(如第四章所讨论),这使得即使是对于适中的输入也可能无法使用。

我们将按类别查看每个问题,从最差到最糟。鉴于该类别中算法的排序,毫不奇怪,我们将在本书的其余部分避免使用这种代码。

检测重言式

在逻辑上,重言式是始终为真的布尔表达式。例如,如果 X、Y 和 Z 是布尔变量,以下两个 JavaScript 表达式是重言式:

  • X 或 Y 或(非 X 且 非 Y)

  • X 或(非 X 且 Y)=== X 或 Y

  • (非 X)或(X 且 Z)或(非 Y)或(Y 且 Z)或 Z

即使是对逻辑和表达式非常熟悉的读者,也可能不立即清楚哪些表达式始终为真。

判断一个有n个布尔参数的函数是否为恒等式,可能需要进行 2^n次测试,检查每种可能的真/假值组合,验证每种情况下该函数是否输出真。或者,你可以尝试找到某个使其为假的参数组合,一旦找到这样的情况,就知道该函数不是恒等式。这样的搜索需要类似于你在海滩上解决 Squarest Game 谜题时使用的逻辑。

使用递归非常方便:如果一个有n个变量的函数是恒等式,将第一个变量设置为假应该也是恒等式,如果第一个变量设置为真,同样也会是恒等式。为了检查原始函数是否为恒等式,你需要测试几个少一个参数的函数,这就导致了一个简单的实现:

❶ const isTautology = (fn, args = []) => {
❷ if (fn.length === args.length) {
  ❸ const result = !!fn(...args);
 ❹ if (!result) {
      console.log("Failed at", . . .args);
    }
    return result;
  } else {
  ❺ return (
      isTautology(fn, [...args, false]) && isTautology(fn, [...args, true])
    );
  }
};

isTautology()函数接收要测试的原始函数 fn 和一个参数列表❶。后者将是你用来测试函数是否为真的值的组合。如果你有正确数量的参数❷,你将评估该函数❸,如果它返回假值❹,你会记录这个事实并返回假值,这将中断所有未来和挂起的评估。如果函数返回真,搜索将继续。如果提供的参数不足❺,你将测试该函数两次:一次添加真值,一次添加假值到参数列表中,最终所有组合都会被测试。

以下是测试之前提到的三个布尔表达式:

const f = (x, y) => x || y || (!x && !y);
console.log(isTautology(f)); // true

const g = (x, y) => (x || (!x && y)) === (x || y);
console.log(isTautology(g)); // true

const h = (x, y, z) => !x || (x && z) || !y || (y && z) || z;
console.log(isTautology(h)); // false: Failed at true true false

前两个函数实际上是恒等式,但最后一个不是。搜索列出了至少一个失败的情况,其中该函数的计算结果为假。

解决密码算术谜题

密码算术谜题(也称为密码算式)是一类数学谜题,其中数字被字母替换。解答者的目标是找出每个字母代表的数字。通常,数字不能以零开头,所有字母的值必须不同,并且方程式应该翻译成一个有意义的短语。图 5-15 展示了一个早期的例子,这个谜题由英国作家、谜题专家和数学家亨利·厄尼斯特·杜德尼(Henry Ernest Dudeney)于 1924 年发明。

图 5-15:经典密码算术谜题

你可以通过仔细分析来解决这种谜题(请参见问题 5.7 中的另一个例子),但在这里你将编写一个求解器,遍历所有可能的数字组合,检查是否有可行的解。在这个例子中,考虑到有 10 个数字,你需要检查 10!(3,628,800)种组合,但有些谜题使用的是不同的数值基数,因此通常来说,这是一个 O(n!) 算法。一个类似的例子(就解决方法而言)是旅行商问题,它提供了 n 个城市和每对城市之间的距离;你需要找到一个最短的路线,访问每个城市仅一次,并最终返回到起始城市。这个问题的解决方法同样是 O(n!),其算法与接下来你将看到的类似。(你还将看到一种使用贪心算法来解决该问题的不同类型的解决方案,稍后会在本章中讨论。)

我们需要什么算法?这个想法很简单:尝试从 0123456789 到 9876543210 的所有数字组合,并检查每一个是否能解开谜题。(在这个例子中,你只会使用前八个数字,但这并不会改变任何事情。)你可以设计如下的主要逻辑,假设 puzzle() 是一个用于测试组合是否合法的函数:

❶ const solve = (puzzle, digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) => {
❷ const d = [...digits].sort();

❸ for (;;) {
  ❹ if (puzzle(...d)) {
      console.log("SOLUTION: ", . . .d);
      return true;
    }
  ❺ // Try generating the next combination of d.
    // If there are no more combinations left, return false.
  }
};

digits 参数 ❶ 将包含你用于此问题的数字集;尽管在这种情况下,可能的值是 0 到 9,但你也可以编写适用于其他进制的密码算术谜题的代码。制作一个 digits 集合的本地副本 ❷,以避免修改原始参数并避免副作用(如 第二章 中讨论的那样),并将其排序,以便按升序遍历所有组合。然后设置一个循环 ❸,当你找到解或者决定没有解时退出。如果当前数字组合有效 ❹,记录结果并退出;否则,生成下一个数字组合 ❺ 并继续循环,直到到达最后一个组合,这时你就知道这个问题没有解。

生成给定集合的下一个排列是一个众所周知的算法,可能是由印度数学家纳拉扬·潘迪塔(Narayana Pandita)在 14 世纪发现的。假设当前的排列存储在数组 d 中,它需要四个步骤,按顺序执行:

  1. 找到最右侧的索引 p,使得 d[p] < d[p + 1];如果没有这样的 p,说明你已经到了最后一个排列,算法结束。

  2. 找到最右侧的索引 q,使得 d[p] < d[q];d[q] 是 d[p] 右侧最小的比 d[p] 大的值。

  3. 交换 d[p] 和 d[q] 的值;现在从 d[p + 1] 到 d 末尾的值将按降序排列。

  4. 将 d[p + 1] 到 d 末尾的值反转。

图 5-16 显示了一个有效的例子,从排列 8403976521 开始。

图 5-16:生成下一个排列

步骤 1 将 p 指向 3,因为 3 < 9;右侧所有其他元素(976521)是降序排列的。步骤 2 将 q 指向 5,这是 3 右侧大于它的最小值。步骤 3 交换 p 和 q 所指向的值;p 右侧的值再次按降序排列(976321)。步骤 4 通过反转 p 右侧的值来结束,使其变为升序排列(123679),你得到了下一个排列:8405123679。

使用这个逻辑,你可以通过添加生成排列的代码,看到完整版本的加密算术谜题求解器:

const solve = (puzzle, digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) => {
  const d = [...digits].sort();

  for (;;) {
    if (puzzle(...d)) {
      console.log("SOLUTION: ", . . .d);
      return true;
    }

 **let p = d.length - 2;**
    while (p >= 0 && d[p] > d[p + 1]) {
      p--;
    }

    if (p === -1) {
      console.log("No solution found");
      return false;
    }

    **let q = d.length - 1;**
    while (d[p] > d[q]) {
      q--;
    }

    **[d[p], d[q]] = [d[q], d[p]];**

    **let l = p + 1;**
    let r = d.length - 1;
    while (l < r) {
      [d[l], d[r]] = [d[r], d[l]];
      l++;
      r--;
    }
  }
};

这段代码与之前相同,突出显示了排列算法的步骤 1 到步骤 4。

现在你可以编写一个函数来测试给定的值组合是否实际上是一个解:

❶ const sendMoreMoney = (s, e, n, d, m, o, r, y) => {
❷ if (s === 0 || m === 0) {
    return false;
❸} else {
    const SEND = Number(`${s}${e}${n}${d}`);
    const MORE = Number(`${m}${o}${r}${e}`);
    const MONEY = Number(`${m}${o}${n}${e}${y}`);
    return SEND + MORE === MONEY;
  }
};

❹ solve(sendMoreMoney);
// SOLUTION:  9 5 6 7 1 0 8 2 3 4
// 9567 + 1085=10652

函数被调用时,传入了所有 10 个数字❶,但你只使用前 8 个,忽略最后 2 个。如果首位数字为 0 ❷,则解无效,因此直接拒绝。如果没有前导零 ❸,则计算三个单词(SEND、MORE 和 MONEY)的值,并检查它们是否满足原始方程。给定这个函数,你只需要将其传递给 solve()函数 ❹,然后稍等片刻(很短时间)就能得到解。

贪心算法

最后,让我们总结一组具有相当奇特特点的算法:它们可能并不总是有效。算法的基本定义意味着它是一个明确的程序,用于解决问题或完成任务。贪心算法可能(也可能不会)做到这一点。

有时会使用启发式方法来描述通过应用一些任意选择而不是进行彻底搜索,从而更快速地获得一个(希望不是太糟糕的)解决方案。例如,一个国际象棋算法原则上可以通过考虑所有可能的走法、所有可能的对手反应以及所有可能的反应来找到最佳走法,但这种方法的复杂度呈指数增长,因此不可行。另一种选择是启发式方法。以国际象棋为例,避免达到最大深度,而是在几步之后停止搜索,对结果的棋盘位置进行粗略评估,并选择最佳评估的走法。这个方法并不能保证做出最佳走法,但至少能提供某种解决方案。

贪心算法通常应用于优化问题。你已经见过使用暴力算法尝试所有可能性的算法;贪心算法则不是这样。每当需要做出决策时,这些算法会在当时做出最佳选择。一方面,这种方法确保算法快速执行,不需要回溯。另一方面,算法不一定做出最佳选择,因为它并没有足够地考虑未来。然而,在某些条件下,正如你在接下来的章节中将会探索的,这些算法表现良好并且成功。

如何找零

如何用最少的钞票和硬币找零?换句话说,假设你必须用今天的美国货币支付某个金额:$100、$50、$20、$10、$5 和$1 钞票,以及$0.25(四分之一美元)、$0.10(十分之一美元)、$0.05(镍币)和$0.01(分币)硬币。如何支付$229.60?你可以使用许多组合来达到这个金额,但使用贪心算法时,你会遵循这样一个简单规则:每一步选择尽可能多的最大面额单位,直到完成为止。

该方法从使用两张$100 钞票开始,然后是$20 钞票、一张$5 钞票、四张$1 钞票、两枚四分之一美元硬币和一枚十分之一美元硬币。没有其他解决方案涉及更少的钞票和硬币。这个贪心算法保证能够成功,但它依赖于可用的面额。在一个只有$9、$8 和$1 钞票的国家,支付$16(贪心方式)将会得到一张$9 钞票和七张$1 钞票,而不是使用仅仅两张$8 钞票。贪心算法的成功与否取决于具体情况。

旅行商问题

让我们考虑一个通常需要暴力搜索但贪心算法通常能很好解决的问题。旅行商问题是这样的:想象一个销售员必须进行一次旅行,访问列表上的每个城市一次,然后返回起点。(在图论中,这称为哈密顿回路。)城市间的旅行距离(或费用)是已知的。如何以最短(或最便宜)的方式完成任务?

按照目前的方式,解决这个问题的算法需要测试所有可能的城市排列(就像你之前为 SEND + MORE = MONEY 谜题所做的那样)。如果城市数量增多,问题将变得无法处理,因为运行算法所需的时间会过长。

这个问题的贪心算法(虽然可能找不到最佳解,但执行迅速)将按如下方式进行:每一步访问最近的未访问城市。此方法不一定能找到最佳路径,几种启发式方法可能会发现一个更好的路径,但在某些条件下,算法能找到最优解。

最小生成树

让我们通过考虑一个问题来结束我们对贪心算法的讨论,这个问题你将在第十七章中探讨。想象一下,某个有线电视公司必须为几户家庭提供服务。公司不能随便铺设电缆,必须遵循现有的道路。为了最小化总成本,它应将电缆铺设在哪里?

该问题的解决方案在技术上称为最小生成树,而克鲁斯卡尔算法(你将在第十七章中实现它)是一个贪心算法,能够解决这个问题,并且保证找到最优解。从选择最便宜的路段开始,直到所有房屋都连接起来,并且始终添加最便宜的不会产生回路的路段;毕竟,拥有一个封闭的电缆回路又有什么用呢?

你可以使用贪心算法解决其他与图相关的问题,因此,当尝试为特定问题编写代码时,这种技术可能也是一个有效的选择。

总结

在本章中,我们考虑了几种技术(递归、动态规划、暴力法和贪心算法),这些技术将帮助你独立开发算法,它们将在本书的其余部分再次出现。

在下一章,我们将探讨几个常见问题,如排序、选择、洗牌、采样和搜索——这有很多头韵,但也有很多有趣的代码,并且有充足的机会让你学习如何编写算法。

问题

5.1  一阶阶乘

factorial() 的代码完全正确,但它有七行!虽然这并不重要(一个长的正确函数比一个短的错误函数要好),但你能把它写得更紧凑一些吗?

5.2  手动汉诺塔

汉诺塔的递归算法对计算机来说是不错的,但对普通人类来说却不太适用。你能设计一种简单的非递归算法来解决这个难题吗?

5.3  射箭回溯

Sam Loyd 设计了另一个类似于你在本章早些时候解决的《沙滩上的方块游戏》的难题(见 图 5-17)。在这个难题中,你需要通过将箭矢射向目标来获得 100 分。重要的区别在于,在这个难题中,你可以多次击中一个靶环,而在另一个问题中你只能投掷一个娃娃一次。

图 5-17:Sam Loyd 的另一个经典难题,玩家必须用箭矢精确击中 100 分(公共领域)

你能修改回溯算法来处理这个变体吗?即使存在差异,你还能使用之前解决其他难题时用过的 solve() 函数来找到解吗?

5.4  计数调用

如果你调用 C(n),也就是用递归实现计算第 n 个斐波那契数所需的调用总次数,例如你会看到 C(7) = 41。你能为 C(n) 给出一个递推公式并找到它的显式解吗?提示:答案将再次涉及斐波那契数。

5.5  避免过多工作

当考虑如何在行中排列块(在“使用自顶向下的动态规划进行行断裂”中见 第 74 页)以及考虑拆分时,你分析了它们作为(7, 2, 5)和(3, 6)或(7, 2, 5, 3)和(6)。然而,这其实并不必要,因为块 7 + 2 + 5 或 7 + 2 + 5 + 3 无法适应一行。图 5-18 显示了一个增强算法不会考虑的被划去的选项。

图 5-18:一种更高效的找到行断点的方法

你能将这个优化添加到代码中吗?

5.6  简化以提高清晰度

totalWidth3(...) 函数(在“通过自底向上的动态规划预计算求和范围”这一节中,参见第 81 页)使用了一个常见的循环来生成部分数组。你能用.reduce(...)来替代这个方法吗?

5.7  得了痛风吗?

图 5-19 展示了另一个著名的密码算术谜题;找到每个字母代表的含义,然后 GOUT 的值就是你的答案。你可以使用本章前面介绍的技巧来解决这个问题,或者也可以尝试直接解答。

图 5-19:一个简单的密码算术谜题,只有四个字母需要找到

第六章:6 排序

在前几章中,我们讨论了与编程和算法设计相关的概念。现在,我们将开始考虑这些概念的实际应用。我们要探讨的问题是如何将一组记录排序,每个记录由一个键(字母顺序、数字或多个字段)和数据组成。

算法的输出应包括完全相同的一组记录,但经过重新排列,以使键按顺序排列。通常,你希望键按升序排列,但降序排列只需要对排序算法进行一个小改动——即反转比较操作——所以在这里不会展示。(请参见本章末的第 6.1 题。)

我们首先会考虑排序问题的总体情况,然后继续研究几种基于键比较的算法(最常见的算法),接着会看一些基于其他原理的算法。我们将考虑所有算法的性能,甚至会加入一些幽默的算法进行比较。

排序问题

排序算法本质上是一种算法,它接受包含键和值的数据记录列表,然后重新排列列表,使得键按非递减顺序排列(没有任何键小于其前一个键),输出列表是输入列表的一个排列,保留所有原始记录。忘记第二个条件很容易,但忽视它将意味着以下情况也可以被视为有效的排序函数:

const wrongWayToSort = (inputData) => [];

排序本身很重要,但它也影响其他算法的效率。例如,在第九章中,我们将看到如何利用排序数据来实现更高效的搜索操作。

对于我们的示例,我们通常假设使用可以直接通过 < 和 > 运算符进行比较的单字段键。对于更通用的情况,你可以修改算法,使用 compare(a,b) 比较函数,正如 JavaScript 的排序算法所做的那样(请参见第 95 页的“JavaScript 自带的排序方法”部分)。在本书中的代码示例中,你总是会写测试如 a>b,因此修改代码以支持通用排序只需要将该比较改为 compare(a,b)>0。(参见第 6.2 题的变体。)在第十四章中,你将通过应用 goesHigher(a,b) 函数来决定在堆中哪个元素应该更大。

内部排序与外部排序

排序数据时,一个重要的考虑因素是数据是否能全部同时存储在内存中,还是数据过大必须存储在外部存储设备中。第一种情况称为内部排序,第二种情况称为外部排序。本章中的所有算法都属于第一类,但如果你需要排序的数据超过内存容量怎么办呢?

外部排序将所有输入数据分解成尽可能大的块,以适应内存,然后使用内部排序对这些块进行排序,保存到外部存储,并将已排序的块合并成最终的输出。也就是说,对于像这样的庞大排序任务,你可能更适合使用标准的系统排序工具,它可能还会优化以使用并行线程、多个中央处理单元(CPU)等。无论如何,如果你决定自己实现外部排序程序,本节中的算法涵盖了所需的内部排序,使用堆(如第十四章所示)将有助于编写高效的合并代码。

自适应排序

如果排序算法能够利用输入数据中已经存在的任何顺序,那么它被称为 自适应算法。壳排序就是一个例子,你将在第 103 页的“通过组合排序和壳排序实现更大跳跃”部分学习到它:当数据部分排序时,算法的性能会更好。另一方面,快速排序,你将在第 105 页的“通过快速排序追求速度”部分学习到它,可以被视为反自适应算法。当数据已经有序时,它的最差表现会出现(尽管也有解决方法)。

原地排序与非原地排序

排序算法的另一个考虑因素是它们是否需要额外的数据结构(因此需要额外的空间)。通常,这一要求被放宽,允许使用常量的、少于 O(n) 的额外内存——关键规则是是否需要与输入大小成比例的额外空间。我们不会考虑存储要排序的 n 个元素所需的 O(n) 空间。那些不需要额外空间的算法称为 原地算法,而那些需要更多内存的则被称为 非原地算法不原地算法。这并不意味着非原地算法会返回一个新的列表;它们完全可以在原地重新排列输入列表,但它们需要额外的 O(n) 或更多的空间来完成此操作。

仔细考虑算法使用的内存量:一些递归算法,如快速排序,内部需要使用一个栈,其空间复杂度为 O(log n),但这也被认为是原地排序。归并排序通常需要额外的空间来合并序列,因此其空间复杂度为 O(n),因此属于非原地排序类别。

在线排序与离线排序

在考虑算法时,另一个需要区分的是它们是否能够像串行流一样处理输入数据,还是所有数据必须从一开始就可用。第一类算法称为 在线算法,第二类则称为 离线算法。这个区别不仅适用于排序问题,也适用于其他问题;例如,在第八章中讨论采样时你将再次看到它。

在排序方面,在线算法会始终保持一个已排序的列表,并将新元素按顺序加入其中,而离线算法则必须等到所有元素都可用后再开始排序。尽管如此,离线算法通常具有更好的性能。在线算法不知道所有输入,因此它们必须做出可能在后续阶段被证明是次优的决策,这与贪心算法的情形相同(参见第五章)。

作为这种区别的一个例子,考虑如何排序一副扑克牌。如果你将已经排序的牌保存在手中,然后每次得到新牌时,将它插入到之前牌的合适位置,你实际上是在实现一个在线算法——实际上,这是一个插入排序,我们将在“扑克牌排序策略”这一节中(第 100 页)学习它。如果你等到所有牌都到齐后,再通过某种方式进行排序,那就是离线排序。

排序稳定性

排序可能具有相等关键字的数据提出了一个问题:具有相等关键字的元素最终会以何种相对顺序排列?稳定排序算法保持与输入相同的顺序,因此,如果一个元素排在另一个元素前面且两者具有相同的关键字,在排序后的输出中,第一个元素会排在第二个元素前面。

为什么稳定性很重要?假设你想在一个 HTML 页面中显示联系人,并且希望遵循这样一个规则:加星标的联系人(收藏)应该排在前面,按字母顺序排列,接着是没有星标的联系人,同样按字母顺序排列。

为了实现所需的排序,你可以先按名字排序整个列表,然后重新排序,使加星标的联系人排在前面。图 6-1 展示了这种方法。

图 6-1:通过稳定排序算法按两个字段排序

第一次排序按字母顺序重排列表,第二次排序将加星标的名字排在没有星标的名字之前。如果第二次排序是稳定的,那么这种排序不会影响之前的字母顺序排序。如果是一个不稳定的排序,可能就不成立了。稳定性是朱丽叶在最终列表中排在罗密欧之前的原因。朱丽叶在按名字排序时排在罗密欧之前,而在按星标排序时,它们保持了相同的相对顺序。

你可以修改任何排序算法,强制其变为稳定的。不论排序的关键是什么,考虑使用由原始关键字后接列表中项的位置形成的新扩展关键字。通过新的扩展关键字排序这个数组时,共享相同(原始)关键字值的项将会被一起排序,但由于添加了位置,它们将保持原有的相对顺序,如图 6-2 所示。

图 6-2:通过使用扩展关键字使排序变得稳定

第一步通过添加项目的位置信息作为额外的键值;第二步按姓名和位置排序。具有相同原始键值(例如示例中的 Alpha 和 Echo)的元素将保持它们之间的相对位置。最后,你可以删除添加的字段。

JavaScript 自带的排序方法

在 JavaScript 中排序时,不要忘记语言已经提供了一个 .sort(...) 方法,尽管本章稍后会考虑更多(可能更好的)排序算法,但在许多情况下,使用 JavaScript 自带的排序方法可能是最有效的。我们来快速回顾一下这个排序方法是如何工作的(更多信息请参见 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)。

给定一个数组,.sort(comparisonFunction) 方法使用一个可选的比较函数就地重新排序数组。(较新的 .toSorted() 方法不会就地排序,而是生成一个新的排序后的数组版本。)如果省略该函数,JavaScript 会将元素转换为字符串,然后按字典顺序排序,这可能不是你想要的:

const a = [22, 9, 60, 12, 4, 56];
a.sort();
console.log(a); // 12 22 4 56 60 9

为了支持其他排序方式,你需要提供一个函数,该函数接收两个元素 a 和 b,并返回一个负值表示 a 应该排在 b 前面,返回一个正值表示 a 应该排在 b 后面,返回零表示两个键值相等且 a 和 b 可以任意排序。你可以很容易地修正前面的例子:

const a = [22, 9, 60, 12, 4, 56];
a.sort(**(a, b) => a - b**);
console.log(a); // 4 9 12 22 56 60

你还可以实现更复杂的比较;下面的例子展示了如何按日期和姓名对对象进行排序:

❶ const people = [
  {d: 22, m: 9, y: 60, n: "alpha"},
  {d: 12, m: 4, y: 56, n: "bravo"},
  {d: 22, m: 3, y: 56, n: "hotel"},
  {d: 9,  m: 1, y: 60, n: "foxtrot"},
  {d: 22, m: 4, y: 56, n: "echo"},
  {d: 22, m: 3, y: 56, n: "delta"},
  {d: 22, m: 3, y: 56, n: "india"},
  {d: 14, m: 1, y: 34, n: "charlie"},
  {d: 9,  m:12, y: 40, n: "golf"}
];

const dateNameCompare = (a, b) => {
❷ if (a.y !== b.y) {
    return a.y - b.y;
❸} else if (a.m !== b.m) {
    return a.m - b.m;
❹} else if (a.d !== b.d) {
    return a.d - b.d;
❺} else if (a.n < b.n) {
 return -1;
  } else if (a.n > b.n) {
    return 1;
  } else {
 ❻ return 0;
  }
};

需要排序的数据❶包含日期作为三个独立的字段(d, m 和 y,分别表示日、月和年)以及姓名(n)。如果两个人来自不同的年份❷,你通过相减年份来返回正确的负值或正值。如果年份相同,你可以使用相同的逻辑来比较月份❸,如果月份也相同❹,你再用同样的方法比较日期。如果日期相同,你就比较姓名❺,由于不能直接使用数学运算来比较日期,你需要逐个字段进行实际比较。只有在所有字段都比较过且相等时,最终才返回 0❻。

如果你使用刚才写的 dateNameCompare(...) 函数对人员数组进行排序,你将得到预期的结果:

console.log(people.sort(dateNameCompare));

[
  {d: 14, m: 1, y: 34, n: 'charlie'},
  {d: 9,  m:12, y: 40, n: 'golf'},
  {d: 22, m: 3, y: 56, n: 'delta'},
  {d: 22, m: 3, y: 56, n: 'hotel'},
  {d: 22, m: 3, y: 56, n: 'india'},
  {d: 12, m: 4, y: 56, n: 'bravo'},
  {d: 22, m: 4, y: 56, n: 'echo'},
  {d:  9, m: 1, y: 60, n: 'foxtrot'},
  {d: 22, m: 9, y: 60, n: 'alpha'}
]

最后,考虑稳定性。最初,.sort(...) 方法的规范并不要求稳定性,但 ECMAScript 2019 添加了这一要求。然而,需要注意的是,如果使用的是较早的 JavaScript 引擎,你不能假定排序是稳定的,因此你可能需要求助于在 第 93 页 中描述的“排序稳定性”解决方案。另外,记住任何给定的引擎可能根本没有正确实现该标准。

排序性能

如果你必须对n个值进行排序,你的逻辑必须能够处理这些值的所有可能的n!排列。那需要多少次比较呢?想想 20 个问题游戏。在这个游戏中,你必须通过最多 20 个“是”或“否”的问题来猜测一个选定的物体。如果你仔细规划你的问题,你应该能够从超过一百万(2²⁰ = 1,048,576,实际上)个可能的选项中挑选出任何一个元素。你可以将这个逻辑应用于排序n个元素。

如果你正在比较元素来排序一个数组,那么可以间接地推断出你在决定原始排列是什么。精心设置的问题将选项范围分成两半,所以你需要知道在n!可能性下需要多少个问题。这相当于在问你应该将n!除以 2 多少次,直到得到 1。答案是 log n!,以 2 为底。(或者,你可以将其看作是在问什么值的k使得 2^k > n!)本节将不讨论它的推导,但斯特林近似法表示n!以n**^n的速度增长,所以n!的对数是O(n log n)。

这自动意味着,任何基于比较元素的算法至少都是O(n log n)。没有更好的结果可以实现,但显然可能有更差的结果。考虑到这一点,在下一节中,我们将考虑几种算法,从最差到最好的表现。

但是请注意,关于这些算法“基于比较元素”的观察。如果你设法在没有进行实际比较的情况下排序一个列表,那么一切都不再确定。你会发现一些方法允许在O(n)时间内排序,而不需要将键相互比较。

基于比较的排序

如前所述,我们将考虑主要的排序算法,所有这些算法都依赖于相互比较值。我们将考虑的第一个算法是O(n²),因此它们不是最优的,但我们将继续研究更好的算法,直到我们达到几种实现最佳O(n log n)表现的算法。

在所有情况下,你都会编写接收值数组的函数(如前所述,你无需担心键+数据对,因为这可以很容易地适应),并且你还会传递参数来指定数组的哪个部分(从, 到)应该被排序。像往常一样,你会想要排序整个数组。那些参数将具有默认值,因此如果没有提供它们,整个数组将被排序。

上升和下降

我们将从回顾排序算法开始,首先介绍冒泡排序,它可能是最有吸引力的名字,可能是为了弥补它的较差表现。这个算法很容易实现,但你只会在数据集较小的情况下使用它。它还产生了几个变体(你将在下一节查看组合排序,它实际上引出了一个表现更好的算法)。

冒泡排序

冒泡排序算法的名称来源于一个简单的概念,即较大的数字像气泡一样浮到列表的顶部。它从数组的开始位置开始,按顺序遍历数组中的所有元素,如果一个元素大于下一个元素,它就交换这两个元素(见图 6-3)。

图 6-3:在冒泡排序中,每次遍历都会将一个元素移到正确的位置。

在图 6-3 的第一次遍历中,从左到右比较相邻的值,并在需要时交换它们,使得较大的值总是出现在右边。第一次遍历后,60 被移到数组的顶部。接着以相同的方式处理数组的其余部分,第二次遍历后,56 被移到倒数第二的位置,因此至少有两个元素已经到位。第三次遍历后,三个元素到位,以此类推。最后两行没有发生交换,因为之前的遍历已经将元素移到正确的位置,这种情况经常发生。

以下是该算法的逻辑:

❶ const bubbleSort = (arr, from = 0, to = arr.length - 1) => {
❷ for (let j = to; j > from; j--) {
  ❸ for (let i = from; i < j; i++) {
    ❹ if (arr[i] > arr[i + 1]) {
        [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
      }
    }
  }
  return arr;
};

所有排序函数都具有相同的签名:一个待排序的数组(arr)和排序的限制(from,to),默认情况下是数组的极限❶。外层循环❷从右到左进行;每次遍历后,数组中位置 j 的元素将处于正确的位置。内层循环❸从左端开始遍历到外层循环 j 的位置(不包括 j);你将每个元素与下一个元素进行比较❹,如果第二个元素较小,就交换它们。

你可以通过检查每次遍历数组时是否发生了交换,来提高大多数已排序数组的性能(这种情况并不罕见)。如果没有检测到交换,说明数组已经是有序的(见问题 6.7)。

该算法的性能是O(n²),计算起来很简单。首先计算比较次数:第一次遍历做了(n – 1)次比较,第二次遍历做了(n – 2)次比较,第三次做(n – 3)次,以此类推。所有比较次数的总和就是从(n – 1)到 1 的所有数之和,即n(n – 1) / 2,所以是O(n²)。 ##### 下沉排序与穿梭排序

冒泡排序迅速将最大的值移到数组的末尾,但最小的值可能需要一些时间才能到达最终位置。类似地,下沉排序(见问题 6.6)将最小的值快速下沉到数组的开头,但相应地,最大值到达其位置的时间会更长。你可以交替进行冒泡遍历和下沉遍历,得到一个增强的算法,称为穿梭排序(也叫鸡尾酒摇晃排序双向冒泡排序)。与冒泡排序相比,穿梭排序的前几次遍历如图 6-4 所示。

图 6-4:穿梭排序交替进行从左到右和从右到左的遍历。

从相同的元素开始,第一次遍历与冒泡排序相同,将数组中最大的值 60 移动到最右端。第二次遍历从右到左,将数组中最小的值 04 移动到最左端。第三次遍历再次从左到右,将 56 移动到其正确位置;之后,继续交替执行左到右和右到左的遍历,直到排序完成。

以下是相应的代码:

❶ const shuttleSort = (arr, from = 0, to = arr.length - 1) => {
❷ let f = from;
  let t = to;

❸ while (f < t) {
  ❹ for (let i = f; i <= t - 1; i++) {
      if (arr[i] > arr[i + 1]) {
        [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
      }
    }
  ❺ t--;

  ❻ for (let i = t - 1; i >= f; i--) {
      if (arr[i] > arr[i + 1]) {
        [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
      }
    }
 ❼ f++;
  }

  return arr;
};

如前所述,这个排序函数的签名始终相同:一个要排序的数组和需要排序的部分 ❶。你有两个变量 ❷,标记数组已经排序到左边和右边的程度:f(来自from)从左边开始,每次从右到左遍历后增加 1,而 t(来自to)从右边开始,每次从左到右遍历后减少 1。当这两个变量相遇 ❸ 时,排序完成。你首先执行如前所示的从左到右遍历 ❹,然后减少 t ❺,因为你已经将一个新值放到了正确的位置。之后,你执行同样的操作 ❻,但顺序是从右到左,然后增加 f ❼ 来完成。

该算法的时间复杂度仍然是 O(n²),但实际实现通常速度是原来的两倍,甚至更快,如果你包括交换测试(参见问题 6.7)。无论如何,很容易证明它不能做得更差,因为在每次遍历中,它都会将一个数字放到最终位置,所以在将 (n – 1) 个数字放到它们的位置之后,排序就完成了,这与冒泡排序相同。

然而,尽管有个引人注目的名字,这种排序算法与我们在本章后面将要探讨的算法相比,还是不够优秀。

扑克牌排序策略

思考如何完成简单的任务可以为开发算法提供一些提示。例如,假设你手里有几张扑克牌,想要按从低到高的顺序排列它们。你可以应用几种不同的策略,我们接下来将讨论:选择排序或插入排序。

选择排序

一个简单的解决方案是寻找最小的牌并将其放置在手中最左边的位置。然后寻找下一个最小的牌并将其放在第一张之后,继续这样做,总是选择剩下的最小牌并将其放置在已经排序的牌旁边。这个过程就是 选择排序 算法的基础,且它添加了一个小细节:当将一张牌放到左边时,你需要与另一张牌交换位置(参见 图 6-5)。

图 6-5:选择排序通过查找最小元素并交换其位置来完成排序。

在最上面的第一次遍历中,你找到最小的数字是 04,并交换将其移到数组的第一个位置。第二次遍历找到 09,并与 12 交换,现在你已经有了两个有序的数字。之后的过程保持相同,唯一的例外是在倒数第二行,在这一步中不需要交换,因为 56 已经在正确的位置。

这里是一个实现:

const selectionSort = (arr, from = 0, to = arr.length - 1) => {
❶ for (let i = from; i < to; i++) {
  ❷ let m = i;
  ❸ for (let j = i + 1; j <= to; j++) {
    ❹ if (arr[m] > arr[j]) {
        m = j;
      }
    }
  ❺ if (m !== i) {
      [arr[i], arr[m]] = [arr[m], arr[i]];
    }
  }

  return arr;
};

按照顺序❶从数组的第一个位置到最后一个位置。变量 m❷跟踪已经找到的最小值的位置。当你循环处理未排序的数字❸时,如果找到新的最小值候选者❹,就更新 m。完成这个循环后,如果最小值还没有排好位置❺,就进行交换。

这个算法的时间复杂度仍然是O(n²)。你需要查看n个元素来找出应该放在第一位的数;然后看n–1 个元素找出第二位的数,依此类推。你已经知道这个总和是O(n²)。下一节中的算法也是基于如何排序扑克牌,但它的性能稍好。

插入排序

在选择排序中,我们想到了排序扑克牌,但你也可以考虑其他方法。拿起第一张牌;显然它已经是排序好的了。现在看第二张牌,要么把它放在第一张牌之前(如果它较小),要么保持它原位(如果它较大)。现在你已经有了两张排序好的牌。看第三张牌,决定它应该放在前两张牌中的哪个位置,然后将它放到那里。当你处理完手中的所有牌后,你会发现它们已经排好序了,这就是所谓的插入排序,因为你是通过将新牌插入到已经排序好的牌中来排序的(见图 6-6)。

图 6-6:插入排序的工作原理就像排序扑克牌一样。

从一张已排序的牌开始,这里是数字 34。然后考虑下一个数值 12,把它放在 34 的左边,这样两张牌就排好了。接下来考虑 22,它放在 12 和 34 之间,现在三个数值已经排序好。继续这样操作,总是把下一个数放在已经排序好的牌中,直到最后一张牌。将 14 放入已排序的牌中后,整个数组就变得有序了。

以下代码实现了这个方法:

const insertionSort = (arr, from = 0, to = arr.length - 1) => {
❶ for (let i = from + 1; i <= to; i++) {
  ❷ for (let j = i; j > from && arr[j - 1] > arr[j]; j--) {
    ❸ [arr[j - 1], arr[j]] = [arr[j], arr[j – 1]];
    }
  }
  return arr;
};

设置一个循环,从数组的第二个位置开始,一直到最后❶,然后在列表没有排序好的情况下继续循环❷,通过交换将新数值放到正确的位置❸。

仔细观察,你会发现它进行了过多的交换才能把新元素放到正确的位置。

你可以快速优化代码,避免多次交换,每次循环只做一次交换:

const insertionSort = (arr, from = 0, to = arr.length - 1) => {
❶ for (let i = from + 1; i <= to; i++) {
  ❷ const temp = arr[i];
  ❸ let j;
    for (j = i; j > from && arr[j - 1] > temp; j--) {
      arr[j] = arr[j - 1];
    }
  ❹ arr[j] = temp;
  }
  return arr;
};

第一个循环❶和之前一样,但是区别在于其中的操作。你将要插入的数值从已排序的数组中取出❷,然后循环找出它应该放在哪个位置❸,并将大于它的数值往右推。最后❹,你把新值放在它的最终位置。

插入排序是一个简单的算法,这使得它在处理较小的数组时是一个不错的选择。章节后面我们将讨论它如何在混合排序算法中使用,作为理论上更方便但实际上较慢的替代方法的替代品。

使用梳状排序和希尔排序进行更大的跳跃

冒泡排序及其变种不是性能最好的排序算法。然而,将元素交换使其上浮或下沉的想法并不坏,应用这种思想让元素做更大的跳跃(例如,交换更远的元素)最终会得到一个更好的算法,即Shell 排序。你将首先通过一个名为梳排序的冒泡排序变种来探索这个思想。

梳排序

我们回到冒泡排序,考虑数组中的元素如何像兔子和乌龟一样移动。兔子代表位于列表前部的较大值,它们会快速通过一次次交换跳到数组末尾的正确位置。另一方面,乌龟代表位于列表后部的较小值,它们会慢慢地通过每次遍历交换移动到正确的位置。你希望兔子和乌龟都能迅速地各自移动到数组的两侧。

这个思想是进行一些交换操作的遍历,但与其将元素与下一个元素比较,不如考虑更大的间隔。因此,兔子会跳得更远,而乌龟则相应地跳得更远。你将以逐渐减小的间隔进行遍历,当间隔变为 1 时,你就应用常规的冒泡排序来完成。

逻辑如下:

const combSort = (arr, from = 0, to = arr.length - 1) => {
❶ const SHRINK_FACTOR = 1.3;

  let gap = to - from + 1;
  for (;;) {
  ❷ gap = Math.floor(gap / SHRINK_FACTOR);
  ❸ if (gap === 1) {
      return bubbleSort(arr, from, to);
    }
  ❹ for (let i = from; i <= to - gap; i++) {
      if (arr[i] > arr[i + gap]) {
        [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
      }
    }
  }
};

通过经验确定,第一个间隔应该等于数组长度除以 1.3,即“收缩因子”❶,后续的间隔将始终是前一个间隔的 1.3 倍❷。当间隔变为 1❸时,直接应用冒泡排序,任务完成。当间隔大于 1❹时,你需要做的本质上是冒泡排序的核心逻辑,只不过不是比较相邻元素,而是比较间隔为“gap”的元素。

梳排序通常比冒泡排序表现更好,但它在最坏情况下仍然是O(n²),而在最好情况下变成O(n log n)。然而,我们考虑这个思想的原因并不是这个;而是排序远距离元素的概念带来了实际的好处,你会发现 Shell 排序正是以类似梳排序的方式做到了这一点。

Shell 排序

要理解 Shell 排序的工作原理,假设你想对图 6-7 所示的数组进行排序。

图 6-7:Shell 排序类似于插入排序,但具有更大的间隔。

在第一次遍历中,进行插入排序,但是元素之间间隔为四个位置,这样会得到一个由四个短序列组成的数组。然后将间隔大小降低为 2,重复排序。此时,数组由两个有序序列组成。最终,你会将间隔大小降到 1,到那时你就只是在进行插入排序,但由于前面的部分排序,它不会像正常算法那样进行那么多的比较或交换,这就是 Shell 排序的优势。

这是 Shell 排序的实现:

const shellSort = (arr, from = 0, to = arr.length - 1) => {
❶ const gaps = [1]; // Knuth, 1973
  while (gaps[0] < (to - from) / 3) {
    gaps.unshift(gaps[0] * 3 + 1);
  }

❷ gaps.forEach((gap) => {
  ❸ for (let i = from + gap; i <= to; i++) {
      const temp = arr[i];
      let j;
 ❹ for (j = i; j >= from + gap && arr[j - gap] > temp; j -= gap) {
        arr[j] = arr[j - gap];
      }
      arr[j] = temp;
    }
  });

  return arr;
};

首先选择要使用哪些间隔 ❶,记住最后应用的间隔必须是 1。你会在网上找到许多关于使用哪种间隔序列的建议,但本例将使用 Knuth 提出的序列(1, 4, 13, 40, 121, ...,每个数字是前一个数字的三倍再加 1),这种序列会导致一个 O(n^(1.5)) 的算法。然后,按降序顺序选择间隔 ❷,并实质上进行插入排序 ❸,不过是对间隔为该数值的元素进行排序 ❹。当间隔较大时,你排序的是较少元素的序列,但随着间隔的减小,你处理的序列会更长,而这些序列通常已经接近有序,因此插入排序表现良好。

提升快速排序的速度

接下来,我们来看更快的算法,它们达到了 O(n log n) 的理论速度限制——尽管在最坏情况下,性能呈二次方增长!快速排序(也称为分区交换排序)由 Tony Hoare 在 1960 年代创造,是一种分治算法,具有高速度。我们首先考虑标准版本,然后讨论一些可能的改进。

标准版本

快速排序是如何工作的?其基本思路是首先从待排序数组中选择一个“枢轴”元素,并根据元素是否小于或大于枢轴,将其他所有元素分布到两个子数组中。排序后的数组以较小的值为前,接着是枢轴,最后是较大的值。然后,递归地排序每个子数组,等到所有子数组都排好序,整个数组也就排序完成了(见图 6-8)。

图 6-8:快速排序通过划分数组并递归地对各部分进行排序来实现。

假设我们始终选择数组中最右侧的元素作为枢轴。(这不会是一个明智的选择,正如你将看到的那样。)在这种情况下,第一个选择是 14,然后重新排列数组,使得所有小于 14 的值排在前面,接着是 14 本身,最后是所有大于 14 的值。相同的过程(选择枢轴、重新排列并递归排序)应用于每个子数组,直到整个数组排序完成。

这是该过程的直接实现:

const quickSort = (arr, left = 0, right = arr.length - 1) => {
❶ if (left < right) {
  ❷ const pivot = arr[right];

  ❸ let p = left;
    for (let j = left; j < right; j++) {
      if (pivot > arr[j]) {
        [arr[p], arr[j]] = [arr[j], arr[p]];
        p++;
      }
    }
  ❹ [arr[p], arr[right]] = [arr[right], arr[p]];

    // Recursively sort the two partitions
  ❺ quickSort(arr, left, p – 1);
    quickSort(arr, p + 1, right);
  }

  return arr;
};

首先,检查是否真的有需要排序的内容;如果左指针大于或等于右指针,说明已经完成 ❶。最右侧的元素将是枢轴 ❷。接下来,从左到右遍历数组 ❸,以类似插入排序的方式交换元素,确保较小的元素移到左边,较大的元素移到右边,枢轴最终位于位置 p ❹。手动模拟枢轴代码的执行过程是个好主意。尽管代码简短,但要正确实现它还是有点棘手的。(如果枢轴值在数组中出现多次,会发生什么?参见问题 6.10。)最后,使用递归对两个分区进行排序 ❺。

分析表明,平均而言,快速排序的时间复杂度是 O(n log n)。然而,最坏情况是很容易找到的。考虑对一个已经排序好的(升序或降序)数组进行排序。检查代码后发现,划分操作总是会以只有一个子数组的形式结束,这就等同于选择排序或冒泡排序,意味着性能会降至 O(n²)。但你可以修复这个问题。

枢轴选择技巧

枢轴的选择对快速排序的性能有着重大影响。特别是,如果你总是选择数组中最大的(或最小的)元素,排序速度会受到负面影响,因此请考虑一些替代的枢轴选择技巧。

避免已排序数组问题的第一个解决方案是随机选择枢轴。选择左侧和右侧之间的一个随机位置(包括边界),如果需要,将选中的元素交换到最右侧位置,然后继续执行算法的其他部分,而不做进一步的修改:

❶ const iPivot = Math.floor(left + (right + 1 - left) * Math.random());
❷ if (iPivot !== right) {
  [arr[iPivot], arr[right]] = [arr[right], arr[iPivot]];
}

我们将在第八章中更详细地讨论随机选择,但你计算 iPivot(枢轴位置)❶的方式会从左到右(包括边界)以相等的概率选择一个值。排序算法的其余部分假设所选枢轴在数组的右侧,因此,如果选中的枢轴在其他位置❷,只需进行交换。

这个随机选择解决了几乎已排序数组的最坏情况,但仍然存在(虽然概率很低)你总是刚好选到数组中最大的或最小的值的可能性,在这种情况下,性能会受到影响。

理想的枢轴是什么?选择数组的中位数(即将数组分成两部分的值)是最优的。一条接近的规则叫做三数中位数:选择数组中左边、中央和右边元素的中位数:

const middle = Math.floor((left + right) / 2);
if (arr[left] > arr[middle]) {
  [arr[left], arr[middle]] = [arr[middle], arr[left]];
}
if (arr[left] > arr[right]) {
  [arr[left], arr[right]] = [arr[right], arr[left]];
}
if (arr[right] > arr[middle]) {
  [arr[right], arr[middle]] = [arr[middle], arr[right]];
}

对这个代码进行测试,使用三个值的所有可能排列,结果表明arr[right]总是以中间值结束。更好的是,你可以选择“第九分位数”,定义为“中位数的中位数”:将数组分成三部分,对每一部分应用三数中位数算法,然后取这三者的中位数。

通过仔细选择枢轴,你可以帮助快速排序变得更快,但你还可以进一步优化它。

混合版本

快速排序很快,但所有的枢轴和递归都会影响运行时间,因此对于小数组,简单算法的组合可能实际表现得更快。你可以应用一种混合算法,将两种不同的方法结合使用。例如,你可能会发现,对于某个特定的数组大小阈值,插入排序表现更好,因此每当你想对一个小于该阈值的数组进行排序时,就切换到该算法:

**const CUTOFF = 7;**

const quickSort = (arr, left = 0, right = arr.length - 1) => {
  if (left < right) {
 **if (right - left < CUTOFF) {**
 **insertionSort(arr, left, right);**
    } else {
      //
      // quicksort as before
      //
    }
  }

  return arr;
};

粗体部分是你需要更改的所有内容。定义截止值,然后在排序时,如果数组足够小,应用替代排序方法。

双枢轴版本

你可以将将数组分为两部分并由一个枢轴分隔的思想,扩展为由两个枢轴分隔的三部分。这个双枢轴版本通常更快。(Java 使用它作为原始数据类型的默认排序算法。)选择最左和最右的元素作为枢轴,如 图 6-9 所示。

图 6-9:双枢轴排序类似于快速排序,但它将数组分成三部分而不是两部分。

从选择 34 和 14 作为枢轴开始,并重新排列数组,使得所有小于 14 的值(12、9、4)排在前面,然后是 14 本身,然后是介于 14 和 34 之间的值(只有 22),接着是 34,最后是大于 34 的值(60、56)。然后,每个子数组使用相同的方法再次排序。

该算法类似于基本的快速排序;主要区别在于枢轴的选择和分区。出于性能考虑,当要排序的数组足够小时,你将采用混合方法,并转为插入排序;例如:

const dualPivot = (arr, left = 0, right = arr.length - 1) => {
  if (left < right) {
    if (right - left < CUTOFF) {
      insertionSort(arr, left, right);
    } else {
      // Choose outermost elements as pivots.
    ❶ if (arr[left] > arr[right]) {
        [arr[left], arr[right]] = [arr[right], arr[left]];
      }
      const pivotLeft = arr[left];
      const pivotRight = arr[right];

      let ll = left + 1;
      let rr = right - 1;
    ❷ for (let mm = ll; mm <= rr; mm++) {
      ❸ if (pivotLeft > arr[mm]) {
          [arr[mm], arr[ll]] = [arr[ll], arr[mm]];
          ll++;
      ❹} else if (arr[mm] > pivotRight) {
          while (arr[rr] > pivotRight && mm < rr) {
            rr--;
          }
          [arr[mm], arr[rr]] = [arr[rr], arr[mm]];
          rr--;

          if (pivotLeft > arr[mm]) {
            [arr[mm], arr[ll]] = [arr[ll], arr[mm]];
            ll++;
          }
        }
      }
    ❺ ll--;
      rr++;
      [arr[left], arr[ll]] = [arr[ll], arr[left]];
      [arr[right], arr[rr]] = [arr[rr], arr[right]];

    ❻ dualPivot(arr, left, ll - 1);
      dualPivot(arr, ll + 1, rr - 1);
      dualPivot(arr, rr + 1, right);
    }
  }

  return arr;
};

你选择最左侧和最右侧的元素作为枢轴,但当然,你可以选择任意两个值并交换它们,使它们最终位于数组的两端,较小的值在左 ❶。(实际上,当处理几乎有序的数组时,选择两个中间元素更好。)接下来,你开始交换元素,保持以下不变式:

  • pivotLeft 位于数组的左侧。

  • 从位置 left + 1 到 ll - 1,所有值都小于 pivotLeft。

  • 从位置 ll 到 mm - 1,所有值严格位于 pivotLeft 和 pivotRight 之间。

  • 从位置 mm 到 rr,值的状态尚未确定。

  • 从位置 rr + 1 到 right - 1,所有值都大于 pivotRight。

  • pivotRight 位于数组的右侧。

你可以通过从一开始就设定 mm 为 left + 1 并让它逐步增大直到到达数组末尾 ❷,来建立这个不变式。如果 mm 处的元素小于 pivotLeft ❸,仅需要交换即可保持不变式。如果 mm 处的元素大于 pivotRight ❹,你需要做更多的工作来保持不变式,将 rr 向左移动。(记住,目标是保持不变式;这个循环确保了倒数第二个不变式。)循环完成后 ❺,交换枢轴到它们的最终位置,并应用递归对三个分区进行排序 ❻。

快速排序是一个非常好的算法,有多种变体,但它总是存在着(尽管很小)性能不佳的可能性。

使用归并排序提升性能

我们将通过归并排序算法来结束我们的比较类排序学习,归并排序能够保证稳定的性能,但代价是需要更高的内存。归并排序基本上通过合并来完成所有排序。如果你有两个已排序的值序列,总共n个元素,将它们合并成一个有序序列可以通过O(n)的过程完成。归并排序的关键思想是应用递归。首先,将待排序的数组分成两半,然后递归地排序每一半,最后将两个已排序的部分合并成一个有序序列(见图 6-10)。

图 6-10:归并排序将数组分成两部分,对其排序,然后通过合并它们结束。

每个待排序的数组都会被分成两部分,分别排序后再合并。要排序一个 8 元素的数组,你需要先排序两个 4 元素的数组,这意味着你需要排序四个 2 元素的数组,而这又需要排序八个 1 元素的数组。排序后者非常简单(不用做任何事),而合并过程则重新构建了原数组。

这是一个递归实现:

const mergesort = (arr, left = 0, right = arr.length - 1) => {
❶ if (right > left) {
  ❷ const split = Math.floor((left + right) / 2);

  ❸ const arrL = mergesort(arr.slice(left, split + 1));
    const arrR = mergesort(arr.slice(split + 1, right + 1));

  ❹ let ll = 0;
    let rr = 0;
    for (let i = left; i <= right; i++) {
      if (
        ll !== arrL.length &&
        (rr === arrR.length || !arrR[ll] > arrL[rr])
      ) {
        arr[i] = arrL[ll];
        ll++;
      } else {
        arr[i] = arrR[rr];
        rr++;
      }
    }
  }

  return arr;
};

首先,检查是否需要排序❶,这可能包括一种混合方法,如果数组足够小,你可以采用其他方法,而不是归并排序。然后将数组分成两半❷,并递归地排序每一半❸。接下来,合并两个已排序的数组❹:ll 和 rr 将遍历每个数组,输出将放入原数组中。最后,返回排序后的数组。

归并排序具有非常好的性能(尽管执行合并时需要额外的空间),它实际上是Tim 排序的基础,Tim 排序是一种稳定的自适应方法,广泛应用于各种场景。Java 采用了它,JavaScript 也在 V8 引擎中应用了它,其他语言也使用它。我们不打算深入探讨其实际实现,因为这个算法比我们之前考虑的算法要长得多(GitHub 上的几个实现几乎有 1,000 行代码)。Tim 排序利用了已经有序的元素序列,通过合并较短的序列来生成更长的序列,并应用插入排序来确保序列足够长。你已经学习了构成完整 Tim 排序算法的所有部分。

注意

关于基于比较的排序方法还有更多内容可以学习,但我们将在看到一些数据结构之前推迟考虑更多的算法。在第十四章中,我们将探索优先队列和堆。同样,在第十二章中,我们将学习二叉树,特别是二叉搜索树。通过将所有要排序的元素添加到这样的结构中,你可以稍后按顺序遍历它,从而产生另一种排序方法,尽管这种解决方案的性能和相对复杂性使得它并不特别吸引人。二叉搜索树更侧重于搜索;排序只是一个副产品。同样,像跳表这样的其他结构(我们将在第十一章中分析)也可以提供一种排序方法,但和二叉搜索树一样,排序并不是它的主要目的。

无需比较的排序

在上一节中,所有的排序算法都依赖于比较键值,并利用这些信息来移动、交换或划分值。但还有其他的排序方法。例如,假设你负责客户支持并且收到许多不同原因的电子邮件。如何简化分类任务呢?你可以为每个类别使用不同的电子邮件地址,这样消息就会自动被分类到正确的处理箱中。

这个简单的解决方案展示了我们将要做的事情。基本上,你不会比较键值;相反,你将利用它们的值来确定它们在最终排序列表中的位置。这并非总是可能的,但如果你能应用这里的方法,性能将变为O(n),这是无法超越的。毕竟,没有算法可以在不至少看一次这些值的情况下对n个值进行排序,而这本身就是一个O(n)的过程。在这一节中,我们将考虑几种方法,位图排序计数排序,我们还会看看一种非常古老的排序方法,基数排序,它的起源与使用打孔卡片做普查工作的制表机相当。

位图排序

让我们从一种性能优秀但有一些限制的排序方法开始,如果你能接受这些限制的话。我们必须做出三个假设。首先,你将只对数字进行排序(没有键+数据)。其次,你知道数字的可能范围,而且范围不是很大。(例如,如果你只知道它们是 64 位数字,那么从最低到最高的数字范围就会让你放弃尝试这个算法。)第三,这些数字不会重复;所有要排序的数字都是不同的。

考虑到这些(太多的)限制,你可以轻松地使用位图。假设你从所有位都关闭开始,每当你读取一个数字时,就将该位设置为开启。完成后,按照顺序检查这些位,每当某一位被设置时,就输出对应的数字,完成排序(见图 6-11)。

图 6-11:位图排序利用了已知要排序的值的范围。

你必须遍历所有数字,找到最小值和最大值,以定义位图的大小。之后,再次遍历这些数字,每当一个数字出现时,就设置一个标志位。在图 6-11 中,设置了与数字 22、24、25、27、28 和 31 对应的位。 (JavaScript 规定所有数组从位置 0 开始,因此你需要记住位置 0 实际上对应键 22,位置 1 对应键 23,依此类推。) 最后,遍历位图,输出那些标志位被设置的数字;这很简单。

这个算法有局限性,但它是另一个改进算法的基础。为了简单起见,这个例子将使用布尔值数组而不是位图,并编写以下代码:

const bitmapSort = (arr, from = 0, to = arr.length - 1) => {
❶ const copy = arr.slice(from, to + 1);
❷ const minKey = Math.min(...copy);
  const maxKey = Math.max(...copy);

❸ const bitmap = new Array(maxKey - minKey + 1).fill(false);
❹ copy.forEach((v) => {
  ❺ if (bitmap[v - minKey]) {
      throw new Error("Cannot sort... duplicate values");
  ❻} else {
      bitmap[v - minKey] = true;
    }
  });

❼ let k = from;
  bitmap.forEach((v, i) => {
  ❽ if (v) {
      arr[k] = i + minKey;
      k++;
    }
  });

  return arr;
};

首先,复制输入数组 ❶ 以简化下一步,即确定最小值和最大值 ❷。(这可以在一个稍微更高效的循环中完成。)然后创建一个合适长度的位图数组 ❸,但实际上你将使用常规布尔值,而不是位。你需要小心索引,因为 JavaScript 的数组总是从零开始;需要进行一些索引计算,将键与数组位置对应起来。接着遍历输入数组 ❹,检查该键是否已经出现。如果是 ❺,那就有问题。如果不是 ❻,只需标记该数字已出现。最后,遍历位图 ❼,每当找到已设置的标志 ❽ 时,输出相应的数字。

无法处理重复键是一个严重的局限,另外只处理数字也是一个限制;你需要能够对由键+数据组成的元素进行排序,就像你迄今为止所探索的所有其他算法一样。

计数排序

前面的排序方法非常有效,但仅适用于有限的情况。你可以通过计算每个排序元素应该去哪里来进行改进。为此,你需要统计每个键出现的次数,然后利用这些信息决定如何将排序元素放入输出数组中(见图 6-12)。

图 6-12:计数排序与位图排序有些相似,但它能够处理重复的键。

与位图排序类似,你需要找到待排序数组中的最小值和最大值,并设置一个计数器数组,所有值初始化为零。(再次提醒,位置 0 对应最小键,这里是 47;位置 1 对应 48,以此类推。)然后再次遍历数组,递增相应的计数器。得到所有计数后,你可以按照一个简单的过程确定每个键的放置位置。例如,最小键(47)的元素从输出数组的第 0 位置开始;下一个键(48)的元素在两位后的位置(因为有两个 47)开始,位于位置 2。每个新的键都会放在前一个键的右边,留下足够的空位来安置所有前面的元素。

这个算法的实现步骤如下:

const countingSort = (arr, from = 0, to = arr.length - 1) => {
❶ const copy = arr.slice(from, to + 1);
  const minKey = Math.min(...copy);
  const maxKey = Math.max(...copy);

❷ const count = new Array(maxKey - minKey + 1).fill(0);
❸ copy.forEach((v) => count[v – minKey]++);

❹ const place = new Array(maxKey - minKey + 1).fill(0);
❺ place.forEach((v, i) => {
    place[i] = i === 0 ? from : place[i - 1] + count[i - 1];
  });

❻ copy.forEach((v) => {
    arr[place[v - minKey]] = v;
 ❼ place[v – minKey]++;
  });

  return arr;
};

这个算法的前三行与位图排序相同❶,你需要创建输入数组的副本并确定最小和最大键。然后创建一个包含所有键计数的数组(初始化为零,并需要像位图排序那样的索引计算❷)。接下来遍历输入数据❸,并对每个键值递增计数。然后生成一个新数组❹来计算每个键元素的起始位置。最小键从位置 0 开始,每个键之间根据前一个键的计数相隔若干位置❺。(例如,如果前一个计数是 5,则新键与前一个键的首次出现位置相隔 5 个位置。)最后,使用位置数组开始将排序后的元素放置到正确的位置❻;每次元素被放入输出数组时,对应的位置会递增 1❼,以便下一个相同键的元素能够放置到正确的位置。

基数排序

本章的最后一种排序算法可能是最古老的。它曾在使用霍勒里斯打孔卡片(参见图 6-13)进行人口普查数据汇总时使用,这发生在 IBM 成立的早期。

图 6-13:原始霍勒里斯卡片(公共领域)

假设你有一组无序的打孔卡片,按第 1 列到第 6 列编号,并且你想要对它们进行排序。使用分类器,一种根据特定列的值将卡片分到不同箱子的机器,你可以按照以下步骤进行操作:

  1. 按照第 6 列将卡片分到不同的箱子中,先选择 0 的卡片,再选择 1 的卡片,依此类推,直到最后选择 9 的卡片。你已经按照第六列对卡片进行了排序,但仍然需要继续工作。

  2. 重复相同的过程,但使用第 5 列。当你拿起卡片时,你会发现它们已经按两列排序(请参阅第 93 页的“排序稳定性”部分以了解原因)。

  3. 按照顺序再次对第 4 列、第 3 列、第 2 列和第 1 列执行相同的过程,最终你将得到一副完全排序的卡片。

你将在第十章中更详细地探讨这个算法,当时你会研究列表,这将是你模拟箱子的方法。

低效排序算法

我们将以一种不太严肃的方式结束,考虑一些真正低效的算法,从差到更差。这些算法不打算用于实际应用!

傻瓜排序

这个算法的名字来自于“三个傻瓜”喜剧团体,如果你熟悉他们,它的低效性会让你想起他们的荒诞行为。排序一个列表的过程从比较其第一个和最后一个元素开始,如果需要的话交换它们,以确保较大的元素位于末尾。接下来,它递归地对列表的最初三分之二部分应用傻瓜排序,然后对列表的最后三分之二部分进行排序(这样可以确保最后三分之一部分按顺序包含最大值),最后再次对列表的前两部分进行排序。所需的比较次数满足 C(n) = 3C(2n / 3) + 1,因此该算法的复杂度是 O(n^(2.71)),这使得它的表现比冒泡排序还差,但还有更糟的。

慢排序

这个算法是作为笑话设计的。它不是基于分治法,而是基于“乘法与投降”。作者为发现一个比以前任何算法都更糟的算法而感到自豪。要排序一个包含两个或更多元素的数组,算法首先将其一分为二,然后使用递归分别排序每一部分。最后,它比较每一部分的最后一个元素,并将其(如有需要,进行交换)放置到原数组的末尾。完成这一操作后,算法继续排序已经提取出的最大值列表。该算法的比较次数满足 C(n) = 2C(n / 2) + C(n – 1) + 1,且其时间为 O(n ^(log) ^n)。它甚至不是多项式时间的!

排列排序

在第五章中,你已经看到如何从一个值的排列转到下一个排列,这暗示了一个更糟的排序算法:反复尝试生成元素的下一个排列,直到算法失败,因为达到了最后一个排列,然后再反转序列。对于一个随机顺序,该算法平均需要测试 n! / 2 个排列,这意味着它的时间至少是阶乘级别的。对于几乎任何大小,算法都因运行时间过长而无法执行。

博格排序

最后的算法名字来源于 bogus(假的)和 sort(排序)这两个词的合成,它是一种概率性算法,通过概率 1 来排序输入,但没有任何关于运行时间的确定性。这个思路也与排列有关:如果待排序的列表不是有序的,它就会随机打乱元素(我们将在第八章中看到类似的算法),然后再进行测试。如果你将这种方法应用于排序一副牌,逻辑如下:如果牌没有按顺序排列,就把它们抛向空中,捡起来,再检查一次——成功的几率是 1/52!,大约是十亿亿亿亿亿亿亿亿亿亿亿的几率。并不太好!

睡眠排序

最后的排序算法专为 JavaScript 设计,其运行时间取决于要排序的最大键值。它适用于数字键,其思想是:如果输入键是 K,则等待 K 秒并输出其值。经过足够的时间,所有值将按顺序输出:

const sleepSort = (arr) =>
  arr.forEach((v) => setTimeout(() => console.log(v), v * 1000));

即使这个算法看起来能够工作,使用足够大的数据集时,它可能会崩溃(因为等待超时过多)或失败。该算法会遍历列表并开始输出数字——可以想象处理一个类似于 1, 2, 2, 2, . . . , 2, 2, 0 这样的列表,当 2 的数量足够多时,初始的 1 可能会在最后一个 0 被处理之前就输出。

总结

在本章中,我们探索了几种具有不同性能水平的排序算法。在下一章中,我们将探讨一个类似的主题——选择问题,它类似于对数组的一部分进行排序,因为你并不关心将所有元素按正确顺序排序,而是只关心将一个元素放到最终位置,而不一定是排序整个列表。

问题

6.1 强制反转

假设你想将一组数字按降序排列,但你有一个只按升序排序且没有任何选项可更改其排序方式的排序函数。你如何管理这些数据,按你希望的方式排序?

6.2 仅限下限

假设你有一个布尔函数 lower(a, b),如果 a 在排序中小于 b,则返回 true,否则返回 false。你如何利用它来判断 a 是否大于 b 的排序顺序?你又如何用它来判断两个键是否相等?

6.3 测试排序算法

想象你正在尝试一种你自己设计的新排序算法。你将如何测试它是否正确排序?

6.4 缺失 ID

假设你获得了一组六位数的 ID,但总数少于 1,000,000,因此至少有一个 ID 是缺失的。你如何找到这个缺失的 ID?

6.5 未匹配的一个

假设你有一个包含交易号的数组,每个数字应该在数组中出现两次,但你知道有个错误,出现了一个只出现一次的交易号。你如何检测到这个错误?

6.6 下沉排序

这是一种冒泡排序的变种。与从数组底部开始让较大的值冒泡到顶部不同,沉降排序从数组顶部开始,让较小的值沉到底部。在性能上,它和冒泡排序相同,但如果你只想找到数组中最小的k个元素,它可能会派上用场,正如你在第七章中看到的那样。你能实现沉降排序吗?

6.7  气泡交换检查

在每次遍历数组后,给冒泡排序添加一个测试,若没有检测到交换则提前退出。如果你处理的是几乎有序的数组,并且只有少数交换就能将所有元素放到正确位置,这个测试将加速排序过程。

6.8  递归插入

你能用递归的方式实现插入排序吗?

6.9  稳定的 Shell 排序?

Shell 排序是稳定的吗?

6.10  荷兰国旗增强法

荷兰国旗问题要求你对数组进行排序,数组中的元素要么是红色、白色或蓝色,排序后的顺序是所有红色元素排在前面,接着是所有白色元素,最后是所有蓝色元素,和荷兰国旗的颜色顺序一样。展示一下如何通过将待排序数组重新排列为三个部分:所有小于基准元素的元素、所有等于基准元素的元素和所有大于基准元素的元素,来提升快速排序的性能。中间的部分不需要再进行排序。

6.11  更简单的合并?

在归并排序中合并两个子数组时,你写了以下内容(特别注意加粗的部分):

for (let i = left; i <= right; i++) {
  if (ll !== arrL.length && (rr === arrR.length || **!arrR[ll] > arrL[rr]**)) {
    ...
  } else {
    ...
  }
}

为什么这样写?你总是希望使用大于运算符进行比较,以便能够轻松地替换为更复杂的比较函数,但是为什么不写成arr[rr]>arr[ll]呢?

6.12  尽量避免负数

如果基数排序中有负数会发生什么?如果有非整数值会怎么样?你能解决这个问题吗?

6.13  填满它!

在基数排序中,假设你想用 10 个空数组来初始化桶数组,你可以这样做:

const buckets = Array(10).fill(0).map(() => []);

为什么下面的替代方法不可行?

const buckets = Array(10).fill([])

那么,这种可能性呢?

const buckets = Array(10).map(() => [])

6.14  字母呢?

你会如何修改基数排序,使其适用于字母字符串?

第七章:7 选择

在上一章中,我们讨论了排序问题,在这里我们将考虑一个与排序有很多相似算法的相关问题:选择。基本情况是,给定一个数字k和一个包含n个项的数组,我们希望找到数组中第k个位置的值,假如我们对数组进行了排序。但实际上我们并不需要排序数组;我们只需要知道它的第k个元素。与排序问题不同,JavaScript 并没有提供一个“现成”的选择解决方案,因此,如果你需要这种功能,你必须使用本章中的某些算法。

这个问题与排序的关系很简单:如果你只是对值列表进行排序(使用上一章中的任何算法),你可以快速地为所有可能的k值生成排序列表中的第k个值;你只需要查看排序数组中的第k个位置。如果你确实需要从同一个数组中进行多次选择,这将是一个很好的解决方案;一个 O(n log n) 排序,接着进行多个 O(1) 选择。然而,实际上并没有要求对列表进行排序,我们将尽量避免这样做。本章将探讨的选择算法比排序算法表现得更好,因为它们不需要排序所有内容。

在选择问题中,如果你请求 k = 1,你就是在请求列表中的最小值;k = n 请求最大值,k = n/2 请求中位数。请记住,在“实际情况”中,k 的范围是从 1 到 n,但是由于 JavaScript 的数组是从 0 开始的,k 的范围是从 0 到数组长度减一。

注意

正式来说,如果值列表的长度是偶数,中位数的定义要求取排序数组中两个中心值的平均值,但我们并不这么做。为了让你的选择代码能够输出偶数长度数组的中位数,你需要调用选择算法两次,获取两个中心值,然后再计算它们的均值。我们只处理找到任何给定位置的值的问题。

无比较的选择

就像你可以实现不需要比较的排序(意思是你永远不需要将一个键与另一个键进行比较),你也可以使用位图排序和计数排序方法的变体来快速找到列表的第k个值,而无需尝试部分排序数据。请记住,这些算法是有限制的;它们仅适用于数字(而不是任何类型的键+数据),并且最好是数字位于一个不太广泛的范围内。

位图选择

位图排序通过读取所有数据并在位图中设置位来工作;然后,输出排序后的数字只需要遍历位图。在这里你也会做同样的事情,只不过不会输出所有数字;你只需要位图中的第k个值。图 7-1 展示了这个方法;假设你想要在与第六章中示例相同的数组中找到第 4 个元素。

图 7-1:位图排序的变体提供了一个快速选择算法。

首先生成位图,然后遍历位图,寻找第 4 个设置的位,在本例中对应的是 27。

代码如下:

❶ const bitmapSelect = (arr, k, from = 0, to = arr.length - 1) => {
❷ const copy = arr.slice(from, to + 1);
  const minKey = Math.min(...copy);
  const maxKey = Math.max(...copy);

  const bitmap = new Array(maxKey - minKey + 1).fill(false);
  copy.forEach((v) => {
    if (bitmap[v - minKey]) {
      throw new Error("Cannot select... duplicate values");
    } else {
      bitmap[v - minKey] = true;
    }
  });

❸ **for (let i = minKey, j = from; i <= maxKey; i++) {**
❹ **if (bitmap[i - minKey]) {**
❺ **if (j === k) {**
❻ **return i;**
 **}**
❼ **j++;**
 **}**
 **}**
};

该算法的参数与排序❶时相同,唯一增加的是k,即感兴趣的位置。创建位图❷的逻辑与排序时完全相同;唯一的区别出现在最终输出❸。将计数器 j 设置为数组的第一个位置,每次找到一个设置位❹时,测试 j 是否达到了所需的位置k❺;如果达到了,就完成了❻。否则,继续循环,计数下一个找到的数字❼。

这个算法显然是O(n),如果不是因为之前提到的限制,它将是解决选择问题的最佳算法之一。

计数选择

在与位图排序相同的情况下,在第六章中我们考虑了计数排序,如果输入中有重复数字则没有问题。然而,使用位图时,这种情况就成了一个问题。

你可以应用相同的解决方案:遍历数组,生成计数列表,然后从左到右遍历这些计数,直到找到第k个位置的值。

以使用第六章中的相同数字为例(参见图 7-2);你想要找到数组中第 4 个位置的值。

图 7-2:计数排序也提供了一个简单的选择算法。

首先找到所有的计数,然后从左到右依次累加,直到累加和等于或超过 4;在这种情况下,当累积和从 3 变到 5 时,发生在值 50 处。(请记住,在输入数组中存在重复值时,可能会发生和超过k的情况。)

逻辑如下:

❶ const countingSelect = (arr, k, from = 0, to = arr.length - 1) => {
❷ const copy = arr.slice(from, to + 1);
  const minKey = Math.min(...copy);
  const maxKey = Math.max(...copy);

  const count = new Array(maxKey - minKey + 1).fill(0);
  copy.forEach((v) => count[v - minKey]++);

❸ **for (let i = minKey, j = from; i <= maxKey; i++) {**
❹ **if (count[i - minKey]) {**
❺ **j += count[i – minKey];**
❻ **if (j > k) {**
 **return i;**
 **}**
 **}**
 **}**
};

参数与之前❶相同,生成计数❷的所有逻辑也与第六章中的相同。变化出现在准备输出时。首先初始化一个计数器 j,位于输入数组的第一个位置❸,每次遇到非零计数❹时,更新计数器❺并检查是否通过该和达到了或超过了k。如果是,则返回相应的值❻;否则,继续循环。

同样,我们有一个O(n)算法,但我们希望能够处理更一般的情况,因此让我们继续讨论基于键对键比较的选择算法,这些算法在所有情况下都适用。

使用比较进行选择

大多数选择问题的算法都基于排序算法。我们将探讨的第一个算法基于选择排序,但我们不会对整个数组进行排序——只会排序它的前k个值。选择排序通过找到数组中的最小值,并将其与第一个位置的值交换;然后,它继续寻找剩余值中的最小值,并将其与第二个位置的值交换,依此类推,直到整个数组排序完成。我们将采取相同的方法,但在找到第k个最小值后停止:

❶ const sortingSelect = (arr, k, from = 0, to = arr.length - 1) => {
❷ for (let i = from; i <= k; i++) {
    let m = i;
    for (let j = i + 1; j <= to; j++) {
      if (arr[m] > arr[j]) {
        m = j;
      }
    }
    if (m !== i) {
      [arr[i], arr[m]] = [arr[m], arr[i]];
    }
  }

❸ return arr[k];
};

这个算法的参数与之前相同❶。我们在循环中做了一些小改动。在排序时,你遍历了整个数组,但现在在达到第k个位置后就会停止❷。其余的逻辑与排序算法完全相同,只是你返回的是所需的值,而不是排序后的数组❸。

该算法的性能为O(kn),对于较小的k值是高效的,但如果k增大并且与n成正比时,性能会趋于差劲。(请参见问题 7.3,了解一种特殊情况。)特别地,如果你要查找数组的中间元素,那么k = n / 2,性能会变成O(n²);这时使用其他算法会更好。

Quickselect 系列

许多选择算法源自快速排序代码,特别是它如何根据枢轴分割数组,将值移动,使得数组的一侧包含小于枢轴的值,枢轴本身位于中间,另一侧则包含大于枢轴的值。在快速排序的情况下,数组这样分割后,算法会递归地对两个部分进行排序;而在选择算法中,你只会在其中一个部分继续搜索。有关如何查找数组第六个元素的示例,请参见图 7-3。

图 7-3:快速排序中使用的枢轴技术提供了一种选择算法。

你将使用与快速排序相同的首个枢轴方案,并选择最右边的值(14)作为枢轴。将数组围绕 14 重新分区后,枢轴将位于数组的第 4 个位置。你要查找的是第六个元素,所以继续在枢轴右侧进行搜索。在右侧,你选择 56 作为枢轴,重新分区后,56 将位于数组的第 7 个位置。这个位置超过了你需要的位置,因此继续在左侧搜索。然后你选择 22 作为枢轴。它位于第 5 个位置,你继续在右侧搜索,此时右侧只剩下一个元素,因此你可以确定 34 是数组中的第六个值。在 34 的左侧是较小的值(但不一定按顺序排列),右侧是较大的值。

如 第六章 所提到的,快速排序的平均性能是 O(n log n),但在最坏情况下,它变成 O(n²)。quickselect 的平均性能已被证明是 O(n),但如果你每次都做出不幸的基准选择,它可能会变成 O(n²),因此我们不仅研究单一算法,而是通过改变基准选择的方式来考虑这一系列算法。

Quickselect

让我们从基本逻辑开始。如同 第六章 中所述,假设是可以用 < 和 > 操作符进行比较的单字段键。始终编写测试 as a > b,因此将代码适配为更通用的比较函数,只需要编写 compare(a,b) > 0,前提是提供一个 compare(x,y) 函数,如果 x 大于 y,则返回正值。

以下代码实现了 quickselect 系列算法的基本结构;基准选择部分加粗显示,我们将对该部分进行更改,以获得其他增强版本的选择函数:

❶ const quickSelect = (arr, k, left = 0, right = arr.length - 1) => {
  if (left < right) {
 **const pick = left + Math.floor((right + 1 - left) * Math.random());**
 **if (pick !== right) {**
 **[arr[pick], arr[right]] = [arr[right], arr[pick]];**
 **}**
 const pivot = arr[right];

    let p = left;
    for (let j = left; j < right; j++) {
      if (pivot > arr[j]) {
        [arr[p], arr[j]] = [arr[j], arr[p]];
        p++;
      }
    }
  ❷ [arr[p], arr[right]] = [arr[right], arr[p]];

  ❸ if (p === k) {
     return;
  ❹} else if (p > k) {
     return quickSelect(arr, k, left, p - 1);
  ❺} else {
     return quickSelect(arr, k, p + 1, right);
   }
 }
};

quickselect ❶ 的参数与选择排序以及本章中所有算法的参数相同。该算法的开始与快速排序完全相同,可以选择随机的基准值,直到如何划分数组,包括让选择的基准值位于位置 p ❷。唯一的不同之处在于后续的处理方式。如果基准值位于 k 位置 ❸,那么就完成了,因为那就是你需要的值。否则,使用递归来检查包含 k 位置的左 ❹ 或右 ❺ 部分。(实际上,你不一定需要使用递归;请参见问题 7.4。)

quickselect 会重新排序(划分)输入数组,确保 k 位置的元素不小于它前面的任何元素,也不大于它后面的任何元素。你可以通过编写辅助函数轻松地获取该值:

const qSelect = (arr, k, left = 0, right = arr.length - 1) => {
❶ quickSelect(arr, k, left, right);
❷ return arr[k];
};

使用 quickselect 重新划分数组 ❶,然后返回所需位置的值 ❷。(有关简单修改,请参见问题 7.5。)平均而言,这个算法的时间复杂度可以证明是线性的,但如果每次都恰巧选择最差的基准值,它将变成二次复杂度。现在考虑一些替代的基准选择策略。

中位数的中位数

以前版本的 quickselect 可能会变得较慢,但你可以更好地划分数组。例如,你不希望两个可能的划分都太小,以防你必须对较大的部分进行递归处理。

你可以应用的一种策略叫做 中位数的中位数,其思路如下:

  1. 将数组分成最多五个元素一组。

  2. 找到每组的中位数。

  3. 找到前一步中找到的中位数的中位数。

  4. 使用该值来划分数组。

图 7-4 展示了这个概念;每个矩形是一个由底部到顶部按从小到大的顺序排列的五个值的集合(如垂直箭头所示),其中中位数位于中间。中位数本身从左到右(最低的中位数到最高的中位数)按水平箭头顺序排列。你将选择的枢轴是这些中位数集合的中位数——图中的中心值。

图 7-4:每列中的中间元素是其中位数;这些中位数从左到右排序,且中心值不小于阴影部分的值,即数组的三分之一。

在图 7-4 中,所有灰色的值(45 个中的 15 个,整个集合的三分之一)保证不会大于所选的枢轴。同样,所选的枢轴也保证不会大于数组中另外三分之一的值(见图 7-5)。

图 7-5:在与图 7-4 相同的情况下,中心值也不大于阴影部分的值,即数组的三分之一。

这意味着所选的枢轴将使数组按某种方式分割为 33/66 百分比和 50/50 百分比。最坏情况下,你将需要在一个大小为原数组三分之二的新数组中应用递归(而最好情况仅为原数组的三分之一大小),这可以证明会产生O(n)的性能。

以下代码实现了此方法(粗体部分为已更改的部分):

const quickSelect = (arr, k, left = 0, right = arr.length - 1) => {
  if (left < right) {
 **let mom;**
❶ **if (right - left < 5) {**
 **mom = simpleMedian(arr, left, right);**
 **} else {**
❷ **let j = left – 1;**
 **for (let i = left; i <= right; i += 5) {**
❸ **const med = simpleMedian(arr, i, Math.min(i + 4, right));**
 **j++;**
❹ **[arr[j], arr[med]] = [arr[med], arr[j]];**
 **}**
❺ **mom = Math.floor((left + j) / 2);**
❻ **quickSelect(arr, mom, left, j);**
 **}**
❼ **[arr[right], arr[mom]] = [arr[mom], arr[right]];**

    const pivot = arr[right];

    let p = left;
    for (let j = left; j < right; j++) {
      if (pivot > arr[j]) {
        [arr[p], arr[j]] = [arr[j], arr[p]];
        p++;
      }
    }
    [arr[p], arr[right]] = [arr[right], arr[p]];

    if (p === k) {
      return;
    } else if (p > k) {
      return quickSelect(arr, k, left, p - 1);
    } else {
      return quickSelect(arr, k, p + 1, right);
    }
  }
};

如果数组足够短(最多五个元素)❶,你可以使用另一种算法来找到中位数的中位数(mom)。如果数组有超过五个元素❷,则考虑每次处理五个元素的集合。你先找到该集合的中位数❸,并通过交换将其移到原数组的左侧❹,这样所有中位数最终会集中在数组左侧的位置。现在你需要计算这个(较小)集合的中位数,因此计算它的位置❺,并使用递归❻来找到所需的枢轴。一旦找到枢轴,交换它与数组右侧的值❼,从那时起,接下来的逻辑与前面展示的枢轴逻辑相同。

现在完成代码。你需要一个快速的 simpleMedian(...)算法来找到最多五个元素的数组的中位数,插入排序可以完成这个任务(你也可以使用第 124 页“通过比较选择”章节中的 sortingSelect(...)代码):

const simpleMedian = (arr, left, right) => {
❶ insertionSort(arr, left, right);
❷ return Math.floor((left + right) / 2);
};

对整个数组进行排序❶,这并不会很慢,因为插入排序在处理这样的小集合时非常迅速,然后选择排序数组的中间元素❷。

这个逻辑效果良好,且结果有保障,不同于原始的快速选择算法,它的最坏情况与平均情况不同。

重复步骤

选择基准的另一种变体叫做重复步骤。这个算法在分区数组时看似效果不佳,但它在速度方面有优势。使用“九分法”技术选择三个元素的中位数非常迅速(如第六章所述):首先遍历数组,从每组三个值中选择中位数,生成一个集合;然后,遍历该中位数集合,从每组三个中位数中选择中位数,生成第二个集合。图 7-6 展示了对于一个包含 18 个元素的数组如何工作。这个思路对于更大的数组是一样的,只是这里没有足够的空间展示。

图 7-6:反复应用“三个中位数”过程将原数组缩小为原始大小的九分之一。

选择三元组中位数的重复步骤将原数组缩小为原始大小的九分之一,并使递归速度非常快。(从某种意义上说,你是在选择中位数的中位数的中位数。)其实现如下:

const simpleMedian = (arr, left, right) => {
  insertionSort(arr, left, right);
  return Math.floor((left + right) / 2);
};

const quickSelect = (arr, k, left = 0, right = arr.length - 1) => {
  if (left < right) {
❶ **let mom;**
❷ **if (right - left < 9) {**
 **mom = simpleMedian(arr, left, right);**
 **} else {**
❸ **let j1 = left - 1;**
❹ **for (let i = left; i <= right; i += 3) {**
 **const med = simpleMedian(arr, i, Math.min(i + 2, right));**
 **j1++;**
 **[arr[j1], arr[med]] = [arr[med], arr[j1]];**
 **}**

❺ **let j2 = left - 1;**
❻ **for (let i = left; i <= j1; i += 3) {**
 **const med = simpleMedian(arr, i, Math.min(i + 2, j1));**
 **j2++;**
 **[arr[j2], arr[med]] = [arr[med], arr[j2]];**
 **}**

❼ **mom = Math.floor((left + j2) / 2);**
 **quickSelect(arr, mom, left, j2);**
 **}**
❽ **[arr[right], arr[mom]] = [arr[mom], arr[right]];**

    const pivot = arr[right];

    let p = left;
    for (let j = left; j < right; j++) {
      if (pivot > arr[j]) {
        [arr[p], arr[j]] = [arr[j], arr[p]];
        p++;
      }
    }
    [arr[p], arr[right]] = [arr[right], arr[p]];

    if (p === k) {
      return;
    } else if (p > k) {
      quickSelect(arr, k, left, p - 1);
    } else {
      quickSelect(arr, k, p + 1, right);
    }
  }
};

mom 变量最终位于数组中的中位数位置 ❶。如果数组元素少于九个 ❷,你不需要做任何复杂的操作;只需使用基于排序的算法找到所需的中位数。变量 j1 跟踪你已交换到数组左侧的中位数 ❸。一个简单的循环遍历数组的元素,每次处理三个元素,找到该三元组的中位数并交换到左侧 ❹。然后,你再使用新的 j2 变量 ❺ 和另一个循环 ❻ 执行相同的逻辑。经过这些循环后,从左侧到 j2 位置的元素就是中位数的中位数 ❼,接着递归地应用算法找到其中位数,并将其与右侧的元素交换 ❽,这样你就可以继续进行其余的未变的快速选择算法。

该算法也可以证明具有 O(n) 的性能,因此它是一个不错的选择。为什么在找完两轮中位数的中位数后还要使用递归呢?(请参见问题 7.6。)

到目前为止,你已经探讨了可以找到任何值的kth 元素的算法;本章最后明确考虑了找到数组中间元素的问题。

使用懒选择法找到中位数

如果你想找到中位数(记住,这里的工作定义不是统计学中使用的那种;你只是选择数组中最接近中心的元素,而不考虑数组长度是否为偶数),你显然可以使用本章中的任何一种算法,设 k 为输入数组长度的一半。然而,还有一些其他方法可以找到中心值,在本节中,我们将考虑一种有趣的方法,它基于随机抽样(你将在第八章中学习抽样算法)和概率计算。懒选择算法使用抽样,并且可能通过一次遍历找到正确的值,具有 O(n^(–1/4)) 的失败概率,按需反复循环直到成功。

求解大小为 n 的集合 S 的中位数的算法如下:

  1. S 中随机选择一个大小为 n^(3/4) 的样本 R

  2. 使用任意算法对 R 进行排序。

  3. R 中选择两个值,du,使其满足 d < median < u,且具有较高的概率(稍后你会看到如何做到这一点)。

  4. dSizeR 中小于 d 的值的个数;如果 dSize > n/2,则表示失败,必须重新尝试。

  5. uSizeR 中大于 u 的值的个数;如果 uSize > n/2,则必须重新尝试。

  6. mS 中值 x 的集合,满足 d < x < u;如果计数超过 4n^(3/4),则必须重新尝试。

  7. m 进行排序,并返回其 n/2 - dSize 位置的值。

该算法的性能证明高度依赖于概率论的推理,这里不再展示。关键概念是,随机选择 R 中的值——但不要选择太多,以确保排序 R 的复杂度为 O(n)——通常应该足够找到中位数的上下限(即前面列表中的 du),而且在 du 之间的值集合应该足够小,因此,排序操作的复杂度仍然保持在 O(n) 以内。该算法可能会失败,但失败的概率很低,为 O(n(1/4)),也就是说在最坏的情况下,几次新的尝试应该能成功。例如,如果失败的概率是 10%(即算法有 90% 的机会第一次就成功),那么连续两次失败的概率是 1%(10% 的 10%,成功的概率为 99%),三次连续失败的概率是每 1000 次失败一次,依此类推。

实现非常简单,但包含大量的数学计算:

❶ const sort = require("../sorting/mergesort");

const lazySelectMedian = (arr, left = 0, right = arr.length - 1) => {
❷ const len = right - left + 1;
❸ const sR = Math.floor(len ** 0.75);
❹ const dIndex = Math.max(0, Math.floor(sR / 2 – Math.sqrt(len)));
  const uIndex = Math.min(sR - 1, Math.ceil(sR / 2 + Math.sqrt(len)));
❺ let dSize, uSize, m;
  do {
  ❻ const r = [];
    for (let i = 0; i < sR; i++) {
      r.push(arr[left + Math.floor((right - left) * Math.random())]);
    }
  ❼ sort(r);

    dSize = uSize = 0;
    m = [];
    for (let i = left; i <= right; i++) {
      if (r[dIndex] > arr[i]) {
        dSize++;
      } else if (arr[i] > r[uIndex]) {
        uSize++;
      } else {
        m.push(arr[i]);
      }
    }
❽} while (dSize > len / 2 || uSize > len / 2 || m.length > 4 * sR);

❾ sort(m);
  return m[Math.floor(len / 2) - dSize];
};

当需要对数组进行排序时,你使用归并排序 ❶;选择一个 O(n log n) 的算法很重要,因为你将用于最大为 4n^(3/4) 大小的数组,因此性能为 O(4n^(3/4) log 4n^(3/4)) < O(n)。接着,你为其余的代码定义几个变量:len 是输入数组的大小 ❷,sR 是样本的大小 ❸,dIndex 和 uIndex 是排序后 r 数组中 d 和 u 的位置 ❹,而 dSize、uSize 和 m ❺ 与本节前面列出的描述相对应。

使用“带重复的抽样”算法 ❻ 从输入数组中选择 sR 个随机值到 r 数组中;确保没有重复的值被抽取也是可行的,但逻辑会更加复杂,正如你在第八章中看到的那样。选择并排序 r ❼后,计算 dSize 和 uSize(输入数组中小于 d 或大于 u 的值的数量;注意你从未定义 d 和 u,你只是通过它们的索引引用它们)和 m(值位于 d 和 u 之间)。

最后,你需要检查结果是否如预期 ❽。如果 dSize 或 uSize 包含超过一半的输入数组,那么中位数就不在 m 中,如预期的那样;你失败了。同样,如果 m 太大,你也失败了。如果所有测试都通过,m 的大小合适,允许你对其进行排序并从中选择中位数 ❾。注意,你还要考虑比 d 小的 dSize 值,这些值位于数组 m 之前。

这个算法与本书中大多数你考虑过的算法有很大不同,因为它依赖于概率性质才能工作,但通常性能非常好,能够在很少的迭代(如果有的话)中找到中位数。

摘要

在本章中,你研究了几种选择算法,其中大多数与第六章中讨论的排序算法密切相关。选择问题并不像排序问题那样常见,因此 JavaScript 没有提供现成的解决方法,所以如果你需要此功能,就必须实现本章中的算法。这里介绍的大多数算法具有O(n)的性能,这是最优的,但它们的行为证明通常较为复杂,因此被省略了。

问题

7.1 网球淘汰赛

假设 111 名网球选手参加淘汰赛以决出冠军。在每一轮中,选手随机配对进行比赛,失败者淘汰,获胜者晋级下一轮。如果选手数量为奇数,则有一名选手获得直接晋级下一轮的机会。为了找出冠军,至少需要多少场比赛?你还需要进行多少额外的比赛来找出第二好的选手?(而且,不是说输给冠军的选手一定是第二好的选手。)你能为n个选手概括出你的答案吗?

7.2 取五个

“Take Five”是 Dave Brubeck 使其闻名的爵士乐作品,但在这个问题中,你需要的是取五个元素的中位数。保证能找到该中位数的最少比较次数是多少?你能提供一个合适的 medianOf5(a,b,c,d,e)函数,返回它五个参数的中位数吗?你可以通过这个方法优化一个更简单的 simpleMedian()函数!

7.3 从上到下

如果k接近n(输入数组的长度),你基于选择排序的算法将会有较差的二次性能,但你可以通过一个简单的技巧使其变得更好;你能看出是什么吗?

7.4 仅仅迭代

Quickselect 只进行一次尾递归调用,并且可以重写以避免所有递归;你能做到吗?

7.5  不改变的选择

如上所示,qSelect 返回所需的 k 值,但它有一个副作用:输入数组会被更改。你能修改 qSelect 以避免这个副作用吗?

7.6  西西里方式

重复步骤选择算法进行两轮选择三个数的中位数,最后使用递归找到结果数组中的中位数的中位数。实现以下变体:不使用递归,而是不断应用相同的方法(按三分分组、选择中位数,依此类推),直到结果数组的长度小于 3,然后从该小数组中选择枢轴,且不使用任何递归。

第八章:8 洗牌与采样

本章可以看作是对前两章的补充,但与其将值排序为某种顺序,你希望将它们洗牌成一个随机、无序的序列(就像扑克牌游戏一样)。与其选择一个给定位置的值,你希望随机选择一组值(就像统计抽样算法一样)。第六章和第七章围绕顺序和一致性展开,而本章则处理无序和随机性。

随机选择数字

首先,考虑一个你将在本章中需要使用的基本函数:在给定区间内生成一个随机数。JavaScript 已经提供了 Math.random(),它会生成一个伪随机数 r,使得 0 ≤ r < 1。(更多信息,请参见 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random.)这个函数生成的数字的分布是 均匀的,意味着每个值的可能性是相等的,没有任何一个值比另一个值更可能。

注意

为什么它是伪随机的?这些随机数实际上是由算法生成的,生成的序列的特性大致与真正的随机数序列相似。然而,事实是这些数字是通过某个过程生成的,这自动意味着它们并不是真正的随机数;它们只是看起来像是随机的。不过,为了简单起见,在本章中我们将把生成的数字视为随机数。

使用这个函数,你可以将结果扩展到任意给定的范围。接下来的函数将在本章其余部分派上用场:

const randomBit = () => Math.random() >= 0.5;
const randomNum = (a, b) => a + (b - a) * Math.random();
const randomInt = (a, b) => Math.floor(randomNum(a, b));

第一个函数在你想随机决定两个选项之间的选择时非常有用,就像模拟抛硬币一样。如果随机数小于 0.5(0 到 0.4999 ...),则返回 false(正面),否则返回 true(反面)(0.5 到 0.9999 ...)。给定一个从 ab 的值范围(不一定是整数,但 a < b),第二个函数会生成一个随机浮动数 r,使得 a < r < b。通过注意到 (b - a)*Math.random() 大于或等于零,但严格小于 (b - a),这可以很容易地验证。最后一个函数旨在传入整数参数,并生成一个随机整数 r,使得 a < r < b。你也可以这样写:

const randomInt = (a, b) => a + Math.floor((b - a) * Math.random());

有些人对于 randomInt(...) 感到困惑。例如,为了模拟掷骰子,他们可能会写 randomInt(1,6),但这行不通:randomInt(1,7) 才是正确的做法。(有关这个问题的其他见解,请参见第 8.2 问题。)显然,你可以重写 randomInt(...) 以用其他方式实现,但你是按照 JavaScript 中的 array.slice(start,end) 方法来做的,该方法的参数与这些相同,表示从 start 开始,直到 end(不包括 end)为止取出元素。

有了这些基本工具,让我们来讨论洗牌和抽样问题,这两者都以某种方式基于随机数。 ### 洗牌

我们将首先考虑洗牌一个值的数组,以产生一个随机值序列——用数学术语来说,就是排列。这相当于在玩游戏前洗牌,每次都用不同的卡牌顺序开始。

一个重要的前提是,每个可能的排列应该具有相同的概率,这就提出了一个棘手的问题:如何确保洗牌代码正确运行?例如,在排序一个数组时,你可以检查排序后的数组是否确实按顺序排列,并且排序前后的元素相同。类似地,对于选择算法,你可以通过单独排序数组来检查它是否正确,然后检查选中的值是否正确。洗牌则更难检查。

首先,你应该(以某种方式)证明逻辑是正确的,以确保所有结果的概率相等。然而,如果你错误地实现了算法,导致有 bug,怎么办?(别问我怎么知道的。)一种经验性的建议是,使用已知的输入序列多次运行该算法,并通过统计方法测试观察到的结果是否表明其符合均匀分布;我们将这些数学方面的内容留给教科书,改为尝试一种更简单的方法(参见问题 8.1)。

通过排序洗牌

我们将从基于排序的算法开始。它的性能不是最优的,但实现最简单。为了洗牌一组值,你将一个随机数与每个值关联,然后根据这个随机值对数组进行排序,最终的结果就是完全随机的洗牌(见图 8-1)。

图 8-1:通过随机分配的键对数组进行排序,生成完全随机的洗牌。

你可以使用第六章中讨论的任何算法来实现这个解决方案。我们选择最简单的方向,使用 JavaScript 自带的 .sort(...) 方法。虽然为了清晰展示,代码使用了更多行,但最终的洗牌代码是一行:

const sortingShuffle = (arr) =>
  arr
 ❶ .map((v) => ({val: v, key: Math.random()}))
  ❷ .sort((a, b) => a.key – b.key)
  ❸ .map((o) => o.val);

该代码直接对应图 8-1 中的步骤。给定一个值的数组,创建一个新数组,其中对象的原始值保存在 val 中,而一个随机值保存在 key ❶ 中。然后按这个随机键 ❷ 对数组进行排序,并生成一个只包含值的新数组 ❸。

这个算法可能是本书中最简短的一个,它能够轻松地生成一个洗牌后的值列表。然而,实现随机排序时容易出错(见问题 8.3 示例)。

这段代码的性能是 O(n log n),但你可以做得更好。不过,我们首先考虑一种基于第五章和第六章中有趣的概念混合设计的方案。

硬币抛掷法洗牌

让我们探索其他洗牌值集合的方法。假设你有一个分治过程,将集合分成两部分(使用模拟的硬币抛掷来决定元素的去向),递归地洗牌每部分,然后将它们合并回去。空集合或只有一个元素的集合不需要洗牌。你可以通过随机决定(同样抛硬币)哪两个元素将是首尾,来洗牌恰好包含两个元素的集合。对于包含超过两个元素的集合,应用图 8-2 所示的递归过程。

图 8-2:随机拆分数组,对每部分进行洗牌,并将结果合并,产生一种类似于归并排序的洗牌效果。

顶部的第一次拆分将数组分成了五个元素和三个元素两部分。随后步骤依次向下进行。五个元素的数组拆分成了包含一个和四个元素的数组。单个元素无需进一步洗牌,包含多个元素的数组被拆分成两部分。两部分中,一部分保持不变(12,60),另一部分则交换位置。合并后的数组创造了原始四元素数组的随机洗牌,接着与单个元素(22)合并,形成最初五个元素数组的随机洗牌。右侧数组执行类似的过程,最终结果见底部。

这是实现方法:

❶ const coinTossingShuffle = (arr, from = 0, to = arr.length - 1) => {
  const len = to - from + 1;
❷ if (len < 2) {
    // nothing to do
❸} else if (len === 2) {
    if (randomBit()) {
      [arr[from], arr[to]] = [arr[to], arr[from]];
    }
❹} else /* len > 2 */ {
    let ind0 = from - 1;
    let ind1 = to + 1;
    let i = from;
    while (i < ind1) {
      if (randomBit()) {
        ind1--;
        [arr[i], arr[ind1]] = [arr[ind1], arr[i]];
      } else {
        ind0++;
        i++;
      }
    }
  ❺ coinTossingShuffle(arr, from, ind0);
  ❻ coinTossingShuffle(arr, ind1, to);
  }
❼ return arr;
};

洗牌函数的参数将是一个数组 arr 以及其要洗牌的部分(from,to)❶。如果数组的长度小于 2 ❷,则无需做任何操作。如果数组恰好有两个元素 ❸,则通过抛硬币决定是保持原样还是交换两个元素的位置。如果数组有超过两个元素 ❹,则应用类似于快速排序中分区的逻辑:通过抛硬币决定每个值应该放在哪个位置。如果抛硬币结果为真,值将放入 ind1 到 to 区间;如果为假,则放入 from 到 ind0 区间。在将每个元素移动到它应该去的位置后(此时 ind0 和 ind1 会指向相邻的位置),使用递归来洗牌那些得到了假位(false bit)的元素❺和得到了真位(true bit)的元素❻。最后,返回已洗牌的数组❼。

这个算法可以证明具有平均O(n log n)性能,最坏情况下是O(n²)。 图 8-2 应该会让你想起归并排序和快速排序,这些算法的工作方式相似,因此你并没有比排序更好。

线性时间洗牌

我们可以有多快的洗牌速度?洗牌的最佳可能结果是O(n),其中你仅访问数组中的每个元素一次。前一节中的所有方法都表现较差(尽管对于较小的n值,它们可能非常合适),所以现在你将考虑线性时间的洗牌算法。为了更好地与我们在第六章中所做的相匹配,我们将仅对数组的一部分进行洗牌。

Floyd 的洗牌

罗伯特·弗洛伊德的线性时间洗牌算法有一些有趣的思想。该过程分为两个步骤:首先,它生成从 0 到n – 1 的随机排列数字,然后使用该生成的排列来洗牌原始数组。(你将在本章后面看到这种技术在 Floyd 的抽样算法中的应用。)首先生成排列,这类似于插入排序(见图 8-3)。

图 8-3:Floyd 的算法通过随机插入新值到之前已洗牌的值中来产生洗牌。

该算法的工作方式与手动排列扑克牌相同。你先挑出第一张牌,然后就这样。接着挑出第二张牌,将其放在前一张牌的左侧或右侧。然后挑出第三张牌,将其放在前两张牌的左侧、中间或右侧。每一张新牌都会随机地放置在之前的牌之间。

这是代码:

const floydShuffleN = (n) => {
❶ const result = [];
❷ for (let i = 0; i < n; i++) {
  ❸ const j = randomInt(0, i + 1);
  ❹ result.splice(j, 0, i);
  }
  return result;
};

对于简单的实现❶,你可以使用一个数组来存储生成的洗牌。首先从 0 开始循环 n 次❷,每次生成一个随机位置❸,将新数字插入到前面的数字中❹,可以使用非常方便的.splice(...)方法。

但是如何从这个排列得到原始数组的洗牌呢?图 8-4 展示了如何使用之前的结果完成任务。

图 8-4:使用随机排列的数字来洗牌数组。

原始数组中的每个元素都根据 floydShuffleN(...)生成的相应值移动到不同的位置。实现这些移动需要一个额外的数组。生成从 0 到n – 1 的洗牌数字列表后,以下是完成洗牌的代码:

const floydShuffle = (arr, from = 0, to = arr.length - 1) => {
❶ const sample = floydShuffleN(to - from + 1);
❷ const original = arr.slice(from, to + 1);
❸ sample.forEach((v, i) => (arr[from + i] = original[v]));
  return arr;
};

首先生成一个与你想要洗牌的数组部分相同大小的数字样本❶,然后取输入数组中的值❷,并根据图 8-3 所示的方法❸进行替换。

代码的执行效果取决于你为样本选择的数据结构。如这里所示,使用数组意味着插入操作 .splice(...) 的时间复杂度是 O(n),因此整个算法的时间复杂度变为 O(n²)。你将在未来的章节中看到适合的数据结构,但 Floyd 建议使用大小为 2n 的哈希表,条目形成链表,以期望的平均 O(n) 性能,或者使用平衡有序树和带链接节点的结构,以确保 O(n log n) 性能。

Robson 算法

这里是生成排列的另一种方法。对于一个包含 n 元素的数组,有 n! 种可能的洗牌结果。Robson 算法的思路是随机选择一个在 0 和 n! – 1 之间的数字(包括 0 和n!),然后利用该数字生成一个排列,这样每个不同的数字都会产生不同的洗牌结果。

注意

这种方法与一个叫做 Lehmer 码的数学概念相关,它是一种编码 n 个数字所有可能排列的方式,但我们在这里不深入探讨。

如果你想洗牌一个包含四个元素的数组,以产生 24 (= 4!) 种可能的随机排列之一,你需要从 0 到 23 之间的随机数开始。然后将该数字除以 4。商会是 0 到 5 之间的数字,而余数会在 0 到 3 之间。(一个重要的细节是,商和余数的所有可能组合具有相同的概率。你能验证这一点吗?)

使用余数选择数组中的四个元素之一,将其取出并继续操作其余三个元素。考虑商:它是一个在 0 到 5 之间的随机值。

这次,除以 3。新的商会是 0 或 1,余数会是 0、1 或 2,你可以使用余数来选择剩下的三个数字中的一个。考虑商,它是 0 或 1。如果你将商除以 2,你将得到商为 0(不再需要做更多操作)。你可以使用余数(0 或 1)来选择剩下的两个数字之一,这样你就得到了你想要的洗牌结果。(当你从四个元素中选择了三个后,完整的洗牌结果就已经隐含在其中了。)图 8-5 显示了如果你选择了 14 作为随机数,算法的样子。

图 8-5:Robson 的洗牌算法也基于根据随机选择的排列转换原始数组。

从数组中的四个位置 ABCD 开始,位置从 0 到 3。在第一步,将 14 除以 4,得到商 3 和余数 2。然后交换数组中位置 2 的元素与最后一个元素,得到 ABDC。第二步,将 3(上一步的商)除以 3,得到商 1 和余数 0。然后交换位置 0 和倒数第二个位置的元素,结果为 DBAC。接着,将 1(最新的商)除以 2,得到商 0 和余数 1。你不需要交换,因为你会将位置 1 的元素与自身交换,结果仍然是 DBAC。经过三次交换,数组中的三个元素已被洗牌,第四个元素也就自然就位,过程完成。

这是逻辑:

const robsonShuffle = (arr, from = 0, to = arr.length - 1) => {
❶ const n = to - from + 1;
❷ let r = randomInt(0, fact(n));
❸ for (let i = n; i > 1; i--) {
  ❹ const q = r % i;
  ❺ [arr[from + i - 1], arr[from + q]] = [arr[from + q], arr[from + i – 1]];
  ❻ r = Math.floor(r / i);
  }
  return arr;
};

需要洗牌的元素数量是 n ❶。你使用 第五章 中开发的阶乘函数生成一个在 0 到 n! – 1 之间的随机数 ❷。然后从右到左循环遍历数组 ❸:计算 q ❹,用它交换元素 ❺,并找到 r 来再次循环 ❻。

该算法显然是 O(n),因为它遵循一个单一的循环。然而,按原样,算法存在问题。你不能用于较大的数组,因为计算阶乘可能超出了 JavaScript 的可用精度(见第 8.5 问题)。幸运的是,还是有解决办法的。

Fisher-Yates 算法

Robson 算法的问题在于需要计算 n! 来获得一个随机数以继续。但如果仔细考虑,你实际上并不需要阶乘。该算法的关键在于一系列的余数,你可以通过使用随机函数来生成这些余数。第一个余数在 0 到 n – 1 之间,用来选择排列的初始值;第二个余数在 0 到 n – 2 之间,用来选择排列的第二个值,依此类推。因此,你只需要在 Robson 算法的合适时刻生成随机值,这就是 Fisher-Yates 算法。

因此,你可以编写这个替代方案来替代 Robson 的代码:

const fisherYatesShuffle = (arr, from = 0, to = arr.length - 1) => {
❶ for (let i = to + 1; i > from + 1; i--) {
  ❷ const j = randomInt(from, i);
  ❸ [arr[i - 1], arr[j]] = [arr[j], arr[i – 1]];
  }
  return arr;
};

与 Robson 的洗牌方法一样,从右到左循环 ❶,每次迭代计算一个随机数 ❷,用来决定交换哪些元素 ❸。基本上,从位置到 i 之间的任何元素都可以选择进行交换。

Fisher-Yates 算法通常被写成从左到右洗牌,基本上是同样的思路:

const fisherYatesShuffle2 = (arr, from = 0, to = arr.length - 1) => {
❶ for (let i = from; i < to; i++) {
  ❷ const j = randomInt(i, to + 1);
  ❸ [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
};

代码是相同的 ❶,只是生成的排列从左到右开始,任何位置从 i 到 ❷ 的元素都可以选择交换 ❸。这个算法非常高效,常用于洗牌。不过要小心,因为它很容易出错;见第 8.4 问题。

采样

抽样是统计学中经常使用的技术。基本上,从一组值(一个数组)中,你想选择一个随机的较小集合,这个较小的集合称为样本。抽样过程有两种:带重复抽样,允许元素被选中多次;和不带重复抽样,元素不能被选择超过一次。用数学术语来说,后一种过程叫做选择一个组合的元素。(在第一种情况下,即带重复的抽样,选择的元素数量可以是任何数字;而在第二种情况下,数量则受原始集合中元素数量的限制。)不用担心选择元素的顺序。

我们首先考虑带重复的抽样,对于这种方法,我们只需要几个简短、最优高效的算法,然后将大部分章节的内容用于没有重复的抽样,这需要更复杂的逻辑。

带重复的抽样

带重复的抽样是一个简单的算法,你将从选择一个单一值开始。选择更大的样本只需要反复选择值即可。

选择单个值

选择单个值是最简单的抽样方式,你只需要一个位于合适范围内的随机数。你可以使用 randomInt(...)函数,若要从数组中选择一个元素,以下代码可以实现:

const singlePick = (arr, from = 0, to = arr.length - 1) =>
  arr[randomInt(from, to + 1)];

要选择一个位于从位置到到位置(包括两者)的元素,你生成该范围内的随机数并返回对应的元素。

当然,如果你总是希望从整个数组中选择值(就像在本章的其余部分一样),更简单的代码就可以完成任务:

const singlePickAll = (arr) => arr[randomInt(0, arr.length)];

这等同于将 from = 0 和 to = arr.length - 1,因此这个新函数的工作方式与之相同。

选择多个值(带重复)

如前所述,要从一组数据中进行多次选择(也许是模拟一系列轮盘赌转动,或者为剪刀石头布游戏创建策略,或者实现第七章中的懒选择中位数查找算法),做几次单次选择就足够了:

const repeatedPick = (arr, k, from = 0, to = arr.length - 1) => {
  const sample = Array(k);
❶ for (let i = 0; i < k; i++) {
  ❷ sample[i] = arr[randomInt(from, to + 1)];
  }
  return sample;
};

逻辑很简单:首先循环 k 次 ❶,每次随机选择一个元素 ❷。与选择单个值时一样,要从整个数组中进行选择,代码更简单,而且你还可以复用前一部分的 singlePickAll 代码:

const repeatedPickAll = (arr, k) => {
  const sample = Array(k);
  for (let i = 0; i < k; i++) {
    sample[i] = singlePickAll(arr);
  }
  return sample;
};

对于一个相关的编程挑战,参见第 8.7 题。接下来,看看没有重复的抽样,它的限制是不允许你多次选择同一个元素,并且要高效地进行。

不带重复的抽样

这个过程等同于 Powerball 风格的彩票抽奖:数字从(但不返回到)一个抽签箱中被移除,保证所有被选中的数字都是不同的。

对于本节中的算法,假设你有一个包含n个元素的数组,你希望从中挑选出k个元素的组合。k必须小于n——如果k等于n,则无需任何算法,而且如果不允许重复,k不能大于n。当你希望的元素越少时,算法会越快,因此为了便于优化,你可以假设k >= n/2;事实上,如果kn/2,实际上你可以选择nk个元素并将其丢弃。

通过排序或洗牌进行抽样

第一个想法受到第 139 页“通过排序进行洗牌”部分的启发,并结合了在第七章中探讨的选择算法。你可以给所有元素分配随机键,将它们排序,然后获取具有最低k键的元素。你已经考虑了实现这种方法所需的所有代码,所以实际的开发留给问题 8.8 去解决。

你可以尝试的第二个想法是基于你已经知道如何生成一个集合的随机排列这一事实。鉴于此,生成样本的显而易见方法是洗牌该集合,然后取其前k个元素。这样也能实现,但你可以通过更高效的逻辑得到所需的样本,而不必进行洗牌(类似于你在选择算法中发现的,当你看到不需要先排序的更好的选择方法时)。你也不会看到这个过程的代码,因为它是从你已经做过的事情派生出来的。让我们转向新的算法。

弗洛伊德算法

通常,你只需要从 0(包括)到n(不包括)之间的k个整数样本。罗伯特·弗洛伊德的 floydSampleKofN(...)算法生成一个包含k个此类数字的数组,这本身就很有趣,并将帮助你编写一个更通用的抽样算法。如果你需要从原始数组中抽样,可以使用 floydSampleKofN()生成的选定数字,如图 8-6 所示。

图 8-6:从 0 到 n – 1 的随机数字选择生成一个随机样本。

你可以使用样本的值作为索引,从原始输入数组中选择值,如图 8-6 所示。输入数组位于顶部,弗洛伊德代码生成的样本是[5, 2, 3],最终结果用灰色标出。

这是使用(尚未展示的)floydSampleKofN()函数的代码:

const floydSample = (arr, k) =>
❶ floydSampleKofN(k, arr.length).map((v) => arr[v]);

你从n中生成一个k个值的随机组合❶,并使用这些数字作为索引,从原始输入数组中获取值。

让我们回到生成组合的部分,最后看看 floydSampleKofN()。递归版本如下,递归帮助你理解算法的工作原理:

❶ const floydSampleKofN = (k, n) => {
❷ if (k === 0) {
    return [];
  } else {
  ❸ const sample = floydSampleKofN(k - 1, n – 1);
  ❹ const j = randomInt(0, n);
    sample.push(sample.includes(j) ? n - 1 : j);
  ❺ return sample;
  }
};

你需要选择一个包含从 0 到 n - 1(包括 n - 1)之间 k 个不同值的组合 ❶。如果 k 为 0 ❷,则返回一个空样本。否则,首先使用递归选择一个包含 k - 1 个值的组合,范围是从 0 到 n - 2 ❸。然后决定要将哪个值添加到该样本中 ❹。最后 ❺,返回创建的样本。

现在,来看看如何将一个新值添加到样本中,假设从 8 个值中抽取 3 个样本,如图 8-6 所示。假设你已经从 0 到 6 的集合中选择了两个值:如果你要加入 7,来生成一个包含 3 个值的样本,应该如何操作?有一种可能性(1/8)是随机数 j 正好为 7。它不在先前的样本中,因此将被添加进去。

添加 7 的另一种方式是,如果 j 已经是样本中两个数字之一(2/8)。因此,7 最终被包含在 3 个值的样本中的概率是 1/8 + 2/8,恰好是 3/8,正是你需要的结果。你可以系统地应用这个论证,发现每个 n 值有 k/n 的概率被包含在最终样本中,因此该算法确实生成了一个正确的样本。

由于递归总是在每次循环开始时发生,你可以将代码转化为等效的迭代版本(见问题 8.9):

const floydSampleKofN = (k, n) => {
❶ const sample = [];
❷ for (let i = n - k; i <= n - 1; i++) {
  ❸ const j = randomInt(0, i + 1);
  ❹ sample.push(sample.includes(j) ? i : j);
  }
❺ return sample;
};

首先,创建一个数组来返回选择的样本 ❶,并在最后返回它 ❺。一个循环执行 k 次 ❷。每次循环中选择一个随机数 ❸,并使用相同的逻辑(检查随机选择的数字是否已经被选择过)来决定添加什么 ❹。证明这个算法正确性的论证与递归版本相同,因此这里不再赘述。

Floyd 算法的性能关键在于如何将一个值添加到样本中并检查给定值是否已经在样本中。换句话说,它需要一个高效的集合实现。你也可以使用位图,正如在第六章和第七章中所提到的(我们暂时先不讨论这一点,稍后在第十三章中会考虑这些选项)。

彩票抽奖

另一种方法是考虑实际模拟一次彩票抽奖。你选择集合中的一个随机元素,将它放到其他地方,然后一次又一次地进行,直到得到完整的样本。图 8-7 展示了这个过程。值的集合在左侧,选中的样本在右侧,三角形标记了每个阶段随机选择的元素。

图 8-7:一个模拟的彩票抽奖生成一个随机样本。

当你移除一个元素时,你将它与数组最后一个位置的元素交换,以避免必须移动整个数组,这样可以提高代码的性能。

这是一个简单的实现:

const lotterySample = (arr, k) => {
❶ const n = arr.length;
  const sample = Array(k);

❷ for (let i = 0; i < k; i++) {
  ❸ const j = randomInt(0, n – i);
  ❹ sample[i] = arr[j];
  ❺ arr[j] = arr[n - i – 1];
  }

❻ return sample;
};

首先创建一个将获取样本的数组 ❶。循环 k 次 ❷,在数组的前 n - i 个元素中生成一个随机位置 ❸,因为已经选中的元素将移到数组的末尾 ❹。将选中的元素添加到样本数组,并进行交换,这样它就不会在其他选择中再次被考虑 ❺。最后,返回生成的样本 ❻。

这个算法足够简单,并且它的O(k)性能无法再改进。毕竟,你想要一个包含 k 个元素的样本。然而,如果你注意到其实不需要一个单独的样本数组,你可以稍微提高一些速度。

Fisher-Yates 抽样

在之前的抽奖抽样算法中,每次数组的每个元素要么被选中,要么不被选中,所以你不需要两个数组,原始数组就可以。图 8-8 展示了这一思想;阴影部分的数字是被选中的元素。

图 8-8:抽奖抽样算法可以就地工作,无需额外的内存。

每次选择一个元素时,将其移动到数组的前面,这样它的所有前面元素都在样本中,其余的则是未选择的元素。这个算法是 Fisher-Yates 洗牌方法的一种变体(逻辑相同,但应用的次数较少,因为你不想随机化整个数组;只需要 k 个元素),你可以按如下方式编写代码:

const fisherYatesSample = (arr, k) => {
  const n = arr.length;
❶ for (let i = 0; i < k; i++) {
  ❷ const j = randomInt(i, n);
  ❸ [arr[i], arr[j]] = [arr[j], arr[i]];
  }
❹ return arr.slice(0, k);
};

在这个算法中,i 变量指向对应的样本元素。你循环 k 次 ❶,从尚未被选择的元素中随机选择一个位置 ❷,然后进行交换,将选中的元素从未选择的部分移到已选择部分 ❸。完成循环 ❹ 后,返回原数组的初始切片(长度为 k 的元素)。

Fisher-Yates 抽样算法也是 O(k);唯一的区别在于样本的存储位置。

Knuth 的算法

Donald Knuth 的算法有一个有趣的特点,即样本中的元素保持与原数组相同的相对顺序。该算法基于概率,直接证明了它的正确性。

为了理解它是如何工作的,假设你想从八个元素中选择三个。第一个元素被选中的概率是 3/8。第二个元素是否被选中取决于第一个元素是否被选中。如果第一个被选中了,第二个被选中的概率是 2/7(因为从八个中选中了一个,剩下七个中选择两个);如果第一个没被选中,第二个被选中的概率是 3/7(因为现在需要从剩下的七个中选择三个)。

该算法基于随机数和概率选择或跳过元素,具体如下:

const orderedSample = (arr, k) => {
❶ if (k === 0) {
   return [];
❷} else if (Math.random() < k / arr.length) {
   return [arr[0], . . .orderedSample(arr.slice(1), k - 1)];
❸} else {
   return [...orderedSample(arr.slice(1), k)];
 }
};

如果你想选择一个空样本 ❶,将返回一个空数组;这是递归的基本情况。否则,你会得到一个随机数,并与概率进行比较:如果它较小 ❷,你将包含数组中的第一个元素,并从剩下的元素中选择一个大小为 k – 1 的样本。如果它较大 ❸,你从剩下的数组中选择所有 k 个元素。

更好的实现方式避免了递归以及数组的拆解和切片,方法如下:

const orderedSample = (arr, k) => {
❶ const sample = [];
❷ let toSelect = k;
❸ let toConsider = arr.length;
❹ for (let i = 0; toSelect > 0; i++) {
    if (Math.random() < toSelect / toConsider) {
     ❺ sample.push(arr[i]);
      toSelect--;
    }
  ❻ toConsider--;
  }
  return sample;
};

如同其他算法一样,sample 是将要生成的数组 ❶。变量 toSelect ❷和 toConsider ❸将记录你还需要从未考虑的值中选择的数量。你将不断循环,直到没有更多的值可以选择 ❹。每次,你根据描述的概率方法决定是否选择或忽略一个值。如果测试结果为真 ❺,则将该值添加到样本数组中,并将待选值的数量减去 1。每次循环,我们都会减少待考虑值的数量 ❻。

你也可以用更简洁的方式写出来:

const orderedSample2 = (arr, k) => {
  const n = arr.length;
  const sample = [];
❶ for (let i = 0; k > 0; i++) {
  ❷ if (Math.random() < k / (n - i)) {
      sample.push(arr[i]);
    ❸ k--;
    }
  }
  return sample;
};

区别在于你将使用 k ❶代替 a toSelect 变量 ❸,并且你将计算仍然未见的值的数量 ❷;否则,算法是相同的。

水库抽样

我们将要考虑的最终算法是由 Alan Waterman 创建的,它的有趣之处在于它可以在在线模式下工作,无需事先获取整个元素数组。本章中所有其他算法都在离线模式下工作。该代码遍历输入数据,在任何时候都保持一个合适的随机样本;它可以随时停止,并且会有一个合适的随机样本,包含到目前为止看到的元素。这个算法非常适合处理大规模数据流,在这种情况下,可能无法将所有值存储在内存中,然后应用本章中考虑的任何一个先前算法。

首先考虑一个简单的情况:从一个未知长度的序列中选择一个元素。(如果你知道序列的长度,使用 randomInt(...)将是选择一个元素的最快方式。)这个问题的解决方法如下:

  • 选择序列中的第一个元素,并将其放入水库中。

  • 对于序列中第i个元素(从第二个元素开始),以 1/i的概率用它来替换水库中的值。

假设序列有 1000 个元素。那么选择最后一个元素的概率是多少?显然,它是 1/1000。如果你没有选择它,当你有 999 个元素时,选择第 999 个元素的概率是多少?它将是 1/999。随着越来越多的元素被处理,选择第i个元素的概率始终是 1/i

你可以扩展这个例子来选择k个元素的样本;过程非常相似:

  • 选择序列中的前k个元素,并将它们放入水库中。

  • 对于序列中第i个元素(在前k个元素之后),以k/i的概率将其添加到储存区值中,通过替换一个随机选择的储存区值。

你可以按以下方式编写代码:

const reservoirSample = (arr, k) => {
❶ const n = arr.length;
❷ const sample = arr.slice(0, k);

❸ for (let i = k; i < n; i++) {
  ❹ const j = randomInt(0, i + 1);
    if (j < k) {
    ❺ sample[j] = arr[i];
    }
  }
  return sample;
};

我们不会使用流,但对此进行修改是很直接的。在这里,要知道何时序列结束,你将使用变量 n ❶,并且样本储存区初始化为序列的前 k 个元素 ❷。你将循环遍历数据 ❸,并进行随机测试 ❹,以判断某个数字是否应该进入数组;变量 j 既用于测试,也用于随机决定替换储存区中的哪个元素 ❺。

如果你修改输入数组(使用其前k个位置作为储存区),算法看起来是这样的:

const reservoirSample2 = (arr, k) => {
  const n = arr.length;
  for (let i = k; i < n; i++) {
    const j = randomInt(0, i + 1);
    if (j < k) {
    ❶ [arr[i], arr[j]] = [arr[j], arr[i]];
    }
  }
❷ return arr.slice(0, k);
};

区别在于你如何将选定的值交换到储存区 ❶ 和如何返回选定的样本 ❷;除此之外,其余功能完全相同。

总结

在本章中,我们已经考虑了生成数组的随机排列和组合的算法,这些方法在多个领域非常有用,如游戏或统计学等。在下一章中,我们将转向另一个常见且重要的任务:高效地搜索一个值。

问题

8.1  足够好的洗牌

实现一个日志记录函数,接受一个洗牌函数作为输入并运行多次测试,统计每种可能的排列出现的次数,然后绘制直方图以可视化其结果。

图 8-9 显示了我自己测试的结果,显示四个元素的数组洗牌效果良好。

图 8-9:一个直方图,显示某个洗牌算法以相似的频率生成所有可能的结果

在 48,000 次随机尝试后,所有排列(24 = 4!)都被生成出来,结果看起来足够相似。尽管这一说法从统计学角度并不完全有效;需要进行χ²(即希腊字母“chi”)拟合优度检验才能确认这一点。

8.2  随机投掷

假设你需要生成一个均匀的随机三选项:不是是/否,而是高/中/低。使用 Math.random()很容易实现,如 randomNum(...)函数所示,但你能仅使用 randomBit()来做吗?类似地,你如何使用 randomBit()生成一个均匀的骰子投掷(1-6)?或者在龙与地下城类型的游戏中进行 1 到 20 的投掷?(最后一个问题更复杂。)

8.3  不那么随机的洗牌

在阅读完随机洗牌的描述后,某个程序员决定让它更简单:不再麻烦地分配随机键并按键排序,而是直接使用排序算法(此处为冒泡排序),并将键之间的比较改为使用一个随机位:

const naiveSortShuffle = (arr) => {
  for (let j = arr.length - 1; j > 0; j--) {
    for (let i = 0; i < j; i++) {
    ❶ if (**randomBit()**) {
        [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
      }
    }
  }
  return arr;
};

逻辑类似于冒泡排序(参见第六章),但有一个变化 ❶。为什么这不是一个好的洗牌生成器?程序员哪里出错了?

8.4  糟糕的交换洗牌

一位开发者在实现 Fisher-Yates 洗牌代码时出错,写出了如下代码,乍看之下似乎足够好:

const naiveSwappingShuffle = (arr) => {
  const n = arr.length;
  for (let i = 0; i < n; i++) {
    **const j = randomInt(0, n);**
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
};

区别在于加粗的那一行。你总是从完整的数组中选择一个随机位置。这个代码有什么问题?

8.5  罗布森的顶端?

使用罗布森算法,最大可以洗牌的数组长度是多少?小心,这个问题的答案有点复杂。

8.6  采样测试

你能开发一个视觉验证采样函数的工具吗,类似于问题 8.1 中所要求的内容?

8.7  单行重复器

本章草稿的审阅者提到,像第 147 页“带重复选择若干值”部分所展示的 repeatedPick(...),可以写成一行,只需一个语句。那会是什么?

8.8  排序采样

实现“通过排序或洗牌进行采样”部分在第 148 页中描述的算法。

8.9  迭代,而非递归

一个类似于以下递归函数的代码:

const something = (p) => (p === 0 ? BASE : other(something(p - 1), p));

可以等效地以迭代方式写成如下:

const something = (p) => {
  let result = BASE;
  for (let i = 1; i <= p; i++) {
    result = other(result, i);
  }
  return result;
};

解释一下为什么这个方法可行。还有,尝试将此转换应用到 第五章 的 factorial(...) 函数,并将其调整到 Floyd 的 sampleKofN(...) 算法(这会更复杂),以验证文中所示内容。

8.10  没有限制?

在 Knuth 的示例代码中,没有检查 i 是否超出边界;为什么不需要做这一步?

const orderedSample = (arr, k) => {
  const n = arr.length;
  const sample = [];
  let toSelect = k;
  let toConsider = n;
  for (**let i = 0; toSelect > 0; i++**) {
    if (Math.random() < toSelect / toConsider) {
      sample.push(arr[i]);
      toSelect--;
    }
    toConsider--;
  }
  return sample;
};

第九章:9 搜索

本章讨论的是一个常见问题:给定一组值,查找某个特定的键是否在这组值中。这个定义与我们将在未来章节中实现字典抽象数据类型(ADT)时所探讨的逻辑有相似之处,但我们这里只关注搜索部分,不涉及添加或删除键。此外,我们只讨论数组,其他数据结构将在未来章节中探讨。

搜索定义

本章中的所有情况中,待解决的问题是:给定一个数组(有序或无序,可能包含重复值)和一个键,你需要知道在数组的哪个位置可以找到该键。如果数组中没有该键,你将返回 -1,以匹配 JavaScript 的一些内置方法。

作为一个进一步的可选要求,有时你可能需要在数组中查找某个键的第一次(或最后一次)出现(如果数组中有重复的键),或者如果数组中不包含某个键,你可能希望知道它应该位于哪个位置。

重要的是要记住你是进行一次搜索还是多次搜索。如果是后者,你可以随着时间的推移摊销某些操作的成本,比如排序数据或构建其他数据结构。如果是前者,你只需要最快的搜索速度。(在后续章节中,你将探讨一些数据结构示例,帮助加快搜索速度。)

对于有序数组,有更高效的算法;我们将从无序数组开始,然后转向性能更好的算法。

搜索无序数组

你将考虑的第一组算法在无序数组中执行线性搜索,这是最基础的(这些也被称为顺序或串行搜索)。如果数组没有任何顺序,唯一的搜索方法就是从开始遍历整个数组。这种搜索显然比较慢,时间复杂度为 O(n),但对于小数组来说,还是相当合理的。此外,JavaScript 本身也有用于这种搜索的方法,所以如果问题的条件允许,接下来一节中的函数可能是最好的选择。

JavaScript 的方法

为了查找某个键是否在数组中及其位置,JavaScript 提供了几个有趣的函数。如果你只想知道键是否存在,可以使用 array.includes(key) 方法,该方法根据键是否被找到返回 true 或 false。如果你需要知道键在数组中的位置(在本章中你将始终需要这个),那么 array.indexOf(key) 就能完成这个任务。它返回键第一次出现的位置,如果没有找到,则返回 -1。

这些方法都在 O(n) 时间内执行,并且它们从数组的开始遍历到末尾。这个性能与接下来你将考虑的线性搜索相匹配。

线性搜索

线性搜索算法基本上是 JavaScript 自带的 .indexOf(...) 方法的实现。它通过遍历整个数组来检查是否找到你需要的值。

图 9-1 展示了两次搜索:一次是成功找到 60,另一次是未能找到 50。

图 9-1:线性搜索从数组开始位置开始,直到找到目标值或到达数组末尾。

该过程从头开始,直到找到目标值或到达数组末尾为止。这种算法通常在早期就教授给未来的开发者,作为循环的基本示例。以下是实现代码:

const linearSearch = (arr, key) => {
  const n = arr.length;
❶ for (let i = 0; i < n; i++) {
  ❷ if (arr[i] === key) {
      return i;
    }
  }
❸ return -1;
};

遍历数组 ❶,如果找到目标值 ❷,返回它的位置。如果循环结束仍未找到目标值 ❸,则按定义返回 -1。

这种算法在最坏情况下的性能是 O(n),而对于成功的搜索,平均需要进行 n/2 次探测,因此结果仍然是 O(n)。在无序数组中,无法显著加速搜索,但一个小技巧可能稍微有点帮助:使用哨兵。

带有哨兵的线性搜索

在搜索目标值之前,先将该值添加到数组末尾,这样搜索就能确保成功。图 9-2 展示了与前面相同的两次搜索:第一次成功,因为在数组末尾之前找到了 60,而第二次失败,因为只找到了添加的 50,而这个值最初并不在数组中。

图 9-2:添加哨兵后,可以在不越过数组末尾的情况下继续搜索。

你可以在不检查数组结束位置的情况下遍历数组,因为你确定最终会找到目标值。现在唯一需要考虑的是在哪里找到它:如果它出现在数组末尾,你只找到了添加的哨兵值,搜索是失败的。逻辑如下:

const linearSearch = (arr, key) => {
  const n = arr.length;
❶ arr[n] = key;
❷ let i = 0;
❸ while (arr[i] !== key) {
    i++;
  }
❹ arr.length = n;
❺ return i === n ? -1 : i;
};

先将要搜索的值添加到数组末尾 ❶。然后像之前一样从第一个位置开始搜索 ❷,直到找到目标值 ❸。你不需要检查数组的末尾,因为你知道一定会找到目标值。找到目标值后,恢复数组 ❹(只需重新赋值数组的长度即可,虽然 .pop(...) 方法可能更常用),然后根据找到目标值的位置决定返回什么 ❺。如果目标值在末尾,则是失败;如果目标值在之前的位置,则是成功。

这种算法的性能仍然是 O(n);唯一(轻微的)优势可能来自于循环中更简单的检查,使得迭代速度更快,但不要指望这个会产生大影响。此外,如果添加哨兵导致 JavaScript 创建一个新数组并复制原数组,那么算法的速度可能会更慢。

当一切都说完后,线性搜索的提升空间非常有限,而且在无序数组中已经达到了极限。接下来你将开始在有序(排序)数组中进行搜索,针对有序数组,可以使用更优的算法。### 搜索有序数组

如果要搜索的数组是有序的,你可以应用更好的技术。例如,如果你知道数组中某个位置的值大于你要查找的关键值,你可以立即舍弃该位置之后的所有值,因为关键值不可能在这些位置。本文节中的所有非线性搜索算法(也称为区间搜索)都利用了数组的顺序,要么加速数组的搜索,要么舍弃其中的大部分,从而减少需要搜索的区域。

跳跃搜索

前面描述的基本线性搜索可能会遍历整个数组,这使得其性能为O(n),且无法进一步优化。然而,如果数组是有序的,你就不需要逐一遍历它。就像当有人在寻找书中的某一页时,他们不会一页一页翻,而是会一次跳过几页,靠近目标时再逐页查找。

跳跃搜索算法的思路与 Shell 排序相似(见第六章)。你首先进行大跳跃,快速接近目标位置,然后再进行小跳跃。图 9-3 展示了我们如何搜索 42,假设初始跳跃大小为 4。(我们稍后会讨论跳跃大小应该是多少。)

图 9-3:跳跃搜索通过尽可能大幅度地跳跃来加速查找过程。

在顶部,你进行线性搜索,但采用大跳跃,每次跳过四个值。你找到的第一个值是 04,太小了,所以你再次跳跃。接着你找到了 14 和 24,但它们依然太小。下一次跳跃让你到达 49,现在你知道 42(如果存在的话)在 24 之后、49 之前。然后你开始进行常规的线性搜索,短跳跃,每次推进一个位置。你检查了 34 和 40,并成功找到了 42。如果你当时在寻找 41,那么当你到达 42 时,你会判断 41 不存在并返回–1。

这个算法的预期测试次数是多少?如果步长是s,数组大小是n,你可以进行最多n/s次大跳跃,接着是s次小跳跃,总共是n/s + s次。通过一些微积分证明,当s为√n时,这是最优解,最多进行 2√n次测试,所以我们将使用这个值,如以下实现所示:

const jumpSearch = (arr, key) => {
  const n = arr.length;
❶ const s = Math.max(2, Math.floor(Math.sqrt(n)));
❷ let i = 0;
❸ while (i + s < n && key >= arr[i + s]) {
    i += s;
  }
❹ while (i + 1 < n && key >= arr[i + 1]) {
    i++;
  }
❺ return i < n && key === arr[i] ? i : -1;
};

首先确定长跳跃的大小 ❶,确保它至少为 2。(对于非常短的数组,跳跃大小只能为 1。)i 变量遍历数组 ❷。每次跳跃 s 个位置 ❸。如果没有越过数组的末尾且测试的数组值不大于你正在寻找的目标,则可以通过更新 i 进行跳跃。在一系列跳跃之后,i 指向的值不大于目标值,然后进入新的循环,按 1 前进 ❹。这个循环结束后 ❺,如果找到目标,则返回其位置;否则返回 -1。

代码中有两个相似的 while 循环。在其中一个情况下,你按 s 跳跃,在另一个情况下,按 1 跳跃。

考虑另一种实现方式,它提出了一种更为优化的解决方案。首先,这是代码:

const jumpSearch = (arr, key) => {
  const n = arr.length;
❶ let s = Math.max(2, Math.floor(Math.sqrt(n)));
  let i = 0;
❷ while (s > 0) {
  ❸ while (i + s < n && key >= arr[i + s]) {
      i += s;
    }
  ❹ s = s > 1 ? 1 : 0;
  }
❺ return i < n && key === arr[i] ? i : -1;
};

现在你不再为 s 使用常量 ❶,因为你会在第二个循环中更改它的值。根据 s 设置外部循环 ❷。当 s 为 0 时,循环结束。内部循环与之前相同,每次跳跃 s 个位置 ❸,但随后 s 会变为 1,进行短跳跃。完成一个循环后 ❹,减少 s。如果 s 大于 1,则按 1 跳跃;如果 s 已为 1,则结束循环,将 s 设置为 0。决定返回值 ❺ 与之前的版本相同。

你分两阶段完成了搜索:先进行长跳跃,再进行短跳跃,从而将搜索次数降低到 O(√n)。如果你有三个阶段,首先进行非常长的跳跃,然后是较长的跳跃,最后用较短的跳跃结束,会怎样呢?在每一阶段,跳跃的长度都相对上一阶段更小。(我们将在第十一章的跳表中重新审视这种跳跃逐渐减小的概念。)图 9-4 展示了在 27 个元素的数组中使用三层跳跃搜索值的工作原理。

图 9-4:三层跳跃搜索

第一次跳跃间隔为九个元素。找到目标所在的九个元素块后,开始进行每次间隔三个元素的跳跃;灰色区域表示目标无法出现在这些位置。之后,当你找出目标所在的三元素块后(这时可以将更多位置标记为灰色),最后进行单元素的跳跃。在这种情况下,平均需要进行 4.5 次测试才能找到目标,最多需要 9 次测试。

你可以这样编码:

const jumpSearch = (arr, key, levels = 3) => {
  const n = arr.length;
❶ const b = Math.max(2, Math.floor(arr.length ** (1 / levels)));
❷ let s = Math.floor(n / b);
❸ let i = 0;
❹ while (s > 0) {
    while (i + s < n && key >= arr[i + s]) {
      i += s;
    }
  ❺ s = Math.floor(s / b);
  }
 ❻ return i < n && key === arr[i] ? i : -1;
};

首先定义每个级别的块数 b ❶,并且(如同之前算法中的跳跃大小)你希望每个级别至少有两个块。然后设置初始的(最长)跳跃大小 ❷。接下来按照之前的方式按层次进行搜索:从头开始 ❸,并继续搜索直到跳跃次数变为 0 ❹。不同之处在于如何减少跳跃大小 ❺,每次使其变为原来的 b 倍小。(因为 b > 1,s 最终会变为 0,外部循环结束。)最终返回 ❻ 与其他版本的跳跃搜索相同。

可以证明,如果你选择每次跳跃的步长为 ³√n,这个方案的时间复杂度将为 O(³√n),所以算法效果更佳。你可以继续添加更多的层级(当然,这只有在数组非常大的情况下才有意义),将算法的时间复杂度提高到 O(⁴√n),然后是 O(⁵√n),依此类推。(问题 9.3 展示了你可以走多远。)

我们已经成功将搜索算法的速度从 O(n) 提升到了 O(^pn),前提是我们在 p 层次上进行搜索。让我们尝试一种不同的方法,看看能否做得更好。

二分查找

现在尝试应用分治法来创建一个搜索算法:给定一个要搜索的数组,检查它的中心值。如果它就是你想要的值,那就完成了。如果中心值大于你想要的值,你可以丢弃数组的右半部分,并递归地搜索左半部分。类似地,如果中心值小于你想要的值,丢弃左半部分并搜索右半部分。如果在某一时刻你需要搜索一个空数组,你就知道这个值不在其中。图 9-5 演示了该过程,其中搜索值为 18。

图 9-5:二分查找在每次迭代时都会将要搜索的数组一分为二。

在 图 9-5 中,带有 m 的三角形指向数组的中间元素。最初,中间元素是 22,所以 18(如果存在)必须位于左侧;你可以丢弃其他部分。第二行显示了你如何继续:中间元素是 12,所以你继续搜索右半部分。第三行你成功了,因为中间元素正是你想要的。你可以按如下方式编写这个方法:

const binarySearch = (arr, key, l = 0, r = arr.length - 1) => {
❶ if (l > r) {
    return -1;
  } else {
  ❷ const m = (l + r) >> 1;
  ❸ if (arr[m] === key) {
      return m;
  ❹} else if (arr[m] > key) {
      return binarySearch(arr, key, l, m - 1);
  ❺} else {
      return binarySearch(arr, key, m + 1, r);
    }
  }
};

如果在任何时候要搜索的区间为空 ❶,则表示搜索失败。如果不为空 ❷,计算区间的中间值。使用右移 >> 运算符是一种优雅且简洁的方式来实现这一点,而不是使用更常见的 Math.floor((l+r)/2)。如果中间值就是你要寻找的关键值 ❸,那么你就完成了。如果中间值大于你想要的值 ❹,则搜索数组的左半部分;否则,搜索右半部分 ❺。

由于这里的所有递归都是尾递归,你可以轻松地将这个方法转换为一个等效的迭代方法。在 图 9-6 中,l 和 r(左和右)三角形显示了你正在搜索的数组部分,而 m(三角形中点)表示该部分的中间点。再次提醒,搜索值是 18。

图 9-6:该算法的迭代版本使用两个指针(l 和 r)来跟踪你正在搜索的数组部分。

被灰色标出的值将在算法中不再考虑。根据中间值与目标值的比较结果,你更新 l 或 r,并继续循环,直到成功或失败。

如何判断搜索失败?如果你是在搜索 17,搜索过程将会像图 9-7 所示那样继续。

图 9-7:如果 l 和 r 指针变得“交叉”,可以得出搜索失败的结论。

当搜索失败时,l 和 r 指针会变得“交叉”,这意味着该值不在数组中。(缺失的值 17 应该位于 l 左边和 r 右边。)

你可以按如下方式实现该算法:

const binarySearch = (arr, key, l = 0, r = arr.length - 1) => {
❶ while (l <= r) {
  ❷ const m = (l + r) >> 1;
  ❸ if (arr[m] === key) {
      return m;
  ❹} else if (arr[m] > key) {
      r = m - 1;
 ❺} else {
      l = m + 1;
    }
  }
❻ return -1;
};

只要 l 和 r 指针没有交叉,继续搜索 ❶。你像递归二分搜索那样计算中间值 m ❷。如果中间值等于你想要的键 ❸,那就完成了。如果中间值大于你想要的值 ❹,更新右指针 r,继续在左侧部分查找;否则,更新 l 指针,改在右侧部分查找 ❺。如果循环结束仍未找到目标键 ❻,返回 -1,表示失败。

这种方法的性能如何?我们之前已经看过类似的分析,它应该让你联想到快速排序等算法。每一步都会将搜索数组的大小减半,因此二分搜索的时间复杂度是 O(log n),这是对你之前看到的所有算法的一个很好的改进。(对于偏数学的读者,问题 9.4 会计算出实际的平均测试次数。)让我们考虑另一种显示相似性能的算法,它实际上使用了二分搜索。

指数搜索

指数搜索(也叫做 加倍搜索奔腾搜索)是两种方法的结合:首先,你需要确定在数组的哪个范围内查找目标键,然后再应用二分搜索来完成搜索。在第一步中,你需要在数组中找到一个大于你想找的键的值,然后你先测试位置 1 的值;接着是位置 2 的值;然后是位置 4、8、16 的值,依此类推,每次都加倍,直到你决定在哪里继续搜索。图 9-8 展示了该算法如何查找 42。

图 9-8:指数搜索结合了越来越长的跳跃和二分搜索。

首先,进行加倍跳跃(大小为 1、2、4 等),直到找到包含 42 的部分(如果存在的话)。(如果你在寻找 22,跳跃就会在查看位置 8 的元素后结束,例如。)找到搜索区间后,二分搜索完成剩余的查找过程。

以下是代码:

const exponentialSearch = (arr, key) => {
  const n = arr.length;
❶ let i = 1;
❷ while (i < n && arr[i] < key) {
  ❸ i <<= 1;
  }
❹ return binarySearch(arr, key, (i >> 1), Math.min(i, n - 1));
};

首先初始化跳跃系列为 1 ❶,尽管你还没有到达数组的末尾,也没有找到比你想要的关键值更大的值 ❷,但是将跳跃大小翻倍 ❸,然后再次循环。如果这行代码看起来很奇怪,你也可以写成 i = i << 1,左移运算符 <<(你已经在二分查找中使用过右移运算符)使得它等同于 i = i * 2。完成所有必要的跳跃 ❹ 后,进行二分查找,再次使用移位运算符将 i 除以 2。

这个算法的时间复杂度是多少?我们从最坏的情况开始,假设你在一个最多有 2^p个元素的数组中查找最后一个值。第一次循环将执行p次,接下来会在一个小于 2p*(−1)大小的数组中进行二分查找:这也属于O(p)。由于p大约等于 log n,最坏情况下总的性能是O(log n),但是如果目标元素更靠近数组的前面,性能会更好。实际上,如果关键值位于位置k,查找将是O*(log k)。

插值查找

当在字典中查找一个单词时,无论你多么熟练于二分查找,如果你要查找一个以字母S开头的单词,你会翻到字典的后半部分,但如果你要查找一个以B开头的单词,你会翻到书的前半部分。你可以将这个想法应用到有序数组的查找中,如果你能进行插值并估算一个给定值应该所在的位置,前提是数组中的值大致均匀分布。

但首先做一些数学运算。如果左侧位置l的值是L,右侧位置r的值是R(且R > L),则与值V对应的位置可以通过以下公式计算:l + (r – l)(V – L)/(R – L)。我们可以验证一下这个公式。如果V等于L,公式的结果是l,这就是正确的。类似地,如果V等于R,公式的结果是r,这同样是正确的。如果你在查找一个值可以转化为数字的数组,可以应用这种插值方法,更快速地找到你想要的值。

看看实际应用中的情况。图 9-9 展示了查找值 34 的过程。

图 9-9:插值查找尝试估算目标值的位置,以便更快地找到它。

首先,左侧值(位置 0)是 4,右侧值(位置 14)是 60,因此估算 34 应该在位置 7 左右。(参见图 9-10,虚线连接了最小值和最大值,它与水平线在高度 34 处的交点位于 7 和 8 之间。)由于该位置的值小于 34,移动左指针到位置 8。然后,用 8 位置的 24 和 14 位置的 60 重新估算,得出 34 应该在 10 的位置。该位置的值大于 34,因此现在将右指针移动到 9。第三次迭代成功,34 的估算位置是(并且实际位置是)9。

图 9-10:估算 42 的位置,假设 4 是数组的起始位置,60 是数组的结束位置

你可以直接实现这个方法:

const interpolationSearch = (arr, key) => {
❶ let l = 0;
   let r = arr.length - 1;
❷ while (l <= r) {
  ❸ const m =
      arr[l] === arr[r]
        ? l
        : Math.round(l + ((r - l) * (key - arr[l])) / (arr[r] - arr[l]));

 ❹ if (m < l || m > r) {
      return -1;
  ❺} else if (arr[m] === key) {
      return m;
  ❻} else if (arr[m] > key) {
      r = m - 1;
  ❼} else /* arr[m] < key */ {
      l = m + 1;
    }
  }

❽ return -1;
};

从设置变量 l 和 r 开始,将它们指向查找范围的极端位置 ❶,就像在二分查找中一样,循环 ❷ 与该算法相同。不同的是,计算 m 时不再使用 l 和 r 的中点 ❸,而是使用插值公式,但要检查极端位置的值是否相等,因为那样的话就会除以零。如果 m 超出了 l 到 r 的区间,那么你要找的值不在数组中(因为你要找的键必须小于 l 处的值或大于 r 处的值),这时返回 -1 ❹。另一方面,如果 m 在 l 和 r 之间(包括两者),就比较它的位置上的值和目标键。如果值相等,表示成功 ❺。如果值更大,向左查找 ❻,如果值更小,向右查找 ❼。如果没有找到目标键,返回 -1 ❽。

这种方法表现良好,但也有几个缺点。首先,你必须使用数字键(如示例中的情况)或可以转换为数字的键来执行插值(一个可能的方案是将字符转换为其 ASCII 或 Unicode 值)。为了能够在更通用的键上使用插值查找,需要解决这个问题。

第二个可能的缺点与算法的性能有关。与每一步都将查找范围减半的二分查找不同,插值法可能表现不佳(快速排序也有类似表现),其性能可能是 O(n)。 (一个可能的性能差的情形是,如果数组中的值呈几何级数增长,那么线性插值将无法产生好的估算;然而,这种分布实际上不太可能出现。)另一方面,如果值均匀分布,可以证明(你不会在这里看到)性能将是 O(log log n),这是一个很大的改进。

总结

在本章中,我们考虑了用于查找有序或无序值数组的算法,这是一个常见的功能。这些方法的性能各异,且其中一些方法基于之前的方法,以便展示有趣的算法开发技巧。

本章结束了本书的第二部分。在第三部分,我们将开始探索数据结构,从列表开始,列表是一种重要的动态结构,具有广泛的用途。

问题

9.1  正确查找?

实现一个框架来测试给定的查找函数,并查看它是否能在数组的每个元素以及缺失元素上都正常工作。我在本章的所有代码中使用了这样的测试,并发现了一些 bug!

9.2  JavaScript 自带的

你能否用其他可用的数组方法实现一个替代 JavaScript 自带的 array.indexOf(...)?

9.3  无限跳跃层次?

在广义的跳跃搜索算法中,如果你想在无限多个级别上进行搜索,会发生什么?(提示:假设级别是无限大的,看看算法是如何表现的。)最终的算法是什么样的?

9.4 恰好多少?

这个问题适合数学思维较强的人。计算一次成功搜索的实际平均测试次数。假设数组的长度是 2^n – 1,可能会有所帮助。在这种情况下,1 个元素只需要 1 次提问,2 个元素需要 2 次提问,4 个元素需要 3 次,8 个元素需要 4 次,依此类推,直到 2^n ^(− 1) 个元素在 n 次提问中找到。

9.5 三个顶部两个?

受二分查找的启发,二分查找通过比较将数组分成两部分来进行搜索,你可以考虑三分查找,通过比较将数组分成三部分,这样你就可以处理更小的子数组。这与二分查找相比如何?三分查找真的有改进吗?

9.6 二进制先行

假设排序后的输入数组可能包含重复值,修改二分查找算法,使其返回搜索键在数组中的第一个位置。如果你想找到最后一个位置,应该做哪些修改?

9.7 更快计数

给定一个排序的输入数组和一个键,你可以通过编写类似 count = arr.filter(x => x === key).length 的代码来找出该键出现的次数,但这会以 O(n) 的时间复杂度运行。你能在 O(log n) 的时间复杂度内找到这个计数吗?

9.8 旋转查找

假设你有一个原本是排序的数组,但后来可能被旋转了:例如 [4, 9, 12, 22, 34, 56, 60] 可能变成了 [34, 56, 60, 4, 9, 12, 22]。写一个函数,确定旋转数组中最小值的位置。例如,在这个例子中,函数应该返回 3。确保你的函数也能在数组没有被旋转的情况下正常工作。

9.9 特殊的第一个?

我发现了一些指数查找的实现,特别是在开始任何循环之前,先检查数组的第一个位置是否有目标键。书中的版本需要这样做吗?

第三部分 数据结构

在本书的第三部分,我们将探讨各种数据结构、它们可以应用的问题以及我们需要的相应算法。

第十章:10 列表

|

在前面的章节中,我们探讨了执行几个通用任务的算法,本章将研究用于特定目标的数据结构,从最基本的开始:元素列表。列表非常简单,但列表背后的概念在许多其他结构中都有体现,正如你将在本书的其余部分中学到的那样。事实上,列表处于仍广泛使用的最古老语言的中心:1959 年创建的 LISP 的缩写代表“列表处理”。 |

什么是列表?一个简单的定义是,列表是元素(或值,或节点)的序列,这意味着列表中有第一个元素,并且每个元素(除最后一个外)后面都跟着另一个元素。另一种递归的定义是,列表要么为空(没有元素),要么由一个特定元素组成,称为列表的头部,头部后面跟着尾部——尾部又是一个列表。 |

我们将从定义列表的基本抽象数据类型(ADT)开始,并探讨如何用几种方式来实现它。(请参见表 10-1 了解所有操作。)然而,ADT 有一些更重要的变体,我们还会考虑这些变体,进而实现其他结构,如栈、队列、双端队列等。 |

表 10-1:列表的基本操作 |

操作 签名 描述
创建 → L 创建一个新列表。
是否为空? L → 布尔值 判断列表是否为空。
大小 L → 数字 统计列表中有多少个元素。
添加 L × 位置 x 值 → L 在列表的指定位置添加一个值。
移除 L × 位置 → L 从列表中移除某个位置的值。
在指定位置 L × 位置 → 值 | 未定义 给定位置,返回该位置的值。
查找 L × 值 → 布尔值 给定一个值,查找它是否存在于列表中。

对于某些类型的列表,如栈、队列或双端队列,我们会替换表 10-2 中的某些函数(可能有不同的名称)来代替添加、移除和指定位置操作。我们还可能会删除其他一些操作,但会根据情况考虑。例如,除了可以在列表中的任何位置添加元素外,我们可能只希望限制只能在列表的前面或后面添加新元素。 |

表 10-2:列表的额外操作 |

操作 签名 描述
在前添加 L × 值 → L 在列表前面添加一个新值。
在后添加 L × 值 → L 在列表末尾添加一个新值。
从前移除 L → 值 | 未定义 从列表前面移除一个值。
从后移除 L → 值 | 未定义 从列表末尾移除一个值。
在前 L → 值 | 未定义 获取列表前面的值。
获取最后一个元素 L → 值 | 未定义 获取列表最后一个元素的值。

最后,我们还可以使用列表来表示其他 ADT,例如集合或映射(参见 第十一章)。

基本列表

我们从最基本的列表实现开始,这可能对于许多应用来说已经足够,然后再转向动态内存版本,它能够处理更复杂的情况和结构。

使用数组实现列表

由于 JavaScript 实现了动态数组,这些数组可以根据需要变大或变小,因此使用数组来表示列表似乎是合乎逻辑的,对于大多数应用来说,确实如此。然而,扩展数组通常需要将整个数组移到新的、更大的内存空间中,因此操作可能不会那么即时。(JavaScript 如何分配数组的内存空间其内部细节并不明确,但如果不断添加元素,JavaScript 到某个时候会用尽空间,必须为数组分配更多空间并将其移动到其他地方。)显然,对于小型短列表,您可能无法察觉到影响,但对于大型结构,它可能会变得显著。

您可以通过以下方式利用现有的 JavaScript 方法,最小化代码行数,来实现 ADT 的所有操作。create 被重命名为 newList,使其功能更加明确,Empty? 被重命名为 isEmpty,这是因为 JavaScript 的命名规则。

❶ const newList = () => [];

❷ const size = (list) => list.length;

❸ const isEmpty = (list) => size(list) === 0;

❹ const add = (list, position, value) => {
  list.splice(list, position, value);
  return list;
};

❺ const remove = (list, position) => {
  list.splice(list, position);
  return list;
};

❻ const at = (list, position) => list[position];

❼ const find = (list, value) => list.includes(value);

创建新列表 ❶ 只需生成一个空数组。列表的大小是数组的长度 ❷,要检查列表是否为空,可以测试其大小是否为 0 ❸。在给定位置添加一个元素 ❹ 完美适用于 splice(...) 标准方法,该方法也用于删除元素 ❺。最后,访问给定位置的元素 ❻ 是微不足道的。(JavaScript 的最新版本提供了 .at(...) 方法,这与这里定义的方法有所不同,因为它允许使用负索引;请参见 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at。)最后,使用 .includes(...) 方法检查列表是否包含某个值 ❼。

表 10-3 显示了这些操作的性能。

表 10-3:基于数组的列表操作性能

操作 性能
创建 O(1)
Empty? O(1)
大小 O(1)
添加 O(n)
删除 O(n)
获取 O(1)
查找 O(n)

创建新列表、检查它是否为空、获取其大小以及访问给定位置的元素都是 O(1) 操作。正如预期的那样,查找一个值是 O(n),因为该操作需要遍历整个列表。另一方面,添加和移除元素是 O(n) 操作,因为它们基本上是将整个数组移到内存中的不同位置。如果您动态实现列表,这些结果会发生变化。

使用动态内存实现链表

支持动态内存的语言提供了一种处理变长链表的不同方式:通过指针。你可以在另一个对象中包含对某个对象的引用,代码大致如下:

const first = {
  name: "George",
  next: null,
};

const second = {
  name: "John",
  next: null,
};

const third = {
  name: "Thomas",
  next: null,
};

first.next = second;
second.next = third;

仅通过指向第一个对象的指针,你可以列出下一个对象的名称,例如,first.next.name;然后 first.next.next.name 将列出第三个对象的名称。所有这些都是标准的 JavaScript 语法。最后一个对象的 next 属性为 null 值,表示链表中没有下一个对象。

图 10-1 使用箭头表示指针,用以圆圈结尾的线表示空指针。当然,你不局限于在一个节点中只有一个指针;你可以根据需要拥有多个指针。让我们从一个简单的案例开始:图 10-1 展示了一个包含六个元素的链表示例,其中 first 指向链表头。

图 10-1:一个简单的链表

添加新元素需要更改指针。例如,图 10-2 展示了在 60 后添加 80。

图 10-2:向链表添加新元素只需要更改一个节点中的指针。

移除一个元素时也会得到相同的结果;你只需要改变一个指针——通常是前一个元素的指针,或者如果移除的是链表头,则是头指针。在下一个示例中,我们将移除 60(见图 10-3)。

图 10-3:从链表中移除元素同样只需要改变一个指针。

添加和移除元素本身是O(1)操作。(当然,这假设你已经知道在哪里进行更改,并且知道其他元素指向你想要移除的元素。)让我们考虑一下所有可能操作的实现代码。

创建一个链表

链表只是一个对象,它可能包含指向另一个对象的链接,依此类推。空链表是一个空指针。考虑到这一点,创建一个新的空链表非常简单,检查链表是否为空或计算链表的大小也同样简单:

❶ const newList = () => null;

❷ const isEmpty = (list) => list === null;

❸ const size = (list) => (isEmpty(list) ? 0 : 1 + size(list.next)); 

创建一个链表会产生一个最终指向链表头的空指针❶。检查链表是否为空❷意味着查看指针是否为空。最后,通过递归计算链表的大小非常简单:空链表的大小为 0,非空链表的大小为 1(即链表头)加上链表尾部的大小❸。

添加一个值

要确定链表的节点,可以使用包含值(一个键或你想添加到链表的任何内容)和指向链表下一个元素的指针(ptr)作为对象:

const add = (list, position, value) => {
❶ if (isEmpty(list) || position === 0) {
  ❷ list = {value, next: list};
  } else {
  ❸ list.next = add(list.next, position - 1, value);
  }
❹ return list;
};

add(...)递归函数获取一个指向列表的指针以及要添加新值的位置。如果指针为 null,或者位置为零 ❶,新节点将插入到列表的开头 ❷,并指向原本是列表第一个元素的部分。否则,递归地移动到下一个节点 ❸。添加新值后,返回更新后的列表指针 ❹。

移除一个值

要从列表中移除一个元素,你有两个选择:移除第一个元素(此时必须更改指向列表第一个元素的指针),或者移除列表中的其他元素(然后修改前一个节点中的指针,如前所述)。以下代码实现了这些操作:

const remove = (list, position) => {
  if (isEmpty(list)) {
  ❶ return list;
 } else if (position === 0) {
  ❷ return list.next;
  } else {
  ❸ list.next = remove(list.next, position – 1);
    return list;
  }
};

如果列表为 null,直接返回 ❶;否则,你不能做其他任何事情。如果它不是 null,并且你想要移除其头部元素,新列表将是列表的尾部 ❷。最后,如果列表不为 null,且你不想移除其头部元素,则继续移动到下一个位置再次尝试移除 ❸,但此时需要移除的位置比之前少 1。在所有情况下,返回移除后的列表指针。

获取某位置的值

你可以通过考虑几个情况,以自然递归的方式获取给定位置的值。如果列表为 null,则没有值可返回,因此返回 undefined。如果列表不为空,且你请求的位置是 0,则你需要返回列表的第一个元素。如果列表不为空,而你请求的是位于列表较远位置的元素,则移动到下一个位置并递归处理。以下代码正是实现了这一点:

const at = (list, position) => {
❶ if (isEmpty(list)) {
   return undefined;
❷} else if (position === 0) {
   return list.value;
❸} else {
   return at(list.next, position - 1);
 }
};

逻辑紧密跟随三个步骤:检查是否为空列表 ❶,测试列表头部 ❷,以及使用递归向下遍历列表 ❸。

搜索值

最后,你可以搜索一个列表,查看它是否包含给定的值。这个操作虽然不常见,但你仍然会进行它,以便通过这个结构积累更多经验。一般的逻辑类似于你刚才看到的 at(...)示例。假设你有一个指针ptr,指向列表中的一个元素。如果指针为 null,则表示该值不在列表中。否则,如果指针ptr指向的对象包含你想要的值,那么你就找到了。如果值不是你想要的,继续从下一个节点开始搜索。以下是递归逻辑:

const find = (list, value) => {
  if (isEmpty(list)) {
 ❶ return false;
  } else {
  ❷ return list.value === value || find(list.next, value);
  }
};

如果列表为空,❶ 返回 false。否则,如果列表头部的值是你想要的 ❷,则返回 true。如果不是你想要的值,继续搜索列表的尾部。(请注意,你通过使用 JavaScript 的||运算符,将这两个测试合并在一起。)

考虑动态内存列表的性能

为了总结这部分关于动态内存列表的讨论,让我们分析它们的性能(见表 10-4)。

表 10-4:动态内存列表操作的性能

操作 性能
创建 O(1)
为空? O(1)
大小 O(n)
添加 O(n)
移除 O(n)
O(n)
查找 O(n)

与基于数组的实现一样,创建新列表和检查它是否为空都是O(1)操作,但所有其他操作的时间复杂度都变成了O(n)!这一差异表明,单纯使用指针实现数组(如前所示)并不是最佳解决方案。然而,一些具有更具体操作集的列表,能更好地满足其特定需求,从而实现更好的性能。 |

列表的种类

对于某些任务,可能需要比上一节中探索的常见列表的基本 ADT 及其实现更专业的 ADT。具体来说,我们将考虑栈、队列、双端队列和循环列表,包括它们的特定操作和应用。 |

栈是一种后进先出(LIFO)数据结构,类似于实际的盘子堆:想象一下,你只能将盘子添加到堆顶,或者只能移除顶上的盘子;不允许添加或移除中间的盘子。栈的行为也是如此。你只能在栈顶进行添加和移除操作,这些操作通常称为压栈(push)和弹栈(pop)。你还需要检查栈是否为空(与常见列表一样),并查看栈顶的值。(有时弹栈操作被定义为返回更新后的栈以及栈顶的值。在这种情况下,你就不需要一个单独的操作来获取栈顶值,因为你可以弹栈,使用栈顶值后再将其压栈。) |

表 10-5 总结了你所需的操作,正如前面提到的,你这里处理的是一个较小、更具体的操作集。

表 10-5:栈的操作

操作 签名 描述
创建 → S 创建一个新栈。
空吗? S → 布尔值 确定栈是否为空。
压栈 S × 值 → S 将一个值添加到栈顶。
弹栈 S → S 移除栈顶的值。
栈顶 S → 值 获取栈顶的值。

栈在应用中被广泛使用。例如,想了解如何通过使用栈以迭代方式实现递归深度优先过程,可以查看第十三章中的第 13.3 题。栈的一个不常见应用场景是惠普计算器中的逆波兰表示法(RPN):你将数字压入栈中,然后操作会弹出这些数字,执行所要求的计算,并将结果重新压入栈中。 |

栈还用于像 FORTH 或 WebAssembly(WASM)这样的编程语言,以及像 PostScript 这样的页面描述语言。中央处理单元(CPU)使用栈进行子例程调用和中断。如果代码正在执行并且中断到来,当前状态会被压入栈中,然后处理中断;中断处理完成后,正常执行会从栈中弹出状态并恢复执行。(显然,在处理一个中断时,可能会有新的中断。在这种情况下,第一个中断的状态也会被压入栈中,然后处理第二个中断,处理完成后,第一个中断的状态会被弹出,继续处理它。)

最后,JavaScript 本身实现了一个调用栈。每当一个函数调用自身时,就好像当前的状态和变量在开始递归调用之前被压入了栈中。当从递归调用返回时,旧的状态会从栈中弹出,执行从停止的地方恢复。

数据结构

使用数组实现栈很简单,因为你可以直接使用 .pop(...) 和 .push(...)(参见问题 10.5)。使用链式内存也很简单,你将基于你为列表编写的函数来编写代码。在这个结构中,你将有一个指向第一个元素的指针,即栈顶元素,每个元素将有一个指向“下方”元素的 .next 指针。“底部”元素将有一个空指针。图 10-4 显示了它是如何工作的。

图 10-4:使用动态内存实现的栈

将新值压入非空栈只需要添加一个新对象,该对象指向原来的栈顶元素,并更改栈顶指针(参见图 10-5)。

图 10-5:将新元素压入栈顶

弹出栈顶元素更简单:调整栈顶指针指向下一个元素,如图 10-6 所示。

图 10-6:从栈中弹出栈顶元素

在两种情况下(压栈和弹栈),当处理空栈时,你需要对逻辑进行简单的调整。

实现

栈是一种列表,因此创建栈和创建通用列表完全相同,如上一节所示;只需更改名称:

const newStack = () => null;
const isEmpty = (stack) => stack === null;

检查栈顶只需要一行代码(栈上的所有其他操作也都是一行代码):

const top = (stack) => (isEmpty(stack) ? undefined : stack.value);

对于空栈,只需返回undefined;否则,栈指向栈顶元素,因此栈的值(stack.value)就是你需要的。

压入一个值意味着栈顶会有一个新元素,这个新元素指向之前的栈顶元素:

const push = (stack, value) => ({value, next: stack});

如果栈为空,这个逻辑也能正常工作。你能看出为什么吗?

最后,弹出栈顶元素也是快速的:

const pop = (stack) => (isEmpty(stack) ? stack : stack.next);

如果栈为空,直接返回栈本身。你也可以轻松修改代码,例如抛出错误。对于非空栈,只需返回栈的尾部。

基于动态内存的栈性能

考虑到此栈实现的性能,结果远比常见列表更好(见表 10-6)。

表 10-6:基于动态内存的栈操作性能

操作 性能
创建 O(1)
是否为空? O(1)
推入 O(1)
弹出 O(1)
顶端 O(1)

所有操作都需要常数时间,这是最优的。如果使用数组实现栈,结果几乎是相同的,唯一的例外是:推入新值可能需要将数组移动到内存中一个新的、更大的位置,这样推入操作就变成了O(n)操作。与实现常见的列表相比,后者大多数操作的成本较高,使用动态内存实现栈既一样好,有时甚至更好。现在再考虑其他提供类似结果的列表变体。

队列

队列是列表的另一种变体,它是一个先进先出(FIFO)的数据结构。队列的工作方式就像一排等待某事的人。新的人从队列的后面进入(没有人可以插队),前面的人将首先离开队列。这两种操作是进入退出(或入队出队),它们模拟了现实中排队的情况。你还需要检查队列是否为空,并能够获取队列前端的值。表 10-7 展示了你需要的操作。

表 10-7:队列的操作

操作 签名 描述
创建 → Q 创建一个新的队列。
是否为空? Q → 布尔值 判断队列是否为空。
进入 Q × 值 → Q 在队列的末尾添加一个值。
退出 Q → Q 移除队列前端的值。
前端 Q → 值 获取队列前端的值。

一种有时使用的替代方案是,退出操作返回更新后的队列和从队列中移除的值,但考虑到前端操作,这并不是必需的。你还可以有一个尾端操作来访问队列末尾的值,但这并不常见。

队列通常用于那些不需要(或无法)立即处理的情况,并且应按顺序处理,例如打印队列或呼叫中心电话系统,在代表空闲之前会将你保持在等待状态。

数据结构

使用数组实现队列非常简单。对于链式内存,你需要指向队列第一个和最后一个节点的指针,因此你将用一个具有 first 和 last 链接的对象来表示队列。队列中的每个元素都有一个指向下一个元素的 next 指针,如 图 10-7 所示。(下一个元素实际上位于队列的前一个位置,因此 prev 也可以是指针的名称。)

图 10-7:一个使用动态内存实现的队列

队列中的第一个元素(即下一个要退出的元素)是 22;接下来的元素是 9。队列的最后一个位置是 56。将新元素添加到队列的末尾只需要修改最后一个元素的指针和最后一个元素本身的指针(见 图 10-8)。

图 10-8:在队列末尾添加一个元素

添加 80 后,之前的最后一个元素 56 现在指向 80,last 指针也是如此。

从队列前端移除元素与栈的操作完全相同,如 图 10-9 所示。

图 10-9:从队列前端移除一个元素

你只需要让第一个指针指向前一个第一个元素所指向的位置。现在我们来看如何实现这一切。

实现

创建一个新的队列并检查它是否为空非常简单:

❶ const newQueue = () => ({first: null, last: null});

❷ const isEmpty = () => .first === null;

队列由一个包含两个指针 ❶ 的对象表示,初始时这两个指针为 null。如果其中一个指针为 null,便能判断队列为空 ❷;实际上,两个指针都为 null 或都不为 null。

获取队列前端(排在最前面的元素)的值很容易:

const front = (queue) => (isEmpty(queue) ? undefined : queue.first.value);

如果队列为空,返回 undefined;否则,queue.first 指向队列中的第一个元素,并返回其值。

在队列的最后位置加入元素是一个简单的操作:

const enter = (queue, value) => {
  if (isEmpty(queue)) {
  ❶ queue.first = queue.last = {value, next: null};
  } else {
  ❷ queue.last.next = {value, next: null};
  ❸ queue.last = queue.last.next;
  }
  return queue;
};

如果队列为空 ❶,则让第一个和最后一个指针指向一个新的对象,并将其指向队列中下一个节点的指针设为 null。否则,让最后一个元素指向一个新元素 ❷,然后也让 last 指针指向它 ❸。

最后,退出队列的操作与栈相同,但当队列为空时有一个特殊情况:

const exit = (queue) => {
❶ if (!isEmpty(queue)) {
  ❷ queue.first = queue.first.next;
  ❸ if (queue.first === null) {
      queue.last === null;
    }
  }
  return queue;
};

如果队列不为空 ❶,你只需要让第一个指针 ❷ 指向队列中的下一个元素;但如果队列已被清空 ❸,你还需要修正最后一个指针。

基于动态内存的队列性能

由于队列和栈的相似性(唯一的区别是 pop 移除栈中的第一个元素,而 exit 移除队列中的最后一个元素),因此性能是相同的,正如 表 10-8 所示。

表 10-8:基于动态内存的队列操作性能

操作 性能
创建 O(1)
是否为空? O(1)
进入 O(1)
退出 O(1)
前端 O(1)

再次提醒,所有操作都需要常数时间;使用数组不如这种实现有效(参见问题 10.9)。

双端队列

列表的下一个变体并没有很多应用场景(栈和队列更为常见),但其实现引入了一个有趣的概念——双向链接(前向和后向)。假设一个队列,允许从两端进入或退出。(可以想象一列火车,多个车厢可以添加到车头或车尾,但车厢只能从两端移除。)这种类型的列表被称为双端队列(Deque,发音类似“deck”),即“双端队列”。

表 10-9 展示了双端队列所需的操作。

表 10-9:双端队列的操作

操作 签名 描述
创建 → D 创建一个新的双端队列。
空? D → boolean 判断双端队列是否为空。
从前端进入 D × value → D 在双端队列前端添加一个值。
从后端进入 D × value → D 在双端队列后端添加一个值。
从前端退出 D → D 移除双端队列前端的值。
从后端退出 D → D 移除双端队列后端的值。
前端 D → value 获取双端队列前端的值。
后端 D → value 获取双端队列后端的值。

基本上,双端队列与队列相同,唯一的区别是你可以从两端进入或退出。同样,你还需要能够获取双端队列两端的值;对于队列,你只需要查看第一个(前端)项。

数据结构

是否可以使用链式内存实现双端队列?由于现在所有操作都具有完全的对称性,你需要能够双向连接的链接。图 10-10 展示了其工作原理:如果你删除所有指向左侧的链接(或删除所有指向右侧的链接),你将剩下一个普通的队列。在这种结构中,你将再次拥有指向双端队列两端的第一个和最后一个指针,并且每个节点将拥有指向相邻节点的 next 和 prev(前一个)指针。

图 10-10:实现双端队列需要在每个节点上有两个指针。

由于对称性,双端队列两端的操作是完全类似的,因此我们只讨论双端队列一端的操作(见图 10-11)。

图 10-11:在双端队列的一个极端添加元素

在末尾添加一个值与队列的操作相同,不同之处在于新增的节点必须指向原先位于双端队列末尾的节点。(从另一端操作完全相同,因此我们跳过不谈。)

从双端队列末尾移除元素与图 10-11 所示相同,只是从底部向上操作;请参见图 10-12。

图 10-12:从双端队列的一个极端移除元素

删除后端的值时,修改相应的指针(last)和双端队列新极端的下一个指针;在另一端工作时,涉及到修改 first 和 prev 指针。

在双向链表中,删除操作很简单。如果你有指向某个元素的指针并想删除它(例如,删除图 10-13 中显示的列表中的 60),操作非常简单。

图 10-13:从双端队列中删除某个元素

关键是所有节点都有指向两个邻居的指针,因此你必须执行类似以下代码的操作,假设 ptr 指向要删除的节点:

ptr.prev.next = ptr.next;
ptr.next.prev = ptr.prev;

这种指针操作很常见,但第一次看到时可能会让人感到困惑,因此需要仔细研究。该代码适用于双端队列中间的元素。对于两端的元素,你需要进行一些小的调整,并至少调整(可能是两个)首尾元素。即使你从未使用过双端队列,双向链接的概念以及从中间提取任何元素的便利性,都是本节的关键要点。以后在本章和未来章节中,你将会使用这一概念处理循环链表。

实现

创建双端队列并检查其是否为空的操作与队列完全相同,因为你有相同的首尾指针:

const newDeque = () => ({first: null, last: null});
const isEmpty = (deque) => deque.first === null;

向双端队列(deque)添加一个新元素与向队列中插入元素相同;唯一的区别是你可以在任一端添加元素,这微妙地改变了你需要修改的指针:

❶ const newNode = (value, prev = null, next = null) => ({value, prev, next});

const enterFront = (deque, value) => {
  if (deque.first === null) {
  ❷ deque.first = deque.last = newNode(value, null, null);
  } else {
  ❸ const newValue = newNode(value, deque.first, null);
    deque.first.next = newValue;
    deque.first = newValue;
  }
};

❹ const enterBack = (deque, value) => {
  if (deque.last === null) {
    deque.first = deque.last = newNode(value, null, null);
  } else {
    const newValue = newNode(value, null, deque.last);
    deque.last.prev = newValue;
    deque.last = newValue;
  }
};

你使用辅助函数创建一个新节点,并为其设置一对指针 ❶。如果双端队列为空,插入前端时需要同时修改首尾指针 ❷。否则,使用与队列相同的指针操作 ❸。从双端队列的后端插入的代码与此完全相同,且具有对称性:只需将 last 改为 first,prev 改为 next ❹。

同样,从双端队列的前端或后端删除一个元素与从队列中删除元素相同;这两种算法是对称的:

const removeFront = (deque) => {
❶ if (!isEmpty(deque)) {
  ❷ deque.first = deque.first.next;
 ❸ if (deque.first === null) {
      deque.last === null;
    }
  }
};

❹ const removeBack = (deque) => {
  if (!isEmpty(deque)) {
    deque.last = deque.last.prev;
    if (deque.last === null) {
      deque.first === null;
    }
  }
};

如果双端队列为空 ❶,则无需进行任何操作。否则,若要删除前端元素,先前进到双端队列的下一个元素 ❷,如果该元素为 null ❸,你还需要调整最后一个元素。对称的操作产生了完全相同的“删除最后一个”操作 ❹。

基于动态内存的双端队列性能

双端队列本质上是一个双向队列:它的一半操作与队列完全相同,另外一半则是对称的,但代码风格相同,因此结果并不意外(见表 10-10)。

表 10-10:基于动态内存的双端队列操作性能

操作 性能
创建 O(1)
空吗? O(1)
进入前端(或后端) O(1)
从前端(或后端)退出 O(1)
前端(或后端) O(1)

双端队列的所有操作与队列相同,所有操作都是 O(1)。

循环链表

循环链表非常适用于“轮流处理”样式的任务。例如,PC 会将应用程序放入一个链表中,并循环遍历它们,最后一个完成后,处理将回到第一个(当你查看第十五章中的斐波那契堆时,你还会看到另一个例子)。与开放式链表不同,循环链表将第一个元素和最后一个元素连接在一起。这种 ADT(抽象数据类型)允许持续的处理,具有一个“当前”元素并能够循环前进到下一个元素。表 10-11 展示了我们需要的操作。

表 10-11:循环链表的操作

操作 签名 描述
创建 → C 创建一个新的循环链表。
空? C → 布尔值 判断循环链表是否为空。
添加 C × 值 → C 在当前元素之前添加一个新值并将其设为当前元素。
移除 C → C 移除当前值并前进。
当前 C → 值 从循环链表中获取当前值。
前进 C → C 循环地前进到链表中的下一个值。

有些变种和变化是可能的。例如,你可以要求一个“回退”操作,它在与“前进”相反的方向上执行。你还可以使用“在当前元素后添加”操作,但你也可以先前进,然后使用添加操作来实现。这些变化不大,且如所示的结构非常有用。然而,你之前在双端队列中的工作会帮助你实现这个功能。

数据结构

循环链表可以是单向或双向链接,但后者是最有用的版本。基本上,你只需要一个没有首尾元素的链表。相反,元素们形成一个圆圈,你将有一个指针指向当前正在处理的元素。图 10-14 展示了这样一个链表;这些节点拥有与双端队列相同的nextprev指针。

图 10-14:循环链表在每个节点都需要两个指针。

“前进到下一个”操作只需要跟随next链接(见图 10-15)。

图 10-15:沿链表移动可以在两个方向上进行。

在当前元素之前添加一个新元素也是通过处理多个指针来完成的(见图 10-16)。

图 10-16:向循环链表添加元素是通过更改几个指针来完成的。

移除当前元素需要一些指针操作,但与双端队列类似,双向链接使得这一过程变得简单(见图 10-17)。

图 10-17:从循环链表中移除元素也只需要做几个指针的调整。

循环链表中的操作基本上需要你已经探索过的相同类型的逻辑。现在,考虑一个实际的实现。

实现

创建一个循环列表与创建普通列表相同,测试该列表是否为空也一样。唯一的区别是命名:

const newCircularList = () => null; // current
const isEmpty = (circ) => circ === null;

在栈中,你有一个指向顶部元素的指针。在这里,你有一个指向列表中某个元素的指针,即当前元素。

添加一个新节点仅仅涉及更多的指针操作:

const add = (circ, valueToAdd) => {
  const newNode = {value: valueToAdd};
  if (isEmpty(circ)) {
  ❶ newNode.next = newNode;
    newNode.prev = newNode;
  } else {
  ❷ newNode.next = circ;
    newNode.prev = circ.prev;
    circ.prev.next = newNode;
    circ.prev = newNode;
  }
❸ return newNode;
};

如果列表为空,它由一个单独的节点组成❶,其 next 和 prev 链接指向自身。否则,新节点位于 circ(当前节点)和 circ.prev(前一个节点)所指向的节点之间。修复涉及的四个指针,使得新节点位于正确的位置❷。最后,返回新节点❸。

删除当前元素要简单一些:

const remove = (circ) => {
  if (isEmpty(circ)) {
  ❶ return circ;
  } else if (circ.next === circ) {
  ❷ return newCircularList();
  } else {
  ❸ circ.prev.next = circ.next;
    circ.next.prev = circ.prev;
    return circ.next;
  }
};

你需要考虑三种不同的情况。如果循环列表为空,什么也不做❶。如果列表只有一个元素(此时它的 next 和 prev 链接都指向自身),返回一个新的空列表❷。最后,如果列表不为空,则让 circ.prev 和 circ.next(即围绕当前节点的节点)互相指向❸。

最后,获取当前值并推进到下一个值都只需要一行代码:

❶ const current = (circ) => (isEmpty(circ) ? undefined : circ.value);
❷ const advance = (circ) => (isEmpty(circ) ? circ : circ.next);

空列表的当前元素只是未定义❶;否则,circ.value 给出它的值。对于非空循环列表,将当前元素推进到下一个位置只是去下一个节点❷。

循环列表的性能

检查所有已实现的函数,发现没有一个需要循环或递归,因此与本章其他数据结构一样,性能是常数的(见表 10-12)。

表 10-12:循环列表操作的性能

操作 性能
创建 O(1)
空吗? O(1)
添加 O(1)
删除 O(1)
当前 O(1)
推进 O(1)

当然,你可以使用数组,但某些操作的性能,比如添加新值,会受到影响,因为可能需要将整个数组移动到内存中的新位置:O(n)。

总结

本章我们检查了几种基于链接内存的线性结构和循环结构,你将在后续章节中有机会重用它们。链接内存是我们将要探讨的所有动态结构的关键,未来的章节中,我们将使用更复杂的结构,以提高更复杂操作的性能。

问题

10.1  遍历列表

“使用动态内存实现列表”部分中的所有示例都使用了递归编写,但它们通常是以迭代方式实现的。你能以这种方式重写它们吗?

10.2  反向操作

实现一个 reverse(list)算法,给定一个列表,将其反转,即第一个元素变为最后一个,第二个元素变为倒数第二个,以此类推。

10.3  联手协作

实现一个 append(list1, list2)函数,给定两个列表,将第二个列表追加到第一个列表后面。

10.4  解除循环

假设你有一个列表,它可能有或没有循环;换句话说,列表可能不会最终以空指针结束,而是有一个元素指向之前的某个元素,这样列表就形成了循环。你能写一个 hasALoop(list) 函数,给定一个列表判断它是否有循环吗?你的解决方案应该使用常量的额外内存;不要假设列表的长度,因为它可能非常长。

10.5  用于栈的数组

由于 JavaScript 提供了对数组的操作,如 .pop(...) 和 .push(...),因此使用数组实现栈应该是相当简单的。你能写出合适的代码吗?

10.6  栈打印

你能写一个打印栈内容的代码,按从上到下的顺序打印吗?你能按反向顺序(从下到上)打印它吗?

10.7  栈的高度

假设你需要知道栈中有多少个元素。你如何实现这个功能?

10.8  最大栈

假设你需要一个栈来进行某些操作,但你还需要在每次压栈或弹栈后,知道栈中的最大值。你如何高效地实现这一点,而不必每次都遍历整个栈?

10.9  排队数组

在之前的问题中,你看到 JavaScript 提供的操作使得使用数组模拟栈变得非常简单。那么队列也是这样吗?你如何用数组模拟队列?这种实现的性能如何?

10.10  队列长度

编写一个函数,给定一个队列,计算其中有多少个值;换句话说,找出队列的长度。

10.11  排序队列

在第六章中,你使用数组实现了基数排序,但使用队列和链式内存更高效。你能根据这个调整算法吗?

10.12  栈式队列

假设你需要使用队列来编写某个程序,但你只有一个实现了栈的库。通过一些技巧,你可以使用一对栈来模拟队列;你能理解其中的原理吗?(你将在第十八章中进一步探讨这个策略。)

10.13  回文检测

你如何使用双端队列(deque)来判断一个字符串是否是回文?回文是指正着读或反着读都相同的单词,如“Hannah”或“radar”,或者忽略空格和标点符号后的“Step on no pets”或“A man, a plan, a canal: Panama。”

10.14  循环列表

实现一个函数来列出循环列表的所有内容;小心不要进入循环。

10.15  连接圆圈

假设你有两个循环列表。你如何将它们合并成一个更大的列表?为简单起见,假设这两个列表都不是空的。

第十一章:11 袋、集合和映射

在本章中,我们将考虑一些广泛使用的抽象数据类型(ADT):袋、集合和映射。只是一个值的集合(无论是否重复),集合是一个不同值的集合,映射是由键+数据对组成的集合。我们将考虑一些实现这些 ADT 的新方法,从 JavaScript 的对象开始,然后继续讨论位图、列表和哈希,这是一种我们尚未探索过的新方法。

引入袋、集合和映射

在第三章中,我们定义了袋的抽象数据类型(ADT),并展示了表 11-1 中的集合操作。(在此上下文中使用“集合”一词完全符合其数学定义。)当你需要存储许多(可能重复的)值时,你需要一个袋。

表 11-1:袋的操作

操作 签名 描述
创建 → bag 创建一个新袋。
空吗? bag → boolean 给定一个袋,判断它是否为空。
添加 bag × value → bag 给定一个新值,添加到袋中。
删除 bag × value → bag 给定一个值,从袋中删除它。
查找 bag × value → boolean 给定一个值,检查它是否存在于袋中。

在第三章中,我们有一个额外的操作来获取袋中的最大值,但在这里不考虑这个操作,因为它不是标准操作。你也可以有一个操作来查找袋的当前大小,可能还有其他一些操作,但这些已经足够。

有时你需要一个实际的集合,因此你不想允许重复值,这种限制需要一组略有不同的操作,如表 11-2 所示。

表 11-2:集合的操作

操作 签名 描述
创建 → set 创建一个新集合。
空吗? set → boolean 给定一个集合,判断它是否为空。
添加 set × value → set | 错误 给定一个新值,添加到集合中。
删除 set × value → set 给定一个值,从集合中删除它。
查找 set × value → boolean 给定一个值,检查它是否存在于集合中。

所有操作都是相同的,唯一的区别是,当你尝试添加一个新值并发现它已经存在时,你会做出不同的处理。一个可能的做法是直接忽略这种情况(毕竟,如果你想将一个值包含在集合中,而该值已经存在,那一切都正常),或者你可以抛出错误或执行其他操作。你也可以事先检查要添加的值是否已经存在于集合中,但通常在添加时进行检查更高效。

最后,在某些情况下,你可能想要存储键+数据对。例如,对于一个使用国家信息的应用程序,键可能是 ISO 3166 国家代码(例如瑞士的 CH、图瓦卢的 TV 或乌拉圭的 UY),数据可能是国家名称、人口等等。实现了集合之后,实现映射就很简单。你只需要存储带有键+数据的对象,并进行更改,使得查找删除仅使用键来工作;前者如果找到,则返回数据而不是布尔值。有关所有操作,请参见表 11-3。

表 11-3:映射的操作

操作 签名 描述
创建 → map 创建一个新的映射。
是否为空? map → boolean 给定一个映射,确定它是否为空。
添加 map × (key + data) → map | 错误 给定一个新的键+数据,将其添加到映射中。
删除 map × key → map 给定一个键,从映射中删除它。
查找 map × key → data | undefined 给定一个键,检查它是否存在于映射中,如果找到,返回数据或 undefined。

所有这些更改都非常简单,所以我们将使用普通的袋和集合。现在让我们考虑具体的实现,从 JavaScript 自身的实现开始。

JavaScript 的集合解决方案

你在第三章中学到了如何使用几种不同的方法实现一个袋(bag)。只需做一些更改,你就可以实现集合而不是袋;你需要做的就是在添加一个新值之前,检查它是否已经存在。在本节中,我们将考虑使用 JavaScript 实现集合的另外两种方式:使用普通对象(这不是最好的方式)和使用标准集合对象。

对象作为集合

即使对象并非设计为集合(或映射),许多开发人员仍然使用普通对象作为集合。如果你可以将值作为属性(通常是字符串或转换为字符串的数字),你就可以将它们用作属性名称:

❶ const mySet = {};
❷ mySet.one = 1;
mySet.two = 2;

在普通 JavaScript 中,创建一个对象意味着分配一个空对象 ❶ 并向其中添加值 ❷。在这里,你现在有一个包含两个键的集合:one 和 two。(如果你想要一个映射,则这些键所关联的值就是数据。)

你可以使用 in 操作符测试一个键是否在对象中:

"two" in mySet;   // true
"three" in mySet; // false

最后,你可以使用 delete 来删除一个键:

delete mySet.two;

作为额外的操作,你可以使用 Object.keys(...) 获取对象的所有属性列表,甚至可以使用 for...in 对它们进行迭代。

使用普通的 JavaScript 对象显然是可行的,但你可能更想让代码更具可读性,明确表达你的意图,使用一个合适的集合,它毕竟直接代表了你想要的数据结构。

集合对象

集合是让你存储唯一值的对象。创建一个新的 JavaScript 集合并添加几个值是很简单的;试着重新做一下上一节的例子:

❶ const mySet = new Set();
❷ mySet.add("one");
mySet.add("two");

通过创建一个新的 Set 类实例 ❶ 来创建集合,并使用其 .add(...) 方法 ❷ 向集合中添加值。顺便说一下,你可以链式调用,因此可以将这两个添加操作写在同一行:

mySet.add("one").add("two");

要测试某个值是否在集合中,使用 .has(...) 方法:

mySet.has("two");   // true
mySet.has("three"); // false

最后,你可以使用.delete(...)方法删除值:

mySet.delete("two");

JavaScript 的集合有一些额外有趣的方法。要删除所有值,可以使用 set.clear()。你还可以使用 .size 属性来查找集合中的元素数量。

位图

在某些情况下,你可以通过使用位图来实现集合(回想一下第六章中的位图排序)。如果要存储的值是具有有限范围的数字,使用布尔标志数组就足够了。

我们在这里不会看到代码,因为它直接基于你在第六章中学习的排序方法。主要思路是设置一个充满 false 值的数组,数组的索引就是值本身。要添加一个值,将其标志设置为 true;要删除它,将其标志重置为 false。最后,要测试某个值是否在集合中,检查对应的标志。没有比这更简单的方法了。

使用列表

我们在第十章中讨论了列表,你可以将其改编为袋子或集合。考虑三种不同的可能性:

有序列表 保持值按升序排列的普通列表

跳表 二维结构,具有快速搜索功能

自组织列表 适用于缓存等类似情况的有趣应用

请注意,你在第 218 页“哈希”部分中考虑的一些解决方案也会使用列表。

有序列表

如第十章中所述,有序列表的概念很简单:你不会总是将值添加到列表的两端,而是将它们按顺序添加。这个做法会减慢插入速度(因为你需要找到正确的插入位置),但它使得搜索平均速度更快(当你遇到一个比目标值大的值时,可以停止搜索)。来看一下实现方法。

在有序列表中查找值

搜索逻辑非常直接:从头开始,沿着链接查找,直到找到目标值或者确认该值不存在,因为你要么到达了列表的末尾,要么遇到了比目标值更大的值。图 11-1 展示了如何在有序列表中(成功地)查找值 22。

图 11-1:在列表中成功查找值(22)

这与线性查找方式相同(见第九章)。从列表的头部开始,持续查找。在这种情况下,你找到了你想要的值,因此搜索成功。如果你原本要查找的是 20,那么在这个点你就会放弃搜索。如果遇到一个比目标值更大的值,搜索就失败了。

另一种失败的可能性是查找超出了列表的末尾;请参见图 11-2,它展示了查找值 86 的过程。

图 11-2:在列表中查找失败的值(86)

线性查找的代码如下:

const find = (list, valueToFind) => {
❶ if (isEmpty(list) || valueToFind < list.value) {
   return false;
❷} else if (valueToFind === list.value) {
   return true;
❸} else {
   // valueToRemove > list.value
   return find(list.next, valueToFind);
 }
};

如果你遇到了一个空列表——无论它是一开始就是空的,还是你遍历到它并到达了末尾(你在第十章中看到过这段代码)——你知道值不在其中。如果列表不为空,但其第一个值大于你正在寻找的值 ❶,那么值也不在其中。如果列表不为空且第一个元素与你想要的值相等 ❷,那么值就找到了。最后,如果列表不为空并且你要找的值大于列表的第一个元素 ❸,则从列表的下一个节点继续查找。

向有序列表中添加新值

要添加一个新值,首先进行查找,直到找到新值应该放置的位置(也就是说,在一个较小值的节点和一个较大值的节点之间),然后更改几个指针以将新值包含到列表中。图 11-3 展示了如何将 20 添加到列表中。

图 11-3:向有序列表中添加新值(20)

如果你想要添加一个比列表头部值小的值,你需要更改列表本身的指针。另一个边界情况是添加一个大于列表中最后一个值的值;你在遍历列表时需要小心。你可以使用递归来更轻松地实现所有这些情况:

const add = (list, valueToAdd) => {
❶ if (isEmpty(list) || valueToAdd < list.value) {
   list = {value: valueToAdd, next: list};
❷} else {
 ❸ list.next = add(list.next, valueToAdd);
 }
 return list;
};

这一逻辑与你在进行线性查找时看到的类似。如果列表为空,或者列表不为空但第一个值大于你想要添加的值 ❶,则创建一个新的节点,其值为新值,且其下一个指针指向你原来的列表。(这涵盖了将值添加到列表末尾的情况;你能理解吗?)如果你想创建一个集合,添加一个测试 ❷,因为如果你找到你想添加的值,则会抛出错误或拒绝此操作。如果你在创建一个袋子,并且要添加的值大于列表中的第一个值 ❸,则使用递归在第一个元素之后添加它。

从有序列表中删除值

删除一个值的过程是找到它(你已经知道如何做),然后修改它的前一个节点的链接,使其指向下一个值,正如图 11-4 所示。

图 11-4:从有序列表中删除值(22)

如前所述,进行一次查找,如果成功,则跳过要删除的值。唯一不同的情况是当你删除列表头部时,需要修改指向列表的指针:

const remove = (list, valueToRemove) => {
❶ if (isEmpty(list) || valueToRemove < list.value) {
   return list;
❷} else if (valueToRemove === list.value) {
   return list.next;
❸} else {
   // valueToRemove > list.value
   list.next = remove(list.next, valueToRemove);
   return list;
 }
};

逻辑完全匹配搜索,逐个处理。它也是合乎逻辑的;您必须先找到该值,才能从列表中删除它。如果列表为空,或其第一个值大于要删除的值❶,则返回原列表,因为没有需要删除的内容。如果要删除的值位于列表头部❷,则返回列表的尾部,跳过要删除的值。最后,如果要删除的值大于列表头部的值❸,则递归地从列表尾部删除该值,并返回(更新后的)列表。

考虑有序列表的性能

无法加速任何过程,如果列表有n个节点,所有功能都是O(n)。平均来说,所有操作都会访问列表的一半节点。这个实现对于小值n已经足够好,但对于较大的值,您需要一种能够让您更快速地遍历列表的方法——接下来的章节将展示这种方法。

跳跃列表

如前所述,搜索一个列表是一个O(n)过程,因为没有办法加速,无法更快地移动。然而,您可以借鉴跳跃搜索方法(见第九章)。如果您能进行长跳跃,快速跳过许多位置,而当接近所需值时,开始进行更小的跳跃,再逐渐减小跳跃,直到最后进行逐一搜索,会发生什么呢?在本节中,我们将讨论跳跃列表,它通过提供更快的跳跃方式,使您能够更快速地遍历列表。

考虑如图 11-5 所示的有序列表(为了清晰起见,我没有包含箭头;所有箭头都从左到右)。

图 11-5:搜索长列表在逻辑上较慢。

如所示,您不能像跳跃搜索那样快速跳跃,但通过辅助的第二个列表,完全可以做到这一点,如图 11-6 所示(垂直线表示从上到下的指针)。

图 11-6:通过添加第二个列表加速列表搜索

如果您想查找 42,您将从最顶层的列表开始,向右移动,直到超过 42;然后您会回退并向下继续搜索(见图 11-7)。

图 11-7:借助第二个列表查找值(42)

最顶层的列表仅包含底层列表中的少数几个值,它允许您进行更长的跳跃,因此搜索速度更快。当然,为了实现更快的过程,您可以拥有三个或更多层次,如图 11-8 所示。

图 11-8:第三个列表有助于进一步加速搜索。

这种方法效果很好,并且提供了更好的期望性能 O(log n)。然而,实际操作中,在每个地方都有重复值的这些列表并不好。根据不同的层次,将每个值仅在一个节点中出现,并且节点有多个指针(如图 11-9 所示)会更好。(实际上,所有节点可能具有相同数量的指针,但图 11-9 仅显示了使用的指针。)

图 11-9:跳表的实际实现,每个节点有多个指针

我们将使用这种搜索方式来简化我们的工作。我们还会在列表的开始和结束添加一些哨兵节点,以简化所有逻辑。您不会遇到“在开头添加”或“在结尾添加”之类的情况,因为没有任何值可以比第一个哨兵小或比最后一个哨兵大。此外,您也不必处理空列表(至少哨兵会存在),而且您永远不会超出列表的最后一个项目。

创建跳表

空列表将由一个包含两个哨兵的节点组成:一个负无穷值和一个仅有一个正无穷值的单一层。其实现如下:

const newSkipList = () => ({
  value: -Infinity,
  next: [{value: Infinity, next: [null]}]
});

您有一个只有两个值的列表:-Infinity 位于第一个,Infinity 位于最后。(这里您处理的是数值,对于字符串,您需要使用合适的低值和高值字符串。)跳表是平坦的,只有一层。(没有任何下一个数组有超过一个元素。)了解这一点后,测试跳表是否为空就稍微复杂一些:

const isEmpty = (sl) => sl.next[0].next[0] === null;

如果跳表中没有任何值,您将拥有初始配置,因此您的哨兵将位于底层。在这个数据结构中,唯一指向下一个节点的指针为 null 的是 +Infinity 哨兵;所有其他节点的指针都不是 null。

您可以通过简单地查看下一个数组的长度来判断跳表有多少层。另一个有用的函数只需返回指针数组的最后一个索引:

const _level = (sl) => sl.next.length - 1;

您需要减去 1,因为指针数组是从零开始索引的,像往常一样。

在跳表中搜索一个值

您之前已经看到了搜索的基本思路,但现在请考虑它们如何在实际实现中工作。搜索从最上层开始,向右推进,除非超出要搜索的值,在这种情况下,它会向下移到下一层。如果没有更多的层次,搜索将失败。

代码并不长,但处理多个层次需要小心:

❶ const _find = (node, currLevel, valueToFind) => {
❷ if (currLevel < 0) {
   return false;
❸} else if (valueToFind === node.value) {
   return true;
❹} else if (valueToFind >= node.next[currLevel].value) {
   return _find(node.next[currLevel], currLevel, valueToFind);
❺} else {
   return _find(node, currLevel - 1, valueToFind);
 }
};

❻ const find = (sl, valueToFind) => _find(sl, _level(sl), valueToFind);

我们将使用一个辅助的递归函数进行搜索。这个函数有三个参数:一个节点(跳表中的某个位置)、它正在搜索的层级,以及要查找的值❶。如果你尝试搜索低于第 0 层❷的地方,表示搜索失败,因为你已经到了底层,无法在该层找到值。如果节点有你想要的值❸,则表示搜索成功。如果你想要的值大于或等于该层级下一个值❹,则继续前进而不改变层级。否则,如果你已经遇到一个更大的值❺,则下降到下一层级。一般搜索的实现❻是从顶层的第一个节点开始的。

向跳表中添加一个值

我们还没有真正讨论如何决定哪些值放在哪些层级。我们将采用基于随机数的解决方案。显然,所有值都会处于最底层,但并不是所有值都会出现在其他层级。我们将通过“抛硬币”来决定一个新值是否上升一层;我们希望大约 50%的值能出现在下一级层级。我们会继续随机决定是否再将该值提升一层,直到抛硬币失败或者达到最大层级。在接下来的代码中,设置 MAX_LEVEL 为 32,这意味着平均每 2^(³²)个值中,只有一个会达到这么高的层级——一个非常庞大的结构!

我们将需要一个辅助函数来在某一层级及其以下所有层级中添加一个值。一个显而易见的问题是,为什么要先在更高的层级添加值,然后再在较低的层级添加?因为更高层级的列表元素较少,所以在那里插入更快。以下是代码:

const _add = (currNode, currLevel, newNode, newLevel) => {
❶ if (newNode.value > currNode.next[currLevel].value) {
    _add(currNode.next[currLevel], currLevel, newNode, newLevel);
  } else {
  ❷ if (currLevel <= newLevel) {
    ❸ newNode.next[currLevel] = currNode.next[currLevel];
      currNode.next[currLevel] = newNode;
    }
  ❹ if (currLevel > 0) {
    ❺ _add(currNode, currLevel - 1, newNode, newLevel);
    }
  }
};

如果新值大于该层级下一个值❶,你必须前进;当新值位于两个连续值之间时,你就能将其添加到列表中。如果你处于一个低于或等于最大新层级的层级❷,则添加该值并调整指针,将新值包含到列表中❸。最后,如果你还没有到达底层❹,则使用递归将该值添加到下一层❺。

使用这个函数,添加一个值的步骤如下:

const add = (sl, valueToAdd) => {
❶ let newLevel = 0;
  while (newLevel < MAX_LEVEL && Math.random() > 0.5) {
    newLevel++;
  }
❷ const newNode = {value: valueToAdd, next: new Array(newLevel)};

  let currLevel = _level(sl);
❸ while (newLevel >= currLevel) {
  ❹ sl.next[currLevel].next.push(null);
    sl.next.push(sl.next[currLevel]);
    currLevel++;
  }
❺ _add(sl, currLevel, newNode, newLevel);
  return sl;
};

首先,决定你要在哪个层级找到新节点❶。是否上升一层将取决于“抛硬币”的结果。决定好之后❷,创建一个带有该值的节点,并且创建一个包含正确数量指针的数组。如果你“上升”到了比之前更高的层级❸,跳表可能会变得更高。如果是这样的话❹,你需要为最右边的值添加新的指针。在解决了这个问题之后❺,使用辅助函数将该值添加到所有相应的列表中。

从跳表中删除一个值

删除一个值需要两个步骤:首先,从它所在的所有列表中删除该值,你可以通过一个辅助函数来实现,然后可能需要使跳表“变短”,因为删除该值后,跳表可能不会像之前那样高。

下面是实际删除值的逻辑:

const _remove = (currNode, currLevel, valueToRemove) => {
❶ if (valueToRemove > currNode.next[currLevel].value) {
    _remove(currNode.next[currLevel], currLevel, valueToRemove);
  } else {
  ❷ if (valueToRemove === currNode.next[currLevel].value) {
    ❸ currNode.next[currLevel] = currNode.next[currLevel].next[currLevel];
    }
  ❹ if (currLevel > 0) {
      _remove(currNode, currLevel - 1, valueToRemove);
    }
  }
};

一直往下遍历列表❶,直到找到值应该在的位置。如果你实际上找到了它❷(用户可能要求移除一个根本不在列表中的值),修复指针❸。然后继续进行删除操作,直到到达最底层❹。

移除值是第一步,如前所述;你可能需要在之后重构多个级别:

const remove = (sl, valueToRemove) => {
❶ _remove(sl, _level(sl), valueToRemove);
  for (
  ❷ let level = _level(sl) – 1;
  ❸ level > 0 && sl.next[level].next[level] === null;
    level--
  ) {
  ❹ sl.next[level].next.splice(level, 1);
    sl.next.splice(level, 1);
  }
  return sl;
};

在移除值❶之后,从顶部❷开始逐级下降,尽管列表基本为空(只有哨兵节点)❸,你已将列表缩短❹。

考虑跳表的性能

跳表的本质是概率性的,平均性能可以证明是对数级的(参见表 11-4)。

表 11-4:跳表操作的性能

操作 平均性能 最坏情况
创建 O(1) O(1)
添加 O(log n) O(n)
移除 O(log n) O(n)
查找 O(log n) O(n)

结构表现不好的概率非常低(可能只有一个级别,或者大多数值分布在所有级别),但这种情况不太可能发生。

与哈希(你将在本章后面深入探讨)及其他结构类似,你可以通过重构跳表来解决性能问题;请参见问题 11.4 了解可能的思路。你也可以修改列表以允许通过位置检索值;参见问题 11.5。

自组织列表

有一种特殊情况,你可以成功地使用“自动修改”的包。考虑一个具有最大容量限制的缓存。常常会出现你在短时间内需要某个元素几次,而在较长时间内完全不需要它。在这种情况下,你可以使用一个自组织列表,将最常用的元素放在列表的前面(以便更快地搜索),而将较少使用的元素放在后面(允许较慢的搜索)。

作为一个示例,想象一个映射(全球定位系统 [GPS] 风格)应用。你无法把每条街道名称都保存在内存中,但为了优化速度,你可以有一个小的街道名称缓存。在某个特定区域内旅行时,通常需要某一组街道名称,而且不太可能需要找到距离很远的街道。自组织列表的想法是始终将新值添加到前面,如果你搜索到一个值并找到了它,就把它移到前面,假设如果很快需要它,你可以通过几步操作找到它。

在自组织列表中搜索值

搜索一个无序列表并不困难;你只需要一直进行下去,直到找到目标值或到达列表的末尾。你可以在第十章中看到如何进行这种搜索(参见第 180 页的“用动态内存实现列表”部分)。重要的细节是,如果找到了该元素该怎么做。将其设置为列表的头,并把它从原来的位置移除。图 11-10 展示了一个搜索 12 的示例。

图 11-10:在自组织列表中成功搜索后,找到的节点被移动到列表的头部。

首先,进行一次搜索查找 12(这没有什么新奇的),但在找到之后会有一些变化。由于该值原本不在列表的头部,你将重新结构化列表,使 12 位于头部,并指向旧的头部。如果将来你再次需要查找它,这些搜索将非常迅速,因为该值将位于列表的头部或者非常接近头部。

代码如下:

const findMTF = (list, keyToFind) => {
❶ if (isEmpty(list)) {
    return [list, false];
❷} else if (list.value === keyToFind) {
    return [list, true];
  } else {
 ❸ let [prev, curr] = [list, list.next];
 ❹ while (!isEmpty(curr) && curr.value !== keyToFind) {
     [prev, curr] = [curr, curr.next];
   }

 ❺ if (isEmpty(curr)) {
     return [list, false];
 ❻} else {
     [prev.next, curr.next] = [curr.next, list];
     return [curr, true];
   }
 }
};

现在进行搜索时,你也在修改列表,因此你需要返回两个值:列表本身(如果找到值,可能已经更新)和一个布尔值表示搜索的结果。(太麻烦了?请参见问题 11.3。)如果列表为空 ❶,返回列表和 false,因为值显然不在其中。如果列表不为空,且你要找的值就在列表的头部 ❷,你不需要改变列表,因此直接返回它,并附带 true,表示成功。如果列表不为空,且你要找的值不在头部 ❸,设置一个循环,其中 prev 和 curr 将分别指向列表中的连续节点。该循环会在以下两种情况之一结束:你要么到达列表末尾,要么找到了你要的值 ❹。在前一种情况 ❺,返回列表和 false,就像列表为空时 ❶;如果是后一种情况 ❻,修改指针并返回当前节点作为新列表的头部,附带 true。

向自组织列表中添加值

由于这些列表是无序的,你可以将值添加到任何地方,使用你之前用过的简单逻辑:

const add = (list, valueToAdd) => {
❶ list = {value: valueToAdd, next: list};
  return list;
};

将新值放到列表的顶部作为头部是非常简单的。创建一个新的节点,指向旧的列表 ❶,新列表的头部就是这个新节点。这个代码与在第十章中为栈编写的 push(...) 方法功能上是等效的。

从自组织列表中删除值

我们之前看到过如何从有序列表中删除一个值。从无序列表中删除的操作与此非常相似,唯一的区别是你可能总是需要遍历到列表的末尾,因为没有办法提前停止搜索。你已经学会了如何以迭代的方式进行搜索,现在可以用递归的方式来实现:

const remove = (list, valueToRemove) => {
❶ if (isEmpty(list)) {
   return list;
❷} else if (valueToRemove === list.value) {
   return list.next;
 } else {
 ❸ list.next = remove(list.next, valueToRemove);
   return list;
 }
};

如果列表为空 ❶,直接返回,因为要删除的值不在那里。如果你要删除的值就是列表当前指向的值 ❷,返回列表的尾部(即 list.next 指向的部分)就完成了删除。最后,如果列表的头部没有你想要的值 ❸,让该节点指向删除尾部值后的结果。

考虑自组织列表的性能和变种

这种结构的性能是O(n),就像常见的列表一样,搜索时平均需要查看n/2 个元素。然而,在实际的集群需求中,它表现得要好得多,需要查看的元素远少于预期。这不是一种理论优势,而是完全经验和务实的优势,而且在最坏的情况下,你的表现不会更差。

还有其他类似性能的变体。“移到前面”(MTF)解决方案并不是唯一的选择。另一种可能性是“与前一个交换”,即不将找到的元素移到列表的头部,而是与它前面的元素交换,使其更接近头部。如果你多次搜索某个值,它最终会到达列表的前面;但如果这次搜索只是偶尔的,那么它将停留在原地。

另一种变体是给每个值添加引用计数,每次搜索到并找到该值时,将计数加 1,并将其移到列表的前面,使得这些值按照计数的降序排列。

哈希

在这一节中,我们将讨论一个不同的概念,它可能提供最快的搜索速度:哈希。哈希的概念与位图有一定的关系。如果要存储在集合中的值来自一个较小的范围,可以使用位图,这样可以提供O(1)的搜索时间,正如你所看到的那样。然而,如果值来自一个非常大的范围(例如,美国的社会保障号码,九位数字,总共有 10 亿个可能值),位图就变得不可行,因为它需要大量的空间。此外,你很可能只会处理所有可能键中的一个非常小的百分比。其基本思路是,首先使用一个数组来存储值,但随后不是像位图中那样将键用作索引,而是计算该值的哈希,并使用该哈希值作为索引。

在谈到哈希时,安布罗斯·比尔斯曾说:“这个词没有定义——没人知道哈希是什么。” 对我们来说,哈希是任何将一个值——无论是数字、字符串等——转换为一个给定范围内的数字的函数。以社会保障号码为例,要得到一个介于 000 和 999 之间的哈希值,你可以直接取后三个数字。要得到一个介于 0 和最大值K之间的哈希值,你可以通过将值除以K并取余数来实现。有很多计算哈希值的方法,但我们将使用余数函数,如下所示:

const hash = (ht, value) => value % ht.slots.length;

使用哈希来决定存储(或查找)值的位置时,我们首先计算哈希,然后去数组中对应的槽(见图 11-11)。这与我们在位图中的做法非常相似,只是那时我们使用键作为索引;在这里,我们假设可能的键的数量极其庞大,因此我们应用哈希将其缩减为一个可管理的值。

图 11-11:在哈希中,使用哈希函数决定一个值应该存储在表中的位置。

如果你想要的值占用了该槽,那么你已经找到了它。如果该槽为空,则可以确定该值不在集合中。但是如何处理那些产生相同哈希值的不同值,以便它们都放入相同的槽中呢?这种情况叫做碰撞,你必须指定如何解决它。(如果你认为这种情况不太可能,试着在线搜索一下“生日悖论”;你会惊讶的!)不同的哈希策略在处理碰撞的方式上有所不同。本章将讨论三种不同的策略:带链式的桶法、开放地址法和双重哈希。实现方式将是集合(bag),但我们将在本章末尾的问题中讨论如何实现集合(set)。

带链式哈希

解决碰撞的第一种方法是将每个槽看作一个桶,你可以将多个值放入其中。实现这一点的最简单方法是利用你在本章之前看到的有序列表,这样大部分工作就已经为你完成了。所有进入该槽的值都被放入一个列表中,为了简便,你将使用一小组数字。图 11-12 展示了左侧的槽(槽 #3 没有占用)和右侧的列表。

图 11-12:使用链式哈希的哈希值相同的元素会使用列表来存储。

要查找一个值,首先计算它应该在什么槽中(在这种情况下,意味着计算值除以 5 的余数,即表的长度),然后搜索相应的列表。如果该值在集合中,你应该能够在列表中找到它。考虑到你已经了解如何使用列表来实现集合或袋子,这个实现并不难,以下是详细说明。

创建一个链式哈希表

为了创建一个新的哈希表以供链式使用,创建一个空数组,并将其填充为新的列表(你将使用本章之前展示的有序列表,作为复习):

const newHashTable = (n = 100) => ({
  slots: new Array(n).fill(0).map(() => newList())
});

在其他需要额外字段的解决方案中,你是创建一个对象,而不是一个数组——例如,追踪有多少槽是已使用的或空闲的。(参见问题 11.6,了解常见的错误。)

向链式哈希表中添加一个值

你可以很容易地将一个新值添加到这个哈希表中;代码如下:

const add = (ht, value) => {
❶ const i = hash(ht, value);
❷ ht.slots[i] = addToList(ht.slots[i], value);
  return ht;
};

你只需要计算新值应该放入哪个槽 ❶ ,然后将其添加到相应的列表 ❷ 。

在链式哈希表中查找一个值

查找一个值也很简单:在决定值应该放在哪个槽之后,搜索相应的列表。你可以用一行代码来写查找,但下面的写法更清晰:

const find = (ht, value) => {
❶ const i = hash(ht, value);
❷ return findInList(ht.slots[i], value);
};

计算相应的槽 ❶ ,然后进行搜索 ❷ 。注意,你必须将 find(...) 方法从列表中的原名称改为 findInList(...),以避免递归调用错误的函数。另一种可能是像 List.find(...) 这样编写。

从链式哈希表中移除一个值

再次强调,由于之前开发的所有代码,删除一个值非常简单:

const remove = (ht, value) => {
❶ const i = hash(ht, value);
❷ ht.slots[i] = removeFromList(ht.slots[i], value);
  return ht;
};

和添加值时一样,首先计算正确的槽位❶,然后通过使用 removeFromList(...)方法从列表中删除该值❷,该方法也已重命名以避免冲突。

考虑链式存储的性能

使用链式哈希时的最坏情况性能显然是O(n),如果所有的值都映射到同一个槽位。如果情况没有那么极端,并且有s个槽位,每个链的长度大约是n/s,因此查找的时间复杂度是O(n/s),实际上是O(n),但有一个更好的预期常数。槽位越多,链就越短,性能就越好。

你可以跟踪表格中有多少值(或者各个链的长度),如果这些数字超过某个限制,你可以重新创建一个有更多槽位的表格来提高性能。我们将在接下来的章节中研究这种过程。

开放地址法哈希

处理碰撞的另一种常见解决方案是:如果要使用的槽位已经被占用,尝试下一个位置(如果需要,再尝试下下一个位置,以此类推,直到表格的末尾,达到末尾后再循环回开始)直到找到一个空槽位。进行查找时,应用相同的方案:首先检查对应的哈希槽,如果槽是空的,则查找失败。如果槽被占用且是你想要的值,则查找成功;否则,继续查找下一个(按循环方式)位置并重试。你可以通过一个简单的例子来理解这个过程。首先,使用一个空的哈希表并向其中添加了 22,如图 11-13 所示。

图 11-13:只有一个元素的哈希表

你可以添加 04、75、09 和 60,每个都会进入其对应的槽位,如图 11-14 所示。

图 11-14:添加了四个元素,目前没有发生碰撞。

如果你尝试添加 12,就会出现问题,因为对应的槽位(第二个槽位)已经被占用。你必须开始向前推进,因此 12 最终会放入槽位 3,如图 11-15 所示。

图 11-15:当你尝试将一个值(12)添加到已占用的槽位时,发生碰撞。

随着表格逐渐变满,新值更有可能远离其正确的槽位;例如,如果你添加 63,它最终会位于槽位 6,如图 11-16 所示。

图 11-16:在一个更满的表格中,值最终会远离其对应的槽位。

当然,如定义所示,表格变满后,你将陷入死循环。负载因子被定义为占用槽位与总槽位的比例。一个空的哈希表负载因子为零,而完全满的哈希表负载因子为 1。这个结果是直观的,但你可以通过数学推导证明,随着负载因子的增长,插入和搜索的速度会逐渐变慢。根据经验法则,如果负载因子超过 0.75,应该迁移到更大的哈希表。

const load = (ht) => ht.used / ht.slots.length;

在进行搜索时,过程与插入完全相同。如果你要查找 63,你会从槽位 3 开始,如果槽位 3 没有该值,就继续前进直到找到它在槽位 6。如果你要查找的是 73,那么你会一直前进到槽位 7,发现该槽为空,从而得出 73 不在表中的结论。

删除操作并不是一个简单的过程。考虑删除 22,如图 11-17 所示。

图 11-17:删除值(22)的错误方式会导致其他搜索出错。

现在,如果你想搜索 12,会发生什么呢?发现槽位 2 为空时,你会认为 12 不在表中,这样就不好了。我们必须进行懒删除。我们不会在删除值时真正清空一个槽位,而是将其标记为可用。我们会将删除的位置在添加新值时视为空闲,而在搜索时视为已占用。删除 12 会得到图 11-18 所示的结果。

图 11-18:删除值(22)的正确方式是将槽位(#2)标记为“已使用但可用”。

对于搜索,槽位 2 被视为已占用,因此当查找 12 时,你不会停在槽位 2,而是继续向前查找。对于插入(假设稍后你想向表中添加 42),槽位 2 被视为可用,因此你可以使用它,如图 11-19 所示。

图 11-19:“已使用但可用”的槽位(#2)可以用于新的插入。

现在,既然你已经了解了哈希表如何工作以及如何处理删除的关键细节,接下来可以考虑实际的代码。

创建一个开放寻址哈希表

对于开放寻址,你只需要一个表格,但你还需要跟踪有多少槽位已经使用,以便计算负载因子。为了简化编码,你将首先定义几个常量:

const EMPTY = undefined;
const AVAILABLE = null;

所有尚未使用的槽位会被赋值为 EMPTY,而曾经占用但现在由于删除原始值而变得可用的槽位将被标记为 AVAILABLE。

一个新的哈希表默认会有 100 个槽位:

const newHashTable = (n = 100) => ({
❶ slots: new Array(n).fill(EMPTY),
❷ used: 0
});

给定哈希表所需的大小(默认为 100),创建一个大小为该值的空数组,数组中的所有槽位填充为 EMPTY 值 ❶,并将已使用槽位的初始计数设置为 0 ❷。

向开放寻址哈希表中添加一个值

你已经看到添加值的逻辑了,代码稍微有点长:

const add = (ht, value) => {
❶ let i = hash(ht, value);
❷ while (ht.slots[i] !== EMPTY && ht.slots[i] !== AVAILABLE) {
  ❸ i = (i + 1) % ht.slots.length;
  }

❹ if (ht.slots[i] === EMPTY) {
    ht.used++;
  }
  ht.slots[i] = value;
  return ht;
};

首先计算值应该放入哪个槽 ❶。然后开始一个线性搜索 ❷,直到找到一个 EMPTY 或 AVAILABLE 的槽;注意,使用取模操作 ❸ 会使搜索回绕到开始处。成功找到空槽后,如果该槽为空 ❹,则将已使用槽的计数加 1。如果你想知道为什么在槽是 AVAILABLE 时不这么做,你会在查看删除值时明白原因。一个重要的细节是,你假设哈希表有一些空闲空间。你将在查看开放地址哈希法的性能时看到如何处理这个问题。

实际上,你在这里实现的是一个袋(bag)。如果要实现集合(set),请参见第 11.7 题。

在开放地址哈希表中查找值

如前所述,搜索过程与插入过程类似。你将进行与插入新值时相同的过程,但会跳过实际的插入操作。代码如下:

const find = (ht, value) => {
❶ let i = hash(ht, value);
❷ while (ht.slots[i] !== EMPTY && ht.slots[i] !== value) {
    i = (i + 1) % ht.slots.length;
  }

❸ return ht.slots[i] === value;
};

和插入时一样,首先决定值应该放在哪个槽 ❶,如果需要,循环 ❷ 直到你找到该值或到达一个 EMPTY 槽。根据循环结束的情况,返回 true 或 false ❸。你会忽略 AVAILABLE 槽,原因很快就会明了。

从开放地址哈希表中删除值

删除值的操作强烈依赖于你如何进行搜索。关键问题在于,一旦找到要删除的值,你会将槽标记为 AVAILABLE,意味着该槽现在可以用于未来的插入,但它并不是真的空闲,因此在搜索时要把它视为已占用并继续搜索。

这是代码:

const remove = (ht, value) => {
❶ let i = hash(ht, value);
  while (ht.slots[i] !== EMPTY && ht.slots[i] !== value) {
    i = (i + 1) % ht.slots.length;
  }

❷ if (ht.slots[i] === value) {
    ht.slots[i] = AVAILABLE;
  }
  return ht;
};

第一部分 ❶ 与搜索函数中的代码相同。唯一的区别是,在循环结束后,如果搜索成功,将该槽设置为 AVAILABLE ❷。

一个重要的问题是,为什么你没有减少已使用的计数。一个边界案例展示了这个问题:假设你将 n 个值从 1 到 n 插入到一个大小为 n 的哈希表中,然后删除所有值,最后尝试添加任何新值。会发生什么?在检查该值是否已在表中时,会出现无限循环!负载因子考虑了所有已经或曾经被占用的槽;当负载因子过高时,你将在下一节看到该怎么办。

考虑使用开放地址法进行哈希时的性能

如前所述,当负载因子接近 1 时,搜索性能会显著下降,最终变为 O(n)。最坏情况下始终是 O(n);一个例子(当然不仅仅是这个)是如果所有的键都哈希到相同的槽。请参见 表 11-5。

表 11-5:开放地址哈希表操作的性能

操作 平均性能 最坏情况
创建 O(1) O(1)
添加 O(1) O(n)
删除 O(1) O(n)
查找 O(1) O(n)

如果你保持负载因子在合理范围内,你可以期待良好的性能,但随着表中值的增加,性能会下降。对此没有解决办法,但你可以修改 add(...)逻辑,使得表格在负载过高时自动增大。你只需要将添加值的代码中最终返回 ht 的部分修改为以下内容:

❶ if (load(ht) > 0.75) {
  ❷ let newHT = newHashTable(ht.slots.length * 2);
  ❸ ht.slots.forEach((v) => {
    ❹ if (v !== EMPTY && v !== AVAILABLE) {
      ❺ newHT = add(newHT, v);
      }
    });
  ❻ return newHT;
  } else {
  ❼ return ht;
  }
};

如果负载因子超过了推荐的 0.75 阈值❶,则创建一个新的哈希表,大小翻倍❷,并逐槽遍历原表❸。你找到的每一个值❹都会被添加到新表中❺。最后,与你之前返回原表不同,你将返回新的、更大的表❻。如果负载因子是可接受的❼,你将像之前一样返回原表。关于另一种技术,请参见第 11.9 题。

这种逻辑对于接下来我们将考虑的哈希表版本也非常有用。

双重哈希

你一直在应用的逻辑对碰撞的处理帮助不大。如果两个值在某个槽位发生冲突,它们也会在接下来的槽位发生冲突,依此类推。这种方案很可能会产生长时间的相邻占用槽位,从而减慢搜索和插入速度。

在这种情况下,一种有用的思路是,不总是尝试下一个槽位,而是跳过若干个槽位,并使跳跃的槽位数依赖于值,这样不同的值跳过不同数量的槽位。双重哈希的概念就是这样工作的:第一个哈希函数确定第一个尝试的槽位,但如果该槽位已被占用,第二个哈希函数决定跳跃的步长,而不是总是跳到下一个槽位。

如果你回到“使用开放地址法的哈希”部分中的示例(第 221 页),虽然没有发生冲突,但一切运作相同,因此在第一次插入五个值后,你将看到图 11-20 中的情况。

图 11-20:使用双重哈希的哈希表,目前没有发生冲突

现在你想添加 12,而第 2 号槽已经被占用。在前一节中,你使用了开放地址法,所以你尝试了第 3 号槽,如果它也被占用,你将依次尝试第 4 号槽、第 5 号槽,直到找到一个空槽。而使用双重哈希时,你将使用第二个哈希函数来决定跳跃多远。使用值对 9 取余数,再加 1,这个值保证是在 1 到 9 之间的一个数。对于值 12,步长将是 4,因此下一个尝试的位置将是第 6 号槽,如图 11-21 所示。

图 11-21:双重哈希使用第二个函数来处理冲突。

如果槽位#6 已被占用,你将再次按四个槽位的步长(循环地)向前移动,尝试槽位#0,然后是槽位#4,以此类推。这里有一个可能的问题。如果你想将值 130 添加到图 11-21 中的表格,结果会怎么样?第一次尝试会在槽位#0,失败。第二次尝试会在槽位#5(因为 130 的步长是 5),但也失败。第三次尝试会再次在槽位#0(因为是循环搜索),你将陷入循环。

你需要处理(并解决)这些循环问题;有两种不同的方法可以实现这一点。

创建一个使用双重哈希的表

创建一个双重哈希表的过程与创建开放地址法表完全相同,因此这里不需要特别的代码。为了方便参考,再次提供所需的逻辑:

const EMPTY = undefined;
const AVAILABLE = null;

const newHashTable = (n = 100) => ({
  slots: new Array(n).fill(EMPTY),
  used: 0
});

关键的区别在于你如何添加、搜索和删除值。

向使用双重哈希的表中添加一个值

你已经在本节前面看到了向双重哈希表中添加值的过程,现在我们来看看代码。你将使用两个哈希函数:第一个用来确定初始的槽位,第二个用来在搜索过程中跳过某些位置。非常重要的一点是,第二个函数必须永远返回一个非零值。

const hash1 = (ht, value) => value % ht.slots.length;
const hash2 = (ht, value) => 1 + (value % (ht.slots.length - 1));

向表中添加值的逻辑需要特别注意,以避免无限循环:

const add = (ht, value) => {
❶ let i = hash1(ht, value);
❷ if (ht.slots[i] !== EMPTY && ht.slots[i] !== AVAILABLE) {
  ❸ const step = hash2(ht, value);
  ❹ let i0 = i;
  ❺ while (ht.slots[i] !== EMPTY && ht.slots[i] !== AVAILABLE) {
    ❻ i = (i + step) % ht.slots.length;
    ❼ if (i === i0) {
        i = (i + 1) % ht.slots.length;
        i0 = i;
      }
    }
  }

❽ if (ht.slots[i] === EMPTY) {
    ht.used++;
  }
  ht.slots[i] = value;
  return ht;
};

首先使用第一个哈希函数❶来获取初始槽位。如果该槽位不为空❷,则使用第二个哈希函数❸来确定每一步跳跃的距离。你将保存初始槽位 i0❹以检测循环,然后开始寻找空槽或可用槽❺。在每次循环中,跳跃步数增加❻,如果检测到你回到了初始的 i0 位置,就只跳跃一步,并保存新的初始槽位❼。找到新值的位置后❽,逻辑与之前的哈希方法相同:更新已用槽位的计数并保存值。若负载因子较高,应该像开放地址法部分中那样重新生成表。

在使用双重哈希的表中搜索一个值

搜索值的逻辑与添加新值的方式相匹配:

const find = (ht, value) => {
❶ let i = hash1(ht, value);
❷ const step = hash2(ht, value);
❸ let i0 = i;
❹ while (ht.slots[i] !== EMPTY && ht.slots[i] !== value) {
    i = (i + step) % ht.slots.length;
  ❺ if (i0 === i) {
      i = (i + 1) % ht.slots.length;
      i0 = i;
    }
  }

❻ return ht.slots[i] === value;
};

首先决定测试的初始槽位❶和跳跃的步长❷。然后保存初始位置以检测循环❸,并开始跳跃,直到找到空槽或所需的值❹;跳跃的逻辑与插入时完全相同,包括循环检测❺。最后,根据你停止搜索的位置,返回 true 或 false❻。

从使用双重哈希的表中删除一个值

我在这里不会重复解释,但是要删除一个值,你需要使用与开放地址法相同的技巧。你不会将删除的值标记为 EMPTY,而是标记为 AVAILABLE。代码如下:

const remove = (ht, value) => {
  let i = hash1(ht, value);
  let i0 = i;
  const step = hash2(ht, value);
  while (ht.slots[i] !== EMPTY && ht.slots[i] !== value) {
    i = (i + step) % ht.slots.length;
    if (i0 === i) {
      i = (i + 1) % ht.slots.length;
      i0 = i;
    }
  }

❶ if (ht.slots[i] === value) {
    ht.slots[i] = AVAILABLE;
  }
  return ht;
};

代码和开放地址查找的代码完全相同,唯一不同的是在完成循环后要做什么。如果找到了值❶,将槽位标记为“可用”;如果没找到,不做任何操作。

双重哈希的逻辑运行得很好,但你可以很容易地使其更加简洁,接下来我们将看到这一点。

使用质数长度的双重哈希

你在使用双重哈希操作时看到的逻辑唯一的问题是需要处理可能的循环。每当你选择的步长与表的长度有公共因子时,就会形成一个循环。例如,如果表的大小是 18,步长是 12,经过三步后你会回到原来的位置。如果你能选择一个与所有可能的步长没有公共因子的表长度,那么逻辑会更简单。有一个简单的方法可以做到这一点:如果表的长度是质数(仅能被 1 或它本身整除),就不可能有循环,因为质数与任何较小的数字都没有公共因子。而且,如果步长是 1,一切都没问题,因为在回到初始位置之前,你会遍历整个数组。

创建一个使用质数长度的表

你可以像以前一样创建一个新的哈希表,只是你必须确保它的长度是质数。首先你需要检查一个数字是否是质数:

const isPrime = (n) => {
❶ if (n <= 3) {
   return true;
❷} else if (n % 2 === 0) {
   return false;
 }

❸ for (let d = 3, q = n; d < q; d += 2) {
   q = n / d;
 ❹ if (Math.floor(q) === q) {
     return false;
   }
 }
❺ return true;
};

小的数字是质数(在这里 1 也算作质数,不管数学家怎么说)❶。偶数(你在之前的 if 语句中排除了 2)不是质数❷,所以这些情况比较简单。对于其他数字,从 3 开始测试所有可能的奇数除数❸,直到找到一个精确的除法❹,或者测试的除数超过了该数字的平方根,在这种情况下,这个数字就是质数❺。

接下来你需要一个简单的函数来找到大于给定值的第一个质数,你可以通过使用 isPrime(...)函数轻松编写:

const findNextPrime = (n) => {
❶ while (!isPrime(n)) {
  ❷ n++;
  }
  return n;
};

逻辑很简单:给定一个数字,如果它不是质数❶,就加 1❷,直到数字变成质数。

现在你可以创建一个表,逻辑和之前一样,只是你要确保表的长度是质数:

const newHashTable = (n = 100) => ({
  slots: new Array(findNextPrime(n)).fill(EMPTY),
  used: 0
});

无论你得到什么大小,都可以找到下一个更大的质数,并将其作为表的长度。

向使用质数长度的表中添加一个值

添加一个值时,和双重哈希代码的方式一样,只是你不需要测试循环;质数已经解决了这个问题:

const add = (ht, value) => {
  let i = hash1(ht, value);
  if (ht.slots[i] !== EMPTY) {
    const step = hash2(ht, value);
    while (ht.slots[i] !== EMPTY && ht.slots[i] !== AVAILABLE) {
      i = (i + step) % ht.slots.length;
    }
  }

  if (ht.slots[i] === EMPTY) {
    ht.used++;
  }
  ht.slots[i] = value;
  return ht;
};

你会像之前一样继续,但与 i0 相关的所有代码(你用来检测循环的)现在已经删除。

在使用质数长度的双重哈希的表中查找值

查找也变得更简单:

const find = (ht, value) => {
  let i = hash1(ht, value);
  const step = hash2(ht, value);
  while (ht.slots[i] !== EMPTY && ht.slots[i] !== value) {
    i = (i + step) % ht.slots.length;
  }

  return ht.slots[i] === value;
};

再次将这段代码与常见双重哈希代码进行比较,主要的区别在于你不再需要进行任何循环检测和预防。

从使用质数长度的表中删除一个值

最后,正如预期的那样,删除一个值也变得更简单:

const remove = (ht, value) => {
  let i = hash1(ht, value);
  const step = hash2(ht, value);
  while (ht.slots[i] !== EMPTY && ht.slots[i] !== value) {
    i = (i + step) % ht.slots.length;
  }

  if (ht.slots[i] === value) {
    ht.slots[i] = AVAILABLE;
  }
  return ht;
};

再次强调,代码与常规双重哈希相同,只是没有检查循环;它的代码更快、更简洁。

总结

在这一章中,我们考虑了几种实现袋(bags)和集合(sets)的方法,包括哈希技术,当正确应用时,它可以提供最快的搜索时间。这里讨论的结构基本上是线性的;在下一章中,我们将开始考虑非线性结构,如树,以进一步探索袋和集合的实现。

问题

11.1  搜索的哨兵

展示如何通过为有序列表添加一个最终的 +Infinity 哨兵值,使代码更简洁。

11.2  更多的哨兵?

为有序列表添加一个初始的 -Infinity 哨兵值是否有帮助?

11.3  更简单的搜索?

你能简化代码,避免在搜索自组织列表时返回两个值吗?提示:如果搜索成功,列表将不会为空,且其头部将包含搜索的值。

11.4  重新跳过列表

你能画出一个算法,将跳表重新结构化,以确保它是平衡的吗?

11.5  跳到一个索引

在之前的定义中,你只是想搜索一个值,但如果你有一个索引 i,并且想要获取列表中的第 i 个值呢?你能想到一种修改跳表的方法,以便通过索引高效地查找一个值吗?

11.6  更简单的填充

为什么下面的代码不能创建一个带有链式处理的哈希表?

const newHashTable = (n = 100) => ({
  slots: new Array(n).fill(newList())
});

11.7  哈希集合

在哈希表的插入代码中,你允许重复值,因此你做的是袋(bags)而不是集合。你能尽可能高效地修改代码,以实现集合功能吗?显然,你可以从做一个搜索开始,但如果搜索失败,你将为添加操作重新做很多工作。

11.8  错误的座位安排

这个问题会让你想起哈希。假设有 100 个人获得了一场演出的票。剧院有 100 个座位,每张票都分配到不同的座位。然而,出现了一个问题。第一个到达剧院的人没有注意,坐到了一个随机的座位。其他人也试图去他们的座位,如果座位已经有人坐,他们也坐到了随机的座位。那么,最后一个人(第 100 个)坐到空座位的概率是多少?

11.9  渐进式调整大小

做一个完整的调整大小操作(并伴随相应的时间延迟)可能不适合某些系统,因此你需要一种渐进式调整大小的解决方案。你能画出一种逐步重新哈希的方式,借助两个表(一个旧表和一个新表),但不一次性重新哈希整个旧表吗?

第十二章:12 二叉树

我们在之前的章节中已经考虑过线性结构,现在我们将开始处理更复杂的结构——特别是二叉树及其一些变体。(我们将在下一章探讨更一般的树。)二叉树在许多地方都有应用,包括数据压缩算法、视频游戏、加密技术、编译器等,因此它们是值得了解的结构。

一种特殊的变体——二叉搜索树,对于实现我们在上一章中探讨的袋(bag)或集合(set)非常高效。然而,由于这些树在某些情况下可能表现不佳,我们还将考虑一些变种,例如保证平衡的二叉搜索树(AVL 树)和概率平衡树(随机二叉搜索树和伸展树)。

什么是树?

树结构允许你表示层次化的数据结构。它们与线性结构不同,因为一个节点可以连接多个其他节点,尽管存在一些限制。组织结构图(或组织图)是树的典型例子,其中一个企业的一个部分可以有多个子部分,这些子部分本身也可能有子子部分,依此类推,采用递归的方式,如图 Figure 12-1 所示。

图 12-1:来自 NASA 的组织结构图,1961 年 11 月。别担心如果文字不可读;重要的是结构部分,而不是标签。(来源:commons.wikimedia.org/wiki/File:NASA_Organizational_Chart_November_1,_1961.jpg

超文本标记语言(HTML)也具有类似树的结构。一个 HTML 元素可以包含多个其他元素,这些元素本身也可能包含进一步的元素。计算机上的目录结构也采用树形结构。一个目录下有文件和其他目录,这些目录下也有文件和更多目录,以此类推。(请参见本章末尾的第 12.2 题,那里有个例外情况。)

一般树

一棵树可以为空,或者由一个节点(称为树的节点)组成,该节点有若干子树,每个子树当然也可以为空。根节点是其子树的父节点,而那些子树的根节点是根节点的子节点。形成树的节点通过连接。既有父节点也有子节点的节点被称为内部节点,没有子节点的节点被称为外部节点,或者(更符合树的概念)称为叶子节点。给定一个节点,它的子节点、子节点的子节点,以此类推,统称为它的后代。类似地,节点的父节点、父节点的父节点,以此类推,统称为节点的祖先。树的根节点的层级为 1,它的子节点为 2 层,子节点的子节点为 3 层,依此类推。节点非空子节点的数量称为它的。最后,对于任何一棵树或子树,它的大小是指其节点的数量,而它的高度是指从根节点到叶子节点的最长路径上节点的数量。

根据之前的定义,可以得出结论:任何两个节点之间最多只有一条路径;树中不能存在任何循环或环路。另外一个属性是:给定任意两个节点,要么一个是另一个的祖先,要么它们有一个共同的祖先。

树通常表示为根节点在上,叶子节点在下。即使这与生物学不符,我们也将遵循这种风格。一个可能的树形结构可以像图 12-2 一样(根节点在上,所有链接向下)。

图 12-2:树通常是根节点在上,枝干向下分布到叶子节点,尽管生物学上并非如此。

我们将在下一章讨论一般树(即每个节点可以有任意数量的子树),所以这里我们将重点讨论最常见的版本——二叉树。

二叉树

二叉树要么为空,要么恰好有两个子树。稍后我们会看到一些额外的定义,所以图 12-2 中的树实际上也可以是二叉树。如果每个节点要么是叶子节点,要么有两个非空子节点,那么这棵树是二叉树。图 12-3 展示了一个可能的情况。

图 12-3:满二叉树:所有节点要么没有子节点,要么有两个子节点。

满二叉树本身并不特别有趣,除非它还满足其他一些属性。例如,如果一棵树是满的,并且所有叶子节点都在同一层级,那么它被称为完美二叉树,这意味着节点尽可能紧密地排列在一起,如图 12-4 所示。

图 12-4:完美的二叉树是满的,且所有叶子节点位于同一层级。

一个有趣的性质是,通过简单的数学证明,完美二叉树的大小为 h 高度的 2^h – 1,因此添加一个新层大约会使树的大小翻倍。反之,具有 n 个节点的完美树的高度是 log n,向上取整。(我们使用的是以 2 为底的对数。)

最后,如果你有一个高度为 h 的树,在删除所有 h 层的节点后(最后一层可以例外),它就会变成完美的树,这种树称为 完全树。我们将在稍后的第十四章中学习堆时,进一步探讨这些结构。图 12-5 展示了一个完全树,因为如果你删除底层的所有节点,剩下的将是一个完美的树。

图 12-5:如果删除底部叶子节点,一个完全树就会变成满二叉树。

了解了通用定义后,我们开始讨论二叉树。我们将在每个节点中包括一个键值,并且可以根据需要添加更多的数据属性。我们还将为左右子树设置指针:每个指针要么为空,要么指向另一个二叉树。让我们开始编写一个二叉树模块(你将会在后面的二叉树变体中重复使用一些方法):

❶ const newTree = () => null;

❷ const newNode = (key, left = null, right = null) => ({
  key,
  left,
  right
});

❸ const isEmpty = (tree) => tree === null;

这段代码不复杂:newTree() ❶ 构建一个初始为空的树;newNode() ❷ 创建一个具有给定键值和(默认是空的)子树的新节点;isEmpty() ❸ 检测树是否为空(这没有什么好惊讶的)。

二叉搜索树

在本章剩下的部分,我们将使用 二叉搜索树,这是一种二叉树的变体,用来实现 集合 抽象数据类型(ADT),因为它们提供了非常高效的键值查找。(记住,袋允许重复值,而集合则不允许。)对于这些树,每个节点将是一个包含键的对象,并且有一些链接指向它的子节点;实际上,你也可以在节点中包含额外的数据字段,用于其他用途。表 12-1 描述了该 ADT。

表 12-1:集合的操作

操作 签名 描述
创建 → 集合 创建一个新的集合。
是否为空? 集合 → 布尔值 给定一个集合,确定它是否为空。
添加 集合 × 值 → 集合 | 错误 给定一个新值,将其添加到集合中。
移除 集合 × 值 → 集合 给定一个值,从集合中移除它。
查找 集合 × 值 → 布尔值 给定一个值,检查它是否存在于集合中。

二叉树和二叉搜索树有什么区别?二叉搜索树满足以下性质:对于所有节点,左子树的键都比根节点的键小,右子树的键都比根节点的键大。如果你决定允许重复键,你需要修改这个条件,规定左子树的键小于或等于根节点的键,右子树的键大于或等于根节点的键。在图 12-6 中,其中一棵树是二叉搜索树,另一棵则不是,原因是一个不幸的细节。你能分辨出哪一棵是吗?

图 12-6:两棵二叉树,但只有一棵是二叉搜索树。哪一棵是?

底部的树不是二叉搜索树,因为 13 的键在 22 的右侧,而它应该在左侧。你能找出它应该放在哪里吗?

关于根节点和子树键的这个性质,使得你可以将二叉搜索树用作集合。

在二叉搜索树中查找键

关于键之间关系的递归性质(同样适用于每个子树)提供了一种简单的搜索方法。如果你在二叉搜索树中查找一个给定的值,必定会发生以下三种情况之一:

  • 如果值就是根节点的键,那你就完成了搜索。

  • 否则,如果值小于根节点的键,该值(如果存在)一定在左子树中。

  • 最后,如果值大于根节点的键,该值一定在右子树中。

你可以测试这个。图 12-7 展示了成功找到 12 的搜索过程,突出显示了所走的路径和所有访问过的节点。

图 12-7:在二叉搜索树中成功找到键 12 的搜索过程

搜索从根节点开始。由于 12 < 22,搜索转向左子树。在那里,由于 12 > 9,搜索进入右子树。接着,由于 12 > 11,搜索再次进入右子树,最终找到目标值。如果你要找的是 34,搜索将会失败,如图 12-8 所示。

图 12-8:在二叉搜索树中查找键 34 失败的过程

由于 34 > 22,搜索从根节点的右子树开始;接着,因 34 < 56,搜索转向左子树。然后,因 34 > 24,搜索尝试进入右子树,但发现是一个空树(用虚线边框表示),因此搜索失败。

你可以直接编写这个逻辑代码,甚至在考虑如何进行树的插入或删除操作之前:

const find = (tree, keyToFind) => {
❶ if (isEmpty(tree)) {
   return false;
❷} else if (keyToFind === tree.key) {
   return true;
 } else {
 ❸ return find(keyToFind < tree.key ? tree.left : tree.right, keyToFind);
  }
};

由于树是递归定义的,因此这个算法(以及本章中的大多数算法)是通过递归实现的,这并不令人惊讶。这里有两个基本情况:如果树为空 ❶,则键不在树中;如果键与正在查找的值匹配 ❷,搜索成功。但接下来如何继续搜索呢?如果你查找的键小于根节点的键,你就递归地搜索左子树,反之则搜索右子树 ❸。

向二叉搜索树中添加值

我们如何将新键添加到树中?让我们使用袋子,并接受重复键;你也会看到如何处理集合。要小心不要破坏根节点与其子树之间的关系——使用递归算法是最好的方法。如果树为空,你可以简单地添加一个新的叶子节点。如果树不为空,使用递归深入左或右子树,具体取决于新键应该位于何处,直到到达一个空树,在那里可以插入新键。

上一节展示了一个失败的查找操作,目标是查找键值 34,因此现在新键值将被添加到查找结束的位置,正如在图 12-9 所示。

图 12-9:在二叉搜索树中将新键值添加到应该在查找中找到的位置

其代码如下:

const add = (tree, keyToAdd) => {
❶ if (isEmpty(tree)) {
    return newNode(keyToAdd);
  } else {
  ❷ const side = keyToAdd <= tree.key ? "left" : "right";
    tree[side] = add(tree[side], keyToAdd);
    return tree;
  }
};

如果树是空的 ❶,创建一个包含要添加的键的新节点,并将其作为根节点。如果树不为空,则决定哪个子树需要添加新键 ❷,然后从那里递归地进行操作。(如果实现的是集合而不是袋子,应该检查 keyToAdd 是否等于 tree.key,如果相等则拒绝添加;见问题 12.16。)这个示例使用了一种与 find() 中不同的编码风格,主要是为了多样性。

从二叉搜索树中移除值

现在让我们看看如何从二叉搜索树中移除一个键。考虑图 12-10 中所示的树。

图 12-10:在删除某些键之前的二叉搜索树

如果你尝试移除一个在树中找不到的键,你什么也不需要做。很简单。

另一个简单的情况是移除叶子节点:只需移除其键,这样它就变成了一个空树。例如,移除 10 会导致如下情况,其中 11 的左子树为空,如图 12-11 所示。

图 12-11:移除叶子节点(此处为键值 10)是直接的。

然而,事情可能会变得复杂。例如,如果你想移除一个至多只有一个子节点的节点,这仍然很简单。只需将其替换为它的子节点,如图 12-12 所示,其中 24 键通过将 23 键设置为 56 键的左子节点被移除。

图 12-12:移除一个只有一个子节点的键(此处为 24)也很简单。

一个复杂的问题是处理具有两个非空子节点的节点。最常见的解决方案是找到它后面紧跟的键,移除它,并将其放入你想要移除的节点位置。例如,如果你想移除图 12-12 中的键 9,由于该节点有两个子树,你需要搜索下一个较大的键(在此示例中为 10),将其移除,并将其放入 9 键的位置,正如在图 12-13 所示。

图 12-13:移除一个键(这里是 9)是最困难的情况;你必须放置另一个键来替代它,以保持二叉搜索树的结构。

这种替代被删除键的方法不会破坏搜索规则。不过,仍有一个缺失的步骤——也就是如何找到下一个更大的键。我们将在下一节讨论这个问题,首先是移除键的代码:

const remove = (tree, keyToRemove) => {
❶ if (isEmpty(tree)) {
   // nothing to do
❷} else if (keyToRemove < tree.key) {
   tree.left = remove(tree.left, keyToRemove);
❸} else if (keyToRemove > tree.key) {
   tree.right = remove(tree.right, keyToRemove);
❹} else if (isEmpty(tree.left) && isEmpty(tree.right)) {
   tree = null;
❺} else if (isEmpty(tree.left)) {
   tree = tree.right;
❻} else if (isEmpty(tree.right)) {
   tree = tree.left;
❼} else {
 ❽ tree.key = minKey(tree.right);
 ❾ tree.right = remove(tree.right, tree.key);
 }

  return tree;
};

前三个条件 ❶ ❷ ❸ 与 find() 方法相匹配:检查树是否为空;如果没有找到要删除的键,则递归地遍历子树。接下来的情况 ❹ 处理删除叶节点:将树设置为 null。接下来的两个条件 ❺ ❻ 处理只有一个子节点的节点;将树设置为该子节点。最后,在最后一种情况 ❼ 中,你必须找到紧随其后的键 ❽,用它替换要删除的键,然后通过递归地从右子树 ❾ 中删除该键来完成操作。你可以通过考虑如何找到下一个更大的键来完成算法。

注意

这并不是唯一的删除方法;我们将在“从随机树中删除键”一节(见第 267 页)和“从 Treap 中删除键”一节(见第 336 页)中看到更多的实现方式。

在二叉搜索树中查找最小值或最大值

由于根节点与其子树之间的关系,所需的键(即下一个键)必须是右子树中的最小值。(相反,前一个键将是左子树中的最大值。)图 12-14 展示了如何查找比 9 更大的键。你需要去它的右子树,然后不断向左移动,直到不能再向左走,从而找到 10 这个键。

图 12-14:查找下一个键;这里你想要找到比 9 更大的最小键。

另一个例子是,如果你想找到 23 的前一个键,你需要先去它的子树,然后向移动,直到到达末尾,找到 22 这个键。请记住,这个逻辑仅适用于具有必要子树的节点。如果你想找到 11、12 或 22 的下一个键,逻辑将会失败。幸运的是,这种情况不适用于你想要找到下一个更大的键的情况。

你可以利用类似的逻辑来实现 minKey() 和 maxKey() 方法:

❶ const _minMax = (tree, side, defaultValue) => {
❷ if (isEmpty(tree)) {
   return defaultValue;
❸} else if (isEmpty(tree[side])) {
   return tree.key;
 } else {
 ❹ return _minMax(tree[side], side, defaultValue);
 }
};

const minKey = (tree) => _minMax(tree, "left", Infinity);
const maxKey = (tree) => _minMax(tree, "right", -Infinity);

首先查看 minKey(),这是您在此情况下需要的;maxKey() 类似。您有一个辅助的 _minMax() 方法,它根据 minKey() 和 maxKey() 传递给它的参数进行实际搜索❶。查找最小值需要始终向左走,因此这解决了 _minMax() 的第二个参数,它将沿着这一侧一直向下 ❹,直到找到一个空树❸。现在,如果您尝试查找一个空树的最小值❷,应该返回什么值呢?您将执行 Math.min() 函数的相同操作;如果没有传递任何参数给它,它会返回 Infinity(类似地,Math.max() === -Infinity),所以这就是 _minMax() 的第三个参数。

注意

如果您分析删除算法,您可能会发现它做的工作比需要的多,因为它会先遍历右子树找到下一个键,然后再次遍历同一个子树来删除找到的键。为什么不将两者合并一次完成呢?请参阅问题 12.17 中的优化方法。 ##### 遍历二叉搜索树

许多过程涉及访问树的所有节点(也叫做遍历树或进行树遍历),以便对每个节点执行某些操作——例如,您可能在一个二叉搜索树中存储了单词,并希望生成一个按字母顺序排列的单词列表。这就是所谓的访问节点。如果您不想排除任何节点,那么存在三种可能的遍历情况(这些遍历方法中的前缀 pre-、in- 和 post- 与根节点的访问时机相关):

前序遍历 访问树的根节点,然后遍历其左子树,最后遍历右子树。

中序遍历 先遍历左子树,然后访问根节点,最后遍历右子树。

后序遍历 先遍历左子树,再遍历右子树,最后访问根节点。

当然,遍历一个空树时什么也不做,因为访问只适用于已存在的键。同时,注意子树的遍历是通过递归地应用相同的遍历算法来完成的。

这是一个基本的算法,其中默认的 visit() 方法只是打印访问的键:

const preOrder = (tree, visit = (x) => console.log(x)) => {
  if (!isEmpty(tree)) {
    visit(tree.key);
    preOrder(tree.left, visit);
    preOrder(tree.right, visit);
  }
};

const inOrder = (tree, visit = (x) => console.log(x)) => {
  if (!isEmpty(tree)) {
    inOrder(tree.left, visit);
    visit(tree.key);
    inOrder(tree.right, visit);
  }
};

const postOrder = (tree, visit = (x) => console.log(x)) => {
  if (!isEmpty(tree)) {
    postOrder(tree.left, visit);
    postOrder(tree.right, visit);
    visit(tree.key);
  }
};

代码遵循描述:例如,preOrder()首先访问根节点,然后遍历左子树,最后遍历右子树。

为了调试目的,能够按升序打印树的键列表非常有用。如果您有一棵树并调用 inOrder(),所有键将按顺序列出。它从根节点开始,处理所有小于根节点的键(按顺序列出)。接下来,它打印根节点,然后处理所有大于根节点的键(也按顺序列出),从而提供所需的结果。

注意

这个算法类似于 第六章 中的快速排序。你有一组左侧的键,将它们排序。然后你有一个枢轴键,然后你有一组右侧的键,也将它们排序,最终的结果就是完整的有序数组。

获取键的列表是可以的,但看到树的结构会更好,因此你需要获取树的打印输出。请参考 图 12-15 中的树。

图 12-15:我们希望打印出其结构的二叉搜索树

你可以使用 console.log() 来输出,但这并不太友好;console.dir() 会稍好一些。你也可以尝试类似 console.log(JSON.stringify(tree)) 的方式,但那样输出非常难以阅读,你会得到一些非常不友好的输出:

{"key":22, "left":{"key":9,  "left":{"key":4, "left":null, "right":null}, "rig
ht":{"key":11, "left":{"key":10, "left":null,"right":null}, "right":{"key":12,
 "left":null, "right":null}}}, "right":{"key":60, "left":{"key":24, "left":{"k
ey":23, "left":null, "right":null}, "right":{"key":56, "left":null, "right":nu
ll}}, "right":null}}

为了理解树的结构,可以考虑基于前序遍历代码的 print() 方法。它首先打印根节点,接着打印左子树(前面加上 L: 来表示左子树),然后是右子树(加上 R:),并且将子节点往右缩进,子节点的子节点进一步缩进,以此类推。

结果输出内容大致如下:

 22
  L: 9
  L:  L: 4
  L:  R: 11
  L:  R:  L: 10
  L:  R:  R: 12
  R: 60
  R:  L: 24
  R:  L:  L: 23
  R:  L:  R: 56

根节点(22)在顶部,接着是 L: 9(再下面是 R: 60),显示了根节点的两个子节点。对于每个新的键,你还会看到它的子节点,且缩进更深,因此对于调试来说是足够清晰的。

产生这种输出的代码如下:

const print = (tree, s = "") => {
  if (!isEmpty(tree)) {
    console.log(s, tree.key);
    print(tree.left, `${s}  L:`);
    print(tree.right, `${s}  R:`);
  }
};

如果你将逻辑与之前的 preOrder() 方法进行比较,会发现其基本思路相同:首先处理键,然后按顺序处理它的左子树和右子树。

考虑二叉搜索树的性能

现在我们已经详细了解了二叉搜索树算法,那它们的性能如何呢?我们先从 最坏 情况开始。你在树中添加了几个键之后,可能遇到的最糟糕情况是某种线性结构,就像 图 12-16 中所示,这种结构在搜索时基本上等同于简单的链表,时间复杂度是 O(n)。

图 12-16:一些最坏情况下的二叉搜索树

回到我们之前看到的树形结构,显然树的形状会影响算法的性能。最理想的树形结构是最好的,其时间复杂度是 O(log n)。对于类似线性结构的情况,搜索的时间复杂度会变为 O(n),而对于大树来说,这是一个巨大的差异。

从概率角度来看,如果你随机顺序取一组键,可以证明大多数树的高度会比较短,坏情况会相对较少。虽然最坏情况下时间复杂度仍为 O(n),但在平均情况下,我们期望获得 O(log n) 的性能。表 12-2 展示了树的平均和最坏情况性能。

表 12-2:二叉搜索树操作的性能

操作 平均性能 最坏情况
创建 O(1) O(1)
添加 O(log n) O(n)
删除 O(log n) O(n)
查找 O(log n) O(n)
遍历 O(n) O(n)

那么该怎么办呢?接下来我们将探讨两个方案,旨在确保树不会变成不良形状,并保持尽可能短且平衡。

保证平衡的二叉搜索树

正如我们之前看到的,树可能变成线性(或几乎线性)的结构,性能会非常差。不过,确保树保持平衡是可能的,从而保证最佳的性能。这里有两种不同的处理方式:

  • 保证平衡树之所以高效,是因为它们遵循某些明确的结构约束,这些约束确保树始终不会失衡,但这也意味着额外的运行时间和内存使用,需要更复杂的算法——通常是 add()和 remove()——来确保修改树之后约束仍然适用。这些树提供了在绝对意义上(一种非摊销也非概率的方式)的稳定性能。

  • 概率平衡树(或自调整树)仅在摊销意义上是高效的。它们没有遵循任何显式的结构规则,而是可以呈现任意形态,依赖像 add()或 find()这样的操作来调整结构,以便随着时间的推移,最有可能获得更好的性能。

高度平衡的AVL 树通过强制保证每个节点的左右子树高度差不超过 1,来避免树失去平衡。权重平衡树也能保证平衡,通过保持每个节点的左右子树的权重差不超过某个给定的因子;稍后我们将讨论有界平衡(BB[α])树。

AVL 树

1962 年,Adelson-Velsky 和 Landis 发明的 AVL 树,通过遵循一个简单的规则保持良好的平衡:对于所有节点,左右子树的高度差最多为 1。这自动排除了所有表现不佳的二叉树形态。

图 12-17 展示了一棵平衡的树和一棵不平衡的树。哪个是哪个?

图 12-17:两棵二叉树,只有一棵是平衡的。是哪一棵?

最右边的树是平衡的,而最左边的树不是,因为根节点的左子树失衡:它的左子树高度为 3,右子树高度为 1。节点的平衡是指其左右子树高度之差,因此图 12-17 中的正确树的平衡情况,将如图 12-18 所示。

图 12-18:展示了所有节点平衡情况的平衡二叉树

现在我们已经了解了 AVL 树的期望形态,你可以开始编写它们的代码了。

定义 AVL 树

我们将基于二叉搜索树构建 AVL 树。许多操作仍然适用——例如,在 AVL 树中查找一个键与在普通二叉搜索树中的方式完全相同,所以我们在这里不会再展示该代码。不过有一个小的不同之处:你需要为每个节点添加一个高度属性,以帮助检查它是否平衡,并且需要编写代码来访问或计算该属性。基本的代码如下——请注意,你将重用一些基本二叉搜索树的方法:

const newAvlTree = () => null;

const newNode = (key) => ({
  key,
  left: null,
  right: null,
❶ height: 1
});

❷ const _getHeight = (tree) => (isEmpty(tree) ? 0 : tree.height);

❸ const _calcHeight = (tree) =>
  isEmpty(tree)
    ? 0
    : 1 + Math.max(_getHeight(tree.left), _getHeight(tree.right));

❹ const _calcBalance = (tree) =>
  isEmpty(tree) ? 0 : _getHeight(tree.right) - _getHeight(tree.left);

当构造一个新节点时,添加新的 _ 高度属性❶,然后有一个 _getHeight()方法来访问它;注意,空树的高度是 0❷。新的 _calcHeight()方法❸计算节点的高度;假设两个子树已经计算出它们自己的高度,而整个树的高度是其最高子树的高度加一。最后,计算节点的平衡性❹,即右子树和左子树的高度差。该平衡值只能是-1、0 或 1;其他值意味着树不平衡。

向 AVL 树添加一个键

添加新键的逻辑与我们之前看到的类似,唯一的不同因素是:在决定添加新键的位置后,树可能会失去平衡,所以你需要移动节点来恢复平衡。以下是附加的代码:

const add = (tree, keyToAdd) => {
  if (isEmpty(tree)) {
    tree = newNode(keyToAdd);
  } else {
    const side = keyToAdd <= tree.key ? "left" : "right";
    tree[side] = add(tree[side], keyToAdd);
  }

 **return _fixBalance(tree);**
};

这与二叉搜索树的代码完全相同,只是它添加了一个最终的 _fixBalance()调用,用于在需要时平衡树。在进入这一部分之前,让我们回顾一下如何删除键,这与之前做的非常相似。

从 AVL 树中删除一个键

在了解如何添加新键之后,删除键会显得很熟悉:

const remove = (tree, keyToRemove) => {
  if (isEmpty(tree)) {
    // nothing to do
  } else if (keyToRemove < tree.key) {
    tree.left = remove(tree.left, keyToRemove);
 } else if (keyToRemove > tree.key) {
    tree.right = remove(tree.right, keyToRemove);
  } else if (isEmpty(tree.left) && isEmpty(tree.right)) {
    tree = null;
  } else if (isEmpty(tree.left)) {
    tree = tree.right;
  } else if (isEmpty(tree.right)) {
    tree = tree.left;
  } else {
    tree.key = minKey(tree.right);
    tree.right = remove(tree.right, tree.key);
  }

  **return _fixBalance(tree);**
};

与添加一个键一样,唯一不同的是在最后应用平衡修复。

在 AVL 树中旋转节点

添加或删除节点本质上与常见的二叉搜索树使用相同的逻辑,但如果不干预,树可能会失去平衡。解决方案是应用旋转,这些旋转不会影响搜索,但能恢复平衡。

两种基本的树旋转是对称的,如图 12-19 所示,其中负号表示比正号小的键值。经过任何旋转后,树仍然可以进行搜索,但高度和结构可能发生变化,这可以让你恢复一个 AVL 树。

图 12-19:可以用来解决平衡问题的两种对称旋转

从左到右的旋转是右旋,从右到左的旋转是左旋。要记住哪个是哪个旋转,可以注意观察旧根节点的移动方向:在右旋中,根节点成为其右子树,而在左旋中,根节点成为其左子树。另一种看法是,在右旋中,原本在左边的节点成为了根节点(也就是它移动到了右边),根节点变成了一个子树,而在左旋中,原本在右边的节点成为了根节点。

注意

如果你搜索更多关于树旋转的信息,你会发现很多不一致的地方,在某些情况下,我们称之为右旋的旋转,其他来源却称之为左旋,所以要小心。

需要进行旋转时有两种可能情况:一种需要单次旋转,另一种需要两次旋转。在第一种情况(如图 12-20 所示),树是平衡的,但在子树 A 中添加了一个新键,使其变得更高,从而导致整个树失去平衡。(或者,您也可以从子树 C 中删除一个键,使其变短。)在这种情况下,问题出现在根节点的左子节点的左子树,或者对称地,出现在右子节点的右子树。这些情况在逻辑上被称为左-左右-右

图 12-20:使用右旋转修复键值为 60 的失衡节点

解决方案是对左子树的根节点应用右旋转(因为失衡发生在左子树上),从而使树恢复平衡。

图 12-21 展示了一个更复杂的场景。一个新键被添加到根节点的左子树的右子树中,使得后者失去平衡。这个左-右情况及其镜像的右-左情况需要两次旋转才能修复。

图 12-21:修复更复杂的失衡情况需要首先进行左旋转(在 9 键节点处),然后进行右旋转(在 60 键节点处)。

第一次左旋转将最低的键(在此情况下为 22)移动到根节点附近,现在右旋转将它完全提升到根节点,恢复平衡。图 12-21 展示了添加操作发生在 B 的场景;如果它发生在 C 中,解决方案仍然相同,如果不是添加,而是从 D 中删除了一个键使其变短,情况也一样。

现在考虑旋转一个节点的代码:

const _rotate = (tree, side) => {
❶ const otherSide = side === "left" ? "right" : "left";
❷ const auxTree = tree[side];
❸ tree[side] = auxTree[otherSide];
  auxTree[otherSide] = tree;

❹ tree.height = _calcHeight(tree);
  auxTree.height = _calcHeight(auxTree);
  return auxTree;
};

你首先找到旋转的“另一侧” ❶,并获取根节点一侧的节点的引用 ❷(将成为树的新根节点)以简化代码。然后,交换一些指针 ❸,最后重新计算这两个节点的高度;重要的是先处理“较低”的节点 ❹。

注意

如果你调用 _rotate() 并传入一个 left 参数,它实际上会执行右旋转,这可能有点令人困惑。其思想是你在指定哪个节点应该成为根节点。因此,对于右旋转,左子节点上升成为根节点。在某些算法中,你会发现这样做更自然。

现在,让我们通过提供缺失的 _fixBalance()方法来完成:

const _fixBalance = (tree) => {
❶ if (!isEmpty(tree)) {
  ❷ tree.height = _calcHeight(tree);
  ❸ const balance = _calcBalance(tree);
  ❹ if (balance < -1) {
    ❺ if (_calcBalance(tree.left) === 1) {
        tree.left = _rotate(tree.left, "right");
      }
    ❻ tree = _rotate(tree, "left");
  ❼} else if (balance > 1) {
      if (_calcBalance(tree.right) === -1) {
        tree.right = _rotate(tree.right, "left");
      }
      tree = _rotate(tree, "right");
    }
  }
  return tree;
};

如果树为空 ❶,则无需任何操作。否则,重新计算根节点的高度 ❷(因为最近的添加或删除可能已改变它),并且还要找到节点的平衡 ❸。如果节点在左侧不平衡 ❹,检查是否需要额外的旋转 ❺,并在必要时进行旋转,最终进行单次旋转 ❻。另一个 if 只是对称的情况 ❼,它执行相同的操作,但左右两侧交换。

考虑 AVL 树的性能

鉴于 AVL 树结构提供的确保平衡的特性,所有操作(添加、删除、查找)都具有相同的对数性能。没有不同的最坏情况,如 表 12-3 所示。

表 12-3:AVL 树操作性能

操作 平均性能 最坏情况
创建 O(1) O(1)
添加 O(log n) O(log n)
删除 O(log n) O(log n)
查找 O(log n) O(log n)
遍历 O(n) O(n)

可以证明,AVL 树的高度被 1.44 log n 所限制,这也确认了之前列出的性能(见问题 12.18)。在下一章中,你将学习 红黑树,它们有类似的限制和性能,但基于多路树。搜索可能稍微慢一点(因为这些树可能更高),插入则稍微快一点(因为需要的旋转更少),但总体结果是相同的。

权重约束平衡树

与确保任何节点的两个子树高度相差不超过 1 不同,权重约束平衡(BB[α]) 树维持着一个不同的不变量:即左右子树的权重(树的大小加 1)有一个特定的关系。如果一棵树的大小为 n,其左右子树的大小分别为 pq,那么就有 (p + 1) ≥ α(n + 1) 和 (q + 1) ≥ α(n + 1),其中 0 < α < 0.5。

另一种等效的方式是要求 (p + 1) / (n + 1) ≥ α 和 (q + 1) / (n + 1) ≥ α。由于这两个分数加起来等于 1(见问题 12.20),这相当于说两个子树必须满足 α ≤ weight(子树) / weight(树) ≤ 1 – α。中间的商称为子树的平衡。图 12-22 显示了一棵 BB[0.29289] 树,其中从 1 到 12 的键按升序插入;边缘上的数字表示相应子树的平衡。

图 12-22:一棵权重约束平衡树(此处为 BB[0.29289]),显示了每个有子节点的节点的计算平衡

α = 0.5 的值看起来像是一个完美的平衡(对于所有节点,左右子树的大小将相等),但已证明它并不适用,并不是所有的 α 值都有效。为了实现平衡,α 应该在 0.18182 (= 2/11) 和 0.29289 (= 1 – √2/2) 之间。

注意

当我们定义一个节点的权重并将其大小加 1 时,如果没有那额外的 1,就无法构建只有两个节点的权重平衡树。你能看出为什么吗?

BB[α] 树需要在每个节点中携带额外的大小数据,以便计算其权重。这对于平衡是必要的(以便你可以检查之前给出的平衡条件),但它对其他操作也很有用,比如通过秩查找键。

在向树中添加或删除键时,如果没有保持平衡,我们会应用旋转(如同在 AVL 树中那样)来恢复平衡。由于 BB[α] 树是二叉搜索树,查找操作和遍历操作不需要任何修改。你只需要考虑添加和删除操作。

定义加权平衡树

这些新树与 AVL 树共享大量的代码。最大的区别是,我们不再在每个节点中包含树的高度,而是包含一个大小属性,并通过考虑大小而不是高度来修复平衡:

const {
  find,
  inOrder,
  isEmpty,
  maxKey,
  minKey,
  postOrder,
  preOrder
} = require("./binary_search_tree.js");
const newBBTree = () => null;

const newNode = (key) => ({
  key,
  left: null,
  right: null,
❶ **size: 1**
});

❷ const _getSize = (tree) => (isEmpty(tree) ? 0 : tree.size);

❸ const _calcSize = (tree) => 1 + _getSize(tree.left) + _getSize(tree.right);

❹ const _balance = (subtree, tree) =>
  (1 + _getSize(subtree)) / (1 + _getSize(tree));

创建新节点时,将其大小设置为 1 ❶,而不是设置为高度属性。并且你将不再使用与获取或计算高度相关的函数,而是有一个函数用于获取树的已计算大小 ❷,另一个函数用于计算任何树的大小 ❸,以及第三个用于计算子树平衡度 ❹,这些都是你修复平衡时所需要的。

向加权平衡树添加和删除键

我提到过会有一个惊喜,那就是添加或删除键的方式与 AVL 树完全相同。请看前面部分的代码。添加新键时,你是以标准方式进行的(即,与二叉搜索树相同),并且在最后通过调用一个函数来修复平衡(如果需要)。这里唯一的不同之处是,后者的函数将以另一种方式实现:

const add = (tree, keyToAdd) => {
  if (isEmpty(tree)) {
    tree = newNode(keyToAdd);
  } else {
    const side = keyToAdd <= tree.key ? "left" : "right";
    tree[side] = add(tree[side], keyToAdd);
  }

  **return _fixBalance(tree);**
};

删除键的过程与添加键相同。你首先应用标准的二叉搜索树算法,并且在结束时,像添加操作一样调用相同的函数,在需要时恢复平衡。

你会看到完全相同的过程;唯一的区别是你恢复平衡的方式。

修复加权平衡树的平衡

描述 BB[α] 树的原始论文(这里没有包含数学推导)显示,有两种可能的情况(以及它们的对称情况),并且简单或双重旋转足以恢复平衡。现在考虑一个节点具有超重的左子树的情况;对称情况将以相同的方式处理。

首先,回顾一些条件。子树的平衡应该满足 α ≤ balance(subtree) ≤ 1 – α。当平衡时,会使用一些常量,但我们在这里不推导这些值:

  • α 是低重心限制;如果子树的平衡度低于 α,则树处于不平衡状态。

  • β = 1 – α 是超重限制;如果子树的平衡度高于 β,树也会不平衡。

  • γ = α/β = α / (1 – α) 是子树孩子的低重心限制。

  • δ = 1 – γ = (1 – 2α) / (1 – α) 是子树孩子的超重限制。

以下代码定义了各个值(注释中显示了每个常量的大致值):

const ALPHA = 0.29289;
const BETA = 1 – ALPHA;     // 0.70711
const GAMMA = ALPHA / BETA; // 0.41421
const DELTA = 1 – GAMMA;    // 0.58579

现在,你将修复不平衡的树。第一个情况如图 12-23 所示。左子树过大(或者右子树的大小减少),导致树不平衡。你可以计算左子树右子树的平衡因子(B),发现它低于δ,因此并没有超重。在这种情况下,通过单次右旋(将 B 子树移至右侧,而 B 子树本来就应为轻的)即可重新平衡树。

图 12-23:在某些情况下,单次旋转可以修复平衡问题。

第二种情况稍微复杂一点。左子树超重,而且左子树的右子树的平衡因子超过了δ值。单次旋转不足以恢复平衡(树仍然不平衡),在这种情况下,需要进行双次旋转才能将一切恢复正常。注意,超重子树的一部分被移至右侧(C),另一部分(B)则保留在左侧,如图 12-24 所示。

图 12-24:在更复杂的情况下,需要两次旋转来修复平衡。

因此,判断是否需要旋转(以及旋转类型)的逻辑如下:首先检查两个孩子,看是否有一个超重(假设是左孩子),通过比较其平衡因子与β。如果是,接着检查另一侧的孙子节点(左孩子的右孩子),但要与不同的δ限制进行比较。根据第二次检查的结果,你将执行一次或两次旋转:

const _fixBalance = (tree) => {
  if (!isEmpty(tree)) {
  ❶ tree.size = _calcSize(tree);

  ❷ if (_balance(tree.left, tree) > BETA) {
    ❸ if (_balance(tree.left.right, tree.left) > DELTA) {
      ❹ tree.left = _rotate(tree.left, "right");
      }
    ❺ tree = _rotate(tree, "left");
  ❻} else if (_balance(tree.right, tree) > BETA) {
      if (_balance(tree.right.left, tree.right) > DELTA) {
        tree.right = _rotate(tree.right, "left");
      }
      tree = _rotate(tree, "right");
    }
  }

  return tree;
};

如果树不为空,首先更新其大小 ❶。然后,首先检查左孩子是否超重 ❷;如果是,接着检查左孩子的右孩子 ❸,如果该树也超重,则进行第一次旋转 ❹;接着执行右旋 ❺,完成任务。如果左孩子没有超重,则检查右孩子 ❻,逻辑与之前提到的情况对称 ❹ ❺。

你现在知道如何通过添加或删除键来更新树,但 BB[α]树还支持其他操作,包括通过秩查找、将树分裂成两棵树,或将两棵树合并成一棵。##### 在带权重约束的平衡树中按秩查找元素

如前所述,BB[α]树为了平衡目的需要在每棵树的根节点上保存其大小,这不仅能提供额外的好处,还能支持更高效的操作。例如:通过秩查找一个元素(在此情况下是第七个元素),就像在第十一章中所看到的那样。图 12-25 中的树与图 12-22 相同,但现在每个节点旁边显示了子树的大小。

图 12-25:在每个子树根节点处包含其大小,可以高效地按秩查找元素;这里你正在查找第七个键。

左子树有三个元素,因此,如果你在寻找第四个元素,那就是根节点本身,查找就完成了!但是在这种情况下,你是在寻找第七个元素,所以你需要继续查找。首先,决定是向左还是向右:左子树只有三个元素,所以第七个元素必须是右子树中的第三个元素。你需要排除左子树的三个元素和根节点,因此从总数中去除四个元素,然后向右移动。

现在你来到了 8 号关键根节点,它的大小为 8。左子树有三个元素,而你正在寻找该树的第三个元素,于是你继续往左走。在 6 号关键根节点处,重复相同的过程,这次要向右走,因为你需要排除左子树的一个元素和根节点的一个元素,因此现在你要找的是右子树的第一个元素。然后你到达了 7 号关键节点,这就是你要找的元素。

你可以很容易通过递归实现这个查找:

const findByRank = (tree, rank) => {
❶ if (isEmpty(tree) || rank < 1 || rank > _getSize(tree)) {
   return undefined;
  } else {
  ❷ if (rank <= _getSize(tree.left)) {
     return findByRank(tree.left, rank);
  ❸} else if (rank === _getSize(tree.left) + 1) {
     return tree.key;
  ❹} else {
 return findByRank(tree.right, rank - _getSize(tree.left) - 1);
   }
 }
};

首先,排除所有查找失败的情况 ❶:一个空树或查询的秩超出了树的大小。如果你想要的秩不大于左子树的大小 ❷,那么就在左子树中继续查找。否则,如果你想要的秩恰好是左子树大小的一个加 ❸,那么根节点就是答案,查找完成。最后,如果以上条件都不成立,就向右走,你需要排除左子树的大小和根节点,继续查找 ❹。

考虑加权平衡二叉树的性能

与 AVL 树的情况一样,确保 BB[α] 树的平衡可以保持恒定的性能,不会有最坏情况。对于所有操作(添加、删除和查找),总成本是对数级别的,因此加权平衡二叉树能确保良好的性能,正如表 12-4 所示。

表 12-4:加权平衡二叉树操作性能

操作 平均性能 最坏情况
创建 O(1) O(1)
添加 O(log n) O(log n)
删除 O(log n) O(log n)
查找 O(log n) O(log n)
按秩查找 O(log n) O(log n)
遍历 O(n) O(n)

与 AVL 树相比,代码并不复杂,并且在这两种情况下,都是依赖于在添加或删除后使用“平衡修复”函数。

概率平衡二叉查找树

保证平衡树使得操作更加复杂,以确保始终保持良好的平衡形态,从而提供操作的恒定性能。另一种方法是概率平衡树,它在实现上更简单,不需要额外的内存使用,并且在效率上(从摊销的角度来看)可以和保证平衡树一样高效——但你必须接受可能会在一长串快速操作中夹杂一些单独的慢操作这一缺点。

因此,这些树并不保证平衡,而是以概率的方式保证平衡,除非你非常不幸运,它们的表现会非常好。在本章中,我们将考虑这两种树的版本:随机化 二叉搜索树,它以随机的方式应用平衡操作,以及伸展树,它通过重构树来加速未来的搜索。在第十四章中,我们还会考虑另一种选择:treaps*。

随机化二叉搜索树

平衡树通过强制执行一些约束来保证性能。这在性能上是一个优势,但它为操作增加了额外的复杂性,而且每个节点还需要一些书籍管理信息来判断是否需要重构。避免坏情况的另一种方法是使用随机化算法,它能保证对于任何类型的输入数据,在概率上提供期望的性能。根据实现方式(以及你的特定数据),随机化算法可能比相应的平衡版本更快,且可能更适合你的需求。例如,如果你按升序添加键,平衡树将不得不频繁执行平衡操作;如果算法在某些点上做出基于随机决策的操作,可能会需要更少的平衡操作;稍后我们会更清楚地看到这一点。

我们将要查看的第一个这种结构使用随机数来决定新插入的元素是应该放在树的根节点,还是放在它的常规位置。插入和删除算法通过随机决定是将一个键添加到树中,还是将一个键从树中移除。这两个过程都会生成一个随机结构,就像你在第十章中看到的那样,输入值被随机打乱。记住,我们不需要重新考虑如何查找一个键,因为我们仍然在处理二叉搜索树,之前的搜索逻辑依然适用。

定义随机化二叉搜索树

随机化树的结构将与 BB[α]树相同,包括一个大小属性,但我们将不再使用它来重新平衡树,而是用它来帮助随机决定采取什么操作。基本代码如下,我们将再次重用一些来自标准二叉搜索树的代码:

const newRandomTree = () => null;

const newNode = (key, **left = null, right = null**) => ({
  key,
  left,
  right,
  size: 1
});

const _getSize = (tree) => (isEmpty(tree) ? 0 : tree.size);

const _calcSize = (tree) => 1 + _getSize(tree.left) + _getSize(tree.right);

这与 BB[α]代码的开始方式完全相同,只是这里的 newNode()方法允许你为左右指针提供初始值,否则将其设置为 null。

注意

拥有一个大小属性意味着你也能像在 BB[α]树中那样快速按排名查找元素。

向随机化二叉搜索树添加一个键

在标准的二叉搜索树中,如果你开始向一个最初为空的树中添加键,第一个添加的键会成为根节点,并且除非你移除它,否则它将一直保持在那里。这个算法的行为有所不同。每次添加一个键时,它会随机决定是否应该将其作为根节点,或者作为叶节点添加到树的某个位置,这与之前描述的抽样算法类似。这个方法确保了任何键都可以成为根节点,因此你进行添加的顺序不会影响结果。

如果算法选择将新键放置为根节点,它会将树分成两个子树:一个包含所有小于新根的键,另一个包含所有大于新根的键。否则,如果算法没有选择将新键放置为根节点,将应用常见的插入逻辑。首先看看基本算法,细节将在稍后填充:

const add = (tree, keyToAdd) => {
❶ if (isEmpty(tree)) {
   tree = newNode(keyToAdd);
❷} else if (tree.size * Math.random() < 1) {
 ❸ const newTrees = _split(tree, keyToAdd);
 ❹ tree = newNode(keyToAdd, newTrees.right, newTrees.left);
❺} else {
   const side = keyToAdd <= tree.key ? "left" : "right";
   tree[side] = add(tree[side], keyToAdd);
 }
❻ tree.size = _calcSize(tree);
  return tree;
};

如果树为空 ❶,则将根节点的键设置为空子树,并在返回之前计算其大小。由于你正在移动节点,因此需要重新计算大小。就像第十章中的抽样算法一样,你可能决定新值必须作为根节点 ❷。在这种情况下,使用辅助算法将树分为两部分 ❸:正在添加的键成为根节点,两个拆分后的子树成为其子树 ❹。

作为替代方案,如果随机测试失败,应用你在二叉搜索树中非常熟悉的算法 ❺。(记住,为了实现一个集合而不是一个袋子,你需要检查 keyToAdd 是否等于 tree.key,如果相等,则拒绝添加新键。)但是需要注意的是,在每个递归步骤中,你还在使用随机数来决定是否拆分当前的树,因此随机性不仅仅作用于树的根节点,还作用于整个结构。add()的最后一步是计算根节点的大小 ❻,无论之前的步骤发生了什么,都必须进行此操作。

在处理缺失的拆分代码之前,考虑一下该算法的示例情况。假设你要将键 20 添加到图 12-26 所示的树中。

图 12-26:在添加键 20 之前的二叉搜索树

在比较 20 和 23 之前,生成一个随机数。由于树的大小为 9,算法有九分之一的概率会拆分树并将 20 设置为根节点,而在九分之八的情况下,根节点仍然是 23。否则,你将继续以通常的方式在二叉搜索树中添加键。

假设测试通过。将树分成两部分,并将它们设置为 20 的子树,20 成为新的根节点,如图 12-27 所示。

图 12-27:如果随机测试成功,新键将成为树的根节点。

现在,假设测试失败。将原始根节点保持在原位,移动到它的左子树,将 20 和 9 进行比较。这一次,由于当前树的大小为 5,随机测试成功的概率为五分之一。如果这次测试成功,20 将取代 9,分割以 9 为根的树,并像之前一样执行相同的操作。

第三种可能性是如果随机测试第一次和第二次都失败。在这种情况下,将 20 和 12 进行比较,并进行另一个随机测试,这次成功的概率为三分之一,因为原始以 12 为根的树有三个节点。如果该测试仍然失败,你还会再次尝试,这时成功的概率为二分之一,然后再将 20 和 22 进行比较。只有当每次随机测试都失败时,你才会最终将 20 放在普通二叉搜索树中应该放置的位置:在这个例子中,放在 22 键的左边。

随机二叉搜索树的分割

分割算法让人联想到 第六章中快速排序的枢轴部分。你有一个“枢轴”键,想要将结构分割成两棵树,这样第一棵树中的所有键都小于枢轴,第二棵树中的所有键都大于枢轴。

从我们之前使用过的相同树开始,看看如何根据 20 键进行分割,如 图 12-28 所示。

图 12-28:与 图 12-26 相同的树,在根据 20 键进行分割之前

首先建立两棵空树:一棵包含小于 20 的值,另一棵包含大于 20 的值。两棵树都从空树开始。第一步将 20 和 23 进行比较。由于 23 大于 20,因此这个根节点及其右子树进入了大值树中。同时,你需要“记住” 23 的左子树(现在为空),因为将来任何大于 20 但小于 23 的值将进入这个位置。两棵分割后的树(当前为空的较小值树)将像 图 12-29 中显示的样子,接下来你将处理以 9 为根的子树。

图 12-29:第一步:23 大于 20,因此部分树结构向右移动。虚线圆圈显示了将要添加新子树的位置。

现在你有 20,它大于 9,因此 9 和它的左子树进入“较小”树,你还需要记住 9 的右子树,这是将来任何大于 9 但小于 20 的值将要进入的地方。现在,分割后的树形就像 图 12-30 中的样子,你可以继续处理 12 键。

图 12-30:第二步:9 小于 20,因此部分树结构向左移动。

这是相同的情况:20 大于 12,因此将 12 和它的左子树连接到较小树的记住的右子树中,得到 图 12-31 中显示的场景。现在记住 12 的右子树作为未来添加更多值的地方。

图 12-31:第三步:12 小于 20,所以添加到左树。

你几乎完成了:20 小于 22,所以 22(以及它的右子树,如果有的话)会进入“较大”树中的预定位置,如图 12-32 所示。

图 12-32:第四步:22 大于 20,所以添加到右树。

由于没有更多的节点需要处理,最终将最终树的根节点设置为 20,并将“较小”和“较大”的树作为子树。结果,如图 12-33 所示,就是你之前看到的情况。

图 12-33:第五步:树被拆分,现在你将 20 设置为其根节点。

现在检查代码,重点在于如何记住拆分树中的位置:

const _split = (
  tree,
  keyForSplit,
  newTrees = {left: null, right: null},
  lastNodes = {left: newTrees, right: newTrees}
) => {
❶ if (isEmpty(tree)) {
   return newTrees;
❷} else {
   const [side, other] =
      keyForSplit <= tree.key ? ["left", "right"] : ["right", "left"];
  ❸ const nextTree = tree[side];
    tree[side] = null;
  ❹ lastNodes[other][side] = tree;
    lastNodes[other] = tree;
  ❺ const newSplit = _split(nextTree, keyForSplit, newTrees, lastNodes);
  ❻ tree.size = _calcSize(tree);
    return newSplit;
  }
};

首先创建两棵树 newTrees,当你拆分原始树时。如果完成树的拆分❶,则返回那对树。否则❷,决定拆分的哪一侧❸,并将拆分部分合并到正确的新树中;你还需要记住下次合并的位置❹,然后递归地处理树的其余部分❺。最后计算树的大小❻,因为你需要它进行随机测试。

从随机树中删除一个键

删除键的算法几乎与之前相同,但有一个主要的区别:如果要删除的键有两个子节点,应该怎么办。以下是基本代码:

const remove = (tree, keyToRemove) => {
  if (isEmpty(tree)) {
    // nothing to do
  } else if (keyToRemove < tree.key) {
    tree.left = remove(tree.left, keyToRemove);
  ❶ **tree.size = _calcSize(tree);**
  } else if (keyToRemove > tree.key) {
    tree.right = remove(tree.right, keyToRemove);
  ❷ **tree.size = _calcSize(tree);**
  } else if (isEmpty(tree.left) && isEmpty(tree.right)) {
    tree = null;
  } else if (isEmpty(tree.left)) {
    tree = tree.right;
  } else if (isEmpty(tree.right)) {
    tree = tree.left;
  } else {
  ❸ **tree = _join(tree.left, tree.right);**
  }
  return tree;
};

该算法相当标准,你已经见过这个代码好几次了,只有一些小的例外。当你从子树中移除一个键时,你需要更新大小属性❶❷,但有一个有趣的区别是,当你要删除一个有两个子树的键时,你需要使用合并过程❸将左右子树合并成一棵新树,然后用新树替代被删除的键。(更多关于删除算法的内容,请参见问题 12.22。)

合并两个随机化的二叉搜索树

你可以通过选择一个子树,并将其根节点作为新子树的根节点,递归处理其余的树,来构建一棵新树。考虑图 12-34 中显示的示例情况,并尝试删除之前添加的 20 键。

图 12-34:删除根节点(这里是 20)需要将其子树合并成一棵树。

你需要通过随机选择的方式将根节点的左子树和右子树合并成一棵树,因此 9 或 23 将成为新的根节点。假设随机选择了 9。将 9 设置为新树的根节点,并将其左子树保留,同时将其右子树与另一个以 23 为根的子树合并,作为右子树。

现在,你需要在 12 和 23 之间做选择;假设你选择了后者。你可以将 23 及其右子树添加到你正在构建的树中,然后仍需完成合并以 12 和 22 为根的子树。如果你随机选择 12 作为下一个根节点,你将得到图 12-35 所示的情况。

图 12-35:随机选择 9 作为根节点,23 作为右子树后得到的新树

作为最后一步,你需要合并一个空子树(12 的右子树)和 22 的子树,因此最终的树形如图 12-36 所示,你通过新算法移除了 20 这个关键字。

图 12-36:最后一步,在合并 12 的右空子树和 22 的子树之后

考虑代码。为了决定从哪棵树选择根节点,使用与你考虑采样时相同的规则:如果子树的大小分别是 6 和 4,你将以 6/10 的概率选择第一棵树的根节点,4/10 的概率选择第二棵树的根节点。以下是算法:

const _join = (leftTree, rightTree) => {
❶ const leftSize = _getSize(leftTree);
  const rightSize = _getSize(rightTree);
  const totalSize = leftSize + rightSize;

❷ if (totalSize === 0) {
   return null;
❸} else if (totalSize * Math.random() < leftSize) {
   leftTree.right = _join(leftTree.right, rightTree);
   leftTree.size = _calcSize(leftTree);
   return leftTree;
❹} else {
   rightTree.left = _join(leftTree, rightTree.left);
   rightTree.size = _calcSize(rightTree);
   return rightTree;
 }
};

首先,获取要合并的树的大小 ❶,以便稍后进行随机选择。如果两棵树都是空的 ❷,那么操作完成。如果不是,基于树本身的大小随机决定哪一棵树将提供根节点 ❸。如果是左树,那么保留其根节点和左子树不变,并用另一棵树的右子树替代右子树。 当然,如果你选择了右子树 ❹,逻辑是相同的,只不过是镜像操作。

考虑随机化二叉查找树的性能

随机化添加过程的效果使得平均性能呈对数级增长。即使结构有时会变形,持续的操作会使其恢复到良好的形状。最坏情况下,时间仍然是线性的。毕竟,存在所有随机数“对你不利”的可能性,导致生成一个形状不好的树,但平均而言,这种情况并不常发生;请参见表 12-5。

表 12-5:随机化二叉查找树操作的性能

操作 平均性能 最坏情况
创建 O(1) O(1)
添加 O(log n) O(n)
删除 O(log n) O(n)
查找 O(log n) O(n)
遍历 O(n) O(n)

这种结构提供了高概率的对数级性能:查找树的形状将是由随机键序列创建的树的形状。现在考虑一种不同的结构,它将提供摊销对数性能,因此一系列操作的总时间在平均上是对数级的。

伸展树

如前所述,二叉搜索树可能会有O(n)的性能,尽管这种情况只会偶尔发生,但它可能是个问题。前面提到的平衡树采取了预防措施来避免这个问题,但伸展树提供了另一种解决方案。这种版本的二叉搜索树保证了摊销的O(log n)性能,这意味着一系列k连续操作的性能将是O(k log n),虽然这不如保证的O(log n)性能好,但几乎差不多。

使用伸展树时,每当访问一个节点时,该节点会通过一个叫做伸展的过程移动到树根,这个过程是一系列旋转操作,目的是将目标节点移到树根。这并不保证树的平衡性,但随着时间的推移,伸展树通常会变得比较合理,并提供了一个比其他二叉搜索树实现更好的替代方案。

请参考图 12-37,假设正在查找 12 这个关键字。

图 12-37:在伸展树中,经过一次搜索(在这里是查找关键字 12),找到的节点成为树的新根节点。

找到 12 后,这个关键字被提到树根(稍后你会看到如何操作),这也会导致其他路径发生变化:23 被推到树根的右子树,10 靠近树根,22 从左边移到右边。即使树形可能变得不太平衡,操作的顺序通常会重新构造它,从而在长时间内提高性能。如果你频繁需要访问一些特定的关键字集合,搜索会非常快速,因为这些关键字会更接近树根,这对于许多应用场景是一个优势。编译器及其符号表就是一个例子:通常,当一个符号被定义(比如在一个函数中)时,很有可能在短时间内多次访问它。

伸展树操作

伸展树有一些特定的规则,并且有一些很有趣的名称,如zig-zigzag,它们基于简单的旋转。考虑不同的情形。在图 12-38 到图 12-40 中,要被移到树根的关键字始终是 1(已高亮显示)。

情形 1:树根的左子节点

如果关键字是树根的左子节点,应用单次右旋将该关键字移到树根。这叫做zig情形,如图 12-38 所示。

图 12-38:单次右旋将左子树移到树根。

相反的情况是如果关键字是树根的右子节点;此时你需要进行一次左旋,这叫做zag

情形 2:树根左子节点的右子节点

在这个zag-zig情形中,首先对关键字进行左旋,然后进行右旋,将其移到树根,如图 12-39 所示。

图 12-39:对于左子节点的右子节点,需要进行两次旋转。

在相反的情况下(zig-zag),先进行右旋再进行左旋。

情况 3:根节点左孩子的左孩子

这个zig-zig情况可能会让你感到困惑,因为旋转的顺序发生了变化:首先你需要将底部键的父节点向右旋转,然后再旋转该键本身,如图 12-40 所示。

图 12-40:左孩子的左孩子需要两次右旋。

你可能认为在这种情况下一个更简单的算法可以旋转键两次,但结果并不理想,一个简单的例子可能会让你相信这一点。假设你从一个(不太好的)树开始,如图 12-41 所示,然后将 1 键进行伸展。

图 12-41:为什么总是旋转找到的键并不好

你可以尝试使用右旋将 1 移动到上面。在每一步中,1 会向上移动一个位置,将原始根节点移到右侧(首先是 2;然后是 3 和 2;再然后是 4、3 和 2;以此类推),当 1 移到根节点时,其他所有的键(2–5)仍然保持在原先的结构中,如图 12-42 所示。

图 12-42:所有旋转后,树的结构变得更差。

在这个伸展算法中,两个 zig-zig 旋转可以处理这种情况。首先,1 变成根节点,2 和 3 位于其右侧,然后 1 移到树的顶部,4 和 5 在其右侧;2 和 3 被重新定位到 4 的左侧,如图 12-43 所示。

图 12-43:文本中建议的旋转产生了更好的结构。

zig-zig 逻辑产生了一个更加平衡的树,根节点到各个节点的路径变得更短,这为使用更复杂的逻辑提供了依据。

现在,考虑一下实际代码中的伸展操作:

const _splay = (tree, keyToUp) => {
❶ if (isEmpty(tree) || keyToUp === tree.key) {
    return tree;
  } else {
  ❷ const side = keyToUp < tree.key ? "left" : "right";
    if (isEmpty(tree[side])) {
     return tree;
  ❸} else if (keyToUp === tree[side].key) {
     return _rotate(tree, side);
  ❹} else {
     if (keyToUp <= tree[side].key === keyToUp <= tree.key) {
     ❺ tree[side][side] = _splay(tree[side][side], keyToUp);
     ❻ tree = _rotate(tree, side);
     } else {
     ❼ const other = side === "left" ? "right" : "left";
     ❽ tree[side][other] = _splay(tree[side][other], keyToUp);
       if (!isEmpty(tree[side][other])) {
         tree[side] = _rotate(tree[side], other);
       }
     }
   ❾ return isEmpty(tree[side]) ? tree : _rotate(tree, side);
   }
 }
};

伸展过程持续进行,直到遇到空树或找到你要找的键 ❶。只要这些条件没有满足,就继续。决定应该在哪个子树中找到键 ❷,如果该子树为空,则也完成了。 (如果树中不包含你要找的键,那么你找到的最后一个键将向上移动,因此某些重构始终会进行。)如果你在子树的根节点找到键 ❸,那么你会遇到一个 zig 或 zag,单次旋转就足够了。如果没有,假如你要查找的键位于子树的同一侧 ❹,那么就是一个 zig-zig 或 zag-zag。首先递归地对最底层子树进行伸展 ❺,然后旋转根节点 ❻,最后再完成最后一次旋转 ❾。另一种可能性是 zig-zag 或 zag-zig:先对最底层子树进行伸展 ❼,然后完成之前描述的另外两次旋转 ❽ ❾。

在伸展树中查找一个键

这个算法很简单。首先应用伸展算法,然后检查移到根节点的值是否是你在寻找的:

const find = (tree, keyToFind) => {
❶ if (!isEmpty(tree)) {
    tree = _splay(tree, keyToFind);
  }
❷ return [tree, !isEmpty(tree) && tree.key === keyToFind];
};

除非树为空,否则要进行伸展。伸展❶是始终进行的,无论关键字是否存在,所以新根节点上的关键字可能是你想要的,也可能不是,这就解释了最终的测试❷。

向伸展树中添加关键字

要添加一个关键字,首先对树进行伸展以重构树结构,然后在顶部添加一个新的根节点。图 12-44 中的树,和之前展示伸展如何工作的树相同,展示了如何添加一个 11 关键字。

图 12-44:一个你将插入 11 关键字的伸展树

第一步是使用 11 作为伸展值进行伸展。这个关键字不在树中,因此算法的结果是 10 作为根节点,如图 12-45 所示。

图 12-45:第一步:树以 11 为伸展值进行伸展。

现在很容易完成:11 应该成为根节点,10(当前根节点)在其左侧,23 在右侧,如图 12-46 所示。

图 12-46:最后一步:11 成为新的根节点,伸展的部分作为子树。

代码如下:

const add = (tree, keyToAdd) => {
❶ const newTree = newNode(keyToAdd);
  if (!isEmpty(tree)) {
  ❷ tree = _splay(tree, keyToAdd);
  ❸ const [side, other] =
      keyToAdd <= tree.key ? ["left", "right"] : ["right", "left"];
    newTree[side] = tree[side];
    newTree[other] = tree;
    tree[side] = null;
  }
  return newTree;
};

首先,创建将成为新根节点的节点❶。然后,进行树的伸展❷,使根节点成为最接近所添加的关键字的节点。接着正确连接新根节点❸,新的节点将成为树的根节点。

从伸展树中删除关键字

删除一个关键字从对树进行伸展开始,这样根节点就会变成你想删除的关键字,或者如果你想删除的关键字不在树中,就会变成其他的关键字。如果找到了该关键字,执行通常的步骤。如果该节点没有子节点或只有一个子节点,删除就很简单;如果有两个子节点,找到右子树中下一个关键字并将其作为根节点,但也需要进行伸展。

你可以通过尝试从图 12-47 所示的树中删除 12 来了解这个过程是如何工作的。

图 12-47:一个你想从中删除 12 关键字的伸展树

第一步,和添加和查找一样,是使用 12 作为关键字对树进行伸展;你已经看过这个例子,结果是更新后的树,如图 12-48 所示。

图 12-48:树经过伸展后,12 成为根节点。

由于找到了 12,你可以继续。此时你有两个子树,所以必须找到紧随 12 之后的关键字(22),并使用该值来伸展根节点的子树,得到如图 12-49 所示的新树。

图 12-49:将 22 作为根节点的子树进行伸展后的树

现在你可以通过将 22 放到 12 的位置轻松删除 12,算法就完成了,如图 12-50 所示。请注意,22 这个关键字不能有左子树,因为 12 和 22 之间没有值。

图 12-50:22 成为根节点后的最终树

首先查看代码来伸展树,将其最小值移到顶部。回想之前的算法,我们通过向左查找最小键,直到无法继续。这里的思路是应用旋转操作,使最小键最终位于顶部:

const _splayMinimum = (tree) => {
  if (isEmpty(tree) || isEmpty(tree.left)) {
    return tree;
  } else {
  ❶ tree.left.left = _splayMinimum(tree.left.left);
  ❷ tree = _rotate(tree, "left");
  ❸ return isEmpty(tree.left) ? tree : _rotate(tree, "left");
  }
};

该算法基本上与 _splay()相同,只不过你总是假设你要向左走❶❷❸。对比代码;它与之前的代码相同,只不过 side 被替换成了 left。(还有另一种方式来推导 _splayMinimum()代码;请见问题 12.25。)有了这些说明,删除操作的代码如下:

const remove = (tree, keyToRemove) => {
  if (!isEmpty(tree)) {
  ❶ tree = _splay(tree, keyToRemove);
    if (keyToRemove === tree.key) {
    ❷ if (isEmpty(tree.left) && isEmpty(tree.right)) {
       tree = null;
    ❸} else if (isEmpty(tree.left)) {
       tree = tree.right;
    ❹} else if (isEmpty(tree.right)) {
       tree = tree.left;
     } else {
     ❺ const oldLeft = tree.left;
     ❻ tree = _splayMinimum(tree.right);
     ❼ tree.left = oldLeft;
     }
   }
 }
 return tree;
};

如果树不为空,首先进行伸展❶,然后检查你要删除的键是否已经位于根节点。如果是这样,你可以轻松处理新根节点少于两个子节点的情况❷❸❹。否则,保存左子树❺,然后伸展右子树,将其最小值移到顶部❻,最小值取代了你要删除的键。只需修复其左子树❼,就完成了。

注意

请参见问题 12.24,确认你理解这个算法的一个重要细节:为什么你只在删除过程中最后几个步骤中覆盖伸展子树的左子树?

考虑伸展树的性能

伸展树在最坏情况下可能会生成一棵线性树,因此在这种情况下性能将是线性的,并且可能会在实时上下文中被排除,因为你需要对处理时间有绝对的保证。然而,一系列操作的摊销成本是对数级的,这意味着,平均而言,一系列k次操作(添加和删除)的总成本是O(k log n),这相当于对数摊销性能;表 12-6 总结了结果。

表 12-6:伸展树操作的性能

操作 摊销性能
创建 O(1)
添加 O(log n)
删除 O(log n)
查找 O(log n)
遍历 O(n)

一个有趣的特点是,这种结构不仅能够自我重组,还能提供更好的性能,因为频繁访问的键最终会靠近根节点。这使得伸展树非常适合用来实现缓存。例如,节点不需要额外的书籍数据(如树的高度或大小),这在内存紧张时特别有用,另一个好处是,平均而言,它的性能和其他树一样高效。

概述

本章介绍了树结构,特别是二叉搜索树,它们为袋(bag)和集合(set)ADT 提供了良好的实现,并具有高效的添加删除查找方法。你探索了这些树的性能,并看到了几种旨在确保良好、快速算法的变体。

在接下来的章节中,我们将探讨更多的树形结构,并且我们还会考虑一些特殊的面向搜索的结构,这些结构能提供非常高效的搜索和更新。

问题

12.1  层次问题

你能用层次来定义树的高度吗?

12.2  打破规则

文件系统的目录通常被认为具有树状结构,但这并不总是正确的。你能想到一个特性,使得目录能够像循环链表(如在第十章中看到的那样)甚至像图(如你将在第十七章中看到的那样)吗?提示:目录条目可以有多种类型。

12.3  名字里有什么?

这里有一些关于满二叉树、完美二叉树和完整二叉树的问题:哪个术语包含另一个?例如,满二叉树也是完整的二叉树吗?那么完整的二叉树是满的吗?满二叉树和完美二叉树会怎样?完美二叉树和完整二叉树呢?

12.4  一个 find() 一行代码

这肯定不太清楚,但你能用一行代码写出 find()方法吗?

12.5  树的大小

编写一个 calcSize()函数来计算二叉树的大小。

12.6  像树一样高

编写一个 calcHeight()函数来计算二叉树的高度。

12.7  复制一棵树

给定一棵二叉树,编写一个算法来生成它的副本。(提示:你可能想要考虑使用前序遍历。)

12.8  做数学运算

如果你正在编写一个编译器或解释器,这个问题可能会出现。假设你有一棵二叉树,其节点可以是数字或数学运算符(加法、减法、乘法、除法)。这样的树可以用来表示任何数学表达式;例如,图 12-51 中的树表示 (2 + 3) * 6。

图 12-51:做一下数学运算。

证明你可以通过正确遍历树来评估这样的表达式。

12.9  让它变坏

在常见的二叉搜索树中,你应该以什么顺序插入键值才能生成一个线性列表?如果你有n个键值,如何生成一棵没有任何一个满节点的树呢?(提示:你可以先选择哪些值来加入树中?接下来可以加入哪些值?)

12.10  重建树

如果给定一棵二叉搜索树的前序遍历和中序遍历,并且树中没有重复的键,你可以重建它;编写一个算法来实现这一点。你的输入将是两个数组:第一个数组是树的前序遍历结果,第二个数组是中序遍历结果。

12.11  更多重建?

对于之前的问题,你能从中序遍历和后序遍历的结果重建出这棵树吗?那么从前序遍历和后序遍历的结果呢?

12.12  相等的遍历

对于哪些树,前序遍历和中序遍历访问键的顺序是相同的?那么中序遍历和后序遍历呢?或者前序遍历和后序遍历呢?

12.13  通过遍历进行排序

使用 inOrder()遍历,给定一棵二叉搜索树,生成一个按顺序排列的键的数组。

12.14  通用顺序

编写一个 anyOrder(tree,order,visit)函数,它接受一个 order 参数,可以是"PRE"、"IN"或"POST",并且会根据给定的 visit()函数执行相应的树遍历。

12.15  无递归遍历

实现所有遍历方法时不要使用递归;请改用栈。

12.16  不允许重复

修改二叉搜索树的添加逻辑,以拒绝添加重复的键。发生这种尝试时,树应该保持不变,并抛出错误。

12.17  获取和删除

编写一个 _removeMinFromTree(tree)方法,找到二叉搜索树中的最小键,删除它,并同时返回该键和更新后的树。利用这个新方法优化 _remove(),从而不再需要 _findNext()。

12.18  AVL 最差

与树的高度相关,AVL 树最小可以有多少个节点?换句话说,如果一棵 AVL 树的高度为 1、2、3……,那么它最少可能有多少个节点?

12.19  仅限单个

考虑一个没有兄弟节点的子节点,称为单一子节点。在 AVL 树中,是否可以有超过 50%的单一子节点?

12.20  为什么是 1?

在权重平衡树中,为什么左子树和右子树的平衡(即weight(left subtree) / weight(tree)weight(right subtree) / weight(tree))加起来是 1?

12.21  更容易随机化?

一位开发者有以下想法:

如果按顺序添加键,二叉搜索树的表现会很差,但如果键是随机顺序添加的,则表现良好。如果我在将键添加到树中之前先对它们进行哈希处理,会发生什么呢?哈希后的键从所有角度来看都是随机的,因此一个有序的键序列会变成完全无序的,保证良好的性能。当然,在查找一个键时,我需要查找哈希后的键,但这不是什么大问题。问题解决了;使用哈希键的二叉树将始终表现良好!

这个推理是否正确?

12.22  为什么不递减?

在随机化二叉树的 remove()逻辑中,为什么使用 _calcSize()而不是像下面那样递减?

const remove = (tree, keyToRemove) => {
  if (isEmpty(tree)) {
    // nothing to do
  } else if (keyToRemove < tree.key) {
    tree.left = remove(tree.left, keyToRemove);
    **tree.size--;**
  } else if (keyToRemove > tree.key) {
    tree.right = remove(tree.right, keyToRemove);
    **tree.size--;**
    return tree;
    ... etc. ...
};

12.23  坏伸展?

你之前看到,按升序或降序添加键是常见二叉搜索树的坏情况。那么在这些情况下,伸展树会怎么样呢?如果在这些添加之后删除一些键,得到的树形状会是什么?会更好吗?

12.24  什么左子树?

在伸展树的 remove()方法的末尾,在伸展根节点的右子树之后,this.right.left的值是多少,为什么?

12.25  代码转换

展示你如何通过假设 keyToUp 等于-Infinity,将 _splay()转换为 _splayMinimum()。为什么这样做有效?

12.26  完全重平衡

你已经看过了几种重新平衡树的方法,但你可能还想平衡一个普通的二叉搜索树。你能想出一个restructure(tree)函数,将二叉搜索树平衡成尽可能完美的形状吗?你应该尽量在树的每个地方将节点在左右子树之间尽可能均匀地分配。

第十三章:13 树和森林

在之前的章节中,我们探讨了二叉树,这些树的每个节点最多只有两个子节点。在本章中,我们将考虑一些不受这一限制的新结构,如森林和果园(当仅处理一棵树不够时)。之后,我们将继续学习 B 树和红黑树,以便进行更快速的搜索。

定义树和森林

二叉树 可以为空树,也可以由一个节点(根节点)和两个子节点组成,而这两个子节点本身也是二叉树。特别地,二叉搜索树 也是有序树,因为我们定义了子节点之间的特定顺序,并区分了左子节点和右子节点。

让我们进一步扩展这些概念。首先,你将允许一个节点拥有多个子节点,而不仅仅是两个——换句话说,节点的度数可以大于 2。你可能会遇到每个节点有特定数量(可能为空)的子节点的树,例如二叉树或三叉树,但通常情况下,节点的度数没有限制。有时,度数不确定的树被称为多叉树多路树

超越单棵树,森林 被定义为一组不相交的树。例如,你可以将计算机中某个硬盘的目录视为一棵树,但计算机中所有不同的存储设备(如硬盘或 USB 闪存)将构成一个森林。

我们甚至可以进一步定义一个果园,它是一个有序关系的森林。在森林中,树木分布杂乱无章,而果园则有着明确的布局。继续用计算机的例子,如果你为你的驱动器分配字母(如 C:、D: 等,微软 Windows 风格),那么你的存储实际上就是一个果园。与森林相关的术语还不止这些:你还可以拥有林地,它们像树一样,但节点之间可以有链接,可能将数据结构转化为一个有向(且可能是循环的)图。

注意

为了简化本章的术语,我们将使用“森林”这一术语(当树木之间没有特定的顺序时),以及“有序森林”(而非果园),这是大多数教科书使用的术语。此外,本章中我们不会涉及林地。

使用数组表示树

你知道如何表示一棵普通的树吗?那森林呢?我们从树开始,因为通过这样做,我们会发现处理森林的一些技巧。你可能会想到的第一个解决方案是使用数组指向每个子节点,在 JavaScript 中,使用长度可变的动态数组是最简单的解决方案。图 13-1 展示了一个普通的树,其中节点有不同的度数。

图 13-1:普通树

为了实现这样的树,在每个节点上添加一个子树数组,形成如下所示的结构(从 JavaScript 的角度来看,这段代码与书中的风格略有不同,表示使用类的树结构,这使得你可以使用像文档对象模型 [DOM] 节点接口这样的标准接口):

class Tree {
  constructor(rootKey) {
  ❶ this._key = rootKey;
  ❷ this._children = [];
  }

  isEmpty() {
  ❸ return this._key === undefined;
  }

❹ _throwIfEmpty() {
    if (this.isEmpty()) {
      throw new Error("Empty tree");
    }
  }

❺ get key() {
    this._throwIfEmpty();
    return this._key;
  }

❻ set key(v) {
    this._key = v;
  }

❼ get isLeaf() {
    this._throwIfEmpty();
    return this.childNodes.length === 0;
  }

❽ get childNodes() {
    this._throwIfEmpty();
    return this._children;
  }

❾ get firstChild() {
    return this.isLeaf ? null : this.childNodes[0];
  }

❿ get lastChild() {
    return this.isLeaf ? null : this.childNodes[this.childNodes.length - 1];
  }

  // ...more methods...
}

一个关键字段 ❶,也作为标志,决定树是否为空 ❸,默认情况下,子节点数组 ❷ 为空。_throwIfEmpty() 方法 ❹ 检测到对空树的不正确访问(这在多个方法中使用)。你还添加了一个 getter ❺ 和一个 setter ❻ 来获取和设置树的键。然后添加一些 getter 来检查节点是否是没有子节点的叶子节点 ❼,如果不是,则获取其子节点 ❽,特别是访问它的第一个 ❾ 和最后一个 ❿ 子节点,模仿常见的 DOM 节点相关方法。

注意

欲了解更多关于 DOM 节点接口的详情,请访问 developer.cdn.mozilla.net/en-US/docs/Web/API/Node.

你可以考虑为树添加更多的方法,但你需要一些额外的字段来重现某些方法,如 parentNodepreviousSibling。我们将在本章稍后看到实现这些功能的一些方法。现在你可以表示一般的树并访问它们的节点,接下来看看如何添加或移除数据来更新树。

向树中添加节点

首先,在同级节点中的特定位置添加一个新子节点:

❶ addChild(keyToAdd, i = this.childNodes.length) {
❷ this._throwIfEmpty();
❸ if (i < 0 || i > this.childNodes.length) {
    throw new Error("Wrong index at add");
  } else {
  ❹ const newTree = new this.constructor();
    newTree.key = keyToAdd;
  ❺ this._children.splice(i, 0, newTree);
  ❻ return this;
  }
}

要添加一个新的键,只需指定它在同级节点中的位置;默认情况下,你将把它添加到末尾 ❶。如果树为空(没有根节点),则抛出错误 ❷,如果索引超出当前子节点数组的范围,也会抛出错误 ❸。如果一切正常,创建一个新的树 ❹,将新键作为其根节点,并将树放置在正确的位置 ❺,最后启用链式操作,如其他情况 ❻。

附加节点很简单:

appendChild(keyToAppend) {
  return this.addChild(keyToAppend);
}

你只需依赖 addChild() 的默认参数,这也会测试树是否有根。这里不需要特殊的代码。

从树中移除节点

要移除给定的子节点,只需要测试并进行一些数组操作:

removeChild(i) {
❶ this._throwIfEmpty();
❷ if (i < 0 || i >= this.childNodes.length) {
    throw new Error("Wrong index at remove");
  } else {
  ❸ this._children.splice(i, 1);
  ❹ return this;
  }
}

在验证树有根且不为空 ❶ 后,检查要删除的子节点的索引是否有效 ❷。如果有效,对数组进行操作,将该子节点从同级节点中移除 ❸。最后,如同添加节点时一样,启用链式操作 ❹。

使用二叉树表示树

使用数组表示树是有效的,但处理树的另一种方法是使用更简单的树形结构——二叉树。诀窍在于不同于以前的使用方式,左指针指向第一个子节点,右指针指向下一个兄弟节点。

注意

如果你在想这种技术是否纯粹是学术性的,或者是否能在实际中使用,我们将在第十五章学习二项堆及其变种时使用它。

回顾一下图 13-1 中展示的树。另一种表示方法是每个节点的左链接指向该节点的第一个子节点,右链接则创建一个节点的兄弟列表。(与本书中的其他所有图示一样,为了清晰起见,左和右的空指针被省略。)如果你重新排列并旋转图像 45 度,使得左指针实际指向下方,这个方案就会更清晰,如图 13-2 所示。

图 13-2:使用“左孩子,右兄弟”样式表示的树

许多结构使用这种左孩子,右兄弟约定,但为了更清晰,最好将左指针重命名为指针仍然指向兄弟节点。至于算法(添加或删除值等),你不需要做任何不同于在第十二章中学到的二叉树内容的事情。

表示森林

你可以将这些表示树的方法扩展到表示森林。如果使用数组作为指针,你可以简单地有一个根节点数组,每个元素指向一个特定的树(我们将在讨论二项堆和斐波那契堆时再次看到这个概念,见第十四章和第十五章,所以可以把图 13-3 看作是一个小剧透)。

图 13-3:一个森林,表示为根节点数组

在森林的顶部是一个数组,其中包含指向每棵单独树根节点的指针。如果你更喜欢二叉树的表示方式,你可以做两件事:要么考虑所有根节点都是兄弟节点,要么添加一个虚拟的“超级根”,它包含所有森林中的树作为子树。第一种是常见的表示方式,类似于图 13-4 所示的森林(这与图 13-3 中展示的森林相同)。

图 13-4:同一森林的另一种表示方法;根节点通过右指针连接。

要访问这个森林,你需要一个指向最左根节点的指针;从那里,你可以访问所有的树。你甚至可以通过使兄弟列表变成循环和双向链表来进一步扩展;我们稍后会探讨这个,并且看看为什么这些增强(以及复杂性)实际上是必要的。

遍历树

在第十二章学习二叉查找树时,我们讨论了三种不同的遍历树的方式,通过“访问”所有节点,按照不同的方案进行遍历。对于一般树,你没有那么多的方法,但我们会新增一些。但首先,复习一下我们之前做过的遍历,并将其中的两种方法适配到一般树上:

前序 对于二叉树,前序意味着首先访问根节点,然后遍历其左子树,最后遍历其右子树。你可以将这个方法适配到一般树上,首先访问根节点,然后按顺序遍历每一个子树。

后序 后序方法对于二叉树来说类似于前序方法,但它首先访问根节点的左子树,然后是右子树,最后访问根节点本身。其变体要求先遍历所有根节点的子节点,并按顺序访问,最后访问根节点。

中序 中序方法实际上没有完全对应的版本。对于二叉树,它意味着首先遍历左子树,然后访问根节点,最后遍历右子树。然而,对于一般树,你没有合适的替代方法,因此可以跳过这种遍历(尽管对于本章后面讨论的 B 树,你确实有可能实现一个中序版本)。

编写前序和后序遍历的代码是相当直接的,二叉树的版本只需要做一些小的修改(我们将在本章末的习题中讨论它们的实现)。然而,有两个新方法值得学习,并且也出现在其他类型的算法中,例如游戏或函数优化:深度优先广度优先遍历。

深度优先遍历

实现可能最合逻辑的遍历方法需要首先访问根节点,然后使用相同的算法遍历所有子节点。实际上,这相当于在移动到另一分支之前尽可能深入地进入一个分支——因此,称为深度优先。 图 13-5 展示了这种遍历的一个示例;节点中的数字反映了访问发生的顺序。

图 13-5:树的深度优先遍历

这种算法是一种经典算法,通常用于搜索或游戏。例如,如果你试图走出迷宫,你会沿着某条路径前进,直到你要么退出(并完成任务),要么遇到障碍,在这种情况下你会回头尝试另一条路径。(请参阅第 69 页的“迷宫路径查找”。)同样,在游戏中,你会尽可能地考虑某一系列的移动(因为时间有限),如果你还没有找到获胜的路线,就会回头尝试另一条。其逻辑如下:

❶ const depthFirst = (tree, visit = (x) => console.log(x)) => {
❷ if (!isEmpty(tree)) {
  ❸ visit(tree.key);
  ❹ tree.children.forEach((v) => depthFirst(v));
  }
};

这个算法与在第十二章中为二叉树编写的某些算法类似。首先,定义一个默认的 visit()函数❶,它仅列出节点的键,然后如果树不为空❷,访问其根节点❸。接下来,递归地按照深度优先的方式访问所有子节点❹。

广度优先遍历

遍历树的另一种方式是广度优先,这是一种你还未遇到的遍历方式。(这种遍历方式也被称为层次顺序,原因稍后会显现出来。)其基本思想是从根节点开始;然后,访问下一层的所有子节点。然后(仅在此时),访问第二层的子节点,以此类推。你永远不会访问一个节点,直到你已经访问了所有更靠近根节点的节点,一层一层地向下访问。图 13-6 显示了这种遍历方式。再次提醒,数字表示节点访问的顺序,你可以验证每一层在开始下一层之前都已被完全访问。

图 13-6:树的广度优先遍历

实现这一策略需要使用队列,如第十章中所讨论的那样。当你开始“水平”地访问节点时,你需要记得稍后访问它们的子节点,因此使用先进先出(FIFO)策略的队列很有效。你可以将其作为一个独立的函数进行编码以增加多样性:

❶ breadthFirst(visit = (x) => console.log(x)) {
❷ if (!this.isEmpty()) {
  ❸ const q = new Queue();
  ❹ q.push(this);
  ❺ while (!q.isEmpty()) {
    ❻ const t = q.pop();
    ❼ visit(t.key);
    ❽ t.childNodes.forEach((v) => q.push(v));
    }
  }
}

与其他遍历方法一样,默认的 visit()函数❶会记录节点的键。如果要遍历的树不是空的❷(在这种情况下你不需要做任何事情),则初始化一个队列❸并将树的根节点压入队列❹。其余的算法比较直接:当队列未被清空❺时,你弹出队列的顶部❻,访问该节点❼,并将它的所有子节点压入队列以便稍后访问❽。

这个算法根本不是递归的,这对于树和其他递归定义的结构来说并不常见。这里有一种有趣的对称性:不使用递归进行广度优先遍历树需要使用队列,而不使用递归进行深度优先遍历则需要使用栈;见第 13.3 题。

B 树

B 树具有自我调整的树结构,确保在添加、删除和查找时具有对数时间性能,因此从这个意义上讲,你可以将它们视为高度平衡的二叉搜索树的扩展——而且是性能更优的扩展。这些树的一个关键特征是节点可以拥有多个子节点,这使得树变得更宽、更矮,从而实现更快速的搜索。

注意

没有人真正知道 B 树中 B 代表什么。这个结构是由 Rudolf Bayer 和 Edward McCreight 于 1972 年定义的,但并没有给出这个术语的解释,所以你可以选择自己的解释:一些提议包括“平衡”,“广泛”,“灌木状”,当然,还有“Bayer”。

B 树的定义(以及实现)在不同的来源和作者之间有所不同,因此我们需要明确这里所使用的定义。一个阶数为m的 B 树满足以下属性:

  • 每个节点有p < m 个按升序排列的键,和p + 1 个子节点。

  • 除了根节点外,每个节点必须至少有m/2(向上取整)个子节点,换句话说,所有节点(除了根节点)应该至少是半满的。

  • 根节点应至少包含一个键。

  • 所有叶子节点必须位于同一层级。

B 树的结构类似于二叉搜索树:给定一个节点中的键,该节点左侧的所有子节点的值都小于该键,右侧的所有子节点的值都大于该键。例如,你可以有一个如下所示的节点,参见图 13-7。

图 13-7:一个 B 树节点,展示了键的位置

在这种情况下,节点有四个键,因此有五个子节点。第一个子节点位于 34 键的左侧,包含比 34 小的键;位于 34 和 40 之间的子节点包含介于这两个值之间的键,以此类推,直到最后一个子节点,它位于 60 键的右侧,包含比该值大的键。(这些事实就是我们用来在 B 树中进行搜索的依据;你很快会看到算法。)这种结构类似于二叉搜索树,不同之处在于每个节点现在允许更多的子节点,而不是最多两个子节点——对于实际的实现(如磁盘中文件的索引),通常更大的值更受欢迎,以便缩短树的高度,从而加快访问速度。

定义 B 树

让我们像在二叉搜索树中一样,首先定义我们需要的基本函数:

❶ let ORDER = undefined;

const newBTree = (order = 3) => {
❷ if (ORDER === undefined) {
    ORDER = order;
  }
  return null;
};

❸ const newNode = (
  newKeys = [null],
❹ newPtrs = new Array(newKeys.length + 1).fill(null)
) => ({
  keys: newKeys,
  ptrs: newPtrs
});

const isEmpty = (tree) => tree === null;

❺ const _tooBig = (tree, d = 0) => tree.keys.length + d > ORDER – 1;

❻ const _tooSmall = (tree, d = 0) =>
  tree.keys.length - d < Math.ceil(ORDER / 2) – 1;

你可以定义任意阶的 B 树,我们将使用一个变量 ORDER ❶来存储你想要的阶数。当你第一次创建 B 树 ❷时,你将存储所需的阶数(默认情况下是 3),这样所有未来的 B 树都会具有这个阶数。(这个决定引出了一个问题:如果你想要不同阶数的 B 树怎么办?参见问题 13.9。)newNode()函数 ❸默认情况下会创建一个包含单一空键的新节点,并且两侧有空指针;当然,这个节点会显得“过于空”,除非 ORDER 值很小。然而,值得注意的是,如果你提供一个键的数组 ❹,一些 JavaScript 的技巧将被用来生成(如果需要)一个对应的空指针数组,数组长度比键数组多一个;你能理解这怎么工作吗?

最后,一些辅助函数会派上用场。有时,你需要测试一个节点是否超载(或者如果添加 d 个键后会超载),即它的键数超过了允许的数量 ❺,_tooBig()函数将检查这一点。类似地,_tooSmall()函数判断节点是否过小(或者如果移除 d 个键后会过小),即它没有足够的键 ❻。(注意不要对根节点使用 _tooSmall()——根节点是唯一允许小于规定键数的节点。)你将在添加或删除键时使用这两个方法。

在 B 树中查找键

让我们从最基本的算法开始:查找一个键。从某种意义上讲,算法类似于查找二叉搜索树中的键;你查找该键,如果在某个节点中没有找到,你会判断接下来应该在哪里继续查找。考虑一些例子。假设你有一个阶数为 3 的 B 树;空链接用空白框表示,如图 13-8 所示。

图 13-8:阶数为 3 的 B 树

如果你在寻找 22,那就简单多了:它就在根节点中,因此无需做任何操作。你可以让它变得更复杂,像寻找 56 一样,参考图 13-9。

图 13-9:键 56 的搜索过程

从根节点开始,由于 56 > 22,沿着根节点的最后一个指针到达一个新节点。在那里,你发现 56 应该位于 34 和 60 之间,因此沿着中间指针到达另一个节点。在这个节点中,你最终找到了 56,因此搜索成功。

如果要查找的键不在树中呢?如果你在寻找 38,整个过程和寻找 56 是一样的,但是在没有找到包含 40 和 56 的节点中的 38 后,你会继续沿着节点的第一个指针(因为 38 < 40),但发现它为空,搜索就会失败。

现在进入算法部分。每次到达一个节点时,你需要检查所需的键是否存在;如果不存在,算法会告诉你该跟随哪个指针到达下一层;为此有一个辅助函数:

const _findIndex = (tree, key) => {
❶ const p = tree.keys.findIndex((k) => k >= key);
  return p === -1 ? tree.keys.length : p;
};

这会寻找键数组中第一个大于或等于你所搜索的键的元素 ❶。如果没有匹配的键,findIndex() 会返回 -1,因此在这种情况下,你返回 ptrs 中最后一个元素的索引(你很快就会明白为什么这段代码如此巧妙):

const find = (tree, keyToFind) => {
❶ if (isEmpty(tree)) {
    return false;
  } else {
  ❷ const p = _findIndex(tree, keyToFind);
  ❸ return tree.keys[p] === keyToFind || find(tree.ptrs[p], keyToFind);
  }
};

如果你在搜索一个键,并且到达一个空节点 ❶,显然该键不在其中。否则,_findIndex() 方法会找到第一个不小于所搜索键的键 ❷。如果该键正好等于你要查找的值 ❸,则搜索完成;否则,继续沿着相应的指针进行搜索。这就是为什么你让 _findIndex() 返回数组中的最后一个位置——因为当你要查找的键大于节点中的所有键时,这是你需要跟随的链接。

进行线性搜索可能看起来是倒退的一步,尤其是在你已经看过更好的搜索方法时;有关更好的搜索方法的想法,请参见问题 13.7。

遍历 B 树

我们可以定义二叉树的中序遍历等效方式,这意味着按升序访问所有键。由于每个节点有多个键,因此需要仔细操作。图 13-10 展示了如何实现这一点。

图 13-10:B 树的遍历,类似于二叉树的中序遍历

这种版本的中序遍历应该按升序访问键,因此,对于图 15-9 中的节点,首先访问最左边的子节点,然后访问该节点的第一个键,再访问第二个子节点,然后是第二个键,然后是第三个子节点,依此类推,直到遍历完最右边的子节点。

下面是一个它工作的示例:

const inOrder = (tree, visit = (x) => console.log(x)) => {
❶ if (!isEmpty(tree)) {
  ❷ tree.ptrs.forEach((p, i) => {
    ❸ inOrder(p, visit);
    ❹ i in tree.keys && visit(tree.keys[i]);
    });
  }
};

如果当前节点为空 ❶,什么都不做;否则,执行一个循环 ❷,在遍历子节点 ❸ 和访问键 ❹ 之间交替进行。对于后者,请记住,键的数量比子节点少一个。此代码使用&&表达式语法作为 if 或三元运算符的快捷方式:当且仅当条件为真时,表达式才会被求值;在此情况下,只有当对应索引在键数组范围内时,才访问键。

向 B 树添加键

现在考虑如何添加一个键。如果你想向一个有足够空余空间的节点添加键,这很简单。问题在于试图将一个键添加到一个已经满了的节点。考虑使用之前提到的 3 阶 B 树作为例子(见图 13-11)。

图 13-11:你将添加一些新键的 B 树

首先,尝试添加一个 66 的键。经过查找后,你决定它应该与 63 的键一起,并且因为该节点有足够的空间,所以不需要额外操作(见图 13-12)。

图 13-12:在有空间的节点添加键不会遇到问题。

现在让事情变得更复杂,添加一个 10 的键。这个问题在于,左下角的节点已经没有空间了。首先,你可以让它超出最大大小(见图 13-13)。

图 13-13:在节点达到限制时添加键需要分裂节点并旋转一个键到上层。

现在你需要将过大的节点分裂成两个,并将它的中间键上移到父节点。幸运的是,父节点有空间,所以操作完成了(见图 13-14)。

图 13-14:分裂后,B 树结构恢复正常。

如果你尝试添加一个 78 的键,这个节点会分裂两次:首先,66 的键会移动到 34-60 节点,然后该节点会分裂,将它的中间键(60)传递到父节点。这是 B 树唯一的生长方式:如果根节点需要分裂,它会增加一个新层级。

看一下代码,看看根节点层级发生了什么:

const add = (tree, keyToAdd) => {
  if (isEmpty(tree)) {
  ❶ return newNode([keyToAdd]);
  } else {
  ❷ _add(tree, keyToAdd);
  ❸ if (_tooBig(tree)) {
      const m = Math.ceil(ORDER / 2);
    ❹ const left = newNode(tree.keys.slice(0, m - 1), tree.ptrs.slice(0, m));
    ❺ const right = newNode(tree.keys.slice(m), tree.ptrs.slice(m));
    ❻ tree.keys = [tree.keys[m – 1]];
    ❼ tree.ptrs = [left, right];
    }
    return tree;
  }
};

最简单的情况是如果树为空 ❶,因为这时你只需要创建一个新节点,并加入新的键和几个空指针。否则,将键添加到树中的某个位置 ❷(使用一个辅助的递归 _add()方法,它会立即处理),并检查根节点是否变得太大 ❸。如果是,创建两个新节点 ❹ ❺,每个节点包含一半的键和指针,并将中间键保留在根节点 ❻,根节点将有这两个新节点作为子节点 ❼。

但是,新的键是如何添加的呢?之前的 _add()方法又有什么问题呢?下面是代码:

const _add = (tree, keyToAdd) => {
❶ const p = _findIndex(tree, keyToAdd);
❷ if (isEmpty(tree.ptrs[p])) {
    tree.keys.splice(p, 0, keyToAdd);
    tree.ptrs.splice(p, 0, null);
  } else {
  ❸ const child = tree.ptrs[p];
  ❹ _add(child, keyToAdd);

  ❺ if (_tooBig(child)) {
      // Child too big? Split it
      const m = Math.ceil(ORDER / 2);
    ❻ const newChild = newNode(child.keys.slice(m), child.ptrs.slice(m));

    ❼ tree.keys.splice(p, 0, child.keys[m – 1]);
      tree.ptrs.splice(p + 1, 0, newChild);

    ❽ child.keys.length = m – 1;
      child.ptrs.length = m;
    }
  }
};

首先找到当前节点的子树中应该添加新键的位置❶。如果子树不存在(相应的指针为 null),说明你已经到达了最底层,可以直接在此添加键❷;无需做其他操作。(当然,底层节点可能变得过大,但这会由其父节点检查,并在必要时解决问题。)如果存在子树❸,则递归地将新键添加到该子树❹,然后检查子节点是否变得过大❺。如果是,你需要添加一个新节点,将过大节点的后半部分键和指针移到新节点❻;中间的键和指针将上升到父节点❼,而原始子节点将保留前半部分键和指针❽。这段代码并不特别难理解,但正确处理索引和数组需要小心。

从 B 树中移除键

添加一个键会引入复杂性,有时节点可能会变得过大,处理这个问题需要旋转或将键上移。移除一个键也会带来困难,因为节点可能变得过空,需要从其他节点获取键,或者最终需要从父节点获取键,这可能导致整个 B 树的高度变短;就像添加键可能使其变高一样,移除键则可能使其变低。

有几种可能的情况需要研究,这里有两种:移除一个不在叶子节点中的键和移除一个位于叶子节点中的键。

从非叶子节点移除键

在内部节点中移除键很容易;它与二叉搜索树中的操作相同。将键替换为后继键(按照升序排列的下一个键),然后从树中移除键(它会位于叶子节点)。例如,假设你想从图 13-15 中的树中移除 22 键。

图 13-15:一个 B 树,你将在其中移除一个非叶子节点的键——在这个例子中是 22

你需要首先定位到 22 后的键,因此跟随 22 后的链接,然后继续沿着最左侧的链接,直到到达一个叶子节点找到 24 键(见图 13-16)。

图 13-16:要移除 22,首先定位到下一个(更大的)键,在此例中是 24。

现在,将要移除的键(22)替换为下一个键(24),然后继续按照从叶子节点移除键的逻辑进行操作(在图 13-17 中以灰色标记)。

图 13-17:将 24 移到 22 的位置后,你现在需要从叶子节点中移除 24。

通过这种方法,你总是需要从叶子节点中移除键。以下是如何操作。

从叶子节点移除键

在找到要移除的键并确认它位于叶子节点后,你有两种可能的情况:要么该节点“足够满”,因此移除键不会使其过空,要么该节点处于最小大小,这意味着移除键会让它变得过小。

第一个情况很容易处理:继续上一节的例子,要移除 24 键,只需将其从节点中移除,因为该节点有足够的键(见图 13-18)。

图 13-18:如果移除后叶子节点仍有足够的键,你就完成了。

但考虑一个更复杂的情况:如果你要移除 12 键,会发生什么?你会遇到问题,因为对应的节点最终会键数不足,如图 13-19 所示。(在这种情况下,节点会变为空,因为你正在处理一个 3 阶的 B 树;在更高阶的 B 树中,节点仍然会有一些键,但不足够。)

图 13-19:尝试从兄弟节点旋转键以重新组织 B 树

这里的解决方案取决于节点的兄弟。你可以尝试从其中一个兄弟借键,检查是否可以从左边或右边的兄弟借键;两个兄弟是对称的。在这种情况下,左兄弟有足够的键(4 和 9),所以从它那里借一个。9 键进入父节点,11 键被旋转到叶子节点(见图 13-20)。

图 13-20:旋转解决了问题,所以你完成了。

你只剩下一个待处理的情况:如果没有兄弟节点有键可共享,会发生什么?在这种情况下,将节点与其兄弟合并,并从父节点借用一个键。这个步骤可能会导致节点变得不足,并需要修复。在这个例子中,假设你要移除 11 键。将它与其兄弟合并,并借用 9 键,达到了图 13-21 所示的情况。

图 13-21:叶子节点已经修复,但它上面出现了问题。

为了解决新的不足节点情况,你再次使用借键的概念,旋转键,最终的树形如图 13-22 所示。

图 13-22:新的旋转解决了问题。

在进行共享和合并时,最终树可能会变得更短。如果你连续移除了 4 和 9 键,你会得到图 13-23 中的树形。

图 13-23:移除几个键可以使 B 树最终变得更短。

你能做出中间步骤吗?

实现 remove()方法

现在你已经看到所有的应用策略,接下来是如何编写代码。处理根节点的删除是简短的:

const remove = (tree, keyToRemove) => {
❶ tree = _remove(tree, keyToRemove);
❷ return (isEmpty(tree) || tree.keys.length === 0) && !isEmpty(tree.ptrs[0])
    ? tree.ptrs[0]
    : tree;
};

首先使用 _remove() 函数递归地从树中移除关键字 ❶。如果树为空,或者根节点已经没有关键字(因为它只有一个关键字,并且在两个子节点合并时必须将其传递下去),但节点仍然有一个非空的子节点(根节点可能已经位于最底层,没有子节点) ❷,则返回该子节点。这意味着 B 树已经变得更短。

现在开始实际的关键字移除过程:

const _remove = (tree, keyToRemove) => {
❶ if (!isEmpty(tree)) {
 ❷ const p = _findIndex(tree, keyToRemove);
    if (tree.keys[p] === keyToRemove) {
    ❸ if (isEmpty(tree.ptrs[p])) {
      ❹ tree.keys.splice(p, 1);
        tree.ptrs.splice(p, 1);
      } else {
      ❺ const nextKey = _findMin(tree.ptrs[p + 1]);
      ❻ tree.keys[p] = nextKey;
      ❼ _remove(tree.ptrs[p + 1], nextKey);
      ❽ _fixChildIfSmall(tree, p + 1);
      }
    } else {
    ❾ _remove(tree.ptrs[p], keyToRemove);
    ❿ _fixChildIfSmall(tree, p);
    }
  }
  return tree;
};

从进行搜索时开始:如果树为空,操作结束 ❶。否则,检查当前节点是否包含你要移除的关键字 ❷。如果找到关键字并且已经到达底层 ❸,直接移除该关键字及其对应的指针 ❹;否则,如果你在更高的层次,使用 _findMin() 查找按升序排列的下一个关键字 ❺,并将其放入你想移除的原始关键字的位置 ❻。最后,移除树中的下一个关键字 ❼,并在需要时修正其大小 ❽(因为子节点变得太小,里面没有足够的关键字)。如果关键字不在节点中,向下移至下一层进行移除 ❾,并在必要时修复大小 ❿。

如何找到下一个关键字?你之前可能见过类似的方法,对于 B 树,代码也相当简短:

const _findMin = (tree) =>
  isEmpty(tree.ptrs[0]) ? tree.keys[0] : _findMin(tree.ptrs[0]);

如果没有最左边的子树,返回节点中的第一个关键字;否则,向下进入子树并寻找那里的最小值。

最后一种方法是 _fixChildIfSmall(),它处理之前提到的所有情况并正确地重新平衡节点。以下包括四种不同的情况,但每种情况的逻辑都很简短:

const _fixChildIfSmall = (tree, p) => {
  const child = tree.ptrs[p];

❶ if (_tooSmall(child)) {
  ❷ if (p > 0 && !_tooSmall(tree.ptrs[p - 1], 1)) {
    ❸ const leftChild = tree.ptrs[p – 1];
      child.keys.unshift(tree.keys[p - 1]);
      child.ptrs.unshift(leftChild.ptrs.pop());
      tree.keys[p - 1] = leftChild.keys.pop();
  ❹} else if (p < tree.keys.length && !_tooSmall(tree.ptrs[p + 1], 1)) {
    ❺ const rightChild = tree.ptrs[p + 1];
      child.keys.push(tree.keys[p]);
 child.ptrs.push(rightChild.ptrs.shift());
      tree.keys[p] = rightChild.keys.shift();
  ❻} else if (p > 0) {
    ❼ const leftChild = tree.ptrs[p – 1];
      leftChild.keys.push(tree.keys[p - 1], . . .child.keys);
      leftChild.ptrs.push(...child.ptrs);
      tree.keys.splice(p - 1, 1);
      tree.ptrs.splice(p, 1);
  ❽} else {
    ❾ const rightChild = tree.ptrs[p + 1];
      rightChild.keys.unshift(...child.keys, tree.keys[p]);
      rightChild.ptrs.unshift(...child.ptrs);
      tree.keys.splice(p, 1);
      tree.ptrs.splice(p, 1);
    }
  }
};

首先,验证子节点是否仍然足够大 ❶,如果是,则无需做任何操作。然后,检查子节点是否有一个左兄弟,如果从中取一个关键字不会导致它变得太空 ❷;如果是这种情况,则进行如前所述的关键字旋转 ❸。或者,检查子节点是否有一个右兄弟,且其包含足够的关键字 ❹,如果是,执行与该兄弟的旋转 ❺。如果无法进行旋转并且有左兄弟 ❻,则将其与子节点合并 ❼;否则,必定有一个右兄弟 ❽,因此与它合并 ❾。再次强调,情况不复杂,但操作索引时需要小心;很容易出错。

考虑 B 树的性能

B 树确保每个节点(根节点除外)至少有一定数量的子节点,因此随着层次的增加,它呈指数增长,意味着高度是对数的;从根节点到另一个关键字的所有路径都将是 O(log n),因此所有算法的时间复杂度都呈对数增长,如 表 13-1 所示。

表 13-1:B 树操作的性能

操作 性能
创建 O(1)
添加 O(log n)
移除 O(log n)
查找 O(log n)
遍历 O(n)

B 树确保良好的性能,因此被广泛应用,特别是在为数据库创建索引时;事实上,B 树是 MySQL 和 PostgreSQL 的默认结构。

红黑树

B 树功能强大,但实现起来可能有些复杂。不过,你可以使用二进制表示法来操作它们,从而以不同的方式产生相同的结果。具体来说,我们将使用阶数为 3 的 B 树,但我们会以二叉树的方式表示它们。最终得到的红黑树性能非常好,广泛应用于 Linux 内核中,用于跟踪目录条目、虚拟内存、调度等。在这一部分,我们将介绍左倾红黑树,这是一种由 Robert Sedgewick 创建的变体,比原始的红黑树更容易实现。

注意

阶数为 3 的 B 树也被称为 2-3 树,暗示它们的节点有两个或三个子节点。同样,阶数为 4 的 B 树被称为 2-3-4 树或 2-4 树。

将红黑树中的节点视为 2 节点(有两个子节点)或 3 节点(有三个子节点)。你可以将 2 节点表示为任何二叉树中的普通节点,但在这里你需要添加一个额外的节点来表示 3 节点(参见图 13-24)。

图 13-24:一个红黑节点实际上等效于一个阶数为 3 的 B 树。

标准节点是黑色的,为了区分 2 节点和 3 节点,3 节点会添加额外的红色节点。你也可以说,指向黑色节点的链接是黑色的,而指向红色节点的链接是红色的。

注意

由于本书是黑白印刷,"红色"节点将用灰色表示,黑色文字;"黑色"节点将用黑色表示,白色文字。

根据表示方式的定义,红色节点总是位于左侧;此外,红色节点不能与另一个红色节点连接(或者说,不能有两个连续的红色链接)。另外,根节点是黑色的,空树(位于底部的叶子)也是黑色的。

现在,让我们把之前处理的 B 树转换成红黑树(见图 13-25)。

图 13-25:将转换为红黑树的 B 树

所有 2 子节点变为黑色节点,3 子节点则添加一个新的红色节点,如图 13-26 所示。

图 13-26:等效的红黑树

现在你拥有的是一棵二叉搜索树,这意味着你可以使用之前的关键字查找逻辑而不需要任何改变,但在添加或删除关键字时需要进行调整。

注意

由于其源自 B 树,红黑树具有另一个重要属性。从根到叶的所有路径具有相同数量的黑色节点,并且最多有相同数量的红色节点。这个属性被称为黑平衡,我们在本书中会经常提到它。

接下来,我们来看看如何实现这些红黑树,但请记住它们与三阶 B 树的等价性,因为这样算法会更容易理解,实际上它们做的是本章早些时候介绍的相同类型的工作。

表示红黑树

红黑树只是二叉查找树,所以你可以从一些已经有的函数开始,例如 find()方法和其他不需要更改的函数。对于这些新树,你需要定义几个常量和一个用于翻转节点颜色的方法(你将频繁使用这些方法):

const RED = "RED";
const BLACK = "BLACK";
const flip = (color) => (color === RED ? BLACK : RED);

现在开始定义新的树:

const newRedBlackTree = () => null;

const newNode = (key) => ({
  key,
  left: null,
  right: null,
❶ color: RED
});

❷ const _isBlack = (node) => isEmpty(node) || node.color === BLACK;
❸ const _isRed = (node) => !_isBlack(node);

为了表示节点的颜色,添加一个颜色属性❶,新节点是红色的,尽管这个颜色可能会在以后更改为黑色。你还需要添加几个辅助方法来测试节点的颜色❷❸。请注意,你定义了空树是黑色的。

向红黑树添加键

本质上,你只是向之前描述过的 B 树中添加一个键。始终将节点添加为红色,这不会影响树的黑色平衡,但你可以稍后修复它们的颜色或做其他更改。还要注意,根节点始终是黑色的,并且确保满足红黑树的所有性质。

为了实现该算法,你将暂时允许出现右侧红链接或两个连续红链接等问题,但你会使用旋转和颜色变化来修复这些情况,直到完成。只需添加键,稍后再修复问题:

❶ const _add = (tree, keyToAdd) => {
  if (isEmpty(tree)) {
    return newNode(keyToAdd);
  } else {
    const side = keyToAdd <= tree.key ? "left" : "right";
    tree[side] = _add(tree[side], keyToAdd);
❷ **return _fixUp(tree);**
  }
};

❸ const add = (tree, keyToAdd) => {
❹ const newRoot = _add(tree, keyToAdd);
❺ newRoot.color = BLACK;
  return newRoot;
};

使用辅助方法 _add()❶ 添加键,算法与普通二叉查找树相同,唯一的创新是调用 _fixUp() 函数❷,该函数负责在结构出现问题时恢复树的结构。添加操作本身完成后❸,首先使用 _add() 将新键添加到树中❹,然后确保根节点是黑色的❺。

恢复红黑树结构

如果红黑树结构被损坏,恢复它的诀窍在于 _fixUp() 方法。记住新节点总是红色的。添加新键时的可能情况取决于新键是否最终形成了 2 节点或 3 节点的一部分。

第一个情况很简单:如果新子节点是黑色节点的左子节点,你就把一个 2 节点变成了 3 节点,并且由于红色子节点位于根的左侧,一切都没问题。将此情况称为(a)。否则,如果新子节点位于黑色根节点的右侧,你可以通过旋转来修复它。将此情况称为(b)。图 13-27 展示了这两种情况;N 是新添加的键,R 是 2 节点的原根节点。

图 13-27:如果将节点添加到右侧,需要进行旋转。

对于情况(a),无需执行任何操作,左旋即可解决情况(b)。在这两种情况下,整个树的黑色平衡没有受到影响。你没有添加任何黑色链接,所以一切仍然正常。同时注意,最初是红色的 N 节点已经变成了黑色。

更复杂的情况发生在你将新键添加到现有的 2-节点(从而创建 3-节点)时,因为在这种情况下,所有的情况都是错误的。修复起来相对最简单的情况是当新键成为 3-节点中的最右边键,如图 13-28 所示。我们将此情况称为 (c)。

图 13-28:你可以通过翻转颜色来修复将新键添加到 2-节点树中的情况,但它上方可能会出现新的问题。

这里有一个快速的解决方案:只需翻转三个节点的颜色。但是要注意,翻转后会将一个红色链接向上传递,可能需要进一步的递归修复。同时,确保树的黑色平衡保持不变,这样修复就算有效。

接下来复杂度较高的情况是将新键添加到 3-节点中最左侧的左子节点左边。我们将此情况称为 (d);你需要两步才能解决它,如图 13-29 所示。

图 13-29:将子节点添加到左子节点的左侧也可以通过旋转来解决。

如果新键是 3-节点中最小的键,你就会有两个连续的左红子节点。首先在根节点做一次右旋转,这样你会得到之前的情况(黑色根节点,两个红色子节点),然后再进行一次颜色翻转来解决问题,尽管你可能还需要更多的递归修复。

最终的情况 (e) 是最复杂的。添加一个新键,这个新键最终成为中间键,位于 3-节点中两个现有键之间,如图 13-30 所示。

图 13-30:你也可以通过旋转加颜色翻转的方式来修复将子节点添加到左子节点右侧的情况。

在这种情况下,你会得到一个红色节点位于另一个红色节点的右侧,这是不允许的。你可以通过开始左旋转来解决这个问题,这会使你得到一个已经处理过的情况,然后完成右旋转,得到一个你之前见过两次的场景(黑色根节点和两个红色子节点),因此最终的颜色翻转可以解决所有问题。

看一看旋转代码,它与我们在第十二章研究 AVL 树时完全相同,唯一的不同是我们不需要维护高度属性:

const _rotate = (tree, side) => {
  const otherSide = side === "left" ? "right" : "left";

  const auxTree = tree[side];
  tree[side] = auxTree[otherSide];
  auxTree[otherSide] = tree;

 **auxTree.color = auxTree[otherSide].color;**
 **auxTree[otherSide].color = RED;**

  return auxTree;
};

唯一的新增内容是两条粗体线,它们交换了颜色。

现在我们将深入探讨应用所有已描述修复的更有趣的代码(请注意,我们也将在删除操作中使用这段代码;同样的逻辑适用):

const _fixUp = (tree) => {
❶ if (_isRed(tree.right)) {
    tree = _rotate(tree, "right");
  }

❷ if (_isRed(tree.left) && _isRed(tree.left.left)) {
    tree = _rotate(tree, "left");
  }

❸ if (_isRed(tree.left) && _isRed(tree.right)) {
    _flipColors(tree);
  }

  return tree;
};

如果你查看的节点有一个红色的右孩子,进行左旋转❶,这解决了(b)情况,并且是(c)和(e)情况的第一步,当你递归向上时,后续步骤将完成。如果你有一个左红色子节点和一个左红色孙子节点❷,这就是(d)情况;你也可能在(e)情况做完旋转后到达这里。最后,在前面的变更后,要么你已经修复了所有问题——如果你最初处于(a)或(b)情况——要么你仍然需要翻转颜色❸,然后完成。

另一个来自该算法的收获是:左右旋转和颜色翻转都能保持树中的黑色平衡,因此,如果你从一棵红黑树开始,并且只应用这些变换,你最终必定会得到一棵红黑树。这个概念对插入操作很重要,但在删除操作时也会应用。

从红黑树中删除一个键

从红黑树中删除一个键可能是本书讨论的最复杂的算法。(许多教科书和其他资源通常会省略它,或者最多只做简单提示。)虽然添加一个键并不复杂,基本上和常见的二叉搜索树算法一样(加上一些检查约束是否保持的逻辑),但是删除需要一个更复杂的过程,包括树的上下变动。

你需要确保要删除的键位于树的底部,作为一个三节点的一部分(无论是黑色节点还是红色节点),因为在这种情况下,删除它不会导致任何问题。如果要删除的是红色键,直接把它删除即可。如果要删除的是黑色键,将红色键替换到它的位置,但将颜色改为黑色以保持平衡。图 13-31 展示了这两种情况;X 标记了要删除的键。

图 13-31:删除一个红色叶子是直接的,而删除黑色根节点也容易实现。

过程是这样的。你可以在向下过程中做旋转或翻转颜色,因此始终保证根节点要么是红色,要么有一个红色的左子节点,并且(暂时)容忍有红色的右子节点或两个红色子节点的黑色节点。当你找到你想要的键时,将其替换为下一个键(如同二叉搜索树),然后继续从树中删除该键。找到时,将会有图 13-31 中所示的两种情况之一适用,你就能有效地删除该键。最后,应用“修复”算法回到根节点,处理可能残留的任何问题。

记住需要遵守的不变式:根节点或其左子节点必须是黑色的。假设在算法的某个时刻,你必须向左移动。显然,如果根节点是黑色的,你只需向左移动(左子节点是红色的)。不变式将得以保持,现在根节点将变为红色。然而,如果左子节点是黑色的,那么有两种情况,取决于根节点右子节点的左子节点的颜色。如果那个子节点是黑色的,你可以直接翻转颜色,正如图 13-32 所示(为简化起见,它没有包括其他链接或子树,因此你可以专注于重要的节点)。小三角形指向新的红色节点,你将其向左移动以更新不变式。

图 13-32:颜色翻转调整了这个情况。

就等效的红黑树而言,这就像将节点合并创建一个 4-节点,稍后你将需要将其分割。

如果根节点右子节点的左子节点是红色的,你将需要更多的步骤:翻转颜色、右旋、左旋,再翻转颜色。但经过这些变换(所有这些都保持黑色平衡)后,你就可以向左移动:根节点左子节点的左子节点将是红色的,并且不变式得到保持。图 13-33 显示了所有步骤。

图 13-33:最复杂的情况需要多个旋转和颜色翻转。

初始树是(a);(b)是翻转后的树;(c)是旋转根节点右子节点的左子节点到右边后的结果;(d)是将根节点旋转到左边后的结果;最后,(e)是翻转颜色后的结果。

考虑等效的 2-3 树,这次删除操作就像从一个 3-节点借用 4 键,并将 3 键下移,和 2 键一起创建一个 3-节点。如前所述,你在保持黑色平衡,稍后不会有需要修复的情况。

现在考虑另一种情况,即你想要向右移动。这个情况与之前的类似,但稍微简单一些。如果根节点是黑色的,且右子节点是红色的,那么直接向右移动即可。如果根节点是红色的,且其左子节点是黑色的,并且根节点左子节点的左子节点也是黑色的,你可以直接翻转颜色。看看图 13-34 中展示的情况。

图 13-34:颜色翻转也修复了潜在的 4-节点树。

如前所述,这个解决方案相当于将节点合并并创建一个 4-节点树,稍后需要修复。

最后一种情况发生在你需要向右移动,而根节点左子节点的左子节点是红色时。你需要翻转颜色,进行旋转,然后再翻转颜色以恢复不变式,正如图 13-35 所示。

图 13-35:这个复杂的情况也需要同时进行颜色翻转和旋转。

在这个场景中,(a) 是初始情况,(b) 显示了翻转后的颜色,(c) 是将根节点右旋后,(d) 是再次翻转颜色后的状态。

再次以原始的 2-3 树为例,这个例子就像是将 2 键从 3 节点中移动到 3 键的位置,之后 4 键与其合并成一个 3 节点。然而,注意最终的情况是不合法的(右侧有一个红色子节点),因此稍后需要修复。

完整的算法直接基于 Sedgewick 自己的代码,但错误请归咎于我。简单部分如下:

const remove = (tree, keyToRemove) => {
❶ const newRoot = _remove(tree, keyToRemove);
  if (!isEmpty(newRoot)) {
  ❷ newRoot.color = BLACK;
  }
  return newRoot;
};

首先应用描述的算法,实际从树中移除键 ❶,然后确保根节点是黑色的 ❷,除非显然没有键,且树变为空。

删除操作的复杂部分是 _remove() 代码:

const _remove = (tree, keyToRemove) => {
  if (isEmpty(tree)) {
    return null;
❶} else if (keyToRemove < tree.key) {
  ❷ if (_isBlack(tree.left) && _isBlack(tree.left.left)) {
      _flipColors(tree);
      if (_isRed(tree.right.left)) {
        tree.right = _rotate(tree.right, "left");
        tree = _rotate(tree, "right");
        _flipColors(tree);
      }
    }
 ❸ tree.left = _remove(tree.left, keyToRemove);
❹} else {
    if (_isRed(tree.left)) {
      tree = _rotate(tree, "left");
    }
  ❺ if (keyToRemove === tree.key && isEmpty(tree.right)) {
      return null;
    } else {
    ❻ if (_isBlack(tree.right) && _isBlack(tree.right.left)) {
        _flipColors(tree);
        if (_isRed(tree.left.left)) {
          tree = _rotate(tree, "left");
          _flipColors(tree);
        }
      }
      if (keyToRemove === tree.key) {
      ❼ tree.key = minKey(tree.right);
        tree.right = _remove(tree.right, tree.key);
      } else {
      ❽ tree.right = _remove(tree.right, keyToRemove);
      }
    }
  }
❾ return _fixUp(tree);
};

在验证树不为空后,检查是否需要向左移动 ❶,如果需要 ❷,你可能需要先应用之前看到的变换,再实际向左移动 ❸。如果要删除的键大于或等于根节点,首先进行一次旋转 ❹,这样红色节点将移至右侧,稍后会用到。如果找到键 ❺ 并且它没有右子节点,那它一定在树的底部,此时可以删除它。你会想向右移动,因此按之前描述的程序设置好 ❻,但不要立刻移动。如果找到了键,但无法删除,替换成树中的下一个键并向右移动删除该值 ❼;否则,直接向右移动继续寻找要删除的键 ❽。最后,一次最终的修复 ❾ 将解决树中的任何错误配置。

红黑树在搜索方面的代码最简洁,而添加新键的代码也不复杂(基本上,只是在最后添加一个修复调用),但是删除操作则相对复杂。正确编写代码是很困难的(见问题 13.10 中的小细节)。

考虑红黑树的性能

我们不需要分析红黑树的性能,因为它们只是 B 树的另一种情况,因此你已经知道所有算法(添加、删除、搜索)都是 O(log n),如表 13-2 所示。

表 13-2:红黑树操作的性能

操作 性能
创建 O(1)
添加 O(log n)
移除 O(log n)
查找 O(log n)
遍历 O(n)

当然,由于可能嵌入的红色链接,红黑树的高度通常比高阶 B 树要高(并且并非从根到叶的所有路径长度都相同),但这并不改变结果。即使搜索速度变慢了一个(有界的)常数因子,性能仍然是对数级的。

总结

在本章中,我们超越了二叉树,探索了字典抽象数据类型(ADT)的两种新结构:B 树和红黑树(红黑树是从 B 树衍生出来的)。这些结构提供了良好的性能,且因其实现不复杂且速度显著,常被使用。

在下一章中,我们将学习堆,它是二叉树的一种变体,随后在接下来的章节中,我们将学习扩展堆,它结合了堆和森林,以实现高性能。

问题

13.1  缺失的测试?

在树的 appendChild()方法中,是否应该包括对 this._throwIfEmpty()的调用?

13.2  遍历一般树

实现缺失的前序和后序遍历。你可能需要对使用子节点数组表示的树和使用“左子节点,右兄弟”表示的树都进行实现。

13.3  非递归访问

实现一个不使用递归的深度优先遍历树的算法,通过使用栈作为辅助结构。

13.4  树的相等性

实现一个 equals(tree1, tree2)算法,用来判断两棵树是否相等——即,它们具有相同的形状和相同位置的键值。你可能需要“跳出框框”思考。也许你甚至不需要递归!

13.5  衡量树

重新实现第十二章中的 calcSize()和 calcHeight()函数(参见问题 12.5 和 12.6),使其适用于多路树。

13.6  更多共享

在 B 树中,除了兄弟节点共享一个键之外,如果它们共享更多的键,则可以实现更好的平衡。例如,在添加键时,如果一个节点变得过于拥挤,而一个兄弟节点有足够的空间,那么与其仅传递一个键,不如尽量传递更多的键,直到两个兄弟节点的容量差不多。删除键时,类似的过程也适用。实现这一优化。

13.7  更快的节点查找

本章使用了节点的线性查找,但由于键值是有序的,使用二分查找会更好。进行这个更改。这样会对 B 树的方法顺序产生影响吗?为什么或为什么不?

13.8  最低阶

阶数为 2 的 B 树是否有意义?

13.9  多阶树

如果需要处理不同阶数的 B 树,你会怎么做?提示:这里的问题是导入的模块是单例模式。尝试寻找一种方法来避免这种行为。

13.10  安全删除?

在红黑树的 remove()算法中,当你实际删除一个节点时,是否确定能够在不产生负面影响的情况下将其删除?

第十四章:14 堆

在上一章,我们处理了二叉树,本章我们将继续使用二叉树,不过这次使用的是一种不需要动态内存的变体:堆。堆允许我们轻松实现一个新的抽象数据类型(ADT)、一个高效的排序方法,并为二叉搜索树的增强版本提供新的结构。我们将考虑实现堆(特别是二叉最大堆,但也包括其他类型),并探讨堆在优先队列、堆排序和另一种叫做 treaps*的新结构中的应用。

在下一章,我们将介绍另一种堆的表示方法,它使用动态内存,并且为一些新操作提供了更多的自由度和更好的性能。

二叉堆

二叉堆,通常简称为,是一种具有两个特定属性的二叉树:一个结构属性,决定了树的形状;一个堆属性,指定了父节点与子节点键值之间的关系。

结构属性

堆是二叉树的一个子集,结构属性要求树必须是完全树,且最后一层的所有叶子节点必须位于左侧。请看图 14-1 中的树。只有一个符合堆的条件,而其他两个不符合。你能分辨出哪个是哪个吗?

图 14-1:在这三种堆候选中,哪一个是正确的?

图 14-1 中左边的树是唯一的堆。中间的树有一个不完整的中间层,而右边的树则底部的子节点不全在左侧。

根据这个规则,你可以在一个普通的数组中存储堆,而无需动态内存或指针,从而简化实现。(参见问题 14.18,考虑另一种替代方案。)将根节点放在数组的第一个位置,然后依次放置第二层节点(从左到右)、第三层节点(也是从左到右),以此类推。图 14-2 展示了一个示例的数组布局。节点中的数字对应于数组中的索引。

图 14-2:将堆的节点存储在数组中

在这种表示法中,堆的根节点总是位于位置 0。位置为 p 的节点的左右子节点分别位于连续的相邻位置 2p+1 和 2p+2,除非它们超出了堆的末尾,此时该节点的子节点会较少。非根节点位置 p 的父节点位于 Math.floor((p-1)/2) 位置。

你可以通过几个示例验证这些规则。根节点(在位置 0)的子节点位于位置 20 + 1=1 和 20 + 2=2。节点 4 有一个子节点,位于位置 2*4 + 1=9,因为另一个子节点会超出堆的大小。节点 9 的父节点位于位置 Math.floor((9-1)/2)=4。节点 2 的父节点位于位置 Math.floor((2-1)/2)=1。

这些规则让你能够实现算法而无需任何指针;一个简单的数组就足够了。与第十二章中的完全树一样,如果堆最多有n = 2h*(–1)个节点,则它的高度将是h*,因此它的高度受限于 log n,这是在顺序计算中会出现的一个结果。

堆属性

堆的第二个属性很简单:节点的键值必须大于或等于其子节点的键值。这与二叉搜索树有重要的区别:在堆中,左右子节点没有区别(任意一个可能大于另一个),但它们的键值都会小于或等于其父节点。任何同时遵循结构属性和堆属性的树被称为二叉最大堆,或者更简单地,称为

注意

为什么我说堆默认表示“最大堆”?歌曲《New York, New York》也许能给我们一些线索:弗兰克·辛纳特拉描述了想成为“山丘之王”或“堆顶之王”。这表明堆的根节点(顶部)应该是最大的值,不是吗?

你可以反转条件,指定父节点的键值要小于或等于其子节点的键值,这意味着根节点将是堆的最小值。这个变体称为最小堆,你可以在(其他场景中)使用它来合并多个链表,这需要反复找到许多元素中的最小值(见第 14.5 题)。图 14-3 中显示的堆同时满足结构和堆的属性。

图 14-3:有效的堆

如前所述,这棵树也可以通过数组来表示,其中 60(根节点)位于数组的第 0 个位置,如图 14-4 所示。

图 14-4:与图 14-3 相同的堆,以数组形式存储

堆属性有一个直接的结果:堆中的最大值必然位于根节点;你能看出为什么吗?你会在堆中的哪里找到第二大值?第三大值?第四大值?(见第 14.13 题。)这个结果对于我们稍后在本章中学习的一种排序算法堆排序至关重要。

堆实现

为了实现堆,你只需要一个简单的数组。堆是一种数据结构,具有一些操作,如表 14-1 所示。

表 14-1:堆的操作

操作 签名 描述
创建 → H 创建一个新的堆。
是否为空? H → boolean 确定堆是否为空。
Top H → key 给定一个堆,返回它的顶部值。
Add H × key → H 给定一个新值,将其添加到堆中。
Remove H → H × key 给定一个堆,提取其顶部值并相应更新结构。

你将为这些操作实现的函数是:

**newHeap() **创建一个新的堆

**isEmpty(heap) **判断堆是否为空

**top(heap) **获取堆顶(最大)元素的值

**add(heap, value) **将新元素添加到堆中

**remove(heap) **移除堆顶元素

前三个函数非常简短:

❶ const newHeap = () => [];

❷ const isEmpty = (heap) => heap.length === 0;

❸ const top = (heap) => {
  if (isEmpty(heap)) {
    return undefined;
  } else {
    return heap[0];
  }
};

创建一个新的空堆与返回一个空数组相同❶。堆的大小是 heap.length,因此检查其是否为 0 可以判断堆是否为空❷。另外,堆的顶部(除非堆为空,此时该代码返回 undefined)位于数组的第一个位置❸,所以实现细节非常直接。

向堆中添加元素

向堆中添加新值的步骤如下:

  1. 将新值添加到数组的末尾。

  2. 如果值大于其父节点,反复交换位置。

  3. 当值小于父节点或者到达堆顶时,停止。

让我们看看这个是如何工作的。首先看一下图 14-5 中显示的堆。

图 14-5:初始堆,在添加新值之前

如果你想插入一个新的值 56,第一步是将它添加到堆的末尾,这样你就会得到图 14-6 所示的结果。

图 14-6:新值(56)从堆的末尾开始。

让我们看看新值是否需要上浮。比较 56 与其父节点(24)后,发现它们需要交换,结果是得到一个新的堆配置(见图 14-7)。

图 14-7:如果新值大于父节点,它必须“上浮”。

上浮后,继续递归检查,如果需要,继续向上移动(见图 14-8)。

图 14-8:上浮继续,直到添加的值不大于其父节点或位于堆的根部。

最后一步导致了插入值不大于其父节点的情况,因此算法停止。

我们的 add()版本简洁明了:

const add = (heap, keyToAdd) => {
❶ heap.push(keyToAdd);
❷ _bubbleUp(heap, heap.length – 1);
  return heap;
};

如前所述,新的值被添加到堆的末尾❶,并通过使用 _bubbleUp()辅助函数强制它上浮到最终位置❷。

如前所述,递归实现是最简单的。如果元素已经上浮,使用 _bubbleUp()递归应用以保持其上浮:

const _bubbleUp = (heap, i) => {
❶ if (i > 0) {
  ❷ const p = Math.floor((i - 1) / 2);
  ❸ if (heap[i] > heap[p]) {
    ❹ [heap[p], heap[i]] = [heap[i], heap[p]];
 ❺ _bubbleUp(heap, p);
    }
  }
};

如果元素尚未位于堆顶❶,则使用数学方法(如“结构属性”第 318 页所示)确定位置 i 的父节点 p❷。如果需要交换元素❸,使用解构赋值非常方便❹,并且可以通过递归继续上浮(如果需要)❺。

从堆中移除

接下来,你需要 remove() 方法。记住,整个堆必须变小一个元素,所以移除堆顶后会发生什么呢?如果堆为空,就没有东西可移除;抛出异常,然后完成。如果堆不为空,取出堆中的最后一个元素,放到堆顶,然后将堆的大小减去一。如果该元素没有子节点,就停止。如果该元素大于其子节点中的最大值,也停止。否则,将该元素与它的最大子节点交换,并继续将它向下移动。

下面是一个示例,展示了这个过程是如何工作的。请从图 14-9 中的堆开始。

图 14-9:移除堆顶之前的初始堆

第一步是移除堆顶的值(60),用堆中的最后一个值(22)替换它,然后将堆的大小减去一,这样就得到了图 14-10 所示的情况。需要向下移动的值已被高亮显示。

图 14-10:移除堆顶后,将其替换为堆中的最后一个元素(22)。

现在开始向下筛选。将 22 与它的子节点进行比较,需要与 56 交换,这样就得到了图 14-11 所示的新情况。

图 14-11:如果新的堆顶不大于其子节点,它必须“下沉”。

递归地将 22 与它的新子节点进行比较,结果它仍然需要向下筛选,如图 14-12 所示。

图 14-12:向下筛选一直进行,直到该值大于其子节点或达到叶节点为止。

在这种情况下,22 现在大于它的子节点,因此向下筛选过程结束。如果值 40 变成了 20,22 将与 24 交换,并且向下筛选过程也会结束,因为 22 将没有子节点。

请看以下代码,它使用递归的 _sinkDown() 辅助函数将一个值推向堆的下方:

const _sinkDown = (heap, i, h) => {
❶ const l = 2 * i + 1;
❷ const r = l + 1;
❸ let g = i;
  if (l < h && heap[l] > heap[g]) {
    g = l;
  }
  if (r < h && heap[r] > heap[g]) {
    g = r;
  }
  if (g !== i) {
  ❹ [heap[g], heap[i]] = [heap[i], heap[g]];
  ❺ _sinkDown(heap, g, h);
  }
};

计算 l 和 r,分别为父节点 i 的左子节点和右子节点;你可以使用之前在“结构属性”章节第 318 页❶讨论的公式,通过添加 1 ❷来找到 r,因为在数组中 r 紧跟 l。使用 g 来确定位置 i、l 和 r 中的最大值 ❸。如果 i 位置的值不大于其子节点,交换它 ❹,并递归地继续向下筛选 ❺。

到这个阶段,你终于可以编写 remove() 函数了:

const remove = (heap) => {
❶ const topKey = top(heap);
❷ if (!isEmpty(heap)) {
  ❸ heap[0] = heap[heap.length – 1];
  ❹ heap.length--;
  ❺ _sinkDown(heap, 0, heap.length);
  }
❻ return [heap, topKey];
};

这段代码紧跟着前面示例中的描述。当你获取堆顶(如果堆为空,则可能是未定义的)❶,如果堆不为空❷,将最后一个值放到堆顶❸,将堆的长度减去一❹,并将新的堆顶向下筛选❺。最后,返回堆顶值和更新后的堆❻。

考虑堆的性能

表 14-2 展示了刚刚探讨过的算法的性能。

表 14-2:堆操作的性能

操作 性能
创建 O(1)
是否为空? O(1)
顶部 O(1)
添加 O(log n)
删除 O(log n)

三个操作是常数时间的:创建堆、测试是否为空和获取顶部值。其他两个操作,添加和删除,则更为复杂。添加元素可能会使其从堆底部向上冒泡,直到顶部。由于堆的高度是 log n,因此此操作需要对数时间。类似地,删除元素意味着将新元素放到顶部,并可能将其下沉到底部。这个过程的操作数量与添加元素时相同,只是顺序相反(同样是对数时间)。

让我们继续考虑一个新的 ADT,并比较堆与之前讨论过的其他数据结构的性能。 ### 优先队列与堆

优先队列(PQs) 与第十章中讨论的队列不同,因为每个元素都有一个关联的优先级,决定了哪个元素会被首先移除。在优先队列中,第一个被移除的元素是具有最高优先级的元素,而不是最早添加的元素(这与先进先出 FIFO 策略不同)。

注意

英语语言存在问题!术语 priority one 意味着最高优先级,但 1 是最低优先级数字。如果你按优先级顺序排序任务,最低编号的任务应该最先处理,那么较低的数字具有较高的优先级。然而,一些工具(例如 Microsoft Project)假定 0 是最低优先级,较高的数字具有较高优先级,因此没有明确的规定。不管怎样,如果你需要最小堆而非最大堆,参见问题 14.4。

优先队列(PQs)在多个算法和不同的场景中都有应用。操作系统调度器通过优先级来选择下一个执行的进程。离散事件模拟根据时间戳决定下一步的操作(在这种情况下,较小的时间戳表示较高的优先级)。Dijkstra 最短路径算法(我们将在第十七章中讨论)需要找到与另一个给定顶点的最短距离的顶点。Prim 算法用于查找图的最小生成树,也需要找到与另一个顶点连接最小(最便宜)的顶点。霍夫曼编码算法构建树并反复需要找到两个概率最小的节点,用它们的概率之和替换为一个新节点。所有这些操作都需要优先队列(PQs)。

从抽象数据类型(ADT)角度来看,优先队列的描述需要以下操作,参见表 14-3(其他增加更多操作的版本将在后面讨论)。

表 14-3:优先队列的操作

操作 签名 描述
创建 → 优先队列 创建一个新的优先队列。
空吗? PQ → boolean 判断一个优先队列(PQ)是否为空。
顶部 PQ → key 给定一个优先队列(PQ),返回其顶部元素。
添加 PQ × key → PQ 给定一个新键,将其添加到一个优先队列(PQ)中。
移除 PQ → PQ × key 给定一个优先队列(PQ),提取其顶部元素并相应地更新优先队列。

就提供的操作而言,堆符合优先队列(PQ)的要求,因此实现起来很简单。然而,为了多样性,看看其他一些简单的优先队列实现方式,并比较它们的性能:

  • 使用无序数组或列表时,获取顶部元素的时间复杂度是 O(n)。移除它的时间复杂度也是 O(n),因为你需要遍历所有元素来找到它,添加新元素的时间复杂度则是 O(1)。

  • 使用有序数组(最大值位于最后一个位置)时,获取和移除顶部元素的时间复杂度是 O(1),但添加新元素的时间复杂度是 O(n);在找到元素的位置后,使用二分查找的时间复杂度是 O(log n),你还需要物理移动元素以腾出空间,这就是 O(n)。

  • 使用有序列表(最大值位于第一个位置)时,结果与使用有序数组时相同。

  • 使用平衡的二叉搜索树时,三种操作的时间复杂度都是 O(log n)。如果你有一个指向最大值的额外指针,获取顶部元素的时间复杂度变为 O(1),但插入和删除操作会稍微变慢,因为它们需要维护该额外指针。

这个优先队列实现方式的列表并不完整,但足以展示堆是实现优先队列的最佳方式之一,且由于其低复杂度,获得额外的优点。在接下来的章节中,我们将考虑一些额外的操作,这些操作可能需要其他优先队列的实现方式。

堆排序

堆可以用来创建一个表现良好的排序方法。给定一组值,使用堆可以轻松找到集合中的最大值。移除最大值并恢复堆后,你可以找到第二大值,依此类推。基本的算法结构如下:

  1. 将待排序的值构建成一个堆。

  2. 然后,直到没有更多元素,交换堆的顶部元素和最后一个元素,堆的大小减一,并恢复堆的性质。

看看这个算法是如何工作的,然后考虑一些优化。

威廉姆斯的原始堆排序

首先,这是 1964 年由约翰·W·J·威廉姆斯发明的堆排序算法的示例实现。你可以重用之前的 _bubbleUp() 和 _sinkDown() 函数(因此我不会在这里列出它们),新增的部分仅包括以下内容:

function heapsort_original(v) {
❶ for (let i = 1; i < v.length; i++) {
    _bubbleUp(v, i);
  }

❷ for (let i = v.length - 1; i > 0; i--) {
  ❸ [v[i], v[0]] = [v[0], v[i]];
  ❹ _sinkDown(v, 0, i);
  }

  return v;
}

堆排序的第一阶段从数组的开始到结束,将每个元素“冒泡”到正确的位置 ❶。第二阶段 ❷ 将堆的顶部元素与(当前)堆的最后一个元素交换 ❸,并使用 _sinkDown() 的第二个参数限制它可以下沉的深度 ❹。

下面是算法的执行过程。构建阶段按照图 14-13 所示的步骤进行。高亮区域对应正在构建的堆,其他部分是尚未添加到堆中的值。

图 14-13:堆排序的第一阶段是逐个构建堆(高亮区域)。

在算法的每一步中,一个新的值被添加到堆中,并根据需要上浮,直到堆中元素的数量增加了一。构建阶段完成后,算法的第二部分开始。堆顶元素与堆的最后一个值交换,堆的大小减一,新的堆顶元素下沉以恢复堆的性质,详见图 14-14。

图 14-14:堆排序的第二阶段是不断移除堆顶元素,以构建有序数组。

在第一步中,堆顶值(60)与堆的最后一个值(11)交换。11 下沉,56 上浮到堆顶。在接下来的步骤中,56 与最后一个值(再次是 11)交换,11 下沉,40 上浮到堆顶。这个过程一步步进行,当堆的大小为 1 时,数组就已排序。

堆排序分析

堆排序的时间复杂度是多少?不深入数学细节,如果你要排序n个元素,你需要调用 _bubbleUp()函数n次,每次一个元素可能会浮到堆顶,而堆的高度是 log n,因此时间复杂度为O(n log n)。类似地,在从堆中移除元素以产生有序数组时,需要调用 _sinkDown() n次,元素可能会下沉到底部,因此也是O(n log n);最终得出的结论是该算法的时间复杂度是O(n log n)。

一个有趣的特性是,这种行为是有保障的。没有任何数据集会导致像快速排序那样的最坏情况(快速排序可能会变成O(n^²))。此外,由于我们已经确定O(n log n)是基于比较的排序算法中最优的时间复杂度,因此可以看出,堆排序是一种稳定的算法,具有一致的性能,通常用于库函数和其他可能需要排序的算法中。

最后,堆排序并不是第六章中所示意义上的稳定排序(具体例子见问题 14.12)。

Floyd 的堆构建优化

Williams 版的算法非常高效,但借助 Robert Floyd 的改进,堆构建部分的时间复杂度被优化为 O(n),尽管我们这里不会深入探讨数学原理。这个结果的原因在于大多数元素都在底部,因此将它们下沉比将它们上浮要快得多。很少有元素接近顶部,那里的下沉速度比上浮慢,这些因素足以改变堆构建的顺序。由于过程的第二部分仍然是O(n log n),算法的总时间复杂度不会改变,但无论如何,它会运行得更快。

这个算法不是让每个元素上浮到它的位置,而是构建小的堆,然后通过将它们合并成更大的堆,直到最终得到完整的堆。最初,你可以将树的所有叶子视为大小为 1 的小堆。然后,取两颗叶子和它们的父节点,并重新组织它们(如果需要的话),使这三个值形成一个堆。不断重复这一过程,最终你会到达堆的顶部并完成堆的构建。

首先查看代码,然后再看一个示例。这种新的堆排序版本的代码将依赖于之前的 sinkDown() 代码,后者将保持不变。其余部分的算法如下:

function heapsort_enhanced(v) {
 **for (let i = Math.floor((v.length - 1) / 2); i >= 0; i--) {**
 **_sinkDown(v, i, v.length);**
 **}**

 for (let i = v.length - 1; i > 0; i--) {
    [v[i], v[0]] = [v[0], v[i]];
    _sinkDown(v, 0, i);
  }
  return v;
}

算法第二部分(交换和重构)的代码是相同的;唯一的区别在于你是通过 sinkDown() 来构建堆的。图 14-15 展示了这段代码的堆构建部分,具体来说,就是数组的更多部分如何逐步成为堆。在每一步中,成为“迷你堆”的数组部分都会被突出显示。

图 14-15:增强版的堆构建算法通过较小的堆逐步构建出完整的堆。

为了更好地理解图 14-15,请查看不同阶段的堆。最初,数组看起来像图 14-16,显然还不是堆。

图 14-16:一个初始数组,未满足堆的性质

经过两步,构建了来自 11 和 24 的子堆(这两个保持不变),以及 12、34 和 56(其中 34 下沉,56 替代了它的位置)。

图 14-17 展示了另外两步。

图 14-17:经过几次旋转,构建了几个子堆。

堆几乎完成,根节点为 56(22 的值下沉,56 取代了它的位置),另一个根为 60(无需任何变化)。你只差一步就可以完成,如图 14-18 所示。

图 14-18:越来越多的子堆被构建出来,逐渐达到顶部。

最后一步完成堆的构建;9 的值下沉到它的位置,被 60 替代。图 14-19 展示了完成后的堆。

图 14-19:到达顶部时,数组已变成堆。

Floyd 的增强算法有两个优点:第一阶段更快(第二阶段生成排序结果相同),并且代码更简短。你可以利用这种方法改进堆逻辑并修改 newHeap()函数(见问题 14.8)。 ### Treaps

在第十二章,我们讨论了二叉搜索树及几种保持其平衡以避免搜索缓慢的方法。在 1989 年,发明了一种新结构,混合了树和堆的特性:treaps。这些树是平衡的,尽管它们的高度不一定是O(log n);相反,随机化和堆属性被用来以较高的概率保持平衡。

Treap(发明的术语)是treeheap两个词的混合词。这种混合是怎么来的呢?基本上,每个键都关联一个随机优先级,当你构建二叉搜索树时,需要确保满足堆属性,因此父节点的优先级总是大于其子节点的优先级。(这种结构属性不一定满足;节点是通过指针链接的,而不是数组。)注意,除了使用随机数生成器外,你还可以对键应用哈希函数,从而生成其“随机”优先级。对 treaps 的数学分析依赖于真正的随机数,但哈希生成的随机性也有效。在测试算法方面,哈希具有确定性的优势。

让我们再深入思考一下。如果你恰好按照优先级排序键并按优先级递减的顺序插入树中,生成的树将满足堆属性。(你能看出为什么吗?)这意味着分配随机优先级等同于在插入树之前对键进行随机排列,这将以概率的方式为生成的树提供良好的形状,期望高度为O(log n),与平衡树相同。

给定一组不同的键及其对应的(也是不同的)优先级,生成的 treap 是唯一的,我们可以为此构造递归证明。首先,treap 的根节点必须是优先级最高的键。然后,所有较小的键将进入根节点的左子树,较大的键将进入右子树,我们可以递归地应用相同的推理来证明这两个子树也是唯一的。

你也可以修改第十二章中的二叉搜索树算法来创建 treaps。与 AVL 树或红黑树的代码相比,这种代码更简洁,但却能提供竞争力的性能,通常优于那些更复杂的替代方案。

创建与搜索 Treap

既然 treaps 本质上只是二叉搜索树,那么在第十二章中讨论的大多数代码仍然适用。开始编写 treaps 的代码如下:

const {
  find,
  inOrder,
  isEmpty,
  maxKey,
  minKey,
  postOrder,
  preOrder
❶} = require("../binary_trees/binary_search_tree.func.js");

const newTreap = () => null;

const newNode = (key) => ({
  key,
  left: null,
  right: null,
❷ **priority: Math.random()**
});

Treap 基于之前的二叉搜索树 ❶,并且许多在那里的函数依然有效。在创建新节点时,添加一个随机的优先级 ❷,但这里的变化就只有这些。

注意

如果你想测试你的代码并且需要确定性的结果,可以将优先级计算为键的哈希值;只要结果足够随机,这样做就行。

接下来我们要进行的是添加一个新键,这需要一些额外的编码。

向 Treap 中添加键

向 treap 中插入节点基本上与二叉搜索树插入的逻辑相同,唯一的区别是在将节点插入到适当位置后,可能需要进行旋转以保持堆的性质。向 treap 中添加新键可以通过前面章节介绍的 _rotate() 方法来完成。基本思路是首先根据值将新节点插入到树中,然后根据需要进行旋转,既保持二叉搜索树条件,又满足堆条件。图 14-20 展示了基本的旋转操作。

图 14-20:旋转操作也使得二叉搜索树变成了堆。

减号表示比加号对应的键值更小。如果放置在下方的节点(减号)比其父节点(加号)优先级更高,如图左侧所示,可以执行 右旋转 操作,从而得到图右侧的情况,这样就满足了堆的性质。相反,如果有右侧的情况,且下方节点(加号)的优先级高于其父节点(减号),则可以执行 左旋转 来获得左侧的情况。在这两种情况下,最终得到的树依然是二叉搜索树,但节点的位置发生了变化,使得最终的父节点优先级大于其子节点。

下面是 add() 算法如何工作的示例。我们从 图 14-21 中显示的 treap 开始,节点的右侧显示了优先级。

图 14-21:在 treap 中,键值形成二叉搜索树,优先级形成堆。

如果你想插入一个优先级为 0.8 的 12 节点,第一步是按照二叉搜索树的标准方法插入新节点,而不考虑优先级和堆的性质,这些会稍后处理。这个插入(按照 第十二章 中描述的标准方法进行)会导致如 图 14-22 所示的树。

图 14-22:在标准插入操作后,得到的二叉搜索树可能不再是堆。

Treap 支持搜索操作,但由于 12 节点的优先级高于其父节点的优先级,因此堆的性质没有得到满足。你可以通过执行左旋转来解决这个问题,从而得到 图 14-23 所示的树。

图 14-23:旋转操作会一直应用,直到满足堆的性质,但这个树仍然是错误的。

旋转仍然保持有效的二叉搜索树,但过程尚未结束,因为堆的性质还没有完全满足。12 节点的优先级高于其父节点;进行一次右旋操作以解决这个问题,这样就得到了图中所示的树图 14-24。

图 14-24:现在堆的性质已经满足。

在第二次旋转后,你可以检查堆的性质是否已经满足,因此添加到 Treap 中的操作是正确的。

以下是最终的代码,其中有一行与普通的二叉搜索树不同:

const add = (tree, keyToAdd) => {
  if (isEmpty(tree)) {
    return newNode(keyToAdd);
  } else {
    const side = keyToAdd <= tree.key ? "left" : "right";
    tree[side] = add(tree[side], keyToAdd);
    **return tree[side].priority <= tree.priority ? tree : _rotate(tree, side);**
  }
};

粗体的那一行确保堆的性质得以满足。在树的[侧边]添加新键后,如果该子树的优先级不大于根节点的优先级,你就完成了;否则,应用该侧的旋转将优先级更高的元素提到上面。

最后需要的方法是从 Treap 中删除一个键。

从 Treap 中删除一个键

从二叉搜索树中删除一个键的算法包括先找到该键,可能需要找到它的后继节点,并将后继节点放到被删除节点的位置。对于 Treap,由于你必须保持堆的性质,这个过程稍微复杂一些,但就像插入操作一样,你可以使用旋转来确保结果是正确的。要删除一个节点,使用与之前二叉搜索树不同的逻辑:

  • 如果你在空树中查找要删除的键,什么都不需要做。

  • 如果要删除的键小于根节点的键,从根节点的左子节点中删除该键。

  • 否则,如果要删除的键大于根节点的键,从根节点的右子节点中删除该键。

  • 否则,如果该键既没有左子节点也没有右子节点,直接删除它。

  • 否则,如果它有右子节点但没有左子节点,将其设置为右子节点。

  • 否则,如果它有左子节点但没有右子节点,将其设置为左子节点。

  • 最后,如果它有左子节点和右子节点,应用一次旋转将该键移到树的更低位置,并尝试再次删除它。

最后一步可能会让人感到惊讶,它与二叉搜索树的处理方式完全不同。通过前面在插入 Treap 时展示的旋转操作,可以旋转一个节点与它的某个子节点,而旋转后的节点会在 Treap 中较低的位置。如果你仔细选择使用哪种旋转方式,你仍然可以保持堆的性质;因此,如果在旋转之前 Treap 是有效的,旋转后它仍然会保持有效。最后,随着要删除的键逐渐下移,它不能总是保有两个子节点。在某个时刻,它只会有一个或没有子节点,然后你可以快速完成删除操作。

考虑一个更复杂的情况。从图 14-25 所示的 treap 开始,删除 9 节点(如在“向 treap 中添加键”一节第 333 页所示,优先级显示在节点的右侧)。

图 14-25:初始的 treap,包含一个待删除节点

找到节点后,恰好它有两个子节点,所以需要进行旋转。9 的右子节点优先级更高,因此执行左旋,得到如图 14-26 所示的中间状态(注意,此时堆性质尚未满足,但在你删除 9 节点后就会满足)。

图 14-26:左旋将要删除的节点向下移动。

9 节点再次有两个子节点,因此进行新的旋转。这次,优先级更高的是左子节点,因此可以进行右旋,得到新的状态(见图 14-27)。

图 14-27:新的旋转将要删除的节点进一步移至 treap 下方。

现在你已经遇到了一个简单的情况,因为 9 节点只有一个子节点,这允许你删除它,最终得到如图 14-28 所示的 treap。

图 14-28:删除目标节点后的最终 treap

删除键的代码如下(请注意,该实现严格按照前面列出的步骤进行):

const remove = (tree, keyToRemove) => {
  if (isEmpty(tree)) {
    // nothing to do
  } else if (keyToRemove < tree.key) {
    tree.left = remove(tree.left, keyToRemove);
  } else if (keyToRemove > tree.key) {
    tree.right = remove(tree.right, keyToRemove);
  } else if (isEmpty(tree.left) && isEmpty(tree.right)) {
    tree = null;
  } else if (isEmpty(tree.left)) {
    tree = tree.right;
  } else if (isEmpty(tree.right)) {
    tree = tree.left;
❶} else {
❷ **const [side, other] =**
 **tree.left.priority < tree.right.priority**
 **? ["right", "left"]**
 **: ["left", "right"];**
❸ **tree = _rotate(tree, side);**
❹ **tree[other] = remove(tree[other], keyToRemove);**
  }
  return tree;
};

代码与常见的二叉搜索树相同,不同之处在于当找到一个有两个子节点的键时❶,你需要决定进行哪种旋转❷,然后执行旋转❸,接着递归地向下尝试删除该键❹。如果执行了左旋,原根节点(即你想删除的键所在节点)将被移到左子树,因此删除过程将在左子树中继续。如果执行右旋,删除过程将在右子树中继续。

你现在已经使用堆来扩展二叉搜索树。让我们看看这种变化的结果。

考虑 treap 的性能

如前所述,treap 的预期高度是O(log n),这意味着添加、删除和查找键的预期时间复杂度都是这个级别。然而,优先级的随机化并不能确保不会出现坏情况,实际上,最坏的情况与二叉搜索树相同:树的深度为O(n),并会影响算法的性能。

与常见的二叉搜索树的主要区别在于,实际上,获取一个“坏”的数据序列并不出乎意料,常常会导致糟糕的树。然而,在 treap 中,由于随机优先级的存在,无论原始数据的顺序如何,构建一个“坏”的 treap 的概率非常低。实际上,要得到一个平衡差的 treap,优先级必须与键值相关联,而这种情况在随机数中非常不可能发生。因此,算法的平均性能将独立于键值插入的顺序(见表 14-4)。

表 14-4:Treap 操作性能

操作 平均性能 最坏情况
创建 O(1) O(1)
添加 O(log n) O(n)
删除 O(log n) O(n)
查找 O(log n) O(n)
遍历 O(n) O(n)

Treap 的关键在于,随机化使得实现一定平衡的可能性非常高,从而提供了高性能。(这与随机化二叉搜索树的论点相同。)此外,treap 允许你实现其他方法,例如将一个 treap 分成两个或将两个 treap 重新合并为一个。我们在这里不会直接讨论这些方法,但请参阅本章末尾的问题 14.15 和 14.16。

三叉堆和 D 叉堆

如果二叉堆是优先队列的一个良好结构,那么逻辑上的推广就是,正如 B 树那样,每个层级拥有更多的子节点会使树更短,算法更快,例如三叉(也称为三元)堆,其中每个节点有三个子节点,或者四叉堆有四个子节点,一般的d 叉堆每个节点有d个子节点。

基本上,所有的差异都在于 _bubbleUp()和 _sinkDown()方法中,这些方法现在必须处理超过两个子节点,如下所示:

const {newHeap, isEmpty, top} = require("./heap.func.js");

❶ **const ORDER = 3;** // with ORDER===2, we get classic heaps

const _bubbleUp = (heap, i) => {
  if (i > 0) {
  ❷ **const p = Math.floor((i - 1) / ORDER);**
    if (heap[i] > heap[p]) {
      [heap[p], heap[i]] = [heap[i], heap[p]];
      _bubbleUp(heap, p);
    }
  }
};

const _sinkDown = (heap, i, h) => {
❸ **const first = ORDER * i + 1;**
❹ **const last = first + ORDER;**
  let g = i;
❺ **for (let j = first; j < last && j < h; j++)** {
    if (heap[j] > heap[g]) {
      g = j;
    }
  }
  if (g !== i) {
    [heap[g], heap[i]] = [heap[i], heap[g]];
    _sinkDown(heap, g, h);
  }
};

const add = (heap, keyToAdd) => {...exactly as before...}

const remove = (heap) => {...exactly as before...}

看一下代码中的变化。我们添加了一个 ORDER 变量(这里设置为 3),用于存储新堆的阶数❶。计算节点的父节点需要使用修正的公式;与二叉堆不同,不是除以 2,而是除以堆的阶数❷。然后,做相同的更改(将堆的阶数替换为 2)来查找元素的子节点❸❹。由于一个节点可能有多个子节点,因此需要循环遍历它们,找到最大的一个❺。

如果你创建一个新的堆(在此为三叉堆),并按顺序添加值 22、9、60、34、24、40、11、12、56、4 和 58,你将得到图 14-29 中的堆(同时以树形和数组形式显示)。

图 14-29:在三叉堆中,实现方式与二叉堆相似。

那么 d 叉堆的一般顺序如何呢?由于树的高度始终是 O(log n),因此所有操作的时间复杂度是相同的。然而,一些操作的性能可能更好或更差。例如,向上冒泡变得更快(因为树更平坦),但向下沉降会变得更慢(因为你必须在更多的值中找到最大的一个)。

总结

本章介绍了一种新的数据结构——堆,有多种变体:二叉堆和 d 叉堆,以及最小堆和最大堆。我们看到了堆如何用于实现一种新的抽象数据类型:优先队列。堆的另一种用法是作为一个具有良好常数性能的排序算法。最后,我们应用堆的概念创建了一个随机化的二叉搜索树:treap。在下一章中,我们将继续探索相关概念,并考虑一些堆的变体,以支持新的操作。

问题

14.1  它是一个堆吗?

给定一个数组,写一个函数返回该数组是否为最大堆。你不需要构建堆,只需回答它是否已经是堆。

14.2  用队列凑合一下

假设你只能使用优先队列,而不能使用栈或队列。你如何使用优先队列来模拟这两种抽象数据类型(ADT)?(提示:由于栈和队列没有优先级字段,你可以为它们分配任意值。)

14.3  从最大堆到最小堆

假设你有一个最大堆;你能否在O(n) 时间内将其转换为最小堆?

14.4  最大或最小

为了将最大堆转换为最小堆,你需要对最大堆做哪些修改?

14.5  合并吧!

假设你有几个有序的列表,并希望将它们合并为一个单一的列表。使用最小堆实现这个算法,在每一步决定选择哪个节点。

14.6  搜索堆

尽管这没有太大意义(因为堆本身并不是为此结构化的),你如何实现一个 find() 函数来在堆中查找一个值呢?

14.7  从堆的中间删除元素

在堆中,你总是移除顶部的值,如果你想移除堆的最后一个值,这很简单,但是你能写一个算法来移除堆中的任何元素吗?

14.8  更快的构建

Floyd 的优化方法以 O(n) 时间构建堆。修改 newHeap(),使其在给定一个值数组时,使用 Floyd 的方法初始化堆。

14.9  另一种循环方式

heapsort_original 函数中,你本可以很容易地使用 forEach() 来构建堆;你能看到怎么做吗?

14.10  额外的循环?

heapsort_enhanced 函数中,如果在构建堆时执行一个完整的循环,会发生什么?更具体地说,如果那段代码写成如下形式会怎样:

for (let i = **v.length - 1**; i >= 0; i--) {
  sinkDown(i, v.length);
}

14.11  最大平等性

如果你用堆排序来排序一个填满相同值的数组,它的时间复杂度是多少?

14.12  不稳定的堆?

堆排序不是稳定的,尝试排序一个短数组就足以验证这一点。你能提供一个这样的例子并展示其不稳定性吗?提示:你不需要一个很大的数组。

14.13 修剪选择

你可以使用堆从 n 中选择出 k 个最大值,通过将堆顶移除 k 次。然而,如果 k << n,你可能会稍微加速一些。证明这 k 个最大值一定出现在第 1 层(根节点)到第 k 层(但不超过此层),并利用这一发现,在进行选择前修剪堆。

14.14 它是一个 Treap 吗?

给定一个二叉树,其中每个节点有键和值域,你能写一个函数来检查该树是否实际上是一个 treap 吗?

14.15 拆分 Treap

给定一个 treap 和一个限制值,将其分成两个独立的 treap:一个包含所有小于限制值的键,另一个包含所有大于限制值的键。假设限制值不在 treap 中。

14.16 重新连接两个 Treap

考虑第 14.15 问题的逆问题:假设你有两个独立的 treap,第一个 treap 中的所有键都小于第二个 treap 中的所有键。你能找到一种方法将这两个 treap 合并为一个吗?

14.17 从 Treap 中移除

如果在 treap 的 remove() 方法中你更改了这一行

tree[other] = remove(tree[other], keyToRemove);

改为

tree = remove(tree, keyToRemove);

它仍然会起作用吗?参考代码如下(更改的行以粗体显示):

const remove = (tree, keyToRemove) => {
  if (isEmpty(tree)) {
    // nothing to do
  } else if (keyToRemove < tree.key) {
    tree.left = remove(tree.left, keyToRemove);
  } else if (keyToRemove > tree.key) {
    tree.right = remove(tree.right, keyToRemove);
  } else if (isEmpty(tree.left) && isEmpty(tree.right)) {
    tree = null;
 } else if (isEmpty(tree.left)) {
    tree = tree.right;
  } else if (isEmpty(tree.right)) {
    tree = tree.left;
  } else {
    const [side, other] =
      tree.left.priority < tree.right.priority
        ? ["right", "left"]
        : ["left", "right"];
    tree = _rotate(tree, side);
    **tree = remove(tree, keyToRemove);**
  }

  return tree;
};

14.18 树作为堆

如果你使用二叉搜索树来表示堆,会发生什么?三个基本操作:add()、remove() 和 top() 的性能会如何?你能想到哪些方法来加速 top() 操作?

第十五章:15 扩展堆

本章中,我们将探索一些允许对堆进行额外操作的新数据结构。例如,我们将能够通过增大或减小键值来改变或调整键的值,或通过合并或融合两个或更多堆来产生一个新的堆。这些新结构基于我们在前几章中讨论的几个概念,如常见堆、链表(双向链表)、森林等。

首先,我们将考虑偏斜堆,这是一种作为二叉树实现的堆,以及二项堆,它是一种基于森林的新堆实现,可以让你快速合并堆。我们还将研究堆的增强版本——懒二项堆,它提供更好的摊销性能,最后我们会介绍斐波那契堆,它允许你在(摊销的)常数时间内改变(增加或减少)一个键值,和配对堆,它提供了一种更简单的替代方案,且性能出奇地相似。

可合并和可寻址的优先队列

首先回顾一下你用二项堆实现的优先队列抽象数据类型(ADT)。该 ADT 需要三个操作:add()将新值添加到队列,remove()从队列中取出最高优先级的元素,top()显示当前队列顶部元素的优先级。使用将堆表示为数组的二项堆实现时,前两个操作的时间复杂度是O(log n),第三个操作的时间复杂度是O(1)。额外的 change()操作会改变队列中某个元素的优先级,它的时间复杂度也是O(log n)。

如果这些三(或四)个操作就是你所需要的,那么你将拥有一个足够好的实现。使用数组且没有指针通常非常快速,提供的性能很难被超越。然而,如果你需要特定的增强时间(例如,常数时间内添加新值)或额外的操作(比如能够将两个队列合并为一个),你将需要其他的解决方案。本章讨论的每种结构都允许我们将两个独立的优先队列合并为一个。这称为可合并优先队列(MPQ)

有多个算法需要能够合并优先队列,因此允许这一操作可以增强它们的性能。想象一下,你正在为系统中的打印机实现一个优先队列。如果某台打印机出现故障,你希望能够将所有打印任务重新分配给另一台。一个可合并堆将为这种重新分配提供最快的性能。

你可能还想包括第二个操作:更改一个键值。这个更改是一个特定的操作,其中旧的键值被替换为一个新的键值,这个新值应该位于堆的更高位置——例如,在多个图算法中,最小堆中较小的值。事实上,当处理最小堆时,这个操作通常叫做 decreaseKey()。由于你同时处理最大堆和最小堆,因此你将使用 changeKey()这个名字,但会检查确保新值应该靠近堆的顶部。

为此,你需要一个插入值的引用,您将使用 add()操作来完成这一任务。提供此类操作的堆称为可寻址堆。本章的主要内容是可合并堆,但所有这些结构都将是可寻址的,因此我们将一起考虑这两个新操作。

注意

我们在第十四章中考虑了最大堆,其最高值位于顶部。由于扩展堆通常使用最小值位于顶部,我们将使用函数goesHigher(a,b)来确定一个值a是否应该比一个值b更高。对于最大堆,您有a > b(因此更大的值位于顶部),而对于最小堆,您有a < b。只需在此新函数的定义中更改一行代码,即可根据需要提供最大堆或最小堆。本章中的所有示例都使用最大堆。

表 15-1 显示了按可合并优先队列 ADT 的操作,首先是创建优先队列,修改添加操作,然后包括合并和更改。

表 15-1:可合并优先队列上的操作

操作 签名 描述
创建 → MPQ 创建一个新的 MPQ。
空吗? MPQ → 布尔值 确定 MPQ 是否为空。
添加 MPQ × 键 → MPQ × 节点 给定一个新键,将其添加到 MPQ 并提供对新节点的引用。
顶部 MPQ → 键 给定一个 MPQ,获取其顶部值。
删除 MPQ → MPQ × 键 给定一个 MPQ,提取其顶部值并相应更新 MPQ。
更改 MPQ × 节点 × 键 → MPQ 给定一个 MPQ、它的一个节点和一个新的键值,将节点的键值更改为新值并更新 MPQ。
合并 MPQ1 × MPQ2 → MPQ 给定两个不同的 MPQ,将它们合并为一个 MPQ。

现在我们转向另一种堆的变体。

倾斜堆

在第十四章中,我们用数组表示了二叉堆,这样做的优点是最大程度的简化。然而,采用这种表示方式时,当合并两个大小为 mn 的堆时,使用 Floyd 算法能达到的最佳性能是 O(m + n)。(作为替代方案,可以考虑选择一个堆并将其他堆的所有值添加到其中,这样做并不是最优的;请参见问题 15.1。)偏斜堆 基于将堆表示为自调整二叉树,提供了更好的(尽管是平摊的)性能。在后续章节中,我们将介绍更加快速的数据结构,但这些结构会增加一定的复杂度。

注意

偏斜堆与一种数据结构(在本书中未讨论)叫做左倾堆相关。偏斜堆的优势在于,它们占用更少的空间、运行时间具有竞争力,而且更容易实现。

偏斜堆与之前讨论的二叉堆有一个共同特性:必须满足堆的性质,即根节点必须大于其子节点——换句话说,偏斜堆是一个堆排序的二叉树。(记住,我们在这里处理的是最大堆;对于最小堆,根节点将小于其子节点。)然而,一个重要的区别是,偏斜堆没有结构约束,因此树可以有任何形状,且其高度可能不是对数级的。图 15-1 说明了这一点。它展示了一个有效的堆,但其形状不同于我们在第十四章中看到的。

图 15-1:偏斜堆不像常见堆那样是完整的。

除了堆的性质和没有结构约束外,还有一个特别的细节是,所有的添加和删除操作都需要使用偏斜合并操作,以确保良好的平摊性能。

表示偏斜堆

由于偏斜堆本质上是一个二叉树,因此可以使用与二叉搜索树和其他树结构相同的代码来表示它:

const goesHigher = (a, b) => a > b;

const newSkewHeap = () => null;

const newNode = (key, left = null, right = null) => ({key, left, right});

const isEmpty = (heap) => heap === null;

const top = (heap) => (isEmpty(heap) ? undefined : heap.key);

goesHigher() 函数决定是否使用最大堆(如这里所示)或最小堆(通过将比较改为 a < b)。以下三个函数是二叉搜索树代码的副本,而最终的 top() 非常简单,因为堆的顶部将是树的根节点。

合并两个偏斜堆

合并两个偏斜堆的逻辑如下:

  1. 如果你合并两个空堆,结果是一个空堆。

  2. 如果一个堆为空,而另一个堆非空,结果是非空堆。

  3. 如果两个堆都非空,根节点较大的堆将成为合并后的堆,但它会交换其子节点,然后将其左子树与根节点较小的堆合并。

前两个情况比较清晰,接下来我们来考虑第三种,更有趣的情况。如果从图 15-2 中显示的两个偏斜堆开始,合并后的结果是什么?

图 15-2:两个待合并的偏斜堆

合并后的堆的根应该是 60,因为它是更大的根。(再次提醒,你在使用的是最大堆。)相应树的左子树将与右子树交换位置,(现在的)左子树将与根为 56 的堆合并。递归地,你会比较 56 和 34,所以新的根是 56,依此类推。你能跟上这些步骤,最终得到图 15-3 中显示的结果吗?例如,很容易看出,60 键的原左子节点现在是它的右子节点;56 键也交换了它的子树。

图 15-3:合并两个倾斜堆的结果

交换子树是提供良好摊销性能的原因;如果不交换进行合并,性能会更差。(参见问题 15.2,其中有两种特定操作序列会导致堆结构不佳。)其逻辑如下:

const merge = (heap1, heap2) => {
❶ if (isEmpty(heap2)) {
   return heap1;
❷} else if (isEmpty(heap1)) {
   return heap2;
 } else if (goesHigher(heap1.key, heap2.key)) {
 ❸ [heap1.left, heap1.right] = [merge(heap2, heap1.right), heap1.left];
 return heap1;
 } else {
 ❹ return merge(heap2, heap1);
 }
};

如果第二个堆为空,返回第一个堆❶。(这也涵盖了两个堆都为空的情况。你能看出为什么吗?)否则,如果第一个堆为空❷,返回第二个堆。如果两个堆都不为空且第一个堆的键值最大,按照之前描述的方法生成一个新的堆❸。如果第二个堆的键值最大,只需交换它们进行合并❹。(参见问题 15.3 的替代方法。)

向倾斜堆添加一个键

如果你只知道如何合并堆,那么如何将一个新键添加到堆中?简单地创建一个包含单一值的新堆并将其与现有堆合并。你已经了解了如何合并堆,因此我们直接跳到实际代码:

const add = (heap, keyToAdd) => {
❶ const newHeap = newNode(keyToAdd);
❷ return [merge(heap, newHeap), newHeap];
};

这仅仅是创建一个单节点堆❶并将其合并❷。

从倾斜堆中移除顶部键

从堆中移除顶部键是直接的。当你移除根节点时,剩下的是两个子树,因此你只需要将它们合并成新的堆:

const remove = (heap) => {
❶ if (isEmpty(heap)) {
    throw new Error("Empty heap; cannot remove");
  } else {
  ❷ const topKey = top(heap);
  ❸ return [merge(heap.left, heap.right), topKey];
  }
};

如果堆为空❶,抛出错误。否则,获取顶部键❷并合并左右子树❸。

考虑倾斜堆的性能

在最初考虑倾斜堆并意识到它们的结构可能变得相当糟糕(就像二叉树可能退化为线性形状一样)时,很难相信它们的性能会很好。然而,添加、移除和合并的摊销时间可以证明是O(log n)。 表 15-2 展示了结果,星号表示摊销值。

表 15-2:倾斜堆操作的性能

操作 性能
创建 O(1)
为空? O(1)
添加 O(log n)*
顶部 O(1)
移除 O(log n)*
变更 O(log n)*
合并 O(log n)*

添加了 changeKey()方法,你可以先删除旧的键,再插入新的键;这两个操作都是O(log n)方法。(参见问题 15.4。)

二项堆

偏斜堆具有整体对数时间的摊销性能,但如果你不止使用单棵树,效果可以更好。二项堆基于一片堆的森林,它们不仅性能优秀,而且是一些增强变种的基础,提供了更好的性能。

二项树

从定义开始:k阶的二项树,或BT(k),是一个k叉树,它要么是空的,要么由一个根节点组成,根节点的子节点是阶次从 0、1、2……到 (k – 1)的二项树。图 15-4 展示了前五个二项树,从 BT(0) 到 BT(4),每个树由一个根节点和每个前一个二项树的一个副本组成。

图 15-4:前五个二项树

当观察这些树时,你可能会注意到一些可以被适当地证明的数学性质,但最重要的两个性质是:BT(k)恰好有 2^k 个节点,并且它的高度是 k。它们与满二叉树具有相同的这两个属性。(实际上,高度为 k 的满二叉树有 2^(k+1) – 1 个节点,但差别不大,可以忽略!)

注意

二项树的阶等于其根的度数,因此在此代码中,我们将使用度数而不是阶,因为这有助于我们考虑斐波那契堆,后者不使用阶的概念,而是使用根的度数。

如果你统计每一层有多少个节点,你会注意到另一个解释这个堆名称的属性:对于图 15-4 中的树,结果是 1;1 和 1;1,2,以及 1;1,3,3 和 1;1,4,6,4 和 1;依此类推。这些是二项系数,就像帕斯卡三角形中,每个值是其上方两个值之和,如图 15-5 所示。这个三角形的另一个属性是,每一行数字的和是 2 的幂,这也与 BT(k)的大小属性相匹配。

图 15-5:帕斯卡三角形提供了二项堆每一层的值的数量。

你可以用另一种方式来看这些树。注意到每棵树实际上是由前一个阶次的两棵树构建而成;稍微修改一下图 15-4,可以让这一点更清楚,如图 15-6 所示。

图 15-6:每个二项树实际上由两个更小的前一个二项树组成。

图 15-6 展示了前四个二项树。每一棵树(除了第一棵)都是由前一棵树的两份副本构建而成,并连接到一个新的根节点。如果这些树还满足堆的顺序属性(所有节点都大于或等于其子节点),则它们被称为堆排序的二项树。使用这样的树,你可以轻松表示大小为 2 的幂的堆,但如果需要表示其他大小的堆该怎么办呢?

定义二项堆

二项堆 是一个堆有序的二项树森林。(一些教科书规定这些树必须按升序排列,但我们不应用这个额外的规则。)这将如何让我们表示任何大小的堆呢?二进制数提供了一个简单的答案。

数字 22 的二进制是 10110,这意味着 22 = 16 + 4 + 2;因此,你可以将任何整数表示为 2 的幂的和。以同样的方式,你用一组二项树来表示任何大小的堆,这些树的大小加起来就是你需要的大小。(你不会有两个相同阶的二项树。你能看出为什么吗?)记住另一个特性:二进制系统中表示一个数字 n 需要 log n 位,向上取整(我们稍后会用到这个)。

为了简化,我们用一组树来表示森林(你也可以使用一组链式树,但那种复杂度在这里不需要)。由于二项树是多叉树,你还需要一种方法来表示它们,因此我们将使用二叉树表示法。节点有一个指向第一个子节点的下指针(而不是左指针)和指向下一个兄弟节点的右指针。节点还包括一个指向父节点的上链,以防你想实现 changeKey() 操作。最后,每个节点还包含它的度。

这种表示法是必要的,因为这些算法需要两个操作:将一棵树拆分成几棵较小的树,以及将两棵树合并成一棵,而这些操作通过链表实现非常快捷且简单。例如,将两棵 BT(2) 合并为一棵 BT(3) 只需要改变两个指针(我们将在下一节中讨论这个)。

从几个基本函数开始,然后添加其余部分:

❶ const newBinomialHeap = () => [];

❷ const newNode = (key) => ({
  key,
  right: null,
  down: null,
  up: null,
  degree: 0
});

❸ const isEmpty = (heap) => heap.length === 0;

因此,二项堆是一个数组 ❶,它的每个元素都是一棵二项树。节点具有前面描述的五个属性 ❷。检查堆是否为空只需要测试树数组的长度 ❸。

注:

你可能会对使用函数来处理二项树产生疑问。然而,由于你实际上并不打算用这些树来进行搜索,因此你会在代码中使用简单的记录,包含 key degree 属性,以及 up down* 和* right 指针。记住,二项树的阶数等于其根节点的度;这就是为什么你使用 degree 而不是 order 的原因。为所有根节点提供度数将有助于 Fibonacci 堆。

首先,编写 top() 过程,使用辅助的 _findTop() 函数遍历所有根节点:

❶ const _findTop = (trees) => {
  let top;
  trees.forEach((v, i) => {
    if (top === undefined || goesHigher(v.key, trees[top].key)) {
      top = i;
    }
  });
  return top;
};

❷ const top = (heap) => (isEmpty(heap) ? undefined : heap[_findTop(heap)].key);

_findTop() 函数 ❶ 用于找到根值最大的树;它会遍历树数组,寻找最大键值。使用这种方法,top() 仅仅是检查堆是否为空。如果为空,则返回 undefined;否则,使用 _findTop() 获取堆的顶部值 ❷。

向二项堆添加一个值

当向堆中添加一个值时,从创建一个阶数为 0 的二项树开始,仅包含键值并将其添加到堆中;然而,如果堆中已经存在另一棵相同阶数的二项树,这可能会导致问题。(记住,重复阶数的树是不允许的。)你可以通过合并来解决这个问题。

假设你有两个相同阶数的二项树,如图 15-7 所示,并且想将它们合并成一个。通常情况下,其中一棵树应成为另一棵树的子树。

图 15-7:合并两棵二项堆,其中一棵成为另一棵的子树

然而,这个例子需要进一步考虑,因为你处理的不仅仅是任意的树,而是满足堆条件的树。那么,在这种情况下,你该怎么做呢?假设这两棵树如图 15-8 所示。

图 15-8:待合并的两棵二项堆

这个思路很简单。较大的根成为新的根,较小的根成为它的子节点,如图 15-9 所示。

图 15-9:从图 15-8 合并两棵树的方式

但是图 15-9 并没有展示如何实际操作。通过之前讨论的二进制表示,两个原始的树将会像图 15-10 所示那样。

图 15-10:两棵二项堆的实际二叉树表示

相应地,结果也遵循“下为子,右为兄”的模式,如图 15-11 所示。

图 15-11:二项树合并后的二叉树表示

通过这种方式,你可以始终将两棵 BT(k) 树合并成一棵 BT(k + 1) 树,并且由于二进制表示,这个过程只需要更改两个链接。以下是该操作的代码,其中 low 是较小根的树,high 是较大根的树:

const _mergeA2B = (low, high) => {
❶ low.right = high.down;
❷ low.up = high;
❸ high.down = low;
❹ high.degree++;
  return high;
};

这非常简单。较低的树会将较高树的子节点作为兄弟节点❶,而那个节点则成为父节点❷ ❸,使得新根的度数增加 1 ❹。(你将在本章中多次使用此操作。)

现在你已经掌握了合并两棵树的方法,但可能会遇到一个额外的复杂问题。在这个例子中,如果原始堆中已经有另一棵 BT(3) 树会发生什么?在这种情况下,你需要将原始的 BT(3) 树与新树不断合并,直到得到 BT(4)。当然,这可能会导致出现重复的 BT(4),依此类推。(你将在下一节查看完整的算法。)

假设你已经有一种通过合并将新树添加到堆中的方法,那么 add() 方法将会很简洁:

const add = (heap, keyToAdd) => {
❶ const newHeap = newNode(keyToAdd);
❷ return [merge(heap, [newHeap]), newHeap];
};

你只需要创建一个新的基本 BT(0),其包含要添加的键值❶,这是一个只有一个对象的数组,然后将其与堆❷合并。基本上,这与斜堆使用的技术相同。区别在于合并堆的方式。

合并两个二项堆

从二进制数求和的角度考虑合并两个堆的问题。如果你熟悉 2048 游戏,其中的目标是将方块合并以达到 2048,那么你会很快理解这些例子。

从一个简单的例子开始。假设你有一个包含 22 个元素的二项堆(22 = 2 + 4 + 16),并且想将它与另一个只有一个元素的二项堆合并,就像添加一个新值一样,如图 15-12 所示。

图 15-12:合并两个堆与二进制数关系密切。

从(a)开始——堆由三个大小分别为 2、4 和 16 的二项树组成(对应于 22 的二进制表示)——并将其用数组表示,使用树的顺序作为索引。包含 2 个元素的树位于位置 1;包含 4 个元素的树位于位置 2;包含 16 个元素的树位于位置 4,其他位置保持空白(黑色)。将要添加的树与当前堆中对应的位置匹配,并且该位置为空,所以直接将其移动到合适的位置(这相当于进行 1 + 0 = 1),得到(b)。不再有树需要合并,所以你完成了。

现在考虑一个更复杂的情况,将两个独立的堆合并,得到一个大小为 23 的堆(23 = 1 + 2 + 4 + 16)和一个大小为 5 的堆(5 = 1 + 4),如图 15-13 所示。

图 15-13:逐步完成合并操作

从(a)开始,匹配 1 和 1:从顶部堆中移除 1,并将其与底部堆中的 1 合并,得到 2。然后到达(b),匹配 2 和 2:再次从顶部堆中移除 2,并将其与底部的 2 合并,得到 4。在(c)中,情况重复:你再次从顶部移除并与底部合并。(底部的值不再是升序排列——你先有 8,再有 4——但这不会影响最终结果。)在(d)中,你有一个简单的情况,因为顶部没有 8,所以直接将 8 放置在顶部并从底部移除它。在(e)中,你现在有一个 4,它没有匹配项,所以把它放在顶部并从底部移除,最终到达(f),此时完成,因为没有更多的值需要合并。

现在你已经解决了问题:合并一直进行,直到没有重复顺序的树存在。实现这个方法并不复杂:

const merge = (heap1, heap2) => {
  const merged = [];
❶ heap1.forEach((v) => {
    merged[v.degree] = v;
  });

  let j = 0;
❷ while (j < heap2.length) {
    const i = heap2[j].degree;

  ❸ if (!(i in merged) || merged[i] === null) {
      merged[i] = heap2[j];
      j++;
    } else {
      if (goesHigher(heap2[j].key, merged[i].key)) {
      ❹ heap2[j] = _mergeA2B(merged[i], heap2[j]);
      } else {
      ❺ heap2[j] = _mergeA2B(heap2[j], merged[i]);
      }
    ❻ merged[i] = null;
    }
  }

 ❼ return merged.filter(Boolean);
};

首先,将堆的所有二项树放入合并数组❶中,根据树的阶数将每棵树放在相应的位置。然后,开始处理第二个列表❷中的所有树,按照描述进行“添加”。如果合并数组中没有匹配的树,就将新树放进去❸,并继续合并下一个树。否则,当有匹配时,合并两棵树。如果第二棵树的根较大,处理方式是❹,如果合并树的根较大,处理方式是❺。在这两种情况下,将合并后的树放入第二个数组中,清空合并数组中的位置❻。最后,在处理完第二个数组中的所有树❼后,过滤合并数组,移除空的树。

从二项堆中移除值

从堆中移除顶部值的方法是通过移除二项树的根节点来将树分开,然后将分离出的树与原堆进行合并。假设堆由两棵二项树(大小为 2 和 8)组成,并移除顶部值(60),如图 15-14 所示。

图 15-14:包含两棵二项树的堆

在移除顶部值之后,分离出其子树(大小分别为 4、2 和 1),剩下四棵二项树,如图 15-15 所示。

图 15-15:在移除 60 值后,树变成了多个堆。

接下来,使用之前相同的方法,将这四棵树与一个初始为空的树集合合并。第一步是将两棵 2 大小的树合并在一起,如图 15-16 所示。

图 15-16:合并的第一步

然后,既然你有两个 4 大小(阶数为 2)的树,进行另一次合并,如图 15-17 所示。

图 15-17:第二次合并完成了工作。

完成了!新的二项堆少了一个元素,你通过将树分割成子树并使用之前编写的合并代码轻松地完成了这一操作。remove()方法如下:

const remove = (heap) => {
❶ if (isEmpty(heap)) {
    throw new Error("Empty heap");
  }

❷ const top = _findTop(heap);
❸ const heapTop = heap[top].key;

❹ const newTrees = [];
❺ let bt = heap[top].down;
  while (bt) {
  ❻ newTrees.push(bt);
  ❼ const nextBt = bt.right;
    bt.right = null;
    bt.up = null;
    bt = nextBt;
  }

❽ heap.splice(top, 1);
❾ return [merge(heap, newTrees), heapTop];
};

首先,检查堆是否为空❶。然后找出哪棵树的根最大❷,并获取其值❸,最后将其返回。创建一个新树数组 newTrees❹并设置一个循环❺来将树❻及其兄弟树❼放入其中。拆分原始树后,从堆中移除它❽,并使用 merge()函数❾将新树与原堆中的其他树合并。

在二项堆中更改值

一些面向图的算法经常需要更改堆中已经存在的值。在这种情况下,堆通常包含完整的记录,而不仅仅是优先级,并保持对包含该记录的堆节点的外部引用。(我们将在第十七章中讨论这些图算法。)我们在这里不会深入讨论所有细节,但逻辑保持不变。

注意

最常见的情况是使用最小堆并通过 decreaseKey() 方法降低优先级。如果你想使用最小堆,所需要做的就是改变本章前面描述的 goesHigher() 函数中的比较方向。

我们已经讨论过如何在二叉堆中更改一个值:进行更改后,接下来就是根据与其他值的关系进行冒泡向上或沉降的操作。在本章中,我们只考虑将键值向上冒泡的逻辑,即向堆顶移动,因为这是实际中需要的操作。在图 15-18 中,假设你要将键值 4 更改为 50。

图 15-18:逐步变更键值的示例

更改键值并与父节点比较,除非你已经在根节点,这意味着操作已完成。如果不是根节点,如果更改后的键值较小,则完成;否则,你必须交换节点并继续向上冒泡。从(a)开始,将键值 4 更改为 50,在(b)中比较 50 与 22 并交换节点(实际上是交换指针),进入(c)。新的比较,50 与 40,再次交换,如(d)所示,但现在 50 比它的父节点(60)小,因此操作完成。

注意

值需要冒泡向上的原因是我们在树的节点中包含一个 up 指针。如果你不打算提供 changeKey() 方法,可以删除代码中所有的 up 实例。

这是此方法的实现:

const changeKey = (heap, node, newKey) => {
❶ if (isEmpty(heap)) {
    throw new Error("Heap is empty!");
❷} else if (!goesHigher(newKey, node.key)) {
    throw new Error("New value should go higher than old value");
  } else {
  ❸ node.key = newKey;  
    _bubbleUp(heap, node);
    return heap;
  }
};

首先检查操作是否可能:堆不应为空 ❶,新键值应位于堆的较高位置 ❷。如果一切正常,将节点的键值更改为新值,并调用 _bubbleUp()方法,如果需要,则将其向上爬升。

冒泡向上的代码如下,它是本书中最长的一行代码:

const _bubbleUp = (heap, node) => {
❶ if (node.up && goesHigher(node.key, node.up.key)) {
  ❷ const parent = node.up;
  ❸ [
      node.up,
      node.down,
      node.right,
      node.degree,
      parent.up,
      parent.down,
      parent.right,
      parent.degree
    ] = [
      parent.up,
      parent,
      parent.right,
      parent.degree,
      node,
      node.down,
      node.right,
      node.degree
    ];

  ❹ if (node.up) {
      _bubbleUp(heap, node);
  ❺} else {
      const i = heap.findIndex((v) => v === parent);
      heap[i] = node;
    }
  }
};

首先查看是否需要向上冒泡。如果节点没有父节点,或者有父节点但父节点的键值高于该节点的键值,则不需要任何操作 ❶。如果需要将节点向上交换,获取指向父节点的指针 ❷,并进行我们之前看到的所有指针(和度数)更改(虽然这一行很长 ❸,但概念上是直观的)。最后,如果节点仍然有父节点,则使用递归检查是否还需要继续冒泡 ❹。如果节点没有父节点 ❺(意味着它已到达堆的顶部),你需要修复堆数组中的引用,因此它现在指向新顶部,而不是旧节点的父节点。(我们能否像二叉堆一样交换节点和父节点的键值?这个问题很重要;请参考问题 15.7 了解更多信息。)

考虑二项堆的性能

表 15-3 总结了二项堆的性能;带星号的结果是摊销后的。

表 15-3:二项堆操作性能

操作 性能
创建 O(1)
空? O(1)
添加 O(log n)*
顶部 O(log n) 如上所示;修复后为 O(1)
删除 O(log n)
更改 O(log n)
合并 O(log n)

由于二项堆可能包含最多 log n 个堆,因此获取 top 的时间复杂度是 O(log n)。如果你只做添加而不做删除,那么添加新值的摊销时间复杂度是 O(1)—更多信息请参见问题 15.5—but 我们可以证明,操作序列会使这个结果恶化为 O(log n)。获取 top 值时,按照之前的实现方法,意味着需要查看 log n 个堆,但这个过程可以优化为 O(1);参见问题 15.6。至于其他结果,删除 top 值意味着将堆分离成最多 log n 个子树,执行一个 O(log n) 的操作,然后进行合并,这是另一个 O(log n) 的操作,因此操作的总时间复杂度是 O(log n)。

懒二项堆

二项堆在添加值时可能会遇到性能问题,从 O(1) 变为 O(log n),这意味着你可以通过摊销计算找到一种解决方案,以增强此过程,尽管有可能(不过不常见)需要进行更昂贵的修复。懒二项堆 正是通过这种方式解决了这个问题。

在懒二项堆中,当你进行添加操作时,你不需要关心合并。你只是让堆中树的数量不断增加,因此 add() 是一个简单的操作,运行时间为 O(1)。但是要小心,记得跟踪最大值,因此 top() 也是 O(1)。当你尝试执行 remove() 操作时,可以修复堆结构,但这时你需要处理堆,将其恢复为二项堆的形状。

定义懒二项堆

懒二项堆毕竟还是二项堆,只不过你添加了一个额外的 top 属性来跟踪堆中的最大值。因此,类定义非常简短,因为大多数方法都与二项堆共享:

const goesHigher = (a, b) => a > b;

❶ const newLazyBinomialHeap = () => ({
 **top: undefined,**
 **trees: []**
});

const newNode = (key) => ({
  key,
  right: null,
  down: null,
  up: null,
  degree: 0
});

❷ const isEmpty = (heap) => **heap.trees.length** === 0;

❸ const top = (heap) => (isEmpty(heap) ? undefined : heap.top);

到目前为止,只有两个差异。堆现在是一个包含两个字段的记录:top ❶,它保存堆的最大值,以及树数组,它是各个子堆。一个空堆没有树 ❷,因此可以用来检测堆是否为空。你需要在向堆中添加或删除元素时更新 top。top() 方法 ❸ 非常简短:如果堆为空,返回 undefined;否则,返回堆的 top 值。通过这种实现方式,你不需要遍历整个树数组来查找 top,从而提高了性能,尽管后续维护堆的 top 值可能需要一些额外的工作。

向懒二项堆中添加一个值

懒散二项堆的第一个重要区别是,添加新键时不会进行任何合并。这怎么可能呢?首先,如果你想知道堆的顶部,你可以不依赖任何结构来实现。之前描述的 heap.top 属性可以轻松更新。只要你不断添加,堆每次只增长一棵树,你总是能知道堆的顶部。例如,假设某一时刻,二项堆的结构如图 Figure 15-19 所示;三角形指向当时堆的最大值。

图 15-19:追踪最大值只需要一个简单的属性。

如果你添加三个新值,过程非常快速,因为你唯一需要做的就是添加新树。图 Figure 15-20 显示了几个相同阶的二项树,这在二项堆中是不允许的。再次强调,每次添加后都要更新最大值。在这个例子中,新增的一个键大于之前的最大值,因此堆的顶部发生了变化。

图 15-20:添加值后,属性需要调整。

但是,移除堆顶则是不同的情况,因为此时找到新的 _heapTop 太慢:O(n)。你可以在移除键时,通过合并树来重新构建堆。一些数学推导(我们会跳过)表明,摊销性能仍然很好。

添加值的代码与之前看到的类似,但不同的是,你不需要合并新树,只需直接添加它,不进行其他操作:

const add = (heap, keyToAdd) => {
❶ const newHeap = newNode(keyToAdd);
❷ heap.trees.push(newHeap);

❸ if (heap.top === undefined || goesHigher(keyToAdd, heap.top)) {
    heap.top = keyToAdd;
  }

  return [heap, newHeap];
};

首先,创建一个新二项树,并将新的键 ❶ 推入当前树数组的末尾 ❷。唯一的额外步骤是可能需要更新堆的顶部。如果树数组为空,或者当前的顶部不大于新添加的值 ❸,则重置 heap.top。

从懒散二项堆中移除一个值

如前所述,懒散二项堆的思想是尽可能延迟合并树(因此称为懒散)。当你移除一个键时,首先将所有当前的二项树合并成一个二项堆,然后再进行移除操作。

这样做是因为,尽管在添加值时堆中的树的数量增长较慢,最终可能会很大,但合并后会急剧下降,在许多快速操作和最终慢操作之间的平衡以一个良好的摊销成本结束。

代码如下;注意,与原始二项堆相比,它有一些变化和新增内容:

const remove = (heap) => {
❶ if (isEmpty(heap)) {
    throw new Error("Empty heap");
  }

❷ const heapTop = heap.top;

❸ const top = _findTop(heap.trees);
  let bt = heap.trees[top].down;
❹ while (bt) {
    heap.trees.push(bt);
    const nextBt = bt.right;
    bt.right = null;
    bt.up = null;
    bt = nextBt;
  }

❺ heap.trees.splice(top, 1);
❻ const newHeap = merge(newLazyBinomialHeap(), {
    top: undefined,
    trees: heap.trees
  });

❼ newHeap.top =
    newHeap.trees.length === 0
      ? undefined
      : newHeap.trees[_findTop(newHeap.trees)].key;

❽ return [newHeap, heapTop];
};

首先检查堆是否为空 ❶ 并保存当前堆顶 ❷,以便稍后返回其值 ❽。然后找到哪棵树有堆顶 ❸,并进行循环来分割其子树 ❹,然后将其添加到树的列表中。接下来删除分割的树 ❺,将所有树合并在一起 ❻,并更新 heap.top ❼ 以找到新的堆顶。它与二项堆并没有太大不同。合并所有树的方式非常巧妙:通过将空堆与树的列表合并,你触发了所有必要的合并,减少了树的数量。你能理解它是如何工作的吗?

更改懒二项堆中的值

还有一个最终的方法:如何更改任何键值。代码如下:

const changeKey = (heap, node, newKey) => {
  if (isEmpty(heap)) {
    throw new Error("Heap is empty!");
  } else if (!goesHigher(newKey, node.key)) {
    throw new Error("New value should go higher than old value");
 } else {
    node.key = newKey;
    _bubbleUp(heap, node);

    **heap.top =**
 **heap.trees.length === 0**
 **? undefined**
 **: heap.trees[_findTop(heap.trees)].key;**

    return heap;
  }
};

这段代码与二项堆相同,只是增加了一行来更新 heap.top;你在 remove() 中做过类似的计算。_bubbleUp() 的代码没有变化,因此这里不再重复。

考虑懒二项堆的性能

懒二项堆的性能与二项堆相似,但推迟合并在摊销成本上有积极影响。特别是,添加值在逻辑上更快,因为你实际上什么都不做,正如表 15-4 所示。记住,星号表示摊销结果。

表 15-4:懒二项堆操作的性能

操作 性能
创建 O(1)
空? O(1)
添加 O(1)
堆顶 O(1)
删除 O(log n)*
更改 O(log n)*
合并 O(log n)*

现在你已经获得了非常好的性能(特别是添加新值更快),但当更改值时,你希望得到更好的结果。我们来看看另一种二项堆的变体,它可以实现这种优化。

斐波那契堆

一些图算法使用最小堆,并且经常调用 decreaseKey() 操作,我们将其重命名为 changeKey(),以适应最大堆和最小堆。在这种情况下,能够以比懒二项堆的 O(log n) 性能更快的方式减少键值变得很重要。于是,斐波那契堆应运而生,它与懒二项堆非常相似,但提供了一种更快的算法来更改键值。

注意

迈克尔·弗雷德曼和罗伯特·塔尔扬在他们的论文《斐波那契堆及其在改进的网络优化算法中的应用》中描述了斐波那契堆(此标题中的网络指的是图),但塔尔扬后来提出了一种更简单的替代结构,称为配对堆,我们稍后会学习。

斐波那契堆背后的理念是什么?add()remove() 方法与懒二项堆相同,但在更改键值时才会出现差异。如果键值发生变化并且需要上浮(在最大堆中,如果新值比之前的值大;在最小堆中,如果新值较小),显然你可能需要将其上浮到树的根节点——这就是 O(log n) 的时间复杂度。

不需要进行任何冒泡操作,只需将该节点及其子树分离,并将其作为一个新树添加到堆中——这就是O(1)的复杂度。然而,由于这个过程改变了二项树的预期形状,且过于频繁地执行可能导致堆结构不良,因此需要做出折衷。你不会允许非根节点以这种方式失去超过一个子节点。如果一个节点失去第二个子节点(你可以通过每次节点失去子节点时该节点被标记来知道这一点),你也会将其分离,这可能会导致进一步的分离。与懒惰二项堆一样,当执行删除操作时,你将修复这些问题;这些内容稍后你会看到,但首先考虑如何表示新的堆。

表示斐波那契堆

之前使用的结构——一个树的数组,其中每棵树通过向上、向右和向下的链接以及一个表示子节点数量的度数字段来表示——是可行的,但对于这里所需的操作来说效率不够。当改变一个键并且它冒泡向上时,目标是移除对应的节点及其子树,但你能迅速将它从兄弟节点中解绑吗?如果你将兄弟节点保存在一个单向链表中,那么这个过程就需要遍历一个最大长度为 O(log n) 的链表,这会破坏 O(1) 的目标。因此,就像在第十章中所展示的那样,这里也使用双向链表。但还有更多!当合并树时,你想合并两组兄弟节点,因此将这些链表设为循环链表,这将完成解决方案。

图 15-21 展示了一个小的二项树 BT(3) 以及它在斐波那契堆中添加了所有链接后的样子。

图 15-21:以斐波那契堆风格表示的二项树

显示所有的上下左右链接会使图表变得杂乱,因此从现在开始,我们将移除任何不必要的链接。例如,指向父节点的向上链接将被移除,因为你可以通过图示推断出这些链接。为了清晰起见,我们还将省略箭头和单节点链表的圆形链接,但我们会在做这些更改时指出。

总结一下这些变化,我们在节点中添加了一个左链接(以便我们可以构建循环双向链表)和一个标记布尔字段,用来标记一个失去子节点的节点:

const goesHigher = (a, b) => a > b;

const newFibonacciHeap = () => ({
  top: undefined,
  trees: []
});

const newNode = (key) => ({
  key,
  degree: 0,
 **marked: false,**
  **left: null**,
  right: null,
  down: null,
  up: null
});

const isEmpty = (heap) => heap.trees.length === 0;

const top = (heap) => (isEmpty(heap) ? undefined : heap.top);

唯一的变化是添加了标记字段;其余部分与懒惰二项堆相同。 #### 合并两棵斐波那契树

当我们第一次查看如何合并二项树时,过程相对简单。然而,现在兄弟节点被放置在一个循环双向链表中,你需要做一些修改。假设你想要合并在图 15-22 中显示的树(记住,子树的细节不会受到影响的部分,以及箭头和向上链接,都被隐藏了)。

图 15-22:待合并的两棵斐波那契树

合并树之后,你将得到图 15-23 所示的结果。特别注意那些发生变化的链接;箭头仅用于表示这些链接。

图 15-23:合并树的结果;只需要更改几个指针。

这是新的合并代码:

mergeA2B(low, high) {
❶ if (high.down) {
  ❷ low.right = high.down;
    low.left = high.down.left;
    high.down.left.right = low;
    high.down.left = low;
  }

❸ high.down = low;
❹ low.up = high;
❺ high.degree++;

❻ return high;
}

如果具有较高键值的树没有子节点,逻辑就很简单,因为你只需将较低的树设置为它的子树 ❸ ❹。然而,如果它确实有子节点 ❶,你就需要将较低树的根节点作为新兄弟添加到较高树根节点的子节点中 ❷。(注意四个链接变化。)之后,让较高根指向较低根 ❸,并且反之亦然 ❹。然后,将较高根的度数加 1 ❺,因为它获得了一个新子节点,并返回合并后的树 ❻。

向斐波那契堆添加一个值

向斐波那契堆中添加新值与懒二项堆没有太大区别。唯一的变化是,你需要为将来的合并操作建立兄弟节点的循环链表(最初只有节点本身)。以下代码高亮显示了所需的变化:

const add = (heap, keyToAdd) => {
  const newHeap = newNode(keyToAdd);

 **newHeap.left = newHeap;**
 **newHeap.right = newHeap;**

  heap.trees.push(newHeap);

  if (heap.top === undefined || goesHigher(keyToAdd, heap.top)) {
    heap.top = keyToAdd;
  }

  return [heap, newHeap];
};

正确初始化新节点的左右指针,使它们形成一个单节点的循环链表。(是的,你可以将这两行合并为一个赋值语句;见问题 15.9。)新树的标记会被设置为 false,因为节点还没有失去任何子节点。要验证这一点,请查看 newNode()代码,参见第 368 页的“表示斐波那契堆”一节。

从斐波那契堆中移除一个值

移除值的逻辑与其他二项堆基本相同,只是有一些小的变化。移除堆顶的操作也和懒二项树一样,唯一需要注意的是,当遍历兄弟节点的循环链表时,必须防止出现无限循环。新添加的代码行如下所示:

const remove = (heap) => {
  if (isEmpty(heap)) {
    throw new Error("Empty heap");
  }

  const heapTop = heap.top;

  const top = _findTop(heap.trees);

 let bt = heap.trees[top].down;

 **if (bt && bt.left) {**
❶ **bt.left.right = null;**
 **}**

  while (bt) {
    heap.trees.push(bt);
    const nextBt = bt.right;
❷ **bt.right = bt;**
 **bt.left = bt;**
    bt.up = null;
    bt = nextBt;
  }

  heap.trees.splice(top, 1);
  const newHeap = merge(newFibonacciHeap(), {
    top: undefined,
    trees: heap.trees
  });

  newHeap.top =
    newHeap.trees.length === 0
      ? undefined
      : newHeap.trees[_findTop(newHeap.trees)].key;

  return [newHeap, heapTop];
};

为了避免无限循环,将链表的最右链接设为 null ❶,以确保以下循环能够停止。(在这里,bt 指向循环链表中的一个元素。向右遍历该链表,因此 bt.left 指向应当是最后一个访问的元素。如果清除 bt.left 的右链接,就能确保循环停止。)另一个区别是,当你提取一个兄弟节点时,根节点必须是一个自我循环链接 ❷,因此你需要修正它的左右链接。

在斐波那契堆中改变一个值

处理键值变化的方式是斐波那契堆区别于其他类型堆的地方。与其通过冒泡向上,不如直接将该键从堆中分离。代码与之前看到的类似,但有一个重要的变化(加粗部分):

const changeKey = (heap, node, newKey) => {
  if (isEmpty(heap)) {
    throw new Error("Heap is empty!");
  } else if (!goesHigher(newKey, node.key)) {
    throw new Error("New value should go higher than old value");
  } else {
    node.key = newKey;
 **_separate(heap, node);**
  }
};

当你实际上改变节点的键时,你会分离它而不是向上冒泡。为了说明这一点,请参考本章前面提到的图 15-24 中的堆。

图 15-24:前一个斐波那契堆

假设 9 这个键值变成了 99,如图 15-25 所示。由于它会被上浮,因此只需将其从堆中移除,并标记其父节点(80)。

图 15-25:斐波那契堆在键值从 9 变为 99 后的状态

你唯一需要做的就是将 9(现在是 99)从它的兄弟节点中解除链接。如果现在你想将 60 改为 66,你还需要修改 80 的下指针。你可以让它指向 60 的右兄弟节点,如图 15-26 所示。

图 15-26:树中的另一个变化,60 变成了 66。

现在你需要执行一个额外的步骤。经过这次分离后,如果 80 不是根节点,由于它已经被标记(意味着它已经失去了一个子节点),你还需要对它进行分离,应用和之前完全相同的逻辑。代码如下:

const _separate = (heap, node) => {
❶ node._marked = false;

❷ const parent = node.up;
❸ if (parent) {
  ❹ if (node.right === node) {
      parent.down = null;
    } else {
    ❺ if (parent.down === node) {
        parent.down = node.right;
      }
    ❻ node.left.right = node.right;
      node.right.left = node.left;
    }
  ❼ parent.degree--;

  ❽ node.up = null;
    node.left = node;
    node.right = node;
    heap.trees.push(node);

  ❾ if (parent._marked) {
      _separate(heap, parent);
  ❿} else {  
      parent._marked = true;
    }
  }

  if (goesHigher(node.key, heap.top)) {
    heap.top = node.key;
  }
};

从取消标记要分离的节点开始 ❶。这是节点能够重新变为未标记的唯一方式。然后获取节点的父节点 ❷,如果它没有父节点(意味着节点是根节点),则不做任何操作。否则,如果节点有父节点 ❸,则开始解除链接。如果改变的节点没有兄弟节点 ❹,只需将父节点的下链接设置为 null,就完成了。但如果父节点直接指向正在变化的节点 ❺,你必须将链接改为指向兄弟节点,以便在移除变化节点时不破坏结构。现在你可以确认父节点指向了另一个兄弟节点,就可以轻松地将节点从双向链表中解除 ❻。然后需要将父节点的度数减一 ❼,因为它将失去一个子节点,并在修复链接后推送分离的子树 ❽。最后检查,如果你正在移除的子节点是已标记的(意味着它已经失去了另一个子节点),则递归地将其分离 ❾;否则,只需标记它 ❿,就完成了。

考虑斐波那契堆的性能

二项堆由二项树组成,每棵树的节点数是 2 的幂。改变之前(这时开始修剪树),斐波那契堆中的树是相同大小的,但它们能变得有多小呢?图 15-27 展示了一个已经尽可能移除节点的斐波那契堆;白色节点已被移除,黑色节点被保留。

图 15-27:具有最小节点数的斐波那契树

如何在不引发级联效果的情况下尽可能多地删除树中的节点?或者,如何从先前的树中构建下一棵树,并尽可能多地修剪它们?最差的情况是提升每个节点的最大子树。在这种情况下,单个树至少会有 1、1、2、3、5、8、……个节点。认出这个序列了吗?这个方案中的树至少有与斐波那契数一样多的节点(而不是 2 的幂)。这也有帮助,因为斐波那契数是指数增长的,这意味着算法的性能仍然是对数级的。

表 15-5 总结了斐波那契堆的性能;带星号的值是摊销后的。

表 15-5:斐波那契堆操作的性能

操作 性能
创建 O(1)
空吗? O(1)
添加 O(1)
顶部 O(1)
删除 O(log n)*
改变 O(1)*
合并 O(1)

对于插入操作,你无法做到比 O(1) 更好,但删除操作的时间复杂度可能会比 O(log n) 更优。然而,在这种情况下,你可以通过将所有 n 个值插入堆中,然后按顺序删除它们,来在 O(n) 时间内对一组 n 个值进行排序,但你已经知道,任何依赖于键值对比的排序算法无法以比 O(n log n) 更快的时间运行,因此你无法以更高的速度完成删除操作。

你可以使用一种更简单的结构,配合更简单的算法。我们将在本章考虑的最后一种扩展堆是配对堆,正是做到了这一点。

配对堆

配对堆是一种多元数据结构,满足堆属性。它基本上由一个根节点组成,该根节点包含堆中的最大值,并且有一组有序的子堆,因此你可以根据第十三章中的定义称其为一个果园。从更正式的角度来看,可以说配对堆要么是一个空结构,要么是一个根元素加上一个(可能为空的)配对堆列表。每个单独的堆以“左孩子,右兄弟”风格表示;图 15-28 展示了一个配对堆的示例。

图 15-28:一个配对堆示例

根节点是 60,并且有三个子堆,分别为 22、56 和 12。子堆包含 3、5 和 2 个元素。

定义配对堆

我们不会考虑 changeKey() 操作(但请参见问题 15.4),因此表示方式稍微简单一些。以下是配对堆的基本起始代码:

const goesHigher = (a, b) => a > b;

const newPairingHeap = () => null;

const newNode = (key, down = null, right = null) => ({key, down, right});

const isEmpty = (heap) => heap === null || heap.key === undefined;

const top = (heap) => (isEmpty(heap) ? undefined : heap.key);

它与偏斜堆的代码相同,只是左指针被命名为“down”,并且在 isEmpty()函数中做了一些小改动。

合并两个配对堆

我们如何合并两个堆?如果两个堆中的一个为空,则直接返回另一个堆。否则,如果两个堆都不为空,则具有最大键值的堆将把另一个堆添加(合并)到它的子堆列表中。例如,看看如果你想合并图 15-29 中的前两个子堆会发生什么(这是一个重要的示例,稍后你会再遇到它)。

图 15-29:两个配对堆待合并

新的根应该是 56,因此第一个堆(根为 22 的堆)将成为第二个堆的子堆,生成如图 15-30 所示的配置。

图 15-30:合并结果

你可以很容易地实现这一点:

const merge = (heap1, heap2) => {
❶ if (isEmpty(heap2)) {
   return heap1;
❷} else if (isEmpty(heap1)) {
   return heap2;
❸} else if (goesHigher(heap1.key, heap2.key)) {
   [heap2.right, heap1.down] = [heap1.down, heap2];
   return heap1;
❹} else {
   [heap1.right, heap2.down] = [heap2.down, heap1];
   return heap2;
  }
};

如果一个堆为空 ❶ ❷,合并的结果就是另一个堆。否则,如果第一个堆的键值最大 ❸,则将第二个堆作为其子堆。最后一种情况是相同的 ❹,但是反过来,将第一个堆作为第二个堆的子堆。

向配对堆中添加值

向堆中添加一个新值的方法与斜堆相同。创建一个仅包含新值的新堆,并将其与当前堆合并。你已经看过合并是如何工作的。代码几乎是一行代码:

const add = (heap, keyToAdd) => {
❶ const newHeap = newNode(keyToAdd);
❷ return [merge(heap, newHeap), newHeap];
};

它创建一个新的堆 ❶,其中只有一个值,并将其与当前堆 ❷ 合并。

从配对堆中删除顶部值

从堆中删除顶部值比添加新值要困难,需要更多的合并。基本上,你需要删除堆的根并生成一个子堆列表,然后你会从左到右成对地合并这些子堆,再将合并后的堆列表从右到左合并。

注意

“配对堆”这个名字来源于前面描述的过程,在这个过程中,许多堆总是成对地合并,每次合并两个。

首先,看看如何将多个堆合并在一起。图 15-31 展示了一个包含七个堆(A 到 G)的示例。

图 15-31:合并七个堆,总是每次合并两个

首先合并 A 和 B,再将其与合并 C 到 G 的结果结合起来。第二次合并从合并 C 和 D 开始,然后再合并 E 到 G。第三次合并先合并 E 和 F,然后等待仅合并 G(这显然就是 G),所以你可以完成 E 到 G 的合并,再合并 C 到 G,最后合并 A 到 G。

实现这种从左到右再到从右到左的“跷跷板”其实很简单。它基于递归思想:给定一个堆列表,将列表中的前两个堆合并生成第一个堆,递归地将其余堆合并成第二个堆,最后将两个结果合并在一起:

const _mergeByPairs = (heaps) => {
❶ if (heaps.length === 0) {
   return newPairingHeap();
❷} else if (heaps.length === 1) {
   return heaps[0];
❸} else {
   return merge(merge(heaps[0], heaps[1]), _mergeByPairs(heaps.slice(2)));
 }
};

这段代码展示了两种简单的单行情况。如果没有堆需要合并 ❶,则返回一个空堆;如果只有一个堆需要合并 ❷,则结果就是该堆本身。如果有多个堆 ❸,则先合并前两个堆,然后递归地合并所有其他堆,最后将两个堆合并在一起。

这实际上是之前描述的从左到右再到从右到左的过程的实现,现在重新处理一下之前你看到的七个堆 A 到 G 的案例。用 mbp()表示 mergeByPairs(),用 m()表示 merge(),代码如下:

mbp([A,B,C,D,E,F,G]) =
m(AB, mbp([C,D,E,F,G]) =
m(AB, m(CD, mbp([E,F,G]))) =
m(AB, m(CD, m(EF, mbp([G])))) =
m(AB, m(CD, m(EF, G))) =
m(AB, m(CD, EFG)) =
m(AB, CDEFG) =
**ABCDEFG**

使用这个辅助方法,你现在可以编写 remove()代码:

const remove = (heap) => {
❶ if (isEmpty(heap)) {
    throw new Error("Empty heap; cannot remove");
  } else {
  ❷ const top = heap.key;

  ❸ const children = [];
  ❹ let child = heap.down;
  ❺ while (!isEmpty(child)) {
      const next = child.right;
      child.right = null;
    ❻ children.push(child);
      child = next;
    }

  ❼ return [_mergeByPairs(children), top];
  }
};

如果堆是空的 ❶,抛出一个错误,因为无法继续进行删除操作。否则,获取顶部值 ❷,以便稍后返回 ❼,并继续分离子堆。为子节点初始化一个数组 ❸,并设置一个子节点变量 ❹,以便遍历所有根节点的子节点 ❺。然后,推入每个子堆 ❻,记得将它们从兄弟节点中解绑。将所有子节点推入数组后,按之前描述的方式成对合并它们,并返回删除的顶部值 ❼。

例如,假设你从原始的配对堆开始(参见图 15-32),并且想要删除其根节点。

图 15-32:重新查看原始的配对堆

删除根节点后,剩下三个堆。首先合并前两个堆,即根节点分别为 22 和 56 的堆(你在前一部分中看过),得到图 15-33。

图 15-33:删除根节点后的配对堆

最后一步是将这些堆合并,得到如图 15-34 所示的情况。

图 15-34:将分开的堆再次合并成一个堆

现在让我们考虑另一种操作:更改键值。 #### 在配对堆中更改键值

更改键值的过程基于你已经看到的内容。首先,在原地更改键值,但如果它需要上浮(像二项堆那样),则将其从堆中分离出来(像斐波那契堆那样)。然后将分离出的堆重新合并到原始堆中。你正在复用概念和算法。

例如,从图 15-35 中所示的堆开始。

图 15-35:更改键值前的配对堆

比如说,如果你想更改键值 40,并将其更改为 40 和 56 之间的任何值,你只需更改键值,操作完成即可。但是,如果你将它更改为大于父节点键(56)的任何值,则必须拆分堆并重新合并。这意味着,如果你想将其更改为 78,在更改键值并拆分堆后,你将得到如图 15-36 所示的两个堆。

图 15-36:将 40 更改为 78 会将堆分成两个。

应用我们之前看过的合并函数,图 15-37 展示了最终结果。

图 15-37:合并结果再次形成单一堆

如果你将 40 改为 58 而不是 78,结果会不同(见图 15-38)。你能看出为什么吗?

图 15-38:如果你将 40 改为 58 的替代结果

以下是如何编码这个过程:

const changeKey = (heap, node, newKey) => {
  if (isEmpty(heap)) {
    throw new Error("Heap is empty!");
  } else if (!goesHigher(newKey, node.key)) {
    throw new Error("New value should go higher than old value");
  } else {
  ❶ node.key = newKey;
  ❷ const parent = node.up;
  ❸ if (parent && goesHigher(newKey, parent.key)) {
    ❹ if (parent.down === node) {
        parent.down = node.right;
      } else {
      ❺ let child = parent.down;
      ❻ while (child.right !== node) {
        ❼ child = child.right;
        }
 ❽ child.right = node.right;
      }
    ❾ node.right = null;
    ❿ heap = merge(heap, node);
    }

    return heap;
  }
};

前两个 if() 语句是你之前见过的,用来检查是否可以进行更改。然后,实际更改节点的键值 ❶ 并获取指向其父节点的指针 ❷。如果有父节点且新节点的键应该更大 ❸,那么你需要采取行动;否则,什么都不需要做。如果该节点是其父节点的第一个子节点 ❹,通过更改从父节点指向该节点的链接,分离该节点;否则,如果该节点不是第一个子节点,遍历兄弟列表 ❺ ❻ ❼,直到找到该节点的前一个兄弟节点。然后将该节点从列表中断开 ❽ ❾,最后将分离出的堆合并回原堆 ❿。(类似的过程在第七章中讨论过。)

考虑配对堆的性能

配对堆的性能类似于斐波那契堆,如表 15-6 所示;带星号的值是摊销时间复杂度。

表 15-6:配对堆操作的性能

操作 性能
创建 O(1)
顶部 O(1)
添加 O(1)
顶部 O(1)
移除 O(log n)*
改变 O(log n)?
合并 O(1)

为什么表格中改变值的操作带有问号?目前对于此操作的精确摊销时间复杂度尚无共识。最初的估计认为它是O(1),但后来证明至少是Ω(log log n)。进一步的工作得出O(log n)的估计,但尚未有明确的证明。不管怎样,这看起来比斐波那契堆差,但实际上,尽管存在理论上的缺陷,配对堆的性能被报告为非常优秀,这很可能是由于实现更简单所导致的。

摘要

在上一章中,我们研究了通过数组表示的二叉堆,在本章中,我们完成了堆的概述,探讨了几种通过二叉树、多路树或森林实现的扩展版本,它们可以更高效地进行合并两个堆和修改关键字等操作。这些改进不仅保持了之前堆的功能,还提升了性能和增加了新特性,让我们可以将这些结构用于一些常规堆无法处理的其他问题。本章展示了修改(或者说是混合)结构的最佳示例,这些结构提高了速度和功能,但显然是以更复杂的算法为代价的!

问题

15.1  直观但更差

假设你有两个常见堆,大小分别为mn,你通过以下直观方法实现合并:依次选择一个堆中的所有元素,并将其插入到另一个堆中。那么,这种方法的时间复杂度是什么?

15.2  顺序情况

如果你按升序插入键值,斜堆的形状会是什么样?如果按降序插入又会如何?

15.3  无需递归

在合并两个斜堆时,如果第二个堆的关键字较大,实际上并不需要递归;你能直接进行合并吗?

15.4  需要改变

你将如何为斜堆实现 changeKey()函数?你需要做一些结构上的修改吗?如果需要,应该做哪些修改?

15.5  仅仅是添加

假设你有一个二项堆,里面只有一个 BT(3),即包含八个值。将一个新值加入该节点八次,并计算需要多少次合并。你能得出什么结论关于该操作的摊销成本?

15.6  更快的二项堆顶

你可以用其他堆的技术加速到达二项堆的顶部;你能想出该怎么做吗?

15.7  更容易的上浮?

为什么你不能像写二叉堆时那样实现二项树的 _bubbleUp()方法?(这个原因很容易忽略。)

const _bubbleUp = (heap, node, newKey) => {
  node.key = newKey;
  const parent = node.up;
  if (parent && goesHigher(newKey, parent.key)) {
    node.key = parent.key;
    _bubbleUp(parent, newKey);
  }
};

15.8  堆的查找

即使这看起来没有太大意义,你能为堆实现一个 find()函数吗?注意斐波那契堆,以免由于循环链表而导致无限循环。

15.9  一石二鸟

在斐波那契堆中,通过将相同右值的赋值语句合并,你可以让 add()、remove()和 mergeA2B()方法稍微短一些;你能看出怎么做吗?

第十六章:16 数字搜索树

在前四章中,我们探索了不同类型的树,包括二叉树、普通树、堆等。所有这些树都是基于存储和比较键的。而在本章我们将要考虑的数字搜索树中,我们不会将键与节点关联。相反,节点在树中的位置将决定它所关联的键。换句话说,你不会将键存储在树的某个单一位置;它们会分布在整个结构中,从树的根部开始。叶子节点将标记键的结束位置。

这看起来可能只是改变了我们的工作方式,就像基数排序改变了我们排序的方式一样(见第六章)。在基数排序中,我们不是通过比较键来排序,而是逐字符(或者逐位,对于数字)处理键。在数字搜索树中,我们不是存储和比较键,而是处理树中的路径。

我们将重点关注三种不同的数据结构:trie,它能在与键长度成正比的时间内进行搜索;基数树,它是 trie 的优化版本;以及三元搜索 trie,它是二叉搜索树的扩展。这些结构在我们搜索字符串时特别有效,就像我们在字典中查找单词一样。

Tries 的经典版本

Tries 通常只是用来存储单词,允许用户快速且轻松地进行搜索,用户只需输入几个字母,以这些字母开头的单词就会显示出来。Tries 也用作通用搜索树,存储键和值,然后对键进行搜索以提供相关数据。

注意

Trie 最初的发音与单词 tree 相似,但它也可以发音为 try,以区别于 tree。你可以选择任意一种发音。

将尝试视为有点像旧电话目录那样,目录上有一组按钮,每个字母对应一个按钮。如果你想查找以F开头的名字,你只需按下该按钮,目录就会打开 F 页。类比并不仅限于此。假设目录上有另一组按钮用于名字的第二个字母,按下这个按钮会带你到另一个页面,页面上有一组新的按钮。如果你按照顺序点击所有按钮,你将找到你想要的名字,或者看到一个空白页,意味着名字不在目录中。这个类比可能很难理解(我想知道有多少读者曾经见过这样的电话目录!也许可以考虑自动完成的工作方式?),所以让我们考虑一下 trie 的实际定义。

字典树有一个链接对应每个可能的字符(就像前面电话索引的例子一样,每个名字字母都有一个按钮),但为了简化,我们仅使用 A 到 E 字母,再加上一个表示单词结束的结束符(EOW)字符。我们用 ■ 来表示这个符号。(其他语言,如 C,使用 NULL \0 字符作为 EOW,但方块符号更为显眼。)假设单词是 ACE、AD、BADE、BE、BED 和 BEE。在字典树中,你会看到 ACE■、AD■ 等。这个字典树看起来像 图 16-1 中的示意图,为了清晰起见,我将根节点放在左边,而不是在顶部,这样你可以水平阅读单词。

图 16-1:使用字母 A 到 E 的单词的字典树示例

字典树中的每个节点由一个链接数组组成,每个可能的字母(A–E)加上结束符(EOW)字符都有一个链接。 (你可以说这是一个六叉树;参见 第十三章)图中的空链接有白色背景,实际链接则有灰色背景。每个小框中的单词表示与相应键相关的额外数据或值(我们将在下一节中讨论)。

你可以定义基本函数来创建一个字典树,如下所示:

const EOW = "■";
const ALPHABET = `${EOW}ABCDE`;
const newTrie = () => null;
const newNode = () => ({links: new Array(ALPHABET.length).fill(null)});
const isEmpty = (trie) => !trie; // null or undefined

首先,定义结束符(EOW)字符;你将在本章中使用相同的定义。ALPHABET 常量包括我们接受的所有字符;在这个例子中,你只使用了五个字母,但在实际应用中,你很可能会包括整个字母表,从 A 到 Z。一个新的字典树只是一个空值,而一个新的节点是一个具有链接属性的对象,这个属性是一个数组,每个字符在 ALPHABET 中都有一个空链接。最后,为了识别空字典树,只需检查最后一行是否为“假值”。

你还可以将某些值与每个键关联,如下所示。

在字典树中存储额外的数据

当我们在前面的章节中研究树时,我们只关心存储键和查找键,因为添加额外的数据很简单。我们可以将记录替换成一个有键字段和额外数据字段的记录。如果我们想修改算法以包括额外的字段,变动也很小:搜索会返回额外的数据,而不仅仅是布尔值,添加键时也会在同一个对象中添加额外的字段。

但是在字典树中,键不会存储在单一的位置,而是分布在字典树的各个分支中。对此有一个解决方案,但由于算法中所需的更改不那么简单,我们将处理键加数据的情况。

字典抽象数据类型(ADT)会略微变化,特别是添加和查找操作,如 表 16-1 所示。

表 16-1:字典树操作

操作 签名 描述
创建 → D 创建一个新的字典。
空? D → 布尔值 判断字典是否为空。
添加 D × key × data → D 给定一个新的键和数据,将它们添加到字典中。
移除 D × key → D 给定一个键,从字典中移除它。
查找 D × key → data | null 给定一个键,返回其数据,如果找不到则返回 null。

如同其他章节一样,我们将研究这种抽象数据类型(ADT)结构的性能。现在我们已经定义了字典树的完整结构并学习了如何创建它,让我们来考虑其余所需的功能。

搜索字典树

你怎么查找一个单词?例如,如果你想知道 BED 是否是一个有效的单词,图 16-2 展示了你将要走的路径。

图 16-2:在字典树中成功搜索 BED

对 BED 的搜索从根节点开始。你查看 B 链接,发现它不是 null,所以跟随它到下一层。在那里,你查看 E 链接并再次跟随它。下一步类似:查看 D 链接并跟随它。最后,你到达 EOW,并在对应的链接中找到一个指向某个数据的链接,认为这是一次成功的搜索并返回找到的数据。

失败的搜索看起来不同。例如,如果你想查找单词 DAB,搜索在一开始就会失败,因为没有单词以 D 开头。那么 ACED 呢?这次,你将从 A 链接开始搜索,然后是 C 链接,最后是 E 链接,但你会到达一个没有 D 链接的节点,意味着 ACED 不在字典树中。图 16-3 展示了这个失败的搜索。

图 16-3:在字典树中搜索(失败)ACED

考虑最后一种情况,搜索 BAD。记住,你要添加一个 EOW 字符,因此实际上,你是在尝试查找 BAD■。图 16-4 展示了会发生什么。

图 16-4:搜索 BAD 失败:BADE 在字典树中,但 BAD 不在。

这个搜索从根节点开始,首先跟随 B 链接,然后是 A 链接,最后是 D 链接。BAD 是至少一个单词的前缀,但缺少 EOW 链接,因此 BAD 不会被认为存在于字典树中。

为了实现这个逻辑,首先创建一个辅助的 _find() 函数,实际上执行搜索操作:

const _find = (trie, [first, . . .rest]) => {
❶ const i = ALPHABET.indexOf(first);
❷ if (isEmpty(trie)) {
   return null;
❸} else if (first === EOW) {
   return isEmpty(trie.links[i]) ? null : trie.links[i].data;
❹} else {
   return _find(trie.links[i], rest);
 }
};

i 变量 ❶ 从链接属性中选择正确的值,现在你准备开始搜索。如果字典树为空(可能一开始就为空,或者可能你沿着一个 null 链接向下遍历),显然搜索失败 ❷。如果你到达单词的末尾(由 EOW 字符标记),进行一个简单的测试:如果相应的链接是 null,搜索失败,如之前所述;如果不是,链接指向一个具有数据属性的对象,你返回它 ❸。否则,如果你还没有到达一个 null 链接或 EOW 字符 ❹,沿着正确的链接继续递归搜索。

find() 函数只是调用之前的 _find(),但它在你要查找的字符串末尾添加了需要的 EOW 字符:

const find = (trie, wordToFind) =>
❶ !!wordToFind ? _find(trie, wordToFind + EOW) : null;

检查空单词以查找 ❶,如果为空,则直接返回 null,不做其他操作。

搜索 trie 并不复杂,类似于我们在前几章讨论过的其他树的搜索方式。

向 Trie 中添加一个键

向现有的 trie 中添加新键(及数据)遵循与搜索相同的策略。逐字查找链接,如果链接存在,就跟随它;如果不存在,就添加一个新的空节点。例如,在 Figure 16-5 中的 trie 图示,如果你想添加一个 BAD 键,只需在最后一个节点添加一个指向数据的链接。

Figure 16-5: 向 trie 添加 BAD

trie 中唯一的变化是(到目前为止)空的 EOW 链接现在指向与 BAD 键关联的数据。

另一个例子是,想要添加 ABE,你需要从根节点开始,跟随 A 的链接,但随后需要添加两个新节点,如 Figure 16-6 所示。

Figure 16-6: 向 trie 添加 ABE 需要新节点。

这个添加操作的代码与我们在前几章讨论的其他插入函数类似:

❶ const _add = (trie, [first, . . .rest], data) => {
❷ if (first) {
  ❸ if (isEmpty(trie)) {
      trie = newNode();
    }
 ❹ const i = ALPHABET.indexOf(first);
    if (first === EOW) {
    ❺ trie.links[i] = {data};
    } else {
    ❻ trie.links[i] = _add(trie.links[i], rest, data);
    }
  }
  return trie;
};

一个 _add() 辅助函数 ❶ 实际上执行插入操作。当你还没有到达字符串的结尾(包括已添加的 EOW)❷时,继续前进,按照连续字母的链接进行跟踪。如果找到一个空链接 ❸,则创建一个新节点并继续前进,直到到达 EOW 字符。在每一步,决定跟随哪个链接 ❹,当到达 EOW 时,添加指向额外数据的链接 ❺;否则,递归地继续添加剩余的字符串 ❻。

最后,add() 函数只调用 _add():

const add = (trie, wordToAdd, dataToAdd = wordToAdd) =>
  _add(trie, wordToAdd + EOW, dataToAdd);

这个函数还确保添加了 EOW 字符。

从 Trie 中删除一个键

现在我们来看删除一个键,这比向 Trie 中添加键要复杂一些。首先,尝试查找该键;如果找不到,就结束了,因为没有其他事情可做。如果找到了该键(并且到达了 EOW 字符),只需删除关联的数据,将指针设为 null。如果这样做后当前节点没有指针了,就删除该节点并修正父节点的指针,这可能又会导致父节点成为空节点,因此继续向上遍历,直到根节点,途中可能会删除更多节点。

让我们考虑几个情况。返回到示例 trie,如果你想删除 BE,你只需清除其 EOW 链接,如 Figure 16-7 所示,完成删除。

Figure 16-7: 从 trie 中删除 BE

为了增加难度,在删除 BE 后,如果你想删除 BADE,首先清除 EOW 链接,但此时整个节点仅包含 null 链接,如 Figure 16-8 所示。

Figure 16-8: 删除 BADE 意味着从 trie 中移除几个(现在为空的)节点。

然而,删除该节点(并清除其父节点的 E 链接)会重复相同的情况,因此你还需要删除父节点,然后删除父节点的父节点,直到遇到一个非空节点。最终的情况如图 16-9 所示。

图 16-9:删除 BADE 后的最终字典树

与搜索和添加类似,你需要一个辅助函数来实现这一点:

❶ const _remove = (trie, [first, . . .rest]) => {
❷ if (isEmpty(trie)) {
    // nothing to do
  } else if (!first) {
  ❸ trie = null;
  } else {
  ❹ const i = ALPHABET.indexOf(first);
  ❺ trie.links[i] = _remove(trie.links[i], rest);
  ❻ if (trie.links.every((t) => isEmpty(t))) {
      trie = null;
    }
  }
❼ return trie;
};

_remove() 函数执行实际的删除操作 ❶。你逐一搜索,每当你遇到一个空链接时,就知道操作完成 ❷。如果到达单词的末尾(越过 EOW 字符),将当前链接设为 null ❸。如果你仍在单词中间,决定接下来应该跟随哪个链接 ❹。然后递归地删除剩余的单词 ❺,并做最后检查,看该节点是否完全为空(全部为 null),如果是的话,你也将该节点设为 null ❻。最后,只需返回修改后的字典树 ❼。

你需要的最终代码如下:

const remove = (trie, wordToRemove) => _remove(trie, wordToRemove + EOW);

remove() 函数仅调用 _remove(),并将需要的 EOW 字符添加到你想要删除的字符串中。

考虑字典树的性能

不同操作的性能如何?表 16-2 几乎是本书中最简单的表格,你能看出为什么吗?

表 16-2:字典树操作的性能

操作 性能
创建 O(1)
为空? O(1)
添加 O(k)
删除 O(k)
查找 O(k)

创建字典树并检查它是否为空是 O(1) 的常数操作。并且,根据该结构,如果键长为 k 个字符,所有其他操作最多需要 k 步,这就是具有该键的字典树的最大高度——非常优秀的常数结果!请注意,O(k) 性能在 k 为常数时应该写作 O(1)。我在这里将其写为 O(k),只是为了提醒你需要执行 k 步,但在性能上,任何常数都意味着 O(1)。

然而,尽管这种结构有着非常好的性能,但需要注意的是,这种结构相当浪费空间。所有节点都有许多链接(每个键中尽可能多的字符),导致大量未使用的空间。如果你需要区分大写和小写字符,或处理整个字母表,或存储外语单词,由于每个节点需要的许多链接,节点的空间需求将急剧增加。

这种情况并不理想,因此让我们考虑另一种字典树的方法,旨在实现一个更现代(且更节省空间)的实现,幸运的是,JavaScript 可以轻松实现这一点。

字典树的增强版

再次考虑如何表示字典树。在每个节点中,你需要为字符串中的每个字符创建一个链接,但如果只有少数字符实际上是必需的,就不需要为其余部分浪费空间。实际上,这听起来你需要某种集合,而这正是解决方案。

如果你有大量不同的可能链接,你应该使用第十三章中讨论的一些结构。然而,由于字母表是有限的,你可以使用一个以字符为键、以链接为值的对象。(有关另一种解决方案,请参见问题 16.1。)

使用与前面字典树相同的六个字符串(ACE、AD、BADE、BE、BED 和 BEE),结构如图 16-10 所示。

图 16-10:具有更少链接的增强型基于对象的字典树

主要区别在于现在的节点可以小得多,只包含严格必要的链接,没有其他内容。

定义基于对象的字典树

与其使用一个链接数组,你可以使用对象来定义这种节省空间的新型字典树:

const EOW = "■";
const newTrie = () => null;
**const newNode = () => ({links: {}});**
const isEmpty = (trie) => !trie; // null or undefined

你只需要对之前的字典树做一个简单的修改(以粗体显示):现在一个新节点有一个名为 links 的对象,而不是一个同名的数组。

搜索基于对象的字典树

搜索基于对象的字典树的过程类似于搜索经典的字典树:从根节点开始,逐个跟随与字符串中每个字符对应的链接。不同的是,你不是使用一个包含所有可能字符链接的数组,而是使用一个仅包含实际需要链接的对象。例如,重新访问图 16-11 中的 BED 搜索。

图 16-11:在基于对象的字典树中成功搜索 BED

从 links 对象中的根节点开始,跟随 B 链接。到达下一个节点后,跟随 E 链接,依此类推,直到到达 EOW 链接。然后,你知道键已找到,可以返回相关数据,无论它是什么。

试着重新执行一个失败的搜索,如图 16-12 所示。

图 16-12:在基于对象的字典树中搜索 ACED 失败

如果你搜索 ACED,过程会在跟随到最后一个 E 链接后停止。你到达的节点没有 D 链接,因此无法继续,因为你想要的键不在字典树中。

搜索基于对象的字典树并不真的与搜索原始字典树有所不同;唯一的变化是如何选择要跟随的链接。以下逻辑展示了所需的变化:

const _find = (trie, [first, . . .rest]) => {
  if (isEmpty(trie)) {
    return null;
  } else if (first === EOW) {
 **return isEmpty(trie.links[first]) ? null : trie.links[first].data;**
 **} else {**
 **return _find(trie.links[first], rest);**
  }
};

const find = (trie, wordToFind) =>
  !!wordToFind && _find(trie, wordToFind + EOW);

与原始字典树的搜索代码相比,你只有两个变化(以粗体显示):你可以直接访问所需的链接,而无需首先检查字母表—例如,字母 A 的链接是 links.A,(或者,等价地,links["A"]),无需多做其他操作。find()函数本身与原始字典树中的完全相同。

向基于对象的字典树添加一个键

添加一个键的过程也与原始字典树的算法类似。你需要做的修改与搜索时的修改类似。例如,考虑添加 ABE,如图 16-13 所示。

图 16-13:向基于对象的字典树添加 ABE

像查找时一样开始跟踪链接,当链接不存在时,添加它们。在这个情况下,根节点已经有一个 A 链接,所以你跟踪它。下一个节点没有 B 链接,因此将其添加到现有的(C 和 D)链接中。从那时起,开始向 trie 中添加新节点。

这个过程与原始 trie 相同;以下是新的逻辑:

const _add = (trie, [first, . . .rest], data) => {
  if (first) {
    if (isEmpty(trie)) {
      trie = newNode();
    }
    if (first === EOW) {
 **trie.links[first] = {data};**
    } else {
 **trie.links[first] = _add(trie.links[first], rest, data);**
    }
  }
  return trie;
};

const add = (trie, wordToAdd, dataToAdd = wordToAdd) =>
  _add(trie, wordToAdd + EOW, dataToAdd);

与原始尝试的逻辑唯一区别是,你总是知道针对给定字符应该使用哪个链接,因此你不需要搜索 ALPHABET 数组(以粗体显示)。

从基于对象的 trie 中移除一个键

最后,移除一个键的过程与原始 trie 中的代码相似,但决定节点是否为空需要不同的方法。例如,如果你想从原始 trie 中删除 BADE,你将得到 图 16-14 中显示的结果。

图 16-14:从基于对象的 trie 中移除 BADE

与之前的基于数组的 trie 实现一样,当节点变为空时,你会将其删除。更新后的逻辑如下:

const _remove = (trie, [first, . . .rest]) => {
  if (isEmpty(trie)) {
    // nothing to do
  } else if (!first) {
    trie = null;
  } else {
  ❶ trie.links[first] = _remove(trie.links[first], rest);
    if (isEmpty(trie.links[first])) {
    ❷ delete trie.links[first];
    ❸ if (Object.keys(trie.links).length === 0) {
        trie = null;
      }
 }
  }
  return trie;
};

const remove = (trie, wordToRemove) => _remove(trie, wordToRemove + EOW);

与原始尝试 ❶ ❷ 的逻辑相比,大部分差异直接与知道使用哪个链接相关,但为了决定一个节点是否为空,你需要查看链接对象中有多少个键 ❸。

考虑基于对象的 trie 性能

这个新版本的 trie 有什么变化?性能与基于数组的 trie 完全相同;唯一的区别是所需的内存量。可以这样理解:如果你使用一个 trie 来表示包含所有重音符号、变音符号和特殊字符的欧洲语言字典(如丹麦语的 å,捷克语的 eˇ,或德语的 ß),你将需要一个每个节点包含超过 200 个链接的数组,尽管大部分链接是空的。使用对象节省了大量空间,这在某些情况下可能很重要。

然而,使用对象代替数组并不像想象中那样高效。毕竟,如果键很长,你仍然需要一个非常高的 trie。作为一个边界情况,假设有一个只包含一个 20 个字符长的键的 trie。你将拥有一个 20 层高的树,只有一个键。基数树提供了一种改进的解决方案。

基数树

到目前为止的两个 trie 都表现良好,但它们有很多层。例如,在示例 trie 中查找 BADE 时,你需要逐层向下搜索每个字母,而没有其他以 A 开头的单词;请参见 图 16-15。

图 16-15:在 trie 中成功查找 BADE 需要许多步骤。

基数树的想法是,如果某一层只有一个链接,我们就“将其推上去”并与父链接合并,以缩短未来的搜索时间。图 16-16 显示了 ACE、AD、BADE、BE、BED 和 BEE 这些单词集合的基数树样式。

图 16-16:基数树通过连接链接来缩短搜索时间。

现在,你只需要两步就能找到 BADE。这个树比之前的所有字典树都要短。一些路径(例如,从 B 到 E 到 BE)仍然是相同的长度,但大多数路径更短,因为某些层次有多字符的链接。让我们来探讨这些树是如何定义和使用的,因为它们代表了一种更高效的数字树。

定义基数树

如果你使用对象来存储链接,就像在基于对象的字典树中那样,你会发现基数树的逻辑与之前完全相同。区别在于如何使用链接(它们不总是单字符链接),但结构是一样的:

const EOW = "■";
const newRadixTree = () => null;
const newNode = () => ({links: {}});
const isEmpty = (rt) => !rt;

由于与之前的基于对象的字典树代码没有变化,我们继续处理这些新的树。

搜索基数树

执行搜索与之前在基于对象的字典树中的操作类似,但由于链接可能对应多个字符,你需要稍作不同的处理。首先考虑一个简单的情况:搜索 BED——或者更准确地说,搜索 BED■。图 16-17 显示了要遵循的路径。

图 16-17:在基数树中成功搜索 BED

在根节点,你需要找到一个 BED■的前缀链接。在这种情况下,跟随 B 链接进入下一层。(如果你没有找到这样的链接,就立即声明搜索失败。)由于你跟随了 B 链接,接下来要搜索 ED■。同样,你会找到一个与搜索匹配的链接,并跟随那个 E 链接进入第三层,在那里你查找 D■。此时,你找到了完整的匹配,因此找到了键并可以返回它的数据。完成!

另一个例子,搜索 ACED■,这将是一个失败的搜索(见图 16-18)。

图 16-18:在基数树中未能成功搜索 ACED

这里发生了什么?在第一层,你找到了一个 A 链接可以跟随,于是你在第二层寻找 CED■。然而,你没有找到任何具有搜索字符串前缀的链接,搜索失败。如果你在搜索 CAB■,你也会遇到同样的问题,但这是在根节点,因为没有任何链接的前缀包含该字符串。

其基本思路是,逐层向下遍历树,匹配前缀与链接。给定两个字符串,你首先需要一个辅助函数来找出它们从开头起有多少个字符是相同的。例如,如果你有 BEE 和 BADE,它们只有一个字符是相同的(B)。如果你有 ACED 和 ACAD,它们有两个字符是相同的(最初的 AC 字符)。

辅助函数如下所示:

const _commonLength = (str1, str2) => {
  let i = 0;
❶ while (str1[i] && str1[i] === str2[i]) {
 ❷ i++;
  }
  return i;
};

从开始处比较字符❶并计数❷,直到字符串结束或不再匹配。

使用这个函数,你就可以开始搜索:

const _find = (trie, wordToFind) => {
❶ if (isEmpty(trie)) {
    return false;
  } else {
  ❷ const linkWord = Object.keys(trie.links).find(
      (v) => v[0] === wordToFind[0]
    );

  ❸ if (linkWord) {
    ❹ if (wordToFind === linkWord) {
        return trie.links[linkWord].data;
      } else {
      ❺ const common = _commonLength(linkWord, wordToFind);
      ❻ return _find(
          trie.links[linkWord.substring(0, common)],
          wordToFind.substring(common)
        );
      }
  ❼} else {
      return false;
    }
  }
};

如果树为空,显然键不可能在那里 ❶。为了查看一个链接是否是搜索键的前缀,首先找到与第一个字符匹配的(唯一)对象键 ❷,因为在一个节点中不能有两个或多个键具有相同的初始字符(这会破坏结构)。如果找到这样的链接 ❸,检查它是否确实包含你需要的完整字符串 ❹;如果是,那么操作完成。如果链接与整个字符串不匹配,找到公共前缀 ❺ 并递归搜索其余的字符串 ❻。另一方面,如果没有找到与字符串的初始字符匹配的链接,搜索失败 ❼。

主要的 find()函数与基于对象的尝试相同:

const find = (trie, wordToFind) =>
  !!wordToFind && _find(trie, wordToFind + EOW);

基数树的搜索逻辑与尝试树的搜索逻辑类似,只是匹配链接要复杂一些。#### 向基数树中添加一个键

向基数树中添加新键的问题是,如果之前已经添加的键与你想要添加的键的部分匹配。我们从一个简单的情况开始:像原始基数树一样添加 ABE■(见图 16-19)。

图 16-19:将 ABE 添加到基数树:初始结构

首先,按前一节所示搜索字符串,在跟随 A 链接之后,你到达一个没有匹配 BE■前缀的节点。这意味着你可以在这里停止搜索,并在该位置添加新的链接。

图 16-20 展示了将 BAD 添加到在图 16-19 中更新的树的更复杂情况。

图 16-20:将 BAD 添加到基数树需要拆分一些链接。

这里的不同之处在于,在跟随 B 链接之后,你会发现已经有一个部分匹配(ADE■),因此现在需要进行拆分:公共前缀(AD)保留在节点中,并创建一个新节点,包含其余的键。

因此,逻辑需要处理两种情况:未找到前缀(如 ABE)或找到部分匹配(如 BADE)。以下是代码:

const _add = (trie, wordToAdd, data) => {
❶ if (wordToAdd) {
  ❷ if (isEmpty(trie)) {
      trie = newNode();
      trie.links[wordToAdd] = {data};
    } else {
    ❸ const linkWord = Object.keys(trie.links).find(
        (v) => v[0] === wordToAdd[0]
      );
    ❹ if (linkWord) {
        const common = _commonLength(linkWord, wordToAdd);
        const prefix = linkWord.substring(0, common);
        const oldSuffix = linkWord.substring(common);
        const newSuffix = wordToAdd.substring(common);

      ❺ if (linkWord === prefix) {
          trie.links[linkWord] = _add(trie.links[linkWord], newSuffix, data);
        } else {
        ❻ trie.links[prefix] = {
            links: {
              [oldSuffix]: trie.links[linkWord],
              [newSuffix]: {data}
            }
          };
        ❼ delete trie.links[linkWord];
        }
      } else {
      ❽ trie.links[wordToAdd] = {data};
      }
    }
  }
❾ return trie;
};

首先检查是否添加一个非空字符串 ❶;如果不是,返回未更改的 trie ❾。如果你有一个字符串需要插入,检查树是否为空 ❷,因为如果为空,创建一个包含单个键的新节点,操作完成。如果树不为空,搜索是否有一个现有的键至少匹配第一个字符 ❸(如前所示)。如果没有找到,创建一个新节点 ❽;否则,检查找到的键是否完全或部分匹配你正在插入的内容 ❹,根据你找到的公共长度拆分键。如果完全匹配 ❺,跟随链接插入剩余的字符串;否则 ❻,进行拆分,将公共前缀留在节点中,通过添加一个新节点并删除旧键 ❼。

其余的逻辑与其他尝试相同:

const add = (trie, wordToAdd, dataToAdd = wordToAdd) =>
  _add(trie, wordToAdd + EOW, dataToAdd);

你已经看到如何添加一个新键,这有两个不同的情况;现在看看使用反向算法从基数树中删除一个键会发生什么。 #### 从基数树中删除一个键

继续使用在图 16-20 中更新的相同基数树,考虑两种删除键的情况:从节点中删除一个链接,剩下两个或更多链接,和从节点中删除一个链接,剩下一个链接。首先看看前一种较简单的情况(图 16-21)。

图 16-21:你将从中删除 ACE 的基数树

要从基数树中删除 ACE,搜索该键,当到达最终链接时,只需删除它。树的结构将如图 16-22 所示。

图 16-22:删除 ACE 只需要删除一个链接。

然而,如果你想删除 BADE 怎么办?记住,如果只有一个链接,你需要将其与父链接连接,如图 16-23 所示。

图 16-23:删除 BADE 需要更多工作。

在搜索 BADE 并删除最后一个链接(E■)后,最终节点只剩下一个 EOW 链接。然后,你将其向上传递,使其与父节点(即 AD 链接)连接。这种操作逆转了你在添加新键时所做的分裂。

考虑实际逻辑。你需要一个辅助的 _remove() 函数,像其他情况一样,用来删除目标键:

const _remove = (trie, wordToRemove) => {
❶ if (!isEmpty(trie) && wordToRemove > "") {
  ❷ const linkWord = Object.keys(trie.links).find(
      (v) => v[0] === wordToRemove[0]
    );
  ❸ if (linkWord && wordToRemove.startsWith(linkWord)) {
      const common = _commonLength(linkWord, wordToRemove);
      const prefix = linkWord.substring(0, common);

    ❹ if (wordToRemove === prefix) {
        delete trie.links[prefix];
    ❺} else {
        trie.links[prefix] = _remove(
          trie.links[prefix],
          wordToRemove.substring(common)
        );
      ❻ if (Object.keys(trie.links[prefix].links).length === 1) {
          const single = Object.keys(trie.links[prefix].links)[0];
          trie.links[prefix + single] = trie.links[prefix].links[single];
          delete trie.links[prefix];
        }
      }
    }
  }
❼ return trie;
};

首先检查是否完成,这意味着到达一个空链接或你要删除的单词的结尾❶。如果没有完成,搜索一个与要删除的单词有公共前缀的链接❷,如前所述。如果没有找到,说明删除的单词不在字典树中,过程也结束。如果找到合适的前缀❸,有两种可能:你找到了整个单词或单词的一部分。在前一种情况❹,删除链接;不需要进一步操作。在后一种情况❺,搜索并删除单词的其余部分,忽略已找到的前缀(你将递归地执行此操作)。之后,检查字典树是否只剩下一个键❻,如果是,将其唯一的键与找到的前缀连接起来。最后,返回更新后的字典树❼。

处理完这个函数后,你可以编写 remove()函数:

const remove = (trie, wordToRemove) => {
❶ if (!isEmpty(trie)) {
  ❷ _remove(trie, wordToRemove + EOW);
  ❸ if (Object.keys(trie.links).length === 0) {
      trie = null;
    }
  }
  return trie;
};

如果字典树不为空❶,使用辅助函数从树中删除键❷,然后进行最后检查:如果根节点变为空❸,字典树为 null。

考虑基数树的性能

基数树就像是“压缩版”的字典树。当节点包含较长字符串时,它们更快;在最坏的情况下,如果所有链接都是“单字符”链接,它们的行为与字典树相同。这意味着你无需做太多分析。表 16-3 展示了性能。

表 16-3:基数树操作的性能

操作 性能
创建 O(1)
空吗? O(1)
添加 O(1)
删除 O(1)
查找 O(1)

对于基数树,结果与字典树相同:所有操作都是 O(1)。

三叉搜索字典树

字典树和基数树基于“沿链接存储键”的思想。你还可以将这个思想应用到 三叉树,一种节省空间并提供良好性能的新结构。我们在第十三章中提到过三叉树,它是二叉树的推广,每个节点有三个链接而不是两个,但我们并没有实际使用它们。其核心思想是,使用三叉搜索树时,每个节点都有一个键(一个字符)和三个链接:

  • 左链接用于当前字符小于节点键的字符串。

  • 中间链接用于当前字符等于节点键的字符串。

  • 右链接用于当前字符大于节点键的字符串。

图 16-24 显示了一个三叉搜索树。

图 16-24:一个包含七个单词的三叉搜索树

尝试沿着每条路径走到每个单词;这样你将理解中间链接与左右链接之间的区别。以下部分将详细解释这一点。

定义三叉字典树

我们已经考虑过二叉搜索树,定义三叉树类似。你需要做的就是添加一个中间链接:

const EOW = "■";

const newTernary = () => null;

const newNode = (key) => ({
  key,
  left: null,
  right: null,
  middle: null
});

const isEmpty = (tree) => tree === null;

创建三叉树非常简单,但存储额外数据和进行搜索需要一些改动。

在三叉字典树中存储额外数据

对于三叉字典树,你会面临与其他常见树相同的问题。你可以将额外的数据存储在键旁边,但问题是,键并没有像在搜索树中那样存储在单一位置;键被分散在整个字典树中。

为了简化这个复杂性,重新应用用于字典树的解决方案。当你到达 EOW 字符时,使用它的中间指针来存储额外的数据:

tree.middle = {data};

当然,如果你使用三叉字典树只是为了存储键,并且不关心额外的数据(例如,如果你使用三叉字典树查找单词,看看它们是否存在于拼字游戏应用中,以检查对手输入的奇怪单词),你可以省略这一行,并修改搜索功能(接下来会讲到),只返回 true 或 false。

搜索三叉字典树

鉴于我们设计三叉字典树的方式,搜索三叉字典树中的键与二叉搜索树的逻辑相似(参见第十二章)。例如,图 16-25 显示了如何查找单词 AD(你实际上会搜索 AD■,即附加了 EOW 字符)。

图 16-25:在三叉字典树中成功查找 AD

搜索从根节点开始,根节点是 B。由于 A(AD■中的第一个字母)小于 B,沿左链接走。然后你找到 A,所以沿中间链接走,继续查找剩下的字符串 D■。你找到 C,所以沿右链接走。然后你找到 D,现在沿中间链接继续,查找 EOW 字符。找到它后,成功返回关联的数据。

如果你原本是在查找 ADD,那么你会在第一个 D 后找到一个空链接,因此搜索会失败。

如前所述,三元查找树的搜索过程与二叉搜索树类似。逻辑如下,与其他情况一样,辅助函数会派上用场:

const _find = (tree, wordToFind) => {
❶ if (isEmpty(tree)) {
   return false;
❷} else if (wordToFind.length === 0) {
   return tree.data;
❸} else if (tree.key === wordToFind[0]) {
   return _find(tree.middle, wordToFind.substring(1));
❹} else {
   return _find(wordToFind < tree.key ? tree.left : tree.right, wordToFind);
 }
};

如果树为空或在搜索时到达空子树,搜索失败 ❶。否则,按字符递归搜索。通过 EOW 字符后,你找到了键并可以返回其数据 ❷;如果没有存储任何数据,仅返回 true。如果当前树的键与你正在搜索的单词的第一个字符匹配 ❸,则沿中间链接继续匹配其余的单词。如果不匹配 ❹,则像在二叉搜索树中一样,沿左或右链接继续搜索。

要进行搜索,你需要像其他查找树那样使用相同的 find()函数:

const find = (trie, wordToFind) =>
  !!wordToFind && _find(trie, wordToFind + EOW);

如你所见,使用三元查找树与使用常规查找树没有太大区别,但在添加或删除键时,结构会发生一些变化。

向三元查找树添加键

向三元查找树添加键的逻辑与向二叉搜索树添加键类似。从根节点开始,将要添加的字符串的第一个字符与根节点的字符进行比较,决定是向左走(如果没有匹配)还是向右走(如果没有匹配),或者向中间走(如果字符匹配)。这个过程会一直继续,直到你要么找到该键(不需要做任何操作),要么决定需要添加它。

尝试向上一节中的三元查找树添加 ABE;图 16-26 展示了这些步骤。

图 16-26:向三元查找树添加 ABE

你要添加 ABE■(记得是 EOW),与根节点的 B 进行比较后,你会向左链接走。你在那里找到了 A,所以沿中间链接走。然后,你找到 C,因为你要添加 BE■,再次沿左链接走,但由于链接为空,你就在该位置添加一个新的子树,根节点为 B,沿中间链接添加 E,最后有一个指向额外数据的 EOW。

这是实际的逻辑:

const _add = (tree, wordToAdd, data) => {
❶ if (wordToAdd.length > 0) {
  ❷ if (isEmpty(tree)) {
      tree = newNode(wordToAdd[0]);
    }
  ❸ if (tree.key === wordToAdd[0]) {
    ❹ tree.middle =  
        wordToAdd[0] === EOW
          ? {data}
          : _add(tree.middle, wordToAdd.substring(1), data);
  ❺} else {
    ❻ const side = wordToAdd < tree.key ? "left" : "right";
    ❼ tree[side] = _add(tree[side], wordToAdd, data);
    }
  }
❽ return tree;
};

首先,逐个字符处理你想添加的字符串直到结束 ❶。如果你到达一个空链接,创建一个新树 ❷,并将其余待处理的字符添加到新键中。如果当前节点的键与你正在添加的字符串的第一个字符匹配 ❸,则要么添加数据(如果你已经到达了 EOW 字符),要么递归地将其余的字符串添加到中间链接子树 ❹,从而使你向下移动树结构。如果没有匹配 ❺,则决定是沿左链接还是右链接继续 ❻,并使用递归在那儿添加字符串 ❼。最后,返回更新后的字典树 ❽。

如前所述,实际的 add() 函数使用了辅助的 _add() 函数:

const add = (tree, wordToAdd, data = wordToAdd) =>
  _add(tree, wordToAdd + EOW, data);

添加操作与二叉字典树其实没什么不同,如果忽略掉处理中间链接的逻辑。删除操作会类似,但会有一些复杂性。

从三叉字典树中删除一个键

删除一个键比其他操作更困难,但借助第十二章关于如何在删除任何给定节点后保持适当的数据结构的课程,你将能够完成这项任务。(剧透:与二叉搜索树一样,删除一个节点时需要根据节点的子节点数量采取不同的处理方式。)从一个更复杂的三叉字典树开始,它将提供一些独特的案例,如图 16-27 所示(为了清晰起见,我用三角形替换了一些子树)。

图 16-27:一个你将要删除一些单词的三叉字典树

请注意,在删除一个单词时,你需要沿着从根节点到最终 EOW 的路径删除几个节点,因为现在这些键不再存储在单个节点中。然而,你不能删除整个路径;你需要在路径与另一路径交汇处停止。

让我们从一个简单的例子开始:如果你想删除 BEAD、BEVY 或 BELL,会发生什么?在每一种情况下,只需从 EOW 向后删除所有节点,直到你到达一个属于不同路径的节点,就完成了。删除这三个单词后,字典树会变成图 16-28 所示的样子。

图 16-28:删除单词可能需要向上移除节点。

“切割”发生在标记的节点上,这些节点仍然保留它们的中间链接,并且(在 BEST 的情况下)还有另一个侧链接。现在考虑如果你想删除 BETS 会发生什么。你不应该保留 T 节点,因为那样会浪费空间。由于该节点只有一个子节点,你可以像处理二叉搜索树一样操作,只需将节点的父节点连接到非空子节点即可(参见图 16-29)。

图 16-29:从三叉字典树中删除 BETS 需要修改父节点的链接。

关键问题出现在你需要删除一个既有左链接又有右链接的节点时。回到原始字典树并考虑删除 BED。回顾一下,图 16-30 展示了原始字典树。

图 16-30:一个三叉 trie,你将在其中移除 BED

在移除 BED 时,你不能只是删除 D 节点,因为那样会破坏 trie,导致丢失许多其他单词;然而,你也不想保留它,因为它不属于任何单词。解决方案与二叉搜索树中的方法类似:你可以在其右子树中找到下一个单词,并用它替换你正在删除的单词。在这种情况下,下一个单词是 BEER,如图 16-31 所示。

图 16-31:将 BEER 放在 BED 的位置,以保持三叉 trie 的结构。

找到要替换删除词(BEER 替代 BED)之后,你需要调整多个链接以维持正确的 trie 结构。如果右子树没有左子树,变更会更简单。看看你是否能弄明白如何移除 BEST 并用 BEVY 替代它。

逻辑如下:

const _remove = (tree, wordToRemove) => {
❶ if (isEmpty(tree)) {
    // nothing to do
❷} else if (wordToRemove.length === 0) {
    tree = null;
  } else {
  ❸ if (wordToRemove[0] === tree.key) {
      tree.middle =
        tree.key === EOW
          ? null
          : _remove(tree.middle, wordToRemove.substring(1));

    ❹ if (isEmpty(tree.middle)) {
      ❺ if (isEmpty(tree.left)) {
          tree = tree.right;
        } else if (isEmpty(tree.right)) {
          tree = tree.left;
      ❻} else {
          let treeR = tree.right;
          let prev = null;
        ❼ while (!isEmpty(treeR.left)) {
            prev = treeR;
            treeR = treeR.left;
          }
        ❽ if (prev) {
 prev.left = treeR.right;
            treeR.right = tree.right;
          }

       ❾ treeR.left = tree.left;
          tree = treeR;
        }
      }
  ❿} else {
      const side = wordToRemove < tree.key ? "left" : "right";
      tree[side] = _remove(tree[side], wordToRemove);
    }
  }

  return tree;
};

如果树为空,则什么也不做 ❶。如果到达词的末尾(经过 EOW 字符,和查找时一样),将树设置为 null,操作完成 ❷。当你查找的字符与当前节点的键匹配时,递归地跟踪它的中间链接 ❸,但如果到达 EOW,则将该链接设为 null。删除后,检查是否到了一个中间链接为空的节点 ❹,这是你不希望出现的情况。如果左或右链接中的一个 ❺ 为空,则选择另一个链接;但如果两者都不为空 ❻,你需要找到右侧最左边的节点 ❼ 并修复链接,像之前描述的那样 ❽ ❾。(如果 prev === null,表示右边只有一个单独的节点;否则,你需要沿着左链接向下。)如果没有提前找到正确的字符,你需要向左或向右 ❿。

完成这个功能后,移除一个键与其他 trie 的操作相同:

const remove = (tree, wordToRemove) => _remove(tree, wordToRemove + EOW);

现在你已经看过了所有与三叉 trie 配合使用的函数,接下来让我们考虑它们的性能。

考虑三叉 trie 的性能

三叉 trie 与本章中看到的其他 trie 有一个不同之处:树的形状取决于添加和删除的顺序,这意味着在最坏情况下,行为会与正常的平均情况不同。你可能还记得这在普通的二叉搜索树中也曾发生过。最坏情况的性能与平均性能不同,而这取决于树的构建方式。

如果你按照升序插入所有键,那么如果你存储的是长度为 k 的字符的键,且字母表包含 s 个符号,那么添加、查找和删除操作的时间复杂度都是 O(sk),这比 O(n) 要好得多!严格来说,O(sk) 实际上是 O(1),因为 sk 是常数,因此三叉 trie 的性能与其他 trie 相当。

总结

在本章中,我们研究了几种搜索树的变体,这些变体被称为字典树,它们应用了不同的概念:字典树并不直接存储和比较键,而是逐字符操作,从而在与我们之前研究的搜索树相比时,提供了更高的性能,且不需要复杂的操作,如旋转或平衡节点。

我们在这里研究的结构通常用于字典(快速查找单词)或简单的查找操作:给定一个键,找到与其相关的数据。字典树的保证性能使其成为一种有用的数据结构,尤其是在需要尽可能快地处理工作并且不想面对可能需要过长时间处理的意外最坏情况时。

问题

16.1  字典树的映射

你能使用映射(map)而不是数组或对象来实现字典树吗?

16.2  是否为空?

在第 410 页的“向基数树添加键”一节中,_add()函数中,wordToAdd 是否可能为空,如果可能,什么时候为空?

16.3  旋转你的字典树

你能对三叉树应用旋转操作吗?

16.4  中间为空?

对错题:在三叉字典树中,中间链接永远不会为空。

16.5  四字母三叉字典树?

假设你从一个空的三叉字典树开始,按顺序添加键 AAAA、AAAB、...AAAZ、BAAA、...BAAZ ...一直到 ZZZZ——包含四个字母的所有可能组合。这个字典树的高度会是多少?

16.6  它们看起来怎样?

总结一下,如果你只向这些结构中添加一个单词“ALGORITHM”,本章中提到的所有结构(基于数组的字典树、基于对象的字典树、基数树和三叉字典树)会是什么样子?

第十七章:17 图

在前几章中,我们讨论了几种数据结构,而在本章中,我们将探讨一个新话题:如何表示图我们还将研究与图相关的几种算法,如寻找最短路径、计算距离、检查软件依赖关系等。

什么是图?

一个抽象的定义是,图是一个由对象组成的集合,这些对象之间以某种方式相互关联。这些对象叫做顶点vertex的复数),也叫做节点。顶点对之间的关系通过连接顶点对的线条来图形化表示,这些线条叫做箭头,或仅仅称作链接。与一个点连接的弧的数量叫做它的。以这种方式相连的点有时称为邻居,或者认为它们是相邻的。同样的词在类似的意义下使用:如果两个边共享一个公共的顶点,则认为它们是相邻的。

这些定义可能听起来模糊或相当“数学化”(事实上,专门研究图及其性质的数学分支叫做图论),因此本章将通过一些实际示例进行探讨。(实际上,我们已经学习过图。树就是图;实际上,定义也适用于它们。)图的某些使用场景包括以下内容:

  • 人与人之间的关系,其中你可以将人(节点)和友谊(弧)联系起来,这样如果两个人是朋友,他们就会相互连接

  • 代码中的依赖关系,包括导入其他模块(节点)中导出的组件(弧)的模块

  • 任务之间有依赖关系的项目(节点),这些任务在其他任务完成之前无法开始(弧)

  • 地图,类似于基于 GPS 的应用程序,包含城市(节点)和道路(弧)

图 17-1 展示了后一种情况,使用图表示城市或国家的一部分。

图 17-1:一个表示某些城市和连接它们的道路的图

在这个图形地图中,顶点代表城市(或街角,或国家),边代表道路(或街区,或航班)。在图 17-1 中,边是无向的,意味着可以任意方向行驶——例如,从 A 到 E 或从 E 到 A。

在一个城市地图中,街道可能是单行道,那么我们需要一个有向图,如图 17-2 所示。在这些图中,我们可以讨论节点的出度(从该节点出发的弧的数量)或入度(指向该节点的弧的数量)。

图 17-2:一个有向图,表示只能单向行驶的道路

边通常有与之相关的值,如时间或成本,这使得它们成为加权图。也可以有无权图,边没有与之相关的值;重要的是关联本身。不要假设对有向图适用对称性或其他规则。例如,在图 17-2 中,你可以直接从 B 到 C,但不能一步到位地从 C 返回 B。此外,从 A 到 B 的成本与从 B 到 A 的成本不同。最后,从 G 经 F 到 D 的成本可能比直接从 G 到 F 更低。在某些情况下,权重可能为负值,但我们在这里始终处理非负值。

图也可以在任意一对顶点之间有多条边,但我们不考虑这些。在本章的所有算法中,仅使用最短的边,忽略其他边就足够了。我们这里还忽略的一种情况是从顶点到它自身的边,称为环。

我们将考虑以下几种类型的过程:

  • 给定两个顶点,你可能想知道是否存在一条从第一个顶点到第二个顶点的路径。作为扩展,你可能想找到从一个顶点到另一个顶点或所有其他顶点的最小成本路径(最短路径)。

  • 一个有向图可以表示一个项目,其中包含任务和任务之间的依赖关系:你可能希望找到一个排序,使得在所有前置任务完成之前,任何任务都不能开始;这就是所谓的拓扑排序

  • 基于任务和依赖关系的这个示例,你可能会担心某种类型的循环(如 A 在 B 之前,B 在 C 之前,C 又在 A 之前)会使得排序变得不可能。与拓扑排序相关的问题是循环检测

  • 一个无向图可以表示地理点,边表示将它们连接起来的成本,比如电力线路或通信电缆的成本。最小生成树显示了如何选择边,使得所有点都以最低的总成本互相连接。

  • 同样地,给定前面的图,你可能会问它是否是连通的(意味着可以从任何一个点到达其他所有点)或不连通。在这种情况下,你需要实现连通性检测

这份程序列表并不完整,但涵盖了最重要的算法。让我们先考虑如何表示图,然后再讨论必要的算法。

最后一个说明:在讨论算法性能时,我们将使用v表示顶点的数量,e表示边的数量。请注意不要将其与数学常数e混淆,后者是自然对数的底数,约为 2.718281728 ...!

图的表示

表示图的方式有几种,我们将考虑三种最常用的方法:邻接矩阵、邻接表和邻接集。

图的邻接矩阵表示法

邻接矩阵表示法是最简单的,基本上展示了哪些节点是相邻的。这种图通过一个方阵表示,每个顶点都有一行一列。如果存在从顶点i到顶点j的连接,矩阵中位置[i][j]就有一个值:对于无权图,这是一个布尔值,表示是否有连接;对于带权图,这是连接边的权重。如果两个顶点之间没有连接,则矩阵在相应的位置上为假值或特殊值(零或+无穷大)。对于无向图,请注意位置[i][j]将始终等于位置[j][i];矩阵相对于主对角线是对称的。

再次考虑前一节中的有向图(图 17-2)。该图的矩阵表示可以如图 17-3 所示。

图 17-3:图 17-2 中的图的邻接矩阵表示

我们已经选择用零表示缺失的边;另一个同样有效的解决方案是用+无穷大表示缺失的边。不管你用哪种方式表示缺失的边,在这两种情况下,矩阵的对角线都会是零。从某个点到达自身不需要成本,因为你已经在那个点上。

这种表示方法相当容易操作,但对于大型图来说,它需要大量空间。在性能方面,检查两个顶点是否相邻,或添加、删除边的操作时间复杂度为O(1),这是最快的。然而,处理一个顶点的边列表的时间复杂度是O(v),无论该节点实际上有多少个邻居。

如果节点只有少数邻居,大部分矩阵会被标记为空(形成稀疏矩阵),这意味着你浪费了空间。在这种情况下,你可以选择邻接表表示法。

图的邻接表表示

图 17-3 中显示的矩阵浪费了太多空间,并且导致所有需要获取从某个点出发的弧列表的过程时间复杂度为O(v)。你可以使用邻接表表示,这样每个顶点就能包含所有与之连接的点以及所有连接到它的点。

对于图 17-2 中显示的同一个有向图,邻接表表示如图 17-4 所示(与图 17-3 中的矩阵表示进行对比)。

图 17-4:邻接表表示是邻接矩阵的另一种选择。

对于每个顶点,你有两个列表:一个是输出边列表(水平显示,作为行),另一个是输入边列表(垂直显示,作为列)。例如,在第一行,你可以看到从 A 出发,可以到达 B(代价为 4)或 E(代价为 11)。查看第一列,你会看到从 B 出发可以到达 A(代价为 3)或 D(代价为 5)。结构中的每个元素都将有一个指向同一行下一个元素的指针,以及一个指向同一列下一个元素的指针。你还可以使用双向链表以便更容易进行更新。节点还需要包含两个端点的身份信息。

使用这种结构,可以快速处理从给定顶点出发或到达的所有边,这将加速多个算法。然而,如果你只是想知道两个给定点是否直接相连,情况就会变得更慢;在这种结构下,它将是一个O(e)操作。你也可以选择使用集合而非列表,稍后会展示这种方法。

图的邻接集合表示

我们可以提出更复杂的解决方案,涉及使用集合(如在第十一章中所述)或树(如在第十二章中所述),而不是使用列表。例如,在处理平衡搜索树时,检查两个点是否相连是一个O(log e)操作。(你可以考虑每个节点的平均度为e/v条边,那么操作将是O(log e/v))。在这种结构下,每个顶点都与两个映射相关联:一个用于输出边,一个用于输入边。两个映射的关键都是边上的“另一个”点。添加和移除边都是O(log e)操作,因此性能更好。处理从节点出发的所有弧的速度与列表相同。

找到最短路径

一个常见的问题如下:给定图中的两个点,找到从第一个点到第二个点的路径。路径是一个相邻边的序列(每条边开始的地方是上一条边的结束处),它从第一个点开始,到第二个点结束。

我们在第五章中已经考虑过这种类型的问题,当时你在迷宫中找到了一条路径,因此现在解决一个更复杂的问题:从一个节点到另一个节点的最短路径,或者更一般地,找到从一个节点到所有其他节点的最短路径。这些算法不仅会找到路径是否存在,还会找到所有可能路径中最优的(最便宜、最短)。如果你只想找到一条路径,任何路径,只需一旦到达目的地就停止搜索。

Floyd-Warshall 的“所有路径”算法

这个第一个算法很有趣,因为它应用了动态规划,这是你在第五章中探讨过的内容。Floyd-Warshall 算法虽然不是性能最优的(你会看到其他更好的选择),但它绝对是最简单的。还有另一个区别:在这里,你将找到所有节点对之间的最短距离,而在其他情况下,你可能只想找到特定节点对之间的距离。这一要求会对性能产生影响,但在某些情况下,拥有整个距离表可能正是所需的。

假设存在一个函数distance(i,j,k),该函数返回从点i到点j的最短路径长度,最多使用图中前k个节点作为路径。(换句话说,你不会考虑经过其他节点的任何路径。)你想计算所有ij值的distance(i,j,n)。

distance(i,j,k)的值必须是一个路径,要么不包含第k个节点的路径,要么是从ik的路径,再从kj的路径,取这两者中较短的路径。换句话说,distance(i,j,k)是distance(i,j,k – 1)和distance(i,k,k – 1) + distance(k,j,k – 1)的最小值。这个公式非常重要;请确保完全理解它。这个定义是递归的,但基本情况很简单:对于所有点,distance(i,i,0)是 0;而对于i ≠ jdistance(i,j,0)是从ij的边,如果存在的话,否则是+无穷大。(如果不仅仅是求距离,而是要找到实际的路径呢?见问题 17.1。)

作为示例,使用图 17-5 中的图形。为了简化起见,它是无向图,但该算法也适用于有向图。

图 17-5:你想在其中找到任意两点之间最短距离的图

图 17-6 展示了图 17-5 中的初始距离数组。

图 17-6:使用无穷大值表示缺失边的图 17-5 图的邻接矩阵

图 17-6 中的对角线全是零,除了现有的边,其他都为+无穷大。(这只是使用+无穷大代替零来表示缺失的边,正如本章之前提到的邻接矩阵。)在第一次迭代中,检查将第一个点(A)作为中介是否能缩短某些距离。实际上,你会发现,现在你可以以 9 的代价从 B 到 D,如图 17-7 所示。

图 17-7:当你尝试将 A 作为中介点时,会发现 B 和 D 之间的距离变短。

第二次迭代检查是否将 B 作为中介可以得到更短的路径。你会发现现在从 A 到 C 的成本为 13,从 C 到 D(C 到 B,再从 B 到 D 经过 A)的成本为 18,如图 17-8 所示。

图 17-8:当你尝试将 B 作为中介时,你会发现另外两条更短的路径。

在下一次迭代中,你将检查是否将 C 作为中介有帮助;接着,你会尝试 D、E 等等。你会继续迭代,直到所有节点都被考虑过,最终结果(查看它)提供了所有节点之间的距离(见图 17-9)。

图 17-9:在尝试了所有可能的中介点之后,你计算出最终的距离矩阵。

代码很简短:

const distances = (graph) => {
❶ const n = graph.length;

❷ const distance = [];
❸ for (let i = 0; i < n; i++) {
    distance[i] = Array(n).fill(+Infinity);
  }

❹ graph.forEach((r, i) => {
  ❺ distance[i][i] = 0;
  ❻ r.forEach((c, j) => {
      if (c > 0) {
        distance[i][j] = graph[i][j];
      }
    });
  });

❼ for (let k = 0; k < n; k++) {
    for (let i = 0; i < n; i++) {
      for (let j = 0; j < n; j++) {
      ❽ if (distance[i][j] > distance[i][k] + distance[k][j]) {
          distance[i][j] = distance[i][k] + distance[k][j];
        }
      }
    }
  }

 ❾ return distance;
};

n 变量 ❶ 有助于缩短代码;它仅仅是图中节点的数量。距离的数组数组 ❷ 包括从每个节点到其他节点的距离。最初,所有的距离都设为 +无穷大 ❸,然后进行修正 ❹。一个点到自身的距离是零 ❺,而一个点到另一个点的距离,如果连接的话,就是边的长度 ❻。现在,距离数组包含了所有没有中介点的距离,正如前面所描述的那样。三个嵌套的循环系统地应用了前面描述的动态规划计算 ❼,如果你发现了一个更好的(更小的)距离 ❽,则更新表格。在这种情况下,你只保留最后的表格;第 k 次迭代的值会替代前一次的值,因为你再也不需要它们了。最终结果 ❾ 是描述的所有点对之间的距离表格。

这个算法的运行时间复杂度是多少?三个嵌套的循环,每个 O(v),给出了答案:O(v³)。正如前面提到的,这是非常陡峭的,但它能计算出所有节点对之间的距离。如果只需要从一个节点到所有其他节点的距离,或者更具体地说,从一个给定节点到另一个节点的距离,你会看到更好的算法。

贝尔曼-福特算法

现在考虑一个不同的问题:找到一个给定节点到所有其他节点的距离。贝尔曼-福特算法的思想是通过遵循一条给定的边,看看是否能找到两个节点之间的更好路径,并重复这个过程,直到没有更多的替代路径。首先考虑一条边长为一的路径,然后检查使用两条边是否有更短的路径,再检查三条边、四条边,依此类推。如果图中有 n 个节点,最长路径可以有 n - 1 条边,因此这是迭代的限制。

我们使用相同的图来计算从 F 到所有其他节点的最短距离。(该算法在有向图中同样有效,但为了简化,使用的是无向图。)初始情况如 图 17-10 所示。没有处理任何边(没有走任何路线),只有 F 可以(显而易见地!)从 F 到达,你无法到达其他节点。

图 17-10:应用贝尔曼-福特算法,找到从 F 到所有其他点的最短距离。

假设节点按照字母顺序处理。当你处理来自 A、B 或 C 的边时,你无法计算距离,因为你还不知道如何到达这些节点。当你处理 (F,D) 边时,你现在能够到达节点 D,并且你有了路径的第一步,如 图 17-11 所示。

图 17-11:按顺序处理节点时,从 F 可以到达的第一个其他节点是 D。

现在你知道可以以 3 的代价到达 D。接下来处理边 (F,G),你可以到达另一个节点,如 图 17-12 所示。

图 17-12:你可以从 F 到达的另一个节点是 G;你将在后续的迭代中重新处理 D。

然后,你可以处理从 G 出发的边,因为现在你知道可以到达 G。处理边 (G,C) 和 (G,E) 将另外两个节点标记为可达。(别忘了:你找到的任何路径或距离,如果有更好的替代方案出现,可能会改变。)图 17-13 显示了新的情况。

图 17-13:处理 G 时,它是从 F 到达的,你也可以到达 C 和 E。

第一遍处理完成。到目前为止,你知道可以到达七个节点中的五个,并且你为每个节点找到了可能的(但可能不是最优的)路径。

现在开始新的一轮处理。你仍然无法处理 A 或 B 出发的边,因为你还没有到达这些节点,但你可以处理边 (C,B),并添加一条新路径,如 图 17-14 所示。

图 17-14:第二次迭代现在发现 B 可以从之前到达的 C 处到达。

(C,E) 和 (C,G) 边并不代表更短的距离,所以你什么也不做。(例如,你可以从 F 到 E,花费 23;而经过 C 的话将花费 26,所以这条路径不行。)考虑 (D,A) 和 (D,E) 边时,情况变得有趣。你现在可以到达 A,并且通过 D 找到了通往 E 的更好路径(参见 图 17-15)。

图 17-15:第二次查看 D,发现到 E 的路径更短。之前的路径是从 F 到 G,再到 E,长度比从 F 到 D 再到 E 要长。

从 F 到 E 的先前最佳路径是通过 G,花费为 23,但现在你发现可以通过 D,花费为 10。没有更多的变化可以进行,因此开始第三次遍历。一个进一步的改进是,通过 A 到 B 的路线比通过 E 更短(见图 17-16)。

图 17-16:第三次遍历找到了一条更好的从 A 到 B 的路径,但没有更多的变化。

第三次遍历没有带来更多的变化,进一步的遍历也不会有,因此你完成了。你已经知道从起点 F 到所有其他点的最短路径,并且知道要遵循哪些边来达到这个成本。

你可以按如下方式编写这个算法:

const distances = (graph, from) => {
  const n = graph.length;

❶ const previous = Array(n).fill(null);
❷ const distance = Array(n).fill(+Infinity);
❸ distance[from] = 0;

❹ const edges = [];
  for (let i = 0; i < n; i++) {
 for (let j = 0; j < n; j++) {
      if (graph[i][j]) {
        edges.push({from: i, to: j, dist: graph[i][j]});
      }
    }
  }

❺ for (let i = 0; i < n - 1; i++) {
  ❻ edges.forEach((v) => {
      const w = v.dist;
    ❼ if (distance[v.from] + w < distance[v.to]) {
      ❽ distance[v.to] = distance[v.from] + w;
      ❾ previous[v.to] = v.from;
      }
    });
  }

  ❿ return [distance, previous];
};

使用一个前驱数组❶来记录你是从哪个节点到达当前节点的;如果 previous[j]是 i,则从起点到 j 的最短路径是先通过 i 再到 j。距离数组❷记录了从起点到每个节点的距离;将所有的距离初始化为+infinity,除了从起点到自身❸的距离,显然为零。为了处理所有的边而不必遍历整个矩阵,创建一个边数组❹;用这个数组进行迭代会更快。现在进行 n - 1 次迭代❺:对于每条边❻,查看使用它是否提供了从两个端点之间的更短路径❼;如果是,更新第二个节点的距离❽,并记录你是从哪个节点到达的❾。(你能通过减少遍历次数做得更好吗?见问题 17.2。)该算法的结果是一个包含距离的数组和一个显示如何从任何节点间接到达起点❿的数组。

这个算法的时间复杂度是多少?鉴于循环运行了O(v)次,并且每次都会遍历所有边,结果是O(ve)。这个比 Floyd-Warshall 算法更好,但它仅找到从单个起点到所有其他点的最短距离。你可以做得更好,正如你将在解决这个问题的最终算法中看到的那样。

Dijkstra 算法

如果你想找到从一个点到所有其他点(或特别是某个特定点)的最短路径,Dijkstra 算法非常高效。它通过从第一个点开始(该点被认为是已访问的,距离自身为零),成为初始的当前点。所有其他点都被认为是未访问的,距离为+infinity。从那时起,它会执行以下操作:

  1. 遍历所有当前节点的未访问邻居,如果到该邻居有更短的路径,就选择那条路径并更新到未访问节点的距离。

  2. 在处理完当前节点的所有未访问邻居之后,将该节点标记为已访问,并且你就完成了它。

  3. 如果还有未访问的点,选择距离最短的那个,将其作为当前节点,并重复此过程。

  4. 当没有未访问的点剩下时,算法结束(如果你想要从起点到所有其他点的距离),或者当目标点被标记为已访问时(如果你只关心该特定距离)。

考虑一个例子,然后进行实现。为了简便,你将使用之前相同的无向图,但 Dijkstra 算法同样适用于有向图。 图 17-17 显示了初始配置,起点被标记为已访问。

图 17-17:Dijkstra 算法的初始设置:从 A 到自身的距离是零,而从 A 到其他节点的距离设置为 +无穷大。

考虑从当前点(A)到邻居的边,你知道你可以到达 B、D 和 E(见 图 17-18)。

图 17-18:考虑从 A 到其邻居的边,你可以暂时更新到 B、D 和 E 的距离。

你已经更新了到这三个节点的距离,但这些更新目前只是暂时性的,因为你可能会在后续找到更好的路径——比如,从 A 到 E 就有一条更短的路径。

下一步标记 B 为当前节点,因为它是最近的未访问节点(见 图 17-19)。

图 17-19:从 B 开始重复这一过程,B 是最近的未访问节点,并找到到 C 和 E 的更短路径。

你找到了一条到 C 的路径,距离为 13,因为 B 距离为 4,而(B,C)边的权重为 9。你还找到了一条更短的到 E 的路径,因此需要更新这些距离。现在,B 被标记为已访问,并且你转向 D 作为新的当前节点(见 图 17-20)。

图 17-20:你现在从 D 开始,D 是下一个最近但尚未访问的节点,找到到 F 和 G 的更短路径。

从 D 开始,你可以更新到 F 和 G 的距离和路径。到 E 的距离没有被修改,因为通过 D 会需要距离 12,而你已经找到了更短的路径。现在,F 成为当前节点,接下来的过程会很快,因为它只允许一条出路,如 图 17-21 所示。

图 17-21:处理 F 后,找到一条更好的到 G 的路径。

你已经更新了到 G 的最佳路径(截至目前),现在是 21,从 A 到 D,再到 F。你接近完成,E 是下一个需要处理的节点(见 图 17-22)。

图 17-22:接下来处理 E,允许你更新到 C 和 G 的距离。

通过 E 可以使到 C 和 G 的距离更短,所以需要更新这些距离。接下来的步骤选择 C 和 G,然后就不需要再做任何更改。 图 17-23 显示了从 A 出发的最终结果,选定路径和计算的距离。

图 17-23:当你完成所有节点的处理后,你会得到从 A 到所有其他节点的最优距离。

为了实现良好的性能,快速确定下一个要处理的节点(即距离最短的节点)非常重要。一个直接的循环会是一个O(v)算法,但你已经看到了一种合适的结构:堆。使用这种结构可以让你在O(1)时间内找到下一个要处理的节点,而更新堆则是O(log v),这要更好。

主要算法如下,但稍后你会看到与堆相关的一部分:

const distance = (graph, from) => {
  const n = graph.length;

❶ const points = [];
  for (let i = 0; i < n; i++) {
  ❷ points[i] = {
      i,
      done: false,
      dist: i === from ? 0 : +Infinity,
      prev: null,
      index: -1
    };
  }

❸ const heap = [from];
  for (let i = 0; i < n; i++) {
    if (i !== from) {
      heap.push(i);
    }
  }
❹ heap.forEach((v, i) => (points[v].index = i));

  // heap functions, omitted for now

❺ while (heap.length) {
    const closest = heap[0];
    points[closest].done = true;
    const dist = points[closest].dist;

 ❻ swap(0, heap.length – 1);
    heap.pop();
    sinkDown(0);

  ❼ graph[closest].forEach((v, next) => {
      if (v > 0 && !points[next].done) {
        const newDist = dist + graph[closest][next];
      ❽ if (newDist < points[next].dist) {
          points[next].dist = newDist;
          points[next].prev = closest;
          bubbleUp(points[next].index);
        }
      }
    });
  }

❾ return points;
};

points 数组❶包含从起点(from)到其他所有节点的距离。i 属性标识点;done 标记你是否已经处理完该点;dist 是距离,除了初始点外,所有点的初始值为正无穷;prev 显示从哪个点到达当前点❷。index 属性需要解释。正如前面提到的,你将把距离保存在堆中并进行更新,这可能导致它们向上浮动。然而,你需要知道每个点在堆中的位置,这就是 index 的作用。这样,当你更新点 p 的距离时,你知道 points[p].index 就是堆中对应的位置。

将每个点推入堆中,从起点 from ❸ 开始,并更新所有的 index 值 ❹。(由于在 points 数组中只有一个零距离,其他所有的距离都是正无穷,所以你可以创建一个无需任何比较的堆。)当堆不为空 ❺ 时,你移除堆顶点 ❻,标记其为已处理,并继续更新所有它能到达的节点的距离 ❼。如果某个距离更新为更小的值 ❽,则检查它是否应该在堆中上浮。最终结果 ❾ 是更新后的 points 数组,包含从初始节点到其他所有节点的距离。

现在考虑堆的代码,直接基于我们在第十四章中看到的内容:

❶ const swap = (i, j) => {
    [heap[i], heap[j]] = [heap[j], heap[i]];
    points[heap[i]].index = i;
    points[heap[j]].index = j;
  };

❷ const sinkDown = (i) => {
    const l = 2 * i + 1;
    const r = l + 1;
    let g = i;
  ❸ if (l < heap.length && points[heap[l]].dist < points[heap[g]].dist) {
      g = l;
 }
    if (r < heap.length && points[heap[r]].dist < points[heap[g]].dist) {
      g = r;
    }
    if (g !== i) {
      swap(g, i);
      sinkDown(g);
    }
  };

❹ const bubbleUp = (i) => {
    if (i > 0) {
      const p = Math.floor((i - 1) / 2);
    ❺ if (points[heap[i]].dist < points[heap[p]].dist) {
        swap(p, i);
        bubbleUp(p);
      }
    }
  };

swap(...) 函数只是交换堆中两个值 ❶,并同时更新 points 数组中相应的 index 属性,这样你就可以追踪每个节点在堆中的位置。sinkDown(...) 函数 ❷ 的工作原理与第十四章中看到的一样。注意,你不比较堆的值 ❸,而是使用堆值作为索引,比较 points 数组中的距离。(在第十四章中的排序代码中,我们直接比较了堆的值。)同样的改动也适用于 bubbleUp(...) 函数 ❹ ❺。

这个算法的性能如何?目前,每个点只处理一次,并且对于每个点,你都检查是否需要更新到其他所有点的距离,所以它的时间复杂度是O(v²)。你可以通过使用邻接点列表来优化它,类似于图的邻接表表示法,这样性能就变成了O(v log v),因为堆的使用。

对图进行排序

在本章开始时,我们提到了一些图的实际应用,例如跟踪代码中的依赖关系(模块间的导入关系)或项目管理(显示依赖于其他任务完成的任务)。在这种情况下,我们可能希望找出某种节点排序是否能使一切顺利进行。相反,我们可能希望检查代码是否存在循环依赖,或是否某个任务无法完成。我们希望能够使用图来检测这些问题。

这种任务被称为拓扑排序,它意味着给定一个图,我们按照顺序对其节点进行排序,使得所有的链接“向前”连接,并且不会有从一个顶点指向先前节点的链接。我们将考虑两种排序算法:Kahn 算法,它基于一个简单的计数程序,以及 Tarjan 算法,它应用深度优先搜索,以倒序的方式生成我们所需的排序,其中最后的顶点首先输出。

Kahn 算法

考虑一个基本的论点:如果一个图有拓扑顺序,那么必须至少有一个节点没有入边,而Kahn 算法正是基于这一点。(这类似于说在任何一组数字中,必定有一个数字小于其余的数字。)你可以毫无问题地选择这些没有入边的节点。如果你接着丢弃所有从这些节点开始的边,你应该剩下没有入边的节点,然后你可以重复这个过程。如果在某个时刻你还有节点需要处理,但所有节点都有至少一条入边,那么就无法进行拓扑排序。

图 17-24 展示了该过程,使用的是我们在本章所有示例中使用的相同有向图。首先计算入边的数量,节点中的数字即为计算出的数量。

图 17-24:为拓扑排序设置的图,其中的数字显示每个点的入边数量。

由于你找到了至少一个入边为零的节点,你可以继续。E 点和 F 点可以按任意顺序输出,然后你减少从这两个点可以到达的节点的计数(见图 17-25)。

图 17-25:E 点和 F 点可以输出,因为它们没有入边,并且你“忘记”了这两个点的出边。

E 点和 F 点被输出,它们位于输出数组的第 0 和第 1 位置。(黑色的节点显示的是索引值。)再次,你找到至少一个没有入边的节点,因此 B 点位于输出数组的第 2 位置,并且你减少了 A 和 C 节点的计数(见图 17-26)。

图 17-26:B 点现在没有前驱节点,因此其输出和边被移除。

现在重复该过程:A 点和 C 点被输出,减少计数,最终得到了图 17-27 所示的状态。

图 17-27:接下来是 A 点,然后是 D 点,最后是 G 点。

最后的两步首先输出 D 点,然后是 G 点。图 17-28 展示了最终状态,你希望得到的拓扑顺序是 E、F、B、A、C、D 和 G。

图 17-28:最终结果,数字表示节点输出的顺序。

你可以按如下方式编写算法:

const topologicalSort = (graph) => {
  const n = graph.length;
❶ const queue = [];
❷ const sorted = [];

❸ const incoming = Array(n).fill(0);
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      if (graph[i][j]) {
        incoming[j]++;
      }
    }
  }

❹ incoming.forEach((v, i) => {
    if (v === 0) {
      queue.push(i);
    }
  });

❺ while (queue.length > 0) {
  ❻ const i = queue.shift();
  ❼ sorted.push(i);

  ❽ graph[i].forEach((v, j) => {
      if (v) {
        incoming[j]--;
      ❾ if (incoming[j] === 0) {
          queue.push(j);
        }
      }
    });
  }

 ❿ return sorted.length === n ? sorted : null;
};

将待处理的节点放入队列 ❶,然后将它们放入输出排序数组 ❷。传入数组会为每个节点计算进入边的数量 ❸,每当有一条边进入,计数加 1。没有进入边的节点会被推入队列进行处理 ❹,然后你可以开始排序过程。只要还有节点需要处理 ❺,就从队列中取出它们 ❻,并将它们推入输出列表 ❼。每输出一个节点 ❽,就丢弃它与其他节点的连接,减少进入计数 ❾。当没有更多节点需要处理时,如果所有节点都已排序,则表示成功 ❿;否则,表示失败。剩余的节点至少有一条进入边,这意味着无法进行拓扑排序。

Tarjan 算法

我们将应用深度优先搜索算法,提供另一种生成拓扑排序的方法。其思路是从一个节点开始遍历,标记每个经过的节点(像汉赛尔和格雷特尔那样),并看看在到达死胡同或回到已标记的节点之前能走多远,这意味着你找到了一个循环,因此无法进行拓扑排序。你可以将没有更多可行路径的节点标记为已完成并输出它们,然后从此忽略它们。通过这种方式,你将反向生成拓扑排序:你会首先输出排序中的最后节点,而最前面的节点则最后输出。

图 17-29 展示了我们在本章中使用的相同图形。首先从 A 点开始,标记它为进行中(灰色),并考虑从它出发的所有边。只有一条边。如果在后续步骤中你回到 A 点,并且它是灰色的,那么就意味着你发现了一个循环。

图 17-29:与上一部分相同的图,用于设置 Tarjan 算法。你从 A 点开始,达到了 D 点;A 点被标记为灰色。

现在你处在 D 点,这个点还没有被访问。也将它标记为灰色,并检查你能从它到达哪些节点。在这种情况下,你只能到达 G(见图 17-30)。

图 17-30:在 D 点之后,你到达了 G 点;D 点被标记为灰色。

在 G 点重复该过程,没有边从 G 点出去,因此将其标记为已完成并输出。排序后的列表如下所示,见图 17-31。

图 17-31:从 G 出发,无法到达任何其他点,因此将 G 输出到排序列表中。

处理完 G 后,你可以将 D 标记为完成(并输出它),然后对 A 进行同样的操作(参见图 17-32)。

图 17-32:D 没有与其他节点的连接,因此可以输出 D,然后 A 也可以输出。

当你完成 A 后,开始处理 B。B 到 A 的链接指向一个已标记为完成的节点,因此你忽略它。然而,B 到 C 的链接需要处理:B 被标记为进行中,你去到 C(参见图 17-33)。

图 17-33:从 B 开始,你只能到达一个尚未输出的点:C。

将 C 标记为进行中,但它的唯一边指向一个已标记为完成的节点(G),因此将 C 标记为完成并将其添加到输出中。之后,你也完成了 B 的处理,它也被输出(参见图 17-34)。

图 17-34:C 仅链接到一个已输出的点,因此可以输出 C,之后再输出 B。

最后的步骤类似。从 E 开始,所有链接都指向已标记为完成的节点,因此 E 被标记为完成并输出;F 也是如此,所有节点都已访问并且完成了拓扑排序,如图 17-35 所示。

图 17-35:当所有点都已输出时,算法结束。

下面是该算法的代码。进行中 的节点标记为 1(临时标记),完成 的节点标记为 2(最终标记):

const topologicalSort = (graph) => {
   const n = graph.length;
❶ const marks = Array(n).fill(0); // 1:temp, 2:final
❷ const sorted = [];

❸ const visit = (p) => {
  ❹ if (marks[p] === 1) {
      throw new Error("Not a DAG");
  ❺} else if (marks[p] === 0) {
      marks[p] = 1;
    ❻ graph[p].forEach((v, q) => {
 ❼ if (v && marks[q] !== 2) {
          visit(q);
        }
      });

    ❽ marks[p] = 2;
    ❾ sorted.unshift(p);
    }
  };

  try {
  ❿ marks.forEach((v, i) => {
      visit(i);
    });
    return sorted;
  } catch (e) {
    return null;
  }
};

标记数组 ❶ 跟踪已访问和未访问的节点。0 表示该节点尚未访问,1 表示已访问并且正在遍历所有其可达的节点,2 表示该节点已处理并已输出。排序数组 ❷ 获取算法的输出。你定义一个递归函数 ❸ 来访问从某个起始节点 p 可以到达的所有节点。如果节点被标记为 1 ❹,则意味着从该节点开始,最终会返回到它本身。换句话说,存在一个环,因此无法进行拓扑排序。如果节点被标记为 0 ❺,则暂时将其标记为 1,并访问所有从该节点可达的未访问节点 ❻;跳过访问任何已标记为 2 ❼ 的节点,因为它们已经被分析过了。所有访问完成后,将 1 改为 2 ❽ 并输出当前节点 p ❾;使用 unshift() 来确保正确的输出顺序。要生成拓扑排序 ❿,你只需要从每个可能的节点开始并应用访问逻辑。

这个算法的性能如何?每个节点只被访问一次,且处理它的所有连接,但对于每个节点,它会检查整行是否有可能的连接可以遍历,这使得该实现的时间复杂度为 O(v²)。该算法如果采用邻接表表示会更加高效,因为那样你就可以直接处理一个节点的边,时间复杂度为 O(ve)。

检测循环

另一个需要考虑的问题是图中是否包含任何循环。(换句话说,图是否是树或森林?)例如,在编程时,如果模块之间的依赖列表中存在循环,那就严重出问题了!一个循环检测算法只需要检查它是否能在给定的图中找到至少一个循环。

幸运的是,我们已经看到了一种可以做这种检测的算法:Tarjan 的拓扑排序包含了检测循环的逻辑,所以我们已经有了所需的内容。以下代码直接提取自该算法:

const hasCycle = (graph) => {
  const n = graph.length;
❶ const marks = Array(n).fill(0); // 1:temp, 2:final

  const visit = (p) => {
    if (marks[p] === 1) {
      throw new Error("cycle found");
    } else if (marks[p] === 0) {
      marks[p] = 1;
      graph[p].forEach((v, q) => {
        if (v && marks[q] !== 2) {
          visit(q);
        }
      });

    ❷ marks[p] = 2;
    }
  };

  try {
    marks.forEach((v, i) => {
      visit(i);
    });
  ❸ return false; // no cycles found
  } catch (e) {
    return true;
  }
};

所有的代码都是一样的;这里唯一的区别在于,你没有为输出定义一个排序数组❶。显然,在标记一个节点为完全访问时,你不会添加任何内容❷,并且你返回的是布尔值,而不是数组或 null❸。

检测连通性

现在考虑另一个问题:如何判断一个图是否是完全连通的?一个无向图是连通的,如果图中每一对点之间都有路径;从任何一点都可以到达其他所有点。(一个极限情况是,只有一个顶点的图也被认为是连通的。)如果图不满足这个条件,我们可以将它拆分为两个或更多的连通子图。

有几种算法可以检测给定图是否是连通的;我们这里考虑两种。一种算法引入了另一种数据结构,允许合并集合,另一种使用递归遍历图。

使用集合检测连通性

有一种相当简单的方法可以找出一个图有多少个连通部分。首先,形成不同的集合,每个集合中只有一个顶点。然后,遍历图中的所有边,如果一条边连接了不同集合中的顶点,就将它们合并,形成一个新的、更大的集合。遍历完所有的边后,如果剩下一个集合,说明图是连通的;如果剩下多个集合,说明图是不连通的。

问题是如何实现这些集合。你需要能够判断任意两点是否在同一个集合中,并能够将两个集合合并。通过操作一组树的森林,有一种高效的方法可以做到这一点,正如我们在第十三章中探索的那样。

考虑一下这个概念是如何工作的。每个集合由一棵向上的树表示,指针从叶子指向根(与前几章中做的正好相反)。树的叶子是其元素,必要时会添加中间节点。要查看两个值是否在同一集合中,沿着从每个叶子到根的路径往上走。如果到达相同的根,则这两个值在同一集合中。最后,连接两个集合时,只需添加一个新根,并让两个集合的根指向它。图 17-36 显示了一个初始设置,其中六个值各自属于一个单独的集合。请注意,所有指针隐含的是指向上方的,这与前几章的做法有很大不同。

图 17-36:每个节点从其单独的树开始。

如果你想检查 D 和 E 是否在同一个集合中,沿着指针向上走,看到不同的根时,你可以得出它们不在同一集合中的结论。

要将它们合并为一个集合,只需添加一个新根,你就得到了图 17-37 中显示的情况。

图 17-37:将 D 和 E 放在同一个集合中需要添加一个新根;现在 D 和 E 在同一棵树中。

如果你现在检查 D 和 E 是否在同一个集合中,答案是肯定的,因为沿着指针向上走,你会到达相同的根。如果你问 F 和 D(或者 F 和 E)是否在同一个集合中,答案是否定的,连接这两个集合就得到了图 17-38 中所示的情况。

图 17-38:将 F 添加到之前的(D,E)集合中,又添加了一个新根,现在原来的三棵树已经连接在一起。

你可以通过简单的指针操作来处理这一切,并最终得到一个颠倒过来的森林。将 A 与 C 连接,再将 A 与 E 连接,得到一个新的配置(参见图 17-39)。

图 17-39:在这个最终方案中,你会发现(A,C,D,E,F)是一个集合,而(B)是一个独立的集合。

在图 17-39 中,你只有两个集合:一个单一集合(仅包含 B)和另一个包含所有其他值的集合。

算法很简短:

const isConnected = (graph) => {
  const n = graph.length;

❶ const groups = Array(n)
    .fill(0)
    .map(() => ({ptr: null}));
❷ let count = n;

❸ const findParent = (x) => (x.ptr !== null ? findParent(x.ptr) : x);

  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
    ❹ if (graph[i][j]) {
      ❺ const pf = findParent(groups[i]);
        const pt = findParent(groups[j]);
      ❻ if (pf !== pt) {
          pf.ptr = pt.ptr = {ptr: null};
 ❼ count--;
        }
      }
    }
  }

❽ return count === 1;
};

从仅定义一个元素的单一集合开始❶,计数变量用于跟踪当前有多少个集合❷。辅助函数 findParent(...)从每个节点向上查找,找到其所在集合的根❸。其余的很简单:遍历所有的边❹,检查边的两个端点❺是否在同一个集合中;如果不在同一个集合❻,通过为两个树创建一个新的公共根并减少集合数量❼来合并这两个集合。处理完所有边后,如果剩下的只有一个集合❽,则图是连通的。如果你想知道它有多少个子图,可以检查计数变量。

使用搜索检测连通性

我们将要考虑的第二个算法基于从任何点开始并应用系统的递归搜索。检查你可以到达哪些点,然后检查你从那些点可以到达哪些点,以此类推,直到你检查完所有的边。每次从某个点开始访问时,将其标记为已访问,以避免再次访问它。

给定你一直在使用的相同图,见图 17-40,并且从 A 开始,哪个节点会是第一个访问的节点?

图 17-40:为了检查连通性,从任何一个点开始,本例中从 A 开始并标记它。

从 A 出发,你可以到达 B、D 和 E,并且这些点都没有被访问过。将它们标记并从 B 开始搜索,如图 17-41 所示。

图 17-41:从 A 你可以到达未标记的点 B、D 和 E,并将其标记。

从 B 你可以到达 A 或 E,但这两个节点已经被标记为已访问,因此只需将 C 添加到搜索中(参见图 17-42)。

图 17-42:从 B 你可以添加尚未标记的 C,从 D 你可以添加尚未标记的 F 和 G。

从 D 出发,你可以到达 A、E、F 和 G,但 A 和 E 已经被标记,因此只需将 F 和 G 添加到流程中(参见图 17-43)。

图 17-43:所有点都已标记,因此图是连通的。

算法的其余部分很快,因为从 E、C、F 或 G(按照你检查它们的顺序)没有剩余的未标记节点可以到达,因此你已经完成了,并且因为所有顶点都已标记,所以图是连通的。如果存在一个独立的子图,你将无法到达它,因此它的节点将保持未标记,算法将返回 false。

你也可以采用深度优先搜索方式进行搜索,这实际上更容易编码:

const isConnected = (graph) => {
  const n = graph.length;
❶ const visited = Array(n).fill(false);

❷ const visit = (x) => {

  ❸ graph[x].forEach((v, i) => {
    ❹ if (v > 0 && !visited[i]) {
      ❺ visited[i] = true;
      ❻ visit(i);
      }
    });
  };

❼ visited[0] = true;
❽ visit(0);

❾ return visited.every((x) => x);
};

首先将所有点标记为未访问❶。辅助访问(...)递归函数❷进行搜索。给定一个点,它会遍历所有出去的边❸。如果它找到一个未访问的点❹,则标记该点❺并访问它❻。要运行算法,首先将任何一个点(本例中为第一个点)标记为已访问❼,然后调用访问函数❽进行搜索。如果你完成算法并且每个点都被标记为已访问❾,则表示成功。

寻找最小生成树

这个问题适用于加权无向图。假设我们想将人们连接到电网或其他类似服务,并且我们知道连接给定一对点的成本。我们不需要构建所有可能的点连接;而是要选择一个集合,以最小成本使所有的顶点连接到一起。几种算法可以解决这个问题,我们将在这里讨论两个最著名的算法:Prim 算法Kruskal 算法。如果这些算法应用于连接图,则输出将是一个连接所有节点的树。如果图不是连通的,我们会为每个独立的节点组找到一片树的森林。

Prim 算法

Prim 算法的描述很简单:构建树时,从任意节点开始,持续添加尚未连接到树中的最近节点(即,最小连接成本的节点),直到没有节点可选。可以证明,这样会产生所需的最小生成树,但我们不会在这里进行证明。

从我们一直在使用的相同无向图开始(见图 17-44),并任意选择 A 作为起始节点。

图 17-44:Prim 算法从选择任意一个节点开始。在这个例子中,从 A 开始。

现在,所有的点仍然没有被选择,所以选择离当前节点最近的点,在此例中是 B(见图 17-45)。

图 17-45:在所有与 A 相邻的点中,选择最近的,即 B。

你已经在生成树中选中了两个节点。重复选择:离 A 或 B 最近且尚未选择的点是 D,因此将其添加进去(见图 17-46)。

图 17-46:在 A 或 B 相邻的节点中,你再次选择离它们最近的节点,即 D。

接下来的步骤很容易预测:首先,添加 F(它距离已选节点仅三单位),然后是 E、C,最后是 G,总成本为 30 单位,如图 17-47 所示。

图 17-47:完成所有节点后,得到一个生成树。

编写代码很简单。为了找出距离当前节点最近的未选择的点,你再次使用堆。实现方式略有不同:这次,你将在堆中使用包含属性的对象,这些属性来自(一个点)、到(离当前选择点最近的点)和 dist(到最近点的距离,即这两个点之间边的长度)。首先探索堆算法,这些算法是迭代实现的,而不是递归实现,仅仅是为了多样性:

 const bubbleUp = (i) => {
  ❶ while (i > 0) {
      const p = Math.floor((i - 1) / 2);
    ❷ if (heap[i].dist > heap[p].dist) {
        return;
      }
    ❸ [heap[p], heap[i]] = [heap[i], heap[p]];
    ❹ i = p;
 }
  };

  const sinkDown = (i) => {
  ❺ for (;;) {
      const l = 2 * i + 1;
      const r = l + 1;
      let m = i;
      if (l < heap.length && heap[l].dist < heap[m].dist) {
        m = l;
      }
      if (r < heap.length && heap[r].dist < heap[m].dist) {
        m = r;
      }
      if (m === i) {
      ❻ return;
      }
    ❼ [heap[m], heap[i]] = [heap[i], heap[m]];
    ❽ i = m;
    }
  };

在上浮一个值时,检查你是否已经在顶部❶。如果不是,计算它父节点的位置并比较距离;如果父节点的位置更低❷,则完成。如果不是,交换堆中的位置❸,并在父节点的位置重复此过程❹。要使一个值下沉,设置一个无限循环❺,当该值无法下沉更低❻时退出,因为它小于其子节点。如果该值需要下沉,则进行交换❼,并在子节点的位置再次循环❽。

Prim 算法的代码如下:

const spanning = (graph) => {
  const n = graph.length;

❶ const newGraph = Array(n)
    .fill(0)
    .map(() => Array(n).fill(0));

❷ const heap = Array(n)
    .fill(0)
    .map((v, i) => ({from: i, to: i, dist: +Infinity}));

  //  bubbleUp and sinkDown, excluded

❸ while (heap.length) {
  ❹ const from = heap[0].from;
  ❺ const to = heap[0].to;

  ❻ newGraph[from][to] = graph[from][to];
    newGraph[to][from] = graph[to][from];

  ❼ heap[0] = heap[heap.length – 1];
    heap.pop(); // or the more unconventional heap.length--;
    sinkDown(0);

 ❽ for (let i = 0; i < heap.length; i++) {
    ❾ const v = heap[i];
      const dist = graph[v.from][from];
    ❿ if (dist > 0 && dist < v.dist) {
        v.to = from;
        v.dist = dist;
        bubbleUp(i);
      }
    }
  }

  return newGraph;
};

首先,设置输出图的 newGraph 矩阵❶和包含所有点的堆:from 属性是该点本身,to 属性是最接近的已选择点,dist 属性是该点到生成树中已选择点的最短距离❷。当堆不为空时(意味着你还没有考虑完所有的顶点),考虑堆顶元素❸,它是距离已选择点最近但尚未被选择的点。连接❹到❺的路径对应着最短的待处理距离,所以将其加入 newGraph❻。然后从堆中弹出节点❼,并调整 from 点与所有剩余堆点之间的距离❽。接着,考虑每个堆元素❾,计算它到 from 点的距离;如果有一条更短(更便宜)的边❿,记录下这条边及其对应的距离,并根据需要让该节点上浮。 (因此,堆中的点始终与已选择的点之间具有最短距离。)当堆为空时,你将在 newGraph 矩阵中得到生成树。

克鲁斯卡尔算法

克鲁斯卡尔算法同样可以找到无向图的最小生成树。与 Prim 算法逐个添加点不同,该算法通过将边加入一个初始为空的图来工作。其思想是将所有边按升序排序,并尝试添加每条边,除非它会形成一个环。(我们也不会给出该算法正确性的证明,但请放心,它是可以证明的。)如何检测环的存在?最开始时,所有点都在不同的、不相交的集合中,每当你添加一条连接两个节点的边时,就将相应的集合合并(这类似于“使用集合检测连通性”一节中的过程,参见第 454 页)。绝不添加两个端点都属于同一集合的边。

现在,使用相同的示例图来探讨算法的工作原理(见图 17-48)。

图 17-48:用于 Prim 算法的相同图

每次添加一条边,从最小的开始,第一步添加(C,E)边;现在 C 点和 E 点在同一集合中,如图 17-49 所示。

图 17-49:添加最小的边(C,E)开始。

接下来的两步分别添加边(D,F)和(A,B);没有形成环,如图 17-50 所示。

图 17-50:继续按边的升序添加,如果它们不会形成环。首先添加(D,F),然后是(A,B)。

接下来的步骤添加(A,D),因此 A、B、D 和 F 都属于同一个集合,然后添加(B,E),这会形成一个包含 A 到 F 所有点的大集合(见图 17-51)。

图 17-51:重复此过程现在添加了(A,D)。

现在事情变得有趣了!下一个顺序的边是(D,E),但是 D 和 E 已经在同一个集合中,因此不要添加这条边。接下来的步骤添加(E,G),你得到最终的树,如图 17-52 所示。

图 17-52:你应该添加(D,E),但这会创建一个环,因此跳过它并改为添加(E,G)。

后续步骤不会添加任何内容,因为边将始终连接已经在同一集合中的点,因此你已经得到了生成树。

克鲁斯卡尔算法如下:

const spanning = (graph) => {
  const n = graph.length;

❶ const newGraph = Array(n)
    .fill(0)
    .map(() => Array(n).fill(0));

❷ const edges = [];
  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
      if (graph[i][j]) {
        edges.push({from: i, to: j, dist: graph[i][j]});
      }
    }
  }
❸ edges.sort((a, b) => a.dist – b.dist);

❹ const groups = Array(n)
    .fill(0)
    .map(() => ({ptr: null}));

❺ const findParent = (x) => {
    while (x.ptr) {
      x = x.ptr;
    }
    return x;
  };

❻ edges.forEach((v) => {
  ❼ const pf = findParent(groups[v.from]);
    const pt = findParent(groups[v.to]);
  ❽ if (pf !== pt) {
    ❾ pf.ptr = pt.ptr = {ptr: null};
      newGraph[v.from][v.to] = newGraph[v.to][v.from] = graph[v.to][v.from];
    }
  });

 ❿ return newGraph;
};

从创建一个空矩阵开始,用于新的树❶。然后生成图中所有边的列表❷并使用最简单的方法进行排序❸,即 JavaScript 自带的方法。(对于更好的方法,请查看问题 17.8。)现在你需要初始化所有不相交的集合❹,就像之前在检测连通性时做的那样。groups 数组将有一个指向每个集合根节点的指针,所有集合都会从一个元素开始。你将使用之前递归的 findParent(...)函数的迭代版本❺来找到节点属于哪个集合。算法的其余部分如下:遍历已排序的边列表❻,对于每一条边,查找其两个端点的父节点❼。如果它们不匹配❽,通过创建一个新的根节点来合并两个集合❾,并将这条边添加到输出图中,最后返回❿。

算法的性能可以表示为O(e log e),基本原因是你必须对所有边进行排序,然后遍历列表,可能会连接集合,这也会产生相同的结果。该实现的唯一缺点是获取节点列表的时间复杂度是O(v²),因为需要遍历整个矩阵,但如果采用邻接表的图表示方法,如我们所见,可以进行优化。### 总结

本章介绍了图的概念。我们考虑了图的表示方法,并研究了许多常见需求的算法,如查找路径或距离、排序节点、检测环路和最小化成本。这些算法也受益于之前的算法(如排序和搜索)和数据结构(堆、位图、树、森林和列表),为你提供了多种方式来应用先前获得的知识。

在本书的下一章也是最后一章,我们将讨论面向完全函数式编程风格的数据结构的具体考虑,这种风格带来了一些优势,但也有一些具有挑战性的劣势。

问题

17.1  路径在哪?

Floyd-Warshall 算法用于找到每对点之间的最短距离,但如果你还想知道该走哪条路径,该怎么办呢?修改算法,使得找到路径变得简单。提示:每当你发现从ij经过k更好时,做个标记,这样以后在尝试找到实际路径时,你就知道该走到k

17.2  尽早停止搜索

在考虑 Bellman-Ford 算法时,我们提到过一定次数的遍历确保能够找到最短路径,但你能做得更好吗?提示:在我们展示的例子中,实际上只需要较少的遍历。

17.3  只需要一个就好

如果你只关心找到到达一个单点的最短路径,你会如何修改 Dijkstra 算法?

17.4  错误的方式

假设你拿到一个有向图,反转它的所有边,然后对其应用 Kahn 的拓扑排序算法。这个算法的输出是什么?

17.5  更快地合并集合

在“使用集合检测连通性”这一节(第 454 页)中,当你合并两个不同的集合时,你总是添加一个新的根节点,但这样做并不是必要的,因为你可以让一个根指向另一个根。考虑在每个根节点中添加一个大小属性(表示对应子树中的节点数),然后将较小的树作为子树合并到较大的树中。你能实现这些修改吗?

17.6  走捷径

在合并集合时,提前做一些工作可以节省后面的时间。再看一下图 17-39,这是“使用集合检测连通性”这一节中的图,位于第 454 页。假设你想知道 C 和 D 是否在同一组。你需要从这两个节点走到根节点才能找到答案。然而,如果你之后再次被问到关于 C 或 D 的问题,你就得重新走一遍路径,除非你修改了一些链接,如图 17-53 所示。

图 17-53:一种优化的集合合并算法

三个链接被修改为直接指向根节点,这样你可以更快速地到达那里。(从 C 或 D 到根节点只需一步,从 E 到根节点比之前少一步。)在 findParent(...)函数中做出修改,使其创建“捷径”路径,从而加速以后的处理。

17.7  树的生成树?

如果你对一个树应用生成树算法,会发生什么?

17.8  一堆边

你能用堆排序、快速排序或书中讨论的其他方法替换 JavaScript 的排序吗?

第十八章:18 不变性与函数式数据结构

我们已经考虑了几个抽象数据类型(ADT)、数据结构和算法。让我们通过考虑一个不仅与函数式编程相关,而且与像 Redux 这样的库在 React 页面开发者中的日常使用相关的方面来结束这本书。我们如何处理数据结构,而不是对它们进行更改,而是以真正的函数式风格生成新的数据结构?为了做到这一点,我们需要考虑一个新概念:持久性(或函数式数据结构,通过这种方式,我们可以在不需要克隆所有内容以获得高性能的情况下更新它们,并且它们还允许我们查看以前状态的“历史”,如果需要的话,还能回滚更改。

函数式数据结构

让我们从几个定义开始。持久性数据结构有一个有趣的属性,你可以在保持先前版本不变的情况下更新它们。这一属性自动意味着这些结构非常适合纯函数式编程语言,这些语言不允许副作用,正如在第二章中提到的。它意味着它们也是函数式数据结构:如果你不修改数据结构,而是生成一个新的数据结构,那么你将同时拥有旧版本和新版本。

让我们分析几种数据结构,其中大多数我们已经在书中讨论过,看看我们是否可以使它们变得函数式。

数组(和哈希表)

我们先从坏消息开始。数组本质上是可变的数据结构,并且没有简单的方法可以实现一个具有相同性能水平的函数式等效物,即O(1)用于访问和更新数组元素。数组支持破坏性更新,而这种更新是无法撤销的。一旦你修改了数组中的某个位置,就无法找回先前的值。事实上,数组与持久性数据结构是完全相反的。

为了绕过这个限制,一种常见的技术是使用平衡二叉搜索树,以索引作为键,但这样做需要O(log n)的时间。其他一些更复杂的技术已经被探索过,但其性能仍然与直接使用数组不同。如果你想了解更多内容,可以在线搜索梅丽莎·奥尼尔(Melissa O’Neill)和 F. 华伦·伯顿(F. Warren Burton)的方法(这不会是一本容易阅读的书)。

对数组得出的一个相关结论是,你不会有哈希表或其多种变体的良好等效物,这使得它们成为另一个需要用潜在较慢的结构来替换的结构。

我们研究函数式数据结构的开始可能会让人感到沮丧,但请放心,我们将能够找到许多之前在本书中考虑过的结构的等效物。

函数式列表

现在考虑最简单的结构,链表,来自第十章。一些类型的列表非常适合函数式工作风格。其他类型(如队列)需要“转换”才能使其函数化,还有一些列表没有函数式的等效物。

普通列表

当我们定义列表时(参见第 178 页的“基本列表”部分),给定一个位置,你希望能够在该位置添加一个新值或删除已有的值。考虑第一个操作。你可以通过复制列表的初始部分来实现这一点。图 18-1 展示了你在第十章中查看的一个列表,然后是你在位置 3 添加 80 值后的列表。(记住,位置 0 是第一个位置,就像数组一样。)该操作涉及到添加一个节点并修改已经存在的节点中的指针。

图 18-1:在列表中插入一个节点需要更改原始节点,即指向新节点的那个节点。

考虑到函数式结构,图 18-2 展示了实现方式。

图 18-2:以函数式方式进行插入会复制一些节点,但保持原始节点不变。

除了具有 80 值的新节点外,还有一些节点重复了先前列表中的值,但你保持了列表的一部分不变。你不需要重新做整个列表。新节点和链接有较粗的线条,而被丢弃的节点则以淡灰色显示。

以这种方式工作的代码使用递归:

const isEmpty = (list) => list === null;

const add = (list, position, value) => {
❶ if (isEmpty(list) || position === 0) {
   return {value, next: list};
❷} else {
   return {value: list.value, next: add(list.next, position - 1, value)};
  }
};

如果在第一个位置❶添加元素,返回一个包含新值并指向原始列表的节点。如果列表最初为空,这也适用。否则,当列表不为空且你想在零位置之外的其他位置添加值时,创建一个新节点❷,该节点具有相同的值,位于列表头部,并链接到在原始列表尾部添加新值的结果。

现在,考虑从列表中删除元素。回到最初的列表,假设你想删除位置 2 的 60。 图 18-3 展示了删除前后的列表。

图 18-3:从列表中删除一个节点也意味着修改某个原始节点。

为了以函数式方式工作,复制列表的初始部分,如图 18-4 所示。

图 18-4:用于插入的相同解决方案也有助于以函数式的方式处理删除操作。

与列表添加一样,一些节点(以淡灰色显示)不再包含在内。列表的一部分由新节点(粗线条)组成,另一部分保持不变,没有重新创建整个结构。考虑以下实现:

const remove = (list, position) => {
❶ if (isEmpty(list)) {
   return list;
❷} else if (position === 0) {
   return list.next;
❸} else {
   return {value: list.value, next: remove(list.next, position - 1)};
  }
};

使用递归使得逻辑更加清晰。如果你想从空列表中移除一个元素❶,你什么也做不了;直接返回空列表。如果列表不为空,且你想移除第一个元素❷,新列表将是以原来第一个元素后面的元素为开头的列表。最后,如果列表不为空,且你想移除第一个以外的元素,构造一个新列表❸,它的头部元素与原列表的头部相同,并指向从剩余列表中移除该元素后的结果。

至于性能,我们再次发现所有操作都是O(n),尽管创建节点的额外操作可能意味着实现会变慢。此外,由于我们仍然使用的是普通链表,之前看到的其他方法(如查找指定位置的值或计算列表大小)仍然像以前一样有效。为了实现常见链表的函数式版本,你只需更改实际修改列表的两个方法。

让我们继续讨论用于其他 ADT(抽象数据类型)的更专业版本的列表。

我们考虑的第一个列表变体是栈,它的限制是所有的添加(“推送”)和移除(“弹出”)操作都发生在列表的一端,即它的“顶部”。回顾一下,我们之前的实现已经是一个功能性的数据结构,看到这一点会是一个惊喜。复习一下第十章中的图示。当你推送一个值时,你会遇到图 18-5 所示的情况。

图 18-5:栈已经以函数式的方式执行推送操作...

更新后的栈共享大部分结构;唯一的区别是新的顶部元素。弹出顶部值的行为类似,如图 18-6 所示。

图 18-6:...弹出操作也适用于此。

与推送操作一样,你更新了栈而没有修改其中的任何值或指针。原始实现已经完全可以使用。这两个操作的性能仍然是O(1),因此不能进一步增强。然而,我们并不总是如此幸运。

队列

队列存在挑战。它们也将操作限制在列表的两端:你在一端(队列的“尾部”)入队(插入)值,并从另一端(队列的“前端”)出队(删除)它们。你还使用了链表作为队列的基础,如图 18-7 所示。

图 18-7:你可以以函数式的方式出队一个节点,就像弹出栈一样。

从队列中移除前端元素(22)与从栈中弹出一个值完全相同,因此这是可行的。更新后的队列将仅移除其前端元素,原本的第二个元素(09)将成为新的第一个元素。

然而,在此示例中,排入新值会导致问题。您必须修改包含 56 的节点,而这需要修改包含 04 的节点,以此类推,最终您将不得不创建队列的整个副本。(这等同于在之前“常见列表”一节中描述的第 470 页中,在简单列表的末尾添加一个值。)我们能做得更好吗?答案是肯定的,但我们需要一个巧妙的技巧:使用一对栈来表示队列。

想一想在某一时刻,包含五个值 A(第一个)到 E(最后一个)的队列会是什么样子,如图 18-8 所示。(参见问题 18.1。)

图 18-8:您可以做功能性队列,但需要两个栈:“后端”和“前端”。

队列被分成两个栈。考虑一下您是如何到达这一状态的。您通过将元素推入栈的“后端”来进入队列,通过从“前端”栈弹出元素来退出队列。例如,如果 F 要进入队列,您会得到图 18-9 中显示的情况。

图 18-9:新节点通过推入“后端”栈来排队。

如前所示,您可以在栈中按功能方式推入元素,所以一切正常。如果某个值要离开队列,您可以弹出它并查看图 18-10 中显示的状态。

图 18-10:出队一个节点意味着从“前端”栈弹出,所以这也是按功能方式完成的。

弹出栈也是按功能方式完成的,所以一切依然正常。在进行另一次退出后,您会得到图 18-11 中显示的情况,而问题就出现了——前栈为空。

图 18-11:如果“前端”栈为空,您如何出队一个节点?

既然前栈为空,我们如何处理下一个退出操作呢?这种队列表示法的关键如下:如果您需要退出队列,而前队列为空,请将所有值从后队列弹出并推入前队列(参见图 18-12)。

图 18-12:将所有元素从“后端”栈弹出到“前端”栈。

在此过程之后(反转了后端栈),您将能够继续退出队列,并且所有操作都会按正确的顺序完成。这看起来像是一个小把戏,但它有效,并且由于所有涉及的操作都是按功能方式完成的,最终结果是一个用于表示队列的功能性数据结构。

我们用两个栈来表示队列,因此构建队列和检查队列是否为空的基本方法如下:

❶ const newQueue = () => ({backPart: null, frontPart: null});

❷ const isEmpty = (queue) =>
  queue.backPart === null && queue.frontPart === null;

一个新的队列❶由两个空栈组成,测试队列是否为空❷只需要验证两个栈的顶部是否为 null。

您可以通过将新值推入列表的后端来进入队列,您已经知道如何做到这一点:

const enter = (queue, value) => ({
❶ backPart: {value, next: queue.backPart},
❷ frontPart: queue.frontPart
});

你返回一个新的队列,将新值推入栈的后端❶,前端❷保持不变。

当退出队列时,情况变得有些复杂。如前所述,如果前端不为空,只需弹出第一个元素;但是如果列表为空,就将整个后端逐个元素推入前端:

const exit = (queue) => {
❶ if (isEmpty(queue)) {
    return queue;
❷} else {
    let newfrontPart = queue.frontPart;
    let oldbackPart = queue.backPart;
  ❸ if (newfrontPart === null) {
    ❹ while (oldbackPart !== null) {
        newfrontPart = {value: oldbackPart.value, next: newfrontPart};
        oldbackPart = oldbackPart.next;
      }
    }
  ❺ return {backPart: oldbackPart, frontPart: newfrontPart.next};
  }
};

首先检查队列是否为空❶,因为如果为空,就没有操作需要进行;你会返回相同的未改变的队列。如果不为空❷,检查前端栈是否为空。如果为空❸,你需要执行一个循环❹,从后端栈弹出值并将其推入前端栈。最后,确认前端不为空❺,你只需返回一个新的队列,其中包含(可能已清空的)后端和弹出前端部分顶部元素的结果。

这种基于栈的队列性能如何?进入队列始终是O(1),但退出队列可能是O(1)或O(n)。然而,从摊销角度来看,你可以看到每个项目将被推入一次(在后端),弹出一次(从后端),再次推入一次(在前端),并最终再次弹出一次(从前端),这些都是四个常数时间操作。在许多操作的历史中,摊销性能是O(1),因为每个值将通过四个O(1)的操作。弹出后端并推入前端的O(n)成本被“稀释”了,因为在第一次将n个值推入空前端后,接下来的n次退出将是O(1)。最终的平均时间是O(1)。

有一个额外的操作,front(...),用于访问队列前端的值;见第 18.2 题。

其他列表

我们已经考虑了几种类型的列表,那么其他类型呢?我们无法为双端队列(或更一般的双向链表)或循环列表提供函数式等价物,因为修改一个节点意味着至少另一个节点必须被修改,而这又意味着其他节点必须变化,依此类推。尝试以函数式方式更新这些结构最终会创建一个完整的副本,这样效率不高。

函数式树

在第十二章到第十六章中,我们探索了各种树结构:二叉搜索树、普通树、几种风格的堆等等。其中一些(但并非全部,遗憾的是)允许以函数式方式工作。

二叉搜索树

我们如何才能使二叉搜索树以函数式方式运作呢?通常,我们会应用与列表相同的解决方法,在需要的地方创建新的节点。首先考虑如何将新值添加到来自第十二章的树中(参见图 18-13)。

图 18-13:一个你希望以函数式方式维护的二叉搜索树

在第十二章中,你添加了一个新的 34 值,它成为了 24 节点的右子节点。你可以在这里做同样的操作,而不需要修改现有的树。解决方案在于添加一些新的节点,如图 18-14 所示。

图 18-14:添加一个新值意味着创建一些新的节点。

有了新的根节点,并且你也有新的节点,一直到新增的节点本身,但树的其余部分保持不变。还有一些节点(以灰色显示)不再是树的一部分,因为它们被新的节点替代了。通过新根访问树时,你会找到新添加的 34 值,而通过旧根访问树时,得到的结构与之前完全相同。你成功创建了一个新的树,增加了新的值,但没有修改原有结构。

以这种功能性方式添加新值所需的代码很少:

const add = (tree, keyToAdd) => {
❶ if (isEmpty(tree)) {
   return newNode(keyToAdd);
❷} else if (keyToAdd <= tree.key) {
   return newNode(tree.key, add(tree.left, keyToAdd), tree.right);
❸} else {
   return newNode(tree.key, tree.left, add(tree.right, keyToAdd));
  }
};

如果你想向一棵空树添加一个值❶,你只需要一个带有该值的新节点。(提醒一下,参见下面的 newNode(...)函数。)如果你想添加一个键值到左子树❷,你需要返回一棵新树,它的值与当前根节点相同,左子树是递归更新后的,右子树保持不变。如果新值需要进入右子树❸,结果类似:你会返回一棵新树,值与当前节点相同,左子树与当前节点相同,右子树是更新后的:

const newNode = (key, left = null, right = null) => ({key, left, right});

现在,你将开始考虑删除节点,并讨论最复杂的情况:删除一个有两个子节点的节点(你可以处理更简单的情况)。图 18-15 展示了原始树(与添加节点时使用的是同一棵树)。

图 18-15:如果一个节点(如 9)有两个子节点,删除它会稍微复杂一些。

要删除节点 9,你必须找到下一个更大的键值(在此案例中是 10),并交换它的位置。它需要从当前位置删除,并取代 9 的位置。以功能化的方式工作时,你再次创建一些新的节点,如图 18-16 所示。

图 18-16:删除一个有两个子节点的节点需要从根节点重新创建节点。

你有一个新的根节点,并且你也重新创建了到达你要删除的节点路径上的节点。为了从树中删除 10,你也应用了功能化方法,因此在该子树(根节点是 11 的子树)中,你也需要创建一些新的节点。删除节点的代码有几种情况:

const remove = (tree, keyToRemove) => {
❶ if (isEmpty(tree)) {
   return tree;
❷} else if (keyToRemove < tree.key) {
   return newNode(tree.key, remove(tree.left, keyToRemove), tree.right);
❸} else if (keyToRemove > tree.key) {
   return newNode(tree.key, tree.left, remove(tree.right, keyToRemove));
❹} else if (isEmpty(tree.left) && isEmpty(tree.right)) {
   return null;
❺} else if (isEmpty(tree.left)) {
   return tree.right;
❻} else if (isEmpty(tree.right)) {
   return tree.left;
❼} else {
  ❽ const rightMin = minKey(tree.right);
  ❾ return newNode(rightMin, tree.left, remove(tree.right, rightMin));
  }
};

这段代码与你在第十二章看到的非常相似,但每次你都会返回新的树,而不是修改节点。如果树为空 ❶,直接返回原树。如果要删除的值小于当前节点的值 ❷,返回一个新树,包含当前节点的值和右子节点,但其左子节点指向一个新树,这个新树是删除左子树中的值后的结果。类似地,如果要删除的值大于当前节点的值 ❸,按对称方式进行:返回一个新树,包含当前节点的值和左子节点,但其右子节点指向删除右子树中值后的结果。当你找到要删除的节点并且它是叶节点 ❹时,直接返回一个空树。如果它不是叶节点但只有一个子节点(如果是右子节点 ❺,左子节点 ❻),返回一个只有非空子节点的树,去掉被删除的节点。最后,在最复杂的情况下,如果你需要删除一个有两个非空子树的节点 ❼,找到其右子树中的最小值 ❽,并返回一个以该值为根的新树,原节点的左子树在左侧,删除右子树中最小值后的结果在右侧 ❾。

你已经看到过 minKey(...)函数的实现,它可以找到二叉搜索树中的最小值,但考虑一种新版本的实现,仅为增加一些变化(再加上简短的一行代码实在让人无法抗拒):

const minKey = (tree) => (isEmpty(tree.left) ? tree.key : minKey(tree.left));

对于我们看到的所有树的变种(如 AVL 树、红黑树、伸展树等),你可以应用一些变体的方法,类似于前面删除代码中展示的方法。在添加或删除一个键后,新的树将以新的根节点和几个新节点结束,但其中许多部分保持不变,没有变化。处理过程的执行顺序也将与之前相同,因此,虽然增加了一些复杂性,但我们可以得到所有这些树的功能版本。

其他树

我们还考虑了其他种类的树;特别地,第十四章和第十五章专门讲解了堆,它们基本上是二叉树或其变种。一个简单的版本是基于数组构建的,因此我们在这里陷入了困境,没有简单的解决方案。对于其他基于动态内存和指针的堆(如 treap、skew heap、Fibonacci heap 等),我们可以像处理对象和二叉树一样,应用相同类型的解决方案。

那么,数字树(包括 trie 树、基于对象的 trie 树、基数树和三叉树)怎么办呢?Trie 树在每个节点中都有一个数组,指向该节点的所有子节点,但本质上,它与二叉树具有两个指针是一样的。我们为二叉树看到的相同类型的解决方案同样适用,更新 trie 树时,你将得到一个新的结构,包含一些新节点,但大多数节点是共享的。图 18-17 展示了这个解决方案,其中的 trie 树是你在第十七章看到的。

图 18-17:你也可以以函数式方式维护字典树。

在第十七章,我们展示了如果添加单词 ABE,字典树会如何被修改。以函数式方式操作时,你会有一个新的根节点,并且其他地方也有一些新的节点。图 18-18 展示了结果。

你有一个新的根节点,一些新的节点(带有较深的边框)以及一些新的链接,但大部分结构仍然和之前一样。两个旧的节点(灰色的)不再是字典树的一部分,就像二叉树一样,但仅此而已。基本过程是相同的,它同样适用于基数树和三叉树,但在这里不会显示。它始终是相同类型的解决方案。

图 18-18:添加一个新词需要创建一些新的节点,但大部分字典树结构保持不变。

最后,对于基于 JavaScript 对象的字典树,你可以简单地应用本章前面描述的关于列表和树的相同思路。在第十六章中,你考虑了一个基于对象的字典树示例;图 18-19 再次展示了它。

图 18-19:你也可以以函数式方式更新基于对象的字典树。

在第十六章中,我们看到了如何将单词 ABE 添加到字典树中。你是通过修改多个对象来实现的;现在你将通过添加一些新对象,并保持大部分旧结构不变的方式,以函数式方法完成此操作,正如图 18-20 所示。

图 18-20:添加一个新词并重新创建一些对象

和之前的字典树示例一样,新的节点有较深的边框,而被删除的节点的边框则是较浅的灰色。大部分的字典树结构并不需要改变。因此,你可以通过稍微调整更新算法来实现各种类型(虽然不是所有)的堆和各种数字树,但始终沿用我们已经应用过的相同思路。 ### 总结

在本章中,我们已经通过考虑函数式数据结构来完成了数据结构和算法的概述,这些结构可以在没有副作用的情况下进行更新,并以高效的方式生成新版本。

使用函数式数据结构可能需要保持一定的平衡。一方面,在清晰度和可维护性上具有优势,因为数据结构何时何地被修改是明确的。然而,另一方面,我们可能会发现一些原本具有良好性能的操作被较慢的操作所替代。

有没有办法避免这些问题?不幸的是,这并不容易。如前所述,目标是以务实的方式应用函数式编程,因此我们将在可能的情况下使用函数式数据结构,但在需要时接受“常见的”可修改结构。例如,处理数组通常是必需的,性能较低的解决方案可能会对性能产生很大影响。你必须灵活应对,知道何时选择什么。

问题

18.1  到达这里

进入和退出队列的最小操作序列是什么,这个序列能生成图 18-7 中所示的图形?

18.2  向阿博特和科斯特罗道歉,谁在前面?

你能实现一个front(q)方法,用于返回函数式风格队列 q 的前端值吗?

18.3  无需更改

在从二叉搜索树中移除一个键时,即使该键不存在,算法也会生成一个新的树。你能修改它,使得在这种情况下返回原始未改变的树吗?

18.4  新的最小值

你能解释一下第 478 页“二叉搜索树”章节中 minKey(...)新版本是如何工作的?

第十九章:答案解析

第二章

2.1  纯粹或不纯?

从纯粹的形式角度来看,这个函数是不纯的,因为它使用了未作为参数提供的内容。考虑到 PI 是一个常量,你或许可以稍微放宽要求并接受它;毕竟,由于 const 定义,没有人能修改 PI。最好的解决方案是使用 Math.PI,而不依赖任何外部变量,无论它是否是常量。

2.2  为失败做准备

你必须使用 try 和 catch;以下是一个可能的解决方案:

❶ const addLogging = (fn) => (...args) => {
❷ console.log(`Entering ${fn.name}: ${args}`);
  try {
  ❸ const valueToReturn = fn(...args);
  ❹ console.log(`Exiting ${fn.name} returning ${valueToReturn}`);
  ❺ return valueToReturn;
  } catch (thrownError) {
  ❻ console.log(`Exiting ${fn.name} throwing ${thrownError}`);
  ❼ throw thrownError;
  }
};

addLogging() 高阶函数接受一个函数作为参数❶并返回一个新函数。如果原函数抛出异常,则捕获它并输出适当的消息。在进入时❷,记录函数的名称和它被调用时的参数。然后尝试调用原函数❸,如果没有问题,则记录返回值❹并将其返回给调用者❺。如果抛出异常,则记录它❻并再次抛出,以便调用者处理❼。

2.3  有时间吗?

以下函数可以满足你的需求;你应该注意它与前一个问题中的日志记录函数有几个相似之处。

❶ const {performance} = require("perf_hooks");

❷ const addTiming = (fn) => (...args) => {
❸ const output = (text, name, tStart, tEnd) =>
    console.log(`${name} - ${text} - Time: ${tEnd - tStart} ms`);

❹ const tStart = performance.now();
  try {
  ❺ const valueToReturn = fn(...args);
  ❻ output("normal exit", fn.name, tStart, performance.now());
  ❼ return valueToReturn;
  } catch (thrownError) {
  ❽ output("exception thrown", fn.name, tStart, performance.now());
  ❾ throw thrownError;
  }
};

在 Node.js 中❶,你需要导入 performance 对象;在浏览器中,它直接可用。(有关更多信息,请参见 nodejs.org/api/perf_hooks.htmldeveloper.mozilla.org/en-US/docs/Web/API/Performance。)addTiming() 函数❷将接受一个函数作为参数并返回一个新函数。你将使用一个辅助函数❸来输出计时数据。在调用原函数之前❹,存储开始时间;如果调用成功❺且没有问题,则输出原始时间和当前时间❻,并返回原始返回值❼。如果发生任何错误,则输出不同的消息❽并再次抛出相同的异常❾,这样计时函数将完全与原函数的行为一致。

2.4  解析问题

问题在于 .map() 将三个参数传递给你的映射函数:数组的当前元素、其索引和整个数组。(有关更多内容,请参见 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map。)另一方面,parseInt() 接收两个参数:要解析的值和一个可选的基数(如果未传递,默认值为 10)。在此案例中,parseInt() 收到三个参数:它忽略第三个多余的参数,但使用数组索引作为基数。例如,“8”会被解析为基数 3 的数字,导致错误,因为基数 3 只使用数字 0、1 和 2,而“32”会被解析为基数 5 的数字,相当于 17。

2.5  否定一切

一行代码就足够了:

const negate = (fn) => (...args) => !fn(...args);

2.6  每个,某些...没有?

仅作为一个起始提示的示例:如果你想检查一个小组中没有人是成年人,你可以等效地检查每个人都不是成年人,因此 people.none(isAdult) 可以被测试为 people.every(negate(isAdult)),如前述回答所示。

2.7  没有 Some,没有 Every

一些() 方法的提示:使用 findIndex() 查看是否有任何元素满足谓词条件;如果返回的不是 -1,则表示至少有一个元素符合条件。对于每个(),你想要查看 findIndex() 是否找不到任何满足 negate(你的谓词) 条件的元素,和前一个问题类似;如果没有找到,则意味着每个元素都符合条件。

2.8  它做了什么?

写作 Boolean(someValue) 会检查给定的参数是否为“真值”或“假值”,并根据结果返回 true 或 false。在此案例中,“詹姆斯·邦德”和 7 是真值,而 0 是假值,所以结果是 [true, false, false, true]。有关转换规则,请参见 developer.mozilla.org/en-US/docs/Glossary/

第三章

3.1  链式调用

add() 和 remove() 方法应该以 return this 结束,以支持链式调用,仅此而已。

3.2  数组,而非对象

以下是一个可能的实现:

❶ const newBag = () => [];

❷ const isEmpty = (bag) => bag.length === 0;

❸ const find = (bag, value) => bag.includes(value);

❹ const greatest = (bag) => isEmpty(bag) ? undefined : bag[bag.length - 1];

❺ const add = (bag, value) => {
  bag.push(value);
 bag.sort();
  return bag;
};

❻ const remove = (bag, value) => {
  const pos = bag.indexOf(value);
  if (pos !== -1) {
    bag.splice(pos);
  }
  return bag;
};

创建一个袋子 ❶ 只需要创建一个空数组,而检查该数组的长度是否为零 ❷ 是测试它是否为空的方式。要在袋子中查找一个值 ❸,使用 JavaScript 自带的 .includes() 方法。你将保持数组的顺序,因此实现最大值 ❹ 只需要检查袋子是否为空(如果是空的,则返回 undefined,否则返回数组的最后一个元素)。要向袋子中添加一个值 ❺,将值推入数组中,然后对更新后的数组进行排序。最后,要检查是否可以移除一个值 ❻,使用 JavaScript 的 .find() 方法,如果该值存在于数组中,则使用 .splice() 移除它。

3.3  额外操作

有许多可能性,但请记住,只有在你确实需要它们解决特定问题时,才会请求这些操作。下表提供了一些操作,尽管这个列表可以扩展。

操作 签名 描述
计数所有 bag → 整数 给定一个袋子,返回它包含的所有值的数量。
计数值 bag × value → 整数 给定一个袋子和一个值,返回该值在袋子中出现的次数。
添加多个 bag × value × 整数 → bag 给定一个新值和一个计数,将该值的多个副本添加到袋子中。
移除所有 bag × value → bag 给定一个袋子和一个值,移除袋子中所有该值的实例。
查找下一个 bag × value → value | undefined 给定一个袋子和一个值,查找袋子中大于该值的最小值。

3.4  错误的操作

如果你想返回特殊的值,可以像 greatest() 操作那样返回,在空集合时返回未定义。如果抛出异常,也可以将其作为一个新的返回值(异常),尽管它是以不同的方式接收的(try/catch),但这同样有效。

3.5  准备,开始...

我们将在第十三章讨论这个内容,请跳到后面!

第四章

4.1  你说的速度是多少?

这是不可能的;对于足够大的 nf(n) 会变成负数。

4.2  奇怪的界限?

是的,n = O(n²) 且(更准确地说,因为我们更倾向于使用更紧的界限)也 o(n²),因为 n² 增长得更快。下界阶数,大和小欧米伽,不适用。

4.3  大 O和欧米伽

在这种情况下(仅在这种情况下),f(n) = Θ(g(n)).

4.4  传递性?

如果 f(n) = O(g(n)) 且 g(n) = O(h(n)), 那么 f(n) = O(h(n))。我们可以通过数学证明这一点,但直观上,第一个等式意味着 fg 成比例增长,第二个等式意味着 gh 也成比例增长,这就意味着 fh 也成比例增长。如果考虑其他任何阶数,传递性仍然适用;例如,f(n) = Ω(g(n)) 且 g(n) = Ω(h(n)) 表示 f(n) = Ω(h(n)).

4.5  一点反思

我们也可以说 f(n) = O(f(n)) 和 f(n) = Ω(f(n)),但对于小 omega 或小 o 不适用。

4.6  倒着做

如果 f(n) = O(g(n)), 那么 g(n) = Ω(f(n)), 并且如果 f(n) = o(g(n)), 那么 g(n) = ω(f(n))。注意对称性:大 O 表示大欧米伽,小 o 表示小欧米伽。

4.7  接踵而至

n 增大时,O(n²) 部分的增长将大于 O(n log n) 部分的增长,因此整个过程的阶数就是这个。一般来说,一个序列的阶数将是最大阶数的阶数。

4.8  循环中的循环

在这种情况下,结果是 O(n³)。整个“循环内循环”的阶数是由两个阶数的乘积得出的。

4.9  几乎是一个幂...

一个正式的证明需要应用数学归纳法,但请考虑你如何构建它。你希望最后得到一个单独的元素,所以 1 是一个有效的大小。在前一步,数组有两个大小为 1 的部分,之间由一个元素分隔:前一个大小是 3。再往前,数组有两个大小为 3 的部分,之间由一个元素分隔:它的大小是 7。以这种方式倒推,如果你有一个 s 大小的数组,在前一步,数组的大小必须是 (2s + 1)。从 s = 1 开始,可以正式证明大小始终比 2 的幂少一个元素,因为 2(2^k* – 1) + 1 等于 2^k ^(+1) – 1。

4.10  这是最好的时代;也是最坏的时代

在这种情况下(仅在这种情况下),我们可以推导出运行时间是 Θ(f(n))。

第五章

5.1  一阶阶乘

你可以使用这一行代码:

const factorial = (n) => (n === 0 ? 1 : n * factorial(n – 1));

5.2  手工汉诺塔

在奇数步时,总是循环移动最小的盘子(从 A 到 B,从 B 到 C,再从 C 到 A);在偶数步时,进行唯一不涉及最小盘子的移动。这种方法适用于偶数个盘子的情况;对于奇数个盘子,最小盘子需要反方向循环:从 A 到 C,从 C 到 A,再从 A 到 B。

5.3  箭术回溯

这个问题的关键是不要在选择一个选项后立即放弃,而是应该先尝试再次使用它。以下代码基本与第 70 页“解决海滩上的方块游戏”部分中的 solve()函数相同,只是增加了一些内容:

❶ const solve = (goal, rings, score = 0, hit = []) => {
  if (score === goal) {
    return hit;
  } else if (score > goal || rings.length === 0) {
    return null;
  } else {
  ❷ const again = solve(goal, rings, score + rings[0], [      ...hit,       rings[0],     ]);
  ❸ if (again) {
     return again;
  ❹} else {
     const chosen = rings[0];
     const others = rings.slice(1);
     return (
        solve(goal, others, score + chosen, [...hit, chosen]) ||
        solve(goal, others, score, hit)
      );
    }
  }
};

console.log(solve(100, [40, 39, 24, 23, 17, 16]));

参数已重新命名,以更好地与当前的难题❶匹配,因此使用 rings 而不是 dolls,使用 hit 而不是 dropped。额外的代码尝试重新使用第一个 ring ❷,如果成功❸,就完成了。如果再次尝试相同的 ring 失败❹,则丢弃它并继续按照之前的方式进行搜索。

对于第二个问题,你确实可以重用原始的 solve()算法,但需要做一些修改,因为你可能会多次击中一个 ring,所以 ring 必须出现多次。例如,40 和 39 应该都被视为两次选择;击中其中任意一个三次或更多次都会超过 100。同样,23 和 24 最多出现四次,17 出现五次,16 出现六次。这个代码找到了答案:

console.log(
  solve(100, [
    40, 40,
    39, 39,
    24, 24, 24, 24,
    23, 23, 23, 23,
    17, 17, 17, 17, 17,
    16, 16, 16, 16, 16, 16
  ])
);

顺便说一下,如果你不运行代码,答案是 16, 16, 17, 17, 17 和 17!

5.4  统计调用次数

要计算第n个斐波那契数,你需要一次调用该数,再加上C(n – 1)次调用第(n – 1)个数,再加上C(n – 2)次调用第(n – 2)个数,因此C(n) = C(n – 1) + C(n – 2) + 1。这个问题的解是C(n) = 2Fibo(n + 1) – 1。

5.5  避免更多工作

只需在 costOfFragment(...)函数的循环开始时添加一个测试,如下所示:

const costOfFragment = memoize((p, q) => {
  ...
  let optimum = Infinity;
  let split = [];
  for (let r = p; r < q; r++) {
    **if (totalWidth(p, r) > MW) {**
 **break;**
 **}**
    ...
  }
  return [optimum, split];
});

一旦从 p 到 r 的块宽度超过 MW,你可以停止循环;所有接下来的总宽度将更大。

5.6  为了清晰起见减少

以下是一种单行计算部分值的方法:

const partial = blocks.reduce((a, c, i) => ((a[i + 1] = a[i] + c), a), [0]);

请注意,在这种情况下,累加器是一个初始化为单个 0 的数组,它将变为 partial[0]。

5.7  得了痛风吗?

如下使用第 83 页“解决加密算术谜题”部分中的 solve()函数。代码的风格与 SEND + MORE = MONEY 谜题中的完全相同:

const {solve} = require("../send_more_money_puzzle");

const toGoOut = (g, o, u, t) => {
  if (t === 0 || g === 0 || o === 0) {
    return false;
  } else {
    const TO = Number(`${t}${o}`);
    const GO = Number(`${g}${o}`);
    const OUT = Number(`${o}${u}${t}`);
    return TO + GO === OUT;
  }
};

solve(toGoOut);

两个两位数的最大和是 99 + 99 = 198,因此 O = 1,并且从中间列到最左边列有进位。在最右边的列,O + O = T,因此 T = 2,并且没有进位到中间列。最后,查看中间列,T + G = 10 + U,但由于 T = 1,T + G 至少为 10 的唯一方式是 G = 9,然后 U = 0;此时 GOUT 的值为 9102,正如 solve()发现的那样!

第六章

6.1  强制反转

先改变所有数字的符号。接着,将它们按升序排序,然后再将符号改回来。例如,要排序[22, 60, 9],首先将它们变为[–22, –60, –9],然后排序,得到[–60, –22, –9]。最后,再将符号改回[60, 22, 9],它们就会按期望的降序排列。

6.2  仅下限

给定 lower(a,b),你可以如下实现 higher(a,b)和 equal(a,b):

❶ const higher = (a, b) => lower(b, a);
❷ const equal = (a, b) => !lower(a, b) && !higher(a, b);

基本上这就像应用数学:a > b 如果 b < a ❶,并且 a 等于 b,仅当 a < b 和 a > b 都不成立时 ❷。

6.3  测试排序算法

使用其他方法对数据的副本进行排序,并使用 JSON.stringify(...)将结果转换为字符串版本。然后,用你的新方法对数据进行排序,使用 JSON.stringify(...)对其输出进行转换,并比较两个 JSON 字符串;它们应该匹配。

6.4  缺失的 ID

这个问题有两种解决方案。你可以先对数字序列进行排序,然后按顺序遍历已排序的序列,当连续数字之间的差大于 1 时,就能检测到缺失的数字。更具体的解决方案是初始化一个大小为 1,000,000 的数组,并将所有值设置为 false,然后对序列中的每个数字,将相应的数组项设置为 true。接着,你可以遍历数组,仍为 false 的项表示缺失的 ID。

6.5  未匹配项

像前一个问题一样,这个问题也有两个解决方案。你可以先对整个系列进行排序,然后快速浏览已排序的数字,找到只出现一次的数字。第二个、更复杂的解决方案是对所有数字应用按位异或(^)。如果你将一个数字与自己异或,结果是零,如果将一个数字与零异或,结果是该数字。如果你对整个系列进行异或,结果将是未匹配的数字。然而,这个解决方案仅适用于有且只有一个未匹配的数字;如果有两个或更多,则会失败。

6.6  下沉排序

这个逻辑类似于冒泡排序,你只需改变索引的行为:

const sinkingSort = (arr, from = 0, to = arr.length - 1) => {
  for (let j = from; j < to; j++) {
    for (let i = to - 1; i >= j; i--) {
      if (arr[i] > arr[i + 1]) {
        [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
      }
    }
  }
  return arr;
};

6.7  冒泡交换检查

以下逻辑实现了这个思路:

const bubbleSort = (arr, from = 0, to = arr.length - 1) => {
  for (let j = to; j > from; j--) {
 **let swaps = false;**
    for (let i = from; i < j; i++) {
      if (arr[i] > arr[i + 1]) {
        [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
 **swaps = true;**
      }
    }
 **if (!swaps) {**
 **break;**
 **}**
  }
  return arr;
};

6.8  递归插入

这个描述足够写出代码:要排序n个数字,首先排序初始的(n – 1)个数字,然后将第n个数字插入已排序的列表中:

const insertionSort = (arr, from = 0, to = arr.length - 1) => {
  if (to > from) {
    insertionSort(arr, from, to - 1);
    const temp = arr[to];
    let j;
    for (j = to; j > from && arr[j - 1] > temp; j--) {
      arr[j] = arr[j - 1];
    }
    arr[j] = temp;
  }
  return arr;
};

6.9  稳定的 Shell 排序?

不,Shell 排序不是稳定的,因为在初期阶段(对于大于 1 的间隔)可能会打乱相等键的相对顺序。

6.10  荷兰增强法

以下实现有效:

const quickSort = (arr, left = 0, right = arr.length - 1) => {
  if (left < right) {
    const pivot = arr[right];

    let p = left;
    for (let j = left; j < right; j++) {
      if (pivot > arr[j]) {
        [arr[p], arr[j]] = [arr[j], arr[p]];
 p++;
      }
    }
  ❶ [arr[p], arr[right]] = [arr[right], arr[p]];

  ❷ let pl = p;
    for (let i = p - 1; i >= left; i--) {
      if (arr[i] === pivot) {
        pl--;
        [arr[i], arr[pl]] = [arr[pl], arr[i]];
      }
    }

  ❸ let pr = p;
    for (let j = p + 1; j <= right; j++) {
      if (arr[j] === pivot) {
        pr++;
        [arr[j], arr[pr]] = [arr[pr], arr[j]];
      }
    }

  ❹ quickSort(arr, left, pl – 1);
    quickSort(arr, pr + 1, right);
  }

  return arr;
};

所有的代码都与枢轴在 arr[p] ❶时相同。然后,你对枢轴位置左侧进行循环 ❷,如果找到与枢轴相等的元素,则交换;最左侧与枢轴相等的位置总是 pl。完成这一轮后,使用类似的逻辑 ❸从枢轴位置向右重复该过程。最右侧与枢轴相等的位置是 pr。经过这些附加的循环后,从 pl 到 pr 的所有值都等于枢轴,因此你可以对其余部分进行排序 ❹。

6.11  更简单的合并?

如果你做了这个修改,归并排序将不再稳定。当第一和第二个列表中有相等的值时,你希望从第一个列表中选择。

6.12  尽量避免消极

对于负数,当数字变为负数时,算法会崩溃。对于非整数,算法会忽略小数部分,因此具有相同整数部分的数字可能无法正确排序。作为额外问题,考虑如何解决这些问题。

6.13  填充它!

第一个选项会用一个对同一数组的引用填充所有桶中的元素;你将得到一个单一的数组,而不是 10 个不同的数组,这个数组对所有桶都通用。第二个选项不会有任何效果,因为 .map(...) 会跳过未定义的位置。

6.14  那字母呢?

在这种情况下,你需要更多的桶,每个桶代表一个可能的符号。如果你想将带有重音符号的字母(例如 á 或 ü)与没有重音符号的字母一起排序,可能需要做一些额外的工作。

第七章

7.1  网球加时赛

比赛的数量很容易计算:每场比赛都会淘汰一名选手,要找出冠军,你必须淘汰 110 名其他选手,因此答案是 110,对于 n 名选手,答案是 n – 1。第二名选手可能是第一个击败的任何选手——甚至可能是第一轮就击败的。这场比赛有七轮,因此你可能需要最多七场额外的比赛。一般来说,轮数是 n 的以 2 为底的对数,向上取整,因此学习一个数组的两个最小值的总比较次数是 n – 1 + log[2] n

7.2  休息五分钟

你可以通过六次比较来完成,方法如下:

const medianOf5 = (a, b, c, d, e) => {
❶ if (a > b) {
    [a, b] = [b, a];
  }

❷ if (c > d) {
    [c, d] = [d, c];
  }

❸ if (a > c) {
    [a, c] = [c, a];
    [b, d] = [d, b];
  }

❹ if (b > e) {
    [b, e] = [e, b];
  }

❺ if (c > b) {
   // b < c < d and b < e: b isn't the median, and d isn't either
   return e > c ? c : e;
❻} else {
   return d > b ? b : d;
  }
};

要理解代码,跟踪变量的变化。经过第一次测试后,你可以确定 a < b ❶,在下一个测试中你知道 c < d ❷。你可以说 a < b && a < c < d ❸,因此 a 不能是中位数。经过 ❹,b < e && c < d,因此 b 和 c 中最小的一个也不可能是中位数。在 ❺,b < c < d && b < e,因此 b 和 d 都不可能是中位数,中位数是 c 和 e 中最小的一个。同样地,在 ❻,c < b < e && c < d,因此 c 和 e 不是中位数;中位数是 b 和 d 中最小的一个。

完整起见,这里提供了一个等效版本的 median5(...),它适用于包含五个独立值的数组,并返回找到的中位数的位置。代码与之前的代码平行:

const swapper = (arr, i, j) => ([arr[i], arr[j]] = [arr[j], arr[i]]);

const medianOf5 = (arr5) => {
  if (arr5[0] > arr5[1]) {
    swapper(arr5, 0, 1);
  }

  if (arr5[2] > arr5[3]) {
    swapper(arr5, 2, 3);
  }

  if (arr5[0] > arr5[2]) {
    swapper(arr5, 0, 2);
    swapper(arr5, 1, 3);
  }

  if (arr5[1] > arr5[4]) {
    swapper(arr5, 1, 4);
  }

  if (arr5[2] > arr5[1]) {
    return arr5[4] > arr5[2] ? 2 : 4;
  } else {
    return arr5[3] > arr5[1] ? 1 : 3;
  }
};

7.3  从上到下

有两种解决方案。你可以修改算法,改为选择最大值并从 n – 1 向 0 遍历,而不是选择最小值并从 0 遍历到 n – 1。为了获得高效的算法,应该从将 kn/2 进行比较并向上(如文本中最初展示)或向下(如这里所描述)移动开始,选择需要最少工作量的方向。

在数值为数字的特殊情况下,你可以使用一个技巧:将所有数字的符号改为相反的,使用该算法找到 nk 位置的值,并将其符号改回;你能看出为什么这样行得通吗?

7.4  仅迭代

你可以使用一个循环,循环将在代码成功将数组的 k 位置值放置到正确位置时退出,而不是递归:

const quickSelect = (arr, k, left = 0, right = arr.length - 1) => {
❶ **while (left < right) {**
    const pick = left + Math.floor((right - left) * Math.random());
    if (pick !== right) {
      [arr[pick], arr[right]] = [arr[right], arr[pick]];
    }
    const pivot = arr[right];

    let p = left;
    for (let j = left; j < right; j++) {
      if (pivot > arr[j]) {
        [arr[p], arr[j]] = [arr[j], arr[p]];
        p++;
      }
    }
    [arr[p], arr[right]] = [arr[right], arr[p]];

❷ **if (p === k) {**
 **left = right = k;**
 **} else if (p > k) {**
 **right = p - 1;**
 **} else {**
 **left = p + 1;**
 **}**
 **}**
};

设置一个循环 ❶,只要左边和右边没有到达相同的位置(k),就继续循环。最后,避免递归或提前返回,只需适当地操作左边和右边 ❷。

7.5  选择而不改变

只需创建输入数组的副本,如下所示:

const qSelect = (arr, k, left = 0, right = arr.length - 1) => {
❶ const copy = [...arr];
❷ quickSelect(copy, k, left, right);
❸ return copy[k];
};

复制数组 ❶,然后对其进行划分 ❷,最后从复制并重新划分后的数组中返回 k 的值 ❸。

7.6  西西里方式

以下算法完成了这个任务。你可以在“S. Battiato 等人所著的《近似中位数选择问题的高效算法》中找到不同的实现(通过改变元素交换方式)”,该文献可以在 web.cs.wpi.edu/~hofri/medsel.pdf 上找到。我们将突出与之前代码的唯一差异,但我们也用新的方式实现了一些方法,只是为了多样性,并且为了同样的原因,我们用迭代代替了递归(如在问题 7.4 中所示)。

❶ const swapIfNeeded = (arr, i, j) => {
  if (i !== j) {
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
};

❷ const medianOf3 = (arr, left, right) => {
  if (right - left === 2) {
    const c01 = arr[left] > arr[left + 1];
    const c12 = arr[left + 1] > arr[left + 2];
    if (c01 === c12) {
      return left + 1;
    } else {
      const c20 = arr[left + 2] > arr[left];
      return c20 === c01 ? left : left + 2;
    }
  } else {
    return left;
  }
};

const quickSelect = (arr, k, left = 0, right = arr.length - 1) => {
  while (left < right) {
❸ **let rr = right;**
❹ **while (rr - left >= 3) {**
 **let ll = left - 1;**
❺ **for (let i = left; i <= rr; i += 3) {**
 **const m3 = medianOf3(arr, i, Math.min(i + 2, rr));**
 **swapIfNeeded(arr, ++ll, m3);**
 **}**
❻ **rr = ll;**
 **}**
 **const m3 = medianOf3(arr, left, rr);**
 **swapIfNeeded(arr, right, m3);**

    const pivot = arr[right];

    let p = left;
    for (let j = left; j < right; j++) {
      if (pivot > arr[j]) {
        swapIfNeeded(arr, p, j);
        p++;
      }
    }
    swapIfNeeded(arr, p, right);

    if (p === k) {
      left = right = p;
    } else if (p > k) {
      right = p - 1;
 } else {
      left = p + 1;
    }
  }
};

const sicilianSelect = (arr, k, left = 0, right = arr.length - 1) => {
  quickSelect(arr, k, left, right);
  return arr[k];
};

你将需要进行大量交换,但 swapIfNeeded(...)函数 ❶ 通过检查是否真的需要交换,避免了一些不必要的调用。因为你总是会找到最多三个值的中位数,所以使用特定的函数比使用通用排序更合适 ❷;medianOf3(...)通过最多三次比较返回中位数的位置,而不需要交换。在快速选择中,唯一变化的是加粗的几行。你将在不断缩短的数组部分中找到中位数;rr 变量标记你正在处理的数组的右边界 ❸,而 left 变量始终指向其左边界。只要数组中的元素超过三个 ❹,你就会进行选择 3 个中位数的操作 ❺,并将它们打包到数组的左边,如重复步骤算法所示;不同之处在于,每次操作后 ❻ 你都会缩短数组,并重新循环以找到 3 个中位数。当中位数的集合(中位数的中位数的中位数……,依此类推)足够短时,你将选择它的最后一个元素作为下一个枢轴。

第八章

8.1  足够好的洗牌

这是我在为书籍测试函数时使用的代码:

❶ const logResults = (fn, from = 0, to = 10, n = to, times = 4000) => {
❷ const bar = (len, val, max) =>
    "#".repeat(Math.round((len * val) / max));
❸ const result = {};
  const compare = (a, b) => (a < b ? -1 : 1);
❹ let max = 0;
❺ for (let i = 1; i <= times; i++) {
  ❻ const arr = Array(n)
      .fill(0)
      .map((v, i) =>
        i < from || i > to ? i : String.fromCharCode(65 + i),
      );
  ❼ const x = fn(arr, from, to).join(“-”);
  ❽ result[x] = x in result ? result[x] + 1 : 1;
  ❾ max = Math.max(max, result[x]);
  }
❿ let count = 0;
  for (const [key, val] of Object.entries(result).sort(compare)) {
    count++;
    console.log(
      `${key}: ${String(val).padStart(5)} ${bar(50, val, max)}`,
    );
  }
  console.log("COUNT=", count);
};

记录这些信息的参数包括洗牌函数 fn,要洗牌的数组部分(from, to),使用的输入数组的大小(n),以及测试运行的次数❶。辅助函数 bar(...)❷绘制一个由井号字符组成的条形图:最大长度为 len,表示 val 值等于最大 max 值时的长度;较小的值会得到比例较短的条形。使用对象 result 来统计每个排列出现的次数❸(你也可以使用 JavaScript 的 map)。max 变量❹跟踪任何排列出现的最大次数。你会循环 n 次❺,每次初始化一个数组进行洗牌❻。使用 fn 来洗牌数组,基于结果创建字符串键❼,并更新出现的次数❽和最大观察频率❾。最后一步❿以表格形式返回结果,排列按字母顺序排序以便清晰查看。

8.2  随机三或六

掷两次硬币会产生四种组合。你可以为这些组合分配数字,如果得到 1 到 3 的组合,就接受它;如果得到 4,则重新掷一次:

❶ const random01 = () => (randomBit() ? 0 : 1);

const random3 = () => {
  let num = 0;
  do {
  ❷ num = 2 * random01() + random01();
❸} while (num > 2);

❹ return ["3-hi", "2-md", "1-lo"][num];
};

这个方法使用二进制系统为硬币的组合分配数字,将正面记作 0,反面记作 1。使用 randomBit()函数生成比特❶。你“掷”骰子两次,并为结果组合分配一个数字❷。循环直到得到一个 0 到 2 之间的数字❸,然后将其映射到结果❹。

为了模拟投骰子,你需要掷三次硬币,但逻辑非常相似:

const randomDie = () => {
  let num = 0;
 do {
    num = 4 * random01() + 2 * random01() + random01();
  } while (num > 5);
  return num + 1;
};

最后,模拟一个 1 到 20 的投掷更为复杂。你可以使用 5 个比特,得到一个 0 到 31 的结果,丢弃最后 12 个值,但这可能需要多次尝试;毕竟,32 次中有 12 次失败率太高。一个更好的方法是使用 6 个比特,得到一个 0 到 63 的结果,丢弃最后 4 个(即 0–59),然后将结果除以 3,得到一个 0 到 19 的结果,这样只需要小几率(64 次中 4 次)就能重新尝试。

8.3  非随机洗牌

如果这个算法产生均匀的洗牌,你会期望原数组中的每个值在洗牌后的数组中每个位置出现相同的次数。为了让初始值出现在最后一个位置,你需要所有的 randomBit()调用都返回 true,因此初始值更可能不会离起始位置太远,而很少出现在最后的位置。

8.4  糟糕的交换洗牌

问题在于,这段代码并没有以相同的频率生成所有可能的排列。你可以通过注意到这段代码循环 n 次,每次选择一个有 n 种可能值的随机数,从而看到这一点,因此它有 n¹ 种运行方式。然而,排列生成算法应该仅以 n! 种方式运行。如果你想手动验证这一点,尝试只对三个元素执行该算法,并模拟所有 27 种可能的随机数字序列:从 (0,0,0),它将 ABC 洗牌为 CBA;(0,0,1),它将 ABC 洗牌为 BCA;依此类推,一直到 (2,2,2),它产生 CBA。计算每个可能的排列出现的次数,你会发现某些排列比其他排列更常见。该算法并没有生成均匀分布的洗牌。

8.5  Robson 的顶端?

这个问题等同于找出在不丢失精度的情况下能够计算的最大阶乘。使用正常精度时,这个值是 18;19! 太大了:

❶ for (let num = 1, fact = 1; fact < fact + 1; num++) {
  fact *= num;
  console.log(`${num}=${fact}`);
}

你可以通过加 1 并检查结果是否变化来测试是否丢失了精度;如果结果没有变化,意味着 JavaScript 没有足够的位数来容纳你的大数字 ❶。

然而,你可以使用 BigInt 值,那么你就能处理更高的值——只要阶乘的大小不超过允许的内存。以下程序将愉快地计算到 19 以上,直到某些地方崩溃:

for (let num = 1n, fact = 1n; fact < fact + 1n; num++) {
  fact *= num;
  console.log(`${num}=${fact}`);
}

唯一的区别是它使用了 BigInt 数字:1n 就是这样的一个数字。

8.6  抽样测试

这个解决方案与我们用于洗牌的方法非常相似,但有一个特别的细节,涉及到你是在进行有放回抽样还是无放回抽样:

❶ const logResults = (fn, k, n = 10, times = 60000, noReps = true) => {
  const bar = (len, val, max) =>
    "#".repeat(Math.round((len * val) / max));

  const result = {};

  const compare = (a, b) => (a < b ? -1 : 1);

  let max = 0;
  for (let i = 1; i < times; i++) {
    const arr = Array(n)
      .fill(0)
      .map((v, i) => String.fromCharCode(65 + i));

❷  const x = noReps

      ? fn(arr, k).sort().join(“-”)
      : fn(arr, k).join(“-”);
    result[x] = x in result ? result[x] + 1 : 1;
    max = Math.max(max, result[x]);
  }
  let count = 0;
  for (const [key, val] of Object.entries(result).sort(compare)) {
    count++;
    console.log(
      `${key}: ${String(val).padStart(5)} ${bar(50, val, max)}`,
    );
  }
  console.log("COUNT=", count);
};

当进行无放回抽样时,样本 B-C-A 和样本 C-A-B 是相同的,因此你需要对元素进行排序,以获得一个唯一的键 A-B-C。然而,当进行有放回抽样时,这两种结果是不同的。添加的参数 noReps ❶ 解决了这个问题;在计数样本时,你会根据它来排序值(或不排序) ❷。

8.7  单行重复器

以下是一个单一的语句,但为了清晰起见,这里将其分成几行展示。你生成一个合适大小的数组,并使用 .map(...) 填充随机选择的值:

const repeatedPick = (arr, k) =>
  Array(k)
    .fill(0)
    .map(() => arr[randomInt(0, arr.length)]);

棘手的部分是,你需要使用 .fill(0) 来填充数组中的值。如果不这样做,.map(...) 什么也不会做,因为它会跳过未初始化的数组位置。

8.8  排序以进行抽样

以下逻辑完成了这个工作:

const sortingSample = (arr, k) => {
❶ const rand = arr.map((v) => ({val: v, key: Math.random()}));

❷ for (let i = 0; i < k; i++) {
    let m = i;
    for (let j = i + 1; j < arr.length; j++) {
      if (rand[m].key > rand[j].key) {
        m = j;
      }
    }
    if (m !== i) {
      [rand[i], rand[m]] = [rand[m], rand[i]];
    }
  }

❸ return rand.slice(0, k).map((obj) => obj.val);
};

这种分配随机键的方法直接来自第 139 页的“通过排序进行洗牌”部分 ❶。后续的逻辑是对第 124 页“通过比较进行选择”逻辑的轻微修改 ❷,最后的代码为了只留下原始值,再次来自“通过排序进行洗牌” ❸。

8.9  迭代,不要递归

为了理解为什么这样有效,考虑其他(...) 的调用是什么,以及使用哪些参数。将转换应用到阶乘函数上,产生了著名的等效形式:

const factorial = (p) => {
  let result = 1;
  for (let i=1; i <= p; i++) {
    result = result * i
 }
  return result;
}

将转换应用于 Floyd 算法稍微复杂一些,因为该函数有两个参数(k 和 n),但由于它们的差值是恒定的,因为它们是并行递减的,所以你可以实现转换。你需要重命名一些变量以避免混淆;例如,在 Floyd 的代码中我们已经使用了 i 来表示其他含义。

8.10  没有限制?

不需要检查;最初 toSelect 并不大于 toConsider。如果它们变得相等,从此以后,所有元素都会被选中,因为测试 Math.random() < toSelect / toConsider 对于所有小于 1 的随机值总是成功的,且 toSelect 最终会变为 0。

第九章

9.1  正确的搜索

这是我使用的代码。checkSearch(...)高阶函数接受一个搜索函数进行测试,并用一个布尔标志表示是使用已排序的数据还是未排序的数据。实际包含数据的文件叫做 data32 和 data_sorted_32:

const checkSearch = (fn, sorted = false) => {
❶ const data32 = sorted ? require("../data_sorted_32") : require("../data32");

❷ const verify = (v, i, f) => {
  ❸ if (i !== f) {
      throw new Error(`Failure searching v=${v} i=${i} fn=${f}`);
    }
  ❹ if (i !== -1) {
      console.log("Searching v=", v, " i=", i);
    }
  };

❺ data32.forEach((v, i) => {
    const f = fn([...data32], v);
    verify(v, i, f);
  });

❻ const m1 = Math.min(...data32);
  const m2 = Math.max(...data32);
❼ for (let i = m1 - 3; i <= m2 + 3; i++) {
  ❽ if (!data32.includes(i)) {
    ❾ verify(i, -1, fn([...data32], i));
    }
  }
};

你根据算法的种类使用已排序或未排序的数据集 ❶。一个辅助的 verify(...)函数 ❷可以让代码更简短:该函数测试结果是否与预期相符 ❸,如果不符,则抛出错误。对于成功的搜索 ❹,它会显示输入和输出。你尝试搜索输入数组中的每个值 ❺。然后你找到数组的最小值(m1)和最大值(m2) ❻,接着尝试所有可能的(无效的)搜索 ❼,从 m1 - 3 到 m2 + 3;每当一个值不在数组中时 ❽,你特别尝试去找它,期望得到-1 作为结果 ❾。

9.2  JavaScript 的自有方法

最简单的解决方案是 array.findIndex(x => x === key)。

9.3  无限搜索层级?

如果层数趋于无限,b 将始终变为 2,这意味着每一步都会将搜索区域一分为二。你重新发现了二分查找!大致来说,i 取代了迭代二分查找代码中的 l,m 是 l 和 r 之间的差值。

9.4  到底多少?

你可以通过求和 1 × 1 + 2 × 2 + 4 × 3 + 8 × 4 等,直到 2^(n–)¹,来计算测试的平均次数,这个测试出现在n个问题中,再将其除以总的搜索次数 2^n – 1。我们在“实践中的算法分析”一节(第 55 页)中计算了这一点,结果和为(n + 1)2^n – (2n*(+1) – 1))。除以后,你会得到你想要的平均值,对于大的n值,结果接近n* – 1,因此,对于任何数组长度k,答案大约是 log[2]k – 1。

9.5  三顶两个?

当试图决定在数组的哪个三分之一继续搜索时,如果关键字位于第一个三分之一,你只需问一个问题即可决定;如果它位于其他两个三分之一,则需要问第二个问题:平均而言,你需要进行 1 × 1/3 + 2 × 2/3 = 5/3 个问题。将数组分为三份,你将进行 log[3] n 次搜索步骤,而二分查找则需要 log[2] n 步骤。由于 log[3] n 约等于 0.631 log[2] n,因此三分查找的性能大约是 5/3 × 0.631,即二分查找的 1.052 倍,实际上差别不大。

9.6  二分查找第一个

这个思路是,如果你找到了关键字,应该记下位置而不是立即返回,继续向左搜索:

const binaryFindFirst = (arr, key, l = 0, r = arr.length - 1) => {
❶ let result = -1;
  while (l <= r) {
    const m = (l + r) >> 1;
    if (arr[m] === key) {
    ❷ result = m;
 ❸ r = m – 1;
    } else if (arr[m] > key) {
      r = m - 1;
    } else {
      l = m + 1;
    }
  }
❹ return result;
};

代码与二分查找相同,只有四个不同之处。初始化一个结果变量 ❶,它将在最后返回 ❹。当你找到关键字时,更新此变量 ❷,但不是返回,而是继续向左搜索 ❸,以防关键字再次出现。

要查找 arr 中关键字的最后位置 ❸,将 l = m + 1,继续在右侧进行搜索。

9.7  计数更快

使用前面问题的解法来查找数组中关键字的第一个和最后一个位置。当然,如果第一次搜索返回 -1,则计数为 0,你就不需要进行第二次搜索。

9.8  旋转查找

你可以使用二分查找的变体来解决这个问题,但它不完全相同;这里有一个需要注意的细节:

const rotationFind = (arr) => {
❶ let l = 0;
  let r = arr.length - 1;
❷ while (arr[l] > arr[r]) {
  ❸ const m = (l + r) >> 1;
  ❹ if (arr[m] > arr[r]) {
      l = m + 1;
  ❺} else {
      r = m;
    }
  }
❻ return l;
};

你像二分查找一样设置了 l 和 r ❶。每当发现 l 处的值不大于 r 处的值时,你就会停止搜索 ❷,而在此条件不成立时,你会继续在数组的一半内搜索。和二分查找一样 ❸,m 是数组的中间位置。如果 m 处的值大于 r 处的值 ❹,则旋转发生在数组的右侧,最小值必定至少出现在 m + 1 处。否则,l 处的值必定大于 m 处的值 ❺,在这里你需要非常小心,因为旋转的位置可能就是 m 本身!因此,当旋转发生在左侧时,你不应像二分查找那样将 r 设置为 m - 1,而应将 r 设置为 m。当你发现 l 处的值不大于 r 处的值时 ❻,l 就是你要找的位置。

9.9  特殊的第一个

不,你不需要这样做。假设 arr[0] === key:

const exponentialSearch = (arr, key) => {
  const n = arr.length;
❶ let i = 1;
❷ while (i < n && key > arr[i]) {
    i = i << 1;
  }
❸ return binarySearch(arr, key, i >> 1, Math.min(i, n – 1));
};

在逻辑中,i 从 1 开始 ❶;循环立即退出 ❷,因为关键字 (arr[0]) 不可能大于 arr[1]。最终的二分查找 ❸ 是在 0 和 1 之间进行的,并成功找到结果。

第十章

10.1  遍历列表

要查找列表的大小,初始化一个指针指向第一个元素,然后跟随下一个指针直到到达末尾,同时计数每个访问过的节点:

const size = (list) => {
❶ let count = 0;
❷ for (let ptr = list; ptr !== null; ptr = ptr.next) {
  ❸ count++;
  }
  return count;
};

count 变量 ❶ 用来记录元素的数量。你将进行一个循环,从头开始,直到遍历到末尾 ❷,并为每个节点更新 count ❸。

使用类似的代码来查找列表中是否存在给定的值:

const find = (list, value) => {
❶ for (let ptr = list; ptr !== null; ptr = ptr.next) {
    if (ptr.value === value) {
    ❷ return true;
    }
  }
❸ return false;
};

遍历列表的逻辑与count(...) ❶相同,对于每个元素,你会测试它是否匹配所需的值。如果匹配,则返回 true ❷,如果遍历到列表末尾都没有匹配,则返回 false ❸。

添加一个元素也是通过不断向列表下移,直到到达末尾或所需的位置:

const add = (list, position, value) => {
  if (position === 0) {
    list = {value, next: list};
  } else {
    let ptr;
    for (
      ptr = list;
      ptr.next !== null && position !== 1;
      ptr = ptr.next
    ) {
      position--;
    }
    ptr = {value, next: ptr.next};
  }
};

最后,移除指定元素的过程也遵循类似的逻辑:遍历列表直到遇到列表的末尾或你想移除的位置:

const remove = (list, position) => {
  if (!isEmpty(list)) {
    if (position === 0) {
      list.first = list.next;
    } else {
      let ptr;
      for (
        ptr = list;
        ptr.next !== null && position !== 1;
        ptr = ptr.next
      ) {
        position--;
      }
      if (ptr.next !== null) {
        ptr.next = ptr.next.next;
      }
    }
  }
  return list;
};

10.2  反向操作

这个思路是,你遍历列表,将每个值推入栈中,最终列表会被反转:

const reverse = (list) => {
❶ let newList = null;
❷ while (list !== null) {
 ❸ [list.next, newList, list] = [newList, list, list.next];
  }
❹ return newList;
};

创建一个以 newList 为指针的反转列表作为栈 ❶,使用 list 逐个元素地推入 newList 栈中 ❷;你可能需要画一个指针操作的示意图 ❸。最后,返回反转后的列表 ❹。给你一个问题:这个算法也适用于空列表吗?

10.3  联手合作

思路是寻找第一个列表的最后一个元素,并将其连接到第二个列表的头部:

const append = (list1, list2) => {
  if (list1 === null) {
  ❶ list1 = list2;
  } else {
  ❷ let ptr = list1;
    while (ptr.next !== null) {
    ❸ ptr = ptr.next;
    }
  ❹ ptr.next = list2;
  }
  return list1;
};

一个有趣的情况是,如果第一个列表为空 ❶,操作的结果就是第二个列表。否则,使用 ptr 遍历第一个列表 ❷,在未到达末尾时一直向前 ❸。当到达末尾 ❹时,只需修改它的 next 指针,使其指向第二个列表。

10.4  解除环路

你不需要存储任何东西,只需要使用两个指针。这个思路是,使用两个指针来遍历列表,一个每次移动一个节点,另一个以两倍的速度移动。如果列表没有环路,第二个指针将到达末尾,操作完成。如果列表有环路,两个指针最终会相遇(因为第二个指针的速度比第一个快),这就意味着存在环路:

const hasALoop = (list) => {
  if (list === null) {
  ❶ return false;
  } else {
  ❷ let ptr1 = list;
    let ptr2 = list.next;

  ❸ while (ptr2 !== null && ptr2 !== ptr1) {
    ❹ ptr1 = ptr1.next;
    ❺ ptr2 = ptr2.next ? ptr2.next.next : null;
    }

 ❻ return ptr2 === ptr1;
  }
};

如果列表为空 ❶,那么肯定没有环路。否则,ptr1 一次移动一个节点,ptr2 则一次移动两个节点 ❷。你会继续操作,直到 ptr2 到达末尾或 ptr2 与 ptr1 相遇 ❸。在迭代过程中,ptr1 前进一个节点 ❹,ptr2 则前进两个节点 ❺,除非它已经到达末尾。最终,如果 ptr2 与 ptr1 相遇,那么就说明有环;否则,没有环。

10.5  栈的数组实现

由于有.pop(...)和.push(...)方法,实现一个栈是直接的:

❶ const newStack = () => [];

❷ const isEmpty = (stack) => stack.length === 0;

const push = (stack, value) => {
❸ stack.push(value);
  return stack;
};

const pop = (stack) => {
❹ if (!isEmpty(stack)) {
    stack.pop();
  }
  return stack;
};
❺ const top = (stack) =>   isEmpty(stack) ? undefined : stack[stack.length – 1];

一个新的栈就是一个空数组 ❶,要检查栈是否为空,你只需查看数组的长度是否为零 ❷。要推入一个新值 ❸,使用.push(...),使用.pop() ❹来弹出值,并检查栈是否为空。最后,查看栈顶元素,只需要查看数组的最后一个元素 ❺。

10.6  栈的打印

由于在本章第一个问题中已经实现了 size(...)和 find(...)方法,你应该不需要进一步的解释:

const print = (list) => {
  for (let ptr = list; ptr !== null; ptr = ptr.next) {
    console.log(ptr.value);
  }
};

10.7  栈的高度

你可以通过使用类似于上一题代码的代码在O(n)时间内实现这一点,但更简单的解决方案是向栈定义中添加一个高度字段,初始值为零,并在推入或弹出值时适当更新该字段。现在的栈更像是一个队列,因为它不仅仅是一个指针,而是使用了一个对象:

const newStack = () => ({first: null, height: 0});

10.8  最大栈

该思路是推入包含两项数据的条目:不仅是正在推入的值,还有当前的最大值,这个最大值取决于正在推入的值和推入前栈顶的最大值。为了找到最小值,你需要推入三项数据:要推入的值、当时的最大值,以及当时的最小值。这使得你能够在任何时刻以O(1)的时间获取最大值或最小值。

10.9  队列数组

这个操作本质上需要与栈相同的实现,只不过对于进入队列的操作,使用.unshift(...)而不是 push(...),这样新值会被添加到数组的开头,而不是末尾。

10.10  队列长度

哗啦!只需像处理第 10.9 题一样应用相同的逻辑即可。

10.11  排序队列

以下代码完成了这项工作;它只是重写了“基数排序”部分中第 115 页的 _radixSort(arr)函数,该函数对输入数组进行排序:

const _radixSort = (arr) => {
  const ML = Math.max(...arr.map((x) => String(x).length));

  for (let i = 0, div = 1; i < ML; i++, div *= 10) {
    const buckets = Array(10)
      .fill(0)
    ❶ .map(() => ({first: null, last: null}));

    arr.forEach((v) => {
    ❷ const digit = Math.floor(v / div) % 10;
    ❸ const newNode = {v, next: null};
      if (buckets[digit].first === null) {
        buckets[digit].first = newNode;
      } else {
        buckets[digit].last.next = newNode;
      }
 buckets[digit].last = newNode;
    });

    arr = [];
  ❹ buckets.forEach((b) => {
      for (let ptr = b.first; ptr; ptr = ptr.next) {
        arr.push(ptr.v);
      }
    });
  }

  return arr;
};

与第六章中的代码不同之处已被突出显示。不是为每个桶创建数组,而是设置队列❶。对于每个值,在决定它将进入哪个桶之后❷,将其放入相应的队列中❸。将值分配到队列中后,依次遍历每个队列❹以生成数组。

10.12  堆叠队列

如建议的那样,思路是使用两个栈:一个是 IN 栈,一个是 OUT 栈。将新值推入 IN 栈以进入队列。从 OUT 栈弹出以退出队列,但如果 OUT 栈为空,首先从 IN 栈弹出所有值,一次一个,并将它们推入 OUT 栈,最后再进行弹出。每个进入队列的值将经过两次推入(先推入 IN,稍后推入 OUT)和两次弹出(稍后从 IN 弹出,最终从 OUT 弹出),因此操作的摊销成本是O(1)。显然,某些退出操作(例如发现 OUT 栈为空的操作)将需要更多的时间。

10.13  回文检测

思路是将字符串拆分为单独的字符,并将所有字母进入一个双端队列。完成后,反复检查前面元素是否与最后一个元素相同,若相同则将它们都退出。如果剩下零个元素(第一个为空)或一个元素(第一个与最后一个相同),则该字符串是回文。

10.14  循环链表

以下逻辑完成这项工作:

const print = (circ) => {
❶ if (!isEmpty(circ)) {
  ❷ let ptr = circ;
    do {
    ❸ console.log(ptr.value);
    ❹ ptr = ptr.next;
  ❺} while (ptr !== circ);
  }
};

首先检查循环链表是否为空;如果为空,你无需做任何事情❶。使用指针(ptr)遍历链表❷,打印每个访问的节点❸,向下一个节点移动❹,并在再次到达初始节点时退出循环❺。

10.15  连接圆环

操作链接并不困难,但你必须小心。最初,你会遇到这种情况:

你希望能达到这种情况:

假设 circ1 和 circ2 都不是 null,你需要四行代码:

❶ circ2.prev.next = circ1.next;
❷ circ1.next.prev = circ2.prev;
❸ circ1.next = circ2;
❹ circ2.prev = circ1;

首先,B 跟随 N ❶。设置 B,使得 N 在它之前 ❷。类似地,J 跟随 A ❸,而 A 在 J 之前 ❹。

第十一章

11.1  查找的哨兵

如果有序列表包含一个最终的 +Infinity 值,你可以简化查找,因为你知道永远不会超出末尾:

const find = (list, valueToFind) => {
  if **(valueToFind < list.value)** {
    return false;
  } else if (valueToFind === list.value) {
 return true;
  } else {
    // valueToRemove > list.value
    return find(list.next, valueToFind);
  }
};

将这段代码与第 183 页“查找一个值”部分的代码进行对比;第一个 if 语句现在更简单了。(我同意速度提升可能微乎其微,但这种技术是常用的,值得了解。)

11.2  更多哨兵?

初始的 -Infinity 值意味着你永远不会在列表的开头添加新值,因此本质上使得指向头部的指针成为常量。这个额外的哨兵使得迭代代码更简洁;你可以自己验证这一点。

11.3  更简单的查找?

你可以修改函数,使其返回 null(如果没有找到值)或指向该值的指针——这将是列表中的第一个元素。

11.4  重新跳过列表

从删除除最底层外所有其他层的指针开始。然后,通过遍历第 0 层并选择所有偶数位置的元素来创建第 1 层;第 1 层将包含大约第 0 层的一半元素。重复此过程,根据第 1 层中偶数位置的元素创建第 2 层;然后,根据第 2 层中的偶数位置元素创建第 3 层,以此类推。当最顶层的列表只包含一个元素时停止。

11.5  跳转到一个索引

你希望能够得到一个列表中的第 229 个值,而不必逐一遍历前面 228 个值。为了解决这个问题,你将定义一个链接的“宽度”,即下一级中由该链接包含的值的数量。(换句话说,通过跟随链接你会跳过多少个节点?)你可以在添加或删除值时创建和更新宽度。底层所有的宽度都是 1。在任何一层,如果一个链接从节点 A 到节点 B,链接的宽度就是从 A(包含)到 B(不包含)之间所有宽度的总和。

知道这些宽度可以轻松找到任何给定的位置。你从最顶层开始,沿水平方向遍历,只要宽度的总和不超过你想要的索引。当这个总和超过你想要的索引时,你就下降到下一层,继续沿水平方向遍历。

例如,在提供的图表中,假设你要查找列表中的第 11 个元素(每个链接的宽度在其下方括号内标出)。在第一层,前面的三个链接覆盖了 2 + 4 + 2 = 8 个元素,所以通过这三步操作,你已经到达了列表中的第 8 个元素。接下来的链接宽度是 4,所以会超过第 11 个元素,你需要向下移动。那里的链接宽度都是 1,所以你再移动几次,就找到了第 11 个值,即 40。

11.6  更简化的填充

它会用对相同列表的引用来填充数组,而不是对 100 个不同列表的引用。

11.7  哈希集合

对于查找和删除操作,完全没有变化。对于添加操作,关键是继续搜索,直到你找到值(在这种情况下,你就不会再添加它)或找到一个空位。为了提高效率,如果在搜索过程中找到一个空位,记下它,而不是将新值放入空位中,而是将其放入可用的空位。

11.8  错误的座位安排

关键是从不同的角度看待这个问题。不要认为当一个人发现自己的座位被占了时,他会去另一个随机座位,想象一下是他们坐下,而原先坐在座位上的人离开了(换句话说,最初坐错位置的人)。当第 100 个人进入时,98 个人已经坐到了自己指定的座位,而最先进入剧院的人要么坐在自己的座位上,要么坐在第 100 个人的座位上——这取决于前一个人如何移动,只会选择两个座位之一:自己的座位或第 100 个人的座位。(如果他们选择了其他座位,当座位的 rightful occupant 出现时,他们就必须再移动。)答案是 50%。

11.9  渐进调整大小

这个方法的思路是同时操作两个表,逐渐从旧表中移除值,并将它们插入新表。当你决定需要调整大小时,创建一个新的、更大的表,从此以后,所有的插入操作都会去新表中。每当你需要查找一个值时,要同时查看两个表;在旧表中的值可能已经被移动到新表中。(删除操作同样适用。)每当进行一次操作(添加、删除或查找),就从旧表中移除一些值,并将它们插入新表。当旧表中的所有值都被移除后,就只使用新表。

第十二章

12.1  层次问题

树的高度就是其节点的最高层级。

12.2  打破规则

符号链接(symlinks)可以指向任何文件或目录,因此它们允许你打破树结构。

12.3  名字有什么含义?

一个完美的树是完整且充满的。一个完整的树不一定是完美的(底部不一定完整),并且它可能也不充满,因为一个节点可能只有一个孩子——例如,一个只有两个节点的树。最后,一个充满的树可能既不是完整的也不是完美的;具体示例见第十三章。

12.4  一个 find() 单行代码

使用三元运算符,你可以按如下方式进行:

const find = (tree, keyToFind) =>
  !isEmpty(tree) &&
  (keyToFind === tree.key ||
    find(
      tree[keyToFind < tree.key ? "left" : "right"],
      keyToFind,
    ));

由于空间限制,这里显示为多行文本,但无论如何,它都是一个单一的语句。

12.5  树的大小

根据定义,如果树为空则大小为 0,否则,树的大小是 1(根节点)加上左右子树的大小,你可以写出一个单行的解决方案:

const {
  isEmpty,
} = require("../binary_search_tree.js");

const calcSize = (tree) =>
 **isEmpty(tree)**
 **? 0**
 **: 1 + getSize(tree.left) + getSize(tree.right);**

12.6  像树一样高

树的高度是从根节点到叶子节点的最长路径长度。所以,如果你知道根节点的左右子树的高度,那么完整树的高度将是最高子树高度加 1。你可以使用递归非常简单地编写这个程序:

const {isEmpty} = require("../binary_search_tree.js");

const calcHeight = (tree) =>
  isEmpty(tree)
    ? 0
    : **1 + Math.max(getHeight(tree.left), getHeight(tree.right))**;

12.7  复制一棵树

递归是最好的解决方案:空树的副本就是空树,而非空树的副本是由树的根节点以及它的左右子树的副本构成的:

const {newNode, isEmpty} = require("../binary_search_tree.js");

const makeCopy = (tree) =>
  isEmpty(tree)
    ? tree
    : **newNode(tree.key, makeCopy(tree.left), makeCopy(tree.right))**;

你也可以用另一种方式构建一个副本,这应该会让你想起后序遍历:

const makeCopy2 = (tree) => {
❶ if (isEmpty(tree)) {
    return tree;
  } else {
  ❷ const newLeft = makeCopy2(tree.left);
  ❸ const newRight = makeCopy2(tree.right);
  ❹ return newNode(tree.key, newLeft, newRight);
  }
};

如果要复制的树为空 ❶,则无需任何操作。否则,首先复制左子树 ❷,然后复制右子树 ❸,最后,根据树的键以及两个新建的树来构建新的树 ❹。

12.8  做数学运算

你需要一个后序遍历来完成这个任务,因为在应用任何运算符之前,你需要知道左右子表达式的值。你可以通过一个函数来实现这个操作,因为整个类可能会显得过于繁重:

const evaluate = (tree) => {
❶ if (!tree) {
   return 0;
❷} else if (typeof tree.key === "number") {
   return tree.key;
❸} else if (tree.key === "+") {
   return evaluate(tree.left) + evaluate(tree.right);
❹} else if (tree.key === "-") {
   return evaluate(tree.left) - evaluate(tree.right);
❺} else if (tree.key === "*") {
   return evaluate(tree.left) * evaluate(tree.right);
❻} else if (tree.key === "/") {
   return evaluate(tree.left) / evaluate(tree.right);
❼} else {
   throw new Error("Don't know what to do with ", tree.key);
  }
};

如果树为空 ❶,返回 0,这是一个合理的值。否则,如果根节点是一个数字 ❷,直接返回该数字。如果根节点是一个运算符 ❸ ❹ ❺ ❻,使用递归来计算表达式两侧的值并返回计算结果。你还需要为任何意外输入添加一个“通配符” ❼。

一个简单的例子展示了这个方法:

const exampleInBook = {
  key: "*",
  left: {
    key: "+",
    left: {key: 2},
    right: {key: 3}
  },
  right: {
    key: 6
  }
};

这段代码返回 30,正如预期的那样。你能猜出为什么我没有包含空指针吗?

12.9  使其变坏

你永远不想有一个有两个孩子的节点,因此根节点必须是键集中的最小值或最大值。之后,接下来的键也必须只有一个孩子,因此它必须是剩余键集中的最小值或最大值。如果按照这个逻辑一直推导下去,你会得到第一个键有两个选择,第二个键也有两个选择,第三个键同样如此,直到第(n – 1)个键,之后只剩一个选择。你可以从 n 个键中生成的线性树的数量是 2^n ^(–1)。

12.10  重建树

给定前序和中序列表,很明显,树的根节点必须是前序列表中的第一个值。如果你在中序列表中查找这个值,那么它前面的所有键将来自根节点的左子树,而它后面的所有键将来自右子树。将前序列表分成两部分,你就得到了两个子树的前序和中序遍历顺序;应用递归,你就能构建出这棵树。

12.11  更多的重建?

使用中序和后序遍历是可能的;唯一的区别是,你会在后序列表的末尾找到根节点,而不是在前序列表的开头。然而,使用前序和后序遍历则不可能——一个例子就足以说明为什么。如果我告诉你前序遍历的顺序是“1, 2”,而后序遍历的顺序是“2, 1”,那么有两棵可能的二叉搜索树会产生这些遍历顺序。你能找到它们吗?

12.12  相等的遍历

如果没有左子树,则前序和中序遍历将是相同的,因此第一个答案是“只有右子树的树”;对于中序和后序遍历,答案类似地是“只有左子树的树”。最后,对于前序和后序遍历,答案是“最多只有一个键的树”。

12.13  通过遍历进行排序

首先,将所有的键值插入到二叉搜索树中,创建一个空数组,然后进行中序遍历,提供一个访问函数,将键值推入数组中。(你将在第 12.26 题中使用这个技术。)

12.14  通用顺序

以下代码可以实现。注意,代码中有两个递归调用和三个可能的 visit()调用:

const {isEmpty} = require("../binary_search_tree.js");

const anyOrder = (tree, order, visit = (x) => console.log(x)) => {
  if (!isEmpty(tree)) {
    order === "PRE" && visit(tree.key);
    anyOrder(tree.left, order, visit);
    order === "IN" && visit(tree.key);
    anyOrder(tree.right, order, visit);
    order === "POST" && visit(tree.key);
  }
};

12.15  无递归遍历

使用第十一章的栈。你可以为每种遍历方式做具体的解决方案,但我们选择一种通用的解决方案(实际上是实现之前的 anyOrder()函数),来展示栈如何让避免递归变得简单。

其思路是将待处理的操作推入栈中,这些操作有两种类型:访问一个键(类型“K”)或遍历一棵树(类型“T”)。你将这些操作推入栈中,主代码将是一个循环,弹出一个操作并执行它,这可能意味着访问或遍历,而遍历则会导致更多的操作被推入栈中:

const anyOrder = (tree, order, visit = (x) => console.log(x)) => {
  let pending = newStack();
  let type = "";
❶ pending = push(pending, {tree, type: "T"});

❷ while (!isEmptyStack(pending)) {
  ❸ [pending, {tree, type}] = pop(pending);

  ❹ if (!isEmptyTree(tree)) {
    ❺ if (type === "K") {
 visit(tree.key);
    ❻} else {
       if (order === "POST") {
       pending = push(pending, {tree, type: "K"});
       }
       pending = push(pending, {tree: tree.right, type: "T"});
       if (order === "IN") {
         pending = push(pending, {tree, type: "K"});
       }
       pending = push(pending, {tree: tree.left, type: "T"});
       if (order === "PRE") {
         pending = push(pending, {tree, type: "K"});
        }
      }
    }
  }
};

首先创建一个栈并压入你想遍历的树 ❶。当还有待处理的操作 ❷ 时,你将弹出栈顶操作 ❸,如果它不指向空树 ❹,你将执行所需操作。如果操作是 "K",则只访问该节点 ❺;如果操作是 "T" ❻,你需要压入两个操作(遍历左子树和右子树)和一个访问操作(对于根节点)。关键是确保按照反向顺序压入操作,这样操作将按正确的顺序弹出;仔细研究这一点。例如,如果你正在进行后序遍历,你将首先压入根节点访问操作,然后是右子树遍历,最后是左子树遍历——当你按反向顺序执行这些操作时,一切都会正确无误。

12.16  不允许重复

基本上,你只需要检查是否到达了你想要添加的值:

const add = (tree, keyToAdd) => {
  if (isEmpty(tree)) {
    return newNode(keyToAdd);
 ❶**} else if (keyToAdd === tree.key) {**
 **throw new Error("No duplicate keys allowed");**
  } else {
  ❷ const side = keyToAdd < tree.key ? "left" : "right";
    tree[side] = add(tree[side], keyToAdd);
    return tree;
  }
};

在继续搜索之前,添加一个相等性测试 ❶,另一个小变化是你不再测试“小于或等于” ❷,因为键值永远不能相等。

12.17  获取并删除

你可以同时获取最小值并将其删除,如果在(非空)树中找到最小值后,将其右子树复制到该节点;参考以下示例图:

下面是实现这个算法的方法:

const _removeMinFromTree = (tree) => {// not empty tree assumed
  if (isEmpty(tree.left)) {
  ❶ return [tree.right, tree.key];
  } else {
    let min;
  ❷ [tree.left, min] = _removeMin(tree.left);
  ❸ return [tree, min];
  }
};

假设树不为空,如果不能向左走,则返回右子树和节点的键,操作完成 ❶。否则,递归获取并删除左子树中的最小键 ❷,并返回更新后的树节点和找到的键 ❸。

如何使用它?remove() 方法中的变化很小,只影响一行代码:不再先找到最小键再删除,而是直接调用 _removeMin():

const remove = (tree, keyToRemove) => {
  if (isEmpty(tree)) {
    // nothing to do
  } else if (keyToRemove < tree.key) {
    tree.left = remove(tree.left, keyToRemove);
  } else if (keyToRemove > tree.key) {
    tree.right = remove(tree.right, keyToRemove);
  } else if (isEmpty(tree.left) && isEmpty(tree.right)) {
    tree = null;
  } else if (isEmpty(tree.left)) {
    tree = tree.right;
  } else if (isEmpty(tree.right)) {
    tree = tree.left;
  } else {
    **[tree.right, tree.key] = _removeMin(tree.right);**
  }
  return tree;
};

12.18  AVL 最差情况

假设 Hn 是最差情况下,具有高度 n 的 AVL 树中节点的数量。前几个这样的树如下所示:

从前两个树(加上根节点)构建下一个最差树,因此 H[n] 等于 H[n–][1] + H[n–][2] + 1:序列为 0, 1, 2, 4, 7, 12, 20, . . . ,比斐波那契序列 1, 2, 3, 5, 8, 13, 21, . . . 少 1。

12.19  仅限单一

鉴于 AVL 树的结构限制,只有叶子节点可以是单个子节点。由于每个单个子节点都有一个父节点,而树中可能有更多(非单个)节点,因此单个子节点不能超过所有节点的 50%。

12.20  为什么是一个?

如果子树的大小分别为 pq,则树的大小为 p + q + 1,分数分别是 (p + 1) / (p + q + 2) 和 (q + 1) / (p + q + 2),将分子相加得到正好是 (p + q + 2),即分母。

12.21  更容易随机化?

开发人员正确地指出,顺序排列的键会变得无序,但也会存在一种无序的键序列,在哈希之后会变得有序,因此,虽然这个技术解决了有序添加的问题,但它并不能完全解决最坏情况的问题。

12.22  为什么不减少?

如果你想删除的键不在树中,你仍然(错误地)会减少节点的大小。

12.23  错误的伸展树?

如果仅添加键,你将得到与常见的二叉搜索树相同的线性结构。然而,经过几次删除操作后,树的高度会显著降低(树变得更加“丛生”),而对于常见的二叉搜索树,形状仍然是线性的。

12.24  什么左子树?

它应该是空的。树中的最小键没有左子树;否则,它就不可能是最小的。

12.25  代码转换

当你根据一个键来伸展树时,最终的树将以最接近给定键的值为根,因此,如果键是 -Infinity(假设键是数字;对于字母键,空字符串也可以),你可以推断出 _splay(tree, -Infinity) 产生的结果与 _splayMinimum(tree) 相同。

但你可以进一步处理。假设将 _splay() 代码中的 keyToUp 设置为 -Infinity,这个值不可能出现在树中,并且比当前所有的键都要小。以下代码中高亮部分可以省略,因为结果已经可以得知,或者是无法访问的代码:

❶ const _splay = (tree**, keyToUp**) => {
❷ if (isEmpty(tree) **|| keyToUp === tree.key**) {
    return tree;
  } else {
  ❸ const side = **keyToUp < tree.key ?** "left" **: "right"**;
    if (isEmpty(tree[side])) {
     return tree;
  ❹**} else if (keyToUp === tree[side].key) {**
 **return _rotate(tree, side);**
   } else {
❺ **if (keyToUp <= tree[side].key === keyToUp <= tree.key) {**
     ❻ tree[side][side] = _splay(tree[side][side]**, keyToUp**);
       tree = _rotate(tree, side);
❼**} else {**
 **const other = side === "left" ? "right" : "left";**
 **tree[side][other] = _splay(tree[side][other], keyToUp);**
 **if (!isEmpty(tree[side][other])) {**
 **tree[side] = _rotate(tree[side], other);**
 **}**
 **}**
    return isEmpty(tree[side]) ? tree : _rotate(tree, side);
   }
 }
};

keyToUp 参数不需要 ❶ ❻,因为你已经假设了它的值。平等测试将始终失败 ❷ ❹。由于 keyToUp 小于任何键,side 变量将始终以“left”结尾 ❸,并且 ❺ 处的测试将始终成功,使得某些代码 ❼ 无法访问。

在这些简化后,将 _splay 重命名为 _splayMin,并将 tree[side] 改为 tree.left,留下如下代码:

const _splayMin = (tree) => {
  if (isEmpty(tree)) {
    return tree;
  } else {
    if (isEmpty(tree.left)) {
      return tree;
 } else {
      tree.left.left = _splayMin(tree.left.left);
      tree = _rotate(tree, "left");
      return isEmpty(tree.left) ? tree : _rotate(tree, "left");
    }
  }
};

现在将这段代码转换为 _splayMinimum() 很容易:将两个 if 语句合并成一个,并完成测试。

12.26  完全重平衡

这个思路是使用第 12.13 题中建议的技术来获取所有的键,并生成一个平衡的树,方法是将数组从中间拆分。这里的键将成为平衡树的根,根左边的键用于生成左子树,右边的键则用于生成右子树:

const {
  newBinaryTree,
  newNode,
  inOrder
} = require("../binary_search_tree.js");

❶ const _buildPerfect = (keys) => {
❷ if (keys.length === 0) {
    return newBinaryTree();
❸} else {
  ❹ const m = Math.floor(keys.length / 2);
  ❺ return newNode(
      keys[m],
      _buildPerfect(keys.slice(0, m)),
      _buildPerfect(keys.slice(m + 1))
    );
  }
};

❻ const restructure = (tree) => {
❼ const keys = [];
  inOrder(tree, (x) => keys.push(x));
❽ return _buildPerfect(keys);
};

从重平衡代码开始,_buildPerfect() ❶。给定一个键的数组,如果数组为空 ❷,则返回空树。否则 ❸,找到数组的中间点 ❹,并返回一个如前所述的节点 ❺;使用递归构建其平衡的子树。接下来,restructure() 函数 ❻ 就非常简短:使用 inOrder() ❼ 生成一个有序的键列表,并将其传递给 _buildPerfect() 函数,以产生最终的输出 ❽。

第十三章

13.1  缺少测试?

不需要;addChild() 已经处理了这部分。

13.2  遍历一般树

实现并不复杂。如果树是通过子节点数组来表示的,非空树的先序遍历需要先访问根节点,然后顺序遍历每个子节点。对于采用左孩子右兄弟表示法的树,逻辑是先访问根节点,然后从第一个子节点开始,遍历该子节点并移动到下一个兄弟节点,直到没有更多的兄弟节点。在这两种情况下,后序遍历首先访问子节点,最后访问根节点。

13.3 非递归访问

该解决方案与广度优先队列版本非常相似:

depthFirstNonRecursive(visit = (x) => console.log(x)) {
❶ if (!isEmptyTree(tree)) {
  ❷ const s = new Stack();
    s = push(s, tree);
  ❸ while (isEmptyStack(s)) {**DZ missing ! operator**
      let t;
    ❹ [s, t] = s.pop();
      visit(t.key);
    ❺ [...t.childNodes].reverse().forEach((v) => {s = push(s, v);});
    }
  }
}

与其他遍历一样,如果树为空,你不需要做任何操作 ❶。否则,你创建一个栈,并将树的根节点压入栈中 ❷。然后,你执行一个循环。当栈不为空时 ❸,弹出栈顶元素 ❹ 并访问它,然后完成这个棘手的细节:你必须按 反向 顺序推送所有子节点(最右侧的节点先推,最左侧的节点最后推),这样第一个子节点会先被访问 ❺。使用 reverse() 时要小心,因为它会修改原数组,所以需要使用解构赋值创建一个副本。

13.4 树的相等性

你可以通过递归的逻辑来比较树,但有一个更简单的解决方案:使用 JSON.stringify() 生成两个树的字符串版本并进行比较。

13.5 测量树

该代码与第十二章中用于二叉树的代码类似:

const {Tree} = require("../tree.class.js");

class TreeWithMeasuring extends Tree {
  calcSize() {
    return this.isEmpty()
 ❶ ? 0
    ❷ : 1 + this._children.reduce((a, v) => a + v.calcSize(), 0);
  }

  calcHeight() {
    if (this.isEmpty()) {
    ❸ return 0;
    } else if (this._children.length === 0) {
    ❹ return 1;
    } else {
    ❺ return 1 + Math.max(...this._children.map((v) => v.calcHeight()));
    }
  }
}

空树的大小为 0 ❶;否则,它的大小为 1(根节点本身)加上所有子树大小的总和 ❷,你可以通过 .reduce() 来计算。然后,对于高度,空树的高度为 0 ❸,叶子节点的高度为 1 ❹,其他树的高度为 1(根节点)加上任意子树的最大高度 ❺。

13.6 更多的共享

在实现中,由于我们只从兄弟节点借用了一个键,所以代码比较简单。如果要均分,你应该设置一个数组,包含来自左兄弟的所有键、父节点的键和右兄弟的所有键,然后将其分配。中间位置的键将进入父节点,左侧的所有键进入左兄弟,右侧的所有键进入右兄弟——对于节点中的指针,也会应用类似的过程。

13.7 更快的节点搜索

第九章中的二分查找算法,在搜索失败时,会让左索引指向应该跟随的链接;查看此代码:

const _findIndex = (tree, key) => {
  let l = 0;
  let r = tree.keys.length - 1;

  while (l <= r) {
    const m = Math.floor((l + r) / 2);
    if (tree.keys[m] === key) {
      return m;
    } else if (tree.keys[m] > key) {
      r = m - 1;
    } else {
      l = m + 1;
    }
  }
 **return l;**
};

这个算法是你之前看到的标准算法;不同之处在于,如果搜索失败,它会返回左指针。至于对 B 树性能的影响,节点内的搜索通过一个常数因子加速(如果 B 树的阶为 p,那么我们执行 log p 次测试,而不是 p 次),但算法的总体时间复杂度仍然是 O(log n)。

13.8 最低阶

在一个 2 阶 B 树中,根节点以外的节点应该有一个键值,因此有两个子节点,这意味着它是一个完全二叉树,因为所有叶子节点必须位于同一层级。所以,是的,你已经了解了 2 阶 B 树!

13.9  多种树的顺序

默认情况下,模块是单例模式,这意味着代码只会被导入一次,因此你创建的所有树都会共享相同的 ORDER 变量。如果你想要不同的变量,而不是导出一个包含多个属性的对象(在 module.exports 中),你需要导出一个函数,该函数在调用时返回所需的对象。你可以在 Iain Collins 的《如何(不)在 Node.js 中创建单例》一文中看到一些此类转换的例子,链接为medium.com/@iaincollins/how-not-to-create-a-singleton-in-node-js-bd7fde5361f5

13.10  可以删除吗?

当删除一个没有右子节点的节点时,根节点为红色,意味着它将没有左子节点。如果根节点是黑色的(考虑到不变性,这是另一种可能性),那么它的左子节点会是红色的,你会将右旋,这样它就不会有空的右子节点了。

第十四章

14.1  它是一个堆吗?

简单地遍历所有根节点之外的元素,并检查每个元素是否不大于其父节点。一个初步实现可能是:

function isHeap1(v) {
  for (let i = 1; i < v.length; i++) {
    if (v[i] > v[Math.floor((i - 1) / 2)]) {
      return false;
    }
  }
  return true;
}

你还可以使用 .every() 来简化代码,使其更加声明式:

function isHeap2(heap) {
  return heap.every(    (v, i) => i === 0 || v <= heap[Math.floor((i - 1) / 2)],
  );

14.2  队列的应用

如果你为进入优先队列的元素分配单调递增的值,优先队列将表现得像栈一样,遵循后进先出(LIFO)方式。类似地,如果为元素分配单调递减的值,优先队列将模拟一个常规队列。

14.3  最大到最小

这是一个 trick 问题!使用 Floyd 改进的堆构建代码,你可以在线性时间内将任何数组转换为最小堆,因此显然你可以在这个时间内将一个最大堆转换为最小堆。

14.4  最大或最小

只需要三处代码更改:在 _bubbleUp()函数中进行一次更改,在 _sinkDown()函数中进行两次更改。将当前比较从 heap[a] > heap[b]反转为 heap[a] < heap[b],这样就完成了。

14.5  合并吧!

所需的算法如下:创建一个最小堆,其节点将来自各个列表。通过取每个列表的第一个元素来初始化堆。初始化一个空的输出列表。在堆不为空时,反复执行以下步骤:选择堆根节点对应的节点,并将其从堆中移除。将选中的节点添加到输出列表。如果选中的节点有下一个节点,将其添加到堆中。

假设节点具有键值和下一个字段,代码(基于本章开发的堆,但通过反转一些比较以产生一个最小堆,如问题 14.4 所示)可能如下所示:

function merge_away(lists) {
  const heap = [];

  const add = (node) => {
    const _bubbleUp = (i) => {
      // Bubble up heap[i] comparing by heap[i].key
      // (you'll have to modify the bubbling code we
      // saw a bit for this).
    };

  ❶ if (node) {
      heap.push(node);
      _bubbleUp(heap.length - 1);
    }

    const remove = () => {
      const _sinkDown = (i, h) => {
        // sink down heap[i] comparing by heap[i].key
 };
    };

    const node = heap[0];
    heap[0] = heap[heap.length - 1];
    heap.pop();
    _sinkDown(0, heap.length);
    return node;
  };

❷ lists.forEach((list) => add(list));
❸ const first = {next: null};
  let last = first;
❹ while (heap.length > 0) {
    const node = remove();
  ❺ add(node.next);
  ❻ last.next = node;
    last = node;
    node.next = null;
  }
❼ return first.next;
}

add() 方法将一个节点压入堆栈(除非它为 null),并将其向上冒泡 ❶。完整的逻辑需要通过将每个列表的第一个元素设置到堆中 ❷,然后进行合并。为输出列表添加一个空的初始值 ❸ 可以简化代码;记得在返回合并后的列表时跳过这个额外的节点 ❼。当堆中仍然有节点 ❹(意味着仍然需要进行合并)时,移除堆顶元素,将相应列表的下一个节点添加到堆中 ❺,并将移除的元素添加到列表的末尾 ❻;虚拟节点避免了需要检查空列表的情况。最后一步仅返回列表,不包含额外的初始节点 ❼。

你可以轻松地测试这段代码。下面的代码输出一些从 1 开始的斐波那契数:

const list1 = {
  key: 2,
  next: {key: 3, next: {key: 8, next: null}},
};

const list2 = {
  key: 1,
  next: {key: 13, next: {key: 55, next: null}},
};

const list3 = null;

const list4 = {key: 21, next: null};

const list5 = {key: 5, next: {key: 34, next: null}};

const merged = merge_away([list1, list2, list3, list4, list5]);

let p = merged;
while (p) {
  console.log(p.key);
  p = p.next;
}

14.6  堆的搜索

你可以编写递归函数,但由于数组基本上是无序的,对于堆中的任何节点,你必须在其左子树和右子树中都进行搜索,因此你最终将不得不遍历整个树,这样的时间复杂度是 O(n)。最好还是直接使用 heap.find()。

14.7  从堆的中间删除元素

更改堆中的键有点类似于删除键:

const removeMiddle = (heap, k) => {
❶ if (isEmpty(heap)) {
    throw new Error("Empty heap; cannot remove");
❷} else if (k < 0 || k >= heap.length) {
    throw new Error("Not valid argument for removeMiddle");
  } else {
  ❸ [heap[k], heap[heap.length - 1]] = [heap[heap.length - 1], heap[k]];
    heap.pop();
  ❹ _bubbleUp(heap, k);
  ❺ _sinkDown(heap, k, heap.length);
  }
  return heap;
};

在进行几次检查以确保可以执行删除操作 ❶ ❷ 后,将堆中的最后一个值移到被删除值的位置 ❸ 可以恢复堆的结构属性。问题是,当前位于索引 k 的值可能不再正确放置,从而违反堆的性质。确保这一点的最简单方法是首先应用 _bubbleUp() ❹,然后应用 _sinkDown() ❺。最多只有其中一个函数会做任何事情,最终你将得到一个完全符合堆性质的堆。

14.8  更快的构建

newHeap() 函数的更改如下:

const newHeap = (values = []) => {
  const newH = [];
❶ values.forEach((v) => newH.push(v));
  for (let i = Math.floor((newH.length - 1) / 2); i >= 0; i--) {
  ❷ _sinkDown(newH, i, newH.length);
  }
  return newH;
};

这段代码从一个空数组开始,并将值(如果有的话)复制到数组中 ❶;然后使用 _sinkDown() 构建堆 ❷,并返回这个堆。

14.9  另一种循环方式

你可以以如下方式使用 forEach()(这里只关注 i 参数):

v.forEach((_, i) => _bubbleUp(v, i));

14.10  额外的循环?

它的工作方式与之前相同(只会稍微慢一点),因为 _sinkDown() 程序对没有子节点的元素不会做任何事情。

14.11  最大均衡

它的时间复杂度是 O(n)。由于 _sinkDown() 和 _bubbleUp() 不会做任何工作,它们的时间复杂度是 O(1),而且这两个函数总共会被调用 n 次。

14.12  不稳定的堆?

一个简单的数组适用于堆排序代码的两个版本:[1, 1]。第一个 1 将排到已排序数组的最后位置。

14.13  修剪选择

如果你在堆的第 i 层找到一个值,你可以确定有至少 (i – 1) 个比它大的值,这是由于堆的性质。因此,如果你正在寻找堆中的 k 个最大值,它们不可能出现在 (k + 1) 层或更深的层次。如果堆的层数超过 k 层,你可以丢弃所有超出 k 层的部分,选择过程会稍微快一点。具有 k 完整层的堆有 2^k – 1 个节点,因此如果堆的节点数超过这个值,你可以将其缩短:

function selection(k, values) {
  const heap = [];

  const _sinkDown = // ...omitted here...

  // Build heap out of values.
  values.forEach((v) => heap.push(v));
  for (let i = Math.floor((heap.length - 1) / 2); i >= 0; i--) {
    _sinkDown(i, heap.length);
  }

  // Trim the heap, if possible.
 **const maxPlace = 2 ** k - 1;**
 **if (heap.length > maxPlace) {**
❶ **heap.length = maxPlace;**
 **}**

  // Do the selection.
  for (let i = heap.length - 1; i >= heap.length - k; i--) {
    [heap[i], heap[0]] = [heap[0], heap[i]];
 _sinkDown(0, i);
  }

  return heap.slice(heap.length - k);
}

你可以使用 .slice() 来缩短堆数组,但 JavaScript 允许你直接修改它的 .length 属性 ❶。

14.14  它是一个 Treap 吗?

你可以通过具有函数式特征的递归解法来得到一个有趣的解决方案。什么是有效的 treap?如果它为空,显然是有效的;否则,它的子节点也应该是 treap:

function isTreap(tr, valid = () => true) {
  return (
    tr === null ||
  ❶ (valid(tr) &&
    ❷ isTreap(
        tr.left,
        (t) => t.key <= tr.key && t.priority <= tr.priority,
      ) &&
    ❸ isTreap(
        tr.right,
        (t) => t.key >= tr.key && t.priority <= tr.priority,
      ))
  );
}

这检查了一个基本条件,涉及将当前节点的键值和优先级与父节点的键值和优先级进行比较 ❶,但请注意,第一次对于根节点来说,提供了一个简单的验证,因为显然根节点没有父节点可以比较!然后,递归地检查两个子节点是否也都是 treap,每个子节点都要满足一个新的不同条件:左子树的键值和优先级应该小于父节点 ❷,右子树的键值应大于父节点,但优先级较低 ❸。

14.15  Treap 分裂

将限制值添加到具有无限优先级的 treap 中:由于优先级高,这个限制值将成为 treap 的新根节点,且由于二叉搜索树结构,所有小于限制值的键会在根节点的左子树中,而较大的键则会在右子树中,从而实现所需的分区。

14.16  重新连接两个 Treap

要将两个独立的 treap 合并为一个,可以创建一个带有随机键值和优先级的虚拟节点,然后将第一个 treap 作为其左子树,第二个 treap 作为其右子树,最后删除根虚拟节点。

14.17  从 Treap 中删除

这个方法也可以按相同的方式工作,但会稍微慢一点,因为它会在进入树的其他部分之前执行一系列的 if 语句。

14.18  作为堆的树

使用平衡的二叉搜索树可以确保所有三种操作都具有对数性能,正如你之前所见。然而,你可以通过引入一个独立的属性来表示到目前为止的最大值来做得更好。每次添加新节点时,你只需通过比较当前最大值和新键值,就可以在 O(1) 时间内更新该属性,当移除顶部元素时,你可以在对数时间内找到新的最大值,因此 top() 本身将变为 O(1)。

第十五章

15.1  直观但较差

这将是 O(m log n),因为有 m 次插入操作,每次是 O(log n)。

15.2  顺序案例

当按降序插入键(最高值先插入)时,您会得到一个形状不佳的堆,但每次调用 add()的时间是常数时间。你能看出原因吗?如果按升序插入,则可以实现一个完整的二叉树;请查看下方展示的从 1 到 7 的两种情况:

15.3  不需要递归

你可以按如下方式编写合并操作;粗体行显示了更改:

const merge = (heap1, heap2) => {
  if (isEmpty(heap2)) {
    return heap1;
  } else if (isEmpty(heap1)) {
    return heap2;
  } else if (goesHigher(heap1.key, heap2.key)) {
    [heap1.left, heap1.right] = [merge(heap2, heap1.right), heap1.left];
    return heap1;
  } else {
 **[heap2.left, heap2.right] = [merge(heap1, heap2.right), heap2.left];**
 **return heap2;**
 }
};

新的行与之前的情况相同(当第一个堆拥有最大键时),只是它们交换了堆 1 和堆 2 的位置。

15.4  需要更改

对于偏斜堆,change()方法需要能够移除一个键,并在修改后重新插入它。给定一个指向该键所在节点的引用,移除它需要一个指向父节点的链接(需要从父节点断开连接),因此应该添加一个指向节点父节点的向上指针。你也可以选择使用 bubbleUp(),但它也需要一个指向父节点的链接。

15.5  仅仅添加

你最初只有一棵包含八个节点的树。下表展示了每个阶段的堆情况,以及添加一个值后需要多少次合并;粗体显示的单元项代表合并。例如,添加第 9 个值不需要合并,但添加第 10 个值会产生两个大小为 1 的堆,因此需要一次合并将它们替换为一个大小为 2 的堆。同样,添加第 11 个值不需要合并,但第 12 个值需要两次合并:第一次合并两个大小为 1 的堆,第二次合并两个大小为 2 的堆。

1 2 4 8 16
8 0 0 0 1 0
9 1 0 0 1 0
10 0 1 0 1 0
11 1 1 0 1 0
12 0 0 1 1 0
13 1 0 1 1 0
14 0 1 1 1 0
15 1 1 1 1 0
16 0 0 0 0 1

你有一个初始堆,n = 8;在添加其他八个值后,总共进行了八次合并(用粗体显示),所以平均而言,每次添加都需要一次合并。当然,这不是一个正式的证明,但可以数学上证明这一结果对于所有二项堆都成立。

15.6  更快的二项堆顶

你可以在懒惰二项堆中添加一个变量,如 _heapTop,以获取堆的顶值。

15.7  更容易向上冒泡?

问题在于你正在处理一个可寻址的堆,如果你改变了键的顺序,旧的节点引用将不再有效,并且会指向不同的值。

15.8  搜索堆

这个算法显然是O(n)的,当然不是你在处理堆时会做的事情,但我们还是来试试。唯一的问题是要注意何时停止遍历兄弟节点的循环链表:

❶ _findInTree(tree, keyToFind, stopT = null) {
❷ let node = null;
❸ if (tree && tree !== stopT) {
  ❹ if (tree.key === keyToFind) {
      node = tree;
    } else {
      node =
      ❺ this._findInTree(tree.down, keyToFind) ||
      ❻ this._findInTree(tree.right, keyToFind, stopT || tree);
    }
  }
❼ return node;
}

这是深度优先遍历。stopT 参数记住了你在兄弟节点列表中的起始位置,以避免循环 ❶。使用 node 来存储 null 值(如果找不到键)或者找到的节点 ❷。如果你正在查看的树既不是 null,也不是列表的停止点 ❸,检查根节点是否是你要找的键 ❹;如果是,保存并稍后返回 ❼。如果根节点不匹配,则向下搜索 ❺,如果返回 null,则向右搜索,并将起始点作为停止值 ❻。使用 xxx || yyy 是 JavaScript 中的典型用法;如果 xxx 表达式的值不是“假值”,则返回其值;否则,返回 yyy 表达式的值。

15.9  一物两用

在 _mergeA2B()中,你可以写:

if (high._down) {
  low.right = high.down;
  low.left = high.down.left;
  **high.down.left.right = high.down.left** = low;
}

在 add()中,你可以将两个赋值语句合并:

**newTree.left = newTree.right** = newTree;

在 remove()中,你可以做类似的事情:

**bt.right = bt.left** = bt;

这也适用于 _separate():

**node.left = node.right** = node;

这样做值得吗?节省了四行代码,但可能会导致误读或误解代码;你自己决定!

第十六章

16.1  字典树的映射

关于基于对象的字典树所需的更改如下;我将注释留给你,但修改的行已经加粗。创建字典树需要以下步骤:

const newNode = () => **({links: new Map()})**;

查找键只是改变了你访问链接的方式:

const _find = (trie, [first, . . .rest]) => {
  if (isEmpty(trie)) {
    return null;
  } else if (first === EOW) {
    return isEmpty(trie.links.get(first))
      ? null
      : trie.links.get(first).data;
  } else {
    return _find(**trie.links.get(first)**, rest);
  }
};

添加新键时,进行相同的更改:

const _add = (trie, [first, . . .rest], data) => {
  if (first) {
    if (isEmpty(trie)) {
      trie = newNode();
    }
    if (first === EOW) {
      **trie.links.set(first, {data})**;
    } else {
      **trie.links.set(first, _add(trie.links.get(first)**, rest, data));
    }
  }
  return trie;
};

当删除键时,也会发生类似的事情:

const _remove = (trie, [first, . . .rest]) => {
  if (isEmpty(trie)) {
    // nothing to do
  } else if (!first) {
    trie = null;
  } else {
    **trie.links.set(first**, _remove(**trie.links.get(first)**, rest));
    if (isEmpty(**trie.links.get(first))**) {
      **trie.links.delete(first)**;
      if (**trie.links.size** === 0) {
        trie = null;
      }
    }
  }
  return trie;
};

16.2  永远为空?

如果你尝试添加一个已经存在于字典树中的键,那么答案是肯定的。

16.3  旋转你的字典树

是的,你可以对字典树进行旋转。你只需要操作左右链接,永远不影响中间链接。

16.4  中间为空?

当然,除非你不想存储任何数据,在这种情况下,EOW 字符的中间链接将为空。

16.5  四字母字典树?

最长路径(字典树的高度)是从根节点到 ZZZZ 的 EOW:104 步。键的字符数是 4,字母表有 26 个字母,这意味着高度如下:4 × 26 = 104。

16.6  它们看起来怎样?

无论是基于数组的字典树还是基于对象的字典树,都会是一个节点列表,每个字母对应一个节点。基数树会有一个单独的节点,里面包含“ALGORITHM”这个单词。最后,三叉树则会是一列垂直排列的节点,每一层都有一个字母。

第十七章

17.1  路径在哪里?

添加一个 next[i][j]矩阵,告诉你如果你在 i 位置,想要到达 j 位置应该走哪里。每当你更新 dist[i][j]时,也会更新 next。以下是更新后的算法:

const distances = (graph) => {
  const n = graph.length;

  const distance = [];
❶ const next = [];
  for (let i = 0; i < n; i++) {
    distance[i] = Array(n).fill(+Infinity);
  ❷ next[i] = Array(n).fill(null);
  }

  graph.forEach((r, i) => {
    distance[i][i] = 0;
  ❸ next[i][i] = i;
    r.forEach((c, j) => {
      if (c > 0) {
        distance[i][j] = graph[i][j];
      ❹ next[i][j] = j;
      }
    });
  });

  for (let k = 0; k < n; k++) {
    for (let i = 0; i < n; i++) {
      for (let j = 0; j < n; j++) {
        ❺ if (distance[i][j] > distance[i][k] + distance[k][j]) {
          distance[i][j] = distance[i][k] + distance[k][j];
          next[i][j] = next[i][k];
        }
      }
    }
  }

❻ return [distance, next];
};

定义下一个矩阵 ❶,并将其填充为 null 值作为默认值 ❷。标记从一个点到自身的路径显然会经过该点 ❸,并且每当两个点之间有一条边时,也标记出来 ❹。每当你找到两个点之间更好的路径时 ❺,不仅更新 dist,还要更新 next。最后,你必须返回 next 矩阵 ❻,你将在以下路径寻找算法中使用它:

const path = (next, u, v) => {
❶ const sequence = [];
❷ if (next[u][v] !== null) {
  ❸ sequence.push(u);
  ❹ while (u !== v) {
     ❺ u = next[u][v];
     ❻ sequence.push(u);
    }
  }
❼ return sequence;
};

创建一个序列数组 ❶,它将包含所有中间步骤。如果有从第一点到最后一点的路径 ❷,则压入起始点 ❸,如果尚未到达目标 ❹,则前进到下一个点 ❺ 并将其压入 ❻。最后,只需返回包含所有步骤的序列 ❼。

17.2  提前停止搜索

确保检查每次遍历是否有任何更改。算法的主循环变化如下:

for (let i = 0; i < n - 1; i++) {
❶ let changes = false;
  edges.forEach((v) => {
    const w = v.dist;
    if (distance[v.from] + w < distance[v.to]) {
      distance[v.to] = distance[v.from] + w;
      previous[v.to] = v.from;
    ❷ changes = true;
    }
  });
❸ if (!changes) {
    break;
  }
}

在每次遍历开始时,将更改变量设置为 false ❶,但如果更改了任何距离,则将其设置为 true ❷。遍历完所有边 ❸ 后,如果没有任何更改,则无需重复循环,可以退出。

17.3  一个就够了

你必须修改函数签名,接收三个参数(图、起点和目标点),并更改主循环,检查堆顶是否是目标点;如果是,则停止:

while (heap.length **&& heap[0] !== to**) {

17.4  走错路

输出是原始图的拓扑排序,但顺序相反:首先出现的节点是前一个图中最后出现的节点。

17.5  更快地合并集合

将会有两个变化。首先,森林中的所有节点将包括一个大小属性,初始值为 1:

const groups = Array(n)
  .fill(0)
  .map(() => ({ptr: null, **size: 1**}));

另一个变化出现在将两个集合合并为一个集合时。isConnected(...)代码的主循环变化如下:

for (let i = 0; i < n; i++) {
  for (let j = i + 1; j < n; j++) {
    if (graph[i][j]) {
      const pf = findParent(groups[i]);
      const pt = findParent(groups[j]);

      if (pf !== pt) {
        count--;
 **if (pf.size < pt.size) {**
 **pt.size += pf.size;**
 **pf.ptr = pt;**
 **} else {**
 **pf.size += pt.size;**
 **pt.ptr = pf;**
 **}**
      }
    }
  }
}

粗体标记的代码表示变化。如果发现两个不同的根,检查哪个根的大小最小,并将其连接到另一个根。不要忘记更新其大小以考虑添加的子集。

17.6  走捷径

以下修改过的 findParent(...)例程按照描述创建了捷径;你能看出它是如何工作的?

const findParent = (x) => {
  if (x.ptr !== null) {
    x.ptr = findParent(x.ptr);
    return x.ptr;
  } else {
    return x;
  }
};

17.7  树的生成树?

产生的图完全相同:在树中,从任何一点到另一点只有一条路径,因此没有替代方案来构造不同的生成树。

17.8  一堆边

这个由你来决定,但我选择了堆排序,因为它的性能有保障。

第十八章

18.1  如何到达这里

前端列表不为空,这意味着有人退出了队列。一个可能的序列如下:X 进入,然后 A 进入,然后 B 进入,然后 X 退出(此时后端列表为空,前端列表包含 A 和 B),然后依次进入 C、D 和 E。

18.2  向阿博特与科斯特洛道歉,谁在前面?

以下逻辑解决了问题:

const front = (queue) => {
❶ if (isEmpty(queue)) {
    return undefined;
❷} else if (queue.frontPart !== null) {
    return queue.frontPart.value;
❸} else {
    let ptr = queue.backPart;
    while (ptr.next !== null) {
      ptr = ptr.next;
    }
    return ptr.value;
  }
};

如果队列为空 ❶,则没有前端元素;返回未定义、抛出异常或执行其他类似操作。如果前端不为空 ❷,其顶部元素就是队列的前端。但如果前端为空 ❸,则遍历后端直到其末尾,因为那就是队列的前端。

18.3  不需要更改

有很多种可能性,但最简单的方法是在没有找到键时抛出异常,并将树搜索算法包装在 try...catch 结构中,这样如果抛出异常,就只需返回原始树。

18.4  一个新的最小值

记住,你需要应用这个函数来查找非空二叉搜索树的最小值:

const minKey = (tree) =>   isEmpty(tree.left) ? tree.key : minKey(tree.left);

如果根节点没有左子节点,则根节点的值就是最小值。否则,树的最小值将是根节点左子节点的最小值,因为该子树中的所有值都比根节点的值小。

第二十章:参考书目

  • Aguilar, Luis Joyanes. 编程基础:算法、数据结构与对象. 第 4 版. 马德里:McGraw-Hill,2008 年。

  • Aravinth, Anto. 开始学习函数式 JavaScript. 加利福尼亚州伯克利:Apress,2017 年。

  • Atencio, Luis. JavaScript 中的函数式编程. 纽约:Manning 出版公司,2016 年。

  • Bae, Sammie. JavaScript 数据结构与算法. 加利福尼亚州伯克利:Apress,2019 年。

  • Baldwin, Douglas, 和 Greg Scragg. 算法与数据结构:计算科学. 波士顿:Charles River Media,2004 年。

  • Bird, Richard, 和 Philip Wadler. 函数式编程导论. 新泽西州霍博肯:Prentice Hall International,1988 年。

  • Braithwaite, Reginald. JavaScript Allongé. 第 6 版. leanpub.com/javascriptallongesix/read.

  • Brass, Peter. 高级数据结构. 英国剑桥:剑桥大学出版社,2008 年。

  • Cormen, Thomas, Charles Leiserson, Ronald Rivest, 和 Clifford Stein. 算法导论. 第 3 版. 马萨诸塞州剑桥:MIT 出版社,2009 年。

  • Dale, Nell. C++与数据结构. 第 3 版. 佛蒙特州伯灵顿:Jones and Bartlett 出版社,2003 年。

  • Drozdek, Adam. Java 中的数据结构与算法. 第 2 版. 波士顿:Thomson Learning,2005 年。

  • Fogus, Michael. 函数式 JavaScript. 加利福尼亚州塞巴斯托波尔:O'Reilly Media,2013 年。

  • Goldman, Sally, 和 Kenneth Goldman. 使用 Java 的实用数据结构与算法指南. 佛罗里达州博卡拉顿:Chapman & Hall/CRC 出版社,2008 年。

  • Groner, Loiane. 学习 JavaScript 数据结构与算法. 第 3 版. 英国伯明翰:Packt 出版公司,2018 年。

  • Harmes, Ross, 和 Dustin Díaz. JavaScript 设计模式实战. 加利福尼亚州伯克利:Apress,2008 年。

  • Jansen, Remo. TypeScript 中的函数式编程实战. 英国伯明翰:Packt 出版公司,2019 年。

  • Karumanchi, Narasimha. 数据结构与算法简明教程. 印度海得拉巴,特伦甘纳邦:CareerMonk 出版公司,2010 年。

  • Kereki, Federico. 精通 JavaScript 函数式编程. 第 3 版. 英国伯明翰:Packt 出版公司,2023 年。

  • Khot, Atul, 和 Raju Kumar Mishra. 学习函数式数据结构与算法. 英国伯明翰:Packt 出版公司,2017 年。

  • Knuth, Donald. 计算机程序设计的艺术,第 1 卷:基础算法. 第 3 版. 波士顿:Addison-Wesley,1997 年。

  • ______. 计算机程序设计的艺术,第 2 卷:半数值算法. 第 3 版. 波士顿:Addison-Wesley,1997 年。

  • ______. 计算机程序设计的艺术,第 3 卷:排序与查找. 第 2 版. 波士顿:Addison-Wesley,1998 年。

  • Lonsdorf, Brian (Dr. Boolean). 弗里斯比教授的函数式编程足够指南. github.com/MostlyAdequate/mostly-adequate-guide.

  • Mantyla, Dan. JavaScript 中的函数式编程. 英国伯明翰:Packt 出版公司,2015 年。

  • Masood, Adnan. 学习 F#函数式数据结构与算法. 英国伯明翰:Packt 出版公司,2015 年。

  • McMillan, Michael. 使用 JavaScript 的数据结构与算法. Sebastopol, CA: O’Reilly Media, 2014.

  • Mehlhorn, Kurt, 和 Peter Sanders. 算法与数据结构:基本工具箱. Berlin: Springer, 2008.

  • Morin, Pat. 开放数据结构. Edmonton, AB: AU Press, 2013.

  • Mukkamala, Kashyap. JavaScript 数据结构与算法实战. Birmingham, UK: Packt Publishing, 2018.

  • Okasaki, Chris. 纯函数式数据结构. Cambridge, UK: Cambridge University Press, 1998.

  • Parker, Alan. C++中的算法与数据结构. Boca Raton, FL: CRC Press, 1993.

  • Sedgewick, Robert. C 中的算法. Boston: Addison-Wesley, 1990.

  • Sedgewick, Robert, 和 Philippe Flajolet. 算法分析导论. 第 2 版. Boston: Addison-Wesley, 2013.

  • Sedgewick, Robert, 和 Kevin Wayne. 算法. 第 4 版. Boston: Addison-Wesley, 2011.

  • Simpson, Kyle. Functional-Light JavaScript. github.com/getify/Functional-Light-JS.

  • Thareja, Reema. 使用 C 的数据结构. 第 2 版. New Delhi: Oxford University Press, 2014.

  • Wengrow, Jay. 数据结构与算法的常识指南. Raleigh, NC: The Pragmatic Programmer, 2017.

  • Wirth, Niklaus. 算法 + 数据结构 = 程序. Englewood Cliffs, NJ: Prentice-Hall, 1976.

第二十一章:索引

  • 数字

  • 2-3 树,304,311–312。另见 B 树

  • A

  • 抽象数据类型(ADT),37–47

  • 抽象,39

  • 创建者,40

  • 实现,40–46

  • 使用类,41

  • 使用函数,43,45

  • 变异,39–40

  • 修改器,40,44–45

  • 观察者,40

  • 操作,39–40

  • 生产者,40

  • 抽象,38,39

  • 自适应排序,92

  • 可寻址堆,346

  • 阿德尔森-维尔斯基,格奥尔基,249

  • 抽象数据类型(ADT),37–47

  • 算法

  • 分析,47,50,52,55

  • 回溯,69–71,89

  • 暴力破解,82

  • 复杂度,50,52

  • 设计,63

  • 分治法,65–68,72

  • 性能,50–58

  • 摊销时间性能,54

  • 弓箭谜题,89

  • 确保平衡的二叉搜索树,249

  • 渐进符号,51

  • 算法的平均情况性能,54

  • AVL 树,235,249–254,261

  • 添加到其中,251

  • 创建,250

  • 节点数,280

  • 性能,255

  • 从中移除,251

  • 旋转,252

  • B

  • 巴别塔,13,19

  • 回溯,69,70,71,89

  • 胶囊,40–47,203–205,219–220,232–233

  • 使用二叉搜索树实现,239

  • 使用列表实现,207

  • 对其的操作,204

  • 拜尔,鲁道夫,291

  • BB[α] 树。参见 权重平衡有界树

  • 贝尔曼-福特最短路径算法,466

  • 算法的最佳情况性能,54

  • 双向冒泡排序,99

  • 拜尔斯,安布罗斯,218

  • 大 Omega 符号,51

  • O 符号,51–59

  • 大 Θ 表示法,51

  • 二叉堆,318,340,341

  • 二叉搜索,56–57,59,166–168,172

  • 二叉搜索树,239–282,485

  • 向其中添加值,241–242

  • 保证平衡,249–261

  • AVL 树,249–254,261

  • 背包,实施,239

  • 平衡,249–250

  • 从中删除,485

  • 寻找最大值,244–245

  • 中序遍历,246–248,280

  • 集合上的操作,239

  • 性能,248–249

  • 后序遍历,246,280

  • 先序遍历,246,279–280

  • 概率平衡,249,261–278,281–282

  • 随机化二叉搜索树,262–270,281

  • 重新平衡,282

  • 红黑树,304–314

  • 从中移除值,242–244

  • 搜索,239–241

  • 自调整树,249,261–278,281–282

  • 集合,实施,239

  • 伸展树,270–278,281–282

  • 遍历,246–248,280

  • 权重约束平衡树,255–261,281

  • 二叉树,237–282,287,288,304

  • 2-3 树,304,311–312

  • 完全,238,244,247,279

  • 复制,279

  • 满,237–238,279,280

  • 堆有序,347

  • 高度,237,238,249–257,279,280

  • 完美,238,248,256,279,282

  • 大小,279

  • 二项堆,351–367,385

  • 向其中添加值,354–356

  • 更改值, 360–362

  • 实现, 353–354

  • 合并, 356–358

  • 性能, 362–363

  • 从中移除值, 358–360

  • 二项树, 351–352

  • 位图, 206

  • 位图选择, 122–123

  • 位图排序, 112–113

  • 博戈排序, 117

  • 穷举算法, 82–86, 87

  • B 树, 291–303, 315。另见 红黑树

  • 向其中添加键, 295–298

  • 实现, 292–293

  • 中序遍历, 294–295

  • 优化, 315

  • 性能, 303

  • 从中移除键, 298–303

  • 在其中搜索, 293–294

  • 遍历, 294–295

  • 冒泡排序, 97–98, 100, 103–104, 118

  • Burton, F. Warren, 470

  • C

  • 链接, 使用哈希的, 219–221

  • 制定变更, 87

  • 循环列表, 195–200

  • 向其中添加元素, 197

  • 实现, 198–199

  • 连接, 200

  • 在其上的操作, 196

  • 性能, 199–200

  • 从中移除元素, 197–199

  • 鸡尾酒摇晃排序(穿梭排序), 99–100

  • 投币洗牌, 140–141

  • 梳理排序, 103–104

  • 算法复杂度, 50–58

  • 连通性检测, 428, 453–458

  • 基于搜索的算法, 456–458

  • 基于集合的算法, 454–456

  • 计数选择, 123–124

  • 计数排序, 114–115

  • 密码算术谜题(密码算式), 83–86, 90

  • 循环检测, 427, 452–453

  • Tarjan 算法, 448, 452–453

  • D

  • d-叉堆, 340–341

  • 数据结构

  • 二叉搜索树, 239–282, 485

  • 双端队列, 191–195, 201

  • 哈希表。 哈希

  • 堆。

  • 列表, 177–184, 195–200, 201

  • 队列,188–191,201,326–327,342,346–347,531

  • 栈,184–188,200,201

  • treap,332–340,343

  • 树。

  • 前缀树。 tries

  • 声明式编程,26–30

  • 双端队列,191–195,201

  • 向中添加元素,192–194

  • 实现,194–195

  • 操作,192

  • 性能,195

  • 从中删除元素,193

  • Dijkstra 算法,438

  • 简化,466

  • 分治,65–68,72

  • 双重哈希,226–229

  • 使用素数长度的双重哈希,229–232

  • 倍增查找。 指数查找

  • 双向链表。 循环链表

  • 荷兰国旗问题,119

  • 动态规划 (DP),63,72–82,89–90

  • 自底向上,72,79–82

  • 计算斐波那契数列,72–74,79–80

  • 换行,74–79,89

  • 记忆化,72–74,78–81

  • 求和范围,80–82,90

  • 查找表,72

  • 自顶向下,72–79

  • E

  • ECMAScript,6,8,10–11,13

  • ESLint,18,19

  • 指数查找,168–169,173

  • 扩展堆

  • 二项堆,351–363,385

  • 斐波那契堆,367–376,386

  • 懒汉式二项堆,363–367

  • 配对堆,376–384

  • 偏斜堆,347–351,385

  • 外部排序,92

  • F

  • 阶乘,52,53,65–66,82,88

  • 斐波那契堆,367–376,386

  • 向中添加值,371

  • 改变其中的值,372–375

  • 实现,368–369

  • 合并,370–371

  • 性能, 375–376

  • 从中移除值, 371–372

  • 斐波那契数列, 66–67, 72–74, 79–80, 89

  • 使用自底向上的动态规划计算, 79–80

  • 使用自顶向下的动态规划计算, 72–74

  • Fira Code 字体, 15–16

  • 菲舍尔-耶茨抽样, 151–152

  • 菲舍尔-耶茨洗牌, 145–146, 151–152, 156

  • 流类型检查器, 18

  • 弗洛伊德, 罗伯特, 142, 329

  • 弗洛伊德的抽样, 148–150

  • 弗洛伊德的洗牌, 142–143

  • 弗洛伊德-沃肖尔算法, 430–434

  • 森林, 实现, 288

  • FORTH, 185

  • FORTRAN, 24

  • 函数式数据结构, 470–484

  • 数组, 470

  • 二叉搜索树, 478–481

  • 常见列表, 470–473

  • 斐波那契堆, 481

  • 哈希表, 470

  • 队列, 474–478

  • 栈, 473–474

  • 函数式编程 (FP)

  • 声明式风格, 26–30

  • 高阶函数, 30–32

  • 非纯函数, 33–34

  • 使用原因, 24

  • 副作用, 32–33

  • G

  • 跑步搜索。参见 指数搜索

  • 一般树, 237

  • 邻接表表示法, 429–430

  • 邻接矩阵表示法, 428–429

  • 邻接集表示法, 430

  • 弧, 425

  • 箭头, 425

  • 贝尔曼-福特算法, 434–438

  • 连通性检测, 428, 453–458

  • 循环检测, 427, 452–453

  • 定义, 425–427

  • 的度, 425

  • 戴克斯特拉算法, 438–444

  • 边, 425

  • 弗洛伊德-沃肖尔算法, 430–434

  • 哈密顿回路, 88

  • 卡恩算法, 445–448

  • 克鲁斯卡尔算法, 88, 462–465

  • 链接, 425

  • 最小生成树, 88, 427, 458–465

  • 邻居, 425

  • 节点, 425

  • 点, 425

  • 普里姆算法, 459–462

  • 表示,428–430

  • 最短路径问题,427,430–443,466

  • 排序,444–452

  • Tarjan 算法,448,452–453

  • 拓扑排序,427,445–448,451–452,466

  • 顶点,425

  • 贪心算法,63,87–88

  • H

  • 哈希,218–232,233

  • 向中添加值,220,224,228,231

  • 链接法,见,219–221

  • 碰撞,219,222

  • 创建,220,223–224,227,230–231

  • 双重哈希,226–229

  • 带质数长度的双重哈希,229–232

  • 哈希余数函数,218

  • 负载因子,222,223,225,226,228

  • 开放定址法,见,221–226

  • 性能,221,225

  • 从中删除值,221,225,229,232

  • 调整大小,233

  • 在中搜索,220,224,228,231

  • 添加到,321–323

  • 可寻址堆,346–347

  • 二叉堆,318–325

  • 二项堆,351–363,385

  • d-叉堆,340–341

  • 斐波那契堆,367–376,386

  • 弗雷德曼,迈克尔,368

  • 堆排序的二叉树。 斜堆

  • 堆属性,318,319–320,330,332–337

  • 实现,320–325

  • 惰性二项堆,363–367

  • 左式堆,347

  • 最大堆,319

  • 最小堆,319

  • 操作,320

  • 配对堆,376–384

  • 性能,325

  • 优先队列,326–327,342

  • 四元堆,340

  • 从中移除, 323–325, 342

  • 搜索, 342, 386

  • 偏斜堆, 347–350, 385

  • 结构属性, 318–319, 332

  • Treap, 332–340, 343

  • 三叉堆, 340

  • 堆排序, 320, 327–331, 342, 343

  • 分析, 329

  • Floyd 的堆构建优化, 329

  • Williams 原始堆排序, 327

  • 高阶函数, 30–32

  • Hindley-Milner 类型系统, 41

  • I

  • 不可变性。请参见 函数式数据结构

  • 非纯函数

  • 避免状态, 33–34

  • 使用注入, 34

  • 低效排序, 116–117

  • 无限循环。请参见 无限循环

  • 二叉搜索树的中序遍历, 246–248, 280

  • 原地排序, 93

  • 插入排序, 101–103, 104, 105, 108, 111, 118

  • 内部排序, 92

  • 插值搜索, 169–171

  • J

  • JavaScript, 3–21, 25–34。另请参见 函数式编程 (FP)

  • 箭头函数, 4–5

  • 类, 5–6

  • 闭包, 11–13

  • CommonJS 模块, 9

  • 解构赋值, 7–8, 9–10

  • 开发工具, 13–20

  • ECMAScript 模块, 10–11

  • 数组筛选, 27

  • 作为函数式语言, 25–34

  • .indexOf 方法, 160

  • 遍历数组, 30

  • 模块, 8–11, 13

  • .pop 方法, 185, 200

  • .push 方法, 185, 200

  • .random 方法, 138

  • 将数组简化为一个值, 29–30

  • 数组搜索, 27–28

  • 睡眠排序, 117

  • .sort 方法, 95–96

  • 扩展运算符, 6–7

  • 测试数组, 28, 36

  • 转换数组, 28–29, 35

  • JSDoc, 16–18

  • 跳跃搜索, 163–166, 172

  • K

  • 卡恩算法,445–448

  • 克努斯,唐纳德,52,152,157

  • 克努斯的抽样,152

  • 克鲁斯卡尔算法,462–465

  • L

  • 兰迪斯,叶夫根尼,249

  • 懒惰二项堆,363–367

  • 向中添加值,364–365

  • 变更值,366–367

  • 实现,363–364

  • 性能,367

  • 从中移除值,365–366

  • 懒惰选择,132–134

  • 左倾堆,347

  • 莱默代码,144

  • 线性查找,160–162

  • LISP,24,177

  • 列表

  • 向中添加值,182

  • 附加到,200

  • 循环列表,195–200

  • 创建,182

  • 双端队列,191–195,201

  • 获取指定位置的值,183

  • 使用数组实现,179–180

  • 使用动态内存实现,180–183

  • 操作,178

  • 有序列表,207–210

  • 性能,184

  • 队列,188–191,201

  • 从中移除值,182–183

  • 在中查找,183–184

  • 自组织,215–218,232

  • 跳表,210–215,232

  • 栈,184–188,200,201

  • 彩票抽样,150–151

  • 洛伊德,萨姆,70,89

  • 卢卡斯,埃德华,67

  • M

  • 地图,203,205,220,221

  • 已定义,203

  • 操作,205

  • 最大堆,319

  • 迷宫,求解,69–70

  • 麦克克雷特,爱德华,291

  • 中位数选择算法,127–130

  • 可合并优先队列(MPQs),346–347

  • 操作,347

  • 归并排序,93,110–112,119,133

  • 最小堆,319

  • 最小生成树,88,427,458–465

  • 克鲁斯卡尔算法,462–465

  • 普里姆算法,459–462

  • Mozilla 开发者网络(MDN),13

  • 多重集,40–47

  • 互递归。参见 互递归

  • O

  • 基于对象的字典树,401–405

  • 向中添加键,404–405

  • 实现,402

  • 性能,406

  • 从中移除键,405–406

  • 搜索,402–404

  • 面向对象编程(OOP),23,32

  • 离线排序,93

  • O’Neill,梅丽莎,470

  • 在线排序,93

  • 仅一个值抽样,146–147

  • 开放寻址,哈希表,221–226

  • 果园,284

  • 有序列表,207–210

  • 向中添加值,208–209

  • 性能,210

  • 从中移除值,209–210

  • 在中搜索,207–208

  • 哨兵和,211,212,232

  • 不同位置排序,93

  • P

  • 配对堆,376–384

  • 向中添加值,378

  • 改变值,382–384

  • 实现,377

  • 合并,377–378

  • 性能,384

  • 从中移除值,378–381

  • Pandita,纳拉扬,84

  • 分区交换排序。参见 快速排序

  • 帕斯卡三角形,352

  • 性能,算法,50–59

  • 平摊时间,54

  • 平均情况,54

  • 最佳情况,52,54,59

  • 大Ω符号,51–52

  • O符号,51–52,57,59

  • 大Θ符号,51,59

  • 类,复杂度,52–54

  • 常数阶,52,53,54

  • 指数阶,52,53

  • 阶乘阶,52,53

  • 线性阶,52,53,55

  • 对数阶,52,53

  • 对数线性阶,52

  • 测量,54–55

  • 二次阶,52,53

  • 小ω符号,51,52,57

  • o表示法,51,52,59

  • 权衡,57–58

  • 最坏情况,52,54,55,57,59

  • 算法的性能测量,54–55

  • 性能权衡,57–58

  • 生成下一个排列,84–86

  • 排列排序,117

  • 持久化数据结构。参见 函数式数据结构

  • PostScript,185

  • Prettier 格式化,16

  • 普里姆算法,459–462

  • 优先级队列,326–327,342,346–347,531

  • 可寻址,346–347

  • 可寻址堆,346

  • 左式堆,347

  • 可合并,346–347

  • 操作,326,347

  • 概率平衡二叉搜索树,249,261–278,281–282

  • Q

  • 四元堆,340

  • 队列,188–191,201,326–327,342,346–347,531

  • 向队列中添加元素(入队),189

  • 实现,189–191

  • 操作,188

  • 性能,191

  • 优先级,326–327,342,346–347,531

  • 从队列中移除元素(出队),189–190

  • 快速选择,125–132,135

  • 快速排序,105–110,119,125–126,127,247,264

  • 双枢轴选择,108–110

  • 混合方法,107–108

  • “三数中值”枢轴选择,107

  • 枢轴选择技巧,106–107

  • 标准版本,105–106

  • R

  • 基数排序,112,115–116,119

  • 基数树,406–413

  • 向其中添加键,410–411,424

  • 创建,407

  • 性能,414

  • 从中移除键,412–414

  • 搜索,407–409

  • 随机化二叉搜索树,262–270,281

  • 添加键到,263–264

  • 创建,262–263

  • 合并,268–269

  • 性能,269–270

  • 从中移除键,267–268

  • 分割,264–267

  • 随机数生成,138

  • React Redux,40

  • 递归,64–72,82,89,208

  • 分治法,65–68,72

  • 备忘录化,73–74,78–81

  • 互相. 互递归

  • 红黑树,304–314

  • 添加键到,306–307

  • 实现,305–306

  • 性能,313–314

  • 从中移除键,309–313

  • 恢复结构,307–309

  • 重复步骤枢轴选择,130–132

  • 水库抽样,153–154

  • 逆波兰表示法(RPN),185

  • Robson, J. M., 143

  • Robson 算法,143–145,156

  • S

  • 抽样,146–153,156,157

  • Fisher-Yates 算法,151–152

  • Floyd 算法,148–150

  • Knuth 算法,152–153

  • 抽奖,150–151

  • 只有一个值,146–147

  • 带重复,146–147,156

  • 不带重复,147–154

  • 水库抽样,153–154

  • 多个值,147

  • 通过排序或洗牌,148

  • 搜索

  • 二分搜索,56–57,59,166–168,172

  • 定义,159–160

  • 指数搜索,168–169,173

  • 插值搜索,169–171

  • 跳跃搜索,163–166,172

  • 线性搜索,160–161

  • 带哨兵的线性搜索,162–163

  • 有序数组,163–171

  • 未排序数组,160–162

  • Sedgewick, Robert,304,312

  • 选择

  • 位图选择,122–123

  • 计数选择,123–124

  • 惰性选择,132–134

  • 中位数的中位数枢轴选择,127–130

  • 归并排序,133

  • 快速选择,125–132,135

  • 快速排序,105–110,119,125–126,127,247,264

  • 重复步骤枢轴选择,130,131,135

  • 使用比较选择,124–125,130

  • 无比较选择,122–124

  • 选择排序,100–101,124–125,135

  • 自调整树,249,261–278,281–282

  • 自组织列表,215–218,232

  • 向其中添加值,217

  • 计数排序策略,218

  • 移动到前端(MTF)策略,218

  • 性能,217–218

  • 从中移除值,217

  • 在中搜索,215–217

  • 与前一个交换策略,218

  • 变种,217–218

  • 集合

  • 二叉搜索树,实现方式,239–282

  • 位图,实现方式,206

  • 哈希,233

  • JavaScript 对象,与之结合,205–206

  • 列表,实现方式,207–218

  • 操作,204

  • 外壳排序,104–105

  • 最短路径问题,427,430–443,466

  • 贝尔曼-福特算法,434–438

  • 戴克斯特拉算法,438–444

  • 弗洛伊德-沃尔沙尔算法,430–434

  • 洗牌,139–146,148,151,155–157

  • 通过投币,140–141

  • 菲舍尔-耶茨算法,145–146,151–152,156

  • 弗洛伊德算法,142–143

  • 线性时间,142–146

  • 排列,139,142–146,155

  • 罗布森算法,143–145,156

  • 通过排序,139–140

  • 穿梭排序,99–100

  • 副作用

  • 保持内部状态,32

  • 变更参数,33

  • 返回非纯函数,33

  • 使用全局状态,32

  • 沉降排序,99,118

  • 偏斜堆,347–350,385

  • 向其中添加键,350

  • 实现,348

  • 合并,348–350

  • 性能,350–351

  • 从中删除键,350

  • 跳表,210–215,232

  • 向其中添加值,213–214

  • 创建,211–212

  • 性能,215

  • 从中删除值,214–215

  • 重构,232

  • 在其中查找,212

  • 睡眠排序,117

  • 慢排序,116

  • 小欧米伽符号,51,52,57

  • o 符号,51,52,59

  • 排序

  • 自适应,92–93

  • 双向冒泡排序,99

  • 位图排序,112–113,115

  • 博戈排序,117

  • 冒泡排序,97–98,100,103–104,118

  • 鸡尾酒摇摆排序,99

  • 梳排序,103–104

  • 计数排序,114–115

  • 荷兰国旗问题,119

  • 外部,92

  • 堆排序,320,327–331,342,343

  • 低效,116–117

  • 原地,93

  • 插入,108

  • 插入排序,101–103,104,105,108,111,118

  • 内部,92

  • 归并排序,93,110–112,119,133

  • 离线,93

  • 在线,93,105

  • 非原地,93

  • 分区交换排序,105

  • 性能,93,96–98,101,105–108,110–112,117–119

  • 排列排序,117

  • 二叉查找树的先序遍历,280

  • 快速排序,105–110,119,125–126,127,247,264

  • 基数排序,112,115–116,119

  • 选择排序,100–101,124–125,135

  • Shell 排序,104–105

  • shuttle 排序,99

  • 沉降排序,99,118

  • 睡眠排序,117

  • 慢排序,116

  • .sort 方法,95–96

  • 稳定性,93–94,96,111,116,118

  • stooge 排序,116

  • Tim 排序,111

  • 展开树,270–278,281–282

  • 向中添加键,274–275

  • 性能,278

  • 从中移除键,275–278

  • 在中搜索,274

  • 展开树,271–274

  • 海滩上的方格游戏,70,89

  • 栈,184–188,200,201

  • 添加到(推入),185–187

  • 使用数组实现,185

  • 操作,185

  • 性能,188

  • 弹出(从中移除),185–187

  • 斯特林近似,97

  • stooge 排序,116

  • T

  • Tarjan,罗伯特,368,448,452–453

  • Tarjan 算法,448,452–453

  • 同义命题,检测,82–83

  • 三元堆,340–341

  • 三元搜索尝试,414–424

  • 向中添加键,417–419

  • 创建,415–416

  • 性能,423

  • 从中移除键,419–423

  • 在中搜索,416–417

  • 在中存储额外数据,416

  • Tim 排序,111

  • 拓扑排序,427,445–448,451–452,466

  • Kahn 算法,445

  • Tarjan 算法,448,452–453

  • 汉诺塔,67,89

  • 旅行商问题,84,87–88

  • treap,332–340,343

  • 向中添加键,333–336

  • 创建,332–333

  • 合并,343

  • 性能,339–340,344

  • 从中移除键,336–339,343

  • 搜索,332–333

  • 分割,343

  • 2-3 树,304,312

  • 向中添加节点,286

  • 祖先,定义,237

  • AVL 树,235,249–254,261

  • 二叉搜索树,239–282,485

  • 二叉树,237–282,287,288,304

  • 二项树,351–352

  • 广度优先(层次遍历)遍历,290–291

  • B 树,291–303,315

  • 子节点,定义,237

  • 定义,283–290

  • 度,237,284

  • 深度优先遍历,289–290

  • 后代,定义,237

  • 等价,314–315

  • 森林,284,288

  • 一般树,237

  • 树丛,定义,284

  • 高度,237,272

  • 使用数组实现,284–287

  • 使用二叉树实现,287

  • 中序遍历,289,294

  • 层次,定义,237

  • 最小生成树,88,427,458–465

  • 多叉(多路),定义,284

  • 果园,定义,284

  • 有序森林,284

  • 组织图(组织结构图),236

  • 父节点,定义,237

  • 后序遍历,289,314

  • 先序遍历,289,314

  • 概率平衡二叉搜索树,249,261–278,281–282

  • 基数树,406–413

  • 红黑树,304–314

  • 从中移除节点,286–287

  • 表示,237,287–291

  • 根节点,定义,237

  • 大小,定义,237

  • 擦拭树,270–278,281–282

  • 遍历,288–291

  • 权重约束平衡树,255–261,281

  • 试验树

  • 添加键到,394–397,404–405,410–411,417–419

  • 经典,388–401,424

  • 实现,388–390,402,407,415–416

  • 基于对象的,401–406

  • 性能,401,406,414,423

  • 基数树,406–413

  • 从中移除键,397–401,405–406,412–413,419–423

  • 在中搜索,390,402–403,407–409,416–417

  • 存储额外数据,390–406

  • 三元搜索尝试,414–423

  • 三元堆,340

  • TypeScript,18–19

  • V

  • Visual Studio Code (VSC),4,13–15,18

  • W

  • Waterman, Alan, 153

  • WebAssembly (WASM),185

  • 权重有界平衡(BB[α])树,255–261,281

  • 向中添加键,257

  • 创建,256–257

  • 按排名查找,260–261

  • 修复平衡,257–259

  • 性能,261

  • 从中移除键,257

  • Williams, John W. J., 327–329

  • 算法的最坏情况性能,54

posted @ 2025-11-26 09:18  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报