学完了顺序表和平衡树,我们知道:元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log₂N),搜索的效率取决于搜索过程中元素的比较次数。
那有没有不经过任何比较,一次直接从表中得到目标元素的方法呢?答案当然是肯定的。
1.哈希的概念
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
插入元素时,根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放;
搜索元素时,对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。
例如:
存储数据集合{8,4,2,9,7,5,3};
可以将哈希函数设置为:hash(key)=key%capacity (capacity=10,capacity具体如何取值,下面会有说明)
如图:
2.哈希冲突
不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞,把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
比如,上图的数据集合中没有出现%10后相同的情况,可在现实情况中一定会出现取模后相等的情况,比如在上图例子中新增加一个元素12,12%10==2%10,都等于2,此时2已经插入到了“2”的位置,那么12又该放到哪里呢?
发生了哈希冲突又该如何解决呢?
3.哈希冲突的解决
解决哈希冲突两种常见的方法是:闭散列和开散列。
3.1闭散式
闭散列也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置的“下一个空位置”中去。
如何寻找“下一个空位置”也是我们要讨论的话题。
1.线性探测
线性探测,顾名思义,就是从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
①插入
1,通过哈希函数获取待插入元素在哈希表中的位置;
2,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素。
还接上文中的例子:数据集合中多了一个数据12,12原本要插入到位置“2”中去,可位置“2”中已经插入了2,按照线性探测,12只能寻找“2”之后的下一个空位置了,也就是位置“6”.
如图:
②删除
与插入类似,删除也是通过哈希函数获取待删除元素在哈希表中的位置,然后再进行删除;
不过需要注意,采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。
还以上图为例,如若删除元素2,如果直接将2删除,此时位置“2”已经没有元素了,那么下次搜索元素12时,会先搜索到位置“2”,可规则是当发生哈希冲突时才会继续寻找“下一个空位置”,此时的位置“2”并没有发生哈希冲突,所以直接删除势必会影响元素12的搜索。
针对这种现象,线性探测采用标记的伪删除法来删除一个元素。
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State
{
EMPTY,
EXIST,
DELETE
};
线性探测具体实现如下:
#pragma once
#include <iostream>
#include <string>
#include <vector>
using namespace std;
namespace open_address
{
enum State
{
EMPTY, //此位置为空
EXIST, //此位置已有元素
DELETE //元素已删除
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY; // 标记为空
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
/*struct HashFuncString
{
size_t operator()(const string& s)
{
"abcd"
"bcad"
"aadd"
size_t hash = 0;
for (auto e : s)
{
hash += e;
hash *= 131;
}
return hash;
}
};*/
// 由于哈希表并不全部存储整型数据,字符串也是经常遇到的数据类型
// 而字符串类型又不能直接进行取模运算
// 所以设计函数将字符串类型转为整型再进行取模运算
// 此处选择将字符串每个字母的ASCII值相加的方法
// 而字符串类型相比整型也并不常用,所以将字符串类型进行函数特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto e : s)
{
hash += e;
hash *= 131;
// 乘一个数再相加,是为了避免出现"abcd""bcad""aadd"这类的数据(他们的ASCII值相加都是394)
// 减少出现“哈希冲突”的现象
// 之所以选择乘131,是因为131是一个能够使他们差异化更加明显的数字
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable(size_t size = 10)
{
_tables.resize(size);
}
// 搜索
HashData<K, V>* Find(const K& key)
{
Hash hs;
// 线性探测
size_t hashi = hs(key) % _tables.size();
// 前面说过,采用闭散列处理哈希冲突时,若直接删除会影响其他元素的搜索
// 所以只有当hashi的_state为空时才停止寻找“下一个空位置”
while (_tables[hashi]._state != EMPTY)
{
if (key == _tables[hashi]._kv.first && _tables[hashi]._state == EXIST)
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
}
return nullptr;
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
//插入之前先检查扩容
// 扩容(为什么选择0.7,下文会有注解)
//if ((double)_n / (double)_tables.size() >= 0.7)
if (_n * 10 / _tables.size() >= 7)
{
HashTable<K, V, Hash> newHT(_tables.size() * 2);
// 遍历旧表,插入到新表
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
//容量没有问题再插入
Hash hs;
// 线性探测
size_t hashi = hs(kv.first) % _tables.size();
//hashi的_state的状态为“有元素”时,此位置不可插入,继续往后寻找
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();
}
//hashi的_state的状态变为“空”或“已删除”时,循环结束,插入此位置
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;//将状态变为“有元素”
++_n;
return true;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
_n--;
ret->_state = DELETE;
return true;
}
else
{
return false;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 实际存储的数据个数
};
}
在以上代码中,关于扩容时为何要选择0.7,资料供参考:
线性探测的缺点:
一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
2.二次探测
线性探测是逐个寻找“下一个空位置”,这势必会导致关键字的分布相对集中,采用二次探测可以实现“跨越式寻找”,使关键字分布相对分散,提高搜索效率。
在哈希函数的基础上,增加二次探测函数:
hash(key) = ( hash( key ) + ( i ²) ) % capacity (i=1,2,3,4,5...)
原理如图:
研究表明:
当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:开散式最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
3.2 开散式(哈希桶)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地 址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链 接起来,各链表的头结点存储在哈希表中。
如图:
具体实现如下:
namespace hash_bucket
{
//这里只需使用单向链表即可,所以自己封装,不用复用库中的list
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 Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_tables.resize(10, nullptr);
_n = 0;
}
~HashTable()
{
for (size_t i = 0; i < _tables.size(); i++)//遍历旧表
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))//键值相同,无法插入
{
return false;
}
Hash hs;
//控制负载因子到1就扩容
if (_n == _tables.size())
{
vector<Node*> newTables(_tables.size() * 2, nullptr);//开辟新表
for (size_t i = 0; i < _tables.size(); i++)//遍历旧表
{
//取出旧表中的节点,重新计算挂到新表桶中
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
//头插到新表
size_t hashi = hs(cur->_kv.first) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = hs(kv.first) % _tables.size();
Node* newnode = new Node(kv);
//头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
//删除
if (prev)
{
prev->_next = cur->_next;
}
else
{
_tables[hashi] = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
//vector<list<pair<K, V>>> _tables;
vector<Node*> _tables;//指针数组
size_t _n;
};
}