目录
除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?
unordered_map
在学习hash函数之前,首先需要了解基于hash表实现的unordered_map
- unordered_map是存储<key, value>键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
- 键和映射值的类型可能不同。
- 内部没有排序。 而在基于红黑树的 map 存在排序。
-
查找效率:
map
的查找效率为O(log n),其中n为元素个数;unordered_map
的查找效率为O(1),但在哈希冲突较多时,查找效率会降低到O(n)。 (此处暂时只用知道这俩的时间复杂度不同即可。)
下面是一个使用 unordered_map
的例子:
#include <iostream>
#include <unordered_map>
int main() {
// 定义一个 unordered_map 对象,key 是 std::string 类型,value 是 int 类型
std::unordered_map<std::string, int> umap;
// 插入键值对
umap["apple"] = 10;
umap["banana"] = 20;
umap["cherry"] = 30;
// 遍历输出每个键值对
for (const auto& p : umap) {
std::cout << "key: " << p.first << ", value: " << p.second << std::endl;
}
return 0;
}
哈希(散列)函数
哈希概念
一种存储结构(哈希表),通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系。
哈希函数是将任意大小的数据映射到固定大小的数据的一种方法。哈希函数将数据映射到数组的索引上,以此来实现快速的查找、插入和删除操作。
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小
哈希冲突
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
按照给定的哈希函数将元素44插入到哈希表中,它会被放在索引为4的位置上,即44 % 10 = 4。但是在数据集合{1,7,6,4,5,9}中,元素4已经被哈希到了索引为4的位置上,因此会出现哈希冲突的情况,这种情况被称为“哈希冲突”(Hash Collision)。
通常使用解决哈希冲突的方法是使用开放地址法或者链式法,将相同哈希值的元素存储在同一个位置上,以避免数据的丢失。
引起哈希冲突的一个原因可能是:哈希函数设计不够合理
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值
- 域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常用的方法:
1. 直接定址法--(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B力扣此题用hash来解。
2. 除留余数法--(常用)
除留余数法是一种常用的哈希函数,其思想是先将关键码key除以一个特定的数p,然后取其余数,即:
Hash(key) = key % p
其中,p是一个不大于哈希表大小m的质数。这个质数的选择非常重要,一般来说,应该选择一个和m比较接近的质数,以尽可能减少哈希冲突的发生。
使用除留余数法的步骤如下:
1. 选择一个适当的质数p作为除数。
2. 对于每个关键码key,计算哈希值Hash(key) = key % p。
3. 将哈希值作为关键码在哈希表中的存储位置。需要注意的是,由于除留余数法只使用了关键码的低位部分,因此如果关键码的高位部分包含了关键信息,那么使用除留余数法就可能导致哈希冲突的发生。
哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
1. 线性探测
线性探测是一种哈希冲突解决方法,它的基本思想是:如果哈希函数将一个关键字映射到某个槽位已经被占用,就顺序地往后找下一个空闲的槽位,直到找到空闲的槽位为止。
插入
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素
会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影
响。因此线性探测采用标记的伪删除法来删除一个元素
下面是一个使用线性探测法解决哈希冲突的简单例子,其中我们将整数键插入到哈希表中。
#include <iostream>
using namespace std;
// 哈希表的槽位类
class Slot {
public:
int key; // 存储的关键字
bool occupied; // 槽位是否被占用
Slot() { // 构造函数,初始化key和occupied
key = 0;
occupied = false;
}
};
// 哈希表类
class HashTable {
private:
const int TABLE_SIZE = 10; // 哈希表的槽位数目
Slot table[TABLE_SIZE]; // 存储关键字的数组
public:
// 哈希函数,用于将关键字映射到哈希表的槽位上
int hashFunction(int key) {
return key % TABLE_SIZE;
}
// 将关键字插入到哈希表中
void insert(int key) {
int index = hashFunction(key); // 通过哈希函数计算出key对应的槽位index
while (table[index].occupied) { // 如果槽位被占用,则往后遍历
index = (index + 1) % TABLE_SIZE;
}
table[index].key = key; // 将key存储到该槽位
table[index].occupied = true; // 将该槽位标记为被占用
}
// 输出哈希表中所有的槽位及其存储的关键字
void print() {
for (int i = 0; i < TABLE_SIZE; i++) {
if (table[i].occupied) {
cout << i << ": " << table[i].key << endl;
} else {
cout << i << ": " << "empty" << endl;
}
}
}
};
int main() {
HashTable ht; // 创建一个哈希表对象
ht.insert(12); // 将关键字12插入到哈希表中
ht.insert(22); // 将关键字22插入到哈希表中
ht.insert(32); // 将关键字32插入到哈希表中
ht.insert(42); // 将关键字42插入到哈希表中
ht.insert(52); // 将关键字52插入到哈希表中
ht.print(); // 输出哈希表中所有的槽位及其存储的关键字
return 0;
}
我们使用 (index + 1) % TABLE_SIZE
的方式往后遍历槽位。
哈希表什么情况下进行扩容?如何扩容?
散列表的载荷因子定义为α = 填入表中的元素个数 / 散列表的长度
通常情况下,当装载因子超过某个设定的阈值时,哈希表就需要进行扩容,以避免哈希表中出现过多的冲突,影响哈希表的性能。 因子应该控制在0.7-0.8以下
扩容操作一般包括以下步骤:
- 新建一个更大的数组,通常容量是原数组的两倍或更大。
- 遍历原数组,将元素重新插入到新数组中。由于新数组容量更大,哈希函数可能需要重新计算,因此每个元素都需要重新计算哈希值并插入到新数组中。
- 删除原数组,将原数组指针指向新数组。
哈希的扩容 参数在最后的哈希函数的插入/删除/查找的三大基本功能实现中
void _Expand()
{
// 创建一个新的哈希表,容量是旧表的两倍
size_t newCapacity = _ht.capacity() * 2;
vector<Elem> newHt(newCapacity);
for (size_t i = 0; i < newCapacity; ++i)
newHt[i]._state = EMPTY;
// 将旧表中的元素重新插入
for (size_t i = 0; i < _ht.capacity(); ++i)
{
if (_ht[i]._state == EXIST)
{
// 计算元素在新表中的哈希地址
size_t newHashAddr = newHt.HashFunc(_ht[i]._val.first);
// 线性探测法解决哈希冲突,直到找到新表中的空位置
while (newHt[newHashAddr]._state == EXIST)
{
newHashAddr++;
if (newHashAddr == newHt.capacity())
newHashAddr = 0;
}
// 插入元素到新表中
newHt[newHashAddr]._state = EXIST;
newHt[newHashAddr]._val = _ht[i]._val;
}
}
// 交换旧表和新表
_ht.swap(newHt);
}
哈希函数的插入/删除/查找的三大基本功能实现
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};
template<class K, class V>
class HashTable
{
// 内部结构体,用于存储元素和元素状态
struct Elem
{
pair<K, V> _val; // 元素本身
State _state; // 元素状态
};
public:
// 构造函数
HashTable(size_t capacity = 3)
: _ht(capacity), _size(0)
{
// 初始化哈希表,全部设置为空
for(size_t i = 0; i < capacity; ++i)
_ht[i]._state = EMPTY;
}
// 插入元素
bool Insert(const pair<K, V>& val)
{
// 哈希地址
size_t hashAddr = HashFunc(val.first);
while(_ht[hashAddr]._state != EMPTY)
{
// 如果已经存在相同 key 的元素,插入失败
if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == val.first)
return false;
// 线性探测,寻找下一个空闲的地址
hashAddr = (hashAddr + 1) % _ht.capacity();
}
// 在空闲地址处插入元素
_ht[hashAddr]._state = EXIST;
_ht[hashAddr]._val = val;
_size++;
return true;
}
// 查找元素
int Find(const K& key)
{
// 哈希地址
size_t hashAddr = HashFunc(key);
while(_ht[hashAddr]._state != EMPTY)
{
// 找到相应的元素,返回哈希地址
if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == key)
return hashAddr;
// 线性探测,寻找下一个可能的地址
hashAddr = (hashAddr + 1) % _ht.capacity();
}
// 没有找到,返回 -1
return -1;
}
// 删除元素
bool Erase(const K& key)
{
// 查找元素的哈希地址
int index = Find(key);
if(index != -1)
{
// 将元素状态标记为删除状态
_ht[index]._state = DELETE;
_size--;
return true;
}
// 没有找到,删除失败
return false;
}
// 获取元素个数
size_t Size() const
{
return _size;
}
// 判断是否为空
bool Empty() const
{
return _size == 0;
}
// 交换哈希表
void Swap(HashTable<K, V>& ht)
{
_ht.swap(ht._ht);
swap(_size, ht._size);
}
private:
// 哈希函数
size_t HashFunc(const K& key)
{
return key % _ht.capacity();
}
private:
vector<Elem> _ht;
size_t _size;
};
开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地
址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链
接起来,各链表的头结点存储在哈希表中。
开散列中每个桶中放的都是发生哈希冲突的元素
以下是开散列函数的基本操作实现
#include <iostream>
#include <vector>
using namespace std;
// 定义哈希表中的每个元素
struct Elem {
int key;
int val;
Elem *next;
Elem(int k = 0, int v = 0, Elem *n = nullptr) : key(k), val(v), next(n) {}
};
// 定义哈希表类
class HashTable {
public:
// 构造函数
HashTable(size_t capacity = 100) : _ht(capacity), _size(0) {}
// 插入操作
void insert(int key, int val);
// 查找操作
bool find(int key, int &val);
// 删除操作
bool erase(int key);
private:
vector<Elem*> _ht; // 哈希表
size_t _size; // 哈希表中元素的个数
void _expand(); // 扩容函数
};
// 插入操作
void HashTable::insert(int key, int val) {
// 计算哈希值
size_t h = key % _ht.size();
// 查找哈希桶中是否已经存在该键值
Elem *p = _ht[h];
while (p) {
if (p->key == key) {
p->val = val;
return;
}
p = p->next;
}
// 若哈希桶中不存在该键值,则插入新节点
_ht[h] = new Elem(key, val, _ht[h]);
++_size;
// 判断是否需要扩容
if (_size * 2 > _ht.size())
_expand();
}
// 查找操作
bool HashTable::find(int key, int &val) {
// 计算哈希值
size_t h = key % _ht.size();
// 在哈希桶中查找该键值
Elem *p = _ht[h];
while (p) {
if (p->key == key) {
val = p->val;
return true;
}
p = p->next;
}
return false;
}
// 删除操作
bool HashTable::erase(int key) {
// 计算哈希值
size_t h = key % _ht.size();
// 在哈希桶中查找该键值
Elem *p = _ht[h], *prev = nullptr;
while (p) {
if (p->key == key) {
if (prev)
prev->next = p->next;
else
_ht[h] = p->next;
delete p;
--_size;
return true;
}
prev = p;
p = p->next;
}
return false;
}
// 扩容函数
void HashTable::_expand() {
// 创建一个新的哈希表,容量是旧表的两倍
size_t newCapacity = _ht.size() * 2;
vector<Elem*> newHt(newCapacity, nullptr);
// 将旧表中的元素重新插入
for (size_t i = 0; i < _ht.size(); ++i) {
Elem* curr = _ht[i];
while (curr != nullptr) {
// 计算新哈希表的索引位置
size_t newIndex = hash(curr->_key) % newCapacity;
// 如果该位置为空,则直接插入元素
if (newHt[newIndex] == nullptr) {
newHt[newIndex] = curr;
}
// 如果该位置不为空,则使用开放地址法解决冲突
else {
Elem* next = curr->_next;
while (newHt[newIndex] != nullptr) {
newIndex = (newIndex + 1) % newCapacity;
}
newHt[newIndex] = curr;
curr->_next = nullptr;
}
curr = next;
}
}
// 更新哈希表的容量和哈希表指针
_capacity = newCapacity;
_ht.swap(newHt);
}
只能存储key为整形的元素,其他类型怎么解决?
string可以转成整形,这里有一个巨佬的BKDR hash算法
template<class T>
size_t BKDRHash(const T *str)
{
register size_t hash = 0;
while (size_t ch = (size_t)*str++)
{
hash = hash * 131 + ch; // 也可以乘以31、131、1313、13131、131313..
// 有人说将乘法分解为位运算及加减法可以提高效率
//,如将上式表达为:hash = hash << 7 + hash << 1 + hash + ch;
}
return hash;
}
//上面大佬代码
//这是地址https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html
// key为字符串类型,需要将其转化为整形
class Str2Int
{
public:
size_t operator()(const string& s)
{
const char* str = s.c_str();
unsigned int seed = 131; // 31 131 1313 13131 131313
unsigned int hash = 0;
while (*str)
{
hash = hash * seed + (*str++);
}
return (hash & 0x7FFFFFFF);
}
};
如果使用的是其他类型的 key,可以将其转换为字符串,然后再使用 BKDR Hash Function 生成哈希值。
除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?
在哈希表中,选择一个好的素数作为哈希表的容量可以减少哈希冲突的概率,从而提高哈希表的效率。通常情况下,我们选择一个与质数相邻的素数作为哈希表的容量。当然,我们也可以在程序中使用算法来寻找素数。
以下是一种方法来快速计算一个与给定数字 n 相邻的素数:
1. 首先检查 n 是否为偶数,如果是,则将 n 加一,使之变为奇数。
2. 从 n 开始,依次增加 2,检查每个数是否为素数。
3. 检查素数的方法可以使用试除法或者 Miller-Rabin 算法。
4. 如果找到了一个素数,返回它。
这种方法并不能保证找到最小的相邻素数,但是可以在很短的时间内找到一个足够大的素数。
开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:
由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=
0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
模拟实现
暂定
1. 模板参数列表的改造
2. 增加迭代器操作
3. 增加通过key获取value操作
哈希的应用
位图
位图(Bit Map)是一种用于表示比特位(bit)集合的数据结构,通常用于高效地处理大量二进制数据。它是一种紧凑的数据结构,可节省内存空间,同时可以快速执行位操作。
- 在位图中,每个元素只占用一个比特位,通常用0或1来表示该元素是否存在。
- 常见的应用场景包括布隆过滤器(Bloom Filter)、文本压缩、数据库索引等。
- 通常是用来判断某个数据存不存在的。
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在 这40亿个数中。【腾讯】
1. 遍历,时间复杂度O(N)
2. 排序(O(NlogN)),利用二分查找: logN
3. 位图解决 数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一 个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0 代表不存在。
位图解决的具体步骤如下:
-
首先需要将这40亿个不重复的无符号整数转化为比特位,用于表示这些数的存在状态。因为40亿个无符号整数需要用2^32个比特位才能全部表示,所以需要一个长度为2^32的比特位数组来表示这些数的存在状态。
-
遍历这40亿个无符号整数,对于每个数,将对应的比特位置为1,表示该数存在。
-
对于给定的无符号整数,通过将该数对应的比特位取出并判断其值是否为1,即可快速判断该数是否在40亿个数中。
需要注意的是,如果使用一个bool类型数组来表示比特位,会占用大量内存空间,因为一个bool类型通常需要占用1个字节(8个比特位)。因此,使用unsigned int类型数组来表示比特位,每个unsigned int类型可以表示32个比特位,能够有效减小内存占用。
#include <iostream>
#include <bitset>
#include <vector>
using namespace std;
class BitMap {
public:
BitMap(size_t n = 0) : _bits(n) {}
void set(size_t pos) {
size_t idx = pos >> 5;// 一个 unsigned int 存储 32 个比特位
//,所以右移 5 位相当于除以 32
size_t bit = pos & 0x1F;// 取余数,用于确定在 unsigned int 中的具体位置
_bits[idx] |= (1 << bit);// 将指定位置的比特位设为 1
}
bool test(size_t pos) const {
size_t idx = pos >> 5;
size_t bit = pos & 0x1F;
return (_bits[idx] & (1 << bit)) != 0;
}
private:
vector<unsigned int> _bits;
};
int main() {
// 构建一个包含 40 亿个随机无符号整数的数组
const size_t n = 1000000000;
vector<unsigned int> data(n);
for (size_t i = 0; i < n; ++i) {
data[i] = rand();
}
// 使用位图来表示整数是否出现过
BitMap bm(1 << 30);
for (size_t i = 0; i < n; ++i) {
bm.set(data[i]);
}
// 验证一个数是否存在
unsigned int num = rand();
if (bm.test(num)) {
cout << num << " exists." << endl;
} else {
cout << num << " does not exist." << endl;
}
return 0;
}
位图的应用
1. 快速查找某个数据是否在一个集合中
2. 排序 + 去重
3. 求两个集合的交集、并集等
4. 操作系统中磁盘块标记
布隆过滤器
概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存
在”
这里有大佬的解释。
布隆过滤器主要基于哈希函数实现,它使用一个比特数组和一组哈希函数。
在将元素加入布隆过滤器时,将元素通过哈希函数映射到比特数组的多个位置,并将这些位置的值设为1。
在查询元素是否存在时,将元素通过相同的哈希函数映射到比特数组上的多个位置,并检查这些位置的值是否都为1。如果有一个或多个位置的值为0,则可以确定该元素不存在于集合中。
如果所有位置的值都为1,则该元素可能存在于集合中,需要进一步检查或者可以根据实际情况确定是否存在。
布隆过滤器的优点是空间效率高和查询速度快,因为它不需要存储元素本身,只需要存储每个元素的哈希值。缺点是可能存在误判(false positive),即在布隆过滤器中没有存储的元素被误判为存在于集合中。误判的概率可以通过调整哈希函数的数量和比特数组的大小来控制。