当前位置:   article > 正文

哈希表(散列表)介绍_hash表

hash表

目录

前言

一.哈希概念

        1.1 什么时哈希表

        1.2 哈希函数

        1.3 哈希冲突

        1.4 哈希冲突的解决

                1.4.1 闭散列

               1.4.2 开散列

                1.4.3 问题       


前言

        哈希表时C++11两容器unordered_set和unordered_map的底层结构。它的搜索的时间复杂度为O(1),常数次。

一.哈希概念

        1.1 什么时哈希表

        哈希表时保存数据的表。通过哈希函数使得数据和存储位置之间建立一一对应的映射关系。在查找时,通过哈希函数可以直接找到该元素。

        1.2 哈希函数

        常见的哈希函数

  • 直接定址法(常用)

        取关键字的某个线性函数来得到存储位置:Hash(key) = A*key + B。key为数据值,Hash(key)为在哈希表中保存的位置。

        优点:简单,均匀,没有哈希冲突

        缺点:数据量小,数据差值大,需要开辟的空间大,但是使用空间少,浪费空间。

        一般数据和保存位置之间是直接或者间接相关的。

        如:保存小写字符:直接开辟一个大小为26字节的数组,按照字符a保存在0好下标位置,b保存在1好下标位置的顺序保存。间接相关。

        保存所有字符:直接开辟一个大小为256字节的数组,按照字符ASCII码值,直接保存,直接相关。     

        使用场景:数据量小且数据连续情况。

  • 除留余数法(常用)

        取哈希表中允许保存数据的个数,即哈希表的容量m,去一个不大于m的数,但是接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key % p(p<=m),key为数据值,Hash(key)为在哈希表中保存的位置。一般p就取哈希表的大小m。

  • 平方取中法(了解)

        将一个数平法后取中间的3位作为哈希地址(保存位置)。

  • 折叠法(了解)

        折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
        折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况

  • 随机数法(了解)

        选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。
        通常应用于关键字长度不等时采用此法

  • 数学分析法(了解)

        设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。

        就是找一些在一串数字中,取与其他都不同的几位数字,可以代表该数字作为保存位置。如身份证号,手机号。

        1.3 哈希冲突

        对于不同的数据,通过相同哈希函数得到在哈希表中保存的位置相同,该现象称为哈希冲突。

        引起哈希冲突的原因可能是哈希函数设计得不够合理,但是,不管怎么优化哈希函数,只能降低哈希冲突的可能性,哈希冲突都是无法避免的。

        1.4 哈希冲突的解决

               解决哈希冲突有两种常见的方法:闭散列和开散列

                1.4.1 闭散列

        闭散列也叫开发定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然有空位置,那么可以把数据保存到冲突位置的下一个空位置中。(占用别的数据的位置)

 如何寻找下一个空位置?

        1. 线性探测

  • 插入

        通过哈希函数获取插入位置

        如果该位置没有元素,直接插入新元素。如果有元素,发生哈希冲突,在冲突位置顺序往后找下一个空位置,插入新元素。

  • 删除

        通过线性探测插入元素,我们知道哈希冲突的元素,一定会保存在保存位置的连续且不为空的位置,意思就是找哈希冲突的数据时,往哈希冲突位置往后找到为空位置截至。所以删除数据时,不能随便删除数据。如下:

 因此线性探测采用标记的伪删除来删除一个元素,就是哈希表中保存的是一个结构体,结构以里有一个变量保存数据,一个变量了代表当前位置的状态。

  1. //状态
  2. enum State{
  3. EXIT,//存在元素
  4. DELETE,//该位置为删除状态
  5. EMPTY,//该位置为空,不存在元素
  6. };
  7. //保存的元素类型
  8. struct Ele{
  9. T _data;//数据
  10. State _state = EMPTY;//状态
  11. };

        线性探测缺点:一旦发生哈希冲突,所有冲突的数据都会连在一起保存,容易产生数据堆积,此时插入一数据时,可能一段位置全被占用了,一直要找空位置,导致效率降低。

         2.二次探测

        针对线性探测导致冲突数据堆积的缺点,二次探测找空位置的方法是:

Hi = (H0 + i * i) % capacity,H0是一开始数据保存的位置,也就是冲突位置,Hi查找的空位置。这样查找可以使得冲突数据位置的错开的。

问题:哈希表什么情况下进行扩容?如何扩容?

        这里得介绍一个负载因子的定义:负载因子 = 填入表中的元素个数 / 哈希表的长度

        负载因子是哈希表装满的标志因子,由于表长是定值,负载因子与填入标志元素的个数成正比,所以负载因子越大,填入表中的元素个数越多,产生冲突的可能性越大,反之,负载因子越小,填入表中的元素个数越少,产生冲突的可能性越小。

        对于闭散列,负载因子是一个很重要的因素,因该严格控制在07~0.8左右。超过0.8,CPU缓存命中率降低。所以,在闭散列中,一般负载因子超过0.7就会进行扩容处理。

为什么在散列表中不在负载因子等于1时扩容?

        因为当哈希表快满了的时候,插入数据,冲突的概率很大,然后需要查找插入位置。会导致效率降低。

扩容代码:

  1. //扩容
  2. //当一开始没有插入数据,哈希表大小为0
  3. //整数/整数=整数,所以乘10,整形没小数
  4. if (_ht.capacity() == 0 || _num * 10 / _ht.capacity() >= 7){
  5. int newcapacity = (_ht.capacity() == 0 ? 10 : 2 * _ht.capacity());
  6. //建立临时哈希对象
  7. HashTable<K, T, KofT> newht;
  8. //size就是capacity,扩容
  9. newht._ht.resize(newcapacity);
  10. for (size_t i = 0; i < _ht.size(); i++){
  11. //往临时对象中插入数据
  12. if (_ht[i]._state == EXIT){
  13. newht.insert(_ht[i]._data);
  14. }
  15. }
  16. //交换两对象数据
  17. _ht.swap(newht._ht);
  18. _num = newht._num;
  19. //临时对象出作用域就析构了
  20. }

 闭散列是数据直接保存在数组中,但是,它不是很好的解决方式。当发生哈希冲突时,是去找空的位置插入数据,占用别的数据的位置。当数据很多时,冲突会越来越多。

闭散列代码实现:

  1. namespace CLOSE_TABLE{
  2. enum State{
  3. EXIT,
  4. DELETE,
  5. EMPTY,
  6. };
  7. //kv模型,KOFT是去出key的仿函数
  8. template<class K, class T, class KofT>
  9. class HashTable
  10. {
  11. public:
  12. bool insert(const T& data){
  13. //扩容
  14. if (_ht.capacity() == 0 || _num * 10 / _ht.capacity() >= 7){
  15. int newcapacity = (_ht.capacity() == 0 ? 10 : 2 * _ht.capacity());
  16. HashTable<K, T, KofT> newht;
  17. //size就是capacity
  18. newht._ht.resize(newcapacity);
  19. for (size_t i = 0; i < _ht.size(); i++){
  20. if (_ht[i]._state == EXIT){
  21. newht.insert(_ht[i]._data);
  22. }
  23. }
  24. _ht.swap(newht._ht);
  25. _num = newht._num;
  26. }
  27. KofT koft;
  28. int i = 1;
  29. int start = koft(data) % _ht.capacity();
  30. size_t index = start;
  31. while (_ht[index]._state != EMPTY){
  32. if (koft(_ht[index]._data) == koft(data)){
  33. return false;
  34. }
  35. //index++;//线性探测
  36. index = (start + i*i) % _ht.capacity();//二次探测
  37. i++;
  38. if (index >= _ht.capacity()){
  39. index = 0;
  40. }
  41. }
  42. _ht[index]._data = data;
  43. _ht[index]._state = EXIT;
  44. _num++;
  45. return true;
  46. }
  47. int find(const T& data){
  48. KofT koft;
  49. int index = koft(data) % _ht.capacity();
  50. if (koft(_ht[index]._data) != koft(data)){
  51. while (_ht[index]._state != EMPTY){
  52. index++;
  53. if (koft(_ht[index]._data) == koft(data)){
  54. return index;
  55. }
  56. }
  57. }
  58. return index;
  59. }
  60. bool erase(const T& data){
  61. KofT koft;
  62. int index = find(data);
  63. if (koft(_ht[index]._data) == koft(data)){
  64. _ht[index]._state = DELETE;
  65. _num--;
  66. return true;
  67. }
  68. return false;
  69. }
  70. public:
  71. struct Ele{
  72. T _data;
  73. State _state = EMPTY;
  74. };
  75. private:
  76. vector<Ele> _ht;//数组里保存的是数据和状态
  77. size_t _num = 0;//元素个数
  78. };
  79. }

               1.4.2 开散列

        开散列又叫链地址法(开链法),哈希表中的数组是一个指针数组。数据是以链表的形式保存,数组的元素指向链表的头节点。

        首先,数据通过哈希函数计算出保存位置,计算出来相同位置的数据归于同一个集合中,每一个子集和称为一个桶,每一个桶中的元素通过链表连接起来,链表的头结点保存在哈希表中。

        将哈希冲突的数据一链表的方式保存在一个位置。不会占用其它数据的位置。

         开散列插入数据时,可以使用头插,尾插或者在中间插入,这个没有要求。但是采用头插法比较简单,不许要找插入位置,数组元素指向的就是链表开头。

开散列增容:

        开散列增容看的也是负载因子。

        桶的数量是一定的,因为数组的数量一定。随着元素的不断插入,桶中元素的数量会不断增多,极端情况下,可能会导致一个桶中数量链表结点非常多,在查找元素时,会影响哈希表的效率。

        因此在一定情况下要对哈希表进行增容。该条件怎么确认呢?最好的情况下,是每一个桶正好一个结点,在插入数据会发生哈希冲突,。

        因当插入元素个数正好等于桶的个数时,即负载因子等于1时,可以给哈希表增容。

        增容时,会按照哈希函数重新改变位置,减少冲突。

  1. //检查扩容
  2. if (_num == _ht.capacity()){
  3. //新容量
  4. int newcapacity = _ht.capacity() == 0 ? 10 : _ht.capacity() * 2;
  5. //建立新指针数组,来保存链表头节点
  6. vector<Node *> newht;
  7. newht.resize(newcapacity);
  8. //将旧数组里的链表结点,放到新数组中
  9. for (size_t i = 0; i < _ht.capacity(); i++){
  10. Node *cur = _ht[i];
  11. while (cur){
  12. //重新确定保存位置,可以减少冲突
  13. int index = koft(cur->_data) % newcapacity;
  14. //不用新创立结点,直接将旧结点重新链到新数组中
  15. Node *next = cur->_next;
  16. cur->_next = newht[index];
  17. newht[index] = cur;
  18. cur = next;
  19. }
  20. _ht[i] = nullptr;//防止野指针
  21. }
  22. //不需要交换_num,_num没变,HashTable没变,变的是里面的数组
  23. _ht.swap(newht);
  24. }

 开散列代码实现:

  1. namespace OPEN_TABLE{
  2. template<class T>
  3. struct HashNode{
  4. HashNode(const T& data)
  5. :_next(nullptr)
  6. , _data(data)
  7. {}
  8. T _data;
  9. HashNode *_next;
  10. };
  11. template<class K,class T,class KOFT>
  12. class HashTable{
  13. typedef HashNode<T> Node;
  14. public:
  15. bool insert(const T& data)
  16. {
  17. KOFT koft;
  18. //检查扩容
  19. if (_num == _ht.capacity()){
  20. //新容量
  21. int newcapacity = _ht.capacity() == 0 ? 10 : _ht.capacity() * 2;
  22. //建立新指针数组,来保存链表头节点
  23. vector<Node *> newht;
  24. newht.resize(newcapacity);
  25. //将旧数组里的链表结点,放到新数组中
  26. for (size_t i = 0; i < _ht.capacity(); i++){
  27. Node *cur = _ht[i];
  28. while (cur){
  29. //重新确定保存位置,可以减少冲突
  30. int index = koft(cur->_data) % newcapacity;
  31. //不用新创立结点,直接将旧结点重新链到新数组中
  32. Node *next = cur->_next;
  33. cur->_next = newht[index];
  34. newht[index] = cur;
  35. cur = next;
  36. }
  37. _ht[i] = nullptr;//防止野指针
  38. }
  39. //不需要交换_num,_num没变,HashTable没变,变的是里面的数组
  40. _ht.swap(newht);
  41. }
  42. //插入位置
  43. int index = koft(data) % _ht.capacity();
  44. //检查是否存在。
  45. Node *cur = _ht[index];
  46. while (cur){
  47. if (koft(cur->_data) == koft(data)){
  48. return false;
  49. }
  50. cur = cur->_next;
  51. }
  52. //插入结点
  53. Node *newnode = new Node(data);
  54. newnode->_next = _ht[index];
  55. _ht[index] = newnode;
  56. _num++;
  57. return true;
  58. }
  59. Node *find(const T& data){
  60. KOFT koft;
  61. int index = koft(data) % _ht.capacity();
  62. Node *cur = _ht[index];
  63. while (cur){
  64. if (koft(cur->_data) == koft(data)){
  65. return cur;
  66. }
  67. cur = cur->_next;
  68. }
  69. return nullptr;
  70. }
  71. bool erase(const T& data){
  72. KOFT koft;
  73. //求位置
  74. int index = koft(data) % _ht.capacity();
  75. Node *prev = nullptr;//保存cur的前一个结点,方便删除
  76. Node *cur = _ht[index];
  77. //找结点
  78. while (cur&&koft(cur->_data) != koft(data)){
  79. prev = cur;
  80. cur = cur->_next;
  81. }
  82. //删除结点
  83. if (cur){
  84. if (prev){//不是头节点
  85. prev->_next = cur->_next;
  86. }
  87. else{//是头节点
  88. _ht[index] = cur->_next;
  89. }
  90. delete cur;
  91. }
  92. return false;
  93. }
  94. private:
  95. vector<Node *> _ht;
  96. size_t _num = 0;
  97. };
  98. }

        1.4.3 问题       

  • 哈希函数使用时,只能直接存储Key为整形的元素。例如:除留余数法,取余时,只能时是整数取余,如果传一个string类时,取余就不能计算了。

        此时需要将被模的Key转成整形。由于现实中字符串出现的比较多,这里给除字符串转整形的思路。

        思路就是:将字符串字符中的每一个的ASCII码加起来。但是研究表明,每次相加前乘一个31,131,1313 ,13131,131313会减少冲突。

代码如下:

  1. struct STR2INT{
  2. int operator()(const string& k){
  3. int hash = 0;
  4. for (int i = 0; i < k.size(); i++){
  5. hash *= 131;
  6. hash += k[i];
  7. }
  8. return hash;
  9. }
  10. };

哈希表种,模板参数还需要增加一个,来将其它类型转成整形。

由于unordered_set和unordered_map底层有哈希表实现,可以看到其实unordered_set和unordered_map传模板参数种有这一个。

那为什么我们使用 unordered_set和unordered_map时key是string也可以直接使用,并不需要我们写一个仿函数传给哈希表?

 这是因为现实中字符串使用太多了,stl在哈希表中将模板进行特化了。

  •  开散列如果一个桶链就是很长,数据很多,冲突很厉害,请问怎么解决?

可以设定一个值,如果桶链数超过这个值,就将链表转化为红黑树。查找效率高。

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

闽ICP备14008679号