哈希相关概念
哈希:用来进行高效查找的一种数据结构
首先,我们查找的方式有:
(1)顺序查找,它的时间复杂度是O(n)
(2)二分查找(有序),它的时间复杂度是O(log2N)
(3)利用二叉平衡搜索树(AVL、红黑树):时间复杂度是O(log2N)
(4)哈希,时间复杂度是O(1)
前三种方法元素之间都要进行比较,因此时间复杂度降不下来,而哈希元素之间不需要比较(最差情况下只需少许比较即可找到),因此时间复杂度小。
那么哈希的原理就是通过某种方式,将元素与其在空间中的位置建立一一对应的关系,例如:
给一个容量为capacity的空间,按照func(x)=x%capacity的方法来存储元素,比如存储21、67、90、33、5,func(21)=21%10=1,也就是21放在1号位置;func(67)=67%10=7,放在7号位置,func(90)=0,放在0号位置,func(33)=3,放在3号位置,func(5)=5,放在5号位置,这样就把元素放好了,也就是一个表格;
然后进行查找,第一步就是通过func(x)找元素在表格中的存储位置,然后验证是否为所找的元素即可;
这种思想就是哈希的思想,也可以称作为散列,func(x)称为哈希函数,建立的表格就是哈希表。
但是这种方法有一个缺陷,例如向这个哈希表中放入一个11,func(11)=1,应该将11放在1号位置,而这时1号位置已经放了一个元素,这时如果再向里面放元素就发生了覆盖(冲突),也就是不同的元素计算出相同的哈希地址,这种情况称为哈希冲突。
哈希冲突的解决方式:
(1)哈希函数可能会导致哈希冲突:可以重新设计哈希函数:
注意:哈希地址必须在哈希表格的范围内,产生的哈希地址尽可能的均匀分布,哈希函数尽可能简单;但是一个哈希函数无论设计的多精妙,都无法完全解决哈希冲突,只能将发生哈希冲突的概率降低。
(2)存放的元素
常见的哈希函数:
(1)直接定值法:Hash(key)=A*key+B,它的优点是简单和均匀,缺点是事先要知道关键字的分布,适用于查找比较小连续的情况,例如在字符串中找第一个只出现一次的字符
class Solution {
public:
int firstUniqChar(string s) {
int count[256]={0};//一个字符有256种状态
//统计每个字符出现的次数
for(int i=0;i<s.size();++i)
{
count[s[i]]++;
}
for(int i=0;i<s.size();++i)
{
if(1==count[s[i]])
return i;
}
return -1;
}
};
(2)除留余数法,也就是上述我们所说的func(x)=x%capacity的方法,也就是Hash(key)=key%p(如果p是素数,出现哈希冲突的概率较低)
(3)平方取中法(了解)例如关键字是1234,对他取平方得到1522756,取227作为他的关键字
(4)折叠法(5)随机数法(6)数学分析法
解决哈希冲突的方法
- 闭散列
也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去,那么如何找到下一个空位置呢?
1、线性探测
就是从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
那么怎么区分要插入的位置是否存在元素或者为空?
我们可以给个标记:EMPTY表示没有元素,EXIST表示有元素
如图:
(1)插入:
通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素,如上图插入44,44%10=4,但是4这个位置已经放了值为4的元素,因此要向后找下一个空位置插入
(2)查找:通过哈希函数计算元素在哈希表中的位置,检测该位置是否有元素,然后检测该元素是否为要查找元素
(3)删除:采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索,例如上图如果将4删除,将标记改为EMPTY,会影响44的搜索,导致44找不到(44的哈希函数计算出来应该在4位置,但是此时4位置标记没有元素,所以会影响)因此不能这样进行删除,因此线性探测采用标记的伪删除法来删除一个元素,也就是使用DELETE标记这个位置删除了元素
当删除一个元素,理论上这个位置就可以插入元素了,但是有特殊情况,例如上图,将5删除,此时如果要插入44,按理来说可以插到5这个位置,但是此时哈希表中已经有44这个元素,就会冲突(如果哈希表要求元素唯一性)
(4)判断是否增容?
那么在插入操作时,什么时候增容,怎么增容,这里涉及到散列表的负载因子(填入表中的个数/散列表的长度),负载因子越大,表示填入表中的元素越多,产生冲突的可能性就越大;负载因子越小,填入表中的元素越少,产生冲突的可能性就越小;
对于线性探测,负载因子一般控制在0.7到0.8之间,这里我们实现增容将负载因子控制在0.7,在进行扩容时,我们不能使用传统的方法进行扩容(传统的是将容量扩大,然后原封不动地将元素拷贝过来),因为在容量发生改变后,哈希函数也会发生改变,这样在扩容之后,就找不到元素了,因此我们必须再创建一个哈希表,然后设置它的容量是原来哈希表的容量的扩大版(我们这里是扩大2倍),然后将原来哈希表中存在的元素(即状态是EXIST的元素)插入到新的哈希表中,然后将2个哈希表交换即可(这里实现交换函数先交换内容,再交换有效元素个数)
最终的代码是:
#pragma once
#include <iostream>
using namespace std;
#include <vector>
//假设哈希表格中的元素是唯一的
enum State
{
EMPTY, EXIST, DELETE
};
template<class T>
struct Elem
{
T _value;//元素值域
State _state;//状态
};
template<class T>
class HashTable
{
public:
HashTable(size_t capacity = 10)
:_ht(capacity)
, _size(0)
{
for (auto& e : _ht)
{
e._state = EMPTY;//将表格的初始状态初始化为空
}
}
bool Insert(const T& val)
{
//检测是否需要扩容
CheckCapacity();
//通过哈希函数计算元素在哈希表中的存储位置
size_t HashAddr = HashFunc(val);
//检测该位置是否可以插入元素
//发生哈希冲突,使用线性探测来解决
while (_ht[HashAddr]._state != EMPTY)
{
if (EXIST == _ht[HashAddr]._state && val == _ht[HashAddr]._value)
{
//就不用插入了,冲突
return false;
}
//使用线性探测继续往后找,直到找到空位
++HashAddr;
if (HashAddr == _ht.capacity())
HashAddr = 0;//如果找到最后一个还没有找到空位,从头开始
}
//肯定不会让哈希表中的元素放的太多,因为发生冲突的概率会提高,这样哈希表查找的效率就会降低
//所以不用考虑造成死循环的情况
//找到空的位置,进行插入
_ht[HashAddr]._value = val;
_ht[HashAddr]._state = EXIST;
++_size;
return true;
}
int Find(const T& val)
{
size_t HashAddr = HashFunc(val);//计算哈希地址
while (_ht[HashAddr]._state != EMPTY)//这个位置可能有元素
{
if (_ht[HashAddr]._state == EXIST && _ht[HashAddr]._value == val)
{
return HashAddr;
}
//如果这个位置是删除或者不等于要找的值,就哈希冲突,线性探测
HashAddr++;
if (HashAddr == _ht.capacity())
HashAddr =