c++系列哈希的原理及实现(上)
文章目录
前言
红黑树平衡树和哈希有不同的用途。
红黑树、平衡树这类数据结构是有序的数据结构,它们可以高效地进行范围查询,比如查找一个区间内的值。在需要保持数据有序存储,并且频繁进行插入、删除和查找操作的场景下很有用,像数据库索引的实现就可能会用到。
而哈希主要用于快速的数据查找。它通过一个哈希函数把数据映射到一个特定的位置,理想情况下,查找操作可以在常数时间复杂度内完成,也就是时间复杂度为O(1)。在只需要快速判断某个元素是否存在的场景下,哈希就非常合适。所以学了红黑树等平衡树之后还需要学哈希,是因为它们解决的是不同类型的问题。
一、哈希的概念
首先我们要知道顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。
而我们想要的是可以不经过任何比较,一次直接从表中得到要搜索的元素。
下面我们看一个小问题:
有这样一个由小写字母组成的字符串"abcdef"我们需要查找某一个字符是否存在,这时我们就可以将他映射到一个数组中,要查找字符是否存在,我们只需要利用这给映射方法,判断它对应数组中的值是否为1即可。
c
string s = "abcdef";
int arr[26] = {0};//开辟空间初始化为0
for (auto ch : s)
{
arr[ch - 'a'] = 1;
}
这就是利用哈希思想,哈希就是一种特殊的存储结构,通过特定的函数(方法),使得数据的存储位置与它的关键码之间建立一种一一映射的关系,这样在查找数据时就可以直接通过关键值来快速查找,这个函数也称为映射函数。
再来看一个例子:
对于这样一组数据{1,2,4,5,7,6},我们要将他映射到一个数组中就可以使用直接映射,即:
而如果数据的范围较大如:{1,2,3,4,7,6,9999999},这样一组数据,如果使用直接映射,就要开辟足够大的空间,这样开空间浪费的有点离谱了,这时我们就需要给它提供一个方法,将数据控制在一定的范围,所以就有了,除留余数法 。我们将这个方法封装为一个函数,这个函数就称为映射函数 。
除留余数法:
我们将待存入数,对开辟空间大小,进行取余,得到的余数,作为下标,利用下标将带存入数存入到空间中。(待存入数据,我们称为关键字,余数我们称为,存储位置)
c
size_t hashFunc(size_t key)
{
size_t i=key%capacity;
return i;
}
下面我们再思考一个问题:
如果我们想再存储一个3,通过3进行哈希映射发现,家被偷了,该怎么办呢?
我们需要的位置已经存在值,这种问题称为,哈希冲突。我们先来对上面进行一下总结再来解决这个问题。
总结:
映射关系 :
1、直接定址法(直接映射):适合数据范围小,数据量小,没有重复值的数据。不存在哈希冲突。
2、除留余数法:适合数据范围大,数据量可以大。存在哈希冲突。
二、哈希冲突
哈希冲突指的是在使用哈希表进行数据存储和查找时,不同的关键字通过哈希函数计算得到了相同的哈希值(存储位置)。
哈希函数是将关键字映射到哈希表中的某个位置的函数。由于哈希表的存储空间是有限的,而可能的关键字数量是无限的,所以不同的关键字有可能被映射到相同的位置,这就产生了哈希冲突。
哈希冲突会影响哈希表的性能,比如增加查找、插入等操作的时间复杂度。
解决哈希冲突有两种常见的方法:
- 闭散列:也叫开放寻址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的"下一个" 空位置中去。
- 开散列:也叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
这次我们先介绍闭散列法
三、哈希冲突解决
3.1、开放寻址法
开放定址法是解决哈希冲突的一种方法,其基本思想是当发生冲突时,通过某种系统的方法在哈希表中寻找下一个空槽位,并将冲突的关键码存储在这个槽位中。常用的方法有两种:
1、线性探测:当发生哈希冲突时,从映射位置开始,向后按顺序查找,直到找到下一个空位。
2、二次探测:从映射位置开始,依次增加1、4、9...,探测距离是i^2的倍数,i为从零开始自增的整数。
线性探测例子:
如果我们想将11 插入哈希表中,通过哈希函数得到的存储位置已经被占用,我们就从当前位置开始,依次向后查找空位置。
这种解决哈希冲突的方式,又给我们带来了一个麻烦。
大家思考一下,当我们要查找1这个元素是否存在哈希表中,我们该如何来查找呢?解铃还须系铃人,肯定第一时间就想到利用哈希映射,找到这个值的存储位置,然后比较哈希表中的值,是否等于要查找的值。这个问题是很简单的,那么我们又该如何查找11 呢?显然简单的使用哈希函数映射得到到值是无法找到的,这时我们就需要判断它后面是否还有值,如果有,我们就将他与后面存储值继续比较,直到找到,或者遇到值为空的位置。
3.2、删除操作
接着上面的来讲,如果我们要将2删除,是否可以直接将它所在位置,制为空。如果我们这样做了那么上面查找11的操作,该怎么来完成呢?这时我们就需要一个方式将存储位置的状态进行标记,我们将删除位置标记为删除、已有数据位置,标记为存在,未存储数据位置设置为空。这样我们在查找元素时,跳过删除位置,直至空或找到终止程序。
3.3、负载因子
在我们向哈希表中不断映射数据时,发生哈希冲突的概率会随数据量的增大而提高,这会导致我们插入、查找效率大幅度下降,这时我们就需要对哈希表进行扩容操作。那么什么时候扩容呢,总不能等到哈希表存满,哈希冲突最多的时候再进行扩容吧!!!!这时就有了负载因子,来作为我们判断是否扩容的阈值,这个负载因子是使用已插入元素除以哈希表大小。那么当这个负载因子多大时我们对哈希表进行扩容操作呢?这个没有具体要求,但是我们要知道,如果设置太小,会产生空间浪费,设置太大就会发生较多的哈希冲突,所以我们也不能设置的太离谱,在接下来的代码实现中,我将他设置为0.7。
四、代码实现
具体操作及原理,上面已经讲解过了,由于这个结构实现起来还是很简单的,大家在看代码时结合注释及上文。
c
//使用枚举标识哈希表状态
enum status {
EMPTY,
EXIST,
DELETE
};
template<class k, class v>
struct hashdate {
pair<k, v>_kv;
status _s;
};
//使用模板
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 cout = 0;
for (int i = 0; i < key.size(); i++)
{
cout += key[i];
}
return cout;
}
};
//缺省值为哈希函数(仿函数),可跟据需要自己提供
template<class k, class v,class Hash=HashFunc<k>>
class hashTable {
Hash hash;
public:
hashTable()
{
_table.resize(10);
}
bool Insert(const pair<k, v>& kv)
{
if (Find(kv))
{
return false;
}
//负载因子设置为0.1因为隐式这里因为会发生隐式类型转换,特殊处理一下
if (_n * 10 / _table.size() == 7)
{
size_t newsize = _table.size() * 2;
hashTable<k, v> Newhs;
Newhs._table.resize(newsize);
for (int i = 0; i < _table.size(); i++)
{
if (_table[i]._s == EXIST)
{
Newhs.Insert(_table[i]._kv);
}
}
_table.swap(Newhs._table);
}
size_t hashi = hash(kv.first) % _table.size();
while (_table[hashi]._s == EXIST)
{
hashi++;
hashi %= _table.size();
}
_table[hashi]._kv = kv;
_table[hashi]._s = EXIST;
_n++;
return true;
}
//查找函数
hashdate<k,v>* Find(const pair<k, v>& kv) {
size_t hsi = hash(kv.first) % _table.size();
while (_table[hsi]._s != EMPTY)
{
if (_table[hsi]._s == EXIST && _table[hsi]._kv.first == kv.first)
{
return &_table[hsi];
}
hsi++;
}
return NULL;
}
bool earse(const k& key)
{
size_t hasi = key % _table.size();
while (_table[hasi]._s != EMPTY)
{
if (_table[hasi]._s == EXIST && _table[hasi]._kv.first == key)
{
_table[hasi]._s = DELETE;
return true;
}
hasi++;
}
return false;
}
void print()
{
for (int i = 0; i < _table.size(); i++)
{
if (_table[i]._s == EXIST)
{
cout << _table[i]._kv.first << ' ';
}
}
}
private:
size_t _n = 0;
vector<hashdate<k, v>> _table;//使用vector充当哈希表
};
总结
线性探测优点:实现非常简单
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据"堆积",即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?这就需要用到开放定址法了
下篇也完成了,链接放下面了