代码改变世界

实用指南:【哈希hash】:程序的“魔法索引”,实现数据瞬移

2025-12-14 10:35  tlnshuju  阅读(29)  评论(0)    收藏  举报

前言:在计算机科学的世界中,哈希表无疑是最优雅且实用的数据结构之一。它以其近乎神奇的O(1)时间复杂度,成为了现代软件开发中不可或缺的基石。从编程语言的内置字典、数据库的索引优化,到缓存系统的快速检索,哈希表的身影无处不在。我很久之前就听说这个神秘的东西了,以为很难,其实抛开来看,一般般,这篇文章带你吃透hash!

目录

一、哈希的基础知识

1.直接定址法

2. 哈希引入

3. 负载因子

二、哈希函数

1. 散列方法

(1)除法散列法/除留余数法✨

(2)乘法散列法(了解)

(3)全域散列法(了解)

(4)其他方法

2. 处理非整数类型

三、哈希冲突的处理

1. 开放定址法

(1)哈希表结构

(2)探测方法

线性探测

二次探测

双重散列(了解即可)

(3)扩容方法

2. 链地址法

(1)具体方法

(2)哈希表结构

(3)头插法插入

(4)扩容方法

(5)删除数据

四、哈希表的实现

五、位图

1. 位图的概念

2. 位图的实现

3. 库函数bitset

4. 变式问题

六、布隆过滤器

1. 布隆过滤器的概念

2. 误判率

3. 代码实现

4. 应用

七、哈希切割

1. 概念

2. 实际问题①

3. 实际问题②



一、哈希的基础知识

        哈希(hash)又称散列,是一种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字Key跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找。

1.直接定址法

        当关键字的范围比较集中时,直接定址法就是非常简单高效的方法,比如一组关键字都在[0,99]之间,那么我们开一个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在[a,z]的小写字母,那么我们开一个26个数的数组,每个关键字ascii码 - 'a'的ascii码就是存储位置的下标。也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。这有一道题:

字符串中的第一个唯一字符

class Solution {
public:
    int firstUniqChar(string s)
    {
        // 每个字母的ascii码-'a'的ascii码作为下标映射到count数组,数组中存储出现的次数
        int count[26] = { 0 };
        // 统计次数
        for (auto ch : s)  count[ch - 'a']++;
        for (size_t i = 0; i < s.size(); ++i)
        {
            if (count[s[i] - 'a'] == 1)
                return i;
        }
        return -1;
    }
};

2. 哈希引入

        直接定址法的缺点也非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。比如,我有一百个1到10的值,一百个1亿到1.1亿的值,让你统计次数,你难道开1.1亿大的数组吗?这时候就要用到我们今天学的哈希函数了。假设我们只有数据范围是[0, 9999]的N个值,我们要映射到一个M个空间的数组中(一般情况下M >= N),那么就要借助哈希函数(hash function) hf,关键字key被放到数组的hf(key)位置,这里要注意的是hf(key)计算出的值必须在[0, M)之间。

        这里存在的一个问题就是,两个不同的key可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。我们后面会具体讲,理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。

3. 负载因子

       假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么 负载因子 α = N / M。负载因子有些地方也翻译为载荷因子/装载因子等,他的英文为load factor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。

二、哈希函数

        哈希函数的作用是接收输入(称为“键”或 Key),输出一个整数(哈希值),这个整数通常用来决定数据在哈希表中的存储位置。一个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做到,但是我们要尽量往这个方向去考量设计。

1. 散列方法

(1)除法散列法/除留余数法

  • 除法散列法也叫做除留余数法,假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。
  • 当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是2^X,那么key % 2^X本质相当于保留key的后X位,那么后x位相同的值,计算出的哈希值都是一样的,就冲突了。如:{63, 31}如果M是16,也就是2^4,那么计算出的哈希值都是15。如果是10^X,就更明显了,保留的都是10进制的后x位,如:{112, 12312},如果M是100,也就是10^2,那么计算出的哈希值都是12。

  • 当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数),因为根据数学证明,这样可以减少哈希冲突。

  • 需要说明的是,实践中Java的HashMap采用除法散列法时就是2的整数次幂做哈希表的大小M,这样就不用取模,而可以直接位运算。但是他不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用key' = key>>16,然后把key和key'异或的结果作为哈希值。这样映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀一些。

    size_t hash2(size_t key, int n)       //n=logM
    {
        size_t mask = (1 << n) - 1;       //创建掩码
        size_t key1 = key ^ (key >> 16);  //高低位混合
        size_t hash = key1 & mask;        //取后n位,保证范围不超
        return hash;
    }

    一个二进制的数,后n位怎么取?

    方法:num&( (1 << n) - 1);

    解析:当num=139,n=5时

    成功取到后5位,这种方法叫做掩码法。

(2)乘法散列法(了解)

公式:h(key) = floor(M × (A × key mod 1))

M是哈希表大小;A是一个常数(0 < A < 1);key%1是取key的小数部分;floor是向下取整。

步骤:

  • 选择常数A:通常在0.618附近(黄金分割相关)
  • 计算乘积A × key
  • 取小数部分(A × key) mod 1 或 (A × key) - floor(A × key)
  • 乘以表大小M × 小数部分
  • 向下取整:得到最终的哈希值
size_t hash(size_t key)
{
    double product = 0.61803 * key;
    double fractional = product - std::floor(product);
    size_t hash_value = std::floor(M * fractional);
    return hash_value;
}

示例:

Key: 1234;   常数 A: 0.618034;    表大小 M: 1000
A × key = 0.618034 × 1234 = 762.653;       小数部分: 762.653 - 762 = 0.652956;
M × 小数部分 = 1000 × 0.652956 = 652.956;        向下取整: 652
最终哈希值: 652 (在 [0, 999] 范围内)。

(3)全域散列法(了解)

  • 如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,解决方法就是给散列函数增加随机性,这种方法叫做全域散列。

  • h_ab(key) = ((a × key + b) % P) % M,P需要选一个足够大的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数。假设P=17,M=6,a = 3,b = 4,则h_34(8) = ((3 × 8 + 4) % 17) % 6 = 5。

  • 需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数。否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数, 查找又是另一个散列函数,就会导致找不到插入的key了。

(4)其他方法

       上面的几种方法是《算法导论》书籍中讲解的方法,最最常用的就是取模!其他教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景。

2. 处理非整数类型

       哈希函数有一个硬性要求就是key必须转化为正整数,可是我们的key往往不是整数,有可能是字符串,甚至是自定义类,那就需要我们自己写转正整数的函数。写的时候要注意减少哈希冲突,其实就是尽量让每个key对应的正整数值不一样,那有同学就问了,该怎么写呢?我在下面举一些例子。

(1)小数类型

struct myhash
{
	size_t operator()(const double key)
	{
		return (size_t)key;
	}
};

这样是转成正整数了,但是如果有5.1,5.2,5.9,5.66,那不都是一个值吗,哈希冲突就多了。所以更好的写法可以是:

struct myhash
{
	size_t operator()(const double key)
	{
		size_t a =(size_t)key;
		double b = (key - a) * 1331;
        //让小数位也参与到运算中
		return a + (size_t)b;
	}
};

(2)string类

很多人会想到把每个字符的ascii码加起来,这确实是一种方法,但是如果有abcd,dabc,aadd,这3种字符串按照这种方法转化出来的整数值是相等的,不是很好,下面我给出一种科学家研究出来的方法:

size_t operator()(const std::string& s)
{
	size_t hash = 0;
	for (auto ch : s)
	{
		hash += ch;
		hash *= 131;
	}
	return hash;
}

(3)自定义类

比如日期类,我们当然可以把年月日加起来,但是像2025.12.1和2025.1.12不就一样了,所以可以让年月日分别乘以一个数再加起来,或者除等等,这里就不演示了。

        说到底,就是让每个数据有不一样的哈希值,让这个数据的更多位来参与运算,形式多种多样,可以自己设计,只要保证查找与插入时用的同一个就行。

三、哈希冲突的处理

上面的做法其实都在尽量减少哈希冲突,但是哈希冲突永远不可能避免,下面我们来讨论如何解决哈希冲突。

1. 开放定址法

       我给大家一个形象的比喻,开放定址法就好比我们在学校上厕所时,我今天想到5号坑(能保证到这个厕所上的人数一定小于坑数),但是我发现5号有人了,那我只能放弃我想要的,往后寻找一个没人的坑,6号,7号,......要是找到最后一个还有人,那就再到第一个找。假设我在1号坑找到了,那今天想蹲1号坑的就还得往后找。因为我只告诉舍友我在5号坑,但是他们找我时发现不在,也只能顺着找,直到找到我!

 (1)哈希表结构

还是用Key-Value模型,不过要加一个状态(原因在后面)

enum State
{
	EXIST,
	EMPTY,
	DELETE
};
template
struct HashDate
{
	std::pair _kv;
	State _state = EMPTY;
};
template
class HashTable
{
	typedef HashDate Date;
private:
	size_t _n = 0;   //哈希表中现有的数据个数
	std::vector _tables;  //动态数组存储哈希值
};

(2)探测方法

       在开放定址法中所有的元素都放到哈希表里,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于1的。这里的规则有三种:线性探测、二次探测、双重探测。

线性探测
  • 从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。

  • h(key) = hash0 = key % M,hash0位置冲突了,则线性探测公式为:hc(key,i) = hashi = (hash0 + i) % Mi = {1, 2, 3, ..., M − 1}

  • 因为负载因子小于1,则最多探测M-1次,一定能找到一个存储key的位置。

  • 线性探测比较简单且容易实现,但假设hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下面的二次探测可以一定程度改善这个问题。

bool Insert(const std::pair& kv)
{
    if (Find(kv.first)) return false;
	size_t hash0 = kv.first % _tables.size();
	size_t hashi = hash0;
	for (int i = 0; _tables[hashi]._state == EXIST; i++)
	{
		hashi = (hash0 + i) % _tables.size();
	}
	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXIST;
	_n++;
	return true;
}
Date* Find(const K& key)
{
	size_t hash0 = key % _tables.size();
	size_t hashi = hash0;
	for (int i = 0; _tables[hashi]._state != EMPTY; i++)
	{
		hashi = (hash0 + i) % _tables.size();
		if (_tables[hashi]._state == EXIST)
		{
			if (_tables[hashi]._kv.first = key)
				return &_tables[hashi];
		}
	}
	return nullptr;
}

这时,你应该懂了为什么需要状态标识了

开放定址法需要状态标识的三个核心原因:

① 解决查找断裂问题

  • 没有状态标识:删除元素后,该位置变空,会错误终止后续冲突元素的查找
  • 有状态标识:DELETE状态表明"这里曾有过数据",查找时继续向后探测

② 区分两种"空"位置

  • EMPTY:从未使用过,查找时遇到立即停止(元素肯定不存在)
  • DELETE:曾经有数据,查找时继续向后探测(元素可能在后面)

③ 空间复用优化

  • 插入新元素时,可以优先使用DELETE位置
  • 避免EMPTY位置被过早占用,保持探测连续性
二次探测
  • 从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置。

  • h(key) = hash0 = key % M,hash0位置冲突了,则二次探测公式为:hc(key,i) = hashi = (hash0 + i²) % Mi = {1, 2, 3, ..., M/2}  (有些书籍上是加一次i方,减一次i方,我觉得直接一直加也是一样的效果,加减交替的代码不太好写)

size_t QuadraticProbe(size_t start, size_t i)
{
	// 这里使用正负交替探测
	if (i % 2 == 0)
	{
		// 偶数次:正向探测 (h(k) + (i/2)²)
		return (start + (i / 2) * (i / 2)) % _table.size();
	}
	else
	{
		// 奇数次:负向探测 (h(k) - (i/2)²)
		size_t result = start - ((i / 2) * (i / 2)) % _table.size();
		if (result < 0) result += _table.size();
		return result;
	}
}
双重散列(了解即可)

(3)扩容方法

这里我们哈希表负载因子控制在0.7,当负载因子到0.7以后我们就需要扩容了,我们还是按照2倍扩容,但是同时我们要保持哈希表大小是一个质数,第一个是质数,2倍后就不是质数了。

那么如何解决呢?一种方案就是上面除法散列中我们讲的Java HashMap的使用2的整数幂,但是计算时不能直接取模的改进方法。另外一种方案是C++给了一个近似2倍的质数表,每次去质数表获取扩容后的大小。

inline unsigned long __stl_next_prime(unsigned long n)
{
	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
	};
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list + __stl_num_primes;
	const unsigned long* pos = std::lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

扩容时要涉及到数据的拷贝,M都不同了,每个数据的位置也不同,那就要重新插入

if (load_factor() > 0.7)
{
	//扩容
	HashTable _newtable;
	_newtable._tables.resize(__stl_next_prime(_tables.size() + 1));
	for (size_t i = 0; i < _tables.size(); i++)
	{
		if (_tables[i]._state == EXIST)
		{
			_newtable.Insert(_tables[i]._kv);
		}
	}
	_tables.swap(_newtable._tables);
}

2. 链地址法

        这个就比较简单了,我今天想到5号坑,如果他有人,我也不找,我就偏要到5号,全世间那么多厕所,还找不到了5号坑没人的?但是走的时候要告诉这个厕所5号坑的人,我要去哪个厕所,这样舍友就能找到我了。

(1)具体方法

        开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,链地址法也叫做拉链法或者哈希桶。

(2)哈希表结构

template
struct HashNode
{
	std::pair _kv;
	HashNode* _next;
	HashNode(const std::pair& kv)
		:_kv(kv),
		_next(nullptr)
	{}
};
template
class HashTable
{
	typedef HashNode Node;
public:
    HashTable()
    {
	   _tables.resize(__stl_next_prime(0),nullptr);
    }
private:
	size_t _n = 0;
	std::vector _tables;
};

(3)头插法插入

bool Insert(const std::pair& kv)
{
	if (Find(kv.first)) return false;
	size_t hashi = kv.first % _tables.size();
    //和list那一块完全一样
	Node* newnode = new Node(kv);
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
	_n++;
	return true;
}
Node* Find(const K& key)
{
	size_t hashi = hash(key) % _tables.size();
	Node* cur = _tables[hashi];
	while (cur)
	{
		if (cur->_kv.first == key)
		{
			return cur;
		}
		cur = cur->_next;
        //顺着指针往下找
	}
	return nullptr;
}

如果极端场景下,某个桶特别长怎么办?

       其实我们可以考虑使用全域散列法,这样就不容易被针对了。但是假设不是被针对了,用了全域散列法,但是偶然情况下,某个桶很长,查找效率很低怎么办?

       这里在Java8的HashMap中当桶的长度超过一定阀值(8)时就把链表转换成红黑树。一般情况下,不断扩容,单个桶很长的场景还是比较少的,下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解一下。

(4)扩容方法

        开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低;STL中有关哈希的容器的最大负载因子基本控制在1,大于1就扩容,我们下面实现使用0.98这个分界点。

下面有两种方式扩容,大家看看哪个好:

//方案1:复用insert方法
if (load_factor() > 0.98)
{
	HashTable newTable;
	newTable._tables.resize(newSize);
	for (size_t i = 0; i < _tables.size(); i++) {
		Node* cur = _tables[i];
		while (cur) {
			Node* next = cur->_next;
			newTable.insert(cur->_kv);  // 调用insert插入
			delete cur;                 // 删除旧节点
			cur = next;
		}
		_tables[i] = nullptr;
	}
	_n = newTable._n;
	_tables.swap(newTable._tables);
}
//方案2:直接节点转移
if (load_factor() > 0.98)
{
	HashTable _newtable;
	_newtable._tables.resize(__stl_next_prime(_tables.size() + 1));
	Hash hash;
	for (int i = 0; i < _tables.size(); i++)
	{
		Node* cur = _tables[i];
		while (cur)
		{
			Node* next = cur->_next;
			size_t hashi = hash(cur->_kv.first) % _newtable._tables.size();
			cur->_next = _newtable._tables[hashi];
			_newtable._tables[hashi] = cur;
			cur = next;
		}
		_tables[i] = nullptr;
	}
	_newtable._n = this->_n;
	_tables.swap(_newtable._tables);
}
对比维度方案2(直接转移)方案1(复用insert)
内存操作✅ 零内存分配,重用现有节点❌ 分配新节点 + 释放旧节点,双重内存操作
性能✅ O(n) 直接转移,无额外开销❌ O(n) + 哈希计算 + 重复检查,额外函数调用开销
哈希计算✅ 1次哈希计算❌ 2次哈希计算(insert内部可能再算一次)
重复检查

✅ 无需检查

(数据来自旧表,保证唯一)

❌ insert会重复检查key是否存在
代码简洁性❌ 需要手动操作链表✅ 代码简洁,逻辑清晰
安全性✅ 原子性交换,异常安全❌ 可能因insert失败导致数据丢失
节点顺序✅ 保持原有链表顺序❌ 链表顺序可能改变

所以,我们用方案2!

(5)删除数据

删除一个数时要记得记录它的前一个数据,这样才能建立链接 prev->_next = cur->_next; ,同时还要注意删除的是头结点的情况。

bool Erase(const K& key)
{
	size_t hashi = key % _tables.size();
	Node* cur = _tables[hashi];
	Node* prev = nullptr;
	while (cur)
	{
		if (cur->_kv.first == key)
		{
			if (prev == nullptr)
			{
				_tables[hashi] = cur->_next;
			}
			else
			{
				prev->_next = cur->_next;
			}
			delete cur;
			--_n;
			return true;
		}
		prev = cur;
		cur = cur->_next;
	}
}

别忘了写析构函数哦!其实它就是单链表,析构也一样,要先记录Node* next = node->_next;

~HashTable()
{
	for (int i = 0; i < _tables.size(); i++)
	{
		Node* node = _tables[i];
		while (node)
		{
			Node* next = node->_next;
			delete node;
			node = next;
		}
		_tables[i] = nullptr;
	}
}

四、哈希表的实现

我们在前边说到key不是正整数时处理办法,我们要在此处写成仿函数传给哈希表,由于字符串转化很常用,我们特化一下。另外,我们在查找时会判断两个key是否相等,如果是自定义类,那就我们必须自己实现==,所以也写一个仿函数传给哈希表class Pred = myequal_to<K>。

#pragma once
#include
#include
#include
#include
#include
template
struct myhash
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};
template<>
struct myhash
{
	size_t operator()(const std::string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 131;
		}
		return hash;
	}
};
template
struct myequal_to
{
	bool operator()(const K& lhs, const K& rhs) const
	{
		return lhs == rhs;
	}
};
namespace open_address
{
	enum State
	{
		EXIST,
		EMPTY,
		DELETE
	};
	template
	struct HashDate
	{
		std::pair _kv;
		State _state = EMPTY;
	};
	template, class Pred = myequal_to>
	class HashTable
	{
		typedef HashDate Date;
	public:
		inline unsigned long __stl_next_prime(unsigned long n)
		{
			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
			};
			const unsigned long* first = __stl_prime_list;
			const unsigned long* last = __stl_prime_list + __stl_num_primes;
			const unsigned long* pos = std::lower_bound(first, last, n);
			return pos == last ? *(last - 1) : *pos;
		}
		HashTable()
		{
			_tables.resize(__stl_next_prime(0));
		}
		float load_factor() const
		{
			return (float)_n / (float)(_tables.size());
		}
		bool Insert(const std::pair& kv)
		{
			if (Find(kv.first)) return false;
			if (load_factor() > 0.7)
			{
				//扩容
				HashTable _newtable;
				_newtable._tables.resize(__stl_next_prime(_tables.size() + 1));
				for (size_t i = 0; i < _tables.size(); i++)
				{
					if (_tables[i]._state == EXIST)
					{
						_newtable.Insert(_tables[i]._kv);
					}
				}
				_tables.swap(_newtable._tables);
			}
			Hash hash;
			size_t hash0 = hash(kv.first) % _tables.size();
			size_t hashi = hash0;
			for (int i = 0; _tables[hashi]._state == EXIST; i++)
			{
				hashi = (hash0 + i) % _tables.size();
			}
			_tables[hashi]._kv = kv;
			_tables[hashi]._state = EXIST;
			_n++;
			return true;
		}
		Date* Find(const K& key)
		{
			Hash hash;
			Pred equal;
			size_t hash0 = hash(key) % _tables.size();
			size_t hashi = hash0;
			for (int i = 0; _tables[hashi]._state != EMPTY; i++)
			{
				hashi = (hash0 + i) % _tables.size();
				if (_tables[hashi]._state == EXIST)
				{
					if (equal(_tables[hashi]._kv.first, key))
						return &_tables[hashi];
				}
			}
			return nullptr;
		}
		bool Erase(const K& key)
		{
			Date* ret = Find(key);
			if (ret)
			{
				ret->_state = DELETE;
				_n--;
				return true;
			}
			else  return false;
		}
	private:
		size_t _n = 0;   //哈希表中现有的数据个数
		std::vector _tables;  //动态数组存储哈希值
	};
}
namespace hash_bucket
{
	template
	struct HashNode
	{
		std::pair _kv;
		HashNode* _next;
		HashNode(const std::pair& kv)
			:_kv(kv),
			_next(nullptr)
		{}
	};
	template, class Pred = equal_to>
	class HashTable
	{
		typedef HashNode Node;
	public:
		inline unsigned long __stl_next_prime(unsigned long n)
		{
			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
			};
			const unsigned long* first = __stl_prime_list;
			const unsigned long* last = __stl_prime_list + __stl_num_primes;
			const unsigned long* pos = std::lower_bound(first, last, n);
			return pos == last ? *(last - 1) : *pos;
		}
		HashTable()
		{
			_tables.resize(__stl_next_prime(0),nullptr);
		}
		~HashTable()
		{
			for (int i = 0; i < _tables.size(); i++)
			{
				Node* node = _tables[i];
				while (node)
				{
					Node* next = node->_next;
					delete node;
					node = next;
				}
				_tables[i] = nullptr;
			}
		}
		float load_factor() const
		{
			return (float)_n / (float)(_tables.size());
		}
		bool Insert(const std::pair& kv)
		{
			if (Find(kv.first)) return false;
			if (load_factor() > 0.98)
			{
				//扩容
				HashTable _newtable;
				_newtable._tables.resize(__stl_next_prime(_tables.size() + 1));
				Hash hash;
				for (int i = 0; i < _tables.size(); i++)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						size_t hashi = hash(cur->_kv.first) % _newtable._tables.size();
						cur->_next = _newtable._tables[hashi];
						_newtable._tables[hashi] = cur;
						cur = next;
					}
					_tables[i] = nullptr;
				}
				_newtable._n = this->_n;
				_tables.swap(_newtable._tables);
			}
			Hash hash;
			size_t hashi = hash(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 hash;
			Pred equal;
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (equal(cur->_kv.first, key))
				{
					return cur;
				}
				cur = cur->_next;
			}
			return nullptr;
		}
		bool Erase(const K& key)
		{
			Hash hash;
			Pred equal;
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (equal(cur->_kv.first, key))
				{
					if (prev==nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
					--_n;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
		}
	private:
		size_t _n = 0;
		std::vector _tables;
	};
}

五、位图

1. 位图的概念

假设有这样一道题:给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。你会怎么做?

解题思路1:暴力遍历

  • 时间复杂度O(N),太慢

解题思路2:排序+二分查找

  • 时间复杂度O(N×logN) + O(logN)
  • 深入分析:40亿个整数约需16GB内存,无法直接放入内存,只能放硬盘文件中
  • 二分查找只能对内存数组中的有序数据进行查找,因此不可行

解题思路3:哈希表

  • 是很快,但还是空间不够
  • 分析一下40亿要多少内存:1G=1024MB=1024*1024KB=1024*1024*1024Byte约等于10亿多字节,一个int是4字节,那么40亿个整数约等于16G。 
  • 我们想,一个int只能存一个整数,要占4个字节,32个比特位,可我们只是要看他在不在,做一个标记就好,1就是在,0就是不在,干嘛要占这么多内存。所以这就用到我们的位图了。

定义:

        位图(Bitmap),也称为位数组或位集合,是一种使用比特位来存储和表示数据的数据结构。每个比特位只能表示两种状态(0或1),这使得位图在存储大量的布尔值或状态标记时具有极高的空间效率。位图本质是一个直接定址法的哈希表,每个整型值映射一个bit位,位图提供控制这个bit的相关接口。

2. 位图的实现

        我们要想控制bit位,只能用位运算。Bitmap有三个核心接口,set用来把一个位标记成1,表示存在;reset用来把一个位标记成0,表示删除;test用来查找。

(1)怎么找一个数对应的位(32位平台下用int存储)

其实这和取一个整数每一位一样,除以10,再模10。我们是先 int j = x / 32; 表示它存在了第几个int 中,再用 int i = x % 32; 算出它在第 j 个数的第几个 bit 位。

(2)如何让第第 j 个数的第 i 位变成1或0

| ——全0才0,让1左移i位,保证该位被置1,其他为|0,值不变;

&——全1才1,让1左移i位,取反,保证该位被置0,其他为&1,值不变.

代码实现

namespace Bitmap
{
	template //N是你要存储的数据个数
	class bitmap
	{
	public:
		bitmap()
		{
			_bs.resize(N / 32 + 1, 0);  //初始全部置0
		}
		void set(size_t x)
		{
			int j = x / 32;
			int i = x % 32;
			_bs[j] |= (1 << i);
		}
		void reset(size_t x)
		{
			int j = x / 32;
			int i = x % 32;
			_bs[j] &= ~(1 << i);
		}
		bool test(size_t x)
		{
			int j = x / 32;
			int i = x % 32;
            //&可以取到第i位,且其他位不变
			if (_bs[j] & (1 << i))  return true;
			else  return false;
		}
	private:
		std::vector _bs;  //vector存储int值
	};

3. 库函数bitset

#include
#include
using namespace std;
int main()
{
    // 1. 默认构造:所有位初始化为0
    bitset<666> bs1;  // 00000000
    // 2. 从整数构造
    bitset<8> bs2(42);  // 00101010 (42的二进制)
    // 3. 从字符串构造
    bitset<8> bs3(string("10101010"));    //10101010
    cout << "第0位: " << bs3[0] << endl;  // 0 (最右边是最低位)
    cout << "第1位: " << bs3[1] << endl;  // 1
    cout << "第7位: " << bs3[7] << endl;  // 1 (最左边是最高位)
    bs3[0] = 1;  // 设置第0位为1
    cout << "修改后: " << bs3 << endl;  // 10101011
    cout << "bs3设置位数量: " << bs3.count() << endl;  // 5(统计1的数量)
    cout << "bs1大小: " << bs1.size() << endl;  //666
    //any检查是否有1
    cout << "bs1有任何位设置吗? " << bs1.any() << endl;  // false
    cout << "bs2有任何位设置吗? " << bs2.any() << endl;  // true
    //none检查是否有0
    cout << "bs1没有位设置吗? " << bs1.none() << endl;  // true
    // 转换为字符串
    string str = bs2.to_string();
    cout << "字符串表示: " << str << endl;  // "00101010"
    // 可以指定显示的字符
    string customStr = bs2.to_string('O', 'X');
    cout << "自定义表示: " << customStr << endl;  // "OOXOXOXO"
    // 实际应用:二进制输出
    cout << "42的二进制: " << bs2.to_string() << endl;
    return 0;
}

4. 变式问题

(1)给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?

解题思路:把数据读出来,分别放到两个位图,依次遍历,同时在两个位图的值就是交集。

(2)一个文件有100亿个整数,1G内存,设计算法找到出现次数不超过2次的所有整数。

解题思路:之前我们是标记在不在,只需要一个位即可,这里要统计出现次数不超过2次的,可以每个值用两个位(可以用两个位图)标记即可:00代表出现0次;01代表出现1次;10代表出现2次;11代表出现2次以上。最后统计出所有01和10标记的值即可。

	template
	class Twobitset
	{
	public:
		void set(size_t x)
		{
			if (!_bs1.test(x) && !_bs2.test(x))
			{
				_bs1.set(x);
			}
			else if (_bs1.test(x) && !_bs2.test(x))
			{
				_bs1.reset(x);
				_bs2.set(x);
			}
			else if (!_bs1.test(x) && _bs2.test(x))
			{
				_bs1.set(x);
			}
			else return;
		}
		int getcount(size_t x)
		{
			if (!_bs1.test(x) && !_bs2.test(x))
				return 0;
			else if (_bs1.test(x) && !_bs2.test(x))
				return 1;
			else if (!_bs1.test(x) && _bs2.test(x))
				return 2;
			else return 3;
		}
	private:
		bitset _bs1;
		bitset _bs2;
	};
}

六、布隆过滤器

1. 布隆过滤器的概念

       相信很多同学也看出来了,位图查找是快,但只能是正整数,我们要想找其他的怎么办,这就要用到布隆过滤器!

布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你“某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。

其实就是,先把非整数类型向上面我们讲的一样转化成正整数,再用位图,但是又因为转化时难免会转成相同的值,所以我们就可以写多个转化函数,把key对应成不同值,再存储到多个位,判断时只要有一个是0,那就不存在,这样就能减少哈希冲突。

2. 误判率

       布隆过滤器这里跟哈希表不一样,它无法解决哈希冲突的,因为他压根就不存储这个值,只标记映射的位。它的思路是尽可能降低哈希冲突。判断一个值key在是不准确的,判断一个值key不在是准确的。比如,我要看“孙悟空”是否存在,我根据我的转化函数,发现所有位都是1,那我不能一定保证它在,因为有可能别的存在的字符串也映射到了这几个位;但是如果有一个是0,那我知道它一定是不在的!

误判率结论:

(这比较复杂,涉及概率论、极限、对数运算,求导函数等知识,有兴趣且数学功底比较好的可以搜一下,其他小伙伴记一下结论即可!)

公式:f\left ( k \right )=\left (1-e^{-k*\frac{n}{m}} \right )^{k}

m:布隆过滤器的bit长度。   n:插⼊过滤器的元素个数。    k:哈希函数的个数。)

由误判率公式可知,在k一定的情况下,当n增加时,误判率增加,m增加时,误判率减少

在m和n一定,在对误判率公式求导,误判率尽可能小的情况下,可以得到hash函数个数:k=\frac{m}{n}*ln2 时误判率最低。

期望的误判率p和插入数据个数n确定的情况下,再把上面的公式带入误判率公式,通过期望的误判率和插入数据个数n得到bitmap的长度:m=-\frac{n*lnp}{(ln2)^{2}}

3. 代码实现

注意点:

(1)布隆过滤器默认是不支持删除的,因为比如"猪八戒"和"孙悟空"都映射在布隆过滤器中,他们映射的位有一个位是共同映射的(冲突的),如果我们把孙悟空删掉,那么再去查找"猪八戒"会查找不到,因为那么"猪八戒"间接被删掉了。

解决方案:可以考虑计数标记的方式,一个位置用多个位标记,记录映射这个位的计数值,删除时,仅仅减减计数,那么就可以某种程度支持删除。但是这个方案也有缺陷,如果一个值不在布隆过滤器中,我们去删除,减减了映射位的计数,那么会影响已存在的值,也就是说,一个确定存在的值,可能会变成不存在,这里就很坑。当然也有人提出,我们可以考虑计数方式支持删除,但是定期重建一下布隆过滤器,这样也是一种思路。

(2)很多科学家都发明了减少字符串哈希冲突的方法,这有一篇博客供大家参考。

各种字符串Hash函数

(3)按道理应该实现一个参数是p和n的模板类,然后算出m和k,但是太麻烦了,我们就确定k,n,m,key为string,来实现一个简单的布隆过滤器。

#include
#include"bitset.h"
struct HashFuncBKDR
{
	size_t operator()(const std::string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash *= 31;
			hash += ch;
		}
		return hash;
	}
};
struct HashFuncAP
{
	size_t operator()(const std::string& s)
	{
		size_t hash = 0;
		for (size_t i = 0; i < s.size(); i++)
		{
			if ((i & 1) == 0)   // 偶数位字符
				hash ^= ((hash << 7) ^ (s[i]) ^ (hash >> 3));
			else                // 奇数位字符
				hash ^= (~((hash << 11) ^ (s[i]) ^ (hash >> 5)));
		}
		return hash;
	}
};
struct HashFuncDJB
{
	size_t operator()(const std::string& s)
	{
		size_t hash = 5381;
		for (auto ch : s)
		{
			hash = hash * 33 ^ ch;
		}
		return hash;
	}
};
namespace Bloomfilter
{
    //X其实就是上面公式中的M/N
	template
	class bloomfilter
	{
	public:
		void set(const K& key)
		{
			size_t bit1 = Hash1(key) % M;
			size_t bit2 = Hash2(key) % M;
			size_t bit3 = Hash3(key) % M;
			_bs.set(bit1);
			_bs.set(bit2);
			_bs.set(bit3);
		}
		bool test(const K& key)
		{
			size_t bit1 = Hash1(key) % M;
			size_t bit2 = Hash2(key) % M;
			size_t bit3 = Hash3(key) % M;
			if (!_bs.test(bit1))  return false;
			if (!_bs.test(bit2))  return false;
			if (!_bs.test(bit3))  return false;
			return true;
			// 存在误判(有可能3个位都是跟别人冲突的)
		}
		double getFalseProbability()
		{
			double p = pow((1.0 - pow(2.71, -3.0 / X)), 3.0);
			return p;
		}
	private:
		static const size_t M = X * N;
		Bitset::bitset _bs;
	};
}

为什么X = 6 

  • 每个元素分配 6 个比特(M/N)

  • 总位图大小 = 6 × 元素个数 N

  • 在3个哈希函数下,根据公式计算得,理论误判率约 1%

  • 是内存消耗和准确性的良好平衡点

4. 应用

(能看懂多少就看懂多少)

七、哈希切割

1. 概念

       哈希切割是一种基于哈希函数的数据分区技术,它将数据分布到多个分区(桶)中,每个分区包含具有相同哈希特征的数据子集。这种技术在分布式计算、数据库管理和大数据处理中有着广泛的应用。它更偏向于实际应用。

2. 实际问题①

给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?

解决方案1:这个首先可以用布隆过滤器解决,一个文件中的query放进布隆过滤器,另一个文件依次查找,在的就是交集,问题就是找到交集不够准确,因为在的值可能是误判的,但是交集一定被找到了。

解决方案2:哈希切割

引入:内存的访问速度远大于硬盘,大文件放到内存搞不定,那么我们可以考虑切分为小文件,再放进内存处理。但是如果平均切分,每个小文件还是需要依次暴力处理,效率仍然低。

做法:利用哈希切割,依次读取文件中query,i = HashFunc(query) % N(N为准备切分多少份小文件,N取决于切成多少份时内存能放下),query放进第i号小文件,这样A和B中相同的query算出的hash值i是一样的,相同的query就进入的编号相同的小文件,然后编号相同的文件直接找交集就行,不用交叉找,效率就提升了。

原理:相同的query在哈希切分过程中,一定进入的同一个小组件Ai和Bi,不可能出现A中的query进入Ai,但是B中的相同query进入了Bj的情况,所以对Ai和Bi进行求交集即可,不需要Ai和Bj求交集。(i≠j)

某个⼩⽂件很⼤内存放不下怎么办?

因为每个小文件不是均匀切分的,所以有可能出现此状况,但只有以下两个原因:

(1)这个小文件中大部分是同一个query:用库函数set(下一篇讲),他可以去重。

(2)这个小文件是有很多的不同query构成,但这些query冲突了:换个哈希函数继续二次哈希切割。

不管哪个情况,我们都可以先用set存储,要是insert时没抛异常,是不是情况一也无所谓了,要是抛了异常,那就是情况2,换个哈希函数进行二次哈希切分后再对应找交集即可。(set插入数据抛异常只可能是申请内存失败了,不会有其他情况)

3. 实际问题②

给一个超过100G大小的log file,log中存着IP地址,设计算法找到出现次数最多的IP地址?查找出现次数前10的IP地址。

本题的思路与上题完全类似:

  1. 依次读取文件中IP地址,计算 i = HashFunc(IP) % 500

  2. 将IP放进第i号小文件Ai中

  3. 依次用 map<string, int> (下一篇讲)对每个Ai小文件统计IP出现次数

  4. 同时求出每个小文件中出现次数最多的IP或者topK IP

核心原理:相同的IP在哈希切分过程中,一定会进入同一个小文件Ai,不可能出现同一个IP进入Ai和Aj的情况(i≠j),因此对每个Ai文件单独统计IP次数就是准确的全局次数。

本文源码在此



后记:哈希切割偏向实际问题,面试官一般也不会让你写出代码,所以最后我没有给出具体代码,哈希表是一个非常主要的数据结构,不管是面试笔试,竞赛学习还是工作当中都非常常见,大家一定重点掌握!如果有帮助,麻烦点个小心心吧!