C++进阶——哈希表的实现

目录

1、哈希的概念

[1.1 直接定址法](#1.1 直接定址法)

[1.2 哈希冲突](#1.2 哈希冲突)

[1.3 负载因子](#1.3 负载因子)

[1.4 将关键字转为整数](#1.4 将关键字转为整数)

2、哈希函数

[2.1 除法散列法/除留余数法](#2.1 除法散列法/除留余数法)

[2.2 乘法散列法(了解)](#2.2 乘法散列法(了解))

[2.3 全域散列法(了解)](#2.3 全域散列法(了解))

[2.4 其他方法(了解)](#2.4 其他方法(了解))

3、处理哈希冲突

[3.1 开放定址法](#3.1 开放定址法)

[3.1.1 线性探测](#3.1.1 线性探测)

[3.1.2 二次探测](#3.1.2 二次探测)

[3.1.3 双重探测(了解)](#3.1.3 双重探测(了解))

[3.2 开放地址法的代码实现](#3.2 开放地址法的代码实现)

[3.3 链地址法](#3.3 链地址法)

[3.4 连地址法的代码实现](#3.4 连地址法的代码实现)

4、测试代码

[4.1 Test.cpp](#4.1 Test.cpp)


1、哈希的概念

哈希 (hash ), 又称散列 ,是一种高效的数据组织方式。其核心思想是通过哈希函数 建立关键字(Key)存储位置之间的映射关系,从而实现快速查找。

cpp 复制代码
Key  →  [哈希函数]  →  存储位置

1.1 直接定址法

关键字的范围比较集中时,直接定址法是一种非常简单高效的方法。例如:

  1. 如果一组关键字都在 [0,99] 之间,那么我们可以开一个包含100个数的数组,每个关键字的值直接作为存储位置的下标。
  2. 如果一组关键字值都在 [a,z] 的小写字母范围内,那么我们可以开一个包含26个数的数组,每个关键字的 ASCII 码减去 'a' 的 ASCII 码,结果就是存储位置的下标。

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 将关键字转为整数

我们将关键字映射到数组中的位置,通常使用整数 便于映射计算 。如果关键字不是整数 ,需要先将其转换为整数

2、哈希函数

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

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

当使用除法散列法时,要尽量避免 M 为 某些值,如2 的幂10 的幂 等。如果是2^X ,那么 key % M 本质相当于保留 key 的后 X 位 ,那么后 X 位相同的值计算出的哈希值都是一样的 ,就冲突 了。如:{63, 31} 看起来没有关联的值,如果 M 是 16,也就是 2^4,那么计算出的哈希值都是 15,因为 63 的二进制后 8 位是 00111111,31 的二进制后 8 位是 00011111。如果是 10^X ,就更明显了,保留 的都是 10 进制的后 X 位,如:{112, 12312},如果 M 是 100,也就是 10^2,那么计算出的哈希值都是 12。

当使用除法散列法 时,一般建议 M 取不太接近 2 的整数次幂的 一个质数(素数)。

注意:哈希表大小 M 是HashTable.size(),因为%size,就放到[0,size-1)

Java 的 HashMap 为什么用 2 的幂次?(了解)

可以位运算 ,相对而言位运算比模更高效一些 。比如 M 是 2^16 次方 ,本质是取后 16 位 ,那么用 key' = key >> 16 ,然后把 key 和 key' 异或 的结果作为哈希值映射出的值 还是在 [0, M) 范围内,但是尽量让 key 所有的位都参与计算 ,这样映射出的哈希值更均匀一些

2.2 乘法散列法(了解)

乘法散列法哈希表大小 M 没有要求

他的大思路第一步:key * A (0 < A < 1)。第二步:再用 M 乘以 key * A 的小数部分,再向下取整。

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,A * key = 762.6539420558,取小数部分为 0.6539420558,M × ((A × key) % 1.0) = 0.6539420558 * 1024 = 669.6366651392,那么 h(1234) = 669。

2.3 全域散列法(了解)

如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列

h_ab(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,则 h_34(8) = ((3 × 8 + 4) % 17) % 6 = 5。

需要注意的是每次初始化哈希表时随机选取全域散列函数组中的一个散列函数使用 ,后续增删查改都固定使用这个散列函数

2.4 其他方法(了解)

上面的几种方法是《算法导论》书籍中讲解的方法。

《殷人昆 数据结构:用面向对象方法与C++语言描述(第二版)》和《数据结构(C语言版) 严蔚敏 吴伟民》等教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景,有兴趣可以去看看这些书籍。

3、处理哈希冲突

3.1 开放定址法

3.1.1 线性探测

h(key) = hash0 = key % M

若hash0位置冲突 了,就向后走 ,hc(key,i) = hashi = (hash0 + i) % Mi = {1,2,3,...,M −1},

3.1.2 二次探测

h(key) = hash0 = key % M

若hash0位置冲突 了,就前后跳跃走 ,hc(key,i) = hashi = (hash0 ± i^2 ) % Mi = {1,2,3,..., M/2}

hashi<0 时,需要 hashi+=M

3.1.3 双重探测(了解)

h1(key) = hash0 = key % M

若hash0位置冲突 了,hc(key,i) = hashi = (hash0 + i∗h2(key) ) % Mi = {1,2,3,..., M}

要求 h2(key) < M 且 h(key)与M互质。

两种简单取值方法:

  1. M2 的整数幂 时,h₂(key) 可以随机选 [0, M-1] 之间的任意奇数

  2. M质数 时,取 h2(key) = key % (M −1) + 1

3.2 开放地址法的代码实现

  1. 开放定址法实践中不如 下面链地址法 ,所以我们简单选择线性探测实现即可

  2. 这里需要给每个存储值的位置加一个状态标识**{EXIST,EMPTY,DELETE}**,否则真的删除一些值以后,会影响后面冲突的值的查找。

  3. 开放定址法相互占位置负载因子一定<1 。一般负载因子 >=0.7扩容 ,扩容使用sgi版本的方法 ,给了一个近似2倍的质数表,每次去质数表获取扩容后的大小。

  4. 不是整数的key通过仿函数转成整数

cpp 复制代码
#pragma once

#include <iostream>
#include <vector>

using namespace std;

namespace Lzc
{
	inline size_t __stl_next_prime(size_t n) {
		static const int __stl_num_primes = 28;
		static const size_t __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 size_t* first = __stl_prime_list;
		const size_t* last = __stl_prime_list + __stl_num_primes;
		const size_t* pos = lower_bound(first, last, n);

		return pos == last ? *(last - 1) : *pos;
	}

	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};

	template<>
	struct HashFunc<string>
	{
		// 字符串转换成整形,可以把字符ASCII码相加即可
		// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
		// 这里使用BKDR哈希,用上次的计算结果去乘以一个质数,
		// 这个质数一般取31, 131等效果会比较好
		size_t operator()(const string& key)
		{
			size_t hash = 0;
			for (auto& ch : key)
			{
				hash = hash * 131 + ch;
			}
			return hash;
		}
	};

	namespace open_address
	{

		enum State
		{
			EMPTY,
			EXIST,
			DELETE
		};

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

			HashData()
				:_kv()
				,_state(EMPTY)
			{ }
		};

		template<class K, class V,class Hash = HashFunc<K>>
		class HashTable
		{
		public:
			HashTable()
				:_table(__stl_next_prime(0), HashData<K,V>())
				, _n(0)
			{ }
			Hash hash;
			bool Insert(const pair<K, V>& kv)
			{
				if (Find(kv.first))
					return false;
				if (_n >= _table.size() * MAX_LOAD_FACTOR)
				{
					vector<HashData<K,V>> newtable(__stl_next_prime(_table.size() + 1), HashData<K, V>());
					for (size_t i = 0; i < _table.size(); ++i)
					{
						if (_table[i]._state == EXIST)
						{
							size_t hash0 = hash(_table[i]._kv.first) % newtable.size();
							size_t hashi = hash0;
							int j = 1;
							while (newtable[hashi]._state == EXIST)
							{
								hashi = (hash0 + j) % newtable.size();
								++j;
							}
							newtable[hashi] = _table[i];
						}
					}
					_table.swap(newtable);
				}

				size_t hash0 = hash(kv.first) % _table.size();
				size_t hashi = hash0;
				int i = 1;
				while (_table[hashi]._state == EXIST)
				{
					hashi = (hash0 + i) % _table.size();
					++i;
				}

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

				return true;
			}

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

				return nullptr;
			}

			bool Erase(const K& key)
			{
				HashData<K,V>* data = Find(key);
				if (data)
				{
					data->_state = DELETE;
					return true;
				}
				else
					return false;
			}

		private:
			static constexpr double MAX_LOAD_FACTOR = 0.7; // 负载因子
			vector<HashData<K,V>> _table;
			size_t _n;
		};
	}
}

3.3 链地址法

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

下面演示{19,30,5,36,13,20,21,12,24,96} 这一组值映射到M=11的表中。

3.4 连地址法的代码实现

  1. 链地址法的负载因子就没有限制STL中 unordered_xxx的负载因子>=1扩容,我们也使用这个方式。

  2. 极端场景,如果被针对,哈希函数使用全域散列法,如果不是被针对,在Java8HashMap 中当桶的长度超过一定阀值(8) 时就把链表转换成红黑树。下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解一下。

cpp 复制代码
#pragma once

#include <iostream>
#include <vector>

using namespace std;

namespace Lzc
{
	inline size_t __stl_next_prime(size_t n) {
		static const int __stl_num_primes = 28;
		static const size_t __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 size_t* first = __stl_prime_list;
		const size_t* last = __stl_prime_list + __stl_num_primes;
		const size_t* pos = lower_bound(first, last, n);

		return pos == last ? *(last - 1) : *pos;
	}

	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};

	template<>
	struct HashFunc<string>
	{
		// 字符串转换成整形,可以把字符ASCII码相加即可
		// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
		// 这里使用BKDR哈希,用上次的计算结果去乘以一个质数,
		// 这个质数一般取31, 131等效果会比较好
		size_t operator()(const string& key)
		{
			size_t hash = 0;
			for (auto& ch : key)
			{
				hash = hash * 131 + ch;
			}
			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()
				:_table(__stl_next_prime(0), nullptr)
				, _n(0)
			{
			}

			~HashTable()
			{
				for (size_t i = 0; i < _table.size(); ++i)
				{
					if (_table[i])
					{
						Node* cur = _table[i];
						while (cur)
						{
							Node* next = cur->_next;
							delete cur;

							cur = next;
						}
					}
					_table[i] = nullptr;
				}
				_n = 0;
			}

			Hash hash;

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

                // 负载因子>=1扩容
				if (_n >= _table.size())
				{
					vector<Node*> newtable(__stl_next_prime(_table.size() + 1), nullptr);
					for (size_t i = 0; i < _table.size(); ++i)
					{
						if (_table[i])
						{
							Node* cur = _table[i];
							while (cur)
							{
								Node* next = cur->_next;

								size_t hash0 = hash(cur->_kv.first) % newtable.size();
								cur->_next = newtable[hash0]; // 头插
								newtable[hash0] = cur;

								cur = next;
							}
						}
						_table[i] = nullptr;
					}
					_table.swap(newtable);
				}

				size_t hash0 = hash(kv.first) % _table.size();
				Node* newnode = new Node(kv);
				newnode->_next = _table[hash0];// 头插
				_table[hash0] = newnode;
				++_n;

				return true;
			}

			Node* Find(const K& key)
			{
				size_t hash0 = hash(key) % _table.size();
				Node* cur = _table[hash0];
				while (cur)
				{
					if (cur->_kv.first == key)
						return cur;
					cur = cur->_next;
				}
				return nullptr;
			}

			bool Erase(const K& key)
			{
				size_t hash0 = hash(key) % _table.size();
				Node* prev = nullptr;
				Node* cur = _table[hash0];
				while (cur)
				{
					if (cur->_kv.first == key)
					{
						if (prev)
							prev->_next = cur->_next;
						else
							_table[hash0] = cur->_next;
						delete cur;
						--_n;

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

		private:
			vector<Node*> _table;
			size_t _n;
		};
	}
}

4、测试代码

4.1 Test.cpp

cpp 复制代码
#include "HashTable.h"
#include <assert.h>

void TestInsertAndFind()
{
    //Lzc::open_address::HashTable<int, string> table;
    Lzc::hash_bucket::HashTable<int, string> table;
    table.Insert({ 1, "one" });
    table.Insert({ 2, "two" });
    table.Insert({ 3, "three" });

    auto* data1 = table.Find(1);
    auto* data2 = table.Find(2);
    auto* data3 = table.Find(3);
    auto* data4 = table.Find(4);  // 不存在的键

    assert(data1 != nullptr && data1->_kv.second == "one");
    assert(data2 != nullptr && data2->_kv.second == "two");
    assert(data3 != nullptr && data3->_kv.second == "three");
    assert(data4 == nullptr);  // 应返回 nullptr
    cout << "TestInsertAndFind passed." << endl;
}

void TestErase()
{
    //Lzc::open_address::HashTable<int, string> table;
    Lzc::hash_bucket::HashTable<int, string> table;
    table.Insert({ 1, "one" });
    table.Insert({ 2, "two" });

    bool Erased1 = table.Erase(1);
    bool Erased2 = table.Erase(3);  // 不存在的键

    assert(Erased1 == true);
    assert(Erased2 == false);
    assert(table.Find(1) == nullptr);  // 应已被删除
    cout << "TestErase passed." << endl;
}

void TestHashCollision()
{
    // 使用固定大小的哈希表(如大小为 3)强制触发冲突
    //Lzc::open_address::HashTable<int, string> table;
    Lzc::hash_bucket::HashTable<int, string> table;
    table.Insert({ 1, "one" });
    table.Insert({ 4, "four" });  // 假设 1 和 4 哈希冲突

    auto* data1 = table.Find(1);
    auto* data4 = table.Find(4);

    assert(data1 != nullptr && data1->_kv.second == "one");
    assert(data4 != nullptr && data4->_kv.second == "four");
    cout << "TestHashCollision passed." << endl;
}

void TestReuseAfterErase()
{
    //Lzc::open_address::HashTable<int, string> table;
    Lzc::hash_bucket::HashTable<int, string> table;
    table.Insert({ 1, "one" });
    table.Erase(1);
    table.Insert({ 1, "new_one" });  // 应复用被删除的位置

    auto* data = table.Find(1);
    assert(data != nullptr && data->_kv.second == "new_one");
    cout << "TestReuseAfterErase passed." << endl;
}

void TestResize()
{
    //Lzc::open_address::HashTable<int, int> table;
    Lzc::hash_bucket::HashTable<int, int> table;

    // 记录插入前的状态(无法直接获取 size,只能通过行为推断)
    // 插入足够多的元素触发扩容(初始大小为 __stl_next_prime(0) = 53)
    // 负载因子 0.7,所以触发扩容的临界值是 53 * 0.7 ≈ 37 个元素
    const int trigger_count = 38;  // 确保超过负载因子

    // 插入元素并验证
    for (int i = 0; i < trigger_count; ++i) {
        bool success = table.Insert({ i, i * 10 });
        assert(success == true);
    }

    // 验证所有插入的元素仍然可找到
    for (int i = 0; i < trigger_count; ++i) {
        auto* data = table.Find(i);
        assert(data != nullptr && data->_kv.second == i * 10);
    }

    cout << "TestResize passed." << endl;
}

void TestStringKey()
{
    //Lzc::open_address::HashTable<string, int> table;
    Lzc::hash_bucket::HashTable<string, int> table;
    table.Insert({ "apple", 1 });
    table.Insert({ "banana", 2 });

    assert(table.Find("apple")->_kv.second == 1);
    assert(table.Find("banana")->_kv.second == 2);
    assert(table.Find("orange") == nullptr);
    cout << "TestStringKey passed." << endl;
}

void TestEmptyTable()
{
    //Lzc::open_address::HashTable<int, int> table;
    Lzc::hash_bucket::HashTable<int, int> table;
    assert(table.Find(1) == nullptr);
    assert(table.Erase(1) == false);
    cout << "TestEmptyTable passed." << endl;
}

void TestDuplicateInsert()
{
    //Lzc::open_address::HashTable<int, string> table;
    Lzc::open_address::HashTable<int, string> table;
    table.Insert({ 1, "one" });
    bool success = table.Insert({ 1, "new_one" });  // 重复插入

    assert(success == false);  // 应返回 false(或 true,取决于你的设计)
    assert(table.Find(1)->_kv.second == "one");  // 值未被覆盖
    cout << "TestDuplicateInsert passed." << endl;
}

void RunAllTests()
{
    TestInsertAndFind();
    TestErase();
    TestHashCollision();
    TestReuseAfterErase();
    TestResize();
    TestStringKey();
    TestEmptyTable();
    TestDuplicateInsert();
    cout << "All tests passed!" << endl;
}

int main()
{
    RunAllTests();
    return 0;
}
相关推荐
努力学习的小廉1 小时前
【C++】 —— 笔试刷题day_9
开发语言·c++·代理模式
m0_687399841 小时前
C++ QT defined a CustomTreeView, 可选择多个node,并且可拖动这些node,改变父节点
java·c++·qt
UestcXiye1 小时前
《TCP/IP网络编程》学习笔记 | Chapter 22:重叠 I/O 模型
c++·计算机网络·网络编程·ip·tcp
muzi_liii1 小时前
封装红黑树模拟实现map和set
c++
__XYZ2 小时前
Vala 编程语言教程-继承
c语言·开发语言·c++·c#
郭涤生2 小时前
Chapter 3: Programming Paradigms_《clean architecture》notes
java·开发语言·c++·笔记
米芝鱼2 小时前
LearnOpenGL(九)自定义转换类
开发语言·c++·算法·游戏·图形渲染·shader·opengl
rigidwill6664 小时前
LeetCode hot 100—LRU缓存
数据结构·c++·算法·leetcode·缓存
Blue.ztl4 小时前
STL入门
开发语言·c++
小凡子空白在线学习5 小时前
c++some
java·开发语言·c++