赞
踩
在我们之前实现的所有数据结构中(比如:顺序结构以及平衡树中),要存储的元素与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过多次的比较。如顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(
l
o
g
2
N
log_2 N
log2N),搜索的效率取决于搜索过程中元素的比较次数。
而我们理想的一种搜索方法为:可以不经过任何比较,一次直接从表中得到要搜索的元素。
下面我们来详细介绍一下这种方法:哈希(散列)方法。
哈希(Hash):是一种通过特定函数(哈希函数,hash function)将数据映射到固定大小的存储空间的方法。其核心思想是将数据(通常是键值对)通过哈希函数将数据的关键码转换为哈希值,进而通过哈希值快速找到元素对应的存储位置。
ps:哈希函数 hashFunc,将元素的关键码(如字符串、数字等)转换成一个固定范围内的整数,这个整数就是哈希值。
如果利用哈希构造一种存储结构,使得元素的存储位置与它的关键码之间能够建立起一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
这种通过哈希方法构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
例如:在哈希表中,存储以下关键字序列{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % size; size为存储元素的表的长度。
如果继续向表中插入14时,通过hash函数计算出其位置为:hash(14) = 4;与关键字4计算出的哈希地址相同,发生了冲突,这种现象叫做哈希冲突或哈希碰撞。
哈希冲突:对于两个数据元素的关键字
k
i
k_i
ki和
k
j
k_j
kj(i != j),有
k
i
k_i
ki !=
k
j
k_j
kj,但有:Hash(
k
i
k_i
ki) == Hash(
k
j
k_j
kj),即:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
ps:把具有不同关键码而具有相同哈希地址的数据元素称为“同义词。
而引起哈希冲突的一个原因可能是:哈希函数设计不够合理。下面我们来了解一下常见的hash函数。
哈希函数设计原则:
常见哈希函数:
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
解决哈希冲突两种常见的方法是:闭散列和开散列。
闭散列,也叫开放定址法(Open Addressing),是一种处理哈希冲突的方法。当发生哈希冲突时,说明hash表没被填满,则表中必然存在空位置,因此我们可以通过探测算法在哈希表中寻找下一个空闲位置来存放元素。开放定址法的核心思想是通过一个探测算法在哈希表中一直探测,直到探测到空位置来解决冲突的。
常见的探测方法包括:线性探测、二次探测等。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
例如在上面的hash表中继续插入14时,通过hash函数计算出其位置为:hash(14) = 4;与关键字4计算出的哈希地址相同发生了冲突,因此我们使用线性探测找到下一个空位置,插入新元素。
关于哈希表结构的几点解释:
存储结构的定义:
HashData定义如下:
//所有状态 enum state { EXIST, EMPTY, DELETE }; template<class K, class V> struct HashData { pair<K, V> _kv;//存储的键值对 state _st;//状态 //构造 HashData(const pair<K, V>& kv = pair<K, V>()) :_kv(kv) ,_st(EMPTY) {} };
哈希表的定义:
哈希表的定义如下:
template<class K, class V, class Hash = HashFuc<K>>
class HastTable
{
public:
//默认构造
HastTable()
{
//初始时表的长度为10
_table.resize(10);
_n = 0;
}
private:
vector<HashData<K, V>> _table;
int _n;//当前表中元素个数
};
哈希表的插入:
步骤如下:
例如:在哈希表中,依次插入一下关键字序列{1,7,6,4,5,9,11,14};
画个图理解一下:
具体代码如下:
bool insert(const pair<K, V>& kv) { //key已经存在 无法插入,不允许key相同 if (find(kv.first)) return false; //负载因子超过0.7扩容 if (_n * 10 / _table.size() == 7) { //扩容 HastTable<K, V> newHT; newHT._table.resize(_table.size() * 2); for (size_t i = 0; i < _table.size(); ++i) { if (_table[i]._st == EXIST) { newHT.insert(_table[i]._kv); } } _table.swap(newHT._table); } Hash h; //通过hash函数计算当前插入元素的位置 size_t hashi = h(kv.first) % _table.size(); while (_table[hashi]._st == EXIST) { ++hashi; hashi %= _table.size(); } _table[hashi]._kv = kv; _table[hashi]._st = EXIST; ++_n; return true; }
哈希表的删除:
上面我们分析了,采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。因此在删除元素的时候采取伪删除,即要删除元素时,先去查找该元素,若找到了将该位置的标志改为DELETE即可。
具体代码如下:
bool erase(const K& k)
{
//伪删除
HashData<K, V>* ret = find(k);
//找到了将该位置的标志改为DELETE,同时表中的元素个数-1
if (ret)
{
ret->_st = DELETE;
--_n;
return true;
}
return false;
}
哈希表的查找:
步骤如下:
具体代码如下:
HashData<K, V>* find(const K& k) { Hash h; //计算哈希值 size_t hashi = h(k) % _table.size(); //循环检查:继续检查下一个位置,直到找到目标元素或遇到一个空位置 while (_table[hashi]._st != EMPTY) { //如果当前索引位置的状态为 EXIST 且键匹配,则找到了目标元素,返回该元素的指针。 if (_table[hashi]._st == EXIST && _table[hashi]._kv.first == k) { return &_table[hashi]; } //如果当前索引位置的状态为 EXIST 但键不匹配,继续检查下一个位置(线性探测)。 ++hashi; hashi %= _table.size(); } //遇到空位置(状态为 EMPTY),表示该键不在哈希表中,可以直接返回 nullptr。 return nullptr; }
总之,线性探测优点是实现非常简单;线性探测缺点是一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。 比如这组关键字序列:{4,5,14,15,24,6},会造成冲突连成一片,导致效率降低。
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: H i H_i Hi = ( H 0 H_0 H0 + i 2 i^2 i2 )% m, 或者: H i H_i Hi = ( H 0 H_0 H0 - i 2 i^2 i2 )% m。其中:i = 1,2,3…, H 0 H_0 H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。即每次探测不是往后探测一个位置,而是依次探测 1 2 1^2 12, 2 2 2^2 22, 3 2 3^2 32,…,这里就不在赘述,有兴趣的可以自己去实现一下。
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
例如:在哈希表中,存储以下关键字序列{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % size; size为存储元素的表的长度。
当继续插入元素14时,通过哈希函数计算出其存储位置为:hash(14) = 14 % 10 = 4;直接将其插入在位置为4的那个链表中即可。
存储结构的定义:
HashNode定义如下:
template<class K, class V>
struct HashNode
{
typedef HashNode<K, V> Node;
pair<K, V> _kv;
Node* _next;
//构造
HashNode(const pair<K, V>& kv)
:_kv(kv)
, _next(nullptr)
{}
};
哈希表的定义:
哈希表的定义如下:
template<class K, class V, class Hash = HashFuc<K>>
class HastTable
{
typedef HashNode<K, V> Node;
public:
//默认构造
HastTable()
{
_table.resize(10);
_n = 0;
}
private:
vector<Node*> _table;
int _n;//当前表中元素个数
};
哈希表的插入:
步骤如下:
例如:在哈希表中,依次插入一下关键字序列{1,7,6,4,5,9,11,14, ,2 ,12,17};
画个图理解一下:
具体代码如下:
bool insert(const pair<K, V>& kv) { Hash h; if (find(kv.first)) return false; if (_n / _table.size() == 1) { //扩容 HastTable<K, V, Hash> ht; ht._table.resize(_table.size() * 2); //遍历旧哈希表中的所有元素,将每个元素重新映射到新哈希表中。 for (int i = 0; i < _table.size(); ++i) { Node* cur = _table[i]; while (cur) { Node* next = cur->_next; int hashi = h(cur->_kv.first) % ht._table.size(); cur->_next = ht._table[hashi]; ht._table[hashi] = cur; cur = next; } _table[i] = nullptr; } _table.swap(ht._table); } //扩容完成后,将新元素插入到新的哈希表中。 Node* newnode = new Node(kv); int hashi = h(kv.first) % _table.size(); newnode->_next = _table[hashi]; _table[hashi] = newnode; ++_n; return true; }
ps: 在扩完容后,将现有元素重新映射到新的哈希表时,要依次取旧表中的节点重新进行映射。这是因为重新链接节点通常比创建和插入新节点更高效,因为创建新节点涉及分配新的内存,而重新链接只需要调整指针。重新链接的开销较低,可以更快地完成扩容操作。
哈希表的删除:
删除步骤如下:
具体代码如下:
bool erase(const K& k) { Hash h; int hashi = h(k) % _table.size(); Node* prev = nullptr; Node* cur = _table[hashi]; while (cur) { if (cur->_kv.first == k) { //删除 if (prev == nullptr) { _table[hashi] = cur->_next; } else { prev->_next = cur->_next; } delete cur; --_n; return true; } prev = cur; cur = cur->_next; } return false; }
哈希表的查找:
步骤如下:
具体代码如下:
bool find(const K& k)
{
Hash h;
int hashi = h(k) % _table.size();
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == k)
{
return true;
}
cur = cur->_next;
}
return false;
}
思考: 哈希函数采用处理余数法,被模的key必须要为整形才可以处理,而上面实现的hash表只能存储key为整形的元素,其他类型怎么解决?
要解决哈希表存储非整型键的问题,常见的做法是设计适用于各种键类型的哈希函数。这可以通过定义模板类和泛型哈希函数来实现,允许哈希表存储不同类型的键(如字符串、对象等)。
解决方案
可以看到上面实现的hash表的第三个类模板参数可以接收使用者传的自定义哈希函数对象,来将key转换成整型。这个是字符串哈希算法(点击即可跳转)有兴趣的可以自己研究一下。
这里直接给出我的解决方案代码:
template<class K> struct HashFuc { unsigned long operator()(const K& K) { return (unsigned long)K; } }; //模板特化 string版本 template<> struct HashFuc<string> { unsigned long operator()(const string& s) { unsigned long h = 0, g; for (auto e : s) { h = (h << 4) + e; if (g = h & 0xF0000000) { h ^= g >> 24; } h &= ~g; } return h; } };
至此,本片文章就结束了,若本篇内容对您有所帮助,请三连点赞,关注,收藏支持下。
创作不易,白嫖不好,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!
如果本篇博客有任何错误,请批评指教,不胜感激 !!!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。