一、哈希概念
我们都知道在顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当我们对该结构进行增删查改,我们可以根据元素的关键码,依据特定函数计算出该元素的存储位置,然后对之进行操作。该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(又称散列表)。
这里举个简单的例子帮助我们理解,比如我们用过的计数排序,其下标对应着该数字,而其内容用0,1(或,表示其出现次数)对应着该数字是否出现,这其实就是一个简单的哈希结构。但是这种结构的缺点太明显了,假设我们的数据量很小,但是数字却大,这就导致其需要开很大的内存,消耗太大,这其实就相当于哈希中的直接定址法。下图就给出一种合适的哈希结构:
二、哈希函数
以上哈希结构的不同是因为采用不同的哈希策略或者说哈希函数。这里介绍几种哈希函数:
2.1 直接定址法
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
2.2 除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
2.3 平方取中法
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
这里暂且介绍着几种哈希函数,其中常用的就是第一和第二种,想学习其他的哈希函数,可以自行搜索。
2.4 哈希冲突
我相信大家肯定会有一个问题,那就是为什么要设计那么多不同的哈希函数,感觉除留余数法已经够用了,这是因为在哈希结构对于不同的关键字,可能会产生哈希冲突,那什么是哈希冲突呢?
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。所以设计不同的哈希函数就是尽量避免哈希冲突。那么这同样引出我们哈希函数的设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值
- 域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中哈希函数应该比较简单
三、哈希冲突的解决方法
解决哈希冲突两种常见的方法是:闭散列和开散列
3.1 闭散列
首先先介绍一下什么叫做闭散列。
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
那么如何去寻找下一个位置呢?方法又很多种,我在这里举两个例子:
线性探测:若冲突了,就向相邻的下一个位置进行判断,若空则插入,否则继续探测。
二次探测:若冲突了,以冲突位 + 某个数的二次方进行探测。
这里我们主要讲解一下线性探测,并模拟实现一下哈希表。首先提供一个场景:
现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。这里采取我们的线性探测的方法来解决我们的冲突问题。
插入:
通过哈希函数获取待插入元素在哈希表中的位置
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
删除:采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。为什么呢?因为我们重新对44进行搜索时,会发现位置4是空的,就会误以为44这个元素不存在,很明显这是不对的。所以我们可以采取标识的方法解决这个问题,用删除,存在,为空这三个状态来对数组进行标识。
enum Status
{
EMPTY,//为空
EXIST,//存在
DELETE//删除
};
接下来就让我们来编写一下哈希表吧。
3.2 哈希表的模拟实现
这里需要注意一点的是,由于我们的数据不断增多,空间肯定是会不够用的,所以我们肯定是需要扩容的,那我们什么时候进行扩容呢? 首先等数组存满了再进行扩容显然是不可取的,这样在插入的后期,会导致效率大大降低,太早扩容又会浪费空间。所以这里我们可以使用一个负载因子,来对空间进行管理,负载因子其实就是已存入元素个数和数组有效长度的比值,大概0.7左右扩容是比较合理的,接下来就让我们进行代码的编写吧。
namespace open_address
{
enum Status
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
Status _s; //状态
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
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, Hash> 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;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 存储的关键字的个数
};
}
3.3 开散列
这里同样先介绍一下什么叫做开散列。
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
不难发现,开散列中每个桶中放的都是发生哈希冲突的元素,也就是我们将哈希值相同的元素链在一起。可以认为将上述的哈希表中原本存的是一个个值,现在存的是一个个链表。这样的实现方式我们称之为哈希桶。
3.4 哈希桶的模拟实现
在实现之前,我们先来谈一些细节比如说哈希桶的扩容思路
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
这里还有一点就是哈希函数采用处理余数法,被模的key必须要为整形才可以处理,此处提供将key转化为整形的方法,那么string该怎么处理呢?这里就需要采取一些字符串的哈希算法,方法有很多,大家可以自行搜索,学习其中的一两种。话不多说,上代码。
namespace hash_bucket
{
template<class K, class V>
struct HashNode
{
HashNode* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
:_kv(kv)
,_next(nullptr)
{}
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_tables.resize(10);
}
~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;
}
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
Hash hf;
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;
}
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;
}
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;
}
private:
vector<Node*> _tables;
size_t _n = 0;
};