目录
前言
本文将深入探讨一种高效且实用的数据结构——哈希表。虽然红黑树以其平衡的特性确保了查找次数与树的高度成正比,但当追求极致的检索效率,希望无需经过层层比较便能直接定位目标元素时,哈希表便成为了我们的不二之选。
1. 概念
哈希表(Hash table),也称散列表,是一种用于存储键值对的数据结构。它通过哈希函数使存储位置和它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。因此,哈希表可以提供快速的数据插入和查找操作,时间复杂度接近O(1)。
哈希表的操作如下
- 插入元素:根据待插入元素的关键码key,以哈希函数计算出来的存储位置并按此位置进行存放。
- 搜索元素:对该元素的关键码进行同样的计算,把所得的函数值当做元素存储的位置,在此位置取元素进行比较。若关键码相等,则搜索成功。
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。此方法叫做除留余数法。以序列{11,6,23,5,34,17}为例
用这种方法进行搜索,不必进行多次关键码比对,搜索效率就高。
2. 哈希冲突
对于两个数据元素关键字k1和k2,且k1!=k2,但是有Hash(k1) = Hash(k2)。即不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或者哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
这种情况会导致数据存储和检索的效率降低。我们该如何应对呢?
3. 哈希函数
哈希函数可以使存储位置和关键码建立一一映射关系。它具有以下原则
- 哈希函数的定义域必须包括需要存储的全部关键码,如果散列表允许有n个地址时,其值域必须在0到n-1之间。
- 哈希函数计算出来的地址能均匀分布在整个空间中。
- 哈希函数应该比较简单。
1. 直接寻址法
直接寻址法是一种简单的哈希函数,它将键直接映射到哈希表的位置上,适用于关键字集合比较小且可以事先知道的情况。
哈希函数实现hash(key) = a * key + b
- 简单性:直接取址法的实现非常简单,易于理解。
- 无冲突:如果哈希表的大小至少与可能的关键字的数目一样大,那么理论上不会发生冲突。
- 局限性:直接取址法不适用于关键字范围很大或者关键字数量不确定的情况,因为这会导致哈希表过大,不切实际。
2. 除留余数法
除留余数法是将键值与哈希表的大小进行模运算。
哈希函数hash(key) = key % capacity
- 简单、易于理解。
- 当哈希表大小为质数时,能够较好地分布键值。
- 适用于整数键,需要先将键值转换为整数。
3. 平方取中法
平方取中法是一种简单的哈希函数生成技术,适用于整数键值。其步骤如下:
- 平方:将键值(key)平方。
- 取中:从得到的平方数中取出中间的几位数字。
例如,如果哈希表的大小是1007,而键值是1234,那么:
- 平方:1234 * 1234 = 1522756
- 取中:从1522756中取出中间三位数字,得到275。
然后,可以将275用作哈希表的索引。
平方取中法比较适合不知道关键字的分布,而位数又不是很大的情况。
4. 折叠法
折叠法通常用于处理较大的键值或字符串。其基本思想是将键值分割成几个部分,然后将这些部分相加,最后取哈希表大小的模。
以下是折叠法的一般步骤:
- 分割:将键值分割成几个等长的部分。如果键值的长度不是分割长度的整数倍,可以在最后的部分中加入填充值。
- 相加:将分割后的各部分相加。
- 取模:将相加的结果取哈希表大小的模。
例如,如果有一个键值123456789,哈希表的大小是1000,我们想要每4位分割一次,那么:
- 分割:12, 34, 56, 78, 9(注意最后一个部分长度不足,可以填充,如变为09)。
- 相加:1234 + 3456 + 5678 + 789 = 17167。
- 取模:17167 % 1000 = 171。
然后,可以将171用作哈希表的索引。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。
哈希函数设计得越好,产生哈希冲突的概率就会降低,但是哈希冲突是不可避免的。
解决哈希冲突的方法通常有两种,闭散列和开散列。
4. 闭散列
闭散列(Closed Hashing),也称为开地址散列(Open Addressing),是一种解决散列表(哈希表)中冲突的方法。在闭散列中,当哈希表的某个槽位已经被占用,即发生了哈希冲突时。
如果哈希表未被装满,说明在哈希表中必然还有空位置,把key存放到冲突位置中的下一个空位置,寻找下一个空位置是关键。当发生哈希冲突时,寻找下一个空位置的探测方法有线性探测,二次探测和双重散列。
4.1 线性探测
如下图,该散列插入了六个整数,其哈希函数使用的是除留余数法。如果还要插入整数33,先通过哈希函数计算出哈希地址是3。但是已经被整数23占据,发生了哈希冲突
线性探测法:从发生冲突的位置开始,向后移动一个哈希地址,直到寻找到空的位置。
4.2 数据结构设计
- 我们定义一个哈希表类,类名为HashTable。此类的成员变量有vector容器,vector容器存储的是哈希表的元素,还有一个记录元素个数的整型变量。
- 枚举类型State表示哈希表元素的状态,有存在、空的和删除三种状态。删除整型元素时,想要赋值成一个特定的值,但是不管赋什么值,这个值都有可能用到。此时就可以使用枚举类型解决这种问题。HashData类是哈希表存储数据的类型,pair可以糅合两个类型的模版类,其中还有枚举类型State表示每个元素的状态。
- 其中HashFunc是转换关键值key的类,通过重载operator()将关键值key强制转换成无符号整型,实现像函数一样的调用。
- HashTable的默认构造函数可以直接给vector容器开辟十个能存储数据的空间。
namespace open_address
{
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
//...
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; //表中存储数据个数
};
}
4.3 插入函数
插入操作:
- 使用哈希函数将关键值转换成哈希地址。
- 再通过哈希地址,查看对应位置是否为空。如果为空,插入新元素。修改相应元素个数。
插入操作之前,我们还需要扩容。而何时扩容由负载因子决定,那么什么是负载因子呢?
负载因子(Load Factor)是一个衡量哈希表填充程度的重要指标,它定义为哈希表中已存储的键值对数量(记为 n)与哈希表大小的比值(记为 k)。
用数学公式表示为:负载因子 =
- n是哈希表中元素的数量。
- k是哈希表的大小,即散列表的长度。
- 负载因子直接反映了哈希表中被占用的空间比例。负载因子越大,表示哈希表越满,剩余空间越少。随着负载因子的增加,哈希表中发生冲突的概率也会增加。
- 对于开放定制法,负载因子需要严格控制在0.7到0.8之间。如果超过0.8,负载因子过高,哈希表的性能会下降。许多哈希表实现会在负载因子超过某个阈值时进行扩容操作,即增加哈希表的大小,这样可以保持操作的性能。例如,Java 的 HashMap 默认当负载因子达到 0.75 时进行扩容。
根据上述内容,我们设定负载因子超过0.7,就进行扩容操作。
哈希函数为hash(key) = key % capacity。扩容之后,capacity值变大。那么原先产生哈希冲突的元素,经过哈希函数计算得到新的哈希地址。这些哈希地址大概率不会相同,就不会产生哈希冲突。所以,需要对每个元素重新计算哈希地址,插入到新哈希表中。
我们定义一个新的哈希表对象,新哈希表容量扩大为旧哈希表的两倍。遍历旧哈希表,如果元素的状态是存在,复用插入函数插入元素到新哈希表中。再交换两个哈希表的vector容器即可。
还需要注意一开始可以使用Find函数查找哈希表中是否有需要插入的元素。
namespace open_address
{
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
bool Insert(const pair<K, V>& kv)
{
//去冗余
if (Find(kv.first))
return false;
if (_n * 10 / _tables.size() >= 7)
{
//遍历旧表,将所有数据映射到新表
HashTable<K, V, Hash> newHT;
newHT._tables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; //表中存储数据个数
};
}
4.4 查找函数
首先使用关键码计算哈希地址,如果该地址状态为空,直接返回空指针。如果不为空,先查看计算出的哈希地址状态是什么,只要是不存在,就往后面查找。遇到空状态的位置就停下,遇到删除状态的位置继续查找。
哈希地址增加后,还要取模哈希表的大小,防止超出范围。
HashData<K, V>* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state != DELETE &&
_tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
}
return nullptr;
}
4.5 删除函数
删除函数可以借用find函数,如果ret为空,说明没有该元素;如果不为空,就把这个元素的状态改为删除。记录元素个数的变量减一。
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
--_n;
return true;
}
}
4.6 测试
我们写一个测试函数,测试一下上述函数的功能。
void TestHT1()
{
HashTable<int, int> ht;
//测试插入函数
int arr[] = { 11,21,4,17,24,15, 9 };
for (auto e : arr)
{
ht.Insert({ e,e });
}
//插入后会扩容
ht.Insert({ 19,19 });
//测试删除函数
ht.Erase(24);
cout << ht.Find(24) << endl;
}
进行调试,当范围for中的插入操作完成,观察监视窗口插入结果正确。
此时负载因子为0.7,如果在插入一个元素就会进行扩容。如下图,关键值为11和关键值为21的元素本来产生了哈希冲突,扩容后就不冲突了。
删除之后,只需要将关键码24的状态修改为delete即可。
我们尝试插入字符串键值对,看看效果如何。
void TestHT2()
{
HashTable<string, string> ht;
ht.Insert({ "sort", "排序" });
ht.Insert({ "string", "字符串" });
}
结果如下,字符串不能通过默认的HashFunc强制转换成无符号整数。
关于字符串转换成整数的函数有许多种,下面的是转换函数可以借鉴。
struct StringHashFunc
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto e : s)
{
hash *= 31;
hash += e;
}
return hash;
}
};
void TestStringHash()
{
cout << StringHashFunc()("abcd") << endl;
cout << StringHashFunc()("bacd") << endl;
cout << StringHashFunc()("aadd") << endl;
}
拥有相同字符,但是不同顺序的字符串,通过StringHashFun可以转换成不同的整数。
我们也可以考虑写成类模版的特化。
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto e : s)
{
hash *= 31;
hash += e;
}
return hash;
}
};
通过调试中的监视窗口,可以发现插入字符串成功。
5. 开散列
开散列(Open Hashing)是一种处理散列冲突的方法,也称为拉链法(Separate Chaining)。在开散列中,每个散列桶(bucket)不直接存储数据元素,而是存储指向一个链表的指针。当不同的关键字通过散列函数映射到同一个桶时,这些关键字将被插入到对应桶所指向的链表中。
如下图,哈希表是一个存放指针的数组,每个位置都有自己的哈希地址,哈希地址相同的元素连成一个链表,数组中存储指向头结点的指针。
5.1 数据结构设计
我们定义一个结点类,类名为HashNode。此类里面存储键值对元素,还有指向下一个结点的指针。哈希表类中的成员变量有一个指针数组,还有一个记录元素个数的整型变量。
无参构造函数直接开辟十个可以使用的槽位。
析构函数需要实现。因为每个槽位不止有一个结点,所以需要手动释放所有结点。使用for循环遍历,注意删除结点后要提前保存下一个结点的指针。
namespace HashBucket
{
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(const pair<K, V>& kv = pair<K, V>())
:_kv(kv)
, _next(nullptr)
{}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_tables.resize(10, 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; // 表中存储的数据个数
};
5.2 插入函数
首先使用Find函数查找这个元素是否存在,存在就不用重复插入。通过哈希函数计算此元素对应的哈希地址。负载因子等于1就需要扩容。如果不扩容,使用头插结点的方式插入新元素。
若要进行扩容,有两种方式:
- 第一种方式创建一个新哈希表,复用插入函数,再交换vector容器。但是这个方法比较消耗时间,因为复用插入函数,需要再次创建新结点,但是哈希表中每个槽位的链表都可以重复利用。
- 第二种方式是for循环遍历,对每个结点计算新的哈希地址,然后再手动头插到新哈希表中。
bool Insert(const pair<K, V>& kv)
{
if(Find(kv.first))
return false;
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
//负载因子==1,就扩容
if (_n == _tables.size())
{
//HashTable<K, V> newHT;
//newHT._tables.resize(_tables.size() * 2);
//for (size_t i = 0; i < _tables.size(); i++)
//{
// Node* cur = _tables[i];
// while(cur)
// {
// newHT.Insert(cur->_kv);
// cur = cur->_next;
// }
//}
//_tables.swap(newHT._tables);
vector<Node*> newtables(_tables.size() * 2, nullptr);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
//旧表中结点,挪动新表重新映射的位置
size_t hashi = hs(cur->_kv.first) % newtables.size();
//头插到新表
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[hashi] = nullptr;
}
_tables.swap(newtables);
}
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
5.3 查找函数
先使用哈希函数计算该元素对应的哈希地址,查找对应槽位的链表是否有该节点。
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
5.4 删除函数
首先计算出对应的哈希地址。在寻找待删除结点时,需要记录前一个结点指针,便于改变结点之间的指向关系。
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == 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;
}
5.5 测试
我们通过调试测试一下插入和删除函数。插入十个元素,再插入一个就会扩容。
void TestHT1()
{
HashTable<int, int> ht;
int arr[] = { 11,21,4,14,24,15, 9,19,29,39 };
for (auto e : arr)
{
ht.Insert({ e,e });
}
//插入该元素会扩容
ht.Insert({ 13,13 });
for (auto e : arr)
{
ht.Erase(e);
}
}
插入十个元素,里面有许多产生哈希冲突的元素,形成链表。
插入第十一元素进行扩容,原先发生冲突的元素位置发生了变化。
删除数组中的十个元素,结果如下:
下面的是插入字符串键值对元素,其中HashFunc已经特化一个针对字符串转整型的函数。
void TestHT2()
{
HashTable<string, string> ht;
ht.Insert({ "sort", "排序" });
ht.Insert({ "string", "字符串" });
HashNode<string, string>* node = ht.Find("sort");
node->_kv.second = "排序+其他";
}
插入两个字符串键值对元素。
利用查找函数,修改元素的值。
总结
哈希表相比于红黑树在查找、插入和删除操作上通常能够提供更快的性能。然而,哈希表的这一优势是以牺牲顺序遍历能力为代价的,因为哈希表中的元素是无序存储的。
两种数据结构各有所长,它们的应用场景也因此而异。哈希表内部实现逻辑相较于红黑树来说更为简单,它不需要复杂的平衡操作,这使得哈希表在某些对性能要求极高的场景中更具优势。
创作不易,希望这篇文章能给你带来启发和帮助,如果喜欢这篇文章,请留下你的三连,你的支持的我最大的动力!!!