数据结构——哈希表

目录

什么是哈希?

哈希表

直接映射法

除留余数法

哈希冲突

线性探测

开散列——开放定值哈希表

插入元素

 查找元素

删除元素

哈希函数

缺点

闭散列——拉链法/哈希桶

插入元素

查找元素

删除元素

析构函数


什么是哈希?


在顺序表、链表、二叉树等数据结构中,如果要找一个值,要遍历一遍才可以找到,因此,这些数据结构查找的效率都是O(N)的。
我们之所以要遍历,原因就在于不知道这个值在哪个位置放着,因此我们要遍历,找到该值所在的位置,如果我们知道这个值在哪里放着,那我们直接去访问那个位置,不就以O(1)的时间复杂度找到了。

那我们怎么知道一个值在哪个位置放着?这就要求我们在插入数据的时候要按照一定的规则,根据这个规则,把数据插入到该位置,然后再查找的时候,我们在根据这个规则,计算出该数的插入位置,这样查找就可以做到近似O(1)的时间复杂度。

那么什么是哈希?哈希就是一种按照特定的规则来建立元素和下标映射关系的数据结构。

哈希表

提到元素和下标建立映射关系,数组不就是,数组的下标和元素天然之间有映射关系呀,比如下图的元素9,对应的下标就是0。

直接映射法

但是上面这样样是不行的,因为这显然就是随便插入的,我们要查找一个数我怎么知道它在哪个下标,因此,我们可以这样,我们可以直接用元素映射到对应的下标,比如说3就映射到3号下标,1就映射到1号下标 。比如我们在做写与日期相关代码是就可以这样来做,1号下标映射1月的日期,2号下标映射到2月的日期,我们就可以快速根据月份找到天数。

但是直接映射会不会有什么问题呢?会存在空间浪费的问题,我要存的数不一定是连续的,我可能存一个1,再存一个10000,我就不存了,为了存两个数就开一个大小为10000的数组,是不是有点得不偿失,那怎么解决呢?我们可以采用除留余数的方法。

除留余数法

我们可以不采用直接映射,我们在存数的时候,可以先对该数取模,那么取哪一个模数呢?比如说这里就对10取模,现在来了一个要插入的数19,我们可以对该数取模得到9,然后把19放到下标为9的位置,来了一个数10000,对10取模之后得到0,把该数放到下标0的位置,这样就可以保证不会浪费大量的空间,因为不管任何一个数,对10取模之后一定在0~9范围内。

哈希冲突

那这样会不会有新问题呢?比如现在来了一个数14,对10取模映射到下标4,又来了一个数34,同样对10取模映射到了下标4,出现冲突了,这个冲突我们称为哈希冲突,不同的值映射到了同一个地址,此时我们可以采取线性探测的方式来解决。

线性探测

比如说现在有6个数,14,13,29,88,10,25,这6个数对10取模后都没有发生冲突,现在来了98,和88冲突了,那就往后走,看下标9,下标9也被人占了,在继续走,走到了10,超出了数组范围,对数组大小10取模后重新回到0,发现0也被人占了,好继续往后走,走到下标11,发现下标1没被占,98就放在下标1的位置,如果新插入一个数字1,以此类推,往后走,走到第一个没有被占的位置就是该数所要放的下标。

查找

 上面所说的是插入,那我要找一个值怎么找呢?

以上图查找98为例,先计算出它所在的下标,98 % 10 = 8,然后看下标8的元素值是不是98,如果不是往后走,看下标9,下标9有元素一看值不是98继续往后走,超出范围对数组大小取模回到数组头部,然后看0下标,值不是98继续往后,1下标,发现1下标的值是98, 就找到了。

这是找到的情况,那么没找到的情况呢,比如要找93,先计算出映射的下标3,发现3被占了,往后走,4、5位置也被占了走到6,发现6为空,说明要找的数不存在,如果找到为空的位置就说明找不到。这也很容易理解,因为根据插入的规则,一个数要么在计算出的位置,要么在该位置的后面,如果你走到空了,说明必定不存在。

删除

怎么删一个数呢?先找到这个数所所在的下标,找到之后,把这个数删除吗?怎么算是删除呢?难道把这个位置的值改成0吗?好像给任何数都不合适,因为可能插入任何一个元素,所以这个地方我们应该给一个标记位,我们定义一个枚举类型,里面有三个值,EXISTS(存在),DELETE(删除),EMPTY(空),我们直接把该位置的状态修改为DELETE即可。

下面我们来实现一个这个哈希表

开散列——开放定值哈希表

先说一下这个哈希表里面存什么?我们可以存一个pair,一个键值对,后续可以通过k来找到val,上面说过,需要每一个位置的状态,因此需要一个枚举变量,里面的类型就是空、存在、删除。

由于需要记录状态和插入的值,因此可以用一个结构体封装一下要存的数据,里面是要存的键值对和状态,状态可以给个缺省参数EMPTY,我们也不知道要存什么样类型的数据,就直接用模版了

哈希表里面的成员变量就是一个vector,这里vector是有size的为啥我们还要自己写一个成员变量_n出来呢?这个成员变量记录的是哈希表中有效元素的个数,我们删除数据是更改状态,因此,vector的size是不会减小的。

#include <iostream>
#include <vector>
using namespace std;

enum STATE{
    EMPTY,
    EXISTS,
    DELETE
};

template <class K, class V>
struct HashData
{
    STATE _state = EMPTY;
    pair<K, V>_kv;
};

template <class K, class V>
class HashTable
{
private:
    vector<HashData<K,V>> _table; // 哈希表
    int _n; // 存储哈希表有效元素个数
};

插入元素

要插入的元素是一个pair,因此我们可以根据key的值也就是pair.first计算下标,但是这里问题来了,我们是根据哈希表的capacity计算下标还是size计算下标?是要根据size的,因为可能当前capacity空间是20,但是size元素存了10,也就意味着10后面的下标你是不能访问的,此时用capacity计算映射的下标可能会出现10以后的下标,顺序表必须要一个位置一个位置存储,你不能下一个位置还没存元素就跳到后面的位置存储元素,因此要根据size计算映射的下标,但是没关系,我们初始化和扩容的时候每次都用resize即可,把capacity和size搞成一样大的即可。

计算好下标之后先判断该下标的状态是否存在,如果存在继续往后走,往后走的过程中要对数组的大小取模,防止越界,走到第一个空的位置,把该元素放到该位置即可,放到该位置之后有效元素个数+1,但是刚开始数组是空的,因此构造函数的时候要给数组开点空间。

构造函数

HashTable() :_n(0) 
{
// 提前开好空间
  _table.resize(10); // 保证 capacity和size大小一致		
}

数组刚开始大小是10,随着数据越来越多,容量也就越来越小,因此必然会出现数组满了的情况,此时怎么办呢,我们要在数组没满之前就扩容,那么什么时候扩容呢?当插入的数据个数超过数组大小的70%的时候就扩容,我们扩容不能太满在扩容,太满扩容会影响效率,太空扩容会浪费空间,如果当元素只有一半的时候就扩容,就意味着永远都有50%的空间是浪费的,这有点不太合适。那么问题来了,怎么扩容呢?简单,直接调用resize给数组扩容2倍。但是扩容之后,数组的size和capacity都会发生变化,这意味这什么?这意味着数据要重新映射,因为可能之前空间是10,来了19,映射到下标9,此时空间是20,查找的时候映射的位置是19,完蛋,19下标啥都没有,返回结果找不到,但实际上是有的,这时的问题是下标和映射关系和之前发生了变化,此时必须重新映射。

那么问题来了,怎么重新映射?我们可以另外搞一个临时的哈希表,我们给该哈希表的vector数组扩容2倍的容量,然后调用插入函数,把当前哈希表的值重新插入到该哈希表,此时必然不会走扩容的逻辑,而只会走插入的逻辑,然后把两个哈希表的vector数组进行一个交换。插入完毕之后,出了作用域临时哈希表会被销毁。

哈希表一般存的值是唯一的,也就是说如同你插入一个已经存在的数,应该直接返回,false插入失败,所以插入以前要先找一下是否存在。

bool Insert(const pair<K, V>& kv)
{
    // 如果存在直接返回失败
     if(Find(kv.first) != nullptr)
     {
            return false;
     }
     // 要考虑扩容的问题,不能一直插入,当哈希表的元素到达数组容量的70%时就扩容
     // 这个地方如果直接除的话是一个小数,取整后是0,因此可以给元素个数*10,最后>=7即可
     if(_n * 10 / _table.size() >= 7)
     {
       // 2倍扩容
       int newCapacity = _table.size() * 2;
       // 这个地方直接扩容还不行,直接扩容后下标的映射关系可能会发生变化,因此要重新映射
       // 我们搞一个临时的的哈希表 tmp
       HashTable<K, V> tmp;
       //给tmp哈希表开newCapacity个空间
       tmp._table.resize(newCapacity);
            
       // 把当前哈希表的元素插入到tmp
       for(int i = 0; i < _table.size(); i++)
       {
          if(_table[i]._state == EXISTS)
          {
               tmp.Insert(_table[i]._kv);
          }
       }
            // 交换两个哈希表
            _table.swap(tmp._table);
        }
        // 1. 用k值对哈希表的大小取模,计算出映射下标
        int hashi = kv.first % _table.size();
        while(_table[hashi]._state == EXISTS){
            hashi++;
            hashi %= _table.size();
        }
        _table[hashi]._kv = kv;
        _table[hashi]._state = EXISTS;
        _n++;//元素个数+1
    }

 查找元素

根据key来查找,先计算出映射的下标,然后判断下标的状态,状态不是空并且不是删除并且值与查找的相同直接返回该元素的地址,这个地址是一个pair的地址,key的类型要是const的,否则对方拿到这个元素后可以把key修改了,此时映射关系就不对了,如果不是空,是删除,继续往后走,如果走到空,直接返回,说明找不到。


     pair<const K, V>* Find(const K& k)
     {
        // 1. 计算出映射下标
        int hashi = k % _table.size();
        // 2.判断状态
        while(_table[hashi]._state != EMPTY)
        {
            // 说明找到了
            if(_table[hashi]._state != DELETE && _table[hashi]._kv.first == k)
            {
                return (pair<const K, V>*)&_table[hashi]._kv;
            }
            hashi++;
            hashi %= _table.size();
        }
        // 为空说明找不到
        return nullptr;
     }

删除元素

走一遍查找的逻辑即可,找到之后把状态改为DELETE,有效数据个数-1,其实我们如果要复用查找逻辑也是可以的,修改一下查找的返回值即可,改成HashDate,之所以不返回这个是因为状态可能被修改,所以就返回了pair。

     bool Erase(const K& k)
     {
         // 1. 计算出映射下标  
          int hashi = k % _table.size();
        // 2.判断状态
        while(_table[hashi]._state != EMPTY)
        {
            // 说明找到了
            if(_table[hashi]._state != DELETE && _table[hashi]._kv.first == k)
            {
                // 找到修改状态
                _table[hashi]._state = DELETE;
                --_n; //有效个数减1
            }
            hashi++;
            hashi %= _table.size();
        }
        // 为空说明找不到,删除失败
        return false;
     }

哈希函数

现在有一个问题来了,现在如果插入浮点数怎么办?如果插入字符串怎么办?我们计算映射下标的时候是直接%数组大小,浮点数还好说,我可以转成整数,但如果是字符串,一个string类型,我怎么%数组大小?没法计算,因此就有一些大佬写了一些字符串哈希函数。

各种字符串Hash函数 - clq - 博客园

我们可以使用下面这个字符串哈希函数。就是读取这个字符串的每一个字符,然后加到一个变量上面,字符也是ASCII码值,也是一个数,因此可以加,在加之前,对这个变量 * 131,最终这个变量的值就是计算出来的下标,这个字符串哈希函数是评价比较高的。

template<class T>  
size_t BKDRHash(const T *str)  
{  
    register size_t hash = 0;  
    while (size_t ch = (size_t)*str++)  
    {         
        hash = hash * 131 + ch;   // 也可以乘以31、131、1313、13131、131313..  
        // 有人说将乘法分解为位运算及加减法可以提高效率,如将上式表达为:hash = hash << 7 + hash << 1 + hash + ch;  
        // 但其实在Intel平台上,CPU内部对二者的处理效率都是差不多的,  
        // 我分别进行了100亿次的上述两种运算,发现二者时间差距基本为0(如果是Debug版,分解成位运算后的耗时还要高1/3);  
        // 在ARM这类RISC系统上没有测试过,由于ARM内部使用Booth's Algorithm来模拟32位整数乘法运算,它的效率与乘数有关:  
        // 当乘数8-31位都为1或0时,需要1个时钟周期  
        // 当乘数16-31位都为1或0时,需要2个时钟周期  
        // 当乘数24-31位都为1或0时,需要3个时钟周期  
        // 否则,需要4个时钟周期  
        // 因此,虽然我没有实际测试,但是我依然认为二者效率上差别不大          
    }  
    return hash;  
}  

那么在我们的代码中,我们可以写一个类,类里面重载一下括号运算符,用来当默认的哈希函数,也就是计算整形浮点型的哈希函数。如果是整形相当于没变,直接返回自己,如果是浮点型把它转成整形。然后我们在哈希表中加入一个模版参数,计算哈希函数的时候封装一层就行。

那么字符串哈希函数呢?这个时候直接进行一个类的特化,此时,我们直接给哈希函数的类模版传个默认值DefaultHash即可,k的类型是int、float就调用整形、浮点型的哈希函数,如果是stirng类型直接调用字符串哈希函数。

template<class T>
struct DefaultHash
{
    size_t operator()(const T& val){
        return (size_t)val;
    }
};

template<>
struct DefaultHash<string>
{
    size_t operator()(const string& str)
    {
        int ret = 0;
        for (auto e : str)
        {
            ret = ret * 131 + e;
        }
        return ret;
    }
};
template <class K, class V, class HashFunc = DefaultHash<K>>
class HashTable
{
public:
    HashTable() :_n(0) {
        // 提前开好空间
        _table.resize(10);
    }
    // 插入
    bool Insert(const pair<K, V>& kv) {
        // 如果存在直接返回失败
        if (Find(kv.first) != nullptr) {
            return false;
        }
        // 要考虑扩容的问题,不能一直插入,当哈希表的元素到达哈希表大小的70%时就扩容
        // 这个地方如果直接除的话是一个小数,取整后是0,因此可以给元素个数*10,最后>=7即可
        if (_n * 10 / _table.size() >= 7) {
            // 2倍扩容
            int newCapacity = _table.size() * 2;
            // 这个地方直接扩容还不行,直接扩容后下标的映射关系可能会发生变化,因此要重新映射
            // 我们搞一个临时的的哈希表 tmp
            HashTable<K, V> tmp;
            //给tmp哈希表开newCapacity个空间
            tmp._table.resize(newCapacity);
            // 把当前哈希表的元素插入到新哈希表
            for (int i = 0; i < _table.size(); i++) {
                if (_table[i]._state == EXISTS) {
                    tmp.Insert(_table[i]._kv);
                }
            }
            // 交换两个哈希表
            _table.swap(tmp._table);
        }
        // 1. 用k值对哈希表的大小取模,计算出映射下标
        HashFunc hf;
        int hashi = hf(kv.first) % _table.size();
        while (_table[hashi]._state == EXISTS) {
            hashi++;
            hashi %= _table.size();
        }
        _table[hashi]._kv = kv;
        _table[hashi]._state = EXISTS;
        _n++;//元素个数+1

        return true;
    }

    pair<const K, V>* Find(const K& k)
    {
        // 1. 计算出映射下标
        HashFunc hf;
        int hashi = hf(k) % _table.size();
        // 2.判断状态
        while (_table[hashi]._state != EMPTY)
        {
            // 说明找到了
            if (_table[hashi]._state != DELETE && _table[hashi]._kv.first == k)
            {
                return (pair<const K, V>*) & _table[hashi]._kv;
            }
            hashi++;
            hashi %= _table.size();
        }
        // 为空说明找不到
        return nullptr;
    }

    bool Erase(const K& k)
    {
        HashFunc hf;
        // 1. 计算出映射下标  
        int hashi = hf(k) % _table.size();
        // 2.判断状态
        while (_table[hashi]._state != EMPTY)
        {
            // 说明找到了
            if (_table[hashi]._state != DELETE && _table[hashi]._kv.first == k)
            {
                // 找到修改状态
                _table[hashi]._state = DELETE;
                --_n; //有效个数减1
            }
            hashi++;
            hashi %= _table.size();
        }
        // 为空说明找不到,删除失败
        return false;
    }

    vector<HashData<K,V>> _table; // 哈希表
    int _n; // 存储哈希表有效元素个数
};

当出现大量冲突的时候会出现踩踏的现象,因为冲突之后会往后找位置,不就意味着你占了别人的位置?如果出现大量冲突,可能有些值的查找插入效率会降低到O(N),要遍历一遍才能找到,会导致效率不太行,因此,还有一种实现哈希的方法,就是下面要介绍的。

闭散列——拉链法/哈希桶

此时我们底层还是一个vector数组,但是,此时数组里面存的是一个一个的链表,当出现元素冲突,我们直接插入到对应的链表里就可以。

 

那我们来实现一下。

哈希函数不用说,哈希表里面的成员就是有效元素个数和数组,数组里面的一个个的节点,这个地方不太推荐使用vector<list>,原因在于,要进行的链表操作很简单,而且如果用list有些操作会很麻烦。因此我们直接自定义一个结构体就可以,因为节点是要一个一个new出来的,因此顺手写一个构造函数初始化。

template<class T>
struct DefaultHash
{
    size_t operator()(const T& val) {
        return (size_t)val;
    }
};

template<>
struct DefaultHash<string>
{
    size_t operator()(const string& str)
    {
        int ret = 0;
        for (auto e : str)
        {
            ret = ret * 131 + e;
        }
        return ret;
    }
};

template <class K, class V>
struct HashNode
{
    HashNode(const pair<K, V>& kv):_kv(kv),_next(nullptr){}
    pair<K, V> _kv;
    HashNode<K,V>* _next;
};

template <class K, class V, class HashFunc = DefaultHash<K>>
class HashBucket
{
public:
     typedef HashNode<K, v> Node;
private:
    vector<Node*> _table; // 哈希表
    int _n; // 哈希表的有效元素
};

插入元素

构造函数,直接构造先开10个空间,节点都初始化为NULL指针即可。

HashBucket():_n(0)
{
  _table.resize(10, nullptr);
}

同样,如果元素已经存在,就别插入了,因此插入前先用Find函数找一下是否存在。

插入元素,同样插入一个pair,先通过哈希函数和除留余数法计算出映射的下标,然后直接头插到对应下标的链表就可以,这里因为我们也不知道链表里面的那个元素被查找,因此没必要尾插,而且如果尾插找尾还挺麻烦,要不是遍历要么加一个前驱节点,没必要,因此直接头插就行。这里同样有扩容的问题,不能只插入不扩容,这里的平衡因子可以给1,因为冲突在链表内部就会消化掉,不会占用其它的空间,问题是我们怎么扩容,这个地方我们是不是可以如同上面开散列哪里一样,创建一个临时的哈希表,给临时链表开2倍的空间,然后把当前哈希表里面的节点复用Insert函数插入到临时的链表里,可以是可以,但是有个问题,问题是插入的时候节点是new出来的,我们原先不new就节点吗?我们何不直接用原先的节点,我们不用创建一个哈希表,只用创建一个vector<Node*>数组,开2倍空间,然后把原来的节点一个个重新计算映射下标头插到这个数组里,然后把数组一交换,不就可以了。

    // 插入
    bool Insert(const pair<K, V>& kv)
    {   
        HashFunc hf;
       // 存在就别找了
       if(Find(kv.first) != nullptr){
            return false;
        }
        // 扩容 此时负载因子可以到1的时候在扩容,因为冲突是在每个链表内部解决的
        if(_n == _table.size())
        {
            // 2倍扩容
            int newSize = _table.size() * 2;
            // 创建新表
            vector<Node*> newTable(newSize, nullptr);
            // 遍历旧表,把旧表的节点头插到新表中
            for(int i = 0; i < _table.size(); i++)
            {
                // 顺手牵羊
                Node* cur = _table[i];
                while(cur)W
                {
                    Node* next = cur->_next;
                    // 头插到新链表
                    // 计算出新的下标
                    int hashi = hf(cur->_kv.first) % newSize;
                    cur->_next = newTable[hashi];
                    newTable[hashi] = cur;
                    cur = next;
                }
            }
            // 交换两个表
            _table.swap(newTable);
        }
        // 1. 计算出映射下标
        
        int hashi = hf(kv.first) % _table.size();
        // 2. 头插到对应的链表中
        Node* node = new Node(kv);
        node->_next = _table[hashi];
        _table[hashi] = node;
        _n++;
        return true;
    }

查找元素

计算出在哪一个链表里,然后遍历链表即可,如果是nullptr说明找不到。同样通过key值来查找。


    Node* Find(const K& k)
    {
        // 1. 计算出映射下标
        HashFunc hf;
        int hashi = hf(k) % _table.size();
        // 遍历链表
        Node* cur = _table[hashi];
        if(cur == nullptr) return nullptr;
        while(cur)
        {
            if(cur->_kv.first == k)
            {
                return cur;
            }
            cur = cur->_next;
        }
    }

删除元素

链表删除就复用不了查找的逻辑了,因为链表删除要更改链表的连接关系,先计算下标,找到在哪一个链表下面,然后遍历链表,遍历链表的时候要保持一下前一个节点,方便更改链接关系。如果是头查,那么前一个节点必然是nullptr,直接把_table[hashi]改为空,否则更改前后连接关系即可。


    bool Erase(const K& k)
    {
         // 1. 计算出映射下标
        HashFunc hf;
        int hashi = hf(k) % _table.size();
        // 遍历链表
        Node* prev = nullptr;
        Node* cur = _table[hashi];
        if(cur == nullptr) return false;
        while(cur)
        {
            if(cur->_kv.first == k)
            {
                if(prev == nullptr)
                {
                    // 说明是链表只有一个节点,头删
                    _table[hashi] = cur = nullptr;
                }
                else
                {
                    prev->_next = cur;
                }
                delete cur;
                return true;
            }
            prev = cur;
            cur = cur->_next;
        }
        return false;
    }

析构函数

节点是一个一个new出来的,因此要写一个析构函数。遍历数组,然后遍历链表,删除就行。

	~HashBucket()
		{
			for (int i = 0; i < _table.size(); ++i)
			{
				Node* cur = _table[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				_table[i] = nullptr;
			}
		}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值