深入理解哈希表:从核心概念到 unordered_map/unordered_set 模拟实现
前言
哈希表(Hash Table)是计算机科学中最重要的数据结构之一,它通过哈希函数将键(Key)映射到数组中的某个位置,从而实现平均 O (1) 时间复杂度的插入、删除和查找操作。在 C++ 标准模板库(STL)中,unordered_map和unordered_set就是基于哈希表实现的关联容器,它们相比基于红黑树的map和set,在大多数场景下具有更高的访问效率。
本文将从哈希表的核心概念入手,深入分析其特性,并详细讲解如何模拟实现 STL 中的unordered_map和unordered_set,重点探讨哈希冲突解决、仿函数处理键值、迭代器设计等关键技术点。
一、哈希表的核心概念与特性
1.1 基本概念
-
哈希函数(Hash Function):将任意长度的输入(键)转换为固定长度的整数输出(哈希值)的函数。理想的哈希函数应满足:
- 计算速度快
- 分布均匀(不同键映射到不同位置的概率尽可能高)
- 确定性(相同输入总是产生相同输出)
-
桶(Bucket):哈希表底层数组的每个元素称为一个桶,用于存储哈希值映射到该位置的元素。
-
哈希冲突(Hash Collision):当两个不同的键通过哈希函数计算得到相同的哈希值时,就会发生哈希冲突。这是哈希表不可避免的问题,需要特定的方法来解决。
-
负载因子(Load Factor):哈希表中已存储元素个数与桶总数的比值。负载因子越大,哈希冲突的概率越高,性能越低。STL 中默认的最大负载因子为 1.0。
-
扩容(Rehashing):当负载因子超过阈值时,哈希表会创建一个更大的新数组,并将所有元素重新哈希到新数组中,这个过程称为扩容或重哈希。
1.2 哈希表的特性
-
时间复杂度:
- 平均情况:插入、删除、查找均为 O (1)
- 最坏情况:所有元素都发生哈希冲突,退化为 O (n)
-
空间复杂度:O (n),通常会预留一定的空闲空间以降低负载因子
-
无序性:元素的存储顺序与插入顺序无关,遍历顺序也不固定
-
键的唯一性 :
unordered_map和unordered_set中的键都是唯一的,不允许重复 -
不支持排序:由于元素无序,无法直接进行排序操作
1.3 常见的哈希冲突解决方法
1.3.1 开放定址法
当发生哈希冲突时,按照某种规则在哈希表中寻找下一个空桶。常见的探测方式有:
- 线性探测:依次检查下一个桶(i+1, i+2, ...)
- 二次探测:按照 i² 的步长进行探测(i+1², i-1², i+2², i-2², ...)
- 双重哈希:使用第二个哈希函数计算步长
优点 :不需要额外的存储空间,缓存友好性好缺点:容易产生 "聚集" 现象,删除操作复杂
1.3.2 链地址法(拉链法)
每个桶中维护一个链表(或其他数据结构),当发生哈希冲突时,将元素添加到对应桶的链表中。STL 中的unordered_map和unordered_set采用的就是这种方法。
优点:
- 实现简单,删除操作方便
- 不会产生聚集现象
- 负载因子可以大于 1
缺点:
- 需要额外的指针空间
- 链表过长时性能会下降(C++11 后,当链表长度超过 8 时会转换为红黑树)
二、unordered_map 和 unordered_set 的底层结构
STL 中的unordered_map和unordered_set共享相同的底层哈希表实现,它们的主要区别在于:
unordered_map存储的是键值对(pair<const Key, T>)unordered_set存储的是单一的键值
它们的底层结构大致如下:
cpp
运行
template <class Key, class T, class Hash = hash<Key>,
class KeyEqual = equal_to<Key>, class Alloc = allocator<pair<const Key, T>>>
class unordered_map {
private:
// 哈希表的节点结构
struct Node {
pair<const Key, T> value;
Node* next;
// 构造函数
Node(const pair<const Key, T>& val) : value(val), next(nullptr) {}
};
// 桶数组
vector<Node*, Alloc> buckets;
// 元素个数
size_t size_;
// 最大负载因子
float max_load_factor_;
// 哈希函数
Hash hash_;
// 键相等比较函数
KeyEqual equal_;
// 分配器
Alloc alloc_;
// 其他私有成员函数...
};
三、模拟实现 unordered_map 和 unordered_set 的要点分析
3.1 哈希函数与仿函数设计
3.1.1 内置类型的哈希函数
C++ 标准库为内置类型提供了默认的哈希函数模板std::hash,我们可以直接使用:
cpp
运行
template <class T> struct hash;
// 特化版本示例
template<> struct hash<int> {
size_t operator()(int val) const noexcept {
return static_cast<size_t>(val);
}
};
template<> struct hash<string> {
size_t operator()(const string& s) const noexcept {
// 字符串哈希算法,如BKDRHash
size_t hash = 0;
for (char c : s) {
hash = hash * 131 + c;
}
return hash;
}
};
3.1.2 自定义类型的哈希函数
对于自定义类型,我们需要提供自己的哈希函数。有两种方式:
- 特化
std::hash模板 - 自定义仿函数作为模板参数
cpp
运行
// 自定义类型
struct Person {
string name;
int age;
bool operator==(const Person& other) const {
return name == other.name && age == other.age;
}
};
// 方式1:特化std::hash
namespace std {
template<> struct hash<Person> {
size_t operator()(const Person& p) const noexcept {
// 组合多个字段的哈希值
size_t h1 = hash<string>()(p.name);
size_t h2 = hash<int>()(p.age);
return h1 ^ (h2 << 1);
}
};
}
// 方式2:自定义仿函数
struct PersonHash {
size_t operator()(const Person& p) const noexcept {
size_t h1 = hash<string>()(p.name);
size_t h2 = hash<int>()(p.age);
return h1 ^ (h2 << 1);
}
};
// 使用自定义仿函数
unordered_map<Person, string, PersonHash> my_map;
3.1.3 键相等比较仿函数
除了哈希函数,我们还需要一个比较函数来判断两个键是否相等。STL 默认使用std::equal_to,它会调用operator==。如果自定义类型没有重载operator==,我们需要提供自己的比较仿函数:
cpp
运行
struct PersonEqual {
bool operator()(const Person& a, const Person& b) const {
return a.name == b.name && a.age == b.age;
}
};
// 使用自定义比较函数
unordered_map<Person, string, PersonHash, PersonEqual> my_map;
3.2 哈希冲突的处理:链地址法实现
我们采用链地址法来解决哈希冲突,每个桶中维护一个单向链表。以下是核心操作的实现要点:
3.2.1 插入操作
- 计算键的哈希值
- 根据哈希值找到对应的桶索引(哈希值 % 桶的数量)
- 遍历该桶的链表,检查是否已存在相同的键
- 如果存在,返回 false(不允许重复键)
- 如果不存在,创建新节点并插入到链表头部(或尾部)
- 元素个数加 1
- 如果负载因子超过阈值,进行扩容
cpp
运行
pair<iterator, bool> insert(const value_type& val) {
// 检查是否需要扩容
if (size_ >= buckets.size() * max_load_factor_) {
rehash(buckets.size() * 2);
}
// 计算哈希值和桶索引
size_t hash_val = hash_(val.first);
size_t bucket_idx = hash_val % buckets.size();
// 遍历链表查找是否已存在相同的键
Node* cur = buckets[bucket_idx];
while (cur != nullptr) {
if (equal_(cur->value.first, val.first)) {
// 键已存在,返回指向该元素的迭代器和false
return {iterator(cur, this), false};
}
cur = cur->next;
}
// 创建新节点并插入到链表头部
Node* new_node = new Node(val);
new_node->next = buckets[bucket_idx];
buckets[bucket_idx] = new_node;
size_++;
// 返回指向新元素的迭代器和true
return {iterator(new_node, this), true};
}
3.2.2 查找操作
- 计算键的哈希值
- 根据哈希值找到对应的桶索引
- 遍历该桶的链表,查找是否存在相同的键
- 如果找到,返回指向该元素的迭代器
- 如果未找到,返回 end () 迭代器
cpp
运行
iterator find(const Key& key) {
size_t hash_val = hash_(key);
size_t bucket_idx = hash_val % buckets.size();
Node* cur = buckets[bucket_idx];
while (cur != nullptr) {
if (equal_(cur->value.first, key)) {
return iterator(cur, this);
}
cur = cur->next;
}
return end();
}
3.2.3 删除操作
- 计算键的哈希值
- 根据哈希值找到对应的桶索引
- 遍历该桶的链表,查找要删除的节点
- 如果找到,调整指针将该节点从链表中移除
- 释放节点内存
- 元素个数减 1
- 返回被删除元素的个数(0 或 1)
cpp
运行
size_t erase(const Key& key) {
size_t hash_val = hash_(key);
size_t bucket_idx = hash_val % buckets.size();
Node* cur = buckets[bucket_idx];
Node* prev = nullptr;
while (cur != nullptr) {
if (equal_(cur->value.first, key)) {
// 找到要删除的节点
if (prev == nullptr) {
// 删除头节点
buckets[bucket_idx] = cur->next;
} else {
// 删除中间或尾节点
prev->next = cur->next;
}
delete cur;
size_--;
return 1;
}
prev = cur;
cur = cur->next;
}
// 未找到要删除的元素
return 0;
}
3.3 扩容与重哈希(Rehashing)
当负载因子超过阈值时,我们需要对哈希表进行扩容。扩容的步骤如下:
- 创建一个新的桶数组,大小通常为原来的 2 倍(或下一个素数)
- 遍历原哈希表的所有桶
- 对于每个桶中的每个节点,重新计算其在新桶数组中的索引
- 将节点插入到新桶数组的对应位置
- 释放原桶数组的内存
- 更新哈希表的桶数组指针
cpp
运行
void rehash(size_t new_bucket_count) {
if (new_bucket_count <= buckets.size()) {
return;
}
// 创建新的桶数组
vector<Node*> new_buckets(new_bucket_count, nullptr);
// 遍历原哈希表的所有桶
for (size_t i = 0; i < buckets.size(); i++) {
Node* cur = buckets[i];
while (cur != nullptr) {
// 保存下一个节点的指针
Node* next = cur->next;
// 重新计算哈希值和桶索引
size_t hash_val = hash_(cur->value.first);
size_t new_bucket_idx = hash_val % new_bucket_count;
// 将节点插入到新桶的头部
cur->next = new_buckets[new_bucket_idx];
new_buckets[new_bucket_idx] = cur;
cur = next;
}
}
// 替换桶数组
buckets.swap(new_buckets);
}
注意:为了获得更好的哈希分布,桶的数量通常选择素数。STL 中预定义了一个素数表,每次扩容时选择下一个素数作为新的桶数量。
3.4 迭代器设计
迭代器是容器的重要组成部分,它提供了一种统一的方式来遍历容器中的元素。对于哈希表,迭代器需要能够遍历所有桶中的所有节点。
3.4.1 迭代器的结构
cpp
运行
template <class Key, class T, class Hash, class KeyEqual, class Alloc>
class unordered_map<Key, T, Hash, KeyEqual, Alloc>::iterator {
public:
// 迭代器类型定义
using value_type = pair<const Key, T>;
using reference = value_type&;
using pointer = value_type*;
using difference_type = ptrdiff_t;
using iterator_category = forward_iterator_tag;
// 构造函数
iterator(Node* node, unordered_map* map) : node_(node), map_(map) {}
// 解引用操作
reference operator*() const {
return node_->value;
}
pointer operator->() const {
return &(node_->value);
}
// 前置++
iterator& operator++() {
if (node_ == nullptr) {
return *this;
}
// 移动到当前链表的下一个节点
node_ = node_->next;
// 如果当前链表遍历完毕,找到下一个非空桶
if (node_ == nullptr) {
size_t current_bucket = map_->hash_(node_->value.first) % map_->buckets.size();
for (size_t i = current_bucket + 1; i < map_->buckets.size(); i++) {
if (map_->buckets[i] != nullptr) {
node_ = map_->buckets[i];
break;
}
}
}
return *this;
}
// 后置++
iterator operator++(int) {
iterator temp = *this;
++(*this);
return temp;
}
// 相等比较
bool operator==(const iterator& other) const {
return node_ == other.node_;
}
bool operator!=(const iterator& other) const {
return node_ != other.node_;
}
private:
Node* node_;
unordered_map* map_;
// 让unordered_map可以访问迭代器的私有成员
friend class unordered_map;
};
3.4.2 begin () 和 end () 函数
cpp
运行
iterator begin() {
// 找到第一个非空桶
for (size_t i = 0; i < buckets.size(); i++) {
if (buckets[i] != nullptr) {
return iterator(buckets[i], this);
}
}
// 所有桶都为空,返回end()
return end();
}
iterator end() {
return iterator(nullptr, this);
}
3.5 unordered_set 的实现
unordered_set的实现与unordered_map非常相似,主要区别在于:
unordered_set存储的是单一的键值,而不是键值对- 它的迭代器返回的是键的引用,而不是 pair 的引用
我们可以通过代码复用的方式来实现unordered_set,例如让unordered_set内部包含一个unordered_map,并将值类型设置为char或bool:
cpp
运行
template <class Key, class Hash = hash<Key>,
class KeyEqual = equal_to<Key>, class Alloc = allocator<Key>>
class unordered_set {
private:
// 内部使用unordered_map来存储数据
unordered_map<Key, char, Hash, KeyEqual, Alloc> map_;
public:
// 类型定义
using key_type = Key;
using value_type = Key;
using size_type = size_t;
using difference_type = ptrdiff_t;
using hasher = Hash;
using key_equal = KeyEqual;
using allocator_type = Alloc;
using reference = value_type&;
using const_reference = const value_type&;
using pointer = typename allocator_type::pointer;
using const_pointer = typename allocator_type::const_pointer;
// 迭代器
class iterator {
public:
// 迭代器实现,包装unordered_map的迭代器
using map_iterator = typename unordered_map<Key, char, Hash, KeyEqual, Alloc>::iterator;
iterator(map_iterator it) : it_(it) {}
reference operator*() const {
return it_->first;
}
pointer operator->() const {
return &(it_->first);
}
iterator& operator++() {
++it_;
return *this;
}
iterator operator++(int) {
iterator temp = *this;
++it_;
return temp;
}
bool operator==(const iterator& other) const {
return it_ == other.it_;
}
bool operator!=(const iterator& other) const {
return it_ != other.it_;
}
private:
map_iterator it_;
friend class unordered_set;
};
// 成员函数
pair<iterator, bool> insert(const value_type& val) {
auto res = map_.insert({val, 0});
return {iterator(res.first), res.second};
}
size_type erase(const key_type& key) {
return map_.erase(key);
}
iterator find(const key_type& key) {
return iterator(map_.find(key));
}
size_type size() const {
return map_.size();
}
bool empty() const {
return map_.empty();
}
iterator begin() {
return iterator(map_.begin());
}
iterator end() {
return iterator(map_.end());
}
// 其他成员函数...
};
四、性能优化与注意事项
4.1 哈希函数的选择
- 对于整数类型,可以直接使用其值作为哈希值
- 对于字符串类型,推荐使用 BKDRHash、APHash 等经典算法
- 对于自定义类型,要确保组合多个字段的哈希值,避免冲突
4.2 负载因子的调整
- 默认的最大负载因子是 1.0,可以通过
max_load_factor()函数调整 - 降低负载因子可以减少哈希冲突,提高访问速度,但会增加内存消耗
- 提高负载因子可以节省内存,但会增加哈希冲突的概率
4.3 预分配空间
- 如果知道要存储的元素数量,可以使用
reserve()函数预分配足够的空间 - 这样可以避免频繁的扩容操作,提高性能
4.4 自定义类型的注意事项
- 必须提供哈希函数和相等比较函数
- 相等比较函数必须与哈希函数保持一致:如果两个键相等,它们的哈希值必须相同
- 键类型必须是可复制和可移动的
五、总结
哈希表是一种高效的数据结构,它通过哈希函数实现了平均 O (1) 时间复杂度的操作。STL 中的unordered_map和unordered_set基于链地址法实现,具有良好的性能和灵活性。
模拟实现这两个容器需要掌握以下核心要点:
- 哈希函数和相等比较仿函数的设计
- 链地址法解决哈希冲突的实现
- 扩容与重哈希机制
- 迭代器的设计与实现
- 代码复用技巧(unordered_set 复用 unordered_map)
在实际使用中,我们需要根据具体场景选择合适的哈希函数和负载因子,以获得最佳的性能。同时,对于自定义类型,要确保正确实现哈希函数和相等比较函数,避免出现难以调试的问题。