哈希
目录
线性探测的缺陷是产生冲突的数据堆积在一块,相比线性探测的好处,如果一个位置有很多值映射,冲突剧烈,那么它们存储时相对会比较分散,不会引发一片一片的冲突
哈希的概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。我们在顺序查找中可以得知时间复杂度为O(N),平衡树中为树的高度,即O(log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该哈希结构中:
- 插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
- 搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表
(Hash Table)(或者称散列表)
那么,例如我们有数据{1,3,7,4,8};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法可以避免对关键字的比较,可以达到快速搜索。但是,如果我们此时再插入44,44%10得到的同样是4,但是位置已经被占用,所以引出了以下的问题:
哈希冲突
对于两个数据元素的关键字 和 (i != j),有 != ,但有:Hash( ) == Hash( ),即:不同关键字通过
相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?
常见哈希函数:
直接定址法
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函
数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
哈希冲突的解决:
闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
方法:
线性探测:index+i (i = 1,2,3,4…)
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
二次探测:index+i^2(i = 1,2,3,4…)
线性探测的缺陷是产生冲突的数据堆积在一块,相比线性探测的好处,如果一个位置有很多值映射,冲突剧烈,那么它们存储时相对会比较分散,不会引发一片一片的冲突
开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
闭散列的实现
当我们实现哈希表时,数据是怎么存储呢?数据的结构又是什么呢?数据的存储很好想到,可以用一个vector线性表存就好了,那么数据的结构除了存储数据还需要什么呢?比如我们有以下数据:
当我们查找一个值时,我们通过哈希函数求得位置,如果对应位置不是,那么很可能是遇到了冲突,所以我们需要继续往后查找,遇到空位置时说明这个值是不存在的,因为如果存在,肯定存储在了第一个空位置,那么当我们删除一个值,直接删除的话这个位置变成了空,但是这样有问题:使得查找一个值提早遇到空,那么就找不到这个值。
就比如我们删除333,当我们查找14时,14%10=4,我们锁定到了4的位置,但是由于删除333后,4的位置是空的,那么就会形成一个14存在,却无法查找的到的局面,
所以我们需要通过枚举来引入三个状态,每个位置存储值得同时再存储一个状态标记:空、满、删除。
三种状态:
enum Status
{
EXIST,//存在
EMPTY,//空
DELETE//删除
};
数据的存储结构
我们使用KV模型
template<class K,class V>
struct HashData
{
Status _status;//当前状态
pair<K, V> _kv;
};
哈希表结构
vector用来存储HashData,_n则用来记录表中的有效数据个数:
template<class K,class V>
class HashTable
{
public:
private:
vecor<HashData<K, V>> _tables;
size_t _n=0;
};
哈希表的插入
我们为了避免哈希冲突变多,我们引入一个负载因子,当负载因子过大时需要对哈希表进行增容。
- 如果哈希表中已经存在该键值对,则插入失败返回false
- 判断该哈希表的大小以及负载因子,确定是否需要增容
- 将键值对插入哈希表
- ++_n
当我们使用线性探测时,会发现产生冲突的数据会堆积在一起,连续位置比较多,会引发踩踏洪水效应,所以在这里我们使用了二次探测。
我们解决增容的问题,首先我们确定的是_table.size() == 0时需要增容,其次我们设置负载因子=有效数据/表的大小,如果负载因子大于0.7时就增容。那么如何增容呢?
我们先创建一个临时的新表,将新表容量开扩到新的容量,此时复用Insert函数来将旧表的内容进入到新表中,最后再将新表与旧表交换。
if (_tables.size() == 0 || (double)(_n / _tables.size() >= 0.7))
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newHT;//开辟一个新表
newHT._tables.resize(newsize);
for (auto& e : _tables)
{
if (e._status == EXIST)
{
newHT.Insert(e._status);//复用Insert
}
}
_tables.swap(newHT._tables);//旧表与新表的交换
}
插入哈希表的代码(首先计算出插入的位置,如果该位置存在的话就遍历后面的位置,遍历的过程中如果等于的表的末尾,则返回起始位置继续遍历,直到遇到空或者删除):
size_t start = kv.first % _tables.size();
size_t i = 0;
size_t index = start + i;
while (_tables[index]._status == EXIST)
{
++i;
index = start + i*i//二次探测;
index %= _tables.size();
}
_tables[index]._kv = kv;
_tables[index]._status = EXIST;
++_n;
下面为插入的完整代码:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first)
{
return false;
}
if (_tables.size() == 0 || (double)(_n / _tables.size()) > 0.7)
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newHT;
newHT._tables.resize(newsize);
for (auto& e : _tables)
{
if (e._status == EXIST)
{
newHT.Insert(e._status);
}
}
_tables.swap(newHT._tables);
}
size_t start = kv.first % _tables.size();
size_t i = 0;
size_t index = start + i;
while (_tables[index]._status == EXIST)
{
++i;
index = start + i*i//二次探测;
index %= _tables.size();
}
_tables[index]._kv = kv;
_tables[index]._status = EXIST;
++_n;
return true;
}
哈希的查找
哈希表的查找逻辑很简单:先计算出查找的key的位置,当这个位置不等于空时,判断key值是不是相等并且状态位为存在,如果是的话返回,不是的话进行线性探测或者二次探测,直到遇到位置为空状态。
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t start = key % _tables.size();
size_t i = 0;
size_t index = start + i;
while (_tables[index]._status != EMPTY)
{
if (_tables[index]._kv.first == key && _tables[index]._status == EXIST)
{
return &_tables[index];
}
else
{
++i;//通过二次探测去查找
index = start + i*i;
index %= _tables.size();
}
}
return nullptr;
}
哈希的删除
通过查找来找到这个点,如果找到则状态设置为删除,并且_n--。
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_status = DELETE;
_n--;
return true;
}
}
哈希初次全部代码:
enum Status
{
EXIST,
EMPTY,
DELETE
};
template<class K,class V>
struct HashData
{
Status _status=EMPTY;//设置一个缺省值
pair<K, V> _kv;
};
template<class K,class V>
class HashTable
{
public:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first)
{
return false;
}
if (_tables.size() == 0 || (double)(_n / _tables.size()) > 0.7)
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newHT;
newHT._tables.resize(newsize);
for (auto& e : _tables)
{
if (e._status == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
size_t start = kv.first % _tables.size();
size_t i = 0;
size_t index = start + i;
while (_tables[index]._status == EXIST)
{
++i;
index = start + i*i//二次探测;
index %= _tables.size();
}
_tables[index]._kv = kv;
_tables[index]._status = EXIST;
++_n;
return true;
}
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t start = key % _tables.size();
size_t i = 0;
size_t index = start + i;
while (_tables[index]._status != EMPTY)
{
if (_tables[index]._kv.first == key && _tables[index]._status == EXIST)
{
return &_tables[index];
}
else
{
++i;
index = start + i*i;
index %= _tables.size();
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_status = DELETE;
_n--;
return true;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n=0;
};
我们对这段代码进行测试时,我们会发现一些问题:如果是key是string,上面的代码是编不过去的,因为key是string,无法计算位置,所以此时就用到了仿函数,仿函数通过重载方法将string转化为整形用来做key,这里运用了BKDR Hash思想来完成string到key的转换,发生冲突的概率很小。
template<class K>
struct Hash
{
size_t operator()(const K&key)
{
return key;
}
};
struct HashStr
{
size_t operator()(const string& str)
{
//BKDR Hash思想
size_t value = 0;
for(size_t i = 0;i<str.size();++i)
{
value*=131;
value += str[i];//转成整形
}
return value;
};
如果我们写成这样我们创建string为key的哈希表的时候就需要这样写,将HashFuncString仿函数传进去,初始化时则为:
HashTable<string, string, HashStr> dict;
但我们查看unordered_map底层时是不需要传这个仿函数进去的,而且我们如果这样写会需要进行string和其他的区分,所以我们这里进行了模板的特化:
template<class K>
struct Hash
{
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct Hash<string>
{
size_t operator()(const string& str)
{
//BKDR
size_t value = 0;
for (int i = 0; i < str.size(); i++)
{
value *= 31;
value += str[i];
}
return value;
}
};
同时哈希表的木板参数也变化为:
template<class K,class V,class HashFunc=Hash<K>>
不传仿函数过去,它会自己推演,是K是int类型就调用Hash<int>,是string就调用更匹配的Hash<string>。
哈希闭散列的完整代码
enum Status
{
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
Status _status = EMPTY;
};
template<class K>
struct Hash
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template<>
struct Hash < string >
{
size_t operator()(const string& s)
{
// BKDR
size_t value = 0;
for (auto ch : s)
{
value *= 31;
value += ch;
}
return value;
}
};
template<class K, class V, class HashFunc = Hash<K>>
class HashTable
{
public:
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
--_n;
ret->_status = DELETE;
return true;
}
}
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
HashFunc hf;
size_t start = hf(key) % _tables.size();
size_t i = 0;
size_t index = start;
// 线性探测 or 二次探测
while (_tables[index]._status != EMPTY)
{
if (_tables[index]._kv.first == key && _tables[index]._status == EXIST)
{
return &_tables[index];
}
++i;
index = start + i*i;
index %= _tables.size();
}
return nullptr;
}
bool Insert(const pair<K, V>& kv)
{
HashData<K, V>* ret = Find(kv.first);
if (ret)
{
return false;
}
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
// 扩容
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V, HashFunc> newHT;
newHT._tables.resize(newSize);
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._status == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
HashFunc hf;
size_t start = hf(kv.first) % _tables.size();
size_t i = 0;
size_t index = start;
// 线性探测 or 二次探测
while (_tables[index]._status == EXIST)
{
++i;
//index = start + i*i;
index = start + i*i;
index %= _tables.size();
}
_tables[index]._kv = kv;
_tables[index]._status = EXIST;
++_n;
return true;
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 有效数据个数
};
开散列
开散列的概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
数据的存储结构
因为桶里面是单链表结构,所以需要有一个指向下一个节点的指针_next,还有一个pair来存储数据。
template<class K,class V>
struct HashNode
{
HashNode(const pair<K,V>& kv)
:_kv(kv),_next(nullptr)
{}
pair<K, V> _kv;
HashNode<K, V>* _next;
};
哈希表的结构
vector里面需要存储Node*而不是Node,如果是Node会出现二义性:存节点了还是没有存,因为定义对象出现时节点都申请出来了,都进行了初始化。所以这里需要存储Node*,指向第一个节点。
class HashTable
{
typedef HashNode<K, V> Node;
private:
size_t _n = 0;//哈希表中的有效数据
vector<Node*> _tables;
};
上面的闭散列我们提到了哈希冲突,那么开散列是如何解决的呢?
开散列是通过单链表链接分方式处理哈希冲突的,所以并不需要给每个节点设置状态,只需要将哈希地址相同的元素都放到同一个哈希桶里以单链表链接,开散列可以无限的插入,如果表的大小太小,那么每个桶中的单链表就会链接的节点越多,这样找一个节点时效率就会变低,所以开散列我们也要通过负载因子来判断是否增容,通过增容,每个哈希桶的链接的元素相对变少,效率也就好了些,所以我们需要一个_n变量来记录哈希表中的有效数据,来计算负载因子。
注意:负载因子:数据个数/表的大小。负载因子越低,冲突的概率越低,空间浪费越高,负载因子越高,冲突的概率越高,空间浪费越低。
哈希表的插入
哈希表的插入过程:
- 对该键值对查找,如果哈希表中存在该键值对,那么久返回false。
- 判断哈希表的大小和负载因子,来判断是否需要增容。
- 通过单链表形式将该键值对插入。
- _n++。
首先当我们遇到扩容问题的时候,扩容问题是有两种情况的,一个为_tables.size()==0时,这是需要扩容的情况。还有当我们的负载因子为1时,需要扩容。此时我们可以直接选择扩容二倍。
此时的表已经发生变化,所以需要重新计算映射位置,这会导致计算量的增加,会造成十分混乱。
所以我们需要开辟一个新表,该表是原哈希表的两倍,之后遍历原有的哈希表,重新插入新表中,之后将旧表与新表交换。
当我们将旧表数据重新插入到新表时,如果我们像闭散列那样复用insert,,我们会发现会创建重复的节点,把已经存在的节点再创建一遍,到最后又要将旧表中和新表中重复的节点释放一次,这是因为闭散列我们是存储的数据,而我们的开散列储存的则是指针。
所以我们只需要通过遍历旧哈希表的哈希桶,将旧表哈希桶的数据头插到新表的哈希桶中,然后将旧表的头节点的next置为空,最后新表和旧表交换即可。
bool Insert(const pair<K, V>& kv)
{
Node* ret = Find(kv.first);
if (ret)
return false;
// 负载因子 == 1时扩容
HashFunc hf;
if (_n == _tables.size())
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
// ...
vector<Node*> newTables;
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 = hf(cur->_kv.first) % newSize;
//头插
cur->_next = newTables[index];
newTables[index] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t index = hf(kv.first) % _tables.size();
Node* newnode = new Node(kv);
// 头插
newnode->_next = _tables[index];
_tables[index] = newnode;
++_n;
return true;
}
哈希表的查找
- 如果表的大小等于0,返回nullptr
- 找到桶的位置,然后进行遍历桶,比较值是否相等,相等则返回,不相等继续下一个节点
- 遍历完没找到返回nullptr
Node* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t index = key % _tables.size();
Node* cur = _tables[index];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
else
{
cur = cur->_next;
}
}
return nullptr;
}
哈希表的删除
- 如果表的大小为0,返回false
- 找到桶的位置,需要注意删除节点需要保存节点的前一个节点,遍历桶找要删除的节点进行删除,需要注意头删和非头删,头删时prev为空,非头删prev不为空
- 删除完成–_n,返回true,否则返回false
bool Erase(const K& key)
{
if (_tables.size() == 0)
{
return false;
}
size_t index = key % _tables.size();
Node* prev = nulltr;
Node* cur = _tables[index];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nulltr)
{
_tables[index] = cur->_next;
delete cur;
}
else
{
prev->_next = cur->_next;
delete cur;
}
--_n;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
开散列和闭散列一样,都需要通过仿函数处理string的情况:
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template<>
struct HashFunc < string >
{
size_t operator()(const string& key)
{
// BKDR Hash思想
size_t hash = 0;
for (size_t i = 0; i < key.size(); ++i)
{
hash *= 131;
hash += key[i];
}
return hash;
}
};
哈希桶的析构函数
~HashTable()
{
for(size_t i = 0;i<_tables.size();i++)
{
Node* cur = _tables[i];
while(cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
tables[i] = nullptr;
}
_n = 0;
}
开散列完整代码
namespace LinkHash
{
template<class K,class V>
struct HashNode
{
HashNode(const pair<K,V>& kv)
:_kv(kv),_next(nullptr)
{}
pair<K, V> _kv;
HashNode<K, V>* _next;
};
template<class K>
struct Hash
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template<>
struct Hash < string >
{
size_t operator()(const string& s)
{
// BKDR
size_t value = 0;
for (auto ch : s)
{
value *= 31;
value += ch;
}
return value;
}
};
template<class K, class V, class HashFunc = Hash<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
Node* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t index = key % _tables.size();
Node* cur = _tables[index];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
else
{
cur = cur->_next;
}
}
return nullptr;
}
bool Erase(const K& key)
{
if (_tables.size() == 0)
{
return false;
}
size_t index = key % _tables.size();
Node* prev = nulltr;
Node* cur = _tables[index];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nulltr)
{
_tables[index] = cur->_next;
delete cur;
}
else
{
prev->_next = cur->_next;
delete cur;
}
--_n;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
bool Insert(const pair<K, V>& kv)
{
Node* ret = Find(kv.first);
if (ret)
return false;
// 负载因子 == 1时扩容
HashFunc hf;
if (_n == _tables.size())
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
// ...
vector<Node*> newTables;
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 = hf(cur->_kv.first) % newSize;
//头插
cur->_next = newTables[index];
newTables[index] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t index = hf(kv.first) % _tables.size();
Node* newnode = new Node(kv);
// 头插
newnode->_next = _tables[index];
_tables[index] = newnode;
++_n;
return true;
}
private:
vector<Node*> _tables;
size_t _n = 0; // 有效数据的个数
};
}