C++入门17——哈希之哈希表详解

学完了顺序表和平衡树,我们知道:元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log₂N),搜索的效率取决于搜索过程中元素的比较次数。

那有没有不经过任何比较,一次直接从表中得到目标元素的方法呢?答案当然是肯定的。

1.哈希的概念

如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

当向该结构中:

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

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

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

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。

例如:

存储数据集合{8,4,2,9,7,5,3};

可以将哈希函数设置为:hash(key)=key%capacity (capacity=10,capacity具体如何取值,下面会有说明)

如图:

 2.哈希冲突

不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞,把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

比如,上图的数据集合中没有出现%10后相同的情况,可在现实情况中一定会出现取模后相等的情况,比如在上图例子中新增加一个元素12,12%10==2%10,都等于2,此时2已经插入到了“2”的位置,那么12又该放到哪里呢?

发生了哈希冲突又该如何解决呢?

3.哈希冲突的解决

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

3.1闭散式

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

如何寻找“下一个空位置”也是我们要讨论的话题。

 1.线性探测

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

①插入

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

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

还接上文中的例子:数据集合中多了一个数据12,12原本要插入到位置“2”中去,可位置“2”中已经插入了2,按照线性探测,12只能寻找“2”之后的下一个空位置了,也就是位置“6”.

如图:

②删除

与插入类似,删除也是通过哈希函数获取待删除元素在哈希表中的位置,然后再进行删除;

不过需要注意,采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。

还以上图为例,如若删除元素2,如果直接将2删除,此时位置“2”已经没有元素了,那么下次搜索元素12时,会先搜索到位置“2”,可规则是当发生哈希冲突时才会继续寻找“下一个空位置”,此时的位置“2”并没有发生哈希冲突,所以直接删除势必会影响元素12的搜索。

针对这种现象,线性探测采用标记的伪删除法来删除一个元素。

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

线性探测具体实现如下:

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

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>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return (size_t)key;
		}
	};

	/*struct HashFuncString
	{
		size_t operator()(const string& s)
		{
			 "abcd"
			 "bcad"
			 "aadd"
			size_t hash = 0;
			for (auto e : s)
			{
				hash += e;
				hash *= 131;
			}
	
			return hash;
		}
	};*/

	// 由于哈希表并不全部存储整型数据,字符串也是经常遇到的数据类型
	// 而字符串类型又不能直接进行取模运算
	// 所以设计函数将字符串类型转为整型再进行取模运算
	// 此处选择将字符串每个字母的ASCII值相加的方法
	// 而字符串类型相比整型也并不常用,所以将字符串类型进行函数特化
	template<>
	struct HashFunc<string>
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto e : s)
			{
				hash += e;
				hash *= 131;
				// 乘一个数再相加,是为了避免出现"abcd""bcad""aadd"这类的数据(他们的ASCII值相加都是394)
				// 减少出现“哈希冲突”的现象
				// 之所以选择乘131,是因为131是一个能够使他们差异化更加明显的数字
			}

			return hash;
		}
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		HashTable(size_t size = 10)
		{
			_tables.resize(size);
		}

		// 搜索
		HashData<K, V>* Find(const K& key)
		{
			Hash hs;
			// 线性探测
			size_t hashi = hs(key) % _tables.size();
			// 前面说过,采用闭散列处理哈希冲突时,若直接删除会影响其他元素的搜索
			// 所以只有当hashi的_state为空时才停止寻找“下一个空位置”
			while (_tables[hashi]._state != EMPTY)
			{
				if (key == _tables[hashi]._kv.first && _tables[hashi]._state == EXIST)
				{
					return &_tables[hashi];
				}

				++hashi;
				hashi %= _tables.size();
			}

			return nullptr;
		}

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

		//插入之前先检查扩容
		
			// 扩容(为什么选择0.7,下文会有注解)
			//if ((double)_n / (double)_tables.size() >= 0.7)
			if (_n * 10 / _tables.size() >= 7)
			{
				HashTable<K, V, Hash> newHT(_tables.size() * 2);
				// 遍历旧表,插入到新表
				for (auto& e : _tables)
				{
					if (e._state == EXIST)
					{
						newHT.Insert(e._kv);
					}
				}
				_tables.swap(newHT._tables);
			}

		//容量没有问题再插入

			Hash hs;
			// 线性探测
			size_t hashi = hs(kv.first) % _tables.size();
			//hashi的_state的状态为“有元素”时,此位置不可插入,继续往后寻找
			while (_tables[hashi]._state == EXIST)
			{
				++hashi;
				hashi %= _tables.size();
			}
			//hashi的_state的状态变为“空”或“已删除”时,循环结束,插入此位置
			_tables[hashi]._kv = kv;
			_tables[hashi]._state = EXIST;//将状态变为“有元素”
			++_n;

			return true;
		}

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

	private:
		vector<HashData<K, V>> _tables;
		size_t _n = 0;  // 实际存储的数据个数
	};
}

在以上代码中,关于扩容时为何要选择0.7,资料供参考:

线性探测的缺点:

一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。 

2.二次探测

线性探测是逐个寻找“下一个空位置”,这势必会导致关键字的分布相对集中,采用二次探测可以实现“跨越式寻找”,使关键字分布相对分散,提高搜索效率。

在哈希函数的基础上,增加二次探测函数:
hash(key) = ( hash( key ) + ( i ²) ) % capacity (i=1,2,3,4,5...)

原理如图:

研究表明:

当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

因此:开散式最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。 

3.2 开散式(哈希桶)

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

 如图:

具体实现如下: 

namespace hash_bucket
{
	//这里只需使用单向链表即可,所以自己封装,不用复用库中的list
	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 Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;

	public:
		HashTable()
		{
			_tables.resize(10, nullptr);
			_n = 0;
		}

		~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 hs;

			//控制负载因子到1就扩容
			if (_n == _tables.size())
			{
				vector<Node*> newTables(_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 = hs(cur->_kv.first) % newTables.size();
						cur->_next = newTables[hashi];	
						newTables[hashi] = cur;

						cur = next;
					}

					_tables[i] = nullptr;
				}

				_tables.swap(newTables);
			}

			size_t hashi = hs(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 hs;
			size_t hashi = hs(key) % _tables.size();
			Node* cur = _tables[hashi];

			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}

				cur = cur->_next;
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			Hash hs;
			size_t hashi = hs(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					//删除
					if (prev)
					{
						prev->_next = cur->_next;
					}
					else
					{
						_tables[hashi] = cur->_next;
					}

					delete cur;

					return true;
				}

				prev = cur;
				cur = cur->_next;
			}

			return false;
		}

	private:
		//vector<list<pair<K, V>>> _tables;
		vector<Node*> _tables;//指针数组
		size_t _n;
	};
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值