前言:前面实现map和set时,底层使用的是搜索树,查找、插入和删除的时间复杂度可以稳定在O(log N),并且遍历结果是有序的。这篇文章继续认识另一类关联结构------哈希表。它不再沿着树逐层比较,而是通过哈希函数直接计算数据应该出现的位置;在分布比较均匀时,增删查的平均效率可以达到O(1)。
这篇的代码量确实比较大我也是陆陆续续完成的,我实际上写了开放地址法和链地址法的哈希表,但我只封装了链地址法的map和set,因为桶哈希比较实用,库里面也是这么做的,但好像有人写的版本是在桶里挂个红黑树???,虽然能提升查找速度但感觉一般情况下不会这么极端,所以我这方面我也没去了解太多(不会写。。。)
1.哈希表的基本概念
1.1 从直接定址法到哈希映射
假设关键字全部集中在[0, 99]之间,那么最简单的做法就是开一个长度为100的数组,直接把关键字当作下标。小写字母也类似,可以使用字符 - 'a'得到[0, 26)之间的位置。这种方法就是直接定址法,它建立的是一种非常直接的key -> 存储位置关系。
直接定址法效率很高,但是它对关键字范围要求比较苛刻。如果只存几个数,关键字却分散在一个很大的范围中,按照最大关键字开数组会浪费大量空间。因此更一般的做法是准备一个大小为M的数组,再通过哈希函数把关键字转换到[0, M)之间:
text
hash_address = hash(key) % M

哈希函数的目标不是从理论上彻底消灭冲突,因为当关键字可能的取值数量大于数组位置数量时,冲突通常无法避免。我们真正希望的是让数据尽量均匀地分布在各个位置,降低冲突发生的频率,并准备好冲突发生以后的处理方法。
1.2 负载因子与复杂度
哈希表中已经存储的有效元素个数记为N,表长或桶数记为M,负载因子可以写成:
text
load_factor = N / M
负载因子越大,空间利用率越高,但冲突概率和平均探测长度也会增加;负载因子越小,冲突通常更少,不过空闲空间会更多,所以哈希表需要在时间和空间之间做取舍。
开放定址法的所有元素都直接放在数组槽位中,因此必须保留可供探测终止和新元素插入的空位,负载因子需要小于1。链地址法把冲突元素挂在桶下的链表中,负载因子可以超过1,但这并不表示越大越好,因为平均链长仍会随之增加。当前实现让开放定址表在负载因子大约达到0.7时扩容,哈希桶则在元素个数等于桶数时扩容。
在哈希函数分布较均匀、负载因子受到控制的情况下,哈希表插入、查找和删除的平均时间复杂度可以看成O(1);极端情况下,如果大量关键字集中到同一探测区间或同一个桶中,单次操作仍可能退化到O(N)。扩容本身需要重新处理已有元素,复杂度是O(N),所以这里所说的插入平均效率还包含了摊还分析,不能理解成任何一次操作都绝对只执行常数步。
1.3 哈希函数与非整数key
当前代码主要使用除留余数法,也就是把哈希仿函数得到的整数再对表长取模。表长如果与数据的某些规律重合,冲突可能会明显增加,因此实现中准备了一组逐渐增大的质数,每次寻找不小于目标值的下一个表长。
默认的HashFunc<K>适合能够转换成size_t的key;std::string不能直接转成整数,所以单独进行了模板特化,让每个字符和字符顺序都参与计算:
cpp
template<typename K>
struct HashFunc
{
size_t operator()(const K& key) const
{
return (size_t)key;
}
};
// 因为string比较常用也特化下
template<>
struct HashFunc<std::string>
{
size_t operator()(const std::string& s) const
{
size_t ret = 0;
for (const auto& e : s)
{
ret += e;
ret *= 131;
}
return ret;
}
};
// 这里是拷贝的远古标准库的做法
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 = std::lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
如果key是自定义类型,就需要提供能够处理该类型的哈希仿函数,同时还要支持key之间的相等比较。

这里最基本的一条约束是:两个相等的key必须得到相同的哈希值,否则插入和查找会落到不同位置。乘法散列、全域散列等方法也可以设计哈希函数,不过当前实现只围绕除留余数法展开。
2.开放定址法及其C++实现
2.1 线性探测解决冲突
开放定址法把所有元素直接保存在数组中。假设Hash0位置已经被其他元素占用,线性探测就从当前位置开始依次向后寻找,走到数组末尾后再绕回开头:
text
Hashi = (Hash0 + i) % M,i = 1、2、3......
例如表长为11时,19 % 11和30 % 11都等于8,所以19先放在8号位置,30再向后探测到9号位置。如果后面插入20,它的初始位置就是9,还要继续探测到10。

线性探测实现简单,但连续冲突的数据容易形成一片聚集区域,后续映射到附近的元素也会参与争夺。二次探测会按照平方距离跳跃,双重散列则用第二个哈希函数产生步长,它们都能从不同角度改善聚集问题;不过本篇代码实际采用的是线性探测,所以其他方法只需要知道思路即可。
2.2三种槽位状态
开放定址法不能在删除元素时直接把槽位恢复成"从未使用"。假设30因为冲突被放到9号位置,20又被放到10号位置;如果删除30后把9号位置改成普通空位,那么查找20从9开始时会误以为探测链已经结束,导致已经存在的20查找失败。
因此每个槽位需要保存三种状态:
EMPTY:从未存放过有效元素,查找走到这里可以停止。EXIST:当前保存着有效元素。DELETE:以前保存过元素但已经删除,查找必须继续向后,插入时可以重新利用。
对应结构如下:
cpp
enum State
{
EXIST,
EMPTY,
DELETE
};
cpp
template<typename K, typename V>
struct Hashdata
{
std::pair<K, V> _kv;
State _state = EMPTY;
};
这也是图中删除30以后仍保留DELETE标记的原因。它不再算有效元素,却依然维持着后面元素的探测路径。
2.3 插入、扩容、查找与删除
开放定址表初始大小取素数表中的第一个值。当代码中的整数比例判断达到大约0.7时,先申请更大的表,再把所有状态为EXIST的元素重新插入。扩容后数组长度已经改变,原位置不能直接照搬,所有有效key都要使用新的表长重新计算位置。
完整的核心接口如下:
cpp
HashTable()
:_tables(__stl_next_prime(0))
, _n(0)
{ }
bool Insert(const std::pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
// 扩容
if ((_n * 10) / _tables.size() >= 7)
{
HashTable<K, V, Hash> newHash;
newHash._tables.resize(__stl_next_prime(_tables.size() + 1));
// 拷贝原数据
for (const auto& e : _tables)
{
if (e._state == EXIST)
{
newHash.Insert(e._kv);
}
}
_tables.swap(newHash._tables);
}
Hash hash;
size_t Hash0 = hash(kv.first) % _tables.size();
size_t Hashi = Hash0;
size_t i = 1;
while (_tables[Hashi]._state == EXIST)
{
// 线性探测
Hashi = (Hash0 + i) % _tables.size();
i++;
}
_tables[Hashi]._kv = kv;
_tables[Hashi]._state = EXIST;
++_n;
return true;
}
Hashdata<K, V>* Find(const K& key)
{
Hash hash;
size_t Hash0 = hash(key) % _tables.size();
size_t Hashi = Hash0;
size_t i = 1;
// 处理全删除时产生的边界死循环问题
// 添加一个计数器统计
size_t count = 0;
while (_tables[Hashi]._state != EMPTY && count < _tables.size())
{
if (key == _tables[Hashi]._kv.first && _tables[Hashi]._state == EXIST)
{
return &_tables[Hashi];
}
Hashi = (Hash0 + i) % _tables.size();
i++;
++count;
}
return nullptr;
}
bool Erase(const K& key)
{
Hashdata<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
return false;
}
private:
std::vector<Hashdata<K, V>> _tables;
size_t _n;
Insert先调用Find排除重复key,找到的槽位只要不是EXIST就可以写入,因此删除标记能够被复用。Find遇到DELETE不会停止,只有遇到EMPTY才说明这条探测链后面不可能存在目标;同时代码用count限制最多检查整张表,避免所有槽位都曾经使用过、表中没有EMPTY时循环一圈又一圈。
Erase也没有真的移动后面的元素,只把状态改成DELETE并让有效元素个数减一。这样删除成本较低,不过删除标记积累以后也可能增加探测长度,而扩容重建会顺便只迁移仍然有效的数据。
3.链地址法与哈希桶实现
3.1 哈希桶的结构
开放定址法发生冲突以后会继续占用其他数组位置,元素之间容易互相影响。链地址法换了一种思路:数组中不再直接保存数据,而是保存链表头指针;多个key映射到同一个下标时,就把对应结点连接到这个桶下面,因此它也叫拉链法或哈希桶。

每个结点只需要保存实际数据和下一个结点的指针。

为了让底层哈希表既能保存set中的单个key,也能保存map中的键值对,这里的结点直接使用泛型 T:
cpp
template<typename T>
struct HashNode
{
T _data;
HashNode<T>* _next;
HashNode(const T& data)
:_data(data)
, _next(nullptr)
{ }
};
查找时先用key计算桶号,然后只遍历当前桶下面的链表。正常分布下每条链都比较短,平均效率就能接近O(1);极端情况下所有数据都进入同一个桶,整张表会退化成一条链,查找也会退化到O(N)。
3.2 插入、扩容、查找与删除
哈希桶中的负载因子可以大于1,但平均链长也会随负载因子增加。当前实现在_n == _tables.size()时扩容,也就是准备插入新元素而已有元素个数等于桶数时,选择素数表中的下一个桶数。
扩容时不需要重新创建所有数据结点。代码遍历旧桶,把每个结点从旧链上摘下,按照新桶数重新计算位置,再头插到newtables对应链表中。真正变化的是_next和桶头指针,结点对象本身可以继续使用。

实际桶数仍由代码中的素数表决定。
插入实现如下:
cpp
std::pair<Iterator, bool> Insert(const T& data)
{
KeyOfT kot;
Iterator it = Find(kot(data));
if (it != End())
{
return { it, false };
}
// 负载因子等于1就扩容
if (_n == _tables.size())
{
Hash hash;
std::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;
size_t hashi = hash(kot(cur->_data)) % newtables.size();
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
newtables.swap(_tables);
}
// 完成头插入
Hash hash;
Node* newNode = new Node(data);
size_t Hash0 = hash(kot(data)) % _tables.size();
newNode->_next = _tables[Hash0];
_tables[Hash0] = newNode;
++_n;
return { Iterator(newNode, this) , true };
}
Insert返回pair<Iterator, bool>。如果key已经存在,迭代器指向原元素,布尔值为false;插入成功时,迭代器指向新结点,布尔值为true。这个返回值后面还会被unordered_map::operator[]复用。
查找与删除只处理目标桶。删除时要区分待删结点是不是链表头:如果是头结点,直接修改桶头;否则让前一个结点跳过它。
cpp
Iterator Find(const K& key)
{
Hash hash;
KeyOfT kot;
size_t Hashi = hash(key) % _tables.size();
Node* cur = _tables[Hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
return Iterator(cur, this);
}
else
{
cur = cur->_next;
}
}
return Iterator(nullptr, this);
}
ConstIterator Find(const K& key) const
{
Hash hash;
KeyOfT kot;
size_t hashi = hash(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
return ConstIterator(cur, this);
cur = cur->_next;
}
return End();
}
bool Erase(const K& key)
{
KeyOfT kot;
Hash hash;
size_t hashi = hash(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
// 删除头节点
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
3.3 深拷贝与资源释放
哈希桶内部管理的是动态申请的链表结点,所以默认浅拷贝会让两个对象指向同一批结点。拷贝构造需要逐桶复制链表;赋值运算符使用传值加交换,让形参副本负责释放原来的资源;析构则逐桶删除所有结点。
cpp
HashTable()
:_tables(__stl_next_prime(0))
,_n(0)
{ }
HashTable(const HashTable& tmp)
: _tables(tmp._tables.size())
, _n(tmp._n)
{
for (size_t i = 0; i < tmp._tables.size(); i++)
{
Node* cur = tmp._tables[i];
Node* prev = nullptr;
while (cur)
{
Node* newNode = new Node(cur->_data);
if (prev == nullptr)
{
_tables[i] = newNode;
}
else
{
// 后续节点接到前一个节点后面
prev->_next = newNode;
}
prev = newNode;
cur = cur->_next;
}
}
// 好像也可以复用 Insert ,但写都写了直接用吧...
}
HashTable& operator=(HashTable tmp)
{
_tables.swap(tmp._tables);
_n = tmp._n;
return *this;
}
~HashTable()
{
for (size_t i = 0; i < _tables.size(); i++)
{
Node* next = nullptr;
Node* cur = _tables[i];
while (cur)
{
next = cur->_next;
delete cur;
cur = nullptr;
cur = next;
}
_tables[i] = nullptr;
}
}
4.通用哈希表与迭代器
4.1 KeyOfT解决两种数据模型
如果底层哈希表直接把数据类型写死成pair<K, V>,它就只能服务map;如果写死成K,又无法保存map中的value。这里把模板参数拆成了几个不同职责:
K表示真正参与哈希和比较的key类型。T表示结点里完整保存的数据类型。KeyOfT负责从一个T对象中取出key。Hash负责把key转换成size_t整数。

对于unordered_set,T就是const K,SetKofT直接返回元素自身;对于unordered_map,T是pair<const K, V>,MapKofT返回first。这样Insert、Find、Erase和扩容代码只需要写一份,任何需要key的地方都统一调用kot(data)。
4.2 迭代器如何跨桶移动
在当前这种"桶数组+单链表"的实现中,哈希桶的迭代器不能只保存一个结点指针。当前结点后面还有链表结点时,++确实只需要走_next;但是当前桶已经结束时,迭代器还要知道整张哈希表的位置分布,才能寻找后面的第一个非空桶。因此迭代器同时保存_node和_ht两个指针。
实现如下:
cpp
// 由于迭代器类与哈希表类互相依赖所以加个前置声明
template<typename K, typename T, typename KeyOfT, typename Hash = HashFunc<K>>
class HashTable;
template<typename K, typename T, typename Ref, typename Ptr, typename KeyOfT, typename Hash>
class HashIterator
{
typedef HashNode<T> Node;
typedef HashTable<K, T, KeyOfT, Hash> Ht;
typedef HashIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;
public:
Node* _node;
const Ht* _ht;
HashIterator(Node* node, const Ht* ht)
:_node(node)
, _ht(ht)
{ }
bool operator==(const Self& s) const
{
return _node == s._node;
}
bool operator!=(const Self& s) const
{
return _node != s._node;
}
Ref operator*() const
{
return _node->_data;
}
Ptr operator->() const
{
return &_node->_data;
}
Self& operator++()
{
if (_node->_next)
{
_node = _node->_next;
}
else
{
const Ht* Hashtables = _ht;
KeyOfT kot;
Hash hash;
// 这里访问了 _tables 而它是私有的所以迭代器类要
// 作为哈希表类的友元 :D
size_t Hashi = hash(kot(_node->_data)) % Hashtables->_tables.size();
Hashi++;
while (Hashi < Hashtables->_tables.size())
{
_node = Hashtables->_tables[Hashi];
if (_node)
{
return *this;
}
++Hashi;
}
}
_node = nullptr;
return *this;
}
};
当_node->_next存在时,迭代器在当前链表中前进;否则先根据当前结点的key重新得到桶号,再从下一个桶开始向后寻找。后面再也没有非空桶时,把_node设成nullptr,它就与End()相等。
4.3 普通迭代器与常量迭代器
HashIterator把引用和指针类型分别抽成Ref、Ptr,于是同一个类模板就能实例化出普通迭代器和常量迭代器。哈希表还要把迭代器声明为友元,因为跨桶时需要访问私有的_tables。
cpp
template<typename K, typename T, typename KeyOfT, typename Hash>
class HashTable
{
// 友元声明
template<typename K1, typename T1, typename Ref, typename Ptr, typename KeyOfT1, typename Hash1>
friend class HashIterator;
typedef HashNode<T> Node;
public:
typedef HashIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;
typedef HashIterator<K, T, const T&, const T*, KeyOfT, Hash> ConstIterator;
Iterator Begin()
{
if (_n == 0)
return { nullptr, this };
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])
return { _tables[i], this };
}
return { nullptr, this };
}
Iterator End()
{
return { nullptr, this };
}
ConstIterator Begin() const
{
if (_n == 0)
return { nullptr, this };
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])
return { _tables[i], this };
}
return { nullptr, this };
}
ConstIterator End() const
{
return { nullptr, this };
}
Begin()从0号桶开始寻找第一个非空桶,End()则统一用空结点表示。对于set,即使调用的是普通Iterator,它的T本身也是const K,所以key仍然不能修改;对于map,T是pair<const K, V>,因此first不能修改,second可以修改。
5.封装unordered_set与unordered_map
5.1 unordered_set的封装
unordered_set只需要准备SetKofT,再把底层哈希表的接口向外转发。这里故意让哈希表保存const K,从类型上阻止迭代器修改key,因为key一旦被改掉,它所在的桶就可能与新的哈希值不一致。
cpp
namespace jy
{
template<typename K, typename Hash = HashFunc<K>>
class unordered_set
{
// 这里是为了和map对齐
struct SetKofT
{
const K& operator()(const K& key) const
{
return key;
}
};
typedef typename Hash_bucket::HashTable<K, const K, SetKofT, Hash>::Iterator iterator;
typedef typename Hash_bucket::HashTable<K, const K, SetKofT, Hash>::ConstIterator const_iterator;
public:
iterator begin()
{
return _ht.Begin();
}
iterator end()
{
return _ht.End();
}
const_iterator begin() const
{
return _ht.Begin();
}
const_iterator end() const
{
return _ht.End();
}
std::pair<iterator, bool> insert(const K& key)
{
return _ht.Insert(key);
}
iterator find(const K& key)
{
return _ht.Find(key);
}
const_iterator find(const K& key) const
{
return _ht.Find(key);
}
bool erase (const K& key)
{
return _ht.Erase(key);
}
private:
// 实际中我们一般不期望 key 被修改,所以直接写成 const K
Hash_bucket::HashTable<K, const K, SetKofT, Hash> _ht;
};
}
外层容器不需要知道桶数组如何扩容,也不需要自己实现查找和删除。
5.2 unordered_map的封装
map结点保存的是pair<const K, V>。const K保证key不能通过迭代器修改,V仍然是普通类型,所以value可以修改。MapKofT接收整个键值对并返回first,底层哈希表就能继续只围绕key计算桶号和判断相等。
cpp
namespace jy
{
template<typename K, typename V, typename Hash = HashFunc<K>>
class unordered_map
{
struct MapKofT
{
const K& operator()(const std::pair<const K, V>& kv) const
{
return kv.first;
}
};
typedef typename Hash_bucket::HashTable<K, std::pair<const K, V>, MapKofT, Hash>::Iterator iterator;
typedef typename Hash_bucket::HashTable<K, std::pair<const K, V>, MapKofT, Hash>::ConstIterator const_iterator;
public:
iterator begin()
{
return _ht.Begin();
}
iterator end()
{
return _ht.End();
}
const_iterator begin() const
{
return _ht.Begin();
}
const_iterator end() const
{
return _ht.End();
}
std::pair<iterator, bool> insert(const std::pair<K, V>& kv)
{
return _ht.Insert(kv);
}
iterator find(const K& key)
{
return _ht.Find(key);
}
const_iterator find(const K& key) const
{
return _ht.Find(key);
}
bool erase(const K& key)
{
return _ht.Erase(key);
}
// map 这里需要支持一下[]
V& operator[](const K& key)
{
std::pair<iterator, bool> ret = insert({ key, V() });
return ret.first->second;
}
private:
Hash_bucket::HashTable<K, std::pair<const K, V>, MapKofT, Hash> _ht;
};
}
6.总结
当前unordered_set和`unordered_map只是围绕核心原理完成的精简封装,我主要还是以学习的目的为主,所以只实现了几个接口,实现上实际参考了STL库的想法,写的肯定是玩具性质的,写的过程也磕磕绊绊的但好在感觉有点收获,也写到了大佬的想法,换是我肯定就肯定为 map 和 set 分别写哈希表了,也体现出泛型编程的强大
完