C++ | 什么是哈希? | 闭散列结构的模拟实现与讲解

什么是哈希?

哈希是一种将任意长度的数据映射到固定长度的数据的方法。哈希的目的是为了快速的比较,查找或者验证数据的完整性,其通常用于数据结构,加密,签名等领域中。哈希有多种算法,如MD5,SHA等

什么是unordered_map?

在C++11之前,STL标准库中的关联式容器只有map和set,还有它们的键值冗余版本multimap和multiset,对于关联式容器,其存储的数据是一对键值对,主要功能除了存储还有查找。而实现map和set的底层结构——红黑树,查找key的时间复杂度为O(logN),最差的情况下会达到树的高度次,如果其存储的数据量较大,查找数据的效率可能就不会很理想。为了达到高效的查找,C++11带来了unordered_map和unordered_set这两个容器,当然还有它们的键值冗余版本unordered_multimap和unordered_multiset。与map和set有什么区别呢?两者的底层实现容器不同,unordered容器底层使用了哈希,直接映射是哈希的一种实现方式,它可以通过key值直接的锁定数据(将关键字直接作为哈希表的索引),完成查找。

比如统计小写字母的个数,a ~ z的26个字母被映射到0 ~ 25中,将其作为数组下标,查找某一字母的出现次数,只要将字母对应下标在数组中的值返回即可,可以说哈希结构的查找效率达到了O(1),无论数据量多大,我们都能很快的查找某一key值。与红黑树的查找有什么不同呢?红黑树需要key值之间进行比较,根据比较结果选择进入哪颗左右子树,然后接着比较,而哈希呢,只要对key值做一个转换就能直接锁定需要查找的数据存储的位置,相比于红黑树的不断比较,哈希的一次转换效率显然更高。

闭散列的线性探测

我们知道在哈希结构中存储数据的位置有限,但是数据是无限的,有没有可能出现两个不同的数据映射到同一位置的情况呢(哈希冲突)?这样的情况很常见,比如一个长度为10的int数组,数组下标就是0 ~ 9了,当存储数据时,我们将数据模10,得到数据在数组中的存储位置。现在要存储11这个数据,模10后得到1,那么我们就将11放到数组下标为1的位置,然后我们还要存储1,1模10还是1,那么1也要放到下标为1的位置,但这块位置已经被使用了,当1无法存储到自己应该存储的位置时,我们称这时发生了哈希冲突,或者说哈希碰撞。这是哈希结构O(1)的查找效率背后的代价——频繁出现的哈希冲突,开散列与闭散列是解决哈希冲突的两种形式,这篇文章主讲闭散列。

闭散列也称开放定址法,具体的算法是线性探测,如果插入数据时数组还有剩余空间,发生哈希冲突后将数据存储到未存储数据的位置,通常是从当前下标往后查找空位置,一旦找到空位置就把数据存进去。这样的算法看起来解决了哈希冲突,但会带来“我占用你的空间,你占用他的空间”这样的情况,进而使查找效率降低。除了线性探测,二次探测也是一种闭散列实现方式,线性探测是从当前下标一个一个地向后找,而二次探测就是跳跃地找,你+了1,二次探测+了1的平方,你+了2,二次探测+了2的平方…其与线性探测没有本质上的区别,它们都会带来“我占用你的空间,你占用他的空间”这样的情况,无非就是二次探测出现的概率降低

闭散列的模拟实现

整体结构的交代

首先我们需要一个数组存储键值对,并且还需要表征数组某个位置的情况(空,被使用,被删除),所以该数组需要存储键值对和表征状态的数据

enum State
{
	EMPTY,
	DELETE,
	EXIST
};

// 哈希表数组所存储的数据
template <class K, class V>
struct HashData
{
	State _state = EMPTY;	// 状态的表征
	pair<K, V> _kv;         // 键值对
};

那么哈希表的结构就很明显了,一个数组,当然还需要一个记录存储数据的多少的变量(用来实现负载均衡,用更多的空间减少哈希冲突)

template <class K, class V>
class HashTable
{
	typedef HashData<K, V> Data;
private:
	vector<Data> _table;   // 哈希表
	size_t _n;             // 负载因子
};

最后,对于哈希表肯定的操作是增删查改了,这里我们实现插入,删除和查找功能

template <class K, class V>
class HashTable
{
	typedef HashData<K, V> Data;
	typedef pair<K, V> Type;
public:

	bool Insert(const Type& kv); // 增
	bool Erase(const K& key);	 // 删
	Data* Find(const K& key);	 // 查
private:
	vector<Data> _table;   // 哈希表
	size_t _n = 0;         // 负载因子
};

查找接口的实现

根据用户传入的key值,将其转换成哈希值,映射到数组_table上,如果该位置被使用(状态为EXIT)并且两者的key值相同,将该位置的地址返回,如果key不同继续向后查找,直到位置的状态是EMPTY(遇到状态是DELETE也要继续向后查找,比如10,20,30将它们依次插入哈希表,当表的长度为10的时候,它们都冲突了,所以它们会依次向后排列,当20被删除,要查找30时,如果遇到20的DELETE就停止,则无法查找到30)

// Find函数的类外实现
template <class K, class V>
typename HashTable<K, V>::Data* HashTable<K, V>::Find(const K& key)
{
	// 数组长度为0直接返回
	if (_table.size() == 0)
	{
		return nullptr;
	}
	// 计算key对应的哈希值
	size_t hashi = key % _table.size();
	// 从对应哈希值处向后遍历,查找对应key值
	// 当该位置不为空时,判断该位置上的数据的key是否是想要的
	while (_table[hashi]._state != EMPTY)
	{
		// 注意如果该位置的状态是删除,不需要往后判断
		if (_table[hashi]._state != DELETE && _table[hashi]._kv.first == key)
		{
			return &_table[hashi];
		}
		hashi++;
		// 注意查找时不要越界
		hashi %= _table.size();
	}
	// 找不到返回空
	return nullptr;
}


插入接口的实现

由于我们默认哈希结构不允许键值冗余的情况存在,所以在插入数据前先判断key值是否已经存在,这里调用刚才实现的Find就可以。如果没有键值冗余的情况存在,我们需要将key转换成哈希值,将其存储到数组_table的对应位置,如果发生冲突就进行线性探测。

当然了,在插入数据前为了降低发生哈希冲突的概率,我们需要判断哈希表的负载因子是否大于指定的值,如果是则扩容(也就是当哈希表中的数据达到一定数量时,需要扩容,负载因子就是哈希表中的数据个数除以哈希表总大小,其范围在0~1)

// Insert函数的类外实现
template <class K, class V>
bool HashTable<K, V>::Insert(const Type& kv)
{
	if (Find(kv.first)) // 键值冗余发生,不插入
	{
		return false; 
	}
	// 扩容检查
	if (_table.size() == 0 || _n * 10 / _table.size() >= 7) // 当负载因子大于0.7,对哈希表扩容
	{
		// 先算出扩容后的内存
		size_t new_size = _table.size() == 0 ? 10 : 2 * _table.size();
		// 创建一个新的哈希表,将旧哈希表的数据插入的新哈希表,最后交换两者
		HashTable<K, V> new_table;
		// 开辟新表的空间
		new_table._table.resize(new_size);
		for (int i = 0; i < _table.size(); ++i)
		{
			// 将旧表中的键值对插入到新表中,复用Insert函数,此时肯定不会再次扩容
			if (_table[i]._state == EXIST)
			{
				new_table.Insert(_table[i]._kv);
			}
		}
		// 最后要交换旧表和新表
		_table.swap(new_table._table);
	}

	// 计算对应的哈希值
	size_t hashi = kv.first % _table.size();
	// 找到一个没有存储数据的位置
	while (_table[hashi]._state == EXIST)
	{
 		hashi++;
	}

	// 将数据存储到表中
	_table[hashi]._kv = kv;
	_table[hashi]._state = EXIST;
	// 记得_n的维护
	_n++;

	return true;
}

删除接口的实现

最后是哈希表的删除接口,很简单直接复用Find接口,查找要删除的数据是否存在,如果存在将其状态改为EXIST并且修改_n维护负载因子。如果不存在直接返回false

// Erase函数的类外实现
template <class K, class V>
bool HashTable<K, V>::Erase(const K& key)
{
	// 调用Find并接收其返回值
	Data* result = Find(key);
	if (result)
	{
		result->_state = EXIST;
		--_n;
		return true;
	}
	return false;
}

至于为什么这里不需要些构造函数与析构函数,首先是HashTable的成员_n不涉及内存管理,涉及内存管理的_table也因为vector实现了构造与析构,HashTable的默认构造和析构会调用vector的构造与析构,至于拷贝构造和拷贝赋值也是因为vector已经实现深拷贝,而其他内置成员也不涉及内存管理,所以我们使用默认函数即可

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值