C++之关联式容器(unordered_map/unordered_set)和哈希表

一、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,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
  • 其实开散列法好闭散列法的差别类似于单链表与顺序表的差别。开散列表利用链接方法存储同义词,不产生堆积现象,且使得动态查找的基本运算特别是查找、删除和插入易于实现。但由于加了链接指针,增加了存储开销。而闭散列表无需附加指针,因此存储效率比较高,但由此带来的问题是容易产生堆积的,而且某些基本算法不易实现。
  • 闭散列法处理哈希冲突时,其平均查找长度要高于开散列法处理哈希冲突。因为拉链法处理冲突简单,没有堆积现象,即非同义词之间绝对不会出现冲突的,因此平均查找长度比较短。
  • 开散列法中各个链表上的结点空间是动态申请的,所以更适合于建表前无法确定表长的情况。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值