哈希表&&哈希桶(C++实现)

哈希的概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:

  • 插入元素 根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
  • 搜索元素 对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置 取元素比较,若关键码相等,则搜索成功

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)。

比如将数据{1,4,6,8,15}存储到哈希表中:
哈希函数设置为hashi = key % capacity(其中hashi就是元素存储的位置,key就是数据项,capacity为存储元素底层空间总的大小,比如vector),存储结构如下:

image.png
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。

哈希冲突

如果继续向上面哈希表中插入14,hashi(14) = 4。而4这个位置已经被占用,此时就会发生不同的值映射到同一个位置上。这种情况就叫做哈希冲突

哈希函数

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
一个好的哈希函数设计应该遵循以下条件:

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

最常见的哈希函数有:
除留余数法(最常用)
对于哈希表长度为m的哈希函数公式为:
hashi(key) = key % p (p <= m)

解决哈希冲突的方法

处理哈希冲突有两种常用的方法:

  • 闭散列
  • 开散列

闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去
。寻找下一个位置的方式有:

  • 线性探测
  • 二次探测

线性探测

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

比如对下面数据项集合{12,67,56,16,25,37,22,29,15,47,48,34}。哈希表长为12。
哈希函数为hashi(key) = key % p (p <= m)。

计算前五个{12,67,56,16,25}时,都不会发生冲突。直接存入哈希表。如下:

image.png
当计算37时,hashi(37) = 1,此时就会与25发生冲突,此时就需要将37存放到25的下一个位置,即下标为2的位置处。

image.png
继续存放{22,29,15,47}都没有发生冲突,正常存放。

image.png
存放48时,hashi(48) = 0,与12所在的位置发生了冲突,此时下一个位置还是会发生冲突,一直线性探测到29的下一个,才有空位。将48存放到29的下一个。

image.png
继续存放34,hash(i) = 10。和22,47,12,37,15,16,29,48,67,56都会发生冲突。只能存放到56的下一个。

image.png

二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位 置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:
hashi = ( H 0 H_0 H0 + i 2 i^2 i2 )% m, 或者:hashi = ( H 0 H_0 H0 - i 2 i^2 i2 )% m。
其中:i = 1,2,3…,
H 0 H_0 H0是通过散列函数Hash(key)对元素的关键码 key 进行计算得到的位置,
m是表的大小。

image.png
比如上面哈希表存放最后一个34时, H 0 H_0 H0 = 10,i = 1,hashi = (10-1)%12 = 9。而下标9正好是空,只需一次二次探测即可存放34。

负载因子

以上就是闭散列寻址的两种方式。当然哈希表也是需要扩容的,如果继续对上面哈希表存放数据,哈希表已满,无法存放了。
关于哈希表扩容,引入了一个新的概念,负载因子。
哈希表的负载因子α = 表中的数据个数 / 哈希表长度。

比如下面哈希表的负载因子 = 6 / 12 = 0.5

image.png
负载因子是哈希表反馈装满程度的标志,由于表长是定值,负载因子和哈希表中的元素个数成正比,所以负载因子越大,表明哈希表中元素个数越多。产生哈希冲突的概率越大,反之,负载因子越小,哈希表中元素个数越少,发生哈希冲突的概率越低。
对于闭散列,哈希表的负载因子是非常重要的,一般严格控制在0.7~0.8以下(也就是说哈希表的负载因子超过0.8就要扩容,避免产生更多的哈希冲突)。

哈希表闭散列的实现

哈希表的存储结构

哈希表中每个位置除了存储数据元素之外,还要存储当前位置的状态(空,已存放,已删除)。因为采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素 会影响其他元素的搜索。因此线性探测采用标记的伪删除法(就是给每个存储位置添加一个存储状态)来删除一个元素。
哈希表存储位置的状态可以使用枚举常量来定义,如下:

enum Status
{
    EXIST,//已存储
    EMPTY,//空
    DELETE//已删除
};

整个哈希表闭散列的结构:

//存储位置状态
enum Status
{
    EXIST,
    EMPTY,
    DELETE
};
//存储位置结构
template<class K, class V>
struct HashData
{
    pair<K, V> _kv;//哈希表存储数据元素。
    Status _status = EMPTY;//存储位置状态默认为空
};
//哈希表
template<class K, class V>
class HashTable
{
    typedef HashData<K, V> HashTableNode;
public:
    //哈希表提供的三个方法(插入、删除、查找)
    bool Insert();
    bool Erase();
    HashTableNode* find();
private:
    vector<HashTableNode> _tables;//使用vector做为哈希表底层存储结构
    size_t n = 0;//哈希表元素个数(方便计算哈希表的负载因子)
};

插入

向哈希表中插入数据的步骤如下:

  1. 查看哈希表中是否存在该键值的键值对,若已存在则插入失败。
  2. 判断是否需要调整哈希表的大小,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整。
  3. 将键值对插入哈希表。
  4. 哈希表中的有效元素个数加一。
bool Insert(const pair<K, V> kv)
{
    //不允许键值冗余
    if (find(kv.first) != nullptr)
    {
        return false;
    }
    //空哈希表初始化10个存储位置
    if (_tables.size() == 0 )//扩容
    {
        _tables.resize(10);
    }
    else if((double)_tables_size / (double)_tables.size() > 0.8)//负载因子大于0.8扩容
    {
        //创建新哈希表对象
        HashTable<K,V> newtables;
        newtables._tables.resize(_tables.size() * 2);
        //遍历旧表,重新映射到新表中
        for (auto& data : _tables)
        {
            if (data._status == EXIST)
            {
                newtables.Insert(data._kv);
            }
        }
        //交换这两个哈希表
        _tables.swap(newtables._tables);
    }

    //线性探测确认哈希地址
    size_t hashi = kv.first % _tables.size();
    size_t i = 1;
    size_t index = hashi;
    while (_tables[index]._status == EXIST)
    {
        index = hashi + i;
        ++i;
        index %= _tables.size();//从0继续寻找位置。
    }

    //确认位置进行存储
    _tables[index]._kv = kv;
    _tables[index]._status = EXIST;
    _tables_size++;
    return true;
}

注意细节:

  • 扩容时创建新哈希表对象,让新哈希表对象调用insert不构成函数递归,来完成扩容后重新建立映射关系。
  • index %= _tables.size();//从0继续寻找位置。

查找

HashTableNode* find(const K& key)
{
    //空表
    if (_tables.size() == 0)
    {
        return nullptr;
    }
    //线性探测
    size_t hashi = key % _tables.size();
    size_t start = hashi;
    size_t i = 1;
    while (_tables[start]._status != EMPTY)
    {
        if (_tables[start]._kv.first == key 
         && _tables[start]._status == EXIST)
        {
            return &_tables[start];
        }
        start = hashi + i;
        i++;
        start %= _tables.size();
        //已经找了一圈了
        if (start == hashi)
        {
            break;
        }
    }
    return nullptr;
}

删除

删除哈希表中的元素采用伪删除法,只需将被删除元素找到,将其状态修改为DELETE即可。

bool Erase(const K& key)
{
    HashTableNode* ret = find(key);
    if (ret == nullptr)
    {
        return false;
    }
    else
    {
        ret->_status = DELETE;
        --_tables_size;
    }
    return true;
}

开散列

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地 址的关键码归于同一子集合,每一个子集合称为一个哈希桶,各个哈希桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

哈希桶和vector< list >本质是一样的(本质就是指针数组)。逻辑图如下:

image.png

比如对下面数据项集合{1,33,3,5,55,7,9},存放到表长为10的哈希表中。同样哈希函数采用除留余数法,存放到哈希表中如下:

image.png
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
实际中哈希表的开散列比闭散列更实用。
开散列同样也要考虑扩容问题,开散列一般负载因子为1时在发生扩容。
这样的话,如果哈希表中所有的元素都发生冲突,都存放在一个哈希桶中,有负载因子的控制扩容,几乎不会出现这种极端情况。
如果真的出现极端场景,可以考虑将这个桶由单链表改为红黑树。
由此可见,哈希表的开散列效率是极高的。增删查改的平均时间复杂度都是O(1)级别的存在,非常的恐怖。

哈希表开散列的模拟实现

在哈希表的开散列中,每个位置存储的是单链表的结点,这里需要实现一个哈希表的结点类。结点类和单链表一样,除了存储数据元素之外,还需要存储一个结点的指针用来指向下一个结点。

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

哈希桶框架

template<class K, class V>
class HashBucket
{
	typedef HashNode<K, V> Node;
public:
	//哈希桶提供的方法...
	~HashBucket();
	bool Insert(const pair<K, V> kv);
	Node* Find(const K& key);
	bool Erase(const K& key);
	size_t size();
private:
	vector<Node*> _tables;
	size_t _size = 0;
};

下面就一一实现哈希桶提供的方法

insert函数

  1. 检查所插入元素是否存在
  2. 检查容量以及负载因为为1时进行扩容
  3. 确认插入元素所在桶的位置申请结点进行头插
bool Insert(const pair<K,V> kv)
{
        //1.检查所插入元素是否存在
        if (Find(kv.first) != nullptr)
        {
                return false;
        }
        //2.检查容量以及负载因为为1时进行扩容
        if (_tables.size() == 0)
        {
                _tables.resize(10);
        }
        else if (_size == _tables.size())//扩容
        {
                vector<Node*> new_bucket(_tables.size() * 2, nullptr);
                for (size_t i = 0; i < _tables.size(); ++i)
                {
                        Node* cur = _tables[i];
                        while (cur != nullptr)
                        {
                                Node* next = cur->_next;
                                size_t hashi = cur->_kv.first % new_bucket.size();
                                cur->_next = new_bucket[hashi];
                                new_bucket[hashi] = cur;
                                cur = next;
                        }
                }
                _tables.swap(new_bucket);
        }
        //3.确认插入元素所在桶的位置申请结点进行头插
        size_t hashi = kv.first % _tables.size();
        Node* newnode = new Node(kv);
        newnode->_next = _tables[hashi];
        _tables[hashi] = newnode;
        ++_size;
        return true;
}

注意: 这里的扩容不像闭散列一样,创建新的哈希表对象调用insert完成插入,会造成同样的数据再次申请结点,还要释放结点。虽然可以解决问题,但是效率不好。采用上面的方式更优,直接与新表重新建立映射关系即可。

// 创建新的哈希表对象调用insert完成插入。会造成同样的数据再次申请和释放。
HashBucket<K, V> new_bucket;
new_bucket._tables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); ++i)
{
        Node* cur = _tables[i];
        while (cur != nullptr)
        {
                new_bucket.Insert(cur->_kv);
                cur = cur->_next;
        }
}
_tables.swap(new_bucket._tables);

这里哈希表中的结点需要申请,那么也需要写析构函数来完成资源的释放,否则就会造成内存泄漏。

~HashBucket()
{
        for (size_t i = 0; i < _tables.size(); ++i)
        {
                Node* cur = _tables[i];
                while (cur != nullptr)
                {
                        Node* next = cur->_next;
                        delete cur;
                        cur = next;
                }
                cur = nullptr;
        }
        cout << "~HashBucket" << endl;
}

Find

  1. 找到查找元素在桶的位置
  2. 遍历单链表进行查找
Node* Find(const K& key)
{
    if (_tables.size() == 0)
    {
            return nullptr;
    }
    //1. 找到查找元素所在桶的位置
    size_t hashi = key % _tables.size();//注意/0错误
    Node* cur = _tables[hashi];
    //2. 遍历单链表查找
    while (cur != nullptr)
    {
            if (cur->_kv.first == key)
            {
                    return cur;
            }
            cur = cur->_next;
    }
    return nullptr;
}

Erase

  1. 找到删除元素所在桶的位置
  2. 单链表的删除
bool Erase(const K& key)
{
    if (_tables.size() == 0)
    {
            return false;
    }
    //1.找到删除元素所在桶的位置
    size_t hashi = key % _tables.size();//防止除0错误
    Node* cur = _tables[hashi];
    Node* prev = nullptr;
    //2.单链表的删除
    while (cur != nullptr)
    {
            if (cur->_kv.first == key)
            {
                    if (prev == nullptr)
                    {
                            _tables[hashi] = cur->_next;
                    }
                    else
                    {
                            prev->_next = cur->_next;
                    }
                    delete cur;
                    --_size;
                    return  true;
            }
            else
            {
                    prev = cur;
                    cur = cur->_next;
            }
    }
    return false;
}

开散列的其他问题

当存储的元素不是整形,而是其他类型,比如string等,如何让解决?

哈希函数采用除留余数法,所以被摸的key必须要为整形才可以。解决方法是提供一个仿函数,将key转化为整形,更准确的来说是无符号整形,如果是负数,转化为正整数进行存储。

struct HashFunc
{
	size_t operator()(const K& key)
	{
		return key;
	}
};

此时,哈希桶需要第三个模板参数将key转化为整形做除余运算(这里第三个模板参数给了缺省值,可以根据自己的需要来实现)。
这里以Find为例,凡是需要%运算的都需要转化。

template<class K, class V,class Hash = HashFunc<K>>
class HashBucket
{
    Node* Find(const K& key)
    {
            if (_tables.size() == 0)
            {
                    return nullptr;
            }
            //1. 找到查找元素所在桶的位置
            size_t hashi = _hash(key) % _tables.size();//注意/0错误
            //....
    }                 
private:
	Hash _hash;
}

如果数据类型是string,可以使用模板的特殊来完成,也可以自己实现做为模板参数来实例化。
这里以模板的特化为例

template<>
struct HashFunc<string>
{
	size_t operator()(const string& str)
	{
		return str[0];//取字符串第一个字符作为key进行 求%
	}
};

这样写的话,如果首字母相同的话,全部在一个桶里面,这样就会造成哈希桶的极端场景。使哈希桶的效率降低,有一种字符串哈希算法BKDRHash(此算法由于在Brian Kernighan与Dennis Ritchie的《The C Programming Language》一书被展示而得 名,是一种简单快捷的hash算法,也是Java目前采用的字符串的Hash算法(累乘因子为31))。这个算法就是每次累加然后乘31即可(原理目前不知道)。

//BKDRHash算法
template<>
struct HashFunc<string>
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 31;// 也可以乘以31、131、1313、13131、131313..
		}
		return hash;
	}
};

哈希桶的效率

产生一百万个随机数,存放到哈希桶中,取出最大桶下面的个数。求最大桶个数成员函数如下:

size_t MaxBucketSize()
{
    size_t max = 0;
    for (size_t i = 0; i < _tables.size(); ++i)
    {
            Node* cur = _tables[i];
            size_t count = 0;
            while (cur != nullptr)
            {
                    count++;
                    cur = cur->_next;
            }

            printf("[%d]->%d\n", i, count);//打印每个桶下面的元素个数

            if (count > max)
            {
                    max = count;
            }
    }
    return max;//最大桶的元素个数
}

测试代码如下:
这里rand()函数产生的不重复随机数最大只用32768个,让rand()+i后 能产生636105个不重复的随机数。

int main()
{
	HashBucket<int, int> hb1;
	srand((unsigned int)time(0));
	for (int i = 0; i < 1000000; ++i)
	{

		hb1.Insert(make_pair(rand() + i, rand() + i));
	}
	cout << hb1.MaxBucketSize() << endl;
	cout << hb1.size() << endl;
	return 0;
}

运行结果:

image.png
对于这636105个不重复的随机数,最大的哈希桶数据个数才是2。哈希表的性能非常恐怖。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

C++下等马

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值