引入
二叉搜索树的查找效率为O(log2N)到O(N),二叉平衡树的查找效率为O(log2N),那么有没有一种算法可以不经过任何比较,一次直接从表中得到要搜索的元素呢?事实上这种算法是存在的,就是哈希表
1、哈希的概念
元素的存储位置与它的关键码有一一映射的关系,在查找元素的时候不需要进行任何比较,可以直接从表中直接检索出元素的值。
1.1 哈希表的优点
(1)查找的效率高
(2)存在预缓存机制,提高查找的速度
哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
哈希函数设置:hash(key) = key % capacity; 其中 capacity为存储元素底层空间总的大小
举个栗子:
从上面的这个例子来看,当 key的值为19时,所得的哈希值为1,此时在1 的这个位置已经存在了数据,那么这样的问题就成为哈希冲突,下面我来介绍哈希冲突。
2、哈希冲突
不同关键字通过相同哈希数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
2.1 引起哈希冲突的原因
引起哈希冲突的主要原因是哈希函数设计的不够合理
,才会导致出现多次哈希冲突,一般在设计哈希函数时,选取地址数m附近的一个质数p作为除数(一般是p<=m),就可以尽可能的避免哈希冲突。
设计哈希函数的原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
3、 常用的哈希函数
-
直接定制法
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况 -
除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函
数:Hash(key) = key% p (p<=m),将关键码转换成哈希地址
以上两种哈希函数是最常用的,还有一些不常用的哈希函数,例如:平方取中法、数学分析法、折叠法、 随机数法。
哈希函数设计的越精妙,产生冲突的几率越小
4、 解决哈希冲突的方法
解决哈希冲突的方法有两种,分别为:开散列和闭散列。
4.1 闭散列(开放定址法)
当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
查找空位置的方法:
1.线性探测
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
-
插入元素
通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
以上面这个例子来看,当key的序列值为19时,会产生哈希冲突,所以要寻找下一个空位置,即为3所对应的位置。 -
删除元素
在删除的时候会出现一定的问题,所以不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素1,如果直接删除掉,19查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};
线性探测的实现
#pragma once
#include<map>
#include<vector>
using namespace std;
namespace tdd
{
enum State
{
EMPTY,
EXIST,
DELETE
};
class dealInt
{
public:
int operator()(int n)
{
return n;
}
};
class dealString
{
public:
int operator()(const string & n)
{
int sum = 0;
int seed = 131;
for (const char & c : n)
{
sum = sum * seed + c;
}
return sum & 0x7FFFFFFF;
}
};
template<class K, class V, class SW>
class hashTable
{
struct elem
{
pair<K, V> m_val;
State m_state;
elem(const K & key = K(), const V & val = V(), State state = EMPTY) :
m_val(key, val),
m_state(state)
{
}
};
vector<elem> m_table;
size_t m_size;
public:
hashTable(size_t capacity = 11):
m_table(capacity),
m_size(0)
{
}
size_t capacity()
{
return m_table.size();
}
private:
int hashFunc(const K & key)
{
SW func;
return func(key) % capacity();
}
public:
bool insert(const pair<K, V> & val)
{
int n = hashFunc(val.first);
while (m_table[n].m_state == EXIST)
{
if (m_table[n].m_val.first == val.first)
{
return false;
}
n++;
if (n == capacity())
{
n = 0;
}
}
m_table[n].m_val = val;
m_table[n].m_state = EXIST;
m_size++;
return true;
}
int find(const K & key)
{
int n = hashFunc(key);
while (m_table[n].m_state != EMPTY)
{
if (m_table[n].m_state == EXIST && m_table[n].m_val.first == key)
{
return n;
}
n++;
if (n == capacity())
{
n = 0;
}
}
return -1;
}
bool erase(const K & key)
{
int ret = find(key);
if (ret < 0)
{
return false;
}
else
{
m_table[ret].m_state = DELETE;
m_size--;
}
}
size_t Size()
{
return m_size;
}
bool Empty()
{
return m_size == 0;
}
void Swap(hashTable<K, V, SW> & ht)
{
m_table.swap(ht.m_table);
size_t tmp;
tmp = m_size;
m_size = ht.m_size;
ht.m_size = tmp;
}
};
};
除留余数法一般选择质数作为除数,原因是为了降低哈希冲突的概率。
闭散列增容
线性探测的优点:实现简单
线性探测的缺点:一旦发生冲突,所有的冲突会连在一起,容易产生数据的“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
为了缓解这样的数据堆积,下面来看二次探测
2>二次探测
在线性探测中查找空位置是一个一个挨着找,而二次探测中的查找方法是:H (i) = ( H(0)+i^2 )% m,或者: H(i)= (H(0) - i ^2 )% m。其中:i = 1,2,3…, 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。通俗的来讲就是先找右边的一个数据,再找左边的一个数据,例如先找1,再找-1,先找3,再找-3……
**注意:**当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5
由上述可知:二次探测的效率要比线性探测的效率高。
4.2 开散列(开链法)
首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
如下图所示:
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素
开散列增容
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容
4.3 开散列与闭散列的比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上: 由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间