9. 哈希****

目录

1. 什么是哈希

2. 哈希表

2.1 哈希冲突

2.2 如何解决哈希冲突

2.2.1 闭散列:开放定址法

1. 线性探测

2. 二次探测

2.2.2 开散列

3. 位图

4. 布隆过滤器

4.1 布隆过滤器的插入:

4.2 布隆过滤器的查找

4.3 布隆过滤器的删除

5. 海量数据面试题

5.1 位图解决

5.2 布隆过滤器解决

5.3 哈希切分


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_mapJava 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

插入流程

  1. 初始化位数组
    布隆过滤器维护一个长度为 m 的二进制位数组(初始所有位为0)和 k 个不同的哈希函数。

  2. 计算哈希值
    对于待插入的元素 x,用 k 个哈希函数分别计算其哈希值,得到 k 个位置索引:

    h1(x),h2(x),…,hk(x)(每个值在 [0,m−1] 范围内)
  3. 设置位数组
    将位数组中这 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。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值