vector容器补充(下面会用到)
我们都知道vector容器不同于数组,能够进行动态扩容,其底层原理:所谓动态扩容,并不是在原空间之后接续新空间,因为无法保证原空间之后尚有可配置的空间。而是以原大小的两倍另外配置一块较大空间,然后将原内容拷贝过来,并释放原空间。因此,对 vector 的任何操作需要注意,当引起空间重新配置时,指向原 vector 的所有迭代器就都失效,因为此时迭代器仍然指向的是原空间的地址,原空间已经被释放,迭代器就是个野指针,所以一旦引起空间重新配置,对迭代器一定要重新赋值
问题来了,容器满后,是我每添加一个数据,就需要重新创建容器扩充一次吗??
答案当然是否定的,我们看图说话,我们可以观察到,当容器满了需要扩容的时候,并不是只增加一个空间,而是会多扩几个空间,以防下次再次增加数据,具体扩多少由底层决定,我们不需要关心
![](/i/l/?n=23&i=blog/2976263/202212/2976263-20221224101202818-1845079420.png)
这样做时有好处的,因为当数据量比较少的时候,将旧容器拷贝至新容器可能会很快,当时当数据量比较大的时候呢??这种扩充很浪费资源。这可能算是利用空间换时间的例子吧。
这样虽然有好处,但是也有弊端。举个例子,如下图:
![](/i/l/?n=23&i=blog/2976263/202212/2976263-20221224101224387-217214605.png)
当我们将一个容量为一百万的容器,使用 resize(5) 函数重新指定大小为5的时候,它并不是向上图一样,就剩下5个空间,将其余空间释放掉,是错误的,而是所有的空间都存在,只是采用了一种特殊的手段,无法让我们访问后面的空间罢了,对比之下我们应该猜的出,resize()做的操作大概是直接修改了 数据量 大小,让我不能访问后面的数据,而不是释放空间
swap()可以解决这个问题
用法:vector<int>(v).swap(v); //v是容器名
解释:首先,vector(v)这条语句,通过拷贝构造函数创建了一个匿名对象,这个匿名对象拥有v的全部数据,但是,没有空闲的空间,也就是说,这个匿名对象的容量和数据量是相等的。如下图:
![](/i/l/?n=23&i=blog/2976263/202212/2976263-20221224101313276-2073640681.png)
所以经过 vector(v) 创建出来的匿名对象是没有空闲空间的,此时,通过 swap(v) 调用该匿名对象的swap()方法,交换v与匿名对象的内容,结果如下:
![](/i/l/?n=23&i=blog/2976263/202212/2976263-20221224101326763-42541109.png)
我们都知道匿名对象在执行完代码之后会自动调用析构函数,那么空间被释放,最终结果就是,原容器中的空位被释放,swap就是这么强大
总结:
-
push_back扩容机制:当push_back一个元素时,
- 如果发现size() == capacity(),那么会以两倍空间扩容,然后将元素插入到finish迭代器的下一个元素(注意会申请一个新的空间,并将老的元素拷贝到新空间中,然后释放老的空间)
- 如果发现size() < capacity(),那么会插入到finish迭代器的下一个元素
- 如果发现size() > capacity(),永远不可能出现这样的情况
-
pop_back、earse、clear缩容机制:
- pop_back会减少一个size(),但是不会改变capacity() 【finish迭代器前移一位】
- earse会减少一个size(),但是不会改变capacity() 【finish迭代器前移一位】
- clear令size()为0,但是不会改变capacity()(将finish迭代器移动到start相同位置)
-
对于resize(new_size)
- 如果new_size== curr.size,什么也不做
- 如果new_size< curr.size, 那么 curr.size = new_size,curr.capacity不变
- 如果new_size> curr.size, 那么 curr.size = new_size,curr.capacity = new_size,将容器capacity 扩大到能容纳new_size的大小,改变容器的curr.size,并且创建对象。
-
对于reserve(new_size)
- 如果new_size== curr.size,什么也不做
- 如果new_size< curr.size,什么也不做
- 如果new_size> curr.size,curr.size不变,curr.capacity=new_size,将容器capacity 扩大到能容纳new_size的大小,在空间内不真正创建对象,所以不改变curr.size
所以所谓的缩容操作,并不是真正意义上的缩容,没有做任何与内存释放相关的工作,而仅仅是进行了逻辑数据的处理,仅仅是做了迭代器的前移。这一点事实也是可以理解的,clear操作是要把容器清空,只要在数据层面它能对外展示的信息为空,然后对它的访问都基于该空间信息,比如按照索引读取和写入等操作,这些只要能基于正确的空间数据,那么我们完全没必要再去释放内存,释放内存这一步只需要等容器最终被销毁的时候一起做就可以了,“数据还在那里啊?”,在那里你访问不到跟不存在有什么区别呢,它已经是编外的孤魂野鬼,不必搭理,最后佛祖会收拾的~~。
那么我们如何做到真正的释放内存呢?
- 上面介绍的swap
#include<iostream>
#include<vector>
using namespace std;
int main()
{
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
v.push_back(5);
cout << "size:" << v.size() << endl;
cout << "capacity:" << v.capacity() << endl;
vector<int>().swap(v);
cout << "after swap size:" << v.size() << endl;
cout << "after swap capacity:" << v.capacity() << endl;
return 0;
}
//输出:
size:5
capacity:6
after swap size:0
after swap capacity:0
- 在C++11中新增了shink_to_fit()用于指导缩减内存空间,但不强制要求调用之后capacity()==size()。各个库提供方可以用自己的策略判断是否需要将数据迁移到较小空间
#include<iostream>
#include<vector>
using namespace std;
int main()
{
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
v.push_back(5);
cout << "size:" << v.size() << endl;
cout << "capacity:" << v.capacity() << endl;
v.clear();
v.shrink_to_fit();
cout << "after swap size:" << v.size() << endl;
cout << "after swap capacity:" << v.capacity() << endl;
return 0;
}
//输出:
size:5
capacity:6
after swap size:0
after swap capacity:0
哈希概念
不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(HashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素,其中哈希方法中用到的转换函数称为哈希函数,构造出来的结构叫哈希表(散列表)
下面是该结构中插入元素和搜索元素的方法(时间复杂度都可以达到O(1)):
- 插入元素: 根据待插入元素的关键码,通过哈希函数计算出该元素的存储位置,并按此位置进行存放
- 查找元素: 对要查找的元素的关键码用样的计算方法得出该元素的存储位置,然后与该位置的元素进行比较,相同就表示查找成功
哈希函数
常见的有以下几种:
- 直接定制法: 取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B,其中A和B为常数
优点: 简单,均匀
缺点: 需要事先知道关键字的分布情况,如果关键字分布很散(范围很大),就需要浪费很多的空间
使用范围: 关键字分布范围小且最好连续的情况 - 除留余数法: 取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key % p,p<=m(p的选择很重要,一般取素数或m)
优点: 可以将范围很大的关键字都模到一个范围内
缺点: 对p的选择很重要
使用范围: 关键字分布不均匀 - 平方取中法(不常用): 取关键字平方后的中间几位作为散列地址
- 随机数法(不常用): 选择一随机函数,取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合
- 折叠法(不常用): 将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址
哈希冲突
看下面一个例子:
有一组元素{0,1,3,15,9}用哈希的方式存放,其中哈希函数是Hash(key)=key%10 (存放后的结果如下)
![](/i/l/?n=23&i=blog/2976263/202212/2976263-20221224101410667-96158047.png)
用这种方式存储和查找数据显然很快,但是如果此时插入一个元素5,它应该放在那个位置?
Hash(5) = 5%10 = 5,但是3这个位置中已经有元素5,难道我们要选择覆盖元素9吗?
显然这样是不妥的。(后面有解决的方法)
总结: 不同关键字通过相同的哈希函数计算出相同的哈希地址, 这里的这种现象称为哈希冲突或哈希碰撞
负载因子以及增容
哈希冲突出现的较为密集,往往代表着此时数据过多,而能够映射的地址过少,而要想解决这个问题,就需要通过 负载因子(装填因子) 的判断来进行增容
负载因子的大小 = 表中数据个数 / 表的容量(长度)
对于闭散列
对于闭散列来说,因为其是一种线性的结构,所以一旦负载因子过高,就很容易出现哈希冲突的堆积,所以当负载因子达到一定程度时就需要进行增容,并且增容后,为了保证映射关系,还需要将数据重新映射到新位置。
经过算法科学家的计算, 负载因子应当严格的控制在 0.7-0.8 以下,所以一旦负载因子到达这个范围,就需要进行增容。
因为除留余数法等方法通常是按照表的容量来计算,所以科学家的计算,当对一个质数取模时,冲突的几率会大大的降低,并且因为增容的区间一般是 1.5-2 倍,所以算法科学家列出了一个增容质数表,按照这样的规律增容,冲突的几率会大大的降低。
这也是 STL 中 unordered_map/unordered_set 使用的增容方法
//算法科学家总结出的一个增容质数表,按照这样增容的效率更高
const int PRIMECOUNT = 28;
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
};
hashmap 的负载因子为什么默认是 0.75 ?
比如说当前的容器容量是 16,负载因子是 0.75,16*0.75=12,也就是说,当容量达到了 12 的时候就会进行扩容操作。而负载因子定义为 0.75 的原因是:
- 当负载因子是 1.0 的时候,也就意味着,只有当散列地址全部填充了,才会发生扩容。意味着随着数据增长,最后势必会出现大量的冲突,底层的红黑树变得异常复杂。虽然空间利用率上去了,但是查询时间效率降低了
- 负载因子是 0.5 的时候,这也就意味着,当数组中的元素达到了一半就开始扩容。虽然时间效率提升了,但是空间利用率降低了。 诚然,填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。但是,这时候空间利用率就会大大的降低,原本存储 1M 的数据,现在就意味着需要 2M 的空间
对于开散列结构
因为哈希桶是开散列的链式结构,发生了哈希冲突是直接在对应位置位置进行头插,而桶的个数是固定的,而插入的数据会不断增多,随着数据的增多,就可能会导致某一个桶过重,使得效率过低。
所以最理想的情况,就是每个桶都有一个数据。这种情况下,如果往任何一个地方插入,都会产生哈希冲突,所以当数据个数与桶的个数相同时,也就是负载因子为 1 时就需要进行扩容。
哈希冲突的解决
闭散列
概念
闭散列: 也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去(下面介绍两种寻找空位置的方式)。
两种寻找空位置的方法:
1.线性探测: 从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。在上面哈希冲突的场景中,插入元素3时,因为此时的位置被占了,所以元素3选择下一个空位置,就是下标为4的位置
思考下面几个问题:
- 如何实现插入元素?
先通过哈希函数确定待插入元素的位置,如果该位置为空,直接插入,如果不为空就需要通过线性探测寻找下一个位置,如下面动图所示:
![](/i/l/?n=23&i=blog/2976263/202212/2976263-20221224101433317-655813506.gif)
- 如何实现删除元素?
先通过哈希函数确定待删除元素的起始位置,然后线性探测往后找到要删除元素,此时不可以直接把这个元素删除,否则会影响到其它元素的搜索。所以这里对每个位置状态进行了标记,EMPTY(空) 、EXITS(存在) 和 DELETE(删除) 三种状态,用DELETE标记删除的位置(这是一种伪删除的方式)
为什么不能直接删除?我们来看图结束
![](/i/l/?n=23&i=blog/2976263/202212/2976263-20221224101446208-564307917.png)
显然,这种删除方式会影响后期元素的查找,所以我们采用三种状态记录每个位置的状态,只有为空才结束元素的查找,具体操作如下
- 如何查找元素?
先通过哈希函数确定待查找元素的起始位置,然后线性探测往后找,如果当前位置不为DELETE 就继续往后找,直到当前位置为EMPTY,就停止查找表示该元素不存在;当前位置为EXIT 就进行比较,一样就查找成功,否则去下一个位置;如果当前位置为DELETE,就继续往下探测
- 何时增容?
要注意的是,哈希表不能满了才增容,这样会导致哈希冲突的概率增大。哈希表中有一个衡量哈希表负载的量,叫负载因子。负载因子(Load Factor) = 数据个数/哈希表大小。
一般我们选择负载因子为0.7-0.8的时候开始增容,如果这个值选取太小,会导致空间浪费;如果这个值选取太大,会导致哈希冲突的概率变大
2.二次探测:
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H(i) = H(0) + i^2。其中:i = 1,2,3…, 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小
增容问题:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容
总结:线性探测的优点是实现起来很简单,缺点就是会有累加效应(我的位置如果被占了,那么我就占别人的位置);二次探测的优点减轻了累加效应,因为哈希冲突的时候抢占的位置会在相对远一点的地方,这样元素排列就相对稀疏了。闭散列最大的缺陷就是空间利用率不高,这同样也是哈希的缺陷
哈希表闭散列的实现(采用线性探测)
整体框架
概念:这里采用线性探测的方式构建哈希表,下面是整体框架,其中模板参数第一个是key关键字,第二个是哈希表存储的元素的数据类型,可以是K,也可以是pair<K,V>类型,主要就是为了同时实现K模型和KV模型。第三个参数就是一个仿函数,为了获取T中K的值,这里要实现两个仿函数,一个是对K模型,一个是对KV模型。这里其实和上一篇博客中通过改造红黑树同时实现map和set容器的方式是一样的。哈希表底层我们借用vector容器来实现。
哈希表数据存什么?
用一个类组织起来,里面有每个位置的状态和每个位置存放的元素
template<class K, class V>
struct KeyOfvalue
{
const K& operator()(const K& key)
{
return key;
}
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
//状态标志位
enum State
{
EMPTY,
DELETE,
EXITS
};
template<class T>
struct HashData
{
T data;
State state;
};
template<class K,class T,class KOFV>
class HashTable
{
typedef HashData<T> HashData;
public:
private:
vector<HashData> tables;
size_t num = 0;//记录已经存放了多少个数据
};
插入元素
有以下几个步骤:
- 一:先判断负载因子是否大于0.7,如果大于0.7,就要考虑增容(下面详细介绍);否则就直接插入
- 二:用哈希函数计算出要插入的元素的起始位置,然后找空位置(状态为EMPTY和DELETE),然后进行插入,并把状态改为EXITS(这里不用担心没有空位置,因为哈希表不可能满,他不是满了才增容的)
- 三:如果此过程中发现要插入的元素存在,则返回FALSE代表元素插入失败;否则返回TRUE
增容问题: 我们需要把原来空间中的元素全部转移到新的空间中,此过程相当于往新空间重新插入元素,且要对它们进行重新定位
一般有以下两种方法:
-
直接开一个新的vector(大小为增容后空间的大小),然后一个元素一个元素地进行转移,最后把哈希表中的vector和新的vector进行交换,让这个新的vector带走旧空间,并清理资源
-
创建一个临时的哈希表,然后把vector成员的空间设置为增容后空间的大小,然后复用insert函数方法,对旧表中元素进行转移,最后新表和旧表的vector进行交换。(这里其实和上面的方法区别就在这里对insert进行了复用,且都用到了利用临时对象的析构函数清理旧空间的资源)
代码实现如下:
bool Insert(const T& data)
{
KOFV kofv;
// 哈希表不能满了在增容,这样会导致哈希冲突的概率增大
// 不能太小,太小会导致空间浪费;也不能太大,太大会导致哈希冲突的概率很大
// 负载因子(Load Factor)等于0.7就增容 num/tables.size()>=0.7
// 负载因子 = 数据个数/哈希表大小
if (tables.size() == 0 || 10 * num / tables.size() >= 7)
{
//创建一个新的vector容器
vector<HashData> newtables;
size_t newsz = tables.size() == 0 ? 10 : tables.size() * 2;
//给新的vector设置的大小
newtables.resize(newsz);
// 先把旧表的数据重新放到新表中
// 因为表的大小发生变化,所以数据在旧表中的位置和新表的位置不一样,需要重新调整
// 写法1
for (size_t i = 0; i < tables.size(); i++)
{
//for循环内就是把旧表中的数据放到新表中,并重新分配位置
if (tables[i].state == EXITS)
{
int index = kofv(tables[i].data) % newsz;
while (newtables[index].state == EXITS)
{
// 不会存在重复数据,因为旧表中不可能有重复的数据
++index;
if (index == newsz)
{
index = 0;
}
}
newtables[index] = tables[i];
}
}
tables.swap(newtables);// 把临时空间和旧空间进行交换,交换后,旧空间的将由临时对象的析构函数来释放
// 写法2
/*
HashTable<K, T, KOFV> newht;
size_t newsz = tables.size() == 0 ? 10 : tables.size() * 2;
newht._tables.resize(newsz);
for (size_t i = 0; i < tables.size(); ++i)
{
if (tables[i].state == EXITS)
{
newht.Insert(tables[i].data);
}
}
tables.swap(newht.tables);
*/
}
int index = kofv(data) % tables.size();
/*二次探测
int start = index;
int i = 1;
*/
while (tables[index].state == EXITS)
{
if (tables[index].data == data)
{
return false;
}
//二次探测
/*
index = start + pow(i,2);
index %= tables.size();
++i;
*/
//线性探测
++index;
//走到末尾
if (index == tables.size())
{
index = 0;
}
}
//DELETE和EMPTY都可以直接插入
tables[index].data = data;
tables[index].state = EXITS;
++num;
return true;
}
查找元素
前面介绍过了,先通过哈希函数确定待查找元素的起始位置,然后线性探测往后找,如果当前位置不为DELETE 就继续往后找,直到当前位置为EMPTY,就停止查找表示该元素不存在;当前位置为EXIT 就进行比较,一样就查找成功,否则去下一个位置;如果当前位置为DELETE,就继续往下探测
代码实现如下:
//查找元素
HashData* Find(const K& key)
{
KOFV kofv;
int index = key % tables.size();
int start = index;//标志位,寻找一遍的标志位
while (tables[index].state != EMPTY)
{
if (kofv(tables[index].data) == key)
{
if (tables[index].state == EXITS)
{
return &tables[index];
}
//tables[index].state == DELETE
//这表情要找的元素被删除了
else
{
return nullptr;
}
}
++index;
if (index == tables.size())
{
index = 0;
//找完了一遍没有就退出
if (index == start)
{
return nullptr;
}
}
}
return nullptr;
}
删除元素
前面介绍过了,这里不多说,比较简单
代码实现如下:
//删除元素
bool Erase(const K& key)
{
HashData* ret = Find(key);
if (ret != nullptr)
{
ret->state = DELETE;
num--;
return true;
}
else
{
return false;
}
}
完整代码
template<class K, class V>
struct KeyOfvalue
{
const K& operator()(const K& key)
{
return key;
}
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
namespace CLOSE_HASH
{
//状态标志位
enum State
{
EMPTY,
DELETE,
EXITS
};
template<class T>
struct HashData
{
T data;
State state;
};
template<class K, class T, class KOFV>
class HashTable
{
typedef HashData<T> HashData;
public:
bool Insert(const T& data)
{
KOFV kofv;
// 哈希表不能满了在增容,这样会导致哈希冲突的概率增大
// 不能太小,太小会导致空间浪费;也不能太大,太大会导致哈希冲突的概率很大
// 负载因子(Load Factor)等于0.7就增容 num/tables.size()>=0.7
// 负载因子 = 数据个数/哈希表大小
if (tables.size() == 0 || 10 * num / tables.size() >= 7)
{
//创建一个新的vector容器
vector<HashData> newtables;
size_t newsz = tables.size() == 0 ? 10 : tables.size() * 2;
//给新的vector设置的大小
newtables.resize(newsz);
// 先把旧表的数据重新放到新表中
// 因为表的大小发生变化,所以数据在旧表中的位置和新表的位置不一样,需要重新调整
// 写法1
for (size_t i = 0; i < tables.size(); i++)
{
//for循环内就是把旧表中的数据放到新表中,并重新分配位置
if (tables[i].state == EXITS)
{
int index = kofv(tables[i].data) % newsz;
while (newtables[index].state == EXITS)
{
// 不会存在重复数据,因为旧表中不可能有重复的数据
++index;
if (index == newsz)
{
index = 0;
}
}
newtables[index] = tables[i];
}
}
tables.swap(newtables);// 把临时空间和旧空间进行交换,交换后,旧空间的将由临时对象的析构函数来释放
// 写法2
/*
HashTable<K, T, KOFV> newht;
size_t newsz = tables.size() == 0 ? 10 : tables.size() * 2;
newht._tables.resize(newsz);
for (size_t i = 0; i < tables.size(); ++i)
{
if (tables[i].state == EXITS)
{
newht.Insert(tables[i].data);
}
}
tables.swap(newht.tables);
*/
}
int index = kofv(data) % tables.size();
/*二次探测
int start = index;
int i = 1;
*/
while (tables[index].state == EXITS)
{
if (tables[index].data == data)
{
return false;
}
//二次探测
/*
index = start + pow(i,2);
index %= tables.size();
++i;
*/
//线性探测
++index;
//走到末尾
if (index == tables.size())
{
index = 0;
}
}
//DELETE和EMPTY都可以直接插入
tables[index].data = data;
tables[index].state = EXITS;
++num;
return true;
}
//查找元素
HashData* Find(const K& key)
{
KOFV kofv;
int index = key % tables.size();
int start = index;//标志位,寻找一遍的标志位
while (tables[index].state != EMPTY)
{
if (kofv(tables[index].data) == key)
{
if (tables[index].state == EXITS)
{
return &tables[index];
}
//tables[index].state == DELETE
//这表情要找的元素被删除了
else
{
return nullptr;
}
}
++index;
if (index == tables.size())
{
index = 0;
//找完了一遍没有就退出
if (index == start)
{
return nullptr;
}
}
}
return nullptr;
}
//删除元素
bool Erase(const K& key)
{
HashData* ret = Find(key);
if (ret != nullptr)
{
ret->state = DELETE;
num--;
return true;
}
else
{
return false;
}
}
private:
vector<HashData> tables;
size_t num = 0;//记录已经存放了多少个数据
};
void TestHashTable1()
{
HashTable<int, int, KeyOfvalue<int, int>> ht;
// HashTable<int, pair<int, int>, KeyOfValue<int, int>> ht;
int arr[] = { 10,20,14,57,26,30,49,72,43,55,82 };
for (auto e : arr)
{
if (e == 72)
{
int a = 0;
}
ht.Insert(e);
}
for (auto e : arr)
{
ht.Erase(e);
}
}
}
开散列
概念
开散列法: 又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。(如下图)
![](/i/l/?n=23&i=blog/2976263/202212/2976263-20221224101544677-179522545.png)
注意: 开散列中每个桶放的都是哈希冲突的元素。哈希桶下面挂着的是一个一个的节点(一条链表),如果该位置哈希冲突的元素过多时,我们会选择在这里挂一颗红黑树
哈希表开散列实现(整数版本哈希桶)
整体框架
哈希桶下面挂着的是一个一个的节点(一条链表),也就是每个位置存放链表头节点的地址。这里和开散列一样,我们还是用vector来存放元素。模板参数列表中前三个就不过多介绍,和闭散列是一样的,第四个参数后面介绍
template<class K, class V>
struct KeyOfvalue
{
const K& operator()(const K& key)
{
return key;
}
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
template <class T>
struct HashNode
{
HashNode(const T& data):data(data),next(nullptr){}
T data;
HashNode<T>* next;
};
template <class K,class T,class KOFV,class Hash>
class HashBucket
{
typedef HashNode<T> Node;
public:
private:
vector<Node*> table;
int num = 0;//记录表中的数据个数
};
插入元素
有以下几个步骤:
- 先根据元素个数考虑增容问题(下面详细介绍)
- 再通过哈希函数确定关键字的位置,然后把节点挂到这个桶下面(可以是链表的头,也可以是链表的尾部)
增容问题: 当哈希桶中元素个数打的一定个数时,就要增容,否则哈希冲突的概率会变得,且时间复杂度会下降的很快。所以,哈希桶一般是在元素个数等于桶的大小,也就是负载因子为1时,就开始增容。
- 先遍历一遍哈希桶的每个位置,然后对旧桶上的元素节点进行转移
- 最后插入新节点
代码实现如下:
bool Insert(const T& data)
{
KOFV kofv;
//插入之前,判断是否需要增容,负载因子为1就增容
if (num == tables.size())
{
vector<Node*> newtables;
size_t newsize = tables.size() == 0 ? 10 : 2 * tables.size();
newtables.resize(newsize);
for (size_t i = 0; i < tables.size(); i++)
{
Node* prev = nullptr;
Node* cur = tables[i];
//把一个位置的所有节点转义,然后换下一个位置
while (cur)
{
//记录下一个节点的位置
Node* next = cur->next;
int index = HashFunc(kofv(cur->data)) % newtables.size();
//把cur连接到新的表上,头插法
cur->next = newtables[index];
newtables[index] = cur;
cur = next;// cur会发生变化,需要提前记录next
}
}
tables.swap(newtables);
}
int index = HashFunc(kofv(data)) % tables.size();
//先查找该条链表上是否有要插入的元素
Node* cur = tables[index];
while (cur)
{
if (kofv(cur->data) == kofv(data))
{
return false;
}
cur = cur->next;
}
//插入数据,选择头插,要注意的是,插入的元素必须在堆中创建,不能被释放
Node* newnode = new Node(data);
newnode->next = tables[index];
tables[index] = newnode;
num++;
return true;
}
查找元素
步骤:
- 先确定要查找的元素在哪个桶
- 然后在该桶下的链表对元素进行查找
代码实现如下:
Node* Find(const K& key)
{
KOFV kofv;
int index = HashFunc(key) % tables.size();
Node* cur = tables[index];
while (cur)
{
if (key == cur->data)
{
return cur;
}
cur = cur->next;
}
return nullptr;
}
删除元素
步骤:
- 先找到元素
- 然后对元素节点进行删除,没找到就删除失败
代码实现如下:
bool Erase(const K& key)
{
KOFV kofv;
int index = HashFunc(key) % tables.size();
Node* prev = nullptr;
Node* cur = tables[index];
while (cur)
{
//如果找到了元素
if (key == kofv(cur->data))
{
//找到了元素,并且就是链表中第一个节点的元素
if (prev == nullptr)
{
tables[index] = cur->next;
}
else
{
prev->next = cur->next;
}
num--;
delete cur;
return true;
}
prev = cur;
cur = cur->next;
}
return false;
}
完整代码
template<class K, class V>
struct KeyOfvalue
{
const K& operator()(const K& key)
{
return key;
}
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
namespace OPEN_HASH
{
template <class T>
struct HashNode
{
HashNode(const T& data):data(data),next(nullptr){}
T data;
HashNode<T>* next;
};
template <class K,class T,class KOFV,class Hash>
class HashBucket
{
typedef HashNode<T> Node;
public:
bool Insert(const T& data)
{
KOFV kofv;
//插入之前,判断是否需要增容,负载因子为1就增容
if (num == tables.size())
{
vector<Node*> newtables;
size_t newsize = tables.size() == 0 ? 10 : 2 * tables.size();
newtables.resize(newsize);
for (size_t i = 0; i < tables.size(); i++)
{
Node* prev = nullptr;
Node* cur = tables[i];
//把一个位置的所有节点转义,然后换下一个位置
while (cur)
{
//记录下一个节点的位置
Node* next = cur->next;
int index = HashFunc(kofv(cur->data)) % newtables.size();
//把cur连接到新的表上,头插法
cur->next = newtables[index];
newtables[index] = cur;
cur = next;// cur会发生变化,需要提前记录next
}
}
tables.swap(newtables);
}
int index = HashFunc(kofv(data)) % tables.size();
//先查找该条链表上是否有要插入的元素
Node* cur = tables[index];
while (cur)
{
if (kofv(cur->data) == kofv(data))
{
return false;
}
cur = cur->next;
}
//插入数据,选择头插,要注意的是,插入的元素必须在堆中创建,不能被释放
Node* newnode = new Node(data);
newnode->next = tables[index];
tables[index] = newnode;
num++;
return true;
}
Node* Find(const K& key)
{
KOFV kofv;
int index = HashFunc(key) % tables.size();
Node* cur = tables[index];
while (cur)
{
if (key == cur->data)
{
return cur;
}
cur = cur->next;
}
return nullptr;
}
bool Erase(const K& key)
{
KOFV kofv;
int index = HashFunc(key) % tables.size();
Node* prev = nullptr;
Node* cur = tables[index];
while (cur)
{
//如果找到了元素
if (key == kofv(cur->data))
{
//找到了元素,并且就是链表中第一个节点的元素
if (prev == nullptr)
{
tables[index] = cur->next;
}
else
{
prev->next = cur->next;
}
num--;
delete cur;
return true;
}
prev = cur;
cur = cur->next;
}
return false;
}
private:
vector<Node*> tables;
int num = 0;//记录表中的数据个数
};
}
字符串哈希(最终版本哈希桶)
在上面的哈希桶中,只能存放key为整形的元素,这个问题应该如何解决呢?
答案:我们上面哈希函数采用除留余数法,key必须为整形才可以进行处理。所以我们需要采取一些措施,将这些key转为整形
字符串哈希函数
因为哈希函数的常用方法如直接定址、除留余数、平方取中等方法需要用的 key值为整型,而大部分时候我们的 key 都是 string,由于无法对 string 进行算数运算,所以需要考虑新的方法。
常见的字符串哈希算法有 BKD、SDB、RS 等,这些算法大多通过一些公式来对字符串每一个 字符的 ascii值 或者 字符串的大小 进行计算,来推导出一个不容易产生冲突的 key值 ,下面是一些字符串转换整数的Hash函数的比较: 戳这里
我们选择上面的一种,来进行使用。
实现如下: 因为较多情况下,key都是可以取模的,所以哈希桶的模板参数列表中选择直接返回key的函数作为缺省参数。有因为字符串哈希用的也比较多,所以这里对key为string类型进行一个特化
template<class K>
struct _Hash
{
// 大多树的类型就是是什么类型就返回什么类型
const K& operator()(const K& key)
{
return key;
}
};
// 特化string
template<>
struct _Hash<string>
{
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;
}
};
我们再实现一个哈希函数,里面是对key进行对应地转换,然后返回整形。
实现如下:
size_t HashFunc(const K& key)
{
Hash hash;
return hash(key);
}
最终版本代码(整型字符串型均适用)
#define _CRT_SECURE_NO_WARNINGS
#include<iostream> //引入头文件
#include<vector>
#include<algorithm>
using namespace std; //标准命名空间
//算法科学家总结出的一个增容质数表,按照这样增容的效率更高
const int PRIMECOUNT = 28;
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
};
template<class K, class V>
struct KeyOfvalue
{
const K& operator()(const K& key)
{
return key;
}
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
namespace Open_Hash
{
template<class T>
struct HashNode
{
T _data;
HashNode<T>* _next;
HashNode(const T& data)
:_data(data)
, _next(nullptr)
{}
};
// 前置声明
template<class K, class T, class KOFV, class Hash = _Hash<K>>
class HashBucket;
template<class K, class T, class Ref, class Ptr, class KOFV, class Hash>
struct __HashBucket_Iterator
{
typedef __HashBucket_Iterator<K, T, Ref, Ptr, KOFV, Hash> Self;
typedef HashNode<T> Node;
typedef HashBucket<K, T, KOFV, Hash> HashBucket;
Node* _node;
HashBucket* _phb;
//Node* _node;
//int _index;// 记录此时迭代器在表中那个位置
//vector<Node*>& _tables;
//__HashBucket_Iterator(Node* node, int index, vector<Node*>& tables)
// :_node(node)
// ,_index(index)
// ,_tables(tables)
//{}
__HashBucket_Iterator(Node* node, HashBucket* phb)
:_node(node)
, _phb(phb)
{}
Ref operator*()
{
return _node->_data;
}
Ptr operator->()
{
return &_node->_data;
}
Self& operator++()
{
if (_node->_next)
{
_node = _node->_next;
return *this;
}
else
{
KOFV kofv;
int index = _phb->HashFunc(kofv(_node->_data)) % _phb->_tables.size();
for (size_t i = index + 1; i < _phb->_tables.size(); ++i)
{
if (_phb->_tables[i])
{
_node = _phb->_tables[i];
return *this;
}
}
_node = nullptr;
return *this;
}
}
bool operator==(const Self& self) const
{
return _node == self._node
&& _phb == self._phb;
}
bool operator!=(const Self& self) const
{
return !this->operator==(self);
}
};
template<class K>
struct _Hash
{
// 大多树的类型就是是什么类型就返回什么类型
const K& operator()(const K& key)
{
return key;
}
};
// 特化string
template<>
struct _Hash<string>
{
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;
}
};
// string类型用的比较多,所以就特化一个版本出来
template<class K, class T, class KOFV, class Hash>
class HashBucket
{
typedef HashNode<T> Node;
friend struct __HashBucket_Iterator<K, T, T&, T*, KOFV, Hash>;
public:
typedef __HashBucket_Iterator<K, T, T&, T*, KOFV, Hash> iterator;
iterator begin()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i] != nullptr)
return iterator(_tables[i], this);// 哈希桶的第一个节点
}
return end();// 没有节点返回最后一个迭代器
}
iterator end()
{
return iterator(nullptr, this);
}
~HashBucket()
{
Clear();
}
void Clear()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
}
}
size_t HashFunc(const K& key)
{
Hash hash;
return hash(key);
}
pair<iterator, bool> Insert(const T& data)
{
KOFV kofv;
// 负载因子为1时就增容
if (_num == _tables.size())
{
vector<Node*> newtables;
//size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
size_t newsize = GetNextPrime(_tables.size());
newtables.resize(newsize);
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* prev = nullptr;
Node* cur = _tables[i];
// 把一个位置的所有节点转移,然后换下一个位置
while (cur)
{
// 记录下一个节点的位置
Node* next = cur->_next;
int index = HashFunc(kofv(cur->_data)) % newtables.size();
// 把cur连接到新的表上
cur->_next = newtables[index];
newtables[index] = cur;
cur = next;// cur会发生变化,需要提前记录next
}
}
_tables.swap(newtables);
}
int index = HashFunc(kofv(data)) % _tables.size();
// 先查找该条链表上是否有要插入的元素
Node* cur = _tables[index];
while (cur)
{
if (kofv(cur->_data) == kofv(data))
return make_pair(iterator(cur, this), false);
cur = cur->_next;
}
// 插入数据,选择头插(也可以尾插)
Node* newnode = new Node(data);
newnode->_next = _tables[index];
_tables[index] = newnode;
++_num;
return make_pair(iterator(newnode, this), true);
}
iterator Find(const K& key)
{
KOFV kofv;
int index = HashFunc(key) % _tables.size();
Node* cur = _tables[index];
while (cur)
{
if (key == kofv(cur->_data))
{
return iterator(cur, this);
}
cur = cur->_next;
}
return iterator(nullptr);
}
bool Erase(const K& key)
{
KOFV kofv;
int index = HashFunc(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[index];
while (cur)
{
if (key == kofv(cur->_data))
{
// 删第一个节点时
if (prev == nullptr)
{
_tables[index] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
--_num;
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
size_t GetNextPrime(size_t prime)
{
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
//返回比那个数大的下一个质数
if (primeList[i] > prime)
return primeList[i];
}
//如果比所有都大,还是返回最后一个,因为最后一个已经是32位最大容量
return primeList[PRIMECOUNT - 1];
}
private:
vector<Node*> _tables;
int _num = 0;// 记录表中的数据个数
};
void TestHashBucket1()
{
HashBucket<int, int, KeyOfvalue<int, int>> ht;
int arr[] = { 15,23,57,42,82,26,30,49,72,43,55 };
for (auto e : arr)
{
ht.Insert(e);
}
for (auto e : arr)
{
HashBucket<int, int, KeyOfvalue<int, int>>::iterator it = ht.begin();
while (it != ht.end())
{
cout << *it << " ";
++it;
}
cout << endl;
ht.Erase(e);
}
}
void TestHashBucket2()
{
HashBucket<string, string, KeyOfvalue<string, string>> ht;
ht.Insert("sort");
ht.Insert("pass");
ht.Insert("cet6");
HashBucket<string, string, KeyOfvalue<string, string>>::iterator it = ht.begin();
while (it != ht.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
}
int main()
{
Open_Hash::TestHashBucket1();
Open_Hash::TestHashBucket2();
system("pause");
return EXIT_SUCCESS;
}
标签:key,tables,进阶,index,cur,C++,哈希,size
From: https://www.cnblogs.com/yzsn12138/p/17002455.html