一、为什么要用哈希表封装两个容器?
在 C++ 标准库中,unordered_set 和**unordered_map** 底层都是哈希表。它们的主要区别在于:
-
unordered_set存储键值本身,且键不可重复、不可修改; -
unordered_map存储 键值对(key-value),键不可修改,值可以修改。
既然底层数据结构相同,我们完全可以复用同一份哈希表代码 ,通过模板参数的不同实例化来得到两种容器。这正是 STL 的设计精髓------泛型编程。
参考 SGI STL 3.0 版本(C++11 之前)的**hash_set / hash_map** 源码,可以看到它们底层都是**hashtable** ,只不过传递给**hashtable** 的类型参数不同:
cpp
// stl_hash_set 简化
template <class Value, class HashFcn = hash<Value>, ...>
class hash_set {
private:
typedef hashtable<Value, Value, HashFcn, identity<Value>, ...> ht;
ht rep;
public:
typedef typename ht::const_iterator iterator; // 注意:set 的迭代器是 const_iterator
};
// stl_hash_map 简化
template <class Key, class T, class HashFcn = hash<Key>, ...>
class hash_map {
private:
typedef hashtable<pair<const Key, T>, Key, HashFcn, select1st<pair<const Key, T>>, ...> ht;
ht rep;
public:
typedef typename ht::iterator iterator;
};
可以看到,hash_set 传给 hashtable 的模板参数中,Value 出现了两次(表示键值相同),而 hash_map 传入的是 pair<const Key, T>。同时,它们各自提供了一个提取键(ExtractKey) 的仿函数:identity 直接返回自身,select1st 返回 pair 的第一个元素。
我们今天要模拟的正是这种设计。
二、整体框架与 KeyOfT 仿函数
我们的目标:
-
实现一个通用的
HashTable类模板,它存储的数据类型为T,但能够通过一个KeyOfT仿函数从中提取出键K。 -
实现
unordered_set,其元素类型为K,内部使用HashTable<K, const K, SetKeyOfT, Hash>。 -
实现
unordered_map,其元素类型为pair<const K, V>,内部使用HashTable<K, pair<const K, V>, MapKeyOfT, Hash>。
这样,哈希表内部所有的操作(插入、查找、删除、迭代器)都基于键 K 进行,而不依赖 T 的完整类型。
2.1 哈希表节点与哈希函数
我们先定义哈希节点:
cpp
template<class T>
struct HashNode
{
T _data;
HashNode<T>* _next;
HashNode(const T& data)
:_data(data)
, _next(nullptr)
{
}
};
哈希函数我们提供默认版本,并针对 string 进行特化(BKDR 算法):
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//除了写仿函数还可以写特化
template<>
struct HashFunc<string>
{
// BKDR
size_t operator()(const string& str)
{
size_t hash = 0;
for (auto ch : str)
{
hash += ch;
hash *= 131;//可以很好的避免ASCII码值相同
}
return hash;
}
};
2.2 素数表 ------ 减少哈希冲突
为了让桶的数量尽量是素数,我们采用 STL 的素数表,并实现**__stl_next_prime**函数:
cpp
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
2.3 HashTable 类框架
哈希表内部由**vector<Node*>** 作为桶数组,_n 记录实际元素个数。构造函数中初始桶数为第一个素数。
cpp
template<class K, class T, class KeyOfT, class Hash>
class HashTable {
typedef HashNode<T> Node;
// ...
public:
HashTable() {
_tables.resize(__stl_next_prime(1), nullptr);
}
~HashTable() {
// 逐一释放每个桶的节点
for (size_t i = 0; i < _tables.size(); ++i) {
Node* cur = _tables[i];
while (cur) {
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
// ...
private:
vector<Node*> _tables;
size_t _n = 0;
};
三、Insert 与 Find ------ 依赖 KeyOfT
哈希表的 Insert 需要:
-
通过
KeyOfT提取出data的键; -
检查键是否已存在(调用
Find); -
若不存在,则计算哈希值,处理负载因子(等于1时扩容),头插新节点。
先看 Find:
cpp
Iterator Find(const K& key)
{
KeyOfT kot;
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
return { cur,this};
}
cur = cur->_next;
}
return End();
}
这里 KeyOfT 是一个仿函数,对于 unordered_set,它接受 const K& 返回自身;对于 unordered_map,它接受 const pair<K,V>& 返回 first。
接下来是 Insert:
cpp
pair<Iterator,bool> Insert(const T& data)
{
KeyOfT kot;
auto it = Find(kot(data));
if (it != End())
return { it,false };
Hash hs;
//扩容(负载因子==1)
if (_n == _tables.size())
{
vector<Node*> newtables(__stl_next_prime(_tables.size() + 1));
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
//当前的桶节点重新映射挂到新表
while (cur)
{
Node* next = cur->_next;
//插入到新表
Hash hs;
size_t hashi = hs(kot(data)) % newtables.size();
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;//以前的桶的数据要变空
}
_tables.swap(newtables);
}
size_t hashi = hs(kot(data)) % _tables.size();
//头插
Node* newNode = new Node(data);
newNode->_next = _tables[hashi];
_tables[hashi] = newNode;
++_n;
return { Iterator(newNode,this),true };
}
注意 :这里**Insert** 返回**pair<Iterator, bool>** ,后面实现**operator[]**会用到。
四、迭代器的实现 ------ 重点难点
哈希表的迭代器是前向迭代器 (单向),必须支持 ++、*、->、!=、== 等操作。
4.1 迭代器需要保存什么?
标准库的哈希表迭代器通常包含两个成员:
-
当前节点的指针
_node; -
所属哈希表的指针
_ptr(或引用),用于在某个桶走完后找到下一个非空桶。
我们定义 HTIterator 如下:
cpp
// 前置声明
template<class K, class T, class KeyOfT, class Hash>
class HashTable;
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
struct HTIterator
{
typedef HashNode<T> Node;
typedef HashTable<K, T, KeyOfT, Hash> HT;
typedef HTIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;
Node* _node;
const HT* _ht;
HTIterator(Node* node, const HT* ht)
:_node(node)
, _ht(ht)
{
}
Ref operator*()
{
return _node->_data;
}
Ptr operator->()
{
return &_node->_data;
}
Self& operator++()
{
if (_node->_next) // 当前还有节点
{
_node = _node->_next;
}
else // 当前桶为空,找下一个不为空的桶的第一个
{
size_t hashi = Hash()(KeyOfT()(_node->_data)) % _ht->_tables.size();
++hashi;
while (hashi != _ht->_tables.size())
{
if (_ht->_tables[hashi])
{
_node = _ht->_tables[hashi];
break;
}
hashi++;
}
// 最后一个桶的最后一个节点已经遍历结束,走到end()去,nullptr充当end()
if (hashi == _ht->_tables.size())
{
_node = nullptr;
}
}
return *this;
}
bool operator!=(const Self& s) const
{
return _node != s._node;
}
bool operator==(const Self& s) const
{
return _node == s._node;
}
};
Ref 和 Ptr 的本质用途是**"包裹迭代器底层指针的访问权限"**:
-
Ref控制 解引用后的读写属性 (是T&可读写,还是const T&只读); -
Ptr控制operator->返回的指针属性 (是T*还是const T*)。
4.2 operator++ 的逻辑
cpp
Self& operator++()
{
if (_node->_next) // 当前还有节点
{
_node = _node->_next;
}
else // 当前桶为空,找下一个不为空的桶的第一个
{
size_t hashi = Hash()(KeyOfT()(_node->_data)) % _ht->_tables.size();
++hashi;
while (hashi != _ht->_tables.size())
{
if (_ht->_tables[hashi])
{
_node = _ht->_tables[hashi];
break;
}
hashi++;
}
// 最后一个桶的最后一个节点已经遍历结束,走到end()去,nullptr充当end()
if (hashi == _ht->_tables.size())
{
_node = nullptr;
}
}
return *this;
}
关键点:当当前节点无后继时,我们通过 _ht 访问桶数组,从当前桶的下一个位置开始查找第一个非空桶,若找到则指向该桶的第一个节点,否则置 nullptr 作为 end。
4.3 Begin 和 End
Begin() 需要找到第一个非空桶,返回指向该桶首节点的迭代器;End() 返回 nullptr 构造的迭代器。
cpp
Iterator Begin()
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])
{
return Iterator(_tables[i], this);
}
}
return End();
}
Iterator End()
{
return Iterator(nullptr, this);
}
ConstIterator Begin() const
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])
{
return ConstIterator(_tables[i], this);
}
}
return End();
}
ConstIterator End() const
{
return ConstIterator(nullptr, this);
}
4.4 友元声明
因为 HTIterator 需要访问 HashTable 的私有成员 _tables,我们需要在 HashTable 中将 HTIterator 声明为友元:
cpp
template<class K,class T,class KeyOfT,class Hash>
class HashTable
{
// 友元声明
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
friend struct HTIterator;
typedef HashNode<T> Node;
//...
}
五、封装 unordered_set
unordered_set 存储键值 K,并且键不可修改 。因此我们传递给 HashTable 的 T 类型为 const K,同时提供 SetKeyOfT 仿函数。
cpp
namespace HWX
{
template<class K,class Hash = HashFunc<K>>
class unordered_set
{
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
public:
typedef typename HashTable<K, const K, SetKeyOfT, Hash>::Iterator iterator;
typedef typename HashTable<K, const K, SetKeyOfT, Hash>::ConstIterator const_iterator;
iterator begin()
{
return _t.Begin();
}
iterator end()
{
return _t.End();
}
const_iterator begin() const
{
return _t.Begin();
}
const_iterator end() const
{
return _t.End();
}
pair<iterator, bool> insert(const K& k)
{
return _t.Insert(k);
}
bool erase(const K& key)
{
return _t.Erase(key);
}
iterator find(const K& key)
{
return _t.Find(key);
}
private:
HashTable<K, const K, SetKeyOfT, Hash> _t;
};
}
注意:unordered_set 的普通迭代器实际上是哈希表的普通迭代器,但由于存储的是 const K,所以无法通过迭代器修改键值,达到了"不可修改"的效果。
六、封装 unordered_map
unordered_map 存储 pair<const K, V>,键不可改,值可改。同样提供 MapKeyOfT 提取键。
cpp
namespace HWX
{
template<class K, class V, class Hash = HashFunc<K>>
class unordered_map
{
struct MapKeyOfT
{
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
public:
typedef typename HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::Iterator iterator;
typedef typename HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::ConstIterator const_iterator;
iterator begin()
{
return _t.Begin();
}
iterator end()
{
return _t.End();
}
const_iterator begin() const
{
return _t.Begin();
}
const_iterator end() const
{
return _t.End();
}
pair<iterator, bool> insert(const pair<K, V>& kv)
{
return _t.Insert(kv);
}
V& operator[](const K& key)
{
pair<iterator, bool> ret = insert({ key, V() });
return ret.first->second;
}
bool erase(const K& key)
{
return _t.Erase(key);
}
iterator find(const K& key)
{
return _t.Find(key);
}
private:
HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _t;
};
}
operator[] 的实现非常简洁:调用 insert 插入键值对(如果不存在则插入默认值),然后返回迭代器指向的 second 引用。这正是标准库的做法。
七、完整实现代码以及测试代码与使用示例
7.1 测试 unordered_set
cpp
void test_uset1()
{
HWX::unordered_set<int> s1;
s1.insert(45);
s1.insert(5);
s1.insert(13);
s1.insert(45);
s1.insert(452);
s1.insert(4513);
s1.insert(333);
s1.insert(123);
Func(s1);
}
对于自定义类型,我们需要提供哈希仿函数和相等比较:
cpp
struct Date
{
int _year;
int _month;
int _day;
bool operator==(const Date& d) const
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
};
struct DateHashFunc
{
// BKDR
size_t operator()(const Date& d)
{
//2025 1 9
//2025 9 1
//2025 2 8
size_t hash = 0;
hash += d._year;
hash *= 131;
hash += d._month;
hash *= 131;
hash += d._day;
hash *= 131;
return hash;
}
};
void test_uset2()
{
HWX::unordered_set<Date, DateHashFunc> s1;
s1.insert({ 2025, 9, 15 });
s1.insert({ 2025, 9, 18 });
auto it = s1.begin();
while (it != s1.end())
{
cout << it->_year <<" "<< it->_month <<" "<< it->_day << endl;
++it;
}
cout << endl;
}
7.2 测试 unordered_map
cpp
void test_umap()
{
HWX::unordered_map<string, string> dict;
dict.insert({ "insert", "插入" });
dict.insert({ "sort", "排序" });
dict.insert({ "test", "测试" });
for (auto& [k, v] : dict)
{
// k += 'x';
cout << k << ":" << v << endl;
}
dict["string"] = "字符串";
dict["key"];
dict["key"] = "关键字";
dict["for"];
for (auto& [k, v] : dict)
{
cout << k << ":" << v << endl;
}
}
同时我们验证 const 迭代器和不可修改键的特性:
cpp
void Func(const HWX::unordered_set<int>& s)
{
auto it1 = s.begin();
while (it1 != s.end())
{
// *it1 = 1;
cout << *it1 << " ";
++it1;
}
cout << endl;
}
八、几个关键设计细节总结
-
KeyOfT 仿函数 :将哈希表与具体容器解耦,哈希表只关心如何从
T中提取K,不同的容器提供不同的提取方式。 -
const K 的使用 :在
unordered_set中存储const K,在unordered_map中存储pair<const K, V>,从类型层面禁止修改键。 -
迭代器中的哈希表指针 :为了完成
operator++,必须持有哈希表指针才能访问桶数组,这是迭代器实现的常见技巧。 -
素数扩容:采用素数表可以有效减少哈希冲突,提高性能。
-
负载因子的控制:当元素个数等于桶数时扩容,使得平均每个桶的节点数不超过1,维持常数时间查找。
-
友元声明:迭代器需要访问哈希表私有成员,必须声明为友元。