【C++】哈希

一、哈希概念

顺序结构以及平衡树中,元素关键码(key)与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),在平衡树中查找的时间复杂度为O(logN),搜索的效率取决于搜索过程中元素的比较次数

有没有一种理想的搜索方法可以实现可以不作任何比较,一次直接从表中得到要搜索的元素?

答案是有的,那就是使用哈希表!

说到哈希表,我们就需要先知道哈希的定义是什么?

哈希(hash)又称散列,是⼀种组织数据的方式。从译名来看,有散乱排列 的意思。本质就是通过哈希函数 把关键字key跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出key存储的位置,进行快速查找。

哈希表就是以哈希的思想设计出来的一种表结构,哈希表不等同于哈希。

二、哈希函数

哈希函数就是一种映射规则。

1、直接定址法

当关键字的范围比较集中时,直接定址法就是非常简单高效的方法,比如⼀组关键字都在[0,99]之间,那么我们开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在[a,z]的小写字母,那么我们开一个大小为26的整形数组,每个关键字acsii码 - 'a',结果就是该关键字值在数组中映射的下标。也就是说直接定址法本质就是用关键字计算出⼀个绝对位置或者相对位置。

范围比较集中就说明这组数中最大值与最小值的差值不大;差值不大,我们就可以开一个不大数组来进行一一映射。我们以一个题目为例,大家就能明白了:

题目:

给定一个字符串 s找到它的第一个不重复的字符,并返回它的索引 。如果不存在,则返回 -1

解答:

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string s) {
        int count[26]={0};  //由于小写字母只有26个,所以我们就可以开26个空间来进行映射
        for(auto ch:s)
            count[ch-'a']++;  //"相对位置",映射过程:如a字符会映射到数组下标为0的位置上去,如果count[0]==1,说明字符串s中只有一个a;如果count[0]==2,说明字符串s中有两个a

        for(int i=0;i<s.size();i++)
            if(count[s[i]-'a'] == 1)  //根据映射后的结果来判断答案
                return i;

        return -1;
    }
};

通过这个题目,相信大家已经了解了哈希映射的大致过程,哈希映射是通过哈希函数来完成的,哈希函数就是一种方法,一种如何映射的方法。哈希函数有许多种,每种都有它自己独特的特点,这里的直接定址法就是一种方法,这种方法对于范围集中的数据有天然优势。

直接定址法的缺点也非常明显:当关键字的范围比较分散时,就很浪费内存甚至内存不够用:

假设我们的数据范围是在[0, 9999]之间的5个数,难道要开10000个数组空间来映射这5个数吗?显然是不行的,这就会导致空间浪费甚至内存不够用(因为栈的空间本来就不大),所以我们这时就不考虑使用直接定址法了。

2、除留余数法/除法散列法(主要)

Ⅰ、概念

除法散列法也叫做除留余数法,顾名思义**,**假设哈希表的大小为M,key的个数为N,那么就可以让每一个key除以M的余数作为其映射位置的下标,也就是哈希函数为:h(key)=key%M。(M是要大于等于N的)

假设我们的数据范围是在[0, 9999]之间的5个数,那我们就可以开一个比5稍微大一点空间的数组,根据除留余数法,让这5个数依次映射进去,这样就可以大大节省空间。

但是这种做法会出现一个问题:不同的数可能会映射到相同位置,假设M=11,key1=3,key2 = 25,那么key1%M == 3,key2%M==3,这样的话,key1和key2就映射到同一个位置上去了,这种情况是不可避免地,所以我们要想办法解决。

这里先引出两个概念:

☛哈希冲突

两个不同的key可能会映射到同一个位置去,这种问题我们叫做哈希冲突 或者哈希碰撞。理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。

☛负载因子

假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子 = N/M,负载因子在有些地方也翻译为载荷因子/装载因子等,它的英文为load factor。负载因子越大,空间利用率越高,同时哈希冲突的概率也就越高;负载因子越小,空间利用率越低,同时哈希冲突的概率也就越低。

负载因子的大小最好<=0.7。

Ⅱ、减少冲突常用到的方法

下面我会围绕着冲突问题展开进行讲解:

当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂(减少冲突发生的概率)等。如果是2^x,那么key%2^x本质相当于保留key二进制形式的后x位,那么后x位相同的值,计算出的哈希值都是⼀样的,就会冲突。如:{63,31}看起来没有关联的值,如果M是16,也就是2^4 ,那么计算出的哈希值都是15,因为63的⼆进制后8位是00111111,31的⼆进制后8位是00011111。如果是10^x ,就更明显了,保留的都是10进值的后x位,如:{112,12312},如果M是100,也就是10^2 ,那么计算出的哈希值都是12。

所以我们在使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数) -- 减少冲突发生的概率。

需要说明的是,实践中也是八仙过海,各显神通,Java的HashMap采用除法散列法时就是用2的整数次幂做哈希表的大小M,但是它不是单纯的去取模,比如M是2^16次方,本质是取后16位,但它的映射规则不是直接%M,而是这样的:映射下标是(key>>16) & (key & (1 >> 16 -1)) -- "1>>16其实就是2^16"。这样玩的话,就不用取模,而可以直接位运算,相对而言,位运算比模更高效⼀些,也就是说我们映射出的值还是在[0,M)范围内(针对M取2^16次方这种情况),但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些。所以我们上面建议M取不太接近2的整数次幂的⼀个质数是大多数数据结构书籍中写的理论,但是实践中,我们要抓住本质,灵活运用。

上面说的都是如何减少冲突的次数,但实际中冲突问题是不可避免的。所以遇到冲突问题,如何解决是需要我们考虑的,这其中主要有两种解决冲突的方法:1.开放定址法, 2.链地址法

Ⅲ、解决冲突常用到的方法

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

在开放定址法中所有的元素都放到哈希表里,当⼀个关键字key用哈希函数计算出的位置冲突了,则按照某种规则 找到⼀个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于某个值的(通常小于等于0.7),这样就会确保能够找到⼀个没有存储数据的位置来存储冲突的key。根据"按照某种规则"中的规则不同又可以分为:线性探测、二次探测、双重探测。

①线性探测:

从发生冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为止,如果走

到哈希表尾部,则回绕到哈希表头的位置。

下面演示 {19,30,5,36,13,20,21,12} 等这一组值映射到M=11的表中。(这里的M=11就是不太接近2的整数次幂的一个质数)

首先计算它们映射的下标(除留余数法):

|--------------------------------------------------------------------------------|
| h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) =10,h(12) = 1 |

分析:

首先,19映射到下标为8的位置上:

30理应映射到下标为8的位置上,但是此时下标为8的位置上已经有值了,根据线性探测,向后找空位那就是下标为9的位置:

接着5映射到下标为5的位置上,36映射到下标为3的位置上,13映射到下标为2的位置上:

20理应映射到下标为9的位置上,但此时下标为9的位置上已经有值了,根据线性探测,向后找空位那就是下标为10的位置:

21理应映射到下标为10的位置上,但此时下标为10的位置上已经有值了,根据线性探测,向后找空位那就是下标为0的位置:

12映射到下标为1的位置上:

最终映射结果:

上面讲了这么多理论知识,接下来我们根据代码来理解一下除留余数法吧!

写代码之前,再讲一个小点:我们在查找某个元素时,首先根据除留余数法找到映射下标,如果哈希表中这个下标对应有值,那么如果这个值就是我们要查找的,那么ok,返回true;如果这个值并不是我们想要的,我们应该继续向后找,直到找到空位置,因为若找到空位置还没有找到那么就一定不存在,返回false。那么还有一种特殊情况,如果我们根据除留余数法找到映射下标,而这个下标在哈希表中对应的值被删了,如果没有特殊声明,那就可以理解为空,根据上述规则,遇到空就停止,那么应该停止吗?答案是:不应该。如果你要查找的值因为冲突被挤到后面去了,如果停止,意味着返回false,但其实是存在的,应该返回true。

要想解决上述问题,很简单,用枚举给个状态值就行了,下面先看代码框架:

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

enum State
{
	EXIST,  //表示当前位置有值
	EMPTY,  //表示当前位置为空
	DELETE  //表示当前位置有值,但被删除了
};

template<class K, class V>
struct HashData  
{
	pair<K, V> _kv;    //哈希表中每个元素类型
	State _state = EMPTY; //当前位置的状态,初始状态都是EMPTY,因为初始状态表中还没有映射数据
};

template<class K, class V>
class HashTable
{
public:
	HashTable()
		:_tables(11) //假设M == 11,也就是哈希表的大小
		, _n(0)
	{}
private:
	vector<HashData<K, V>> _tables; //这里的哈希表我们用数组表示,里面的元素类型是HashData
	size_t _n; //记录表中已经映射的数据个数
};

哈希表中的元素类型之所以不是pair,是因为我们需要有东西来表示当前位置状态,所以用了一个HashData结构体来同时存储这两个信息。

(1)Insert

上述框架看懂后,我们接着实现insert功能:

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	int hash0 = kv.first % _tables.size(); //新元素在哈希表中的映射下标
	//判断hash0位置上是否有值,若有值,需要进行线性探测来放到一个合适的位置
	int hashi = hash0, i = 1;
	while (_tables[hashi]._state == EXIST)
	{
		//方式1(线性探测)
		//hashi = (hashi + 1) % _tables.size();

		//方式2(线性探测)
		hashi = (hash0 + i) % _tables.size();
		++i;
	}
	_tables[hashi]._kv = kv; //放值到对应映射位置
	_tables[hashi]._state = EXIST;//同时设置状态
	++_n;

	return true;
}

这段代码非常简单,也很容易理解。 但是,这段代码会有bug,当插入的值大于11时,也就是当哈希表满了,再进行插入时,那么while就会变为死循环,很快代码就会崩。所以,当我们插入一定数量的值后,需要对哈希表进行扩容,那么到底满足什么条件时扩容,这就需要根据我们上面提到的负载因子决定,通常情况下负载因子大于等于0.7时就扩容。那么我们要怎么扩容呢?开一个比原表空间大二倍的新表,然后将原来表中的数据拷贝到新表中,然后交换它们两个吗?这肯定不行,如果直接将原表中的数据拷贝到新表,那么映射关系就全变了,比如21在原表中映射的是下标为10的位置,如果直接拷贝到新表那么还是到下标为10的位置上,但其实新表的大小为22(扩2倍),真正的映射关系应该是下标为21的位置。所以,我们在扩容时应该重新更新映射关系!

代码实现:

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	//扩容方式1
	//负载因子若大于等于0.7就扩容
	if (_n * 1.0 / _tables.size() >= 0.7)
	{
		//扩容后必须将原先表中的数据重新映射到新表中,否则映射规则就会混乱
		vector<HashData<K, V>> newtables(_tables.size() * 2);
		for (auto& data: _tables)
		{
			if (data._state == EXIST)
			{
				int hash0 = data._kv.first % newtables.size(); 
				int hashi = hash0, i = 1;
				while (newtables[hashi]._state == EXIST)
					hashi = (hash0 + (i++)) % newtables.size();
				newtables[hashi]._kv = data._kv; 
				newtables[hashi]._state = EXIST;
			}
		}
		_tables.swap(newtables); //扩容后记得将新表给"我"
	}

	int hash0 = kv.first % _tables.size(); //新元素在哈希表中的映射下标
	//判断hash0位置上是否有值,若有值,需要进行线性探测
	int hashi = hash0, i = 1;
	while (_tables[hashi]._state == EXIST)
	{
		//方式1
		//hashi = (hashi + 1) % _tables.size();

		//方式2(线性探测)
		hashi = (hash0 + i) % _tables.size();
		++i;
	}

	_tables[hashi]._kv = kv; //放值到对应映射位置
	_tables[hashi]._state = EXIST;//同时设置状态
	++_n;
	return true;
}

这种扩容方法当然可以满足我们的需求,但是这段扩容代码的逻辑和下面扩容后映射时的逻辑几乎一模一样,这会显得代码有点"难看",我们可以使用一个巧妙地方法来帮助我们完成扩容任务:

cpp 复制代码
//扩容方式2
if (_n * 1.0 / _tables.size() >= 0.7)
{
	HashTable<K, V> newht; //直接搞一个新的哈希表
	newht._tables.resize(2 * _tables.size()); //2倍扩容

	for (auto& data : _tables)
		if (data._state == EXIST)
			newht.Insert(data._kv); //这里是一定不会扩容的,可以放心调用

	_tables.swap(newht._tables);
}

这种方式有点"偷梁换柱"的感觉,但是这种方式比第一种要"好看"。

插入的逻辑基本上完成了,但是还有一个问题,我们之前说过哈希表的大小最好取不太接近2的整数次幂的一个质数,而我们在扩容时,直接扩的是2倍,那就不是素数啦,那怎么改变这点呢?我们可以参考C++中SGI版本的STL在实现哈希表时用的方法,它将所有比较有利的素数全部列了出来:

cpp 复制代码
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;
}

__stl_next_prime的功能我们可以理解为,传一个参数,返回大于等于这个参数的在__stl_prime_list中的最小素数,那么我们在扩容时就可以这样写:

cpp 复制代码
if (_n * 1.0 / _tables.size() >= 0.7)
{
	HashTable<K, V> newht;
	//newht._tables.resize(2 * _tables.size()); //直接×2不满足哈希表空间大小是素数
	newht._tables.resize(__stl_next_prime(_tables.size() + 1)); //我们给一个参数,__stl_next_prime会返回大于等于它的最有利的素数,这里的+1一定不要少,否则就可能出错
	for (auto& data : _tables)
		if (data._state == EXIST) 
			newht.Insert(data._kv);

	_tables.swap(newht._tables);
}

具体C++是怎么算出来这些有利的素数,我想那应该不是我们考虑的事情。(有利的素数就是指可以减少冲突发生的最佳素数)

我们初始化时也不需要给11了,可以这样:

(2)Find

查找逻辑非常简单,我们根据key的值,通过除留余数法找到哈希表中对应的下标,然后看看哈希表中这个下标的状态,如果是EMPTY就肯定找不到,如果是其它状态,若当前位置是我们要找的值就返回,否则就向后找(可能因为冲突,值跑后面了),直到找到EMPTY,若找到EMPTY还没找到就表示没有。

代码实现:

cpp 复制代码
HashData<K, V>* Find(const K& key)
{
	int hash0 = key % _tables.size();
	int hashi = hash0, i = 1;
	while (_tables[hashi]._state != EMPTY)
	{
		if (_tables[hashi]._kv.first == key)
			return &_tables[hashi];

		hashi = (hash0 + i) % _tables.size();
		++i;
	}
	return nullptr;
}

我们规定再插入时不允许插入相同值,那么就可以用Find接口在插入前进行判断:

(3)Erase

删除逻辑就更简单了, 我们只需将要删除的位置的元素状态改为DELETE即可。

cpp 复制代码
bool Erase(const K& key)
{
	HashData<K, V>* ret = Find(key);
	if (ret)
	{	ret->_state = DELETE; return true;	}
	else 
	    return false;
}

根据删除的逻辑,我们反观Find逻辑,是不是感觉有点问题?

是的,我们删除的逻辑是直接将该位置的状态改为DELETE,它的_kv是不变的,如果我们删除key,那么再调用Find查找已经删除的key,这时就会发现,还能找到,这是因为Find的逻辑只要不是EMPTY,其它状态只要_kv.first==key就说明找到,这是不符合逻辑的,因为只有在EXIST状态下找到才是真正的存在,所以我们要对Find进行修改,我们只需修改while中if的判断条件即可:

cpp 复制代码
HashData<K, V>* Find(const K& key)
{
	int hash0 = key % _tables.size();
	int hashi = hash0, i = 1;
	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;
}

根据上面的函数,对比我们之前二叉树的Insert、Find、Erase,是不是觉得这种方式效率很高!

完整代码(M取素数 -- c++采用的):

cpp 复制代码
#pragma once
#include <iostream>
#include <vector>
using namespace std;

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 HashTable
{
public:
	HashTable()
		:_tables(__stl_next_prime(0))
		, _n(0)
	{}

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

	bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first))
			return false; //不允许插入相同值

		//扩容
		if (_n * 1.0 / _tables.size() >= 0.7)
		{
			HashTable<K, V> newht;
			newht._tables.resize(__stl_next_prime(_tables.size() + 1));//这里要+1,不加1就会出现问题
			for (auto& data : _tables)
				if (data._state == EXIST)
					newht.Insert(data._kv);
			_tables.swap(newht._tables);
		}

		int hash0 = kv.first % _tables.size(); //新元素在哈希表中的映射下标
		//判断hash0位置上是否有值,若有值,需要进行线性探测
		int hashi = hash0, i = 1;
		while (_tables[hashi]._state == EXIST)
		{
			//方式2(线性探测)
			hashi = (hash0 + i) % _tables.size();
			++i;
		}
		_tables[hashi]._kv = kv; //放值到对应映射位置
		_tables[hashi]._state = EXIST;//同时设置状态
		++_n;

		return true;
	}

	HashData<K, V>* Find(const K& key)
	{
		int hash0 = key % _tables.size();
		int hashi = hash0, i = 1;
		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)
		{
			ret->_state = DELETE; return true;
		}
		else
			return false;
	}

private:
	vector<HashData<K, V>> _tables;
	size_t _n; //记录映射的数据个数
};

完整代码(M取2的次方 -- java采用的 -- 了解即可):

cpp 复制代码
#pragma once
#include <iostream>
#include <vector>
using namespace std;

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 HashTable
{
public:
	HashTable()
		:_tables(pow(2, 16)) //表的大小初始为2^16,这是为了确保一次性的将key的所有位参与比较,缺陷就是一次开这么大的空间可能会浪费空间
		, _n(0)
		,_m(16)
	{}

	size_t HashMapIndex(const K& key)
	{
		//size_t hash = key % _tables.size();
		size_t hash = (key & _tables.size() - 1);
		hash ^= (key >> 32 - _m); //将所有位参与比较,32位环境下,如果_m < 16,那么就不可以这样写了,必须确保_m >= 16

		return hash;
	}

	bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first))
			return false;

		if (_n * 1.0 / _tables.size() >= 0.7)
		{
			HashTable<K, V> newht;
			_m++;
			newht._tables.resize(pow(2, _m)); //扩2倍
			for (auto& data : _tables)
				if (data._state == EXIST)
					newht.Insert(data._kv);
			_tables.swap(newht._tables);
		}

		//int hash0 = kv.first % _tables.size(); //新元素在哈希表中的映射下标
		int hash0 = HashMapIndex(kv.first);
		int hashi = hash0, 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)
	{
		int hash0 = HashMapIndex(key);
		int hashi = hash0, i = 1;
		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)
		{
			ret->_state = DELETE; return true;
		}
		else
			return false;
	}
private:
	vector<HashData<K, V>> _tables;
	size_t _n; //记录映射的数据个数
	size_t _m; //M是2^n次方,所以用_m来记录n的值
};

上面这段M取2的次方的代码,我没有对某一部分具体解释,原因是我们本篇内容的重点不在这上面,如果大家看不懂M取2的次方时的代码,也没关系,我们现只需了解M取素数时的情况即可;如果大家想知道实现这段代码的细节,可以私聊我,我也很乐意去帮助大家理解!

我们下面也主要是根据M是素数情况下进行展开的。

②二次探测:

线性探测有一种情况下会有缺陷:

将{19,30,31,32,33}这⼀组值映射到M=11的表中,它们映射的下标(除留余数法):

|----------------------------------------------------|
| h(19) = 8,h(30) = 8,h(31) = 9,h(32) = 10,h(33) = 0 |

这组数你会发现,19占30的位置,30占31的位置,31占32的位置,32占33的位置。

再比如这组数:将{11,22,33,44,55}这⼀组值映射到M=11的表中,它们映射的下标(除留余数法):

|---------------------------------------------------|
| h(11) = 0,h(22) = 0,h(33) = 0,h(44) = 0,h(55) = 0 |

11占22的位置,22占33的位置,33占44的位置,44占55的位置。

这种来回互相占用对方位置的现象我们称为群集/堆积。出现群集/堆积正是因为我们用的是线性探测来解决冲突问题的,而二次探测可以一定程度上改善这个问题。

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

(1)Insert
cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))
		return false; //不允许插入相同值


	if (_n * 1.0 / _tables.size() >= 0.7)
	{
		HashTable<K, V> newht;
		newht._tables.resize(__stl_next_prime(_tables.size())); 
		for (auto& data : _tables)
			if (data._state == EXIST)
				newht.Insert(data._kv);

		_tables.swap(newht._tables);
	}

	int hash0 = kv.first % _tables.size(); //新元素在哈希表中的映射下标
	//判断hash0位置上是否有值,若有值,需要进行线性探测
	int hashi = hash0, i = 1;
	int flag = 1; //用作二次探测
	while (_tables[hashi]._state == EXIST)
	{	
		//二次探测 -- 防止群集/堆积
		hashi = (hash0 + (i * i * flag)) % _tables.size();
		if (hashi < 0)  //hashi可能会减到负数,我们需单独处理
			hashi += _tables.size();
		if (flag == 1)
			flag = -1;
		else
			++i, flag = 1;
	}

	_tables[hashi]._kv = kv; //放值到对应映射位置
	_tables[hashi]._state = EXIST;//同时设置状态
	++_n;

	return true;
}
(2)Find
cpp 复制代码
HashData<K, V>* Find(const K& key)
{
	int hash0 = key % _tables.size();
	int hashi = hash0, i = 1;
	int flag = 1;
	while (_tables[hashi]._state != EMPTY)
	{
		if (_tables[hashi]._state == EXIST 
			&& _tables[hashi]._kv.first == key)
			return &_tables[hashi];

		hashi = (hash0 + (i * i * flag)) % _tables.size();
		if (hashi < 0)  //hashi可能会减到负数,我们需单独处理
			hashi += _tables.size();
		if (flag == 1)
			flag = -1;
		else
			++i, flag = 1;
	}
	return nullptr;
}

查找逻辑一定是根据insert的逻辑展开的!!!

(3)Erase
cpp 复制代码
bool Erase(const K& key)
{
	HashData<K, V>* ret = Find(key);
	if (ret)
	{	ret->_state = DELETE; return true;	}
	else 
	    return false;
}
③ 双重探测(了解)

二次探测是跳跃着找位置,那么就可能出现有些位置始终找不到,这样空间利用率就会降低,为了处理这一种情况,引入双重探测。

双重探测的过程大致是:若第⼀个哈希函数计算出的值发生冲突,再使用第二个哈希函数计算出⼀个跟key相关的偏移量值,不断往后探测,直到寻找到下⼀个没有存储数据的位置为止。

用公式表示就是:h1(key) = key%M = hash0,此时hash0位置冲突了,则双重探测公式为:

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

h2就是第二个哈希函数(映射规则):

对h2是有要求的:

要求h2(key) < M且h2(key)和M互为质数,要想互为质数,有两种简单取值方法:(1)当M为2的整数幂时,h2(key)从[0,M-1]中任选一个奇数;(2)当M为质数时,h2(key) = key % (M - 1) + 1

互为质数可以充分利用散列表。具体为什么,需要向数学界大佬请教,这里我就不班门弄斧了。

下面演示 {19,30,52,74} 等这一组值映射到M=11的表中的过程,因为M是质数,所以设h2(key) = key%10 + 1;

首先计算它们映射的下标(除留余数法):

|-----------------------------------------|
| h(19) = 8,h(30) = 8,h(52) = 8,h(74) = 8 |

分析:

首先,19映射到下标为8的位置上:

30理应映射到下标为8的位置上,但是此时下标为8的位置上已经有值了,根据双重探测,映射的下标为:(8 + 1*1)%11 = 9

52理应映射到下标为8的位置上,但是此时下标为8的位置上已经有值了,根据双重探测,映射的下标为:(8 + 1*3)%11 = 0

74理应映射到下标为8的位置上,但是此时下标为8的位置上已经有值了,根据双重探测,映射的下标为:(8 + 1*4)%11 = 2

双重探测我们仅作了解即可。

注意:

当我们在用一个哈希函数时导致映射位置冲突时可以考虑使用开放地址法来解决冲突问题,但是在实际生活中,当遇到冲突,我们往往会用下面要讲的链地址法来解决冲突。

我们上面只是用除留余数法为例来引出开放地址法,而实际中开放地址发和链地址法可以针对任何哈希函数映射时发生冲突问题而不仅仅是针对除留余数法。哈希函数有很多,哈希函数只是决定映射时的映射规则,根据映射规则,可能会发生冲突,一旦发生冲突,就可以用开放地址法或者链地址法来解决冲突。

☛关键字不是整数

我们上面映射插入的值都是整形,那如果我们要映射插入的值不是整形呢?

cpp 复制代码
int main()
{
	const char* arr[] = { "phisolophy","texture","adolescence","stimulate","contest" };
	HashTable<string, string> ht;
	for (auto e : arr)
		ht.Insert({ e,e });  //插入的值是字符串类型

	ht.Erase("stimulate");
	if (ht.Find("adolescence"))
		cout << "find it" << endl;

	if (ht.Find("stimulate"))  cout << "find it" << endl;
	else cout << "no find" << endl;

	return 0;
}

那我们上面的代码写的一定会出现问题:

那么,我们这时就需要考虑一个问题了,我们可以先让字符串转为整形,然后再进行映射!

我们起初是并不知道kv.first是什么类型的,有可能是整形、浮点型、结构体、指针、字符串等等,我们能执行"int hash0 = kv.first % _tables.size();"这段代码的前提必须是kv.first是正整数,如果是负整数,也可以正确执行,但是hash0就变为负数了,hash0一旦是负数,那么"_tables[hashi]._state"就不可能执行起来,所以只有kv.first满足正整数时,才会确保能做模运算,且hash0为正整数;kv.first若满足正整数,那么它的类型应该是无符号整型。

一般内置类型都可以强制转换为无符号整型,一些其他的类型比如结构体、字符串不能强制转换为无符号整型。

所以我们可以在HashTable这个类中给上第三个模板参数,这第三个模板参数用于提供仿函数,我们通过仿函数就可以将kv.first强转为无符号整型,具体看代码实现:

因为默认的我们给缺省参数只能用来处理内置类型的强转(这里需要说明一下的是如果内置类型强转的结果并不是我们所想要的,我们也可以单独写一个仿函数传过去)、而现在我们传的是字符串,字符串当然不能强制转换为无符号整形,所以我们要单独写一个仿函数来帮助我们解决问题:

cpp 复制代码
struct StringHashFunc
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)  //将字符串中的所有字符的ASCII码值加起来(让所有字符参与运算,减少冲突的发生)
			hash += ch;
		return hash;
	}
};

由于string这个仿函数特别常用,所以我们也可以在库中实现(也就是不让使用者单独实现),可以利用模板的特化:

cpp 复制代码
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

template<>
struct HashFunc<string>
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)  //将字符串中的所有字符的ASCII码值加起来(让所有字符参与运算,减少冲突的发生)
		{
			hash += ch;
			hash *= 131; //两个字符串顺序不同但内容相同,那么它们的ASCII值相加也相同,考虑到这种情况,我们可以每次加完一个ch后,再乘131,可以有效减少冲突,这个131是怎么来的,大家可以不必关心
		}
		return hash;
	}
};

如果key是一个日期类对象,那么同样地,在映射时,它不支持强转为无符号整型,那么我们也要写一个仿函数:

cpp 复制代码
struct Date
{
	int _year;
	int _month;
	int _day;

	Date(int year = 0, int month = 0, int day = 0)
		:_year(year)
		,_month(month)
		,_day(day)
	{}
};

struct DateHashFunc
{
	size_t operator()(const Date& d)
	{
		size_t hash = 0;
		hash += d._year;  hash *= 131;  //乘131也是为了减少冲突
		hash += d._month; hash *= 131;
		hash += d._day;   hash *= 131;
		return hash;
	}
};

int main()
{
	HashTable<Date, int, DateHashFunc> ht;
	ht.Insert({ Date(2024, 11, 17), 1 });
	ht.Insert({ {2024, 17, 11}, 1 });

	return 0;
}

这里的Date类型我们就没有必要写到模板特化里面了。

当我们运行时,这段代码是通不过的:

我们上面的Date类中并不支持==比较,所以编译无法通过。 修改如下:

cpp 复制代码
struct Date
{
	int _year;
	int _month;
	int _day;

	Date(int year = 0, int month = 0, int day = 0)
		:_year(year)
		,_month(month)
		,_day(day)
	{}

	bool operator==(const Date& d)
	{
		return _year == d._year
			&& _month == d._month
			&& _day == d._day;
	}
};

所以,要想实现哈希思想,key必须能够转成无符号整形,且必须要支持等于(==)比较!!!

(2)链地址法(开散列)

开放地址法有一个问题,如果发生冲突,那么必然会"抢占"别人的位置,那么别人来了,就会再去抢占下一个人的位置,永远处于一个漩涡之中,这是不可避免的。而链地址法大致就是:如果发生冲突,坚决不占别人的位置,而是通过链表"悬挂"起来,形象的称为哈希桶。

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

假设将 {19,30,5,36,13,20,21,12,24,96} 等这一组值映射到M=11的表中:

那么h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) =10,h(12) = 1,h(24) = 2,h(96) = 88

链地址法表示如下:

关于扩容问题:

开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。但是如果太大,那么单个链表可能就会挂的比较多,挂的越多查找的效率就会越低。所以,在使用链地址法时,如果负载因子大于1,我们也要考虑进行扩容。

如果被恶意者攻击了,导致某个桶长度特别长的就可以考虑使用全域散列法(下面)。假设没有被攻击,某个桶很长,查找效率很低该怎么办?这里在Java8的HashMap中当桶的长度超过一定阀值(8)时就把链表转换成红黑树,C++中反而没有特别说明。一般情况下,经过扩容,单个桶很长的场景还是比较少的,下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解⼀下。

用链地址法实现起来还是很方便的:

cpp 复制代码
namespace hash_bucket
{
	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 Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable()
			:_tables(__stl_next_prime(0), nullptr)
			, _n(0)
		{}

		//拷贝构造
		HashTable(const HashTable& ht)
			: _tables(ht._tables.size(), nullptr)
			, _n(ht._n)
		{
			for (size_t i = 0; i < ht._tables.size(); ++i)
			{
				Node* cur = ht._tables[i];
				while (cur) 
				{
					Node* newnode = new Node(cur->_kv);
					newnode->_next = _tables[i];
					_tables[i] = newnode;
					cur = cur->_next;
				}
			}
		}

		//赋值重载
		HashTable& operator=(HashTable tmp)
		{
			_tables.swap(tmp._tables);
			std::swap(_n, tmp._n); 
			return *this;
		}

		~HashTable()
		{
			for (size_t i = 0;i < _tables.size();++i)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;

					cur = next;
				}
				_tables[i] = nullptr;
			}
		}

		bool Insert(const pair<K, V>& kv)
		{

			//负载因子等于1时扩容
			//扩容:方法一(不可取 -- 交换后原表中的桶结点没有释放)
			/*if (_n / _tables.size() == 1)
			{
				HashTable<K, V, Hash> newht;
				newht._tables.resize(__stl_next_prime(_tables.size() + 1));
				for (size_t i = 0;i < _tables.size();++i)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						newht.Insert(cur->_kv);
						cur = cur->_next;
					}
				}
				_tables.swap(newht._tables);
			}*/

			//不允许插入两个相同的元素
			if (Find(kv.first))
				return false;

			Hash hash;
			//扩容:方法二
			if (_n / _tables.size() == 1)
			{
				vector<Node* > newtable(__stl_next_prime(_tables.size() + 1));
				for (size_t i = 0;i < _tables.size();++i)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						//头插到新表
						size_t hashi = hash(cur->_kv.first) % newtable.size();
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;

						cur = next;
					}
					_tables[i] = nullptr;
				}
				_tables.swap(newtable);
			}

			size_t hashi = hash(kv.first) % _tables.size();
			//头插,时间复杂度O(1)
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;

			++_n;
			return true;
		}

		Node* Find(const K& key)
		{
			Hash hash;
			size_t hashi = hash(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)
		{
			Hash hash;
			size_t hashi = hash(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;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}
	private:
		vector<Node*> _tables;
		size_t _n;
	};
}

由于链地址法非常容易,我这里就不解释了,大家有不懂的地方,直接联系我就行,我看到就会回复的。 实际生活中,基本上用的都是链地址法,反而开放地址法用的较少。

2、乘法散列法(了解)

乘法散列法对哈希表大小M没有要求,它的大致思路第一步:用关键字K乘上常数A(0<A<1),并抽取出K*A的小数部分。第二步:再用M乘以K*A的小数部分,再向下取整。其中经过大佬Knuth的各种研究,他建议A取(黄金分割点)比较好。

乘法散列法对哈希表大小M是没有要求的,假设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。

3、全域散列法(了解)

如果存在⼀个恶意的对手,他针对我们提供的散列(哈希)函数,特意构造出⼀个发生严重冲突的数据集,比如,让所有关键字全部落入同⼀个位置中,这样的话,如果是哈希表,那么大多数据都会冲突,别人来访问数据就会特别慢,感觉就像"卡死"一样。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击;解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据,这种解决方法叫做全域散列。

假设我的散列函数要公开,之前是提供一个散列函数,容易受到攻击,现在我提供一个散列函数组(组中有多个散列函数),程序启动起来后我从这个散列函数组中随机选取一个散列函数来作为映射时的规则,我用的是一个散列函数(运行时根据算法决定),但我对外公布的是多个散列函数,那么恶意的对手就无法确定我们到底用的是哪一个散列函数,那么就很难进行攻击。

那么如何生成这么些散列函数的呢?

根据上述公式 ,P需要选⼀个足够大的质数(比M大,否则M中有些空间可能用不到),a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,随着a、b的不断变化,就形成了一个个散列函数,a和b的变化组合共有P*(P-1)种,所以这些函数构成了⼀个P*(P-1)组全域散列函数组。假设P=17,M=6,a = 3, b = 4, 则 h34 (8) = ((3 × 8 + 4)%17)%6 = 5 (其中a,b的组合情况有17*16种);假设P=1024,那么a、b的组合情况共有1024*1023种,那么攻击者显然是很难攻击的!

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

注意:

我们如果使用乘法散列法或者全域散列法遇到冲突时,也可以用我们在除留余数法中讲到的两种解决冲突的办法:开放定址法、链地址法。

三、源码

源码的实现用的哈希函数都是除留余数法,平时中除留余数法用的是比较多的。大家有兴趣可以试试用乘法散列法或全域散列法或者其它的哈希函数。

1、HashTable.h

cpp 复制代码
#pragma once
#include <iostream>
#include <vector>
using namespace std;

enum State
{
	EXIST,
	EMPTY,
	DELETE
};

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

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

template<>
struct HashFunc<string>
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)  //将字符串中的所有字符的ASCII码值加起来(让所有字符参与运算,减少冲突的发生)
		{
			hash += ch;
			hash *= 131; //两个字符串顺序不同但内容相同,那么它们的ASCII值相加也相同,考虑到这种情况,我们可以每次加完一个ch后,再乘131,可以有效减少冲突,这个131是怎么来的,大家可以不必关心
		}

		return hash;
	}
};

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

//开放地址发实现哈希表
namespace open_address
{
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		HashTable()
			:_tables(__stl_next_prime(0))
			, _n(0)
		{}

		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
				return false; //不允许插入相同值

			//扩容方式1
			//负载因子若大于等于0.7就扩容
			//if (_n * 1.0 / _tables.size() >= 0.7)
			//{
			//	//扩容后必须将原先表中的数据重新映射到新表中,否则映射规则就会混乱
			//	vector<HashData<K, V>> newtables(_tables.size() * 2);
			//	for (auto& data: _tables)
			//	{
			//		if (data._state == EXIST)
			//		{
			//			int hash0 = data._kv.first % newtables.size(); 
			//			int hashi = hash0, i = 1;
			//			while (newtables[hashi]._state == EXIST)
			//				hashi = (hash0 + (i++)) % newtables.size();
			//			newtables[hashi]._kv = data._kv; 
			//			newtables[hashi]._state = EXIST;
			//		}
			//	}
			//	_tables.swap(newtables);
			//}

			//扩容方式2
			if (_n * 1.0 / _tables.size() >= 0.7)
			{
				HashTable<K, V, Hash> newht;
				//newht._tables.resize(2 * _tables.size()); //直接×2不满足哈希表空间大小是素数
				newht._tables.resize(__stl_next_prime(_tables.size() + 1));//这里要+1,不加1就会出现问题
				for (auto& data : _tables)
					if (data._state == EXIST)
						newht.Insert(data._kv);

				_tables.swap(newht._tables);
			}

			Hash hash;
			int hash0 = hash(kv.first) % _tables.size(); //新元素在哈希表中的映射下标
			//判断hash0位置上是否有值,若有值,需要进行线性探测
			int hashi = hash0, i = 1;
			int flag = 1; //用作二次探测
			while (_tables[hashi]._state == EXIST)
			{
				//方式1
				//hashi = (hashi + 1) % _tables.size();

				//方式2(线性探测)
				//hashi = (hash0 + i) % _tables.size();
				//++i;

				//方式3(二次探测 -- 防止群集/堆积)
				hashi = (hash0 + (i * i * flag)) % _tables.size();
				if (hashi < 0)
					hashi += _tables.size();
				if (flag == 1)
					flag = -1;
				else
					++i, flag = 1;
			}

			_tables[hashi]._kv = kv; //放值到对应映射位置
			_tables[hashi]._state = EXIST;//同时设置状态
			++_n;

			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
			Hash hash;
			int hash0 = hash(key) % _tables.size();
			int hashi = hash0, i = 1;
			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)
			{
				ret->_state = DELETE; return true;
			}
			else
				return false;
		}

	private:
		vector<HashData<K, V>> _tables;
		size_t _n; //记录映射的数据个数
	};
}

//链地址法实现哈希表
namespace hash_bucket
{
	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 Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable()
			:_tables(__stl_next_prime(0), nullptr)
			, _n(0)
		{}

		//拷贝构造
		HashTable(const HashTable& ht)
			: _tables(ht._tables.size(), nullptr)
			, _n(ht._n)
		{
			for (size_t i = 0; i < ht._tables.size(); ++i)
			{
				Node* cur = ht._tables[i];
				while (cur) 
				{
					Node* newnode = new Node(cur->_kv);
					newnode->_next = _tables[i];
					_tables[i] = newnode;
					cur = cur->_next;
				}
			}
		}

		//赋值重载
		HashTable& operator=(HashTable tmp)
		{
			_tables.swap(tmp._tables);
			std::swap(_n, tmp._n); 
			return *this;
		}

		~HashTable()
		{
			for (size_t i = 0;i < _tables.size();++i)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;

					cur = next;
				}
				_tables[i] = nullptr;
			}
		}

		bool Insert(const pair<K, V>& kv)
		{

			//负载因子等于1时扩容
			//扩容:方法一(不可取 -- 交换后原表中的桶结点没有释放)
			/*if (_n / _tables.size() == 1)
			{
				HashTable<K, V, Hash> newht;
				newht._tables.resize(__stl_next_prime(_tables.size() + 1));
				for (size_t i = 0;i < _tables.size();++i)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						newht.Insert(cur->_kv);
						cur = cur->_next;
					}
				}
				_tables.swap(newht._tables);
			}*/

			//不允许插入两个相同的元素
			if (Find(kv.first))
				return false;

			Hash hash;
			//扩容:方法二
			if (_n / _tables.size() == 1)
			{
				vector<Node* > newtable(__stl_next_prime(_tables.size() + 1));
				for (size_t i = 0;i < _tables.size();++i)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						//头插到新表
						size_t hashi = hash(cur->_kv.first) % newtable.size();
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;

						cur = next;
					}
					_tables[i] = nullptr;
				}
				_tables.swap(newtable);
			}

			size_t hashi = hash(kv.first) % _tables.size();
			//头插,时间复杂度O(1)
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;

			++_n;
			return true;
		}

		Node* Find(const K& key)
		{
			Hash hash;
			size_t hashi = hash(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)
		{
			Hash hash;
			size_t hashi = hash(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;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}
	private:
		vector<Node*> _tables;
		size_t _n;
	};
}

2、Test.cpp

cpp 复制代码
#include "HashTable.h"

//int main()
//{
//	//int a[] = { 19,30,52,63,11,22 }; //这一组数全在"抢位置"
//	int a[] = { 19,30,5,36,13,20,21,12 };
//	open_address::HashTable<int, int> ht;
//	for (auto e : a)
//		ht.Insert({ e,e });
//
//	//ht.Insert({1,1}); //验证在插入时扩容的逻辑
//
//	ht.Erase(30);
//	if (ht.Find(20))
//		cout << "find it" << endl;
//	if (ht.Find(30))
//		cout << "find it too" << endl;
//	else
//		cout << "no find" << endl;
//	return 0;
//}


//int main()
//{
//	const char* arr[] = { "phisolophy","texture","adolescence","stimulate","contest" };
//	open_address::HashTable<string, string> ht;
//	for (auto e : arr)
//		ht.Insert({ e,e });
//
//	ht.Erase("stimulate");
//	if (ht.Find("adolescence"))
//		cout << "find it" << endl;
//
//	if (ht.Find("stimulate"))  cout << "find it" << endl;
//	else cout << "no find" << endl;
//
//	return 0;
//}


//struct Date
//{
//	int _year;
//	int _month;
//	int _day;
//
//	Date(int year = 0, int month = 0, int day = 0)
//		:_year(year)
//		,_month(month)
//		,_day(day)
//	{}
//
//	bool operator==(const Date& d)
//	{
//		return _year == d._year
//			&& _month == d._month
//			&& _day == d._day;
//	}
//};
//
//struct DateHashFunc
//{
//	size_t operator()(const Date& d)
//	{
//		size_t hash = 0;
//		hash += d._year;  hash *= 131;
//		hash += d._month; hash *= 131;
//		hash += d._day;   hash *= 131;
//		return hash;
//	}
//};
//
//
//int main()
//{
//	open_address::HashTable<Date, int, DateHashFunc> ht;
//	ht.Insert({ Date(2024, 11, 17), 1 });
//	ht.Insert({ {2024, 17, 11}, 1 });
//
//	return 0;
//}

/*+++++++++++++++++++++++++++++++++++++++++++++++++++++*/

int main()
{
	int a[] = { 19,30,5,36,13,20,21,12,24,96 };
	hash_bucket::HashTable<int, int> ht;
	for (auto e : a)
		ht.Insert({ e,e });

	hash_bucket::HashTable<int, int> other1(ht);


	cout << ht.Find(96) << endl;
	cout << ht.Find(30) << endl;
	cout << ht.Find(19) << endl << endl;

	ht.Erase(96);
	ht.Erase(19);
	ht.Erase(30);

	cout << ht.Find(96) << endl;
	cout << ht.Find(30) << endl;
	cout << ht.Find(19) << endl << endl;


	const char* arr[] = { "phisolophy","texture","adolescence","stimulate","contest" };
	hash_bucket::HashTable<string, string> ht1;
	for (auto e : arr)
		ht1.Insert({ e,e });

	hash_bucket::HashTable<int, int> other(ht);

	other = other1;

	return 0;
}

四、结语

本篇内容到这里就结束了,主要讲了在哈希这一思想下的哈希表的实现过程,希望对大家有帮助,祝生活愉快!我们下篇再见!

相关推荐
xiaoshiguang34 小时前
LeetCode:222.完全二叉树节点的数量
算法·leetcode
爱吃西瓜的小菜鸡4 小时前
【C语言】判断回文
c语言·学习·算法
别NULL4 小时前
机试题——疯长的草
数据结构·c++·算法
TT哇4 小时前
*【每日一题 提高题】[蓝桥杯 2022 国 A] 选素数
java·算法·蓝桥杯
CYBEREXP20085 小时前
MacOS M3源代码编译Qt6.8.1
c++·qt·macos
ZSYP-S5 小时前
Day 15:Spring 框架基础
java·开发语言·数据结构·后端·spring
yuanbenshidiaos6 小时前
c++------------------函数
开发语言·c++
yuanbenshidiaos6 小时前
C++----------函数的调用机制
java·c++·算法
唐叔在学习6 小时前
【唐叔学算法】第21天:超越比较-计数排序、桶排序与基数排序的Java实践及性能剖析
数据结构·算法·排序算法
ALISHENGYA6 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(switch语句)
数据结构·算法