前言
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序结构查找时间复杂度为O(N),平衡树查找时间复杂度为O(logN),搜索的效率取决于搜索过程中元素的比较次数。
有一种理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
一:哈希
哈希是一种新的搜索方式,哈希会通过某种函数将元素的存储位置与它的关键码之间建立一一映射的关系,这样我们可以不经过任何比较,一次直接从表中得到要搜索的元素。哈希方法中使用的转换函数称为哈希函数(散列函数),构造出来的结构称为哈希表(散列表)。
- 插入元素: 根据待插入元素的关键码,以哈希函数计算出该元素的存储位置并按此位置进行存放。
- 搜索元素: 根据待搜索元素的关键码,以哈希函数计算出该元素的存储位置并在此位置取元素比较元素关键码。
1.1 哈希冲突
不同的关键码通过相同的哈希函数计算出相同的哈希地址
我们把具有不同关键码而具有相同哈希地址的数据元素称为 ”同义词“,哈希冲突越多,搜索效率就会越低。
我们该怎样解决哈希冲突呢?在下面我们会详细讲解。
1.2 哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理
常见的哈希函数:
- 直接定址法: 取关键码的某个线性函数为哈希地址。简单均匀,但需要事先知道关键码的分布情况,若数据分布不均匀则可能会浪费较多空间,直接定制法适合查找关键码比较小并且连续的场景。(不存在哈希冲突)
- 除留余数法: 在限定大小的空间中将关键码进行映射,依据index = key % capacity将关键码转为哈希地址。(可能会导致哈希冲突)
1.3 哈希表
哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。
也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。 这个映射函数叫做哈希函数,存放记录的数组叫做哈希表。
二:哈希冲突的解决
解决哈希冲突两种常见的方法是:闭散列和开散列。
2.1 闭散列(开放定址法)
当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的下一个空位置中去。
那么该如何找到哈希表中的下一个空位置呢?
2.1.1 线性探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
插入:
- 通过哈希函数获取待插入元素在哈希表中的位置。
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
删除:
采用闭散列解决哈希冲突时,不能随意物理删除哈希表中已有的元素,否则可能会影响其它元素的搜索(比如直接物理删除元素4,44查找起来也会受影响)。
因此线性探测采用标记的伪删除法来删除一个元素,哈希表的每个空间都有标志,删除将标志位置为DELETE。
代码实现:
enum State{
EMPTY,
EXITS,
DELETE,
};
template<class T>
struct HashData{
HashData(const T& data = T(), const State& state = EMPTY)
: _data(data)
, _state(state)
{}
T _data;
State _state;
};
// unordered_set<K> -> HashTable<K, K>
// unordered_map<K, V> -> HashTable<K, pair<K, V>>
template<class K,class T, class KeyOfT>
class HashTable{
public:
typedef HashData<T> HashData;
bool Insert(const T& val){
// 负载因子 = 表的数据 / 表的大小(空间换时间)
// 衡量哈希表满的程度
// 哈希表并不是满了才增容
// 哈希表越接近满,冲突的概率就越大,效率就越低
// 负载因子越小,冲突概率越小,效率提高的同时,伴随着空间的浪费。
if (_tables.size() == 0 || _num*10 / _tables.size() >= 7){
// 增容(直接将原表数据拷贝下来会造成表容量增大,而原有的映射关系发生改变的问题)
// 1. 开一个二倍的空间
HashTable<K,T,KeyOfT> newHT;
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
newHT._tables.resize(newsize);
for (size_t i = 0; i < _tables.size(); i++){
if (_tables[i]._state == EXITS){
newHT.Insert(_tables[i]._data);
}
}
_tables.swap(newHT._tables);
// 2. 将旧表的数据重新映射到新表
// 3. 释放旧表的空间
}
KeyOfT koft;
// 计算val中的key在表中映射的位置
size_t index = koft(val) % _tables.size();
while (_tables[index]._state == EXITS){
if (koft(_tables[index].data) == koft(val)){
return false;
}
++index;
if (index == _tables.size()){
index = 0;
}
}
_tables[index]._data = val;
_tables[index]._state = EXITS;
_num++;
}
HashData* Find(const K& key){
KeyOfT koft;
// 计算val中的key在表中映射的位置
size_t index = key % _tables.size();
while (_tables[index]._state != EMPTY){
if (koft(_tables[index]._data) == key){
if (_tables[index]._state == EXITS){
return &_tables[index];
}
else if (_tables[index]._state == DELETE){
return nullptr;
}
}
++index;
if (index == _tables.size()){
index = 0;
}
}
return nullptr;
}
bool Erase(const K& key){
HashData* ret = Find(key);
if (ret){
ret->_state == DELETE;
--_num;
return true;
}
else{
return false;
}
}
private:
vector<HashData> _tables;
size_t _num = 0; // 存储有效数据的个数
};
优缺点:
- 优点:实现非常的简单
- 缺点:哈希冲突连在一起造成数据的堆积,不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
2.1.2 二次探测
二次探测:从发生冲突的位置开始以二次方间隔向后探测,找到空位置为止。
代码实现:
bool Insert(const T& d){
KeyOfT koft;
// 二次探测
// 计算d中的key在表中映射的位置
size_t start = koft(d) % _tables.size();
size_t index = start;
int i = 1;
while (_tables[index]._state == EXITS){
if (koft(_tables[index]._data) == koft(d)){
return false;
}
index = start + i*i;
++i;
index %= _tables.size();
}
_tables[index]._data = d;
_tables[index]._state = EXITS;
_num++;
return true;
}
二次探测使得冲突的数据相对更为分散,可以初步缓解哈希冲突导致的数据堆积问题。
闭散列没有从根源解决哈希冲突,是一种抢占式的插入法则,有可能会造成空间的浪费并且效率会降低。
2.2 开散列(哈希桶)
对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。开散列中每个桶中放的都是发生哈希冲突的元素。
当大量的数据冲突时,这些发生冲突的数据就会放到同一个链式桶中,查找时效率就会随着桶中数据的增多而降低。
哈希桶如何控制哈希冲突呢?
哈希桶通过负载因子控制哈希冲突,哈希桶中的负载因子可以高一些,一般控制到1。
代码实现:
#include<iostream>
#include<vector>
using namespace std;
// 结点结构体
template<class T>
struct Hash_Node{
T _data;
Hash_Node<T>* _next;
Hash_Node(const T& data)
:_data(data)
, _next(nullptr)
{}
};
template<class K,class T,class KeyOfT>
class Hash_Table{
typedef Hash_Node<T> Node;
private:
bool Insert(const T& data){
// 计算关键码在vector中的位置
KeyOfT koft;
// 如果负载因子=1,则增容,避免大量的哈希冲突
if (_tables.size() == _nums){
vector<Node*> newtables;
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
newtables.resize(newsize);
for (size_t i = 0; i < _tables.size(); i++){
// 将旧表中的结点取下,计算在新表中的位置
Node* cur = _tables[i];
while (cur){
Node* next = cur->_next;
size_t index = koft(cur->_data) % newtables.size();
cur->_next = newtables[index];
newtables[index] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
size_t index = koft(data) % _tables.size();
// 寻找结点存在不存在
Node* cur = _tables[index];
while (cur){
if (koft(cur->_data) == koft(data)){
return false;
}
else{
cur = cur->_next;
}
}
// 头插
Node* newnode = new Node(data);
newnode->_next = _tables[index];
_tables[index] = newnode;
++_nums;
return true;
}
Node* Find(const K& key){
KeyOfT koft;
size_t index = key % _tables.size();
Node* cur = _tables[index];
while (cur){
if (koft(cur->_data) == key){
return cur;
}
else{
cur = cur->_next;
}
}
return nullptr;
}
bool Erase(const K& key){
// 计算key在表中位置再寻找桶
KeyOfT koft;
size_t index = key % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[index];
while (cur){
// 单链表的删除就要找到被删除结点的前一个结点
if (koft(cur->_data) == key){
if (prev == nullptr){
// 此时删除的是第一个结点
_tables[index] = cur->_next;
}
else{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else{
prev = cur;
cur = cur->_next;
}
}
return false;
}
vector<Node*> _tables;// 数组中存放结点的指针
size_t _nums = 0; // 记录表中存储数据的个数
};
假设总是有一些桶挂的数据很多,哈希冲突比较严重,该怎么解决?
一个桶链的长度超过一定值,就将挂链表改为挂红黑树。(Java HashMap长度超过8)