一:哈希表
哈希表又称散列表,通过键值映射,key-value。可实现高效的元素查询。即向哈希表输入一个键key,就可以在O(1)的时间内查找到value。
除了哈希表之外,数组和链表也可以实现查询功能,时间复杂度之间的关系如下:
发现哈希表的效率是真滴高!
1.哈希表的常见操作
初始化、查询、添加键值对、删除键值对等
使用内置的哈希表map实例如下:
//初始化哈希表
unordered_map<int,string> mp;
//添加键值对
mp[1287]= "哈罗";
mp[1345]= "美团";
mp[1685]= "支付宝";
mp[1098]= "淘宝";
//查询操作,向哈希表中输入键key,可以得到value
string APP=mp[1345];//美团
//删除操作,在哈希表中删除键值对(key,value)
mp.erase(1098);//删除淘宝
哈希表的遍历方式有:遍历键值对、遍历键、遍历值。
//遍历哈希表
//遍历键值对key->value
for(auto kv:mp){
cout<<kv.first<<"->"<<kv.second<<endl;
}
//使用迭代器遍历key->value
for(auto iter = mp.begin(); iter != mp.end(); iter++){
cout<< iter->first<< "-> "<<iter->second<<endl;
}
2.哈希表的实现
仅用一个数组实现哈希表,将数组的每个空位成为桶,每个桶可存储一个键值对,因此查询操作就是找到key所对应的桶,并在桶中获取value值。
key如何定位到对应的桶?通过哈希函数实现。哈希函数的作用:将一个大的输入空间映射到一个较小的输出空间。哈希表中:输入空间是{所有key},输出空间是{所有桶(数组索引)}。也就是说,输入一个key,可以通过哈希函数得到该key所对应的键值对在数组中的位置。
哈希函数计算分为两步:
I:通过某种哈希算法hash()计算得到哈希值;
II:将哈希值对桶数量(数组长度)capacity取模,获得该key对应的数组索引index。
index = hash(key) % capacity
然后,就可以利用index在哈希表中访问对应的桶,从而获取value。
//键值对
struct Pair{
public:
int key;
string val;
Pair(int key, string val){
this->key=key;
this->val=val;
}
};
//用数组实现哈希表、
class ArrayHashMap{
private:
vector<Pair* > buckets;
public:
ArrayHashMap(){
//初始化数组,假设包含100个桶,capacity=100
buckets = vector<Pair* > (100);
}
~ArrayHashMap(){
//释放内存
for(const auto &bucket : buckets){
delete bucket;
}
buckets.clear();
}
//哈希函数
int hashFunc(int key){
int index = key % 100;
return index;
}
//c查询操作
string get(int key){
int index = hashFunc(key);
Pair* pair = buckets[index];
if(pair == nullptr) return "";
return pair->val;
}
//添加操作
void put(int key , string val){
Pair *pair = new Pair(key, val);
int index = hashFunc(key);
buckets[index]= pair;
}
//删除操作
void remove(int key){
int index = hashFunc(key);
//释放内存并置为nullptr
delete buckets[index];
buckets[index]= nullptr;
}
//获取所有键值对
vector <Pair* > pairSet(){
vector<Pair* > pairSet;
for(Pair *pair: buckets){
if(pair!=nullptr){
pairSet.push_back(pair);
}
}
return pairSet;
}
//获取所有键
vector<int> keySet(){
vector<int> keySet;
for(Pair* pair: buckets){
if(pair!=nullptr){
keySet.push_back(pair->key);
}
}
return keySet;
}
//获取所有值
vector<string> valueSet(){
vector<string> valueSet;
for(Pair *pair: buckets){
for(pair!=nullptr){
valueSet.push_back(pair->val);
}
}
return valueSet;
}
//打印哈希表
void print(){
for(Pair* kv: pairSet()){
cout<<kv->key<<" -> "<<kv->val<<endl;
}
}
};
哈希冲突,由于上面了解到将大的空间key映射到小的输出空间,理论上存在多个输入对应相同输出的情况,这种情况被称作哈希冲突,显然,还洗标容量增大会减少哈希冲突,即可以通过扩容哈希表减少冲突。
负载因子常作为哈希表扩容的触发条件。在Java中负载因子超过0.75,哈希表就会扩容至原来的2倍。
二 :哈希冲突
哈希冲突会导致查询结果错误,严重影响哈希表的可用性,而直接使用哈希表扩容的方法解决冲突,虽然简单粗暴,但是效率太低。因为扩容需要大量的数据搬运和哈希值计算。
哈希表的结构改良:链式地址、开放寻址。
1.链式地址
原始哈希表中,每个桶仅装一个键值对。链式地址:将单个元素转化为链表,将键值对作为链表节点,将所有发生冲突的键值对都储存在一个链表中。如下图
链式地址实现的哈希表,操作方法发生了以下变化:
查询元素:输入key,经哈希函数得到桶索引,既可以访问链表头节点,然后遍历链表并对比 key已以查询目标键值对。
添加元素:先通过哈希函数访问链表头节点,然后将节点(键值对)添加至链表中。
删除元素:根据哈希函数的访问结果访问链表头部,接着遍历链表以查找目标节点,并将其 删除。
链式地址的局限性:
占用空间大:链表包含节点指针,它相比数组更加耗费内存空间。
查询效率降低:因为还得遍历链表查找对应元素。
实现代码如下,注意包含哈希扩容方法,当负载因子超过2/3时,哈希表扩容至2倍。
//键值对
struct Pair{
public:
int key;
string val;
Pair(int key, string val){
this->key=key;
this->val=val;
}
};
class HashMapChaining{
private:
int size; //键值对数量
int capacity;//哈希表容量
double loadThres;//触发扩容的负载因子阈值
int extendRatio; //扩容倍数
vector<vector<Pair *>> buckets;//桶数组
public:
//构造函数
HashMapChaining():size(0),capacity(4),loadThres(2.0/3.0),extendRatio(2){
buckets.resize(capacity);
}
//析构函数
~HashMapChaining(){
for(auto &bucket : buckets){
for(Pair *pair:bucket){
//释放内存
delete pair;
}
}
}
//哈希函数
int hashFunc(int key){
return key%capacity;
}
//负载因子
double loadFactor(){
return (double)size/(double)capacity;
}
//查询操作
string get(int key){
int index = hashFunc(key);
//遍历桶,若找到key,则返回对应的value
for(Pair *pair: buckets[index]){
if(pair->key==key){
return pair->val;
}
}
//若未找到key,则返回空字符串
return "";
}
//添加操作
void put(int key, string val){
//当负载因子超过阈值时,则执行扩容
if(loadFactor()>loadThres){
extend();
}
int index=hashFunc(key);
//遍历桶,直到遇到指定的key,则更新对应的val并返回
for(Pair* pair: buckets[index]){
if(pair->key==key){
pair->val=val;
return ;
}
}
//若找不到该key,将该键值对添加到尾部
buckets[index].push_back(new Pair(key,val));
size++;
}
//删除操作
void remove(int key){
int index=hashFunc(key);
auto &bucket = buckets[index];
//遍历桶,从中删除键值对
for(int i = 0 ; i<bucket.size(); i++){
if(bucket[i]->key==key){
Pair *tmp = bucket[i];
bucket.erase(bucket.begin()+i);//从中删除键值对
delete tmp;
size--;
return;
}
}
}
//扩容哈希表
void extend(){
//暂存原哈希表
vector<vector<Pair* > > bucketsTmp = buckets;
//初始化扩容的新哈希表
capacity *= extendRatio;
buckets.clear();
buckets.resize(capacity);
size=0;
//将键值对从原哈希表搬运至新哈希表
for(auto &bucket : bucketsTmp){
for(Pair *pair:bucket){
put(pair->key,pair->val);
//释放内存
delete pair;
}
}
}
//打印函数
void print(){
for(auto &bucket:buckets){
cout<<"[ ";
for(Pair* pair:bucket){
cout<<pair->key<<" - > "<<pair->val<<",";
}
cout<<"]\n";
}
}
};
当链表很长时,查询效率 𝑂(𝑛) 很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而 将查询操作的时间复杂度优化至 𝑂(log 𝑛) 。
2.开放寻址
此方式不引入额外的数据结构,通过“多次试探”解决哈希冲突,试探方式:线性试探、平方试探、多次哈希等。具体实现代码略。
三:哈希算法
前面介绍哈希表的工作原理和哈希冲突的处理方法。无论是开放寻址还是链式地址,只能保证哈希表可以在发生冲突时正常工作,而无法减少哈希冲突的发生。此时如果哈希冲突比较频繁,哈希表的行嗯会急剧恶化。
键值对的分布情况由哈希函数决定,哈希函数的计算步骤;先计算哈希值,再对数组长度进行取模。即
index = hash(key) % capacity
当capacity固定时,哈希算法hash()决定了输出值,进而决定了键值对在哈希表中的分布情况。意味着未来降低哈希冲突发生的概率,需要集中注意力到哈希算法上。
1.哈希算法的目标
哈希算法应具备以下特点:
确定性:相同输入,得到相同输出。
效率高:计算哈希值应该足够快,开销小。
均匀分布:应使得键值对尽量均匀分布在哈希表中。
密码储存:保护用户密码的安全性。
数据完整性检查:哈希算法需要具备更高级的安全特性。
单向性:无法通过哈希值反推出关于输入数据的相关信息。
抗碰撞性:极难找到两个不同输入,使得他们的哈希值相同。
雪崩效应:输入的微小变化应当导致输出的显著变化,且不可预测。
2. 哈希算法的设计
哈希算法设计是一个很复杂问题,对于一些要求不高的场景,可以设计一些简单的哈希算法。
加法哈希:对输入的每个字符的ASCII码进行相加,将得到的总和作为哈希值,
乘法哈希:利用乘法的不相关性,每轮乘以一个常数,将各个字符的ASCII码累积到哈希值中,
异或哈希:将输入的每一个元素通过异或操作雷击到一个哈希值中,
旋转哈希:将每个字符的ASCII码累积到一个哈希值中,每次累积之前都对哈希值进行旋转操作。
//加法哈希
int addHash(string key){
long long hash=0;
const int MODULUS = 1000000007;
for(unsigned char c : key){
hash = (hash + (int)c)%MODULUS;
}
return (int)hash;
}
//乘法哈希
int mulHash(string key){
long long hash = 0;
const int MODULUS = 1000000007;
for(unsigned char c : key){
hash = (31*hash + (int)c)%MODULUS;
}
return (int)hash;
}
//异或哈希
int xorHash(string key){
int hash=0;
const int MODULUS = 1000000007;
for(unsigned char c : key){
hash ^=(int )c;
}
return hash&MODULUS;
}
//旋转哈希
int rotHash(string key){
long long hash=0;
const int MODULUS = 1000000007;
for(unsigned char c: key){
hash=((hash<<4)^(hash>>28)^(int)c)%MODULUS;
}
return (int)hash;
}
使用大质数作为模数,可以最大化地保证哈希值的均匀分布哦!
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA‑1、SHA‑2 和 SHA‑3 等。它们可以将任意长 度的输入数据映射到恒定长度的哈希值。
声明:本人所写内容全部参考hello-algo,仅用于个人复习。