【C++】---哈希算法
一、哈希概念
顺序结构以及平衡树中,元素关键码(K,pair等)与 其存储位置 之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。
顺序查找:时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一 一映射 的关系,那么在查找时通过该函数可以很快找到该元素。
说白了:哈希思想就是,值 与其 存储位置 之间的映射关系
1、插入和查找
- 插入:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放hashi就是对应的存储位置,key就是关键码,table是哈希表
其对应的映射位置的计算方法是: hashi = key % table.size();
注意:这里%取得是,哈希数组的size(),为什么不取capacity()?
原因很简单:
就是如果你取模取 capacity 的话,他最终得到的hashi可能是大于size小于capacity,但是这块区间的内容是不能访问的,由于vector的概念上面就已经明确的规定了。大于szie小于capacity,这个区间的内容虽然存在为空,但是你无法进行确切的访问!
画图解释:
2. 查找元素:
(1)先计算出 hashi (2)再去计算出来的 hashi 位置 进行key的判断 :判断hashi ->key 与我们所要找的是否相等!
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功
2、哈希表
(1)什么是哈希表?
简单的说,就是运用->哈希思想,而形成的数据结构!
运用 “哈希思想” 的函数叫做哈希函数,其关键的核心步骤:就是找出各个数据对应的储存位置的转换公式!
即:
按照这种方法查找不用拿key多次比较,不用像链表2叉树等等那些数据结构进行多次比较才会查找到结果。因此查找的速度比较快。
(2)什么是:哈希冲突?
哈希冲突:就是说,不同的数据,通过哈希函数,来计算出它所对应的映射位置的时候,位置相同,就是哈希冲突!
比如说这里的10001和11,%10之后,他们所得的值都是1,都应该放在下标为1的位置,这就是哈希冲突!
引起哈希冲突的原因:哈希函数设计不合理。哈希函数设计原则包括:
(1)哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
(2)哈希函数计算出来的地址能均匀分布在整个空间中
(3)哈希函数应比较简单
3、常见的哈希函数
(1)直接定址法
取关键字的某个线性函数为散列地址:Hash(key)= A*key + B
优点:简单,速度快,节省空间,查找key O(1)的时间复杂度
缺点:当数据范围大时会浪费空间,不能处理浮点数,字符串数据
使用场景:适用于整数,数据范围比较集中
例如计数排序,统计字符串中出现的用26个英文字符统计,给数组分配26个空间,遍历到的字符是谁,就把相应的元素值++
(2)除留余数法
把数据映射到有限的空间里面。设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将key转换成哈希地址。如第2节哈希表的例子。
哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
二、用闭散列解决哈希冲突
闭散列也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。 下一个位置怎样找呢?有以下两种常见方式:1、线性探测法 2、哈希桶
1、线性探测法介绍
(1)如下场景,要插入22,通过哈希函数hashfunc(22) = 22%10=2计算出的地址为2,2的位置已经有数据2了,现在发生了冲突:
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
①插入:通过哈希函数获取待插入元素在哈希表中的位置。如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
②删除:采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,否则会影响其他元素的搜索。比如删除元素2,如果直接删除掉,22查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素,即给每个位置一个标记,用空(EMPTY)、存在(EXIST)、删除(DELETE)3种状态来区分。
(2)关于扩容,负载因子概念的引入:
负载因子= 哈希表中的有效数据个数 / hash的size()
在开放定址法中,负载因子最多在0.7~0.8之间,大于负载因子 就扩容!
2、线性探测法实现
(1)状态
区分哈希表的一个位置有没有数据,如果用两种状态表示,在(1)或不在(0),那么就会带来两个问题:
①0表示不在,那么如何存数据0呢?
②如果数据发生冲突,当前位置和后面位置都存放的是冲突数据,加入当前位置的数据被删除了,那么查找key时发现当前位置状态为不在,那么就不会再向后查找了。
因此要用3个状态位分别表示空、已占用、已删除,用枚举表示状态位:
// 关于:储存位置状态的类
enum State// 标记存储位置的状态,为了便于查找
{
EMPTY,
EXIST,
DELETE
};
(2)定义HashData
哈希数据应包含两个成员:数据和状态:
// 关于:所给数据的类
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY; // 初始化
};
(3)哈希表
哈希表包含两个成员:哈希数据、存储的有效数据的个数
模板有3个参数K、V、HashFunc(仿函数)。
①由于不知道key是K还是pair,所以需要定义两个模板参数K、V来包含key是K或pair的两种情况
②由于不知道key的数据类型是int还是string、pair、struct,计算key的映射位置时需要取模,但是只能对int型取模,string、struct、pair无法取模,HashFunc作为仿函数,它的实例可以分别应对这些类型的取模。
template<class K, class V, class HashFunc>
class HashTable
{
private:
vector<HashData<K, V>> _table;//哈希表
size_t _n = 0;//存储有效数据的个数
};
(4)插入
①先查看key查看在不在,在就插入失败
②第一次插入时,哈希表的的是0,所以第一次插入时就要让表扩容
③还需要判断负载因子是否>0.7,如果表满了,就要开一个新表,并把旧表的数据都插入到新表上
④当计算的位置有数据时,就向后探测,直到探测到空位置即可存入数据
// 一、插入
bool Insert(const pair<K, V>& kv)
{
// 防止数据冗余,重复的不能插进去!
if (Find(kv.first))
return false;
// 1、但是如果负载因子超标了 就需要扩容!
if (_n * 10 / _tables.size() >= 7) // 一般>=0,7就要扩容,因为_n/_tables.size()不会是浮点数,所以两边就要*10
{
// 现代写法:复用 insert 让他来帮我计算,重新映射数值所对应的位置
HashTable<K, V> newHT;// 创建了一个新的哈希表:newHT
newHT._tables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)// 把原来的hashtable里面的值,依次重新映射到newHT中
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);// 为啥不是 直接插入:_tables[i]???
// 因为 _tables[i]是HashData对象,里面包括:_kv和_state,而且insert函数接收的参数是kv,不是HashData
}
}
_tables.swap(newHT._tables);
}
// 2、插入
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();// 防止下标超出范围,%后会让hashi不断在这个范围内循环!
}
// 走出循环后,说明已经找到了空位!
_tables[hashi]._kv = kv;
// 为啥不是这样写:_tables[hashi] = kv; _tables[hashi]和kv难道不是一个等级吗?
// 因为_tables[hashi]是数据、是HashData对象里面包括:_kv和_state
_tables[hashi]._state = EXIST;
++_n;
return true;
}
(5)查找
①无论传给哈希表的数据是K还是pair,查找时,都需要用K做key来进行查找
②计算元素位置
③如果当前位置元素为key,那么就返回该元素,否则可能发生了冲突,继续向后探测
// 二、查找
HashData<K, V>* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
// 正常情况下找到hashi就会找到其对应的值,由于hash冲突,不得不往后遍历
while (_tables[hashi]._state != EMPTY)
{
// 因为下面的删除函数并不是 把数值真的删除了,而是标记了状态,所以为了避免删除后你还能找到,这里的if判断要加一个存在(EXIST)!
if (_tables[hashi]._state == EXIST &&
_tables[hashi]._kv.first == key)
// 查找的时候,这个值必须 存在 && 相等 才算找到
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
}
return nullptr;
}
(6)删除
利用假删除,将状态标记为删除即可:
// 三、删除
bool Erase(const K& key)
{
// 这个删除函数,不是真正意义上的删除,只是对状态进行标记,数值并没有删除!
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
--_n;
return true;
}
}
(7)仿函数
1、仿函数的目的是为了让不同类型的数据能够取模,方便计算数据位置
类的仿函数模板,默认支持int:
// 针对所给数据是:整数、负数、浮点数......(将数据强制转化为 size_t类型,才能%算下标) 的仿函数!!!
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
2、string类型的仿函数,不能用上述仿函数的类模板,因为字符不能取模。string类型的仿函数用来做key的数值尽量要找不重复的,否则会导致发生冲突的概率比较高
// string转int的仿函数 (以后所说的:仿函数 都指的是一个类!因为仿函数的定义就是:使用起来像函数的一个类!)
struct StringHashFunc
{
// BKDR
size_t hash = 0;
size_t operator()(const string& str)
{
for (auto e : str)
{
hash *= 131;// 每次遍历str中的一个字符就让 hash*131,最后再加起来!
hash += e;
}
return hash;
}
};
任意类型(pair、结构体)都可以做key,key尽量选择不容易重复的成员,跟一个把这个类型对象转换成整形的仿函数。比如一个类型做map/set的key,那就要求该类型能支持比较大小。又比如一个类型做unordered_map/unordered_set的key,那就要求该类型能支持转换成整形+相等比较。
(8)完整代码段
namespace open_address
{
// 关于:储存位置状态的类
enum State// 标记存储位置的状态,为了便于查找
{
EMPTY,
EXIST,
DELETE
};
// 关于:所给数据的类
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY; // 初始化
};
// string转int的仿函数 (以后所说的:仿函数 都指的是一个类!因为仿函数的定义就是:使用起来像函数的一个类!)
struct StringHashFunc
{
// BKDR
size_t hash = 0;
size_t operator()(const string& str)
{
for (auto e : str)
{
hash *= 131;// 每次遍历str中的一个字符就让 hash*131,最后再加起来!
hash += e;
}
return hash;
}
};
// 关于:哈希表的 类
template<class K, class V, class Hash = HashFunc<K>>// 给一个缺省值,默认数据就是int类型
class HashTable
{
// newHT 是哈希表整个类对象、_tables类成员变量(相当于数组)
public:
// 构造函数
HashTable()
{
_tables.resize(10);
}
// 一、插入
bool Insert(const pair<K, V>& kv)
{
// 防止数据冗余,重复的不能插进去!
if (Find(kv.first))
return false;
// 1、但是如果负载因子超标了 就需要扩容!
if (_n * 10 / _tables.size() >= 7) // 一般>=0,7就要扩容,因为_n/_tables.size()不会是浮点数,所以两边就要*10
{
// 现代写法:复用 insert 让他来帮我计算,重新映射数值所对应的位置
HashTable<K, V> newHT;// 创建了一个新的哈希表:newHT
newHT._tables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)// 把原来的hashtable里面的值,依次重新映射到newHT中
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);// 为啥不是 直接插入:_tables[i]???
// 因为 _tables[i]是HashData对象,里面包括:_kv和_state,而且insert函数接收的参数是kv,不是HashData
}
}
_tables.swap(newHT._tables);
}
// 2、插入
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();// 防止下标超出范围,%后会让hashi不断在这个范围内循环!
}
// 走出循环后,说明已经找到了空位!
_tables[hashi]._kv = kv;
// 为啥不是这样写:_tables[hashi] = kv; _tables[hashi]和kv难道不是一个等级吗?
// 因为_tables[hashi]是数据、是HashData对象里面包括:_kv和_state
_tables[hashi]._state = EXIST;
++_n;
return true;
}
// 二、查找
HashData<K, V>* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
// 正常情况下找到hashi就会找到其对应的值,由于hash冲突,不得不往后遍历
while (_tables[hashi]._state != EMPTY)
{
// 因为下面的删除函数并不是 把数值真的删除了,而是标记了状态,所以为了避免删除后你还能找到,这里的if判断要加一个存在(EXIST)!
if (_tables[hashi]._state == EXIST &&
_tables[hashi]._kv.first == key)
// 查找的时候,这个值必须 存在 && 相等 才算找到
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
}
return nullptr;
}
// 三、删除
bool Erase(const K& key)
{
// 这个删除函数,不是真正意义上的删除,只是对状态进行标记,数值并没有删除!
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
--_n;
return true;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n;// 表里面 有效数据个数
};
}
三、用开散列解决哈希冲突
1、开散列(哈希桶)的介绍:
开散列也叫拉链法,先对所有key用散列函数计算散列地址,把有相同地址的key每个key都作为一个桶,通过单链表链接在哈希表中。
因此,开散列的每个桶中存放的都是哈希冲突的元素,负载因子较低。当桶超过一定长度时,就把冲突最多的桶就换成红黑树。实际中哈希桶的结构更实用,因为哈希桶空间利用率高,并且在极端情况下还有解决方案
2、哈希桶的实现
哈希桶作为指针数组,数组的每个元素是一个结点的指针,链表不需要带哨兵位,且头插的效率比较高。
(1)哈希仿函数
// 针对所给数据是:整数、负数、浮点数......
// (将数据强制转化为 size_t类型,才能%算下标) 的仿函数!!!
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 针对所给数据是:string(将string转化为size_t类型,才能%算下标!)
// 利用字符串哈希算法(BKDR)
//模版的特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& str)
{
size_t hash = 0;// 临时变量 用来标记string中每个字符对应的ASC码值,
// 相乘相加后得到 hash 并返回便于%
for (auto e : str)
{
hash *= 131;// 字符串哈希算法之BKDR
hash += e;
}
return hash;
}
};
(2)哈希桶节点
哈希桶只需要2个成员:数据、下一个桶指针
就相当于单链表!!!
// 关于桶节点的类:HashNode
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(const pair<K,V>& kv)
:_kv(kv)
,_next(nullptr)
{
}
};
(3)哈希表
哈希表有两个成员:哈希表、有效数据的个数
template<class K,class V,class HashFunc=Hash<K>>
class HashTable
{
typedef HashNode<K, V> Node;
private:
vector<Node*> _table;//哈希表
size_t _n;//有效数据个数
};
(4)查找
先计算key在哈希表中的位置,然后后再该位置的哈希桶中遍历查找:
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
// 找到了就返回!
if (cur->_kv.first == key)
return cur;
// 没找到 就继续往下遍历!
cur = cur->_next;
}
return nullptr;
}
(5)插入
①查找key在不在哈希表中(去重)
②不在就要先判断哈希表是否满了(扩容)
③若哈希表满了就要重新开一个新的哈希表,将旧表数据全部头插到新表中
④插入数据
bool Insert(const pair<K, V>& kv)
{
// 扩容:
// 负载因子==1的时候就扩容!
方法一:太浪费空间了,new10个空间,最后又释放了10个空间。
//if (_n == _tables.size())
//{
// HashTable<K, V> newHT;
// newHT._tables.resize(_tables.size() * 2);
// // 将旧表重新计算 负载到新表!
// for (size_t i = 0; i < _tables.size(); i++)
// {
// Node* cur = _tables[i];
// while (cur)
// {
// newHT.Insert(cur->_kv); // 复用
// cur = cur->_next;
// }
// }
// _tables.swap(newHT._tables);
//}
if(Find(kv.first))
return false;
// 方法二:遍历旧数组、直接将旧表的数据计算好位置,然后直接插入到新表。
if (_n == _tables.size())
{
vector<Node*> new_table(_tables.size() * 2, nullptr);
//遍历旧数组
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;// 先保存一下 next
// 头插到新表相应位置!
// 老套路:先计算位置
size_t hashi = cur->_kv.first % new_table.size();
// 头插:不要搞错了:cur是旧表中将要插入的数据!
cur->_next = new_table[hashi];// new_table[hashi]是桶的第一个!!!
cur= new_table[hashi];
cur = next;// 继续对原数组进行遍历查找出所有带有桶节点的。
}
_tables[i] = nullptr;// 防止 有两个位置 指向同一个节点!对原数组的数据进行指向空
}
_tables.swap(new_table);
}
Hash hs;
size_t hashi = hs(kv.first )% _tables.size();
Node* newnode = new Node(kv);
// 头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
(6)删除
①计算key在表中的位置
②要删除的数据是不是该位置的第一个哈希桶,如果是,那就让哈希表的第一个节点变成第二个桶,否则让这个桶的前一个桶指向这个桶的下一个桶
(这就是:写while 和 判断的原因!)
bool Erase(const K& key)
{
// 删除就相当于在单链表中进行删除,需要保存前一个位置:prev
// 大致分两种情况:①删除头结点 ②删除中间 和 尾结点
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
// 可以删除了
// ①如果删除的是头结点
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
关于 :删除的理解,主要是一定要搞清楚!变量的类型
(7)关于哈希桶的细节总结、以及注意事项
-
报错:无法将参数进行转换:就需要我们自己再写一个构造函数
-
哈希桶为啥要自己写一个析构函数?
-
为啥在哈希桶里面的扩容,要舍弃“线性探测”的扩容方法?
所以我们直接采用方法二:
哈希桶扩容采用,方法二:遍历旧数组、直接将旧表的数据计算好位置,然后直接插入到新表
(8)完整代码段
HashTable.h
namespace hash_bucket
{
// 关于桶节点的类:HashNode
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(const pair<K,V>& kv)
:_kv(kv)
,_next(nullptr)
{
}
};
// 关于:哈希表的 类
template<class K, class V,class Hash=HashFunc<K> >
class HashTable
{
typedef HashNode<K, V> Node;
private:
vector<Node*> _tables; // 指针数组!
size_t _n;
public:
// 构造
HashTable()
{
_tables.resize(10, nullptr);
_n = 0;
}
// 析构函数:针对于 自定义类型(实际上就是链表的析构)
~HashTable()// 就遍历数组、看哪个地方有桶就对它进行delete
{
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
// 1、插入
bool Insert(const pair<K, V>& kv)
{
// 扩容:
// 负载因子==1的时候就扩容!
方法一:太浪费空间了,new10个空间,最后又释放了10个空间。
//if (_n == _tables.size())
//{
// HashTable<K, V> newHT;
// newHT._tables.resize(_tables.size() * 2);
// // 将旧表重新计算 负载到新表!
// for (size_t i = 0; i < _tables.size(); i++)
// {
// Node* cur = _tables[i];
// while (cur)
// {
// newHT.Insert(cur->_kv); // 复用
// cur = cur->_next;
// }
// }
// _tables.swap(newHT._tables);
//}
if(Find(kv.first))
return false;
// 方法二:遍历旧数组、直接将旧表的数据计算好位置,然后直接插入到新表。
if (_n == _tables.size())
{
vector<Node*> new_table(_tables.size() * 2, nullptr);
//遍历旧数组
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;// 先保存一下 next
// 头插到新表相应位置!
// 老套路:先计算位置
size_t hashi = cur->_kv.first % new_table.size();
// 头插:不要搞错了:cur是旧表中将要插入的数据!
cur->_next = new_table[hashi];// new_table[hashi]是桶的第一个!!!
cur= new_table[hashi];
cur = next;// 继续对原数组进行遍历查找出所有带有桶节点的。
}
_tables[i] = nullptr;// 防止 有两个位置 指向同一个节点!对原数组的数据进行指向空
}
_tables.swap(new_table);
}
Hash hs;
size_t hashi = hs(kv.first )% _tables.size();
Node* newnode = new Node(kv);
// 头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
// 2、查找:
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
// 找到了就返回!
if (cur->_kv.first == key)
return cur;
// 没找到 就继续往下遍历!
cur = cur->_next;
}
return nullptr;
}
// 3、删除:
bool Erase(const K& key)
{
// 删除就相当于在单链表中进行删除,需要保存前一个位置:prev
// 大致分两种情况:①删除头结点 ②删除中间 和 尾结点
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
// 可以删除了
// ①如果删除的是头结点
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
};
}