c++|unordered系列关联式容器(unordered_set、unordered_map介绍使用+哈希结构)

目录

一、unordered_set的介绍与使用

1.1unordered_set介绍

1.2unordered_set使用

2.2.1构造

2.2.2容量

2.2.3修改

二、unordered_map的介绍与使用

2.1unordered_map介绍

2.2unordered_map使用

2.2.1构造

2.2.2容量

2.2.3修改

三、底层结构(哈希)

3.1哈希概念

3.2哈希函数(引出哈希冲突)

3.3哈希冲突解决

3.3.1闭散列(开放定址法)

3.3.2开放定址法实现哈希

3.3.3开散列(链地址法)实现哈希

3.3.4探究哈希表的大小

3.3.5开散列和闭散列的比较


在前面篇章,我们学习过由c++98提供的关联式容器的概念以及一些具体关联式容器,那么在这里的继续延续几个由c++11提供的unordered关联式容器来为学习哈希结构做铺垫。其次, 底层为红黑树结构的一系列关联式容器,在查询时效率可达到log N,但与此相比unordered关联式容器可以达到O(1)效率,这也是我们为什么要学习它的原因。

一、unordered_set的介绍与使用

1.1unordered_set介绍

1.unordered_set与set一样元素key与value是一一对应的,且是唯一的,set中的key不能在容器中修改(元素总是const),但是可以插入和删除,且元素是去重的。

2.unordered_set与set不一样的是,其底层是由数组实现,数组存储的是<key,key>,且节点值的顺序没有按照特定的顺序排序,为了能在常数范围内找到可以所对应的key,unordered_set将相同哈希值的键值对放在相同的桶中(比较抽象,待后续分析)。

3.所以unordered_set访问单个元素要比set快,但它通常在遍历元素子集的范围迭代方面效率低,因为其底层是个数组,遍历数组的效率那肯定就低咯。

unordered_set第一个参数是key,表示值也表示键,第二个参数代表底层的实现结构,叫做哈希表,本质就是个数组,且数组中的值的顺序是不确定的,但元素是去重的,其他参数暂时不做了解。

1.2unordered_set使用

2.2.1构造

|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------|
| 函数声明 | 功能介绍 |
| explicit unordered_set (); | 构造空unordered_set |
| template <class InputIterator> unordered_set ( InputIterator first, InputIterator last, size_type n = /* see below */ const hasher& hf = hasher(), const key_equal& eql = key_equal(), const allocator_type& alloc = allocator_type() ); | 用[first,last)区间中的元素构造unordered_set |
| unordered_set(const unordered_set& ust); | 拷贝构造 |

其中n表示:

最小初始存储桶数。

这不是容器中的元素数,而是构造时内部哈希表所需的最小插槽数。

如果未指定此参数,则构造函数会自动确定此参数(以取决于特定库实现的方式)。

cpp 复制代码
#include <iostream>
#include <unordered_set>
using namespace std;


int main()
{
	int arr[] = { 2,34,6,6,56,8,321,9,4,88 };

	unordered_set<int> us(arr, arr + sizeof(arr) / sizeof(arr[0]));//迭代器区间构造

	for (auto e : us)
	{
		cout << e << " ";
	}
	cout << endl;

	unordered_set<int> us1(us);//拷贝构造
	for (auto e : us1)
	{
		cout << e << " ";
	}
	cout << endl;

	return 0;
}

输出结果:

2.2.2容量

|-------------------------------------|---------------------------------------|
| 函数声明 | 功能介绍 |
| bool empty() const; | 检测unordered_set是否为空,空返回true,否则返回false |
| size_type size() const; | 返回unordered_set中有效元素的个数 |
| size_type max_size() const; | 返回unordered_set能够存储的最大容量 |

cpp 复制代码
#include <iostream>
#include <unordered_set>
using namespace std;


int main()
{
	int arr[] = { 2,34,6,6,56,8,321,9,4,88 };

	unordered_set<int> us(arr, arr + sizeof(arr) / sizeof(arr[0]));//迭代器区间构造

	for (auto e : us)
	{
		cout << e << " ";
	}
	cout << endl;

	cout << boolalpha << us.empty() << endl;//bool值形式打印
	cout << us.size() << endl;
	cout << us.max_size() << endl;

	return 0;
}

输出结果:

2.2.3修改

|------------------------------------------------------|---------------------------------------|
| 函数声明 | 功能介绍 |
| pair<iterator,bool> insert(const value_type& x) | 在unordered_set中插入元素 |
| void erase(iterator position) | 删除迭代器所指向position位置上的元素 |
| size_type erase(const key_type& x) | 删除unordered_set中值为x的元素,返回删除的元素的个数 |
| void erase(const_iterator first,const_iterator last) | 删除迭代器区间[first,last)中的元素 |
| void swap(set& ust) | 交换两个unordered_set中的元素 |
| void clear() | 将unordered_set中的元素清空 |
| iterator find(const key_type& x) const | 返回unordered_set中值为x的元素的位置,未找到,返回end() |
| size_type count(const key_type& x) const | 返回set中值为x的元素个数,因为set会去重,所以要么返回1,要么返回0 |

cpp 复制代码
#include <iostream>
#include <unordered_set>
using namespace std;


int main()
{
	int arr[] = { 2,34,6,6,8,9,4 };

	unordered_set<int> s(arr, arr + sizeof(arr) / sizeof(arr[0]));//迭代器区间构造

	unordered_set<int>::iterator it = s.begin();
	while (it != s.end())
	{
		cout << *it << " ";
		it++;
	}
	cout << endl;

	it = s.find(6);//返回元素的当前位置
	cout << *it << endl;
	cout << s.count(6) << endl;

	size_t num = s.erase(6);
	cout << num << endl;

	s.erase(s.begin());//删除迭代器当前位置元素
	it = s.begin();

	while (it != s.end())
	{
		cout << *it << " ";
		it++;
	}
	cout << endl;

	s.erase(s.begin(), s.end());//删除迭代器区间中的元素

	return 0;
}

输出结果:

二、unordered_map的介绍与使用

2.1unordered_map介绍

1.unordered_map与map一样也是存储<key,value>键值对的关联式容器,通过key找到value,且元素是去重的。

2.unordered_map与map不一样的是,其底层是由数组实现,数组存储的是<key,value>,且节点值的顺序没有按照特定的顺序排序,为了能在常数范围内找到可以所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中(比较抽象,待后续分析)。

3.所以unordered_map通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率低,因为其底层是个数组,遍历数组的效率那肯定就低咯。

unordered_map容器的第一个参数是key,代表键,第二个参数代表key对应的value,第三个参数代表底层的实现结构,叫做哈希表,本质就是个数组,且数组中的值的顺序是不确定的,但元素是去重的,其他参数暂时不做了解。

2.2unordered_map使用

对于unordered_map的接口使用,大部分跟map的使用是差不多的,所以我就直接使用,不做过多的解释。

2.2.1构造

|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------|
| 函数声明 | 功能介绍 |
| explicit unordered_map (); | 构造空unordered_map |
| template <class InputIterator> unordered_map (InputIterator first, InputIterator last, const hasher& hf = hasher(), const key_equal& eql = key_equal(), const allocator_type& alloc = allocator_type()); | 用[first,last)区间中的元素构造unordered_map |
| unordered_map (const unorderedmap& x); | 拷贝构造 |
| unordered_map& operator= (const unordered_map& x); unordered_map& operator= (initializer_list<value_type> il); | 通过对象进行初始化。 通过初始化列表进行初始化,C++11的用法 |

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main()
{
	unordered_map<int, int> unmap{ {8,1},{3,2},{20,3}, {4,4} };//通过初始化列表进行初始化
	for (auto e : unmap)
	{
		cout << e.first << ":" << e.second << endl;
	}
	cout << endl;

	unordered_map<int, int> unmap2(unmap);
	for (auto& e : unmap2)
	{
		cout << e.first << ":" << e.second << endl;
	}
	cout << endl;

	unordered_map<int, int> unmap3(unmap.begin(), unmap.end());
	for (auto& e : unmap)
	{
		cout << e.first << ":" << e.second << endl;
	}

	return 0;
}

输出结果:

通过结果也可以看出,其值顺序是不确定的,至于为什么是这样,由其底层结构性质决定,不是三言两语就能解决的,待后续叙述。

2.2.2容量

|-----------------------------------------------------------------------------------------------|---------------------------------------------------------------|
| 函数声明 | 功能简介 |
| bool empty() const; | 检测unordered_map中元素是否为空,是,返回true,否,返回false |
| size_type size() const; | 返回unordered_map中有效元素的个数 |
| mapped_type& operator[] (const key_type& k); | 返回key对应的value。(支持插入,修改。若key不存在,则插入value,并返回。若存在,修改value,并返回)。 |
| mapped_type& at (const key_type& k); const mapped_type& at (const key_type& k) const; | 返回key对应的value。(支持修改,不支持插入。若key不存在,抛异常。若存在,修改value,并返回) |

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main()
{
	unordered_map<int, int> unmap{ {8,1},{3,2},{20,3}, {4,4} };//通过初始化列表进行初始化
	for (auto e : unmap)
	{
		cout << e.first << ":" << e.second << endl;
	}
	

	cout << unmap.size() << endl;
	cout << unmap.empty() << boolalpha << endl;

	unmap[8] = 8;//修改
	unmap[3] = 3;
	unmap[20] = 20;
	unmap[4] = 5;
	unmap[7] = 7;//插入
	//unmap.at(6) = 3;抛异常
	cout << endl;
	for (auto& e : unmap)
	{
		cout << e.first << ":" << e.second << endl;
	}
	cout << endl;


	return 0;
}

输出结果:

2.2.3修改

|------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| 函数声明 | 功能介绍 |
| pair<iterator,bool> insert(const value_type& x) | 在unordered_map中插入键值对x |
| void erase(iterator position) size_type erase(const key_type& x) void erase(iterator first,iterator last) | 删除position位置上的元素 删除键值为x的元素 删除[first,last)区间中的元素 |
| void swap(unordered_map& ump) | 交换两个unordered_map中的元素 |
| void clear() | 将map中的元素清空 |
| iterator find(const key_type& x) const_iterator find(const key_type& x) const | 返回key在哈希桶的迭代器位置,未找到返回end() |
| size_type count(const key_type& x) | 返回哈希桶中关键码为key的键值对的个数,因为key是唯一的,所以该函数的返回值要么为0,要么为1,因此也可以用该函数来检测一个key是否在哈希桶中 |

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main()
{
	int arr[] = { 10,2,3,4,5,5,3,3,8 };
	unordered_map<int, int> unmap;

	for (auto e : arr)
	{
		unmap.insert(make_pair(e, e));
	}

	for (auto& e : unmap)
	{
		cout << e.first << ":" << e.second << endl;
	}

	unmap.erase(8);
	unordered_map<int, int>::iterator it = unmap.begin();
	it = unmap.find(3);
	cout << it->first << ":" << it->second << endl;
	it = unmap.find(1);//返回end()位置迭代器
	if (it == unmap.end())
	{
		cout << "未查询到该元素" << endl;
	}
	

	unordered_map<int, int> umap;
	umap.swap(unmap);
	cout << "unmap whether is empty():" << boolalpha << unmap.empty() << endl;
	cout << "实际3的元素有几个:" << umap.count(3) << endl;
	for (auto& e : umap)
	{
		cout << e.first << ":" << e.second << endl;
	}
	
	return 0;
}

输出结果:

当然他们的使用还有一些其他的接口,我们暂且放一放,接下来学习它的底层结构。

三、底层结构(哈希)

3.1哈希概念

对于顺序结构,查询单个元素的时间复杂度是O(N),在平衡树中,查询单个元素的时间复杂度是O(log N),这两种时间复杂度的结果取决于搜索过程中元素的比较次数。但是对于理想程度来说,肯定是查询时间越小越好,那么为了达到此结果,哈希结构能够满足,可以不经过任何比较,一次直接从表中取到要搜索的元素。

哈希结构:构造一种存储结构,通过某种函数使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。说白了就是存储的位置和存储的值的映射关系,只要满足映射关系就称为哈希结构。

当向该结构中:

  • 插入元素

根据待插入元素的关键码(key),以此函数计算出该元素的存储位置并按此位置进行存放

  • 搜索元素

对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置 取元素比较,如关键码相等,则搜索成功

在该哈希结构中使用的函数称为哈希(散列)函数,构造出来的结构称为哈希表或者称为散列表,接下来对如何通过哈希函数来构造一张哈希表。

3.2哈希函数(引出哈希冲突)

1.直接定址法

取关键字的某个线性函数为散列地址:hash(key) = A*key + B。如图:

由此可看出该方法的优点是简单,均匀,但是缺点就是需事先知道A,B关键字的分布情况,且极其的浪费空间。

那么它的场景就适合查找比较小且连续的情况

2.除留余数法

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

由上图可知该哈希函数也能实现哈希结构,不用去找关键字,但是导致的结果就是,会出现同一个地址内出现两个元素,我们把不同关键字通过相同哈希函数计算出相同的哈希地址称为哈希冲突,把具有不同关键码而具有相同哈希地址的数据元素称为"同义词"。而引起哈希冲突的原因就是哈希函数设计不够合理。

还有一些哈希函数,做个了解:3.平放取中法 4.折叠法 5.随机数法 6.数学分析法

为了使得哈希函数设计合理,须符合以下原则:

  • 哈希函数的定义域必须包括需要存储的全部关键码(就是得有一个包括关键码的集合),而如果散列表允许有m个地址时,其值必须在0到m-1之间(由关键码计算得到的值必须在0到m-1之间)
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 设计的哈希函数需比较简单

设计哈希函数的目的就是为了减少哈希冲突,但是不能够完全避免哈希冲突。

注意:

要区分哈希结构和哈希表。

哈希/散列:K和V的映射关系

哈希表/散列表:K和存储位置的映射关系

3.3哈希冲突解决

解决哈希冲突两种常见的方法是:闭散列和开散列

3.3.1闭散列(开放定址法)

也叫开放定址法,当发生哈希冲突时,如果哈希表未装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的"下一个"空位置中去。什么意思呢,又该如何找寻下一个位置,这里提供了两种方式

1.线性探测解决哈希冲突

通过线性探测来实现插入、查找、删除等操作。

插入:

通过哈希函数获取待插入元素在哈希表中的位置(hash(key) = i % 表的大小)

如果在该位置中没有元素则直接插入新元素,如果该位置中已有元素,则线性往后找寻空位置进行插入元素,如图:

其中9和44发生了哈希冲突,由于9是先插入的新元素,那么44就会遵循线性探测原则,继续往后寻找空位置插入,最先符合空位置的是3地址,如果3地址处也有元素,那么就在5插入,以此类推。当往后的位置都插满时,这时再发生哈希冲突时,就需要去前面寻找空位置插入。

注意:

在一定的空间,随着元素的增多,哈希冲突的概率会不断增大,所以,当达到一定的元素时,需要进行扩容,对于扩容,引入了负载因子/载荷因子的概念,负载因子 = 实际存进去的元素个数/表的大小,一般负载因子会控制在0.7到0.8,由实验表明,当负载因子超过0.8,在查表时CPU缓存不命中按照指数曲线上升 。当达到该负载因子时,进行扩容,在进行扩容后,需要对原来的元素在新的空间里重新映射,这从另一方面,也说明其元素顺序是无序的。由于负载因子的控制,元素个数始终保持不会超过7/10,例如:容量为10,元素个数不会超过7个,如果达到7个,会进行相应扩容,元素个数不变,但容量变大了,依然小于7/10。所以在该容量的空间中就必然会出现空位置。

查找:

要查找的元素可能因为哈希冲突,而填到了后面的空位置,所以在进行查找时,需从i = key%capacity位置开始依次线性往后查询,如果找到了,返回该位置,如果该元素确实不存在,当往后查找时,若遇到空位置,则说明元素不存在(因为发生冲突时,插入就是往后线性插入,在查找时为空位置,说明就没有在该位置插入元素),当往后查找的位置都不为空,但是没有该元素时,也要跑到前面需寻找该元素。由于空间中必然会出现空位置,结果就是要么找到元素,返回该元素位置,要么遇到空结束返回空

删除:

采用闭散列删除元素时,不能够直接找到该元素就删除,因为可能会影响其他元素的查询。比如删除9,那么在查询44时,是从44 % 7 = 2位置开始查找,而2位置为空,就会返回该元素不存在的信息,所以为了不影响其他元素查找,线性探测采用标记的伪删除法来删除一个元素。

用三个标记代表该位置的状态,enum State{ EMPTY,EXSIT,DELETE},EMPTY代表该位置是空,EXIST带表该位置有元素,DELETE代表该位置元素已经删除,所以当要查找元素,只需判断该位置的状态是否继续往后查找。

线性探测优点:实现非常简单

线性探测缺点:一旦发生哈希冲突,所有与之冲突都会连在一起,造成数据堆积的效果。也就是关键码占据了可利用的空位置,使得寻找关键码需要多次比较,导致效率降低

2.二次探测解决哈希冲突

已知哈希冲突的缺点,为了避免挨个方式往后查找,二次探测就能够代替,理解起来也很简单,那就不是挨个查找,而是以平放的形式跳跃查找插入。如图:

4和44发生冲突,4是先插入,为了避免冲突,需要往后寻找空位插入,线性探测是挨个往后查找空位,而二次探测是以平放形式跳跃查找,假设变量i(i>=0),第一次跳跃为1^2 = 1,跳跃一个位置即4+1 = 5 ,发现该位置不为空,那么第二次跳跃,2^2 = 4,4+4 = 8,该位置为空,那么44就插入到8位置。

二次探测优点:实现非常简单

二次探测缺点:与线性探测一样,一旦发生哈希冲突,所有与之冲突都会连在一起,造成数据堆积的效果。也就是关键码占据了可利用的空位置,使得寻找关键码需要多次比较,导致效率降低

接下来通过开放定址法实现哈希结构,这里只提供线性探测方式。

3.3.2开放定址法实现哈希

cpp 复制代码
//HashTable.h

#include <iostream>
#include <vector>
#include <string>
using namespace std;

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 hash = 0;
		for (auto e : key)
		{
			//BKDR--一种哈希算法
			hash *= 31;
			hash += e;
		}
		return hash;
	}
};

//开放地址法
namespace open_address
{
    //用枚举存放该表的状态
	enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};
    
    //存放节点值
	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state = EMPTY;
	};
    
    //哈希表,用来管理数据的结构
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		HashTable(size_t size = 10)
		{
			_tables.resize(size);//给哈希表空间大小初始化为10
		}
        //查找元素
		HashData<K, V>* Find(const K& key)
		{
			Hash hs;
			int hashi = hs(key) % _tables.size();//获取元素的对应哈希表中的下标
			while (_tables[hashi]._state == EXIST)//判断该位置的状态
			{
				if (_tables[hashi]._kv.first == key)//是否有冲突,否,直接返回该位置
				{
					return &_tables[hashi];
				}
				++hashi;//是,往后线性探测查询
				hashi %= _tables.size();//若到达末尾还未查询到,则往前回绕查找
			}
			return nullptr;//查询完还未找到,返回空
		}
        
        //插入
		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))//是否已存在该值
				return false;


			//扩容
			/*if ((double)n / _tables.size() >= 0.7)*/
			if (n * 10 / _tables.size() >= 7)//到达负载因子就扩容
			{
				HashTable<K, V> newt(_tables.size() * 2);//每次扩2倍
				//遍历旧表,插入新表
				for (auto& e : _tables)
				{
					if (e._state == EXIST)
					{
						newt.Insert(e._kv);//递归插入到新表
					}
				}

				//浅拷贝交换
				_tables.swap(newt._tables);//交换两个表的指针指向
			}

			//插入元素
			Hash hs;

			int hashi = hs(kv.first) % _tables.size();//获取新表下标
			while (_tables[hashi]._state == EXIST)//判断该位置状态
			{
				++hashi;//若有冲突,线性往后探测,找待插入位置
				hashi %= _tables.size();//若查找到末尾,则往前继续探测待插入位置
			}
			_tables[hashi]._kv = kv;//插入元素
			_tables[hashi]._state = EXIST;//给该位置附一个状态
			n++;//元素+1

			return true;//插入成功,返回true
		}

        //删除
		bool Erase(const K& key)
		{
			if (Find(key) == nullptr)//要删元素不存在
				return false;

			/*int hashi = hash(key) % _tables.size();
			_tables[hashi]._state = DELETE;*/

			HashData<K, V>* ret = Find(key);//返回查询元素位置所在
			if (ret)
			{
				ret->_state = DELETE;//更改要删除元素状态,所以实则并没有将该元素从数组中删掉,在输出该数组内容时,也是通过判断每个位置的状态来进行打印
				--n;//元素-1
			}

            
			return true;
		}


		void Print()
		{
			for (int i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._state == EXIST)//通过判断该位置状态来进行输出
				{
					cout << _tables[i]._kv.first << ":" << _tables[i]._kv.second << endl;
				}
			}
		}
	private:
		vector<HashData<K, V>> _tables;//哈希表本质就是个数组,每个位置存储的是一个结构体,结构体存储的才是真正的值。
		size_t n = 0;//记录元素个数
	};
	void TestHT1()
	{
		HashTable<int, int> ht;
		int a[] = { 4,14,24,34,5,7,1 };
		for (auto e : a)
		{
			ht.Insert(make_pair(e, e));
		}

		ht.Insert(make_pair(3, 3));
		ht.Insert(make_pair(3, 3));
		ht.Insert(make_pair(-3, -3));
		ht.Print();
		cout << endl;
		ht.Erase(3);
		ht.Print();
		cout << endl;
		if (ht.Find(3))
		{
			cout << "3存在" << endl;
		}
		else
		{
			cout << "3不存在" << endl;
		}

		ht.Insert(make_pair(3, 3));
		ht.Insert(make_pair(23, 3));
		ht.Print();
	}
}

测试:

cpp 复制代码
//test.cpp

#include "HashTable.h"

int main()
{

	open_address::TestHT1();
	return 0;
}

输出结果:

除了开放定址法,还有一种链地址法来解决哈希冲突

3.3.3开散列(链地址法)实现哈希

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

示意图:

话不多少,直接来实现开散列,以kv版参照。

cpp 复制代码
//HashTable.h
#include <iostream>
#include <vector>
#include <string>
#include <set>
#include <unordered_set>
using namespace std;

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 hash = 0;
		for (auto e : key)
		{
			//BKDR
			hash *= 31;
			hash += e;
		}
		return hash;
	}
};

//哈希桶/链地址法
namespace Hash_Bucket
{
	//存放节点值
	template<class K, class V>
	struct HashNode
	{
		HashNode* _next;
		pair<K, V> _kv;

		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(size_t size = 10)
		{
			_tables.resize(size);//默认给10
		}

		~HashTable()
		{
			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;
			}
		}

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

			Hash hf;

			// 负载因子最大到1,目的是增加空间利用率,尽量使每个桶挂更多的元素
			//if (_n*10 / _tables.size() == 7)
			if (_n == _tables.size())
			{
				//size_t newSize = _tables.size() * 2;
				//HashTable<K, V> newHT;
				//newHT._tables.resize(newSize);

				 遍历旧表
				//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*> newTables;
				newTables.resize(_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 = hf(cur->_kv.first) % newTables.size();
						cur->_next = newTables[i];
						newTables[i] = cur;

						cur = next;
					}

					_tables[i] = nullptr;
				}

				_tables.swap(newTables);
			}

			size_t hashi = hf(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 hf;
			size_t hashi = hf(key) % _tables.size();//查找该元素位置
			Node* cur = _tables[hashi];
			while (cur)//遍历链表
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}

				cur = cur->_next;
			}

			return NULL;
		}


		bool Erase(const K& key)
		{
			Hash hf;
			size_t hashi = hf(key) % _tables.size();//待删除位置
			Node* prev = nullptr;//记录cur的前驱节点
			Node* cur = _tables[hashi];
			while (cur)//若cur为空(不管一来为空,还是查找完为空),说明没有该元素,若cur不为空,遍历链表查找该元素
			{
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)//说明第一个元素就是要删除元素,将第一个元素置为空
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;//删除当前节点,并将前驱节点和后驱节点链接
					}
					delete cur;

					return true;
				}

				//未找到该元素,就继续往后迭代查找,直到末尾
				prev = cur;
				cur = cur->_next;
			}

			//未查找到要删除元素
			return false;
		}




		void Some()
		{
			size_t bucketSize = 0;
			size_t maxBucketLen = 0;
			size_t sum = 0;
			double averageBucketLen = 0;

			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				if (cur)
				{
					++bucketSize;
				}

				size_t bucketLen = 0;
				while (cur)
				{
					++bucketLen;
					cur = cur->_next;
				}

				sum += bucketLen;

				if (bucketLen > maxBucketLen)
				{
					maxBucketLen = bucketLen;
				}
			}

			averageBucketLen = (double)sum / (double)bucketSize;

			printf("all bucketSize:%d\n", _tables.size());
			printf("bucketSize:%d\n", bucketSize);
			printf("maxBucketLen:%d\n", maxBucketLen);
			printf("averageBucketLen:%lf\n\n", averageBucketLen);
		}
	private:
		vector<Node*> _tables;
		size_t _n = 0;
	};

	void TestHT1()
	{
		HashTable<int, int> ht;
		int a[] = { 4,14,24,34,5,7,1,15,25,3 };
		for (auto e : a)
		{
			ht.Insert(make_pair(e, e));
		}

		ht.Insert(make_pair(13, 13));

		cout << ht.Find(4) << endl;
		ht.Erase(4);
		cout << ht.Find(4) << endl;
	}

	void TestHT2()
	{
		string arr[] = { "香蕉", "甜瓜","苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
		HashTable<string, int> ht;
		for (auto& e : arr)
		{
			//auto ret = ht.Find(e);
			HashNode<string, int>* ret = ht.Find(e);
			if (ret)
			{
				ret->_kv.second++;
			}
			else
			{
				ht.Insert(make_pair(e, 1));
			}
		}
	}

	void TestHT3()
	{
		const size_t N = 1000000;

		unordered_set<int> us;
		set<int> s;
		HashTable<int, int> ht;

		vector<int> v;
		v.reserve(N);
		srand(time(0));
		for (size_t i = 0; i < N; ++i)
		{
			//v.push_back(rand()); // N比较大时,重复值比较多
			v.push_back(rand() + i); // 重复值相对少
			//v.push_back(i); // 没有重复,有序
		}

		// 21:15
		size_t begin1 = clock();
		for (auto e : v)
		{
			s.insert(e);
		}
		size_t end1 = clock();
		cout << "set insert:" << end1 - begin1 << endl;

		size_t begin2 = clock();
		for (auto e : v)
		{
			us.insert(e);
		}
		size_t end2 = clock();
		cout << "unordered_set insert:" << end2 - begin2 << endl;

		size_t begin10 = clock();
		for (auto e : v)
		{
			ht.Insert(make_pair(e, e));
		}
		size_t end10 = clock();
		cout << "HashTbale insert:" << end10 - begin10 << endl << endl;


		size_t begin3 = clock();
		for (auto e : v)
		{
			s.find(e);
		}
		size_t end3 = clock();
		cout << "set find:" << end3 - begin3 << endl;

		size_t begin4 = clock();
		for (auto e : v)
		{
			us.find(e);
		}
		size_t end4 = clock();
		cout << "unordered_set find:" << end4 - begin4 << endl;

		size_t begin11 = clock();
		for (auto e : v)
		{
			ht.Find(e);
		}
		size_t end11 = clock();
		cout << "HashTable find:" << end11 - begin11 << endl << endl;

		cout << "插入数据个数:" << us.size() << endl << endl;
		ht.Some();

		size_t begin5 = clock();
		for (auto e : v)
		{
			s.erase(e);
		}
		size_t end5 = clock();
		cout << "set erase:" << end5 - begin5 << endl;

		size_t begin6 = clock();
		for (auto e : v)
		{
			us.erase(e);
		}
		size_t end6 = clock();
		cout << "unordered_set erase:" << end6 - begin6 << endl;

		size_t begin12 = clock();
		for (auto e : v)
		{
			ht.Erase(e);
		}
		size_t end12 = clock();
		cout << "HashTable Erase:" << end12 - begin12 << endl << endl;
	}
}

测试:

cpp 复制代码
//test.cpp
#include "HashTable.h"

int main()
{

	Hash_Bucket::TestHT3();
	return 0;
}

输出结果:

3.3.4探究哈希表的大小

有研究表面,哈希表的大小最好是一个素数,这样的话能够提供哈希结构的效率。

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n)
		{
			static const int __stl_num_primes = 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
			};

			for (int i = 0; i < __stl_num_primes; ++i)
			{
				if (__stl_prime_list[i] > n)
				{
					return __stl_prime_list[i];
				}
			}

			return __stl_prime_list[__stl_num_primes - 1];
		}

该方法也是STL库中获取素数的方式。

  • 将素数放在一个数组中,两个素数之间的关系接近二倍。
  • 当需要进行扩容时,就从数组中寻找一个比当前素数大的素数作为新的容量。

上面数组中存放了28个素数,最大的素数约等于2^32,约等于哈希表有4GB个数据,每个数据是一个指针,占4个字节大小,假设插入的数据量是整形最大值,那么算下来插入的数据总大小为16GB,超出了哈希表的大小,但是需要考虑每个桶长度,插入这么多个数据,通过除留余数法,必有重复值,每个桶挂的数据也是大量的,所以,总体算下来,哈希表是够用的。

那么在初始化时就可以这样定义,以数组第一个素数作为容量大小:

扩容时从数组中查询下一个素数

3.3.5开散列和闭散列的比较

开散列每个节点中多了一个指针,看起来比闭散列增加了存储开销,但是它空间利用率高,负载因子大于1的时候才会扩容。

闭散列必须保持大量的空闲空间以确保搜索的效率,二次探测甚至要求负载因子必须小于等于0.7,由于其扩容次数比开散列多,所以其表项所占的空间比哈希桶大的多。

  • 所以在空间利用率上,哈希桶比闭散列更有优势。

在搜索上效率上,无论是开散列还是闭散列,都是通过哈希函数直接映射到待查找位置,然后再查询常数次,所以这两种方式的时间复杂度都是O(1),比红黑树的查找效率高的多。

但相比开散列,闭散列的负载因子越大,哈希碰撞的概率就越大,扩容消耗越大,查询次数越多。

  • 所以,综合考虑,哈希结构的底层也采用了哈希桶结构。

补充:

在极端情况下,哈希桶的单链表可能会很长,此时可以将该桶改成红黑树结构来提高效率。

通过设置一个链表长度,当单链表的长度超过这个值时,就将桶改成树结构,如AVL树或者红黑树等等。

下一章节,将使用哈希结构封装unordered_set和unordered_map

end~

相关推荐
魔道不误砍柴功2 分钟前
实际开发中的协变与逆变案例:数据处理流水线
java·开发语言
鲤籽鲲10 分钟前
C# MethodTimer.Fody 使用详解
开发语言·c#·mfc
亚图跨际13 分钟前
Python和R荧光分光光度法
开发语言·python·r语言·荧光分光光度法
Hera_Yc.H19 分钟前
数据结构之一:复杂度
数据结构
Rverdoser22 分钟前
RabbitMQ的基本概念和入门
开发语言·后端·ruby
dj244294570725 分钟前
JAVA中的Lamda表达式
java·开发语言
工业3D_大熊39 分钟前
3D可视化引擎HOOPS Luminate场景图详解:形状的创建、销毁与管理
java·c++·3d·docker·c#·制造·数据可视化
暮色_年华1 小时前
Modern Effective C++ Item 11:优先考虑使用deleted函数而非使用未定义的私有声明
c++
流星白龙1 小时前
【C++习题】10.反转字符串中的单词 lll
开发语言·c++
尘浮生1 小时前
Java项目实战II基于微信小程序的校运会管理系统(开发文档+数据库+源码)
java·开发语言·数据库·微信小程序·小程序·maven·intellij-idea