在C++98中,STL提供了底层为红黑树结构的一系列关联式容器map/set,在查询时效率可达到log_2N
,即最差情况下需要比较红黑树的高度次。
在C++11中,STL又提供了4个unordered系列的关联式容器:unordered_map、unordered_multimap、unordered_set、unordered_multiset。这四个容器与红黑树结构的关联式容器相比查询效率更高接近于O(1),且使用方式基本类似,只是其底层结构不同。
顺序结构以及平衡树
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系。因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N);平衡树中为树的高度,即O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
哈希表
如果构造一种存储结构,通过某种转换函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系。那么在查找时可以不经过任何比较,通过该函数一次直接从表中得到要搜索的元素:
当向该结构中插入元素时:根据待插入元素的关键码,通过转换函数计算出该元素的存储位置并按此位置进行存放。
当从该结构中搜索元素时:对元素的关键码进行同样的计算,获得元素的存储位置。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table或者称散列表)
哈希函数
哈希函数的设计原则:
常见的哈希函数:
直接定址法
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀、不存在哈希冲突
缺点:需要事先知道关键字的分布情况,只适合查找分布相对集中的情况。
举例:1.编程题:字符串中第一个只出现一次字符 2.排序算法:计数排序
除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数;
按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
例如:数据集合{1,7,6,4,5,9};
哈希函数采用除留余数法:hash(key) = key % capacity; capacity为存储元素底层空间的总大小。
问:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
哈希冲突
对于两个数据元素的关键字k_i
和 k_j
,有k_i != k_j
,但有:Hash(k_i) ==Hash(k_j)
;
即:不同关键字通过哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把关键码不同而具有相同哈希地址的数据元素称为“同义词”。
问:发生哈希冲突该如何处理呢?
解决哈希冲突两种常见的方法是:闭散列和开散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的下一个空位置中去。那如何寻找下一个空位置呢?
线性探测
比如2.2中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。
比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
哈希表每个空间给个标记:EMPTY此位置为空, EXIST此位置已经有元素, DELETE元素已经删除
插入时:对于EMPTY和DELETE标记的位置可以进行插入,EXIST不能插入。
搜索时:遇到EXIST和DELETE标记的位置继续向后搜索,遇到EMPTY结束。
扩容
思考:哈希表什么情况下进行扩容?如何扩容?
载荷因子(空间占用率)达到基准值(0.7~0.8)就扩容。
基准值越大,哈希冲突的概率越大,查找效率越低,但空间利用率越高。
基准值越小,哈希冲突的概率越小,查找效率越高,但空间利用率越低。
Hash算法
对于类型不匹配或者复杂类型的key值,不能直接求余计算哈希地址。这时我们需要一种算法,将不匹配或复杂类型的key转化为无符号整型,然后才能通过除留余数法计算哈希地址。我们将这样的算法称为Hash算法。
Hash算法的设计原则是:尽量避免出现key值不同但转换后的无符号整型相同的情况。使不同的key值转换成唯一、独特的无符号整型数据。降低哈希冲突的概率。
以字符串Hash算法为例:
问:为什么不选字符串首字母的assic码做key?
答:字符的assic码共有128个,而字符串有无数种组合方式。单靠首字母的assic码区分字符串,违背了Hash算法的设计原则。会使哈希冲突的概率变大,所以我们取字符串所有字符的assic码和做key。
仍然无法解决的问题:abcd acbd aadd
最终方案:BKDR算法,在每次加和时累乘131,能使哈希冲突的概率大大降低。也是Java目前采用的字符串Hash算法。
线性探测的实现
enum State{
EMPTY,
DELETE,
EXIST
};
template <class K, class V>
struct HashData{
pair<K,V> _kv;
State _state = EMPTY;
};
//HashKey用于将不匹配或复杂的key值转化为size_t类型,然后才能通过除留余数法计算哈希地址。
//对于不匹配的内置类型做强转:
template <class K>
struct HashKey{
size_t operator()(const K& k)
{
return (size_t)k;
}
};
//对于常见复杂类型提供模版的特化:
template <>
struct HashKey<string>{
size_t operator()(const string& str)
{
size_t ret = 0;
for(auto ch : str)
{
ret += ch;
ret *= 131; //BKDR算法
}
return ret;
}
};
template <class K, class V, class Hash = HashKey<K>>
class HashTable{
vector<HashData<K,V>> _table;
size_t _size= 0; //哈希表中的实际有效数据
public:
bool insert(const pair<K,V>& kv){
//不允许键值冗余
if(find(kv.first) != nullptr)
return false;
//检查载荷因子,进行扩容,复用下面的插入逻辑
if(_table.size() == 0 || _size*10/_table.size() >= 7)
{
int newsize = _table.size()==0? 10 : _table.size()*2;
HashTable newHT; //创建新的哈希表对象
newHT._table.resize(newsize);
for(auto &e : _table)
{
if(e._state == EXIST)
newHT.insert(e._kv); //调用成员函数insert重新计算元素的映射位置
}
//交换两个哈希表的vector
//函数返回前newHT包含扩容前的vector会被析构
_table.swap(newHT._table);
}
Hash hash; //hash算法会将不匹配或复杂的key值转化为size_t类型
int hashi = hash(kv.first)%_table.size();
//线性探测
//遇到EMPTY或DELETE位置停下
while(_table[hashi]._state == EXIST)
{
++hashi;
hashi %= _table.size(); //如果超出范围需折返到开头继续探测
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_size;
return true;
}
HashData<K,V>* find(const K& k)
{
if(_table.size() == 0)
return nullptr; //空表返回nullptr
Hash hash;
int hashi = hash(k)%_table.size();
int start = hashi;
//线性探测
//遍历到EMPTY位置表示对应key值的元素不存在。
//注意:遇到DELETE位置不能停,要继续向后查找。
while(_table[hashi]._state != EMPTY)
{
if(_table[hashi]._state == EXIST && _table[hashi]._kv.first == k)
{
return &_table[hashi]; //找到返回数据地址
}
++hashi;
hashi%=_table.size();
//处理极端情况:表中元素的状态全是DELETE
if(hashi == start)
break;
}
return nullptr; //找不到返回nullptr
}
bool erase(const K& k)
{
HashData<K,V>* ret = find(k);
if(ret == nullptr)
return false;
else
{
//线性探测采用标记的伪删除法来删除一个元素
ret->_state = DELETE; //所谓删除就是将对应key值的元素状态改为DELETE
--_size; //记得修改大小哦
return true;
}
}
void printHT(){ //打印哈希表
for(int i=0; i<_table.size(); ++i)
{
if(_table[i]._state == EXIST)
{
printf("[%d]:%d ", i, _table[i]._kv.first);
//cout << _table[i]._kv.first << ":" << _table[i]._kv.second << endl;
}
else
{
printf("[%d]:* ", i);
}
}
}
};
二次探测
线性探测的优点是实现非常简单,但其缺陷是元素之间相互占用位置导致产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找:H_i = (H_0 + i )% m
或 H_i = (H_0 - i )% m
因此二次探测为了避免该问题,找下一个空位置的方法为:H_i = (H_0 + i^2 )% m
, 或者 H_i = (H_0 - i^2 )% m
。
其中:i =1,2,3…。 H_0是通过散列函数Hashfunc(key)对元素的关键码 key 进行计算得到的位置。m是表的大小。
将线性探测改为二叉探测
bool insert(const pair<K,V>& kv){
if(find(kv.first) != nullptr)
return false;
//检查载荷因子,进行扩容
//......
Hash hash;
int i = 1;
int hashi = hash(kv.first)%_table.size();
//二次探测
while(_table[hashi]._state == EXIST)
{
hashi += i*i; //加i的平方
hashi %= _table.size();
++i;
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_size;
return true;
}
提示:对应的find函数也应该改为二次探测才能正确运行!
二次探测只能在一定程度上缓解线性探测带来的“洪水效应”,但其终归是占用式的,没有从根源上解决因占用而导致的冲突问题。
概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
仍以2.2中的场景为例:
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
对比哈希表和红黑树
查找
哈希表的查找更快:O(1);红黑树的查找:O(log_2N)
如果某个哈希桶过长(一般不会),可以考虑挂红黑树,以提高该哈希桶的搜索速度。
插入
红黑树的插入:消耗主要在查找空位置O(log_2N)+变色O(log_2N)+旋转O(1) ==> O(log_2N)。
哈希表的插入:消耗主要在扩容,不仅要开空间拷贝数据,还要重新计算每个元素的哈希地址。扩容的时间复杂度O(N)
使用rehash/reserve提前开空间,提高哈希表的插入效率。
unordered_map和unordered_set底层的哈希结构采用的就是开散列法。
//数据节点的结构
template <class T>
struct HashNode{
typedef HashNode<T> Node;
T _data; //泛型底层哈希表的存储类型,通过不同的实例化参数,实现出unordered_map和unordered_set。
Node *_next; //指向下一个节点的指针
HashNode(const T& data = T(), Node *next = nullptr)
:_data(data),
_next(next)
{}
};
//默认哈希算法
template <class K>
struct Hashkey{
size_t operator()(const K& key){
return (size_t)key;
}
};
template <>
struct Hashkey<string>{
size_t operator()(const string& str){
size_t ret = 0;
for(char e : str)
{
ret += e;
ret *= 131;
}
return ret;
}
};
//哈希表的结构(哈希桶)
template <class K, class T, class Hash, class KofT>
class HashTable{
typedef HashNode<T> Node;
vector<Node*> _table; //数组存放指向节点的指针
size_t _size = 0;
//将迭代器设为友元类,注意类模版要带模版参数
template <class k, class t, class hash, class kofT>
friend class __Hashiterator;
public:
//迭代器
typedef __Hashiterator<K, T,Hash,KofT> iterator;
iterator begin(){
for(int i=0; i<_table.size(); ++i)
{
if(_table[i] != nullptr)
return iterator(_table[i], this);
}
return end();
}
iterator end(){
return iterator(nullptr, this);
}
//析构函数
~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;
}
}
//查找、插入、删除
iterator find(const K& key);
pair<iterator, bool> insert(const T& data);
bool erase(const K& key);
size_t size(){
return _size;
}
private:
//__stl_next_prime用于获取下一个大于n的质数作为扩容后的容量
size_t __stl_next_prime(size_t n);
};
//前置声明
template <class K, class T, class Hash, class KofT>
class HashTable;
template <class K, class T, class Hash, class KofT>
class __Hashiterator{
typedef HashNode<T> Node;
typedef HashTable<K,T,Hash,KofT> HT;
typedef __Hashiterator<K,T, Hash, KofT> iterator;
Node *_pnode; //指向数据节点的指针
HT *_pht; //指向哈希表对象的指针
public:
__Hashiterator(Node *pnode, HT *pht)
:_pnode(pnode),
_pht(pht)
{}
public:
T& operator*() const{
return _pnode->_data;
}
T* operator->() const{
return &_pnode->_data;
}
bool operator==(const iterator& it) const{
return it._pnode == _pnode;
}
bool operator!=(const iterator& it) const{
return it._pnode != _pnode;
}
iterator& operator++(){
if(_pnode->_next != nullptr)
{
//在当前桶中迭代
_pnode = _pnode->_next;
}
else
{
//找下一个桶
KofT kot;
Hash hash;
size_t hashi = hash(kot(_pnode->_data)) % _pht->_table.size();
int i = hashi + 1;
for(; i<_pht->_table.size(); ++i)
{
if(_pht->_table[i] != nullptr)
{
_pnode = _pht->_table[i];
break;
}
}
//如果后面没有有数据的桶了
if(i == _pht->_table.size())
_pnode = nullptr;
}
return *this;
}
iterator operator++(int){
iterator it(*this);
++*this;
return it;
}
};
iterator find(const K& key){
if(_table.size() == 0)
return end();
Hash hash;
KofT kot;
size_t hashi = hash(key) % _table.size();
Node *cur = _table[hashi];
while(cur != nullptr)
{
if(kot(cur->_data) == key)
{
return iterator(cur, this);
}
cur = cur->_next;
}
return end();
}
pair<iterator, bool> insert(const T& data){
KofT kot;
Hash hash;
//去重
iterator ret = find(kot(data));
if(ret != end())
return make_pair(ret, false);
//扩容
//当载荷因子为1时进行扩容
if(_size == _table.size())
{
//获取大于_table.size()的下一个质数作为新容量
size_t newsize = __stl_next_prime(_table.size());
//创建新数组
vector<Node*> newtable;
newtable.resize(newsize);
//计算元素在新表中的哈希地址,并将节点移动到新表
for(size_t i=0; i<_table.size(); ++i)
{
Node *cur = _table[i];
Node *next = nullptr; //用于记录cur->_next
while(cur != nullptr)
{
size_t hashi = hash(kot(cur->_data)) % newsize;
next = cur->_next;
//将节点头插到新表的对应桶中
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;
}
//最后将旧表中的桶置空,防止析构时释放新表中的节点
_table[i] = nullptr;
}
//交换新旧两表
swap(_table, newtable);
}
//插入
size_t hashi = hash(kot(data)) % _table.size();
Node *newnode = new Node(data);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_size;
return make_pair(iterator(newnode, this), true);
}
private:
//__stl_next_prime用于获取下一个大于n的质数作为扩容后的容量
inline size_t __stl_next_prime(size_t n)
{
//预置一个质数表,从表中依次取质数作为扩容后的容量
static const int __stl_num_primes = 28;
static const size_t __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
for(int i=0; i<__stl_num_primes; ++i)
{
if(__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
bool erase(const K& key){
//空表删除返回false
if(_table.size() == 0)
return false;
Hash hash;
KofT kot;
size_t hashi = hash(key) % _table.size();
Node *cur = _table[hashi];
Node *prev = nullptr; //记录cur的前驱节点便于删除节点前进行连接
while(cur != nullptr)
{
//找到进行删除
if(key == kot(cur->_data))
{
//如果删除的是头结点
if(cur == _table[hashi])
_table[hashi] = cur->_next;
else
prev->_next = cur->_next;
delete cur;
--_size; //不要忘了修改_size
return true;
}
prev = cur;
cur = cur->_next;
}
//找不到返回false
return false;
}
set和unordered_set的key类型(K)分别有什么要求?
set中的key类型:
unordered_set中的key类型:
提示:模拟实现代码中没有体现unordered_set的等于比较仿函数(class Pred)。
#pragma once
#include "HashTable.hpp"
namespace zty{
template <class K, class Hash = Hashkey<K>>
class unordered_set{
struct SetKofT{
const K& operator()(const K& key){
return key;
}
};
typedef HashNode<K> Node;
typedef HashTable<K, K, Hash, SetKofT> HT;
HT _ht;
public:
typedef typename HT::iterator iterator;
iterator begin(){
return _ht.begin();
}
iterator end(){
return _ht.end();
}
size_t size(){
return _ht.size();
}
pair<iterator, bool> insert(const K& key){
return _ht.insert(key);
}
iterator find(const K& key){
return _ht.find(key);
}
bool erase(const K& key){
return _ht.erase(key);
}
};
}
#pragma once
#include "HashTable.hpp"
namespace zty
{
template <class K, class V, class Hash = Hashkey<K>>
class unordered_map{
struct MapKofT{
const K& operator()(const pair<K,V>& kv){
return kv.first;
}
};
typedef HashTable<K,pair<K,V>,Hash,MapKofT> HT;
HT _ht;
public:
typedef typename HT::iterator iterator;
iterator begin(){
return _ht.begin();
}
iterator end(){
return _ht.end();
}
pair<iterator, bool> insert(const pair<K,V>& kv){
return _ht.insert(kv);
}
//重载operator[]
V& operator[](const K& key){
auto ret = _ht.insert(make_pair(key, V()));
return ret.first->second;
}
iterator find(const K& key){
return _ht.find(key);
}
bool erase(const K& key){
return _ht.erase(key);
}
};
}