手写哈希表不是最终目的,我们的目标是像真正的 STL 那样,让 unordered_set 和 unordered_map 都能复用同一套哈希表代码。
1. 站在巨人的肩膀上:SGI STL 源码框架分析
观察 SGI STL(虽然不是 C++ 标准的一部分,但设计思路极具参考价值)的源码,会发现其 hash_set 和 hash_map 的结构出奇地简洁。它们内部都只持有一个 hashtable 对象,所有操作都委托给这个对象完成。
cpp
// 简化后的 SGI STL 框架
// hash_set 内部
template <class Value, ...>
class hash_set {
private:
// 关键:hashtable 的模板参数
typedef hashtable<Value, Value, HashFcn, identity<Value>, EqualKey, Alloc> ht;
ht rep; // 底层就是一个哈希表对象
// ... 所有接口都调用 rep 对应的方法
};
// hash_map 内部
template <class Key, class T, ...>
class hash_map {
private:
// 关键:hashtable 的模板参数
typedef hashtable<pair<const Key, T>, Key, HashFcn, select1st<pair<const Key, T>>, EqualKey, Alloc> ht;
ht rep; // 底层也是一个哈希表对象
// ... 所有接口都调用 rep 对应的方法
};
观察它们的模板参数,核心区别在于两点:
-
数据类型
T不同 :hash_set里T就是Value(也就是Key本身);而hash_map里T是pair<const Key, T>。 -
提取 Key 的仿函数不同 :由于
hash_map的数据类型是键值对,哈希表在计算哈希值或做比较时,只关心pair中的first(即键)。因此,它需要额外传入一个函数对象,用来从T中提取出Key。这个函数对象在hash_set中是identity<Value>(直接返回自身),在hash_map中是select1st<pair<...>>(返回pair的第一个成员)。
这个设计非常巧妙,它让同一套哈希表代码,通过调整这两个参数,就适配了两种完全不同的容器。
2. 动手实践:搭建兼容 set 和 map 的通用哈希表框架
借鉴上述思路,我们开始改造自己的 HashTable。
首先,在 unordered_set 和 unordered_map 这一层,分别定义好从数据类型中提取 Key 的仿函数 ,并作为模板参数传给 HashTable。
cpp
// MyUnorderedSet.h
namespace yyq {
template<class K, class Hash = HashFunc<K>>
class unordered_set {
// 对于 set,数据类型就是 Key 本身,直接返回即可
struct SetKeyOfT {
const K& operator()(const K& key) { return key; }
};
public:
bool insert(const K& key) { return _ht.Insert(key); }
private:
// 注意第二个模板参数:T 就是 K
hash_bucket::HashTable<K, K, SetKeyOfT, Hash> _ht;
};
}
// MyUnorderedMap.h
namespace yyq {
template<class K, class V, class Hash = HashFunc<K>>
class unordered_map {
// 对于 map,数据类型是 pair,需要返回其 first 成员
struct MapKeyOfT {
const K& operator()(const pair<K, V>& kv) { return kv.first; }
};
public:
bool insert(const pair<K, V>& kv) { return _ht.Insert(kv); }
private:
// 注意第二个模板参数:T 是 pair<K, V>
hash_bucket::HashTable<K, pair<K, V>, MapKeyOfT, Hash> _ht;
};
}
接下来,改造底层的 HashTable。它现在的模板参数变成了 K(键类型)、T(实际存储的数据类型)、KeyOfT(提取 Key 的仿函数类型)和 Hash(哈希函数)。
cpp
// HashTable.h
namespace hash_bucket {
template<class T>
struct HashNode {
T _data; // 存储的数据,可能是 K,也可能是 pair<K, V>
HashNode<T>* _next;
HashNode(const T& data) : _data(data), _next(nullptr) {}
};
template<class K, class T, class KeyOfT, class Hash>
class HashTable {
typedef HashNode<T> Node;
// ...
bool Insert(const T& data) {
KeyOfT kot; // 用仿函数从 data 中提取出 Key
// ...
if (Find(kot(data))) return false; // 查重时,用 kot 获取 Key 比较
Hash hs;
// 用 kot 获取 Key 来计算哈希值
size_t hashi = hs(kot(data)) % _tables.size();
// ... 扩容、头插等操作
}
Node* Find(const K& key) {
KeyOfT kot;
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur) {
// 比较时,用 kot 提取出当前节点数据的 Key,再与目标 key 比较
if (kot(cur->_data) == key) return cur;
cur = cur->_next;
}
return nullptr;
}
// ...
};
}
到这里,我们就成功搭建起了一个能让 set 和 map 完美复用的哈希表框架。
3. 最关键的挑战:迭代器的实现
要让我们的容器支持范围for循环和各种算法,就必须实现迭代器。哈希表的迭代器是单向迭代器 ,只支持 ++ 操作。实现它的难点在于 operator++ 的逻辑:你需要在一个桶的链表走完后,能自动跳转到下一个非空的桶。
为此,迭代器内部需要"知道"两个东西:
-
_node:一个指向当前链表节点的指针。 -
_pht:一个指向所属哈希表对象的指针。这样一来,当它需要寻找下一个桶时,就能通过这个指针访问到整个桶数组。
cpp
// 前置声明,因为迭代器要用到 HashTable,而 HashTable 也要用它
template<class K, class T, class KeyOfT, class Hash>
class HashTable;
template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
struct HTIterator {
typedef HashNode<T> Node;
typedef HTIterator<K, T, Ptr, Ref, KeyOfT, Hash> Self;
Node* _node; // 当前节点
const HashTable<K, T, KeyOfT, Hash>* _pht; // 指向哈希表的指针
HTIterator(Node* node, const HashTable<K, T, KeyOfT, Hash>* pht)
: _node(node), _pht(pht) {}
Ref operator*() { return _node->_data; }
Ptr operator->() { return &_node->_data; }
bool operator!=(const Self& s) const { return _node != s._node; }
// 核心:++ 操作的实现
Self& operator++() {
if (_node->_next) {
// 情况1:当前桶的链表还有下一个节点,直接走
_node = _node->_next;
} else {
// 情况2:当前桶已经走完,需要寻找下一个非空的桶
KeyOfT kot;
Hash hs;
// 1. 计算当前节点数据所属的桶索引
size_t hashi = hs(kot(_node->_data)) % _pht->_tables.size();
++hashi; // 从下一个桶开始找
while (hashi < _pht->_tables.size()) {
if (_pht->_tables[hashi]) {
// 找到了,让_node指向这个桶的第一个节点
_node = _pht->_tables[hashi];
return *this;
}
++hashi;
}
// 3. 所有桶都遍历完了,置为nullptr,等于 end()
_node = nullptr;
}
return *this;
}
};
为什么可以这样设计(友元和内部类访问权限)?
可能你会注意到,迭代器 HTIterator 内部直接访问了 _pht->_tables,而 _tables 是 HashTable 的私有成员。这是如何做到的?
-
C++ 中的"内部类"是"朋友" :在 C++ 中,一个类内部定义的类(或结构体)被称为嵌套类,它天生就能访问外部类的 私有(
private) 成员。这不是单方向的,嵌套类自己本身也隐式地是外部类的一个"友元"。 -
反过来呢? 如果
HTIterator是独立于HashTable外部定义的(就像我们代码中这样,仅仅为了代码组织清晰而分开),那么HashTable类就必须显式地将HTIterator声明为friend,才能授予它访问私有成员的权利。
这种"迭代器持有容器指针,并访问其私有成员"的模式是 C++ 中非常经典的实现方式,兼顾了效率和封装性。
HashTable 内部需要做相应调整,提供 Begin() 和 End() 方法,并正确定义迭代器类型:
cpp
template<class K, class T, class KeyOfT, class Hash>
class HashTable {
// 为了让外部定义的迭代器能访问私有成员 _tables,声明为友元
template<class, class, class, class, class, class>
friend struct HTIterator;
typedef HashNode<T> Node;
public:
// 普通迭代器
typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
// const 迭代器
typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;
Iterator Begin() {
// 找到第一个非空桶的第一个节点
for (size_t i = 0; i < _tables.size(); ++i) {
if (_tables[i]) return Iterator(_tables[i], this);
}
return End();
}
// End() 返回一个指向空节点的迭代器
Iterator End() { return Iterator(nullptr, this); }
// ConstBegin() 和 ConstEnd() 类似,返回的是 ConstIterator
ConstIterator Begin() const {
// 需要返回 ConstIterator
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); }
// …(其他成员不变)
};
在 unordered_set 和 unordered_map 中,将 insert 等接口的返回值也修改为迭代器类型,这样就完美地支持了范围for循环。特别地,为了让用户不能通过迭代器修改 unordered_set 的元素和 unordered_map 的键,我们在实例化 HashTable 时会使用 const K 等带 const 限制的模板参数,从类型系统层面加以禁止。
4. 锦上添花:实现 operator[]
operator[] 是 unordered_map 的特色功能。它的实现完全建立在 Insert 基础上:
cpp
// MyUnorderedMap.h
V& operator[](const K& key) {
// insert 如果成功,返回 {指向新节点的迭代器, true}
// 如果失败(key已存在),返回 {指向已存在节点的迭代器, false}
pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
// 无论成功与否,ret.first 都指向正确的节点
return ret.first->second;
}
这简洁地实现了"如果键不存在就插入一个默认值,最终返回该键对应值的引用"的语义。
5. 完整代码总览
将上述所有部分组合起来,就构成了我们的完整实现。下面的代码通过 HashTable 的泛型设计,让 unordered_set 和 unordered_map 成为了极其轻量的"适配器"。理解透这个案例,你会对 C++ 的泛型编程和设计模式有全新的认识。
cpp
// HashTable.h
namespace hash_bucket {
// ... (HashNode, HTIterator 的实现见上)
template<class K, class T, class KeyOfT, class Hash>
class HashTable {
// 友元声明...
public:
typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;
Iterator Begin() { /* ... */ }
Iterator End() { return Iterator(nullptr, this); }
ConstIterator Begin() const { /* ... */ }
ConstIterator End() const { return ConstIterator(nullptr, this); }
pair<Iterator, bool> Insert(const T& data) {
KeyOfT kot;
Iterator it = Find(kot(data));
if (it != End()) return make_pair(it, false);
// ... (扩容和插入逻辑)
return make_pair(Iterator(newnode, this), true);
}
Iterator Find(const K& key) { /* ... */ }
bool Erase(const K& key) { /* ... */ }
private:
vector<Node*> _tables;
size_t _n = 0;
};
}
// MyUnorderedSet.h
namespace yyq {
template<class K, class Hash = HashFunc<K>>
class unordered_set {
struct SetKeyOfT { const K& operator()(const K& key) { return key; } };
public:
typedef typename hash_bucket::HashTable<K, const K, SetKeyOfT, Hash>::Iterator iterator;
// ... (begin, end, insert, find... 等直接委托给 _ht)
private:
hash_bucket::HashTable<K, const K, SetKeyOfT, Hash> _ht;
};
}
// MyUnorderedMap.h
namespace yyq {
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 hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::Iterator iterator;
V& operator[](const K& key) { /* 封装 insert */ }
// ... (begin, end, insert, find... 等直接委托给 _ht)
private:
hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
};
}
我们通过"提取 Key"的仿函数这个巧妙的设计,优雅地复用了哈希表代码,并完整实现了迭代器和 operator[]。能把这一套走下来,你对 C++ 的理解就已经超过大多数人了。