C++之哈希表的基本介绍以及其自我实现(开放定址法版本)

哈希表

引言(鸽巢原理)

鸽巢原理(Pigeonhole Principle)也称为抽屉原理,是组合数学中一个基本且重要的原理,其核心思想是:如果物体的数量多于容器的数量,那么至少有一个容器中会放置多个物体。

这也就意味着,当元素个数小于容器的大小时,则每一个元素都能够找到自己唯一的一个地址来存放自己。由此引出了直接寻址法

这种思想,在之前的leetcode387题,字符串中的第一个唯一字符中使用过。

bash 复制代码
class Solution {
public:
    int firstUniqChar(string s) {
        int count[26] = {0};
        for(auto e : s)
        {
            count[e-'a']++;
        }
        for(int i = 0; i < s.size();i++)
        {
            if(count[s[i] - 'a'] == 1)
            return i;
        }
        return -1;
    }
};

将每一个字符出现的次数存储到大小为26的数组中,找到次数为1的字符。在这里不做过多的赘述。

但我们的哈希表如果使用上述方式实现,必然会造成效率低下。而C++的大佬们,早已实现各种方法的哈希表,让我们来看看。

一.哈希概念

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

1.1 哈希冲突

两个不同的key可能会映射到同⼀个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。

哈希冲突的产生原因

哈希冲突是指不同的键通过哈希函数计算后,得到了相同的哈希值,从而被映射到哈希表中相同的位置。产生哈希冲突的原因主要有以下几点:

(一)哈希函数的局限性

哈希函数的设计至关重要,但再好的哈希函数也无法完全避免冲突。理想情况下,哈希函数应该能够将不同的键均匀地分布到哈希表的各个位置,但实际上,由于键的集合通常是无限的,而哈希表的大小是有限的,这就导致了不同键产生相同哈希值的情况。例如,对于简单的取模哈希函数 hash(key) = key % table_size,当键是 5 和 10,且哈希表大小为 5 时,它们的哈希值都是 0,从而产生冲突。

(二)键的分布特性

键本身的分布特性也会影响哈希冲突的发生。如果键的分布较为集中,即大量键的特征相似,那么它们通过哈希函数计算后更容易产生相同的哈希值。比如,在一个存储用户信息的哈希表中,如果用户 ID 是连续的整数,且哈希表大小较小,那么相邻的用户 ID 很可能产生冲突。

1.2负载因⼦

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

1.3 将关键字转为整数

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

1.4 哈希函数

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

除法散列法/除留余数法

除法散列法是一种基于取模运算的散列函数设计方法。其基本思想是将键值 k 通过取模运算映射到散列表的某个位置。具体来说,散列函数可以表示为:

h(k)= k % m

其中,k 是键值,m 是散列表的大小(即表的长度),h(k) 是计算得到的散列地址。

  1. 取模运算的作用
    取模运算的核心作用是将键值 k 映射到一个较小的范围内,即 [0,m−1]。这样,无论键值 k 有多大,都可以通过取模运算将其"折叠"到散列表的有效索引范围内。
  2. 选择合适的 m
    选择合适的散列表大小 m 对于除法散列法的性能至关重要。理论上,m 应该是一个质数,因为质数可以减少键值之间的相关性,从而降低冲突的概率。例如,如果 m 是 2 的幂(如 16、32、64 等),那么取模运算可以简化为位运算,但这种情况下冲突的概率可能会增加。因此,选择一个质数作为 m 是一个较好的选择。

乘法散列法

乘法散列法的核心思想是通过乘法和位运算将键值映射到散列表的某个位置。其基本步骤如下:

  1. 选择一个常数 A:常数 A 是一个介于 0 和 1 之间的浮点数,通常选择为一个无理数,如黄金分割比 ≈ 0.6180339887。选择无理数的原因是它可以更好地将键值分布到散列表的不同位置,减少冲突的概率。
  2. 计算乘积:将键值 k 与常数 A 相乘,得到一个浮点数 kA。
  3. 提取小数部分:从 kA 中提取小数部分,即 {kA}=kA−⌊kA⌋,其中 ⌊x⌋ 表示对 x 向下取整。
  4. 计算散列地址:将提取的小数部分乘以散列表的大小 m,并向下取整,得到最终的散列地址。具体公式为:h(k)=⌊m⋅{kA}⌋

全域散列法

全域散列法(Universal Hashing)是一种高效的散列函数设计方法,通过随机选择散列函数来减少冲突的概率,从而提高散列表的性能。

P需要选⼀个⾜够⼤的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组。

需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使⽤,后续增删查改都固定使⽤这个散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了。

1.5解决哈希冲突

为了解决哈希冲突,C++ 中主要有以下几种常用的方法

(一)链地址法

链地址法是将所有具有相同哈希值的键存储在一个链表中。每个哈希表的槽位对应一个链表的头指针。当发生冲突时,新的键会被添加到相应链表的末尾。这种方法的优点是实现简单,且能够很好地处理大量冲突的情况。例如,对于上述提到的键 5 和 10 的冲突,它们会被存储在同一个链表中,通过遍历链表可以找到对应的键值对。

(二)开放定址法

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

  1. 线性探测

  2. 二次探测

  3. 双重探测
    .

(三)再哈希法

再哈希法是当发生冲突时,使用另一个哈希函数重新计算哈希值,直到找到空闲位置。这种方法可以有效避免聚集现象,但需要设计两个合适的哈希函数,且计算成本相对较高。例如,第一个哈希函数是 hash1(key) = key % table_size,第二个哈希函数是 hash2(key) = prime - (key % prime),其中 prime 是小于哈希表大小的质数。当发生冲突时,通过 hash(key, i) = (hash1(key) + i * hash2(key)) % table_size 来寻找新的位置,i 是探测次数。

二.哈希表的自我实现

2.1开放地址法的哈希表结构

bash 复制代码
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; // 表中存储数据个数 
};

哈希表的核心思想是利用哈希函数将键(key)映射到一个较小范围的整数,这个整数通常被用作在表中的索引,从而快速定位到对应的值(value)。理想情况下,哈希函数能够将不同的键映射到不同的索引,但在实际应用中,由于键的范围可能远大于表的大小,冲突(即多个键映射到同一个索引)是不可避免的。因此,如何设计哈希函数以及如何解决冲突,是哈希表设计的关键问题。

采用除留余数法,这是一种简单而常见的哈希函数设计方法。对于非字符串类型的键,直接将键的值转换为大小为 size_t 的整数,然后对表的大小取模,得到哈希值。对于字符串类型的键,通过遍历字符串的每个字符,将其累加到一个初始值为 0 的哈希值中,每次累加前将当前哈希值乘以 31(这是一个经验值,用于增加不同字符串产生相同哈希值的难度),最终得到的哈希值再对表的大小取模。这种针对字符串的哈希函数设计能够较好地分散不同字符串的哈希值,减少冲突的可能性。

然而,冲突仍然会发生。在本代码中,采用开放定址法中的线性探测来解决冲突。当发生冲突时,即当前计算出的哈希值对应的表位置已经被占用,就从该位置开始,依次向后探测,直到找到一个空闲的位置(状态为 EMPTY)来存储新的键值对。这种线性探测的方式简单直观,但在大量数据和高负载因子(表中数据量与表大小的比值)的情况下,可能会导致聚集现象,即多个连续的位置被占用,从而降低查找效率。

2.2操作代码实现

(一)HashTable 类的构造函数

cpp 复制代码
HashTable()
{
	_tables.resize(10);
}

在构造函数中,初始化哈希表的大小为 10。这是一个较小的初始大小,随着数据的插入,可能会很快触发扩容操作。在实际应用中,可以根据预计的数据量来选择一个更合适的初始大小,以减少不必要的扩容次数。

(二)Insert 方法

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))
	{
		return false;
	}
	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);
			}
		}
		_table.swap(newht._tables);
	}

	Hash hs;
	size_t hash0 = hs(kv.first) % _table.size();
	size_t hashi = hash0;
	size_t i = 1;
	while (_table[hashi].state == EXIST)
	{
		hashi = hash0 + i;
		i++;
		hashi %= _tables.size();
	}

	_table[hashi]._kv = kv;
	_table[hashi]._state = EXIST;
	++_n;

	return true;
}

在插入操作中,首先检查要插入的键是否已经存在,如果存在则直接返回 false,表示插入失败。接着判断当前负载因子是否达到阈值 0.7,如果达到则进行动态扩容。扩容时,创建一个新的哈希表,并将原表中的所有数据重新插入到新表中。插入数据时,使用线性探测解决冲突,直到找到一个空闲的位置来存储新的键值对。

(三)Find 方法

cpp 复制代码
HashData<K, V>* Find(const K& key)
{
	Hash hs;
	size_t hash0 = hs(key) % _table.size();
	size_t hashi = hash0;
	size_t i = 1;
	while (_table[hashi]._state != EMPTY)
	{
		if (_tables[hashi]._state == EXIST && _table[hashi]._kv.first == key)
		{
			return &_table[hashi];

		}

		hashi = hash0 +;
 i		i++;
		
		hashi %= _table.size();
	}

	return nullptr;
}

查找操作也是基于线性探测。从计算出的初始哈希值开始,依次向后探测,直到找到状态为 EMPTY 的位置或者找到与目标键匹配的键值对。如果找到匹配的键值对,则返回对应的 HashData 指针;否则返回 nullptr

(四)Erase 方法

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

删除操作相对简单。首先通过 Find 方法查找要删除的键,如果找到,则将其状态设置为 DELETE,并将表中的数据个数 _n 减 1。这里需要注意的是,删除操作并不是直接将数据从表中移除,而是将状态标记为 DELETE。这是因为在开放定址法中,删除操作可能会破坏后续线性探测的连续性,导致查找失败。通过标记 DELETE 状态,可以在后续的查找和插入操作中跳过这些被删除的位置。

(五)动态扩容策略

为了保证哈希表的查找效率,需要控制负载因子在一个合理的范围内。在代码中,当负载因子达到 0.7 时,就会触发动态扩容操作。扩容的基本思路是创建一个新的哈希表,其大小为当前表大小的下一个质数(通过 __stl_next_prime 函数计算得到),然后将原表中的所有数据重新插入到新表中。这个过程涉及到重新计算每个键的哈希值,并按照新的表大小进行存储。虽然动态扩容操作会带来一定的性能开销,但它是保证哈希表在大量数据插入时仍能保持高效查找的关键策略。

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

(六)key不能取模的问题

当key是string/Date等类型时,key不能取模,那么我们需要给HashTable增加⼀个仿函数,这个仿函数⽀持把key转换成⼀个可以取模的整形,如果key可以转换为整形并且不容易冲突,那么这个仿函数就⽤默认参数即可,如果这个Key不能转换为整形,我们就需要⾃⼰实现⼀个仿函数传给这个参数,实现这个仿函数的要求就是尽量key的每值都参与到计算中,让不同的key转换出的整形值不同。string做哈希表的key⾮常常⻅,所以我们可以考虑把string特化⼀下。

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

2.3完整代码

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


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 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()
		{
			_tables.resize(10);
		}

		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
			{
				return false;
			}
			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);
					}
				}
				_table.swap(newht._tables);
			}

			Hash hs;
			size_t hash0 = hs(kv.first) % _table.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (_table[hashi].state == EXIST)
			{
				hashi = hash0 + i;
				i++;
				hashi %= _tables.size();
			}

			_table[hashi]._kv = kv;
			_table[hashi]._state = EXIST;
			++_n;

			return true;
		}
		HashData<K, V>* Find(const K& key)
		{
			Hash hs;
			size_t hash0 = hs(key) % _table.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (_table[hashi]._state != EMPTY)
			{
				if (_tables[hashi]._state == EXIST && _table[hashi]._kv.first == key)
				{
					return &_table[hashi];

				}

				hashi = hash0 + i;
				i++;
				
				hashi %= _table.size();
			}

			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;  // 表中存储数据个数
	};
}

2.4重提链地址法

链地址法本质是底层用一个链表,将Hash得出结果相同的值存到一个位置并用链表连接起来。

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

开放定址法负载因⼦必须⼩于1,链地址法的负载因⼦就没有限制了,可以⼤于1。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低;stl中unordered_xxx的最⼤负载因⼦基本控制在1,⼤于1就扩容,我们下⾯实现也使这个⽅式。

在一个链表中元素过多时,为了优化效率,这是会用红黑树将链表代替。

三.实现kv结构

c 复制代码
namespace open_adrress
{

	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 += i * i;
				i++;

				hashi %= _tables.size();
			}

			_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 += i;
				i++;

				hashi %= _tables.size();
			}

			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;   // 实际存储的数据个数
	};

	struct pairHash
	{
		size_t operator()(const pair<int, int>& kv) const
		{
			size_t hash = 0;
			hash += kv.first;
			hash *= 131;
			hash += kv.second;
			hash *= 131;

			cout << hash << endl;
			return hash;
		}
	};
}

主要通过pairHash来使得能够提取到key值。

相关推荐
CodeWithMe4 小时前
【读书笔记】《C++ Software Design》第三章 The Purpose of Design Patterns
c++·设计模式
Thymme5 小时前
C++获取时间和格式化时间
c++
真的想上岸啊5 小时前
学习C++、QT---25(QT中实现QCombobox库的介绍和用QCombobox设置编码和使用编码的讲解)
c++·qt·学习
刃神太酷啦5 小时前
C++ 多态详解:从概念到实现原理----《Hello C++ Wrold!》(14)--(C/C++)
java·c语言·c++·qt·算法·leetcode·面试
山河木马5 小时前
前端学C++可太简单了:引用
前端·javascript·c++
wjm0410066 小时前
C++后端面试八股文
java·c++·面试
越城6 小时前
C++类与对象(上)
开发语言·c++
序属秋秋秋7 小时前
《C++初阶之STL》【泛型编程 + STL简介】
开发语言·c++·笔记·学习
点云SLAM9 小时前
二叉树算法详解和C++代码示例
数据结构·c++·算法·红黑树·二叉树算法