目录
哈希的概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较.搜索的效率取决于搜索过程中元素的比较次数,因此顺序结构中查找的时间复杂度为O(N),平衡树中查找的时间复杂度为树的高度O(logN).
而最理想的搜索方法是,可以不经过任何比较,一次直接找到表中得到要搜索的元素,即查找的时间复杂度为O(1).
如果构造一种存储结构,该结构能够通过某种函数使元素的存储位置于它的关键码之间能够建立一一映射关系,那么在查找时就能通过该函数快速找到该元素.
向该结构中插入和搜索元素的过程如下:
- 插入元素:根据待插入元素的关键码,用此函数计算出该元素的存储位置,并将元素放到此位置.
- 搜索元素:对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置取元素进行比较,若关键码相同,则搜索成功.
该方式即为(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(散列表).
列如,集合{1,7,6,4,5,9}
哈希函数设置为:hash(key) = key%capacity.其中capacity为存储元素底层空间的总大小.
若我们将该集合存储在capacity为10的哈希表中,则各元素存储位置对应如下:
用该方法进行存储,在搜索时就只需要通过哈希函数判断对应位置是否存放待查找的元素,而不必进行多次比较,因此搜索的速度比较块.
哈希冲突
不同关键字通过相同哈希函数计算出相同的哈希地址,这种现象称为哈希冲突或哈希碰撞.我们把关码不同而具有相同哈希地址的数据称为"同义词".
列如,在上述列子中,再将元素11插入当前的哈希表就会产生哈希冲突.因为元素11通过该哈希函数得到的哈希地址于元素1相同,都是下标为1的位置.
hash(11) = 11%10 = 1;
哈希函数
引起哈希冲突的一个原因可能是哈希函数设计不够合理.
哈希函数设计的原则:
- 哈希函数的定义域内必须包括需要存储的全部关键码,且如果散列表允许有m各地址,其值域必须在0到m-1之间.
- 哈希函数计算出来的地址能均匀分布在整个空间中.
- 哈希函数应该比较简单.
常见的哈希函数如下:
一,直接定址法(常用)
取关键字的某个线性函数为哈希地址:Hash(key) = A*key+B.
优点:每个值都具有唯一的位置,效率很高,每个都是一次就能找到.
缺点:使用场景比较局限,通常要求数据是整数,范围比较集中.
适应场景:适应于整数,且数据范围比较集中的情况.
二,除留余数法(常用)
设散列表中允许的地址数为m,取一个不大于m但接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key%p(怕<=m),将关键码转换成哈希地址.
优点:使用场景广泛,不受限制.
缺点:存在哈希冲突,需要解决哈希冲突,哈希冲突越多,效率下降的越厉害.
三,平方取中法
假设关键字为1234,对他平方1522756,抽取中间三位227作为哈希地址.
使用场景:不知道关键字的分布,而位数又不是很大的情况.
四,折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并含哈希表表长,取后几位为哈希地址.
使用场景:折叠法适合事先不知道关键字的分布,或关键字位数比较多的情况.
五,随机数法
选择一个随机函数,取关键字的随机函数作为它的哈希地址,即Hash(key) = random(key),其中random为随机函数.
使用场景:通常用于关键字长度不等时.
六,数字分析法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各自位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会相等,而在某些位上分布不均匀,只有几种符号经常出现,此时,我们可以根据哈希表的大下,选择其中各种符号分布均匀的若干位作为哈希地址.
列如:
假如要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前七位都是相同的,那么我们可以选择后面四位作为哈希地址.
如果这样的抽取方式还容易出现冲突,还可以对抽取出来的数字进行反转(如1234改成4321),右环位移(如1234改成4123).左环位移(1234改成2341),前俩个数于后俩个数叠加(如1234改成12+34=46)等操作.
数字分析法通常适合处理关键字位数比较大的情况,或事先知道关键字的分布情况.
注意:哈希函数设计的越巧妙,产生哈希冲突的可能性越低,但是无法避免哈希冲突.
哈希冲突解决
哈希冲突有两种常见的方法:闭散序列和开散序列.
闭散序列--开放定址法
闭散序列,也交开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把产生冲突的元素存放到冲突位置的"下一个"空位置中去.
寻找"下一个位置"的方式多种多样,常见的方式有一下两种:
当发生哈希冲突时,从发生冲突的位置开始计算,一次向后探测,直到找到下一个空位置为止.
Hi = (H0+i)%m (i=1,2,3,...)
H0:通过哈希函数对元素的关键码进行计算得到的位置.
Hi:冲突元素通过线性探测后得到的存放位置.
m:表的大小.
列如,我们用除留余数法将序列{1,6,10,1000,101,18,7,40}插入到表长为10的哈希表中,当发生哈希冲突时我们采用闭散序列的线性探测找到下一个位置进行插入,如下:
通过上图可以看到,随着哈希表中的数据增多,产生的哈希冲突的可能性也随之增大,最后40在进行插入的时候更是出现了连续四次的哈希冲突.
我们将数据插入到有限的空间,那么空间中的元素越多,插入元素是产生冲突的概率也就越大,冲突多次后插入哈希表的元素,在查找时的效率必然也会降低,因此,哈希表中引入了负载因子(载荷因子):
负载因子=表中有效数据的个数/空间大小
- 负载因子越大,产生的冲突的概率越高,增删查改的效率越低.
- 负载因子越小,产生冲突的概率越低,增删查改的效率越高.
列如,我们将哈希表的大小改为20,可以看到在插入相同序列时,产生的哈希冲突会有所降低.
但负载因子越小,也就意味着空间的利用率越低,此时大量的空间实际上就被浪费了,对于闭散列(开放定址法)来说,负载因子是特别重要的因素,一般控制在0.7~0.8一下,超过会导致在查表是cpu缓存的命中率按照指数曲线上生.
因此,一些采用开放定址法的hash库,如Java的系统库限制了负载因子为0.75,当超过该值时,会对哈希表进行增容.
线性探测的优点:实现非常简单.
线性探测的缺点:一旦发生冲突,所有的冲突连在一起,容易产生数据"堆积",即不同关键码占据了可利用的空位置,使得寻找某关键码的位置时需要比较多次(踩踏效应),导致搜索效率降低.
二,二次探索
线性探索的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探索为了避免该问题,找一个空位置的方法为:
Hi=(H0+i^2)%m(i=1,2,3,....)
H0:通过哈希函数对元素的关键码进行计算得到的位置.
Hi:冲突元素通过二次探测后得到的存放位置.
m:表的大小
列如,我们用留余数法将序列 {1,6,10,1000,101,18,7,40}插入到表长为10的哈希表中,当发生哈希冲突时我们采用闭散二次探索找到下一个空位置进行插入,插入过程如下:
采用二次探测为产生哈希冲突的数据寻找下一个位置,相比线性探测而言,采用二次探测的哈希表中元素的分布会相对疏松一些,不容易导致数据的堆积.
和线性探测一样,采用二次探测也需要关注哈希表的负载因子,列如采用二次探测将上述数据插入到表长为20的哈希表中,产生的冲突的次数也会减少.
开散序列--链地地址法(拉链法,哈希桶)
开散列,又叫链地址法(拉链法),首先对关键码集合用哈希函数计算哈希地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头节点存储在哈希表中.
列如,我们用除留余数法将序列 {1,6,15,60,80,7,40,5,10}插入到表长为10的哈希表中,当发生哈希冲突时我们采用开散列的形式,将哈希地址相同的元素都链接到同一个同下.插入过程如下:
闭散列解决哈希冲突,采用的是一种报复的方式,"我的位置被占用了我就去占用其他位置".二开散列解决哈希冲突,采用的是一种乐观的方式,"虽然我的位置被占用了,但每关系,我可以挂到这个的下面.
与闭散列不同的是,这种将相同的哈希地址的元素通过单链表链接起来,然后将链表存储在哈希表中的方式,不会影响与自己哈希地址不同的元素的增删查改的效率.因此开散列的负载因子相对于闭散列而言,可以稍微大一点.
- 闭散列开放定址法,负载因子不能超过1,一般建议控制在[0,0.7]之间.
- 开散列的哈希桶,负载因子可以超过1,一般控制在[0.0,1.0]之间.
实际上开散列哈希桶结构比闭散列更实用,主要原因有一下俩点:
- 哈希桶的负载因子可以更大,空间利用率更高.
- 哈希桶在极端情况下还有可用的解决方案.
哈希桶的极端情况就是,所有元素全部冲突,最终都放到了同一个哈希桶中,此时该哈希表的增删查改的效率就退化成O(N):
这时,我们可以考虑将这个桶中的元素,由单链表结构改为红黑树结构,并将红黑树的根结点存储在哈希表中.
这种情况下,就算有十亿个数据全部冲突到一个哈希桶中,我们也只需要在这个哈希桶中查找30次左右,这就是所谓的桶里种树.
为了避免出现这种极端的情况,当桶中的元素个数超过一定长度,有些地方就会选择将改桶中的单链表结构换成红黑树,比如在Java中比较新的版本中,当桶中的个数超过8时,就会将桶中的单链表结构换成红黑树结构,而当该桶中的数据个数减少到8时,又会将该桶中的红黑树结构换成单链表结构.
但一些地方也会选择不做此处理,因为随着哈希表中数据越来越多,该哈希表的负载因子也会逐渐增大,最终会触发哈希表的增容条件,此时该哈希表当中的数据会全部插入到另一个空间更大的哈希表中,此时桶一个桶中冲突的数据个数也会减少,因此不做处理问题也不大.
哈希表的闭散列实现
哈希表的结构
在闭散列的哈希表中,哈希表每个位置除了存储所给的数据之外,还因该存储当前位置的状态,哈希表中每个位置的可能状态如下:
- EMPTY(无数据的空位置).
- EXIST(已存储数据)
- DELETE(原本有数据,但现在被删除了).
我们可以用枚举定义这三个状态.
enum Status
{
EMPTY,
EXIST,
DELETE
};
为什么需要标识哈希表中每个位置的状态?
若是不设置哈希表中每个位置的状态,那么在哈希表中查找数据的时候可能是这样的,以除留余数法的线性探测为列,我们若要判断下面这个哈希表中是否存在40,步骤如下:
- 通过除留余数法求得元素40在该哈希表中的哈希地址是0.
- 从0下标开始向后查找,若找到了40则说明存在.
但是我们在寻找元素40时,不可能从0下标开始将整个哈希表遍历一次,这样就失去了哈希的意义.我们只需要从0下标开始往后查找,直到找到元素40判定为存在,或是找到一个空位置判定为不存在即可.
因为线性探测在为冲突元素寻找下一个位置时是依次往后寻找的,既然我们已经找到了一个空位置,那么说明空位置的后面不会再有从下标0位置开始的冲突元素了.比如我们要判断哈希表中是否存在元素90,步骤如下:
- 通过除留余数法求得元素90在该哈希表中的地址是0.
- 从0下表开始向后进行查找,直到找到下标为5的空位置,停止查找,判定元素90不存在.
但这种方法是不可行的,原因如下:
- 如何标识一个空位置?用数字0吗?那如果我们要存储的元素就是0怎么办?因此我们必须要单独给每个位置设置一个状态字段.
- 如果只给哈希表中的每个位置设置存在和不存在两种状态,那么当遇到下面这种情况就会出现错误.
我们先将上述哈希表当中的元素1000找到,并将其删除,此时我们要判断当前哈希表中是否存在元素40,当我们从下标0开始往后查找到2下标(空位置)时,我们就因该停下来,此时并没有找到元素40,但时元素40却在哈希表中存在.
因此我们必须为哈希表中的每一个元素设置一个状态,并且每个位置的状态因该有三种可能,当哈希表中的一个元素被删除后,我们不因该简单的将该位置的状态设置为EMPTY,而时将该位置的状态设置为DELETE.
这样一来,当我们在哈希表中查找元素的过程中,若当前位置的元素与待查找的元素不匹配,但当前位置的状态是EXIST或是DELETE,那么我们因该继续往后进行查找,而当我们插入元素的时候,可以将元素插入状态为EMPTY或是DELETE的位置.
因此,闭散列哈希表中每个位置的存储结构,因该包括所给数据和该位置的当前状态.
template<class K,class V>
struct HashDate
{
pair<K, V> _kv;
Status _s = EMPTY;
};
而为了在插入元素是好计算当前哈希表的负载因子,我们还因该时刻存储整个哈希表中有效元素的个数,当负载因子过大时就因该进行哈希表的增容.
private:
vector<HashDate<K, V>> _tables;
size_t _n = 0; //存储的关键字的个数
哈希表的插入
向哈希表中插入数据的步骤如下:
- 查看哈希表中是否存在该键值的键值对,若已存在则插入失败.
- 判断是否需要调整哈希表的大小,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整.
- 将键值对插入哈希表.
- 哈希表中有效元素个数加一.
其中,哈希表的调整如下:
- 哈希表的大小为0,则将哈希表的初始大小设置为10.
- 若哈希表的负载因子大于0.7,则先创建一个新的哈希表,该哈希表的大小为原来哈希表的二倍,之后遍历原哈希表,将原哈希表的数据插入到新哈希表,最后将原哈希表与新哈希表交换即可.
注意:在将原哈希表的数据插入到新哈希表的过程中,不能只是简单的将原哈希表中的数据对于的搬到新哈希表中,而是需要根据新哈希表的大小重新计算每个数据在新哈希表的位置,然后进行插入.
将键值对插入哈希表的具体步骤如下:
- 通过哈希函数计算出对于的哈希地址.
- 若产生哈希冲突,则从哈希地址处开始,采用线性探索向后寻找一个状态为EMPTY或DELETE的位置.
- 将键值对插入到该位置,并将该位置的状态设置为EXIST.
注意:产生哈希冲突向后进行探索时,一定会找到一个合适位置进行插入,因为哈希表的负载因子是控制在0.7一下的,也就是说哈希表永远不会被装满.
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
if ((_n * 10 / _tables.size()) == 7)
{
size_t newsize = _tables.size() * 2;
HashTable<K, V> newHt;
newHt._tables.resize(newsize);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._s == EXIST)
{
newHt.Insert(_tables[i]._kv);
}
}
_tables.swap(newHt._tables);
}
Hash hf;
size_t hashi = hf(kv.first) % _tables.size();
while (_tables[hashi]._s == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._s = EXIST;
++_n;
return true;
}
哈希表的查找
在哈希表中查找数据如下:
- 先判断哈希表的大小是否为0,若为0则查找失败.
- 通过还需函数计算出对应的哈希地址.
- 从哈希地址处开始,采用线性探测向后进行数据的查找,直到找到待查找的元素判定为查找成功,或找到一个状态为EMPTY的位置判定查找失败.
注意:在查找过程中,必须找到位置状态为EXIST,并且key值匹配的元素,才算查找成功.若仅仅是key值匹配,但该位置的状态为DELETE,则还需要继续进行查找,因为该位置的元素已经被删除了.
HashDate<K, V>* Find(const K& key)
{
Hash hf;
size_t hashi = hf(key) % _tables.size();
while (_tables[hashi]._s != EMPTY)
{
if (_tables[hashi]._s == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
}
return nullptr;
}
哈希表的删除
删除哈希表中的元素非常简单,我们只需要进行伪删除即可,也就是将待删除元素所在的位置的状态设置为DELETE.
在哈希表中删除数据的步骤如下:
- 查看哈希表中是否存在该键值对,若不存在则删除失败.
- 若存在,则将该键值对所在的位置的状态置为DELETE即可.
- 哈希表中的有效元素个数减一.
注意:虽然删除元素是没有将该位置的元素数据清0,只是将该元素所在的状态设为了DELETE,但是并不会造成空间的浪费,因为我们在插入数据时是可以将数据插入到状态为DELETE的位置,此时插入的数据就会把该数据覆盖.
bool Erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret)
{
ret->_s = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
哈希表的开散列实现(哈希桶)
哈希表的结构
在开散列的哈希表中,哈希表的每个位置存储的实际上是某个单链表的头节点,即每个哈希桶中存储的数据实际上是一个结点类型,该结点类型除了存储所给的数据之外,还需要存储一个结点指针用于指向下一个结点.
template<class K,class V>
struct HashNode
{
HashNode<K,V>* _next;
pair<K, V> _kv;
HashNode(const pair<K,V>& kv)
:_kv(kv)
,_next(nullptr)
{}
};
为了让代码看起来更清晰,在实现哈希表时,我们最好将结点的类型进行typedef.
typedef HashNode<K, V> Node;
与闭散列的哈希表不同的是,在实现开散列的哈希表时,我们不用为哈希表的每个位置设置一个状态字段,因为在开散列的哈希表中,我们将哈希地址相同的元素都放在了同一个哈希桶中,并不需要进行探测寻找所谓的下一个位置.
哈希表的开散列实现方式,在插入数据时也需要根据负载因子判断是否增容,所以我们因该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就因该进行哈希表的增容.
private:
vector<Node*> _tables;
size_t _n = 0;
哈希表的插入
向哈希表中插入数据的步骤如下:
- 查看哈希表中是否存在该键值的键值对,若已存在则插入失败.
- 判断是否需要进行哈希表大小的调整,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整.
- 将键值对插入哈希表中.
- 哈希表中有效元素个数加1.
其中,哈希表的调整方式如下:
- 若哈希表的大小为0,则将哈希表的初始大小设置为10.
- 若哈希表的负载因子已经等于1了,则先创建一个新的哈希表,该哈希表的大小为原哈希表的俩倍,之后遍历哈希表,将原哈希表中的数据插入到新哈希表中,最后将原哈希表与新哈希表交换即可.
注意:在将原哈希表的数据插入到新哈希表的过程中,不要通过重复插入函数将原哈希表中的数据插入到新哈希表,因为在这个过程中我们需要创建相同数据结点插入到新哈希表中,在插入完闭后还需要将原哈希表中的数据结点进行释放,多此一举.
实际上,我们只需要遍历原哈希表中的每个桶,通过哈希函数将每个桶中的结点重新找到对应位置插入到新哈希表即可,不用进行结点的创建与释放.
说明一下:下面待码中为了降低时间复杂度,在增容时取结点都是从单链表的头开始依次取的,在插入结点时也是直接将头结点插入到对应单链表.
将键值对插入哈希表的具体步骤如下:
- 通过哈希函数计算出对应的哈希地址.
- 若产生哈希冲突,则直接将该结点插入对应单链表即可.
bool Insert(const pair<K, V>& kv)
{
Hash hf;
if (Find(kv.first))
return false;
if (_n == _tables.size())
{
size_t newSize = _tables.size() * 2;
vector<Node*> newtables;
newtables.resize(newSize, nullptr);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = hf(cur->_kv.first) % newtables.size();
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
size_t hashi = hf(kv.first)% _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
哈希表的查找
在哈希表中的查找数据步骤如下:
- 先判断哈希表的大小是否为0,若为0则查找失败.
- 通过哈希函数计算出对应的哈希地址.
- 通过哈希地址找出对应的哈希桶中的单链表,遍历链表进行查找即可.
Node* Find(const K& key)
{
Hash hf;
size_t hashi = hf(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 hf;
size_t hashi = hf(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
Node* prev = nullptr;
if (cur->_kv.first == key)
{
if (prev)
{
prev->_next = cur->_next;
}
else
{
_tables[hashi] = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}