目录
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O(㏒⑵N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当我们向该结构中: 插入元素时,可以根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放;搜索元素时,对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置;取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)
我们之前讲过的计数排序就用到了这个方法(使每个要插入的元素和其物理空间有着映射关系):
但是该方法存在着一个问题:如果我们要插入的元素值分布很广泛,但是要进行插入的元素个数却远远小于其中最大元素和最小元素之差,对于这种情况会非常的浪费空间:
为了解决该问题,我们可以这样设置存储地址和元素值相映射的哈希函数:hash(key) = key % capacity?
其中capacity为存储元素底层空间总的大小,这样子只要capacity大于我们所要插入的元素个数,就会大大增加空间的利用率
对于两个数据元素的关键字Ki和 Kj(i ≠ j),有Ki ≠ Kj,但有:Hash(Ki) = Hash(Kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
例如下面的元素在插入时,元素0和元素4580会产生哈希冲突:
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
● 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
● 哈希函数计算出来的地址能均匀分布在整个空间中
● 哈希函数应该比较简单
常见哈希函数的设计方法
1. 直接定址法(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
2. 除留余数法(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
3. 平方取中法
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
4. 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这 几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
5. 随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中 random为随机数函数。
通常应用于关键字长度不等时采用此法
6. 数学分析法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为?列地址。
例如:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同 的,那么我们可以选择后面的四位作为散列地址
如果这样的抽取工作还容易出现冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
需要注意的是:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
解决哈希冲突两种常见的方法是:闭散列(开放定值法)和开散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有 空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。如何寻找下一个空位置呢?
主要有两中寻找方式:线性探测和二次探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
比如下面插入元素4581时会发生哈希冲突:
我们向后探测一位,即找到1号位的位置,发现其位置是空的,那我们就将4581插入到一号位,这样子就解决了哈希冲突的问题:
我们使用线性探测在哈希表中寻找元素时,也是先通过哈希函数定位要查找元素所在的映射位置,再比对映射位置的值和所要查找的值是否匹配,不匹配就依次向后探测直到寻找到下一个空位置为止
比如我们要在下面的哈希表中查找值为55的元素:
使用线性探测在哈希表中删除元素时,过程和查找元素时一样的,这里不再画图演示
但是找到对应元素进行删除时,要注意如果直接将删除元素的位置置空,可能会影响到该表后续的查询(因为线性探测遇到空会不再继续进行探测)
所以我们在实现时要注意,不能直接将要删除的位置置空,而是要标记为删除状态,这样子下次线性探测时,探测到此位置会继续向下探测,不会造成影响
在具体的代码实现之前我们先要讲一下负载因子的概念:
哈希表的负载因子定义为:α=填入表中的元素个数/哈希表的容量
我们可以从哈希表的规律中看出:如果表中的有效元素个数与表容量的比值越大(负载因子越大),那么在插入元素时发生哈希冲突的概率也会越大?,发生哈希冲突时,我们就要进行探测,这会大大降低哈希表的性能
对于开放定址法,负载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)会按照指数曲线上升。因此,一些采用开放定址法的hash库,如教老版本的Java的系统库限制了荷载因子为0.75(新版本Java已经不采用开放定址法了),超过此值将哈希表扩容
#include<iostream>
#include<vector>
namespace OpenAddress
{
enum Status
{
EMPTY,
EXIST,
DELETE,
};//哈希表中存储元素位置的三种状态
template<class K, class V>
struct HashData//存储数据单位的结构定义
{
std::pair<K, V> _kv;//存储元素为k-v类型的数据
int _status = EMPTY;//存储位置状态
};
template<class K, class V>
class HashTable
{
public:
bool Insert(const std::pair<K, V>& kv)//哈希表的元素插入
{
if (Find(kv.first))//如果元素已存在就不进行插入了
return false;
//计算负载因子,判断是否需要扩容
if (_data.size() == 0 || _num * 10 / _data.size() >= 7)//当size为0时也要注意需要扩容
{
int newsize = _data.size() == 0 ? 10 : _data.size() * 2;
HashTable<K, V> newtable;//建立一个新表
newtable._data.resize(newsize);//扩容时要使用resize,而不是reserve
for (auto& e : _data)//将旧表中的元素重新映射到新表中对应的位置
{
if (e._status == EXIST)
{
newtable.Insert(e._kv);
}
}
_data.swap(newtable._data);//用新表替换旧表
}
int hashi = kv.first % _data.size();//除留余数法
int index = hashi;
for (int i = 1; _data[index]._status == EXIST; ++i)
{
index = (hashi + i) % _data.size();//线性探测,%_data.size()防止探测时发生越界访问
}
_data[index]._kv = kv;
_data[index]._status = EXIST;
++_num;
return true;
}
HashData<K, V>* Find(const K& key)//哈希表的元素查找
{
if (_data.size() == 0)
return nullptr;
int hashi = key % _data.size();//除留余数法
int index = hashi, i = 1;
while (_data[index]._status != EMPTY)
{
if (_data[index]._status == EXIST && _data[index]._kv.first == key)
{
return &_data[index];
}
index = (hashi + i) % _data.size();//线性探测,%_data.size()防止探测时发生越界访问
++i;
if (index == hashi)//防止哈希表中的元素位置状态没有EMPTY,从而进入死循环
return nullptr;
}
return nullptr;
}
bool Erase(const K& key)//哈希表的元素删除
{
HashData<K, V>* del = Find(key);
if (del)
{
del->_status = DELETE;
--_num;
return true;
}
else
{
return false;
}
}
private:
std::vector<HashData<K, V>> _data;
int _num = 0;//记录哈希表中存储元素的个数
};
}
上述代码有几个要注意的点:
● 在Insert时可能会发生扩容,扩容时并不能在原表上直接扩,因为扩容后表的容量会发生变化,导致原数据通过哈希函数计算出来的映射位置会发生变化,所以我们要建立一个新表将旧表的数据重新映射到新表中。由此可见哈希表的扩容代价是很大的
● 在Find函数中,哈希表可能通过不断的插入删除元素会出现一种极端情况:存储元素的位置状态只有删除和存在,没有空位置状态。这样就会导致Find函数进入死循序,所以对于这种情况我们进行了特殊处理
线性探测的缺陷是产生冲突的数据会堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此为了避免该问题,我们可以使用二次探测
二次探测找下一个空位置的方法为:index = (hashi?+ i^2)%m , 或者:index = (hashi -?i^2)%m,其中:i = 1,2,3… ;?hashi是通过哈希函数Hash(x)对元素的关键码key进行计算得到的位置,m是表的大小(%m是防止越界访问)。
我们只需要修改一下探测时的算法即可:
#include<iostream>
#include<vector>
namespace OpenAddress
{
enum Status
{
EMPTY,
EXIST,
DELETE,
};//哈希表中存储元素位置的三种状态
template<class K, class V>
struct HashData//存储数据单位的结构定义
{
std::pair<K, V> _kv;//存储元素为k-v类型的数据
int _status = EMPTY;//存储位置状态
};
template<class K, class V>
class HashTable
{
public:
bool Insert(const std::pair<K, V>& kv)//哈希表的元素插入
{
if (Find(kv.first))//如果元素已存在就不进行插入了
return false;
//计算负载因子,判断是否需要扩容
if (_data.size() == 0 || _num * 10 / _data.size() >= 7)//当size为0时也要注意需要扩容
{
int newsize = _data.size() == 0 ? 10 : _data.size() * 2;
HashTable<K, V> newtable;//建立一个新表
newtable._data.resize(newsize);//扩容时要使用resize,而不是reserve
for (auto& e : _data)//将旧表中的元素重新映射到新表中对应的位置
{
if (e._status == EXIST)
{
newtable.Insert(e._kv);
}
}
_data.swap(newtable._data);//用新表替换旧表
}
int hashi = kv.first % _data.size();//除留余数法
int index = hashi;
for (int i = 1; _data[index]._status == EXIST; ++i)
{
//二次探测
index = (hashi + i * i) % _data.size();//%_data.size()防止探测时发生越界访问
//index = (hashi + i) % _data.size();//线性探测
}
_data[index]._kv = kv;
_data[index]._status = EXIST;
++_num;
return true;
}
HashData<K, V>* Find(const K& key)//哈希表的元素查找
{
if (_data.size() == 0)
return nullptr;
int hashi = key % _data.size();//除留余数法
int index = hashi, i = 1;
while (_data[index]._status != EMPTY)
{
if (_data[index]._status == EXIST && _data[index]._kv.first == key)
{
return &_data[index];
}
//二次探测
index = (hashi + i * i) % _data.size();//%_data.size()防止探测时发生越界访问
//index = (hashi + i) % _data.size();//线性探测
++i;
if (index == hashi)//防止哈希表中的元素位置状态没有EMPTY,从而进入死循环
return nullptr;
}
return nullptr;
}
bool Erase(const K& key)//哈希表的元素删除
{
HashData<K, V>* del = Find(key);
if (del)
{
del->_status = DELETE;
--_num;
return true;
}
else
{
return false;
}
}
private:
std::vector<HashData<K, V>> _data;
int _num = 0;//记录哈希表中存储元素的个数
};
}
在实际运用中闭散列的方法还是有缺陷的,我们更喜欢用开散列来解决哈希冲突
开散列法又叫链地址法(开链法/哈希桶),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中:
namespace HashBucket
{
template<class K, class V>
struct HashNode
{
HashNode<K, V>* _next;
std::pair<K, V> _kv;
HashNode(const std::pair<K, V>& kv)
:_next(nullptr),
_kv(kv)
{}
};
template<class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
public:
~HashTable()
{
for (auto e : _data)
{
while (e)
{
Node* del = e;
e = e->_next;
delete del;
}
}
}
bool Insert(const std::pair<K, V>& kv)
{
if (Find(kv.first))
{
std::cout << "插入的值重复" << std::endl;
return false;
}
//负载因子为1时扩容
if (_num == _data.size())
{
int newsize = _data.size() == 0 ? 10 : _data.size() * 2;
std::vector<Node*> newtable;//建立一个新表
newtable.resize(newsize, nullptr);//扩容时要使用resize,而不是reserve
for (auto& cur : _data)//将旧表数据重新计算过后插入新表
{
while (cur)
{
//下面是直接移动节点位置插入到新表的对应位置中
int hashi = cur->_kv.first % newtable.size();
//头插
Node* next = cur->_next;
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;
}
}
_data.swap(newtable);//插入玩后交换数据存储新表
}
int hashi = kv.first % _data.size();
Node* newnode = new Node(kv);
//头插
newnode->_next = _data[hashi];
_data[hashi] = newnode;
++_num;
return true;
}
Node* Find(const K& key)
{
if (_data.size() == 0)
return nullptr;
int hashi = key % _data.size();
Node* cur = _data[hashi];
while (cur)
{
if (cur->_kv.first == key)
break;
cur = cur->_next;
}
return cur;
}
bool Erase(const K& key)
{
int hashi = key % _data.size();
Node* cur = _data[hashi], *prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_data[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
std::vector<Node*> _data;
size_t _num = 0;//记录表中有效数据个数
};
}
上述代码有几个要注意的点:
● 在开散列中,扩容条件可以适当放宽,因为以链表的形式来存储数据可以使哈希冲突的概率大大降低,所以我们当负载因子等于1时再进行扩容(这里也不建议将负载因子放的更大,这样虽然可以提高空间的利用率,但是会是查找的效率降低)
● 在Insert时可能会发生扩容,扩容时并不能在原表上直接扩,因为扩容后表的容量会发生变化,导致原数据通过哈希函数计算出来的映射位置会发生变化,所以我们要建立一个新表将旧表的数据重新映射到新表中。
●?在Insert时,我们选择了头插,在单链表中头插的效率是要大于尾插的
#include<iostream>
#include<vector>
namespace OpenAddress
{
enum Status
{
EMPTY,
EXIST,
DELETE,
};//哈希表中存储元素位置的三种状态
template<class K, class V>
struct HashData//存储数据单位的结构定义
{
std::pair<K, V> _kv;//存储元素为k-v类型的数据
int _status = EMPTY;//存储位置状态
};
template<class K, class V>
class HashTable
{
public:
bool Insert(const std::pair<K, V>& kv)//哈希表的元素插入
{
if (Find(kv.first))//如果元素已存在就不进行插入了
return false;
//计算负载因子,判断是否需要扩容
if (_data.size() == 0 || _num * 10 / _data.size() >= 7)//当size为0时也要注意需要扩容
{
int newsize = _data.size() == 0 ? 10 : _data.size() * 2;
HashTable<K, V> newtable;//建立一个新表
newtable._data.resize(newsize);//扩容时要使用resize,而不是reserve
for (auto& e : _data)//将旧表中的元素重新映射到新表中对应的位置
{
if (e._status == EXIST)
{
newtable.Insert(e._kv);
}
}
_data.swap(newtable._data);//用新表替换旧表
}
int hashi = kv.first % _data.size();//除留余数法
int index = hashi;
for (int i = 1; _data[index]._status == EXIST; ++i)
{
//二次探测
index = (hashi + i * i) % _data.size();//%_data.size()防止探测时发生越界访问
//index = (hashi + i) % _data.size();//线性探测
}
_data[index]._kv = kv;
_data[index]._status = EXIST;
++_num;
return true;
}
HashData<K, V>* Find(const K& key)//哈希表的元素查找
{
if (_data.size() == 0)
return nullptr;
int hashi = key % _data.size();//除留余数法
int index = hashi, i = 1;
while (_data[index]._status != EMPTY)
{
if (_data[index]._status == EXIST && _data[index]._kv.first == key)
{
return &_data[index];
}
//二次探测
index = (hashi + i * i) % _data.size();//%_data.size()防止探测时发生越界访问
//index = (hashi + i) % _data.size();//线性探测
++i;
if (index == hashi)//防止哈希表中的元素位置状态没有EMPTY,从而进入死循环
return nullptr;
}
return nullptr;
}
bool Erase(const K& key)//哈希表的元素删除
{
HashData<K, V>* del = Find(key);
if (del)
{
del->_status = DELETE;
--_num;
return true;
}
else
{
return false;
}
}
private:
std::vector<HashData<K, V>> _data;
int _num = 0;//记录哈希表中存储元素的个数
};
}
namespace HashBucket
{
template<class K, class V>
struct HashNode
{
HashNode<K, V>* _next;
std::pair<K, V> _kv;
HashNode(const std::pair<K, V>& kv)
:_next(nullptr),
_kv(kv)
{}
};
template<class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
public:
~HashTable()
{
for (auto e : _data)
{
while (e)
{
Node* del = e;
e = e->_next;
delete del;
}
}
}
bool Insert(const std::pair<K, V>& kv)
{
if (Find(kv.first))
{
std::cout << "插入的值重复" << std::endl;
return false;
}
//负载因子为1时扩容
if (_num == _data.size())
{
int newsize = _data.size() == 0 ? 10 : _data.size() * 2;
std::vector<Node*> newtable;//建立一个新表
newtable.resize(newsize, nullptr);//扩容时要使用resize,而不是reserve
for (auto& cur : _data)//将旧表数据重新计算过后插入新表
{
while (cur)
{
//下面是直接移动节点位置插入到新表的对应位置中
int hashi = cur->_kv.first % newtable.size();
//头插
Node* next = cur->_next;
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;
}
}
_data.swap(newtable);//插入玩后交换数据存储新表
}
int hashi = kv.first % _data.size();
Node* newnode = new Node(kv);
//头插
newnode->_next = _data[hashi];
_data[hashi] = newnode;
++_num;
return true;
}
Node* Find(const K& key)
{
if (_data.size() == 0)
return nullptr;
int hashi = key % _data.size();
Node* cur = _data[hashi];
while (cur)
{
if (cur->_kv.first == key)
break;
cur = cur->_next;
}
return cur;
}
bool Erase(const K& key)
{
int hashi = key % _data.size();
Node* cur = _data[hashi], *prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_data[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
std::vector<Node*> _data;
size_t _num = 0;//记录表中有效数据个数
};
}