unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(
l
o
g
2
N
log_2 N
log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
哈希冲突
对于两个数据元素的关键字
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),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?
哈希函数:
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
常见的哈希函数:
- 直接定址法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
- 除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,
按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
线性探测插入:
上面这个场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,
因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入通过哈希函数获取待插入元素在哈希表中的位置。如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素
会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影
响。因此线性探测采用标记的伪删除法来删除一个元素。
提示:以下是本篇文章正文内容,下面案例可供参考
哈希表中元素状态
enum State
{
EMPTY,//空
EXIST,//存在
DELETE,//删除
};
存储类型用pair即可,但是数据中要包含状态,我们进行一次封装
//由于数据需要一个状态,所以需要将pair封装一层
template<class K,class V>
struct HashDate
{
pair<K, V>_kv;
State _state;
};
构建哈希表的内容
template<class K,class V>
class HashTable
{
public:
bool Insert(const pair<K,V>& kv);
HashDate<K, V>* find(const K& key);
bool Erase(const K& key);
private:
vector<HashDate<K,V>> _table;
size_t _n= 0;
};
闭散列的插入:
bool insert(const pair<K, V>& kv)
{
if (Find(kv.first))return false;
if (_table.size() == 0 || 10 * _n / _table.size() >= 0.7) //如果hash表的负载因子 >= 0.7 || hash表一开始为空
{
size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable<K, V> NewHashTable;
NewHashTable._table.resize(newsize);
for (auto& e : _table) //这里的e每一个vector里面的值-》pair AND _s
{
if (e._s == EXIST)
{
NewHashTable.insert(e._kv); //对象不同,调用的成员函数里面的内容也不同
}
}
//走到这里,说明已经将原来的vector里面的内容拷贝到现在newHashTable里面
_table.swap(NewHashTable._table); //vector析构的时候会调用HashData的析构函数,但是HashData里面没有动态开辟的内存,所以不需要在HashData里面写一个析构函数
}
HashFuni<K> hashfuni;
size_t hashi = hashfuni(kv.first) % _table.size(); //计算表中的下标
while (_table[hashi]._s == EXIST)
{
hashi++;
hashi %= _table.size();
}
_table[hashi]._kv = kv;
_table[hashi]._s = EXIST;
_n++;
return true;
}
闭散列的查找:
HashData<K, V>* Find(const K& key)
{
HashFuni<K> hashfuni;
size_t hashi = hashfuni(key) % _table.size();
while (_table[hashi]._s != EMPTY)
{
//这里必须使用两个条件,因为我们的HashTable的删除并不是真正的删除,仅仅只是修改状态_s和_n
if (_table[hashi]._kv.first == key && _table[hashi]._s == EXIST)
{
return &_table[hashi];
}
hashi++;
hashi %= _table.size();
}
return nullptr;
}
闭散列的删除:
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_s = DELETE;
_n--;
return true;
}
else
return false;
}
开散列:又叫链地址法(开链法),首先对key值集合用哈希函数计算映射下标,具有相同下标的key值归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
如上图所示,此时的哈希表中存放的是一个单链表的头指针。
- 不同的数据,根据哈希函数算出的映射位置发生哈希碰撞时,这些碰撞的数据会挂在哈希表对应位置指向的单链表中。这些单链表被形象称为桶。
- 每个桶中放的都是发生哈希冲突的元素。
- 当有新数据插入时,进行头插。
如上图中所示,7,27,57,根据哈希函数都映射到哈希表下标为7的位置,这几个数据按照头插的顺序以单链表的形式挂在哈希表下标为7的位置。
新插入的数据如果尾插的话,在找单链表的尾部时,会有效率损失,由于没有排序要求,所以头插是效率最高的。
闭散列的方法,通常被称为哈希桶,使用的也最广泛,能够解决闭散列中空间利用率不高的问题。
采用哈希同的方式来解决哈希碰撞时,哈希表中存放的数据是单链表的头节点,如上图所示。
- 链表节点中,有键值对,还有下一个节点的指针。
- 仍然使用闭散列中转换整形的仿函数。
在哈希桶的构造函数中,哈希表的初始大小是10个元素,每个元素都是nullptr,因为此时还没有桶。
哈希桶必须有析构函数,闭散列的方式,默认生成的析构函数就能满足要求,但是哈希桶不可以。
如果只使用默认生成的析构函数,在哈希桶销毁的时候,默认的析构函数会调用vector的析构函数。
vector的析构函数只会释放vector的本身,而不会释放vector上挂着的桶。
插入的扩容
一般情况下,当哈希表的负载因子等于1的时候,发生扩容。
在将旧表中的数据插入新表的时候,每插入一个,新表就需要new一个节点,旧表中的所有节点都会被new一遍。
然后将旧表中的所有节点再释放,这里做了没必要的工作。相同的一个节点,会先在新表中new一个,再释放旧表的。
新表中完全可以不再new新的节点,直接使用旧表中的节点。
- 旧表中可以直接复用的节点是:改变了哈希表容量以后,映射关系不变的节点。
- 比如节点27,哈希表的容量从10变成20,但是映射后的下标仍然是7,这样的节点就可以复用。
那些映射关系变了的节点就不可以直接复用了,需要改变所在桶的位置。
如节点18,哈希表的容量从10变成20,映射后的下标从8变成18,此时就需要改变18所在的桶了。
这里不用创建新的哈希桶结构,只创建底层的vector就可以,因为不再复用Insert了。将旧表中的数据一个个拿出来,通过哈希函数重新计算映射关系,并且头插到新新表的桶中。
旧表的每个桶中的数据处理完后,必须把表中的单链表头置空,因为此时新表和旧表都指向这些桶,否则在旧表析构的时候会析构掉所有桶,导致新表中没有数据。
如上图所示,使用Find先找到key值所在哈希表中的位置,然后删除。
哈希表挂的桶是单链表,只指定要删除节点是无法进行删除的,必须指定前一个节点,否则无法再链接。
所以上面的方法是不能用的,只能拿着key值通过哈希函数重新寻找哈希表中的key值,在这个过程中同时记录前一个节点prev。
bool Erase(const K& key)
{
HashFuni<K> hashfuni;
size_t hashi = hashfuni(key) % _table.size(); //计算表中的下标
Node* cur = _table[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
if (cur == _table[hashi])
{
_table[hashi] = cur->_next;
delete cur;
}
else
{
prev->_next = cur->_next;
delete cur;
}
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}