原文連接: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) | 使用雙向鏈表 |
注意: 二叉搜索樹 與其餘樹結構、圖結構,將在另外一篇文章中討論。