一个人为什么要努力? 我见过最好的答案就是:因为我喜欢的东西都很贵,我想去的地方都很远,我爱的人超完美。
文章目录
- 哈希表的引出
- unordered系列的关联式容器
- 底层结构
- 哈希的概念
- 开放寻址法
- 拉链法(哈希桶)
- 拉链法的结构
- 什么是拉链法
- 总结
哈希表的引出
unordered系列的关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 l o g 2 N log_2 N log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,本文中只对unordered_map和unordered_set进行介绍,unordered_multimap和unordered_multiset可查看文档介绍。
底层结构
unordered系列的查询效率高是因为底层运用了哈希结构
哈希的概念
顺序表以及平衡树中元素的关键码和数据的大小没有直接对应的关系,因此我们在顺序表和平衡树中需要对关键码或者是数据进行逐一比对,在平衡树中我们使用关键码的大小关系从而减少比对次数,而顺序表我们只能逐一对数据进行比对才可以确定我们想要的元素,我们发现查找中我们中间会因为查找大量的无关元素而浪费时间,平衡树和顺序表的区别就在于,顺序表是逐个查找而平衡树则是通过不断的判断查找的方向从而减少查询的次数。所以查找的效率主要在于能不能减少无谓的查找。
理想情况下理想的情况下的查找是不需要经过任何比对,直接通过可以直接找到数据元素的,但是这种方式是理想的我们无法做到只能尽可能的接近理想状态,那么这里就引出了我们的哈希表。
哈希表就是通过键值对和数据的映射关系从而可以在接近O(1)的时间内找到对应的数据。
那么哈希表的插入等情况是怎么进行的呢?其实就是通过特定的函数来算出该数据的关键码从而在关键码中插入。
列如我们的函数设为关键码=数据%10然后我们要插入的数值为 1,2,13,16。那么我们该如何进行插入呢其实很简单,那就是用1%10,2%10,13%10,16%10,算出来他们的关键码(关键码其实就是可以理解为要插入的数据的下标值)然后通过关键码进行存储
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
对于上面的这个方法我们减少了关键码的比较因此搜索的速度非常的快,但是这里也会有问题那就是冲突,因为我们在插入的时候是可能会出现一对多的情况的,比如说上面的(%10)我们会知道20%10,30%10都是0这里就出现了冲突也就是说不同的关键字通过相同的函数进行计算可能会得到相同的关键码,那么这里处理冲突就分为两种了,拉链法(哈希痛)和开放寻址法。
开放寻址法
首先讲解一下开放寻址法开放寻址法其实就是当前的位置产生冲突的时候就去找下一个位置,就像我们去蹲坑这个坑位有人了我们就去下一个坑位一直到最后一个坑位都有人的话我们就去第一个坑位继续往后看,这里我们会发现开放寻址法的话必须保证这个厕所有坑位,那么其实我们哈希表的底层结构中是有办法保证,他肯定有空余位置的。
这里的线段编号代表的是查找空余坑位的次数。那么用代码的表示其实就是下面这样字
bool Insert(const pair<K, V>& kv)
{
// 扩容
//if ((double)_n / (double)_table.size() >= 0.7)
if (_n*10 / _table.size() >= 7)
{
size_t newSize = _table.size() * 2;
// 遍历旧表,重新映射到新表
HashTable<K, V, HashFunc> newHT;
newHT._table.resize(newSize);
// 遍历旧表的数据插入到新表即可
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST)
{
newHT.Insert(_table[i]._kv);
}
}
_table.swap(newHT._table);
}
// 线性探测
HashFunc hf;
size_t hashi = hf(kv.first) % _table.size();
while (_table[hashi]._state == EXIST)
{
++hashi;
hashi %= _table.size();
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
上面的插入代码用了模板泛型编程我给解读一下首先哈希表的插入我们首先就是要根据数据和函数从而计算出我们的关键码,另外就是我们在插入的时候为了避免表满了的情况我们设置的会有一个值也就是(当前插入节点)/(总长度)这样一个比值,当这个比值我们一般设为0.7当比值大于等于0.7的时候我们就会对原来的哈希表进行扩容。但是这里有个问题那就是我们在进行扩容的时候由于我们在计算关键码的时候做分母的值一般为目前容器的容量那么当我们扩容后这个容器的容量就会产生变化此时已经插入进入的值在扩容后的容器中位置是会发生改变的。那么有什么好的解决方法呢?
其实很简单我们只需要再开辟一个新容器然后把原来的容器中的值插入到新容器中再让新容器与就容器进行swap一下就可以了。(上面的代码中写的有)
拉链法(哈希桶)
拉链法的结构
上面我们讲了开放寻址法,开放寻址法有什么缺点呢?他的缺点就是说我们在寻找坑位的时候可能需要我们找到末尾再从头开始找就像我们去厕所的时候会发生可能你从这个位置一直找到最后一个坑位之后再回头才发现原来第一个坑位就是空余的。因此这时候就会导致我们查找的效率较慢,那么有什么办法呢?拉链法再处理的时候就比较不错。
什么是拉链法
如果我们把开放寻址法看成一个一维数组的话那么拉链法就是一个二维的数组,我觉得用二维数组也可以很好的讲述拉链法我给大家写一个很朴素的模仿拉链法的代码大家可以看一下
#include<iostream>
using namespace std;
int num[1010][1010];//假设num是我们要插入元素的容器这里呢我们"假设!!!!"当这个位置是0的时候就代表没有元素插入
int main()
{
int N = 101;//假设我们的公式为(关键码)i=n(存储的数据)%N(101也是假设)
int n;
cin >> n;
int i = n % N;//找到了要插入的位置是第i列
for (int j = 0; j < 1010; j++)//从第i列的第一行往下找
{
if (num[j][i] == 0)
{
num[j][i] = n;
}
}
return 0;
}
正如上面的代码所示就是一个朴素的拉链法那么我们在实际中应该是什么的组合呢?相信大家不难知道我们实际上的组合应该是vector+list的组合代码如下
bool insert(const T& data)
{
HashFunc func;
if (Find(data.first))
{
return false;
}
if (_n == _table.size())//当原来的容器满了的时候
{
vector<Node*>newtable;//开辟一个新容器
size_t newsize = _n * 2;//设置新容器的容量
newtable.resize(newsize, nullptr);//开辟容器
for (int i = 0; i < _table.size(); i++)//讲原来容器中的值插入到新容器中
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = func(cur->data) % newsize;
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(newtable);//将新就容器进行swap
}
size_t hashi = func(data) % _table.size();
Node* cur = new Node(data);
cur->_next = _table[hashi];
_table[hashi] = cur;
++_n;
return true;
}
那么这里也有扩容,为什么这里也有扩容呢,是因为拉链法也有一个极端情况那就是很多数据甚至是全部数据在一条链上因此拉链法也有扩容而拉链法的扩容条件一般就是当插入元素个数与链表长度相同的时候。我们需要扩容。
总结
哈希表的优点
哈希最大的优点我相信就是哈希减少了比较的次数,从而使我们的查找效率都更加的快速。
哈希的缺点
哈希的缺点的我认为比较明显的一个就是当我们插入元素的时候可能会遇到扩容那么就会导致某一个元素插入的时候会比较慢但是总体而言利大于弊。