原文连接:Data Structures for Beginners: Arrays, HashMaps, and Listsjavascript
众成翻译地址:初学者应该了解的数据结构:Array、HashMap 与 Listjava
校对:Volongnode
挺长的一篇文章,建议不太熟悉数据结构的同窗慢慢阅读一下这篇文章,但愿对你有所帮助~如下是译文正文:git
当开发程序时,咱们(一般)须要在内存中存储数据。根据操做数据方式的不一样,可能会选择不一样的数据结构。有不少经常使用的数据结构,如:Array、Map、Set、List、Tree、Graph 等等。(然而)为程序选取合适的数据结构可能并不容易。所以,但愿这篇文章能帮助你了解(不一样数据结构的)表现,以求在工做中合理地使用它们。程序员
本文主要聚焦于线性的数据结构,如:Array、Set、List、Sets、Stacks、Queues 等等。github
本篇是如下教程的一部分(译者注:若是你们以为还不错,我会翻译整个系列的文章):算法
初学者应该了解的数据结构与算法(DSA)编程
下表是本文所讨论内容的归纳。数组
加个书签、收藏或分享本文,以便不时之需。bash
* = 运行时分摊
数据结构 | 插入 | 访问 | 查找 | 删除 | 备注 |
---|---|---|---|---|---|
Array | O(n) | O(1) | O(n) | O(n) | 插入最后位置复杂度为 O(1)。 |
(Hash)Map | O(1)* | O(1)* | O(1)* | O(1)* | 从新计算哈希会影响插入时间。 |
Map | O(log(n)) | - | O(log(n)) | O(log(n)) | 经过二叉搜索树实现 |
Set(使用 HashMap) | O(1)* | - | O(1)* | O(1)* | 由 HashMap 实现 |
Set (使用 List) | O(n) | - | O(n)] | O(n) | 经过 List 实现 |
Set (使用二叉搜索树) | O(log(n)) | - | O(log(n)) | O(log(n)) | 经过二叉搜索树实现 |
Linked List (单向) | O(n) | - | O(n) | O(n) | 在起始位置添加或删除元素,复杂度为 O(1) |
Linked List (双向) | O(n) | - | O(n) | O(n) | 在起始或结尾添加或删除元素,复杂度为 O(1)。然而在中间位置是 O(n)。 |
Stack (由 Array 实现) | O(1) | - | - | O(1)] | 插入与删除都遵循与后进先出(LIFO) |
Queue (简单地由 Array 实现) | O(n) | - | - | O(1) | 插入(Array.shift)操做的复杂度是 O(n) |
Queue (由 Array 实现,但进行了改进) | O(1)* | - | - | O(1) | 插入操做的最差状况复杂度是 O(n)。然而分摊后是 O(1) |
Queue (由 List 实现) | O(1) | - | - | O(1) | 使用双向链表 |
注意: 二叉搜索树 与其余树结构、图结构,将在另外一篇文章中讨论。
原始数据类型是构成数据结构最基础的元素。下面列举出一些原始原始数据类型:
数组可由零个或多个元素组成。因为数组易于使用且检索性能优越,它是最经常使用的数据结构之一。
你能够将数组想象成一个抽屉,能够将数据存到匣子中。
数组就像将东西存到匣子中的抽屉
当你想查找某个元素时,你能够直接打开对应编号的匣子(时间复杂度为 O(1))。然而,若是你忘记了匣子里存着什么,就必须逐个打开全部的匣子(时间复杂度为 O(n)),直到找到所需的东西。数组也是如此。
根据编程语言的不一样,数组存在一些差别。对于 JavaScript 和 Ruby 等动态语言而言,数组能够包含不一样的数据类型:数字,字符串,对象甚至函数。而在 Java 、 C 、C ++ 之类的强类型语言中,你必须在使用数组以前,定好它的长度与数据类型。JavaScript 会在须要时自动增长数组的长度。
根据编程序言的不一样,数组(方法)的实现稍有不一样。
好比在 JavaScript 中,咱们可使用 unshift
与 push
添加元素到数组的头或尾,同时也可使用 shift
与 pop
删除数组的首个或最后一个元素。让咱们来定义一些本文用到的数组经常使用方法。
经常使用的 JS 数组内置函数
函数 | 复杂度 | 描述 |
---|---|---|
array.push(element1[, …[, elementN]]) | O(1) | 将一个或多个元素添加到数组的末尾 |
array.pop() | O(1) | 移除数组末尾的元素 |
array.shift() | O(n) | 移除数组开头的元素 |
array.unshift(element1[, …[, elementN]]) | O(n) | 将一个或多个元素添加到数组的开头 |
array.slice([beginning[, end]]) | O(n) | 返回浅拷贝原数组从 beginning 到 end (不包括 end )部分组成的新数组 |
array.splice(start[, deleteCount[, item1[,…]]]) | O(n) | 改变 (插入或删除) 数组 |
将元素插入到数组有不少方式。你能够将新数据添加到数组末尾,也能够添加到数组开头。
先看看如何添加到末尾:
function insertToTail(array, element) {
array.push(element);
return array;
}
const array = [1, 2, 3];
console.log(insertToTail(array, 4)); // => [ 1, 2, 3, 4 ]
复制代码
根据规范,push
操做只是将一个新元素添加到数组的末尾。所以,
Array.push
的时间复杂度度是 O(1)。
如今看看如添加到开头:
function insertToHead(array, element) {
array.unshift(element);
return array;
}
const array = [1, 2, 3];
console.log(insertToHead(array, 0));// => [ 0, 1, 2, 3, ]
复制代码
你以为 insertToHead
函数,时间复杂度是什么呢?看起来和上面(push
)差很少,除了调用的方法是 unshift
而不是 push
。但这有个问题,unshift
是经过将数组的每一项移到下一项,腾出首项的空间来容纳新添加的元素。因此它是遍历了一次数组的。
Array.unshift
的时间复杂度度是 O(n)。
若是你知道待查找元素在数组中的索引,那你能够经过如下方法直接访问该元素:
function access(array, index) {
return array[index];
}
const array = [1, 'word', 3.14, { a: 1 }];
access(array, 0);// => 1
access(array, 3);// => {a: 1}
复制代码
正如上面你所看到的的代码同样,访问数组中的元素时间是恒定的:
访问数组中元素的时间复杂度是 O(1)。
注意:经过索引修改数组的值所花费的时间也是恒定的。
若是你想查找某个元素但不知道对应的索引时,那只能经过遍历数组的每一个元素,直到找到为止。
function search(array, element) {
for (let index = 0;
index < array.length;
index++) {
if (element === array[index]) {
return index;
}
}
}
const array = [1, 'word', 3.14, { a: 1 }];
console.log(search(array, 'word'));// => 1
console.log(search(array, 3.14));// => 2
复制代码
鉴于使用了 for
循环,那么:
在数组中查找元素的时间复杂度是 O(n)
你以为从数组中删除元素的时间复杂度是什么呢?
先一块儿思考下这两种状况:
Talk is cheap, let’s do the code!
function remove(array, element) {
const index = search(array, element);
array.splice(index, 1);
return array;
}
const array1 = [0, 1, 2, 3];
console.log(remove(array1, 1));// => [ 0, 2, 3 ]
复制代码
咱们使用了上面定义的 search
函数来查找元素的的索引,复杂度为 O(n)。而后使用JS 内置的 splice
方法,它的复杂度也是 O(n)。那(删除函数)总的时间复杂度不是 O(2n) 吗?记住,(对于时间复杂度而言,)咱们并不关心常量。
对于上面列举的两种状况,考虑最坏的状况:
在数组中删除某项元素的时间复杂度是 O(n)。
在下表中,小结了数组(方法)的时间复杂度:
数组方法的时间复杂度
操做方法 | 最坏状况 |
---|---|
访问 (Array.[] ) |
O(1) |
添加新元素至开头 (Array.unshift ) |
O(n) |
添加新元素至末尾 (Array.push ) |
O(1) |
查找 (经过值而非索引) | O(n) |
删除 (Array.splice ) |
O(n) |
HashMap有不少名字,如 HashTableHashMap、Map、Dictionary、Associative Array 等。概念上它们都是一致的,实现上稍有不一样。
哈希表是一种将键 映射到 值的数据结构。
回想一下关于抽屉的比喻,如今匣子有了标签,再也不是按数字顺序了。
HashMap 也和抽屉同样存储东西,经过不一样标识来区分不一样匣子。
此例中,若是你要找一个玩具,你不须要依次打开第一个、第二个和第三个匣子来查看玩具是否在内。直接代开被标识为“玩具”的匣子便可。这是一个巨大的进步,查找元素的时间复杂度从 O(n) 降为 O(1) 了。
数字是数组的索引,而标识则做为 HashMap 存储数据的键。HashMap 内部经过 哈希函数 将键(也就是标识)转化为索引。
至少有两种方式能够实现 hashmap:
咱们会介绍树与二叉搜索树,如今先不用担忧太多。实现 Map 最经常使用的方式是使用 数组与哈希转换函数。让咱们(经过数组)来实现它吧
经过数组实现 HashMap
正如上图所示,每一个键都被转换为一个 hash code。因为数组的大小是有限的(如此例中是10),(如发生冲突,)咱们必须使用求模函数找到对应的桶(译者注:桶指的是数组的项),再循环遍历该桶(来寻找待查询的值)。每一个桶内,咱们存储的是一组组的键值对,若是桶内存储了多个键值对,将采用集合来存储它们。
咱们将讲述 HashMap 的组成,让咱们先从哈希函数开始吧。
实现 HashMap 的第一步是写出一个哈希函数。这个函数会将键映射为对应(索引的)值。
完美的哈希函数 是为每个不一样的键映射为不一样的索引。
借助理想的哈希函数,能够实现访问与查找在恒定时间内完成。然而,完美的哈希函数在实践中是难以实现的。你极可能会碰到两个不一样的键被映射为同一索引的状况,也就是 冲突。
当使用相似数组之类的数据结构做为 HashMap 的实现时,冲突是难以免的。所以,解决冲突的其中一种方式是在同一个桶中存储多个值。当咱们试图访问某个键对应的值时,若是在对应的桶中发现多组键值对,则须要遍历它们(以寻找该键对应的值),时间复杂度为 O(n)。然而,在大多数(HashMap)的实现中, HashMap 会动态调整数组的长度以避免冲突发生过多。所以咱们能够说分摊后的查找时间为 O(1)。本文中咱们将经过一个例子,讲述分摊的含义。
一个简单(但糟糕)的哈希函数能够是这样的:
class NaiveHashMap {
constructor(initialCapacity = 2) {
this.buckets = new Array(initialCapacity);
}
set(key, value) {
const index = this.getIndex(key);
this.buckets[index] = value;
}
get(key) {
const index = this.getIndex(key);
return this.buckets[index];
}
hash(key) {
return key.toString().length;
}
getIndex(key) {
const indexHash = this.hash(key);
const index = indexHash % this.buckets.length;
return index;
}
}
复制代码
咱们直接使用 buckets
而不是抽屉与匣子,相信你能明白喻义的意思 :)
HashMap 的初始容量(译者注:容量指的是用于存储数据的数组长度,即桶的数量)是2(两个桶)。当咱们往里面存储多个元素时,经过求模 %
计算出该键应存入桶的编号(,并将数据存入该桶中)。
留意代码的第18行(即 return key.toString().length;
)。以后咱们会对此进行一点讨论。如今先让咱们使用一下这个新的 HashMap 吧。
// Usage:
const assert = require('assert');
const hashMap = new NaiveHashMap();
hashMap.set('cat', 2);
hashMap.set('rat', 7);
hashMap.set('dog', 1);
hashMap.set('art', 8);
console.log(hashMap.buckets);
/*
bucket #0: <1 empty item>,
bucket #1: 8
*/
assert.equal(hashMap.get('art'), 8); // this one is ok
assert.equal(hashMap.get('cat'), 8); // got overwritten by art 😱
assert.equal(hashMap.get('rat'), 8); // got overwritten by art 😱
assert.equal(hashMap.get('dog'), 8); // got overwritten by art 😱
复制代码
这个 HashMap 容许咱们经过 set
方法设置一组键值对,经过往 get
方法传入一个键来获取对应的值。其中的关键是哈希函数,当咱们存入多组键值时,看看这 HashMap 的表现。
你能说出这个简单实现的 HashMap 存在的问题吗?
1) Hash function 转换出太多相同的索引。如:
hash('cat') // 3
hash('dog') // 3
复制代码
这会产生很是多的冲突。
2) 彻底不处理冲突的状况。cat
与 dog
会重写彼此在 HashMap 中的值(它们均在桶 #1 中)。
3 数组长度。 即便咱们有一个更好的哈希函数,因为数组的长度是2,少于存入元素的数量,仍是会产生不少冲突。咱们但愿 HashMap 的初始容量大于咱们存入数据的数量。
HashMap 的主要目标是将数组查找与访问的时间复杂度,从 O(n) 降至 O(1)。
为此,咱们须要:
让咱们从新设计哈希函数,再也不采用字符串的长度为 hash code,取而代之是使用字符串中每一个字符的ascii 码的总和为 hash code。
hash(key) {
let hashValue = 0;
const stringKey = key.toString();
for (let index = 0; index < stringKey.length; index++) {
const charCode = stringKey.charCodeAt(index);
hashValue += charCode;
}
return hashValue;
}
复制代码
再试一次:
hash('cat') // 312 (c=99 + a=97 + t=116)
hash('dog') // 314 (d=100 + o=111 + g=103)
复制代码
这函数比以前的要好!这是由于相同长度的单词由不同的字母组成,于是 ascii 码的总和不同。
然而,仍然有问题!单词 rat 与 art 转换后都是327,产生冲突了! 💥
能够经过偏移每一个字符的 ascii 码再求和来解决:
hash(key) {
let hashValue = 0;
const stringKey = `${key}`;
for (let index = 0; index < stringKey.length; index++) {
const charCode = stringKey.charCodeAt(index);
hashValue += charCode << (index * 8);
}
return hashValue;
}
复制代码
如今继续试验,下面列举出了十六进制的数字,这样能够方便咱们观察位移。
// r = 114 or 0x72; a = 97 or 0x61; t = 116 or 0x74
hash('rat'); // 7,627,122 (r: 114 * 1 + a: 97 * 256 + t: 116 * 65,536) or in hex: 0x726174 (r: 0x72 + a: 0x6100 + t: 0x740000)
hash('art'); // 7,631,457 or 0x617274
复制代码
然而,若是数据类型不一样会有怎样的表现呢?
hash(1); // 49
hash('1'); // 49
hash('1,2,3'); // 741485668
hash([1,2,3]); // 741485668
hash('undefined') // 3402815551
hash(undefined) // 3402815551
复制代码
天啊,仍然有问题!!不一样的数据类型不该该返回相同的 hash code!
该如何解决呢?
其中一种方式是在哈希函数中,将数据的类型做为转换 hash code 的一部分。
hash(key) {
let hashValue = 0;
const stringTypeKey = `${key}${typeof key}`;
for (let index = 0; index < stringTypeKey.length; index++) {
const charCode = stringTypeKey.charCodeAt(index);
hashValue += charCode << (index * 8);
}
return hashValue;
}
复制代码
让咱们让咱们再试一次:
console.log(hash(1)); // 1843909523
console.log(hash('1')); // 1927012762
console.log(hash('1,2,3')); // 2668498381
console.log(hash([1,2,3])); // 2533949129
console.log(hash('undefined')); // 5329828264
console.log(hash(undefined)); // 6940203017
复制代码
Yay!!! 🎉 咱们终于有了更好的哈希函数!
同时,咱们能够改变 HashMap 的原始容量以减小冲突,让咱们在下一节中优化 HashMap。
经过优化好的哈希函数,HashMap 能够表现得更好。
尽管冲突仍可能发生,但经过一些方式能够很好地处理它们。
对于咱们的 HashMap,但愿有如下改进:
更完善 HashMap 实现完整代码
class DecentHashMap {
constructor(initialCapacity = 2) {
this.buckets = new Array(initialCapacity);
this.collisions = 0;
}
set(key, value) {
const bucketIndex = this.getIndex(key);
if(this.buckets[bucketIndex]) {
this.buckets[bucketIndex].push({key, value});
if(this.buckets[bucketIndex].length > 1) { this.collisions++; }
} else {
this.buckets[bucketIndex] = [{key, value}];
}
return this;
}
get(key) {
const bucketIndex = this.getIndex(key);
for (let arrayIndex = 0; arrayIndex < this.buckets[bucketIndex].length; arrayIndex++) {
const entry = this.buckets[bucketIndex][arrayIndex];
if(entry.key === key) {
return entry.value
}
}
}
hash(key) {
let hashValue = 0;
const stringTypeKey = `${key}${typeof key}`;
for (let index = 0; index < stringTypeKey.length; index++) {
const charCode = stringTypeKey.charCodeAt(index);
hashValue += charCode << (index * 8);
}
return hashValue;
}
getIndex(key) {
const indexHash = this.hash(key);
const index = indexHash % this.buckets.length;
return index;
}
}
复制代码
看看这个 HashMap 表现如何:
// Usage:
const assert = require('assert');
const hashMap = new DecentHashMap();
hashMap.set('cat', 2);
hashMap.set('rat', 7);
hashMap.set('dog', 1);
hashMap.set('art', 8);
console.log('collisions: ', hashMap.collisions); // 2
console.log(hashMap.buckets);
/*
bucket #0: [ { key: 'cat', value: 2 }, { key: 'art', value: 8 } ]
bucket #1: [ { key: 'rat', value: 7 }, { key: 'dog', value: 1 } ]
*/
assert.equal(hashMap.get('art'), 8); // this one is ok
assert.equal(hashMap.get('cat'), 2); // Good. Didn't got overwritten by art assert.equal(hashMap.get('rat'), 7); // Good. Didn't got overwritten by art
assert.equal(hashMap.get('dog'), 1); // Good. Didn't got overwritten by art 复制代码
完善后的 HashMap 很好地完成了工做,但仍然有一些问题。使用改良后的哈希函数不容易产生重复的值,这很是好。然而,在桶#0与桶#1中都有两个值。这是为何呢??
因为 HashMap 的容量是2,尽管算出来的 hash code 是不同的,当求余后算出所需放进桶的编号时,结果不是桶#0就是桶#1。
hash('cat') => 3789411390; bucketIndex => 3789411390 % 2 = 0
hash('art') => 3789415740; bucketIndex => 3789415740 % 2 = 0
hash('dog') => 3788563007; bucketIndex => 3788563007 % 2 = 1
hash('rat') => 3789411405; bucketIndex => 3789411405 % 2 = 1
复制代码
很天然地想到,能够经过增长 HashMap 的原始容量来解决这个问题,但原始容量应该是多少呢?先来看看容量是如何影响 HashMap 的表现的。
若是初始容量是1,那么全部的键值对都会被存入同一个桶,即桶#0。查找操做并不比纯粹用数组存储数据的时间复杂度简单,它们都是 O(n)。
而假设将初始容量定为10:
const hashMapSize10 = new DecentHashMap(10);
hashMapSize10.set('cat', 2);
hashMapSize10.set('rat', 7);
hashMapSize10.set('dog', 1);
hashMapSize10.set('art', 8);
console.log('collisions: ', hashMapSize10.collisions); // 1
console.log('hashMapSize10\n', hashMapSize10.buckets);
/*
bucket#0: [ { key: 'cat', value: 2 }, { key: 'art', value: 8 } ],
<4 empty items>,
bucket#5: [ { key: 'rat', value: 7 } ],
<1 empty item>,
bucket#7: [ { key: 'dog', value: 1 } ],
<2 empty items>
*/
复制代码
换个角度看:
正如你所看到的,经过增长 HashMap 的容量,能有效减小冲突次数。
那换个更大的试试怎样,好比 💯:
const hashMapSize100 = new DecentHashMap(100);
hashMapSize100.set('cat', 2);
hashMapSize100.set('rat', 7);
hashMapSize100.set('dog', 1);
hashMapSize100.set('art', 8);
console.log('collisions: ', hashMapSize100.collisions); // 0
console.log('hashMapSize100\n', hashMapSize100.buckets);
/*
<5 empty items>,
bucket#5: [ { key: 'rat', value: 7 } ],
<1 empty item>,
bucket#7: [ { key: 'dog', value: 1 } ],
<32 empty items>,
bucket#41: [ { key: 'art', value: 8 } ],
<49 empty items>,
bucket#90: [ { key: 'cat', value: 2 } ],
<9 empty items>
*/
复制代码
Yay! 🎊 没有冲突!
经过增长初始容量,能够很好的减小冲突,但会消耗更多的内存,并且极可能许多桶都没被使用。
若是咱们的 HashMap 能根据须要自动调整容量,这不是更好吗?这就是所谓的rehash(从新计算哈希值),咱们将在下一节将实现它!
若是 HashMap 的容量足够大,那就不会产生任何冲突,所以查找操做的时间复杂度为 O(1)。然而,咱们怎么知道容量多大才是足够呢,100?1000?仍是一百万?
(从开始就)分配大量的内存(去创建数组)是不合理的。所以,咱们能作的是根据装载因子动态地调整容量。这操做被称为 rehash。
装载因子是用于衡量一个 HashMap 满的程度,能够经过存储键值对的数量除以 HashMap 的容量获得它。
根据这思路,咱们将实现最终版的 HashMap:
最佳的 HasnMap 实现
class HashMap {
constructor(initialCapacity = 16, loadFactor = 0.75) {
this.buckets = new Array(initialCapacity);
this.loadFactor = loadFactor;
this.size = 0;
this.collisions = 0;
this.keys = [];
}
hash(key) {
let hashValue = 0;
const stringTypeKey = `${key}${typeof key}`;
for (let index = 0; index < stringTypeKey.length; index++) {
const charCode = stringTypeKey.charCodeAt(index);
hashValue += charCode << (index * 8);
}
return hashValue;
}
_getBucketIndex(key) {
const hashValue = this.hash(key);
const bucketIndex = hashValue % this.buckets.length;
return bucketIndex;
}
set(key, value) {
const {bucketIndex, entryIndex} = this._getIndexes(key);
if(entryIndex === undefined) {
// initialize array and save key/value
const keyIndex = this.keys.push({content: key}) - 1; // keep track of the key index
this.buckets[bucketIndex] = this.buckets[bucketIndex] || [];
this.buckets[bucketIndex].push({key, value, keyIndex});
this.size++;
// Optional: keep count of collisions
if(this.buckets[bucketIndex].length > 1) { this.collisions++; }
} else {
// override existing value
this.buckets[bucketIndex][entryIndex].value = value;
}
// check if a rehash is due
if(this.loadFactor > 0 && this.getLoadFactor() > this.loadFactor) {
this.rehash(this.buckets.length * 2);
}
return this;
}
get(key) {
const {bucketIndex, entryIndex} = this._getIndexes(key);
if(entryIndex === undefined) {
return;
}
return this.buckets[bucketIndex][entryIndex].value;
}
has(key) {
return !!this.get(key);
}
_getIndexes(key) {
const bucketIndex = this._getBucketIndex(key);
const values = this.buckets[bucketIndex] || [];
for (let entryIndex = 0; entryIndex < values.length; entryIndex++) {
const entry = values[entryIndex];
if(entry.key === key) {
return {bucketIndex, entryIndex};
}
}
return {bucketIndex};
}
delete(key) {
const {bucketIndex, entryIndex, keyIndex} = this._getIndexes(key);
if(entryIndex === undefined) {
return false;
}
this.buckets[bucketIndex].splice(entryIndex, 1);
delete this.keys[keyIndex];
this.size--;
return true;
}
rehash(newCapacity) {
const newMap = new HashMap(newCapacity);
this.keys.forEach(key => {
if(key) {
newMap.set(key.content, this.get(key.content));
}
});
// update bucket
this.buckets = newMap.buckets;
this.collisions = newMap.collisions;
// Optional: both `keys` has the same content except that the new one doesn't have empty spaces from deletions this.keys = newMap.keys; } getLoadFactor() { return this.size / this.buckets.length; } } 复制代码
完整代码 (译者注:其实 has
方法有问题,只是不影响阅读。)
注意第99行至第114行(即 rehash
函数),那里是 rehash 魔法发生的地方。咱们创造了一个新的 HashMap,它拥有原来 HashMap两倍的容量。
测试一下上面的新实现吧:
const assert = require('assert');
const hashMap = new HashMap();
assert.equal(hashMap.getLoadFactor(), 0);
hashMap.set('songs', 2);
hashMap.set('pets', 7);
hashMap.set('tests', 1);
hashMap.set('art', 8);
assert.equal(hashMap.getLoadFactor(), 4/16);
hashMap.set('Pineapple', 'Pen Pineapple Apple Pen');
hashMap.set('Despacito', 'Luis Fonsi');
hashMap.set('Bailando', 'Enrique Iglesias');
hashMap.set('Dura', 'Daddy Yankee');
hashMap.set('Lean On', 'Major Lazer');
hashMap.set('Hello', 'Adele');
hashMap.set('All About That Bass', 'Meghan Trainor');
hashMap.set('This Is What You Came For', 'Calvin Harris ');
assert.equal(hashMap.collisions, 2);
assert.equal(hashMap.getLoadFactor(), 0.75);
assert.equal(hashMap.buckets.length, 16);
hashMap.set('Wake Me Up', 'Avicii'); // <--- Trigger REHASH
assert.equal(hashMap.collisions, 0);
assert.equal(hashMap.getLoadFactor(), 0.40625);
assert.equal(hashMap.buckets.length, 32);
复制代码
注意,在往 HashMap 存储第 12 个元素的时候,装载因子将超过0.75,于是触发 rehash,HashMap 容量加倍(从16到32)。同时,咱们也能看到冲突从2下降为0。
这版本实现的 HashMap 能以很低的时间复杂度进行常见的操做,如:插入、查找、删除、编辑等。
小结一下,HashMap 的性能取决于:
咱们终于处理好了各类问题 🔨。有了不错的哈希函数,能够根据不一样输入返回不一样输出。同时,咱们也有 rehash
函数根据须要动态地调整 HashMap的容量。这实在太好了!
往一个 HashMap 插入元素须要两样东西:一个键与一个值。可使用上文开发优化后的 HashMap 或内置的对象进行操做:
function insert(object, key, value) {
object[key] = value;
return object;
}
const hash = {};
console.log(insert(hash, 'word', 1)); // => { word: 1 }
复制代码
在新版的 JavaScript 中,你可使用 Map。
function insertMap(map, key, value) {
map.set(key, value);
return map;
}
const map = new Map();
console.log(insertMap(map, 'word', 1)); // Map { 'word' => 1 }
复制代码
注意。咱们将使用 Map 而不是普通的对象,这是因为 Map 的键能够是任何东西而对象的键只能是字符串或者数字。此外,Map 能够保持插入的顺序。
进一步说,Map.set
只是将元素插入到数组(如上文 DecentHashMap.set
所示),相似于 Array.push
,所以能够说:
往 HashMap 中插入元素,时间复杂度是 O(1)。若是须要 rehash,那么复杂度则是 O(n)。
rehash 能将冲突可能性降至最低。rehash 操做时间复杂度是 O(n) ,但不是每次插入操做都要执行,仅在须要时执行。
这是 HashMap.get
方法,咱们经过往里面传递一个键来获取对应的值。让咱们回顾一下 DecentHashMap.get
的实现:
get(key){
const hashIndex = this .getIndex(key);
const values = this .array [hashIndex];
for(let index = 0 ; index <values.length; index ++){
const entry = values [index];
if(entry.key === key){
返回 entry.value
}
}
}
复制代码
若是并未发生冲突,那么 values
只会有一个值,访问的时间复杂度是 O(1)。但咱们也知道,冲突老是会发生的。若是 HashMap 的初始容量过小或哈希函数设计糟糕,那么大多数元素访问的时间复杂度是 O(n)。
HashMap 访问操做的时间复杂度平均是 O(1),最坏状况是 O(n) 。
**特别注意:**另外一个(将访问操做的)时间复杂度从 O(n) 降至 O(log n) 的方法是使用 二叉搜索树 而不是数组进行底层存储。事实上,当存储的元素超过8 个时, Java HashMap 的底层实现会从数组转为树。
修改(HashMap.set
)或删除(HashMap.delete
)键值对,分摊后的时间复杂度是 O(1)。若是冲突不少,可能面对的就是最坏状况,复杂度为 O(n)。然而伴随着 rehash 操做,能够大大减小最坏状况的发生的概率。
HashMap 修改或删除操做的时间复杂度平均是 O(1) ,最坏状况是 O(n)。
在下表中,小结了 HashMap(方法)的时间复杂度:
HashMap 方法的时间复杂度
操做方法 | 最坏状况 | 平均 | 备注 |
---|---|---|---|
访问或查找 (HashMap.get ) |
O(n) | O(1) | O(n) 是冲突极多的极端状况 |
插入或修改 (HashMap.set ) |
O(n) | O(1) | O(n) 只发生在装载因子超过0.75,触发 rehash 时 |
删除 (HashMap.delete ) |
O(n) | O(1) | O(n) 是冲突极多的极端状况 |
集合跟数组很是相像。它们的区别是集合中的元素是惟一的。
咱们该如何实现一个集合呢(也就是没有重复项的数组)?可使用数组实现,在插入新元素前先检查该元素是否存在。但检查是否存在的时间复杂度是 O(n)。能对此进行优化吗?以前开发的 Map (插入操做)分摊后时间复杂度度才 O(1)!
可使用 JavaScript 内置的 Set。然而经过本身实现它,能够更直观地了解它的时间复杂度。咱们将使用上文优化后带有 rehash 功能的 HashMap 来实现它。
const HashMap = require('../hash-maps/hash-map');
class MySet {
constructor() {
this.hashMap = new HashMap();
}
add(value) {
this.hashMap.set(value);
}
has(value) {
return this.hashMap.has(value);
}
get size() {
return this.hashMap.size;
}
delete(value) {
return this.hashMap.delete(value);
}
entries() {
return this.hashMap.keys.reduce((acc, key) => {
if(key !== undefined) {
acc.push(key.content);
}
return acc
}, []);
}
}
复制代码
(译者注:因为 HashMap 的 has
方法有问题,致使 Set 的 has
方法也有问题)
咱们使用 HashMap.set
为集合不重复地添加元素。咱们将待存储的值做为 HashMap的键,因为哈希函数会将键映射为惟一的索引,于是起到排重的效果。
检查一个元素是否已存在于集合中,可使用 hashMap.has
方法,它的时间复杂度平均是 O(1)。集合中绝大多数的方法分摊后时间复杂度为 O(1),除了 entries
方法,它的事件复杂度是 O(n)。
注意:使用 JavaScript 内置的集合时,它的 Set.has
方法时间复杂度是 O(n)。这是因为它的使用了 List 做为内部实现,须要检查每个元素。你能够在这查阅相关的细节。
下面有些例子,说明如何使用这个集合:
const assert = require('assert');
// const set = new Set(); // Using the built-in
const set = new MySet(); // Using our own implementation
set.add('one');
set.add('uno');
set.add('one'); // should NOT add this one twice
assert.equal(set.has('one'), true);
assert.equal(set.has('dos'), false);
assert.equal(set.size, 2);
// assert.deepEqual(Array.from(set), ['one', 'uno']);
assert.equal(set.delete('one'), true);
assert.equal(set.delete('one'), false);
assert.equal(set.has('one'), false);
assert.equal(set.size, 1);
复制代码
这个例子中,MySet 与 JavaScript 中内置的 Set 都可做为容器。
根据 HashMap 实现的的 Set,能够小结出的时间复杂度以下(与 HashMap 很是类似):
Set 方法的时间复杂度
操做方法 | 最坏状况 | 平均 | 备注 |
---|---|---|---|
访问或查找 (Set.has ) |
O(n) | O(1) | O(n) 是冲突极多的极端状况 |
插入或修改 (Set.add ) |
O(n) | O(1) | O(n) 只发生在装载因子超过0.75,触发 rehash 时 |
删除 (Set.delete ) |
O(n) | O(1) | _O(n)_是冲突极多的极端状况) |
链表是一种一个节点连接到下一个节点的数据结构。
链表是(本文)第一种不用数组(做为底层)实现的数据结构。咱们使用节点来实现,节点存储了一个元素,并指向下一个节点(若没有下一个节点,则为空)。
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
复制代码
当每一个节点都指向它的下了一个节点时,咱们就拥有了一条由若干节点组成链条,即单向链表。
对于单向链表而言,咱们只需关心每一个节点都有指向下一个节点的引用。
从首个节点或称之为根节点开始构建(单向链表)。
class LinkedList {
constructor() {
this.root = null;
}
// ...
}
复制代码
每一个链表都有四个基础操做:
向链表末尾添加与删除一个元素
(对添加操做而言,)有两种状况。1)若是链表根节点不存在,那么将新节点设置为链表的根节点。2)若存在根节点,则必须不断查询下一个节点,直到链表的末尾,并将新节点添加到最后。
addLast(value) { // similar Array.push
const node = new Node(value);
if(this.root) {
let currentNode = this.root;
while(currentNode && currentNode.next) {
currentNode = currentNode.next;
}
currentNode.next = node;
} else {
this.root = node;
}
}
复制代码
上述代码的时间复杂度是多少呢?若是是做为根节点添加进链表,时间复杂度是 O(1),然而寻找最后一个节点的时间复杂度是 O(n).。
删除末尾的节点与上述代码相差无几。
removeLast() {
let current = this.root;
let target;
if(current && current.next) {
while(current && current.next && current.next.next) {
current = current.next;
}
target = current.next;
current.next = null;
} else {
this.root = null;
target = current;
}
if(target) {
return target.value;
}
}
复制代码
时间复杂度也是 O(n)。这是因为咱们必须依次往下,直到找到倒数第二个节点,并将它 next
的引用指向 null
。
向链表开头添加与删除一个元素
往链表开头添加一个元素(的代码)以下所示:
addFirst(value) {
const node = new Node(value);
node.next = this.first;
this.first = node;
}
复制代码
向链表的开头进行增删操做,所耗费的时间是恒定的,由于咱们持有根元素的引用:
removeFirst(value) {
const target = this.first;
this.first = target ? target.next : null;
return target.value;
}
复制代码
(译者注:做者原文 removeFirst
的代码放错了,上述代码是译者实现的)
如你所见,对链表的开头进行增删操做,时间复杂度永远是 O(1)。
从链表的任意位置删除元素
删除链表首尾的元素,可使用 removeFirst
或 removeLast
。然而,如若移除的节点在链表的中间,则须要将待删除节点的前一个节点指向待删除节点的下一个节点,从而从链表中删除该节点:
remove(index = 0) {
if(index === 0) {
return this.removeFirst();
}
let current;
let target = this.first;
for (let i = 0; target; i++, current = target, target = target.next) {
if(i === index) {
if(!target.next) { // if it doesn't have next it means that it is the last return this.removeLast(); } current.next = target.next; this.size--; return target.value; } } } 复制代码
(译者注:原文实现有点问题,译者稍做了点修改。removeLast
的调用其实浪费了性能,但可读性上增长了,于是此处未做修改。)
注意, index
是从0开始的:0是第一个节点,1是第二个,如此类推。
在链表任意一处删除节点,时间复杂度为 O(n).
在链表中查找元素
在链表中查找一个元素与删除元素的代码差很少:
contains(value) {
for (let current = this.first, index = 0; current; index++, current = current.next) {
if(current.value === value) {
return index;
}
}
}
复制代码
这个方法查找链表中第一个与给定值相等的节点(的索引)。
在链表中查找一个元素,时间复杂度是 O(n)
在下表中,小结了单向链表(方法)的时间复杂度:
操做方法 | 时间复杂度 | 注释 |
---|---|---|
addFirst | O(1) | 将元素插入到链表的开头 |
addLast | O(n) | 将元素插入到链表的末尾 |
add | O(n) | 将元素插入到链表的任意地方 |
removeFirst | O(1) | 删除链表的首个元素 |
removeLast | O(n) | 删除链表最后一个元素 |
remove | O(n) | 删除链表中任意一个元素 |
contains | O(n) | 在链表中查找任意元素 |
注意,当咱们增删链表的最后一个元素时,该操做的时间复杂度是 O(n)…
但只要持有最后一个节点的引用,能够从原来的 O(n),降至与增删首个元素一致,变为 O(1)!
咱们将在下一节实现这功能!
当咱们有一串节点,每个都有指向下一个节点的引用,也就是拥有了一个单向链表。而当一串节点,每个既有指向下一个节点的引用,也有指向上一个节点的引用时,这串节点就是双向链表。
双向链表的节点有两个引用(分别指向前一个和后一个节点),所以须要保持追踪首个与最后一个节点。
class Node {
constructor(value) {
this.value = value;
this.next = null;
this.previous = null;
}
}
class LinkedList {
constructor() {
this.first = null; // head/root element
this.last = null; // last element of the list
this.size = 0; // total number of elements in the list
}
// ...
}
复制代码
(双向链表的完整代码)
添加或删除链表的首个元素
因为持有首个节点的引用,于是添加或删除首个元素的操做是十分简单的:
addFirst(value) {
const node = new Node(value);
node.next = this.first;
if(this.first) {
this.first.previous = node;
} else {
this.last = node;
}
this.first = node; // update head
this.size++;
return node;
}
复制代码
(LinkedList.prototype.addFirst
的完整代码
注意,咱们须要十分谨慎地更新节点的 previous
引用、双向链表的 size
与双向链表最后一个节点的引用。
removeFirst() {
const first = this.first;
if(first) {
this.first = first.next;
if(this.first) {
this.first.previous = null;
}
this.size--;
return first.value;
} else {
this.last = null;
}
}
复制代码
(LinkedList.prototype.removeFirst
的完整代码
时间复杂度是什么呢?
不管是单向链表仍是双向链表,添加与删除首个节点的操做耗费时间都是恒定的,时间复杂度为 O(1)。
添加或删除链表的最后一个元素
_从双向链表的末尾_添加或删除一个元素稍有点麻烦。当你查询单向链表(操做的时间复杂度)时,这两个操做都是 O(n),这是因为须要遍历整条链表,直至找到最后一个元素。然而,双向链表持有最后一个节点的引用:
addLast(value) {
const node = new Node(value);
if(this.first) {
node.previous = this.last;
this.last.next = node;
this.last = node;
} else {
this.first = node;
this.last = node;
}
this.size++;
return node;
}
复制代码
(LinkedList.prototype.addLast
的完整代码)
一样,咱们须要当心地更新引用与处理一些特殊状况,如链表中只有一个元素时。
removeLast() {
let current = this.first;
let target;
if(current && current.next) {
target = this.last;
current = target.previous;
this.last = current;
current.next = null;
} else {
this.first = null;
this.last = null;
target = current;
}
if(target) {
this.size--;
return target.value;
}
}
复制代码
(LinkedList.prototype.removeLast
的完整代码)
使用了双向链表,咱们再也不须要遍历整个链表直至找到倒数第二个元素。能够直接使用 this.last.previous
来找到它,时间复杂度是 O(1)。
下文将介绍队列相关的知识,本文中队列是使用两个数组实现的。能够改成使用双向链表实现队列,由于(双向链表)添加首个元素与删除最后一个元素时间复杂度都是 O(1)。
添加一个元素至链表任意一处
借助 addFirst
与 addLast
,能够实现将一个元素添加到链表任意一处,实现以下:
add(value, index = 0) {
if(index === 0) {
return this.addFirst(value);
}
for (let current = this.first, i = 0; i <= this.size; i++, current = (current && current.next)) {
if(i === index) {
if(i === this.size) { // if it doesn't have next it means that it is the last return this.addLast(value); } const newNode = new Node(value); newNode.previous = current.previous; newNode.next = current; current.previous.next = newNode; if(current.next) { current.next.previous = newNode; } this.size++; return newNode; } } } 复制代码
(LinkedList.prototype.add
的完整代码)
若是添加元素的位置是在链表中间,咱们就必须更新该元素先后节点的 next
与 previous
引用。
添加一个元素至链表任意一处的时间复杂度是 O(n).
双向链表每一个方法的时间复杂度以下表:
操做方法 | 时间复杂度 | 注释 |
---|---|---|
addFirst | O(1) | 将元素插入到链表的开头 |
addLast | O(1) | 将元素插入到链表的末尾 |
add | O(n) | 将元素插入到链表的任意地方 |
removeFirst | O(1) | 删除链表的首个元素 |
removeLast | O(1) | 删除链表最后一个元素 |
remove | O(n) | 删除链表中任意一个元素 |
contains | O(n) | 在链表中查找任意元素 |
与单向链表相比,有了很大的改进(译者注:其实看场景,不要盲目认为双向链表必定比单向链表强)!(addLast
与 removeLast
)操做时间复杂度从 O(n) 降至 O(1) ,这是因为:
删除首个或最后一个节点能够在恒定时间内完成,然而删除中间的节点时间复杂度仍然是 O(n)。
栈是一种越后被添加的元素,越先被弹出的数据结构。也就是后进先出(LIFO).
让咱们从零开始实现一个栈!
class Stack {
constructor() {
this.input = [];
}
push(element) {
this.input.push(element);
return this;
}
pop() {
return this.input.pop();
}
}
复制代码
正如你看到的,若是使用内置的 Array.push
与 Array.pop
实现一个栈,那是十分简单的。两个方法的时间复杂度都是 O(1)。
下面来看看栈的具体使用:
const stack = new Stack();
stack.push('a');
stack.push('b');
stack.push('c');
stack.pop(); // c
stack.pop(); // b
stack.pop(); // a
复制代码
最早被加入进去的元素 a 直到最后才被弹出。栈也能够经过链表来实现,对应方法的时间复杂度是同样的。
这就是栈的所有内容啦!
队列是一种越先被添加的元素,越先被出列的数据结构。也就是先进先出(FIFO)。就如现实中排成一条队的人们同样,先排队的先被服务(也就是出列)。
能够经过数组来实现一个队列,代码与栈的实现相相似。
经过 Array.push
与 Array.shift
能够实现一个简单(译者注:即不是最优的实现方式)的队列:
class Queue {
constructor() {
this.input = [];
}
add(element) {
this.input.push(element);
}
remove() {
return this.input.shift();
}
}
复制代码
Queue.add
与 Queue.remove
的时间复杂度是什么呢?
Queue.add
使用 Array.push
实现,能够在恒定时间内完成。这很是不错!Queue.remove
使用 Array.shift
实现,Array.shift
耗时是线性的(即 O(n))。咱们能够减小 Queue.remove
的耗时吗?试想一下,若是只用 Array.push
与 Array.pop
能实现一个队列吗?
class Queue {
constructor() {
this.input = [];
this.output = [];
}
add(element) {
this.input.push(element);
}
remove() {
if(!this.output.length) {
while(this.input.length) {
this.output.push(this.input.pop());
}
}
return this.output.pop();
}
}
复制代码
如今,咱们使用两个而不是一个数组来实现一个队列。
const queue = new Queue();
queue.add('a');
queue.add('b');
queue.remove() // a
queue.add('c');
queue.remove() // b
queue.remove() // c
复制代码
当咱们第一次执行出列操做时,output
数组是空的,所以将 input
数组的内容反向添加到 output
中,此时 output
的值是 ['b', 'a']
。而后再从 output
中弹出元素。正如你所看到的,经过这个技巧实现的队列,元素输出的顺序也是先进先出(FIFO)的。
那时间复杂度是什么呢?
若是 output
数组已经有元素了,那么出列操做就是恒定的 O(1)。而当 output
须要被填充(即里面没有元素)时,时间复杂度变为 O(n)。output
被填充后,出列操做耗时再次变为恒定。所以分摊后是 O(1)。
也能够经过链表来实现队列,相关操做耗时也是恒定的。下一节将带来具体的实现。
若是但愿队列有最好的性能,就须要经过双向链表而不是数组来实现(译者注:并不是数组实现就彻底很差,空间上双向链表就不占优点,仍是具体问题具体分析)。
const LinkedList = require('../linked-lists/linked-list');
class Queue {
constructor() {
this.input = new LinkedList();
}
add(element) {
this.input.addFirst(element);
}
remove() {
return this.input.removeLast();
}
get size() {
return this.input.size;
}
}
复制代码
经过双向链表实现的队列,咱们持有(双向链表中)首个与最后一个节点的引用,所以入列与出列的时间复杂度都是 O(1)。这就是为遇到的问题选择合适数据结构的重要性 💪。
咱们讨论了大部分线性的数据结构。能够看出,根据实现方法的不一样,相同的数据结构也会有不一样的时间复杂度。
如下是本文讨论内容的总结:
时间复杂度
* = 运行时分摊
数据结构 | 插入 | 访问 | 查找 | 删除 | 备注 |
---|---|---|---|---|---|
Array | O(n) | O(1) | O(n) | O(n) | 插入最后位置复杂度为 O(1)。 |
(Hash)Map | O(1)* | O(1)* | O(1)* | O(1)* | 从新计算哈希会影响插入时间。 |
Map | O(log(n)) | - | O(log(n)) | O(log(n)) | 经过二叉搜索树实现 |
Set(使用 HashMap) | O(1)* | - | O(1)* | O(1)* | 由 HashMap 实现 |
Set (使用 List) | O(n) | - | O(n)] | O(n) | 经过 List 实现 |
Set (使用二叉搜索树) | O(log(n)) | - | O(log(n)) | O(log(n)) | 经过二叉搜索树实现 |
Linked List (单向) | O(n) | - | O(n) | O(n) | 在起始位置添加或删除元素,复杂度为 O(1) |
Linked List (双向) | O(n) | - | O(n) | O(n) | 在起始或结尾添加或删除元素,复杂度为 O(1)。然而在其余位置是 O(n)。 |
Stack (由 Array 实现) | O(1) | - | - | O(1)] | 插入与删除都遵循与后进先出(LIFO) |
Queue (简单地由 Array 实现) | O(n) | - | - | O(1) | 插入(Array.shift)操做的复杂度是 O(n) |
Queue (由 Array 实现,但进行了改进) | O(1)* | - | - | O(1) | 插入操做的最差状况复杂度是 O(n)。然而分摊后是 O(1) |
Queue (由 List 实现) | O(1) | - | - | O(1) | 使用双向链表 |
注意: 二叉搜索树 与其余树结构、图结构,将在另外一篇文章中讨论。