🛸顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此查找一个元素时,需要用关键码进行多次的比较。顺序查找时间复杂度为O(N),平衡树中查找元素的时间复制度为O(logN),即取决于树的高度。而理想的搜索方法是可以不经过任何比较,在时间复杂度为O(1)的情况下从表中找到需要的元素。如果构造一种存储结构,该结构可通过某种函数使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么查找元素时就可以直接定位元素的位置,使查找元素的效率大大提高。
❓该结构是如何存储和查找元素的呢?
插入元素: 根据待插入元素的关键码计算出该元素在此存储结构中对应的位置进行插入。
查找元素: 对元素的关键码进行同样的计算,计算出的函数值作为元素的存储位置,在结构中按此位置进行元素的比较,若关键码相等,则查找成功。
这种存储元素的方式即为哈希(散列)方法,哈希方法中使用的关键码转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table),也可以叫做散列表。
🌰举个例子:将以下数据插入哈希表中 {1,5,6,8,9,10},哈希函数设置为 hash(key) = key % capacity ,capacity 为存储元素底层空间的总大小。
其各元素对应的位置如下图所示:
当我们需要搜索某个元素时,只需要将它的关键码按照对应哈希函数计算其存放位置并判断是否待查找元素即可。但是也出现了一些问题:若多个待插入元素用哈希函数计算出来的值相等,那么就存在冲突问题了,那么应该怎么进行解决呢?
哈希冲突:
不同元素的关键码通过哈希函数计算出相同的哈希地址,这称为哈希冲突(哈希碰撞)。
在实际运用中,冲突只能尽量减小,而不能完全避免。
哈希函数(Hash Function),又叫做散列函数、散列算法。哈希函数能够将任意长度的输入值转换成固定长度的值输出,该值称为散列值,输出值通常为字母与数字组合。
引起哈希冲突的一个可能原因是:哈希函数的设计不够合理
哈希函数的设计原则:
以下是常见的哈希函数:
下列哈希函数中第一种和第二种较为常用,其它的可作为了解。哈希函数的设计方法有很多,哈希函数设计的越精妙,产生的哈希冲突会降低,但是哈希冲突是无法完全避免的。因此,我们只能设计更精妙的方法去降低哈希冲突的概率。
1、直接定址法
取关键字的某个线性函数为散列地址:Hash(key) = A*key + B
优点:简单,且计算出的值在空间中分布比较均匀。
缺点:需要预先知道关键字的分布情况,使用场景较局限,通常要求数据是整数且范围集中。
使用场景:适用于整数且数据范围比较集中的情况。
2、除留余数法
若散列表中允许的地址数为 n ,取一个不大于 n ,且最接近或者等于 n 的质数 p 作为除数,按照哈希函数:Hash(key) = key % p (p<=n),将关键码转换为哈希地址。
优点:简单且使用场景较广泛。
缺点:存在一定的哈希冲突,若哈希冲突较多而无法解决,将会导致效率大大的下降。
3、平方取中法
将数据中的关键字进行平方运算,抽取运算结果中间部分的值作为哈希地址。如:若关键字为1234 ,1234^2 = 1522756,因此可取中间的三位数 227 用来作为哈希地址。
使用场景:关键码位数不是很大,不知道关键码的分布情况。
4、折叠法
折叠法是将关键字分割成位数相等的几部分(最后一部分可以短一些),然后将这几部分叠加求和,并按照散列表表长,取后几位作为哈希地址。
使用场景:预先不知道关键字的分布,适合关键字位数比较多的情况。
5、随机数法
选择一个随机函数,取关键字的随机函数值作为哈希地址,即 Hash(key) = random(key),其中 random 为随机数函数。
使用场景:适用于关键字长度不等
6、数学分析法
设有 n 个 d 位数,每一个可能有 r 种不同的符号,这 r 种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的概率均等,在某些位上分布不均匀只有某几种符号经常出现。可以根据散列表的大小,选择其中各种符号分布均匀的若干位作为哈希地址。
使用场景:关键字位数比较大的情况,事先知道关键字的分布且关键字的若干位分布较均匀的情况。
在散列函数的构造过程中,我们尽量使散列地址均匀地分布与整个地址空间,但在实际应用中,冲突只能尽量减小,而不能完全避免。接下来我们要解决的是在哈希冲突发生时,如何有效的解决它。常用的处理冲突的方法有两种:闭散列(开放地址法 - Open Addressing)和 闭散列(链地址法 - Linear Probing)。
闭散列:也叫做开放定址法,当发生哈希冲突时,若哈希表未被填满,说明哈希表中还有空的存储位置,这时就需要将冲突的这个值存在另外一个空位置去,那我们应该怎样寻找那个空位置呢?我们较为常用的有以下两种方法:
1、线性探测
线性探测即从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置即可。它的基本公式是:Hash(key) = (key + d) mod TableSize (d = 1,2,3,4......)
.
例如:将序列{10,3,5,7,0,13,17}用除留余数法插入到表长为10的哈希表中。
序列插入过程如下:
通过以上插入数据的演示我们可以发现,随着插入数据的增多, 插入的数据产生冲突的概率也增加了。哈希表中的元素越多,查找数据时的效率就越低。因此,引入了负载因子(载荷因子)的概念。
载荷因子 = 表中元素的个数 / 散列表的空间大小
载荷因子越大,产生冲突的可能性就越大;
载荷因子越小,产生冲突的可能性就越小;
❓哈希表什么情况下进行扩容?怎样扩容?
负载因子越大,哈希冲突的概率越高,效率降低。负载因子越小,哈希表冲突的概率越小,但也意味着哈希表中的空间利用率越低,此时很多空间都被浪费了。对于开放定址法,载荷因子是特别重要的因素,一般控制在 0.7 - 0.8 以下,若超过 0.8 则会导致在表中查询数据时的 CPU 缓存不命中(cache missing) ,按照质数曲线上升。因此,一些采用开放定址法的Hash库,如Java的系统限制了负载因子为0.75,若超过此值则采用扩容的机制。
总结:线性探测的实现原理非常简单且易于理解,但是却存在着一些问题,若产生的冲突较多,所有冲突连成一片,则产生数据堆积,继续插入数据时发生踩踏效应(需要多次比较寻找位置),因此导致哈希表的搜索效率降低。
2、二次探测
线性探测的缺陷是产生冲突的数据堆积在一起 ,这与其找下一个空位置的方法有关,因为找空位置的方式是挨着逐个往后查找,因此二次探测为了避免这个问题,找下一个空位置的公式为::Hash(key) = (key + d^2) mod TableSize (d = 1,2,3,4......)
.
例如:将序列{1,3,11,12,5,17,7}用除留余数法插入到表长为10的哈希表中。当发生哈希冲突时使用二次探测的方法寻找下一个空位置。
序列插入过程如下:
研究表明:当表的长度为质数且表的负载因子不超过0.5时,新的表项一定能够插入,而且任意一个位置都不会被探测两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表满的情况,但是在插入时一般情况下确保负载因子不超多0.7,超出则需要考虑扩容了。
散列表最大的缺陷就是空间利用率较低,这也是哈希的缺陷。 |
开散列又叫链地址法,首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集称为一个桶,各个桶中的元素通过一个单链表链接起来,桶中存储各链表的头节点。
例如:用除留余数法将序列{47,7,29,11,16,92,22,8,3,50,37,89,94,21}插入到表长为11的哈希表中,散列函数为:Hash(key) = key % 11
。用分离链接法处理冲突,如下如所示:
一般情况下,每个结点对应的链表结点都很少,将n个关键码通过某个散列函数,存放在散列表中的m个桶中,平均每一个桶中存储n/m个数据。以搜索平均长度为n/m的链表代替了搜索长度为n的顺序表,搜索效率增加了。
🧩用链地址法处理哈希冲突,需要增加链表指针,增加了存储开销。事实上,由于开放地址法需要确保搜索效率而浪费了大量的空间,而表项所占的空间又比指针大得多,因此使用链地址法相较于开放地址法更加节省空间。开散列的哈希桶,负载因子可以超过1,一般情况下控制在[0.0,1.0]之间。
❗桶的数量是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表很长,影响哈希表的性能。因此在一定条件下需要对哈希表进行增容。此时哈希表中的数据会全部重新插入到增容后的哈希表中,此时降低了冲突的概率。
在新版的Java中,为了避免数据冲突太多导致效率低下的情况,当某个哈希桶中的数据超过8个时,就将该桶下的单链表结构改为红黑树结构。而当桶中的数据减小到8个及以下时,又将红黑树结构退回为单链表结构。因此就算冲突的数据很多也解决了效率的问题。
如下图所示:
在闭散列实现的哈希表中,哈希表的每个存储位除了存储数据之外还应该存储一个状态值来标识该位置的状态,其中可用3个状态位(EMPTY(没有存储数据),EXITS(当前位置已存储数据),DELTE(当前位置存在的数据被删除))来标识每个存储位当前的存储情况。
// 哈希表每个空间给个标记
// EMPTY此位置为空, EXITS此位置已有元素, DELETE元素已经删除
enum State{EMPTY,EXITS,DELETE};
❓为什么每个存储位都需要标识自身的状态呢?
例如:我们需要查找数据25,但是在查找数据之前对数据15进行了删除,因此我们在算出25的哈希位置是5之后开始进行对比依次向后进行查找,若找到数据25则判断为存在,若遇到空格还没有找到就停下来,说明哈希表中没有存储此数据。在此案例中,15被删除了,所以查找到下标为6的位置为空则停止查找,就找不到25了。因此我们需要用一个状态去标识每个存储位的实际情况。
显然,我们不可能用0,1这样的数字标识符去标识状态,因为哈希表中可能存储这样的数据。因此,我们用三个状态(EMPTY,EXITS,DELETE)来标识。当我们在哈希表中查找数据时,若当前位置数据与待查找数据不匹配,且当前位置的状态是EXITS或者DELETE时,都应该继续往后进行查找,若遇到状态为EMPTY则不存在待查找的数据。插入元素时,可将元素插入到状态为EMPTY和DELETE的位置,并将状态改为EXITS。
// 闭散列
namespace CloseHash
{
// 标识存储位置状态
enum State
{
EMPTY,
EXITS,
DELETE,
};
template<class K,class V>
struct HashDate
{
pair<K, V> _kv; // 存储数据的键值对
State _state; // 标记状态
};
template<class K,class V,class HashFunc>
class HashTable
{
public:
private:
vector<HashDate<K, V>> _table; // vector作为底层数据结构
// 为了插入元素时计算当前哈希表的负载因此,因此我们增加此变量
size_t _n = 0; // 存储的有效数据个数
};
}
哈希表中插入数据的步骤如下:
哈希表的初始空间大小为0时,直接将其初始化为10(根据具体情况设置初始值),负载因子的大小控制在0.7及以下,若负载因子大于0.7则创建一个空间大小为原始哈希表空间大小2倍的新哈希表。然后遍历原始哈希表,将其中数据插入新的哈希表中,然后交换两个哈希表即可。
❗注意: 将原始哈希表中的数据插入新的哈希表中时需要根据新的哈希表空间大小计算对应的哈希位置,而不是直接将原始哈希表中的数据挪到新哈希表对应的位置中去。
插入代码如下:
bool insert(const pair<K, V>& kv)
{
// 检测待插入的数据在哈希表中是否已经存在
if (find(kv.first))
{
return false; // 若待插入值已存在,则插入失败
}
// 检测负载因子大小,负载因子到0.7以上就需要扩容
if (_table.size() == 0 || _n * 10 / _table.size() > 7)
{
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
// 扩容以后,需要重新映射位置到新哈希表中
// 方法一:
//vector newTable;
//newTable.resize(newSize * 2);
//for (auto& e : _table)
//{
// // 重新计算哈希地址将元素插入新表中,与下面逻辑类似
//}
//_table.swap(newTable);
// 方法二:
// 用哈希表定义一个对象
HashTable<K, V, HashFunc> newHT;
newHT._table.resize(newSize);
for (auto& e : _table)
{
if (e._state == EXITS)
{
newHT.insert(e._kv);
}
}
// 与对象中的哈希表交换
_table.swap(newHT._table);
}
// 用哈希函数算出哈希表中的映射位置
HashFunc hf;
size_t start = hf(kv.first) % _table.size();
size_t index = start;
// 探测待插入元素位置 - 线性探测 or 二次探测
size_t i = 0;
while (_table[index]._state == EXITS)
{
//index = start + i * i; // 二次探测
index = start + i; // 线性探测
index %= _table.size(); // 防止下标越界
++i;
}
// 将待插入元素插入对应位置,并将其状态改为EXITS,将哈希表中有效元素个数+1
_table[index]._kv = kv;
_table[index]._state = EXITS;
++_n;
return true;
}
因为标识了每个存储位置的状态,因此删除元素的时候直接采用伪删除的方式即可,也就是将要删除位置的状态改为DELETE。伪删除也不会影响新的元素插入,因为插入新的元素只需要将该位置元素覆盖即可。
哈希表闭散列删除元素的步骤:
删除代码如下:
bool erase(const K& key)
{
// 查找该元素是否在哈希表中存在
HashDate<K, V>* ret = find(key);
if (ret == nullptr)
return false;
else // 找到该元素
{
// 将其状态置为DELETE,并将有效元素个数-1
ret->_state = DELETE;
--_n;
return true;
}
}
哈希表闭散列查找元素的步骤:
查找代码如下:
HashDate<K, V>* find(const K& key)
{
// 表的大小为0,则查找失败,返回nullptr
if (_table.size() == 0)
{
return nullptr;
}
// 计算待查找元素的哈希地址
HashFunc hf;
size_t start = hf(key) % _table.size();
size_t index = start;
size_t i = 1; // 初始值设定为1,映射位置查找不到,则直接到下一个位置查找
// 哈希表中存储位置状态不为空,则继续查找
while (_table[index]._state != EMPTY)
{
// 找到待查找元素,并返回它的地址
if (_table[index]._state == EXITS && _table[index]._kv.first == key)
{
return &_table[index];
}
// 该位置不是待查找元素,继续按照相应规则向后查找
//index = start + i * i; // 二次探测
index = start + i; // 线性探测
index %= _table.size();
++i;
}
// 走到存储位置为空的状态,不存在此元素,返回空指针
return nullptr;
}
开散列的哈希表中,哈希表的每个位置存储的是一个单链表的头节点,即每个哈希桶存储的数据是一个结点类型,该节点中包含数据域和指针域。开散列的实现也需要判断负载因子的大小,因此需要记录当前开散列中有效元素的个数。
namespace Bucket
{
template<class K,class V>
struct HashNode
{
HashNode<K, V>* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
:_next(nullptr)
,_kv(kv)
{}
};
template<class K,class V,class HashFunc>
class HashTable
{
// 重定义一下,方便使用
typedef HashNode<K, V> Node;
public:
private:
vector<Node*> _table; // 表的每个元素为一个结点指针
size_t _n = 0; // 有效数据个数
};
}
开散列中插入数据步骤:
注意:在扩容的时候,需要将原哈希表的数据插入到新哈希表中,这里可以通过复用插入函数进行数据的重新插入,但在这个过程中需要创建相同数据的结点插入到新哈希表中,还需要清理原哈希表。可以这样做,但是没有必要。事实上,我们只需要遍历原哈希表的每个哈希地址,通过哈希函数计算每个数据在新哈希表中的对应位置,只需要将该节点取下来接在新的哈希表中即可。省略了创建节点和释放节点的过程。
插入代码如下:
bool insert(const pair<K, V>& kv)
{
// 检测该值是否存在哈希表中,存在则返回false
if (find(kv.first))
return false;
HashFunc hf;
// 负载因子到1时进行扩容
if (_table.size() == _n)
{
// 不推荐这样写
/*size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable newHT;
newHT._table.resize(newSize,nullptr);
for (size_t i = 0;i < _table.size();++i)
{
Node* cur = _table[i];
while (cur)
{
newHT.insert(cur->_kv);
cur = cur->_next;
}
}
newHT._table.swap(_table);*/
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newSize, nullptr);
// 遍历取原表中节点,重新算映射到新表中的位置,接到新表中
for (size_t i = 0;i < _table.size();++i)
{
Node* cur = _table[i];
// 遍历当前哈希地址中的数据
while (cur)
{
// 保存当前需要摘取节点的下一个节点
Node* next = cur->_next;
// 计算当前节点在新表中对应的哈希地址并头插到新的哈希表中
size_t index = hf(cur->_kv.first) % newSize;
cur->_next = newTable[index];
newTable[index] = cur;
cur = next;
}
_table[i] = nullptr;
}
newTable.swap(_table);
}
// 计算待插入节点的哈希地址,将待插入节点头插到对应哈希地址下
size_t index = hf(kv.first) % _table.size();
Node* newnode = new Node(kv);
newnode->_next = _table[index];
_table[index] = newnode;
// 更新有效元素个数
++_n;
return true;
}
开散列中删除数据步骤:
删除代码如下:
bool erase(const K& key)
{
// 检测哈希表大小是否为0
if (_table.size() == 0)
return false;
// 计算待删除元素对应的哈希地址
HashFunc hf;
size_t index = hf(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[index];
while (cur)
{
// 找到待删除元素
if (cur->_kv.first == key)
{
// 判断待删除元素是第一个节点还是中间节点
if (_table[index] == cur)
_table[index] = cur->_next;
else
prev->_next = cur->_next;
--_n;
delete cur;
return true;
}
// 向后查找
prev = cur;
cur = cur->_next;
}
// 没有找到待删除元素
return false;
}
开散列中查找数据步骤:
查找代码如下:
Node* find(const K& key)
{
if (_table.size() == 0)
return nullptr;
HashFunc hf;
size_t index = hf(key) % _table.size();
Node* cur = _table[index];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
❓哈希函数计算哈希地址时只能用整数去计算,其它类型应该怎么解决呢?
哈希函数采用除留余数法,被模的key必须要为整数才可以处理,此处提供将key转化为整形的方法去解决此问题。具体应该怎么转化可以看看这篇文章:各种字符串哈希函数
例如:
// 特化
template<>
struct Hash<string>
{
// 字符串转成对应的一个整形值,因为整形才能取模算映射位置
// 尽量让字符串不同,转出的整形值尽量不同
size_t operator()(const string& s)
{
// "abc" "cba"
size_t value = 0;
for (auto ch : s)
{
value += ch;
// BKDL Hash
value *= 131;
}
return value;
}
};
❓哈希表的大小为什么建议是素数?
对于哈希表的大小最好为素数这个问题目前是存在争议的。若对其原因感兴趣可以去网上找来看看,这里就不解释了,因为也解释不准确。
那么我们应该如何快速获取一个类似两倍关系的素数呢?哈希增容两倍时哈希表的大小就不再是素数了,找到下一个素数也非常不容易,因此我们可以提前准备一个素数表,需要的时候直接从中获取即可。如下所示:以下表中的素数都以接近2倍的速度增长。
size_t 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[i];
}
增容的时候,直接获取下一个素数即可。
newTable.resize(GetNextPrime(_table.size()));