資料結構:Array、HashMap 與 List 深入解析
當開發程式時,我們(通常)需要在記憶體中儲存資料。根據操作資料方式的不同,可能會選擇不同的資料結構。有很多常用的資料結構,如:Array、Map、Set、List、Tree、Graph 等等。(然而)為程式選取合適的資料結構可能並不容易。因此,希望這篇文章能幫助你瞭解(不同資料結構的)表現,以求在工作中合理地使用它們。
本文主要聚焦於線性的資料結構,如:Array、Set、List、Sets、Stacks、Queues 等等。
下表是本文所討論內容的概括。
加個書籤、收藏或分享本文,以便不時之需。
* = 執行時分攤
資料結構 | 插入 | 訪問 | 查詢 | 刪除 | 備註 |
---|---|---|---|---|---|
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.
陣列可由零個或多個元素組成。由於陣列易於使用且檢索效能優越,它是最常用的資料結構之一。
你可以將陣列想象成一個抽屜,可以將資料存到匣子中。
陣列就像是將東西存到匣子中的抽屜
當你想查詢某個元素時,你可以直接開啟對應編號的匣子(時間複雜度為 O(1))。然而,如果你忘記了匣子裡存著什麼,就必須逐個開啟所有的匣子(時間複雜度為 O(n)),直到找到所需的東西。陣列也是如此。
根據程式語言的不同,陣列存在一些差異。對於 JavaScript 和 Ruby 等動態語言而言,陣列可以包含不同的資料型別:數字,字串,物件甚至函式。而在 Java 、 C 、C ++ 之類的強型別語言中,你必須在使用陣列之前,定好它的長度與資料型別。JavaScript 會在需要時自動增加陣列的長度。
Array 的內建方法
根據程式設計序言的不同,陣列(方法)的實現稍有不同。
比如在 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, ]
你覺得新增元素到陣列開頭的函式,時間複雜度是什麼呢?看起來和上面(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)
在陣列中刪除元素
你覺得從陣列中刪除元素的時間複雜度是什麼呢?
先一起思考下這兩種情況:
-
從陣列的末尾刪除元素所需時間是恆定的,也就是 O(1)。
-
然而,無論是從陣列的開頭或是中間位置刪除元素,你都需要調整(刪除元素後面的)元素位置。因此複雜度為 O(n)。
說多無謂,看程式碼好了:
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:
-
陣列:通過雜湊函式將鍵對映為陣列的索引。(查詢)最差情況: O(n),平均: O(1)。
-
二叉搜尋樹: 使用自平衡二叉搜尋樹查詢值(另外的文章會詳細介紹)。 (查詢)最差情況: O(log n),平均:O(log n)。
我們會介紹樹與二叉搜尋樹,現在先不用擔心太多。實現 Map 最常用的方式是使用 陣列與雜湊轉換函式。讓我們(通過陣列)來實現它吧
通過陣列實現 HashMap
正如上圖所示,每個鍵都被轉換為一個 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;
}
}
我們直接使用桶而不是抽屜與匣子,相信你能明白喻義的意思 :)
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