【数据结构】哈希Hash

目录

一、哈希的概念

(一)基本概念

(二)哈希函数

1.直接定制法

2.除留余数法

(三)哈希冲突

1.闭散列(开放定址法)

(1)线性探测

(2)二次探测

2.开散列(拉链法)

二、代码实现

(一)线性探测

1.插入

2.查找

3.删除

(二)哈希桶

​​​​​​​1.迭代器中的重载++

2.哈希表中的构造/析构函数

3.哈希表中迭代器的实现

(1)begin

(2)end

4.将key转化成能取模的数 

5.插入

6.查找

7.删除

8.总结


在哈希之前的搜索:例如顺序结构以及平衡树中,查找一个元素时,必须要经过关键码的多次比

较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即o(logN),搜索的效率取决于搜索过

程中元素的比较次数。平衡树相对于顺序结构来说效率已经有了很大的提升,但是现在想要一种

更加高效的搜索:可以不经过任何比较,一次直接从表中得到要搜索的元素。这就是哈希,下面

我们来介绍与哈希有关的概念,并且代码实现。

一、哈希的概念

(一)基本概念

1.哈希是一种映射关系:通过一个函数计算出一个元素要存储的位置

2.哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(散列表)

(二)哈希函数

这部分将介绍哈希函数的选择:主要介绍两种最常用的①直接定制法 ②除留余数法

当然,哈希函数不止这两种,还有:平方取中法、折叠法、随机数法、数学分析法等等

1.直接定制法

优点:没有哈希冲突,每个值都映射了一个唯一位置

缺点:如果数据分布不均匀,那么我们还给每个值映射一个位置,可能会存在巨大的空间浪费

2.除留余数法

优点:不再是给每个值映射一个位置,而是在限定大小的空间中将我们的值映射进去。

这种方式避免了出现巨大的空间浪费。除留余数法的哈希函数:index = key % 空间大小。

缺点:不同的值可能会映射到相同位置上去,导致哈希沖突。哈希冲突越多效率就越低。

(三)哈希冲突

这部分将介绍哈希冲突:不同的值映射到相同位置,这就产生了哈希冲突。

解决哈希冲突的方法有很多,两种常见的方法是:闭散列和开散列,我们主要介绍这两种。

1.闭散列(开放定址法)

当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存

放到冲突位置中的“下一个”空位置中去。我们只要能找到“下一个”空位置就可以解决哈希冲突,

所以现在的问题就是去找“下一个”空位置,介绍两种方法:①线性探测 ②二次探测

(1)线性探测

线性探测:挨着往后找,直到找到空位置。

线性探测优点:代码实现非常简单。

线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,导致搜索效率

降低。如何缓解呢?这就引出了二次探测:线性探测找空位置的方式就是挨着往后逐个去找,而

二次探测是跳跃着找,因此二次探测为了避免该问题,一定程度上解决了数据“堆积”的问题。

(2)二次探测

二次探测:按i^2跳跃着往后找,直到找到空位置。

使用二次探测的好处就是一定程度上解决了数据“堆积”的问题,但这还不是最好的方案,这里就

又引出了拉链法解决哈希冲突,这是一个更优的方案。

2.开散列(拉链法)

首先利用哈希函数计算数据要存放的地址,具有相同地址的数据归于同一子集合,每一个子集合

称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。


引入哈希桶:当我产生哈希冲突了,我自力更生解决,不占用别人的位置,我自己挂起来。

二、代码实现

通过上面的讲解,我相信大家已经对哈希有了正确的认识。现在我们来实现代码,但是我首先要

说明一点:我不会很详细的去讲解这部分代码是如何实现的,大家看代码上的注释一定可以看懂

细节是如何实现的。我把重点放在实现带迭代器的哈希桶上,重点学习他的代码结构。

(一)线性探测

哈希函数选择除留余数法,解决哈希冲突的方法为闭散列中的线性探测。

线性探测法代码的大框架

//开放定制法
//用于标记哈希表每个位置的状态
enum State
{
    EMPTY,
    EXITS,
    DELETE,
};

template<class T>
struct HashData
{
    T _data;
    State _state;
};

//unordered_set<K> -> HashTable<K, K>
//unordered_map<K, V> -> HashTable<K, pair<K, V>>
template<class K, class T, class KeyOfValue>
class HashTable
{
public:
    HashTable()
        :_num(0)
    {}
    
    bool Insert(const T& data);//插入
    HashData<T>* Find(const K& key);//查找
    bool Erase(const K& key);//删除
    
private:
    vector<HashData<T>> _table;
    size_t _num;//哈希表中存了几个有效数据
};

解读HashTable的模版参数: 

①class K:插入元素的first。key模型,就是key的类型;key/value模型,还是key的类型。

②class T:插入的元素。key模型,就是key的类型;key/value模型,就是pair<key, value>类型

③class KeyOfValue:仿函数,作用是取出key。把键值对中的key取出来。

1.插入

bool Insert(const T& data);

bool Insert(const T& data)
{
    KeyOfValue kofv;
    //负载因子 =表中数据/表的大小 衡量哈希表满的程度
    //表越接近满,插入数据越容易冲突,冲突越多,效率越低
    //哈希表并不是满了才增容,在开放定址法中,一般负载因子到了0.7左右就开始增容
    //负载因子越小,冲突概率越低,整体效率越高,但是负载因子越小,浪费的空问越大,所以负载因子一般取一个折中值
    if(_table.size() == 0 || _num * 10 / _table.size() >= 7)
    {
        //增容
        //1.开2倍大小的新表
        vector<HashData<T>> newtable;
        size_t newsize = (_table.size() == 0) ? 10 : _table.size() * 2;
        newtable.resize(newsize);
        //2.遍历旧表的数据,重新计算在新表中位置
        for(size_t i=0; i<_table.size(); i++)
        {
            if(_table[i]._state == EXITS)
            {
                //计算在新表中的位置
                size_t newindex = kofv(_table[i]._data) % newtable.size();
                //处理哈希冲突
                while(newtable[newindex]._state == EXITS)
                {
                    newindex++;
                    //当下标走到哈希表的结尾,就把下标绕回来
                    if(newindex == newtable.size())
                        newindex = 0;
                }
                //将元素放进新表
                newtable[newindex] = _table[i];
            }
        }
        //3.释放旧表:现在已经将把旧表复制到新表中了,所以需要释放旧表
        _table.swap(newtable);
    }
    
    //计算data中的key在表中映射的位置
    size_t index = kofv(data) % _table.size();
    //解决哈希冲突:利用线性探测法
    while(_table[index]._state == EXITS)
    {
        //不可以插入重复的元素
        if(kofv(_table[index]._data) == kofv(data))
            return false;
        //线性探测:挨着往后走,所以是++
        index++;
        //当下标走到哈希表的结尾,就把下标绕回来
        if(index == _table.size())
            index = 0;
    }
    
    //插入数据
    _table[index]._data = data;
    _table[index]._state = EXITS;
    _num++;
    return true;
}

插入的实现大体上分成如下四步:

        ①通过负载因子来判断是否需要对哈希表进行扩容。

        ②除留余数法计算出映射到的位置。

        ③解决哈希冲突。

        ④插入数据。

2.查找

HashData<T>* Find(const K& key);(结合代码中的注释,一定可以看懂的!😁)

HashData<T>* Find(const K& key)
{
    KeyOfValue kofv;
    //计算data中的key在表中映射的位置
    size_t index = key % _table.size();
    //如果计算出的位置不是EMPTY
    while(_table[index]._state != EMPTY)
    {
        //如果index的值是要查找的值
        if(kofv(_table[index]._data) == key)
        {
            //如果这个位置是EXITS,就返回这个位置的地址
            if(_table[index]._state == EXITS)
                return &_table[index];
            //如果这个位置是DELETE,直接返回空
            else if(_table[index]._state == DELETE)
                return nullptr;
        }
        //如果index的值不是要查找的值,index++
        index++;
        //当下标走到哈希表的结尾,就把下标绕回来
        if(index == _table.size())
            index = 0;
    }
    //如果计算出的位置是EMPTY,直接返回空
    return nullptr;
}

3.删除

bool Erase(const K& key);(结合代码中的注释,一定可以看懂的!😁)

bool Erase(const K& key)
{
    HashData<T>* ret = Find(key);
    //找到了,直接删除
    if(ret)
    {
        ret->_state = DELETE;
        _num--;
        return true;
    }
    //没找到,返回false
    else
        return false;
}

(二)哈希桶

哈希函数选择除留余数法,解决哈希冲突的方法为开散列中的拉链法(哈希桶)。

哈希桶代码的大框架

//哈希桶
//节点定义
template<class T>
struct HashNode
{
    HashNode(const T& data)
        :_data(data)
        ,_next(nullptr)
    {}
    
    T _data;
    HashNode<T>* _next;
};

//前置声明:因为这里需要提前用到哈希表用来实现++找到下一个桶
template<class K, class T, class KeyofT, class Hash>
class HashTable;

//迭代器
//解释为什么要加上class Hash,因为HashFunc需要使用
//HashFunc作用:将不能取模的数据转化成可以可以取模的整数
template<class K, class T, class KeyOfT, class Hash>
struct __HashTableIterator
{
    //复杂类型重命名
    typedef __HashTableIterator<K, T, KeyOfT, Hash> Self;
    typedef HashTable<K, T, KeyOfT, Hash> HT;
    typedef HashNode<T> Node;
    
    //成员变量
    Node* _node;//指向节点的指针
    HT* _pht;//指向哈希表的指针
    
    //成员函数
    __HashTableIterator(Node* node, HT* pht)
        :_node(node)
        ,_pht(pht)
    {}
    
    T& operator*(){return _node->_data;}
    T* operator->(){return &(_node->_data);}

    Self& operator++();//后面单独讲解
    bool operator!=(const Self& s){return _node != s._node;}
};

//哈希表
template<class K, class T, class KeyofT, class Hash>
class HashTable
{
    //声明迭代器是哈希表的友元,在迭代器里面可以访问哈希表的私有成员
    friend struct __HashTableIterator<K, T, KeyofT, Hash>;
public:
    typedef HashNode<T> Node;
    typedef __HashTableIterator<K, T, KeyofT, Hash> iterator;
public:
    //构造函数与析构函数
    HashTable()
        :_num(0)
    {}
    
    ~HashTable(){Clear();}
    void Clear();
    
    //迭代器
    iterator begin();
    iterator end(){return iterator(nullptr, this);}
    
    //研究表明:哈希表的大小是一个素数的时候,发生哈希冲突的概率会降低
    size_t GetNextPrime(size_t prime);
    //将key转化成能取模的数
    size_t HashFunc(const K& key);

    pair<iterator, bool> Insert(const T& data);
    Node* Find(const K& key);
    bool Erase(const K& key);
    
private:
    vector<Node*> _table;
    size_t _num;//记录表中数据个数
};

解读__HashTableIterator/HashTable的模版参数: 

①class K:插入元素的first。key模型,就是key的类型;key/value模型,还是key的类型。

②class T:插入的元素。key模型,就是key的类型;key/value模型,就是pair<key, value>类型

③class KeyOfT:仿函数,作用是取出key。把键值对中的key取出来。

④class Hash:仿函数,作用是将不能取模的数据转化成可以可以取模的整数。

​​​​​​​1.迭代器中的重载++

Self& operator++()
{
    //如果当前的桶还没有走完
    if(_node->_next)
        _node = _node->_next;
    //如果当前的桶已经走完了,就去找到下一个桶继续遍历
    else
    {
        //因为这里是迭代器,没有HashTable,所以我们需要把他传过来
        KeyOfT koft;
        //计算出当前元素映射到的位置
        size_t index = _pht->HashFunc(koft(_node->_data)) % _pht->_table.size();
        index++;
        for(;index<_pht->_table.size() ;index++)
        {
            Node* cur = _pht->_table[index];
            if(cur)
            {
                _node = cur;
                return *this;
            }
        }
        //如果没找到,就是空
        _node = nullptr;
    }
    return *this;
}

(1)如果当前的桶还没有走完:直接走向下一个

(2)如果当前的桶已经走完了:就去找到下一个桶继续遍历

①++it,当一个桶走完了,迭代器中如何找到下一个桶进行遍历?这是实现迭代器的关键问题!

要想找到下一个桶在哪里,就一定要有哈希表。可是++是要在迭代器中实现的,但是迭代器中没

有哈希表,怎么办?那只能把哈希表传过来了!这就是 HT* _pht 存在的原因!!!

解释迭代器上面的前置声明:因为要提前使用HashTable,所以需要前置声明HashTable

②++的实现还访问了 _pht 的私有成员变量,怎么办?把迭代器设计成哈希表的友元

2.哈希表中的构造/析构函数

①构造函数的实现非常简单,只是将计数用的_num设置成0就可以了

②析构函数借助 void Clear(){} 函数来实现,Clear()函数的作用是将挂着的桶全部清除。

void Clear()
{
    //清除所有元素
    for(size_t i=0; i<_table.size(); i++)
    {
        Node* cur = _table[i];
        while(cur)
        {
            Node* next = cur->_next;
            delete cur;
            cur = next;
        }
        _table[i] = nullptr;
    }
}

3.哈希表中迭代器的实现

(1)begin
//迭代器
iterator begin()
{
    for(size_t i=0; i<_table.size(); i++)
    {
        //遍历整个哈希表,找到第一个元素
        if(_table[i] != nullptr)
            return iterator(_table[i], this);
    }
    return end();
}
(2)end

直接返回一个nullptr构造的迭代器作为end

iterator end(){return iterator(nullptr, this);}

4.将key转化成能取模的数 

在实现线性探测法的哈希表时,我们默认了一件事情:所有存储的数据都是可以直接通过 %

取模的(也就是说默认存储的数据全都是整数)。但是实际上,存储的数据一定都是整数吗?

很明显不一定都是整数,比如我们会存储字符串,字符串本身不能直接进行取模,所以就有了

size_t HashFunc(const K& key),用于把不能直接取模的key转换成能取模的整数。

//将key转化成能取模的数
size_t HashFunc(const K& key)
{
    //利用Hash这个仿函数
    //Hash仿函数将对应的key转成可以取模的整形(因为有些类型不可以直接取模)
    Hash hash;
    return hash(key);
}

拓展知识:介绍一种常用的 字符串转化成整数的算法(BKDR算法)

//将string转成可以取模的仿函数
struct _HashString
{
    size_t operator()(const string& key)
    {
        //BKDR算法
        size_t hash = 0;
        for(size_t i=0; i<key.size(); i++)
        {
            hash *= 131;//乘上131后效果最好
            hash += key[i];
        }
        return hash;
    }
};

5.插入

(1)前置函数:size_t GetNextPrime(size_t prime);

作用:在插入之前,首先需要判断哈希表是否需要扩容。在上面线性探测实现的时候,我们选择

的是直接扩大二倍,但是这样是最好的选择吗?答:不是!!!研究表明:哈希表的大小是一个

素数的时候,发生哈希冲突的概率会降低。所以就有了GetNextPrime()函数来控制哈希表大小。

//研究表明:哈希表的大小是一个素数的时候,发生哈希冲突的概率会降低
size_t GetNextPrime(size_t prime)
{
    const int PRIMECOUNT = 28;
    //数据来源:STL源码
    static const size_t primeList[PRIMECOUNT] ={
        53ul, 97ul, 193ul, 389ul, 769ul,
        1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
        49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
        1572869ul, 3145739ul, 6291469ul, 12582917ul,
        25165843ul,50331653ul, 100663319ul, 201326611ul, 402653189ul,
        805306457ul,1610612741ul, 3221225473ul, 4294967291ul};
    
    for(size_t i=0; i<PRIMECOUNT; i++)
    {
        if(primeList[i] > prime)
            return primeList[i];
    }
    return primeList[PRIMECOUNT - 1];
}

(2)插入函数:pair<iterator, bool> Insert(const T& data);

pair<iterator, bool> Insert(const T& data)
{
    KeyofT koft;
    //如果负载因子等于1,则增容,避免大量的哈希沖突
    if(_table.size() == _num)
    {
        //1.开2倍空间
        vector<Node*> newtable;
        //size_t newsize = (_table.size() == 0) ? 10 : _table.size() * 2;
        size_t newsize = GetNextPrime(_table.size());
        newtable.resize(newsize);
        //2.把旧表数据放进新表
        for(size_t i=0; i<_table.size(); i++)
        {
            Node* cur = _table[i];//指向第i个桶的开始
            //遍历第i个桶
            while(cur)
            {
                Node* next = cur->_next;//指向cur的下一个
                //计算在新表中的位置
                size_t newindex = HashFunc(koft(cur->_data)) % newtable.size();
                //把cur取下来头插到新表中
                //  newtable[newindex](新表头) node node
                //cur
                cur->_next = newtable[newindex];
                newtable[newindex] = cur;
                cur = next;
            }
            _table[i] = nullptr;
        }
        _table.swap(newtable);
    }
    
    //计算data中的key在表中映射的位置
    size_t index = HashFunc(koft(data)) % _table.size();
    //1.先查找这个值在不在表中
    Node* cur = _table[index];
    while(cur)
    {
        //遇到重复的不插入
        if(HashFunc(koft(data)) == HashFunc(koft(cur->_data)))
            return make_pair(iterator(cur, this), false);
        else
            cur = cur->_next;
    }
    //2.把数据挂到链表上(选择头插)
    Node* newnode = new Node(data);
    newnode->_next = _table[index];
    _table[index] = newnode;
    _num++;
    return make_pair(iterator(newnode, this), true);
}

插入的实现大体上分成如下三步:

        ①通过负载因子来判断是否需要对哈希表进行扩容。

        ②除留余数法计算出映射到的位置。

        ③插入(先判断是否有重复,再利用头插法把新数据插入)。

6.查找

Node* Find(const K& key);(结合代码中的注释,一定可以看懂的!😁)

Node* Find(const K& key)
{
    KeyofT koft;
    size_t index = HashFunc(key) % _table.size();
    Node* cur = _table[index];//指向桶的第一个节点
    while(cur)
    {
        if(HashFunc(koft(cur->_data)) == key)
            return cur;
        else
            cur = cur->_next;
    }
    //走到这里就是把整个桶都走了一遍,还没找到
    return nullptr;
}

7.删除

bool Erase(const K& key);(结合代码中的注释,一定可以看懂的!😁)

bool Erase(const K& key)
{
    KeyofT koft;
    size_t index = HashFunc(key) % _table.size();
    Node* cur = _table[index];//指向桶的第一个节点
    Node* prev = nullptr;//记录cur的前一个节点
    while(cur)
    {
        if(HashFunc(koft(cur->_data)) == HashFunc(key))
        {
            //prev  cur(删)  cur->next
            //连接
            if(cur == _table[index])
                _table[index] = cur->next;//头删
            else
                prev->_next = cur->_next;//非头删
            delete cur;
            _num--;
            return true;
        }
        else
        {
            prev = cur;
            cur = cur->_next;
        }
    }
    //走到这里就是把整个桶都走了一遍,还没找到
    return false;
}

8.总结

上面介绍了哈希桶的实现,其中涉及到了复杂的模版参数。最后结合实际使用场景梳理一下各个

模版参数之间的关系,以及如何使用仿函数。(用图表示)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值