C++进阶——哈希思想及实现

目录

前言:何谓哈希?

一、最简单的哈希:直接定址法

 二、哈希存在的问题:哈希冲突

三、与哈希冲突程度息息相关:负载因子

四、key值问题:转成整数才能映射

五、解决哈希冲突的两种方法:开放定址法、链地址法

1. 开放定址法

1.1 线性探测

1.2 二次探测

1.3 双重散列

2. 链地址法

六、哈希代码实现

1. 关于C++标准库对哈希表大小的规定

2. Key值类型的解决

3. 代码实现

4. 测试


前言:何谓哈希?

        哈希,又称散列。哈希是人名直译过来的名字,实际上散列更能概括哈希的情况。

        哈希/散列是一种组织数据的方式,散列听起来就有散乱排列的意思,实际上确实如此。

给我们一组数据(key-value或者key都可以)和一部分空间(vector),我们直接通过某种方式对key做一定的计算得出数据的存储位置进行存储,在查找的时候利用同样的方式来计算出其位置。这样实现出来的哈希理想状态下增删查改的效率就是O(1)。

本篇博客就让我们一起来探讨一下哈希的各种细节及实现吧!!


一、最简单的哈希:直接定址法

可能很多人都做过类似的题目:

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

解决问题的方法也很简单,建立一个有26个空间的int数组,数组的0下标对应的值就是字母'a'对应的出现次数,我们通过将数组下标和字母建立一个映射关系,统计出各个字母出现的次数,再再次遍历字符串s找到第一个不重复的字符也就是值为1的字符。

// 统计次数可以用一个容量为26的int数组
// 找到第一个不重复字符可以重头到尾遍历s找对应的数组中的值,为1则返回

class Solution {
public:
    int firstUniqChar(string s) {
        // 计数数组
        int count[26] = {0};
        
        // 遍历s利用数组计数
        for(auto ch : s)
        {
            ++count[ch-'a'];
        }

        // 再次遍历s找到第一个不重复字符
        for(size_t i = 0; i < s.size(); ++i)
        {
            if(count[s[i]-'a']==1)
            {
                // 找到了就返回索引
                return i;
            }
        }
        // 没有找到就返回-1
        return -1;
    }
};

这其中的第一步,将字符出现的次数与数组下标建立映射关系时,用到的就是哈希的思想。

字符和字符出现次数是一对key-value值,通过key(字符)找到数组中的对应位置并修改相应值。

对应到一般情况就是一对key-value值,使用key通过哈希函数找到该键值对存储的位置并得到(修改)value的值。

当关键字的范围比较集中时,直接定址法就是非常简单高效的方法,比如一组关键字都在[0,99]之间,那么我们开一个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如⼀组关键字值都在[a,z]的小写字母,那么我们开一个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。也就是说直接定址法本质就是用关键字计算出⼀个绝对位置或者相对位置。

但是直接定址法是有很强的局限性的:

直接定址法在面临key值范围过大而数据很少的时候,开的空间很大,但是实际上利用到的空间却很少,(比如你只有0,5000,10000三个数据,却要开一万个空间)所以很多情况下直接定址法并不实用。
 

 二、哈希存在的问题:哈希冲突

        我们上面使用的直接定址法,不会存在两个key值映射到同一个位置的情况,但是浪费的空间有时候真的太多了,所以我们可以考虑缩小空间,比如给我们一组数据{19,30,52,63,11,22}(都是key值,value值都省去了,下文无特殊说明都是这样),使用直接定址法就需要开辟至少45个空间,但是却只存储了6组数据。所以我们就可以考虑给更少一点的空间,也能将数据存进去。

        假如我们只开11个空间:

我们将数据填入空间中,而且不能按直接定址法的规则,所以这个映射关系就得我们自己去找,将key值映射到对应空间的过程用到的函数,我们称哈希函数。

对于哈希函数,常见的有除法散列法、乘法散列法以及全域散列法等等,最常用的还是除法散列法,一般是key % M(M为哈希表的大小)

比如我们这里将哈希函数设置为h(key) = key % 11,那么:

可以发现,如果我们将19填入到下标为8的位置后,30,52,63同样需要下标为8的位置,但是已经被19占了,这种位置冲突的情况我们就称为哈希冲突。 

哈希冲突是不可避免的,我们能做的只是尽量寻找到合适的哈希函数来减少冲突的次数

三、与哈希冲突程度息息相关:负载因子

        试想,一个没有存储任何数据的vector中,你用key映射到这个vector的任何一个位置都是不会发生冲突的,再来一个数据,那么只有一丝丝的可能会发生哈希冲突,如果vector中的数据很多接近于满,那么来一个数据存入,发生哈希冲突的概率就会大大增加。

        所以,负载因子:N / M (已使用空间、存储数据个数 / 总空间)

负载因子越小,发生哈希冲突的可能就越小,但是空间利用率就会很低,所以为了平衡哈希冲突和空间利用率,当负载因子增长到一定大小时我们就需要扩容。

一般来说,对于开放定址法,负载因子大于等于0.7时就需要扩容

                  对于链地址法,负载因子大于等于1时就需要扩容

四、key值问题:转成整数才能映射

我们哈希使用的基础是在vector之上,vector的下标都是整数,所以key-value中的key必须通过哈希函数转换成整数再进行一些其它的映射操作才能映射到vector之上。

如果是浮点数、无符号整数等可以通过强制类型转换来进行转换。

如果是字符串类型的key,那么可以利用各个字符的ascll值。

这其中的一些细节我会在后面的代码部分详细展示。

五、解决哈希冲突的两种方法:开放定址法、链地址法

如果你的位置被别人占了,那么你要找一个位置坐,能做的方法就只能是去占没来的人的位置或者去和占你位置的人一起坐,这两种方式其实就对应我们解决哈希冲突的两种方法:开放定址法和链地址法

1. 开放定址法

如果一个数据要放入哈希表中,它通过哈希函数算出来的位置被其它数据抢占了,那么就在这个数据的附近位置找一个空位置去存储,当然这个附近的位置也是有规则的。根据这个规则,开放定址法可以分为线性探测、二次探测和双重散列等。

1.1 线性探测

线性探测就是算出key对应的位置后,如果位置被抢占了,那么就一个一个往后找空位置,找到尾还没找到就跳到头开始找,直到找到空位置时占用        

这种方法实现起来逻辑很简单,用一个简单的取模和遍历就可以搞定,不过存在堆积的问题。

1.2 二次探测

二次探测也是在key对应位置的附近找空位置,只是寻找逻辑与线性探测不同,

h(key) = hash0 = key % M

如果冲突,那么再去查找:

hc(key, i) = hashi = (hash0 ± i2) % M, i = {1, 2, 3, ..., }

也就是在原被占位置的左右两边来回跳跃查找,查找的距离会逐渐增大 

1.3 双重散列

二次探测的堆积问题相比于线性探测会好很多,但是仍然会存在,本质上是因为不管是什么key值,发现哈希冲突后往附近查找空位置的逻辑都是相同的,双重散列可以理解为在发现哈希冲突后,再次用一个哈希函数对key值进行处理,计算出一个位置,这种方式会比二次探测解决哈希冲突和堆积的问题更优秀

h1(key) = hash0 = key % M

如果冲突,那么用h2再去处理key

hc(key, i) = hashi = (hash0 + i ∗ h2(key)) % M, i = {1, 2, 3, ..., M}

2. 链地址法

开放定址法其实说白了就是去抢别人的位置,那么别人来了又得去抢另外一个人的位置,也就是说,开放定址法并不能从根本上解决哈希冲突的问题

链地址法的思想就是,我的位置被占了,那我就在我的位置那里继续挂着,两个/多个人共享一个位置。

这样就根本上解决了哈希冲突的问题,查找的时候就直接找到相应位置再去相应的链表上查找相应数据即可。

极端情况:

如果一个链表(哈希桶)上的结点个数过多,也会影响查找效率,这种情况在隔壁的java中会把链表变为红黑树挂在vector的结点上,我们也可以考虑这种做法,或者借用开放定址法的思想把一个桶分为多个桶挂在不同的vector的位置上。

六、哈希代码实现

1. 关于C++标准库对哈希表大小的规定

这是stl30中stl_hashtable.h中的一段源码

哈希表的大小尽量应该是素数(基于数学的推算可以尽可能减小哈希冲突),扩容时尽量2倍扩容,于是C++标准库就给出了一个这样的静态数组以及获取哈希表大小的函数

所以待会我们实现哈希表就用这个函数来决定哈希表的大小

2. Key值类型的解决

对于key值类型的问题,我们可以对我们的哈希表多开一个模板参数,利用仿函数的方式处理,当key的类型是浮点数等数值类型时,我们可以直接强转,如果是字符串类型,我们就利用它的ascll值算出一个该字符串对应的整数值。

3. 代码实现

 原理理解清楚,写代码就需要我们亲自上手了,我会把一些细节的注释写清楚,大家可以参考~

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

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

inline unsigned long __stl_next_prime(unsigned long n)
{
	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 KeyToInt
{
	int operator()(const K& key)
	{
		return (int)key;
	}
};

template<>
struct KeyToInt<string>
{
	int operator()(const string& key)
	{
		int ret = 0;
		for (auto& e : key)
		{
			ret += e;
			ret *= 131;
		}
		return ret;
	}
};

// 开放定址法
namespace OpenAddressing
{
	// 如果没有状态位,在查找的时候可能会因为删除的位置为EMPTY而不能继续向后查找出现问题
	enum State
	{
		EXIST,
		DELETE,
		EMPTY
	};

	template<class K, class V>
	struct HashDate
	{
		pair<K, V> _kv;
		State _state;

		HashDate()
			:_state(EMPTY)
		{}
	};

	template<class K, class V, class KeyToInt = KeyToInt<K>>
	class HashTable
	{
		typedef HashDate<K, V> Date;
	public:
		HashTable()
			:_hashtable(__stl_next_prime(0))
			, _n(0)
		{}

		bool Insert(const pair<K, V>& kv)
		{
			// 不允许重复
			if (Find(kv.first))
				return false;

			// 扩容
			// 负载因子大于等于0.7时就扩容
			if (_n * 10 / _hashtable.size() >= 7)
			{
				// 复用Insert逻辑 + 交换
				HashTable<K, V> newHashTable;              // 大于等于x的素数,不+1就不会变
				newHashTable._hashtable.resize(__stl_next_prime(_hashtable.size() + 1));
				
				for (auto& e : _hashtable)
				{
					newHashTable.Insert(e._kv);
				}
				
				_hashtable.swap(newHashTable._hashtable);
			}
			
			// 插入
			KeyToInt kti;
			size_t hashi = kti(kv.first) % _hashtable.size();
			size_t tmpi = hashi;

			for (size_t i = 0; i < _hashtable.size(); ++i)
			{
				// 线性探测
				tmpi = (hashi + i) % _hashtable.size();
				if (_hashtable[tmpi]._state != EXIST)
				{
					_hashtable[tmpi]._kv = kv;
					_hashtable[tmpi]._state = EXIST;
					++_n;
					return true;
				}
			}

			return false;
		}

		Date* Find(const K& key)
		{
			// 与插入逻辑很相似
			KeyToInt kti;
			size_t hashi = kti(key) % _hashtable.size();
			size_t tmpi = hashi;

			for (size_t i = 0; i < _hashtable.size(); ++i)
			{
				tmpi = (hashi + i) % _hashtable.size();
				if (_hashtable[tmpi]._state == EXIST)
				{
					if (_hashtable[tmpi]._kv.first == key)
						return &_hashtable[tmpi];
				}
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			// 查找到之后只改标志位就ok
			Date* ret = Find(key);
			if (ret == nullptr)
				return false;
			ret->_state = DELETE;
			--_n;
			return true;
		}
	private:
		vector<Date> _hashtable;
		size_t _n;
	};
}

// 链地址法
namespace Chaining
{
	template<class K, class V>
	struct HashNode
	{
		HashNode(const pair<K, V>& kv)
			:_kv(kv)
			,_next(nullptr)
		{}

		pair<K, V> _kv;
		HashNode* _next;
	};

	template<class K, class V, class KeyToInt = KeyToInt<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable()
			:_hashtable(__stl_next_prime(0))
			,_n(0)
		{}

		// 清除数据,释放结点,便于析构和赋值运算符重载复用
		void clear()
		{
			// 走个循环就好
			for (size_t i = 0; i < _hashtable.size(); ++i)
			{
				// 查找每个桶有无需要释放的结点,有则释放,没有就看下一个桶
				Node* cur = _hashtable[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				// 我嘞个de了个惊天大bug,
				// 如果这里不置为空,那么就是说_hashtable里面仍存储了已经释放掉结点的指针
				// 下次再new的时候,编译器可能刚刚好又利用了这块空间,然后增加了一个新节点
				// 所以结果就是:原来释放掉的空间又有效了,存储了新的数据,但是这个数据却在_hashtable中存了两份
				// 这样出现的问题就是:1. 一个数据在哈希表中存了两份,有一份数据的位置不对
				//					 2.等析构的时候,同一份数据就会delete两次,当然就会出现问题,
				// 所以一定要把这个数组恢复原状阿就是初始化的状态,全部存的指针都置为空
				// 长记性了/(ㄒoㄒ)/~~
				_hashtable[i] = nullptr;
			}
		}

		// 有new申请的结点,这些结点是自定义类型,需要手动写析构函数
		~HashTable()
		{
			clear();
		}

		// 拷贝构造
		HashTable(const HashTable& other)
			:_hashtable(other._hashtable.size())
			, _n(0)
		{
			// 初始化列表是先走的,这里用_hashtable的size不用担心 
			for (size_t i = 0; i < _hashtable.size(); ++i)
			{
				Node* cur = other._hashtable[i];
				while (cur)
				{
					Insert(cur->_kv);
					cur = cur->_next;
				}
			}
		}

		// 赋值运算符重载
		HashTable& operator=(const HashTable& other)
		{
			// 自我赋值检测
			//if(other != *this)
			{
				// 先清除原来的数据
				clear();

				// 调整大小
				_hashtable.resize(other._hashtable.size());
				_n = 0;

				// 复制数据
				for (size_t i = 0; i < _hashtable.size(); ++i)
				{
					Node* cur = other._hashtable[i];
					while (cur)
					{
						Insert(cur->_kv);
						cur = cur->_next;
					}
				}
			}
			return *this;
		}

		bool Insert(const pair<K, V>& kv)
		{
			// 不允许重复
			if (Find(kv.first))
				return false;

			// 扩容
			if (_n * 10 / _hashtable.size() >= 10)
			{
				HashTable newHashTable;
				newHashTable._hashtable.resize(__stl_next_prime(_hashtable.size() + 1));

				for (size_t i = 0; i < _hashtable.size(); ++i)
				{
					KeyToInt kit;
					Node* cur = _hashtable[i]->_next;
					while (cur)
					{
						size_t hashi = kit(cur->_kv.first) % newHashTable._hashtable.size();
						newHashTable.Insert(cur->_kv);
						cur = cur->_next;
					}

				}
				_hashtable.swap(newHashTable._hashtable);
			}

			KeyToInt kit;
			Node* newnode = new Node(kv);
			size_t hashi = kit(kv.first) % _hashtable.size();
			// 头插
			newnode->_next = _hashtable[hashi];
			_hashtable[hashi] = newnode;
			++_n;
			return true;
		}

		Node* Find(const K& key)
		{
			KeyToInt kit;
			size_t hashi = kit(key) % _hashtable.size();
			Node* cur = _hashtable[hashi];

			while (cur && cur->_kv.first != key)
			{
				cur = cur->_next;
			}
			// 不存在:最后cur是nullptr
			// 存在:  返回cur
			return cur;

		}

		bool Erase(const K& key)
		{
			KeyToInt kit;
			size_t hashi = kit(key) % _hashtable.size();
			Node* cur = _hashtable[hashi];
			Node* prev = nullptr;

			while (cur)
			{
				if (cur->_kv.first == key)
				{
					// 头删/不是头删
					prev == nullptr ? _hashtable[hashi] = cur->_next : prev->_next = cur->_next;
					delete cur;
					--_n;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

	private:
		// 这里vector会把指针默认初始化为nullptr,并不会调用HashNode的默认构造产生对象
		vector<Node*> _hashtable;
		size_t _n;
	};
}

4. 测试

下面这些是我测试的时候用到的一些数据,大家测试扩容的时候可以暂时把哈希表的初始化和扩容的逻辑改改

#include "HashTable.h"


int main()
{
	/*OpenAddressing::HashTable<int, int> hashtable1;
	int a1[] = { 19, 30, 5, 36, 13, 20, 21, 12, 15, 27 };

	for (auto& e : a1)
	{
		hashtable1.Insert({ e,e });
	}
	
	cout << hashtable1.Find(19) << endl;
	cout << hashtable1.Find(55) << endl;
	hashtable1.Erase(19);
	cout << hashtable1.Find(19) << endl;

	OpenAddressing::HashTable<string, string> hashtable3;
	string a3[] = {"apple", "orange", "sort", "handsome", "banana", "pear", "acbd", "abcd","aadd","dcab","dlkasjd", "dasdas"};

	for (auto& e : a3)
	{
		hashtable3.Insert({ e,e });
	}

	cout << hashtable3.Find("orange") << endl;
	cout << hashtable3.Find("null!") << endl;
	hashtable3.Erase("orange");
	cout << hashtable3.Find("orange") << endl;



	Chaining::HashTable<int, int> hashtable2;
	int a2[] = { 19, 30, 5, 36, 13, 20, 21, 12, 24, 96 };
	for (auto& e : a2)
	{
		hashtable2.Insert({ e,e });
	}

	cout << hashtable2.Find(19) << endl;
	cout << hashtable2.Find(30) << endl;
	cout << hashtable2.Find(96) << endl;
	cout << hashtable2.Find(25) << endl;
	cout << hashtable2.Find(99) << endl;*/

	Chaining::HashTable<string, string> hashtable4;
	string a4[] = { "apple", "orange", "sort", "handsome", "banana", "pear", "acbd", "abcd","aadd","dcab","dlkasjd", "dasdas" };

	for (auto& e : a4)
	{
		hashtable4.Insert({ e,e });
	}

	//cout << hashtable4.Find("orange") << endl;
	//cout << hashtable4.Find("null!") << endl;
	//hashtable4.Erase("orange");
	//cout << hashtable4.Find("orange") << endl;

	//Chaining::HashTable<string, string> hashtable5(hashtable4);

	Chaining::HashTable<string, string> hashtable6;
	Chaining::HashTable<string, string> hashtable7;
	hashtable6.Insert({ "666", "666" });
	hashtable7 = hashtable6 = hashtable4;
	hashtable7.Insert({ "666", "666" });



	return 0;
}

那么这篇博客到这里就结束啦~~~

下篇博客我们将用这次实现的哈希表来封装unordered_map和unordered_set

完结撒花!~

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值