C++:哈希表

哈希

一.哈希的概念与实现方法

1.概念

哈希(Hash)是一种将任意长度的输入(如数据、字符串、对象等)通过哈希函数转换为固定长度输出(哈希值/散列值)的技术。它的核心作用是快速映射、查找和去重,常见于哈希表、密码学(如哈希加密)、数据校验等领域。

2.实现方法

  1. 核心构成与负载因子

    • 基本结构:由哈希桶数组、哈希函数、冲突解决机制组成。哈希桶数组是存储元素的基础容器,哈希函数负责将关键字映射到数组索引,冲突解决机制处理不同关键字映射到同一位置的问题。
    • 扩容机制:当负载因子(元素数量/桶数组大小)超过阈值(如0.7)时,需重新申请更大的桶数组,将原有元素重新哈希并插入新桶,以保证查询、插入等操作的效率。
    • 负载因子特性:负载因子越大,哈希表空间利用率越高,但哈希冲突概率也越高;反之,冲突概率低但空间利用率低。其计算公式为"负载因子=元素数/桶数"。
  2. 关键字处理与哈希函数设计

    • 关键字转换 :哈希映射通常基于整数计算,非整数关键字(如字符串)需先转换为整数(例如字符串通过多项式滚动计算:hash = hash * 131 + ch,利用质数131减少冲突)。
    • 哈希函数要求:必须满足"相同输入必有相同输出",同时尽可能让不同输入的哈希值均匀分散,以减少冲突。
    • 具体哈希函数
      • 除法散列法h(key) = key % M(M为桶数组大小)。需避免M为2的幂或10的幂(易因保留关键字低位导致冲突,如M=16时,63和31的哈希值均为15),建议M选接近但非2的整数次幂的质数。实践中可灵活优化(如Java HashMap用2的幂作为M,结合位运算让关键字所有位参与计算,提升均匀性)。
      • 乘法散列法h(key) = floor(M × ((A × key) % 1.0))(A为0~1的常数,推荐黄金分割点0.618)。对M无特殊要求,通过关键字与A的乘积小数部分映射位置(如key=1234、M=1024时,哈希值为669)。
      • 全域散列法h_ab(key) = ((a × key + b) % P) % M(P为大质数,a、b为随机参数)。通过随机性抵御恶意构造的冲突数据,初始化时固定a、b,确保增删查改使用同一函数。
  3. 哈希冲突解决方法

    • 开放定址法 :所有元素存储在哈希表内,冲突时按规则寻找空闲位置,负载因子必须小于1。
      • 线性探测 :冲突后从当前位置依次向后探测(h_i = (h0 + i) % M,i=1,2,...),实现简单但易产生"群集"(连续冲突位置争夺后续空闲位置)。
      • 二次探测 :冲突后按平方跳跃探测(h_i = (h0 ± i²) % M),减少群集现象,需处理负数索引(如h_i < 0时加M调整)。
    • 链地址法(哈希桶):每个桶数组元素关联链表或红黑树,冲突元素直接挂载到对应链表/树中,无需寻找空闲位置,灵活性更高。此方法建议负载因子控制在1以内。
  4. 实践特点

    • 实际应用中多采用除法散列法作为哈希函数,结合链地址法或开放定址法解决冲突。
    • 通过动态调整负载因子(扩容)和优化哈希函数,平衡空间利用率与操作效率,实现高效的增删查改功能。

二.开放定址法

1.实现逻辑

开放定址法是哈希冲突解决的重要方式,其核心是当关键字通过哈希函数映射的位置发生冲突时,按照特定规则在哈希表内寻找空闲位置存储元素,且所有元素均直接存于哈希表数组中,负载因子需小于1。结合除法散列法与线性探测,来具体理解其机制。

1.1 基础映射:除法散列法确定初始位置

采用除法散列法(除留余数法)计算关键字的初始映射位置,即 h(key) = hash0 = key % M(M为哈希表大小)。

  • 为减少冲突,M的选择需避开2的幂、10的幂等特殊值(这类M会导致哈希值仅由关键字的后几位决定,例如M=16(2⁴)时,63和31的后4位均为1111,哈希值均为15,易冲突),建议选取不接近2的整数次幂的质数。

1.2 冲突解决:线性探测的定位规则

当初始位置hash0已被占用(冲突)时,线性探测通过以下规则寻找下一个空闲位置:

  • 探测公式:h_i(key, i) = (hash0 + i) % M(i=1,2,...,M-1),即从hash0开始依次向后探测,若到达表尾则回绕至表头。
  • 因负载因子小于1,表中必有空闲位置,最多探测M-1次即可找到存储位置。

1.3 特点与问题

  • 优点:实现简单,仅通过连续地址探测即可解决冲突。
  • 缺点 :易产生"群集(堆积)"现象。例如,若hash0hash1hash2已存储元素,后续映射到这些位置及hash3的关键字会集中争夺hash3,导致冲突范围扩大,降低操作效率。(如下图)

2.代码实现

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

namespace yl {
	// 哈希表元素状态:存在/空/已删除(用于开放定址法)
	enum Status
	{
		EXIST,   // 元素存在
		EMPTY,   // 位置为空
		DELETE   // 元素已删除
	};

	// 哈希表存储的节点结构
	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;       // 键值对
		Status _state = EMPTY; // 初始状态为空
	};

	// 哈希函数模板(默认处理整数类型)
	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return (size_t)key; // 整数直接转换为哈希值
		}
	};

	// 哈希函数特化:处理字符串类型
	template<>
	struct HashFunc<string>
	{
		size_t ret = 0;
		size_t operator()(const string& str)
		{
			for (auto& ch : str)
			{
				ret = ret * 131 + ch; // 多项式滚动哈希(131为质数,减少冲突)
			}
			return ret;
		}
	};

	// 哈希表类(开放定址法-线性探测)
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		// 构造函数:初始容量为最小质数
		HashTable()
			:_tables(__stl_next_prime(1))
			, _n(0)
		{
		}

		// 查找下一个大于等于n的质数(用于扩容)
		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* pos = lower_bound(__stl_prime_list, __stl_prime_list + __stl_num_primes, n);
			return pos == __stl_prime_list + __stl_num_primes ? *(pos - 1) : *pos;
		}
cpp 复制代码
		// 插入键值对(不允许重复)
		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> newHT;
				newHT._tables.resize(__stl_next_prime(_tables.size() + 1)); // 新表容量为下一个质数
				// 迁移旧表元素到新表
				for (auto& data : _tables)
				{
					if (data._state == EXIST)
						newHT.Insert(data._kv);
				}
				_tables.swap(newHT._tables); // 交换新旧表
			}

			Hash hs;
			size_t hash0 = hs(kv.first) % _tables.size(); // 初始哈希位置
			size_t hashi = hash0;
			// 线性探测:冲突时依次向后寻找空闲位置
			while (_tables[hashi]._state == EXIST)
			{
				hashi = (hash0 + 1) % _tables.size(); // 线性探测公式
			}
			// 插入元素
			_tables[hashi]._kv = kv;
			_tables[hashi]._state = EXIST;
			++_n;
			return true;
		}

newHT.Insert(data._kv);的巧用

Insert函数的扩容逻辑中,使用newHT.Insert(data._kv)迁移旧元素是一种**"复用逻辑、简化代码"**的巧妙设计。

1. 复用插入逻辑,避免重复编码

扩容时需要将旧表中所有有效元素(state == EXIST)重新插入新表。而Insert函数本身已经实现了:

  • 计算新的哈希位置(基于新表大小)
  • 处理冲突(线性探测)
  • 插入元素并更新状态

若不复用Insert,则需要在扩容时重复编写上述逻辑(如重新计算哈希、探测位置等),不仅代码冗余,还可能因逻辑不一致导致错误(例如哈希计算或冲突处理与Insert不一致)。

通过newHT.Insert(data._kv),直接复用已验证的插入逻辑,确保旧元素在新表中的插入规则与正常插入完全一致,减少了出错概率

2. 自动适配新表容量,无需额外处理

新表的容量是通过__stl_next_prime计算的更大质数,与旧表容量不同。Insert函数在计算哈希位置时依赖当前表的大小(_tables.size()),而newHT_tables已 resize 为新容量,因此:

  • 调用newHT.Insert时,会自动基于新表大小计算哈希位置
  • 线性探测也会基于新表的容量进行(避免超出新表范围)

这种方式无需手动传递新表大小或修改哈希计算逻辑,让代码更简洁,且自动适配新表的参数

总结

这种设计的核心是**"利用已有接口完成新逻辑"**,既保证了代码一致性,又减少了重复开发,是面向对象中"复用"思想的典型体现。缺点是可能带来轻微的性能开销(多次调用Insert),但对于哈希表的使用场景,这种可读性和可维护性的提升通常更重要。

cpp 复制代码
		// 查找键对应的节点
		HashData<K, V>* Find(const K& key)
		{
			Hash hs;
			size_t hash0 = hs(key) % _tables.size(); // 初始哈希位置
			size_t hashi = hash0;
			// 线性探测查找:遇到空位置则停止
			while (_tables[hashi]._state != EMPTY)
			{
				// 找到存在的目标键
				if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
					return &_tables[hashi];
				hashi = (hash0 + 1) % _tables.size(); // 继续探测下一个位置
			}
			return nullptr; // 未找到
		}

		// 删除键对应的元素(标记为DELETE,不实际删除)
		bool Erease(const K& key)
		{
			auto* ptr = Find(key);
			if (ptr)
			{
				ptr->_state = DELETE; // 标记为已删除
				--_n;
				return true;
			}
			return false; // 键不存在
		}

		// 打印哈希表元素(仅打印存在的元素)
		void Print()
		{
			for (auto& e : _tables) {
				if (e._state == EXIST) // 只输出存在的元素
					cout << e._kv.first << ":" << e._kv.second << endl;
			}
			cout << endl;
		}

	private:
		vector<HashData<K, V>> _tables; // 哈希表数组(开放定址法)
		size_t _n = 0;                  // 有效元素个数
	};
}

三.哈希桶

1.哈希桶的结构

上图展示了**链地址法(哈希桶)**的哈希表结构。

1.1 哈希桶数组

由连续的"桶"组成(图中索引0-10的方框),每个桶是哈希表的基本存储单元,用于挂载冲突的元素。

1.2 冲突元素的链式存储

当不同关键字经哈希函数映射到同一桶时,通过链表依次挂载:

  • 桶1:仅存储元素12
  • 桶2:存储24,因冲突挂载13
  • 桶3:仅存储36
  • 桶5:仅存储5
  • 桶8:存储96,因冲突依次挂载3019
  • 桶9:仅存储20
  • 桶10:仅存储21
  • 桶0、4、6、7:无元素,为空桶。

1.3 链地址法的特点

  • 每个桶对应一个链表(或红黑树),冲突元素直接追加到链表尾部,无需像开放定址法那样寻找空闲位置;
  • 空间利用率灵活,负载因子可超过1(图中元素数多于桶数,负载因子>1);
  • 查询时需遍历链表匹配关键字,若链表过长会影响效率,因此实际中可能将链表升级为红黑树(如Java的HashMap)以优化查询速度。

2.代码实现

cpp 复制代码
#pragma once
#include<iostream>
#include<vector>
#include<algorithm>  // 补充lower_bound所需头文件
using namespace std;

namespace yl {
	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;
		HashNode<K, V>* _next;  // 成员变量为_next(带下划线)
		// 构造函数参数改为const引用,避免拷贝
		HashNode(const pair<K, V>& kv)
			:_kv(kv)
			, _next(nullptr)
		{
		}
	};

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

	template<>
	struct HashFunc<string>
	{
		// ret改为局部变量,避免多次调用累积错误
		size_t operator()(const string& str)
		{
			size_t ret = 0;
			for (auto& ch : str)
			{
				ret += (char)ch;
				ret *= 131;
			}
			return ret;
		}
	};

	template<class K, class V,class Hash= HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable()
			:_tables(__stl_next_prime(1), nullptr)  // 初始化桶为nullptr
			, _n(0)
		{}
		~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;
			}
		}

		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;
		}
cpp 复制代码
		bool Insert(const pair<K, V>& kv)
		{
			Hash hs;
			// 检查是否已存在(避免重复插入)
			if (Find(kv.first))
				return false;

			// 扩容条件:负载因子达到1时扩容(可根据需要调整)
			if (_n == _tables.size())
			{
				// 新桶初始化时显式置为nullptr
				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;  // 访问成员变量_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);  // 交换新旧桶
			}

			// 计算插入位置的哈希值
			size_t hashi = hs((kv.first)) % _tables.size();
			Node* newnode = new Node(kv);
			// 头插法插入新节点
			newnode->_next = _tables[hashi];  // 新节点next指向原桶头
			_tables[hashi] = newnode;         // 桶头指向新节点
			++_n;
			return true;
		}

值得注意的点:扩容

在哈希表的Insert函数中,扩容时选择"摘去旧节点放到新表"而非"直接创建新节点再交换",核心原因是避免不必要的内存开销和数据拷贝,提高效率

1. 减少内存分配与释放的开销

  • 若采用"创建新节点"的方式:需要为每个旧节点对应创建一个新节点(拷贝kv数据),然后释放所有旧节点。这会导致双倍的内存操作 (新节点的new和旧节点的delete),尤其是当哈希表中元素较多时,会显著增加时间开销。
  • 而"摘去旧节点"的方式:直接复用原有节点(仅修改节点的_next指针),无需重新分配内存和释放旧内存,仅通过指针调整完成元素迁移,内存操作成本极低。

2. 避免数据拷贝的成本

  • 哈希表中存储的是pair<K, V>类型的键值对,若KV是自定义类型(如字符串、结构体等),拷贝操作可能涉及深拷贝(例如字符串的字符数组复制),成本较高。
  • 复用旧节点时,节点中存储的kv数据无需拷贝,仅通过指针移动即可完成迁移,避免了数据拷贝的开销,尤其适合大数据量场景。

3. 保证指针语义的一致性

  • 哈希表的节点(Node)通常通过指针链接(_next),节点本身是动态分配的独立内存块。迁移时只需调整指针指向,即可将节点从旧桶"摘离"并"挂载"到新桶,操作简单且不易出错。
  • 若重新创建新节点,需要确保新节点的指针关系与旧节点一致(例如链表的顺序),可能引入额外的逻辑复杂度。

总结

"摘去旧节点放到新表"是一种原地复用资源 的优化策略,通过减少内存操作和数据拷贝,显著提升了扩容效率。这是哈希表实现中常见的优化手段,尤其在追求高性能的场景中(如STL的unordered_map)被广泛采用。

cpp 复制代码
		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;  // 访问_next(带下划线)
			}
			return nullptr;
		}

		bool Erase(const K& key)
		{
			Hash hs;
			size_t hashi = hs(key) % _tables.size();
			Node* cur = _tables[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					// 处理头节点删除
					if (!prev)
						_tables[hashi] = cur->_next;  // 桶头指向当前节点的下一个
					else
						prev->_next = cur->_next;      // 前一个节点指向当前节点的下一个

					delete cur;  // 释放节点内存
					--_n;
					return true;
				}
				prev = cur;
				cur = cur->_next;  // 访问_next(带下划线)
			}
			return false;  // 未找到返回false(原代码返回nullptr错误)
		}

		void Print() const  // 加const确保不修改数据
		{
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				cout << "哈希桶[" << i << "]: ";
				Node* cur = _tables[i];  // 从当前桶的头节点开始遍历
				while (cur)
				{
					// 打印键值对,格式为 "key:value"
					cout << cur->_kv.first << ":" << cur->_kv.second << " -> ";
					cur = cur->_next;  // 移动到下一个节点
				}
				cout << "nullptr" << endl;  // 链表结束标志
			}
			cout << endl;
		}

		size_t Size() { return _n; };

	private:
		vector<Node*> _tables;  // 存储链表头指针的桶
		size_t _n = 0;          // 有效元素个数
	};
}
相关推荐
mit6.8242 小时前
[C++] 时间处理库函数 | `tm`、`mktime` 和 `localtime`
开发语言·c++
SweetCode2 小时前
C++ 大数乘法
开发语言·c++
listhi5202 小时前
基于空时阵列最佳旋转角度的卫星导航抗干扰信号处理的完整MATLAB仿真
开发语言·matlab·信号处理
lly2024063 小时前
Kotlin 类和对象
开发语言
是苏浙3 小时前
零基础入门C语言之C语言内存函数
c语言·开发语言
zhmhbest3 小时前
Qt 全球峰会 2025:中国站速递 —— 技术中立,拥抱更大生态
开发语言·qt·系统架构
程序员大雄学编程3 小时前
用Python来学微积分30-微分方程初步
开发语言·python·线性代数·数学·微积分
关于不上作者榜就原神启动那件事3 小时前
模拟算法乒乓球
开发语言·c++·算法
初圣魔门首席弟子3 小时前
C++ STL list 容器学习笔记:双向链表的 “小火车“ 操控指南
c++·windows·笔记·学习