【C++】Hash闭散列
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
目录
一、哈希的概念
在前面学习了二叉搜索树、AVL树、红黑树之后我们得知顺序结构以及平衡树中元素关键码与其存储位置之间没有对应的关系因此在查找一个元素时必须经过关键码的多次比较。顺序查找的时间复杂度为 O(N)平衡树中为树的高度即(logN)搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法可以不经过任何比较一次直接从表中得到要搜索的元素。
如果构造一种存储结构通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系那么在查找时通过该函数可以很快的找到该元素。
当向该结构中
- 插入元素
根据待插入元素的关键码以此函数计算出该元素的存储位置并按此位置进行存放。
- 搜索元素
对元素的关键码进行同样的计算把求得的函数当作元素的存储位置在结构中按此位置取元素进行比较若关键码相等则搜索成功。
该方法即为哈希(散列)方法哈希方法中使用的转换函数称为哈希(散列)函数构造出来的结构称为哈希表(hash Table)(或称散列表)
使用以上方法插入直接进行映射插入搜索时不必进行关键码的比较因为搜索速度非常快。
问题按照上述哈希方式向集合中插入元素44会出现什么问题
1.1 哈希冲突
对于两个数据元素的关键字其 key1 != key2但是存在 key1 % p == key2 % p此时 key1 和key2 就会被映射到 hash Table 中相同的位置。
假设我们将需要存的数n,存的索引值 = n % 10
现在需要将20存入该表中,计算出的键值为0,但是该位置已经有数据了,即发生了哈希冲突.
此时不同关键字通过相同的哈希函数计算处相同的哈希地址该种现象称为哈希冲突或哈希碰撞。
1.2 哈希函数
引起哈希冲突的一个原因在于哈希函数设计的不够合理。
哈希函数的设计原则
- 哈希函数的定义域必须包括需要存储的全部关键码而如果散列表允许有 m 个地址时其值域必须在 0 到 m-1 之间。
- 哈希函数计算出来的地址能均匀的分布在整个空间中。
- 哈希函数设计应足够简单。
常见的两种哈希函数
1. 直接定址法
取关键字的某个线性函数为散列地址hash(key) = A * Key + B
优点简单、均匀
缺点需要事先知道关键字的分布情况
使用场景适合查找比较小且连续的情况。
2.除留余数法(常用)
设散列表中允许的地址数为 m 取一个不大于 m 但最接近或者等于 m 的质数 p 作为除数按照哈希函数hash(key) = key % p(p<= m)将关键码转化为哈希地址。
就是以下这种方法
1.3 装载因子
如果hash的插入进行映射后如果找不到空位就要一直往后面检测查找空位所以如果当哈希表中只有一个空位时插入一个数据的时间复杂度很可能就变成了O(N)所以说再这种情况发生前我们就要对其进行扩容。
那什么情况下进行扩容呢?应该括多大呢如果是除留余数法那质数 p又应该是多少呢
散列表的装载因子定义为a = 填入表中的元素个数 / 散列表的长度
即。装载因子越小冲突的概率越小空间利用率低装载因子越大冲突的概率越大空间利用率高。
而库中的做法是装载因子如果大于0.7就进行扩容。
二、闭散列
闭散列也叫开放地址法当发生哈希冲突时如果哈希表未被装满说明在哈希表中必然还有空位置那么可以把 key 存放到冲突位置中的"下一个"空位置中去。
寻找下一个空位置方式有线性探测和二次探测接下来我们结合理论并实现一下。
2.1 线性探测
线性探测从发生冲突的位置开始依次向后探测直到寻找到下一个空位置即可。
结构定义
首先我们定义结构我们定义每个位置有三种状态{存在空位删除}一个位置要么是以及有数据了要么是没有数据没什么有一个删除呢
如果出现下面这种情况20在3的后面进行插入将3删除后20便无法查找到所以我们会再添加一个删除状态防止以下这种情况发生。
使用一个数组进行存储其中每一个位置位HashDate类型该类型中会记录当前位置的状态。再添加一个size变量用于记录当前存储的有效数据个数。结构如下
enum State {EMPTY,EXIST,DELETE}; //每个位置有三种状态
template<class K,class V>
struct HashDate
{
pair<K, V> _kv;
State _state;
};
template<class K,class V>
class HashTable
{
public:
//成员函数
private:
vector<HashDate<K, V>> _table;
size_t _size=0;
};
2.2 Insert 插入
- 通过哈希函数(保留余数法)获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素如果该位置中有元素发生哈希冲突使用线性探测找到下一个空位置插入新元素。
接下来有几个问题
1. 确定质数p我们应该取vector的size()还是capacity()作为质数
使用size()因为capacity()是vector开辟的总空间超过size()的部分是不能直接使用的只能使用size()以内的空间而size要通过我们插入数据或resize进行改变。简而言之vector中超过size()小于capacity()的部分我们是不能直接访问的尽管已经开辟。
2.如果线性探测一直探测到 i 下标超过hash_table.size()我们应该如何做。
如果一直探测超过数组的下标应该绕回数组的开始处所以每次 i++ 后我们可以继续进行取模如果超过了size()会自动从数组0下标处开始探测当然使用if判断 i 超过size()超过就置0也是可以的。
3.当装载因子超过0.7之后我们应该怎么做。
即_size / _table.size() >=7 时我们要进行扩容创建一个新哈希表然后将旧表中的数据拷贝到新表中此时我们可以复用 Insert 函数因为新表是不存在扩容问题的所以会使用 Insert 中插入逻辑的代码然后将数据全部插入到新表中最后我们将新表与旧表的进行swap一下就将新表扩容指向的内容交换给了临时变量临时变量调用析构函数自动释放这样扩容问题就得到了解决。
代码如下
bool Insert(const pair<K, V>& kv)
{
//如果 size==0 或装载因子 >=0.7 进行扩容
if (_table.size() == 0 || 10 * _size / _table.size() >= 7)
{
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable<K, V> newHash;
newHash._table.resize(newSize);
//将旧表中的数据拷贝到新表中 --- 复用Insert继续拷贝数据
for (auto e : _table)
{
if (e._state == EXIST)
{
newHash.Insert(e._kv);
}
}
//进行交换 newHash自动调用其析构函数
_table.swap(newHash._table);
}
size_t hashi = kv.first % _table.size();
while (_table[hashi]._state == EXIST) //如果存在数据就一直往后找
{
hashi++;
//如果hashi++超过size()需要绕回数组的开始
hashi %= _table.size();
}
//找到位置插入数据
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_size;
return true;
}
注意如果插入的是负数会发生整形提升int类型会转变为我们的size_t 类型此时负数再进行取模就可以得到一个合法的映射位置也可以被查找的。
接下来还有一个问题如果数据发生冗余怎么办。就是如果插入的是已经存在的值应该如何处理呢
2.3 Find 查找
所以我们可以在插入之前编写一个find函数如果该数据存在则不进行插入。
HashDate<K, V>* Find(const K& key)
{
//判断表为空的情况
if (_table.size() == 0)
return nullptr;
size_t hashi = key % _table.size();
while (_table[hashi]._state != EMPTY)
{
//如果找到key了并且状态不是DELETE
if (_table[hashi]._kv.first == key && _table[hashi]._state != DELETE)
{
return &_table[hashi];
}
hashi++;
//如果超过表的长度则除以表的大小让其回到表头。
hashi %= _table.size();
}
return nullptr;
}
此时有一个问题 我们的循环是_state !=EMPT如果遍历重回到起点这些遍历到的数据_state都为EMPTY就可能导致死循环所以我们还要保存起始位置的状态如果重回起点则也返回false(当然这是一种非常极端的情况但是会出现)。
2.4 Erase删除
删除的思路非常简单如果find查找到该值直接将其对应的state改为DELETE即可。
bool Erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_size;
return true;
}
return false;
}
然后我们测试一下这插入、查找、删除这三个接口。
2.5 插入复杂类型
那如果我们想实现一个统计次数的哈希表则 key 值是string类型的怎么办呢string类型或字符串类型是无法被取模的。那再如果我们想插入一个自己定义的复杂类型呢
我们先来看看STL库中是如何解决这个问题的。
所以我们要编写默认的hash取key的仿函数作为缺省参数。
在任何进行取模的地方我们要让key值调用默认的仿函数取出key值。
所以我们可以自定义编写将string类型(或其它复杂类型)转化为 size_t 类型的仿函数
struct HashFuncString
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
val += ch;
return val;
}
};
然后我们将测试代码跑起来
void test_hash04_string()
{
string arr[] = { "苹果","西瓜","菠萝","草莓","菠萝","草莓" ,"菠萝","草莓"
, "西瓜", "菠萝", "草莓", "西瓜", "菠萝", "草莓","苹果" };
HashTable<string,int, HashFuncString> countHT;
for (auto& str : arr)
{
auto ptr = countHT.Find(str);
if (ptr)
ptr->_kv.second++;
else
countHT.Insert({ str,1 });
}
countHT.Print();
}
但是在库中的unordered_map并不需要我们自己传入仿函数因为string是一个挺常见的类型库中使用了模板的特化对string类型进行了特殊处理我们接下来也将其进行改动为特化的形式。
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
val += ch;
return val;
}
};
此时不传入自定义的仿函数一样可以成功运行
其实我们模拟实现的string->int的仿函数写的并不规范因为key值是唯一的如果出现以下这种情况即使key值是不同的但是通过仿函数计算后的映射却是相同的。比如
所以我们要对字符串的Hash函数进行特殊处理这里有一篇博客进行了详细的讲解字符串Hash函数对比这里我就直接使用(BKDR)的规则进行改写了
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
val = val * 131 + ch;
return val;
}
};
线性探测就实现完了接下来就是改造为二次探测的实现
2.6 二次探测
二次探测不是指探测两次而是 i 的指数方进行探测。
如下是使用线性探测和二次探测插入同一组数据的插入结果如下
然后我们在线性探测的方式上进行改动
三、源代码与测试用例
3.1 hash源代码
enum State { EMPTY, EXIST, DELETE }; //每个位置有三种状态
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;
State _state= EMPTY;
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
val = val * 131 + ch;
return val;
}
};
template<class K, class V,class Hash=HashFunc<K>>
class HashTable
{
public:
bool Insert(const pair<K, V>& kv)
{
//如果表中已经存在该数据
if (Find(kv.first)) return false;
//如果 size==0 或装载因子 >=0.7 进行扩容
if (_table.size() == 0 || 10 * _size / _table.size() >= 7)
{
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable<K, V, Hash> newHash;
newHash._table.resize(newSize);
//将旧表中的数据拷贝到新表中 --- 复用Insert继续拷贝数据
for (auto e : _table)
{
if (e._state == EXIST)
{
newHash.Insert(e._kv);
}
}
//进行交换 newHash自动调用其析构函数
_table.swap(newHash._table);
}
Hash hash;
size_t hashi = hash(kv.first) % _table.size();
while (_table[hashi]._state == EXIST) //如果存在数据就一直往后找
{
hashi++;
//如果hashi++超过size()需要绕回数组的开始
hashi %= _table.size();
}
//找到位置插入数据
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_size;
return true;
}
HashDate<K,V>* Find(const K& key)
{
//判断表为空的情况
if (_table.size() == 0)
return nullptr;
Hash hash;
size_t hashi = hash(key) % _table.size();
while (_table[hashi]._state != EMPTY)
{
//如果找到key了并且状态不是DELETE
if (_table[hashi]._kv.first == key && _table[hashi]._state!=DELETE)
{
return &_table[hashi];
}
hashi++;
//如果超过表的长度则除以表的大小让其回到表头。
hashi %= _table.size();
}
return nullptr;
}
bool Erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_size;
return true;
}
return false;
}
void Print()
{
for(int i=0;i< _table.size();i++)
{
if (_table[i]._state == EXIST)
cout <<"i:" <<i<<" [" << _table[i]._kv.first << " " << _table[i]._kv.second <<"]" << endl;
}
}
private:
vector<HashDate<K, V>> _table;
size_t _size=0;
};
3.2 测试用例
void test_hash01()
{
HashTable<int, int> Hash;
int a[] = { 1,11,4,15,26,7};
for (auto e : a)
{
Hash.Insert(make_pair(e, e));
}
Hash.Print();
cout << endl;
}
void test_hash02()
{
HashTable<int, int> Hash;
int a[] = { 1,11,4,15,26,7,13,5,34,9 };
for (auto e : a)
{
Hash.Insert(make_pair(e, e));
}
Hash.Print();
cout << endl;
}
void test_hash03()
{
HashTable<int, int> Hash;
int a[] = { 1,11,4,15,26,7,13,5,34,9 };
for (auto e : a)
{
Hash.Insert(make_pair(e, e));
}
Hash.Print();
cout << endl<<"find:"<<endl;
cout << (Hash.Find(11)->_kv).first << endl;
cout << (Hash.Find(4)->_kv).first << endl;
cout << (Hash.Find(5)->_kv).first << endl;
cout << (Hash.Find(34)->_kv).first << endl;
cout << "Erase:" << endl;
Hash.Erase(11);
cout << Hash.Find(11) << endl;
}
void test_hash04_string()
{
string arr[] = { "苹果","西瓜","菠萝","草莓","菠萝","草莓" ,"菠萝","草莓"
, "西瓜", "菠萝", "草莓", "西瓜", "菠萝", "草莓","苹果" };
HashTable<string,int> countHT;
for (auto& str : arr)
{
auto ptr = countHT.Find(str);
if (ptr)
ptr->_kv.second++;
else
countHT.Insert({ str,1 });
}
countHT.Print();
}
void test_hash05_string()
{
HashFunc<string> hash;
cout << hash({ "abc" }) << endl;
cout << hash({ "bac" }) << endl;
cout << hash({ "cba" }) << endl;
cout << hash({ "bbb" }) << endl;
}