【数据结构进阶】哈希表

news2025/2/28 21:24:45
🌟🌟作者主页:ephemerals__
🌟🌟所属专栏:数据结构

目录

前言

一、哈希表的概念

二、哈希函数的实现方法

1. 直接定址法

2. 除留余数法

三、哈希冲突 

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

线性探测

二次探测

双重探测

2. 链地址法(开散列)

四、装填因子

五、哈希表的实现

1. 线性探测法实现

整形转化的支持与字符串哈希

初始大小与扩容策略

哈希表结构定义

构造函数

析构函数

哈希表的插入

哈希表的查找

哈希表的删除

总代码

2. 链地址法实现

整形转化支持、大小设置

哈希表结构定义

构造函数

析构函数

哈希表的插入

哈希表的查找

哈希表的删除

总代码

总结


前言

        之前我们学习了红黑树,其虽然通过自平衡机制提供了高效的查找、插入和删除操作,但其操作仍依赖于树的高度,最坏情况下的时间复杂度为O(log n)。当我们希望能够实现常数时间复杂度的查找操作时,哈希表便成为了一个更优的选择。哈希表虽然不再像红黑树的元素一样默认有序,但其通过 “ 哈希 ” 将数据进行映射,从而在理想情况下实现O(1)时间复杂度的操作,在某些需要高效查找的场景非常实用。本篇文章,我们将深入学习哈希表的相关知识,并尝试实现。

一、哈希表的概念

        哈希表(Hash Table),也叫做散列表,是一种特殊的数据结构。其通过一定的规则将数据进行 “ 散乱排列 ” , 并通过该规则进行快速查找。这个规则叫做 “ 哈希函数 ” ,其可以将数据转化为一个固定的索引值,后续将该数据根据索引值存储在数组的某个位置。当进行查找时,直接通过 “ 哈希函数 ” 求出数据的索引值,理想状态下就能在常数时间内完成查找。

二、哈希函数的实现方法

        在理想状态下,哈希函数会将数据均匀散列分布在哈希表当中。为了尽量逼近这个理想状态,先辈们发明了多种哈希函数的实现方法,如直接定址法、除留余数法、乘法散列法、全域散列法等。其中直接定址法和除留余数法最为常用,我们重点介绍一下。

1. 直接定址法

         当数据的范围比较集中时,优先考虑使用直接定址法。例如有一组数据的范围在0~99之间,那么我们开一个大小为100的数组,然后将数据按照数组下标来存储。比如这组数据当中有3个“6”,那么数组下标为6处的值就是3。

        那么当这组数据的下限非0呢?处理方法也很简单。例如这组数据的范围是26个小写英文字母,ascii值在97~122之间。我们开一个大小为26的数组,然后每一个字母对应存储位置的下标即为其ascii值减去'a'的ascii值,这样就可以进行存储了。

        当然直接定址法的缺陷也很明显:当数据比较分散且范围很大时,会造成空间浪费

2. 除留余数法

        除留余数法(也叫做除法散列法),是哈希函数最常用的实现方式。它的核心思想是将数据对某个数(一般是表长)进行求余运算,将运算结果作为索引值,即表示该数据存放的下标位置。假设某个哈希表的大小为M,那么哈希函数为:hash(x) = x % M

如图所示,各个元素通过除留余数法存储在哈希表当中:

注意以下两点:

1. 为了尽量减少哈希冲突出现的次数,建议M取不太接近2的整数次幂的一个素数

2. 除留余数法中,为了方便数据转化为索引值,要求哈希表的数据元素支持转换为整数

三、哈希冲突 

        当一个数据通过哈希函数产生了与另一个数据相同的哈希值,那么也就意味着它们需要存储在同一个位置,显然无法满足。此时的情况就叫做哈希冲突(哈希碰撞)。理想情况下,数据会均匀分布在哈希表中,但是实际场景当中,哈希冲突是无法避免的。所以我们要有适当的方案处理哈希冲突

处理哈希冲突的方法主要有开放定址法、链地址法、再哈希法和位图法等,这里只介绍最常用的前两种

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

        开放定址法是按照某种策略将冲突的数据放置在表中的另一个空位当中。常见的策略有三种:线性探测、二次探测和双重探测

线性探测

        线性探测指的是从冲突位置开始,依次向后一个一个查找,直到找到空位,再将冲突元素放置在当前空位。 当查找到表尾还没有找到空位,那么从表头位置开始重新查找

        线性探测的缺点是容易造成表中的冲突数据聚集,且会占用其他元素的原本位置,从而影响效率。

二次探测

        二次探测对线性探测进行了优化。它的查找方式是依次左右按照二次方跳跃式探测。例如先探测冲突位置的右边 1^{2} 个位置,然后左边 1^{2} 个位置,右边 2^{2} 个位置,左边 2^{2} 个位置......依次类推。

        二次探测有效地减少了数据聚集问题。

双重探测

        双重探测指的是使用另一个哈希函数计算探测步长,使探测序列更加分散。

2. 链地址法(开散列)

        链地址法,也叫做拉链法,它改变了传统的哈希表元素储存策略。在链地址法当中,所有数据不再直接存储在哈希表当中,而是将哈希表的每一个元素设置为指针,用于维护一个链表。此时哈希表的每一个元素就叫做哈希桶。当某个元素位置不存储元素时,该指针为空;若有元素需要存储在该位置,则在其维护的链表中添加该元素。因此,一条链表中存储的元素互相之间就是发生哈希冲突的。

链地址法在发生冲突时,就不会占用其他元素的原本位置相比开放定址法效率较高。但当某个位置的冲突元素过多时,链表过长,会使查找效率接近O(N),此时可以考虑将链表换为红黑树

Java8以及之后的HashMap实现当中,就采用了如上优化:当某个哈希桶维护的链表长度超过8时,将该链表换成红黑树,提升总体性能

四、装填因子

        装填因子(负载因子)是一个数值,表示已存储元素与哈希表容量的比值当装填因子过大时,哈希表的空间利用率就越高,发生哈希冲突的概率就越高,哈希表的总体性能就越低

        在实际实现当中,为了保证哈希表的高效,我们将装填因子作为哈希表是否需要扩容的标准当装填因子到达一定阈值时,哈希表就需要扩容。若哈希表使用开放定址法处理冲突,那么该阈值通常在0.7~0.8之间;若使用链地址法,则该阈值通常为1。

五、哈希表的实现

        接下来我们将选用除留余数法的哈希函数,并用线性探测链地址法两种解决冲突的策略分别实现一个哈希表。至于其他方法,这里就不再一一实现。

与之前实现AVL树和红黑树相同,我们的实现当中将键值对作为数据元素,其哈希值由键Key来决定

1. 线性探测法实现

整形转化的支持与字符串哈希

        之前提到哈希表的数据元素要支持转化为整形,所以在这里我们需要实现一个仿函数模板,用仿函数对象将内置类型进行转化,并且对于不能转化为整形的类型,可以用特化来支持

代码如下:

//支持内置类型转化为整形的仿函数
template<class K>
class ToInt
{
public:
	size_t operator()(const K& key)//这里转换为size_t类型更符合数组下标范围
	{
		return (size_t)(key);
	}
};

除了内置类型,字符串(string)作为键值存储在哈希表中的场景较多,我们实现一个特化版本支持字符串转换为整形。

        传统的字符串转整形可以将所有字符的ASCII值相加,但这种方法转换出的整形值可能重复率较高(例如"abc"和"acb"),更容易导致冲突。所以这里我们使用BKDR哈希的思路:每一个字符累加之后,总累加值乘以一个素数(通常是131或31),这样就能对每个字符进行加权。最后的累加值即为转化后的整形值

注意:当字符串过长时,多次相乘可能会导致溢出,但无需考虑溢出问题,因为我们只要确定能得到唯一的整形值即可。

代码如下:

//针对string的特化版本
template<>
class ToInt<string>
{
public:
	size_t operator()(const string& s)
	{
		size_t ret = 0;
		for (auto& c : s)
		{
			ret += c;
			ret *= 131;
		}
		return ret;
	}
};

初始大小与扩容策略

        使用除留余数法时,为了降低哈希冲突的概率,哈希表的大小(也就是除数M)的选择十分重要,应取不太接近2的整数次幂的一个素数。那么应该如何设置哈希表的初始大小及其扩容后的大小呢?这里我们直接采用SGI STL哈希表源码中的大小设置策略

inline size_t __stl_next_prime(size_t n)
{
	// Note: assumes long is at least 32 bits.
	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;
}

可以看到,函数创建了一个大小为28的数组,其中的数据即是哈希表每次扩容的大小(53、97、193...以此类推)。函数接收一个参数n,并返回数组中第一个不小于n的元素。而第一个元素53就可以作为我们哈希表的初始大小,接下来实现构造函数时调用该函数即可。

哈希表结构定义

        接下来是线性探测法哈希表及其数据元素的结构定义: 

//元素的状态表示
enum State
{
	EMPTY,//空
	EXIST,//存在
	DELETE//已删除
};

//数据元素
template<class K,class V>
struct HashData
{
	pair<K, V> _kv;//键值对
	State _state = EMPTY;//状态
};

//哈希表
template<class K, class V, class ToInteger = ToInt<K>>
class HashTable
{
public:
private:
	vector<HashData<K, V>> _tables;//使用vector表示哈希表
	size_t _n = 0;//记录已有元素个数
    ToInteger _toi;//仿函数对象
};

结构定义当中,我们使用了vector用于存储数据,其中元素是一个HashData,HashData包含一个键值对和状态表示(空、存在和已删除)。另外成员n和toi分别用于计算装填因子和整形转化。

构造函数

        构造函数当中,我们只需要设置哈希表的初始大小。代码如下:

//构造函数
HashTable()
{
	_tables.resize(__stl_next_prime(1));
}

传入参数1,则函数__stl_next_prime返回不小于1的第一个数组元素,也就是53,然后用它调整vector的大小。

析构函数

        析构函数不需要我们显式实现,编译器默认生成的析构函数会调用vector的析构,进行数据销毁。 

哈希表的插入

        哈希表插入的大致过程如下:

1. 首先判断表中是否已有相同键值元素,若有则插入失败(不允许键值重复)。

2. 判断装填因子的值是否过大(这里我们判断是否超过0.7),若过大,哈希表需要扩容。

3. 根据哈希函数进行插入,并处理冲突。 

需要注意的是:扩容之后,哈希表的大小(即除数M)会发生变化,之后进行插入或查找操作时,根据新的M进行插入/查找原有元素就会出现问题。所以当哈希表扩容之后,需要将其中的所有元素都拿出来重新根据哈希函数插入在哈希表当中,使其符合新哈希表的除数M。(数据重排)

代码实现:

//插入
bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))//如果表中已有相同键,则插入失败返回false
	{
		return false;
	}
	if ((double)_n / (double)_tables.size() >= 0.7)//当装填因子大于0.7,则需要扩容
	{
		size_t newSize = __stl_next_prime(_tables.size() + 1);//先确定扩容后的大小

		//创建新的哈希表,并调整大小
		HashTable<K, V, ToInteger> newHT;
		newHT._tables.resize(newSize);

		//遍历原哈希表,将有效数据插入到新哈希表中
		for (size_t i = 0; i < _tables.size(); i++)
		{
			if (_tables[i]._state == EXIST)//存在说明是有效数据
			{
				//直接调用Insert函数本身进行插入
				//此时已经扩容,装填因子一定不会超过阈值,不会再次走到这里
				newHT.Insert(_tables[i]._kv);
			}
		}

		//最后交换两个哈希表的tables
		_tables.swap(newHT._tables);
	}

	//开始插入
	//除留余数法计算哈希值
	size_t hash0 = _toi(kv.first) % _tables.size();
	size_t i = 1;
	size_t hashi = hash0;
	while(_tables[hashi]._state == EXIST)//该位置已有元素,说明发生了哈希冲突,循环向后寻找
	{
		hashi = (hash0 + i) % _tables.size();//相比直接++,这样设置能确保找到末尾还能从头开始寻找
		i++;
	}
	//找到了不出现冲突的位置,进行插入
	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXIST;
	_n++;
	return true;
}

可以看到,在进行扩容时,我们巧妙地创建了一个新的哈希表,并调用Insert方法本身进行数据的重新插入,减少了代码冗余。

哈希表的查找

        查找时的逻辑与插入过程大体相同,同样使用哈希函数求出索引值,然后用线性探测法进行查找(注意按键查找)。代码如下:

HashData<K, V>* Find(const K& key)
{
	//除留余数法计算哈希值
	size_t hash0 = _toi(key) % _tables.size();
	size_t i = 1;
	size_t hashi = hash0;

	//没有走到空,则一直查找
	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;
}

这里需要注意:由于线性探测法一定会使得冲突的数据在表中连续排列,所以我们查找时,如果在线性探测的过程中出现了空位,说明一定查找失败,就无需遍历整个哈希表了

哈希表的删除

        删除的大致过程也很简单(注意按键删除):

如果表中不存在该元素,则删除失败。

如果存在该元素,直接将元素状态修改为DELETE,然后调整已有元素个数即可。

代码如下:

//删除
bool Erase(const K& key)
{
	//首先进行查找
	HashData<K, V>* ret = Find(key);

	//没找到,删除失败
	if (ret == nullptr)
	{
		return false;
	}
	else//找到了,删除
	{
		ret->_state = DELETE;//修改元素状态
		_n--;//修改已有元素个数
		return true;
	}
}

总代码

哈希表线性探测版本的全部代码如下:

#include <iostream>
#include <utility>
#include <vector>
#include <string>
using namespace std;

//支持内置类型转化为整形的仿函数
template<class K>
class ToInt
{
public:
	size_t operator()(const K& key)//这里转换为size_t类型更符合数组下标范围
	{
		return (size_t)(key);
	}
};

//针对string的特化版本
template<>
class ToInt<string>
{
public:
	size_t operator()(const string& s)
	{
		size_t ret = 0;
		for (auto& c : s)
		{
			ret += c;
			ret *= 131;
		}
		return ret;
	}
};

//元素的状态表示
enum State
{
	EMPTY,//空
	EXIST,//存在
	DELETE//已删除
};

//数据元素
template<class K, class V>
struct HashData
{
	pair<K, V> _kv;//键值对
	State _state = EMPTY;//状态
};

//哈希表
template<class K, class V, class ToInteger = ToInt<K>>
class HashTable
{
public:
	//构造函数
	HashTable()
	{
		_tables.resize(__stl_next_prime(1));
	}

	//插入
	bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first))//如果表中已有相同键,则插入失败返回false
		{
			return false;
		}
		if ((double)_n / (double)_tables.size() >= 0.7)//当装填因子大于0.7,则需要扩容
		{
			size_t newSize = __stl_next_prime(_tables.size() + 1);//先确定扩容后的大小

			//创建新的哈希表,并调整大小
			HashTable<K, V, ToInteger> newHT;
			newHT._tables.resize(newSize);

			//遍历原哈希表,将有效数据插入到新哈希表中
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._state == EXIST)//存在说明是有效数据
				{
					//直接调用Insert函数本身进行插入
					//此时已经扩容,装填因子一定不会超过阈值,不会再次走到这里
					newHT.Insert(_tables[i]._kv);
				}
			}

			//最后交换两个哈希表的tables
			_tables.swap(newHT._tables);
		}

		//开始插入
		//除留余数法计算哈希值
		size_t hash0 = _toi(kv.first) % _tables.size();
		size_t i = 1;
		size_t hashi = hash0;
		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 = _toi(key) % _tables.size();
		size_t i = 1;
		size_t hashi = hash0;

		//没有走到空,则一直查找
		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:
	inline size_t __stl_next_prime(size_t n)
	{
		// Note: assumes long is at least 32 bits.
		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;
	}

	vector<HashData<K, V>> _tables;//使用vector表示哈希表
	size_t _n = 0;//记录已有元素个数
	ToInteger _toi;//仿函数对象
};

2. 链地址法实现

整形转化支持、大小设置

        对于进行整形转化的仿函数和哈希表的初始大小设置,我们继续沿用之前线性探测法的实现。

//支持内置类型转化为整形的仿函数
template<class K>
class ToInt
{
public:
	size_t operator()(const K& key)//这里转换为size_t类型更符合数组下标范围
	{
		return (size_t)(key);
	}
};

//针对string的特化版本
template<>
class ToInt<string>
{
public:
	size_t operator()(const string& s)
	{
		size_t ret = 0;
		for (auto& c : s)
		{
			ret += c;
			ret *= 131;
		}
		return ret;
	}
};
inline size_t __stl_next_prime(size_t n)
{
	// Note: assumes long is at least 32 bits.
	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, 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 ToInteger = ToInt<K>>
class HashTable
{
	typedef HashNode<K, V> Node;
public:
private:
	vector<Node*> _tables;
	size_t _n = 0;//记录已有元素个数
	ToInteger _toi;//仿函数对象
};

这里我们创建了一个指针数组,每一个指针都用于维护一个链表。其次,成员n与toi的作用和线性探测实现中相同。由于链地址法中的数据都是通过链表存储,所以这里就无需对每个元素进行状态表示。

构造函数

        构造函数的实现与线性探测法相同,注意初始化时将表中的所有指针制为空。

代码如下:

//构造函数
HashTable()
{
	_tables.resize(__stl_next_prime(1), nullptr);
}

析构函数

        与线性探测法实现不同,由于每个哈希桶都维护一个单链表,所以析构时需要我们手动释放这些链表所占内存。 遍历哈希表,逐个销毁即可。代码如下:

//析构函数
~HashTable()
{
	//循环遍历所有哈希桶
	for (size_t i = 0; i < _tables.size(); i++)
	{
		//使cur指向当前哈希桶
		Node* cur = _tables[i];

		//循环销毁当前链表中的所有节点
		while (cur)
		{
			Node* next = cur->_next;
			delete cur;
			cur = next;
		}

		//将当前哈希桶制空
		_tables[i] = nullptr;
	}
}

哈希表的插入

        链地址法哈希表的插入大致过程:

1. 与线性探测一样,首先判断是否已有相同键值。

2. 判断装填因子是否大于1,如果大于1就需要扩容。(扩容仍需注意数据重排

3. 通过哈希函数找到对应的哈希桶,进行插入。 

需要注意两点:

1. 这里的插入过程涉及单链表的插入,为了提高效率,我们将使用头插法。 

2. 由于数据存储在链表节点当中,扩容之后,进行数据重排时释放节点再创建节点就过于麻烦,所以我们遍历所有链表,将节点连接到新的哈希表中对应位置即可。

代码实现:

//插入
bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))//已有相同键,插入失败
	{
		return false;
	}
	if (_n >= _tables.size())//装填因子的值超过1,需要扩容
	{
		size_t newSize = __stl_next_prime(_tables.size() + 1);//先确定扩容后大小

		//创建新的vector,然后进行数据重排
		vector<Node*> newV(newSize, nullptr);
		for (size_t i = 0; i < _tables.size(); i++)//遍历所有哈希桶
		{
			Node* cur = _tables[i];//cur指向当前哈希桶
			while (cur)
			{
				Node* next = cur->_next;//先记录下一个节点
				size_t hashi = _toi(cur->_kv.first) % newV.size();//计算当前节点在新表中的索引值

				//将当前节点连接到新桶中
				cur->_next = newV[hashi];
				newV[hashi] = cur;

				//继续操作下一个节点
				cur = next;
			}
			//一个哈希桶遍历结束后,及时制空
			_tables[i] = nullptr;
		}
		//重排结束,交换两个vector
		_tables.swap(newV);
	}
	//进行插入
	size_t hashi = _toi(kv.first) % _tables.size();//计算索引值
	Node* newnode = new Node(kv);//创建新节点
	
	//头插
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;

	_n++;
	return true;
}

可以看到,与刚才的线性探测实现不同,这里我们在扩容时创建的是一个vector,相比直接创建HashTable再调用Insert方法,能够有效地控制数据重排时节点之间的连接。

对于不同的结构实现,同一种操作所使用的方法也会有所不同,因此在实际场景中,我们应抓住问题本质,灵活运用各种方法,而非死抓一种应对措施。

哈希表的查找

        相比线性探测法,链地址法的查找中,我们只需要确定索引值,然后顺着链表直接查找即可,总体过程相对简洁。代码实现如下:

//查找
Node* Find(const K& key)
{
	size_t hashi = _toi(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)
{
	size_t hashi = _toi(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;//没找到,删除失败
}

总代码

链地址法实现哈希表的全部代码如下:

#include <iostream>
#include <utility>
#include <vector>
#include <string>
using namespace std;

//支持内置类型转化为整形的仿函数
template<class K>
class ToInt
{
public:
	size_t operator()(const K& key)//这里转换为size_t类型更符合数组下标范围
	{
		return (size_t)(key);
	}
};

//针对string的特化版本
template<>
class ToInt<string>
{
public:
	size_t operator()(const string& s)
	{
		size_t ret = 0;
		for (auto& c : s)
		{
			ret += c;
			ret *= 131;
		}
		return ret;
	}
};

//链表节点
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 ToInteger = ToInt<K>>
class HashTable
{
	typedef HashNode<K, V> Node;
public:
	//构造函数
	HashTable()
	{
		_tables.resize(__stl_next_prime(1), nullptr);
	}

	//析构函数
	~HashTable()
	{
		//循环遍历所有哈希桶
		for (size_t i = 0; i < _tables.size(); i++)
		{
			//使cur指向当前哈希桶
			Node* cur = _tables[i];

			//循环销毁当前链表中的所有节点
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;
				cur = next;
			}

			//将当前哈希桶制空
			_tables[i] = nullptr;
		}
	}

	//插入
	bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first))//已有相同键,插入失败
		{
			return false;
		}
		if (_n >= _tables.size())//装填因子的值超过1,需要扩容
		{
			size_t newSize = __stl_next_prime(_tables.size() + 1);//先确定扩容后大小

			//创建新的vector,然后进行数据重排
			vector<Node*> newV(newSize, nullptr);
			for (size_t i = 0; i < _tables.size(); i++)//遍历所有哈希桶
			{
				Node* cur = _tables[i];//cur指向当前哈希桶
				while (cur)
				{
					Node* next = cur->_next;//先记录下一个节点
					size_t hashi = _toi(cur->_kv.first) % newV.size();//计算当前节点在新表中的索引值

					//将当前节点连接到新桶中
					cur->_next = newV[hashi];
					newV[hashi] = cur;

					//继续操作下一个节点
					cur = next;
				}
				//一个哈希桶遍历结束后,及时制空
				_tables[i] = nullptr;
			}
			//重排结束,交换两个vector
			_tables.swap(newV);
		}
		//进行插入
		size_t hashi = _toi(kv.first) % _tables.size();//计算索引值
		Node* newnode = new Node(kv);//创建新节点
		
		//头插
		newnode->_next = _tables[hashi];
		_tables[hashi] = newnode;

		_n++;
		return true;
	}

	//查找
	Node* Find(const K& key)
	{
		size_t hashi = _toi(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)
	{
		size_t hashi = _toi(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:
	inline size_t __stl_next_prime(size_t n)
	{
		// Note: assumes long is at least 32 bits.
		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;
	}

	vector<Node*> _tables;
	size_t _n = 0;//记录已有元素个数
	ToInteger _toi;//仿函数对象
};

总结

        哈希表是一种基于哈希函数的高效数据结构,其通过数据映射,支持 O(1) 级别的查找、插入和删除操作。哈希表有多种实现方法,我们实现了开放地址法(线性探测)链地址法两种。开放地址法适用于低负载、节省空间,但处理冲突时可能导致探测成本增加;链地址法适用于高负载,扩展性更强,但需额外存储指针。合理选择哈希函数和冲突解决策略是优化哈希表性能的关键。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2307562.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【蓝桥杯嵌入式】各模块学习总结

系列文章目录 留空 文章目录 系列文章目录前言一、LED模块1.1 赛题要求1.2 模块原理图1.3 编写代码1.4 赛题实战 二、LCD模块2.1 赛题要求2.2 模块原理图2.3 编写代码2.4 赛题实战 三、按键模块3.1 赛题要求3.2 模块原理图3.3 编写代码3.4 赛题实战 四、串口模块4.1 赛题要求4…

Rust学习总结之-枚举

枚举是一个很多语言都有的功能&#xff0c;不过不同语言中其功能各不相同但是要表达的意思是一致的&#xff0c;枚举就是对于一个事物可以穷举出所有可能得值。比如说人的性别就可以用枚举&#xff0c;男人和女人两种。下面我们来学习Rust中的枚举。 一&#xff1a;枚举定义 …

Linux系统管理(十七)——配置英伟达驱动、Cuda、cudnn、Conda、Pytorch、Pycharm等Python深度学习环境

文章目录 前言安装驱动下载安装Cuda编辑环境变量安装Cudnn安装conda验证安装成功配置conda镜像退出conda环境创建python环境查看当前conda环境激活环境安装python包安装pytorch 安装pycharm安装jupyter notebook 前言 深度学习和大语言模型的部署不免会用到Linux系统&#xff…

SLAM算法工程师的技术图谱和学习路径

SLAM(Simultaneous Localization and Mapping)算法工程师是负责开发和实现用于机器人、自动驾驶车辆等领域的SLAM算法的专业人士。下面是SLAM算法工程师需要掌握的基础理论知识: 机器人运动学和动力学:理解机器人在空间中的运动方式和控制方法,包括轮式、蜘蛛腿、飞行器等…

深入了解 Python 中的 MRO(方法解析顺序)

文章目录 深入了解 Python 中的 MRO&#xff08;方法解析顺序&#xff09;什么是 MRO&#xff1f;如何计算 MRO&#xff1f;C3 算法的合并规则C3 算法的合并步骤示例&#xff1a;合并过程解析 MRO 解析失败的场景使用 mro() 方法查看 MRO示例 1&#xff1a;基本用法 菱形继承与…

如何防止 Instagram 账号被盗用:安全设置与注意事项

如何防止 Instagram 账号被盗用&#xff1a;安全设置与注意事项 在这个数字化时代&#xff0c;社交媒体平台如 Instagram 已成为我们日常生活的一部分。然而&#xff0c;随着网络犯罪的增加&#xff0c;保护我们的在线账户安全变得尤为重要。以下是一些关键的安全设置和注意事…

采样算法二:去噪扩散隐式模型(DDIM)采样算法详解教程

参考 https://arxiv.org/pdf/2010.02502 一、背景与动机 去噪扩散隐式模型&#xff08;DDIM&#xff09; 是对DDPM的改进&#xff0c;旨在加速采样过程同时保持生成质量。DDPM虽然生成效果优异&#xff0c;但其采样需迭代数百至数千次&#xff0c;效率较低。DDIM通过以下关键…

各种类型网络安全竞赛有哪些 网络安全大赛的简称

本文是对入门学习的一些概念了解和一些常规场景记录 1.CTF&#xff08;capture the flag&#xff09;是夺旗赛的意思。 是网络安全技术人员之间进行攻防的比赛。 起源1996年DEFCON全球黑客大会&#xff0c;替代之前真实攻击的技术比拼。 (DEFCON极客大会诞生1993&#xff0c;…

包子凑数——蓝桥杯真题Python

包子凑数 输入输出样例 示例 1 输入 2 4 5输出 6样例说明 凑不出的数目包括&#xff1a;1, 2, 3, 6, 7, 11。 示例 2 输入 2 4 6输出 INF样例说明 所有奇数都凑不出来&#xff0c;所以有无限多个 运行限制 最大运行时间&#xff1a;1s最大运行内存: 256M 最大公约数 最大公…

网络通信/IP网络划分/子网掩码的概念和使用

文章目录 概述子网的考题子网掩码的历史有/无类地址子网划分!子网掩码超网技术/CIDR子网掩码和路由IP子网掩码定义 网络规划网络规划-拆子网网络规划-组超网子网划分案例 区分于其他特殊IP地址IP地址和网络地址子网掩码和网络地址子网掩码和广播地址 子网间的通信其他 概述 本…

MySQL--》如何在MySQL中打造高效优化索引

目录 初识索引 索引结构 性能分析 索引使用 最左前缀法则 SQL提示使用 覆盖索引使用 前缀索引使用 索引失效情况 初识索引 索引(index)&#xff1a;是帮助MySQL高效获取数据的数据结构(有序)&#xff0c;在数据之外数据库系统还维护着满足特定查找算法的数据结构&…

盛京开源社区加入 GitCode,书写东北开源生态新篇章

在数字化转型与开源技术蓬勃发展的浪潮下&#xff0c;开源社区已成为推动技术创新的核心力量。盛京开源社区&#xff08;SJOSC&#xff09;作为沈阳地区的开源交流平台&#xff0c;始终致力于连接开发者、企业及高校&#xff0c;构建区域技术生态圈。 现在&#xff0c;盛京开源…

网络运维学习笔记(DeepSeek优化版)005网工初级(HCIA-Datacom与CCNA-EI)链路层发现协议与VLAN技术

文章目录 一、链路层发现协议1.1 思科CDP协议1.2 华为LLDP协议 二、VLAN&#xff08;Virtual Local Area Network&#xff0c;虚拟局域网&#xff09;技术详解2.1 基本概念2.2 技术特性2.3 接口工作原理2.3.1 Access模式2.3.2 Trunk模式 2.4 厂商配置对比思科配置华为配置 2.5 …

DeepSeek开源周Day4:三连发!突破 AI 训练瓶颈的立体解决方案,并行计算三剑客DualPipe、EPLB与Profile-data

项目地址&#xff1a; https://github.com/deepseek-ai/DualPipehttps://github.com/deepseek-ai/eplbhttps://github.com/deepseek-ai/profile-data 开源日历&#xff1a;2025-02-24起 每日9AM(北京时间)更新&#xff0c;持续五天 (4/5)&#xff01; ​ ​ 一、背景概述 …

树莓百度百科更新!宜宾园区业务再添新篇

树莓集团宜宾园区业务不断拓展&#xff0c;主要体现在以下几个方面&#xff1a; 产业布局 -聚焦数字经济核心领域&#xff1a;涵盖软件开发、人工智能、大数据等&#xff0c;吸引众多上下游企业入驻&#xff0c;形成从芯片研发、软件开发到系统集成的完整产业链条。 -推进“双…

RabbitMQ操作实战

1.RabbitMQ安装 RabbitMQ Windows 安装、配置、使用 - 小白教程-腾讯云开发者社区-腾讯云下载erlang&#xff1a;http://www.erlang.org/downloads/https://cloud.tencent.com/developer/article/2192340 Windows 10安装RabbitMQ及延时消息插件rabbitmq_delayed_message_exch…

OpenWebUI配置异常的外部模型导致页面无法打开

一、使用Ollama关闭OpenAI OpenWebUI自带OpenAI的API设置&#xff0c;且默认是打开的&#xff0c;默认情况下&#xff0c;启动后&#xff0c;会不断的去连https://api.openai.com/v1&#xff0c;但是无法连上&#xff0c;会报错&#xff0c;但是不会影响页面&#xff0c;能正常…

鸿蒙兼容Mapbox地图应用测试

鸿蒙Next已经发布一段时间了&#xff0c;很多之前的移动端地图应用&#xff0c;纷纷都要求适配鸿蒙Next。作为开发者都清楚&#xff0c;所谓的适配其实都是重新开发&#xff0c;鸿蒙的开发语言和纯前端的Javascript不同&#xff0c;也可以Android原始开发的语言不同。鸿蒙自带的…

java练习(45)

ps:题目来自力扣 两数相除 给你两个整数&#xff0c;被除数 dividend 和除数 divisor。将两数相除&#xff0c;要求 不使用 乘法、除法和取余运算。 整数除法应该向零截断&#xff0c;也就是截去&#xff08;truncate&#xff09;其小数部分。例如&#xff0c;8.345 将被截断…

部署Flink1.20.1

1、设置环境变量 export JAVA_HOME/cluster/jdk export CLASSPATH.:$JAVA_HOME/lib/tools.jar:$JAVA_HOME/lib/dt.jarp #export HIVE_HOME/cluster/hive export MYSQL_HOME/cluster/mysql export HADOOP_HOME/cluster/hadoop3 export HADOOP_CONF_DIR$HADOOP_HOME/etc/hadoop …