• C++:哈希表和哈希桶


    "总是担心明天,又怎能把我好今天?" 


    (一) 哈希

    ①哈希概念

    我们要查找关键key值,在不管是 顺序结构/平衡树 中,都需要进行遍历比较。

    顺序结构的时间复杂度为O(N) 平衡树的时间复杂度为高度次O(logN);

    哈希的本质,就是基于完成快速查找,提出的。

    结构1(散列表):

    它是一种,让查找数通过某种函数(Func),让元素的存储位置与关键码(Func与查找数)

    -----建立映射关系.

    1.插入元素;

    根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放.

    2.搜索元素;

    计算元素关键码,用关键码去找元素的存储位置,进行比较.

    ②哈希冲突(碰撞);

    我们看上面的列表+函数。如果key为22时,

     

    因此,所谓的哈希冲突,就是指。

    不同key值 通过同样的哈希函数, 算出的关键码所映射出的位置(地址)一样~ 

    为什么会出现哈希冲突?

    其原因就在于哈希函数设计的不够合理;

    哈希函数设计原则:

    1.定义域内必须包含所需的存储码(关键码).如果散列表允许有m个地址,其值域必须在0~m-1

    2.哈希函数算出来的地址,需要均匀空间.

    3.设计简单 

    常见的哈希函数有两种;

    1.直接定制法

    Hash(Key)= A*Key + B

    2.除留余数法

    Hash(key) = key% p(p<=m)


    (二)哈希闭散列:

    解决哈希冲突的两个方法: 闭散列开散列

    (1)什么是闭散列?

    也叫开放定址法.

    当发生哈希冲突时,如果哈希表未被装满,就把key存放到冲突位置中的 “下一个” 空位置中

    找寻方法;

    1.线性探测: 从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止

    2.二次探测;在线性探测的基础上,走二次方的存放地址。

    本质上,是牺牲别人的存储位置。且线性探测会造成连续的冲突,发生踩踏效应. 

    负载因子;存储数据的个数/空间大小.

    负载因子大 冲突的概念更高

    负载因子小 冲突的概念小。

    因此,在表中进行数据的插入时,一定要随时进行增容,来减小哈希冲突!

    (2)闭散列实现;

    ①哈希结构

    1. //这种结构设计的优势在于 ,可以不用真正意义上删除数据
    2. //只需要改变数据的 状态 然后 进行覆盖
    3. enum State
    4. {
    5. EXIST, //存在
    6. EMPTY, //空
    7. DELTE //删除
    8. };
    9. //哈希表 映射位置的值
    10. template<class K,class V>
    11. struct HashData
    12. {
    13. pair _data;
    14. State _state=EMPTY;
    15. };
    16. //Hash 表
    17. template<class K,class V>
    18. class HashTable
    19. {
    20. typedef HashData HashData;
    21. public:
    22. private:
    23. //可以看出 哈希表 就是一种线性结构
    24. /*HashData* _table;
    25. size_t _size;
    26. size_t capacity;*/
    27. //可直接用vector代替
    28. vector _table;
    29. size_t _n=0; //有效数据数
    30. };

    ②查找

    1. HashData* find(const K& key)
    2. {
    3. if (_table.size() == 0)
    4. return nullptr;
    5. //查找
    6. size_t start = key % _table.size();
    7. size_t index = start;
    8. int i = 1;
    9. //找到空为止
    10. //因为insert 插入 是往空插入
    11. while (_table[index]._state != EMPTY)
    12. {
    13. if (_table[index].state == EXIST
    14. && _table[index]._kv == key)
    15. {
    16. return _table[index];
    17. }
    18. index = start + i;
    19. index %= _table.size();
    20. index+=i;
    21. }
    22. return nullptr;
    23. }

    查找的逻辑较为简单,还是注意的是,循环的条件是 判断到index处为空 为止。

    ③插入;

    1. bool insert(const pair& kv)
    2. {
    3. HashData* ret = find(kv.first);
    4. //说明已经存在 了
    5. if (ret)
    6. return false;
    7. //负载因子 = 数据个数 / 空间大小 --->扩容 减少哈希冲突
    8. if (_table.size() == _n) //这样子设计 也可以避免在 未插入数据前 _n 为空 size为0 的情况
    9. {
    10. //扩容10 空间
    11. _table.resize(10);
    12. }
    13. //当负载因子 出现 问题
    14. else if ((double)_n / (double)_table.size() > 0.7)
    15. {
    16. //需要扩容
    17. //构造一个 新对象
    18. HashTable newtable;
    19. //开好空间
    20. newtable._table.resize(2 * _table.size());
    21. //从_table中读取数值
    22. for (auto& e : _table)
    23. {
    24. if (e._state == EXIST)
    25. {
    26. //复用insert
    27. newtable.insert(e._kv);
    28. }
    29. }
    30. //再进行交换
    31. _table.swap(newtable._table);
    32. }
    33. //利用哈希函数找大 关键码
    34. //线性探测 + 二次探测
    35. //记录 第一个关键位置的值
    36. size_t start = (kv.first) % _table.size();
    37. size_t index = start;
    38. int i = 1;
    39. //查找表里的index位置 进行数据储存
    40. //如果 index 索引位置 只有为 EMPTY+DELETE 才停下
    41. while (_table[index]._state == EXIST)
    42. {
    43. //找冲突位置的 后面为空的位置
    44. //以start 为基准 + i 的偏移量
    45. index = start + i; // index += start + i*i ---->就可以实现二次探测
    46. //不让index 超出 _table.size()的范围;
    47. index %=_table.size();
    48. i++;
    49. }
    50. //存入数据
    51. _table[index]._kv = kv;
    52. _table[index]._state = EXIST;
    53. _n++;
    54. return true;
    55. }

    ④删除;

    1. bool Erase(const K& key)
    2. {
    3. HashData* ret = find(key); //找这个数在不在
    4. if (ret == nullptr)
    5. return false;
    6. else
    7. {
    8. //找到ret 并把状态置位DELETE
    9. ret->_state = DELTE;
    10. --_n;
    11. }
    12. }

    ⑤测试;

    我们先来插入些数据;

     测试样例 很正常。

    但此时我们换成字符串就出问题了。 

    根本原因在于;字符串无法取模。

    哈希函数 仅仅只能针对整型处理。

    字符串哈希算法https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html 这里有 一些前人 经过实验得出的 字符串转整型

    我们就用一种方法即可;

     

     完成统计;

     闭散列的讲解就到这里。因为还有一种更优的设计。我们会把重心放在 开散列上。


    (2)什么是开散列?

    开散列; 又称链表地址法

    每个关键码地址下, 会链接多个节点。形似 挂桶一样。所以也叫哈希桶。

    各链表的头节点,会存放在关键码地址中。

     
    ①结构;

    1. //本质上 哈希数据 就是单链表
    2. template<class K,class V>
    3. struct HashData
    4. {
    5. HashData* _next;
    6. pair _kv;
    7. HashData(const pair& kv)
    8. :_kv(kv),
    9. _next(nullptr)
    10. {}
    11. };
    12. template<class K,class V,class HashFunc=Hash>
    13. class HashBuckets
    14. {
    15. typedef HashData Node;
    16. public:
    17. private:
    18. //存 节点地址
    19. vector _table;
    20. size_t _n=0;
    21. };

    ②查找;

    1. Node* find(const K& key)
    2. {
    3. HashFunc hf;
    4. if (_table.size() == 0)
    5. return nullptr;
    6. else
    7. {
    8. //找相对位置
    9. size_t index = hf(key) % _table.size();
    10. Node* cur = _table[index];
    11. //cur 走到nullptr 为止
    12. while (cur)
    13. {
    14. //找到就返回
    15. if (hf(cur->_kv.first) == key)
    16. {
    17. return cur;
    18. }
    19. else
    20. {
    21. //没找到就走
    22. cur = cur->_next;
    23. }
    24. }
    25. }
    26. return nullptr;
    27. }

    ④插入;

    插入需要注意的是,增容部分 不是拷贝节点。而是让节点链接到新表上去!

    1. bool insert(const pair& kv)
    2. {
    3. Node* ret = find(kv.first);
    4. if (ret)
    5. return false;
    6. HashFunc hf;
    7. //哈希桶同样也需要 扩容
    8. //当负载因子 为 1 到时候扩容
    9. if (_n == _table.size())
    10. {
    11. vector NTable;
    12. // NTable._table.resize(_table.size() * 2);
    13. NTable.resize(GetNextPrime(_table.size()));
    14. // 这里不是拷贝赋值 而是让旧表里的指针
    15. //链接进新表
    16. for (int i = 0; i < _table.size() ; i++)
    17. {
    18. if (_table[i])
    19. {
    20. //旧表节点
    21. Node* cur = _table[i];
    22. while (cur)
    23. {
    24. //记录 cur 的下一个 因为 头插 会改变cur->next
    25. Node* next = cur->_next;
    26. //重新计算映射位置
    27. size_t index = hf(kv.first) % NTable.size();
    28. //头插
    29. cur->_next = NTable[index];
    30. NTable[index] = cur;
    31. cur = next;
    32. }
    33. _table[i] = nullptr;
    34. }
    35. }
    36. _table.swap(NTable);
    37. }
    38. //插入 哪个位置
    39. size_t index = hf(kv.first) % _table.size();
    40. //去构建 一个节点nenode 以备插入
    41. Node* newnode = new Node(kv);
    42. //插入选择头插 因为效率高
    43. newnode->_next = _table[index];
    44. _table[index] = newnode;
    45. ++_n;
    46. return true;
    47. }

    ⑤删除;

    1. bool Erase(const K& key)
    2. {
    3. //删除节点 的区别 就在头删 + 其它删除删
    4. size_t index = hf(key) % _table.size();
    5. Node* prev = nullptr;
    6. Node* cur = _table[index];
    7. while (cur)
    8. {
    9. if (cur->_kv.first == key)
    10. {
    11. if (cur == _table[index])
    12. {
    13. _table[index] = cur->_next;
    14. }
    15. else
    16. {
    17. prev->_next = cur->_next;
    18. }
    19. delete cur;
    20. --_n;
    21. return true;
    22. }
    23. else
    24. {
    25. prev = cur;
    26. cur = cur->_next;
    27. }
    28. }
    29. return false;
    30. }

    有一个观点;

    除留余数法,最好模一个素数

    这个仅供参考。因为没什么理论依据。

    ⑤测试; 


     

    (三) 哈希三散列的反思;

    在实际应用中,使用开散列(哈希桶)的场景远远多余闭散列(哈希表).、

    (1)内存空间的使用

    对于开散列而言;

    为了避免哈希冲突,提高查找效率。会开辟多余空闲空间。

    而表项所占的空间也比指针大。 因此链接地址法反而更节省空间。

    因为哈希的目的; 节省空间!

    当然也有极端情况;当所有节点链接在同一块区域。

    哈希桶的负载因子很低,但事实上却构成冲突。


     

    哈希的闭散列 、开散列也就结束了。

    感谢你的阅读。

    祝你好运~

    ​​​​​​​

  • 相关阅读:
    Ubuntu20.04+GTX1060+显卡驱动+CUDA11.8+cuDNN8.5.0
    Day3:面试必考题目
    (2022|TMLR,Parti,ViT-VQGAN,P2,樱桃树)扩展自回归模型以进行内容丰富的文本到图像生成
    (Carousel)解决:Element-ui 中 Carousel 走马灯的样式的修改问题
    JAVA面试不背八股文面试就过不了吗?老猿教你一招
    【Linux系统】第一篇:基础指令篇
    AI大全-通往AGI之路
    Python 3.10里面的Match-Case语法详解
    Java递归算法(Java算法和数据结构总结笔记)[6/20]
    leetcode 41. 缺失的第一个正数(困难)
  • 原文地址:https://blog.csdn.net/RNGWGzZs/article/details/126067252