目录
一、哈希概念
二、常见哈希函数
1.直接定址法
2.除留余数法
2.1 哈希冲突
2.2 闭散列——直接定址法
2.3 直接定址法代码实现
2.3 开散列——拉链法/哈希桶
三、哈希表的模拟实现
四、哈希桶的模拟实现
在以前我们所学习的数据结构,如顺序结构和平衡树,它们的元素关键码与其存储位置之间没有对应关系。因此在查找一个元素时,必要要对关键码进行多次比较。其中,顺序查找的时间复杂度为O(N),平衡树中为树的高度,即O(logN)。它们的搜索效率取决于搜素过程中元素的比较次数。
尽管如此,它们依然不是最为理想的搜索方法。最理想的搜索方法是可以不经过任何比较,一次直接从表中得到要搜索的元素。
既然如此,我们就可以通过构造一种存储结构,通过某种函数使元素的存储位置与它的关键码之间能够建立一一映射的关系,在查找时通过调用函数就能够很快找到对应的元素。
由此可以得到该数据结构的实现思路:
在插入元素时,根据待插入元素的关键码,通过特定函数计算出该元素的存储位置并存放
在搜索元素时,对元素的关键码通过同样的函数进行计算,找到该元素的存储位置并取出。此时就实现了常数次的搜索。
这种搜索数据的方法就好比,在图书馆中,当我们想要借一本书时,并不是自己去图书馆的书架上面一本一本的去查找。而是会去询问图书管理员,他会帮你查询对应书的位置,告诉你这本书在几楼几号区域的几号书架上的几号位置,此时你就可以拿着这个关键码,去对应的位置上取这本书。这其实就是一种“哈希”思想。
因此,“哈希”其实就是一种“映射思想”,并不是某种具体的数据结构。而通过哈希思想衍生出来的,例如“哈希桶”,这种才是具体的数据结构。
哈希函数是用于获取数据对应的关键码而诞生的。在这里就介绍两种比较常用的哈希函数。当然,实际中还存在大量的其他优秀哈希函数,有兴趣的话大家可以自行了解。
这种方法我们以前其实也用过。例如要找到一串小写英文字母中的最先出现的重复值。因为小写英文字母一共只有26个,所以就可以开一个具有26个空间的数组,然后将字符串中的每个字母都一一映射到数组中,例如‘a’映射到0,‘b’映射到1。映射完成后,再遍历字符串,根据字符串的次序在数组中查询对应字母的出现次数,找到第一个大于1的位置即可。这其实就是运用了哈希的直接定址法思想。
直接定址法,即取关键字的某个线性函数为散列地址:Hash(key) = A*key + B。
它的优点就是实现和理解起来都非常简单易懂。
但缺点也很明显。首先我们需要知道关键字的分布情况。即它们所处的区间。第二点也是最致命的缺点,那就是它只适合查找数据连续的情况。
例如有一组数字“1, 2, 10 ,6, 12, 8”,这种数字分布就比较均匀且连续,就比较适合使用直接定址法。但是,如果这组数据出现一个比较大的数字,如100,乃至1000。此时它的最小值为1,而最大值却是它的100倍千倍,此时如果使用直接定址法查找,仅仅7个的数据,就需要开大量的空间,空间浪费严重。
这种方法相较于直接定址法就有所优化。设散列表中允许的地址数为m,取一个不大于m,但最接近m或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key % p(p <= m),将关键码转为哈希地址进行插入。
但是,使用这种方法又会出现另一种问题,那就是“哈希冲突”。
假设现在有如下一串数据:
为了存储串数据,采用除留余数法,p设置为10进行存储。这时就会出现一个问题,即10和20这两个数字,它们模10的值都为0,这就会导致在0下标位置上,会同时出现两个数字,0下标处要么存10要么存20,无论存储谁,都会导致数据丢失。
因此,“哈希冲突”的含义就是:不同的值映射到了不同的位置。有时也将“哈希冲突”称为“哈希碰撞”。
为了解决哈希冲突,又衍生出了两种解决方法,即“闭散列”和“开散列”。
我们知道,在直接定址法中,因为每个数据映射唯一的位置,所以不会出现哈希冲突。因此,我们就可以将除留余数法与直接定址法结合起来,将数据以p为除数进行转换后,当出现哈希冲突时,就将数据向前挪动,直到找到为空的位置。
2.2.1 线性探测
将数据逐个向前挪动就是“线性探测”。但是这种方法还是有缺陷,在上图中,20被挪动到了位置3。但如果20后面还有一个数,例如3,13,它们的映射值为3,但是位置3已经被20占了,这就会导致它们也需要向前挪动。进而导致在需要查找数据时,导致查找时的命中率降低,查找效率降低。
但是线性探测有一个问题。就是如果我们删除了数组中的某一个值,该值所处的位置就会被清空。而查找一个数是从它映射的位置开始,如果遇到空,就是不存在。此时就可能影响到后续的数据查找。
注意,这里的清空并不是置为0,因为如果刚好这个位置上存储的值就是0呢?这就会导致清除无效。
2.2.2 二次探测
二次探测, 指的并不是进行两次探测,而是以平方来进行探测。
在线性探测中,是根据关键码找到要插入数据的位置,如果存在就往前走,即+1。但是这种方式就可能导致数据在数组中的分布太集中,因此就有人提出了二次探测的概念,即如果关键码要插入的位置有数据存在,就走i^2步。如线性探测是每次都可以看成是start +i(i >= 0),有数据就++i。二次探测就是start + i^2(i >= 0)。
2.3.1 结构设置
为了满足查找的需求,在删除时就不能够真的将对应位置上的数据删除。而是给每个位置都提供一个标记位,通过这个标记位来标定每个位置的“存在”、“空”、“删除”三种状态。当删除数据时,就是修改状态而不是真的删除数据。搜索数据时就是从映射点开始遍历不为空的位置,进而不受数据删除的干扰。
enum State//枚举定义三种状态
{
EMPTY,
EXIST,
DELETE
};
template
struct DirectData//每个节点的数据
{
pair _kv;
State _state = EMPTY;//节点状态
};
template>//第三个参数是仿函数,用于将不是整形的数转换为整形
class HashTable //如果不能转化,就需要使用者自己提供对应的转化
{
typedef DirectData Data;
public:
HashTable()
:_n(0)
{
_tables.resize(10);//构建对象时就开辟10个空间。如果不手动开辟空间,系统去调vector的默认构造会将size()
} //设置为0,导致插入时出现除0错误
private:
vector _tables;//将节点存储进一个vector中
size_t _n = 0;//记录vector中的元素个数
};
2.3.2 插入
注意,因为在直接定址法中,如果插入的数据太多,就可能导致插入下一个数据时很难找到空或已经删除的位置。为了解决这一问题,又提出了“负载/载荷因子”的概念。即在存储数据的数组中,数据的个数与数组的容量之间应该有一个阈值,超过这个阈值就需要扩容。
负载因子的大小又和数据插入的效率和空间使用有关。负载因子越大,插入效率越低,空间浪费越少;负载因子越小,插入效率越高,空间浪费越大。
经过了前人的一系列研究后,一般都是将负载因子控制在0.7左右,即如果数据个数超过了数组容量的70%,就需要扩容。
当然,既然数据会不断插入,就势必会出现需要扩容的情况。当扩容后,就需要修改除数,即修改映射关系,让这些值重新映射到数组中。因此,当空间扩容后,需要从头开始映射,即无论是新插入的值还是之前已经映射过的值,都需要重新映射。有这些概念后,就可以完成插入函数了。
bool insert(const pair& kv)//插入
{
if (_n * 10 / _tables.size() > 7)//以0.7为负载因子,超过就扩容
{
HashTable newHT;//创建一个新的哈希表对象
newHT._tables.resize(2 * _tables.size());//2倍扩容
for (const auto& e : _tables)//遍历旧表,因为旧表使用的库中的vector,所以支持迭代器
{
if(e._state == EXIST)//存在就插入
newHT.insert(e._kv);//将数据插入到新表中
}
_tables.swap(newHT._tables);//交换两个哈希表中的数据。newHT是一个局部对象,出了作用域会自动销毁
}
Hash knt;
size_t hashi = knt(kv.first) % _tables.size();//以数组容量为除值
while (_tables[hashi]._state == EXIST)//如果访问的位置存在数据,则向前走
{
++hashi;
hashi %= _tables.size();//保证hashi在数组下标范围内,当超过下标后,从0开始访问
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;//插入数据后将状态修改为存在
++_n;//记录当前vector中的数据个数
return true;
}
2.3.3 删除
删除函数的实现,是需要查找函数的支持的。所以在这里就一并将查找函数完成。
Data* find(const K& key)//查找函数
{
Hash knt;
size_t pos = knt(key) % _tables.size();//找key值对应的位置
while (_tables[pos]._state != EMPTY)//只要不等于空,就向后找
{
if (_tables[pos]._state == EXIST && _tables[pos]._kv.first == key)//键值存在且能找到
return &_tables[pos];
++pos;
pos %= _tables.size();//保证pos在vector内,超过则从开始
if (pos == knt(key) / _tables.size())//找了一圈后没找到,且未遇到空
return nullptr;
}
return nullptr;//没找到
}
bool erase(const K& key)//删除
{
Data* pos = find(key);
if (pos == nullptr)
return false;
pos->_state = DELETE;
--_n;
return true;
}
2.3.4 提供整形转化
哈希表,简单来讲就是通过将整形映射到数组的特定下标,然后将数据存储在该下标对应的空间中。因此,哈希表的使用就有一个先决条件——传入的数据类型必须是整形。
但是,在实际中不可能每次传入的数据都是整形,例如字符串。字符串的使用是非常常见的,但是它并不是整形,就无法放入哈希表中。因此,便需要提供能够将特定数据转化为整形的函数。
在这里,因为string的使用非常常见,所以就只提供了对string的转化。在实际使用中,哪怕是库中提供的unordered容器中也不法提供所有类型的转化,因为有时你传入的可能是自己定义的类,也可能是将库中的类进行了组合,例如vector
例如stl中的unordered_map的参数列表就有一个“Hash = hash
同时,因为字符串传入哈希在实际中挺常见的。并且要将字符串转整形,字符串是无限的,而可用的整形是有限的,所以势必会出现“哈希冲突”。因此,就有很多人研究了一些字符串转整形的方法,这些方法就是在尽可能的减少可能出现的哈希冲突,但都无法完全避免。
例如BKRDHash法。
当然,尽管这些算法都很大程度上减少了哈希冲突,但依然无法完全避免。下面的代码中就是使用的BKRD法。
template
struct HashFunc//仿函数,用于将可以直接转换为整形的键值转换为无符号整形
{
size_t operator()(const K& key)
{
return (size_t)key;//类型转换并不能随意转换,需要有所关联
} //例如string就不能转换为size_t
};
struct HashFuncString//仿函数,提供字符串转整形,但是这里的转整形很简单,仅仅是返回首字符,很容易出现冲突
{ //在实际中,需要进行比较复杂的转化,例如加上ascii码等方法减少冲突的可能。
size_t operator()(const string& key)
{
size_t hash = 0;
for (const auto& str : key)
{
hash = hash * 131 + str;//可以乘31,131,1313,13131等等
}
return hash;
}
};
template<>//模板特化,当函数模板HashFunc遇到string类型时,就会来调用该特化模板
struct HashFunc
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (const auto& str : key)
{
hash = hash * 131 + str;//可以乘31,131,1313,13131等等
}
return hash;
}
};
在提供时,我们可以同时提供仿函数和模板特化。其实stl库中就对一些无法直接转化为整形的类型、容器提供了模板特化,例如string。以实现在传入这些类型时无需修改仿函数或让用户自己提供仿函数。
由于闭散列法需要挪动数据导致查找数据的命中率较低,查找效率较低,便就有了“开散列”法。
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址(除留余数法),具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来, 个链表的头节点存储在哈希表中。因此,这种方法也被叫做拉链法或哈希桶。
unordered_set和unordered_map的底层数据结构就是使用的哈希桶。
每个下标位置就被看做一个拉链或者一个桶,不断往里面链接映射到该节点的数据。在开散列中,每个桶中放的都是发生哈希冲突的元素。
哈希表,其实就可以看成是用直接定址法完成的数据结构。要实现起来其实也很简单。
#pragma once
#include
#include
#include
using namespace std;
namespace close//哈希表
{
enum State//枚举定义三种状态
{
EMPTY,
EXIST,
DELETE
};
template
struct HashTableData//每个节点的数据
{
pair _kv;
State _state = EMPTY;//节点状态
};
template
struct HashFunc//仿函数,用于将可以直接转换为整形的键值转换为无符号整形
{
size_t operator()(const K& key)
{
return (size_t)key;//类型转换并不能随意转换,需要有所关联
} //例如string就不能转换为size_t
};
struct HashFuncString//仿函数,提供字符串转整形,但是这里的转整形很简单,仅仅是返回首字符,很容易出现冲突
{ //在实际中,需要进行比较复杂的转化,例如加上ascii码等方法减少冲突的可能。
size_t operator()(const string& key)
{
size_t hash = 0;
for (const auto& str : key)
{
hash = hash * 131 + str;//可以乘31,131,1313,13131等等
}
return hash;
}
};
template<>//模板特化,当函数模板HashFunc遇到string类型时,就会来调用该特化模板
struct HashFunc
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (const auto& str : key)
{
hash = hash * 131 + str;//可以乘31,131,1313,13131等等
}
return hash;
}
};
template>//第三个参数是仿函数,用于将不是整形的数转换为整形
class HashTable //如果不能转化,就需要使用者自己提供对应的转化
{
typedef HashTableData Data;
public:
HashTable()
:_n(0)
{
_tables.resize(10);//构建对象时就开辟10个空间。如果不手动开辟空间,系统去调vector的默认构造会将size()
} //设置为0,导致插入时出现除0错误
bool insert(const pair& kv)//插入
{
if (_n * 10 / _tables.size() > 7)//以0.7为负载因子,超过就扩容
{
HashTable newHT;//创建一个新的哈希表对象
newHT._tables.resize(2 * _tables.size());//2倍扩容
for (const auto& e : _tables)//遍历旧表,因为旧表使用的库中的vector,所以支持迭代器
{
if(e._state == EXIST)//存在就插入
newHT.insert(e._kv);//将数据插入到新表中
}
_tables.swap(newHT._tables);//交换两个哈希表中的数据。newHT是一个局部对象,出了作用域会自动销毁
}
Hash knt;
size_t hashi = knt(kv.first) % _tables.size();//以数组容量为除值
while (_tables[hashi]._state == EXIST)//如果访问的位置存在数据,则向前走
{
++hashi;
hashi %= _tables.size();//保证hashi在数组下标范围内,当超过下标后,从0开始访问
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;//插入数据后将状态修改为存在
++_n;//记录当前vector中的数据个数
return true;
}
Data* find(const K& key)//查找函数
{
Hash knt;
size_t pos = knt(key) % _tables.size();//找key值对应的位置
while (_tables[pos]._state != EMPTY)//只要不等于空,就向后找
{
if (_tables[pos]._state == EXIST && _tables[pos]._kv.first == key)//键值存在且能找到
return &_tables[pos];
++pos;
pos %= _tables.size();//保证pos在vector内,超过则从开始
if (pos == knt(key) / _tables.size())//找了一圈后没找到,且未遇到空
return nullptr;
}
return nullptr;//没找到
}
bool erase(const K& key)//删除
{
Data* pos = find(key);
if (pos == nullptr)
return false;
pos->_state = DELETE;
--_n;
return true;
}
private:
vector _tables;//将节点存储进一个vector中
size_t _n = 0;//记录vector中的元素个数
};
}
这里仅仅只是实现了哈希表的插入、查找和删除。其他部分并没有实现。因为在实际中,哈希表的使用其实是比较少的。在这里仅仅只是模拟实现,没有必要实现的太过复杂。
在这里实现的哈希桶,是一个比较简单的哈希桶,只包含了插入、查找和删除三种操作,且只支持传入kv模型的数据。如果想看实现了迭代器且能够兼容map和set两种容器的哈希桶,可以到文章“初识C++之unordered_map与unordered_set”中看。这里因为不需要兼容map和set的使用,就没有实现的过于复杂。
#pragma once
#include
#include
#include
using namespace std;
namespace BucketHash//哈希桶
{
template
struct HashNode
{
pair _kv;//存储数据
HashNode* _next;//指向下一个节点
HashNode(const pair& kv)
: _kv(kv)
, _next(nullptr)
{}
};
template
struct HashFunc//用于提供将可以转换成整形的数据进行转换
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc//模板特化,用于将字符串转为整形
{
size_t operator()(const string& str)
{
int hash = 0;
for (const auto& e : str)
{
hash = hash * 131 + e;
}
return hash;
}
};
template>
class HashBucket
{
typedef HashNode Node;
public:
HashBucket()
:_n(0)
{
_bucket.resize(NextPrime(0));//构建时默认开10个空间
}
~HashBucket()//析构函数
{
for (auto& cur : _bucket)
{
while (cur)
{
Node* prev = cur;
cur = cur->_next;
delete prev;
prev = nullptr;
}
}
}
inline unsigned long NextPrime(unsigned long n)//获取素数,用于扩容
{
static const int primenum = 28;
static const unsigned long primelist[primenum] =
{
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 (int i = 0; i < primenum; ++i)
{
if (primelist[i] > n)
return primelist[i];
}
return primelist[primenum - 1];//如果扩大了最大值,就不再扩容
}
bool insert(const pair& kv)
{
if (find(kv.first))//不允许数据重复,找到相同的返回
return false;
if (_bucket.size() == _n)//负载因子设置为1,超过就扩容
{
//HashBucket newHB;
//newHB._bucket.resize(2 * _bucket.size());//开2倍空间
//for (const auto* cur : _bucket)
//{
// while (cur)
// {
// newHB.insert(cur->_kv);
// cur = cur->_next;
// }
//}
//_bucket.swap(newHB._bucket);//交换
//上面的方法可行,但是每次都需要开空间插入,效率比较低
vector newbucket;//这种方式就无需再开空间拷贝节点
newbucket.resize(NextPrime(_bucket.size()));//开一个素数大小的空间
for (size_t i = 0; i < _bucket.size(); ++i)
{
Node* cur = _bucket[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = Hash()(cur->_kv.first) % newbucket.size();//找映射位置
cur->_next = newbucket[hashi];//头插到新哈希表
newbucket[hashi] = cur;
cur = next;
}
_bucket[i] = nullptr;//将原哈希表中的指针置为空
}
_bucket.swap(newbucket);//将newbucket中的节点全部交换给_bucket
}
Hash knt;
size_t hashi = knt(kv.first) % _bucket.size();//找映射位置
Node* newnode = new Node(kv);
newnode->_next = _bucket[hashi];//头插,让插入的节点的下一个指针指向头节点
_bucket[hashi] = newnode;//让头节点指向插入的节点
++_n;
return true;
}
Node* find(const K& key)//查找
{
size_t pos = Hash()(key) % _bucket.size();//找映射位置
Node* cur = _bucket[pos];
while (cur)
{
if (cur->_kv.first == key)
return cur;
else
cur = cur->_next;
}
return nullptr;
}
bool erase(const K& key)//删除
{
size_t hashi = Hash()(key) % _bucket.size();//找映射位置
Node* cur = _bucket[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
if (cur == _bucket[hashi])
_bucket[hashi] = cur->_next;
else
prev->_next = cur->_next;
delete cur;
--_n;
cur = nullptr;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
private:
vector _bucket;//存储数据的指针数组
size_t _n;
};
}
如果想兼容map和set,就要对这个哈希桶进行多方面的改造。