JavaScript-数据结构和算法教程-全-
JavaScript 数据结构和算法教程(全)
一、大 O 符号
O①是神圣的。
-哈米德·提胡什
在学习如何实现算法之前,你应该了解如何分析算法的有效性。这一章将集中在时间和算法空间复杂性分析的 Big-O 符号的概念上。本章结束时,你将理解如何从时间(执行时间)和空间(消耗的内存)两方面分析一个算法的实现。
大 O 符号初级读本
Big-O 符号衡量算法的最坏情况复杂度。在 Big-O 符号中, n 表示输入的数量。问 Big-O 的问题如下:“当 n 接近无穷大时会发生什么?”
当你实现一个算法时,Big-O 符号很重要,因为它告诉你这个算法有多有效。图 1-1 显示了一些常见的 Big-O 符号。
图 1-1
常见的大 O 复杂性
下面几节用一些简单的例子说明了这些常见的时间复杂性。
常见示例
O(1)相对于输入空间不变。因此,O(1)被称为恒定时间。O(1)算法的一个例子是通过索引访问数组中的项。O( n )是线性时间,适用于在最坏情况下必须做 n 运算的算法。
O( n )算法的一个例子是打印从 0 到 n -1 的数字,如下所示:
1 function exampleLinear(n) {
2 for (var i = 0 ; i < n; i++ ) {
3 console.log(i);
4 }
5 }
同样,O(n??【2】??)是二次时间,O(n3 是三次时间。这些复杂性的例子如下所示:
1 function exampleQuadratic(n) {
2 for (var i = 0 ; i < n; i++ ) {
3 console.log(i);
4 for (var j = i; j < n; j++ ) {
5 console.log(j);
6 }
7 }
8 }
1 function exampleCubic(n) {
2 for (var i = 0 ; i < n; i++ ) {
3 console.log(i);
4 for (var j = i; j < n; j++ ) {
5 console.log(j);
6 for (var k = j; j < n; j++ ) {
7 console.log(k);
8 }
9 }
10 }
11 }
最后,对数时间复杂度的一个示例算法是打印 2 和 n 之间的 2 的幂的元素。例如,exampleLogarithmic(10)
将打印以下内容:
2,4,8,16,32,64
对数时间复杂性的效率在大量输入(如一百万项)的情况下是显而易见的。虽然 n 是一百万,但是exampleLogarithmic
将只打印 19 项,因为 log 2 (1,000,000) = 19.9315686。实现这种对数行为的代码如下:
1 function exampleLogarithmic(n) {
2 for (var i = 2 ; i <= n; i= i*2 ) {
3 console.log(i);
4 }
5 }
大 O 符号的规则
让我们把一个算法的复杂度表示为 f( n )。 n 表示输入次数,f( n ) time 表示需要的时间,f( n ) space 表示算法需要的空间(附加内存)。算法分析的目标是通过计算 f( n )来了解算法的效率。然而,计算 f( n )可能具有挑战性。Big-O 符号提供了一些帮助开发人员计算 f( n )的基本规则。
-
系数法则:若 f( n )为 O(g( n ),则 kf( n )为 O(g( n )),对于任意常数 k > 0。第一个规则是系数规则,它排除与输入大小 n 无关的系数。这是因为随着 n 接近无穷大,另一个系数变得可以忽略不计。
-
求和规则:若 f( n )为 O(h( n )、g( n )为 O(p( n )),则 f( n )+g( n )为 O(h( n )+p( n )。求和规则简单地说明,如果合成的时间复杂度是两个不同时间复杂度的和,则合成的 Big-O 符号也是两个不同的 Big-O 符号的和。
-
乘积法则:如果 f( n )是 O(h(n))g(n)是 O(p( n )),那么 f( n )g( n )是 O(h( n )p( n ))。类似地,乘积法则表明,当时间复杂性增加时,Big-O 也会增加。
-
传递规则:若 f( n )为 O(g( n )),g( n )为 O(h( n )),则 f( n )为 O(h( n ))。传递规则是一种简单的方式来说明相同的时间复杂度具有相同的 Big-O。
-
多项式法则:若 f( n 为 k 次多项式,则 f( n )为 O(nk)。直观地说,多项式规则表明多项式时间复杂性具有相同多项式次数的 Big-O。
-
对数幂法则:对数( n k)对于任意常数 k > 0 为 O(对数( n ))。对于幂规则的对数,对数函数中的常数在 Big-O 符号中也会被忽略。
应特别注意前三条规则和多项式规则,因为它们是最常用的。我将在下面的小节中讨论这些规则。
系数法则:“去掉常数”
我们先来回顾一下系数法则。这个规则是最容易理解的规则。它只需要您忽略任何与输入大小无关的常量。输入大时,Big-O 中的系数可以忽略不计。因此,这是 Big-O 记数法最重要的规则。
- 若 f( n )为 O(g( n )),则 kf( n )为 O(g( n )),对于任意常数 k > 0。
这意味着 5f( n )和 f( n )都有相同的大 O 符号 O(f( n ))。
下面是一个时间复杂度为 O( n )的代码块的例子:
1 function a(n){
2 var count =0;
3 for (var i=0;i<n;i++){
4 count+=1;
5 }
6 return count;
7 }
这段代码有 f( n ) = n 。这是因为它增加了count
n 次。因此,该函数的时间复杂度为 O( n ):
1 function a(n){
2 var count =0;
3 for (var i=0;i<5*n;i++){
4 count+=1;
5 }
6 return count;
7 }
这个块有 f( n ) = 5 n 。这是因为它从 0 运行到 5 n 。然而,前两个例子都有 O( n )的 Big-O 符号。简单来说,这是因为如果 n 接近无穷大或者另一个大数,那四个额外的运算就没有意义了。它将执行第次次。任何常数在 Big-O 符号中都是可以忽略的。
下面的代码块演示了另一个具有线性时间复杂度的函数,但是在第 6 行中有一个额外的操作:
1 function a(n){
2 var count =0;
3 for (var i=0;i<n;i++){
4 count+=1;
5 }
6 count+=3;
7 return count;
8 }
最后,这段代码有 f( n ) = n +1。上一次操作的结果是+1(计数+=3)。这仍然有一个大 O 符号 O( n )。这是因为 1 操作不依赖于输入 n 。随着 n 趋近于无穷大,它将变得可以忽略不计。
求和规则:“将 Big-Os 相加”
求和规则理解起来很直观;可以增加时间复杂度。想象一个包含两个其他算法的主算法。主算法 Big-O 符号只是另外两个 Big-O 符号的总和。
- 如果 f( n )是 O(h(n))g(n)是 O(p( n )),那么 f( n )+g( n )是 O(h( n )+p( n )。
在应用这个规则之后,记住应用系数规则是很重要的。
下面的代码块演示了一个带有两个主循环的函数,这两个循环的时间复杂度必须单独考虑,然后求和:
1 function a(n){
2 var count =0;
3 for (var i=0;i<n;i++){
4 count+=1;
5 }
6 for (var i=0;i<5*n;i++){
7 count+=1;
8 }
9 return count;
10 }
在这个例子中,第 4 行有 f( n ) = n ,第 7 行有 f( n ) = 5 n 。这导致 6 个 n 个。但是应用系数法则,最后的结果是 O( n ) = n 。
产品规则:“乘以大操作系统”
乘积法则简单地说明了 Big-Os 可以相乘到什么程度。
- 如果 f( n )是 O(h(n))g(n)是 O(p( n )),那么 f( n )g( n )是 O(h( n )p( n ))。
以下代码块演示了一个具有两个嵌套的for
循环的函数,该函数应用了乘积规则:
1 function (n){
2 var count =0;
3 for (var i=0;i<n;i++){
4 count+=1;
5 for (var i=0;i<5*n;i++){
6 count+=1;
7 }
8 }
9 return count;
10 }
在这个例子中,f(n)= 5nn,因为第 7 行运行 5 n 次,总共 n 次迭代。因此,这导致总共 5 个 n 个2 个操作。应用系数法则,结果是 O(n)=n*2。
多项式规则:“大到 k 的幂”
多项式规则表明,多项式时间复杂性具有相同多项式次数的 Big-O 符号。
数学上,它如下:
- 如果 f( n )是 k 次多项式,那么 f( n )是 O(nk)。
下面的代码块只有一个二次时间复杂度的for
循环:
1 function a(n){
2 var count =0;
3 for (var i=0;i<n*n;i++){
4 count+=1;
5 }
6 return count;
7 }
在这个例子中,f(n)=nˇ2,因为第 4 行运行了 n * n 次迭代。
这是对 Big-O 符号的一个快速概述。随着这本书的进展,还会有更多的内容。
摘要
Big-O 对于分析和比较算法的效率非常重要。对 Big-O 的分析从查看代码和应用规则来简化 Big-O 符号开始。以下是最常用的规则:
-
消除系数/常数(系数规则)
-
将大 0 相加(求和规则)
-
乘法大操作系统(产品规则)
-
通过查看循环来确定 Big-O 符号的多项式(多项式规则)
练习
计算每个练习代码片段的时间复杂度。
练习 1
1 function someFunction(n) {
2
3 for (var i=0;i<n*1000;i++) {
4 for (var j=0;j<n*20;j++) {
5 console.log(i+j);
6 }
7 }
8
9 }
练习 2
1 function someFunction(n) {
2
3 for (var i=0;i<n;i++) {
4 for (var j=0;j<n;j++) {
5 for (var k=0;k<n;k++) {
6 for (var l=0;l<10;l++) {
7 console.log(i+j+k+l);
8 }
9 }
10 }
11 }
12
13 }
练习 3
1 function someFunction(n) {
2
3 for (var i=0;i<1000;i++) {
4 console.log("hi");
5 }
6
7 }
练习
1 function someFunction(n) {
2
3 for (var i=0;i<n*10;i++) {
4 console.log(n);
5 }
6
7 }
练习 5
1 function someFunction(n) {
2
3 for (var i=0;i<n;i*2) {
4 console.log(n);
5 }
6
7 }
练习 6
1 function someFunction(n) {
2
3 while (true){
4 console.log(n);
5 }
6 }
答案
-
o(n2
有两个嵌套循环。忽略 n 前面的常数。
-
o(n3
有四个嵌套循环,但最后一个循环只运行到 10。
-
O(1)
持续的复杂性。该函数从 0 到 1000。这个不取决于 n 。
-
O( n )
线性复杂度。该功能从 0 到 10 n 运行。常量在 Big-O 中被忽略。
-
o(日志2n
对数复杂度。对于给定的 n ,这只运行 log 2 n 次,因为 I 是通过乘以 2 来递增的,而不是像其他例子中那样加 1。
-
O(∞)
无限循环。这个功能不会结束。
二、JavaScript:独特的部分
本章将简要讨论 JavaScript 的语法和行为的一些例外和案例。作为一种动态的解释型编程语言,它的语法不同于传统的面向对象编程语言。这些概念是 JavaScript 的基础,将帮助您更好地理解用 JavaScript 设计算法的过程。
JavaScript 范围
范围定义了对 JavaScript 变量的访问。在 JavaScript 中,变量可以属于全局范围,也可以属于局部范围。全局变量是属于全局范围的变量,可以从程序的任何地方访问。
全球声明:全球范围
在 JavaScript 中,可以不使用任何运算符来声明变量。这里有一个例子:
1 test = "sss";
2 console.log(test); // prints "sss"
然而,这会创建一个全局变量,这是 JavaScript 中最糟糕的做法之一。不惜一切代价避免这样做。总是使用var
或let
来声明变量。最后,当声明不会被修改的变量时,使用const
。
带变量的声明:功能范围
在 JavaScript 中,var
是一个用于声明变量的关键字。这些变量声明一直“浮动”到顶部。这就是所谓的可变提升。脚本底部声明的变量不会是 JavaScript 程序运行时最后执行的东西。
这里有一个例子:
1 function scope1(){
2 var top = "top";
3 bottom = "bottom";
4 console.log(bottom);
5
6 var bottom;
7 }
8 scope1(); // prints "bottom" - no error
这是如何工作的?前面的和下面写的是一样的:
1 function scope1(){
2 var top = "top";
3 var bottom;
4 bottom = "bottom"
5 console.log(bottom);
6 }
7 scope1(); // prints "bottom" - no error
位于函数最后一行的bottom
变量声明被浮动到顶部,记录变量就可以了。
关于var
关键字要注意的关键是变量的作用域是最近的函数作用域。这是什么意思?
在下面的代码中,scope2
函数是最接近print
变量的函数范围:
1 function scope2(print){
2 if(print){
3 var insideIf = '12';
4 }
5 console.log(insideIf);
6 }
7 scope2(true); // prints '12' - no error
举例来说,前面的函数相当于以下函数:
1 function scope2(print){
2 var insideIf;
3
4 if(print){
5 insideIf = '12';
6 }
7 console.log(insideIf);
8 }
9 scope2(true); // prints '12' - no error
在 Java 中,这种语法会抛出一个错误,因为insideIf
变量通常只在那个if
语句块中可用,而不在它之外。
这是另一个例子:
1 var a = 1;
2 function four() {
3 if (true) {
4 var a = 4;
5 }
6
7 console.log(a); // prints '4'
8 }
4
被打印,而不是1
的全局值,因为它被重新声明并在该范围内可用。
带字母的声明: Block Scope
另一个可以用来声明变量的关键字是let
。以这种方式声明的任何变量都在最近的块范围内(意味着在声明它们的{}
内)。
1 function scope3(print){
2 if(print){
3 let insideIf = '12';
4 }
5 console.log(insideIf);
6 }
7 scope3(true); // prints ''
在这个例子中,没有任何东西被记录到控制台,因为insideIf
变量只在if
语句块中可用。
等式和类型
JavaScript 的数据类型与 Java 等传统语言不同。让我们探讨一下这是如何影响诸如相等比较之类的事情的。
变量类型
在 JavaScript 中,有七种基本数据类型:布尔型、数字型、字符串型、未定义型、对象型、函数型和符号型(不讨论符号)。这里突出的一点是,undefined 是一个赋给刚刚声明的变量的原始值。typeof
是用于返回变量类型的原始运算符。
1 var is20 = false; // boolean
2 typeof is20; // boolean
3
4 var age = 19;
5 typeof age; // number
6
7 var lastName = "Bae";
8 typeof lastName; // string
9
10 var fruits = ["Apple", "Banana", "Kiwi"];
11 typeof fruits; // object
12
13 var me = {firstName:"Sammie", lastName:"Bae"};
14 typeof me; // object
15
16 var nullVar = null;
17 typeof nullVar; // object
18
19 var function1 = function(){
20 console.log(1);
21 }
22 typeof function1 // function
23
24 var blank;
25 typeof blank; // undefined
真假检验
真/假检查用于if
语句。在许多语言中,if()
函数中的参数必须是布尔类型。然而,JavaScript(和其他动态类型语言)在这方面更加灵活。这里有一个例子:
1 if(node){
2 ...
3 }
这里,node
是一些变量。如果该变量为空、null 或未定义,它将被评估为false
。
以下是评估为false
的常用表达式:
-
false
-
Zero
-
空字符串(
''
和""
) -
NaN
-
undefined
-
null
以下是评估为true
的常用表达式:
-
true
-
0 以外的任何数字
-
非空字符串
-
非空对象
这里有一个例子:
1 var printIfTrue = ";
2
3 if (printIfTrue) {
4 console.log('truthy');
5 } else {
6 console.log('falsey'); // prints 'falsey'
7 }
=== vs ==
JavaScript 是一种脚本语言,变量在声明时不会被赋予类型。相反,类型在代码运行时被解释。
因此,===
用于比==
更严格地检查相等性。===
检查类型和值,而==
只检查值。
1 "5" == 5 // returns true
2 "5" === 5 // returns false
"5" == 5
返回true
,因为"5"
在比较之前被强制为一个数字。另一方面,"5" === 5
返回false
,因为"5"
的类型是字符串,而 5 是数字。
目标
大多数强类型语言如 Java 使用isEquals()
来检查两个对象是否相同。您可能想简单地使用==
操作符来检查 JavaScript 中的两个对象是否相同。
但是,这不会计算到true
。
1 var o1 = {};
2 var o2 = {};
3
4 o1 == o2 // returns false
5 o1 === o2 // returns false
虽然这些对象是等价的(相同的属性和值),但它们并不相等。也就是说,变量在内存中有不同的地址。
这就是为什么大多数 JavaScript 应用程序使用具有isEqual(
object1
、object2
)
函数的实用程序库如 lodash1或下划线、 2 来严格检查两个对象或值。这是通过实现一些基于属性的相等性检查来实现的,其中比较对象的每个属性。
在此示例中,将比较每个属性以获得准确的对象相等结果。
1 function isEquivalent(a, b) {
2 // arrays of property names
3 var aProps = Object.getOwnPropertyNames(a);
4 var bProps = Object.getOwnPropertyNames(b);
5
6 // If their property lengths are different, they're different objects
7 if (aProps.length != bProps.length) {
8 return false;
9 }
10
11 for (var i = 0; i < aProps.length; i++) {
12 var propName = aProps[i];
13
14 // If the values of the property are different, not equal
15 if (a[propName] !== b[propName]) {
16 return false;
17 }
18 }
19
20 // If everything matched, correct
21 return true;
22 }
23 isEquivalent({'hi':12},{'hi':12}); // returns true
但是,这对于只有字符串或数字作为属性的对象仍然有效。
1 var obj1 = {'prop1': 'test','prop2': function (){} };
2 var obj2 = {'prop1': 'test','prop2': function (){} };
3
4 isEquivalent(obj1,obj2); // returns false
这是因为函数和数组不能简单地使用==
操作符来检查相等性。
1 var function1 = function(){console.log(2)};
2 var function2 = function(){console.log(2)};
3 console.log(function1 == function2); // prints 'false'
虽然这两个函数执行相同的操作,但是函数在内存中有不同的地址,因此相等运算符返回false
。基本的相等检查操作符==
和===
只能用于字符串和数字。要实现对象的等价性检查,需要检查对象中的每个属性。
摘要
JavaScript 有一种不同于大多数编程语言的变量声明技术。var
在函数范围内声明变量,let
在块范围内声明变量,变量在全局范围内可以不用任何运算符声明;然而,任何时候都应该避免全局范围。对于类型检查,应该使用typeof
来验证预期的类型。最后,对于相等性检查,使用==
检查值,使用===
检查类型和值。但是,只能在数字、字符串和布尔值等非对象类型上使用它们。
三、JavaScript 数字
本章将重点介绍 JavaScript 数字运算、数字表示、Number
对象、常用数字算法和随机数生成。在本章结束时,你将理解如何在 JavaScript 中处理数字,以及如何实现质因数分解,这是加密的基础。
编程语言的数字运算允许你计算数值。以下是 JavaScript 中的数字运算符:
+ : addition
- : subtraction
/ : division
* : multiplication
% : modulus
这些操作符在其他编程语言中普遍使用,并不特定于 JavaScript。
数系
JavaScript 对数字使用 32 位浮点表示,如图 3-1 所示。在本例中,该值为 0.15625。如果符号位为 1,符号位(第 31 位)表示数字为负。接下来的 8 位(第 30 位至第 23 位)表示指数值,即 e。最后,剩余的 23 位表示分数值。
图 3-1
32 位浮点数系统
对于 32 位,该值通过以下深奥的公式计算:
图 3-1 显示了 32 位的如下分解:
符号= 0
e = (0111100) 2 = 124(以 10 为基数)
这将导致以下结果:
值= 1 x 2124-127x 1.25 = 1 x 2-3x 1.25 = 0.15625
对于小数,这种浮点数系统会在 JavaScript 中导致一些舍入错误。例如,0.1 和 0.2 无法精确表示。
因此,0.1 + 0.2 === 0.3 得出false
。
1 0.1 + 0.2 === 0.3; // prints 'false'
要真正理解为什么 0.1 不能正确地表示为 32 位浮点数,必须理解二进制。用二进制表示许多小数需要无限多的位数。这是因为二进制数由 2 n 表示,其中 n 是整数。
在努力计算 0.1 的同时,长除法会一直进行下去。如图 3-2 所示,1010 在二进制中代表 10。试图计算 0.1 (1/10)会导致小数点位数不确定。
图 3-2
长除法为 0.1
JavaScript 数字对象
幸运的是,JavaScript 中有一些Number
对象的内置属性可以帮助解决这个问题。
整数舍入
由于 JavaScript 使用浮点来表示所有数字,因此整数除法不起作用。
像 Java 这样的编程语言中的整数除法只是简单地计算除法表达式的商。
例如,Java 中的 5/4 是 1,因为商是 1(尽管还有 1 的余数)。但是,在 JavaScript 中,它是一个浮点。
1 5/4; // 1.25
这是因为 Java 要求您将整数显式地输入为整数。因此,结果不能是浮点。但是,如果 JavaScript 开发人员想要实现整数除法,他们可以执行以下操作之一:
Math.floor - rounds down to nearest integer
Math.round - rounds to nearest integer
Math.ceil - rounds up to nearest integer
Math.floor(0.9); // 0
Math.floor(1.1); // 1
Math.round(0.49); // 0
Math.round(0.5); // 1
Math.round(2.9); // 3
Math.ceil(0.1); // 1 Math.ceil(0.9); // 1 Math.ceil(21); // 21 Math.ceil(21.01); // 22
号码。希腊语字母之第五字
Number.EPSILON
返回两个可表示数字之间的最小间隔。这对于浮点近似的问题很有用。
1 function numberEquals(x, y) {
2 return Math.abs(x - y) < Number.EPSILON;
3 }
4
5 numberEquals(0.1 + 0.2, 0.3); // true
该功能通过检查两个数字之间的差值是否小于Number.EPSILON
来工作。记住Number.EPSILON
是两个可表示的数之间的最小差值。0.1+0.2 和 0.3 的差别会比Number.EPSILON
小。
最大限度
Number.MAX_SAFE_INTEGER
返回最大整数。
1 Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2; // true
这会返回true
,因为它不能再高了。但是,它不适用于浮点小数。
1 Number.MAX_SAFE_INTEGER + 1.111 === Number.MAX_SAFE_INTEGER + 2.022; // false
Number.MAX_VALUE
返回可能的最大浮点数。
Number.MAX_VALUE
等于 1.7976931348623157e+308。
1 Number.MAX_VALUE + 1 === Number.MAX_VALUE + 2; // true
与 like Number.MAX_SAFE_INTEGER
不同,它使用双精度浮点表示,也适用于浮点。
1 Number.MAX_VALUE + 1.111 === Number.MAX_VALUE + 2.022; // true
最低限度
Number.MIN_SAFE_INTEGER
返回最小的整数。
Number.MIN_SAFE_INTEGER
等于-9007199254740991。
1 Number.MIN_SAFE_INTEGER - 1 === Number.MIN_SAFE_INTEGER - 2; // true
这将返回true
,因为它不能再小了。但是,它不适用于浮点小数。
1 Number.MIN_SAFE_INTEGER - 1.111 === Number.MIN_SAFE_INTEGER - 2.022; // false
Number.MIN_VALUE
返回可能的最小浮点数。
Number.MIN_VALUE
等于 5e-324。这不是一个负数,因为它是最小的浮点数,这意味着Number.MIN_VALUE
实际上大于Number.MIN_- SAFE_INTEGER
。
Number.MIN_VALUE
也是最接近零的浮点。
1 Number.MIN_VALUE - 1 == -1; // true
这是因为这类似于写0 - 1 == -1
。
无穷
唯一大于Number.MAX_VALUE
的是Infinity
,唯一小于Number.MAX_SAFE_INTEGER
的是-Infinity
。
1 Infinity > Number.MAX_SAFE_INTEGER; // true
2 -Infinity < Number.MAX_SAFE_INTEGER // true;
3 -Infinity -32323323 == -Infinity -1; // true
这等于true
,因为没有比-Infinity
更小的了。
尺寸汇总
这个不等式总结了 JavaScript 数字从最小(左)到最大(右)的大小:
-Infinity < Number.MIN_SAFE_INTEGER < Number.MIN_VALUE < 0 < Number.MAX_SAFE_IN- TEGER < Number.MAX_VALUE < Infinity
数字算法
讨论最多的涉及数字的算法之一是测试一个数字是否是质数。现在我们来回顾一下。
素性检验
素性测试可以通过从 2 到 n 的迭代来完成,检查模除(余数)是否等于零。
1 function isPrime(n){
2 if (n <= 1) {
3 return false;
4 }
5
6 // check from 2 to n-1
7 for (var i=2; i<n; i++) {
8 if (n%i == 0) {
9 return false;
10 }
11 }
12
13 return true;
14 }
时间复杂度: O( n
时间复杂度为 O( n ),因为该算法检查从 0 到 n 的所有数。
这是一个很容易改进的算法的例子。想想这个方法是怎么迭代 2 到 n 的。有没有可能找到一个模式,让算法更快?首先,可以忽略 2 的任何倍数,但可能有更多的优化。
让我们列出一些质数。
2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97
这很难注意到,但是所有的素数都是 6k ^ 1 的形式,除了 2 和 3,其中 k 是某个整数。这里有一个例子:
5 = (6-1) , 7 = ((1*6) + 1), 13 = ((2*6) + 1) etc
还要认识到,为了测试质数 n ,循环只需测试到 n 的平方根。这是因为如果 n 的平方根不是素数, n 就不是数学定义上的素数。
1 function isPrime(n){
2 if (n <= 1) return false;
3 if (n <= 3) return true;
4
5 // This is checked so that we can skip
6 // middle five numbers in below loop
7 if (n%2 == 0 || n%3 == 0) return false;
8
9 for (var i=5; i*i<=n; i=i+6){
10 if (n%i == 0 || n%(i+2) == 0)
11 return false;
12 }
13
14 return true;
15 }
时间复杂度: O( sqrt ( n ))
这个改进的解决方案大大降低了时间复杂度。
质因数分解
另一个需要理解的有用算法是确定一个数的质因数分解。质数是加密(在第四章中介绍)和哈希(在第十一章中介绍)的基础,而质因数分解是确定哪些质数乘以一个给定的数的过程。给定 10,它将打印 5 和 2。
1 function primeFactors(n){
2 // Print the number of 2s that divide n
3 while (n%2 == 0) {
4 console.log(2);
5 n = n/2;
6 }
7
8 // n must be odd at this point. So we can skip one element (Note i = i +2)
9 for (var i = 3; i*i <= n; i = i+2) {
10 // While i divides n, print i and divide n
11 while (n%i == 0) {
12 console.log(i);
13 n = n/i;
14 }
15 }
16 // This condition is to handle the case when n is a prime number
17 // greater than 2
18 if (n > 2) {
19 console.log(n);
20 }
21 }
22 primeFactors(10); // prints '5' and '2'
时间复杂度: O( sqrt ( n ))
这种算法的工作原理是打印任何能被 I 整除且没有余数的数。如果一个质数被传递到这个函数中,它将通过打印 n 是否大于 2 来处理。
随机数生成器
随机数生成对于模拟条件很重要。JavaScript 内置了生成数字的函数:Math.random()
。
Math.random()
返回一个介于 0 和 1 之间的浮点数。
你可能想知道如何得到大于 1 的随机整数。
要获得高于 1 的浮点,只需将Math.random()
乘以范围。加上或减去它来设定基数。
Math.random() * 100; // floats between 0 and 100
Math.random() * 25 + 5; // floats between 5 and 30
Math.random() * 10 - 100; // floats between -100 and -90
要获得随机整数,只需使用Math.floor()
、Math.round()
或Math.ceil()
舍入到一个整数。
Math.floor(Math.random() * 100); // integer between 0 and 99
Math.round(Math.random() * 25) + 5; // integer between 5 and 30
Math.ceil(Math.random() * 10) - 100; // integer between -100 and -90
练习
-
【maxDivide(数,除数) : O(对数除数(数))的时间复杂度
-
maxDivide 的时间复杂度是一个对数函数,它依赖于除数和个数。当测试 2、3 和 5 的素数时,2 的对数(log 2 ( n ))产生最高的时间复杂度。
-
isUgly 的时间复杂度 : O(log 2 ( n ))
-
数组编号的时间复杂度 : O( n (对数 2 ( n ))
-
给定三个数 x、y 和 p,计算(xˇy)% p(这是模幂运算。)
这里,x 是底数,y 是指数,p 是模数。
模幂运算是对一个模数执行的一种幂运算,它在计算机科学中很有用,并用于公钥加密算法领域。
At first, this problem seems simple. Calculating this is a one-line solution, as shown here:
1 function modularExponentiation ( base, exponent, modulus ) { 2 return Math.pow(base,exponent) % modulus; 3 }
这正是问题所要求的。然而,它不能处理大指数。
记住这是用加密算法实现的。在强密码术中,基数通常至少是 256 位(78 位)。
例如,考虑这种情况:
基数:6 x 10 77 ,指数:27,模数:497
在这种情况下,(6x1077)27是一个非常大的数,不能存储在 32 位浮点中。
还有另一种方法,涉及到一些数学。人们必须观察以下数学性质:
For arbitrary a and b,
c % m = (a b) % m c % m = [(a % m) (b % m)] % m
使用这个数学属性,您可以迭代 1 的指数,每次通过将当前模数值乘以上一次模数值来重新计算。
Here is the pseudocode:
1 Set value = 1, current exponent = 0. 2 Increment current exponent by 1. 3 Set value = (base value) mod modulus until current exponent is reached exponent
例如:基数:4,指数:3,模数:5
4% 3% 5 = 64% 5 = 4
值=(最后值 x 基数)%模数:
值= (1 x 4) % 5 = 4 % 5 = 4
值= (4 x 4) % 5 = 16 % 5 = 1
值= (1 x 4) % 5 = 4 % 5 = 4
Finally, here is the code:
1 function modularExponentiation ( base, exponent, modulus ) { 2 if (modulus == 1) return 0; 3 4 var value = 1; 5 6 for ( var i=0; i<exponent; i++ ){ 7 value = (value * base) % modulus; 8 } 9 return value; 10 }
时间复杂度: O( n
时间复杂度为 O( n ),其中 n 等于指数值。
-
打印所有小于 n 的质数。
To do this, use the
isPrime
function covered in this chapter. Simply iterate from 0 to n and print any prime numbers whereisPrime()
evaluates totrue
.1 function allPrimesLessThanN(n){ 2 for (var i=0; i<n; i++) { 3 if (isPrime(i)){ 4 console.log(i); 5 } 6 } 7 } 8 9 function isPrime(n){ 10 if (n <= 1) return false; 11 if (n <= 3) return true; 12 13 // This is checked so that we can skip 14 // middle five numbers in below loop 15 if (n%2 == 0 || n%3 == 0) return false; 16 17 for (var i=5; i*i<=n; i=i+6){ 18 if (n%i == 0 || n%(i+2) == 0) 19 return false; 20 } 21 22 return true; 23 } 24 25 allPrimesLessThanN(15); 26 27 // prints 2, 3, 5, 7, 11, 13
时间复杂度: O( nsqrt ( n ))
这是因为时间复杂度为 O( sqrt ( n ))的
isPrime
(本章前面已经介绍过)运行了 n 次。 -
检查一组质因数。
让我们把难看的数字定义为那些唯一的质因数是 2、3 或 5 的数字。序列 1,2,3,4,5,6,8,9,10,12,15,…显示了前 11 个难看的数字。按照惯例,包含 1。
To do this, divide the number by the divisors (2, 3, 5) until it cannot be divided without a remainder. If the number can be divided by all the divisors, it should be 1 after dividing everything.
1 function maxDivide (number, divisor) { 2 while (number % divisor == 0) { 3 number /= divisor; 4 } 5 return number; 6 } 7 8 function isUgly (number){ 9 number = maxDivide(number, 2); 10 number = maxDivide(number, 3); 11 number = maxDivide(number, 5); 12 return number === 1; 13 }
Iterate this over n, and now the list of ugly numbers can be returned.
1 function arrayNUglyNumbers (n) { 2 var counter = 0, currentNumber = 1, uglyNumbers = []; 3 4 while ( counter != n ) { 5 6 if ( isUgly(currentNumber) ) { 7 counter++; 8 uglyNumbers.push(currentNumber); 9 } 10 11 currentNumber++; 12 } 13 14 return uglyNumbers; 15 }
isUgly
功能受限于maxDivide(number, 2)
的时间复杂度。因此,arrayNUglyNumbers
有 n 倍的时间复杂度。
摘要
回想一下,JavaScript 中的所有数字都是 32 位浮点格式。为了获得尽可能小的浮点增量,应该使用Number.EPILSON
。JavaScript 的最大和最小数量可以用下面的不等式来概括:
-Infinity < Number.MIN_SAFE_INTEGER < Number.MIN_VALUE < 0
< Number.MAX_SAFE_INTEGER < Number.MAX_VALUE < Infinity
质数验证和质因数分解是在各种计算机科学应用中使用的概念,比如加密,在第四章中有介绍。最后,JavaScript 中的随机数生成通过Math.random()
工作。
四、JavaScript 字符串
这一章将集中讨论字符串、JavaScript String
对象和String
对象的内置函数。您将学习如何访问、比较、分解和搜索现实生活中常用的字符串。此外,本章将探讨字符串编码、解码、加密和解密。到本章结束时,你将理解如何有效地使用 JavaScript 字符串,并对字符串编码和加密有一个基本的了解。
JavaScript 字符串原语
JavaScript 的原生String
原语带有各种常见的字符串函数。
字符串访问
访问字符时,使用.chartAt()
。
1 'dog'.charAt(1); // returns "o"
.charAt(index)
获取一个索引(从 0 开始)并返回字符串中该索引位置的字符。
对于字符串(多字符)访问,您可以使用.substring(startIndex, endIndex)
,它将返回指定索引之间的字符。
1 'YouTube'.substring(1,2); // returns 'o'
2 YouTube'.substring(3,7); // returns 'tube'
如果您没有传递第二个参数(endIndex
),它将返回从指定的开始位置到结束位置的所有字符值。
1 return 'YouTube'.substring(1); // returns 'outube'
字符串比较
大多数编程语言都有比较字符串的功能。在 JavaScript 中,这可以通过使用小于和大于操作符来实现。
1 var a = 'a';
2 var b = 'b';
3 console.log(a < b); // prints 'true'
这对于在排序算法时比较字符串非常有用,这将在本书后面介绍。
但是,如果您正在比较两个不同长度的字符串,它将从字符串的开头开始比较,直到较小字符串的长度。
1 var a = 'add';
2 var b = 'b';
3
4 console.log(a < b); // prints 'true'
在这个例子中,比较了a
和b
。由于a
小于b
,所以a < b
的计算结果为true
。
1 var a = 'add';
2 var b = 'ab';
3 console.log(a < b); // prints 'false'
在这个例子中,在比较了'a'
和'b'
之后,比较了'd'
和'b'
。处理无法继续,因为'ab'
的一切都已被关注。这和比较'ad'
和'ab'
是一样的。
1 console.log('add'<'ab' == 'ad'<'ab'); // prints 'true'
字符串搜索
要在字符串中查找特定的字符串,可以使用.indexOf(searchValue[, fromIndex])
。这需要一个作为要搜索的字符串的参数,以及一个用于搜索的起始索引的可选参数。它返回匹配字符串的位置,但是如果什么也没有找到,那么返回-1。请注意,该函数区分大小写。
1 'Red Dragon'.indexOf('Red'); // returns 0
2 'Red Dragon'.indexOf('RedScale'); // returns -1
3 'Red Dragon'.indexOf('Dragon', 0); // returns 4
4 'Red Dragon'.indexOf('Dragon', 4); // returns 4
5 'Red Dragon'.indexOf(", 9); // returns 9
要在更大的字符串中检查搜索字符串的出现,只需检查-1 是否从. indexOf 返回。
1 function existsInString (stringValue, search) {
2 return stringValue.indexOf(search) !== -1;
3 }
4 console.log(existsInString('red','r')); // prints 'true';
5 console.log(existsInString('red','b')); // prints 'false';
您可以使用附加参数在字符串中的某个索引后进行搜索。一个例子是计算某些字母的出现次数。在以下示例中,将计算字符'a'
的出现次数:
1 var str = "He's my king from this day until his last day";
2 var count = 0;
3 var pos = str.indexOf('a');
4 while (pos !== -1) {
5 count++;
6 pos = str.indexOf('a', pos + 1);
7 }
8 console.log(count); // prints '3'
最后,如果字符串以指定的输入开始,startsWith
返回 true(布尔值),并且endsWith
检查字符串是否以指定的输入结束。
1 'Red Dragon'.startsWith('Red'); // returns true
2 'Red Dragon'.endsWith('Dragon'); // returns true
3 'Red Dragon'.startsWith('Dragon'); // returns false
4 'Red Dragon'.endsWith('Red'); // returns false
字符串分解
要将一个字符串分解成多个部分,可以使用.split(separator)
,这是一个非常有用的函数。它接受一个参数(分隔符)并创建一个子字符串数组。
1 var test1 = 'chicken,noodle,soup,broth';
2 test1.split(","); // ["chicken", "noodle", "soup", "broth"]
传递一个空分隔符将创建一个包含所有字符的数组。
1 var test1 = 'chicken';
2 test1.split(""); // ["c", "h", "i", "c", "k", "e", "n"]
当一个字符串中列出多个项目时,这很有用。可以将字符串转换成数组,以便轻松地遍历它们。
字符串替换
.replace(string, replaceString)
用另一个字符串替换字符串变量中的指定字符串。
1 "Wizard of Oz".replace("Wizard","Witch"); // "Witch of Oz"
正则表达式
正则表达式( regexes )是定义搜索模式的一组字符。学习如何使用正则表达式本身就是一项艰巨的任务,但是作为一名 JavaScript 开发人员,了解正则表达式的基础知识非常重要。
JavaScript 还带有原生对象RegExp
,用于正则表达式。
RegExp
对象的构造函数有两个参数:正则表达式和可选的匹配设置,如下所示:
i Perform case-insensitive matching
g Perform a global match (find all matches rather than stopping after first match)
m Perform multiline matching
RegExp
有以下两个功能:
-
search()
:测试字符串中的匹配。这将返回匹配的索引。 -
match()
:匹配测试。这将返回所有匹配项。
JavaScript String
对象还有以下两个与 regex 相关的函数,它们接受RegExp
对象作为参数:
-
exec()
:测试字符串中的匹配。这将返回第一个匹配项。 -
test()
:测试字符串中的匹配。这将返回true
或false
。
基本正则表达式
以下是基本的正则表达式规则:
^
:表示字符串/行的开始
\d
:查找任意数字
[abc]
:查找括号之间的任何字符
[^abc]
:查找括号中除以外的任何字符
*[0-9]
:查找括号之间的任意数字
[⁰-9]
:查找括号中除以外的任何数字
*(x|y)
:查找任何指定的备选项
以下返回索引 11,它是字符D
的索引,该字符是匹配的正则表达式的第一个字符:
1 var str = "JavaScript DataStructures";
2 var n = str.search(/DataStructures/);
3 console.log(n); // prints '11'
常用的正则表达式
正则表达式对于检查 JavaScript 中用户输入的有效性非常有帮助。一种常见的输入检查是验证它是否有任何数字字符。
以下是开发人员经常使用的五种正则表达式。
任何数字字符
/\d+/
1 var reg = /\d+/;
2 reg.test("123"); // true
3 reg.test("33asd"); // true
4 reg.test("5asdasd"); // true
5 reg.test("asdasd"); // false
仅数字字符
/^\d+$/
1 var reg = /^\d+$/;
2 reg.test("123"); // true
3 reg.test("123a"); // false
4 reg.test("a"); // false
浮动数字字符
/^[0-9]*.[0-9]*[1-9]+$/
1 var reg = /^[0-9]*.[0-9]*[1-9]+$/;
2 reg.test("12"); // false
3 reg.test("12.2"); // true
仅字母数字字符
/[a-zA-Z0-9]/
1 var reg = /[a-zA-Z0-9]/;
2 reg.test("somethingELSE"); // true
3 reg.test("hello"); // true
4 reg.test("112a"); // true
5 reg.test("112"); // true
6 reg.test("^"); // false
查询字符串
/([^?=&]+)(=([^&]*))/
在 web 应用程序中,出于路由或数据库查询的目的,web URLs 经常在 URL 中传递参数。
例如,对于 URL http://your.domain/product.aspx?category=4&product_id=2140&query=lcd+tv
,URL 可能会对后端 SQL 查询做出如下响应:
1 SELECT LCD, TV FROM database WHERE Category = 4 AND Product_id=2140;
要解析这些参数,正则表达式会很有用。
1 var uri = 'http://your.domain/product.aspx?category=4&product_id=2140&query=lcd+tv' ;
2 var queryString = {};
3 uri.replace(
4 new RegExp ("([^?=&]+)(=([^&]*))?" , "g" ),
5 function ($0, $1, $2, $3) { queryString[$1] = $3; }
6 );
7 console.log('ID: ' + queryString['product_id' ]); // ID: 2140
8 console.log('Name: ' + queryString['product_name' ]); // Name: undefined
9 console.log('Category: ' + queryString['category' ]); // Category: 4
编码
编码是计算机科学中的一个通用概念,它以一种专门的格式来表示字符,以便有效地传输或存储。
所有计算机文件类型都以特定的结构编码。
例如,当您上传 PDF 时,编码可能如下所示:
1 JVBERi0xLjMKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBS\
2 ID4+CmVuZG9iagoyIDAgb2JqCjw8IC9UeXBlIC9PdXRsaW5lcyAvQ291bnQgMCA+PgplbmRvYmoKMyAwIG9i\
3 ago8PCAvVHlwZSAvUGFnZXMKL0tpZHMgWzYgMCBSCl0KL0NvdW50IDEKL1Jlc291cmNlcyA8PAovUHJvY1Nl\
4 dCA0IDAgUgovRm9udCA8PCAKL0YxIDggMCBSCj4+Cj4+Ci9NZWRpYUJveCBbMC4wMDAgMC4wMDAgNjEyLjAw\
5 MCA3OTIuMDAwXQogPj4KZW5kb2JqCjQgMCBvYmoKWy9QREYgL1RleHQgXQplbmRvYmoKNSAwIG9iago8PAov\
6 Q3JlYXRvciAoRE9NUERGKQovQ3JlYXRpb25EYXRlIChEOjIwMTUwNzIwMTMzMzIzKzAyJzAwJykKL01vZERh\
7 dGUgKEQ6MjAxNTA3MjAxMzMzMjMrMDInMDAnKQo+PgplbmRvYmoKNiAwIG9iago8PCAvVHlwZSAvUGFnZQov\
8 UGFyZW50IDMgMCBSCi9Db250ZW50cyA3IDAgUgo+PgplbmRvYmoKNyAwIG9iago8PCAvRmlsdGVyIC9GbGF0\
9 ZURlY29kZQovTGVuZ3RoIDY2ID4+CnN0cmVhbQp4nOMy0DMwMFBAJovSuZxCFIxN9AwMzRTMDS31DCxNFUJS\
10 FPTdDBWMgKIKIWkKCtEaIanFJZqxCiFeCq4hAO4PD0MKZW5kc3RyZWFtCmVuZG9iago4IDAgb2JqCjw8IC9U\
11 eXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovTmFtZSAvRjEKL0Jhc2VGb250IC9UaW1lcy1Cb2xkCi9FbmNv\
12 ZGluZyAvV2luQW5zaUVuY29kaW5nCj4+CmVuZG9iagp4cmVmCjAgOQowMDAwMDAwMDAwIDY1NTM1IGYgCjAw\
13 MDAwMDAwMDggMDAwMDAgbiAKMDAwMDAwMDA3MyAwMDAwMCBuIAowMDAwMDAwMTE5IDAwMDAwIG4gCjAwMDAw\
14 MDAyNzMgMDAwMDAgbiAKMDAwMDAwMDMwMiAwMDAwMCBuIAowMDAwMDAwNDE2IDAwMDAwIG4gCjAwMDAwMDA0\
15 NzkgMDAwMDAgbiAKMDAwMDAwMDYxNiAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDkKL1Jvb3QgMSAwIFIK\
16 L0luZm8gNSAwIFIKPj4Kc3RhcnR4cmVmCjcyNQolJUVPRgo=.....
这是一个 Base64 编码的 PDF 字符串。像这样的数据通常在上传 PDF 文件时被传递到服务器。
Base64 编码
btoa()
函数从一个字符串创建一个 Base64 编码的 ASCII 字符串。字符串中的每个字符都被视为一个字节(8 位:8 个 0 和 1)。
.atob()
函数对使用 Base64 编码的数据字符串进行解码。例如,Base64 编码字符串中的字符串“hello I love learning to computer program”如下所示:agvsbg 8 GSS BSB 3 zlig xlyxjuaw 5 nihrvignvbxb 1 dgvyihby B2 dyyw 0。
1 btoa('hello I love learning to computer program');
2 // aGVsbG8gSSBsb3ZlIGxlYXJuaW5nIHRvIGNvbXB1dGVyIHByb2dyYW0
1 atob('aGVsbG8gSSBsb3ZlIGxlYXJuaW5nIHRvIGNvbXB1dGVyIHByb2dyYW0');
2 // hello I love learning to computer program
在 https://en.wikipedia.org/wiki/Base64
了解 Base64 的更多信息。
字符串缩短
你有没有想过像这样的网址缩短网站。ly 工作?一种简化的 URL 压缩算法遵循一定的结构,如下图所示为 www.google.com
:
图 4-2
缩短后的数据库条目
- 整数 ID 被缩短为一个字符串。使用 Base62 编码缩短时,11231230 将是 VhU2。
图 4-1
数据库条目
- 数据库为 URL 创建一个唯一的基于整数的 ID。在图 4-1 ,
www.google.com
数据库中有条目 11231230。
对于缩短部分,可以使用下面的算法。有 62 个可能的字母和数字,由 26 个小写字母、26 个大写字母和 10 个数字(0 到 9)组成。
1 var DICTIONARY = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" .split(");
2
3 function encodeId(num) {
4 var base = DICTIONARY.length;
5 var encoded = " ;
6
7 if (num === 0 ) {
8 return DICTIONARY[0 ];
9 }
10
11 while (num > 0 ) {
12 encoded += DICTIONARY[(num % base)];
13 num = Math .floor(num / base);
14 }
15
16 return reverseWord(encoded);
17 }
18
19 function reverseWord(str) {
20 var reversed = "" ;
21 for (var i = str.length - 1 ; i >= 0 ; i-- ) {
22 reversed += str.charAt(i);
23 }
24 return reversed;
25 }
26
27 function decodeId(id) {
28 var base = DICTIONARY.length;
29 var decoded = 0 ;
30
31 for (var index = 0 ; index < id.split("" ).length; index++ ) {
32 decoded = decoded * base + DICTIONARY.indexOf(id.charAt(index));
33 }
34
35 return decoded;
36 }
37
38 console.log(encodeId(11231230 )); // prints 'VhU2'
39 console.log(decodeId('VhU2' )); // prints '11231230'
加密
在保护人们的在线信息时,加密极其重要。你在谷歌 Chrome 浏览器中见过图 4-3 中的警告吗?
图 4-3
SSL 警告
这可能意味着您尝试访问的网站没有正确的安全套接字层(SSL)证书。
图 4-4
TSL 过程
TSL 是一种标准的安全技术,用于在服务器和客户端(浏览器)之间建立加密链接。以下是 TSL 过程的简化步骤。在这个过程中,服务器对不同的密钥使用非对称加密进行加密和解密。浏览器只使用对称加密,即使用一个密钥来加密和解密数据。
-
服务器将其非对称公钥发送给浏览器。
-
浏览器为当前会话创建一个对称密钥,该密钥由服务器的非对称公钥加密。
-
服务器通过其私钥解密浏览器的会话,并检索会话密钥。
-
两个系统都有会话密钥,并将使用该密钥安全地传输数据。
这是安全的,因为只有浏览器和服务器知道会话密钥。如果浏览器第二天连接到同一个服务器,将会创建一个新的会话密钥。
SSL 警告消息表示浏览器和服务器可能没有加密该连接上的数据。
最常用的公钥加密算法是 RSA 算法。
RSA 加密
RSA 是一种基于分解大整数难度的加密算法。在 RSA 中,生成两个大素数和一个补充值作为公钥。任何人都可以用公钥加密信息,但是只有那些有质因数的人才能解码信息。
这个过程有三个阶段:密钥生成、加密和解密。
-
密钥生成:生成公钥(共享)和私钥(保密)。生成的密钥的构造方法也应该是保密的。
-
加密:秘密消息可以通过公钥加密。
-
解密:只有私钥可以用来解密消息。
以下是该算法的概述:
-
选择两个(通常是大的)素数, p 和 q 。
-
p 和 q 的乘积记为 n 。
-
( p -1)和( q -1)的乘积表示为φ。
-
-
选择两个指数, e 和 d 。
-
e 通常为 3。可以使用大于 2 的其他值。
-
d 是使得(e × d) % phi = 1 的值。
-
Encryption
process is as shown:
m - message:
m^e % n = c
c - encrypted message
Decryption process is as shown:
c^d % n = m
这是计算 d 的实现:
1 function modInverse(e, phi) {
2 var m0 = phi, t, q;
3 var x0 = 0, x1 = 1;
4
5 if (phi == 1)
6 return 0;
7
8 while (e > 1) {
9 // q is quotient
10 q = Math.floor(e / phi);
11
12 t = phi;
13
14 // phi is remainder now, process same as
15 // Euclid's algo
16 phi = e % phi, e = t;
17
18 t = x0;
19
20 x0 = x1 - q * x0;
21
22 x1 = t;
23 }
24
25 // Make x1 positive
26 if (x1 < 0)
27 x1 += m0;
28
29 return x1;
30 }
31 modInverse(7,40); // 23
还需要生成公钥和私钥的密钥对。
1 function RSAKeyPair(p, q) {
2 // Need to check that they are primes
3 if (! (isPrime(p) && isPrime(q)))
4 return;
5
6 // Need to check that they're not the same
7 if (p==q)
8 return;
9
10 var n = p * q,
11 phi = (p-1)*(q-1),
12 e = 3,
13 d = modInverse(e,phi);
14
15 // Public key: [e,n], Private key: [d,n]
16 return [[e,n], [d,n]]
17 }
让我们选择 5 和 11 作为质数,看看message
是 50 的例子。
1 RSAKeyPair(5,11); //Public key: [3,55], Private key: [27,55]
p = 5, 11
n = p x q = 55
phi = (5-1) x (11-1) = 4 x 10 = 40
e = 3
(e x d) % phi = 1 (3 x d) % 40 = 1
(81) % 40 = 1\. 81 = 3 x d = 3 x 27
d = 27
Encryption:
m - message: 50
m^e % n = c
50³ % 55 = 40
Encrypted message.,c:
40
Decryption:
c^d % n = m
40²⁷ % 55 = 50
这完全加密了 50,而接收者可以将其解密回 50。通常,对于 RSA 算法,所选择的素数非常大。这是因为大数的质因数分解需要很长的计算时间。今天的标准是使用 4096 位的 prime 产品。即使是先进的计算机,计算它的质因数也需要数年时间。图 4-5 显示了 4096 位数的最大可能值。
图 4-5
2 4096
摘要
本章涵盖了各种本机实现的字符串函数,并在表 4-1 中进行了总结。
表 4-1
字符串函数摘要
|功能
|
使用
|
| --- | --- |
| charAt(index)
| 在index
访问单个字符 |
| substring(startIndex, endIndex)
| 访问从startIndex
到endIndex
的部分字符串 |
| str1 > str2
| 如果str1
在字典上比str2
大,则返回true
|
| indexOf(str, startIndex)
| 从startIndex
开始的期望str
的索引 |
| str.split(delimiter)
| 用指定的delimiter
将一个字符串拆分成一个数组 |
| str.replace(original,new)
| 用new
替换original
|
此外,JavaScript 原生Regex
对象可以用于常用的字符串验证。表 4-2 提供了一个总结。
表 4-2
正则表达式摘要
|正则表达式模式
|
使用
|
| --- | --- |
| /\d+/
| 任何数字字符 |
| /^\d+$/
| 仅数字字符 |
| /^[0-9]*.[0-9]*[1-9]+$/
| 浮点数字字符 |
| /[a-zA-Z0-9] /
| 仅字母数字字符 |**
五、JavaScript 数组
这一章将集中讨论 JavaScript 数组的使用。作为一个 JavaScript 开发者,你会经常用到数组;它是最常用的数据结构。JavaScript 中的数组有很多内置方法。事实上,对于每个用例,有各种方法可以进行相同类型的数组操作。本章结束时,你将理解如何使用数组,并能够根据情况选择正确的方法。
阵列简介
数组是最基本的数据结构之一。如果你以前编程过,你很可能使用过数组。
1 var array1 = [1,2,3,4];
对于任何数据结构,开发人员感兴趣的是与四个基本操作相关的时间和空间复杂性:访问、插入、删除和搜索。(关于 Big-O 符号的回顾,请参考第一章。)
插入
插入意味着在数据结构中添加一个新元素。JavaScript 用.push(element)
方法实现数组插入。此方法在数组末尾添加一个新元素。
1 var array1 = [1,2,3,4];
2 array1.push(5); //array1 = [1,2,3,4,5]
3 array1.push(7); //array1 = [1,2,3,4,5,7]
4 array1.push(2); //array1 = [1,2,3,4,5,7,2]
该运算的时间复杂度理论上为 O(1)。应该注意的是,实际上,这取决于运行代码的 JavaScript 引擎。这适用于所有本地支持的 JavaScript 对象。
删除
JavaScript 用.pop()
方法实现数组删除。此方法移除数组中最后添加的元素。这也将返回移除的元素。
1 var array1 = [1,2,3,4];
2 array1.pop(); //returns 4, array1 = [1,2,3]
3 array1.pop(); //returns 3, array1 = [1,2]
与.push
类似,.pop
的时间复杂度为 O(1)。
另一种从数组中移除元素的方法是使用.shift()
方法。此方法将移除第一个元素并返回它。
1 array1 = [1,2,3,4];
2 array1.shift(); //returns 1, array1 = [2,3,4]
3 array1.shift(); //returns 2, array1 = [3,4]
接近
访问指定索引处的数组只需要 O(1),因为该进程使用该索引直接从内存中的地址获取值。这是通过指定索引来完成的(记住索引从 0 开始)。
1 var array1 = [1,2,3,4];
2 array1[0]; //returns 1
3 array1[1]; //returns 2
循环
迭代是访问数据结构中包含的每一项的过程。JavaScript 中有多种方法可以遍历数组。它们都具有 O( n )的时间复杂度,因为迭代正在访问 n 个元素。
for(变量;条件;修改)
for
是最常见的迭代方法。它通常以这种形式使用:
1 for ( var i=0, len=array1.length; i<len; i++ ) {
2 console.log(array1[i]);
3 }
前面的代码简单来说就是初始化变量i
,在执行主体(i<len)
之前检查条件是否为假,然后修改(i++)
,直到条件为假。同样,你可以使用一个while
循环。但是,计数器必须设置在室外。
1 var counter=0;
2 while(counter<array1.length){
3 // insert code here
4 counter++;
5 }
您可以使用while
循环实现无限循环,如下所示:
1 while(true){
2 if (breakCondition) {
3 break;
4 }
5 }
类似地,for
循环可以通过不设置条件来实现无限循环,如下所示:
1 for ( ; ;) {
2 if (breakCondition) {
3 break
4 }
5 }
对于(在)
另一种迭代 JavaScript 数组的方法是逐个调用索引。在in
之前指定的变量是数组的index
,如下所示:
1 var array1 = ['all','cows','are','big'];
2
3 for (var index in array1) {
4 console.log(index);
5 }
这将打印以下内容:0,1,2,3
。
要打印内容,请使用:
1 for (var index in array1) {
2 console.log(array1[index]);
3 }
这将打印出all
、cows
、are
和big
。
对于(的)
在of
之前指定的变量是数组的element
(值),如下所示:
1 for (var element of array1) {
2 console.log(element);
3 }
这将打印出all
、cows
、are
和big
。
forEach()
forEach
和其他迭代方法的最大区别是forEach
不能中断迭代或者跳过数组中的某些元素。forEach
通过遍历每一个元素,更加具有表现力和明确性。
1 var array1 = ['all','cows','are','big'];
2
3 array1.forEach( function (element, index){
4 console.log(element);
5 });
6
7 array1.forEach( function (element, index){
8 console.log(array1[index]);
9 });
都打印all
、cows
、are
和big
。
助手功能
以下部分讨论了其他常用的处理帮助函数。此外,还将介绍如何使用数组。
。切片(开始,结束)
这个 helper 函数返回一个现有数组的一部分,而不修改数组。.slice()
接受两个参数:数组的开始索引和结束索引。
1 var array1 = [1,2,3,4];
2 array1.slice(1,2); //returns [2], array1 = [1,2,3,4]
3 array1.slice(2,4); //returns [3,4], array1 = [1,2,3,4]
如果只传递开始的索引,那么结尾将被认为是最大的索引。
1 array1.slice(1); //returns [2,3,4], array1 = [1,2,3,4]
2 array1.slice(1,4); //returns [2,3,4], array1 = [1,2,3,4]
如果没有传递任何东西,这个函数只返回数组的一个副本。需要注意的是,array1.slice() === array1
评估为false
。这是因为尽管数组的内容是相同的,但是这些数组所在的内存地址是不同的。
1 array1.slice(); //returns [1,2,3,4], array1 = [1,2,3,4]
这对于在 JavaScript 中复制数组很有用。记住 JavaScript 中的数组是基于引用的,这意味着如果你给一个数组赋值一个新的变量,对该变量的修改会应用到原来的数组。
1 var array1 = [1,2,3,4],
2 array2 = array1;
3
4 array1 // [1,2,3,4]
5 array2 // [1,2,3,4]
6
7 array2[0] = 5;
8
9 array1 // [5,2,3,4]
10 array2 // [5,2,3,4]
array2
的 changing 元素意外改变了原数组,因为它是对原数组的引用。要创建一个新的数组,可以使用.from()
。
1 var array1 = [1,2,3,4];
2 var array2 = Array.from(array1);
3
4 array1 // [1,2,3,4]
5 array2 // [1,2,3,4]
6
7 array2[0] = 5;
8
9 array1 // [1,2,3,4]
10 array2 // [5,2,3,4]
.from()
取 O( n ,其中 n 是数组的大小。这很直观,因为复制数组需要复制数组的所有 n 个元素。
。拼接(开始、尺寸、元素 1、元素 2…)
这个 helper 函数通过删除现有元素和/或添加新元素来返回和更改数组的内容。
.splice()
接受三个参数:起始索引、要删除的内容的大小和要添加的新元素。在第一个参数指定的位置添加新元素。它返回删除的元素。
1 var array1 = [1,2,3,4];
2 array1.splice(); //returns [], array1 = [1,2,3,4]
3 array1.splice(1,2); //returns [2,3], array1 = [1,4]
这个例子演示了删除。[2,3]
被返回,因为它从索引 1 开始选择了两个项目。
1 var array1 = [1,2,3,4];
2 array1.splice(); //returns [], array1 = [1,2,3,4]
3 array1.splice(1,2,5,6,7); //returns [2,3],array1 = [1,5,6,7,4]
任何东西(任何对象类型)都可以添加到数组中。这就是 JavaScript 的美妙之处(也是奇怪的地方)。
1 var array1 = [1,2,3,4];
2 array1.splice(1,2,[5,6,7]); //returns [2,3], array1 = [1,[5,6,7],4]
3 array1 = [1,2,3,4];
4 array1.splice(1,2,{'ss':1}); //returns [2,3], array1 = [1,{'ss':1},4]
.splice()
是,最坏的情况,O( n )。类似于复制,如果指定的范围是整个数组,每个 n 项都必须被删除。
。concat()
这将在数组末尾添加新元素,并返回数组。
1 var array1 = [1,2,3,4];
2 array1.concat(); //returns [1,2,3,4], array1 = [1,2,3,4]
3 array1.concat([2,3,4]); //returns [1,2,3,4,2,3,4],array1 = [1,2,3,4]
。长度属性
属性返回数组的大小。将此属性更改为较小的大小会从数组中删除元素。
1 var array1 = [1,2,3,4];
2 console.log(array1.length); //prints 4
3 array1.length = 3; // array1 = [1,2,3]
传播算子
由三个句点表示的扩展运算符(...),用于在应该没有参数的地方扩展参数。
1 function addFourNums(a, b, c, d) {
2 return a + b + c + d;
3 }
4 var numbers = [1, 2, 3, 4];
5 console.log(addFourNums(...numbers)); // 10
Math.max
和Math.min
函数都接受无限数量的参数,因此您可以使用 spread 操作符进行以下操作。
要查找数组中的最大值,请使用以下命令:
1 var array1 = [1,2,3,4,5];
2 Math.max(array1); // 5
要查找数组中的最小值,请使用以下命令:
1 var array2 = [3,2,-123,2132,12];
2 Math.min(array2); // -123
练习
所有练习的代码都可以在 GitHub 上找到。 1
在一个数组中找出两个相加为一个数的数组元素
问题:给定数组arr
,找到并返回数组中两个加起来为weight
的索引,如果没有加起来为weight
的组合,则返回-1。
比如像[1,2,3,4,5]这样的数组,有哪些数字加起来是 9?
答案当然是 4 和 5。
简单的解决方案是通过两个for
循环来尝试每种组合,如下所示:
1 function findSum(arr, weight) {
2 for (var i=0,arrLength=arr.length; i<arrLength; i++){
3 for (var j=i+1; j<arrLength; j++) {
4 if (arr[i]+arr[j]==weight){
5 return [i,j];
6 }
7 }
8 }
9 return -1;
10 }
这个解决方案遍历一个数组,查看是否存在匹配对。
数组的 n 元素上的两个for
循环产生高时间复杂度。但是,没有创建额外的内存。类似于时间复杂度如何描述相对于输入大小 n 完成算法所需的时间,空间复杂度描述实现所需的额外存储器。空间复杂度 O(1)是常数。
时间复杂度:O(n2
空间复杂度: O(1)
我们来想想在 O( n )的线性时间内如何做到这一点。
如果存储了任何以前看到的数组元素并且可以很容易地进行检查,那会怎么样呢?
输入如下:
1 var arr = [1,2,3,4,5];
2 var weight = 9;
这里 4 和 5 是组合,它们的索引是[3,4]
。当访问 5 时,如何确定解决方案存在?
如果当前值为 5,权重为 9,则剩余的所需权重仅为 4 (9-5=4)。由于在数组中 4 显示在 5 之前,这个解决方案可以在 O( n )中工作。最后,为了存储看到的元素,使用一个 JavaScript 对象作为哈希表。散列表的实现和使用将在后面的章节中讨论。存储和检索 JavaScript 对象属性在时间上是 O(1)。
1 function findSumBetter(arr, weight) {
2 var hashtable = {};
3
4 for (var i=0, arrLength=arr.length; i<arrLength; i++) {
5 var currentElement = arr[i],
6 difference = weight - currentElement;
7
8 // check the right one already exists
9 if (hashtable[currentElement] != undefined) {
10 return [i, hashtable[weight-currentElement]];
11 } else {
12 // store index
13 hashtable[difference] = i;
14 }
15 }
16 return -1;
17 }
时间复杂度: O( n
空间复杂度: O( n
存储到哈希表中并从哈希表中查找一个项目只需要 O(1)。空间复杂度增加到 O( n )来存储哈希表中的访问过的数组索引。
实现数组。Slice()函数从头开始
让我们回顾一下.slice()
函数的作用。
.slice()
接受两个参数:数组的开始索引和最后一个结束索引。它返回现有数组的一部分,而不修改数组函数arraySlice
( array
、beginIndex
、endIndex
)。
1 function arraySlice(array, beginIndex, endIndex) {
2 // If no parameters passed, return the array
3 if (! beginIndex && ! endIndex) {
4 return array;
5 }
6
7 // If only beginning index is found, set endIndex to size
8 endIndex = array.length;
9
10 var partArray = [];
11
12 // If both begin and end index specified return the part of the array
13 for (var i = beginIndex; i < endIndex; i++ ) {
14 partArray.push(array[i]);
15 }
16
17 return partArray;
18 }
19 arraySlice([1 , 2 , 3 , 4 ], 1 , 2 ); // [2]
20 arraySlice([1 , 2 , 3 , 4 ], 2 , 4 ); // [3,4]
时间复杂度: O( n
空间复杂度 : O( n
时间复杂度为 O( n ),因为必须访问数组中的所有 n 项。复制数组时空间复杂度也是 O( n )容纳所有 n 项。
求两个大小相同的排序数组的中间值
回想一下,偶数集合中的中位数是两个中间数的平均值。如果数组是排序的,这就简单了。
这里有一个例子:
[1,2,3,4]的中位数为(2+3)/2 = 2.5。
1 function medianOfArray(array) {
2 var length = array.length;
3 // Odd
4 if (length % 2 == 1) {
5 return array[Math.floor(length/2)];
6 } else {
7 // Even
8 return (array[length/2]+array[length/2 - 1])/2;
9 }
10 }
现在,您可以遍历这两个数组,比较哪个更大,以跟踪中位数。如果两个数组大小相同,则总大小将是一个偶数。
这是因为两个偶数和两个奇数加起来就是一个偶数。请参阅第八章了解更多背景信息。
因为两个数组都是排序的,所以这个函数可以递归调用。每次,它都会检查哪个中值更大。
如果第二个数组的中值较大,则第一个数组被切成两半,只有较高的一半被递归传递。
如果第一个数组的中值较大,则第二个数组被切成两半,只有较高的一半作为下一个函数调用的第一个数组被传入,因为函数中的array2
参数必须总是大于array1
参数。最后,需要用pos
表示的数组的大小来检查数组的大小是偶数还是奇数。
这是另一个例子:
数组 1 = [1,2,3]和数组 2 = [4,5,6]
这里,array1
的中位数是 2,array2
的中位数是 5。因此,中位数必须在[2,3]和[4,5]之间。由于只剩下四个元素,中值可以计算如下:
max(arr1[0],arr2[0]) + min(arr1[1],arr 2[1])/2;
1 function medianOfArray(array) {
2 var length = array.length;
3 // Odd
4 if (length % 2 == 1 ) {
5 return array[Math .floor(length / 2 )];
6 } else {
7 // Even
8 return (array[length / 2 ] + array[length / 2 - 1 ]) / 2 ;
9 }
10 }
11 // arr2 is the bigger array
12 function medianOfTwoSortedArray(arr1, arr2, pos) {
13 if (pos <= 0 ) {
14 return -1 ;
15 }
16 if (pos == 1 ) {
17 return (arr1[0] + arr2[0]) / 2 ;
18 }
19 if (pos == 2 ) {
20 return (Math .max(arr1[0], arr2[0]) + Math .min(arr1[1], arr2[1])) / 2 ;
21 }
22
23 var median1 = medianOfArray(arr1),
24 median2 = medianOfArray(arr2);
25
26 if (median1 == median2) {
27 return median1;
28 }
29
30 var evenOffset = pos % 2 == 0 ? 1 : 0 ,
31 offsetMinus = Math .floor(pos / 2 ) - evenOffset,
32 offsetPlus = Math .floor(pos / 2 ) + evenOffset;
33
34
35 if (median1 < median2) {
36 return medianOfTwoSortedArray(arr1.slice(offsetMinus), arr2.slice(offsetMinus), offsetPlus);
37 } else {
38 return medianOfTwoSortedArray(arr2.slice(offsetMinus), arr1.slice(offsetMinus), offsetPlus);
39 }
40 }
41
42 medianOfTwoSortedArray([1 , 2 , 3 ], [4 , 5 , 6 ], 3 ); // 3.5
43 medianOfTwoSortedArray([11 , 23 , 24 ], [32 , 33 , 450 ], 3 ); // 28
44 medianOfTwoSortedArray([1 , 2 , 3 ], [2 , 3 , 5 ], 3 ); // 2.5
时间复杂度:O(log2(n))
通过每次将数组大小减半,实现了对数时间复杂度。
在 K 排序数组中寻找公共元素
1 var arr1 = [1, 5, 5, 10];
2 var arr2 = [3, 4, 5, 5, 10];
3 var arr3 = [5, 5, 10, 20];
4 var output = [5 ,10];
在这个有三个数组的例子中, k =3。
为此,只需迭代每个数组并计算每个元素的实例数。但是,不要跟踪重复的(5 和 5.5 应该在一次数组迭代中计算一次)。为此,在递增之前检查最后一个元素是否相同。这只有在排序的情况下才会起作用。
迭代完所有三个数组后,遍历哈希表的属性。如果该值与 3 匹配,则意味着该数字出现在所有三个数组中。这可以通过将 k 循环检查放入另一个for
循环中来推广到 k 个数组。
1 function commonElements(kArray) {
2 var hashmap = {},
3 last, answer = [];
4
5 for (var i = 0 , kArrayLength = kArray.length; i < kArrayLength; i++ ) {
6 var currentArray = kArray[i];
7 last = null ;
8 for (var j = 0 , currentArrayLen = currentArray.length;
9 j < currentArrayLen; j++ ) {
10 var currentElement = currentArray[j];
11 if (last != currentElement) {
12 if (! hashmap[currentElement]) {
13 hashmap[currentElement] = 1 ;
14 } else {
15 hashmap[currentElement]++ ;
16 }
17 }
18 last = currentElement;
19 }
20 }
21
22 // Iterate through hashmap
23 for (var prop in hashmap) {
24 if (hashmap[prop] == kArray.length) {
25 answer.push(parseInt (prop));
26 }
27 }
28 return answer;
29 }
30
31 commonElements([[1 ,2 ,3 ],[1 ,2 ,3 ,4 ],[1 ,2 ]]); // [ 1, 2 ]
时间复杂度: O( kn
空间复杂度: O( n
这里, n 是最长的数组长度, k 是数组的个数。
JavaScript 函数数组方法
JavaScript 的某些部分可以像函数式编程语言一样编写。与命令式编程不同,JavaScript 并不关注程序的状态。它不使用循环,只使用函数(方法)调用。你可以从 Anto Aravinth (Apress,2017)的开始函数式 JavaScript 中了解更多关于 JavaScript 的函数式编程。
在本节中,将只探讨 JavaScript 中的三种函数数组方法:map
、filter
和reduce
。这些方法不会改变原始数组内容。
地图
map 函数将传递的函数转换应用于数组中的每个元素,并返回应用了这些转换的新数组。
例如,您可以将每个元素乘以 10,如下所示:
1 [1,2,3,4,5,6,7].map(function (value){
2 return value*10;
3 });
4 // [10, 20, 30, 40, 50, 60, 70]
过滤器
filter 函数只返回满足传递的条件参数的数组元素。同样,这不会改变原始数组。
例如,这会过滤大于 100 的元素:
1 [100,2003,10,203,333,12].filter(function (value){
2 return value > 100;
3 });
4 // [2003, 203, 333]
减少
reduce 函数使用传递的转换函数参数将数组中的所有元素组合成一个值。
例如,这将添加所有元素:
1 var sum = [0,1,2,3,4].reduce( function (prevVal, currentVal, index, array) {
2 return prevVal + currentVal;
3 });
4 console.log(sum); // prints 10
这个函数也可以将initialValue
作为第二个参数,它初始化 reduce 值。例如,在前面的示例中提供 1 的initialValue
将产生 11,如下所示:
1 var sum = [0,1,2,3,4].reduce( function (prevVal, currentVal, index, array) {
2 return prevVal + currentVal;
3 }, 1);
4 console.log(sum); // prints 11
多维数组
与 Java 和 C++不同,JavaScript 没有多维数组(见图 5-1 )。
图 5-1
多维数组
取而代之的是“锯齿状”阵列。一个交错数组是一个数组,它的元素是数组。交错数组的元素可以有不同的维度和大小(参见图 5-2 )。
图 5-2
锯齿状阵列
这里有一个帮助器函数来创建一个类似图 5-3 中的交错数组:
图 5-3
三乘三矩阵
1 function Matrix(rows, columns) {
2 var jaggedarray = new Array(rows);
3 for (var i=0; i < columns; i +=1) {
4 jaggedarray[i]=new Array(rows);
5 }
6 return jaggedarray;
7 }
8 console.log(Matrix(3,3));
要访问交错数组中的元素,请指定一行和一列(参见图 5-4 )。
图 5-4
三乘三的数字矩阵
1 var matrix3by3 = [[1,2,3],[4,5,6],[7,8,9]];
2 matrix3by3[0]; // [1,2,3]
3 matrix3by3[1]; // [4,5,6]
4 matrix3by3[1]; // [7,8,9]
5
6 matrix3by3[0][0]; // 1
7 matrix3by3[0][1]; // 2
8 matrix3by3[0][2]; // 3
9
10 matrix3by3[1][0]; // 4
11 matrix3by3[1][1]; // 5
12 matrix3by3[1][2]; // 6
13
14 matrix3by3[2][0]; // 7
15 matrix3by3[2][1]; // 8
16 matrix3by3[2][2]; // 9
练习
所有练习的代码都可以在 GitHub 上找到。 2
螺旋印刷
让我们用矩阵做一个例题。给定一个矩阵,按照螺旋顺序打印元素,如图 5-5 所示。
图 5-5
螺旋印刷
起初,这看起来是一项艰巨的任务。然而,这个问题可以分解为五个主要部分。
-
从左向右打印
-
从上到下打印
-
从右向左打印
-
从下到上打印
-
对这四种操作进行限制
换句话说,保留四个关键变量,这四个变量表明:
-
顶行
-
底端行
-
左列
-
右列
每当四个print
函数中的一个被成功执行时,简单地增加四个变量中的一个。例如,打印完第一行后,将其递增 1。
1 var M = [
2 [1, 2, 3, 4, 5],
3 [6, 7, 8, 9, 10],
4 [11, 12, 13, 14, 15],
5 [16, 17, 18, 19, 20]
6 ];
7 function spiralPrint(M) {
8 var topRow = 0,
9 leftCol = 0,
10 btmRow = M.length - 1,
11 rightCol = M[0].length - 1;
12
13 while (topRow < btmRow && leftCol < rightCol) {
14 for (var col = 0; col <= rightCol; col++) {
15 console.log(M[topRow][col]);
16 }
17 topRow++;
18 for (var row = topRow; row <= btmRow; row++) {
19 console.log(M[row][rightCol]);
20 }
21 rightCol--;
22 if (topRow <= btmRow) {
23 for (var col = rightCol; col >= 0; col--) {
24 console.log(M[btmRow][col]);
25 }
26 btmRow--;
27 }
28 if (leftCol <= rightCol) {
29 for (var row = btmRow; row > topRow; row--) {
30 console.log(M[row][leftCol]);
31 }
32 leftCol++;
33 }
34 }
35 }
36 spiralPrint(M);
时间复杂度: O( mn
空间复杂度: O(1)
这里, m 是行数, n 是列数。矩阵中的每个项目只被访问一次。
井字游戏
给定一个代表井字游戏棋盘的矩阵,确定是否有人赢了,是否是平局,或者游戏是否还没有结束。 3
这里有一些例子。
这里,X 赢了:
OX-
-XO
OX
这里是作为一个矩阵:[['O', 'X', '-'], ['-' ,'X', 'O'], ['O', 'X', '-']]
。
在这里,O 赢了:
O-X
-O-
-XO
这里是作为一个矩阵:[['O','-','X'], ['-','O','-'], ['-','X','O']]
。
为此,使用for
循环检查所有三行,使用for
循环检查所有列,并检查对角线。
1 function checkRow ( rowArr, letter ) {
2 for ( var i=0; i < 3; i++) {
3 if (rowArr[i]!=letter) {
4 return false;
5 }
6 }
7 return true;
8 }
9
10 function checkColumn ( gameBoardMatrix, columnIndex, letter ) {
11 for ( var i=0; i < 3; i++) {
12 if (gameBoardMatrix[i][columnIndex]!=letter) {
13 return false;
14 }
15 }
16 return true;
17 }
18
19 function ticTacToeWinner ( gameBoardMatrix, letter) {
20
21 // Check rows
22 var rowWin = checkRow(gameBoardMatrix[0], letter)
23 || checkRow(gameBoardMatrix[1], letter)
24 || checkRow(gameBoardMatrix[2], letter);
25
26 var colWin = checkColumn(gameBoardMatrix, 0, letter)
27 || checkColumn(gameBoardMatrix, 1, letter)
28 || checkColumn(gameBoardMatrix, 2, letter);
29
30 var diagonalWinLeftToRight = (gameBoardMatrix[0][0]==letter && gameBoardMatrix[1][1]==letter && gameBoardMatrix[2][2]==letter);
31 var diagonalWinRightToLeft = (gameBoardMatrix[0][2]==letter && gameBoardMatr ix[1][1]==letter && gameBoardMatrix[2][0]==letter);
32
33 return rowWin || colWin || diagonalWinLeftToRight || diagonalWinRightToLeft;
34 }
35
36 var board = [['O','-','X'],['-','O','-'],['-','X','O']];
37 ticTacToeWinner(board, 'X'); // false
38 ticTacToeWinner(board, 'O'); // true
路由选择
在图 5-6 中,给定位置 x ,找到出口 e 。
图 5-6
寻找一条路
\n
是 JavaScript 中用来换行的字符集,就像在许多标准编程语言中一样。将它与反斜线结合起来,可以在变量到字符串的赋值过程中创建换行符。
1 var board =
2 `%e%%%%%%%%%\n
3 %...%.%...%\n
4 %.%.%.%.%%%\n
5 %.%.......%\n
6 %.%%%%.%%.%\n
7 %.%.....%.%\n
8 %%%%%%%%%x%`;
var rows = board.split("\n")
然后在数组上使用.map
将某些字符分成每一列。
function generateColumnArr (arr) {
return arr.split("");
}
var mazeMatrix = rows.map(generateColumnArr);
这将生成适当的矩阵,其中每一行都是字符的数组,棋盘是这些行的数组。
现在,首先找到入口, e ,和出口, x 。该函数将返回要搜索的字符的行位置 i 和列位置 j :
1 function findChar(char , mazeMatrix) {
2 var row = mazeMatrix.length,
3 column = mazeMatrix[0 ].length;
4
5 for (var i = 0 ; i < row; i++ ) {
6 for (var j = 0 ; j < column; j++ ) {
7 if (mazeMatrix[i][j] == char ) {
8 return [i, j];
9 }
10 }
11 }
12 }
当然,还需要一个函数将矩阵很好地打印为字符串,如下所示:
1 function printMatrix(matrix) {
2 var mazePrintStr = "" ,
3 row = matrix.length,
4 column = matrix[0 ].length;
5
6 for (var i = 0 ; i < row; i++ ) {
7
8 for (var j = 0 ; j < column; j++ ) {
9 mazePrintStr += mazeMatrix[i][j];
10 }
11
12 mazePrintStr += "\n" ;
13
14 }
15 console.log(mazePrintStr);
16 }
最后,定义一个名为path
的函数。这递归地检查上,右,下,左。
Up: path(x+1,y)
Right: path(x,y+1)
Down: path(x-1,y)
Left: path(x,y-1)
function mazePathFinder(mazeMatrix) {
var row = mazeMatrix.length,
column = mazeMatrix[0].length,
startPos = findChar('e', mazeMatrix),
endPos = findChar('x', mazeMatrix);
path(startPos[0], startPos[1]);
function path(x, y) {
if (x > row - 1 || y > column - 1 || x < 0 || y < 0) {
return false;
}
// Found
if (x == endPos[0] && y == endPos[1]) {
return true;
}
if (mazeMatrix[x][y] == '%' || mazeMatrix[x][y] == '+') {
return false;
}
// Mark the current spot
mazeMatrix[x][y] = '+';
printMatrix(mazeMatrix);
if (path(x, y - 1) || path(x + 1, y) || path(x, y + 1) || path(x - 1, y)) {
return true;
}
mazeMatrix[x][y] = '.';
return false;
}
}
图 5-7 显示控制台输出。
图 5-7
控制台输出
时间复杂度: O( mn
空间复杂度: O(1)
这里, m 是行长度, n 是列长度。每个元素只被访问一次。
矩阵旋转
将矩阵向左旋转 90 度。
例如,以下内容:
101
001
111
旋转到此:
111
001
101
图 5-8 所示为旋转。
图 5-8
矩阵逆时针旋转
如图 5-8 所示,向左旋转 90 度时,会出现以下情况:
-
矩阵的第三列成为结果的第一行。
-
矩阵的第二列成为结果的第二行。
-
矩阵的第一列成为结果的第三行。
以下旋转将转动原稿的第三列:
1 var matrix = [[1,0,1],[0,0,1],[1,1,1]];
2
3
4 function rotateMatrix90Left (mat){
5 var N = mat.length;
6
7 // Consider all squares one by one
8 for (var x = 0; x < N / 2; x++) {
9 // Consider elements in group of 4 in
10 // current square
11 for (var y = x; y < N-x-1; y++) {
12 // store current cell in temp variable
13 var temp = mat[x][y];
14
15 // move values from right to top
16 mat[x][y] = mat[y][N-1-x];
17
18 // move values from bottom to right
19 mat[y][N-1-x] = mat[N-1-x][N-1-y];
20
21 // move values from left to bottom
22 mat[N-1-x][N-1-y] = mat[N-1-y][x];
23
24 // assign temp to left
25 mat[N-1-y][x] = temp;
26 }
27 }
28 }
29 rotateMatrix90Left(matrix);
30 console.log(matrix); // [[1,1,1],[0,0,1],[1,0,1]]
时间复杂度: O( mn
空间复杂度: O(1)
这里, m 是行长度, n 是列长度。每个元素只被访问一次。空间复杂度为 O(1 ),因为原始数组被修改而不是创建新数组。
摘要
本章涵盖了各种本机实现的数组函数,并在表 5-1 中进行了总结。
表 5-1
数组函数摘要
|功能
|
使用
|
| --- | --- |
| push(element)
| 将元素添加到数组的末尾 |
| pop()
| 移除数组的最后一个元素 |
| shift()
| 移除数组的第一个元素 |
| slice(beginIndex, endIndex)
| 返回从beginIndex
到endIndex
的数组的一部分 |
| splice(beginIndex, endIndex)
| 返回从beginIndex
到endIndex
的数组的一部分,并通过删除这些元素来修改原始数组 |
| concat(arr)
| 在数组末尾添加新元素(来自arr
) |
除了标准的while
和for
循环机制,数组元素的迭代可以使用表 5-2 中所示的替代循环机制。
表 5-2
迭代摘要
|功能
|
使用
|
| --- | --- |
| for
( var 道具进场) | 按数组元素的索引进行迭代 |
| for
| 通过数组元素的值进行迭代 |
| arr.forEach(fnc)
| 在每个元素上应用fnc
值 |
最后,回想一下 JavaScript 利用交错数组(数组的数组)来获得多维数组行为。使用二维数组,可以很容易地表示二维表面,如井字游戏棋盘和迷宫。
六、JavaScript 对象
JavaScript 对象是 JavaScript 编程语言如此通用的原因。在深入研究数据结构和算法之前,让我们回顾一下 JavaScript 对象是如何工作的。本章将关注什么是 JavaScript 对象,如何声明它们,以及如何改变它们的属性。此外,本章将介绍如何使用原型继承实现 JavaScript 类。
JavaScript 对象属性
JavaScript 对象可以通过对象字面量{}
或语法 new Object();
来创建。可以通过两种方式添加或访问附加属性:object.propertyName
或object['propertyName']
。
1 var javaScriptObject = {};
2 var testArray = [1,2,3,4];
3
4 javaScriptObject.array = testArray;
5 console.log(javaScriptObject); // {array: [1,2,3,4]}
6
7 javaScriptObject.title = 'Algorithms';
8 console.log(javaScriptObject); // {array: [1,2,3,4], title:'Algorithms'}
如前面的代码所示,title
属性在第 7 行被动态添加到 JavaScript 对象中。类似地,JavaScript 类中的函数也是通过动态添加到对象中来添加的。
原型遗传
在大多数强类型语言(如 Java)中,类的方法是与类同时定义的。然而,在 JavaScript 中,该函数必须作为该类的 JavaScript Object
属性添加。
下面是一个使用this.functionName = function(){}
的 JavaScript 类的例子:
1 function ExampleClass(){
2 this.name = "JavaScript";
3 this.sayName = function(){
4 console.log(this.name);
5 }
6 }
7
8 //new object
9 var example1 = new ExampleClass();
10 example1.sayName(); //"JavaScript"
这个类在构造函数中动态添加了sayName
函数。这种模式被称为原型遗传。
原型继承是 JavaScript 中唯一的继承方法。要添加一个类的函数,只需使用.prototype
属性并指定函数名。
当您使用.prototype
属性时,您实际上是在动态扩展对象的 JavaScript Object
属性。这是标准的,因为 JavaScript 是动态的,类可以在以后需要时添加新的函数成员。这对于 Java 等编译语言来说是不可能的,因为它们会在编译时抛出错误。JavaScript 的这个独特属性让开发人员可以利用原型继承。
这里有一个使用.prototype
的例子:
1 function ExampleClass(){
2 this.array = [1,2,3,4,5];
3 this.name = "JavaScript";
4 }
5
6 //new object
7 var example1 = new ExampleClass();
8
9 ExampleClass.prototype.sayName = function() {
10 console.log(this.name);
11 }
12
13 example1.sayName(); //"JavaScript"
重申一下,动态地向类添加函数是 JavaScript 实现原型继承的方式。类的函数要么在构造函数中添加,要么通过.prototype
添加。
构造函数和变量
因为 JavaScript 中一个类的变量是该类对象的属性,所以任何用this.propertyName
声明的属性都是公开可用的。这意味着可以在其他范围内直接访问对象的属性。
1 function ExampleClass(name, size){
2 this.name = name;
3 this.size = size;
4 }
5
6 var example = new ExampleClass("Public",5);
7 console.log(example); // {name:"Public", size: 5}
8
9 // accessing public variables
10 console.log(example.name); // "Public"
11 console.log(example.size); // 5
为了模仿私有变量,而不是使用this.propertyName
,您可以声明一个局部变量,并让 getter/setter 允许访问该变量。这样,变量只对构造函数的作用域可用。然而,值得注意的是,这些被模仿的私有变量现在只能通过定义的接口函数(getter getName
和 setter setName
)来访问。这些 getters 和 setters 不能添加到构造函数之外。
1 function ExampleClass(name, size) {
2 var privateName = name;
3 var privateSize = size;
4
5 this.getName = function() {return privateName;}
6 this.setName = function(name) {privateName = name;}
7
8 this.getSize = function() {return privateSize;}
9 this.setSize = function(size) {privateSize = size;}
10 }
11
12 var example = new ExampleClass("Sammie",3);
13 example.setSize(12);
14 console.log(example.privateName); // undefined
15 console.log(example.getName()); // "Sammie"
16 console.log(example.size); // undefined
17 console.log(example.getSize()); // 3
摘要
在 JavaScript 中,与其他面向对象的编程语言不同,原型继承是首选的继承方法。原型继承通过.prototype
向 JavaScript 类添加新函数来工作。在 Java 和 C++中,私有变量是显式声明的。然而,JavaScript 不支持私有变量,为了模仿私有变量的功能,您需要创建一个作用于构造函数的变量。通过this.variableName
在构造函数中将变量声明为该对象的一部分会自动使该属性成为公共属性。
练习
向对象添加属性
以两种不同的方式向一个空的 JavaScript 对象添加一个exampleKey
属性,并将其设置为exampleValue
。
正如本章前面所讨论的,可以通过两种方式将属性添加到对象中。使用一种方法比使用另一种方法没有性能优势或劣势;选择归结于风格。
1 var emptyJSObj = {};
2 emptyJSObj['exampleKey'] = 'exampleValue';
3 emptyJSObj.exampleKey = 'exampleValue';
定义类别
创建两个类:Animal
和Dog
。Animal
类应该在构造函数中接受两个参数(name
和animalType
)。将它们设置为它的公共属性。
另外,Animal
类应该有两个函数:sayName
和sayAnimalType
。sayName
打印name
,sayAnimalType
打印在构造函数中初始化的animalType
。
最后,Dog
类继承了Animal
类。
-
让我们首先定义
Animal
类和指定的所需函数。 -
为了让
Dog
类继承它,定义Dog
类,然后复制它的原型,如下面的代码块所示:
1 function Animal(name, animalType) {
2 this.name = name;
3 this.animalType = animalType;
4 }
5 Animal.prototype.sayName = function () {
6 console.log(this.name);
7 }
8 Animal.prototype.sayAnimalType = function () {
9 console.log(this.animalType);
10 }
1 function Dog(name) {
2 Animal.call(this, name, "Dog");
3 }
4 // copy over the methods
5 Dog.prototype = Object.create(Animal.prototype);
6 var myAnimal = new Animal("ditto", "pokemon");
7 myAnimal.sayName(); // "ditto"
8 myAnimal.sayAnimalType(); // "pokemon"
9 var myDog = new Dog("candy", "dog");
10 myDog.sayName(); // "candy"
11 myDog.sayAnimalType(); // "dog"
七、JavaScript 内存管理
在任何程序中,变量都会占用一些内存。在 C 之类的低级编程语言中,程序员必须手动分配和释放内存。相比之下,V8 JavaScript 引擎和其他现代 JavaScript 引擎有垃圾收集器,为程序员删除未使用的变量。尽管这种内存管理是由 JavaScript 引擎完成的,但是开发人员可能会陷入一些常见的陷阱。本章将展示这些陷阱的一些基本例子,并介绍帮助垃圾收集器最小化关键 JavaScript 内存问题的技术。
内存泄漏
一个内存泄漏是一个程序释放被丢弃的内存失败,导致性能下降,有时甚至失败。当 JavaScript 引擎的垃圾收集器没有正确释放内存时,就会发生内存泄漏。
遵循本章概述的关键原则,以避免 JavaScript 开发过程中的内存泄漏。
对对象的引用
如果存在对某个对象的引用,它就在内存中。在这个例子中,假设memory()
函数返回一个包含 5KB 数据的数组。
1 var foo = {
2 bar1: memory(), // 5kb
3 bar2: memory() // 5kb
4 }
5
6 function clickEvent(){
7 alert(foo.bar1[0]);
8 }
您可能希望clickEvent()
函数使用 5KB 的内存,因为它只从foo
对象中引用bar1
。然而,事实是它使用了 10KB 的内存,因为它必须将整个foo
对象加载到函数的 into 范围中,以访问bar1
属性。
泄漏 DOM
如果指向 DOM 元素的变量是在事件回调之外声明的,那么它就在内存中,如果元素被删除,就会泄漏 DOM。
在这个例子中,document.getElementByID
选择了两个 DOM 元素。
1 <div id="one">One</div>
2 <div id="two">Two</div>
下面的 JavaScript 代码演示了 DOM 内存泄漏。当one
被点击时,它移除two
。当one
再次被点击时,它仍然试图引用被移除的two
。
1 var one = document.getElementById("one");
2 var two = document.getElementById("two");
3 one.addEventListener('click', function(){
4 two.remove();
5 console.log(two); // will print the html even after deletion
6 });
当点击时,one
元素上的事件监听器将导致two
从网页上消失。然而,即使在 HTML 中删除了 DOM,如果在事件回调中使用,对它的引用仍将保留。当two
元素不再被使用时,这就是内存泄漏,应该避免。
这很容易修复,因此不会导致内存泄漏,如下所示:
1 var one = document.getElementById("one");
2
3 one.addEventListener('click', function(){
4 var two = document.getElementById("two");
5 two.remove();
6 });
解决这个问题的另一种方法是在使用 click 处理程序后将其注销,如下所示:
1 var one = document.getElementById("one");
2 function callBackExample() {
3 var two = document.getElementById("two");
4 two.remove();
5 one.removeEventListener("click",callBackExample);
6 }
7 one.addEventListener("click",callBackExample);
8 });
全局窗口对象
如果一个对象在全局窗口对象上,它就在内存中。window
对象是浏览器中的一个全局对象,带有各种内置方法,如alert()
和setTimeout()
。声明为window
属性的任何附加对象都不会被清除,因为window
是浏览器运行所必需的对象。记住,任何声明的全局变量都将被设置为window
对象的属性。
在这个例子中,声明了两个全局变量。
1 var a = "apples"; //global with var
2 b = "oranges"; //global without var
3
4 console.log(window.a); // prints "apples"
5 console.log(window.b); // prints "oranges"
尽可能避免全局变量是有好处的。这将有助于节省内存。
限制对象引用
当所有引用都被清除时,对象也被清除。一定要记住限制函数的作用域,只将对象的属性传递给函数,而不是整个对象。这是因为对象的内存占用可能非常大(例如,用于数据可视化项目的 100,000 个整数的数组);如果只需要对象的一个属性,应该避免使用整个对象作为参数。
例如,不要这样做:
1 var test = {
2 prop1: 'test'
3 }
4
5 function printProp1(test){
6 console.log(test.prop1);
7 }
8
9 printProp1(test); //'test'
相反,应该像这样传递属性:
1 var test = {
2 prop1: 'test'
3 }
4
5 function printProp1(prop1){
6 console.log(prop1);
7 }
8
9 printProp1(test.prop1); //'test'
删除操作符
永远记住delete
操作符可以用来删除不需要的对象属性(尽管它对非对象不起作用)。
1 var test = {
2 prop1: 'test'
3 }
4 console.log(test.prop1); // 'test'
5 delete test.prop1;
6 console.log(test.prop1); // _undefined_
摘要
尽管 JavaScript 中的内存不是由程序员分配的,但是仍然有很多方法可以减少内存泄漏。如果对象在引用中,它就在内存中。同样,HTML DOM 元素一旦被删除就不应该被引用。最后,只引用函数中需要的对象。在许多情况下,传入对象的属性比传入对象本身更适用。此外,在声明全局变量时要特别小心。
练习
在这一章中,练习是关于识别内存低效和优化一段给定的代码。
分析和优化属性调用
分析并优化对printProperty
的调用。
1 function someLargeArray() {
2 return new Array(1000000);
3 }
4 var exampleObject = {
5 'prop1': someLargeArray(),
6 'prop2': someLargeArray()
7 }
8 function printProperty(obj){
9 console.log(obj['prop1']);
10 }
11 printProperty(exampleObject);
问题:在printProperty
中使用了过量的内存,因为整个对象都被带入了printProperty
函数。要解决这个问题,应该只将正在打印的属性作为函数的参数引入。
答案:
1 function someLargeArray() {
2 return new Array(1000000);
3 }
4 var exampleObject = {
5 'prop1': someLargeArray(),
6 'prop2': someLargeArray()
7 }
8 function printProperty(prop){
9 console.log(prop);
10 }
11 printProperty(exampleObject['prop1']);
分析和优化范围
分析并优化以下代码块的全局范围:
1 var RED = 0,
2 GREEN = 1,
3 BLUE = 2;
4
5 function redGreenBlueCount(arr) {
6 var counter = new Array(3) .fill(0);
7 for (var i=0; i < arr.length; i++) {
8 var curr = arr[i];
9 if (curr == RED) {
10 counter[RED]++;
11 } else if (curr == GREEN) {
12 counter[GREEN]++;
13 } else if (curr == BLUE) {
14 counter[BLUE]++;
15 }
16 }
17 return counter;
18 }
19 redGreenBlueCount([0,1,1,1,2,2,2]); // [1, 3, 3]
问题:全局变量用在了不必要的地方。尽管很小,全局变量RED
、GREEN
和BLUE
扩大了全局范围,应该被移到redGreenBlueCount
函数中。
答案:
1 function redGreenBlueCount(arr) {
2 var RED = 0,
3 GREEN = 1,
4 BLUE = 2,
5 counter = new Array(3) .fill(0);
6 for (var i=0; i < arr.length; i++) {
7 var curr = arr[i];
8 if (curr == RED) {
9 counter[RED]++;
10 } else if (curr == GREEN) {
11 counter[GREEN]++;
12 } else if (curr == BLUE) {
13 counter[BLUE]++;
14 }
15 }
16 return counter;
17 }
18 redGreenBlueCount([0,1,1,1,2,2,2]); // [1, 3, 3]
分析和修复内存问题
分析并修复以下代码的内存问题。
HTML:
<button id="one">Button 1</button>
<button id="two">Button 2</button>
JavaScript:
1 var one = document.querySelector("#one");
2 var two = document.querySelector("#two");
3 function callBackExample () {
4 one.removeEventListener("",callBackExample);
5 }
6 one.addEventListener('hover', function(){
7 two.remove();
8 console.log(two); // will print the html even after deletion
9 });
10 two.addEventListener('hover', function(){
11 one.remove();
12 console.log(one); // will print the html even after deletion
13 });
问题:这是本章前面讨论的“泄漏 DOM”问题。当元素被移除时,它们仍然被回调函数引用。为了解决这个问题,将one
和two
变量放入回调的作用域中,然后移除事件监听器。
答案:
HTML:
<button id="one"> Button 1 </button>
<button id="two"> Button 2 </button>
JavaScript:
1 var one = document.querySelector("#one");
2 var two = document.querySelector("#two");
3 function callbackOne() {
4 var two = document.querySelector("#two");
5 if (!two)
6 return;
7 two.remove();
8 one.removeEventListener("hover", callbackOne);
9 }
10
11 function callbackTwo() {
12 var one = document.querySelector("#one");
13 if (!one)
14 return;
15 one.remove();
16 two.removeEventListener("hover", callbackTwo);
17 }
18 one.addEventListener("click", callbackOne);
19 two.addEventListener("click", callbackTwo);
八、递归
本章介绍递归和递归算法的概念。首先,将探索递归的定义和递归算法的基本规则。此外,分析递归函数效率的方法将使用数学符号详细介绍。最后,章节练习将有助于巩固这些信息。
引入递归
在数学、语言学和艺术中,递归指的是根据自身定义的事物的发生。在计算机科学中,递归函数是一个调用自身的函数。递归函数通常很优雅,通过“分而治之”的方法解决复杂的问题。递归很重要,因为你会在各种数据结构的实现中一次又一次地看到它。图 8-1 展示了一个递归的可视化说明,其中图片有自己的更小的图片。
图 8-1
图解递归
递归规则
当递归函数实现不正确时,会导致致命的问题,因为程序会被卡住而不会终止。无限递归调用导致栈溢出。栈溢出是程序的调用栈的最大数量超过了地址空间(内存)的限量。
为了正确实现递归函数,它们必须遵循一定的规则,以避免栈溢出。这些规则将在下面介绍。
基础案例
在递归中,必须有一个基础用例(也称为终止用例)。因为递归方法调用它们自己,除非达到这个基本情况,否则它们永远不会停止。递归导致的栈溢出很可能是因为没有合适的基本用例。在基本情况下,有没有递归函数调用。
让我们看看下面的函数,它打印从n
到 0 的数字作为例子:
1 function countDownToZero(n) {
2 // base case. Stop at 0
3 if (n < 0) {
4 return; // stop the function
5 } else {
6 console.log(n);
7 countDownToZero(n - 1); // count down 1
8 }
9 }
10 countDownToZero(12);
该功能的基本情况是当n
小于或等于 0 时。这是因为期望的结果是从 0 开始停止计数。如果输入的是负数,由于基本情况,它不会打印该数字。除了一个基本案例,这个递归函数还展示了分治法。
分治法
在计算机科学中,分而治之方法是指通过解决一个问题的所有较小部分来解决该问题。以倒计时为例,从 2 开始倒计时可以通过打印 2 然后从 1 开始倒计时来解决。这里,从 1 开始倒数的是通过“分而治之”解决的部分有必要将问题变小以达到基本情况。否则,如果递归调用没有收敛到基本情况,就会发生栈溢出。
现在让我们检查一个更复杂的递归函数,称为斐波那契数列。
经典例子:斐波那契数列
斐波那契数列是一个无限数字的列表,每个数字都是过去两项的总和(从 1 开始)。
- 1, 1, 2, 3, 5, 8, 13, 21 …
你如何编写程序来打印斐波那契数列的第 n 项?
迭代解法:斐波那契数列
使用for
循环的迭代解决方案可能如下所示:
1 function getNthFibo(n) {
2 if ( n <= 1) return n;
3 var sum = 0,
4 last = 1,
5 lastlast = 0;
6
7 for (var i = 1; i < n; i++) {
8 sum = lastlast + last;
9 lastlast = last;
10 last = sum;
11 }
12 return sum;
13 }
一个for
循环可以用来跟踪斐波纳契数列的最后两个元素,它的和产生斐波纳契数。
现在,这是如何递归完成的呢?
递归解:斐波那契
下面显示了递归解决方案:
1 function getNthFibo(n) {
2 if (n <= 1) {
3 return n;
4 } else {
5 return getNthFibo(n - 1) + getNthFibo(n - 2);
6 }
7 }
基本情况:斐波纳契数列的基本情况是第一个元素是 1。
分而治之:根据斐波那契数列的定义,第 n 个斐波那契数是第( n -1)个和第( n -2)个斐波那契数之和。但是,这种实现的时间复杂度为 O(2 n ),这将在本章后面详细讨论。在下一节中,我们将使用尾部递归来探索斐波那契数列的更有效的递归算法。
斐波那契数列:尾部递归
尾递归函数是递归函数,其中递归调用是函数中最后执行的东西。首先让我们看看迭代解:
1 function getNthFibo(n) {
2 if ( n <= 1) return n;
3 var sum = 0,
4 last = 1,
5 lastlast = 0;
6
7 for (var i = 1; i < n; i++) {
8 sum = lastlast + last;
9 lastlast = last;
10 last = sum;
11 }
12 return sum;
13 }
在每次迭代中,会发生以下更新:(lastlast, last) = (last, lastlast+last)
。利用这种结构,可以形成下面的递归函数:
1 function getNthFiboBetter(n, lastlast, last) {
2 if (n == 0) {
3 return lastlast;
4 }
5 if (n == 1) {
6 return last;
7 }
8 return getNthFiboBetter(n-1, last, lastlast + last);
9 }
时间复杂度: O( n
这个函数最多执行 n 次,因为每次只有一次递归调用,它就递减 n -1。
空间复杂度 : O( n
因为这个函数使用了栈调用,所以空间复杂度也是 O( n )。这将在本章后面的“递归调用栈内存”一节中进一步解释。
为了总结递归的规则,让我们检查另一个更复杂的例子。
帕斯卡三角形
在这个例子中,将探索用于计算帕斯卡三角形的一项的函数。帕斯卡三角形是一个三角形,其元素值是其顶两(左、右)值之和,如图 8-2 。
图 8-2
帕斯卡三角形
基本情况:帕斯卡三角形的基本情况是顶元素(row=1,col=1)为 1。其他的一切都是单单从这个事实推导出来的。因此,当列为 1 时,返回 1,当行为 0 时,返回 0。
分而治之:根据帕斯卡三角形的数学定义,帕斯卡三角形的一项定义为其上项之和。因此,这可以表述为:pascalTriangle(row - 1, col) + pascalTriangle(row - 1, col - 1)
。
1 function pascalTriangle(row, col) {
2 if (col == 0) {
3 return 1;
4 } else if (row == 0) {
5 return 0;
6 } else {
7 return pascalTriangle(row - 1, col) + pascalTriangle(row - 1, col - 1);
8 }
9 }
10 pascalTriangle(5, 2); // 10
这就是递归的妙处!接下来看看这段代码有多短多优雅。
大 O 代表递归
在第一章中,没有涉及递归算法的 Big-O 分析。这是因为递归算法很难分析。为了对递归算法进行 Big-O 分析,必须分析其递归关系。
递推关系
在迭代实现的算法中,Big-O 分析要简单得多,因为循环清楚地定义了何时停止以及每次迭代增加多少。为了分析递归算法,使用递归关系。递归关系由两部分分析组成:基础情况的 Big-O 和递归情况的 Big-O。
让我们重温一下简单的斐波那契数列例子:
function getNthFibo(n) {
if (n <= 1) {
return n;
} else {
return getNthFibo(n - 1) + getNthFibo(n - 2);
}
}
getNthFibo(3);
基本情况的时间复杂度为 O(1)。递归情况调用自己两次。我们把这个表示为T(n)=T(n1)+T(n2)+O(1)。
-
基本情况: T ( n ) = O(1)
-
递归情况:T(n)=T(n1)+T(n2)+O(1)
现在,这个关系意味着,既然T(n)=T(n1)+T(n2)+O(1),那么(通过将 n 替换为n1),T(n1)=用n2 替换n1 得到T(n2)=T(n3)+T(n4)+O(1)。因此,你可以看到,对于每个调用,每个调用都有两个以上的调用。换句话说,这有 O 的时间复杂度(2 n )。
把它想象成这样会有所帮助:
F(6) * <-- only once
F(5) *
F(4) **
F(3) ****
F(2) ********
F(1) **************** <-- 16
F(0) ******************************** <-- 32
用这种方法计算 Big-O 很困难,而且容易出错。谢天谢地,有一个叫做主定理的概念可以帮忙。主定理帮助程序员轻松分析递归算法的时间和空间复杂性。
主定理
主定理陈述如下:
- 给定一个形式为T(n)=aT(n/b)+O(nc)的递推关系,其中 a > = 1,b > =1,有三种情况。
a 是与递归调用相乘的系数。 b 是“对数”项,是递归调用过程中划分 n 的项。最后, c 是方程非递归分量上的多项式项。
第一种情况是非递归分量 O( n c )上的多项式项小于logb(a)时。
-
案例一:c<logb(a)然后 T(n)= O(n(logb(a))。
-
比如T(n)= 8T(n/2)+1000n2
-
识别 a,b,c: a = 8, b = 2, c = 2
-
评估: 日志 2 (8) = 3。c<3 满足。
-
结果:T(n)= O(n3)
第二种情况是当 c 为等于对logb(a)。
-
案例二:c=logb(a)然后 T(n)= O(nclog(n)。
-
比如T(n)= 2T(n/2)+10n。
-
确定 a,b,c: a = 2, b = 2, c = 1
-
求值: 日志 2 (2) = 1。 c = 1 满足。
-
结果:(n)= o(n【c】日志**
**第三种也是最后一种情况是当 c 大于logb(a)时。
-
案例三:c>logb(a)然后 T(n)= O(f(n))。
-
比如T(n)= 2T(n/2)+n2。
-
确定 a,b,c: a = 2,b = 2,c = 2
-
求值: 日志 2 (2) = 1。c>1 满足。
-
结果:T(n)=f(n)= O(n2)
这一节介绍了很多关于分析递归算法的时间复杂度。空间复杂性分析同样重要。递归函数调用使用的内存也应该记录下来,并进行空间复杂度分析。
递归调用栈内存
当一个递归函数调用它自己时,会占用内存,这在 Big-O space 复杂性分析中非常重要。
例如,这个从 n 到 1 的简单打印函数在空间中递归地取 O( n ):
1 function printNRecursive(n) {
2 console.log(n);
3 if (n > 1){
4 printNRecursive(n-1);
5 }
6 }
7 printNRecursive(10);
开发人员可以在浏览器或任何 JavaScript 引擎上运行该程序,并将在调用栈中看到如图 8-3 所示的结果。
图 8-3
开发人员工具中的调用栈
如图 8-3 和 8-4 所示,每个递归调用都必须存储在内存中,直到基本情况得到解决。由于调用栈,递归算法需要额外的内存。
图 8-4
调用栈内存
递归函数有一个额外的空间复杂性成本,它来自需要存储在操作系统内存栈中的递归调用。栈被累积,直到基础案例被解决。事实上,这通常是迭代解决方案优于递归解决方案的原因。在最坏的情况下,如果基本情况实现不正确,递归函数将导致程序崩溃,因为当内存栈中的元素超过允许的数量时,会出现栈溢出错误。
摘要
递归是实现复杂算法的强大工具。回想一下,所有递归函数都由两部分组成:基本情况和分治法(解决子问题)。
分析这些递归算法的 Big-O 可以凭经验(不推荐)或通过使用主定理来完成。回想一下,主定理需要以下形式的递推关系:T(n)=aT(n/b)+O(nc)。使用主定理时,识别 a 、 b 和 c 来确定它属于主定理三种情况中的哪一种。
最后,在实现和分析递归算法时,要考虑递归函数调用的调用栈所导致的额外内存。每个递归调用在运行时都需要在调用栈中有一个位置;当调用栈累计 n 次调用时,那么函数的空间复杂度为 O( n )。
练习
这些递归练习涵盖了不同的问题,有助于巩固从本章学到的知识。重点应该是在解决整个问题之前首先确定正确的基础案例。你可以在 GitHub 上找到所有练习的代码。 1
将十进制(以 10 为基数)转换为二进制数
要做到这一点,请将数字除以 2,每次计算模数(余数)和除法。
基本情况:这个问题的基本情况是当 n 小于 2 时。小于 2 时,只能是 0 或 1。
1 function base10ToString(n) {
2 var binaryString = "";
3
4 function base10ToStringHelper(n) {
5 if (n < 2) {
6 binaryString += n;
7 return;
8 } else {
9 base10ToStringHelper(Math.floor(n / 2));
10 base10ToStringHelper(n % 2);
11 }
12 }
13 base10ToStringHelper(n);
14
15 return binaryString;
16 }
17
18 console.log(base10ToString(232)); // 11101000
时间复杂度:O(log2(n))
时间复杂度是对数的,因为递归调用将 n 除以 2,这使得算法很快。例如,对于 n = 8,它只执行三次。对于 n =1024,它执行 10 次。
空间复杂度:O(log2(n))
打印数组的所有排列
这是一个经典的递归问题,也是一个很难解决的问题。问题的前提是在每个可能的位置交换数组的元素。
先来画一下这个问题的递归树(见图 8-5 )。
图 8-5
数组递归树的排列
基本情况: beginIndex
等于endIndex
。
当这种情况发生时,函数应该打印当前的排列。
排列:我们需要一个函数来交换元素:
1 function swap(strArr, index1, index2) {
2 var temp = strArr[index1];
3 strArr[index1] = strArr[index2];
4 strArr[index2] = temp;
5 }
1 function permute(strArr, begin, end) {
2 if (begin == end) {
3 console.log(strArr);
4 } else {
5 for (var i = begin; i < end + 1; i++) {
6 swap(strArr, begin, i);
7 permute(strArr, begin + 1, end);
8 swap(strArr, begin, i);
9 }
10 }
11 }
12
13 function permuteArray(strArr) {
14 permute(strArr, 0, strArr.length - 1);
15 }
16
17 permuteArray(["A", "C", "D"]);
18 // ["A", "C", "D"]
19 // ["A", "D", "C"]
20 // ["C", "A", "D"]
21 // ["C", "D", "A"]
22 // ["D", "C", "A"]
23 // ["D", "A", "C"]
时间复杂度: O( n !)
空间复杂度: O( n !)
有n!
种排列,它创建了n!
个调用栈。
弄平一个物体
给定这样一个 JavaScript 数组:
1 var dictionary = {
2 'Key1': '1',
3 'Key2': {
4 'a' : '2',
5 'b' : '3',
6 'c' : {
7 'd' : '3',
8 'e' : '1'
9 }
10 }
11 }
展平成{'Key1': '1', 'Key2.a': '2','Key2.b' : '3', 'Key2.c.d' : '3', 'Key2.c.e' : '1'}
,在父子之间用.
表示子(见图 8-6 )。
图 8-6
展平字典递归树
要做到这一点,迭代任何属性并递归地检查它的子属性,传入连接的字符串名称。
基本情况:这个问题的基本情况是输入不是对象的时候。
1 function flattenDictionary(dictionary) {
2 var flattenedDictionary = {};
3
4 function flattenDitionaryHelper(dictionary, propName) {
5 if (typeof dictionary != 'object') {
6 flattenedDictionary[propName] = dictionary;
7 return;
8 }
9 for (var prop in dictionary) {
10 if (propName == "){
11 flattenDitionaryHelper(dictionary[prop], propName+prop);
12 } else {
13 flattenDitionaryHelper(dictionary[prop], propName+'.'+prop);
14 }
15 }
16 }
17
18 flattenDitionaryHelper(dictionary, ");
19 return flattenedDictionary;
20 }
时间复杂度: O( n
空间复杂度: O( n
每个属性只被访问一次,并且每 n 个属性存储一次。
写一个程序,递归地确定一个字符串是否是回文
一个回文是一个向后和向前拼写相同的单词,如神化、赛车、 testset 和 aibohphobia (对回文的恐惧)。
1 function isPalindromeRecursive(word) {
2 return isPalindromeHelper(word, 0, word.length-1);
3 }
4
5 function isPalindromeHelper(word, beginPos, endPos) {
6 if (beginPos >= endPos) {
7 return true;
8 }
9 if (word.charAt(beginPos) != word.charAt(endPos)) {
10 return false;
11 } else {
12 return isPalindromeHelper(word, beginPos + 1, endPos - 1);
13 }
14 }
15
16 isPalindromeRecursive('hi'); // false
17 isPalindromeRecursive('iii'); // true
18 isPalindromeRecursive('ii'); // true
19 isPalindromeRecursive('aibohphobia'); // true
20 isPalindromeRecursive('racecar'); // true
这背后的想法是,用两个索引(一个在前面,一个在后面),你检查每一步,直到前面和后面相遇。
时间复杂度: O( n
空间复杂度: O( n
由于递归调用栈,这里的空间复杂度仍然是 O( n )。请记住,调用栈仍然是内存的一部分,即使它没有声明变量或存储在数据结构中。
**九、集合
本章重点介绍如何使用集合。集合的概念从数学定义和在实现水平上被描述和探索。常见的集合操作,以及它们的实现,都有非常详细的介绍。本章结束时,你将理解如何使用 JavaScript 的本地Set
对象来利用集合操作。
器械包简介
集合是最基本的数据结构之一。集合的概念很简单:它是一组明确的、不同的对象。通俗地说,在编程中,一个集合就是一组无序的唯一的(无重复)元素。例如,一组整数可能是{1,2,3,4}。其中,它的子集是{}、{1}、{2}、{3}、{4}、{1,2}、{1,3}、{1,4}、{2,3}、{2,4}、{3,4}、{1,2,3}、{1,2,4}、{1,3,4}和{2,3,4}。集合对于检查和添加 O(1)常数时间中的唯一元素非常重要。集合具有常量时间操作的原因是其实现是基于散列表的(在第十一章中讨论)。
Set
在 JavaScript 中受本地支持,如下所示:
1 var exampleSet = new Set();
原生的Set
对象只有一个属性:size
(整数)。此属性是集合中元素的当前数量。
集合操作
该集合是用于执行唯一性检查的强大数据结构。本节将涵盖以下关键操作:插入、删除、包含。
插入
有一个主要功能:检查唯一性。Set
可以添加项目,但不允许重复。
1 var exampleSet = new Set();
2 exampleSet.add(1); // exampleSet: Set {1}
3 exampleSet.add(1); // exampleSet: Set {1}
4 exampleSet.add(2); // exampleSet: Set {1, 2}
请注意,添加重复元素不适用于集合。正如在引言中所讨论的,插入到集合中是在恒定时间内发生的。
时间复杂度: O(1)
删除
Set
也可以从集合中删除项目。Set.delete
返回一个布尔值(如果该元素存在并被删除,则为真,否则为假)。
1 var exampleSet = new Set();
2 exampleSet.add(1); // exampleSet: Set {1}
3 exampleSet.delete(1); // true
4 exampleSet.add(2); // exampleSet: Set {2}
这对于能够在恒定时间内删除项目是有用的,相比之下,在数组中删除一个项目需要 O( n )时间。
时间复杂度: O(1)
包含
Set.has
执行快速 O(1)查找,检查元素是否存在于集合中。
1 var exampleSet = new Set();
2 exampleSet.add(1); // exampleSet: Set {1}
3 exampleSet.has(1); // true
4 exampleSet.has(2); // false
5 exampleSet.add(2); // exampleSet: Set {1, 2}
6 exampleSet.has(2); // true
时间复杂度: O(1)
其他实用功能
除了本机支持的 set 函数之外,其他基本操作也是可用的;本节将对此进行探讨。
交集
首先,两个集合的交集由这两个集合之间的公共元素组成。此函数返回两个集合之间具有公共元素的集合:
1 function intersectSets (setA, setB) {
2 var intersection = new Set();
3 for (var elem of setB) {
4 if (setA.has(elem)) {
5 intersection.add(elem);
6 }
7 }
8 return intersection;
9 }
10 var setA = new Set([1, 2, 3, 4]),
11 setB = new Set([2, 3]);
12 intersectSets(setA,setB); // Set {2, 3}
父集
第二,一个集合是另一个集合的“超集”,如果它包含另一个集合的所有元素。这个函数检查一个集合是否是另一个集合的超集。这可以简单地通过检查另一个集合是否包含参考集合的所有元素来实现。
1 function isSuperset(setA, subset) {
2 for (var elem of subset) {
3 if (!setA.has(elem)) {
4 return false;
5 }
6 }
7 return true;
8 }
9 var setA = new Set([1, 2, 3, 4]),
10 setB = new Set([2, 3]),
11 setC = new Set([5]);
12 isSuperset(setA, setB); // true
13 // because setA has all elements that setB does
14 isSuperset(setA, setC); // false
15 // because setA does not contain 5 which setC contains
联盟
第三,两个集合的并集合并了两个集合中的元素。这个函数返回一个包含两个元素的新集合,没有重复的元素。
1 function unionSet(setA, setB) {
2 var union = new Set(setA);
3 for (var elem of setB) {
4 union.add(elem);
5 }
6 return union;
7 }
8 var setA = new Set([1, 2, 3, 4]),
9 setB = new Set([2, 3]),
10 setC = new Set([5]);
11 unionSet(setA,setB); // Set {1, 2, 3, 4}
12 unionSet(setA,setC); // Set {1, 2, 3, 4, 5}
差异
最后,集合 A 与集合 B 的差异是集合 A 中不在集合 B 中的所有元素。该函数通过使用本机delete
方法来实现差异运算。
1 function differenceSet(setA, setB) {
2 var difference = new Set(setA);
3 for (var elem of setB) {
4 difference.delete(elem);
5 }
6 return difference;
7 }
8 var setA = new Set([1, 2, 3, 4]),
9 setB = new Set([2, 3]);
10 differenceSet(setA, setB); // Set {1, 4}
摘要
集合是表示无序唯一元素的基本数据结构。本章介绍了 JavaScript 的本机Set
对象。Set
对象支持插入、删除、包含检查,时间复杂度均为 O(1)。使用这些内置方法,可以实现其他基本的集合运算,如交集、差集、并集和超集检查。这些将使你在以后的章节中实现快速唯一性检查的算法。
表 9-1 总结了设置操作。
表 9-1
设置摘要
|操作
|
函数名
|
描述
|
| --- | --- | --- |
| 插入 | Set.add
| 原生 JavaScript。如果元素不在集合中,则将其添加到集合中。 |
| 删除 | Set.delete
| 原生 JavaScript。如果元素在集合中,则将其从集合中删除。 |
| 包含 | Set.has
| 原生 JavaScript。检查元素是否存在于集合中。 |
| 交集(A∩B) | intersectSets
| 返回具有集合 A 和集合 b 的公共元素的集合。 |
| 联合(a 至 b) | unionSet
| 返回包含集合 A 和集合 b 的所有元素的集合。 |
| 差异(A-B) | differenceSet
| 返回包含所有元素的集合。 |
练习
使用集合检查数组中的重复项
使用集合检查整数数组中是否有重复项。通过将数组转换为集合,可以将集合的大小与数组的长度进行比较,从而轻松检查重复项。
1 function checkDuplicates(arr) {
2 var mySet = new Set(arr);
3 return mySet.size < arr.length;
4 }
5 checkDuplicates([1,2,3,4,5]); // false
6 checkDuplicates([1,1,2,3,4,5]); // true
时间复杂度: O( n
空间复杂度: O( n
在一个长度为 n 的数组中,这个函数必须在最坏的情况下遍历整个数组,并且将所有这些元素存储在集合中。
从单独的数组中返回所有唯一值
给定两个具有相同值的整数数组,返回一个包含两个原始数组中所有唯一元素的数组。
使用集合,可以轻松存储唯一的元素。通过连接两个数组并将它们转换为一个集合,只存储唯一的项。将集合转换为数组会产生一个只包含唯一项的数组。
1 function uniqueList(arr1, arr2) {
2 var mySet = new Set(arr1.concat(arr2));
3 return Array.from(mySet);
4 }
5
6 uniqueList([1,1,2,2],[2,3,4,5]); // [1,2,3,4,5]
7 uniqueList([1,2],[3,4,5]); // [1,2,3,4,5]
8 uniqueList([],[2,2,3,4,5]); // [2,3,4,5]
时间复杂度: O( n + m
空间复杂度: O( n + m
该算法的时空复杂度为 O( n + m ),其中 n 为arr1
的长度, m 为arr2
的长度。这是因为两个数组中的所有元素都需要被迭代。
十、搜索和排序
搜索数据和整理数据是基本的算法。搜索指的是迭代数据结构的元素来检索一些数据。排序指的是将数据结构的元素按顺序排列。每种数据结构的搜索和排序算法都是不同的。本章重点介绍数组的搜索和排序。在本章结束时,你将理解如何使用数组的常用排序和搜索算法。
搜索
如前所述,搜索是在数据结构中寻找特定元素的任务。在数组中搜索时,根据数组是否排序,有两种主要的技术。在本节中,您将学习线性和二进制搜索。线性搜索特别灵活,因为它们可以用于排序和未排序的数据。二进制搜索专门用于排序数据。然而,线性搜索比二分搜索法具有更高的时间复杂度。
线性搜索
线性搜索的工作方式是依次遍历数组中的每个元素。下面的代码示例是线性搜索的实现,该搜索遍历整个数字数组,以确定数组中是否存在 4 和 5。
1 //iterate through the array and find
2 function linearSearch(array,n){
3 for(var i=0; i<array.length; i++) {
4 if (array[i]==n) {
5 return true;
6 }
7 }
8 return false;
9 }
10 console.log(linearSearch([1,2,3,4,5,6,7,8,9], 6)); // true
11 console.log(linearSearch([1,2,3,4,5,6,7,8,9], 10)); // false
时间复杂度: O( n
如图 10-1 所示,当搜索 6 时,会经历 6 次迭代。当搜索 10 时,它必须遍历所有的 n 个元素,然后返回false
;因此时间复杂度为 O( n )。
图 10-1
线性搜索
作为另一个例子,对于数组[1,2,3,4,5]和搜索项 3,将需要三次迭代来完成(1,2,3)。这个算法的大 O 为 O( n )的原因是,在最坏的情况下,需要迭代整个数组。例如,如果搜索项为 5,则需要 5 次迭代(1、2、3、4、5)。如果 6 是搜索项,它将遍历整个数组(1,2,3,4,5),然后返回false
,因为没有找到它。
如前所述,像这样的线性搜索算法是很棒的,因为不管数组是否排序,它都能工作。在线性搜索算法中,检查数组的每个元素。因此,当数组没有排序时,应该使用线性搜索。如果数组已排序,通过二分搜索法可以更快地进行搜索。
二进位检索
二分搜索法是一种搜索算法,对排序后的数据进行处理。与检查数组中每个元素的线性搜索算法不同,二进制搜索可以检查中间值,以查看所需值是大于还是小于它。如果期望值较小,该算法可以搜索较小的部分,或者如果期望值较大,它可以搜索较大的部分。
图 10-2 说明了二分搜索法的过程。首先,搜索范围是 1 到 9。因为中间的元素 5 大于 3,所以搜索范围被限制为 1 到 4。最后发现 3 是中间元素。图 10-3 展示了在数组的右半部分搜索一个项目。
图 10-3
二分搜索法在数组的右半部分
图 10-2
二分搜索法在阵的左半部
下面的代码实现了所描述的二分搜索法算法:
1 function binarySearch(array,n){
2 var lowIndex = 0, highIndex = array1.length-1;
3
4 while(lowIndex<=highIndex){
5 var midIndex = Math.floor((highIndex+lowIndex) /2);
6 if (array[midIndex]==n) {
7 return midIndex;
8 } else if (n>array[midIndex]) {
9 lowIndex = midIndex;
10 } else {
11 highIndex = midIndex;
12 }
13 }
14 return -1;
15 }
16 console.log(binarySearch([1,2,3,4], 4)); // true
17 console.log(binarySearch([1,2,3,4], 5)); // -1
二分搜索法算法速度很快,但只有在对数组进行排序的情况下才能实现。它检查中间的元素是否是正在搜索的元素。如果搜索元素比中间元素大,则下限被设置为中间元素加 1。如果搜索元素小于中间元素,则上限设置为中间元素减一。
这样,算法不断地将数组分成两部分:下半部分和上半部分。如果元素比中间的元素小,应该在下半部分找;如果元素比中间的元素大,应该在上半部分寻找。
人类在不知不觉中使用了二进制搜索。一个例子是按照姓氏从 A 到 Z 排列的电话簿。
如果你的任务是找到一个姓勒泽的人,你会先去 L 区,然后打开一半。莉莎在那一页上;这意味着下半部分包含 L + [a 到 i],上半部分包含 L + [i 到 z]个姓氏。然后你可以检查下半部分的中间。Laar 出现,所以您现在可以检查上面的部分。重复这个过程,直到找到勒泽。
整理
排序是计算机科学中最重要的课题之一;与未排序的排序数组相比,在排序数组中查找项目更快更容易。您可以使用排序算法对内存中的数组进行排序,以便稍后在程序中进行搜索,或者写入文件以便稍后检索。在本节中,我们将探讨不同的排序技术。我们将从简单的排序算法开始,然后探索高效的排序算法。高效的排序算法具有在使用过程中应该考虑的各种权衡。
冒泡排序
冒泡排序是最简单的排序算法。它简单地遍历整个数组,如果一个比另一个大,就交换元素,如图 10-4 和图 10-5 所示。
图 10-5
剩余的冒泡排序运行
图 10-4
首次运行冒泡排序
swap
是排序中常用的函数。它只是切换两个数组元素的值,并将被用作前面提到的大多数排序算法的辅助函数。
1 function swap(array, index1, index2) {
2 var temp = array[index1];
3 array[index1] = array[index2];
4 array[index2] = temp;
5 }
下面的bubbleSort
代码块说明了前面描述的冒泡排序算法:
1 function bubbleSort(array) {
2 for (var i=0, arrayLength = array.length; i<arrayLength; i++) {
3 for (var j=0; j<=i; j++) {
4 if (array[i] < array[j]) {
5 swap(array, i, j);
6 }
7 }
8 }
9 return array;
10 }
11 bubbleSort([6,1,2,3,4,5]); // [1,2,3,4,5,6]
时间复杂度:O(n2
空间复杂度: O(1)
冒泡排序是最差的排序类型,因为它比较每一对可能的排序,而其他排序算法利用数组的预排序部分。因为冒泡排序使用嵌套循环,所以时间复杂度为 O( n 2 )。
选择排序
选择排序的工作方式是扫描元素中最小的元素,并将其插入到数组的当前位置。这个算法比冒泡排序稍微好一点。图 10-6 显示了这个最小选择过程。
图 10-6
选择排序
下面的代码实现了选择排序。在代码中,有一个for
循环迭代数组,还有一个嵌套的for
循环扫描以获得最小元素。
1 function selectionSort(items) {
2 var len = items.length,
3 min;
4
5 for (var i=0; i < len; i++){
6 // set minimum to this position
7 min = i;
8 //check the rest of the array to see if anything is smaller
9 for (j=i+1; j < len; j++){
10 if (items[j] < items[min]){
11 min = j;
12 }
13 }
14 //if the minimum isn't in the position, swap it
15 if (i != min){
16 swap(items, i, min);
17 }
18 }
19
20 return items;
21 }
22 selectionSort([6,1,23,4,2,3]); // [1, 2, 3, 4, 6, 23]
时间复杂度:O(n2
空间复杂度: O(1)
- 由于嵌套的 for 循环,选择排序的时间复杂度仍然是 O( n 2 )。
插入排序
插入排序的工作方式类似于选择排序,它按顺序搜索数组,并将未排序的项目移动到数组左侧已排序的子列表中。图 10-7 详细显示了这一过程。
图 10-7
插入排序
下面的代码实现了插入排序算法。外部的for
循环遍历数组索引,内部的for
循环将未排序的项目移动到数组左侧已排序的子列表中。
1 function insertionSort(items) {
2 var len = items.length, // number of items in the array
3 value, // the value currently being compared
4 i, // index into unsorted section
5 j; // index into sorted section
6
7 for (i=0; i < len; i++) {
8 // store the current value because it may shift later
9 value = items[i];
10
11 // Whenever the value in the sorted section is greater than the value
12 // in the unsorted section, shift all items in the sorted section
13 // over by one. This creates space in which to insert the value.
14
15 for (j=i-1; j > -1 && items[j] > value; j--) {
16 items[j+1] = items[j];
17 }
18 items[j+1] = value;
19 }
20 return items;
21 }
22 insertionSort([6,1,23,4,2,3]); // [1, 2, 3, 4, 6, 23]
时间复杂度:O(n2
空间复杂度: O(1)
同样,由于嵌套的for
循环,这种排序算法像冒泡和插入排序一样具有 O(n2 的二次时间复杂度。
快速分类
Quicksort 的工作原理是获得一个支点,并围绕它划分数组(一边是较大的元素,另一边是较小的元素),直到所有的元素都被排序。理想的支点是数组的中值,因为它将均匀地划分数组,但要计算未排序数组线性时间的中值。因此,通常通过取分区中第一个、中间和最后一个元素的中值来获得枢轴。这种排序是递归的,并使用分治法来打破二次复杂度障碍,并将时间复杂度降低到 O(nlog2(n))。然而,使用一个将所有东西都划分到一边的枢纽,时间复杂度更差:O(n2)。
图 10-8 非常详细地显示了快速排序过程的划分步骤。
图 10-8
快速分类
以下代码显示了快速排序算法的实现:
1 function quickSort(items) {
2 return quickSortHelper(items, 0, items.length-1);
3 }
4
5 function quickSortHelper(items, left, right) {
6 var index;
7 if (items.length > 1) {
8 index = partition(items, left, right);
9
10 if (left < index - 1) {
11 quickSortHelper(items, left, index - 1);
12 }
13
14 if (index < right) {
15 quickSortHelper(items, index, right);
16 }
17 }
18 return items;
19 }
20
21 function partition(array, left, right) {
22 var pivot = array[Math.floor((right + left) / 2)];
23 while (left <= right) {
24 while (pivot > array[left]) {
25 left++;
26 }
27 while (pivot < array[right]) {
28 right--;
29 }
30 if (left <= right) {
31 var temp = array[left];
32 array[left] = array[right];
33 array[right]= temp;
34 left++;
35 right--;
36 }
37 }
38 return left;
39 }
40
41 quickSort([6,1,23,4,2,3]); // [1, 2, 3, 4, 6, 23]
时间复杂度:平均 O(nlog2(n))最坏情况 O(n 2 )
空间复杂度:O(log2(n))
快速排序算法的一个缺点是,如果总是选择一个不好的支点,它可能是 O( n 2 )。一个坏的枢纽是它没有均匀地划分阵列。理想的支点是数组的中间元素。此外,由于递归中的调用栈,快速排序算法比其他排序算法需要更大的空间复杂度 O(log2(n))。
当平均性能应该是最佳时,使用快速排序算法。这与快速排序更适合 RAM 缓存这一事实有关。
快速选择
Quickselect 是一种在无序列表中寻找第 k 个最小元素的选择算法。快速选择使用与快速排序算法相同的方法。选择一个轴心,并对数组进行分区。然而,它不是像 quicksort 那样递归两边,而是只递归元素的一边。这样就把复杂度从 O(nlog2(n))降低到 O( n )。
Quickselect 在以下代码中实现:
1 var array = [1,3,3,-2,3,14,7,8,1,2,2];
2 // sorted form: [-2, 1, 1, 2, 2, 3, 3, 3, 7, 8, 14]
3
4 function quickSelectInPlace(A, l, h, k){
5 var p = partition(A, l, h);
6 if(p==(k-1)) {
7 return A[p];
8 } else if(p>(k-1)) {
9 return quickSelectInPlace(A, l, p - 1,k);
10 } else {
11 return quickSelectInPlace(A, p + 1, h,k);
12 }
13 }
14
15 function medianQuickselect(array) {
16 return quickSelectInPlace(array,0,array.length-1, Math.floor(array.length/2));
17 }
18
19 quickSelectInPlace(array,0,array.length-1,5); // 2
20 // 2 - because it's the fifth smallest element
21 quickSelectInPlace(array,0,array.length-1,10); // 7
22 // 7 - because it's the tenth smallest element
时间复杂度: O( n
合并分类
Mergesort 的工作原理是将数组分成子数组,直到每个数组都有一个元素。然后,每个子阵列按照排序顺序串接(合并)(见图 10-9 )。
图 10-9
合并分类
merge
函数应该将两个数组中的所有元素按照排序后的顺序加到一个“结果数组”中为此,可以创建每个数组的索引来跟踪已经比较过的元素。一旦一个数组用完了它的所有元素,剩下的元素可以追加到结果数组中。
1 function merge(leftA, rightA){
2 var results= [], leftIndex= 0, rightIndex= 0;
3
4 while (leftIndex < leftA.length && rightIndex < rightA.length) {
5 if( leftA[leftIndex]<rightA[rightIndex] ){
6 results.push(leftA[leftIndex++]);
7 } else {
8 results.push(rightA[rightIndex++]);
9 }
10 }
11 var leftRemains = leftA.slice(leftIndex),
12 rightRemains = rightA.slice(rightIndex);
13
14 // add remaining to resultant array
15 return results.concat(leftRemains).concat(rightRemains);
16 }
merging 函数的工作原理是获取两个数组(左和右)并将它们合并成一个结果数组。为了保持顺序,在合并元素时需要对它们进行比较。
现在,mergeSort
函数必须将较大的数组分成两个独立的数组,并递归调用merge
。
1 function mergeSort(array) {
2
3 if(array.length<2){
4 return array; // Base case: array is now sorted since it's just 1 element
5 }
6
7 var midpoint = Math.floor((array.length)/2),
8 leftArray = array.slice(0, midpoint),
9 rightArray = array.slice(midpoint);
10
11 return merge(mergeSort(leftArray), mergeSort(rightArray));
12 }
13 mergeSort([6,1,23,4,2,3]); // [1, 2, 3, 4, 6, 23]
时间复杂度:O(nlog2(n))
空间复杂度: O( n
Mergesort 具有 O( n )的大空间复杂度,这是因为需要创建 n 个待合并的数组。当需要稳定排序时,使用 mergesort。稳定排序保证不会对具有相同键的元素进行重新排序。Mergesort 保证是 O(nlog2(n))。mergesort 的一个缺点是它在空间中使用 O( n )。
计数排序
计数排序可以在 O( k+n )中完成,因为它不比较值。它只对数字和给定的一定范围有效。这种计数不是通过交换元素来排序,而是通过计算数组中每个元素的出现次数来进行。一旦统计了每个元素的出现次数,就可以使用这些出现次数创建新数组。这样就可以对数据进行排序,而不必交换元素,如图 10-10 所示。
图 10-10
计数排序
下面是一个使用 JavaScript 对象的实现:
1 function countSort(array) {
2 var hash = {}, countArr= [];
3 for(var i=0;i<array.length;i++){
4 if(!hash[array[i]]){
5 hash[array[i]] = 1;
6 }else{
7 hash[array[i]]++;
8 }
9 }
10
11 for(var key in hash){
12 // for any number of _ element, add it to array
13 for(var i=0;i<hash[key];i++) {
14 countArr.push(parseInt(key));
15 }
16 }
17
18 return countArr;
19 }
20 countSort([6,1,23,2,3,2,1,2,2,3,3,1,123,123,4,2,3]); // [1, 2, 3, 4, 6, 23]
时间复杂度: O( k + n
空间复杂度: O( k
当对有限范围内的整数进行排序时,请使用计数排序。这将是这种情况下最快的排序。
JavaScript 的内置排序
JavaScript 有一个内置的用于数组对象的sort()
方法,它按照升序对元素进行排序。要使用它,有一个可选参数,您可以在比较器函数中传递它。
然而,默认的比较器函数是按字母顺序排序的,所以它不适用于数字。
1 var array1 = [12,3,4,2,1,34,23];
2 array1.sort(); // array1: [1, 12, 2, 23, 3, 34, 4]
在前面的例子中,请注意以 1 开头的数字先出现(1,12),然后是以 2 开头的数字,依此类推。这是因为没有传递比较器函数,JavaScript 将元素转换成字符串并根据字母表排序。
要正确排序数字,请使用以下命令:
1 var array1 = [12,3,4,2,1,34,23];
2
3 function comparatorNumber(a,b) {
4 return a-b;
5 }
6
7 array1.sort(comparatorNumber);
8 // array1: [1, 2, 3, 4, 12, 23, 34]
a-b
表示应该从最小到最大(升序)。降序可以按如下方式进行:
1 var array1 = [12,3,4,2,1,34,23];
2
3 function comparatorNumber(a,b) {
4 return b-a;
5 }
6
7 array1.sort(comparatorNumber); // array1: [34, 23, 12, 4, 3, 2, 1]
当您需要一种快速的方法来对某样东西进行排序而不需要自己实现时,sort()
函数会很有用。
摘要
有两种方法来搜索数组内部:线性搜索和二分搜索法。二分搜索法以 O(log2(n))的时间复杂度更快,而线性搜索具有 O( n )的时间复杂度。但是,二分搜索法只能在已排序的数组上执行。
表 10-1 总结了不同排序算法的时间和空间复杂度。最有效的排序算法是快速排序、合并排序和计数排序。计数排序虽然速度最快,但仅限于数组值的范围已知的情况。
表 10-1
排序摘要
|算法
|
时间复杂度
|
空间复杂性
|
| --- | --- | --- |
| 快速分类 | o(nlog2(n)) | o(nlog2(n)) |
| 合并分类 | o(nlog2(n)) | o(nlog2(n)) |
| 冒泡排序 | o(n2 | o(n2 |
| 插入排序 | o(n2 | o(n2 |
| 选择排序 | o(n2 | o(n2 |
| 计数排序 | O( k + n ) | O( k ) |
练习
对整数使用平方根函数,而不使用任何数学库
想到的第一个解决方案是尝试从 1 到数字的每一种可能性,如下所示:
1 function sqrtIntNaive(number){
2 if(number == 0 || number == 1)
3 return number;
4
5 var index = 1, square = 1;
6
7 while(square < number){
8 if (square == number){
9 return square;
10 }
11
12 index++;
13 square = index*index;
14 }
15 return index;
16 }
17 sqrtIntNaive(9);
时间复杂度: O( n
这本质上是一种线性搜索,因为它必须一个接一个地线性检查平方根的值。
二分搜索法算法可以应用于这个问题。不要一个接一个地增加,而是将范围分成介于 1 和给定数字之间的上半部分和下半部分,如下所示:
1 function sqrtInt(number) {
2 if(number == 0 || number == 1) return number;
3
4 var start = 1, end = number, ans;
5
6 while(start <= end) {
7 let mid = parseInt((start+end)/2);
8
9 if (mid*mid == number)
10 return mid;
11
12 if(mid*mid<number){
13 start = mid+1; // use the upper section
14 ans = mid;
15 }else{
16 end = mid-1; // use the lower section
17 }
18 }
19 return ans;
20 }
21 sqrtInt(9);
时间复杂度:O(log2(n))
Bonus: Find a Square Root of a Float
对于本练习,唯一的区别是使用阈值来计算精度,因为 double 的平方根有小数。因此,时间复杂度也保持不变。
1 function sqrtDouble(number) {
2 var threshold = 0.1;
3 //9 try middle,
4 var upper = number;
5 var lower = 0;
6 var middle;
7 while(upper-lower>threshold){
8 middle = (upper+lower)/2;
9 if(middle*middle>number){
10 upper = middle;
11 }else{
12 lower = middle;
13 }
14 }
15 return middle
16 }
17 sqrtDouble(9); // 3.0234375
查找数组中的两个元素相加是否为给定的数字
解决这个问题的简单方法是对数组中的每个元素每隔一个元素进行检查。
1 function findTwoSum(array, sum) {
2
3 for(var i=0, arrayLength = array.length; i<arrayLength;i++){
4 for(var j=i+1;j<arrayLength;j++){
5 if(array[j]+array[i] == sum){
6 return true;
7 }
8 }
9 }
10 return false;
11 }
时间复杂度:O(n2
空间复杂度: O(1)
有许多检查,因此需要二次时间。
一个更好的方法是存储已经访问过的号码并对照它们进行检查。这样,可以在线性时间内完成。
1 function findTwoSum(array, sum){
2 var store = {};
3
4 for(var i=0, arrayLength = array.length; i<arrayLength;i++){
5 if(store[array[i]]){
6 return true;
7 }else{
8 store[sum-array[i]] = array[i];
9 }
10 }
11 return false;
12 }
时间复杂度: O( n
空间复杂度: O( n
该算法将时间复杂度降低到 O( n ),但是将项目存储到store
对象中也需要 O( n )的空间。
在数组中查找只出现一次的元素
给定一个排序数组,其中所有元素出现两次(一个接一个),一个元素只出现一次,在 O(log2n复杂度中找到那个元素。这可以通过修改二分搜索法算法和检查加法指数来完成。
Input: arr = [1, 1, 3, 3, 4, 5, 5, 7, 7, 8, 8] Output: 4
Input: arr = [1, 1, 3, 3, 4, 4, 5, 5, 7, 7, 8] Output: 8
1 function findOnlyOnce(arr, low, high) {
2 if (low > high) {
3 return null;
4 }
5 if (low == high) {
6 return arr[low];
7 }
8
9 var mid = Math.floor((high+low)/2);
10
11 if (mid%2 == 0) {
12 if (arr[mid] == arr[mid+1]) {
13 return findOnlyOnce(arr, mid+2, high);
14 } else {
15 return findOnlyOnce(arr, low, mid);
16 }
17 } else {
18 if (arr[mid] == arr[mid-1]) {
19 return findOnlyOnce(arr, mid+1, high);
20 } else {
21 return findOnlyOnce(arr, low, mid-1);
22 }
23 }
24 }
25 function findOnlyOnceHelper(arr) {
26 return findOnlyOnce(arr, 0, arr.length);
27 }
28 findOnlyOnceHelper([ 1, 1, 2, 4, 4, 5, 5, 6, 6 ]);
时间复杂度:O(log2n
空间复杂度: O(1)
创建一个 JAVASCRIPT 排序比较器函数,根据长度对字符串进行排序
这相当简单。如果是字符串数组,字符串都有一个属性length
,可以用来对数组进行排序。
1 var mythical = ['dragon', 'slayer','magic','wizard of oz', 'ned stark'];
2
3 function sortComparator(a,b){
4 return a.length - b.length;
5 }
6 mythical.sort(sortComparator);
7 // ["magic", "dragon", "slayer", "ned stark", "wizard of of"]
Examples
对字符串元素进行排序,首先放置带有a
的字符串,如下所示:
1 var mythical = ['dragon', 'slayer','magic','wizard of oz', 'ned tark'];
2
3 function sortComparator(a,b){
4 return a.indexOf("a") - b.indexOf("a");
5 }
6
7 mythical.sort(sortComparator);
8 // ["magic", "dragon", "slayer", "wizard of oz", "ned stark"]
按属性的数量对对象元素进行排序,如下所示:
1 var mythical=[{prop1:", prop2:"},{prop1:", prop2:", prop3:"},{prop1:", prop2:"}];
2
3 function sortComparator(a,b){
4 return Object.keys(a).length - Object.keys(b).length;
5 }
6
7 mythical.sort(sortComparator);
// [{prop1:", prop2:"},{prop1:", prop2:"},{prop1:", prop2:", prop3:"}]
如图所示,这些比较器非常灵活,可以用于排序,而不需要自己实现排序。
实现单词计数列表
创建一个函数,该函数生成一个单词对象(作为键)和单词在一个字符串中出现的次数,按出现次数从高到低排序。
这里有一些输入的例子:熟能生巧。通过练习变得完美。练好就行了。
下面是示例输出:{ practice: 3, perfect: 2, makes: 1, get: 1, by: 1, just: 1 }
。
1 function wordCount(sentence) {
2 // period with nothing so it doesn't count as word
3 var wordsArray = sentence.replace(/[.]/g,"").split(" "),
4 occurenceList = {}, answerList = {};
5
6 for (var i=0, wordsLength=wordsArray.length; i<wordsLength; i++) {
7 var currentWord = wordsArray[i];
8 // doesn't exist, set as 1st occurrence
9 if (!occurenceList[currentWord]) {
10 occurenceList[currentWord] = 1;
11 } else {
12 occurenceList[currentWord]++; // add occurrences
13 }
14 }
15
16 var arrayTemp = [];
17 // push the value and key as fixed array
18 for (var prop in occurenceList) {
19 arrayTemp.push([occurenceList[prop], prop]);
20 }
21
22 function sortcomp(a, b) {
23 return b[0] - a[0]; // compare the first element of the array
24 }
25
26 arrayTemp.sort(sortcomp); //sort
27
28 for (var i = 0, arrlength = arrayTemp.length; i < arrlength; i++) {
29 var current = arrayTemp[i];
30 answerList[current[1]] = current[0]; // key value pairs
31 }
32 return answerList;
33 }
34 wordCount("practice makes perfect. get perfect by practice. just practice");
时间复杂度:O(nlog2(n))
空间复杂度: O( n
时间复杂度受到 JavaScript 引擎使用的排序算法的限制。大部分用的不是 mergesort 就是 quicksort,都是 O(nlog2(n))。
十一、哈希表
哈希表是一种固定大小的数据结构,其大小是在开始时定义的。本章通过重点介绍哈希(生成唯一键的方法)来解释哈希表是如何工作的。本章结束时,你将理解各种散列技术,并知道如何从头开始实现一个散列表。
哈希表简介
哈希表非常适合基于键值对快速存储和检索数据。在 JavaScript 中,JavaScript 对象通过定义一个键(属性)及其相关值来实现这种工作方式。图 11-1 显示了每个按键及其相关项目。
图 11-1
简单哈希表概述
哈希表包含两个主要函数:put()
和get()
。put()
用于将数据存储到哈希表中,而get()
用于从哈希表中检索数据。这两个函数的时间复杂度都是 O(1)。
简而言之,哈希表类似于一个数组,其索引是用哈希函数计算的,以唯一地标识内存中的空间。
localStorage
是基于散列表的数据结构的例子。它是所有主流浏览器都支持的原生 JavaScript 对象。它允许开发人员将数据保存在浏览器中,这意味着可以在会话后访问这些数据。
1 localStorage.setItem("testKey","testValue");
2 location = location; // refreshes the page
3
4 //-----------------------------------
5 localStorage.getItem("testKey"); // prints "testValue"
哈希技术
哈希表最重要的部分是哈希函数。hash 函数将指定的键转换为存储所有数据的数组的索引。一个好的散列函数的三个主要要求如下:
-
确定性:相等的键产生相等的哈希值。
-
效率:在时间上应该是 O(1)。
-
均匀分布:最大限度利用数组。
第一种散列技术是使用质数。通过对素数使用模数运算符,可以保证索引的均匀分布。
素数散列法
质数在散列法中很重要。这是因为使用质数的模数除法以分布式方式产生数组索引。
Modulus number: 11
4 % 11 = 4
7 % 11 = 7
9 % 11 = 9
15 % 11 = 4
可以看到 15 和 4 产生相同密钥的冲突;本章稍后将讨论如何处理这种冲突。这里重要的是素数的模保证了固定大小的最佳分布。小的非质数(如 4)的模数只能保证从 0 到 3 的范围,并且会导致大量的冲突。
Modulus number: 4
6 % 4 = 2
10 % 4 = 2
这是我们将观察到的第一种散列技术。看一下图 11-2 ,这是一个散列表,有两个大小为 11 的数组,11 个元素都是空的。一个数组用于键,另一个用于值。
图 11-2
大小为 11 的哈希表,所有元素都为空
在这个例子中,键是整数,字符串被存储为键。让我们散列下面的键值对:
{key:7, value: "hi"}
{key:24, value: "hello"}
{key:42, value: "sunny"}
{key:34, value: "weather"}
Prime number: 11
7 % 11 = 7
24 % 11 = 2
42 % 11 = 9
34 % 11 = 1
在插入所有的键值对之后,产生的散列表如图 11-3 所示。
图 11-3
插入值对后的哈希表
现在我们散列{key:18,value:“wow”}。
Prime number: 11
18 % 11 = 7
这是一个问题,因为 7 已经存在于 7 的索引中,并会导致索引冲突。有了完美的散列函数,就不会有冲突。然而,在大多数情况下,无冲突哈希几乎是不可能的。因此,哈希表需要处理冲突的策略。
探索
为了解决发生的冲突,探测散列技术在数组中查找下一个可用的索引。线性探测技术通过增量试验寻找下一个可用索引来解决冲突,而二次探测使用二次函数来生成增量试验。
线性探测
线性探测通过一次递增一个索引来寻找下一个可用的索引。例如,在 18 和 7 散列到同一个键的情况下,18 将被散列到键 8 中,因为那是下一个空位(参见图 11-4 )。
图 11-4
使用线性探测后的哈希表 1
然而,现在当使用get(key)
函数时,它必须从原始散列结果(7)开始,然后迭代直到找到 18。
线性探测的主要缺点是它容易创建簇,这是不好的,因为它们创建了更多的数据来迭代。
二次探测
二次探测是解决集群问题的好方法。二次探测使用完美的平方,而不是每次递增 1,这有助于在可用索引中均匀分布,如图 11-5 所示。
图 11-5
线性探测(顶部)和二次探测(底部)
h + (1)², h + (2)², h + (3)², h + (4)²
h + 1, h + 4, h + 9, h + 16
重新散列/双重散列
另一个统一分配密钥的好方法是使用第二个散列函数,对原始结果进行散列。这是良好的第二散列函数的三个主要要求:
-
不同:需要不同才能更好的分配。
-
效率:时间上应该还是 O(1)。
-
非零:永远不应该评估为零。零表示初始哈希值。
常用的第二种散列函数如下:
- 哈希 2(x) = R − (x % R)
这里, x 是第一次哈希的结果, R 小于哈希表的大小。每个哈希冲突通过以下方式解决,其中 i 是迭代试验次数:
- 我 * 【散列】**(x**
哈希表实现
既然已经解释了哈希表,让我们从头实现一个。在本节中,您将对同一个示例应用三种不同的技术。下面是将要使用的键-值对示例:
-
7、“嗨”
-
20、“你好”
-
33、《阳光灿烂》
-
46、《天气》
-
59、“哇”
-
72、《四十》
-
85、“快乐”
-
98、《伤心》
使用线性探测
让我们从简单的线性探测开始这个例子。
1 function HashTable(size) {
2 this.size = size;
3 this.keys = this.initArray(size);
4 this.values = this.initArray(size);
5 this.limit = 0;
6 }
7
8 HashTable.prototype.put = function(key, value) {
9 if (this.limit >= this.size) throw 'hash table is full'
10
11 var hashedIndex = this.hash(key);
12
13 // Linear probing
14 while (this.keys[hashedIndex] != null) {
15 hashedIndex++;
16
17 hashedIndex = hashedIndex % this.size;
18
19 }
20
21 this.keys[hashedIndex] = key;
22 this.values[hashedIndex] = value;
23 this.limit++;
24 }
25
26 HashTable.prototype.get = function(key) {
27 var hashedIndex = this.hash(key);
28
29 while (this.keys[hashedIndex] != key) {
30 hashedIndex++;
31
32 hashedIndex = hashedIndex % this.size;
33
34 }
35 return this.values[hashedIndex];
36 }
37
38 HashTable.prototype.hash = function(key) {
39 // Check if int
40 if (!Number.isInteger(key)) throw 'must be int';
41 return key % this.size;
42 }
43
44 HashTable.prototype.initArray = function(size) {
45 var array = [];
46 for (var i = 0; i < size; i++) {
47 array.push(null);
48 }
49 return array;
50 }
51
52 var exampletable = new HashTable(13);
53 exampletable.put(7, "hi");
54 exampletable.put(20, "hello");
55 exampletable.put(33, "sunny");
56 exampletable.put(46, "weather");
57 exampletable.put(59, "wow");
58 exampletable.put(72, "forty");
59 exampletable.put(85, "happy");
60 exampletable.put(98, "sad");
结果如下:
Keys:
[ 85, 98, null, null, null, null, null, 7, 20, 33, 46, 59, 72 ]
Values:
[ 'happy', 'sad', null, null, null, null, null, 'hi', 'hello', 'sunny', 'weather', 'wow', 'forty' ]
使用二次探测
现在,让我们将put()
和get()
方法改为使用二次探测。
1 HashTable.prototype.put = function (key, value) {
2 if (this.limit >= this.size) throw 'hash table is full'
3
4 var hashedIndex = this.hash(key), squareIndex = 1;
5
6 // quadratic probing
7 while (this.keys[hashedIndex] != null) {
8 hashedIndex += Math.pow(squareIndex,2);
9
10 hashedIndex
11 squareIndex++;
12 }
13
14 this.keys[hashedIndex] = key;
15 this.values[hashedIndex] = value;
16 this.limit++;
17 }
18
19 HashTable.prototype.get = function (key) {
20 var hashedIndex = this.hash(key), squareIndex = 1;
21
22 while ( this.keys[hashedIndex] != key ) {
23 hashedIndex += Math.pow(squareIndex, 2);
24
25 hashedIndex = hashedIndex % this.size;
26 squareIndex++;
27 }
28
29 return this.values[hashedIndex];
30 }
结果如下:
Keys:
[ null, null, null, 85, 72, null, 98, 7, 20, null, 59, 46, 33 ]
Values:
[ null, null, null, 'happy', 'forty', null, 'sad', 'hi', 'hello', null, 'wow', 'weather', 'sunny' ]
该结果比线性探测的结果分布更均匀。更大的数组和更多的元素会更容易看到。
使用带有线性探测的双重散列
最后,让我们结合双重散列和线性探测。回想一下常见的第二个哈希函数,hash2(x)=R—(x % R),其中 x 是第一次哈希的结果, R 小于哈希表的大小。
1 HashTable.prototype.put = function(key, value) {
2 if (this.limit >= this.size) throw 'hash table is full'
3
4 var hashedIndex = this.hash(key);
5
6 while (this.keys[hashedIndex] != null) {
7 hashedIndex++;
8
9 hashedIndex = hashedIndex % this.size;
10
11 }
12 this.keys[hashedIndex] = key;
13 this.values[hashedIndex] = value;
14 this.limit++;
15 }
16
17 HashTable.prototype.get = function(key) {
18 var hashedIndex = this.hash(key);
19
20 while (this.keys[hashedIndex] != key) {
21 hashedIndex++;
22
23 hashedIndex = hashedIndex % this.size;
24
25 }
26 return this.values[hashedIndex];
27 }
28
29 HashTable.prototype.hash = function(key) {
30 if (!Number.isInteger(key)) throw 'must be int'; // check if int
31 return this.secondHash(key % this.size);
32 }
33
34 HashTable.prototype.secondHash = function(hashedKey) {
35 var R = this.size - 2;
36 return R - hashedKey % R;
37 }
结果如下:
Keys:
[ null, 59, 20, 85, 98, 72, null, 7, null, 46, null, 33, null ]
Values:
[ null, 'wow', 'hello', 'happy', 'sad', 'forty', null, 'hi', null, 'weather', null, 'sunny', null ]
同样,与线性探测的结果相比,双重散列产生更均匀分布的阵列。二次探测和双重散列都是减少哈希表中冲突数量的很好的技术。有比这些技术更高级的冲突解决算法,但是它们超出了本书的范围。
摘要
哈希表是一种固定大小的数据结构,其大小是在开始时定义的。哈希表是使用哈希函数为数组生成索引来实现的。一个好的散列函数是确定的、高效的和均匀分布的。使用一个好的均匀分布的散列函数应该可以最小化散列冲突,但是有些冲突是不可避免的。哈希冲突处理技术包括但不限于线性探测(将索引递增 1)、二次探测(使用二次函数来递增索引)和双重哈希(使用多个哈希函数)。
下一章探索栈和队列,它们是动态调整大小的数据结构。
十二、栈和队列
本章介绍栈和队列;两者都是通用的数据结构,通常用于实现其他更复杂的数据结构。您将了解什么是栈和队列,如何以及何时使用它们,以及如何实现它们。最后,练习将帮助你理解这些概念,以及什么时候应用栈和队列来解决算法问题。
大量
一个栈是一个数据结构,其中只有最后插入的元素可以被移除和访问(见图 12-1 )。想象一下把盘子堆在桌子上。要到达底部的一个,你必须移除顶部的所有其他的。这就是所谓的后进先出 (LIFO)原则。栈很棒,因为它很快。因为已知最后一个元素将被移除,所以查找和插入发生在常数时间 O(1)内。当您需要处理 LIFO 形式的数据时,应该使用栈而不是数组,在这种情况下,算法只需要访问最后添加的元素。栈的限制是它们不能像数组那样直接访问非最后添加的元素;此外,访问更深层次的元素需要从数据结构中删除元素。
图 12-1
栈,后进先出
在 JavaScript 中,数组有定义栈类的方法:pop
和push
(如第五章所讨论的)。这样,可以很容易地实现栈。
下面是一些基本代码。你可以在 GitHub 上找到代码。 1
1 function Stack(array){
2 this.array = [];
3 if(array) this.array = array;
4 }
5
6 Stack.prototype.getBuffer = function(){
7 return this.array.slice();
8 }
9
10 Stack.prototype.isEmpty = function(){
11 return this.array.length == 0;
12 }
13
14 //instance of the stack class
15 var stack1 = new Stack();
16
17 console.log(stack1); // {array: []}
让我们首先考虑“偷看”最近添加的元素。这可以简单地通过使用数组的最大索引来完成。
偷看
窥视栈最后添加的元素意味着返回最后添加的元素,而不从数据结构中移除它。扫视通常用于将最后添加的元素与其他变量进行比较,并评估最后添加的元素是否应该从数据结构中删除。
1 Stack.prototype.peek = function(){
2 return this.array[this.array.length-1];
3 }
4 stack1.push(10);
5 console.log(stack1.peek()); // 10
6 stack1.push(5);
7 console.log(stack1.peek()); // 5
时间复杂度: O(1)
插入
插入栈可以通过 JavaScript 数组本身支持的push
函数来完成。
1 Stack.prototype.push = function(value){
2 this.array.push(value);
3 }
4
5 stack1.push(1);
6 stack1.push(2);
7 stack1.push(3);
8 console.log(stack1); // {array: [1,2,3]}
时间复杂度: O(1)
删除
删除也可以使用本地 JavaScript 数组方法实现,称为pop
。
1 Stack.prototype.pop = function() {
2 return this.array.pop();
3 };
4
5 stack1.pop(1);
6 stack1.pop(2);
7 stack1.pop(3);
8
9 console.log(stack1); // {array: []}
时间复杂度: O(1)
接近
访问数据结构中的特定元素非常重要。这里,让我们看看如何根据顺序访问元素。
要从顶部访问第 n 个节点,需要调用pop
n 次。
1 function stackAccessNthTopNode(stack, n){
2 var bufferArray = stack.getBuffer();
3 if(n<=0) throw 'error'
4
5 var bufferStack = new Stack(bufferArray);
6
7 while(--n!==0){
8 bufferStack.pop();
9 }
10 return bufferStack.pop();
11 }
12
13 var stack2 = new Stack();
14 stack2.push(1);
15 stack2.push(2);
16 stack2.push(3);
17 stackAccessNthTopNode(stack2,2); // 2
时间复杂度: O( n
搜索将以类似的方式实现。
搜索
在栈数据结构中搜索特定元素是一项非常关键的操作。为此,您必须首先创建一个缓冲栈,以便可以在该缓冲栈上调用pop
。这样,原始栈不会发生变化,也不会从中删除任何内容。
1 function stackSearch(stack, element) {
2 var bufferArray = stack.getBuffer();
3
4 var bufferStack = new Stack(bufferArray); // copy into buffer
5
6 while(!bufferStack.isEmpty()){
7 if(bufferStack.pop()==element){
8 return true;
9 }
10 }
11 return false;
12 }
时间复杂度: O( n
行列
队列也是一种数据结构,但是您只能删除第一个添加的元素(参见图 12-2 )。这是一个被称为先进先出 (FIFO)的原则。队列之所以伟大,还因为它的操作时间是恒定的。与栈类似,它也有局限性,因为一次只能访问一项。当您需要处理 FIFO 形式的数据时,应该使用队列而不是数组,在 FIFO 形式中,算法只需要访问第一个添加的元素。
图 12-2
伫列,FIFO
在 JavaScript 中,数组有定义队列类的方法:shift()
和push()
(如第五章所述)。回想一下 JavaScript 中数组的shift()
方法移除并返回数组的第一个元素。添加到队列中通常称为入队,从队列中移除通常称为出队。shift()
可用于出列,和。push()
可用于入队。
下面是一些基本代码。你可以在 GitHub 上找到代码。 2
1 function Queue(array){
2 this.array = [];
3 if(array) this.array = array;
4 }
5
6 Queue.prototype.getBuffer = function(){
7 return this.array.slice();
8 }
9
10 Queue.prototype.isEmpty = function(){
11 return this.array.length == 0;
12 }
13
14 //instance of the queue class
15 var queue1 = new Queue();
16
17 console.log(queue1); // { array: [] }
偷看
peek
函数查看第一个项目,而不将它从队列中弹出。在栈实现中,返回数组中的最后一个元素,但是由于 FIFO 的原因,队列返回数组中的第一个元素。
1 Queue.prototype.peek = function(){
2 return this.array[0];
3 }
插入
如上所述,队列的插入被称为入队。由于使用数组来保存栈数据,因此可以使用push()
方法来实现enqueue
。
1 Queue.prototype.enqueue = function(value){
2 return this.array.push(value);
3 }
时间复杂度: O(1)
删除
如上所述,队列的删除也被称为出列。因为数组用于保存栈数据,所以可以使用shift()
方法移除并返回队列中的第一个元素。
1 Queue.prototype.dequeue = function() {
2 return this.array.shift();
3 };
4
5 var queue1 = new Queue();
6
7 queue1.enqueue(1);
8 queue1.enqueue(2);
9 queue1.enqueue(3);
10
11 console.log(queue1); // {array: [1,2,3]}
12
13 queue1.dequeue();
14 console.log(queue1); // {array: [2,3]}
15
16 queue1.dequeue();
17 console.log(queue1); // {array: [3]}
时间复杂度: O(n)
因为shift()
实现移除了零索引处的元素,然后连续向下移动剩余的索引,所以数组中的所有其他元素都需要改变它们的索引,这需要 O( n )。如第十三章所述,对于链表实现,这可以简化为 O(1)。
接近
与数组不同,队列中的项不能通过索引来访问。要访问最后添加的第 n 个节点,需要调用dequeue
n 次。需要一个缓冲区来防止对原始队列的修改。
1 function queueAccessNthTopNode(queue, n){
2 var bufferArray = queue.getBuffer();
3 if(n<=0) throw 'error'
4
5 var bufferQueue = new Queue(bufferArray);
6
7 while(--n!==0){
8 bufferQueue.dequeue();
9 }
10 return bufferQueue.dequeue();
11 }
时间复杂度: O( n
搜索
您可能需要搜索队列来检查队列中是否存在某个元素。同样,这需要首先创建一个缓冲队列,以避免修改原始队列。
1 function queueSearch(queue, element){
2 var bufferArray = queue.getBuffer();
3
4 var bufferQueue = new Queue(bufferArray);
5
6 while(!bufferQueue.isEmpty()){
7 if(bufferQueue.dequeue()==element){
8 return true;
9 }
10 }
11 return false;
12 }
时间复杂度: O( n
摘要
栈和队列都支持 O(1)中的查看、插入和删除。栈和队列之间最重要的区别是栈是后进先出的,而队列是先进先出的。表 12-1 总结了时间复杂度。
表 12-1
队列和栈时间复杂度摘要
| |接近
|
搜索
|
偷看
|
插入
|
删除
|
| --- | --- | --- | --- | --- | --- |
| 长队 | O(n) | O(n) | O(1) | O(1) | o(n)3 |
| 堆 | O(n) | O(n) | O(1) | O(1) | O(1) |
练习
所有练习的代码都可以在 GitHub 上找到。 4
仅使用队列设计栈,然后仅使用栈设计队列
使用队列栈
一个队列可以由两个栈组成。队列是一种数据结构,它使用dequeue()
方法返回第一个添加的元素。栈是一种数据结构,它通过pop
返回最后添加的元素。换句话说,队列以与栈相反的方向移除元素。
例如,检查具有[1,2,3,4,5]的栈数组。
为了颠倒顺序,可以将所有的元素推到第二个栈上,并弹出第二个栈。因此,第二个栈数组将是这样的:[5,4,3,2,1]。
当这个被弹出时,最后一个元素被删除,即 1。所以,1 本来就是第一个元素。因此,只使用两个栈就实现了一个队列。
1 function TwoStackQueue(){
2 this.inbox = new Stack();
3 this.outbox= new Stack();
4 }
5
6 TwoStackQueue.prototype.enqueue = function(val) {
7 this.inbox.push(val);
8 }
9
10 TwoStackQueue.prototype.dequeue = function() {
11 if(this.outbox.isEmpty()){
12 while(!this.inbox.isEmpty()){
13 this.outbox.push(this.inbox.pop());
14 }
15 }
16 return this.outbox.pop();
17 };
18 var queue = new TwoStackQueue();
19 queue.enqueue(1);
20 queue.enqueue(2);
21 queue.enqueue(3);
22 queue.dequeue(); // 1
23 queue.dequeue(); // 2
24 queue.dequeue(); // 3
使用栈排队
栈可以由两个队列组成。栈是返回最后一个元素的数据结构。要使用队列实现这一点,只需将除最后一个元素之外的所有元素排入主队列。然后返回最后一个元素。
1 function QueueStack(){
2 this.inbox = new Queue(); // first stack
3 }
4
5 QueueStack.prototype.push = function(val) {
6 this.inbox.enqueue(val);
7 };
8
9 QueueStack.prototype.pop = function() {
10 var size = this.inbox.array.length-1;
11 var counter =0;
12 var bufferQueue = new Queue();
13
14 while(++counter<=size){
15 bufferQueue.enqueue(this.inbox.dequeue());
16 }
17 var popped = this.inbox.dequeue();
18 this.inbox = bufferQueue;
19 return popped
20 };
21
22 var stack = new QueueStack();
23
24 stack.push(1);
25 stack.push(2);
26 stack.push(3);
27 stack.push(4);
28 stack.push(5);
29
30 console.log(stack.pop()); // 5
31 console.log(stack.pop()); // 4
32 console.log(stack.pop()); // 3
33 console.log(stack.pop()); // 2
34 console.log(stack.pop()); // 1
设计一个收银员类,它接受一个客户对象,并根据先来先服务的原则处理食物订购
以下是要求:
-
收银员需要订单的客户名称和订单项目。
-
首先被服务的顾客首先被处理。
以下是必需的实现:
-
addOrder(customer)
:将deliverOrder()
处理的客户对象入队 -
deliverOrder()
:打印下一个待处理客户的名称和订单
对于这个练习,Cashier
类应该用一个队列将客户类对象入队,并在完成时将它们出队。
1 function Customer(name, order){
2 this.name = name;
3 this.order = order;
4 }
5
6 function Cashier(){
7 this.customers = new Queue();
8 }
9
10 Cashier.prototype.addOrder = function (customer){
11 this.customers.enqueue(customer);
12 }
13
14 Cashier.prototype.deliverOrder = function(){
15 var finishedCustomer = this.customers.dequeue();
16
17 console.log(finishedCustomer.name+", your "+finishedCustomer.order+" is ready!");
18 }
19
20 var cashier = new Cashier();
21 var customer1 = new Customer('Jim',"Fries");
22 var customer2 = new Customer('Sammie',"Burger");
23 var customer3 = new Customer('Peter',"Drink");
24
25 cashier.addOrder(customer1);
26 cashier.addOrder(customer2);
27 cashier.addOrder(customer3);
28
29 cashier.deliverOrder(); // Jim, your Fries is ready!
30 cashier.deliverOrder(); // Sammie, your Burger is ready!
31 cashier.deliverOrder(); // Peter, your Drink is ready!
使用栈设计括号验证检查器
((()))
是有效的括号集,而((()
和)))
不是。通过存储左括号和使用push
并在看到右括号时触发pop
,可以使用栈来检查括号的有效性。
如果之后栈中还有任何东西,那就不是有效的括号集。此外,如果右括号比左括号多,则不是有效的括号集。使用这些规则,使用栈来存储最近的括号。
1 function isParenthesisValid(validationString){
2 var stack = new Stack();
3 for(var pos=0;pos<validationString.length;pos++){
4 var currentChar = validationString.charAt(pos);
5 if(currentChar=="("){
6 stack.push(currentChar);
7 }else if(currentChar==")"){
8
9 if(stack.isEmpty())
10 return false;
11
12 stack.pop();
13 }
14 }
15 return stack.isEmpty();
16 }
17 isParenthesisValid("((()"); // false;
18 isParenthesisValid("(((("); // false;
19 isParenthesisValid("()()"); // true;
时间复杂度: O( n
该算法逐字符处理字符串。因此,它的时间复杂度是 O( n ),其中 n 是字符串的长度。
设计一个便携式栈
这个想法是有两个栈,一个是排序的,一个是非排序的。当排序时,从未排序的栈中弹出,当排序后的栈中任何较小(如果降序)或较大(如果升序)的数字在顶部时,排序后的栈元素应该移回未排序,因为它是无序的。运行一个循环,直到栈全部排序。
1 function sortableStack(size){
2 this.size = size;
3
4 this.mainStack = new Stack();
5 this.sortedStack = new Stack();
6
7 // let's initialize it with some random ints
8 for(var i=0;i<this.size;i++){
9 this.mainStack.push(Math.floor(Math.random()*11));
10 }
11 }
12
13 sortableStack.prototype.sortStackDescending = function(){
14 while(!this.mainStack.isEmpty()){
15 var temp = this.mainStack.pop();
16 while(!this.sortedStack.isEmpty() && this.sortedStack.peek()< temp){
17 this.mainStack.push(this.sortedStack.pop());
18 }
19 this.sortedStack.push(temp);
20 }
21 }
22
23 var ss = new sortableStack(10);
24 console.log(ss); // [ 8, 3, 4, 4, 1, 2, 0, 9, 7, 8 ]
25 ss.sortStackDescending();
26 console.log(ss.sortedStack); // [ 9, 8, 8, 7, 4, 4, 3, 2, 1, 0 ]
时间复杂度:O(n2
该算法涉及两个栈之间的元素的重排,这在最坏的情况下可能需要 O( n 2 ),其中 n 是要排序的元素的数量。
十三、链表
本章将介绍链表。链表是一种数据结构,其中每个节点指向另一个节点。与固定大小的数组不同,链表是一种动态数据结构,可以在运行时分配和释放内存。本章结束时,你将理解如何实现和使用链表。
本章讨论了两种类型的链表:单向和双向链表。我们先来考察一下单链表。
单链表
链表数据结构是每个节点(元素)都引用下一个节点(见图 13-1 )。
图 13-1
单向链表
单链表中的一个节点有以下属性:data
和next
。data
是链表节点的值,next
是指向SinglyLinkedListNode
的另一个实例的指针。
1 function SinglyLinkedListNode(data) {
2 this.data = data;
3 this.next = null;
4 }
以下代码是单向链表示例的基础。你可以在 GitHub 上找到代码。 1 代码块有一个 helper 函数,用来检查单链表是否为空。
1 function SinglyLinkedList(){
2 this.head = null;
3 this.size = 0;
4 }
5
6 SinglyLinkedList.prototype.isEmpty = function(){
7 return this.size == 0;
8 }
链表的开始被称为头。在向链表中插入任何元素之前,该属性默认为null
。
插入
下面的代码块演示如何插入到单链表中。如果链表的头为空,则将头设置为新节点。否则,旧堆保存在temp
中,新堆头成为新添加的节点。最后,新头的next
指向了temp
(旧头)。
1 SinglyLinkedList.prototype.insert = function(value) {
2 if (this.head === null) { //If first node
3 this.head = new SinglyLinkedListNode(value);
4 } else {
5 var temp = this.head;
6 this.head = new SinglyLinkedListNode(value);
7 this.head.next = temp;
8 }
9 this.size++;
10 }
11 var sll1 = new SinglyLinkedList();
12 sll1.insert(1); // linked list is now: 1 -> null
13 sll1.insert(12); // linked list is now: 12 -> 1 -> null
14 sll1.insert(20); // linked list is now: 20 -> 12 -> 1 -> null
时间复杂度: O( 1
这是一个恒定时间操作;不需要循环或遍历。
按值删除
单链表中节点的删除是通过移除该节点的引用来实现的。如果节点在链表的“中间”,这是通过让指向该节点的next
指针指向该节点自己的next
节点来实现的,如图 13-2 所示。
图 13-2
从单链表中删除内部节点
如果该节点位于链表的末尾,那么倒数第二个元素可以通过将其next
设置为null
来取消对该节点的引用。
1 SinglyLinkedList.prototype.remove = function(value) {
2 var currentHead = this.head;
3 if (currentHead.data == value) {
4 // just shift the head over. Head is now this new value
5 this.head = currentHead.next;
6 this.size--;
7 } else {
8 var prev = currentHead;
9 while (currentHead.next) {
10 if (currentHead.data == value) {
11 // remove by skipping
12 prev.next = currentHead.next;
13 prev = currentHead;
14 currentHead = currentHead.next;
15 break; // break out of the loop
16 }
17 prev = currentHead;
18 currentHead = currentHead.next;
19 }
20 //if wasn't found in the middle or head, must be tail
21 if (currentHead.data == value) {
22 prev.next = null;
23 }
24 this.size--;
25 }
26 }
27 var sll1 = new SinglyLinkedList();
28 sll1.insert(1); // linked list is now: 1 -> null
29 sll1.insert(12); // linked list is now: 12 -> 1 -> null
30 sll1.insert(20); // linked list is now: 20 -> 12 -> 1 -> null
31 sll1.remove(12); // linked list is now: 20 -> 1 -> null
32 sll1.remove(20); // linked list is now: 1 -> null
时间复杂度: O( n
在最坏的情况下,必须遍历整个链表。
开头删除
在 O(1)中删除链表头部的元素是可能的。当从头部删除一个节点时,不需要遍历。下面的代码块显示了这种删除的实现。这允许链表实现栈。最后添加的项目(到头部)可以在 O(1)中移除。
1 DoublyLinkedList.prototype.deleteAtHead = function() {
2 var toReturn = null;
3
4 if (this.head !== null) {
5 toReturn = this.head.data;
6
7 if (this.tail === this.head) {
8 this.head = null;
9 this.tail = null;
10 } else {
11 this.head = this.head.next;
12 this.head.prev = null;
13 }
14 }
15 this.size--;
16 return toReturn;
17 }
18 var sll1 = new SinglyLinkedList();
19 sll1.insert(1); // linked list is now: 1 -> null
20 sll1.insert(12); // linked list is now: 12 -> 1 -> null
21 sll1.insert(20); // linked list is now: 20 -> 12 -> 1 -> null
22 sll1.deleteAtHead(); // linked list is now: 12 -> 1 -> null
搜索
为了找出一个值是否存在于一个单链表中,需要简单的遍历所有的next
指针。
1 SinglyLinkedList.prototype.find = function(value) {
2 var currentHead = this.head;
3 while (currentHead.next) {
4 if (currentHead.data == value) {
5 return true;
6 }
7 currentHead = currentHead.next;
8 }
9 return false;
10 }
时间复杂度: O( n
像删除操作一样,在最坏的情况下,必须遍历整个链表。
双向链表
双向链表可以被认为是双向单向链表。双向链表中的每个节点都有一个next
指针和一个prev
指针。下面的代码块实现了双向链表节点:
1 function DoublyLinkedListNode(data) {
2 this.data = data;
3 this.next = null;
4 this.prev = null;
5 }
此外,双向链表有头指针和尾指针。头是指双向链表的开头,尾是指双向链表的结尾。这在下面的代码中实现,并带有一个帮助器函数来检查双向链表是否为空:
1 function DoublyLinkedList (){
2 this.head = null;
3 this.tail = null;
4 this.size = 0;
5 }
6 DoublyLinkedList.prototype.isEmpty = function(){
7 return this.size == 0;
8 }
双向链表中的每个节点都有next
和prev
属性。双向链表中的删除、插入和搜索实现类似于单向链表。然而,对于插入和删除,必须更新next
和prev
属性。图 13-3 显示了一个双向链表的例子。
图 13-3
具有五个节点的双向链表示例
在头部插入
插入双向链表的头部与插入单向链表是一样的,除了它还必须更新prev
指针。下面的代码块显示了如何插入到双向链表中。如果链表的头为空,则头和尾被设置为新的节点。这是因为当只有一个元素时,该元素既是头部也是尾部。否则,temp
变量用于存储新节点。新节点的next
指向当前头,然后当前头的prev
指向新节点。最后,头指针被更新到新节点。
1 DoublyLinkedList.prototype.addAtFront = function(value) {
2 if (this.head === null) { //If first node
3 this.head = new DoublyLinkedListNode(value);
4 this.tail = this.head;
5 } else {
7 var temp = new DoublyLinkedListNode(value);
8 temp.next = this.head;
9 this.head.prev = temp;
10 this.head = temp;
11 }
12 this.size++;
13 }
14 var dll1 = new DoublyLinkedList();
15 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
16 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
17 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
时间复杂度: O(1)
尾部插入
类似地,可以向双向链表的尾部添加一个新节点,如下面的代码块所示:
1 DoublyLinkedList.prototype.insertAtTail = function(value) {
2 if (this.tail === null) { //If first node
3 this.tail = new DoublyLinkedListNode(value);
4 this.head = this.tail;
5 } else {
6 var temp = new DoublyLinkedListNode(value);
7 temp.prev = this.tail;
8 this.tail.next = temp;
9 this.tail = temp;
10 }
11 this.size++;
12 }
13
14 var dll1 = new DoublyLinkedList();
15 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
16 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
17 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
18 dll1.insertAtTail(30); // ddl1's structure: tail: 30 head: 20
时间复杂度: O(1)
开头删除
从双向链表中移除头部的节点可以在 O(1)时间内完成。如果头尾相同的情况下只有一项,那么头尾都设置为null
。否则,头部被设置为头部的next
指针。最后,将新头的prev
设置为null
以移除旧头的引用。这在下面的代码块中实现。这很棒,因为它可以像队列数据结构中的dequeue
函数一样使用。
1 DoublyLinkedList.prototype.deleteAtHead = function() {
2 var toReturn = null;
3
4 if (this.head !== null) {
5 toReturn = this.head.data;
6
7 if (this.tail === this.head) {
8 this.head = null;
9 this.tail = null;
10 } else {
11 this.head = this.head.next;
12 this.head.prev = null;
13 }
14 }
15 this.size--;
16 return toReturn;
17 }
时间复杂度: O(1)
尾部删除
与移除头部节点类似,尾部节点可以在 O(1)时间内移除并返回,如下面的代码块所示。由于具有在尾部移除的能力,双向链表也可以被认为是一种双向队列数据结构。队列可以将第一个添加的项出队,但是双向链表可以在 O(1)时间内将尾部的项或头部的项出队。
1 DoublyLinkedList.prototype.deleteAtTail = function() {
2 var toReturn = null;
3
4 if (this.tail !== null) {
5 toReturn = this.tail.data;
6
7 if (this.tail === this.head) {
8 this.head = null;
9 this.tail = null;
10 } else {
11 this.tail = this.tail.prev;
12 this.tail.next = null;
13 }
14 }
15 this.size--;
16 return toReturn;
17 }
18 var dll1 = new DoublyLinkedList();
19 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
20 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
21 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
22 dll1.insertAtTail(30); // ddl1's structure: tail: 30 head: 20
23 dll1.deleteAtTail();
24 // ddl1's structure: tail: 10 head: 20
时间复杂度: O(1)
搜索
要找出一个值是否存在于双向链表中,可以从头部开始使用next
指针,或者从tail
开始使用prev
指针。以下代码块与单链表搜索实现相同,它从头部开始查找项:
1 DoublyLinkedList.prototype.findStartingHead = function(value) {
2 var currentHead = this.head;
3 while(currentHead.next){
4 if(currentHead.data == value){
5 return true;
6 }
7 currentHead = currentHead.next;
8 }
9 return false;
10 }
11 var dll1 = new DoublyLinkedList();
12 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
13 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
14 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
15 dll1.insertAtTail(30); // ddl1's structure: tail: 30 head: 20
16 dll1.findStartingHead(10); // true
17 dll1.findStartingHead(100); // false
时间复杂度: O( n
以下代码使用prev
指针从尾部开始遍历双向链表:
1 DoublyLinkedList.prototype.findStartingTail = function(value) {
2 var currentTail = this.tail;
3 while (currentTail.prev){
4 if(currentTail.data == value){
5 return true;
6 }
7 currentTail = currentTail.prev;
8 }
9 return false;
10 }
11
12 var dll1 = new DoublyLinkedList();
13 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
14 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
15 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
16 dll1.insertAtTail(30); // ddl1's structure: tail: 30 head: 20
17 dll1.findStartingTail(10); // true
18 dll1.findStartingTail(100); // false
时间复杂度: O( n
虽然搜索的时间复杂度与单链表的搜索相同,但是只有双向链表可以双向搜索(使用prev
或next
)。这意味着如果给定一个对双向链表节点的引用,双向链表可以执行完全搜索,但是单向链表仅限于它的next
指针。
摘要
链表数据结构的工作原理是,每个节点都有一个指向不同节点的下一个指针(以及前一个指针,如果是双重链接,则为prev
指针)。单向链表和双向链表的插入都具有恒定的时间复杂度 O(1)。从单链表和双向链表的头部删除的时间复杂度也是 O(1)。然而,在单向链表和双向链表中搜索一个条目需要 O( n )时间。当需要双向遍历/搜索时,双向链表应该比单向链表使用得多。此外,双向链表允许您从链表的尾部或头部弹出,以实现灵活快速的 O(1)运算。
练习
你可以在 GitHub 上找到所有练习的代码。 2
反转单向链表
要反转单向链表,只需遍历每个节点,并将当前节点的next
属性设置为前一个节点。
1 function reverseSingleLinkedList(sll){
2 var node = sll.head;
3 var prev = null;
4 while(node){
5 var temp = node.next;
6 node.next = prev;
7 prev = node;
8 if(!temp)
9 break;
10 node = temp;
11 }
12 return node;
13 }
时间复杂度: O( n
空间复杂度: O(1)
要完全反转一个链表,必须遍历链表的全部 N 个元素。
删除链表中的重复项
删除链表中的项目很简单。简单地在一个数组中迭代和存储访问过的节点。如果当前元素之前已经出现过,则删除当前元素。
1 // delete duplicates in unsorted linkedlist
2 function deleteDuplicateInUnsortedSll(sll1) {
3 var track = [];
4
5 var temp = sll1.head;
6 var prev = null;
7 while (temp) {
8 if (track.indexOf(temp.data) >= 0) {
9 prev.next = temp.next;
10 sll1.size--;
11 } else {
12 track.push(temp.data);
13 prev = temp;
14 }
15 temp = temp.next;
16 }
17 console.log(temp);
18 }
时间复杂度:O(n2
空间复杂度: O( n
但是,这个算法必须用.indexOf()
方法迭代数组,这是 O( n )以及迭代 n 次。因此在时间复杂度上是 O( n 2 )。另外,track
数组增长到了 N 的大小,这导致空间复杂度为 O( n )。让我们把时间复杂度降低到 O( n )。
1 //delete duplicates in unsorted linkedlist
2 function deleteDuplicateInUnsortedSllBest(sll1) {
3 var track = {};
4
5 var temp = sll1.head;
6 var prev = null;
7 while (temp) {
8 if (track[temp.data]) {
9 prev.next = temp.next;
10 sll1.size--;
11 } else {
12 track[temp.data] = true;
13 prev = temp;
14 }
15 temp = temp.next;
16 }
17 console.log(temp);
18 }
时间复杂度: O( n
空间复杂度: O( n
使用 JavaScript Object
作为哈希表来存储和检查可见的元素,将空间减少到 O( n )但是 O( n )因为哈希表需要额外的内存。
十四、缓存
缓存是将数据存储到临时存储器中的过程,以便日后再次需要时可以轻松检索。例如,数据库系统缓存数据以避免重新读取硬盘,web 浏览器缓存网页(图像和素材)以避免重新下载内容。简而言之,在缓存中,目标是最大化命中(请求时项目在缓存中)和最小化未命中(请求时项目不在缓存中)。
本章将讨论两种缓存技术:最少使用的(LFU)和最近最少使用的(LRU)缓存。
注意
缓存的概念来自操作系统领域。你可以在滑铁卢大学的杰夫·扎奈特的演讲中了解更多。
了解缓存
高速缓存设计通常考虑这两个因素:
-
时间局部性:最近被访问过的存储器位置很可能再次被访问。
-
空间位置:最近被访问过的存储器位置附近的存储器位置很可能再次被访问。
最佳缓存算法将能够用要插入的新元素来替换缓存中在将来最远处使用的部分。对于每个项目,这将需要计算该项目在未来将被访问多少次。你应该很清楚这是不可能实现的,因为它需要展望未来。
最不常用的缓存
最不频繁使用的**(LFU)缓存是操作系统用来管理内存的一种缓存算法。系统跟踪内存中块被引用的次数。根据设计,当缓存超过其限制时,系统会删除引用频率最低的项目。LFU 缓存最简单的实现是为加载到缓存中的每个块分配一个计数器,并在每次引用该块时递增一个计数器。当缓存超过其限制时,系统会搜索计数器最低的块,并将其从缓存中删除。
*尽管 LFU 缓存看起来是一种直观的方法,但当内存中的某个项被短期重复引用且不再被访问时,这种方法并不理想。由于其重复引用,该块的频率较高,但是这迫使系统删除在短时间块之外可能更频繁使用的其他块。此外,系统中的新项目很容易被快速删除,因为它们被访问的频率较低。由于这些问题,LFU 是不常见的,但一些混合系统利用核心 LFU 概念。这种系统的例子是移动键盘应用。建议的单词出现在键盘应用程序上,使用 LFU 缓存来实现这一点是有意义的,因为用户可能经常使用相同的单词。一个单词的出现频率将是一个很好的衡量该单词是否应该存在于缓存中的标准。
LFU 缓存使用双向链表在 O(1)时间内移除元素。LFUs 中的双重链接节点也有freqCount
属性,它表示在第一次插入后它被访问/设置的频率。
1 function LFUNode(key, value) {
2 this.prev = null;
3 this.next = null;
4 this.key = key;
5 this.data = value;
6 this.freqCount = 1;
7 }
LFU 缓存有两个散列表:keys
和freq
。 freq
有 frequency 键(1 到 n ,其中 n 是元素访问的最高频率),每一项都是一个双向链表类的实例。keys
存储每个双向链表节点,用于 O(1)检索。双向链表和 LFU 缓存的类定义如下:
1 function LFUDoublyLinkedList(){
2 this.head = new LFUNode('buffer head',null);
3 this.tail = new LFUNode('buffer tail',null);
4 this.head.next = this.tail;
5 this.tail.prev = this.head;
6 this.size = 0;
7 }
8
9 function LFUCache(capacity){
10 this.keys = {}; // stores LFUNode
11 this.freq = {}; // stores LFUDoublyLinkedList
12 this.capacity = capacity;
13 this.minFreq = 0;
14 this.size =0;
15 }
LFUDoublyLinkedList
类也需要双向链表来实现插入和移除。然而,只需要在头部插入和在尾部移除。该实现与第十三章(链表)中所示的双向链表类的实现相同。
1 LFUDoublyLinkedList.prototype.insertAtHead = function(node) {
2 node.next = this.head.next;
3 this.head.next.prev = node;
4 this.head.next = node;
5 node.prev = this.head;
6 this.size++;
7 }
8
9 LFUDoublyLinkedList.prototype.removeAtTail = function() {
10 var oldTail = this.tail.prev;
11 var prev = this.tail.prev;
12 prev.prev.next = this.tail;
13 this.tail.prev = prev.prev;
14 this.size--;
15 return oldTail;
16 }
17
18 LFUDoublyLinkedList.prototype.removeNode = function(node) {
19 node.prev.next = node.next
20 node.next.prev = node.prev
21 this.size--;
22 }
LFU 的实施有几个步骤。有两种情况:插入新项目和替换旧项目。插入新项目时,会创建一个新节点。如果缓存未满,可以将其插入到freq
的频率为 1 的双向链表中。如果容量已满,则删除频率双向链表中的尾项,然后插入新节点。
如果该元素已经存在并且需要被替换,则该节点被带到其对应的频率双向链表的头部。最后,最小频率变量minFreq
相应地递增,以计算将来应该驱逐哪个项目。
1 LFUCache.prototype.set = function(key, value) {
2 var node = this.keys[key];
3
4 if (node == undefined) {
5 node = new LFUNode(key, value);
6
7 this.keys[key] = node;
8
9 if (this.size != this.capacity) {
10 // insert without deleting
11 if (this.freq[1] === undefined){
12 this.freq[1] = new LFUDoublyLinkedList();
13 }
14 this.freq[1].insertAtHead(node);
15 this.size++;
16 } else {
17 // delete and insert
18 var oldTail = this.freq[this.minFreq].removeAtTail();
19 delete this.keys[oldTail.key];
20
21 if (this.freq[1] === undefined){
22 this.freq[1] = new LFUDoublyLinkedList();
23 }
24
25 this.freq[1].insertAtHead(node);
26 }
27 this.minFreq = 1;
28 } else {
29 var oldFreqCount = node.freqCount;
30 node.data = value;
31 node.freqCount++;
32
33 this.freq[oldFreqCount].removeNode(node);
34
35 if (this.freq[node.freqCount] === undefined){
36 this.freq[node.freqCount] = new LFUDoublyLinkedList();
37 }
38
39 this.freq[node.freqCount].insertAtHead(node);
40
41 if (oldFreqCount == this.minFreq && Object.keys(this.freq[oldFreqCount]).size == 0) {
42 this.minFreq++;
43 }
44
45 }
46 }
为了实现get
,缓存需要在 O(1)时间内返回现有的节点,并增加访问的计数器。如果缓存中不存在该元素,则强制返回一个空元素。否则,增加元素的频率,将项目放在双向链表的头部,并相应地调整最小频率变量minFreq
。
1 LFUCache.prototype.get = function(key) {
2 var node = this.keys[key];
3
4 if (node == undefined) {
5 return null;
6 } else {
7
8 var oldFreqCount = node.freqCount;
9 node.freqCount++;
10
11 this.freq[oldFreqCount].removeNode(node);
12
13 if (this.freq[node.freqCount] === undefined){
14 this.freq[node.freqCount] = new LFUDoublyLinkedList();
15 }
16
17 this.freq[node.freqCount].insertAtHead(node);
18
19 if (oldFreqCount == this.minFreq && Object.keys(this.freq[oldFreqCount]).length == 0) {
20 this.minFreq++;
21 }
22 return node.data;
23 }
24 }
定义了所有函数后,以下代码显示了 LFU 用法的一个示例:
1 var myLFU = new LFUCache(5);
2 myLFU.set(1, 1); // state of myLFU.freq: {1: 1}
3 myLFU.set(2, 2); // state of myLFU.freq: {1: 2<->1}
4 myLFU.set(3, 3); // state of myLFU.freq: {1: 3<->2<->1}
5 myLFU.set(4, 4); // state of myLFU.freq: {1: 4<->3<->2<->1}
6 myLFU.set(5, 5); // state of myLFU.freq: {1: 5<->4<->3<->2<->1}
7 myLFU.get(1); // returns 1, state of myLFU.freq: {1: 5<->4<->3<->2, 2: 1}
8 myLFU.get(1); // returns 1, state of myLFU.freq: {1: 5<->4<->3<->2, 3: 1}
9 myLFU.get(1); // returns 1, state of myLFU.freq:{1: 5<->4<->3<->2, 4: 1}
10 myLFU.set(6, 6); // state of myLFU.freq: {1: 6<->5<->4<->3, 4: 1}
11 myLFU.get(6); // state of myLFU.freq: {1: 5<->4<->3, 4: 1, 2: 6}
最近最少使用的缓存
最近最少使用的 使用的 (LRU)缓存是一种缓存算法,它首先删除最旧的(最近最少使用的)项,因此被替换的项是最早访问的项。当访问高速缓存中的项目时,该项目移动到列表的后面(顺序中最新的)。当访问在高速缓存中没有找到的页面时,前面的项目(或顺序中最老的)被移除,而新的项目被放在列表的后面(顺序中最新的)。
该算法的实现需要跟踪何时使用了哪个节点。为了实现这一点,LRU 缓存是使用双向链表和哈希表实现的。
需要一个双向链表来跟踪头部(最老的数据)。因为最近使用的需求,所以需要双向链表。每次插入新数据时,头部都会向上移动,直到超出大小。那么最老的数据被驱逐。
图 14-1 显示了一个大小为 5 的 LRU 缓存的示意图。
图 14-1
LRU 高速缓存
为了实现 LRU 缓存,节点的定义类似于第十三章中的双向链表节点。该节点还有一个key
属性,其实现如下面的代码块所示:
1 function DLLNode(key, data) {
2 this.key = key;
3 this.data = data;
4 this.next = null;
5 this.prev = null;
6 }
可以通过传递参数capacity
来初始化 LRU 缓存。capacity
定义缓存中允许有多少个节点。
1 function LRUCache(capacity) {
2 this.keys = {};
3 this.capacity = capacity;
4 this.head = new DLLNode(", null);
5 this.tail = new DLLNode(", null);
6 this.head.next = this.tail;
7 this.tail.prev = this.head;
8 }
由于 LRU 缓存使用双向链表,这里将定义两个函数,用于删除一个节点和添加一个节点到尾部:
1 LRUCache.prototype.removeNode = function(node) {
2 var prev = node.prev,
3 next = node.next;
4 prev.next = next;
5 next.prev = prev;
6 }
7
8 LRUCache.prototype.addNode = function(node) {
9 var realTail = this.tail.prev;
10 realTail.next = node;
11
12 this.tail.prev = node;
13 node.prev = realTail;
14 node.next = this.tail;
15 }
还需要定义两个函数:get
和set
。每当调用get
时,LRU 缓存方案将该节点放在双向链表的头部,因为它是最近使用的节点。这与删除和添加节点是一样的。对于通过set
设置节点,LRU 缓存上的keys
属性用于存储节点,以保持在get
的 O(1)时间内检索。但是,如果缓存达到最大容量,它会从尾部逐出最远的节点。
1 LRUCache.prototype.get = function(key) {
2 var node = this.keys[key];
3 if (node == undefined) {
4 return null;
5 } else {
6 this.removeNode(node);
7 this.addNode(node);
8 return node.data;
9 }
10 }
11
12 LRUCache.prototype.set = function(key, value) {
13 var node = this.keys[key];
14 if (node) {
15 this.removeNode(node);
16 }
17
18 var newNode = new DLLNode(key, value);
19
20 this.addNode(newNode);
21 this.keys[key] = newNode;
22
23 // evict a node
24 if (Object.keys(this.keys).length > this.capacity) {
25 var realHead = this.head.next;
26 this.removeNode(realHead);
27 delete this.keys[realHead.key];
28 }
29 }
最后,下面是一个大小为 5 的 LRU 缓存的示例:
1 var myLRU = new LRUCache(5);
2
3 myLRU.set(1, 1); // 1
4 myLRU.set(2, 2); // 1 <-> 2
5 myLRU.set(3, 3); // 1 <-> 2 <-> 3
6 myLRU.set(4, 4); // 1 <-> 2 <-> 3 <-> 4
7 myLRU.set(5, 5); // 1 <-> 2 <-> 3 <-> 4 <-> 5
8
9
10 myLRU.get(1); // 2 <-> 3 <-> 4 <-> 5 <-> 1
11 myLRU.get(2); // 3 <-> 4 <-> 5 <-> 1 <-> 2
12
13 myLRU.set(6, 6);// 4 <-> 5 <-> 1 <-> 2 <-> 6
14 myLRU.set(7, 7);// 5 <-> 1 <-> 2 <-> 6 <-> 7
15 myLRU.set(8, 8);// 1 <-> 2 <-> 6 <-> 7 <-> 8
摘要
本章介绍了两个主要的缓存概念:最少使用和最近最少使用。这一章谈到了最佳缓存算法的概念,这是不可能实现的,但提供了一个你想要近似的概念。LFU 缓存听起来很棒,因为它使用频率来确定应该驱逐哪个节点,但是 LFU 在大多数情况下不如 LRU,因为它没有考虑时间局部性。还有其他的缓存算法,但是大多数算法在一般情况下都比较差,比如最近没有使用的算法和先进先出算法。最后,需要注意的是,鉴于现实生活中系统行为工作负载的许多已知数据,LRU 在大多数情况下是最有效的算法。表 14-1 总结了缓存算法。
表 14-1
缓存摘要
|算法
|
评论
|
| --- | --- |
| 最佳的 | 不可能实现 |
| 最不常用 | 对时间局部性不利 |
| 最近最少使用 | 使用双向链接+ hashmap |
十五、树
一般的树数据结构由带有子节点的节点组成。第一个/顶层节点被称为根节点。本章将探讨许多不同类型的树,如二叉树、二分搜索法树和自平衡二分搜索法树。首先,本章将介绍什么是树以及它们是如何构成的。然后,它将详细介绍遍历树数据结构的方法。最后,您将学习二分搜索法树和自平衡二分搜索法树,了解如何存储易于搜索的数据。
一般树形结构
一个普通的树数据结构看起来如图 15-1 所示,它可以有任意数量的孩子。
图 15-1
具有任意数量子树的广义树
图 15-1 树中节点的代码块如下:
1 function TreeNode(value){
2 this.value = value;
3 this.children = [];
4 }
二叉树
二叉树是一种只有两个子节点的树:左和右。参见下面的代码和图 15-2 :
图 15-2
二叉树
1 function BinaryTreeNode(value) {
2 this.value = value;
3 this.left = null;
4 this.right = null;
5 }
二叉树总是有一个根节点(顶部的节点),在插入任何元素之前,它被初始化为null
。
1 function BinaryTree(){
2 this._root = null;
3 }
树遍历
遍历数组很简单:使用索引访问树,并递增索引,直到索引达到大小限制。对于树,为了遍历树中的每个元素,必须跟随左右指针。当然,有各种方法可以做到这一点;最流行的遍历技术是前序遍历、后序遍历、按序遍历和层次序遍历。
GitHub 上提供了所有的树遍历代码。 1
前序遍历
前序遍历按以下顺序访问节点:根(当前节点)、左、右。在图 15-3 中,可以看到 42 是根,所以先访问它。然后向左走;此时,父根(41)的左边现在被认为是新的根。这个新的根(41)被打印;然后它又向左走到 10。因此,10 被设置为新的根,但没有子节点就无法继续。那么 40 被访问,因为这是前一个父(41)的权利。这个过程继续,整个订单由图 15-3 中的灰色方块表示。
图 15-3
前序遍历
递归地,这很容易实现。当节点为null
时,基本情况终止。否则,它将打印节点值,然后对其左侧子节点和右侧子节点调用递归函数。
1 BinaryTree.prototype.traversePreOrder = function() {
2 traversePreOrderHelper(this._root);
3
4 function traversePreOrderHelper(node) {
5 if (!node)
6 return;
7 console.log(node.value);
8 traversePreOrderHelper(node.left);
9 traversePreOrderHelper(node.right);
10 }
11 }
这也可以迭代完成,但是实现起来比较困难。
1 BinaryTree.prototype.traversePreOrderIterative = function() {
2 //create an empty stack and push root to it
3 var nodeStack = [];
4 nodeStack.push(this._root);
5
6 // Pop all items one by one. Do following for every popped item
7 // a) print it
8 // b) push its right child
9 // c) push its left child
10 // Note that right child is pushed first so that left
11 // is processed first */
12 while (nodeStack.length) {
13 //# Pop the top item from stack and print it
14 var node = nodeStack.pop();
15 console.log(node.value);
16
17 //# Push right and left children of the popped node to stack
18 if (node.right)
19 nodeStack.push(node.right);
20 if (node.left)
21 nodeStack.push(node.left);
22 }
23 }
下面是结果:[42,41,10,40,50,45,75]。
有序遍历
有序遍历按以下顺序访问节点:左、根(当前节点)、右。对于图 15-4 所示的树,灰色方块表示有序遍历顺序。如您所见,首先打印 10(最左边的节点),最后打印 7(最右边的节点)。
图 15-4
有序遍历
用递归也可以很容易地实现有序遍历。基本情况是当一个节点是null
时。在非基本情况下,它调用左边子节点上的递归函数,打印当前节点,然后调用右边子节点上的递归函数。
1 BinaryTree.prototype.traverseInOrder = function() {
2 traverseInOrderHelper(this._root);
3
4 function traverseInOrderHelper(node) {
5 if (!node)
6 return;
7 traverseInOrderHelper(node.left);
8 console.log(node.value);
9 traverseInOrderHelper(node.right);
10 }
11 }
12
13 BinaryTree.prototype.traverseInOrderIterative = function() {
14 var current = this._root,
15 s = [],
16 done = false;
17
18 while (!done) {
19 // Reach the left most Node of the current Node
20 if (current != null) {
21 // Place pointer to a tree node on the stack
22 // before traversing the node's left subtree
23 s.push(current);
24 current = current.left;
25 } else {
26 if (s.length) {
27 current = s.pop();
28 console.log(current.value);
29 current = current.right;
30 } else {
31 done = true;
32 }
33 }
34 }
35 }
下面是这次遍历的结果:[10,41,40,42,45,50,75]。
后序遍历
后序遍历按以下顺序访问节点:左、右、根(当前节点)。对于图 15-5 所示的树,灰色方块表示有序遍历顺序。如您所见,首先打印 10(最左边的节点),最后打印 42(根节点)。
图 15-5
后序遍历
代码如下:
1 BinaryTree.prototype.traversePostOrder = function() {
2 traversePostOrderHelper(this._root);
3
4 function traversePostOrderHelper(node) {
5 if (node.left)
6 traversePostOrderHelper(node.left);
7 if (node.right)
8 traversePostOrderHelper(node.right);
9 console.log(node.value);
10 }
11 }
12
13 BinaryTree.prototype.traversePostOrderIterative = function() {
14 // Create two stacks
15 var s1 = [],
16 s2 = [];
17
18 // Push root to first stack
19 s1.push(this._root);
20
21 //# Run while first stack is not empty
22 while (s1.length) {
23 // Pop an item from s1 and append it to s2
24 var node = s1.pop();
25 s2.push(node);
26
27 // Push left and right children of removed item to s1
28 if (node.left)
29 s1.push(node.left);
30 if (node.right)
31 s1.push(node.right);
32 }
33 // Print all elements of second stack
34 while (s2.length) {
35 var node = s2.pop();
36 console.log(node.value);
37 }
38 }
结果是这样的:[10,40,41,45,75,50,42]。
层次顺序遍历
层次顺序遍历,如图 15-6 所示,又称广度优先搜索 (BFS)。
图 15-6
层次顺序遍历
更多内容将在第十七章中介绍,但这种方法本质上是逐层访问每个节点,而不是深入左侧或右侧。
1 BinaryTree.prototype.traverseLevelOrder = function() {
2 // Breath first search
3 var root = this._root,
4 queue = [];
5
6 if (!root)
7 return;
8 queue.push(root);
9
10 while (queue.length) {
11 var temp = queue.shift();
12 console.log(temp.value);
13 if (temp.left)
14 queue.push(temp.left);
15 if (temp.right)
16 queue.push(temp.right);
17 }
18 }
下面是结果:[42,41,50,10,40,45,75]。
树遍历摘要
如果您知道您需要在检查任何叶子之前探索根,选择前序遍历,因为您将在所有叶子之前遇到所有的根。
如果您知道您需要在任何节点之前探索所有的叶子,选择后序遍历,因为您在搜索叶子时不会浪费任何时间来检查根。
如果您知道树在节点中有一个固有的序列,并且您想要将树展平到它的原始序列,那么您应该使用有序遍历。该树将以创建时的方式展平。前序或后序遍历可能不会将树展开回创建它时的顺序。
时间复杂度: O( n
任何这些遍历的时间复杂度是相同的,因为每个遍历都需要访问所有节点。
二分搜索法树
二分搜索法树(BST)也有两个孩子,左和右。然而,在二叉查找树中,左边的孩子比父母小,右边的孩子比父母大。BST 具有这种结构,因为这种特性使得搜索、插入和删除特定值的时间复杂度为 O(log 2 ( n ))。
图 15-7 显示了 BST 属性。1 比 2 小,所以是 2 的左子,由于 3 比 3 大,所以是 2 的右子。
图 15-7
二叉查找树
二分搜索法树有一个根节点(最顶端的节点),它最初被初始化null
(在插入任何项目之前)。
1 function BinarySearchTree(){
2 this._root = null;
3 }
图 15-7 也显示了一个平衡的二叉查找树,通过在左右两侧都有孩子来最小化高度。然而,图 15-8 显示了一个不平衡的树,其中子节点仅位于父节点的右侧。这对数据结构有很大的影响,增加了插入、删除和搜索的时间复杂度,从 O(log 2 ( n ))增加到 O( n )。完美平衡的树的高度是 log 2 ( n ),而不平衡的树在最坏的情况下可以是 n 。
图 15-8
不平衡的二叉查找树
插入
插入 BST 需要几个步骤。首先,如果根是空的,那么根将成为新的节点。否则,使用while
循环遍历 BST,直到满足正确的条件。在每次循环迭代中,检查新节点是大于还是小于currentRoot
。
1 BinarySearchTree.prototype.insert = function(value) {
2 var thisNode = {left: null, right: null, value: value};
3 if(!this._root){
4 //if there is no root value yet
5 this._root = thisNode;
6 }else{
7 //loop traverse until
8 var currentRoot = this._root;
9 while(true){
10 if(currentRoot.value>value){
11 //let's increment if it's not a null and insert if it is a null
12 if(currentRoot.left!=null){
13 currentRoot = currentRoot.left;
14 }else{
15 currentRoot.left = thisNode;
16 break;
17 }
18 } else if (currentRoot.value<value){
19 //if bigger than current, put it on the right
20 //let's increment if it's not a null and insert if it is a null
21 if(currentRoot.right!=null){
22 currentRoot = currentRoot.right;
23 }else{
24 currentRoot.right = thisNode;
25 break;
26 }
27 } else {
28 //case that both are the same
29 break;
30 }
31 }
32 }
33 }
时间复杂度(对于平衡树):O(log2(n))
时间复杂度(对于不平衡树): O( n
时间复杂度取决于二叉查找树的高度。
删除
该算法首先遍历树,专门寻找具有指定值的节点。找到节点后,有三种可能的情况:
-
案例 1:节点没有子节点。
这是最简单的情况。如果节点没有子节点,则返回
null
。该节点现在已被删除。 -
案例 2:节点有一个子节点。
如果节点只有一个子节点,只需返回现有的子节点。那个孩子现在已经长大并取代了父母。
-
案例 3:节点有两个子节点。
如果节点有两个子节点,要么找到左子树的最大值,要么找到右子树的最小值来替换该节点。
下面的代码实现了上述三种情况。首先,它递归遍历,直到满足其中一种情况,然后删除节点。
1 BinarySearchTree.prototype.remove = function(value) {
2
3 return deleteRecursively(this._root, value);
4
5 function deleteRecursively(root, value) {
6 if (!root) {
7 return null;
8 } else if (value < root.value) {
9 root.left = deleteRecursively(root.left, value);
10 } else if (value > root.value) {
11 root.right = deleteRecursively(root.right, value);
12 } else {
13 //no child
14 if (!root.left && !root.right) {
15 return null; // case 1
16 } else if (!root.left) { // case 2
17 root = root.right;
18 return root;
19 } else if (!root.right) { // case 2
20 root = root.left;
21 return root;
22 } else {
23 var temp = findMin(root.right); // case 3
24 root.value = temp.value;
25 root.right = deleteRecursively(root.right, temp.value);
26 return root;
27 }
28 }
29 return root;
30 }
31
32 function findMin(root) {
33 while (root.left) {
34 root = root.left;
35 }
36 return root;
37 }
38 }
时间复杂度(对于平衡树):O(log2(n))
时间复杂度(对于不平衡树): O( n
删除的时间复杂度也是 O(log2(n)),因为最多需要遍历这个高度来找到并删除想要的节点。
搜索
可以使用 BST 节点的左子节点总是小于其父节点并且 BST 节点的右子节点总是大于其父节点的属性来执行搜索。遍历树可以通过检查currentRoot
是否小于或大于要搜索的值来完成。如果currentRoot
较小,则访问正确的孩子。如果currentRoot
更大,则访问左边的孩子。
1 BinarySearchTree.prototype.findNode = function(value) {
2 var currentRoot = this._root,
3 found = false;
4 while (currentRoot) {
5 if (currentRoot.value > value) {
6 currentRoot = currentRoot.left;
7 } else if (currentRoot.value < value) {
8 currentRoot = currentRoot.right;
9 } else {
10 //we've found the node
11 found = true;
12 break;
13 }
14 }
15 return found;
16 }
17 var bst1 = new BinarySearchTree();
18 bst1.insert(1);
19 bst1.insert(3);
20 bst1.insert(2);
21 bst1.findNode(3); // true
22 bst1.findNode(5); // false
时间复杂度(对于平衡树):O(log2(n))
时间复杂度(对于不平衡树): O( n
注意,所有操作的时间复杂度都等于二叉树搜索的高度。对于不平衡的二分搜索法树,时间复杂度很高。为了解决这个问题,有二分搜索法树家族确保高度平衡。这种自平衡树的一个例子是 AVL 树。
AVL 树
AVL 是一个自我平衡的二叉查找树;它是以发明家乔治·阿德尔森-维尔斯基和叶夫根尼·兰迪斯的名字命名的。AVL 树将 BST 高度保持在最小,并确保 O(log2(n))的时间复杂度用于搜索、插入和删除。在前面的例子中,我们定义了TreeNode
和Tree
类,并将Tree
的根设置为TreeNode
类。然而,对于 AVL 树实现,只有代表 AVL 树节点的AVLTree
类将被用于简化代码。
1 function AVLTree (value) {
2 this.left = null;
3 this.right = null;
4 this.value = value;
5 this.depth = 1;
6 }
AVL 树的高度基于子树的高度,可以使用以下代码块来计算:
1 AVLTree.prototype.setDepthBasedOnChildren = function() {
2 if (this.node == null) {
3 this.depth = 0;
4 } else {
5 this.depth = 1;
6 }
7
8 if (this.left != null) {
9 this.depth = this.left.depth + 1;
10 }
11 if (this.right != null && this.depth <= this.right.depth) {
12 this.depth = this.right.depth + 1;
13 }
14 }
单次旋转
AVL 树旋转他们的孩子来保持插入后的平衡。
左旋 90 度
这是一个节点必须向左旋转的例子。节点 40 的子节点 45 和 47 导致高度不平衡,如图 15-9 所示。45 成为图 15-10 中的父节点,以平衡 BST。
图 15-10
之后向左旋转
图 15-9
之前向左旋转
要执行向左旋转,首先获取左边的子元素并存储它。这才是“原本”的左孩子。最初的左子节点现在将成为该节点的父节点。将节点的左子节点设置为原左子节点的左子节点。最后,将原左子的右子设置为节点。
1 AVLTree.prototype.rotateLL = function() {
2
3 var valueBefore = this.value;
4 var rightBefore = this.right;
5 this.value = this.left.value;
6
7 this.right = this.left;
8 this.left = this.left.left;
9 this.right.left = this.right.right;
10 this.right.right = rightBefore;
11 this.right.value = valueBefore;
12
13 this.right.getDepthFromChildren();
14 this.getDepthFromChildren();
15 };
右旋 90 度
这是一个节点必须向右旋转的例子。60 的孩子,55 和 52 的节点,导致高度不平衡,如图 15-11 。55 节点成为图 15-12 中的父节点,以平衡 BST。
图 15-12
右后旋转
图 15-11
之前向右旋转
要实现前面描述的算法,首先获取左边的子元素并存储它。这是最初的左孩子。最初的左子节点现在将成为该节点的父节点。将节点的左子节点设置为原左子节点的左子节点。最后,将原左子的右子设置为节点。
1 AVLTree.prototype.rotateRR = function() {
2 // the right side is too long => rotate from the right (_not_ rightwards)
3 var valueBefore = this.value;
4 var leftBefore = this.left;
5 this.value = this.right.value;
6
7 this.left = this.right;
8 this.right = this.right.right;
9 this.left.right = this.left.left;
10 this.left.left = leftBefore;
11 this.left.value = valueBefore;
12
13 this.left.updateInNewLocation();
14 this.updateInNewLocation();
15 }
双旋转
如果 AVL 树在一次旋转后仍然不平衡,它必须旋转两次以达到完全平衡。
左右旋转(先右后左)
在本例中,图 15-13 显示了高度为 3 的 BST。先右后左旋转,如图 15-14 和图 15-15 所示,达到平衡。
图 15-15
之后向左旋转
图 15-14
先向右旋转
图 15-13
先向右旋转,然后向左旋转是合适的
向左向右旋转(先左后右)
同样,在本例中,图 15-16 显示了高度为 3 的 BST。先左后右旋转,如图 15-17 和图 15-18 所示,达到平衡。
图 15-18
右后旋转
图 15-17
先向左旋转
图 15-16
先向左旋转,然后向右旋转是合适的
平衡树
要检查 AVL 树的平衡,只需简单比较左右儿童的身高。如果高度不平衡,就需要旋转。当左大于右时,左旋转完成。当右大于左时,右旋转完成。
1 AVLTree.prototype.balance = function() {
2 var ldepth = this.left == null ? 0 : this.left.depth;
3 var rdepth = this.right == null ? 0 : this.right.depth;
4
5 if (ldepth > rdepth + 1) {
6 // LR or LL rotation
7 var lldepth = this.left.left == null ? 0 : this.left.left.depth;
8 var lrdepth = this.left.right == null ? 0 : this.left.right.depth;
9
10 if (lldepth < lrdepth) {
11 // LR rotation consists of a RR rotation of the left child
12 this.left.rotateRR();
13 // plus a LL rotation of this node, which happens anyway
14 }
15 this.rotateLL();
16 } else if (ldepth + 1 < rdepth) {
17 // RR or RL rorarion
18 var rrdepth = this.right.right == null ? 0 : this.right.right.depth;
19 var rldepth = this.right.left == null ? 0 : this.right.left.depth;
20
21 if (rldepth > rrdepth) {
22 // RR rotation consists of a LL rotation of the right child
23 this.right.rotateLL();
24 // plus a RR rotation of this node, which happens anyway
25 }
26 this.rotateRR();
27 }
28 }
插入
AVL BST 中的插入与普通 BST 中的插入是一样的,除了一旦插入,父节点必须平衡其子节点并设置正确的深度。
1 AVLTree.prototype.insert = function(value) {
2 var childInserted = false;
3 if (value == this.value) {
4 return false; // should be all unique
5 } else if (value < this.value) {
6 if (this.left == null) {
7 this.left = new AVLTree(value);
8 childInserted = true;
9 } else {
10 childInserted = this.left.insert(value);
11 if (childInserted == true) this.balance();
12 }
13 } else if (value > this.value) {
14 if (this.right == null) {
15 this.right = new AVLTree(value);
16 childInserted = true;
17 } else {
18 childInserted = this.right.insert(value);
19
20 if (childInserted == true) this.balance();
21 }
22 }
23 if (childInserted == true) this.setDepthBasedOnChildren();
24 return childInserted;
25 }
时间复杂度:O(nlog2(n))
空间复杂度:O(nlog2(n))
空间复杂性来自内存中的递归调用栈。
删除
AVL BST 是 BST 的一种,因此删除功能是相同的。通过在遍历过程中调用setDepthBasedOnChildren()
可以调整深度。
1 AVLTree.prototype.remove = function(value) {
2 return deleteRecursively(this, value);
3
4 function deleteRecursively(root, value) {
5 if (!root) {
6 return null;
7 } else if (value < root.value) {
8 root.left = deleteRecursively(root.left, value);
9 } else if (value > root.value) {
10 root.right = deleteRecursively(root.right, value);
11 } else {
12 //no child
13 if (!root.left && !root.right) {
14 return null; // case 1
15 } else if (!root.left) {
16 root = root.right;
17 return root;
18 } else if (!root.right) {
19 root = root.left;
20 return root;
21 } else {
22 var temp = findMin(root.right);
23 root.value = temp.value;
24 root.right = deleteRecursively(root.right, temp.value);
25 return root;
26 }
27 }
28 root.updateInNewLocation(); // ONLY DIFFERENCE from the BST one
29 return root;
30 }
31 function findMin(root) {
32 while (root.left) root = root.left;
33 return root;
34 }
35 }
时间复杂度和空间复杂度都是 O(nlog2(n))因为 AVL 树是平衡的。空间复杂度来自内存中的递归调用栈。
将所有这些放在一起:AVL 树示例
实现 AVL 树类后,图 15-19 显示了由以下代码块生成的 AVL 树示例:
图 15-19
AVL 结果
1 var avlTest = new AVLTree(1,");
2 avlTest.insert(2);
3 avlTest.insert(3);
4 avlTest.insert(4);
5 avlTest.insert(5);
6 avlTest.insert(123);
7 avlTest.insert(203);
8 avlTest.insert(2222);
9 console.log(avlTest);
如果使用普通二叉查找树,图 15-20 显示了相同插入顺序的情况。
图 15-20
BST 结果
显然,这是一个完全不平衡的扭曲的二叉查找树。此时,它看起来像一个链表。一旦树像这样变得完全不平衡,它的删除、插入和搜索就有了线性的时间复杂度,而不是对数时间。
摘要
表 15-1 显示了每次二叉查找树操作的时间复杂度。与其他数据结构相比,搜索操作比链表、数组、栈和队列更快。顾名思义,二进制搜索树非常适合搜索元素。但是插入和删除操作比较慢,时间复杂度为 O(log2(n))而不是像栈或者队列那样的 O(1)。此外,当树变得不平衡时,所有操作都变成 O( n )。为确保树保持平衡,应使用自平衡树(如红黑树或 AVL 树)来确保树操作具有对数时间复杂度。
表 15-1
树摘要
|操作
|
最佳(如果平衡)
|
最差(如果完全不平衡)
|
| --- | --- | --- |
| 删除 | O( 日志 2 ( n )) | O( n ) |
| 插入 | O( 日志 2 ( n )) | O( n ) |
| 搜索 | O( 日志 2 ( n )) | O( n ) |
练习
你可以在 GitHub 上找到所有练习的代码。 2
在给定的二叉树中寻找两个节点的最低共同祖先
这个的逻辑实际上相当简单,但是一开始很难注意到。
如果两个值中的最大值小于当前根,则向左。如果两个值中的最小值大于当前根,则向右。图 15-21 和 15-22 显示了这两种不同的情况。
图 15-22
最低共同祖先,示例 2
图 15-21
最低共同祖先,示例 1
1 function findLowestCommonAncestor(root, value1, value2) {
2 function findLowestCommonAncestorHelper(root, value1, value2) {
3 if (!root)
4 return;
5 if (Math.max(value1, value2) < root.value)
6 return findLowestCommonAncestorHelper(root.left, value1, value2);
7 if (Math.min(value1, value2) > root.value)
8 return findLowestCommonAncestorHelper(root.right, value1, value2);
9 return root.value
10 }
11 return findLowestCommonAncestorHelper(root, value1, value2);
12 }
13 var node1 = {
14 value: 1,
15 left: {
16 value: 0
17 },
18 right: {
19 value: 2
20 }
21 }
22
23 var node2 = {
24 value: 1,
25 left: {
26 value: 0,
27 left: {
28 value: -1
29 },
30 right: {
31 value: 0.5
32 }
33 },
34 right: {
35 value: 2
36 }
37 }
38 console.log(findLowestCommonAncestor(node1, 0, 2)); // 1
39 console.log(findLowestCommonAncestor(node2, 0, 2)); // 1
40 console.log(findLowestCommonAncestor(node1, 0.5, -1)); // 0
时间复杂度:O(log2(n))
打印距根第 n 个距离的节点
对于这个问题,以任何方式遍历 BST(在这个例子中使用了 level order ),并检查每个 BST 节点的高度,看是否应该打印它。
1 function printKthLevels(root, k) {
2 var arrayKth = [];
3 queue = [];
4
5 if (!root) return;
6
7 // Breath first search for tree
8 queue.push([root, 0]);
9
10 while (queue.length) {
11 var tuple = queue.shift(),
12 temp = tuple[0],
13 height= tuple[1];
14
15 if (height == k) {
16 arrayKth.push(temp.value);
17 }
18 if (temp.left) {
19 queue.push([temp.left, height+1]);
20 }
21 if (temp.right) {
22 queue.push([temp.right,height+1]);
23 }
24 }
25 console.log(arrayKth);
26 }
1 var node1 = {
2 value: 1,
3 left: {
4 value: 0
5 },
6 right: {
7 value: 2
8 }
9 }
10
11 var node2 = {
12 value: 1,
13 left: {
14 value: 0,
15 left: {
16 value: -1
17 },
18 right: {
19 value: 0.5
20 }
21 },
22 right: {
23 value: 2
24 }
25 }
26
27 var node3 = {
28 value: 1,
29 left: {
30 value: 0
31 },
32 right: {
33 value: 2,
34 left: {
35 value: 1.5
36 },
37 right: {
38 value: 3,
39 left: {
40 value: 3.25
41 }
42 }
43 }
44 }
45
46 printKthLevels(node1, 1); // 1
47 printKthLevels(node1, 2); // [0,2]
检查二叉树是否是另一棵树的子树
要做到这一点,以任何方式遍历二叉树(我选择层次顺序)并检查它当前所在的树是否与子树相同。
1 function isSameTree(root1, root2) {
2 if (root1 == null && root2 == null) {
3 return true;
4 }
5 if (root1 == null || root2 == null) {
6 return false;
7 }
8
9 return root1.value == root2.value &&
10 isSameTree(root1.left, root2.left) &&
11 isSameTree(root1.right, root2.right)
12 }
13
14 function checkIfSubTree(root, subtree) {
15 // Breath first search
16 var queue = [],
17 counter = 0;
18
19 // sanity check for root
20 if (!root) {
21 return;
22 }
23
24 queue.push(root);
25
26 while (queue.length) {
27 var temp = queue.shift();
28
29 if (temp.data == subtree.data == isSameTree(temp, subtree)) {
30 return true;
31 }
32
33 if (temp.left) {
34 queue.push(temp.left);
35 }
36 if (temp.right) {
37 queue.push(temp.right);
38 }
39 }
40 return false;
41 }
42
43 var node1 = {
44 value: 5,
45 left: {
46 value: 3,
47 left: {
48 value: 1
49 },
50 right: {
51 value: 2
52 }
53 },
54 right: {
55 value: 7
56 }
57 }
58
59 var node2 = {
60 value: 3,
61 left: {
62 value: 1
63 },
64 right: {
65 value: 2
66 }
67 }
68
69
70 var node3 = {
71 value: 3,
72 left: {
73 value: 1
74 }
75 }
76
77 console.log(checkIfSubTree(node1, node2)); // true
78 console.log(checkIfSubTree(node1, node3)); // false
79 console.log(checkIfSubTree(node2, node3)); // false
检查一棵树是否是另一棵树的镜像
图 15-23 显示了一个例子。
图 15-23
镜像树
这里有三种可能的情况:
-
它们的根节点的键必须相同。
-
a 的根的左子树和 b 的右子树根是镜像。
-
a 的右边子树和 b 的左边子树是镜像。
1 function isMirrorTrees(tree1, tree2) {
2 // Base case, both empty
3 if (!tree1 && !tree2) {
4 return true;
5 }
6
7 // One of them is empty, since only one is empty, not mirrored
8 if (!tree1 || !tree2) {
9 return false;
10 }
11
12 // Both non-empty, compare them recursively.
13 // Pass left of one and right of the other
14
15 var checkLeftwithRight = isMirrorTrees(tree1.left, tree2.right),
16 checkRightwithLeft = isMirrorTrees(tree2.right, tree1.left);
17
18 return tree1.value == tree2.value && checkLeftwithRight && checkRightwithLeft;
19 }
20
21 var node1 = {
22 value: 3,
23 left: {
24 value: 1
25 },
26 right: {
27 value: 2
28 }
29 }
30
31 var node2 = {
32 value: 3,
33 left: {
34 value: 2
35 },
36 right: {
37 value: 1
38 }
39 }
40
41 var node3 = {
42 value: 3,
43 left: {
44 value: 1
45 },
46 right: {
47 value: 2,
48 left: {
49 value: 2.5
50 }
51 }
52 }
53
54 console.log(isMirrorTrees(node1, node2)); // true
55 console.log(isMirrorTrees(node2, node3)); // false
十六、堆
本章将介绍堆。堆是一种重要的数据结构,它在 O(1)时间内返回最高或最低的元素。本章将重点解释堆是如何实现的,以及如何使用它们。一个例子是堆排序,这是一种基于堆的排序算法。
了解堆
堆是一种类似树的数据结构,其中父堆大于其子堆(如果是最大堆)或小于其子堆(如果是最小堆)。堆的这一属性使它对数据排序非常有用。
与其他树数据结构不同,堆使用数组来存储数据,而不是拥有指向其子级的指针。堆节点的子节点在数组中的位置(索引)很容易计算。这是因为父子关系很容易用堆来定义。
有许多类型的堆有不同数量的子堆。在本章中,只考虑二进制堆。因为堆使用数组来存储数据,所以数组的索引定义了每个元素的顺序/高度。二进制堆可以通过将第一个数组元素作为根元素,然后依次填充每个左边和右边的元素来构建。
例如,对于图 16-1 中所示的堆,数组应该是这样的:[2,4,23,12,13]。
图 16-1
堆索引
有两种类型的二进制堆:最大堆和最小堆。在 max-heap 中,根节点的值最高,每个节点的值都大于其子节点。在最小堆中,根节点的值最低,每个节点的值都小于其子节点。
堆可以存储任何类型的任何值:字符串、整数,甚至自定义类。如第 3 和 4 章所述,字符串和整数值的比较由 JavaScript 本地处理(例如,9 大于 1, z 大于 a )。然而,对于定制类,开发人员需要实现一种方法来比较两个类。本章将着眼于只存储整数值的堆。
最大堆
最大堆是指父堆总是大于其子堆的堆(见图 16-2 )。
图 16-2
最大堆
这里是 max-heap 的数组,如图 16-2 所示:[100,19,36,17,3,25,1,2,7]。
最小堆
最小堆是一个父堆总是比它的任何子堆都小的堆(见图 16-3 )。
图 16-3
最小堆
这里是如图 16-3 所示的 max-heap 的数组:[1,2,3,17,19,36,7,25,100]。
二进制堆数组索引结构
对于二进制堆,通过使用以下索引,使用数组来表示堆,其中N
是节点的索引:
Node Index
(itself) N
Parent (N-1) / 2
Left Child (N*2) + 1
Right Child (N*2) + 2
图 16-4 用指数说明了这种家族关系。
图 16-4
堆关系
让我们首先定义一个通用的Heap
类。使用前面描述的索引结构,一个数组将被用来存储所有的值。下面的堆类实现了检索父节点、左侧子节点和右侧子节点的帮助器函数。下面的代码块有一个peek
函数,它返回最大堆的最大值和最小堆的最小值。
1 function Heap() {
2 this.items = [];
3 }
4
5 Heap.prototype.swap = function(index1, index2) {
6 var temp = this.items[index1];
7 this.items[index1] = this.items[index2];
8 this.items[index2] = temp;
9 }
10
11 Heap.prototype.parentIndex = function(index) {
12 return Math.floor((index - 1) / 2);
13 }
14
15 Heap.prototype.leftChildIndex = function(index) {
16 return index * 2 + 1;
17 }
18
19 Heap.prototype.rightChildrenIndex = function(index) {
20 return index * 2 + 2;
21 }
22
23 Heap.prototype.parent = function(index) {
24 return this.items[this.parentIndex(index)];
25 }
26
27 Heap.prototype.leftChild = function(index) {
28 return this.items[this.leftChildIndex(index)];
29 }
30
31 Heap.prototype.rightChild = function(index) {
32 return this.items[this.rightChildrenIndex(index)];
33 }
34
35 Heap.prototype.peek = function(item) {
36 return this.items[0];
37 }
38 Heap.prototype.size = function() {
39 return this.items.length;
40 }
size
函数是另一个返回堆大小(元素数量)的助手。
渗透:上下冒泡
当添加或删除元素时,堆的结构必须保持不变(最大堆的节点大于其子节点,最小堆的节点小于其子节点)。
这需要交换项目并“冒泡”到堆的顶部。与向上冒泡类似,有些项需要“向下冒泡”到它们正确的位置,以便保持堆的结构。逾渗在时间上需要 O(log2(n))。
让我们遍历一个 min-heap 示例,并按以下顺序将以下值插入 min-heap:12、2、23、4、13。以下是步骤:
图 16-11
最新和最大的 13 节点仍在原处
- 插入 13,如图 16-11 所示。
图 16-10
较小的 4 节点已经冒泡以维持最小堆结构
- 12 与 4 交换以保持最小堆结构(图 16-10 )。
图 16-9
最小堆中的新节点比它上面的节点小
- 在堆中插入 4,如图 16-9 所示。
图 16-8
较大的 23 节点保留在最小堆结构中
- 在第二个子位置插入一个新的 23 节点(图 16-8 )。
图 16-7
较小的节点直到父节点位置都有气泡
- 2 节点会冒泡,因为它小于 12,因此应该位于最小堆的顶部(图 16-7 )。
图 16-6
最新的节点比父节点小
- 插入一个新的 2 节点(图 16-6 )。
图 16-5
最小堆根节点
- 插入 12 作为第一个节点(图 16-5 )。
下面是这个堆的数组内容:[2,4,23,12,13]。
实现渗透
为了实现渗滤的“上下冒泡”,交换直到最小堆结构形成,最小元素在顶部。对于向下冒泡,如果一个子元素更小,则将顶部元素(数组中的第一个)与其子元素交换。同样,对于向上冒泡,如果父元素大于新元素,则将新元素与其父元素交换。
1 function MinHeap() {
2 this.items = [];
3 }
4 MinHeap.prototype = Object.create(Heap.prototype); // inherit helpers from heap by copying prototype
5 MinHeap.prototype.bubbleDown = function() {
6 var index = 0;
7 while (this.leftChild(index) && this.leftChild(index) < this.items[index]) {
8 var smallerIndex = this.leftChildIndex(index);
9 if (this.rightChild(index)
10 && this.rightChild(index) < this.items[smallerIndex]) {
11 // if right is smaller, right swaps
12 smallerIndex = this.rightChildrenIndex(index);
13 }
14 this.swap(smallerIndex, index);
15 index = smallerIndex;
16 }
17 }
18
19 MinHeap.prototype.bubbleUp = function() {
20 var index = this.items.length - 1;
21 while (this.parent(index) && this.parent(index) > this.items[index]) {
22 this.swap(this.parentIndex(index), index);
23 index = this.parentIndex(index);
24 }
25 }
最大堆实现的不同之处仅在于比较器。对于向下冒泡,如果子节点更大,则 max-heap 节点与其一个子节点交换。同样,对于冒泡,如果最新节点的父节点比新节点小,则该节点与其父节点交换。
最大堆示例
现在让我们构建一个 max-heap,其值与前面的 min-heap 示例中使用的值相同,按顺序插入以下值:12、2、23、4、13。
图 16-19
渗透恢复最大堆结构
- 由于 max-heap 结构,13 和 4 交换位置(图 16-19 )。
图 16-18
新节点比它上面的节点大
- 插入 13,如图 16-18 所示。
图 16-17
4 和 2 节点交换位置
- 为了保持最大堆结构,4 个气泡向上,2 个气泡向下(图 16-17 )。
图 16-16
新节点比它上面的节点大
- 插入 4,如图 16-16 所示。
图 16-15
新的较大节点与较小的 12 交换
- 这 23 个节点“冒泡”到顶部以维持最大堆结构(图 16-15 )。
图 16-14
新子节点大于父节点
- 插入 23,如图 16-14 所示。
图 16-13
新的较小节点保留在最大堆结构中
- 插入一个新的 2 节点(图 16-13 )。
图 16-12
第一个最大堆节点
- 插入第一个节点,即 12(图 16-12 )。
下面是这个堆的数组内容:[23,13,12,2,4]。
最小堆完成实现
将所有定义的函数放在一起并继承Heap
的函数,min-heap 的完整实现和示例如下所示。增加了add
和poll
功能。add
简单地向堆中添加一个新元素,但是bubbleUp
确保最小堆中的这个元素满足顺序。poll
从堆中移除最小元素(根),并调用bubbleDown
来保持最小堆顺序。
1 function MinHeap() {
2 this.items = [];
3 }
4 MinHeap.prototype = Object.create(Heap.prototype); // inherit helpers from heap by copying prototype
5 MinHeap.prototype.add = function(item) {
6 this.items[this.items.length] = item;
7 this.bubbleUp();
8 }
9
10 MinHeap.prototype.poll = function() {
11 var item = this.items[0];
12 this.items[0] = this.items[this.items.length - 1];
13 this.items.pop();
14 this.bubbleDown();
15 return item;
16 }
17
18 MinHeap.prototype.bubbleDown = function() {
19 var index = 0;
20 while (this.leftChild(index) && (this.leftChild(index) < this.items[index] || this.rightChild(index) < this.items[index]) ) {
21 var smallerIndex = this.leftChildIndex(index);
22 if (this.rightChild(index) && this.rightChild(index) < this.items[smallerIndex]) {
23 smallerIndex = this.rightChildrenIndex(index);
24 }
25 this.swap(smallerIndex, index);
26 index = smallerIndex;
27 }
28 }
29
30 MinHeap.prototype.bubbleUp = function() {
31 var index = this.items.length - 1;
32 while (this.parent(index) && this.parent(index) > this.items[index]) {
33 this.swap(this.parentIndex(index), index);
34 index = this.parentIndex(index);
35 }
36 }
37
38 var mh1 = new MinHeap();
39 mh1.add(1);
40 mh1.add(10);
41 mh1.add(5);
42 mh1.add(100);
43 mh1.add(8);
44
45 console.log(mh1.poll()); // 1
46 console.log(mh1.poll()); // 5
47 console.log(mh1.poll()); // 8
48 console.log(mh1.poll()); // 10
49 console.log(mh1.poll()); // 100
最大堆完成实现
如前所述,最小堆和最大堆实现之间的唯一区别是bubbleDown
和bubbleUp
中的比较器。添加了与上一个示例相同的元素,即(1,10,5,100,8),当调用poll
时,max-heap 返回最高的元素。
1 function MaxHeap() {
2 this.items = [];
3 }
4 MaxHeap.prototype = Object.create(Heap.prototype); // inherit helpers from heap by copying prototype
5 MaxHeap.prototype.poll = function() {
6 var item = this.items[0];
7 this.items[0] = this.items[this.items.length - 1];
8 this.items.pop();
9 this.bubbleDown();
10 return item;
11 }
12
13 MaxHeap.prototype.bubbleDown = function() {
14 var index = 0;
15 while (this.leftChild(index) && (this.leftChild(index) > this.items[index] || this.rightChild(index) > this.items[index] ) ) {
16 var biggerIndex = this.leftChildIndex(index);
17 if (this.rightChild(index) && this.rightChild(index) > this.items[bigger\Index])
18 {
19 biggerIndex = this.rightChildrenIndex(index);
20 }
21 this.swap(biggerIndex, index);
22 index = biggerIndex;
23 }
24 }
25
26 MaxHeap.prototype.bubbleUp = function() {
27 var index = this.items.length - 1;
28 while (this.parent(index) && this.parent(index) < this.items[index]) {
29 this.swap(this.parentIndex(index), index);
30 index = this.parentIndex(index);
31 }
32 }
33
34 var mh2 = new MaxHeap();
35 mh2.add(1);
36 mh2.add(10);
37 mh2.add(5);
38 mh2.add(100);
39 mh2.add(8);
40
41 console.log(mh2.poll()); // 100
42 console.log(mh2.poll()); // 10
43 console.log(mh2.poll()); // 8
44 console.log(mh2.poll()); // 5
45 console.log(mh2.poll()); // 1
堆排序
既然已经创建了堆类,用堆进行排序就相当简单了。要获得一个排序的数组,只需在堆上调用.pop()
直到它为空,并存储已存储的弹出对象。这就是所谓的堆排序。由于逾渗需要 O(log2(n)),并且排序必须弹出 n 个元素,所以堆排序的时间复杂度为 O(nlog2(n)),类似于快速排序和合并排序。
在本节中,我们将首先使用最小堆实现升序排序,然后使用最大堆实现降序排序。
升序排序(最小堆)
图 16-20 显示了当所有项目都被添加到最小堆中时的最小堆,图 16-21 到 16-23 显示了弹出项目时的堆重组。最后,当它为空时,排序完成。
图 16-23
最小堆排序:取出 12
图 16-22
最小堆排序:弹出 4
图 16-21
最小堆排序:弹出 2
图 16-20
添加所有项目后的最小堆排序
1 var minHeapExample = new MinHeap();
2 minHeapExample.add(12);
3 minHeapExample.add(2);
4 minHeapExample.add(23);
5 minHeapExample.add(4);
6 minHeapExample.add(13);
7 minHeapExample.items; // [2, 4, 23, 12, 13]
8
9 console.log(minHeapExample.poll()); // 2
10 console.log(minHeapExample.poll()); // 4
11 console.log(minHeapExample.poll()); // 12
12 console.log(minHeapExample.poll()); // 13
13 console.log(minHeapExample.poll()); // 23
最后一个节点(原来是 13 的地方)被去掉,然后 13 被放在最上面。通过过滤过程,13 向下移动到 12 的左孩子之后,因为它比 4 和 13 都大。
降序排序(最大堆)
图 16-24 显示了所有项目都被添加到最小堆时的最大堆,图 16-25 到 16-27 显示了弹出项目时的最大堆重组。最后,当它为空时,排序完成。
图 16-27
最大排序:弹出 12 个
图 16-26
最大排序:弹出 13 个
图 16-25
最大排序:弹出 23 个
图 16-24
添加所有项目后的最大堆排序
1 var maxHeapExample = new MaxHeap();
2 maxHeapExample.add(12);
3 maxHeapExample.add(2);
4 maxHeapExample.add(23);
5 maxHeapExample.add(4);
6 maxHeapExample.add(13);
7 maxHeapExample.items; // [23, 13, 12, 2, 4]
8
9 console.log(maxHeapExample.poll()); // 23
10 console.log(maxHeapExample.poll()); // 13
11 console.log(maxHeapExample.poll()); // 12
12 console.log(maxHeapExample.poll()); // 2
13 console.log(maxHeapExample.poll()); // 4
摘要
堆是用数组表示的树状数据结构。要获得树节点的父节点、左子节点和右子节点,可以使用表 16-1 中的索引公式。
表 16-1
堆 节点指标汇总
|结节
|
索引
|
| --- | --- |
| (自己) | 普通 |
| 父母 | (N-1) / 2 |
| 左边的孩子 | (N2) + 1 |
| 正确的孩子 | (N2) + 2 |
堆通过渗透来维持它们的结构;当一个节点被插入时,它通过重复地与元素交换来“冒泡”,直到获得合适的堆结构。对于一个最小堆,这意味着根节点中值最低的节点。对于 max-heap,这意味着根节点中值最高的节点。堆基本上是通过渗滤工作的,渗滤允许在 O(log 2 ( n ))时间内删除和插入,如表 16-2 所示。
表 16-2
堆 作战总结
|操作
|
时间复杂度
|
| --- | --- |
| 删除(导致“气泡下降”) | O( 日志 2 ( n )) |
| 插入(导致“冒泡”) | O( 日志 2 ( n )) |
| 堆排序 | o(nlog2(n)) |
练习
你可以在 GitHub 上找到所有练习的代码。 1
跟踪数字流中的中位数
既然这个问题在这一章里,那已经是接近它的一个很大的暗示了。理论上,解决方案相当简单。有一个最小堆和一个最大堆,那么检索中间值只需要 O(1)。
例如,让我们有一个如下整数的流:12,2,23,4,13。
当插入 12 时,中位数是 12,因为这是唯一的元素。当插入 2 时,有偶数个项目:2 和 12。因此,中位数是它的算术平均值,7 ((12+2)/2)。当插入 23 时,中位数是 12。最后,当插入 13 时,中位数是 12.5,即两个中间项(12 和 13)的平均值。
1 medianH.push(12);
2 console.log(medianH.median()); // 12
3 medianH.push(2);
4 console.log(medianH.median()); // 7 ( because 12 + 2 = 14; 14/2 = 7)
5 medianH.push(23);
6 console.log(medianH.median()); // 12
7 medianH.push(13);
8 console.log(medianH.median()); // 12.5
1 function MedianHeap() {
2 this.minHeap = new MinHeap();
3 this.maxHeap = new MaxHeap();
4 }
5
6 MedianHeap.prototype.push = function (value) {
7 if (value > this.median()) {
8 this.minHeap.add(value);
9 } else {
10 this.maxHeap.add(value);
11 }
12
13 // Re balancing
14 if (this.minHeap.size() - this.maxHeap.size() > 1) {
15 this.maxHeap.push(this.minHeap.poll());
16 }
17
18 if (this.maxHeap.size() - this.minHeap.size() > 1){
19 this.minHeap.push(this.maxHeap.poll());
20 }
21 }
22
23 MedianHeap.prototype.median = function () {
24 if (this.minHeap.size() == 0 && this.maxHeap.size() == 0){
25 return Number.NEGATIVE_INFINITY;
26 } else if (this.minHeap.size() == this.maxHeap.size()) {
27 return (this.minHeap.peek() + this.maxHeap.peek()) / 2;
28 } else if (this.minHeap.size() > this.maxHeap.size()) {
29 return this.minHeap.peek();
30 } else {
31 return this.maxHeap.peek();
32 }
33 }
34
35 var medianH = new MedianHeap();
36
37 medianH.push(12);
38 console.log(medianH.median()); // 12
39 medianH.push(2);
40 console.log(medianH.median()); // 7 ( because 12 + 2 = 14; 14/2 = 7)
41 medianH.push(23);
42 console.log(medianH.median()); // 12
43 medianH.push(13);
44 console.log(medianH.median()); // 12.5
找出数组中第 K 个最小值
这个问题之前已经在第十章使用 quicksort 的辅助函数探讨过了。另一种方法是使用堆。简单地将元素添加到一个堆中,并弹出第 k 次。根据最小堆的定义,这将返回数组中第 k 个最小值。
1 var array1 = [12, 3, 13, 4, 2, 40, 23]
2
3 function getKthSmallestElement(array, k) {
4 var minH = new MinHeap();
5 for (var i = 0, arrayLength = array.length; i < arrayLength; i++) {
6 minH.add(array[i]);
7 }
8 for (var i = 1; i < k; i++) {
9 minH.poll();
10 }
11 return minH.poll();
12 }
13 getKthSmallestElement(array1, 2); // 3
14 getKthSmallestElement(array1, 1); // 2
15 getKthSmallestElement(array1, 7); // 40
找出数组中的 KTH 最大值
这与之前关于 max-heap 的想法相同。
1 var array1 = [12,3,13,4,2,40,23];
2
3 function getKthBiggestElement(array, k) {
4 var maxH = new MaxHeap();
5 for (var i=0, arrayLength = array.length; i<arrayLength; i++) {
6 maxH.push(array[i]);
7 }
8 for (var i=1; i<k; i++) {
9 maxH.pop();
10 }
11 return maxH.pop();
12 }
13 getKthBiggestElement(array1,2); // 23
14 getKthBiggestElement(array1,1); // 40
15 getKthBiggestElement(array1,7); // 2
时间复杂度:o(klug2(n))
这里, n 是数组的大小,因为每次.pop
花费 O(log2(n)),要做 k 次。
空间复杂度: O( n
内存中需要 O( n )来存储堆数组。
*十七、图
本章包括图。图是表示对象之间联系的一种通用方式。在本章中,您将学习图基础知识,包括基本术语和图类型。本章还将介绍如何使用这些不同的图类型,以及在已经探索过的数据结构中表示图的方法。最后,探索遍历、搜索和排序图的算法,以解决诸如寻找两个图节点之间的最短路径等问题。
图基础
如简介中所述,图是对象之间联系的可视化表示。这种表示可以是许多事物,并有不同的应用;表 17-1 显示了一些例子。
表 17-1
图应用示例
|应用
|
项目
|
关系
|
| --- | --- | --- |
| 网站 | 网页 | 链接 |
| 地图 | 交集 | 路 |
| 电路 | 成分 | 接线 |
| 社会化媒体 | 人 | “友谊”/联系 |
| 电话 | 电话号码 | 固定电话 |
图 17-1 显示了两个简单图的例子。
图 17-1
图的两个例子
在我们深入研究图之前,介绍一些基本的术语和概念是有用的。
图 17-5
B 上有圈的图
- 循环图:如果一个有向图有一条从顶点到自身的路径,那么这个有向图被认为是循环的。例如,在图 17-5 中,B 可以沿着边到 C,然后 D,然后 E,然后再到 B。
图 17-4
稠密图
- 稠密图:当不同顶点之间有大量连接时,一个图被认为是稠密的(见图 17-4 )。
图 17-3
稀疏图
-
顶点的度数:顶点的度数是指该顶点(节点)上的边数。
-
稀疏图:当顶点之间只存在一小部分可能的连接时,一个图被认为是稀疏的(见图 17-3 )。
图 17-2
顶点和边
-
顶点:顶点是构成图的节点。在这一章中,对于 Big-O 分析,节点将被标注为 V 。顶点用圆来表示,如图 17-2 所示。
-
边:边是图中节点之间的连接。从图上看,它是顶点之间的“线”。对于 Big-O 分析,它将被标记为 E 。用线条表示一条边,如图 17-2 所示。
相比之下,图 17-6 是非循环图的一个例子。
图 17-6
无圈图
图 17-7
带权重的有向图
- 权重:权重是边上的值。根据上下文,权重可以表示各种事物。例如,有向图上的权重可以表示从节点 A 到 B 所需的距离,如图 17-7 所示。
无向图
无向图是边之间没有方向的图。边意味着两个节点之间没有方向的相互连接。无向图关系的一个真实例子是友谊。只有双方都承认这种关系,友谊才会产生。友谊图内的边的值可以指示友谊有多近。图 17-8 是一个简单的无向图,有五个顶点和六条带权的无向边。
图 17-8
带权重的无向图
有多种方法可以将无向图示为数据结构类。两种最常见的方法是使用邻接矩阵或邻接表。邻接表使用顶点作为节点的关键字,其邻居存储在列表中,而邻接矩阵是 V 乘 V 矩阵,矩阵的每个元素指示两个顶点之间的连接。图 17-9 说明了邻接表和邻接矩阵的区别(这本书只涉及邻接表)。
图 17-9
图(左)、邻接表(中)和邻接矩阵(右)
到目前为止,已经讨论了图的概念和定义。现在,让我们实际开始在代码中实现这些想法,并学习如何添加和删除边和顶点。
添加边和顶点
在这个例子中,我们创建了一个加权的无向图并添加了顶点和边。首先,我们将为无向图创建一个新类。无向图应该有一个对象来存储边。这是如下面的代码块所示实现的:
1 function UndirectedGraph() {
2 this.edges = {};
3 }
要添加边,必须先添加顶点(节点)。该实现将采用邻接表方法,将顶点作为存储边值的this.edges
对象内的对象。
1 UndirectedGraph.prototype.addVertex = function(vertex) {
2 this.edges[vertex] = {};
3 }
为了将加权边添加到无向图中,this.edges
对象中的两个顶点都用于设置权重。
1 UndirectedGraph.prototype.addEdge = function(vertex1,vertex2, weight) {
2 if (weight == undefined) {
3 weight = 0;
4 }
5 this.edges[vertex1][vertex2] = weight;
6 this.edges[vertex2][vertex1] = weight;
7 }
这样,让我们用下面的代码添加一些顶点和边:
1 var graph1 = new UndirectedGraph();
2 graph1.addVertex(1);
3 graph1.addVertex(2);
4 graph1.addEdge(1,2, 1);
5 graph1.edges; // 1: {2: 0}, 2: {1: 0}
6 graph1.addVertex(3);
7 graph1.addVertex(4);
8 graph1.addVertex(5);
9 graph1.addEdge(2,3, 8);
10 graph1.addEdge(3,4, 10);
11 graph1.addEdge(4,5, 100);
12 graph1.addEdge(1,5, 88);
图 17-10 显示了该代码的图输出。
图 17-10
第一个无向图
移除边和顶点
继续同一个例子,让我们实现移除 graph 类的边和顶点的函数。
要从顶点移除边,在this.edges
中查找该顶点的边对象,并使用 JavaScript 的delete
操作符将其删除。
1 UndirectedGraph.prototype.removeEdge = function(vertex1, vertex2) {
2 if (this.edges[vertex1] && this.edges[vertex1][vertex2] != undefined) {
3 delete this.edges[vertex1][vertex2];
4 }
5 if (this.edges[vertex2] && this.edges[vertex2][vertex1] != undefined) {
6 delete this.edges[vertex2][vertex1];
7 }
8 }
接下来,让我们删除整个顶点。需要记住的重要一点是,任何时候一个顶点被移除,所有连接到它的边也必须被移除。这可以使用循环来完成,如以下实现所示:
1 UndirectedGraph.prototype.removeVertex = function(vertex) {
2 for (var adjacentVertex in this.edges[vertex]) {
3 this.removeEdge(adjacentVertex, vertex);
4 }
5 delete this.edges[vertex];
6 }
现在实现了删除,让我们创建另一个无向图对象,类似于第一个例子,但删除一些顶点和边。先去掉顶点 5,结果如图 17-11 所示。顶点 1 也被删除,如图 17-12 所示。最后,图 17-13 显示了移除 2 和 3 之间的边缘时的结果。
图 17-13
2 和 3 之间的边缘已移除
图 17-12
顶点 1 已移除
图 17-11
移除顶点 5
1 var graph2 = new UndirectedGraph();
2 graph2.addVertex(1);
3 graph2.addVertex(2);
4 graph2.addEdge(1,2, 1);
5 graph2.edges; // 1: {2: 0}, 2: {1: 0}
6 graph2.addVertex(3);
7 graph2.addVertex(4);
8 graph2.addVertex(5);
9 graph2.addEdge(2,3, 8);
10 graph2.addEdge(3,4, 10);
11 graph2.addEdge(4,5, 100);
12 graph2.addEdge(1,5, 88);
13 graph2.removeVertex(5);
14 graph2.removeVertex(1);
15 graph2.removeEdge(2,3);
有向图
有向图是指在顶点之间有方向的图。有向图中的每条边从一个顶点到另一个顶点,如图 17-14 所示。
图 17-14
有向图
在本例中,E 节点可以“行进”到 D 节点,而 D 节点只能行进到 C 节点。
现在让我们实现一个加权有向图类。将使用无向图实现中使用的类似邻接表方法。首先用如图所示的edges
属性定义了DirectedGraph
类,添加顶点的方法与从无向图类实现的方法相同。
1 function DirectedGraph() {
2 this.edges = {};
3 }
4 DirectedGraph.prototype.addVertex = function (vertex) {
5 this.edges[vertex] = {};
6 }
给定一条起始于原点并终止于目的顶点的边,要将边添加到有向图中,权重应仅设置在原点上,如下所示:
1 DirectedGraph.prototype.addEdge = function(origVertex, destVertex, weight) {
2 if (weight === undefined) {
3 weight = 0;
4 }
5 this.edges[origVertex][destVertex] = weight;
6 }
实现了添加顶点和边的函数后,让我们添加一些示例顶点和边。
1 var digraph1 = new DirectedGraph();
2 digraph1.addVertex("A");
3 digraph1.addVertex("B");
4 digraph1.addVertex("C");
5 digraph1.addEdge("A", "B", 1);
6 digraph1.addEdge("B", "C", 2);
7 digraph1.addEdge("C", "A", 3);
图 17-15 显示了添加在 A 和 B 顶点之间的边(第 5 行)。图 17-16 表示 B 和 C 之间的连接(6 号线),图 17-17 表示 C 和 A 之间的连接(7 号线)。
图 17-17
将 C 添加到 A
图 17-16
将 B 添加到 C
图 17-15
将 A 添加到 B
有向图中删除顶点和删除边的实现与无向图中看到的实现相同,只是必须删除edges
对象中的原始顶点,如下所示:
1 DirectedGraph.prototype.removeEdge = function(origVertex, destVertex) {
2 if (this.edges[origVertex] && this.edges[origVertex][destVertex] != undefined) {
3 delete this.edges[origVertex][destVertex];
4 }
5 }
6
7 DirectedGraph.prototype.removeVertex = function(vertex) {
8 for (var adjacentVertex in this.edges[vertex]) {
9 this.removeEdge(adjacentVertex, vertex);
10 }
11 delete this.edges[vertex];
12 }
图遍历
一个图可以用多种方式遍历。两种最常见的方法是广度优先搜索和深度优先搜索。类似于如何探索不同的树遍历技术,这一节将集中讨论这两种遍历技术以及何时使用它们中的每一种。
广度优先搜索
广度优先搜索 (BFS)指的是一种在图中的搜索算法,按顺序聚焦于连通节点及其连通节点。这个想法实际上已经在第十五章中用层次顺序遍历的树进行了探索。图 17-18 显示了二叉查找树的层次顺序遍历。
图 17-18
二叉查找树的层次顺序遍历
请注意,遍历的顺序是根据从根节点算起的高度来确定的。注意与图 17-19 中的图相似。
图 17-19
广度优先搜索图
类似于树形数据结构的层次顺序遍历,BFS 需要一个队列。
对于每个节点,将每个连接的顶点添加到队列中,然后访问队列中的每个项目。让我们为图类编写一个通用的 BFS 算法。
1 DirectedGraph.prototype.traverseBFS = function(vertex, fn) {
2 var queue = [],
3 visited = {};
4
5 queue.push(vertex);
6
7 while (queue.length) {
8 vertex = queue.shift();
9 if (!visited[vertex]) {
10 visited[vertex] = true;
11 fn(vertex);
12 for (var adjacentVertex in this.edges[vertex]) {
13 queue.push(adjacentVertex);
14 }
15 }
16 }
17 }
18 digraph1.traverseBFS("B", (vertex)=>{console.log(vertex)});
时间复杂度: O( V + E
时间复杂度为 O( V + E ,其中 V 为顶点数, E 为边数。这是因为该算法必须遍历整个图的每个边和节点。
回忆一下本章前面使用的“无向图”中图 17-20 的图结构。
图 17-20
之前的无向图示例
将 BFS 应用于图,会打印出以下内容:1,2,5,3,4。
在图 17-21 和 17-22 中,浅阴影节点表示当前正在访问的节点,而深色节点表示该节点已经被访问过。
图 17-21
广度优先搜索,第一部分
在图 17-21 中,广度优先搜索从 1 节点开始。因为它有两个邻居 2 和 5,所以它们被添加到队列中。然后,2 被访问,它的邻居 3 被添加到队列中。5 然后出列,并且它的邻居 4 被添加到队列中。最后访问 3 和 4,搜索结束,如图 17-22 所示。
图 17-22
广度优先搜索,第二部分
深度优先搜索
深度优先搜索 (DFS)指的是图中的一种搜索算法,它专注于在访问其他连接之前深入遍历一个连接。
这个想法已经在第十五章中用树中的顺序、后顺序和前顺序遍历进行了探讨。例如,后序树遍历在访问顶部根节点之前访问底部子节点(见图 17-23 )。
图 17-23
后序遍历
类似的情况如图 17-24 所示。
图 17-24
深度优先搜索图
注意最后 E 是如何被访问的。这是因为搜索在访问 e 之前访问了在深度中连接到 C 的所有节点。
类似于树数据结构的前置后置和有序遍历,递归被用于深入到节点中,直到该路径被用尽。
让我们为 graph 类编写一个通用的 DFS 算法。
1 DirectedGraph.prototype.traverseDFS = function(vertex, fn) {
2 var visited = {};
3 this._traverseDFS(vertex, visited, fn);
4 }
5
6 DirectedGraph.prototype._traverseDFS = function(vertex, visited, fn) {
7 visited[vertex] = true;
8 fn(vertex);
9 for (var adjacentVertex in this.edges[vertex]) {
10 if (!visited[adjacentVertex]) {
11 this._traverseDFS(adjacentVertex, visited, fn);
12 }
13 }
14 }
时间复杂度: O( V + E
时间复杂度为 O( V + E ),其中 V 为顶点数, E 为边数。这是因为该算法必须遍历整个图的每个边和节点。这与 BFS 算法的时间复杂度相同。
同样,让我们使用本章前面的图结构(见图 17-25 )。
图 17-25
图 17-20 中的早期图示例
将 DFS 应用于图,将打印出以下内容:1,2,3,4,5。
在图 17-26 和 17-27 中,浅阴影节点表示当前正在访问的节点,而深色节点表示该节点已经被访问过。
图 17-26
深度优先搜索,第一部分
在图 17-26 中,深度优先搜索从 1 节点开始。它的第一个邻居 2 被访问。然后,2 的第一个邻居 3 被访问。在访问 3 之后,下一个将访问 4,因为它是 3 的第一个邻居。最后,4 被访问,然后是 5,如图 17-27 所示。深度优先搜索总是递归地访问第一个邻居。
图 17-27
深度优先搜索,第二部分
加权图和最短路径
既然我们已经介绍了图的基本知识以及如何遍历它们,我们可以讨论加权边和 Dijkstra 算法,它使用最短路径搜索。
带权边的图
回想一下,图中的边表示顶点之间的连接。如果边建立了连接,则权重可以被分配给该连接。例如,对于表示地图的图,边上的权重是距离。
重要的是要注意,一条边的图长度对于该边的重量没有任何意义。它纯粹是为了视觉目的。在实现和代码中,不需要可视化表示。在图 17-28 中,权重告诉我们五个城市的图表示中城市之间的距离。例如,从图上看,从城市 1 到城市 2 的距离比从城市 2 到城市 3 的距离短。然而,边缘表明从城市 1 到城市 2 的距离是 50 公里,从城市 2 到城市 3 的距离是 10 公里,这是 5 倍大。
图 17-28
五个城市的图表示
加权边图最重要的问题是,从一个节点到另一个节点的最短路径是什么?图的最短路径算法有一系列。我们讨论的是 Dijkstra 算法。
Dijkstra 算法:最短路径
Dijkstra 算法的工作原理是在每一层选择最短的路径到达目的地。起初,距离被标记为无穷大,因为一些节点可能无法到达(见图 17-29 )。然后在每次遍历迭代中,为每个节点选择最短的距离(见图 17-30 和 17-31 )。
图 17-31
Dijkstra 阶段 3:现在已处理所有节点
图 17-30
Dijkstra 阶段 2: B 和 C 已处理
图 17-29
Dijkstra 阶段 1:所有标记为无穷大的东西
_extractMin
用于计算给定顶点的距离最小的相邻节点。当从起点到目的地节点遍历图时,使用广度优先搜索大纲将每个顶点的相邻节点排队,更新并计算距离。
1 function _isEmpty(obj) {
2 return Object.keys(obj).length === 0;
3 }
4
5 function _extractMin(Q, dist) {
6 var minimumDistance = Infinity,
7 nodeWithMinimumDistance = null;
8 for (var node in Q) {
9 if (dist[node] <= minimumDistance) {
10 minimumDistance = dist[node];
11 nodeWithMinimumDistance = node;
12 }
13 }
14 return nodeWithMinimumDistance;
15 }
16
17 DirectedGraph.prototype.Dijkstra = function(source) {
18 // create vertex set Q
19 var Q = {}, dist = {};
20 for (var vertex in this.edges) {
21 // unknown distances set to Infinity
22 dist[vertex] = Infinity;
23 // add v to Q
24 Q[vertex] = this.edges[vertex];
25 }
26 // Distance from source to source init to 0
27 dist[source] = 0;
28
29 while (!_isEmpty(Q)) {
30 var u = _extractMin(Q, dist); // get the min distance
31
32 // remove u from Q
33 delete Q[u];
34
35 // for each neighbor, v, of u:
36 // where v is still in Q.
37 for (var neighbor in this.edges[u]) {
38 // current distance
39 var alt = dist[u] + this.edges[u][neighbor];
40 // a shorter path has been found
41 if (alt < dist[neighbor]) {
42 dist[neighbor] = alt;
43 }
44 }
45 }
46 return dist;
47 }
48
49 var digraph1 = new DirectedGraph();
50 digraph1.addVertex("A");
51 digraph1.addVertex("B");
52 digraph1.addVertex("C");
53 digraph1.addVertex("D");
54 digraph1.addEdge("A", "B", 1);
55 digraph1.addEdge("B", "C", 1);
56 digraph1.addEdge("C", "A", 1);
57 digraph1.addEdge("A", "D", 1);
58 console.log(digraph1);
59 // DirectedGraph {
60 // V: 4,
61 // E: 4,
62 // edges: { A: { B: 1, D: 1 }, B: { C: 1 }, C: { A: 1 }, D: {} }}
63 digraph1.Dijkstra("A"); // { A: 0, B: 1, C: 2, D: 1 }
时间复杂度:O(V2+E)
这里的算法类似于 BFS 算法,但是需要使用时间复杂度为 O( n )的_extractMin
方法。正因为如此,时间复杂度为 O(V2+E),因为在_extractMin
方法中必须检查当前被遍历节点的所有邻居顶点。可以使用提取 min 的优先级队列来改进该算法,这将产生 O(log2(V)_extractMin
,并且因此产生 O(E+V)** O(log2(V*))= O()这甚至可以通过使用 Fibonacci 堆来优化,Fibonacci 堆有固定的时间来计算_extractMin
。然而,为了简单起见,在这个演示中既没有使用 Fibonacci 堆,也没有使用优先级队列。
拓扑排序
对于有向图,对于各种应用程序,知道应该首先处理哪个节点是很重要的。这方面的一个例子是任务调度器,其中一个任务依赖于前一个正在完成的任务。另一个例子是 JavaScript 库依赖管理器,它必须确定在其他库之前导入哪些库。拓扑排序算法实现了这一点。它是一种改进的 DFS,使用栈来记录顺序。
简而言之,它的工作方式是从一个节点执行 DFS,直到其连接的节点被递归耗尽,并将其添加到栈,直到所有连接的节点都被访问(见图 17-32 )。
图 17-32
拓扑排序
拓扑排序有一个被访问的集合,以确保递归调用不会导致无限循环。对于给定的节点,该节点被添加到已访问过的集合中,并且在下一次递归调用中访问其未被访问过的邻居。递归调用结束时,使用 unshift 将当前节点的值添加到栈中。这确保了顺序是按时间顺序的。
1 DirectedGraph.prototype.topologicalSortUtil = function(v, visited, stack) {
2 visited.add(v);
3
4 for (var item in this.edges[v]) {
5 if (visited.has(item) == false) {
6 this.topologicalSortUtil(item, visited, stack)
7 }
8 }
9 stack.unshift(v);
10 };
11
12 DirectedGraph.prototype.topologicalSort = function() {
13 var visited = {},
14 stack = [];
15
16
17 for (var item in this.edges) {
18 if (visited.has(item) == false) {
19 this.topologicalSortUtil(item, visited, stack);
20 }
21 }
22 return stack;
23 };
24
25 var g = new DirectedGraph()
;
26 g.addVertex('A');
27 g.addVertex('B');
28 g.addVertex('C');
29 g.addVertex('D');
30 g.addVertex('E');
31 g.addVertex('F');
32
33 g.addEdge('B', 'A');
34 g.addEdge('D', 'C');
35 g.addEdge('D', 'B');
36 g.addEdge('B', 'A');
37 g.addEdge('A', 'F');
38 g.addEdge('E', 'C');
39 var topologicalOrder = g.topologicalSort();
40 console.log(g);
41 // DirectedGraph {
42 // V: 6,
43 // E: 6,
44 // edges:
45 // { A: { F: 0 },
46 // B: { A: 0 },
47 // C: {},
48 // D: { C: 0, B: 0 },
49 // E: { C: 0 },
50 // F: {} } }
51 console.log(topologicalOrder); // [ 'E', 'D', 'C', 'B', 'A', 'F' ]
时间复杂度: O( V + E
空间复杂度: O( V
拓扑排序算法就是带有额外栈的 DFS。因此,时间复杂度与 DFS 相同。拓扑排序在空间上需要 O( V ),因为它需要存储栈中的所有顶点。这种算法对于根据给定的依赖关系调度作业是非常有效的。
摘要
本章讨论了不同类型的图、它们的属性以及如何对它们进行搜索和排序。由顶点组成并通过边连接的图可以用许多不同的方式表示为数据结构。在这一章中,邻接表被用来表示图。如果图是密集的,最好使用基于矩阵的图表示。在图的边中,权重表示相连顶点的重要性(或不重要)。此外,通过给边分配权重,实现了 Dijkstra 的最短路径算法。最后,图是具有各种用例和有趣算法的通用数据结构。
表 17-2 显示了图的一些关键属性。
表 17-2
图属性摘要
|财产
|
描述
|
| --- | --- |
| 稠密的 | 不同顶点之间有很多联系。 |
| 稀少的 | 顶点之间只存在一小部分可能的连接。 |
| 周期的 | 有一条路径将顶点带回到它们自己。 |
| 传阅的 | 没有路径可以让顶点回到它们自己。 |
| 定向的 | 图在边之间有一个方向。 |
| 未受指导的 | 图在边之间没有方向。 |
表 17-3 总结了图算法。
表 17-3
图算法概述
|算法
|
描述/使用案例
|
时间复杂度
|
| --- | --- | --- |
| 宽度优先搜索 | 通过一次访问一级邻居节点来遍历图 | O( V + E ) |
| 深度优先搜索 | 通过一次深入一个邻居节点来遍历图 | O( V + E ) |
| 最短路径 | 查找从一个顶点到其他顶点的最短路径 | o(V2T5+E |
| 拓扑排序 | 对有向图进行排序;对于作业调度算法 | O( V + E ) |
十八、高级字符串
本章将涵盖比前几章讨论的更高级的字符串算法。既然您已经了解了其他一些数据结构,它们应该更容易理解。具体来说,本章将集中讨论字符串搜索算法。
前缀树(Prefix Tree)
trie 是一种特殊类型的树,通常用于搜索字符串和匹配存储的字符串。在每一层,节点可以分支形成完整的单词。例如,图 18-1 显示了一个单词的 trie:余思敏,西姆兰,西亚,萨姆。每个结束节点都有一个布尔标志:isCompleted
。这表示该单词以此路径结束。例如, Sam 中的 m 将endOfWord
设置为true
。endOfWord
设置为true
的节点在图 18-1 中用阴影表示。
图 18-1
余思敏、西姆兰、新加坡、萨姆
trie 是使用嵌套对象实现的,其中每一层都有它的直接子对象作为键。可以通过使用对象存储子节点来形成 trie 节点。trie 有一个根节点,它在Trie
类的构造函数中被实例化,如下面的代码块所示:
1 function TrieNode() {
2 this.children = {}; // table
3 this.endOfWord = false;
4 }
5
6 function Trie() {
7 this.root = new TrieNode();
8 }
为了插入到 trie 中,如果子 trie 节点不存在,则在根节点上创建它。对于要插入的单词中的每个字符,如果该字符不存在,它会创建一个子节点,如下面的代码块所示:
1 Trie.prototype.insert = function(word) {
2 var current = this.root;
3 for (var i = 0; i < word.length; i++) {
4 var ch = word.charAt(i);
5 var node = current.children[ch];
6 if (node == null) {
7 node = new TrieNode();
8 current.children[ch] = node;
9 }
10 current = node;
11 }
12 current.endOfWord = true; //mark the current nodes endOfWord as true
13 }
要在 trie 中搜索,必须检查单词的每个字符。这是通过在根上设置一个临时变量current
来实现的。当单词中的每个字符被检查时,current
变量被更新。
1 Trie.prototype.search = function(word) {
2 var current = this.root;
3 for (var i = 0; i < word.length; i++) {
4 var ch = word.charAt(i);
5 var node = current.children[ch];
6 if (node == null) {
7 return false; // node doesn't exist
8 }
9 current = node;
10 }
11 return current.endOfWord;
12 }
13 var trie = new Trie();
14 trie.insert("sammie");
15 trie.insert("simran");
16 trie.search("simran"); // true
17 trie.search("fake") // false
18 trie.search("sam") // false
要从 trie 中删除一个元素,算法应该遍历根节点,直到到达单词的最后一个字符。然后,对于没有任何其他子节点的每个节点,应该删除该节点。例如,在一个有 sam 和 sim 的 trie 中,当 sim 被删除时,根中的 s 节点保持不变,但是 i 和 m 被删除。以下代码块中的递归实现实现了该算法:
1 Trie.prototype.delete = function(word) {
2 this.deleteRecursively(this.root, word, 0);
3 }
4
5 Trie.prototype.deleteRecursively = function(current, word, index) {
6 if (index == word.length) {
7 //when end of word is reached only delete if currrent.end Of Word is true.
8 if (!current.endOfWord) {
9 return false;
10 }
11 current.endOfWord = false;
12 //if current has no other mapping then return true
13 return Object.keys(current.children).length == 0;
14 }
15 var ch = word.charAt(index),
16 node = current.children[ch];
17 if (node == null) {
18 return false;
19 }
20 var shouldDeleteCurrentNode = this.deleteRecursively(node, word, index + 1);
21
22 // if true is returned then
23 // delete the mapping of character and trienode reference from map.
24 if (shouldDeleteCurrentNode) {
25 delete current.children[ch];
26 //return true if no mappings are left in the map.
27 return Object.keys(current.children).length == 0;
28 }
29 return false;
30 }
31 var trie1 = new Trie();
32 trie1.insert("sammie");
33 trie1.insert("simran");
34 trie1.search("simran"); // true
35 trie1.delete("sammie");
36 trie1.delete("simran");
37 trie1.search("sammie"); // false
38 trie1.search("simran"); // false
时间复杂度: O( W
空间复杂度: O( NM*
所有操作(插入、搜索、删除)的时间复杂度都是 O( W ),其中 W 是被搜索字符串的长度,因为字符串中的每个字符都被检查。空间复杂度为 O( NM* ,其中 N 为插入 trie 的字数, M 为最长字符的长度。因此,当有多个具有共同前缀的字符串时,trie 是一种有效的数据结构。为了在一个特定的字符串中搜索一个特定的字符串模式,trie 并不是高效的,因为需要额外的内存来存储树状结构中的字符串。
对于单个目标字符串中的模式搜索,Boyer-Moore 算法和 Knuth-Morris-Pratt(KMP)算法很有用,将在本章后面介绍。
boyer-Moore 字符串搜索
Boyer-Moore 字符串搜索算法用于支持文本编辑器应用程序和网络浏览器中的“查找”工具,如图 18-2 中的工具。
图 18-2
查找许多应用程序中常见的工具
Boyer–Moore 字符串搜索算法通过在字符串中搜索模式时跳过索引,允许线性时间搜索。例如图案果酱和串果冻果酱的暴力对比可视化如图 18-3 所示。应当注意,在第四次迭代中,当 j 与 m 进行比较时,由于 j 在模式中示出,向前跳 2 将是有效的。图 18-4 显示了一个优化的迭代周期,当模式中存在索引处的字符串时,通过向前跳跃来限制字符串比较的次数。
图 18-4
boyer-Moore 跳过指数
图 18-3
强力模式匹配迭代
要实现这个跳过规则,您可以构建一个“坏匹配表”结构。不良匹配表指示对于模式的给定字符要跳过多少。各种模式及其对应的不良匹配表的一些示例如下所示:
|模式
|
错误的匹配表
|
| --- | --- |
| jam | {j: 2, a: 1, m: 3}
|
| 数据 | {d: 3, a: 2, t: 1}
|
| 结构 | {s: 5, t: 4, r: 3, u: 2, c: 1}
|
| 国王 | {r: 2, o: 1, i: 3}
|
对于 roi 的例子,r:2
表示如果在字符串中没有找到r
,则索引应该跳过 2。这个不良匹配表可以用下面的代码块实现:
function buildBadMatchTable(str) {
var tableObj = {},
strLength = str.length;
for (var i = 0; i < strLength - 1; i++) {
tableObj[str[i]] = strLength - 1 - i;
}
if (tableObj[str[strLength-1]] == undefined) {
tableObj[str[strLength-1]] = strLength;
}
return tableObj;
}
buildBadMatchTable('data'); // {d: 3, a: 2, t: 1}
buildBadMatchTable('struct'); // {s: 5, t: 4, r: 3, u: 2, c: 1}
buildBadMatchTable('roi'); // {r: 2, o: 1, i: 3}
buildBadMatchTable('jam'); // {j: 2, a: 1, m: 3}
使用这个坏匹配表,可以实现 Boyer-Moore 字符串搜索算法。在扫描模式的输入字符串时,如果正在查看的当前字符串存在于不良匹配表中,则跳过与当前字符串相关联的不良匹配表值。否则,它将递增 1。这种情况一直持续到找到字符串或者索引大于模式和字符串长度之差。这在下面的代码块中实现:
function boyerMoore(str, pattern) {
var badMatchTable = buildBadMatchTable(pattern),
offset = 0,
patternLastIndex = pattern.length - 1,
scanIndex = patternLastIndex,
maxOffset = str.length - pattern.length;
// if the offset is bigger than maxOffset, cannot be found
while (offset <= maxOffset) {
scanIndex = 0;
while (pattern[scanIndex] == str[scanIndex + offset]) {
if (scanIndex == patternLastIndex) {
// found at this index
return offset;
}
scanIndex++;
}
var badMatchString = str[offset + patternLastIndex];
if (badMatchTable[badMatchString]) {
// increase the offset if it exists
offset += badMatchTable[badMatchString]
} else {
offset += 1;
}
}
return -1;
}
boyerMoore('jellyjam','jelly'); // 5\. indicates that the pattern starts at index 5
boyerMoore('jellyjam','jelly'); // 0\. indicates that the pattern starts at index 0
boyerMoore('jellyjam','sam'); // -1\. indicates that the pattern does not exist
最佳情况:
在最好的情况下,模式中的所有字符都是相同的,这持续地产生移位 T ,其中 T 是模式的长度。因此,O( W/T )是最佳时间复杂度,其中 W 是正在搜索模式的字符串。空间复杂度是 O( 1 ),因为只有 1 个值被存储到不良匹配表中。
时间复杂度: O( T/W
空间复杂度: O( 1
最坏情况:
在最坏的情况下,字符串的末尾是模式,前面的部分都是唯一的字符。这样的一个例子是一串 abcdefgxyz 和模式 xyz 。在这种情况下, TW* 字符串比较就完成了。
时间复杂度: O( TW*
空间复杂度: O( T
模式和字符串中的所有字符都是相同的。这种情况的一个例子是字符串 bbbbbb 和模式 bbb 。在这种情况下,不能最大限度地使用跳过机制,因为索引将总是递增 1。在这种情况下,空间复杂度是 T ,因为模式可以包含所有独特的字符。
knuth–Morris–Pratt 字符串搜索
第四章讨论了原生String.prototype.indexOf
函数。一个简单的String.prototype.indexOf
函数的实现被作为那一章的练习。一个更好的(更快的)实现使用 Knuth–Morris–Pratt(KMP)字符串搜索算法。KMP 算法的以下实现返回出现该模式的所有索引。
KMP 字符串搜索算法通过观察到不匹配的出现包含关于下一个匹配可以从哪里开始的足够信息,来搜索“单词” W 在输入“文本”中的出现,即 T 。这有助于跳过对先前匹配字符的重新检查。必须构建一个前缀数组来指示它必须回溯多少个索引才能得到相同的前缀。对于字符串 ababaca ,前缀 building 如下所示:
在当前索引 0 处,没有要比较的字符串,前缀数组值被初始化为 0。
-
数组索引 0 1 2 3 4 5 6
-
字符 a b a b a c a
-
前缀数组 0
在当前索引 1 :
-
人物是
b
。 -
前一个前缀数组值
prefix[0]
为 0。
将索引 0 与当前索引进行比较: a (索引= 0 时)和 b (索引= 1 时)不匹配。
prefix[1]
设置为 0:
-
数组索引 0 1 2 3 4 5 6
-
字符 a b a b a c a
-
前缀数组 0 0
当前索引 2 :
-
人物是一个。
-
前一个前缀数组值
prefix[1]
为 0。
将索引与当前索引进行比较: a (索引= 0 时)和 a (索引= 2 时)匹配。
prefix[2]
设置为 1(从prefix[1]
开始递增):
-
数组索引 0 1 2 3 4 5 6
-
字符 a b a b a c a
-
前缀数组 0 0 1
在当前索引 3 :
-
人物是 b 。
-
前一个前缀数组值
prefix[2]
为 1。
比较索引 1 和当前索引: b (索引= 1 时)和 b (索引= 3 时)匹配。
prefix[3]
设置为 2(从prefix[2]
开始递增):
-
数组索引 0 1 2 3 4 5 6
-
字符 a b a b a c a
-
前缀数组 0 0 1 2
在当前索引 4 :
-
人物是一个。
-
前一个前缀数组值
prefix[3]
是 2。
比较索引 2 和当前索引: a (索引= 2 时)和 a (索引= 4 时)匹配。
prefix[4]
设置为 3(从prefix[3]
开始递增):
-
数组索引 0 1 2 3 4 5 6
-
字符 a b a b a c a
-
前缀数组 0 0 1 2 3
在当前索引 5 :
-
人物是
c
。 -
前一个前缀数组值
prefix[4]
是 3。
比较索引 3 和当前索引: b (索引= 3 时)和 c (索引= 5 时)不匹配。
prefix[5]
设置为 0:
-
数组索引 0 1 2 3 4 5 6
-
字符 a b a b a c a
-
前缀数组 0 0 1 2 3 0
在当前索引 6 :
-
人物是 c 。
-
前一个前缀数组值
prefix[5]
为 0。
从索引 0 和当前索引比较: a (索引= 0 时)和 a (索引= 5 时)匹配。
prefix[6]
设置为 1(从prefix[5]
开始递增):
-
数组索引 0 1 2 3 4 5 6
-
字符 a b a b a c a
-
前缀数组 0 0 1 2 3 0 1
以下代码块中的函数说明了构建前缀表的算法:
function longestPrefix(str) {
// prefix array is created
var prefix = new Array(str.length);
var maxPrefix = 0;
// start the prefix at 0
prefix[0] = 0;
for (var i = 1; i < str.length; i++) {
// decrement the prefix value as long as there are mismatches
while (str.charAt(i) !== str.charAt(maxPrefix) && maxPrefix > 0) {
maxPrefix = prefix[maxPrefix - 1];
}
// strings match, can update it
if (str.charAt(maxPrefix) === str.charAt(i)) {
maxPrefix++;
}
// set the prefix
prefix[i] = maxPrefix;
}
return prefix;
}
console.log(longestPrefix('ababaca')); // [0, 0, 1, 2, 3, 0, 1]
现在有了这个前缀表,KMP 就可以实现了。KMP 搜索逐个索引地遍历要搜索的字符串和模式。每当出现不匹配时,它可以使用前缀表来计算一个新的索引进行尝试。当模式的索引达到模式的长度时,就找到了字符串。这在下面的代码块中详细实现:
function KMP(str, pattern) {
// build the prefix table
var prefixTable = longestPrefix(pattern),
patternIndex = 0,
strIndex = 0;
while (strIndex < str.length) {
if (str.charAt(strIndex) != pattern.charAt(patternIndex)) {
// Case 1: the characters are different
if (patternIndex != 0) {
// use the prefix table if possible
patternIndex = prefixTable[patternIndex - 1];
} else {
// increment the str index to next character
strIndex++;
}
} else if (str.charAt(strIndex) == pattern.charAt(patternIndex)) {
// Case 2: the characters are same
strIndex++;
patternIndex++;
}
// found the pattern
if (patternIndex == pattern.length) {
return true
}
}
return false;
}
KMP('ababacaababacaababacaababaca', 'ababaca'); // true
KMP('sammiebae', 'bae'); // true
KMP('sammiebae', 'sammie'); // true
KMP('sammiebae', 'sammiebaee'); // false
时间复杂度: O( W
空间复杂度: O( W
预处理长度为 W 的单词需要 O( W )的时间和空间复杂度。
时间复杂度: O( W + T
这里, W 是 T (被搜索的主字符串)中的“单词”。
拉宾-卡普搜索
Rabin–Karp 算法基于哈希算法来查找文本中的指定模式。虽然 KMP 被优化为在搜索过程中跳过冗余检查,但拉宾-卡普试图通过散列函数来加速子串模式的相等。为了有效地做到这一点,散列函数必须是 O(1)。特别是对于 Rabin-Karp 搜索,使用 Rabin 指纹散列技术。
拉宾指纹
拉宾指纹是通过下面的等式计算的:f(x)= m0+m1x+…+mn-1xn-1其中 n 是被散列的字符数,而 x 是某个质数。
这是一个简单的实现,如下面的代码块所示。在这个例子中,101 是一个任意的质数。在这种情况下,任何高素数都应该工作良好。但是,请注意,如果 x 太高,可能会导致整数溢出,因为 x n-1 增长很快。endLength
参数指示散列应该计算到哪个字符串索引。如果参数没有通过,它应该默认为str
的长度。
1 function RabinKarpSearch() {
2 this.prime = 101;
3 }
4 RabinKarpSearch.prototype.rabinkarpFingerprintHash = function (str, endLength) {
5 if (endLength == null) endLength = str.length;
6 var hashInt = 0;
7 for (var i=0; i < endLength; i++) {
8 hashInt += str.charCodeAt(i) * Math.pow(this.prime, i);
9 }
10 return hashInt;
11 }
12 var rks = new RabinKarpSearch();
13 rks.rabinkarpFingerprintHash("sammie"); // 1072559917336
14 rks.rabinkarpFingerprintHash("zammie"); // 1072559917343
如前面的代码块结果所示,来自 sammie 和 zammie 的散列是唯一的,因为它们是两个不同的字符串。哈希值允许您在固定时间内快速检查两个字符串是否相同。举个例子,让我们在中寻找 am 同。由于 am 只有两个字符长,当你扫描文本时, sa 、 am 和 me 由同一个组成,计算散列如下:
1 rks.rabinkarpFingerprintHash("sa"); // 9912
2 rks.rabinkarpFingerprintHash("am"); // 11106
3 rks.rabinkarpFingerprintHash("me"); // 10310
这是一个滑动哈希计算。如何高效地做到这一点?我们从数学上分析一下。回想一下,在这个例子中, x 是 101。另外, s 、 a 、 m 和 e 的字符代码分别为 115、97、109 和 101。
-
sa
:f(x)= m0+m1x = 115+(97)*(101)= 9912 -
am
:f(x)= m0+m1x = 97+(109)*(101)= 11106 -
me
:f(x)= m0+m1x = 109+(101)*(101)= 10310
要得到从 sa 到 am 的哈希值,必须减去第一项,将余数除以质数,然后加上新项。此重新计算算法在以下代码块中实现:
1 RabinKarpSearch.prototype.recalculateHash = function (str, oldIndex, newIndex, oldHash, patternLength) {
2 if (patternLength == null) patternLength = str.length;
3 var newHash = oldHash - str.charCodeAt(oldIndex);
4 newHash = Math.floor(newHash/this.prime);
5 newHash += str.charCodeAt(newIndex) * Math.pow(this.prime, patternLength - 1);
6 return newHash;
7 }
8 var oldHash = rks.rabinkarpFingerprintHash("sa"); // 9912
9 rks.recalculateHash("same", 0, 2, oldHash, "sa".length); // 11106
最后,两个不同的字符串仍然可以有相同的哈希值,尽管这不太可能。因此,在给定两个字符串的起始索引和结束索引的情况下,需要一个函数来检查两个字符串是否相等。这在下面的代码块中实现:
1 RabinKarpSearch.prototype.strEquals = function (str1, startIndex1, endIndex1,
2 str2, startIndex2, endIndex2) {
3 if (endIndex1 - startIndex1 != endIndex2 - startIndex2) {
4 return false;
5 }
6 while ( startIndex1 <= endIndex1
7 && startIndex2 <= endIndex2) {
8 if (str1[startIndex1] != str2[startIndex2]) {
9 return false;
10 }
11 startIndex1++;
12 startIndex2++;
13 }
14 return true;
15 }
然后,通过计算起始散列,然后以滑动方式重新计算散列,直到找到模式或到达字符串末尾,来实现主要的 Rabin–Karp 搜索函数。
1 RabinKarpSearch.prototype.rabinkarpSearch = function (str, pattern) {
2 var T = str.length,
3 W = pattern.length,
4 patternHash = this.rabinkarpFingerprintHash(pattern, W),
5 textHash = this.rabinkarpFingerprintHash(str, W);
6
7 for (var i = 1; i <= T - W + 1; i++) {
8 if (patternHash == textHash &&
9 this.strEquals(str, i - 1, i + W - 2, pattern, 0, W - 1)) {
10 return i - 1;
11 }
12 if (i < T - W + 1) {
13 textHash = this.recalculateHash(str, i - 1, i + W - 1, textHash, W);
14 }
15 }
16
17 return -1;
18 }
19
20 var rks = new RabinKarpSearch();
21 rks.rabinkarpSearch("SammieBae", "as"); // -1
22 rks.rabinkarpSearch("SammieBae", "Bae"); // 6
23 rks.rabinkarpSearch("SammieBae", "Sam"); // 0
预处理时间复杂度: O( W
预处理时间复杂度 W 是“单词”的长度
匹配时间复杂度: O( W + T
这个算法最多迭代长度 T 和长度 W 之和,其中 T 是要搜索的字符串。
现实生活中的应用
Rabin–Karp 算法可用于检测剽窃。对于源材料,该算法可以在提交的论文中搜索源材料中的短语和句子的实例(并通过在预处理阶段省略标点符号来忽略标点符号等语法细节)。这个问题对于单一搜索算法来说是不切实际的,因为有大量的搜索(输入)短语和句子。Rabin–Karp 算法也用于其他字符串匹配应用,例如在大量 DNA 数据中寻找特定序列。
摘要
这一章回到了字符串的主题,看了更高级的例子和字符串模式的搜索。本章讨论了几种不同的类型。
-
Trie 非常适合多重搜索和前缀模式匹配。
-
boyer–Moore 假设结尾没有匹配意味着不需要匹配开头,试图匹配模式的最后一个字符,而不是第一个字符;这允许较大的“跳跃”(索引之间的空格),当文本较大时效果更好。
-
KMP 算法通过观察当出现不匹配时,模式本身具有足够的信息来确定下一个匹配可能开始的字符串中的索引,从而在字符串中搜索模式的出现。因此,KMP 算法对小集合更好。
表 18-1 总结了不同的搜索算法。
表 18-1
单字符串搜索摘要
|算法
|
预处理时间复杂度
|
匹配时间复杂度
|
空间复杂性
|
| --- | --- | --- | --- |
| 天真的 | 没有人 | o(w′t〖 | 没有人 |
| 博伊尔-摩尔 | O( W + T | O( T / W )最佳情况 o(W∫T)最坏情况 | O(1) |
| KMP | O( W ) | O( T | O( W ) |
| 拉宾卡 rp | O( W ) | O( W + T | O(1) |
十九、动态规划
动态编程包括将问题分解成它们的子问题。通过求解最优子问题,并将这些结果保存到内存中,以便在需要解决重复问题时访问它们,算法的复杂性显著降低。实现动态编程算法需要对问题的模式进行更高层次的思考。为了解释动态编程,让我们重新检查一下在第八章中讨论过的斐波那契数列。然后这一章将介绍动态编程的规则,并通过一些例子来使概念更加具体。
动态编程的动机
斐波纳契数列的代码已经确定如下:
function getNthFibo(n) {
if (n <= 1) {
return n;
} else {
return getNthFibo(n - 1) + getNthFibo(n - 2);
}
}
getNthFibo(3);
回想一下,这个算法的递归实现是 O(2 n )。这是一个指数级的运行时间,对于现实世界的应用来说是不切实际的。经过更仔细的检查,您会注意到许多相同的计算是重复的。如图 19-1 所示,当调用 6 的getNthFibo
时,4、3、2、1 的计算重复多次。知道了这些,怎么才能让这个算法更高效呢?
图 19-1
斐波那契数的递归树
使用哈希表,一旦计算出斐波纳契数,就可以像下面的实现那样存储它:
1 var cache={};
2 function fiboBest(n){
3 if(n<=1)return n;
4 if(cache[n])return cache[n];
5 return (cache[n]=fiboBest(n-1)+fiboBest(n-2));
6 }
7 fiboBest(10); // 55
这被称为重叠子问题。计算 6 的斐波那契数列需要计算 4 和 5 的斐波那契数列。因此,5 的斐波纳契数列与第四次斐波纳契数列计算重叠。这个问题还有一个最优子结构,指的是问题的最优解包含其子问题的最优解。
有了这些,现在让我们形式化什么是动态编程。
动态规划规则
动态编程 (DP)是一种存储已经计算过的值并使用这些值来避免任何重新计算的方法(通常在递归算法中)。该方法只能应用于那些重叠子问题和最优子结构的问题。
重叠子问题
类似于递归中的分而治之,DP 结合了子问题的解决方案。当多次需要子问题的解决方案时,使用 DP。它通常将子问题的解决方案存储在哈希表、数组或矩阵中,这被称为记忆化。DP 对于解决有许多重复子问题的问题很有用。
斐波那契数列递归方法就是一个例子。可以观察到,有些数字比如 3 会被重新计算很多次。
哈希表可用于存储结果,以避免任何重新计算。这样做将时间复杂度从 O(2 n )降低到 O( n ),这是一个巨大的变化。计算 O(2 n )与一个实际上足够大的 n 可能需要几年的时间来计算。
最优子结构
最优子结构是指利用子问题的最优解可以找到问题的最优解。
例如,最短路径查找算法具有最优子结构。考虑寻找在城市间驾车旅行的最短路径。如果从洛杉矶到温哥华的最短路线经过旧金山,然后经过西雅图,那么从旧金山到温哥华的最短路线也必须经过西雅图。
示例:覆盖步骤的方法
给定一段距离, n ,计算一、二、三步走完 n 步的总数。例如,当 n =3 时,有四种组合(方式),如下所示:
-
一步,一步,一步,一步
-
一步,一步,两步
-
1 步,3 步
-
两步,两步
下面是实现计数的函数:
1 function waysToCoverSteps(step){
2 if (step<0) return 0;
3 if (step==0) return 1;
4
5 return waysToCoverSteps(step-1)+waysToCoverSteps(step-2)+waysToCoverSteps(step-3 );
6 }
7 waysToCoverSteps(12);
时间复杂度:O(3nT5)
这种递归方法具有很大的时间复杂度。要优化时间复杂度,只需缓存结果并使用它,而不是重新计算值。
1 function waysToCoverStepsDP(step) {
2 var cache = {};
3 if (step<0) return 0;
4 if (step==0) return 1;
5
6 // check if exists in cache
7 if (cache[step]) {
8 return cache[step];
9 } else {
10 cache[step] = waysToCoverStepsDP(step-1)+waysToCoverStepsDP(step-2)+waysToCoverStepsDP(step-3);
11 return cache[step];
12 }
13 }
14 waysToCoverStepsDP(12);
时间复杂度: O( n
这显示了动态编程的威力。它极大地改善了时间复杂度。
经典动态编程示例
本节将探索和解决一些经典的动态规划问题。首先要探讨的是背包问题。
背包问题
背包问题如下:
- 给定 n 重量和物品的价值,将这些物品放入一个给定容量 w 的背包中,得到背包中总价值的最大值。
最优子结构
对于数组中的每个项目,可以观察到以下情况:
-
该项目被包括在最佳子集中。
-
该项目不包括在最佳集中。
最大值必须是下列值之一:
-
(不包括第 n 项):用 n-1 项获得的最大值
-
(包括第 n 项):用 n-1 项减去第 n 项得到的最大值(只有当第 n 项的重量小于 W 时才有效)
天真的方法
简单的方法递归地实现所描述的最佳子结构,如下所示:
1 function knapsackNaive(index, weights, values, target) {
2 var result = 0;
3
4 if (index <= -1 || target <= 0) {
5 result = 0
6 } else if (weights[index] > target) {
7 result = knapsackNaive(index-1, weights, values, target);
8 } else {
9 // Case 1:
10 var current = knapsackNaive(index-1, weights, values, target)
11 // Case 2:
12 var currentPlusOther = values[index] +
13 knapsackNaive(index-1, weights, values, target - weights[index]);
14
15 result = Math.max(current, currentPlusOther);
16 }
17 return result;
18 }
19 var weights = [1,2,4,2,5],
20 values = [5,3,5,3,2],
21 target = 10;
22 knapsackNaive(4,weights, values, target);
时间复杂度:O(2nT5)
图 19-2 显示了背包容量为 2 个单位和 3 个单位重量物品的递归树。如图所示,该函数重复计算相同的子问题,并且具有指数时间复杂度。为了优化这一点,您可以得到基于项目(通过索引引用)和目标(权重: w )的结果。
图 19-2
背包递归树
动态规划方法
如前所述,下面的 DP 实现使用当前数组索引和目标作为 JavaScript 对象的键来存储背包的结果,以供以后检索。对于已经计算过的递归调用,它将使用存储的结果,这大大降低了算法的时间复杂度。
1 function knapsackDP(index, weights, values, target, matrixDP) {
2 var result = 0;
3
4 // DP part
5 if (matrixDP[index + '-' + target]){
6 return matrixDP[index + '-' + target];
7 }
8
9 if (index <= -1 || target <= 0) {
10 result = 0
11 } else if (weights[index] > target) {
12 result = knapsackDP(index - 1, weights, values, target, matrixDP);
13 } else {
14 var current = knapsackDP(index-1, weights, values, target),
15 currentPlusOther = values[index] + knapsackDP(index-1, weights, values, target - weights[index]);
16 result = Math.max(current, currentPlusOther);
17 }
18 matrixDP[index + '-' + target] = result
19 return result;
20 }
21 knapsackDP(4, weights, values, target, {});
时间复杂度: O( nw*
这里, n 是物品的数量, w 是背包的容量。
空间复杂度: O( nw*
这个算法需要一个 n 乘以 w 的组合来将缓存的结果存储在matrixDP
中。
接下来要研究的 DP 问题又是一个经典。
最长公共子序列
给定两个序列,找出最长的子序列的长度,其中子序列被定义为以相对顺序出现但不一定连续的序列。比如山姆、西、艾依等等,都是珊米的子序列。一个字符串有 2 个 n 个 可能的子序列,其中 n 是字符串的长度。
作为一个现实世界的例子,让我们考虑一个出现在生物信息学(DNA 测序)等主要领域的广义计算机科学问题。这种算法也是在版本控制和操作系统中实现 diff 功能(文件之间输出差异的文件比较)的方式。
天真的方法
设str1
为第一串长度 m ,str2
为第二串长度 n ,LCS
为函数,天真的做法可以先考虑下面的伪代码:
1\. if last characters of both sequences match (i.e. str1[m-1] == str2[n-1]):
2\. result = 1 + LCS(X[0:m-2], Y[0:n-2])
3\. if last characters of both sequences DO NOT match (i.e. str1[m-1] != str2[n-1]):
4\. result = Math.max(LCS(X[0:m-1], Y[0:n-1]),LCS(X[0:m-2], Y[0:n-2]))
考虑到这种递归结构,可以实现以下内容:
1 function LCSNaive(str1, str2, str1Length, str2Length) {
2 if (str1Length == 0 || str2Length == 0) {
3 return 0;
4 }
5
6 if (str1[str1Length-1] == str2[str2Length-1]) {
7 return 1 + LCSNaive(str1, str2,
8 str1Length - 1,
9 str2Length - 1);
10 } else {
11 return Math.max(
12 LCSNaive(str1, str2, str1Length, str2Length-1),
13 LCSNaive(str1, str2, str1Length-1, str2Length)
14 );
15 }
16 }
17
18 function LCSNaiveWrapper(str1, str2) {
19 return LCSNaive(str1, str2, str1.length, str2.length);
20 }
21 LCSNaiveWrapper('AGGTAB', 'GXTXAYB'); // 4
时间复杂度:O(2nT5)
图 19-3 显示了 SAM 和 BAE 的递归树(视觉上在 3 的高度截断)。可以看到,('SA', 'BAE')
是重复的。
图 19-3
最长公共字符串长度的递归树
动态规划方法
所描述的递归结构可以转换成一个表/缓存,其中每一行代表str1
中的一个字符,每一列代表str2
中的一个字符。矩阵中的每一项在一行 i ,一列 j 代表LCS(str1[0:i], str2[0:j])
。
1 function longestCommonSequenceLength(str1, str2) {
2 var matrix = Array(str1.length + 1).fill(Array(str2.length + 1).fill(0)),
3 rowLength = str1.length + 1,
4 colLength = str2.length + 1,
5 max = 0;
6
7 for (var row = 1; row < rowLength; row++) {
8 for (var col = 1; col < colLength; col++) {
9 var str1Char = str1.charAt(row - 1),
10 str2Char = str2.charAt(col - 1);
11
12 if (str1Char == str2Char) {
13 matrix[row][col] = matrix[row - 1][col - 1] + 1;
14 max = Math.max(matrix[row][col], max);
15 }
16 }
17 }
18 return max;
19 }
20 longestCommonSequenceLength('abcd', 'bc');
时间复杂度: O( m * n
空间复杂度: O( m * n
这里, m 是str1
的长度,n
是str2
的长度。
硬币零钱
给定一个价值/货币 n 和不同价值的每种硬币的无限供应量,S = {S1,S2,..Sm},大小为 M ,在不考虑硬币顺序的情况下,可以有多少种变化方式?
给定 N =4, M =3, S = {1,2,3},答案为 4。
1\. 1,1,1,1,
2\. 1,1,2
3\. 2,2
4\. 1,3
最优子结构
您可以观察到以下关于硬币数量的变化:
1) Solutions without Mth coin
2) Solutions with (at least) one Mth coin
假设coinChange(S, M, N)
是一个计算硬币变化次数的函数,从数学上讲,它可以通过使用前面的两个观察值重写如下:
coinChange(S, M, N) = coinChange(S, M-1, N) + coinChange(S, M, N-Sm)
天真的方法
简单的方法可以使用递归实现所描述的算法,如下所示:
1 // Returns the count of ways we can sum coinArr which have
2 // index like: [0,...,numCoins]
3 function countCoinWays(coinArr, numCoins, coinValue){
4 if (coinValue == 0) {
5 // if the value reached zero, then only solution is
6 // to not include any coin
7 return 1;
8 }
9 if (coinValue < 0 || (numCoins<=0 && coinValue >= 1)) {
10 // value is less than 0 means no solution
11 // no coins left but coinValue left also means no solution
12 return 0;
13 }
14 //
15 return countCoinWays(coinArr,numCoins-1, coinValue) +
16 countCoinWays(coinArr,numCoins, coinValue-coinArr[numCoins-1]);
17 }
18 function countCoinWaysWrapper(coinArr, coinValue) {
19 return countCoinWays(coinArr, coinArr.length, coinValue);
20 }
21 countCoinWaysWrapper([1,2,3],4);
时间复杂度:O(nm
空间复杂度: O( n
这里, m 是可用硬币种类的数量, n 是想要兑换成零钱的货币。
重叠子问题
从图 19-4 中的递归树可以看出,有许多重叠的子问题。
图 19-4
最长硬币兑换的递归树
为了解决这个问题,可以使用一个表(矩阵)来存储已经计算的结果。
动态规划方法
DP 方法的矩阵有coinValue
个行数和numCoins
个列数。在 i 和 j 的任意矩阵代表给定一个 i 的coinValue
和一个 j 的numCoins
的路数。
1 function countCoinWaysDP(coinArr, numCoins, coinValue) {
2 // creating the matrix
3 var dpMatrix = [];
4
5 for (var i=0; i <= coinValue; i++) {
6 dpMatrix[i] = [];
7 for(var j=0; j< numCoins; j++) {
8 dpMatrix[i][j] = undefined;
9 }
10 }
11
12 for (var i=0; i < numCoins; i++) {
13 dpMatrix[0][i] = 1;
14 }
15
16 for (var i=1; i < coinValue + 1; i++) {
17 for (var j=0; j < numCoins; j++) {
18 var temp1 = 0,
19 temp2 = 0;
20
21 if (i - coinArr[j] >= 0) {
22 // solutions including coinArr[j]
23 temp1 = dpMatrix[i - coinArr[j]][j];
24 }
25
26 if (j >= 1) {
27 // solutions excluding coinArr[j]
28 temp2 = dpMatrix[i][j-1];
29 }
30
31 dpMatrix[i][j] = temp1 + temp2;
32 }
33 }
34 return dpMatrix[coinValue][numCoins-1];
35 }
36
37 function countCoinWaysDPWrapper(coinArr, coinValue) {
38 return countCoinWaysDP(coinArr, coinArr.length, coinValue);
39 }
40 countCoinWaysDPWrapper([1,2,3],4);
时间复杂度: O( m * n
空间复杂度: O( m * n
这里, m 是可用硬币种类的数量, n 是想要兑换成零钱的货币。
编辑(Levenshtein)距离
编辑距离问题考虑以下因素:
- 给定一个长度为 m 的字符串(
str1
)和另一个长度为 n 的字符串(str2
),将str1
转换为str2
的最小编辑次数是多少?
有效的操作如下:
-
插入
-
移动
-
替换
最优子结构
如果从每个str1
和str2
逐个处理每个字符,则可能出现以下情况:
1\. the characters are the same:
do nothing
2\. the characters are different:
consider the cases recursively:
Insert: for m and n-1
Remove: for m-1 and n
Replace: for m-1 and n-1
天真的方法
简单的方法可以递归地实现所描述的子结构,如下所示:
1 function editDistanceRecursive(str1, str2, length1, length2) {
2 // str1 is empty. only option is insert all of str2
3 if (length1 == 0) {
4 return length2;
5 }
6 // str2 is empty. only option is insert all of str1
7 if (length2 == 0) {
8 return length1;
9 }
10
11 // last chars are same,
12 // ignore last chars and count remaining
13 if (str1[length1-1] == str2[length2-1]) {
14 return editDistanceRecursive(str1, str2,
15 length1-1, length2-1);
16 }
17
18 // last char is not the same
19 // there are three operations: insert, remove, replace
20 return 1 + Math.min (
21 // insert
22 editDistanceRecursive(str1, str2, length1, length2-1),
23 // remove
24 editDistanceRecursive(str1, str2, length1-1, length2),
25 // replace
26 editDistanceRecursive(str1, str2, length1-1, length2-1)
27 );
28 }
29
30 function editDistanceRecursiveWrapper(str1, str2) {
31 return editDistanceRecursive(str1, str2, str1.length, str2.length);
32 }
33
34 editDistanceRecursiveWrapper('sammie','bae');
时间复杂度:O(3mT5)
简单解决方案的时间复杂度是指数级的,最坏的情况是两个字符串中没有匹配的字符。这是有意义的,因为每个调用有三个调用(插入、移除、替换)。
同样,你可以看到同样的问题被一遍又一遍地解决(见图 19-5 )。这可以通过构造一个矩阵来优化,该矩阵存储子问题的已经计算的结果。
图 19-5
编辑距离的递归树
动态规划方法
动态规划方法将构建具有维度str1
和str2
的矩阵。基本情况是当 i 或 j 等于 0 时。在其他情况下,它是1 + min(insert, remove, replace)
,就像递归方法一样。
1 function editDistanceDP(str1, str2, length1, length2) {
2 // creating the matrix
3 var dpMatrix = [];
4 for(var i=0; i<length1+1; i++) {
5 dpMatrix[i] = [];
6 for(var j=0; j<length2+1; j++) {
7 dpMatrix[i][j] = undefined;
8 }
9 }
10
11 for (var i=0; i < length1 + 1; i++) {
12 for (var j=0; j < length2 + 1; j++) {
13 // if first str1 is empty,
14 // have to insert all the chars of str2
15 if (i == 0) {
16 dpMatrix[i][j] = j;
17 } else if (j == 0) {
18 dpMatrix[i][j] = i;
19 } else if (str1[i-1] == str2[j-1]) {
20 // if the same, no additional cost
21 dpMatrix[i][j] = dpMatrix[i-1][j-1];
22 } else {
23 var insertCost = dpMatrix[i][j-1],
24 removeCost = dpMatrix[i-1][j],
25 replaceCost= dpMatrix[i-1][j-1];
26
27 dpMatrix[i][j] = 1 + Math.min(insertCost,removeCost,replaceCost);
28 }
29 }
30 }
31 return dpMatrix[length1][length2];
32 }
33
34 function editDistanceDPWrapper(str1, str2) {
35 return editDistanceDP(str1, str2, str1.length, str2.length);
36 }
37
38 editDistanceDPWrapper('sammie','bae');
时间复杂度: O( m * n
空间复杂度: O( m * n
这里, m 是str1
的长度, n 是str2
的长度。
摘要
如果满足以下条件,可以利用动态规划来优化算法:
-
最优子结构:问题的最优解包含其子问题的最优解。
-
重叠子问题:子问题的解法需要多次。
为了存储已经计算出的子问题的解,通常使用矩阵或散列表;这是因为两者都提供 O(1)查找时间。这样做,时间复杂度可以从指数级(如 O(2 n ))提高到多项式时间(如 O( n 2 )。
二十、位操作
位操作是 JavaScript 开发人员通常不需要了解的高级主题。像 C 这样的低级编程语言就利用了这些运算符。但是,如果您想实现高性能的服务器端代码,您应该学习一点位操作。
理解位操作需要一些数字逻辑知识。任何离散数学或电路的入门课程都有助于理解这些概念。
按位运算符
以下是 JavaScript 中的按位运算符:
-
&:和
-
|:或者
-
~:不是
-
^:异或
-
<<:/>
-
:右移
-
:零填充右移
注意
回想一下第三章,所有的数字都用 32 位表示(意味着有 32 个 1 和 0)。将十进制数(基数为 10)转换为二进制数(基数为 2)时,记住这一点很重要。
和
当两位都为 1 时,AND
操作符为真。&
(与号)用于AND
操作符。
a b a AND b
0 0 0
0 1 0
1 0 0
1 1 1
在按位运算中,数字用二进制表示。比如二进制的 9 是1001
,二进制的 5 是101
。
对于每个位,必须执行AND
操作:
9: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1
5: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1
9 & 5: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 = 1
1 console.log(9 & 5); // prints 1
这是另一个例子:
40 in base 10 = 100010 in base 2
41 in base 10 = 100011 in base 2
40: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0
41: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 1
40 & 41: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0 = 40
运筹学
当任一位为 1 时,OR
操作符为。|
(管子)用于OR
操作器。
a b a OR b
0 0 0
0 1 1
1 0 1
1 1 1
我们以9 | 5
和40 | 41
为例。
9: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1
5: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1
9 | 5: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 1 = 13
这是另一个例子:
40: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0
41: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 1
40 & 41: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 1 = 41
异或
XOR
意为“异或”只有当其中一个位为 1 时,它才计算为真。^
(插入符号)用于XOR
操作符。
a b a XOR b
0 0 0
0 1 1
1 0 1
1 1 0
9: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1
5: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1
9 ^ 5: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 = 12
40: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0
41: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 1
40 ^ 41: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 = 1
不
NOT
运算符反转所有位。~
(波浪号)用于NOT
操作符。请不要混淆NOT
运算符和负运算符。一旦这些位反转,32 位中的数字也随之反转。
a NOT a
0 1
1 0
我们以 9 和 5 为例:
9: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1
~9: 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 0 = -10
5: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1
~5: 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 0 = -6
左移
在左移位中,所有的位都向左移位,任何向左移位的多余位都被丢弃。<<
(双左尖括号)是左移操作符。
9: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1
9 << 1: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 = 18
9 << 2: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 0 = 36
左移位通常是每次移位将元素乘以 2。这是因为二进制是以 2 为基数的系统,意味着左移等于所有数字乘以 2。但是,移位可能会导致位溢出并减少值。
1073741833: 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1
1073741833 << 2: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 0 = 36
右移
在右移位中,所有的位都向右移位,任何向右移位的多余位都被丢弃。>>
(双右尖括号)是右移操作符。
9: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1
9 >> 1: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 = 4
-9: 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1
-9 >> 2: 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 = -3
在这个例子中,移位将 9 除以 2(整数除法)。这也是因为二进制是以 2 为基数的系统。
零填充右移
在零填充右移位中,所有的位都向右移位,任何向右移位的多余位都被丢弃。然而,符号位(最左边的位)在移位前变成 0,这导致非负数。>>>
(三个右括号)是零填充右移的操作符。
-9: 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1
-9 >>> 1: 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 = 2147483643
在这个例子中,移位将 9 除以 2(整数除法)。这也是因为二进制是以 2 为基数的系统。
为了更好地理解为什么这些操作有效,建议在学校或网上学习数字逻辑入门课程。最后,一切都由 1 和 0 组成,因为计算机中的晶体管只能有两种状态:开和关。
数字运算
本节将介绍如何使用按位运算符执行加、减、乘、除和取模运算。
添加
二进制数相加和十进制数相加没什么区别。孩子们在二年级学习的规则是一样的:将两个数字相加,如果超过 10,就将 1 进位到下一个数字。
实现这一点的函数如下。你可以在 GitHub 上找到所有的代码。 1
1 function BitwiseAdd(a, b){
2 while (b != 0) {
3 var carry = (a & b);
4 a = a ^ b;
5 b = carry << 1;
6 }
7 return a;
8 }
9
10 console.log(BitwiseAdd(4,5)); // 9
这里有两个详细的例子:
bitwiseAdd(4, 5);
4: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0
5: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1
sum = 4 ^ 5 = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 = 1 (base 10)
carry = (a & b) << 1
a & b = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0
(a & b) << 1 = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 = 8 (base 10)
bitwiseAdd(1, 8);
1: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1
8: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0
sum = 1 ^ 8 = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 = 9 (base 10)
carry = (a & b) << 1
a & b = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
-> return 9 (a)
减法
减法是两个数的差。但是,你也可以把它想成是加一个负数。下面举个例子:5 - 4 = 5 + (-4)
。
因此,首先使用NOT
操作符创建一个求反函数。在二进制中,正二进制数减去负二进制数是通过反转所有位并加 1 得到的。这在下面的代码块中实现:
1 function BitwiseNegate(a) {
2 return BitwiseAdd(~a,1);
3 }
4
5 console.log(BitwiseNegate(9)); // -9
6 // negation with itself gives back original
7 console.log(BitwiseNegate(BitwiseNegate(9))); // 9
8
9 function BitwiseSubtract(a, b) {
10 return BitwiseAdd(a, BitwiseNegate(b));
11 }
12
13 console.log(BitwiseSubtract(5, 4)); // 1
增加
以 2 为基数的数字相乘遵循与以 2 为基数的数字相乘相同的逻辑;将数字相乘,将 10 以上的任何数字进位到下一位,然后将下一位与移位后的基数相乘(对于小数,每次移位都要乘以 10)。例如,12 乘以 24 的方法是先将 2 与 4 相乘,然后将 10 与 4 相乘,然后将数字移动到 2(现在是 20),再将 20 与 2 相乘,然后将 20 乘以 10。最后,将这些值相加得到 288。
12
24
------
48
24
------
288
二进制:
0 1 1 0 0
1 1 0 0 0
-----------------
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 1 1 0 0
0 1 1 0 0
-------------
1 0 0 1 0 0 0 0 0
下面的代码块说明了这种实现,它也处理负数:
1 function BitwiseMultiply(a, b) {
2 var m = 1,
3 c = 0;
4
5 if (a < 0) {
6 a = BitwiseNegate(a);
7 b = BitwiseNegate(b);
8 }
9 while (a >= m && b) {
10 if (a & m) {
11 c = BitwiseAdd(b, c);
12 }
13 b = b << 1;
14 m = m << 1;
15 }
16 return c;
17 }
18 console.log(BitwiseMultiply(4, 5)); // 20
分开
给定a
/ b
,除法可以被认为是你可以从a
中减去b
的次数。比如 4/2 = 2,因为 4-2-2 = 0。使用此属性,可以按如下方式实现按位除法:
1 function BitwiseDividePositive(a, b) {
2 var c = 0;
3
4 if (b != 0) {
5 while (a >= b) {
6 a = BitwiseSubtract(a, b);
7 c++;
8 }
9 }
10 return c;
11 }
12 console.log(BitwiseDividePositive(10, 2)); // 5
这对于正数来说相对简单。while
循环可以一直减法,一个计数器变量可以存储b
减去了多少次a
。然而,对于负数呢?-10 /2 = -5,但是我们不能从-10 中减去 2,因为while
循环会永远继续下去。为了避免这种情况,请将这两个数字都转换为正数。这样做的时候,我们必须跟踪标志。
a b a * b
+ + +
+ - -
- + -
- - +
如果负数表示为 1,正数表示为 0,则该表与XOR
表相同:
a b a * b
0 0 0
0 1 1
1 0 1
1 1 0
除法算法如下所示。该函数从a
中减去b
,直到为零。同样,负数必须在最后用一个否定辅助函数进行适当的处理。
1 function BitwiseDivide(a, b) {
2 var c = 0,
3 isNegative = 0;
4
5 if (a < 0) {
6 a = BitwiseNegate(a); // convert to positive
7 isNegative = !isNegative;
8 }
9
10 if (b < 0) {
11 b = BitwiseNegate(b); // convert to positive
12 isNegative = !isNegative;
13 }
14
15 if (b != 0) {
16 while (a >= b) {
17 a = BitwiseSubtract(a, b);
18 c++;
19 }
20 }
21
22 if (isNegative) {
23 c = BitwiseNegate(c);
24 }
25
26 return c;
27 }
28
29 console.log(BitwiseDivide(10, 2)); // 5
30 console.log(BitwiseDivide(-10, 2)); // -5
31 console.log(BitwiseDivide(-200, 4)); // -50
摘要
本章讲述了 JavaScript 中位操作的基础知识。位操作用于高性能的数值运算。使用位操作符比在Math
类中使用本地方法要快得多。随着 JavaScript 通过 Node.js 进入服务器端编程,需要更高效的代码。为了巩固本章的概念,表 20-1 总结了按位运算符及其用法。
表 20-1
位操作摘要
|操作员
|
操作
|
用例
|
| --- | --- | --- |
| &
| AND
| 两位都为 1 时为 1 |
| |
| OR
| 任一位为 1 时为 1 |
| 你是谁 | NOT
| 反转所有位 |
| ^
| XOR
| 1 当只有位之一为 1 时 |
| <<
| 左移 | 向左移位,任何多余的位都被移出 |
| >>
| 右移 | 向右移位,任何多余的位都被移出 |
| >>>
| 零填充右移 | 向右移位,任何多余的位被移出,符号位变为 0 |
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
2023-08-19 赏味不足:详细来聊下轻资产运作,我从不做重资产
2023-08-19 老隋:什么赚钱就做什么,记住轻资产运营,试错成本低
2023-08-19 iBooker 技术评论 20230819:打工是风险最高的事情
2023-08-19 卓钥商学苑:创业期间被合伙人背叛了怎么办?处理方式有哪些?