2022 年 11 月 14 日

Map 和 Set

到目前為止,我們已經學習了以下複雜的資料結構

  • 物件用於儲存鍵控集合。
  • 陣列用於儲存順序集合。

但這還不足以應付實際情況。這就是為什麼 MapSet 也存在的緣故。

Map

Map 是一個鍵值資料項目的集合,就像一個 Object。但主要的不同點是 Map 允許任何類型的鍵。

方法和屬性為

例如

let map = new Map();

map.set('1', 'str1');   // a string key
map.set(1, 'num1');     // a numeric key
map.set(true, 'bool1'); // a boolean key

// remember the regular Object? it would convert keys to string
// Map keeps the type, so these two are different:
alert( map.get(1)   ); // 'num1'
alert( map.get('1') ); // 'str1'

alert( map.size ); // 3

正如我們所見,與物件不同,鍵不會轉換成字串。任何類型的鍵都是可能的。

map[key] 不是使用 Map 的正確方法

雖然 map[key] 也能運作,例如我們可以設定 map[key] = 2,這將 map 視為一個純粹的 JavaScript 物件,因此它暗示所有對應的限制(僅字串/符號鍵等)。

所以我們應該使用 map 方法:setget 等。

Map 也可以使用物件作為鍵。

例如

let john = { name: "John" };

// for every user, let's store their visits count
let visitsCountMap = new Map();

// john is the key for the map
visitsCountMap.set(john, 123);

alert( visitsCountMap.get(john) ); // 123

使用物件作為鍵是 Map 最顯著且重要的功能之一。Object 沒有這個功能。字串作為 Object 中的鍵是可以的,但我們無法使用另一個 Object 作為 Object 中的鍵。

讓我們試試看

let john = { name: "John" };
let ben = { name: "Ben" };

let visitsCountObj = {}; // try to use an object

visitsCountObj[ben] = 234; // try to use ben object as the key
visitsCountObj[john] = 123; // try to use john object as the key, ben object will get replaced

// That's what got written!
alert( visitsCountObj["[object Object]"] ); // 123

由於 visitsCountObj 是個物件,它會將所有 Object 鍵,例如上述的 johnben,轉換成相同的字串 "[object Object]"。這絕對不是我們想要的。

Map 如何比較鍵

為了測試鍵的等價性,Map 使用演算法 SameValueZero。它大致與嚴格相等 === 相同,但不同之處在於 NaN 被視為等於 NaN。因此 NaN 也可用作鍵。

此演算法無法變更或自訂。

串接

每個 map.set 呼叫都會傳回地圖本身,因此我們可以「串接」呼叫

map.set('1', 'str1')
  .set(1, 'num1')
  .set(true, 'bool1');

迭代 Map

對於迴圈 map,有 3 個方法

  • map.keys() – 傳回一個可迭代的鍵值,
  • map.values() – 傳回一個可迭代的值,
  • map.entries() – 傳回一個可迭代的條目 [key, value],預設用於 for..of

例如

let recipeMap = new Map([
  ['cucumber', 500],
  ['tomatoes', 350],
  ['onion',    50]
]);

// iterate over keys (vegetables)
for (let vegetable of recipeMap.keys()) {
  alert(vegetable); // cucumber, tomatoes, onion
}

// iterate over values (amounts)
for (let amount of recipeMap.values()) {
  alert(amount); // 500, 350, 50
}

// iterate over [key, value] entries
for (let entry of recipeMap) { // the same as of recipeMap.entries()
  alert(entry); // cucumber,500 (and so on)
}
使用插入順序

迭代順序與插入值的順序相同。Map 會保留此順序,與一般 Object 不同。

此外,Map 有內建的 forEach 方法,類似於 Array

// runs the function for each (key, value) pair
recipeMap.forEach( (value, key, map) => {
  alert(`${key}: ${value}`); // cucumber: 500 etc
});

Object.entries:從物件建立 Map

建立 Map 時,我們可以傳入一個陣列(或其他可迭代物件)包含鍵值對進行初始化,如下所示

// array of [key, value] pairs
let map = new Map([
  ['1',  'str1'],
  [1,    'num1'],
  [true, 'bool1']
]);

alert( map.get('1') ); // str1

如果我們有一個純粹的物件,我們想要從中建立一個 Map,那麼我們可以使用內建方法 Object.entries(obj),它會傳回一個陣列,其中包含物件的鍵值對,格式完全相同。

因此,我們可以從一個物件建立一個 map,如下所示

let obj = {
  name: "John",
  age: 30
};

let map = new Map(Object.entries(obj));

alert( map.get('name') ); // John

在此,Object.entries 傳回鍵值對陣列:[ ["name","John"], ["age", 30] ]。這就是 Map 所需的。

Object.fromEntries:從 Map 建立物件

我們剛剛看到如何使用 Object.entries(obj) 從純粹的物件建立 Map

有一個 Object.fromEntries 方法可以執行相反的動作:給定一個 [key, value] 對陣列,它會從中建立一個物件

let prices = Object.fromEntries([
  ['banana', 1],
  ['orange', 2],
  ['meat', 4]
]);

// now prices = { banana: 1, orange: 2, meat: 4 }

alert(prices.orange); // 2

我們可以使用 Object.fromEntriesMap 取得一個純粹的物件。

例如,我們將資料儲存在 Map 中,但我們需要將其傳遞給預期純粹物件的第三方程式碼。

如下所示

let map = new Map();
map.set('banana', 1);
map.set('orange', 2);
map.set('meat', 4);

let obj = Object.fromEntries(map.entries()); // make a plain object (*)

// done!
// obj = { banana: 1, orange: 2, meat: 4 }

alert(obj.orange); // 2

呼叫 map.entries() 會傳回一個鍵值對可迭代物件,格式完全符合 Object.fromEntries 的需求。

我們也可以縮短第 (*)

let obj = Object.fromEntries(map); // omit .entries()

這是一樣的,因為 Object.fromEntries 預期一個可迭代物件作為參數。不一定是陣列。而 map 的標準迭代會傳回與 map.entries() 相同的鍵值對。因此,我們會取得一個純粹的物件,其鍵值與 map 相同。

Set

一個 Set 是一個特殊的集合類型 - 「值集合」(沒有鍵),其中每個值只能出現一次。

它的主要方法是

  • new Set([iterable]) – 建立集合,如果提供了 iterable 物件(通常是陣列),則從中複製值到集合中。
  • set.add(value) – 新增一個值,傳回集合本身。
  • set.delete(value) – 移除該值,如果 value 在呼叫時存在,則傳回 true,否則傳回 false
  • set.has(value) – 如果值存在於集合中,則傳回 true,否則傳回 false
  • set.clear() – 從集合中移除所有項目。
  • set.size – 是元素數量。

主要特色是重複呼叫 set.add(value) 時,如果值相同,則不會執行任何動作。這就是為什麼每個值只會在 Set 中出現一次的原因。

例如,我們有訪客到來,我們想要記住每個人。但重複拜訪不應該導致重複資料。訪客必須只被「計算」一次。

Set 正好適合這個需求

let set = new Set();

let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };

// visits, some users come multiple times
set.add(john);
set.add(pete);
set.add(mary);
set.add(john);
set.add(mary);

// set keeps only unique values
alert( set.size ); // 3

for (let user of set) {
  alert(user.name); // John (then Pete and Mary)
}

Set 的替代方案可能是使用者陣列,以及使用 arr.find 在每次插入時檢查重複資料的程式碼。但效能會差很多,因為這個方法會遍歷整個陣列,檢查每個元素。Set 在內部針對唯一性檢查進行了更好的最佳化。

遍歷 Set

我們可以使用 for..offorEach 來迴圈遍歷 Set

let set = new Set(["oranges", "apples", "bananas"]);

for (let value of set) alert(value);

// the same with forEach:
set.forEach((value, valueAgain, set) => {
  alert(value);
});

請注意一個有趣的事情。傳遞給 forEach 的回呼函式有 3 個參數:一個 value,然後同一個值 valueAgain,然後是目標物件。的確,同一個值會出現在參數中兩次。

這是為了與 Map 相容,其中傳遞給 forEach 的回呼函式有三個參數。看起來有點奇怪,這點可以確定。但在某些情況下,這可能有助於輕鬆地用 Set 取代 Map,反之亦然。

Map 針對反覆運算子所使用的相同方法也受到支援

  • set.keys() – 傳回值的可迭代物件,
  • set.values() – 與 set.keys() 相同,用於與 Map 相容,
  • set.entries() – 傳回條目的可迭代物件 [value, value],用於與 Map 相容。

摘要

Map – 是鍵值集合。

方法和屬性

  • new Map([iterable]) – 建立 Map,並提供初始化用的 [key,value] 成對可迭代物件(例如陣列)。
  • map.set(key, value) – 根據鍵值儲存值,傳回 Map 本身。
  • map.get(key) – 傳回鍵的值,如果 key 不存在於地圖中,則傳回 undefined
  • map.has(key) – 如果 key 存在,則傳回 true,否則傳回 false
  • map.delete(key) – 根據鍵值移除元素,如果在呼叫時 key 存在,則傳回 true,否則傳回 false
  • map.clear() – 移除地圖中的所有東西。
  • map.size – 傳回目前的元素計數。

與一般 Object 的差異

  • 任何鍵值,物件都可以是鍵值。
  • 額外方便的方法,size 屬性。

Set - 是唯一值的集合。

方法和屬性

  • new Set([iterable]) - 建立集合,可選擇 iterable (例如陣列) 的值來初始化。
  • set.add(value) - 加入一個值 (如果 value 存在則不執行任何動作),回傳集合本身。
  • set.delete(value) – 移除該值,如果 value 在呼叫時存在,則傳回 true,否則傳回 false
  • set.has(value) – 如果值存在於集合中,則傳回 true,否則傳回 false
  • set.clear() – 從集合中移除所有項目。
  • set.size – 是元素數量。

MapSet 的迭代總是按照插入順序,所以我們不能說這些集合是無序的,但我們不能重新排序元素或直接透過數字取得元素。

任務

重要性: 5

arr 成為一個陣列。

建立一個函式 unique(arr),它應該回傳一個陣列,其中包含 arr 的唯一項目。

例如

function unique(arr) {
  /* your code */
}

let values = ["Hare", "Krishna", "Hare", "Krishna",
  "Krishna", "Krishna", "Hare", "Hare", ":-O"
];

alert( unique(values) ); // Hare, Krishna, :-O

P.S. 這裡使用字串,但可以是任何類型的值。

P.P.S. 使用 Set 來儲存唯一值。

開啟一個包含測試的沙盒。

function unique(arr) {
  return Array.from(new Set(arr));
}

在沙盒中開啟包含測試的解答。

重要性: 4

字謎 是具有相同數量的相同字母,但順序不同的字詞。

例如

nap - pan
ear - are - era
cheaters - hectares - teachers

撰寫一個函式 aclean(arr),它回傳一個從字謎中清除的陣列。

例如

let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"];

alert( aclean(arr) ); // "nap,teachers,ear" or "PAN,cheaters,era"

從每個字謎群組中只保留一個字詞,無論哪一個。

開啟一個包含測試的沙盒。

要找出所有字謎,我們將每個字詞拆分為字母並對它們進行排序。當字母排序後,所有字謎都是相同的。

例如

nap, pan -> anp
ear, era, are -> aer
cheaters, hectares, teachers -> aceehrst
...

我們將字母排序的變體用作地圖鍵,以每個鍵只儲存一個值

function aclean(arr) {
  let map = new Map();

  for (let word of arr) {
    // split the word by letters, sort them and join back
    let sorted = word.toLowerCase().split('').sort().join(''); // (*)
    map.set(sorted, word);
  }

  return Array.from(map.values());
}

let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"];

alert( aclean(arr) );

字母排序是由 (*) 行中的呼叫鏈完成的。

為了方便,我們將它拆分為多行

let sorted = word // PAN
  .toLowerCase() // pan
  .split('') // ['p','a','n']
  .sort() // ['a','n','p']
  .join(''); // anp

兩個不同的字詞 'PAN''nap' 接收相同的字母排序形式 'anp'

下一行將字詞放入地圖中

map.set(sorted, word);

如果我們再次遇到具有相同字母排序形式的字詞,那麼它將使用相同的鍵覆寫地圖中的先前值。因此,我們將始終每個字母形式最多只有一個字詞。

最後,Array.from(map.values()) 會對地圖值進行迭代 (我們在結果中不需要鍵),並回傳它們的陣列。

在這裡,我們也可以使用一般物件而不是 Map,因為鍵是字串。

解決方案看起來就像這樣

function aclean(arr) {
  let obj = {};

  for (let i = 0; i < arr.length; i++) {
    let sorted = arr[i].toLowerCase().split("").sort().join("");
    obj[sorted] = arr[i];
  }

  return Object.values(obj);
}

let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"];

alert( aclean(arr) );

在沙盒中開啟包含測試的解答。

重要性: 5

我們想要在變數中取得 map.keys() 的陣列,然後對它套用陣列特定的方法,例如 .push

但這行不通

let map = new Map();

map.set("name", "John");

let keys = map.keys();

// Error: keys.push is not a function
keys.push("more");

為什麼?我們要如何修正程式碼讓 keys.push 能夠運作?

那是因為 map.keys() 回傳的是可迭代物件,而不是陣列。

我們可以使用 Array.from 將它轉換成陣列

let map = new Map();

map.set("name", "John");

let keys = Array.from(map.keys());

keys.push("more");

alert(keys); // name, more
教學地圖

留言

留言前請先閱讀…
  • 如果你有建議要如何改進,請 提交 GitHub 議題 或提出 pull request,而不是留言。
  • 如果你看不懂文章中的某個部分,請詳細說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,要插入多行,請用 <pre> 標籤包覆,要插入超過 10 行,請使用沙盒 (plnkrjsbincodepen…)