C++从入门到起飞之——开放定址法实现哈希表 全方位剖析!

目录

1、搭建HashTable基本框架

 2、实现Insert

3、实现Find

4、实现Erase

5、解决key不为整形时问题

​编辑 6、完整源码


1、搭建HashTable基本框架

//HashData的状态
enum State
{
	EXIST,//存在
	DELET,//删除
	EMPTY //空
};

//定义HashData
template<class K,class V>
struct HashData
{
	pair<K, V> _kv;
	State _state = EMPTY;
};

//定义HashTable
template<class K,class V>
class HashTable
{
public:

private:
	size_t n=0;//记录哈希表中有效数据的个数
	vector<HashData<K, V>>  _tables;
};

 2、实现Insert

这里的插入我们使用除留余数法(即用插入数据的key%上表的大小所得值寻找到该值在Hash表中的初始映射位置)!

但是,我们插入元素映射的位置可能已经被其他元素占据(即发生Hash冲突),这里我先用简单的一次线性探测解决该问题!

一次线性探测:一次线性探测就是在初始映射位置开始往后面依次寻找状态是空或则删除的位置

由于Hash表中的元素是散列的,可能找到表尾时依旧没有找到合适放位置,但是在表头或表中依然存在合适的位置,那我们就要进行回绕(回到表头)继续寻找。

//插入
bool insert(const pair<K, V>& kv)
{
	size_t hashi = kv.first % _tables.size();//初始映射位置
	while (_tables[hashi]._state==EXIST)//找到一个删除位置或空位置
	{
		hashi++;//一次线性探测
		hashi %= _tables.size();//如果走到表尾进行回绕继续寻找
	}
	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXIST;
	++_n;//别忘了有效个数++
	return true;
}

上面的代码还存在一个致命的缺陷:如果表满了就无法进行插入了!

所以我们还要写一个扩容逻辑,这里规定如果表的负载因子超过0.7我们就进行扩容!

这里我们先按照两倍扩容的逻辑来走,当然这样扩容是不合适的(后面会讲到C++中采用素数表的方式如何扩容)!

扩容逻辑:我们先定义一个两倍大小的的newTables,然后取出旧表中的数据再次重新映射到新表中,最后再交换两个表就可以完成扩容逻辑!

//插入
bool insert(const pair<K, V>& kv)
{
	if (_n * 10 / _tables.size() > 7)//负载因子大于0.7进行扩容
	{
		vector<HashData<K, V>>  newTables(_tables.size()*2);//定义2倍新表
		for (int i = 0; i < _tables.size(); i++)//遍历旧表元素映射到新表
		{
			if (_tables[i]._state == EXIST)//旧表中元素存在走插入逻辑
			{
				size_t hashi = _tables[i]._kv.first % newTables.size();//初始映射位置
				while (newTables[hashi]._state == EXIST)//找到一个删除位置或空位置
				{
					hashi++;//一次线性探测
					hashi %= newTables.size();//如果走到表尾进行回绕继续寻找
				}
				newTables[hashi]._kv = _tables[i]._kv;
				newTables[hashi]._state = EXIST;
				//只是单纯的重新映射,不需要增加有效个数
			}
		}
		_tables.swap(newTables);//交换新旧表
	}
	size_t hashi = kv.first % _tables.size();//初始映射位置
	while (_tables[hashi]._state==EXIST)//找到一个删除位置或空位置
	{
		hashi++;//一次线性探测
		hashi %= _tables.size();//如果走到表尾进行回绕继续寻找
	}
	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXIST;
	++_n;//别忘了有效个数++
	return true;
}

不过,我们会发现旧表元素映射到新表元素的代码逻辑和插入逻辑高度重复,这样的代码就有点龊了!下面展示一下比较好一点的代码!

//插入
bool insert(const pair<K, V>& kv)
{
	if (_n * 10 / _tables.size() > 7)//负载因子大于0.7进行扩容
	{
		HashTable<K, V> newht;//定义2倍新表
		newht._tables.resize(_tables.size() * 2);
		for (auto& e:_tables)//遍历旧表元素映射到新表
		{
			if (e._state == EXIST)//旧表中元素存在走插入逻辑
			{
				newht.insert(e._kv);//复用插入代码
			}
		}
		_tables.swap(newht._tables);//交换新旧表
	}
	size_t hashi = kv.first % _tables.size();//初始映射位置
	while (_tables[hashi]._state==EXIST)//找到一个删除位置或空位置
	{
		hashi++;//一次线性探测
		hashi %= _tables.size();//如果走到表尾进行回绕继续寻找
	}
	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXIST;
	++_n;//别忘了有效个数++
	return true;
}

我们定义一个HashTable对象,那么我们就可以复用insert逻辑进行新表和旧表的重新映射,因为在复用insert逻辑之前,我们已经进行了2倍扩容,所以insert决不会循环进入扩容逻辑,只会走插入逻辑!

好,我们接下来看看C++标准中是怎么设计扩容逻辑的!

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

C++标准中设计了一个获取素数的函数,扩容时,我们只需要将当前表的大小传给这个函数,我们就能获取到接近2倍的素数!

如果我们实现的哈希表不存在相同的key的话,我们还要判断一下!

if (Find(kv.first)) return false;

下面我们就会实现Find接口,这里先提前说明一下!

完整Insert代码:

	bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first)) return false;
		if (_n * 10 / _tables.size() > 7)//负载因子大于0.7进行扩容
		{
			HashTable<K, V> newht;//定义2倍新表
			newht._tables.resize(__stl_next_prime((unsigned long)_tables.size()+1));
			for (auto& e:_tables)//遍历旧表元素映射到新表
			{
				if (e._state == EXIST)//旧表中元素存在走插入逻辑
				{
					newht.insert(e._kv);//复用插入代码
				}
			}
			_tables.swap(newht._tables);//交换新旧表
		}
		size_t hashi = kv.first % _tables.size();//初始映射位置
		while (_tables[hashi]._state==EXIST)//找到一个删除位置或空位置
		{
			hashi++;//一次线性探测
			hashi %= _tables.size();//如果走到表尾进行回绕继续寻找
		}
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		++_n;//别忘了有效个数++
		return true;
	}

3、实现Find

查找逻辑:我们先用key找到该元素在哈希表中的初始映射位置,然后从这个位置开始判断key值想不相等!注意:只要表中Hashi位置的状态为存在或删除,我们都要进入循环往后探测,只有我们探测到表中位置状态为空时,我们才能跳出循环。

原因在于:

我们的插入逻辑是找到一个不为空的位置进行插入,如果该元素发生Hash冲突占据了Hashi后面的位置HashN,并且在查找之前,我们又恰好删除了从初始hashi到HashN中的元素(删除元素的核心逻辑就是把该位置的状态标记为DELETE),此时该元素的前面就会有状态为删除的位置,而我们如果在查找时从初始hashi开始直到空都没有找到匹配的key,则说明该key在表中不存在。所以我们在查找探测时遇到删除状态也要继续向后探测寻找!直到找到该key或则遇到空!

//查找
HashData<K,V>* Find(const K& key)
{
	size_t hashi = key % _tables.size();
	while (_tables[hashi]._state != EMPTY)//如果映射值存在或删除则进入循环进行判断
	{
		//如果映射值存在并且key相等,则返回该位置的指针
		if (_tables[hashi]._state == EXIST &&
			key == _tables[hashi]._kv.first)
			return &_tables[hashi];
		hashi++;
		hashi %= _tables.size();
	}
	return nullptr;
}

4、实现Erase

Erase的实现非常简单,调用Find找到key映射的位置,如果该位置存在把状态置为DELETE即可,如果不存在,则删除失败,返回false!

//删除
bool Erase(const K& key)
{
	HashData<K, V>* pos = Find(key);
	if (pos == nullptr) return false;
	else
	{
		(*pos)._state = DELETE;//状态置为删除即可
		_n--;//有效个数减一
	}
	return true;
}

5、解决key不为整形时问题

我们的Hash映射都是通过无符号整形来确定key在表中的位置,但是,如果外层的key类型并不是无符号整形如string,浮点数,甚至是负数时,上面代码的逻辑就有大问题了!要解决这个问题,我们只需要再增加一个类模板参数(仿函数)即可。

该仿函数的作用是将key类型转换成无符号的整形!我们默认传递的仿函数模板就是直接将key强制类型转换为size_t,但是,如果key类型是字符串或者是其他自定义类型时,默认的仿函数模板就失效了。因此,我们需要根据自己的需求在外层自己写一个仿函数传递给内层,否则编译器就会报错!

//默认仿函数模板(解决key不为无符号整形问题)
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

//定义HashTable
template<class K,class V,class Hash= HashFunc<K>>

由于string作为key的场景在现实中很常见的(比如统计单词出现的次数,中英互译......),所以我们在内层特化一个仿函数模板。当key为string类型时我们就走特化的仿函数模板!

//特化
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 *= 131;
			hash += ch;
		}
		return hash;
	}
};

接下来我们只要将key使用时调用我们传递的仿函数重载的()即可解决这个问题了!

insert:

find: 

 6、完整源码

#pragma once
#include<iostream>
#include<vector>
using namespace std;

//HashData的状态
enum State
{
	EXIST,//存在
	DELETE,//删除
	EMPTY //空
};

//定义HashData
template<class K,class V>
struct HashData
{
	pair<K, V> _kv;
	State _state = EMPTY;
};

//默认仿函数模板(解决key不为无符号整形问题)
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)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 *= 131;
			hash += ch;
		}
		return hash;
	}
};

//定义HashTable
template<class K,class V,class Hash= HashFunc<K>>
class HashTable
{
public:
	//素数表
	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;
	}
	//构造
	HashTable()
		:_tables(__stl_next_prime(0))
		,_n(0)
	{}
	//插入
	bool Insert(const pair<K, V>& kv)
	{
		//if (_n * 10 / _tables.size() > 7)//负载因子大于0.7进行扩容
		//{
		//	vector<HashData<K, V>>  newTables(_tables.size()*2);//定义2倍新表
		//	for (int i = 0; i < _tables.size(); i++)//遍历旧表元素映射到新表
		//	{
		//		if (_tables[i]._state == EXIST)//旧表中元素存在走插入逻辑
		//		{
		//			size_t hashi = _tables[i]._kv.first % newTables.size();//初始映射位置
		//			while (newTables[hashi]._state == EXIST)//找到一个删除位置或空位置
		//			{
		//				hashi++;//一次线性探测
		//				hashi %= newTables.size();//如果走到表尾进行回绕继续寻找
		//			}
		//			newTables[hashi]._kv = _tables[i]._kv;
		//			newTables[hashi]._state = EXIST;
		//			//只是单纯的重新映射,不需要增加有效个数
		//		}
		//	}
		//	_tables.swap(newTables);//交换新旧表
		//}
		if (Find(kv.first)) return false;
		if (_n * 10 / _tables.size() > 7)//负载因子大于0.7进行扩容
		{
			HashTable<K, V> newht;//定义2倍新表
			newht._tables.resize(__stl_next_prime((unsigned long)_tables.size()+1));
			for (auto& e:_tables)//遍历旧表元素映射到新表
			{
				if (e._state == EXIST)//旧表中元素存在走插入逻辑
				{
					newht.Insert(e._kv);//复用插入代码
				}
			}
			_tables.swap(newht._tables);//交换新旧表
		}
		Hash hs;
		size_t hashi = hs(kv.first) % _tables.size();//初始映射位置
		while (_tables[hashi]._state==EXIST)//找到一个删除位置或空位置
		{
			hashi++;//一次线性探测
			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 hashi = hs(key) % _tables.size();
		while (_tables[hashi]._state != EMPTY)//如果映射值存在或删除则进入循环进行判断
		{
			//如果映射值存在并且key相等,则返回该位置的指针
			if (_tables[hashi]._state == EXIST &&
				key == _tables[hashi]._kv.first)
				return &_tables[hashi];
			hashi++;
			hashi %= _tables.size();
		}
		return nullptr;
	}
	//删除
	bool Erase(const K& key)
	{
		HashData<K, V>* pos = Find(key);
		if (pos == nullptr) return false;
		else
		{
			(*pos)._state = DELETE;//状态置为删除即可
			_n--;//有效个数减一
		}
		return true;
	}
private:
	size_t _n=0;//记录哈希表中有效数据的个数
	vector<HashData<K, V>>  _tables;
};