学习笔记——基础hash思想及其简单C++实现

新星杯·14天创作挑战营·第17期 10w+人浏览 490人参与

哈希表

引言

哈希表(Hash Table)是一种查找时间复杂度为O(1)的数据结构,你没看错,它的查找时间复杂度是常数,比RBTree与AVL树还要高,但这里不要有错误观念,实际上,O(1)与O(logN)的差距并不算大,比如,对于2103 ≈ 一百万数据,对于O(logN),查询次数为30次;而O(1)查询次数无论数据量,通常查询次数固定,对于哈希表,其查询次数与内部设计相关,若哈希函数等设计良好,可能查询仅需几次,但若设计较差或遇到极端情况,则查询次数甚至可能会上升到N次(一直产生哈希冲突)。

1. 哈希的基础概念

哈希的时间复杂度虽然极低,但实际上其底层原理的学习成本远低于RBTree与AVL树,其实,稍微做过一些算法题的话,基本都会了解过计数排序,而计数排序的思路其实就相当于一个简单的哈希,事实上,哈希的原理就是数据的一一映射

计数排序:对于一组数据范围较小的数据,需要对其中数据出现的次数进行统计,可以采取创建一个长度为数据范围大小的数组,然后遍历数据,将数据出现的次数记录在数组中,最后再遍历数组,将数据按照出现的次数放入空间,具体来说:

#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<string.h>

//计数排序:根据数组内数据的最大值和最小值求出差值,创建该差值+1(差值+1才为元素个数)
//长度的数组arr2,然后arr2[0]用于存储在源数组中最小值出现的次数,
//arr2[1]储存min+1出现的次数……
//最后按顺序遍历arr2,当arr2[n] != 0时,将n + min放入源数组,当arr2[n] == 0时,++n
//
//计数排序的时间复杂度为O(n+k):n为源数组数据个数,k为差值+1
//也就是说,对于一组极差较小的数据,计数排序的时间复杂度接近O(n),很快
//空间复杂度为O(k)
//该排序的缺点是:只能排整数,不能排浮点数,结构体等



//计数排序的实现
void CountSort(int* arr, int n)
{
	//找出源数组的最值
	int max = arr[0], min = arr[0];
	for (int i = 1; i < n; i++)
	{
		if (arr[i] > max)
		{
			max = arr[i];
		}
		if (arr[i] < min)
		{
			min = arr[i];
		}
	}
	//开辟足够的空间
	int* str = (int*)malloc((max - min + 1) * sizeof(int));
	assert(str);
	memset(str, 0, (max - min + 1) * sizeof(int));
	//填充str数组
	for (int i = 0; i < n; i++)
	{
		str[arr[i] - min]++;
	}
	//将str的数据按思路填入源数组
	for (int i = 0, j = 0; i < n;j++)
	{
		while(str[j]--)
		{
			arr[i++] = j + min;
		}
	}
	//销毁malloc的内存
	free(str);
	str = NULL;
}

上述的计数排序缺点很明显,当数据返回较大时,空间复杂度就会很高,且仅通过数组下标进行映射的话,将会无法正确处理浮点数,结构体等数据,那么,如何才能解决这个问题呢?其实很简单,计数排序之所以限制大,是因为其映射关系过于直接,且要保证数据能够进行排序,而哈希表的主要功能是快速查询,因此也就没有这些顾虑

哈希函数:指的是用于进行数据映射的函数,下面会介绍三种映射方案,分别是除留余数法(重点介绍),乘法散列法,全域散列法

负载因子:哈希函数的选取可能导致空间到达一定程度时,尽管未将空间完全利用,但无法继续进行数据存储/查找空闲空间困难,消耗较大,因此需要使用负载因子来判断当前的空间占用率,若到达则扩容,以确保不会出现上述无法插入数据的情况

哈希冲突:由于哈希的核心思想是映射,就难免出现多个数据会映射到同一位置的情况,而多个数据映射到同一位置的情况,就被称为哈希冲突,而解决哈希冲突的方式也有多种,下面会主要介绍开放定址法链地址法

哈希表:哈希表便是一种数据结构了,由于实践中常常使用链地址法处理哈希冲突,所以其存储结构通常为一个指针数组,其中的指针往往是一个链表的头节点/平衡搜索二叉树的根节点,本文旨在了解哈希的思想,因此下面主要以较为简单的链表为例

2. 三种基础的哈希函数

2.1 除留余数法(重点)

除留余数法也叫除法散列法,原理很简单,就是将需要处理的数据模上当前数组空间的大小,那么得到的结果就会以下标的形式映射到数组中,但需要注意的是,作为除数的空间的大小若为2的幂,或10的幂等值,就会比较容易出现哈希冲突,数学研究发现,为尽可能避免频繁产生哈希冲突,使用除留余数法时,建议M(数组长度)取不太接近2的整数次幂的一个质数(素数)

哈希函数为:h(key)=key%M

注:M的取值也是有例外的,像是java的hash,底层就特意使用了2的幂作为数组大小,这样做的优点是可以避免进行模运算,能够采取对计算机更友好的模运算,而C++底层使用的方式是创建了一个静态素数表,这点会在模拟实现中详细说明

2.2 乘法散列法(了解)

乘法散列法对空间大小没有要求,其思路为:将数据代表的key乘一个系数A(0 < A < 1),然后取出结果的小数部分,用该小数乘数组长度M并向下取整,得到的结果便会以下标的形式映射到数组中,为减少哈希冲突的概率,有专家建议将A取黄金分割率0.6180339887…

哈希函数为h(key)=M*(A * key%1.0),举例来说:假设M为1024,key为1234,A=0.6180339887,A * key=762.6539420558,取⼩数部分为0.6539420558, M × ((A×key)%1.0)=0.6539420558 * 1024=669.6366651392,那么h(1234)=669

2.3 全域散列法(了解)

全域散列法的哈希函数为:h(key) = ((a × key + b)%P )%M

该哈希函数的核心作用是防御攻击者,对于哈希,最害怕的就是被插入一直造成哈希冲突的数据,因此,只要使用的哈希函数被公开,攻击者就完全可以制造出针对当前哈希的一组数据,使被攻击的程序崩溃

这里可能会有疑问,既然知道哈希函数具备这样的缺点,为什么还要公开程序所使用的哈希函数呢?

答案是:公开算法逻辑是现代密码学和计算机科学的一个基本原则,叫做“柯克霍夫原则”。其核心思想是,一个系统的安全性不应该依赖于算法的保密,而应该依赖于密钥(在这里就是随机选择的参数a和b)的保密。公开算法可以让全世界的专家来审查它是否有漏洞,从而让它更健壮。如果把算法本身藏起来(“安全通过隐匿”),一旦算法被泄露,整个系统就彻底崩溃了

那么,全域散列法如何做到既公开哈希又确保安全呢?

答案是:公开时,仅公开h(key) = ((a × key + b) % P )%M这个函数,其中a,b为随机值,会在每次程序运行时生成,直到当前程序结束,并且,该随机值可能连程序员本人都不得而知,也就是说,只要a,b的范围足够大,就可能产生极多确切的全域散列哈希函数,如此,攻击者想要确定哈希函数,就如同大海捞针,就算真的试出来,可能当前的程序已经结束了……

此时,可能还会有一个小疑问,我们知道,像是微信之类的大型程序,很有可能一启动就运行一辈子,这样的话,只要攻击者锲而不舍,那不就能尝试出正确的函数吗,但是但是,事实上,对于攻击者的锲而不舍,多次尝试,会被程序的防御措施拦截,而就算攻击者真的尝试出了正确的函数,并造成了有效的攻击,程序崩溃了,但只要在后台重启一下程序,就会重新随机出新的哈希函数,至此,对于攻击者来说,完全没必要付出这么大的成本进行一次攻击

2.4 其他

哈希函数当然不止有这三种,在一些大佬的教材中还出现了诸如平方取中法、折叠法、随机数法、数学分析法等,但这些方法相对更适用于一些局限的特定场景,本文仅用于了解基础哈希,暂且不对这些复杂哈希进行介绍

3. 两种基础的哈希冲突解决方案

需要注意一点,使用解决哈希冲突的方案的前提是,当前负载因子在对应方案的合理范围内,否则可能会造成效率低下,甚至永远无法找到合适位置等问题

3.1 开放定址法

开放定址法,也叫开放地址法,其思路为:当发生哈希冲突时,为造成冲突的元素通过某种规则寻找到一个数组中的空间位置,将该数据存储到此位置处,对于这里提到的某种规则,开放定址法提供了以下三种常用思路:

  1. 线性探测法:当发生哈希冲突时,查看当前下标+1%数组大小的位置是否空闲,若空闲则将发生冲突的元素存放到该位置,若仍然冲突,则继续按照最开始发生冲突的位置+n%数组大小的方式查看后续位置,直到找到一个空闲位置为止
  2. 二次探测法:当发生哈希冲突时,查看当前下标+/-1^2%数组大小的位置是否空闲,若空闲则将发生冲突的元素存放到该位置,若仍然冲突,则继续按照最开始发生冲突的位置+/-n^2的方式查看后续位置,直到找到一个空闲位置为止
  3. 双重散列法(了解即可):
    为开放定址法中最优解,本身是跳跃探测,但冲突率低于二次探测,且可通过合理的公式避免死循环,第一个哈希函数计算出的值发生冲突时,使用第二个哈希函数计算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。

两个哈希函数分别为:

  1. h1 (key) = hash0 =  key % M
  2. hc(key,i) = hashi = (hash0 +  i ∗ h2 (key)) % M, i  =  {1, 2, 3, …, M}

3.2 链地址法

链地址法是常见方法,并且,其思路相当简单,就是发生冲突时,将数据以链表/树结构存储在冲突的元素所在的位置(每个节点的链表/树也被称为哈希桶),当然,这也就意味着数组中存放的类型需要是节点的指针类型

4. 除留余数法+线性探测模拟实现

首先,需要解决一下实现中会遇到的一些问题:

  1. 关于除留余数法扩容方案:C++底层使用了质数表(包含了适合作为扩容值的质数)来指定扩容值,当需要扩容时,使用当前容量在质数表的下一个值进行扩容即可,扩容时也具备减少代码冗余或提高效率的办法,会注释在下面的代码中

    注:虽然质数表中的数据是有限的,但目前无需担心数据量会大到超过质数表最大值,理由是最大值为42亿+,若使用开放定址法解决哈希冲突,则每个位置仅存放一个char都会占据4G内存,若为结构体显然会极大,且由于一般哈希会采用链地址法,其中每个位置至少需要一个指针变量(8字节),若需要启用质数表最大值,则所占据的内存是相当恐怖的,所以基本不可能出现需要质数表中最大值的情况

  2. 关于key为不能直接使用除留余数法(无法取余)的类型时:key的类型可能为string,double,值也可以是负数,面对类似情况,无法直接取余,那么,就需要为HashTable类模板传递‘可以将string等无法直接取余的类型值顺利转换为可求余’仿函数(即HashTable的第三个模板参数)

  3. 关于自定义类型作为key时:由于需要判断key值是否冗余等原因,该自定义类型需包含==运算符重载,但由于库中某些类模板不具备==运算符重载,所以HashTable诞生了第四个模板参数(用于比较自定义类型的仿函数)

  4. 对数据进行操作时,需要判断当前位置是否为空,因此需要采取枚举的方式来标识状态

下面以实现不允许键值冗余的除留余数法+线性探测构建的k_v型Hash为例:

//在诸如find接口中,需要EMPTY时停止,DELETE时继续,因此需要创建相关枚举对位置进行状态标识
enum State
{
	EXIST,//存在
	EMPTY,//空
	DELETE//删除
};
//每个空间都需要存放当前空间的情况与pair<K, V>,因此,需创建HashData类模板
template<class K, class V>
struct HashData
{
	//这里使用了struct构建了类,因此可以暂且不写构造函数,可以在外部为_kv赋值
	pair<K, V> _kv = pair<K, V>();
	State _state = EMPTY;//一个位置的初始状态一定为EMPTY
};
//转换key为可求余值的仿函数
template<class K>
struct HashFunc
{
	//此方案可用于处理小数与负数且兼容正整数(仅为示例,具体需构建能尽可能减少哈希冲突的方案):
	unsigned long operator()(K key)
	{
		return (unsigned long)key;//直接将key显示类型转换为unsigned long类型
	}
};
//使用类模板特化,设计针对K为string的仿函数
//字符串转换成整形,最简单的办法就是把字符ascii码值相加
//但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的 
//这里我们使用BKDR哈希(真正能够解决K为stirng的方法)的思路,
//用上次的计算结果去乘以一个质数,且这个质数一般取31, 131等效果会比较好
template<>
struct HashFunc<string>
{
	unsigned long operator()(string key)
	{
		unsigned long hash = 0;
		for (const auto& e : key)
		{
			hash += e;
			hash *= 131;//注:BKDR哈希的方案面对长字符串很可能会溢出,但这也无所谓,
			//因为目的是将字符串转换为一个可求余的数,所以就算溢出也问题不大
		}
		return hash;
	}
};
//==运算符重载仿函数(这里只是举个例子,就先不做自定义类型的具体实现了)
template<class K>
struct Compare
{
	Compare(const K& key)
		:_key(key)
	{
	}
	bool operator==(K other)
	{
		return _key == other;
	}
	K _key;
};
//提供扩容数值的函数
inline unsigned long __stl_next_prime(unsigned long n)//注:inline为内联函数关键字,这里的函数参数为size
{
	//质数表
	static const int __stl_num_primes = 28;//表示质数表中数据个数为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;
	//lower_bound函数返回第一个参数到第二个参数的左闭右开区间中第一个大于等于n的值
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;//当n已经为质数表最大值时,仍返回该值,确保哈希表至少有一个可用的容量值(此时不代表空间存满,只是因子较大,冲突会加剧,因此仍可继续使用该值)
}

namespace open_address//命名空间——开放定址法
{
	//创建HashTable类模板
	template<class K, class V, class HashFunc = HashFunc<K>, class Compare = Compare<K>>
	class HashTable
	{
		typedef HashData<K, V> HashData;
		HashFunc hashfunc;//转换key值的仿函数对象(也可在需要是创建临时对象来进行函数调用,
						  //这里采用了直接实例化仿函数对象作为私有成员变量便于使用)
	public:
		HashTable()
			//:_tables(11)
			//:_tables(4)
			:_tables(__stl_next_prime(0))//使用质数表第一个值
		{
		}

		bool Insert(const pair<K, V>& kv)
		{
			//通过find进行判断,当键值冗余时,直接返回false
			if (Find(kv.first).first == true)
			{
				return false;
			}
			//当负载因子大于0.7时,需要扩容
			//注:这里最直观的写法是创建临时成员tables,将其在_tables.size()的基础上进行质数扩容,
			//	  随后将当前_tables的元素通过哈希函数全部重新插入到tables中(不能直接使用范围for将_tables的元素拷贝到tables中,这样会打破哈希函数的映射规律)
			//    最终与_tables进行swap(注:swap仅交换指针及成员变量,无需进行深拷贝,对于vector,swap效率高于赋值运算符重载,因此此处采用swap)
			//	  但这样会重复使用insert中的插入代码,致使代码冗余,
			//	  事实上,这里可以采取现代写法创建ht临时成员,直接实现Insert的代码复用
			if (_size * 1.0 / _tables.size() > 0.7)//可以采用将其中一个操作数*1.0的方式来避免/运算符面对整形的自动向下取整
			{
				HashTable ht;
				//ht._tables.resize(_tables.size() * 2);//这里参数最优解应为一个质数,这里暂且*2,后续会进行调整
				ht._tables.resize(__stl_next_prime(_tables.size() + 1));//使用质数表中质数进行扩容
				for (const auto& e : _tables)
				{
					if (e._state == EXIST)
					{
						ht.Insert(e._kv);//由于此时ht._tables的容量比_tables的容量大,所以无需担心此处insert会再次进行扩容操作
					}
				}
				swap(ht._tables, _tables);//这里采取效率更高的swap,直接交换容器的成员变量,而非使用赋值运算符重载进行深拷贝,提高了效率
			}
			size_t hash0 = hashfunc(kv.first) % _tables.size();//取余定位存储位置
			//当定位的位置存在元素时,进行线性探测
			if (_tables[hash0]._state == EXIST)
			{
				size_t i = 1;
				size_t hash1 = (hash0 + i) % _tables.size();
				while (_tables[hash1]._state == EXIST)//由于负载因子需在0.7以内,因此无需担心死循环,一定可以找到空位置
				{
					++i;
					hash1 = (hash0 + i) % _tables.size();
				}
				_tables[hash1]._kv = kv;
				_tables[hash1]._state = EXIST;
			}
			//当定位到的位置不存在元素时,直接将kv存储即可
			else {
				_tables[hash0]._kv = kv;
				_tables[hash0]._state = EXIST;
			}
			++_size;
			return true;
		}
		//Find查找思路:先使用哈希函数锁定位置,若此位置非所求,
		//则进行线性探测,当该位置状态为EXIST时,再次进行判断;
		//若为DELETE,则继续探测;若为EMPTY,则停止搜索,返回false
		pair<bool, V&> Find(const K& key)
		{
			size_t hash0 = hashfunc(key) % _tables.size();
			//若直接通过哈希函数查找到
			if (Compare(_tables[hash0]._kv.first) == key)
			{
				return pair<bool, V&>(true, _tables[hash0]._kv.second);
			}
			//若未直接找到,则进行线性探测
			size_t i = 1;
			size_t hash1 = (hash0 + i) % _tables.size();
			//当状态非空时,继续线性探测
			while (_tables[hash1]._state != EMPTY)//由于负载因子需不大于0.7,所以一定可以走到空(但需注意,初始值不能给1)
			{
				if (_tables[hash1]._state == EXIST && _tables[hash1]._kv.first == key)//当状态为存在且与key相等
				{
					return pair<bool, V&>(true, _tables[hash1]._kv.second);
				}
				++i;
				hash1 = (hash0 + i) % _tables.size();
			}
			//退出循环时即为找到EMPTY时,返回false
			return pair<bool, V&>(false, _tables[hash0]._kv.second);//最好不要返回kv.second,这里有待改善->最终敲定返回指针(链地址法),不过这里就暂且不改了
		}
		//Erase实现思路:使用哈希函数+线性探测,若找到,则将其状态置为DELETE即可
		//循环条件同样为状态非EMPTY
		bool Erase(const K& key)
		{
			size_t hash0 = hashfunc(key) % _tables.size();
			//若直接通过哈希函数查找到
			if (Compare(_tables[hash0]._kv.first) == key)
			{
				_tables[hash0]._state = DELETE;
				--_size;
				return true;
			}
			//若未直接找到,则进行线性探测
			size_t i = 1;
			size_t hash1 = (hash0 + i) % _tables.size();
			//当状态非空时,继续线性探测
			while (_tables[hash1]._state != EMPTY)
			{
				if (_tables[hash1]._state == EXIST && Compare(_tables[hash1]._kv.first).operator==(key))
				{
					_tables[hash1]._state = DELETE;
					--_size;
					return true;
				}
				++i;
				hash1 = (hash0 + i) % _tables.size();
			}
			//退出循环时即为找到EMPTY时,返回false
			return false;
		}
		size_t Size()
		{
			return _size;
		}
		void Print()
		{
			for (const auto& e : _tables)
			{
				if (e._state == EXIST)
				{
					cout << "key: " << e._kv.first << " val: " << e._kv.second << endl;
				}
			}
		}
	private:
		vector<HashData> _tables;//使用vector实现哈希表整体(姑且先将允许存放数据的空间设置为11)
		size_t _size = 0;//_size记录真实的已存储数据,用于计算负载因子,以便进行扩容操作
	};
}

5. 除留余数法+链地址法模拟实现

链地址法的实现甚至更为简单,不过要注意,此时vector的存储类型应设置为HashData*,且HashData中也应该具备next指针,不过,值得一提的是,该方式也无需枚举表示状态了,若当前位置不存在节点,大可用nullptr进行表示

#pragma once
#include<iostream>
#include<vector>
#include<assert.h>
using namespace std;
//需要构建单链表,因此,节点类模板中成员变量应为pair<K, V> kv与HashData* next
template<class K, class V>
struct HashData
{
	HashData(const pair<K, V>& kv)
		:_kv(kv)
	{
	}
	pair<K, V> _kv;
	HashData* _next = nullptr;
};
//转换key为可求余值的仿函数
template<class K>
struct HashFunc
{
	//此方案可用于处理小数与负数且能兼容处理正整数(仅为示例,具体需构建能尽可能减少哈希冲突的方案):
	unsigned long operator()(K key)
	{
		return (unsigned long)key;//直接将key显示类型转换为unsigned long类型
	}
};
 
//特化仿函数BKDR哈希处理string类型
template<>
struct HashFunc<string>
{
	unsigned long operator()(string key)
	{
		unsigned long hash = 0;
		for (const auto& e : key)
		{
			hash += e;
			hash *= 131;
		}
		return hash;
	}
};
//==运算符重载仿函数简单示例
template<class K>
struct Compare
{
	Compare(const K& key)
		:_key(key)
	{
	}
	bool operator==(K other)
	{
		return _key == other;
	}
	K _key;
};
//提供扩容数值的函数
inline unsigned long __stl_next_prime(unsigned long n)//注:inline为内联函数关键字,这里的函数参数为size
{
	//质数表
	static const int __stl_num_primes = 28;//表示质数表中数据个数为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;
	//lower_bound函数返回第一个参数到第二个参数的左闭右开区间中第一个大于n的值
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;//当n已经为质数表最大值时,仍返回该值,确保哈希表至少有一个可用的容量值(此时不代表空间存满,只是因子较大,冲突会加剧,因此仍可继续使用该值)
}

//使用链地址法处理哈希冲突:
//思路很简单,当通过哈希函数映射到的key的位置已被占据,则将造成哈希冲突的新元素挂在源数据后
//(可以通过单链表,双链表,java底层似乎为红黑树,C++底层为单链表,因此这里以单链表为例)
//对于链地址法,当负载因子处于0.7~1之间进行扩容(C++底层为确保降低扩容次数,默认为1,java释放/申请空间开销比C++小,且更注重查找效率,默认为0.75)

namespace hash_bucket//命名空间——哈希桶
{
	template<class K, class V, class HashFunc = HashFunc<K>, class Compare = Compare<K>>
	class HashTable
	{
		typedef HashData<K, V> HashData;
		HashFunc hashfunc;
	public:
		HashTable()
			:_tables(__stl_next_prime(0))
			//:_tables(1)
		{
		}
		HashTable(const HashTable& other)
			:_tables(other._tables.size())//调用拷贝构造时直接为新对象开辟与other大小相同的空间,而非一边插入一边扩容,避免因多次扩容降低效率
		{
			for (size_t i = 0; i < other._tables.size(); ++i)
			{
				if (other._tables[i])
				{
					HashData* cur = other._tables[i];
					while (cur)
					{
						this->Insert(cur->_kv);
						cur = cur->_next;
					}
				}
			}
		}
		HashTable& operator=(const HashTable& other)
		{
			HashTable mid = other;
			swap(mid._tables, _tables);
			swap(mid._size, _size);
			return *this;
		}
		~HashTable()
		{
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				if (_tables[i])
				{
					HashData* cur = _tables[i];
					HashData* next = nullptr;
					while (cur)
					{
						next = cur->_next;//在进入循环第一步就进行next的更新,
										  //而不是在最后一步(最后一步说明之前以deletecur且cur目前等于cur->next,但不确定next是否为空,需额外if)
						delete cur;
						cur = next;
					}
				}
			}
		}
		
		bool Insert(const pair<K, V>& kv)
		{
			//调用Find接口实现不允许键值冗余的哈希表
			if (Find(kv.first))//返回非空指针,表示查询成功,出现键值冗余,返回false
			{
				return false;
			}

			//当负载因子大于0.7时,需进行扩容操作
			//这里的扩容操作不建议使用开放定址法中的创建HashTable临时成员并复用Insert函数的思路
			//(每次重新插入都需要重新开辟空间/释放源空间,效率极低),
			//这里建议采取使用key值找到空间更新后的位置,然后直接链接key所在的源空间
			
			//if (_size * 1.0 / _tables.size() > 1000)//->测试删除逻辑
			if (_size * 1.0 / _tables.size() > 0.7)
			{
				//vector<HashData*> newtables(_tables.size() * 2);
				//创建临时vector成员,将其size扩充到大于源vector的质数
				vector<HashData*> newtables(__stl_next_prime(_tables.size() + 1));//lower返回区间内不小于参数的值,因此需使用size()+1做参数,避免扩容失败
				//变量源vector,将旧数据全部链接到newtables
				for (size_t i = 0; i < _tables.size(); ++i)
				{
					HashData* cur = _tables[i];
					//取节点并插入时要注意不要直接取整个链表,而应该一个节点一个节点取,进行映射(原因是,比如扩容前的同映射位置数据在扩容后有可能不会再映射到同一位置了)
					while (cur)
					{
						HashData* next = cur->_next;
						size_t hash0 = hashfunc(cur->_kv.first) % newtables.size();
						cur->_next = newtables[hash0];
						newtables[hash0] = cur;
						cur = next;
					}
				}
				swap(_tables, newtables);//直接交换两容器中的成员变量,避免了拷贝,提高了效率
			}
			
			HashData* newdata = nullptr;
			newdata = new HashData(kv);
			assert(newdata);
			size_t hash0 = hashfunc(kv.first) % _tables.size();
			//当未造成哈希冲突时,直接插入即可
			if (_tables[hash0] == nullptr)
			{
				_tables[hash0] = newdata;
			}
			//当产生哈希冲突时,使用链地址法(头插)
			else
			{
				newdata->_next = _tables[hash0];
				_tables[hash0] = newdata;
			}
			
			++_size;
			return true;
		}
		//Erase接口的实现不可复用Find接口,因为Erase过程中可能需要上一个节点的地址,而单链表无法向上查询
		bool Erase(const K& key)
		{	
			//Erase接口使用prev的简单实现:
			size_t hash0 = hashfunc(key) % _tables.size();
			HashData* cur = _tables[hash0];//最终被删除的节点指针
			HashData* prev = nullptr;//指向最终被删除节点的上一个节点
			while (cur)//当cur为空,直接不进入循环,返回false即可
			{
				if (Compare(cur->_kv.first) == key)
				{
					//当prev不为空时,表示所找数据非链头,进行删除中间节点的方法
					if (prev)
					{
						prev->_next = cur->_next;//由于所找数据非链头,所以无需更改数组中指针
					}
					//否则执行删除头节点的方式
					else {
						_tables[hash0] = cur->_next;
					}
					delete cur;
					--_size;
					return true;
				}
				prev = cur;
				cur = prev->_next;
			}
			//当cur为空/单链表中未查到,直接返回false即可
			return false;
		}
		void Print() const 
		{
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				HashData* cur = _tables[i];
				while (cur)
				{
					cout << "key: " << cur->_kv.first << " val: " << cur->_kv.second << endl;
					cur = cur->_next;
				}
			}
		}
		//Find接口思路很简单,使用哈希函数找到当前key对应的位置,然后遍历所在链表即可
		//找到则返回对应指针,未找到返回nullptr
		HashData* Find(const K& key)  
		{
			size_t hash0 = hashfunc(key) % _tables.size();
			auto cur = _tables[hash0];
			while (cur)
			{
				if (Compare(cur->_kv.first) == key)
				{
					return cur;
				}
				cur = cur->_next;
			}
			return nullptr;
		}
		size_t Size() const 
		{
			//cout << _tables.size() << endl;
			return _size;
		}
	private:
		vector<HashData*> _tables;//如上述思路,链地址法需将vector元素的类型设置为HashData*
		size_t _size = 0;
	};
}

结语

至此,哈希的基础实现就告一段落了,由于不需要掌握哈希函数的原理,哈希表的基础模拟实现难度算是相当低的了,但更重要的是体会映射的思想,并且以此为基础,了解布隆过滤器等以映射思想为基础的数据结构算法,后续作者也会对这些更复杂的算法进行了解,并书写博客,与大家分享

评论 2
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值