LeetCode 705. 设计哈希集合
题目描述
题意分析
不借助任何内建的哈希表库,自己实现一个只装非负整数的集合,支持三个操作:
add(key)插入(已存在则无效果)、remove(key)删除(不存在则无效果)、contains(key)查询是否存在。要什么:一个满足集合语义的数据结构。集合语义的核心是幂等——重复
add同一个 key 不应产生第二份,重复remove不应报错。这一点决定了内部表示必须是「每个 key 对应一个唯一的存在位」,而不是可追加的列表。约束透露的信号极其关键:
0 <= key <= 10^6,调用次数最多 $10^4$。键的值域有限且很小,这是整道题最重要的一句话。值域封闭意味着我们可以为每一个可能的键预留一个专属位置,从而把「哈希」这个动作退化成「直接用键当下标」——恒等映射本身就是一个完美哈希,零冲突。$10^6 + 1$ 个boolean在 Java 中占约 1 MB,完全在内存限制之内。反过来说,如果题目把值域放开到整个
int范围甚至任意对象,直接寻址就不可行了,那时才必须回到真正的哈希设计:取模分桶 + 冲突处理 + 负载因子扩容。识别出「值域是否封闭」,就是识别出该走哪条路。边界:
key = 0和key = 10^6都是合法输入,数组长度必须开到 $10^6 + 1$ 而不是 $10^6$;对不存在的 key 调用remove必须静默成功而不是抛异常;contains在从未add过的 key 上必须返回false,这要求数组初值全为false(Java 与 Go 的数组/切片默认零值恰好满足)。
解法:固定域布尔数组
核心思路
先看一个显然可行但很差的做法:用一个动态数组存所有已插入的键,
contains线性扫描、add先扫描去重再追加、remove扫描后删除。三个操作都是 $O(S)$($S$ 为当前元素个数),$10^4$ 次调用下最坏约 $10^8$ 次比较,而且remove还要处理元素搬移。瓶颈很清楚:每次操作都在「查找 key 在哪」上花线性时间。真正的哈希表用「哈希函数把 key 映射到桶下标」来把查找压到均摊 $O(1)$,代价是要处理两个 key 撞进同一个桶的冲突。但冲突本身是「值域比桶数大得多」被迫压缩的产物。本题的值域只有 $10^6 + 1$ 个取值,而我们完全开得起 $10^6 + 1$ 个桶——于是可以选择最朴素的哈希函数:
\[h(key) = key\]这个恒等映射是单射,不同的 key 永远落在不同的下标上,冲突率恒为 0。冲突处理、负载因子、扩容重哈希这三大块复杂度直接消失。这种技巧叫直接寻址表(direct-address table),它是哈希表在「值域可枚举」这一特例下的退化形态。
于是内部表示定为一个长度 $10^6 + 1$ 的布尔数组
data,不变量是:对任意 $0 \le key \le 10^6$,
data[key] == true当且仅当key当前在集合中。三个操作在这个不变量下都退化成一行:
add是把对应位置为true,remove是置为false,contains是直接读取。集合的幂等性也是白送的——把一个已经是true的位置再置true,值不变;对已经是false的位置再置false,同样不变。这正是「用布尔位而非计数器/列表」的好处:赋值天然幂等,而count++或list.add都不是。唯一的代价是空间:无论集合里实际只有 1 个元素还是 $10^4$ 个元素,都固定占用 $10^6 + 1$ 字节。这是一次明确的空间换时间且换简洁的交易,在值域已知且不大时非常划算。
解题步骤
- 构造函数中分配长度为
1_000_001的布尔数组。为什么是1_000_001而不是1_000_000:题面的键范围是闭区间 $[0, 10^6]$,两端都取得到,共 $10^6 + 1$ 个合法值;开成 $10^6$ 会在key = 10^6时越界。为什么可以在构造时一次性分配:调用次数有限而值域固定,预分配避免了运行期任何扩容判断,也让三个操作彻底无分支。- 数组不需要显式初始化。为什么:Java 的
new boolean[n]保证全部元素为false,Go 的make([]bool, n)同样是零值false,而false恰好就是「不在集合中」的正确初始语义。若把存在性用int的 0/1 表示也可以,但布尔更贴合语义且省内存。add(key)执行data[key] = true。为什么不先判断是否已存在:赋值是幂等的,重复插入不会破坏不变量;多一次判断只会增加分支预测失败的机会,没有任何收益。remove(key)执行data[key] = false。为什么删除不存在的 key 也安全:同样是幂等赋值,把false写成false不改变任何状态,天然满足「不存在则无效果」的要求,不需要任何特判或异常。contains(key)直接返回data[key]。为什么不需要边界检查:题面已保证key落在合法范围内,数组长度覆盖了整个范围;如果是工程代码而非竞赛代码,这里应当补上范围校验。data声明为final(Java)。为什么:数组引用在对象生命周期内不再改变,标记final既表达了「这个表不会被替换或扩容」的设计意图,也让 JIT 可以更激进地优化字段访问。以
具体用例走一遍完整的调用序列:MyHashSet(); add(1); add(2); contains(1); contains(3); add(2); contains(2); remove(2); contains(2);
MyHashSet():分配data,长度 $10^6 + 1$,全部为false。此时集合为空,不变量成立(所有位置都是false,集合里确实没有任何元素)。
add(1):data[1] = true。集合内容{1}。
add(2):data[2] = true。集合内容{1, 2}。
contains(1):读data[1],得true,返回true。正确。
contains(3):读data[3],从未被写过,仍是初始的false,返回false。正确——这里体现了「零值即默认语义」的价值,不需要任何「是否被初始化过」的额外判断。
add(2):data[2]本已是true,再赋true值不变。集合仍是{1, 2}而不是{1, 2, 2}。这一步验证了集合的幂等性——如果内部改用「计数器 +1」或「链表追加」,这里就会错误地产生第二份 2。
contains(2):返回true。正确。
remove(2):data[2] = false。集合内容{1}。
contains(2):读data[2],得false,返回false。正确。再补一个边界调用
remove(999999):该位置从未被写过、本就是false,赋false后依然是false,函数正常返回,没有异常,集合内容不变。这验证了「删除不存在的元素必须静默成功」。
代码实现
class MyHashSet {
// 这类题不是考察散列冲突方案,而是考察“在固定值域里如何用最少复杂度实现常量操作”。
private final boolean[] data;
public MyHashSet() {
data = new boolean[1_000_001];
}
public void add(int key) {
data[key] = true;
}
public void remove(int key) {
data[key] = false;
}
public boolean contains(int key) {
return data[key];
}
}
type MyHashSet struct {
// 这类题不是考察散列冲突方案,而是考察“在固定值域里如何用最少复杂度实现常量操作”。
data []bool
}
func ConstructorMyHashSet() MyHashSet {
return MyHashSet{data: make([]bool, 1_000_001)}
}
func (this *MyHashSet) Add(key int) {
this.data[key] = true
}
func (this *MyHashSet) Remove(key int) {
this.data[key] = false
}
func (this *MyHashSet) Contains(key int) bool {
return this.data[key]
}
复杂度分析
- 时间复杂度:三个操作均为最坏 $O(1)$。凭什么:恒等哈希是单射,不存在冲突,因此没有任何探测循环或链表遍历;每个操作只是一次数组下标读或写,是真正的最坏情况常数而非均摊常数——这一点强于工业级哈希表,后者在扩容重哈希那一次调用上是 $O(n)$。构造函数是 $O(V)$,$V = 10^6 + 1$ 为值域大小。
- 空间复杂度:$O(V)$,即 $10^6 + 1$ 个布尔值,Java 中约 1 MB。凭什么不是 $O(1)$ 也不是 $O(n)$:占用量既与实际元素个数 $n$ 无关(存 1 个和存 $10^4$ 个一样大),也不是常数(它随题目给定的值域上界线性增长)。这正是直接寻址表的本质代价:用与值域成正比的空间,换取零冲突的常数时间。
关键点总结
- 先看值域,再选结构。「键的取值范围有限且不大」是直接寻址表的准入条件;一旦满足,哈希函数取恒等映射即可,冲突处理、负载因子、扩容三大块复杂度全部消失。这个判断在位图去重、计数排序、桶排序、字符频次统计里反复出现,是同一条原则的不同外衣。
- 幂等的表示天然给出幂等的操作。用布尔位表达「在/不在」,
add与remove都是赋值,重复调用零副作用;若改用计数器或链表追加,就必须额外写去重逻辑。设计数据结构时,让不变量把边界情况吃掉,比事后打补丁可靠得多。- 让语言的零值承担默认语义。
false恰好等于「不在集合中」,于是构造函数一行不写也能保证初始状态正确;反之若用-1表示空、0表示某个合法状态,就必须显式填充整个数组。选表示法时优先让零值有意义。- 空间换时间是一次要说清代价的交易。这里固定花 1 MB,无论集合里有几个元素。当值域扩大到 $10^9$ 或键是任意对象时,这笔交易立刻不成立,必须换方案。
- 同样的思路可以再压缩 8 倍:用
long[] bits = new long[(1_000_001 >> 6) + 1],把每个键映射到bits[key >> 6]的第key & 63位,add是|= 1L << (key & 63),remove是&= ~(1L << (key & 63)),contains是(bits[key >> 6] >>> (key & 63) & 1) == 1。空间从 1 MB 降到 128 KB,时间仍是 $O(1)$。这就是位图(bitset),面试中主动提出往往是加分项。- 面试视角:这道题挂着「设计」标签,面试官很可能不满足于直接寻址表,会追问「如果 key 是任意 32 位整数,或者内存只有 64 KB 呢」。标准答案是回到真正的哈希设计:开一个质数大小(如 769)的桶数组,
hash(key) = key % 769,每个桶挂一条链表(或用ArrayList),add先在桶内查重再头插,remove在桶内删除,contains在桶内线性查找;元素数超过桶数 × 负载因子时扩容并重哈希。回答时要能说清「链地址法 vs 开放寻址法」的取舍、「桶数取质数是为了让低位分布更均匀」、以及「均摊 $O(1)$ 与最坏 $O(n)$ 的区别」。把直接寻址表当作「值域封闭时的特例优化」讲出来,而不是当作唯一解法,才是这题该有的答法。
易错点总结
- 错误写法:
data = new boolean[1_000_000](少开一格) → 用例add(1000000),题面允许key取到 $10^6$,此时下标等于数组长度,直接抛ArrayIndexOutOfBoundsException。闭区间 $[0, N]$ 需要 $N + 1$ 个格子。- 错误写法:在
add里写if (!data[key]) data[key] = true;→ 逻辑正确但毫无收益,多出的分支在 $10^4$ 次调用里纯属浪费;更糟的是这种写法容易被顺手改成count++之类的副作用,破坏幂等性。- 错误写法:用
int[] count记录出现次数,add时count[key]++、remove时count[key]--→ 用例add(1); add(1); remove(1); contains(1),计数从 0 加到 2 再减到 1,contains返回true;集合语义下正确答案是false,因为集合里 1 只应存在一份。- 错误写法:用
ArrayList<Integer>存元素,add直接list.add(key)不查重 → 用例add(1); add(1); remove(1); contains(1),列表里有两个 1,删掉一个还剩一个,返回true,错误;同时三个操作都退化成 $O(S)$。- 错误写法:
remove里先判断存在性,不存在就抛异常或返回错误 → 用例remove(5)在空集合上调用,题面要求静默无效果,抛异常会直接判错。删除操作在集合语义下必须容忍不存在的键。- 错误写法:把数组声明成静态字段
private static boolean[] data→ 用例是判题程序连续构造两个MyHashSet实例时,第二个实例会继承第一个实例残留的所有元素,contains返回莫名其妙的true。实例状态必须是实例字段。- 错误写法:为了省内存改用
HashSet<Integer>或Set接口的内建实现 → 逻辑上能过,但题面明确要求「不使用任何内建的哈希库」,这是直接绕开考点,面试中等同于没答。- 错误写法:改用取模分桶
int idx = key % 1000却把桶里存成单个int而非链表 → 用例add(1); add(1001); contains(1),两个键都映射到桶 1,后者覆盖前者,contains(1)返回false。一旦哈希函数不是单射,就必须配套冲突处理。- 错误写法:改用位图但把移位量写成
1 << (key & 63)(用int字面量 1) → 用例add(64),key & 63 = 0尚且正常,但add(127)时key & 63 = 63,1 << 63在int下按63 % 32 = 31处理,得到Integer.MIN_VALUE再符号扩展成long,把整个高 32 位全部置 1,污染了 32 个不相关的键。移位必须写成1L << ...。- 错误写法:Go 中把接收者写成值类型
func (this MyHashSet) Add(key int)→ 用例add(1); contains(1),虽然切片底层数组是共享的所以本题侥幸能过,但一旦结构体里增加了size之类的标量字段,对它的修改就会作用在副本上而丢失。设计类题目的所有方法应统一用指针接收者。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 706. 设计哈希映射 | 简单 | 从「存在性」升级到「键值对」,直接寻址表要存值且需要一个哨兵表示「无此键」 |
| 217. 存在重复元素 | 简单 | 集合的最直接应用,元素值域不受限,只能用真正的哈希集合或先排序 |
| 349. 两个数组的交集 | 简单 | 用集合做去重与求交,考的是把「查存在性」这个 $O(1)$ 能力用在双数组上 |
| 380. O(1) 时间插入、删除和获取随机元素 | 中等 | 集合外还要支持等概率随机取,必须「数组 + 哈希表」双向索引并用尾部交换做删除 |
| 146. LRU 缓存 | 中等 | 哈希表提供 $O(1)$ 定位,双向链表维护访问顺序,考的是两种结构的组合 |
| 208. 实现 Trie (前缀树) | 中等 | 同样是「值域有限(26 个字母)所以直接用下标」,但按字符逐层展开成树 |
| 1206. 设计跳表 | 困难 | 键值域无限且要求有序,只能靠随机化的多层链表做到期望 $O(\log n)$ |
| 622. 设计循环队列 | 中等 | 同为定长数组的设计题,难点在用取模复用空间以及区分「队满」与「队空」 |
| 155. 最小栈 | 中等 | 设计题的另一类范式:靠辅助栈把「查询极值」也压到 $O(1)$ |