O (1) 查找神技!哈希表核心玩法全解析,闭散列 + 开散列实战代码直接抄

「C++ 40 周年」主题征文大赛(有机会与C++之父现场交流!) 10w+人浏览 461人参与


个人主页

🎬 个人主页Vect个人主页

🎬 GitHubVect的代码仓库

🔥 个人专栏: 《数据结构与算法》《C++学习之旅》《计算机基础

⛺️Per aspera ad astra.


1. 哈希概念引入

对于之前的学习,顺序结构以及平衡树,元素的关键码和其存储位置之间没有对应的关系,所以,查找一个元素时,必须要经过多次比较顺序查找时间复杂度为 O ( n ) O(n) O(n),平衡树为树的高度, O ( l o g n ) O(logn) O(logn) ,搜索的效率取决于比较次数。
哈希确立了一种新的思想:不经过任何比较,依次直接从表中得到想要的搜索结果–>映射,那么就可以设计一种存储结构,通过某种方式时元素的存储位置和关键码之间能够建立一一映射的关系,那么在查找时通过改方法就能很快找到改元素。
由此我们引出了哈希表:值和存储位置建立映射关系的数据结构

哈希方法中使用的转换函数称为哈希函数,构造出来的结构称为哈希表(Hash Table)

eg:{10,24,38,17,66,15}这个数据集合
哈希函数设置为:hashIdx(key) = key % capacity,capacity是存储元素底层空间的大小
在这里插入图片描述

用这个方式查找,不用多次比较,效率为 O ( 1 ) O(1) O(1)
但是,按照上述方法,向列表中插入3535存到哪里?

2. 哈希冲突

紧接上文,对于两个数据元素的关键字字 k i k_i ki k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==
Hash( k j k_j kj),即是:不同关键字通过相同的哈希函数计算出了相同的映射地址,出现了哈希冲突
该如何处理?

3. 哈希函数

引起哈希冲突的一个重要原因:哈希函数设计不够合理
以下是常见的哈希函数:

3.1. 直接定址法

取关键字的某个线性函数为哈希地址:hashIdx(key) = A*key + B

  • 优点:简单均匀、没有冲突
  • 缺点: 需要提前知道关键字分布情况,只适合查找比较小且连续的情况
    比如这个数据集{10,24,38,17,66,15},完全可以映射到0~9这个区间,但是出现10000这样跨度巨大的数,就很难处理了

3.2. 除留余数法(常用)

本文也只介绍这一种方法
设哈希表中允许的地址数为m,取一个不大于m,但是最接近或者等于m的指数p作为除数,按照哈希函数:hashIdx(key) = key % p (p<=m),将关键码转成哈希地址

4. 解决哈希冲突

4.1. 闭散列(开放定址法)

发生哈希冲突时,如果哈希表未被填满,说明在哈希表中还有空位置,那么就可以把key存在冲突位置的下一个空位置去,如何寻找空位置?

4.1.1. 线性探测

比如1结尾提出的问题,现在需要插入35,经过计算,映射的位置为索引5,但是已经存放了15
从发生冲突的位置开始,依次向后探测,知道找到下一个空位为止

  • 插入
    • 通过哈希函数获取待插入元素在哈希表中的映射位置
    • 如果该位置为空,直接插入新元素,如果该位置不为空,使用线性探测找到下一个空位置,插入新元素
      线性探测
  • 删除
    采用闭散列处理哈希冲突,不能随便删除哈希表中已有的元素,若直接删除会影响其他元素搜索
    比如说:删除17之后,查找35
    在这里插入图片描述
    所以,线性探测需要采用标记的方式来伪删除一个元素
enum STATE{ EMPTY, DELETE, EXIST };

4.1.2. 线性探测的实现

// 这里采用KV模型实现
template<class K, class V>
struct HashDate{
	pair<K,V>& _kv;
	STATE _state = EMPTY;
};

template<class K, class V>
class HashTable{
private:
	vector<HashData<K,V>> _tables;
	size_t _nums;
public:
	HashTable() { _tables.resize(10); }

// 插入
bool Insert(const pair<K, ValueType>& kv) {
	// 1. 查重
	if (Find(kv.first)) return false;
	// 2. 扩容
	// 创建新的哈希表 复用Insert后面部分的逻辑 完成旧数据映射到新表
	if (_nums * 10 / _tables.size() >= 7) {
		HashTable<K, ValueType> newHT;
		newHT._tables.resize(2 * _tables.size());
		for (size_t i = 0; i < _tables.size(); i++)
		{
			if (_tables[i]._state == EXIST) {
				newHT.Insert(_tables[i]._kv);
			}
		}
		_tables.swap(newHT._tables);
	}

	// 3. 插入
	size_t hashIdx = kv.first % _tables.size();
	while (_tables[hashIdx]._state == EXIST) {
		++hashIdx;
		hashIdx %= _tables.size();
	}
	_tables[hashIdx]._kv = kv;
	_tables[hashIdx]._state = EXIST;
	++_nums;

	return true;
}

// 查找 越过EMPTY 且保证EXIST
HashData<K,ValueType>* Find(const K& key) {
	size_t hashIdx = key % _tables.size();

	while (_tables[hashIdx]._state != EMPTY) {
		if (_tables[hashIdx]._state == EXIST
			&& _tables[hashIdx]._kv.first == key)
			return &_tables[hashIdx];
		++hashIdx;
		hashIdx %= _tables.size();
	}
	return nullptr;
}

// 删除 调用Find 
bool Erase(const K& key) {
	HashData<K, ValueType>* ret = Find(key);
	if (ret == nullptr) return false;
	else {
		ret->_state = DELETE;
		return true;
	}
}
};

注意看代码中的扩容逻辑:什么情况下扩容?怎么扩容?
负载因子 = 填入表中元素个数 / 哈希表的长度
负载因子越大,产生冲突可能性就越大,反之就越小。
这里规定负载因子>= 0.7时,就扩容
而扩容则是开一张新的哈希表,将旧表中的元素重新映射到新表(这里可以复用Insert后半部分逻辑,不用重复书写),最后交换新旧表即可

4.2. 开散列(链地址法)

对关键码集合用哈希函数计算出哈希地址,具有相同哈希地址的关键码归于同一个集合,每一个子集合称为一个桶,各个桶中的元素通过单链表链接起来,链表的头节点存在哈希表中,如下图所示:
在这里插入图片描述

4.2.1. 开散列的实现

template<class K, class ValueType>
struct HashNode {
	HashNode<K, ValueType>* _next;
	pair < K, ValueType> _kv;

	HashNode(const pair<K,ValueType>& kv)
		:_next(nullptr)
		,_kv(kv)
	{ }
};

template<class K, class ValueType, class Hash = HashFunc<K>>
class HashTable {
	typedef HashNode<K, ValueType> Node;
private:
	vector<Node*> _tables;
	size_t _nums = 0;
public:
	HashTable() { _tables.resize(10); }
	~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, ValueType>& kv) {
		// 1. 查重
		Node* ret = Find(kv.first);
		if (ret) return false;

		// 2. 扩容
		// 扩容逻辑: 建一张新的vector表 把旧表的节点搬运到新表 自己控制新表映射位置
		// 为什么不复用Insert?
		// 会delete n个节点 new n个节点 低效
		size_t hashIdx = kv.first % _tables.size();
		if (_nums == _tables.size()) {
			vector<Node*> newTables(2 * _tables.size(), nullptr);
			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur) {
					Node* next = cur->_next;
					// 旧表节点映射到新表
					hashIdx = hs(cur->_kv.first) % newTables.size();
					// 头插
					cur->_next = newTables[i];
					newTables[i] = cur;

					cur = next;
				}
				_tables[i] = nullptr;
			}
			_tables.swap(newTables);
		}

		// 3. 头插
		hashIdx = kv.first % _tables.size();
		Node* newNode = new Node(kv);
		newNode->_next = _tables[hashIdx];
		_tables[hashIdx] = newNode;
		++_nums;

		return true;
	}

	Node* Find(const K& key) {
		size_t hashIdx = key % _tables.size();

		Node* cur = _tables[hashIdx];
		while (cur) {
			if (cur->_kv.first == key) {
				return cur;
			}

			cur = cur->_next;
		}
		return nullptr;
	}

	bool Erase(const K& key) {
		size_t hashIdx = key % _tables.size();

		Node* cur = _tables[hashIdx];
		Node* prev = nullptr;
		while (cur) {
			if (cur->_kv.first == key) {
				if (prev == nullptr) {
					_tables[hashIdx] = cur->_next;
				}
				else {
					prev->_next = cur->_next;
				}
				delete cur;
				--_nums;
				return true;
			}
			prev = cur;
			cur = cur->_next;
		}
		return false;
	}

注意看扩容逻辑:
桶的个数是一定的,随着元素不断插入,每个桶中的元素个数不断增多,极端情况下,一个桶中的节点会非常多,影响整个哈希表的性能,因此需要扩容---->最好的情况是:每个桶刚好挂一个节点,再次插入元素时,每次都会发生冲突,因此,在元素个数刚好等于桶的个数时,可以扩容
而此时,我们需要新开的是vector表了,我们需要扩容桶,不能复用Insert后半部分,因为如果复用会删除和新增相同数量的节点,效率极低,而我们自己实现映射到新表的逻辑也不复杂,直接将旧表的节点搬运到新表即可

4.2.2. 开散列的优化

只能存储key为整型的元素,其他类型怎么解决?
我们哈希函数采用的是除留余数法,被模的key必须是整型才能处理,所以需要提供将key转成整型的方法:

template<class K>
struct HashFunc {
	size_t operator()(const K& key) {
		return (size_t)key;
	}
};
template<>
struct HashFunc<string> {
	size_t operator()(const string& s) {
		size_t hash = 0;
		for (const auto& e : s) {
			hash *= 31;
			hash += e;
		}
		return hash;
	}
};

5. 总结

大家可以根据以下内容进行回顾复习

  1. 哈希表核心思想: 通过哈希函数建立关键字与存储位置的一一映射,实现无需比较的直接查找,理想时间复杂度为 O ( 1 ) O (1) O(1),突破传统顺序结构与平衡树依赖比较的查找局限。
  2. 哈希冲突定义: 不同关键字通过同一哈希函数计算得到相同映射地址,是哈希表设计中的核心问题,与哈希函数合理性直接相关。
  3. 哈希函数设计 除留余数法,通过hashIdx(key) = key % p(p 为不大于哈希表地址数 m 的质数)将关键字转换为哈希地址;直接定址法,仅适用于关键字分布集中的场景。
  4. 冲突解决方案:
    • 闭散列(开放定址法 - 线性探测):冲突时从当前位置向后探测空位置插入,删除需采用 “伪删除” 标记(DELETE 状态)避免影响查找,负载因子≥0.7 时触发扩容,通过新旧表元素重新映射实现扩容。
    • 开散列(链地址法):相同哈希地址的元素构成单链表(桶),元素插入采用头插法,删除时直接操作链表节点,元素个数等于桶数时扩容,通过搬运旧表节点至新桶实现,避免节点重复创建与删除以提升效率。
  5. 关键优化技巧: 哈希函数特化(如字符串转整型)适配非整型关键字,确保除留余数法的通用性,满足不同数据类型的存储需求。
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值