魯斯前端布魯斯前端

文章中英模式

布魯斯前端JS面試題目 - Map與Set資料結構

深入解析JavaScript中的Map與Set資料結構,比較Map與Object的差異,探討WeakMap與WeakSet的應用場景,以及面試常見問題解答。

影片縮圖

懶得看文章?那就來看影片吧

Map與Set是什麼?

Map和Set是ES6引入的兩種新型資料結構:

  • Map 是一種鍵值對的集合,類似於物件,但允許任何類型的值作為鍵
  • Set 是一種值的集合,其中的每個值都是唯一的

Map的基本用法

// 建立Map
const userMap = new Map();

// 設置鍵值對
userMap.set('name', 'Bruce');
userMap.set(123, 'ID');

// 取得值
console.log(userMap.get('name')); // Bruce

// 檢查鍵是否存在
console.log(userMap.has('name')); // true

// 刪除鍵值對
userMap.delete('name');

Set的基本用法

// 建立Set
const uniqueNumbers = new Set();

// 添加值
uniqueNumbers.add(1);
uniqueNumbers.add(2);
uniqueNumbers.add(1); // 重複值不會被添加

// 檢查值是否存在
console.log(uniqueNumbers.has(1)); // true

// 刪除值
uniqueNumbers.delete(1);

WeakMap與WeakSet

WeakMap和WeakSet是它們的「弱引用」版本,當物件不再被使用時會自動釋放記憶體:

  • 只允許物件作為鍵/值
  • 不會阻止垃圾回收器回收它們的鍵/值
  • 沒有size屬性和清單方法

小遊戲應用範例

// 使用WeakMap儲存關卡暫存資料
const gameCache = new WeakMap();

// 創建關卡 (每關是一個DOM元素)
function createLevel(levelNum) {
  const levelDiv = document.createElement('div');
  levelDiv.className = 'game-level';
  levelDiv.innerHTML = `<h2>${levelNum}</h2>`;
  
  // 儲存關卡相關暫存資料
  gameCache.set(levelDiv, {
    score: 0,
    items: ['寶石', '金幣', '武器']
  });
  
  return levelDiv;
}

// 切換到下一關
function goToNextLevel() {
  // 移除當前關卡DOM元素
  const oldLevel = document.querySelector('.game-level');
  oldLevel.remove();
  
  // 創建新關卡
  const newLevel = createLevel(2);
  document.body.appendChild(newLevel);
  
  // 此時舊關卡的暫存資料會自動被回收
  // 如果用Map而非WeakMap, 即使關卡DOM被移除
  // 暫存資料仍會佔用記憶體, 遊戲久了會變慢或崩潰

Map與Set的內部原理:Hash Table實現

Map和Set在內部都使用Hash Table(雜湊表)實現:

1. Hash函數原理

  • 將鍵(或值)轉換為數字索引(雜湊碼)
  • 對於原始類型,有專門的雜湊演算法
  • 對於物件類型,一般基於記憶體地址

2. Set.add()的重複值處理

  • 計算值的雜湊碼
  • 檢查該雜湊碼位置是否已有值
  • 相同則忽略,不同則添加
// Set處理重複值的例子
const set = new Set();

set.add('a');         // 存儲
set.add('a');         // 已存在,忽略
console.log(set.size); // 1

// 物件比較
const obj1 = {};
const obj2 = {};
set.add(obj1);        // 存儲
set.add(obj2);        // 不同物件,存儲
console.log(set.size); // 2

Map與Object的區別

特性MapObject
鍵的類型任何值只能是字符串或Symbol
鍵的順序保留插入順序不保證順序
Size直接獲取需手動計算
效能頻繁增刪更優查找可能更快

Set中的值比較邏輯

Set使用「Same-value-zero」演算法比較值的唯一性:

  • Object.is() === 的混合體
  • NaN 視為等於自身(像 Object.is
  • 但將 +0 -0 視為相等(像 ===
  • 物件比較是基於參考位址
const set = new Set();

// NaN 在 Set 中被視為相等
set.add(NaN);
set.add(NaN); // 不會添加
console.log(set.size); // 1

// +0 和 -0 在 Set 中被視為相等
set.add(+0);
set.add(-0); // 不會添加
console.log(set.size); // 仍然是 1

// 物件比較是基於參考
const obj1 = {id: 1};
const obj2 = {id: 1};
set.add(obj1);
set.add(obj2); // 會添加,因為參考不同
console.log(set.size); // 2

set.add(obj1); // 不會添加,參考相同
console.log(set.size); // 仍然是 2

🔥 常見面試題目

(一)如何使用Set去除陣列中的重複元素?

解答:

// 基本用法
const array = [1, 2, 3, 3, 4, 4, 5];
const uniqueArray = [...new Set(array)];
console.log(uniqueArray); // [1, 2, 3, 4, 5]

// 物件陣列去重
const users = [
  { id: 1, name: 'Bruce' },
  { id: 1, name: 'Bruce' },
  { id: 2, name: 'Tony' }
];

const uniqueUsers = Array.from(
  new Map(users.map(user => [user.id, user])).values()
);
console.log(uniqueUsers); // 只有兩個物件

這是利用Set不允許重複元素的特性,將陣列轉換為Set後再轉回陣列,是最簡潔高效的去重方法。

(二)Map和Object的主要區別?選擇使用Map的場景?

解答:Map和Object的主要區別在於:

// 1. Map可使用任何類型作為鍵
const map = new Map();
const func = function() {};
map.set(func, 'Function as key');
console.log(map.get(func)); // 'Function as key'

// 2. Map保留插入順序
map.set('first', 1);
map.set('second', 2);

for (let [key, value] of map) {
  console.log(key, value); // 按插入順序輸出
}

適合使用Map的場景:

  • 當需要非字符串作為鍵時
  • 需要頻繁添加和刪除屬性時
  • 當鍵值對的順序很重要時

(三)WeakMap和Map的區別?什麼時候應該使用WeakMap?

解答:

// WeakMap用於存儲DOM節點相關數據
const nodeData = new WeakMap();

// 為DOM節點添加數據,不會影響垃圾回收
document.querySelectorAll('button').forEach(node => {
  nodeData.set(node, { clickCount: 0 });
});

// 當節點被移除時,相關數據自動被回收
document.body.innerHTML = '';
// 不需要手動清理nodeData

使用WeakMap的場景:

  • 需要將額外數據關聯到DOM元素時
  • 實現私有變數或狀態時
  • 需要避免記憶體洩漏的場合

(四)請解釋HashTable的原理以及Map是如何實現的?

解答:

// HashTable視覺化解釋
/*
  Hash表原理示意圖:
  
  鍵值對: { "name": "Bruce", "age": 30, "city": "Taipei", "code": "TW" }
  
  Hash函數                  儲存桶(Buckets)
  ┌─────────┐               ┌───────────────────┐
  │ hash()  │──── 0 ────────► [                 ]
  └─────────┘               │                   │
                            └───────────────────┘
                            ┌───────────────────┐
  "name" → hash() → 1       │  ┌───────────┐    │
                            │  │"name":"Bruce"│  │
                            │  └───────────┘    │
                            └───────────────────┘
                            ┌───────────────────┐
  "city" → hash() → 2       │  ┌───────────┐    │
                            │  │"city":"Taipei"│ │
                            │  └───────────┘    │
                            └───────────────────┘
                            ┌───────────────────────────────┐
  "age" → hash() → 3        │  ┌───────────┐  ┌───────────┐ │
  "code" → hash() → 3       │  │"age": 30  │  │"code":"TW" │ │ ← 碰撞(collision)!
  "job" → hash() → 3        │  └───────────┘  └───────────┘ │   桶內陣列變長了
                            └───────────────────────────────┘
*/

// 簡化版HashTable實現
class SimpleHashTable {
  constructor() {
    this.buckets = Array(4).fill().map(() => []);
  }
  
  hash(key) {
    return String(key).length % this.buckets.length;
  }
  
  set(key, value) {
    const index = this.hash(key);
    const bucket = this.buckets[index];
    
    const item = bucket.find(item => item.key === key);
    if (item) {
      item.value = value;
    } else {
      bucket.push({ key, value });
    }
  }
  
  get(key) {
    const index = this.hash(key);
    const bucket = this.buckets[index];
    const item = bucket.find(item => item.key === key);
    return item ? item.value : undefined;
  }
}

// 使用示例 (展示碰撞)
const table = new SimpleHashTable();

// 'name'和'code'都雜湊到索引0
table.set('name', 'Bruce');
table.set('code', 'TW');

// 'age'和'job'都雜湊到索引3
table.set('age', 30);
table.set('job', 'Dev');

// 查看桶的內容
console.log('Bucket 0:', table.buckets[0]);
// 輸出: [
//   { key: 'name', value: 'Bruce' },
//   { key: 'code', value: 'TW' }
// ]

console.log('Bucket 3:', table.buckets[3]);
// 輸出: [
//   { key: 'age', value: 30 },
//   { key: 'job', value: 'Dev' }
// ]

實際的Map實現更複雜,包含動態大小調整、高效Hash函數和優化的碰撞處理。碰撞過多時,查找效率會從O(1)退化為O(n),因此現代實現會平衡桶數量與內容。Map和Set的主要操作通常能維持接近O(1)的時間複雜度,這是它們廣泛應用的原因。

(五)Set如何處理重複值?內部機制是什麼?

解答:Set在添加值時會檢查該值是否已存在,使用的是Same-value-zero比較:

// Set處理原始類型重複值
const set = new Set();

// 數字類型
set.add(1);
set.add(1);     // 不會添加
console.log(set.size); // 1

// NaN比較
set.add(NaN);
set.add(NaN);   // 不會添加
console.log(set.size); // 2

// 物件比較
const obj1 = { id: 1 };
const obj2 = { id: 1 };
set.add(obj1);
set.add(obj2); // 會添加,因為參考不同
console.log(set.size); // 3

set.add(obj1); // 不會添加,參考相同
console.log(set.size); // 3