哈希表,相信大家都早有耳闻,今天我们就来解开hash表的面纱,看看hash表的功能及其底层;
hash表功能
hash表的作用是用来快速查找存在hash表中的数据;hash表查找效率是O(1)速度非常非常快;当应用场景为我们需要快速的在大量数据中寻找相对应的数据时就可以庸hash表来存储数据;
我们知道了hash表的查询速度非常快,那究竟有多快呢?我们下面来测试一下:
void test()
{
unordered_set<int> um;//底层是hash表,我们暂时将它看作hash表
set<int> m;//底层是红黑树
vector<int> v;
srand((unsigned int)time(nullptr));
for (int i = 0; i < 10000000; i++)
{
int num = rand();
v.push_back(num);
}
clock_t start = clock();
for (auto e : v)
{
um.insert(e);
}
clock_t finish = clock();
cout << "hash insert: " << finish - start << endl << endl;
start = clock();
for (auto e : v)
{
m.insert(e);
}
finish = clock();
cout << "RBTree insert: " << finish - start << endl << endl;
start = clock();
for (auto e : v)
{
um.find(e);
}
finish = clock();
cout << "hash find: " << finish - start << endl << endl;
start = clock();
for (auto e : v)
{
m.find(e);
}
finish = clock();
cout << "RBTree find: " << finish - start << endl << endl;
}
测试结果:
接下来我们就来看看hash表到底是如何实现的,有如此之高的效率;
hash表的底层实现
直接定址法:
我们首先需要知道hash表是如何存储数据的;hash表是通过映射来建立数据与坐标之间的关系从而达到快速查找数据的功能:
这就是此方法的基本思路,通过映射关系来寻找数据;
但仅仅如此是无法完成的,如果数据量很小但数据极其分散时,这样的一一映射就无法满足条件了;
这样的方式必然是极其浪费空间的;所以为了解决这样空间的浪费;hash表有着其他的解决办法:
除留余数法:
闭散列
(暂时先不用知道闭散列是什么继续往下看)
线性探测
这就是我们需要查找数据的时候,如果相应余数不是我们要查找的数据就通过线性探测的方式一个一个的向后寻找即可;
这样的方式我们还需要注意的问题是如果我们在寻找数据的时候,线性探测的过程中有数据被删除了;这时我们探测到了空就会退出探测,可是我们需要探测的数据依然在表中,只是还没被探测到而已;
为了解决这样的情况,我们有两种方式;1.可以改变判断条件,改变探查结束的条件为遇到最开始模到的余数的时候才退出,也就是寻找了整个表的长度,这样都没找到数据时,才退出(但当我们的表很大时遍历整个表的消耗非常大);2.我们给每个节点添加一个标志,当标志为空时才退出,被删除的数据标志为被删除;(会让每个节点增加啊新的数据);
我们这里使用第二种方式解决这样的问题;
所以我们的节点成为了这样:
enum state {
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct hashnode {
hashnode<K, V>()
{}
pair<K, V> _data;
state _state = EMPTY;
};
这样就可以完成我们数据的插入删除了;
哈希冲突
我们的除留余数法虽然可以成功的插入和删除数据,但是因为我们是通过余数的方式来获得数据所以一定会导致很多数据的余数相同,从而会让数据不停的抢占其他数据的位置;这样的问题就叫做哈希冲突;
这样的冲突虽然不影响插入和删除的正确性,但是会影响效率,为了减轻这样的影响,可以将线性探测的方式改为
二次探测
构建hash表
下面来看看表我们如何组织起来节点;因为stl中现成的具有随机迭代器的容器;我们可以直接使用所以,我们可以直接将节点放入我们vector中:
//除留余数法
template<class K, class V>
class hash_table {
public:
typedef hashnode<K, V> node;
private:
vector<node> _table;
size_t _size = 0;
};
底层使用vector表存放数据;这样我们不需要手动的开辟空间,vector自动帮我们完成工作;
可以看到hash表底层还封装了一个数据_size,这是用来标识,表中有多少数据的;当_size的个数太大了的时候,表就需要扩容,什么时候扩如何扩呢?这个时候就引入一个新的概念荷载因子;
荷载因子
荷载因子是控制扩容场景的关键;荷载因子=表中存储的数据/表的大小;当表大小*荷载因子等于表中数据个数时就需要扩容了;荷载因子最好设置在0.7-0.8之间;其实我也不知道确切的原因,但是我知道随着hash表中的数据增多,我们的位置(取模获得)被占据的可能一定会变大(哈希冲突概率会变大),这样我们就一定得通过线性探测或者二次探测的方式寻找可以放置数据的位置;所以我们不可以让表的数据百分比太大;
我们看看gpt给出的解释:
通常,负载因子的选择是一个权衡问题:
- 如果负载因子过低,哈希表会浪费大量空间,导致内存使用效率低下。
- 如果负载因子过高,哈希表会出现更多的冲突,降低了操作的效率。
经验上,一般建议将负载因子控制在0.7到0.8之间。这个范围是根据实际经验得出的折衷方案,可以在保证较高空间利用率的同时,尽可能降低冲突发生的概率,从而维持较好的性能。
对于哈希表的具体应用场景和需求,可能需要根据实际情况调整负载因子的设置。较大的负载因子可能适用于空间资源相对充足的场景,而较小的负载因子则可以提高哈希表的性能,但会占用更多的内存空间。
这也是我找到的比较严谨的说法:
有了荷载因子,我们就可以是实现我们的hash表扩容了;
hash表扩容
由于表中数据量与表容量(vector大小)比率达到了荷载因子的量,所以这个时候,需要扩容;由于我们扩容后hash表(vector)的大小改变了;所以表中数据取模的pos位置是发生改变了的;所以数据都需要重新插入;但是扩容首先要在创建一个新的hash表,因为我们vector的扩容本身就是不知道是原地扩容还是异地扩容的;所以我们直接创建一个新表还可以免去再旧表上进行数据处理的麻烦;创建了一个新的扩容好了的表后我们就可以将数据一个个重新从旧表中插入到新表;全部数据复制完成后,将旧表和新表交换vector的swap函数;完成数据交换,旧表就会随着函数调用的结束而自动进行析构,而扩容好的新表则会成为hash的新内存空间供之后数据插入;
结合上面的多个因素我们现在来实现我们的增删查;(不支持改)
实现:
bool insert(const pair<K, V>& data)
{
if (find(data.first))
return false;
//先看是否需要扩容
if (_table.size() == 0 || _size * 10 / _table.size() >= 7)//荷载因子大于0.7与一开始的插入都需要扩容
{
size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
self newhash;
newhash._table.resize(newsize);
for (auto cur : _table)
{
if (cur._state == EXIST)
{
//newhash.insert(cur._data); //调用insert的方法
newhash.linear(cur._data); //封装线性探测的方法
}
}
_table.swap(newhash._table);
}
//线性探测寻找插入的位置
linear(data);
return true;
}
void linear(const pair<K, V>& data)//线性探测
{
size_t pos = data.first % _table.size();
size_t index = pos;
int i = 1;
while (_table[index]._state != EMPTY)
{
index = pos + i;
index %= _table.size();
i++;
}
_table[index]._data = data;
_table[index]._state = EXIST;
_size++;
}
node* find(const K& key)
{
if (_table.size() == 0)
return nullptr;
size_t pos = key % _table.size();
size_t index = pos;
int i = 1;
while (_table[index]._state != EMPTY)
{
if (_table[index]._state == EXIST
&& _table[index]._data.first == key)
{
return &_table[index];
}
index = pos + i;
index %= _table.size();
i++;
if (index == pos)
break;
}
return nullptr;
}
bool erase(const K& key)
{
node* erasenode = find(key);
if (erasenode)
{
erasenode->_state = DELETE;
_size--;
return true;
}
return false;
}
这样我们的hash表就实现了;
上面的一个萝卜一个坑,所有数据插入在一个表中的方式也叫做闭散列;这种方式的hash冲突非常容易产生,所以要设置平衡因子减小冲突,而这样就一定会有至少%20到%30以上的空间浪费;
除留余数法还有另一种存储数据的方式:
开散列
这就是开散列 ;
开散列与闭散列的不同是开散列将hash冲突的数据放在了一个桶中,而不是像闭散列一样,一个个向后占据(引起踩踏);这样的闭散列我们需要自己实现桶内的析构函数,因为vector只会自动析构它自己那块连续区域的内存,而向下延申的桶区域的空间我们要自己析构;
开散列的实现也有所不同:
实现:
bool insert(const pair<K, V>& data)
{
//判断表中是否已经拥有了此数据
if (find(data.first))
return false;
//先判断是否需要扩容
if (_size == _table.size())//荷载因子为1的时候需要扩容
{
size_t newsize = GetNextPrime(_table.size());
self newhash;
newhash._table.resize(newsize);
//for (auto cur : _table)
//{
// while (cur)
// {
// insert(cur->_data);//这样会导致又开辟了一个节点,并且还得释放之前的节点,会降低效率
// cur = cur->_next;
// }
//}
//_table.swap(newhash._table);
for (auto& cur : _table)
{
while (cur)
{
//insert(cur->_data);//这样会导致又开辟了一个节点,并且还得释放之前的节点,会降低效率
//cur = cur->_next;
Hash hash;
size_t pos = hash(cur->_data.first) % newhash._table.size();
size_t index = pos;
node* next = cur->_next;
cur->_next = newhash._table[pos];
newhash._table[pos] = cur;
cur = next;
}
}
_table.swap(newhash._table);
}
//寻找位置插入数据
Hash hash;
size_t pos = hash(data.first) % _table.size();
node* newnode = new node(data);
size_t index = pos;
//头插法
newnode->_next = _table[index];//就算head为空也是没有关系的
_table[index] = newnode;
_size++;
return true;
}
讲解:
我们发生hash冲突的数据放在了一个hash桶中,这个桶的结果可以是链表,当然也不只有链表,只要是可以增删的容器就可以作为hash桶;但我们这里的实现使用的就是链表;我们可以使用stl库中实现的链表,也可以自己实现节点;
扩容时节点的复制
使用库中实现和自己实现区别:
使用stl库中链表,那么一切的析构与插入节点都是库实现的,我们不需要自己实现,会非常的方便;但自己的实现,我们可以控制hash表扩容时节点的复制,这样可以提高扩容时的效率;
看上面我们自己实现扩容时链表的内容复制,我们是将链表的节点指针直接传递给新的vector,这样就不会有新节点的内存申请和旧节点的内存释放;这就是自己控制桶中数据的好处;如果直接使用stl的链表,那么我们想拷贝hash表中原有数据到新扩容的表一定又得重新插入,然后会创建新的节点;这样一定会降低扩容的效率;
这就是自己控制与直接套用的区别,各有长短;
开散列的荷载因子
由于我们开散列的hash表一个桶中可以放置多个数据,所以对于荷载因子的要求有所不同,荷载因子为1时才需要扩容,因为如果荷载因子太大时,会导致hash桶的长度过长,通过科学的计算控制在1时是一个比较合适的选择;
我们讲解完了hash表的底层实现(当然还有很多实现方法,我们只说了一小部分),我们来总结一下这小部分;
hash表优化
保持表的大小为素数
为了减少hash冲突,hash表的大小保持在质数的时候可以有效减小hash冲突;下面是hash表扩容时给hash表新大小的算法:
这是stl库中sgi版本的优化:
size_t GetNextPrime(size_t prime)//SGI版本下的获取新的质数大小的空间;
{
// 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];
}
支持其他类型数据存入hash表
如果想要支持其他的类型也可以在hash表中存储,那么就得转换其他类型为整形,如何转换呢?很简单,传一个仿函数即可,通过这个仿函数将我们传递的数据转换为整形,再取模计算大小;
当然不同类型的仿函数需要我们自己实现,来控制数据的转换;但因为数据转换为整形一定很容易出现数据重复(大部分);就拿我们的字符类型来算:
一个字符串的组合有128^n种组合,而一个整形数据最多也只有42亿种组合;随着字符串长度增大(假设字符串长度为5,那就有30亿多种组合了,组合数量指针爆炸式的增长),整形数据一定是无法装载这么多组合的字符串的;所以字符串转换的整形一定会有hash冲突;为了减小hash冲突(开散列是减小桶长度,闭散列是减少踩踏),大佬们有很多的优化算法:
这是字符串类型转换hash仿函数的讲解:
各种字符串Hash函数 - clq - 博客园 (cnblogs.com)
我们这里就展示一下排名第一的BKDRHash:
template<class T>
size_t BKDRHash(const T *str)
{
register size_t hash = 0;
while (size_t ch = (size_t)*str++)
{
hash +=ch;
hash*=31;// 也可以乘以31、131、1313、13131、131313..
}
return hash;
}
所以不同的类型,我们需要不同的仿函数来控制数据的转换,在让数据可以插入hash表的同时可以优化减少hash冲突;
降低桶高度
hash表开散列如果桶中数据还是很多可以将桶转换为红黑树降低高度;(联合体接收红黑树结构);
hash表总结
hash表的结构有开散列和闭散列两种,这两种结构都可以减轻hash冲突的问题,开散列通过增加hash桶高度来解决,而闭散列通过控制荷载因子,使得hash表中总是存在部分空闲空间提高,数据存储的命中率来减轻hash冲突;这两种方式中,由于闭散列总是要留出一部分空闲空间的原因,使得开散列的平均空间利用率还是高于闭散列的(优化算法也发挥着作用),而且开散列增加的是指针,但闭散列增加的是节点,节点的空间是远大于指针的;
hash表的哈希函数有很多;直接定址法和除留余数法只是其中最常用的两种,可以使用的场景也是不同的;有的场景用其他的hash函数是更好的,需要灵活变通使用;当然我们的库中是由hash表的实现的,库中的hash表一定是满足大多数场景的;stl库中的hash表有unordered_map与unordered_set;接下来的博客我将会讲解它们的实现与使用;