哈希unordered系列介绍(上)

一.Unordered_map,Unordered_set介绍

在之前我们已经介绍过set,map,multiset等等关联式容器,它们的底层是红黑树进行模拟实现的,在查询时效率可达到 l o g 2 N log_2 N log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到

因此,在C++11中又引入了unordered系列的关联式容器,它们的用法和map,set非常类似,不过底层实现上有所不同,后者是用红黑树模拟实现的,而前者则是用一种名叫哈希的数据结构

二.简单性能对比

我们分别创建set对象,和unordered_set对象,然后生成一大堆随机数进行插入,通过时间上实现find,erase,insert等操作进行对比,来简单对比两者性能上的差别

cpp 复制代码
#include <unordered_map>
#include <unordered_set>
#include <time.h>
#include <set>
#include <map>

using namespace std;
int main()
{
	const size_t N = 200000;
	unordered_set<int> s1;
	set<int> s2;

	vector<int> v1;
	v1.reserve(N);
	srand(time(0));

	for (size_t i = 0; i < N; ++i)
	{
		//v1.push_back(rand());
		v1.push_back(rand()+i);
		//v1.push_back(i);
	}
		
	//插入时间对比
	size_t begin1 = clock();
	for (auto e : v1)
	{
		s2.insert(e);
	}
	size_t end1 = clock();
	cout << "set insert:" << end1 - begin1 << endl;
		
	size_t begin2 = clock();
	for (auto e : v1)
	{
		s1.insert(e);
	}
	size_t end2 = clock();
	cout << "unordered_set insert:" << end2 - begin2 << endl;
		
	//寻找时间对比
	size_t begin3 = clock();
	for (auto e : v1)
	{
		s2.find(e);
	}
	size_t end3 = clock();
	cout << "set find:" << end3 - begin3 << endl;
		
	size_t begin4 = clock();
	for (auto e : v1)
	{
		s2.find(e);
	}
	size_t end4 = clock();
	cout << "unordered_set find:" << end4 - begin4 << endl << endl;
	
	//删除时间对比
	size_t begin5 = clock();
	for (auto e : v1)
	{
		s2.erase(e);
	}
	size_t end5 = clock();
	cout << "set erase:" << end5 - begin5 << endl;
	
	size_t begin6 = clock();
	for (auto e : v1)
	{
		s1.erase(e);
	}
	size_t end6 = clock();
	cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
	return 0;
}

可以看到,在insert,erase等操作上,哈希实现的unordered系列具有一定的显著优势

在cplusplus网站对unordered系列的介绍也直接指出来它的优势所在,以及两者的不同,这里只简单列举unordered_map为例

前两段指出了unordered_map也是关联式容器(associative containers),支持键值对,key和mapped value的类型可以不同

第三段指出,它与map系列的不同,它的底层实现是一种叫哈希桶(buckets)的数据结构

第四,五段也指出了该系列的优势和区别,unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭

代方面效率较低,同时map我们知道它是支持双向迭代器,而unordered_map只支持单向迭代器(forward iterators)

三.哈希概念介绍

2.1哈希概念

对于平衡树来说,元素关键字与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较

查找的效率即为平衡树中树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数

那我们是否能建立一种这样的结构,将元素的关键字和其存储位置之间建立映射关系,从而大大提高查找的效率?

就像我们的宿舍号,在开学前,每位学生都会按照一定的规则 (比如说按照学号进行分宿舍,按成绩进行分宿舍)分到相应的宿舍

假如班级管理委员要找某位同学,他并不会按照顺序,一间间宿舍往下找,假如一层有十多个宿舍,那效率实在太慢了,正确的做法,就是根据分配的规则,比如说按照学号进行分宿舍,然后根据学号直接找宿舍名单 中对应宿舍即可.

将上述过程中的关键字抽离出来

一定的规则,我们称之为哈希函数

宿舍名单,我们称之为哈希表 (HashTable)

从上述例子也可以看出,哈希函数并不是唯一的

比如说,数据集合{1,7,6,4,5,9};

我们将哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小.

我们找4,只需要直接去哈希表中的4寻找即可,直接一步到位,它的关键思想和数组下标映射有点类似.

2.2哈希冲突

虽然理论上我们已经基本建立所谓的哈希表,但是实际运用上还有一些问题需要解决

假如我们对上面的例子中的表,再插入一个14呢?它应该放置在哪个位置?

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

数学上解释:

对于两个数据元素的关键字 k i k_i ki和 k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) == Hash( k j k_j kj),我们称之为发生了哈希冲突

2.3哈希函数

引起哈希冲突的其中一个原因:可能是哈希函数设计的不够合理

因此,哈希函数虽然有很多种类型,但是也需要符合一定的设计原则

哈希函数设计原则:

1.哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间

2.哈希函数计算出来的地址能均匀分布 在整个空间中

3.哈希函数应该比较简单

常见的哈希函数如下:

1.直接定址法--(常用)

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

优点:简单、均匀

缺点:需要事先知道关键字的分布情况

使用场景:适合查找比较小且连续的情况

2.除留余数法--(常用)

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

按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址

3.平方取中法--(了解)

假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址

平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况

注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

2.4哈希冲突解决

既然出现相应的问题,我们就要找相应的办法解决

解决哈希冲突两种常见的方法是:闭散列和开散列

2.4.1闭散列

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

那如何理解闭散列中闭的含义呢?

因为哈希表的原始空间是固定大小的,比如说我们上述所举的例子,对应的哈希表本质是一个vector,大小是10个字节,后面即便再插入新元素,需要打到某种条件才会改变哈希表的大小

那闭散列如何解决哈希冲突的问题?

抓住闭散列的定义,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的"下一个" 空位置中去.

因此,假如插入新元素14,和4发生冲突,就从哈希表中找一个新的位置,比如下标为8,下标为0的位置等等,这些位置都没有元素放置,可以用来放置14,从而解决哈希冲突的问题

但是怎么找空位置也是有讲究的

常用的探测方式主要是线性探测和二次探测

线性探测:

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

用大白话讲,就是一个一个从前往后找空位置

比如插入14,4发生冲突,往后找,看下标为5的位置有没有放置新元素,有;于是继续往后找,直到到下标为8的位置,发现为空,所以,放置对应的新元素14

二次探测:

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

大白话来说,就是跳着找,第一次找下标为冲突下标+1的位置,如果冲突,找冲突下标+2的位置,以此类推

当然,还有很多探测方式,比如左右交替找等等,这里不再过多介绍

2.4.2 负载因子

前面我们提到过,哈希表在一定条件下需要扩容,那这个一定条件是什么呢?

有人会说,哈希表全部已经装满了,就可以考虑扩容

这固然是一种实现方法,但在现实中往往采取的是另一种策略

我们定义

负载因子 α = 插入表中元素个数 散列表长度 \alpha = \frac{插入表中元素个数}{散列表长度} α=散列表长度插入表中元素个数

表中元素个数在哈希表中占据的相对比重越大,就越可能发生哈希冲突,因此负载因子在等于0.7至0.8的时候就要及时考虑哈希表的扩容问题,否则发生哈希冲突的概率将会急剧增大

2.4.3闭散列代码实现

哈希表的本质是一个自定义类型,里面包含两个成员变量,一个是vector,另一个是哈希表大小n(当然也可以采取函数指针的方式,不过c++本身就实现了vector,明显用vector更香)

插入的每一个HashData也不是简单的数字,同样也是一个自定义类型,里面包含的是pair键值对,和对应的元素状态

至于为什么HashData(哈希表中每个对应的元素)的成员变量要这样设计,其实背后也蕴含深意

1.插入哈希表中的元素类型并不固定,可能是内置类型int,或者是自定义类型string,再加上需要映射相应的位置,所以采取模板+pair的方式作为其中一个成员变量是合情合理的

2.那为什么要加入相应的元素状态呢?

这主要是为了实现删除和扩容的方便

删除对应的元素,只需要将对应的元素状态设为空即可,假如用一个不存在的值覆盖,反而还不方便

同样的,扩容需要重新创建一个新大小n的vector,并将旧的原有元素重新映射到新的vector中,如何快速判断对应位置是否存在元素呢?如果每个元素本身就有一个状态EXIST(存在)or Empty(空),那就能迅速判断是否要将该旧元素映射到新的哈希表中.
具体代码实现如下:

cpp 复制代码
namespace OpenAddress {
    //设三个状态
    enum State{
        EMPTY,
        EXIST,
        DELETE
    };

    template <class K,class V>
    struct HashData {
        pair<K, V> _kv;
        //初始状态都设为空
        State _state = EMPTY;
    };


    template <class K,class V>
    class HashTable {
    public:
        //insert
        bool insert(const pair<K, V>& kv)
        {
            //如果哈希表中本身已经存在该元素,则直接返回false
            if (Find(kv.first))
                return false;

     //考虑扩容问题,表长为0或负载因子超过0.7都要考虑扩容,并重新映射
     if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
            {
                size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
                HashTable<K,V> new_hash_table;
                //调整新哈希表size为newsize
                new_hash_table._tables.resize(newsize);

                //重新映射
                for (auto& data : _tables)
                {
                    //假设元素状态为存在,则移动到新表
                    if (data._state == EXIST)
                    {
                        new_hash_table.insert(data._kv);
                    }
                }
                _tables.swap(new_hash_table._tables);
            }

            //实际的插入代码
            size_t hashi = kv.first % _tables.size();

            size_t i = 1;
            size_t index = hashi;

            //找到对应插入的位置
            while (_tables[index]._state == EXIST)
            {
                index = hashi + i;
                index %= _tables.size();
                ++i;
            }

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

            return true;
        }

        //Find
        HashData<K,V>* Find(const K& key)
        {
            //假如哈希表本身大小就为0,则直接返回空即可
            if (_tables.size() == 0)
                return nullptr;

            //计算应该找的下标 = 键值%表长
            size_t hashi = key % _tables.size();

            //线性探测---从前往后找对应元素,直到遇到空
            size_t i = 1;
            size_t index = hashi;
            while (_tables[index]._state != EMPTY)
            {
                //如果状态不为删除,为存在;且键值相等,表明相同,说明
                if (_tables[index]._state == EXIST
                    && _tables[index]._kv.first == key)
                {
                    return &_tables[index];
                }

                //当前位置没找到,调整下标位置
                index = hashi + i;
                //下标不能越界,及时调整回来
                index %= _tables.size();
                ++i;

          //如果已经遍历了一圈,说明全都为空或者删除,则直接break即可
                if (index == hashi)
                {
                    break;
                }
            }
            return nullptr;
        }
        //Erase
        bool Erase(const K& key)
        {
            HashData<K, V>* ret = Find(key);
            //找到对应的元素,状态设为删除即可
            if (ret)
            {
                ret->_state = DELETE;
                //调整容量
                --_n;
                return true;
            }
            else
            {
                return false;
            }
        }
    private:
        //哈希表本质是一个vector,里面存放的都是HashData自定义类型数据
        vector <HashData<K,V>> _tables;
        //存储的数据个数
        size_t _n = 0;

        //HashData* tables;
        //size_t _size;
        //size_t _capacity;
    };
}

2.4.4开散列

闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷

于是有人想到用开散列的方式来实现哈希表

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

从上面的定义我们就可以看出来,开散列中的开是什么意思

虽然也需要考虑扩容问题,但是开散列有一个很显著的特征,它处理哈希冲突的方式简单又粗暴,直接挂到相应的桶即可,不需要再进行诸如线性探测,二次探测等等的操作

2.4.4.1 开散列增容

桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多 ,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容

那该条件怎么确认呢?

开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容

2.4.4.2 映射方案

还有一些细节需要思考

第一个,就是映射元素的问题,在闭散列中,我们故意忽略了该问题,实际上,实现哈希表是无法躲开的

比如说下面的代码,假如是字符串,我们是无法挂到相应的桶的,需要人为将key转为整型(下面例子的key对应的就是string类型)

cpp 复制代码
    //哈希表不能映射字符串,只能映射整型
    void TestHashTable3()
    {
        //HashTable<string, string, HashStr> ht;
        HashTable<string, string> ht;
        ht.insert(make_pair("sort","排序"));
        ht.insert(make_pair("left", "左边"));
        ht.insert(make_pair("right", "右边"));
        ht.insert(make_pair("", "右边"));
    }

所以我们设计时,需要加入相应的模板,让用户自己传递相应的哈希函数实现,使得我们的哈希表能够适应不同类型的数据

具体如何将字符串映射为相应的整型,可以参照下面这篇文章

链接: 字符串Hash函数

在模拟实现开散列时,我们采取的是BKDR算法

cpp 复制代码
template <class K>
    struct HashFunc {
        size_t operator()(const K& key)
        {
            return key;
        }
    };

    //特化
    template <>
    struct HashFunc<string>
    {   
        //BKDR
        size_t operator()(const string& s)
        {
            size_t hashi = 0;
            for (auto ch : s)
            {
                hashi = hashi * 131 + ch;
            }

            return hashi;
        }
    };

还有另外一个细节,我们扩容时采取的策略是扩2倍的方式,这当然没有问题,不过出于某种原因(效率上),现代大多采取开辟邻近相应的最大素数的方式

所以,我们下面的模拟实现开散列,也是直接仿照stl库中的方式,直接给出对应的素数大小空间,一旦超过元素和旧表大小相同,则开辟邻近最大素数大小的新表

cpp 复制代码
size_t GetNextPrime(size_t prime)
        {
            // SGI
            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];
        }

2.4.5 开散列代码实现

cpp 复制代码
namespace HashBucket {
    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>
    struct HashFunc {
        size_t operator()(const K& key)
        {
            return key;
        }
    };

    //特化,由于整型和字符串作为Key值最为常用,整型Hash函数直接作为
    //默认模板,而字符串Hash函数直接特化
    template <>
    struct HashFunc<string>
    {   
        //BKDR
        size_t operator()(const string& s)
        {
            size_t hashi = 0;
            for (auto ch : s)
            {
                hashi = hashi * 131 + ch;
            }

            return hashi;
        }
    };

    template <class K, class V, class Hash = HashFunc<K>>
    class HashTable
    {
        typedef HashNode<K, V> Node;
    public:
        //析构函数
        ~HashTable()
        {
            for (auto& cur : _tables)
            {
                while (cur)
                {
                    Node* next = cur->_next;
                    delete cur;
                    cur = next;
                }

                cur = nullptr;
            }
        }
        //Find函数实现
        Node* Find(const K& key)
        {
            //如果哈希表本身为空
            if (_tables.size() == 0)
            {
                return nullptr;
            }
            Hash hash;
            //找到对应的数组下标
            size_t hashi = hash(key) % _tables.size();
            Node* cur = _tables[hashi];
            //遍历链表
            while (cur)
            {
                if (cur->_kv.first == key)
                    return cur;

                cur = cur->_next;
            }

            return nullptr;
        }
        //Erase函数实现
        bool Erase(const K& key)
        {
            //如果哈希表本身为空
            if (_tables.size() == 0)
            {
                return false;
            }

            //先确定对应的下标
            size_t hashi = key % _tables.size();
            Node* prev = nullptr;
            Node* cur = _tables[hashi];
            while (cur)
            {   
                //假设找到对应要删除的元素
                if (cur->_kv.first == key)
                {
                    //假设就为头节点,直接调整头节点
                    if (prev == nullptr)
                    {
                        _tables[hashi] = cur->_next;
                    }
                    //不为头节点,让prev指向cur的下一个节点即可
                    else
                    {
                        prev->_next = cur->_next;
                    }

                    //删除当前节点
                    delete cur;

                    return true;
                }
                //没有找到对应要删除的元素,cur和prev往后移动
                else
                {
                    prev = cur;
                    cur = cur->_next;
                }

            }

            return false;
        }
        //insert函数实现
        bool insert(const pair<K, V>& kv)
        {
            //如果找到对应相同的元素,则直接返回false
            if (Find(kv.first))
                return false;

            Hash hash;
            //哈希表长度为0或负载因子为1时扩容,尽量让每条链不超过三个节点
            if (_n == _tables.size())
            {
             //size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
                size_t newsize = GetNextPrime(_tables.size());
     //与闭散列不同,哈希桶扩容释放旧哈希桶,不断析构,代价还是有点大的
     //所以我们采取直接将节点,挂到新哈希桶的方法
                vector <Node*> newtables(newsize, nullptr);
                for (auto& cur : _tables)
                {
                    //只要cur不为空,则将链表挂到新哈希桶上
                    while (cur)
                    {
                        Node* next = cur->_next;
                        //找到需要挂的新位置
                        size_t hashi = hash(cur->_kv.first) % newtables.size();

                        //头插挂到新哈希桶
                        cur->_next = newtables[hashi];
                        newtables[hashi] = cur;
                        //向后一个节点移动
                        cur = next;
                    }
                }

                _tables.swap(newtables);
            }

            //正式插入代码
            //找到需要挂的新位置
            size_t hashi = hash(kv.first) % _tables.size();
            //建新节点
            Node* newnode = new Node(kv);
            //头插
            newnode->_next = _tables[hashi];
            _tables[hashi] = newnode;
            ++_n;

            return true;
        }


        size_t GetNextPrime(size_t prime)
        {
            // SGI
            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];
        }

        size_t MaxSizeBucket()
        {
            size_t max = 0;
            //遍历每一个哈希桶
            for (size_t i = 0;i < _tables.size();i++)
            {
                auto cur = _tables[i];
                size_t size = 0;
                //一直到空指针为止
                while (cur)
                {
                    size++;
                    cur = cur->_next;
                }

                printf("[%d] --> %d\n", i, size);
                //如果比最大值要大,则更新最大桶值
                if (size > max)
                {
                    max = size;
                }
            }

            return max;
        }
    private:
        //自定义类型的指针数组
        vector<Node*> _tables;
        //数组大小(有效数据个数)
        size_t _n = 0;
    };

}
相关推荐
KpLn_HJL31 分钟前
leetcode - 2139. Minimum Moves to Reach Target Score
java·数据结构·leetcode
程序员老冯头2 小时前
第十五章 C++ 数组
开发语言·c++·算法
AC使者6 小时前
5820 丰富的周日生活
数据结构·算法
cwj&xyp7 小时前
Python(二)str、list、tuple、dict、set
前端·python·算法
无 证明7 小时前
new 分配空间;引用
数据结构·c++
xiaoshiguang311 小时前
LeetCode:222.完全二叉树节点的数量
算法·leetcode
爱吃西瓜的小菜鸡11 小时前
【C语言】判断回文
c语言·学习·算法
别NULL11 小时前
机试题——疯长的草
数据结构·c++·算法
TT哇11 小时前
*【每日一题 提高题】[蓝桥杯 2022 国 A] 选素数
java·算法·蓝桥杯
ZSYP-S12 小时前
Day 15:Spring 框架基础
java·开发语言·数据结构·后端·spring