一、实现思路
-
目标 :实现
unordered_map和unordered_set,底层使用链地址法(哈希桶)解决冲突 -
核心架构 :
HashTable+HTIterator(迭代器) +HashFunc<K>(哈希仿函数) +KeyOfT(键提取)
二、核心类设计
1. HashNode<T> ------ 哈希桶节点
-
单链表节点,存储数据
_data和指向下一个节点的指针_next -
构造函数初始化数据,
_next置空
cpp
template <class T>
struct HashNode
{
T _data;
HashNode<T>* _next;
HashNode(const T& data)
:_data(data)
, _next(nullptr)
{ }
};
2. HashFunc<K> ------ 哈希仿函数
-
通用版本:将
K转为size_t -
特化版本
HashFunc<string>:BKDR 哈希算法,乘以 33 和 累加字符 ASCII 值
cpp
template <class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return size_t(key);
}
};
template<>//BKDR 哈希
struct HashFunc<string>//仿函数特化string作为键值
{
size_t operator()(const string& key) const//要对 const敏感点 不改变值的 尽量都加上const
{
size_t _num = 0;
for (auto e : key)
{
_num *= 33;
_num += e;
}
return _num;
}
};
//每个哈希桶的结构---先写整形 再加其他类型的 这里以string为例 其他的思路大致是一样的 主要是找出唯一性
3. HTIterator<K, T, Ref, Ptr, KeyOfT, Hash> ------ 迭代器
-
成员 :
_node当前节点、_ht指向哈希表的指针、_bucket当前桶索引 -
operator++:cppSelf& operator++()//前置++ { if (_node && _node->_next) { _node = _node->_next; return *this; } if (_node == nullptr) { return *this; // 或者 assert(false) } size_t nextBucket = _bucket + 1;// 从当前桶的下一个开始查找 while (nextBucket < _ht->_tables.size()) { if (_ht->_tables[nextBucket]) { _node = _ht->_tables[nextBucket]; _bucket = nextBucket; return *this; } ++nextBucket;//没有节点的桶跳过继续++ } // 后面没有非空桶,直接返回 end() _node = nullptr; _bucket = _ht->_tables.size(); //把桶索引置为最大 size 表示结束end return *this; } Self operator++(int) { Self temp = *this; ++(*this); return temp; }-
如果
_node->_next存在,直接前进 -
否则从
_bucket + 1开始找下一个非空桶,更新_node和_bucket -
若找不到,则
_node = nullptr,_bucket = _ht->_tables.size()即end()
-
-
operator--:cppSelf& operator--()//前置-- { // 如果是 end() (_node == nullptr)退到最后一个元素 if (_node == nullptr) { for (size_t i = _ht->_tables.size(); i > 0; --i) { size_t bucket = i - 1; if (_ht->_tables[bucket])//找到一个非空桶 { // 跳到该桶的最后一个节点 Node* cur = _ht->_tables[bucket]; while (cur->_next) cur = cur->_next; _node = cur; _bucket = bucket; return *this; } } _node = nullptr; _bucket = 0; return *this; } // 不是桶的头节点 if (_node != _ht->_tables[_bucket]) { // 从桶头开始遍历,找到前一个节点 Node* cur = _ht->_tables[_bucket]; while (cur->_next != _node) cur = cur->_next; _node = cur;// _bucket 不变 return *this; } //当前节点是桶头 需要向前找上一个非空桶 for (size_t i = _bucket; i > 0; --i) { size_t bucket = i - 1; if (_ht->_tables[bucket]) { // 跳到该桶的最后一个节点 Node* cur = _ht->_tables[bucket]; while (cur->_next) cur = cur->_next; _node = cur; _bucket = bucket; return *this; } } //没有上一个桶 说明当前是第一个元素 则退回 end() _node = nullptr; _bucket = 0; return *this; } Self operator--(int) { Self temp = *this; --(*this); return temp; }-
如果是
end()(_node == nullptr),从最后一个桶向前找最后一个非空桶的尾节点 -
如果当前节点不是桶头,在桶内遍历找前驱
-
如果是桶头,向前找上一个非空桶的尾节点
-
若没有更前的桶,退回
end()
-
-
解引用 :
operator*返回_node->_data的引用;operator->返回_node->_data的指针
cpp
Ref operator*()
{
return _node->_data;
}
Ptr operator->()
{
return &(_node->_data);
}
4. HashTable<K, T, KeyOfT, Hash>
-
模板参数:
-
K:键类型 -
T:节点存储的数据类型(map为pair<const K,V>,set为const K) -
KeyOfT:从T中提取K的仿函数 -
Hash:哈希函数,默认为HashFunc<K>
-
成员 :vector<Node*> _tables,size_t _n
-
核心接口:
-
Insert(const T& data) -
Find(const K& key):找不到返回end() -
Erase(const K& key): -
Begin()/End():
-
-
扩容机制 :当
_n == _tables.size()时,使用素数表(__stl_next_prime)扩容,重新哈希所有节点
具体哈希表结构见文章末尾留的仓库
5. unordered_map<K, V> 与 unordered_set<K> ------ 容器封装


-
通过
KeyOfT提取器区分map和set的数据类型 -
map存储pair<const K, V>,set存储const K -
map::operator[]实现:调用insert({key, V()}),返回迭代器指向的second
面试常考题目及答案
Q1:哈希表解决冲突的常见方法有哪些?你的实现用了哪种?
答 :常用方法有链地址法 和开放地址法(线性探测、二次探测、双重哈希),这里 实现使用链地址法,每个桶是一个单链表,冲突元素挂在同一桶的链表上
Q2:为什么哈希表的扩容因子通常为 1?
答 :_n == _tables.size() 时扩容,即负载因子为 1。这是在空间与时间之间的权衡:
-
负载因子小(如 0.5):冲突少,查询快,但浪费内存
-
负载因子大(如 2):节省内存,但冲突增多,性能下降
STL 的
unordered_map默认负载因子为 1,这里实现保持一致
Q3:迭代器 operator++ 和 operator-- 的时间复杂度是多少?
答:均摊 O(1) 虽然跳桶时需要线性扫描,但每个桶在遍历过程中只被检查一次,整体遍历所有元素的时间复杂度为 O(N)
Q4:unordered_map 的 operator[] 如果键不存在会发生什么?
答 :插入一个默认值 V() 并返回其引用 这是通过 insert({key, V()}) 实现的,如果键不存在则插入,存在则返回已有值的引用
Q5:为什么 unordered_map 的键是 const K?
答:防止用户通过迭代器修改键,破坏哈希表的内部结构(修改键会导致哈希值变化,使元素无法被正确查找)
Q6:拷贝构造为什么要深拷贝?浅拷贝会有什么后果?
答 :如果使用编译器默认的浅拷贝,两个对象会共享同一块内存(vector<Node*> 中的指针指向相同的 HashNode 对象) 析构时会导致双重释放,程序崩溃
Q7:扩容时,为什么需要重新计算哈希值?
答:扩容后桶的数量变化,哈希值取模的结果会改变 如果不重新哈希,所有元素都会跑到错误的桶中,导致查找失败
Q8:你的哈希函数支持哪些类型?如何扩展支持自定义类型?
答 :默认支持整数类型和 string(通过特化) 自定义类型只需特化 HashFunc<MyType>,提供 size_t operator()(const MyType& key) 接口就可以
我的gitee仓库