鲁斯前端布鲁斯前端

文章中英模式

布鲁斯前端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