文章目录
一、unordered系列容器和map、set
1.unordered_map的使用
函数声明 | 功能介绍 |
---|---|
bool empty() const | 检测unordered_map是否为空 |
size_t size() const | 获取unordered_map的有效元素个数 |
begin | 返回unordered_map第一个元素的迭代器 |
end | 返回unordered_map最后一个元素下一个位置的迭代器 |
cbegin | 返回unordered_map第一个元素的const迭代器 |
cend | 返回unordered_map最后一个元素的下一个位置的const迭代器 |
operator[] | 返回与key对应的value,没有一个默认值 |
iterator find(const K& key) | 返回key在哈希桶中的位置 |
size_t count(const K& key) | 返回哈希桶中关键码为key的键值对的个数 |
insert | 向容器中插入键值对 |
erase | 删除容器中的键值对 |
void clear() | 清空容器中有效元素的个数 |
void swap(unordered_map& ) | 交换两个容器中的元素 |
size_t bucket count() const | 返回哈希桶的总个数 |
size_t bucket size(size_t n) const | 返回n号桶中有效元素的总个数 |
size_t bucket(const K& key) | 返回元素key所在的桶号 |
#include <iostream>
#include <map>
#include <unordered_map>
#include <string>
using namespace std;
void test()
{
unordered_map<string, string> unmp;
unmp.insert(make_pair("insert", "插入"));
unmp.insert(make_pair("sort", "排序"));
unmp.insert(make_pair("unordered", "没有顺序的"));
unmp["set"];//插入
unmp["unordered"] = "无序的";
unmp["map"] = "地图";//插入+修改
auto it = unmp.begin();
while (it != unmp.end())
{
cout << it->first << " : " << it->second << endl;
++it;
}
cout << endl;
}
int main()
{
test();
return 0;
}
- unordered_map和map的使用差不多,只不过unordered_map是无序的,而map是按照key的顺序存储的。
- unordered_map的效率比map高。
2.unordered_set的使用
#include <iostream>
#include <set>
#include <unordered_set>
#include <string>
using namespace std;
void test()
{
unordered_set<int> us;
us.insert(4);
us.insert(5);
us.insert(6);
us.insert(3);
us.insert(2);
us.insert(1);
set<int> s;
s.insert(4);
s.insert(5);
s.insert(6);
s.insert(3);
s.insert(2);
s.insert(1);
unordered_set<int>::iterator usit = us.begin();
cout << "unordered_set的遍历:" << endl;
while (usit != us.end())
{
cout << *usit << " ";
++usit;
}
cout << endl;
cout << endl;
cout << "set的遍历:" << endl;
for (auto e : s)
cout << e << " ";
cout << endl;
cout << endl;
cout << "删除unordered_set中的元素3之后:" << endl;
auto uspos = us.find(3);
if (usit != uspos)
us.erase(uspos);
for (auto e : us)
cout << e << " ";
cout << endl;
}
int main()
{
test();
return 0;
}
- unordered_set和set的使用差不多,只不过unordered_set是无序的,而set是按照key的顺序存储的。
- unordered_set的效率比set高。
3.unordered系列容器与map和set的效率对比
- 下面用unordered_set和set的效率对比来作性能对比
#include <iostream>
#include <time.h>
#include <set>
#include <unordered_set>
#include <string>
using namespace std;
void test()
{
set<int> s;
unordered_set<int> us;
srand(time(0));
const size_t n = 1000000;
vector<int> vc;
vc.reserve(n);
for (size_t i = 0; i < n; ++i)
vc.push_back(rand());
size_t begin1 = clock();
for (auto e : vc)
us.insert(e);
size_t end1 = clock();
size_t begin2 = clock();
for (auto e : vc)
s.insert(e);
size_t end2 = clock();
cout << "unordered_set<int> us.insert():" << end1 - begin1 << endl;
cout << "set<int> s.insert():" << end2 - begin2 << endl;
}
int main()
{
test();
return 0;
}
- 从上面的对比不难发现,unordered_set的性能要比set性能好。
- 如果插入的是有序的数的话,set的性能会比较好。
二、哈希
1.概念
- unordered_map和unordered_set的底层结构是哈希桶。
- 顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log N),搜索的效率取决于搜索过程中元素的比较次数。
- 当向该结构中:插入元素(根据插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放)和搜索元素(对元素的关键码进行相同的计算,把求得的函数值当做元素的存储位置,在该结构中按此位置取元素比较,若关键码相等,则搜索成功)该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表。
- 哈希表物理上是数组,逻辑上是按哈希映射关系存储的。
2.哈希函数
- 常见的哈希函数有直接定址法、除留余数法、平方取中法、折叠法、随机数法、数学分析法。
(1)直接定址法
- 取关键字的某个线性函数为散列地址:Hash(Key) = A * Key + B。
- 优点:简单、均匀
- 缺点:需要事先知道关键字的分布情况。
- 使用场景:适用于数据比较集中,数量有限,每个值映射一个位置的场景。
(2)除留余数法
- 设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) % p (p <= m),将关键码转换成哈希地址。
- 但此方法会产生哈希冲突。
(3)平法取中法
- 假设关键字为1234,对它的平方就是1522756,抽取中间的3位227作为哈希地址。
- 使用场景:不知道关键字的分布,而位数又不是很大的场景。
(4)折叠法
- 折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
- 使用场景:事先不知道关键字的分布,适合关键字位数比较多的情况。
(5)随机数法
- 选择一个随机函数,取关键字的随机函数值作为它的哈希地址,即Hash(key) = random(key),其中random为随机数函数。
- 使用场景:关键字长度不等时。
(6)数学分析法
- 设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
- 使用场景:通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布均匀的场景。
3.哈希冲突
- 不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
- 把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
- 哈希冲突无法避免。
- 解决哈希冲突的两种常见的方法:闭散列和开散列
(1)闭散列
- 亦叫开放定址法,当发生哈希冲突时,如果是哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的下一个空位置中去。
- 寻找下一个空位置的方法有线性探测和二次探测法。
① 线性探测法:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
//线性探测实现哈希表
namespace sheena
{
enum State
{
EXITS,
EMPTY,
DELETE,
};
template <class T>
struct HashData
{
T _data;
State _state;
};
template <class K, class V>
class HashTable
{
typedef HashData<pair<K, V>> HashData;
public:
pair<HashData*, bool> Insert(const pair<K, V>& kv)
{
//考虑容量问题
if (_dataNum == _tables.size())
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht;
newht._tables.resize(newSize);
//将旧表的数据重新计算位置映射到新表中
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXITS)
newht.Insert(_tables[i]._data);
}
_tables.swap(newht._tables);
}
//线性探测
size_t index = kv.first % _tables.size();
while (_tables[index]._state == EXITS)
{
//插入失败
if (_tables[index]._data.first == kv.first)
return make_pair(&_tables[index], false);
++index;
if (index == _tables.size())
index = 0;
}
_tables[index]._data = kv;
_tables[index]._state = EXITS;
++_dataNum;
return make_pair(&_tables[index], true);
}
HashData* Find(const K& key)
{
size_t index = key % _tables.size();
while (_tables[index]._state != EMPTY)
{
if (_tables[index]._state == EXITS
&& _tables[index]._data.first == key)
{
return &_tables[index];
}
else
{
++index;
if (index == _tables.size())
index = 0;
}
}
return nullptr;
}
void Erase(const K& key)
{
HashData* ret = Find(key);
if (ret)
ret->_state == DELETE;
}
V& operator[](const K& key)
{
pair<HashData*, bool> ret = Insert(make_pair(key, V()));
return ret.first->_data.second;
}
private:
vector<HashData> _tables;
size_t _dataNum = 0;
};
}
- 线性探测优点是实现非常简单。缺点是一旦发生哈希冲突,所有冲突连在一起,容器产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要比较许多次,导致搜索效率降低。
② 二次探测法:线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置的方法为:H = (H + i^2) % m,其中:i = 1,2,3,…,
//二次探测
namespace sheena
{
enum State
{
EXITS,
EMPTY,
DELETE,
};
template <class T>
struct HashData
{
T _data;
State _state;
};
template <class K, class V>
class HashTable
{
typedef HashData<pair<K, V>> HashData;
public:
pair<HashData*, bool> Insert(const pair<K, V>& kv)
{
//考虑容量问题
if (_dataNum == _tables.size())
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht;
newht._tables.resize(newSize);
//将旧表的数据重新计算位置映射到新表中
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXITS)
newht.Insert(_tables[i]._data);
}
_tables.swap(newht._tables);
}
//二次探测
size_t start = kv.first % _tables.size();
size_t index = start, i = 1;
while (_tables[index]._state == EXITS)
{
//插入失败
if (_tables[index]._data.first == kv.first)
return make_pair(&_tables[index], false);
index = start + i*i;
index %= _tables.size();
++i;
}
_tables[index]._data = kv;
_tables[index]._state = EXITS;
++_dataNum;
return make_pair(&_tables[index], true);
}
HashData* Find(const K& key)
{
size_t start = key % _tables.size();
size_t index = start, i = 1;
while (_tables[index]._state != EMPTY)
{
if (_tables[index]._state == EXITS
&& _tables[index]._data.first == key)
{
return &_tables[index];
}
else
{
index = start + i * i;
index %= _tables.size();
++i;
}
}
return nullptr;
}
void Erase(const K& key)
{
HashData* ret = Find(key);
if (ret)
ret->_state == DELETE;
}
V& operator[](const K& key)
{
pair<HashData*, bool> ret = Insert(make_pair(key, V()));
return ret.first->_data.second;
}
private:
vector<HashData> _tables;
size_t _dataNum = 0;
};
}
- 研究表明,当表的长度为质数且表装载因子不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。因此,闭散列最大的缺陷就是空间利用率比较低,这就是哈希的缺陷。
- 负载因子 = 表中数据的个数 / 表的大小。即表中数据的占有率。负载因子越大,概率上冲突越严重,效率越低。负载因子太小,空间利用率就太低。
(2)开散列(哈希桶)
- 开散列法又叫开链法(链地址法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
- 由上面的图片内容,可以看出开散列中每个桶放的都是发生哈希冲突的元素。
(3)开散列和闭散列比较
- 应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
- 其实开散列法好闭散列法的差别类似于单链表与顺序表的差别。开散列表利用链接方法存储同义词,不产生堆积现象,且使得动态查找的基本运算特别是查找、删除和插入易于实现。但由于加了链接指针,增加了存储开销。而闭散列表无需附加指针,因此存储效率比较高,但由此带来的问题是容易产生堆积的,而且某些基本算法不易实现。
- 闭散列法处理哈希冲突时,其平均查找长度要高于开散列法处理哈希冲突。因为拉链法处理冲突简单,没有堆积现象,即非同义词之间绝对不会出现冲突的,因此平均查找长度比较短。
- 开散列法中各个链表上的结点空间是动态申请的,所以更适合于建表前无法确定表长的情况。