c++进阶--哈希表的实现

大家好,今天我们来学习ubordered_set和unordered_map的底层哈希表。

目录

哈希表实现

1. 哈希概念

  1.1 直接定址法

1.2 哈希冲突

1.3 负载因⼦

1.4 将关键字转为整数 

1.5 哈希函数 

下面我们介绍几种哈希函数:1.5.1 除法散列法/除留余数法 

1.5.2 乘法散列法 

1.5.3 全域散列法 

1.6 处理哈希冲突 

1.7 开放地址法

1.7.1 线性探测

1.7.2 二次探测

1.7.3 双重探测(了解)

1.7.4 开放定址法代码实现 

1.8 链地址法

解决冲突的思路

扩容

极端场景

1.8.1 链地址法代码实现 


哈希表实现

1. 哈希概念
哈希(hash)⼜称散列,是⼀种组织数据的⽅式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字Key跟存储位置建⽴⼀个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进⾏快速查找。
  1.1 直接定址法

求映射的方法有很多,最直接的就是直接定址法:

当关键字的范围⽐较集中时,直接定址法就是⾮常简单⾼效的⽅法,⽐如⼀组关键字都在[0,99]之间,那么我们开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。再⽐如⼀组关键字值都在[a,z]的⼩写字⺟,那么我们开⼀个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。

 也就是说直接定址法本质就是⽤关键字计算出⼀个绝对位置或者相对位置。这个⽅法我们在计数排序部分已经⽤过了,其次在string章节的下⾯OJ也⽤过了。

387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

 s是由小写字母构成的字符串,所以我们使用一个长度为26的数组,遍历字符串,e - ' a ' 就是字符串中每个字符在数组中的映射位置,字符每出现一次,在该映射位置就增加一次,最后再进行查找。

1.2 哈希冲突
直接定址法的缺点也⾮常明显,当关键字的范围⽐较分散时,就很浪费内存甚⾄内存不够⽤。假设我们只有数据范围是[0, 9999]的N个值,我们要映射到⼀个M个空间的数组中(⼀般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的h(key)位置,这⾥要注意的是h(key)计算出的值必须在[0, M)之间。

 所以在数据范围大时,就要换一种映射方式,

这⾥存在的⼀个问题就是,两个不同的key可能会映射到同⼀个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。理想情况是找出⼀个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的⽅案。

1.3 负载因⼦
假设哈希表中已经映射存储了N个值,哈希表的⼤⼩为M,那么 ,负载因⼦有些地⽅也翻译为载荷因⼦/装载因⼦等,他的英⽂为load factor。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低;
1.4 将关键字转为整数 

我们将关键字映射到数组中位置,⼀般是整数好做映射计算,如果不是整数,我们要想办法转换成整数,这个细节我们后⾯代码实现中再进⾏细节展⽰。下⾯哈希函数部分我们讨论时,如果关键字不是整数,那么我们讨论的Key是关键字转换成的整数。
1.5 哈希函数 

哈希函数就是求映射的方法,

⼀个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做到,但是我们要尽量往这个⽅向去考量设计。
下面我们介绍几种哈希函数:
1.5.1 除法散列法/除留余数法 
1. 除法散列法也叫做除留余数法,顾名思义,假设哈希表的⼤⼩为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。
2. 当使⽤除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是 ,那么key %
本质相当于保留key的后X位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。如:{63 , 31}看起来没有关联的值,如果M是16,也就是 ,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。如果是 ,就更明显了,保留的都是10进值的后x位,如:{112, 12312},如果M是100,也就是 ,那么计算出的哈希值都是12。 所以 当使⽤除法散列法时,建议M取不太接近2的整数次幂的⼀个质数(素数)。
3. 需要说明的是,Java的HashMap采⽤除法散列法时就是2的整数次幂做哈希表的⼤⼩M,这样的话就不⽤取模,⽽可以直接位运算,相对⽽⾔位运算⽐模更⾼效⼀些。但是他不是单纯的去取模,⽐如M是2^16次⽅,本质是取后16位,那么⽤key’ =key>>16,然后把key和key' 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可。所以我们上⾯建议M取不太接近2的整数次幂的⼀个质数的理论是⼤多数数据结构书籍中写的理论,但是实践中,灵活运⽤,抓住本质。
1.5.2 乘法散列法 

对于除法散列法,对于M的选择是有要求的,下面介绍的乘法散列法则对M没有要求:

1. 乘法散列法对哈希表⼤⼩M没有要求,他的⼤思路第⼀步:⽤关键字 K 乘上常数 A (0<A<1),并抽取出 k*A 的⼩数部分。第⼆步:后再⽤M乘以k*A 的⼩数部分,再向下取整。
2. h ( key ) = floor ( M × (( A × key )%1.0)),其中floor表⽰对表达式进⾏下取整,A∈(0,1),这⾥
最重要的是A的值应该如何设定,Knuth认为A = ( 5^1/2 − 1)/2 = 0.6180339887.... (⻩⾦分割点])⽐较好。
3. 乘法散列法对哈希表⼤⼩M是没有要求的,假设M为1024,key为1234,A = 0.6180339887, A*key= 762.6539420558,取⼩数部分为0.6539420558, M×((A×key)%1.0) = 0.6539420558*1024 =669.6366651392,那么h(1234) = 669。
1.5.3 全域散列法 

对于上面两种方法,由于求映射的函数公式是相同的,所以在面对特定的数据时,会产生严重的数据冲突,下面这种全域散列法可以解决这种问题:

1. 如果存在⼀个恶意的对⼿,他针对我们提供的散列函数,特意构造出⼀个发⽣严重冲突的数据集,⽐如,让所有关键字全部落⼊同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决⽅法⾃然是⻅招拆招,给散列函数增加随机性,攻击者就⽆法找出确定可以导致最坏情况的数据。这种⽅法叫做全域散列。
2. h ab ( key ) = (( a × key + b )% P )%M,P需要选⼀个⾜够⼤的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组。假设P=17,M=6,a = 3, b = 4, 则 h 34 (8) = ((3 × 8 + 4)%17)%6  = 5。
3. 需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使⽤,后续增删查改都固定使⽤这个散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了。
1.6 处理哈希冲突 

介绍了几种方法,可见无论哪种方法都绕不开哈希冲突这个难题,实践中哈希表⼀般还是选择除法散列法作为哈希函数,那么插⼊数据时,如何解决冲突呢?主要有两种两种⽅法,开放定址法和链地址法。

下面我们来一一学习这两种方法:

1.7 开放地址法
在开放定址法中所有的元素都放到哈希表⾥,当⼀个关键字key⽤哈希函数计算出的位置冲突了,则按照某种规则找到⼀个没有存储数据的位置进⾏存储,开放定址法中负载因⼦⼀定是⼩于1的。这⾥的规则有三种:线性探测、⼆次探测、双重探测。
1.7.1 线性探测
1. 从发⽣冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果⾛到哈希表尾,则回绕到哈希表头的位置。
2. h ( key ) = hash 0 = key % M, hash0位置冲突了,则线性探测公式为:hc ( key , i ) = hashi = ( hash 0 + i ) % M i = {1, 2, 3, ..., M − 1},因为负载因⼦⼩于1, 则最多探测M-1次,⼀定能找到⼀个存储key的位置。
3. 线性探测的⽐较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下⾯的⼆次探测可以⼀定程度改善这个问题。
下⾯演⽰ {19,30,5,36,13,20,21,12} 等这⼀组值映射到M=11的表中。
h(19) = 8 h(30) = 8 h(5) = 5 h(36) = 3 h(13) = 2 h(20) = 9 h(21) = 10, h(12) = 1

 首先根据hash0进行计算,计算结果如上,可见19和30的映射值是相同的,对于线性探测来说,对于映射值相同的数据,则从该映射值开始向后查找,在未插入值的地方进行插入。此时19被插入到了映射值为8的地方,30被插入到映射值为9的地方,那么当20进行插入的时候,由于映射值为9的位置已经被插入了,所以认为20也冲突,也需要向后查找一个空的位置进行插入,以次类推。

但是在相同映射值的数据很多的情况下,会导致数据的堆积。 

1.7.2 二次探测
1. 从发⽣冲突的位置开始,依次左右按⼆次⽅跳跃式探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果往右⾛到哈希表尾,则回绕到哈希表头的位置;如果往左⾛到哈希表头,则回绕到哈希表尾的位置;
2. h ( key ) = hash 0 = key % M , hash0位置冲突了,则⼆次探测公式为: hc ( key , i ) = hashi = ( hash 0 ± i ^  2 ) % M i = {1, 2, 3, ...,M/2 }
3. ⼆次探测当 hashi = ( hash 0 − i ^ 2 )% M 时,当hashi<0时,需要hashi += M
下⾯演⽰ {19,30,52,63,11,22} 等这⼀组值映射到M=11的表中。

h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0 

 二次探测和线性探测的不同点就是,线性探测是从一个方向开始查找位置,到末尾了再从开头查找,而二次探测是从左右两个方向交替进行查找,每次跳过i^2个位置,这样,在相同映射值的数据很多的时候,不会形成像线性探测那样大规模的数据堆积。

1.7.3 双重探测(了解)
1. 第⼀个哈希函数计算出的值发⽣冲突,使⽤第⼆个哈希函数计算出⼀个跟key相关的偏移量值,不断往后探测,直到寻找到下⼀个没有存储数据的位置为⽌。
2. h 1 ( key ) = hash 0 = key % M , hash0位置冲突了,则双重探测公式为:
hc ( key , i ) = hashi = ( hash 0 + i h 2 ( key )) % M i = {1, 2, 3, ..., M }
3. 要求h 2 ( key ) < Mh 2 ( key )和M互为质数,有两种简单的取值⽅法:1、当M为2整数幂时,h2 (key)从[0,M-1]任选⼀个奇数;2、当M为质数时, h 2 ( key ) = key % ( M − 1) + 1
4. 保证h 2 ( key)与M互质是因为根据固定的偏移量所寻址的所有位置将形成⼀个群,若最⼤公约数p = gcd ( M , h 1 ( key)) > 1,那么所能寻址的位置的个数为M / P < M,使得对于⼀个关键字来说⽆法充分利⽤整个散列表。举例来说,若初始探查位置为1,偏移量为3,整个散列表⼤⼩为12,那么所能寻址的位置为{1, 4, 7, 10},寻址个数为12/gcd (12, 3) = 4
1.7.4 开放定址法代码实现 
enum State
{
	EXIST,
	EMPTY,
	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;
	}
};

template<>
struct HashFunc<string>
{
	size_t operator()(const string& s)
	{
		// BKDR
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 131;
		}

		return hash;
	}
};

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, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	HashTable()
		:_tables(__stl_next_prime(0))
		, _n(0)
	{}

	bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first))
			return false;
		// 负载因子 >= 0.7扩容
		if (_n * 10 / _tables.size() >= 7)
		{
			HashTable<K, V, Hash> newht;
			//newht._tables.resize(_tables.size() * 2);
			newht._tables.resize(__stl_next_prime(_tables.size() + 1));
			for (auto& data : _tables)
			{
				// 旧表的数据映射到新表
				if (data._state == EXIST)
				{
					newht.Insert(data._kv);
				}
			}
			_tables.swap(newht._tables);
		}
		Hash hash;
		size_t hash0 = hash(kv.first) % _tables.size();
		size_t hashi = hash0;
		size_t i = 1;
		int flag = 1;
		while (_tables[hashi]._state == EXIST)
		{
			// 线性探测
			hashi = (hash0 + i) % _tables.size();
			++i;
		}
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		++_n;
		return true;
	}

	HashData<K, V>* Find(const K& key)
	{
		Hash hash;
		size_t hash0 = hash(key) % _tables.size();
		size_t hashi = hash0;
		size_t i = 1;
		while (_tables[hashi]._state != EMPTY)
		{
			if (_tables[hashi]._state == EXIST
				&& _tables[hashi]._kv.first == key)
			{
				return &_tables[hashi];
			}
			// 线性探测
			hashi = (hash0 + i) % _tables.size();
			++i;
		}

		return nullptr;
	}

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

private:
	vector<HashData<K, V>> _tables;
	size_t _n;  // 记录数据个数
};
1.8 链地址法

对于开放定址法解决冲突不管使⽤哪种⽅法,占⽤的都是哈希表中的空间,始终存在互相影响的问题。但链地址法不会占用哈希表表内的空间,下面我们来看一下:
解决冲突的思路
开放定址法中所有的元素都放到哈希表⾥,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储⼀个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成⼀个链表,挂在哈希表这个位置下⾯,链地址法也叫做拉链法或者哈希桶。
下⾯演⽰ {19,30,5,36,13,20,21,12,24,96} 等这⼀组值映射到M=11的表中。

 

h(19) = 8 h(30) = 8 h(5) = 5 h(36) = 3 h(13) = 2 h(20) = 9 h(21) =10, h(12) = 1,h(24) = 2,h(96) = 88

 

扩容
开放定址法负载因⼦必须⼩于1,链地址法的负载因⼦就没有限制了,可以⼤于1。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低;stl中unordered_xxx的最⼤负载因⼦基本控制在1,⼤于1就扩容,我们下⾯实现也使⽤这个⽅式。
极端场景
如果极端场景下,某个桶特别⻓怎么办?其实我们可以考虑使⽤全域散列法,这样就不容易被针对了。但是假设不是被针对了,⽤了全域散列法,但是偶然情况下,某个桶很⻓,查找效率很低怎么办?这⾥在Java8的HashMap中当桶的⻓度超过⼀定阀值(8)时就把链表转换成红⿊树。⼀般情况下,不断扩容,单个桶很⻓的场景还是⽐较少的,下⾯我们实现就不搞这么复杂了,这个解决极端场景的思路,⼤家了解⼀下。
1.8.1 链地址法代码实现 
template<class K>
struct HashFunc {
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

template<>
struct HashFunc<string> {
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto e : s)
		{
			hash += e;
			hash *= 131;
		}
		return hash;
	}
};

template<class K,class V>
struct HashNode {
	pair<K,V> _kv;
	HashNode<K, V>* _next;
	HashNode(const pair<K,V>& kv)
		:_kv(kv)
		,_next(nullptr)
	{}
};
template<class K,class V,class Hash=HashFunc<K>>
class HashTable {
public:
	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;
	}
	typedef HashNode<K, V> Node;
	HashTable()
		:_tables(__stl_next_prime(0))
		,_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;
		}
	}

	HashTable(const HashTable& table)
		:_tables(__stl_next_prime(table.size()))
		,_n(0)
	{
		Hash hs;
		for (size_t i = 0; i < table.size(); i++)
		{
			Node* cur = table[i];
			while (cur)
			{					
				Node* newnode = new Node(cur->_kv);
				size_t hashi = hs(cur->_kv.first) % _tables.size();
				newnode->_next = _tables[hashi];
				_tables[hashi] = newnode;
				cur = cur->_next;
			}
		}
	}

	HashTable& operator=(const HashTable& table)
	{
		this->swap(table);
		return *this;
	}

	void swap(HashTable& table)
	{
		swap(_tables, table._tables);
		swap(_n, table._n);
	}

	bool Insert(const pair<K,V>& kv)
	{
		if (Find(kv.first))
			return false;
		Hash hs;
		if (_n == _tables.size())
		{
			vector<Node*> newtables(__stl_next_prime(_tables.size() + 1));
			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					size_t hashi = hs(cur->_kv.first) % newtables.size();
					cur->_next = newtables[hashi];
					newtables[hashi] = cur;
					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;
			}
			else {
				cur = cur->_next;
			}
		}
		return nullptr;
	}

	bool Erase(const K& key)
	{
		if (Find(key))
		{
			Hash hs;
			size_t hashi = hs(key) % _tables.size();
			Node* cur = _tables[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (hs(cur->_kv.first) == key)
				{
					if (prev == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else {
						prev->_next = cur->_next;
					}
					delete cur;
					--_n;
					return true;
				}
				else {
					prev = cur;
					cur = cur->_next;
				}
			}
		}
		else {
			return false;
		}
	}
private:
	vector<Node*> _tables;
	size_t _n;
};

今天就讲到这里,对于哈希表是不是有了初步的认识了呢,下一期我们来学习哈希表的封装:ubordered_set和unordered_map的实现,我们下次再见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值