哈希函数和哈希表的实现
哈希函数
哈希函数(Hash function)是一种将任意长度的消息映射到固定长度的输出的函数。它接收一个消息或数据块作为输入,然后使用算法将其转换为固定长度的哈希值或消息摘要,通常是一个定长的字符串或数字。哈希函数的主要作用是保证数据的完整性和一致性。
常用的哈希函数有MD5(生成的哈希值范围
[
0
,
2
64
−
1
]
[0,2^{64}-1]
[0,264−1])、SHA-1(生成的哈希值范围
[
0
,
2
128
−
1
]
[0,2^{128}-1]
[0,2128−1])、SHA-256等。
哈希函数特点
- 固定输出长度:哈希函数将任意长度的输入消息映射为固定长度的哈希值,这个长度通常是固定的,不会因输入的大小而改变。
- 确定性:对于相同的输入消息,哈希函数总是会生成相同的哈希值。这种特性可以用来验证数据的完整性和一致性。
- 不可逆性:从哈希值反推出原始消息是困难的甚至不可能的。因此,哈希函数是一种单向函数,它可以确保原始数据的保密性。
- 抗碰撞性:对于不同的输入消息,哈希函数生成的哈希值应该是不同的。尽管在理论上可能存在两个不同的消息生成相同的哈希值(称为哈希碰撞),但是好的哈希函数应该能够最大限度地减少碰撞的概率。
- 散列性:哈希函数应该能够将输入消息的每一个比特位都均匀地分散到哈希值中,以最大限度地减少哈希碰撞的可能性。
哈希表
哈希表(Hash Table)是一种基于哈希函数实现的数据结构,用于高效地存储和查找数据。其底层实现通常包括以下几个部分:
- 数组:哈希表通常是基于数组实现的。数组的每个元素都可以存储一个数据项,数据项包括键值对或者其他需要存储的信息。
- 哈希函数:哈希函数将输入的关键字映射为一个数组下标。这个函数应该能够将不同的输入映射到不同的下标,并尽可能地减少哈希碰撞的概率。常用的哈希函数包括除余法、乘法哈希等。
- 冲突处理:由于哈希函数可能存在哈希碰撞,即将不同的关键字映射到同一个下标的情况。为了解决这个问题,哈希表通常采用开放地址法或者链地址法来处理哈希碰撞。
- 链地址法:当出现哈希碰撞时,将数据插入到一个链表中。如果多个关键字映射到同一个下标,那么它们会存储在同一个链表中。
- 开放地址法:当出现哈希碰撞时,从当前下标开始依次查找空闲位置,并将数据插入到第一个空闲位置。常用的开放地址法包括线性探测、二次探测、双重散列等。 - 扩容:随着数据的不断插入,哈希表的负载因子(元素个数与数组大小的比值)会增大,导致哈希表的性能下降。为了解决这个问题,哈希表通常会设置一个负载因子阈值,当负载因子超过阈值时,就需要进行扩容操作,即创建一个更大的数组,将所有数据重新哈希到新数组中。假设一共N个元素,单次扩容代价为 O ( N ) O(N) O(N),扩容次数为 O ( l o g N ) O(log N) O(logN)(实际上因为负载因子阈值的存在, l o g N logN logN前的常数很小,使用时可以认为接近O(1)),所以总扩容代价为 O ( N ∗ l o g N ) O(N*logN) O(N∗logN)。
设计类型例题
设计RandomPool结构
题目说明
设计一种结构,在该结构中有如下三个功能:
insert(key):将某个key加入到该结构,做到不重复加入;
delete(key):将原本在结构中的某个key移除;
getRandom():等概率随即返回结构中的任何一个key。
要求三种方法的时间复杂度都是O(1)。
设计思路
分别用两个哈希表Map1<K, Integer>,Map2<Integer, K>
来存储数据,Map1
的值和Map2
的键为所给Key
对应的index
(注意,这里的索引和数组没有关系,存粹是为了等概率返回Key的方法设计),再用一个整型变量size
记录当前的key总数。insert(key)
时将(key, size)
插入Map1
,(size, key)
插入Map2
,然后size++
;getRandom()
时直接获取一个[0,size]
的随机数然后返回Map2中
对应的值即可;关键是delete(key)
的操作,要让随机数保证能选到有效的index
,我们可以将Map1中
最后(这里的最后相对index而言,因为哈希表内部实际上是无序的)一个键值对的值更新为要删除的那个key
的index
(Map2
同理也要更新),然后size--
,要删除的key
从两个Map
中删除,也即用最后一个key
去填补删掉的那个空,这样就可以保证随机数选中有效的位置,同时概率不变。
代码实现
public class RandomizedSet<K> {
HashMap<K, Integer> valToIndex; // 用来存储每个元素在数组中的下标的哈希表
HashMap<Integer, K> indexToVal; // 用来存储每个下标对应的元素值的哈希表
int size; // 记录集合的元素个数
Random random; // 用来生成随机数的 Random 对象
/** Initialize your data structure here. */
public RandomizedSet() {
valToIndex = new HashMap<K, Integer>();
indexToVal = new HashMap<Integer, K>();
size = 0;
random = new Random();
}
/** 将一个元素插入到集合中。如果集合中已经存在该元素,则返回 false,否则插入并返回 true。 */
public boolean insert(K val) {
if (valToIndex.containsKey(val)) {
return false;
}
valToIndex.put(val, size); // 在哈希表中记录元素的下标
indexToVal.put(size, val); // 在哈希表中记录下标对应的元素值
size++; // 更新集合的元素个数
return true;
}
/** 将一个元素从集合中删除。如果集合中不存在该元素,则返回 false,否则删除并返回 true。 */
public boolean remove(K val) {
if (!valToIndex.containsKey(val)) {
return false;
}
int index = valToIndex.get(val); // 获取要删除的元素的下标
K lastVal = indexToVal.get(size - 1); // 获取末尾元素的值
valToIndex.put(lastVal, index); // 更新末尾元素在哈希表中的下标
indexToVal.put(index, lastVal); // 将末尾元素的值放到要删除元素的下标位置上
valToIndex.remove(val); // 删除要删除的元素在哈希表中的记录
indexToVal.remove(size - 1); // 删除末尾元素在哈希表中的记录
size--; // 更新集合的元素个数
return true;
}
/** 从集合中随机获取一个元素,并返回该元素。 */
public K getRandom() {
return indexToVal.get(random.nextInt(size)); // 生成随机数并返回对应下标的元素值
}
}
布隆过滤器
简介
布隆过滤器(Bloom Filter)是一种空间效率非常高的随机数据结构,用于快速判断一个元素是否属于某个集合,它的优点是空间和时间复杂度都比较低,缺点是有一定的误判率。
具体来说,一个布隆过滤器包括一个位数组(详细说明见下文)和若干个哈希函数。首先将所有元素的哈希值映射到位数组上,将相应的位置设为1。当判断一个元素是否存在于集合中时,先将该元素的哈希值映射到位数组上,检查相应的位是否都为1,若有一位为0,则该元素一定不存在于集合中,若都为1,则该元素可能存在于集合中(可能存在误判)。由于哈希函数的特殊性,布隆过滤器能够在空间占用相对较小的情况下,达到比哈希表更快的查询速度。
使用布隆过滤器可以有效地缓解大规模数据的存储和查询压力。例如,搜索引擎可以使用布隆过滤器来过滤掉明显不存在的关键词,从而减轻底层数据库的压力。此外,布隆过滤器也可以用于网络安全领域,如防止恶意软件和垃圾邮件的传播等。
需要注意的是,布隆过滤器的误判率p
与哈希函数的数量k
和位数组的大小m
有关,误判率越低需要的哈希函数越多、位数组越大,空间占用就越大。在实际应用中需要根据具体情况来选择合适的参数。同时,由于布隆过滤器只能判断元素是否可能存在于集合中,无法准确地判断元素是否真正存在于集合中,因此在某些应用场景中需要结合其他数据结构来进行补充判断。
位数组
位数组内每个元素只占用一比特(bit),可以用基础类型以基础来实现位数组,比如Java中长度为10的int型数组arr
可以视为长度320的位数组bitArr
(一个int型有32个bit),arr[0]
代表bitArr[0] - bitArr[31]
。对与这样一个数组,首先得到两个映射变量:
// 第i位对应的arr下标
int numIndex = i / 32;
// 第i位对应的arr[numIndex]中的第几位
int bitIndex = 1 % 32;
然后对于数组有以下几种操作:
// 获取第i位的状态(0还是1)
int s = ((arr[numIndex] >> bitIndex) & 1);
// 把第i位的状态改为1
arr[numIndex] = arr[numIndex] | (1 << bitIndex);
// 把第i位的状态改为0
arr[numIndex] = arr[numIndex] & (~ (1 << bitIndex));
布隆过滤器的参数选取
根据前文的描述,很明显可以看出失误率p
是随着m
的增加而减小的,即位数组越大,空位越多,越不容易误判;而p
随k
的变化比较复杂,首先k
从较小值增加时,不同的原始数据更不容易得到同样的哈希结果,误判率是下降的,但是当k
增加到一定程度时,位数组中的空位(0)会急剧减少,从而导致误判率的上升,所以p
随k
的增加是先下降再上升。实际场景使用时可以根据以下公式进行参数的设置(n为过滤器中元素个数,p为期望的失误率):
m
=
−
(
n
∗
l
n
(
p
)
)
/
(
l
n
(
2
)
2
)
m = - (n * ln(p)) / (ln(2) ^ 2)
m=−(n∗ln(p))/(ln(2)2)
k
=
(
m
/
n
)
∗
l
n
(
2
)
k = (m / n) * ln(2)
k=(m/n)∗ln(2)
实际使用时 m 和 k 向上取整(m一定是2的整数次幂),此时的实际失误率为:
p
真
=
(
1
−
e
−
n
∗
k
真
m
真
)
k
真
p_真=(1-e^{\frac{-n*k_真}{m_真}})^{k_真}
p真=(1−em真−n∗k真)k真
Java实现布隆过滤器
import java.util.BitSet;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class BloomFilter {
private int size; // 位数组的大小
private int k; // 哈希函数的个数
private BitSet bitSet; // 位数组
private MessageDigest md; // 哈希函数
public BloomFilter(int size, int k) {
this.size = size;
this.k = k;
this.bitSet = new BitSet(size);
try {
this.md = MessageDigest.getInstance("MD5"); // 选择MD5作为哈希函数
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
}
// 添加元素到布隆过滤器中
public void add(String element) {
for (int i = 0; i < k; i++) {
int hash = hash(element, i); // 计算第i个哈希函数的哈希值
bitSet.set(Math.abs(hash) % size); // 将哈希值对应的位置设为1
}
}
// 判断元素是否可能存在于集合中
public boolean contains(String element) {
for (int i = 0; i < k; i++) {
int hash = hash(element, i); // 计算第i个哈希函数的哈希值
if (!bitSet.get(Math.abs(hash) % size)) { // 判断对应位置是否为1
return false; // 如果有一位为0,则该元素一定不存在于集合中
}
}
return true; // 如果所有位置都为1,则该元素可能存在于集合中
}
// 计算哈希值
private int hash(String element, int i) {
md.reset(); // 重置哈希函数
md.update((element + i).getBytes()); // 加盐
byte[] bytes = md.digest(); // 计算哈希值
return (bytes[0] & 0xFF)
| ((bytes[1] & 0xFF) << 8)
| ((bytes[2] & 0xFF) << 16)
| ((bytes[3] & 0xFF) << 24); // 将4个字节的哈希值合并成一个32位整数
}
public static void main(String[] args) {
BloomFilter filter = new BloomFilter(1000000, 10); // 创建一个大小为1000000,包含10个哈希函数的布隆过滤器
filter.add("hello");
filter.add("world");
System.out.println(filter.contains("hello")); // 输出 true
System.out.println(filter.contains("world")); // 输出 true
System.out.println(filter.contains("foo")); // 输出 false
}
}
一致性哈希
简介
一致性哈希是一种用于分布式系统中的数据分区和负载均衡的算法。在分布式系统中,数据通常被分成多个分区,并存储在不同的节点上,而负载均衡则是将客户端请求分配到这些节点上以避免单个节点过载。
传统的哈希算法通常将数据映射到固定数量的分区上(哈希值取模),当需要添加或删除节点时,这些分区需要重新分配,这会导致大量的数据移动和性能问题。而一致性哈希算法则可以避免这个问题,因为它在添加或删除节点时,只需要重新映射一小部分数据。
一致性哈希的原理是将数据和节点都映射到一个环上,环的范围为0到2^32-1(根据哈希函数确定,直接使用原始哈希值)。数据被映射到环上的位置通过哈希函数计算得到,而节点则通过节点的标识符计算得到。当需要将数据映射到节点时,从数据在环上的位置开始沿着顺时针方向寻找最近的节点。因此,每个节点都负责一段连续的区域,当需要添加或删除节点时,只需要调整相邻两个节点之间的数据映射即可。
一致性哈希的优点是,它能够在节点增减时保持数据的稳定性,尽可能地减少数据的迁移,避免过多的网络开销。同时,一致性哈希算法可以支持动态扩容,当系统需要增加节点时,只需要将新节点插入环中,然后将原有节点的一部分数据迁移到新节点即可。一致性哈希还能够提供负载均衡的功能,因为每个节点都负责一段连续的区域,所以每个节点的负载相对均衡。
然而,一致性哈希算法也有一些缺点。首先,一致性哈希算法在分布式系统中可能会出现热点问题,即某些数据分布不均,导致一些节点的负载过高。其次,一致性哈希算法需要保证节点的标识符在哈希函数的输出空间中均匀分布,否则会导致数据分布不均匀。最后,一致性哈希算法的实现比传统的哈希算法更为复杂,需要维护节点的状态和数据映射关系等信息。
缺点解决
对于节点标识符难以在环上分布均匀的问题,可以用虚拟节点的方式解决:对每个节点,我们给它分配1000个字符串(虚拟节点标识符),每个节点通过这1000个虚拟节点在环上的映射分配数据即可尽可能保证数据均匀,即使要增减节点,每个节点新增或减少的数据也是尽可能均匀的。
对于负载均衡问题,也可以通过虚拟节点解决。假设某个节点(机器)的性能即将达到瓶颈,我们就可以减少它的虚拟节点,即减少它负载的数据量,增加某个性能充裕节点的虚拟节点数量,由于这种方式数据迁移的代价也不是很高,我们可以根据服务器的实时负载情况动态调整虚拟节点数量,做到优秀的负载均衡。