『 C++ - Hash 』闭散列与开散列哈希表详解及其实现 ( 万字 )

news2024/10/7 18:20:50

文章目录

    • 👾 哈希表概念
    • 👾 常见哈希函数
      • 🎏 直接定址法
      • 🎏 除留余数法
    • 👾 哈希冲突的解决方案
      • 🎏 闭散列与闭散列哈希表的实现
        • 🎐 闭散列哈希表的节点设置与基本架构
        • 🎐 闭散列哈希表的插入逻辑及实现
        • 🎐 闭散列哈希表的扩容
        • 🎐 闭散列哈希表的查找
        • 🎐 闭散列哈希表的数据删除
        • 🎐 闭散列哈希表整体代码(供参考)
      • 🎏 开散列与开散列哈希表的实现
        • 🎐 开散列哈希表的节点设置与基本架构
        • 🎐 开散列哈希表的插入逻辑及实现
        • 🎐 开散列哈希表的扩容
          • 🦠 哈希表扩容的优化
        • 🎐 确保哈希表的泛型特性
        • 🎐 开散列哈希表的查找
        • 🎐 开散列哈希表的数据删除
        • 🎐 开散列哈希表整体代码(供参考)


👾 哈希表概念

请添加图片描述

哈希表是一种常用的数据结构,该数据结构往往能存储大量的数据,在C++当中,底层为哈希表的容器最常见的为unordered_xxx系列,例如unordered_mapunordered_set,这两个容器是在C++当中以哈希表为底层的关联式容器,具体的关联式容器的特点参照上篇;

哈希表通过一个叫做哈希函数(Hash Function)的算法,将存储的每个数据项与一个唯一的键值(key)进行绑定;这个函数会将每个键值映射到哈希表中的一个位置,以便对数据进行快速访问;

哈希表的高效决定了这个数据结构在计算机中的地位:

  • 高效增删查改

    在理想情况下,哈希表在进行这些操作的时间复杂度一般可以达到接近O(1);

由于哈希表中的数据是以元素的存储位置与关键码的绑定映射的关系,那么在对数据进行查找的时候只需要通过所谓的关键码即能找到该元素;

本文主要实现对K,V键值对数据插入的哈希表模型;

该篇博客进行对哈希表中迭代器的封装

👾 常见哈希函数

请添加图片描述

假设存在一组数据;

{13,10,7,4,8,9};

要将数据进行存储,即可以使用一种简单的哈希对数据进行存储;

以该图为例即为一种简单的哈希;

该方法也被称为直接定址法,即将数据以绝对映射的方式放置在顺序容器当中(例如数组),从而达到存储数据的目的;


🎏 直接定址法

请添加图片描述

在上面的例子当中即为直接定址法;

取关键字的某个线性函数为散列地址:Hash(Key) = A*Key + B;

以绝对映射的关系将数据进行存储,该方法方便快捷;

  • 代码(简单实现,供参考):

    #include <iostream>
    #include <vector>
    
    using namespace std;
    
    class HashTable {
    private:
        vector<int> table;
    
    public:
        HashTable(int size) {
            table.resize(size, -1); // 初始化哈希表,将所有元素设为-1
        }
    
        void insert(int key, int value) {
            if (key >= 0 && key < table.size()) {
                table[key] = value; // 将值插入哈希表中对应的位置
            } else {
                cout << "Key out of range!" << endl;
            }
        }
    };
    
    int main() {
        HashTable hashtable(100); // 创建一个大小为100的哈希表
    
        hashtable.insert(42, 10);
        hashtable.insert(15, 20);
        hashtable.insert(67, 30);
        
        return 0;
    }
    
    

当然其有一个较大的缺点,该方法只支持数据量差值较小,或是数据相对集中的场景;

  • 若是数据间差值较大,则可能导致空间浪费的情况;

    存在一组数据为{3,2,5,7,1001};

    在这种场景中若是使用直接定址法则至少需要开最大值1001+1个空间对数据进行存储;

    而实际数据量仅仅为5个,这将大大浪费空间;


🎏 除留余数法

请添加图片描述

除留余数法能够大大降低直接定址法所出现的极端场景;

同样以上面直接定址法的极端场景为例:

  • 存在一组数据为{3,2,5,7,1001};

    使用除留余数法对数据进行存储:

    将上述的数据取余表的大小,并将数据存储至余数部分;

    { 3 % 10 = 3 , 2 % 10 = 2 ,5 % 10 = 5 , 7 % 10 = 7 ,1001 % 10 = 1 }

即设散列表中允许的地址数为m,取一个不大于m但最接近或者等于m的质数P作为除数;

按照哈希函数:Hash(key) = key%p(p<=m)将关键码转换成对应的哈希地址(一般除数P为表的大小);

  • 代码(简单实现,供参考):

    #include <iostream>
    #include <vector>
    
    using namespace std;
    
    class HashTable {
    private:
        vector<vector<int>> table;
        int size;
    
    public:
        HashTable(int tableSize) {
            size = tableSize;
            table.resize(size);
        }
    
        int hash(int key) {
            return key % size; // 使用除留余数法计算哈希值
        }
    
        void insert(int key, int value) {
            int index = hash(key);
            table[index].push_back(value); // 插入值到哈希表的对应位置
        }
    
    };
    
    int main() {
        HashTable hashtable(10); // 创建一个大小为10的哈希表
    
        hashtable.insert(42, 10);
        hashtable.insert(15, 20);
        hashtable.insert(67, 30);
    
    
        return 0;
    }
    
    

这种方法可以有效的解决极端情况当中对于两数之间差值较大的情况;

但是随之而来的又有另外的问题:

  • 若是两个数据的取余余数相等应该如何进行解决?

    如该问题所述,若是出现这种情况仍然将数据放置进对应的位置时,新数据将会把原来的数据进行覆盖,从而出现数据丢失的情况;

    这种情况也被称为哈希冲突;

本文将着重以除留余数法为主题;


👾 哈希冲突的解决方案

请添加图片描述

在上文当中,在使用除留余数法对数据进行存储时将会造成哈希冲突;

若是出现哈希冲突的情况下一样强行将数据进行存储那么将可能出现数据覆盖的问题;

上文中出现的两段代码,尤其是对于除留余数法都是简单的哈希表实现,而在实际的使用当中,在使用除留余数法对数据进行存储时应该注意哈希冲突的问题;

在一般情况下处理哈希冲突有两种方案:

  • 闭散列
  • 开散列

🎏 闭散列与闭散列哈希表的实现

请添加图片描述

闭散列(Closed Hashing)也被称作开放定址法;

当数据在插入时发生哈希冲突时,如果哈希表未满,说明在哈希表当中还有可以存放数据的位置;

而闭散列则为当哈希未满时将数据插入至其他非冲突位置;

而所谓的其他非冲突位置即为空位置;

至于空位置的位置决定于插入时的探测方式,一般的探测方式为以下两种:

  • 线性探测

    线性探测即为将数据插入至冲突位置的下一个空位;

  • 二次探测

    二次探测为将数据插入至下一个位置的乘方倍的位置,即 i2 位置;


🎐 闭散列哈希表的节点设置与基本架构

请添加图片描述

当数据插入时若是出现哈希冲突,则需要采用线性探测或者二次探测的方式对数据进行插入;

在删除时只需要使用插入时相同的哈希规则(映射关系)对数据进行查找并删除即可;

而实际当中,在数组中对数据直接进行删除其效率会变得尤其低效;

且若是对数据直接删除则会出现一种情况:

  • 不能很好的在线性探测或者二次探测中探测到冲突数据应该插入的位置;

那么在这种情况下不能对数据直接进行删除,对应的应该采用一种伪删除法;

即表中所存储的数据也不能是所需要的数据,相对的应该存储一个原生结构,这个结构内应存放数据以及其存在状态;

  • 代码(供参考):

    enum State{
      //设置枚举类型
      /*
        分别为
        1.空
        2.存在
        3.删除
        三个状态
      */
        EMPTY,
        EXIST,
        DELETE
    };
    
    template<class K,class V>
    struct HashiData{
          //设置节点
            std::pair<K, V> _kv;//Key Value模型
            State _state = EMPTY;//默认情况下节点为空
    };
    
    template<class K, class V>
    class HashiTable{
        //整体构架
        public:
        typedef HashiData<K, V> Data;//使用typedef进行重命名方便后序的调用
    
        HashiTable():_tables(0),_n(0){}//构造函数使得初始的Vector容器的大小为0,_n表示当前存在的有效数据
        
        
        //其他成员函数...
        
        private:
            std::vector<Data> _tables;//利用vector容器实现闭散列的哈希表
            size_t _n;//存储数据个数
    };
    

    在该段代码中,使用了枚举的方式设置了数据存在状态的可能性(空,存在,删除);

    并设置了一个节点,这个节点主要存储了数据及其存在的情况;

这段代码中整体结构主要存在两个成员:

  • _table

    该容器即为本质哈希表的结构,为一个vector容器;

  • _n

    这个成员表示了目前数据的存储个数,当然它表示的是表中状态为EXIST状态的数据个数;


🎐 闭散列哈希表的插入逻辑及实现

请添加图片描述

插入逻辑即为上文提到的除留余数法,当发生哈希冲突的时候使用线性探测或者二次探测的方式来解决哈希冲突的问题;

  • 代码(供参考):

     bool Insert(const std::pair<K, const V> &kv) {
    
           size_t hashi = kv.first % _tables.size();//除留余数法确定数据需要插入的位置
           size_t index = hashi;
           size_t i = 0;
    
           while (_tables[index]._state == EXIST) {
            /*
            线性探测:
            	当该数据所需要存储的位置发生哈希冲突时则将数据插入至发生冲突的后一个位置;
            	若是后一个位置也存在数据则继续向后遍历;
            */
             index = (hashi + i)%_tables.size();
             ++i;
           }
    
           _tables[index]._kv = kv;
           _tables[index]._state = EXIST;
           ++_n;
    
           return true;
            }
    
    

    在该段代码当中,使用了除留余数法对数据进行插入;

    并在发生哈希冲突时使用线性探测对数据进行插入;


🎐 闭散列哈希表的扩容

请添加图片描述

在哈希表中,在使用除留余数法对数据进行插入时若是数据量到一定程度时则需要对表进行扩容;

  • 那么当数据量到什么程度时可以对表进行扩容?

    在哈希表当中,扩容的需求是在插入过程中进行的,所以只需要在插入中的对应环境进行扩容即可;

针对提出的问题可以引入一个新的概念,即负载因子;

  • 什么是负载因子?

    负载因子实际上是哈希表中现存的有效数据占表大小的一个因子;

    其的计算即为表中存在有效数据/表的大小;

    在该哈希表的实现当中可以看作_n/_tables;

    负载因子的大小取决于用户需要表的能力以及实现,一般控制在0.7或是0.8;

    当负载因子在0.7或是0.8时即可以对表进行扩容操作;

那么关键的问题是:

  • 如何对表进行扩容?

    从上面的大致结构可以看出,实际上闭散列的哈希表本质上是一个数组容器vector,那么是否可以直接对容器进行扩容?

    实际上,在扩容时有一个非常重要的思想,即为哈希表的规则;

    哈希表的规则自始至终都贯穿在整个结构当中,无论是增删查改;

    那么如果忽略哈希表的哈希规则而直接采用vector中的resize()接口对齐进行扩容将会导致哈希规则错乱;

    简而言之即为若是直接使用resize()接口对vector容器进行扩容时,其对应的映射关系将会打乱;

    以该图为例,若是直接将vector容器进行扩容时将破坏原有的映射关系;

    那么实际上在哈希表需要进行扩容时只需要另开一个新表并将原有的数据重新以新的哈希规则进行插入即可(可直接开辟新的哈希表遍历原哈希表并调用Insert接口进行插入);

而在插入过程当中还需要处理对初始插入时表大小为0的状态;

  • 代码(供参考):

    bool Insert(const std::pair<K, const V> &kv) {
    		//-------扩容-------
            if(_tables.size() == 0 || (_n*100) / _tables.size() >=75){
              /*
                哈希表中存在一个概念为负载因子
                负载因子即为数据中存在的数据/表的大小 负载因子越大时越容易造成哈希冲突 [ps:哈希冲突指的是一个值占了另一个值的位置];
                当负载因子到达一定大小时需要扩大表的大小从而降低负载因子
    
                开空间时不能直接在原地开空间,若是使用在原地开空间则将会破坏原来的哈希规则 使得在查找以及下次插入时都存在问题;
                开空间时应该重新开辟一块空间 并且以新的表的大小来确定新的哈希规则重新对数据进行插入 
                同时新的哈希规则制定完毕以后 旧的哈希规则则可以摒弃遗忘 对应的空间也应该释放
                (因为闭散列中使用的哈希结构只采用了vector容器,故不需要再节点中指定对应的析构函数)
              */
              size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
              HashiTable<K, V> newtables;
              newtables._tables.resize(newsize);
              for(auto &it : _tables){
                newtables.Insert(it._kv);
              }
            _tables.swap(newtables._tables);
            
            //-------正常插入-------
           size_t hashi = kv.first % _tables.size();
           size_t index = hashi;
           size_t i = 0;
        //    if (_tables[index]._kv.first == kv.first) return false;
           while (_tables[index]._state == EXIST) {
            
             index = (hashi + i)%_tables.size();
             ++i;
           }
    
           _tables[index]._kv = kv;
           _tables[index]._state = EXIST;
           ++_n;
    
           return true;
            }
    

当然,在哈希表中的负载因子并不是越小越好;

  • 负载因子太小则,哈希冲突概率低,空间利用率低,查找效率高;

  • 负载因子太大则,哈希冲突概率高,空间使用率高,查找效率低;


🎐 闭散列哈希表的查找

请添加图片描述

哈希表的查找只需要遵从当前的哈希规则进行查找即可;

即按照插入的逻辑思路进行查找;

  • 代码(供参考):

    Data* Find(const K& key){
              //与插入函数的逻辑相同
              
              if(_tables.size() == 0){
                //如果是空表则返不进行查找
                return nullptr;
              }
    
              size_t hashi = key % _tables.size();
              size_t index = hashi;
              size_t i = 0;
              while (_tables[index]._state != EMPTY) {  //如果不为空则循环继续找数据
    
                if (_tables[index]._state == EXIST &&//条件为数据存在在表中且状态为存在
                 _tables[index]._kv.first == key)
                  return &_tables[index];
    
                index = (hashi + i) % _tables.size();
                ++i;
                if(index == hashi) break;
                //在查找过程中如果没找到数据的前提下index又回到了hashi的位置则代表已经找了一圈了 说明不存在数据 可以跳出循环(极端情况)
              }
              return nullptr;
            }
    

由于在哈希表的逻辑当中需要用到取模运算%,所以若是表的大小为0时将会引发除零错误;

为了避免除零错误的发生,应该在查找之前判断表的大小是否为0,若是表为空则停止查找;

当然,在实际的使用当中,哈希表不能插入表中已经存在的数据,所以在插入函数Insert()的插入之前可以调用Find()接口进行检查;

若是表中存在相同数据则不再进行插入;


🎐 闭散列哈希表的数据删除

请添加图片描述

闭散列哈希表的删除采用的是一个伪删除法,即将对应数据中的状态进行修改即可;

  • 代码(供参考):

    bool Erase(const K& key){
              //采用伪删除法
              /*
                伪删除法的思路只要改变节点中的状态即可
              */
              Data *to_del = Find(key);
               if (to_del) {
                to_del->_state = DELETE;
                --_n;
                return true;
               }
              return false;
            }
    

🎐 闭散列哈希表整体代码(供参考)

请添加图片描述

#pragma once

//***************************************
//**********闭散列哈希表的实现***********
//***************************************

#include<iostream>
#include<vector>

enum State{
  //设置枚举类型
  /*
    分别为 ( 1.空 2.存在 3.删除 ) 三个状态
  */
    EMPTY,
    EXIST,
    DELETE
};

template<class K,class V>
struct HashiData{
      //设置节点
        std::pair<K, V> _kv;//Key Value模型
        State _state = EMPTY;//默认情况下节点为空
};

template<class K, class V>
class HashiTable{
    //整体模型
    public:
     typedef HashiData<K, V> Data;//使用typedef进行重命名方便后序的调用

    HashiTable():_tables(0),_n(0){}//构造函数使得初始的Vector容器的大小为0,_n表示当前存在的有效数据

     bool Insert(const std::pair<K, const V> &kv) {

        Data *to_find = Find(kv.first);
        if(to_find) return false; 

        if(_tables.size() == 0 || (_n*100) / _tables.size() >=75){

          size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
          HashiTable<K, V> newtables;
          newtables._tables.resize(newsize);
          for(auto &it : _tables){
            newtables.Insert(it._kv);
          }
        _tables.swap(newtables._tables);
   
       size_t hashi = kv.first % _tables.size();
       size_t index = hashi;
       size_t i = 0;
    //    if (_tables[index]._kv.first == kv.first) return false;
       while (_tables[index]._state == EXIST) {
        
         index = (hashi + i)%_tables.size();
         ++i;
       }

       _tables[index]._kv = kv;
       _tables[index]._state = EXIST;
       ++_n;

       return true;
        }

        Data* Find(const K& key){
          //与插入函数的逻辑相同
          
          if(_tables.size() == 0){
            //如果是空表则返不进行查找
            return nullptr;
          }

          size_t hashi = key % _tables.size();
          size_t index = hashi;
          size_t i = 0;
          while (_tables[index]._state != EMPTY) {  //如果不为空则循环继续找数据

            if (_tables[index]._state == EXIST &&//条件为数据存在在表中且状态为存在
             _tables[index]._kv.first == key)
              return &_tables[index];

            index = (hashi + i) % _tables.size();
            ++i;
            if(index == hashi) break;
            //在查找过程中如果没找到数据的前提下index又回到了hashi的位置则代表已经找了一圈了 说明不存在数据 可以跳出循环(极端情况)
          }
          return nullptr;
        }

        bool Erase(const K& key){
          //采用伪删除法
          /*
            伪删除法的思路只要改变节点中的状态即可
          */
          Data *to_del = Find(key);
           if (to_del) {
            to_del->_state = DELETE;
            --_n;
            return true;
           }
          return false;
        }
         
    protected:
       

    private:
        std::vector<Data> _tables;//利用vector容器实现闭散列的哈希表
        size_t _n;//存储数据个数
};

虽然闭散列的方式可以很好的对数据进行插入并且解决除留余数法中的哈希冲突的问题;

但虽然解决了哈希冲突的问题后又引发了一个问题:

  • 即多组数据引发哈希冲突将会发生踩踏

    当一组数据因为哈希冲突而使用线性探测的方式使得数据插入到了冲突位置的下一个位置时;

    这个数据所插入的数据可能是后面数据所要插入的位置,这个问题即被称为哈希碰撞(Hash Collision);

当发生哈希碰撞时可以使用二次探测来减少哈希碰撞的问题;

二次探测的说明参照上文;

虽然二次探测能够减少哈希碰撞的频率,但是无法完全避免;

所以为了解决该问题可以使用开散列的方式来实现哈希表;


🎏 开散列与开散列哈希表的实现

请添加图片描述

开散列(Open Hashing)也被称作拉链法;

当数据在插入时发生哈希冲突时,可以采用链式结构的方式对数据进行存储;

即本质上也是利用一个vector容器,只不过对应的容器内并不存放其他数据,而是存放一个节点的指针;

通过链式结构的方式将发生哈希冲突的数据链接在一起;

在开散列当中,哈希表的每个桶通常是一个链表(或是其他的动态数据结构),用于存储映射到同一哈希值的所有元素;

因此,当发生哈希冲突时,元素会被添加到对应的哈希值的链表当中;

这种方式能够很好的解决使用除留余数法的哈希冲突;


🎐 开散列哈希表的节点设置与基本架构

请添加图片描述

开散列的哈希表的节点设置并不像闭散列哈希表需要定义节点的状态;

对于开散列而言,vector容器中所存储的并不是数据本身,而是一个节点,所以对于开散列而言若是在数据删除时可以直接对数据进行delete操作,不需要定义其节点状态;

  • 代码(供参考);

    template <class K, class V>
    struct HashNode {
      // 哈希表的节点设置
      typedef HashNode<K, V> Node;
      Node* _next = nullptr;
      std::pair<K, V> _kv;
    
      HashNode(const std::pair<K, V> kv) : _kv(kv) {}
    };
    
    template <class K, class V, class Hash = HashFunc<K>>
    class HashTable {
     public:
      typedef HashNode<K, V> Node;
    
        //成员函数
        
     private:
      std::vector<Node*> _hashtable;  // 哈希表整体构造
      size_t _n = 0;                  // 负载因子
    };
    

在开散列的哈希表中也需要一个负载因子来判断其是否需要进行扩容操作;

  • 那么为什么开散列的哈希表也需要进行扩容操作?

实际上虽然开散列使用了拉链法的方式对数据进行插入,但若是一个桶下挂了过多的数据也会降低哈希表的整体效率;

所以在使用开链法实现哈希表时同样也要对哈希表进行扩容操作;


🎐 开散列哈希表的插入逻辑及实现

请添加图片描述

开散列哈希表的插入操作与闭散列哈希表的插入逻辑大部相同;

  • 唯一不同的是

    对于闭散列哈希表来说,当出现哈希冲突的问题时闭散列采用的是线性探测或是二次探测的解决方式;

    而对于开散列而言,开散列只需要对数据进行头插即可,其并不需要担心发生的哈希冲突问题;

  • 代码(供参考):

    bool Insert(const std::pair<K, V> kv) {
        /*
        	扩容...
        */
    
        // 正常插入
        Node* newnode = new Node(kv);
        size_t hashi = to_int(kv.first) % _hashtable.size();
        // std::cout << kv.first <<std::endl;
        newnode->_next = _hashtable[hashi];
        _hashtable[hashi] = newnode;
        ++_n;
        return true;
      }
    

    插入时只需要进行链表的头插即可;


🎐 开散列哈希表的扩容

请添加图片描述

在上文中提到,对于开散列而言也需要判断其是否需要进行扩容;

  • 那么开散列哈希表在哪种情况中需要对表进行扩容操作?

    在开散列的哈希表中,扩容操作一般取决于其最坏的情况;

    即假设存在一个哈希表,它的大小为m,而最坏的情况即为表中各个桶下都挂上一个数据;

    实际上只要数据的个数_n与表的大小相同,即负载因子1时进行扩容;

    当然,扩容的情况也可根据需要来进行变化;

在上文当中关于闭散列哈希表的扩容中提到在进行扩容时不能直接在原地扩容;

相同,由于开散列与闭散列的插入规则采用的都是哈希中的除留余数法进行操作,所以相对的开散列的哈希表也需要像闭散列一样新开一块空间并将原来的数据以新的哈希规则进行插入以免破坏对应的映射关系;

在扩容时也可按照闭散列中的方式,扩容之后重新建立哈希表并对数据进行重新插入;

但当数据重新插入后原有的数据将会被释放,但本质上并不会释放链表中的元素数据,所以应该在哈希表中进行析构函数的写入;

  • 析构函数(供参考):

      ~HashTable() {
        Node* cur = nullptr;
        for (size_t i = 0; i < _hashtable.size(); ++i) {
          if (_hashtable[i]) {
            cur = _hashtable[i];
            Node* next = cur->_next;
            while (cur) {
              delete cur;
              cur = next;
            }
          }
        }
      }
    

    该方法的扩容不进行赘述,参照闭散列中的扩容逻辑即可;

而使用上述这种方法虽然可以简短对于扩容操作的代码,但实际上这样将原来的哈希表即表中的数据全部进行删除使得整体在扩容中的效率变慢且进行了冗余的操作(对数据节点重新进行构造);

在不使用该方法的前提可以使用其他的方式将其进行扩容;

最简单的方式即为构建一个新的vector容器遍历原来的vector容器将对应的节点以新的哈希规则插入至新的vector容器当中;

  • 代码(供参考);

      bool Insert(const std::pair<K, V> kv) {
        Hash to_int;//仿函数
    
        // 使用Find函数进行判断是否需要进行插入(需要预防除零错误)
    
        if (_n == _hashtable.size()) {
          // 判断负载因子是否为1 负载因子若是为1则进行扩容
        //   size_t newsize = _hashtable.size() == 0 ? 10 : _hashtable.size() * 2;
          size_t newsize = GetNextPrime(_hashtable.size());
          std::vector<Node*> newTable;
          newTable.resize(newsize);
    
          // for(Node *&cur : _hashtable) 遍历Node*指针数组
          for (auto& cur : _hashtable) {
            while (cur) {
              Node* next = cur->_next;
              size_t hashi = to_int(cur->_kv.first) % newTable.size();
              cur->_next = newTable[hashi];
              newTable[hashi] = cur;
              cur = next;
            }
          }
          _hashtable.swap(newTable);
        }
    
        // 正常插入
        Node* newnode = new Node(kv);
        size_t hashi = to_int(kv.first) % _hashtable.size();
        // std::cout << kv.first <<std::endl;
        newnode->_next = _hashtable[hashi];
        _hashtable[hashi] = newnode;
        ++_n;
        return true;
      }
    

    该种方式所实现的扩容在代码层面中要比上一方法复杂;

    但不可否认的是该方法实际上在效率当中要高于上一方法,因为该方法避免了同一节重复进行构造的冗余以及将原有节点进行释放的浪费操作;

    在该段代码中存在一个仿函数为Hash,该仿函数具体的作用为将类型转化为可以在哈希表中实现的类型,具体实现将在下文中提到;


🦠 哈希表扩容的优化

请添加图片描述

在上文的代码中存在一个函数为GetNextPrime();

这个函数的功能是哈希表中在扩容中的一个优化;

其本身并不复杂,即设置一个全部为素数的扩容指数,当需要扩容时则去该函数中取下一个素数作为需要扩容的新大小;

  • 代码(供参考):

      size_t GetNextPrime(size_t prime) {
        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};
        size_t i = 0;
        for (; i < __stl_num_primes; ++i) {
          if (__stl_prime_list[i] > prime) return __stl_prime_list[i];
        }
        return __stl_prime_list[i];
      }
    
  • 那么为什么需要采取该操作?或者说使用该种方法进行扩容的优势是什么?

    • 减少哈希冲突

      当哈希函数产生的地址分布不均时,会导致哈希冲突的概率增加,从而影响哈希表的性能;

      使用素数作为容量因子可以使得哈希值分布更加均匀。这是因为素数不能被除以其他较小的数(除了1它自身),这有助于减少在哈希表中的“分组”现象,从而减少冲突。

    • 优化哈希函数

      如果容量因子是一个素数,它可以帮助哈希函数更有效地分散键值,尤其是在键值本身具有一定的规律性时。

      如果键值倾向于在某些特定的数值范围内聚集,使用素数作为容量因子可以帮助打破这种规律性,使得哈希值更加分散。

    • 提高计算效率

      在某些情况下,使用素数作为容量因子可以简化哈希函数的计算,提高效率。例如,在一些模运算中,使用素数作为模可以减少计算量。

可以发现,上述代码的容量因子实际是在素数的二倍附近进行取值;


🎐 确保哈希表的泛型特性

请添加图片描述

这个泛型特性实际上不只针对开散列哈希表,这是大部分哈希表中都需要的;

尤其是在使用除留余数法时,需要将数据进行取模操作,而进行取模操作的只能是整型数据;

而若是需要存储其他类型的数据时,本身并不支持;

最简单的方式即为使用一个仿函数将数据转换为整型数据;

  • 代码(供参考):

    template <class K>
    struct HashFunc {
      size_t operator()(const K& key) {
        // std::cout << key << std::endl;
        return (size_t)key;
      }
    };
    

    使用该代码能够解决部分类型不匹配的问题;

那么为什么这里提到的是部分而不是所有;

在存储数据当中,可能遇到需要将字符串string类型数据进行存储;

而实际上string无法有效的转化为size_t整型数据;

所以需要对string数据在仿函数当中使其变为size_t类型;

最普遍的使用即为使用相加的操作将string字符串中的所有字符的ASCII码值进行相加;

那么使用该种方法时还会遇到另一个问题:

  • 若是字符串中的字符相同而字符顺序不同该如何进行比较?

    如该问题而言,若是使用普通的将字符串的ASCII码值进行相加的话将会出现顺序不同但其ASCII值相同;

    例:abcd,adbc,bcad,dcba等;

而解决方法也很简单,在各大语言的对应的哈希表实现中都引用了一个概念,即乘数因子;

使用乘数因子的方法可以很好的避免string类型中对于字符串中字符(或是总ASCII码之和)相同但顺序不同的数据的插入;

  • 即使用一个数作为乘数对当前遍历到的string中的字符的ASCII码值进行相乘;

    sum *= 乘数因子;sum += ch(ch为当前字符的ASCII值);

  • 那么这个乘数因子应该取什么值?

    实际上这个乘数因子取谁都行,大多数乘数因子所取值都为素数,最常见用来作为乘数因子的值实际上是31;

    具体原因31是一个小的素数,其能够快速计算(即左移5位后减去原值),且在实践中表现良好;

    Java中的 String.hashCode() 所使用的乘数因子即为31;

  • 代码(供参考):

    template <>
    struct HashFunc<std::string> {
      size_t operator()(const std::string& key) {
        size_t hash = 0;
        for (auto it : key) {
          hash += it;
          hash *= 31;
        }
        // std::cout << hash << std::endl;
        return hash;
      }
    };
    

实际上在对于string的处理可以使用模板中的特化进行处理;

编译器在处理时若是遇到该类型则走模板特化,否则走原仿函数即可("特化" 参考 『C++ - 模板』之模板进阶 中对于特化的理解;


🎐 开散列哈希表的查找

请添加图片描述

开散列哈希表的查找与闭散列哈希表的查找的思路相同,即在查找过程当中复用插入函数中的思路即可;

相比于闭散列哈希表而言,开散列哈希表的查找相对闭散列要简单,具体原因为开散列哈希表无需像闭散列哈希表那样再次进行线性探测;

对于开散列的哈希表的查找而言,只需要找到对应的哈希位置,并判断该位置是否存有指针:

  • 存在指针

    若是对应的哈希位置存在指针则要查找的数据可能存在该位置的链表当中,需要对链表进行遍历查找;

  • 不存在指针(为空)

    若是不存在指针(为空)时则表示该数据不存在;

当然上面两点的前提是你总体的哈希规则(映射关系)没有错乱;

  • 代码(供参考):

      Node* Find(const K& key) {
        Hash to_int;
    
        if (_hashtable.size() == 0) return nullptr;  // 防止除零错误
    
        size_t hashi = to_int(key) % _hashtable.size();
        Node* cur = _hashtable[hashi];
    
        while (cur) {
          if (cur->_kv.first == key) {
            return cur;
          }
          cur = cur->_next;
        }
        return nullptr;
      }
    

当然,当Find()接口被实现后应该在插入时调用该接口判断所插入的数据是否存在,若是存在则不进行插入;

接下来的Erase()删除接口也可按照插入的判断来判断数据是否存在,当然这个并不是特别必要,因为删除的时间复杂度与查找的时间复杂度的差距不大,若是数据存在则还需要再次进行遍历,且再删除接口时也可判断该条件(按照具体需求适合进行调用);

在查找时也需要判断当前vector容器的大小是否为0,从而避免出现除零错误的问题;


🎐 开散列哈希表的数据删除

请添加图片描述

开散列哈希表的数据删除与查找的思路类似,即判断数据是否存在,若是存在则删除,若是不存在则删除失败;

开散列哈希表的删除与闭散列哈希表的删除并不相同,闭散列哈希表的删除采用的是伪删除法进行删除;

而开散列哈希表的数据删除为确确实实的删除,将节点进行释放;

当然开散列哈希表的数据删除也该分为两种情况:

  • 删除数据的节点指针存在于链表当中

    当删除数据的节点指针存在链表当中时,只需要进行普通的链表删除即可;

  • 删除数据的节点指针存在于表(vector容器)中

    当删除数据的节点指针存在于表(vector容器)当中时,在删除之后需要将删除后的节点的下一个节点赋值给表中对应的位置;

  • 代码(供参考):

      bool Erase(const K& key) {
        Hash to_int;
        if (_hashtable.size() == 0) return false;  // 防止空的情况继续删除
    
        size_t hashi = to_int(key) % _hashtable.size();
    
        Node* cur = _hashtable[hashi];
        Node* prev = nullptr;
        while (cur) {
          if (cur->_kv.first == key) {
            if (prev) {
              prev->_next = cur->_next;
            } else {
              _hashtable[hashi] = cur->_next;
            }
            delete cur;
            return true;
          } else {
            prev = cur;
            cur = cur->_next;
          }
        }
        return false;
      }
    

🎐 开散列哈希表整体代码(供参考)

请添加图片描述

#include <iostream>
#include <string>
#include <vector>

template <class K, class V>
struct HashNode {
  // 哈希表的节点设置
  typedef HashNode<K, V> Node;
  Node* _next = nullptr;
  std::pair<K, V> _kv;

  HashNode(const std::pair<K, V> kv) : _kv(kv) {}
};

template <class K>
struct HashFunc {
  size_t operator()(const K& key) {
    // std::cout << key << std::endl;
    return (size_t)key;
  }
};

template <>
struct HashFunc<std::string> {
  size_t operator()(const std::string& key) {
    size_t hash = 0;
    for (auto it : key) {
      hash += it;
      hash *= 31;
    }
    // std::cout << hash << std::endl;
    return hash;
  }
};

template <class K, class V, class Hash = HashFunc<K>>
class HashTable {
 public:
  typedef HashNode<K, V> Node;

  ~HashTable() {
    Node* cur = nullptr;
    for (size_t i = 0; i < _hashtable.size(); ++i) {
      if (_hashtable[i]) {
        cur = _hashtable[i];
        Node* next = cur->_next;
        while (cur) {
          delete cur;
          cur = next;
        }
      }
    }
  }

  bool Insert(const std::pair<K, V> kv) {
    Hash to_int;

    // 使用Find函数进行判断是否需要进行插入(需要预防除零错误)
    if (Find(kv.first)) {
      // 找到该数据说明该数据存在不予继续插入
      return false;
    }

    if (_n == _hashtable.size()) {
      // 判断负载因子是否为1 负载因子若是为1则进行扩容
    //   size_t newsize = _hashtable.size() == 0 ? 10 : _hashtable.size() * 2;
      size_t newsize = GetNextPrime(_hashtable.size());
      std::vector<Node*> newTable;
      newTable.resize(newsize);

      // for(Node *&cur : _hashtable) 遍历Node*指针数组
      for (auto& cur : _hashtable) {
        while (cur) {
          Node* next = cur->_next;
          size_t hashi = to_int(cur->_kv.first) % newTable.size();
          cur->_next = newTable[hashi];
          newTable[hashi] = cur;
          cur = next;
        }
      }
      _hashtable.swap(newTable);
    }

    // 正常插入
    Node* newnode = new Node(kv);
    size_t hashi = to_int(kv.first) % _hashtable.size();
    // std::cout << kv.first <<std::endl;
    newnode->_next = _hashtable[hashi];
    _hashtable[hashi] = newnode;
    ++_n;
    return true;
  }

  Node* Find(const K& key) {
    Hash to_int;

    if (_hashtable.size() == 0) return nullptr;  // 防止除零错误

    size_t hashi = to_int(key) % _hashtable.size();
    Node* cur = _hashtable[hashi];

    while (cur) {
      if (cur->_kv.first == key) {
        return cur;
      }
      cur = cur->_next;
    }
    return nullptr;
  }

  bool Erase(const K& key) {
    Hash to_int;
    if (_hashtable.size() == 0) return false;  // 防止空的情况继续删除

    size_t hashi = to_int(key) % _hashtable.size();

    Node* cur = _hashtable[hashi];
    Node* prev = nullptr;
    while (cur) {
      if (cur->_kv.first == key) {
        if (prev) {
          prev->_next = cur->_next;
        } else {
          _hashtable[hashi] = cur->_next;
        }
        delete cur;
        return true;
      } else {
        prev = cur;
        cur = cur->_next;
      }
    }
    return false;
  }

  void Check() {
    // 检查函数 没有太重要的意义
    int i = 0;
    for (auto cur : _hashtable) {
      std::cout << "(" << i << ")"
                << " == ";
      if (cur) {
        while (cur) {
          std::cout << cur->_kv.first << " : " << cur->_kv.second << " || ";
          cur = cur->_next;
        }
        std::cout << std::endl;
      } else {
        std::cout << "nullptr" << std::endl;
      }
      ++i;
    }
  }

 protected:
  size_t GetNextPrime(size_t prime) {
    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};
    size_t i = 0;
    for (; i < __stl_num_primes; ++i) {
      if (__stl_prime_list[i] > prime) return __stl_prime_list[i];
    }
    return __stl_prime_list[i];
  }

 private:
  std::vector<Node*> _hashtable;  // 哈希表整体构造
  size_t _n = 0;                  // 负载因子
};

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1433357.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Linux系统 01】Vim工具

目录 一、Vim概述 1. 文件打开方式 2. 模式切换 二、命令模式 1. 移动与跳转 2. 复制与粘贴 3. 剪切与撤销 三、编辑模式 1. 插入 2. 替换 四、末行模式 1. 保存与退出 2. 查找与替换 3. 分屏显示 4. 命令执行 一、Vim概述 1. 文件打开方式 vim 文件路径&#…

jmeter设置关联

一、为什么要设置关联&#xff1f; http协议本身是无状态的&#xff0c;客户端只需要简单向服务器请求下载某些文件&#xff0c;无论是客户端还是服务端都不去记录彼此过去的行为&#xff0c;每一次请求之间都是独立的。如果jmeter需要设置跨线程组脚本&#xff0c;就必须设置…

【代码随想录-哈希表】有效的字母异位词

💝💝💝欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。 推荐:kwan 的首页,持续学习,不断总结,共同进步,活到老学到老导航 檀越剑指大厂系列:全面总结 jav…

flutter开发实战-ijkplayer视频播放器功能

flutter开发实战-ijkplayer视频播放器功能 使用better_player播放器进行播放视频时候&#xff0c;在Android上会出现解码失败的问题&#xff0c;better_player使用的是video_player&#xff0c;video_player很多视频无法解码。最终采用ijkplayer播放器插件&#xff0c;在flutt…

C# OMRON PLC FINS TCP协议简单测试

FINS(factory interface network service)通信协议是欧姆龙公司开发的用于工业自动化控制网络的指令&#xff0f;响应系统。运用 FINS指令可实现各种网络间的无缝通信&#xff0c;包括用于信息网络的 Etherne(以太网)&#xff0c;用于控制网络的Controller Link和SYSMAC LINK。…

前端框架学习 Vue(3)vue生命周期,钩子函数,工程化开发脚手架CLI,组件化开发,组件分类

Vue 生命周期 和生命周期的四个阶段 Vue生命周期:一个Vue实例从创建 到 销毁 的整个过程 生命周期四个阶段 :(1)创建 (2)挂载 (3)更新 (4)销毁 Vue生命周期函数(钩子函数) Vue生命周期过程中,会自动运行一些函数,被称为[生命周期钩子] ->让开发者可以在[特定阶段] 运行自…

[MFC] MFC消息机制的补充

之前写了[MFC] 消息映射机制的使用和原理浅析&#xff0c;还有些需要补充的&#xff0c;都记在这里。 MFC 消息的分类 MFC消息分为系统消息和自定义消息。 图片来源&#xff1a;C语言/C教程 大型源码案例分析&#xff1a;MFC消息系统的代码解析 易道云编程 系统消息分为窗口…

【SpringBoot】SpringBoot的web开发

&#x1f4dd;个人主页&#xff1a;五敷有你 &#x1f525;系列专栏&#xff1a;SpringBoot ⛺️稳重求进&#xff0c;晒太阳 Wbe开发 使用Springboot 1&#xff09;、创建SpringBoot应用&#xff0c;选中我们需要的模块&#xff1b; 2&#xff09;、SpringBoot已经默…

用友GRP-U8 listSelectDialogServlet SQL注入漏洞复现

0x01 产品简介 用友GRP-U8R10行政事业内控管理软件是用友公司专注于国家电子政务事业,基于云计算技术所推出的新一代产品,是我国行政事业财务领域最专业的政府财务管理软件。 0x02 漏洞概述 用友GRP-U8R10行政事业内控管理软件 listSelectDialogServlet 接口处存在SQL注入…

chisel RegInit/UInt/U

val reg RegInit(0.U(8.W)) //ok val reg RegInit(0.UInt(8.W)) //errU 使用在数字 . 后边50.U UInt 使用在IO(new Bundle val a Input(UInt(8.W)) 或者 def counter(max:UInt, a1:UInt) package emptyimport chisel3._ import chisel3.util._class MyCounter extends …

操作系统-【预备学习-1】(Linux 文件目录)

文章目录 相关知识目录结构进入目录补充查看目录创建文件删除文件创建文件夹删除文件夹文件和文件夹拷贝文件和文件夹移动/重命名 任务要求 相关知识 目录结构 Linux 文件系统是树形层次结构&#xff0c;具体如下图所示&#xff0c;最重要的是根目录&#xff08;/&#xff09…

06 - python操作xml

认识XML 与HTML很像&#xff0c;是一种将数据存储在标记之间的标记语言&#xff0c;用户可以自定义自己的标记。 XML文件可以表示称为&#xff1a;XML树。这个XML树从根元素开始&#xff0c;根元素进一步分支到子元素。XML文件的每个元素都是XML树的一个节点&#xff0c;没有…

服务器和云服务器哪个更安全?

随着云计算技术的不断发展&#xff0c;越来越多的企业开始选择使用云服务器来存储和处理数据。然而&#xff0c;对于一些企业来说&#xff0c;他们可能更倾向于使用传统的服务器。在这种情况下&#xff0c;安全性成为了一个重要的考虑因素。那么&#xff0c;服务器和云服务器哪…

arm 汇编积累

C语言函数与汇编对应关系 一、MOV 系列指令 1、指令格式 MOV{条件}{S} 目的寄存器&#xff0c;源操作数 2、含义解析&#xff1a; &#xff08;1&#xff09;&#xff1a;mov 指令传送数据 案例&#xff1a; MOV R0,R1 ; R0 R1; MOV PC,R14 ;PC R14; MOV R0,R…

[Angular 基础] - 数据绑定(databinding)

[Angular 基础] - 数据绑定(databinding) 上篇笔记&#xff0c;关于 Angular 的渲染过程及组件的创建&简单学习&#xff1a;[Angular 基础] - Angular 渲染过程 & 组件的创建 Angular 之中的 databinding 是一个相对而言更加复杂&#xff0c;以及我个人觉得相对而言比…

Java on Azure Tooling 2024年1月更新|Azure Key Vault 支持、示例项目创建支持及更多

作者&#xff1a;Jialuo Gan - Program Manager, Developer Division At Microsoft 排版&#xff1a;Alan Wang 大家好&#xff0c;欢迎来到 2024 年 Java on Azure 工具的首次更新。在本次更新中&#xff0c;我们将介绍对于 Azure Key Vault 支持、基于 Azure 示例项目的创建支…

C++ 调用lua 脚本

需求&#xff1a; 使用Qt/C 调用 lua 脚本 扩展原有功能。 步骤&#xff1a; 1&#xff0c;工程中引入 头文件&#xff0c;库文件。lua二进制下载地址&#xff08;Lua Binaries&#xff09; 2&#xff0c; 调用脚本内函数。 这里调用lua 脚本中的process函数&#xff0c;并…

如何让虚拟机拥有愉快网络环境,vmware,ubuntu,centos

博客原文 文章目录 前言拥有愉快网络环境步骤:测试网关连接 Ubuntu修改 http 与 sock 代理地址修改 /etc/resolv.conf配置 apt 使用代理测试连接 Centos设置代理地址修改 NetworkManager最后重启网卡&#xff1a;测试代理 前言 相信计算机专业的同学在学习 linux 时, 一定会被无…

Element UI+Spring Boot进行CRUD的实例

ElementUI安装与使用指南 前端代码&#xff1a;点击查看learnelementuispringboot项目源码 后端代码&#xff1a;点击查看 LearnElementUiAndSpringBoot 一、前端配置 安装axios Gitee的axios介绍与使用 GitHub的axios介绍与使用 方式一&#xff1a;使用npm安装 $ npm in…

2024年最新幻兽帕鲁服务器搭建教程

玩转幻兽帕鲁服务器&#xff0c;阿里云推出新手0基础一键部署幻兽帕鲁服务器教程&#xff0c;傻瓜式一键部署&#xff0c;3分钟即可成功创建一台Palworld专属服务器&#xff0c;成本仅需26元&#xff0c;阿里云服务器网aliyunfuwuqi.com分享2024年新版基于阿里云搭建幻兽帕鲁服…