在顺序结构和平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N)
,平衡树中为树的高度,O(logN )
,搜索的效率取决于搜索过程中元素的比较次数。
而理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找通过该函数可以很快找到该元素。
向该结构中:
哈希(散列)方法
,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9}
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
我们将数据映射到capacity为10的哈希表中如下:
这种方法的搜索速度很快。
常用的2种方法:
1.直接定址法:取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:1需要知道关键字的分布情况,如果给的一组数据范围很大,就会浪费空间
2.不能处理浮点数,字符串等数据
2.除留余数法:设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
优点:数据范围很大
缺点:不同的值映射到同一个位置会冲突
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。 哈希函数设计原则:
1.哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0 到m-1之间
2.哈希函数计算出来的地址能均匀分布在整个空间中
3.哈希函数应该比较简单
闭散列:也叫开放定址法
,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
Hash(key)=key%len+i(i=0,1,2…)
len:哈希表的大小
如果+0后面有数据那么再+1…直到找到空位置
比如插入11,和1冲突就往后探测。
动图演示:插入44
通过上图发现:哈希表中的数据越多,产生哈希冲突的可能性会越大。此时我们引入了负载因子
负载因子=表中的有效数据/空间的大小
1.负载因子越大,产生冲突的概率越大,增删查改的效率低
2.负载因子越小,产生冲突的概率越小,增删查改的效率高
将哈希表的大小改为20在插入{1,7,6,4,5,9,11,13},产生的冲突变少:
对于闭散列来说,负载因子是特别重要的因素,一般控制在0.7~0.8以下
,超过0.8会导致在查表时CPU缓存不命中按照曲线上升。
线性探测的优点:实现简单
缺点:发生冲突,容易产生数据堆积,出现踩踏,导致搜索效率低。
✨✨✨✨✨✨✨✨✨✨✨✨我是分割线✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
二次探测
为了解决空位置是一个一个找,二次探测避免了这个问题。
二次探测不是再探测一次的意思,二次探测的方法
Hash(key)=key%len+i ^2(i=0,1,2…)
比如插入数据{1,4,5,9,11,13},为了测试我又加了44,54来看看效果:
比之前的探测好了很多,之前的54是插在44后面的现在到了下表16了,避免了踩踏。
在闭散列哈希表中不仅要存数据之外,还要存储当前位置的状态,有3种状态:
1.EMPTY(无数据的空位置)
2.EXIST(已存储数据)
3.DELETE(原本有数据,但是被删除了)
我们用枚举即可
enum State//标识每个位置的状态
{
EMPTY,
EXIST,
DELETE
};
为什么要加上状态?
那怎么标识一个空位置呢?用数字标识?例如1:
但是把11删除后就找不到21了。
它是和1冲突,从1开始找,1后面是空停止。但是21还在表中,不可能遍历一遍哈希表,这样就是去了哈希表的意义了。因此要在哈希表中加上状态。当哈希表中删除一个元素后,我们不应该简单的将状态设为EMPTY,要将该位置的状态设为DELETE
。在查找的过程中,如果当前位置和查找的Key值不相等,但是当前位置的状态是EXIST或者是DELETE,我们都要往后查找,而当我们插入时,可以插入到状态EMPTY和DELETE上。
哈希表中的每个位置的存储结构包括状态和数据
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;//数据
State _state = EMPTY;//状态
};
我们还要在哈希表中存入数据的个数来计算负载因子,当负载因子过大就要将哈希表增容。
template<class K, class V>
class HashTable
{
public:
//...
private:
vector<HashDate<K, V>> _table;
size_t _n = 0;//哈希表中元素的个数
};
插入的逻辑如下:
1.查看是否存在该键值对,如存在则返回false
2.判断哈希表的大小是否需要调整,为0或者是负载因子过大都需要调整
3.插入元素
4.哈希表大小加1
增容的逻辑:
不能原地扩容,那原来数据的映射关系就乱了。我们要新创建一个哈希表对象,是原来的2倍,把旧的数据插入到新表中,在交换2张表即可。
插入键值对的过程:
1.先计算出对应的位置
2.如果发生冲突,进行线性探测处理,向后找到一个状态为EMPTY或者DELETE的位置插入
3.插入到该位置,并将状态设为EXIST
代码如下:
bool insert(const pair<K, V>& kv)
{
//通过查找看看哈希表中是否存在
HashDate<K,V>* ret = Find(kv.first);
if (ret)
{
//如果存在返回false
return false;
}
if (_table.size() == 0)
{
_table.resize(10);
}
else if ((double)_n /(double)_table.size() > 0.7)
{
//增容
HashTable<K,V> newHT;//创建一个新的表
newHT._table.resize(2 * _table.size());
//将旧表的数据插入到新的哈希表
for (auto& e : _table)
{
if (e._state == EXIST)
{
newHT.insert(e._kv);
}
}
_table.swap(newHT._table);
}
//计算位置
size_t start = kv.first % _table.size();
size_t index = start;
size_t i = 1;
while (_table[index]._state == EXIST)
{
index = start + i;//线性探测
index %= _table.size();//防止超出表的范围
++i;
}
//找到可以插入的位置
_table[index]._kv = kv;
_table[index]._state = EXIST;//调整状态
_n++;//哈希表元素++
return true;
}
查找的步骤:
1.先判断哈希表是否为0,等于0返回false,因为不能除0操作
2.除留余数算出映射位置
3.从映射的地址开始线性探测查找,找到返回成功,找到一个状态为EMPTY返回false
重点:在查找时,必须找到位置状态为EXIST且Key值相等,才能查找成功。仅仅是Key值相等,但是当前位置的状态为DELETE,则还需要继续向后查找,因为该位置的值已经删除了。
代码如下:
HashDate<K,V>* Find(const K& key)
{
//表为空返回false
if (_table.size() == 0)
{
return nullptr;
}
size_t start = key % _table.size();//计算出映射的地址
size_t index = start;
size_t i = 1;
while (_table[index]._state != EMPTY)
{
//该位置的状态为EXIST,且Key值相等,查找成功
if (_table[index]._state == EXIST
&& _table[index]._kv.first == key)
{
return &_table[index];
}
//继续线性探测查找
index = start + i;
index %= _table.size();
i++;
}
return nullptr;
}
删除的过程很简单,我们只需要进行伪删除,将待删除元素的状态设为DELETE即可。
步骤如下:
1.先调用查找,找到该元素,若没有找到返回false
2.找到将状态设为DELETE
3.哈希表中的有效数据–
代码如下:
bool Erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
return false;
}
下面来测试测试:
void Test_Hash()
{
int a[] = { 1,5,10,1000,100,18,15,7,40 };
CloseHash::HashTable<int, int> ht;
for (auto e : a)
{
ht.insert(make_pair(e,e));
}
auto ret = ht.Find(10);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
ht.Erase(10);
ret = ht.Find(100);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
ret = ht.Find(10);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
}
我先找10,在删除10,看看能不能找到100,在看看10删除后能不能找到。
是没有问题的。
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
例如:下面的数据
动图演示:
✨✨✨✨✨✨✨✨✨✨✨✨我是分割线✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
哈希表的每个位置存某个单链表的头结点,所以每个哈希桶中存储的数据应该是一个结点的类型,还需要一个节点的指针指向它的下一个结点。
template<class K,class V>
struct HashNode
{
HashNode<K, V>* _next;//结点的指针
pair<K, V> _kv;//键值对
//构造函数
HashNode(const pair<K,V> kv)
:_next(nullptr)
,_kv(kv)
{}
};
template<class K, class V>
class HashTable
{
public:
typedef HashNode<K,V> Node;
private:
vector<Node*> _table;//哈希表
size_t _n = 0;//缺省值
};
插入的步骤:
1.它是不允许冗余的,所以先查找Key,存在返回false
2.检查负载因子,如果哈希表为空或者负载因子过大就需要进行增容处理
3.计算出在哈希表的位置进行头插
4.哈希表的有效数据在++
增容处理:
1.创建新的哈希表,大小是原来旧表的2倍
2.遍历旧表,算出在新表中的位置,在将结点头插到新表中
3.旧表位置的指针置空,交换2张哈希表
代码如下:
//插入
bool insert(const pair<K,V>& kv)
{
//如果表中已经有该元素返回false,不能出现数据冗余
HashNode<K, V>* ret = Find(kv.first);
if (ret)
{
return false;
}
//检查负载因子,负载因子超过1或者table.szie=0需要调整
if (_n == _table.size())
{
//进行增容,创建一个新的哈希表
vector<Node*> newtable;
//新表的大小等于旧表的2倍
size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
newtable.resize(newsize, nullptr);//把newsize给新表,并将表的位置初始化
//把旧表的结点插入到新表中
for (size_t i = 0; i < _table.size(); ++i)
{
if (_table[i])//桶不为空,插到新表中
{
Node* cur = _table[i];
while (cur)//桶中的结点不为空
{
//计算新表中的映射位置
size_t index = kv.first % newtable.size();
//保存cur的下1个结点,不保存头插的话找不到下个结点
Node* next = cur->_next;
cur->_next = newtable[index];
newtable[index] = cur;
cur = next;
}
_table[i] = nullptr;//旧桶置空
}
}
_table.swap(newtable);//交换2个哈希表
}
//计算出映射的位置
size_t index = kv.first % _table.size();
Node* newnode = new Node(kv);
//头插
newnode->_next = _table[index];
_table[index] = newnode;
++_n;
return true;
}
查找的步骤:
1.计算出在哈希表中的映射位置
2.遍历结点在链表的位置
注意:哈希表为0时处理一下,不能进行除0操作
//查找
HashNode<K, V>* Find(const K& key)
{
if (_table.size() == 0)
{
return nullptr;
}
//1.先计算出映射的位置
size_t index = key % _table.size();
//2.遍历链表的位置
Node* cur = _table[index];
while (cur)
{
//找到返回结点
if (cur->_kv.first == key)
{
return cur;
}
else
{
cur = cur->_next;
}
}
//没找到返回空
return nullptr;
}
删除的步骤:
1.还是先算出在哈希表中的映射位置
2.开始删除
3.哈希表中的数据–
删除的时候注意删除第1个结点
//删除
bool Erase(const K& key)
{
//1.先计算出映射的位置
size_t index = key % _table.size();
//2.开始删除
Node* cur = _table[index];
Node* prev = nullptr;//保存cur的前1个结点
while (cur)
{
//找到了开始删除
if (cur->_kv.first == key)
{
if (prev == nullptr)//删除第1个结点
{
_table[index] = cur->_next;//指向cur的下一个结点
}
else
{
prev->_next = cur->_next;
}
delete cur;//释放该结点
_n--;//哈希表中数据--
return true;
}
prev = cur;
cur = cur->_next;
}
return false;//没有该结点返回false
}
下面来测试一下,博主这里是没有实现迭代器的。等到封装unordered_set和unordered_map的时候在实现迭代器。
void Test_Hash1()
{
int a[] = { 1,5,10,20,18,15,7,40 };
OpenHash::HashTable<int,int> ht1;
for (auto e : a)
{
ht1.insert(make_pair(e, e));
}
auto ret = ht1.Find(10);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
ht1.Erase(10);
ret = ht1.Find(20);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
ret = ht1.Find(10);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
}
插入好的效果如下图:
我们通过调试来看看:
看出是没有问题的。
查找也都可以。
在实际中哈希桶更实用,在极端的情况下也能处理。比如下图,在下面挂了很多的结点,此时效率就会很低很低。
前辈们的办法是把挂多的结点放在红黑树中。
桶中种树
所以前辈还是很牛的。在JAVA中比较新的版本中结点超过8个就会将单链表改成红黑树,结点少于8个又会变回单链表的结构。这种结构我们知道即可,不用我们自己去实现。
但是有的地方没做这种处理也是可以的,当负载因子够大的时候就会触发增容的机制,就会开更大的表,映射位置也就随之改变而桶中的结点也会减少。