个人主页:在肯德基吃麻辣烫
我的gitee:C++仓库
个人专栏:C++专栏
这篇文章进入哈希表的学习,以及详细介绍如何用哈希表封装unordered_map和unordered_set。
在红黑树的查找效率中,平均查找时间复杂度为O(logN)。
但还是不够快,因为有大佬想出了更快的方法。即不像红黑
树那样遍历树进行节点之间的比较,而是直接知道查找的数据在结构中的位置。
怎么知道这个数据在不在呢?直接通过一个映射关系即可找到。
如何实现映射关系?通过函数实现,而这个函数就是哈希函数。
总结:哈希就是能让一个数据直接通过这个哈希函数直接查找是否在结构中,而不需要进行比较。
举个简单的例子:计数排序
计数排序就是一个经典的哈希式算法。
以该图为例,1这个数字出现了1次,3这个数字出现了1次,6这个数字出现了3次。
它的底层算法是这样的:比如接下来要统计的数字为x。
_a[x % _a.size()]++;
通过映射关系,直接找到x这个数字对应在_a顺序表中的下标,再+1即可。
而上面这个就是一个哈希函数
在实际的底层场景,哈希表就是一个vector。
来看下面的一段数据插入哈希表中。
int a[] = {1,7,6,4,5,9};
哈希函数设置为:hash(i) = key % hashtable.size();
此时hash(1) = 1 % 10, hash(7) = 7 % 10, hash(6) = 6 % 10,
hash(4) = 4 % 10, hash(5) = 5 % 10, hash(9) = 9 % 10;
此时如果再插入一个11,就会产生冲突
因为hash(11) = 11 % 10 = 1,11这个数据会通过哈希函数计算后映射到1这个位置,但是1这个位置已经存入了1,此时就产生了哈希冲突。
闭散列也叫做开放定址法,当产生哈希冲突时,如果哈希表未被装满,说明哈希表中必然存在其他空位置,使用线性探测进行探测到空位置,然后在空位置进行插入。
比如在上面的案例中,
1这个位置已经存储数据了,11这个数据就往后找空位置,找到的第一个空位置进行插入即可。
在这种以线性探测来解决哈希冲突的操作中,不能真正地物理删除一个元素,因为你也不知道怎么删除,删除无非就是找一个数进行覆盖,找什么数都不行,所以,我们应该用一种状态表示一个位置是否存在元素,即
一个位置可以存在三种状态:
在这里可以使用枚举来表示三种状态
enum
{
EXIST,
EMPTY,
DELETE
};
所以删除一个元素非常简单,只需要将该位置的状态设置为空即可。
下面来简单实现线性探测的闭散列哈希表。
注意到这里的一些问题:
vector
是无法存储状态的,只能搞一个哈希节点存储数据和状态template<class K,class V>
struct HashData
{
pair<K, V> _kv;
STATUS _status = EMPTY;
};
2.在插入过程中,如果哈希表满了,即每个位置的状态都不是空了该怎么办呢?
O(n)
。O(1)
的水平。负载因子 = _n / hashtable.size();
负载因子 = _n*100 / hashtable.size();
3.在插入函数扩容逻辑中,由于需要重新建立映射关系,所以需要重新获取key,重新计算映射位置,而这些过程刚好是Insert这个函数的工作,所以在这段逻辑中可以复用Insert函数。
namespace open_addr //(开放定址法,闭散列法)
{
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
STATUS _status = EMPTY;
};
//为了解决一个string不能做key的问题,需要增加一个仿函数
template<class K,class V,class HashFunc = DefaultHashFunc<K>>
class HashTable
{
public:
HashTable()
{
_table.resize(10);
}
//Insert不可能会出现插入失败的情况
bool Insert(const pair<K,V>& kv)
{
//扩容问题
//当负载因子>=0.75时
if (_n * 100 / _table.size() >= 75)
{
size_t newsize = _table.size() * 2;
HashTable<K,V,HashFunc> newtable;
newtable._table.resize(newsize);
//将旧数据重新映射到新表中
//在开放定址法中复用Insert的原因是:
//插入过程并没有new的操作,复用更加方便
for (auto& e : _table)
{
newtable.Insert(e._kv);
}
//现代写法,最后newtable出了作用域会调析构,而此时newtabl的空间正好是_table想要的
//这样写也刚好可以将_table的数据析构
_table.swap(newtable._table);
}
HashFunc hf;
//找到映射位置
size_t hashi = hf(kv.first) % _table.size(); //注意必须是%size,如果%capacity
//假如出现capacity扩容了,但是size不是等于capacity的情况,就可能出现越界的问题。
while(_table[hashi]._status == EXIST)
{
++hashi;
hashi %= _table.size(); // 如果走到最后位置的下一个,就回到0位置
}
//走到这里可能为空/删除
_table[hashi]._kv = kv; //如果在HashData将K设置成const K,连插入都不能插入了,最好的方法是封装成迭代器
_table[hashi]._status = EXIST;
++_n;
return true;
}
HashData<const K,V>* Find(const K& key)
{
HashFunc hf;
size_t hashi = hf(key) % _table.size();
while (_table[hashi]._status != EMPTY)
{
if (_table[hashi]._status == EXIST && _table[hashi]._kv.first == key)
{
return (HashData<const K, V>*)&_table[hashi];
}
++hashi;
hashi %= _table.size();
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<const K, V>* ret = Find(key);
if (ret)
{
ret->_status = EMPTY;
return true;
}
return false;
}
private:
vector<HashData< K,V>> _table; // 哈希表
size_t _n = 0; //存储有效数据个数
};
}
所以为了减小哈希冲突而提高效率,需要多一点的空位置,也就是需要更多的空间来减小哈希冲突产生的概率,但这样就会降低空间利用率。
开散列法,又叫哈希桶,不同于前面的用线性探测来解决哈西冲突问题,哈希桶,顾名思义,就是一个位置会挂着几个节点,这些节点被形象地称为桶。
大致结构如下:
vector存储结构就应该修改一下了:
template<class K, class V>
struct HashData
{
typedef HashData<K, V> Node;
HashData(const pair<K,V>& kv)
:_kv(kv)
,_next(nullptr)
{}
pair<K, V> _kv;
Node* _next = nullptr;
};
此时vector存储的每个位置的数据为:
vector<Node*> _hashtable;
vector的每个节点并不存储有效数据,而是一个数据通过哈希函数:key % _hashtable.size()
找到。
找到映射位置后,此时就可以像链表头插一样,将该节点插入哈希表中挂起来。
核心代码如下:
size_t hashi = key % _hashtable.size();
Node* newnode = new Node(kv);
//头插
newnode->next = _hashtable[hashi];
//由于_hashtable[hashi]存储的是一个节点的地址
_hashtable[hashi] = newnode;
插入的过程上面已经讲解了,需要注意的就是一个细节:
这样会降低效率,因为旧的节点仍然可以使用,何必多一层对每个节点拷贝构造呢。
所以可以在遍历旧表的时候,直接顺手牵羊将每个节点牵到新表上。
bool Insert(const pair<K, V>& kv)
{
//扩容问题
//负载因子为1时扩容,此时最坏的情况就是一个位置可能挂好几个节点
//最好情况是每一个位置都只挂一个节点
if (_n == _table.size())
{
size_t newsize = _table.size() * 2;
HashTable<K, V, HashFunc> newht;
newht._table.resize(newsize, nullptr);
//遍历旧的哈希表,重新进行映射关系
//在哈希桶写法中不复用Insert的原因是
//Insert中有new Node的操作,复用时一个个申请新空间,挂上去,最后还得释放旧的节点
//还不如直接把旧表的节点直接顺手牵羊拉下来
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
Node* next = nullptr;
if (cur)
next = cur->_next;
while (cur)
{
size_t newhashi = hf(cur->_kv.first) % newht._table.size();
cur->next = newht._table[newhashi];
newht._table[newhashi] = cur;
cur = next;
if(next)
next = next->_next;
}
}
//现代写法,交换资源
_table.swap(newht._table);
}
HashFunc hf;
//如果待插入节点重复了,那就不插入
if (Find(kv.first))
{
assert(0);
return false;
}
size_t hashi = hf(kv.first) % _table.size();
//头插
Node* newnode = new Node(kv);
//头插 ,假如_table[hashi]不为空
//那么新节点的next就指向当前位置挂着的第一个节点
newnode->_next = _table[hashi];
//更新当前位置挂着的第一个节点
_table[hashi] = newnode;
++_n;
return true;
}
最后不要忘记,交换两个vector。
哈希桶的删除和查找与前面的线性探测几乎一致,这里不再赘述。
实现哈希表并不难,哈希表的结构也比较清晰,难点就难在对哈希表进行封装,这个才是重点。
下集预告:
pair
的问题,这也是为了适配map的operator[]
而产生的。