哈希表的实现

一、哈希

1.1 哈希的概念

什么是哈希呢

哈希又称散列,是一种组织数据的方式是一种通过哈希函数把关键字 key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出 key 存储的位置,进行快速查找

. 直接定址法

当关键字的范围比较集中时,就可以采用直接定址法。举个例子,一组关键字都在0,99之间,我们就可以开一个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在a,z的小写字母,那么我们就可以开一个26个数的数组,每个关键字的 ascii码值就是存储位置的下标。

直接定址法本质就是用关键字计算出一个绝对位置或者相对位置

但是这种方式也有缺点,那就是关键字的范围比较分散时,就很浪费内存甚至内存不够用。所以这种方式也比较鸡肋。

利用哈希函数来计算哈希值的这种方式,有一个问题就是,两个不同的 key 可能会映射到同一个位置去,这个问题就叫做哈希冲突/哈希碰撞理想情况下是找一个好的哈希函数来避免哈希冲突,但哈希冲突是不可避免的。所以要尽可能的设计出优秀的哈希函数来减少冲突的次数

. 负载因子

假设哈希表中已经映射存储了N个值,哈希表的大小为M那么负载因子 = N / M负载因子也可以叫做载荷因子/装载因子等负载因子越大,哈希冲突的概率越高,空间利用率越高,反之负载因子越小,哈希冲突的概率越低,空间利用率越低

为什么呢?因为负载因子 = N / M负载因子越大没说明N越大,哈希表中存储的数据越多,所以空间利用率越高,哈希冲突概率越高

通过上述对于哈希的基础了解,我们可以看到,我们将关键字映射到数组中位置,一般是整数好做映射计算,如果不是整数,就需要先将关键字转换为整数

1.2 除法散列法/除留余数法

除法散列法也叫做除留余数法 ,顾名思义,假设哈希表的大小为M,那么通过 key 除以M 的余数作为映射位置的下标也就是哈希函数为:h(key) = key % M

当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。为什么呢因为如果是这种值的话,那么在利用上述哈希函数来计算的时候,就相当于保留了 key 的后 X位,那么后X位相同的值,计算出的哈希值是一样的,就容易冲突因此我们要尽可能的让更多位参与运算,这样就可以降低哈希冲突的概率

因此,在使用除法散列时,建议M取不太接近2的整数次幂的一个质数

1.3 乘法散列法

乘法散列法对哈希表大小M没有要求,第一步用关键字 k 乘上常数A(0<A<1),并抽取 k * A 的小数部分。第二步再用M乘以 k * A的小数部分,再向下取整

1.4 全域散列法

如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如让所有关键字全部落入同一个位置中,这种情况是存在的,只要散列函数是公开且确定的,就可以实现此攻击 。所以,为了解决这个问题,就可以给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列

需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数来使用,后续增删改查都固定使用这个散列函数,否则每次哈希都是随机选取一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的 key 了

1.5 处理哈希冲突

实践中哈希表一般还是选取除法散列法来作为哈希函数 ,不管选用哪个哈希函数也避免不了哈希冲突,那么我们应该如何解决哈希冲突呢

有两种方法开放定址法和链地址法

. 开放定址法

开放定址法中所有的元素都放到哈希表里,当一个关键字 key 用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置存储。这里的规则有三种线性探测,二次探测,双重探测

线性探测从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置

因为负载因子小于1,所以最多探测M-1次,一定能找到一个存储 key 的位置

线性探测比较简单,如果 hash0 位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到 hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积二次探测可以一定程度改善这个问题

二次探测从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置,如果往左走到哈希表头,则回绕到哈希表尾的位置

双重散列第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟 key 相关的偏移量的值,不断往后探测,直到寻找到下一个没有存储数据的位置为止

. key 不能取模的问题

当 key 是 string/Date 等类型时,key 不能取模,那么我们需要给HashTable哈希表增加一个仿函数,这个仿函数支持把 key 转换成一个可以取模的整形,如果 key 可以转换成整型并且不容易冲突,那么这个仿函数就用默认参数即可,如果这个 key 不能转换成整型,我们就需要自己实现一个仿函数传给这个参数

实现这个仿函数的要求就是尽量 key 的每个值都参与到计算中,让不同的 key 转换出的整型值不同

. 链地址法

解决冲突的思路开放定址法中所有的元素都放到哈希表里链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,链地址法也叫做拉链法或者哈希桶

开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。为什么呢

负载因子 = N / M,N代表哈希表中存储数据的个数,M代表哈希表的大小在开放定址法中哈希表直接存储的就是数据,所以为了避免频繁的哈希冲突,我们不会将哈希表中全部填入数据,所以负载因子小于1而链地址法中,因为哈希冲突时会将多个数据以链表的形式存储在哈希表中,所以哈希表中存储数据的个数是有可能大于哈希表的大小的,所以负载因子可以大于1

如果极端场景下,某个桶特别长怎么办可以考虑使用全域散列法这样就不容易被针对了。但是偶然情况下,某个桶很长,查找效率很低怎么办我们可以将链表转化为红黑树,这样就可以提高查找时的效率了。不过,在C++中,unordered_map,unordered_set在底层并没有采用红黑树来实现,是用链地址法来实现的

二、链地址法代码实现

. HashTable.h

cpp 复制代码
#pragma once
#include<iostream>
#include<vector>
#include<unordered_map>
using namespace std;
static const int __stl_num_primes = 28;
//数组
//哈希这里用的素数表的目的是为了让数的更多比特位参与运算,
//从而降低哈希冲突(因为素数无法被整除,所有比特位都会参与运算),
//如果是2的整数幂(较小)或者是2的倍数,参与运算的比特位就会很少,
//就会增加哈希冲突
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
};

inline unsigned long __stl_next_prime(unsigned long n)
{
	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;
}


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

//特化
template<>
struct HashFunc<string>
{
	size_t operator()(const string& key)
	{
		size_t hashi = 0;
		for (const auto& ch : key)
		{
			hashi *= 131;
			hashi += ch;
		}
		return hashi;
	}
};

enum State
{
	EXIST,
	EMPTY,
	DELETE
};


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


template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	//构造函数
	HashTable(size_t size = __stl_next_prime(0))
		:_tables(size)
		,_n(0)
	{}

	bool Insert(const pair<K, V>& kv)
	{
		//负载因子达到0.7就开始扩容
		if ((double)_n / (double)_tables.size() >= 0.7)
		{
			//第一种方法
			//申请一块新空间,遍历旧表,重新映射
			//vector<HashData<K, V>> newtables(_tables.size() * 2);
			//for (size_t i = 0; i < _tables.size(); ++i)
			//{
			//	if (_tables[i]._state == EXIST)
			//	{
			//		size_t hash0 = _tables[i]._kv.first % newtables.size();
			//		//...
			//	}
			//} 
			 
			
			//第二种方法
			//HashTable<K, V> newHT(_tables.size() * 2);
			HashTable<K, V> newHT(__stl_next_prime(_tables.size() + 1));
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				if (_tables[i]._state == EXIST)
				{
					newHT.Insert(_tables[i]._kv);
				}
			}
			//调用的是vector里面的swap函数
			_tables.swap(newHT._tables);
			//不需要
			//扩容是为了减少哈希冲突,重新映射关系,并没有增添新的数据
			//swap(_n, newHT._n);
		}
		Hash hs;
		//查找插入位置
		//取模操作符只能用于整数
		size_t hash0 = hs(kv.first) % _tables.size();
		size_t hashi = hash0;
		size_t i = 1;
		//线性探测
		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)
	{
		Hash hs;
		size_t hash0 = hs(key) % _tables.size();
		size_t hashi = hash0;
		size_t i = 1;
		//线性查找
		//因为是线性探测,所以key取模的位置有可能就是要查找key的位置
		//但也有可能因为该位置被其他元素所占据,从而线性探测到其他位置
		//查找过程中,如果遇到了EMPTY说明未找到对应的key
		while(_tables[hashi]._state == EXIST)
		{
			if (_tables[hashi]._kv.first == key)
			{
				return &_tables[hashi];
			}
			hashi = (hash0 + i) % _tables.size();
			++i;
		}
		return nullptr;
	}

	bool Erase(const K& key)
	{
		HashData<K, V>* ht = Find(key);
		if (ht)
		{
			ht->_state = DELETE;
			return true;
		}
		return false;
	}

private:
	vector<HashData<K, V>> _tables;
	size_t _n;//表中存储数据的个数
};

. test.cpp

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS
#include"HashTable.h"
void TestHT1()
{
	int a[] = { 19, 30, 5, 36, 13, 20, 21, 12 };
	HashTable<int, int> ht;
	for (auto e : a)
	{
		ht.Insert({ e, e });
	}

	for (size_t i = 0; i < 50; i++)
	{
		ht.Insert({ rand(),1 });
	}

	cout << ht.Find(-20) << endl;
	cout << ht.Find(9) << endl;

	ht.Erase(30);

	cout << ht.Find(20) << endl;
	cout << ht.Find(9) << endl;
	cout << ht.Find(30) << endl;
}

struct HashFuncString
{
	size_t operator()(const string& key)
	{
		size_t hashi = 0;
		for (auto ch : key)
		{
			hashi += ch;
		}

		return hashi;
	}
};

struct Date
{
	int _year;
	int _month;
	int _day;
};

void TestHT2()
{
	//HashTable<string, string, HashFuncString> dict;
	HashTable<string, string> dict;
	dict.Insert({ "sort", "排序" });
	dict.Insert({ "string", "字符串" });

	HashTable<double, int> ht;
	ht.Insert({ 1.23, 1 });

	unordered_map<string, string> stddict;
	stddict.insert({ "sort", "排序" });
	stddict.insert({ "string", "字符串" });

	//unordered_map<Date, string> m2;
}
int main()
{
	//TestHT2();
	return 0;
}
相关推荐
汉克老师1 小时前
GESP6级C++考试语法知识(五十五、动态规划----背包问题(八、混合背包)
c++·动态规划·dp·背包问题·gesp六级·混合背包问题
C+-C资深大佬1 小时前
Python 新手学习指南
开发语言·python
玖釉-1 小时前
nvpro_core2 详解:NVIDIA Vulkan / OpenGL 图形样例背后的现代 C++ 基础库
c++·windows·图形渲染
不会C语言的男孩1 小时前
C++ Primer 第19章:特殊工具与技术
数据结构·c++
小张小张爱学习1 小时前
Java基础面试题
java·开发语言
Drone_xjw2 小时前
Qt国际化多语言配置详解-入门到精通
开发语言·qt·命令模式
爱吃提升2 小时前
Python 多线程 threading + 多进程 multiprocessing 完整实操教程
开发语言·python
不会C语言的男孩2 小时前
C++ Primer 第18章:用于大型程序的工具
开发语言·c++
星恒随风2 小时前
C++ 类和对象入门(三):拷贝构造、赋值运算符重载和深浅拷贝
开发语言·c++·笔记·学习