【C++】哈希表的实现(开放定址法)中我们介绍了一个哈希函数:除法散列法(除留余数法),还有一个处理哈希冲突的方法:开放定址法中的线性探测。
本篇继续讲解一些别的哈希函数和处理哈希函数的方法,以及如何用链地址法实现这个哈希表。
乘法散列法对哈希表⼤⼩ M没有要求 ,他的⼤思路:
其实就是让M去乘一个[0, 1)之间的小数,这个小数要与K相关,K不同,这个小数就不同,就能映射的更分散。
经过大佬Knuth的分析,建议这个A取黄金分割点:
所以乘法散列法的公式为:h(key) = floor(M * (A * key) % 1.0)),其中floor表⽰对表达式进⾏向下取整,A∈(0,1)。
举个例子:乘法散列法对哈希表⼤⼩M是没有要求的,假设M为1024,key为1234,A = 0.6180339887, A*key = 762.6539420558,取⼩数部分为0.6539420558, M×((A×key)%1.0) = 0.6539420558*1024 = 669.6366651392,那么h(1234) = 669。
这个函数主要是为了抵御恶意攻击的。如果存在⼀个恶意的对⼿,他针对我们提供的散列函数,特意构造出⼀个发⽣严重冲突的数据集, ⽐如,让所有关键字全部落⼊同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。
解决⽅法就是给散列函数 增加随机性 ,攻击者就⽆法找出确定可以导致最坏情况的数据。这种⽅法叫做全域散列。
全域散列法的公式为: h(key) = ((a * key + b) % P) % M ,P需要选一个足够大的质数,a可以随机选[1, P-1]之间的任意整数,b可以随机选[0, P-1]之间的任意整数。
这些函数构成了一个P * (P - 1)组 全域散列函数组 。
假设key是8,P=17,M=6,a = 3, b = 4, 则h(8) = ((3 * 8 + 4) % 17) % 6 = 5。
需要注意的是每次 初始化 哈希表 时 , 随机选取 全域散列函数组中的 ⼀个散列函数 使⽤,后续增删查改都 固定使⽤这个 散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了。
在开放定址法中所有的元素都放到哈希表⾥,当⼀个关键字key⽤哈希函数计算出的位置冲突了,则按照某种规则找到⼀个没有存储数据的位置进⾏存储,开放定址法中负载因⼦⼀定是⼩于的。这⾥的规则有三种:线性探测(已做讲解)、⼆次探测、双重探测。
) % M, i = { 1, 2, 3, ... ,
}。
) % M 时,hashi<0时,需要hashi += M。
把 {19,30,52,63,11,22} 等这⼀组值映射到M=11的表中。
h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0

代码就不实现了。
线性探测是挨着找空位,二次探测是有规律的跳跃着找空位,双重探测就是再弄一个哈希函数,无规律的跳跃着找空位,减少堆积。
第⼀个哈希函数计算出的值发⽣冲突,使⽤第⼆个哈希函数计算出⼀个跟key相关的偏移量值,不
断往后探测,直到寻找到下⼀个没有存储数据的位置为⽌。
h1 ( key ) = hash 0 = key % M , hash0位置冲突了,则双重探测公式为:
hc ( key , i ) = hashi = ( hash 0 + i ∗ h 2 ( key )) % M , i = {1, 2, 3, ..., M }
前面的开放定址法不管是哪种探测,还是会出现相互挤占位置的情况,不能完全解决这个问题。更好的解决方法其实是链地址法。
链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储⼀个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据 链接成⼀个链表 ,挂在哈希表这个位置下⾯,链地址法也叫做 拉链法或者哈希桶 。
比如说把 {19,30,5,36,13,20,21,12,24,96} 等这⼀组值映射到M=11的表中。
h(19) = 8 , h(30) = 8 , h(5) = 5 , h(36) = 3 , h(13) = 2 , h(20) = 9 , h(21) = 10, h(12) = 1, h(24) = 2, h(96) = 8

开放定址法负载因⼦必须⼩于1, 链地址法 的 负载因⼦就没有限制 了,可以⼤于1。
我们下⾯实现也使⽤这个⽅式。
先把哈希桶的结构搞出来。
namespace hash_bucket // 链地址法/哈希桶
{
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next; //存放下一个节点的地址
HashNode(const pair<K, V>& kv)
:_kv(kv)
,_next(nullptr)
{}
};
template<class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
:_table(11)
,_n(0)
{}
private:
vector<Node*> _table; // vector里面存HashNode
size_t _n;
};
}vector里面其实也可以存list,但这里选择存自定义类型HashNode。
bool Insert(const pair<K, V>& kv)
{
size_t hashi = kv.first % _table.size(); //映射位置
Node* newnode = new Node(kv);
}创建好结点之后,这里可以选择头插,也可以选择尾插,都是一样的 ,我在里就写头插的实现。
比如现在要插入这个96,96映射的位置是8。

要让newnode的next链接到30,newnode成为新的头,而hashi在这个位置里面存的就是值为30的节点的地址,所以让newnode的next指向hashi里面存的地址就行,然后更新hashi。

插入数据之后要更新_n。
bool Insert(const pair<K, V>& kv)
{
size_t hashi = kv.first % _table.size(); //映射位置
Node* newnode = new Node(kv);
//头插
newnode->_next = _table[hashi];
_table[hashi] = newnode;
_n++;
}我们要把负载因⼦基本控制在1,⼤于1就扩容。
在开放地址法的实现中,我们用了巧妙的写法针对扩容后旧数据的处理,就是insert里面复用insert,但是那种写法在这里并不推荐使用。
实现如下。
bool Insert(const pair<K, V>& kv)
{
if (_n == _table.size()) //扩容
{
vector<Node*> newtable(2 * _table.size());//创建新表
for (int i = 0; i < _table.size(); i++) //遍历旧表
{
Node* cur = _table[i];
while (cur) //遍历当前位置挂的所有节点
{
Node* next = cur->_next; //记录cur的下一个节点
size_t hash0 = cur->_kv.first % newtable.size(); //计算在新表的位置
//直接把节点头插到新表
cur->_next = newtable[hash0];
newtable[hash0] = cur;
cur = next; //遍历这条链上的下一个节点
}
_table[i] = nullptr;
}
_table.swap(newtable);//旧表新表互换
}
size_t hashi = kv.first % _table.size(); //映射位置
Node* newnode = new Node(kv);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
_n++;
return true;
}先拿下面这组测试样例测试一下。
int main()
{
int arr[] = { 19,30,5,36,13,20,21,12,24,96,4,17 };
hash_bucket::HashTable<int, int> h;
for (auto i : arr)
{
h.Insert({ i, i });
}
return 0;
}如果通过,就可以在扩容部分换成用素数表。
素数表代码在 【C++】哈希表的实现(开放定址法)

find实现逻辑就是 先计算出要找的这个值映射的位置,然后遍历挂着的链表就行。
Node* Find(const K& key)
{
size_t hashi = key % _table.size();
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur; //找到了
}
cur = cur->_next;
}
return nullptr; //没找到
}测试一下。
int arr[] = { 19,30,5,36,13,20,21,12,24,96,4,17 };
hash_bucket::HashTable<int, int> h;
for (auto i : arr)
{
h.Insert({ i, i });
}
if (h.Find(1))
cout << "找到了" << endl;
else
cout << "没找到" << endl;
if (h.Find(19))
cout << "找到了" << endl;
else
cout << "没找到" << endl;
return 0;
我们可以在insert里面添加一下find,把insert实现成不允许冗余的。
if (Find(kv.first))
return false;
节点的删除要分情况讨论:删除节点为头节点;删除节点为中间节点或尾节点。
删除头节点,比如把这个96删了,96记为cur。

直接让下标为8的这个位置存储cur的下一个节点的地址就行了。
如果删除中间节点,比如删除30,30记为cur。

我们要让96和19链接上,就是让cur的前一个指向cur的后一个,所以我们需要把cur的前一个记录下来。

bool Erase(const K& key)
{
size_t hashi = key % _table.size();
Node* cur = _table[hashi];
while (cur)
{
Node* prev = nullptr;
if (cur->_kv.first == key) //找到了
{
if (prev == nullptr) //删除节点为头节点
{
_table[hashi] = cur->_next;
}
else //删除节点为中间节点或尾节点
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false; //删除失败
}测试一下。
int arr[] = { 19,30,5,36,13,20,21,12,24,96,4,17 };
hash_bucket::HashTable<int, int> h;
for (auto i : arr)
{
h.Insert({ i, i });
}
if (h.Find(19))
cout << "找到了" << endl;
else
cout << "没找到" << endl;
h.Erase(19); //删除19
if (h.Find(19))
cout << "找到了" << endl;
else
cout << "没找到" << endl;
这里涉及自定义类型的释放,可以自己手动写一个析构函数。
~HashTable()
{
for (int i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}拷贝构造这些也可以实现一下,这里就不写了。
这里也是和开放地址法一样的,要多加一个模板参数。
template<class K>
struct HashFunc
{
bool operator()(const K& key)
{
return (size_t)key;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
//...
}string类型经常用到,把string特化。
template<>
struct HashFunc<string>
{
bool operator()(const string& s)
{
size_t hashi = 0;
for(auto ch : s)
{
hashi += ch;
hashi *= 131;
}
return hashi;
}
};然后其他需要改动的地方也要一起改。
Insert:


Find:

Erase:

测试一下。
int main()
{
const char* arr[] = { "hello", "left", "sort", "passage" };
hash_bucket::HashTable<string, string> s;
for (auto& e : arr)
{
s.Insert({ e, e });
}
return 0;
}如果没有错误就可以了,这个哈希桶就实现的差不多了。
本篇就分享到这里,我们下篇见~