1.undered系列的关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 l o g 2 N log_2 N log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,只对unordered_map和unordered_set进行介绍。
1.1 unordered_map
- unordered_map是存储<key, value>键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
- 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
- 在内部,unordered_map没有对<kye, value>按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
- unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
- unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问value。
- 它的迭代器至少是前向迭代器
1.2 unordered_set
这里不多做解释。
请参见http://www.cplusplus.com/reference/unordered_set/unordered_set/?kw=unordered_set
1.3 unordered系列容器和普通容器的区别
- unordered系列的map与set两者与map和set的相似度90%
- map和set是双向迭代器 O(logN)
unordered_set:运用单向迭代器 O(1)- 重复数据多时,unordered全方面碾压map和set
- unordered系列容器,存储数据是无序的
map和set容器,存储数据是有序的
2.底层结构
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
2.1 哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放- 搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
什么是哈希/散列?
映射:值和值进行1对1或者1对多的关联
值和位置直接或者间接映射
1.值很分散
2.有些值不好映射,比如:string,结构体对象
2.2 哈希冲突
对于两个数据元素的关键字 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),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
除留取余法之后,会有一些值会放在同一个位置,这个就叫做哈希冲突。
2.3 哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数
- 直接定址法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
面试题:字符串中第一个只出现一次字符- 除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,
按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址- 平方取中法–(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况- 折叠法–(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这
几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况- 随机数法–(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中
random为随机数函数。
通常应用于关键字长度不等时采用此法- 数学分析法–(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定
相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只
有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散
列地址。
2.4 哈希冲突解决(闭散列和开散列)
2.4.1 线性探测的概念(闭散列)
解决哈希冲突两种常见的方法是:闭散列和开散列
- 闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去
那如何寻找下一个空位置呢?
- 线性探测
比如2.1中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 插入
a. 通过哈希函数获取待插入元素在哈希表中的位置
b. 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
- 删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
2.4.2 开放地址法(线性探测)的模拟实现
哈希表的模拟实现,用一个vector数组来存放哈希数据,每个数据要有相应的状态。
1.节点数据的状态条件
// 哈希表中的三种状态
enum State
{
EMPTY,// 空
EXIST,// 值在的话,就是存在
DELETE // 删除
};
2.哈希数据
// 哈希的数据
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // 存放的数据
State _state = EMPTY; // 数据虽在的状态,初始状态置为空
};
3.哈希表
// 哈希表
// key value 仿函数
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
// 构造函数
HashTable()
{
_tables.resize(10);// 防止负载因子除数为零的情况,提前初始化空间
}
//...
private:
vector<HashData<K, V>> _tables;// 哈希表
size_t _n;// 由于哈希表不是每个索引都有数据,所以要记录有效数据的个数
};
哈希表中的一些类型结构
- 插入
a.插入数据不能冗余
b.应用载荷因子, 载荷因子/负载因子 = 填入表中的元素个数/表的总长度,负载因子达到0.7时,进行扩容,将旧表数据插入到新表当中
c.出留取余法时,取余的key值可以是很多类型,所以要应用,仿函数来返回可以取余的类型,进行强制转换成int型,用ASCII码值进行区分。对于string类型,常见类型进行单独处理,进行特化。
// 普遍的仿函数
// 用于可以直接转换成整形的类型
// double,int,float,char
template<class K>
struct HashFunc
{
// 比较他们的ASCII码值
size_t operator()(const K& key)
{
return (size_t)key;// 也不怕复数,都会为正数
}
};
// 如果是string类型的数据比较
// string数据比较的仿函数
// 特化
// 以适应编译器对上面的仿函数遇到特殊情况的处理
template<>
struct HashFunc<string>
{
// 1.通常是将string的首字符的ASCII码值进行比较,但是可能会出现收字符是一样的情况
// 2.将string的字符的ASCII码值全部加在一起,但是下面的情况,字符不一样,但是加起来的ASCII码值是一样的
// abcd
// bcad
// aadd
// 如果是上述的情况,则会无法判断
size_t operator()(const string& key)
{
size_t hash = 0;// 存放字符ASCII的总值
for (auto ch : key)
{
// 3.(BKDR)每次字符乘以131,则可以保证ASCII的码值会不一样
hash *= 131;
hash += ch;
}
return hash;
}
};
string类型,转化为ASCII码可能会有重复,所以我们将string的所有字符的ASCII码值*131,之后再加起来,则不会有相等的情况,即为BKDR方法。
// 插入
bool Insert(const pair<K, V>& kv)
{
// 判断不能冗余
if (Find(kv.first))
{
return false;
}
// 扩容
// 载荷因子/负载因子 = 填入表中的元素个数/表的总长度
// 负载因子越高,冲突率越高,效率就越低
// 负载因子越小,冲突率越低,效率就越高,空间利用率越低
if (_n / _tables.size() >= 0.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);
}
}
}
Hash hs;// 仿函数对象,用于不同类型对象的通用比较
size_t hashi = hs(kv.first) % _tables.size();// 除留取余法,寻找相应的位置
// 如果除留取余法判断的hashi位置有数据,则要进行线性探测找到可以放置数据的位置
// 线性探测:是一个循环探测,探测完后,在绕到开头,继续探测
// EXIST位置不能插入,其他的EMPTY和DELETE都可以插入数据
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();// 防止探测到最后,循环探测
}
// 找到位置之后
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
- 查找
a.用除留取余法找到相应的位置,如果当时的位置的数据不匹配,则时进行线性探测,向后查找,查找空就停。
b.查找时,也会有查找到删除了的值只是将状态置为DELETE,但是本身实际的数据没有删除可以找到,所以查找时,要不为空的状态查找。
// 查找
// 查找时,也会有查找到删除了的值可以找到,由于删除的时候只是修改状态
// 并没有真的删除那个值,所以需要加上条件
HashData<K, V>* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();// 除留取余法,寻找相应的位置
// 如果除留取余法判断的hashi位置有数据,则要进行线性探测找到可以放置数据的位置
// 线性探测,从除留取余法的hashi位置,向后查找
// 为空则就停止
while (_tables[hashi]._state != EMPTY)
{
// 数据存在并且找到的key是相等的
// 防止是之前删除的值被找到,有可能状态是DELETE的数据也是一样的
if (_tables[hashi]._state == EXIST &&
_tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();// 循环查找
}
return nullptr;// 没找到返回空
}
- 删除
用Find先查找,查找到,如果为空,就不存在,不为空则将状态置为DELETE
// 删除
// 找到位置,直接将状态置为DELETE
bool Erase(const K& key)
{
// 先查找,查找到,如果为空,就不存在,不为空则将状态置为DELETE
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
--_n;
return true;
}
}
线性探测优点:实现非常简单,
线性探测缺点:**一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。**如何缓解呢?
二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: H i H_i Hi = ( H 0 H_0 H0 + i 2 i^2 i2 )% m, 或者: H i H_i Hi = ( H 0 H_0 H0 - i 2 i^2 i2 )% m。其中:i =1,2,3…, H 0 H_0 H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
2.4.3 哈希桶的概念(开散列)
- 开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中
2.4.4 哈希桶的模拟实现
哈希同是由一个存放节点指针的vector数组,将节点都挂在这个数组的节点上。
- 节点数据
// 节点数据
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)
{}
};
- 实现哈希表
析构函数应该自己写,vector数组会调用自己的析构函数,但是我们开辟的节点空间需要自己单独释放
// 哈希表
// Key Value 哈希强制类型转换成相应int类型,ASCII码值
template< class K,class V,class Hash= HashFunc<K>>
class HashTable
{
public:
// 重定义节点
typedef HashNode<K, V> Node;
// 构造函数
HashTable()
{
_tables.resize(10,nullptr);// 提前开辟好空间
_n = 0;
}
// 析构函数
~HashTable()
{
// vector的数组有自己的析构函数
// 我们只有写相应的Node*节点的销毁
// 遍历整个vector,全部删除节点
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;
}
}
// ...
private:
vector<Node*> _tables;
size_t _n;
//vector<list<pair<K, V>>> _tables; 不用这种,这种不好套迭代器
};
- 插入
a.扩容,负载因子为1,理想状态下,平均每个桶下面挂一个数据,将旧表上的节点数据,挂在新表的相应位置。
b.头头插节点
除留取余法,由于不知道key的类型,应该用仿函数进行判断
// 普遍的仿函数
// 用于可以直接转换成整形的类型
// double,int,float,char
template<class K>
struct HashFunc
{
// 比较他们的ASCII码值
size_t operator()(const K& key)
{
return (size_t)key;// 也不怕复数,都会为正数
}
};
// 如果是string类型的数据比较
// string数据比较的仿函数
// 特化
// 以适应编译器对上面的仿函数遇到特殊情况的处理
template<>
struct HashFunc<string>
{
// 1.通常是将string的首字符的ASCII码值进行比较,但是可能会出现收字符是一样的情况
// 2.将string的字符的ASCII码值全部加在一起,但是下面的情况,字符不一样,但是加起来的ASCII码值是一样的
// abcd
// bcad
// aadd
// 如果是上述的情况,则会无法判断
size_t operator()(const string& key)
{
size_t hash = 0;// 存放字符ASCII的总值
for (auto ch : key)
{
// 3.(BKDR)每次字符乘以131,则可以保证ASCII的码值会不一样
hash *= 131;
hash += ch;
}
return hash;
}
};
插入
// 插入
bool Insert(const pair<K,V>& kv)
{
// 扩容
// 负载因子为1,理想状态下,平均每个桶下面挂一个数据
// 第一种扩容,会不断开辟新空间,会造成浪费
//if (_n == _tables.size())
//{
// // 建立一个新表
// HashTable<K, V> newHT;
// newHT._tables.resize(_tables.size() * 2);// 新表是旧表空间的两倍
// // 将旧表的数据加入到新表中
// for (size_t i = 0; i < _tables.size(); i++)
// {
// // 取旧表中的每一个指针
// Node* cur = _tables[i];
// while (cur)
// {
// // 直接插入到新表
// newHT.Insert(cur->_kv);
// cur = cur->_next;
// }
// }
// // 将新表和旧表在此交换
// // 则就变成_tables指向这份新表的空间了
// _tables.swap(newHT._tables);
//}
Hash hs;
// 第二种扩容,建立一个Node*数据的数组
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)
{
// 记录旧表的cur的下一节点位置,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);
// 头插新节点
// _tables[hashi]只是一个指针,并不是哨兵位
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
return true;
}
- 查找
a.除留取余法,先找到key所在数组的索引位置
b.进行单链表节点的数据查找
// 查找
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;
}
else// 不相等,则就继续向下查找
{
cur = cur->_next;
}
}
// 找完了,没找到
return nullptr;
}
- 删除
a.除留取余法,先找到key所在数组的索引位置
b.进行单链表节点的数据查找到所匹配的节点数据
c.节点删除分为删除头节点和删除非头节点
// 删除
// 找到相应的key
bool Erase(const K& key)
{
Hash hs;
// 先找到key在数组中的相应位置
size_t hashi = hs(key) % _tables.size();
// 删除节点
// 提前记录前一节点的指针
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
// 如果找到相应的节点时
if (cur->_kv.first == key)
{
// 开始删除
// 删除头节点
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else// 删除非头节点
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}