【C++】哈希表的实现(开放定址法)
哈希(hash)⼜称散列,是⼀种组织数据的⽅式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字Key跟存储位置建⽴⼀个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进⾏快速查找。
哈希表是通过哈希这种方式设计出来的一种存储数据的结构。
1.哈希概念
1.1 直接定址法
- ⽐如⼀组关键字都在[0,99]之间,那么我们开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。
- 再⽐如⼀组关键字值都在[a,z]的⼩写字⺟,那么我们开⼀个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。
1.2 哈希冲突
1.3 负载因子
1.4 将关键字转为整数
1.5 哈希函数
除法散列法/除留余数法
- 除法散列法也叫做除留余数法,顾名思义,假设哈希表的⼤⼩为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的整数次幂的⼀个质数(素数)。
2.处理哈希冲突
2.1 开放定址法:线性探测
- 从发⽣冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果⾛到哈希表尾,则回绕到哈希表头的位置。
- h(key) = hash0 = key % M, hash0的位置冲突了,则线性探测的公式为 hc(key, i) = hashi = (hash0 + i) % M, i = { 1, 2, 3, ... , M-1 },因为负载因子小于1,所以最多探测M-1次,一定能找到存储key的位置。
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(19) = 8 , h(30) = 8 , h(5) = 5 , h(36) = 3 , h(13) = 2 , h(20) = 9 , h(21) = 10, h(12) = 1
//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就行了,代码逻辑就是线性探测一直找找。
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就可以了,代码如下。
bool Erase(const K& key)
{HashData<K, V>* ret = Find(key);if (ret){ret->_state = DELETE;return true;}else{return false;}
}
代码测试。
#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;elsecout << "没找到" << endl;ht.Erase(20);//把20删了if (ht.Find(20))cout << "找到了" << endl;elsecout << "没找到" << endl;return 0;
}
insert代码实现
先写一个基础版的插入,这里是不允许有重复值出现的,所以插入之前查找一下这个值是否存在。
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;
}
测试一下。
#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;
}
和理论分析的结果一致。
扩容
所以我们要把原来的内容重新映射到新的空间去,要把映射的代码重新写一遍吗?不用,看下面的巧妙解决方法。
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的效率更高。
_tables.swap(newtables._tables);//交换的写法
//_tables = newtables._tables;//赋值的写法
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;
}
然后我们用这个表,扩容的地方修改一下。
//newtables._tables.resize(_tables.size() * 2);//2倍扩//找一个最接近当前空间大小的素数为扩容大小
newtables._tables.resize(__stl_next_prime(_tables.size()));
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;}elsereturn 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转换成⼀个可以取模的整形。
template<class K>
struct HashFunc
{size_t operator()(const K& key){return (size_t)key;}
};
//模板参数多加一个仿函数
template<class K, class V, class Hash = HashFunc<K>> //HashFunc当缺省
class HashTable
{//...
}
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(); //下标映射,用仿函数//...
}
HashData<K, V>* Find(const K& key)
{Hash hash;size_t hash0 = hash(key) % _tables.size(); //用仿函数//...
}
上面写的是一个默认的仿函数,这个仿函数还是不能将string转为整形,我们要自己为string专门写一个仿函数。
//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码值相加是一样的,更容易发生冲突。
//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,传了就是相应的仿函数。
而且写了这个仿函数,传负数也可以了。
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的仿函数进行一个特化,写法如下。
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的类,是自定义类型。
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然后加起来,因为不经常用到,就不用写成特化。
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()
{HashTable<Date, int, DateHashFunc> dht; //传Date仿函数过去dht.Insert({ {2025, 1, 21}, 0 });dht.Insert({ {2025, 12, 1}, 1 });return 0;
}
发现程序不能运行,因为我们漏了关键的一点,key需要支持等于的比较,不支持的话这个类型就无法做key。
所以我们的Date类里要支持==才可以。
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;
};
支持相等的比较之后,上面的测试代码就能通过了。
本篇分享就到这里,我们下篇见~