对unordered_map和unordered_set的了解

目录

1. unordered系列关联式容器

1.1 unordered_map

常见构造

容量

迭代器

成员接口

修改接口

2.unordered_set

底层结构

哈希概念

哈希冲突(哈希碰撞)

哈希函数

常见哈希函数 

哈希冲突解决


1. unordered系列关联式容器

        在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到$log_2 N$,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好 的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个 unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是 其底层结构不同,本文中只对unordered_map和unordered_set进行介绍, unordered_multimap和unordered_multiset可查看文档介绍。

1.1 unordered_map

unordered_map的文档说明

https://cplusplus.com/reference/unordered_map/unordered_map/?kw=unordered_map

常见构造

上面是一些我们再unordered_map 中的一些构造函数。

容量

 unordered_map的容量接口。

empty(),和前面的容器一样,判断是否为空,是否插入了数据。

size(),容器的当前的大小。

max_size(),容器所能容纳的最大范围。

迭代器

unordered_map的迭代器,只有这四种,就是普通迭代器和const迭代器。因为容器是无序的,它的数据是成散列的。所以就没有反向迭代器。

成员接口

[ ] 是通过key 去返回value 的引用。at 的作用现在可以理解为何 [ ] 是差不多的。

修改接口

emplace(),目前我们认为是和insert差不多的。

insert(),插入一个元素到容器中。

erase(), 删除某个元素。

clear() , 清空容器。

swap(),交换两个unordered_map的值。

上面这些剩下的,都是一些关于底层的数据接口,我们需要弄懂底层的实现,才能看懂这些接口。

2.unordered_set

unordered_set的文档链接:

https://legacy.cplusplus.com/reference/unordered_set/unordered_set/

unordered_set的使用和set 差不多,只是迭代器的有所不同。unordered_map 是无序的,所以遍历出来的顺序不同。

底层结构

unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。

哈希概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N)平衡树中为树的高度 O($log_2 N$),搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

当向该结构中:

插入元素 :根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放 。搜索元素 对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置 取元素比较,若关键码相等,则搜索成功。

但是这有个问题,当我们使用hashFunc去计算的时候,总归有一些数据通过hashFunc算出来的值是一样的。

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)

例如:数据集合{1,7,6,4,5,9}; 哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快,我们通过一次计算,就可以精确我们需要查找的数据的位置,从而提高效率。

问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?

我们通过计算,算出44对印的hash(44)=4,但是我们在4那里已经有位置了,这个时候该怎么办呢?这样的情况,我们称为哈希冲突。

哈希冲突(哈希碰撞)

当我们使用不同的关键字,通过哈希函数算出来的值相同的时候,这种情况就叫做哈希冲突(哈希碰撞)。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

发生哈希冲突该如何处理呢?

哈希函数

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。

哈希函数设计原则:

~  哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值 域必须在0到m-1之间。

~ 哈希函数计算出来的地址能均匀分布在整个空间中。

~ 哈希函数应该比较简单。

常见哈希函数 

1. 直接定址法--(常用)

         取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B

         优点:简单、均匀

         缺点:需要事先知道关键字的分布情况

         使用场景:适合查找比较小且连续的情况

2. 除留余数法--(常用)

         设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key% p(p将关键码转换成哈希地址

3. 平方取中法--(了解)

        假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址

         平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况

4. 折叠法--(了解)

        折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这 几部分叠加求和,并按散列表表长,取后几位作为散列地址。

        折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况

5. 随机数法--(了解)

        选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中 random为随机数函数。

        通常应用于关键字长度不等时采用此法

6. 数学分析法--(了解)

        设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定 相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只 有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散 列地址。

注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突(只能降低冲突率)

哈希冲突解决

闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有 空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。

1. 线性探测

        现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4, 因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。

线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

如上图,当我们想要去插入44的时候,我们通过hashFunc算出来的位置它应该在4那个位置,但是,现在已经被4给占据了,如果想要替换掉4 肯定是不行的,这样4 的位置又找不到了。所以说我们现在的操作是。

插入

 1.通过哈希函数获取待插入元素在哈希表中的位置

 2.如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素

所以我们就依次从4 往后找位置,如果该位置有数据就接着向后找,知道我们看到8这个位置是没有数据的,我们就把44 放在8这个位置。

  删除

采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素若直接删除元素 会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影 响。因此线性探测采用标记的伪删除法来删除一个元素。

因为我们如果删除4的话,我们再查找4的时候就会查到到是空,就认为没有44这个元素。

// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};

所以我们设置了三种状态,分别是 对应 空 (EMPTY) , 存在(EXIST) ,删除(DELETE)

线性探测的实现

#pragma once
#include<vector>
#include<string>
#include<iostream>
using namespace std;
//枚举类型
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& s)
	{
		// BKDR
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 131;
		}

		return hash;
	}
};

//素数表
inline unsigned long __stl_next_prime(unsigned long n)
{
	// Note: assumes long is at least 32 bits.
	static const int __stl_num_primes = 28; //28个素数
	static const unsigned long __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
	};
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list + __stl_num_primes;
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

//开放地址法
namespace open_address
{
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		HashTable()
			:_tables(__stl_next_prime(0))
			, _n(0)
		{}

		//插入
		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
				return false;

			// 负载因子 >= 0.7扩容
			if (_n * 10 / _tables.size() >= 7)
			{
				//vector<HashData<K, V>> newtables(_tables.size()*2);
				//for (auto& data : _tables)
				//{
				//	// 旧表的数据映射到新表
				//	if (data._state == EXIST)
				//	{
				//		size_t hash0 = data._kv.first % newtables.size();
				//		// ...
				//	}
				//}

				//_tables.swap(newtables);

				HashTable<K, V, Hash> newht;
				//newht._tables.resize(_tables.size() * 2);
				newht._tables.resize(__stl_next_prime(_tables.size() + 1));

				for (auto& data : _tables)
				{
					// 旧表的数据映射到新表
					if (data._state == EXIST)
					{
						newht.Insert(data._kv);
					}
				}

				_tables.swap(newht._tables);
			}

			Hash hash;
			size_t hash0 = hash(kv.first) % _tables.size();
			size_t hashi = hash0;
			size_t i = 1;
			int flag = 1;
			while (_tables[hashi]._state == EXIST)
			{
				// 线性探测
				hashi = (hash0 + i) % _tables.size();
				++i;

				/*hashi = (hash0 + (i*i*flag)) % _tables.size();
				if (hashi < _tables.size())
					hashi += _tables.size();

				if (flag == 1)
				{
					flag = -1;
				}
				else
				{
					++i;
					flag = 1;
				}*/
			}

			_tables[hashi]._kv = kv;
			_tables[hashi]._state = EXIST;
			++_n;

			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
			Hash hash;
			size_t hash0 = hash(key) % _tables.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (_tables[hashi]._state != EMPTY)
			{
				if (_tables[hashi]._state == EXIST
					&& _tables[hashi]._kv.first == key)
				{
					return &_tables[hashi];
				}

				// 线性探测
				hashi = (hash0 + i) % _tables.size();
				++i;
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			HashData<K, V>* ret = Find(key);
			if (ret)
			{
				ret->_state = DELETE;
				return true;
			}
			else
			{
				return false;
			}
		}

	private:
		vector<HashData<K, V>> _tables;
		size_t _n;  // 记录数据个数
	};
}

思考:哈希表什么情况下进行扩容?如何扩容?

        因为底层是vector,我们需要知道底层怎样扩容。对于哈希表,我们有个特别的概念,叫做负载因子,我们一般把负载因子控制到0.7~0.8就比较合适,因为如果选择闭散列法 如果数据冗余的太多,也会影响效率。所以说,当我们的数据量占比到我们总容量的70%左右,我们就需要扩容。

开散列

开散列概念

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地 址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链 接起来,各链表的头结点存储在哈希表中。

看下面这幅图,当44和4冲突的时候,它们都会被悬挂在4的下面。

pair<Iterator,bool> Insert(const T& data)
{
	KeyOfT kot;

	Iterator it = Find(kot(data));
	if (it!=End())
		return {it,false};

	Hash hash;

	// 负载因子 == 1时扩容
	if (_n == _tables.size())
	{
		/*HashTable<K, V> newht;
		newht._tables.resize(__stl_next_prime(_tables.size() + 1));
		for (size_t i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				newht.Insert(cur->_kv);
				cur = cur->_next;
			}
		}

		_tables.swap(newht._tables);*/
		vector<Node*> newTable(__stl_next_prime(_tables.size() + 1));
		for (size_t i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				Node* next = cur->_next;
				// 头插到新表
				size_t hashi = hash(kot(cur->_data)) % newTable.size();
				cur->_next = newTable[hashi];
				newTable[hashi] = cur;

				cur = next;
			}

			_tables[i] = nullptr;

		}

		_tables.swap(newTable);
	}

	size_t hashi = hash(kot(data)) % _tables.size();
	// 头插
	Node* newnode = new Node(data);
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
	++_n;

	return { Iterator(newnode,this),true };
}

开散列增容

        桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可 能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希 表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点, 再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可 以给哈希表增容。

哈希表最重要的是需要把我们的储存数据转化成整形,通过整形来确定数据的存储位置。

那如果我们存储的是string 字符串类型 该怎么办呢? 我们知道字符串的每个字符都有它的ASCII编码,再内存中还是以char存储的,我们可以把它的每一个字符都转换成整形 再加起来作为它的hash值。

开散列与闭散列比较

        应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。

        事实上: 由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a ,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。

unordered_setunordered_mapC++标准库中的容器,用于存储管理不重复的元素集合键值对集合。 unordered_set是一个无序的集合容器,其中的元素是唯一的且无序的。它基于哈希表实现,因此插入、删除查找操作的平均时间复杂度为常数时间O(1)。使用unordered_set可以快速判断一个元素是否存在于集合中。 unordered_map是一个无序的键值对容器,其中的键是唯一的且无序的。它也基于哈希表实现,因此插入、删除查找操作的平均时间复杂度为常数时间O(1)。使用unordered_map可以根据键快速查找对应的值。 使用unordered_setunordered_map时,需要包含头文件<unordered_set><unordered_map>。以下是它们的基本用法: 1. 创建容器: unordered_set<int> mySet; // 创建一个空的unordered_set unordered_map<string, int> myMap; // 创建一个空的unordered_map 2. 插入元素或键值对: mySet.insert(10); // 插入元素10到unordered_set中 myMap["apple"] = 5; // 插入键值对("apple", 5)到unordered_map中 3. 删除元素或键值对: mySet.erase(10); // 从unordered_set中删除元素10 myMap.erase("apple"); // 从unordered_map中删除键为"apple"的键值对 4. 查找元素或键值对: auto it = mySet.find(10); // 在unordered_set中查找元素10,返回迭代器 if (it != mySet.end()) { // 找到了元素 } auto it = myMap.find("apple"); // 在unordered_map中查找键为"apple"的键值对,返回迭代器 if (it != myMap.end()) { // 找到了键值对 int value = it->second; // 获取对应的值 }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值