当前位置:   article > 正文

【C++进阶:哈希--unordered系列的容器及封装】

【C++进阶:哈希--unordered系列的容器及封装】

本课涉及到的所有代码都见以下链接,欢迎参考指正!

practice: 课程代码练习 - Gitee.comhttps://gitee.com/ace-zhe/practice/tree/master/Hash

unordered系列关联式容器

在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到log_2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,这里中只对unordered_map和unordered_set进行介绍,unordered_multimap和unordered_multiset可自行查看文档介绍。

unordered系列mpa、set和普通map、set区别:

unordered_mpa、unordered_set的简单测试:

 使用时与map、set的基本用法几乎一样,观察发现,unordered系列的关联容器只能完成key值的去重,而不能完成排序,那为什么C++11要搞出这个系列呢?原因是综合考虑后unordered系列的容器基于哈希表底层的特性会使整体操作效率更高,下面我们从几个方面来测试set和unordered_set的性能:

性能测试:

性能测试代码如下(这里我们主要测试插入大量不同形式的随机数,二者性能,足够说明问题):

  1. void performance_test()
  2. {
  3. const size_t N = 100000;
  4. unordered_set<int> us;
  5. set<int> s;
  6. vector<int> v;
  7. v.reserve(N);
  8. srand(time(0));
  9. for (size_t i = 0; i < N; ++i)
  10. {
  11. //v.push_back(rand());
  12. //v.push_back(rand()+i);
  13. v.push_back(i);
  14. }
  15. size_t begin1 = clock();
  16. for (auto e : v)
  17. {
  18. s.insert(e);
  19. }
  20. size_t end1 = clock();
  21. cout << "set insert:" << end1 - begin1 << endl;
  22. size_t begin2 = clock();
  23. for (auto e : v)
  24. {
  25. us.insert(e);
  26. }
  27. size_t end2 = clock();
  28. cout << "unordered_set insert:" << end2 - begin2 << endl << endl;
  29. size_t begin3 = clock();
  30. for (auto e : v)
  31. {
  32. s.find(e);
  33. }
  34. size_t end3 = clock();
  35. cout << "set find:" << end3 - begin3 << endl;
  36. size_t begin4 = clock();
  37. for (auto e : v)
  38. {
  39. us.find(e);
  40. }
  41. size_t end4 = clock();
  42. cout << "unordered_set find:" << end4 - begin4 << endl << endl;
  43. cout << s.size() << endl;
  44. cout << us.size() << endl << endl;;
  45. size_t begin5 = clock();
  46. for (auto e : v)
  47. {
  48. s.erase(e);
  49. }
  50. size_t end5 = clock();
  51. cout << "set erase:" << end5 - begin5 << endl;
  52. size_t begin6 = clock();
  53. for (auto e : v)
  54. {
  55. us.erase(e);
  56. }
  57. size_t end6 = clock();
  58. cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
  59. }

 测试结果和分析如下:

由此,我们总结,实际当中大部分情况下我们还是更推荐使用unordered系列的关联式容器,前面提到它的高效率源自于其底层的哈希结构,因此接下来的重点我们就是要学习哈希结构的模拟实现。 

unordered系列容器的底层结构

哈希(又称散列)概念:

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)。

哈希函数:

哈希方法中使用的转换函数称为哈希(散列)函数,引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则
1.哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必2.须在0到m-1之间
3.哈希函数计算出来的地址能均匀分布在整个空间中
4.哈希函数应该比较简单
常见哈希函数:
常用的有两种:直接定制法和除留余数法,分别通过下图来演示分析

哈希冲突的解决:

上述我们了解到,除留余数法是比较好的哈希函数设计方法,但存在哈希冲突,那我们就要着手解决哈希冲突,解决哈希冲突常用的有两种方法:闭散列和开散列,下面我们分别介绍并实现。

闭散列:

     也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
1. 线性探测概念
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止


插入:

1.通过哈希函数获取待插入元素在哈希表中的位置
2.如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

删除:                                                                                                                                             采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素12,如果直接删除掉,22查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

2.线性探测的模拟实现

这里顺便就尝试搭建哈希表的整体框架,本处我们主要以学习哈希思想及结构为主,因此暂时不需要考虑封装unordered_map和unordered_set时的问题,其中重点将以注释的形式在代码中标注。

整体结构代码如下:

  1. #pragma once
  2. #include<iostream>
  3. #include<vector>
  4. namespace wz
  5. {
  6. //定义枚举结构表示哈希表每一个地址空间的状态
  7. enum State
  8. {
  9. EEMPTY,
  10. EXIST,
  11. DELETE
  12. };
  13. template<class K, class V>
  14. //定义哈希表中每个地址空间的数据类型,包括两部分:数据+状态
  15. class HashData
  16. {
  17. pair<K, V> _kv;
  18. State _s = EMPTY;
  19. };
  20. template<class K,class V>
  21. class HashTable
  22. {
  23. public:
  24. //...
  25. //此处实现其各个成员函数,insert等
  26. //...
  27. private:
  28. vector<HashData<K,V>> _table;//用vector来存储HastData类型的数据来表示哈希表结构
  29. size_t _n=0//表示存储的数据
  30. };
  31. }

insert()代码如下: 

实现的思路:

1、实现核心的线性探测部分,我们会发现只有这部分代码会报错:

  1. //线性探测
  2. size_t hashi = kv.first % _table.size();
  3. size_t index = hashi;
  4. int i = 1;
  5. while (_table[index]._s == EXIST)
  6. {
  7. index = hashi + i;
  8. index %= _table.size();
  9. ++i;
  10. }
  11. _table[index]._kv = kv;
  12. _table[index]._s = EXIST;
  13. _n++;

 原因是刚开始并没有给_table开辟空间,因此扩容发生在刚开始插入的时候,另外当哈希表中数据量占整体空间过大时,哈希冲突会增加,因此在现有空间即将存满的时候也会进行扩容,我们怎么衡量哈希表即将存满呢,这时候就要提出一个概念叫负载因子,负载因子=_n/_table.size(),这里扩容我们采用的是重新构造一个新容量的哈希表,将现有哈希表中的数据入新表,新表旧表数据互换,运行并测试如下:

观察上述代码我们发现在扩容后旧表数据入新表时,也需要每插入一次,线性探测一次,与后续插入新元素时的线性探测代码几乎一样,考虑C++代码的高复用性,我们需对整体代码加以改造,通过分析得到扩容后对新表的操作也可以看作是插入操作,因此这里其实直接复用insert函数即可,具体实现如下:

  1. //扩容
  2. if (_table.size() == 0 || _n * 10 / _table.size() > 7)
  3. {
  4. size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
  5. HashTable<K,V> newht;
  6. newht._table.resize(newsize);
  7. for (auto& data : _table)
  8. {
  9. if (data._s== EXIST)
  10. {
  11. newht.insert(data._kv);
  12. }
  13. }
  14. _table.swap(newht._table);
  15. }

 另外重要的一点是不能忘记还要考虑去重问题,去重就需要查找,因此我们先来实现查找函数,查找的思路也是以线性探测为核心

find()代码如下: 

  1. HashData<K, V>* find(const K& key)
  2. {
  3. //如果当前哈希表没开空间,当然不会找到,返回空指针
  4. if (_table.size() == 0)
  5. {
  6. return nullptr;
  7. }
  8. // 线性探测
  9. size_t hashi = key% _table.size();
  10. size_t i = 1;
  11. size_t index = hashi;
  12. //如果当前哈希表中数据状态不为空,则可能有与要插入的值相等的值
  13. while (_table[index]._s != EMPTY)
  14. {
  15. //存在且相等,表示该值已经插入到表中了,返回该数据所在表中的地址
  16. if (_table[index]._s == EXIST
  17. && _table[index]._kv.first == key)
  18. {
  19. return &_table[index];
  20. }
  21. index = hashi + i;
  22. index %= _table.size();
  23. ++i;
  24. // 如果已经查找一圈,那么说明全是存在+删除
  25. if (index == hashi)
  26. {
  27. break;
  28. }
  29. }
  30. //到这里就说明没找到,返回空指针即可
  31. return nullptr;
  32. }

完善insert()代码如下:

  1. bool insert(const pair<K, V>& kv)
  2. {
  3. //判断表里原来有没有
  4. if (find(kv.first))
  5. {
  6. return false;
  7. }
  8. //扩容
  9. if (_table.size() == 0 || _n * 10 / _table.size() > 7)
  10. {
  11. size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
  12. HashTable<K,V> newht;
  13. newht._table.resize(newsize);
  14. for (auto& data : _table)
  15. {
  16. if (data._s== EXIST)
  17. {
  18. newht.insert(data._kv);
  19. }
  20. }
  21. _table.swap(newht._table);
  22. }
  23. //线性探测
  24. size_t hashi = kv.first % _table.size();
  25. size_t index = hashi;
  26. int i = 1;
  27. while (_table[index]._s == EXIST)
  28. {
  29. index = hashi + i;
  30. index %= _table.size();
  31. ++i;
  32. }
  33. _table[index]._kv = kv;
  34. _table[index]._s = EXIST;
  35. _n++;
  36. return true;
  37. }

测试结果如下:

erase()代码如下:

删除部分我们采取直接用状态标识的假删除,因此实现起来比较简单,如下:

  1. bool erase(const K& key)
  2. {
  3. HashData<K, V>* ret = find(key);
  4. if (ret)
  5. {
  6. ret->_s = DELETE;
  7. --_n;
  8. return true;
  9. }
  10. else
  11. {
  12. return false;
  13. }

 测试结果如下:

综合测试:

 3.二次探测概念

 上面我们用代码实现了线性探测一次来解决哈希碰撞的问题,但解决方式不止一种,二次探测也是常用的方法

线性探测的缺陷是产生冲突的数据堆积在一块,容易形成踩踏这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:hashi = (H_0 + i^2 )% m, 或者:hashi = (H_0 - i^2 )% m。其中:i = 1,2,3…, H_0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。

研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷,我们实际上不常以闭散列的方式实现哈希表,接下来介绍常用的开散列实现方式。

开散列:

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

 本质可以认为哈希表中存的是一个个的结点指针,以这些结点指针为头指针后面还链接这其它结点指针,从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素

开散列的模拟实现

这里仍然不考虑封装的问题,先实现功能,首先哈希桶整体结构如下:

  1. namespace HashBucket
  2. {
  3. template<class K,class V>
  4. struct HashNode
  5. {
  6. HashNode<K,V>* _next;
  7. pair<K, V> _kv;
  8. HashNode(const pair<K, V>& kv)
  9. :_next(nullptr)
  10. , _kv(kv)
  11. {}
  12. };
  13. template<class K,class V>
  14. class HashTable
  15. {
  16. typedef HashNode<K,V> Node;
  17. //...
  18. //成员函数部分
  19. //...
  20. private:
  21. vector<Node*> _table;
  22. size_t _n=0;
  23. };
  24. }
析构函数:
这里与闭散列不同,需要自己实现析构函数,原因是闭散列的时候,存在表里的是一个个数据本身,而开散列存的则是一个个结点指针,关键是头结点指针后面可能还链接着其它结点指针,闭散列在程序结束后,默认的析构函数就够用了,而开散列调用默认析构函数指针释放各个桶的头结点指针,每个桶后面挂着的结点指针还在,造成内存泄露,代码如下:
  1. ~HashTable()
  2. {
  3. for (auto cur : _table)
  4. {
  5. while (cur)
  6. {
  7. Node* next = cur->next;
  8. delete cur;
  9. cur = next;
  10. }
  11. cur = nullptr;
  12. }
  13. }

Find()查找函数:

无论是删除还是插入都需要用到查找函数,因此我们先来实现它,代码如下:

  1. Node* Find(const K& key)
  2. {
  3. if (_table.size() == 0)
  4. return nullptr;
  5. size_t hashi = key% _table.size();
  6. Node* cur = _table[hashi];
  7. while (cur)
  8. {
  9. if (cur->_kv.first == key)
  10. {
  11. return cur;
  12. }
  13. cur = cur->_next;
  14. }
  15. return nullptr;
  16. }

Insert()插入函数:

插入函数的的思想其实和闭散列的实现相类似,都是先判断表中有没有,再判断是否需要扩容,最后在插入到合适的位置,只是在扩容部分有些区别,开散列的扩容是可以在负载因子为1时扩容的,其次在闭散列时,我们推荐的是复用思想,即构造一个新的哈希表,就哈希表中元素依次插入新哈希表后,新旧表内容交换,但这里不推荐这种方法,原因是消耗太大,假若一共有N个数据在哈希表中,那么构造一个新的哈希桶就要构造再N个结点,析构N个结点,完全没必要,因此我们选择定义一个newtable,直接将_table中的结点指针按照规则插入链接即可,如下:

  1. bool Insert(const pair<K, V>& kv)
  2. {
  3. if (find(kv.first))
  4. {
  5. return false;
  6. }
  7. if (_table.size() == _n)
  8. {
  9. size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
  10. vector<Node*> newtable(newsize, nullptr);
  11. //for (Node*& cur : _table)
  12. for (auto& cur : _table)
  13. {
  14. while (cur)
  15. {
  16. Node* next = cur->_next;
  17. size_t hashi = cur->_kv.first% newtable.size();
  18. // 头插到新表
  19. cur->_next = newtable[hashi];
  20. newtable[hashi] = cur;
  21. cur = next;
  22. }
  23. }
  24. _table.swap(newtable);
  25. }
  26. size_t hashi = kv.first % _table.size();
  27. Node* newnode = new Node(kv);
  28. newnode->_next = _table[hashi];
  29. _table[hashi] = newnode;
  30. _n++;
  31. return true;
  32. }

Erase()删除函数:

开散列删除函数的实现就不想闭散列那么简单了,它没有状态的概念,只能实打实的删除,并且设计到指针的链接修改问题,实现如下,需要注意的点都以注释的形式标注了:

  1. bool erase(const K& key)
  2. {
  3. int ret = Finf(key);
  4. if (ret)//ret不为空,找到了,开始删除
  5. {
  6. size_t hashi = key % _table.size();
  7. Node* cur = _table[hashi];
  8. Node* prev = nullptr;
  9. while (cur)//从头结点开始往下找,知道碰到nullptr这个桶就走完了
  10. {
  11. if (cur->_kv.first == key)
  12. {
  13. if (prev == nullptr)//说明该节点为头结点,头结点直接更新为cur的下一个即可
  14. {
  15. _table[hashi] = cur->_next;
  16. }
  17. else//不是头结点,让prev即cur的前一个节点的_next指向cur的_next
  18. {
  19. prev->_next = cur->_next;
  20. }
  21. delete cur;
  22. return true;
  23. }
  24. else
  25. {
  26. prev = cur;
  27. cur = cur->_next;
  28. }
  29. }
  30. }
  31. return false;
  32. }

测试结果如下:

 综上,我们自己以开散列形式实现的哈希表基本功能已经实现,接下来就是要考虑细节。

代码完善

1.key值类型不确定,如何解决

我们现在实现的代码,只适用于key值为整型的数据类型,因为在定位hashi中都涉及到取模的运算,并不是所有类型都能隐式转换为整型再进行取模运算,比如说字符串类型,字符数组是无法自动转换为整型的,这个时候,我们采用的是设置仿函数,如下:

  1. template<class K>
  2. struct Hashfunc
  3. {
  4. size_t operator()(const K& key)
  5. {
  6. return key;
  7. }
  8. };
  9. template<class K,class V,class Hash= Hashfunc<K>>
  10. class HashTable
  11. {
  12. //..//
  13. };

使用过程中,只需要在要进行取模运算之前定义一个Hash仿函数对象,使用时按照函数的方式使用即可,以Find()函数中的应用为例,如下:

  1. Node* Find(const K& key)
  2. {
  3. if (_table.size() == 0)
  4. return nullptr;
  5. Hash hash;
  6. size_t hashi = hash(key)% _table.size();
  7. Node* cur = _table[hashi];
  8. while (cur)
  9. {
  10. if (cur->_kv.first == key)
  11. {
  12. return cur;
  13. }
  14. cur = cur->_next;
  15. }
  16. return nullptr;
  17. }

如果类似于字符串这样不能自动转为整型的类型就手动实现一个,使用时传自己实现的即可

注意:对于将字符串转换为整型存在以下问题

1.如果转换方式选择返回字符串首字符对应的ASCII值,那就会存在,首字符相同的值会被认为是相同的值,如:"student"和"string";

2.如果转换方式选择返回字符串所有字符对应的ASCII值相加,那就会存在,字母组成相同只有顺序不同的会被认为是相同的值,如:“ate”和“eat”;

要想解决这个问题,我们就要找到尽可能转换结果不会重复的转换方式,为此专门有人研究为我们提供了字符串相关的哈希算法,实现方式很多,具体可以参考以下链接,我们这里只以其中一种常用的为例:

https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.htmlhttps://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html我们选择的方式是每加一个字符,就给结果*31,实现如下:

  1. struct HashStr
  2. {
  3. // BKDR,该算法的名称,是发明的两名作者名字首字母的缩写组合
  4. size_t operator()(const string& s)
  5. {
  6. size_t hash = 0;
  7. for (auto ch : s)
  8. {
  9. hash += ch;
  10. hash *= 31;
  11. }
  12. return hash;
  13. }
  14. };

 测试结果如下:

由于字符串类型经常作为key值被使用,每次用的时候都要传一次未免过于麻烦,而且之前测试使用unordered_map时发现也是不用自己传仿函数的,这是因为库里针对key值为字符串类型的情况对仿函数模板进行了特化,我们也来特化一下:

  1. template<class K>
  2. struct Hashfunc
  3. {
  4. size_t operator()(const K& key)
  5. {
  6. return key;
  7. }
  8. };
  9. // 特化
  10. template<>
  11. struct Hashfunc<string>
  12. {
  13. // BKDR
  14. size_t operator()(const string& s)
  15. {
  16. size_t hash = 0;
  17. for (auto ch : s)
  18. {
  19. hash += ch;
  20. hash *= 31;
  21. }
  22. return hash;
  23. }
  24. };

这样我们使用的时候,以字符串类型做key值类型的时候就不用自己传了,编译器会自动识别匹配。

2.除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?

具体原因其实没有找到特别权威准确的描述,但有人提出来,可能是做了测试,认为素数造成的碰撞会少一些,我们就参考SGI库里的理解一下即可,代码如下:

  1. size_t GetNextPrime(size_t prime)
  2. {
  3. // SGI
  4. static const int __stl_num_primes = 28;
  5. static const unsigned long __stl_prime_list[__stl_num_primes] =
  6. {
  7. 53, 97, 193, 389, 769,
  8. 1543, 3079, 6151, 12289, 24593,
  9. 49157, 98317, 196613, 393241, 786433,
  10. 1572869, 3145739, 6291469, 12582917, 25165843,
  11. 50331653, 100663319, 201326611, 402653189, 805306457,
  12. 1610612741, 3221225473, 4294967291
  13. };
  14. size_t i = 0;
  15. for (; i < __stl_num_primes; ++i)
  16. {
  17. if (__stl_prime_list[i] > prime)
  18. return __stl_prime_list[i];
  19. }
  20. return __stl_prime_list[i];
  21. }
  22. //使用方法: size_t newsize = GetNextPrime(_tables.size());

总的来说就是初始容量为53开始,每次扩容都是上一次容量2倍附近的一个值,这些数都是无符号长整型类型的数,直至最大无符号长整型为止【事实上根本不可能到这么大,从内存角度理解】。

3.性能优化

理论上,最坏情况下,哈希表的查找效率是O(N),这种情况就是所有的数据都挂在一个桶上,但实际上这种最坏的情况几乎不会发生,因为我们有上述负载因子的控制,哈希表每个桶的长度不会过长,以一组随机数为例来测试:

  1. size_t MaxBucketSize()
  2. {
  3. size_t max = 0;
  4. for (size_t i = 0; i < _table.size(); ++i)
  5. {
  6. auto cur = _table[i];
  7. size_t size = 0;
  8. while (cur)
  9. {
  10. ++size;
  11. cur = cur->_next;
  12. }
  13. //printf("[%d]->%d\n", i, size);
  14. if (size > max)
  15. {
  16. max = size;
  17. }
  18. }
  19. return max;
  20. }

测试结果如下:

插入9万个数最多数据的桶才2,足可见,效率其实可以几乎达到O(1)

 即使几乎不可能达到最坏的情况,但不排除会有人故意难为你,问你如何解决单个桶元素过多的情况,这里提供一种思想,了解一下就好,不需要去实现,我们可以给设置一个标准值,当一个桶链接的节点个数超过标准值是,就不在一个个的按照原来的方式往下链接,而是转换为红黑树插入,这样就能够解决每个桶链接数据量过大导致效率低下的问题。

模拟实现

基本框架的搭建:

首先要按照封装map和set的方式改造哈希表结构,先搭架子。

  1. //HashTable.h
  2. #pragma once
  3. #include<vector>
  4. #include<string>
  5. template<class T>
  6. struct HashNode
  7. {
  8. HashNode<T>* _next;
  9. T _data;
  10. HashNode(const T& data)
  11. :_next(nullptr)
  12. , _data(data)
  13. {}
  14. };
  15. template<class K>
  16. struct Hashfunc
  17. {
  18. size_t operator()(const K& key)
  19. {
  20. return key;
  21. }
  22. };
  23. // 特化
  24. template<>
  25. struct Hashfunc<string>
  26. {
  27. // BKDR
  28. size_t operator()(const string& s)
  29. {
  30. size_t hash = 0;
  31. for (auto ch : s)
  32. {
  33. hash += ch;
  34. hash *= 31;
  35. }
  36. return hash;
  37. }
  38. };
  39. namespace HashBucket
  40. {
  41. template<class K, class T,class KeyOfT, class Hash = Hashfunc<K>>
  42. class HashTable
  43. {
  44. typedef HashNode<T> Node;
  45. public:
  46. ~HashTable()
  47. {
  48. for (auto cur : _table)
  49. {
  50. while (cur)
  51. {
  52. Node* next = cur->_next;
  53. delete cur;
  54. cur = next;
  55. }
  56. cur = nullptr;
  57. }
  58. }
  59. Node* Find(const K& key)
  60. {
  61. if (_table.size() == 0)
  62. return nullptr;
  63. KeyOfT kot;
  64. Hash hash;
  65. size_t hashi = hash(key) % _table.size();
  66. Node* cur = _table[hashi];
  67. while (cur)
  68. {
  69. if (kot(cur->_data) == key)
  70. {
  71. return cur;
  72. }
  73. cur = cur->_next;
  74. }
  75. return nullptr;
  76. }
  77. size_t GetNextPrime(size_t prime)
  78. {
  79. // SGI
  80. static const int __stl_num_primes = 28;
  81. static const unsigned long __stl_prime_list[__stl_num_primes] =
  82. {
  83. 53, 97, 193, 389, 769,
  84. 1543, 3079, 6151, 12289, 24593,
  85. 49157, 98317, 196613, 393241, 786433,
  86. 1572869, 3145739, 6291469, 12582917, 25165843,
  87. 50331653, 100663319, 201326611, 402653189, 805306457,
  88. 1610612741, 3221225473, 4294967291
  89. };
  90. size_t i = 0;
  91. for (; i < __stl_num_primes; ++i)
  92. {
  93. if (__stl_prime_list[i] > prime)
  94. return __stl_prime_list[i];
  95. }
  96. return __stl_prime_list[i];
  97. }
  98. bool Insert(const T& data)
  99. {
  100. KeyOfT kot;
  101. // if (Find(kot(data)){return false;}
  102. if (_table.size() == _n)
  103. {
  104. //size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
  105. size_t newsize = GetNextPrime(_table.size());
  106. vector<Node*> newtable(newsize, nullptr);
  107. //for (Node*& cur : _table)
  108. for (auto& cur : _table)
  109. {
  110. while (cur)
  111. {
  112. Node* next = cur->_next;
  113. Hash hash;
  114. KeyOfT kot;
  115. size_t hashi = hash(kot(cur->_data)) % newtable.size();
  116. // 头插到新表
  117. cur->_next = newtable[hashi];
  118. newtable[hashi] = cur;
  119. cur = next;
  120. }
  121. }
  122. _table.swap(newtable);
  123. }
  124. Hash hash;
  125. size_t hashi = hash(kot(data)) % _table.size();
  126. Node* newnode = new Node(data);
  127. // 头插到新表
  128. newnode->_next = _table[hashi];
  129. _table[hashi] = newnode;
  130. _n++;
  131. return true;
  132. }
  133. bool erase(const K& key)
  134. {
  135. auto ret = Find(key);
  136. if (ret)//ret不为空,找到了,开始删除
  137. {
  138. Hash hash;
  139. KeyOfT kot;
  140. size_t hashi = hash(key) % _table.size();
  141. Node* cur = _table[hashi];
  142. Node* prev = nullptr;
  143. while (cur)//从头结点开始往下找,知道碰到nullptr这个桶就走完了
  144. {
  145. if (kot(cur->_data) == key)
  146. {
  147. if (prev == nullptr)//说明该节点为头结点,头结点直接更新为cur的下一个即可
  148. {
  149. _table[hashi] = cur->_next;
  150. }
  151. else//不是头结点,让prev即cur的前一个节点的_next指向cur的_next
  152. {
  153. prev->_next = cur->_next;
  154. }
  155. delete cur;
  156. _n--;
  157. return true;
  158. }
  159. else
  160. {
  161. prev = cur;
  162. cur = cur->_next;
  163. }
  164. }
  165. }
  166. return false;
  167. }
  168. size_t MaxBucketSize()
  169. {
  170. size_t max = 0;
  171. for (size_t i = 0; i < _table.size(); ++i)
  172. {
  173. auto cur = _table[i];
  174. size_t size = 0;
  175. while (cur)
  176. {
  177. ++size;
  178. cur = cur->_next;
  179. }
  180. //printf("[%d]->%d\n", i, size);
  181. if (size > max)
  182. {
  183. max = size;
  184. }
  185. }
  186. return max;
  187. }
  188. private:
  189. vector<Node*> _table;
  190. size_t _n = 0;
  191. };
  192. }
  1. //unordered_map
  2. #pragma once
  3. #include "HashTable.h"
  4. namespace wz
  5. {
  6. template<class K,class V,class Hash= Hashfunc<K>>
  7. class unordered_map
  8. {
  9. public:
  10. struct MapKeyofT
  11. {
  12. const K& operator()(const pair<K, V>& kv)
  13. {
  14. return kv.first;
  15. }
  16. };
  17. bool Insert(const pair<const K,V>& kv)
  18. {
  19. return _ht.Insert(kv);
  20. }
  21. bool Find(const K& key)
  22. {
  23. return _ht.Find(key);
  24. }
  25. bool Erase(const K& key)
  26. {
  27. return _ht.erase(key);
  28. }
  29. private:
  30. HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash> _ht;
  31. };
  32. void test_unordered_map1()
  33. {
  34. unordered_map<int, int> m;
  35. m.Insert(make_pair(1, 1));
  36. m.Insert(make_pair(2, 2));
  37. m.Insert(make_pair(3, 3));
  38. }
  39. }
  40. //unordered_set.h
  41. #pragma once
  42. #include "HashTable.h"
  43. namespace wz
  44. {
  45. template<class K, class Hash = Hashfunc<K>>
  46. class unordered_set
  47. {
  48. public:
  49. struct SetKeyofT
  50. {
  51. const K& operator()(const K& key)
  52. {
  53. return key;
  54. }
  55. };
  56. bool Insert(const K& key)
  57. {
  58. return _ht.Insert(key);
  59. }
  60. bool Find(const K& key)
  61. {
  62. return _ht.Find(key);
  63. }
  64. bool Erase(const K& key)
  65. {
  66. return _ht.erase(key);
  67. }
  68. private:
  69. HashBucket::HashTable<K, K, SetKeyofT, Hash> _ht;
  70. };
  71. void test_unordered_set1()
  72. {
  73. unordered_set<int> s;
  74. s.Insert(1);
  75. s.Insert(2);
  76. s.Insert(3);
  77. }
  78. }

以上完成了哈希表基本结构的改造和unordered_map和unordered_set基本框架的编写及测试,测试结果都显示是没有什么问题的,实现方式和用红黑树封装map和set大同小异,接下来的重点依然是如何实现哈希表的迭代器进而如何封装出unordered_map和unordered_set的迭代器。

迭代器的实现:

哈希表的迭代器实现如下:

  1. //因为要在里面定义哈希表,因此最好做前置声明
  2. template<class K, class T, class KeyOfT, class Hash>
  3. class HashTable;
  4. //下面正式开始实现迭代器
  5. template<class K,class T,class Ref,class Ptr,class KeyOfT,class Hash>
  6. struct __HashIterator
  7. {
  8. typedef HashNode<T> Node;
  9. typedef HashTable<K, T, KeyOfT, Hash> HT;
  10. typedef __HashIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;
  11. typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;
  12. //模拟遍历哈希表的行为,思考作为哈希表的迭代器需要的成员变量
  13. Node* _node;//当前结点指针
  14. const HT* _ht;//当前所在哈希表
  15. __HashIterator(Node* node, const HT* ht)
  16. :_node(node)
  17. , _ht(ht)
  18. {}
  19. __HashIterator(const Iterator& it)
  20. :_node(it._node)
  21. , _ht(it._ht)
  22. {}
  23. Ref operator*()
  24. {
  25. return _node->_data;
  26. }
  27. Ptr operator->()
  28. {
  29. return &_node->_data;
  30. }
  31. bool operator!=(const Self& s)
  32. {
  33. return _node != s._node;
  34. }
  35. Self& operator++()
  36. {
  37. if (_node->_next != nullptr)
  38. {
  39. _node = _node->_next;
  40. }
  41. else
  42. {
  43. // 找下一个不为空的桶
  44. KeyOfT kot;
  45. Hash hash;
  46. // 算出我当前的桶位置
  47. size_t hashi = hash(kot(_node->_data)) % _ht->_table.size();
  48. ++hashi;
  49. while (hashi < _ht->_table.size())
  50. {
  51. if (_ht->_table[hashi])
  52. {
  53. _node = _ht->_table[hashi];
  54. break;
  55. }
  56. else
  57. {
  58. ++hashi;
  59. }
  60. }
  61. // 没有找到不为空的桶
  62. if (hashi == _ht->_table.size())
  63. {
  64. _node = nullptr;
  65. }
  66. }
  67. return *this;
  68. }
  69. };

在HashTable中定义begin()、end()、const_begin()、const_end(),如下:

  1. //友元声明,因为需要访问到迭代器的成员
  2. template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
  3. friend struct __HashIterator;
  4. typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> iterator;
  5. typedef __HashIterator<K, T, const T&, const T*, KeyOfT, Hash> const_iterator;
  6. iterator begin()
  7. {
  8. Node* cur = nullptr;
  9. for (size_t i = 0; i < _table.size(); ++i)
  10. {
  11. cur = _table[i];
  12. if (cur)
  13. {
  14. break;
  15. }
  16. }
  17. return iterator(cur, this);
  18. }
  19. iterator end()
  20. {
  21. return iterator(nullptr, this);
  22. }
  23. const_iterator begin() const
  24. {
  25. Node* cur = nullptr;
  26. for (size_t i = 0; i < _table.size(); ++i)
  27. {
  28. cur = _table[i];
  29. if (cur)
  30. {
  31. break;
  32. }
  33. }
  34. return const_iterator(cur, this);
  35. }
  36. const_iterator end() const
  37. {
  38. return const_iterator(nullptr, this);
  39. }

unordered_map和unordered_set的迭代器封装哈希表中的迭代器如下:

  1. //unordered_map.h
  2. typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash>::iterator iterator;
  3. typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash>::const_iterator const_iterator;
  4. iterator begin()
  5. {
  6. return _ht.begin();
  7. }
  8. iterator end()
  9. {
  10. return _ht.end();
  11. }
  12. const_iterator begin() const
  13. {
  14. return _ht.begin();
  15. }
  16. const_iterator end() const
  17. {
  18. return _ht.end();
  19. }
  20. //unordered_set.h
  21. typedef typename HashBucket::HashTable<K, K, SetKeyofT, Hash>::const_iterator iterator;
  22. typedef typename HashBucket::HashTable<K, K, SetKeyofT, Hash>::const_iterator const_iterator;
  23. iterator begin()
  24. {
  25. return _ht.begin();
  26. }
  27. iterator end()
  28. {
  29. return _ht.end();
  30. }
  31. const_iterator begin() const
  32. {
  33. return _ht.begin();
  34. }
  35. const_iterator end() const
  36. {
  37. return _ht.end();
  38. }

测试基本功能如下,可见目前我们的实现没有问题:

进一步完善:

到这一步,我们对unordered_set的封装就已经差不多了,unordered_map还差一个重要的部分就是实现[ ],类比map和set的[ ]分析与实现,我们同样可以快速完成,与此同时要做一件事情就是将所有Insert()的返回值类型都修改为pair<iterator,bool>类型

  1. //unordered_map.h
  2. V& operator[](const K& key)
  3. {
  4. pair<iterator, bool> ret = Insert(make_pair(key, V()));
  5. return ret.first->second;
  6. }
  7. pair<iterator, bool> Insert(const pair<const K,V>& kv)
  8. {
  9. return _ht.Insert(kv);
  10. }
  11. //unordered_set.h
  12. pair<iterator, bool> Insert(const K& key)
  13. {
  14. return _ht.Insert(key);
  15. }
  16. //HashTable.h
  17. pair<iterator, bool> Insert(const T& data)
  18. {
  19. KeyOfT kot;
  20. iterator it = Find(kot(data));
  21. if (it != end())
  22. {
  23. return make_pair(it, false);
  24. }
  25. if (_table.size() == _n)
  26. {
  27. //size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
  28. size_t newsize = GetNextPrime(_table.size());
  29. vector<Node*> newtable(newsize, nullptr);
  30. //for (Node*& cur : _table)
  31. for (auto& cur : _table)
  32. {
  33. while (cur)
  34. {
  35. Node* next = cur->_next;
  36. Hash hash;
  37. KeyOfT kot;
  38. size_t hashi = hash(kot(cur->_data)) % newtable.size();
  39. // 头插到新表
  40. cur->_next = newtable[hashi];
  41. newtable[hashi] = cur;
  42. cur = next;
  43. }
  44. }
  45. _table.swap(newtable);
  46. }
  47. Hash hash;
  48. size_t hashi = hash(kot(data)) % _table.size();
  49. Node* newnode = new Node(data);
  50. // 头插到新表
  51. newnode->_next = _table[hashi];
  52. _table[hashi] = newnode;
  53. _n++;
  54. return make_pair(iterator(newnode, this), true);
  55. }

修改Find()返回值类型为iterator,记得所有需要用到Find返回值的都需要修改:

  1. //unordered_map.h
  2. iterator Find(const K& key)
  3. {
  4. return _ht.Find(key);
  5. }
  6. //unordered_map.h
  7. iterator Find(const K& key)
  8. {
  9. return _ht.Find(key);
  10. }
  11. //HashTable.h
  12. iterator Find(const K& key)
  13. {
  14. if (_table.size() == 0)
  15. return end();
  16. KeyOfT kot;
  17. Hash hash;
  18. size_t hashi = hash(key) % _table.size();
  19. Node* cur = _table[hashi];
  20. while (cur)
  21. {
  22. if (kot(cur->_data) == key)
  23. {
  24. return iterator(cur, this);;
  25. }
  26. cur = cur->_next;
  27. }
  28. return end();
  29. }

综上,我们的代码基本完善,我来来用自定义类型作为key的方式来测试一下,这里就用我们之前实现过的日期类的基本代码即可,日期类如下:

  1. class Date
  2. {
  3. friend struct HashDate;
  4. public:
  5. Date(int year = 1900, int month = 1, int day = 1)
  6. : _year(year)
  7. , _month(month)
  8. , _day(day)
  9. {}
  10. bool operator<(const Date& d)const
  11. {
  12. return (_year < d._year) ||
  13. (_year == d._year && _month < d._month) ||
  14. (_year == d._year && _month == d._month && _day < d._day);
  15. }
  16. bool operator>(const Date& d)const
  17. {
  18. return (_year > d._year) ||
  19. (_year == d._year && _month > d._month) ||
  20. (_year == d._year && _month == d._month && _day > d._day);
  21. }
  22. bool operator==(const Date& d) const
  23. {
  24. return _year == d._year
  25. && _month == d._month
  26. && _day == d._day;
  27. }
  28. friend ostream& operator<<(ostream& _cout, const Date& d);
  29. private:
  30. int _year;
  31. int _month;
  32. int _day;
  33. };
  34. ostream& operator<<(ostream& _cout, const Date& d)
  35. {
  36. _cout << d._year << "-" << d._month << "-" << d._day;
  37. return _cout;
  38. }

作为key值的话必须能支持转换成整型,才能参与哈希底层的取模运算,日期类不支持自行转换,因此我们自己实现HashDate,实现如下,需要注意的是,在这过程中,由于需要访问日期类内部私有成员变量,因此可以将其在日期类中声明为友元类,HashDate实现代码如下:

  1. struct HashDate
  2. {
  3. size_t operator()(const Date& d)
  4. {
  5. //这里要访问私有成员,因此将其在日期类里声明为友元类
  6. size_t hash = 0;
  7. hash += d._year;
  8. hash *= 31;
  9. hash += d._month;
  10. hash *= 31;
  11. hash += d._day;
  12. hash *= 31;
  13. return hash;
  14. }
  15. };

做一个简单的测试,测试代码和结果如下,说明我们封装的unordered_map基本上没什么问题:

综上,我关于以哈希为底层的unordered_set和unordered_map总结的就差不多了,内容很多,细节也不少,多看几遍,多敲代码,以便加深理解!

本课涉及到的所有代码都见以下链接,欢迎参考指正!

practice: 课程代码练习 - Gitee.comhttps://gitee.com/ace-zhe/practice/tree/master/Hash

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/繁依Fanyi0/article/detail/563605
推荐阅读
相关标签
  

闽ICP备14008679号