【C++】哈希表

概念

哈希(Hash)又称散列,是一种数据组织方式。从名称上看,它带有"分散排列"的含义。其核心原理是借助哈希函数,建立关键字 Key 与其存储位置之间的映射关系。在查找时,再次通过该哈希函数计算出 Key 对应的存储位置,从而实现快速定位。


直接定址法

直接定址法是一种高效的哈希策略,适用于关键字范围集中且连续的情况。该方法直接使用关键字的值或经过简单线性变换(如减去一个固定偏移量)后的结果作为存储地址。例如,关键字在0到99之间,就可用其值直接作为数组下标;关键字为小写字母时,用字母ASCII码 - 'a'的ASCII码即可得到下标。这种方法的核心在于建立了关键字到存储位置的直接映射,无需解决冲突,实现了常数时间复杂度的查找。


哈希冲突

直接定址法虽然高效,但其缺点显著:当关键字的取值范围大但实际数据量稀疏时,会造成巨大的存储空间浪费。例如,若关键字理论范围是[0, 9999],但实际只有少量(N个)数据,若仍开辟万级大小的数组,则效率极低。

为此,我们引入哈希函数。其核心作用是将一个取值范围较大的关键字(Key)映射到一个较小的、固定长度为M的连续地址空间(通常是数组,下标从0到M-1)中。即,关键字key被存储在数组的h(key)位置。

然而,由此引出一个核心问题:由于关键字空间远大于地址空间,不同的关键字很可能被映射到同一个数组位置,这种情况称为哈希冲突或哈希碰撞。因此,哈希表技术的核心在于两方面:一是尽可能设计出分布均匀的哈希函数以减少冲突;二是必须提供一套行之有效的冲突解决机制。


负载因子

在哈希表中,负载因子是衡量其空间使用与冲突概率的关键指标。假设哈希表大小为M,已存储N个元素,则负载因子定义为 α = N / M

负载因子的大小直接体现了哈希表的性能权衡:

当负载因子​​较高​​时,意味着表内元素密集,空间利用率高,但发生哈希冲突的概率也随之显著增加。

当负载因子​​较低​​时,意味着表内元素稀疏,发生冲突的概率较低,查找效率高,但会造成较多的空间浪费。


哈希函数

除法散列法

除法散列法,即取模哈希法,是一种基础的哈希函数构造方法。其原理是取关键字key除以哈希表大小M的余数作为其存储位置,即哈希函数为:h(key) = key % M

选择模数M至关重要,应避免使用2的幂(如16, 64)或10的幂(如100, 1000)。因为当M为 2^x 时,取模运算等价于直接取key的二进制的后x位。这会导致所有后x位相同的key都发生冲突,例如M=16时,63(00111111)和31(00011111)的哈希值相同。同理,当M为10^x时,取模运算等价于取key的十进制后x位,如M=100时,112和12312的哈希值均为12。

为了促使关键字更均匀地分布,建议选择一个远离2的整数次幂的质数作为M,这能有效减少这种规律性导致的冲突。


乘法散列法

乘法散列法不依赖哈希表大小M的具体数值。其核心步骤分为两步:首先,用关键字K乘以一个介于0和1之间的常数A,并取出结果的小数部分;然后,将这个小数部分再乘以M,并对最终结果向下取整,得到的整数即为哈希地址。


全域散列法

全域散列法是一种应对恶意攻击的策略。当哈希函数固定时,攻击者可能构造出使所有关键字都发生冲突的特殊数据集。全域散列通过向哈希函数引入随机性来化解此问题,使攻击者无法预知应针对哪个函数进行攻击。

该方法的核心是预先设计一个庞大的哈希函数集合。具体实现之一是采用以下形式的函数:h(key) = ((a * key + b) % P) % M。其中P是一个足够大的质数,a和b是在特定范围内随机选取的整数(a ∈ [1, P-1], b ∈ [0, P-1]),所有可能的(a, b)组合构成了一个全域散列函数组。

在使用时,每当初始化一个哈希表,就从该函数组中​​随机选定一个​​具体的函数(即固定一组a和b)用于后续所有操作。这一点至关重要,必须确保插入和查找使用同一个函数,否则将无法正确定位数据。


处理哈希冲突

开放定址法

开放定址法将所有元素直接存储在哈希表内。当发生冲突时,它会按照预定规则(探测序列)在表中寻找下一个空位进行存放,因此其负载因子必须小于1。主要的探测规则包括线性探测、二次探测和双重散列。

线性探测

​线性探测​ ​是最简单的实现方式。从初始的哈希位置h0 = h(key)开始,如果发生冲突,则依次线性检查后续位置,即按 hi = (h0 + i) % M(i=1, 2, 3...)的顺序探测,直到找到空位。由于负载因子小于1,最终总能找到一个可用位置。

线性探测的缺点是容易导致"初级群集"(Primary Clustering)。即当一片连续区域被占用后,任何哈希到该区域的关键字都需要探测多次才能找到空位,这会显著增加后续插入和查找的时间。二次探测正是为了缓解这一问题而提出的。


二次探测

二次探测 是为了缓解线性探测带来的"初级群集"问题。当在初始位置h0 = h(key)发生冲突时,它不再顺序探测,而是按照一个二次方序列进行跳跃式查找。

其探测公式为:hi = (h0 ± i²) % M,其中i = 1, 2, 3...。这意味着探测的步长是1², -1², 2², -2²...这样依次增大。如果计算出的位置hi为负数,需要通过加上表长M来将其调整到合法的数组下标范围内。

这种方式使得发生冲突的关键字能够更快地分散开来,减少了数据聚集的现象,但可能会引入一种范围更广的"次级群集"(


双重散列

双重散列是开放定址法中一种更高效的冲突解决方法。它使用两个不同的哈希函数来生成探测序列。

当关键字key在初始位置h1(key)发生冲突时,探测步长由第二个哈希函数h2(key)决定。其探测公式为:hi = (h1(key) + i * h2(key)) % M,其中i = 0, 1, 2...。

这种方法的关键在于,步长h2(key)依赖于关键字本身。因此,即使两个关键字的初始位置h1(key)相同,只要它们的h2(key)不同,它们的探测序列就会完全不同。这有效避免了线性探测和二次探测中可能出现的"群集"现象,使关键字在表中分布得更为均匀。为了确保探测序列能够遍历整个哈希表,通常要求h2(key)的值与表大小M互质。


代码实现

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

// 哈希函数采用除留余数法
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)
	{
		size_t hash = 0;
		for (auto e : key)
		{
			hash *= 31;
			hash += e;
		}

		return hash;
	}
};

// 以下采用开放定址法,即线性探测解决冲突
namespace mumu
{
	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()
		{
			_tables.resize(10);
		}

		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
				return false;
			
			if (_n * 10 / _tables.size() >= 7)
			{
				HashTable <K, V, Hash> newht;
				newht._tables.resize(newht._tables.size() * 2);
				for (size_t i = 0; i < _tables.size(); i++)
				{
					if (_tables[i]._state == EXIST)
						newht.Insert(_tables[i]._kv);
				}
				_tables.swap(newht._tables);
			}

			Hash hash;
			size_t hash0 = hash(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)
		{
			size_t hash0 = 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 == nullptr)
			{
				return false;
			}
			else
			{
				ret->_state = DELETE;
				--_n;
				return true;
			}
		}

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

注意点

当关键字是字符串、日期等无法直接取模的类型时,我们需要为哈希表增加一个仿函数(或称哈希函数对象)。这个仿函数的核心职责是将任意的key类型转换成一个整数值,以便进行后续的取模运算。

如果key本身可以自然地转换为一个分布均匀的整型(如整数类型),则可以使用默认的仿函数。但对于字符串、日期等复杂类型,则需要自定义仿函数。设计此仿函数的关键原则是:让key的每一个有意义的部分都参与到计算中,旨在最大限度地减少不同key映射到同一整数值的可能性(即减少冲突),从而生成一个分布良好的哈希值。


链地址法

链地址法采用了与开放定址法完全不同的冲突解决思路。在此方法中,哈希表的每个位置不再直接存储数据元素,而是存储一个链表的头指针。

当没有关键字映射到某个位置时,该指针为空。当有多个关键字被哈希函数映射到同一位置时(发生冲突),我们并不在表内寻找空位,而是将这些发生冲突的数据全部链接成一个链表,并挂载在哈希表的对应位置下。因此,链地址法又常被称为"拉链法"或"哈希桶"。


代码实现

cpp 复制代码
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;
		inline unsigned long __stl_next_prime(unsigned long n)
		{
			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;
		}
	public:
		HashTable()
		{
			_tables.resize(__stl_next_prime(0), nullptr);
		}// 拷⻉构造和赋值拷⻉需要实现深拷⻉,有兴趣的同学可以⾃⾏实现
		~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;
			}
		}
		bool Insert(const pair<K, V>& kv)
		{
			Hash hs;
			size_t hashi = hs(kv.first) % _tables.size();
			// 负载因⼦==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->_next = newtables[hashi];
						newtables[hashi] = cur;
						cur = next;
					}
					_tables[i] = nullptr;
				}
				_tables.swap(newtables);
			}
			// 头插
			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;
					}
					delete cur;
					--_n;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			return false;
		}
	private:
		vector<Node*> _tables;// 指针数组
		size_t _n = 0;
		// 表中存储数据个数
	};
}
相关推荐
cici158743 小时前
在Ubuntu18.04安装兼容JDK 8的Eclipse集成开发环境
java·开发语言·eclipse
不枯石3 小时前
Matlab通过GUI实现点云的统计滤波(附最简版)
开发语言·图像处理·算法·计算机视觉·matlab
代码村新手3 小时前
C语言-操作符
开发语言·c++
老赵的博客3 小时前
c++ 之多态虚函数表
java·jvm·c++
liu****3 小时前
负载均衡式的在线OJ项目编写(四)
运维·c++·负载均衡·个人开发
天天进步20153 小时前
Python项目--交互式VR教育应用开发
开发语言·python·vr
@卞3 小时前
第十六届蓝桥杯软件赛C组省赛C++题解(京津冀)
c语言·c++·蓝桥杯
啃啃大瓜4 小时前
字典 dictionary
开发语言·python
无所事事的海绵宝宝4 小时前
使用python+flask设置挡板
开发语言·python·flask