不止语法糖:TypeScript Set 与 Map 深度解析
一、本质先行:Set 与 Map 到底是什么?
很多人会把 Set、Map 当成普通的 "语法糖",但二者的核心是基于哈希表(散列表)实现的高性能数据结构,这也是它们和数组、普通对象最核心的区别。
数组的元素查找、插入、删除操作,最坏时间复杂度为O(n)(需要全量遍历数组);普通对象的键值操作会受隐式类型转换、原型链污染影响,性能极不稳定。而 Set 与 Map 的增、删、查操作,平均时间复杂度稳定在O(1),无需遍历即可定位元素,性能远超数组和普通对象。
二者的核心定位非常清晰:
- Set:无序、不重复的集合,专门解决「唯一性判断、数据去重、集合运算」场景,核心能力是快速判断元素是否存在
- Map:键值对的映射集合,专门解决「键值关联、快速查找」场景,核心能力是通过键快速定位对应的值,支持任意类型作为键
二、TypeScript 的核心优势:类型安全的泛型约束
TypeScript 对 Set 与 Map 做了深度的类型强化,通过内置泛型约束,我们可以精准定义集合内的元素类型、映射的键值类型,从编译层面杜绝类型错误,避免运行时的类型 bug。
2.1 基础类型约束
typescript
// 定义仅能存储数字的Set
const numSet: Set<number> = new Set();
numSet.add(1); // 正常编译
numSet.add("2"); // 编译直接报错:类型"string"的参数不能赋给类型"number"的参数
numSet.has(1); // 正常查询
numSet.delete(1); // 正常删除
// 定义键为数字、值为学生信息的Map
interface Student {
id: number;
name: string;
age: number;
score: number;
}
const studentMap: Map<number, Student> = new Map();
studentMap.set(2024001, { id: 2024001, name: "张三", age: 18, score: 90 }); // 正常编译
studentMap.set(2024002, { id: 2024002, name: "李四", age: 19 }); // 编译报错:缺少score属性
const targetStudent = studentMap.get(2024001); // IDE自动推导类型为 Student | undefined
泛型约束带来了两个核心收益:
- 编译期类型检查:提前发现类型不匹配、属性缺失的问题,无需等到运行时再排查 bug
- 智能代码提示:IDE 可自动推导元素 / 值的类型,给出精准的属性提示,大幅提升开发效率
2.2 灵活的类型扩展
针对复杂场景,TS 还支持联合类型约束与只读约束,兼顾灵活性与安全性:
typescript
// 联合类型:支持存储数字和字符串两种类型
const mixSet: Set<number | string> = new Set([1, "2", 3, "4"]);
// 只读约束:禁止修改集合,仅开放查询能力
const readonlyMap: ReadonlyMap<string, number> = new Map([
["a", 1],
["b", 2]
]);
readonlyMap.get("a"); // 正常查询
readonlyMap.set("c", 3); // 编译报错:ReadonlyMap 类型不支持set修改方法
三、实战落地:算法场景高频用法
Set 与 Map 是哈希表类算法题的最优解工具,绝大多数力扣哈希表标签题目,都可以用二者写出时间复杂度最优、逻辑最简洁的 TS 代码。
3.1 数组去重
O (n) 时间复杂度,远优于数组双重循环、排序去重的方案:
typescript
// 通用数组去重函数,泛型支持任意类型
function uniqueArray<T>(arr: T[]): T[] {
return Array.from(new Set(arr));
}
// 调用
const nums = [1, 2, 2, 3, 3, 3];
const uniqueNums = uniqueArray(nums); // 输出 [1,2,3]
3.2 两数之和(力扣第 1 题)
经典哈希表解法,时间复杂度从暴力解法的 O (n²) 降至 O (n):
typescript
function twoSum(nums: number[], target: number): number[] {
// 键:数组元素,值:元素对应的下标
const map: Map<number, number> = new Map();
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
if (map.has(complement)) {
return [map.get(complement)!, i];
}
map.set(nums[i], i);
}
return [];
}
3.3 有效的字母异位词(力扣第 242 题)
通过 Map 统计字符频次,逻辑清晰且效率拉满:
typescript
function isAnagram(s: string, t: string): boolean {
if (s.length !== t.length) return false;
// 键:字符,值:字符出现的次数
const countMap: Map<string, number> = new Map();
// 统计第一个字符串的字符频次
for (const char of s) {
countMap.set(char, (countMap.get(char) || 0) + 1);
}
// 用第二个字符串抵消频次
for (const char of t) {
const count = countMap.get(char);
if (!count) return false;
countMap.set(char, count - 1);
}
return true;
}
3.4 两个数组的交集(力扣第 349 题)
通过 Set 实现集合运算,无需双重循环即可快速找到公共元素:
typescript
function intersection(nums1: number[], nums2: number[]): number[] {
const set1 = new Set(nums1);
return Array.from(new Set(nums2.filter(num => set1.has(num))));
}
四、避坑指南:引用类型的核心注意点
使用 Set 时最常见的坑,就是引用类型无法正常去重:
typescript
const objSet = new Set();
objSet.add({ id: 1 });
objSet.add({ id: 1 });
console.log(objSet.size); // 输出2,而非预期的1
原因在于,对象、数组这类引用类型,变量存储的不是内容本身,而是内容在内存中的地址。两个内容完全一致的对象,内存地址不同,会被 Set 判定为两个完全独立的元素。
如果需要实现 "内容相同即判定为重复",解决方案是将引用类型转为可对比的基础类型字符串,再存入集合:
typescript
const set = new Set<string>();
set.add(JSON.stringify({ id: 1 }));
set.add(JSON.stringify({ id: 1 }));
console.log(set.size); // 输出1,成功去重
五、最佳实践:场景选择指南
Set 与 Map 并非替代数组和对象的 "银弹",不同场景有明确的最优选择:
| 数据结构 | 最佳使用场景 | 不推荐场景 |
|---|---|---|
| Set | 数组去重、元素存在性判断、集合运算、黑白名单校验 | 需要有序存储、需要通过下标随机访问元素 |
| Map | 动态键值映射、频次统计、哈希表类算法题、非字符串类型键需求 | 固定结构的静态数据(优先用 interface/type 定义) |
| 数组 | 有序列表存储、需要遍历排序、需要下标随机访问 | 频繁的存在性判断、大规模去重操作 |
六、总结
Set 与 Map 是 TypeScript 对原生数据结构的完美补全。从底层来看,它们基于哈希表实现的 O (1) 时间复杂度操作,能显著提升代码性能;从类型安全来看,TS 的泛型约束让它们可以在编译期规避类型错误,写出更健壮的代码;从实际开发来看,它们能覆盖从算法刷题到业务开发的绝大多数场景,是 TS 入门必须吃透的核心内容。
无需死记硬背所有 API,只需要记住核心原则:遇到唯一性判断用 Set,遇到动态键值映射用 Map,在实战中多练多用,就能完全掌握二者的精髓。