
🎬 个人主页 :MSTcheng · CSDN
🌱 代码仓库 :MSTcheng · Gitee
🔥 精选专栏 : 《C语言》
《数据结构》
《算法学习》
《C++由浅入深》
💬座右铭: 路虽远行则将至,事虽难做则必成!
前言:在上一篇文章中我们使用了开放地址法来实现哈希表,但是开放地址法在实际使用的不多,主要还是使用链地址法来实现,所以本篇文章就来介绍一下链地址法。
文章目录
一、开发地址法的局限与链地址法的优势
1、开放定址法:
开放定址法使用的是线性探测的来解决哈希冲突的(即多个值映射到了同一个位置), 而线性探测的核心思想就是,有一个值与另外一个值在相同的位置冲突了,这个位置被第一个值占了,那么第二个值就占用后面空的位置。这就导致了一个非常大的缺陷,一个位置冲突了就会往后占位,从而影响后面的值插入。 这就会影响查找,在查找时计算到这个位置后还要往后走若干个位置后才能找到,效率大大降低。
2、链地址法 开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储⼀个指针! 没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成⼀个链表,挂在哈希表这个位置下⾯,链地址法也叫做拉链法或者哈希桶。

这里可能有人分不清除留余数法与开放定址法有什么区别?
- 除留余数法 是用来解决我们依靠什么方法去映射值的一个方法,比如直接定址法它是直接依靠下标来映射值,而除留余数法是依靠余数来映射值。就是决定我们依靠什么去映射值。
- 开放定址法 主要是用来解决哈希冲突,就是两个不同的值映射到了同一个位置怎么办?这时候就要线性探测。既然使用了除留余数法,那么哈希冲突就是不可避免的,所以线性探测是用来解决冲突的一个方法。
二、链地址法的实现
链地址法我们还是选择除留余数法来实现哈希桶
我们先给出整体框架:
cpp
#include<iostream>
#include<vector>
using namespace std;
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)
{}
};
注意:哈希桶每一个位置存的是一个指针,是一个链表结构,所以我们要定义节点,节点里面存储一个
pair和一个next指针。
cpp
template<class k,class v,class Hash=HashFunc<k>>
class HashTable
{
typedef HashNode<k, v> Node;
public:
//默认构造
HashTable()
:_tables(11, nullptr)//底层容器选用的是vector 所以_tables(11,nullptr)
, _n(0) //是给哈希表开11个空间 并且使用空指针初始化的意思
{}
private:
vector<Node*> _tables;
size_t _n;//有效数据个数
};
注意,哈希桶每一个位置存的都是一个指针,所以每一个位置的类型都是
Node*,初始状态下为空。
仿函数:
cpp
template<class k>
struct HashFunc
{
size_t operator()(const k& key)
{
return (size_t)key;
}
};
//特化版本 支持string取模
template<>
struct HashFunc<string>
{
size_t operator()(const string& str)
{
size_t hash = 0;
for (auto ch : str)
{
hash += ch;
hash *= 131;
}
return hash;
}
};
这两个仿函数一个是解决负数取模问题的仿函数,一个是支持string取模的仿函数,对于这两个仿函数不了解的可以去看上一篇文章: 【C++】开放定址法实现哈希表!
下面来看插入逻辑:
2.1哈希桶的插入
cpp
bool Insert(const pair<k, v>& kv)
{
if (Find(kv.first))
return false;
HashF hs;
//通过余数找到要映射的位置
size_t Hashi = hs(kv.first) % _tables.size();
Node* NewNode = new Node(kv);//根据kv开一个节点的空间
//将新节点与哈希表链接
NewNode->_next = _tables[Hashi];
_tables[Hashi] = NewNode;
_n++;
return true;
}
这就是哈希桶的插入,逻辑就是通过计算余数找到相应的位置,然后就是一个像链表一样的插入,如果该位置存有节点那么就将新节点的
next与原哈希表指向的节点相连,然后再将自己丢给哈希表,这样就完成了插入。
扩容:
在使用开放定址法实现哈希表的时候我们说负载因子大于等于0.7的时候就要扩容,保证查找高效的同时空间利用率尽量的高。 但是在链地址法这里就不一样了,链式地法实现的哈希桶负载因子可以等于1再扩容,因为链地址法实现的哈希桶从根本上解决了哈希冲突的问题。 下面就来看看扩容逻辑:
下面的代码位于插入函数内部!!!
cpp
//扩容
//HashTable<K, V> newHT;
//newHT._tables.resize(_tables.size()*2);
//// 遍历旧表将所有值映射到新表
//for (auto cur : _tables)
//{
// while (cur)
// {
// newHT.Insert(cur->_kv);
// cur = cur->_next;
// }
//}
//_tables.swap(newHT._tables);
//法二 将旧表的节点直接拿下来挂到新表对应的位置 而不是拷贝
if (_n == _tables.size())
{
vector<Node*> newtables(_tables.size() * 2);
//遍历旧表 将旧表的节点移动到新表
for (size_t i = 0;i < _tables.size();i++)
{
Node* cur = _tables[i];
//遍历每一个位置的哈希桶
while (cur)
{
//根据新表的大小重新计算映射位置
size_t Hashi = hs(kv.first) % _tables.size();
Node* next = cur->_next;
cur->_next = newtables[Hashi];
newtables[Hashi] = cur;
cur = next;
}
//注意将旧的哈希表每个位置的结点置空
_tables[i] = nullptr;
}
//旧表全部遍历完后 与新表交换内部指针_start _finish _endofstorage
_tables.swap(newtables);
}
注意:
- 法一这种做法是不提倡的 因为如果复用插入逻辑每一次插入时都会重新创建节点如果节点数量比较多的话 相当于是将旧表的节点拷贝到新表,且旧表析构的时候还要去析构节点这样不仅效率低下而且还浪费空间
- 法二的做法是直接将链表从旧的哈希桶上面拿下来,然后重新计算位置挂到新的哈希桶的相应位置。 但一定要注意,将旧的哈希桶上面的结点拿下来挂到新的哈希桶上面之后要记得置空旧桶,不然旧桶每个位置的指针还是会指向结点!!!
插入部分的完整代码:
cpp
bool Insert(const pair<k, v>& kv)
{
if (Find(kv.first))//插入前首先查找这个值在不在,在的话就返回false
return false; //这里默认不支持数据冗余
//法二 将旧表的节点直接拿下来挂到新表对应的位置 而不是拷贝
if (_n == _tables.size())
{
vector<Node*> newtables(_tables.size() * 2);
//遍历旧表 将旧表的节点移动到新表
for (size_t i = 0;i < _tables.size();i++)
{
Node* cur = _tables[i];
//遍历每一个位置的哈希桶
while (cur)
{
//根据新表的大小重新计算映射位置
size_t Hashi = hs(kv.first) % _tables.size();
Node* next = cur->_next;
cur->_next = newtables[Hashi];
newtables[Hashi] = cur;
cur = next;
}
}
//旧表全部遍历完后 与新表交换内部指针_start _finish _endofstorage
_tables.swap(newtables);
}
HashF hs;
//通过余数找到要映射的位置
size_t Hashi = hs(kv.first) % _tables.size();
Node* NewNode = new Node(kv);//根据kv开一个节点的空间
//将新节点与哈希表链接
NewNode->_next = _tables[Hashi];
_tables[Hashi] = NewNode;
_n++;
return true;
}
2.2哈希桶的查找
cpp
Node* Find(const k& key)
{
//通过计算余数先找到相应的位置
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;
}

2.3哈希表的删除
cpp
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)
{
//说明要删除的节点就是第一个节点 即cur就是哈希桶的第一个节点
_tables[Hashi] = cur->_next;
}
else
{
//将前一个结点的next 连接到当前结点cur的下一个结点
//与链表的删除完全一样
prev->_next = cur->_next;
}
delete cur;
return true;
}
//cur每次往后走之前要给给prev 让prev记录下cur的前一个位置
prev = cur;
cur = cur->_next;
}
return false;
}
注意:这一部分的删除与链表的删除完全一样,不熟悉的请点击-> 【数据结构】顺序表和链表详解(上)
2.4整个哈希桶实现的全部代码
cpp
#include<iostream>
#include<vector>
using namespace std;
template<class k>
struct HashFunc
{
size_t operator()(const k& key)
{
return (size_t)key;
}
};
//特化版本 支持string取模
template<>
struct HashFunc<string>
{
size_t operator()(const string& str)
{
size_t hash = 0;
for (auto ch : str)
{
hash += ch;
hash *= 131;
}
return hash;
}
};
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 Hash=HashFunc<k>>
class HashTable
{
typedef HashNode<k, v> Node;
public:
//默认构造
HashTable()
:_tables(11, nullptr)//底层容器选用的是vector 所以_tables(11,nullptr)
, _n(0) //是给哈希表开11个空间 并且使用空指针初始化的意思
{}
bool Insert(const pair<k, v>& kv)
{
if (Find(kv.first))
return false;
Hash hs;
//法二 将旧表的节点直接拿下来挂到新表对应的位置 而不是拷贝
if (_n == _tables.size())
{
vector<Node*> newtables(_tables.size() * 2);
//遍历旧表 将旧表的节点移动到新表
for (size_t i = 0;i < _tables.size();i++)
{
Node* cur = _tables[i];
//遍历每一个位置的哈希桶
while (cur)
{
//根据新表的大小重新计算映射位置
size_t Hashi = hs(kv.first) % _tables.size();
Node* next = cur->_next;
cur->_next = newtables[Hashi];
newtables[Hashi] = cur;
cur = next;
}
}
//旧表全部遍历完后 与新表交换内部指针_start _finish _endofstorage
_tables.swap(newtables);
}
//通过余数找到要映射的位置
size_t Hashi = hs(kv.first) % _tables.size();
Node* NewNode = new Node(kv);//根据kv开一个节点的空间
//将新节点与哈希表链接
NewNode->_next = _tables[Hashi];
_tables[Hashi] = NewNode;
_n++;
return true;
}
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;
}
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)
{
//说明要删除的节点就是第一个节点 即cur就是哈希桶的第一个节点
_tables[Hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables;
size_t _n;//有效数据个数
};
以上就是链地址法实现哈希表的所有内容了,下面给两段测试代码测试一下:
2.5测试代码
cpp
void TestHT1()
{
HashTable<int, int> ht;
int a[] = { 19,30,5,36,13,20,21,12,24,96 };
for (auto e : a)
{
ht.Insert({ e, e });
}
ht.Erase(30);
ht.Erase(19);
ht.Erase(96);
for (size_t i = 100; i < 200; i++)
{
ht.Insert({ i, i });
}
}
void TestHT2()
{
//HashTable<string, string, StringHashFunc> dict;
HashTable<string, string> dict;
dict.Insert({ "insert", "插入" });
auto ptr = dict.Find("insert");
if (ptr)
{
cout << ptr->_kv.second << endl;
}
}

三、总结
链地址法相当于省略了线性探测这一步,连地址法将所有余数相同的值都挂在同一个位置中, 像一个链表一样来了一个值就往后面挂。 这样的好处是不用再去占别人的位置了,查找的时候直接通过计算就能找到相应的位置,然后再遍历链表找出这个值即可,所以相比于开放定址法,链地址法做了一个很大的优化,效率也得到了非常大的提升。 这也是实际中为什么使用链地址法而不用开放定址法的原因!
html
MSTcheng 始终坚持用直观图解 + 实战代码,把复杂技术拆解得明明白白!
👁️ 【关注】 看普通程序员如何用实用派思路搞定复杂需求
👍 【点赞】 给 "不搞虚的" 技术分享多份认可
🔖 【收藏】 把这些 "好用又好懂" 的干货技巧存进你的知识库
💬 【评论】 来唠唠 ------ 你踩过最 "离谱" 的技术坑是啥?
🔄 【转发】把实用技术干货分享给身边有需要的程序员伙伴
技术从无唯一解,让我们一起用最接地气的方式,写出最扎实的代码! 🚀💻
