1. unordered 系列
在 c++98 中, STL 提供了底层是红黑树结构的一系列关联式容器,set 和 map 的查询效率可以达到 log2N,红黑树最差的情况也只是需要比较红黑树的高度次,当节点数量非常多时,查找一个节点还需要比较几十次,效果也不是太理想,最理想的查询是,进行很少次的比较次数就能将元素找到。
因此在 c++11 中,STL 又提供了 unordered 系列,unordered_map, unordered_set,unordered_mulitmap, unordered_mulitset。
前面学的 map 和 set 是 TreeMap, TreeSet
而这里的是 HashMap, HashSet
unordered_set, unordered_map 的底层都是哈希
但是 unordered 是 无序的,因为 stl 原本是没有打算引入 hash 结构的,但是 hash 的速度确实快,而且应用方面有需求,虽然这里名字没有直接叫 hash,但是名字 unordered 说明了功能,存储的数据是无序的
和前面学习容器一样,我们现在根据文档来看看,
大部分的函数应该都不陌生,我们主要学习前面没有的部分(大部分和哈希相关的,学到哈希再讲解)
这里要注意,unordered_map 的迭代器是一个单向迭代器。
1.1. 使用
cpp
#include<iostream>
#include<unordered_map>
#include<unordered_set>
using namespace std;
int main()
{
unordered_set<int> s;
s.insert(2);
s.insert(5);
s.insert(3);
s.insert(1);
s.insert(6);
s.insert(9);
unordered_set<int>::iterator it = s.begin();
while(it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
还是这段熟悉的代码
我们输出后,发现数据好像是无序的,不符合升序降序,也不符合我们插入的顺序。
因此 unordered 的含义在这里能体现,unordered_set 和 set 不同,set 输出的结果是有序的
同样的,unordered_map 和上面的玩法一样。
cpp
void test_unordered_map()
{
unordered_map<string, string> m;
m.insert(make_pair("left", "左边"));
m.insert(make_pair("right", "右边"));
m.insert(make_pair("up", "上边"));
m.insert(make_pair("down", "下边"));
m.insert(make_pair("string", "字符串"));
m.insert(make_pair("pair", "键值对"));
m.insert(make_pair("pair", "键值对"));
m.insert(make_pair("pair", "键值对"));
unordered_map<string, string>::iterator it = m.begin();
while (it != m.end())
{
cout << (*it).first << ":" << (*it).second << " ";
++it;
}
cout << endl;
}
想要传入多个相同值,还是需要用到 multi 系列
unordered_map 和 map 以及 unordered_set 和 set 功能上来说没有什么区别,只是有无顺序的问题。
还要注意 unordered_map 和 map 都支持 [] 操作,但是 multi 是不支持的。
cpp
void test_map()
{
char a[] = { 'c', 'd', 'e', 'g', 'a', 'b', 'f' };
unordered_map<char, int> um;
map<char, int> m;
for (auto e : a)
{
um[e]++;
m[e]++;
}
unordered_map<char, int>::iterator it1 = um.begin();
map<char, int>::iterator it2 = m.begin();
cout << "unordered_map:" << " ";
while (it1 != um.end())
{
cout << (*it1).first << " ";
it1++;
}
cout << endl << "map: ";
while (it2 != m.end())
{
cout << (*it2).first << " ";
it2++;
}
cout << endl;
}
包括这里的英文也是无序的状态。
map,set 底层是搜索树,unordered_map,unordered_set 底层是哈希表,所以map 数据是有序的,而 unordered_map 的数据无序。
1.2. 性能
前面提到,unordered 系列既然是无序的,正常的map,set 数据都有序,那么为什么要引入这个东西呢?
我们这里进行一场 set 的性能的对比
比赛选手是 set,unordered_set
cpp
void test_speed()
{
const size_t N = 1000000;
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N);
srand(time(0));
for (size_t i = 0; i < N; i++)
{
//v.push_back(rand());//重复值较多
//v.push_back(rand() + i);//重复值较少
v.push_back(i);//没有重复值,但有序
}
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set.size():" << s.size() << endl;
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "us.size()" << us.size() << endl;
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "insert:" << endl;
cout << "set:" << end1 - begin1 << endl;
cout << "unordered_set:" << end2 - begin2 << endl;
cout << "find:" << endl;
cout << "set:" << end3 - begin3 << endl;
cout << "unordered_set:" << end4 - begin4 << endl;
cout << "erase:" << endl;
cout << "set:" << end5 - begin5 << endl;
cout << "unordered_set:" << end6 - begin6 << endl;
}
注意我们这里设置了三种数据,一种是重复值较多,一种是重复值较少,一种是升序无重复值,我们挨个对插入,查找,删除做对比
100w,debug,重复值较多,unordered_set 插入删除更快
100w,debug,重复值较少,unordered_set 插入删除更快
100w,debug,无重复值的升序,unordered_set 插入删除更快,set查找更快。
这里就能看出 unordered_set 的性能确实比 set 更好
如果是 release 下,100w的 无重复升序,set 的插入会比 unordered_set 更快,但是删除还是 unordered_set 更快。
从上面的对比中,我们能看见,unordered_set 性能上几乎全面碾压 set,这也是为什么要引入这个结构的原因。
本质原因还是,哈希表的查找更快(删除和插入都会用到查找),哈希表的性能比红黑树略好一点。有序的情况下可能不相上下,其他情况下哈希表性能更好。
2. 哈希结构
先看其他结构的问题:在顺序结构和平衡中,元素的关键码与其存储位置之间没有对应关系,因此在查找同一个元素时,必须要经过关键码的多次比较,顺序查找的时间复杂度为O(N),平衡树中的时间复杂度为树的高度O(log2N),搜索效率去结余搜索过程中元素的比较次数。
哈希(散列) :关键字和另一个值建立一个关联关系。
首先,哈希是一种算法思想
哈希表(散列表):通过哈希思想创建出来的数据结构。
简单举个例:图书馆借书,每次读者还书后需要把书放入书架,
但是不做标记的话不好找书需要放的位置,所以每次在给读者借阅时,
在书上附上便签,写上从哪个架子,哪个位置取的,当读者还书时,
直接根据这个位置信息放书。
简单理解为这个过程,传入的值,和值的位置之间有一个映射关系,通过传入的值能直接找到位置。
哈希表的查找时间复杂度能达到 O(1),因为只要传入值,就等于传入了位置,也就不需要查找,直接去取就行了。
2.1. 直接定址法
直接定址法:值和位置关系 = 唯一关系,每个人都有一个唯一的位置
这种方法和计数排序那里的方式有点像
当我们要把这些数据填入数组中,直接定址法是:把值当做下标,想要插入什么值,就在对应下标插入。这样的好处是数据之间不容易产生冲突,但是缺点是值很分散会导致空间浪费严重
比如这里,我们想要用直接定址法插入,我们就要开 2 ~ 99999 的空间,为了存这 8 个值而开了上万块空间是很不划算的。
所以我们要想一个方法不去直接定址也能很快找到位置。
解决方法:
要保证数据处理后的值能对应数组的下标,我们可以考虑用 数据 取 数组长度 的模
hashi = key % len;
key 取 len 的模后,绝对是一个小于 len 的值
比如:99999 % 10 = 9
这样处理后,我们把 99999 存在 9 的位置就行了。
但是这种方法也有缺点:
3 % 10 == 3;
33 % 10 == 3;
难道 3 和 33 都要存在 下标为 3 的位置上吗?
当然不会,哈希表每个位置只能存一个值。
这种情况叫做 哈希碰撞/哈希冲突,不同的值映射到相同的位置上去。
这种取模来计算下标的方法:除留余数法,关键字可以很分散,量可以很大。
2.2. 开放定址法
如果除留余数法出现了哈希冲突,我们可以把冲突的值放在一个没有占用的位置存储。
一般有两种处理方法:
- 线性探测 hashi + i(i >= 0)
- 二次探测 hashi + i ^ 2(i >= 0)
线性探测是,出现冲突,就将新插入值放在冲突值后面,如何还是冲突就一直向后找,直到找到空位置就插入。
二次探测:将新插入的值取模后加上循环次数的二次方,插在新的位置。
这里方法不固定,三次探测都可以,看哪种方法适合。
如果存储的数据个数和空间的大小不符,我们可以创建一个负载因子,存储(关键字的个数 / 空间大小),当这个比例大于 80%就需要开辟空间。
当然每种方法都有自己的优缺点,线性探测可能会导致一篇数据的堆叠
当我们插入 2,3,4,33,34 后,我们再插入 5, 6,5 和 6 就会被挤到后面的位置,导致后面顺序所有插入的值都会被挤到后面
2.3. 开放地址法-模拟实现
2.3.1. 节点
注意 哈希表 和 数组的区别,数组中每个位置都有值,但是哈希表中有些位置是可以没有值的。
这里我们先使用 vector 来模拟实现
cpp
#include<iostream>
#include<vector>
namespace xsz
{
template<class K, class V>
class HashTable
{
private:
vector<pair<K, V>> _tables;
};
}
用 vector 来处理的话,还存在一个问题,怎么判断当前位置存在值呢?
我们如果删除 4 ,那 5 这个位置怎么处理?直接调用 vector 的 erase 可以吗?
不行,因为如果直接 erase,5 的下标位置就会发生改变,那么我们查找时也无法根据下标来查找了(只是向前查找不行,因为我们的查找规则已经被破坏了)
能不能元素位置给个 -1 代表没有元素呢?不行,如果这样操作了,我们就再也无法插入 -1 这个值了。
所以我们还要另想高招。
我们想既然要判断当前节点有没有值,也就是判断当前节点的状态,这不和红黑树那里很像吗,我们用一个枚举来判断当前节点的状态
cpp
enum Status
{
EMPTY,
EXIST,
DELETE
};
struct HashDate
{
pair<K, V> _KV;
Status _sl;
};
2.3.2. insert
cpp
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;
Status _sl;
};
template<class K, class V>
class HashTable
{
bool Insert(const pair<K, V>& KV)
{
size_t hashi = kv.first % _tables.size();
while(_tables[hashi]._sl == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._sl = EXIST;
_n++;
}
private:
vector<HahsDate> _tables;
size_t _n;
};
哈希这里的插入逻辑整体要比 AVL,红黑树简单很多,主要还是要处理这个映射的关系。
第一步,先对传入的值模上size,然后用模的结果作为下标尝试将数据插入。
第二步,如果当前位置存在数据,那么我们就需要线性探测,向后找空位,注意这里的条件 _tables[hashi]._sl ==EXIST 当前节点的状态是存在值,不存在或者已删除的状态都可以插入数据。
第三步,当找到空位后,就是填入数据。
这些难度都不大,这里暂时不需要担心扩容的问题,我们后面会实现负载因子的自动扩容,所以空间绝对够够的。
而且注意,数据是与 size 取模,而不是与 capacity,size 是能存数据的空间,而capacity
是 vector 当前的最大容量,但是capacity ~ size 这段区间是不能存数据的,如果对 capacity 取模后刚好落在这段区间内,程序就会崩。
负载因子控制容量
上面说了,负载因子是 关键字个数/空间大小。
负载因子太大,冲突可能会剧增,节省空间,效率降低
(如果_n / _tables.size() == 0.9,当数组快满了才开始扩容,可能会导致大多数数据拥挤,比如在1位置插入数据,在数组末尾才找到空位)
负载因子太小,冲突降低,但是空间利用率也会降低。
(如果 _n / _tables.size() == 0.5,还有一半的空间没装数据就开始急急忙忙扩容,每次容量至少空一半,空间利用率大大降低)
所以我们选的负载因子不能太大,也不能太小,一般是 70% 差不多
cpp
if(_n / _tables.size() == 0.7)
{
//...
}
但是问题又来了,_n 和 size() 的值都是 size_t 类型的,两个这种类型,相除后的结果不可能是 浮点数,所以我们要改变方式
cpp
if((double) _n / (double)_tables.size() == 0.7)
//...
if((_n * 10 / _tables.size() == 7)
//...
两种方式都可以,只要能保证结果 _n 是 size() 的 70%就行。
判断扩容的条件写好了,接下来是扩容,哈希表的扩容是为了让数据更好的展开,我们能不能像之前模拟实现 vector那样,直接扩 2 倍空间就完事了?
还是这个哈希表,如果我们直接扩容二倍,在扩容前,我们查找 34 , 34 % 10 == 4,然后向后找,但是扩二倍后 34 % 20 == 14, 我们从 14往后找,我们发现找不到。因为 vector 的 size 发生了变化。我们对应的查找规则也会发生变化,所以在扩容后,我们还需要把原表中的数据全部移入新表中,让数据符合新表的查找规则
cpp
if(_n * 10/ _tables.size() == 7)
{
size_t newSize = _tables.size() * 2;
HashTable<K, V> newHT;
newHT._tables.resize(newSize);
for(size_t = 0; i < table.size() ; i++)
{
if(_tables[i]._s == EXIST)
{
newHT.Insert(_tables [i]._kv);
}
}
_tabels.swap(newHT._tables);
}
我们先以二倍的空间对其扩容,我们的方法是先创建一个新的 哈希表,然后将原表中的数据全部 以插入的规则放入新表中,注意这里我们实现的并不是递归,只是对新表的插入操作。
当原表中的数据全部插入新表中,新表因为是vector ,我们不是 new 出来的,所以函数结束会自己析构,但是原表是不会自己析构的,所以我们把 原表 和 新表 swap 一下,要注意,这个 swap 只是指针发生了交换,所以并不会影响效率。
接下来开始测试我们的代码
cpp
void testHS1()
{
xsz::HashTable<int, int> ht;
int ap[ = {4, 14, 24, 34, 5, 7, 1};
for(auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(3, 3));
}
运行后,我们发现我们的代码崩了,因为最开始 vector 没插入数据时 size 是 0,所以取模那里就出现问题了。
所以我们要写一个构造函数,先开点空间
cpp
_HashTable()
{
_tables.resize(10);
_n = 0;
}
我们最开始默认扩容 10
因为我们采用的是线性探测,所以 14, 24, 34的位置被放在了后面,导致 5, 7的位置也发生冲突。
为了方便展示,我们简单实现一个 print 函数来观察结果
cpp
void print()
{
for(size_t i = 0; i<_n; i++)
{
if(_tables[i]._sl == EXIST)
{
cout << '[' << i << "] -> " << _tables[i]._kv.first << endl;
}
else
{
cout << '[' << i << "] -> " << "EMPTY" << endl;
}
}
}
当我们运行代码后,就能看到这里的结果,注意这个函数库中是没有的,这里只是方便我们观察数据。
但除此之外我们的哈希表还是 存在问题,正确情况下,哈希表是不支持相同数据插入的,比如 4 只能存在一份,再插入 4 就会出问题,但是我们的代码课没有这样实现
我们看到,当插入多个 3 的时候,好多位置都出现了 3 ,说明存储进了相同数据,所以下面我们要对这个位置进行调整,在调整之前我们先简单实现 find,当查找到值时说明数据存在,就不需要插入
2.3.3. find
find 函数难度不大,存入数据时是除留余数法存入,那么按照这个逻辑我们把数据取出来即可。
cpp
HashDate<K, V> find(const K& key)
{
size_t hashi = key & _tables.size();
while(_tables[hashi]._sl != EMPTY)
{
if(_tables[hashi]._kv.first == key)
{
return _tables[hashi];
return true;
}
hashi++;
hashi %= _tables.size();
}
return nullptr;
}
注意这里的逻辑,和插入时的逻辑很像。
先用传入的值取模,然后对 hashi++,直到找到相同的值返回 true。
这里要注意循环逻辑的判断,当节点为 EXIST 和 DELETE 我们都要向后找,因为当前节点是删除状态,不代表该结点后面不存在节点,比如我们在长度为 10 的哈希表中插入 4, 14, 24,当我们删除 4 后,将节点的状态设置为 DELETE,那么我们查找 14,24时,我们取模后还是 4,我们还是要从 4 这个位置向后找。当节点为 EMPTY时,说明当前节点从来没有过节点,如果是线性探测,第一次模的结果位置直到空的位置,这个范围是我们查找模都为第一次摸结果的范围。
查找逻辑很简单,接下来是修改我们的 find
cpp
bool insert(const pair<K, V>& kv)
{
//...
if(find(kv.first))
{
return false;
}
//...
}
这样我们哈希表的整体插入逻辑基本完成
2.3.4. erase
erase 如果只修改状态的话很简单,先查找,找到了把状态修改成 DELETE 即可。
cpp
bool erase(const K& key)
{
HashDate<K, V>* ret = find(key);
if(ret)
{
ret->_sl = DELETE;
--_n;
return true;
}
else
{
retunr false;
}
}
这里我们看到,3,4被成功删除了。
但是还有点小 bug,当我们还要插入 3时:
我们看到,3是没有被插入的,因为我们这里的删除是伪删除,表面上我们说删除了,实际上节点的值还是存在的,只是状态发生了变化,而我们的查找逻辑是向后查找,当值不为空时向后找,这里既要保证我们遇见 DELETE 的节点还能向后找,同时 DELETE 位置的值是不能判断相等的,所以我们 find 的判断逻辑要修改一下。
cpp
if(_tables[hashi]._sl == EXIST && _tablesphashi]._kv.first == key)
这样,我们就不会出现上面的问题。
2.3.5. 计算非整形的模
前面我们简单实现了哈希表的基本功能,但是我们的哈希表局限性有点高,只能是整形。如果我们想用我们的哈希表实现统计次数
cpp
void test_hash()
{
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果" , "西瓜", "苹果" , "西瓜", "苹果" ,"香蕉" };
xsz::HashTable<string, int> ht;
for(auto& str : arr)
{
xsz::HashDate<string, int>* ret = ht.Find(str);
if(ret)
{
ret->_kv.second++;
}
else
{
ht.insert(make_pair(str, 1));
}
}
ht.print();
}
和前面使用 map 统计次数没什么区别。但是当我们想要运行这段代码时,我们发现
这里出问题了,我们哈希表存储数据使用的是 除留余数法,但是当成员是非数值类型的,那么这里就无法进行处理。
所以这里我们的思路是用哈希的思想,将 字符串 与 整形 建立起映射关系,然后我们通过这个整形取模进行存储。
为了实现泛型编程,我们还是需要采用仿函数的方法
cpp
template<class k>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<class k, class V, class Hash = HashFunc<K>>
class HashTable
{
//...
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
//...
};
然后我们开始实现将 string 的值转化成 size_t 类型的方法。
但是 string 该怎么转换,如果只取每个字符串第一个字母的 ASCLL 值,那么发生冲突的可能会很多,所以我们的思路是将一串 string 中所有字符的 ASCLL 值全部加起来
cpp
struct HashFuncString
{
size_t operator()(const string& key)
{
size_t ret = 0;
for(auto e : key)
{
ret += (size_t)e;
}
return ret;
}
};
当我们传入的参数是 string 时,我们再传入这个仿函数,这样,所有求模的地方都会是用 上面的size_t 进行取模,而不是直接使用 string 进行取模
但是每个字符的 ASCLL 值相加,值过大怎么办?
我们看到,这里的值大的有点离谱了,甚至可能出现溢出的现象,但是这不影响我们的结果,因为我们的的目的是,将 string 转化成 size_t 类型,只要转化后取模鞥保证模后的值尽可能的少出现冲突即可。
如果我们这里插入的是英文字符,值就会小很多
实践中,把字符串转化成 size_t 类型进行除留余数法还是很常见的,除了我们上面将所有 ASCLL 值加起来的方法,还有大佬提出了其他方法(只要达到模后的值尽量不起冲突最好)
我们看到,这里是每次都要给 h 乘 5,然后再加上 s 的 ASCLL 值,值比我们累加的结果更大。
这样能减少冲突,但是不能避免冲突。字符串的值有无限多个,但是 size_t 类型最多就 2^32 个,不可避免的会出现冲突,只能减少冲突出现的可能。
接下来是对上面步骤的优化
我们看看库里的是怎么样调用的
注意,这里只传入了一个 hash< Key > 也就是说我们不需要自己手动传入,而我们自己的 hashfiunc 是需要自己传入的,所以我们还要对我们的仿函数改造一下
我们前面学过模版特例化来实现
cpp
template<class K>
struct HashFunc
{
size_t operator()(const string& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t ret = 0;
for(auto e :key)
{
ret *= 13;
ret += (size_t)e;
}
return ret;
}
};
模版特例化能用,那我们可不可以在 HashFunc 函数中重载一个 string 的 operator()
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
size_t operator()(const string& key)
//...
};
注意这样写是不行的,因为 这个仿函数使用的是模版,当模版参数是非 string 类型,就会直接使用第一个函数,如果传入的模版参数类型是 string,此时仿函数内就存在两个相同的函数,就不构成重载。
cpp
#pragma once
#include<iostream>
#include<vector>
#include<string>
using std::pair;
using std::cout;
using std::cin;
using std::endl;
using std::vector;
using std::string;
namespace xsz
{
enum Colour
{
RED,
BLACK
};
enum Status
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;
Status _sl;
};
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 ret = 0;
for (auto e : key)
{
ret *= 13;
ret += (size_t)e;
}
return ret;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
if (_n * 10 / _tables.size() == 7)
{
size_t newSize = _tables.size() * 2;
HashTable<K, V, Hash> newHT;
newHT._tables.resize(newSize);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._sl == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hf;
cout << kv.first << ":" << hf(kv.first) << endl;
size_t hashi = hf(kv.first) % _tables.size();
cout << "模后:" << hashi << endl;
while (_tables[hashi]._sl == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._sl = EXIST;
++_n;
return true;
}
void print()
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._sl == EXIST)
{
cout << i << "->" << _tables[i]._kv.first << ":" << _tables[i]._kv.second << endl;
}
else
{
cout << i << "->" << "E" << endl;
}
}
}
HashDate<K, V>* Find(const K& key)
{
Hash hf;
size_t hashi = hf(key) % _tables.size();
while (_tables[hashi]._sl != EMPTY)
{
if(_tables[hashi]._sl == EXIST && _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
}
return nullptr;
}
bool erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret)
{
ret->_sl = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
vector<HashDate<K, V>> _tables;
size_t _n = 0;
};
}
直接定址法的哈希结构,实践中用的并不多,下面我们开始模拟实现哈希桶。
2.4. 哈希桶(拉链法)
线性探测和二次探测都存在问题,线性探测会导致数据拥堵,二次探测可能会导致空间过大,所以这里有大佬通过哈希的思想创建了 哈希桶,也称为 拉链法
哈希表的插入特点是,当前位置如果存在值,就向后查找空位置。
哈希桶的特点是,出现问题,想办法在自己内部处理。
哈希桶的每个节点都是个链表的头
比如说我们这个哈希桶已经满了(暂时不考虑扩容),如果还想插入14,24,那么就交给 14 % 10 == 4, 24 % 10 == 4,交给下标为 4 的位置去处理
这里就会按照这种方法去插入数据。
如果要查找数据,也是按照先1取模,获得下标位置,然后去链表内挨个找。
当然这里不一定是链表,其他结构也是可以的,只是链表插入删除方便一点。
哈希桶的效率比上面的哈希表效率快多了,这里讲究一个内部消化
当我们要查找 24 哈希表和哈希桶都需要查找两次,但是如果查找 5 时,哈希桶只需要查找 1 次,但是 哈希表中的 5 因为哈希冲突被挤到 7 的位置,所以还是要查找两次,从这方面来说 哈希桶 的效率是快于 哈希表的。
哈希桶 - 开散列
哈希表 - 闭散列
一般情况下,哈希桶的平均时间复杂度能达到 O(1),正常情况下,不会有太多相同位置的节点,大部分的节点可能都是空,如果空节点的位置很少,我们可以考虑扩容来减少某些值很多的节点的长度。
如果某个位置的节点数非常多,我们可以使用其他数据结构如红黑树来代替节点的链表,比如 java 中当某个桶的数据超过了 8 个,就会直接挂红黑树,从而加快查找速率。
但是 c++ 并没有这样做,每个节点位置都是链表,因为正常情况下,是很少出现某个位置插入数据非常多,除非在我们知道模的规则情况下,故意去模很多相同余数的值才会出现。所以挂红黑树其实效率提升的并不是很多。
2.5. 哈希桶- 模拟实现
2.5.1. 基本逻辑
开放定址法 - 闭散列,在当前位置后面空间去找
哈希桶 - 开散列,在当前空间的指针下去找
按照上面的结构,我们可以先创建一个数组,数组内每个元素是个单链表
cpp
namespace hash_bucket
{
template<class K, class V>
struct HashNode
{
//...
};
template<class K, class V>
class HashTable
{
private:
vector<list<pair<K, V>> _table;
size_t _n;
};
}
数组还是上面的结构,但是元素我们可以选择使用容器中的 list 来实现,但是为了学习,这里我们手搓一个单链表。
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 HashTable
{
typedef HashNode<K, V> Node;
private:
vector<Node*> _table;
size_t _n;
};
}
简单的节点结构是这样,下面开始实现功能
2.5.2. insert
这里数组每个元素都是条单链表的头节点,插入节点只需要在某一条单链表进行头插即可。头插尾插都可以,不过我们有头结点指针,头插方便,如果尾插还要找尾太麻烦。
这里的图看起来第一个位置不存值,好像是哨兵位,其实这里带不带哨兵位都可以,我们查找是按照数组下标来查找的,单链表只是用来存储数据的。比如上面我们查找 33,先用 33 % 10 == 3,然后我们在下标为 3 的这个链表中查找 33 这个数据。
单链表和哈希结合起来就能完成哈希桶的结构,如果使用的是双向链表,每个节点还会浪费一个指针的空间。
unordered 系列的迭代器都是单向的,也是因为使用的是单链表。
cpp
bool insert(const pair<K, V> kv)
{
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] =-newnode;
++_n;
return true;
}
注意这里的单链表插入
cpp
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
看起来有点奇怪,其实这里就是头插的操作
单链表插入,没什么问题,接下来是完善,如果值不存在时直接插入,如果值已经存在了,需要返回 false。
所以我们还需要实现 find 对值进行查找
2.5.3. find
cpp
Node* find(const pair<K, V> kv)
{
size_t hashi = kv.first % _tables.size();
Node* cur = _tables[hashi];
while(cur)
{
if(cur->_kv.first == kv.first)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
insert + find
cpp
bool insert(const pair<K, V> kv)
{
if(find(kv));
{
return false;
}
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] =-newnode;
++_n;
return true;
}
2.5.4. 负载因子
和上面的哈希表一样,哈希桶和哈希表都需要扩容,但是按照我们的理解,哈希桶下都是单链表,当插入节点时会直接头插到单链表上,不存在空间不够的问题,为什么还是要扩容呢?
注意单链表是干什么的,单链表和上面哈希表的线性探测都是为了解决哈希冲突的,在插入数据很多的情况下,冲突越少,查找效率越快,如果说有一条单链表上的节点很多,说明哈希冲突很多,查找到下标后,在单链表中查找的效率就会很慢,因此当插入数据很多时,我们为了尽量让每个单链表都存在值,而不是大部分节点在同一条单链表上,我们需要对这个数组进行扩容
扩容方法还是和上面哈希表一样,设置一个负载因子,这个负载因子就没有哈希表的要求那么高了,毕竟哈希表容量不够连值也存不进去。我们这里默认负载因子是 1
那么接下来我们怎么去扩容呢?是不是和之前的哈希表一样,先创建一个 哈希表,然后把旧表中的数据全部移入新表中,再交换头结点指针?
不行,因为哈希表交换指针后,其结构本身就是 vector,vector 直接调用析构函数,内置类型直接析构,自定义类型调用自己的析构函数,而这里的单链表是我们自己写的,没有析构函数。所以 vector 在析构时只会删除掉指针,不会析构指针指向的内容,所以这里我们需要给我们的结构写一个析构函数
cpp
~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;
}
}
有了析构函数,我们就可以放心创建 hash_bucket 的对象了。
cpp]
bool insert(const pair<K, V>& kv)
{
if (find(kv.first))
{
return false;
}
if (_n == _tables.size())
{
size_t newSize = _tables.size() * 2;
HashTable<K, V> newHT;
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
newHT.insert(cur->_kv);
cur = next;
}
}
_tables.swap(newHT._tables);
}
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
这里我们是创建了一个新的表,然后将旧表中的值挨个拷贝进新表中,最后将新表和旧表的指针交换,就能实现扩容的操作。
是这样写存在一个非常不好的地方。
我们在向新表内拷贝值时,是将原表中的值一个一个拷贝进去的,在拷贝时,会创建一个内容相同的节点,而在最后交换完指针后,所有节点都会随着析构函数一起被析构掉。这个过程会加大时间和空间的消耗。
如果有 1w 个节点,我们开需要开 1w 个节点完成向新表的拷贝,然后删除掉原有的 1w 个节点空间。既然原有的节点值都相同,我们只需要把节点拿下来直接插入到新表中,就不需要再开空间了。
cpp
bool insert(const pair<K, V>& kv)
{
if (find(kv.first))
{
return false;
}
if (_n == _tables.size())
{
vector<Node*> newTables;
newTables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
size_t hashi = cur->_kv.first % newTables.size();
Node* next = cur->_next;
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
我哦们把原表中的数据挨个取出来,放入新表时,是对插入节点进行头插,最后旧表中不存在数据后,再将旧表析构掉。
2.5.5. erase
删除节点也很简单,先查找,没找到返回 false,找到了就是单链表删除的方法。
cpp
bool
erase(const K& key)
{
size_t hashi = kv.first % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while(cur)
[
if(cur->_kv.first == key)
{
if(prev == nullptr)
{
_tables[hashi] = cur->_next;
delete cur;
}
else
{
prev->_next = cur->_next;
delete cur;
}
return true;
}
prev = cur;
cur = cur-.>_next;
}
return false;
}
这里要注意,单链表的头检点删除要单独处理
这里我们能看到,可以直接查找,也可以输出 14 的地址
2.5.6. 计算非整形的模
还是和上面哈希表的原因一样,需要把其他类型转化为 size_t 类型的方便取模。
cpp
template<class K>
struct HashFunc
{
size_t operator()(const string& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t ret = 0;
for(auto e :key)
{
ret *= 13;
ret += (size_t)e;
}
return ret;
}
};
然后当做模版参数传入,其他地方和上面没什么区别
cpp
template<class K, class V, class Hash = HashFunc<K>>
接着注意把所有取第一参数的位置都改成这种写法
cpp
Hash hs
hs(cur->_kv.first);
2.3. 性能对比
上面我们的哈希桶整体功能就实现差不多了,和库中的容器相比,性能会怎么样?
cpp
void test_speed2()
{
const size_t N = 1000000;
unordered_set<int> us;
set<int> s;
hash_bucket::HashTable<int, int> ht;
vector<int> v;
v.reserve(N);
srand((unsigned int)time(0));
for (size_t i = 0; i < N; i++)
{
//v.push_back(rand());//重复值较多
//v.push_back(rand() + i);//重复值较少
v.push_back(i);//没有重复值,但有序
}
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set.size():" << s.size() << endl;
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "us.size():" << us.size() << endl;
size_t begin3 = clock();
for (auto e : v)
{
ht.insert(make_pair(e, e));
}
size_t end3 = clock();
cout << "ht.size():" << us.size() << endl;
cout << "insert:" << endl;
cout << "set:" << end1 - begin1 << endl;
cout << "unordered_set:" << end2 - begin2 << endl;
cout << "hashtable:" << end3 - begin3 << endl;
}
还是这段代码的逻辑,我们下面在不同数据情况下对 insert,find,erase 的效率进行对比
debug,100w 不重复升序的数据
debug,100w 重复较少的数据,插入
debug,100w 重复很多的数据,插入
从上面的结果来看,我们的哈希桶是完全碾压 set 和 unordered_set 的。这也是因为那两个容器封装的比我们写的要深一点,所以哈希桶的效率上是绝对没问题的。
速度这么快,我们再写一个函数,观察一下哈希桶插入数据时的相关信息
cpp
void some()
{
size_t bucketSize = 0;
size_t maxBucketLen = 0;
double sum = 0;
double averageBucketLen = 0;
for(size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
if (cur)
{
++bucketSize;
}
size_t bucketLen = 0;
while (cur)
{
++bucketLen;
cur = cur->_next;
}
sum += bucketLen;
if (bucketLen > maxBucketLen)
{
maxBucketLen = bucketLen;
}
}
averageBucketLen = (double)sum / (double)bucketSize;
cout << "bucketSize:" << bucketSize << endl;
cout << "maxBuctetLen:" << maxBucketLen << endl;
cout << "averageBucketLen:" << averageBucketLen << endl;
}
我们用 buctetSize 记录有数据桶的数量,maxBucketLen 记录 数据最多的桶中数据个数,
averageBucketLen 记录有数据的桶中,平均数据数量
在 100w 重复数据较多的情况下,我们看到,最大的桶的长度也就是 2,有数据的桶平均含有的数据也就是 1.2 个数据。
偶一正常情况下,冲突的情况是很少发生的,我们没有必要为了这种影响不大的情况单独使用一个数据结构来提高效率。
2.4. 全部代码
cpp
#pragma once
#include<iostream>
#include<vector>
#include<string>
using std::pair;
using std::cout;
using std::cin;
using std::endl;
using std::vector;
using std::string;
namespace open_address
{
enum Status
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;
Status _sl = 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& key)
{
size_t ret = 0;
for (auto e : key)
{
ret *= 13;
ret += (size_t)e;
}
return ret;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
if (_n * 10 / _tables.size() == 7)
{
size_t newSize = _tables.size() * 2;
HashTable<K, V, Hash> newHT;
newHT._tables.resize(newSize);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._sl == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hf;
cout << kv.first << ":" << hf(kv.first) << endl;
size_t hashi = hf(kv.first) % _tables.size();
cout << "模后:" << hashi << endl;
while (_tables[hashi]._sl == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._sl = EXIST;
++_n;
return true;
}
void print()
{
for(size_t i = 0; i < (_tables.size()); i++)
{
if (_tables[i]._sl == EXIST)
{
cout << i << "->" << _tables[i]._kv.first << ":" << _tables[i]._kv.second << endl;
}
else
{
cout << i << "->" << "E" << endl;
}
}
}
HashDate<K, V>* Find(const K& key)
{
Hash hf;
size_t hashi = hf(key) % _tables.size();
while (_tables[hashi]._sl != EMPTY)
{
if(_tables[hashi]._sl == EXIST && _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
}
return nullptr;
}
bool erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret)
{
ret->_sl = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
vector<HashDate<K, V>> _tables;
size_t _n = 0;
};
}
namespace hash_bucket
{
template<class K, class V>
struct HashNode
{
HashNode* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
:_kv(kv)
,_next(nullptr)
{}
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
typedef HashNode<K, V> Node;
public:
HashTable()
:_n(0)
{
_tables.resize(10);
}
~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)
{
if (find(kv.first))
{
return false;
}
Hash hs;
if (_n == _tables.size())
{
//size_t newSize = _tables.size() * 2;
//HashTable<K, V> newHT;
//for (size_t i = 0; i < _tables.size(); i++)
//{
// Node* cur = _tables[i];
// while (cur)
// {
// Node* next = cur->_next;
// newHT.insert(cur->_kv);
// cur = next;
// }
//}
//_tables.swap(newHT._tables);
vector<Node*> newTables;
newTables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
size_t hashi = hs(cur->_kv.first) % newTables.size();
Node* next = cur->_next;
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = hs(kv.first) % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
double load_factor()
{
return (_n * 10 / _tables.size());
}
Node* find(const K& key)
{
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];
Hash hs;
while (cur)
{
if (hs(cur->_kv.first) == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool erase(const K& key)
{
size_t hashi = key % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
Hash hs;
while (cur)
{
if (hs(cur->_kv.first) == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
void some()
{
size_t bucketSize = 0;
size_t maxBucketLen = 0;
double sum = 0;
double averageBucketLen = 0;
for(size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
if (cur)
{
++bucketSize;
}
size_t bucketLen = 0;
while (cur)
{
++bucketLen;
cur = cur->_next;
}
sum += bucketLen;
if (bucketLen > maxBucketLen)
{
maxBucketLen = bucketLen;
}
}
averageBucketLen = (double)sum / (double)bucketSize;
cout << "bucketSize:" << bucketSize << endl;
cout << "maxBuctetLen:" << maxBucketLen << endl;
cout << "averageBucketLen:" << averageBucketLen << endl;
}
private:
vector<Node*> _tables;
size_t _n = 0;
};
}