JavaScript-数据结构和算法实用手册-全-

JavaScript 数据结构和算法实用手册(全)

原文:zh.annas-archive.org/md5/929680AA3DCF1ED8FDD0EBECC6F0F541

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的主要重点是使用 JavaScript 在真实的 Web 应用程序中应用数据结构和算法。

随着 JavaScript 进入服务器端,并且单页应用程序(SPA)框架接管客户端,很多,如果不是全部,业务逻辑都被移植到了客户端。这使得使用手工制作的数据结构和算法对于特定用例至关重要。

例如,在处理数据可视化(如图表、图形和 3D 或 4D 模型)时,可能会有数以万计甚至数十万个复杂对象从服务器提供,有时几乎是实时的。处理这些数据的方式有多种,这就是我们将要探讨的,配以真实世界的例子。

这本书适合谁

这本书适合对 HTML、CSS 和 JavaScript 有兴趣和基本知识的任何人。我们还将使用 Node.js、Express 和 Angular 来创建一些利用我们的数据结构的 Web 应用程序和 API。

本书涵盖了什么

第一章,“构建堆栈管理应用程序状态”,介绍了构建和使用堆栈,例如为应用程序创建自定义返回按钮以及在线 IDE 的语法解析器和评估器。

第二章,“为顺序执行创建队列”,演示了使用队列及其变体来创建一个能够处理消息失败的消息服务。然后,我们对不同类型的队列进行了快速比较。

第三章,“使用集合和映射加速应用程序”,使用集合和映射创建键盘快捷方式以在应用程序状态之间导航。然后,我们创建了一个自定义应用程序跟踪器,用于记录 Web 应用程序的分析信息。最后,我们对集合和映射与数组和对象进行了性能比较。

第四章,“使用树加速查找和修改”,利用树数据结构构建了一个自动完成组件。然后,我们创建了一个信用卡批准预测器,根据历史数据确定信用卡申请是否会被接受。

第五章,“使用图简化复杂应用程序”,讨论了图,并附有示例,例如为职业门户创建参考生成器以及在社交媒体网站上的朋友推荐系统。

第六章,“探索各种类型的算法”,探讨了一些最重要的算法,如 Dijkstra 算法、0/1 背包问题、贪婪算法等。

第七章,“排序及其应用”,探讨了归并排序、插入排序和快速排序,并附有示例。然后,我们对它们进行了性能比较。

第八章,“大 O 符号、空间和时间复杂度”,讨论了表示复杂性的符号,然后讨论了空间和时间复杂度以及它们如何影响我们的应用程序。

第九章,“微优化和内存管理”,探讨了 HTML、CSS、JavaScript 的最佳实践,然后讨论了 Google Chrome 的一些内部工作原理,以及我们如何利用它更好地和更快地渲染我们的应用程序。

充分利用本书

  • JavaScript、HTML 和 CSS 的基本知识

  • 已安装 Node.js(nodejs.org/en/download/

  • 安装 WebStorm IDE(www.jetbrains.com/webstorm/download)或类似软件

  • 下一代浏览器,如 Google Chrome (www.google.com/chrome/browser/desktop/)

  • 熟悉 Angular 2.0 或更高版本是一个优势,但不是必需的

  • 本书中的屏幕截图是在 macOS 上拍摄的。对于任何其他操作系统的用户,可能会有一些差异(如果有的话)。但是,无论操作系统如何,代码示例都将运行而不会出现任何差异。在任何我们指定CMD/cmd/command的地方,请在 Windows 对应的地方使用CTRL/ctrl/control键。如果看到return,请使用Enter,如果看到术语terminal/Terminal,请在 Windows 上使用其等效的command prompt

  • 在本书中,代码库是随着主题的进展逐步构建的。因此,当您将代码示例的开头与 GitHub 中的代码库进行比较时,请注意 GitHub 中的代码是您所参考的主题或示例的最终形式。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择 SUPPORT 选项卡。

  3. 点击 Code Downloads & Errata。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩软件解压文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Practical-JavaScript-Data-Structures-and-Algorithms。我们还有来自丰富书籍和视频目录的其他代码包可供下载,网址为github.com/PacktPublishing/。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载它:

www.packtpub.com/sites/default/files/downloads/HandsOnDataStructuresandAlgorithmswithJavaScript_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“本地数组操作的时间复杂度各不相同。让我们来看一下Array.prototype.spliceArray.prototype.push。”

代码块设置如下:

class Stack {
    constructor() {

    }
}

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

var express = require('express');
var app = express();
var data = require('./books.json');
var Insertion = require('./sort/insertion');

任何命令行输入或输出都以以下形式书写:

ng new back-button

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。这是一个例子:“当用户点击back按钮时,我们将从堆栈中导航到应用程序的上一个状态。”

警告或重要提示会出现在这样的形式中。提示和技巧会出现在这样的形式中。

第一章:构建应用程序状态管理的堆栈

堆栈是我们可以想到的最常见的数据结构之一。它们在个人和专业设置中无处不在。堆栈是一种后进先出LIFO)的数据结构,提供一些常见操作,如推送、弹出、查看、清除和大小。

在大多数面向对象编程OOP)语言中,您会发现堆栈数据结构是内置的。另一方面,JavaScript 最初是为网络设计的;它没有内置堆栈。但是,不要让这阻止您。使用 JS 创建堆栈非常容易,而且使用最新版本的 JavaScript 可以进一步简化这一过程。

在本章中,我们的目标是了解堆栈在新时代网络中的重要性以及它们在简化不断发展的应用程序中的作用。让我们探索堆栈的以下方面:

  • 对堆栈的理论理解

  • 它的 API 和实现

  • 在现实世界网络中的用例

在我们开始构建堆栈之前,让我们看一下我们希望堆栈具有的一些方法,以便行为符合我们的要求。必须自己创建 API 是一种幸事。你永远不必依赖别人的库做得对,甚至担心任何缺失的功能。您可以添加所需的内容,直到需要为止,不必担心性能和内存管理。

先决条件

以下是以下章节的要求:

本章中所示代码示例的代码样本可以在github.com/NgSculptor/examples找到。

术语

在本章中,我们将使用以下与堆栈相关的术语,让我们更多地了解它:

  • 顶部:指示堆栈的顶部

  • 基底:指示堆栈的底部

API

这是棘手的部分,因为很难预测应用程序将需要哪些方法。因此,通常最好的做法是从正常情况开始,然后根据应用程序的需求进行更改。按照这种方式,您最终会得到一个看起来像这样的 API:

  • 推送:将项目推送到堆栈的顶部

  • 弹出:从堆栈的顶部移除一个项目

  • 窥视:显示推送到堆栈中的最后一个项目

  • 清除:清空堆栈

  • 大小:获取堆栈的当前大小

我们难道没有数组吗?

到目前为止,您可能会想知道为什么首先需要堆栈。它与数组非常相似,我们可以在数组上执行所有这些操作。那么,拥有堆栈的真正目的是什么?

更喜欢堆栈而不是数组的原因有很多:

  • 使用堆栈为您的应用程序提供更语义化的含义。考虑这样一个类比,您有一个背包(一个数组)和一个钱包(一个堆栈)。您可以在背包和钱包中都放钱吗?当然可以;但是,当您看着背包时,您不知道里面可能会找到什么,但是当您看着钱包时,您非常清楚它里面装着钱。它装着什么样的钱(即数据类型),比如美元、印度卢比和英镑,目前还不清楚(除非您从 TypeScript 获得支持)。

  • 本机数组操作具有不同的时间复杂度。例如,让我们看一下Array.prototype.spliceArray.prototype.push。例如,Splice的最坏时间复杂度为 O(n),因为它必须搜索所有索引并在从数组中剪切元素时进行调整。Push在内存缓冲区已满时具有最坏情况的复杂度为 O(n),但是摊销为 O(1)。堆栈避免直接访问元素,并在内部依赖于WeakMap(),这在内存上是高效的,您很快就会看到。

创建一个堆栈

现在我们知道何时以及为什么要使用堆栈,让我们继续实现一个。正如前一节中讨论的,我们将使用WeakMap()进行实现。您可以使用任何本机数据类型进行实现,但是有一些原因使WeakMap()成为一个强有力的竞争者。WeakMap()对其持有的键保留了弱引用。这意味着一旦您不再引用特定的键,它将与值一起被垃圾回收。然而,WeakMap()也有其自身的缺点:键只能是非原始类型,并且不可枚举,也就是说,您无法获取所有键的列表,因为它们依赖于垃圾回收器。然而,在我们的情况下,我们更关心WeakMap()持有的值,而不是键和它们的内部内存管理。

实现堆栈方法

实现堆栈是一个相当简单的任务。我们将遵循一系列步骤,其中我们将使用 ES6 语法,如下所示:

  1. 定义一个constructor
class Stack {
    constructor() {

    }
}
  1. 创建一个WeakMap()来存储堆栈项:
const sKey = {};
const items = new WeakMap();

class Stack {
 constructor() {
 items.set(sKey, [])
    }
}
  1. Stack类中实现前面 API 中描述的方法:
const sKey = {};
const items = new WeakMap();

class Stack {
 constructor() {
 items.set(sKey, []);
    }

 push(element) {
 let stack = items.get(sKey);
 stack.push(element);
    }

 pop() {
 let stack = items.get(sKey)
 return stack.pop()
    }

 peek() {
 let stack = items.get(sKey);
 return stack[stack.length - 1];
    }

 clear() {
 items.set(sKey, []);
    }

 size() {
 return items.get(sKey).length;
    }
}
  1. 因此,Stack的最终实现将如下所示:
var Stack = (() => {
 const sKey = {};
 const items = new WeakMap();

 class Stack {

 constructor() {
 items.set(sKey, []);
        }

 push(element) {
 let stack = items.get(sKey);
 stack.push(element);
        }

 pop() {
 let stack = items.get(sKey);
 return stack.pop();
        }

 peek() {
 let stack = items.get(sKey);
 return stack[stack.length - 1];
        }

 clear() {
 items.set(sKey, []);
        }

 size() {
 return items.get(sKey).length;
        }
    }

 return Stack;
})();

这是 JavaScript 堆栈的一个全面实现,这绝不是全面的,可以根据应用程序的要求进行更改。然而,让我们通过这个实现中采用的一些原则。

我们在这里使用了WeakMap(),正如前面的段萀中所解释的,它有助于根据对堆栈项的引用进行内部内存管理。

另一件重要的事情要注意的是,我们已经将Stack类包装在 IIFE 中,因此itemssKey常量在Stack类内部是可用的,但不会暴露给外部世界。这是当前 JSClass实现的一个众所周知和有争议的特性,它不允许声明类级变量。TC39 基本上设计了 ES6 类,使其只定义和声明其成员,这些成员在 ES5 中是原型方法。此外,由于向原型添加变量不是常规做法,因此没有提供创建类级变量的能力。然而,人们仍然可以做到以下几点:

 constructor() {
        this.sKey = {};
        this.items = new WeakMap();
 this.items.set(sKey, []);
    }

然而,这将使items也可以从我们的Stack方法外部访问,这是我们想要避免的。

测试堆栈

为了测试我们刚刚创建的Stack,让我们实例化一个新的堆栈,并调用每个方法,看看它们如何向我们呈现数据:

var stack = new Stack();
stack.push(10);
stack.push(20);

console.log(stack.items); // prints undefined -> cannot be accessed directly   console.log(stack.size()); // prints 2

console.log(stack.peek()); // prints 20   console.log(stack.pop()); // prints 20   console.log(stack.size()); // prints 1   stack.clear();

console.log(stack.size()); // prints 0 

当我们运行上面的脚本时,我们会看到如上面的注释中指定的日志。正如预期的那样,堆栈在每个操作阶段提供了看似预期的输出。

使用堆栈

使用之前创建的Stack类,您需要进行一些微小的更改,以允许根据您计划使用的环境来使用堆栈。使这种更改通用相当简单;这样,您就不需要担心支持多个环境,并且可以避免在每个应用程序中重复编写代码:

// AMD
if (typeof define === 'function' && define.amd) {

    define(function () { return Stack; });

// NodeJS/CommonJS

} else if (typeof exports === 'object') {

    if (typeof module === 'object' && typeof module.exports ===
    'object') {

        exports = module.exports = Stack;
    }

// Browser

} else {

    window.Stack = Stack;
}

一旦我们将这个逻辑添加到堆栈中,它就可以在多个环境中使用。为了简单和简洁起见,我们不会在看到堆栈的每个地方都添加它;然而,一般来说,在您的代码中拥有这个功能是件好事。

如果您的技术堆栈包括 ES5,则需要将先前的堆栈代码转译为 ES5。这不是问题,因为在线有大量选项可用于将代码从 ES6 转译为 ES5。

用例

现在我们已经实现了一个Stack类,让我们看看如何在一些 Web 开发挑战中使用它。

创建一个 Angular 应用程序

为了探索堆栈在 Web 开发中的一些实际应用,我们将首先创建一个 Angular 应用程序,并将其用作基础应用程序,我们将用于后续用例。

从最新版本的 Angular 开始非常简单。您只需要预先在系统中安装 Node.js。要测试您的计算机上是否安装了 Node.js,请转到 Mac 上的终端或 Windows 上的命令提示符,并键入以下命令:

node -v

这应该会显示已安装的 Node.js 版本。如果您看到以下内容:

node: command not found

这意味着您的计算机上没有安装 Node.js。

一旦您在计算机上安装了 Node.js,您就可以访问npm,也称为 node 包管理器命令行工具,它可以用于设置全局依赖项。使用npm命令,我们将安装 Angular CLI 工具,该工具为我们提供了许多 Angular 实用方法,包括但不限于创建新项目。

安装 Angular CLI

要在您的终端中安装 Angular CLI,请运行以下命令:

npm install -g @angular/cli

这将全局安装 Angular CLI 并让您访问ng命令以创建新项目。

要测试它,您可以运行以下命令,这应该会显示可用于使用的功能列表:

ng

使用 CLI 创建应用程序

现在,让我们创建 Angular 应用程序。为了清晰起见,我们将为每个示例创建一个新应用程序。如果您感到舒适,您可以将它们合并到同一个应用程序中。要使用 CLI 创建 Angular 应用程序,请在终端中运行以下命令:

ng new <project-name>

project-name替换为您的项目名称;如果一切顺利,您应该在终端上看到类似的东西:

 installing ng
 create .editorconfig
 create README.md
 create src/app/app.component.css
 create src/app/app.component.html
 create src/app/app.component.spec.ts
 create src/app/app.component.ts
 create src/app/app.module.ts
 create src/assets/.gitkeep
 create src/environments/environment.prod.ts
 create src/environments/environment.ts
 create src/favicon.ico
 create src/index.html
 create src/main.ts
 create src/polyfills.ts
 create src/styles.css
 create src/test.ts
 create src/tsconfig.app.json
 create src/tsconfig.spec.json
 create src/typings.d.ts
 create .angular-cli.json
 create e2e/app.e2e-spec.ts
 create e2e/app.po.ts
 create e2e/tsconfig.e2e.json
 create .gitignore
 create karma.conf.js
 create package.json
 create protractor.conf.js
 create tsconfig.json
 create tslint.json
 Installing packages for tooling via npm.
 Installed packages for tooling via npm.
 Project 'project-name' successfully created.

如果遇到任何问题,请确保您已按前面所述安装了 angular-cli。

在为此应用程序编写任何代码之前,让我们将先前创建的堆栈导入项目中。由于这是一个辅助组件,我希望将其与其他辅助方法一起分组到应用程序根目录下的utils目录中。

创建一个堆栈

由于现在 Angular 应用程序的代码是 TypeScript,我们可以进一步优化我们创建的堆栈。使用 TypeScript 使代码更易读,因为可以在 TypeScript 类中创建private变量。

因此,我们优化后的 TypeScript 代码看起来像以下内容:

export class Stack {
 private wmkey = {};
 private items = new WeakMap();

 constructor() {
 this.items.set(this.wmkey, []);
    }

 push(element) {
 let stack = this.items.get(this.wmkey);
 stack.push(element);
    }

 pop() {
 let stack = this.items.get(this.wmkey);
 return stack.pop();
    }

 peek() {
 let stack = this.items.get(this.wmkey);
 return stack[stack.length - 1];
    }

 clear() {
 this.items.set(this.wmkey, []);
    }

 size() {
 return this.items.get(this.wmkey).length;
    }
}

要使用先前创建的Stack,您只需将堆栈导入任何组件,然后使用它。您可以在以下截图中看到,由于我们将WeakMap()Stack类的 keyprivate 成员,它们不再可以从类外部访问:

从 Stack 类中访问的公共方法

为 Web 应用程序创建自定义返回按钮

如今,Web 应用程序都关注用户体验,采用扁平设计和小负载。每个人都希望他们的应用程序快速而紧凑。使用笨重的浏览器返回按钮正在逐渐成为过去的事情。要为我们的应用程序创建自定义返回按钮,我们首先需要从先前安装的ngcli 客户端创建一个 Angular 应用程序,如下所示:

ng new back-button

设置应用程序及其路由

现在我们已经设置了基本代码,让我们列出构建应用程序的步骤,以便我们能够在浏览器中创建自定义返回按钮:

  1. 为应用程序创建状态。

  2. 记录应用程序状态更改时的情况。

  3. 检测我们自定义返回按钮的点击。

  4. 更新正在跟踪的状态列表。

让我们快速向应用程序添加一些状态,这些状态也被称为 Angular 中的路由。所有 SPA 框架都有某种形式的路由模块,您可以使用它来为应用程序设置一些路由。

一旦我们设置了路由和路由设置,我们将得到以下目录结构:

添加路由后的目录结构

现在让我们设置导航,以便我们可以在各个路由之间切换。要在 Angular 应用程序中设置路由,您需要创建要路由到的组件以及该特定路由的声明。因此,例如,您的home.component.ts将如下所示:

import { Component } from '@angular/core';

@Component({
    selector: 'home',
    template: 'home page' })
export class HomeComponent {

}

home.routing.ts文件将如下所示:

import { HomeComponent } from './home.component';

export const HomeRoutes = [
    { path: 'home', component: HomeComponent },
];

export const HomeComponents = [
    HomeComponent
];

我们可以为所需的路由设置类似的配置,并一旦设置完成,我们将创建一个应用程序级文件用于应用程序路由,并在该文件中注入所有路由和navigatableComponents,以便我们不必一遍又一遍地触及我们的主模块。

因此,您的app.routing.ts文件将如下所示:

import { Routes } from '@angular/router';
import {AboutComponents, AboutRoutes} from "./pages/about/about.routing";
import {DashboardComponents, DashboardRoutes} from "./pages/dashboard/dashboard.routing";
import {HomeComponents, HomeRoutes} from "./pages/home/home.routing";
import {ProfileComponents, ProfileRoutes} from "./pages/profile/profile.routing";

export const routes: Routes = [
    {
 path: '',
 redirectTo: '/home',
 pathMatch: 'full'
  },
    ...AboutRoutes,
    ...DashboardRoutes,
    ...HomeRoutes,
    ...ProfileRoutes ];

export const navigatableComponents = [
    ...AboutComponents,
    ...DashboardComponents,
    ...HomeComponents,
    ...ProfileComponents ];

在这里,您会注意到我们正在做一些特别有趣的事情:

{
 path: '',
 redirectTo: '/home',
 pathMatch: 'full' }

这是 Angular 设置默认路由重定向的方式,因此当应用程序加载时,它会直接转到/home路径,我们不再需要手动设置重定向。

检测应用程序状态更改

幸运的是,我们可以使用 Angular 路由器的更改事件来检测状态更改,并根据此进行操作。因此,在您的app.component.ts中导入Router模块,然后使用它来检测任何状态更改:

import { Router, NavigationEnd } from '@angular/router';
import { Stack } from './utils/stack';

...
...

constructor(private stack: Stack, private router: Router) {

    // subscribe to the routers event
 this.router.events.subscribe((val) => {

        // determine of router is telling us that it has ended
        transition
 if(val instanceof NavigationEnd) {

            // state change done, add to stack
 this.stack.push(val);
        }
    });
}

用户采取的任何导致状态更改的操作现在都被保存到我们的堆栈中,我们可以继续设计我们的布局和过渡状态的返回按钮。

布局 UI

我们将使用 angular-material 来为应用程序设置样式,因为它快速可靠。要安装angular-material,运行以下命令:

npm install --save @angular/material @angular/animations @angular/cdk

一旦将 angular-material 保存到应用程序中,我们可以使用提供的Button组件来创建所需的 UI,这将非常简单。首先,导入我们想要在此视图中使用的MatButtonModule,然后将该模块注入到主AppModule中作为依赖项。

app.module.ts的最终形式将如下所示:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material';

import { AppComponent } from './app.component';
import { RouterModule } from "@angular/router";
import { routes, navigatableComponents } from "./app.routing";
import { Stack } from "./utils/stack";

// main angular module
@NgModule({
 declarations: [
        AppComponent,

        // our components are imported here in the main module
        ...navigatableComponents
    ],
 imports: [
        BrowserModule,
        FormsModule,
        HttpModule,

        // our routes are used here
        RouterModule.forRoot(routes),
        BrowserAnimationsModule,

 // material module  MatButtonModule
    ],
 providers: [
        Stack
    ],
 bootstrap: [AppComponent]
})
export class AppModule { }

我们将在顶部放置四个按钮,用于在我们创建的四个状态之间切换,然后在router-outlet指令中显示这些状态,然后是返回按钮。完成所有这些后,我们将得到以下结果:

<nav>
    <button mat-button 
        routerLink="/about" 
        routerLinkActive="active">
      About
    </button>
    <button mat-button 
        routerLink="/dashboard" 
        routerLinkActive="active">
      Dashboard
    </button>
    <button mat-button 
        routerLink="/home" 
        routerLinkActive="active">
      Home
    </button>
    <button mat-button 
        routerLink="/profile" routerLinkActive="active">
      Profile
    </button>
</nav>

<router-outlet></router-outlet>

<footer>
    <button mat-fab (click)="goBack()" >Back</button>
</footer>

在各个状态之间导航

从这里开始为返回按钮添加逻辑相对较简单。当用户点击返回按钮时,我们将从堆栈中导航到应用程序的上一个状态。如果堆栈在用户点击返回按钮时为空,这意味着用户处于起始状态,则我们将其放回堆栈,因为我们执行pop()操作来确定堆栈的当前状态。

goBack() {
 let current = this.stack.pop();
 let prev = this.stack.peek();

 if (prev) {
 this.stack.pop();

        // angular provides nice little method to 
        // transition between the states using just the url if needed.
 this.router.navigateByUrl(prev.urlAfterRedirects);

    } else {
 this.stack.push(current);
    }
}

请注意,我们在这里使用urlAfterRedirects而不是普通的url。这是因为我们不关心特定 URL 在达到最终形式之前经历了多少跳转,因此我们可以跳过它之前遇到的所有重定向路径,并直接将用户发送到重定向后的最终 URL。我们只需要最终状态,以便将用户导航到他们之前所在的状态,因为那是他们导航到当前状态之前所在的位置。

最终应用程序逻辑

因此,现在我们的应用程序已经准备就绪。我们已经添加了堆栈正在导航到的状态的逻辑,并且我们还有用户点击返回按钮时的逻辑。当我们将所有这些逻辑放在我们的app.component.ts中时,我们将得到以下内容:

import {Component, ViewEncapsulation} from '@angular/core';
import {Router, NavigationEnd} from '@angular/router';
import {Stack} from "./utils/stack";

@Component({
 selector: 'app-root',
 templateUrl: './app.component.html',
 styleUrls: ['./app.component.scss', './theme.scss'],
 encapsulation: ViewEncapsulation.None })
export class AppComponent {
 constructor(private stack: Stack, private router: Router) {
 this.router.events.subscribe((val) => {
 if(val instanceof NavigationEnd) {
 this.stack.push(val);
            }
        });
    }

 goBack() {
 let current = this.stack.pop();
 let prev = this.stack.peek();

 if (prev) {
 this.stack.pop();
 this.router.navigateByUrl(prev.urlAfterRedirects);
        } else {
 this.stack.push(current);
        }
    }
}

我们还有一些在应用程序中使用的辅助样式表。这些样式表基于您的应用程序和产品的整体品牌;在这种情况下,我们选择了一些非常简单的东西。

对于 AppComponent 的样式,我们可以在app.component.scss中添加组件特定的样式:

.active {
  color: red !important;
}

对于应用程序的整体主题,我们将在theme.scss文件中添加样式:

@import '~@angular/material/theming';
// Plus imports for other components in your app.   // Include the common styles for Angular Material. We include this here so that you only // have to load a single css file for Angular Material in your app. // Be sure that you only ever include this mixin once! @include mat-core();

// Define the palettes for your theme using the Material Design palettes available in palette.scss // (imported above). For each palette, you can optionally specify a default, lighter, and darker // hue. $candy-app-primary: mat-palette($mat-indigo);
$candy-app-accent:  mat-palette($mat-pink, A200, A100, A400);

// The warn palette is optional (defaults to red). $candy-app-warn:    mat-palette($mat-red);

// Create the theme object (a Sass map containing all of the palettes). $candy-app-theme: mat-light-theme($candy-app-primary, $candy-app-accent, $candy-app-warn);

// Include theme styles for core and each component used in your app. // Alternatively, you can import and @include the theme mixins for each component // that you are using. @include angular-material-theme($candy-app-theme);

这个前面的主题文件取自 Angular 材料设计文档,并可以根据您的应用程序的颜色方案进行更改。

一旦我们准备好所有的更改,我们可以通过从应用程序的根文件夹运行以下命令来运行我们的应用程序:

ng serve

这将启动应用程序,可以通过http://localhost:4200访问。

从上面的截图中,我们可以看到应用程序正在运行,并且我们可以使用我们刚刚创建的返回按钮在不同的状态之间导航。

构建基本 JavaScript 语法解析器和评估器的一部分

该应用程序的主要目的是在计算密集的环境中展示多个堆栈的并发使用。我们将解析和评估表达式,并生成它们的结果,而不必使用 eval。

例如,如果你想构建自己的plnkr.co或类似的东西,你需要在更深入了解复杂的解析器和词法分析器之前,采取类似的步骤,这些解析器和词法分析器用于全面的在线编辑器。

我们将使用与之前描述的类似的基本项目。要使用 angular-cli 创建新应用程序,我们将使用之前安装的 CLI 工具。在终端中运行以下命令来创建应用程序:

ng new parser

构建基本的 Web Worker

一旦我们创建并实例化了应用程序,我们将首先使用以下命令从应用程序的根目录创建worker.js文件:

cd src/app
mkdir utils
touch worker.js

这将在utils文件夹中生成worker.js文件。

请注意以下两点:

  • 这是一个简单的 JS 文件,而不是一个 TypeScript 文件,尽管整个应用程序都是用 TypeScript 编写的。

  • 它被称为worker.js,这意味着我们将为我们即将执行的解析和评估创建一个 Web Worker

Web Worker 用于模拟 JavaScript 中的多线程的概念,这通常不是情况。此外,由于此线程运行在隔离中,我们无法为其提供依赖项。这对我们来说非常有利,因为我们的主应用程序只会在每次按键时接受用户的输入并将其传递给 worker,而工作人员的责任是评估这个表达式并返回结果或必要时返回错误。

由于这是一个外部文件,而不是标准的 Angular 文件,我们将不得不将其作为外部脚本加载,以便我们的应用程序随后可以使用它。为此,打开您的.angular-cli.json文件,并更新scripts选项如下所示:

...
"scripts": [
  "app/utils/worker.js" ],
...

现在,我们将能够使用注入的 worker,如下所示:

this.worker = new Worker('scripts.bundle.js');

首先,我们将对app.component.ts文件进行必要的更改,以便它可以根据需要与worker.js进行交互。

布局 UI

我们将再次使用 angular-material,就像在前面的示例中描述的那样。因此,安装并使用组件,以便根据需要为应用程序的 UI 添加样式:

npm install --save @angular/material @angular/animations @angular/cdk

我们将使用MatGridListModule来创建应用程序的 UI。在主模块中导入它后,我们可以创建以下模板:

<mat-grid-list cols="2" rowHeight="2:1">
    <mat-grid-tile>
        <textarea (keyup)="codeChange()" [(ngModel)]="code"></textarea>
    </mat-grid-tile>
    <mat-grid-tile>
        <div>
            Result: {{result}}
        </div>
    </mat-grid-tile>
</mat-grid-list>

我们正在铺设两个瓷砖;第一个包含textarea用于编写代码,第二个显示生成的结果。

我们将输入区域与ngModel绑定,这将为我们的视图和组件之间提供双向绑定。此外,我们利用keyup事件来触发名为codeChange()的方法,该方法将负责将我们的表达式传递给 worker。

codeChange()方法的实现将相对容易。

基本 Web Worker 通信

组件加载时,我们将希望设置工作线程,以便不必多次重复。因此,想象一下,如果有一种方法可以有条件地设置并仅在需要时执行操作。在我们的情况下,您可以将其添加到构造函数或任何生命周期挂钩中,这些挂钩表示组件所处的阶段,例如OnInitOnContentInitOnViewInit等,这些由 Angular 提供如下:

this.worker = new Worker('scripts.bundle.js');

this.worker.addEventListener('message', (e) => {
 this.result = e.data;
});

初始化后,我们使用addEventListener()方法来监听任何新消息,即来自工作线程的结果。

每当代码更改时,我们只需将数据传递给我们现在设置的工作线程。这样的实现如下所示:

codeChange() {
 this.worker.postMessage(this.code);
}

正如您所注意到的,主应用程序组件是有意保持简洁的。我们利用工作线程的唯一原因是,CPU 密集型操作可以远离主线程。在这种情况下,我们可以将所有逻辑,包括验证,移动到工作线程中,这正是我们所做的。

启用 Web Worker 通信

现在,应用程序组件已经设置并准备好发送消息,工作线程需要启用以接收来自主线程的消息。为此,请将以下代码添加到您的worker.js文件中:

init();

function init() {
   self.addEventListener('message', function(e) {
      var code = e.data;

      if(typeof code !== 'string' || code.match(/.*[a-zA-Z]+.*/g)) {
         respond('Error! Cannot evaluate complex expressions yet. Please try
         again later');
      } else {
         respond(evaluate(convert(code)));
      }
   });
}

如您所见,我们增加了监听可能发送到工作线程的任何消息的功能,然后工作线程只需获取该数据并在尝试评估并返回表达式的任何值之前对其进行基本验证。在我们的验证中,我们只拒绝了任何字母字符,因为我们希望用户只提供有效的数字和运算符。

现在,使用以下命令启动应用程序:

npm start

您应该在localhost:4200上看到应用程序启动。现在,只需输入任何代码来测试您的应用程序;例如,输入以下内容:

var a = 100;

您将看到以下错误弹出在屏幕上:

现在,让我们详细了解正在进行的算法。算法将分为两部分:解析和评估。算法的逐步分解如下:

  1. 将输入表达式转换为机器可理解的表达式。

  2. 评估后缀表达式。

  3. 将表达式的值返回给父组件。

将输入转换为机器可理解的表达式

输入(用户输入的任何内容)将是中缀表示法中的表达式,这是人类可读的。例如:

(1 + 1) * 2

但是,这并不是我们可以直接评估的内容,因此我们将其转换为后缀表示法或逆波兰表示法。

将中缀表达式转换为后缀表达式是需要一点时间来适应的。我们在维基百科中有一个简化版本的算法,如下所示:

  1. 获取输入表达式(也称为中缀表达式)并对其进行标记化,即拆分。

  2. 迭代评估每个标记,如下所示:

  3. 如果遇到数字,则将标记添加到输出字符串(也称为后缀表示法)中

  4. 如果是(,即左括号,则将其添加到输出字符串中。

  5. 如果是),即右括号,则将所有运算符弹出,直到前一个左括号为止,然后将其添加到输出字符串中。

  6. 如果字符是运算符,即*^+-/,,则在将其从堆栈中弹出之前,首先检查运算符的优先级。

  7. 弹出标记化列表中的所有剩余运算符。

  8. 返回结果输出字符串或后缀表示法。

在将其转换为一些代码之前,让我们简要讨论一下运算符的优先级和结合性,这是我们需要预先定义的内容,以便在将中缀表达式转换为后缀表达式时使用。

优先级,顾名思义,确定了特定运算符的优先级,而结合性则决定了在没有括号的情况下表达式是从左到右还是从右到左进行评估。根据这一点,由于我们只支持简单的运算符,让我们创建一个运算符、它们的优先级结合性的映射:

var operators = {
 "^": {
 priority: 4,
 associativity: "rtl" // right to left
    },
 "*": {
 priority: 3,
 associativity: "ltr" // left to right
    },
 "/": {
 priority: 3,
 associativity: "ltr"
    },
 "+": {
 priority: 2,
 associativity: "ltr"
    },
 "-": {
 priority: 2,
 associativity: "ltr"
    }
};

现在,按照算法,第一步是对输入字符串进行标记化。考虑以下示例:

(1 + 1) * 2

它将被转换如下:

["(", "1", "+", "1", ")", "*", "2"]

为了实现这一点,我们基本上删除所有额外的空格,用空字符串替换所有空格,并在任何*^+-/ *运算符上拆分剩下的字符串,并删除任何空字符串的出现。

由于没有简单的方法可以从数组中删除所有空字符串"",我们可以使用一个称为 clean 的小型实用方法,我们可以在同一个文件中创建它。

这可以翻译成如下代码:

function clean(arr) {
 return arr.filter(function(a) {
 return a !== "";
    });
}

因此,最终表达式如下:

expr = clean(expr.trim().replace(/\s+/g, "").split(/([\+\-\*\/\^\(\)])/));

现在我们已经将输入字符串拆分,我们准备分析每个标记,以确定它是什么类型,并相应地采取行动将其添加到后缀表示输出字符串中。这是前述算法的第 2 步,我们将使用一个堆栈使我们的代码更易读。让我们将堆栈包含到我们的工作中,因为它无法访问外部世界。我们只需将我们的堆栈转换为 ES5 代码,它将如下所示:

var Stack = (function () {
   var wmkey = {};
   var items = new WeakMap();

   items.set(wmkey, []);

   function Stack() { }

   Stack.prototype.push = function (element) {
      var stack = items.get(wmkey);
      stack.push(element);
   };
   Stack.prototype.pop = function () {
      var stack = items.get(wmkey);
      return stack.pop();
   };
   Stack.prototype.peek = function () {
      var stack = items.get(wmkey);
      return stack[stack.length - 1];
   };
   Stack.prototype.clear = function () {
      items.set(wmkey, []);
   };
   Stack.prototype.size = function () {
      return items.get(wmkey).length;
   };
   return Stack;
}());

正如你所看到的,这些方法都附加在prototype上,我们的堆栈就准备好了。

现在,让我们在中缀转后缀转换中使用这个堆栈。在进行转换之前,我们将要检查用户输入是否有效,也就是说,我们要检查括号是否平衡。我们将使用下面代码中描述的简单的isBalanced()方法,如果不平衡,我们将返回错误:

function isBalanced(postfix) {
   var count = 0;
   postfix.forEach(function(op) {
      if (op === ')') {
         count++
      } else if (op === '(') {
         count --
      }
   });

   return count === 0;
}

我们需要使用堆栈来保存我们遇到的运算符,以便我们可以根据它们的优先级结合性后缀字符串中重新排列它们。我们需要做的第一件事是检查遇到的标记是否是一个数字;如果是,那么我们将它附加到后缀结果中:

expr.forEach(function(exp) {
 if(!isNaN(parseFloat(exp))) {
 postfix += exp + " ";
    }
});

然后,我们检查遇到的标记是否是一个开括号,如果是,那么我们将它推到运算符堆栈中,等待闭括号。一旦遇到闭括号,我们将在后缀输出中组合所有内容(运算符和数字),如下所示:

expr.forEach(function(exp) {
 if(!isNaN(parseFloat(exp))) {
 postfix += exp + " ";
    }  else if(exp === "(") {
 ops.push(exp);
    } else if(exp === ")") {
 while(ops.peek() !== "(") {
 postfix += ops.pop() + " ";
        }
 ops.pop();
    }
});

最后(稍微复杂)的一步是确定标记是否是*^+-/中的一个,然后我们首先检查当前运算符的结合性。当它是从左到右时,我们检查当前运算符的优先级是否小于或等于上一个运算符的优先级。当它是从右到左时,我们检查当前运算符的优先级是否严格小于上一个运算符的优先级。如果满足任何这些条件,我们将弹出运算符直到条件失败,将它们附加到后缀输出字符串,然后将当前运算符添加到下一次迭代的运算符堆栈中。

我们对从右到左的严格检查而不是从左到右的结合性进行严格检查的原因是,我们有多个具有相同优先级结合性的运算符。

在此之后,如果还有其他运算符剩下,我们将把它们添加到后缀输出字符串中。

将中缀转换为后缀表达式

将上面讨论的所有代码放在一起,将中缀表达式转换为后缀的最终代码如下:

function convert(expr) {
 var postfix = "";
 var ops = new Stack();
 var operators = {
 "^": {
 priority: 4,
 associativity: "rtl"
        },
 "*": {
 priority: 3,
 associativity: "ltr"
        },
 "/": {
 priority: 3,
 associativity: "ltr"
        },
 "+": {
 priority: 2,
 associativity: "ltr"
        },
 "-": {
 priority: 2,
 associativity: "ltr"
        }
    };

    expr = clean(expr.trim().replace(/\s+/g, "").split(/([\+\-\*\/\^\(\)])/));

    if (!isBalanced(expr) {
        return 'error';
    }    

    expr.forEach(function(exp) {
 if(!isNaN(parseFloat(exp))) {
 postfix += exp + " ";
        }  else if(exp === "(") {
 ops.push(exp);
        } else if(exp === ")") {
 while(ops.peek() !== "(") {
 postfix += ops.pop() + " ";
            }
 ops.pop();
        } else if("*^+-/".indexOf(exp) !== -1) {
 var currOp = exp;
 var prevOp = ops.peek();
 while("*^+-/".indexOf(prevOp) !== -1 && ((operators[currOp].associativity === "ltr" && operators[currOp].priority <= operators[prevOp].priority) || (operators[currOp].associativity === "rtl" && operators[currOp].priority < operators[prevOp].priority)))
            {
 postfix += ops.pop() + " ";
 prevOp = ops.peek();
            }
 ops.push(currOp);
        }
    });

 while(ops.size() > 0) {
 postfix += ops.pop() + " ";
    }
 return postfix;
}

这将把提供的中缀运算符转换为后缀表示法。

评估后缀表达式

从这里开始,执行这种后缀表示法相当容易。算法相对简单;您将每个运算符弹出到最终结果堆栈上。*如果运算符是*,^+-/中的一个,则相应地对其进行评估;否则,继续将其附加到输出字符串中:

function evaluate(postfix) {
 var resultStack = new Stack();
    postfix = clean(postfix.trim().split(" "));
    postfix.forEach(function (op) {
 if(!isNaN(parseFloat(op))) {
 resultStack.push(op);
        } else {
 var val1 = resultStack.pop();
 var val2 = resultStack.pop();
 var parseMethodA = getParseMethod(val1);
 var parseMethodB = getParseMethod(val2);
 if(op === "+") {
 resultStack.push(parseMethodA(val1) + parseMethodB(val2));
            } else if(op === "-") {
 resultStack.push(parseMethodB(val2) - parseMethodA(val1));
            } else if(op === "*") {
 resultStack.push(parseMethodA(val1) * parseMethodB(val2));
            } else if(op === "/") {
 resultStack.push(parseMethodB(val2) / parseMethodA(val1));
            } else if(op === "^") {
 resultStack.push(Math.pow(parseMethodB(val2), 
 parseMethodA(val1)));
            }
       }
    });

 if (resultStack.size() > 1) {
 return "error";
    } else {
 return resultStack.pop();
    }
}

在这里,我们使用一些辅助方法,比如getParseMethod()来确定我们处理的是整数还是浮点数,以便我们不会不必要地四舍五入任何数字。

现在,我们需要做的就是指示我们的工作人员返回它刚刚计算的数据结果。这与我们返回的错误消息的方式相同,因此我们的init()方法如下更改:

function init() {
 self.addEventListener('message', function(e) {
 var code = e.data;

 if(code.match(/.*[a-zA-Z]+.*/g)) {
 respond('Error! Cannot evaluate complex expressions yet. Please try
            again later');
        } else {
 respond(evaluate(convert(code)));
        }
    });
}

总结

在这里,我们有使用堆栈的真实网络示例。*在这两个示例中需要注意的重要事情是,大部分逻辑不像预期的那样围绕数据结构本身。它是一个辅助组件,极大地简化了访问并保护您的数据免受意外的代码问题和错误。

在本章中,我们介绍了为什么我们需要一个特定的堆栈数据结构而不是内置数组的基础知识,使用所述数据结构简化我们的代码,并注意数据结构的应用。这只是令人兴奋的开始,还有更多内容要来。

在下一章中,我们将沿着相同的线路探索队列数据结构,并分析一些额外的性能指标,以检查是否值得麻烦地构建和/或使用自定义数据结构。

第二章:为顺序执行创建队列

队列是一个编程构造,与现实世界的队列(例如电影院、ATM 或银行的队列)有很大的相似之处。与堆栈相反,队列是先进先出FIFO),因此无论什么先进去,也会先出来。当您希望保持数据以流入的相同顺序时,这是特别有帮助的。

队列的更多计算机/科学定义如下:

一个抽象数据集合,其中元素可以被添加到后端称为 enqueue,并从前端称为 dequeue 中移除,这使其成为 FIFO 数据结构。

当然,只有enqueuedequeue操作可能足够覆盖大多数情况,以涵盖我们可能遇到的更广泛的问题;然而,我们可以扩展 API 并使我们的队列具有未来的可扩展性。

在本章中,我们将讨论以下主题:

  • 队列的类型

  • 不同类型的队列实现

  • 显示队列的有用性的用例

  • 与其他本地数据结构相比的队列性能

队列的类型

在我们开始理解队列之前,让我们快速看一下我们可能想在应用程序中使用的队列类型:

  • 简单队列:在简单的 FIFO 队列中,顺序被保留,数据以进入的顺序离开

  • 优先队列:队列中的元素被赋予预定义的优先级

  • 循环队列:类似于简单队列,只是队列的后端跟随队列的前端

  • 双端队列Dequeue):类似于简单队列,但可以从队列的前端或后端添加或移除元素

实现 API

实现 API 从来不像看起来那么容易,正如之前讨论的那样。在创建通用类时,我们无法预测我们的队列将在何种情况下使用。考虑到这一点,让我们为我们的队列创建一个非常通用的 API,并根据需要在将来扩展它。我们可以添加到队列的一些最常见的操作如下:

  • add(): 将项目推送到队列的后端

  • remove(): 从队列的开头移除一个项目

  • peek(): 显示添加到队列的最后一个项目

  • front(): 返回队列前端的项目

  • clear(): 清空队列

  • size(): 获取队列的当前大小

创建队列

在我们之前讨论过的四种类型的队列中,首先,我们将实现一个简单的队列,然后继续修改每种类型的后续队列。

一个简单的队列

与堆栈类似,我们将使用以下步骤创建一个队列:

  1. 定义一个constructor()
class Queue {
    constructor() {

    }
}
  1. 我们将使用WeakMap()来进行内存数据存储,就像我们为堆栈所做的那样:
 const qKey = {};
 const items = new WeakMap();

 class Queue {
 constructor() {

        }
    }
  1. 实现先前在 API 中描述的方法:
var Queue = (() => {
 const qKey = {};
 const items = new WeakMap();

 class Queue {

 constructor() {
 items.set(qKey, []);
        }

 add(element) {
 let queue = items.get(qKey);
 queue.push(element);
        }

 remove() {
 let queue = items.get(qKey);
 return queue.shift();
        }

 peek() {
 let queue = items.get(qKey);
 return queue[queue.length - 1];
        }

 front() {
 let queue = items.get(qKey);
 return queue[0];
        }

 clear() {
 items.set(qKey, []);
        }

 size() {
 return items.get(qKey).length;
        }
    }

 return Queue;
})();

我们再次将整个类包装在 IIFE 中,因为我们不希望从外部访问Queue项:

测试队列

要测试这个队列,您可以简单地实例化它并向队列中添加/移除一些项目:

var simpleQueue = new Queue();
simpleQueue.add(10);
simpleQueue.add(20);

console.log(simpleQueue.items); // prints undefined   console.log(simpleQueue.size()); // prints 2   console.log(simpleQueue.remove()); // prints 10   console.log(simpleQueue.size()); // prints 1   simpleQueue.clear();

console.log(simpleQueue.size()); // prints 0

正如您可以从前面的代码中注意到的那样,所有元素都被同等对待。无论它们包含的数据是什么,元素始终以 FIFO 的方式对待。尽管这是一个很好的方法,但有时我们可能需要更多:即优先处理进入和离开队列的元素,正如我们可以在接下来的部分中注意到的那样。

优先队列

优先队列在操作上类似于简单队列,即它们支持相同的 API,但它们所持有的数据还有一个小小的附加项。除了元素(您的数据)之外,它们还可以保持一个优先级,这只是一个表示队列中元素优先级的数值。

从队列中添加或移除这些元素是基于优先级的。您可以拥有最小优先级队列或最大优先级队列,以帮助确定您是基于增加优先级还是减少优先级来添加元素。我们将看一下add()方法如何替代我们之前定义的简单队列的add()方法:

add(newEl) {
 let queue = items.get(pqkey);
 let newElPosition = queue.length;

 if(!queue.length) {
 queue.push(newEl);
 return;
    }

 for (let [i,v] of queue.entries()) {
 if(newEl.priority > v.priority) {
 newElPosition = i;
 break;
        }
    }

 queue.splice(newElPosition, 0, newEl);
}

由于我们在插入堆栈时考虑了元素的优先级,所以我们在从队列中移除元素时不必关注优先级,因此remove()方法对于简单队列和优先队列是相同的。其他实用方法,如front()clear()peek()size(),与保存在队列中的数据类型无关,因此它们也保持不变。

创建优先队列时的一个聪明举措是优化您的代码,并决定您是否想要在添加或移除时确定优先级。这样,您就不会在每一步都过度计算或分析数据集。

测试优先队列

让我们首先设置用于测试队列的数据:

var priorityQueue = new PriorityQueue();

priorityQueue.add({ el : 1, priority: 1});

// state of Queue
// [1]
//  ^

priorityQueue.add({ el : 2, priority: 2});

// state of Queue
// [2, 1]
//  ^

priorityQueue.add({ el : 3, priority: 3});

// state of Queue
// [3, 2, 1]
//  ^

priorityQueue.add({ el : 4, priority: 3});

// state of Queue
// [3, 4, 2, 1]
//     ^

priorityQueue.add({ el : 5, priority: 2});

// state of Queue
// [3, 4, 2, 5, 1]
//           ^

从视觉上看,前面的步骤将生成一个如下所示的队列:

从前面的图中,我们可以注意到当我们添加一个优先级为 2 的元素时,它会排在所有优先级为 1 的元素之前:

priorityQueue.add({ el : 6, priority: 1});

// state of Queue
// [3, 4, 2, 5, 1, 6]
//                 ^  

当我们添加一个优先级为 1(最低)的元素时,它会被添加到队列的末尾:

我们在这里添加的最后一个元素恰好也是优先级最低的元素,这使它成为队列的最后一个元素,从而根据优先级保持所有元素的顺序。

现在,让我们从队列中移除元素:

console.log(priorityQueue.remove());

// prints { el: 3, priority: 3}

// state of Queue
// [4, 2, 5, 1, 6]

console.log(priorityQueue.remove());

// prints { el: 4, priority: 3 }

// state of Queue
// [2, 5, 1, 6]

console.log(priorityQueue.remove());

// prints { el: 2, priority: 2 }

// state of Queue
// [5, 1, 6]

priorityQueue.print();

// prints { el: 5, priority: 2 } { el: 1, priority: 1 } { el: 6, priority: 1 }

这就是:使用WeakMap()在 JavaScript 中创建简单和优先队列。现在让我们来看一下这些队列的一些实际应用。

队列的用例

在开始使用案例之前,我们需要一个基本的起点,即一个 Node.js 应用程序。要创建一个,请确保您已安装了最新的 Node.js:

node -v

这应该显示您当前安装的 Node.js 版本;如果没有,那么请从nodejs.org/en下载并安装最新版本的 Node.js。

创建一个 Node.js 应用程序

要开始一个示例 Node.js 项目,首先创建一个项目文件夹,然后从该文件夹运行以下命令:

npm init

运行此命令时,Node 将提示您一系列问题,您可以选择填写或留空:

创建空应用程序后,您将看到一个名为package.json的文件。现在,您可以添加创建 Node.js 应用程序所需的依赖项:

npm install body-parser express --save

body-parser模块有助于解析 POST 请求体,而express模块有助于创建 Node.js 服务器。

启动 Node.js 服务器

一旦我们创建了应用程序外壳,创建一个名为index.js的文件,这将是您的应用程序的主文件;您可以随意命名,但请确保您相应地更新package.json中的main属性。

现在,让我们在index.js文件中添加一些代码来启动一个 express 服务器:

var express = require('express');
var app = express();

app.listen(3000, function () {
 console.log('Chat Application listening on port 3000!')
});

就是这样!服务器现在在3000端口上运行。要测试它,只需添加一个空路由来告诉您应用程序是否正常运行:

app.get('/', function (req, res) {
    res.status(200).send('OK!')
});

您可以打开浏览器并导航到localhost:3000,这应该会显示服务器状态为OK!,或者如果服务器宕机,则会给出错误。

创建一个聊天端点

现在我们的服务器已经运行起来了,我们可以创建一个内存中的聊天端点,它将接受来自两个用户的消息,并使用队列将其转发给其预期的接收者,同时保留顺序。

在添加逻辑之前,我们需要进行一些基础工作,以模块化地设置应用程序。首先,让我们包含body-parser并在 express 中间件中使用它,以便我们可以轻松访问请求的body。因此,更新后的index.js文件如下所示:

var express = require('express');
var app = express();
var bodyParser = require('body-parser');

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

app.get('/', function (req, res) {
    res.status(200).send('OK!')
});

app.listen(3000, function () {
 console.log('Chat Application listening on port 3000!')
});

现在,要为消息添加端点,我们可以在routes文件夹下创建一个名为messages.js的新文件,然后在其中添加基本的post请求:

var express = require('express');
var router = express.Router();

router.route('/')
   .post(function(req, res) {

         res.send(`Message received from: ${req.body.from} to ${req.body.to} with message ${req.body.message}`);

});

module.exports = router;

然后,我们可以将其注入到我们的index.js中,并使其成为我们应用程序的一部分:

var message = require('./routes/messages');

...
...
...

app.use('/message', message);

现在,为了测试这个,我们可以启动服务器并使用 Postman 向localhost:3000/message发送一条消息;然后我们可以看到以下响应:

图:示例发布消息

现在,我们可以继续开始添加逻辑,以便在两个用户之间发送消息。我们将抽象、模拟和简化应用程序的聊天部分,并更专注于在这种复杂应用程序中使用队列应用。

工作流本身相对简单:用户 A 向用户 B 发送消息,我们的服务器尝试将其转发给用户 B。如果没有任何问题,一切顺利,消息将被传递给用户 B;但如果失败,我们将调用我们的FailureProtocol(),它会重试发送上一次失败的对话消息。为简单起见,我们现在假设只有一个通道,即用户 A 和用户 B 之间的通道*。

生产环境中的对应部分将能够同时处理多个通道,当通道上的消息发送失败时,会为特定通道创建一个新的FailureProtocol()处理程序,并具有将作业推迟到多个线程的灵活性。

现在,让我们在一个名为messaging-utils.js的文件中模拟sendMessage()getUniqueFailureQueue()方法,这将是我们的包装器,以便我们可以将它们移动到自己的模块中,因为它们在这种情况下对于理解队列并不重要:

var PriorityQueue = require('./priority-queue');

var Utils = (()=> {
 class Utils {

 constructor() {

        }

 getUniqueFailureQueue(from, to) {
 // use from and to here to determine 
            // if a failure queue already 
            // exists or create a new one return new PriorityQueue();
        }

 sendMessage(message) {
 return new Promise(function(resolve, reject) {
 // randomize successes and failure of message being
                   sent  if(Math.random() < 0.1) {

                    resolve(message)

                } else {

                    reject(message);

                }

            });
        }

    }

 return Utils;
})();

module.exports = Utils;

现在,当我们收到新消息时,我们会尝试将其发送给预期的最终用户:

var express = require('express');
var router = express.Router();
var Utils = require('../utils/messaging-utils');
const msgUtils = new Utils();

router.route('/')
    .post(function(req, res) {
 const message = req.body.message;
 let failedMessageQueue;

 // try to send the message msgUtils.sendMessage(req.body)
            .then(function() {

                res.send(`Message received from: ${req.body.from} to ${req.body.to} with message ${req.body.message}`);

            }, function() {

 failedMessageQueue = 
 msgUtils.getUniqueFailureQueue(req.body.from,
                   req.body.to);

 failedMessageQueue.add(message);

 // trigger failure protocol triggerFailureProtocol();

         });

如果消息发送成功,我们需要立即确认并发送成功消息;否则,我们将在两个用户之间得到一个唯一的failedMessageQueue,然后将消息添加到其中,随后触发失败协议。

失败协议对不同的应用程序可能意味着不同的事情。虽然一些应用程序选择只显示失败消息,像我们这样的应用程序会重试发送消息,直到成功发送为止:

function triggerFailureProtocol() {

 var msg = failedMessageQueue.front();

 msgUtils.sendMessage(msg)
        .then(function() {

 failedMessageQueue.remove();

             res.send('OK!');

         }, function(msg) {

 //retry failure protocol triggerFailureProtocol();

         });
}

我们可以使用我们的Queue中可用的方法来选择顶部消息,然后尝试发送它。如果成功,然后删除它;否则,重试。正如你所看到的,使用队列极大地简化和抽象了实际失败消息排队的逻辑,更好的是,你可以随时升级和增强队列,而不必考虑其他组件会受到这种变化的影响。

现在我们已经准备好解析传入请求、发送给预期接收者并触发我们自定义的失败协议的 API 调用。当我们将所有这些逻辑结合在一起时,我们有以下内容:

var express = require('express');
var router = express.Router();
var Utils = require('../utils/messaging-utils');
const msgUtils = new Utils();

router.route('/')
    .post(function(req, res) {
 const message = req.body.message;
 let failedMessageQueue;

 // try to send the message msgUtils.sendMessage(req.body)
            .then(function() {

 console.log("Sent Successfully : " + message);

 res.send(`Message received from: ${req.body.from} to ${req.body.to} with message ${req.body.message}`);

            }, function(msg) {

 console.log('Failed to send: ' + message);

 failedMessageQueue = 
 msgUtils.getUniqueFailureQueue(req.body.from,
                     req.body.to);

 failedMessageQueue.add(message);

 // trigger failure protocol triggerFailureProtocol();
            });

 function triggerFailureProtocol() {

 var msg = failedMessageQueue.front();

 msgUtils.sendMessage(msg)
                .then(function() {

 failedMessageQueue.remove();

 res.send('OK!');

                 }, function(msg) {

 //retry failure protocol triggerFailureProtocol();

                 });
        }
});

module.exports = router;

使用优先队列实现日志记录

端点失败是不可避免的。虽然我们可以尝试重新发送失败的消息,但我们需要意识到在某个时候我们的端出现了问题,并停止向服务器发送请求以转发消息。这就是优先队列可以派上用场的地方。

我们将替换现有逻辑,使用优先队列来检测何时停止尝试重新发送消息,并通知支持团队。

最大的变化在triggerFailureProtocol()方法中,我们检查消息是否失败的次数超过了预设的retryThreshold;如果是,那么我们将消息添加到具有关键优先级的队列中,稍后我们将使用它来防止服务器的后续轰炸,直到支持团队解决问题。这个解决方案虽然相当天真,但在保留服务器资源方面非常有效。

因此,带有优先队列的更新代码如下:

function triggerFailureProtocol() {

 console.log('trigger failure protocol');

 // get front message from queue var frontMsgNode = failedMessageQueue.front();

 // low priority and hasnt hit retry threshold if (frontMsgNode.priority === 0 
        && failureTriggerCount <= failureTriggerCountThreshold) {

 // try to send message msgUtils.sendMessage(frontMsgNode.message)
            .then(function() {

 console.log('resend success');
 // success, so remove from queue failedMessageQueue.remove();

 // inform user                res.send('OK!');

             }, function() {

 console.log('resend failure');

 // increment counter failureTriggerCount++;

 //retry failure protocol triggerFailureProtocol();

             });

    } else {

 console.log('resend failed too many times');

 // replace top message with higher priority message let prevMsgNode = failedMessageQueue.remove();

 prevMsgNode.priority = 1;

 // gets added to front failedMessageQueue.add(prevMsgNode);

        res.status(500).send('Critical Server Error! Failed to send
        message');

    }
}

在上面的代码中,我们将相同的登录包装在if-else块中,以便能够重试发送消息或创建关键错误并停止我们的重试努力。

因此,下次该频道收到新消息时,您可以验证是否已经存在关键错误,并直接拒绝请求,而不必经历尝试发送消息并失败的麻烦,这会不断膨胀失败队列。

这当然是解决这个问题的一种方法,但更合适的方法是在用户尝试访问频道时通知用户任何关键错误,而不是在用户向其发布消息时这样做,这超出了本示例的范围。

以下是包括优先队列的完整代码:

var express = require('express');
var router = express.Router();
var Utils = require('../utils/messaging-utils');
const msgUtils = new Utils();

router.route('/')
    .post(function(req, res) {
 const message = req.body.message;
 let failedMessageQueue;
 let failureTriggerCount = 0;
 let failureTriggerCountThreshold = 3;
 let newMsgNode = {
 message: message,
 priority: 0
        };

 // try to send the message msgUtils.sendMessage(req.body)
            .then(function() {

 console.log('send success');

 // success                res.send(`Message received from: ${req.body.from} to ${req.body.to} with message ${req.body.message}`);

         }, function() {

 console.log('send failed');

 // get unique queue failedMessageQueue = 
 msgUtils.getUniqueFailureQueue(req.body.from,
                    req.body.to);

 // get front message in queue var frontMsgNode = failedMessageQueue.front();
 // already has a critical failure if (frontMsgNode && frontMsgNode.priority === 1) {

 // notify support   // notify user                   res.status(500)
                      .send('Critical Server Error! Failed to send
                      message');

               } else {

 // add more failedMessageQueue.add(newMsgNode);

 // increment count failureTriggerCount++;

 // trigger failure protocol triggerFailureProtocol();

               }
        });

 function triggerFailureProtocol() {

 console.log('trigger failure protocol');

 // get front message from queue var frontMsgNode = failedMessageQueue.front();

 // low priority and hasnt hit retry threshold if (frontMsgNode.priority === 0 
               && failureTriggerCount <= failureTriggerCountThreshold) {

 // try to send message msgUtils.sendMessage(frontMsgNode.message)
                   .then(function() {

 console.log('resend success');
 // success, so remove from queue failedMessageQueue.remove();

 // inform user                       res.send('OK!');

                    }, function() {

 console.log('resend failure');

 // increment counter failureTriggerCount++;

 //retry failure protocol triggerFailureProtocol();

                     });

            } else {

 console.log('resend failed too many times');

 // replace top message with higher priority message let prevMsgNode = failedMessageQueue.remove();

 prevMsgNode.priority = 1;

 // gets added to front failedMessageQueue.add(prevMsgNode);

                res.status(500)
                   .send('Critical Server Error! Failed to send 
                   message');

           }
        }
});

module.exports = router;

性能比较

之前,我们看到了如何简单地将简单队列替换为优先队列,而不必担心它可能引起的功能性变化;同样,我们可以将优先队列替换为性能更高的变体:循环双端队列。

在我们开始进行比较之前,我们需要讨论循环队列以及为什么我们需要它们。

循环队列和简单队列之间的区别在于队列的尾部紧随队列的前部。也就是说,它们在功能上没有区别。它们仍然执行相同的操作,并产生相同的结果;您可能想知道它们究竟在哪里不同,如果最终结果是相同的,那有什么意义。

在 JavaScript 数组中,内存位置是连续的。因此,当创建队列并执行remove()等操作时,我们需要担心将剩余元素移动到更新的front而不是null,从而增加操作的数量;这也是一个内存开销,除非您的队列有无限/动态数量的插槽。

现在,想象一个循环队列——由于它的循环性质,这个队列有固定数量的内存位置,当元素被移除或添加时,您可以重用内存位置并减少执行的操作数量,这使得它比常规队列更快。

在我们对比这个队列与 JavaScript 中的原生数组的性能之前,让我们来看看 Chrome 的 JavaScript 引擎 V8 的内部工作,并检查它是否真的在我们的情况下很重要。我们考虑这个的原因是因为 JavaScript 中经常被忽视的稀疏数组和密集数组的概念,尽管这是一个底层实现,可能会不断变化。大多数情况下,JavaScript 数组是密集的,如果处理不当很容易变得稀疏。测试这一点的一个简单方法是创建一个数组,如下所示:

  • 考虑示例 1:
const a = [undefined, undefined, 10];

当你记录它时,你会看到相同的结果:

[undefined, undefined, 10];

现在,创建一个这样的数组:

  • 考虑示例 2:
const b = [];
b[3] = 10; // hole as we missed out index 0,1,2

当你记录它时,你会得到相同的结果:

[undefined x 3, 10];

这很有趣,因为它展示了 JavaScript 数组的密集(示例 1)和稀疏(示例 2)行为之间的差异。当您创建这些密集数组时,数组的元素被认为是特定值,并且这些值在初始化时是已知的,这使得 JavaScript 有可能将这些值保存在连续的内存中。

JavaScript 数组实现的 V8 代码有以下注释,这使得我们可以观察到另一个有趣的现象,与我们之前讨论的内容一致。

// The JSArray describes JavaScript Arrays // Such an array can be in one of two modes: //           - fast, backing storage is a FixedArray and length <= elements.length(); //           Please note: push and pop can be used to grow and shrink the array. //         - slow, backing storage is a HashTable with numbers as keys. class JSArray: public JSObject {

因此,数组在内部根据正在保存在数组中的数据的类型和大小而有所不同。作为一个经验法则,总是使用数组文字创建一个空数组,并从 0 索引开始逐步为元素分配值,同时不在数组中留下空隙或空洞。这样可以使数组保持快速,并且除非数据的规模要求,否则不会进入字典模式。

双端循环队列,也称为循环双端队列,与简单队列类似,只是add()remove()可以从队列的前面或后面进行。

这基本上是与您的数组相同的 API,我们可以构建一个提供此功能的类的示例,但让我们更进一步,看看如何使用循环队列实现我们之前讨论的一切,并使其尽可能高效:

首先,我们假设这个队列有一个有限的大小;它可以随后扩展为动态的性质,但现在不是一个问题。到目前为止,WeakMap()已被用作内存中的数据存储,我们在其中保存了队列所需的数据,但是在性能方面,它只是为我们的数据结构添加了另一层检索,因此在这种情况下,我们将转移到标准数组,因为这是我们将在基准测试中进行比较的。将这些转化为一些代码,我们可以得到我们的CircularDequeue,如下所示:

var CircularDequeue = (()=> {
 class CircularDequeue {
 constructor() {
 // pseudo realistic 2^x value this._size = 1024;
 this._length = 0;
 this._front = 0;
 this._data = [];
        }

 push (item) {
 // get the length of the array var length = this._length;

 // calculate the end var i = (this._front + length) & (this._size - 1);

 // assign value to the current end of the data this._data[i] = item;

 // increment length for quick look up this._length = length + 1;

 // return new length return this._length;
        }

 pop () {
 // get the length of the array var length = this._length;

 // calculate the end var i = (this._front + length - 1) & (this._size - 1);

 // copy the value to return var ret = this._data[i];

 // remove the value from data this._data[i] = undefined;

 // reduce length for look up  this._length = length - 1;

 // return value  return ret;
       }

 shift () {
 // get the current front of queue var front = this._front;

 // capture return value var ret = this._data[front];

 // reset value in the data this._data[front] = undefined;

 // calculate the new front of the queue this._front = (front + 1) & (this._size - 1);

 // reduce the size this._length = this._length - 1;

 // return the value return ret;

        }

 unshift (item) {
 // get the size var size = this._size;

 // calculate the new front var i = (((( this._front - 1 ) & ( size - 1) ) ^ size ) -
            size );

 // add the item this._data[i] = item;

 // increment the length this._length = this._length + 1;

 // update the new front this._front = i;

 // return the acknowledgement of the addition of the new
            item return this._length;
        }
    }

 return CircularDequeue;
})();

module.exports = CircularDequeue;

当然,这只是实现循环双端队列的一种方式;您可以通过将属性添加到类的构造函数本身而不是将它们包装在 IIFE 中(即避免作用域链查找),并且如果您使用 TypeScript,还可以进一步简化代码,这允许私有类成员,就像我们在讨论堆栈时所讨论的那样。

运行基准测试

在运行基准测试之前,重要的是要理解我们比较队列与本机数组的意图。我们并不试图证明队列比数组更快,这就是为什么我们应该使用它们。同时,我们也不想使用一些非常慢的东西。这些测试的目标是帮助我们了解队列在本机数据结构方面的位置,以及我们是否可以依赖它们提供高性能的自定义数据结构(如果需要)。

现在,让我们运行一些基准测试来比较循环双端队列和数组。我们将使用benchmark.js来设置和运行我们的基准测试。

要开始测试,让我们首先在项目中包含基准测试节点模块。要安装它,请在项目根目录的终端上运行以下命令:

npm install benchmark --save-dev

安装完成后,我们准备创建我们的测试套件。创建一个tests文件夹,并在其中添加一个名为benchmark.js的文件。为了创建一个测试套件,我们首先设置数据。如前所述,我们将比较我们的CircularDequeue和一个数组:

var Benchmark = require("benchmark");
var suite = new Benchmark.Suite();
var CircularDequeue = require("../utils/circular-dequeue.js");

var cdQueue = new CircularDequeue();
var array = [];

for(var i=0; i < 10; i++) {
 cdQueue.push(i);
 array.push(i);
}

在这里,我们首先使用循环双端队列和数组中的小数据集。这将使数组变得密集,从而使 V8 引擎以快速模式运行并应用内部优化。

现在,我们可以继续并向我们的测试套件添加测试:

suite
   .add("circular-queue push", function(){
 cdQueue.push(cdQueue.shift());
   })
   .add("regular array push", function(){
 array.push(array.shift());
   })
   .add("circular-queue pop", function(){
 cdQueue.pop();
   })
   .add("regular array pop", function(){
 array.pop();
   })
   .add("circular-queue unshift", function(){
 cdQueue.unshift(cdQueue.shift());
   })
   .add("regular array unshift", function(){
 array.unshift( array.shift());
   })
   .add("circular-queue shift", function(){
 cdQueue.shift();
   })
   .add("regular array shift", function(){
 array.shift();
   })
   .on("cycle", function(e) {
 console.log("" + e.target);
   })
   .run();

在先前的测试中需要注意的一点是,我们总是将两个操作耦合在一起,如下所示:

.add("regular array push", function(){
 array.push(array.shift());
});

如果我们在执行push()方法之前不执行shift()方法并推送一个数字,例如12,那么我们将很快遇到内存不足错误,因为测试的迭代次数对于数组来说太大了;另一方面,循环队列将没有问题,因为它们的循环性质:它们只会覆盖先前的值。

现在,将测试添加到您的package.json脚本中以便更轻松地访问:

"scripts": {
 "start": "node index.js",
 "test": "node tests/benchmark.js" },

要运行基准测试套件,请运行以下命令:

npm run test

结果将如下:

正如您可以从前面的截图中看到的,循环队列的 push 和 unshift 比本机的 push 和 unshift 操作快得多,而 pop 和 shift 操作几乎慢了 30%。

现在,让我们使数组稀疏,以便强制 V8 以字典模式运行数组方法(这对某些情况可能是真实用例,有时在处理混合数据类型的数组时也可能是可能的):

var i = 1000;

while(i--){
 cdQueue.push(i);
 array.push(i);
}

当我们使用稀疏数组运行类似的测试时,结果如下:

您可以看到,性能与push()操作的快速模式大不相同,而其他操作基本保持不变。这是了解采用特定编码实践后果的好方法。您需要了解应用程序的要求,并相应地选择合适的工具来完成工作。

例如,当内存是优先考虑因素时,我们将使用简单队列,它可以与WeakMap()一起使用,而不是常规数组。我们可以创建两个新的测试,可以分开运行以跟踪它们各自的内存使用情况:

suite
  .add("regular array push", function(){
 array.push(array.shift());
   })
   .on("cycle", function(e) {
 console.log("" + e.target);
 console.log(process.memoryUsage());
   })
   .run();

它产生了以下结果:

我们可以从前面的截图中看到,它记录了我们测试运行的结果,即 ops/sec,并记录了该周期的总内存使用情况。

类似地,我们可以对简单队列进行remove操作的基准测试,这与我们对 shift 操作所做的非常相似:

suite
  .add("simple queue push", function(){
 simpleQueue.add(simpleQueue.remove());
   })
   .on("cycle", function(e) {
 console.log("" + e.target);
 console.log(process.memoryUsage());
   })
   .run();

这产生了以下结果:

您可以看到,简单队列显然比数组慢了 4 倍,但这里重要的是要注意两种情况下的heapUsed。这是另一个让您决定何时以及如何选择特定类型数据结构的因素。

总结

至此,我们结束了关于队列的章节。我们学习了简单队列、优先级队列、循环队列以及双端队列的变体。我们还学习了何时根据使用情况应用它们,并且通过示例看到了如何利用基准测试任何算法或数据结构的能力。在下一章中,我们将对集合、映射和哈希进行深入研究,以了解它们的内部工作原理,并看看它们在哪些情况下可以发挥作用。

第三章:使用集合和映射加速应用程序

集合映射是两种看似简单的数据结构,在最新版本的 ES6 中已经标准化。

在本章中,我们将涵盖以下主题:

  • 为什么我们需要集合和映射?

  • 何时以及如何使用集合和映射

  • ES6 中集合和映射的 API

  • 用例

  • 性能比较

探索集合和映射的起源

在我们试图了解如何在现实世界的应用程序中使用集合和映射之前,更有意义的是了解集合和映射的起源,以及为什么我们首先需要它们在 JavaScript 中。

直到 ES5 之前,传统数组不支持开发人员通常想要利用的一些主要功能:

  • 承认它包含一个特定的元素

  • 添加新元素而不产生重复。

这导致开发人员实现了自己的集合和映射版本,这在其他编程语言中是可用的。使用 JavaScript 的Object来实现集合和映射的常见方法如下:

// create an empty object
var setOrMap = Object.create(null);

// assign a key and value
setOrMap.someKey = someValue;

// if used as a set, check for existence
if(setOrMap.someKey) {
    // set has someKey 
}

// if used as a map, access value
var returnedValue = setOrMap.someKey;

虽然使用Object.create创建集合或映射可以避免很多原型问题,但它仍然不能解决一个问题,那就是主要的Key只能是一个string,因为Object只允许键为字符串,所以我们可能会无意中得到值互相覆盖:

// create a new map object
let map = Object.create(null);

// add properties to the new map object
let b = {};
let c = {};
map[b] = 10
map[c] = 20

// log map
Object [object Object]: 20

分析集合和映射类型

在实际使用集合和映射之前,我们需要了解何时以及何地需要使用它们。每种数据结构,无论是原生的还是自定义的,都有其自己的优势和劣势。

利用这些优势是非常重要的,更重要的是避免它们的弱点。为了理解其中一些弱点,我们将探讨集合和映射类型,以及它们为何需要以及在哪里使用。

主要有四种不同的集合和映射类型:

  • 映射:一个键值对,其中键可以是一个Object或一个原始值,可以容纳任意值。

  • WeakMap:一个键值对,其中键只能是一个Object,可以容纳任意值。键是弱引用的;这意味着如果不使用,它们不会被阻止被垃圾回收。

  • 集合:允许用户存储任何类型的唯一值的数据类型。

  • WeakSet:类似于集合,但维护一个弱引用。

WeakMap 有多弱?

到目前为止,我们都知道什么是映射,以及如何添加键和值,至少在理论上。然而,如何确定何时使用映射,何时使用WeakMap呢?

内存管理

根据 MDN 的官方定义(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap),WeakMap的官方定义如下:

WeakMap 对象是一个键/值对的集合,其中键是弱引用。键必须是对象,值可以是任意值。

重点在于弱引用

在比较MapWeakMap之前,了解何时使用特定的数据结构是至关重要的。如果您需要随时知道集合的键,或者需要遍历集合,那么您将需要使用Map而不是WeakMap,因为键是不可枚举的,也就是说,您无法获得后者中可用键的列表,因为它只维护了一个弱引用。

因此,自然而然地,前面的陈述应该在你的脑海中引起两个问题:

  • 如果我总是使用映射会发生什么?

  • 没什么,生活还在继续。你可能会或可能不会遇到内存泄漏,这取决于你如何使用你的映射。在大多数情况下,你会没事的。

  • 什么是弱引用?

  • 弱引用是一种允许对象引用的所有内容在所有引用者被移除时被垃圾回收的东西。困惑吗?很好。让我们看下面的例子来更好地理解它:

var map = new Map();

(function() {
 var key =  {}; <- Object 
 map.set(key, 10); <- referrer of the Object 
    // other logic which uses the map

})(); <- IIFE which is expected to remove the referrer once executed

我们都知道 IIFE 主要用于立即执行函数并删除其作用域,以避免内存泄漏。在这种情况下,尽管我们已经将key和地图设置器包装在 IIFE 中,但key并没有被垃圾回收,因为在内部Map仍然保留对key及其值的引用:

var myWeakMap = new WeakMap();

(function() {
 var key =  {};<- Object
 myWeakMap.set(key, 10);<- referrer of the Object

    // other logic which uses the weak map
})(); <- IIFE which is expected to remove the referrer once executed

当使用WeakMap编写相同的代码时,一旦执行 IIFE,键和该键的值将从内存中删除,因为键已经超出了作用域;这有助于将内存使用量保持在最低水平。

API 差异

在标准操作方面,MapWeakMap的 API 非常相似,例如set()get()。这使得 API 非常直观,并包括以下内容:

  • Map.prototype.size:返回地图的大小;在典型对象上不可用,除非您循环并计数

  • Map.prototype.set:为给定键设置值并返回整个新地图

  • Map.prototype.get:获取给定键的值,如果未找到则返回 undefined

  • Map.prototype.delete:删除给定键的值并在删除成功时返回true,否则返回false

  • Map.prototype.has:检查地图中是否存在具有提供的键的元素;返回布尔值

  • Map.prototype.clear:清除地图;返回空

  • Map.prototype.forEach:循环遍历地图并访问每个元素

  • Map.prototype.entries:返回一个迭代器,您可以在其上应用next()方法以获取Map中下一个元素的值,例如mapIterator.next().value

  • Map.prototype.keys:类似于entries返回一个迭代器,可用于访问下一个值

  • Map.prototype.values:类似于key;返回对值的访问

主要区别在于访问与WeakMap相关的键和值的任何内容。如前所述,由于在WeakMap的情况下存在枚举挑战,因此诸如size()forEach()entries()keys()values()等方法在WeakMap中不可用。

集合与 WeakSets

现在,我们了解了WeakMapWeakSet中 weak一词的基本含义。预测集合的工作方式以及WeakSet与其不同并不是非常复杂。让我们快速看一下功能差异,然后转向 API。

了解 WeakSets

WeakSetWeakMap非常相似;WeakSet可以容纳的值只能是对象,不能是原始值,就像WeakMap的情况一样。WeakSets也不可枚举,因此您无法直接访问集合中可用的值。

让我们创建一个小例子,了解SetWeakSet之间的区别:

var set = new Set();
var wset = new WeakSet();

(function() {

  var a = {a: 1};
  var b = {b: 2};
  var c = {c: 3};
  var d = {d: 4};

  set.add(1).add(2).add(3).add(4);
  wset.add(a).add(b).add(b).add(d);

})();

console.dir(set);
console.dir(wset);

一个重要的事情要注意的是WeakSet不接受原始值,只能接受与WeakMap键类似的对象。

前面代码的输出如下,这是从WeakSet中预期的。WeakSet不会保留元素超出持有它们的变量的寿命:

如预期的那样,一旦 IIFE 终止,WeakSet就为空了。

API 差异

WeakMap地图的情况下,文档中记录的 API 差异与集合的情况非常接近:

  • Set.prototype.size:返回集合的大小

  • Set.prototype.add:为给定元素添加值并返回整个新集合

  • Set.prototype.delete:删除一个元素并在删除成功时返回true,否则返回false

  • Set.prototype.has:检查集合中是否存在元素并返回布尔值

  • Set.prototype.clear:清除集合并返回空

  • Set.prototype.forEach:循环遍历集合并访问每个元素

  • Set.prototype.values:返回一个迭代器,可用于访问下一个值

  • Set.prototype.keys:类似于 values—返回对集合中值的访问

另一方面,WeakSet不包含forEach()keys()values()方法,原因在先前讨论过。

用例

在开始使用用例之前,让我们创建一个基础应用程序,它将像我们在第一章中所做的那样,为每个示例重复使用。

以下部分是创建基本 Angular 应用程序的快速回顾:

创建 Angular 应用程序

在进入个别用例之前,我们将首先创建 Angular 应用程序,这将作为我们示例的基础。

按照给定的命令启动应用程序:

  1. 安装 Angular CLI:
npm install -g @angular/cli
  1. 通过运行以下命令在您选择的文件夹中创建一个新项目:
ng new <project-name>
  1. 完成这两个步骤后,您应该能够看到新创建的项目以及所有相应的节点模块已安装并准备就绪。

  2. 要运行您的应用程序,请从终端运行以下命令:

ng serve

为您的应用程序创建自定义键盘快捷键

在大多数情况下,创建 Web 应用程序意味着拥有一个美观的 UI 和无障碍的数据。您希望用户能够流畅地体验,而不必通过点击多个页面来解决问题,有时这可能会变得相当麻烦。

拿任何 IDE 来说吧。尽管它们非常有用,而且在日常生活中非常方便,但想象一下如果它们没有简单的快捷方式,比如代码缩进。抱歉吓到你了,但事实上,像这样的细节可以使用户体验非常流畅,让用户愿意再次使用。

现在让我们创建一组简单的键盘快捷键,您可以为您的应用程序提供,以使最终用户的操作变得更加简单。要创建这个,您需要以下东西:

  • 一个 Web 应用程序(我们之前创建了一个)

  • 一组您希望能够使用键盘控制的功能

  • 一个足够简单的实现,使得向其添加新功能非常简单

如果您还记得来自第一章的自定义返回按钮,我们将创建一个类似的应用程序。让我们快速再次组合示例应用程序。有关详细说明,您可以按照相同的示例(创建 Angular 应用程序)从第一章中进行。

创建 Angular 应用程序

  1. 创建应用程序:
 ng new keyboard-shortcuts
  1. src/pages文件夹下创建多个状态(About、Dashboard、Home 和 Profile)并添加基本模板:
import { Component } from '@angular/core';

@Component({
   selector: 'home',
   template: 'home page' })
export class HomeComponent {

}
  1. <component_name>.routing.ts下创建该状态的路由:
import { HomeComponent } from './home.component';

export const HomeRoutes = [
   { path: 'home', component: HomeComponent },
];

export const HomeComponents = [
   HomeComponent
];
  1. app.routing.ts文件中添加新的routesComponents到应用程序的主路由文件app.module.ts旁边:
import { Routes } from '@angular/router';
import {AboutComponents, AboutRoutes} from "./pages/about/about.routing";
import {DashboardComponents, DashboardRoutes} from "./pages/dashboard/dashboard.routing";
import {HomeComponents, HomeRoutes} from "./pages/home/home.routing";
import {ProfileComponents, ProfileRoutes} from "./pages/profile/profile.routing";

export const routes: Routes = [
   {
 path: '',
 redirectTo: '/home',
 pathMatch: 'full'
  },
   ...AboutRoutes,
   ...DashboardRoutes,
   ...HomeRoutes,
   ...ProfileRoutes ];

export const navigatableComponents = [
   ...AboutComponents,
   ...DashboardComponents,
   ...HomeComponents,
   ...ProfileComponents ];
  1. 使用RouterModule注册应用程序的路由,并在app.module.ts文件中声明您的navigatableComponents
@NgModule({
    declarations: [
        AppComponent,
        ...navigatableComponents
    ],
    imports: [
        BrowserModule,
        FormsModule,
        RouterModule.forRoot(routes)
    ],
    providers: [],
    bootstrap: [AppComponent]
})
export class AppModule { }
  1. 创建 HTML 模板以在app.component.html中加载四个路由:
<nav>
    <button mat-button
            routerLink="/about"
  routerLinkActive="active">
        About
    </button>
    <button mat-button
            routerLink="/dashboard"
  routerLinkActive="active">
        Dashboard
    </button>
    <button mat-button
            routerLink="/home"
  routerLinkActive="active">
        Home
    </button>
    <button mat-button
            routerLink="/profile"
  routerLinkActive="active">
        Profile
    </button>
</nav>

<router-outlet></router-outlet>

一旦您完成了之前列出的所有步骤,请在终端中运行以下命令;Web 应用程序应该已经运行,并具有四个状态供您切换:

ng serve

使用 keymap 创建状态

到目前为止,在状态(或路由)中我们声明的是路径和我们想要与其一起使用的组件。Angular 允许我们添加一个名为data的新属性到路由配置中。这允许我们添加关于任何路由的任何数据。在我们的情况下,这非常有效,因为我们希望能够根据用户按下的键来切换路由。

因此,让我们以前定义的一个示例路由为例:

import { HomeComponent } from './home.component';

export const HomeRoutes = [
 { path: 'home', component: HomeComponent },
];

export const HomeComponents = [
 HomeComponent
];

现在我们将修改这个,并在路由配置中添加新的data属性:

import { HomeComponent } from './home.component';

export const HomeRoutes = [
 { path: 'home', component: HomeComponent, data: { keymap: 'ctrl+h'} },
];

export const HomeComponents = [
 HomeComponent
];

您可以看到,我们添加了一个名为keymap的属性及其值ctrl+h;我们也将对所有其他定义的路由执行相同的操作。在一开始要确定的一个重要事项是锚键(在这种情况下是ctrl),它将与次要的标识键(在这里是h代表主页路由)一起使用。这真的有助于过滤用户在应用程序中可能进行的按键。

一旦我们将每个路由与键映射关联起来,我们就可以在应用程序加载时注册所有这些键映射,然后开始跟踪用户活动,以确定他们是否选择了我们预定义的键映射中的任何一个。

要注册键映射,在app.component.ts文件中,我们将首先定义我们将保存所有数据的Map,然后从路由中提取数据,然后将其添加到Map中:*:

import {Component} from '@angular/core';
import {Router} from "@angular/router";

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss',  './theme.scss']
})
export class AppComponent {

    // defined the keyMap
    keyMap = new Map();

    constructor(private router: Router) {
        // loop over the router configuration
        this.router.config.forEach((routerConf)=> {

            // extract the keymap
            const keyMap = routerConf.data ? routerConf.data.keymap :
            undefined;

            // if keymap exists for the route and is not a duplicate,
            add
            // to master list
            if (keyMap && !this.keyMap.has(keyMap)) {
                this.keyMap.set(keyMap, `/${routerConf.path}`);
            }
        })
    }

}

一旦数据被添加到keyMap中,我们将需要监听用户交互,并确定用户想要导航到哪里。为此,我们可以使用 Angular 提供的@HostListener装饰器,监听任何按键事件,然后根据应用程序的要求过滤项目,如下所示:

import {Component, HostListener} from '@angular/core';
import {Router} from "@angular/router";

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss',  './theme.scss']
})
export class AppComponent {

    // defined the keyMap
  keyMap = new Map();

    // add the HostListener
    @HostListener('document:keydown', ['$event'])
    onKeyDown(ev: KeyboardEvent) {

        // filter out all non CTRL key presses and 
        // when only CTRL is key press
        if (ev.ctrlKey && ev.keyCode !== 17) {

            // check if user selection is already registered
            if (this.keyMap.has(`ctrl+${ev.key}`)) {

                // extract the registered path
                const path = this.keyMap.get(`ctrl+${ev.key}`);

                // navigate
                this.router.navigateByUrl(path);
            }
        }
    }

    constructor(private router: Router) {
        // loop over the router configuration
  this.router.config.forEach((routerConf)=> {

            // extract the keymap
  const keyMap = routerConf.data ? routerConf.data.keymap :
            undefined;

            // if keymap exists for the route and is not a duplicate,
            add
 // to master list  if (keyMap && !this.keyMap.has(keyMap)) {
                this.keyMap.set(keyMap, `/${routerConf.path}`);
            }
        })
    }
}

现在我们可以轻松地定义和导航到路由,每当用户进行按键时。然而,在我们继续之前,我们需要换个角度来理解下一步。考虑一下,你是最终用户,而不是开发人员。你怎么知道绑定是什么?当你想要绑定页面上的路由以及按钮时,你该怎么办?你怎么知道自己是否按错了键?

所有这些都可以通过对我们目前的情况进行非常简单的 UX 审查来解决,以及我们需要的东西。很明显,我们需要向用户显示他们正在选择的内容,以便他们不会用错误的键组合不断地攻击我们的应用程序。

首先,为了告知用户他们可以选择什么,让我们修改导航,以便每个路由名称的第一个字符被突出显示。让我们还创建一个变量来保存用户选择的值,在 UI 上显示它,并在几毫秒后清除它。

我们可以修改我们的app.component.scss文件,如下所示:

.active {
    color: red;
}

nav {
    button {
      &::first-letter {
        font-weight:bold;
        text-decoration: underline;
        font-size: 1.2em;
      }
    }
}

.bottom-right {
  position: fixed;
  bottom: 30px;
  right: 30px;
  background: rgba(0,0,0, 0.5);
  color: white;
  padding: 20px;
}

我们的模板在最后增加了一个内容,显示用户按下的键:

<nav>
    <button mat-button
            routerLink="/about"
  routerLinkActive="active">
        About
    </button>
    <button mat-button
            routerLink="/dashboard"
  routerLinkActive="active">
        Dashboard
    </button>
    <button mat-button
            routerLink="/home"
  routerLinkActive="active">
        Home
    </button>
    <button mat-button
            routerLink="/profile"
  routerLinkActive="active">
        Profile
    </button>
</nav>

<router-outlet></router-outlet>

<section [class]="keypress? 'bottom-right': ''">
    {{keypress}}
</section>

我们的app.component.ts最终形式如下:

import {Component, HostListener} from '@angular/core';
import {Router} from "@angular/router";

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss',  './theme.scss']
})
export class AppComponent {

    // defined the keyMap
  keyMap = new Map();

    // defined the keypressed
  keypress: string = '';

    // clear timer if needed
 timer: number;

    // add the HostListener
  @HostListener('document:keydown', ['$event'])
    onKeyDown(ev: KeyboardEvent) {

        // filter out all non CTRL key presses and
 // when only CTRL is key press  if (ev.ctrlKey && ev.keyCode !== 17) {

            // display user selection
  this.highlightKeypress(`ctrl+${ev.key}`);

            // check if user selection is already registered
  if (this.keyMap.has(`ctrl+${ev.key}`)) {

                // extract the registered path
  const path = this.keyMap.get(`ctrl+${ev.key}`);

                // navigate
  this.router.navigateByUrl(path);
            }
        }
    }

    constructor(private router: Router) {
        // loop over the router configuration
  this.router.config.forEach((routerConf)=> {

            // extract the keymap
  const keyMap = routerConf.data ? routerConf.data.keymap :
            undefined;

            // if keymap exists for the route and is not a duplicate,
            add
 // to master list  if (keyMap && !this.keyMap.has(keyMap)) {
                this.keyMap.set(keyMap, `/${routerConf.path}`);
            }
        })
    }

    highlightKeypress(keypress: string) {
        // clear existing timer, if any
  if (this.timer) {
            clearTimeout(this.timer);
        }

        // set the user selection
  this.keypress = keypress;

        // reset user selection
  this.timer = setTimeout(()=> {
            this.keypress = '';
        }, 500);
    }

}

这样,用户总是知道他们的选择,使您的应用程序的整体可用性更高。

无论用户选择什么,只要按住Ctrl键,他们总是会在屏幕的右下角看到他们的选择。

网络应用程序的活动跟踪和分析

每当有人提到分析,特别是针对网络应用程序时,通常首先想到的是像 Google 分析或新的遗迹之类的东西。尽管它们在收集页面浏览和自定义事件等分析方面做得很好,但这些工具会将数据保留在它们那里,不允许您下载/导出原始数据。因此,有必要构建自己的自定义模块来跟踪用户操作和活动。

活动跟踪和分析是复杂的,随着应用程序规模的增长,很快就会失控。在这个用例中,我们将构建一个简单的网络应用程序,我们将跟踪用户正在进行的自定义操作,并为将应用程序数据与服务器同步打下一些基础。

在我们开始编码之前,让我们简要讨论一下我们的方法将是什么,以及我们将如何利用可用的 Angular 组件。在我们的应用程序中,我们将为用户构建一个基本表单,他们可以填写并提交。当用户与表单上可用的不同组件进行交互时,我们将开始跟踪用户活动,然后根据生成的事件提取一些自定义数据。这些自定义数据显然会根据正在构建的应用程序而改变。为简洁起见,我们将简单地跟踪事件的时间、xy坐标以及自定义值(如果有)。

创建 Angular 应用程序

首先,让我们创建一个 Angular 应用程序,就像我们在前面的用例中所做的那样:

ng new heatmap

这应该创建应用程序,并且应该准备就绪。只需进入您的项目文件夹并运行以下命令即可查看您的应用程序运行:

ng serve

应用程序启动后,我们将包含 Angular 材料,这样我们就可以快速拥有一个漂亮的表单。要在您的 Angular 应用程序中安装材料,请运行以下命令:

npm install --save @angular/material @angular/animations @angular/cdk

安装material后,在主app.module.js中包含您选择的模块,在这种情况下,将是MatInputModuleReactiveFormsModule,因为我们将需要它们来创建表单。之后,您的app.module.js将如下所示:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import { MatInputModule } from '@angular/material';

import { AppComponent } from './app.component';

@NgModule({
    declarations: [
        AppComponent
    ],
    imports: [
        BrowserModule,
        FormsModule,
        ReactiveFormsModule,
        BrowserAnimationsModule,
        MatInputModule
    ],
    providers: [

    ],
    bootstrap: [AppComponent]
})
export class AppModule { }

现在我们已经设置好了应用程序,我们可以设置我们的模板,这将是非常简单的,所以让我们将以下模板添加到我们的app.component.html文件中:

<form>
    <mat-input-container class="full-width">
        <input matInput placeholder="Company Name">
    </mat-input-container>

    <table class="full-width" cellspacing="0">
        <tr>
            <td>
                <mat-input-container class="full-width">
                    <input matInput placeholder="First Name">
                </mat-input-container>
            </td>
            <td>
                <mat-input-container class="full-width">
                    <input matInput placeholder="Last Name">
                </mat-input-container>
            </td>
        </tr>
    </table>
    <p>
        <mat-input-container class="full-width">
            <textarea matInput placeholder="Address"></textarea>
        </mat-input-container>
        <mat-input-container class="full-width">
            <textarea matInput placeholder="Address 2"></textarea>
        </mat-input-container>
    </p>

    <table class="full-width" cellspacing="0">
        <tr>
            <td>
                <mat-input-container class="full-width">
                    <input matInput placeholder="City">
                </mat-input-container>
            </td>
            <td>
                <mat-input-container class="full-width">
                    <input matInput placeholder="State">
                </mat-input-container>
            </td>
            <td>
                <mat-input-container class="full-width">
                    <input matInput #postalCode maxlength="5" placeholder="Postal Code">
                    <mat-hint align="end">{{postalCode.value.length}} / 5</mat-
                    hint>
                </mat-input-container>
            </td>
        </tr>
    </table>
</form>

这是一个带有用户详细信息标准字段的简单表单;我们将稍微调整它的样式,使其居中显示在页面上,因此我们可以更新我们的app.component.scss文件以包含我们的样式:

body {
  position: relative;
}

form {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.full-width {
  width: 100%;
}

这是 UI 上的最终结果:

现在我们已经准备好表单,我们需要一个活动跟踪器,这将非常轻量级,因为我们将经常调用它。

一个很好的做法是将跟踪逻辑移入 Web Worker;这样,您的跟踪器将不会占用唯一可用的线程,从而使您的应用程序免受任何额外负载的影响。

在我们实际开始创建 Web Worker 之前,我们需要一些东西来调用我们的工作人员;为此,我们将创建一个跟踪器服务。此外,为了使工作人员可以包含在 Angular 项目中,我们将将其添加到.angular-cli.json文件的scripts选项中,这将允许我们将其用作外部脚本调用scripts.bundle.js,该脚本是由webpack从文件utils/tracker.js生成的。

让我们在名为services的文件夹下创建一个名为tracker的文件夹,然后创建一个tracker.service.ts文件:

import {Injectable} from '@angular/core';

@Injectable()
export class TrackerService {
    worker: any;

    constructor() {
        this.setupTracker();
    }

    setupTracker () {
        this.worker = new Worker('scripts.bundle.js');
    }

    addEvent(key: string, event: any, customValue ?: string) {
        this.worker.postMessage({
            key: key,
            user: 'user_id_here'
            event: {
                pageX: event.pageX,
                pageY: event.pageY
  },
            customValue : customValue
        });
    }
}

这里没有什么特别的;当我们触发服务并添加了addEvent()方法时,我们初始化了工作人员,该方法接受一些参数,如事件的名称(键)、事件数据(或其部分)和自定义值(如果有)。我们将其余的逻辑推迟到工作人员,以便我们的应用程序是无缝的。

但是,要触发服务的构造函数,我们需要将服务添加到主模块的提供者中。因此,我们的app.module.ts现在更新为以下内容:

....
import {TrackerService} from "./service/tracker/tracker.service";

@NgModule({
    ....,
    providers: [
        TrackerService
    ],
    bootstrap: [AppComponent]
})
export class AppModule { }

好了,现在我们已经启动了应用程序并设置好了工作人员。但是,实际上是什么在调用addEvent()方法来跟踪这些自定义事件的?您可以执行以下一项或两项操作:

  • TrackerService注入到您的组件/服务中,并使用正确的参数调用addEvent()方法

  • 创建一个指令来捕获点击并使用TrackerService上的addEvent()方法同步数据

对于这个例子,我们将采用第二种方法,因为我们有一个表单,不想为每个元素添加点击处理程序。让我们创建一个directives文件夹和另一个名为tracker的文件夹,其中将包含我们的tracker.directive.ts

import {Directive, Input, HostListener} from '@angular/core';
import {TrackerService} from "../../service/tracker/tracker.service";

@Directive({
    selector: '[tracker]',
})
export class tracker {

    @Input('tracker') key: string;

    constructor(private trackerService: TrackerService) {}

    @HostListener('click', ['$event'])
    clicked(ev: MouseEvent) {
        this.trackerService.addEvent(this.key, ev);
    }
}

你可以看到指令非常简洁;它注入了TrackerService,然后在点击时触发addEvent()方法。

为了使用它,我们只需要将指令添加到之前创建的表单的输入元素中,就像这样:

<input matInput placeholder="First Name" tracker="first-name">

现在,当用户与表单上的任何字段进行交互时,我们的 worker 会收到更改通知,现在我们的 worker 基本上要批量处理事件并将其保存在服务器上。

让我们快速回顾一下我们到目前为止所做的事情:

  1. 我们设置了 worker 并通过我们的TrackerService的构造函数调用它,该构造函数在应用程序启动时实例化。

  2. 我们创建了一个简单的指令,能够检测点击,提取事件信息,并将其传递给TrackerService,以便转发给我们的 worker。

上面的截图显示了到目前为止应用程序的目录结构。

我们的下一步将是更新我们的 worker,以便我们可以轻松处理传入的数据,并根据应用程序的逻辑将其发送到服务器。

让我们将utils/tracker.js下的 worker 分解为简单的步骤:

  • 工人从TrackerService接收消息,然后将该消息转发以添加到事件主列表中:
var sessionKeys = new Set();
var sessionData = new Map();
var startTime = Date.now();
var endTime;

self.addEventListener('message', function(e) {
 addEvent(e.data);
});

我们将通过维护两个列表来做一些不同的事情,一个用于保存正在保存的键,另一个将键映射到我们正在接收的数据集合。

  • addEvent()方法然后分解传入的数据并将其存储在正在收集的项目的主列表中,以便与数据库同步:
function addEvent(data) {
   var key = data.key || '';
   var event = data.event || '';
   var customValue = data.customValue || '';
   var currentOccurrences;

   var newItem = {
      eventX: event.pageX,
      eventY: event.pageY,
      timestamp: Date.now(),
      customValue: customValue ? customValue : ''
  };

   if (sessionKeys.has(key)) {
      currentOccurrences = sessionData.get(key);
      currentOccurrences.push(newItem);

      sessionData.set(key, currentOccurrences);
   } else {
      currentOccurrences = [];
      currentOccurrences.push(newItem);

      sessionKeys.add(key);
      sessionData.set(key, currentOccurrences);
   }

   if (Math.random() > 0.7) {
      syncWithServer(data.user);
   }
}

我们将尝试检查用户是否已经与提供的键的元素进行了交互。如果是,我们将其追加到现有的事件集合中;否则,我们将创建一个新的事件集合。这个检查是我们利用集合及其极快的has()方法的地方,我们将在下一节中探讨。

除此之外,我们现在唯一需要的逻辑是根据预定的逻辑将这些数据与服务器同步。正如你所看到的,现在我们只是基于一个随机数来做这个,但是,当然,这对于生产应用程序是不推荐的。相反,你可以根据用户与应用程序的交互程度来学习,并相应地进行同步。对于一些应用程序跟踪服务来说,这太多了,所以他们采用更简单的方法,要么按照固定的时间间隔进行同步(几秒钟的顺序),要么根据有效负载大小进行同步。你可以根据应用程序的需求采取任何这些方法。

然而,一旦你掌握了这一点,一切都很简单:

function syncWithServer(user) {
   endTime = Date.now();

   fakeSyncWithDB({
      startTime: startTime,
      endTime: endTime,
      user: user,
      data: Array.from(sessionData)
   }).then(function () {
      setupTracker();
   });
}

function fakeSyncWithDB(data) {
   //fake sync with DB
  return new Promise(function (resolve, reject) {
      console.dir(data);
      resolve();
   });
}

function setupTracker() {
   startTime = Date.now();
   sessionData.clear();
   sessionKeys.clear();
}

这里需要注意的一件奇怪的事情是,在将数据发送到服务器之前,我们将数据转换为数组的方式。也许我们可以直接传递整个sessionData?也许,但它是一个 Map,这意味着数据无法直接访问,你必须使用.entires().values()来获取一个迭代器对象,然后可以迭代地从地图中获取数据。虽然在处理数组时,需要在将数据发送到服务器之前转换数据似乎有点倒退,但考虑到 Map 为我们的应用程序提供的其他好处,这样做是非常值得的。

现在让我们来看看在我们的tracker.js文件中如何将所有内容整合在一起:

var sessionKeys = new Set();
var sessionData = new Map();
var startTime = Date.now();
var endTime;

self.addEventListener('message', function(e) {
   addEvent(e.data);
});

function addEvent(data) {
   var key = data.key || '';
   var event = data.event || '';
   var customValue = data.customValue || '';
   var currentOccurrences;

   var newItem = {
      eventX: event.pageX,
      eventY: event.pageY,
      timestamp: Date.now(),
      customValue: customValue ? customValue : ''
  };

   if (sessionKeys.has(key)) {
      currentOccurrences = sessionData.get(key);
      currentOccurrences.push(newItem);

      sessionData.set(key, currentOccurrences);
   } else {
      currentOccurrences = [];

      currentOccurrences.push(newItem);
      sessionKeys.add(key);

      sessionData.set(key, currentOccurrences);
   }

   if (Math.random() > 0.7) {
      syncWithServer(data.user);
   }
}

function syncWithServer(user) {
   endTime = Date.now();

   fakeSyncWithDB({
      startTime: startTime,
      endTime: endTime,
      user: user,
      data: Array.from(sessionData)
   }).then(function () {
      setupTracker();
   });
}

function fakeSyncWithDB(data) {
   //fake sync with DB
  return new Promise(function (resolve, reject) {
      resolve();
   });
}

function setupTracker() {
   startTime = Date.now();
   sessionData.clear();
   sessionKeys.clear();
}

正如你在上面的代码中所注意到的,集合和映射悄然而有效地改变了我们设计应用程序的方式。我们不再只有一个简单的数组和一个对象,而是实际上可以使用一些具体的数据结构和一组固定的 API,这使我们能够简化我们的应用程序逻辑。

性能比较

在本节中,我们将比较集合和映射与它们的对应物:数组和对象的性能。正如前几章所述,进行比较的主要目标不是要知道数据结构优于其本机对应物,而是要了解它们的局限性,并确保在尝试使用它们时做出明智的决定。

重要的是要以一颗谷物的方式对待基准测试。基准测试工具通常使用诸如 V8 之类的引擎,这些引擎被构建和优化以以一种与其他一些基于 Web 的引擎非常不同的方式运行。这可能导致结果在应用程序运行的环境中有些偏差。

我们需要做一些初始设置来运行我们的性能基准测试。要创建一个 Node.js 项目,请转到终端并运行以下命令:

mkdir performance-sets-maps

这将设置一个空目录;现在,进入目录并运行npm初始化命令:

cd performance-sets-maps
npm init

这一步将询问您一系列问题,所有问题都可以填写或留空,视您的意愿而定。

项目设置好后,接下来我们将需要基准测试工具,我们可以使用npm安装它:

npm install benchmark --save

现在,我们准备开始运行一些基准测试套件。

集合和数组

由于benchmark工具,创建和运行suite非常容易。我们只需要设置我们的sets-arr.js文件,然后就可以开始了:

var Benchmark = require("benchmark");
var suite = new Benchmark.Suite();

var set = new Set();
var arr = [];

for(var i=0; i < 1000; i++) {
   set.add(i);
   arr.push(i);
}

suite
  .add("array #indexOf", function(){
      arr.indexOf(100) > -1;
   })
   .add("set #has", function(){
      set.has(100);
   })   .add("array #splice", function(){
      arr.splice(99, 1);
   })
   .add("set #delete", function(){
      set.delete(99);
   })
   .add("array #length", function(){
      arr.length;
   })
   .add("set #size", function(){
      set.size;
   })
   .on("cycle", function(e) {
      console.log("" + e.target);
   })
   .run();

您可以看到设置非常简单明了。一旦创建了新的suite,我们为初始加载设置一些数据,然后可以将我们的测试添加到suite中:

var set = new Set();
var arr = [];

for(var i=0; i < 1000; i++) {
 set.add(i);
 arr.push(i);
}

要执行这个suite,您可以从终端运行以下命令:

node sets-arr.js

suite的结果如下:

请注意,在这个设置中,集合比数组稍快。当然,我们在测试中使用的数据也会导致结果的变化;您可以通过在数组和集合中存储的数据类型之间切换来尝试一下。

映射和对象

我们将在一个名为maps-obj.js的文件中为映射和对象设置类似的设置,这将给我们类似以下的东西:

var Benchmark = require("benchmark");
var suite = new Benchmark.Suite();

var map = new Map();
var obj = {};

for(var i=0; i < 100; i++) {
   map.set(i, i);
   obj[i] = i;
}

suite
  .add("Object #get", function(){
      obj[19];
   })
   .add("Map #get", function(){
      map.get(19);
   })
   //
  .add("Object #delete", function(){
      delete obj[99];
   })
   .add("Map #delete", function(){
      map.delete(99);
   })
   .add("Object #length", function(){
      Object.keys(obj).length;
   })
   .add("Map #size", function(){
      map.size;
   })
   .on("cycle", function(e) {
      console.log("" + e.target);
   })
   .run();

现在,要运行这个suite,请在终端上运行以下命令:

node maps-obj.js

这将给我们以下结果:

您可以看到Object在这里远远优于映射,并且显然是两者中更好的,但它不提供映射能够提供的语法糖和一些功能。

总结

在本章中,我们深入研究了集合和映射,它们的较弱对应物以及它们的 API。然后,我们在一些真实世界的例子中使用了集合和映射,比如使用集合进行导航的应用程序的键盘快捷键和使用集合和映射进行应用程序分析跟踪器。然后,我们通过对象和数组之间的性能比较结束了本章。

在下一章中,我们将探讨树以及如何利用它们使我们的 Web 应用程序更快,代码复杂性更低。

第四章:使用树进行更快的查找和修改

树是最先进和复杂的数据结构之一。它为图论打开了大门,用于表示对象之间的关系。对象可以是任何类型,只要它们有一个确定的关系,就可以以树的形式表示。

尽管有成千上万种树,但在本章中不可能涵盖所有树,因此我们将采取不同的方法,在示例中以更实际的方式学习树,而不是像在之前的章节中那样提前学习。

在本章中,我们将探讨以下主题:

  • 创建一个基本的 Angular 应用程序,

  • 使用trie 树创建一个自动完成查找组件

  • 使用 ID3 算法创建信用卡批准预测器。

所以,让我们深入研究一下。

创建一个 Angular 应用程序

在我们实现任何树之前,让我们建立一个基本应用程序,我们可以在随后的示例中使用。

就像我们在之前的章节中所做的那样,我们将使用 Angular CLI 创建一个 Angular 应用程序,具体步骤如下:

  1. 使用以下命令安装 Angular CLI(如果尚未完成):
npm install -g @angular/cli
  1. 通过运行以下命令在您选择的文件夹中创建新项目:
ng new <project-name>

完成这两个步骤后,您应该能够看到新创建的项目以及所有相应的节点模块已安装并准备就绪。

  1. 要运行您的应用程序,请从终端运行以下命令:
ng serve

创建一个自动完成查找

想象一下,你有一个用户注册表格,你的用户必须填写他们的信息,包括他们的国家。幸运的是,只有固定数量的国家,所以围绕填充和选择的用户体验可以变得非常有趣和简单,而不是让他们浏览数百个选项。

在这个例子中,我们将创建一个 trie 树,并预先填充它与所有国家的列表。然后用户可以输入他们国家的名称,我们的组件将作为自动完成,并向用户显示可用的选项。

现在让我们讨论为什么我们需要 trie 树。根据维基百科的定义,简单 trie 树的定义如下:

在计算机科学中,trie,也称为数字树,有时称为基数树或前缀树(因为它们可以通过前缀搜索),是一种搜索树——一种用于存储动态集合或关联数组的有序树数据结构,其中键通常是字符串

换句话说,trie 树是一种优化的搜索树,其中键是字符串。让我们用一个简单的例子来说明这一点:

假设我们有一个字符串数组:

var friends = [ 'ross', 'rachel', 'adam', 'amy', 'joey'];

这个,当转换成trie树时,会看起来像这样:

从上图中,我们可以注意到树从根开始,然后根据输入字符串构建树。在将字符串插入到trie树中时,单词被分解为单个字符,然后重复的节点不会被重新插入,而是被重用来构建树的其余部分。

创建一个 trie 树

现在让我们创建trie树,在我们的应用程序中使用。在我们的应用程序中,让我们首先在src文件夹下创建一个名为utils的目录,在其中添加我们的trie.ts文件。

我们树的 API 将非常简洁,只有两种方法:

  • add():向trie树添加元素

  • search():接受一个输入字符串并返回与查询字符串匹配的子树:

import {Injectable} from "@angular/core";

@Injectable()
export class Trie {
    tree: any = {};

    constructor() {}

}

创建完毕后,让我们将其注入到我们主模块中的提供者列表中,列在app.module.ts中,以便我们的组件可以访问树,如下所示:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import {Trie} from "./utils/trie";

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule
  ],
  providers: [
      Trie
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

实现add()方法

现在,我们的树已经准备好实现其第一个方法了。我们的树一开始是空的(即一个空对象)。您可以使用任何数据结构来实现,但为了简单起见,我们将使用对象作为我们的数据存储:

add(input) {
    // set to root of tree
  var currentNode = this.tree;

    // init next value
  var nextNode = null;

    // take 1st char and trim input
    // adam for ex becomes a and dam
  var curChar = input.slice(0,1);
    input = input.slice(1);

    // find first new character, until then keep trimming input
  while(currentNode[curChar] && curChar){
        currentNode = currentNode[curChar];

        // trim input
  curChar = input.slice(0,1);
        input = input.slice(1);
    }

    // while next character is available keep adding new branches and 
    // prune till end
  while(curChar) {
        // new reference in each loop   nextNode = {};

        // assign to current tree node
  currentNode[curChar] = nextNode;

        // hold reference for next loop
  currentNode = nextNode;

        // prepare for next iteration
  curChar = input.slice(0,1);
        input = input.slice(1);
    }
}

正如您在前面的代码中所看到的,这种方法由以下两个步骤组成:

  1. 确定树已经建到哪一级并忽略这些字符。

  2. 将余数作为新的子树添加并继续直到结束。

朋友的例子

让我们在一个示例中运用我们的知识,其中我们的用户想要向这棵树添加两个元素AdamAdrian。首先,我们将Adam添加到树中,所以我们有节点adam。然后,当添加Adrian时,我们检查已经添加的内容——在这种情况下是ad,因此单词的其余部分rian被添加为新的子树。

当记录时,我们看到以下内容:

正如您从前面的截图中所看到的,ad对于两个单词都是相同的,然后其余部分是我们添加的每个字符串的两个子树。

实现search()方法

search()方法更简单,效率更高,复杂度为 O(n),其中 n 是搜索输入的长度。大 O 符号将在后面的章节中详细介绍:

search(input) {
    // get the whole tree
    var currentNode = this.tree;
    var curChar = input.slice(0,1);

    // take first character
    input = input.slice(1);

   // keep extracting the sub-tree based on the current character
    while(currentNode[curChar] && curChar){
        currentNode = currentNode[curChar];
        curChar = input.slice(0,1);
        input = input.slice(1);
    }

    // reached the end and no sub-tree found
    // e.g. no data found
    if (curChar && !currentNode[curChar]) {
        return {};
    }

    // return the node found
    return currentNode;
}

让我们以前面代码中描述的朋友示例为例。例如,如果用户输入 a,我们使用刚刚实现的search()方法提取子树。我们得到了 a*下面的子树。

用户提供的输入字符越多,响应对象就越精细:

从前面的截图中可以看出,随着用户输入更多内容,我们的search()方法会持续返回与之匹配的节点的子树,而整个树可以在其下面看到。为了在屏幕上呈现它,我们使用以下代码。

在我们的app.component.ts文件中,我们添加以下内容,查询Trie类上的search()方法:

import { Component } from '@angular/core';
import {Trie} from "../utils/trie";

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent {
    countries = ["Afghanistan","Albania","Algeria",...,"Yemen","Zambia","Zimbabwe"];
    searchResp = [];

    constructor(private trie : Trie) {
        this.countries.forEach((c) => {
            this.trie.add(c); 
        });
    }

    search(key) {
        this.searchResp = this.trie.search(key).remainder;
    }
}

然后,使用简单的pre标记将此搜索结果绑定到模板:

<pre>{{searchResp}}</pre>

节点保留余数

我们之前实现的search()方法效果很好;然而,作为开发人员,您现在需要循环遍历返回的子树,并构建出其中的单词余数,以便在 UI 上显示。这有点麻烦,不是吗?如果我们可以简化它,使树能够返回子树以及它们形成的单词余数,那会怎么样?实际上,实现这一点相当容易。

我们需要对算法进行小的修改,并在每个节点处添加余数集合;这样,每当识别到一个节点时,我们可以将余数添加到该集合中,并在创建新节点时将新元素推入该集合。让我们看看这如何修改我们的代码:

add(input) {
    // set to root of tree
  var currentNode = this.tree;

    // init value
  var nextNode = null;

    // take 1st char and trim input
  var curChar = input.slice(0,1);
    input = input.slice(1);

    // find first new character, until then keep triming input
  while(currentNode[curChar] && curChar){
        currentNode = currentNode[curChar];

        // update remainder array, this will exist as we added the node
        earlier
  currentNode.remainder.push(input);

        // trim input
  curChar = input.slice(0,1);
        input = input.slice(1);
    }

    // while next character is available keep adding new branches and
    prune till end
  while(curChar) {
        // new reference in each loop
 // create remainder array starting with current input // so when adding the node `a` we add to the remainder `dam`
        and so on  nextNode = {
            remainder: [input]
        };

        // assign to current tree node
  currentNode[curChar] = nextNode;

        // hold reference for next loop
  currentNode = nextNode;

        // prepare for next iteration
  curChar = input.slice(0,1);
        input = input.slice(1);
    }
}

正如您在前面的代码中所看到的,添加两行使我们的工作比以前更容易了。不再需要在子树对象上进行不必要的循环,我们在子树的每个节点上都返回了单词的余数:

这也意味着我们必须更新我们的search()方法的失败条件,以返回一个带有remainder设置的空对象,而不是一个空对象,与以前不同:

search(input) {
    var currentNode = this.tree;
    var curChar = input.slice(0,1);

    input = input.slice(1);

    while(currentNode[curChar] && curChar){
        currentNode = currentNode[curChar];
        curChar = input.slice(0,1);
        input = input.slice(1);
    }

    if (curChar && !currentNode[curChar]) {
        return {
            remainder: [] 
        };
    }

    return currentNode;
}

最终形式

将所有这些放在一起,并对我们的 UI 进行简单的更改,我们最终可以以非常快速和高效的方式搜索列表并显示结果。

经过前面的更改,我们的app.component.ts已经准备好最终形式:

import { Component } from '@angular/core';
import {Trie} from "../utils/trie";

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent {
    countries = ["Afghanistan","Albania","Algeria","Andorra","Angola","Anguilla","Antigua & Barbuda","Argentina","Armenia","Aruba","Australia","Austria","Azerbaijan","Bahamas","Bahrain","Bangladesh","Barbados","Belarus","Belgium","Belize","Benin","Bermuda","Bhutan","Bolivia","Bosnia & Herzegovina","Botswana","Brazil","British Virgin Islands","Brunei","Bulgaria","Burkina Faso","Burundi","Cambodia","Cameroon","Cape Verde","Cayman Islands","Chad","Chile","China","Colombia","Congo","Cook Islands","Costa Rica","Cote D Ivoire","Croatia","Cruise Ship","Cuba","Cyprus","Czech Republic","Denmark","Djibouti","Dominica","Dominican Republic","Ecuador","Egypt","El Salvador","Equatorial Guinea","Estonia","Ethiopia","Falkland Islands","Faroe Islands","Fiji","Finland","France","French Polynesia","French West Indies","Gabon","Gambia","Georgia","Germany","Ghana","Gibraltar","Greece","Greenland","Grenada","Guam","Guatemala","Guernsey","Guinea","Guinea Bissau","Guyana","Haiti","Honduras","Hong Kong","Hungary","Iceland","India","Indonesia","Iran","Iraq","Ireland","Isle of Man","Israel","Italy","Jamaica","Japan","Jersey","Jordan","Kazakhstan","Kenya","Kuwait","Kyrgyz Republic","Laos","Latvia","Lebanon","Lesotho","Liberia","Libya","Liechtenstein","Lithuania","Luxembourg","Macau","Macedonia","Madagascar","Malawi","Malaysia","Maldives","Mali","Malta","Mauritania","Mauritius","Mexico","Moldova","Monaco","Mongolia","Montenegro","Montserrat","Morocco","Mozambique","Namibia","Nepal","Netherlands","Netherlands Antilles","New Caledonia","New Zealand","Nicaragua","Niger","Nigeria","Norway","Oman","Pakistan","Palestine","Panama","Papua New Guinea","Paraguay","Peru","Philippines","Poland","Portugal","Puerto Rico","Qatar","Reunion","Romania","Russia","Rwanda","Saint Pierre & Miquelon","Samoa","San Marino","Satellite","Saudi Arabia","Senegal","Serbia","Seychelles","Sierra Leone","Singapore","Slovakia","Slovenia","South Africa","South Korea","Spain","Sri Lanka","St Kitts & Nevis","St Lucia","St Vincent","St. Lucia","Sudan","Suriname","Swaziland","Sweden","Switzerland","Syria","Taiwan","Tajikistan","Tanzania","Thailand","Timor L'Este","Togo","Tonga","Trinidad & Tobago","Tunisia","Turkey","Turkmenistan","Turks & Caicos","Uganda","Ukraine","United Arab Emirates","United Kingdom","Uruguay","Uzbekistan","Venezuela","Vietnam","Virgin Islands (US)","Yemen","Zambia","Zimbabwe"];
    searchResp = [];

    constructor(private trie : Trie) {
        this.countries.forEach((c) => {
            this.trie.add(c); 
        });
    }

    search(key) {
        this.searchResp = this.trie.search(key).remainder;
    }
}

类似地,更新app.component.html模板以显示搜索结果:


<input type="text" placeholder="search countries" #searchInp (keyup)="search(searchInp.value)" />

<div *ngFor="let resp of searchResp">
    <strong>{{searchInp.value}}</strong>{{resp}}
</div>

<div *ngIf="searchInp.value && !searchResp.length">
    No results found for {{searchInp.value}}
</div>

结果如下:

创建信用卡批准预测器

树是无处不在的。无论您使用什么应用程序,都有可能在内部使用树。话虽如此,并非所有树都是数据结构。在这个例子中,我们将探索一些不同的东西,这是一种非常流行但不是典型的数据结构,即决策树。

在某个阶段,您可能已经遇到某种自动预测系统。无论是预测游戏赢家的体育网站,还是告诉您应该申请哪张信用卡以获得快速批准的信用评分网站。在这个例子中,我们将使用信用卡批准预测器,但这可以轻松转移到您选择的任何应用程序。

在大多数情况下,我们有一个复杂的机器学习模型在后台运行,以生成准确的预测,但是,因为我们知道影响批准或拒绝的因素数量是有限的,我们可以使用决策树来确定基于过去展示的模式的批准机会。以下是我们在这个例子中需要完成的任务列表:

  1. 通过实现迭代二分器 3ID3)算法创建决策树以对未来样本进行分类。

  2. 创建训练数据集。

  3. 运行新输入通过算法并验证响应。

ID3 算法

到目前为止,我们所见过的算法并不是非常复杂;它们相当琐碎,我们大部分的关注点都在实现特定数据结构的 API 上。在这个例子中,没有数据结构需要实现;算法本身生成了我们将用于应用程序的决策树。

首先,让我们看看如何将历史数据表转换为决策树。主要形成的树由决策节点(表示决策)和叶节点(表示最终响应,如是或否)组成。决策节点可以有两个或更多的子节点,取决于数据集。然而,树必须从某个地方开始,对吧?根节点是什么,我们如何得到它?

为了确定根节点,我们首先需要在这里了解一些信息理论的基础知识:

  • :熵是一系列输入的不确定性度量——输入消息越不确定,就需要更多的输入来确定消息是什么;例如,如果我们的输入系列总是发送相同的消息,那么就没有不确定性,因此熵为零。这样的输入系列也被称为纯的。然而,如果相同的系列以相同的概率发送n种不同类型的输入,那么熵就会变高,接收者需要询问 log[2]n 个布尔问题来确定消息。需要识别消息的平均位数是发送者熵的度量。

  • 信息增益:为了确定根节点,首先我们需要根据提供的属性拆分数据集,然后确定每个属性的熵,每个属性的熵与目标的差异确定了每个属性的信息增益或损失。

具有最高信息增益的属性成为根属性。然后,我们为每个子树重复该过程,直到没有熵为止。让我们通过一个例子来看看这个,然后开始编码。

对于以下示例,我们将采用简单的输入和流行的数据集,根据天气条件决定是否要玩游戏:

外观 温度 湿度 踢足球
晴朗 炎热
晴朗 炎热
阴天 炎热
温和
凉爽 正常
晴朗 凉爽 正常
温和 正常
晴朗 温和 正常
阴天 温和
Overcast Hot Normal Weak Yes
Rain Cool Normal Strong No
Overcast Cool Normal Strong Yes
Sunny Mild High Weak No

在上面的例子中,目标是Play Soccer属性。假设我们的输入源有发送n条消息的能力,每条消息发送的概率是 P[n],那么源的熵是概率p[i] * log2n的总和。

计算目标熵

由于Play Soccer(目标)属性有两种可能的输出,我们将使用目标属性的频率表(指示接收到特定值的次数)来计算熵:

接收 yes 的概率是接收到的总次数除以接收到的消息总数,依此类推。

Play Soccer
Yes
9

因此,目标的熵如下:

targetEntropy =  -( (9/13) log2 (9/13) ) - ( (4/13) log2 (4/13) );
targetEntropy = 0.89049164021;

计算分支熵

现在,让我们进一步分解数据集,并根据每个分支计算熵。我们这里有以下四个主要分支:

  • Outlook

  • Temperature

  • Humidity

  • Wind

让我们首先从 Outlook 分支开始:

Play
Yes No Total
Outlook Sunny 2 3 5
Overcast 4 0 4
Rain 3 1 4
13

为了计算分支的熵,我们将首先计算每个子分支的概率,然后将其与该分支的熵相乘。然后,我们将每个子分支的结果熵相加,以获得分支的总熵;然后,我们可以计算分支的信息增益:

*P(Play, Outlook) = P(Outcast) * E(4,0) + P(Sunny) * E(2,3)  + P(Rain) * E(3,1) *

= (4/13) * 0 + (5/13) *  0.970 + (4/13) * 0.811

= 0.62261538461

因此,Outlook 分支的总信息增益=目标熵-分支熵

= 0.89049164021 - 0.62261538461

= 0.2678762556 或0.27

每个分支的最终信息增益

现在,我们可以使用其余列的两个属性的频率表来计算所有分支的熵,类似于我们为 Outlook 所做的操作,并得到以下结果:

对于 Humidity 分支,有两个可能的子分支,其结果分解如下:

yes no total
Humidity high 3 3 6
normal 6 1 7
13

同样,对于 Wind,分解如下:

yes no total
Wind weak 6 2 8
strong 3 2 5
13

对于 Temperature,情况如下:

yes no total
Temperature Hot 2 2 4
Mild 4 1 5
Cool 3 1 4
13

我们计算每个分支的branchEntropyInformation gain,以下是结果,步骤与我们为 Outlook 分支所做的类似:

Outlook Temperature Humidity Wind
Gain 0.27 0.055510642 0.110360144 0.017801027

由于 Outlook 具有最高的信息增益,我们可以将其作为根决策节点,并根据其分支拆分树,然后递归继续该过程,直到获得所有叶节点,例如熵为 0。

选择根节点后,我们的输入数据从左到右如下所示:

Overcast Hot High Weak Yes
Overcast Mild High Strong Yes
Overcast Hot Normal Weak Yes
Overcast Cool Normal Strong Yes
Sunny Hot High Weak No
Outlook Sunny Hot High Strong No
Sunny Cool Normal Weak Yes
Sunny Mild Normal Strong Yes
Sunny Mild High Weak No
Rain Mild High Weak Yes
Rain Cool Normal Weak Yes
Rain Mild Normal Weak Yes
Rain Cool Normal Strong No

现在,我们可以看到分支 Overcast 总是产生Yes的响应(最右边的列),所以我们可以将该分支排除在外,因为熵总是 0,也就是说,节点 Overcast 是一个叶节点。

现在,在分支 Outlook -> Sunny,我们将需要通过重复我们类似于根节点的过程来确定下一个决策节点。基本上,我们之前做过的步骤将继续递归进行,直到确定所有叶节点。让我们将这个转化为代码,用我们的信用卡示例来看看它的运行情况。

编写 ID3 算法的代码

首先,我们需要一个应用程序;让我们继续创建一个 Angular 应用程序,如前所示。

从前面的示例中,我们已经看到,我们首先需要列出我们的训练数据,这些数据将被输入到我们的算法中。在这种情况下,我们首先需要确定影响我们目标属性(approved)的不同属性。不深入讨论,以下是我们将作为影响您批准机会的主要因素(及其可能的值)的示例:

  • 信用评分:您的信用总体评分(优秀、良好、一般、差)

  • 信用年龄:您的信用历史年限(>10、>5、>2、>=1)

  • 贬损性言论:如果您的账户上有任何言论(0、1、2、>=3)

  • 利用率:您使用的批准信用额度的比例(高、中、低)

  • 困难的问题:您最近开了多少个新账户(0、1、2、>=3)

由于前面列表的组合数量是固定的,理论上我们可以生成一个包含所有场景的数据集,然后我们可以使用该数据集预测 100%的准确性,但这样做有什么乐趣呢。相反,我们将只取生成数据集的一半,并用它来预测另一半的结果。

生成训练数据集

虽然可以手动生成训练数据集,但这并不有趣。所以,让我们编写一个小脚本,来帮助我们创建数据集:

// input attributes and the target values

var _ = require('lodash');

var creditScore = ['Excellent', 'Good', 'Average', 'Poor'];
var creditAge = ['>10', '>5', '>2', '>=1'];
var remarks = ['0', '1', '2', '>=3'];
var utilization = ['Low', 'Medium', 'High'];
var hardInquiries = ['0', '1', '2', '>=3'];

// expected output structure
/* {
 "creditScore": "",
 "creditAge": "",
 "remarks": "",
 "utilization": "",
 "hardInquiries": "",
 "approval": ""
 } */

var all = [];
var even = [];
var odd = [];

// does not have to be optimal, this is a one time script
_.forEach(creditScore, function(credit) {

  // generate new object on each loop at top

  var resp = {};

  resp.creditScore = credit;

  _.forEach(creditAge, function(age) {

    resp.creditAge = age;

    _.forEach(remarks, function(remark) {

      resp.remarks = remark;

      _.forEach(utilization, function(util) {

        resp.utilization = util;

        _.forEach(hardInquiries, function(inq) {

          resp.hardInquiries = inq;

          // looping is by reference so persist a copy

          all.push(_.cloneDeep(resp));

        });
      });
    });
  });
});

for (var i = 0; i < all.length; i++) {

  // index is even
  if (i % 2 === 0) {

    // training data set
    even.push(all[i]);

  } else {

    // prediction data set (input)
    odd.push(all[i])

  }
}

// apply our fake algorithm to detect which application is approved
var trainingDataWithApprovals = applyApprovals(even);

// apply approval logic so that we know what to expect
var predictionDataWithApprovals = applyApprovals(odd);

function applyApprovals(data) {
  return _.map(data, function(d) {

    // Excellent credit score is approved, no questions asked

    if (d.creditScore === 'Excellent') {
      d.approved = 'Yes';
      return d;
    }

    // if credit score is good, then account should have a decent age
    // not very high utilization, less remarks and less inquiries

    if (d.creditScore === 'Good' &&
      (d.creditAge != '>=1') &&
      (d.remarks == '1' || d.remarks == '0') &&
      d.utilization !== 'High' &&
      (d.hardInquiries != '>=3')) {
      d.approved = 'Yes';
      return d;
    }

    // if score is average, then age should be high, no remarks, not
    very high
    // utilization and little to no inquiries.

    if (d.creditScore === 'Average' &&
      (d.creditAge == '>5' || d.creditAge == '>10') &&
      d.remarks == '0' &&
      d.utilization !== 'High' &&
      (d.hardInquiries == '1' || d.hardInquiries == '0')) {
      d.approved = 'Yes';
      return d;
    }

    // reject all others including all Poor credit scores
    d.approved = 'No';
    return d;

  });
}

console.log(trainingDataWithApprovals);
console.log(predictionDataWithApprovals);

要运行上述脚本,让我们在 credit-card 项目中创建一个小的 Node.js 项目。在项目的根目录中,从终端运行以下命令来创建项目:

// create folder for containing data
mkdir training-data

// move into the new folder
cd training-data

// create a new node project (answer the questions and hit return)
npm init

// install lodash to use helper methods
npm install lodash --save

// create the js file to generate data and copy paste the code above
// into this file
touch data.js

// run the script
node data.js

运行上面的脚本会记录trainingDataWithApprovalspredictionDataWithApprovals

接下来,将trainingDataWithApprovals复制到以下路径的文件中:src/utils/training-data/credit-card.ts。从前面的代码中记录的数据,可以在以下截图中看到一个示例:

现在,我们可以将predictionDataWithApprovals移到app.component.ts文件中,并将approved属性重命名为expected,因为这是我们期望的输出。稍后我们将把实际输出与此进行比较:

现在我们已经准备好训练数据并导入到项目中,让我们创建算法的其余部分来完成树。

生成决策树

为了将代码复杂性降到最低,我们将提取所有我们将递归调用的辅助方法,就像前面的示例中所看到的那样。我们可以从train()方法开始,因为这将首先被调用以确定根决策节点。

在我们这样做之前,让我们在utils文件夹中为我们的 ID3 算法创建一个可注入的服务,我们将在希望使用它的地方进行注入。这个逻辑可以存在于您希望的任何地方,服务器端或客户端。需要注意的一点是,这种情况下的数据集相对较小,所以在客户端进行训练数据集和预测结果是可以接受的。对于需要更长时间进行训练的更大数据集,建议在服务器端进行。

import {Injectable} from "@angular/core";

@Injectable()
export class ID3 {

    constructor() {

    }

}

在算法的每一步,我们将大量依赖辅助方法来保持实现细节清晰;其中大部分将由lodash提供,所以让我们安装并导入它,以便我们可以实现train()方法:

npm install lodash --save

安装了lodash后,我们可以开始使用train()方法,它接受三个参数:训练数据集、目标属性和从训练数据集中提取的所有属性列表,除了目标属性:

import {Injectable} from "@angular/core";
import { } from "lodash";

@Injectable()
export class ID3 {

    constructor() {

    }

    public train(trainingData, target, attributes) {

    }

}

要使用这个服务,在主模块中将其标记为provider,然后在app.component中注入它:

...
import { ID3 } from '../utils/id3';
...

@NgModule({
   ...
    providers: [
        ID3
    ],
    ...
})
export class AppModule { }

然后,为了在主组件中使用它,我们只需导入我们刚刚创建的 ID3 服务,然后在服务实例上调用train()方法:

import { Component, OnInit } from '@angular/core';
import {ID3} from "../utils/id3";
import {without, keys, filter} from "lodash";
import {CreditCard} from "../utils/training-data/credit-card";

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
    tree;
    tests: any;

    constructor(private id3: ID3) {
        this.tree = this.id3.train(
                         CreditCard.data,
                         'approved', 
                         without(keys(CreditCard.data[0]),
                         'approved'));
    }

    ngOnInit() {
        this.tests = ... // testing data
    }

}

让我们也给我们的页面添加一些样式,使其看起来更漂亮,所以更新app.component.scss文件:

.split {
    width: 50%;
    float: left }

table, td, th {
  text-align: center;
  border: 1px solid black;
}

table {
  border-collapse: collapse;
  width: 100%;
}

th {
  height: 50px;
}

.true {
  background: #bcf9bc;
}

.false {
  background: #ffa2a7;
}

如前面的算法所讨论的,我们应用程序中的第一件事是确定根决策节点,例如,具有最高信息增益的属性:

import {Injectable} from "@angular/core";
import { maxBy, uniq, map, filter, without, keys, size, chain, find, countBy } from "lodash";

@Injectable()
export class ID3 {

    constructor() {

    }

    public train(trainingData, target, attributes) {

        // calculate root node from current list of attributes
  var currentRootNode = this.getCurrentRootNode(
                                    trainingData, target, attributes);

    }

    private getCurrentRootNode(trainingData, target, attributes) {

        // get max extropy attribute
  return maxBy(attributes, (attr) => {

            // calculate information gain at each attribute
 // e.g. 'creditScore', 'creditAge' etc  return this.gain(trainingData, target, attr);
        });
    }

    private gain(trainingData, target, attr) {
        // calculate target branches entropy e.g. approved
  var targetEntropy = this.entropy(map(trainingData, target));

        // calculate the summation of all branches entropy
  var sumOfBranchEntropies =
            chain(trainingData)

                // extract branches for the given attribute
 // e.g creditScore has the branches Excellent, Good, // Average, Poor  .map(attr)

                // make the values unique
  .uniq()

                // for each unique branch calculate the branch entropy
 // e.g. calculate entropy of Excellent, Good, Average,
                Poor  .map((branch) => {

                    // extract only the subset training data
 // which belongs to current branch  var branchTrainingData = filter(trainingData, 
                    [attr, branch]);

                    // return (probability of branch) * entropy of
                    branch
  return (branchTrainingData.length /
                    trainingData.length)
                        * this.entropy(map(branchTrainingData,
                        target));
                })

                // add all branch entropies
 // e.g. add entropy of Excellent, Good, Average, Poor  .reduce(this.genericReducer, 0)

                // return the final value
  .valueOf();

        // return information gain
        return targetEntropy - sumOfBranchEntropies;
    }

    private entropy(vals) {

        // take all values
  return chain(vals)

            // make them unique
 // e.g. an array of Yes and No  .uniq()

            // calculate probability of each
  .map((x) => this.probability(x, vals))

            // calculate entropy
  .map((p) => -p * Math.log2(p))

            // reduce the value
  .reduce(this.genericReducer, 0)

            // return value
  .valueOf();
    }

    private probability(val, vals){

        // calculate total number of instances
 // e.g. Yes is 100 out of the 300 values  var instances = filter(vals, (x) => x === val).length;

        // total values passed e.g. 300
  var total = vals.length;

        // return 1/3
  return instances/total;
    }

    private genericReducer(a, b) {

        // add and return
  return a + b;
    }

从前面的代码中,您可以看到我们首先计算树的根决策节点,通过计算每个属性的分支熵并确定最大信息增益。

现在我们有了根节点,我们可以递归地重复这个过程,对节点的每个分支进行操作,然后继续查找决策节点,直到熵为 0,也就是叶节点。

这将修改我们的train()方法如下:

public train(trainingData, target, attributes) {
    // extract all targets from data set e.g.
 // Yes or No  var allTargets = uniq(map(trainingData, target));

    // only Yes or No is remaining e.g. leaf node found
  if (allTargets.length == 1){
        return { leaf: true, value: allTargets[0] };
    }

    // calculate root node from current list of attributes
  var currentRootNode = this.getCurrentRootNode(
                                trainingData, target, attributes);

    // form node for current root
  var node: any = { name: currentRootNode, leaf: false };

    // remove currentRootNode from list of all attributes
 // e.g. remove creditScore or whatever the root node was // from the entire list of attributes  var remainingAttributes = without(attributes, currentRootNode);

    // get unique branch names for currentRootNode
 // e.g creditScore has the branches Excellent, Good, // Average, Poor  var branches = uniq(map(trainingData, currentRootNode));

    // recursively repeat the process for each branch
  node.branches = map(branches, (branch) => {

        // take each branch training data
        // e.g. training data where creditScore is Excellent
  var branchTrainingData = filter(trainingData, [currentRootNode,
        branch]);

        // create node for each branch
  var branch: any = { name: branch, leaf: false };

        // initialize branches for node
  branch.branches = [];

        // train and push data to subbranch
  branch.branches.push(this.train(
 branchTrainingData, target, remainingAttributes));

        // return branch as a child of parent node
  return branch;
    });

    return node;
}

有了这个,train()方法:

  1. 接受输入的训练数据、目标属性和属性列表。

  2. 通过计算每个属性的分支的最大信息增益来获取当前根属性,并创建树的根节点。

  3. 将递归生成的子树推入根节点的分支中。

预测样本输入的结果

现在我们的树已经准备好并返回,我们可以在app.component中使用它,使用predict()方法来确定预测是否与预期结果匹配:

public predict(tree, input) {
    var node = tree;

    // loop over the entire tree
  while(!node[0].leaf){

        // take node name e.g. creditScore
  var name = node[0].name;

        // take value from input sample
  var inputValue = input[name];

        // check if branches for given input exist
  var childNode = filter(node[0].branches, ['name', inputValue]);

        // if branches exist return branches or default to No
  node = childNode.length ?
            childNode[0].branches : [{ leaf: true, value: 'No'}];
    }

    // return final leaf value
  return node[0].value;
}

然后,在app.component中,我们调用predict()方法:

...

    accuracyPct: any;

 ngOnInit() {     this.tests = // test data set;

        this.tests.forEach((test) => {
            test.actual = this.id3.predict([this.tree], test);
            test.accurate = test.expected === test.actual;
        });

        this.accuracyPct =  (filter(this.tests, { accurate: true }).length / 
 this.tests.length)*100;
    }

}

树的可视化和输出

尽管我们已经生成了树和基于训练集的输入数据的预期/实际结果,但现在很难可视化这些数据。因此,为了做到这一点,让我们创建一个小组件,接受树并在 UI 上呈现树的嵌套格式。这非常简单,只是为了理解我们的数据以决策树的形式。

utils文件夹下,让我们首先创建一个名为treeview的文件夹,用于包含我们的组件。当我们创建组件并将其注入到主模块中时,让我们称之为treeview

对于treeview,让我们首先创建treeview.ts文件:

import {Component, Input} from '@angular/core';

@Component ({
    selector: 'tree-view',
    templateUrl:'./treeview.html',
    styleUrls: ['./treeview.scss']
})
export class TreeView {
    @Input() data;
}

然后,我们将创建与组件配套的模板,并将其添加为treeview.html

<ul *ngFor="let node of data">
    <li *ngIf="node.name">

 <!-- show name when available -->  <span class="name">{{node.name}}</span>
    </li>
 <!-- is not root node, render branches recursively -->  <tree-view *ngIf="!node.leaf" [data]="node.branches"></tree-view>

    <!-- if leaf node render node value -->
  <li *ngIf="node.leaf">
        <span class="leaf {{node.value}}">{{node.value}}</span>
    </li>
</ul>

让我们为treeview添加样式,使其更易读,treeview.scss

ul {
  list-style: none;
  line-height: 40px;
  position: relative;

  &::before{
    content: "";
    height: calc(100% - 60px);
    display: block;
    top: 40px;
    left: 60px;
    border-left: 1px solid #333;
    position: absolute;
  }
}

li {
  position: relative;

  &::before{
    content: "";
    width: 20px;
    display: block;
    top: 50%;
    left: -20px;
    border-bottom: 1px solid #333;
    position: absolute;
    transform: translateY(-50%);
  }
}

.name {
  padding: 10px;
  background: #e1f4ff;
}

.leaf {
  padding: 10px;
  position: relative;

  &.Yes {
    background: #bcf9bc;
  }

  &.No {
    background: #ffa2a7;
  }
}

现在,为了使用treeview组件,让我们在app.module.ts的 declarations 中添加它:

...
import {TreeView} from "../utils/treeview/treeview";

@NgModule({
    declarations: [
        ...
        TreeView
    ],
   ...
})
export class AppModule { }

要使用这个,我们只需要将我们在app.component中生成的树绑定到tree-view组件:

添加了treeview后,app.component.html将更新如下:

<div *ngIf="tree">
    <tree-view [data]="[tree]"></tree-view>
</div>

这将如预期地在 UI 上呈现树:

然而,这只是生成的大树的一部分,很难阅读和可视化。让我们尝试使用相同的方法来处理足球示例,通过将训练和测试数据与足球数据进行交换,这是我们在前几节中看到的:

让我们渲染我们传入的输入数据,以测试我们的决策树。为此,我们可以修改我们的app.component.html来同时显示表格和可视化:

<div class="split">
    <div *ngIf="tree">
        <tree-view [data]="[tree]"></tree-view>
    </div>
</div>
<div class="split">
    <h3>Overall accuracy {{accuracyPct | number}}%</h3>

    <table>
        <thead>
            <th>Credit Score</th>
            <th>Credit Age</th>
            <th>Remarks</th>
            <th>Utilization</th>
            <th>Hard Inquiries</th>
            <th>Expected</th>
            <th>Actual</th>
            <th>Accurate</th>
        </thead>
        <tbody>
            <tr *ngFor="let test of tests">
                <td>{{test.creditScore}}</td>
                <td>{{test.creditAge}}</td>
                <td>{{test.remarks}}</td>
                <td>{{test.utilization}}</td>
                <td>{{test.hardInquiries}}</td>
                <td>{{test.expected}}</td>
                <td>{{test.actual}}</td>
                <td [class]="test.accurate">{{test.accurate}}</td>
            </tr>
        </tbody>
    </table>
</div>

为了给表格添加样式,我们可以在我们的app.component.scss文件中添加以下内容:

.split {
    width: 50%;
    float: left }

table, td, th {
  text-align: center;
  border: 1px solid black;
}

table {
  border-collapse: collapse;
  width: 100%;
}

th {
  height: 50px;
}

.true {
  background: #bcf9bc;
}

.false {
  background: #ffa2a7;
}

预期输出如下:

对于足球示例:

摘要

在本章中,我们采取了一种相当正统的方法来理解树作为数据结构,我们偏离了学习树和实现其方法的标准流程。相反,我们采用了一些真实世界的例子,并根据手头的用例实现了树。这将是一个情况,你会被提供数据,并被挑战以一种通用的方式来扩展用例。在下一章中,我们将扩展这种方法,并将其推进一步,我们将注意到它如何扩展到图论中。

第五章:使用图简化复杂应用程序

定义图的最简单方法是任何由边连接的节点集合。图是计算机科学中使用的最流行的数学概念之一。图的常见实现示例是任何社交媒体网站。Facebook 使用朋友作为节点,友谊作为边;而 Twitter 则将追随者定义为节点,关注作为边,依此类推。看一下下面的图像:

在上述图像中,你可以看到一个典型的图,有节点。正如你所注意到的,我们的边没有列出方向,节点也没有详细信息。这是因为有不同类型的图,节点和边在这些不同类型的图之间略有不同,我们将在接下来的部分中看到。

在本章中,我们将首先讨论以下主题:

  1. 图的类型

  2. 为求职门户网站创建一个参考生成器

  3. 创建一个朋友推荐系统

图的类型

根据前面的描述,我们可以推测出图的类型。有太多类型要在本章甚至本书中涵盖。然而,让我们来看一些最重要和最流行的图,我们将在本章中通过示例来探索:

  • 简单图:简单图是一个无向、无权重的图,不包含循环或多边(即两个节点之间的多条边,也称为平行边)节点:

  • 无向图:这是一个图,其中边的定义是可互换的。例如,在下面的图像中,节点12之间的边可以表示为(1,2)或(2,1)。因此,节点之间通过一条没有箭头指向任何节点的线连接:

  • 有向图:这是一个图,其中边根据功能或逻辑条件给定预定义方向。边用箭头绘制,表示流动的方向,例如 Twitter 上的一个用户关注另一个用户。看一下下面的图像:

  • 循环图:这是一个图,其中边形成节点之间的循环连接,即起始和结束节点相同。例如,在下面的图像中,我们可以注意到节点1 >> 5 >> 6 >> 7 >> 3 >> 1形成了图中的循环:

  • 有向无环图:这是一个没有循环的有向图。这是最常见的图的类型。在下面的例子中,节点是1234567,边是{(1, 2), (1, 3), (1, 5), (2, 4), (4, 3), (4, 6), (5, 4), (5, 6), (6, 7), (7, 3)}:

  • 加权图:这是一个图,其中边根据穿越该边的成本或便宜程度被分配数值权重。每条边的权重的使用可以根据用例而变化。在下面的例子中,你可以注意到图之间的边被分配了权重(0135):

幸运的是,或不幸的是,我们在日常挑战中面临的问题并没有直接告诉我们是否可以用图解决它们,如果可以,它需要什么样的图或者我们需要使用什么样的解析算法。这是我们会根据具体情况来处理的事情,这也是我们将在下面的用例中所做的。

用例

实现图与树的方式类似;没有固定的创建方式。然而,根据您的用例,您可以根据需要将图结构化为有向、循环或其他形式,如前面所述。这样做可以使它们的遍历更容易,从而使数据检索更容易、更快。

让我们先看一些示例,我们首先需要一个基础应用程序。

创建一个 Node.js Web 服务器

首先,让我们使用 Node.js 创建一个 Web 服务器,稍后我们将使用它来创建端点以访问我们基于图的应用程序:

  1. 第一步是创建应用程序的项目文件夹;要做到这一点,从终端运行以下命令:
 mkdir <project-name>
  1. 然后,要初始化一个 Node.js 项目,在项目的根目录运行init命令。这将提示一系列问题以生成package.json文件。您可以填写您想要的答案,或者只需点击return接受提示的默认值:
 cd <project-name>
 npm init
  1. 接下来,因为我们想要创建一个 Web 服务器,我们将使用express,这是一个非常强大和流行的 Node.js 框架。我们还将使用另一个名为body-parser的库,它可以帮助我们轻松解析传入的 JSON 请求体。最后,我们还将使用lodash来帮助处理一些复杂的数据操作。要安装lodashexpressbody-parser,运行以下命令:
 npm install express body-parser lodash --save
  1. 一旦我们完成了应用程序的设置,我们将需要使用 express 启动应用程序服务器,并包含我们的body-parser中间件。因此,我们现在可以在根目录下创建一个server.js文件,然后添加以下代码:
 var express = require('express');
 var app = express();
 var bodyParser = require('body-parser');

 // middleware to parse the body of input requests app.use(bodyParser.json());

 // test url app.get('/', function (req, res) {
           res.status(200).send('OK!')
        });

 // start server app.listen(3000, function () {
           console.log('Application listening on port 3000!')
        });
  1. 现在,应用程序已经准备好启动了。在您的package.json文件的scripts标签下,添加以下内容,然后从终端运行npm start来启动服务器:
 {

        ...

        "scripts": {
          "start": "node server.js",
          "test": "echo \"Error: no test specified\" && exit 1"        },

        ...

        }

为求职门户创建一个参考生成器

在这个例子中,我们将为一个求职门户创建一个参考生成器。例如,我们有一些彼此为朋友的用户,我们将为每个用户创建节点,并将每个节点与数据关联,例如他们的姓名和他们工作的公司。

一旦我们创建了所有这些节点,我们将根据节点之间的一些预定义关系将它们连接起来。然后,我们将使用这些预定义关系来确定一个用户需要与谁交谈,以便获得推荐去他们选择的公司的工作面试。例如,A 在 X 公司工作,B 在 Y 公司工作并且是朋友,B 和 C 在 Z 公司工作并且是朋友。因此,如果 A 想要被推荐到 Z 公司,那么 A 与 B 交谈,B 可以介绍他们给 C,以获得去 Z 公司的推荐。

在大多数生产级应用程序中,您不会以这种方式创建图。您可以简单地使用图数据库,它可以直接执行许多功能。

回到我们的例子,更加技术性地说,我们有一个无向图(将用户视为节点,友谊视为它们之间的边),我们想要确定从一个节点到另一个节点的最短路径。

为了实现我们到目前为止所描述的内容,我们将使用一种称为广度优先搜索BFS)的技术。BFS 是一种图遍历机制,首先检查或评估相邻节点,然后再移动到下一级。这有助于确保在结果链中找到的链接数量始终是最小的,因此我们总是得到从节点 A 到节点 B 的最短可能路径。

尽管还有其他算法,比如Dijkstra,可以实现类似的结果,但我们将选择 BFS,因为 Dijkstra 是一种更复杂的算法,适用于每个边都有相关成本的情况。例如,在我们的情况下,如果我们的用户友谊有与之相关的权重,比如熟人朋友密友,那么我们将选择 Dijkstra,这将帮助我们为每条路径关联权重。

考虑使用 Dijkstra 的一个很好的用例是地图应用程序,它会根据两点之间的交通情况(即每条边的权重或成本)为你提供从 A 点到 B 点的方向。

创建一个双向图

我们可以通过在utils/graph.js下创建一个新文件来为我们的图形创建逻辑,该文件将保存边缘,然后提供一个简单的shortestPath方法来访问图形,并在生成的图形上应用 BFS 算法,如下面的代码所示:

var _ = require('lodash');

class Graph {

   constructor(users) {
      // initialize edges
  this.edges = {};

      // save users for later access
  this.users = users;

      // add users and edges of each
  _.forEach(users, (user) => {
         this.edges[user.id] = user.friends;
      });
   }
}

module.exports = Graph;

一旦我们将边添加到我们的图形中,它就有了节点(用户 ID),边被定义为每个用户 ID 和friends数组中的朋友之间的关系。由于我们的数据结构的方式,形成图形是一项容易的任务。在我们的示例数据集中,每个用户都有一个朋友列表,如下面的代码所示:

[
   {
      id: 1,
      name: 'Adam',
      company: 'Facebook',
      friends: [2, 3, 4, 5, 7]
   },
   {
      id: 2,
      name: 'John',
      company: 'Google',
      friends: [1, 6, 8]
   },
   {
      id: 3,
      name: 'Bill',
      company: 'Twitter',
      friends: [1, 4, 5, 8]
   },
   {
      id: 4,
      name: 'Jose',
      company: 'Apple',
      friends: [1, 3, 6, 8]
   },
   {
      id: 5,
      name: 'Jack',
      company: 'Samsung',
      friends: [1, 3, 7]
   },
   {
      id: 6,
      name: 'Rita',
      company: 'Toyota',
      friends: [2, 4, 7, 8]
   },
   {
      id: 7,
      name: 'Smith',
      company: 'Matlab',
      friends: [1, 5, 6, 8]
   },
   {
      id: 8,
      name: 'Jane',
      company: 'Ford',
      friends: [2, 3, 4, 6, 7]
   }
]

正如你在前面的代码中所看到的,我们在这里并不需要专门建立双向边,因为如果用户1是用户2的朋友,那么用户2也是用户1的朋友。

生成最短路径的伪代码

在实施之前,让我们快速记录一下我们将要做的事情,这样实际的实施就会变得更容易:

INITIALIZE tail to 0 for subsequent iterations

MARK source node as visited

WHILE result not found

    GET neighbors of latest visited node (extracted using tail)

    FOR each of the node

        IF node already visited

            RETURN

        Mark node as visited

        IF node is our expected result

            INITIALIZE result with current neighbor node

            WHILE not source node

               BACKTRACK steps by popping users 
               from previously visited path until
               the source user

            ADD source user to the result

            CREATE and format result variable

        IF result found return control

        NO result found, add user to previously visited path

        ADD friend to queue for BFS in next iteration

    INCREMENT tail for next loop

RETURN NO_RESULT

实现最短路径生成

现在让我们创建我们定制的 BFS 算法来解析图并生成用户被推荐到 A 公司的最短路径:

var _ = require('lodash');

class Graph {

   constructor(users) {
      // initialize edges
  this.edges = {};

      // save users for later access
  this.users = users;

      // add users and edges of each
  _.forEach(users, (user) => {
         this.edges[user.id] = user.friends;
      });
   }

   shortestPath(sourceUser, targetCompany) {
      // final shortestPath
  var shortestPath;

      // for iterating along the breadth
  var tail = 0;

      // queue of users being visited
  var queue = [ sourceUser ];

      // mark visited users
  var visitedNodes = [];

      // previous path to backtrack steps when shortestPath is found
  var prevPath = {};

      // request is same as response
  if (_.isEqual(sourceUser.company, targetCompany)) {
         return;
      }

      // mark source user as visited so
 // next time we skip the processing  visitedNodes.push(sourceUser.id);

      // loop queue until match is found
 // OR until the end of queue i.e no match  while (!shortestPath && tail < queue.length) {

         // take user breadth first
  var user = queue[tail];

         // take nodes forming edges with user
  var friendsIds = this.edges[user.id];

         // loop over each node
  _.forEach(friendsIds, (friendId) => {
            // result found in previous iteration, so we can stop
            if (shortestPath) return;

            // get all details of node
  var friend = _.find(this.users, ['id', friendId]);

            // if visited already,
 // nothing to recheck so return  if (_.includes(visitedNodes, friendId)) {
               return;
            }

            // mark as visited
  visitedNodes.push(friendId);

            // if company matched
  if (_.isEqual(friend.company, targetCompany)) {

               // create result path with the matched node
  var path = [ friend ];

               // keep backtracking until source user and add to path
  while (user.id !== sourceUser.id) {

                  // add user to shortest path
  path.unshift(user);

                  // prepare for next iteration
  user = prevPath[user.id];
               }

               // add source user to the path
  path.unshift(user);

               // format and return shortestPath
  shortestPath = _.map(path, 'name').join(' -> ');
            }

            // break loop if shortestPath found
  if (shortestPath) return;

            // no match found at current user,
 // add it to previous path to help backtracking later  prevPath[friend.id] = user;

            // add to queue in the order of visit
 // i.e. breadth wise for next iteration  queue.push(friend);
         });

         // increment counter
  tail++;
      }

      return shortestPath ||
            `No path between ${sourceUser.name} & ${targetCompany}`;
   }

}

module.exports = Graph;

代码的最重要部分是当找到匹配时,如前面代码块所示:

// if company matched if (_.isEqual(friend.company, targetCompany)) {

   // create result path with the matched node
  var path = [ friend ];

   // keep backtracking until source user and add to path
  while (user.id !== sourceUser.id) {

      // add user to shortest path
  path.unshift(user);

      // prepare for next iteration
  user = prevPath[user.id];
   }

   // add source user to the path
  path.unshift(user);

   // format and return shortestPath
  shortestPath = _.map(path, 'name').join(' -> ');
}

在这里,我们使用了一种称为回溯的技术,当找到结果时,它可以帮助我们重新追溯我们的步骤。这里的想法是,每当找不到结果时,我们将迭代的当前状态添加到一个映射中——键作为当前正在访问的节点,值作为我们正在访问的节点。

因此,例如,如果我们从节点 3 访问节点 1,那么直到我们从其他节点访问节点 1,地图中将包含{1:3},当发生这种情况时,我们的地图将更新为指向我们从中得到节点 1 的新节点,例如{1:newNode}。一旦我们设置了这些先前的路径,我们可以通过查看这个地图轻松地追溯我们的步骤。通过添加一些日志语句(仅在 GitHub 代码中可用,以避免混淆),我们可以轻松地查看数据的长但简单的流程。让我们以我们之前定义的数据集为例,当 Bill 试图寻找可以推荐他给丰田的朋友时,我们看到以下日志语句:

starting the shortest path determination added 3 to the queue marked 3 as visited
 shortest path not found, moving on to next node in queue: 3 extracting neighbor nodes of node 3 (1,4,5,8) accessing neighbor 1 mark 1 as visited result not found, mark our path from 3 to 1 result not found, add 1 to queue for next iteration current queue content : 3,1 accessing neighbor 4 mark 4 as visited result not found, mark our path from 3 to 4 result not found, add 4 to queue for next iteration current queue content : 3,1,4 accessing neighbor 5 mark 5 as visited result not found, mark our path from 3 to 5 result not found, add 5 to queue for next iteration current queue content : 3,1,4,5 accessing neighbor 8 mark 8 as visited result not found, mark our path from 3 to 8 result not found, add 8 to queue for next iteration current queue content : 3,1,4,5,8 increment tail to 1 shortest path not found, moving on to next node in queue: 1 extracting neighbor nodes of node 1 (2,3,4,5,7) accessing neighbor 2 mark 2 as visited result not found, mark our path from 1 to 2 result not found, add 2 to queue for next iteration current queue content : 3,1,4,5,8,2 accessing neighbor 3 neighbor 3 already visited, return control to top accessing neighbor 4 neighbor 4 already visited, return control to top accessing neighbor 5 neighbor 5 already visited, return control to top accessing neighbor 7 mark 7 as visited result not found, mark our path from 1 to 7 result not found, add 7 to queue for next iteration current queue content : 3,1,4,5,8,2,7 increment tail to 2 shortest path not found, moving on to next node in queue: 4 extracting neighbor nodes of node 4 (1,3,6,8) accessing neighbor 1 neighbor 1 already visited, return control to top accessing neighbor 3 neighbor 3 already visited, return control to top accessing neighbor 6 mark 6 as visited result found at 6, add it to result path ([6]) backtracking steps to 3 we got to 6 from 4 update path accordingly: ([4,6]) add source user 3 to result form result [3,4,6] return result increment tail to 3 return result Bill -> Jose -> Rita

我们基本上在这里使用 BFS 进行迭代过程,以遍历树并回溯结果。这构成了我们功能的核心。

创建一个 Web 服务器

我们现在可以添加一个路由来访问这个图形及其相应的shortestPath方法。让我们首先在routes/references下创建路由,并将其添加为 Web 服务器的中间件:

var express = require('express');
var app = express();
var bodyParser = require('body-parser');

// register endpoints var references = require('./routes/references');

// middleware to parse the body of input requests app.use(bodyParser.json());

// route middleware app.use('/references', references);

// start server app.listen(3000, function () {
   console.log('Application listening on port 3000!');
});

然后,创建如下代码所示的路由:

var express = require('express');
var router = express.Router();
var Graph = require('../utils/graph');
var _ = require('lodash');
var userGraph;

// sample set of users with friends 
// same as list shown earlier var users = [...];

// middleware to create the users graph router.use(function(req) {
   // form graph
  userGraph = new Graph(users);

   // continue to next step
  req.next();
});

// create the route for generating reference path // this can also be a get request with params based // on developer preference router.route('/')
   .post(function(req, res) {

      // take user Id
  const userId = req.body.userId;

      // target company name
  const companyName = req.body.companyName;

      // extract current user info
  const user = _.find(users, ['id', userId]);

      // get shortest path
  const path = userGraph.shortestPath(user, companyName);

      // return
  res.send(path);
   });

module.exports = router;

运行参考生成器

要测试这个,只需从项目的根目录运行npm start命令启动 Web 服务器,如前面所示。

一旦服务器启动运行,你可以使用任何你希望的工具将请求发送到你的 Web 服务器,如下面的截图所示:

正如你在前面的截图中所看到的,我们得到了预期的响应。当然,这可以以一种方式进行更改,以返回所有用户对象而不仅仅是名称。这可能是一个有趣的扩展示例,你可以自己尝试一下。

为社交媒体创建一个好友推荐系统

你不能简单地否认社交网络网站都是关于数据的事实。这就是为什么这些网站中构建的大多数功能都依赖于你提供给它们的数据。这些中的一个例子就是你可能认识的人推荐关注组件,你可以在许多网站上找到。

从前面的例子中,我们知道数据可以分组为“节点”和“边”,其中节点是人,边是您想要在节点之间建立的关系。

我们可以简单地形成一个双向图,然后应用 BFS 算法来确定第 n 度的连接节点,然后我们可以去重以显示朋友或节点推荐。然而,考虑到我们在前面的例子中已经这样做了,而且在生产应用程序中,每个用户和这些用户的朋友的实际列表都非常庞大,我们将采取不同的方法。我们将假设我们的数据集存储在图数据库中,比如neo4j,然后我们将使用一种称为Personalized PageRank的算法,这是一种 BFS 和 PageRank 的组合,我们将在下一节中探讨。

理解 PageRank 算法

在我们的生活中的某个时刻,我们一定遇到过这个术语,PageRank。PageRank 是 Google 对网页进行搜索和索引排名的众多方式之一。一个简单的谷歌搜索(完全是故意的双关语)将返回结果,告诉您它基本上涉及从中我们可以随机走向的一组节点。然而,这到底意味着什么呢?

假设控制权被放置在图中的任何节点上,我们说控制权可以以alpha的概率不偏向地跳转到图上的任何节点,当它确实落在任何节点上时,它会在以(1-alpha)的概率随机地沿着这些节点的边之一遍历之前,与所有连接的节点平均分享其排名的一部分。

这有什么意义和原因呢?这只是从一个节点跳到另一个节点,然后随机地遍历到其他连接的节点,对吧?

如果您这样做足够长的时间,您会落在所有节点上,有些节点会比其他节点多次。您明白我要说什么吗?这最终会告诉您哪些节点比其他节点更频繁地访问,这可能是由于以下两个原因:

  • 我们碰巧多次跳转到同一个节点

  • 该节点连接到多个节点

第一种情况可能发生,但是,由于我们知道我们的跳跃是不偏向的,大数定律规定,这将在足够长的时间内产生归一化的值,我们可以安全地排除它。

另一方面,第二种情况不仅可能,而且对 PageRank 非常重要。一旦您落在其中一个节点上,这时我们根据 alpha 和从前一个节点继承的排名来计算该节点的 PageRank。

我们在抽象的节点和边的术语中进行了讨论;然而,让我们暂时看一下 Sergey Brin 和 Lawrence Page 在 PageRank 的第一篇发表文章中所说的一句话(infolab.stanford.edu/~backrub/google.html):

我们假设页面 A 有指向它的页面 T1...Tn(即引用)。参数 d 是一个阻尼因子,可以设置在 0 和 1 之间。我们通常将 d 设置为 0.85。关于 d 的更多细节将在下一节中介绍。此外,C(A)被定义为从页面 A 指向外部的链接数。页面 A 的 PageRank 如下所示:

PR(A) = (1-d) + d (PR(T1)/C(T1) + ... + PR(Tn)/C(Tn))

请注意,PageRanks 形成了网页的概率分布,因此所有网页的 PageRanks 之和将为 1。

从前面的陈述中,我们可以看到给定页面/节点的 PageRank (PR)是从其引用(T1...Tn)的PR派生出来的,但是我们如何知道从哪里开始,因为我们需要知道它的引用来计算T1的 PR。简单的答案是,实际上我们不需要知道PR(T1)的值或者事实上任何其他引用的值。相反,我们可以简单地猜测PR(T1)的值,并递归地应用从前一步骤派生出的值。

然而,你为什么会这样问呢?答案很简单,记得大数定律吗?如果你重复一个动作足够长的时间,该动作的结果将收敛到中位数值。然后,还有关于如何在数百万和数十亿的网页上进行有效操作的问题?有方法和手段,这超出了本章和本书的范围;然而,对于那些感兴趣的人,这本解释 Google Page Rank 的书是一本很好的读物,可在press.princeton.edu/titles/8216.html上获得。我希望这本书能为基本原则提供一些启发。

理解个性化 PageRank(PPR)算法

现在我们对 PageRank 有了简要的了解,那么个性化 PageRank 是什么?实际上很简单,每次不是跳转到随机节点,而是跳转到预定义的节点,然后递归地累积每个节点的命中概率,使用 BFS 进行遍历。

假设我们有一些朋友,他们的结构如下图所示:

这很简单;节点之间有双向边,表示它们之间有友谊关系。在这个问题中,我们可以假设我们想向用户A推荐新的朋友。

最简单的部分也是我们在转到 PPR 的代码之前需要讨论的重要事情。我们将始终从我们的目标节点开始,也就是说,跳转不再是随机开始的。我们从我们的目标节点开始,假设控制以相等的方式遍历所有边,然后回到父节点。然后,我们递归地重复这个过程,同时通过一条边扩展度,直到满足目标度。

此外,每次我们从目标节点增加一度搜索时,我们都会与邻居分享节点的概率,但如果我们全部分享,节点就会变为 0,所以我们要做的是应用一个阻尼因子(alpha)。

例如,假设我们在节点X,它的概率为 1(即,它是目标节点),并且这个节点X有两个邻居YZ。我们设置的 alpha(例如,0.5)将在这里应用,因此在第一次迭代之后,X的概率将为 0.5,然后YZ将有相等的概率 0.25。然后,这个过程将递归地重复到下一个度,使用我们刚刚创建的新概率映射。

个性化 PageRank 的伪代码

让我们将之前部分讨论的内容转换为伪代码,以便更容易实现:

START at root node

    assign it a probability of 1 in the probabilityMap

    trigger CALC_PPR with current node, probabilityMap and iterations count

FUNCTION CALC_PPR

    IF number of iteration left is 0

        remove target and its neighbors from probabilityMap

        return rest of probabilityMap

    ELSE

        determine an ALPHA

        extract all nodes at the current degree

        FOR each nodes at current degree

            extract neighbors

            calculate the probability to propagate to neighbor

            IF neighbor already has a probability

                add to existing probability

            ELSE

               assign new probability

        CALC_PPR with decreased iteration count                 

现在这并不可怕,是吗?现在实现 PPR 算法将会很容易。

创建一个 Web 服务器

在我们为个性化 PageRank 编写任何代码之前,让我们首先创建一个 Node.js 应用程序,就像之前解释的那样。

一旦应用程序准备就绪,让我们创建一个路由,用于为我们提供用户建议。类似于之前的示例,我们可以快速拼凑出以下路由,放在routes/suggestions.js下:

const express = require('express');
const router = express.Router();
const _ = require('lodash');

// sample set of users with friends extracted from some grapgh db const users = {
   A: { neighbors: [ 'B', 'D' ] },
   B: { neighbors: [ 'A', 'C', 'E' ] },
   C: { neighbors: [ 'B', 'D', 'E' ] },
   D: { neighbors: [ 'A', 'C' ] },
   E: { neighbors: [ 'B', 'C' ] }
};

// middleware router.use(function(req) {
   // intercept, modify and then continue to next step
  req.next();
});

// route router.route('/:userId')
   .get(function(req, res) {
      var suggestions;

      // take user Id
  const userId = req.params.userId;

      // generate suggestions   // return suggestions  res.send(userId);
   });

module.exports = router;

我们还可以快速拼凑出我们的 express 服务器:

var express = require('express');
var app = express();
var bodyParser = require('body-parser');

// suggestion endpoints var suggestions = require('./routes/suggestions');

// middleware to parse the body of input requests app.use(bodyParser.json());

// route middleware app.use('/suggestions', suggestions);

// start server app.listen(3000, function () {
   console.log('Application listening on port 3000!');
});

实现个性化 PageRank

现在,让我们转到创建我们的个性化 PageRankPPR)算法。我们将创建一个ES6类,它将处理提供图形和目标节点后生成建议的所有逻辑。请注意,在上面的代码中,我已经向您展示了图形的样子:

const users = {
   A: { neighbors: [ 'B', 'D' ] },
   B: { neighbors: [ 'A', 'C', 'E' ] },
   C: { neighbors: [ 'B', 'D', 'E' ] },
   D: { neighbors: [ 'A', 'C' ] },
   E: { neighbors: [ 'B', 'C' ] }
};

我们通过指定两个节点为彼此的邻居建立了双向关系。现在,我们可以开始编写 PPR 的代码:

const _ = require('lodash');

class PPR {

   constructor(data) {
      this.data = data;
   }

   getSuggestions(nodeId) {
      return this.personalizedPageRankGenerator(nodeId);
   };
}

module.exports = PPR;

我们首先将图形作为输入接受到我们的constructor中。接下来,我们将定义我们的getSuggestions方法,它将接受输入的nodeId,然后将其传递给计算 PPR。这也是我们之前伪代码的第一步,如下所示:

personalizedPageRankGenerator(nodeId) {
   // Set Probability of the starting node as 1
 // because we will start from that node  var initProbabilityMap = {};

   initProbabilityMap[nodeId] = 1;

   // call helper to iterate thrice
  return this.pprHelper(nodeId, initProbabilityMap, 3);
};

由于我们的控制被定义为从一个固定节点开始,我们将其概率设置为1。我们将进行三次迭代,只是因为我们只想走出三个级别来获取建议。第 1 级是目标节点,第 2 级是目标节点的邻居(即当前的朋友),然后第 3 级是邻居的邻居(即朋友的朋友)。

现在,我们来到了有趣的部分。我们将递归地计算我们跳到每个相邻节点的概率,从目标节点开始:

pprHelper(nodeId, currentProbabilitiesMap, iterationCount) {
   // iterations done
  if (iterationCount === 0) {

      // get root nodes neighbors
  var currentNeighbors = this.getNeighbors(nodeId);

      // omit neighbors and self node from calculated probabilities
  currentProbabilitiesMap = _.omit(currentProbabilitiesMap,
      currentNeighbors.concat(nodeId));

      // format data and sort by probability of final suggestions
  return _.chain(currentProbabilitiesMap)
         .map((val, key) => ({ name: key, score: val }))
         .orderBy('score', 'desc')
         .valueOf();

   } else {
      // Holds the updated set of probabilities for the next iteration
  var nextIterProbabilityMap = {};

      // set alpha
  var alpha = 0.5;

      // With probability alpha, we teleport to the start node again
  nextIterProbabilityMap[nodeId] = alpha;

      // extract nodes within current loop
  var parsedNodes = _.keys(currentProbabilitiesMap);

      // go to next degree nodes of each of the currently parsed nodes
  _.forEach(parsedNodes, (parsedId) => {

         // get current probability of each node
  var prob = currentProbabilitiesMap[parsedId];

         // get connected nodes
  var neighbors = this.getNeighbors(parsedId);

         // With probability 1 - alpha, we move to a connected node...
 // And at each node we distribute its current probability
         equally to // its neighbors    var probToPropagate = (1 - alpha) * prob / neighbors.length;

         // spreading the probability equally to neighbors   _.forEach(neighbors, (neighborId) => {
            nextIterProbabilityMap[neighborId] =
         (nextIterProbabilityMap[neighborId] || 0) + probToPropagate;
         });
      });

      // next iteration
  return this.pprHelper(nodeId, nextIterProbabilityMap, iterationCount - 1);
   }
}

getNeighbors(nodeId) {
   return _.get(this.data, [nodeId, 'neighbors'], []);
}

这并不像你想象的那样糟糕,对吧?一旦我们准备好 PPR 算法,我们现在可以将这个类导入到我们的suggestions路由中,并可以用它来为任何输入用户生成推荐,如下面的代码片段所示:

const express = require('express');
const router = express.Router();
const _ = require('lodash');
const PPR = require('../utils/ppr');

// sample set of users with friends extracted from some grapgh db const users = .... // from previous example

....

// route router.route('/:userId')
   .get(function(req, res) {
      var suggestions;

      // take user Id
  const userId = req.params.userId;

----> // generate suggestions ----> suggestions = new PPR(users).getSuggestions(userId);

      // return suggestions
  res.send(suggestions);
   });

module.exports = router;

结果和分析

现在,为了测试这个,让我们通过从根文件夹运行npm start命令来启动我们的 Web 服务器。一旦您的应用程序启动,您将在终端上看到以下消息:

Application listening on port 3000!

一旦消息出现,您可以打开 Postman 或您选择的其他任何东西来进行 API 调用以获取建议:

我们可以看到用户C比用户E得分更高。这是因为我们可以从输入数据集中看到用户AC比用户AE有更多的共同朋友。这就是为什么,根据我们之前的推断,我们的控制落在节点C上的机会比节点E上的机会更高。

另外,需要注意的有趣的事情是,这里实际分数的值并不重要。您只需要看分数的比较来确定哪一个更有可能发生。您可以根据需要更改 alpha 来决定每个节点之间将分配多少概率,这最终会改变每个结果节点的分数,例如,我们将 alpha 值更改为 0.5 的结果,显示了名称和分数,我们将现在将其更改为0.33,即父节点保留三分之一,其余与邻居分配:

在每个递归调用之前添加了一些日志语句,以便更清晰地理解:

.....

console.log(`End of Iteration ${ 4 - iterationCount} : ${JSON.stringify(nextIterProbabilityMap)}`);
 // next iteration return this.pprHelper(nodeId, nextIterProbabilityMap, iterationCount - 1);

前面的日志语句产生了以下结果:

从前面的截图中,您可以注意到在第一次迭代结束时,我们分配给目标节点A的总概率为 1,在我们的逻辑确定的 BFS 遍历后,被分成了三部分,即节点A的邻居BD。现在,这成为了第 2 次迭代的输入,我们重复这个过程,直到最后一次迭代结束,在最后一次迭代结束时,我们移除了当前目标节点A及其直接邻居节点BD(因为它们已经是朋友),并返回剩下的节点CE

摘要

在本章中,我们直面了一些现实世界的挑战,并根据手头的问题创建了一些定制解决方案。这是本章最重要的收获之一。很少会有一个理想的解决方案是 readily available。我们采用了图论算法之一,称为 BFS,并利用它来为我们的职位门户和用户建议生成推荐。我们还简要讨论了 PageRank 算法,任何开发人员都应该熟悉。这引出了为什么以及何时使用一种算法而不是另一种算法的问题。选择算法的利弊是什么?这将是我们下一章的主题,我们将分析不同类型的算法以及它们可以应用的地方。

第六章:探索算法类型

在计算机科学世界中,算法是一组指令,它需要有限的空间和时间来执行。它从应用程序的初始状态开始,然后逐步执行一系列指令以达到最终结果。

算法有各种各样的形状和大小,当您将其与算法的过于通用的定义进行比较时,所有这些算法都将符合要求。重要的问题是决定在哪种情况下使用哪种算法,并根据应用程序的需求进行修改以增强其功能。

正如我在前几章的用例中所展示的,大多数时候,那些已经存在的算法并不直接适用于手头的问题。这就是在需要对算法进行深入理解时的用武之地。这正是我们将在本章中要做的;我们将看一系列算法,然后尝试通过一些示例更好地理解它们。

在本章中,我们将讨论以下算法,并陦有一些示例:

  • 递归

  • 迪杰斯特拉

  • 广度优先搜索(BFS)

  • 动态规划

  • 贪婪算法

  • 分支和界限

在我们开始查看用例之前,让我们先建立一个简单的 Node.js 项目。

创建一个 Node.js 应用程序

在本章中,我们将使用一个非常简单和轻量的 Node.js 应用程序,它将保存我们的示例脚本。这里的主要目标是能够单独运行每个用例,而不是为每个用例都有一个完整的 Web(客户端或服务器)应用程序。这有助于我们拥有一个统一的基础项目。

  1. 第一步是创建应用程序的项目文件夹。从终端运行以下命令:
mkdir <project-name>
  1. 然后,要初始化一个 Node.js 项目,请在项目的root文件夹中运行init命令。这将提示一系列问题以生成package.json文件。您可以填写您希望的答案,或者只需点击return接受提示的默认值:
cd <project-name>
npm init
  1. 让我们也安装我们心爱的lodash,以帮助我们处理一些琐碎的数组和对象操作和实用程序:
npm install --save lodash

用例

一旦您的项目准备就绪,我们现在可以在项目的根目录中添加必要的脚本,然后独立运行它们。

使用递归来序列化数据

递归是一种非常流行的编程范式,其中问题陈述可以被分解成几个较小的问题,这些问题可以用自身来定义。递归通常与分而治之混淆在一起,其中问题陈述被分解成不重叠的子问题,可以同时解决。

在接下来的部分中,我们将采用一个简单的树结构,其中有一个根元素,后面跟着一些子元素。我们将对这棵树的数据进行序列化,然后可以轻松地将其发送到 UI 或持久化在数据库中。

让我们首先在我们基于前一节创建的项目中创建一个名为recursion的文件夹。然后,我们可以在这个文件夹中创建我们的serializer.js文件,其中将包含用于序列化树数据的类。

伪代码

在实现递归序列化器之前,让我们用伪代码来制定我们的算法:

INITIALIZE response

FOR each node

    extract child nodes

    add current node info to serialized string

    IF childNodes exist

        repeat process for child nodes

    ELSE

        add ^ to indicate end of the level 

IF rootnode

    return serialized string

ELSE

   add ^ to indicate child node of root

序列化数据

现在我们已经有了伪代码,序列化的代码变得非常简单,让我们将以下内容添加到一个名为recursion.js的文件中,放在我们的序列化器旁边:

var _ = require('lodash');

class Recursion {
   constructor(tree) {
      this.tree = tree;
   }

   // serialize method which accepts list of nodes
  serialize(nodes) {
      // initialize response
  this.currentState = this.currentState || '';

      // loop over all nodes
  _.forEach(nodes, (node) => {

         // depth first traversal, extracting nodes at each level
 // traverse one level down  var childNodes = this.tree[node];

         // add current node to list of serialized nodes
  this.currentState += ` ${node}`;

         // has child nodes
  if (childNodes) {

            // recursively repeat
  this.serialize(childNodes);
         } else {

            // mark as last node, traverse up
  this.currentState += ` ^`;
         }
      });

      // loop complete, traverse one level up
 // unless already at root otherwise return response  if (!this.isRoot(nodes)) {
         this.currentState += ` ^`;
      } else {
         return this.currentState.trim();
      }
   }

   isRoot(nodes) {
      return _.isEqual(this.tree.root, nodes);
   }
}

module.exports = Recursion;

请注意,在前面的代码中,我们按照自身的方式分解了问题,确定了一个级别需要做什么,然后递归地为所有节点重复了这个过程。现在,为了使用这种序列化方法,创建一个serialization.js文件,然后将以下代码添加到其中:

var fs = require('fs');
var Recursion = require('./recursion');

// set up data const tree = {
   root: ['A'],
   A: ['B', 'C', 'D'],
   B: ['E', 'F'],
   D: ['G', 'H', 'I', 'J'],
   F: ['K']
};

// initialize var serializer = new Recursion(tree);

// serialize var serializedData = serializer.serialize(tree.root);

console.log(serializedData);

当我们从项目的根目录运行上述文件时,使用node recursion/serializer.js命令,我们会在控制台上得到序列化的响应日志:

A B E ^ F K ^ ^ ^ C ^ D G ^ H ^ I ^ J ^ ^ ^

从前面的响应中,您可以注意到基于我们的输入数据集,深度优先方法可以很清楚地看到。BA的子节点,EB的叶子节点(在E后面的^符号表示)。使用递归来反序列化这个序列化的数据也是一个简单的过程,您可以自己尝试一下。

使用 Dijkstra 确定最短路径

在前面的章节中,我们只探讨了图遍历的简单方法,广度优先搜索BFS)和深度优先搜索DFS)。在前一章中,我们简要讨论了 Dijkstra 以及它如何帮助我们确定图中从节点A到节点B的路径,前提是图是有向的,带有加权边。

在这个例子中,我们就是这样。我们有一个节点(城市)和边(大约的距离)的图,我们需要确定用户从给定的起始节点到达目的节点的最快路径,前提是其他因素,如速度、交通和天气保持不变:

我们的行程从旧金山SF)开始,到凤凰城PX)结束。我们已经确定了一些中间城市,用户可以在那里停下来休息或加油:蒙特利MT)、圣何塞SJ)、圣巴巴拉SB)、洛杉矶LA)、圣迭戈SD)、弗雷斯诺FR)、贝克斯菲尔德BK)和拉斯维加斯LV)。到达每个城市的距离由每个城市之间的边关联的权重表示。

伪代码

让我们来看一下实现 Dijkstra 算法的伪代码:

INITIALIZE Costs, Previous Paths, Visited Nodes

ADD each neighbor of start node to Previous Paths

GET cheapest node from start node and set as current node

WHILE node exists

    GET cost of current node from costs

    GET neighbors of current node

    FOREACH neighbor

        ADD cost of neighbor to current nodes cost as new cost

        IF cost of neighbor not recorded OR cost of 
            neighbor is the lowest amongst all neighbors

            SET cost of neighbor as new cost

            SET the path of neighbor as current node

    MARK current node as visited

    GET cheapest node from start node and set as current node

INITIALIZE response

BACKTRACK path from end to start

RETURN distance and path    

实现 Dijkstra 算法

让我们根据前一节描述的伪代码来分解 Dijkstra 算法的实现。第一步是初始化所有变量。我们将使用一个变量来跟踪通过每个节点的成本,一个用于跟踪我们所采取的路径,还有一个用于跟踪已经访问的节点,以避免重新计算:

var _ = require('lodash');

class Dijkstra {
   solve (graph, start, end) {

      // track costs of each node
  const costs = graph[start];

      // set end to infinite on 1st pass
  costs[end] = Infinity;

      // remember path from
 // which each node was visited  const paths = {};

      // add path for the start nodes neighbors
  _.forEach(graph[start], (dist, city) => {
         // e.g. city SJ was visited from city SF
  paths[city] = start;
      });

      // track nodes that have already been visited nodes
  const visitedNodes = [];

      ....

我们的solve()方法在这里已经用起始节点的成本初始化了costs,然后将终点节点的成本设置为Infinity,因为还没有计算。这意味着在开始时,costs set将包含与从起始节点出发的节点和边完全相同的数据。

我们还相应地计算了路径,例如,由于在我们的示例中从SF开始,节点SJMTSB都是从节点SF到达的。以下代码解释了如何在每个节点提取最低成本:

...

// track nodes that have already been visited nodes const visitedNodes = [];

// get current nodes cheapest neighbor let currentCheapestNode = this.getNextLowestCostUnvisitedNode(costs, visitedNodes);

// while node exists while (currentCheapestNode) {

   // get cost of reaching current cheapest node
  let costToReachCurrentNode = costs[currentCheapestNode];

   // access neighbors of current cheapest node
  let neighbors = graph[currentCheapestNode];

   // loop over neighbors
  _.forEach(neighbors, (dist, neighbor) => {

      // generate new cost to reach each neighbor
  let newCost = costToReachCurrentNode + dist;

      // if not already added
 // or if it is lowest cost amongst the neighbors  if (!costs[neighbor] || costs[neighbor] > newCost) {

         // add cost to list of costs
  costs[neighbor] = newCost;

         // add to paths
  paths[neighbor] = currentCheapestNode;

      }

   });

   // mark as visited
  visitedNodes.push(currentCheapestNode);

   // get cheapest node for next node
  currentCheapestNode = this.getNextLowestCostUnvisitedNode(costs, visitedNodes);
}

...

这可能是代码中最重要的部分;我们根据costsvisitedNodes数组计算了currentCheapestNode,在第一次迭代中,它的值将是SJ,正如我们从前面的图中可以看到的。

一旦我们有了第一个节点,我们就可以访问它的邻居,并且只有在到达这些邻居的“成本”小于当前节点的“成本”时,我们才会更新到达这些邻居的“成本”。此外,如果成本更低,那么我们很可能会通过这个节点到达终点节点,因此我们也会更新到这个邻居的路径。然后在标记访问过的节点后,我们递归重复这个过程。在所有迭代结束时,我们将得到所有节点的更新成本,从而得到到达节点的最终成本:

....

        // get cheapest node for next node
  currentCheapestNode = 
 this.getNextLowestCostUnvisitedNode(costs, visitedNodes);
       }

       // generate response
  let finalPath = [];

       // recursively go to the start
  let previousNode = paths[end];

       while (previousNode) {
          finalPath.unshift(previousNode);
          previousNode = paths[previousNode];
       }

       // add end node at the end
  finalPath.push(end);

       // return response
  return {
          distance: costs[end],
          path: finalPath
  };
    }
 getNextLowestCostUnvisitedNode(costs, visitedNodes) {
       //extract the costs of all non visited nodes
  costs = _.omit(costs, visitedNodes);

       // return the node with minimum cost
  return _.minBy(_.keys(costs), (node) => {
          return costs[node];
       });
    }
}

module.exports = Dijkstra;

一旦生成了所有节点的“成本”,我们将简单地回溯到达终点节点所采取的步骤,然后我们可以返回终点节点的成本和到达终点节点的路径。在最后添加了一个获取未访问节点最低成本的实用方法。

现在,要使用这个类,我们可以在dijkstra文件夹下创建一个名为shortest-path.js的文件,以及刚刚创建的dijkstra.js类:

var Dijkstra = require('./dijkstra');

const graph = {
   'SF': { 'SB': 326, 'MT': 118, 'SJ': 49 },
   'SJ': { 'MT': 72, 'FR': 151, 'BK': 241 },
   'MT': { 'SB': 235, 'LA': 320 },
   'SB': { 'LA': 95 },
   'LA': { 'SD': 120 },
   'SD': { 'PX': 355 },
   'FR': { 'LV': 391 },
   'BK': { 'LA': 112, 'SD': 232, 'PX': 483, 'LV': 286 },
   'LV': { 'PX': 297 },
   'PX': {}
};

console.log(new Dijkstra().solve(graph, 'SF', 'PX'));

现在,要运行这个文件,只需运行以下命令:

node dijkstra/shortest-path.js 

上述命令记录了以下代码:

{ distance: 773, path: [ 'SF', 'SJ', 'BK', 'PX' ] } 

基于原始插图的可视化如下:

使用 BFS 确定关系

好吧,这不是听起来的样子。我们不是在走一条浪漫的道路,彼此问难题。然而,我们正在谈论一个简单的图,例如,一个家谱(是的,树是图的形式)。在这个例子中,我们将使用 BFS 来确定两个节点之间的最短路径,然后可以建立这两个节点之间的关系。

让我们首先设置我们的测试数据,以便我们有准备好的输入图:

您可以从前面的图中注意到,我们有一个小家庭,其中节点AEF是兄弟姐妹。 AB结婚,节点CD是他们的孩子。节点G是节点F的孩子。这里没有复杂或不寻常的地方。我们将使用这些数据来确定节点CG之间的关系。您肯定可以看一下图表并自己判断,但现在这样做并不有趣,对吧?

现在让我们将其转换为我们的程序可以理解的格式:

[
  {
    "name": "A",
    "connections": [
      {
        "name": "E",
        "relation": "Brother"
  },
      {
        "name": "F",
        "relation": "Sister"
  },
      {
        "name": "B",
        "relation": "Wife"
  },
      {
        "name": "D",
        "relation": "Son"
  },
      {
        "name": "C",
        "relation": "Daughter"
  }
    ]
  },
  {
    "name": "B",
    "connections": [
      {
        "name": "A",
        "relation": "Husband"
  },
      {
        "name": "D",
        "relation": "Son"
  },
      {
        "name": "C",
        "relation": "Daughter"
  }
    ]
  },
  {
    "name": "C",
    "connections": [
      {
        "name": "A",
        "relation": "Father"
  },
      {
        "name": "B",
        "relation": "Mother"
  },
      {
        "name": "D",
        "relation": "Brother"
  }
    ]
  },
  {
    "name": "D",
    "connections": [
      {
        "name": "A",
        "relation": "Father"
  },
      {
        "name": "B",
        "relation": "Mother"
  },
      {
        "name": "C",
        "relation": "Sister"
  }
    ]
  },
  {
    "name": "E",
    "connections": [
      {
        "name": "A",
        "relation": "Brother"
  },
      {
        "name": "F",
        "relation": "Sister"
  }
    ]
  },
  {
    "name": "F",
    "connections": [
      {
        "name": "E",
        "relation": "Brother"
  },
      {
        "name": "A",
        "relation": "Brother"
  },
      {
        "name": "G",
        "relation": "Son"
  }
    ]
  },
  {
    "name": "G",
    "connections": [
      {
        "name": "F",
        "relation": "Mother"
  }
    ]
  }
]

这很快变得复杂了,不是吗?这是节点的一个挑战,您想建立关系(即有标签的边)。让我们将这些数据添加到family.json文件中,然后再看一下 BFS 的伪代码,以便在实现之前更好地理解它。

伪代码

BFS 的伪代码与 DFS 非常相似,主要区别在于 BFS 在移动到另一个级别寻找目标节点之前,我们首先迭代所有连接的节点:

INITIALIZE paths, nodes to visit (queue), visited nodes

SET start node as visited

WHILE nodes to visit exist

    GET the next node to visit as current node from top of queue

    IF current node is target

        INITIALIZE result with target node

        WHILE path retrieval not at source

            EXTRACT how we got to this node

            PUSH to result

        FORMAT and return relationship

    ELSE

        LOOP over the entire graph

            IF node is connected to current node

                SET its path as current node

                MARK node as visited

                PUSH it to queue for visiting breadth wise

RETURN Null that is no result

听起来与我们之前使用 DFS 处理的另一个示例非常相似,不是吗?这是因为 DFS 和 BFS 在解决问题的方式上非常相似。两者之间的微小区别在于,在 BFS 中,我们在扩展到另一个级别之前首先评估所有连接的节点,而在 DFS 的情况下,我们选择一个连接的节点,然后遍历它直到整个深度。

实施 BFS

为了实现先前讨论的伪代码,我们将首先简化我们的数据。有两种方法可以做到这一点,如下所示:

  1. 创建图数据的邻接矩阵,指示图作为大小为m x m的二维数组,其中包含 1 和 0。 1表示mrow节点与mcolumn之间的连接,0表示没有连接。

  2. 我们简化数据集,只提取节点作为一个映射,其中键是节点,值是它连接到的节点列表。

虽然这两种方法都是解决问题的好方法,但通常更喜欢第一种选项,因为第二种选项由于所有附带的集合和列表的开销而具有更高的代码复杂性。

然而,现在我们不需要担心代码复杂性,因为我们想要得到可能的最简单的解决方案,所以我们将选择第二个选项。

首先,我们将简化输入数据,以便将转换后的输入传递到我们将创建的 BFS 算法中:

var _ = require('lodash');
var BFS = require('./bfs');
var familyNodes = require('./family.json');

// transform familyNodes into shorter format for simplified BFS var transformedFamilyNodes = _.transform(familyNodes, (reduced, currentNode) => {

      reduced[currentNode.name] = _.map(currentNode.relations, 'name');

      return reduced;
}, {});

这基本上将transformedFamilyNodes设置为前面描述的结构,在我们的情况下,它看起来如下:

{ 
    A: [ 'E', 'F', 'B', 'D', 'C' ],
    B: [ 'A', 'D', 'C' ],
    C: [ 'A', 'B', 'D' ],
    D: [ 'A', 'B', 'C' ],
    E: [ 'A', 'F' ],
    F: [ 'E', 'A', 'G' ],
    G: [ 'F' ] 
}

然后,我们创建我们的 BFS 搜索类,然后添加一个方法来实现搜索功能:

var _ = require('lodash');

class BFS {

   constructor(familyNodes) {
      this.familyNodes = familyNodes;
   }

   search (graph, startNode, targetNode) {

   }

}

module.exports = BFS;

我们在构造函数中接受原始家庭节点的列表,然后在我们的搜索方法中接受修改后的图,我们将对其进行迭代。那么,为什么我们需要原始家庭节点?因为一旦我们从一个节点提取路径到另一个节点,我们将需要建立它们之间的关系,这是记录在原始未处理的家庭节点上的。

我们将继续实现search()方法:

search (graph, startNode, targetNode) {
   // initialize the path to traverse
  var travelledPath = [];

   // mark the nodes that need to be visited breadthwise
  var nodesToVisit = [];

   // mark all visited nodes
  var visitedNodes = {};

   // current node being visited
  var currentNode;

   // add start node to the to be visited path
  nodesToVisit.push(startNode);

   // mark starting node as visited node
  visitedNodes[startNode] = true;

   // while there are more nodes to go
  while (nodesToVisit.length) {

      // get the first one in the list to visit
  currentNode = nodesToVisit.shift();

      // if it is the target
  if (_.isEqual(currentNode, targetNode)) {

         // add to result, backtrack steps based on path taken
  var result = [targetNode];

         // while target is not source
  while (!_.isEqual(targetNode, startNode)) {

            // extract how we got to this node
  targetNode = travelledPath[targetNode];

            // add it to result
  result.push(targetNode);
         }

         // extract the relationships between the edges and return
         // value
  return this.getRelationBetweenNodes(result.reverse());
      }

      // if result not found, set the next node to visit by traversing
 // breadth first  _.forOwn(graph, (connections, name) => {

         // if not current node, is connected to current node 
         // and not already visited
  if (!_.isEqual(name, currentNode)
            && _.includes(graph[name], currentNode)
            && !visitedNodes[name]) {

            // we will be visiting the new node from current node
  travelledPath[name] = currentNode;

            // set the visited flag
  visitedNodes[name] = true;

            // push to nodes to visit
  nodesToVisit.push(name);
         }
      });
   }

   // nothing found
  return null;
}

这一切都很快而且没有痛苦。如果您注意到,我们正在调用getRelationBetweenNodes,它会根据传入构造函数的familyNodes提取节点之间的关系,一旦确定了两个节点之间的路径。这将提取每个节点与其后继节点的关系:

getRelationBetweenNodes(relationship) {
   // extract start and end from result
  var start = relationship.shift();
   var end = relationship.pop();

   // initialize loop variables
  var relation = '';
   var current = start;
   var next;
   var relationWithNext;

   // while end not found
  while (current != end) {
      // extract the current node and its relationships
  current = _.find(this.familyNodes, { name: current });

      // extract the next node, if nothing then set to end node
  next = relationship.shift() || end;

      // extract relationship between the current and the next node
  relationWithNext = _.find(current.relations, {name : next });

      // add it to the relation with proper grammar
  relation += `${relationWithNext.relation}${next === end ? '' : 
 '\'s'} `;

      // set next to current for next iteration
  current = next;
   }

   // return result
  return `${start}'s ${relation}is ${end}`;
}

现在我们的类已经准备好了,我们可以通过调用node bfs/relations.js来调用它:

var _ = require('lodash');
var BFS = require('./bfs');
var familyNodes = require('./family.json');

// transform familyNodes into shorter format for simplified BFS var transformedFamilyNodes = _.transform(familyNodes, (reduced, currentNode) => {

      reduced[currentNode.name] = _.map(currentNode.relations, 'name');

      return reduced;
}, {});

var relationship = new BFS(familyNodes).search(transformedFamilyNodes, 'C', 'G');

console.log(relationship);

前面的代码记录了以下内容:

C's Father's Sister's Son is G 

根据初始示例,这可以用以下方式进行可视化表示:

使用动态规划来构建财务规划师

动态规划DP)是解决某一类问题的一种非常常见和强大的方法。这些问题以主要问题可以分解为子问题,子问题可以进一步分解为更小的问题,并且它们之间存在一些重叠的方式呈现。

DP 经常因为与递归的相似性而被混淆。DP 问题只是一种问题类型,而递归是解决这类问题的一部分。我们可以通过两种主要方式来解决这类问题:

  • 将问题分解为子问题:如果子问题已经解决,则返回保存的解决方案,否则解决并保存解决方案,然后返回。这也被称为记忆化。这也被称为自顶向下的方法。

  • 将问题分解为子问题:开始解决最小的子问题,然后逐步解决更大的问题。这种方法被称为自底向上方法。

在这个例子中,我们有一系列用户的开销;我们需要根据用户设定的总数为用户提供所有可能的结果。我们希望用户能够自由选择他们喜欢的选项,因此我们将采用自底向上的方法。首先,让我们分解输入数据,然后从伪代码中推导出代码:

let expenses = [
   {
      type: 'rent',
      cost: 5
  },
   {
      type: 'food',
      cost: 3
  },
   {
      type: 'entertainment',
      cost: 2
  },
   {
      type: 'car and gas',
      cost: 2
  },
   {
      type: 'ski-trip',
      cost: 5
  }
];
 let total = 10;

您可以从前面的代码中注意到,样本输入数据已经被规范化,以便简化和提高代码效率。一旦我们设置好了,我们就可以创建我们的伪代码来理解算法。

伪代码

在这个例子中,对于这种类型的问题,我们将创建一个二维数组,其中一个维度(y)表示元素的值(即每个开销的成本:5、3、2、2 和 5),另一个维度(x)表示总成本的增量(即 0 到 10)。这就是为什么我们在第一步中规范化我们的数据——它有助于我们在维度方面保持数组的小型化。

一旦我们有了数组,我们将为数组的每个位置arr[i][j]分配一个 true,如果0 到 i的任何费用可以在任何时候创建j的总和,否则为 false:

CREATE empty 2d array with based on input data

IF expected total 0, any element can achieve this, so set [i][0] to true

IF cost of first row is less than total, set [0][cost] to true

LOOP over each row from the second row

    LOOP over each column

        IF current row cost is less than the current column total

            COPY from the row above, the value of the current column if
            it is true
                or else offset the column by current rows cost

        ELSE

            Copy value from the row above for the same column

IF last element of the array is empty

   No results found

generate_possible_outcomes()

FUNCTION generate_possible_outcomes

    IF reached the end and sum is non 0

       ADD cost as an option and return options

    IF reached the end and sum is 0

        return option

    IF sum can be derived without current row cost

        generate_possible_outcomes() from the previous row

    IF sum cannot be derived without current row

        ADD current row as an option

        generate_possible_outcomes() from the previous row

请注意在前面的代码中,算法非常简单;我们只是将问题分解为更小的子问题,并尝试回答每个子问题的问题,同时向更大的问题迈进。一旦我们构建好数组,我们就从数组的最后一个单元格开始,然后向上遍历并根据当前单元格是否为 true,将一个单元格添加到所采取的路径上。一旦我们达到总数为0,也就是第一列时,递归过程就停止了。

实施动态规划算法

现在我们了解了这种方法,让我们首先为我们的算法创建类,并添加analyze()方法,该方法将在生成算法之前首先创建 2D 数组。

当类被初始化时,我们将构建一个 2D 数组,其中所有的值都设置为false。然后我们将使用这个 2D 数组,并根据我们的条件更新其中的一些值,我们将很快讨论这些条件:

var _ = require('lodash');

class Planner {

   constructor(rows, cols) {
      // create a 2d array of rows x cols
 // all with value false  this.planner = _.range(rows).map(() => {
         return _.range(cols + 1).map(()=> false);
      });
 // holds the response
      this.outcomes = [];
   }
}

module.exports = Planner;

现在,我们可以实现analyze()方法,该方法将在 2D 数组的每个单元格中设置适当的值。

首先,我们将设置第一列的值,然后是第一行的值:

analyze(expenses, sum) {
   // get size of expenses
  const size = _.size(expenses);

   // if sum 0, result can be done with 0 elements so
 // set col 0 of all rows as true  _.times(size, (i)=> {
      this.planner[i] = this.planner[i] || [];
      this.planner[i][0] = true;
   });

   // for the first row, if the first cost in the expenses
 // is less than the requested total, set its column value // to true  if(expenses[0].cost <= sum) {
      this.planner[0][expenses[0].cost] = true;
   }

虽然第一列都是 true,但是第一行的一个单元格只有在与该行相关的成本小于总和时才为 true,也就是说,我们可以只用一个元素构建所请求的总和。接下来,我们取出已填写的行和列,用它们来构建数组的其余部分:

 // start from row #2 and loop over all other rows  for(let i = 1; i < size; i++) {

      // take each column
  _.times(sum + 1, (j) => {

         // if the expenses cost for the current row
 // is less than or equal to the sum assigned to the // current column  if (expenses[i].cost <= j) {

            // copy value from above row in the same column if true
 // else look at the value offset by the current rows cost  this.planner[i][j] =  this.planner[i - 1][j] 
                                || this.planner[i - 1][j -
                                expenses[i].cost];
         } else {
            // copy value from above row in the same column
  this.planner[i][j] =  this.planner[i - 1][j];
         }
      });
   }

   // no results found
  if (!this.planner[size - 1][sum]) {
      return [];
   }

   // generate the outcomes from the results found
  this.generateOutcomes(expenses, size - 1, sum, []);

   return this.outcomes;
}

接下来,我们可以实现generateOutcomes()方法,这将允许我们递归地捕获可能的路径。当我们列出我们的二维数组并查看生成的数组的外观时,如下所示:

您可以在上面的屏幕截图中看到,列0(即总和0)都是true,对于行0(成本5),唯一的其他所有值都为 true 的列是列 5(即总和5)。

现在,继续下一行,让我们逐个分析值,例如,在这个阶段,来自当前行和上面一行的成本53不能相加得到成本12,但可以得到358,所以只有它们是true,其余都是false

现在,继续到下一行的每个值,我们可以尝试从上面的行导入值,如果为 true,则从当前行的成本中减去该列总和,并检查上面一行的该列是否为 true。这样我们就可以确定总和是由先前的子集确定的。

例如,在第3行第1列,我们只是从父行导入(记住列0始终为true)。当我们到达第2列时,我们看到父行的列2false,所以我们用当前行的成本(2)抵消这一列的总和(2),所以我们最终得到了第2行第0列为true。因此,我们将值为true分配给第2行第2列,然后我们继续这个过程,直到结束。

构建整个数组后,我们需要从最后开始,也就是array[4][10],然后递归向上遍历,直到达到总和0或者到达非零总和的顶部:

generateOutcomes(expenses, i, sum, p) {
   // reached the end and the sum is non zero
  if(i === 0 && sum !== 0 && this.planner[0][sum]) {
      p.push(expenses[i]);
      this.outcomes.push(_.cloneDeep(p));
      p = [];
      return;
   }

   // reached the end and the sum is zero
 // i.e. reached the origin  if(i === 0 && sum === 0) {
      this.outcomes.push(_.cloneDeep(p));
      p = [];
      return;
   }

   // if the sum can be generated
 // even without the current value  if(this.planner[i - 1][sum]) {
      this.generateOutcomes(expenses, i - 1, sum, _.cloneDeep(p));
   }

   // if the sum can be derived
 // only by including the the current value  if(sum >= expenses[i].cost && this.planner[i - 1][sum -
   expenses[i].cost]) {
      p.push(expenses[i]);
      this.generateOutcomes(expenses, i - 1, sum - expenses[i].cost,
      p);
   }
}

现在,这可以在我们的计划中使用,以生成用户选择的选项列表:

var Planner = require('./dp');

let expenses = [
   {
      type: 'rent',
      cost: 5
  },
   {
      type: 'food',
      cost: 3
  },
   {
      type: 'entertainment',
      cost: 2
  },
   {
      type: 'car and gas',
      cost: 2
  },
   {
      type: 'ski-trip',
      cost: 5
  }
];
let total = 10;

var options = new Planner(expenses.length, total).analyze(expenses, total);

console.log(options);

运行上面的代码记录符合我们预算的不同组合,结果是:

[ 
    [ { type: 'entertainment', cost: 2 },
      { type: 'food', cost: 3 },
      { type: 'rent', cost: 5 } 
    ],
    [ { type: 'car and gas', cost: 2 },
      { type: 'food', cost: 3 },
      { type: 'rent', cost: 5 } 
    ],
    [ { type: 'ski-trip', cost: 5 }, 
      { type: 'rent', cost: 5 } 
    ],
    [ { type: 'ski-trip', cost: 5 },
      { type: 'entertainment', cost: 2 },
      { type: 'food', cost: 3 } 
    ],
    [ { type: 'ski-trip', cost: 5 },
      { type: 'car and gas', cost: 2 },
      { type: 'food', cost: 3 } 
    ] 
]

使用贪婪算法构建旅行行程

贪婪算法是一种将问题分解为较小子问题,并根据每一步的局部优化选择拼凑出每个子问题的解决方案的算法。这意味着,在加权边图的情况下,例如,下一个节点是根据从当前节点出发的最小成本来选择的。这可能不是最佳路径,但是在贪婪算法的情况下,获得解决方案是主要目标,而不是获得完美或理想的解决方案。

在这个用例中,我们有一组城市以及前往每个城市的权重(旅行/停留成本+享受因素等)。目标是找出我们想要旅行和访问这些城市的方式,以便旅行是完整和有趣的。当然,对于给定的一组城市,可以以许多可能的方式前往这些城市,但这并不保证路径将被优化。为了解决这个问题,我们将使用 Kruskal 的最小生成树算法,这是一种贪婪算法,将为我们生成最佳可能的解决方案。图中的生成树是指所有节点都连接在一起,并且节点之间没有循环的图。

假设我们的输入数据格式如下,与我们之前在 Dijkstra 示例中看到的格式相同,只是我们没有定义节点之间的方向,允许我们从任一方向进行旅行:

这些数据可以以编程方式写成如下形式:

const graph = {
   'SF': { 'SB': 326, 'MT': 118, 'SJ': 49 },
   'SJ': { 'MT': 72, 'FR': 151, 'BK': 241 },
   'MT': { 'SB': 235, 'LA': 320 },
   'SB': { 'LA': 95 },
   'LA': { 'SD': 120 },
   'SD': { 'PX': 355 },
   'FR': { 'LV': 391 },
   'BK': { 'LA': 112, 'SD': 232, 'PX': 483, 'LV': 286 },
   'LV': { 'PX': 297 },
   'PX': {}
};

从这些信息中,我们可以提取唯一的边,如下所示:

[ 
  { from: 'SF', to: 'SB', weight: 326 },
  { from: 'SF', to: 'MT', weight: 118 },
  { from: 'SF', to: 'SJ', weight: 49 },
  { from: 'SJ', to: 'MT', weight: 72 },
  { from: 'SJ', to: 'FR', weight: 151 },
  { from: 'SJ', to: 'BK', weight: 241 },
  { from: 'MT', to: 'SB', weight: 235 },
  { from: 'MT', to: 'LA', weight: 320 },
  { from: 'SB', to: 'LA', weight: 95 },
  { from: 'LA', to: 'SD', weight: 120 },
  { from: 'SD', to: 'PX', weight: 355 },
  { from: 'FR', to: 'LV', weight: 391 },
  { from: 'BK', to: 'LA', weight: 112 },
  { from: 'BK', to: 'SD', weight: 232 },
  { from: 'BK', to: 'PX', weight: 483 },
  { from: 'BK', to: 'LV', weight: 286 },
  { from: 'LV', to: 'PX', weight: 297 } 
]

理解生成树

在继续实现伪代码和代码之前,让我们花一些时间了解生成树是什么,以及我们如何利用它们来简化前面提到的问题。

图中的生成树是一系列边,可以连接所有节点而不形成任何循环。因此,很明显对于任何给定的图,可能会有多个生成树。在我们的例子中,现在更有意义的是我们想要生成最小生成树MST),也就是说,边的总权重最小的生成树。

然而,我们如何生成生成树并确保它具有最小值呢?解决方案虽然不太明显,但相当简单。让我们用伪代码来探讨这个方法。

伪代码

现在手头的问题已经归结为以下内容——用最小权重的边连接图的所有节点,且没有循环。为了实现这一点,首先,我们需要分离所有的边,并按权重递增的顺序进行排序。然后,我们使用一种称为按秩合并的技术来获得最终的边列表,这些边可以用来创建 MST:

SORT all edges by weight in increasing order

DIVIDE all nodes into their own subsets whose parent is the node iteself

WHILE more edges are required

    EXTRACT the first edge from the list of edges

    FIND the parent nodes of the from and to nodes of that edge

    IF start and end nodes do not have same parent

        ADD edge to results

        GET parent of from node and to node

        IF parent nodes of from and to are the same rank

            SET one node as the parent of the other and increment rank 
            of parent

        ELSE

            SET parent of element with lesser rank            

RETURN Results

find()方法中,我们将执行一种称为路径压缩的小优化。听起来很花哨,但实际上并不是。假设我们在一个名为A的节点,其父节点是节点B,而其父节点是节点C。当我们试图确定这一点时,我们只需一次解析整个路径,然后下一次,我们就记住了节点A的父节点最终是节点C。我们做这件事的方式也相对简单——每次我们遍历树的上一个节点时,我们将更新它的parent属性:

FIND_PARENT(all_subsets, currentNode)

    IF parent of currentNode is NOT currentNode

        FIND_PARENT(all_subsets, currentNode.parent)

    RETURN currentNode.parent

使用贪婪算法实现最小生成树

到目前为止,我们已经按照前面描述的方式设置了数据集。现在,我们将从这些数据生成边,然后将其传递给我们的生成树类以生成 MST。因此,让我们将以下代码添加到greeds/travel.js中:

const _ = require('lodash');
const MST = require('./mst');

const graph = {
   'SF': { 'SB': 326, 'MT': 118, 'SJ': 49 },
   'SJ': { 'MT': 72, 'FR': 151, 'BK': 241 },
   'MT': { 'SB': 235, 'LA': 320 },
   'SB': { 'LA': 95 },
   'LA': { 'SD': 120 },
   'SD': { 'PX': 355 },
   'FR': { 'LV': 391 },
   'BK': { 'LA': 112, 'SD': 232, 'PX': 483, 'LV': 286 },
   'LV': { 'PX': 297 },
   'PX': {}
};

const edges= [];

_.forEach(graph, (values, node) => {
   _.forEach(values, (weight, city) => {
      edges.push({
         from: node,
         to: city,
         weight: weight
      });
   });
});

var mst = new MST(edges, _.keys(graph)).getNodes();

console.log(mst);

我们的 MST 类可以添加到greedy/mst.js中,如下所示:

const _ = require('lodash');

class MST {

   constructor(edges, vertices) {
      this.edges = _.sortBy(edges, 'weight');
      this.vertices = vertices;
   }

   getNodes () {
      let result = [];

      // subsets to track the parents and ranks
  var subsets = {};

      // split each vertex into its own subset
 // with each of them initially pointing to themselves  _.each(this.vertices, (val)=> {
         subsets[val] = {
            parent: val,
            rank: 0
  };
      });

      // loop over each until the size of the results
 // is 1 less than the number of vertices  while(!_.isEqual(_.size(result), _.size(this.vertices) - 1)) {

         // get next edge
  var selectedEdge = this.edges.shift();

         // find parent of start and end nodes of selected edge
  var x = this.find(subsets, selectedEdge.from);
         var y = this.find(subsets, selectedEdge.to);

         // if the parents nodes are not the same then
 // the nodes belong to different subsets and can be merged  if (!_.isEqual(x, y)) {

            // add to result
  result.push(selectedEdge);

            // push is resultant tree as new nodes
  this.union(subsets, x, y);
         }
      }

      return result;
   }

   // find parent with path compression
  find(subsets, i) {
      let subset = subsets[i];

      // until the parent is not itself, keep updating the
 // parent of the current node  if (subset.parent != i) {
         subset.parent = this.find(subsets, subset.parent);
      }

      return subset.parent;
   }

   // union by rank
  union(subsets, x, y) {
      // get the root nodes of each of the nodes
  let xRoot = this.find(subsets, x);

      let yRoot = this.find(subsets, y);

      // ranks equal so it doesnt matter which is the parent of which
      node
  if (_.isEqual(subsets[xRoot].rank, subsets[yRoot].rank)) {

         subsets[yRoot].parent = xRoot;

         subsets[xRoot].rank++;

      } else {
         // compare ranks and set parent of the subset
  if(subsets[xRoot].rank < subsets[yRoot].rank) {

            subsets[xRoot].parent = yRoot;
         } else {

            subsets[yRoot].parent = xRoot;
         }
      }
   }

}

module.exports = MST;

运行上述代码将记录边缘,如下所示:

[ { from: 'SF', to: 'SJ', weight: 49 },
  { from: 'SJ', to: 'MT', weight: 72 },
  { from: 'SB', to: 'LA', weight: 95 },
  { from: 'BK', to: 'LA', weight: 112 },
  { from: 'LA', to: 'SD', weight: 120 },
  { from: 'SJ', to: 'FR', weight: 151 },
  { from: 'MT', to: 'SB', weight: 235 },
  { from: 'BK', to: 'LV', weight: 286 },
  { from: 'LV', to: 'PX', weight: 297 } ]

一旦连接,这些路径将如下所示:

使用分支和界限算法创建自定义购物清单

分支和界限算法适用于一组涉及组合优化的问题。这意味着我们手头可能有一个问题,并不一定有一个正确的解决方案,但根据我们拥有的信息,我们需要从可用解决方案的有限但非常大的数量中生成最佳解决方案。

我们将使用分支和界限算法来优化和解决一类称为 0/1 背包问题的动态规划问题。在这种情况下,考虑我们有一个购物清单,其中列出了物品、它们的成本(以美元计)以及它们对你的重要性(价值)在 0 到 10 的范围内。例如,考虑以下示例清单:

const list = [
   {
      name: 'vegetables',
      value: 12,
      cost: 4   },
   {
      name: 'candy',
      value: 1,
      cost: 1   },
   {
      name: 'magazines',
      value: 4,
      cost: 2   },
   {
      name: 'dvd',
      value: 6,
      cost: 2   },
   {
      name: 'earphones',
      value: 6,
      cost: 3   },
   {
      name: 'shoes',
      value: 4,
      cost: 2   },
   {
      name: 'supplies',
      value: 9,
      cost: 3   }
];

给定清单,我们现在需要找到最佳组合以最大化价值,给定一个固定的预算(例如,10 美元)。该算法称为 0/1 背包,因为你可以做出的决定只有二进制,即,要么拿起一个物品,要么放下它。

现在,让我们试着从数学的角度理解问题陈述是什么。我们希望在预算范围内最大化价值,因此如果我们假设我们有e[1], e[2], e[3]等元素,我们知道每个元素都可以被选择(这将为其分配一个值 1)或不选择(这将为其分配一个值 0),为了确定总价值,我们可以将其公式化如下:

虽然我们试图最大化价值,但我们也希望保持总成本低于预算:

好的,很好,现在我们知道问题在哪里了,但是对于这个问题有什么解决方案呢?由于我们知道值始终只能是 0 或 1,我们可以创建一个二叉树来表示解的状态空间,然后在每个节点上为每种可能性布置一个分支;例如在我们的情况下,我们将有2^(n)种可能性(n = 7),总共 128 种。现在,遍历这 128 种可能性听起来并不是很理想,我们可以注意到这个数字会呈指数增长。

理解分支和界限算法

在编写伪代码之前,让我们分解解决方案以便更好地理解。我们要做的是创建一个二叉树,在树的每个级别上,我们要为到达该节点的成本、节点的价值和到达该节点的成本的上限分配值。

然而,我们如何计算树的上限呢?为了确定这一点,让我们首先将我们的问题分解成更小的部分:

const costs = [4, 1, 2, 2, 3, 2, 3];
const value = [12, 1, 4, 6, 6, 4, 9];
const v2c = [12/4, 1/1, 4/2, 6/2, 6/3, 4/2, 9/3];
const maxCost = 10

一旦我们有了这个,我们将按照价值与成本比的递减顺序重新排列我们的元素,因为我们希望以最小的成本选择价值最高的元素:

const costs = [4, 2, 3, 3, 2, 2, 1];
const value = [12, 6, 9, 6, 4, 4, 1];
const v2c = [3, 3, 3, 2, 2, 2, 1];
const maxCost = 10;

为了确定上限,我们现在将使用贪婪算法(按递减顺序排列的元素),在其中我们将允许分数值以获得可能的最高上限。

因此,让我们首先选择显而易见的第一个元素,其价值为12,成本为4,因此此步骤的总上限价值为12,总成本为4,小于最大值,即10。然后,我们继续下一个元素,其中上限现在变为12+6=18,成本为4+2=6,仍然小于10。然后,我们选择下一个元素,将价值的上限提高到18+9=27,成本为6+3=9。如果我们选择成本为3的下一个元素,我们将超过最大成本,因此我们将按比例选择它,即(剩余成本/项目成本) * 项目价值,这将等于(1/3)6,即2。因此,根元素的上限为27+2=29

因此,我们现在可以说在给定的约束条件下,例如成本和价值,我们可以获得的上限值是29。现在我们有了价值的上限,我们可以为我们的二叉树创建一个根元素,该根元素的上限值为此,成本和价值分别为 0。

一旦计算出根节点的最大上限,我们可以从第一个节点开始递归地重复这个过程,为后续节点计算。在每个级别上,我们将以反映节点被选择与未被选择时的值的方式更新成本、值和上限:

在上图中,您可以注意到我们已经为几个级别构建了状态空间树,显示了当采取分支和不采取分支时每个节点的状态。正如您所看到的,其中一些分支低于我们之前计算的最大上限27,而其中一个分支超过了27,因此我们可以将该分支从进一步考虑中移除。现在,在每个步骤中,我们的主要目标是在增加累积值的同时保持在上限以下或等于上限。任何偏离太远或超过上限的分支都可以安全地从考虑中移除。

实施分支和界限算法

到目前为止,我们已经讨论了如何逐步为每个可用元素构建状态空间树,但这并不是必要的,我们只需要根据我们的界限和已设置的最大成本有选择地添加节点。

那么,对我们的实施意味着什么?我们将逐个节点地考虑,考虑如果包括它或者不包括它会发生什么,然后根据我们设置的条件(上限)将其添加到队列中以供进一步处理。

由于我们已经有了一系列项目,我们将首先进行一些转换,以使算法的其余部分更简单:

const _ = require('lodash');

class BranchAndBound {

   constructor(list, maxCost) {
      // sort the costs in descending order for greedy calculation of 
      upper bound
  var sortedList = _.orderBy(list, 
                     (option) => option.value/option.cost,
                     'desc');

      // original list
  this.list = list;

      // max allowed cost
  this.maxCost = maxCost;

      // all costs
  this.costs = _.map(sortedList, 'cost');

      // all values
  this.values = _.map(sortedList, 'value');
   }
}

module.exports = BranchAndBound;

一旦我们有了成本和值排序并提取出来,我们就可以实现算法来计算每个节点的最大值,以及当前节点的最大上限值,和不包括当前节点的情况:

const _ = require('lodash');

class BranchAndBound {

   constructor(list, maxCost) {
      // sort the costs in descending order for greedy calculation of
      upper bound
  var sortedList = _.orderBy(list,
                     (option) => option.value/option.cost,
                     'desc');

      // original list
  this.list = list;

      // max allowed cost
  this.maxCost = maxCost;

      // all costs
  this.costs = _.map(sortedList, 'cost');

      // all values
  this.values = _.map(sortedList, 'value');
   }

   calculate() {
      // size of the input data set
  var size = _.size(this.values);

      // create a queue for processing nodes
  var queue = [];

      // add dummy root node
  queue.push({
         depth: -1,
         value: 0,
         cost: 0,
         upperBound: 0
  });

      // initialize result
  var maxValue = 0;

      // initialize path to the result
  var finalIncludedItems = [];

      // while queue is not empty
 // i.e leaf node not found  while(!_.isEmpty(queue)) {

         // initialize next node
  var nextNode = {};

         // get selected node from queue
  var currentNode = queue.shift();

         // if leaf node, no need to check for child nodes
  if (currentNode.depth !== size - 1) {

            // increment depth of the node
  nextNode.depth = currentNode.depth + 1;

            /*
 * *  We need to calculate the cost and value when the next
               item *  is included and when it is not * * *  First we check for when it is included */   // increment cost of the next node by adding current nodes
            cost to it // adding current nodes cost is indicator that it is
            included  nextNode.cost =  currentNode.cost +
            this.costs[nextNode.depth];

            // increment value of the next node similar to cost
  nextNode.value =  currentNode.value +
            this.values[nextNode.depth];

            // if cost of next node is below the max and the value
            provided
 // by including it is more than the currently accrued value // i.e. bounds and constrains satisfied  if (nextNode.cost <= this.maxCost && nextNode.value >
            maxValue) {

               // add node to results
  finalIncludedItems.push(nextNode.depth);

               // update maxValue accrued so far
  maxValue = nextNode.value;
            }

            // calculate the upper bound value that can be
 // generated from the new node  nextNode.upperBound = this.upperBound(nextNode, size,
                              this.maxCost, this.costs, this.values);

            // if the node is still below the upper bound
  if (nextNode.upperBound > maxValue) {

               // add to queue for further consideration
  queue.push(_.cloneDeep(nextNode));
            }

            /*
 *  Then we check for when the node is not included */   // copy over cost and value from previous state  nextNode.cost = currentNode.cost;
            nextNode.value = currentNode.value;

            // recalculate upper bound
  nextNode.upperBound = this.upperBound(nextNode, size,
                              this.maxCost, this.costs, this.values);

            // if max value is still not exceeded,
 // add to queue for processing later  if (nextNode.upperBound > maxValue) {

               // add to queue for further consideration
  queue.push(_.cloneDeep(nextNode));
            }
         }
      }

      // return results
  return { val: maxValue, items: _.pullAt(this.list,
      finalIncludedItems) };
   }

   upperBound(node, size, maxCost, costs, values) {
      // if nodes cost is over the max allowed cost
  if (node.cost > maxCost) {
         return 0;
      }

      // value of current node
  var valueBound = node.value;

      // increase depth
  var nextDepth = node.depth + 1;

      // init variable for cost calculation
 // starting from current node  var totCost = node.cost;

      // traverse down the upcoming branch of the tree to see what
 // cost would be at the leaf node  while ((nextDepth < size) && (totCost + costs[nextDepth] <=
      maxCost)) {
         totCost += costs[nextDepth];
         valueBound += values[nextDepth];
         nextDepth++;
      }

      // allow fractional value calculations
 // for the last node  if (nextDepth < size) {
         valueBound += (maxCost - totCost) * values[nextDepth] / 
         costs[nextDepth];
      }

      // return final value at leaf node
  return valueBound;
   }
}

module.exports = BranchAndBound;

运行相同的算法,我们得到了返回给我们的最大值的结果:

const _ = require('lodash');
const BnB = require('./bnb');

const list = [
   {
      name: 'vegetables',
      value: 12,
      cost: 4
  },
   {
      name: 'candy',
      value: 1,
      cost: 1
  },
   {
      name: 'magazines',
      value: 4,
      cost: 2
  },
   {
      name: 'dvd',
      value: 6,
      cost: 2
  },
   {
      name: 'earphones',
      value: 6,
      cost: 3
  },
   {
      name: 'shoes',
      value: 4,
      cost: 2
  },
   {
      name: 'supplies',
      value: 9,
      cost: 3
  }
];

const budget = 10;

var result = new BnB(list, budget).calculate();

console.log(result);

这记录如下:

{ 
  val: 28,
  items:[ 
     { name: 'vegetables', value: 12, cost: 4 },
     { name: 'candy', value: 1, cost: 1 },
     { name: 'magazines', value: 4, cost: 2 },
     { name: 'supplies', value: 9, cost: 3 } 
  ] 
}

何时不使用蛮力算法

蛮力算法是一种问题解决技术,它在选择或拒绝问题的最终解决方案之前,探索特定问题的每种可能的解决方案。

面对挑战时,最自然的反应是蛮力解决方案,或者首先尝试蛮力解决方案,然后再进行优化。然而,这真的是解决这类问题的最佳方式吗?有更好的方法吗?

答案绝对是肯定的,因为到目前为止我们在整个章节中已经看到了。蛮力不是解决方案,直到它是唯一的解决方案。有时,我们可能会觉得我们正在创建一个自定义算法来解决我们所面临的问题,但我们需要问自己是否我们真的正在尝试找到问题的所有可能解决方案,如果是的话,那么这又是蛮力。

不幸的是,蛮力不是一个固定的算法供我们检测。方法随着问题陈述而改变,因此需要查看我们是否试图生成所有解决方案并避免这样做。

然而,你可能会问,我如何知道何时蛮力解决一个问题,何时应该尝试找到最优解?我如何知道更优解或算法是否存在?

没有快速简单的方法来判断是否有比蛮力更容易的解决方案来计算任何解决方案。例如,一个问题可以以蛮力方式解决,例如,本章中的任何一个例子。我们可以列出所有可能性(无论生成这个列表有多困难,因为可能存在大量的可能性),然后筛选出我们认为是解决方案的那些。

在我们生成最短路径的示例中,我们使用 Dijkstra 算法来使用与到达每个城市相关的成本。这个问题的蛮力解决方案是计算从起点到终点节点的图中所有可用路径,然后计算每条路径的成本,最终选择成本最低的路径。

了解问题陈述可以极大地帮助减少问题的复杂性,也可以帮助我们避免蛮力解决方案。

蛮力斐波那契生成器

例如,让我们以斐波那契生成器为例,蛮力生成一些数字:

var _ = require('lodash');

var count = 10;

bruteForceFibonacci(count);

function bruteForceFibonacci(count) {
   var prev = 0;
   var next = 1;
   var res = '';

   res += prev;
   res += ',' + next;

   _.times(count, ()=> {
      var tmp = next;
      next = prev + next;
      prev = tmp;

      res += ',' + next;
   });

   console.log(res);
}

在这里,我们可以看到我们没有应用任何领域知识;我们只是从系列中取出前两个数字并相加。这是一个很好的方法,但我们可以看到这里有一些改进的空间。

递归斐波那契生成器

我们可以使用递归生成斐波那契数列如下:

function recursiveFibonacci(num) {
   if (num == 0) {
      return 0;
   } else if (num == 1 || num == 2) {
      return 1;
   } else {
      return recursiveFibonacci(num - 1) + recursiveFibonacci(num - 2);
   }
}

你可以看到我们应用了与之前相同的概念,即下一个数字是斐波那契数列数字的前两个数字的总和。然而,我们依赖递归来在需要新值时重新计算所有旧值。

记忆化斐波那契生成器

我们可以进一步增强生成器,使用记忆化,这是一种只计算一次值并记住它以备后用的技术:

function memoizedFibonacci(num) {
   if (num == 0) {
      memory[num] = 0;
      return 0;
   } else if (num == 1 || num == 2) {
      memory[num] = 1;
      return 1;
   } else {
      if (!memory[num]) {
         memory[num] = memoizedFibonacci(num - 1) +
         memoizedFibonacci(num - 2);
      }

      return memory[num];
   }
}

在这里,我们依赖于一个名为memory的内存变量来存储和检索系列中先前计算的斐波那契数的值,从而避免一系列重复计算。

如果记录每种方法所花费的时间,您会发现随着输入数字的大小增加,递归方法的性能确实会显著下降。仅仅因为一个算法是蛮力算法,并不意味着它是最差/最慢/最昂贵的。然而,通过对递归进行简单的改变(记忆化),您会发现它再次比蛮力技术更快。

在尝试为任何问题编写解决方案时,最大的帮助是减少不必要的空间和时间复杂度。

总结

在本章中,我们涵盖了一些重要类型的算法,并为一些示例用例实施了它们。我们还讨论了各种算法优化技术,如记忆化和回溯。

在下一章中,我们将讨论一些排序技术,并将它们应用于解决一些示例。

第七章:排序及其应用

排序是我们用来重新排列一组数字或对象以升序或降序排列的非常常见的算法。排序的更技术性的定义如下:

在计算机科学中,排序算法是一种将列表中的元素按照特定顺序排列的算法。

现在,假设您有一个包含n个项目的列表,并且您想对它们进行排序。您取出所有n个项目,并确定您可以将这些项目放置在所有可能的序列中,这种情况下总共有n!种可能。我们现在需要确定这些n!序列中哪些没有任何倒置对,以找出排序后的列表。倒置对被定义为列表中位置由i,j表示的一对元素,其中i < j,但值x[i] > x[j]

当然,上述方法是繁琐的,需要一些繁重的计算。在本章中,我们将讨论以下主题:

  • 排序算法的类型

  • 为图书管理系统(如图书馆)创建 API

  • 插入排序算法用于对书籍数据进行排序

  • 归并排序算法用于对书籍数据进行排序

  • 快速排序算法用于对书籍数据进行排序

  • 不同排序算法的性能比较

让我们看一下上面列出的一些更优化的排序类型,可以在各种场景中使用。

排序算法的类型

我们都知道有不同类型的排序算法,大多数人在编程生涯中的某个时候都听说过这些不同类型的算法的名称。排序算法和数据结构之间的主要区别在于,无论使用哪种类型的算法,前者总是有相同的目标。这使得我们非常容易和重要地在各个方面比较不同的排序算法,大多数情况下都归结为速度和内存使用。在选择特定的排序算法之前,我们需要在手头的数据类型基础上做出这一决定。

考虑到以上情况,我们将比较和对比以下三种不同类型的算法:

  • 插入排序

  • 归并排序

  • 快速排序

归并排序和快速排序是 v8 引擎在内部用于对数据进行排序的算法;当数据集大小太小(<10)时,使用归并排序,否则使用快速排序。另一方面,插入排序是一种更简单的算法。

然而,在我们深入讨论每种排序算法的实现之前,让我们快速看一下用例,然后设置相同的先决条件。

不同排序算法的用例

为了测试不同的排序算法,我们将创建一个小型的 express 服务器,其中将包含一个端点,用于获取按每本书的页数排序的所有书籍列表。在这个例子中,我们将从一个 JSON 文件开始,其中包含一个无序的书籍列表,这将作为我们的数据存储。

在生产应用中,排序应该推迟到数据库查询,并且不应作为应用逻辑的一部分来完成,以避免在处理筛选和分页请求等场景时出现痛苦和混乱。

创建一个 Express 服务器

我们设置项目的第一步是创建一个目录,我们想要在其中编写我们的应用程序;为此,在终端中运行以下命令:

mkdir sorting

创建后,通过运行cd进入目录,然后运行 npm 初始化命令将其设置为 Node.js 项目:

cd sorting
npm init

这将询问您一系列问题,您可以回答或留空以获得默认答案,两者都可以。项目初始化后,添加以下 npm 包,如前几章所做,以帮助我们设置 express 服务器:

npm install express --save

添加后,我们现在准备创建我们的服务器。在项目的根目录中添加以下代码到一个新文件中,并将其命名为index.js

var express = require('express');
var app = express();

app.get('/', function (req, res) {
   res.status(200).send('OK!')
});

app.listen(3000, function () {
   console.log('Chat Application listening on port 3000!')
});

我们已经设置了一个返回OK的单个端点,并且我们的服务器正在端口3000上运行。让我们还在package.json文件的脚本中添加一个快捷方式来轻松启动应用程序:

...
"scripts": {
  "start": "node index.js",
  "test": "echo \"Error: no test specified\" && exit 1" },
...

现在,要测试这些更改,请从根文件夹运行npm start,并在浏览器中打开localhost:3000。您应该在屏幕上看到一个OK!消息,如我们在index.js文件中定义的那样。

模拟图书馆书籍数据

现在,让我们创建我们的图书馆书籍的模拟数据,当用户请求书籍列表时,我们希望对其进行排序和返回。在本章中,我们将专注于按每本书的页数对图书馆书籍进行排序,因此我们只能简单地添加页数和书籍的 ID,如下面的代码所示:

[
{"id":"dfa6cccd-d78b-4ea0-b447-abe7d6440180","pages":1133},
{"id":"0a2b0a9e-5b3d-4072-ad23-92afcc335c11","pages":708},
{"id":"e1a58d73-3bd2-4a3a-9f29-6cfb9f7a0007","pages":726},
{"id":"5edf9d36-9b5d-4d1f-9a5a-837ad9b73fe9","pages":1731},
...
]

我们想测试每个算法的性能,因此让我们添加 5000 本书,以确保我们有足够的数据来测试性能。此外,我们将在 300 到 2000 页之间随机添加这些页数,由于我们总共有 5000 本书,因此在不同的书籍中将会有明显的页数重复。

以下是一个示例脚本,您可以使用它来生成这些数据,如果您想使用此脚本,请确保安装了uuid npm 模块:

npm install uuid --save

还要在项目的根目录创建一个名为generator.js的文件,并添加以下代码:

const fs = require('fs');
const uuid = require('uuid');
const books = [];

for(var i = 0; i < 5000; i++) {
   books.push({
      "id": uuid.v4(),
      "pages": Math.floor(Math.random() * (2000 - 300 + 1) + 300)
   })
}

fs.writeFile('books.json', JSON.stringify(books), (err) => {});

现在,要运行它,请从根目录运行node generator.js命令,这将生成与前面代码中显示的记录类似的数据的books.json文件。

插入排序 API

现在,让我们创建一个端点,使用插入排序来根据页面计数对数据进行排序和返回。

什么是插入排序

插入排序,顾名思义,是一种排序类型,我们从输入数据集中逐个提取元素,然后确定元素应该放置的位置后,将它们插入到排序好的结果数据集中。

我们可以立即确定这种方法将需要额外的集合(与输入相同大小)来保存结果。因此,如果我们有一个包含 10 个元素的Set作为输入,我们将需要另一个大小也为 10 的Set作为输出。我们可以稍微改变这种方法,使我们的排序在内存中进行。在内存中执行操作意味着我们不会请求更多的内存(通过创建与输入相同大小的额外集合)。

伪代码

让我们快速勾画一下插入排序的伪代码:

LOOP over all data excluding first entry (i = 1)

    INITIALIZE variable j = i - 1

    COPY data at index i

    WHILE all previous values are less than current

        COPY previous value to next

        DECREMENT j

    ADD current data to new position

RETURN sorted data

实现插入排序 API

根据前面描述的伪代码,实现插入排序非常容易。让我们首先创建一个名为sort的文件夹,然后创建一个名为insertion.js的文件,在其中我们将添加我们的插入类,如下面的代码所示:

class Insertion {

   sort(data) {
      // loop over all the entries excluding the first record
  for (var i = 1; i< data.length; ++i) {

         // take each entry
  var current = data[i];

         // previous entry
  var j = i-1;

         // until beginning or until previous data is lesser than
         current
  while (j >= 0 && data[j].pages < current.pages) {

            // shift entries to right
  data[j + 1] = data[j];

            // decrement position for next iteration
  j = j - 1;
         }

         // push current data to new position
  data[j+1] = current;
      }

      // return all sorted data
  return data;
   }
}

module.exports = Insertion;

如伪代码和实际实现中所讨论的,我们将取每个值并将其与之前的值进行比较,当您有 5000 个随机顺序的项目时,这听起来并不是一件好事;这是真的,插入排序只有在数据集几乎排序并且整个数据集中有一些倒置对时才是首选。

改进此功能的一种方法是改变我们确定要在排序列表中插入的位置的方式。我们可以不再将其与所有先前的值进行比较,而是执行二进制搜索来确定数据应该移动到排序列表中的位置。因此,通过稍微修改前面的代码,我们得到以下结果:

class Insertion {

   sort(data) {
      // loop over all the entries
  for (var i = 1; i < data.length; ++i) {

         // take each entry
  var current = data[i];

         // previous entry
  var j = i - 1;

         // find location where selected sould be inseretd
  var index = this.binarySearch(data, current, 0, j);

         // shift all elements until new position
  while (j >= index) {
            // shift entries to right
  data[j + 1] = data[j];

            // decrement position for next iteration
  j = j - 1;
         }

         // push current data to new position
  data[j + 1] = current;
      }

      // return all sorted data
  return data;
   }

   binarySearch(data, current, lowPos, highPos) {
      // get middle position
  var midPos = Math.floor((lowPos + highPos) / 2);

      // if high < low return low position;
 // happens at the beginning of the data set  if (highPos <= lowPos) {

         // invert condition to reverse sorting
  return (current.pages < data[lowPos].pages) ? (lowPos + 1):
         lowPos;
      }

      // if equal, give next available position
  if(current.pages === data[midPos].pages) {
         return midPos + 1;
      }

      // if current page count is less than mid position page count,
 // reevaluate for left half of selected range // invert condition and exchange return statements to reverse
      sorting  if(current.pages > data[midPos].pages) {
         return this.binarySearch(data, current, lowPos, midPos - 1);
      }

      // evaluate for right half of selected range
  return this.binarySearch(data, current, midPos + 1, highPos);
   }
}

module.exports = Insertion;

一旦实现,我们现在需要定义在我们的数据集上使用此排序的路由。为此,首先我们将导入之前创建的 JSON 数据,然后在我们的端点中使用它,我们专门创建它来使用插入排序对数据进行排序:

var express = require('express');
var app = express();
var data = require('./books.json');
var Insertion = require('./sort/insertion');

app.get('/', function (req, res) {
   res.status(200).send('OK!')
});

app.get('/insertion', function (req, res) {
 res.status(200).send(new Insertion().sort(data));
});

app.listen(3000, function () {
   console.log('Chat Application listening on port 3000!')
});

现在,我们可以重新启动服务器,并尝试在浏览器或 postman 中访问localhost:3000/insertion端点,如下截图所示,以查看包含排序数据的响应:

归并排序 API

现在,让我们创建一个端点,使用 Mergesort 对基于页面计数的数据进行排序并返回。

什么是 Mergesort

Mergesort 是一种分而治之的排序算法,首先将整个数据集划分为每个元素一个子集,然后重复地将这些子集连接和排序,直到得到一个排序好的集合。

这个算法同时使用了递归和分而治之的方法。让我们来看看这种实现的伪代码。

伪代码

根据我们迄今为止对 mergesort 的了解,我们可以得出实现的伪代码,如下所示:

MERGE_SORT(array)
    INITIALIZE middle, left_half, right_half

    RETURN MERGE(MERGE_SORT(left_half), MERGE_SORT(right_half))

MERGE(left, right)

    INITIALIZE response

    WHILE left and right exist 

        IF left[0] < right[0]

            INSERT left[0] in result

        ELSE

            INSERT right[0] in result

    RETURN result concatenated with remainder of left and right

请注意,在前面的代码中,我们首先递归地将输入数据集划分,然后对数据集进行排序和合并。现在,让我们实现这个排序算法。

实现 Mergesort API

现在,让我们创建我们的 Mergesort 类,以及之前创建的 Insertionsort 类,并将其命名为merge.js

class Merge {

   sort(data) {
      // when divided to single elements
  if(data.length === 1) {
         return data;
      }

      // get middle index
  const middle = Math.floor(data.length / 2);

      // left half
  const left = data.slice(0, middle);

      // right half
  const right = data.slice(middle);

      // sort and merge
  return this.merge(this.sort(left), this.sort(right));
   }

   merge(left, right) {
      // initialize result
  const result = [];

      // while data
  while(left.length && right.length) {

         // sort and add to result
 // change to invert sorting  if(left[0].pages > right[0].pages) {
            result.push(left.shift());
         } else {
            result.push(right.shift());
         }
      }

      // concat remaining elements with result
  return result.concat(left, right);
   }
}

module.exports = Merge;

一旦我们有了这个类,我们现在可以添加一个新的端点来使用这个类:

var express = require('express');
var app = express();
var data = require('./books.json');
var Insertion = require('./sort/insertion');
var Merge = require('./sort/merge');

app.get('/', function (req, res) {
   res.status(200).send('OK!')
});

app.get('/insertion', function (req, res) {
   res.status(200).send(new Insertion().sort(data));
});

app.get('/merge', function (req, res) {
 res.status(200).send(new Merge().sort(data));
});

app.listen(3000, function () {
   console.log('Chat Application listening on port 3000!')
});

现在重新启动服务器并测试所做的更改:

Quicksort API

与 Mergesort 类似,Quicksort 也是一种分而治之的算法。在本节中,我们将创建一个端点,使用这个算法对数据集进行排序并返回。

什么是 Quicksort

Quicksort 根据预先选择的枢轴值将集合分为两个较小的低值和高值子集,然后递归地对这些较小的子集进行排序。

选择枢轴值可以通过几种方式完成,这是算法中最重要的方面。一种方法是简单地从集合中选择第一个、最后一个或中间值。然后,还有自定义的分区方案,如 Lomuto 或 Hoare(我们将在本章后面使用),可以用来实现相同的效果。我们将在本节中探讨其中一些实现。

让我们来看看这个实现的伪代码。

伪代码

根据我们迄今为止讨论的内容,quicksort 的伪代码非常明显:

QUICKSORT(Set, lo, high)

    GET pivot

    GENERATE Left, Right partitions

    QUICKSORT(SET, lo, Left - 1)

    QUICKSORT(SET, Right + 1, high)

正如您在前面的代码中所注意到的,一旦我们抽象出获取枢轴的逻辑,算法就不是很复杂。

实现 Quicksort API

首先,让我们创建 Quicksort 类,它将根据传递的集合中的第一个元素作为枢轴来对元素进行排序。让我们在sort文件夹下创建一个名为quick.js的文件:

class Quick {

   simpleSort(data) {

      // if only one element exists
  if(data.length < 2) {
         return data;
      }

      // first data point is the pivot
  const pivot = data[0];

      // initialize low and high values
  const low = [];
      const high = [];

      // compare against pivot and add to
 // low or high values  for(var i = 1; i < data.length; i++) {

         // interchange condition to reverse sorting
  if(data[i].pages > pivot.pages) {
            low.push(data[i]);
         } else {
            high.push(data[i]);
         }
      }

      // recursively sort and concat the
 // low values, pivot and high values  return this.simpleSort(low)
         .concat(pivot, this.simpleSort(high));
   }

}

module.exports = Quick;

这很直接了当,现在,让我们快速添加一个端点来访问这个算法,对我们的书进行排序并将它们返回给请求的用户:

var express = require('express');
var app = express();
var data = require('./books.json');
var Insertion = require('./sort/insertion');
var Merge = require('./sort/merge');
var Quick = require('./sort/quick');

....

app.get('/quick', function (req, res) {
 res.status(200).send(new Quick().simpleSort(data));
});

app.listen(3000, function () {
   console.log('Chat Application listening on port 3000!')
});

此外,现在重新启动服务器以访问新创建的端点。我们可以看到这里的方法并不理想,因为它需要额外的内存来包含低值和高值,与枢轴相比。

因此,我们可以使用之前讨论过的 Lomuto 或 Hoare 分区方案来在内存中执行此操作,并减少内存成本。

Lomuto 分区方案

Lomuto 分区方案与我们之前实现的简单排序函数非常相似。不同之处在于,一旦我们选择最后一个元素作为枢轴,我们需要通过在内存中对元素进行排序和交换来不断调整其位置,如下面的代码所示:

partitionLomuto(data, low, high) {

   // Take pivot as the high value
  var pivot = high;

   // initialize loop pointer variable
  var i = low;

   // loop over all values except the last (pivot)
  for(var j = low; j < high - 1; j++) {

      // if value greater than pivot
  if (data[j].pages >= data[pivot].pages) {

         // swap data
  this.swap(data, i , j);

         // increment pointer
  i++;
      }
   }

   // final swap to place pivot at correct
 // position by swapping  this.swap(data, i, j);

   // return pivot position
  return i;
}

例如,让我们考虑以下数据:

[{pages: 20}, {pages: 10}, {pages: 1}, {pages: 5}, {pages: 3}]

当我们使用这个数据集调用我们的 partition 时,我们的枢轴首先是最后一个元素3(表示pages: 3),低值为 0(所以是我们的指针),高值为 4(最后一个元素的索引)。

现在,在第一次迭代中,我们看到第j个元素的值大于枢轴,所以我们将第j个值与低当前指针位置交换;由于它们两者相同,交换时什么也不会发生,但我们会增加指针。因此,数据集保持不变:

20, 10, 1, 5, 3
pointer: 1

在下一次迭代中,同样的事情发生了:

20, 10, 1, 5, 3
pointer: 2

在第三次迭代中,值较小,所以什么也不会发生,循环继续:

20, 10, 1, 5, 3
pointer: 2

在第四次迭代中,值(5)大于枢轴值,所以值交换并且指针增加:

20, 10, 5, 1, 3
pointer: 3

现在,控制权从for循环中退出,我们最终通过最后一次交换将数据放在正确的位置,得到以下结果:

20, 10, 5, 3, 1 

之后,我们可以返回指针的位置,这只是枢轴的新位置。在这个例子中,数据在第一次迭代中就已经排序,但可能会有情况,也会有情况,其中不是这样,因此我们递归地重复这个过程,对枢轴位置左右的子集进行排序。

霍尔分区方案

另一方面,霍尔分区方案从数据集的中间获取一个枢轴值,然后开始解析从低端和高端确定枢轴的实际位置;与 Lomuto 方案相比,这会导致更少的操作次数:

partitionHoare(data, low, high) {
   // determine mid point
  var pivot = Math.floor((low + high) / 2 );

   // while both ends do not converge
  while(low <= high) {

      // increment low index until condition matches
  while(data[low].pages > data[pivot].pages) {
         low++;
      }

      // decrement high index until condition matches
  while(data[high] && (data[high].pages < data[pivot].pages)) {
         high--;
      }

      // if not converged, swap and increment/decrement indices
  if (low <= high) {
         this.swap(data, low, high);
         low++;
         high--;
      }
   }

   // return the smaller value
  return low;
}

现在,我们可以将所有这些放入我们的Quick类中,并更新我们的 API 以使用新创建的方法,如下面的代码所示:

class Quick {

   simpleSort(data) {
        ...
   }

   // sort class, default the values of high, low and sort
   sort(data, low = 0, high = data.length - 1, sort = 'hoare') {
      // get the pivot   var pivot =  (sort === 'hoare') ? this.partitionHoare(data, low,
      high)
                  : this.partitionLomuto(data, low, high);

      // sort values lesser than pivot position recursively
  if(low < pivot - 1) {
         this.sort(data, low, pivot - 1);
      }

      // sort values greater than pivot position recursively
  if(high > pivot) {
         this.sort(data, pivot, high);
      }

      // return sorted data
  return data;
   }

   // Hoare Partition Scheme
  partitionHoare(data, low, high) {
        ...
   }

   // Lomuto Partition Scheme
  partitionLomuto(data, low, high) {
        ...
   }

   // swap data at two indices
  swap(data, i, j) {
      var temp = data[i];
      data[i] = data[j];
      data[j] = temp;
   }

}

module.exports = Quick;

当我们更新 API 调用签名时,在我们的index.js文件中得到以下结果:

app.get('/quick', function (req, res) {
 res.status(200).send(new Quick().sort(data));
});

重新启动服务器并访问端点后,我们会得到以下结果:

从前面的截图中可以看出,对于给定的数据集,快速排序比归并排序稍微快一些。

性能比较

现在我们列出并实现了一些排序算法,让我们快速看一下它们的性能。在我们实现这些算法时,我们简要讨论了一些性能增强;我们将尝试量化这种性能增强。

为此,我们将首先安装名为benchmark的节点模块,以创建我们的测试套件:

npm install benchmark --save

安装了基准框架后,我们可以将我们的测试添加到项目根目录下的名为benchmark.js的文件中,该文件将运行前面部分描述的不同排序算法:

var Benchmark = require('benchmark');
var suite = new Benchmark.Suite();
var Insertion = require('./sort/insertion');
var Merge = require('./sort/merge');
var Quick = require('./sort/quick');
var data = require('./books.json');

suite
  .add('Binary Insertionsort', function(){
      new Insertion().sort(data);
   })
   .add('Mergesort', function(){
      new Merge().sort(data);
   })
   .add('Quicksort -> Simple', function(){
      new Quick().simpleSort(data);
   })
   .add('Quicksort -> Lomuto', function(){
      new Quick().sort(data, undefined, undefined, 'lomuto');
   })
   .add('Quicksort -> Hoare', function(){
      new Quick().sort(data);
   })
   .on('cycle', function(e) {
      console.log(`${e.target}`);
   })
   .on('complete', function() {
      console.log(`Fastest is ${this.filter('fastest').map('name')}`);
   })
   .run({ 'async': true });

现在,让我们更新package.json文件的脚本标签以更新和运行测试:

...

"scripts": {
  "start": "node index.js",
  "test": "node benchmark.js" },

...

要查看更改,请从项目的根目录运行npm run test命令,我们将在终端中看到类似的东西:

Binary Insertionsort x 1,366 ops/sec ±1.54% (81 runs sampled)
Mergesort x 199 ops/sec ±1.34% (78 runs sampled)
Quicksort -> Simple x 2.33 ops/sec ±7.88% (10 runs sampled)
Quicksort -> Lomuto x 2,685 ops/sec ±0.66% (86 runs sampled)
Quicksort -> Hoare x 2,932 ops/sec ±0.67% (88 runs sampled)
Fastest is Quicksort -> Hoare

总结

排序是我们经常使用的东西。了解排序算法的工作原理以及根据数据集类型如何使用这些算法是很重要的。我们对基本方法进行了一些关键的改变,以确保我们优化了我们的算法,并最终得出了一些统计数据,以了解这些算法在相互比较时的效率如何。当然,有人可能会想到是否有必要进行性能测试来检查一个算法是否比另一个更好。我们将在接下来的章节中讨论这个问题。

第八章:大 O 符号、空间和时间复杂度

在前几章中,我们经常谈到优化我们的代码/算法,并简要使用了空间和时间复杂度这些术语,以及我们希望将它们降到最低。顾名思义,我们希望将代码的复杂性保持在最低,但这意味着什么?这种复杂性有不同的级别吗?我们如何计算算法的空间和时间复杂度?这些是我们将在本章讨论的问题,同时讨论以下主题:

  • 不同程度的时间复杂度

  • 空间复杂度和辅助空间

术语

讨论算法的空间和时间复杂度时使用的术语是开发人员经常会遇到的。流行的术语,如大 O 符号,也被称为O(something),以及一些不那么流行的术语,如Omega(something)Theta(something)经常用来描述算法的复杂性。O 实际上代表 Order,表示函数的阶数。

让我们首先只讨论算法的时间复杂度。基本上,这归结为我们试图弄清楚系统在给定数据集(D)上执行我们的算法需要多长时间。我们可以在所述系统上运行此算法并记录其性能,但由于并非所有系统都相同(例如,操作系统、处理器数量和读写速度),我们不能期望结果真正代表执行我们的算法所需的平均时间。同时,我们还需要知道我们的算法在数据集 D 的大小变化时的表现。它对于 10 个元素和 1000 个元素需要相同的时间吗?还是花费的时间呈指数增长?

有了上述所有内容,我们如何清楚地理解算法的复杂性呢?我们通过将算法分解为一组基本操作,然后将它们组合起来,得到每个操作的总体数量/复杂度。这真正定义了算法的时间复杂度,即随着输入数据集 D 的大小增长而增长的时间速率。

现在,为了以抽象的方式计算时间复杂度,让我们假设我们有一台机器,它需要一个单位的时间来执行一些基本操作,比如读取、写入、赋值、算术和逻辑计算。

说到这里,让我们来看一个简单的函数,它返回给定数字的平方:

function square(num) {
    return num*num;
}

我们已经定义了我们的机器,它消耗一个单位的时间来执行乘法,另一个单位来返回结果。不考虑输入,我们的算法总是只需要 2 个单位的时间,因为这不会改变,所以被称为常数时间算法。这里所花费的常数时间是 k 个时间单位并不重要。我们可以将所有类似的函数表示为O(1)big-O(1)的一组函数,这些函数执行需要恒定的时间。

让我们再举一个例子,我们循环遍历一个大小为 n 的列表,并将每个元素乘以一个因子:

function double(array) {
    for(var i = 0; i <  array.length; i++) {
        array[i] *= 2;
    }

    return array;
}

要计算这个函数的时间复杂度,我们首先需要计算这个函数中每个语句的执行成本。

第一条语句在中断之前执行n+1次,并且每次执行时,增加 1 个单位的成本和进行比较检查等其他操作也需要 1 个单位的成本。换句话说,我们可以假设每次迭代中花费了C[1]个时间单位,因此下面这行代码的总成本是C[1](n+1)*:

for(var i = 0; i <  array.length; i++) {

在下一条语句中,我们将数组中给定索引处的值乘以 2。由于这是在循环内部,这条语句执行了 n 次,每次执行时,我们假设它花费了C[2]个单位。因此,这行代码的总执行成本将是C[2]n*:

array[i] *= 2;

然后,我们最终有返回语句,它也需要花费一个常数的时间—C[3]—来将最终的数组返回给调用者。将所有这些成本加在一起,我们得到方法的总成本如下:

Tdouble = C1*(n + 1) + C2* n + C3;
        = C5 * n + C4 // where C4 = C3 + C1 and C5 = C1 + C2

我们可以看到,在这种情况下,方法的成本与输入数组的大小N成正比。因此,这组函数可以用O(n)表示,表明它们与输入大小成正比。

然而,在我们跳到更多的例子之前,让我们先看看如何在没有所有计算的情况下表示复杂度。

渐近符号

当我们想要推导和比较两个或更多算法的时间复杂度时,渐近符号非常有用。渐近符号的意思是,一旦我们计算出一个算法的时间复杂度,我们只需要用一个非常大的数(趋向于无穷大)来替换n(我们算法的输入大小),然后去掉方程中的常数。这样做会让我们留下真正影响我们执行时间的唯一因素。

让我们拿和前面部分相同的例子:

Tdouble = C1*(n + 1) + C2* n + C3;
        = C5 * n + C4 // where C4 = C3 + C1 and C5 = C1 + C2

当我们应用刚刚描述的关于渐近符号的规则时,即n -> 无穷大,我们很快就能看到C[4]的影响相当微不足道,可以忽略不计。我们也可以说相同的事情适用于乘法因子C[5]。我们得到的是这一次,T[double]与输入数组的大小(n)成正比,因此我们能够用O(n)符号表示这一点,因为在这种情况下,大小 n 是唯一重要的变量。

有三种主要类型的渐近符号,可以用来对算法的运行时间进行分类:

  • Big-O:表示运行时间增长率的上界

  • Omega:表示运行时间增长率的下界

  • Theta:表示运行时间增长率的紧密界限

大 O 符号

假设我们有一个f(n)方法,我们想用一个时间复杂度函数(即一个集合)g(n)来表示:

当且仅当存在常数 c 和 n[0],使得f(n) <= cg(n),且输入大小n >= n[0]时,f(n)O(g(n))

现在,让我们尝试将这个应用到我们之前的例子中:

f(n) = Tdouble = C5 * n + C4 
f(n) = Tdouble = 4n + 1 // cause C5 and C4 can be any constants

对于这个例子,我们用集合O(n)表示它,也就是g(n) = n

为了使我们的时间复杂度断言成立,我们需要满足以下条件:

4n + 1 <= c * n , where n >= n0

这个方程对于c = 5n[0] = 1的值是满足的。另外,由于定义得到满足,我们可以安全地说f(n)函数是big-O(g(n)),也就是O(g(n)),或者在这种情况下是O(n)。我们也可以在图表上看到这一点,如下图所示;在n = 1之后,我们可以看到c * g(n)的值在渐近上始终大于f(n)的值。看一下下面的图表:

Omega 符号

类似于之前讨论的大 O 符号,Omega 符号表示算法运行时间的增长率的下界。因此,如果我们有一个f(n)方法,我们想用一个时间复杂度函数(即一个集合)g(n)来表示,那么 Omega 符号可以定义如下:

当且仅当存在常数 c 和 n[0],使得f(n) >= cg(n),其中输入大小n >= n[0]时,f(n)O(g(n))

采用和前面部分相同的例子,我们有f(n) = 4n + 1,然后g(n) = n。我们需要验证存在 c 和 n[0],使得前面的条件成立,如下面的片段所示:

4n + 1 >= c * n , where n >= n0 

我们可以看到这个条件对于c = 4n[0] = 0是成立的。因此,我们可以说我们的函数f(n)Ω(n)。我们也可以在图表上表示这一点,看一下它如何表示我们的函数f(n)以及它的上界和下界:

从前面的图表中,我们可以看到我们的函数f(n)(黑色)位于渐近上限和下限(灰色)之间。x轴表示大小(n)的值。

θ符号

计算了函数f(n)的增长率的上限和下限之后,我们现在也可以确定函数f(n)的紧密边界或θ。因此,如果我们有一个f(n)方法,我们想用时间复杂度函数(也称为集合)g(n)来表示,那么函数的紧密边界可以定义如下:

如果f(n)是 O(g(n)),当且仅当存在常数 c 和 n[0],使得 c[1]g(n) <= f(n) <= c[2]g(n),其中输入大小 n >= n[0]

前两节的操作已经计算了我们的函数,即f(n) = 4n + 1c[1] = 4c[2] = 5n[0] = 1

这为我们提供了函数f(n)的紧密边界,由于函数始终在n = 1之后的紧密边界内,我们可以安全地说我们的函数 f(n)具有紧密的增长率,即θ(n)

回顾

在继续下一个主题之前,让我们快速回顾一下我们讨论的不同类型的符号:

  • O表示f(n)的增长率渐近小于或等于g(n)的增长率

  • Ω表示f(n)的增长率渐近大于或等于g(n)的增长率

  • θ表示f(n)的增长率渐近等于g(n)的增长率

时间复杂度的例子

现在让我们检查一些时间复杂度计算的例子,因为在 99%的情况下,我们需要知道函数可能执行的最长时间;我们将主要分析最坏情况时间复杂度,即基于函数输入的增长率的上限。

常数时间

常数时间函数是指执行时间不受传入函数的大小的影响:

function square(num) {
    return num*num;
}

前面的代码片段是一个常数时间函数的例子,用 O(1)表示。常数时间算法是最受追捧的算法,因为它们无论输入的大小如何都在恒定时间内运行。

对数时间

对数时间函数是指执行时间与输入大小的对数成比例。考虑以下例子:

for(var i = 1; i < N; i *= 2) {
    // O(1) operations
}

我们可以看到,在任何给定的迭代中,i = 2^i,因此在第n次迭代中,i = 2^n。此外,我们知道i的值始终小于循环本身的大小(N)。由此,我们可以推断出以下结果:

2n < N

log(2n) < log(N)

n < log(N) 

从前面的代码中,我们可以看到迭代次数始终小于输入大小的对数。因此,这样的算法的最坏情况时间复杂度将是O(log(n))

让我们考虑另一个例子,下一次迭代将i的值减半:

for(var i = N; i >= 1; i /= 2) {
    // O(1) operations
}

在第n次迭代中,i的值将为N/2^n,我们知道循环以值1结束。因此,为了使循环停止,i的值需要<= 1;现在,通过结合这两个条件,我们得到以下结果:

N/2n <= 1

N <= 2n

Log(N) <= n

我们可以得出与第一个例子类似的结论,即迭代次数始终小于输入大小或值的对数值。

需要注意的一点是,这不仅限于加倍或减半现象。这可以应用于任何算法,其中步骤的数量被因子k减少。这类算法的最坏情况时间复杂度将是O(logk),在我们的前面的例子中,k恰好是2

对数时间复杂度算法是下一个受欢迎的,因为它们以对数方式消耗时间。即使输入的大小翻倍,算法的运行时间也只会增加一个小的数(这是对数的定义)。

线性时间

现在让我们讨论最常见的时间复杂度之一,线性时间。可以猜到,方法的线性时间复杂度表示该方法执行需要线性时间:

for(var i = 0; i < N; i += c) {
    // O(1) operations
}

这是一个非常基本的for循环,我们在其中执行一些常数时间的操作。随着 N 的大小增加,循环执行的次数也会增加。

正如你所看到的,在每次迭代中,i的值都会增加一个常数c,而不是1。这是因为增量是什么并不重要,只要它们是线性的。

在第一次迭代中,i = 0;在第二次迭代中,i = c,然后在第三次迭代中是c + c = 2c,在第四次迭代中是3c,依此类推。因此,在第 n 次迭代中,我们有i = c(n-1)的值,渐近地是O(n)

根据你的用例是什么,线性时间复杂度可能是好的,也可能不是。这有点是灰色地带,如果你不确定是否需要进一步优化,有时可能会放弃。

二次时间

随着二次时间复杂度算法,我们现在进入了时间复杂度的黑暗面。顾名思义,输入的大小会二次影响算法的运行时间。一个常见的例子是嵌套循环:

for (int i = 0; i <n; i += c) {
    for (int j = 0; j < n; j += c) {
        // some O(1) expressions
    }
}

正如前面的例子所示,对于i = 0,内部循环运行n次,对于i = 1i = 2,依此类推。内部循环总是运行 n 次,不依赖于 n 的值,因此使得算法的时间复杂度为O(n²)

多项式时间

多项式时间复杂度是算法的运行时间复杂度,其顺序为n^k。二次时间复杂度算法是多项式时间算法的某种类型,其中k = 2。这样的算法的一个非常简单的例子如下:

for (int i = 0; i <n; i += c) {
    for (int j = 0; j < n; j += c) {
        for (int k = 0; k < n; k += c) {
            // some O(1) expressions
        }
    }
}

正如你所看到的,这个例子只是二次时间部分例子的延伸。这种情况的最坏时间复杂度是O(n³)

多项式时间复杂度类

现在我们已经开始了这个对话,到目前为止我们讨论的大部分时间复杂度类型都是O(n^k)类型的,例如,对于n = 1,它是常数时间复杂度,而对于k = 2,它是二次复杂度。

多项式时间复杂度的概念引导我们进入了一类问题,这些问题是根据其解决方案的复杂性定义的。以下是类别的类型:

  • P:任何可以在多项式时间O(n^k)内解决的问题。

  • NP:任何可以在多项式时间内验证的问题。可以存在可以在非确定性多项式时间内解决的问题(例如数独求解)。如果这些问题的解决方案可以在多项式时间内验证,那么问题被分类为 NP 类问题。NP 类问题是 P 类问题的超集。

  • NP-Complete:任何可以在多项式时间内减少为另一个 NP 问题的 NP 问题可以被分类为 NP-Complete 问题。这意味着如果我们知道某个NP问题的解决方案,那么可以在多项式时间内推导出另一个 NP 问题的解决方案。

  • NP-Hard:如果存在一个可以在多项式时间内减少为NP-Complete问题的NP-Complete问题,那么问题可以被分类为 NP-Hard 问题(H)。

在大多数现实场景中,我们会遇到很多 P 和 NP 问题,NP 类问题的一个经典例子是旅行推销员问题,其中推销员想要访问n个城市,从他的家出发并结束他的旅行。在汽油有限和总里程数有上限的情况下,推销员能否访问所有城市而不用完汽油?

递归和加法复杂度

到目前为止,我们已经看到一些相当简单的例子:它们都只有一个循环或嵌套循环。然而,很多时候,会有一些情况需要处理多个循环/函数调用/分支,让我们看一个这种情况下如何计算复杂度的例子?

  1. 当我们有连续的循环/函数调用时,我们需要计算每个步骤的个体复杂度,然后将它们相加以获得总体复杂度,如下所示:
            function xyz() {

                abc(); // O(n) operation

                pqr(); // O(log(n)) operation

            }

这段代码的综合复杂度将是两个部分复杂度的总和。因此,在这种情况下,总体复杂度将是O(n + log n),渐近地将是O(n)

  1. 当我们的函数中有不同时间复杂度的分支时,根据我们所谈论的运行时复杂度的类型,我们需要选择正确的选择:
        function xyz() {

            if (someCondition) {

                abc(); // O(n) operation

            } else {

                pqr(); // O(log(n)) operation

            }

        }

在这种情况下,最坏情况的复杂度将由两个分支中较差的那个决定,即O(n),但最佳情况的复杂度将是O(log(n))

  1. 递归算法与非递归算法相比有点棘手,因为我们不仅需要确定算法的复杂度,还需要记住递归会触发多少次,因为这将对算法的总体复杂度产生影响,如下面的代码片段所示:
        function rec1(array) {
            // O(1) operations

            if (array.length === 0) return;

            array.pop();

            return rec1(array);
        }

虽然我们的方法只执行一些O(1)的操作,但它不断改变输入并调用自身,直到输入数组的大小为零。因此,我们的方法最终执行了 n 次,使得总体时间复杂度为O(n)

空间复杂度和辅助空间

空间复杂度和辅助空间是在谈论某个算法的空间复杂度时经常混淆和交替使用的术语之一:

  • 辅助空间:算法暂时占用的额外空间以完成其工作

  • 空间复杂度:空间复杂度是算法相对于输入大小所占用的总空间加上算法使用的辅助空间。

当我们尝试比较两个算法时,通常会有类似类型的输入,也就是说,输入的大小可以忽略不计,因此我们最终比较的是算法的辅助空间。使用这两个术语没有太大问题,只要我们理解两者之间的区别并正确使用它们。

如果我们使用低级语言如 C,那么我们可以根据数据类型来分解所需/消耗的内存,例如,用 2 个字节来存储整数,4 个字节来存储浮点数等。然而,由于我们使用的是 JavaScript 这种高级语言,情况就不那么简单了,因为我们没有明确区分不同的数据类型。

空间复杂度的例子

在谈论算法的空间复杂度时,我们有类似于时间复杂度的类型,如常量空间S(1)和线性空间S(N)。让我们在下一节中看一些例子。

常量空间

常量空间算法是指算法消耗的空间不会因输入的大小或算法的输入参数而改变。

在这一点上,我想重申一下,当我们谈论算法的空间复杂度时,我们谈论的是算法消耗的辅助空间。这意味着即使我们的数组大小为n,我们的算法消耗的辅助(或额外)空间将保持不变,如下面的代码片段所示:

function firstElement(arr) {
    return arr[0];
}

我们可以看到firstElement方法不再占用任何空间,无论输入是什么。因此,我们可以将其表示为空间复杂度S(1)

线性空间

线性空间算法是指算法占用的空间量与输入大小成正比的算法,例如,在返回值之前循环遍历数组并将值推送到新数组的算法:

function redundant(array) {
    var result = [];

    for(var i = 0, i < array.size; i++) {
        result.push(array[i]);
    }

    return result;
}

如你所见,尽管冗余,我们正在创建一个新数组,并将所有值推送到该数组中,这将占用与输入数组相同的空间。考虑在push之前有一个条件的情况,如下面的代码所示:

function notRedundant(array) {
    var result = [];

    for(var i = 0, i < array.size; i++) {
        if (someCondition) {
            result.push(array[i]);
        }
    }

    return result;
}

在最坏的情况下,someCondition 标志始终为真,并且我们最终得到的结果与输入的大小相同。因此,我们可以断言前面方法的空间复杂度为 S(n)

总结

在本章中,我们只是浅尝计算复杂性这个庞然大物。计算复杂性比我们在本章讨论的要多得多。然而,本章讨论的主题和示例是我们大多数人在日常工作中面对的。空间复杂性还有更高级的主题,比如 LSPACE,它是一类可以在对数空间中解决的问题,以及 NLSPACE,它是使用非确定性图灵机的空间量。本章的主要目标是确保我们理解算法的复杂度是如何计算的,以及它如何影响整体输出。在下一章中,我们将讨论我们可以对应用程序进行哪些微观优化,并了解浏览器(主要是 Chrome)的内部工作原理以及我们如何利用它们来改进我们的应用程序。

第九章:微优化和内存管理

在本章中,我们将介绍 HTML、CSS、JavaScript 和我们期望所有这些内容在其中运行的浏览器的一些基本概念。我们一直以来都以某种风格编码,这是自然的。然而,我们是如何形成这种风格的?它是好的还是可以变得更好?我们如何决定我们应该和不应该要求其他人遵循什么?这些是我们将在本章中尝试回答的一些问题。

在本章中,我们将讨论以下内容:

  • 最佳实践的重要性,以及一些示例。

  • 探索不同类型的 HTML、CSS 和 JavaScript 优化

  • 深入了解 Chrome 一些功能的内部工作。

最佳实践

出于明显的原因,最佳实践是一个相对的术语。什么被认为是最佳的,更多取决于你所在的团队以及你使用的 JavaScript 版本。在本节中,我们将尝试广泛涵盖一些最佳实践,并了解一些实践看起来是什么样子,以便我们也可以适应并使用它们。

HTML 的最佳实践

让我们从上到下来处理 HTML 文件中每个部分的最佳实践。

声明正确的 DOCTYPE

你是否曾经想过为什么我们在页面顶部有<!DOCTYPE html>?我们显然可以不写它,页面似乎仍然可以工作。那么,我们为什么需要这个?答案是避免向后兼容性——如果我们不指定 DOCTYPE,解释和呈现我们的 HTML 的浏览器将进入怪癖模式,这是一种支持使用过时版本和标记的 HTML、CSS 和 JS 构建的非常旧的网站的技术。怪癖模式模拟了旧版本浏览器中存在的许多错误,我们不想处理这些错误。

向页面添加正确的元信息

任何网页在呈现时都需要一些元信息。虽然这些信息不会在页面上呈现,但对于正确呈现页面至关重要。以下是一些添加元信息的良好实践:

  • html标签中添加正确的lang属性,以符合 w3c 的国际化标准:
<html lang="en-US">
  • 声明正确的charset以支持网页上的特殊字符:
<meta charset="UTF-8">
  • 添加正确的titledescription标签以支持搜索引擎优化:
<title>This is the page title</title>

<meta name="description" content="This is an example description.">
  • 添加适当的base URL 以避免在各处提供绝对 URL:
<base href="http://www.mywebsite.com" />
...
...
<img src="/cats.png" /> // relative to base 

删除不必要的属性

这可能看起来很明显,但仍然被广泛使用。当我们添加一个link标签来下载样式表时,我们的浏览器已经知道它是一个样式表。没有理由指定该链接的类型:

<link rel="stylesheet" href="somestyles.css" type="text/css" />

使您的应用程序适用于移动设备

你是否曾经见过那些在桌面和移动设备上看起来完全相同的网站,并想知道为什么他们要这样构建?在新时代的网页开发中,为什么有人不利用最新的 HTML 和 CSS 版本提供的响应性?这可能发生在任何人身上;我们已经定义了所有正确的断点,并且按预期使用媒体查询,但什么都没有发生。这通常是因为我们忘记了包括viewportmeta标签;包括viewportmeta标签可以解决我们所有的问题:

<meta name="viewport" content="width=device-width, initial-scale=1">

“视口”基本上是用户可见区域的总和,在移动设备上较小,在桌面上较大;meta标签定义了浏览器根据“视口”的大小来呈现网站的方式。

在中加载样式表

这是一个偏好和选择的问题。我们可以在页面加载的末尾加载样式表吗?当然可以,但我们希望避免这样做,以便我们的用户在捕捉到正确的样式之前不会看到未经样式化的页面闪烁。当浏览器提供 CSS 和 HTML 时,它们创建一个CSS 对象模型CSSOM)和文档对象模型DOM)。在构建 DOM 时,浏览器查找 CSSOM,以检查是否有任何与 DOM 节点对应的样式。因此,我们希望确保 CSSOM 已经构建并准备好供 DOM 渲染。

一个替代方法是首先在页面的头部标签中只加载基本样式,其余的样式可以在 body 的末尾请求。这意味着我们的页面可以渲染得更快一些,但值得注意的是,这有时可能不值得,这取决于您的应用程序大小和用例。

避免内联样式

通过在 HTML 文件中直接提供内联样式来使用它们是不好的,原因有很多:

  • 我们无法重用应用于一个元素的样式

  • 我们的 HTML 充斥着 CSS,变得非常嘈杂

  • 我们无法利用伪元素,比如beforeafter

使用语义标记

有了 HTML5,我们不再需要担心为所有内容使用<div>标签。我们得到了一组更强大的语义标签,这些标签帮助我们以更有意义的方式构建我们的模板:

值得注意的是,这些新标签只为我们的模板提供了含义,而没有样式。如果我们希望它看起来某种方式,我们需要根据我们希望它们看起来的样子来设计元素。此外,新的 HTML5 标签在 IE9 之前的浏览器中不可用,因此我们需要准备一些备用方案,如 HTML5shiv。

使用可访问的丰富互联网应用程序(ARIA)属性

每当我们开发一个网络应用程序时,我们都需要确保我们的应用程序与屏幕阅读器兼容,以支持残障用户:

<div id="elem" aria-live="assertive" role="alert" aria-hidden="false"> An error occurred </div>

这些信息不会与屏幕上的任何现有信息发生冲突,并且使屏幕阅读器能够捕捉和处理这些信息。当然,只有在 HTML 渲染器支持 ARIA 时,所有这些才是可能的,这在所有最新的浏览器中都是可用的。

在末尾加载脚本

任何应用程序的核心都存在于开发人员定义的 JavaScript 文件中。因此,当我们尝试加载和执行这些文件时,我们需要格外注意,因为它们的大小可能比它们的 HTML 和 CSS 文件的大小要大得多。当我们尝试使用脚本标签加载外部 JS 文件时,浏览器首先下载然后执行它们(在解析和编译之后)。我们需要确保我们的应用程序在正确的时间加载和执行。对我们来说,这意味着如果我们的应用逻辑依赖于 DOM,我们需要确保 DOM 在脚本执行之前被渲染。这就是为什么我们需要在应用程序的 body 标签末尾加载脚本的一个很好的理由。

即使我们的 JavaScript 不依赖于 DOM,我们仍然希望在末尾加载我们的脚本,因为脚本标签默认是渲染阻塞的,也就是说,如果您的浏览器在头部(例如)遇到您的脚本标签,它开始下载和执行 JS 文件,并且在执行完成之前不渲染页面的其余部分。此外,如果我们有太多的 JS 文件,那么页面似乎已经挂起,并且在所有 JS 文件都已成功下载和执行之前,不会完全渲染 UI 给我们的最终用户。

如果您仍然希望添加脚本标签以及链接标签以下载样式表,则有一个解决方法。您可以向脚本标签添加deferasync属性。Defer允许您在 DOM 渲染时并行下载文件,并在渲染完成后执行脚本。async在 DOM 渲染时并行下载文件,并在执行时暂停渲染,然后在执行后恢复。明智地使用它们。

CSS 最佳实践

CSS 最佳实践的列表不像 HTML 那么长。此外,通过使用预处理语言,如Sassy CSSSCSS),许多潜在问题可以得到显著缓解。假设由于某种原因您不能使用 SCSS,并讨论纯粹的 CSS 的优缺点。

避免内联样式

这足够重要,以至于成为 HTML 和 CSS 最佳实践的一部分。不要应用内联样式。

不要使用!important

说起来容易,做起来难。使用!important是使样式应用于元素的最简单的解决方法之一。然而,这也有其代价。CSS 或层叠样式表依赖于样式根据应用程序的优先级(ID、类和元素标签)或它们出现的顺序进行级联。使用!important会破坏这一点,如果您有多个 CSS 文件,那么纠正它将变得非常混乱。最好避免这样的做法,从一开始就用正确的方法做。

在类中按字母顺序排列样式

这听起来不像什么大不了的事,对吧?如果您只有一个带有几个类的 CSS 文件,那也许还可以。但是,当您有一个包含复杂层次结构的大文件时,您最不希望的是犯一个小错误,这会花费您大量的时间。看看以下示例:

.my-class {
    background-image: url('some-image.jpg');
 background-position: 0 100px;
 background-repeat: no-repeat;
    height: 500px;
    width: 500px;
    ...
    ...
    margin: 20px;
    padding: 10px;
 background: red;
}

请注意,在上述代码中,我们为元素的背景属性添加了冲突的样式,现在在渲染时,它全部是红色的。这本来很容易被发现,但由于类内属性的顺序,它被忽略了。

按升序定义媒体查询

定义媒体查询是另一个随着应用程序规模增长而变得混乱的领域。在定义媒体查询时,始终按递增顺序定义它们,以便您可以隔离您的样式并留下一个开放的上限,如下所示:

...

Mobile specific styles

...  // if screen size is greater than a small mobile phone
@media only screen and (min-width : 320px)  { // overrides which apply }   // if screen size is greater than a small mobile phone in portrait mode // or if screen size is that of a tablet @media only screen and (min-width : 480px)  { // overrides that apply }  // if screen size is greater than a tablet  @media only screen and (min-width : 768px)  { 
    // overrides that apply }   // large screens @media only screen and (min-width : 992px)  { ... }   // extra large screens and everything above it @media only screen and (min-width : 1200px)  { ... }

请注意,在上述代码中,我们将最后一个媒体查询留给了适用于所有屏幕尺寸为1200px及以上的情况,这将涵盖显示器、电视等。如果我们按照屏幕尺寸的最大宽度设置样式,那么这样做就不会奏效。如果我们在投影仪上打开它会发生什么?它肯定不会像您希望的那样工作。

JavaScript 最佳实践

这个话题没有开始和结束。关于 JavaScript 应该如何完成任务,有很多不同的观点,结果是大多数都是正确的(取决于您的背景、经验和用例)。让我们来看看一些关于 JavaScript(ES5)最常讨论的最佳实践。

避免污染全局范围

不要向全局范围添加属性或方法。这将使您的窗口对象膨胀,并使您的页面变得缓慢和不稳定。相反,总是在方法内创建一个变量,在方法被销毁时会被处理。

使用'use strict'

这是一个一行的改变,当涉及捕捉代码异味和任何代码不规则性时,可以走很长的路,比如删除一个变量。use strict子句在运行时执行非法操作时会抛出错误,因此它并不一定防止我们的应用程序崩溃,但我们可以在部署之前捕捉并修复问题。

严格检查(== vs ===)

当涉及到类型转换时,JavaScript 可能是一门相当棘手的语言。没有数据类型使得这一过程变得更加复杂。使用会强制进行隐式类型转换,而=则不会。因此,建议始终使用=,除非你想让 12 12 成立。

要了解它为什么会这样工作的更多细节,请参考抽象相等比较算法,网址为www.ecma-international.org/ecma-262/5.1/#sec-11.9.3

使用三元运算符和布尔||或&&

建议始终保持代码可读,但在必要时,使用三元运算符使代码简洁易读:

if(cond1) {
    var1 = val1;
} else {
    var1 = val2
}

if(cond2) {
    var2 = val3;
} else {
    var2 = val4
}

例如,上述代码可以简化如下:

var1 = cond1 ? val1 : val2;
var2 = cond2 ? val3 : val4;

设置默认值也可以轻松实现如下:

var1 = ifThisVarIsFalsy || setThisValue;
var2 = ifThisVarIsTruthy && setThisValue;

代码的模块化

当我们创建一个脚本时,很明显我们希望它能做多种事情,例如,如果我们有一个登录页面,登录页面的脚本应该处理登录(显然),重置密码和注册。所有这些操作都需要电子邮件验证。将验证作为每个操作的一部分放入自己的方法中被称为模块化。它帮助我们保持方法小,可读,并且使单元测试变得更容易。

避免金字塔式的厄运

金字塔式的厄运是一个经典场景,我们有大量的嵌套或分支。这使得代码过于复杂,单元测试变得非常复杂:

promise1()
    .then((resp) => {
        promise2(resp)
            .then((resp2) => {
                promise3(resp2)
                    .then((resp3) => {
                        if(resp3.something) {
                            // do something
                        } else {
                            // do something else
                        }
                    });
            });
    });

而不是,做以下事情:

promise1()
    .then((resp) => {
        return promise2(resp);
    })
   .then((resp2) => {
        return promise3(resp2);
    })                
    .then((resp3) => {
        if(resp3.something) {
            // do something
        } else {
            // do something else
        }
    })

尽量减少 DOM 访问

DOM 访问是一个昂贵的操作,我们需要尽量减少它,以避免页面崩溃。尝试在访问 DOM 元素后将它们缓存到一些本地变量中,或者利用虚拟 DOM,它更有效,因为它批处理所有 DOM 更改并一起分派它们。

验证所有数据

注册新用户?确保所有输入的字段在 UI 和后端都经过验证。在两个地方都这样做会使它变得两倍好,UI 上的验证帮助用户更快地获得错误消息,而不是服务器端验证。

不要重复造轮子

当涉及到开源软件和项目时,JavaScript 社区非常慷慨。利用它们;不要重写已经在其他地方可用的东西。重写一些经过社区测试的免费可用软件不值得时间和精力。如果一个软件只满足你需求的 90%,考虑为开源项目贡献剩下的 10%功能。

HTML 优化

作为网页开发者,我们对创建模板非常熟悉。在这一部分,我们将探讨如何尽可能地提高这个过程的效率。

DOM 结构

显而易见的是,DOM 结构在渲染 UI 时会产生很大的差异。要使 HTML 模板成为 DOM,需要经历一系列步骤:

  1. 模板解析:解析器读取 HTML 文件

  2. 标记化:解析器识别标记,比如htmlbody

  3. 词法分析:解析器将标记转换为标签,比如<html><body>

  4. DOM 构建:这是最后一步,浏览器将标记转换为树,同时应用适用的样式和规则给元素

考虑到这一点,重要的是我们不要不必要地嵌套我们的元素。尽量对元素应用样式,而不是将它们嵌套在其他元素中。话虽如此,人们可能会想,这到底有多重要?浏览器在这方面做得相当不错,所以如果我的 DOM 中有一个额外的元素,真的会有多大关系吗?事实上,不会,如果你的 DOM 中有一个额外的元素并不会有关系。然而,想想所有不同的浏览器。还有,你添加这个额外元素的地方有多少;考虑这样一个做法会设定什么样的先例。随着时间的推移,你的开销会开始变得重要起来。

预取和预加载资源

<link>标签的一些较少为人知的属性是rel=prefetchrel=preload选项。它们允许浏览器预加载一些在随后或者有时甚至是当前页面中需要的内容。

让我们讨论一个非常简单的例子来理解预取:加载图像。加载图像是网页执行的最常见操作之一。我们决定加载哪个图像,可以使用 HTML 模板中的img标签或 CSS 中的background-image属性。

无论如何,直到元素被解析,图像都不会被加载。另外,假设你的图像非常大,需要很长时间才能下载,那么你将不得不依赖于一堆备用方案,比如提供图像尺寸,以便页面不会闪烁,或者在下载失败时使用alt属性。

一种可能的解决方案是预取将来需要的资源。这样,你可以避免在用户登陆到该页面之前下载资源。一个简单的例子如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <!-- a very large image -->
  <link rel="prefetch" href="http://gfsnt.no/oen/foto/Haegefjell_Jan_2013_Large.jpg">
</head>

<body>
    <script>
        window.onload = function() {
            setTimeout(function() {
                var x = document.createElement("IMG");
                x.setAttribute("src",
             "http://gfsnt.no/oen/foto/Haegefjell_Jan_2013_Large.jpg");
                document.body.appendChild(x);
            }, 5000);
        }
    </script>
</body>
</html>

我们有意延迟了img标签的加载,直到预取完成。理想情况下,你会预取下一页所需的资源,但这样也能达到同样的效果。

一旦我们运行这个页面,我们可以看到对图像的请求如下:

这听起来太好了,对吧?是的,尽管这个功能很有用,但在处理跨多个浏览器的预取时会遇到问题。Firefox 只在空闲时预取;一些浏览器可以在用户触发其他操作后暂停下载,然后在浏览器再次空闲时重新下载剩余的图像,但这取决于服务器如何提供可缓存内容(即服务器需要支持提供多部分文件)。然后,有些浏览器可以且会放弃预取,因为网络太慢。

预加载与预取非常相似,不同之处在于一旦资源下载被触发,浏览器就没有放弃下载的选择。

语法也非常相似,只是我们定义了我们试图预加载的资源的类型:

<link rel="preload" href="http://gfsnt.no/oen/foto/Haegefjell_Jan_2013_Large.jpg" as="image">

预取和预加载在下载字体和字体系列时也是一个非常常见的选择,因为加载字体的请求直到 CSSOM 和 DOM 都准备好才会被触发。

HTML 的布局和分层

为 UI 渲染元素设计 HTML 模板是作为 Web 开发人员最简单的任务之一。在本节中,我们将讨论 Chrome 如何处理模板并将其渲染到 UI 上。HTML 模板有两个关键部分,布局和层,我们将看一些例子,以及它们如何影响页面性能。

HTML 布局

让我们从一个非常简单的网页开始,看看 Chrome 如何处理渲染这个页面:

<!DOCTYPE html>
<html>
    <head></head>

    <body>
        <div>test</div>
    </body>
</html>

一旦我们加载页面,我们将使用 Chrome开发者工具DevTools)生成这个模板加载的性能快照。要这样做,导航到 Chrome 浏览器上的 CDT(设置->更多工具->开发者工具)。

一旦我们到达那里,让我们通过点击打开面板左上角的记录按钮来记录一个新的快照。一旦你的页面加载完成,停止录制,让快照在面板中加载。结果如下:

难以理解,对吧?好吧,让我们把它分解成我们可以理解的小块。我们的主要关注点将是main部分(在截图中展开)。让我们放大一下,看看从左到右的事件是什么。

首先,我们将看到 beforeunload 事件:

接下来,我们将看到更新图层树(我们稍后会讨论):

现在我们注意到一个 Minor GC,这是一个特定于浏览器的事件(我们将在后面的部分讨论这个):

然后,我们将注意DOMContentLoaded事件,然后是Recalculate Style事件,这是当我们的页面准备好进行交互时发生的事件:

很酷,对吧?这与我们之前听说的浏览器完全一致。它们加载页面,然后在一切准备就绪时触发DOMContentLoaded。然而,请注意,还有另一个被触发的事件叫做 Minor GC。我们可以忽略这个,因为它是由浏览器内部处理的,与我们的代码结构几乎没有关系。

一旦 DOM 加载完成,我们注意到另一个被触发的事件叫做Recalculate Style,这正是它听起来的样子。DOM 已经准备好了,浏览器会检查并应用需要应用到这个元素的所有样式。然而,你可能会想,我们没有向我们的模板添加任何样式,对吧?那么,我们在谈论什么样式呢?默认情况下,所有浏览器都会向它们渲染的所有元素应用样式,这些被称为用户代理样式表。浏览器仍然需要将用户代理样式表样式添加到 CSSOM 中。

除了它是浏览器将安排元素的几何结构之外,我们还没有真正讨论Layout是什么,包括但不限于它们在页面上的大小、形状和位置。Layout也是一个事件,将被 CDT 记录下来,以显示浏览器在尝试重新排列布局时花费了多长时间。我们尽量将布局事件保持在最小范围内非常重要。为什么?因为Layout不是一个孤立的事件。它是由一系列其他事件(例如更新图层树和绘制 UI)链接在一起的,这些事件需要完成 UI 上元素的排列。

另一个重要的事情要考虑的是,Layout事件会为页面上受影响的所有元素触发,也就是说,即使一个深度嵌套的元素被改变,你的整个元素(或者根据改变而改变的周围元素)都会被重新布局。让我们看一个例子:

<!DOCTYPE html>
<html>
    <head>

        <style>
            .parent {
                border: 1px solid black;
                padding: 10px;
            }

            .child {
                height: 20px;
                border: 1px solid red;
                padding: 5px;
            }
        </style>

    </head>

    <body>
        <div class="parent">
            <div class="child">
                child 1
            </div>
            <div class="child">
                child 2
            </div>
            <div class="child">
                child 3
            </div>
            <div class="child">
                child 4
            </div>
        </div>

        <button onclick="updateHeight();">update height</button>

        <script>
            function updateHeight() {
                var allEl = document.getElementsByTagName('div');
                var allElemLength = allEl.length;

                for(var i = 0; i < allElemLength; i++) {
                    allEl[i].style.height = '100px';
                }

            }
        </script>
    </body>
</html>

这很简单;我们有一个包含四个子元素的非常小的父元素的页面。我们有一个按钮,它将所有元素的高度设置为100px。现在让我们运行这个页面,并跟踪当我们点击按钮update height来改变元素的高度时的性能,我们在 UI 上看到以下内容:

我们可以从前面的截图中看到,一旦点击事件开始,它触发了我们的函数,然后触发了一系列事件,包括Layout,用时 0.23 毫秒。然而,你可能会想,为什么在FunctionLayout之间有一个Recalculate Style事件?还记得我们的老朋友用户代理样式表吗?它在按钮激活时设置了一些样式,这触发了Recalculate Style事件。

如果您想要删除元素的所有样式(例如在前面描述的按钮中),您可以通过将all:unset属性应用于您选择的元素来这样做。这将完全取消元素的样式。但是,它将减少Recalculate Style事件的时间,使其成为应用用户代理样式的一小部分。

现在让我们将 JavaScript 函数更改为仅更改页面上的第一个子元素的样式,而不是所有元素,并看看这如何影响我们的情况下Layout事件的执行:

function updateHeight() {
 var allEl = document.getElementsByTagName('div');    
 allEl[1].style.height = '100px';  }

现在,当我们运行页面并分析点击方法的执行时,我们将在分析器中看到以下内容:

正如您在前面的屏幕截图中看到的,整个页面的布局仍然需要 0.21 毫秒,这与我们先前的值并没有太大不同。在我们先前的示例中,我们有五个更多的元素。但是,在生产应用程序中,这可能会扩展到数千个元素,并且为了平稳过渡,我们希望保持我们的Layout事件在 16 毫秒以下(60fps)。

很可能,您可能永远不会遇到这个问题,但如果您遇到了,处理它的最简单方法是首先检查您的浏览器是否支持最新的布局模型。在大多数浏览器中,它将是 flexbox 或 grid,因此最好选择它而不是浮动、百分比或定位。

HTML 图层

正如我们在前面的示例中所看到的,一旦元素重新布局,我们就会Paint元素,也就是说,用颜色填充像素,这应该是元素在给定位置的一部分(由Layout确定)。

一旦Paint事件完成,浏览器就会执行Composition,基本上是我们的浏览器将页面的所有部分放在一起。部分越少,页面加载速度就越快。此外,如果Composition的某个部分花费太长时间,那么整个页面加载就会延迟。

我们如何处理这些花费太长时间的操作?我们可以通过将它们提升到它们自己的图层来处理。有一些 CSS 操作,我们可以对元素执行,这将使它们提升到它们自己的图层。这对我们意味着什么?这些提升的元素现在将被延迟并在 GPU 上作为纹理执行。我们不再需要担心我们的浏触发这些提升元素的LayoutPaint事件,我们只关心元素的Composition

从前面的示例中,到目前为止,我们已经确定了任何更改流程的前四个步骤如下:

  1. JavaScript 文件被执行

  2. 样式重新计算

  3. Layout事件

  4. Paint事件

现在,我们可以将以下步骤添加到列表中,以完全在 UI 上呈现元素:

  1. Composition

  2. 多线程光栅化

步骤 6仅仅是将我们的像素渲染到 UI 上,可以批处理并在并行线程上运行。让我们创建一个简单的 HTML 并看看它如何渲染到 UI 上的单个图层:

<!DOCTYPE html>
<html>
<head>

</head>

<body>
    <div>
        Default Layer
    </div>
</body>
</html>

我们可以通过导航到“设置”选项,然后选择“更多工具”和“图层”来从 DevTool 中访问图层。在加载先前显示的页面时,我们将在图层中看到以下内容:

当我们对前面的页面进行分析时,我们可以看到,如预期的那样,页面在Main线程上加载和呈现 UI:

现在让我们将此示例更改为加载到自己的图层上,以便我们可以完全跳过LayoutPaint部分。要将元素加载到自己的图层上,我们只需要给它一个 CSS 变换或将will-change属性设置为 transform:

.class-name {
    will-change: transform:
    // OR
    transform: translateZ(0); <- does nothing except loading to a new Layer
}

以下是一个更新后的示例模板,它使用 CSS3transform属性:

<!DOCTYPE html>
<html>
<head>
    <style>
        div {
            width: 100px;
            height: 100px;
            margin: 200px;
            border: 1px solid black;
            animation: spin 1s infinite;
            transition: all 0.35s ease;
        }

        @keyframes spin {
            from {
                transform: rotate(0deg);
            }

            to {
                transform: rotate(360deg);
            }
        }
    </style>
</head>

<body>
    <div></div>
</body>
</html>

在前面的代码中,我们添加了一个非常小的动画,它将无限旋转元素。当我们重新加载页面时,我们可以看到它已被添加到自己的图层中:

不仅如此,当我们记录修改模板的性能时,我们会看到一些非常有趣的东西:

正如我们在前面的截图中看到的,浏览器完全将“层”推迟到 GPU 作为新的纹理,从那时起,GPU 处理元素的渲染/更新,而不是浏览器。

好吧,这是否意味着我们将每个元素加载到自己的“层”上,然后让 GPU 接管?当然不是,因为每个“层”在内部都需要内存,并且将成千上万的元素加载到每个“层”上将是适得其反的。例如,我们有意将元素提升到自己的“层”的唯一时间是当元素在“合成”期间花费太长时间,并且正在阻碍操作,例如滚动或滑动时。另一个用例可能是当您有一个单一元素执行多个更改时,例如动画高度、宽度和背景颜色。这将不断调用渲染过程的所有步骤(从“布局”到光栅化),如果我们知道它仅限于这些少量更改,那么我们实际上不需要做所有这些。我们可以简单地将此元素提升到自己的层并完成。

CSS 优化

如果您有使用任何预处理器框架(如 SCSS/LESS)的开发经验,那么 CSS 优化非常容易并且显而易见。当我们讨论 CSS 优化时,我们实际上在谈论两个不同但又相关的事情:

  • 加载样式表

  • 渲染和应用样式

编码实践

有许多编码实践可以适应和学习,以使我们的应用程序表现更好。其中大多数可能看起来微不足道,但当扩展到大型应用程序时确实很重要。我们将用示例讨论其中一些技术。

对常见的 ENUM 使用较小的值

由于我们正在讨论减少页面加载时间,因此一种快速的方法是通过删除 CSS 文件本身中的冗余来实现:

  • 使用#FFFFFF?改用#FFF,这是相同的 RGB 值,用简短表示。

  • 如果值为0,则不要在属性值后添加px

  • 如果尚未使用,请使用缩小。这会将所有正在使用的 CSS 文件连接起来,并删除所有空格和换行符。

  • 在通过网络传输时使用 GZip 压缩已经被缩小的文件。这很容易,浏览器非常擅长高效地解压文件。

  • 注意手头的特定于浏览器的优化。例如,在 Chrome 的情况下,我们不必以rgba(x,y,z,a)格式应用样式。我们可以在开发过程中应用它为rgba,并使用 DevTool 提取相应的 HEX 值。简单地检查相关元素,同时按下Shift点击小矩形:

使用简写属性

使用简写属性是加快页面加载速度的一种方法。尽管听起来很明显,但有时候当我们在舒适的笔记本电脑上工作时,我们会认为浏览器和网络是理所当然的,而忘记考虑那些使用 3G 设备的用户。因此,下次您想要为元素设置背景或边框样式时,请确保它们都被折叠并使用简写方式编写。

有时,您可能会遇到这样的情况,您只想覆盖某个元素样式的一个属性。例如,如果您想在元素的三个边上应用边框,请使用以下方法:

.okay {
    border-left: 1px solid black;
    border-right: 1px solid black;
    border-bottom: 1px solid black;
} // 114 characters including spaces

.better {
    border: 1px solid black;
    border-top: 0;
} // 59 characters including spaces

避免复杂的 CSS 选择器

每当您创建 CSS 样式时,都必须了解将这些样式应用于任何元素对浏览器都有成本。我们可以像分析 JavaScript 一样分析我们的 CSS 选择器,并得出我们应用的每种样式的最佳和最坏情况运行时性能。

例如,考虑我们有以下样式:

.my-class > div > ul.other-class .item:nth-child(3) {

这种复杂性要比简单地创建一个类并直接分配给元素本身要高得多:

.my-class-child {

我们的浏览器不再需要检查每个元素是否完全符合先前定义的样式层次结构。基于这个概念发展出的一种技术称为块-元素-修饰符BEM),这是非常容易理解的。给您的元素一个单一的类名,并尽量不要嵌套它们:

因此,假设您的模板如下所示:

<div class="nav">
 <a href="#" class="nav__trigger">hamburger_icon</a>   <ul class="nav__items"> <li class="nav__item"> <a href="#" class="nav__link">About</a> </li>   <li class="nav__item"> <a href="#" class="nav__link">Blog</a> </li>   <li class="nav__item"> <a href="#" class="nav__link">Contact</a> </li> </ul> </div>

您可以使用 BEM 应用样式,如下所示:

.nav {
    /* styles */ }

.nav__items {
    /* styles */ }

.nav__item {
    /* styles */ }

.nav__link {
    /* styles */ }

.nav__link--active {
    /* styles */ }

如果您需要为元素添加自定义样式,可以创建一个新类并直接应用,或者可以将嵌套与当前级别结合起来:

.nav__item--last-child--active {
    /* styles */ }

理解浏览器

与 HTML 渲染类似,CSS 解析和渲染也是复杂的过程,浏览器非常轻松地隐藏了这些过程。了解我们可以避免什么总是有好处的。让我们以与 HTML 相同的示例为例,讨论 Chrome 如何处理这些问题。

避免重绘和回流

让我们首先简要讨论一下重绘和回流是什么:

重绘:浏览器在元素的非几何属性发生变化时执行的操作,例如背景颜色、文本颜色等。

回流:浏览器执行的操作,因为元素(或其父元素)的几何变化,直接或通过计算属性。这个过程与之前讨论的Layout相同。

虽然我们无法完全防止重绘和回流事件,但我们肯定可以在最小化触发这些操作的更改中发挥作用。几乎所有 DOM read操作(例如offsetWidthgetClientRects)都会触发Layout事件,因为这些读操作的值是按需进行的,浏览器在明确请求之前不关心它们的值。此外,每当我们修改 DOM 时,Layout都会失效,如果我们需要下次读取 DOM 元素属性,它将不得不重新计算。

关键渲染路径(CRP)

到目前为止,我们已经看到了如何优化页面加载(减少负载、大小等),然后我们谈到了渲染后需要考虑的事情。关键渲染路径是优化页面加载的技术,即在折叠线之上(即在任何滚动之前显示的页面顶部部分)的初始加载。这也被称为交互时间TTI)或首字节时间TTFB),我们希望减少以保持页面加载速度。

从技术上讲,CRP 包括以下步骤:

  1. 接收并开始解析 HTML。

  2. 下载并构建 CSSOM。

  3. 下载并执行 JS。

  4. 完成构建 DOM。

  5. 创建渲染树。

因此,如果我们希望我们的 TTI 低,很明显,我们需要尽快构建我们的 DOM 和 CSSOM,而不需要任何阻塞渲染的 CSS 或阻塞解析器的 JS 文件。我们的 TTI 低的一个指标是我们的DOMContentLoaded事件快速触发,因为 DCL 仅在 DOM 和 CSSOM 准备就绪时触发。让我们看下面的示例模板:

<html>
<head>
    <title>CRP Blank</title>
</head>
<body>
    <div>Blank</div>
</body>
</html>

我们可以看到它非常简洁,甚至没有加载任何外部样式或脚本。这对于网页来说非常不寻常,但它作为一个很好的例子。当我们运行这个页面并打开网络选项卡时,我们可以看到以下内容:

然而,我们提到的 HTML 是非常不寻常的。很可能,我们将加载多个外部 CSS 和 JS 文件到我们的页面中。在这种情况下,我们的 DCL 事件会被延迟。让我们在blank.html文件中添加空白的 CSS 和 JS 文件以加载:

在这里,我们可以看到,即使没有太多要加载,DCL 事件也被推迟,直到浏览器下载并运行 JS 文件,因为 JS 文件的获取和执行是渲染阻塞操作。我们的目标现在更加明确:我们需要将 DCL 减少到最低限度,并且从目前我们已经看到的情况来看,我们需要尽快加载 HTML,而其他所有内容可以在初始页面被渲染后(或者至少正在被渲染时)加载。之前我们已经看到,我们可以使用 async 关键字和脚本标签一起使 JavaScript 异步加载和执行。现在让我们使用相同的方法来使我们的页面加载更快:

<html>
<head>
    <title>CRP Blank</title>
    <link rel="stylesheet" href="blank.css">
</head>
<body>
    <div>Blank</div>

    <script async src="blank.js"></script>
</body>
</html>

现在,当我们打开网络选项卡运行这个页面时,我们会看到以下内容:

我们可以看到 DCL(在 瀑布 选项卡下表示为蓝色垂直线)发生在 CSS 和 JS 文件被下载和执行之前。使用 async 属性的另一个优势是,async 属性表示 JavaScript 不依赖于 CSSOM,因此不需要被 CSSOM 构建阻塞。

JavaScript 优化

有大量的在线资源可以讨论可以应用于 JavaScript 的各种优化。在本节中,我们将看一些这些微优化,并确定我们如何采取小步骤使我们的 JavaScript 更高效。

真值/假值比较

我们都曾经在某个时候编写过 if 条件或者依赖于 JavaScript 变量的真值或假值来分配默认值。尽管大多数时候这很有帮助,但我们需要考虑这样一个操作对我们的应用程序会造成什么影响。然而,在我们深入细节之前,让我们讨论一下在 JavaScript 中如何评估任何条件,特别是在这种情况下的 if 条件。作为开发者,我们倾向于做以下事情:

if(objOrNumber) {
    // do something
}

这对大多数情况都适用,除非数字是 0,这种情况下会被评估为 false。这是一个非常常见的边缘情况,我们大多数人都会注意到。然而,JavaScript 引擎为了评估这个条件需要做些什么呢?它如何知道 objOrNumber 评估为 true 还是 false?让我们回到我们的 ECMA262 规范并提取 IF 条件规范 (www.ecma-international.org/ecma-262/5.1/#sec-12.5)。以下是同样的摘录:

语义

The production IfStatement : If (Expression) Statement else Statement

Statement 的评估如下:

  1. 让 exprRef 成为评估 Expression 的结果。

  2. 如果 ToBoolean(GetValue(exprRef)) 是 true,那么

  • 返回评估第一个 Statement 的结果。
  1. 否则,
  • 返回评估第二个 Statement 的结果。

现在,我们注意到我们传递的任何表达式都经历以下三个步骤:

  1. Expression 获取 exprRef

  2. GetValueexprRef 上调用。

  3. ToBoolean 被作为 步骤 2 的结果调用。

步骤 1 在这个阶段并不关心我们太多;可以这样想——一个表达式可以是像 a == b 这样的东西,也可以是像 shouldIEvaluateTheIFCondition() 方法调用这样的东西,也就是说,它是用来评估你的条件的东西。

步骤 2 提取了 exprRef 的值,也就是 10、true、undefined。在这一步中,我们根据 exprRef 的类型区分了值是如何提取的。你可以参考 www.ecma-international.org/ecma-262/5.1/#sec-8.7.1GetValue 的详细信息。

步骤 3 然后根据以下表格(取自 www.ecma-international.org/ecma-262/5.1/#sec-9.2)将从 步骤 2 中提取的值转换为布尔值:

在每一步,您可以看到,如果我们能够提供直接的布尔值而不是真值或假值,那么总是有益的。

循环优化

我们可以深入研究 for 循环,类似于我们之前对 if 条件所做的(www.ecma-international.org/ecma-262/5.1/#sec-12.6.3),但是在循环方面可以应用更简单和更明显的优化。简单的更改可以极大地影响代码的质量和性能;例如:

for(var i = 0; i < arr.length; i++) {
    // logic
}

前面的代码可以更改如下:

var len = arr.length;

for(var i = 0; i < len; i++) {
    // logic
}

更好的是以相反的方式运行循环,这比我们之前看到的更快:

var len = arr.length;

for(var i = len; i >= 0; i--) {
    // logic
}

条件函数调用

我们应用程序中的一些功能是有条件的。例如,日志记录或分析属于这一类。一些应用程序可能会在某段时间内关闭日志记录,然后重新打开。实现这一点最明显的方法是将日志记录方法包装在 if 条件中。但是,由于该方法可能被触发多次,我们可以以另一种方式进行优化:

function someUserAction() {

    // logic

    if (analyticsEnabled) {
        trackUserAnalytics();
    }

}

// in some other class

function trackUserAnalytics() {

    // save analytics

}

不是前面的方法,我们可以尝试做一些稍微不同的事情,这样 V8 引擎可以优化代码的执行方式:

function someUserAction() {

    // logic

   trackUserAnalytics();
}

// in some other class

function toggleUserAnalytics() {

    if(enabled) {
        trackUserAnalytics =  userAnalyticsMethod;
    } else {
        trackUserAnalytics = noOp;
    }
}

function userAnalyticsMethod() {

    // save analytics

}

// empty function
function noOp  {}

现在,前面的实现是一把双刃剑。原因很简单。JavaScript 引擎采用一种称为内联缓存IC)的技术,这意味着 JS 引擎对某个方法的任何先前查找都将被缓存并在下次触发时重用;例如,如果我们有一个具有嵌套方法的对象 a.b.c,方法 a.b.c 只会被查找一次并存储在缓存中(IC);如果下次调用 a.b.c,它将从 IC 中获取,并且 JS 引擎不会再次解析整个链。如果 a.b.c 链有任何更改,那么 IC 将被使无效,并且下次将执行新的动态查找,而不是从 IC 中检索。

因此,从我们之前的例子中,当我们将noOp分配给trackUserAnalytics()方法时,该方法路径被跟踪并保存在 IC 中,但它在内部删除了这个函数调用,因为它是对一个空方法的调用。但是,当它应用于具有一些逻辑的实际函数时,IC 直接指向这个新方法。因此,如果我们多次调用我们的toggleUserAnalytics()方法,它将不断使我们的 IC 失效,并且我们的动态方法查找必须每次发生,直到应用程序状态稳定下来(也就是说,不再调用toggleUserAnalytics())。

图像和字体优化

在图像和字体优化方面,我们可以进行各种类型和规模的优化。但是,我们需要牢记我们的目标受众,并根据手头的问题调整我们的方法。

对于图像和字体,首要重要的是我们不要过度提供,也就是说,我们只请求和发送应用程序运行设备的尺寸所需的数据。

最简单的方法是为设备大小添加一个 cookie,并将其与每个请求一起发送到服务器。一旦服务器收到图像的请求,它可以根据发送到 cookie 的图像尺寸检索图像。大多数时候,这些图像是用户头像或评论某篇帖子的人员列表之类的东西。我们可以同意缩略图图像不需要与个人资料页面的大小相同,我们可以在传输基于图像的较小图像时节省一些带宽。

由于现在的屏幕具有非常高的每英寸点数DPI),我们为屏幕提供的媒体需要值得。否则,应用程序看起来很糟糕,图像看起来都是像素化的。这可以通过使用矢量图像或SVGs来避免,这些图像可以通过网络进行 GZip 压缩,从而减小负载大小。

另一个不那么明显的优化是更改图像压缩类型。您是否曾经加载过一个页面,其中图像从顶部到底部以小的增量矩形加载?默认情况下,图像使用基线技术进行压缩,这是一种自上而下压缩图像的默认方法。我们可以使用诸如imagemin之类的库将其更改为渐进式压缩。这将首先以模糊的方式加载整个图像,然后是半模糊,依此类推,直到整个图像未经压缩地显示在屏幕上。解压渐进式 JPEG 可能需要比基线更长的时间,因此在进行此类优化之前进行测量非常重要。

基于这一概念的另一个扩展是一种仅适用于 Chrome 的图像格式,称为WebP。这是一种非常有效的图像服务方式,在生产中为许多公司节省了近 30%的带宽。使用WebP几乎和之前讨论的渐进式压缩一样简单。我们可以使用imagemin-webp节点模块,它可以将 JPEG 图像转换为webp图像,从而大大减小图像大小。

Web 字体与图像有些不同。图像会按需下载并呈现到 UI 上,也就是说,当浏览器从 HTML 或 CSS 文件中遇到图像时。然而,字体则有些不同。字体文件只有在渲染树完全构建时才会被请求。这意味着在发出字体请求时,CSSOM 和 DOM 必须准备就绪。此外,如果字体文件是从服务器而不是本地提供的,那么我们可能会看到未应用字体的文本(或根本没有文本),然后我们看到应用了字体,这可能会导致文本的闪烁效果。

有多种简单的技术可以避免这个问题:

  • 在本地下载、提供和预加载字体文件:
<link rel="preload" href="fonts/my-font.woff2" as="font">
  • 在字体中指定 unicode 范围,以便浏览器可以根据实际期望的字符集和字形进行适应和改进:
@font-face(
    ...
    unicode-range: U+000-5FF; // latin
    ...
)
  • 到目前为止,我们已经看到我们可以将未经样式化的文本加载到 UI 上,并且按照我们期望的方式进行样式化;这可以通过使用字体加载 API 来改变,该 API 允许我们使用 JavaScript 加载和呈现字体:
var font = new FontFace("myFont", "url(/my-fonts/my-font.woff2)", {
    unicodeRange: 'U+000-5FF'  });

// initiate a fetch without Render Tree font.load().then(function() {
   // apply the font 
  document.fonts.add(font);

   document.body.style.fontFamily = "myFont";  });

JavaScript 中的垃圾回收

让我们快速看一下垃圾回收GC)是什么,以及我们如何在 JavaScript 中处理它。许多低级语言为开发人员提供了在其代码中分配和释放内存的显式能力。然而,与这些语言不同,JavaScript 自动处理内存管理,这既是好事也是坏事。好处是我们不再需要担心需要分配多少内存,何时需要这样做,以及如何释放分配的内存。整个过程的坏处是,对于一个不了解的开发人员来说,这可能是一场灾难,他们可能最终得到一个可能会挂起和崩溃的应用程序。

幸运的是,理解 GC 的过程非常容易,并且可以很容易地融入到我们的编码风格中,以确保在内存管理方面编写最佳代码。内存管理有三个非常明显的步骤:

  1. 将内存分配给变量:
var a = 10; // we assign a number to a memory location referenced by variable a
  1. 使用变量从内存中读取或写入:
a += 3; // we read the memory location referenced by a and write a new value to it
  1. 当不再需要时,释放内存。

现在,这是不明显的部分。浏览器如何知道我们何时完成变量a并且它已准备好进行垃圾回收?在我们继续讨论之前,让我们将其包装在一个函数中:

function test() {
    var a = 10;
    a += 3;
    return a;
}

我们有一个非常简单的函数,它只是将我们的变量a相加并返回结果,然后执行结束。然而,实际上还有一步,这将在这个方法执行后发生,称为标记和清除(不是立即发生,有时也会在主线程上完成一批操作后发生)。当浏览器执行标记和清除时,它取决于应用程序消耗的总内存和内存消耗的速度。

标记和清除算法

由于没有准确的方法来确定特定内存位置的数据将来是否会被使用,我们将需要依赖于可以帮助我们做出这个决定的替代方法。在 JavaScript 中,我们使用引用的概念来确定变量是否仍在使用,如果不是,它可以被垃圾回收。

标记和清除的概念非常简单:从所有已知的活动内存位置到达哪些内存位置?如果有些地方无法到达,就收集它,也就是释放内存。就是这样,但是已知的活动内存位置是什么?它仍然需要一个起点,对吧?在大多数浏览器中,GC 算法会保留一个roots列表,从这些roots开始标记和清除过程。所有roots及其子代都被标记为活动,可以从这些roots到达的任何变量也被标记为活动。任何无法到达的东西都可以标记为不可到达,因此可以被收集。在大多数情况下,roots包括 window 对象。

所以,我们将回到之前的例子:

function test() {
    var a = 10;
    a += 3;
    return a;
}

我们的变量 a 是局部的test()方法。一旦方法执行,就无法再访问该变量,也就是说,没有人持有该变量的引用,这时它可以被标记为垃圾回收,这样下次 GC 运行时,var a将被清除,分配给它的内存可以被释放。

垃圾回收和 V8

在 V8 中,垃圾回收的过程非常复杂(应该是这样)。因此,让我们简要讨论一下 V8 是如何处理的。

在 V8 中,内存(堆)分为两个主要代,即新生代老生代。新生代和老生代都分配了一些内存(在1MB20MB之间)。大多数程序和它们的变量在创建时都分配在新生代中。每当我们创建一个新变量或执行一个消耗内存的操作时,默认情况下会从新生代分配内存,这对内存分配进行了优化。一旦分配给新生代的总内存几乎被完全消耗,浏览器就会触发一个Minor GC,它基本上会删除不再被引用的变量,并标记仍然被引用且暂时不能被删除的变量。一旦一个变量经历了两次或更多次Minor GC,那么它就成为了老生代的候选对象,老生代的 GC 周期不像新生代那样频繁。当老生代达到一定大小时,会触发一个 Major GC,所有这些都由应用程序的启发式驱动,这对整个过程非常重要。因此,编写良好的程序会将更少的对象移动到老生代,从而触发更少的 Major GC 事件。

毋庸置疑,这只是对 V8 垃圾回收的一个非常高层次的概述,由于这个过程随着时间的推移不断变化,我们将转变方向,继续下一个主题。

避免内存泄漏

现在我们已经大致了解了 JavaScript 中垃圾回收的工作原理,让我们来看一些常见的陷阱,这些陷阱会阻止浏览器标记我们的变量进行垃圾回收。

将变量分配给全局范围

现在这应该是显而易见的了;我们讨论了 GC 机制如何确定根(即 window 对象)并将根及其子对象视为活动对象,永远不会标记它们进行垃圾回收。

所以,下次当你忘记在变量声明中添加var时,请记住你创建的全局变量将永远存在,永远不会被垃圾回收:

function test() {
    a = 10; // created on window object
    a += 3;
    return a;
}

删除 DOM 元素和引用

非常重要的是,我们要尽量减少对 DOM 的引用,因此我们喜欢执行的一个众所周知的步骤是在我们的 JavaScript 中缓存 DOM 元素,这样我们就不必一遍又一遍地查询任何 DOM 元素。然而,一旦 DOM 元素被移除,我们需要确保这些方法也从我们的缓存中移除,否则它们永远不会被 GC 回收:

var cache = {row: document.getElementById('row') };

function removeTable() {
 document.body.removeChild(document.getElementById('row'));
}

先前显示的代码从 DOM 中删除了row,但变量 cache 仍然引用 DOM 元素,因此阻止它被垃圾回收。这里还有一件有趣的事情需要注意,即使我们删除了包含row的表,整个表仍将保留在内存中,并且不会被 GC 回收,因为在内部引用表的 cache 中的row仍然指向表。

闭包边缘情况

闭包很棒;它们帮助我们处理很多棘手的情况,还为我们提供了模拟私有变量概念的方法。好吧,这一切都很好,但有时我们倾向于忽视与闭包相关的潜在缺点。这就是我们所知道和使用的。

function myGoodFunc() {
 var a = new Array(10000000).join('*'); 
    // something big enough to cause a spike in memory usage   function myGoodClosure() {
        return a + ' added from closure';
    }

 myGoodClosure();
}

setInterval(myGoodFunc, 1000);

当我们在浏览器中运行这个脚本,然后对其进行分析,我们会看到预期的结果,即该方法消耗了恒定的内存量,然后被 GC 回收,并恢复到脚本消耗的基线内存:

现在,让我们放大到其中一个峰值,并查看调用树,以确定在峰值时触发了哪些事件:

我们可以看到一切都按照我们的预期发生;首先,我们的setInterval()被触发,调用myGoodFunc(),一旦执行完成,就会有一个 GC,它收集数据,因此会有一个峰值,正如我们从前面的截图中所看到的。

现在,这是处理闭包时预期的流程或正常路径。然而,有时我们的代码并不那么简单,我们最终会在一个闭包中执行多个操作,有时甚至会嵌套闭包:

function myComplexFunc() {
   var a = new Array(1000000).join('*');
   // something big enough to cause a spike in memory usage    function closure1() {
      return a + ' added from closure';
   }

   closure1();

   function closure2() {
      console.log('closure2 called')
   }

   setInterval(closure2, 100);
}

setInterval(myComplexFunc, 1000);

我们可以注意到在前面的代码中,我们扩展了我们的方法以包含两个闭包:closure1closure2。尽管closure1仍然执行与以前相同的操作,但closure2将永远运行,因为我们将其运行频率设置为父函数的 1/10。此外,由于两个闭包方法共享父闭包作用域,在这种情况下变量 a,它永远不会被 GC 回收,从而导致巨大的内存泄漏,可以从以下的分析中看到:

仔细观察,我们可以看到 GC 正在被触发,但由于方法被调用的频率,内存正在慢慢泄漏(收集的内存少于创建的内存):

好吧,这是一个极端的边缘情况,对吧?这比实际更理论化——为什么会有人有两个嵌套的setInterval()方法和闭包。让我们看看另一个例子,其中我们不再嵌套多个setInterval(),但它是由相同的逻辑驱动的。

假设我们有一个创建闭包的方法:

var something = null;

function replaceValue () {
   var previousValue = something;

   // `unused` method loads the `previousValue` into closure scope
  function </span>unused() {
      if (previousValue)
         console.log("hi");
   }

   // update something    something = {
      str: new Array(1000000).join('*'),

      // all closures within replaceValue share the same
 // closure scope hence someMethod would have access // to previousValue which is nothing but its parent // object (`something`)     // since `someMethod` has access to its parent // object, even when it is replaced by a new (identical) // object in the next setInterval iteration, the previous // value does not get garbage collected because the someMethod // on previous value still maintains reference to previousValue // and so on.    someMethod: function () {}
   };
}

setInterval(replaceValue, 1000);

解决这个问题的一个简单方法是显而易见的,因为我们自己已经说过,对象 something 的先前值不会被垃圾回收,因为它引用了上一次迭代的 previousValue。因此,解决这个问题的方法是在每次迭代结束时清除 previousValue 的值,这样在卸载时 something 就没有任何东西可引用,因此可以看到内存分析的变化:

前面的图片变化如下:

总结

在本章中,我们探讨了通过对我们为应用程序编写的 HTML、CSS 和 JavaScript 进行优化来改善代码性能的方法。非常重要的是要理解,这些优化可能对你有益,也可能没有,这取决于你尝试构建的应用程序。本章的主要收获应该是能够打开浏览器的内部,并且不害怕解剖和查看浏览器如何处理我们的代码。此外,要注意 ECMA 规范指南不断变化,但浏览器需要时间来跟上这些变化。最后但同样重要的是,不要过度优化或过早优化。如果遇到问题,首先进行测量,然后再决定瓶颈在哪里,然后再制定优化计划。

接下来是什么?

随着这一点,我们结束了这本书。我们希望你有一个很棒的学习经验,并且能够从这些技术中受益。JavaScript,就像它现在的样子,一直在不断发展。事情正在以快速的速度发生变化,跟上这些变化变得很困难。以下是一些建议,你可以尝试并修改:

  1. 确定你感兴趣的领域。到现在为止,你已经知道 JavaScript 存在(并且在浏览器之外的很多东西中都很棒)。你更喜欢用户界面吗?你喜欢 API 和可扩展的微服务吗?你喜欢构建传感器来计算你每天消耗了多少咖啡吗?找到你的热情所在,并将你新学到的 JavaScript 概念应用到那里。概念是相同的,应用是不同的。

  2. 订阅来自你感兴趣领域的新闻简报和邮件列表。你会惊讶于每封邮件每天或每周都能获取到的信息量。这有助于你保持警惕,你可以及时了解最新的技术。

  3. 写一篇博客(甚至是 StackOverflow 的回答)来分享你所知道和学到的东西。当你把学到的东西写下来时,总是会有帮助的。有一天,你甚至可以用它来作为自己的参考。

posted @ 2024-05-22 12:07  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报