目录
1. 什么是哈希
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时的效率达到了O(logN),最差情况下需要比较红黑树的高度次,当树中节点非常多时,查询效率也不理想。最好的查询是进行常数次的比较就能将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同。
unordered系列的关联式容器之所以所以效率比较高,因为其底层使用了哈希结构。
理想的搜索方法:不经过任何比较,一次直接从数据中得到想查找的元素。如果构造一种存储结构,通过某种函数使元素的存储位置与它的key之间能够建立一一映射的关系,那么在查找时通过该函数可以很快的找到该元素。
当向该结构中:
- 插入元素:对每个键值对来说,对key用哈希函数计算出存储位置并进行存放值
- 搜索元素:仍对key进行相同的计算,计算出元素的存储位置,搜索成功
哈希(Hashing) 是一种高效的数据存储和检索技术,它通过哈希函数(Hash Function) 将键(Key)映射到固定大小的数组(称为哈希表,Hash Table)的某个位置,从而实现接近 O(1) 时间复杂度的插入、删除和查找操作。
计算机只需调用哈希函数,计算key的哈希值,然后跳转到对应索引并读取即可。
2. 哈希表
2.1 哈希冲突
哈希表很强大,但也不是没有缺点。
以同义词典为例:如果要加入下面这个词条,那么会发生什么呢?
thesaurus["dab"] = "pat"
首先,计算机会把键哈希。DAB = 4 × 1 × 2 = 8然后,计算机会试图把"pat"存储到哈希表的第8格中,如下图所示。
不好,第8格已经被邪恶的"evil"占据了。
不同key通过相同哈希函数计算出相同的哈希地址,就叫哈希冲突。幸运的是,我们有解决办法。
2.2 如何解决哈希冲突
引起哈希冲突的一个可能原因是:哈希函数设计的不够合理。
常见的哈希函数:直接定址法(数据几种,范围集中),除留余数法。哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
解决哈希冲突的两种常见方法:闭散列和开散列
2.2.1 闭散列:开放定址法
当发生哈希冲突时,如果哈希表还没有填满,说明在哈希表中必然还有空位置,那么就可以把值放到冲突位置的下一个空位置,那么如何寻找下一个空位置呢?
1. 线性探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入:
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入,如果该位置有元素发生冲突,使用线性探测找下一个空位置,插入元素
删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有元素,若直接删除元素会影响其他元素的搜索,因此线性探测采用标记的伪删除法来删除一个元素。
enum STATE
{
EXIST,
EMPTY,
DELETE
};
//线性探测
size_t hashi = kv.first % _table.size();
while(_table[hashi].state == EXIST)
{
++hashi;
hashi %= _table.size();//到最后的下一个位置,回到0,从头开始找
}
_table[hashi]._kv = kv;
_table[hashi].state = EXIST;
++_n;
哈希表什么情况下进行扩容?如何扩容
散列表的载荷因子定义为:填入表中的元素个数/散列表的长度,对于开放定址法,应严格限制在0.7-0.8以下。
载荷因子越大,冲突概率越大,空间利用率越高;
载荷因子越小,冲突概率越小,空间利用率越低(浪费越多)
哈希表不能满了再扩容,载荷因子到一定程度就得扩容。
bool Insert(const pair<K, V>& kv)
{
if(_n*10 /_table.size() >= 7)
{
size_t newSize = _table.size() * 2;
HashTable<K, V> newHT;
newHT._table.resize(newSize);
for(size_t i = 0; i < _table.size(); ++i)
{
if(_table[i].state == EXIST)
{
newHT.Insert(_table[i]._kv);
}
}
_table.swap(newHT._table);
}
//线性探测
}
扩容后映射关系变了,不能直接复制过去,要重新映射。
线性探测的优点:实现很简单
线性探测的缺点:一旦发生冲突,所有的冲突连在一起,容易产生数据堆积,不同关键码占据了可利用的空位置,使得寻找某key的位置需要许多次比较,导致搜索效率降低。
2. 二次探测
二次探测使用二次函数生成探测序列,能够减少初级聚集,但可能产生次级聚集(secondary clustering),即具有相同初始哈希值的键会有相同的探测序列。
二次探测不一定能覆盖所有槽位,计算的位置间隔开了,没有那么集中。
当哈希表的长度为质数且载荷因子不超过 0.5 时,一定能插入,但是一旦超过0.5,必须要考虑扩容。
闭散列最大的缺陷就是:空间利用率低,这也是哈希的缺陷。
2.2.2 开散列
开散列也叫链地址法,首先对key集合用散列函数计算出散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表连接起来,各链表的头节点存储在哈希表中。
哈希表索引 链表(桶)
[0] -> 14 -> 21 -> null
[1] -> null
[2] -> 9 -> 16 -> null
[3] -> 3 -> 10 -> 17 -> null
[4] -> 4 -> 11 -> null
[5] -> null
[6] -> 6 -> 13 -> 20 -> null
h(key) = key % 7
开散列增容:极端情况下,一个桶中节点非常多,这时哈希表跟链表一样了,因此在一定条件下要对哈希表进行扩容,开散列最好的情况是:每个哈希桶中刚好挂一个节点,再插入元素时,每一次都会发生哈希冲突,所以载荷因子一般控制在1,平均下来每个桶一个数据。
开散列优点:
特性 | 开散列(链地址法) | 闭散列(开放寻址法) |
---|---|---|
装载因子限制 | 无严格限制 | 通常需保持<0.7 |
删除操作 | 简单直接 | 需要特殊标记 |
内存使用 | 动态分配 | 预先分配固定大小 |
聚集问题 | 无 | 有 |
插入操作 | 直接添加到链表尾部 | 复杂探测 |
无闲置空间浪费:所有位置都可以存储数据,不像开放寻址法需要保持一定空闲位置,所以链地址法比开地址法更省空间。
开散列特别适合处理大数据集和频繁插入删除的场景,是大多数标准库(如Java的HashMap、C++的unordered_map)的实现选择。
特性 | C++ std::unordered_map | Java HashMap |
---|---|---|
冲突处理结构 | 纯链表(单向或双向) | 链表过长时转红黑树(JDK8+) |
时间复杂度(最坏) | O(n) | O(logn)(树化后) |
扩容策略 | 自动重新哈希 | 自动重新哈希 + 树化 |
标准是否强制实现树化? | 否 | 是(JDK8+) |
3. 位图
面试题:给40亿个不重复的无符号整数,没有排序,给一个无符号整数,如何快速判断一个数是否在这40亿个数中?
我们肯定会想到排序+二分查找,但是这其实是不行的,因为数据量太庞大了,40亿个int:
4×10^9 × 4B/int = 16×10^9 B = 16 GB
内存不可能会给开16G的内存,我的电脑现在一共才16G内存,多一点的32G.
这时就需要用到位图:
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。在或不在刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。
数字: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
位图值: 1 0 0 0 0 1 0 0 1 0 1 0 0 1 0 0
原来是40亿个 4 比特位,现在是40亿个 1 比特位,大概需要2^32个bit就能存下40亿个int的状态。现在只需要0.5G内存就能实现。
2^32 bits = 4,294,967,296 bits
4,294,967,296 bits / 8 = 536,870,912 Bytes = 2^29 bytes
536,870,912 Bytes / 1024 = 524,288 KB
524,288 KB / 1024 = 512 MB
这种方法也被称为直接定址法。
在插入一个数据时,比如 x = 80,要把它在位图中对应的位置为1:
首先要知道它的位置:i = x / 32,表示在第几个整形上面,一个整形是32个bit,j = x % 32,表示在整形的第几个位上面,那怎么把那个位置置为1呢?
void set(size_t x)
{
size_t i = x/32;
size_t j = x%32;
_bitmap[i] |= (1<<j);
}
让1左移 j 位,左移是向高位移动,不是说向左移动,移位是逻辑操作,左移是指二进制权重增加的方向,和大小端存储无关,因为它不是物理内存操作。右移同理。
第 i 个int 和 左移 j 位的 1 相或,有1为1,所以能置1。
如果要把一个位置置为0:
void reset(size_t x)
{
size_t i = x/32;
size_t j = x%32;
_bitmap[i] &= ~(1<<j);
}
&0:有 0 为 0。
那怎么检查某个位置是 0 还是 1 呢?
bool test(size_t x)
{
size_t i = x/32;
size_t j = x%32;
return _bitmap[i] & (1<<j);
}
&1:都为 1 才为1,有 0 为 0,即那个位如果为 1 结果就为1,就 return true;如果是 0 就return fasle
4. 布隆过滤器
位图一般只能处理整形,如果内容编号是字符串,就无法处理了,将哈希与位图结合,即布隆过滤器,它可以存储任意值。
布隆过滤器的特点是:高效的插入和查询,可以用来告诉你“某样东西一定不存在或可能存在”,它是用多个哈希函数,将一个数据映射到位图中。不仅可以提高查询效率,也可以节省大量空间。
4.1 布隆过滤器的插入:
核心思路是使用多个哈希函数将元素映射到位数组的多个位置,并将这些位置标记为1:
插入流程
-
初始化位数组
布隆过滤器维护一个长度为m
的二进制位数组(初始所有位为0)和k
个不同的哈希函数。 -
计算哈希值
h1(x),h2(x),…,hk(x)(每个值在 [0,m−1] 范围内)
对于待插入的元素x
,用k
个哈希函数分别计算其哈希值,得到k
个位置索引: -
设置位数组
将位数组中这k
个位置的值全部置为1
:
关键点
-
哈希函数独立性:
k
个哈希函数应尽可能独立,减少冲突(如 MurmurHash、SHA 等)。 -
无删除操作:由于多个元素可能共享某些位,删除会导致误判(如需删除需用变体如计数布隆过滤器)。虽然支持删除,但是删除会付出代价。
-
空间效率:仅存储二进制位数组,无需存储元素本身。
比如一个string,要映射多个位,理论上映射的位越多,误判率越低,但是空间占用提高了。
4.2 布隆过滤器的查找
分别计算每个哈希值对应的 bit 位存储的是否是0,只要有一个为0,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说一个元素不存在,该元素一定不存在,如果它说该元素存在时,那该元素可能不存在,因为有些哈希函数存在一定误判。
比如:在布隆过滤器查找“zzy”时,假设3个哈希函数计算的哈希值为1,3,7,刚好和其他元素的bit位重叠,此时布隆过滤器告诉我们元素存在,但实际该元素是不存在的。
4.3 布隆过滤器的删除
一种支持删除的方法:将布隆过滤器的每个bit位扩展成一个小的计数器,插入元素时给k个哈希函数计算出的哈希地址的计数器+1,删除时-1。
5. 海量数据面试题
5.1 位图解决
1. 给定100亿个整数,设计算法找到只出现一次的整数。
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
//00->01
if(!bs1.test(x) && !bs2.test(x))
bs2.set(x);
//01->10
else if(bs1.test(x) && !bs2.test(x))
{
bs1.set(x);
bs2.reset(x);
}
//如果已经是10说明出现两次了,两次以上就不处理了
}
bool is_once(size_t x)
{
return !bs1.test(x) && bs2.test(x);//01
}
private:
bitset<N> bs1{};
bitset<N> bs2{};
};
把数据统计到位图中,然后找is_once是true的情况,输出就可以了。
2.给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
两个文件分别映射到两个位图,对应位置相&,如果结果为1,那么这个值就是交集。
3. 1个文件有100亿int,1G内存,找出出现次数不超过2次的所有整数?
类似1,用两个位图,统计00,01,10,11(两次以上)。
5.2 布隆过滤器解决
1.给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?
为第一个文件(file1)创建一个布隆过滤器。2.将file1的所有query添加到布隆过滤器中。3.遍历file2,用布隆过滤器检查每个query是否可能在file1中
5.3 哈希切分
给一个超过100G大小的log file,log中存着ip地址,怎么找出出现次数最多的IP地址?
1.将原始大文件按照IP地址的哈希值切分成多个小文件,确保相同IP一定会被分到同一个文件中。
2.用map对每个小文件统计IP出现频率,并记录每个文件的最高频IP。
3.合并所有分片的统计结果,找出全局最高频IP。