【C++进阶:哈希--unordered系列的容器及封装】

本课涉及到的所有代码都见以下链接,欢迎参考指正!

practice: 课程代码练习 - Gitee.comhttps://gitee.com/ace-zhe/practice/tree/master/Hash

unordered系列关联式容器

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

unordered系列mpa、set和普通map、set区别:

unordered_mpa、unordered_set的简单测试:

 使用时与map、set的基本用法几乎一样,观察发现,unordered系列的关联容器只能完成key值的去重,而不能完成排序,那为什么C++11要搞出这个系列呢?原因是综合考虑后unordered系列的容器基于哈希表底层的特性会使整体操作效率更高,下面我们从几个方面来测试set和unordered_set的性能:

性能测试:

性能测试代码如下(这里我们主要测试插入大量不同形式的随机数,二者性能,足够说明问题):

void performance_test()
{
	const size_t N = 100000;

	unordered_set<int> us;
	set<int> s;

	vector<int> v;
	v.reserve(N);
	srand(time(0));
	for (size_t i = 0; i < N; ++i)
	{
		//v.push_back(rand());
		//v.push_back(rand()+i);
		v.push_back(i);
	}

	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 << 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 << endl;

	cout << s.size() << endl;
	cout << us.size() << endl << endl;;

	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 << endl;
}

 测试结果和分析如下:

由此,我们总结,实际当中大部分情况下我们还是更推荐使用unordered系列的关联式容器,前面提到它的高效率源自于其底层的哈希结构,因此接下来的重点我们就是要学习哈希结构的模拟实现。 

unordered系列容器的底层结构

哈希(又称散列)概念:

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)。

哈希函数:

哈希方法中使用的转换函数称为哈希(散列)函数,引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则
1.哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必2.须在0到m-1之间
3.哈希函数计算出来的地址能均匀分布在整个空间中
4.哈希函数应该比较简单
常见哈希函数:
常用的有两种:直接定制法和除留余数法,分别通过下图来演示分析

哈希冲突的解决:

上述我们了解到,除留余数法是比较好的哈希函数设计方法,但存在哈希冲突,那我们就要着手解决哈希冲突,解决哈希冲突常用的有两种方法:闭散列和开散列,下面我们分别介绍并实现。

闭散列:

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

1. 线性探测概念

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


插入:

1.通过哈希函数获取待插入元素在哈希表中的位置
2.如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

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

2.线性探测的模拟实现

这里顺便就尝试搭建哈希表的整体框架,本处我们主要以学习哈希思想及结构为主,因此暂时不需要考虑封装unordered_map和unordered_set时的问题,其中重点将以注释的形式在代码中标注。

整体结构代码如下:

#pragma once
#include<iostream>
#include<vector>
namespace wz
{
	//定义枚举结构表示哈希表每一个地址空间的状态
	enum State
	{
		EEMPTY,
		EXIST,
		DELETE
	};
	template<class K, class V>
	//定义哈希表中每个地址空间的数据类型,包括两部分:数据+状态
	class HashData
	{
		pair<K, V> _kv;
		State _s = EMPTY;
	};
	template<class K,class V>
	class HashTable
	{
	public:
		//...
		//此处实现其各个成员函数,insert等
		//...
	private:
		vector<HashData<K,V>> _table;//用vector来存储HastData类型的数据来表示哈希表结构
		size_t _n=0//表示存储的数据
	};

}

insert()代码如下: 

实现的思路:

1、实现核心的线性探测部分,我们会发现只有这部分代码会报错:

//线性探测
size_t hashi = kv.first % _table.size();
size_t index = hashi;
int i = 1;
while (_table[index]._s == EXIST)
{
	index = hashi + i;
	index %= _table.size();
	++i;
 }
_table[index]._kv = kv;
_table[index]._s = EXIST;
_n++;

 原因是刚开始并没有给_table开辟空间,因此扩容发生在刚开始插入的时候,另外当哈希表中数据量占整体空间过大时,哈希冲突会增加,因此在现有空间即将存满的时候也会进行扩容,我们怎么衡量哈希表即将存满呢,这时候就要提出一个概念叫负载因子,负载因子=_n/_table.size(),这里扩容我们采用的是重新构造一个新容量的哈希表,将现有哈希表中的数据入新表,新表旧表数据互换,运行并测试如下:

观察上述代码我们发现在扩容后旧表数据入新表时,也需要每插入一次,线性探测一次,与后续插入新元素时的线性探测代码几乎一样,考虑C++代码的高复用性,我们需对整体代码加以改造,通过分析得到扩容后对新表的操作也可以看作是插入操作,因此这里其实直接复用insert函数即可,具体实现如下:

//扩容
if (_table.size() == 0 || _n * 10 / _table.size() > 7)
{
	size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
	HashTable<K,V> newht;
	newht._table.resize(newsize);
	
	for (auto& data : _table)
	{
		if (data._s== EXIST)
		{
			newht.insert(data._kv);
		}
	}
	_table.swap(newht._table);
}

 另外重要的一点是不能忘记还要考虑去重问题,去重就需要查找,因此我们先来实现查找函数,查找的思路也是以线性探测为核心

find()代码如下: 

HashData<K, V>* find(const K& key)
{
//如果当前哈希表没开空间,当然不会找到,返回空指针
if (_table.size() == 0)
{
	return nullptr;
}

  // 线性探测
  size_t hashi = key% _table.size();
  size_t i = 1;
  size_t index = hashi;
    //如果当前哈希表中数据状态不为空,则可能有与要插入的值相等的值
    while (_table[index]._s != EMPTY)
	{
        //存在且相等,表示该值已经插入到表中了,返回该数据所在表中的地址
		if (_table[index]._s == EXIST
			&& _table[index]._kv.first == key)
		  {
			return &_table[index];
		  }

			index = hashi + i;
			index %= _table.size();
			++i;

			// 如果已经查找一圈,那么说明全是存在+删除
			if (index == hashi)
			{
				break;
			}
	  }
//到这里就说明没找到,返回空指针即可
	return nullptr;
}

完善insert()代码如下:

bool insert(const pair<K, V>& kv)
{
	//判断表里原来有没有
	if (find(kv.first))
	{
		return false;
	}
	//扩容
	if (_table.size() == 0 || _n * 10 / _table.size() > 7)
	{
		size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
		HashTable<K,V> newht;
	    newht._table.resize(newsize);
		
		for (auto& data : _table)
		{
			if (data._s== EXIST)
			{
				newht.insert(data._kv);
			}
		}
		_table.swap(newht._table);
	}
	//线性探测
	size_t hashi = kv.first % _table.size();
	size_t index = hashi;
	int i = 1;
	while (_table[index]._s == EXIST)
	{
		index = hashi + i;
		index %= _table.size();
		++i;
	 }
	_table[index]._kv = kv;
	_table[index]._s = EXIST;
	_n++;
return true;
}

测试结果如下:

erase()代码如下:

删除部分我们采取直接用状态标识的假删除,因此实现起来比较简单,如下:

bool erase(const K& key)
{
	HashData<K, V>* ret = find(key);
	if (ret)
	{
		ret->_s = DELETE;
		--_n;
		return true;
	}
	else
	{
		return false;
	}

 测试结果如下:

综合测试:

 3.二次探测概念

 上面我们用代码实现了线性探测一次来解决哈希碰撞的问题,但解决方式不止一种,二次探测也是常用的方法

线性探测的缺陷是产生冲突的数据堆积在一块,容易形成踩踏这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:hashi = (H_0 + i^2 )% m, 或者:hashi = (H_0 - i^2 )% m。其中:i = 1,2,3…, H_0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。

研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷,我们实际上不常以闭散列的方式实现哈希表,接下来介绍常用的开散列实现方式。

开散列:

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

 本质可以认为哈希表中存的是一个个的结点指针,以这些结点指针为头指针后面还链接这其它结点指针,从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素

开散列的模拟实现

这里仍然不考虑封装的问题,先实现功能,首先哈希桶整体结构如下:

namespace HashBucket
{
template<class K,class V>
struct HashNode
{
	HashNode<K,V>* _next;
	pair<K, V> _kv;

	HashNode(const pair<K, V>& kv)
		:_next(nullptr)
		, _kv(kv)
	{}
};

template<class K,class V>
class HashTable
{
	typedef HashNode<K,V> Node;
//...
//成员函数部分
//...

private:
	vector<Node*> _table;
	size_t _n=0;
};

}
析构函数:
这里与闭散列不同,需要自己实现析构函数,原因是闭散列的时候,存在表里的是一个个数据本身,而开散列存的则是一个个结点指针,关键是头结点指针后面可能还链接着其它结点指针,闭散列在程序结束后,默认的析构函数就够用了,而开散列调用默认析构函数指针释放各个桶的头结点指针,每个桶后面挂着的结点指针还在,造成内存泄露,代码如下:
~HashTable()
	{
		for (auto cur : _table)
		{
			while (cur)
			{
				Node* next = cur->next;
				delete cur;
				cur = next;
			}
			cur = nullptr;
		}
	}

Find()查找函数:

无论是删除还是插入都需要用到查找函数,因此我们先来实现它,代码如下:

	Node* Find(const K& key)
	{
		if (_table.size() == 0)
			return nullptr;

		size_t hashi = key% _table.size();
		Node* cur = _table[hashi];
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				return cur;
			}

			cur = cur->_next;
		}

		return nullptr;
	}

Insert()插入函数:

插入函数的的思想其实和闭散列的实现相类似,都是先判断表中有没有,再判断是否需要扩容,最后在插入到合适的位置,只是在扩容部分有些区别,开散列的扩容是可以在负载因子为1时扩容的,其次在闭散列时,我们推荐的是复用思想,即构造一个新的哈希表,就哈希表中元素依次插入新哈希表后,新旧表内容交换,但这里不推荐这种方法,原因是消耗太大,假若一共有N个数据在哈希表中,那么构造一个新的哈希桶就要构造再N个结点,析构N个结点,完全没必要,因此我们选择定义一个newtable,直接将_table中的结点指针按照规则插入链接即可,如下:

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

		if (_table.size() == _n)
		{
			size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
			vector<Node*> newtable(newsize, nullptr);
			//for (Node*& cur : _table)
			for (auto& cur : _table)
			{
				while (cur)
				{
					Node* next = cur->_next;
					size_t hashi = cur->_kv.first% newtable.size();

					// 头插到新表
					cur->_next = newtable[hashi];
					newtable[hashi] = cur;

					cur = next;
				}
			}
			_table.swap(newtable);
		}

      size_t hashi = kv.first % _table.size();
	  Node* newnode = new Node(kv);
		newnode->_next = _table[hashi];
		_table[hashi] = newnode;
		_n++;
	return true;
	}

Erase()删除函数:

开散列删除函数的实现就不想闭散列那么简单了,它没有状态的概念,只能实打实的删除,并且设计到指针的链接修改问题,实现如下,需要注意的点都以注释的形式标注了:

bool erase(const K& key)
{
	int ret = Finf(key);
	if (ret)//ret不为空,找到了,开始删除
	{
		size_t hashi = key % _table.size();
		Node* cur = _table[hashi];
		Node* prev = nullptr;
		while (cur)//从头结点开始往下找,知道碰到nullptr这个桶就走完了
		{
		  if (cur->_kv.first == key)
			{
			  if (prev == nullptr)//说明该节点为头结点,头结点直接更新为cur的下一个即可
				{
				  _table[hashi] = cur->_next;
				}
			  else//不是头结点,让prev即cur的前一个节点的_next指向cur的_next
				{
				  prev->_next = cur->_next;
				}
			delete cur;
			return true;
		    }
		  else
			{
			 prev = cur;
			 cur = cur->_next;
			}
	    }
   }
	return false;
}

测试结果如下:

 综上,我们自己以开散列形式实现的哈希表基本功能已经实现,接下来就是要考虑细节。

代码完善

1.key值类型不确定,如何解决

我们现在实现的代码,只适用于key值为整型的数据类型,因为在定位hashi中都涉及到取模的运算,并不是所有类型都能隐式转换为整型再进行取模运算,比如说字符串类型,字符数组是无法自动转换为整型的,这个时候,我们采用的是设置仿函数,如下:

template<class K>
struct Hashfunc
{
	size_t operator()(const K& key)
	{
		return key;
	}
};
template<class K,class V,class Hash= Hashfunc<K>>
class HashTable
{
//..//
};

使用过程中,只需要在要进行取模运算之前定义一个Hash仿函数对象,使用时按照函数的方式使用即可,以Find()函数中的应用为例,如下:

Node* Find(const K& key)
	{
		if (_table.size() == 0)
			return nullptr;
		Hash hash;
		size_t hashi = hash(key)% _table.size();
		Node* cur = _table[hashi];
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				return cur;
			}

			cur = cur->_next;
		}

		return nullptr;
	}

如果类似于字符串这样不能自动转为整型的类型就手动实现一个,使用时传自己实现的即可

注意:对于将字符串转换为整型存在以下问题

1.如果转换方式选择返回字符串首字符对应的ASCII值,那就会存在,首字符相同的值会被认为是相同的值,如:"student"和"string";

2.如果转换方式选择返回字符串所有字符对应的ASCII值相加,那就会存在,字母组成相同只有顺序不同的会被认为是相同的值,如:“ate”和“eat”;

要想解决这个问题,我们就要找到尽可能转换结果不会重复的转换方式,为此专门有人研究为我们提供了字符串相关的哈希算法,实现方式很多,具体可以参考以下链接,我们这里只以其中一种常用的为例:

https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.htmlhttps://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html我们选择的方式是每加一个字符,就给结果*31,实现如下:

struct HashStr
	{
		// BKDR,该算法的名称,是发明的两名作者名字首字母的缩写组合
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto ch : s)
			{
				hash += ch;
				hash *= 31;
			}

			return hash;
		}
	};

 测试结果如下:

由于字符串类型经常作为key值被使用,每次用的时候都要传一次未免过于麻烦,而且之前测试使用unordered_map时发现也是不用自己传仿函数的,这是因为库里针对key值为字符串类型的情况对仿函数模板进行了特化,我们也来特化一下:

	template<class K>
	struct Hashfunc
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};
	// 特化
	template<>
	struct Hashfunc<string>
	{
		// BKDR
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto ch : s)
			{
				hash += ch;
				hash *= 31;
			}

			return hash;
		}
	};

这样我们使用的时候,以字符串类型做key值类型的时候就不用自己传了,编译器会自动识别匹配。

2.除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?

具体原因其实没有找到特别权威准确的描述,但有人提出来,可能是做了测试,认为素数造成的碰撞会少一些,我们就参考SGI库里的理解一下即可,代码如下:

size_t GetNextPrime(size_t prime)
{
	// SGI
	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
	};

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

	return __stl_prime_list[i];
}

//使用方法: size_t newsize = GetNextPrime(_tables.size());

总的来说就是初始容量为53开始,每次扩容都是上一次容量2倍附近的一个值,这些数都是无符号长整型类型的数,直至最大无符号长整型为止【事实上根本不可能到这么大,从内存角度理解】。

3.性能优化

理论上,最坏情况下,哈希表的查找效率是O(N),这种情况就是所有的数据都挂在一个桶上,但实际上这种最坏的情况几乎不会发生,因为我们有上述负载因子的控制,哈希表每个桶的长度不会过长,以一组随机数为例来测试:

size_t MaxBucketSize()
{
	size_t max = 0;
	for (size_t i = 0; i < _table.size(); ++i)
	{
		auto cur = _table[i];
		size_t size = 0;
		while (cur)
		{
			++size;
			cur = cur->_next;
		}

		//printf("[%d]->%d\n", i, size);
		if (size > max)
		{
			max = size;
		}
	}
	return max;
}

测试结果如下:

插入9万个数最多数据的桶才2,足可见,效率其实可以几乎达到O(1)

 即使几乎不可能达到最坏的情况,但不排除会有人故意难为你,问你如何解决单个桶元素过多的情况,这里提供一种思想,了解一下就好,不需要去实现,我们可以给设置一个标准值,当一个桶链接的节点个数超过标准值是,就不在一个个的按照原来的方式往下链接,而是转换为红黑树插入,这样就能够解决每个桶链接数据量过大导致效率低下的问题。

模拟实现

基本框架的搭建:

首先要按照封装map和set的方式改造哈希表结构,先搭架子。

//HashTable.h

#pragma once
#include<vector>
#include<string>
template<class T>
struct HashNode
{
	HashNode<T>* _next;
	T _data;

	HashNode(const T& data)
		:_next(nullptr)
		, _data(data)
	{}
};

template<class K>
struct Hashfunc
{
	size_t operator()(const K& key)
	{
		return key;
	}
};
// 特化
template<>
struct Hashfunc<string>
{
	// BKDR
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 31;
		}

		return hash;
	}
};
namespace HashBucket
{
	
	template<class K, class T,class KeyOfT, class Hash = Hashfunc<K>>
	class HashTable
	{
		typedef HashNode<T> Node;
	public:
		~HashTable()
		{
			for (auto cur : _table)
			{
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				cur = nullptr;
			}
		}
		Node* Find(const K& key)
		{
			if (_table.size() == 0)
				return nullptr;
			KeyOfT kot;
			Hash hash;
			size_t hashi = hash(key) % _table.size();
			Node* cur = _table[hashi];
			while (cur)
			{
				if (kot(cur->_data) == key)
				{
					return cur;
				}

				cur = cur->_next;
			}

			return nullptr;
		}
		size_t GetNextPrime(size_t prime)
		{
			// SGI
			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
			};

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

			return __stl_prime_list[i];
		}

		bool Insert(const T& data)
		{
			KeyOfT kot;
		//	if (Find(kot(data)){return false;}

			if (_table.size() == _n)
			{
				//size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
				size_t newsize = GetNextPrime(_table.size());
				vector<Node*> newtable(newsize, nullptr);
				//for (Node*& cur : _table)
				for (auto& cur : _table)
				{
					while (cur)
					{
						Node* next = cur->_next;
						Hash hash;
						KeyOfT kot;
						size_t hashi = hash(kot(cur->_data)) % newtable.size();

						// 头插到新表
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;

						cur = next;
					}
				}
				_table.swap(newtable);
			}
			Hash hash;
			size_t hashi = hash(kot(data)) % _table.size();
			Node* newnode = new Node(data);
			// 头插到新表
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			_n++;
			return true;

		}
		
		bool erase(const K& key)
		{
			auto ret = Find(key);
			if (ret)//ret不为空,找到了,开始删除
			{
				Hash hash;
				KeyOfT kot;
				size_t hashi = hash(key) % _table.size();
				Node* cur = _table[hashi];
				Node* prev = nullptr;
				while (cur)//从头结点开始往下找,知道碰到nullptr这个桶就走完了
				{
					if (kot(cur->_data) == key)
					{
						if (prev == nullptr)//说明该节点为头结点,头结点直接更新为cur的下一个即可
						{
							_table[hashi] = cur->_next;
						}
						else//不是头结点,让prev即cur的前一个节点的_next指向cur的_next
						{
							prev->_next = cur->_next;
						}
						delete cur;
						_n--;
						return true;
					}
					else
					{
						prev = cur;
						cur = cur->_next;
					}
				}
			}
			return false;
		}
		size_t MaxBucketSize()
		{
			size_t max = 0;
			for (size_t i = 0; i < _table.size(); ++i)
			{
				auto cur = _table[i];
				size_t size = 0;
				while (cur)
				{
					++size;
					cur = cur->_next;
				}

				//printf("[%d]->%d\n", i, size);
				if (size > max)
				{
					max = size;
				}
			}

			return max;
		}
	private:
		vector<Node*> _table;
		size_t _n = 0;
	};


}
//unordered_map


#pragma once
#include "HashTable.h"
namespace wz
{
	template<class K,class V,class Hash= Hashfunc<K>>
	class unordered_map
	{
	public:
		struct MapKeyofT
		{
			const K& operator()(const pair<K, V>& kv)
			{
				return kv.first;
			}
		};
		bool Insert(const pair<const K,V>& kv)
		{
			return _ht.Insert(kv);
		}
		bool Find(const K& key)
		{
			return _ht.Find(key);
		}
		bool Erase(const K& key)
		{
			return _ht.erase(key);
		}
	private:
		HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash> _ht;
	};
	void test_unordered_map1()
	{
		unordered_map<int, int> m;
		m.Insert(make_pair(1, 1));
		m.Insert(make_pair(2, 2));
		m.Insert(make_pair(3, 3));

	}
}


//unordered_set.h


#pragma once
#include "HashTable.h"
namespace wz
{
	template<class K, class Hash = Hashfunc<K>>
	class unordered_set
	{
	public:
		struct SetKeyofT
		{
			const K& operator()(const K& key)
			{
				return key;
			}
		};
		bool Insert(const K& key)
		{
			return _ht.Insert(key);
		}
		bool Find(const K& key)
		{
			return _ht.Find(key);
		}
		bool Erase(const K& key)
		{
			return _ht.erase(key);
		}
	private:
		HashBucket::HashTable<K, K, SetKeyofT, Hash> _ht;
	};
	void test_unordered_set1()
	{
		unordered_set<int> s;
		s.Insert(1);
		s.Insert(2);
		s.Insert(3);


	}
}

以上完成了哈希表基本结构的改造和unordered_map和unordered_set基本框架的编写及测试,测试结果都显示是没有什么问题的,实现方式和用红黑树封装map和set大同小异,接下来的重点依然是如何实现哈希表的迭代器进而如何封装出unordered_map和unordered_set的迭代器。

迭代器的实现:

哈希表的迭代器实现如下:

//因为要在里面定义哈希表,因此最好做前置声明
	template<class K, class T, class KeyOfT, class Hash>
	class HashTable;

	//下面正式开始实现迭代器
	template<class K,class T,class Ref,class Ptr,class KeyOfT,class Hash>
	struct __HashIterator
	{
		typedef HashNode<T> Node;
		typedef HashTable<K, T, KeyOfT, Hash> HT;
		typedef __HashIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;
		typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;

		//模拟遍历哈希表的行为,思考作为哈希表的迭代器需要的成员变量
		Node* _node;//当前结点指针
		const HT* _ht;//当前所在哈希表
		__HashIterator(Node* node, const HT* ht)
			:_node(node)
			, _ht(ht)
		{}

		__HashIterator(const Iterator& it)
			:_node(it._node)
			, _ht(it._ht)
		{}
		Ref operator*()
		{
			return _node->_data;
		}

		Ptr operator->()
		{
			return &_node->_data;
		}

		bool operator!=(const Self& s)
		{
			return _node != s._node;
		}
		Self& operator++()
		{
			if (_node->_next != nullptr)
			{
				_node = _node->_next;
			}
			else
			{
				// 找下一个不为空的桶
				KeyOfT kot;
				Hash hash;
				// 算出我当前的桶位置
				size_t hashi = hash(kot(_node->_data)) % _ht->_table.size();
				++hashi;
				while (hashi < _ht->_table.size())
				{
					if (_ht->_table[hashi])
					{
						_node = _ht->_table[hashi];
						break;
					}
					else
					{
						++hashi;
					}
				}

				// 没有找到不为空的桶
				if (hashi == _ht->_table.size())
				{
					_node = nullptr;
				}
			}

			return *this;
		}
	};

在HashTable中定义begin()、end()、const_begin()、const_end(),如下:

//友元声明,因为需要访问到迭代器的成员
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
friend struct __HashIterator;

typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> iterator;
typedef __HashIterator<K, T, const T&, const T*, KeyOfT, Hash> const_iterator;
		iterator begin()
		{
			Node* cur = nullptr;
			for (size_t i = 0; i < _table.size(); ++i)
			{
				cur = _table[i];
				if (cur)
				{
					break;
				}
			}

			return iterator(cur, this);
		}

		iterator end()
		{
			return iterator(nullptr, this);
		}

		const_iterator begin() const
		{
			Node* cur = nullptr;
			for (size_t i = 0; i < _table.size(); ++i)
			{
				cur = _table[i];
				if (cur)
				{
					break;
				}
			}

			return const_iterator(cur, this);
		}

		const_iterator end() const
		{
			return const_iterator(nullptr, this);
		}

unordered_map和unordered_set的迭代器封装哈希表中的迭代器如下:

//unordered_map.h


typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash>::iterator iterator;

typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash>::const_iterator const_iterator;

		iterator begin()
		{
			return _ht.begin();
		}

		iterator end()
		{
			return _ht.end();
		}

		const_iterator begin() const
		{
			return _ht.begin();
		}

		const_iterator end() const
		{
			return _ht.end();
}


//unordered_set.h



typedef typename HashBucket::HashTable<K, K, SetKeyofT, Hash>::const_iterator iterator;
typedef typename HashBucket::HashTable<K, K, SetKeyofT, Hash>::const_iterator const_iterator;


		iterator begin()
		{
			return _ht.begin();
		}

		iterator end()
		{
			return _ht.end();
		}

		const_iterator begin() const
		{
			return _ht.begin();
		}

		const_iterator end() const
		{
			return _ht.end();
		}

测试基本功能如下,可见目前我们的实现没有问题:

进一步完善:

到这一步,我们对unordered_set的封装就已经差不多了,unordered_map还差一个重要的部分就是实现[ ],类比map和set的[ ]分析与实现,我们同样可以快速完成,与此同时要做一件事情就是将所有Insert()的返回值类型都修改为pair<iterator,bool>类型

//unordered_map.h


V& operator[](const K& key)
{
	pair<iterator, bool> ret = Insert(make_pair(key, V()));
	return ret.first->second;
}

pair<iterator, bool> Insert(const pair<const K,V>& kv)
{
	return _ht.Insert(kv);
}


//unordered_set.h


pair<iterator, bool> Insert(const K& key)
{
	return _ht.Insert(key);
}


//HashTable.h


pair<iterator, bool> Insert(const T& data)
{
	KeyOfT kot;
	iterator it = Find(kot(data));
	if (it != end())
	{
		return make_pair(it, false);
	}


	if (_table.size() == _n)
	{
		//size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
		size_t newsize = GetNextPrime(_table.size());
		vector<Node*> newtable(newsize, nullptr);
		//for (Node*& cur : _table)
		for (auto& cur : _table)
		{
			while (cur)
			{
				Node* next = cur->_next;
				Hash hash;
				KeyOfT kot;
				size_t hashi = hash(kot(cur->_data)) % newtable.size();

				// 头插到新表
				cur->_next = newtable[hashi];
				newtable[hashi] = cur;
					cur = next;
			}
		}
		_table.swap(newtable);
	}
	Hash hash;
	size_t hashi = hash(kot(data)) % _table.size();
	Node* newnode = new Node(data);
	// 头插到新表
	newnode->_next = _table[hashi];
	_table[hashi] = newnode;
	_n++;
	return make_pair(iterator(newnode, this), true);

}

修改Find()返回值类型为iterator,记得所有需要用到Find返回值的都需要修改:

//unordered_map.h


iterator Find(const K& key)
{
	return _ht.Find(key);
}


//unordered_map.h


iterator Find(const K& key)
{
	return _ht.Find(key);
}


//HashTable.h


iterator Find(const K& key)
{	
    if (_table.size() == 0)
		return end();
	KeyOfT kot;
	Hash hash;
	size_t hashi = hash(key) % _table.size();
	Node* cur = _table[hashi];
	while (cur)
	{
		if (kot(cur->_data) == key)
		{
			return iterator(cur, this);;
		}
			cur = cur->_next;
	}
	return end();
}

综上,我们的代码基本完善,我来来用自定义类型作为key的方式来测试一下,这里就用我们之前实现过的日期类的基本代码即可,日期类如下:

	class Date
	{
		friend struct HashDate;
	public:
		Date(int year = 1900, int month = 1, int day = 1)
			: _year(year)
			, _month(month)
			, _day(day)
		{}

		bool operator<(const Date& d)const
		{
			return (_year < d._year) ||
				(_year == d._year && _month < d._month) ||
				(_year == d._year && _month == d._month && _day < d._day);
		}

		bool operator>(const Date& d)const
		{
			return (_year > d._year) ||
				(_year == d._year && _month > d._month) ||
				(_year == d._year && _month == d._month && _day > d._day);
		}

		bool operator==(const Date& d) const
		{
			return _year == d._year
				&& _month == d._month
				&& _day == d._day;
		}

		friend ostream& operator<<(ostream& _cout, const Date& d);
	private:
		int _year;
		int _month;
		int _day;
	};

	ostream& operator<<(ostream& _cout, const Date& d)
	{
		_cout << d._year << "-" << d._month << "-" << d._day;
		return _cout;
	}

作为key值的话必须能支持转换成整型,才能参与哈希底层的取模运算,日期类不支持自行转换,因此我们自己实现HashDate,实现如下,需要注意的是,在这过程中,由于需要访问日期类内部私有成员变量,因此可以将其在日期类中声明为友元类,HashDate实现代码如下:

struct HashDate
	{
		size_t operator()(const Date& d)
		{
			//这里要访问私有成员,因此将其在日期类里声明为友元类
			size_t hash = 0;
			hash += d._year;
			hash *= 31;

			hash += d._month;
			hash *= 31;

			hash += d._day;
			hash *= 31;

			return hash;
		}
	};

做一个简单的测试,测试代码和结果如下,说明我们封装的unordered_map基本上没什么问题:

综上,我关于以哈希为底层的unordered_set和unordered_map总结的就差不多了,内容很多,细节也不少,多看几遍,多敲代码,以便加深理解!

本课涉及到的所有代码都见以下链接,欢迎参考指正!

practice: 课程代码练习 - Gitee.comhttps://gitee.com/ace-zhe/practice/tree/master/Hash

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/51499.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

使用Pytest生成HTML测试报告

背景 最近开发有关业务场景的功能时&#xff0c;涉及的API接口比较多&#xff0c;需要自己模拟多个业务场景的自动化测试&#xff08;暂时不涉及性能测试&#xff09;&#xff0c;并且在每次测试完后能够生成一份测试报告。 考虑到日常使用Python自带的UnitTest&#xff0c;所…

观察者模式与观察者模式实例EventBus

什么是观察者模式 顾名思义&#xff0c;观察者模式就是在多个对象之间&#xff0c;定义一个一对多的依赖&#xff0c;当一个对象状态改变时&#xff0c;所有依赖这个对象的对象都会自动收到通知。 观察者模式也称为发布订阅模式(Publish-Subscribe Design Pattern)&#xff0…

ViT-vision transformer

ViT-vision transformer 介绍 Transformer最早是在NLP领域提出的&#xff0c;受此启发&#xff0c;Google将其用于图像&#xff0c;并对分类流程作尽量少的修改。 起源&#xff1a;从机器翻译的角度来看&#xff0c;一个句子想要翻译好&#xff0c;必须考虑上下文的信息&…

【Linux后端服务器开发】MAC地址与其他重要协议

目录 一、以太网 二、MAC地址 三、MTU 四、ARP协议 五、DNS系统 六、ICMP协议 七、NAT技术 八、代理服务器 一、以太网 “以太网”不是一种具体的网路&#xff0c;而是一种技术标准&#xff1a;既包含了数据链路层的内容&#xff0c;也包含了一些物理层的内容&#xf…

k8s容器日志收集方案

背景 由于以下容器本身特性和现有日志采集工具的缺陷&#xff0c;开发者在收集Kubernetes分布式集群日志时常常遇到困扰&#xff1a; 容器本身特性&#xff1a; 采集目标多&#xff1a;容器本身的特性导致采集目标多&#xff0c;需要采集容器内日志、容器stdout。对于容器…

实验六 调度器-实验部分

目录 一、知识点 1.进程调度器设计的目标 1.1.进程的生命周期 1.2.用户进程创建与内核进程创建 1.3.进程调度器的设计目标 2.ucore 调度器框架 2.1.调度初始化 2.2.调度过程 2.2.1.调度整体流程 2.2.2.设计考虑要点 2.2.3.数据结构 2.2.4.调度框架应与调度算法无关…

Zabbix分布式监控快速入门

目录 1 Zabbix简介1.1 软件架构1.2 版本选择1.3 功能特性 2 安装与部署2.1 时间同步需求2.2 下载仓库官方源2.3 Zabbix-Server服务端的安装2.3.1 安装MySQL2.3.1.1 创建Zabbix数据库2.3.1.2 导入Zabbix库的数据文件 2.3.2 配置zabbix_server.conf2.3.3 开启Zabbix-Server服务2.…

ElementUI Select选择器如何根据value值显示对应的label

修改前效果如图所示&#xff0c;数据值状态应显示为可用&#xff0c;但实际上仅显示了状态码1&#xff0c;并没有显示其对应的状态信息。在排查了数据类型对应关系问题后&#xff0c;并没有产生实质性影响&#xff0c;只好对代码进行了如下修改。 修改前代码&#xff1a; <…

微服务划分的原则

微服务的划分 微服务的划分要保证的原则 单一职责原则 1、耦合性也称块间联系。指软件系统结构中各模块间相互联系紧密程度的一种度量。模块之间联系越紧密&#xff0c;其耦合性就越强&#xff0c;模块的独立性则越差。模块间耦合高低取决于模块间接口的复杂性、调用的方式及…

JS逆向之猿人学爬虫第20题-wasm

文章目录 题目地址sign参数分析python算法还原往期逆向文章推荐题目地址 https://match.yuanrenxue.cn/match/20第20题被置顶到了第1页,题目难度 写的是中等 算法很简单,就一个标准的md5算法,主要是盐值不确定, 而盐值就在wasm里面,可以说难点就在于wasm分析 sign参数分…

最优除法(力扣)数学 JAVA

给定一正整数数组 nums&#xff0c;nums 中的相邻整数将进行浮点除法。例如&#xff0c; [2,3,4] -> 2 / 3 / 4 。 例如&#xff0c;nums [2,3,4]&#xff0c;我们将求表达式的值 “2/3/4”。 但是&#xff0c;你可以在任意位置添加任意数目的括号&#xff0c;来改变算数的…

Git的安装以及本地仓库的创建和配置

文章目录 1.Git简介2.安装Git2.1在Centos上安装git2.2 在ubuntu上安装git 3.创建本地仓库4.配置本地仓库 1.Git简介 Git是一个分布式版本控制系统&#xff0c;用于跟踪和管理文件的更改。它可以记录和存储代码的所有历史版本&#xff0c;并可以方便地进行分支管理、合并代码和协…

【自动化运维】Ansible常见模块的运用

目录 一、Ansible简介二、Ansible安装部署2.1环境准备 三、ansible 命令行模块3.1&#xff0e;command 模块3.2&#xff0e;shell 模块3.3&#xff0e;cron 模块3.4&#xff0e;user 模块3.5&#xff0e;group 模块3.6&#xff0e;copy 模块3.7&#xff0e;file 模块8&#xff…

VLAN原理(Virtual LAN 虚拟局域网)

VLAN&#xff08;Virtual LAN 虚拟局域网&#xff09; 1、广播/广播域 2、广播的危害&#xff1a;增加网络/终端负担&#xff0c;传播病毒&#xff0c; 3、如何控制广播&#xff1f;&#xff1f; ​ 控制广播隔离广播域 ​ 路由器物理隔离广播 ​ 路由器隔离广播缺点&…

机器学习深度学习——多层感知机

&#x1f468;‍&#x1f393;作者简介&#xff1a;一位即将上大四&#xff0c;正专攻机器学习的保研er &#x1f30c;上期文章&#xff1a;机器学习&&深度学习——感知机 &#x1f4da;订阅专栏&#xff1a;机器学习&&深度学习 希望文章对你们有所帮助 上一节…

VR全景在酒店的发展状况如何?酒店该如何做营销?

现阶段&#xff0c;VR全景技术已经被酒店、民宿、旅游景区、房产楼盘、校园等行业所应用&#xff0c;每天都有不少人通过VR全景展示来了解酒店的设施环境&#xff0c;而酒店也可以借此机会&#xff0c;详细展示自身优势&#xff0c;更大范围吸引顾客。 VR酒店拥有真实、立体的全…

互斥量 的初识

Q: 什么是互斥量&#xff1f; A: 在多数情况下&#xff0c;互斥型信号量和二值型信号量非常相似&#xff0c;但是从功能上二值型信号量用于同步&#xff0c; 而互斥型信号量用于资源保护。 互斥型信号量和二值型信号量还有一个最大的区别&#xff0c;互斥型信号量可以有效解决…

分布式理论:CAP理论 BASE理论

文章目录 1. CAP定理1.1 一致性1.2 可用性1.3 分区容错1.4 矛盾 2. BASE理论3. 解决分布式事务的思路4. 扩展 解决分布式事务问题&#xff0c;需要一些分布式系统的基础知识作为理论指导。 1. CAP定理 Consistency(一致性): 用户访问分布式系统中的任意节点&#xff0c;得到的…

Sentinel dashboard的使用;Nacos保存Sentinel限流规则

Sentinel dashboard的使用 往期文章 Nacos环境搭建Nacos注册中心的使用Nacos配置中心的使用Sentinel 容灾中心的使用 参考文档 Sentinel alibaba/spring-cloud-alibaba Wiki GitHub 限流结果 下载sentinel-dashboard github地址&#xff1a;Sentinel/sentinel-dashboar…

SpringBoot的三层架构以及IOCDI

目录 一、IOC&DI入门 二、三层架构 数据库访问层 业务逻辑层 控制层 一、IOC&DI入门 在软件开发中&#xff0c;IOC&#xff08;Inversion of Control&#xff09;和DI&#xff08;Dependency Injection&#xff09;是密切相关的概念。 IOC&#xff08;控制反转&a…