【C++】哈希表原理与实现详解


各位大佬好,我是落羽!一个坚持不断学习进步的学生。
如果您觉得我的文章还不错,欢迎多多互三分享交流,一起学习进步!

也欢迎关注我的blog主页: 落羽的落羽

文章目录

  • 一、哈希是什么
  • 二、哈希表实现
      1. 哈希表相关概念
      1. 除法散列法
      1. 将关键字转为整数
      1. 处理哈希冲突
      • 4.1 开放定址法
      • 4.2 链地址法

一、哈希是什么

哈希(hash),又称散列,是一种组织数据的方式。本质是通过哈希函数把关键字key跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出key存储的位置,进行快速查找。

一个常见的哈希映射例子是,如果要统计一段小写字母文字中各个字母的出现次数,可以开一块大小为26的数组,每个字母的ASCII值 - a的ASCII值就是存储这个字母的次数的数组位置下标。这样经过一次遍历就能统计完了。

上述这种方法,也叫直接定址法

例题:
387. 字符串中的第一个唯一字符

非常简单吧。

二、哈希表实现

1. 哈希表相关概念

  • 哈希函数:直接定址法的缺点也十分明显,当key值的分布范围比较分散时,会导致开的内存空间极大,甚至有很多浪费。假设,我们的数据范围是0~9999的N个值,我们一开始开一块大小为M的空间,我们需要构造一个哈希函数(hash function)hf,关键字key的数据被放在hf(key)的位置上,hf(key)的值必须在[0, M)之间。
  • 哈希冲突:还有一个问题,两个不同的key可能会映射到同一个位置上,这种情况叫哈希冲突,或哈希碰撞。最理想的情况是,设计出一种好的哈希函数避免冲突。但是实际应用中,冲突是不可避免的,我们只能尽可能设计出尽可能优秀的哈希函数,尽可能减少冲突。
  • 负载因子:假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子 = N/M。负载因子越大,哈希冲突的概率越高,空间利用率也高;负载因子越小,哈希冲突的概率越低,空间利用率也越低。

2. 除法散列法

除了直接定址法,常用的哈希映射方法还有除法散列法,下面我们也使用这种方法实现哈希函数。

除法散列法,也叫除留余数法。假设哈希表的大小为M,那么key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M

使用除法散列法时,要尽量避免使用2的幂次、10的幂次之类的值。因为key%2x相当于留下key的二进制的后x位,key%10x相当于留下key的十进制的后x位,就更容易导致哈希冲突。根据前人的总结,M最好取不接近2的整数次幂的质数。

3. 将关键字转为整数

除留余数法最重要的要求是,key能够取模,因此key的类型必须是整数或能转换为整数。

cpp 复制代码
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

key是无法直接强制转换为整型的类型时,如string,就可以对hashFunc进行模板特化,单独写一个方法:字符串转换为整型,可以选择直接把字符的ASCII值相加,但是这样计算类似"abcd"和"acdb"结果是一样的。前人总结出的一个绝佳方法是,上一次计算的结果乘以一个质数,一般是31或131:

cpp 复制代码
// string特化
template<>
struct HashFunc<string>
{
	size_t operator()(const string& key) const
	{
		size_t hash = 0;
		for (auto ch : key)
		{
			hash += ch;
			hash *= 131;
		}

		return hash;
	}
};

4. 处理哈希冲突

哈希冲突是避免不了的,所以需要学会处理哈希冲突。处理方式一般有开放定址法、链地址法

例如,将一组数据30、19、5、36、13、20、21、12映射到大小为11的表中,则h(key) = key % 11。h(30) = 8,h(19) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) = 10,h(12) = 1。哈希函数算出的值即为存储它们的数组下标,注意到h(30) = h(19),两个数据的存储位置冲突了。

4.1 开放定址法

开放定址法是,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的空位置进行存储,开放定址法中负载因子一定是小于1的。寻找空位置的规则有三种:线性探测、二次探测、双重探测。

  • 线性探测:从发生冲突的位置开始,依次线性向后探测,直到找到下一个没有存储数据的位置为止,如果找到哈希表尾,则回到哈希表头的位置。因为负载因子小于1,所以最多探测M-1次,一定能找到一个存储key的位置。
    h(key) = hashi = (hash0+i) % M, i = {1, 2, 3, ..., M-1}

    线性探测比较简单而且容易实现,但缺点是如果出现位置的连续冲突,多个数据按照插入顺序的不同可能造成位置混乱,争夺同一个位置,这种现象叫做群集(堆积)。

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

    h(key) = hash0 = key % M

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

    当 hashi = (hash0 − i2)%M 时,当hashi<0时,需要hashi += M

下面我们主要使用线性探测,用开放定址法实现哈希表:

先搭出框子:

cpp 复制代码
namespace open_address
{
	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 HashTable
	{
	private:
		vector<HashData<K, V>> _tables;
		// 记录实际存储数据个数
		size_t _n = 0;
	};
}

要注意的是,我们需要给每个存储值的位置加一个状态标识,否则删除值时,会影响后面新插入的值无法判断这个位置的状态。

哈希表也需要扩容。

这里我们的哈希表的负载因子可以控制在0.7,当负载因子到0.7时就进行一次扩容。假如还按照2倍的扩容,就不能保证下一个M是质数了。一种解决方法是,SGI版本的哈希表方法,提供了一个质数表:

cpp 复制代码
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;
}

我们这里也借用这个质数表,每次去这个表里获取哈希表扩容后的下一个大小。

开放定址法的哈希表完整实现:

cpp 复制代码
#include<iostream>
#include<vector>
using namespace std;

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)
	{
		return (size_t)key;
	}
};

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

		return hash;
	}
};


namespace open_address
{
	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 = __stl_next_prime(0))
			:_tables(n)
			, _n(0)
		{}

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

			//负载因子到了0.7就进行扩容
			if ((double)_n / (double)_tables.size() >= 0.7)
			{
				HashTable<K, V, Hash> newht(__stl_next_prime(_tables.size() + 1));
				
				//遍历旧表,将旧表的数据全部重新映射到新表中
				for (size_t i = 0; i < _tables.size(); i++)
				{
					if (_tables[i]._state == EXIST)
					{
						newht.Insert(_tables[i]._kv);
					}
				}
				_tables.swap(newht._tables);
			
			}

			Hash hs;
			size_t hash0 = hs(kv.first) % _tables.size();
			size_t hashi = hash0;
			size_t i = 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 hs;
			size_t hash0 = hs(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;
				_n--;
				return true;
			}
			else
			{
				return false;
			}
		}

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

4.2 链地址法

开放定址法中,所有元素都放在哈希表中。链地址法中所有的数据不再直接存储在哈希表中,而是哈希表中每一个位置存储一个指针,没有数据映射到这个位置时,指针为空,有多个数据映射到这个位置时,把冲突的数据连接成一个链表,"挂在"这个哈希表位置下面。链地址法也叫拉链法或哈希桶

举个例子:

开放定址法的负载因子必须小于1,而链地址法的负载因子就没有限制了,可以大于1。负载因子越大,哈希冲突的概率越高,空间利用率也高;负载因子越小,哈希冲突的概率越低,空间利用率也越低。STL中unordered_xxx系列容器的最大负载因子基本控制在1,大于1就扩容,我们下面也使用这个方式。

链地址法的哈希桶完整实现:

cpp 复制代码
#include<iostream>
#include<vector>
using namespace std;

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)
	{
		return (size_t)key;
	}
};

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

		return hash;
	}
};


namespace hash_bucket
{
	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
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable(size_t n = __stl_next_prime(0))
			:_tables(n)
			,_n(0)
		{ }

		//涉及结点空间的开辟,因此需要自己写析构函数
		~HashTable()
		{
			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete next;
					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(__stl_next_prime(_tables.size() + 1), 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头插到新表
						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 == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					_n--;
					delete cur;

					return true;
				}

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


	private:
		vector<Node*> _tables;
		size_t _n; //记录实际存储数据个数

	};

}

本文完整项目代码已上传至我的gitee仓库,欢迎浏览:
https://gitee.com/zhang-yunkai060524/luoyu-c-language

本篇完,感谢阅读。

相关推荐
Vesan,2 小时前
无人机开发分享——基于行为树的无人机集群机载自主决策算法框架搭建及开发
c++·算法·决策树·无人机
董莉影2 小时前
学习嵌入式第二十二天
数据结构·学习·算法·链表
R-G-B3 小时前
【24】C++实战篇——【 C++ 外部变量】 C++多个文件共用一个枚举变量,外部变量 extern,枚举外部变量 enum
c++·c++ 外部变量·c++文件共用一个枚举变量·外部变量 extern·枚举外部变量 enum
快乐的划水a3 小时前
表驱动法-灵活编程范式
c++·链表
疯狂的Alex3 小时前
未来20年哪几种编程语言会保持优势?哪几种编程语言会得到更广泛的应用?
java·开发语言·c++·python·c#
GawynKing4 小时前
图论(1):图数据结构
数据结构·算法·图论·图形理论
爱吃生蚝的于勒4 小时前
一文学会c++继承 组合
java·c语言·开发语言·数据结构·c++·算法·蓝桥杯
愿天堂没有C++4 小时前
剑指offer第2版——面试题1:赋值运算符函数
c++·面试
zgc12453675 小时前
Linux学习-数据结构(链表)
linux·开发语言·数据结构·vscode·链表