1、哈希概念
在常见的搜索方式中我们通常分为,静态的和动态的。
静态搜索方式:
给定一段序列数据 我们通用常采用:
顺序查找----- for循环遍历 ——> O(N)
二分查找---- 要求:带查找的数据排列必须是有序的
这些方式都是在一个固定的序列中查找
动态搜索方式 :底层结构发生改变,进行增 删等操作
二叉搜索树 ---- 有序或者接近有序 ----> 退化成单支树 ——>查找效率 O(N)
AVL树:二叉搜索树 + 平衡因子 ----->保证树的平衡性:任意节点的左右子树的高度差(平衡因子)绝对值不超过1 ——> 查找效率O(logN)
以上方式必须通过元素比较才能进行元素查找
在查找元素中,能否不用经过比较呢?
如果构造一种存储结构,通过某种函数(hashFunc)使
元素的存储位置与它的关键码之间能够建立一一映射的关系,
那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
插入元素
根据待插入的关键码,以此作为函数计算出该元素的存储位置,并按照此位置存放元素
搜索元素
对元素的关键码进行计算出,将所求出的函数值当作该元素的存储位置,将该存储位置上的元素与搜素元素进行比较,若关键码相同,则搜索成功
该方式即为哈希(散列)方法
哈希方法中使用的转换函数称为哈希(散列)函数
构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
2、哈希冲突
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快 问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”
发生hash冲突该如何进行处理呢?
3、哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
如何设计一个比较合理的哈希函数(哈希函数设计原则):
哈希函数的值域必须在表格空间的范围之内
尽量保证哈希函数值域分布的均匀
哈希函数设计应该比较简单
常见的哈希函数:
1、直接定制法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀 缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
2. 除留余数法–(常用) 尽可能模上素数
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
4、哈希冲突解决
闭散列:
**闭散列:**也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
如何寻找下一个空位置?
1、线性探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
#插入
通过哈希函数获取待插入元素在哈希表中的位置
如果该位置中没有元素则直接插入新元素,
如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
#删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。
比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum STATE{EMPTY, EXIST, DELETE};
线性探测与二次探测
template<class T>
struct Elem
{
Elem(const T& data = T())
:_data(data)
,_state(EMPTY)
{
}
T _data;
STATE _state;
};
// 自己约定:哈希表格中的元素必须唯一
// T: 表示元素的类型
// DF: 表示将T类型的对象转换为整数数据的方法的类型
// isLine: 选择用线性探测还是二次探测来解决哈希冲突
template<class T, class DF = DFDef<T>, bool isLine = true>
class HashTable
{
public:
HashTable(size_t capacity = 10)
: _size(0)
, _table(0)
{
_table.resize(GetNextPrime(10));
}
bool Insert(const T& data)
{
CheckCapacity();
// 1. 通过哈希函数,计算哈希地址
size_t hashAddr = HashFunc(data);
size_t i = 0;
// 2. 找合适位置
while (_table[hashAddr]._state != EMPTY)
{
// 元素已经存在
if (_table[hashAddr]._state == EXIST && _table[hashAddr]._data == data)
return false;
// 位置状态:DELETE
// 位置状态:EXIST,发生哈希冲突
if (isLine)
{
// 线性探测
hashAddr++;
// 方式二:
if (hashAddr == _table.capacity())
hashAddr = 0;
}
else
{
i++;
hashAddr = hashAddr + 2 * i + 1;
hashAddr %= _table.capacity();
}
}
// 找到了一个空位置,插入元素
_table[hashAddr]._data = data;
_table[hashAddr]._state = EXIST;
++_size;
_total++;
return true;
}
int Find(const T& data)
{
// 1. 通过哈希函数,计算元素在表格中的位置
size_t hashAddr = HashFunc(data);
size_t i = 0;
// 2. 查找
while (_table[hashAddr]._state != EMPTY)
{
if (_table[hashAddr]._state == EXIST && _table[hashAddr]._data == data)
return hashAddr;
// 需要继续往后探测:线性探测
if (isLine)
{
// 线性探测
hashAddr++;
// 方式二:
if (hashAddr == _table.capacity())
hashAddr = 0;
}
else
{
i++;
hashAddr = hashAddr + 2 * i + 1;
hashAddr %= _table.capacity();
}
}
return -1;
}
bool Erase(const T& data)
{
int pos = Find(data);
if (pos != -1)
{
_table[pos]._state = DELETE;
_size--;
return true;
}
return false;
}
size_t Size()const
{
return _size;
}
void Swap(HashTable<T, DF, isLine>& ht)
{
_table.swap(ht._table);
swap(_size, ht._size);
}
private:
size_t HashFunc(const T& data)
{
//DF df;
//return df(data) % 10;
return DF()(data) % _table.capacity();
}
private:
vector<Elem<T>> _table;
size_t _size; // 表示哈希表中存储的有效元素个数
size_t _total; // 表示哈希表格中已经存储元素个数:有效元素+删除元素之和
};
线性探测优点:
实现非常简单。
线性探测缺点:
一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”。
即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
如何缓解呢?
不要一次挨着往后找,就不会产生堆积。
二次探测
不是探测两次,而是相比于线性探测,不会挨着往后查找。
优点:
可以解决线性探测的堆积问题
缺点:
当表格中空位置较少时,找下一个空位置,可能需要多次探测
注意: 二次探测中,如果哈希地址越界,必须要用%的方式将哈希地址回到哈希表中,一定不能将哈希地址置零。
在往哈希表存储时,我们不一定存整型,这个问题如何解决?
为了避免只能存储为整形的元素,提供不同的将T类型的对象转换为整数数据的方法的类型
// 如果T是整型
template <class T>
class DFDef
{
public:
T operator()(const T& data)
{
return data;
}
};
// 比如T 是string 类型得对象,
class DFStr
{
public:
size_t operator()(const string& s)
{
// 如何将string类型字符串转化为整型?
return BKDRHash(s.c_str());
}
private:
size_t BKDRHash(const char *str) // 提供好的字符串hash算法
{
register size_t hash = 0;
while (size_t ch = (size_t)*str++)
{
hash = hash + 131 + ch; //也可以乘以31、131、1313、13131
}
return hash;
}
};
上述还未对哈希表扩容进行实现,那哈希表什么时机增容?如何增容?能否按照vector的方式进行扩容呢?
哈希负载因子(散列表的载荷因子) = 哈希表元素个数 / 表格容量
载荷因子必须严格控制在0.7–0.8以下。
超过0.8,查表时的cpu缓冲不命中(cache missing)按照指数曲线上升。
vector的扩容方式:申请新空间、拷贝元素、释放旧空间
一旦扩容 ,容量就会发生变化,根据哈希函数所求的哈希地址也随之发生变化,
因此并不能使用vector的扩容方式。
对于哈希表的扩容方式我们采取:
- 新创建一个哈希表
- 将旧哈希表中状态为存在的元素向新哈希表格中插入
void CheckCapacity()
{
//if (_size*10 / _table.capacity() >= 7) // 如果删除位置可以插入元素
if (_total * 10 / _table.capacity() >= 7) // 如果删除位置不可以插入元素
{
// 1. 新创建一个哈希表
HashTable<T, DF, isLine> newHT(GetNextPrime(_table.capacity()));
// 2. 将旧哈希表中状态为存在的元素向新哈希表格中插入
for (auto e : _table)
{
if (e._state == EXIST)
newHT.Insert(e);
}
Swap(newHT);
}
}