JavaScript 数据结构和算法教程(三)
十一、哈希表
哈希表是一种固定大小的数据结构,其大小是在开始时定义的。本章通过重点介绍哈希(生成唯一键的方法)来解释哈希表是如何工作的。本章结束时,你将理解各种散列技术,并知道如何从头开始实现一个散列表。
哈希表简介
哈希表非常适合基于键值对快速存储和检索数据。在 JavaScript 中,JavaScript 对象通过定义一个键(属性)及其相关值来实现这种工作方式。图 11-1 显示了每个按键及其相关项目。
图 11-1
简单哈希表概述
哈希表包含两个主要函数:put()和get()。put()用于将数据存储到哈希表中,而get()用于从哈希表中检索数据。这两个函数的时间复杂度都是 O(1)。
简而言之,哈希表类似于一个数组,其索引是用哈希函数计算的,以唯一地标识内存中的空间。
localStorage是基于散列表的数据结构的例子。它是所有主流浏览器都支持的原生 JavaScript 对象。它允许开发人员将数据保存在浏览器中,这意味着可以在会话后访问这些数据。
1 localStorage.setItem("testKey","testValue");
2 location = location; // refreshes the page
3
4 //-----------------------------------
5 localStorage.getItem("testKey"); // prints "testValue"
哈希技术
哈希表最重要的部分是哈希函数。hash 函数将指定的键转换为存储所有数据的数组的索引。一个好的散列函数的三个主要要求如下:
-
确定性:相等的键产生相等的哈希值。
-
效率:在时间上应该是 O(1)。
-
均匀分布:最大限度利用数组。
第一种散列技术是使用质数。通过对素数使用模数运算符,可以保证索引的均匀分布。
素数散列法
质数在散列法中很重要。这是因为使用质数的模数除法以分布式方式产生数组索引。
Modulus number: 11
4 % 11 = 4
7 % 11 = 7
9 % 11 = 9
15 % 11 = 4
可以看到 15 和 4 产生相同密钥的冲突;本章稍后将讨论如何处理这种冲突。这里重要的是素数的模保证了固定大小的最佳分布。小的非质数(如 4)的模数只能保证从 0 到 3 的范围,并且会导致大量的冲突。
Modulus number: 4
6 % 4 = 2
10 % 4 = 2
这是我们将观察到的第一种散列技术。看一下图 11-2 ,这是一个散列表,有两个大小为 11 的数组,11 个元素都是空的。一个数组用于键,另一个用于值。
图 11-2
大小为 11 的哈希表,所有元素都为空
在这个例子中,键是整数,字符串被存储为键。让我们散列下面的键值对:
{key:7, value: "hi"}
{key:24, value: "hello"}
{key:42, value: "sunny"}
{key:34, value: "weather"}
Prime number: 11
7 % 11 = 7
24 % 11 = 2
42 % 11 = 9
34 % 11 = 1
在插入所有的键值对之后,产生的散列表如图 11-3 所示。
图 11-3
插入值对后的哈希表
现在我们散列{key:18,value:“wow”}。
Prime number: 11
18 % 11 = 7
这是一个问题,因为 7 已经存在于 7 的索引中,并会导致索引冲突。有了完美的散列函数,就不会有冲突。然而,在大多数情况下,无冲突哈希几乎是不可能的。因此,哈希表需要处理冲突的策略。
探索
为了解决发生的冲突,探测散列技术在数组中查找下一个可用的索引。线性探测技术通过增量试验寻找下一个可用索引来解决冲突,而二次探测使用二次函数来生成增量试验。
线性探测
线性探测通过一次递增一个索引来寻找下一个可用的索引。例如,在 18 和 7 散列到同一个键的情况下,18 将被散列到键 8 中,因为那是下一个空位(参见图 11-4 )。
图 11-4
使用线性探测后的哈希表 1
然而,现在当使用get(key)函数时,它必须从原始散列结果(7)开始,然后迭代直到找到 18。
线性探测的主要缺点是它容易创建簇,这是不好的,因为它们创建了更多的数据来迭代。
二次探测
二次探测是解决集群问题的好方法。二次探测使用完美的平方,而不是每次递增 1,这有助于在可用索引中均匀分布,如图 11-5 所示。
图 11-5
线性探测(顶部)和二次探测(底部)
h + (1)², h + (2)², h + (3)², h + (4)²
h + 1, h + 4, h + 9, h + 16
重新散列/双重散列
另一个统一分配密钥的好方法是使用第二个散列函数,对原始结果进行散列。这是良好的第二散列函数的三个主要要求:
-
不同:需要不同才能更好的分配。
-
效率:时间上应该还是 O(1)。
-
非零:永远不应该评估为零。零表示初始哈希值。
常用的第二种散列函数如下:
- 哈希 2(x) = R − (x % R)
这里, x 是第一次哈希的结果, R 小于哈希表的大小。每个哈希冲突通过以下方式解决,其中 i 是迭代试验次数:
- 我 * 【散列】**(x
哈希表实现
既然已经解释了哈希表,让我们从头实现一个。在本节中,您将对同一个示例应用三种不同的技术。下面是将要使用的键-值对示例:
-
7、“嗨”
-
20、“你好”
-
33、《阳光灿烂》
-
46、《天气》
-
59、“哇”
-
72、《四十》
-
85、“快乐”
-
98、《伤心》
使用线性探测
让我们从简单的线性探测开始这个例子。
1 function HashTable(size) {
2 this.size = size;
3 this.keys = this.initArray(size);
4 this.values = this.initArray(size);
5 this.limit = 0;
6 }
7
8 HashTable.prototype.put = function(key, value) {
9 if (this.limit >= this.size) throw 'hash table is full'
10
11 var hashedIndex = this.hash(key);
12
13 // Linear probing
14 while (this.keys[hashedIndex] != null) {
15 hashedIndex++;
16
17 hashedIndex = hashedIndex % this.size;
18
19 }
20
21 this.keys[hashedIndex] = key;
22 this.values[hashedIndex] = value;
23 this.limit++;
24 }
25
26 HashTable.prototype.get = function(key) {
27 var hashedIndex = this.hash(key);
28
29 while (this.keys[hashedIndex] != key) {
30 hashedIndex++;
31
32 hashedIndex = hashedIndex % this.size;
33
34 }
35 return this.values[hashedIndex];
36 }
37
38 HashTable.prototype.hash = function(key) {
39 // Check if int
40 if (!Number.isInteger(key)) throw 'must be int';
41 return key % this.size;
42 }
43
44 HashTable.prototype.initArray = function(size) {
45 var array = [];
46 for (var i = 0; i < size; i++) {
47 array.push(null);
48 }
49 return array;
50 }
51
52 var exampletable = new HashTable(13);
53 exampletable.put(7, "hi");
54 exampletable.put(20, "hello");
55 exampletable.put(33, "sunny");
56 exampletable.put(46, "weather");
57 exampletable.put(59, "wow");
58 exampletable.put(72, "forty");
59 exampletable.put(85, "happy");
60 exampletable.put(98, "sad");
结果如下:
Keys:
[ 85, 98, null, null, null, null, null, 7, 20, 33, 46, 59, 72 ]
Values:
[ 'happy', 'sad', null, null, null, null, null, 'hi', 'hello', 'sunny', 'weather', 'wow', 'forty' ]
使用二次探测
现在,让我们将put()和get()方法改为使用二次探测。
1 HashTable.prototype.put = function (key, value) {
2 if (this.limit >= this.size) throw 'hash table is full'
3
4 var hashedIndex = this.hash(key), squareIndex = 1;
5
6 // quadratic probing
7 while (this.keys[hashedIndex] != null) {
8 hashedIndex += Math.pow(squareIndex,2);
9
10 hashedIndex
11 squareIndex++;
12 }
13
14 this.keys[hashedIndex] = key;
15 this.values[hashedIndex] = value;
16 this.limit++;
17 }
18
19 HashTable.prototype.get = function (key) {
20 var hashedIndex = this.hash(key), squareIndex = 1;
21
22 while ( this.keys[hashedIndex] != key ) {
23 hashedIndex += Math.pow(squareIndex, 2);
24
25 hashedIndex = hashedIndex % this.size;
26 squareIndex++;
27 }
28
29 return this.values[hashedIndex];
30 }
结果如下:
Keys:
[ null, null, null, 85, 72, null, 98, 7, 20, null, 59, 46, 33 ]
Values:
[ null, null, null, 'happy', 'forty', null, 'sad', 'hi', 'hello', null, 'wow', 'weather', 'sunny' ]
该结果比线性探测的结果分布更均匀。更大的数组和更多的元素会更容易看到。
使用带有线性探测的双重散列
最后,让我们结合双重散列和线性探测。回想一下常见的第二个哈希函数,hash2(x)=R—(x % R),其中 x 是第一次哈希的结果, R 小于哈希表的大小。
1 HashTable.prototype.put = function(key, value) {
2 if (this.limit >= this.size) throw 'hash table is full'
3
4 var hashedIndex = this.hash(key);
5
6 while (this.keys[hashedIndex] != null) {
7 hashedIndex++;
8
9 hashedIndex = hashedIndex % this.size;
10
11 }
12 this.keys[hashedIndex] = key;
13 this.values[hashedIndex] = value;
14 this.limit++;
15 }
16
17 HashTable.prototype.get = function(key) {
18 var hashedIndex = this.hash(key);
19
20 while (this.keys[hashedIndex] != key) {
21 hashedIndex++;
22
23 hashedIndex = hashedIndex % this.size;
24
25 }
26 return this.values[hashedIndex];
27 }
28
29 HashTable.prototype.hash = function(key) {
30 if (!Number.isInteger(key)) throw 'must be int'; // check if int
31 return this.secondHash(key % this.size);
32 }
33
34 HashTable.prototype.secondHash = function(hashedKey) {
35 var R = this.size - 2;
36 return R - hashedKey % R;
37 }
结果如下:
Keys:
[ null, 59, 20, 85, 98, 72, null, 7, null, 46, null, 33, null ]
Values:
[ null, 'wow', 'hello', 'happy', 'sad', 'forty', null, 'hi', null, 'weather', null, 'sunny', null ]
同样,与线性探测的结果相比,双重散列产生更均匀分布的阵列。二次探测和双重散列都是减少哈希表中冲突数量的很好的技术。有比这些技术更高级的冲突解决算法,但是它们超出了本书的范围。
摘要
哈希表是一种固定大小的数据结构,其大小是在开始时定义的。哈希表是使用哈希函数为数组生成索引来实现的。一个好的散列函数是确定的、高效的和均匀分布的。使用一个好的均匀分布的散列函数应该可以最小化散列冲突,但是有些冲突是不可避免的。哈希冲突处理技术包括但不限于线性探测(将索引递增 1)、二次探测(使用二次函数来递增索引)和双重哈希(使用多个哈希函数)。
下一章探索栈和队列,它们是动态调整大小的数据结构。
十二、栈和队列
本章介绍栈和队列;两者都是通用的数据结构,通常用于实现其他更复杂的数据结构。您将了解什么是栈和队列,如何以及何时使用它们,以及如何实现它们。最后,练习将帮助你理解这些概念,以及什么时候应用栈和队列来解决算法问题。
大量
一个栈是一个数据结构,其中只有最后插入的元素可以被移除和访问(见图 12-1 )。想象一下把盘子堆在桌子上。要到达底部的一个,你必须移除顶部的所有其他的。这就是所谓的后进先出 (LIFO)原则。栈很棒,因为它很快。因为已知最后一个元素将被移除,所以查找和插入发生在常数时间 O(1)内。当您需要处理 LIFO 形式的数据时,应该使用栈而不是数组,在这种情况下,算法只需要访问最后添加的元素。栈的限制是它们不能像数组那样直接访问非最后添加的元素;此外,访问更深层次的元素需要从数据结构中删除元素。
图 12-1
栈,后进先出
在 JavaScript 中,数组有定义栈类的方法:pop和push(如第五章所讨论的)。这样,可以很容易地实现栈。
下面是一些基本代码。你可以在 GitHub 上找到代码。 1
1 function Stack(array){
2 this.array = [];
3 if(array) this.array = array;
4 }
5
6 Stack.prototype.getBuffer = function(){
7 return this.array.slice();
8 }
9
10 Stack.prototype.isEmpty = function(){
11 return this.array.length == 0;
12 }
13
14 //instance of the stack class
15 var stack1 = new Stack();
16
17 console.log(stack1); // {array: []}
让我们首先考虑“偷看”最近添加的元素。这可以简单地通过使用数组的最大索引来完成。
偷看
窥视栈最后添加的元素意味着返回最后添加的元素,而不从数据结构中移除它。扫视通常用于将最后添加的元素与其他变量进行比较,并评估最后添加的元素是否应该从数据结构中删除。
1 Stack.prototype.peek = function(){
2 return this.array[this.array.length-1];
3 }
4 stack1.push(10);
5 console.log(stack1.peek()); // 10
6 stack1.push(5);
7 console.log(stack1.peek()); // 5
时间复杂度: O(1)
插入
插入栈可以通过 JavaScript 数组本身支持的push函数来完成。
1 Stack.prototype.push = function(value){
2 this.array.push(value);
3 }
4
5 stack1.push(1);
6 stack1.push(2);
7 stack1.push(3);
8 console.log(stack1); // {array: [1,2,3]}
时间复杂度: O(1)
删除
删除也可以使用本地 JavaScript 数组方法实现,称为pop。
1 Stack.prototype.pop = function() {
2 return this.array.pop();
3 };
4
5 stack1.pop(1);
6 stack1.pop(2);
7 stack1.pop(3);
8
9 console.log(stack1); // {array: []}
时间复杂度: O(1)
接近
访问数据结构中的特定元素非常重要。这里,让我们看看如何根据顺序访问元素。
要从顶部访问第 n 个节点,需要调用pop n 次。
1 function stackAccessNthTopNode(stack, n){
2 var bufferArray = stack.getBuffer();
3 if(n<=0) throw 'error'
4
5 var bufferStack = new Stack(bufferArray);
6
7 while(--n!==0){
8 bufferStack.pop();
9 }
10 return bufferStack.pop();
11 }
12
13 var stack2 = new Stack();
14 stack2.push(1);
15 stack2.push(2);
16 stack2.push(3);
17 stackAccessNthTopNode(stack2,2); // 2
时间复杂度: O( n
搜索将以类似的方式实现。
搜索
在栈数据结构中搜索特定元素是一项非常关键的操作。为此,您必须首先创建一个缓冲栈,以便可以在该缓冲栈上调用pop。这样,原始栈不会发生变化,也不会从中删除任何内容。
1 function stackSearch(stack, element) {
2 var bufferArray = stack.getBuffer();
3
4 var bufferStack = new Stack(bufferArray); // copy into buffer
5
6 while(!bufferStack.isEmpty()){
7 if(bufferStack.pop()==element){
8 return true;
9 }
10 }
11 return false;
12 }
时间复杂度: O( n
行列
队列也是一种数据结构,但是您只能删除第一个添加的元素(参见图 12-2 )。这是一个被称为先进先出 (FIFO)的原则。队列之所以伟大,还因为它的操作时间是恒定的。与栈类似,它也有局限性,因为一次只能访问一项。当您需要处理 FIFO 形式的数据时,应该使用队列而不是数组,在 FIFO 形式中,算法只需要访问第一个添加的元素。
图 12-2
伫列,FIFO
在 JavaScript 中,数组有定义队列类的方法:shift()和push()(如第五章所述)。回想一下 JavaScript 中数组的shift()方法移除并返回数组的第一个元素。添加到队列中通常称为入队,从队列中移除通常称为出队。shift()可用于出列,和。push()可用于入队。
下面是一些基本代码。你可以在 GitHub 上找到代码。 2
1 function Queue(array){
2 this.array = [];
3 if(array) this.array = array;
4 }
5
6 Queue.prototype.getBuffer = function(){
7 return this.array.slice();
8 }
9
10 Queue.prototype.isEmpty = function(){
11 return this.array.length == 0;
12 }
13
14 //instance of the queue class
15 var queue1 = new Queue();
16
17 console.log(queue1); // { array: [] }
偷看
peek函数查看第一个项目,而不将它从队列中弹出。在栈实现中,返回数组中的最后一个元素,但是由于 FIFO 的原因,队列返回数组中的第一个元素。
1 Queue.prototype.peek = function(){
2 return this.array[0];
3 }
插入
如上所述,队列的插入被称为入队。由于使用数组来保存栈数据,因此可以使用push()方法来实现enqueue。
1 Queue.prototype.enqueue = function(value){
2 return this.array.push(value);
3 }
时间复杂度: O(1)
删除
如上所述,队列的删除也被称为出列。因为数组用于保存栈数据,所以可以使用shift()方法移除并返回队列中的第一个元素。
1 Queue.prototype.dequeue = function() {
2 return this.array.shift();
3 };
4
5 var queue1 = new Queue();
6
7 queue1.enqueue(1);
8 queue1.enqueue(2);
9 queue1.enqueue(3);
10
11 console.log(queue1); // {array: [1,2,3]}
12
13 queue1.dequeue();
14 console.log(queue1); // {array: [2,3]}
15
16 queue1.dequeue();
17 console.log(queue1); // {array: [3]}
时间复杂度: O(n)
因为shift()实现移除了零索引处的元素,然后连续向下移动剩余的索引,所以数组中的所有其他元素都需要改变它们的索引,这需要 O( n )。如第十三章所述,对于链表实现,这可以简化为 O(1)。
接近
与数组不同,队列中的项不能通过索引来访问。要访问最后添加的第 n 个节点,需要调用dequeue n 次。需要一个缓冲区来防止对原始队列的修改。
1 function queueAccessNthTopNode(queue, n){
2 var bufferArray = queue.getBuffer();
3 if(n<=0) throw 'error'
4
5 var bufferQueue = new Queue(bufferArray);
6
7 while(--n!==0){
8 bufferQueue.dequeue();
9 }
10 return bufferQueue.dequeue();
11 }
时间复杂度: O( n
搜索
您可能需要搜索队列来检查队列中是否存在某个元素。同样,这需要首先创建一个缓冲队列,以避免修改原始队列。
1 function queueSearch(queue, element){
2 var bufferArray = queue.getBuffer();
3
4 var bufferQueue = new Queue(bufferArray);
5
6 while(!bufferQueue.isEmpty()){
7 if(bufferQueue.dequeue()==element){
8 return true;
9 }
10 }
11 return false;
12 }
时间复杂度: O( n
摘要
栈和队列都支持 O(1)中的查看、插入和删除。栈和队列之间最重要的区别是栈是后进先出的,而队列是先进先出的。表 12-1 总结了时间复杂度。
表 12-1
队列和栈时间复杂度摘要
| |接近
|
搜索
|
偷看
|
插入
|
删除
| | --- | --- | --- | --- | --- | --- | | 长队 | O(n) | O(n) | O(1) | O(1) | o(n)3 | | 堆 | O(n) | O(n) | O(1) | O(1) | O(1) |
练习
所有练习的代码都可以在 GitHub 上找到。 4
仅使用队列设计栈,然后仅使用栈设计队列
使用队列栈
一个队列可以由两个栈组成。队列是一种数据结构,它使用dequeue()方法返回第一个添加的元素。栈是一种数据结构,它通过pop返回最后添加的元素。换句话说,队列以与栈相反的方向移除元素。
例如,检查具有[1,2,3,4,5]的栈数组。
为了颠倒顺序,可以将所有的元素推到第二个栈上,并弹出第二个栈。因此,第二个栈数组将是这样的:[5,4,3,2,1]。
当这个被弹出时,最后一个元素被删除,即 1。所以,1 本来就是第一个元素。因此,只使用两个栈就实现了一个队列。
1 function TwoStackQueue(){
2 this.inbox = new Stack();
3 this.outbox= new Stack();
4 }
5
6 TwoStackQueue.prototype.enqueue = function(val) {
7 this.inbox.push(val);
8 }
9
10 TwoStackQueue.prototype.dequeue = function() {
11 if(this.outbox.isEmpty()){
12 while(!this.inbox.isEmpty()){
13 this.outbox.push(this.inbox.pop());
14 }
15 }
16 return this.outbox.pop();
17 };
18 var queue = new TwoStackQueue();
19 queue.enqueue(1);
20 queue.enqueue(2);
21 queue.enqueue(3);
22 queue.dequeue(); // 1
23 queue.dequeue(); // 2
24 queue.dequeue(); // 3
使用栈排队
栈可以由两个队列组成。栈是返回最后一个元素的数据结构。要使用队列实现这一点,只需将除最后一个元素之外的所有元素排入主队列。然后返回最后一个元素。
1 function QueueStack(){
2 this.inbox = new Queue(); // first stack
3 }
4
5 QueueStack.prototype.push = function(val) {
6 this.inbox.enqueue(val);
7 };
8
9 QueueStack.prototype.pop = function() {
10 var size = this.inbox.array.length-1;
11 var counter =0;
12 var bufferQueue = new Queue();
13
14 while(++counter<=size){
15 bufferQueue.enqueue(this.inbox.dequeue());
16 }
17 var popped = this.inbox.dequeue();
18 this.inbox = bufferQueue;
19 return popped
20 };
21
22 var stack = new QueueStack();
23
24 stack.push(1);
25 stack.push(2);
26 stack.push(3);
27 stack.push(4);
28 stack.push(5);
29
30 console.log(stack.pop()); // 5
31 console.log(stack.pop()); // 4
32 console.log(stack.pop()); // 3
33 console.log(stack.pop()); // 2
34 console.log(stack.pop()); // 1
设计一个收银员类,它接受一个客户对象,并根据先来先服务的原则处理食物订购
以下是要求:
-
收银员需要订单的客户名称和订单项目。
-
首先被服务的顾客首先被处理。
以下是必需的实现:
-
addOrder(customer):将deliverOrder()处理的客户对象入队 -
deliverOrder():打印下一个待处理客户的名称和订单
对于这个练习,Cashier类应该用一个队列将客户类对象入队,并在完成时将它们出队。
1 function Customer(name, order){
2 this.name = name;
3 this.order = order;
4 }
5
6 function Cashier(){
7 this.customers = new Queue();
8 }
9
10 Cashier.prototype.addOrder = function (customer){
11 this.customers.enqueue(customer);
12 }
13
14 Cashier.prototype.deliverOrder = function(){
15 var finishedCustomer = this.customers.dequeue();
16
17 console.log(finishedCustomer.name+", your "+finishedCustomer.order+" is ready!");
18 }
19
20 var cashier = new Cashier();
21 var customer1 = new Customer('Jim',"Fries");
22 var customer2 = new Customer('Sammie',"Burger");
23 var customer3 = new Customer('Peter',"Drink");
24
25 cashier.addOrder(customer1);
26 cashier.addOrder(customer2);
27 cashier.addOrder(customer3);
28
29 cashier.deliverOrder(); // Jim, your Fries is ready!
30 cashier.deliverOrder(); // Sammie, your Burger is ready!
31 cashier.deliverOrder(); // Peter, your Drink is ready!
使用栈设计括号验证检查器
((()))是有效的括号集,而((()和)))不是。通过存储左括号和使用push并在看到右括号时触发pop,可以使用栈来检查括号的有效性。
如果之后栈中还有任何东西,那就不是有效的括号集。此外,如果右括号比左括号多,则不是有效的括号集。使用这些规则,使用栈来存储最近的括号。
1 function isParenthesisValid(validationString){
2 var stack = new Stack();
3 for(var pos=0;pos<validationString.length;pos++){
4 var currentChar = validationString.charAt(pos);
5 if(currentChar=="("){
6 stack.push(currentChar);
7 }else if(currentChar==")"){
8
9 if(stack.isEmpty())
10 return false;
11
12 stack.pop();
13 }
14 }
15 return stack.isEmpty();
16 }
17 isParenthesisValid("((()"); // false;
18 isParenthesisValid("(((("); // false;
19 isParenthesisValid("()()"); // true;
时间复杂度: O( n
该算法逐字符处理字符串。因此,它的时间复杂度是 O( n ),其中 n 是字符串的长度。
设计一个便携式栈
这个想法是有两个栈,一个是排序的,一个是非排序的。当排序时,从未排序的栈中弹出,当排序后的栈中任何较小(如果降序)或较大(如果升序)的数字在顶部时,排序后的栈元素应该移回未排序,因为它是无序的。运行一个循环,直到栈全部排序。
1 function sortableStack(size){
2 this.size = size;
3
4 this.mainStack = new Stack();
5 this.sortedStack = new Stack();
6
7 // let's initialize it with some random ints
8 for(var i=0;i<this.size;i++){
9 this.mainStack.push(Math.floor(Math.random()*11));
10 }
11 }
12
13 sortableStack.prototype.sortStackDescending = function(){
14 while(!this.mainStack.isEmpty()){
15 var temp = this.mainStack.pop();
16 while(!this.sortedStack.isEmpty() && this.sortedStack.peek()< temp){
17 this.mainStack.push(this.sortedStack.pop());
18 }
19 this.sortedStack.push(temp);
20 }
21 }
22
23 var ss = new sortableStack(10);
24 console.log(ss); // [ 8, 3, 4, 4, 1, 2, 0, 9, 7, 8 ]
25 ss.sortStackDescending();
26 console.log(ss.sortedStack); // [ 9, 8, 8, 7, 4, 4, 3, 2, 1, 0 ]
**时间复杂度:**O(n2
该算法涉及两个栈之间的元素的重排,这在最坏的情况下可能需要 O( n 2 ),其中 n 是要排序的元素的数量。
Footnotes 1https://github.com/Apress/js-data-structures-and-algorithms
2
https://github.com/Apress/js-data-structures-and-algorithms
3
这可以通过链表实现提高到 O(1)。
4
https://github.com/Apress/js-data-structures-and-algorithms
十三、链表
本章将介绍链表。链表是一种数据结构,其中每个节点指向另一个节点。与固定大小的数组不同,链表是一种动态数据结构,可以在运行时分配和释放内存。本章结束时,你将理解如何实现和使用链表。
本章讨论了两种类型的链表:单向和双向链表。我们先来考察一下单链表。
单链表
链表数据结构是每个节点(元素)都引用下一个节点(见图 13-1 )。
图 13-1
单向链表
单链表中的一个节点有以下属性:data和next。data是链表节点的值,next是指向SinglyLinkedListNode的另一个实例的指针。
1 function SinglyLinkedListNode(data) {
2 this.data = data;
3 this.next = null;
4 }
以下代码是单向链表示例的基础。你可以在 GitHub 上找到代码。 1 代码块有一个 helper 函数,用来检查单链表是否为空。
1 function SinglyLinkedList(){
2 this.head = null;
3 this.size = 0;
4 }
5
6 SinglyLinkedList.prototype.isEmpty = function(){
7 return this.size == 0;
8 }
链表的开始被称为头。在向链表中插入任何元素之前,该属性默认为null。
插入
下面的代码块演示如何插入到单链表中。如果链表的头为空,则将头设置为新节点。否则,旧堆保存在temp中,新堆头成为新添加的节点。最后,新头的next指向了temp(旧头)。
1 SinglyLinkedList.prototype.insert = function(value) {
2 if (this.head === null) { //If first node
3 this.head = new SinglyLinkedListNode(value);
4 } else {
5 var temp = this.head;
6 this.head = new SinglyLinkedListNode(value);
7 this.head.next = temp;
8 }
9 this.size++;
10 }
11 var sll1 = new SinglyLinkedList();
12 sll1.insert(1); // linked list is now: 1 -> null
13 sll1.insert(12); // linked list is now: 12 -> 1 -> null
14 sll1.insert(20); // linked list is now: 20 -> 12 -> 1 -> null
时间复杂度: O( 1
这是一个恒定时间操作;不需要循环或遍历。
按值删除
单链表中节点的删除是通过移除该节点的引用来实现的。如果节点在链表的“中间”,这是通过让指向该节点的next指针指向该节点自己的next节点来实现的,如图 13-2 所示。
图 13-2
从单链表中删除内部节点
如果该节点位于链表的末尾,那么倒数第二个元素可以通过将其next设置为null来取消对该节点的引用。
1 SinglyLinkedList.prototype.remove = function(value) {
2 var currentHead = this.head;
3 if (currentHead.data == value) {
4 // just shift the head over. Head is now this new value
5 this.head = currentHead.next;
6 this.size--;
7 } else {
8 var prev = currentHead;
9 while (currentHead.next) {
10 if (currentHead.data == value) {
11 // remove by skipping
12 prev.next = currentHead.next;
13 prev = currentHead;
14 currentHead = currentHead.next;
15 break; // break out of the loop
16 }
17 prev = currentHead;
18 currentHead = currentHead.next;
19 }
20 //if wasn't found in the middle or head, must be tail
21 if (currentHead.data == value) {
22 prev.next = null;
23 }
24 this.size--;
25 }
26 }
27 var sll1 = new SinglyLinkedList();
28 sll1.insert(1); // linked list is now: 1 -> null
29 sll1.insert(12); // linked list is now: 12 -> 1 -> null
30 sll1.insert(20); // linked list is now: 20 -> 12 -> 1 -> null
31 sll1.remove(12); // linked list is now: 20 -> 1 -> null
32 sll1.remove(20); // linked list is now: 1 -> null
时间复杂度: O( n
在最坏的情况下,必须遍历整个链表。
开头删除
在 O(1)中删除链表头部的元素是可能的。当从头部删除一个节点时,不需要遍历。下面的代码块显示了这种删除的实现。这允许链表实现栈。最后添加的项目(到头部)可以在 O(1)中移除。
1 DoublyLinkedList.prototype.deleteAtHead = function() {
2 var toReturn = null;
3
4 if (this.head !== null) {
5 toReturn = this.head.data;
6
7 if (this.tail === this.head) {
8 this.head = null;
9 this.tail = null;
10 } else {
11 this.head = this.head.next;
12 this.head.prev = null;
13 }
14 }
15 this.size--;
16 return toReturn;
17 }
18 var sll1 = new SinglyLinkedList();
19 sll1.insert(1); // linked list is now: 1 -> null
20 sll1.insert(12); // linked list is now: 12 -> 1 -> null
21 sll1.insert(20); // linked list is now: 20 -> 12 -> 1 -> null
22 sll1.deleteAtHead(); // linked list is now: 12 -> 1 -> null
搜索
为了找出一个值是否存在于一个单链表中,需要简单的遍历所有的next指针。
1 SinglyLinkedList.prototype.find = function(value) {
2 var currentHead = this.head;
3 while (currentHead.next) {
4 if (currentHead.data == value) {
5 return true;
6 }
7 currentHead = currentHead.next;
8 }
9 return false;
10 }
时间复杂度: O( n
像删除操作一样,在最坏的情况下,必须遍历整个链表。
双向链表
双向链表可以被认为是双向单向链表。双向链表中的每个节点都有一个next指针和一个prev指针。下面的代码块实现了双向链表节点:
1 function DoublyLinkedListNode(data) {
2 this.data = data;
3 this.next = null;
4 this.prev = null;
5 }
此外,双向链表有头指针和尾指针。头是指双向链表的开头,尾是指双向链表的结尾。这在下面的代码中实现,并带有一个帮助器函数来检查双向链表是否为空:
1 function DoublyLinkedList (){
2 this.head = null;
3 this.tail = null;
4 this.size = 0;
5 }
6 DoublyLinkedList.prototype.isEmpty = function(){
7 return this.size == 0;
8 }
双向链表中的每个节点都有next和prev属性。双向链表中的删除、插入和搜索实现类似于单向链表。然而,对于插入和删除,必须更新next和prev属性。图 13-3 显示了一个双向链表的例子。
图 13-3
具有五个节点的双向链表示例
在头部插入
插入双向链表的头部与插入单向链表是一样的,除了它还必须更新prev指针。下面的代码块显示了如何插入到双向链表中。如果链表的头为空,则头和尾被设置为新的节点。这是因为当只有一个元素时,该元素既是头部也是尾部。否则,temp变量用于存储新节点。新节点的next指向当前头,然后当前头的prev指向新节点。最后,头指针被更新到新节点。
1 DoublyLinkedList.prototype.addAtFront = function(value) {
2 if (this.head === null) { //If first node
3 this.head = new DoublyLinkedListNode(value);
4 this.tail = this.head;
5 } else {
7 var temp = new DoublyLinkedListNode(value);
8 temp.next = this.head;
9 this.head.prev = temp;
10 this.head = temp;
11 }
12 this.size++;
13 }
14 var dll1 = new DoublyLinkedList();
15 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
16 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
17 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
时间复杂度: O(1)
尾部插入
类似地,可以向双向链表的尾部添加一个新节点,如下面的代码块所示:
1 DoublyLinkedList.prototype.insertAtTail = function(value) {
2 if (this.tail === null) { //If first node
3 this.tail = new DoublyLinkedListNode(value);
4 this.head = this.tail;
5 } else {
6 var temp = new DoublyLinkedListNode(value);
7 temp.prev = this.tail;
8 this.tail.next = temp;
9 this.tail = temp;
10 }
11 this.size++;
12 }
13
14 var dll1 = new DoublyLinkedList();
15 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
16 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
17 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
18 dll1.insertAtTail(30); // ddl1's structure: tail: 30 head: 20
时间复杂度: O(1)
开头删除
从双向链表中移除头部的节点可以在 O(1)时间内完成。如果头尾相同的情况下只有一项,那么头尾都设置为null。否则,头部被设置为头部的next指针。最后,将新头的prev设置为null以移除旧头的引用。这在下面的代码块中实现。这很棒,因为它可以像队列数据结构中的dequeue函数一样使用。
1 DoublyLinkedList.prototype.deleteAtHead = function() {
2 var toReturn = null;
3
4 if (this.head !== null) {
5 toReturn = this.head.data;
6
7 if (this.tail === this.head) {
8 this.head = null;
9 this.tail = null;
10 } else {
11 this.head = this.head.next;
12 this.head.prev = null;
13 }
14 }
15 this.size--;
16 return toReturn;
17 }
时间复杂度: O(1)
尾部删除
与移除头部节点类似,尾部节点可以在 O(1)时间内移除并返回,如下面的代码块所示。由于具有在尾部移除的能力,双向链表也可以被认为是一种双向队列数据结构。队列可以将第一个添加的项出队,但是双向链表可以在 O(1)时间内将尾部的项或头部的项出队。
1 DoublyLinkedList.prototype.deleteAtTail = function() {
2 var toReturn = null;
3
4 if (this.tail !== null) {
5 toReturn = this.tail.data;
6
7 if (this.tail === this.head) {
8 this.head = null;
9 this.tail = null;
10 } else {
11 this.tail = this.tail.prev;
12 this.tail.next = null;
13 }
14 }
15 this.size--;
16 return toReturn;
17 }
18 var dll1 = new DoublyLinkedList();
19 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
20 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
21 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
22 dll1.insertAtTail(30); // ddl1's structure: tail: 30 head: 20
23 dll1.deleteAtTail();
24 // ddl1's structure: tail: 10 head: 20
时间复杂度: O(1)
搜索
要找出一个值是否存在于双向链表中,可以从头部开始使用next指针,或者从tail开始使用prev指针。以下代码块与单链表搜索实现相同,它从头部开始查找项:
1 DoublyLinkedList.prototype.findStartingHead = function(value) {
2 var currentHead = this.head;
3 while(currentHead.next){
4 if(currentHead.data == value){
5 return true;
6 }
7 currentHead = currentHead.next;
8 }
9 return false;
10 }
11 var dll1 = new DoublyLinkedList();
12 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
13 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
14 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
15 dll1.insertAtTail(30); // ddl1's structure: tail: 30 head: 20
16 dll1.findStartingHead(10); // true
17 dll1.findStartingHead(100); // false
时间复杂度: O( n
以下代码使用prev指针从尾部开始遍历双向链表:
1 DoublyLinkedList.prototype.findStartingTail = function(value) {
2 var currentTail = this.tail;
3 while (currentTail.prev){
4 if(currentTail.data == value){
5 return true;
6 }
7 currentTail = currentTail.prev;
8 }
9 return false;
10 }
11
12 var dll1 = new DoublyLinkedList();
13 dll1.insertAtHead(10); // ddl1's structure: tail: 10 head: 10
14 dll1.insertAtHead(12); // ddl1's structure: tail: 10 head: 12
15 dll1.insertAtHead(20); // ddl1's structure: tail: 10 head: 20
16 dll1.insertAtTail(30); // ddl1's structure: tail: 30 head: 20
17 dll1.findStartingTail(10); // true
18 dll1.findStartingTail(100); // false
时间复杂度: O( n
虽然搜索的时间复杂度与单链表的搜索相同,但是只有双向链表可以双向搜索(使用prev或next)。这意味着如果给定一个对双向链表节点的引用,双向链表可以执行完全搜索,但是单向链表仅限于它的next指针。
摘要
链表数据结构的工作原理是,每个节点都有一个指向不同节点的下一个指针(以及前一个指针,如果是双重链接,则为prev指针)。单向链表和双向链表的插入都具有恒定的时间复杂度 O(1)。从单链表和双向链表的头部删除的时间复杂度也是 O(1)。然而,在单向链表和双向链表中搜索一个条目需要 O( n )时间。当需要双向遍历/搜索时,双向链表应该比单向链表使用得多。此外,双向链表允许您从链表的尾部或头部弹出,以实现灵活快速的 O(1)运算。
练习
你可以在 GitHub 上找到所有练习的代码。 2
反转单向链表
要反转单向链表,只需遍历每个节点,并将当前节点的next属性设置为前一个节点。
1 function reverseSingleLinkedList(sll){
2 var node = sll.head;
3 var prev = null;
4 while(node){
5 var temp = node.next;
6 node.next = prev;
7 prev = node;
8 if(!temp)
9 break;
10 node = temp;
11 }
12 return node;
13 }
时间复杂度: O( n
空间复杂度: O(1)
要完全反转一个链表,必须遍历链表的全部 N 个元素。
删除链表中的重复项
删除链表中的项目很简单。简单地在一个数组中迭代和存储访问过的节点。如果当前元素之前已经出现过,则删除当前元素。
1 // delete duplicates in unsorted linkedlist
2 function deleteDuplicateInUnsortedSll(sll1) {
3 var track = [];
4
5 var temp = sll1.head;
6 var prev = null;
7 while (temp) {
8 if (track.indexOf(temp.data) >= 0) {
9 prev.next = temp.next;
10 sll1.size--;
11 } else {
12 track.push(temp.data);
13 prev = temp;
14 }
15 temp = temp.next;
16 }
17 console.log(temp);
18 }
**时间复杂度:**O(n2
空间复杂度: O( n
但是,这个算法必须用.indexOf()方法迭代数组,这是 O( n )以及迭代 n 次。因此在时间复杂度上是 O( n 2 )。另外,track数组增长到了 N 的大小,这导致空间复杂度为 O( n )。让我们把时间复杂度降低到 O( n )。
1 //delete duplicates in unsorted linkedlist
2 function deleteDuplicateInUnsortedSllBest(sll1) {
3 var track = {};
4
5 var temp = sll1.head;
6 var prev = null;
7 while (temp) {
8 if (track[temp.data]) {
9 prev.next = temp.next;
10 sll1.size--;
11 } else {
12 track[temp.data] = true;
13 prev = temp;
14 }
15 temp = temp.next;
16 }
17 console.log(temp);
18 }
时间复杂度: O( n
空间复杂度: O( n
使用 JavaScript Object作为哈希表来存储和检查可见的元素,将空间减少到 O( n )但是 O( n )因为哈希表需要额外的内存。
https://github.com/Apress/js-data-structures-and-algorithms
2
https://github.com/Apress/js-data-structures-and-algorithms
十四、缓存
缓存是将数据存储到临时存储器中的过程,以便日后再次需要时可以轻松检索。例如,数据库系统缓存数据以避免重新读取硬盘,web 浏览器缓存网页(图像和素材)以避免重新下载内容。简而言之,在缓存中,目标是最大化命中(请求时项目在缓存中)和最小化未命中(请求时项目不在缓存中)。
本章将讨论两种缓存技术:最少使用的(LFU)和最近最少使用的(LRU)缓存。
注意
缓存的概念来自操作系统领域。你可以在滑铁卢大学的杰夫·扎奈特的演讲中了解更多。
了解缓存
高速缓存设计通常考虑这两个因素:
-
时间局部性:最近被访问过的存储器位置很可能再次被访问。
-
空间位置:最近被访问过的存储器位置附近的存储器位置很可能再次被访问。
最佳缓存算法将能够用要插入的新元素来替换缓存中在将来最远处使用的部分。对于每个项目,这将需要计算该项目在未来将被访问多少次。你应该很清楚这是不可能实现的,因为它需要展望未来。
最不常用的缓存
最不频繁使用的*(LFU)缓存是操作系统用来管理内存的一种缓存算法。系统跟踪内存中块被引用的次数。根据设计,当缓存超过其限制时,系统会删除引用频率最低的项目。LFU 缓存最简单的实现是为加载到缓存中的每个块分配一个计数器,并在每次引用该块时递增一个计数器。当缓存超过其限制时,系统会搜索计数器最低的块,并将其从缓存中删除。*
*尽管 LFU 缓存看起来是一种直观的方法,但当内存中的某个项被短期重复引用且不再被访问时,这种方法并不理想。由于其重复引用,该块的频率较高,但是这迫使系统删除在短时间块之外可能更频繁使用的其他块。此外,系统中的新项目很容易被快速删除,因为它们被访问的频率较低。由于这些问题,LFU 是不常见的,但一些混合系统利用核心 LFU 概念。这种系统的例子是移动键盘应用。建议的单词出现在键盘应用程序上,使用 LFU 缓存来实现这一点是有意义的,因为用户可能经常使用相同的单词。一个单词的出现频率将是一个很好的衡量该单词是否应该存在于缓存中的标准。
LFU 缓存使用双向链表在 O(1)时间内移除元素。LFUs 中的双重链接节点也有freqCount属性,它表示在第一次插入后它被访问/设置的频率。
1 function LFUNode(key, value) {
2 this.prev = null;
3 this.next = null;
4 this.key = key;
5 this.data = value;
6 this.freqCount = 1;
7 }
LFU 缓存有两个散列表:keys和freq 。 freq有 frequency 键(1 到 n ,其中 n 是元素访问的最高频率),每一项都是一个双向链表类的实例。keys存储每个双向链表节点,用于 O(1)检索。双向链表和 LFU 缓存的类定义如下:
1 function LFUDoublyLinkedList(){
2 this.head = new LFUNode('buffer head',null);
3 this.tail = new LFUNode('buffer tail',null);
4 this.head.next = this.tail;
5 this.tail.prev = this.head;
6 this.size = 0;
7 }
8
9 function LFUCache(capacity){
10 this.keys = {}; // stores LFUNode
11 this.freq = {}; // stores LFUDoublyLinkedList
12 this.capacity = capacity;
13 this.minFreq = 0;
14 this.size =0;
15 }
LFUDoublyLinkedList类也需要双向链表来实现插入和移除。然而,只需要在头部插入和在尾部移除。该实现与第十三章(链表)中所示的双向链表类的实现相同。
1 LFUDoublyLinkedList.prototype.insertAtHead = function(node) {
2 node.next = this.head.next;
3 this.head.next.prev = node;
4 this.head.next = node;
5 node.prev = this.head;
6 this.size++;
7 }
8
9 LFUDoublyLinkedList.prototype.removeAtTail = function() {
10 var oldTail = this.tail.prev;
11 var prev = this.tail.prev;
12 prev.prev.next = this.tail;
13 this.tail.prev = prev.prev;
14 this.size--;
15 return oldTail;
16 }
17
18 LFUDoublyLinkedList.prototype.removeNode = function(node) {
19 node.prev.next = node.next
20 node.next.prev = node.prev
21 this.size--;
22 }
LFU 的实施有几个步骤。有两种情况:插入新项目和替换旧项目。插入新项目时,会创建一个新节点。如果缓存未满,可以将其插入到freq的频率为 1 的双向链表中。如果容量已满,则删除频率双向链表中的尾项,然后插入新节点。
如果该元素已经存在并且需要被替换,则该节点被带到其对应的频率双向链表的头部。最后,最小频率变量minFreq相应地递增,以计算将来应该驱逐哪个项目。
1 LFUCache.prototype.set = function(key, value) {
2 var node = this.keys[key];
3
4 if (node == undefined) {
5 node = new LFUNode(key, value);
6
7 this.keys[key] = node;
8
9 if (this.size != this.capacity) {
10 // insert without deleting
11 if (this.freq[1] === undefined){
12 this.freq[1] = new LFUDoublyLinkedList();
13 }
14 this.freq[1].insertAtHead(node);
15 this.size++;
16 } else {
17 // delete and insert
18 var oldTail = this.freq[this.minFreq].removeAtTail();
19 delete this.keys[oldTail.key];
20
21 if (this.freq[1] === undefined){
22 this.freq[1] = new LFUDoublyLinkedList();
23 }
24
25 this.freq[1].insertAtHead(node);
26 }
27 this.minFreq = 1;
28 } else {
29 var oldFreqCount = node.freqCount;
30 node.data = value;
31 node.freqCount++;
32
33 this.freq[oldFreqCount].removeNode(node);
34
35 if (this.freq[node.freqCount] === undefined){
36 this.freq[node.freqCount] = new LFUDoublyLinkedList();
37 }
38
39 this.freq[node.freqCount].insertAtHead(node);
40
41 if (oldFreqCount == this.minFreq && Object.keys(this.freq[oldFreqCount]).size == 0) {
42 this.minFreq++;
43 }
44
45 }
46 }
为了实现get,缓存需要在 O(1)时间内返回现有的节点,并增加访问的计数器。如果缓存中不存在该元素,则强制返回一个空元素。否则,增加元素的频率,将项目放在双向链表的头部,并相应地调整最小频率变量minFreq。
1 LFUCache.prototype.get = function(key) {
2 var node = this.keys[key];
3
4 if (node == undefined) {
5 return null;
6 } else {
7
8 var oldFreqCount = node.freqCount;
9 node.freqCount++;
10
11 this.freq[oldFreqCount].removeNode(node);
12
13 if (this.freq[node.freqCount] === undefined){
14 this.freq[node.freqCount] = new LFUDoublyLinkedList();
15 }
16
17 this.freq[node.freqCount].insertAtHead(node);
18
19 if (oldFreqCount == this.minFreq && Object.keys(this.freq[oldFreqCount]).length == 0) {
20 this.minFreq++;
21 }
22 return node.data;
23 }
24 }
定义了所有函数后,以下代码显示了 LFU 用法的一个示例:
1 var myLFU = new LFUCache(5);
2 myLFU.set(1, 1); // state of myLFU.freq: {1: 1}
3 myLFU.set(2, 2); // state of myLFU.freq: {1: 2<->1}
4 myLFU.set(3, 3); // state of myLFU.freq: {1: 3<->2<->1}
5 myLFU.set(4, 4); // state of myLFU.freq: {1: 4<->3<->2<->1}
6 myLFU.set(5, 5); // state of myLFU.freq: {1: 5<->4<->3<->2<->1}
7 myLFU.get(1); // returns 1, state of myLFU.freq: {1: 5<->4<->3<->2, 2: 1}
8 myLFU.get(1); // returns 1, state of myLFU.freq: {1: 5<->4<->3<->2, 3: 1}
9 myLFU.get(1); // returns 1, state of myLFU.freq:{1: 5<->4<->3<->2, 4: 1}
10 myLFU.set(6, 6); // state of myLFU.freq: {1: 6<->5<->4<->3, 4: 1}
11 myLFU.get(6); // state of myLFU.freq: {1: 5<->4<->3, 4: 1, 2: 6}
最近最少使用的缓存
最近最少使用的 使用的 (LRU)缓存是一种缓存算法,它首先删除最旧的(最近最少使用的)项,因此被替换的项是最早访问的项。当访问高速缓存中的项目时,该项目移动到列表的后面(顺序中最新的)。当访问在高速缓存中没有找到的页面时,前面的项目(或顺序中最老的)被移除,而新的项目被放在列表的后面(顺序中最新的)。
该算法的实现需要跟踪何时使用了哪个节点。为了实现这一点,LRU 缓存是使用双向链表和哈希表实现的。
需要一个双向链表来跟踪头部(最老的数据)。因为最近使用的需求,所以需要双向链表。每次插入新数据时,头部都会向上移动,直到超出大小。那么最老的数据被驱逐。
图 14-1 显示了一个大小为 5 的 LRU 缓存的示意图。
图 14-1
LRU 高速缓存
为了实现 LRU 缓存,节点的定义类似于第十三章中的双向链表节点。该节点还有一个key属性,其实现如下面的代码块所示:
1 function DLLNode(key, data) {
2 this.key = key;
3 this.data = data;
4 this.next = null;
5 this.prev = null;
6 }
可以通过传递参数capacity来初始化 LRU 缓存。capacity定义缓存中允许有多少个节点。
1 function LRUCache(capacity) {
2 this.keys = {};
3 this.capacity = capacity;
4 this.head = new DLLNode(", null);
5 this.tail = new DLLNode(", null);
6 this.head.next = this.tail;
7 this.tail.prev = this.head;
8 }
由于 LRU 缓存使用双向链表,这里将定义两个函数,用于删除一个节点和添加一个节点到尾部:
1 LRUCache.prototype.removeNode = function(node) {
2 var prev = node.prev,
3 next = node.next;
4 prev.next = next;
5 next.prev = prev;
6 }
7
8 LRUCache.prototype.addNode = function(node) {
9 var realTail = this.tail.prev;
10 realTail.next = node;
11
12 this.tail.prev = node;
13 node.prev = realTail;
14 node.next = this.tail;
15 }
还需要定义两个函数:get和set。每当调用get时,LRU 缓存方案将该节点放在双向链表的头部,因为它是最近使用的节点。这与删除和添加节点是一样的。对于通过set设置节点,LRU 缓存上的keys属性用于存储节点,以保持在get的 O(1)时间内检索。但是,如果缓存达到最大容量,它会从尾部逐出最远的节点。
1 LRUCache.prototype.get = function(key) {
2 var node = this.keys[key];
3 if (node == undefined) {
4 return null;
5 } else {
6 this.removeNode(node);
7 this.addNode(node);
8 return node.data;
9 }
10 }
11
12 LRUCache.prototype.set = function(key, value) {
13 var node = this.keys[key];
14 if (node) {
15 this.removeNode(node);
16 }
17
18 var newNode = new DLLNode(key, value);
19
20 this.addNode(newNode);
21 this.keys[key] = newNode;
22
23 // evict a node
24 if (Object.keys(this.keys).length > this.capacity) {
25 var realHead = this.head.next;
26 this.removeNode(realHead);
27 delete this.keys[realHead.key];
28 }
29 }
最后,下面是一个大小为 5 的 LRU 缓存的示例:
1 var myLRU = new LRUCache(5);
2
3 myLRU.set(1, 1); // 1
4 myLRU.set(2, 2); // 1 <-> 2
5 myLRU.set(3, 3); // 1 <-> 2 <-> 3
6 myLRU.set(4, 4); // 1 <-> 2 <-> 3 <-> 4
7 myLRU.set(5, 5); // 1 <-> 2 <-> 3 <-> 4 <-> 5
8
9
10 myLRU.get(1); // 2 <-> 3 <-> 4 <-> 5 <-> 1
11 myLRU.get(2); // 3 <-> 4 <-> 5 <-> 1 <-> 2
12
13 myLRU.set(6, 6);// 4 <-> 5 <-> 1 <-> 2 <-> 6
14 myLRU.set(7, 7);// 5 <-> 1 <-> 2 <-> 6 <-> 7
15 myLRU.set(8, 8);// 1 <-> 2 <-> 6 <-> 7 <-> 8
摘要
本章介绍了两个主要的缓存概念:最少使用和最近最少使用。这一章谈到了最佳缓存算法的概念,这是不可能实现的,但提供了一个你想要近似的概念。LFU 缓存听起来很棒,因为它使用频率来确定应该驱逐哪个节点,但是 LFU 在大多数情况下不如 LRU,因为它没有考虑时间局部性。还有其他的缓存算法,但是大多数算法在一般情况下都比较差,比如最近没有使用的算法和先进先出算法。最后,需要注意的是,鉴于现实生活中系统行为工作负载的许多已知数据,LRU 在大多数情况下是最有效的算法。表 14-1 总结了缓存算法。
表 14-1
缓存摘要
|算法
|
评论
| | --- | --- | | 最佳的 | 不可能实现 | | 最不常用 | 对时间局部性不利 | | 最近最少使用 | 使用双向链接+ hashmap |
Footnotes 1https://github.com/jzarnett/ece254/blob/master/lectures/L21-slides-Memory_Segmentation_Paging.pdf
*
十五、树
一般的树数据结构由带有子节点的节点组成。第一个/顶层节点被称为根节点。本章将探讨许多不同类型的树,如二叉树、二分搜索法树和自平衡二分搜索法树。首先,本章将介绍什么是树以及它们是如何构成的。然后,它将详细介绍遍历树数据结构的方法。最后,您将学习二分搜索法树和自平衡二分搜索法树,了解如何存储易于搜索的数据。
一般树形结构
一个普通的树数据结构看起来如图 15-1 所示,它可以有任意数量的孩子。
图 15-1
具有任意数量子树的广义树
图 15-1 树中节点的代码块如下:
1 function TreeNode(value){
2 this.value = value;
3 this.children = [];
4 }
二叉树
二叉树是一种只有两个子节点的树:左和右。参见下面的代码和图 15-2 :
图 15-2
二叉树
1 function BinaryTreeNode(value) {
2 this.value = value;
3 this.left = null;
4 this.right = null;
5 }
二叉树总是有一个根节点(顶部的节点),在插入任何元素之前,它被初始化为null。
1 function BinaryTree(){
2 this._root = null;
3 }
树遍历
遍历数组很简单:使用索引访问树,并递增索引,直到索引达到大小限制。对于树,为了遍历树中的每个元素,必须跟随左右指针。当然,有各种方法可以做到这一点;最流行的遍历技术是前序遍历、后序遍历、按序遍历和层次序遍历。
GitHub 上提供了所有的树遍历代码。 1
前序遍历
前序遍历按以下顺序访问节点:根(当前节点)、左、右。在图 15-3 中,可以看到 42 是根,所以先访问它。然后向左走;此时,父根(41)的左边现在被认为是新的根。这个新的根(41)被打印;然后它又向左走到 10。因此,10 被设置为新的根,但没有子节点就无法继续。那么 40 被访问,因为这是前一个父(41)的权利。这个过程继续,整个订单由图 15-3 中的灰色方块表示。
图 15-3
前序遍历
递归地,这很容易实现。当节点为null时,基本情况终止。否则,它将打印节点值,然后对其左侧子节点和右侧子节点调用递归函数。
1 BinaryTree.prototype.traversePreOrder = function() {
2 traversePreOrderHelper(this._root);
3
4 function traversePreOrderHelper(node) {
5 if (!node)
6 return;
7 console.log(node.value);
8 traversePreOrderHelper(node.left);
9 traversePreOrderHelper(node.right);
10 }
11 }
这也可以迭代完成,但是实现起来比较困难。
1 BinaryTree.prototype.traversePreOrderIterative = function() {
2 //create an empty stack and push root to it
3 var nodeStack = [];
4 nodeStack.push(this._root);
5
6 // Pop all items one by one. Do following for every popped item
7 // a) print it
8 // b) push its right child
9 // c) push its left child
10 // Note that right child is pushed first so that left
11 // is processed first */
12 while (nodeStack.length) {
13 //# Pop the top item from stack and print it
14 var node = nodeStack.pop();
15 console.log(node.value);
16
17 //# Push right and left children of the popped node to stack
18 if (node.right)
19 nodeStack.push(node.right);
20 if (node.left)
21 nodeStack.push(node.left);
22 }
23 }
下面是结果:[42,41,10,40,50,45,75]。
有序遍历
有序遍历按以下顺序访问节点:左、根(当前节点)、右。对于图 15-4 所示的树,灰色方块表示有序遍历顺序。如您所见,首先打印 10(最左边的节点),最后打印 7(最右边的节点)。
图 15-4
有序遍历
用递归也可以很容易地实现有序遍历。基本情况是当一个节点是null时。在非基本情况下,它调用左边子节点上的递归函数,打印当前节点,然后调用右边子节点上的递归函数。
1 BinaryTree.prototype.traverseInOrder = function() {
2 traverseInOrderHelper(this._root);
3
4 function traverseInOrderHelper(node) {
5 if (!node)
6 return;
7 traverseInOrderHelper(node.left);
8 console.log(node.value);
9 traverseInOrderHelper(node.right);
10 }
11 }
12
13 BinaryTree.prototype.traverseInOrderIterative = function() {
14 var current = this._root,
15 s = [],
16 done = false;
17
18 while (!done) {
19 // Reach the left most Node of the current Node
20 if (current != null) {
21 // Place pointer to a tree node on the stack
22 // before traversing the node's left subtree
23 s.push(current);
24 current = current.left;
25 } else {
26 if (s.length) {
27 current = s.pop();
28 console.log(current.value);
29 current = current.right;
30 } else {
31 done = true;
32 }
33 }
34 }
35 }
下面是这次遍历的结果:[10,41,40,42,45,50,75]。
后序遍历
后序遍历按以下顺序访问节点:左、右、根(当前节点)。对于图 15-5 所示的树,灰色方块表示有序遍历顺序。如您所见,首先打印 10(最左边的节点),最后打印 42(根节点)。
图 15-5
后序遍历
代码如下:
1 BinaryTree.prototype.traversePostOrder = function() {
2 traversePostOrderHelper(this._root);
3
4 function traversePostOrderHelper(node) {
5 if (node.left)
6 traversePostOrderHelper(node.left);
7 if (node.right)
8 traversePostOrderHelper(node.right);
9 console.log(node.value);
10 }
11 }
12
13 BinaryTree.prototype.traversePostOrderIterative = function() {
14 // Create two stacks
15 var s1 = [],
16 s2 = [];
17
18 // Push root to first stack
19 s1.push(this._root);
20
21 //# Run while first stack is not empty
22 while (s1.length) {
23 // Pop an item from s1 and append it to s2
24 var node = s1.pop();
25 s2.push(node);
26
27 // Push left and right children of removed item to s1
28 if (node.left)
29 s1.push(node.left);
30 if (node.right)
31 s1.push(node.right);
32 }
33 // Print all elements of second stack
34 while (s2.length) {
35 var node = s2.pop();
36 console.log(node.value);
37 }
38 }
结果是这样的:[10,40,41,45,75,50,42]。
层次顺序遍历
层次顺序遍历,如图 15-6 所示,又称广度优先搜索 (BFS)。
图 15-6
层次顺序遍历
更多内容将在第十七章中介绍,但这种方法本质上是逐层访问每个节点,而不是深入左侧或右侧。
1 BinaryTree.prototype.traverseLevelOrder = function() {
2 // Breath first search
3 var root = this._root,
4 queue = [];
5
6 if (!root)
7 return;
8 queue.push(root);
9
10 while (queue.length) {
11 var temp = queue.shift();
12 console.log(temp.value);
13 if (temp.left)
14 queue.push(temp.left);
15 if (temp.right)
16 queue.push(temp.right);
17 }
18 }
下面是结果:[42,41,50,10,40,45,75]。
树遍历摘要
如果您知道您需要在检查任何叶子之前探索根,选择前序遍历,因为您将在所有叶子之前遇到所有的根。
如果您知道您需要在任何节点之前探索所有的叶子,选择后序遍历,因为您在搜索叶子时不会浪费任何时间来检查根。
如果您知道树在节点中有一个固有的序列,并且您想要将树展平到它的原始序列,那么您应该使用有序遍历。该树将以创建时的方式展平。前序或后序遍历可能不会将树展开回创建它时的顺序。
时间复杂度: O( n
任何这些遍历的时间复杂度是相同的,因为每个遍历都需要访问所有节点。
二分搜索法树
二分搜索法树(BST)也有两个孩子,左和右。然而,在二叉查找树中,左边的孩子比父母小,右边的孩子比父母大。BST 具有这种结构,因为这种特性使得搜索、插入和删除特定值的时间复杂度为 O(log 2 ( n ))。
图 15-7 显示了 BST 属性。1 比 2 小,所以是 2 的左子,由于 3 比 3 大,所以是 2 的右子。
图 15-7
二叉查找树
二分搜索法树有一个根节点(最顶端的节点),它最初被初始化null(在插入任何项目之前)。
1 function BinarySearchTree(){
2 this._root = null;
3 }
图 15-7 也显示了一个平衡的二叉查找树,通过在左右两侧都有孩子来最小化高度。然而,图 15-8 显示了一个不平衡的树,其中子节点仅位于父节点的右侧。这对数据结构有很大的影响,增加了插入、删除和搜索的时间复杂度,从 O(log 2 ( n ))增加到 O( n )。完美平衡的树的高度是 log 2 ( n ),而不平衡的树在最坏的情况下可以是 n 。
图 15-8
不平衡的二叉查找树
插入
插入 BST 需要几个步骤。首先,如果根是空的,那么根将成为新的节点。否则,使用while循环遍历 BST,直到满足正确的条件。在每次循环迭代中,检查新节点是大于还是小于currentRoot。
1 BinarySearchTree.prototype.insert = function(value) {
2 var thisNode = {left: null, right: null, value: value};
3 if(!this._root){
4 //if there is no root value yet
5 this._root = thisNode;
6 }else{
7 //loop traverse until
8 var currentRoot = this._root;
9 while(true){
10 if(currentRoot.value>value){
11 //let's increment if it's not a null and insert if it is a null
12 if(currentRoot.left!=null){
13 currentRoot = currentRoot.left;
14 }else{
15 currentRoot.left = thisNode;
16 break;
17 }
18 } else if (currentRoot.value<value){
19 //if bigger than current, put it on the right
20 //let's increment if it's not a null and insert if it is a null
21 if(currentRoot.right!=null){
22 currentRoot = currentRoot.right;
23 }else{
24 currentRoot.right = thisNode;
25 break;
26 }
27 } else {
28 //case that both are the same
29 break;
30 }
31 }
32 }
33 }
**时间复杂度(对于平衡树):**O(log2(n))
时间复杂度(对于不平衡树): O( n
时间复杂度取决于二叉查找树的高度。
删除
该算法首先遍历树,专门寻找具有指定值的节点。找到节点后,有三种可能的情况:
-
案例 1:节点没有子节点。
这是最简单的情况。如果节点没有子节点,则返回
null。该节点现在已被删除。 -
案例 2:节点有一个子节点。
如果节点只有一个子节点,只需返回现有的子节点。那个孩子现在已经长大并取代了父母。
-
案例 3:节点有两个子节点。
如果节点有两个子节点,要么找到左子树的最大值,要么找到右子树的最小值来替换该节点。
下面的代码实现了上述三种情况。首先,它递归遍历,直到满足其中一种情况,然后删除节点。
1 BinarySearchTree.prototype.remove = function(value) {
2
3 return deleteRecursively(this._root, value);
4
5 function deleteRecursively(root, value) {
6 if (!root) {
7 return null;
8 } else if (value < root.value) {
9 root.left = deleteRecursively(root.left, value);
10 } else if (value > root.value) {
11 root.right = deleteRecursively(root.right, value);
12 } else {
13 //no child
14 if (!root.left && !root.right) {
15 return null; // case 1
16 } else if (!root.left) { // case 2
17 root = root.right;
18 return root;
19 } else if (!root.right) { // case 2
20 root = root.left;
21 return root;
22 } else {
23 var temp = findMin(root.right); // case 3
24 root.value = temp.value;
25 root.right = deleteRecursively(root.right, temp.value);
26 return root;
27 }
28 }
29 return root;
30 }
31
32 function findMin(root) {
33 while (root.left) {
34 root = root.left;
35 }
36 return root;
37 }
38 }
**时间复杂度(对于平衡树):**O(log2(n))
时间复杂度(对于不平衡树): O( n
删除的时间复杂度也是 O(log2(n)),因为最多需要遍历这个高度来找到并删除想要的节点。
搜索
可以使用 BST 节点的左子节点总是小于其父节点并且 BST 节点的右子节点总是大于其父节点的属性来执行搜索。遍历树可以通过检查currentRoot是否小于或大于要搜索的值来完成。如果currentRoot较小,则访问正确的孩子。如果currentRoot更大,则访问左边的孩子。
1 BinarySearchTree.prototype.findNode = function(value) {
2 var currentRoot = this._root,
3 found = false;
4 while (currentRoot) {
5 if (currentRoot.value > value) {
6 currentRoot = currentRoot.left;
7 } else if (currentRoot.value < value) {
8 currentRoot = currentRoot.right;
9 } else {
10 //we've found the node
11 found = true;
12 break;
13 }
14 }
15 return found;
16 }
17 var bst1 = new BinarySearchTree();
18 bst1.insert(1);
19 bst1.insert(3);
20 bst1.insert(2);
21 bst1.findNode(3); // true
22 bst1.findNode(5); // false
**时间复杂度(对于平衡树):**O(log2(n))
时间复杂度(对于不平衡树): O( n
注意,所有操作的时间复杂度都等于二叉树搜索的高度。对于不平衡的二分搜索法树,时间复杂度很高。为了解决这个问题,有二分搜索法树家族确保高度平衡。这种自平衡树的一个例子是 AVL 树。
AVL 树
AVL 是一个自我平衡的二叉查找树;它是以发明家乔治·阿德尔森-维尔斯基和叶夫根尼·兰迪斯的名字命名的。AVL 树将 BST 高度保持在最小,并确保 O(log2(n))的时间复杂度用于搜索、插入和删除。在前面的例子中,我们定义了TreeNode和Tree类,并将Tree的根设置为TreeNode类。然而,对于 AVL 树实现,只有代表 AVL 树节点的AVLTree类将被用于简化代码。
1 function AVLTree (value) {
2 this.left = null;
3 this.right = null;
4 this.value = value;
5 this.depth = 1;
6 }
AVL 树的高度基于子树的高度,可以使用以下代码块来计算:
1 AVLTree.prototype.setDepthBasedOnChildren = function() {
2 if (this.node == null) {
3 this.depth = 0;
4 } else {
5 this.depth = 1;
6 }
7
8 if (this.left != null) {
9 this.depth = this.left.depth + 1;
10 }
11 if (this.right != null && this.depth <= this.right.depth) {
12 this.depth = this.right.depth + 1;
13 }
14 }
单次旋转
AVL 树旋转他们的孩子来保持插入后的平衡。
左旋 90 度
这是一个节点必须向左旋转的例子。节点 40 的子节点 45 和 47 导致高度不平衡,如图 15-9 所示。45 成为图 15-10 中的父节点,以平衡 BST。
图 15-10
之后向左旋转
图 15-9
之前向左旋转
要执行向左旋转,首先获取左边的子元素并存储它。这才是“原本”的左孩子。最初的左子节点现在将成为该节点的父节点。将节点的左子节点设置为原左子节点的左子节点。最后,将原左子的右子设置为节点。
1 AVLTree.prototype.rotateLL = function() {
2
3 var valueBefore = this.value;
4 var rightBefore = this.right;
5 this.value = this.left.value;
6
7 this.right = this.left;
8 this.left = this.left.left;
9 this.right.left = this.right.right;
10 this.right.right = rightBefore;
11 this.right.value = valueBefore;
12
13 this.right.getDepthFromChildren();
14 this.getDepthFromChildren();
15 };
右旋 90 度
这是一个节点必须向右旋转的例子。60 的孩子,55 和 52 的节点,导致高度不平衡,如图 15-11 。55 节点成为图 15-12 中的父节点,以平衡 BST。
图 15-12
右后旋转
图 15-11
之前向右旋转
要实现前面描述的算法,首先获取左边的子元素并存储它。这是最初的左孩子。最初的左子节点现在将成为该节点的父节点。将节点的左子节点设置为原左子节点的左子节点。最后,将原左子的右子设置为节点。
1 AVLTree.prototype.rotateRR = function() {
2 // the right side is too long => rotate from the right (_not_ rightwards)
3 var valueBefore = this.value;
4 var leftBefore = this.left;
5 this.value = this.right.value;
6
7 this.left = this.right;
8 this.right = this.right.right;
9 this.left.right = this.left.left;
10 this.left.left = leftBefore;
11 this.left.value = valueBefore;
12
13 this.left.updateInNewLocation();
14 this.updateInNewLocation();
15 }
双旋转
如果 AVL 树在一次旋转后仍然不平衡,它必须旋转两次以达到完全平衡。
左右旋转(先右后左)
在本例中,图 15-13 显示了高度为 3 的 BST。先右后左旋转,如图 15-14 和图 15-15 所示,达到平衡。
图 15-15
之后向左旋转
图 15-14
先向右旋转
图 15-13
先向右旋转,然后向左旋转是合适的
向左向右旋转(先左后右)
同样,在本例中,图 15-16 显示了高度为 3 的 BST。先左后右旋转,如图 15-17 和图 15-18 所示,达到平衡。
图 15-18
右后旋转
图 15-17
先向左旋转
图 15-16
先向左旋转,然后向右旋转是合适的
平衡树
要检查 AVL 树的平衡,只需简单比较左右儿童的身高。如果高度不平衡,就需要旋转。当左大于右时,左旋转完成。当右大于左时,右旋转完成。
1 AVLTree.prototype.balance = function() {
2 var ldepth = this.left == null ? 0 : this.left.depth;
3 var rdepth = this.right == null ? 0 : this.right.depth;
4
5 if (ldepth > rdepth + 1) {
6 // LR or LL rotation
7 var lldepth = this.left.left == null ? 0 : this.left.left.depth;
8 var lrdepth = this.left.right == null ? 0 : this.left.right.depth;
9
10 if (lldepth < lrdepth) {
11 // LR rotation consists of a RR rotation of the left child
12 this.left.rotateRR();
13 // plus a LL rotation of this node, which happens anyway
14 }
15 this.rotateLL();
16 } else if (ldepth + 1 < rdepth) {
17 // RR or RL rorarion
18 var rrdepth = this.right.right == null ? 0 : this.right.right.depth;
19 var rldepth = this.right.left == null ? 0 : this.right.left.depth;
20
21 if (rldepth > rrdepth) {
22 // RR rotation consists of a LL rotation of the right child
23 this.right.rotateLL();
24 // plus a RR rotation of this node, which happens anyway
25 }
26 this.rotateRR();
27 }
28 }
插入
AVL BST 中的插入与普通 BST 中的插入是一样的,除了一旦插入,父节点必须平衡其子节点并设置正确的深度。
1 AVLTree.prototype.insert = function(value) {
2 var childInserted = false;
3 if (value == this.value) {
4 return false; // should be all unique
5 } else if (value < this.value) {
6 if (this.left == null) {
7 this.left = new AVLTree(value);
8 childInserted = true;
9 } else {
10 childInserted = this.left.insert(value);
11 if (childInserted == true) this.balance();
12 }
13 } else if (value > this.value) {
14 if (this.right == null) {
15 this.right = new AVLTree(value);
16 childInserted = true;
17 } else {
18 childInserted = this.right.insert(value);
19
20 if (childInserted == true) this.balance();
21 }
22 }
23 if (childInserted == true) this.setDepthBasedOnChildren();
24 return childInserted;
25 }
**时间复杂度:**O(nlog2(n))
**空间复杂度:**O(nlog2(n))
空间复杂性来自内存中的递归调用栈。
删除
AVL BST 是 BST 的一种,因此删除功能是相同的。通过在遍历过程中调用setDepthBasedOnChildren()可以调整深度。
1 AVLTree.prototype.remove = function(value) {
2 return deleteRecursively(this, value);
3
4 function deleteRecursively(root, value) {
5 if (!root) {
6 return null;
7 } else if (value < root.value) {
8 root.left = deleteRecursively(root.left, value);
9 } else if (value > root.value) {
10 root.right = deleteRecursively(root.right, value);
11 } else {
12 //no child
13 if (!root.left && !root.right) {
14 return null; // case 1
15 } else if (!root.left) {
16 root = root.right;
17 return root;
18 } else if (!root.right) {
19 root = root.left;
20 return root;
21 } else {
22 var temp = findMin(root.right);
23 root.value = temp.value;
24 root.right = deleteRecursively(root.right, temp.value);
25 return root;
26 }
27 }
28 root.updateInNewLocation(); // ONLY DIFFERENCE from the BST one
29 return root;
30 }
31 function findMin(root) {
32 while (root.left) root = root.left;
33 return root;
34 }
35 }
时间复杂度和空间复杂度都是 O(nlog2(n))因为 AVL 树是平衡的。空间复杂度来自内存中的递归调用栈。
将所有这些放在一起:AVL 树示例
实现 AVL 树类后,图 15-19 显示了由以下代码块生成的 AVL 树示例:
图 15-19
AVL 结果
1 var avlTest = new AVLTree(1,");
2 avlTest.insert(2);
3 avlTest.insert(3);
4 avlTest.insert(4);
5 avlTest.insert(5);
6 avlTest.insert(123);
7 avlTest.insert(203);
8 avlTest.insert(2222);
9 console.log(avlTest);
如果使用普通二叉查找树,图 15-20 显示了相同插入顺序的情况。
图 15-20
BST 结果
显然,这是一个完全不平衡的扭曲的二叉查找树。此时,它看起来像一个链表。一旦树像这样变得完全不平衡,它的删除、插入和搜索就有了线性的时间复杂度,而不是对数时间。
摘要
表 15-1 显示了每次二叉查找树操作的时间复杂度。与其他数据结构相比,搜索操作比链表、数组、栈和队列更快。顾名思义,二进制搜索树非常适合搜索元素。但是插入和删除操作比较慢,时间复杂度为 O(log2(n))而不是像栈或者队列那样的 O(1)。此外,当树变得不平衡时,所有操作都变成 O( n )。为确保树保持平衡,应使用自平衡树(如红黑树或 AVL 树)来确保树操作具有对数时间复杂度。
表 15-1
树摘要
|操作
|
最佳(如果平衡)
|
最差(如果完全不平衡)
| | --- | --- | --- | | 删除 | O( 日志 2 ( n )) | O( n ) | | 插入 | O( 日志 2 ( n )) | O( n ) | | 搜索 | O( 日志 2 ( n )) | O( n ) |
练习
你可以在 GitHub 上找到所有练习的代码。 2
在给定的二叉树中寻找两个节点的最低共同祖先
这个的逻辑实际上相当简单,但是一开始很难注意到。
如果两个值中的最大值小于当前根,则向左。如果两个值中的最小值大于当前根,则向右。图 15-21 和 15-22 显示了这两种不同的情况。
图 15-22
最低共同祖先,示例 2
图 15-21
最低共同祖先,示例 1
1 function findLowestCommonAncestor(root, value1, value2) {
2 function findLowestCommonAncestorHelper(root, value1, value2) {
3 if (!root)
4 return;
5 if (Math.max(value1, value2) < root.value)
6 return findLowestCommonAncestorHelper(root.left, value1, value2);
7 if (Math.min(value1, value2) > root.value)
8 return findLowestCommonAncestorHelper(root.right, value1, value2);
9 return root.value
10 }
11 return findLowestCommonAncestorHelper(root, value1, value2);
12 }
13 var node1 = {
14 value: 1,
15 left: {
16 value: 0
17 },
18 right: {
19 value: 2
20 }
21 }
22
23 var node2 = {
24 value: 1,
25 left: {
26 value: 0,
27 left: {
28 value: -1
29 },
30 right: {
31 value: 0.5
32 }
33 },
34 right: {
35 value: 2
36 }
37 }
38 console.log(findLowestCommonAncestor(node1, 0, 2)); // 1
39 console.log(findLowestCommonAncestor(node2, 0, 2)); // 1
40 console.log(findLowestCommonAncestor(node1, 0.5, -1)); // 0
**时间复杂度:**O(log2(n))
打印距根第 n 个距离的节点
对于这个问题,以任何方式遍历 BST(在这个例子中使用了 level order ),并检查每个 BST 节点的高度,看是否应该打印它。
1 function printKthLevels(root, k) {
2 var arrayKth = [];
3 queue = [];
4
5 if (!root) return;
6
7 // Breath first search for tree
8 queue.push([root, 0]);
9
10 while (queue.length) {
11 var tuple = queue.shift(),
12 temp = tuple[0],
13 height= tuple[1];
14
15 if (height == k) {
16 arrayKth.push(temp.value);
17 }
18 if (temp.left) {
19 queue.push([temp.left, height+1]);
20 }
21 if (temp.right) {
22 queue.push([temp.right,height+1]);
23 }
24 }
25 console.log(arrayKth);
26 }
1 var node1 = {
2 value: 1,
3 left: {
4 value: 0
5 },
6 right: {
7 value: 2
8 }
9 }
10
11 var node2 = {
12 value: 1,
13 left: {
14 value: 0,
15 left: {
16 value: -1
17 },
18 right: {
19 value: 0.5
20 }
21 },
22 right: {
23 value: 2
24 }
25 }
26
27 var node3 = {
28 value: 1,
29 left: {
30 value: 0
31 },
32 right: {
33 value: 2,
34 left: {
35 value: 1.5
36 },
37 right: {
38 value: 3,
39 left: {
40 value: 3.25
41 }
42 }
43 }
44 }
45
46 printKthLevels(node1, 1); // 1
47 printKthLevels(node1, 2); // [0,2]
检查二叉树是否是另一棵树的子树
要做到这一点,以任何方式遍历二叉树(我选择层次顺序)并检查它当前所在的树是否与子树相同。
1 function isSameTree(root1, root2) {
2 if (root1 == null && root2 == null) {
3 return true;
4 }
5 if (root1 == null || root2 == null) {
6 return false;
7 }
8
9 return root1.value == root2.value &&
10 isSameTree(root1.left, root2.left) &&
11 isSameTree(root1.right, root2.right)
12 }
13
14 function checkIfSubTree(root, subtree) {
15 // Breath first search
16 var queue = [],
17 counter = 0;
18
19 // sanity check for root
20 if (!root) {
21 return;
22 }
23
24 queue.push(root);
25
26 while (queue.length) {
27 var temp = queue.shift();
28
29 if (temp.data == subtree.data == isSameTree(temp, subtree)) {
30 return true;
31 }
32
33 if (temp.left) {
34 queue.push(temp.left);
35 }
36 if (temp.right) {
37 queue.push(temp.right);
38 }
39 }
40 return false;
41 }
42
43 var node1 = {
44 value: 5,
45 left: {
46 value: 3,
47 left: {
48 value: 1
49 },
50 right: {
51 value: 2
52 }
53 },
54 right: {
55 value: 7
56 }
57 }
58
59 var node2 = {
60 value: 3,
61 left: {
62 value: 1
63 },
64 right: {
65 value: 2
66 }
67 }
68
69
70 var node3 = {
71 value: 3,
72 left: {
73 value: 1
74 }
75 }
76
77 console.log(checkIfSubTree(node1, node2)); // true
78 console.log(checkIfSubTree(node1, node3)); // false
79 console.log(checkIfSubTree(node2, node3)); // false
检查一棵树是否是另一棵树的镜像
图 15-23 显示了一个例子。
图 15-23
镜像树
这里有三种可能的情况:
-
它们的根节点的键必须相同。
-
a 的根的左子树和 b 的右子树根是镜像。
-
a 的右边子树和 b 的左边子树是镜像。
1 function isMirrorTrees(tree1, tree2) {
2 // Base case, both empty
3 if (!tree1 && !tree2) {
4 return true;
5 }
6
7 // One of them is empty, since only one is empty, not mirrored
8 if (!tree1 || !tree2) {
9 return false;
10 }
11
12 // Both non-empty, compare them recursively.
13 // Pass left of one and right of the other
14
15 var checkLeftwithRight = isMirrorTrees(tree1.left, tree2.right),
16 checkRightwithLeft = isMirrorTrees(tree2.right, tree1.left);
17
18 return tree1.value == tree2.value && checkLeftwithRight && checkRightwithLeft;
19 }
20
21 var node1 = {
22 value: 3,
23 left: {
24 value: 1
25 },
26 right: {
27 value: 2
28 }
29 }
30
31 var node2 = {
32 value: 3,
33 left: {
34 value: 2
35 },
36 right: {
37 value: 1
38 }
39 }
40
41 var node3 = {
42 value: 3,
43 left: {
44 value: 1
45 },
46 right: {
47 value: 2,
48 left: {
49 value: 2.5
50 }
51 }
52 }
53
54 console.log(isMirrorTrees(node1, node2)); // true
55 console.log(isMirrorTrees(node2, node3)); // false
Footnotes 1
https://github.com/Apress/js-data-structures-and-algorithms
2
https://github.com/Apress/js-data-structures-and-algorithms