哈希表的概念
顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度 ,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放 - 搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。哈希表的主要优点在于其查找、插入和删除操作的平均时间复杂度可以接近常数级别 O(1)
,效率非常高。
举个例子,如果我们想要知道一个字符串中,每个字母都出现了多少次,那么该如何做呢?我们可以创建一个大小为26的数组,然后让a对应数组中的第一个位置,b对应第二个位置,以此类推,每个位置都对应了一个字母,然后我们可以遍历字符串,出现一次,就把对应位置的字母的出现次数加1。比如说当前的字母是v,按照转换规则:hash[v - 'a']++
进行转换。
所以哈希表可以简单的理解为:把数据转换成下标,然后用数组的下标对应的值来表示这个数据。例如,在一个存储学生信息的哈希表中,学生的学号可以作为关键码。通过设计合适的哈希函数,将学号转换为哈希表中的索引,能够快速定位和获取对应的学生信息。
哈希函数
哈希函数的设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间,即生成的地址(下标)必须小于哈希表的最大地址(下标)
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数
1.直接定址法
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
假设我们要存储一组整数 {12, 25, 36, 48, 59}
,哈希函数为 H(key) = key
。
那么,对于数字 12
,其在哈希表中的位置就是 12
;数字 25
的位置是 25
;数字 36
的位置是 36
;数字 48
的位置是 48
;数字 59
的位置是 59
。
这样,当我们要查找某个数字时,例如查找数字 36
,直接去位置 36
查看是否存在即可。
这种直接定址法的优点是简单直观,不会产生冲突。但是如果要存储的数字范围很大,而哈希表的容量有限,就不太适合使用这种方法。
2.除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
比如我们想要搜索一个数据集合arr]{1,7,6,4,5,9}
里面的某些数据,哈希函数可以设置为hash(key) = key % capacity
,capacity为存储元素底层空间总的大小。
3.平方取中法
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
4.随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。通常应用于关键字长度不等时采用此法
还有其它类型的函数,这里就不再过多进行举例,我们常用的就是前2个。
哈希冲突
对于两个数据元素的关键字 k i k_i ki和 k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==Hash( k j k_j kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。比如上面的除留余数法,当有个数据为24的时候,那么它在数组中的位置就是下标为4的位置,与4共用一个位置,这就是哈希冲突。我们把具有不同关键码而具有相同哈希地址的数据元素称为"同义词"。
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
那么该如何避免哈希冲突呢?两种常见的方法是:闭散列和开散列
闭散列(开放定址法)
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的"下一个" 空位置中去。
比如刚才的情况,插入24,发现4的位置已经被占用了,5,6,7的位置也已经被占用了,只有8的位置没有被占用,所以放在8这个位置
但是当我们查找24的时候,通过哈希函数算出它的下标为4,发现哈希表中4的位置不是24,于是向后查找,第5,6,7的位置也没有,直到发现24在下标为8的位置。
实现方法
基本结构
我们使用枚举标记出哈希表中不同的状态
cpp
enum Status
{
EMPTY, // 节点为空
EXIST, // 节点中的值存在
DELETE // 数据被删除
};
我们为什么要这样来标记节点呢?
我们来看一下这种情况。当我们想要删除5时,将位置 5 标记为 "delete"。接下来插入元素 15,在遇到冲突时可能会探查位置 5,由于是 "delete" 状态,所以可以将 15 插入到这个位置。再比如说我们要查找24,根据哈希函数为先到下标为4的位置开始寻找,发现不是24,于是往后找,但是5这个位置发现没有数据了,于是停止查找。所以遇到位置 5 是 "delete" 状态,我们不能停止查找,要继续往后探查。
"delete" 用于标识该位置曾经存储过数据,但现在已经被删除。这与 "empty" 是不同的,"empty" 表示该位置从未被使用过。在查找元素时,遇到 "delete" 位置不能停止查找,而遇到 "empty" 位置则可以确定目标元素不存在。
现在我们来看哈希表的基本结构
cpp
enum Status
{
EMPTY,
EXIST,
DELETE
};
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
Status _s;
};
template<class K,class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
private:
vector<HashData<K,V>> _tables; // 哈希表
size_t _n = 0; // 存储的关键字的个数
};
构造函数的意思是先给10个空间,装不下再进扩容。
查找
查找数据遵循以下原则:通过哈希函数算出数据对应的位置,从该位置开始查找,如果不是,就继续往后进行查找。当遇到EMPTY
时,说明数据不在哈希表中,当遇到DELETE,EXIST
时,继续往后查找。
cpp
HashData<K, V>* Find(const K& key)
{
size_t hashi = key % _tables.size();
while (_tables[hashi]._s != EMPTY)
{
if (_tables[hashi]._s == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
}
return NULL;
}
代码解析:
HashData<K,V>* Find(const K& key)
输入一个key值,返回指向该节点的指针size_t hashi = key % _tables.size();
通过哈希函数计算出key对应的下标while (_tables[hashi]._s != EMPTY)
当该位置不是EMPTY时,就继续往后查找if (_tables[hashi]._s == EXIST&& _tables[hashi]._kv.first == key)
如果当前位置的状态是EXIST(表示有效数据),并且键值对中的键与要查找的键key相等,就返回当前位置的指针hashi++; hashi %= _tables.size();
如果没有找到,就往后移动一位,但是防止索引越界,就要对哈希表重新取模。return NULL
如果遍历完都没有找到,就返回空指针
插入
插入的基本原则是:
1.先通过Find函数,查找目标值在不在哈希表中,因为哈希表中不允许出现重复的值,如果已经存在,返回false,表示无法插入。
2.通过哈希函数计算对应位置的下标
3.开始插入数据
a. 如果下标对应的位置的状态是EXIST,就往后进行查找,直到遇到EMPTY或者DELETE
b. 如果对应的位置没有数据,就直接插入,插入后,把对应位置的状态改成EXIST
4.插入成功后,数据个数加1
cpp
bool Insert(const pair<K, V>& kv)
{
size_t hashi = hf(kv.first) % _tables.size();
while (_tables[hashi]._s == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._s = EXIST;
++_n;
return true;
}
在上面的讲解中,我们的哈希函数模的是数组的容量,而这里为什么模的是数组的大小呢?这是因为比如说我们的数组大小是15,容量是20,有一个数据经过计算得出对应的位置是18,但是数组只有15个位置,显然超过数组大小,所以我们要对数组的size进行取模。
有一个问题,如果插入的时候,位置满了,应该怎么办呢?
显而易见,我们需要进行扩容操作,但是我们并不是当哈希数组已经满了的时候才开始进行扩容。那么我们需要在什么时候就行扩容呢?讲解之前,我们需要引入一个概念:叫做负载因子( ∂ \partial ∂),它的定义为: ∂ \partial ∂ = 填入表中的元素个数 / 哈希表的长度
由于表长是定值, ∂ \partial ∂与"填入表中的元素个数"成正比,所以, ∂ \partial ∂越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之, ∂ \partial ∂越小,表明填入表中的元素越少,产生冲突的可能性就越小。所以对于开放定址法,负载因子应严格控制在0.7~0.8
之间,超过0.7就扩容。
cpp
// 负载因子0.7就扩容 数据个数/空间长度
if (_n * 10 / _tables.size() == 7)
{
size_t newSize = _tables.size() * 2;
HashTable<K, V> newHt;
newHt._tables.resize(newSize);
// 遍历旧表
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._s == EXIST)
{
newHt.Insert(_tables[i]._kv);
}
}
_tables.swap(newHt._tables);
}
由于_n和_tables.size()
都是整形,得到的结果不可能是小数,所以我们两边同时乘以10,这样才能得到整数。
代码解析:
size_t newSize = _tables.size() * 2;
计算新的哈希表的大小,变为原大小的二倍HashTable<K, V> newHt; newHt._tables.resize(newSize);
创建一个新的哈希表对象,并为新的哈希表调整内部存储空间的大小。for (size_t i = 0; i < _tables.size(); i++)
遍历旧的哈希表if (_tables[i]._s == EXIST) newHt.Insert(_tables[i]._kv);
只要当前节点的状态是EXIST,就将旧表的有效数据插入到新表当中_tables.swap(newHt._tables);
交换新旧哈希表的内部存储
这里有一个知识点,就是我们新创建的哈希表newHT
的生命周期仅在if的这个括号内。当出了这个作用域,newHT
就会调用析构函数,自动销毁内部的vector,交换完成之后,旧的哈希表就交给了newHT
,此时这个newHT
起到了销毁旧的哈希表的功能。
插入函数总代码
cpp
bool Insert(const pair<K, V>& kv)
{
// 负载因子0.7就扩容 数据个数/空间长度
if (_n * 10 / _tables.size() == 7)
{
size_t newSize = _tables.size() * 2;
HashTable<K, V> newHt;
newHt._tables.resize(newSize);
// 遍历旧表
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._s == EXIST)
{
newHt.Insert(_tables[i]._kv);
}
}
_tables.swap(newHt._tables);
}
size_t hashi = hf(kv.first) % _tables.size();
while (_tables[hashi]._s == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._s = EXIST;
++_n;
return true;
}
删除
删除就比较简单了。先通过Find函数查找目标值在不在哈希表中,如果在,就把该位置的状态变成DELETE,再把数据个数减1,;如果没有找到,就返回false。
cpp
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_s = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
接下来有一个问题,就是上面的只适合查找整型,当遇到浮点型,字符串类型的时候,就失去作用了,因为无法对字符串类型取模,那么这个问题该如何解决呢?
办法就是把传进来的数据变成一个整型。为此我们可以写一个仿函数,可以把数据转换成整型类型,然后再对这个整型进行除留余数法。我们可以先处理一下"整型->整型"的仿函数。
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
我们可以把传进来的数据强转成无符号的类型。
接着我们来处理一下string->int
的转换规则,有人发现,可以把字符串的每一位的ASCII的值加起来,这样就可以转成整型来处理了,但是还有一个小问题,如果是"abc"和"acb"
的ASCII值的和是一样的,这样会产生冲突。所以我们可以在此基础上,对每一个字母乘以一个数值,这样就会减少冲突产生,分散性很强。
cpp
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 31;
hash += e;
}
return hash;
}
};
现在我们就可以跟哈希表传入第三个模板参数,用于传入仿函数
cpp
template<class K,class V, class Hash = HashFunc<K>>
class HashTable
{};
由于我们的字符串转整型被写成了一个模板特化,所以我们的string也可以通过默认值直接转化,不用自己传入模板参数,所有用到取模的操作都要通过仿函数转成整型,再对此统一操作。
Hash hf;
size_t hashi = hf(kv.first) % _tables.size();
闭散列完整代码展示:
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 31;
hash += e;
}
cout << key << ":" << hash << endl;
return hash;
}
};
namespace open_address
{
enum Status
{
EMPTY,
EXIST,
DELETE
};
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
Status _s;
};
template<class K,class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子0.7就扩容 数据个数/空间长度
if (_n * 10 / _tables.size() == 7)
{
size_t newSize = _tables.size() * 2;
HashTable<K, V> newHt;
newHt._tables.resize(newSize);
// 遍历旧表
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._s == EXIST)
{
newHt.Insert(_tables[i]._kv);
}
}
_tables.swap(newHt._tables);
}
Hash hf;
size_t hashi = hf(kv.first) % _tables.size();
while (_tables[hashi]._s == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._s = EXIST;
++_n;
return true;
}
HashData<K, V>* Find(const K& key)
{
Hash hf;
size_t hashi = hf(key) % _tables.size();
while (_tables[hashi]._s != EMPTY)
{
if (_tables[hashi]._s == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
}
return NULL;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_s = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
void Print()
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._s == EXIST)
{
cout << "[" << i << "]->" << _tables[i]._kv.first << ":" << _tables[i]._kv.second << endl;
}
else if (_tables[i]._s == EMPTY)
{
printf("[%d]->\n", i);
}
else
{
printf("[%d]->D\n", i);
}
}
cout << endl;
}
private:
vector<HashData<K,V>> _tables;
size_t _n = 0; // 存储的关键字的个数
};
}
开散列(哈希桶)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素 。
而使用哈希桶来实现哈希表,那么哈希表中的数组不再直接存储数据,而是存储一个链表的指针,通过哈希函数得到对应的下标后,就插入到当前的位置。每一个链表称为一个哈希桶,每个哈希桶中,都存放着存在哈希冲突的元素。本质是通过指针数组来实现的。
实现方法
基本结构
既然是链表存储,那么每个节点既要存储当前的值,也要存储下一个节点的指针。
cpp
template<class K, class V>
struct HashNode
{
HashNode* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
:_kv(kv)
, _next(nullptr)
{}
};
哈希表的结构:
cpp
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_tables.resize(10);
}
private:
vector<Node*> _tables; //链表指针数组
size_t _n = 0; //数据个数
};
由于我们开辟了外部资源,所以我们需要写一个析构函数,防止内存泄露。
cpp
~HashTable()
{
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;
}
}
查找
先通过哈希函数算出对应的下标,找到对应下标的链表,然后遍历链表,如果找到,就返回该节点的指针,否则返回NULL。
cpp
Node* Find(const K& key)
{
Hash hf;
size_t hashi = hf(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return NULL;
}
插入
开始的逻辑和开放定址法中的一样,找到对应的位置后,使用头插将数据插入到链表当中。
cpp
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
Hash hf;
// 负载因子最大到1
if (_n == _tables.size())
{
vector<Node*> newTables;
newTables.resize(_tables.size() * 2, nullptr);
// 遍历旧表
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
// 挪动到映射的新表
size_t hashi = hf(cur->_kv.first) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = hf(kv.first) % _tables.size();
Node* newnode = new Node(kv);
// 头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
如果按照以前的方法进行扩容的话,会极大的浪费时间,因为如果我们单纯的进行插入,就要把原来的节点释放掉,再创建一个新的节点,所以我们需要重新处理一下。
代码解析:
vector<Node*> newTables;
创建一个新的存储指针节点的数组newTables.resize(_tables.size() * 2, nullptr);
调整新数组的大小为原大小的2倍,初始化为空Node* cur = _tables[i];
获取当前桶中的头结点的指针。当cur不为空的时候,重新计算新的哈希表中的映射位置,然后使用头插法把数据插入到新的哈希表中。插入完成后将旧表中的位置置成空。_tables.swap(newTables);
交换新旧表中的数据
删除
先通过哈希函数找出桶的位置,然后在桶的位置遍历查找目标值。删除节点的逻辑和链表一样,使用头删法。
cpp
bool Erase(const K& key)
{
Hash hf;
size_t hashi = hf(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;
}
prev = cur;
cur = cur->_next;
}
return false;
}
需要注意的是,删除节点后,需要把节点的前后连接起来。