哈希表的实现(开放定址法与链地址法)

1. 哈希概念

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

1.1 直接定址法

当关键字的范围⽐较集中时,直接定址法就是⾮常简单⾼效的⽅法,⽐如⼀组关键字都在[0,99]之间,那么我们开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。再⽐如⼀组关键字值都在[a,z]的⼩写字⺟,那么我们开⼀个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。也就是说直接定址法本质就是⽤关键字计算出⼀个绝对位置或者相对位置。这个⽅法我们在计数排序部分已经⽤过了。
https://leetcode.cn/problems/first-unique-character-in-a-string/description/

1.2 哈希冲突

直接定址法的缺点也⾮常明显,当关键字的范围⽐较分散时,就很浪费内存甚⾄内存不够⽤。假设我们只有数据范围是[0, 9999]的N个值,我们要映射到⼀个M个空间的数组中(⼀般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的h(key)位置,这⾥要注意的是h(key)计算出的值必须在[0, M)之间。
这⾥存在的⼀个问题就是,两个不同的key可能会映射到同⼀个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。理想情况是找出⼀个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的⽅案。

1.3 负载因⼦

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

1.4 将关键字转为整数

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

2. 哈希函数

⼀个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做到,但是我们要尽量往这个⽅向去考量设计。

2.1 除法散列法/除留余数法

• 除法散列法也叫做除留余数法,顾名思义,假设哈希表的⼤⼩为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。
• 当使⽤除法散列法时,要尽量避免M为某些值,如2的冥,10的冥等。如果是 2X ,那么key %
本质相当于保留key的后X位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。如:{63 , 31}看起来没有关联的值,如果M是16,也就是 24 ,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。如果是 10X ,就更明显了,保留的都是10进值的后x位,如:{112, 12312},如果M是100,也就是 102 ,那么计算出的哈希值都是12。
• 当使⽤除法散列法时,建议M取不太接近2的整数次冥的⼀个质数(素数)。
• 需要说明的是,实践中也是⼋仙过海,各显神通,Java的HashMap采⽤除法散列法时就是2的整数次冥做哈希表的⼤⼩M,这样玩的话,就不⽤取模,⽽可以直接位运算,相对⽽⾔位运算⽐模更⾼效⼀些。但是他不是单纯的去取模,⽐如M是2^16次⽅,本质是取后16位,那么⽤key’ =
key>>16,然后把key和key’ 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围
内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可。所以我们上⾯建议M取不太接近2的整数次冥的⼀个质数的理论是⼤多数数据结构书籍中写的理论吗,但是实践中,灵活运⽤,抓住本质,⽽不能死读书。

2.2 乘法散列法

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

2.3 全域散列法

• 如果存在⼀个恶意的对⼿,他针对我们提供的散列函数,特意构造出⼀个发⽣严重冲突的数据集,⽐如,让所有关键字全部落⼊同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决⽅法⾃然是⻅招拆招,给散列函数增加随机性,攻击者就⽆法找出确定可以导致最坏情况的数据。这种⽅法叫做全域散列。
• hab (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, 则 h34 (8) = ((3 × 8 + 4)%17)%6 = 5 。
• 需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使⽤,后续增删查改都固定使⽤这个散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了。

2.4 其他⽅法

• 上⾯的⼏种⽅法是《算法导论》书籍中讲解的⽅法。
• 《殷⼈昆 数据结构:⽤⾯向对象⽅法与C++语⾔描述 (第⼆版)》和 《[数据结构(C语⾔版)].严蔚敏_吴伟⺠》等教材型书籍上⾯还给出了平⽅取中法、折叠法、随机数法、数学分析法等,这些⽅法相对更适⽤于⼀些局限的特定场景,有兴趣可以去看看这些书籍。

在实际生活中用的最多的是除留余数法,所以下面的代码我也使用的除留余数法

3.处理哈希冲突

3.1 开放定址法

在开放定址法中所有的元素都放到哈希表⾥,当⼀个关键字key⽤哈希函数计算出的位置冲突了,则按照某种规则找到⼀个没有存储数据的位置进⾏存储,开放定址法中负载因⼦⼀定是⼩于的。这⾥的规则有三种:线性探测、⼆次探测、双重探测。

线性探测
• 从发⽣冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果⾛到哈希表尾,则回绕到哈希表头的位置。
• h(key) = hash0 = key % M , hash0位置冲突了,则线性探测公式为:hc(key, i) = hashi = (hash0 + i) % M, i = {1, 2, 3, …, M − 1},因为负载因⼦⼩于1,则最多探测M-1次,⼀定能找到⼀个存储key的位置。
• 线性探测的⽐较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下⾯的⼆次探测可以⼀定程度改善这个问题。
• 下⾯演⽰ {19,30,5,36,13,20,21,12} 等这⼀组值映射到M=11的表中。

在这里插入图片描述

其他两种方式大家可以不用了解,知道有这个东西即可。

3.2 开放定址法代码实现

namespace open_address
{
    
    
	template<class k>
	struct HashFunc
	{
    
    
		size_t operator()(const k& key)
		{
    
    
			return (unsigned int)key;
		}
	};
	//仿函数,来转换为无符号整形

	template<>
	struct HashFunc<string>//特化
	{
    
    
		size_t operator()(const string& s)
		{
    
    
			size_t Hash = 0;
			for (auto e : s)
			{
    
    
				Hash += e;
				Hash *= 131;//这里的131可以为任意值,但是大佬通过计算测量出131是效果最好的数
			}
			return Hash;
		}
	};

	enum  state
	{
    
    
		EXITE,
		DELETE,
		EMPTY
	};//状态

	template<class k, class v>
	struct HashData
	{
    
    
		pair<k, v> _kv;
		state _state = EMPTY;
	};//vector里存的数据

	template<class k, class v, class Hash = HashFunc<k>>
	class HashTable
	{
    
    
	public:
		HashTable() :
			_table(__stl_next_prime(0)),
			_n(0)
		{
    
    }

		bool Insert(const pair<k, v>& kv)
		{
    
    
			if (Find(kv.first))
			{
    
    
				return false;
			}
			//如果存在

			Hash hs;
			if (_n * 10 / _table.size() >= 7)
			{
    
    
				//扩容
				HashTable<k, v, Hash> _tmp;
				_tmp._table.resize(__stl_next_prime(_table.size() + 1));
				for (auto& e : _table)
				{
    
    
					if (e._state == EXITE)
						_tmp.Insert(e._kv);
				}

				swap(_tmp._table, _table);
			}

			size_t hash0 = hs(kv.first) % _table.size();
			size_t i = 1;
			size_t hashi = hash0;

			while (_table[hashi]._state == EXITE)
			{
    
    
				hashi = (hash0 + i) % _table.size();
				i++;
			}

			_table[hashi]._kv = kv;
			_table[hashi]._state = EXITE;
			_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 != EMPTY)
			{
    
    
				if (_table[hashi]._state == EXITE && _table[hashi]._kv.first == key)
				{
    
    
					return &_table[hashi];
				}
				hashi = (hash0 + i) % _table.size();
				i++;
			}

			return nullptr;
		}

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

			return false;//没找到
		}

	private:
		vector<HashData<k, v>> _table;
		size_t _n;
	};
}

测试用例:

void test1()
{
    
    
	open_address::HashTable<int, int> ht1;
	int a[] = {
    
     19, 30, 5, 36, 13, 20, 21, 12 };
	for (auto& e : a)
	{
    
    
		ht1.Insert({
    
     e,e });
	}

	ht1.Insert({
    
     100,100 });

	if (ht1.Find(30))
		cout << "存在" << endl;
	else
		cout << "不存在" << endl;

	ht1.Erase(30);

	if (ht1.Find(30))
		cout << "存在" << endl;
	else
		cout << "不存在" << endl;

}

struct stringFunc
{
    
    
	size_t operator()(const string& k)
	{
    
    
		size_t Hash = 0;//记录整个字符串相加的ascll值
		for (size_t i = 0; i < k.size(); i++)
		{
    
    
			Hash += k[i];
		}
		return Hash;
	}
};

void test2()
{
    
    
	
	open_address::HashTable<string, int> ht;


	ht.Insert({
    
     "apple",10 });
	ht.Insert({
    
     "pear",3 });
	ht.Insert({
    
     "banana",10 });
	ht.Insert({
    
     "lemon",2 });
}

3.3 链地址法

解决冲突的思路
开放定址法中所有的元素都放到哈希表⾥,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储⼀个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成⼀个链表,挂在哈希表这个位置下⾯,链地址法也叫做拉链法或者哈希桶。

• 下⾯演⽰ {19,30,5,36,13,20,21,12,24,96} 等这⼀组值映射到M=11的表中。
在这里插入图片描述
在这里插入图片描述

扩容

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

3.4 链地址法代码实现



namespace hash_bucket
{
    
    
	template<class k, class v>
	struct HashData
	{
    
    
		pair<k, v> _kv;
		HashData<k, v>* _next;

		HashData(const pair<k,v>& kv):
			_kv(kv),_next(nullptr)
		{
    
    }
	};

	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;
			for (auto& e : s)
			{
    
    
				Hash += e;
				Hash *= 131;
			}
			return Hash;
		}
	};

	template<class k, class v, class Hash = HashFunc<k>>
	//template<class k, class v>
	class HashTable
	{
    
    
		typedef HashData<k, v> Node;
		//typedef HashData<k, v> Node;
	public:
		HashTable() :
			_table(__stl_next_prime(0)),
			//_table(11),
			_n(0)
		{
    
    }

		bool Insert(const pair<k, v>& kv)
		{
    
    
			Hash hs;
			if (Find(hs(kv.first)))
				return false;
			//不允许冗余

			if (_n / _table.size() == 1)
			{
    
    
				//扩容
				vector<Node*> tmp;
				tmp.resize(__stl_next_prime(_table.size() + 1));

				for (size_t i = 0; i < _table.size(); i++)
				{
    
    
					Node* cur = _table[i];
					while (cur)
					{
    
    
						Node* next = cur->_next;
						size_t Hashi = hs(cur->_kv.first) % tmp.size();
						cur->_next = tmp[Hashi];
						tmp[Hashi] = cur;
						cur = next;
					}
					_table[i] = nullptr;
				}
				swap(tmp, _table);
			}

			size_t hashi = hs(kv.first) % _table.size();

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

		Node* Find(const k& key)
		{
    
    
			Hash hs;
			size_t hashi = hs(key) % _table.size();
			Node* cur = _table[hashi];
			while (cur)
			{
    
    
				if (hs(key) == cur->_kv.first)
					return cur;
				cur = cur->_next;

			}
			return nullptr;
		}

		bool Erase(const k& key)
		{
    
    
			Hash hs;
			size_t hashi = hs(key) % _table.size();
			Node* Prev = nullptr;
			Node* cur = _table[hashi];

			while (cur)
			{
    
    
				if (hs(cur->_kv.first) == key)
				{
    
    
					if (Prev == nullptr)
					{
    
    
						_table[hashi] = cur->_next;
					}
					else
					{
    
    
						Prev->_next = cur->_next;
					}

					delete cur;
					--_n;
					return true;
				}
				Prev = cur;
				cur = cur->_next;
			}
			return false;
		}


	private:
		vector< HashData<k,v>*> _table;
		size_t _n;
	};
}

这个函数是官方采用的比较好的一组数,冲突比较少,我们只需要传入一个值,它会返回大于当前值的最小的那个数

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

猜你喜欢

转载自blog.csdn.net/2401_87257864/article/details/143168604