目录
一、哈希结构概念
二、哈希冲突
三、哈希函数
3.1 哈希函数设计原则
3.2 常见哈希函数设计方法
1. 直接定址法--(常用)
2. 除留余数法--(常用)
3. 平方取中法
4. 折叠法
5. 随机数法
6. 数学分析法
3.3 处理key的局限性问题
四、通过闭散列解决哈希冲突
4.1 闭散列概念
4.2 基础操作
4.2.1 插入操作
4.2.2 删除操作
4.2.3 扩容机制
4.3 线性探测
4.4 二次探测
五、通过开散列解决哈希冲突
5.1 概念
5.2 扩容机制
5.3 完整代码
六、 闭散列与开散列对比
在顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素
时必须要经过关键码的多次比较。
顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法: 可以不经过任何比较,一次直接从表中得到要搜索的元素
若构造一种存储结构,通过某种函数(哈希函数)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中插入元素时:
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
当在该结构中搜索元素时 :
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称
为哈希表(Hash Table)(或者称散列表)
不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为"同义词"。
引起哈希冲突的一个重要原因可能是:哈希函数设计不够合理
下图为线性探测情况下发生哈希冲突:
1. 哈希函数的定义域必须包括需要存储的全部关键码。若散列表允许有m个地址时,其值域必须在0到m-1之间
2. 哈希函数计算出来的地址能均匀分布在整个空间中
3. 哈希函数应该比较简单
取关键字的某个线性函数为散列地址:Hash(Key) = A*Key + B
优点:简单、均匀 、不存在哈希冲突
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p (p<=m),将关键码转换成哈希地址
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这
几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中
random为随机数函数。
通常应用于关键字长度不等时采用此法,要求random结果固定。
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定
相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只
有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散
列地址。
当哈希函数采用除留余数法时,被模的key必须要为整型才可以处理。
unordered_map dict;
但unordered_map又为何可以用string类型的数据作为key呢?
template//默认仿函数
struct hash {
size_t operator()(const K& key) {
return (size_t)key;
}
};
template<>//特化
struct hash {
//BKDR算法
size_t operator()(const string& key) {
size_t sum = 0;
for (auto& e : key) {
sum = sum * 131 + e;
}
return sum;
}
};
通过提供模板仿函数,利用仿函数处理key后即可得到整型类型的数据。
具体可参考: 各种字符串Hash函数 - clq - 博客园 (cnblogs.com)
即开放定址法。当发生哈希冲突时,若哈希表未被装满,说明在哈希表中必然存在空位置,那么可将key存放到冲突位置中的"下一个"空位置中去。
1. 通过哈希函数获取待插入元素在哈希表中的位置
2. 若该位置中没有元素则直接插入新元素,若该位置中有元素发生哈希冲突,使用线性探测或者二次探测找到下一个空位置,插入新元素
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。因此线性探测采用标记的伪删除法来删除一个元素。
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
优点: 简单且易于实现
缺点: 一旦发生哈希冲突,所有的冲突连在一起,容易产生数据"堆积",即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
namespace CloseHash {
enum State {
EMPTY,
EXIST,
DELETE
};
template
struct HashData {
pair _kv;
State _state = EMPTY;
};
template//默认仿函数
struct hash {
size_t operator()(const K& key) {
return (size_t)key;
}
};
template<>//特化
struct hash {
//BKDR算法
size_t operator()(const string& key) {
size_t sum = 0;
for (auto& e : key) {
sum = sum * 131 + e;
}
return sum;
}
};
template>
class HashTable
{
public:
bool insert(const pair& kv) {
if (find(kv.first) != nullptr) return false;//不允许键值冗余
if (_table.size() == 0 || 10 * _size / _table.size() >= 7) {//扩容
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable new_table;
new_table._table.resize(newSize);
//旧表数据映射到新表
for (auto& e: _table){
if (e._state == EXIST) {
new_table.insert(e._kv);
}
}
_table.swap(new_table._table);
}
Hash hash;
size_t index = hash(kv.first) % _table.size();//int提升为size_t
while (_table[index]._state == EXIST) {//线性探测
++index;
index %= _table.size();
}
_table[index]._kv = kv;
_table[index]._state = EXIST;
++_size;
return true;
}
bool erase(const K& key) {
HashData* ret = find(key);
if (ret == nullptr) {
return false;
}
else {
ret->_state = DELETE;
--_size;
return true;
}
}
HashData* find(const K& key) {
if (_table.size() == 0) return nullptr;
Hash hash;
size_t start = hash(key) % _table.size();//int提升为size_t
size_t index = start;
while (_table[index]._state != EMPTY) {
if (_table[index]._state != DELETE && _table[index]._kv.first == key) {
return &_table[index];
}
++index;
index %= _table.size();
if (index == start) {//当哈希表中全为DELETE 和 EXIST时避免死循环
break;
}
}
}
return nullptr;
}
private:
vector> _table;
size_t _size = 0;//有效数据
};
}
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置的方法有关(逐个往后去找)。
因此二次探测为了避免该问题,找下一个空位置的方法为: H_i = (H_0 + i ^ 2) % m, 或者: H_i = (H_0 - i ^ 2) % m。其中: i = 0,1,2,3……
H_0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
namespace CloseHash {
#define LINEAR
enum State {
EMPTY,
EXIST,
DELETE
};
template
struct HashData {
pair _kv;
State _state = EMPTY;
};
template//默认仿函数
struct hash {
size_t operator()(const K& key) {
return (size_t)key;
}
};
template<>//特化
struct hash {
//BKDR算法
size_t operator()(const string& key) {
size_t sum = 0;
for (auto& e : key) {
sum = sum * 131 + e;
}
return sum;
}
};
template>
class HashTable
{
public:
bool insert(const pair& kv) {
if (find(kv.first) != nullptr) return false;//不允许键值冗余
if (_table.size() == 0 || 10 * _size / _table.size() >= 5) {//扩容
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable new_table;
new_table._table.resize(newSize);
//旧表数据映射到新表
for (auto& e : _table) {
if (e._state == EXIST) {
new_table.insert(e._kv);
}
}
_table.swap(new_table._table);
}
Hash hash;
size_t start = hash(kv.first) % _table.size();//int提升为size_t
size_t index = start, i = 0;
while (_table[index]._state == EXIST) {//二次探测
++i;
index = start + i * i;
index %= _table.size();
}
_table[index]._kv = kv;
_table[index]._state = EXIST;
++_size;
return true;
}
bool erase(const K& key) {
HashData* ret = find(key);
if (ret == nullptr) {
return false;
}
else {
ret->_state = DELETE;
--_size;
return true;
}
}
HashData* find(const K& key) {
if (_table.size() == 0) return nullptr;
Hash hash;
size_t start = hash(key) % _table.size();//int提升为size_t
size_t index = start, i = 0;
while (_table[index]._state == EXIST) {//二次探测
if (_table[index]._state != DELETE && _table[index]._kv.first == key) {
return &_table[index];
}
++i;
index = start + i * i;
index %= _table.size();
}
return nullptr;
}
private:
vector> _table;
size_t _size = 0;//有效数据
};
}
研究表明: 当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。
因此只要表中有一半的空位置,在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,若超出必须考虑增容。
又称链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合。每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
开散列中每个桶中放的都是发生哈希冲突的元素
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多。极端情况下,可
能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希
表进行增容。那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,
再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可
以给哈希表增容。
但是该如何扩容呢?采用除留余数法的情况下,除数(即哈希表的长度)最好为质数,且每次扩容最好近似之前的两倍大小。这里采用SGI版本的方案(开散列、闭散列都可以使用该种扩容方式):
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
namespace OpenHash {
template//默认仿函数
struct hash {
size_t operator()(const K& key) {
return (size_t)key;
}
};
template<>//特化
struct hash {
//BKDR算法
size_t operator()(const string& key) {
size_t sum = 0;
for (auto& e : key) {
sum = sum * 131 + e;
}
return sum;
}
};
template
struct HashNode {
HashNode() = default;
HashNode(const pair& kv):_kv(kv),_next(nullptr) {}
pair _kv;
HashNode* _next;
};
template>
class HashBucket
{
typedef HashNode Node;
inline size_t __stl_next_prime(unsigned long n)
{
static const size_t __stl_num_primes = 28;
static const size_t __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
for (size_t i = 0; i < __stl_num_primes; ++i) {
if (__stl_prime_list[i] > n) return __stl_prime_list[i];
}
return -1;
}
public:
bool insert(const pair& kv) {
Hash hash;
if (find(kv.first) != nullptr) return false;//不允许键值冗余
//荷载因子到达1进行扩容
if (_table.size() == 0 || _size == _table.size()) {
vector new_table;
new_table.resize(__stl_next_prime(_table.size()), nullptr);
for (size_t i = 0; i < _table.size(); ++i) {
Node* cur = _table[i];
while (cur != nullptr) {
Node* next = cur->_next;
size_t hashi = hash(cur->_kv.first) % new_table.size();
//头插
cur->_next = new_table[hashi];
new_table[hashi] = cur;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(new_table);
}
size_t hashi = hash(kv.first) % _table.size();
//头插
Node* newNode = new Node(kv);
newNode->_next = _table[hashi];
_table[hashi] = newNode;
++_size;
return true;
}
bool erase(const K& key) {
Hash hash;
if (_table.size() == 0) return false;
size_t hashi = hash(key) % _table.size();
Node* cur = _table[hashi];
Node* prev = nullptr;
while (cur != nullptr) {
if (cur->_kv.first == key) {
if (prev == nullptr) {//头删
_table[hashi] = cur->_next;
}
else {
prev->_next = cur->_next;
}
delete cur;
--_size;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
Node* find(const K& key) {
Hash hash;
if (_table.size() == 0) return nullptr;
size_t hashi = hash(key) % _table.size();
Node* cur = _table[hashi];
while (cur != nullptr) {
if (cur->_kv.first == key) {
return cur;
}
cur = cur->_next;
}
return nullptr;
}
~HashBucket(){
for (size_t i = 0; i < _table.size(); ++i) {
Node* cur = _table[i];
while (cur != nullptr) {
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
//存储的元素个数
size_t size() { return _size; }
// 表的长度
size_t table_size()
{
return _tables.size();
}
// 桶的个数
size_t bucket_num(){
size_t num = 0;
for (size_t i = 0; i < _tables.size(); ++i) {
if (_tables[i]) {
++num;
}
}
return num;
}
size_t max_bucket_length() {
size_t maxLen = 0;
for (size_t i = 0; i < _tables.size(); ++i) {
size_t len = 0;
Node* cur = _tables[i];
while (cur){
++len;
cur = cur->_next;
}
if (len > maxLen) maxLen = len;
}
return maxLen;
}
private:
vector _table;
size_t _size = 0;
};
}
使用链地址法处理溢出时,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子α <= 0.7且最好是α <= 0.5,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。