【C++从入门到踹门】第十八篇(上):哈希表实现

目录

  • 哈希概念
    • 什么是哈希表
    • 直观了解哈希
  • 哈希冲突
  • 哈希函数
  • 哈希冲突处理
    • 闭散列 —— 开放定址法
    • 哈希表开散列
  • 哈希表闭散列实现
    • Hash仿函数
    • 哈希表基本框架
    • 查找函数——find
    • 插入元素——insert
    • 删除函数——erase
  • 哈希表开散列实现
    • 基础结构
    • 查找元素 —— find
    • 插入元素 —— insert
    • 删除元素 —— erase
  • 如何设置哈希表的大小(拓展)

【C++从入门到踹门】第十八篇(上):哈希表实现_第1张图片

完整代码实现已上传至gitee,戳->哈希表实现

哈希概念

什么是哈希表

之前学过的数据结构,如线性表(数组,链表)和树状结构(红黑树,AVL树),这些结构中存储元素的关键码Key与其存储位置是没有对应联系的。于是,在查找数据时,我们只能不断地让索引值key与数据结构中的存储元素的关键码比较来查询。例如顺序表的比较结果有 =两种可能,于是查找的时间复杂度为O(N),在树中则有 >,=,<三种可能,查找的时间复杂度为O(logN)。

如果我们将元素关键码和其存储位置相关联,即确立关键码与位置的映射关系,因此在查找时只要输入我们想要得到的数据的关键码,就可以通过映射找到它所存储的位置,那么我们就可以在表中直接搜索到元素,即查找的时间复杂度为O(1)。

那么首先在插入元素时就要按照这种映射关系计算出该元素存储的位置进行存放。

搜索数据,则对与元素的关键码进行同样的映射关系运算,求得值即为存储位置。

该方法称为哈希(散列)法, 其中的映射关系称为哈希函数,构造出来的结构即为哈希表。

直观了解哈希

这里通过一个例子来直观了解哈希的使用方法:

【C++从入门到踹门】第十八篇(上):哈希表实现_第2张图片

我们建立一个含有26个int的数组frequency,数组下标(i)与小写英文字母(变量c,char)一一对应,于是映射关系就有:i=c-'a',数组元素则存放字母出现的次数,可以得到:

【C++从入门到踹门】第十八篇(上):哈希表实现_第3张图片

字母a出现的次数存放在frequency[0],字母b出现的次数存放在frequency[1],…,字母z出现的次数存放在frequency[25]。只要再遍历一次字符串,通过字符找到frequency下标(即元素存放位置)就可以找到这个字符出现的次数,只要找到第一个为1的元素,即找到第一个只出现一次的字符。

代码如下:

class Solution {
public:
    char firstUniqChar(string s) {

        int frequency[26]={0};
        for(const auto& c : s)
        {
            frequency[c-'a']++;
        }

        char ret=' ';
        for(const auto& c : s)
        {
            if(frequency[c-'a']==1)
            {
                ret= c;
                break;
            }
        }
        return ret;
    }
};

哈希冲突

对于两个数据元素的关键字 keyi和keyj(i!=j),有keyi != keyj,但哈希函数计算后 : Hash(keyi)==Hash(keyj)

不同关键字通过相同的哈希函数计算出相同的哈希地址,这种现象称为哈希冲突或哈希碰撞

哈希函数

一个理想的哈希函数

  • 哈希函数的定义域必须包括需要存储的元素的全部关键码,如果散列表允许有n个地址,其值域只能在0~n-1之间
  • 哈希函数计算出来的地址能够均匀分布在整个空间中
  • 哈希函数应该比较简单

直接定址法

取关键字的线性函数为散列地址:Hash(Key)=A*Key+B (A,B为常数,A!=0)

  • 优点:简单,可适用于元素Key值分布均匀的元素
  • 缺点:需要实现得知关键字的分布情况(如上述的字母表),如果关键字的分布范围很广,将需要一个很大的哈希表,浪费空间。

除留余数法

设哈希表中存在的地址数为n,取一个不大于n,但最接近或等于m的素数p作为除数,按照哈希函数:Hash(Key)=Key%p,将关键码转换为哈希地址。

【C++从入门到踹门】第十八篇(上):哈希表实现_第4张图片

这是最简单且常用的构造哈希函数的方法,他不仅可以直接对关键字取余,还能对关键字折叠,平方取中后再取余。

平方取中法(不常用)

取关键字平方后的中间几位作为散列地址,关键字1234,平方得1522756,那么可抽取中间三位227为哈希地址。

随机数法(不常用):

选择一随机函数,取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合。

折叠法(不常用):

将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址

哈希冲突处理

在之前介绍的除留余数法中会有哈希冲突的可能,取模后的数组下标出现重复:

【C++从入门到踹门】第十八篇(上):哈希表实现_第5张图片

下面将介绍解决方案

闭散列 —— 开放定址法

当发生哈希冲突时,若哈希表此时还没有被装满,说明哈希表中还存在空位置,那么可以把key存放到冲突位置的“下一个”空位置中

寻找下一个空位置的常见方法有线性探测法二次探测法

  1. 线性探测

当发现哈希冲突时,从发生冲突的位置开始,依次往后探测一个位置,直到找到空位置。

  • 插入

    • 通过哈希函数函数获取哈希表的位置

    • 哈希函数:Hi=(H0+i)%p (i=0,1,2,3,…)

      • H0:通过哈希函数对元素的关键码进行计算得到的位置。
      • Hi:冲突元素通过线性探测后得到的存放位置。
      • p:表的大小。

【C++从入门到踹门】第十八篇(上):哈希表实现_第6张图片

  • 查找与删除

    使用哈希函数确定待删除元素的起点位置,如果当前位置的元素不是待删除元素,那就线性探测往后找,如果遇到空位置,说明没有该元素,停止搜索。

    如果找到该元素,不能直接删除,否则会影响其他元素的搜索。这里可以对每个位置进行标记,EMPTY(空),EXIST(存在),DELETE(删除)。

    当搜索遇到EMPTY状态,说明不存在该元素,因为线性探测已结束。

    如果遇到DELETE,则需要继续往后找,如果到达EMPTY或者删除结点的起点说明没有该元素。

    如果找到删除元素,直接标记为DELETE(DELETE状态和EMPTY状态可插入元素)。

【C++从入门到踹门】第十八篇(上):哈希表实现_第7张图片

【C++从入门到踹门】第十八篇(上):哈希表实现_第8张图片

  • 哈希表扩容

哈希表中如果元素过多,会大大提升哈希冲突的概率,这里需要提到一个概念:负载因子α=填入表中的元素个数/哈希表长度。

对于开放定址法,负载因子是特别重要的参数,应严格限制在0.7或0.8以下,超过0.8,查表时的缓存不命中概率按照指数曲线上升,因此当负载因子过红线时,应扩容哈希表。

  • 线性探测的缺点:

如果数据的哈希值过于集中容易产生数据的堆积,线性探测的次数过多,不同关键码都占据了其他的空位置,使得寻找关键码也要比较多次(踩踏),导致搜索效率降低。

  1. 二次探测

线性探测的缺陷是产生冲突的数据堆积在了一起,这与其找下一个空位置的方法有关系,二次探测寻找下一个空位的方法则是:

  • 哈希函数:Hi=(H0+i^2)%p (i=0,1,2,3,…)

    • H0:通过哈希函数对元素的关键码进行计算得到的位置。
    • Hi:冲突元素通过线性探测后得到的存放位置。
    • p:表的大小。

如果哈希值较为集中,那么二次探测后的哈希表元素的分布会相对稀疏一些,不容易导致堆积。采用二次探测同样需要考虑负载因子。

哈希表开散列

开散列又称哈希桶或者拉链法,对key值计算地址,具有相同地址的关键码将归于同一子集合,每一个子集合称为一个哈希桶,各个桶中元素通过链表连接起来,各链表的头结点存储在哈希表中。

【C++从入门到踹门】第十八篇(上):哈希表实现_第9张图片

可以看出,开散列的每个哈希桶中放的都是发生哈希冲突的元素。

闭散列解决哈希冲突采用的是抢占的方式,自己的位置如果被占用就只能转而占用他人的位置。开散列将哈希冲突的元素通过链表连接,然后将链表的头结点存储在哈希表中。开散列的方式使结点不会影响与自己哈希地址不同的元素的增删查改的效率,因此其负载因子稍大些,可达到1。

当哈希桶也有缺陷,极端情况下,插入的元素全部产生冲突,最终都放到同一个哈希桶中,于是删查改的效率就退化成了O(N)。这种场景可以考虑将链表的数据结构改为红黑树结构:

【C++从入门到踹门】第十八篇(上):哈希表实现_第10张图片

在Java语法的HashMap中,如果哈希桶中的元素超过8个就会将链表结构转换为红黑树结构,当红黑树的结点小于等于6个以后,又会恢复为链表。

在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率。

链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。通常如果哈希函数正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。

那么哈希表的结点结构可以设计为:

template <class T>
struct HashData
{
    forward_list<T> _list;
    set<T> _rbtree;
};

哈希表闭散列实现

我们使用闭散列方式建立哈希表,以实现 unordered_map 为前提(存放的元素类型为pair)。

Hash仿函数

首先我们要确保key值是可以转换为非负整型的(为了取余),遇到存储类型为pair时,其中key类型为string,这需要使用仿函数来帮助我们将string转为整型值。如果key是自定义类型,就需要用户自己指定仿函数,如自定义结构体,其中若存在唯一值,那可做为Key,如身份证号,如果没有为唯一值,则考虑多项组合。

STL中的unordered_map参数中的Hash函数:

【C++从入门到踹门】第十八篇(上):哈希表实现_第11张图片

我们自己的适合内置类型的仿函数如下:

template <class K>
//遇到double,float,char类型通过强转后返回
struct Hash
{
    size_t operator()(const K& key)
    {
        return (size_t)key;
    }
};

//特化string版本,使用BKDRHash装换算法
template<>
struct Hash<string>
{
    const size_t operator()(const string& key)
    {
        size_t hash = 0;
        // 把字符串的所有字母加起来   hash = hash*131 + key[i]
        for (size_t i = 0; i < key.size(); ++i)
        {
            hash *= 131;
            hash += key[i];
        }
        return hash;
    }
};

字符串哈希函数的对比:

【C++从入门到踹门】第十八篇(上):哈希表实现_第12张图片

关于字符串装换整型的Hash函数的详情戳:各种字符串Hash函数

哈希表基本框架

之前在闭散列中介绍过,一个哈希表存储单元,除了存元素之外还需存储状态:EMPTY,DELETE,EXIST

  • 存储单元框架如下
//状态
enum State
{
    EMPTY,
    EXIST,
    DELETE
};

//存储类型结构体
template <class K,class V>
struct HashData
{
    pair<K, V> _kv;
    State _state=EMPTY;
};
  • 哈希表基本框架

使用vector<>作为容器,

template <class K, class V,class HashFunc=Hash<K>>//HashFunc负责将key值(可能为string)转换为size_t,方便取余
class HashTable
{
public:

    HashData<K, V>* find(const K& key)
    {
        //...
    }

    bool insert(const pair<K,V>&  kv)
    {
        //...
    }

    bool erase(const K& key)
    {
        //...
    }

private:
    vector<HashData<K,V>> _table;//容器
    size_t _n=0;//实际存储的元素个数
};

查找函数——find

查找步骤如下:

  1. 如果哈希表的容量为0,查找失败。
  2. 通过哈希函数计算出对应的哈希地址;
  3. 以哈希地址作为起始地点,如果没有找到元素,则采用线性探测(或者二次探测)向后查找数据,如果找到状态为EMPTY的元素则查找失败,过程中如果key值匹配且状态为EXIST,则找到元素。
public:
    HashData<K,V>* find(const K& key)
    {
        if(_table.size()==0)
        {
            return nullptr;
        }

        HashFunc hf;//仿函数(Key转整型)实例化对象
        size_t start = hf(key)%_table.size();
        size_t index = start;
        int i = 1;
        while (_table[index]._state!=EMPTY)
        {
            if(_table[index]._kv.first==key
                && _table[index]._state==EXIST)
            {
                return &_table[index];//找到返回元素指针
            }
            index = start+i;//线性探测
            //index = start+i*i;//二次探测
            i++;
            index %= _table.size();
        }
        return nullptr;
    }

插入元素——insert

插入步骤如下:

  1. 判断容器内元素数量如果为0,先预设10个元素空间的大小;
  2. 如果负载因子大于0.7,考虑增容(增容后需要对元素存放位置进行重新计算和存放)
  3. 用哈希函数计算出要插入的元素的起始位置,然后找空位置(状态为EMPTY和DELETE)。然后进行插入,并把状态改为EXIST(这里不用担心没有空位置,因为元素数量超过容量七成时会自动扩容)
  4. 此过程中如果待插入的元素在哈希表中已存在,则返回false
public:
    bool insert(const pair<K,V>& kv)
    {
        if (find(kv.first) != nullptr)//防止重复
        {
            return false;
        }

        //扩容逻辑
        //初始时是一个空表,则resize空间。
        if (_table.size() == 0)
        {
            _table.resize(10);
        }
        else if ( 10 * _n / _table.size() > 7)//或者负载因子超过0.7,则需要换一个更大的表
        {
            //法一 新建一个table,然后将元素转移
            //vector newtable;
            //newtable.resize(2 * _table.size());
            //for (auto& e : _table)
            //{
            //	if (e._state == EXIST)//因为新表的容量更大,所以元素位置会变化
            //	{
            //		//重新按照线性探测或二次探测规则在新表中插入
            //	}
            //}

            //法二 新建一个HashTable类(推荐)
            HashTable<K, V,HashFunc> newHT;
            newHT._table.resize(2*_table.size());
            for (const auto& e : _table)
            {
                if (e._state == EXIST)
                {
                    newHT.insert(e._kv);//复用了该类的insert成员函数
                }
            }
            _table.swap(newHT._table);
        }

        //插入新值的逻辑
        HashFunc hf;//仿函数 将key转换为size_t
        size_t start = hf(kv.first)%_table.size();
        size_t index = start;
        size_t i = 1;
        while (_table[index]._state == EXIST)
        {
            index =start+ i;//线性探测
            //index=start+i*i;//二次探测
            index %= _table.size();
            ++i;
        }
        _table[index]._kv = kv;
        _table[index]._state = EXIST;
        _n++;

        return true;
    }

删除函数——erase

删除步骤如下:

  1. 先通过find查找是否存在该元素,若不存在则删除失败;
  2. 若存在,则将该位置的状态置为DELETE;
  3. 将元素数量减1;
public:
    bool erase(const K& key)
    {
        HashData<K, V>* del = find(key);
        if (del == nullptr)//没有该点
        {
            return false;
        }
        del->_state = DELETE;
        _n--;
        return true;
    }

哈希表开散列实现

基础结构

在开散列的哈希表中,哈希表的结点类型,除了存储一个数据外还需存储指向下一个结点的结点指针。

当然也需要将key转换成size_t 的仿函数,和闭散列一样

template<class K>
struct Hash
{
    const size_t operator()(const K& key)
    {
        return (size_t)key;
    }
};

template<>
struct Hash<string>
{
    const size_t operator()(const string& key)
    {
        size_t hash=0;
        for (size_t i = 0; i < key.size(); ++i)
        {
            hash *= 131;
            hash += key[i];
        }
        return hash;
    }
};


template <class K,class V>
struct HashData
{
    //链表指针
    HashData<K, V>* _next;
    //链表结点数据
    pair<K, V> _kv;

    HashData(const pair<K, V>& kv) :_kv(kv), _next(nullptr)
    {}
};

哈希表使用vector作为容器,元素类型则为结点指针:

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

    Node* find(const K& key)
    {
       //....
    }

    bool insert(const pair<K,V>& kv)
    {
        //...
    }

    bool erase(const K& key)
    {
        //...
    }


private:
    vector<Node*> _table;//指针数组
    size_t _n=0;//实际存放的元素数量
};

查找元素 —— find

步骤:

  1. 哈希表元素数量为0,查找失败。
  2. 计算出哈希地址。
  3. 找到对应哈希桶,遍历查找。
public:
    Node* find(const K& key)
    {
        HashFunc hf;//key转size_t 仿函数实例化
        if (_table.size() == 0)//元素数量为0,无法查找
        {
            return nullptr;
        }
        //计算哈希地址
        size_t index = hf(key) % _table.size();
        Node* cur = _table[index];
        while (cur)//遍历哈希桶
        {
            //key值匹配,
            //注意哈希表的Key值一定是可以做相等比较的
            //如果不存在相等运算符,则提供重载
            if (cur->_kv.first == key)
            {
                return cur;
            }
            cur = cur->_next;
        }
        return nullptr;
    }

插入元素 —— insert

步骤

  1. 查找哈希表中是否已存在待插入的元素,若存在则插入失败。

  2. 判断哈希表大小吗,若哈希表为0,则直接赋予空间;

  3. 若负载因子达到1,需扩容,

    这里的扩容方式与闭散列不同:闭散列是新建一个哈希表,再复用了成员函数insert的插入逻辑,而开散列的元素为结点指针,若new一个新结点后,还需对旧表结点delete,折损效率,不如新建一个指针数组,将旧表的元素插入至新数组中来得方便,不用进行结点的创建与释放。

  4. 计算哈希地址,将元素头插入对应哈希桶中;

  5. 有效元素数量+1。

public:
    bool insert(const pair<K,V>& kv)
    {
        //查找是否已有该元素
        if (find(kv.first))
        {
            return false;
        }
        HashFunc hf;//key转size_t仿函数实例化


        //空表或负载因子为1 增容
        if (_n == _table.size())
        {
            size_t newsize = _n == 0 ? 10 : 2 * _table.size();
            vector<Node*> newtable;
            newtable.resize(newsize);
            for (size_t i = 0; i < _n; ++i)
            {
                if (_table[i])//桶不空,遍历该哈希桶
                {
                    //拿到这个桶的头结点
                    Node* cur = _table[i];
                    while (cur)
                    {
                        Node* next = cur->_next;

                        //重新计算映射位置
                        size_t index = hf(cur->_kv.first) % newtable.size();
                        //插入至新表
                        cur->_next = newtable[index];
                        newtable[index] = cur;
                        cur = next;
                    }
                }
            }
            _table.swap(newtable);//交换哈希表
        }


        size_t index = hf(kv.first) % _table.size();
        Node* newnode = new Node(kv);

        //头插
        newnode->_next = _table[index];
        _table[index]= newnode;
        ++_n;

        return true;
    }

删除元素 —— erase

步骤

  1. 计算哈希结点地址;
  2. 找到对应哈希桶;
  3. 遍历哈希桶;
  4. 将元素从单链表中移除,元素数量-1。
public:
    bool erase(const K& key)
    {
        HashFunc hf;//key转size_t仿函数实例化
        //1.计算哈希地址
        size_t index = hf(key) % _table.size();

        //2.在桶中寻找待删除结点
        Node* cur = _table[index];
        Node* prev = nullptr;
        while (cur)
        {
            if (cur->_kv.first == key)
            {
                if (prev == nullptr)//cur为头结点
                {
                    _table[index]= cur->_next;
                }
                else//cur为中间结点
                {
                    prev->_next = cur->_next;
                }
                delete cur;
                --_n;
                return true;
            }
            prev = cur;
            cur = cur->_next;
        }
        return false;
    }

如何设置哈希表的大小(拓展)

上文我们得知,在初次插入数据时需要给定数组的初始大小,以及当负载因子超过限定时需要扩容,而有研究表明当数组的长度为素数时,可以减少除留余数法的哈希冲突。

于是获取素数大小

public:
    GetNextPrime(size_t prime)
    {
        const int PRIMECOUNT=28;
        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
        };
        size_t i=0;
        for(;i<PRIMECOUNT;++i)
        {
            if(primeList[i]>prime)
            {
                return primeList[i];
            }
        }
        return primeList[0];
    }

改造增容部分代码:

//改造前
//size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
//改造后
size_t newsize = GetNextPrime(_tables.size());

— end —

青山不改 绿水长流

你可能感兴趣的:(C++,散列表,数据结构,c++)