這週是 六角鼠年鐵人賽 第十五週,這週來看具有不重父元素特性的資料結構,「集合」 &「 映射」。
  
  
集合(Set)
集合(Set) 是一組無順序且唯一的資料所組成的資料結構,概念衍生自數學的「集合」。
特性:
- 無序性:集合內各元素無特定排序或排序不重要。
- 互異性:集合內每個元素且只能出現一次。
- 確定性:給定一個集合,任給一個元素,該元素或者屬於或者不屬於該集合,二者必居其一。
基本操作:
集合的基本操作,沒有取得指定元素的操作,這是因為集合是無序的資料結構,沒有索引或鍵名可以快速取得元素,必須透過迭代來查詢。
運算:
- 聯集:將兩個集合的元素合併成一個新集合(元素不重複)。
- 交集:將兩個集合中共有的元素,組成一個新集合。
- 對稱差:將兩個集合中不重複的元素,組成一個新集合。
- 差集:給定兩集合,回傳一個包含存在第一個集合元素但不存在於第二集合的集合

JavaScript 實作集合
JavaScript 在 ES6 就新增了 Set 物件,但我們這裡嘗試使用物件模擬簡易的 Set。
1. 建立類別
| 1
2
3
4
5
6
 | class MySet {
  constructor() {
    this.items = {};
  }
  // methods
}
 | 
 
裝資料的容器 items 使用物件而非陣列,是因為 JavaScript 的物件屬性不會重複,可以確保集合內的元素都是唯一的。
2. 方法
基本操作:
- add(element):新增元素。
- delete(element):移除元素。
- has(element):檢查元素是否存在。
因為 add 和 delete 方法會用到 has 方法,所以先來實現 has 方法。可以使用 in 關係運算子,來判斷元素是否是 items 物件的屬性:
| 1
2
3
 | has(element) {
  return element in this.items;
}
 | 
 
或是使用 hasOwnProperty() 來判斷物件是否有該屬性:
| 1
2
3
 | has(element) {
  return this.items.hasOwnProperty(element);
}
 | 
 
如果你有使用 ESLint 會拋出 錯誤,可以改成 Object.prototype.hasOwnProperty.call(this.items, element)。
新增元素 add 方法,必須先檢查元素是否存在,如果元素已存在就跳出:
| 1
2
3
4
 | add(element) {
  if (this.has(element)) return;
  this.items[element] = element;
}
 | 
 
刪除元素 delete 方法,也要檢查元素是否存在,如果元素已存在就刪除:
| 1
2
3
4
5
 | delete(element) {
  if (this.has(element)) {
    delete this.items[element];
  }
}
 | 
 
其餘輔助屬性方法:
- clear():清空。
- size():元素數量。
- values():回傳一個包含所有元素的陣列。
最終程式碼:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 | class MySet {
  constructor() {
    this.items = {};
  }
  has(element) {
    return Object.prototype.hasOwnProperty.call(this.items, element);
  }
  add(element) {
    if (this.has(element)) return;
    this.items[element] = element;
  }
  delete(element) {
    if (this.has(element)) {
      delete this.items[element];
    }
  }
  clear() {
    this.items = {};
  }
  size() {
    return Object.keys(this.items).length;
  }
  values() {
    return Object.values(this.items);
  }
}
 | 
 
建立實體:
| 1
2
3
4
5
6
7
8
9
 | const set = new MySet();
set.add(1);
set.add(2);
set.add(3);
console.log(set.values()); // [1, 2, 3]
set.delete(1);
console.log(set.values()); // [2, 3]
 | 
 
因為是簡易的 Set,所以有很多問題,例如:物件的屬性只能儲存字串,如果元素值為其他的型別都會被強制轉型成字串。
3. ES6 原生 Set
Set 只能使用建構式建立:
能夠接受一個參數 iterable(可迭代物件)。
| 1
2
3
4
5
 | const set1 = new Set(['a', 'b', 'c']);
console.log(set1); // Set {'a', 'b', 'c'}
const set2 = new Set('Hello');
console.log(set2); // Set {'H', 'e', 'l', 'o'}
 | 
 
屬性方法:
- add():新增元素。
- clear():移除其所有元素。
- delete():移除指定元素。
- has():檢查員素是否存在。
- values():回傳一個 Iterator 物件,包含著 Set 物件中所有元素,由插入順序排序。
- size:元素數量。
- forEach():迭代處理元素,用法等同陣列的- forEach()。
| 1
2
3
4
5
6
7
8
9
 | const set = new Set();
set.add('a');
set.add('b');
set.add('c');
console.log(set); // Set {'a', 'b', 'c'}
set.delete('b');
console.log(set); 
 | 
 
更詳細內容請參考 MDN。
集合操作
使用 ES6 原生 Set。
1. 聯集
創建一個函式,回傳兩個集合中所有元素的新集合。
最基本的作法就是迭代集合,將元素新增至新集合中:
| 1
2
3
4
5
6
 | function uion(set1, set2) {
  const newSet = new Set();
  set1.forEach(item => newSet.add(item) );
  set2.forEach(item => newSet.add(item) );
  return newSet;
}
 | 
 
更簡單的的作法就是,使用展開運算子(spread operator),將兩個集合在一個陣列中展開,並建立新集合。
| 1
2
3
 | function union(set1, set2) {
  return new Set([...set1, ...set2]);
}
 | 
 
| 1
2
3
4
 | const set1 = new Set(['a', 'b', 'c']);
const set2 = new Set(['a', 'c', 'e']);
console.log(union(set1, set2)); // Set {'a', 'b', 'c', 'e'}
 | 
 
2. 交集
將兩個集合中共有的元素,組成一個新集合。
| 1
2
3
4
5
6
7
8
9
 | function intersection(set1, set2) {
  const temp = new Set();
  set1.forEach(item => {
    if(set2.has(item)) {
      temp.add(item);
    }
  });
  return temp;
}
 | 
 
使用 has() 去檢查元素。
| 1
2
3
4
 | const set1 = new Set(['a', 'b', 'c']);
const set2 = new Set(['a', 'c', 'e']);
console.log(intersection(set1, set2));  // Set {'a', 'c'}
 | 
 
3. 對稱差
將兩個集合中不重複的元素,組成一個新集合。
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 | function difference(set1, set2) {
  const temp = union(set1, set2);
  const intersectionSet = intersection(set1, set2);
  
  intersection.forEach(item => {
    if(temp.has(item)) {
      temp.delete(item);
    }
  });
  return temp;
}
 | 
 
簡單來說就是聯集減去交集。
| 1
2
3
4
 | const set1 = new Set(['a', 'b', 'c']);
const set2 = new Set(['a', 'c', 'e']);
console.log(difference(set1, set2)); // Set {'b', 'e'}
 | 
 
4. 差集
給定兩集合,回傳一個包含存在第一個集合元素但不存在於第二集合的集合。
| 1
2
3
4
5
6
7
8
9
 | function subtracting(set1, set2) {
  const temp = new Set(set1);
  set2.forEach((item) => {
    if (temp.has(item)) {
      temp.delete(item);
    }
  });
  return temp;
}
 | 
 
先複製 set1 再減去 set2 擁有的元素。
或者是新建一個集合,再將沒重複的元素新增至新集合中:
| 1
2
3
4
5
6
7
8
9
 | function subtracting(set1, set2) {
  const temp = new Set();
  set2.forEach((item) => {
    if (!temp.has(item)) {
      temp.add(item);
    }
  });
  return temp;
}
 | 
 
| 1
2
3
4
 | const set1 = new Set(['a', 'b', 'c']);
const set2 = new Set(['a', 'c', 'e']);
console.log(subtracting(set1, set2)); // Set {'b'}
 | 
 
5. 子集
檢查該集合是是否為另一個集合的子集。
| 1
2
3
4
5
6
7
8
 | function subSet(set1, set2) {
  if (set1.size > set2.size) return false;
  for (let item of set1) {
    if (!set2.has(item)) return false;
  }
  return true;
}
 | 
 
forEach 沒辦法 return 所以我們使用 for of 來迭代 Set。
| 1
2
3
4
5
 | console.log(subSet(new Set(['a', 'b', 'c']), new Set(['a', 'c', 'e'])));
// false
console.log(subSet(new Set(['a', 'b', 'c']), new Set(['a', 'b', 'c', 'e'])));
// true
 | 
 
映射(Map)
映射(Map)或稱 關聯陣列(Associative Array)、字典(Dictionary)是一種以「鍵值對 {key: value} 」形式儲存的有序資料結構,會根據不同語言有不同的名稱。
映射,就如同電話簿中的名字和號碼一樣,先找到名字,就知道它的電話號碼。鍵(key)就是姓名,而值(value)如同電話號碼。
定義:
- key一定對映- value
- 一個映射不能包含重複的 key
- 每個 key最多只能對映到一個value
基本操作:
JavaScript 中的映射
JavaScript 的物件物件本質上是鍵值對的資料結構,當你需要將 key 對應到 value 時,可以將字串作為 key 對應到任何型別的 value,搭配 in、delete、[] 等,能達到映射的相關操作。
然而,物件終究不是映射類型,可能會遇到以下問題:
- 因為物件原型的特性,因此可能對應到意外的東西。
- 不易知道物件裡有多少對應。
- 物件無法使用非字串值作為 key。
- 無法保證 key的順序。
請考慮以下情況,物件無法使用非字串值作為屬性名:
| 1
2
3
4
5
6
7
8
9
 | const m = {};
const x = { id: 1 };
const y = { id: 1 };
m[x] = 'foo';
m[y] = 'bar';
console.log(m[x]); // bar
 | 
 
你可以發現 { id: 1 } 被強制轉型成字串,m[x] 與 m[y] 指向同一個屬性。
1. ES6 原生 Map 物件
JavaScript 在 ES6 實現了 Map 物件,就是字典。
Map 只能使用建構式建立:
iterable 基本上為陣列(或其他元素成鍵值對的可迭代物件)。
用法如下:
| 1
2
3
4
5
6
 | const m = new Map([
  ['a', 1],
  ['b', 2],
]);
console.log(m); // Map { 'a' => 1, 'b' => 2 }
 | 
 
陣列的第一個項目就 key,第二個為 value。
或是使用 Object.entries 方法,它回傳的格式就如同上面所需要的:
| 1
2
3
4
5
6
 | const obj = { a: 1, b: 2 };
const iterable = Object.entries(obj);
console.log(iterable); // [ [ 'a', 1 ], [ 'b', 2 ] ]
const m = new Map(iterable);
console.log(m); // Map { 'a' => 1, 'b' => 2 }
 | 
 
也就是說可以使用此方法將物件轉成 Map。
存取元素的方法為:
- set(key, value):根據- key存儲- value。
- get(key):根據- key回傳- value,如果 map 中該- key不存在,回傳- undefined。
| 1
2
3
4
5
6
 | const m = new Map();
m.set('a', 1);
console.log( m.get('a') ); // 1
console.log( m.get('b') ); // undefined
 | 
 
set() 會回傳當前 map 實體,因此可以採鏈式寫法:
| 1
2
3
4
5
 | const m = new Map();
m.set('a', 1)
  .set('b', 2)
  .set('c', 3);
 | 
 
移除元素需要使用 delete(key),而不是 delete 運算子,而 has(key) 可以判斷 key 是否存在。
| 1
2
3
4
5
6
7
8
 | const m = new Map([
  ['a', 1],
  ['b', 2]
]);
console.log( m.has('a') );  // true
m.delete('a');
console.log( m.has('a') );  // false
 | 
 
Map 與物件另一個最大差異就是有 forEach 方法和可以使用 for of 迭代:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
 | const m = new Map([
  ['a', 1],
  ['b', 2],
  ['c', 3],
]);
m.forEach((value, key) => {
  console.log(value, key);
});
// 1 'a'
// 2 'b'
// 3 'c'
for (let key of m) {
  console.log(key);
}
// ['a', 1]
// ['b', 2]
// ['c', 3]
 | 
 
更詳細內容請參考 Map。
2. WeakMap
除了 Map,ES6 還新增了 WeakMap。
WeakMap 與 Map 基本上一樣,除了以下幾點:
- WeakMap 的 key只接受物件作為key。
- WeakMap 的 key所指向的物件可以被垃圾回收。
- WeakMap 無法被迭代或清除。
| 1
2
3
4
5
6
 | const wm = new WeakMap();
const obj = {};
wm.set(obj, 1); // 正常
wm.set('a', 2); // Uncaught TypeError Invalid value used as weak map key
 | 
 
WeakMap 只有以下屬性方法:
- get(key)
- set(key, value)
- delete(key)
- has(key)
size、values()、entries()、forEach() 等皆沒有。
WeakMap 對 key 引用是「弱引用」,這意味著若沒有其他引用存在時,垃圾回收機制就會釋放該物件所佔用的記憶體空間。因此 WeakMap 被設計成無法迭代。
舉例來說,我們使用一個 Map 來記錄用戶的訪問次數:
| 1
2
3
4
 | let john = { name: 'John' };
const visitsCountMap = new Map();
visitsCountMap.set(john, 1);
 | 
 
某天這個用戶都不會來了,所以我們不需要它的訪問資料了,我們如果只有移除 john 的指向,map 內的內容還是會存在:
| 1
2
3
 | john = null;
console.log( visitsCountMap.size );  // 1
 | 
 
因為還需要清除 map 的內容,所以不能先移除 john 的指向,需要改成先移除 map 內的引用,再移除 john 的指向:
| 1
2
3
4
5
6
7
 | // 上段程式碼改成這樣
visitsCountMap.delete( john );
console.log( visitsCountMap.size ); // 0
// 清除 map 的內容再移除指向
john = null;
 | 
 
但這樣的資料移除非常麻煩,因此就有了 WeakMap:
| 1
2
3
4
5
 | let john = { name: 'John' };
const visitsCountWeakMap = new WeakMap();
visitsCountWeakMap.set(john, 1);
john = null;
 | 
 
{ name: 'John' } 這個物件,除了 WeakMap 沒有其他引用了,所以這個物件會自動的從記憶體和 visitsCountWeakMap 中刪除。
更詳細內容請參考 WeakMap。