我们在之前一定听过哈希映射,哈希表这种神奇的东西,在这一章我们来深入探究一下,这种以空间换时间的重要思想
我们的set与map加上unordered之后,我们根据字面意思也可以进行理解,就是未排序的set与map
map/set与unordered_map/unordered_set区别与联系
1.他都可以实现key与key/value的搜索场景,并且功能与使用基本一致
2.map/set的底层是使用红黑树来进行实现的,遍历出来是有序的,增删改查时间复杂度为O(logN)
3.unordered_map/unordered_set是使用哈希表来进行实现的,遍历出来是无序的,增删改查时间复杂度为O(1)
4.map与set为双向迭代器,.unordered_map/unordered_set是单向迭代器
那么我们已经拥有了set与map这么高效的工具,为什么还要去学习unordered_map/unordered_set呢?在我们C++11,引入了unordered_map/unordered_set,事实上,尽管set与map很高效,但是当我们数据量足够大时,他们的效率也会减弱,我们的unordered_map/unordered_set采用的底层是哈希表,比红黑树性能更优,所以我们仍然需要了解unordered_map/unordered_set
我们对map/set与unordered_map/unordered_set进行性能测试
#include
#include
#include
#include
我们发现,当我们的测试用例达到1000万时,差别是很明显的,特别是查找,unordered set的时间为0(之前也是0)
unordered 系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素时,必须要经 过关键码的多次比较 。 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即 O(N ) ,搜索的效率取决于搜索过程中元素的比较次数。理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素 。 如果构造一种存储结构,通过 某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函 数可以很快找到该元素 。当向该结构中:插入元素根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放搜索元素对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
引起哈希冲突的一个原因可能是: 哈希函数设计不够合理 。 哈希函数设计原则 :哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有 m 个地址时,其值域必须在 0到 m-1 之间哈希函数计算出来的地址能均匀分布在整个空间中哈希函数应该比较简单
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) 作为哈希地址 平方取中法比较适合:不知 道关键字的分布,而位数又不是很大的情况4. 折叠法 --( 了解 )折叠法是将关键字从左到右分割成位数相等的几部分 ( 最后一部分位数可以短些 ) ,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况5. 随机数法 --( 了解 )选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random(key), 其中 random 为随机数函数。通常应用于关键字长度不等时采用此法6. 数学分析法 --( 了解 )设有 n 个 d 位数,每一位可能有 r 种不同的符号,这 r 种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前 7 位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转( 如 1234 改成 4321) 、右环位移 ( 如 1234 改成 4123) 、左环移位、前两数与后两数叠加 ( 如 1234 改成 12+34=46) 等方法。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布 较均匀的情况注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
解决哈希冲突 两种常见的方法是: 闭散列 和 开散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那 么可以把 key 存放到冲突位置中的 “ 下一个 ” 空位置中去。 那如何寻找下一个空位置呢?1. 线性探测比如 2.1 中的场景,现在需要插入元素 44 ,先通过哈希函数计算哈希地址, hashAddr 为 4 ,因此 44 理论上应该插在该位置,但是该位置已经放了值为4 的元素,即发生哈希冲突。线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止 。插入 通过哈希函数获取待插入元素在哈希表中的位置
#pragma once
#include
template
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
namespace CLOSE_HASH
{
//unordered_set->HashTable
//unorderrd_map->HashTable>
enum State
{
EMPTY,
EXITS,
DELETE,
};
template
struct HashData
{
T _Data;
State _state;
};
template
class HashTable
{
typedef HashData HashData;
public:
bool Insert(const T& d)//插入函数
{
KeyOfT koft;
//负载因子=表中数据/表的大小 衡量哈希表满的程度
//表越接近满,插入数据越容易冲突,冲突越多,效率越低
//哈希表并不是满了之后才进行扩容,开放定址法中,一般负载因子到0.7左右就开始增容
//负载因子越小,冲突概率越低,整体效率越高,但是负载因子越小,浪费空间越大
//所以负载因子应该取一个折中值,大概0.7
if (_tables.size() == 0 || _num * 10 / _tables.size() >= 7)//开辟空间的条件:负载因子>0.7或者初始大小为0
{
//开二倍大小的新表
//2.遍历旧表数据,重新计算在新表中的位置
//3.释放旧表
HashTable newht;//开新hashtable
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;//若为0,初始为10,若不为0,则扩大二倍
newht._tables.resize(newsize);//新表的大小为旧表的二倍
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXITS)//当旧表中有数据
{
newht.Insert(_tables[i]._data);//将其插入新表(Insert函数中自动计算在新表的位置)
}
}
_tables.swap(newht._tables);//交换指向新旧表的指针
//走到最后旧表出作用域自动析构
}
//线性探测
size_t index = koft(d) % _tables.size();//计算插入数据d在表中的位置(除留余数法)
while (_tables[index]._state == EXITS)//当位置上的标记为EXITS时
{
if (koft(_tables[index]._data) == koft(d))//如果表中数据与要插入的数据相同
{
return false;//插入失败
}
++index;//下标后移
if (index == _tables.size())//当下标走到了最后
{
index = 0;//置零从头循环
}
}
_tables[index]._data = d;//插入数据
_tables[index]._state = EXITS;//标记为EXITS
_num++;//有效数据+1
}
private:
vector _tables;
size_t , _num = 0;
};
}
线性探测其实就是先计算位置放入表中,若有冲突就继续向后探测,直到碰到空的位置填进去,但我们又会对表进行删除等操作,所以设置了一个标记,标记这个位置,若有数据则标记为EXITS,若开始就没有数据,则为EMPTY,当冲突数据碰到这个标记的位置是可以直接放入的,还有DELETE,顾名思义就是之前存有数据但是被删除了,也可以进行插入,总的来说,线性探测解决冲突的方式比较暴力
线性探测的查找与删除
HashData* Find(const K& key)//查找函数
{
KeyOfT koft;
// 计算d中的key在表中映射的位置
size_t index = key % _tables.size();
while (_tables[index]._state != EMPTY)//当标记不为EMPTY时
{
if (koft(_tables[index]._data) == key)//当查找的数据与此位置数据相同
{
if (_tables[index]._state == EXITS)//当标志为EXITS时
{
return &_tables[index];//返回找到的那个位置
}
else if (_tables[index]._state == DELETE)//当标记点为DELETE时
{
return nullptr;//返回空指针
}
}
++index;//向后查找
if (index == _tables.size())//当找到末尾时
{
index = 0;//将下标置0,代表未找到
}
}
return nullptr;//返回空指针
}
bool Erase(const K& key)//删除函数
{
HashData* ret = Find(key);//查找到key,将ret指针指向key所在位置
if (ret)//当ret不为空时
{
ret->_state = DELETE;//标记置DELETE
--_num;//有效字符数量-1
return true;//删除成功
}
else
{
return false;//删除失败
}
}
线性探测优点:实现非常简单,线性探测缺点: 一旦发生哈希冲突,所有的冲突连在一起,容易产生数据 “ 堆积 ” ,即:不同关键码占据 了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低 。如何缓解呢?
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法改为:(H+/-i^2)/m的方式,m为表的大小,i = 1,2,3…对于图中如果要插入44,产生冲突,使用解决后的情况为
研究表明: 当表的长度为质数且表装载因子 a 不超过 0.5 时,新的表项一定能够插入,而且任何一个位置 都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装 满的情况,但在插入时必须确保表的装载因子 a 不超过 0.5 , 如果超出必须考虑增容。因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
// 二次探测
// 计算d中的key在表中映射的位置
size_t start = koft(d) % _tables.size();//start为表中初始数据插入的位置
size_t index = start;//初始化index
int i = 1;
while (_tables[index]._state == EXITS)//当标记为EXITS
{
if (koft(_tables[index]._data) == koft(d))//数据相同插入失败
{
return false;
}
index = start + i*i;//对index进行平方位置的探测
++i;
index %= _tables.size();//锁定在size范围内
}
//当此位置为空时
_tables[index]._data = d;//进行插入
_tables[index]._state = EXITS;
_num++;
其实我们的二次探测本质上是对找空位置的方式做了个修改,变为了对其左右两边找i平方的空位置,这样不容易造成数据堆积,但实质还是没能逃出探测找位置这种为满足自己位置而占用别的数据的位置的方式,当数据多起来依旧会有很多冲突发生导致效率低下,我们可以看到,闭散列的开放定址法不是一种好的方式,下面我们来看一中可以解决这种不足的方式,开散列
开散列概念开散列法又叫链地址法 (拉链法, 开链法 ) ,首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码 归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结 点存储在哈希表中 。
这种开散列其实可以简单地理解为数组+链表,将插入的数据都利用链表的链式结构链起来,此时若有冲突,直接链在后面,就不会占用其他位置空间
pair Insert(const T& data)//开散列插入
{
KeyOfT koft;
// 如果负载因子等于1,则增容,避免大量的哈希冲突
if (_tables.size() == _num)
{
// 1.开2倍大小的新表(不一定是2倍)
// 2.遍历旧表的数据,重新计算在新表中位置
// 3.释放旧表
vector newtables;//创建存储指针的指针数组
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;//扩容
newtables.resize(newsize);//开辟新大小的哈希表
for (size_t i = 0; i < _tables.size(); ++i)
{
// 将旧表中的节点取下来重新计算在新表中的位置,并插入进去
Node* cur = _tables[i];//初始化cur指针
while (cur)//当cur不为null时
{
Node* next = cur->_next;//初始化next指针
size_t index = HashFunc(koft(cur->_data)) % newtables.size();//计算数据在哈希表中的位置
cur->_next = newtables[index];//将冲突的数据链接到cur的next
newtables[index] = cur;//cur下移到新节点
cur = next;//next同时下移
}
_tables[i] = nullptr;//将原来的表置空
}
_tables.swap(newtables);//交换新旧表指针,随着出作用域旧表也会自动被析构
}
//正常插入,表未满
// 计算数据在表中映射的位置
size_t index = HashFunc(koft(data)) % _tables.size();
// 1、先查找这个值在不在表中
Node* cur = _tables[index];//初始化cur
while (cur)//当cur不为NULL,也就是还在链表中时
{
if (koft(cur->_data) == koft(data))//当值在表中
{
return make_pair(iterator(cur, this), false);//返回pair,也就是返回当前节点
}
else//不在表中时
{
cur = cur->_next;//下移
}
}//此时找到要插入的位置
// 2、头插到挂的链表中 (尾插也可以)
Node* newnode = new Node(data);//新节点赋给newnode
newnode->_next = _tables[index];//新节点next链上之前的指针
_tables[index] = newnode;//新节点变为之前的指针
++_num;//有效数据+1
return make_pair(iterator(newnode, this), false);//返回pair,也就是返回插入的结点
}
这种方式可以有效地解决冲突元素较多的问题,但是这种方式也不是万能的,当大量的数据冲突时,这些哈希冲突会挂在同一个链式桶中,查找时的效率就会降低,所以开散列-哈希桶也是要控制哈希冲突的,如何控制呢?通过控制负载因子,不过这里九八空间利用率提高一些,负载因子也高一些,一般开散列把负载因子控制在1左右
开散列增容桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容
但是也可能总有一些桶挂的数据很多,冲突很厉害,那么此时我们可以设定一个界限值,当一个桶链的长度超过一定值,就将挂链表改为挂红黑树,这样即使数据很多,查找的次数也不会多,保证了效率
开散列的查找和删除
Node* Find(const K& key)//查找函数
{
KeyOfT koft;
size_t index = HashFunc(key) % _tables.size();//找到数据所对应在表中的位置
Node* cur = _tables[index];//cur指针指向该位置
while (cur)//当cur不为NULL时
{
if (koft(cur->_data) == key)//当要查找的数与当前指针所指位置的data相同时
{
return cur;//返回该指针
}
else
{
cur = cur->_next;//指针后移
}
}
return nullptr;//未找到
}
bool Erase(const K& key)//删除函数
{
KeyOfT koft;
size_t index = HashFunc(key) % _tables.size();//找到key所对应在表中的位置
Node* prev = nullptr;//初始化prev指针
Node* cur = _tables[index];//初始化cur
while (cur)//当cur不为NULL时
{
if (koft(cur->_data) == key)//当找到目标时
{
if (prev == nullptr)//当prev仍为初始值时
{
// 表示要删的值在第一个节点
_tables[index] = cur->_next;//跳过cur链接
}
else
{
prev->_next = cur->_next;//跳过cur链接
}
delete cur;//释放cur指针
return true;
}
else//未找到目标
{
prev = cur;//依次后移
cur = cur->_next;
}
}
return false;//删除失败
}
我们在解决了开散列的基本问题之后,又有几个新的问题。
当我们存储的key非整型时,该如何解决?
对于这个问题,我们解决的办法是利用仿函数来进行分情况处理的
size_t GetNextPrime(size_t num)
{
const int PRIMECOUNT = 28;
const static 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
};
for (size_t i = 0; i < PRIMECOUNT; ++i)
{
if ( primeList[i] > num )
{
return primeList[i];
}
}
}
直接设定近二倍素数表,当我们需要增容时通过这个函数将数值调整为素数
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销 。事实上: 由于开地址法必须保持大 量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <= 0.7 ,而表项所占空间又比指针大的 多,所以使用链地址法反而比开地址法节省存储空间。
template//加入Hash参数判断类型,进而转到所对应的仿函数分情况处理
class HashTable
{
typedef HashNode Node;
public:
friend struct __HashTableIterator < K, T, KeyOfT, Hash>;//将迭代器与哈希表设为友元,此时迭代器就可以访问哈希表中元素了
typedef __HashTableIterator iterator;
iterator begin()//设置begin()
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])//当哈希表不为空
{
return iterator(_tables[i], this);//返回此位置,也就是第一个有效单元
}
}
return end();//迭代完整个表,都为空,返回end()
}
iterator end()//设置end()
{
return iterator(nullptr, this);//返回空
}
~HashTable()//析构函数
{
Clear();
}
void Clear()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];//指针迭代指向各个单元
while (cur)//当指针不为空
{
Node* next = cur->_next;//初始化next为cur的next
delete cur;//释放cur
cur = next;//后移,循环释放
}
_tables[i] = nullptr;//最后将单元格置空
}
}
size_t HashFunc(const K& key)//返回key的元素数值,当为int,直接计算,当为string则转到int型
{
Hash hash;
return hash(key);//返回计算模数后的结果
}
// 前置声明
template//加入Hash参数判断类型,进而转到所对应的仿函数分情况处理
class HashTable;
template
struct __HashTableIterator//
{
typedef __HashTableIterator Self;
typedef HashTable HT;
typedef HashNode Node;
Node* _node;
HT* _pht;
__HashTableIterator(Node* node, HT* pht)//初始化列表初始化
:_node(node)
, _pht(pht)
{}
T& operator*()//重载解引用
{
return _node->_data;//返回node的data
}
T* operator->()
{
return &_node->_data;
}
Self operator++()//迭代器++
{
if (_node->_next)//当node->_next不为NULL时
{
_node = _node->_next;//后移
}
else
{
// 如果一个桶走完了,找到下一个桶继续遍历
KeyOfT koft;
size_t i = _pht->HashFunc(koft(_node->_data)) % _pht->_tables.size();//找到node所在表的桶位置
++i;//走到下一个桶
for (; i < _pht->_tables.size(); i++)//向后循环
{
Node* cur = _pht->_tables[i];//初始化cur
if (cur)//当cur不为NULL,为cur则自动后移
{
_node = cur;//将cur赋给_node
return *this;//返回当前node,实际为cur结点
}
}
_node = nullptr;//置空_node
}
return *this;//直接返回链表中的node
}
bool operator!=(const Self& s)
{
return _node != s._node;
}
};
template
struct _Hash
{
const K& operator()(const K& key)//对()重载
{
return key;//返回key值
}
};
// 特化
template<>//对string做key的情况进行特化
struct _Hash < string >
{
size_t operator()(const string& key)//传入string
{
// BKDR Hash
size_t hash = 0;
for (size_t i = 0; i < key.size(); ++i)
{
hash *= 131;//为防止字符ASSIC码相加相同而不是每个字符相同,我们对字符*131(有研究证明131准确率高)进行处理
hash += key[i];
}
return hash;
}
};
#pragma once
#include "HashTable.h"
using namespace OPEON_HASH;
namespace wxy
{
template>//引入条件参数
class unordered_map
{
struct MapKOfT
{
const K& operator()(const pair& kv)//传入pair
{
return kv.first;//返回K值
}
};
public:
typedef typename HashTable, MapKOfT, Hash>::iterator iterator;//声明迭代器,加上typename作用是提醒编译器生成可执行文件后仍为迭代器
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
pair insert(const pair& kv)//插入
{
return _ht.Insert(kv);
}
V& operator[](const K& key)//【】重载
{
pair ret = _ht.Insert(make_pair(key, V()));//ret赋给插入的值,插入的key
return ret.first->second;//返回缺省值,取决于对象的value类型
}
private:
HashTable, MapKOfT, Hash> _ht;
};
#include "HashTable.h"
using namespace OPEON_HASH;
namespace wxy
{
template>
class unordered_set
{
private:
struct SetKOfT
{//针对set的k,k模型而生的仿函数
const K& operator()(const K& k)//传入k
{
return k;//返回k
}
};
public:
typedef typename HashTable::iterator iterator;//说明迭代器
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
pair insert(const K& k)
{
return _ht.Insert(k);
}
private:
HashTable _ht;
};