JavaScript-数据结构和算法学习指南第四版-全-
JavaScript 数据结构和算法学习指南第四版(全)
原文:
zh.annas-archive.org/md5/7e09bf5d52a7802ecd8a14ab3775a3b6
译者:飞龙
第一章:《JavaScript 数据结构和算法,第四版》:提升您在 JavaScript 和 TypeScript 中的问题解决能力
欢迎来到 Packt 早期预览。在本书上市之前,我们为您提供独家预览。撰写一本书可能需要数月时间,但我们的作者今天可以分享一些前沿信息。早期预览通过提供章节草案,让您深入了解最新的发展。目前章节可能有些粗糙,但我们的作者会随着时间的推移进行更新。
您可以随意翻阅这本书,或者从头到尾跟随阅读;早期预览旨在提供灵活性。我们希望您喜欢了解更多关于撰写 Packt 书籍的过程。
-
第一章:介绍 JavaScript 中的数据结构和算法
-
第二章:理解 Big O 表示法
-
第三章:数组
-
第四章:栈
-
第五章:队列和双端队列
-
第六章:链表
-
第七章:集合
-
第八章:字典和散列
-
第九章:递归
-
第十章:树
-
第十一章:二叉堆和堆排序
-
第十二章:Trie 树
-
第十三章:图
-
第十四章:排序算法
-
第十五章:搜索和洗牌算法
-
第十六章:字符串算法
-
第十七章:数学算法
-
第十八章:算法设计和技巧
第二章:1 在 JavaScript 中介绍数据结构和算法
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“EARLY ACCESS SUBSCRIPTION”下找到“learning-javascript-dsa-4e”频道)。
JavaScript 是一种功能强大的语言。它是世界上最流行的语言之一,也是互联网上最突出的语言之一。例如,GitHub(世界上最大的代码托管平台,网址为 github.com
)在撰写本文时托管了超过 300,000 个 JavaScript 仓库(GitHub 上活跃的仓库中,JavaScript 仓库数量最多;请参阅 githut.info
)。JavaScript 在 GitHub 上的项目数量每年都在增长。
JavaScript 是任何网页开发人员必备的技能。它提供了一个方便的环境来学习数据结构和算法,只需要一个文本编辑器或浏览器就可以开始。更重要的是,JavaScript 在网页开发中的广泛应用,允许你直接将此知识应用于构建高效、可扩展的网页应用程序,优化性能并处理复杂任务。
数据结构和算法是软件开发的基本构建块。数据结构提供了组织和存储数据的方法,而算法定义了对这些数据执行的操作。掌握这些概念对于创建结构良好、易于维护和性能高的 JavaScript 代码至关重要。
在本章中,我们将介绍开始构建自己的数据结构和算法所需的必要 JavaScript 语法和功能。此外,我们还将介绍 TypeScript,这是一种基于 JavaScript 的语言,它提供了增强的代码安全性、结构和工具。这将使我们能够使用 JavaScript 和 TypeScript 创建数据结构和算法,展示它们各自的优势。我们将涵盖:
-
数据结构的重要性
-
为什么算法很重要
-
为什么公司在面试中要求这些概念
-
为什么选择 JavaScript 来学习数据结构和算法
-
设置环境
-
JavaScript 基础
-
TypeScript 基础
数据结构的重要性
数据结构是一种在计算机内存中组织和存储数据的方式,使数据能够被高效地访问和修改。
将数据结构视为设计用来存储特定类型信息的容器,它们有自己安排和管理信息的方式。例如,在你的家中,你在厨房烹饪食物,在卧室睡觉,在浴室洗澡。房屋或公寓中的每个地方都是为了完成特定任务而设计的,以便我们可以保持家庭整洁有序。
在现实世界中,数据往往可能过于复杂,这归因于诸如数量、各种形式(数字、日期、文本、图像、电子邮件)以及数据生成速度等因素。数据结构为这种混乱带来了秩序,使得计算机能够系统且高效地处理大量信息。想象一下,这就像一个组织良好的图书馆与一大堆书籍相比。在图书馆中,由于书籍按类型和作者(按字母顺序)组织,因此找到特定书籍要容易得多。
良好的数据结构选择有助于程序在处理的数据量不同的情况下保持一致的性能。想象一下,需要存储 10 天或 10 年的天气预报数据。使用正确的数据结构可以在算法崩溃或能够扩展之间产生差异。
最后,数据结构可以极大地影响编写与该数据一起工作的算法的难易程度。例如,使用显示城市之间距离的图数据结构来找到地图上的最短路线可以高效地解决,而不是使用无序的城市名称数组。
为什么算法很重要
计算机是强大的工具,但它们的智能源于我们提供的指令。算法是一系列规则和程序,指导计算机的行动,使其能够解决问题、做出决策和执行复杂任务。本质上,算法是我们与计算机沟通的语言,将它们从单纯的机器转变为智能问题解决者。
算法可以将任务转化为可重复和自动化的过程。如果你需要在工作中每天生成一份报告,那么这是一个可以通过算法自动化的任务。
算法无处不在,从搜索引擎到社交网络,再到自动驾驶汽车。算法和数据结构是它们运作的基础。理解数据结构和算法能够解锁在技术世界中创造和创新的能力。
作为软件开发者,编写算法和处理数据是我们工作的核心方面。这正是为什么公司在招聘面试中强调这些概念——它们是评估应聘者问题解决能力和他们为软件开发项目有效贡献潜力的关键技能。
为什么公司在面试中要求这些概念
公司在招聘面试中关注数据结构和算法概念的原因有很多,即使你在日常任务中不会使用这些概念,包括:
-
问题解决技能:数据结构和算法是评估应聘者问题解决能力的优秀工具。它们可以用来评估一个人如何处理不熟悉的问题,将它们分解成更小的任务,并设计解决方案。
-
编码能力:公司可以评估候选人如何将解决方案转化为干净高效的代码,候选人如何为问题选择合适的数据结构,设计具有正确逻辑的算法,考虑边缘情况并优化代码。
-
软件性能:对数据结构和算法的深刻理解直接转化为成功交付每个开发任务,例如通过选择合适的数据结构和算法来设计可扩展的解决方案,尤其是在处理大量数据集时。在处理大数据集的大数据领域,你的解决方案不仅需要正确运行,而且还需要在规模上高效运行。性能优化通常依赖于选择最佳的数据结构或调整现有算法。
-
调试和故障排除:对数据结构和算法的扎实掌握可以帮助工程师确定代码中可能出现问题的位置。
-
学习和适应能力:技术始终在发展,编程语言和框架也是如此,然而,数据结构和算法的概念在多年间一直保持基本。这就是我们经常说这些概念是计算机科学基础知识之一的原因。一旦你学会了这些概念,你就可以轻松地适应不同的编程语言。这有助于公司测试一个人是否能够适应不断变化的要求,这对于这个行业至关重要。
-
沟通:通常,当公司提出使用数据结构和算法解决的问题时,他们并不是在寻找最终的答案,而是在寻找候选人到达最终答案的过程。公司可以评估候选人如何解释他们的思维过程和决策背后的原因;以及候选人在讨论选择不同数据结构和算法以解决程序时涉及的不同权衡的能力。这可以用来评估一个人是否能够在团队环境中协作,以及候选人是否能够清楚地传达信息。
当然,这些只是公司评估的一些因素,而特定领域的知识、经验和文化适应性也是选择合适职位候选人时的关键因素。
为什么选择 JavaScript 来学习数据结构和算法?
根据各种行业调查,JavaScript 是世界上最受欢迎的编程语言之一,如果你已经熟悉编程的基础知识,那么它是一个很好的选择。繁荣的 JavaScript 社区和丰富的在线资源为学习、协作和提升 JavaScript 开发者职业生涯提供了一个支持性和动态的环境。
JavaScript 也是一种适合初学者的语言,您无需担心像 C++ 这样的其他语言中存在的复杂内存管理概念。这对于学习像链表、树和图这样的数据结构非常有帮助,这些数据结构由于其能够在程序执行(运行时)期间增长或缩小尺寸的能力而具有动态性,并且在使用 JavaScript 时,您可以专注于数据结构概念,而无需与内存管理控制混合。
由于 JavaScript 用于网页开发,使用 JavaScript 学习数据结构和算法可以使您直接将这些技能应用于构建交互式网页应用。
然而,使用 JavaScript 的一大缺点是它缺乏像 C++ 和 Java 这样的其他语言中存在的严格类型。JavaScript 是一种动态类型语言,这意味着您不需要显式声明变量的数据类型。在处理数据结构时,我们需要注意不要在同一个数据结构中混合数据类型,因为这可能导致微妙的错误。通常,当处理数据结构时,确保同一结构内的所有数据类型相同被认为是最佳实践。在本书中,我们将通过始终为同一数据结构实例使用相同的数据类型来弥补这一差距,并且我们还将通过提供扩展 JavaScript 并向语言添加类型的 TypeScript 源代码来解决缺乏严格类型的问题。
并且重要的是要记住:最好的语言是您最舒适使用并且能激励您学习的语言。本书将使用 JavaScript 和 TypeScript 展示不同的数据结构和算法,您也可以将这些概念适应到其他编程语言中。
设置环境
与其他语言相比,JavaScript 语言的优点之一是您无需安装或配置复杂的环境即可开始使用。为了跟随本书中的示例,您需要从 nodejs.org
下载 Node.js,这样我们才能执行源代码。在下载页面,您将找到在您的操作系统上下载和安装 Node.js 的详细步骤。
作为一条经验法则,始终下载 LTS (长期支持) 版本,这通常是企业公司所使用的。
虽然 JavaScript 可以在浏览器和 Node.js 中运行,但后者为学习数据结构和算法提供了一个更流畅和专注的环境。Node.js 消除了浏览器特定的复杂性,提供了强大的调试工具,并促进了学习这些核心概念的更直接的方法。
本书源代码也以 TypeScript 格式提供,它提供了增强的类型安全和结构。要运行 TypeScript 代码,包括本书中的示例,我们需要将其转换为 JavaScript,这个过程我们将在详细说明。
安装代码编辑器或 IDE
我们还需要一个编辑器或 IDE (集成开发环境) 来在舒适的环境中开发应用程序。对于本书的示例,作者使用了 Visual Studio Code (VSCode),这是一个免费的开源编辑器。但是,您可以使用任何您选择的编辑器(Notepad++、WebStorm 以及市场上可用的其他编辑器或 IDE)。
您可以在
code.visualstudio.com
下载适用于您操作系统的 VSCode 安装程序。
现在我们已经拥有了所有需要的东西,我们可以开始编写我们的示例了!
JavaScript 基础知识
在我们开始深入各种数据结构和算法之前,让我们快速了解一下 JavaScript 语言。本节将介绍实现后续章节中我们将创建的算法所需的 JavaScript 基本概念。
Hello World
我们将从经典的 "Hello, World!" 示例开始,这是一个简单的程序,显示消息 "Hello, World!"。
让我们一起创建第一个示例。请按照以下步骤操作:
-
创建一个名为
javascript-datastructures-algorithms
的文件夹。 -
在其中,创建一个名为
src
的文件夹(源文件夹,我们将为本书创建文件)。 -
在
src
文件夹中,创建一个名为01-intro
的文件夹
我们可以将本章的所有示例都放在这个目录中。现在让我们创建一个 Hello, World
示例。为此,创建一个名为 01-hello-variables.js
的文件。在文件中,添加以下代码:
console.log('Hello, World!');
要运行此示例,您可以使用默认的操作系统终端或命令提示符(或者如果您正在使用 Visual Studio Code,请打开内置终端)并执行以下命令:
node src/01-intro/01-hello-variables.js
你将看到如以下图片所示的 "Hello, World!
" 输出:
图 1.1 – Visual Studio Code 与 JavaScript Hello, World! 示例
对于本书的每个源文件,你将在第一行看到文件的路径,然后是我们将一起创建的源代码,文件将以可以在终端中查看输出的命令结束。
变量和数据类型
在 JavaScript 中有三种声明变量的方式:
-
var
: 声明一个变量,并且可以选择初始化它。这是在 JavaScript 中声明变量的最古老方式。 -
let
: 声明一个局部变量,块级作用域(这意味着变量仅在特定的代码块内可访问,例如在循环或条件语句内),并且也可以选择初始化它。对于我们的算法,这将是我们的首选方式,因为它具有更可预测的行为。 -
const
: 声明一个只读常量。它必须初始化,并且我们无法重新分配它。
让我们看看几个示例:
var num = 1;
num = 'one' ;
let myVar = 2;
myVar = 4;
const price = 1.5;
位置:
-
在第一行,我们有一个如何在 JavaScript 中声明变量(传统方式,在现代 JavaScript 之前)的例子。尽管使用
var
关键字声明不是必需的,但始终指定我们声明新变量是一个好习惯。 -
在第二行,我们更新了一个现有的变量。JavaScript 不是一种强类型语言。这意味着你可以声明一个变量,用数字初始化它,然后更新为文本或其他任何数据类型。将不同类型的值赋给变量通常不被认为是良好的实践,尽管这是可能的。
-
在第三行,我们也声明了一个数字,但这次我们使用
let
关键字来指定这是一个局部变量。 -
在第四行,我们可以将
myVar
的值更改为不同的数字; -
在第五行,我们声明了另一个变量,但这次使用
const
关键字。这意味着这个变量的值是最终的,如果我们尝试赋予另一个值,我们将得到一个错误(常量变量赋值错误)。
让我们看看 JavaScript 支持哪些数据类型。
数据类型
-
在编写本文时,最新的ECMAScript标准(JavaScript 规范)定义了一些原始数据类型:
-
Number:整数或浮点数;
-
String:文本值;
-
Boolean:真或假值;
-
null:表示空值的特殊关键字;
-
undefined:没有值或尚未初始化的变量;
-
Symbol,它们是唯一且不可变的;
-
BigInt:任意精度的整数:
1234567890n
; -
以及Object。
-
让我们看看如何声明不同数据类型的变量的例子:
const price = 1.5; // number
const publisher = 'Packt'; // string
const javaScriptBook = true; // boolean
const nullVar = null; // null
let und; // undefined
如果我们想查看我们声明的每个变量的值,我们可以使用console.log
来实现,如下面的代码片段所示:
console.log('price: ' + price);
console.log('publisher: ' + publisher);
console.log('javaScriptBook: ' + javaScriptBook);
console.log('nullVar: ' + nullVar);
console.log('und: ' + und);
console.log 方法也接受多个参数。而不是
console.log('num: ' + num)
,我们也可以使用console.log('num: ', num)
。第一个选项将结果连接成单个字符串,而第二个选项允许我们添加描述并可视化变量内容,如果它是对象的话。
在 JavaScript 中,typeof
运算符是一个帮助确定变量或表达式数据类型的运算符。它返回一个表示操作数类型的字符串。如果我们想检查声明变量的类型,我们可以使用以下代码:
console.log('typeof price: ', typeof price); // number
console.log('typeof publisher: ', typeof publisher); // string
console.log('typeof javaScriptBook: ', typeof javaScriptBook); // boolean
console.log('typeof nullVar: ', typeof nullVar); // object
console.log('typeof und: ', typeof und); // undefined
在 JavaScript 中,
typeof null
返回"object",这可能令人困惑,因为null
实际上不是一个对象。这被认为是语言中的历史怪癖或错误。
对象和 Symbol 数据类型
在 JavaScript 中,对象是一种基本的数据结构,它作为一个键值对的集合。这些键值对通常被称为对象的属性或方法。可以将其视为一个可以存储各种类型的数据和功能的容器。
如果我们想在 JavaScript 中使用像标题这样的属性来表示一本书,我们可以有效地使用对象来实现:
const book = {
title: 'Data Structures and Algorithms',
}
对象是 JavaScript 编程的基石,提供了一种强大的方式来结构化数据、封装逻辑以及模拟现实世界的实体。
如果我们想要输出书的标题,我们可以使用点符号如下进行操作:
console.log('book title: ', book.title);
尽管我们不能重新赋值常量的值,但如果其数据类型是对象,我们可以修改它。让我们看一个例子:
book.title = 'Data Structures and Algorithms in JavaScript';
// book = {anotherTitle:'Data Structures'} this will not work
修改对象意味着改变现有对象中的属性,就像我们刚才做的那样。重新赋值对象意味着改变变量所引用的整个对象,如下面的示例所示:
let book2 = {
title: 'Data Structures and Algorithms',
}
book2 = { title: 'Data Structures' };
这个概念很重要,因为我们将在很多例子中使用它。
JavaScript 还支持一种特殊且独特的数据类型,称为符号。符号作为唯一的标识符,主要用于以下目的:
-
独特的属性键:符号可以用作对象属性的键,确保这些属性是独特的,不会与其他键(即使是具有相同名称的字符串)冲突。这在处理库或模块时尤其有用,因为可能会出现命名冲突。
-
隐藏属性:符号默认是不可枚举的,这意味着它们不会出现在
for...in
循环或Object.keys()
中。这使得它们非常适合在对象中创建私有属性。
让我们通过一个例子来更好地理解:
const title = Symbol('title');
const book3 = {
[title]: 'Data Structures and Algorithms'
};
console.log(book3[title]); // Data Structures and Algorithms
在这个例子中,我们创建了一个符号title
,并使用它作为book3
对象的键。当我们需要访问这个属性时,我们不能简单地使用点符号book3.title
,而必须使用方括号符号book3.[title]
。
在本书的示例中,我们不会使用符号,但了解这个概念是有趣的。
控制结构
JavaScript 与 C 和 Java 语言具有类似的控制结构集。条件语句由if...else
和switch
支持。JavaScript 还支持不同的循环语句,如for
、while
、do…while
、for…in
和for…of
。
在本节中,我们将探讨条件语句和循环语句,从条件语句开始。
条件语句
JavaScript 中的条件语句是控制代码流程的基本构建块。它们允许你根据某些条件做出决策,并相应地执行不同的代码块。
如果我们只想在条件为真时执行一段代码,我们可以使用if
语句。如果条件为假,我们可以使用可选的else
语句:
let number = 0;
if (number === 1) {
console.log('number is equal to 1');
} else {
console.log('number is not equal to 1, the value of number is ' + number);
}
if...else
语句也可以用三元运算符来表示。例如,看看下面的 if...else 语句:
if (number === 1) {
number--;
} else {
number++;
}
它也可以表示如下:
number === 1 ? number-- : number++;
三元运算符是返回一个值的表达式,而 if 语句只是一个命令式语句。这意味着我们可以在另一个表达式中直接使用它,将其结果赋给一个变量,或者将其用作函数调用的参数。例如,我们可以将前面的例子重写如下,而不改变其输出:
number = number === 1 ? number - 1 : number + 1;
此外,如果我们有多个脚本,我们可以多次使用 if...else
来根据不同的条件执行不同的脚本,如下所示:
let month = 5;
if (month === 1) {
console.log('January');
} else if (month === 2) {
console.log('February');
} else if (month === 3) {
console.log('March');
} else {
console.log('Month is not January, February or March');
}
最后,我们有 switch
语句。switch
语句提供了一种编写多个 if...else
条件链的替代方法。它评估一个表达式,然后将其值与一系列情况匹配。当找到匹配项时,将执行与该情况关联的代码:
switch (month) {
case 1:
console.log('January');
break;
case 2:
console.log('February');
break;
case 3:
console.log('March');
break;
default:
console.log('Month is not January, February or March');
}
JavaScript 将在 case
语句中查找值的匹配(在这个例子中,是变量 month
)。如果没有找到匹配项,则执行 default
语句。每个 case
语句内部的 break
子句将停止执行并跳出 switch
语句。如果我们不添加 break
语句,后续每个 case
语句内部的代码也会执行,包括 default
语句。
循环
在 JavaScript 中,循环是基本结构,允许你在指定的条件为 true
时重复执行代码块。它们对于自动化重复性任务和遍历数据集合(如数组或对象)是必不可少的。
for
循环与 C 和 Java 中的相同。它由一个通常赋值为数值的循环计数器组成,然后变量与条件比较以跳出循环,最后数值增加或减少。
在下面的例子中,我们有一个 for
循环。它在控制台输出 i
的值,当 i
小于 10
时。i
的初始值为 0,因此下面的代码将输出从 0 到 9 的值:
for (let i = 0; i < 10; i++) {
console.log(i);
}
在 for
循环中,首先我们有初始化(let i = 0
),这仅在循环开始之前发生一次。接下来,我们有在每次迭代之前评估的条件(i < 10
)。如果评估结果为 true
,则执行循环体。如果为 false
,则循环结束。然后我们有最终表达式(i++
),这通常用于更新计数器变量。最终表达式在执行循环体之后执行。
我们接下来要查看的下一个循环结构是 while
循环。当条件为真时,while 循环内部的脚本将被执行。
在下面的代码中,我们有一个变量 i
,其初始值为 0,我们希望当 i
小于 10(或小于或等于 9)时记录 i
的值。输出将是从 0 到 9 的值:
let i = 0;
while (i < 10) {
console.log(i);
i++;
}
do...while
循环与 while
循环类似。唯一的区别是,在 while
循环中,条件在执行脚本之前被评估,而在 do...while
循环中,条件在脚本执行后被评估。do...while
循环确保脚本至少执行一次。以下代码也输出了从 0 到 9 的值:
i = 0;
do {
console.log(i);
i++;
} while (i < 10);
for…in
循环遍历对象的属性。这个循环在处理字典和集合时特别有用。
在以下代码中,我们将声明一个对象,并输出每个属性的名称及其值:
const obj = { a: 1, b: 2, c: 3 };
for (const key in obj) {
console.log(key, obj[key]);
}
// output: a 1 b 2 c 3
for…of
循环遍历数组、映射或集合的值,如下面的代码所示:
const arr = [1, 2, 3];
for (const value of arr) {
console.log(value);
}
// output: 1 2 3
函数
在使用 JavaScript 时,函数非常重要。函数是设计用来执行特定任务的代码块。它们是 JavaScript 的基本构建块之一,提供了一种组织代码、提高可重用性和可维护性的方法。我们也会在我们的示例中使用函数。
以下代码演示了函数的基本语法:
function sayHello(name) {
console.log('Hello! ', name);
}
这个函数的目的是创建一段可重用的代码,通过名字问候一个人。我们有一个名为 name
的参数。参数在函数被调用时充当提供值的占位符。
要执行此代码,我们只需使用以下语句:
sayHello('Packt');
将字符串 "Packt" 作为参数传递。这个值被分配给函数内部的 name
参数。
函数也可以返回一个值,如下所示:
function sum(num1, num2) {
return num1 + num2;
}
注意,在 JavaScript 中,函数总是会返回一个值。函数可以使用
return
关键字后跟一个表达式显式地返回一个值。如果一个函数没有
return
语句(如上面的sayHello
示例)或者在其代码块结束时没有遇到return
(没有返回),它将隐式返回undefined
。
这个函数计算两个给定数字的和,并返回其结果。我们可以如下使用它:
const result = sum(1, 2);
console.log(result); // outputs 3
我们也可以为参数分配默认值。如果我们没有传递值,函数将使用默认值:
function sumDefault(num1, num2 = 2) { // num2 has a default value
return num1 + num2;
}
console.log(sumDefault(1)); // outputs 3
console.log(sumDefault(1, 3)); // outputs 4
在这本书中,我们将探索更多关于函数的内容,以及与函数相关的其他高级特性,尤其是在介绍算法部分时。
变量作用域
作用域指的是在算法的哪个位置我们可以访问变量。为了理解变量作用域是如何工作的,让我们使用以下示例:
let movie = 'Lord of the Rings';
function starWarsFan() {
const movie = 'Star Wars';
return movie;
}
function marvelFan() {
movie = 'The Avengers';
return movie;
}
现在让我们记录一些输出,以便我们可以看到结果:
console.log(movie); // Lord of the Rings
console.log(starWarsFan()); // Star Wars
console.log(marvelFan()); // The Avengers
console.log(movie); // The Avengers
以下是为什么我们得到这个输出的解释:
-
我们首先在全局作用域中声明一个名为
movie
的变量,并将其值设置为 "指环王"。这个变量可以从代码的任何地方访问。第一个console.log
语句正在打印这个变量的初始值。 -
对于
starWarsFan
函数,我们使用const
声明了一个名为movie
的新变量。这个变量与全局变量movie
有相同的名称,但它只存在于函数的作用域内。这被称为 "遮蔽",在函数的上下文中,局部变量隐藏了全局变量。第二个console.log
调用了starWarsFan
函数。它打印 "Star Wars",因为在函数内部我们正在使用局部变量movie
,而全局的movie
变量保持不变。 -
接下来,我们有
marvelFan
函数,它没有使用let
或const
声明一个新的movie
变量。相反,它直接通过将其赋值为 "The Avengers" 来修改全局的movie
变量。这是可能的,因为没有局部变量会遮蔽全局变量。因此,当我们调用这个函数的第三个console.log
时,输出是 "The Avengers"。 -
最后,我们有最后一个
console.log(movie)
。这再次打印了全局的movie
变量,由于之前的marvenFan
函数调用,它现在持有值 "The Avengers"。
让我们回顾第二个例子,这次只使用一个函数,来展示变量作用域如何影响代码不同部分中变量的可见性和值:
function blizzardFan() {
const isFan = true;
let phrase = 'Warcraft';
console.log('Before if: ' + phrase);
if (isFan) {
let phrase = 'initial text';
phrase = 'For the Horde!';
console.log('Inside if: ' + phrase);
}
phrase = 'For the Alliance!';
console.log('After if: ' + phrase);
}
当我们调用 blizzardFan()
函数时,输出将是:
Before if: Warcraft
Inside if: For the Horde!
After if: For the Alliance!
让我们理解一下原因:
-
我们首先声明一个常量变量
isFan
并将其初始化为true
。由于它使用const
声明,其值不能被更改。 -
使用
let
声明了一个名为phrase
的变量并将其赋值为 'Warcraft'。 -
接下来,我们有一个第一个
console.log
输出phrase
变量的当前值,它是 Warcraft。 -
然后我们有
if (isFan)
块。由于isFan
是true
,if 块内的代码被执行。 -
在 if 块内部,我们在 if 块的作用域内声明了一个新的变量,也命名为
phrase
。这创建了一个独立的、块级作用域的变量,它遮蔽了外部的phrase
变量。 -
内部
phrase
变量的值(在 if 块内声明的那个)被更改为 "For the Horde!"。 -
console.log('Inside if: ' + phrase)
打印 "Inside if: For the Horde!",因为这是内部的phrase
变量。 -
在 if 块之后,外部的
phrase
变量(在函数开始时声明的那个)仍然可访问。它的值被更改为 "For the Alliance!"。 -
最后,
console.log('After if: ' + phrase)
打印 "After if: For the Alliance!",因为我们正在打印函数第一行声明的变量。
现在我们已经了解了 JavaScript 语言的基础知识,让我们看看我们如何使用面向对象编程方法来使用它。
JavaScript 中的面向对象编程
面向对象编程(OOP) 在 JavaScript 中包含五个概念:
-
对象:这些是面向对象编程(OOP)的基本构建块。它们代表现实世界的实体或抽象概念,封装了数据(属性)和行为(方法)。
-
类:这是一种更结构化的创建对象的方式。类作为创建多个类似类型对象(实例)的蓝图。
-
封装:这涉及到将数据和操作这些数据的函数捆绑成一个单一的单位(对象)。它保护对象的内部状态,并允许你控制对其属性和方法访问的控制。
-
继承:这允许我们创建新的类(子类),这些子类可以从现有的类(父类)继承属性和方法。这促进了代码的重用,并在类之间建立了关系。
-
多态:这意味着多种形式。在面向对象编程(OOP)中,它指的是不同类的对象以自己独特的方式响应相同方法调用的能力。
面向对象编程(OOP)有助于组织代码,促进重用,使代码更易于维护,并允许更好地模拟现实世界的关系。让我们回顾每个概念,以了解它们在 JavaScript 中的工作方式。
对象、类和封装
JavaScript 对象是简单的键值对集合。
在 JavaScript 中创建简单对象有两种方式。第一种方式的例子如下:
let obj = new Object();
第二种方式的例子如下:
obj = {};
第二种方式的例子称为对象字面量,这是一种在代码中直接使用方便的符号创建和定义对象的方法。这是在 JavaScript 中处理对象最常见的方式之一,也是比第一个例子中的new Object
构造函数更受欢迎的方式,因为其便利性(紧凑的语法)以及在创建对象时的整体性能。
我们也可以完全创建一个对象,如下所示:
obj = {
name: {
first: 'Gandalf',
last: 'the Grey'
},
address: 'Middle Earth'
};
要声明 JavaScript 对象,使用[键,值]对,其中键可以被认为是对象的属性,而值是属性值。在先前的例子中,address
是键,其值是"Middle Earth"。我们将使用这个概念来创建一些数据结构,例如集合或字典。
对象可以作为其属性包含其他对象。我们称它们为嵌套对象。这创建了一个层次结构,其中对象可以在多个级别上嵌套,就像我们可以在先前的例子中看到的那样,其中name
是obj
中的嵌套对象。
在面向对象编程(OOP)中,对象是类的实例。类定义了对象的特性,并帮助我们进行封装,将属性和方法捆绑在一起,以便它们可以作为一个单元(对象)一起工作。对于我们的算法和数据结构,我们将创建一些代表它们的类。这就是我们如何定义一个代表书籍的类的例子:
class Book {
#percentagePerSale = 0.12;
constructor(title, pages, isbn) {
this.title = title;
this.pages = pages;
this.isbn = isbn;
}
get price() {
return this.pages * this.#percentagePerSale;
}
static copiesSold = 0;
static sellCopy() {
this.copiesSold++;
}
printIsbn() {
console.log(this.isbn);
}
}
我们可以通过构造函数在 JavaScript 类中声明属性。JavaScript 将自动声明一个公共属性,这意味着它可以被直接访问和修改。
在构造函数内部,this
指向正在创建的对象实例。在我们的例子中,这指的是自身。我们可以将代码解释为这本书的标题正在被分配给构造函数传递的标题值。
现代 JavaScript 还允许我们通过添加前缀#
来声明私有属性,例如#percentagePerSale
。这个属性仅在类内部可见,不能直接访问。
公共成员(属性和方法)可以从任何地方访问,无论是类内部还是外部。默认情况下,JavaScript 类中的所有成员都是公共的。
私有成员只能在类内部访问。它们不能从类外部直接访问或修改,这提供了更好的封装和数据保护。
我们还可以使用get
关键字(get price()
)创建 getter。这些可以用来声明一个基于对象其他属性的返回计算值的属性。在这种情况下,书的定价取决于pages
(这是一个公共属性)的数量和每笔销售的利润百分比,这是一个私有属性。我们通过引用关键字this
在类内部访问本地属性。
我们还可以在类中声明方法(printIsbn
)。方法只是与类关联的函数。它们定义了从类(实例)创建的对象可以执行的操作或行为。
现代 JavaScript 也允许我们使用static
关键字声明静态属性和静态方法。静态属性在类的所有实例之间共享,这是一种跟踪类中每个对象共享属性(例如,例如,总共卖出了多少本书)的绝佳方式。在其他语言,如 Ruby 中,它们是类变量。
静态方法不需要类的实例,可以直接访问,例如Book.sellCopy()
。
要实例化这个类,我们可以使用以下代码:
let myBook = new Book('title', 400, 'isbn');
然后,我们可以访问其公共属性并更新它们如下:
console.log(myBook.title); // outputs the book title
myBook.title = 'new title'; // update the value of the book title
console.log(myBook.title); // outputs the updated value: new title
我们可以使用 getter 方法来找出书的定价如下:
console.log(myBook.price); // 48
然后我们可以这样访问静态属性和方法:
console.log(Book.copiesSold); // 0
Book.sellCopy();
console.log(Book.copiesSold); // 1
Book.sellCopy();
console.log(Book.copiesSold); // 2
如果我们想表示另一种类型的书,比如电子书?我们能否重用Book
类中声明的某些定义?让我们在下一节中找出答案。
继承和多态
JavaScript 还允许使用继承,这是面向对象编程中的一种强大机制,允许我们创建新的类(子类),这些类从现有的类(父类或超类)继承属性和方法。让我们看一个例子:
class Ebook extends Book {
constructor(title, pages, isbn, format) {
super(title, pages, isbn);
this.format = format;
}
printIsbn() {
console.log('Ebook ISBN:',this.isbn);
}
}
Ebook.sellCopy();
console.log(Ebook.copiesSold); // 3
我们可以使用extends
关键字扩展另一个类并继承其行为。在我们的例子中,Ebook 是子类,而 Book 是超类。
在constructor
中,我们可以使用关键字super
来引用superclass
构造函数。我们可以在子类中添加更多属性(format
)。子类仍然可以访问超类中的静态方法(sellCopy
)和属性(copiesSold
)。
子类也可以为其在超类中最初声明的方法提供自己的实现。这被称为方法覆盖,允许子类的对象对同一方法调用表现出不同的行为。
通过覆盖超类的方法,我们可以实现一个称为多态的概念,字面上意味着多种形式。在面向对象编程(OOP)中,多态是不同类的对象以它们独特的方式响应相同方法调用的能力。例如:
const myBook = new Book('title', 400, 'isbn');
myBook.printIsbn(); // isbn
const myEbook = new Ebook('DS Ebook', 401, 'isbn 123', 'pdf');
myEbook.printIsbn(); // Ebook ISBN: isbn 123
我们这里有两组书实例,其中一个是电子书。我们可以从两个实例中调用printIsbn
方法,由于每个实例中的行为不同,我们将得到不同的输出。
在本书中,我们将涵盖的大部分数据结构都将遵循 JavaScript 类方法。
尽管 JavaScript 中的类语法与其他编程语言(如 Java 和 C/C++)非常相似,但记住 JavaScript 面向对象编程是通过原型来完成的,这是很好的。
现代技术
JavaScript 是一种不断进化并每年都获得新特性的语言。有一些特性使得在处理数据结构和算法时某些概念更容易。让我们来看看它们。
箭头函数
箭头函数是 JavaScript 中编写函数的一种简洁且富有表现力的方式。它们提供了一种更短的语法,并且在行为上与传统函数表达式有一些关键的区别。考虑以下示例:
const circleAreaFn = function(radius) {
const PI = 3.14;
const area = PI * radius * radius;
return area;
};
console.log(circleAreaFn(2)); // 12.56
使用箭头函数,我们可以将前面代码的语法简化为以下代码:
const circleArea = (radius) => {
const PI = 3.14;
return PI * radius * radius;
};
主要区别在于示例的第一行,我们可以省略关键字function
使用=>
,因此得名箭头函数。
如果函数只有一个语句,我们可以使用更简单的版本,通过省略关键字return
和大括号,如下面的代码片段所示:
const circleAreaSimp = radius => 3.14 * radius * radius;
console.log(circleAreaSimp(2)); // 12.56
如果函数没有接收任何参数,我们可以使用空括号如下所示:
const hello = () => console.log('hello!');
hello(); // hello!
我们将在本书的后面使用箭头函数来编写一些算法,以实现更简单的语法。
扩展和剩余操作符
在 JavaScript 中,我们可以使用apply()
函数将数组转换为参数。现代 JavaScript 有扩展操作符(...
)用于此目的。例如,考虑以下sum
函数:
const sum = (x, y, z) => x + y + z;
我们可以执行以下代码来传递x
、y
和z
参数:
const numbers = [1, 2, 3];
console.log(sum(...numbers)); // 6
上述代码与以下经典 JavaScript 编写的代码相同:
console.log(sum.apply(null, numbers));
扩展操作符(...
)也可以用作函数中的剩余参数来替换arguments
。考虑以下示例:
const restParamaterFunction = (x, y, ...a) => (x + y) * a.length;
console.log(restParamaterFunction(1, 2, 'hello', true, 7)); // 9
上述代码与以下代码相同(在控制台中也输出 9):
function restParamaterFunction(x, y) {
const a = Array.prototype.slice.call(arguments, 2);
return (x + y) * a.length;
}
console.log(restParamaterFunction(1, 2, 'hello', true, 7));
剩余和扩展运算符将在本书的一些数据结构和算法中非常有用。
指数运算符
当处理数学算法时,指数运算符可能会很有用。让我们以使用公式计算圆的面积为例,这个例子可以用指数运算符来改进/简化:
let area = 3.14 * radius * radius;
表达式 radius * radius
与半径的平方相同。我们也可以使用 JavaScript 中可用的 Math.pow
函数来编写相同的代码:
area = 3.14 * Math.pow(radius, 2);
在 JavaScript 中,指数运算符用两个星号(**)表示。它用于将一个数(基数)提升到另一个数(指数)的幂。我们可以使用指数运算符来计算圆的面积,如下所示:
area = 3.14 * (radius ** 2);
TypeScript 基础知识
TypeScript 是由 Microsoft 创建和维护的开源 渐进式类型化的 JavaScript 超集。渐进式类型化是一种类型系统,它将静态类型和动态类型的元素结合在同一编程语言中。
TypeScript 允许我们在 JavaScript 代码中添加类型,提高代码可读性,提高早期错误检测,因为我们可以在开发过程中捕捉到类型相关错误,并增强工具,因为代码编辑器和 IDE 提供了更好的代码自动完成和导航。
关于本书的范围,使用 TypeScript,我们可以使用一些在 JavaScript 中不可用的面向对象概念,例如接口 - 这在处理数据结构和排序算法时可能很有用。当然,我们还可以利用类型化功能,这对于某些数据结构尤为重要。在修改数据结构的算法中,如搜索或排序,确保集合中数据类型的一致性对于平稳运行和可预测的结果至关重要。TypeScript 在自动强制执行这种类型一致性方面表现出色,而 JavaScript 需要采取额外措施才能达到相同水平的保证。
所有这些功能都在编译时可用。在 TypeScript 代码能够在浏览器或 Node.js 环境中运行之前,它需要被编译成 JavaScript。TypeScript 编译器(tsc)会将你的 TypeScript 文件(*.ts 扩展名)转换为相应的 JavaScript 文件。在编译过程中,TypeScript 会检查代码中的类型相关错误并提供反馈。这有助于在开发早期就捕捉到潜在问题,从而产生更可靠且易于维护的代码。
要在我们的数据结构和算法源代码中工作 TypeScript,我们将利用 npm(Node 包管理器)。让我们在 "javascript-datastructures-algorithms
" 文件夹内设置 TypeScript 作为开发依赖项。这涉及到创建一个 package.json
文件,该文件将管理项目依赖项。要启动此过程,请在项目目录中使用终端执行以下命令:
npm init
您将收到一些问题,只需按 Enter 键继续。最后,我们将有一个包含以下内容的package.json
文件:
{
"name": "javascript-datastructures-algorithms",
"version": "4.0.0",
"description": "Learning JavaScript Data Structures and Algorithms",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Loiane Groner"
}
接下来,我们将安装 TypeScript:
npm install --save-dev typescript
运行此命令后,我们将 TypeScript 保存为开发依赖项,这意味着这将在开发期间仅本地使用。此命令还将创建一个package-lock.json
文件,这可以帮助确保从本书中安装依赖项的人将使用与示例中完全相同的包。
如果您想下载源代码,要本地安装依赖项,请运行以下命令:
npm install
接下来,我们需要创建一个扩展名为ts
的文件,这是 TypeScript 文件的扩展名,例如src/01-intro/08-typescript.ts
,内容如下:
let myName = 'Packt';
myName = 10;
上面的代码是一个简单的 JavaScript 代码。现在让我们使用tsc
命令来编译它:
npx tsc src/01-intro/08-typescript.ts
位置:
-
npx
是 Node Package eXecute,这意味着它是一个包运行器,我们将使用它来执行 TypeScript 编译器命令。 -
tsc
是 TypeScript 编译器命令,它将编译并将 src/01-intro/08-typescript.ts 中的源代码转换为 JavaScript。
在终端上,我们将得到以下警告:
src/01-intro/08-typescript.ts:4:1 - error TS2322: Type 'number' is not assignable to type 'string'.
4 myName = 10;
~~~~~~
Found 1 error in src/01-intro/08-typescript.ts:4
警告是由于将数字值 10 分配给初始化为字符串的变量myName
。
但如果我们检查我们创建文件的src/01-intro
文件夹,我们会看到它创建了一个08-typescript.js
文件,内容如下:
var myName = 'Packt';
myName = 10;
上面的生成代码是 JavaScript 代码。即使在终端中存在错误(实际上是一个警告,而不是错误),TypeScript 编译器仍然生成了应该生成的 JavaScript 代码。这证实了 TypeScript 在编译时进行所有类型和错误检查的事实,它不会阻止编译器生成 JavaScript 代码。这允许开发者在编写代码时利用所有这些验证,并得到一个错误或错误更少的 JavaScript 代码。
类型推断
在使用 TypeScript 时,您可能会找到如下代码:
let age: number = 20;
let existsFlag: boolean = true;
let language: string = 'JavaScript';
TypeScript 允许我们为变量分配一个类型。但上面的代码比较冗长。TypeScript 具有类型推断功能,这意味着 TypeScript 会根据分配给变量的值自动验证并应用类型。让我们用更简洁的语法重写前面的代码:
let age = 20; // number
let existsFlag = true; // boolean
let language = 'JavaScript'; // string
如上面的代码所示,TypeScript 仍然知道age
是数字,existsFlag
是布尔值,language
是字符串,基于它们被分配的值,因此不需要显式地为这些变量分配类型。
因此,我们在什么时候为变量指定类型?如果我们声明了变量但没有初始化它,那么根据下面的代码示例,建议分配一个类型:
let favoriteLanguage: string;
let langs = ['JavaScript', 'Ruby', 'Python'];
favoriteLanguage = langs[0];
如果我们没有为变量指定类型,那么它将自动被指定为any
类型,这意味着它可以接收任何值,就像在 JavaScript 中一样。
尽管在 JavaScript 中有如
String
、Number
、Boolean
等对象类型,但在 TypeScript 中声明变量时,使用首字母大写的对象类型并不是一个好的做法。在 TypeScript 中声明变量时,始终优先使用string
、number
、boolean
(小写)。
String
、Number
和Boolean
是对应原始类型的包装对象。它们提供了额外的方法和属性,但通常效率较低,并且不常用于基本变量类型。原始类型(小写类型)确保与现有 JavaScript 代码和库有更好的兼容性。
接口
在 TypeScript 中,接口有两个概念:类型和面向对象接口。让我们逐一回顾。
接口作为类型
在 TypeScript 中,接口是一种强大的定义对象结构或形状的方法。考虑以下代码:
interface Person {
name: string;
age: number;
}
function printName(person: Person) {
console.log(person.name);
}
通过声明一个 Person
接口,我们指定了一个对象可能具有的属性和方法,以便符合 Person
的描述,这意味着我们可以将接口 Person
作为类型使用,正如我们在 printName
函数中声明的参数一样。
这使得像 VSCode 这样的编辑器能够具有如以下所示的自动完成和智能感知功能:
图 1.2 – 带有类型接口智能感知的 Visual Studio Code
现在我们尝试使用 printName
函数:
const john = { name: 'John', age: 21 };
const mary = { name: 'Mary', age: 21, phone: '123-45678' };
printName(john);
printName(mary);
上面的代码没有编译错误。变量 john
有 name
和 age
属性,符合 printName
函数的预期。变量 mary
有 name
和 age
属性,但还有 phone
信息。
那么为什么这段代码能正常工作呢?TypeScript 有一个称为 鸭子类型 的概念。如果它看起来像鸭子,发出像鸭子的声音,并且表现得像鸭子,那么它一定就是鸭子!在示例中,变量 mary
的行为符合 Person
接口,因为它具有 name
和 age
属性,所以它必须是 Person
。这是 TypeScript 的一个强大特性。
再次运行 npx tsc src/01-intro/08-typescript.ts
命令后,我们将在 08-typescript.js
文件中得到以下输出:
function printName(person) {
console.log(person.name);
}
var john = { name: 'John', age: 21 };
var mary = { name: 'Mary', age: 21, phone: '123-45678' };
上面的代码是纯 JavaScript。代码补全、类型和错误检查仅在编译时可用。
默认情况下,TypeScript 编译为 ECMAScript 3。变量声明
let
和const
仅在 ECMAScript 6 中引入。你可以通过创建一个tsconfig.json
文件来指定目标版本。如果你想要更改此行为,请查阅文档中的步骤。
面向对象接口
TypeScript 接口的第二个概念与面向对象编程相关,这与 Java、C#、Ruby 等其他面向对象语言中的概念相同。接口是一个合约。在这个合约中,我们可以定义将实现此合约的类或接口应该具有的行为。以 ECMAScript 标准为例。ECMAScript 是 JavaScript 语言的接口。它告诉 JavaScript 语言它应该具备哪些功能,但每个浏览器可能对此有不同的实现。
让我们看看一个有用的例子,我们将在这个书中实现的数据结构和算法中用到。考虑下面的代码:
interface Comparable {
compareTo(b): number;
}
class MyObject implements Comparable {
age: number;
constructor(age: number) {
this.age = age;
}
compareTo(b): number {
if (this.age === b.age) {
return 0;
}
return this.age > b.age ? 1 : -1;
}
}
接口 Comparable
告诉类 MyObject
应该实现一个名为 compareTo
的方法,该方法接收一个参数。在这个方法内部,我们可以编写所需的逻辑。在这种情况下,我们正在比较两个数字,但我们可以使用不同的逻辑来比较两个字符串,甚至是一个具有不同属性的更复杂对象。compareTo
方法在对象相同的情况下返回 0,如果当前对象大于另一个对象时返回 1,如果当前对象小于另一个对象时返回 -1。这个接口的行为在 JavaScript 中不存在,但它在处理排序算法等任务时非常有帮助。
为了演示多态的概念,我们可以使用下面的代码:
function compareTwoObjects(a: Comparable, b: Comparable) {
console.log(a.compareTo(b));
console.log(b.compareTo(a));
}
在这种情况下,函数 compareTwoObjects
接收两个实现了 Comparable
接口的对象。它可以是由 MyObject
或任何其他实现了此接口的类实例。
泛型
泛型 是 TypeScript(以及许多其他强类型编程语言)中的一个强大功能,它允许你编写可重用的代码,这些代码可以与各种类型一起工作,同时保持类型安全。可以将它们视为函数、类或接口的模板或蓝图,这些模板或蓝图可以用不同的类型进行参数化。
让我们修改 Comparable
接口,以便我们可以定义方法 compareTo
应接收作为参数的对象类型:
interface Comparable<T> {
compareTo(b: T): number;
}
通过将类型 T
动态传递给 Comparable
接口——在菱形运算符 <>
之间,我们可以指定 compareTo
函数的参数类型:
class MyObject implements Comparable<MyObject> {
age: number;
constructor(age: number) {
this.age = age;
}
compareTo(b: MyObject): number {
if (this.age === b.age) {
return 0;
}
return this.age > b.age ? 1 : -1;
}
}
这很有用,因为我们确保正在比较的是相同类型的对象。这是通过确保参数 b
的类型与菱形运算符内的 T
匹配来实现的。通过使用此功能,我们还可以从编辑器中获得代码补全。
枚举
枚举(简称 枚举)是一种定义一组命名常量的方式。它们通过给值赋予有意义的名称来帮助组织代码,并使代码更易于阅读。
我们可以使用 TypeScript 的 枚举
来避免代码异味,如 魔法数字。魔法数字是指没有明确解释其含义的数值常量。
当处理比较值或对象时,这在排序算法中相当常见,我们经常看到 -1、1 和 0 这样的值。但这些数字代表什么意思呢?
正是在这个时候,枚举(enums
)出现以改善代码的可读性。让我们使用枚举对前面的示例中的 compareTo
函数进行重构:
enum Compare {
LESS_THAN = -1,
BIGGER_THAN = 1,
EQUALS = 0
}
function compareTo(a: MyObject, b: MyObject): number {
if (a.age === b.age) {
return Compare.EQUALS;
}
return a.age > b.age ? Compare.BIGGER_THAN : Compare.LESS_THAN;
}
通过为每个枚举常量分配值,我们可以用简短的说明替换 -1、1 和 0 的值,而不会改变代码的输出。
类型别名
TypeScript 还有一个名为 类型别名 的酷特性。它允许你为现有类型创建新名称。它也使得代码更容易理解,尤其是在处理复杂类型时。
让我们检查一个示例:
type UserID = string;
type User = {
id: UserID;
name: string;
}
在前面的示例中,我们创建了一个名为 UserID
的类型,它是 string
的别名。当我们声明第二个类型 User
时,我们表示 id
的类型是 UserID
,这使得阅读代码和理解 id
的含义更加容易。
当处理排序算法时,这个特性将会很有用,因为我们能够创建比较函数的别名,这样我们就可以以最通用的可行方式编写算法,以处理任何数据类型。
在 JavaScript 文件中进行 TypeScript 编译时检查
一些开发者仍然更喜欢使用纯 JavaScript 来开发他们的代码,而不是 TypeScript。但如果我们能在 JavaScript 中也使用 TypeScript 的一些类型和错误检查功能,那会很好,因为 JavaScript 不提供这些功能。
好消息是 TypeScript 有一个特殊的功能,允许我们在编译时进行错误和类型检查!要使用它,我们需要在计算机上全局安装 TypeScript,使用 npm install -g TypeScript
命令。
让我们看看 JavaScript 是如何处理我们在本章中之前使用的代码类型的:
图 1.3 – 无 TypeScript 编译时检查的 VSCode 中的 JavaScript 代码
在 JavaScript 文件的第 一行,如果我们想使用类型和错误检查,我们需要添加 // @ts-check
,如下所示:
图 1.4 – 使用 TypeScript 编译时检查的 VSCode 中的 JavaScript 代码
当我们向代码中添加 JSDoc(JavaScript 文档)时,类型检查被启用。为此,请在函数声明之前添加以下代码:
/**
* Arrow function example, calculates the area of a circle
* @param {number} radius
* @returns
*/
然后,如果我们尝试向我们的圆(或 circleAreaFn
)函数传递一个字符串,我们将得到一个编译错误:
图 1.5 – JavaScript 的类型检查动作
要在 VSCode 中启用推断变量名称和类型,请打开您的设置,并通过 内联提示 进行搜索,然后启用此功能。在编码时,这可能会带来(更好的)差异。
其他 TypeScript 功能
这只是一个对 TypeScript 的快速介绍。TypeScript 文档是一个学习所有其他功能并深入了解本章中快速覆盖的主题细节的绝佳地方:www.typescriptlang.org
。
本书源代码包还包含本书中将要开发的 JavaScript 数据结构和算法的 TypeScript 版本,作为额外资源。并且每当 TypeScript 使与数据结构和算法相关的概念更容易理解时,我们也会在本书中使用它。
摘要
在本章中,我们学习了学习数据结构和算法的重要性,以及它如何使我们成为更好的开发者,并帮助我们通过技术面试。我们还回顾了我们选择 JavaScript 来学习和应用这些概念的原因。
你学习了如何设置开发环境,以便能够创建或执行本书中的示例。我们还涵盖了在开始开发本书中将要涵盖的算法和数据结构之前所需的 JavaScript 语言的基础知识。
我们还全面介绍了 TypeScript,展示了它如何通过静态类型和错误检查增强 JavaScript,以实现更可靠的代码。我们探讨了接口、类型推断和泛型等基本概念,使我们能够编写更健壮和可维护的数据结构和算法。
在下一章中,我们将重点转向一个关键主题——大 O 表示法,这是评估和理解我们代码实现效率和性能的基本工具。
第三章:2 Big O 表示法
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“学习-javascript-dsa-4e”频道下找到“EARLY ACCESS SUBSCRIPTION”)。
在本章中,我们将揭示Big O 表示法的力量,这是一个分析算法效率的基本工具,它涉及时间复杂度(运行时间如何随着输入规模的变化而变化)和空间复杂度(内存使用率如何变化)。我们将探讨常见的如 O(1)、O(log n)、O(n) 等时间复杂度,以及它们在选择正确算法和优化代码方面的实际影响。理解 Big O 表示法不仅对于编写可扩展和性能良好的软件至关重要,而且对于通过技术面试也至关重要,因为它展示了你批判性地思考算法效率的能力。在本章中,我们将涵盖:
-
Big O 时间复杂度
-
空间复杂度
-
计算算法的复杂度
-
Big O 表示法和技术面试
-
练习
理解 Big O 表示法
Big O 表示法用于描述和分类算法的性能或复杂度,根据输入规模增长时算法运行所需的时间来衡量。
我们如何衡量算法的效率呢?我们通常使用资源,如 CPU(时间)使用率、内存使用率、磁盘使用率和网络使用率。当谈到 Big O 表示法时,我们通常考虑 CPU(时间)使用率。
用更简单的话来说,这种表示法是一种描述算法运行时间如何随着输入规模增大而增长的方法。虽然算法实际运行所需的时间可能会因处理器速度和可用资源等因素而变化,但 Big O 表示法允许我们关注算法必须执行的基本步骤。把它想象成测量算法相对于输入规模执行的操作数量。
想象你桌子上有一摞文件。如果你需要找到一份特定的文件,你必须逐张搜索直到找到它。对于 10 张小文件堆,这不会花很长时间。但是如果你有 20 张文件,搜索可能需要两倍的时间,而有 100 张文件时,可能需要十倍的时间!
开发者必须每天完成的任务包括选择使用哪些数据结构和算法来解决特定问题。这可能是一个现有的算法,或者你可能需要编写自己的逻辑来解决业务用户故事。需要注意的是,任何算法在处理低量级数据时可能运行良好,看起来也还不错,然而,当输入数据的量增加时,效率低下的算法将变得缓慢,并影响应用程序。了解如何衡量性能是实现这些任务成功的关键。
大 O 符号很重要,因为它帮助我们比较不同的算法,并为特定任务选择最有效的算法。例如,如果你在一个大型在线商店中寻找特定的产品,你不会想使用需要查看每个产品的算法。相反,你会使用一个更高效的算法,该算法只需要查看产品的小子集。
大 O 时间复杂度
大 O 符号使用大写 O 来表示上界。它表示实际运行时间可能小于但不大于函数所表达的时间。它并不告诉我们算法的确切运行时间。相反,它告诉我们当输入大小增大时,事情可能变得有多糟糕。
想象你有一个杂乱的房间,需要找到一只特定的袜子。在最坏的情况下,你必须逐件检查每一件衣服(这就像线性时间算法)。大 O 告诉你,即使你的房间变得非常杂乱,你也不需要查看比实际存在的物品更多的物品。你可能很幸运,能快速找到袜子!实际时间可能远小于大 O 预测的时间。
在分析算法时,最常遇到的时间复杂度和空间复杂度的分类如下:
符号 | 名称 | 说明 |
---|---|---|
O(1) | 常数 | 算法的运行时间或空间使用量不随输入大小(n)的变化而变化。 |
O(log(n)) | 对数 | 算法的运行时间或空间使用量与输入大小(n)的对数成正比。这意味着当输入大小加倍时,操作次数或内存使用量增加一个常数量。 |
O(n) | 线性 | 算法的运行时间或空间使用量与输入大小(n)线性增长。这意味着当输入大小加倍时,操作次数或内存使用量也会加倍。 |
O(n²) | 二次方 | 算法的运行时间或空间使用量与输入大小(n)的平方成正比。这意味着当输入大小加倍时,操作次数或内存使用量会增加到原来的四倍。 |
O(n^c) | 多项式 | 算法的运行时间或空间使用量随着输入大小(n)的多项式函数增长。这意味着当输入大小加倍时,操作次数或内存使用量增加一个因子(c),该因子是输入大小的多项式函数。 |
O(c^n) | 指数 | 算法的运行时间或空间使用量与输入大小(n)的指数成正比。这意味着当输入大小增加时,操作次数或内存使用量的增长速度会越来越快。 |
表 2.1:大 O 符号的时间复杂度和空间复杂度分类
让我们逐一回顾,以详细了解时间复杂度。
O(1):常数时间
O(1) 表示算法的运行时间(或有时是空间复杂度)保持不变,无论输入数据的大小如何。无论是处理小输入还是大输入,执行算法所需的时间都不会显著变化。
例如,假设我们想要计算给定天数内的秒数。我们可以创建以下函数来解决这个问题:
function secondsInDays(numberOfDays) {
if (numberOfDays <= 0 || !Number.isInteger(numberOfDays)) {
throw new Error('Invalid number of days');
}
return 60 * 60 * 24 * numberOfDays;
}
每分钟有 60 秒,每小时有 60 分钟,每天有 24 小时。
我们可以使用console.log
来查看通过不同天数传递的结果输出:
console.log(secondsInDays(1)); // 86400
console.log(secondsInDays(10)); // 864000
console.log(secondsInDays(100)); // 8640000
如果我们传递参数1
来调用这个函数(secondsinDays(1)
),这段代码输出结果将需要几毫秒。如果我们再次执行函数,传递参数10
(secondsinDays(10)
),代码输出结果同样需要几毫秒。
这个secondsInDays
函数的时间复杂度是O(1)——常数时间。它执行的操作(乘法)是固定的,不会随着输入numberOfDays
的变化而变化。无论你输入 1 天还是 1000 天,计算结果所需的时间都是相同的。
O(1)算法通常不涉及遍历数据或递归调用,这些调用会乘以操作数。它们通常涉及直接访问数据,比如通过索引查找数组中的值或执行简单的计算。虽然O(1)算法非常高效,但它们并不总是适用于每个问题。有些任务本质上需要处理输入中的每个项目,从而导致不同的时间复杂度。
O(log(n)):对数时间
O(log n)算法的运行时间(有时是空间复杂度)随着输入大小(n)的对数增长。这意味着算法的每一步都会显著减小问题规模,通常是通过将其分成一半或类似的分数。输入规模越大,每个额外元素对整体运行时间的影响就越小。换句话说,当输入规模加倍时,运行时间增加一个常数(例如,只增加一步)。
想象你正在玩一个“猜数字”游戏。你从一个 1 到 64 的范围开始,每次猜测都把可能的数字范围减半。假设你的第一次猜测是 30。如果太高,你现在知道数字在 1 到 29 之间。你实际上已经将搜索空间减半了!接下来,你猜测 10(太低),进一步缩小范围到 11 到 29。你的第三次猜测,20,恰好是正确的!
即使你从一个更大的数字范围开始(比如 1 到 1000,甚至 1 到 100 万),这种折半策略仍然可以让你在出人意料的小次数猜测中找到这个数字——对于 1 到 64,大约需要 7 次,对于 1 到 1000,大约需要 10 次,对于 1 到 100 万,大约需要 20 次。这展示了对数增长的力量。
我们可以说这种方法的复杂度是O(log(n))。在每一步中,算法都会消除输入数据的一个很大部分,使得剩余的工作量大大减小。
时间复杂度为*O(log(n))的函数通常在每一步将问题规模减半。这种复杂性与分而治之算法相关,我们将在第十八章“算法设计和技巧”中介绍。
对数算法非常高效,尤其是在处理大数据集时。它们通常用于需要快速搜索或操作排序数据的场景,我们将在本书的后面部分介绍这些内容。
O(n):线性时间
O(n)表示算法的运行时间(有时是空间复杂度)与输入大小n线性且成比例增长。如果我们加倍输入数据的大小,算法运行所需的时间大约也会加倍。如果我们增加输入的三倍,它将大约需要三倍的时间,依此类推。
假设你有一个月度费用的数组,并想计算总支出金额。以下是我们可以这样做的方法:
function calculateTotalExpenses(monthlyExpenses) {
let total = 0;
for (let i = 0; i < monthlyExpenses.length; i++) {
total += monthlyExpenses[i];
}
return total;
}
for 循环遍历数组中的每个元素(monthlyExpense
),将其添加到total
变量中,然后返回总费用的金额。
我们可以使用以下代码来检查此函数的输出,传递不同的参数:
console.log(calculateTotalExpenses([100, 200, 300])); // 600
console.log(calculateTotalExpenses([200, 300, 400, 50])); // 950
console.log(calculateTotalExpenses([30, 40, 50, 100, 50])); //270
迭代次数(以及total
的增加)直接取决于数组的大小(monthlyExpenses.length
)。如果数组有 12 个月的费用,循环运行 12 次。如果它有 24 个月,循环运行 24 次。运行时间与数组中的元素数量成比例增加。
这是因为该函数包含一个运行n次的循环。因此,运行此函数所需的时间与输入大小n成正比。如果n加倍,运行函数的时间大约也会加倍。因此,我们可以说前面的函数具有O(n)的复杂度,在这个上下文中,n代表输入大小。
虽然O(n)算法不如常数时间(O(1))算法快,但它们对于许多任务仍然被认为是高效的。有许多情况需要处理输入的每个元素,使得线性时间是一个合理的期望。
O(n²):二次时间
O(n²)表示算法的运行时间(有时是空间复杂度)随着输入大小n的平方增长。这意味着当输入大小加倍时,运行时间大约会增加到四倍。如果你将输入增加到三倍,运行时间将增加九倍,依此类推。O(n²)算法通常涉及嵌套循环,其中内循环在外循环的每次迭代中迭代n次。这导致大约n * n(或n²)次操作。
让我们回到计算费用的例子。假设你在电子表格中有以下数据,每个月的费用如下:
月份/费用 | 一月 | 二月 | 三月 | 四月 | 五月 | 六月 |
---|---|---|---|---|---|---|
水务公用事业 | 100 | 105 | 100 | 115 | 120 | 135 |
功率公用事业 | 180 | 185 | 185 | 185 | 200 | 210 |
垃圾处理费 | 30 | 30 | 30 | 30 | 30 | 30 |
租金/抵押贷款 | 2000 | 2000 | 2000 | 2000 | 2000 | 2000 |
杂货 | 600 | 620 | 610 | 600 | 620 | 600 |
爱好 | 150 | 100 | 130 | 200 | 150 | 100 |
表 2.2:每月费用示例
如果我们想编写一个函数来计算几个月的总费用呢?这个函数的代码如下:
function calculateExpensesMatrix(monthlyExpenses) {
let total = 0;
for (let i = 0; i < monthlyExpenses.length; i++) {
for (let j = 0; j < monthlyExpenses[i].length; j++) {
total += monthlyExpenses[i][j];
}
}
return total;
}
这个函数有两个嵌套循环:
-
外循环(
i
)遍历矩阵的行(每个月份内的类别或费用类型)。 -
内循环(
j
)遍历矩阵的列(每个月份)的每一行。
在嵌套循环中,我们只需将费用加到total
上,然后在函数结束时返回。
让我们用之前表示的数据来测试这个函数:
const monthlyExpenses = [
[100, 105, 100, 115, 120, 135],
[180, 185, 185, 185, 200, 210],
[30, 30, 30, 30, 30, 30],
[2000, 2000, 2000, 2000, 2000, 2000],
[600, 620, 610, 600, 620, 600],
[150, 100, 130, 200, 150, 100]
];
console.log('Total expenses: ', calculateExpensesMatrix(monthlyExpenses)); // 18480
我们可以说前面的函数具有O(nˆ2)的复杂度。这是因为函数包含两个嵌套循环。外循环将运行 6 次(n),内循环也将运行 6 次,因为我们有 6 个月(m)。我们可以说操作的总数是n * m。如果n和m是相似的数字,我们可以说是n * n,因此是nˆ2。
在大 O 记号中,我们简化为最高阶的量级,即nˆ2。这意味着函数的时间复杂度随着输入大小的平方(输入大小平方)而增长。所以,如果你有一个 12x12 的矩阵(12 个费用类别和 12 个月份),内循环在每个月份的 12 次中运行 12 次,总共 144 次操作。如果我们扩展费用列表和月份数量,使用一个 24x24 的矩阵,操作次数变为 576(24 * 24)。这是具有O(nˆ2)时间复杂度的算法的特征。
O(2^n):指数时间复杂度
O(2^n) 表示算法的运行时间(有时是空间复杂度)随着输入大小(n)的每个额外单位而加倍。如果你向输入中添加一个更多元素,算法大约需要两倍的时间。如果你添加两个更多元素,它大约需要四倍的时间,以此类推。运行时间呈指数增长。具有指数时间复杂度的算法性能不令人满意。
一个经典的O(2ˆn)算法的例子是当我们有穷举法,会尝试一组值的所有可能的组合。
想象一下,我们想知道我们可以有多少种独特的冰淇淋配料组合,或者完全没有配料。可用的配料有巧克力酱、樱桃和彩虹糖。
可能的组合有哪些?
由于每种配料可以是有的也可以是没有,而我们有三中不同的配料,所以可能的组合总数是:2 * 2 * 2 = 2³ = 8。
这里是以下组合的列表:
-
没有配料
-
只有巧克力酱
-
只有樱桃
-
只有彩虹糖
-
巧克力酱 + 樱桃
-
巧克力酱 + 彩虹糖
-
樱桃 + 彩虹糖
-
巧克力酱 + 樱桃 + 彩虹糖
如果我们有 10 种配料可供选择,我们将有 2 ^ 10 种可能的组合,总共 1024 种不同的组合。
另一个指数复杂度算法的例子是破解密码或 PIN 的暴力攻击。如果我们有一个 4 位(0-9)的 PIN 码,我们总共有 10ˆ4 种组合,总共 10000 种组合。如果我们使用仅包含字母的密码,我们将有 26ˆn 种组合,其中 n 是密码中字母的数量。如果我们允许密码中包含大小写字母,我们将有 62ˆn 种组合。这就是为什么始终创建包含字母(大小写)、数字和特殊字符的长密码很重要的原因,因为可能的组合数量呈指数增长,这使得通过暴力破解密码变得更加困难。
指数算法通常被认为对于大输入不切实际,因为它们的运行时间增长非常快。它们甚至对于中等大小的数据集也可能变得不可行。在可能的情况下,找到更有效的算法至关重要。
O(n!): 阶乘时间复杂度
O(n!) 表示一个算法的运行时间(有时是空间复杂度)随着输入大小 (n) 的增加而急剧增长。这种增长甚至比指数时间复杂度还要快。具有阶乘时间复杂度的算法性能最差。
数字 n 的阶乘(表示为 n!) 的计算方法是 n * (n-1) * (n-2) , …, * 1. 例如,4!是 4 * 3 * 2 * 1 = 24 。1 如我们所见,阶乘增长得非常快
一个 O(n!) 算法的经典例子是当我们尝试找到集合的所有可能的排列,例如,字母 ABCD 如下所示:
ABCD | BACD | CABD | DABC |
---|---|---|---|
ABDC | BADC | CADB | DACB |
ACBD | BCAD | CBAD | DBAC |
ACDB | BCDA | CBDA | DBCA |
ADBC | BDAC | CDAB | DCAB |
ADCB | BDCA | CDBA | DCBA |
表 2.3:字母 ABCD 的所有排列
具有阶乘时间复杂度的算法通常被认为效率极低,应尽可能避免。对于许多最初似乎需要 O(n!) 解决方案的问题,通常存在更聪明的算法,具有更好的时间复杂度(例如:动态规划技术)。
我们将在第十八章“算法设计与技术”中介绍具有指数和阶乘时间的算法。
比较复杂度
我们可以创建一个表格,列出一些值,以说明算法的成本,基于其时间复杂度和输入大小,如下所示:
输入大小 (n) | O(1) | O(log (n)) | O(n) | O(n log(n)) | O(n²) | O(2^n) | O(n!) |
---|---|---|---|---|---|---|---|
10 | 1 | 1 | 10 | 10 | 100 | 1024 | 3628800 |
20 | 1 | 1.30 | 20 | 26.02 | 400 | 1048576 | 2.4329E+18 |
50 | 1 | 1.69 | 50 | 84.94 | 2500 | 1.1259E+15 | 3.04141E+64 |
100 | 1 | 2 | 100 | 200 | 10000 | 1.26765E+30 | 9.33262E+157 |
500 | 1 | 2.69 | 500 | 1349.48 | 250000 | 3.27339E+150 | 非常大的数字 |
1000 | 1 | 3 | 1000 | 3000 | 1000000 | 1.07151E+301 | 非常大的数字 |
10000 | 1 | 4 | 10000 | 40000 | 100000000 | 非常大的数字 | 非常大的数字 |
表 2.4:基于输入大小比较大 O 时间复杂度
我们可以根据前表中提供的信息绘制一张图表,以显示不同大 O 表示法复杂性的成本如下:
图 2.1 – 大 O 表示法复杂度图表
前面的图表也是使用 JavaScript 绘制的。您可以在源代码包的
src/02-bigOnotation
目录中找到其源代码。
当我们将具有不同时间复杂度的算法的运行时间与输入大小在图表上绘制时,会出现不同的模式:
-
O(1) - 常数时间:一条水平线。无论输入大小如何,运行时间都保持不变。
-
O(log n) - 对数时间:一条随着输入大小增加而逐渐变平缓的曲线。可以将其视为越来越不陡峭的斜率。每个额外的输入元素对整体运行时间的影响逐渐减小。
-
O(n) - 线性时间:一条具有正斜率的直线。运行时间与输入大小成比例增加。输入加倍,运行时间大约也加倍。
-
O(n²) - 二次时间:一条开始时较浅但变得越来越陡峭的曲线。运行时间增长速度远快于输入大小。输入加倍,运行时间大约增加四倍。
-
O(2^n) - 指数时间:一条最初看似平坦但随后随着输入大小略微增加而急剧上升的曲线。运行时间增长速度极快。
-
O(n!) - 阶乘时间:一条几乎垂直上升的曲线。即使对于相对较小的输入,运行时间也会变得极其巨大,很快变得不切实际。
这些可视化是理解算法随着输入大小增长长期行为的宝贵工具。它们帮助我们做出明智的选择,关于哪些算法最适合不同的场景,尤其是在处理大数据集时。
空间复杂度
空间复杂度指的是算法解决问题时使用的内存(或空间)量。它是衡量算法除输入数据本身占用的空间外还需要多少额外存储的指标。
理解空间复杂度很重要,因为现实世界的计算机内存是有限的。如果算法的空间复杂度太高,它可能在处理大数据集时耗尽内存。即使我们有足够的内存,具有高空间复杂度的算法也可能因为内存访问时间增加和缓存问题等因素而变慢。此外,这完全是关于权衡。有时,如果我们选择的算法在时间复杂度上提供了显著的改进,我们可能会选择稍微高一点的空间复杂度。当然,这需要根据具体情况逐一审查。
大 O 符号在空间复杂度方面与时间复杂度一样适用。它表达了随着输入大小增加,算法内存使用量增长的界限。让我们回顾常见的 Big O 空间复杂度:
-
**O(1) - 常数空间:算法使用固定数量的内存,无论输入大小如何。这是理想的,因为内存使用不会成为瓶颈。
- 例如:交换两个变量。
-
**O(n) - 线性空间:算法的内存使用量随着输入大小的增加而线性增长。如果我们加倍输入,内存使用量大致也会加倍。
- 例如:存储输入数组的副本。
-
**O(log n) - 对数空间:算法的内存使用量以对数方式增长。这对于大型数据集来说相对高效。
- 例如:某些递归算法,其递归深度是对数级的。
-
**O(n²) - 二次空间:算法的内存使用量呈二次增长。对于大输入,这可能会成为一个问题。
- 例如:在一个二维数组中存储乘法表。
-
**O(2^n) - 指数空间:类似于指数时间复杂度,这表明内存使用量会极其迅速地增长。这通常不实用,应避免。
计算算法的复杂度
理解如何阅读算法代码并识别其以大 O 符号表示的复杂度也很重要。通过分析算法的复杂度,我们可以识别潜在的瓶颈,并专注于改进该特定区域。
为了确定代码在时间复杂度方面的成本,我们需要逐步审查它,并关注以下要点:
-
基本操作,如赋值、位运算和数学运算,通常具有常数时间复杂度(O(1))。
-
对数算法(O(log(n)))通常遵循分而治之的策略。它们将问题分解成更小的子问题并递归地解决它们。
-
循环:循环执行的次数直接影响时间复杂度。嵌套循环会放大其效果。因此,如果我们有一个循环遍历大小为n的输入,它将是线性时间(O(n)),两个嵌套循环(O(n²)),依此类推。
-
递归:递归函数会调用自身,如果不精心设计,可能会导致指数级的时间复杂度。我们将在第九章,递归中介绍递归。
-
函数调用:考虑代码中调用的任何函数的时间复杂度。
为了确定代码在空间复杂度方面的成本,我们需要逐步审查它,并关注以下要点:
-
变量:算法中使用的变量消耗多少内存?变量的数量是否随着输入大小的增加而增长?
-
数据结构:正在使用哪些数据结构(数组、列表、树等)?它们的大小如何与输入规模成比例?
-
函数调用:如果算法使用递归,会进行多少次递归调用?每次调用都会增加调用栈的空间复杂度。
-
分配:算法中是否动态分配内存?分配了多少内存,它与输入大小有何关系?
让我们看看一个函数的示例,该函数记录给定数字的乘法表:
function multiplicationTable(num, x) {
let s = '';
let numberOfAsterisks = num * x;
for (let i = 1; i <= numberOfAsterisks; i++) {
s += '*';
}
console.log(s);
for (let i = 1; i <= num; i++) {
console.log(`Multiplication table for ${i} with x = ${x}`);
for (let j = 1; j <= x; j++) {
console.log(`${i} * ${j} = `, i * j);
}
}
}
让我们使用 Big O 表示法分解 multiplicationTable
函数的时间和空间复杂度。首先,让我们关注时间复杂度:
-
O(1) 操作:
-
赋值变量(
let s = ''
和let numberOfAsterisks = num * x
) -
打印固定字符串(
console.log('Calculating the time complexity of a function')
)
-
-
O(n) 操作:
-
构建星号字符串:循环迭代 num * x 次,每次迭代涉及字符串连接,这取决于 JavaScript 实现的线性操作。
-
打印星号字符串:输出长度为 num * x 的字符串所需的时间与其实际长度成比例。
-
-
O(nˆ2) 操作:
- 嵌套循环:外循环运行
num
次,对于每次迭代,内循环运行 x 次。这导致大约 num * x(或 nˆ2)次内层console.log
语句的迭代,其中实际进行乘法操作。
- 嵌套循环:外循环运行
虽然函数中存在 O(1) 和 O(n) 操作,但时间复杂度中的主导因素是嵌套循环结构,这导致二次时间复杂度 O(n²)。在 Big O 表示法中,我们将其简化为最高阶数,即 n²。因此,函数的整体时间复杂度是 O(n²)。
现在,让我们回顾空间复杂度:
-
O(1) 空间:
- 简单变量(
s
,numberOfAsterisks
,循环计数器i
和j
)使用固定数量的内存,无论输入值num
和x
如何。
- 简单变量(
-
O(n) 空间复杂度 (潜在):
- 字符串
s
可能会增长到 num * x 的大小,这意味着其空间使用与输入大小成线性关系。然而,在大多数实现中,字符串连接是经过优化的,所以这可能不是一个大问题,除非输入值非常大。
- 字符串
因此,总体而言,由于星号字符串的潜在增长,空间复杂度可以被认为是 O(n)。然而,出于实际目的,空间使用通常不是一个重大问题,我们通常关注此函数的主要关注点是 O(n²) 时间复杂度。
Big O 表示法和技术面试
在软件工程师职位的技术面试中,公司通常会在一些在线服务(如 LeetCode,Hackerrank 和其他类似服务)上执行编码测试。
选择正确的数据结构或算法来解决问题可以告诉公司一些关于你如何解决可能出现的解决问题的信息。
面试官可能会要求你分析代码并预测在不同的输入大小下其运行时间或内存使用情况可能会有何变化。一旦你编写了代码来解决问题,面试官还可能会要求你指出代码中可能存在的性能问题,以及你是否能够识别出优化的区域。此外,不同的算法和数据结构具有不同的时间复杂度,了解大 O 符号可以帮助你做出明智的决定,选择最适合特定问题的解决方案,考虑到所有的权衡。
在面试过程中,你也可以展示你解决问题的速度以及如何优化它们。例如,如果遇到任何涉及数组搜索的问题,你可以从一个简单的算法开始,以展示你能够快速解决问题,根据问题的紧急程度,一旦问题得到解决,如果你有更多时间解决问题,可以展示如何将其优化为使用更高效的搜索算法。
在本书的每一章中,我们将涵盖一些与章节主题相关的问题,以及我们可以如何进一步优化它们。
练习
现在你已经通过大 O 符号探索了时间和空间复杂性的基础知识,是时候测试你的理解了!分析以下 JavaScript 函数并确定它们的时间和空间复杂度。尝试不同的输入以查看函数的行为。
1:确定数组的大小是奇数还是偶数:
const oddOrEven = (array) => array.length % 2 === 0 ? 'even' : 'odd';
2:计算并返回一个数字数组的平均值:
function calculateAverage(array) {
let sum = 0;
for (let i = 0; i < array.length; i++) {
sum += array[i];
}
return sum / array.length;
}
3:检查两个数组是否有任何共同值:
function hasCommonElements(array1, array2) {
for (let i = 0; i < array1.length; i++) {
for (let j = 0; j < array2.length; j++) {
if (array1[i] === array2[j]) {
return true;
}
}
}
return false;
}
4:从输入数组中过滤出奇数:
function getOddNumbers(array) {
const result = [];
for (let i = 0; i < array.length; i++) {
if (array[i] % 2 !== 0) {
result.push(array[i]);
}
}
return result;
}
你将在本章的源代码中找到答案(文件 src/02-bigOnotation/03-exercises.js
)。将你的分析结果与提供的解决方案进行比较,以巩固你在实际 JavaScript 代码中对大 O 符号的理解!
摘要
在本章中,我们深入探讨了 Big O 符号的基本概念,这是一个分析并表达算法效率的强大工具。我们探讨了如何计算时间复杂度(输入大小与运行时间之间的关系)和空间复杂度(输入大小与内存使用之间的关系)。我们还讨论了 Big O 分析对于软件开发者来说是一个关键技能,有助于算法选择、性能优化和技术面试。
在下一章中,我们将深入探讨我们的第一个数据结构:多才多艺的数组。我们将探讨其常见操作,分析它们的时间复杂度,并解决一些实际的编码挑战。
第四章:3 数组
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“学习 JavaScript 数据结构与算法”第四版下的“EARLY ACCESS SUBSCRIPTION”中找到“learning-javascript-dsa-4e”频道)。
数组是最简单的内存数据结构。因此,所有编程语言都有内置的数组数据类型。JavaScript 也原生支持数组,尽管它的第一个版本发布时没有数组支持。在本章中,我们将深入研究数组数据结构和其功能。
数组按顺序存储相同数据类型的值。尽管 JavaScript 允许我们创建包含不同数据类型值的数组,但我们将遵循最佳实践,并假设我们无法这样做(大多数语言都没有这种功能)。
为什么我们应该使用数组?
让我们考虑我们需要存储我们居住的城市每年每个月的平均温度。我们可以使用以下代码片段来存储这些信息:
const averageTempJan = 12;
const averageTempFeb = 15;
const averageTempMar = 18;
const averageTempApr = 20;
const averageTempMay = 25;
然而,这并不是最佳方法。如果我们只存储一年的温度,我们可以管理 12 个变量。但是,如果我们需要存储 50 年的平均温度怎么办?幸运的是,这就是数组被创建的原因,我们可以轻松地用以下方式表示之前提到的相同信息:
const averageTemp = [12, 15, 18, 20, 25];
// or
averageTemp[0] = 12;
averageTemp[1] = 15;
averageTemp[2] = 18;
averageTemp[3] = 20;
averageTemp[4] = 25;
我们也可以用图形表示 averageTemp
数组:
图 3.1:
创建和初始化数组
在 JavaScript 中声明、创建和初始化数组很简单,如下面的示例所示:
let daysOfWeek = new Array(); // {1}
daysOfWeek = new Array(7); // {2}
daysOfWeek = new Array('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'); // {3}
// preferred
daysOfWeek = []; // {4}
daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; // {5}
我们可以:
-
行
{1}
:使用关键字new
声明并实例化一个新的数组——这将创建一个空数组。 -
行
{2}
:指定数组的 长度(我们计划在数组中存储多少个元素)来创建一个空数组。 -
行
{3}
:通过在构造函数中直接传递元素来创建和初始化数组。 -
行
{4}
:通过分配空括号([]
)创建一个空数组。使用关键字new
并不是最佳实践,因此使用括号是首选方式。 -
行
{5}
:使用括号作为最佳实践创建和初始化数组。
如果我们想知道数组中有多少个元素(其大小),我们可以使用 length
属性。以下代码将输出 7
:
console.log('daysOfWeek.length', daysOfWeek.length); // output: 7
访问元素和迭代数组
要访问数组的特定位置,我们也可以使用括号,传递我们想要访问的位置的索引。例如,假设我们想要输出 daysOfWeek
数组中的所有元素。为此,我们需要循环数组并打印元素,从索引 0 开始如下:
for (let i = 0; i < daysOfWeek.length; i++) {
console.log(`daysOfWeek[${i}]`, daysOfWeek[i]);
}
让我们看看另一个例子。假设我们想找出斐波那契数列的前 20 个数字。斐波那契数列的前两个数字是 1 和 2,每个后续数字都是前两个数字的和:
// Fibonacci: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...
const fibonacci = []; // {1}
fibonacci[1] = 1; // {2}
fibonacci[2] = 1; // {3}
// create the fibonacci sequence starting from the 3rd element
for (let i = 3; i < 20; i++) {
fibonacci[i] = fibonacci[i - 1] + fibonacci[i - 2]; // //{4}
}
// display the fibonacci sequence
for (let i = 1; i < fibonacci.length; i++) { // {5}
console.log(`fibonacci[${i}]`, fibonacci[i]); // {6}
}
下面的代码是对前面代码的解释:
-
在行
{1}
中,我们声明并创建了一个数组。 -
在行
{2}
和{3}
中,我们将斐波那契数列的前两个数字赋给了数组的第二个和第三个位置(在 JavaScript 中,数组的第一个位置始终通过 0(零)引用,由于斐波那契数列中没有零,我们将跳过它)。 -
然后,我们只需要创建序列的第 3 到第 20 个数字(因为我们已经知道了前两个数字)。为此,我们可以使用循环并将数组前两个位置的值之和赋给当前位置(从数组的索引 3 开始到第 19 个索引,行
{4}
)。 -
然后,为了查看输出(行
{6}
),我们只需要从数组的第一个位置循环到其长度(行{5}
)。
我们可以使用
console.log
输出数组的每个索引(行{5}
和{6}
),或者我们也可以使用console.log(fibonacci)
输出数组本身。
如果你想要生成超过 20 个斐波那契数列的数字,只需将数字 20 改为你想要的任何数字。
使用 for..in 循环
使用 for..in
循环的好处是,我们不需要跟踪数组长度,因为循环将遍历所有数组索引。以下代码实现了与前面的 for
循环相同的输出。
for (const i in fibonacci) {
console.log(`fibonacci[${i}]`, fibonacci[i]);
}
这是一种编写循环的另一种方式,你可以使用你感觉最舒服的一种。
使用 for…of 循环
另一种方法,如果你想要直接提取数组的值,可以使用以下 for..of
循环:
for (const value of fibonacci) {
console.log('value', value);
}
使用这个循环,我们不需要访问数组中的每个索引来检索值,因为每个位置上的值可以直接在循环中访问。
添加元素
向数组中添加和删除元素并不那么困难;然而,它可能有点棘手。对于本节中我们将创建的示例,让我们假设我们有一个初始化了从 0 到 9 的数字的数组:
let numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
在数组的末尾插入一个元素
如果我们想向这个数组中添加一个新元素(例如,数字 10),我们只需要引用数组的最后一个空闲位置并将其赋值:
numbers[numbers.length] = 10;
在 JavaScript 中,数组是一个可变对象。我们可以很容易地向其中添加新元素。随着我们添加新元素,对象会动态增长。在许多其他语言中,例如 C 和 Java,我们需要确定数组的大小,如果我们需要向数组中添加更多元素,我们需要创建一个全新的数组;我们不能简单地按需添加新元素。
使用 push 方法
JavaScript API 还有一个名为 push
的方法,允许我们将新元素添加到数组的末尾。我们可以将任意数量的元素作为参数传递给 push
方法:
numbers.push(11);
numbers.push(12, 13);
数字数组的输出将是从 0 到 13 的数字。
在第一个位置插入元素
假设我们需要向数组添加一个新元素(数字 -1
)并希望将其插入到第一个位置,而不是最后一个位置。要做到这一点,首先我们需要通过将所有元素向右移动来释放第一个位置。我们可以遍历数组的所有元素,从最后一个位置(length
的值将是数组的末尾)开始,将前一个元素(i-1
)移动到新位置(i
),最后将我们想要的新值赋给第一个位置(索引 0)。我们可以创建一个函数来表示这个逻辑,甚至可以直接添加一个新方法到 Array 原型中,使得 insertAtBeginning
方法对所有数组实例都可用。以下代码表示了这里描述的逻辑:
Array.prototype.insertAtBeginning = function(value) {
for (let i = this.length; i >= 0; i--) {
this[i] = this[i - 1];
}
this[0] = value;
};
numbers.insertAtBeginning(-1);
我们可以用以下图表来表示这个动作:
图 3.2:
使用 unshift 方法
JavaScript 数组类还有一个名为 unshift
的方法,它将方法参数中传递的值插入到数组的开头(幕后逻辑与 insertAtBeginning
方法的行为相同):
numbers.unshift(-2);
numbers.unshift(-4, -3);
因此,使用 unshift
方法,我们可以将值 -2 然后是 -3 和 -4 添加到 numbers
数组的开头。这个数组的输出将是从 -4 到 13 的数字。
移除元素
到目前为止,你已经学习了如何在数组中添加元素。让我们看看我们如何从数组中移除一个值。
从数组末尾移除元素
要从数组的末尾移除一个值,我们可以使用 pop
方法:
numbers.pop(); // number 13 is removed
pop
方法也会返回被移除的值,如果没有元素被移除(数组为空),则返回 undefined
。因此,如果需要,我们也可以将返回的值捕获到变量或控制台中:
console.log('Removed element: ', numbers.pop());
我们数组的输出将是从 -4 到 12 的数字(移除一个数字后)。我们数组的长度(大小)是 17。
push
和pop
方法允许数组模拟基本的栈
数据结构,这是下一章的主题。
从第一个位置移除元素
要手动从数组的开头移除一个值,我们可以使用以下代码:
for (let i = 0; i < numbers.length; i++) {
numbers[i] = numbers[i + 1];
}
我们可以用以下图表来表示前面的代码:
图 3.3:
我们将所有元素向左移动了一个位置。然而,数组的长度仍然是相同的(16
),这意味着我们仍然在数组中有一个额外的元素(具有 undefined
值)。最后一次代码在循环中执行时,i+1
是一个引用不存在位置的引用。在某些语言中,例如 Java、C/C++ 或 C#,代码将抛出异常,并且我们必须在 numbers.length -1
处结束我们的循环。
我们只是覆盖了数组的原始值,并没有真正删除值(因为数组的长度仍然是相同的,并且我们有一个额外的 undefined
元素)。
要从数组中删除值,我们还可以创建一个 removeFromBeginning
方法,其中包含本主题中描述的逻辑。然而,要真正从数组中删除元素,我们需要创建一个新的数组,并将原始数组中除 undefined
值之外的所有值复制到新数组中,并将新数组赋值给我们的变量。为此,我们还可以创建一个 reIndex
方法,如下所示:
Array.prototype.reIndex = function(myArray) {
const newArray = [];
for(let i = 0; i < myArray.length; i++ ) {
if (myArray[i] !== undefined) {
newArray.push(myArray[i]);
}
}
return newArray;
}
// remove first position manually and reIndex
Array.prototype.removeFromBeginning = function() {
for (let i = 0; i < this.length; i++) {
this[i] = this[i + 1];
}
return this.reIndex(this);
};
numbers = numbers.removeFromBeginning();
前面的代码仅用于教育目的,不应在实际项目中使用。要删除数组中的第一个元素,我们应该始终使用下一节中介绍的
shift
方法。
使用 shift 方法
要从数组的开头删除元素,我们可以使用 shift
方法,如下所示:
numbers.shift();
如果我们在执行前面的代码后认为我们的数组具有从 -4 到 12 的值和 17 的长度,那么数组将包含从 -3 到 12 的值,并且长度为 16。
shift
和unshift
方法允许数组模拟基本的队列
数据结构,这是 第五章,队列和双端队列 的主题。
从特定位置添加和删除元素
到目前为止,我们已经学习了如何在数组的末尾和开头添加元素,我们也学习了如何从数组的开头和末尾删除元素。如果我们还想从数组的任何位置添加或删除元素怎么办?我们如何做到这一点?
我们可以使用 splice
方法通过指定我们想要从其中删除的位置/索引以及我们想要删除多少个元素来从数组中删除一个元素,如下所示:
numbers.splice(5,3);
此代码将从数组的 5
个索引处开始删除三个元素。这意味着 numbers[5]
、numbers[6]
和 numbers[7]
将从 numbers
数组中删除。我们的数组内容将是 -3, -2, -1, 0, 1, 5, 6, 7, 8, 9, 10, 11
和 12
(因为数字 2
、3
和 4
已被删除)。
与 JavaScript 数组和对象一样,我们也可以使用
delete
操作符从数组中删除一个元素,例如,delete numbers[0]
。然而,数组的0
位置将具有undefined
的值,这意味着它将与执行numbers[0] = undefined
相同,并且我们需要重新索引数组。因此,我们应该始终使用splice
、pop
或shift
方法来删除元素。
现在,假设我们想从位置 5 开始将数字 2、3 和 4 插入数组中。我们可以再次使用 splice 方法来完成这个操作:
numbers.splice(5, 0, 2, 3, 4);
方法的第一参数是我们想要从中删除元素或插入元素的位置索引。第二个参数是我们想要删除的元素数量(在这种情况下,我们不想删除任何元素,所以我们将传递值 0(零))。从第三个参数开始,我们有我们想要插入到数组中的值(元素 2、3 和 4)。输出将再次是-3 到 12 的值。
最后,让我们执行以下代码:
numbers.splice(5, 3, 2, 3, 4);
输出将是从-3 到 12 的值。这是因为我们从索引 5 开始删除了三个元素,并且我们也在索引 5 处添加了元素 2、3 和 4。
迭代器方法
JavaScript 也有一些内置方法作为 Array API 的一部分,这些方法在日常编码任务中非常有用。这些方法接受一个回调函数,我们可以使用它来根据需要操作数组中的数据。
让我们看看这些方法。考虑以下数组作为本节示例的基础:
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
使用 forEach 方法迭代
如果我们需要无论什么情况都要完全迭代数组,我们可以使用forEach
函数。它具有与在for
循环中包含函数代码相同的输出,如下所示:
numbers.forEach((value, index) => {
console.log(`numbers[${index}]`, value);
});
大多数时候,我们只对使用数组每个位置的值感兴趣,而不必像前面的例子那样访问每个位置。以下是一个更简洁的例子:
numbers.forEach(value => console.log(value));
根据个人喜好,你可以使用这个方法或传统的for
循环。在性能方面,两种方法都是O(n),即线性时间,因为它将遍历数组的所有值。
使用 every 方法迭代
every
方法迭代数组的每个元素,直到函数返回false
。让我们看看一个例子:
const isBelowSeven = numbers.every(value => value < 7);
console.log('All values are below 7?:', isBelowSeven); // false
该方法将遍历数组中的每个值,直到找到一个等于或大于 7 的值。对于前面的例子,它返回false
,因为我们数组中有 7 这个值。如果我们没有大于或等于 7 的值,变量isBelowSeven
将具有true
的值。
我们可以使用 for 循环重写这个例子,以了解其内部工作原理:
let isBelowSevenForLoop = true;
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] >= 7) {
isBelowSevenForLoop = false;
break;
}
}
console.log('All values are below 7?:', isBelowSevenForLoop);
break
语句将在找到等于或大于 7 的值时停止循环。
通过使用every
方法,我们可以编写更简洁的代码以实现相同的结果。
使用 some 方法迭代
some
方法与every
方法的行为相反。然而,some
方法会迭代数组的每个元素,直到函数返回true
。以下是一个例子:
const isSomeValueBelowSeven = numbers.some(value => value < 7);
console.log('Is any value below 7?:', isSomeValueBelowSeven); // true
在这个例子中,数组的第一个数字是 1,它将立即返回 true,停止代码的执行。
我们可以使用 for 循环for
重写前面的代码,以更好地理解逻辑:
let isSomeValueBelowSevenForLoop = false;
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] < 7) {
isSomeValueBelowSevenForLoop = true;
break;
}
}
搜索数组
JavaScript API 提供了一些不同的方法,我们可以使用它们在数组中搜索或查找元素。尽管我们将学习如何重新创建经典算法来搜索数组中的元素,但在第十五章,搜索和洗牌算法中,了解我们可以使用现有的 API 而不必自己编写代码总是好的。
让我们看看现有的 JavaScript 方法,这些方法允许我们在数组中搜索元素。
使用indexOf
、lastIndexOf
和includes
方法进行搜索
indexOf
、lastIndexOf
和includes
方法有非常相似的语法如下:
-
indexOf(element, fromIndex)
:从索引fromIndex
开始搜索element
,如果元素存在,则返回其索引,否则返回值-1
。 -
includes(element, fromIndex)
:从索引fromIndex
开始搜索element
,如果元素存在,则返回true
,否则返回false
。
如果我们尝试在我们的numbers
数组中搜索一个数字,让我们检查数字 5 是否存在:
console.log('Index of 5:', numbers.indexOf(5)); // 4
console.log('Index of 11:', numbers.indexOf(11)); // -1
console.log('Is 5 included?:', numbers.includes(5)); // true
console.log('Is 11 included?:', numbers.includes(11)); // false
如果我们想要在整个数组中进行搜索,我们可以省略fromIndex
,默认情况下将使用索引 0。
lastIndexOf
也是类似的,但是它会返回找到的最后一个匹配元素的索引。把它想象成从数组的末尾向数组开头搜索:
console.log('Last index of 5:', numbers.lastIndexOf(5)); // 4
console.log('Last index of 11:', numbers.lastIndexOf(11)); // -1
当数组中有重复元素时,此方法很有用。
使用find
、findIndex
和findLastIndex
方法进行搜索
在现实世界的任务中,我们经常处理更复杂的对象。find
和findIndex
方法对于更复杂的场景特别有用,但这并不意味着我们不能在更简单的情况下使用它们。
find
和findIndex
方法都接收一个回调函数,该函数将搜索满足测试函数(回调)中提出的条件的元素。让我们从一个简单的例子开始:假设你想要找到数组中第一个值低于 7 的数字。我们可以使用以下代码:
const firstValueBelowSeven = numbers.find(value => value < 7);
console.log('First value below 7:', firstValueBelowSeven); // 1
我们使用一个回调函数,即箭头函数来测试数组的每个元素(value < 7
),并且第一个返回true
的元素将被返回。这就是为什么输出是1
,因为它是数组的第一个元素。
findIndex
方法类似,但是它将返回元素的索引而不是元素本身:
console.log('Index: ', numbers.findIndex(value => value < 7)); // 0
同样,还有一个findLastIndex
方法,它将返回匹配回调函数的元素的最后一个索引。
console.log('Index of last value below 7:', numbers.findLastIndex(value => value < 7)); // 5
在前面的例子中,返回索引 5 是因为数字 6 是数组中低于 7 的最后一个元素。
现在,让我们检查一个更复杂的例子,更接近现实生活。考虑以下数组,一组书籍:
const books = [
{ id: 1, title: 'The Fellowship of the Ring' },
{ id: 2, title: 'Fourth Wing' },
{ id: 3, title: 'A Court of Thorns and Roses' }
];
如果我们需要查找id
为 2 的书籍,我们可以使用find
方法:
console.log('Book with id 2:', books.find(book => book.id === 2));
它将输出{ id: 2, title: 'Fourth Wing' }
。如果我们尝试找到《霍比特人》这本书,我们将得到输出undefined
,因为这本书不在数组中:
console.log(books.find(book => book.title === 'The Hobbit'));
假设我们想要从数组中移除id
为 3 的书籍。我们首先找到书籍的索引,然后使用splice
方法在给定索引处移除书籍:
const bookIndex = books.findIndex(book => book.id === 3);
if (bookIndex !== -1) {
books.splice(bookIndex, 1);
}
当然,在尝试从列表中移除书籍之前,先检查书籍是否被找到(bookIndex
不等于-1)是一个好习惯,以避免逻辑错误。
过滤元素
让我们再次回顾以下示例:
const firstValueBelowSeven = numbers.find(value => value < 7);
console.log('First value below 7:', firstValueBelowSeven); // 1
find
方法返回第一个符合给定条件的元素。如果我们想了解数组中小于 7 的所有元素怎么办?这时filter
方法就派上用场了:
const valuesBelowSeven = numbers.filter(value => value < 7);
console.log('Values below 7:', valuesBelowSeven); // [1, 2, 3, 4, 5, 6]
filter
方法返回所有匹配元素的数组,输出将是:[1, 2, 3, 4, 5, 6]
。
排序元素
在整本书中,你将学习如何编写最常用的排序算法。然而,JavaScript 也提供了一个排序方法,我们可以在需要排序数组时使用,而无需编写自己的逻辑。
首先,让我们取我们的数字数组并将元素顺序打乱([1, 2, 3, ... 10]
已经是排序好的)。为此,我们可以应用reverse
方法,其中最后一个元素将成为第一个,反之亦然,如下所示:
numbers.reverse();
因此,现在数字数组的输出将是[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
。然后,我们可以应用排序方法,如下所示:
numbers.sort();
然而,如果我们输出数组,结果将是[1, 10, 2, 3, 4, 5, 6, 7, 8, 9]
。这不是正确的顺序。这是因为 JavaScript 中的sort
方法按字典顺序排序元素,并假设所有元素都是字符串。
我们也可以编写自己的比较函数。由于我们的数组包含数字元素,我们可以编写以下代码:
numbers.sort((a, b) => a - b);
如果b
大于a
,此代码将返回一个负数,如果a
大于b
,则返回一个正数,如果它们相等,则返回 0(零)。这意味着如果返回负值,则意味着a
小于b
,这进一步被sort
函数用于排列元素。
之前的代码可以用以下代码表示:
function compareNumbers(a, b) {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
// a must be equal to b
return 0;
}
numbers.sort(compareNumbers);
这是因为 JavaScript 数组类的sort
函数可以接收一个名为compareFunction
的参数,该参数负责排序数组。在我们的例子中,我们声明了一个将负责比较数组元素的函数,结果是一个按升序排序的数组。
自定义排序
我们可以用任何类型的对象来排序数组,我们也可以创建一个compareFunction
来比较所需的元素。例如,假设我们有一个名为 Person 的对象,包含姓名和年龄,我们想根据人的年龄对数组进行排序。我们可以使用以下代码:
const friends = [
{ name: 'Frodo', age: 30 },
{ name: 'Violet', age: 18 },
{ name: 'Aelin', age: 20 }
];
const compareFriends = (friendA, friendB => friendA.age - friendB.age;
friends.sort(compareFriends);
console.log('Sorted friends:', friends);
在这种情况下,之前代码的输出将是 Violet(18),Aelin(20),和 Frodo(30)。
排序字符串
假设我们有以下数组:
let names = ['Ana', 'ana', 'john', 'John'];
console.log(names.sort());
你认为输出会是什么?答案是如下:
["Ana", "John", "ana", "john"]
为什么 ana
在字母表中排在 John
之后,尽管 a
排在前面?答案是 JavaScript 会根据每个字符的 ASCII 值进行比较(www.asciitable.com
)。
例如,A,J,a,和 j 的十进制 ASCII 值分别为 A:65,J:74,a:97,和 j:106。因此,J 的值小于 a,因此它在字母表中排在前面。
现在,如果我们向 sort
方法传递一个函数,该函数包含忽略字母大小写的代码,我们将得到以下输出:["Ana", "ana", "john", "John"
]:
names = ['Ana', 'ana', 'john', 'John']; // reset the array to its original state
names.sort((a, b) => {
const nameA = a.toLowerCase();
const nameB = b.toLowerCase();
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
});
在这种情况下,排序函数将不会有任何效果;它将遵循当前的大小写字母顺序。
如果我们想让排序后的数组中字母小写字母排在前面,那么我们需要使用 localeCompare
方法:
names.sort((a, b) => a.localeCompare(b));
输出将是 ['ana', 'Ana', 'john', 'John']
。
对于带重音的字符,我们也可以使用 localeCompare
方法:
const names2 = ['Maève', 'Maeve'];
console.log(names2.sort((a, b) => a.localeCompare(b)));
输出将是 ['Maeve', 'Maève']
。
转换数组
JavaScript 还支持可以修改数组元素或改变其顺序的方法。到目前为止,我们已经介绍了两种转换方法:reverse
和 sort
。让我们了解其他有用的可以转换数组的方法。
映射数组的值
map
方法是使用 JavaScript 或 TypeScript 进行日常编码任务时最常用的方法之一。让我们看看它的实际应用:
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const squaredNumbers = numbers.map(value => value * value);
console.log('Squared numbers:', squaredNumbers);
假设我们想要找到数组中每个数字的平方。我们可以使用 map
方法转换数组中的每个值,并返回一个包含结果的数组。对于我们的例子,输出将是:[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
。
我们可以使用 for
循环重写前面的代码以实现相同的结果:
const squaredNumbersLoop = [];
for (let i = 0; i < numbers.length; i++) {
squaredNumbersLoop.push(numbers[i] * numbers[i]);
}
这也是为什么 map
方法经常被使用的原因,因为它在我们需要修改数组中所有值时可以节省时间。
将数组拆分并合并为字符串
想象我们有一个逗号分隔的 CSV 文件,包含不同的名称,并且我们想要将这些值添加到数组中进行处理(可能需要通过 API 保存到数据库中)。我们可以使用 String 的 split
方法,它将返回一个包含这些值的数组:
const namesFromCSV = 'Aelin,Gandalf,Violet,Poppy';
const names = namesFromCSV.split(',');
console.log('Names:', names); // ['Aelin', 'Gandalf', 'Violet', 'Poppy']
如果我们不需要使用逗号分隔的文件,而是需要使用分号,我们可以使用 JavaScript 数组的 join
方法来输出一个包含数组值的单个字符串:
const namesCSV = names.join(';');
console.log('Names CSV:', namesCSV); // 'Aelin;Gandalf;Violet;
使用 reduce
方法进行计算
reduce
方法用于从数组中计算出一个值。该方法接收一个带有以下参数的回调函数:accumulator
(计算结果),数组的element
,index
以及数组本身,第二个参数是初始值。通常,索引和数组不太常用,可以省略。让我们看看几个例子。
当我们想要计算总和时,reduce
方法经常被使用。例如,假设我们想知道给定数组中所有数字的总和:
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const sum = numbers.reduce((acc, value) => acc + value, 0); // 55
其中 0
是初始值,acc
是总和。我们可以使用循环重写前面的代码来理解其背后的逻辑:
let sumLoop = 0;
for (let i = 0; i < numbers.length; i++) {
sumLoop += numbers[i];
}
我们也可以使用 reduce
方法在数组中找到最小或最大值:
const scores = [30, 70, 85, 90, 100];
const highestScore = scores.reduce((max, score) => score > max ? score : max, scores[0]); // 100
此外,还有 reduceRight
方法,它将执行相同的逻辑,但是它将从数组的末尾开始迭代。
这些方法
map
、filter
和reduce
是 JavaScript 中 函数式编程 的基础。
其他 JavaScript 数组方法的参考
JavaScript 数组非常有趣,因为它们功能强大,并且比其他语言中的原始数组具有更多可用功能。这意味着我们不需要自己编写基本功能,并且可以利用这些强大的功能。
我们在本章中已经介绍了许多不同的方法。让我们看看其他有用的方法。
使用 isArray 方法
在 JavaScript 中,我们可以使用 typeof
运算符检查变量或对象的类型,如下所示:
console.log(typeof 'Learning Data Structures'); // string
console.log(typeof 123); // number
console.log(typeof { id: 1 }); // object
console.log(typeof [1, 2, 3]); // object
注意,对象 { id: 1}
和数组 [1, 2, 3]
的类型都是 object
。
但如果我们想再次检查类型是否为数组,以便我们可以调用任何特定的数组方法呢?幸运的是,JavaScript 也提供了一个通过 Array.isArray
的方法:
console.log(Array.isArray([1, 2, 3])); // true
这样我们就可以始终检查我们是否收到了未知类型的数据。例如,当在客户端使用 JavaScript 时,我们经常从服务器端的 API 接收 JSON 对象。我们可以将接收到的数据解析成对象,并检查接收到的对象是否为数组,这样我们就可以使用我们学到的方法来查找特定信息:
const jsonString = JSON.stringify('[{"id":1,"title":"The Fellowship of the Ring"},{"id":2,"title":"Fourth Wing"}]');
const dataReceived = JSON.parse(jsonString);
if (Array.isArray(dataReceived)) {
console.log('It is an array');
// check if The Fellowship of the Ring is in the array
const fellowship = dataReceived.find((item) => {
return item.title === 'The Fellowship of the Ring';
});
if (fellowship) {
console.log('We received the book we were looking for!');
} else {
console.log('We did not receive the book we were looking for!');
}
}
这有助于确保我们的代码不会抛出错误,并且在处理我们代码中未创建的数据结构时是一种良好的实践。
使用 from 方法
Array.from
方法从一个现有的数组创建一个新的数组。例如,如果我们想将数组 numbers
复制到一个新的数组中,我们可以使用以下代码:
const numbers = [1, 2, 3, 4, 5];
const numbersCopy = Array.from(numbers);
console.log(numbersCopy); // [1, 2, 3, 4, 5]
也可以传递一个函数,这样我们就可以确定我们想要映射的值。考虑以下代码:
const evens = Array.from(numbers, x => (x % 2 == 0));
console.log(evens); // [false, true, false, true, false]
前面的代码创建了一个名为 evens 的新数组,如果原始数组中的数字是偶数,则值为 true,否则为 false。
重要的是要注意,Array.from()
方法创建了一个新的、浅拷贝。让我们看看另一个例子:
const friends = [
{ name: 'Frodo', age: 30 },
{ name: 'Violet', age: 18 },
{ name: 'Aelin', age: 20 }
];
const friendsCopy = Array.from(friends);
复制完成后,让我们将第一个朋友的名字修改为 Sam:
friends[0].name = 'Sam';
console.log(friendsCopy[0].name); // Sam
被复制的数组的第一个朋友的名字也会更新,所以在使用这个方法时我们必须小心。
如果我们需要复制数组,并且有不同实例的内容,可以通过 JSON 使用一种解决方案:
const friendsDeepCopy = JSON.parse(JSON.stringify(friends));
friends[0].name = 'Frodo';
console.log(friendsDeepCopy[0].name); // Sam
通过将数组的所有内容转换为 JSON 格式的字符串,然后再将此内容解析回数组结构,我们创建全新的数据。然而,根据我们需要实现的目标,还有更健壮的方法来做这件事。
使用 Array.of 方法
Array.of
方法可以从传递给方法的方法参数创建一个新的数组。例如,让我们考虑以下示例:
const numbersArray = Array.of(1, 2, 3, 4, 5);
console.log(numbersArray); // [1, 2, 3, 4, 5]
上述代码等同于执行以下操作:
const numbersArray = [1, 2, 3, 4, 5];
我们还可以使用这个方法来复制现有的数组。以下是一个示例:
let numbersCopy2 = Array.of(...numbersArray);
上述代码等同于使用 Array.from(numbersArray)
。这里的区别在于我们使用了扩展运算符。扩展运算符(...
)将 numbersArray
的每个值展开为参数。
使用 fill
方法
fill
方法用值填充数组。例如,假设一个新的游戏锦标赛即将开始,我们想要将所有结果存储在数组中。随着比赛的结束,我们可以更新每个结果。
const tornamentResults = new Array(5).fill('pending');
tornamentResults
数组的长度为 5,这意味着我们有五个位置。每个位置都初始化为 pending
值。
现在,假设比赛 1 和 2 已经获胜。我们也可以使用 fill
方法通过传递起始位置(包含)和结束位置(不包含)来填充这两个位置:
tornamentResults.fill('win', 1, 3);
console.log(tornamentResults);
// ['pending', 'win', 'win', 'pending', 'pending']
这个方法很有用,因为它提供了一种紧凑的方式来使用单个值初始化数组,并且通常比手动循环填充数组更快(在编写代码所需的时间方面)。
合并多个数组
考虑一个场景,你拥有不同的数组,并且需要将它们全部合并成一个单一的数组。我们可以遍历每个数组,并将每个元素添加到最终的数组中。幸运的是,JavaScript 已经有一个可以为我们完成这个任务的方法,名为 concat
方法,其语法如下:
const zero = 0;
const positiveNumbers = [1, 2, 3];
const negativeNumbers = [-3, -2, -1];
let allNumbers = negativeNumbers.concat(zero, positiveNumbers);
我们可以向这个数组传递任意数量的数组、对象/元素。数组将按照传递给方法的参数顺序连接到指定的数组中。在这个例子中,0 将连接到 negativeNumbers
,然后 positiveNumbers
将连接到结果数组。数字数组的输出将是值 [-3, -2, -1, 0, 1, 2, 3]
。
二维数组
在本章的开头,我们使用了一个温度测量的例子。现在我们将再次使用这个例子。让我们考虑一下,我们需要测量几天内的每小时温度。既然我们已经知道可以使用数组来存储温度,我们可以轻松地编写以下代码来存储两天的温度:
let averageTempDay1 = [72, 75, 79, 79, 81, 81];
let averageTempDay2 = [81, 79, 75, 75, 73, 72];
然而,这不是最佳方法;我们可以做得更好!我们可以使用一个 矩阵(一个二维数组或 数组数组)来存储这些信息,其中每一行将代表一天,每一列将代表温度的小时测量值,如下所示:
let averageTempMultipleDays = [];
averageTempMultipleDays[0] = [72, 75, 79, 79, 81, 81];
averageTempMultipleDays[1] = [81, 79, 75, 75, 73, 73];
JavaScript 只支持一维数组;它不支持矩阵。然而,我们可以使用数组数组实现矩阵或任何多维数组,就像前面的代码一样。同样的代码也可以写成以下形式:
averageTempMultipleDays = [
[72, 75, 79, 79, 81, 81],
[81, 79, 75, 75, 73, 73]
];
或者,如果你更喜欢为每个位置单独分配值,我们也可以将代码重写为以下代码片段:
// day 1
averageTemp[0] = [];
averageTemp[0][0] = 72;
averageTemp[0][1] = 75;
averageTemp[0][2] = 79;
averageTemp[0][3] = 79;
averageTemp[0][4] = 81;
averageTemp[0][5] = 81;
// day 2
averageTemp[1] = [];
averageTemp[1][0] = 81;
averageTemp[1][1] = 79;
averageTemp[1][2] = 75;
averageTemp[1][3] = 75;
averageTemp[1][4] = 73;
averageTemp[1][5] = 73;
我们分别指定了每一天和每个小时的价值。我们也可以将这个二维数组表示为以下图表:
图 3.4:
每一行代表一天,每一列代表一天中每个小时的温度。
另一种可视化二维数组的方法是想象一个 Excel 文件(或 Google Sheets)。我们可以使用二维数组存储任何类型的表格数据,例如棋盘、剧院座位,甚至表示图像,其中数组的每个位置可以存储每个像素的颜色值。
遍历二维数组的元素
如果我们想验证矩阵的输出,我们可以创建一个通用函数来记录其输出:
function printMultidimensionalArray(myArray) {
for (let i = 0; i < myArray.length; i++) {
for (let j = 0; j < myArray[i].length; j++) {
console.log(myArray[i][j]);
}
}
}
我们需要遍历所有行和列。为此,我们需要使用嵌套的 for
循环,其中变量 i
代表行,j
代表列。在这种情况下,每个 myMatrix[i]
也代表一个数组,因此我们还需要在嵌套的 for
循环中迭代 myMatrix[i]
的每个位置。
我们可以使用以下代码输出 averageTemp
矩阵的内容:
printMatrix(averageTemp);
我们还可以使用
console.table(averageTemp)
语句输出一个二维数组。这将提供更友好的用户输出,显示表格数据格式。
多维数组
我们还可以在 JavaScript 中处理多维数组。例如,假设我们需要存储多个地点和多个天气条件下的多天的平均温度。我们可以使用一个三维矩阵来做到这一点:
-
维度 1 (
i
): 每天的时间 -
维度 2 (
j
): 地点 -
维度 3 (
z
): 温度
假设我们只存储过去 3 天,3 个不同地点和 3 种不同的天气条件。我们可以用一个立方图表示一个 3x3x3 矩阵,如下所示:
图 3.5:
我们可以表示一个 3x3 矩阵,如下所示:
let averageTempMultipleDaysAndLocation = [];
// day 1
averageTempMultipleDaysAndLocation[0] = [];
averageTempMultipleDaysAndLocation[0][0] = [19, 20, 21]; // location 1
averageTempMultipleDaysAndLocation[0][1] = [20, 22, 23]; // location 2
averageTempMultipleDaysAndLocation[0][2] = [30, 31, 32]; // location 3
// day 2
averageTempMultipleDaysAndLocation[1] = [];
averageTempMultipleDaysAndLocation[1][0] = [21, 22, 23]; // location 1
averageTempMultipleDaysAndLocation[1][1] = [22, 23, 24]; // location 2
averageTempMultipleDaysAndLocation[1][2] = [29, 30, 30]; // location 3
// day 3
averageTempMultipleDaysAndLocation[2] = [];
averageTempMultipleDaysAndLocation[2][0] = [22, 23, 24]; // location 1
averageTempMultipleDaysAndLocation[2][1] = [23, 24, 23]; // location 2
averageTempMultipleDaysAndLocation[2][2] = [30, 31, 31]; // location 3
而且,如果我们想输出这个矩阵的内容,我们需要迭代每个维度(i
、j
和 z
):
function printMultidimensionalArray3D(myArray) {
for (let i = 0; i < myArray.length; i++) {
for (let j = 0; j < myArray[i].length; j++) {
for (let z = 0; z < myArray[i][j].length; z++) {
console.log(myArray[i][j][z]);
}
}
}
}
在性能方面,前面的代码是 O(nˆ3),即立方时间,因为我们有三个嵌套循环。
我们可以使用三维矩阵来表示医学图像,例如 MRI 扫描,这是一系列身体的二维图像幻灯片。每个幻灯片是一个像素网格,将这些幻灯片组合起来,我们就有了身体扫描区域的 3D 表示。另一种用途是可视化 3D 打印机的模型,甚至是视频数据(每一帧都是一个二维像素数组,第三个维度是时间)。
如果我们有一个 3x3x3x3 矩阵,我们的代码中就会有四个嵌套的 for
循环,依此类推。作为开发者,你很少需要四维数组,因为它有非常专业的用途,例如交通模式分析。二维数组在开发者日常活动中最为常见,他们将在大多数项目中使用。
TypedArray 类
我们可以在 JavaScript 数组中存储任何数据类型。这是因为 JavaScript 数组不像 C 和 Java 等其他语言那样是强类型。
TypedArray 的创建是为了让我们能够处理具有单一数据类型的数组。其语法是 let myArray = new TypedArray(length)
,其中 TypedArray
需要替换为以下表中定义的特定类:
TypedArray | 描述 |
---|---|
Int8Array |
8 位二进制补码有符号整数 |
Uint8Array |
8 位无符号整数 |
Uint8ClampedArray |
8 位无符号整数 |
Int16Array |
16 位二进制补码有符号整数 |
Uint16Array |
16 位无符号整数 |
Int32Array |
32 位二进制补码有符号整数 |
Uint32Array |
32 位无符号整数 |
Float32Array |
32 位 IEEE 浮点数 |
Float64Array |
64 位 IEEE 浮点数 |
BigInt64Array |
64 位大整数 |
BigUint64Array |
64 位无符号大整数 |
表 3.1:
以下是一个示例:
const arrayLength = 5;
const int16 = new Int16Array(arrayLength);
for (let i = 0; i < arrayLength; i++) {
int16[i] = i + 1;
}
console.log(int16);
Typed 数组非常适合与 WebGL API 一起工作,操作位,以及操作文件、图像和音频。Typed 数组的工作方式与简单数组完全相同,我们还可以使用本章中学到的相同方法和功能。
使用 TypedArray
的一个实际例子是在使用 TensorFlow (www.tensorflow.org
) 时,这是一个用于创建 机器学习 模型的库。TensorFlow 有 张量 的概念,它是 TensorFlow.js 的核心数据结构。它内部使用 TypedArrays
来表示张量数据。这有助于提高库的效率和性能,尤其是在处理大型数据集或复杂模型时。
TypeScript 中的数组
本章中所有源代码都是有效的 TypeScript 代码。区别在于 TypeScript 会在编译时进行类型检查,以确保我们只操作所有值具有相同数据类型的数组。
让我们回顾一下本章前面提到的先前示例:
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
由于类型推断,TypeScript 能够理解数字数组的声明与 const numbers: number[]
相同。因此,如果我们初始化变量时,我们不需要总是显式声明变量的类型。
如果我们回到 friends
数组的排序示例,我们可以将 TypeScript 中的代码重构如下:
interface Friend {
name: string;
age: number;
}
const friends = [
{ name: 'Frodo', age: 30 },
{ name: 'Violet', age: 18 },
{ name: 'Aelin', age: 20 }
];
const compareFriends = (friendA: Friend, friendB: Friend) => {
return friendA.age - friendB.age;
};
friends.sort(compareFriends);
通过声明 Friend
接口,我们确保 compareFriend
函数只接收具有 name
和 age
属性的对象。朋友数组没有显式的类型,因此在这种情况下,如果我们想的话,我们可以显式地使用 const friends: Friend[]
声明其类型。
总结来说,如果我们想使用 TypeScript 来编写 JavaScript 变量,我们只需使用 const
或 let variableName: <type>[]
,或者在具有 .js
扩展名的文件中,我们也可以通过在 JavaScript 文件的第 一行添加注释 // @ts-check
来进行类型检查。
在运行时,输出将与我们使用纯 JavaScript 时完全相同。
使用数组创建简单的 TODO 列表
数组是通用中最常用的数据结构之一,无论我们使用的是 JavaScript、.NET、Java、Python 还是任何其他语言。这也是大多数语言都有对这种数据结构原生支持的原因之一,JavaScript 对 Array
类提供了出色的 API(应用程序编程接口)。
每当我们访问数据库时,我们都会得到一个记录集合,我们可以使用数组来管理从数据库检索到的信息。如果我们在前端使用 JavaScript,并且调用服务器 API,我们通常将以 JSON(JavaScript 对象表示法)格式返回一个记录集合,我们可以将 JSON 解析成数组,以便按需管理和操作数据,这样我们就可以在屏幕上显示给用户。
让我们看看一个简单的 HTML 页面示例,使用 JavaScript 我们可以创建任务、完成任务和删除任务。当然,我们将使用数组来管理我们的 TODO 列表:
<!-- Path: src/03-array/10-todo-list-example.html -->
<!DOCTYPE html>
<html>
<head>
<title>Simple TODO List (Array-Based)</title>
</head>
<body>
<h1>My TODO List</h1>
<input type="text" id="newTaskInput" placeholder="Add new task">
<button onclick="addTask()">Add</button>
<ul id="taskList"></ul>
<script>
const taskList = document.getElementById('taskList');
const newTaskInput = document.getElementById('newTaskInput');
let tasks = []; // Initialize empty task array
function addTask() {}
function renderTasks() {}
function toggleComplete(index) {}
function removeTask(index) {}
</script>
</body>
</html>
这段 HTML 代码将帮助我们渲染一个基本的 TODO 应用程序。一旦我们完成这个页面的开发,并在浏览器中打开它,我们将得到以下应用程序:
图 3.6:
让我们检查用于渲染任务项目符号列表的代码:
function addTask() {
const taskText = newTaskInput.value.trim(); // {1}
if (taskText !== "") {
tasks.push({ text: taskText, completed: false }); // {2}
renderTasks(); // Update the displayed list
newTaskInput.value = ""; // Clear input
}
}
当我们点击 添加 按钮,将调用 addTask
函数。我们将使用 trim
方法去除文本开头和结尾的所有额外空格({1}
),如果文本不为空,我们将以包含文本和表示任务未完成的对象格式将其添加到我们的数组({2}
)中。然后我们将调用 renderTasks
函数,并清空输入,以便我们可以输入更多任务。
接下来,让我们看看 renderTasks
函数:
function renderTasks() {
taskList.innerHTML = ''; // Clear the list
tasks.forEach((task, index) => { // {3}
const listItem = document.createElement("li");
listItem.innerHTML = `
<input type="checkbox" ${task.completed ? "checked" : ""}
onchange="toggleComplete(${index})">
<span class="${task.completed ? "completed" : ""}">
${task.text}</span>
<button onclick="removeTask(${index})">Delete</button>
`;
taskList.appendChild(listItem);
});
}
每次我们添加一个新任务或删除一个任务时,我们都会调用这个 renderTasks
函数。首先,我们将通过在屏幕上渲染一个空格来清除列表,然后,对于数组中的每个任务({3}
),我们将在 HTML 列表中创建一个元素,该元素包含一个复选框,如果任务已完成则被选中,任务文本以及一个按钮,我们可以使用它来删除任务,传递数组中任务的索引。
最后,让我们检查 toggleComplete
函数(在勾选或取消勾选复选框时调用)和 removeTask
函数:
function toggleComplete(index) { // Toggle task completion status
tasks[index].completed = !tasks[index].completed; // {4}
renderTasks();
}
function removeTask(index) {
tasks.splice(index, 1); // {5} Remove from array
renderTasks();
}
两个函数都接收数组的index
作为参数,因此我们可以轻松访问正在切换或删除的任务。对于切换,我们可以直接访问数组位置并标记任务为完成或不完成({4}
),要删除任务,我们可以使用本章学到的splice
方法从数组中删除任务({5}
),当然,每次我们进行更改时,我们都会重新渲染任务。
数组无处不在,因此掌握这种数据结构的重要性不言而喻。
练习
我们将使用本章学到的概念解决Hackerrank的一些数组练习。
反转数组
我们将解决的第一个问题是可用的反转数组问题,链接为www.hackerrank.com/challenges/arrays-ds/problem
。
当使用 JavaScript 或 TypeScript 解决问题时,我们需要在函数reverseArray(a: number[]): number[] {}
中添加我们的逻辑,该函数接收一个数字数组并期望返回一个数字数组。
给定的示例输入是[1,4,3,2]
,预期的输出是[2,3,4,1]
。
我们需要实现的逻辑是反转数组,这意味着第一个元素将成为最后一个元素,依此类推。
最直接的方法是使用现有的reverse
方法:
function reverseArray(a: number[]): number[] {
return a.reverse();
}
这是一个通过所有测试并解决问题的解决方案。然而,如果这个练习在技术面试中使用,面试官可能要求你尝试一个不同的解决方案,该方案不包括使用现有的reverse
方法,这样他们就可以评估你的思考方式和沟通解决方案的能力。
第二种解决方案是手动编写代码来反转数组。
function reverseArray2(a: number[]): number[] {
const result = [];
for (let i = a.length - 1; i >= 0; i--) {
result.push(a[i]);
}
return result;
}
我们将创建一个新的数组,我们将从数组的末尾开始遍历给定的数组(因为我们必须反转它),直到我们达到第一个索引,即 0。然后,对于每个元素,我们将将其(push
)到新数组中,并可以返回result
。这个解决方案是O(n),因为我们需要遍历数组的长度。
说到大 O 表示法,正如你可能已经注意到的,我们经常需要遍历一个数组。遍历数组是线性时间,直接访问元素是O(1),因为我们可以通过访问其索引来访问数组的任何位置。
如果你更喜欢从数组的开始遍历到其最后一个位置,我们可以使用unshift
方法:
function reverseArray3(a: number[]): number[] {
const result = [];
for (let i = 0; i < a.length; i++) {
result.unshift(a[i]);
}
return result;
}
然而,这是最差的一种解决方案。考虑到我们需要遍历数组,我们谈论的是O(n)复杂度。unshift
方法也有O(n)复杂度,因为它需要移动数组中已经存在的所有元素,这使得这个解决方案的复杂度达到O(n²),即二次时间复杂度。
你能想到一个不需要遍历整个数组的解决方案吗?如果我们只遍历数组的一半并交换元素,意味着我们交换第一个元素与最后一个元素,第二个元素与倒数第二个元素,依此类推:
function reverseArray4(a: number[]): number[] {
for (let i = 0; i < a.length / 2; i++) {
const temp = a[i];
a[i] = a[a.length - 1 - i];
a[a.length - 1 - i] = temp;
}
return a;
}
这个函数的循环将大约运行 n/2 次,其中 n
是数组的长度。在 Big O 表示法中,这仍然是一个复杂度为 O(n) 的算法,因为我们忽略了常数因子和低阶项,然而,n/2 比更优,所以这个最后的解决方案可能稍微快一些。
数组左旋转
我们将要解决的下一个练习是位于 https://www.hackerrank.com/challenges/array-left-rotation/problem 的数组左旋转问题。
当使用 JavaScript 或 TypeScript 解决问题时,我们将在函数 function rotLeft(a: number[], d: number): number[] {}
内部添加我们的逻辑,该函数接收一个数字数组,一个数字 d
,表示左旋转的次数,并且它还期望返回一个数字数组。
给定的示例输入是 [1,2,3,4,5]
,d
是 2,预期的输出是 [3,4,5,1,2]
。
我们需要实现的逻辑是将数组的第一个元素移除并添加到数组的末尾,重复此操作 d
次。
让我们检查第一个解决方案:
function rotLeft(a: number[], d: number): number[] {
return a.concat(a.splice(0, d));
}
我们通过使用现有的 JavaScript 方法,通过 splice
方法移除需要旋转的元素数量,来使用现有的 JavaScript 方法。然后,我们将原始数组与移除的元素数组连接起来。基本上,我们将原始数组分成两个数组并交换它们的顺序。
这个解决方案的复杂度是 O(n),因为:
-
a.splice(0, d)
: 这个操作的时间复杂度为 O(n),因为它需要在移除前d
个元素后,将数组中剩余的所有元素进行移动。 -
a.concat()
: 这个操作的时间复杂度也是 O(n),因为它需要遍历两个数组(原始数组和被截取的数组)的所有元素以创建一个新数组。
由于这些操作是按顺序执行的(不是嵌套的),时间复杂度会累加,导致总的时间复杂度为 O(n + n) = O(2n)。然而,在 Big O 表示法中,我们忽略常数项,所以最终的时间复杂度是 O(n)。
另一个类似的解决方案如下:
function rotLeft2(a: number[], d: number): number[] {
return [...a.slice(d), ...a.slice(0, d)];
}
我们从索引 d
的元素开始创建一个新数组(a.slice(d)
),并通过移除我们被要求旋转的元素数量(a.slice(0, d)
)来创建一个新数组。扩展运算符(…
)用于展开两个新数组的元素,当它被方括号包围时,我们创建一个新数组。
让我们回顾这个解决方案的复杂度,它也是 O(n):
-
a.slice(d)
: 这个操作的时间复杂度为 O(n - d),因为它需要创建一个包含从索引d
到数组末尾的元素的新数组。 -
a.slice(0, d)
: 这个操作的时间复杂度为 O(d),因为它需要创建一个包含数组前d
个元素的新数组。 -
扩展运算符(
...
):这个操作的时间复杂度为 O(n),因为它需要遍历两个数组中的所有元素以创建一个新数组。
由于这些操作是顺序执行的(而不是嵌套的),时间复杂度会累加,导致总时间复杂度为 O((n - d) + d + n) = O(2n)。因此,最终的时间复杂度是 O(n)。
再次强调,在面试过程中,我们可能会被要求实现一个手动解决方案,所以让我们回顾第三种可能的解决方案:
function rotLeft3(a: number[], d: number): number[] {
for (let i = 0; i < d; i++) {
const temp = a[0]; // {1}
for (let j = 0; j < a.length - 1; j++) {
a[j] = a[j + 1]; // {2}
}
a[a.length - 1] = temp; // {3}
}
return a;
}
外层循环将运行 d
次,因为我们需要旋转元素。对于每个需要旋转的元素,我们将将其保存在一个临时变量中({1}
)。然后,我们将遍历数组,将下一个位置的元素移动到当前索引位置({2}
)。最后,我们将之前保存在临时变量中的元素移动到数组的最后一个位置({3}
)。这与我们创建的从第一个位置删除元素算法非常相似。这里的区别是我们不是从数组的开始删除元素并丢弃它,而是将其移动到数组的末尾。
这个解决方案的时间复杂度是 O(nd)*。前面提出的解决方案可能更快,因为它们是 O(n)。
摘要
在本章中,我们介绍了最常用的数据结构:数组。我们学习了如何声明、初始化和赋值,以及如何添加和删除元素。我们还了解了二维和多维数组,以及数组的主要方法,这些方法在我们开始创建自己的算法时将特别有用。
我们还学习了如何通过使用 TypeScript 或 TypeScript 对 JavaScript 文件的编译时检查能力来确保数组只包含相同类型的值。
最后,我们解决了一些可能成为技术面试主题的练习,并回顾了它们的复杂度。
在下一章中,我们将学习栈,它可以被视为具有特殊行为的数组。
第五章:4 栈
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“学习 JavaScript 数据结构与算法 4e”频道下找到“EARLY ACCESS SUBSCRIPTION”)。
在上一章中,我们学习了如何创建和使用数组,这是计算机科学中最常见的类型之一。正如我们所学的,我们可以在任何所需的索引处向数组中添加或移除元素。然而,有时我们需要某种形式的数据结构,使我们能够更多地控制添加和移除项目。有两种数据结构与数组有一些相似之处,但它们使我们能够更多地控制元素的添加和移除。这些数据结构是栈和队列。
在本章中,我们将涵盖以下主题:
-
栈数据结构
-
向栈中添加元素
-
从栈中弹出元素
-
如何使用 Stack 类
-
我们可以使用栈数据结构解决的问题
栈数据结构
想象你在自助餐厅或食品广场有一堆托盘,或者像以下图像中那样有一堆书籍:
图 4.1:关于编程语言和框架的书籍堆
现在假设你需要向这堆书中添加一本新书。标准做法是将新书简单地放在书籍堆的顶部。如果你需要将书籍放回书架,你会拿起堆顶的书籍,放好,然后取走堆顶的下一本书,直到所有书籍都被存放好。从书籍堆中添加或移除书籍的行为遵循栈数据结构的相同原则。
栈是有序元素集合,遵循后进先出(LIFO)原则。新元素的添加或现有元素的移除发生在同一端。栈的末端被称为顶部,起始端被称为底部。最新元素靠近顶部,最旧元素靠近底部。
栈在编程语言中的编译器中被使用,用于在计算机内存中存储变量和方法调用,以及浏览器历史记录(浏览器的后退按钮)。
另一个栈数据结构的真实世界例子是文本编辑器(如 Microsoft Word 或 Google 文档)中的撤销功能,如下面的图像所示:
图 4.2:Microsoft Word 软件中撤销风格功能的图像
在此示例中,我们有一个由 Microsoft Word 内部使用的栈:撤销样式功能,其中所有在文档中执行的操作都被堆叠起来,我们可以通过点击撤销样式按钮多次来撤销任何操作,直到操作栈为空。
让我们通过使用 JavaScript 和 TypeScript 创建自己的栈类来将这些概念付诸实践。
创建基于数组的栈类
我们将创建自己的类来表示栈。本章的源代码可在 GitHub 上的src/04-stack
文件夹中找到。
我们将首先创建stack.js
文件,该文件将包含我们使用基于数组的策略表示栈的类。
首先,我们将声明我们的Stack
类:
class Stack {
#items = []; // {1}
// other methods
}
我们需要一个数据结构来存储栈的元素。由于我们已经熟悉数组数据结构({1}
),我们可以使用数组来完成此操作。此外,注意变量items
的前缀:我们使用了一个哈希#
前缀。这意味着#items
属性只能在Stack
类内部引用。这将允许我们保护这个私有数组,因为数组数据结构允许我们在数据结构的任何位置添加或删除元素。由于栈遵循 LIFO 原则,我们将限制可用于插入和删除元素的功能。
Stack
类中将提供以下方法:
-
push(item)
: 此方法将新项目添加到栈的顶部。 -
pop()
: 此方法从栈中移除顶部元素。它还返回被移除的元素。 -
peek()
: 此方法返回栈的顶部元素。栈不会被修改(它不会删除元素;它只返回元素以供信息用途)。 -
isEmpty()
: 此方法返回true
,如果栈不包含任何元素,如果栈的大小大于 0,则返回false
。 -
clear()
: 此方法移除栈中的所有元素。 -
size()
: 此方法返回栈包含的元素数量。它与数组的length
属性类似。
我们将在以下章节中为每个方法编写代码。
将元素推送到栈顶
我们将要实现的第一种方法是push
方法。此方法负责向栈中添加新元素,有一个非常重要的细节:我们只能将新项目添加到栈顶,即数组的末尾(内部)。push
方法表示如下:
push(item) {
this.#items.push(item);
}
由于我们使用数组来存储栈的元素,我们可以使用上一章中介绍的 JavaScript Array
类的push
方法。
从栈中弹出元素
接下来,我们将实现 pop
方法。此方法负责从堆栈中移除项目。由于堆栈使用 LIFO 原则,我们将移除最后添加的项目。因此,我们可以使用 JavaScript Array
类中的 pop
方法,我们在上一章中也介绍了这个方法。Stack.pop
方法表示如下:
pop() {
return this.#items.pop();
}
如果堆栈为空,此方法将返回值 undefined
。
由于 push
和 pop
方法是唯一可用于向堆栈添加和移除项目的功能,LIFO 原则将适用于我们的 Stack
类。
查看堆栈顶部的元素
接下来,我们将在我们的类中实现额外的辅助方法。如果我们想知道最后添加到我们的堆栈中的元素是什么,我们可以使用 peek
方法。此方法将返回堆栈顶部的项目:
peek() {
return this.#items[this.#items.length - 1];
}
由于我们使用数组来内部存储项目,我们可以使用 length - 1
来从数组中获取最后一个项目,如下所示:
图 4.3:模拟文本编辑器撤销功能的四个打字动作的堆栈。
假设我们正在模拟文本编辑器的撤销功能。我们输入 "stack 的顶部"。我们正在开发的功能将分别堆叠每个单词。因此,我们将得到一个包含四个项目的堆栈;因此,内部数组的长度是 4。在内部数组中使用的最后一个位置是 3。因此,length - 1
(4 - 1)是 3。
因此,如果我们查看堆栈的顶部,我们将得到以下结果:{ action: 'typing', text: 'stack' }
。
验证堆栈是否为空及其大小
我们将要创建的下一个方法是 isEmpty
方法,它返回 true
如果堆栈为空(没有添加元素),否则返回 false
:
isEmpty() {
return this.#items.length === 0;
}
使用 isEmpty
方法,我们可以简单地验证内部数组的长度是否为 0。
就像从数组类继承的 length
属性一样,我们也可以为我们的 Stack
类添加一个获取长度的 getter。对于集合,我们通常使用术语 size
而不是 length
。同样,由于我们使用数组来内部存储元素,我们可以简单地返回其 length
:
get size() {
return this.#items.length;
}
在 JavaScript 中,我们可以利用 getter 来高效地跟踪我们的堆栈数据结构的大小。Getter 提供了更简洁的语法,允许我们像访问属性一样检索大小(myStack.size
),而不是调用像 myStack.size()
这样的方法。这增强了代码的可读性和可维护性。
清除堆栈的元素
最后,我们将实现 clear
方法。clear
方法简单地清空堆栈,移除所有元素。实现此方法的最简单方式是直接将内部数组重置为空数组,如下所示:
clear() {
this.#items = [];
}
另一种实现方式是调用 pop
方法直到堆栈为空:
clear2() {
while (!this.isEmpty()) {
this.pop();
}
}
然而,在大多数情况下,JavaScript 中第一种实现被认为是更好的,因为它更高效。通过直接将内部数组重置为空数组,操作通常是常数时间(O(1)),无论栈的大小如何。第二种方法(clear2()
)遍历栈并逐个弹出每个元素,时间复杂度是线性的(O(n)),其中 n
是栈中元素的数量——这意味着随着栈的增长,这个操作会变慢。从内存使用角度来看,对于第一种方法,虽然创建一个新的空数组看起来会使用更多内存,但 JavaScript 引擎通常会优化这个操作,尽可能重用内存。
在极少数情况下,如果栈非常大,并且对 clear()
的内存使用有顾虑,那么由于它的增量方法,clear2()
可能会稍微好一些。然而,这是一个边缘情况,在大多数实际场景中,效率差异可能微乎其微。此外,对于 clear()
方法,一些开发者可能会争论,在最坏的情况下,它实际上是 O(n),因为垃圾回收。
将 Stack 数据结构作为库类导出
我们创建了一个名为 src/04-stack/stack.js
的文件,其中包含我们的 Stack 类。我们希望在不同的文件中使用 Stack 类以方便我们代码的维护性(src/04-stack/01-using-stack-class.js
)。我们如何实现这一点?
根据你工作的环境,有不同的方法。
我们将要学习的第一种方法是 CommonJS 模块(module.exports
)。这是在 Node.js 中导出模块的传统方式:
// stack.js
class Stack {
// our Stack class implementation
}
module.exports = Stack;
最后一行将暴露我们的类,这样我们就可以在不同的文件中使用它,如下所示:
// 01-using-stack-class.js
const Stack = require('./stack');
const myStack = new Stack();
在本书中,我们将使用 CommonJS 模块方法,因为我们使用以下命令来查看我们代码的输出:
node src/04-stack/01-using-stack-class.js
然而,如果你想在前端使用代码,我们可以使用 ECMAScript 模块(export default
)如下所示:
// stack.js
export default class Stack {
// our Stack class implementation
}
并且要在不同的文件中使用它:
import Stack from './stack.js';
const myStack = new Stack();
我们还可以在前端使用的一种第三种方法是 命名导出,它允许我们从模块中导出多个项目:
export class Stack {
// our Stack class implementation
}
并且要在不同的文件中使用它:
import { Stack } from './stack';
const myStack = new Stack();
虽然我们将使用 Node.js 方法,但了解其他方法是有用的,这样我们就可以根据不同的环境调整我们的代码。
使用 Stack 类
测试 Stack 类的时间到了!如前一小节所述,让我们继续创建一个单独的文件,这样我们就可以编写尽可能多的测试:src/04-stack/01-using-stack-class.js
。
我们需要做的第一件事是从 stack.js 文件中导入代码并实例化我们刚刚创建的 Stack 类:
const Stack = require('./stack');
const stack = new Stack();
接下来,我们可以验证它是否为空(输出 is true
,因为我们还没有向我们的栈中添加任何元素):
console.log(stack.isEmpty()); // true
接下来,让我们模拟文本编辑器的撤销功能。假设我们的文本编辑器将存储动作(如输入),以及正在输入的文本。每个按键都会被存储为一个动作。
例如,让我们输入 Stack。在每次按键后,我们将 action
和 text
作为对象推入栈中。我们将从 "St" 开始。
stack.push({action: 'typing', text: 'S'});
stack.push({action: 'typing', text: 't'});
如果我们调用 peek
方法,它将返回文本为 t
的对象,因为它是最后添加到栈中的元素:
console.log(stack.peek()); // { action: 'typing', text: 't' }
让我们也检查栈的大小:
console.log(stack.size); // 2
现在,让我们再输入几个字符:“ack”。这将把另外三个字符推入栈中:
stack.push({action: 'typing', text: 'a'});
stack.push({action: 'typing', text: 'c'});
stack.push({action: 'typing', text: 'k'});
如果我们检查大小并检查栈是否为空:
console.log(stack.size); // 5
console.log(stack.isEmpty()); // false
以下图表显示了迄今为止执行的所有 push 操作以及我们的栈的状态:
图 4.4:通过输入 Stack 在栈中进行的 push 操作
接下来,让我们通过从栈中移除两个元素来撤销最后两个动作:
stack.pop();
stack.pop();
在我们两次调用 pop
方法之前,我们的栈中有五个元素。经过两次 pop
方法的执行后,栈现在只剩三个元素。我们可以通过输出大小和查看栈顶来检查:
console.log(stack.size); // 3
console.log(stack.peek()); // { action: 'typing', text: 'a' }
以下图表展示了 pop
方法的执行过程:
图 4.5:通过弹出两个元素在栈中进行的 pop 操作
通过创建 toString 方法增强栈
如果我们尝试执行以下代码:
console.log(stack);
我们将得到以下输出:
[object Object],[object Object],[object Object]
这一点也不友好,我们可以通过在 Stack
类中创建一个 toString
方法来增强输出:
toString() {
if (this.isEmpty()) {
return "Empty Stack";
} else {
return this.#items.map(item => { // {1}
if (typeof item === 'object' && item !== null) { // {2}
return JSON.stringify(item); // Handle objects
} else {
return item.toString(); // Handle other types {3}
}
}).join(', '); // {4}
}
}
如果栈为空,我们可以返回一条消息或简单地返回 []
(根据您的喜好)。由于我们使用基于数组的栈,我们可以利用 map
方法迭代并转换我们的栈中的每个元素({1}
)。对于每个项目或元素,我们可以检查项目是否是对象({2}
),并为用户友好的输出输出对象的 JSON 版本。否则,我们可以使用项目的自身 toString
方法({3}
)。并且为了分隔栈中的每个元素,我们可以使用逗号和空格({4}
)。
审查我们的栈类的效率
由于栈类是我们从头开始创建的第一个数据结构,让我们通过回顾 Big O 符号来审查每个方法的效率,以时间执行为标准:
方法 | 复杂度 | 说明 |
---|---|---|
push(item) |
O(1) | 将项目添加到数组的末尾通常是常数时间。 |
pop() |
O(1) | 从数组中移除最后一个项目通常是常数时间。 |
isEmpty() |
O(1) | 检查数组的长度是一个常数时间的操作。 |
get size() |
O(1) | 访问数组的 length 属性是常数时间。 |
clear() |
O(1) | 分配一个新的空数组通常被认为是常数时间,尽管一些开发者可能会因为垃圾回收器在最坏情况下的表现而将其视为 O(n)。 |
toString |
O(n) | 遍历栈中的每个元素需要线性时间。 |
表 4.1:
在基于数组的实现中,由于数组需要调整大小,push()
等操作有时可能会更长。然而,平均而言,时间复杂度在多次操作中仍然倾向于 O(1)。JavaScript 数组不像某些其他语言中的数组那样是固定大小的。它们是动态的,这意味着它们可以根据需要增长或缩小。内部,它们通常实现为动态数组或哈希表。
JavaScript 遵循 ECMAScript 标准,每个浏览器或引擎可能有它自己的实现,这意味着 Node.js、Chrome、Firefox 或 Edge 中的数组 push
方法可能有不同的内部源代码。无论实现如何,契约或功能都将相同,这意味着 push
方法将向数组的末尾添加一个新元素,即使不同的引擎在如何实现它方面有不同的方法。
对于动态数组方法,当你将一个元素推入数组时,如果数组没有更多空间,可能需要分配一个更大的内存块,将现有元素复制过去,然后添加新元素。这种调整大小可能是一个昂贵的操作,需要 O(n) 的时间(其中 n 是元素的数量)。在一些 JavaScript 引擎中,数组可能内部使用哈希表以实现更快的访问:push
和 pop
操作通常仍然是 O(1)
,但具体细节可能因实现而异。我们将在 第八章,字典和哈希表 中了解更多关于哈希表的内容。
创建基于 JavaScript 对象的 Stack
类
创建 Stack
类的最简单方法就是使用数组来存储其元素。当处理大量数据(这在现实世界的项目中相当常见)时,我们还需要分析最有效率的操作数据的方式。
当我们回顾基于数组的 Stack
类的效率时,我们了解到一些 JavaScript 引擎可能使用哈希表来实现数组。我们还没有学习哈希表,但我们可以使用 JavaScript 对象来存储栈元素,并通过这样做,我们可以直接访问任何元素,时间复杂度为 O(1)。当然,也要遵守 LIFO 原则。让我们看看我们如何实现这种行为。
我们将首先声明 Stack
类(src/04-stack/stack-object.js
文件)如下:
class Stack {
#items = {}; // {1}
#count = 0; // {2}
// other methods
}
对于这个版本的 Stack
类,我们将使用一个 JavaScript 空对象而不是空数组 ({1}
) 来存储数据,并使用一个 count
属性来帮助我们跟踪栈的大小(并且,相应地,也有助于我们在添加和删除元素时)。
向栈中推入元素
我们将声明第一个方法,用于向栈顶添加元素:
push(item) {
this.#items[this.#count] = item;
this.#count++;
}
在 JavaScript 中,一个对象是一组 键 和 值 对。要将一个 item
添加到栈中,我们将使用 count
变量作为 items
对象的键,而 item
将是其值。在将元素推入栈后,我们将增加 count
。
在 JavaScript 中,我们有两种主要方式来为对象中的特定键赋值:
-
点表示法:
this.#items.1 = item
。当我们事先知道键名时,这是最常见且简洁的赋值方式。 -
括号表示法:
this.#items[this.#count] = item
。括号表示法提供了更多的灵活性,因为可以使用变量或表达式来确定键名。在处理动态键时,这种表示法是必不可少的,正如我们在这个场景中所做的那样。
我们可以使用之前的相同示例来使用 Stack
类并输入 "St":
// src/04-stack/02-using-stack-object-class.js
const Stack = require('./stack-object');
const stack = new Stack();
stack.push({action: 'typing', text: 'S'});
stack.push({action: 'typing', text: 't'});
在内部,我们将在 items
和 count
私有属性中拥有以下值:
#items = {
0: { action: 'typing', text: 'S' },
1: { action: 'typing', text: 't' }}
};
#count = 2;
验证栈是否为空及其大小
#count
属性也作为栈的大小。因此,对于 size
获取器,我们可以简单地返回 #count
属性:
get size() {
return this.#count;
}
要验证栈是否为空,我们可以比较 #count
值是否为 0
,如下所示:
isEmpty() {
return this.#count === 0;
}
从栈中弹出元素
由于我们没有使用数组来存储元素,我们需要实现手动删除元素的逻辑。pop
方法也返回从栈中删除的元素。基于对象的 pop
方法如下所示:
pop() {
if (this.isEmpty()) { // {1}
return undefined;
}
this.#count--; // {2}
const result = this.#items[this.#count]; // {3}
delete this.#items[this.#count]; // {4}
return result; // {5}
}
首先,我们需要验证栈是否为空({1}
),如果是,则返回值 undefined
(数组 pop
方法在数组为空时返回 undefined,因此我们遵循相同的行为)。如果栈不为空,我们将减少 #count
属性({2}
),并将栈顶的值临时存储起来,以便在元素被删除后({4}
)返回它({5}
)。
由于我们正在使用 JavaScript 对象,要从一个特定的值中删除,我们可以使用 JavaScript 的 delete
操作符,如第 {4}
行所示。
让我们使用以下内部值来模拟 pop
操作:
#items = {
0: { action: 'typing', text: 'S' },
1: { action: 'typing', text: 't' }}
};
#count = 2;
要访问栈顶的元素(最新添加的 text
:t
),我们需要访问值为 1
的键。为此,我们需要将 #count
变量从 2
减少到 1
。我们可以访问 #items[1]
,删除它,并返回其值。
查看栈顶并清除它
要查看栈顶的元素,我们将使用以下代码:
peek() {
return this.#items[this.#count - 1];
}
其行为类似于基于数组的实现中的 peek 方法。如果栈为空,它将返回 undefined
。
要清除栈,我们可以简单地将其重置为初始化类时的相同值:
clear() {
this.#items = {};
this.#count = 0;
}
创建 toString 方法
要为基于对象的 Stack
类创建 toString
方法,我们将使用以下代码:
toString() {
if (this.isEmpty()) {
return 'Empty Stack';
}
let objString = this.#itemToString(this.#items[0]); // {1}
for (let i = 1; i < this.#count; i++) { // {2}
objString += `, ${this.#itemToString(this.#items[i])}`; // {3}
}
return objString;
}
#itemToString(item) { // {4}
if (typeof item === 'object' && item !== null) {
return JSON.stringify(item); // Handle objects
} else {
return item.toString(); // Handle other types
}
}
如果栈为空,我们可以返回一个消息或 {}
(根据您的偏好)。接下来,我们将第一个元素转换为字符串 ({1}
) – 这是在栈只有一个元素或我们不需要在字符串末尾添加逗号的情况下。接下来,我们将通过使用 #count
属性(它也在我们的 #items
对象中作为键)迭代所有元素 ({2}
)。对于每个额外的元素,我们将添加一个逗号,然后是元素的字符串版本 ({3}
)。由于我们需要将栈的第一个元素和所有后续元素转换为字符串,而不是重复代码,我们可以创建另一个方法 ({4}
) 来将元素转换为字符串(这是我们在基于数组的版本中使用的相同逻辑)。通过在方法前加上哈希 (#
) 前缀,JavaScript 不会公开此方法,并且它将不可用于在类外部使用(这是一个私有方法)。
比较基于对象的实现与基于数组的栈
对于基于对象的 Stack
类的所有方法,时间复杂度都是常数时间 (O(1)),因为我们可以直接访问任何元素。唯一一个线性时间 (O(n)) 的方法是 toString
方法,因为我们需要遍历栈中的所有元素,其中 n 是栈的大小。
如果我们将基于数组的实现与基于对象的实现进行比较,你认为哪一个更好?
让我们回顾一下这两种方法:
-
性能:这两种实现对于大多数操作都具有相似的 Big O 复杂度。然而,由于基于对象的栈可能存在潜在的调整大小问题,基于数组的栈在整体性能上可能略有优势。
-
元素访问:基于数组的栈通过索引提供高效的随机访问,这在某些场景中可能很有用。在一些现实世界的例子中,例如撤销功能,如果用户想要一次性撤销多个步骤,可以根据其在栈中的位置快速访问相关更改(在这种情况下,栈对 LIFO 行为不是那么严格)。如果栈操作主要是由推送、弹出和查看顶部元素组成,那么随机访问可能不是一个重要的因素。
-
顺序:如果保持元素严格顺序很重要,基于数组的栈是首选选择。
-
内存:基于数组的栈通常更节省内存。
对于涉及栈的大多数用例,基于数组的实现通常推荐,因为它保留了顺序、高效的访问和更好的内存使用。在顺序不是关键,并且需要简单直接的基本栈操作实现的情况下,可以考虑基于对象的实现。
使用 TypeScript 创建 Stack 类
如本书之前所述,使用 TypeScript 创建类似于我们的 Stack 类的数据结构 API 相比于纯 JavaScript 有几个显著的优势,例如:
-
增强类型安全性:通过静态类型在开发期间捕获类型相关错误,防止它们导致运行时失败。这在构建其他人将使用的 API(如我们的 Stack 类)时至关重要,因为它有助于确保正确的使用。
-
显式合约:TypeScript 的接口和类型别名让我们能够定义我们的栈将持有的数据的精确结构和类型,这使得其他人更容易理解如何与之交互。
-
泛型:我们可以通过使用泛型来指定 Stack 类将存储的数据类型,使 Stack 类更加通用。这允许对各种类型的数据(数字、字符串、对象等)进行类型安全的操作。
-
自文档化代码:TypeScript 的类型注解作为内置文档,解释了函数、参数和返回值的目的。这减少了单独文档的需求,并使我们的代码更容易理解。
让我们看看如何使用 TypeScript 的基于数组的实现来重写我们的 Stack 类:
// src/04-stack/stack.ts
class Stack<T> { // {1}
private items: T[] = []; // {2}
push(item: T): void { }
pop(): T | undefined { }
peek(): T | undefined { }
isEmpty(): boolean { }
get size(): number { }
clear(): void { }
toString(): string { }
}
export default Stack; // {3}
我们将使用泛型({1}
)来使我们的类更灵活。它可以持有任何类型的元素(T
),无论是数字、字符串、对象还是自定义类型。这比 JavaScript 实现有更大的优势,因为 JavaScript 允许数据结构中混合类型的数据,通过为我们的 Stack 类添加类型,我们强制所有元素都将具有相同的类型({2}
)。TypeScript 还有一个 private
关键字来声明私有属性和方法。这个特性在 JavaScript 添加哈希 # 前缀以允许私有属性和方法之前几年就已经可用。
方法内的代码与 JavaScript 实现相同。这里的优势是我们可以为任何方法参数及其返回类型进行类型化,从而更容易地阅读代码。
导出({3}
)语法遵循我们在本章前面回顾的 ECMAScript 方法。
如果我们想在单独的文件中使用这个数据结构以便进行测试,我们可以创建另一个文件(相当于我们创建的 JavaScript 文件):
// src/04-stack/01-using-stack-class.ts
import Stack from './stack';
enum Action { // {4}
TYPE = 'typing'
}
interface EditorAction { // {5}
action: Action;
text: string;
}
const stack = new Stack<EditorAction>(); // {6}
stack.push({action: Action.TYPE, text: 'S'});
stack.push({action: Action.TYPE, text: 't'});
我们可以创建一个枚举器来定义文本编辑器中允许的所有类型({4}
)。这一步是可选的,但是一个良好的实践,可以避免打字错误。接下来,我们可以创建一个接口来定义我们的栈将存储的数据类型({5}
)。最后,当我们实例化 Stack
数据结构时,我们可以对其进行类型化以确保所有元素都将具有相同的类型({6}
)。剩余的示例代码将与 JavaScript 中的相同。
要查看示例文件的输出,我们可以使用以下命令:
npx ts-node src/04-stack/01-using-stack-class.ts
ts-node
包允许我们在不先手动编译的情况下执行 TypeScript 代码。输出将与 JavaScript 中的相同。
使用栈解决问题
现在我们已经知道了如何使用 Stack
类,让我们用它来解决一些计算机科学问题。在本节中,我们将介绍十进制转二进制问题,并将该算法转换为基数转换算法。
将十进制数转换为二进制
我们已经熟悉十进制基数。然而,在计算机科学中,二进制表示尤为重要,因为计算机中的所有东西都是由二进制位(0 和 1)表示的。
在处理数据存储时,例如,这非常有帮助。计算机将所有信息存储为二进制位。当我们保存文件时,每个字符或像素的十进制表示在存储到硬盘或其他存储介质之前会被转换为二进制。一些文件格式,如图像文件(.bmp, .png)和音频文件(.wav),部分或全部以二进制格式存储数据。理解二进制转换对于在低级别处理这些文件至关重要。另一个应用是条形码,它本质上是由黑白线条组成的二进制模式,代表十进制数。扫描仪将这些模式解码回十进制以识别产品和其他信息。
要将十进制数转换为二进制表示,我们可以将数字除以 2(二进制是一个基数为 2 的数制)直到除法结果为 0。作为一个例子,我们将数字 10 转换为二进制位:
图 4.6:将数字 10 转换为二进制位数的数学表示
这是在计算机科学课程中最早学习的内容之一。十进制到二进制的算法如下所示:
// src/04-stack/decimal-to-binary.js
const Stack = require('./stack');
function decimalToBinary(decimalNumber) {
const remainderStack = new Stack();
let binaryString = '';
if (decimalNumber === 0) { '0'; }
while (decimalNumber > 0) { // {1}
const remainder = Math.floor(decimalNumber % 2); // {2}
remainderStack.push(remainder); // {3}
decimalNumber = Math.floor(decimalNumber / 2); // {4}
}
while (!remainderStack.isEmpty()) { // {5}
binaryString += remainderStack.pop().toString();
}
return binaryString;
}
在提供的代码中,只要除法的商非零(行 {1}
),我们就使用取模运算符(行 {2}
)计算余数并将其推入栈中(行 {3})。然后我们通过除以 2 并使用 Math.floor
(行 {4}
)丢弃任何小数部分来更新下一次迭代的被除数。这是必要的,因为 JavaScript 无法区分整数和浮点数。最后,我们弹出栈中的元素直到栈为空(行 {5}
),将它们连接成字符串以形成二进制表示。
如果我们调用 decimalToBinary(13)
,这个过程将会是这样的:
-
13 % 2 = 1(余数推入栈中);13 / 2 = 6(整数除法)
-
6 % 2 = 0(余数推入栈中);6 / 2 = 3
-
3 % 2 = 1(余数推入栈中);3 / 2 = 1
-
1 % 2 = 1(余数推入栈中);1 / 2 = 0(循环终止)
-
栈:[1, 1, 0, 1](从上到下)
-
从栈中弹出并构建结果字符串:"1101"
基数转换算法
我们可以修改之前的算法,使其作为从 2
到 36
之间的十进制到基数的转换器工作。我们不需要将十进制数除以 2,而是可以将所需的基数作为方法的参数传递,并在除法操作中使用它,如下面的算法所示:
// src/04-stack/decimal-to-base.js
const Stack = require('./stack');
function decimalToBase(decimalNumber, base) {
if (base < 2 || base > 36) {
throw new Error('Base must be between 2 and 36');
}
const digits = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // {6}
const remainderStack = new Stack();
let baseString = '';
while (decimalNumber > 0) {
const remainder = Math.floor(decimalNumber % base);
remainderStack.push(remainder);
decimalNumber = Math.floor(decimalNumber / base);
}
while (!remainderStack.isEmpty()) {
baseString += digits[remainderStack.pop()]; // {7}
}
return baseString;
}
我们还需要更改一件事。在十进制到二进制的转换中,余数将是 0 或 1;在十进制到八进制的转换中,余数将是 0 到 8;在十进制到十六进制的转换中,余数可以是 0 到 9 加上字母 A 到 F(值 10 到 15)。因此,我们需要将这些值也转换({6}
和 {7}
行)。所以,从 base 11 开始,每个字母将代表其基数。字母 A
代表 base 11,B
代表 base 12,以此类推。
我们可以使用之前的算法并输出其结果如下:
console.log(decimalToBase(100345, 2)); // 11000011111111001
console.log(decimalToBase(100345, 8)); // 303771
console.log(decimalToBase(100345, 16)); // 187F9
console.log(decimalToBase(100345, 35)); // 2BW0
我们何时可以在现实世界中使用这个算法?这个算法有多个应用,例如:
-
十六进制(基数 16):网络开发人员和图形设计师经常使用十六进制表示法在 HTML、CSS 和其他数字设计工具中表示颜色。例如,白色表示为
#FFFFFF
,相当于十进制值16777215
。decimalToBase
算法可用于在十进制和十六进制表示之间转换颜色值。 -
Base64 编码:Base64 是一种常见的编码方案,用于将二进制数据(图像、音频等)表示为文本。它使用一个 64 个字符的字母表(A-Z,a-z,0-9,+,/)并将二进制数据转换为 base-64 表示,以便更容易地在基于文本的协议(如电子邮件)中传输。我们可以增强我们的算法以转换为 base-64(将其作为挑战尝试,你将在本书的源代码中找到解决方案)。
-
短网址或唯一标识符:像 bit.ly 这样的服务生成使用字母数字字符混合的短网址。这些短网址通常代表已转换为更高基数(例如:base 62)的唯一数字标识符,以使其更加紧凑。
当你下载本书的源代码时,你也会找到 汉诺塔 的示例。
练习
我们将使用本章学到的概念解决来自 LeetCode 的几个数组练习。
Valid Parentheses
我们将解决的第一个练习是 leetcode.com/problems/valid-parentheses/
上的 20. Valid Parentheses 问题。
当使用 JavaScript 或 TypeScript 解决问题时,我们将在函数 function isValid(s: string): boolean {}
内部添加我们的逻辑,该函数接收一个字符串并期望返回一个布尔值。
以下是由问题提供的样本输入和预期输出:
-
输入 "()",输出 true。
-
输入 "()",输出 true。
-
输入 "(]", 输出 false。
一个输入字符串是有效的,如果:
-
开括号必须由相同类型的括号关闭。
-
开括号必须以正确的顺序关闭。
-
每个闭合括号都有一个相同类型的对应开括号。
问题还提供了三个提示,其中包含我们需要实现以解决此问题的逻辑:
-
使用字符栈。
-
当你遇到开括号时,将其推入栈顶。
-
当你遇到闭括号时,检查栈顶是否为其开括号。如果是,则从栈中弹出。否则,返回 false。
让我们编写isValid
函数:
const isValid = function(s) {
const stack = []; // {1}
const open = ['(', '[', '{']; // {2}
const close = [')', ']', '}']; // {3}
for (let i = 0; i < s.length; i++) { // {4}
if (open.includes(s[i])) { // {5}
stack.push(s[i]);
} else if (close.includes(s[i])) { // {6}
const last = stack.pop(); // {7}
if (open.indexOf(last) !== close.indexOf(s[i])) { // {8}
return false;
}
}
}
return stack.length === 0; // {9}
}
之前的代码使用栈数据结构来跟踪括号,如提示中所示({1}
)。尽管我们没有使用自己的栈类来解决这个问题,但我们了解到我们可以使用数组,并通过使用 JavaScript 数组类的 push 和 pop 方法来应用 LIFO 行为。我们还声明了两个数组:open
({2}
)和close
({3}
),分别包含三种开闭括号。然后,我们遍历字符串({4}
)。如果它遇到open
数组中存在的开括号({5}
),则将其推入栈中。如果它遇到close
数组中存在的闭括号({6}
),则从栈中弹出最后一个元素({7}
)并检查弹出的开括号是否与其相应的闭括号匹配({8}
)。在循环结束后,如果栈为空({9}
),则意味着所有开括号都已正确匹配闭括号,因此函数返回true
,否则如果栈中仍有元素,则意味着存在未匹配的开括号。
这是通过所有测试并解决问题的解决方案。然而,如果这个练习用于技术面试,面试官可能会要求你尝试一个不同的解决方案,该方案不包括用于跟踪开闭括号的数组,毕竟,includes
方法的时间复杂度是 O(n),因为它可能会遍历整个数组,即使我们的数组只包含三个元素。
在本章中,我们了解到可以使用 JavaScript 对象来作为键值对。因此,我们可以使用 JavaScript 对象重写isValid
函数,以映射开闭括号:
const isValid2 = function(s) {
const stack = [];
const map = { // {10}
'(': ')',
'[': ']',
'{': '}'
};
for (let i = 0; i < s.length; i++) {
if (map[s[i]]) { // {11}
stack.push(s[i]);
} else if (s[i] !== map[stack.pop()]) { // {12}
return false;
}
}
return stack.length === 0;
}
逻辑仍然是相同的,然而,我们可以将开括号映射为键,闭括号映射为值({10}
)。这允许我们在{11}
和{12}
行中直接访问对象内的元素,从而避免遍历数组。
这个函数的时间复杂度是O(n),其中n是字符串s
的长度。这是因为函数遍历字符串s
一次,对字符串中的每个字符执行恒定的操作(要么推入栈,要么从栈中弹出,要么比较字符)。
空间复杂度也是O(n),因为在最坏的情况下(当所有字符都是开括号时),函数会将所有字符推入栈中。
你能想到我们可以对这个算法应用哪些优化吗?
尽管我们的代码正在运行,但可以通过在算法开始时添加一些对边缘情况的验证来进一步优化:
const isValid3 = function(s) {
// opt 1: if the length of the string is odd, return false
if (s.length % 2 === 1) return false;
// opt 2: if the first character is a closing bracket, return false
if (s[0] === ')' || s[0] === ']' || s[0] === '}') return false;
// opt 3: if the last character is an opening bracket, return false
if (s[s.length - 1] === '(' ||
s[s.length - 1] === '[' || s[s.length - 1] === '{') return false;
// remaining algorithm is same
}
函数开头进行的优化不会改变整体的时间复杂度,因为它们是常数时间操作。然而,它们可能通过允许函数提前退出而提高函数在某些场景下的性能。
在算法挑战、竞赛和技术面试中,这些优化特别重要。这样的优化表明你注重细节,关心编写干净、高效的代码。这可以向潜在雇主发出积极的信号。能够识别和实施这些优化表明你能够批判性地思考代码效率,并对问题的约束有良好的理解。面试官通常重视这种能力,因为它展示了你对算法和数据结构的更深入理解。
最小栈
我们将要解决的下一个练习是155. 最小栈,可在leetcode.com/problems/min-stack
找到。
这是一个设计问题,要求你设计一个支持 push、pop、top 和在常数时间内检索最小元素的栈。问题还指出,你必须为每个函数实现一个O(1)时间复杂度的解决方案。
我们已经在本章中设计了Stack
类(top
方法是我们的peek
方法)。我们需要做的是跟踪栈中的最小元素。
给定的示例输入是:
-
["MinStack", "push", "push", "push", "getMin", "pop", "top", "getMin"]
-
[[], [-2], [0], [-3], [], [], [], []]
给出的解释是:
MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.getMin(); // return -3
minStack.pop();
minStack.top(); // return 0
minStack.getMin(); // return -2
让我们回顾一下MinStack
的设计如下:
class MinStack {
stack = [];
minStack = []; // {1}
push(x) {
this.stack.push(x);
if (this.minStack.length === 0 ||
x <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(x);
}
}
pop() {
const x = this.stack.pop();
if (x === this.minStack[this.minStack.length - 1]) {
this.minStack.pop();
}
}
top() {
return this.stack[this.stack.length - 1];
}
getMin() {
return this.minStack[this.minStack.length - 1];
}
}
为了能够在常数时间内返回栈的最小元素,我们还需要跟踪最小值。有几种不同的方法可以实现这一点,而第一种选择是也保持一个minStack
来跟踪最小值({1}
)。
push
方法接受一个数字x
作为参数,并将其推入栈中。然后检查minStack
是否为空或x
是否小于或等于当前最小元素(这是minStack
中的最后一个元素)。如果任一条件为真,x
也将被推入minStack
。
pop
方法从栈中移除顶部元素并将其赋值给x
。如果x
等于当前最小元素(再次,这是minStack
中的最后一个元素),它也将从minStack
中移除顶部元素。
top
方法与我们的peek
方法有相同的实现。getMin
方法与查看minStack
相同,minStack
始终在其顶部持有栈当前状态的最小元素。
另一种方法是跟踪变量中的最小元素而不是栈。我们会在push
方法中将其初始化为min= +Infinity
,这是 JavaScript 中最大的数值,每次向栈中添加新元素时,我们会更新其值(this.min = Math.min(val, this.min)
),在pop
方法中,如果从栈中移除相同的元素,我们也会更新最小值(if (this.min === val) this.min = Math.min(...this.stack)
)。而对于getMin
方法,我们只需简单地返回this.min
。然而,在这种方法中,pop
方法的时间复杂度会是O(n),因为它每次弹出元素时都会使用Math.min(...this.stack)
来找到新的最小值,这个操作需要遍历整个栈,所以这并不一定是更好的解决方案。
当你下载这本书的源代码时,你也会找到第 77 题 简化路径的解决方案。
摘要
在本章中,我们深入探讨了基本栈数据结构。我们使用数组和 JavaScript 对象实现了自己的栈算法,掌握了如何使用push
和pop
方法高效地添加和移除元素。
我们探索并比较了 Stack 类的不同实现,权衡了内存使用、性能和顺序保持等因素,为实际用例提供了明智的建议。我们还审查了使用 TypeScript 实现的 Stack 类及其优点。
不仅仅是实现,我们还使用栈解决了著名的计算机科学问题,并剖析了在技术面试中常见的一些练习题,分析了它们的时间和空间复杂度。
在下一章中,我们将把重点转向队列,这是一种与栈紧密相关的数据结构,它的工作原理与控制栈的 LIFO(后进先出)模型不同。
第六章:5 队列和双端队列
开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“EARLY ACCESS SUBSCRIPTION”下找到“learning-javascript-dsa-4e”频道)。
我们已经探讨了栈的内部工作原理,这是一种遵循LIFO(后进先出)原则的数据结构。现在,让我们将注意力转向队列,这是一种类似但不同的数据结构。虽然栈优先考虑最新的添加项,但队列基于FIFO(先进先出)原则,优先考虑最早的条目。我们将深入研究队列的机制,然后探索双端队列,这是一种多功能混合数据结构,结合了栈和队列的元素。到本章结束时,您将对这些基本数据结构和它们的实际应用有一个扎实的理解。
在本章中,我们将涵盖以下主题:
-
队列数据结构
-
双端队列数据结构
-
向队列和双端队列中添加元素
-
从队列和双端队列中移除元素
-
通过“热土豆”游戏模拟循环队列
-
使用双端队列检查一个短语是否是回文
-
我们可以使用队列和双端队列解决的问题
队列数据结构
在日常生活中,队列无处不在。想想买电影票的队伍、午餐时的自助餐厅队伍,或者杂货店的结账队伍。在这些场景中,基本原理是相同的:第一个加入队列的人将是第一个被服务的人。
图 5.1:现实生活中的排队示例:一群人排队购票
计算机科学中一个非常流行的例子是打印队列。假设我们需要打印五份文件。我们打开每一份文件并点击打印按钮。每一份文件都将被发送到打印队列。我们要求打印的第一份文件将会首先打印,以此类推,直到所有文件都打印完毕。
在数据结构的领域,队列是一个遵循First In, First Out(FIFO)原则的线性元素集合,通常被称为先到先得。新元素始终添加到队列的尾部(末尾),而元素的移除始终发生在队列的前部(开始)。
让我们通过使用 JavaScript 和 TypeScript 创建自己的 Queue 类来将这些概念付诸实践。
创建队列类
我们将创建自己的类来表示队列。本章的源代码可在 GitHub 上的 src/05-queue-deque
文件夹中找到。我们将首先创建 queue.js
文件,该文件将包含我们使用基于数组的策略表示栈的类。
在本书中,我们将采取渐进式的方法来构建我们对数据结构的理解。我们将利用我们在上一章中学到的概念,并在我们前进的过程中逐渐增加复杂性(因此请确保您不要跳过章节)。这种方法将使我们能够建立一个坚实的基础,并自信地处理更复杂的数据结构。
首先,我们将声明我们的Queue
类:
class Queue {
#items = [];
// other methods
}
我们需要一个数据结构来存储队列的元素。我们可以使用数组来完成这项工作,因为我们已经熟悉数组数据结构,并且在上一章中我们已经了解到,基于数组的方案比基于对象的方案更受欢迎。
再次强调,我们将变量items
前缀为哈希#
以表示此属性是私有的,并且只能在Queue
类内部引用,因此,允许我们在元素插入和删除时遵循 FIFO 原则。
以下方法将在Queue
类中可用:
-
enqueue(item)
: 此方法将新项目添加到队列的末尾。 -
dequeue()
: 此方法从队列的开始处移除第一个项目。它还返回被移除的项目。 -
front()
: 此方法从队列的开始处返回第一个元素。队列不会被修改(它不会移除元素;它仅返回元素以供信息用途)。这就像Stack
类中的peek
方法。 -
isEmpty()
: 此方法返回true
,如果队列不包含任何元素,如果栈的大小大于 0 则返回false
。 -
clear()
: 此方法移除队列中的所有元素。 -
size()
: 此方法返回队列包含的元素数量。
我们将在以下小节中为每个方法编写代码。
将元素入队到队列的末尾
我们将要实现的第一种方法是enqueue
方法。此方法负责向队列中添加新元素,有一个特别重要的细节:我们只能将新项目添加到队列的末尾,如下所示:
enqueue(item) {
this.#items.push(item);
}
由于我们正在使用数组来存储队列的元素,我们可以使用 JavaScript Array
类的push
方法,该方法将新项目添加到数组的末尾。enqueue
方法与Stack
类中的push
方法具有相同的实现;从代码的角度来看,我们只是在更改方法名称。
从队列的开始处出队元素
接下来,我们将实现dequeue
方法。此方法负责从队列中移除项目。由于队列使用 FIFO 原则,队列开始处的项目(我们内部数组的索引 0)将被移除。因此,我们可以使用 JavaScript Array
类的shift
方法,如下所示:
dequeue() {
return this.#items.shift();
}
Array
类的shift
方法从数组中移除第一个元素并返回它。如果数组为空,则返回undefined
,并且数组不会被修改。因此,这与队列的行为完美匹配。
enqueue
方法的复杂度是常数时间(O(1))。dequeue
方法可以有线性时间复杂度(O(n)),因为我们使用shift
方法来移除数组的第一个元素,在最坏的情况下,这可能导致O(n)的性能,因为剩余的元素需要向下移动。
查看队列前端的元素
接下来,我们将在我们的类中实现额外的辅助方法。如果我们想知道队列的前端元素是什么,我们可以使用front
方法。此方法将返回位于我们内部数组索引 0 处的项目:
front() {
return this.#items[0];
}
验证它是否为空,获取大小和清空队列
我们将要创建的下一个方法是isEmpty
方法、size
获取器和clear
方法。这三个方法与Stack
类的实现完全相同:
isEmpty() {
return this.#items.length === 0;
}
get size() {
return this.#items.length;
}
clear() {
this.#items = [];
}
使用isEmpty
方法,我们可以简单地验证内部数组的长度是否为 0。
对于大小,我们为队列的size
创建了一个 getter,它只是内部数组的长度。
对于clear
方法,我们可以简单地分配一个新的空数组来表示一个空队列。
最后,我们有toString
方法,其代码与Stack
类相同如下:
toString() {
if (this.isEmpty()) {
return 'Empty Queue';
} else {
return this.#items.map(item => {
if (typeof item === 'object' && item !== null) {
return JSON.stringify(item);
} else {
return item.toString();
}
}).join(', ');
}
}
将队列数据结构导出为库类
我们已经创建了一个名为src/05-queue-deque/queue.js
的文件,其中包含我们的Queue
类。我们希望在另一个文件中使用Queue
类进行测试,以便于我们代码的易于维护(src/05-queue-deque/01-using-queue-class.js
)。我们如何实现这一点?
我们在上一章也讨论了这个问题。我们将使用CommonJS
模块的module.exports
来导出我们的类:
// queue.js
class Queue {
// our Queue class implementation
}
module.exports = Queue;
使用队列类
现在是时候测试和使用我们的Queue
类了!如前一小节所述,让我们创建一个单独的文件,这样我们就可以编写尽可能多的测试:src/05-queue-deque/01-using-queue-class.js
。
我们需要做的第一件事是从queue.js
文件中导入代码并实例化我们刚刚创建的Queue
类:
const Queue = require('./queue');
const queue = new Queue();
接下来,我们可以验证它是否为空(输出为true
,因为我们还没有向我们的队列中添加任何元素):
console.log(queue.isEmpty()); // true
接下来,让我们模拟一个打印机的队列。假设我们在电脑上打开了三个文档。然后我们点击每个文档中的打印按钮。这样做会将文档按打印按钮点击的顺序入队到队列中:
queue.enqueue({ document: 'Chapter05.docx', pages: 20 });
queue.enqueue({ document: 'JavaScript.pdf', pages: 60 });
queue.enqueue({ document: 'TypeScript.pdf', pages: 80 });
如果我们调用front
方法,它将返回文件Chapter05.docx
,因为它是第一个被添加到队列中等待打印的文档:
console.log(queue.front()); // { document: 'Chapter05.docx', pages: 20 }
让我们也检查队列的大小:
console.log(queue.size); // 3
现在,让我们通过出队直到队列为空来“打印”队列中的所有文档:
// print all documents
while (!queue.isEmpty()) {
console.log(queue.dequeue());
}
以下图显示了从队列中打印第一个文档时的出队操作:
图 5.2:打印机队列的模拟
检查我们队列类的效率
让我们通过考虑执行时间的大 O 符号来检查队列类中每个方法的效率:
方法 | 复杂度 | 说明 |
---|---|---|
enqueue |
O(1) | 将元素添加到数组的末尾通常是一个常数时间操作。 |
dequeue |
O(n) | 删除第一个元素需要将所有剩余元素进行移动,所需时间与队列的大小成比例。 |
front |
O(1) | 通过索引直接访问第一个元素是一个常数时间操作。 |
isEmpty |
O(1) | 检查数组的长度属性是一个常数时间操作。 |
size |
O(1) | 访问长度属性是一个常数时间操作。 |
clear |
O(1) | 使用空数组覆盖内部数组被认为是常数时间操作。 |
toString |
O(n) | 遍历元素,可能将它们转换为字符串,并将它们连接成一个字符串,所需时间与元素数量成比例。 |
表 5.1:
使用数组实现的队列中,出队操作通常是性能最敏感的操作。这是由于在删除第一个元素后需要移动元素。有其他队列实现方式(例如使用链表,我们将在下一章中介绍,或者使用循环队列),这些方式可以在大多数情况下将出队操作优化为常数时间复杂度。我们将在本章后面创建循环队列。
双端队列数据结构
deque 数据结构,也称为双端队列,是一种特殊的队列,允许我们从队列的末尾或前端插入和删除元素。
双端队列可以用来存储用户的网页浏览历史。当用户访问新页面时,它被添加到双端队列的前端。当用户导航回退时,最近的页面从前端被移除,而当用户导航前进时,页面被重新添加到前端。
另一个应用将是撤销/重做功能。我们在上一章中了解到我们可以使用两个栈来实现此功能,但我们也可以使用双端队列作为替代。用户操作被推入双端队列,撤销操作从队列前端弹出操作,而重做操作将它们推回。
创建 Deque 类
如同往常,我们将从声明位于文件 src/05-queue-deque/deque.js
中的 Deque
类开始:
class Deque {
#items = [];
}
我们将继续使用基于数组的实现来构建我们的数据结构。鉴于双端队列数据结构允许从两端插入和删除元素,我们将有以下方法:
-
addFront(item)
: 此方法将新元素添加到双端队列的前端。 -
addRear(item)
: 此方法将新元素添加到双端队列的后端。 -
removeFront()
: 此方法从双端队列中移除第一个元素。 -
removeRear()
: 此方法从双端队列中移除最后一个元素。 -
peekFront()
: 此方法返回双端队列中的第一个元素。 -
peekRear()
: 此方法返回双端队列中的最后一个元素。
Deque
类还实现了isEmpty
、clear
、size
和toString
方法(你可以通过下载本书的源代码包来查看完整的源代码)。这些方法的代码与Queue
类相同。
向双端队列中添加元素
让我们检查两种方法,这些方法将允许我们向双端队列的前端和后端添加元素:
addFront(item) {
this.#items.unshift(item);
}
addRear(item) {
this.#items.push(item);
}
addFront
方法通过使用 Array.unshift
方法在内部数组的索引 0 处插入一个元素。
addRear
方法在双端队列的末尾插入一个元素。它的实现与 Queue
类的 enqueue
方法相同。
从双端队列中删除元素
从双端队列的前端和后端删除元素的方法如下所示:
removeFront() {
return this.#items.shift();
}
removeRear() {
return this.#items.pop();
}
removeFront
方法移除并返回双端队列开头(前端)的元素。如果双端队列为空,则返回 undefined
。它的实现与 Queue
类的 dequeue
方法相同。
removeRear
方法移除并返回双端队列末尾(后端)的元素。如果双端队列为空,则返回 undefined
。它的实现与 Stack
类的 pop
方法相同。
查看双端队列的元素
最后,让我们检查以下 peek 方法:
peekFront() {
return this.#items[0];
}
peekRear() {
return this.#items[this.#items.length - 1];
}
peekFront
方法允许你在不删除它的情况下查看双端队列开头(前端)的元素。它的实现与 Queue
类的 peek
方法相同。
peekRear
方法允许你在不删除它的情况下查看双端队列末尾(后端)的元素。它的实现与 Stack
类的 peek
方法相同。
注意双端队列方法实现与
Stack
和Queue
类的相似性。我们可以认为双端队列数据结构是栈和队列数据结构的混合版本。请参考队列和栈效率回顾,以检查这些方法的时间复杂度。
使用 Deque
类
是时候测试我们的 Deque
类了(src/05-queue-deque/03-using-deque-class.js
)。我们将在浏览器的 "后退" 和 "前进" 按钮功能场景中使用它。让我们看看如何使用我们的 Deque
类来实现这一点:
const Deque = require('./deque');
class BrowserHistory {
#history = new Deque(); // {1}
#currentPage = null; // {2}
visit(url) {
this.#history.addFront(url); // {3}
this.#currentPage = url; // {4}
}
goBack() {
if (this.#history.size() > 1) { // {5}
this.#history.removeFront(); // {6}
this.#currentPage = this.#history.peekFront(); // {7}
}
}
goForward() {
if (this.#currentPage !== this.#history.peekBack()) { // {8}
this.#history.addFront(this.#currentPage); // {9}
this.#currentPage = this.#history.removeFront(); // {10}
}
}
get currentPage() { // returns the current page for information
return this.#currentPage;
}
}
以下是对其的解释:
-
创建一个名为
history
的Deque
来存储已访问页面的 URL({1}
)。currentPage
变量跟踪当前显示的页面({2}
)。 -
visit(url)
方法将新的url
添加到history
双端队列的前端({3}
),并更新currentPage
为新的 URL({4}
)。 -
goBack()
方法:-
检查
history
中是否有至少两个页面(当前和上一个 -{5}
)。 -
如果是这样,它将从历史双端队列的前端移除当前页面(
{6}
)。 -
将
currentPage
更新为现在的前端元素,它代表上一页({7}
)。
-
-
goForward()
方法:-
检查
currentPage
是否与历史双端队列的最后一个元素不同(意味着存在“下一页”-{8}
)。 -
如果是这样,将当前页面重新添加到历史双端队列的前端(
{9}
)。 -
移除并设置
currentPage
为现在的前端元素,即“下一页”({10}
)。
-
在我们的浏览器模拟准备就绪后,我们可以使用它:
const browser = new BrowserHistory();
browser.visit('loiane.com');
browser.visit('https://loiane.com/about'); // click on About menu
browser.goBack();
console.log(browser.currentPage); // loiane.com
browser.goForward();
console.log(browser.currentPage); // https://loiane.com/about
我们将模拟访问loiane.com
,这是作者的博客。接下来,我们将访问“关于”页面,这样我们就可以将另一个 URL 添加到浏览器的历史记录中。然后,我们可以点击“后退”按钮回到主页。我们还可以点击“前进”按钮回到“关于”页面。当然,我们还可以查看当前页面或历史记录。以下图像展示了这个模拟:
图 5.3:浏览器后退和前进按钮的模拟
在 TypeScript 中创建 Queue 和 Deque 类
在完成 JavaScript 实现后,我们可以使用 TypeScript 重写我们的 Queue 和 Deque 类:
// src/05-queue-deque/queue.ts
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void {}
// all other methods are the same as in JavaScript
}
export default Queue;
以及Deque
类:
// src/05-queue-deque/deque.ts
class Deque<T> {
private items: T[] = [];
addFront(item: T): void {}
addRear(item: T): void {}
// all other methods are the same as in JavaScript
}
export default Deque;
我们将使用泛型使我们的数据结构更加灵活,并使项目仅包含一种类型。方法内部的代码与 JavaScript 实现相同。
使用队列和双端队列解决问题
现在我们已经知道了如何使用Queue
和Deque
类,让我们用它们来解决一些计算机科学问题。在本节中,我们将介绍使用队列模拟热土豆游戏以及如何使用双端队列检查一个短语是否是回文。
循环队列:热土豆游戏
热土豆游戏是一个经典的儿童游戏,参与者围成一圈,在音乐播放时尽可能快地传递一个物体(“热土豆”)。当音乐停止时,持有土豆的人被淘汰。游戏继续进行,直到只剩下一个人。
CircularQueue 类
我们可以使用循环队列的实现完美地模拟这个游戏:
class CircularQueue {
#items = [];
#capacity = 0; // {1}
#front = 0; // {2}
#rear = -1; // {3}
#size = 0; // {4}
constructor(capacity) { // {5}
this.#items = new Array(capacity);
this.#capacity = capacity;
}
get size() { return this.#size; }
}
循环队列是使用固定大小数组实现的队列,意味着一个预定义的容量({1}
),其中前端({2}
)和后端({3}
)指针可以在达到数组末尾时“环绕”到数组的开始。这有效地重用了数组空间,避免了不必要的调整大小。front
指针初始化为 0,指向第一个元素的位置。rear
指针初始化为-1,表示一个空队列。size
属性({4}
)跟踪队列中的当前元素数量。
当我们创建循环队列时,我们需要告知我们计划存储多少个元素({5}
)。
让我们接下来回顾enqueue
和isFull
方法:
enqueue(item) {
if (this.isFull()) { // {6}
throw new Error("Queue is full");
}
this.#rear = (this.#rear + 1) % this.#capacity; // {7}
this.#items[this.#rear] = item; // {8}
this.#size++; // {9}
}
isFull() { return this.#size === this.#capacity; }
在向队列添加任何元素之前,我们需要检查它是否未满,这意味着大小与容量相同({6}
)。由于容量固定,这使得循环队列在内存使用方面可预测,但这也可能被视为一种限制。
如果队列未满,我们将rear
指针增加 1。这里的关键点是使用取模运算符(%
)将rear
回绕到 0,如果它达到数组的末尾({7}
)。然后我们在新的rear
位置插入项,并增加大小计数器({9}
)。
最后,我们有dequeue
和isEmpty
方法:
dequeue() {
if (this.isEmpty()) { throw new Error("Queue is empty"); } // {10}
const item = this.#items[this.#front]; // {11}
this.#size--; // {12}
if (this.isEmpty()) {
this.#front = 0; // {13}
this.#rear = -1; // {14}
} else {
this.#front = (this.#front + 1) % this.#capacity; // {15}
}
return item; // {16}
}
isEmpty() { return this.#size === 0; }
要移除队列前端的项,首先需要检查队列大小({10}
)。如果队列不为空,我们可以检索当前存储在队列前端位置的项({11}
),以便稍后返回({16}
)。然后,我们减少大小计数器({12}
)。
如果出队后队列不为空,我们需要通过取模运算符({15}
)将front
指针增加 1 并回绕。如果出队后队列为空,我们将front
({13}
)和rear
({14}
)指针重置为其初始值。
循环队列最大的优点是入队和出队操作通常都是O(1)(常数时间),这是由于直接操作指针。
热土豆游戏模拟
新的类已经准备好使用,让我们将其应用于热土豆游戏的模拟:
function hotPotato(players, numPasses) {
const queue = new CircularQueue(players.length); // {1}
for (const player of players) { // {2}
queue.enqueue(player);
}
while (queue.size > 1) { // {3}
for (let i = 0; i < numPasses; i++) { // {4}
queue.enqueue(queue.dequeue()); // {5}
}
console.log(`${queue.dequeue()} is eliminated!`); // {6}
}
return queue.dequeue(); // {7} The winner
}
此函数接受一个玩家数组和一个表示“土豆”传递次数的数字num
,在玩家被淘汰之前,它返回获胜者。
首先,我们创建一个包含玩家数量的循环队列({1}
),并将所有玩家入队({2}
)。
我们运行循环直到只剩下一个玩家({3}
):
-
另一个循环将土豆通过出队然后重新入队的方式传递给同一个玩家
numPasses
次({4}
)。 -
前端的玩家被移除并宣布淘汰(
{6}
)。
最后剩下的玩家被出队并宣布为获胜者({7}
)。
我们可以使用以下代码来尝试hotPotato
算法:
const players = ["Violet", "Feyre", "Poppy", "Oraya", "Aelin"];
const winner = hotPotato(players, 7);
console.log(`The winner is: ${winner}!`);
算法的执行将产生以下输出:
Poppy is eliminated!
Feyre is eliminated!
Aelin is eliminated!
Oraya is eliminated!
The winner is: Violet!
以下是对应的模拟图:
图 5.4:使用循环队列的热土豆游戏模拟
您可以使用hotPotato
函数更改传递次数来模拟不同的场景。
回文检查器
以下是根据维基百科的定义的回文:
回文是一个单词、短语、数字或其他字符序列,它从后向前读与从前向后读相同,例如 madam 或 racecar。
我们可以使用不同的算法来验证一个短语或字符串是否是回文。最简单的方法是将字符串反转并与原始字符串比较。如果两个字符串相等,那么我们有一个回文。我们也可以使用一个栈来做这件事,但使用数据结构解决此问题的最简单方法是使用双端队列:字符被添加到双端队列中,然后从两端同时移除。如果在整个过程中移除的字符匹配,则字符串是回文。
以下算法使用双端队列来解决这个问题:
const Deque = require('./deque');
function isPalindrome(word) {
if (word === undefined || word === null ||
(typeof word === 'string' && word.length === 0)) { // {1}
return false;
}
const deque = new Deque(); // {2}
word = word.toLowerCase().replace(/\s/g, ''); // {3}
for (let i = 0; i < word.length; i++) {
deque.addRear(word[i]); // {4}
}
while (deque.size() > 1) { // {5}
if (deque.removeFront() !== deque.removeRear()) { // {6}
return false;
}
}
return true;
}
在我们开始算法逻辑之前,我们需要验证传递给参数的字符串是否有效,包括边缘情况({1}
)。如果它无效,那么我们返回false
,因为空字符串或不存在的单词不能被认为是回文。
我们将使用本章实现的Deque
类({2}
)。由于我们可以接收包含小写和大写字母的字符串,我们将所有字母转换为小写,并且我们还将删除所有空格({3}
)。如果您愿意,您还可以删除所有特殊字符,例如!?-()等。为了使此算法简单,我们将跳过这部分。接下来,我们将字符串的所有字符入队到双端队列({4}
)。
当双端队列({5}
- 如果只剩下一个字符,它就是回文)中至少有两个元素时,我们将从前面和后面各移除一个元素({6}
)。为了成为回文,从双端队列中移除的两个字符需要匹配。如果字符不匹配,那么字符串就不是回文({7}
)。
我们可以使用以下代码来尝试isPalindrome
算法:
console.log(isPalindrome("racecar")); // Output: true
练习
我们将使用本章学到的概念解决LeetCode的一个练习。
无法吃午餐的学生数量
我们将解决的练习是可在leetcode.com/problems/number-of-students-unable-to-eat-lunch/
找到的1700. 无法吃午餐的学生数量问题。
当使用 JavaScript 或 TypeScript 解决问题时,我们将在function countStudents(students: number[], sandwiches: number[]): number {}
函数内部添加我们的逻辑,该函数接收一个队列students
,这些学生愿意吃 0 或 1 个三明治,以及一个三明治的堆,它们将具有相同的大小。
这是一个模拟练习,根据问题描述:
-
如果排在队首的学生喜欢栈顶的三明治,他们将取走它并离开队伍。
-
否则,他们将留下它并走到队伍的末尾。
-
这将继续,直到没有队列中的学生想要取走顶部的三明治,因此无法吃午餐。
解决这个问题的关键是将三明治的堆也视为一个队列(FIFO)而不是一个栈(LIFO)。
让我们编写countStudents
函数:
function countStudents(students: number[], sandwiches: number[]) {
while (students.length > 0) { // {1}
if (students[0] === sandwiches[0]) { // {2}
students.shift(); // {3}
sandwiches.shift(); // {4}
} else {
if (students.includes(sandwiches[0])) { // {5}
let num = students.shift(); // {6}
students.push(num); // {7}
} else {
break; // {8}
}
}
}
return students.length;
}
当队列为空时,while 循环会继续运行({1}
)。
如果第一个学生的偏好(students[0]
)与({2}
)队列前部的三明治(顶部三明治 sandwiches[0]
)相匹配,那么学生({3}
)和三明治({4}
)都会从各自队列的前端移除。
如果没有匹配,我们使用 JavaScript Array
类的 includes
方法检查潜在的匹配,看看队列中的任何学生是否愿意接受当前顶部的三明治({5}
)。如果有潜在的匹配,当前学生({6}
)会被移动到队列的末尾({7}
)。
如果队列中没有剩下想要当前三明治的人,循环会中断({8}
)。这表明剩余的学生将无法吃到三明治。
该函数返回 students
数组的长度。这个长度代表了仍然在队列中等待且未能得到他们喜欢三明治的学生数量。
这个解决方案通过了所有测试并解决了问题。includes
检查({5}
)对于效率很重要。如果没有它,代码会在没有任何学生想要顶部的三明治时无谓地旋转队列,因此我们得到了额外的分数,尽管这种方法并不是队列数据结构的标准做法。
该函数的时间复杂度和空间复杂度是 O(n²),其中 n 是学生数量。这是因为外层循环会一直运行,直到没有学生为止,在最坏的情况下需要 n 次迭代。
在循环内部,有两个可能代价高昂的操作:students.shift()
,其复杂度为 O(n),以及 students.includes(sandwiches[0])
,其复杂度也是 O(n)。由于这些操作嵌套在循环内部,总复杂度是 O(n²)。
空间复杂度是 O(1),不包括输入数组所需的额外空间。这是因为该函数仅使用固定数量的额外空间来存储 num
变量。
你能想到一个更优化的方法来解决这个问题,可能不涉及队列数据结构吗?在技术面试中,考虑优化也很重要。尝试一下,你将在源代码中找到解决方案以及解释,包括 2034. 买票所需时间 问题解决方案。
摘要
在本章中,我们深入探讨了队列的基本概念及其多才多艺的表亲,双端队列(deque)。我们自行设计了一个队列算法,掌握了以先进先出(FIFO)的方式添加(入队)和移除(出队)元素的艺术。在探索双端队列时,我们发现其从两端插入和删除元素时的灵活性,这为创造性的解决方案提供了更多可能性。
为了巩固我们的理解,我们将知识应用于现实世界的场景。我们模拟了经典的“热土豆”游戏,利用循环队列来模拟其循环性质。此外,我们还创建了一个回文检查器,展示了双端队列在处理来自两个方向的数据时的强大功能。我们还解决了来自 LeetCode 的模拟挑战,强化了队列在问题解决中的实际应用。
现在,我们已牢固掌握了这些线性数据结构(数组、栈、队列和双端队列),我们准备进入下一章动态链表的世界,在那里我们将解锁更复杂的数据操作和管理潜力。
第七章:6 链表
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“学习 JavaScript 数据结构与算法 4e”频道下找到“早期访问订阅”)。
在前面的章节中,我们探讨了存储在内存中的顺序数据结构。现在,我们将注意力转向链表,这是一种动态且线性的数据结构,具有非顺序的内存排列。本章深入探讨了链表的内部工作原理,包括:
-
链表数据结构
-
添加和删除链表元素的技术
-
链表的变体:双向链表、循环链表和有序链表
-
链表如何用于实现其他数据结构
-
使用链表实现其他数据结构
-
使用链表的练习
链表数据结构
数组,这是一种几乎在每种编程语言中都存在的通用数据结构,提供了一种方便的方式来存储元素集合。它们熟悉的括号表示法([]
)提供了对单个项目的直接访问。然而,数组存在一个关键限制:在大多数语言中它们的固定大小。这种限制使得从开始或中间插入或删除元素变得昂贵,因为需要移动剩余的元素。虽然 JavaScript 提供了处理这些的方法,但底层过程仍然涉及这些移动,影响性能。
链表,就像数组一样,维护一个元素的顺序集合。然而,与数组中元素占据连续内存位置不同,链表将元素存储为散布在内存中的节点。每个节点封装了元素的数据(我们想要存储的信息或值)以及一个引用(也称为指针或链接),它指向序列中的下一个节点。以下图展示了这种链表结构:
具有节点、数据和指针的链表数据结构
第一个节点被称为 头,最后一个节点通常指向 null
(或 undefined
)以指示列表的末尾。
与传统的数组相比,链表的一个关键优势是能够在不涉及其他项的昂贵开销的情况下插入或删除元素。然而,这种灵活性是以使用指针为代价的,这要求在实现过程中更加小心。虽然数组允许直接访问任何位置的元素,但链表需要从头部遍历才能到达中间的元素,这可能会影响访问时间。
然而,如果你需要通过索引访问元素(就像我们使用数组那样),链表并不是最佳的数据结构。这是因为我们需要从列表的开始遍历,这可能会更慢。链表还需要额外的内存来存储,因为每个节点都需要额外的内存来存储指针,这对于简单的数据来说可能是额外的开销。
由于其灵活性和处理动态数据的效率,链表在现实世界中有着众多的应用。一个流行的例子是媒体播放器。媒体播放器使用链表来组织和管理工作表。添加、删除和重新排列歌曲或视频在链表上作为以下表示是直接的操作:
使用链表作为数据结构的媒体播放器表示
存在着不同类型的链表:
-
单链表(或简称链表):每个节点都有一个指向下一个节点的指针。
-
双链表:每个节点都有指向下一个和前一个节点的指针。
-
循环链表:最后一个节点指向头节点,形成一个循环。
在本章中,我们将介绍链表及其变体,但让我们先从最简单的数据结构开始。
创建 LinkedList 类
现在你已经了解了链表是什么,让我们开始实现我们的数据结构。我们将创建自己的类来表示链表。本章的源代码位于src/06-linked-list
文件夹中。我们将首先创建linked-list.js
文件,该文件将包含表示我们的数据结构的类以及存储数据和指针所需的节点。
首先,我们定义一个LinkedListNode
类,它代表链表中的每个元素(或节点)。每个节点包含我们想要存储的数据,以及一个指向后续节点的引用(next
):
class LinkedListNode {
constructor(data, next = null) {
this.data = data;
this.next = next;
}
}
默认情况下,新创建的节点的next
指针被初始化为null
。然而,构造函数还允许你在事先知道的情况下指定下一个节点,这在某些场景中非常有用。
接下来,我们声明LinkedList
类,它代表我们的链表数据结构:
class LinkedList {
#head;
#size = 0;
// other methods
}
这个类首先声明一个私有的#head
引用,指向列表中的第一个节点(元素)。为了避免每次需要元素计数时都遍历整个列表,我们还维护一个私有的#size
变量。这两个属性都使用#
前缀来保持私有,以确保封装。
LinkedList
类将提供以下方法:
-
append(data)
: 在列表的末尾添加包含data
的新节点。 -
prepend(data)
: 在列表的头部(头节点)添加包含data
的新节点。 -
insert(data, position)
: 在列表的指定position
位置插入包含data
的新节点。 -
removeAt(position)
: 从列表的特定position
位置删除节点。 -
remove(data)
: 从列表中删除包含指定data
的第一个节点。 -
indexOf(data)
: 返回包含指定data
的第一个节点的索引。如果data
未找到,则返回-1
。 -
isEmpty()
: 如果列表不包含任何元素,则返回true
,否则返回false
。 -
clear()
: 从列表中删除所有元素。 -
size()
: 返回当前列表中元素的数量。 -
toString()
: 返回链表的字符串表示形式,按顺序显示元素。
我们将在以下章节中详细实现这些方法。
将元素追加到链表末尾
当在 LinkedList 的末尾追加元素时,我们会遇到两种情况:
-
空列表:列表没有现有元素,我们正在添加第一个元素。
-
非空列表:列表已经包含元素,我们正在将其添加到列表的末尾。
以下是对 append
方法的实现:
append(data) {
const newNode = new LinkedListNode(data);
if (!this.#head) {
this.#head = newNode;
} else {
let current = this.#head;
while (current.next !== null) {
current = current.next;
}
current.next = newNode;
}
this.#size++;
}
无论列表的状态如何,第一步是创建一个新节点以保存数据。
对于第一种情况,我们检查列表是否为空。如果 #head
指针当前为 null
(或 undefined
),则条件 !this.#head
评估为 true
,表示列表为空。
如果列表为空,新创建的节点(newNode
)将成为列表的头部。由于它是列表中唯一的节点,其下一个指针将自动为 null
。
让我们看看这些步骤的直观表示:
向空链表添加新元素
在我们的链表不为空的情况下,我们只有一个对 head
(第一个节点)的引用。要向末尾添加新元素,我们需要遍历列表:
-
我们首先将一个临时变量,通常称为
current
,赋值给列表的head
。这个变量将作为我们在列表中移动时的指针。 -
使用循环,我们不断地将
current
移动到下一个节点(current.next
),只要current.next
不是null
。这意味着我们继续移动,直到达到最后一个节点,其下一个指针将是null
。 -
一旦循环终止,
current
将引用最后一个节点。我们只需将current.next
设置为我们的新节点,从而将其添加到列表的末尾。
最后,我们增加 size
以反映新节点的添加。
以下图表展示了在链表不为空时将元素追加到末尾的情况:
添加新元素到链表末尾
向链表添加新元素到头部
将新元素添加到链表的头部(开始处)是一个简单的操作:
prepend(data) {
const newNode = new LinkedListNode(data, this.#head);
this.#head = newNode;
this.#size++;
}
第一步是创建一个新节点以保存data
。重要的是,我们将列表的当前head
作为构造函数的第二个参数传递。这设置了新节点的next
指针指向当前head
,从而建立了链接。如果列表为空,当前头部是null
,因此新节点的next
引用也将是null
。
接下来,我们更新列表的head
,使其指向新创建的节点(newNode
)。由于新节点已经与之前的头部节点相连,整个列表可以无缝调整。最后,我们增加列表的size
以反映新节点的添加。以下图表展示了这一场景:
在链表中预加一个新元素
在特定位置插入新元素
现在,让我们探讨如何在链表的任何位置插入元素。为此,我们将创建一个insert
方法,该方法接受data
和期望的position
作为参数:
insert(data, position) {
if (this.#isInvalidPosition(position)) {
return false;
}
const newNode = new LinkedListNode(data);
if (position === 0) {
this.prepend(data);
return true;
}
let current = this.#head;
let previous = null;
let index = 0;
while (index++ < position) {
previous = current;
current = current.next;
}
newNode.next = current;
previous.next = newNode;
this.#size++;
return true;
}
我们首先使用一个名为#isInvalidPosition
的辅助私有方法验证提供的position
是否有效。有效的位置是指落在列表边界内(0 到 size-1)的位置。如果位置无效,该方法返回false
以表示插入失败。辅助方法声明如下:
#isInvalidPosition(position) {
return position < 0 || position >= this.size;
}
接下来,我们创建将保存要插入的数据的新节点。
第一种情况是处理在列表头部插入。如果position
为 0,这意味着我们在列表的起始位置进行插入。在这种情况下,我们可以简单地调用我们之前定义的prepend
方法,并返回true
表示成功。
如果不是在头部插入,这意味着我们需要遍历列表。为此,我们需要一个名为current
的辅助变量,并将其设置为第一个节点(head
)。我们还需要第二个辅助变量来帮助连接新节点,我们将其命名为previous
。同时,我们初始化一个索引变量以跟踪我们在遍历中的位置。
然后,我们将遍历列表,直到达到期望的位置。为此,while
循环迭代,直到索引与position
匹配。在每次迭代中,我们将previous
移动到current
节点,并将current
移动到下一个节点。
循环结束后,previous
指向插入点之前的节点,而current
指向插入点的节点。我们调整下一个指针:newNode.next
设置为current
(原本位于插入点的节点),而previous.next
设置为newNode
,从而将新节点有效地插入到列表中。
让我们在以下图表中看看这个场景的实际操作:
在链表中间插入元素
拥有引用我们需要控制的节点的变量非常重要,这样我们才不会丢失节点之间的链接。我们可以只用一个变量(
previous
),但这样控制节点之间的链接会更困难。因此,最好声明一个额外的变量来帮助我们进行这些引用。
返回元素的索引
现在我们知道了如何遍历列表直到达到期望的位置,这使得在列表中搜索特定元素并返回其索引变得更容易。
让我们回顾一下indexOf
方法的实现:
indexOf(data, compareFunction = (a, b) => a === b) {
let current = this.#head;
let index = 0;
while (current) {
if (compareFunction(current.data, data)) {
return index;
}
index++;
current = current.next;
}
return -1;
}
我们首先创建一个变量current
来跟踪列表中的节点,它最初设置为列表的头部。我们还初始化一个索引变量为 0,代表列表中的当前位置。
当当前节点不是null
时(我们还没有到达列表的末尾),while
循环会继续。在每次迭代中,我们检查当前节点的数据属性是否与我们要搜索的元素匹配,如果是,则返回该元素位置的索引。
我们可以将自定义比较函数传递给indexOf
方法。这个函数应该接受两个参数(两个不同的对象)并返回true
,如果根据所需标准两个对象匹配,否则返回false
。有了这个函数,我们获得了灵活性,能够精确地定义元素的比较方式,以适应复杂的数据结构和不同的匹配标准。如果没有提供比较函数,默认情况下我们只是比较对象的引用。
使用比较函数也是其他编程语言中的标准做法。如果你愿意,我们可以在
LinkedList
类的构造函数中放置该函数,这样就可以在需要时使用它,而不是直接将函数传递给方法。
如果元素在当前节点中未找到,则增加index
并移动到下一个节点。
如果循环完成而没有找到元素,这意味着元素不在列表中。在这种情况下,该方法返回-1
(这是行业惯例)。
拥有一个indexOf
方法很有用,因为我们可以使用这个方法来搜索元素,我们也会重用它来从列表中删除元素。
从特定位置删除元素
让我们探索如何从我们的链表中删除元素。与追加类似,有两个场景需要考虑:删除第一个元素(头节点)和删除任何其他元素。
removeAt
代码如下所示:
removeAt(position) {
if (this.#size === 0) {
throw new RangeError('Cannot remove from an empty list.');
}
if (this.#isInvalidPosition(position)) {
throw new RangeError('Invalid position');
}
if (position === 0) {
return this.#removeFromHead();
}
return this.#removeFromMiddleOrEnd(position);
}
我们将逐步深入这段代码:
-
首先,我们检查列表是否为空,如果为空,则返回错误。
-
接下来,我们使用
#isInvalidPosition
辅助方法检查给定位置是否有效。 -
然后我们检查第一种情况:删除列表的第一个元素,如果是这样,我们将逻辑分离到一个单独的私有方法中,以获得更好的组织和理解。
-
最后,如果我们不是删除第一个元素,这意味着我们正在删除最后一个元素或链表中间的元素。对于单链表,这两种情况是相似的,因此我们将它们在单独的私有方法中处理。
虽然从链表中删除节点可能看起来很复杂,但将问题分解成更小、更易于管理的步骤可以简化过程。这种方法不仅对于链表操作是一种有价值的技巧,而且在各种现实场景中解决复杂任务时也非常有用。
让我们深入了解#removeFromHead
方法,这是我们第一个场景,用于从链表中删除第一个元素:
#removeFromHead() {
const nodeToRemove = this.#head;
this.#head = this.#head.next;
this.#size--;
return nodeToRemove.data;
}
如果position
是 0(表示头部),我们首先将头节点存储在nodeToRemove
中。然后,我们简单地移动head
指针到它的next
节点,从而有效地断开原始的head
。最后,我们减少链表的大小并返回被删除节点的数据。
以下图表展示了这一动作:
删除链表头部的元素
接下来,让我们检查删除链表中间或末尾节点的代码:
#removeFromMiddleOrEnd(position) {
let nodeToRemove = this.#head;
let previous;
for (let index = 0; index < position; index++) {
previous = nodeToRemove;
nodeToRemove = nodeToRemove.next;
}
// unlink the node to be removed
previous.next = nodeToRemove.next;
this.#size--;
return nodeToRemove.data;
}
对于除了位置 0 之外的其他位置,我们需要遍历链表以找到要删除的节点。在之前的章节中,我们使用了while
循环,现在我们将使用for
循环来展示有不同方式可以达到相同的结果。
我们保留两个变量,nodeToRemove
(从第一个元素开始)和previous
,以遍历链表。在每次迭代中,我们将previous
移动到当前节点(nodeToRemove
),并将nodeToRemove
移动到下一个节点。
一旦我们到达目标位置,previous
指向我们想要删除的节点之前的节点,而nodeToRemove
指向节点本身。我们调整previous
节点的next
指针以跳过nodeToRemove
节点,直接链接到nodeToRemove
之后的节点。这实际上删除了nodeToRemove
节点。然后我们可以减少链表的大小并返回被删除的数据。
以下图表展示了从链表中间删除元素的过程:
从链表中间删除元素
这种逻辑也适用于链表的最后一个元素,因为nodeToRemove
的下一个值将是null
,当previous.next
接收到null
时,它将自动断开最后一个元素的链接。以下图表展示了这一动作:
删除链表的最后一个元素
现在我们知道了如何从列表中删除任何元素,让我们学习如何搜索特定元素然后删除它。
在链表中搜索和删除元素
有时候,我们需要在不了解其确切位置的情况下从链表中删除一个元素。在这种情况下,我们需要一个基于其数据搜索元素并删除它的方法。我们将创建一个接受目标数据和可选的compareFunction
以进行自定义比较逻辑的remove
方法:
remove(data, compareFunction = (a, b) => a === b) {
const index = this.indexOf(data, compareFunction);
if (index === -1) {
return null;
}
return this.removeAt(index);
}
该方法首先利用我们之前创建的indexOf
方法来确定第一个数据与提供的data
匹配的节点的位置(index
),使用可选的compareFunction
。
如果indexOf
返回-1,则表示元素未在列表中找到。在这种情况下,我们返回null
。如果找到元素,该方法将调用removeAt(index)
来删除该位置的节点。removeAt
方法返回被删除的数据,然后该方法也返回该数据。
检查是否为空、清除和获取当前大小
isEmpty
、获取size
和clear
方法与我们在上一章中创建的方法非常相似,为管理我们的链表提供了基本操作。让我们无论如何看看它们:
isEmpty() {
return this.#size === 0;
}
get size() {
return this.#size;
}
clear() {
this.#head = null;
this.#size = 0;
}
这里有一个解释:
-
isEmpty
:此方法检查链表是否为空。它是通过简单地比较私有#size
属性与零来完成的。如果#size
是 0,则表示列表没有元素,并返回true
;否则,它返回false
。 -
size
:此方法直接返回私有#size
属性的值,提供链表中当前元素的数量。 -
clear
:此方法用于完全清空链表。它是通过将#head
指针设置为null
来完成的,从而有效地断开所有节点。#size
属性也被重置为 0。
将链表转换为字符串
最后一个方法是toString
方法,其主要目标是提供链表的字符串表示形式。这对于调试、记录或简单地向用户显示列表内容非常有用:
toString() {
let current = this.#head;
let objString = '';
while (current) {
objString += this.#elementToString(current.data);
current = current.next;
if (current) {
objString += ', ';
}
}
return objString;
}
初始化一个临时变量current
以指向链表的head
。这将是我们在遍历列表时的游标。创建一个空字符串objString
以累积列表的字符串表示形式。
while
循环在current
不是null
的情况下继续。这意味着我们将遍历列表中的每个节点,直到我们到达末尾(最后一个节点的next
属性是null
)。
被称为#elementToString
的私有方法(我们在前面的章节中已经编写过)被调用来将存储在current
节点(current.data
)中的数据转换为字符串表示形式。然后,这个字符串被附加(添加)到objString
中。我们将current
游标向前推进到列表的next
节点,如果有下一个节点(我们还没有到达末尾),则将逗号和空格附加到objString
中以分隔最终字符串表示中的元素。
双向链表
双链表与普通或单链表之间的区别在于,在链表中我们只从一个节点到下一个节点建立链接,而在双链表中,我们有一个双链接:一个用于下一个元素,一个用于前一个元素,如下面的图所示:
带有前一个和下一个节点的双链表
让我们开始实施DoublyLinkedList
类所需的变化。我们首先声明我们的双链表节点:
class DoublyLinkedListNode {
constructor(data, next = null, previous = null) {
this.data = data;
this.next = next;
this.previous = previous; // new
}
}
在双链表中,每个节点维护两个引用:
-
next
:指向列表中下一个节点的指针。 -
previous
:指向列表中前一个节点的指针。
这种双向链接使得双向遍历变得高效。为了适应这种结构,我们在DoublyLinkedListNode
类中添加了previous
指针。构造函数被设计成灵活的。默认情况下,next
和previous
指针都被初始化为null
。这允许我们创建尚未连接到列表中其他节点的节点。当将节点插入列表中时,我们明确更新这些指针以在列表内建立正确的链接。
接下来,我们将声明我们的DoublyLinkedList
类:
class DoublyLinkedList {
#head;
#tail; // new
#size = 0;
// other methods
}
双链表的一个关键区别是它跟踪了head
(第一个节点)和tail
(最后一个节点)。这种双向链接使我们能够高效地在两个方向上遍历列表,与单链表相比提供了更大的灵活性。
虽然双链表的核心功能与单链表相似,但实现方式不同。在双链表中,我们必须为每个节点管理两个引用:next
(指向后续节点)和previous
(指向前一个节点)。因此,在插入或删除节点时,我们不仅需要仔细更新下一个指针(如单链表中的情况),还需要更新前一个指针以保持整个列表的正确链接。这意味着像append
、prepend
、insert
和removeAt
这样的方法将需要修改以适应这种双向链接。
让我们深入了解每个必要的修改。
添加新元素
在双链表中插入新元素与链表非常相似。区别在于在链表中我们只控制一个指针(next
),而在双链表中我们需要控制next
和previous
两个引用。
让我们看看append
方法在双链表中的表现:
append(data) {
const newNode = new DoublyLinkedListNode(data);
if (!this.#head) { // empty list
this.#head = newNode;
this.#tail = newNode;
} else { // non-empty list
newNode.previous = this.tail;
this.#tail.next = newNode;
this.#tail = newNode;
}
this.#size++;
}
当我们试图将新元素添加到列表末尾时,会遇到两种不同的场景:如果列表为空或非空。如果列表为空(head
为null
),我们创建一个新的节点(newNode
)并将head
和tail
都设置为这个新节点。由于它是唯一的节点,它既是开始也是结束。
如果列表不为空,双链表的优点是我们不需要遍历整个列表就能到达其末尾。因为我们有tail
引用,我们可以简单地将新节点的previous
指针链接到尾部,然后我们将尾部的next
指针链接到新节点,最后我们将tail
引用更新为新节点。这些操作的顺序至关重要,因为如果我们提前更新tail
,我们将失去对原始最后一个节点的引用,这将使得无法正确地将新节点链接到列表的末尾。以下图示了将新节点添加到列表末尾的过程:
在双链表中添加新节点
接下来,让我们回顾并修改需要添加到双链表中的元素。
在双链表中添加新元素到头部
在双链表中添加新元素到头部与在单链表中添加新元素到头部没有太大区别:
prepend(data) {
const newNode = new DoublyLinkedListNode(data);
if (!this.#head) { // empty list
this.#head = newNode;
this.#tail = newNode;
} else { // non-empty list
newNode.next = this.#head;
this.#head.previous = newNode;
this.#head = newNode;
}
this.#size++;
}
再次,我们有两种情况。如果列表为空,其行为与append
方法相同,新节点同时成为head
和tail
。
如果列表不为空,我们将新节点(newNode
)的next
指针设置为当前的head
。然后我们更新当前头节点的previous
指针以引用newNode
。最后,我们将head
更新为newNode
,因为它现在是列表中的第一个节点。
在单链表中, prepend 操作只需要更新新节点的next
指针和列表的head
。然而,在双链表中,我们必须还更新原始头节点的previous
指针以确保双向链接得到维护。
现在我们能够向列表的头部和尾部添加元素,让我们来看看如何在任何位置插入。
在任何位置插入新元素
在双链表中任意位置插入元素需要比简单地追加或 prepend 考虑更多的因素。让我们看看如何在任何位置插入:
insert(data, position) {
if (this.isInvalidPosition(position)) {
return false;
}
if (position === 0) { // first position
this.prepend(data);
return true;
}
if (position === this.#size) { // last position
this.append(data);
return true;
}
// middle position
return this.#insertInTheMiddle(data, position);
}
让我们逐个案例进行回顾:
-
首先,我们检查位置是否有效,如果不是,我们返回
false
以指示插入操作未成功。 -
接下来,我们将检查插入操作是否在
head
处,如果是的话,我们可以重用prepend
方法,并返回true
以指示插入操作成功。 -
下一种情况是,如果插入操作在列表的末尾,那么我们可以重用
append
方法并返回true
。检查这种情况将避免遍历整个列表以到达其末尾。 -
如果既不是在头部添加也不是在尾部添加,这意味着位置在列表的中间,对于这种情况,我们将创建一个私有方法来保存逻辑。
如果位置在中间,我们将使用#insertInTheMiddle
来帮助我们更好地组织步骤,如下所示:
#insertInTheMiddle(data, position) {
const newNode = new DoublyLinkedListNode(data);
let currentNode = this.#head;
let previousNode;
for (let index = 0; index < position; index++) {
previousNode = currentNode;
currentNode = currentNode.next;
}
newNode.next = currentNode;
newNode.previous = previousNode;
currentNode.previous = newNode;
previousNode.next = newNode;
this.#size++;
return true;
}
因此,我们将创建一个新节点,并将遍历列表到所需的位置。在for
循环之后,我们将按照以下步骤在之前的引用和当前引用之间插入新节点:
-
newNode
的next
指针被设置为currentNode
。 -
newNode
的previous
指针被设置为previousNode
。通过这两个步骤,我们已经将新节点部分插入到列表中。我们现在需要更新列表中现有节点的引用到新节点。 -
currentNode
的previous
指针被更新,以指向newNode
。 -
previousNode
的next
指针被更新,以指向newNode
。
以下图表展示了这一场景:
在双链表的中间插入一个新节点
由于我们有一个对列表的
head
和tail
的引用,我们可以对这个方法进行改进,即检查位置是否大于大小/2,如果是,则最好从末尾开始迭代而不是从开头开始(这样做,我们将不得不遍历列表中的更少元素)。
现在我们已经了解了如何处理列表中插入节点时两个指针的细节,让我们看看如何从任何位置删除一个元素。
从特定位置删除一个元素
让我们深入了解删除列表中任何位置的元素的细节和区别:
removeAt(position) {
if (this.#size === 0) {
throw new RangeError('Cannot remove from an empty list.');
}
if (this.#isInvalidPosition(position)) {
throw new RangeError('Invalid position.');
}
if (position === 0) {
return this.#removeFromHead();
}
if (position === this.#size - 1) {
return this.#removeFromTail();
}
return this.#removeFromMiddle(position);
}
我们首先检查列表是否为空,以及是否有一个无效的位置。如果列表为空或给定位置超出了列表的有效范围(0 到 size-1),我们将抛出一个RangeError
。
接下来,我们将检查三种可能的情况:
-
如果是从头部(列表的第一个位置)进行删除
-
如果是从尾部(列表的最后一个位置)进行删除
-
或从列表的中间删除。
让我们深入了解每个场景。
第一种情况是删除第一个元素。以下是#removeFromHead
私有方法的代码:
#removeFromHead() {
const nodeToRemove = this.#head;
this.#head = nodeToRemove.next;
if (this.#head) {
this.#head.previous = null;
} else {
this.#tail = null; // List becomes empty
}
this.#size--;
nodeToRemove.next = null;
return nodeToRemove.data;
}
我们首先创建一个引用(nodeToRemove
)到当前头节点。这很重要,因为我们需要稍后返回其数据。接下来,head
引用现在移动到列表中的下一个节点。
如果只有一个节点,nodeToRemove.next
将是null
,head
将变为null
,表示列表为空。
如果删除后列表不为空,新head
节点的previous
引用(之前是第二个节点)被设置为null
,因为它现在是第一个节点,没有前驱。
如果列表为空,则head
和tail
都需要设置为null
。由于我们在方法的第二行已经将head
设置为null
,我们只需要将tail
设置为null
。
最后,我们需要移除nodeToRemove
的下一个引用,以防万一。以下图表展示了这一场景:
从双链表的头部移除节点
下一个场景是检查我们是否在移除尾部(最后一个元素)。以下是对 #
removeFromTail
私有方法的代码:
#removeFromTail() {
const nodeToRemove = this.#tail;
this.#tail = nodeToRemove.previous;
if (this.#tail) {
this.#tail.next = null;
} else {
this.#head = null; // List becomes empty
}
this.#size--;
nodeToRemove.previous = null;
return nodeToRemove.data;
}
在创建对当前 tail
节点的引用后,将 tail
引用移动到 previous
节点。我们需要检查移除后列表是否为空——这与 removeFromHead
方法的行为类似。如果列表不为空,我们将新尾部的 next
指针设置为 null
。如果列表为空,我们还将 head
更新为 null
。以下图展示了这个场景:
从双链表的尾部移除节点
最后一个场景是从列表的中间移除元素。#removeFromMiddle
方法如下所示:
#removeFromMiddle(position) {
let nodeToRemove = this.#head;
let previousNode;
for (let index = 0; index < position; index++) {
previousNode = nodeToRemove;
nodeToRemove = nodeToRemove.next;
}
previousNode.next = nodeToRemove.next;
nodeToRemove.next.previous = previousNode;
nodeToRemove.next = null;
nodeToRemove.previous = null;
this.#size--;
return nodeToRemove.data;
}
由于位置不是 head
或 tail
,我们需要遍历列表以找到节点并正确调整周围节点的引用。我们首先声明一个 nodeToRemove
,将其引用到 head
作为起点,我们将在迭代过程中移动这个节点。previousNode
跟踪 nodeToRemove
之前的节点,它从 null
开始,因为头部没有前一个节点。
for
循环会一直继续,直到 index
与我们想要移除的位置 position
匹配。在循环内部,我们更新 previousNode
为当前节点(在我们移动它之前)并将 nodeToRemove
设置为下一个节点。
当循环停止时,nodeToRemove
将引用我们想要移除的节点。因此,我们通过使 previousNode
的 next
指针指向 nodeToRemove
后的节点来跳过对 nodeToRemove
的引用。
然后,nodeToRemove.next.previous = previousNode
更新了 nodeToRemove
后的节点的 previous
指针,使其指向 previousNode
。这一步对于维护双链表的结构至关重要。
最后,我们移除 nodeToRemove
的下一个和前一个引用,减少列表的大小,并返回被移除的数据。
下面的图展示了这个场景:
从双链表的中间移除节点
要检查双链表其他方法的实现(因为它们与链表相同),请参考书籍的源代码。源代码的下载链接在书的序言中提到,也可以在以下网址访问:
github.com/loiane/javascript-datastructures-algorithms
。
循环链表
循环链表是链表的一种变体,其中最后一个节点的下一个指针(或 tail.next
)引用第一个节点(head
),而不是 null
或 undefined
。这创建了一个封闭的环状结构,如下面的图所示:
循环链表的结构
双向循环链表有tail.next
指向头元素,head.previous
指向tail
元素,如下所示:
双向循环链表的结构
循环链表与常规(线性)链表的关键区别在于,循环链表没有明确的结束。你可以从任何节点开始连续遍历列表,最终返回到起点。
我们将实现一个单链循环链表,你可以在本书的源代码中找到双向循环链表的奖励源代码。
让我们查看创建CircularLinkedList
类的代码:
class CircularLinkedList {
#head;
#size = 0;
// other methods
}
我们将利用相同的LinkedListNode
结构为我们的CircularLinkedList
类,因为基本的节点结构保持不变。然而,列表的循环性质引入了一些关键差异,这些差异体现在我们实现append
、prepend
和removeAt
等操作的方式上。让我们详细探讨这些修改。
追加新元素
将新元素追加到循环链表与追加到标准链表有相似之处,但由于循环结构,有一些关键的区别。让我们看看append
方法的代码:
append(data) {
const newNode = new LinkedListNode(data);
if (!this.#head) { // empty list
this.#head = newNode;
newNode.next = this.#head; // points to itself
} else { // non-empty list
let current = this.#head;
while (current.next !== this.#head) {
current = current.next;
}
current.next = newNode;
newNode.next = this.#head; // circular reference
}
this.#size++;
}
我们首先创建一个新节点来保存数据。然后检查列表是否为空。如果是,新节点成为head
,其下一个指针设置为指向自身,完成循环。以下图例展示了第一种情况:
在循环链表中追加一个元素作为唯一的节点
如果列表不为空,我们需要找到最后一个节点。这是通过从head
开始遍历列表,直到找到一个其下一个指针指向head
的节点来完成的。
一旦找到最后一个节点(current
),其下一个指针被更新为引用newNode
。然后newNode
的下一个指针设置为指向head
,重新建立循环链接。以下图例展示了第二种情况:
非空循环链表中追加元素
接下来,让我们看看如何在循环链表的开始处插入一个新节点。
预先添加一个新元素
由于循环结构的性质,在循环链表中预先添加一个新元素涉及几个关键步骤。让我们首先查看代码:
prepend(data) {
const newNode = new LinkedListNode(data, this.#head);
if (!this.head) {
this.head = newNode;
newNode.next = this.head; // make it circular
} else {
// Find the last node
let current = this.head;
while (current.next !== this.head) {
current = current.next;
}
current.next = newNode;
this.head = newNode;
}
this.#size++;
}
我们首先创建一个新节点来保存数据。新节点的next
指针立即设置为该列表的当前head
,以保持插入后的列表的循环性质。
如果列表为空,新节点成为head
,我们添加一个自引用作为下一个指针。
如果列表不为空,我们找到最后一个元素,因此我们可以更新它的next
指针到新节点,该节点将成为新的head
。下面的图例展示了这一动作:
在非空循环链表中预加一个元素
如果我们想在列表中间插入一个新元素,代码与LinkedList
类相同,因为不会对列表的最后一个或第一个节点应用任何更改。
从特定位置移除元素
对于循环链表元素的移除,我们将介绍移除第一个和最后一个元素,因为从中间移除元素的行为与单链表相同。
首先,让我们看看如何从头部移除,代码如下:
#removeFromHead() {
const nodeToRemove = this.#head;
let lastNode = this.#head;
while (lastNode.next !== this.#head) { // Find the last node
lastNode = lastNode.next;
}
this.#head = nodeToRemove.next; // skip the head
lastNode.next = this.#head; // make it circular
if (this.#size === 1) { // only one node
this.#head = null;
}
this.#size--;
return nodeToRemove.data;
}
以下是对此的解释:
-
首先,我们遍历列表以找到最后一个节点。
-
接下来,我们将头节点设置为下一个节点以移除第一个元素。
-
然后我们将最后一个节点指向新的
head
,闭合循环。 -
最后,如果移除的节点是列表中唯一的节点,将
head
设置为null
以重置它。
下面的图例展示了这一动作:
从循环链表中移除头部
现在,让我们看看如何从列表末尾移除:
#removeFromTail() {
if (this.#head.next === this.#head) { // single node case
const nodeToRemove = this.#head;
this.#head = null;
this.#size--;
return nodeToRemove.data;
} else {
let lastNode = this.#head;
let previousNode = null;
while (lastNode.next !== this.#head) { // Find the last node
previousNode = lastNode;
lastNode = lastNode.next;
}
previousNode.next = this.#head; // skip the last node to remove it
this.#size--;
return lastNode.data;
}
}
以下是对此的解释:
-
如果只有一个节点(
head
指向自身),移除它会使列表为空。我们更新head
为null
并返回移除的数据。 -
如果列表不为空,我们需要找到最后一个节点和第二个最后一个节点(
previousNode
)。因此,我们遍历列表直到其末尾,更新previous
和last
节点引用。 -
当
while
循环结束时,lastNode
就是我们想要移除的节点。因此,我们设置previousNode.next = this.#head
,使第二个最后一个节点指向头部,跳过现在已被移除的最后一个节点。 -
我们减少列表大小并返回移除的
data
。
下面的图例展示了这一动作:
从循环链表中移除尾部
现在我们已经知道如何从三种不同类型的链表中添加和移除元素,让我们通过创建一个有趣的项目来应用我们学到的所有这些概念!
使用链表创建媒体播放器
为了巩固我们对链表的理解并探索新的概念,让我们构建一个现实世界的应用:一个媒体播放器!这个项目将利用链表结构,并介绍我们如何使用双循环链表和有序插入等额外技术。
在我们深入之前,让我们概述一下媒体播放器的核心功能:
-
有序歌曲插入:根据歌曲标题将歌曲添加到播放列表中。
-
顺序播放:模拟按播放列表顺序播放歌曲。
-
导航:轻松跳转到上一首或下一首歌。
-
连续重复:自动循环播放列表,重复播放歌曲。
以下图像表示我们将使用链表开发的媒体播放器:
使用双循环链表的多媒体播放器
为了模拟我们的媒体播放器的播放列表,我们将使用自定义节点结构来表示每首歌曲。以下是 MediaPlayerSong
类:
class MediaPlayerSong {
constructor(songTitle) {
this.songTitle = songTitle;
this.previous = null;
this.next = null;
}
}
每个 MediaPlayerSong
节点存储:
-
songTitle
: 歌曲的标题。 -
previous
: 对播放列表中前一个歌曲节点的引用(如果是第一首歌曲,则是最后一个歌曲的引用)。 -
next
: 对播放列表中下一个歌曲节点的引用(如果是最后一首歌曲,则是第一个歌曲的引用)。
这将使我们能够实现媒体播放器的连续重复功能。
接下来,让我们定义我们的 MediaPlayer
类的结构:
class MediaPlayer {
#firstSong;
#lastSong;
#size = 0;
#playingSong;
// other methods
}
MediaPlayer
类维护:
-
#firstSong
: 对播放列表中第一个MediaPlayerSong
的引用。 -
#lastSong
: 对播放列表中最后一个MediaPlayerSong
的引用。 -
#size
: 当前播放列表中歌曲的数量。 -
#playingSong
: 对当前正在播放的MediaPlayerSong
的引用。
在我们开始播放歌曲之前,我们需要能够将歌曲添加到我们的播放列表中。让我们看看下一节如何实现。
按标题顺序添加新歌曲(排序插入)
为了保持字母顺序的播放列表,我们将实现一个名为 addSongByTitle
的方法。此方法将根据歌曲的标题将新歌曲插入正确的位置,确保播放列表保持排序。
在幕后,我们正在双循环链表中执行排序插入操作!
我们将首先声明插入新歌曲的方法:
addSongByTitle(newSongTitle) {
const newSong = new MediaPlayerSong(newSongTitle);
if (this.#size === 0) { // empty list
this.#insertEmptyPlayList(newSong);
} else {
const position = this.#findIndexOfSortedSong(newSongTitle);
if (position === 0) { // insert at the beginning
this.#insertAtBeginning(newSong);
} else if (position === this.#size) { // insert at the end
this.#insertAtEnd(newSong);
} else { // insert in the middle
this.#insertInMiddle(newSong, position);
}
}
this.#size++;
}
在我们深入了解细节之前,这里有一个简要的解释:
-
我们首先使用给定的
newSongTitle
创建一个新的MediaPlayerSong
节点。 -
如果播放列表为空,我们调用私有方法
#insertEmptyPlayList
来处理第一首歌曲的插入。 -
对于非空播放列表,我们调用私有方法
#findIndexOfSortedSong
来确定新歌曲应该插入的正确位置
以保持字母顺序。 -
根据返回的
位置
,方法将插入操作调度到三个私有方法之一:-
#insertAtBeginning
: 在列表开头插入新歌曲。 -
#insertAtEnd
: 在列表末尾插入新歌曲。 -
#insertInMiddle
: 在指定位置将新歌曲插入列表中间。
-
-
最后,将播放列表的
#size
增加以反映新歌曲的添加。
让我们更详细地回顾每个步骤。
向空播放列表插入
在这个场景中,我们正在处理双循环链表的插入。让我们深入了解 #insertEmptyPlayList
方法:
#insertEmptyPlayList(newSong) {
this.#firstSong = newSong;
this.#lastSong = newSong;
newSong.next = newSong; // points to itself
newSong.previous = newSong; // points to itself
}
我们将新歌曲分配给 firstSong
(头)和 lastSong
(尾)。为了保持循环引用,我们将新歌曲的 next
和 previous
引用设置为自身。
逻辑的下一步是找到在播放列表不为空的情况下需要插入歌曲的位置。
查找字母顺序排序的插入位置
我们正在处理一个复杂场景:将排序插入到双循环链表中。为了简化,我们将分两个阶段来处理,第一阶段是确定新歌曲应该插入以保持字母顺序的正确位置(索引),第二阶段是实际的插入操作。所以,现在让我们专注于找到正确的插入索引:
#findIndexOfSortedSong(newSongTitle) {
let currentSong = this.#firstSong;
let i = 0;
for (; i < this.#size && currentSong; i++) {
const currentSongTitle = currentSong.songTitle;
if (this.#compareSongs(currentSongTitle, newSongTitle) >= 0) {
return i;
}
currentSong = currentSong.next;
}
return 0;
}
我们将遍历列表以找到插入的位置。为此,我们将使用一个名为 currentSong
的光标。我们还需要一个索引计数器 i
。
我们将从播放列表的第一首歌曲循环到最后一首歌曲。在循环内部,我们将调用一个包含比较歌曲逻辑的辅助方法。如果辅助方法的结果是 0(重复歌曲)或正数,这意味着我们找到了位置。
如果新歌曲不属于当前位置,我们将移动到列表中的下一首歌曲。如果循环完成而没有返回,这意味着新歌曲应该插入到开始位置(索引 0)。
接下来,让我们检查 #compareSongs
方法的代码:
#compareSongs(songTitle1, songTitle2) {
return songTitle1.localeCompare(songTitle2);
}
此方法是一个辅助函数,用于按字母顺序比较两首歌曲标题,考虑地区特定的排序规则。localeCompare 方法返回一个数字,表示两个字符串之间的排序关系:
-
负数:
songTitle1
在字母顺序中排在songTitle2
之前。 -
0(零):在当前地区,
songTitle1
和songTitle2
被认为是相等的。 -
正数:
songTitle1
在字母顺序中排在songTitle2
之后。
您可以根据需要修改此方法以自定义您想要比较歌曲标题的方式。
现在我们知道了需要插入的位置,让我们回顾一下每个方法。
在播放列表开头插入
让我们检查如何在非空播放列表中添加一首新歌曲:
#insertAtBeginning(newSong) {
newSong.next = this.#firstSong;
newSong.previous = this.#lastSong;
this.#firstSong.previous = newSong;
this.#lastSong.next = newSong;
this.#firstSong = newSong;
}
给定我们的下一首歌曲,我们将将其 next
引用指向第一首歌曲(头),并将其 last
引用指向最后一首歌曲(尾)。然后,我们更新现有第一首歌曲的前一个引用到新歌曲,并更新最后一首歌曲的 next
引用到新歌曲。最后,我们更新第一首歌曲引用到新歌曲。
接下来,让我们看看如何追加一首新歌曲。
在播放列表末尾插入
让我们回顾一下如何使用以下方法在播放列表末尾添加新歌曲:
#insertAtEnd(newSong) {
newSong.next = this.#firstSong;
newSong.previous = this.#lastSong;
this.#lastSong.next = newSong;
this.#firstSong.previous = newSong;
this.#lastSong = newSong;
}
给定新歌曲,当插入到末尾时,我们需要将其下一个引用链接到第一首歌曲,并将其上一个引用链接到最后一首歌曲,这样我们就可以保持双重循环引用。然后,我们需要更新最后一首歌曲的下一个引用为新歌曲,第一首歌曲的前一个引用也更新为新歌曲以保持循环引用,最后,更新最后一首歌曲的引用为新歌曲。
现在,最后一步是在播放列表的中间插入一首歌曲。
在播放列表中间插入
现在我们已经涵盖了在双链表的头部和尾部插入,让我们深入了解如何在列表的中间插入一个新元素,如下所示:
#insertInMiddle(newSong, position) {
let currentSong = this.#firstSong;
for (let i = 0; i < position - 1; i++) {
currentSong = currentSong.next;
}
newSong.next = currentSong.next;
newSong.previous = currentSong;
currentSong.next.previous = newSong;
currentSong.next = newSong;
}
在中间插入与在双链表中插入相同。因为我们既有前一个也有下一个引用,所以我们不需要两个引用。所以首先,我们找到我们正在寻找的位置,并在我们想要的位置之前停止。然后,我们将新歌曲的下一个引用链接到当前歌曲的下一个引用,并将新歌曲的前一个引用链接到当前歌曲。通过这一步,我们已经将新歌曲插入到列表中,现在我们需要修复剩余的链接。因此,我们修复当前歌曲的下一个节点的上一个引用为新歌曲,以及当前歌曲的下一个引用为新歌曲。
将歌曲添加到播放列表后,我们可以开始播放它们!
播放歌曲
当我们选择媒体播放器的播放歌曲功能时,目标是开始播放歌曲。对于我们的模拟,这意味着将第一首歌曲分配给正在播放的歌曲引用,如下所述:
play() {
if (this.#size === 0) {
return null;
}
this.#playingSong = this.#firstSong;
return this.#playingSong.songTitle;
}
如果播放列表中没有歌曲,我们可以返回null
,或者如果您愿意,也可以抛出一个错误。然后,我们将正在播放的歌曲的引用分配给第一首歌曲,并返回我们正在播放的标题。
播放下一首或上一首歌曲
播放下一首或上一首歌曲的行为非常相似。区别在于我们正在更新的引用:“前一个”或“下一个”。让我们首先回顾播放下一首歌曲的行为:
next() {
if (this.#size === 0) {
return null;
}
if (!this.#playingSong) {
return this.play();
}
this.#playingSong = this.#playingSong.next;
return this.#playingSong.songTitle;
}
如果播放列表中没有歌曲,我们返回null
。同样,如果此时没有歌曲正在播放,我们将播放第一首歌曲。然而,如果有歌曲正在播放,而我们决定想播放下一首歌曲,我们只需更新正在播放的歌曲为其下一个引用,并返回歌曲标题。
之前方法的代码也非常相似:
previous() {
if (this.#size === 0) {
return null;
}
if (!this.#playingSong) {
return this.play();
}
this.#playingSong = this.#playingSong.previous;
return this.#playingSong.songTitle;
}
差别在于如果有歌曲正在播放,而我们想播放上一首歌曲,我们只需更新当前歌曲为其上一个引用。
在这两种情况下,当我们到达播放列表的末尾或播放列表的第一首歌曲时,我们可以继续播放,因为有了双重循环引用。
使用我们的媒体播放器
既然我们已经构建了我们的媒体播放器,让我们来测试一下。我们将从创建一个实例并添加我们最喜欢的歌曲开始:
const mediaPlayer = new MediaPlayer();
mediaPlayer.addSongByTitle('The Bard\'s Song');
mediaPlayer.addSongByTitle('Florida!!!');
mediaPlayer.addSongByTitle('Run to the Hills');
mediaPlayer.addSongByTitle('Nothing Else Matters');
在我们的播放列表创建后,我们可以开始播放歌曲并查看输出:
console.log('Playing:', mediaPlayer.play()); // Florida!!!
我们可以多次选择下一首歌曲并检查连续播放是否按以下方式工作:
console.log('Next:', mediaPlayer.next()); // Nothing Else Matters
console.log('Next:', mediaPlayer.next()); // Run to the Hills
console.log('Next:', mediaPlayer.next()); // The Bard's Song
console.log('Next:', mediaPlayer.next()); // Florida!!!
如果我们反过来选择上一个按钮:
console.log('Previous:', mediaPlayer.previous()); // The Bard's Song
console.log('Previous:', mediaPlayer.previous()); // Run to the Hills
console.log('Previous:', mediaPlayer.previous()); // Nothing Else Matters
console.log('Previous:', mediaPlayer.previous()); // Florida!!!
如果我们回顾输出,可以确认歌曲是按字母顺序插入的。
享受使用我们的媒体播放器吧!
检查链表的效率
让我们通过查看每种方法的执行时间的大 O 符号来检查每个方法的效率:
方法 | 单链表 | 双链表 | 循环链表 | 说明 |
---|---|---|---|---|
append |
O(n) | O(1) | O(n) | 在单链表和循环链表中,我们必须遍历到末尾才能追加。双链表有尾引用,可以实现常数时间的追加。 |
prepend |
O(1) | O(1) | O(n) | 所有列表都可以直接添加新节点作为头部。然而,在循环链表中,我们必须更新最后一个节点的下一个指针到新的头部。 |
insert |
O(n) | O(n) | O(n) | 对于所有类型,我们都需要遍历到插入位置。 |
removeAt |
O(n) | O(n) | O(n) | 与插入类似,需要遍历到指定位置。双链表在删除尾部时有一个优化(O(1)),但这比从任意位置删除要少见。 |
remove |
O(n) | O(n) | O(n) | 在所有情况下,搜索数据需要 O(n),然后删除本身要么是 O(1)(如果节点在头部找到)或 O(n)(遍历到节点)。 |
indexOf |
O(n) | O(n) | O(n) | 在最坏的情况下,我们可能需要遍历整个列表来找到数据或确定其不存在。 |
isEmpty |
O(1) | O(1) | O(1) | 检查列表是否为空是一个简单的尺寸参考检查。 |
size |
O(1) | O(1) | O(1) | 大小作为一个属性跟踪,可以直接访问。 |
clear |
O(1) | O(1) | O(1) | 清空列表只需重置头指针(在双链表中还包括尾指针),这是一个常数时间操作。 |
toString |
O(n) | O(n) | O(n) | 构建字符串表示需要访问每个节点。 |
由于尾指针的存在,双链表在追加操作中通常具有性能优势。否则,所有三种列表类型在大多数操作中的时间复杂度相似,因为它们都涉及某种程度的遍历。
所有三种类型的空间复杂度都是 O(n),因为使用的空间与存储的元素数量成正比。
如果我们要将链表与数组进行比较,每种数据结构都有其优缺点。让我们回顾几个关键点:
-
链表:在以下情况下优先选择链表:
-
您需要一个动态集合,其中元素的数目频繁变化。
-
您经常在列表的开始或中间进行插入和删除操作。
-
您不需要随机访问元素。
-
-
数组:在以下情况下优先选择数组:
-
您事先知道集合的最大大小。
-
您需要通过索引快速访问元素。
-
您主要需要按顺序遍历元素。
-
我们在这本书的前面也学习了栈、队列和双端队列,并且我们内部使用了数组。这些数据结构也可以使用链表实现。那么,每种数据结构最好的实现方式是什么?在决定时,我们需要考虑以下因素:
-
操作频率:如果你经常需要通过索引(随机访问)访问元素,数组可能是一个更好的选择。如果开始或中间的插入和删除操作很常见,链表可能更适合。
-
内存限制:如果内存是一个重要的问题,并且你知道数据结构的最大大小,数组可能更节省内存。然而,如果大小高度可变,链表可以通过不预留未使用的内存来节省空间。
-
简单性与灵活性:数组实现通常更容易编码。链表提供了更多动态调整大小和高效修改的灵活性。
当涉及到答案时,这完全取决于我们将要执行的最多的操作(以及在哪里)以及我们需要存储数据的空间。
对于栈和队列,由于它们的简单性,数组实现通常是默认选择。然而,如果你需要实现一个具有非常频繁操作(如 push/pop 和 queue/dequeue)的队列,一个具有头和尾引用的双向链表可能更有效率。对于双端队列,双向链表是自然的选择,因为它们允许在两端以常数时间进行高效的插入和删除。
由于我们已经学习了链表,这是一种多功能的动态数据结构,现在用你的知识来测试一下!尝试使用链表而不是数组来重新实现经典的数据结构,如栈、双端队列和队列。这个动手练习将加深你对链表和这些抽象数据类型的理解。此外,你可以比较你的基于链表的版本与基于数组的版本的性能和特性。作为参考,你可以在本书的源代码中找到这些链表实现。
让我们通过一些练习来将我们的知识付诸实践。
练习
我们将解决一个来自LeetCode的练习,这样我们可以学习本章尚未涉及的概念。
然而,在 LeetCode 上有很多有趣的链表练习,我们应该能够用本章学到的概念来解决。以下是一些额外的建议,你可以尝试解决,你还可以在本书的源代码中找到解决方案和解释:
-
- 加法两个数:遍历两个链表并求和每个数字。
-
- 旋转列表:从尾部移除节点并将它们预加到列表中。
-
- 删除链表元素:遍历列表并检查需要删除的值。提示:保持一个前向引用可以使删除更容易。
-
- 回文链表:检查链表的元素是否是回文。
-
- 设计循环双端队列:实现双端队列数据结构。
-
- 设计循环队列实现队列数据结构。
反转链表
我们将要解决的练习是可在 leetcode.com/problems/reverse-linked-list/description/
找到的 206. 反转链表 问题。
当使用 JavaScript 或 TypeScript 解决这个问题时,我们需要在函数 function reverseList(head: ListNode | null): ListNode | null
中添加我们的逻辑,该函数接收链表的头节点,并期望一个表示反转链表头节点的节点。ListNode
类由一个 val
(数字)和一个 next
指针组成。
让我们编写 reverseList
函数:
function reverseList(head: ListNode | null): ListNode | null {
let current = head;
let newHead = null;
let nextNode = null;
while (current) {
nextNode = current.next;
current.next = newHead;
newHead = current;
current = nextNode;
}
return newHead;
}
为了更好地理解代码中发生的事情,让我们使用一些图表。我们将使用练习提供的示例,这是一个具有以下值的链表:[1, 2, 3, 4, 5],并期望以下结果:[5, 4, 3, 2, 1]。
对于这个练习,我们将使用三个变量:
-
current
指向列表的头部。 -
newHead
开始时为null
,代表反转链表的新头节点。它也是我们将作为函数结果返回的变量。 -
nextNode
是原始列表中下一个节点的一个临时指针。
逻辑仅由一个循环组成,该循环将遍历整个列表。在循环内部,我们有四个重要的操作:
-
nextNode = current.next
:在我们修改当前节点的链接之前保存下一个节点。 -
current.next = newHead
:反转当前节点的链接,使其指向前一个节点(现在是newHead
)。 -
newHead = current
:将newHead
向前移动一步,使current
节点成为新的头节点。 -
current = nextNode
:将current
移动到下一个节点(之前存储在nextNode
中)。
在循环内部第一次遍历之后,列表将看起来是这样的:
while 循环内部第一次遍历后的反转链表
在循环的第二次遍历之后,列表将看起来是这样的:
while 循环内部第二次遍历后的反转链表
并且这个过程会一直持续到 current
是 null
并且链表被反转。这个解决方案通过了所有的测试并且解决了问题。
这个函数的时间复杂度是 O(n),其中 n 是链表中的节点数量。空间复杂度是 O(1),因为我们只使用了额外的变量来跟踪节点,并且我们没有使用任何额外的空间,因为我们的解决方案是在原地反转链表。
回到
LinkedList
、DoublyLinkedList
和CircularLinkedList
类,创建一个方法来原地反转每个列表,遵循我们用来解决这个练习的类似逻辑。你也会在这个书的源代码中找到这个方法。
概述
本章探讨了链表及其变体:单链表、双链表和循环链表。我们涵盖了插入、删除和遍历技术,强调了链表由于其动态特性,在频繁添加和删除元素方面相较于数组具有的优势。
为了巩固我们的知识,我们构建了一个媒体播放器,应用了双循环链表和有序链表等概念。我们还解决了一个 LeetCode 挑战,就地反转链表,增加了额外的趣味性。
准备好了!接下来,我们将深入探讨集合,这是一种独特的用于存储不同元素的数据结构。
第八章:7 集合
开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“学习 JavaScript 数据结构与算法”第四版下的“EARLY ACCESS SUBSCRIPTION”中找到“learning-javascript-dsa-4e”频道)。
建立在您对顺序数据结构的了解之上,本章将带您进入独特的集合世界,这是一个只存储唯一值的集合。我们将介绍创建集合、添加或删除元素以及高效检查成员资格的基本原理。然后我们将发现如何利用集合的并集、交集和差集等数学运算的强大功能。为了使事情更加简单,我们将探索 JavaScript 的内置 Set
类,为您提供直接处理集合的便捷工具。因此,在本章中,我们将涵盖:
-
从零开始创建 Set 类
-
使用集合执行数学运算
-
JavaScript 原生 Set 类
-
练习
集合数据结构
集合是数学和计算机科学中的基本概念。它是一个无序的不同项(元素)的集合。把它想象成一个袋子,你可以往里放东西,但你放东西的顺序不重要,而且你不能有重复。
集合是数学和计算机科学中的基本概念,在各个领域的实际应用中有着众多的应用。
在我们深入计算机科学实现之前,先看看集合的数学概念。在数学中,集合是一组不同的对象。例如,我们有一个自然数集合,它由大于或等于 0 的整数组成——即 N = {0, 1, 2, 3, 4, 5, 6, ...}。集合中对象的列表被大括号 {}
包围。
还有空集的概念。没有元素的集合称为空集或空集。例如,24 和 29 之间的质数集合。由于在 24 和 29 之间没有质数(一个大于 1 的自然数,除了 1 和它本身没有其他正除数),该集合将是空的。我们将用 {}
来表示空集。
在数学中,集合也有一些基本操作,如并集、交集和差集。我们也将在这章中介绍这些操作。
在计算机科学中,例如,集合用于模拟数据之间的关系,并执行过滤、排序和搜索等操作。集合还可以从列表等其他集合中删除重复元素,非常有用。
您也可以想象集合为一个没有重复元素且没有顺序概念的数组。
创建 MySet 类
ECMAScript 2015 (ES6) 将原生的 Set
类引入 JavaScript,提供了一种内置且高效的方式来处理集合。然而,理解集合的底层实现对于掌握数据结构和算法至关重要。我们将深入了解创建我们自己的自定义 MySet
类,该类模仿原生 Set
的功能,但具有额外的功能,如并集、交集和差集操作。
我们的实现将位于 src/07-set/set.js
文件中。我们首先定义 MySet
类:
class MySet {
#items = {};
#size = 0;
}
我们选择名称 MySet
以避免与原生的 Set
类冲突。我们利用对象 ({}
) 而不是数组来存储 #items
私有属性中的元素。该对象的键代表集合的唯一值,而相应的值可以是任何东西(我们将使用 true 作为简单的占位符)。这种选择利用了 JavaScript 对象不能有重复键的事实,自然地强制集合元素的唯一性。虽然也可以使用数组,但它们需要额外的逻辑来防止重复,并且在某些情况下可能会稍微慢一些。在其他语言中,这种数据结构(使用类似哈希表的方法)通常被称为 哈希集。我们还将使用 size
属性跟踪集合中的元素数量。
接下来,我们需要声明集合可用的方法:
MySet
类将提供以下方法:
-
add(value)
: 向集合添加一个唯一的值。 -
delete(value)
: 如果存在,则从集合中移除该值。 -
has(value)
: 如果元素存在于集合中,则返回 true,否则返回 false。 -
clear()
: 从集合中移除所有值。 -
size()
: 返回集合包含的值的数量。 -
values()
: 返回集合中所有值的数组。 -
union(otherSet)
: 合并两个集合。 -
intersection(otherSet)
: 查找两个集合之间的公共元素。 -
difference(otherSet)
: 查找仅存在于一个集合中的元素。
我们将在以下章节中详细实现这些方法。
在集合中查找值
我们在自定义 MySet
类中首先实现的方法是 has(value)
。这个方法在添加和移除元素等其他操作中扮演着至关重要的基础角色。它允许我们有效地确定给定值是否已经存在于集合中。以下是实现方法:
has(value) {
return this.#items.hasOwnProperty(value);
}
该方法直接利用 JavaScript 内置的 hasOwnProperty
函数在内部 #items
对象上。这是一种高度优化的方式来检查特定键(表示值)是否存在于对象中。
hasOwnProperty
方法在平均情况下提供常数时间复杂度 (O(1)),这使得它成为检查集合中存在性的极快方式。这种效率是我们经常在 JavaScript 中使用对象而不是数组来实现集合的关键原因。
现在我们有了这个方法,我们可以继续实现添加和移除值的方法的实现。
向集合添加值
接下来,我们将在自定义的MySet
类中实现add
方法。这个方法负责将新元素插入到集合中,但只有当它尚未存在时(保持集合的唯一性属性)如下所示:
add(value) {
if (!this.has(value)) {
this.#items[value] = true; // mark the value as present
this.#size++;
return true;
}
return false;
}
我们首先使用我们之前实现的has(value)
方法高效地检查值是否已经存在于集合中。如果值不存在,我们将其插入到#items
对象中。我们使用value
本身作为键,并给它分配一个值为true
的值。这作为一个简单的标志,表示该值是集合的一部分。插入成功后,我们将#size
属性增加以准确反映集合中新的元素数量。
我们返回true
以表示值已成功添加(它不在集合中)。否则,我们返回false
以指示值没有被添加,因为它是一个重复项。
移除和清除所有值
接下来,我们将实现delete
方法,如下所示:
delete(value) {
if (this.has(value)) {
delete this.#items[value];
this.#size--;
return true;
}
return false;
}
我们首先使用之前实现的has(value)
方法检查指定的值是否存在于集合中。这确保我们只尝试删除存在的元素。如果找到值,我们使用delete
运算符从#items
对象中删除相应的键值对。这直接从集合的内部存储中删除了元素。删除成功后,我们将#size
属性减少以保持集合中元素数量的准确计数。
我们返回true
以表示值已成功从集合中删除,并返回false
以表示值未在集合中找到,因此无法删除。
如果我们想从集合中移除所有元素,可以使用clear
方法,如下所示:
clear() {
this.#items = {};
this.#size = 0;
}
我们通过直接将#items
对象重新分配给一个新的空对象{}
来实现集合的完全清除。这有效地丢弃了所有之前的关键值对(代表集合的元素),并为未来的添加创建了一个全新的空容器。我们还把#size
属性重置为 0,以准确反映集合现在不包含任何元素。
这种实现方式非常高效,因为重新分配#items
对象是一个常数时间操作(O(1))。逐个迭代并删除每个元素的方法会慢得多,尤其是对于大型集合。除非我们有特定的原因需要在清除操作期间跟踪被移除的元素,否则通常不推荐这样做。
获取大小并检查是否为空
我们接下来要实现的方法是大小方法(技术上是一个获取器方法),如下所示:
get size() {
return this.#size;
}
此方法简单地返回我们用来计数的size
属性。
如果我们没有跟踪#size
属性,我们可以通过以下方式确定集合的大小:
-
遍历
#items
对象的键(元素)。 -
为遇到的每个键增加计数器。
下面是这个替代方法的代码:
getSizeWithoutSizeProperty() {
let count = 0;
for (const key in this.#items) {
if (this.#items.hasOwnProperty(key)) {
count++;
}
}
return count;
}
代码使用 for...in
循环遍历 #items
对象中的键(即集合的值)。在循环内部,使用 hasOwnProperty
确保我们只计算属于对象本身的属性(而不是从原型链继承的属性)。
这种方法对于大型集合来说效率较低,因为它需要遍历所有元素,导致时间复杂度为 O(n),其中 n 是集合中元素的数量。
为了确定 MySet
是否为空,我们实现了 isEmpty()
方法,遵循与其他数据结构一致的模板:
isEmpty() {
return this.#size === 0;
}
此方法直接比较私有 #size
属性与 0。属性 #size
被精心维护,始终反映集合中的元素数量。
检索所有值
为了检索包含 MySet
中所有元素(值)的数组,我们可以如下实现 values
方法:
values() {
return Object.keys(this.#items);
}
我们可以利用内置的 Object.keys()
方法来实现简洁的代码。这个内置的 JavaScript 方法接受一个对象(在我们的例子中,是 this.#items
),并返回一个包含所有可枚举属性键的字符串数组。记住,在我们的 MySet
实现中,我们使用 #items
对象的键来存储实际添加到集合中的值。
现在我们已经完成了自定义 MySet
数据结构的实现,让我们来看看如何将其付诸实践!
使用 MySet 类
我们将深入探讨一些实际例子,展示 MySet
的实用性和灵活性,演示它如何高效地管理唯一元素集合。想象一下,我们正在构建一个博客或内容管理系统,用户可以为他们的文章或帖子添加标签(关键词)。我们希望确保每篇文章都有一个唯一标签列表,没有重复。
此示例的源代码可以在文件 src/07-set/01-using-myset-class.js 中找到。让我们首先定义文章:
const MySet = require('./set');
const article = {
title: 'The importance of data structures in programming',
content: '...',
tags: new MySet() // using MySet to store tags
};
现在,让我们为我们的文章添加一些标签:
article.tags.add('programming');
article.tags.add('data structures');
article.tags.add('algorithms');
article.tags.add('programming');
注意,第一个和最后一个标签是重复的。我们可以确认集合中有三个标签,这意味着没有重复:
console.log(article.tags.size); // 3
我们还可以使用 has
方法来再次确认哪些标签是文章的一部分:
console.log(article.tags.has('data structures')); // true
console.log(article.tags.has('algorithms')); // true
console.log(article.tags.has('programming')); // true
console.log(article.tags.has('javascript')); // false
我们还可以使用 values
方法来检索所有标签:
console.log(article.tags.values());
// output: ['programming', 'data structures', 'algorithms']
现在,假设我们想要移除标签编程并添加标签 JavaScript:
article.tags.delete('programming');
article.tags.add('JavaScript');
console.log(article.tags.values());
// output: ['data structures', 'algorithms', 'JavaScript']
因此,现在我们有一个与 ECMAScript 2015 中的 Set 类非常相似的实现。但我们可以通过添加一些基本操作来增强我们的实现,例如并集、交集和差集。
使用集合执行数学运算
集合是数学中的一个基本概念,在计算机科学中有着广泛的应用,尤其是在数据库领域。数据库是无数应用程序的骨架,集合在它们的设计和运行中起着至关重要的作用。
当我们构建查询以从关系型数据库(如 Oracle、Microsoft SQL Server、MySQL 等)检索数据时,我们实际上是在使用集合符号来定义所需的结果。数据库反过来返回一组符合我们标准的数据。
SQL 查询允许我们指定我们想要检索的数据范围。我们可以选择表中的所有记录,或者我们可以根据某些条件缩小搜索范围到特定的子集。此外,SQL 利用集合操作来执行各种类型的数据操作。SQL 中的连接概念在本质上基于集合操作。以下是一些常见的例子:
-
并集:将两个或多个表中的数据合并,创建一个包含所有唯一行的新的集合。
-
交集:识别多个表中共同存在的行,结果是一个只包含共享数据的集合。
-
差异(除法/减法):找出存在于一个表中但不存在于另一个表中的行,从第一个表中创建一个包含唯一行的集合。
除了 SQL 中使用的操作之外,还有其他重要的集合操作,例如:
- 子集:确定一个集合是否完全包含在另一个集合中。这有助于建立集合之间的关系,并且对于各种逻辑和分析任务可能很有用。
理解集合及其操作对于处理数据库和其他数据密集型应用至关重要。有效地操作集合的能力使我们能够有效地从复杂的数据集中提取、过滤和分析信息。让我们看看我们如何使用我们的MySet
类来模拟这些操作。
并集:合并两个集合
两个集合 A 和 B 的并集是一个新集合,它包含来自两个集合的所有唯一元素。这就像将两个包的内容合并到一个更大的包中,确保不放入任何重复的元素。
例如,假设我们有两个集合:A 和 B 如下所示:
-
集合 A =
-
集合 B =
-
A ∪ B =
在这个例子中,值 3 同时出现在两个集合中,但在结果的并集集合中只包含一次,因为集合不能包含重复的元素。
集合 A 和 B 的并集用符号∪表示。因此,A 和 B 的并集在数学表示法中写作 A ∪ B。以下图表展示了并集操作:
两个集合的并集操作,突出显示两个集合的所有区域
现在,让我们用以下代码在我们的MySet
类中实现并集方法:
union(otherSet) {
const unionSet = new MySet();
this.values().forEach(value => unionSet.add(value));
otherSet.values().forEach(value => unionSet.add(value));
return unionSet;
}
要执行两个集合的并集,我们需要三个步骤:
-
创建一个新的空集合:这将是一个用于存储并集结果的集合。
-
遍历第一个集合:将第一个集合中的每个元素添加到新集合中。
-
遍历第二个集合:将第二个集合中的每个元素添加到新集合中。
在执行添加操作时,它将评估值是否重复,从而得到一个包含原始集合中所有唯一元素的新集合。
重要的是要注意,我们在本章中实现的并集、交集和差集方法不会修改当前的
MySet
类实例,也不会修改作为参数传递的otherSet
。没有副作用的方法或函数被称为纯函数。纯函数不会修改当前实例或参数;它只产生一个新的结果。
让我们看看这个方法是如何工作的。假设一个在线广告平台想要根据用户的兴趣进行定位,这些兴趣来自各种来源(例如:访问的网站和社交媒体活动)。为了能够发起活动,我们需要:
-
收集来自不同来源的兴趣关键词集合。
-
计算这些集合的并集,以获得一个全面的用户兴趣列表。
-
使用这个组合集合来匹配相关广告的用户。
以下代码将代表这个逻辑。让我们首先收集来自网站的兴趣:
const interestsFromWebsites = new MySet();
interestsFromWebsites.addAll(['technology', 'politics', 'photography']);
接下来,让我们收集来自社交媒体的兴趣:
const interestsFromSocialMedia = new MySet();
interestsFromSocialMedia.addAll(['technology', 'movies', 'books']);
使用这两个来源,我们可以计算它们的并集,得到一个包含所有兴趣的列表:
const allInterests = interestsFromWebsites.union(interestsFromSocialMedia);
console.log(allInterests.values());
// output: ['technology', 'politics', 'photography', 'movies', 'books']
现在我们可以尝试发起一个成功的活动!
为了方便我们的示例,我们还可以创建一个新的方法,它将接受一个值数组作为输入:
addAll(values) {
values.forEach(value => this.add(value));
}
这个方法将逐个添加每个元素,这样我们可以在下一个示例中节省一些时间。
交集:识别两个集合中的共同值
两个集合 A 和 B 的交集是一个新集合,它只包含两个集合共有的元素。将其视为寻找两个袋子内容的重叠部分。
例如,考虑我们有两个集合:A 和 B,如下所示:
-
集合 A =
-
集合 B =
-
A ∩ B =
在这个例子中,值 3 和 4 同时存在于两个集合中,因此它们被包含在结果交集集合中。
集合 A 和 B 的交集用符号 ∩ 表示。因此,A 和 B 的交集写作 A ∩ B。以下图表展示了交集操作:
两个集合的交集操作,仅突出显示中间部分,这是两个集合的共享区域
现在,让我们用以下代码在 MySet
类中实现交集方法:
intersection(otherSet) {
const intersectionSet = new MySet();
this.values().forEach(value => {
if (otherSet.has(value)) {
intersectionSet.add(value);
}
});
return intersectionSet;
}
要执行两个集合的交集,我们需要三个步骤:
-
创建一个新的空集合:这将是一个用于存储交集结果的集合。
-
遍历第一个集合:对于第一个集合中的每个元素,检查它是否也存在于第二个集合中。
-
条件添加:如果元素在两个集合中都找到,则将其添加到新集合中。
让我们看看这个方法在实际中的应用。假设一个招聘平台想要根据求职者的技能来匹配职位发布。为了这个实现,我们需要以下逻辑:
-
将求职者的技能和职位所需的技能表示为集合。
-
找出这些集合的交集,以确定求职者拥有的技能和职位所需的技能。
-
根据交集的大小对职位发布进行排名,以显示对求职者最相关的职位。
以下将代表此逻辑的代码。首先,我们将定义可用的职位发布:
const job1Skills = new MySet();
job1Skills.addAll(['JavaScript', 'Angular', 'Java', 'SQL']);
const job2Skills = new MySet();
job2Skills.addAll(['Python', 'Machine Learning', 'SQL', 'Statistics']);
const jobPostings =
[{
title: 'Software Engineer',
skills: job1Skills
},
{
title: 'Data Scientist',
skills: job2Skills
}];
jobPostings
变量是一个包含职位对象的数组,每个对象都有一个 title
和一个名为 skills
的 MySet
,其中包含该职位的所需技能。
接下来,我们将定义具有名字和技能的求职者:
const candidateSkills = new MySet();
candidateSkills.addAll(['JavaScript', 'Angular', 'TypeScript', 'AWS']);
const candidate = {
name: 'Loiane',
skills: candidateSkills
};
candidate
是一个对象,代表一个求职者,包含一个名字和一个名为 skills
的 MySet
,其中包含他们的技能。
然后,我们可以创建一个函数,该函数将计算求职者和可用的职位发布之间的最佳潜在匹配:
function matchCandidateWithJobs(candidate, jobPostings) {
const matches = [];
for (const job of jobPostings) {
const matchingSkillsSet = candidate.skills.intersection(job.skills);
if (!matchingSkillsSet.isEmpty()) {
matches.push({
title: job.title,
matchingSkills: matchingSkillsSet.values()
});
}
}
return matches;
}
下面是 matchCandidateWithJobs
函数的解释:
-
将
candidate
和jobPostings
作为输入。 -
初始化一个空数组
matches
来存储匹配的职位。 -
遍历
jobPostings
数组中的每个职位。 -
对于每个职位,它计算求职者技能和职位所需技能的交集。
-
如果交集集合不为空(意味着存在匹配的技能),则将职位标题和匹配的技能(作为一个数组)添加到
matches
数组中。 -
最后,我们返回包含职位标题及其与求职者匹配的技能的
matches
数组。
将所有这些放在一起:
const matchingJobs = matchCandidateWithJobs(candidate, jobPostings);
console.log(matchingJobs);
// output: [{ title: 'Software Engineer', matchingSkills: [ 'JavaScript', 'Angular' ] }]
我们得到的结果是,对于这个求职者来说,最佳职位发布将是软件工程师职位,因为求职者还拥有 JavaScript 和 Angular 技能。
我们创建的交集逻辑工作得很好,然而,我们可以进行一些改进。
改进交集逻辑
考虑以下场景:
-
集合 A 包含以下值:
-
集合 B 包含以下值:
在我们最初的交集方法中,我们会遍历集合 A 的所有七个元素,并检查它们是否存在于集合 B 中。然而,存在一种更有效的方法。
我们可以通过遍历两个集合中较小的一个来优化交集方法。当其中一个集合明显小于另一个集合时,这显著减少了所需的迭代和比较次数。优化后的代码如下所示:
intersection(otherSet) {
const intersectionSet = new MySet();
const [smallerSet, largerSet] = this.size <= otherSet.size ? [this, otherSet] : [otherSet, this];
smallerSet.values().forEach(value => {
if (largerSet.has(value)) {
intersectionSet.add(value);
}
});
return intersectionSet;
}
我们使用简洁的三元表达式来确定哪个集合元素较少:this.size <= otherSet.size ? [this, otherSet] : [otherSet, this]
。这会将较小的集合分配给 smallerSet
,较大的集合分配给 largerSet
。然后,我们遍历 smallerSet
的 values()
。这立即将循环迭代次数减少到较小集合的大小。
在一个集合远小于另一个集合的情况下,这种优化显著减少了迭代和比较的次数,从而提高了执行时间。特别是对于集合大小差异大的场景,交集操作的整体性能得到了提升。
两个集合之间的差集
两个集合 A 和 B 之间的差集(表示为 A - B 或 A \ B),是一个包含 A 中所有不在 B 中的元素的新集合。换句话说,它是集合 A 中独特的元素集合。
例如,考虑我们有两个集合:A 和 B,如下所示:
-
集合 A =
-
集合 B =
-
A - B =
-
B - A =
在这个例子中,A - B 的结果是集合 {1, 2},因为这些元素在 A 中但不在 B 中。同样,B - A 的结果是 {5, 6}。
集合 A 和 B 的差集表示为:
-
A - B(有时读作 A 减去 B)
-
A \ B
以下图表展示了 A - B 的差集操作:
两个集合 A - B 的差集操作,突出显示 A 中不与 B 共同的部分
现在,让我们用以下代码在我们的 MySet
类中实现差集方法:
difference(otherSet) {
const differenceSet = new MySet();
this.values().forEach(value => {
if (!otherSet.has(value)) {
differenceSet.add(value);
}
});
return differenceSet;
}
要执行两个集合的差集,我们需要三个步骤:
-
创建一个新的空集合:这将保存差集的结果。
-
遍历第一个集合:对于第一个集合中的每个元素,检查它是否存在于第二个集合中。
-
条件添加:如果元素在第二个集合中未找到,则将其添加到新集合中。
让我们看看实际操作。假设我们正在运行一个在线商店,有一份接收促销电子邮件的订阅者列表。我们已经根据他们的兴趣(书籍、时尚、技术)对订阅者进行了细分。我们想要发送关于书籍的目标电子邮件活动,但我们希望排除已经对这些书籍表示过兴趣的订阅者。
因此,让我们首先声明我们在这个场景中需要的所有集合:
const allSubscribers = new MySet();
allSubscribers.addAll(['Aelin', 'Rowan', 'Xaden', 'Poppy', 'Violet']);
const booksInterested = new MySet();
booksInterested.addAll(['Aelin', 'Poppy', 'Violet']);
const alreadyPurchasedBooks = new MySet();
alreadyPurchasedBooks.addAll(['Poppy']);
我们有三个集合:
-
allSubscribers
:所有电子邮件订阅者的集合。 -
booksInterested
:一组表示对书籍感兴趣的用户。 -
alreadyPurchasedBooks
:一组已经购买书籍的用户。
接下来,我们将找到对书籍感兴趣但尚未购买的订阅者:
const targetSubscribers = booksInterested.difference(alreadyPurchasedBooks);
我们使用 booksInterested.difference(alreadyPurchasedBooks)
来找到对书籍感兴趣但尚未在该类别中购买的订阅者。这给我们带来了 targetSubscribers
集合。
最后,我们将向目标订阅者发送电子邮件:
targetSubscribers.values().forEach(subscriber => {
sendEmail(subscriber, 'New books you will love!');
});
function sendEmail(subscriber, message) {
console.log(`Sending email to ${subscriber}: ${message}`);
}
我们将得到的输出是:
Sending email to Aelin: New books you will love!
Sending email to Violet: New books you will love!
我们只剩下一个最后操作要覆盖:子集
子集:检查一个集合是否包含所有值
如果集合 A 的每个元素也是集合 B 的元素,则集合 A 是集合 B 的子集。用更简单的话说,A 完全包含在 B 内。
例如,考虑我们有两个集合:A 和 B,如下所示:
-
集合 A =
-
集合 B =
-
A ⊆ B
在这个例子中,A 是 B 的子集,因为 A 中的每个元素也在 B 中。
子集关系用符号⊆表示。所以,如果 A 是 B 的子集,我们写成:A ⊆ B。以下图表展示了子集关系:
A 是 B 的子集,因为 A 中的元素也在 B 中
现在,让我们用以下代码在MySet
类中实现isSubsetOf
方法:
isSubsetOf(otherSet) {
if (this.size > otherSet.size) {
return false;
}
return this.values().every(value => otherSet.has(value));
}
我们首先检查当前集合的大小(this.size
)是否大于otherSet
的大小。如果是这样,我们立即知道当前集合不能是otherSet
的子集,因为子集不能比它所组成的集合有更多的元素。在这种情况下,方法提前返回false
,节省了不必要的进一步检查。
如果大小检查通过,我们调用this.values()
来获取当前集合中所有值的数组。然后,我们在这个数组上使用every()
方法来检查另一个集合是否有当前集合中的值。如果当前集合中的每个值都在otherSet
中找到,那么every()
方法返回true
(意味着当前集合是一个子集)。如果当前集合中的任何一个值在otherSet
中没有找到,every()
返回false
(意味着它不是子集)。
让我们看看这个操作的实际效果。想象一下,我们正在开发一个拥有大量食谱数据库的食谱应用。每个食谱都有一组食材。用户可以根据他们拥有的食材来筛选食谱。
我们首先声明存储我们食谱食材的集合:
const chickenIngredients = new MySet()
chickenIngredients.addAll(['chicken', 'tomato', 'onion', 'garlic', 'ginger', 'spices']);
const spaghettiIngredients = new MySet();
spaghettiIngredients.addAll(['spaghetti', 'eggs', 'bacon', 'parmesan', 'pepper']);
接下来,我们将声明食谱及其食材:
const recipes =
[{
name: 'Chicken Tikka Masala',
ingredients: chickenIngredients
},
{
name: 'Spaghetti Carbonara',
ingredients: spaghettiIngredients
}];
recipes
变量是一个食谱对象的数组,每个对象都有一个名称和一个名为ingredients
的MySet
,表示该食谱所需的食材。
然后,我们还需要一个包含我们有可用食材的列表的集合:
const userIngredients = new MySet();
userIngredients.addAll(['chicken', 'onion', 'garlic', 'ginger']);
下一步将是检查我们是否有匹配我们食材的食谱的逻辑:
function filterRecipes(recipes, userIngredients) {
const filteredRecipes = [];
for (const recipe of recipes) {
if (userIngredients.isSubsetOf(recipe.ingredients)) {
filteredRecipes.push({ name: recipe.name });
}
}
return filteredRecipes;
}
这里是filterRecipes
函数的解释:
-
接收
recipes
和userIngredients
作为输入。 -
初始化一个空的数组
filteredRecipes
来存储匹配的食谱名称。 -
遍历
recipes
数组中的每个食谱。 -
对于每个食谱,它检查
recipe.ingredients.isSubsetOf(userIngredients)
。如果为true
(意味着食谱的所有食材都在用户的食材中),则将食谱的名称添加到filteredRecipes
。 -
返回
filteredRecipes
数组。
最后,将所有这些放在一起:
const matchingRecipes = filterRecipes(recipes, userIngredients);
console.log(matchingRecipes);
我们将得到以下输出:
[ { name: 'Chicken Tikka Masala' } ]
我们还可以实现
isSupersetOf
方法,该方法将检查当前集合 A 是否是另一个集合 B 的超集,如果 B 中的每个元素也是 A 的元素。用更简单的术语来说,B 完全包含在 A 中。尝试一下,你可以在下载本书源代码时在MySet
类中找到源代码。
现在我们已经向MySet
类添加了一些额外的逻辑,让我们来看看原生的 JavaScript Set 类是如何工作的。
JavaScript Set 类
让我们深入了解 ECMAScript 2015(ES6)中引入的原生 Set 类,并探讨如何有效地使用它。
Set 类提供了在 JavaScript 中处理集合的内置、高效方式。它提供了所有基本集合操作,并针对性能进行了优化。
现在,让我们看看原生 Set 类中可用的方法和功能:
-
两个构造函数:
-
new Set()
: 创建一个空的 Set。 -
new Set(iterable)
: 从可迭代对象(例如,数组)创建一个 Set。
-
-
add(value)
: 向集合中添加一个值(如果它尚未存在)。返回 Set 对象本身以实现链式调用。 -
delete(value)
: 从集合中删除指定的值。如果值存在并已删除,则返回true
,否则返回false
。 -
clear()
: 从集合中删除所有元素。 -
has(value)
: 如果值存在于集合中,则返回true
,否则返回false
。 -
遍历集合的不同方法:
-
forEach(callbackFn)
: 对集合中的每个值执行提供的callbackFn
。 -
values()
: 返回集合值的迭代器。 -
keys()
:values()
的别名。 -
entries()
: 返回一个遍历[value, value]
对的迭代器(因为在 Set 中键和值是相同的)。
-
-
size
: 返回集合中元素数量的属性。
如果我们想要重写文章及其标签的示例,我们可以简单地用 Set
替换 MySet
,代码仍然可以按以下方式工作:
const article = {
title: 'The importance of data structures in programming',
content: '...',
tags: new Set()
};
article.tags.add('programming');
article.tags.add('data structures');
article.tags.add('algorithms');
article.tags.add('programming');
由于 Set 类还有一个接受数组的构造函数,我们可以简化之前的代码,并将标签直接传递给构造函数:
const article = {
title: 'The importance of data structures in programming',
content: '...',
tags: new Set(['programming', 'data structures', 'algorithms'])
};
其他方法,如删除、检查大小、存在和值,也会按预期工作。
构建我们的自定义 MySet
类是一个有价值的练习,它让我们深入了解集合数据结构的内部工作和机制。虽然在日常 JavaScript 开发中,我们可能会使用高效且方便的内建 Set 类,但通过实现自己的集合所获得的知识使我们能够理解底层原理,在内置和自定义解决方案之间做出明智的选择,并更有效地解决与集合相关的问题。
检查集合的效率
让我们通过查看每个方法的 Big O 符号来回顾其执行时间效率:
方法 | MySet | Set | 说明 |
---|---|---|---|
add(value) |
O(1) | O(1) | 将值插入到对象或底层数据结构中具有恒定时间的操作。 |
addAll(values) |
O(n) | O(n) | 对输入数组中的每个值调用 add(value) ,其中 n 是数组的大小。 |
delete(value) |
O(1) | O(1) | 从对象或底层数据结构中删除具有恒定时间的操作。 |
has(value) |
O(1) | O(1) | 在两种情况下,对象查找都具有恒定时间 |
values() |
O(n) | O(n) | 在 MySet 中,它遍历对象的键。在 Set 中,它创建一个迭代器,以线性时间产生每个值。 |
size (getter) |
O(1) | O(1) | 返回 #size 属性或原生的 Set 中的等效值。 |
isEmpty() |
O(1) | O(1) | 检查 #size 是否为 0。 |
values() |
O(n) | O(n) | 在 MySet 中,它遍历对象的键。在 Set 中,它创建一个迭代器,以线性时间产生每个值。 |
clear() |
O(1) | O(1) | 将 #items 对象重新赋值为空对象,并重置 #size 。 |
集合的整体空间复杂度被认为是 O(n),其中 n 是集合中存储的唯一元素的数量。这意味着集合数据结构使用的内存与其包含的元素数量成线性增长。
回顾时间复杂度,向集合中添加、删除值以及检查值是否存在都具有常数时间。有人可能会问,为什么我们总是使用集合而不是数组或列表?虽然集合在特定任务上表现出色,但也有一些原因说明为什么我们不会总是选择它们而不是数组或列表:
-
如果元素的顺序至关重要,数组是最佳选择。集合不保证元素的任何特定顺序。
-
如果我们经常需要通过位置访问元素(例如,获取列表中的第三个元素),数组由于直接索引而要快得多。集合需要迭代来查找特定元素。
-
如果数据自然包含重复项,并且这些重复项是有意义的,那么数组是更合适的选择。
让我们将我们的知识运用到一些练习中。
练习
我们将使用集合数据结构解决来自 LeetCode 的一个练习,以从数组中删除重复值。
从排序数组中删除重复项
我们将要解决的练习是位于 leetcode.com/problems/remove-duplicates-from-sorted-array/description/
的 26. 删除排序数组中的重复项 问题。
当使用 JavaScript 或 TypeScript 解决问题时,我们需要在 function removeDuplicates(nums: number[]): number
函数内部添加我们的逻辑,该函数接收一个数字数组并期望返回数组中的唯一元素数量。为了使我们的解决方案被接受,我们还需要在原地从 nums
数组中删除重复项,这意味着我们不能简单地为其分配一个新的引用。
让我们使用集合数据结构来编写 removeDuplicates
函数,以便轻松地从数组中删除重复项:
export function removeDuplicates2(nums: number[]): number {
const set = new Set(nums);
const arr = Array.from(set);
for (let i = 0; i < arr.length; i++) {
nums[i] = arr[i];
}
return arr.length;
}
这里是解决方案的解释:
我们首先创建一个新的 JavaScript Set 对象,并用输入数组 nums
的值初始化它。集合自动存储唯一值,因此 nums
中的任何重复项都会被消除。
接下来,我们将集合转换回一个常规数组 arr
。这个新数组只包含从原始 nums
数组中提取的唯一元素,并按顺序排列。这一步是必要的,因为我们不能直接访问集合中的每个值,就像访问 array[i]
一样。
for 循环遍历arr
(唯一元素)数组,并将每个元素复制回原始的nums
数组,覆盖任何现有的重复值。由于arr
的长度保证不会超过nums
,我们只需要遍历到arr
的长度。这一步是必需的,因为问题评判者也会检查nums
数组是否被就地修改。
最后,该方法返回arr.length
,即原始数组中的唯一元素个数。这是 LeetCode 问题的预期输出。
这个函数的时间复杂度是O(n),其中n是数组nums
中的值数。我们创建集合,添加所有元素(O(n)),然后将集合转换为数组(O(n)),还有一个循环来覆盖原始数组(O(k),其中k是唯一元素的个数)。因此,整体时间复杂度是O(n),因为它受创建集合并将其转换为数组的线性时间操作的支配。
空间复杂度是O(k),因为我们创建了一个集合来存储唯一元素。在最坏的情况下,即所有元素都是唯一的,它将存储所有n个元素。然而,在大多数情况下,k(唯一元素的个数)将小于n。我们还有一个只存储唯一元素的数组arr
,所以其大小是k。因此,整体空间复杂度是O(k),其中k是输入数组中唯一元素的个数。
虽然算法是正确的并且解决了问题,但它并不是最空间高效的解决方案。我们将在本书的后面部分使用不同的技术解决相同的问题。在此期间,请尝试使用O(1)空间复杂度解决这个问题。
摘要
在本章中,我们通过实现自定义的MySet
类深入探讨了集合数据结构的内部工作原理。这种动手方法反映了 ECMAScript 2015 中引入的 Set 类的核心功能,使你对集合在底层如何操作有了更深入的理解。我们还扩展了我们的探索,通过实现并集、交集、差集和子集等额外方法,丰富了你在处理集合时的工具箱。
为了将我们新获得的知识付诸实践,我们解决了一个真实的 LeetCode 问题,展示了集合在解决算法挑战中的力量。
在下一章中,我们将把重点转向非顺序数据结构,特别是哈希和字典。准备好发现这些多才多艺的结构如何通过键值对实现高效的数据存储和检索!
第九章:8 字典和散列
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“学习 JavaScript 数据结构算法第 4 版”频道下找到“EARLY ACCESS SUBSCRIPTION”)。
在上一章中,我们深入探讨了集合的世界,关注它们高效存储唯一值的能力。在此基础上,我们现在将探索两个旨在存储不同元素的数据结构:字典和哈希。
虽然集合优先考虑值本身作为主要元素,但字典和哈希采取了不同的方法。这两种结构都以键值对的形式存储数据,使我们能够将一个唯一的键与相应的值关联起来。这种配对是字典和哈希工作的基本原理。
然而,在实现上存在一个微妙但重要的区别。正如我们很快就会发现的,字典遵循每个键一个值的严格规则。另一方面,哈希表在处理与同一键关联的多个值时提供了一些灵活性,为数据组织和检索开辟了额外的可能性。
在本章中,我们将涵盖:
-
字典数据结构
-
哈希表数据结构
-
处理哈希表中的冲突
-
JavaScript 原生的
Map
、WeakMap
和WeakSet
类
字典数据结构
正如我们所探讨的,集合是一组唯一的元素集合,确保在结构中不存在重复的元素。相比之下,字典被设计用来存储键值对。这种配对使我们能够利用键作为标识符,有效地定位特定的元素。
虽然字典与集合有相似之处,但它们存储的数据类型存在一个关键的区别。集合维护一组键键对,其中对的两元素是相同的。另一方面,字典包含键值对,将每个唯一的键与相应的值关联起来。
值得注意的是,在不同的环境中,字典有不同的名称,包括 映射、符号表和关联数组。这些术语突出了字典的基本目的:在键和值之间建立关联,促进高效的数据检索和组织。
在计算机科学中,字典经常被用来存储对象的引用地址。这些地址作为内存中对象的唯一标识符。为了可视化这个概念,请考虑打开 Chrome 开发者工具 并导航到 内存 选项卡。运行快照将显示一个对象列表及其相应的地址引用,通常以 @<数字> 的格式显示。以下截图说明了字典如何将这些键与这些内存地址关联起来,从而实现高效的对象检索和处理。
浏览器内存标签页显示地址引用的内存分配
在本章中,我们还将涵盖一些如何在现实世界项目中使用字典数据结构的示例。
创建字典类
除了Set
类之外,ECMAScript 2015 (ES6) 还引入了Map
类,这是一个在编程中常被称为字典的基本数据结构。这种原生实现是我们将在本章中开发的自定义字典类的基础。
我们将要构建的Dictionary
类在很大程度上借鉴了 JavaScript Map 实现的设计原则。在我们探索其结构和功能时,你会观察到与Set
类惊人的相似之处。然而,一个关键的区别在于数据存储机制。与 Set 只存储值不同,我们的字典类将容纳键值对。这种修改使我们能够将唯一的键与其对应的值关联起来,从而释放字典作为数据结构的全部功能和灵活性。
我们的实现将位于src/08-dictionary-hash/dictionary.js
文件中。我们将首先定义Dictionary
类:
class Dictionary {
#items = {};
#size = 0;
}
我们使用一个对象({}
)在#items
私有属性中存储元素。这个对象的键代表唯一的键,而相应的值可以是任何东西。我们还将使用size
属性跟踪集合中的元素数量。
在理想情况下,一个字典可以无缝地存储字符串类型的键和任何类型的值,无论是原始值如数字或字符串,还是更复杂的对象。然而,JavaScript 的动态类型特性引入了一个潜在的挑战。由于我们无法保证键始终是字符串,我们必须实现一个机制来将任何作为键传递的对象转换为字符串格式。这种转换简化了在字典类中搜索和检索值的过程,增强了其整体功能。相同的逻辑也可以应用于我们在上一章中探讨的 Set 类。
注意,在 TypeScript 实现中我们没有这个问题,因为我们可以将键的类型定义为字符串。
为了实现这一关键转换,我们需要一个能够可靠地将对象转换为字符串的函数。作为一个默认选项,我们将利用本书之前在先前数据结构中定义的#elementToString
方法。这个函数提供了一个可重用的解决方案,用于将键字符串化,使其适用于我们创建的任何数据结构:
#elementToString(data) {
if (typeof data === 'object' && data !== null) {
return JSON.stringify(data);
} else {
return data.toString();
}
}
这个方法有效地将数据转换为字符串表示。如果数据是一个复杂对象(不包括null
),它将使用JSON.stringify()
生成JSON字符串。否则,它利用toString
方法确保任何其他数据类型的字符串转换。
现在,让我们定义将赋予我们的字典/映射数据结构功能的方法:
-
set(key, value)
: 将新的键值对插入到字典中。如果指定的键已经存在,其关联的值将使用新值进行更新。 -
remove(key)
: 从字典中移除与提供的键对应的条目。 -
hasKey(key)
: 确定给定的键是否存在于字典中,如果存在则返回true
,否则返回false
。 -
get(key)
: 获取与指定键关联的值。 -
clear()
: 清空字典,移除所有键值对。 -
size()
: 返回当前存储在字典中的键值对数量,类似于数组的长度属性。 -
isEmpty()
: 检查字典是否为空,如果大小为零则返回true
,否则返回false
。 -
keys()
: 生成一个包含字典中所有键的数组。 -
values()
: 生成一个包含字典中所有值的数组。 -
forEach(callbackFn)
: 遍历字典中的每个键值对。接受键和值作为参数的callbackFn
函数对每个条目执行。如果回调函数返回false
,则可以终止迭代过程,这与Array
类中的every
方法的行为类似。
我们将在接下来的章节中详细实现这些方法。
验证键是否存在于字典中
我们将要实现的第一种方法是hasKey(key)
方法。这个方法是基本的,因为它将在set
和remove
等其它方法中使用。让我们来看看它的实现:
hasKey(key) {
return this.#items[this.#elementToString(key)] != null;
}
在 JavaScript 中,对象的键本质上是字符串。因此,如果提供了一个复杂对象作为键,我们必须将其转换为字符串表示形式。为了实现这一点,我们一致地调用#elementToString
方法,确保在我们的字典中键始终被视为字符串。
hasKey
方法检查在项目表中(我们字典的底层存储)是否有与给定键关联的值。如果表中对应的位置不是null
或undefined
,表明存在值,则方法返回true
。如果没有找到值,则方法返回false
。
现在我们有了这个方法,我们可以继续实现添加和移除值的相应方法。
在字典中设置键和值
接下来,我们将在我们的Dictionary
类中实现set
方法。set
方法具有双重作用:它既可以向字典中添加新的键值对,也可以更新现有键的值:
set(key, value) {
if (key != null && value != null) {
const tableKey = this.#elementToString(key);
this.#items[tableKey] = value;
this.#size++;
return true;
}
return false;
}
此方法接受一个key
和一个value
作为输入。如果键和值都有效(不是null
或undefined
),则方法继续将键转换为字符串表示形式。这是一个关键步骤,因为 JavaScript 对象键只能是字符串。这个转换由私有的#elementToString
方法内部处理,确保所有键类型的一致性和可靠性。在键以字符串形式存在后,该方法将值存储在字典的内部存储(#items
)中。
最后,该方法通过返回true
来传达其成功,表示键值对已成功插入或更新,并且我们增加其大小。如果键或值无效(null
或undefined
),则方法返回false
,表示插入或更新操作失败。
删除和清除字典中的所有值
删除方法的主要功能是根据提供的键从字典中删除键值对。它在尝试删除之前检查键的存在性,并相应地更新大小,以确保字典的完整性:
delete(value) {
if (this.has(value)) {
delete this.#items[value];
this.#size--;
return true;
}
return false;
}
我们首先验证提供的键是否存在于字典中。这是通过调用has
方法实现的,该方法检查字典的底层存储中是否存在指定的键。这个检查是至关重要的,以防止在尝试删除不存在的条目时出现错误。
如果找到键,JavaScript 中的delete
运算符被用来从字典的内部数据结构(#items
)中删除相应的键值对。在成功删除条目后,字典的内部大小计数器(#size
)减一,以准确反映存储元素数量的变化。
作为最后一步,方法通过返回true
来表示操作的结果,以指示键存在且已成功删除,或者返回false
以指示键未找到且未发生删除。
如果我们想要从集合中移除所有元素,我们可以使用clear
方法,如下所示:
clear() {
this.#items = {};
this.#size = 0;
}
这实际上丢弃了所有之前的键值对,并为未来的添加创建了一个全新的空容器。我们还把#size
属性重置为 0,以准确反映集合现在不包含任何元素。
检索大小并检查是否为空
我们接下来要实现的方法是size
方法,如下所示:
get size() {
return this.#size;
}
此方法简单地返回我们用来计数的size
属性。
为了确定字典是否为空,我们实现了isEmpty()
方法,遵循与本书中覆盖的其他数据结构一致的模式:
isEmpty() {
return this.#size === 0;
}
此方法直接比较私有的#size
属性与 0。属性#size
被精心维护,始终反映集合中的元素数量。
从字典中检索值
要在我们的字典中搜索特定的键并检索其关联的值,我们使用 get
方法。这个方法通过封装必要的逻辑简化了访问存储数据的过程,如下所示:
get(key) {
return this.#items[this.#elementToString(key)];
}
当接收到 key
作为输入时,get
方法首先使用私有的 #elementToString
函数将其转换为字符串表示。然后,方法直接从字典的内部存储(#items
)访问相应的值。这是通过使用字符串化的键来索引到 #items 对象来实现的,该对象假设包含键值对。如果找到,方法将返回与给定键关联的值。
从字典中检索所有值和键
让我们探索如何从我们的自定义 JavaScript 字典类中检索所有值和键。我们将从声明方法 values
开始,该方法将检索字典类中存储的所有值,如下所示:
values() {
return Object.values(this.#items);
}
这个方法非常直接。它利用了内置的 Object.values()
函数,该函数接受一个对象(在这种情况下,我们的私有 #items
存储)并返回一个包含所有值的数组。
接下来,我们有 keys
方法:
keys() {
return Object.keys(this.#items);
}
类似地,keys
方法使用了 Object.keys()
函数。这个函数,当给定一个对象时,会返回该对象中所有基于字符串的键(属性名)的数组。由于我们确保在字典实现中所有键都是字符串,所以这工作得非常完美。
在大多数情况下,这些方法都有良好的性能。然而,对于特别大的字典,在某些 JavaScript 引擎中直接遍历 #items
对象可能会稍微高效一些。让我们看看我们如何在下一节中实现这一点。
使用 forEach
迭代字典中的每个值-键对
到目前为止,我们还没有实现一个方法来方便地遍历存储在我们数据结构中的每个值。现在,我们将介绍 Dictionary
类的 forEach
方法,它带来的额外好处是这种行为也可以应用于我们之前构建的其他数据结构。
这里是 forEach
方法:
forEach(callbackFn) {
for (const key in this.#items) {
if (this.#items.hasOwnProperty(key)) {
callbackFn(this.#items[key], key);
}
}
}
forEach
方法旨在遍历字典中的每个键值对,对每个条目应用提供的回调函数。对于每个键值对,提供的 callbackFn
函数被执行,接收值和键作为参数。
我们使用for...in
循环遍历对象属性。然而,为了确保我们只处理字典的自身属性(而不是从其原型链继承的属性),我们采取了一种保护措施。hasOwnProperty
方法检查属性是否直接属于对象。在这种情况下,它验证循环中的当前key
是否是#items
对象中的实际键,即字典的底层存储。然后,我们将提供的回调函数应用于每个条目,从字典中检索值并将键作为参数传递给回调。
现在我们有了我们的数据结构,让我们来测试它!
使用字典类
想象我们正在构建一个简单的语言学习程序。我们想要存储常用单词和短语的翻译,以帮助用户快速查找不同语言中的含义。
这个示例的源代码可以在文件src/08-dictionary-hash/01-using-dictionary-class.js
中找到。让我们先创建字典并添加一些值:
const translations = new Dictionary();
// Add some translations - English to Portuguese
translations.set("hello", "olá");
translations.set("thank you", "obrigado");
translations.set("book", "livro");
translations.set("cat", "gato");
translations.set("computer", "computador");
我们使用set
来用表示单词翻译的键值对填充字典。键是英语单词,值是它们的葡萄牙语翻译。
接下来,我们将创建一个函数,以便用户可以与之交互以检索特定单词或短语的翻译:
function translateWord(word) {
if (translations.hasKey(word)) {
const translation = translations.get(word);
console.log(`The translation of "${word}" is "${translation}"`);
} else {
console.log(`Sorry, no translation found for "${word}"`);
}
}
translateWord
函数接受一个word
作为输入。它使用hasKey
来检查单词是否存在于字典中。如果找到单词,它使用get
方法检索翻译并打印出来。如果没有找到,它显示“未找到翻译”的消息。
我们可以用以下代码尝试这个函数:
translateWord("hello"); // Output: The translation of "hello" is "olá"
translateWord("dog"); // Output: Sorry, no translation found for "dog"
我们还可以检查所有可用的翻译:
console.log("All translations:", translations.values());
// All translations: [ 'olá', 'obrigado', 'livro', 'gato', 'computador' ]
我们有所有可用的交易词汇:
console.log("All words:", translations.keys());
// All words: [ 'hello', 'thank you', 'book', 'cat', 'computer' ]
如果我们想打印字典,可以使用forEach
方法如下:
translations.forEach((value, key) => {
console.log(`${key}: ${value}`);
});
我们将得到以下输出:
hello: olá
thank you: obrigado
book: livro
cat: gato
computer: computador
因此,现在我们有了与原生 JavaScript Map 类非常相似的实现,
JavaScript Map 类
ECMAScript 2015 将 Map 类作为 JavaScript API 的一部分引入。我们的字典类是基于 ES2015 Map 类开发的。
在其核心,Map 是一个键值对的集合,类似于其他编程语言中的字典或哈希表。然而,与纯 JavaScript 对象不同,Map 提供了以下几个关键优势:
-
Map 类允许任何数据类型的键,包括对象、函数,甚至是其他 Map 对象。相比之下,对象键会自动转换为字符串。
-
Map 类维护键值对插入的顺序,使得迭代可预测。
-
我们可以轻松地使用
size
属性获取条目数量,而对于对象,我们通常需要使用Object.keys(obj).length
。 -
Map 类原生支持使用
for...of
循环进行迭代,这使得与它一起工作更加方便。
现在,让我们看看原生 Map 类中可用的方法和功能:
-
set(key, value)
: 添加或更新一个键值对。 -
get(key)
: 获取与键关联的值。 -
has(key)
: 检查键是否存在。 -
delete(key)
: 移除键值对。 -
size
: 返回条目的数量。 -
clear()
: 移除所有条目。 -
forEach(callbackFn)
: 遍历所有条目。
如果我们想要重写我们的翻译应用程序示例,我们是否可以简单地用Map
替换Dictionary
,代码仍然可以按以下方式工作:
const translations = new Map();
translations.set("hello", "olá");
translations.set("thank you", "obrigado");
translations.set("book", "livro");
translations.set("cat", "gato");
translations.set("computer", "computador");
其他方法,如get
、size
、has
、values
和forEach
也会按预期工作。
构建我们的自定义Dictionary
类已经证明是一个富有教育意义的努力,让我们对映射数据结构的内部机制有了更深入的理解。虽然内置的 JavaScript Map
类为大多数日常场景提供了效率和便利,但创建我们自己的字典使我们获得了宝贵的知识。
JavaScript 还支持 Map 和 Set 类的弱版本:WeakMap
和WeakSet
。让我们简要地看看它们。
JavaScript 的WeakMap
和WeakSet
类
除了标准的Map
和Set
类之外,JavaScript 还提供了两种称为WeakMap
和WeakSet
的专用集合类型。这些类提供了一种独特的方式来管理对象引用,在内存管理是关注点的情况下尤其有用。
与Map
类似,WeakMap
存储键值对。然而,WeakMap
中的键必须是对象,并且对这些键的引用是弱引用。这意味着如果对象的唯一引用是作为WeakMap
中的键存在,JavaScript 垃圾回收器可以将其从内存中移除。
WeakSet
的功能类似于Set
,存储一组唯一的值。然而,它只能存储对象
,并且对这些对象的引用是弱引用。类似于WeakMap
,如果一个对象的唯一引用是它在WeakSet
中的存在,那么它可以被垃圾回收。
WeakMap
和WeakSet
也与其常规对应物相比方法较少。它们缺少size
、clear
和迭代方法(如forEach
和keys()
)。
让我们回顾一个现实场景,我们会使用这些类。想象一下,我们正在设计一个提供Person
类的程序。我们希望存储与每个实例相关的一些敏感的私有数据,例如他们的社会保险号(或税号)或医疗记录。然而,我们不希望对象本身被这些属性所杂乱,并且我们想要确保它们可以在Person
对象不再需要时被垃圾回收。以下是演示此场景的代码:
const privateData = new WeakMap();
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
privateData.set(this, { ssn: 'XXX-XX-XXXX', medicalHistory: [] });
}
getSSN() {
return privateData.get(this)?.ssn;
}
}
创建一个 WeakMap
来存储私有数据。键是 Person
对象本身(this
)。在 Person
构造函数内部,我们使用 privateData.set(this, { ... })
将私有数据与新创建的人对象(this
)关联起来。getSSN
方法使用 privateData.get(this)
获取私有 SSN 数据。注意 可选链 (?.
) 以安全地处理 Person
对象可能不再存在的情况(这样我们就不会得到一个 null pointer 错误)。
为什么在这里使用 WeakMap
而不是 Map
?当一个 Person
对象变得不可访问(没有对其的引用)时,垃圾收集器可以移除对象的引用以及 WeakMap
中相关的私有数据,防止内存泄漏。这可以被认为是一种管理敏感或临时数据的好做法,这些数据不需要比与之关联的对象存活得更久。
这种模式也可以用来在引入哈希(#)符号之前在 JavaScript 类中实现私有属性。
现在我们已经了解了映射或字典数据结构,让我们通过哈希表将其提升到下一个层次。
哈希表数据结构
哈希表数据结构,也称为 哈希映射,是字典或映射数据结构的哈希实现。哈希表也是一个键值对的集合。键是一个唯一的标识符,值是你想要与该键关联的数据。哈希表通过使用 哈希函数 来实现其速度。
这就是哈希表的工作方式:
-
哈希函数:哈希函数接受一个键作为输入,并产生一个唯一的数值,称为 哈希码(或 哈希值)。这个哈希码就像键的指纹。
-
存储(桶/槽):哈希表内部由一个数组(或类似的结构,如链表)组成,具有固定大小的桶或槽。每个桶可以存储一个或多个键值对。
-
插入:当你插入一个键值对时:
-
哈希函数应用于键以获取其哈希码。
-
哈希码用于确定键值对应该存储的索引(桶)。
-
这对数据被放入那个桶中。
-
-
检索:当你想要检索一个值时,你提供一个键和:
-
哈希函数再次应用于键,产生相同的哈希码。
-
哈希码用于直接访问应存储值的桶。
-
值(希望)就在那个桶中。
-
哈希表存在于许多不同的地方。例如:
-
数据库:用于为快速检索索引数据。
-
缓存:存储最近访问的数据以快速查找。
-
符号表:在编译器中用于存储有关变量和函数的信息。
哈希表最经典的例子之一是电子邮箱簿。例如,每当我们想要发送电子邮件时,我们会查找人的名字并检索他们的电子邮件地址。以下图像展示了这个过程:
一个基于联系名的电子邮件地址存储的哈希表
在这个例子中,我们将使用一个哈希函数,该函数将简单地累加键长度的每个字符的 ASCII 值。这被称为输输哈希函数,这是一个非常简单的函数,可能导致我们在下一节中将要探讨的不同问题。
让我们将这个图表翻译成源代码,通过在新的主题中创建一个HashTable
类,这样我们就可以深入探讨这个概念。
创建哈希表类
我们的哈希表实现将位于src/08-dictionary-hash/hash-table.js
文件中。我们首先定义HashTable
类:
class HashTable {
#table = [];
}
这个初始步骤只是初始化私有的#table
数组,它将作为我们的键值对的底层存储。
接下来,我们将为我们的HashTable
类配备三个基本方法:
-
put(key, value)
:这个方法要么将新的键值对添加到哈希表中,要么更新与现有键关联的值。 -
remove(key)
:这个方法根据提供的键从哈希表中删除值及其对应的键。 -
get(key)
:这个方法从哈希表中检索与特定键关联的值。
为了使这些方法的功能得以实现,我们还需要创建一个关键组件:哈希函数。这个函数将在确定哈希表中每个键值对的存储位置中发挥至关重要的作用,成为我们实现的基础。
创建输输哈希函数
在实现核心的put
、remove
和get
方法之前,我们必须首先建立一个hash
方法。这个方法是基本的,因为它将决定哈希表中键值对的存储位置。代码如下所示:
hash(key) {
return this.#loseLoseHashCode(key);
}
hash
方法作为loseLoseHashCode
方法的包装器,将提供的key
作为其参数。这种包装器设计具有战略意义:它允许我们在不影响我们代码的其他使用哈希码的区域的情况下,未来灵活地修改哈希函数。loseLoseHashCode
方法是实际哈希计算发生的地方:
#loseLoseHashCode(key) {
if (typeof key !== 'string') {
key = this.#elementToString(key);
}
const calcASCIIValue = (acc, char) => acc + char.charCodeAt(0);
const hash = key.split('').reduce((acc, char) => calcASCIIValue, 0);
return hash % 37; // mod to reduce the hash code
}
在loseLoseHashCode
中,我们首先检查键是否已经是字符串。如果不是,我们使用我们在前几章中创建的#elementToString
方法将其转换为字符串,以确保对键的一致处理。
接下来,我们通过计算键字符串中每个字符的 ASCII 值的总和来计算一个哈希值。它利用两个强大的数组方法split
和reduce
来有效地实现这一点。它首先将字符串拆分为一个包含单个字符的数组。然后,它使用reduce
方法遍历这些字符,将它们的 ASCII 值累加到一个单一的哈希值中。对于每个字符,我们使用charCodeAt
方法检索其 ASCII 值,并将其添加到哈希变量中。
最后,为了避免处理可能不适合数字变量的较大数字,我们使用一个任意的除数(在这种情况下,37)对哈希值应用模运算(除以另一个数后的余数)。这确保了生成的哈希码在一个可管理的范围内,优化了哈希表中的存储和检索。
现在我们有了哈希函数,我们可以开始深入研究下一个方法。
在哈希表中放置键和值
在建立了我们的hash
函数之后,我们现在可以继续实现put
方法。此方法与Dictionary
类中的set
方法的功能相似,只是在命名约定上略有不同,以符合其他编程语言中的惯例。put
方法如下所示:
put(key, value) {
if (key == null && value == null) {
return false;
}
const index = this.hash(key);
this.#table[index] = value;
return true;
}
put
方法便于在哈希表中插入或更新键值对。它首先验证提供的键和值,确保它们都不是null
或undefined
。这个检查防止在哈希表中存储不完整或无意义的数据。
如果键和值都被认为是有效的,我们就继续计算给定键的哈希码。这个哈希码由hash
函数确定,将作为在底层#table
数组中存储值的索引。
最后,put
方法返回true
以指示键值对已成功插入或更新。相反,如果键或值无效,该方法返回false
,表示操作未成功。
一旦值存在于表中,我们就可以尝试检索它。
从哈希表中检索值
从HashTable
实例中检索值是一个简单的过程,由get
方法提供便利。此方法使我们能够根据其关联的键高效地访问哈希表中存储的数据:
get(key) {
if (key == null) {
return undefined;
}
const index = this.hash(key);
return this.#table[index];
}
我们首先验证输入的键,确保它不是null
或undefined
。如果键确实有效,我们继续使用先前定义的hash
函数确定其在哈希表中的位置。这个函数将键转换为一个数值哈希码,该哈希码直接对应于底层数组中值的索引。
利用这个计算出的索引,方法访问表数组中的相应元素并返回其值。这提供了一种无缝的方式从哈希表中检索数据,因为hash
函数消除了线性搜索的需要,并直接指向所需值的存储位置。
值得注意的是,在我们的
HashTable
实现中,我们已经包括了输入验证,以确保提供的键和值不是无效的(null
或undefined
)。这是一种推荐的做法,可以应用于我们在这本书中迄今为止开发的所有数据结构。通过主动验证输入,我们增强了数据结构的健壮性和可靠性,防止了由错误或不完整数据引起的错误和意外行为。
最后,让我们将注意力转向我们类中的剩余方法:remove
方法。
从哈希表中删除一个值
我们将为我们的HashTable
实现的最后一个方法是remove
方法,它旨在根据提供的键删除键值对。此方法对于维护动态和可适应的哈希表结构至关重要:
remove(key) {
if (key == null) {
return false;
}
const index = this.hash(key);
if (this.#table[index]) {
delete this.#table[index];
return true;
}
return false;
}
要成功删除一个值,我们首先需要确定它在哈希表中的位置。这是通过使用hash
函数获取给定键对应的哈希码来实现的。
接下来,我们从计算出的哈希位置检索存储的值对。如果这个值对不是null
或undefined
,表明键存在于哈希表中,我们就继续删除它。这是通过使用 JavaScript 的delete
运算符来实现的,它有效地从哈希表的内部存储中消除了键值对。
为了提供操作成功与否的反馈,如果删除成功(意味着键存在且已被删除),我们返回true
;如果键在哈希表中未找到,则返回false
。
值得注意的是,作为使用删除运算符的替代方案,我们也可以将null
或undefined
分配给相应的哈希位置,以表示其空缺。这种方法仍然可以有效地从哈希表中删除键值关联,同时可能提供管理数组中空槽位的不同策略。
现在我们类的实现已经完成,让我们看看它是如何工作的。
使用HashTable
类
让我们说明如何使用我们的HashTable
类来创建一个电子邮件地址簿:
const addressBook = new HashTable();
// Add contacts
addressBook.put('Gandalf', 'gandalf@email.com');
addressBook.put('John', 'johnsnow@email.com');
addressBook.put('Tyrion', 'tyrion@email.com');
通过检查为特定键生成的哈希码,我们可以深入了解我们哈希表的内部结构。例如,我们可以观察使用哈希方法计算出的"Gandalf"、"John"和"Tyrion"的哈希值:
console.log(addressBook.hash('Gandalf')); // 19
console.log(addressBook.hash('John')); // 29
console.log(addressBook.hash('Tyrion')); // 16
生成的哈希码(分别是 19、29 和 16)揭示了哈希表如何将这些键分布在其底层数组的不同位置。这种分布对于高效存储和检索值至关重要。
以下图表示包含这些值的HashTable
数据结构:
一个包含三个联系人的哈希表
现在,让我们测试我们的get
方法。通过执行以下代码,我们可以验证其行为:
console.log(addressBook.get('Gandalf')); // gandalf@email.com
console.log(addressBook.get('Loiane')); // undefined
由于“Gandalf”是我们HashTable
中存在的一个键,因此get
方法成功检索并输出了其关联的值“gandalf@email.com”。然而,当我们尝试检索“Loiane”的值,一个不存在的键时,get
方法返回undefined
,表示该键不在哈希表中。
接下来,让我们使用remove
方法从HashTable
中移除“Gandalf”:
console.log(addressBook.remove('Gandalf')); // true
console.log(addressBook.get('Gandalf')); // undefined
在移除“Gandalf”后,调用hash.get('Gandalf')
现在结果为undefined
。这证实了条目已被成功删除,并且该键不再存在于哈希表中。
有时,不同的键会导致相同的哈希值,这种现象被称为碰撞。让我们深入了解如何在我们的哈希表中有效地管理碰撞。
哈希表中的键碰撞
在某些情况下,不同的键可能会产生相同的哈希值。我们将这种现象称为碰撞,因为它会导致在哈希表中同一索引处尝试存储多个键值对。
例如,让我们回顾以下电子邮件地址簿:
const addressBook = new HashTable();
addressBook.put('Ygritte', 'ygritte@email.com');
addressBook.put('Jonathan', 'jonathan@email.com');
addressBook.put('Jamie', 'jamie@email.com');
addressBook.put('Jack', 'jack@email.com');
addressBook.put('Jasmine', 'jasmine@email.com');
addressBook.put('Jake', 'jake@email.com');
addressBook.put('Nathan', 'nathan@email.com');
addressBook.put('Athelstan', 'athelstan@email.com');
addressBook.put('Sue', 'sue@email.com');
addressBook.put('Aethelwulf', 'aethelwulf@email.com');
addressBook.put('Sargeras', 'sargeras@email.com');
为了说明碰撞概念,让我们检查每个提到的名字调用addressBook.hash
方法生成的输出:
4 - Ygritte
5 - Jonathan
5 - Jamie
7 - Jack
8 - Jasmine
9 - Jake
10 - Nathan
7 - Athelstan
5 - Sue
5 - Aethelwulf
10 - Sargeras
注意到多个键共享相同的哈希值:
-
内森和萨格拉斯都有一个哈希值 10。
-
杰克和艾瑟尔斯坦都有一个哈希值 7。
-
乔纳森、杰米、苏和艾瑟尔乌尔夫都共享一个哈希值 5。
在添加所有联系人后,哈希表内部发生了什么?哪些值最终被保留?为了回答这些问题,让我们引入一个toString
方法来检查哈希表的内容:
toString() {
const keys = Object.keys(this.#table);
let objString = `{${keys[0]} => ${this.#table[keys[0]].toString()}}`;
for (let i = 1; i < keys.length; i++) {
const value = this.#elementToString(this.#table[keys[i]]).toString();
objString = `${objString}\n{${keys[i]} => ${value}}`;
}
return objString;
}
toString
方法提供了哈希表内容的字符串表示。由于我们无法直接确定底层数组中哪些位置包含值,我们使用Object.keys
从#table
对象中检索一个键数组。然后我们遍历这些键,构建一个格式化的字符串,显示每个键值对。
在调用console.log(hashTable.toString())
后,我们观察到以下输出:
{4 => ygritte@email.com}
{5 => aethelwulf@email.com}
{7 => athelstan@email.com}
{8 => jasmine@email.com}
{9 => jake@email.com}
{10 => sargeras@email.com}
在他的例子中,乔纳森、杰米、苏和艾瑟尔乌尔夫都共享相同的哈希值 5。由于我们当前哈希表实现的性质,作为最后添加的艾瑟尔乌尔夫占据了位置 5。乔纳森、杰米和苏的值已被覆盖。具有其他碰撞哈希值的键也会发生类似的覆盖。
由于碰撞而丢失值在哈希表中是不希望的。这种数据结构的目的在于保留所有键值对。为了解决这个问题,我们需要碰撞解决技术。有几种方法,包括分离链接、线性探测和双重哈希,我们将详细探讨前两种。
使用分离链接技术处理碰撞
分离链接是一种广泛用于处理哈希表冲突的技术。在哈希表的每个索引(桶)中,不是存储单个值,分离链接允许每个桶持有值的链表(或类似的数据结构)。当发生冲突时(多个键哈希到相同的索引),新的键值对将简单地追加到该索引处的链表中。
为了可视化这个概念,让我们考虑上一节中用于测试的代码。如果我们应用分离链接并图示化地表示结果结构,输出将类似于以下内容:
使用分离链接技术的哈希表
在这种表示中,位置 5 将包含一个包含四个元素的链表,而位置 7 和 10 将各自包含包含两个元素的链表。位置 4、8 和 9 将各自包含包含单个元素的链表。这说明了分离链接如何通过在同一个桶中存储多个键值对的链表来有效地处理冲突。
使用分离链接技术有一些优点:
-
当发生冲突时,优雅地处理冲突,不会覆盖数据。
-
与其他冲突解决技术相比,该实现相对简单易懂。
-
链表具有动态大小,可以根据需要增长,从而可以容纳更多的冲突,而无需对哈希表进行大小调整。
-
只要链(链表)保持相对较短,搜索、插入和删除操作仍然高效(平均情况下接近O(1)),这意味着它具有令人满意的表现。
任何技术一样,它也有一些缺点:
-
链表需要额外的内存开销。
-
在最坏的情况下,如果许多键哈希到相同的索引,链表可能会变得很长,影响性能。
为了展示分离链接的实际应用,让我们创建一个新的数据结构,称为HashTableSeparateChaining
。这个实现将主要关注 put、get 和 remove 方法,展示分离链接如何增强哈希表中的冲突处理。为了在我们的哈希表中实现分离链接,我们从以下代码开始,该代码位于src/08-dictionary-hash/hash-table-separate-chaining.js
文件中:
const LinkedList = require('../06-linked-list/linked-list');
class HashTableSeparateChaining {
#table = [];
}
这个初始代码片段完成了两个关键任务:
-
它从另一个文件(
../06-linked-list/linked-list.js
)中导入LinkedList
类,这是我们之前在第六章,链表中创建的。 -
它还定义了
HashTableSeparateChaining
类,该类将封装我们的哈希表功能。该类有一个私有属性#table
,初始化为空数组。这个数组将作为我们哈希表的主干,每个元素都充当一个桶,可以潜在地存储键值对的链表。
随后的步骤将涉及填充核心方法(put
、get
和remove
),这些方法利用链表来有效地处理冲突并在哈希表中管理键值对。
使用分离链接技术插入键和值
让我们实现第一个方法,使用分离链接技术实现的put
方法如下:
put(key, value) {
if (key != null && value != null) {
const index = this.hash(key);
if (this.#table[index] == null) {
this.#table[index] = new LinkedList();
}
this.#table[index].append({key, value});
return true;
}
return false;
}
我们HashTableSeparateChaining
类的put
方法负责插入或更新键值对。其第一步是验证输入,确保键和值都不是null
或undefined
。
接下来,我们使用哈希函数计算哈希码(索引)。这个索引决定了键值对应该存储的桶。
我们首先检查计算出的索引处的桶是否为空。如果是空的,则创建一个新的LinkedList
来存储该索引处的值。这是分离链接法的核心:使用链表来容纳散列到相同索引的多个值。
最后,键值对,封装为对象{key, value}
,被追加到指定索引处的链表中。如果链表中已存在该键,则其关联的值将被更新。方法在成功插入或更新后返回true
,如果键或值无效则返回false
。
使用分离链接技术检索值
现在,让我们实现get
方法,根据给定的键从我们的HashTableSeparateChaining
类中检索值:
get(key) {
const index = this.hash(key);
const linkedList = this.#table[index];
if (linkedList != null) {
linkedList.forEach((element) => {
if (element.key === key) {
return element.value;
}
});
}
return undefined; // key not found
}
在这个方法中,我们首先将提供的键哈希,以确定其在哈希表中的对应索引。然后我们访问存储在该索引处的链表。
如果存在链表(linkedList != null
),我们使用forEach
循环遍历其元素,传递一个回调函数。对于链表中的每个元素,我们比较其键属性与输入键。如果我们找到一个匹配项,我们返回该元素的对应值属性。
如果在链表中找不到键,或者计算出的索引处的链表为空,则该方法返回undefined
,表示键不在哈希表中。
通过结合链表结构和遍历,这个get
方法有效地处理了由多个键散列到相同索引引起的潜在冲突,确保即使在冲突存在的情况下,我们也能检索到正确的值。
LinkedList
的forEach
方法
由于我们之前的LinkedList
类缺少forEach
方法,我们需要为HashTableSeparateChaining
类的get
方法中所需的效率遍历添加它。
下面是LinkedList
类的forEach
方法的实现:
forEach(callback) {
let current = this.#head;
let index = 0;
while (current) {
callback(current.data, index);
current = current.next;
index++;
}
}
current
变量跟踪列表中的当前节点。它从列表的头部(this.#head
)开始。循环继续,直到处理完所有节点(current is not null
)。在每次迭代中,提供的callback
函数被调用,传递当前节点存储的元素及其索引作为参数。current
变量更新为列表中的下一个节点,将迭代向前推进。
使用分离链接技术删除值
从HashTableSeparateChaining
实例中删除值与之前实现的remove
方法略有不同。由于使用了链表,我们现在需要特别针对并从哈希表中的相关链表中删除元素。
让我们分析remove
方法的实现:
remove(key) {
const index = this.hash(key);
const linkedList = this.#table[index];
if (linkedList != null) {
const compareFunction = (a, b) => a.key === b.key;
const toBeRemovedIndex = linkedList.indexOf({key}, compareFunction);
if (toBeRemovedIndex >= 0) {
linkedList.removeAt(toBeRemovedIndex);
if (linkedList.isEmpty()) {
this.#table[index] = undefined;
}
return true;
}
}
return false; // key not found
}
我们首先计算给定键的哈希码(index
),类似于get
方法。然后检索存储在该索引处的链表。如果链表存在(linkedList != null
),我们定义一个比较函数(compareFunction
),该函数将用于识别要删除的元素。此函数比较两个对象的键(a
和b
)。
接下来,我们使用链表的indexOf
方法找到要删除的元素的索引。indexOf
方法接受要搜索的元素({key}
)和比较函数作为参数。如果找到元素,indexOf
返回其索引;否则,返回-1。
如果找到元素(toBeRemovedIndex >= 0
),我们使用removeAt
方法从链表中删除它,该方法从指定的索引删除元素。
删除元素后,我们检查链表是否现在为空。如果是,我们将哈希表中的相应桶(this.#table[index]
)设置为undefined
,从而有效地删除空链表。最后,我们返回true
以指示删除成功。
如果在链表中找不到键,或者计算出的索引处的链表为空,我们返回false
,表示键不在哈希表中。
通过结合链表删除逻辑,这个增强的remove
方法无缝地与分离链接方法集成,即使在发生冲突的情况下也能有效地删除键值对。接下来,让我们深入了解另一种处理冲突的技术。
使用线性探测技术处理冲突
线性探测是处理哈希表冲突的另一种技术,其处理存储具有相同哈希码的多个键值对的方法与分离链接不同。
线性探测不是使用链表,而是直接在哈希表数组本身中存储所有键值对。当发生冲突时,线性探测按顺序搜索数组中下一个可用的空槽,从原始哈希索引开始。
以下图表展示了此过程:
使用线性探测技术的哈希表
让我们考虑一个场景,即我们的哈希表已经包含几个值。当添加一个新的键值对时,我们计算新键的哈希值。如果表中相应位置是空的,我们就可以直接在该索引处插入值。然而,如果位置已被占用,我们则启动线性探测。我们将索引加一,并检查下一个位置。这个过程会一直持续,直到我们找到一个空槽位或确定表已满。
线性探测提供了一个简单且空间高效的冲突解决机制。然而,它可能导致聚类,随着表的填充,连续占用的槽位会降低性能。
为了说明线性探测的实际应用,我们将开发一个新的数据结构,称为HashTableLinearProbing
。这个类将位于src/08-dictionary-hash/hash-table-linear-probing.js
文件中。我们首先定义基本结构:
class HashTableLinearProbing {
#table = [];
}
这个初始结构反映了HashTable
类,有一个私有属性#table
,初始化为一个空数组以存储键值对。然而,我们将重写put
、get
和remove
方法,以采用线性探测技术来解决冲突。这种修改将从根本上改变哈希表处理多个键哈希到相同索引的情况的方式,与分离链接方法相比,展示了一种独特的方法。
使用线性探测技术插入键和值
现在,让我们实现我们的三个核心方法中的第一个:put
方法。该方法负责在哈希表中插入或更新键值对,并采用线性探测技术来解决冲突:
put(key, value) {
if (key != null && value != null) {
let index = this.hash(key);
// linear probing to find an empty slot
while (this.#table[index] != null) {
if (this.#table[index].key === key) {
this.#table[index].value = value;
return true;
}
index++;
index %= this.#table.length;
}
this.#table[index] = {key, value};
return true;
}
return false;
}
我们首先确保键和值都是有效的(不是null
)。然后我们计算键的哈希码,这决定了值应该存储的初始位置。
然而,如果初始位置已经被占用,线性探测过程就开始了。该方法进入一个 while 循环,如果当前索引被一个值占用,则循环继续。在循环内部,它首先检查当前索引的key
是否与提供的key
匹配。如果是,则更新值,并返回true
。否则,索引递增,如果需要,过程会绕回表的开始,继续寻找空槽位。
一旦找到空槽位,键值对就存储在该索引处,并返回true
以指示成功插入。如果键或值无效,则该方法返回false
。
在某些编程语言中,我们需要定义数组的大小。使用线性探测的一个担忧是当数组没有未占用的位置时。当算法到达数组的末尾时,它需要回到开始并继续迭代其元素——如果需要,我们还需要创建一个更大的数组并将元素复制到新数组中。在 JavaScript 中,我们得益于数组的动态特性,它可以自动按需增长。因此,我们不需要显式管理表的大小或担心空间不足。这简化了我们的实现,并允许哈希表适应存储的数据量。
让我们使用线性探测来处理冲突,在我们的哈希表中模拟插入过程:
-
Ygritte: “Ygritte”的哈希值为 4。由于哈希表最初为空,我们可以直接在位置 4 插入它。
-
Jonathan: 哈希值为 5,且位置 5 是可用的,所以我们将其插入那里。
-
Jamie: 这也哈希到 5,但位置 5 现在被占用。我们探测到位置 6(5 + 1),该位置是空的,所以我们将其插入那里。
-
Jack: 哈希值为 7,且位置 7 是空的,所以我们无冲突地插入“Jack”。
-
Jasmine: 哈希值为 8,且位置 8 是可用的,所以“Jasmine”被插入。
-
Jake: 哈希值为 9,且位置 9 是开放的,这允许我们无冲突地插入“Jake”。
-
Nathan: 哈希值为 10,且位置 10 是空的,“Nathan”被顺利插入。
-
Athelstan: 这也哈希到 7,但位置 7 被“Jack”占用。我们线性探测到位置 8、9、10(都已被占用),最后在第一个可用位置 11 插入“Athelstan”。
-
Sue: 将“Sue”哈希到 5,我们发现位置 5 到 11 已被占用。我们继续探测,并在位置 12 插入“Sue”。
-
Aethelwulf: 类似地,哈希到 5,我们探测过被占用的位置,并在位置 13 插入“Aethelwulf”。
-
Sargeras: 哈希值为 10,位置 10 到 13 已被占用。我们进一步探测,并在位置 14 插入“Sargeras”。
这个模拟突出了线性探测如何通过在哈希表数组中系统地搜索下一个可用槽位来解决冲突。虽然有效,但重要的是要注意,线性探测可能导致聚集,这可能会影响后续插入和检索的性能。
接下来,让我们回顾一下如何检索一个值。
使用线性探测技术检索值
现在哈希表包含元素后,让我们实现 get 方法来根据相应的键检索值:
get(key) {
let index = this.hash(key);
while (this.#table[index] != null) {
if (this.#table[index].key === key) {
return this.#table[index].value;
}
index++;
index %= this.#table.length;
}
return undefined;
}
要检索一个值,我们首先需要确定它在哈希表中的位置。我们使用哈希函数计算给定键的初始索引。如果键存在于哈希表中,其值应位于初始索引或由于潜在的冲突而更远的位置。
如果初始索引不为空(this.#table[index] != null
),我们需要验证该位置的元素是否与我们正在搜索的键匹配。如果键匹配,我们立即返回相应的值。
然而,如果键不匹配,由于线性探测,可能已将所需的值移位。我们进入一个 while
循环,遍历表中的后续位置,增加索引,并在必要时进行环绕。循环继续,直到找到键或遇到空槽,这表示键不存在。
如果在遍历表之后,索引指向一个空槽(undefined
或 null
),这意味着未找到键,该方法返回 undefined
。
接下来,让我们回顾如何使用线性探测技术移除值。
使用线性探测技术移除值
使用线性探测从哈希表中移除值与其他数据结构相比,提出了独特的挑战。在线性探测中,由于潜在的冲突,元素不一定存储在直接从其哈希值计算出的索引处。简单地删除哈希索引处的元素可能会破坏探测序列,使其他元素不可访问。
为了解决这个问题,我们需要一种策略,在移除所需的键值对的同时,仍然保持探测序列的完整性。有两种主要的方法:
-
软删除,也称为墓碑标记。
-
硬删除和重新哈希表。
在软删除方法中,我们不是物理删除元素,而是使用特殊值(通常称为墓碑或标志)将其标记为 已删除。此值表示该槽位之前被占用,但现在可供重用。此方法易于实现,然而,随着时间的推移,搜索键值将变得缓慢,这会逐渐降低哈希表的效率。此方法还需要额外的逻辑来处理插入和搜索操作中的墓碑。以下图表展示了使用软删除方法的搜索操作过程:
使用软删除的线性探测移除
第二种方法,硬删除,涉及物理删除已删除的元素,然后重新哈希探测序列中的所有后续元素。这确保了探测序列在未来的搜索中保持完整。在此方法中,由于墓碑而造成的空间浪费不存在,并且它保持了一个最佳的探测序列。然而,它可能计算成本较高,尤其是在大型哈希表或频繁删除的情况下。此实现也比软删除更复杂。在搜索键时,这种方法可以防止找到空位,但如果需要移动元素,这意味着我们将在哈希表中移动键值。以下图表展示了此过程:
带有重新散列的线性探测删除
两种方法都有其优缺点。对于本章,我们将实现第二种方法(重新散列:将一个或多个元素移动到向后位置)。要检查懒删除方法的实现(
HashTableLinearProbingLazy
类),请参阅本书的源代码。源代码的下载链接在本书的序言中提及,或者也可以在github.com/loiane/javascript-datastructures-algorithms
访问。
让我们看看删除方法的代码。
实现带有重新散列的删除方法
我们散列表中的remove
方法与get
方法非常相似,但有一个关键的区别。它不是简单地检索值,而是删除整个键值对:
remove(key) {
let index = this.hash(key);
while (this.#table[index] != null) {
if (this.#table[index].key === key) {
delete this.#table[index];
this.#verifyRemoveSideEffect(key, index);
return true;
}
index++;
index %= this.#table.length;
}
return false;
}
在get
方法中,当我们找到键时,我们返回其值。然而,在remove
中,我们使用delete
运算符从散列表中删除元素。这可能是在原始散列位置,也可能是因为之前的冲突而位于不同的位置。
挑战在于我们不知道由于冲突,具有相同散列值的其他元素是否被放置在其他地方。如果我们简单地删除找到的元素,我们可能会在探测序列中留下空隙,导致在搜索这些被替换的元素时出现错误。为了解决这个问题,我们引入了一个辅助方法,#verifyRemoveSideEffect
。此方法负责管理删除元素可能产生的潜在副作用。其目的是将任何碰撞元素在探测序列中向后移动以填充新创建的空位,确保散列表结构的完整性。这个过程也被称为重新散列:
#verifyRemoveSideEffect(key, removedPosition) {
const size = this.#table.length;
let index = removedPosition + 1;
while (this.#table[index] != null) {
const currentKey = this.#table[index].key;
const currentHash = this.hash(currentKey);
// check if the element should be repositioned
if (currentHash <= removedPosition) {
this.#table[removedPosition] = this.#table[index];
delete this.#table[index];
removedPosition = index;
}
index++;
index %= size;
}
}
我们首先初始化几个变量:要删除的key
,键值对所在的removedPosition
,散列表数组的size
,以及一个用于遍历表的index
变量。索引从删除元素后的位置开始(removedPosition + 1
)。
该方法的核心在于一个while
循环,只要表中还有要检查的元素,它就会继续。在每次迭代中,从当前index
处的元素中提取key
及其散列值。
然后评估一个关键条件,currentHash <= removedPosition
。这检查元素的原哈希值(在线性探测之前)是否在表的起始索引到 removedPosition
的范围内。如果这个条件为 true
,则意味着元素最初由于与被移除元素的冲突而被放置在探测序列的更下游。为了纠正这一点,将当前 index
处的元素移动回现在为空的 removedPosition
。然后清除其原始位置,并将 removedPosition
更新为当前 index
。这确保了探测序列中的后续元素也被考虑进行重新定位。
此过程重复进行,增加索引,并在达到表尾时回绕,直到检查并必要时重新定位所有可能受影响的元素。通过仔细评估哈希值并重新定位元素,我们保证在移除后探测序列保持完整,确保哈希表的持续功能和效率。
这是一个简化的实现,因为我们本可以添加对边缘情况的验证并优化性能。然而,这段代码展示了如何通过线性探测管理哈希表中删除元素时的副作用的核心逻辑。
让我们模拟从我们之前创建的哈希表中移除 "Jonathan" 的过程,以展示线性探测删除和随后副作用验证的过程。
-
定位和移除 Jonathan:我们在位置 5(哈希值 5)找到 "Jonathan" 并将其移除,留下位置 5 为空。现在,我们需要评估这次移除的副作用。
-
评估 Jamie:我们将位置移动到 6,其中存储着 "Jamie"(同样具有哈希值 5)。由于 Jamie 的哈希值小于或等于被移除的位置(5),我们认识到 Jamie 是由于冲突而最初放置在这里的。我们将 Jamie 复制到位置 5,并删除位置 6 的条目。
-
跳过 Jack 和 Jasmine:我们继续到位置 7 和 8,其中存储着 "Jack"(哈希值 7)和 "Jasmine"(哈希值 8)。由于它们的哈希值大于被移除的位置(5)和当前的位置(6),我们确定它们没有受到 Jonathan 移除的影响,应保持在其当前位置。
-
我们重复此评估,从位置 9 到 11,没有发现需要重新定位的元素。
-
重新定位 Sue:在位置 12,我们找到 "Sue"(哈希值 5)。由于哈希值小于或等于被移除的位置(5),我们将 Sue 复制到位置 6(最初空出的位置)并删除位置 12 的条目。
-
重新定位 Aethelwulf 和 Sargeras:我们继续对位置 13 和 14 进行此过程,发现 "Aethelwulf"(哈希值 5)和 "Sargeras"(哈希值 10)都需要被移动回原位。Aethelwulf 被复制到位置 12,Sargeras 被复制到位置 13。
通过遵循这些步骤,remove
方法以及#verifyRemoveSideEffect
辅助函数确保了移除"Jonathan"不会在探测序列中留下任何空隙。所有元素都根据需要重新定位,以保持哈希表的完整性和可搜索性。
在我们的示例中,我们故意使用了 lose-lose 哈希函数来突出碰撞的发生并说明解决碰撞的机制。然而,在实际场景中,使用更健壮的哈希函数来最小化碰撞并优化哈希表性能至关重要。我们将在下一节深入探讨更好的哈希函数选项。
创建更好的哈希函数
一个设计良好的哈希函数在性能和碰撞避免之间取得平衡。它应该快速计算以实现高效的元素插入和检索,同时最大限度地减少碰撞的可能性,即不同的键产生相同的哈希码。虽然网上有众多实现,我们也可以创建自己的自定义哈希函数以满足特定需求。
lose-lose 哈希函数的一个替代方案是djb2
哈希函数,以其简单性和相对良好的性能而闻名。以下是其实施方法:
#djb2HashCode(key) {
if (typeof key !== 'string') {
key = this.#elementToString(key);
}
const calcASCIIValue = (acc, char) => (acc * 33) + char.charCodeAt(0);
const hash = key.split('').reduce((acc, char) => calcASCIIValue, 5381);
return hash % 1013;
}
在将key
转换为字符串之后,键字符串被分割成一个单独字符的数组。然后使用reduce
方法遍历这些字符,累积它们的 ASCII 值。从初始值 5381(在这个算法中最常见的素数)开始,累加器乘以 33(用作一个神奇数字),然后将结果与每个字符的 ASCII 码相加,从而有效地生成这些码的总和。
最后,我们将使用总数量除以另一个随机素数(1013)的余数,这个素数大于我们认为哈希表实例可能具有的大小。在我们的场景中,让我们将 1000 作为大小。
让我们回顾一下线性探测部分中的插入场景,但这次使用djb2HashCode
函数而不是loseloseHashCode
。对于相同的一组键,生成的哈希码将是:
-
807 - 伊格丽特
-
288 - 约翰逊
-
962 - 詹姆斯
-
619 - 杰克
-
275 - 贾丝明
-
877 - 杰克
-
223 - 内森
-
925 - 阿塞尔斯坦
-
502 - 苏
-
149 - 埃塞尔沃尔夫
-
711 - 萨格拉斯
尤其值得注意的是,在这个场景中我们没有观察到任何碰撞。这是由于djb2HashCode
函数提供的哈希值分布比简单的loseloseHashCode
函数得到了改进。
虽然djb2HashCode
不是最好的哈希函数,但在编程社区中,由于其简单性、有效性和在许多用例中相对良好的性能,它被广泛认可和推荐。它在这个示例中显著减少碰撞的能力强调了选择适合您特定数据和应用程序需求合适的哈希函数的重要性。
现在我们已经对哈希表有了坚实的掌握,让我们回顾集合的概念,并探讨如何利用哈希来增强其实现,创建一个称为哈希集合的强大数据结构。
哈希集合数据结构
哈希集合是一组唯一的值(不允许重复)。它结合了数学集合的特性与哈希表的效率。像哈希表一样,哈希集合使用哈希函数计算每个我们想要存储的元素(值)的哈希码。这个哈希码决定了值应该放置在底层数组中的索引(桶)。
我们可以重用本章中创建的代码来创建哈希集合数据结构,但有一个重要的细节:在插入操作之前,我们需要检查重复的值。
使用哈希集合的好处是,可以保证集合中的所有值都是唯一的。在 JavaScript 中,原生的 Set 类也被认为是哈希集合数据结构。例如,我们可以使用哈希集合来存储所有英语单词(不包括它们的定义)。
Maps 和 TypeScript
在 TypeScript 中实现像映射或哈希映射这样的数据结构可以显著受益于语言静态类型的能力。通过显式定义变量和方法参数的类型,我们增强了代码的清晰度,减少了运行时错误的风险,并使更好的工具支持成为可能。
让我们检查 HashTable
类的 TypeScript 签名:
class HashTable<V> {
private table: V[] = [];
private loseLoseHashCode(key: string): number { }
hash(key: string): number { }
put(key: string, value: V): boolean { }
get(key: string): V { }
remove(key: string): boolean { }
}
在这个 TypeScript 实现中,我们引入了一个泛型类型参数 <V>
来表示哈希表中存储的值的类型。这允许我们创建可以存储任何特定类型值的哈希表(例如:HashTable<string>
,HashTable<number>
等)。table
属性被定义为泛型类型 V[]
的数组,表示它存储了指定类型的值数组。
使用 TypeScript 的一个显著优势在 loseLoseHashCode
方法中变得明显。由于 key
参数被显式地定义为 string
类型,我们不再需要在方法中检查其类型。类型系统保证只有字符串会被作为键传递,消除了冗余检查的需要,并简化了代码:
privaye loseLoseHashCode(key: string) {
const calcASCIIValue = (acc, char) => acc + char.charCodeAt(0);
const hash = key.split('').reduce(calcASCIIValue, 0);
return hash % 37;
}
通过利用 TypeScript 的类型系统,我们增强了哈希表实现的健壮性、可维护性和可读性,使其更容易在大项目中推理和工作。
审查映射和哈希映射的效率
让我们通过审查每个方法的 Big O 符号来审查其执行时间效率:
方法 | 字典 | 哈希表 | 分离链接 | 线性探测 |
---|---|---|---|---|
put(key, value) |
O(1) | O(1) | O(1)* | O(1)* |
get(key) |
O(1) | O(1) | O(1)* | O(1)* |
remove(key) |
O(1) | O(1) | O(1)* | O(1)* |
对于Dictionary
类,由于可以直接通过字符串化的键访问底层对象,平均情况下所有操作通常是O(1)。
对于HashMap
类,类似于字典,在平均情况下,所有操作通常是O(1),假设有一个好的哈希函数。然而,它缺乏冲突处理,因此冲突会导致数据丢失或覆盖现有条目。
对于HashTableSeparateChaining
,在平均情况下,所有操作仍然是O(1)。分离链接法有效地处理了冲突,因此即使有一些冲突,每个索引处的链表也可能会保持较短。在最坏的情况下(所有键都散列到相同的索引),性能下降到O(n),因为你需要遍历整个链表。
最后,对于HashTableLinearProbing
,如果哈希表稀疏填充(低负载因子),平均情况下的复杂度也是O(1)。然而,随着负载因子的增加和冲突变得更加频繁,线性探测可能导致聚集,其中多个键被放置在连续的槽位中。这可能会将最坏情况下的性能降低到O(n)。
检查执行时间,哈希函数的质量会显著影响性能。一个好的哈希函数最小化冲突,使性能更接近O(1)。在许多情况下,分离链接法通常比线性探测更优雅地处理冲突,尤其是在较高的负载因子下。在哈希表中,负载因子是一个关键指标,用于衡量表有多满。它定义为:
负载因子 = (表中元素的数量)/(总桶数)
接下来,让我们回顾每种数据结构的空间复杂度:
数据结构 | 空间复杂度 | 说明 |
---|---|---|
字典 | O(n) | 使用的空间随着存储的键值对数量线性增长。每个对都占用底层对象的空间。 |
哈希表 | O(n) | 数组的大小是固定的,但你仍然需要为每个存储的键值对留出空间。未使用的槽位也会消耗空间,特别是如果冲突很少的话。 |
分离链接 | O(n + m) | n是元素的数量,m是桶的数量。除了元素的空间外,每个桶还持有链表,这增加了内存开销。 |
线性探测 | O(n) | 与简单的哈希表类似,但线性探测通常比分离链接法更有效地使用空间,因为没有链表。 |
我们应该使用哪种数据结构?
如果我们在有 150 个桶的哈希表中存储 100 个元素:
-
字典:空间使用与 100 个元素成比例。
-
哈希表(没有冲突处理):空间使用仍然是 100 个元素,加上剩余 50 个空桶中可能浪费的空间。
-
HashTableSeparateChaining:空间使用是 100 个元素,加上每个桶中链表的额外开销(这可能会根据冲突的数量而变化)。
-
HashTableLinearProbing:空间使用可能接近 100 个元素,因为它试图更密集地填充数组。
到最后,一切都取决于我们正在处理的场景。
让我们通过一些练习来将我们的知识付诸实践。
练习
我们将使用映射数据结构从整数数字转换成罗马数字,来解决来自 LeetCode 的一个练习。
然而,LeetCode 上有许多有趣的练习,我们应该能够用我们在本章中学到的概念来解决它们。以下是一些额外的建议,你可以尝试解决,你还可以在本书的源代码中找到解决方案及其解释:
-
- 两数之和:给定一个整数数组,找到两个数,它们的和等于目标总和。这是一个经典问题,它介绍了如何使用哈希表存储补数并快速找到匹配项。
-
- 有效的字母异位词:确定两个字符串是否是彼此的字母异位词(包含相同的字符,但顺序不同)。哈希表对于计数字符频率很有用。
-
- 设计 HashSet:实现哈希集合数据结构。
-
- 设计 HashMap:实现哈希映射数据结构。
-
- 罗马数字转整数:与我们将要解决的问题类似,但方向相反。
整数转罗马数字
我们将要解决的练习是位于 leetcode.com/problems/integer-to-roman/description/
的 12. 整数转罗马数字 问题。
当使用 JavaScript 或 TypeScript 解决这个问题时,我们需要在函数 intToRoman(num: number): string
中添加我们的逻辑,该函数接收一个数值输入并返回其对应的罗马数字表示字符串。
让我们探索使用映射数据结构来简化转换过程的一个解决方案:
function intToRoman(num: number): string {
const romanMap = {
M:1000, CM:900, D:500, CD:400, C:100, XC:90,
L:50, XL:40, X:10, IX:9, V:5, IV:4, I:1
};
let result = '';
for(let romanNum in romanMap){
while (num >= romanMap[romanNum]) {
result += romanNum;
num -= romanMap[romanNum];
}
}
return result;
}
这个函数的核心是 romanMap
,它作为一个字典,将罗马数字符号与它们对应的整数值关联起来。这个映射包括标准的罗马数字(M, D, C)和用于减法的特殊组合(CM, XC)。在转换过程中,键按值降序排列对于使用的贪婪算法至关重要,因此我们不需要在转换过程之前对数据结构进行排序。
接下来,我们初始化一个空字符串,result
,用于累积罗马数字字符。然后它进入一个循环,遍历 romanMap
的键。
在循环中,一个嵌套的 while
循环会反复检查输入的数字 (num
) 是否大于或等于当前罗马数字的整数值。如果是,则将罗马数字追加到 result
字符串中,并从 num
中减去其整数值。这个过程会一直持续到 num
变得小于罗马数字的值,这表明我们需要移动到映射中下一个更小的罗马数字。
通过迭代选择可以适应剩余输入值的最大可能罗马数字,该函数以贪婪的方式构建罗马数字表示。一旦遍历完整个romanMap
,函数将返回完成的字符串结果,此时它现在包含了原始输入整数的准确罗马数字等价物。
该函数的时间复杂度是O(1)。它遍历一组固定的罗马数字符号(总共 13 个符号)。对于每个符号,它执行一系列的减法和连接操作。操作次数受符号数量和输入数字最大值的限制,但由于符号及其值是固定的,操作不会随着输入大小的增加而扩展。
空间复杂度也是O(1)。romanMap
对象是一个常量,其大小不会随着输入而改变,因此它贡献了一个常量空间开销。结果字符串的大小基于表示输入数字所需的罗马数字字符数量。然而,由于表示任何整数所需的罗马数字字符的最大数量是固定的(例如:3999 是 MMMCMXCIX),这也贡献了一个常量空间开销。没有使用与输入大小成比例扩展的额外数据结构。
我们也可以使用原生的Map
类来存储romanMap
键值对,然而,在for
循环中,我们需要首先提取键并对其进行排序。Map
类不保证键的顺序,因此我们需要在迭代之前提取和排序键,这会增加开销。所以,在这种情况下,最简单的数据结构在我们的解决方案中更有效。
摘要
在本章中,我们探索了字典的世界,掌握了添加、删除和检索元素的技术,同时了解了它们与集合的不同之处。我们深入研究了哈希的概念,学习了如何构建哈希表(或哈希映射)以及实现插入、删除和检索等基本操作。此外,我们还学习了如何构造哈希函数,并考察了处理冲突的两种不同技术:分离链接和线性探测。
我们还探讨了 JavaScript 的内置Map
类,以及专门的WeakMap
和WeakSet
类,它们为内存管理提供了独特的功能。通过各种实际示例和 LeetCode 练习,我们巩固了对这些数据结构和它们应用的理解。
带着这些知识,我们现在准备迎接下一章中递归的概念,为探索另一个基本的数据结构:树铺平道路。