目录
前言
一、哈希
1、哈希的概念
2、哈希函数
(1)直接定址法
(2)除留余数法
(3)平方取中法(了解)
(4)随机数法(了解)
3、哈希冲突
4、闭散列及其实现
(1)闭散列的查找
(2)闭散列的插入
(3)闭散列的删除
5、开散列及其实现
(1)开散列的查找
(2)开散列的插入
(3)开散列的删除
(4)其他函数
6、开散列与闭散列的一些其他问题
(1)对于自定义类型成员无法确定位置
(2)模素数优化
二、unordered_set与unordered_map的封装
前面我们学习了unordered_set、unordered_map的使用,这里我们从底层来看看这两个容器,并对其封装,再封装之前,我们需要清楚者两个容器的底层是哈希结构,我们首先自己实现一个哈希表,再拿这个哈希表对容器进行封装;
前面的二叉搜索树我们想找到一个值就必须对这个值从根节点开始依次比较,知道找到这个数或者找到空姐点指针;但是我们想通过一种一 一映射的思想以最快的速度找到我们想要的值,这种将我们的关键码与位置建立关系的思想,我们称之为哈希;举个例子;
题目链接
如上述这道题目,我们想要找出只出现一个只出现一次的字符,我们怎么处理呢?我们之前可通过类似计数排序的思想,将每个字母映射一个数字下标的位置(题目有说只会出现小写字母);如我们创建一个大小为26的数组,并都初始化为0,a映射到数组0下标的位置,b映射到数组1下标的位置,这样依次映射我们可以映射到下标25,z的位置;然后遍历字符串,每遇到一个字符,就将该字符对应的下标的数组值+1;如遇到a,我们将0下标对应的数组值+1;这样的思想也就是我们哈希的思想;
哈希函数就是将我们关键码转化成对应的哈希值,上述题目中将小写字母转换成0到26这一过程我们就可以理解成我们用哈希函数将小写字母转换成特定的哈希值;接下来我们来看看了解一下常见的哈希函数;
所谓直接定址法就是去关键码作为哈希地址,如来了一个3,我们就将3放进数组下标为3的位置,来了一个13,就将13放进数组13的位置;
但是这种方法有一个明显的缺陷,我们要是我们的数据并不是集中分布的呢?假如有三个数据,分别为3,5,10007;那么我们是不是要开辟10007这么大的空间存放数据呢??因此我们不使用这种方法;
我们在得到一个关键码时,我们将它余上某个数字得到储存位置;这种方法就是我们的除留余数法;我们后面实现哈希表就是采用这种方法;例如,还是上述三个数据,3,5,10007;
我们将上述的值依次余上一个10,得到的余数就是对应的位置;后面还有一个写方法,我们了解即可;
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。
当然还有很多很多的方法,大家感兴趣可以自行搜索;
在上述不同方法中,仍然会出现一些特殊状况,其中有一种被称为哈希冲突,如下图所示;
上图采取的是除留取余法,目前数据存储并没有什么问题,但是接下来,当我们想存入一个13,这是我们对其取10的余数,即为3,但是此时我们3的位置已经存储了一个数据了,那这时我们应该怎么存储这个数据呢?这种存储数据位置冲突的现象就是我们的哈希冲突;那么如何解决这种哈希冲突呢?没错,就是我们接下来的闭散列和开散列两种方法;
闭散列也叫开放定址法,当发生哈希冲突的时候,我们将当前数据存储到“下一个”位置存储;关于这下一个位置,我们有几种不同的定义;可以是+1的位置,也可以每次都加不同的数,主要看实现者想如何实现;以下我们依次分析;
首先,我们分析一下,我们想实现我们的闭散列有哪些困难;
1、我们开辟一块空间后,当来了一个关键码以后,我们通过特定的算法函数,将这个关键码转换成我们的位置,然后我们需要查看数组的这个位置是否存储了数据,那我们如何确定这个位置是否存储了数据呢?
2、当我们删除一个元素后,我们应该如何表明该数据被删除了呢?
我们可以将数组中存储一个结构化数据,而不是单个数据,结构化数据包括我们存储的数据,以及数据的状态,这里我设置了三种状态,分别是EMPT(此位置为空),EXIST(此位置有数据),DELETE(此位置目前没数据了,被删除了)
注意:这里可能有很多同学看不懂这里为什么要设置DELETE这种状态,删除一个数据直接设置成EMPTY不可以吗?这里我们需要考虑删除的情况;假设如下;
此时插入了数据如上图所示,我们使用的下一个位置是+1;我们查找数据的逻辑是先算出位置值,我们算出存入hashi中,然后我们从hashi位置开始查找,如果不是我们要查找的数值,我们就找下一个位置,知道我们加到的那个位置的值为EMPTY状态为止;这时,如果我们删除数据的逻辑是将数据对应位置设置为EMPTY就有坑了,假设我们删除15,此时下标5这个位置被设置成EMPTY,然后我们接着想查找12,我们从算出hashi值2,从2开始查找,发现不等于12,接着探测下一个位置下标3的值,对比还是不相等,一直到下标5的位置时,我们发现下标5为EMPTY状态,停止查找,可是我们数据明明存储在这个哈希表中;所以两个状态是不够的!
根据如上分析,我们写出闭散列的大体框架,如下所示;
namespace ClosedHash
{
// 状态表示
enum State
{
EMPTY,
EXIST,
DELETE
};
// 哈希表中数据存储类型
template
struct HashDate
{
std::pair _kv;
State _state = EMPTY;
};
// 哈希表
template
class HashTable
{
public:
private:
std::vector> _hash_table;
size_t _n = 0; // 哈希表中元素个数
};
}
闭散列的查找需要先算出hashi,这里是余上这个vector的size;这里采用的是一次探测,即不断+1;
HashDate* find(const K& key)
{
if (_n == 0)
{
return nullptr;
}
size_t hashi = key % _hash_table.size();
size_t i = 1;
size_t index = hashi;
// 若查找位置不为空,继续往后找
while (_hash_table[index]._state != EMPTY)
{
if (_hash_table[index]._state == EXIST && _hash_table[index]._kv.first == key)
{
return &_hash_table[index];
}
else
{
// 下一个探测
index = hashi + i;
index %= _hash_table.size();
i++;
}
// 防止都是DELETE状态造成死循环
if (index == hashi)
break;
}
return nullptr;
}
插入的代码中,关于哈希碰撞时,我们可以选择一次探测,也可以选择二次探测;这里插入时,需要考虑扩容问题,这里涉及到什么时候扩容,关于什么时候扩容,我们这里引入了负载因子这一概念;
负载因子 = 元素个数 / size;
所以这里需要控制除零错误,第一次进去必须扩容;还有我们这的负载因子一般控制在0.7到0.8左右;大了就哈希碰撞的几率大,空间利用率高,搜索效率低;小了就哈希碰撞几率低,空降利用率低,搜索效率高;
bool insert(const std::pair& kv)
{
if (find(kv.first))
return false;
// 是否需要扩容
if (_hash_table.size() == 0 || _n * 10 / _hash_table.size() == 7)
{
int newsize = _hash_table.size() == 0 ? 10 : _hash_table.size() * 2;
HashTable tmp;
tmp._hash_table.resize(newsize);
for (auto& e : _hash_table)
{
tmp.insert(e._kv);
}
_hash_table.swap(tmp._hash_table);
}
size_t hashi = kv.first % _hash_table.size();
size_t i = 1;
size_t index = hashi;
// 若插入位置发生冲突,则继续探测
while (_hash_table[index]._state == EXIST)
{
// 一次探测
index = hashi + i;
// 二次探测
//index = hashi + i * i;
i++;
index %= _hash_table.size();
}
_hash_table[index]._kv = kv;
_hash_table[index]._state = EXIST;
_n++;
return true;
}
bool erase(const K& key)
{
auto pos = find(key);
if (pos == nullptr)
return false;
pos->_state = DELETE;
_n--;
return false;
}
开散列又叫链地址法(开链法),当我们得到一个关键码时,我们将这个关键码求出对应的地址,我们称对应地址所在位置为一个哈希桶,我们将这个数据以链表的形式挂在对应的哈希桶下面;如下图所示;
不难看出,每个哈希桶下面挂着发生哈希冲突的值,实际上,我们的unordered_set与unordered_map同样也是使用开散列的哈希桶进行封装;;我们根据上述,同样也可以实现出开散列类的基本结构,如下所示;
namespace OpenHash
{
template
struct HashNode
{
typedef HashNode Node;
HashNode(const std::pair& kv)
:_kv(kv)
,_next(nullptr)
{}
std::pair _kv;
Node* _next = nullptr;
};
template
class HashBucket
{
public:
typedef HashNode Node;
private:
std::vector _hash_table;
size_t _n = 0; // 存储的元素个数
};
}
查找并无难度,我们仅仅只需要算出对应的hashi,然后在对应桶下面的单链表中查找即可;
Node* find(const K& key)
{
if (_hash_table.size() == 0)
return nullptr;
size_t hashi = key % _hash_table.size();
Node* cur = _hash_table[hashi];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
开散列同样也要考虑扩容问题,开散列的负载因子我们可以略微增加,因为开散列的哈希碰撞几率明显降低了,这里我们把负载因子设置为了1;
bool insert(const std::pair& kv)
{
// 扩容
if (_hash_table.size() == 0 || _n * 10 / _hash_table.size() == 10)
{
int newsize = _hash_table.size() == 0 ? 10 : _hash_table.size() * 2;
HashBucket tmp;
tmp._hash_table.resize(newsize, nullptr);
for (auto& cur : _hash_table)
{
// 将该链表下所有结点放入新表中
while (cur)
{
Node* next = cur->_next;
size_t hashi = cur->_kv.first % tmp._hash_table.size();
// 头插入新表中
cur->_next = tmp._hash_table[hashi];
tmp._hash_table[hashi] = cur;
cur = next;
}
}
_hash_table.swap(tmp._hash_table);
}
// 插入
size_t hashi = kv.first % _hash_table.size();
Node* newnode = new Node(kv);
if (_hash_table[hashi] == nullptr)
{
_hash_table[hashi] = newnode;
}
else
{
// 头插
newnode->_next = _hash_table[hashi];
_hash_table[hashi] = newnode;
}
_n++;
return true;
}
这里需要注意头删的特殊处理;
bool erase(const K& key)
{
Node* pos = find(key);
if (pos == nullptr)
return false;
size_t hashi = key % _hash_table.size();
Node* prev = nullptr;
Node* cur = _hash_table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
// 头删
if (prev == nullptr)
{
_hash_table[hashi] = cur->_next;
}
else // 中间尾部删除
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
这里需要实现拷贝构造,因为vector里存的成员是链表,对于链表,我们要进行深拷贝,不然会内存泄露;
// 默认构造
HashBucket()
:_n(0)
{}
// 拷贝构造
HashBucket(const HashBucket& hb)
{
// 深拷贝
if (this != &hb)
{
_hash_table.resize(hb._hash_table.size(), nullptr);
for (size_t i = 0; i < hb._hash_table.size(); i++)
{
Node* cur = hb._hash_table[i];
Node* copytail = nullptr;
while (cur)
{
Node* newnode = new Node(cur->_kv);
if (_hash_table[i] == nullptr)
{
_hash_table[i] = newnode;
copytail = newnode;
}
else
{
copytail->_next = newnode;
copytail = copytail->_next;
}
cur = cur->_next;
}
}
}
}
// 赋值重载(现代写法)
HashBucket& operator=(HashBucket hb)
{
_hash_table.swap(hb._hash_table);
std::swap(_n, hb._n);
return *this;
}
// 析构函数
~HashBucket()
{
clear();
}
size_t size()
{
return _n;
}
void clear()
{
for (size_t i = 0; i < _hash_table.size(); i++)
{
Node* cur = _hash_table[i];
while (cur)
{
Node* del = cur;
cur = cur->_next;
delete del;
}
_hash_table[i] = nullptr;
}
}
前面不管是开散列还是闭散列,我们在求取hashi的时候,我们都是直接求余数的,假如我们的key是string类型呢?那不就都会报错吗?所以,此时我们必须多提供一个模板参数,也就是我们unordered_set/unordered_map第三个模板参数Hash,这个参数就是我们可以传入一个仿函数,控制如何将key转换成size_t类型;
// 增加后的模板列表
template>
class HashBucket
//增加的默认Hash函数
template
struct Hash
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template<>
struct Hash
{
// BKDRHash
size_t operator()(const std::string& s1)
{
size_t hash = 0;
for (auto ch : s1)
{
hash = hash * 31 + ch;
}
return hash;
}
};
关于字符串哈希算法下面有一篇博客进行了详细介绍,上述代码就是采用其中之一的BKDRHash; 字符串哈希函数
经过研究发现,除留余数法最好模上一个素数,这样哈希冲突的概率比较低;因此,我们可以在每次扩容时,我们取比当前容量大两倍的一个素数,因此有了以下代码;
size_t GetNextPrime(size_t prime)
{
const int PRIMECOUNT = 28;
static const size_t primeList[PRIMECOUNT] =
{
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul,
25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul,
805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
我们每次扩容直接调用这个函数即可拿到扩容的大小;
这两个容器的封装与我们map、set封装差不多,我们修改一下我们之前写的哈希表,然后进行封装即可;代码提交至gitee中;有兴趣的可以查看;
容器封装代码