[译文] 初学者应该了解的数据结构:Array、HashMap 与 List

原文连接:Data Structures for Beginners: Arrays, HashMaps, and Listsjavascript

众成翻译地址:初学者应该了解的数据结构:Array、HashMap 与 Listjava

校对:Volongnode

挺长的一篇文章,建议不太熟悉数据结构的同窗慢慢阅读一下这篇文章,但愿对你有所帮助~如下是译文正文:git

Data Structures for Beginners: Arrays, HashMaps, and Lists

当开发程序时,咱们(一般)须要在内存中存储数据。根据操做数据方式的不一样,可能会选择不一样的数据结构。有不少经常使用的数据结构,如:Array、Map、Set、List、Tree、Graph 等等。(然而)为程序选取合适的数据结构可能并不容易。所以,但愿这篇文章能帮助你了解(不一样数据结构的)表现,以求在工做中合理地使用它们。程序员

本文主要聚焦于线性的数据结构,如:Array、Set、List、Sets、Stacks、Queues 等等。github


本篇是如下教程的一部分(译者注:若是你们以为还不错,我会翻译整个系列的文章):算法

初学者应该了解的数据结构与算法(DSA)编程

  1. 算法的时间复杂性与大 O 符号
  2. 每一个程序员应该知道的八种时间复杂度
  3. 初学者应该了解的数据结构:Array、HashMap 与 List 👈 即本文
  4. 初学者应该了解的数据结构: Graph (译文)
  5. 初学者应该了解的数据结构:Tree (敬请期待)
  6. 附录 I:递归算法分析

(操做)数据结构的时间复杂度

下表是本文所讨论内容的归纳。数组

加个书签、收藏或分享本文,以便不时之需。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) 使用双向链表

注意: 二叉搜索树 与其余树结构、图结构,将在另外一篇文章中讨论。

原始数据类型

原始数据类型是构成数据结构最基础的元素。下面列举出一些原始原始数据类型:

  • 整数,如:1, 2, 3, …
  • 字符,如:a, b, "1", "*"
  • 布尔值, true 与 false.
  • 浮点数 ,如:3.14159, 1483e-2.

Array

数组可由零个或多个元素组成。因为数组易于使用且检索性能优越,它是最经常使用的数据结构之一。

你能够将数组想象成一个抽屉,能够将数据存到匣子中。

数组就像将东西存到匣子中的抽屉

Array is like a drawer that stores things on bins

当你想查找某个元素时,你能够直接打开对应编号的匣子(时间复杂度为 O(1))。然而,若是你忘记了匣子里存着什么,就必须逐个打开全部的匣子(时间复杂度为 O(n)),直到找到所需的东西。数组也是如此。

根据编程语言的不一样,数组存在一些差别。对于 JavaScript 和 Ruby 等动态语言而言,数组能够包含不一样的数据类型:数字,字符串,对象甚至函数。而在 Java 、 C 、C ++ 之类的强类型语言中,你必须在使用数组以前,定好它的长度与数据类型。JavaScript 会在须要时自动增长数组的长度。

Array 的内置方法

根据编程序言的不一样,数组(方法)的实现稍有不一样。

好比在 JavaScript 中,咱们可使用 unshiftpush 添加元素到数组的头或尾,同时也可使用 shiftpop 删除数组的首个或最后一个元素。让咱们来定义一些本文用到的数组经常使用方法。

经常使用的 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) 返回浅拷贝原数组从 beginningend(不包括 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)

在数组中删除元素

你以为从数组中删除元素的时间复杂度是什么呢?

先一块儿思考下这两种状况:

  1. 从数组的末尾删除元素所需时间是恒定的,也就是 O(1)
  2. 然而,不管是从数组的开头或是中间位置删除元素,你都须要调整(删除元素后面的)元素位置。所以复杂度为 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)

HashMaps

HashMap有不少名字,如 HashTableHashMap、Map、Dictionary、Associative Array 等。概念上它们都是一致的,实现上稍有不一样。

哈希表是一种将键 映射到 值的数据结构。

回想一下关于抽屉的比喻,如今匣子有了标签,再也不是按数字顺序了。

HashMap 也和抽屉同样存储东西,经过不一样标识来区分不一样匣子。

HashMap is like a drawer that stores things on bins and label them

此例中,若是你要找一个玩具,你不须要依次打开第一个、第二个和第三个匣子来查看玩具是否在内。直接代开被标识为“玩具”的匣子便可。这是一个巨大的进步,查找元素的时间复杂度从 O(n) 降为 O(1) 了。

数字是数组的索引,而标识则做为 HashMap 存储数据的键。HashMap 内部经过 哈希函数 将键(也就是标识)转化为索引。

至少有两种方式能够实现 hashmap:

  1. 数组:经过哈希函数将键映射为数组的索引。(查找)最差状况: O(n),平均: O(1)。
  2. 二叉搜索树: 使用自平衡二叉搜索树查找值(另外的文章会详细介绍)。 (查找)最差状况: O(log n),平均:O(log n)

咱们会介绍树与二叉搜索树,如今先不用担忧太多。实现 Map 最经常使用的方式是使用 数组与哈希转换函数。让咱们(经过数组)来实现它吧

经过数组实现 HashMap

HashMap: hash function translates keys into bucket (array) indexes

正如上图所示,每一个键都被转换为一个 hash code。因为数组的大小是有限的(如此例中是10),(如发生冲突,)咱们必须使用求模函数找到对应的桶(译者注:桶指的是数组的项),再循环遍历该桶(来寻找待查询的值)。每一个桶内,咱们存储的是一组组的键值对,若是桶内存储了多个键值对,将采用集合来存储它们。

咱们将讲述 HashMap 的组成,让咱们先从哈希函数开始吧。

哈希函数

实现 HashMap 的第一步是写出一个哈希函数。这个函数会将键映射为对应(索引的)值。

完美的哈希函数 是为每个不一样的键映射为不一样的索引。

借助理想的哈希函数,能够实现访问与查找在恒定时间内完成。然而,完美的哈希函数在实践中是难以实现的。你极可能会碰到两个不一样的键被映射为同一索引的状况,也就是 冲突

当使用相似数组之类的数据结构做为 HashMap 的实现时,冲突是难以免的。所以,解决冲突的其中一种方式是在同一个桶中存储多个值。当咱们试图访问某个键对应的值时,若是在对应的桶中发现多组键值对,则须要遍历它们(以寻找该键对应的值),时间复杂度为 O(n)。然而,在大多数(HashMap)的实现中, HashMap 会动态调整数组的长度以避免冲突发生过多。所以咱们能够说分摊后的查找时间为 O(1)。本文中咱们将经过一个例子,讲述分摊的含义。

HashMap 的简单实现

一个简单(但糟糕)的哈希函数能够是这样的:

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) 彻底不处理冲突的状况。catdog 会重写彼此在 HashMap 中的值(它们均在桶 #1 中)。

3 数组长度。 即便咱们有一个更好的哈希函数,因为数组的长度是2,少于存入元素的数量,仍是会产生不少冲突。咱们但愿 HashMap 的初始容量大于咱们存入数据的数量。

改进哈希函数

HashMap 的主要目标是将数组查找与访问的时间复杂度,从 O(n) 降至 O(1)

为此,咱们须要:

  1. 一个合适的哈希函数,尽量地减小冲突。
  2. 一个长度足够大的数组用于保存数据。

让咱们从新设计哈希函数,再也不采用字符串的长度为 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,但愿有如下改进:

  • 哈希函数, 检查类型与计算各字符(ascii 码的总和)以减小冲突的发生。
  • 解决冲突,经过将值添加到集合中来解决这问题,同时须要一个计数器追踪冲突的数量。

更完善 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: hash function translates keys into bucket (array) indexes

正如你所看到的,经过增长 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 的实现

若是 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 的性能取决于:

  1. 哈希函数能根据不一样的键输出不一样的值。
  2. HashMap 容量的大小。

咱们终于处理好了各类问题 🔨。有了不错的哈希函数,能够根据不一样输入返回不一样输出。同时,咱们也有 rehash 函数根据须要动态地调整 HashMap的容量。这实在太好了!

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 中查找或访问元素的时间复杂度

这是 HashMap.get 方法,咱们经过往里面传递一个键来获取对应的值。让咱们回顾一下 DecentHashMap.get 的实现:

get(key){
  const hashIndex = this .getIndex(key);
  const values = this .array [hashIndex];
  forlet 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 中修改或删除元素的时间复杂度

修改(HashMap.set)或删除(HashMap.delete)键值对,分摊后的时间复杂度是 O(1)。若是冲突不少,可能面对的就是最坏状况,复杂度为 O(n)。然而伴随着 rehash 操做,能够大大减小最坏状况的发生的概率。

HashMap 修改或删除操做的时间复杂度平均是 O(1) ,最坏状况是 O(n)

HashMap 方法的时间复杂度

在下表中,小结了 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) 是冲突极多的极端状况

Sets

集合跟数组很是相像。它们的区别是集合中的元素是惟一的。

咱们该如何实现一个集合呢(也就是没有重复项的数组)?可使用数组实现,在插入新元素前先检查该元素是否存在。但检查是否存在的时间复杂度是 O(n)。能对此进行优化吗?以前开发的 Map (插入操做)分摊后时间复杂度度才 O(1)

Set 的实现

可使用 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 都可做为容器。

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)_是冲突极多的极端状况)

Linked Lists

链表是一种一个节点连接到下一个节点的数据结构。

LinkedList

链表是(本文)第一种不用数组(做为底层)实现的数据结构。咱们使用节点来实现,节点存储了一个元素,并指向下一个节点(若没有下一个节点,则为空)。

class Node {
  constructor(value) {
    this.value = value;
    this.next = null;
  }
}
复制代码

当每一个节点都指向它的下了一个节点时,咱们就拥有了一条由若干节点组成链条,即单向链表

Singly Linked Lists

对于单向链表而言,咱们只需关心每一个节点都有指向下一个节点的引用。

从首个节点或称之为根节点开始构建(单向链表)。

class LinkedList {
  constructor() {
    this.root = null;
  }
  // ...
}
复制代码

每一个链表都有四个基础操做:

  1. addLast:将一个元素添加至链表尾部。
  2. removeLast:删除链表的最后一个元素。
  3. addFirst:将一个元素添加到链表的首部。
  4. removeFirst:删除链表的首个元素。

向链表末尾添加与删除一个元素

(对添加操做而言,)有两种状况。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)

从链表的任意位置删除元素

删除链表首尾的元素,可使用 removeFirstremoveLast。然而,如若移除的节点在链表的中间,则须要将待删除节点的前一个节点指向待删除节点的下一个节点,从而从链表中删除该节点:

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)

咱们将在下一节实现这功能!

Doubly Linked Lists

当咱们有一串节点,每个都有指向下一个节点的引用,也就是拥有了一个单向链表。而当一串节点,每个既有指向下一个节点的引用,也有指向上一个节点的引用时,这串节点就是双向链表

Doubly Linked List

双向链表的节点有两个引用(分别指向前一个和后一个节点),所以须要保持追踪首个与最后一个节点。

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)

添加一个元素至链表任意一处

借助 addFirstaddLast,能够实现将一个元素添加到链表任意一处,实现以下:

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完整代码

若是添加元素的位置是在链表中间,咱们就必须更新该元素先后节点的 nextprevious 引用。

添加一个元素至链表任意一处的时间复杂度是 O(n).

双向链表方法的时间复杂度

双向链表每一个方法的时间复杂度以下表:

操做方法 时间复杂度 注释
addFirst O(1) 将元素插入到链表的开头
addLast O(1) 将元素插入到链表的末尾
add O(n) 将元素插入到链表的任意地方
removeFirst O(1) 删除链表的首个元素
removeLast O(1) 删除链表最后一个元素
remove O(n) 删除链表中任意一个元素
contains O(n) 在链表中查找任意元素

与单向链表相比,有了很大的改进(译者注:其实看场景,不要盲目认为双向链表必定比单向链表强)!(addLastremoveLast)操做时间复杂度从 O(n) 降至 O(1) ,这是因为:

  • 添加对前一个节点的引用。
  • 持有链表最后一个节点的引用。

删除首个或最后一个节点能够在恒定时间内完成,然而删除中间的节点时间复杂度仍然是 O(n)

Stacks

栈是一种越后被添加的元素,越先被弹出的数据结构。也就是后进先出(LIFO).

Stack: push and pop

让咱们从零开始实现一个栈!

class Stack {
  constructor() {
    this.input = [];
  }
  push(element) {
    this.input.push(element);
    return this;
  }
  pop() {
    return this.input.pop();
  }
}
复制代码

正如你看到的,若是使用内置的 Array.pushArray.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 直到最后才被弹出。栈也能够经过链表来实现,对应方法的时间复杂度是同样的。

这就是栈的所有内容啦!

Queues

队列是一种越先被添加的元素,越先被出列的数据结构。也就是先进先出(FIFO)。就如现实中排成一条队的人们同样,先排队的先被服务(也就是出列)。

Queue: enqueue and dequeue

能够经过数组来实现一个队列,代码与栈的实现相相似。

经过数组实现队列

经过 Array.pushArray.shift 能够实现一个简单(译者注:即不是最优的实现方式)的队列:

class Queue {
  constructor() {
    this.input = [];
  }
  add(element) {
    this.input.push(element);
  }
  remove() {
    return this.input.shift();
  }
}
复制代码

Queue.addQueue.remove 的时间复杂度是什么呢?

  • Queue.add 使用 Array.push 实现,能够在恒定时间内完成。这很是不错!
  • Queue.remove 使用 Array.shift 实现,Array.shift 耗时是线性的(即 O(n))。咱们能够减小 Queue.remove 的耗时吗?

试想一下,若是只用 Array.pushArray.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) 使用双向链表

注意: 二叉搜索树 与其余树结构、图结构,将在另外一篇文章中讨论。

相关文章
相关标签/搜索