Hash表模拟实现——开放定址法

一、前言

1.1、unordered_map和unorder_set

首先先说一下unordered_map和unordered_set究竟是什么,它们和map,set的区别是啥,其实他俩和map,set的作用是一样的都是可以进行快速查找的两个容器,它们是在C++11的时候引入的,只不过他俩的效率比map,set会更高,map,set的查找效率可以达到O(logn)而它们两个效率可以达到O(1),这其实就取决于它们底层实现的不同,map,set的底层实现使用的是红黑树这样的一棵搜索二叉树,而unordered_map和unordered_set底层实现使用的是哈希表这种数据结构。它们还有一个区别就是有序和无序。

我们可以看到像stl库中的unordered_map和unordered_set都是通过哈希表实现的。由于早期C++认为只有map和set就够用了,没有考虑引入哈希表,但是后续发现哈希还是用的非常多的,于是就是C++11标准引入了unordered_map和unordered_set,并没有和java中的TreeMap和TreeSet以及HashMap和HashSet一样取名。主要是按照一个有序一个无序进行取名的,后续建议大家尽量使用unordered_map和unordered_set。

1.2、hash的概念

哈希就是hash的音译结果,它又被称为散列,意思就是散乱排列的意思,其实哈希是音译,散列是形译。实际上哈希表并不是真的是混乱的而是通过key关键字和存储位置做一个映射,这样可以在查找的时候通过计算出key的存储位置从而实现快速的查找。

1.3、直接定址法

key的值比较集中的时候我们通常采用直接定址法实现哈希表。举一个最简单的哈希实现的例子,就是直接定制法实现哈希表。比如我们有一组key值它们的范围在[0,99]这个闭区间的范围内,那么它们映射在哈希表中的位置就可以直接按照数组的下标进行映射,下标为0的位置就是第一个元素存放的位置,依次类推通过这种映射关系我们便可以实现O(1)的查找效率。还有就是统计26个小写字母中第一个出现的唯一的字符,我们只需要按照ascii来对这些字母在数组中的位置进行映射便可以很轻松的找到每一个字母出现的位置和次数,这依然可以很轻松的找到第一个唯一出现的字符。

class Solution {
public:
	int firstUniqChar(string s) {
    int count[26] = { 0 };
		for (auto ch : s)
		{
			count[ch - 'a']++;
		}
		for (size_t i = 0; i < s.size(); ++i)
		{
			if (count[s[i] - 'a'] == 1)
				return i;
		}
		return -1;
	}
};

二、实现哈希函数

2.1、直接定址法的弊端

我们前面看到了使用直接定址法来实现哈希表,对于使用这种方法来实现哈希表的要求是十分苛刻的,原因就是当key的范围特别集中的时候才能使用这种方法,如果key的范围非常大,key与key之间非常的分散那么直接定址法就不能满足我们实现哈希表的要求了。举个例子此时我们有一组key的值的范围在[0,99999]之间的N个值,我们如果采用直接定址法就必须开辟范围个空间,而这种做法是非常浪费空间的,如果N=100的话,我们可以想象一下我们浪费了多少的空间。所以此时我们的做法是开辟一个大小为M的空间,要保证M>=N,这样需要我们设计一个哈希函数来对N个数据进行映射。我们设计的哈希函数就是通过通过哈希函数的计算公式将我们的key映射到对应的M个空间的对应位置。

2.2、哈希冲突

上面这种设计哈希函数进行映射的方法,可能会出现这种情况就是多个key可能会映射到同一块空间,我们成这种行为为哈希冲突。哈希冲突是无法避免的,我们要做的是设计的哈希函数如何减少哈希冲突和如何解决哈希冲突。因为哈希冲突越多,那么查找的效率就会越低。

证明哈希冲突不可避免(了解):我们使用鸽巢原理来证明,我们已知有n个鸽子,m个巢穴,n > m,那么我们可以得出必然有一个巢穴有两只鸽子。对于我们的哈希表因为输入是无限的而输出是有限的,那么必然会出现哈希冲突。

2.3、负载因子

负载因子的计算方法为:如果哈希表中存储了N个值,哈希表的空间为M那么负载因子就等于N/M,负载因子一定小于1大于0。负载因子在有些地方也被称为载荷因子/装载因子,它的英文名字为load factor负载因子越大,哈希冲突的概率就会越高,空间的利用率就会越高,负载因子越小,哈希冲突的概率就越低,空间利用率就会越低。

为什么会有上面的结论应该是很容易理解的,就是如果负载因子越大,也就是N越接近M,这样鸽巢原理的出现的概率就会越高。所以说实现哈希函数的时候通常会使用较小的负载因子。

2.4、哈希函数的概念

一个好的哈希函数应该尽可能的让N的关键字等概率的分配到M个空间,尽量的减少哈希冲突。

2.5、如何实现哈希函数

2.5.1、将关键字转化为整数

实现哈希函数一定要满足这一点,就是key一定要是可以转化为int类型的。在unordered类型和ordered类型的区别是没有说的一个区别就是对key的区别,set,map都要求key是可以进行比较的,这也是实现keyoft的原因,但是在unordered类型中要求key不仅仅可以进行比较还要能过转化为整形。具体的原因下面会进行分析的。

2.5.2、除法散列发/除留余数法(最常用的哈希函数实现方法)

下面我们来介绍第一种实现哈希函数的方式:除法散列法。除法散列法又称为除留余数法,顾名思义就是通过key%M所得的余数作为映射的位置的下标。也就是说哈希函数为hash(key) = key % M;为什么这样计算映射的位置呢?实际上很简单,就是key % M得到的结果一定是[0,M- 1]这个区间里的值的,也就是说,所有的key都可以映射到[0,M - 1]这个区间里。

当使用除法散列法时我们要尽力避免M为某些值,如2的幂,10的幂。举个例子:如果M为那么key % 2^x就相当于保留key的后x位那也就是说本来应该比较两个数的所有位,但是由于M是2^x所以导致最终只需要后x位相同的key就可以映射到同意个位置,这样增加了哈希冲突的概率,举个例子63,31两个数看似毫无关联,如果M取16,也就是保留后8位,63的二进制为00111111,31的二进制位00011111所以二者会发生哈希冲突。如果M为10^x也是一样的道理,就比如,112和12312最后都是保留12所以M不要取2的幂和10的幂。

我们的建议是M尽量取一个和2的幂不太接近的一个质数。因为质数的因数只有1和它本身所以会减少哈希冲突的概率。

需要说明的是,实践中也是八仙过海,各显神通,Java的HashMap采用除法散列法时就是2的整数 次幂做哈希表的大小M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些。但是他不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用key’= key>>16,然后把key和key'异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围 内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀一些即可。所以我们上面建议M取不太接近2的整数次幂的一个质数的理论是大多数数据结构书籍中写的理论吗,但是实践中,灵活运用,抓住本质,而不能死读书。(了解)

2.5.3、乘法散列法(了解)

乘法散列法对哈希表大小M没有要求,他的大思路第一步:用关键字K乘上常数 A(0<A<1) ,并抽取出k*A的小数部分。 第二步:后再用M乘以k*A的小数部分,再向下取整。这里最重要的是A的取值,一个发明这个发明这个算法的大佬knuth认为A = 0.6180339887.... (黄金分割点) 比较好。

乘法散列法对应的哈希函数为hash(key) = floor(M ×((A ×key)%1.0)) 其中floor为向下取整。

2.5.4、全域散列法(了解)

这种方法,主要是出于安全的角度进行考虑的,一般不会使用。

如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出⼀个发生严重冲突的数据集, 比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确 定可以导致最坏情况的数据。这种方法叫做全域散列。

h (key) = ((a ×key +b)%P)%M ,P需要选一个足够大的质数,a可以随机选[1,P-1]之间的 h (8) = 任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了一个P*(P-1)组全域散列函数组。 假设P=17,M=6,a=3,b=4,则 34 ((3 ×8+4)%17)%6 = 5 。

需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的key了。

2.6、解决哈希冲突

2.6.1、开放定址法

2.6.1.1、线性探测

从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。

h(key) = hash0 = key % M hc(key,i) = hashi = (hash0+i) % M ,i = {1,2,3,...,M −1} , hash0位置冲突了,则线性探测公式为: hc(key,i) = hashi = (hash0+i) % M ,因为负载因子小于1, 则最多探测M-1次,一定能找到一个存储key的位置。注意公式中要进行%M因为要回到起点。

举个例子:

这种探测方法我们可以发现就是可能会造成连续的冲突,你占了我的位置我就去占别人的位置,这种情况就会造成连续的哈希冲突,我们需要明白的一点就是冲突越多哈希表的效率就会越低,所以我们要尽量减少这种冲突的概率,所以下面给出了两种方案。

2.6.1.2、二次探测(了解)

二次探测就是一种解决办法。

从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置;

h(key) = hash0 = key % M , hash0位置冲突了,则二次探测公式为:hc(key,i) = hashi = (hash0±

) % M , i = M {1,2,3,..., M/2};

二次探测当hashi =(hash0−i )%M,当hashi<0时,需要hashi+=M。

2.6.1.3、双重散列(了解)

第一个哈希函数计算出的值发生冲突,使⽤第二个哈希函数计算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。

h1(key) = hash0 = key % M , hash0位置冲突了,则双重探测公式为:hc(key,i) = hashi = (hash0+ i∗h2(key)) % M , i = {1,2,3...M}。

要求h2(key) < h(key)和M互为质数,有两种简单的取值放法:1、当M为2整数幂时, 从[0,M-1]任选一个奇数;2、当M为质数时, h2(key) = key % (M −1) + 1;

保证 h2(key)与M互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数 p =gcd(M,h1(key)) > 1,那么所能寻址的位置的个数为 M/P < M,使得对于一个关键字来 说无法充分利用整个散列表。举例来说,若初始探查位置为1,偏移量为3,整个散列表大小为12,那么所能寻址的位置为{1,4,7,10},寻址个数为12/gcd(12,3) = 4。

2.6.1.4、实现代码

我们实现哈希表的插入和删除操作主要依赖与三个状态进行操作,所以我们使用枚举类型定义了三个状态即存在,空,删除。

为了解决二倍扩容之后不是素数的问题,我们引入了一个素数表,他会返回大于等于我们扩容大小的迭代器。

为了解决string这些无法取模的类型,我们必须要有一个模板参数来传仿函数的类型,来解决string等不能进行取模的类型转化为整形,这也是前面所说的为什么需要key的类型能过转化为整形。这一步因为string类型的应用非常广泛我们还对这个哈希函数的类模板进行了特化。

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

namespace Code_Journey
{
	// 解决扩容之后不是素数的问题
	inline unsigned long __stl_next_prime(unsigned long n)
	{
		// Note: assumes long is at least 32 bits.
		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
		};

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

	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)const
		{
			return (size_t)key;
		}
	};
	// 特化HashFunc类模板
	template<>
	struct HashFunc<string>
	{
		size_t operator()(const string& str)const
		{
			size_t hash = 0;
			for (auto e : str)
			{
				hash += e;
				hash *= 131;
			}
			return hash;
		}
	};
	enum State
	{
		EXIST,
		EMPTY,
		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 n = 11)
			:_table(n)
			,_n(0)
		{}
		bool insert(const pair<K, V>& kv)
		{
			if (find(kv.first))
				return false;
			// 扩容
			//if ((double)_n / (double)_table.size() > 0.7)
			//{
			//	HashTable<K, V> newht(__stl_next_prime(2 * _table.size());
			//	// 拷贝旧表中的数据
			//	for (size_t i = 0; i < _table.size(); i++)
			//	{
			//		// 在走一遍下面的插入操作,代码太冗余,直接复用insert
			//	}
			//  //直接进行交换,出了作用域newht会自动调用vector的析构函数析构_table
			//  _table.swap(newht._table);
			//}
			if ((double)_n / (double)_table.size() > 0.7)
			{
				HashTable<K, V> newht(__stl_next_prime(2 * _table.size()));
				// 拷贝旧表中的数据
				for (size_t i = 0; i < _table.size(); i++)
				{
					if (_table[i]._state == EXIST)
						// 这里不是递归调用,因为是两个不同的对象进行调用的
						newht.insert(_table[i]._kv);
				}
				// 直接进行交换,出了作用域newht会自动调用vector的析构函数析构_table
				_table.swap(newht._table);
			}
			Hash hs;
			size_t hash0 = hs(kv.first) % _table.size();
			size_t i = 1;
			size_t hashi = hash0;
			// 直接进行线性探测,这样减少了代码的冗余
			while (_table[hashi]._state == EXIST)
			{
				hashi += i;
				i++;
				hashi %= _table.size();
			}
			_table[hashi]._kv = kv;
			_table[hashi]._state = EXIST;
			_n++;
			return true;
		}
		HashData<K, V>* find(const K& key)
		{
			Hash hs;
			size_t hash0 = hs(key) % _table.size();
			size_t i = 1;
			size_t hashi = hash0;
			// 直接进行线性探测
			while (_table[hashi]._state == EXIST)
			{
				if (_table[hashi]._kv.first == key)
				{
					return &_table[hashi];
				}
				hashi += i;
				i++;
				hashi %= _table.size();
			}
			return nullptr;
		}

		bool erase(const K& key)
		{
			HashData<K, V>* ptr = find(key);
			if (ptr)
			{
				ptr->_state = DELETE;
				return true;
			}
			return false;
		}
	private:
		vector<HashData<K, V>> _table;
		size_t _n;
	};
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值