Hashtable
哈希表(hash table),又称散列表,它通过建立键key与值value之间的映射,实现高效的元素查询。具体而言,我们向哈希表中输入一个键 key,则可以在 $O(1)$ 时间内获取对应的值value 。
同时数组和链表也可以实现查询功能,元素查询效率对比如图表所示:
| 操作 |
数组 |
链表 |
哈希表 |
| 查找元素 |
O(n) |
O(n) |
O(1) |
| 添加元素 |
O(1) |
O(1) |
O(1) |
| 删除元素 |
O(n) |
O(n) |
O(1) |
可以发现,在哈希表中进行增删查改的时间复杂度都是 $O(1)$ ,非常高效。
Hashtable in STL
详情请看这篇博客Problem A的注释
哈希表的实现
我们先考虑最简单的情况,仅用一个数组来实现哈希表。在哈希表中,我们将数组中的每个空位称为桶(bucket),每个桶可存储一个键值对。因此,查询操作就是找到key对应的桶,并在桶中获取value。
这是通过哈希函数(hash function)实现的,它能将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有key,输出空间是所有桶(数组索引)。换句话说,输入一个key,我们可以通过哈希函数得到该key对应的键值对在数组中的存储位置。
输入一个key,哈希函数的计算过程分为以下两步。
- 通过某种哈希算法hash()计算得到哈希值。
- 将哈希值对桶数量(数组长度)capacity 取模,从而获取该key对应的数组索引index。
1
| index = hash(key) % capacity
|
随后,我们就可以利用index在哈希表中访问对应的桶,从而获取value。
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
| struct Pair { public: int key; string val; Pair(int key, string val) { this->key = key; this->val = val; } };
class ArrayHashMap { private: vector<Pair *> buckets;
public: ArrayHashMap() { buckets = vector<Pair *>(100); }
~ArrayHashMap() { for (const auto &bucket : buckets) { delete bucket; } buckets.clear(); }
int hashFunc(int key) { int index = key % 100; return index; }
string get(int key) { int index = hashFunc(key); Pair *pair = buckets[index]; if (pair == nullptr) return ""; return pair->val; }
void put(int key, string val) { Pair *pair = new Pair(key, val); int index = hashFunc(key); buckets[index] = pair; }
void remove(int key) { int index = hashFunc(key); delete buckets[index]; buckets[index] = nullptr; }
vector<Pair *> pairSet() { vector<Pair *> pairSet; for (Pair *pair : buckets) { if (pair != nullptr) { pairSet.push_back(pair); } } return pairSet; }
vector<int> keySet() { vector<int> keySet; for (Pair *pair : buckets) { if (pair != nullptr) { keySet.push_back(pair->key); } } return keySet; }
vector<string> valueSet() { vector<string> valueSet; for (Pair *pair : buckets) { if (pair != nullptr) { valueSet.push_back(pair->val); } } return valueSet; }
void print() { for (Pair *kv : pairSet()) { cout << kv->key << " -> " << kv->val << endl; } } };
|
哈希冲突
从本质上看,哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,理论上一定存在“多个输入对应相同输出”的情况,我们将这种多个输入对应同一输出的情况称为哈希冲突(hash collision)。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为了解决该问题,每当遇到哈希冲突时,我们就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低。为了提升效率,我们可以采用以下策略:
- 改良哈希表数据结构,使得哈希表可以在出现哈希冲突时正常工作。
- 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
链式地址
在原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。

基于链式地址实现的哈希表,需要通过哈希函数访问链表头节点,遍历链表到目标节点在进行增删改查的操作。
Limitations
- 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
- 查询效率降低:因为需要线性遍历链表来查找对应元素。
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
| class HashMapChaining { private: int size; int capacity; double loadThres; int extendRatio; vector<vector<Pair *>> buckets;
public: HashMapChaining() : size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2) { buckets.resize(capacity); }
~HashMapChaining() { for (auto &bucket : buckets) { for (Pair *pair : bucket) { delete pair; } } }
int hashFunc(int key) { return key % capacity; }
double loadFactor() { return (double)size / (double)capacity; }
string get(int key) { int index = hashFunc(key); for (Pair *pair : buckets[index]) { if (pair->key == key) { return pair->val; } } return ""; }
void put(int key, string val) { if (loadFactor() > loadThres) { extend(); } int index = hashFunc(key); for (Pair *pair : buckets[index]) { if (pair->key == key) { pair->val = val; return; } } buckets[index].push_back(new Pair(key, val)); size++; }
void remove(int key) { int index = hashFunc(key); auto &bucket = buckets[index]; for (int i = 0; i < bucket.size(); i++) { if (bucket[i]->key == key) { Pair *tmp = bucket[i]; bucket.erase(bucket.begin() + i); delete tmp; size--; return; } } }
void extend() { vector<vector<Pair *>> bucketsTmp = buckets; capacity *= extendRatio; buckets.clear(); buckets.resize(capacity); size = 0; for (auto &bucket : bucketsTmp) { for (Pair *pair : bucket) { put(pair->key, pair->val); delete pair; } } }
void print() { for (auto &bucket : buckets) { cout << "["; for (Pair *pair : bucket) { cout << pair->key << " -> " << pair->val << ", "; } cout << "]\n"; } } };
|
开放寻址
开放寻址(open addressing)不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。
- 线性探测
通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为1,固定步长索引),直至找到空桶(none)/目标元素。
然而,线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
- 平方探测
平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即1、4、9…步。
Advantages
- 平方探测通过跳过探测次数平方的距离,试图缓解线性探测的聚集效应。
- 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。
Limitations
- 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
- 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。
- 多次哈希
顾名思义,多次哈希方法使用多个哈希函数 $f(x),g(x),k(x)$ 进行探测。
⚠️注意:开放寻址哈希表都存在“不能直接删除元素”的问题。
这是因为删除元素会在数组内产生一个空桶None,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。
为了解决该问题,我们可以采用懒删除机制:它不直接从哈希表中移除元素,而是利用一个常量TOMBSTONE来标记这个桶。在该机制下,None和TOMBSTONE都代表空桶,都可以放置键值对。但不同的是,线性探测到TOMBSTONE时应该继续遍历,因为其之下可能还存在键值对。
然而,懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着TOMBSTONE的增加,搜索时间也会增加,因为线性探测可能需要跳过多个TOMBSTONE才能找到目标元素。
为此,考虑在线性探测中记录遇到的首个TOMBSTONE的索引,并将搜索到的目标元素与该TOMBSTONE交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
哈希扩容
哈希表容量越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以通过扩容哈希表来减少哈希冲突。
- 类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时
- 哈希表容量 capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步增加了扩容过程的计算开销
为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
负载因子(load factor)是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,也常作为哈希表扩容的触发条件。例如,当负载因子超过 0.75 时,我们可以考虑扩容哈希表。
哈希算法
前面的方法只能处理哈希冲突,并不能从本质上减少哈希冲突。如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。对于链式地址哈希表,理想情况下键值对均匀分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都存储到同一个桶中,时间复杂度退化至$O(n)$。

我们制定出的哈希算法应达到以下目标:
- 确定性:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
- 效率高:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
- 均匀分布:哈希算法应使得键值对均匀分布在哈希表中。分布越均匀,哈希冲突的概率就越低。
▶
为什么不使用哈希函数$f(x)=x$呢?这样就不会有冲突了!
在 $f(x)=x$ 哈希函数下,每个元素对应唯一的桶索引,这与数组等价。然而给定的数据空间是未知的,如果数据范围较为稀疏如学号12592、16754,那么数组的绝大部分空间都将被闲置,导致空间复杂度急剧上升。并且我们的目标是在有限的数组范围内存储较大数量的键值,也就是将一个较大的状态空间映射到一个较小的空间。