1. unordered系列关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到logN。后来在C++11中STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的使用方法类似,但是底层结构不同,事实上底层是哈希表,因此查询时效率可达到O(1)
这四个容器分别叫 unordered_set 、unordered_multiset 、unordered_map 、unordered_multimap。它们的函数接口就不展示了,我们在set与map中基本上都涉及过了,还有几个哈希结构特有的函数接口我们在讲底层的时候在说。
Containers - C++ Referencehttps://legacy.cplusplus.com/reference/stl/
2. 底层结构
一般来讲搜索的效率与关键码的比较次数有关,顺序表的关键码值最坏比较次数是N,平衡搜索树的最坏比较次数是logN。那最理想的搜索方案就是不经过任何比较,直接一次找到需要的关键码。
如果构造一种储存结构,通过某种方法使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快的找到该元素。
2.1 哈希概念
插入元素:根据待插入元素的关键码,以此计算该元素的存储位置并按此位置存放。
搜索元素:对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置取元素比较,若关键码值相等,则搜索成功。
该方式为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或称散列表)。
哈希是一种映射的思想,这种思想其实我们在排序那节中就接触过了,计数排序的时候我们让数字去映射数组的下标,每出现一次该数字就在下标对应位置+1,最后得到数字出现频率的高低顺序。
2.2 哈希函数与哈希冲突
我们可以借助上面的思想,让数据的关键码值作为数组的下标让数组形成一个哈希表,但是这么做会存在一个要求的数据关键码值不集中就会导致浪费空间的问题,关于这个问题我们解决办法就是让数据的关键码值取模哈希表所开空间大小 hash(key) = key%capacity
这个hash()函数我们称之为哈希函数,它用来控制映射的规则
如果我们再向上面的哈希表中插入一个36会怎样,6中已经存上数据了此时两个数据的存储发生了冲突,这个冲突就叫哈希冲突
引发哈希冲突的原因就是哈希函数设置的不合理。
哈希函数的设置原则:
1. 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间,就是说哈希函数映射出的位置必须在哈希表中。
2. 哈希函数计算出来的地址能均匀分布在整个空间中。
3. 哈希函数应该比较简单
2.3 解决哈希冲突
解决哈希冲突的两种常用办法是:闭散列、开散列
2.3.1 闭散列(开放地址法)
闭散列也叫开放地址法,当发生哈希冲突时,如果哈希表为被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置的"下一个"空位中去。这个寻找空位的也有两种方案,线性探测和二次探测
线性探测
线性探测就是从被占的关键码值位置一个一个挨着向后看,遇到空位置就把这个元素插入,就比如我们现在想插入36,但是6的位置发生哈希冲突了,那就往后看,7是空着的就存到7的位置上去。
36存进去之后又要存37,那往后找至顺序表结束之后要有回溯的操作,从顺序表头开始向后找。
但是这个方案会造成数据拥堵的问题,某个范围内的数据会异常爆满,但别的地方还很空旷,这不符合哈希函数设置的第二条原则
二次探测
每次从映射位置向后找 i的二次方 的位置是否为空,第一次找映射位置后的 1^2 位置,第二次找映射位置后的 2^2 位置,第三次找射位置后的 3^2 位置。
这个方案一定程度上解决了局部数据拥堵的问题,但还是治标不治本。
不过不用担心,解决哈希冲突还有另一类方法:开散列,或者叫哈希桶或拉链法,这个方案可以治本。但是先不急,我们先实现一下线性探测的方案
线性探测的实现
线性探测的思路中还有一个坑没解决,比如此时要查找36。按哈希函数的逻辑来走,36的映射位置是6,6位置被占用了,而且不是36,那就往后走,发现36找到了。
但如过我们现在把10086删除了,这个位置现在是空的,然后再查找36,按哈希函数逻辑来走,映射6位置,但6位置为空,那就说明36不在哈希表中,没找到退出了。这个结果明显是错的,为了解决这个问题我们对每个位置引入3个状态,存在、空、删除。
此时哈希表的框架就可以写成这样
插入数据
插入数据时先算出应该的存放位置,然后判断如果位置被占了就一直向后走知道有一个空位置,或被删除的位置,寻找的位置超出容器长度就重回第一个位置找。
但是这么写是不对的,如果此时哈希表已经满了,那这个函数就会陷入死循环,因此我们要加入扩容逻辑。当然还有插入成功和失败的逻辑也还没写。
哈希表的扩容
哈希表中有一个控制扩容的参数叫 载荷因子 一旦载荷因子超过限定值就要进行扩容。
载荷因子 = 表中现有元素个数 / 哈希表的长度
是哈希表装满程度的标志因子。由于表长是定值,与 表中现有元素成正比,因此越大,表中元素越多,插入新元素时产生冲突的概率越大;反之越小,产生冲突的可能性就越小。实际上,哈希表的平均查找长度是载荷因子的函数,只是不同处理冲突的方法有不同的函数
对于开放地址法,载荷因子是特别重要的因素,应严格限制在 0.7~0.8 以下。超过 0.8 查表时的CPU缓存不命中概率指数级上升。因此,一些采用开放定址法的hash库,比如Java的系统库限制了载荷因子为 0.75 ,超过此值将resize扩容。
我们修改一下默认构造函数,让它一上来就先开一部分空间,然后扩容的时候因为我们的 _n 是无符号整形,运算不出0.7这样的小数,所以我们干脆给它乘10,让它的结果变成整数来比较。
扩容逻辑内部我们可以复用insert函数,这不是递归,然后用现代写法交换一下容器就可以完成扩容了。
查找数据
查找数据需要注意查找到的数据不能是删除的状态。
删除数据
自定义哈希码值
至此我们的哈希表还没有完全完成,比如现在插入pair<string, string>类型的元素就会报错,因为string类型无法进行取模,也就没办法找映射的存放位置。此时就要借助别仿函数,生成一个整形哈希关键码值。
如果是可以强转成整形的数据我们就直接强转
如果是string类型(库中支持转成整形了),或类似的不能强转的自定义类型元素,我们就要自己写一个强转的仿函数。
但是库中是支持string类转成整形关键码的,原理就是用到了模板的特化,所以我们在使用库中的哈希容器时是可以不用传第三个模板参数的。
完整代码
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t n = 0;
for (auto e : key)
{
n += e;
}
return n;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
bool insert(const pair<K, V>& kv)
{
Hash hs;
//不接收冗余
if (Find(kv.first))
return false;
//扩容
if (_n * 10 / _tables.size() >= 7)
{
HashTable<K, V, Hash> newHT;
newHT._tables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._state == EXIST)
{
newHT.insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
size_t hashi = hs(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
HashData<K, V>* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._kv.first == key && _tables[hashi]._state == EXIST)
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
return true;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; //哈希表中有效数据个数
};
2.3.2 开散列(拉链法)
开散列法又叫链地址法,首先对关键码集合用哈希函数计算哈希地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头节点从存储在哈希表中。
开散列的实现逻辑与闭散列基本一致,唯一需要注意的是开散列的插入不要完全用现代写法,否则在表中数据量较大的情况下,new节点和delete节点的消耗巨大,不如直接把原表的节点直接拿到新表中去,拿完的时候原表也空了,这时再交换,完全省去了new和delete节点的消耗。
完整代码
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t n = 0;
for (auto e : key)
{
n += e;
}
return n;
}
};
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(const pair<K, V>& kv)
:_kv(kv)
,_next(nullptr)
{}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_tables.resize(10, nullptr);
}
~HashTable()
{
//依次把每个桶置空
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur != nullptr)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{
//不接收冗余
if (Find(kv.first))
return false;
Hash hs;
//负载因子==1 时扩容
if (_n == _tables.size())
{
vector<Node*> newtables(_tables.size() * 2, nullptr);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
//将旧表中的节点直接拿到新表中去
size_t hashi = hs(cur->_kv.first) % newtables.size();
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
size_t hashi = hs(kv.first) % _tables.size();
//头插,不用找尾
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables;
size_t _n = 0; //表中存储数据个数
};
3. 其他接口
剩余这些与set和map有区别的接口其实就是针对哈希桶和哈希结构本身的一些功能。
bucket_count顾名思义就是返回该哈希结构中桶的个数,bucket_size是给它一个关键码,它返回关键码所在桶的长度,bucket是给一个关键码,所在的桶是第几号桶
load_factor返回哈希结构当前的载荷因子,max_load_factor返回设置的或者说最大的载荷因子,剩下两个是控制哈希结构的空间大小用的,如果在使用哈希结构之前我们知道大概需要多大空间,就可以使用这两个接口先把空间开好,避免后期扩容消耗。