完整代码实现已上传至gitee,戳->哈希表实现
之前学过的数据结构,如线性表(数组,链表)和树状结构(红黑树,AVL树),这些结构中存储元素的关键码Key与其存储位置是没有对应联系的。于是,在查找数据时,我们只能不断地让索引值key与数据结构中的存储元素的关键码比较来查询。例如顺序表的比较结果有 =
和 ≠
两种可能,于是查找的时间复杂度为O(N),在树中则有 >
,=
,<
三种可能,查找的时间复杂度为O(logN)。
如果我们将元素关键码和其存储位置相关联,即确立关键码与位置的映射关系,因此在查找时只要输入我们想要得到的数据的关键码,就可以通过映射找到它所存储的位置,那么我们就可以在表中直接搜索到元素,即查找的时间复杂度为O(1)。
那么首先在插入元素时就要按照这种映射关系计算出该元素存储的位置进行存放。
搜索数据,则对与元素的关键码进行同样的映射关系运算,求得值即为存储位置。
该方法称为哈希(散列)法, 其中的映射关系称为哈希函数,构造出来的结构即为哈希表。
这里通过一个例子来直观了解哈希的使用方法:
我们建立一个含有26个int的数组frequency,数组下标(i)与小写英文字母(变量c,char)一一对应,于是映射关系就有:i=c-'a'
,数组元素则存放字母出现的次数,可以得到:
字母a出现的次数存放在frequency[0],字母b出现的次数存放在frequency[1],…,字母z出现的次数存放在frequency[25]。只要再遍历一次字符串,通过字符找到frequency下标(即元素存放位置)就可以找到这个字符出现的次数,只要找到第一个为1的元素,即找到第一个只出现一次的字符。
代码如下:
class Solution {
public:
char firstUniqChar(string s) {
int frequency[26]={0};
for(const auto& c : s)
{
frequency[c-'a']++;
}
char ret=' ';
for(const auto& c : s)
{
if(frequency[c-'a']==1)
{
ret= c;
break;
}
}
return ret;
}
};
对于两个数据元素的关键字 keyi和keyj(i!=j),有keyi != keyj,但哈希函数计算后 : Hash(keyi)==Hash(keyj)
即不同关键字通过相同的哈希函数计算出相同的哈希地址,这种现象称为哈希冲突或哈希碰撞
一个理想的哈希函数
直接定址法
取关键字的线性函数为散列地址:Hash(Key)=A*Key+B (A,B为常数,A!=0)
除留余数法
设哈希表中存在的地址数为n,取一个不大于n,但最接近或等于m的素数p作为除数,按照哈希函数:Hash(Key)=Key%p,将关键码转换为哈希地址。
这是最简单且常用的构造哈希函数的方法,他不仅可以直接对关键字取余,还能对关键字折叠,平方取中后再取余。
平方取中法(不常用)
取关键字平方后的中间几位作为散列地址,关键字1234,平方得1522756,那么可抽取中间三位227为哈希地址。
随机数法(不常用):
选择一随机函数,取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合。
折叠法(不常用):
将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址
在之前介绍的除留余数法中会有哈希冲突的可能,取模后的数组下标出现重复:
下面将介绍解决方案
当发生哈希冲突时,若哈希表此时还没有被装满,说明哈希表中还存在空位置,那么可以把key存放到冲突位置的“下一个”空位置中
寻找下一个空位置的常见方法有线性探测法和二次探测法
当发现哈希冲突时,从发生冲突的位置开始,依次往后探测一个位置,直到找到空位置。
插入
通过哈希函数函数获取哈希表的位置
哈希函数:Hi=(H0+i)%p (i=0,1,2,3,…)
查找与删除
使用哈希函数确定待删除元素的起点位置,如果当前位置的元素不是待删除元素,那就线性探测往后找,如果遇到空位置,说明没有该元素,停止搜索。
如果找到该元素,不能直接删除,否则会影响其他元素的搜索。这里可以对每个位置进行标记,EMPTY(空),EXIST(存在),DELETE(删除)。
当搜索遇到EMPTY状态,说明不存在该元素,因为线性探测已结束。
如果遇到DELETE,则需要继续往后找,如果到达EMPTY或者删除结点的起点说明没有该元素。
如果找到删除元素,直接标记为DELETE(DELETE状态和EMPTY状态可插入元素)。
哈希表中如果元素过多,会大大提升哈希冲突的概率,这里需要提到一个概念:负载因子α=填入表中的元素个数/哈希表长度。
对于开放定址法,负载因子是特别重要的参数,应严格限制在0.7或0.8以下,超过0.8,查表时的缓存不命中概率按照指数曲线上升,因此当负载因子过红线时,应扩容哈希表。
如果数据的哈希值过于集中容易产生数据的堆积,线性探测的次数过多,不同关键码都占据了其他的空位置,使得寻找关键码也要比较多次(踩踏),导致搜索效率降低。
线性探测的缺陷是产生冲突的数据堆积在了一起,这与其找下一个空位置的方法有关系,二次探测寻找下一个空位的方法则是:
哈希函数:Hi=(H0+i^2)%p (i=0,1,2,3,…)
如果哈希值较为集中,那么二次探测后的哈希表元素的分布会相对稀疏一些,不容易导致堆积。采用二次探测同样需要考虑负载因子。
开散列又称哈希桶或者拉链法,对key值计算地址,具有相同地址的关键码将归于同一子集合,每一个子集合称为一个哈希桶,各个桶中元素通过链表连接起来,各链表的头结点存储在哈希表中。
可以看出,开散列的每个哈希桶中放的都是发生哈希冲突的元素。
闭散列解决哈希冲突采用的是抢占的方式,自己的位置如果被占用就只能转而占用他人的位置。开散列将哈希冲突的元素通过链表连接,然后将链表的头结点存储在哈希表中。开散列的方式使结点不会影响与自己哈希地址不同的元素的增删查改的效率,因此其负载因子稍大些,可达到1。
当哈希桶也有缺陷,极端情况下,插入的元素全部产生冲突,最终都放到同一个哈希桶中,于是删查改的效率就退化成了O(N)。这种场景可以考虑将链表的数据结构改为红黑树结构:
在Java语法的HashMap中,如果哈希桶中的元素超过8个就会将链表结构转换为红黑树结构,当红黑树的结点小于等于6个以后,又会恢复为链表。
在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率。
链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。通常如果哈希函数正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。
那么哈希表的结点结构可以设计为:
template <class T>
struct HashData
{
forward_list<T> _list;
set<T> _rbtree;
};
我们使用闭散列方式建立哈希表,以实现 unordered_map 为前提(存放的元素类型为pair
首先我们要确保key值是可以转换为非负整型的(为了取余),遇到存储类型为pair
STL中的unordered_map参数中的Hash函数:
我们自己的适合内置类型的仿函数如下:
template <class K>
//遇到double,float,char类型通过强转后返回
struct Hash
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//特化string版本,使用BKDRHash装换算法
template<>
struct Hash<string>
{
const 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;
}
};
字符串哈希函数的对比:
关于字符串装换整型的Hash函数的详情戳:各种字符串Hash函数
之前在闭散列中介绍过,一个哈希表存储单元,除了存元素之外还需存储状态:EMPTY,DELETE,EXIST
//状态
enum State
{
EMPTY,
EXIST,
DELETE
};
//存储类型结构体
template <class K,class V>
struct HashData
{
pair<K, V> _kv;
State _state=EMPTY;
};
使用vector<>作为容器,
template <class K, class V,class HashFunc=Hash<K>>//HashFunc负责将key值(可能为string)转换为size_t,方便取余
class HashTable
{
public:
HashData<K, V>* find(const K& key)
{
//...
}
bool insert(const pair<K,V>& kv)
{
//...
}
bool erase(const K& key)
{
//...
}
private:
vector<HashData<K,V>> _table;//容器
size_t _n=0;//实际存储的元素个数
};
查找步骤如下:
public:
HashData<K,V>* find(const K& key)
{
if(_table.size()==0)
{
return nullptr;
}
HashFunc hf;//仿函数(Key转整型)实例化对象
size_t start = hf(key)%_table.size();
size_t index = start;
int i = 1;
while (_table[index]._state!=EMPTY)
{
if(_table[index]._kv.first==key
&& _table[index]._state==EXIST)
{
return &_table[index];//找到返回元素指针
}
index = start+i;//线性探测
//index = start+i*i;//二次探测
i++;
index %= _table.size();
}
return nullptr;
}
插入步骤如下:
public:
bool insert(const pair<K,V>& kv)
{
if (find(kv.first) != nullptr)//防止重复
{
return false;
}
//扩容逻辑
//初始时是一个空表,则resize空间。
if (_table.size() == 0)
{
_table.resize(10);
}
else if ( 10 * _n / _table.size() > 7)//或者负载因子超过0.7,则需要换一个更大的表
{
//法一 新建一个table,然后将元素转移
//vector newtable;
//newtable.resize(2 * _table.size());
//for (auto& e : _table)
//{
// if (e._state == EXIST)//因为新表的容量更大,所以元素位置会变化
// {
// //重新按照线性探测或二次探测规则在新表中插入
// }
//}
//法二 新建一个HashTable类(推荐)
HashTable<K, V,HashFunc> newHT;
newHT._table.resize(2*_table.size());
for (const auto& e : _table)
{
if (e._state == EXIST)
{
newHT.insert(e._kv);//复用了该类的insert成员函数
}
}
_table.swap(newHT._table);
}
//插入新值的逻辑
HashFunc hf;//仿函数 将key转换为size_t
size_t start = hf(kv.first)%_table.size();
size_t index = start;
size_t i = 1;
while (_table[index]._state == EXIST)
{
index =start+ i;//线性探测
//index=start+i*i;//二次探测
index %= _table.size();
++i;
}
_table[index]._kv = kv;
_table[index]._state = EXIST;
_n++;
return true;
}
删除步骤如下:
public:
bool erase(const K& key)
{
HashData<K, V>* del = find(key);
if (del == nullptr)//没有该点
{
return false;
}
del->_state = DELETE;
_n--;
return true;
}
在开散列的哈希表中,哈希表的结点类型,除了存储一个数据外还需存储指向下一个结点的结点指针。
当然也需要将key转换成size_t 的仿函数,和闭散列一样
template<class K>
struct Hash
{
const size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct Hash<string>
{
const size_t operator()(const string& key)
{
size_t hash=0;
for (size_t i = 0; i < key.size(); ++i)
{
hash *= 131;
hash += key[i];
}
return hash;
}
};
template <class K,class V>
struct HashData
{
//链表指针
HashData<K, V>* _next;
//链表结点数据
pair<K, V> _kv;
HashData(const pair<K, V>& kv) :_kv(kv), _next(nullptr)
{}
};
哈希表使用vector作为容器,元素类型则为结点指针:
template <class K, class V,class HashFunc=Hash<K> >
class HashTable
{
typedef HashData<K, V> Node;
public:
Node* find(const K& key)
{
//....
}
bool insert(const pair<K,V>& kv)
{
//...
}
bool erase(const K& key)
{
//...
}
private:
vector<Node*> _table;//指针数组
size_t _n=0;//实际存放的元素数量
};
步骤:
public:
Node* find(const K& key)
{
HashFunc hf;//key转size_t 仿函数实例化
if (_table.size() == 0)//元素数量为0,无法查找
{
return nullptr;
}
//计算哈希地址
size_t index = hf(key) % _table.size();
Node* cur = _table[index];
while (cur)//遍历哈希桶
{
//key值匹配,
//注意哈希表的Key值一定是可以做相等比较的
//如果不存在相等运算符,则提供重载
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
步骤:
查找哈希表中是否已存在待插入的元素,若存在则插入失败。
判断哈希表大小吗,若哈希表为0,则直接赋予空间;
若负载因子达到1,需扩容,
这里的扩容方式与闭散列不同:闭散列是新建一个哈希表,再复用了成员函数insert的插入逻辑,而开散列的元素为结点指针,若new一个新结点后,还需对旧表结点delete,折损效率,不如新建一个指针数组,将旧表的元素插入至新数组中来得方便,不用进行结点的创建与释放。
计算哈希地址,将元素头插入对应哈希桶中;
有效元素数量+1。
public:
bool insert(const pair<K,V>& kv)
{
//查找是否已有该元素
if (find(kv.first))
{
return false;
}
HashFunc hf;//key转size_t仿函数实例化
//空表或负载因子为1 增容
if (_n == _table.size())
{
size_t newsize = _n == 0 ? 10 : 2 * _table.size();
vector<Node*> newtable;
newtable.resize(newsize);
for (size_t i = 0; i < _n; ++i)
{
if (_table[i])//桶不空,遍历该哈希桶
{
//拿到这个桶的头结点
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
//重新计算映射位置
size_t index = hf(cur->_kv.first) % newtable.size();
//插入至新表
cur->_next = newtable[index];
newtable[index] = cur;
cur = next;
}
}
}
_table.swap(newtable);//交换哈希表
}
size_t index = hf(kv.first) % _table.size();
Node* newnode = new Node(kv);
//头插
newnode->_next = _table[index];
_table[index]= newnode;
++_n;
return true;
}
步骤:
public:
bool erase(const K& key)
{
HashFunc hf;//key转size_t仿函数实例化
//1.计算哈希地址
size_t index = hf(key) % _table.size();
//2.在桶中寻找待删除结点
Node* cur = _table[index];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)//cur为头结点
{
_table[index]= cur->_next;
}
else//cur为中间结点
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
上文我们得知,在初次插入数据时需要给定数组的初始大小,以及当负载因子超过限定时需要扩容,而有研究表明当数组的长度为素数时,可以减少除留余数法的哈希冲突。
于是获取素数大小
public:
GetNextPrime(size_t prime)
{
const int PRIMECOUNT=28;
static 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
};
size_t i=0;
for(;i<PRIMECOUNT;++i)
{
if(primeList[i]>prime)
{
return primeList[i];
}
}
return primeList[0];
}
改造增容部分代码:
//改造前
//size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
//改造后
size_t newsize = GetNextPrime(_tables.size());
— end —
青山不改 绿水长流