【C++和数据结构】模拟实现哈希表和unordered_set与unordered_map

目录

一、哈希的概念与方法

1、哈希概念

2、常用的两个哈希函数

二、闭散列的实现

1、基本结构:

2、两种增容思路 和 插入

闭散列的增容:

哈希表的插入:

3、查找

4、删除

 三、开散列的实现

1、基本结构

2、仿函数Hash 

3、迭代器实现

4、增容和插入

5、查找 

6、删除

7、Clear和析构函数

四、哈希表模拟实现unordered_set和unordered_map


看这篇文章之前你需要对哈希表有一定了解,本文主讲代码实现 

一、哈希的概念与方法

1、哈希概念

哈希表和数组区别:哈希表是在数组的基础上按 映射关系放的 

比如我们身份证号就是个映射关系,比如看身份证号的前几位,就能知道你是哪个省的,后几位就能看出你的生日

注:

哈希:映射方式的算法

哈希表:使用哈希建立的数据结构


2、常用的两个哈希函数

注:由于除留余数法是目前很好的哈希函数,本文讲解用的全是除留余数法,故不用直接定址法

1. 直接定址法 --( 常用 )
 取关键字的某个线性函数为散列地址: Hash Key = A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
面试题: 字符串中第一个只出现一次字符

2. 除留余数法 --( 常用 )
设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数,
按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址
直接定址法是是 没有哈希冲突的,每个值都映射了一个唯一位置,但为什么要引入除留余数法,因为若数据分布不均匀,那么我们给每个值映射一个位置,可能会造成巨大的空间浪费。而除留余数法,不再是给每个值映射一个位置,是在限定大小的空间中将我们的值映射进去。 index = key % 空间大小

 除留余数法带来的问题:不同的值可能会映射到相同位置上去,导致哈希冲突。哈希冲突越多整体而言效率越低。

除留余数法的使用:

 如何解决哈希冲突呢?

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

1、闭散列——开放定址法(当前位置冲突了,则按规则再给你找个位置)

        a、线性探测     b、二次探测

2、开散列——拉链法哈希桶)(重点


二、闭散列的实现

先用哈希表的查找来引出基本结构的设计

 可见,删除会影响查找,可不可以这样考虑:直接遍历整个表查找一下有没有21?不行,哈希表就是为了效率而生的,你这么搞效率就是O(N)了,哈希表就没意义了。

注:哈希表的代码在HashTable.h中实现

1、基本结构:

这里的哈希表实现完全可以写为KV模型的,但是我们这里是为了模拟实现unordered_setunordered_map,故我们用K,T 

  • K就是按照关键字K来查找等
  • unordered_set对于T,会传入K,而unordered_map对于T,会传入pair<K,V>,故用T来表示对两种结构的通用
  • KeyOfT表示要取出的数据是K类型的,因为我们后续的比较都是用K类型的来直接比较,所以我们要取出T中的K,这一点利用仿函数来实现,(因为两种结构所传入的T不同)

哈希表中存放两个变量,为什么要用vector?首要原因是哈希表的本质就是数组,而vector符合是动态数组这一点,其次是方便,vector是自定义类型,它支持的resize等操作在我们实现哈希表过程中非常好用,并且它的析构也不用我们管

enum State
{
	EMPTY, //空
	EXIST, //存在
	DELETE //删除
};

template<class T>
struct HashData
{
	T _data;
	State _state; //代表数据状态

	HashData()
		:_state(EMPTY)
		,_data(0)
	{}
};

template<class K, class T, class KeyOfT>
class HashTable
{
	typedef HashData<T> HashData;
private:
	vector<HashData> _tables; //哈希数组
	size_t _num = 0;	//表中存了多少个有效个数,不等于容量
};

代码中HashTable不写构造函数是因为_num初始给0了,而vector是自定义类型,它本身就有构造函数,所以没必要写构造函数 


2、两种增容思路 和 插入

闭散列的增容:

负载因子衡量哈希表满的程度,哈希表不敢让表太满,表一满,那对于哈希表插入等操作的效率是非常低的,所以引入负载因子

增容分两种思路:

1、传统思路 

  • a、开2倍大小的新表
  • b、遍历旧表的数据,重新计算在新表中位置
  • c、释放旧表

增容不能直接将旧表中的数据拷贝到新表中就完事了,而应该重新映射,若不重新映射,由于表的大小会变,旧表中的数据到新表中的数据会改变,故要重新映射 

 

2、简便思路

创建一个新的哈希表,利用哈希表已实现的Insert操作直接把原哈希表中每个数据插入到新哈希表中,注意临界条件,如果是初次增容只会resize开辟空间,详细操作见代码

哈希表的插入:

当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有 空位置,那么可以把 key 存放到冲突位置中的 下一个 空位置中去。那如何寻找下一个空位置呢?怎么找利用线性探测和二次探测,怎么插入就要找空位置或者是被删除过的位置,这一点闭散列使用enum枚举状态做到的

规则:

  • a、线性探测(挨着往后找,直到找到空位置
  • b、二次探测(按 i^2,跳跃着往后找,直到找到空位置)

线性探测:

我们先计算出该数据映射到的位置,如果映射的位置已有数据,则继续挨着往后找,直到找到一个空位置(EMPTY)或一个被删除过的位置(DELETE),我们一定能找到一个位置的,因为闭散列的数组我们引入了负载因子,我们不可能让数组满了的。

其次要注意闭散列是一个数组结构,走到尾部还没找到一个位置就要回数组头部去找,针对此操作代码可以用以下两种写法(index代表位置):

//法一、
if (index == _tables.capacity())
{
	index = 0;
}
//法二、
index %= _tables.capacity();

 二次探测:

 线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位
置的方式就是挨着往后逐个去找,会导致一片一片的冲突,因此二次探测为了避免该问题,其让冲突的数据相对分散了,不会导致连片冲突效率更高

代码如下:

bool Insert(const T& data)
{
	KeyOfT koft;
	//1、增容:第一次插入或者负载因子>=0.7就要增容
	if (_tables.capacity() == 0 || _num * 10 / _tables.capacity() == 7)
	{
		//A、增容——传统思路
		//vector<HashData> newtables;
		//size_t newcapacity = _tables.capacity() == 0 ? 10 : _tables.capacity() * 2;
		//newtables.resize(newcapacity);//开空间+自动初始化为0
		把旧空间数据拷贝到新空间中
		//for (size_t i = 0; i < _tables.capacity(); ++i)
		//{
		//	if (_tables[i]._state == EXIST)
		//	{
		//		size_t index = koft(_tables[i]._data) % newtables.capacity();
		//		while (newtables[index]._state == EXIST)
		//		{
		//			index++;

		//			if (index == newtables.capacity())
		//			{
		//				index = 0;//走到尾了就要返回头找位置
		//			}
		//		}
		//		newtables[index] = _tables[i];
		//	}
		//}

		//_tables.swap(newtables);

		//B、增容——简便思路
		HashTable<K, T, KeyOfT> newht;
		size_t newcapacity = _tables.capacity() == 0 ? 10 : _tables.capacity() * 2;
		newht._tables.resize(newcapacity);

		for (size_t i = 0; i < _tables.capacity(); ++i)
		{
			if (_tables[i]._state == EXIST)
			{
				newht.Insert(_tables[i]._data);//把原哈希表中每个数据利用Insert都插入到新哈希表中
			}
		}

		_tables.swap(newht._tables);//交换两者的vector
	}

	//1、线性探测
	//size_t index = koft(data) % _tables.capacity();//计算出要映射的位置
	//while (_tables[index]._state == EXIST)
	//{
	//	if (koft(_tables[index]._data) == koft(data))
	//	{
	//		return false;//如果存在相同的数据
	//	}

	//	++index;
	//	if (index == _tables.capacity())
	//	{
	//		index = 0;
	//	}
	//}

	//2、二次探测
	size_t start = koft(data) % _tables.capacity();
	size_t index = start;
	int i = 1;
	while (_tables[index]._state == EXIST)
	{
		if (koft(_tables[index]._data) == koft(data))
		{
			return false;
		}

		index = start + i * i;
		++i;
		index %= _tables.capacity();
	}

	//插入数据
	_tables[index]._data = data;
	_tables[index]._state = EXIST;//用状态表示该位置已有数据
	++_num;		//有效数据个数++
	
	return true;
}

问题解释和知识回顾(理解代码):

 1、vector的resize

resize会开空间+初始化,而初始化什么值,你不传,他就会默认用这个类型的默认值,比如你vector中存的是int,那就默认初始化为0,你vector中存的是指针,默认初始化为nullptr,你vector中存的是string,默认初始化为空串

2、

增容完这个操作什么意思?符合现代写法

1、交换的是vector,那_tables不就是增容完的vector吗,那我后续操作还可以用_tables

2、newht的_tables就被换得了原vector的空间,而newht是个局部变量,出作用域就销毁,正好把我原vector的空间释放了

3、

KeyOfT koft;

这是什么?KeyOfT是模板参数,而它是为了取unordered_set和unordered_map中的key值的,本质上是利用仿函数实现了这一点,故你定义一个仿函数对象koft,利用koft调用仿函数operator()即可取出T类型数据中K类型的key值

4、

_num * 10 / _tables.capacity() == 7

判断负载因子为什么要这么写,因为整数相除不能得到0.7,其实转为double也是个方法,但是不推荐,直接*10不就行了嘛


3、查找

查找操作开头就已解释过

HashData* Find(const K& key)
{
	KeyOfT koft;
	size_t index = key % _tables.capacity();
	while(_tables[index]._state != EMPTY)
	{//只要是存在和删除状态就要持续往下找
		if (koft(_tables[index]._data) == key)
		{
			if (_tables[index]._state == EXIST)
				return &_tables[index];//值相等且为存在状态
			else
				return nullptr;//值相等但为删除状态,说明被删除了
		}

		++index;//没找到继续往后找
		index %= _tables.capacity();
	}

	return nullptr;
}

4、删除

采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素
会影响其他元素的搜索 。比如删除元素 4 ,如果直接删除掉, 44查找起来可能会受影响。因此 线性探测采用标记的伪删除法来删除一个元素
	bool Erase(const K& key)
	{
		HashData* ret = Find(key);
		if (ret)
		{
			ret->_state = DELETE;//用状态代表删除状态
			--_num; //--有效元素个数
			return true;
		}
		else
		{
			return false;
		}
	}

测试开散列代码:

template<class K>
struct SetKeyOfT
{
	const K& operator()(const K& key)
	{
		return key;
	}
};

void TestCloseHash()
{
	CLOSEHASH::HashTable<int, int, SetKeyOfT<int>> ht;

	ht.Insert(2);
	ht.Insert(4);
	ht.Insert(14);
	ht.Insert(24);
	ht.Insert(26);
	ht.Insert(16);

	ht.Erase(14);
	ht.Erase(2);

	CLOSEHASH::HashData<int>* data = ht.Find(4);
}

用线性探测测试代码: 

 用二次探测测试代码:

因为闭散列没有开散列好,所以这里闭散列简单实现下即可,对于更进一步的迭代器、和实现unordered_set和unordered_map等等操作我们都是用开散列实现,开散列才是重中之重 


 三、开散列的实现

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

这里的开散列就像个数组和链表的融合体,但开散列本质还是个数组


1、基本结构

template<class T>
struct HashNode
{
	T _data;
	HashNode<T>* _next;

	HashNode(const T& data)
		:_data(data)
		, _next(nullptr)
	{}
};
template<class K, class T, class KeyOfT, class Hash>
class HashTable
{
	typedef HashNode<T> Node;

private:
	vector<Node*> _tables; //使用vector一定要包含std库,不然使用不了
	size_t _num = 0;      //有效元素的个数,不是容量
};

细品:(_tables)表是一个指针数组,是这个数组中存了第一个节点的地址  

2、仿函数Hash 

 为什么模板参数多了个Hash?

它是个仿函数,他可以针对string、自定义类型等不能取模的类型,让他们能支持取模运算,因为对于开散列也涉及取模运算,它要求数据的映射位置

若用string类型就无法取模,如果key的类型是一个结构体呢,是不是也不能取模,也就是进来的key不一定能取模

 因为string不支持直接取模,故再写一个仿函数_Hash,然后内部使用_Hash即可,用仿函数来支持取模运算,默认情况下使用_Hash<K>,但遇到string我们需显式地传_HashString

那么_HashString的思路是什么?拿字符串的第一个字母的ASCII码取模可以吗?不好,因为若这些字符串的第一个字母相同,就容易产生冲突,故我们把所有字母ASCII码值加起来(因为哈希表是取模后映射位置,都用ASCII码来计算,就会有对应的映射位置)

 那怎么使用这个仿函数呢?

HashTable中写个HashFunc直接把数据转换成能取模的,因为这个操作需要频繁使用

size_t HashFunc(const K& key)
{
	Hash hash;
	return hash(key);//利用仿函数把key值转为整形
}

存在的问题:

 因为不同的字符串,相加后可能是相同的,那放在同一位置会引起哈希冲突,故要把字符串转成整形等,用字符串哈希算法来算,比如出色的BKDR Hash算法字符串每次乘以131即可,则字符串算出来的值就不那么容易冲突了(也可以乘以31,1313,13131,131313..),但是无论如何一定会有可能发生冲突,因为字符串在不限定长度的情况下是无限长的,我们要做的就是降低冲突概率

应用BKDR后,就会发现不冲突了,它大大的降低冲突性

那对于结构体想取模怎么办?比如一个人的信息是一个结构体,那我们就可以找人的信息中有代表项的东西,比如人的电话号码,是个字符串,就能唯一的代表人。用身份证也行。那如果这个人不能用电话号码和身份证等,我们用名字作为代表项码?不好,名字相同的人很多,很容易产生冲突,故我们可以用名字+另一个代表项,比如名字+家乡地址作为代表项,算出映射位置

注:

因为string类型会经常去做key,我们干脆让string作为默认的仿函数,一个模板类型要对一个参数作特殊处理就要用到模板特化,这下即使不传参数,也能处理好string类型的哈希表使用,就是在于它变成默认支持的了

template<class K>
struct _Hash
{
	const K& operator()(const K& key)
	{
		return key;//可以取模的直接返回即可
	}
};

//特化
template<>
struct _Hash<string>
{
	size_t operator()(const string& key)
	{
		//运用BKDR Hash算法
		size_t hash = 0;
		for (size_t i = 0; i < key.size(); ++i)
		{
			hash *= 131; //BKDR
			hash += key[i];//字符串中每个字母ASCII码值相加
		}

		return hash;
	}
};

template<class K, class T, class KeyOfT, class Hash = _Hash<K>>
class HashTable

注意:上面模板参数class Hash = _Hash<K>表示默认会调用_Hash<K>,那这个默认一个是string可以默认调用,另一个就是能正常取模的类型可以正常调用,也就是你调用HashTable时不传这个Hash参数就可以帮你自动调用这个默认的,你若不用模板特化的话string类型的是不会帮你默认调用的(但我们说了我们的哈希表是为了给unordered_set和unordered_map使用的,也就是上层使用的,你下层哈希表的实现就不用写为class Hash = _Hash<K>了,这一点在模拟实现unordered_set和unordered_map时,它们俩会写的,也就是模拟实现时这里HashTable的参数只会写为class Hash即可)

如果我不想在创建对象时还要传仿函数(说的是取模的仿函数),但是对于string还能正常调用到它的仿函数,那就可以用模板特化,一个模板类型要对一个参数作特殊处理就要用到模板特化,当K是string类型的时候,就会调用_Hash的模板特化,正常处理string取模问题

但是再来一个类型你也可以写成模板特化(但它变成默认的模板参数前提应该是这个类型在某个场景下会被频繁使用),但如果不是频繁使用的,就没必要整成默认的,直接单独写一个仿函数,显示传参数即可


3、迭代器实现

关于闭散列的哈希表的迭代器,就和list迭代器的思路相仿(建议先把list的迭代器的模拟实现看明白),不过在此基础上还要额外考虑

这里最需要说明的是operator++,其他的操作容易理解

 哈希桶的迭代器operator++思路:

迭代器不需要按照插入顺序迭代,底层实现也没有考虑,但是我们可以思考一下如果按插入的顺序迭代遍历要如何实现 :

为了我们能找到下一个桶,若我们使用vector,但我就一个迭代器,怎么用vector呢

我怎么知道我在哪个桶呢?可以再计算下此时的映射位置,即可以通过桶中的值,计算我是哪个桶。

如果这里直接传vector,那HashFunc就调用不到了,所以干脆给个哈希表,并且是哈希表指针,就能用HashFunc

总结:

利用哈希表,也就是在迭代器中再创建个哈希表对象的指针,利用哈希表找下一个桶,因为我哈希表里面有vector啊,vector里面存的是头指针,利用这个头指针不就能找到下一个桶了

注:哈希表迭代器不支持--操作,因为它是单向迭代器,也就是没有rbegin和rend等等

//前置声明:为了让哈希表的迭代器能用哈希表
template<class K, class T, class KeyOfT, class Hash>
class HashTable;		

template<class K, class T, class KeyOfT, class Hash>
struct __HashTableIterator
{
	typedef __HashTableIterator<K, T, KeyOfT, Hash> Self;
	typedef HashTable<K, T, KeyOfT, Hash> HT;
	typedef HashNode<T> Node;
	Node* _node;//迭代器中存的是节点指针
	HT* _pht;//对象的指针

	__HashTableIterator(Node* node, HT* pht)
		:_node(node)
		,_pht(pht)
	{}

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

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

	Self operator++()
	{
		if (_node->_next)
		{//如果还能在一个桶中,就直接在一个桶中往后走(单链表)
			_node = _node->_next;
		}
		else
		{
			// 如果一个桶走完了,要往下找到下一个桶继续遍历
			KeyOfT koft;
			//先计算我当前是在哪个桶
			size_t i = _pht->HashFunc(koft(_node->_data)) % _pht->_tables.capacity();
			++i;//下一个桶
			for (; i < _pht->_tables.capacity(); ++i)
			{	//找不为空的桶
				Node* cur = _pht->_tables[i];
				if (cur)
				{	//如果这个桶不为空
					_node = cur;
					return *this;//迭代器++返回的是迭代器本身
				}
			}

			_node = nullptr;//如果没有找到有数据的桶,则指针置为空,与end()相符
			return *this;
		}
	}

	bool operator!=(const Self& s)
	{
		return _node != s._node;
	}
};

哈希表中的begin()和end()实现:

	typedef __HashTableIterator<K, T, KeyOfT, Hash> iterator;
    //begin()返回第一个不为空的桶的第一个节点
	iterator begin()
	{
		for (size_t i = 0; i < _tables.capacity(); ++i)
		{
			if (_tables[i])
			{
				return iterator(_tables[i], this);//找到了则构造匿名对象返回
			}
		}

		return end();//每个桶中都没找到则返回end()
	}

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

4、增容和插入

增容就涉及重新映射元素位置的问题,那我们就取出原哈希表中每个桶中的元素,然后重新计算它在新表中的映射位置,那放到新表中有冲突的元素怎么办?头插即可,和插入的思路一样,或者说增容的思路就包含了插入的思路。我们会一个一个桶往后走,那每一个桶都是一个单链表,遍历这个单链表用while,不断计算桶中每个元素在新表中的映射位置,遍历桶我们相当于遍历_tables

插入的关键是这个冲突的值是尾插还是头插到对应位置呢?按理说,冲突的数据的顺序是无所谓的,故头插尾插都可以,也就是达到的效果一样,但是尾插还要先找到尾再插入,故这里用头插实现

假设表容量capacity为10

pair<iterator, bool> Insert(const T& data)
{
	KeyOfT koft;
	//1、判断是否增容
	if (_tables.capacity() == _num)
	{	//开散列的实现平衡因子为1就增容且第一次插入也会增容
		size_t newcapacity = _tables.capacity() == 0 ? 10 : _tables.capacity() * 2;
		vector<Node*> newtables;
		newtables.resize(newcapacity, nullptr);//给新的vector开新空间+初始化
		//重新计算旧表的数据在新表中的映射位置
		for (size_t i = 0; i < _tables.capacity(); ++i)
		{	//如果是第一次的增容不会进for循环的,故不用担忧表的初始数据是否为nullptr
			//哈希表中每一个桶都是一个单链表,故考察单链表的头插
			Node* cur = _tables[i];
			while (cur)
			{
				Node* next = cur->_next;
				size_t index = HashFunc(koft(cur->_data)) % newtables.capacity();//重新计算映射位置
				//头插
				cur->_next = newtables[index];
				newtables[index] = cur;
						 
				cur = next;
			}
			_tables[i] = nullptr;//映射到新表后置为空
		}

		_tables.swap(newtables);//新旧表的vector交换

	}

	size_t index = HashFunc(koft(data)) % _tables.capacity();//计算新的映射位置

	//1、先查找这个元素是否在哈希表中
	Node* cur = _tables[index];//知道映射位置就能确定是哪个桶
	while (cur)
	{
		if (koft(cur->_data) == koft(data))
			return make_pair(iterator(cur, this), false);//找到相同数据则插入失败
		else
			cur = cur->_next;
	}

	//2、头插到这个桶中
	Node* newnode = new Node(data);//开辟新节点
	//头插
	newnode->_next = _tables[index];
	_tables[index] = newnode;

	++_num;//哈希表中有效元素个数++
	return make_pair(iterator(newnode, this), false);
}

 1、Insert的返回值底层实现就是pair<iterator, bool>

插入数据:

若要插入的数据已存在于哈希表中,则返回已存在的数据的位置,并返回false,即代码中的make_pair(iterator(newnode, this), false),这里用的是iterator的构造函数,因为构造函数初始化列表有一个哈希表指针,而this就是我本身的哈希表指针( HashTable<K, T, KeyOfT, Hash>*类型的)

若要插入的数据不存在于哈希表中,则返回这个要插入数据的位置,并返回true,即代码中的make_pair(iterator(cur, this), true);


5、查找 

Node* Find(const K& key)
{
	KeyOfT koft;
	size_t index = HashFunc(key) % _tables.capacity();//先计算映射位置
	Node* cur = _tables[index];
	while (cur)
	{
		if (koft(cur->_data) == key)
			return cur;
		else
			cur = cur->_next;
	}

	return nullptr;//走遍桶都没找到则返回空
}

6、删除

 开散列的删除,就相当于单链表的删除,故要找前一个节点,而删除的前一步是找到你要删除的数据在不在

bool Erase(const K& key)
{
	assert(_tables.capacity() > 0);//有空间才能删

	KeyOfT koft;
	size_t index = HashFunc(key) % _tables.capacity();
	Node* cur = _tables[index];
	Node* prev = nullptr;//记录cur的前一位置

	while (cur)
	{
		if (koft(cur->_data) == key)
		{
			if (prev == nullptr)
			{	//如果是头删
				_tables[index] = cur->_next;
			}
			else
			{
				prev->_next = cur->_next;
			}
			delete cur;
			--_num;

			return true;
		}
		else
		{
			prev = cur;
			cur = cur->_next;
		}
	}

	return false;//要删除数据根本不存在
}

7、Clear和析构函数

虽然哈希表不用我们写构造函数,但是因为哈希表中每个节点都是动态开辟的,故我们要释放下每个节点,遍历每个桶,每个桶都是单链表,即考察单链表的节点释放

	~HashTable()
	{
		Clear();
		//vector不用我们释放,因为它是自定义类型,哈希表要清理时,vector也会自动清理
	}

	void Clear()
	{
		for (size_t i = 0; i < _tables.capacity(); ++i)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;
				cur = next;
			}

			_tables[i] = nullptr;//清空完数据后置为nullptr
		}
	}

遗留的问题: 

 当大量的数据冲突,这些哈希冲突的数据就会挂在同一个链式桶中 ,查找时效率就会降低,所以开散列-哈希桶也是要控制哈希冲突的。如何控制呢?通过控制负载因子,不过这里就把空间利用率提高一些,负载因子也可以高一些,一般开散列把负载因子控制到1,会比较好一些  


四、哈希表模拟实现unordered_set和unordered_map

 对于unordered_set和unordered_map第二个模板参数是个通过哈希来比较的(我们实现的是把这个参数放在第四个模板参数),万一我的unordered_set的模板参数K是个结构体类型,我不能直接取模就直接完蛋了,所以要传一个仿函数给哈希表

总结:这个默认的模板参数是利用上层模拟实现用的,你哈希表的底层实现就写为class Hash即可,我上层使用的时候会直接传给你

MyUnordered_set.h中实现unordered_set的

#pragma once
#include"HashTable.h"
using namespace OPENHASH;

namespace mz
{
	using OPENHASH::_Hash;
	template<class K, class Hash = _Hash<K>>
	class unordered_set
	{
		struct SetKOfT
		{
			const K& operator()(const K& k)
			{
				return k;
			}
		};

	public:
		//加typename是告诉编译器这是个类型,你先让我过,等实例化了再去找
		//因为模板没实例化它是不接受你用模板里面的一个类型,故要用typename 

		typedef typename HashTable<K, K, SetKOfT, Hash>::iterator iterator;
		iterator begin()
		{
			return _ht.begin();
		}

		iterator end()
		{
			return _ht.end();
		}
		pair<iterator, bool> insert(const K& k)
		{
			return _ht.Insert(k);
		}
	private:
		HashTable<K, K, SetKOfT, Hash> _ht;
	};

	void test_unordered_set()
	{
		unordered_set<int> s;
		s.insert(1);
		s.insert(5);
		s.insert(4);
		s.insert(2);

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

 

MyUnordered_map.h中实现unordered_map的:

#pragma once
#include"HashTable.h"
using namespace OPENHASH;

namespace mz
{
	using OPENHASH::_Hash;
	template<class K, class V, class Hash = _Hash<K>>//一般模板参数都是由上一层来控制的
	class unordered_map
	{
		struct MapKOfT 
		{
			const K& operator()(const pair<K, V>& kv)
			{
				return kv.first;
			}
		};

	public:

		typedef typename HashTable<K, pair<K,V>, MapKOfT, Hash>::iterator iterator;
		iterator begin()
		{
			return _ht.begin();
		}
		
		iterator end()
		{
			return _ht.end();
		}

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

		V& operator[](const K& key)
		{//unordered_map的operator[]是给key值返回v的引用
         //底层实现用的是哈希表的Insert来实现,在介绍使用时讲过这点
			pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
			return ret.first->second;
		}

	private:
		HashTable<K, pair<K, V>, MapKOfT, Hash> _ht;//底层是个哈希表
	};

	void test_unordered_map()
	{
		unordered_map<string, string> dict;
		dict.insert(make_pair("factual", "真实的"));
		dict.insert(make_pair("fringe", "侵犯"));
		dict.insert(make_pair("intermittent", "间歇的"));
		dict["prerequisite"] = "先决条件";
		dict["reduce to"] = "处于";

		//unordered_map<string, string>::iterator it = dict.begin();
		auto it = dict.begin();
		while (it != dict.end())
		{
			cout << it->first << ":" << it->second << endl;
			++it;
		}
		cout << endl;

	}
}


 

遗留的问题: 

很明显我们实现的unordered_map和unordered_set排出来的数据并不是有序的,但std库里面会自动排序的

思路:再建立一个链表存储数据,尾插,迭代器遍历时,遍历链表,就可以保持遍历有序,那么主要问题是 遍历时保持插入的顺序,这样结构会复杂,但迭代器会变简单,定义两个指针,一个next指针用来挂桶,另一个prev指针用来遍历

这个知识了解思路即可

进一步改进:

 如果表的大小是一个素数,冲突率会有一定的降低,素数是只能被1和本身所整除的,那怎么保证是个素数呢?我们可以提供一个素数表,数字后面加ul,表示无符号的长整形,即unsigned long,这个素数表从头到尾基本上每个数据都是前一个的2倍,而最大的数接近整数最大值,【注:这个表是STL中的,其经过研究具有一定的优越性】

怎么用这个表?

在开辟表大小时调用这个素数表,从头遍历这个素数表,找到比我当前要开辟的大小大的即可

完整源码:

http://t.csdnimg.cn/XTY7Y

猜你喜欢

转载自blog.csdn.net/m0_74044018/article/details/133690549