JavaScript-数据结构和算法教程-四-

110 阅读47分钟

JavaScript 数据结构和算法教程(四)

原文:JavaScript Data Structures and Algorithms

协议:CC BY-NC-SA 4.0

十六、堆

本章将介绍堆。堆是一种重要的数据结构,它在 O(1)时间内返回最高或最低的元素。本章将重点解释堆是如何实现的,以及如何使用它们。一个例子是堆排序,这是一种基于堆的排序算法。

了解堆

是一种类似树的数据结构,其中父堆大于其子堆(如果是最大堆)或小于其子堆(如果是最小堆)。堆的这一属性使它对数据排序非常有用。

与其他树数据结构不同,堆使用数组来存储数据,而不是拥有指向其子级的指针。堆节点的子节点在数组中的位置(索引)很容易计算。这是因为父子关系很容易用堆来定义。

有许多类型的堆有不同数量的子堆。在本章中,只考虑二进制堆。因为堆使用数组来存储数据,所以数组的索引定义了每个元素的顺序/高度。二进制堆可以通过将第一个数组元素作为根元素,然后依次填充每个左边和右边的元素来构建。

例如,对于图 16-1 中所示的堆,数组应该是这样的:[2,4,23,12,13]。

img/465726_1_En_16_Fig1_HTML.jpg

图 16-1

堆索引

有两种类型的二进制堆:最大堆和最小堆。在 max-heap 中,根节点的值最高,每个节点的值都大于其子节点。在最小堆中,根节点的值最低,每个节点的值都小于其子节点。

堆可以存储任何类型的任何值:字符串、整数,甚至自定义类。如第 3 和 4 章所述,字符串和整数值的比较由 JavaScript 本地处理(例如,9 大于 1, z 大于 a )。然而,对于定制类,开发人员需要实现一种方法来比较两个类。本章将着眼于只存储整数值的堆。

最大堆

最大堆是指父堆总是大于其子堆的堆(见图 16-2 )。

img/465726_1_En_16_Fig2_HTML.jpg

图 16-2

最大堆

这里是 max-heap 的数组,如图 16-2 所示:[100,19,36,17,3,25,1,2,7]。

最小堆

最小堆是一个父堆总是比它的任何子堆都小的堆(见图 16-3 )。

img/465726_1_En_16_Fig3_HTML.jpg

图 16-3

最小堆

这里是如图 16-3 所示的 max-heap 的数组:[1,2,3,17,19,36,7,25,100]。

二进制堆数组索引结构

对于二进制堆,通过使用以下索引,使用数组来表示堆,其中N是节点的索引:

Node                Index
(itself)            N
Parent              (N-1) / 2
Left Child          (N*2) + 1
Right Child         (N*2) + 2

图 16-4 用指数说明了这种家族关系。

img/465726_1_En_16_Fig4_HTML.jpg

图 16-4

堆关系

让我们首先定义一个通用的Heap类。使用前面描述的索引结构,一个数组将被用来存储所有的值。下面的堆类实现了检索父节点、左侧子节点和右侧子节点的帮助器函数。下面的代码块有一个peek函数,它返回最大堆的最大值和最小堆的最小值。

 1   function Heap() {
 2       this.items = [];
 3   }
 4
 5   Heap.prototype.swap = function(index1, index2) {
 6       var temp = this.items[index1];
 7       this.items[index1] = this.items[index2];
 8       this.items[index2] = temp;
 9   }
10
11   Heap.prototype.parentIndex = function(index) {
12       return Math.floor((index - 1) / 2);
13   }
14
15   Heap.prototype.leftChildIndex = function(index) {
16       return index * 2 + 1;
17   }
18
19   Heap.prototype.rightChildrenIndex = function(index) {
20       return index * 2 + 2;
21   }
22
23   Heap.prototype.parent = function(index) {
24       return this.items[this.parentIndex(index)];
25   }
26
27   Heap.prototype.leftChild = function(index) {
28       return this.items[this.leftChildIndex(index)];
29   }
30
31   Heap.prototype.rightChild = function(index) {
32       return this.items[this.rightChildrenIndex(index)];
33   }
34
35   Heap.prototype.peek = function(item) {
36       return this.items[0];
37   }
38   Heap.prototype.size = function() {
39       return this.items.length;
40   }

size函数是另一个返回堆大小(元素数量)的助手。

渗透:上下冒泡

当添加或删除元素时,堆的结构必须保持不变(最大堆的节点大于其子节点,最小堆的节点小于其子节点)。

这需要交换项目并“冒泡”到堆的顶部。与向上冒泡类似,有些项需要“向下冒泡”到它们正确的位置,以便保持堆的结构。逾渗在时间上需要 O(log2(n))。

让我们遍历一个 min-heap 示例,并按以下顺序将以下值插入 min-heap:12、2、23、4、13。以下是步骤:

img/465726_1_En_16_Fig11_HTML.jpg

图 16-11

最新和最大的 13 节点仍在原处

  1. 插入 13,如图 16-11 所示。

img/465726_1_En_16_Fig10_HTML.jpg

图 16-10

较小的 4 节点已经冒泡以维持最小堆结构

  1. 12 与 4 交换以保持最小堆结构(图 16-10 )。

img/465726_1_En_16_Fig9_HTML.jpg

图 16-9

最小堆中的新节点比它上面的节点小

  1. 在堆中插入 4,如图 16-9 所示。

img/465726_1_En_16_Fig8_HTML.jpg

图 16-8

较大的 23 节点保留在最小堆结构中

  1. 在第二个子位置插入一个新的 23 节点(图 16-8 )。

img/465726_1_En_16_Fig7_HTML.jpg

图 16-7

较小的节点直到父节点位置都有气泡

  1. 2 节点会冒泡,因为它小于 12,因此应该位于最小堆的顶部(图 16-7 )。

img/465726_1_En_16_Fig6_HTML.jpg

图 16-6

最新的节点比父节点小

  1. 插入一个新的 2 节点(图 16-6 )。

img/465726_1_En_16_Fig5_HTML.jpg

图 16-5

最小堆根节点

  1. 插入 12 作为第一个节点(图 16-5 )。

下面是这个堆的数组内容:[2,4,23,12,13]。

实现渗透

为了实现渗滤的“上下冒泡”,交换直到最小堆结构形成,最小元素在顶部。对于向下冒泡,如果一个子元素更小,则将顶部元素(数组中的第一个)与其子元素交换。同样,对于向上冒泡,如果父元素大于新元素,则将新元素与其父元素交换。

 1   function MinHeap() {
 2       this.items = [];
 3   }
 4   MinHeap.prototype = Object.create(Heap.prototype); // inherit helpers from heap by copying prototype
 5   MinHeap.prototype.bubbleDown = function() {
 6       var index = 0;
 7       while (this.leftChild(index) && this.leftChild(index) < this.items[index]) {
 8           var smallerIndex = this.leftChildIndex(index);
 9           if (this.rightChild(index)
10               && this.rightChild(index) < this.items[smallerIndex]) {
11              // if right is smaller, right swaps
12               smallerIndex = this.rightChildrenIndex(index);
13           }
14           this.swap(smallerIndex, index);
15           index = smallerIndex;
16       }
17   }
18
19   MinHeap.prototype.bubbleUp = function() {
20       var index = this.items.length - 1;
21       while (this.parent(index) && this.parent(index) > this.items[index]) {
22           this.swap(this.parentIndex(index), index);
23           index = this.parentIndex(index);
24       }
25   }

最大堆实现的不同之处仅在于比较器。对于向下冒泡,如果子节点更大,则 max-heap 节点与其一个子节点交换。同样,对于冒泡,如果最新节点的父节点比新节点小,则该节点与其父节点交换。

最大堆示例

现在让我们构建一个 max-heap,其值与前面的 min-heap 示例中使用的值相同,按顺序插入以下值:12、2、23、4、13。

img/465726_1_En_16_Fig19_HTML.jpg

图 16-19

渗透恢复最大堆结构

  1. 由于 max-heap 结构,13 和 4 交换位置(图 16-19 )。

img/465726_1_En_16_Fig18_HTML.jpg

图 16-18

新节点比它上面的节点大

  1. 插入 13,如图 16-18 所示。

img/465726_1_En_16_Fig17_HTML.jpg

图 16-17

4 和 2 节点交换位置

  1. 为了保持最大堆结构,4 个气泡向上,2 个气泡向下(图 16-17 )。

img/465726_1_En_16_Fig16_HTML.jpg

图 16-16

新节点比它上面的节点大

  1. 插入 4,如图 16-16 所示。

img/465726_1_En_16_Fig15_HTML.jpg

图 16-15

新的较大节点与较小的 12 交换

  1. 这 23 个节点“冒泡”到顶部以维持最大堆结构(图 16-15 )。

img/465726_1_En_16_Fig14_HTML.jpg

图 16-14

新子节点大于父节点

  1. 插入 23,如图 16-14 所示。

img/465726_1_En_16_Fig13_HTML.jpg

图 16-13

新的较小节点保留在最大堆结构中

  1. 插入一个新的 2 节点(图 16-13 )。

img/465726_1_En_16_Fig12_HTML.jpg

图 16-12

第一个最大堆节点

  1. 插入第一个节点,即 12(图 16-12 )。

下面是这个堆的数组内容:[23,13,12,2,4]。

最小堆完成实现

将所有定义的函数放在一起并继承Heap的函数,min-heap 的完整实现和示例如下所示。增加了addpoll功能。add简单地向堆中添加一个新元素,但是bubbleUp确保最小堆中的这个元素满足顺序。poll从堆中移除最小元素(根),并调用bubbleDown来保持最小堆顺序。

 1   function MinHeap() {
 2       this.items = [];
 3   }
 4   MinHeap.prototype = Object.create(Heap.prototype); // inherit helpers from heap by copying prototype
 5   MinHeap.prototype.add = function(item) {
 6       this.items[this.items.length] = item;
 7       this.bubbleUp();
 8   }
 9
10   MinHeap.prototype.poll = function() {
11       var item = this.items[0];
12       this.items[0] = this.items[this.items.length - 1];
13       this.items.pop();
14       this.bubbleDown();
15       return item;
16   }
17
18   MinHeap.prototype.bubbleDown = function() {
19       var index = 0;
20       while (this.leftChild(index) && (this.leftChild(index) < this.items[index] || this.rightChild(index) < this.items[index]) ) {
21           var smallerIndex = this.leftChildIndex(index);
22           if (this.rightChild(index) && this.rightChild(index) < this.items[smallerIndex]) {
23               smallerIndex = this.rightChildrenIndex(index);
24           }
25           this.swap(smallerIndex, index);
26           index = smallerIndex;
27       }
28   }
29
30   MinHeap.prototype.bubbleUp = function() {
31       var index = this.items.length - 1;
32       while (this.parent(index) && this.parent(index) > this.items[index]) {

33           this.swap(this.parentIndex(index), index);
34           index = this.parentIndex(index);
35       }
36   }
37
38   var mh1 = new MinHeap();
39   mh1.add(1);
40   mh1.add(10);
41   mh1.add(5);
42   mh1.add(100);
43   mh1.add(8);
44
45   console.log(mh1.poll()); // 1
46   console.log(mh1.poll()); // 5
47   console.log(mh1.poll()); // 8
48   console.log(mh1.poll()); // 10
49   console.log(mh1.poll()); // 100

最大堆完成实现

如前所述,最小堆和最大堆实现之间的唯一区别是bubbleDownbubbleUp中的比较器。添加了与上一个示例相同的元素,即(1,10,5,100,8),当调用poll时,max-heap 返回最高的元素。

 1   function MaxHeap() {
 2       this.items = [];
 3   }
 4   MaxHeap.prototype = Object.create(Heap.prototype); // inherit helpers from heap by copying prototype
 5   MaxHeap.prototype.poll = function() {
 6       var item = this.items[0];
 7       this.items[0] = this.items[this.items.length - 1];
 8       this.items.pop();
 9       this.bubbleDown();
10       return item;
11   }
12
13   MaxHeap.prototype.bubbleDown = function() {
14       var index = 0;
15       while (this.leftChild(index) && (this.leftChild(index) > this.items[index] ||  this.rightChild(index) > this.items[index] ) ) {
16           var biggerIndex = this.leftChildIndex(index);
17           if (this.rightChild(index) && this.rightChild(index) > this.items[bigger\Index])
18           {
19               biggerIndex = this.rightChildrenIndex(index);
20           }
21           this.swap(biggerIndex, index);
22           index = biggerIndex;
23       }
24   }
25
26   MaxHeap.prototype.bubbleUp = function() {
27       var index = this.items.length - 1;
28       while (this.parent(index) && this.parent(index) < this.items[index]) {
29           this.swap(this.parentIndex(index), index);
30           index = this.parentIndex(index);
31       }
32   }
33
34   var mh2 = new MaxHeap();
35   mh2.add(1);
36   mh2.add(10);
37   mh2.add(5);
38   mh2.add(100);
39   mh2.add(8); 

40
41   console.log(mh2.poll()); // 100
42   console.log(mh2.poll()); // 10
43   console.log(mh2.poll()); // 8
44   console.log(mh2.poll()); // 5
45   console.log(mh2.poll()); // 1

堆排序

既然已经创建了堆类,用堆进行排序就相当简单了。要获得一个排序的数组,只需在堆上调用.pop()直到它为空,并存储已存储的弹出对象。这就是所谓的堆排序。由于逾渗需要 O(log2(n)),并且排序必须弹出 n 个元素,所以堆排序的时间复杂度为 O(nlog2(n)),类似于快速排序和合并排序。

在本节中,我们将首先使用最小堆实现升序排序,然后使用最大堆实现降序排序。

升序排序(最小堆)

图 16-20 显示了当所有项目都被添加到最小堆中时的最小堆,图 16-21 到 16-23 显示了弹出项目时的堆重组。最后,当它为空时,排序完成。

img/465726_1_En_16_Fig23_HTML.jpg

图 16-23

最小堆排序:取出 12

img/465726_1_En_16_Fig22_HTML.jpg

图 16-22

最小堆排序:弹出 4

img/465726_1_En_16_Fig21_HTML.jpg

图 16-21

最小堆排序:弹出 2

img/465726_1_En_16_Fig20_HTML.jpg

图 16-20

添加所有项目后的最小堆排序

 1   var minHeapExample = new MinHeap();
 2   minHeapExample.add(12);
 3   minHeapExample.add(2);
 4   minHeapExample.add(23);
 5   minHeapExample.add(4);
 6   minHeapExample.add(13);
 7   minHeapExample.items; // [2, 4, 23, 12, 13]
 8
 9   console.log(minHeapExample.poll()); // 2
10   console.log(minHeapExample.poll()); // 4
11   console.log(minHeapExample.poll()); // 12
12   console.log(minHeapExample.poll()); // 13
13   console.log(minHeapExample.poll()); // 23

最后一个节点(原来是 13 的地方)被去掉,然后 13 被放在最上面。通过过滤过程,13 向下移动到 12 的左孩子之后,因为它比 4 和 13 都大。

降序排序(最大堆)

图 16-24 显示了所有项目都被添加到最小堆时的最大堆,图 16-25 到 16-27 显示了弹出项目时的最大堆重组。最后,当它为空时,排序完成。

img/465726_1_En_16_Fig27_HTML.jpg

图 16-27

最大排序:弹出 12 个

img/465726_1_En_16_Fig26_HTML.jpg

图 16-26

最大排序:弹出 13 个

img/465726_1_En_16_Fig25_HTML.jpg

图 16-25

最大排序:弹出 23 个

img/465726_1_En_16_Fig24_HTML.jpg

图 16-24

添加所有项目后的最大堆排序

 1   var maxHeapExample = new MaxHeap();
 2   maxHeapExample.add(12);
 3   maxHeapExample.add(2);
 4   maxHeapExample.add(23);
 5   maxHeapExample.add(4);
 6   maxHeapExample.add(13);
 7   maxHeapExample.items; // [23, 13, 12, 2, 4]
 8
 9   console.log(maxHeapExample.poll()); // 23
10   console.log(maxHeapExample.poll()); // 13
11   console.log(maxHeapExample.poll()); // 12
12   console.log(maxHeapExample.poll()); // 2
13   console.log(maxHeapExample.poll()); // 4

摘要

堆是用数组表示的树状数据结构。要获得树节点的父节点、左子节点和右子节点,可以使用表 16-1 中的索引公式。

表 16-1

节点指标汇总

|

结节

|

索引

| | --- | --- | | (自己) | 普通 | | 父母 | (N-1) / 2 | | 左边的孩子 | (N2) + 1 | | 正确的孩子 | (N2) + 2 |

堆通过渗透来维持它们的结构;当一个节点被插入时,它通过重复地与元素交换来“冒泡”,直到获得合适的堆结构。对于一个最小堆,这意味着根节点中值最低的节点。对于 max-heap,这意味着根节点中值最高的节点。堆基本上是通过渗滤工作的,渗滤允许在 O(log 2 ( n ))时间内删除和插入,如表 16-2 所示。

表 16-2

作战总结

|

操作

|

时间复杂度

| | --- | --- | | 删除(导致“气泡下降”) | O( 日志 2 ( n )) | | 插入(导致“冒泡”) | O( 日志 2 ( n )) | | 堆排序 | o(nlog2(n)) |

练习

你可以在 GitHub 上找到所有练习的代码。 1

跟踪数字流中的中位数

既然这个问题在这一章里,那已经是接近它的一个很大的暗示了。理论上,解决方案相当简单。有一个最小堆和一个最大堆,那么检索中间值只需要 O(1)。

例如,让我们有一个如下整数的流:12,2,23,4,13。

当插入 12 时,中位数是 12,因为这是唯一的元素。当插入 2 时,有偶数个项目:2 和 12。因此,中位数是它的算术平均值,7 ((12+2)/2)。当插入 23 时,中位数是 12。最后,当插入 13 时,中位数是 12.5,即两个中间项(12 和 13)的平均值。

1   medianH.push(12);
2   console.log(medianH.median()); // 12
3   medianH.push(2);
4   console.log(medianH.median()); // 7 ( because 12 + 2 = 14; 14/2 = 7)
5   medianH.push(23);
6   console.log(medianH.median()); // 12
7   medianH.push(13);
8   console.log(medianH.median()); // 12.5

 1   function MedianHeap() {
 2       this.minHeap = new MinHeap();
 3       this.maxHeap = new MaxHeap();
 4   }
 5
 6   MedianHeap.prototype.push = function (value) {
 7       if (value > this.median()) {
 8           this.minHeap.add(value);
 9       } else {
10           this.maxHeap.add(value);
11       }
12
13       // Re balancing
14       if (this.minHeap.size() - this.maxHeap.size() > 1) {
15           this.maxHeap.push(this.minHeap.poll());
16       }
17
18       if (this.maxHeap.size() - this.minHeap.size() > 1){
19           this.minHeap.push(this.maxHeap.poll());
20       }
21   }
22
23   MedianHeap.prototype.median = function () {
24       if (this.minHeap.size() == 0 && this.maxHeap.size() == 0){
25           return Number.NEGATIVE_INFINITY;
26       } else if (this.minHeap.size() == this.maxHeap.size()) {
27           return (this.minHeap.peek() + this.maxHeap.peek()) / 2;
28       } else if (this.minHeap.size() > this.maxHeap.size()) {
29           return this.minHeap.peek();
30       } else {
31           return this.maxHeap.peek();
32       }
33   }
34
35   var medianH = new MedianHeap();
36
37   medianH.push(12);
38   console.log(medianH.median()); // 12
39   medianH.push(2);
40   console.log(medianH.median()); // 7 ( because 12 + 2 = 14; 14/2 = 7)
41   medianH.push(23);
42   console.log(medianH.median()); // 12
43   medianH.push(13);
44   console.log(medianH.median()); // 12.5

找出数组中第 K 个最小值

这个问题之前已经在第十章使用 quicksort 的辅助函数探讨过了。另一种方法是使用堆。简单地将元素添加到一个堆中,并弹出第 k 次*。根据最小堆的定义,这将返回数组中第 k 个最小值。*

 1   var array1 = [12, 3, 13, 4, 2, 40, 23]
 2
 3   function getKthSmallestElement(array, k) {
 4       var minH = new MinHeap();
 5       for (var i = 0, arrayLength = array.length; i < arrayLength; i++) {
 6           minH.add(array[i]);
 7       }
 8       for (var i = 1; i < k; i++) {
 9           minH.poll();
10       }
11       return minH.poll();
12   }
13   getKthSmallestElement(array1, 2); // 3
14   getKthSmallestElement(array1, 1); // 2
15   getKthSmallestElement(array1, 7); // 40

找出数组中的 KTH 最大值

这与之前关于 max-heap 的想法相同。

 1   var array1 = [12,3,13,4,2,40,23];
 2
 3   function getKthBiggestElement(array, k) {
 4       var maxH = new MaxHeap();
 5       for (var i=0, arrayLength = array.length; i<arrayLength; i++) {
 6           maxH.push(array[i]);
 7       }
 8       for (var i=1; i<k; i++) {
 9           maxH.pop();
10       }
11       return maxH.pop();
12   }
13   getKthBiggestElement(array1,2); // 23
14   getKthBiggestElement(array1,1); // 40
15   getKthBiggestElement(array1,7); // 2

**时间复杂度:**o(klug2(n))

这里, n 是数组的大小,因为每次.pop花费 O(log2(n)),要做 k 次。

空间复杂度: O( n

内存中需要 O( n )来存储堆数组。

Footnotes 1

https://github.com/Apress/js-data-structures-and-algorithms

 

*

十七、图

本章包括图。图是表示对象之间联系的一种通用方式。在本章中,您将学习图基础知识,包括基本术语和图类型。本章还将介绍如何使用这些不同的图类型,以及在已经探索过的数据结构中表示图的方法。最后,探索遍历、搜索和排序图的算法,以解决诸如寻找两个图节点之间的最短路径等问题。

图基础

如简介中所述,是对象之间联系的可视化表示。这种表示可以是许多事物,并有不同的应用;表 17-1 显示了一些例子。

表 17-1

图应用示例

|

应用

|

项目

|

关系

| | --- | --- | --- | | 网站 | 网页 | 链接 | | 地图 | 交集 | 路 | | 电路 | 成分 | 接线 | | 社会化媒体 | 人 | “友谊”/联系 | | 电话 | 电话号码 | 固定电话 |

图 17-1 显示了两个简单图的例子。

img/465726_1_En_17_Fig1_HTML.jpg

图 17-1

图的两个例子

在我们深入研究图之前,介绍一些基本的术语和概念是有用的。

img/465726_1_En_17_Fig5_HTML.jpg

图 17-5

B 上有圈的图

  • 循环图:如果一个有向图有一条从顶点到自身的路径,那么这个有向图被认为是循环的。例如,在图 17-5 中,B 可以沿着边到 C,然后 D,然后 E,然后再到 B。

img/465726_1_En_17_Fig4_HTML.jpg

图 17-4

稠密图

  • 稠密图:当不同顶点之间有大量连接时,一个图被认为是稠密的(见图 17-4 )。

img/465726_1_En_17_Fig3_HTML.jpg

图 17-3

稀疏图

  • 顶点的度数:顶点的度数是指该顶点(节点)上的边数。

  • 稀疏图:当顶点之间只存在一小部分可能的连接时,一个图被认为是稀疏的(见图 17-3 )。

img/465726_1_En_17_Fig2_HTML.jpg

图 17-2

顶点和边

  • 顶点:顶点是构成图的节点。在这一章中,对于 Big-O 分析,节点将被标注为 V 。顶点用圆来表示,如图 17-2 所示。

  • :边是图中节点之间的连接。从图上看,它是顶点之间的“线”。对于 Big-O 分析,它将被标记为 E 。用线条表示一条边,如图 17-2 所示。

相比之下,图 17-6 是非循环图的一个例子。

img/465726_1_En_17_Fig6_HTML.jpg

图 17-6

无圈图

img/465726_1_En_17_Fig7_HTML.jpg

图 17-7

带权重的有向图

  • 权重:权重是边上的值。根据上下文,权重可以表示各种事物。例如,有向图上的权重可以表示从节点 A 到 B 所需的距离,如图 17-7 所示。

无向图

无向图是边之间没有方向的图。边意味着两个节点之间没有方向的相互连接。无向图关系的一个真实例子是友谊。只有双方都承认这种关系,友谊才会产生。友谊图内的边的值可以指示友谊有多近。图 17-8 是一个简单的无向图,有五个顶点和六条带权的无向边。

img/465726_1_En_17_Fig8_HTML.jpg

图 17-8

带权重的无向图

有多种方法可以将无向图示为数据结构类。两种最常见的方法是使用邻接矩阵邻接表。邻接表使用顶点作为节点的关键字,其邻居存储在列表中,而邻接矩阵是 V 乘 V 矩阵,矩阵的每个元素指示两个顶点之间的连接。图 17-9 说明了邻接表和邻接矩阵的区别(这本书只涉及邻接表)。

img/465726_1_En_17_Fig9_HTML.jpg

图 17-9

图(左)、邻接表(中)和邻接矩阵(右)

到目前为止,已经讨论了图的概念和定义。现在,让我们实际开始在代码中实现这些想法,并学习如何添加和删除边和顶点。

添加边和顶点

在这个例子中,我们创建了一个加权的无向图并添加了顶点和边。首先,我们将为无向图创建一个新类。无向图应该有一个对象来存储边。这是如下面的代码块所示实现的:

 1   function UndirectedGraph() {
 2       this.edges = {};
 3   }

要添加边,必须先添加顶点(节点)。该实现将采用邻接表方法,将顶点作为存储边值的this.edges对象内的对象。

 1   UndirectedGraph.prototype.addVertex = function(vertex) {
 2       this.edges[vertex] = {};
 3   }

为了将加权边添加到无向图中,this.edges对象中的两个顶点都用于设置权重。

 1   UndirectedGraph.prototype.addEdge = function(vertex1,vertex2, weight) {
 2       if (weight == undefined) {
 3           weight = 0;
 4       }
 5       this.edges[vertex1][vertex2] = weight;
 6       this.edges[vertex2][vertex1] = weight;
 7  }

这样,让我们用下面的代码添加一些顶点和边:

 1   var graph1 = new UndirectedGraph();
 2   graph1.addVertex(1);
 3   graph1.addVertex(2);
 4   graph1.addEdge(1,2, 1);
 5   graph1.edges;   // 1: {2: 0},  2: {1: 0}
 6   graph1.addVertex(3);
 7   graph1.addVertex(4);
 8   graph1.addVertex(5);
 9   graph1.addEdge(2,3, 8);
10   graph1.addEdge(3,4, 10);
11   graph1.addEdge(4,5, 100);
12  graph1.addEdge(1,5, 88);

图 17-10 显示了该代码的图输出。

img/465726_1_En_17_Fig10_HTML.jpg

图 17-10

第一个无向图

移除边和顶点

继续同一个例子,让我们实现移除 graph 类的边和顶点的函数。

要从顶点移除边,在this.edges中查找该顶点的边对象,并使用 JavaScript 的delete操作符将其删除。

 1   UndirectedGraph.prototype.removeEdge = function(vertex1, vertex2) {
 2       if (this.edges[vertex1] && this.edges[vertex1][vertex2] != undefined) {
 3           delete this.edges[vertex1][vertex2];
 4       }
 5       if (this.edges[vertex2] && this.edges[vertex2][vertex1] != undefined) {
 6           delete this.edges[vertex2][vertex1];
 7       }
 8   }

接下来,让我们删除整个顶点。需要记住的重要一点是,任何时候一个顶点被移除,所有连接到它的边也必须被移除。这可以使用循环来完成,如以下实现所示:

 1   UndirectedGraph.prototype.removeVertex = function(vertex) {
 2       for (var adjacentVertex in this.edges[vertex]) {
 3           this.removeEdge(adjacentVertex, vertex);
 4       }
 5       delete this.edges[vertex];
 6   }

现在实现了删除,让我们创建另一个无向图对象,类似于第一个例子,但删除一些顶点和边。先去掉顶点 5,结果如图 17-11 所示。顶点 1 也被删除,如图 17-12 所示。最后,图 17-13 显示了移除 2 和 3 之间的边缘时的结果。

img/465726_1_En_17_Fig13_HTML.png

图 17-13

2 和 3 之间的边缘已移除

img/465726_1_En_17_Fig12_HTML.png

图 17-12

顶点 1 已移除

img/465726_1_En_17_Fig11_HTML.jpg

图 17-11

移除顶点 5

 1   var graph2 = new UndirectedGraph();
 2   graph2.addVertex(1);
 3   graph2.addVertex(2);
 4   graph2.addEdge(1,2, 1);
 5   graph2.edges;   // 1: {2: 0},  2: {1: 0}
 6   graph2.addVertex(3);
 7   graph2.addVertex(4);
 8   graph2.addVertex(5);
 9   graph2.addEdge(2,3, 8);
10   graph2.addEdge(3,4, 10);
11   graph2.addEdge(4,5, 100);
12   graph2.addEdge(1,5, 88);
13   graph2.removeVertex(5);
14   graph2.removeVertex(1);
15   graph2.removeEdge(2,3);

有向图

有向图是指在顶点之间有方向的图。有向图中的每条边从一个顶点到另一个顶点,如图 17-14 所示。

img/465726_1_En_17_Fig14_HTML.jpg

图 17-14

有向图

在本例中,E 节点可以“行进”到 D 节点,而 D 节点只能行进到 C 节点。

现在让我们实现一个加权有向图类。将使用无向图实现中使用的类似邻接表方法。首先用如图所示的edges属性定义了DirectedGraph类,添加顶点的方法与从无向图类实现的方法相同。

 1   function DirectedGraph() {
 2       this.edges = {};
 3   }
 4   DirectedGraph.prototype.addVertex = function (vertex) {
 5       this.edges[vertex] = {};
 6   }

给定一条起始于原点并终止于目的顶点的边,要将边添加到有向图中,权重应仅设置在原点上,如下所示:

 1   DirectedGraph.prototype.addEdge = function(origVertex, destVertex, weight) {
 2       if (weight === undefined) {
 3           weight = 0;
 4       }
 5       this.edges[origVertex][destVertex] = weight;
 6   }

实现了添加顶点和边的函数后,让我们添加一些示例顶点和边。

 1   var digraph1 = new DirectedGraph();
 2   digraph1.addVertex("A");
 3   digraph1.addVertex("B");
 4   digraph1.addVertex("C");
 5   digraph1.addEdge("A", "B", 1);
 6   digraph1.addEdge("B", "C", 2);
 7   digraph1.addEdge("C", "A", 3);

图 17-15 显示了添加在 A 和 B 顶点之间的边(第 5 行)。图 17-16 表示 B 和 C 之间的连接(6 号线),图 17-17 表示 C 和 A 之间的连接(7 号线)。

img/465726_1_En_17_Fig17_HTML.png

图 17-17

将 C 添加到 A

img/465726_1_En_17_Fig16_HTML.png

图 17-16

将 B 添加到 C

img/465726_1_En_17_Fig15_HTML.png

图 17-15

将 A 添加到 B

有向图中删除顶点和删除边的实现与无向图中看到的实现相同,只是必须删除edges对象中的原始顶点,如下所示:

 1   DirectedGraph.prototype.removeEdge = function(origVertex, destVertex) {
 2       if (this.edges[origVertex] && this.edges[origVertex][destVertex] != undefined) {
 3           delete this.edges[origVertex][destVertex];
 4       }
 5   }
 6
 7   DirectedGraph.prototype.removeVertex = function(vertex) {
 8       for (var adjacentVertex in this.edges[vertex]) {
 9           this.removeEdge(adjacentVertex, vertex);
10       }
11       delete this.edges[vertex];
12   }

图遍历

一个图可以用多种方式遍历。两种最常见的方法是广度优先搜索和深度优先搜索。类似于如何探索不同的树遍历技术,这一节将集中讨论这两种遍历技术以及何时使用它们中的每一种。

广度优先搜索

广度优先搜索 (BFS)指的是一种在图中的搜索算法,按顺序聚焦于连通节点及其连通节点。这个想法实际上已经在第十五章中用层次顺序遍历的树进行了探索。图 17-18 显示了二叉查找树的层次顺序遍历。

img/465726_1_En_17_Fig18_HTML.jpg

图 17-18

二叉查找树的层次顺序遍历

请注意,遍历的顺序是根据从根节点算起的高度来确定的。注意与图 17-19 中的图相似。

img/465726_1_En_17_Fig19_HTML.jpg

图 17-19

广度优先搜索图

类似于树形数据结构的层次顺序遍历,BFS 需要一个队列。

对于每个节点,将每个连接的顶点添加到队列中,然后访问队列中的每个项目。让我们为图类编写一个通用的 BFS 算法。

 1   DirectedGraph.prototype.traverseBFS = function(vertex, fn) {
 2       var queue = [],
 3          visited = {};
 4
 5       queue.push(vertex);
 6
 7       while (queue.length) {
 8           vertex = queue.shift();
 9           if (!visited[vertex]) {
10               visited[vertex] = true;
11               fn(vertex);
12               for (var adjacentVertex in this.edges[vertex]) {
13                   queue.push(adjacentVertex);
14               }
15           }
16       }
17   }
18   digraph1.traverseBFS("B", (vertex)=>{console.log(vertex)});

时间复杂度: O( V + E

时间复杂度为 O( V + E ,其中 V 为顶点数, E 为边数。这是因为该算法必须遍历整个图的每个边和节点。

回忆一下本章前面使用的“无向图”中图 17-20 的图结构。

img/465726_1_En_17_Fig20_HTML.jpg

图 17-20

之前的无向图示例

将 BFS 应用于图,会打印出以下内容:1,2,5,3,4。

在图 17-21 和 17-22 中,浅阴影节点表示当前正在访问的节点,而深色节点表示该节点已经被访问过。

img/465726_1_En_17_Fig21_HTML.jpg

图 17-21

广度优先搜索,第一部分

在图 17-21 中,广度优先搜索从 1 节点开始。因为它有两个邻居 2 和 5,所以它们被添加到队列中。然后,2 被访问,它的邻居 3 被添加到队列中。5 然后出列,并且它的邻居 4 被添加到队列中。最后访问 3 和 4,搜索结束,如图 17-22 所示。

img/465726_1_En_17_Fig22_HTML.jpg

图 17-22

广度优先搜索,第二部分

深度优先搜索

深度优先搜索 (DFS)指的是图中的一种搜索算法,它专注于在访问其他连接之前深入遍历一个连接。

这个想法已经在第十五章中用树中的顺序、后顺序和前顺序遍历进行了探讨。例如,后序树遍历在访问顶部根节点之前访问底部子节点(见图 17-23 )。

img/465726_1_En_17_Fig23_HTML.jpg

图 17-23

后序遍历

类似的情况如图 17-24 所示。

img/465726_1_En_17_Fig24_HTML.jpg

图 17-24

深度优先搜索图

注意最后 E 是如何被访问的。这是因为搜索在访问 e 之前访问了在深度连接到 C 的所有节点。

类似于树数据结构的前置后置和有序遍历,递归被用于深入到节点中,直到该路径被用尽。

让我们为 graph 类编写一个通用的 DFS 算法。

 1   DirectedGraph.prototype.traverseDFS = function(vertex, fn) {
 2      var visited = {};
 3      this._traverseDFS(vertex, visited, fn);
 4   }
 5
 6   DirectedGraph.prototype._traverseDFS = function(vertex, visited, fn) {
 7       visited[vertex] = true;
 8       fn(vertex);
 9       for (var adjacentVertex in this.edges[vertex]) {
10           if (!visited[adjacentVertex]) {
11               this._traverseDFS(adjacentVertex, visited, fn);
12           }
13       }
14   }

时间复杂度: O( V + E

时间复杂度为 O( V + E ),其中 V 为顶点数, E 为边数。这是因为该算法必须遍历整个图的每个边和节点。这与 BFS 算法的时间复杂度相同。

同样,让我们使用本章前面的图结构(见图 17-25 )。

img/465726_1_En_17_Fig25_HTML.jpg

图 17-25

图 17-20 中的早期图示例

将 DFS 应用于图,将打印出以下内容:1,2,3,4,5。

在图 17-26 和 17-27 中,浅阴影节点表示当前正在访问的节点,而深色节点表示该节点已经被访问过。

img/465726_1_En_17_Fig26_HTML.jpg

图 17-26

深度优先搜索,第一部分

在图 17-26 中,深度优先搜索从 1 节点开始。它的第一个邻居 2 被访问。然后,2 的第一个邻居 3 被访问。在访问 3 之后,下一个将访问 4,因为它是 3 的第一个邻居。最后,4 被访问,然后是 5,如图 17-27 所示。深度优先搜索总是递归地访问第一个邻居。

img/465726_1_En_17_Fig27_HTML.jpg

图 17-27

深度优先搜索,第二部分

加权图和最短路径

既然我们已经介绍了图的基本知识以及如何遍历它们,我们可以讨论加权边和 Dijkstra 算法,它使用最短路径搜索。

带权边的图

回想一下,图中的边表示顶点之间的连接。如果边建立了连接,则权重可以被分配给该连接。例如,对于表示地图的图,边上的权重是距离。

重要的是要注意,一条边的长度对于该边的重量没有任何意义。它纯粹是为了视觉目的。在实现和代码中,不需要可视化表示。在图 17-28 中,权重告诉我们五个城市的图表示中城市之间的距离。例如,从图上看,从城市 1 到城市 2 的距离比从城市 2 到城市 3 的距离短。然而,边缘表明从城市 1 到城市 2 的距离是 50 公里,从城市 2 到城市 3 的距离是 10 公里,这是 5 倍大。

img/465726_1_En_17_Fig28_HTML.jpg

图 17-28

五个城市的图表示

加权边图最重要的问题是,从一个节点到另一个节点的最短路径是什么?图的最短路径算法有一系列。我们讨论的是 Dijkstra 算法。

Dijkstra 算法:最短路径

Dijkstra 算法的工作原理是在每一层选择最短的路径到达目的地。起初,距离被标记为无穷大,因为一些节点可能无法到达(见图 17-29 )。然后在每次遍历迭代中,为每个节点选择最短的距离(见图 17-30 和 17-31 )。

img/465726_1_En_17_Fig31_HTML.jpg

图 17-31

Dijkstra 阶段 3:现在已处理所有节点

img/465726_1_En_17_Fig30_HTML.jpg

图 17-30

Dijkstra 阶段 2: B 和 C 已处理

img/465726_1_En_17_Fig29_HTML.jpg

图 17-29

Dijkstra 阶段 1:所有标记为无穷大的东西

_extractMin用于计算给定顶点的距离最小的相邻节点。当从起点到目的地节点遍历图时,使用广度优先搜索大纲将每个顶点的相邻节点排队,更新并计算距离。

 1  function _isEmpty(obj) {
 2       return Object.keys(obj).length === 0;
 3  }
 4
 5   function _extractMin(Q, dist) {
 6       var minimumDistance = Infinity,
 7           nodeWithMinimumDistance = null;
 8       for (var node in Q) {
 9           if (dist[node] <= minimumDistance) {
10               minimumDistance = dist[node];
11               nodeWithMinimumDistance = node;
12           }
13       }
14       return nodeWithMinimumDistance;
15   }
16
17   DirectedGraph.prototype.Dijkstra = function(source) {
18       // create vertex set Q
19       var Q = {}, dist = {};
20       for (var vertex in this.edges) {
21           // unknown distances set to Infinity
22           dist[vertex] = Infinity;
23           // add v to Q
24           Q[vertex] = this.edges[vertex];
25       }
26       // Distance from source to source init to 0
27       dist[source] = 0;
28
29      while (!_isEmpty(Q)) {
30           var u = _extractMin(Q, dist); // get the min distance
31
32           // remove u from Q
33           delete Q[u];
34
35           // for each neighbor, v, of u:
36           // where v is still in Q.
37           for (var neighbor in this.edges[u]) {
38               // current distance
39               var alt = dist[u] + this.edges[u][neighbor];
40               // a shorter path has been found
41               if (alt < dist[neighbor]) {
42                   dist[neighbor] = alt;
43               }
44           }
45       }
46       return dist;
47   }
48
49   var digraph1 = new DirectedGraph();
50   digraph1.addVertex("A");
51   digraph1.addVertex("B");
52   digraph1.addVertex("C");
53   digraph1.addVertex("D");
54   digraph1.addEdge("A", "B", 1);
55   digraph1.addEdge("B", "C", 1);
56   digraph1.addEdge("C", "A", 1);
57   digraph1.addEdge("A", "D", 1);
58   console.log(digraph1);
59   // DirectedGraph {
60   // V: 4,
61   // E: 4,
62   // edges: { A: { B: 1, D: 1 }, B: { C: 1 }, C: { A: 1 }, D: {} }}
63   digraph1.Dijkstra("A"); // { A: 0, B: 1, C: 2, D: 1 }

**时间复杂度:**O(V2+E)

这里的算法类似于 BFS 算法,但是需要使用时间复杂度为 O( n )的_extractMin方法。正因为如此,时间复杂度为 O(V2+E),因为在_extractMin方法中必须检查当前被遍历节点的所有邻居顶点。可以使用提取 min 的优先级队列来改进该算法,这将产生 O(log2(V)_extractMin,并且因此产生 O(E+V)** O*(log2(V))= O()这甚至可以通过使用 Fibonacci 堆来优化,Fibonacci 堆有固定的时间来计算_extractMin。然而,为了简单起见,在这个演示中既没有使用 Fibonacci 堆,也没有使用优先级队列。

拓扑排序

对于有向图,对于各种应用程序,知道应该首先处理哪个节点是很重要的。这方面的一个例子是任务调度器,其中一个任务依赖于前一个正在完成的任务。另一个例子是 JavaScript 库依赖管理器,它必须确定在其他库之前导入哪些库。拓扑排序算法实现了这一点。它是一种改进的 DFS,使用栈来记录顺序。

简而言之,它的工作方式是从一个节点执行 DFS,直到其连接的节点被递归耗尽,并将其添加到栈,直到所有连接的节点都被访问(见图 17-32 )。

img/465726_1_En_17_Fig32_HTML.jpg

图 17-32

拓扑排序

拓扑排序有一个被访问的集合,以确保递归调用不会导致无限循环。对于给定的节点,该节点被添加到已访问过的集合中,并且在下一次递归调用中访问其未被访问过的邻居。递归调用结束时,使用 unshift 将当前节点的值添加到栈中。这确保了顺序是按时间顺序的。

 1   DirectedGraph.prototype.topologicalSortUtil = function(v, visited, stack) {
 2      visited.add(v);
 3
 4      for (var item in this.edges[v]) {
 5           if (visited.has(item) == false) {
 6               this.topologicalSortUtil(item, visited, stack)
 7          }
 8       }
 9       stack.unshift(v);
10   };
11
12   DirectedGraph.prototype.topologicalSort = function() {
13       var visited = {},
14           stack = [];
15
16
17       for (var item in this.edges) {
18           if (visited.has(item) == false) {
19              this.topologicalSortUtil(item, visited, stack);
20           }
21       }
22       return stack;
23   };
24
25   var g = new DirectedGraph()

;
26   g.addVertex('A');
27   g.addVertex('B');
28   g.addVertex('C');
29   g.addVertex('D');
30   g.addVertex('E');
31   g.addVertex('F');
32
33   g.addEdge('B', 'A');
34   g.addEdge('D', 'C');
35   g.addEdge('D', 'B');
36   g.addEdge('B', 'A');
37   g.addEdge('A', 'F');
38   g.addEdge('E', 'C');
39   var topologicalOrder = g.topologicalSort();
40   console.log(g);
41   // DirectedGraph {
42   // V: 6,
43   // E: 6,
44   // edges:
45   //  { A: { F: 0 },
46   //    B: { A: 0 },
47   //    C: {},
48   //    D: { C: 0, B: 0 },
49   //    E: { C: 0 },
50   //    F: {} } }
51   console.log(topologicalOrder); // [ 'E', 'D', 'C', 'B', 'A', 'F' ]

时间复杂度: O( V + E

空间复杂度: O( V

拓扑排序算法就是带有额外栈的 DFS。因此,时间复杂度与 DFS 相同。拓扑排序在空间上需要 O( V ),因为它需要存储栈中的所有顶点。这种算法对于根据给定的依赖关系调度作业是非常有效的。

摘要

本章讨论了不同类型的图、它们的属性以及如何对它们进行搜索和排序。由顶点组成并通过边连接的图可以用许多不同的方式表示为数据结构。在这一章中,邻接表被用来表示图。如果图是密集的,最好使用基于矩阵的图表示。在图的边中,权重表示相连顶点的重要性(或不重要)。此外,通过给边分配权重,实现了 Dijkstra 的最短路径算法。最后,图是具有各种用例和有趣算法的通用数据结构。

表 17-2 显示了图的一些关键属性。

表 17-2

图属性摘要

|

财产

|

描述

| | --- | --- | | 稠密的 | 不同顶点之间有很多联系。 | | 稀少的 | 顶点之间只存在一小部分可能的连接。 | | 周期的 | 有一条路径将顶点带回到它们自己。 | | 传阅的 | 没有路径可以让顶点回到它们自己。 | | 定向的 | 图在边之间有一个方向。 | | 未受指导的 | 图在边之间没有方向。 |

表 17-3 总结了图算法。

表 17-3

图算法概述

|

算法

|

描述/使用案例

|

时间复杂度

| | --- | --- | --- | | 宽度优先搜索 | 通过一次访问一级邻居节点来遍历图 | O( V + E ) | | 深度优先搜索 | 通过一次深入一个邻居节点来遍历图 | O( V + E ) | | 最短路径 | 查找从一个顶点到其他顶点的最短路径 | o(V2T5*+E* | | 拓扑排序 | 对有向图进行排序;对于作业调度算法 | O( V + E ) |

十八、高级字符串

本章将涵盖比前几章讨论的更高级的字符串算法。既然您已经了解了其他一些数据结构,它们应该更容易理解。具体来说,本章将集中讨论字符串搜索算法。

前缀树(Prefix Tree)

trie 是一种特殊类型的树,通常用于搜索字符串和匹配存储的字符串。在每一层,节点可以分支形成完整的单词。例如,图 18-1 显示了一个单词的 trie:余思敏西姆兰西亚萨姆。每个结束节点都有一个布尔标志:isCompleted。这表示该单词以此路径结束。例如, Sam 中的 mendOfWord设置为trueendOfWord设置为true的节点在图 18-1 中用阴影表示。

img/465726_1_En_18_Fig1_HTML.jpg

图 18-1

余思敏、西姆兰、新加坡、萨姆

trie 是使用嵌套对象实现的,其中每一层都有它的直接子对象作为键。可以通过使用对象存储子节点来形成 trie 节点。trie 有一个根节点,它在Trie类的构造函数中被实例化,如下面的代码块所示:

 1   function TrieNode() {
 2       this.children = {}; // table
 3       this.endOfWord = false;
 4   }
 5
 6   function Trie() {
 7       this.root = new TrieNode();
 8    }

为了插入到 trie 中,如果子 trie 节点不存在,则在根节点上创建它。对于要插入的单词中的每个字符,如果该字符不存在,它会创建一个子节点,如下面的代码块所示:

 1   Trie.prototype.insert = function(word) {
 2       var current = this.root;
 3       for (var i = 0; i < word.length; i++) {
 4           var ch = word.charAt(i);
 5           var node = current.children[ch];
 6           if (node == null) {
 7               node = new TrieNode();
 8               current.children[ch] = node;
 9           }
10           current = node;
11       }
12       current.endOfWord = true; //mark the current nodes endOfWord as true
13   }

要在 trie 中搜索,必须检查单词的每个字符。这是通过在根上设置一个临时变量current来实现的。当单词中的每个字符被检查时,current变量被更新。

 1   Trie.prototype.search = function(word) {
 2       var current = this.root;
 3       for (var i = 0; i < word.length; i++) {
 4           var ch = word.charAt(i);
 5           var node = current.children[ch];
 6           if (node == null) {
 7               return false; // node doesn't exist
 8           }
 9           current = node;
10       }
11       return current.endOfWord;
12   }
13   var trie = new Trie();
14   trie.insert("sammie");
15   trie.insert("simran");
16   trie.search("simran"); // true
17   trie.search("fake") // false
18   trie.search("sam") // false

要从 trie 中删除一个元素,算法应该遍历根节点,直到到达单词的最后一个字符。然后,对于没有任何其他子节点的每个节点,应该删除该节点。例如,在一个有 samsim 的 trie 中,当 sim 被删除时,根中的 s 节点保持不变,但是 im 被删除。以下代码块中的递归实现实现了该算法:

 1   Trie.prototype.delete = function(word) {
 2       this.deleteRecursively(this.root, word, 0);
 3   }
 4
 5   Trie.prototype.deleteRecursively = function(current, word, index) {
 6       if (index == word.length) {
 7           //when end of word is reached only delete if currrent.end Of Word is true.
 8           if (!current.endOfWord) {
 9               return false;
10           }
11           current.endOfWord = false; 

12           //if current has no other mapping then return true
13           return Object.keys(current.children).length == 0;
14       }
15       var ch = word.charAt(index),
16           node = current.children[ch];
17       if (node == null) {
18           return false;
19       }
20       var shouldDeleteCurrentNode = this.deleteRecursively(node, word, index + 1);
21
22       // if true is returned then
23       // delete the mapping of character and trienode reference from map.
24       if (shouldDeleteCurrentNode) {
25           delete current.children[ch];
26           //return true if no mappings are left in the map.
27           return Object.keys(current.children).length == 0;
28       }
29       return false; 

30   }
31   var trie1 = new Trie();
32   trie1.insert("sammie");
33   trie1.insert("simran");
34   trie1.search("simran"); // true
35   trie1.delete("sammie");
36   trie1.delete("simran");
37   trie1.search("sammie"); // false
38   trie1.search("simran"); // false

时间复杂度: O( W

空间复杂度: O( NM*

所有操作(插入、搜索、删除)的时间复杂度都是 O( W ),其中 W 是被搜索字符串的长度,因为字符串中的每个字符都被检查。空间复杂度为 O( NM* ,其中 N 为插入 trie 的字数, M 为最长字符的长度。因此,当有多个具有共同前缀的字符串时,trie 是一种有效的数据结构。为了在一个特定的字符串中搜索一个特定的字符串模式,trie 并不是高效的,因为需要额外的内存来存储树状结构中的字符串。

对于单个目标字符串中的模式搜索,Boyer-Moore 算法和 Knuth-Morris-Pratt(KMP)算法很有用,将在本章后面介绍。

boyer-Moore 字符串搜索

Boyer-Moore 字符串搜索算法用于支持文本编辑器应用程序和网络浏览器中的“查找”工具,如图 18-2 中的工具。

img/465726_1_En_18_Fig2_HTML.jpg

图 18-2

查找许多应用程序中常见的工具

Boyer–Moore 字符串搜索算法通过在字符串中搜索模式时跳过索引,允许线性时间搜索。例如图案果酱和串果冻果酱的暴力对比可视化如图 18-3 所示。应当注意,在第四次迭代中,当 jm 进行比较时,由于 j 在模式中示出,向前跳 2 将是有效的。图 18-4 显示了一个优化的迭代周期,当模式中存在索引处的字符串时,通过向前跳跃来限制字符串比较的次数。

img/465726_1_En_18_Fig4_HTML.jpg

图 18-4

boyer-Moore 跳过指数

img/465726_1_En_18_Fig3_HTML.jpg

图 18-3

强力模式匹配迭代

要实现这个跳过规则,您可以构建一个“坏匹配表”结构。不良匹配表指示对于模式的给定字符要跳过多少。各种模式及其对应的不良匹配表的一些示例如下所示:

|

模式

|

错误的匹配表

| | --- | --- | | jam | {j: 2, a: 1, m: 3} | | 数据 | {d: 3, a: 2, t: 1} | | 结构 | {s: 5, t: 4, r: 3, u: 2, c: 1} | | 国王 | {r: 2, o: 1, i: 3} |

对于 roi 的例子,r:2表示如果在字符串中没有找到r,则索引应该跳过 2。这个不良匹配表可以用下面的代码块实现:

function buildBadMatchTable(str) {
    var tableObj = {},
        strLength = str.length;
    for (var i = 0; i <  strLength - 1; i++) {
        tableObj[str[i]] = strLength - 1 - i;
    }
    if (tableObj[str[strLength-1]] == undefined) {
        tableObj[str[strLength-1]] = strLength;
    }
    return tableObj;
}
buildBadMatchTable('data');     // {d: 3, a: 2, t: 1}
buildBadMatchTable('struct');   // {s: 5, t: 4, r: 3, u: 2, c: 1}
buildBadMatchTable('roi');      // {r: 2, o: 1, i: 3}
buildBadMatchTable('jam');      // {j: 2, a: 1, m: 3}

使用这个坏匹配表,可以实现 Boyer-Moore 字符串搜索算法。在扫描模式的输入字符串时,如果正在查看的当前字符串存在于不良匹配表中,则跳过与当前字符串相关联的不良匹配表值。否则,它将递增 1。这种情况一直持续到找到字符串或者索引大于模式和字符串长度之差。这在下面的代码块中实现:

function boyerMoore(str, pattern) {
    var badMatchTable = buildBadMatchTable(pattern),
        offset = 0,
        patternLastIndex = pattern.length - 1,
        scanIndex = patternLastIndex,
        maxOffset = str.length - pattern.length;

    // if the offset is bigger than maxOffset, cannot be found
    while (offset <= maxOffset) {
        scanIndex = 0;
        while (pattern[scanIndex] == str[scanIndex + offset]) {
            if (scanIndex == patternLastIndex) {
                // found at this index
                return offset;
            }
            scanIndex++;
        }
        var badMatchString = str[offset + patternLastIndex];
        if (badMatchTable[badMatchString]) {
            // increase the offset if it exists
            offset += badMatchTable[badMatchString]
        }  else {
            offset += 1;
        }
    }
    return -1;
}
boyerMoore('jellyjam','jelly');  // 5\. indicates that the pattern starts at index 5
boyerMoore('jellyjam','jelly');  // 0\. indicates that the pattern starts at index 0
boyerMoore('jellyjam','sam');    // -1\. indicates that the pattern does not exist

最佳情况:

在最好的情况下,模式中的所有字符都是相同的,这持续地产生移位 T ,其中 T 是模式的长度。因此,O( W/T )是最佳时间复杂度,其中 W 是正在搜索模式的字符串。空间复杂度是 O( 1 ),因为只有 1 个值被存储到不良匹配表中。

时间复杂度: O( T/W

空间复杂度: O( 1

最坏情况:

在最坏的情况下,字符串的末尾是模式,前面的部分都是唯一的字符。这样的一个例子是一串 abcdefgxyz 和模式 xyz 。在这种情况下, TW* 字符串比较就完成了。

时间复杂度: O( TW*

空间复杂度: O( T

模式和字符串中的所有字符都是相同的。这种情况的一个例子是字符串 bbbbbb 和模式 bbb 。在这种情况下,不能最大限度地使用跳过机制,因为索引将总是递增 1。在这种情况下,空间复杂度是 T ,因为模式可以包含所有独特的字符。

knuth–Morris–Pratt 字符串搜索

第四章讨论了原生String.prototype.indexOf函数。一个简单的String.prototype.indexOf函数的实现被作为那一章的练习。一个更好的(更快的)实现使用 Knuth–Morris–Pratt(KMP)字符串搜索算法。KMP 算法的以下实现返回出现该模式的所有索引。

KMP 字符串搜索算法通过观察到不匹配的出现包含关于下一个匹配可以从哪里开始的足够信息,来搜索“单词” W 在输入“文本”中的出现,即 T 。这有助于跳过对先前匹配字符的重新检查。必须构建一个前缀数组来指示它必须回溯多少个索引才能得到相同的前缀。对于字符串 ababaca ,前缀 building 如下所示:

在当前索引 0 处,没有要比较的字符串,前缀数组值被初始化为 0。

  • 数组索引 0 1 2 3 4 5 6

  • 字符 a b a b a c a

  • 前缀数组 0

在当前索引 1 :

  • 人物是b

  • 前一个前缀数组值prefix[0]为 0。

将索引 0 与当前索引进行比较: a (索引= 0 时)和 b (索引= 1 时)不匹配。

prefix[1]设置为 0:

  • 数组索引 0 1 2 3 4 5 6

  • 字符 a b a b a c a

  • 前缀数组 0 0

当前索引 2 :

  • 人物是一个

  • 前一个前缀数组值prefix[1]为 0。

将索引与当前索引进行比较: a (索引= 0 时)和 a (索引= 2 时)匹配。

prefix[2]设置为 1(从prefix[1]开始递增):

  • 数组索引 0 1 2 3 4 5 6

  • 字符 a b a b a c a

  • 前缀数组 0 0 1

在当前索引 3 :

  • 人物是 b

  • 前一个前缀数组值prefix[2]为 1。

比较索引 1 和当前索引: b (索引= 1 时)和 b (索引= 3 时)匹配。

prefix[3]设置为 2(从prefix[2]开始递增):

  • 数组索引 0 1 2 3 4 5 6

  • 字符 a b a b a c a

  • 前缀数组 0 0 1 2

在当前索引 4 :

  • 人物是一个

  • 前一个前缀数组值prefix[3]是 2。

比较索引 2 和当前索引: a (索引= 2 时)和 a (索引= 4 时)匹配。

prefix[4]设置为 3(从prefix[3]开始递增):

  • 数组索引 0 1 2 3 4 5 6

  • 字符 a b a b a c a

  • 前缀数组 0 0 1 2 3

在当前索引 5 :

  • 人物是c

  • 前一个前缀数组值prefix[4]是 3。

比较索引 3 和当前索引: b (索引= 3 时)和 c (索引= 5 时)不匹配。

prefix[5]设置为 0:

  • 数组索引 0 1 2 3 4 5 6

  • 字符 a b a b a c a

  • 前缀数组 0 0 1 2 3 0

在当前索引 6 :

  • 人物是 c

  • 前一个前缀数组值prefix[5]为 0。

从索引 0 和当前索引比较: a (索引= 0 时)和 a (索引= 5 时)匹配。

prefix[6]设置为 1(从prefix[5]开始递增):

  • 数组索引 0 1 2 3 4 5 6

  • 字符 a b a b a c a

  • 前缀数组 0 0 1 2 3 0 1

以下代码块中的函数说明了构建前缀表的算法:

function longestPrefix(str) {
    // prefix array is created
    var prefix = new Array(str.length);
    var maxPrefix = 0;
    // start the prefix at 0
    prefix[0] = 0;
    for (var i = 1; i < str.length; i++) {
        // decrement the prefix value as long as there are mismatches
        while (str.charAt(i) !== str.charAt(maxPrefix) && maxPrefix > 0) {
            maxPrefix = prefix[maxPrefix - 1];
        }
        // strings match, can update it
        if (str.charAt(maxPrefix) === str.charAt(i)) {
            maxPrefix++;
        }
        // set the prefix
        prefix[i] = maxPrefix;
    }
    return prefix;
}
console.log(longestPrefix('ababaca')); // [0, 0, 1, 2, 3, 0, 1]

现在有了这个前缀表,KMP 就可以实现了。KMP 搜索逐个索引地遍历要搜索的字符串和模式。每当出现不匹配时,它可以使用前缀表来计算一个新的索引进行尝试。当模式的索引达到模式的长度时,就找到了字符串。这在下面的代码块中详细实现:

function KMP(str, pattern) {
    // build the prefix table
    var prefixTable = longestPrefix(pattern),
        patternIndex = 0,
        strIndex = 0;

    while (strIndex < str.length) {
        if (str.charAt(strIndex) != pattern.charAt(patternIndex)) {
            // Case 1: the characters are different

            if (patternIndex != 0) {
                // use the prefix table if possible
                patternIndex = prefixTable[patternIndex - 1];
            } else {
                // increment the str index to next character
                strIndex++;
            }

        } else if (str.charAt(strIndex) == pattern.charAt(patternIndex)) {
            // Case 2: the characters are same
            strIndex++;
            patternIndex++;
        }

        // found the pattern
        if (patternIndex == pattern.length) {
            return true
        }
    }
    return false;
}
KMP('ababacaababacaababacaababaca', 'ababaca'); //  true
KMP('sammiebae', 'bae'); //  true
KMP('sammiebae', 'sammie'); //  true
KMP('sammiebae', 'sammiebaee'); // false

时间复杂度: O( W

空间复杂度: O( W

预处理长度为 W 的单词需要 O( W )的时间和空间复杂度。

时间复杂度: O( W + T

这里, WT (被搜索的主字符串)中的“单词”。

拉宾-卡普搜索

Rabin–Karp 算法基于哈希算法来查找文本中的指定模式。虽然 KMP 被优化为在搜索过程中跳过冗余检查,但拉宾-卡普试图通过散列函数来加速子串模式的相等。为了有效地做到这一点,散列函数必须是 O(1)。特别是对于 Rabin-Karp 搜索,使用 Rabin 指纹散列技术。

拉宾指纹

拉宾指纹是通过下面的等式计算的:f(x)= m0+m1x+…+mn-1xn-1其中 n 是被散列的字符数,而 x 是某个质数。

这是一个简单的实现,如下面的代码块所示。在这个例子中,101 是一个任意的质数。在这种情况下,任何高素数都应该工作良好。但是,请注意,如果 x 太高,可能会导致整数溢出,因为 x n-1 增长很快。endLength参数指示散列应该计算到哪个字符串索引。如果参数没有通过,它应该默认为str的长度。

 1 function RabinKarpSearch() {
 2     this.prime = 101;
 3 }
 4 RabinKarpSearch.prototype.rabinkarpFingerprintHash = function (str, endLength) {
 5     if (endLength == null) endLength = str.length;
 6     var hashInt = 0;
 7     for (var i=0; i < endLength; i++) {
 8         hashInt += str.charCodeAt(i) * Math.pow(this.prime, i);
 9     }
10    return hashInt;
11 }
12 var rks = new RabinKarpSearch();
13 rks.rabinkarpFingerprintHash("sammie"); // 1072559917336
14 rks.rabinkarpFingerprintHash("zammie"); // 1072559917343

如前面的代码块结果所示,来自 sammiezammie 的散列是唯一的,因为它们是两个不同的字符串。哈希值允许您在固定时间内快速检查两个字符串是否相同。举个例子,让我们在中寻找 am。由于 am 只有两个字符长,当你扫描文本时, saamme同一个组成,计算散列如下:

 1   rks.rabinkarpFingerprintHash("sa"); // 9912
 2   rks.rabinkarpFingerprintHash("am"); // 11106
 3   rks.rabinkarpFingerprintHash("me"); // 10310

这是一个滑动哈希计算。如何高效地做到这一点?我们从数学上分析一下。回想一下,在这个例子中, x 是 101。另外, same 的字符代码分别为 115、97、109 和 101。

  • sa:f(x)= m0+m1x = 115+(97)*(101)= 9912

  • am:f(x)= m0+m1x = 97+(109)*(101)= 11106

  • me:f(x)= m0+m1x = 109+(101)*(101)= 10310

要得到从 saam 的哈希值,必须减去第一项,将余数除以质数,然后加上新项。此重新计算算法在以下代码块中实现:

1 RabinKarpSearch.prototype.recalculateHash = function (str, oldIndex, newIndex, oldHash, patternLength) {
2     if (patternLength == null) patternLength = str.length;
3     var newHash = oldHash - str.charCodeAt(oldIndex);
4     newHash = Math.floor(newHash/this.prime);
5     newHash += str.charCodeAt(newIndex) * Math.pow(this.prime, patternLength - 1);
6     return newHash;
7 }
8 var oldHash = rks.rabinkarpFingerprintHash("sa"); // 9912
9 rks.recalculateHash("same", 0, 2, oldHash, "sa".length); //  11106

最后,两个不同的字符串仍然可以有相同的哈希值,尽管这不太可能。因此,在给定两个字符串的起始索引和结束索引的情况下,需要一个函数来检查两个字符串是否相等。这在下面的代码块中实现:

 1 RabinKarpSearch.prototype.strEquals = function (str1, startIndex1, endIndex1,
 2                                                 str2, startIndex2, endIndex2) {
 3     if (endIndex1 - startIndex1 != endIndex2 - startIndex2) {
 4         return false;
 5     }
 6     while ( startIndex1 <= endIndex1
 7           && startIndex2 <= endIndex2) {
 8         if (str1[startIndex1] != str2[startIndex2]) {
 9             return false;
10         }
11         startIndex1++;
12         startIndex2++;
13     }
14     return true;
15 }

然后,通过计算起始散列,然后以滑动方式重新计算散列,直到找到模式或到达字符串末尾,来实现主要的 Rabin–Karp 搜索函数。

 1 RabinKarpSearch.prototype.rabinkarpSearch = function (str, pattern) {
 2     var T = str.length,
 3         W = pattern.length,
 4         patternHash = this.rabinkarpFingerprintHash(pattern, W),
 5         textHash = this.rabinkarpFingerprintHash(str, W);
 6
 7     for (var i = 1; i <= T - W + 1; i++) {
 8         if (patternHash == textHash &&
 9             this.strEquals(str, i - 1, i + W - 2, pattern, 0, W - 1)) {
10             return i - 1;
11         }
12         if (i < T - W + 1) {
13             textHash = this.recalculateHash(str, i - 1, i + W - 1, textHash, W);
14         }
15     }
16
17     return -1;
18 }
19
20 var rks = new RabinKarpSearch();
21 rks.rabinkarpSearch("SammieBae", "as"); // -1
22 rks.rabinkarpSearch("SammieBae", "Bae"); // 6
23 rks.rabinkarpSearch("SammieBae", "Sam"); // 0

预处理时间复杂度: O( W

预处理时间复杂度 W 是“单词”的长度

匹配时间复杂度: O( W + T

这个算法最多迭代长度 T 和长度 W 之和,其中 T 是要搜索的字符串。

现实生活中的应用

Rabin–Karp 算法可用于检测剽窃。对于源材料,该算法可以在提交的论文中搜索源材料中的短语和句子的实例(并通过在预处理阶段省略标点符号来忽略标点符号等语法细节)。这个问题对于单一搜索算法来说是不切实际的,因为有大量的搜索(输入)短语和句子。Rabin–Karp 算法也用于其他字符串匹配应用,例如在大量 DNA 数据中寻找特定序列。

摘要

这一章回到了字符串的主题,看了更高级的例子和字符串模式的搜索。本章讨论了几种不同的类型。

  • Trie 非常适合多重搜索和前缀模式匹配。

  • boyer–Moore 假设结尾没有匹配意味着不需要匹配开头,试图匹配模式的最后一个字符,而不是第一个字符;这允许较大的“跳跃”(索引之间的空格),当文本较大时效果更好。

  • KMP 算法通过观察当出现不匹配时,模式本身具有足够的信息来确定下一个匹配可能开始的字符串中的索引,从而在字符串中搜索模式的出现。因此,KMP 算法对小集合更好。

表 18-1 总结了不同的搜索算法。

表 18-1

单字符串搜索摘要

|

算法

|

预处理时间复杂度

|

匹配时间复杂度

|

空间复杂性

| | --- | --- | --- | --- | | 天真的 | 没有人 | o(w′t〖 | 没有人 | | 博伊尔-摩尔 | O( W + T | O( T / W )最佳情况 o(WT)最坏情况 | O(1) | | KMP | O( W ) | O( T | O( W ) | | 拉宾卡 rp | O( W ) | O( W + T | O(1) |

十九、动态规划

动态编程包括将问题分解成它们的子问题。通过求解最优子问题,并将这些结果保存到内存中,以便在需要解决重复问题时访问它们,算法的复杂性显著降低。实现动态编程算法需要对问题的模式进行更高层次的思考。为了解释动态编程,让我们重新检查一下在第八章中讨论过的斐波那契数列。然后这一章将介绍动态编程的规则,并通过一些例子来使概念更加具体。

动态编程的动机

斐波纳契数列的代码已经确定如下:

function getNthFibo(n) {
    if (n <= 1) {
        return n;
    } else {
        return getNthFibo(n - 1) + getNthFibo(n - 2);
    }
}
getNthFibo(3);

回想一下,这个算法的递归实现是 O(2 n )。这是一个指数级的运行时间,对于现实世界的应用来说是不切实际的。经过更仔细的检查,您会注意到许多相同的计算是重复的。如图 19-1 所示,当调用 6 的getNthFibo时,4、3、2、1 的计算重复多次。知道了这些,怎么才能让这个算法更高效呢?

img/465726_1_En_19_Fig1_HTML.jpg

图 19-1

斐波那契数的递归树

使用哈希表,一旦计算出斐波纳契数,就可以像下面的实现那样存储它:

1   var cache={};
2   function fiboBest(n){
3       if(n<=1)return n;
4       if(cache[n])return cache[n];
5       return (cache[n]=fiboBest(n-1)+fiboBest(n-2));
6   }
7   fiboBest(10); // 55

这被称为重叠子问题。计算 6 的斐波那契数列需要计算 4 和 5 的斐波那契数列。因此,5 的斐波纳契数列与第四次斐波纳契数列计算重叠。这个问题还有一个最优子结构,指的是问题的最优解包含其子问题的最优解。

有了这些,现在让我们形式化什么是动态编程。

动态规划规则

动态编程 (DP)是一种存储已经计算过的值并使用这些值来避免任何重新计算的方法(通常在递归算法中)。该方法只能应用于那些重叠子问题最优子结构的问题。

重叠子问题

类似于递归中的分而治之,DP 结合了子问题的解决方案。当多次需要子问题的解决方案时,使用 DP。它通常将子问题的解决方案存储在哈希表、数组或矩阵中,这被称为记忆化。DP 对于解决有许多重复子问题的问题很有用。

斐波那契数列递归方法就是一个例子。可以观察到,有些数字比如 3 会被重新计算很多次。

哈希表可用于存储结果,以避免任何重新计算。这样做将时间复杂度从 O(2 n )降低到 O( n ),这是一个巨大的变化。计算 O(2 n )与一个实际上足够大的 n 可能需要几年的时间来计算。

最优子结构

最优子结构是指利用子问题的最优解可以找到问题的最优解。

例如,最短路径查找算法具有最优子结构。考虑寻找在城市间驾车旅行的最短路径。如果从洛杉矶到温哥华的最短路线经过旧金山,然后经过西雅图,那么从旧金山到温哥华的最短路线也必须经过西雅图。

示例:覆盖步骤的方法

给定一段距离, n ,计算一、二、三步走完 n 步的总数。例如,当 n =3 时,有四种组合(方式),如下所示:

  1. 一步,一步,一步,一步

  2. 一步,一步,两步

  3. 1 步,3 步

  4. 两步,两步

下面是实现计数的函数:

1   function waysToCoverSteps(step){
2       if (step<0) return 0;
3       if (step==0) return 1;
4
5       return waysToCoverSteps(step-1)+waysToCoverSteps(step-2)+waysToCoverSteps(step-3 );
6   }
7   waysToCoverSteps(12);

**时间复杂度:**O(3nT5)

这种递归方法具有很大的时间复杂度。要优化时间复杂度,只需缓存结果并使用它,而不是重新计算值。

 1   function waysToCoverStepsDP(step) {
 2       var cache = {};
 3       if (step<0) return 0;
 4       if (step==0) return 1;
 5
 6       // check if exists in cache
 7       if (cache[step]) {
 8           return cache[step];
 9       } else {
10           cache[step] = waysToCoverStepsDP(step-1)+waysToCoverStepsDP(step-2)+waysToCoverStepsDP(step-3);
11           return cache[step];
12       }
13   }
14   waysToCoverStepsDP(12);

时间复杂度: O( n

这显示了动态编程的威力。它极大地改善了时间复杂度。

经典动态编程示例

本节将探索和解决一些经典的动态规划问题。首先要探讨的是背包问题。

背包问题

背包问题如下:

  • 给定 n 重量和物品的价值,将这些物品放入一个给定容量 w 的背包中,得到背包中总价值的最大值。
最优子结构

对于数组中的每个项目,可以观察到以下情况:

  • 该项目被包括在最佳子集中。

  • 该项目不包括在最佳集中。

最大值必须是下列值之一:

  1. (不包括第 n 项):用 n-1 项获得的最大值

  2. (包括第 n 项):用 n-1 项减去第 n 项得到的最大值(只有当第 n 项的重量小于 W 时才有效)

天真的方法

简单的方法递归地实现所描述的最佳子结构,如下所示:

 1   function knapsackNaive(index, weights, values, target) {
 2       var result = 0;
 3
 4       if (index <= -1 || target <= 0) {
 5           result = 0
 6       } else if (weights[index] > target) {
 7           result = knapsackNaive(index-1, weights, values, target);
 8       } else {
 9           // Case 1:
10           var current = knapsackNaive(index-1, weights, values, target)
11           // Case 2:
12           var currentPlusOther = values[index] +
13               knapsackNaive(index-1, weights, values, target - weights[index]);
14
15           result = Math.max(current, currentPlusOther);
16       }
17       return result;
18   }
19   var weights = [1,2,4,2,5],
20       values  = [5,3,5,3,2],
21       target = 10;
22   knapsackNaive(4,weights, values, target);

**时间复杂度:**O(2nT5)

图 19-2 显示了背包容量为 2 个单位和 3 个单位重量物品的递归树。如图所示,该函数重复计算相同的子问题,并且具有指数时间复杂度。为了优化这一点,您可以得到基于项目(通过索引引用)和目标(权重: w )的结果。

img/465726_1_En_19_Fig2_HTML.jpg

图 19-2

背包递归树

动态规划方法

如前所述,下面的 DP 实现使用当前数组索引和目标作为 JavaScript 对象的键来存储背包的结果,以供以后检索。对于已经计算过的递归调用,它将使用存储的结果,这大大降低了算法的时间复杂度。

 1   function knapsackDP(index, weights, values, target, matrixDP) {
 2       var result = 0;
 3
 4       // DP part
 5       if (matrixDP[index + '-' + target]){
 6           return matrixDP[index + '-' + target];
 7       }
 8
 9       if (index <= -1 || target <= 0) {
10           result = 0
11       } else if (weights[index] > target) {
12           result = knapsackDP(index - 1, weights, values, target, matrixDP);
13       } else {
14           var current = knapsackDP(index-1, weights, values, target),
15               currentPlusOther = values[index] + knapsackDP(index-1, weights, values, target - weights[index]);
16           result = Math.max(current, currentPlusOther);
17       }
18       matrixDP[index + '-' + target] = result
19       return result;
20   }
21   knapsackDP(4, weights, values, target, {});

时间复杂度: O( nw*

这里, n 是物品的数量, w 是背包的容量。

空间复杂度: O( nw*

这个算法需要一个 n 乘以 w 的组合来将缓存的结果存储在matrixDP中。

接下来要研究的 DP 问题又是一个经典。

最长公共子序列

给定两个序列,找出最长的子序列的长度,其中子序列被定义为以相对顺序出现但不一定连续的序列。比如山姆西艾依等等,都是珊米的子序列。一个字符串有 2 个 n 个 可能的子序列,其中 n 是字符串的长度。

作为一个现实世界的例子,让我们考虑一个出现在生物信息学(DNA 测序)等主要领域的广义计算机科学问题。这种算法也是在版本控制和操作系统中实现 diff 功能(文件之间输出差异的文件比较)的方式。

天真的方法

str1为第一串长度 mstr2为第二串长度 nLCS为函数,天真的做法可以先考虑下面的伪代码:

1\.  if last characters of both sequences match (i.e. str1[m-1] == str2[n-1]):
2\.     result = 1 + LCS(X[0:m-2], Y[0:n-2])
3\.  if last characters of both sequences DO NOT match (i.e. str1[m-1] != str2[n-1]):
4\.     result = Math.max(LCS(X[0:m-1], Y[0:n-1]),LCS(X[0:m-2], Y[0:n-2]))

考虑到这种递归结构,可以实现以下内容:

 1   function LCSNaive(str1, str2, str1Length, str2Length) {
 2       if (str1Length == 0 || str2Length == 0) {
 3           return 0;
 4       }
 5
 6       if (str1[str1Length-1] == str2[str2Length-1]) {
 7           return 1 + LCSNaive(str1, str2,
 8                               str1Length - 1,
 9                               str2Length - 1);
10       } else {
11           return Math.max(
12               LCSNaive(str1, str2, str1Length, str2Length-1),
13               LCSNaive(str1, str2, str1Length-1, str2Length)
14           );
15       }
16   }
17
18   function LCSNaiveWrapper(str1, str2) {
19       return LCSNaive(str1, str2, str1.length, str2.length);
20   }
21   LCSNaiveWrapper('AGGTAB', 'GXTXAYB'); // 4

**时间复杂度:**O(2nT5)

图 19-3 显示了 SAM 和 BAE 的递归树(视觉上在 3 的高度截断)。可以看到,('SA', 'BAE')是重复的。

img/465726_1_En_19_Fig3_HTML.jpg

图 19-3

最长公共字符串长度的递归树

动态规划方法

所描述的递归结构可以转换成一个表/缓存,其中每一行代表str1中的一个字符,每一列代表str2中的一个字符。矩阵中的每一项在一行 i ,一列 j 代表LCS(str1[0:i], str2[0:j])

 1   function longestCommonSequenceLength(str1, str2) {
 2       var matrix = Array(str1.length + 1).fill(Array(str2.length + 1).fill(0)),
 3           rowLength = str1.length + 1,
 4           colLength = str2.length + 1,
 5           max = 0;
 6
 7       for (var row = 1; row < rowLength; row++) {
 8           for (var col = 1; col < colLength; col++) {
 9               var str1Char = str1.charAt(row - 1),
10                   str2Char = str2.charAt(col - 1);
11
12               if (str1Char == str2Char) {
13                   matrix[row][col] = matrix[row - 1][col - 1] + 1;
14                   max = Math.max(matrix[row][col], max);
15               }
16           }
17       }
18       return max;
19   }
20   longestCommonSequenceLength('abcd', 'bc');

时间复杂度: O( m * n

空间复杂度: O( m * n

这里, mstr1的长度,nstr2的长度。

硬币零钱

给定一个价值/货币 n 和不同价值的每种硬币的无限供应量,S = {S1,S2,..Sm},大小为 M ,在不考虑硬币顺序的情况下,可以有多少种变化方式?

给定 N =4, M =3, S = {1,2,3},答案为 4。

1\.   1,1,1,1,
2\.   1,1,2
3\.   2,2
4\.   1,3

最优子结构

您可以观察到以下关于硬币数量的变化:

1)   Solutions without Mth coin
2)   Solutions with (at least) one Mth coin

假设coinChange(S, M, N)是一个计算硬币变化次数的函数,从数学上讲,它可以通过使用前面的两个观察值重写如下:

coinChange(S, M, N) = coinChange(S, M-1, N) + coinChange(S, M, N-Sm)

天真的方法

简单的方法可以使用递归实现所描述的算法,如下所示:

 1   // Returns the count of ways we can sum coinArr which have
 2   // index like: [0,...,numCoins]
 3   function countCoinWays(coinArr, numCoins, coinValue){
 4       if (coinValue == 0) {
 5           // if the value reached zero, then only solution is
 6           // to not include any coin
 7           return 1;
 8       }
 9       if (coinValue < 0 || (numCoins<=0 && coinValue >= 1)) {
10           // value is less than 0 means no solution
11           // no coins left but coinValue left also means no solution
12           return 0;
13       }
14       //
15       return countCoinWays(coinArr,numCoins-1, coinValue) +
16           countCoinWays(coinArr,numCoins, coinValue-coinArr[numCoins-1]);
17   }
18   function countCoinWaysWrapper(coinArr, coinValue) {
19       return countCoinWays(coinArr, coinArr.length, coinValue);
20   }
21   countCoinWaysWrapper([1,2,3],4);

**时间复杂度:**O(nm

空间复杂度: O( n

这里, m 是可用硬币种类的数量, n 是想要兑换成零钱的货币。

重叠子问题

从图 19-4 中的递归树可以看出,有许多重叠的子问题。

img/465726_1_En_19_Fig4_HTML.jpg

图 19-4

最长硬币兑换的递归树

为了解决这个问题,可以使用一个表(矩阵)来存储已经计算的结果。

动态规划方法

DP 方法的矩阵有coinValue个行数和numCoins个列数。在 ij 的任意矩阵代表给定一个 icoinValue和一个 jnumCoins的路数。

 1   function countCoinWaysDP(coinArr, numCoins, coinValue) {
 2       // creating the matrix
 3       var dpMatrix = [];
 4
 5       for (var i=0; i <= coinValue; i++) {
 6           dpMatrix[i] = [];
 7           for(var j=0; j< numCoins; j++) {
 8               dpMatrix[i][j] = undefined;
 9           }
10       }
11
12       for (var i=0; i < numCoins; i++) {
13           dpMatrix[0][i] = 1;
14       }
15
16       for (var i=1; i < coinValue + 1; i++) {
17           for (var j=0; j < numCoins; j++) {
18               var temp1 = 0,
19                   temp2 = 0;
20
21               if (i - coinArr[j] >= 0) {
22                   // solutions including coinArr[j]
23                   temp1 = dpMatrix[i - coinArr[j]][j];
24               }
25
26               if (j >= 1) {
27                   // solutions excluding coinArr[j]
28                   temp2 = dpMatrix[i][j-1];
29               }
30
31               dpMatrix[i][j] = temp1 + temp2;
32           }
33       }
34       return dpMatrix[coinValue][numCoins-1];
35   }
36
37   function countCoinWaysDPWrapper(coinArr, coinValue) {
38       return countCoinWaysDP(coinArr, coinArr.length, coinValue);
39   }
40   countCoinWaysDPWrapper([1,2,3],4);

时间复杂度: O( m * n

空间复杂度: O( m * n

这里, m 是可用硬币种类的数量, n 是想要兑换成零钱的货币。

编辑(Levenshtein)距离

编辑距离问题考虑以下因素:

  • 给定一个长度为 m 的字符串(str1)和另一个长度为 n 的字符串(str2),将str1转换为str2的最小编辑次数是多少?

有效的操作如下:

  1. 插入

  2. 移动

  3. 替换

最优子结构

如果从每个str1str2逐个处理每个字符,则可能出现以下情况:

1\.   the characters are the same:
      do nothing
2\.   the characters are different:
      consider the cases recursively:
          Insert:     for m   and n-1
          Remove:     for m-1 and n
          Replace:    for m-1 and n-1

天真的方法

简单的方法可以递归地实现所描述的子结构,如下所示:

 1   function editDistanceRecursive(str1, str2, length1, length2) {
 2       // str1 is empty. only option is insert all of str2
 3       if (length1 == 0) {
 4           return length2;
 5       }
 6       // str2 is empty. only option is insert all of str1
 7       if (length2 == 0) {
 8           return length1;
 9       }
10
11       // last chars are same,
12       // ignore last chars and count remaining
13       if (str1[length1-1] == str2[length2-1]) {
14           return editDistanceRecursive(str1, str2,
15                                        length1-1, length2-1);
16       }
17
18       // last char is not the same
19       // there are three operations: insert, remove, replace
20       return 1 + Math.min (
21           // insert
22           editDistanceRecursive(str1, str2, length1, length2-1),
23           // remove
24           editDistanceRecursive(str1, str2, length1-1, length2),
25           // replace
26           editDistanceRecursive(str1, str2, length1-1, length2-1)
27       );
28   }
29
30   function editDistanceRecursiveWrapper(str1, str2) {
31       return editDistanceRecursive(str1, str2, str1.length, str2.length);
32   }
33
34   editDistanceRecursiveWrapper('sammie','bae');

**时间复杂度:**O(3mT5)

简单解决方案的时间复杂度是指数级的,最坏的情况是两个字符串中没有匹配的字符。这是有意义的,因为每个调用有三个调用(插入、移除、替换)。

同样,你可以看到同样的问题被一遍又一遍地解决(见图 19-5 )。这可以通过构造一个矩阵来优化,该矩阵存储子问题的已经计算的结果。

img/465726_1_En_19_Fig5_HTML.jpg

图 19-5

编辑距离的递归树

动态规划方法

动态规划方法将构建具有维度str1str2的矩阵。基本情况是当 ij 等于 0 时。在其他情况下,它是1 + min(insert, remove, replace),就像递归方法一样。

 1   function editDistanceDP(str1, str2, length1, length2) {
 2       // creating the matrix
 3       var dpMatrix = [];
 4       for(var i=0; i<length1+1; i++) {
 5           dpMatrix[i] = [];
 6           for(var j=0; j<length2+1; j++) {
 7               dpMatrix[i][j] = undefined;
 8           }
 9       }
10
11       for (var i=0; i < length1 + 1; i++) {
12           for (var j=0; j < length2 + 1; j++) {
13               // if first str1 is empty,
14               // have to insert all the chars of str2
15               if (i == 0) {
16                   dpMatrix[i][j] = j;
17               } else if (j == 0) {
18                   dpMatrix[i][j] = i;
19               } else if (str1[i-1] == str2[j-1]) {
20                   // if the same, no additional cost
21                   dpMatrix[i][j] = dpMatrix[i-1][j-1];
22               } else {
23                   var insertCost = dpMatrix[i][j-1],
24                       removeCost = dpMatrix[i-1][j],
25                       replaceCost= dpMatrix[i-1][j-1];
26
27                   dpMatrix[i][j] = 1 + Math.min(insertCost,removeCost,replaceCost);
28               }
29           }
30       }
31       return dpMatrix[length1][length2];
32   }
33
34   function editDistanceDPWrapper(str1, str2) {
35       return editDistanceDP(str1, str2, str1.length, str2.length);
36   }
37
38   editDistanceDPWrapper('sammie','bae');

时间复杂度: O( m * n

空间复杂度: O( m * n

这里, mstr1的长度, nstr2的长度。

摘要

如果满足以下条件,可以利用动态规划来优化算法:

  • 最优子结构:问题的最优解包含其子问题的最优解。

  • 重叠子问题:子问题的解法需要多次。

为了存储已经计算出的子问题的解,通常使用矩阵或散列表;这是因为两者都提供 O(1)查找时间。这样做,时间复杂度可以从指数级(如 O(2 n ))提高到多项式时间(如 O( n 2 )。