目录
前言:
我们已经知道了map和set底层是使用红黑树进行封装的,它的增删查的效率可以达到O(log n),这已经很快了。但是其实还有更快的,就是哈希表!
哈希表的效率都可以达到O(1),最坏情况也是O(n),这里一般分为两种哈希表的实现,一种是开放寻址法,一种是链地址法(有很多方法,但是我们只说最常见的两种方法)。
接下来我们一一介绍。
一:什么是哈希?
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O(log N),搜索的效率取决于搜索过程中元素的比较次数。 理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
举个栗子你就懂了:
二:哈希冲突
对于两个数据元素的关键字k_i和 k_j(i != j),有k_i != k_j,但有:Hash(k_i) == Hash(k_j),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突 或 哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。 发生哈希冲突该如何处理呢?
解决哈希冲突两种常见的方法是:闭散列(开放寻址法)和开散列(链地址法)。
三:平衡因子
当数据已经占据到一定的空间时,我们就要对其扩容。这里就需要一个条件,这个条件就是负载因子。对于也就是 存放数据个数 / 总容量 = 负载因子 。具体可以看我们实现哈希表的具体方法。
四:开放寻址法
比如我们在刚才的数据中再插入一个元素44,此时要映射到下标为4的位置上去,但是此时已经有数据了,我们要找下一个为空的地方存放:
1.定义状态
这里我们写代码的话应该怎么办?对,增加标记。
两个标记是不是就可以了?存在,不存在。但是我们还会进行删除,下次插入的元素当然也可以在删除的位置。
所以这里定义三个标记状态(在myHash.h头文件中,因为本篇要实现两个哈希表,所以这里用到命名空间,这里是open_address):
enum States
{
EMPTY, //空
EXIST, //存在
DELETE //删除
};
2.定义哈希表节点
这里和树一样,都需要定义节点(这里依旧是键值对存储,不存在重复值):
template<class K, class V>
struct HashNode
{
HashNode() = default;
HashNode(const pair<K, V>& kv) //调用其默认构造
: _kv(kv)
, _s(EMPTY) //状态默认是空
{}
pair<K, V> _kv;
States _s;
};
3.定义哈希表
这里的哈希表我们不要像之前一样傻傻的去声明什么T* capacity,我们已经学习过了vector,所以直接把它定义为哈希表的成员变量即可。这里当然还要用一个值来记录当前数组中已经存放的节点个数。
template<class K, class V>
class HashTable
{
public:
using Node = HashNode<K, V>;
private:
vector<Node> _table; //用vector存放好操作
size_t _n; //记录哈希表中存放数据的个数
};
4.定义默认构造
仔细想想,我们要先让vector开空间,也就是resize,改变其size,所以我们先写一个构造函数出来。
HashTable()
{
_table.resize(10); //这里就先开10个空间
}
5.定义Insert方法
这里我们先不考虑扩容,只考虑插入。就是先取模,之后开是否为空,为空或删除就插入。
bool Insert(const pair<K, V>& kv)
{
//这里无需创建新节点 不需要开空间
size_t hashi = kv.first % _table.size();
while (_table[hashi]._s == EXIST)
{
++hashi;
hashi %= _table.size(); //因为可能会越界
}
//遇到空或DELETE
_table[hashi]._kv = kv;
_table[hashi]._s = EXIST;
++_n;
return true;
}
6.定义Find方法
比对值并且存在即可,我们返回对应节点的指针,注意最多查找size次,所以我们需要再次多增加一个条件:
Node* Find(const K& key)
{
size_t hashi = key % _table.size();
//我们要限制次数 因为可能死循环
size_t limit = 0;
while (limit < _table.size() && _table[hashi]._s != EMPTY)
{
//此时键相等且存在 就找到了
if (_table[hashi]._kv.first == key &&
_table[hashi]._s == EXIST)
{
return &_table[hashi];
}
++hashi;
hashi %= _table.size();
++limit; //最多找10次
}
return nullptr;//没找到
}
tips:所以Insert函数中可以复用Find,存在就不插入。
7.定义Erase方法
这个方法也很简单,我们先查找,如果该值存在,将其对应的标记修改为DELETE即可。
bool Erase(const K& key)
{
//先查找
Node* ret = Find(key);
if (ret) //存在
{
ret->_s = DELETE;
return true;
}
return false;
}
8.实现扩容
什么时候扩容呢?这里就需要用到负载因子了,我们定义当 _n / _table.size() > 7 时扩容。
可能很多人会想,我们直接调用_table的reserve函数不就好了?如下图:
所以不能直接扩容,我们需要将所有值再次重新映射一遍,所以在Insert方法中添加一下代码:
//扩容
if (_n * 10 / _table.size() > 7)
{
//这里创建一个新的哈希表
HashTable newHT;
newHT._table.resize(_table.size() * 2); //二倍扩容
//遍历旧表
for (size_t i = 0; i < _table.size(); ++i)
{
if (_table[i]._s == EXIST)
{
newHT.Insert(_table[i]._kv);
}
}
_table.swap(newHT._table); //最后交换即可
}
OK了,就这么简单,我们已经实现了开放寻址法的哈希表。
五:提供仿函数能够处理string
刚才我们已经能处理很多类型的数据,但是string该怎么办呢?它是怎么取模的?所以这里我们要进行处理,对哈希表多增加一个模板参数,使其能够处理string类型,也就是仿函数。
所以我们先写一个仿函数,之后提供特化版本,特化就是针对不同类型而尽心特殊处理。
template<class T>
struct HashFunc
{
size_t operator()(const T& data)
{
//直接进行强转即可
return (size_t)data;
}
};
这里处理string类型的特化版本我们要特殊处理,不能直接加上每个字符对应的ASCII码值,因为这样处理会存在如:"abc"和"bca"处理的值相同的情况。所以这里可以以下方式处理(大佬研究的,几乎可以保证都不一样,这里我们只要记住就行):
//特化版本
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t len = 0;
for (auto e : s)
{
len *= 31;
len += e;
}
return len;
}
};
所以我们在HashTable中多加一个模板参数,并提供缺省值就是刚才写的HashFunc。之后再多加一个成员变量,就是实例化一个仿函数对象,需要计算hashi的时候就转换为size_t即可。
//提供缺省值
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
using Node = HashNode<K, V>;
//实例化仿函数对象
Hash hs;
//...
bool Insert(const pair<K, V>& kv)
{
//...
//这里无需创建新节点 不需要开空间
//size_t hashi = kv.first % _table.size();
size_t hashi = hs(kv.first) % _table.size();
//...
}
Node* Find(const K& key)
{
//size_t hashi = key % _table.size();
size_t hashi = hs(key) % _table.size();
//...
return nullptr;//没找到
}
//...
};
这时我们就可以正确处理string类型了。
六:哈希桶(链地址法)
我们已经实现了开放寻址法。接下来介绍第二种方法,链地址法。也就是数组每个值都存放的是指针, 类似一个链表。
当有冲突节点插入相同索引时,我们使用头插:
不同于开放寻执法的是,我们已经可以不再定义状态了,还是先定义节点(命名在hast_bucket命名空间中)。
1.定义节点
这里我们只需要数据和一个指针即可:
template<class K, class V>
struct HashNode
{
HashNode() = default;
HashNode(const pair<K, V>& kv)
: _kv(kv)
, _next(nullptr)
{}
pair<K, V> _kv;
HashNode* _next;
};
2.定义哈希表和默认构造
没什么好说的,这里和之前的哈希表类似,很简单,看代码:
template<class K, class V>
class HashTable
{
public:
using Node = HashNode<K, V>;
HashTable()
{
_table.resize(10, nullptr); //开10个空间并赋值nullptr
}
private:
vector<Node*> _table;
size_t _n;
};
你可能会问,为什么不用list呢?这是为了后面的封装等操作更简单。
3.定义Insert方法
这里使用头插法,同样先不处理扩容。
bool Insert(const pair<K, V>& kv)
{
//需要开辟空间
Node* newNode = new Node(kv);
size_t hashi = kv.first % _table.size();
//头插
newNode->_next = _table[hashi];
_table[hashi] = newNode;
++_n;
return true;
}
4.定义Find方法
找到对应索引,之后逐个遍历即可,这里我们同样返回指针:
Node* Find(const K& key)
{
size_t hashi = key % _table.size();
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
5.定义Erase方法
找到并释放,但是要注意要让前一个节点的_next指向删除节点的下一个节点。且要注意头删的情况:
bool Erase(const K& key)
{
size_t hashi = key % _table.size();
Node* cur = _table[hashi];
Node* parent = nullptr; //记录父节点
while (cur)
{
if (cur->_kv.first == key)
{
//要考虑parent是否为空
if (parent)
{
parent->_next = cur->_next;
}
else
{
_table[hashi] = cur->_next;
}
delete cur;
return true; //删除完毕
}
parent = cur;
cur = cur->_next;
}
return false;
}
6.定义析构方法
因为我们这里new了,所以就要用delete释放空间,所以就要写析构函数。
//析构
~HashTable()
{
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
Node* nextP = nullptr;
while (cur)
{
nextP = cur->_next;
delete cur;
cur = nextP;
}
//记得置空
_table[i] = nullptr;
}
}
7.实现扩容
当我们数据个数达到_table.size()时,就扩容。
依旧是二倍扩容,但是我们不再使用之前的方法扩容,因为会释放和新建节点,效率低下。
我们只需要改变节点指针指向即可,这里我们给出完整的Insert代码:
bool Insert(const pair<K, V>& kv)
{
//先复用Find
Node* ret = Find(kv.first);
if (ret)
{
//存在 无需插入
return false;
}
//扩容
if (_n == _table.size())
{
HashTable newHT;
newHT._table.resize(_table.size() * 2, nullptr);
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
while (cur)
{
Node* nextP = cur->_next;
//不复用Insert
size_t hashi = cur->_kv.first % newHT._table.size();
//直接头插即可
cur->_next = newHT._table[hashi];
newHT._table[hashi] = cur;
cur = nextP;
}
//记得置空
_table[i] = nullptr;
}
//交换
_table.swap(newHT._table);
}
//需要开辟空间
Node* newNode = new Node(kv);
size_t hashi = kv.first % _table.size();
//头插
newNode->_next = _table[hashi];
_table[hashi] = newNode;
++_n;
return true;
}
最后我们当然要能够处理string类,所以我们可以将之前的仿函数作为全局函数,之后给HashTable多加一个模板参数。这里不再赘述。
总结:
本章内容相对红黑树部分比较简单,当然需要用红黑树的基础才会更好的学习。下一篇我将带领大家讲本篇的哈希桶封装为unordered_set和unordered_map,也就类似于我们之前将红黑树封装为set和map一样,很复杂。
大家继续追剧,别掉队!