【C++】哈希表的实现(开放定址法)

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

哈希表是通过哈希这种方式设计出来的一种存储数据的结构。

1.哈希概念

1.1 直接定址法

当关键字的范围⽐较集中时,直接定址法就是⾮常简单⾼效的⽅法,

  • ⽐如⼀组关键字都在[0,99]之间,那么我们开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。
  • 再⽐如⼀组关键字值都在[a,z]的⼩写字⺟,那么我们开⼀个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。

也就是说直接定址法本质就是 ⽤关键字计算出⼀个绝对位置或者相对位置

1.2 哈希冲突

直接定址法的缺点也⾮常明显,当关键字的范围⽐较分散时,就很浪费内存甚⾄内存不够⽤。假设我们只有数据范围是[0, 9999]的N个值,我们要映射到⼀个M个空间的数组中(⼀般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的h(key)位置,这⾥要注意的是h(key)计算出的值必须在[0, M)之间。
⾥存在的⼀个问题就是,两个不同的key可能会映射到同⼀个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。理想情况是找出⼀个好的哈希函数避免冲突,但是实际场景中, 冲突是不可避免的 ,所以我们尽可能设计出优秀的哈希函数, 减少冲突的次数 ,同时也要去设计出解决冲突的⽅案。

1.3 负载因子

设哈希表中已经映射存储了N个值,哈希表的⼤⼩为M,那么 负载因子= ,负载因⼦有些地⽅也翻译为载荷因⼦/装载因⼦等,他的英⽂为load factor。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低。

1.4 将关键字转为整数

我们将关键字映射到数组中位置,⼀般是整数好做映射计算,如果不是整数,我们要想办法转换成整数,这个细节我们后⾯代码实现中再进⾏细节展⽰。下⾯哈希函数部分我们讨论时,如果关键字不是整数,那么我们讨论的Key是关键字转换成的整数。

1.5 哈希函数

⼀个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做到,但是我们要尽量往这个⽅向去考量设计。

除法散列法/除留余数法

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

  • 当使⽤除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。

如果是 ,那么key % ,本质相当于保留key的后x位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。

如:{63 , 31}看起来没有关联的值,如果M是16,也就是 ,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。

如果是 ,就更明显了,保留的都是10进值的后x位。

如:{112, 12312},如果M是100,也就是 ,那么计算出的哈希值都是12。

  • 使⽤除法散列法时,建议M取不太接近2的整数次幂的⼀个质数(素数)。

需要说明的是,实践中也是⼋仙过海,各显神通,Java的HashMap采⽤除法散列法时就是2的整数
次幂做哈希表的⼤⼩M,这样玩的话,就不⽤取模,⽽可以直接位运算,相对⽽⾔位运算⽐模更⾼
效⼀些。但是他不是单纯的去取模,⽐如M是2^16次⽅,本质是取后16位,那么⽤key' =
key>>16,然后把key和key' 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围
内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可。所以我们上⾯建
议M取不太接近2的整数次幂的⼀个质数的理论是⼤多数数据结构书籍中写的理论吗,但是实践中,灵活运⽤,抓住本质,⽽不能死读书。(了解)

2.处理哈希冲突

践中哈希表⼀般还是选择除法散列法作为哈希函数,当然哈希表⽆论选择什么哈希函数也避免不了
冲突,那么插⼊数据时,如何解决冲突呢?主要有两种两种⽅法,开放定址法和链地址法。

2.1 开放定址法: 线性探测

开放定址法中所有的元素都放到哈希表⾥,当⼀个关键字key⽤哈希函数计算出的位置冲突了,则按照某种规则找到⼀个没有存储数据的位置进⾏存储,开放定址法中负载因⼦⼀定是⼩于的。这⾥的规则有三种:线性探测、⼆次探测、双重探测(了解)。
本文我们先说线性探测。

  • 从发⽣冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果⾛到哈希表尾,则回绕到哈希表头的位置。
  • h(key) = hash0 = key % M, hash0的位置冲突了,则线性探测的公式为 hc(key, i) = hashi = (hash0 + i) % M, i = { 1, 2, 3, ... , M-1 },因为负载因子小于1,所以最多探测M-1次,一定能找到存储key的位置。

下⾯演⽰ {19,30,5,36,13,20,21,12} 等这⼀组值映射到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

要注意的是这⾥需要给每个存储值的位置加⼀个状态标识,否则删除⼀些值以后,会影响后⾯冲突的值的查找。
如下图,我们删除30,会导致查找20失败,当我们给每个位置加⼀个状态标识{EXIST, EMPTY, DELETE} ,删除30就可以不⽤删除值,⽽是把状态改为 DELETE ,那么查找20时是遇到 EMPTY 才能,就可以找到20。

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

cpp 复制代码
//HashTable.h文件
#include <iostream>
using namespace std;
enum State
{
	EMPTY,  //为空
	DELETE, //删除
	EXIST   //存在
};

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

template<class K, class V>
class HashTable
{
public:
	HashTable()
		:_tables(11) //先手动开空间
		,_n(0)
	{}

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

find代码实现

find查找,参数传key就行了,代码逻辑就是线性探测一直找找。

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

erase代码实现

删除一个值我们先找这个值,找到了就删,而且删除其实不用真的删掉,我们只要改状态为DELETE就可以了,代码如下。

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

代码测试。

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

	if (ht.Find(20))
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;

	ht.Erase(20);//把20删了

	if (ht.Find(20))
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;

	return 0;
}

insert代码实现

先写一个基础版的插入,这里是不允许有重复值出现的,所以插入之前查找一下这个值是否存在。

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    if (Find(kv.first)) //如果这个值已经有了
	    return false;

	size_t hash0 = 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;
}

测试一下。

cpp 复制代码
#include "HashTable.h"
int main()
{
	//int arr[] = { 19,30,52,63,11,22 }; //全是冲突的值
	int arr[] = { 19,30,5,36,13,20,21,12 };
	HashTable<int, int> ht;
	for (auto e : arr)
	{
		ht.Insert({ e, e });
	}
	return 0;
}

和理论分析的结果一致。

扩容

哈希表负载因⼦控制在0.7,当负载因⼦到0.7以后我们就需要扩容了,我们先还是按照2倍扩容,后面再做修改。扩容之后,不能直接将原来的东西复制下来,因为会导致 映射关系全部变了

所以我们要把原来的内容重新映射到新的空间去,要把映射的代码重新写一遍吗?不用,看下面的巧妙解决方法。

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

	//扩容
	if (_n * 10 / _tables.size() >= 7)//当负载因子>=0.7时
	{
		HashTable<K, V> newtables;
		newtables._tables.resize(_tables.size() * 2);//按2倍扩
		for (auto& data : _tables)//遍历旧表
		{
            //旧表映射到新表
			if (data._state == EXIST)//旧表有数据的
			{
				newtables.Insert(data._kv);//在新表直接调用Insert插入
			}
		}
		_tables.swap(newtables._tables);//新表和旧表交换
	}

	size_t hash0 = 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;
}

这其实是一种现代写法的思路,关于现代写法的详解,在【C++拓展】深拷贝的现代写法

旧表和 新表交换的那句代码,可以用赋值,不用swap,但是swap的效率更高。

cpp 复制代码
_tables.swap(newtables._tables);//交换的写法
//_tables = newtables._tables;//赋值的写法

按照2倍扩容是不行的,因为我们要保持哈希表⼤⼩是⼀个质数,第⼀个是质数,2倍后就不是质数了。那么如何解决呢?⼀种⽅案就是上⾯1.4.1除法散列中我们讲的Java HashMap的使⽤2的整数幂,但是计算时不能直接取模的改进⽅法。
另外⼀种⽅案是sgi版本的哈希表使⽤的⽅法,给了 ⼀个近似2倍的质数表 ,每次去质数表获取扩容后的⼤⼩。如下。

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

然后我们用这个表,扩容的地方修改一下。

cpp 复制代码
//newtables._tables.resize(_tables.size() * 2);//2倍扩

//找一个最接近当前空间大小的素数为扩容大小
newtables._tables.resize(__stl_next_prime(_tables.size()));
cpp 复制代码
template<class K, class V>
class HashTable
{
public:
	HashTable()
		:_tables(__stl_next_prime(0)) //先给一个最接近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;
	}

	HashData<K, V>* Find(const K& key)
	{
		size_t hash0 = key % _tables.size();
		size_t hashi = hash0;
		size_t 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;
	}

	bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first))
			return false;
		//扩容
		if (_n * 10 / _tables.size() >= 7)//当负载因子>=0.7时
		{
			HashTable<K, V> newtables;

			//newtables._tables.resize(_tables.size() * 2);//2倍扩

			//找一个最接近当前空间大小的素数为扩容大小
			newtables._tables.resize(__stl_next_prime(_tables.size()));

			for (auto& data : _tables)//遍历旧表
			{
				if (data._state == EXIST)//旧表有数据的
				{
					newtables.Insert(data._kv);//在新表直接调用Insert插入
				}
			}
			_tables.swap(newtables._tables);//新表和旧表交换
			//_tables = newtables._tables;//赋值的写法可行,但效率低
		}

		size_t hash0 = 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;
	}

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

key不能取模问题

当key是string/Date等类型时,key不能取模,那么我们需要给HashTable增加⼀个仿函数,这个仿函数**⽀持把key转换成⼀个可以取模的整形**。

cpp 复制代码
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};
cpp 复制代码
//模板参数多加一个仿函数
template<class K, class V, class Hash = HashFunc<K>> //HashFunc当缺省
class HashTable
{
    //...
}
cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))
		return false;
	//扩容
	if (_n * 10 / _tables.size() >= 7)//当负载因子>=0.7时
	{
		HashTable<K, V, Hash> newtables; //注意这里也要改成3个模板参数
        //...
	}

	Hash hash;
	size_t hash0 = hash(kv.first) % _tables.size(); //下标映射,用仿函数
    //...
}
cpp 复制代码
HashData<K, V>* Find(const K& key)
{
	Hash hash;
	size_t hash0 = hash(key) % _tables.size(); //用仿函数
    //...
}

上面写的是一个默认的仿函数,这个仿函数还是不能将string转为整形,我们要自己为string专门写一个仿函数。

cpp 复制代码
//test.cpp
struct stringHashFunc
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
            hash *= 131;
		}
		return hash;
	}
};

字符串转为整形我们可以 把全部字符ASCII码值乘以一个131(其他值也可以,但是想知道为什么是乘131,去搜BKDR哈希),然后加起来。如果不乘一个数的话,有的字符串不同,但ASCII码值相加是一样的,更容易发生冲突。

cpp 复制代码
//test.cpp
struct stringHashFunc
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
            hash *= 131;
		}
		return hash;
	}
};

int main()
{
	const char* arr[] = { "hello", "left", "sort", "passage" };
	HashTable<string, string, stringHashFunc> s; //传三个参数
	for (auto& e : arr)
	{
		s.Insert({ e, e });
	}
	return 0;
}

第三个参数不传,就用缺省值,就是之前实现的那个HashFunc,传了就是相应的仿函数。

而且写了这个仿函数,传负数也可以了。

cpp 复制代码
int main()
{
	int arr[] = { -19,30,-5,36,13,-20,21,-12,-15 };
	HashTable<int, int> ht; //不传第三个参数
	for (auto e : arr)
	{
		ht.Insert({ e, e });
	}
	return 0;
}

此时用的仿函数就是前面的HashFunc,把负数强转为size_t类型。

对string的仿函数特殊处理

由于我们经常用string类型做key,要额外多传一个仿函数比较麻烦,这里可以对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)
		{
			hash += ch;
            hash *= 131;
		}
		return hash;
	}
};

特化的知识在【C++】模板(进阶)2.2 类模板的特化 中有详细讲解。

2.2 对hash的相关概念做进一步了解

比如说现在有一个Date的类,是自定义类型。

cpp 复制代码
struct Date
{
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year)
		,_month(month)
		,_day(day)
	{}

	int _year;
	int _month;
	int _day;
};

Date类肯定不支持取模,所以要自己写仿函数,这个仿函数我们也可以仿照string的仿函数写,把年、月、日乘个131然后加起来,因为不经常用到,就不用写成特化。

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

我们测试一下。

cpp 复制代码
int main()
{
	HashTable<Date, int, DateHashFunc> dht; //传Date仿函数过去
	dht.Insert({ {2025, 1, 21}, 0 });
	dht.Insert({ {2025, 12, 1}, 1 });

	return 0;
}

发现程序不能运行,因为我们漏了关键的一点,key需要支持等于的比较,不支持的话这个类型就无法做key。

所以我们的Date类里要**支持==**才可以。

cpp 复制代码
struct Date
{
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year)
		,_month(month)
		,_day(day)
	{}

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

	int _year;
	int _month;
	int _day;
};

支持相等的比较之后,上面的测试代码就能通过了。

本篇分享就到这里,我们下篇见~