如何判断一个元素在亿级数据中是否存在?

来源公众号:crossoverJie   本文作者:crossoverJie

分享一篇技术好文,也是一道常见的面试题。相信大家看完也会有所收获

640

前言

最近有朋友问我这么一个面试题目:

现在有一个非常庞大的数据,假设全是 int 类型。现在我给你一个数,你需要告诉我它是否存在其中(尽量高效)。

需求其实很清晰,只是要判断一个数据是否存在即可。

但这里有一个比较重要的前提:非常庞大的数据

常规实现

先不考虑这个条件,我们脑海中出现的第一种方案是什么?

我想大多数想到的都是用 HashMap 来存放数据,因为它的写入查询的效率都比较高。

写入和判断元素是否存在都有对应的 API,所以实现起来也比较简单。

为此我写了一个单测,利用 HashSet 来存数据(底层也是 HashMap );同时为了后面的对比将堆内存写死:

-Xms64m -Xmx64m -XX:+PrintHeapAtGC -XX:+HeapDumpOnOutOfMemoryError 

为了方便调试加入了 GC 日志的打印,以及内存溢出后 Dump 内存。

    @Test	
    public void hashMapTest(){	
        long star = System.currentTimeMillis();	
        Set<Integer> hashset = new HashSet<>(100) ;	
        for (int i = 0; i < 100; i++) {	
            hashset.add(i) ;	
        }	
        Assert.assertTrue(hashset.contains(1));	
        Assert.assertTrue(hashset.contains(2));	
        Assert.assertTrue(hashset.contains(3));	
        long end = System.currentTimeMillis();	
        System.out.println("执行时间:" + (end - star));	
    }

当我只写入 100 条数据时自然是没有问题的。

还是在这个基础上,写入 1000W 数据试试:

640

执行后马上就内存溢出。

640

可见在内存有限的情况下我们不能使用这种方式。

实际情况也是如此;既然要判断一个数据是否存在于集合中,考虑的算法的效率以及准确性肯定是要把数据全部 load 到内存中的。

Bloom Filter

基于上面分析的条件,要实现这个需求最需要解决的是 如何将庞大的数据load到内存中。

而我们是否可以换种思路,因为只是需要判断数据是否存在,也不是需要把数据查询出来,所以完全没有必要将真正的数据存放进去。

伟大的科学家们已经帮我们想到了这样的需求。

BurtonHowardBloom 在 1970 年提出了一个叫做 BloomFilter(中文翻译:布隆过滤)的算法。

它主要就是用于解决判断一个元素是否在一个集合中,但它的优势是只需要占用很小的内存空间以及有着高效的查询效率。

所以在这个场景下在合适不过了。

Bloom Filter 原理

下面来分析下它的实现原理。

官方的说法是:它是一个保存了很长的二级制向量,同时结合 Hash 函数实现的。

听起来比较绕,但是通过一个图就比较容易理解了。

640

如图所示:

  • 首先需要初始化一个二进制的数组,长度设为 L(图中为 8),同时初始值全为 0 。

  • 当写入一个 A1=1000 的数据时,需要进行 H 次 hash 函数的运算(这里为 2 次);与 HashMap 有点类似,通过算出的 HashCode 与 L 取模后定位到 0、2 处,将该处的值设为 1。

  • A2=2000 也是同理计算后将 47 位置设为 1。

  • 当有一个 B1=1000 需要判断是否存在时,也是做两次 Hash 运算,定位到 0、2 处,此时他们的值都为 1 ,所以认为 B1=1000 存在于集合中。

  • 当有一个 B2=3000 时,也是同理。第一次 Hash 定位到 index=4 时,数组中的值为 1,所以再进行第二次 Hash 运算,结果定位到 index=5 的值为 0,所以认为 B2=3000 不存在于集合中。

整个的写入、查询的流程就是这样,汇总起来就是:

对写入的数据做 H 次 hash 运算定位到数组中的位置,同时将数据改为 1 。当有数据查询时也是同样的方式定位到数组中。 一旦其中的有一位为 0 则认为数据肯定不存在于集合,否则数据可能存在于集合中

所以布隆过滤有以下几个特点:

  1. 只要返回数据不存在,则肯定不存在。

  2. 返回数据存在,但只能是大概率存在。

  3. 同时不能清除其中的数据。

第一点应该都能理解,重点解释下 2、3 点。

为什么返回存在的数据却是可能存在呢,这其实也和 HashMap 类似。

在有限的数组长度中存放大量的数据,即便是再完美的 Hash 算法也会有冲突,所以有可能两个完全不同的 AB 两个数据最后定位到的位置是一模一样的。

这时拿 B 进行查询时那自然就是误报了。

删除数据也是同理,当我把 B 的数据删除时,其实也相当于是把 A 的数据删掉了,这样也会造成后续的误报。

基于以上的 Hash 冲突的前提,所以 BloomFilter 有一定的误报率,这个误报率和 Hash 算法的次数 H,以及数组长度 L 都是有关的。

自己实现一个布隆过滤

算法其实很简单不难理解,于是利用 Java 实现了一个简单的雏形。

public class BloomFilters {	
    /**	
     * 数组长度	
     */	
    private int arraySize;	
    /**	
     * 数组	
     */	
    private int[] array;	
    public BloomFilters(int arraySize) {	
        this.arraySize = arraySize;	
        array = new int[arraySize];	
    }	
    /**	
     * 写入数据	
     * @param key	
     */	
    public void add(String key) {	
        int first = hashcode_1(key);	
        int second = hashcode_2(key);	
        int third = hashcode_3(key);	
        array[first % arraySize] = 1;	
        array[second % arraySize] = 1;	
        array[third % arraySize] = 1;	
    }	
    /**	
     * 判断数据是否存在	
     * @param key	
     * @return	
     */	
    public boolean check(String key) {	
        int first = hashcode_1(key);	
        int second = hashcode_2(key);	
        int third = hashcode_3(key);	
        int firstIndex = array[first % arraySize];	
        if (firstIndex == 0) {	
            return false;	
        }	
        int secondIndex = array[second % arraySize];	
        if (secondIndex == 0) {	
            return false;	
        }	
        int thirdIndex = array[third % arraySize];	
        if (thirdIndex == 0) {	
            return false;	
        }	
        return true;	
    }	
    /**	
     * hash 算法1	
     * @param key	
     * @return	
     */	
    private int hashcode_1(String key) {	
        int hash = 0;	
        int i;	
        for (i = 0; i < key.length(); ++i) {	
            hash = 33 * hash + key.charAt(i);	
        }	
        return Math.abs(hash);	
    }	
    /**	
     * hash 算法2	
     * @param data	
     * @return	
     */	
    private int hashcode_2(String data) {	
        final int p = 16777619;	
        int hash = (int) 2166136261L;	
        for (int i = 0; i < data.length(); i++) {	
            hash = (hash ^ data.charAt(i)) * p;	
        }	
        hash += hash << 13;	
        hash ^= hash >> 7;	
        hash += hash << 3;	
        hash ^= hash >> 17;	
        hash += hash << 5;	
        return Math.abs(hash);	
    }	
    /**	
     *  hash 算法3	
     * @param key	
     * @return	
     */	
    private int hashcode_3(String key) {	
        int hash, i;	
        for (hash = 0, i = 0; i < key.length(); ++i) {	
            hash += key.charAt(i);	
            hash += (hash << 10);	
            hash ^= (hash >> 6);	
        }	
        hash += (hash << 3);	
        hash ^= (hash >> 11);	
        hash += (hash << 15);	
        return Math.abs(hash);	
    }	
}
  1. 首先初始化了一个 int 数组。

  2. 写入数据的时候进行三次 hash 运算,同时把对应的位置置为 1。

  3. 查询时同样的三次 hash 运算,取到对应的值,一旦值为 0 ,则认为数据不存在。

实现逻辑其实就和上文描述的一样。

下面来测试一下,同样的参数:

-Xms64m -Xmx64m -XX:+PrintHeapAtGC
    @Test	
    public void bloomFilterTest(){	
        long star = System.currentTimeMillis();	
        BloomFilters bloomFilters = new BloomFilters(10000000) ;	
        for (int i = 0; i < 10000000; i++) {	
            bloomFilters.add(i + "") ;	
        }	
        Assert.assertTrue(bloomFilters.check(1+""));	
        Assert.assertTrue(bloomFilters.check(2+""));	
        Assert.assertTrue(bloomFilters.check(3+""));	
        Assert.assertTrue(bloomFilters.check(999999+""));	
        Assert.assertFalse(bloomFilters.check(400230340+""));	
        long end = System.currentTimeMillis();	
        System.out.println("执行时间:" + (end - star));	
    }

执行结果如下:

640

只花了 3 秒钟就写入了 1000W 的数据同时做出来准确的判断。


640

当让我把数组长度缩小到了 100W 时就出现了一个误报, 400230340 这个数明明没在集合里,却返回了存在。

这也体现了 BloomFilter 的误报率。

我们提高数组长度以及 hash 计算次数可以降低误报率,但相应的 CPU、内存的消耗就会提高;这就需要根据业务需要自行权衡。

Guava 实现

640

刚才的方式虽然实现了功能,也满足了大量数据。但其实观察 GC 日志非常频繁,同时老年代也使用了 90%,接近崩溃的边缘。

总的来说就是内存利用率做的不好。

其实 Google Guava 库中也实现了该算法,下面来看看业界权威的实现。

-Xms64m -Xmx64m -XX:+PrintHeapAtGC 

    @Test	
    public void guavaTest() {	
        long star = System.currentTimeMillis();	
        BloomFilter<Integer> filter = BloomFilter.create(	
                Funnels.integerFunnel(),	
                10000000,	
                0.01);	
        for (int i = 0; i < 10000000; i++) {	
            filter.put(i);	
        }	
        Assert.assertTrue(filter.mightContain(1));	
        Assert.assertTrue(filter.mightContain(2));	
        Assert.assertTrue(filter.mightContain(3));	
        Assert.assertFalse(filter.mightContain(10000000));	
        long end = System.currentTimeMillis();	
        System.out.println("执行时间:" + (end - star));	
    }

也是同样写入了 1000W 的数据,执行没有问题。

640

观察 GC 日志会发现没有一次 fullGC,同时老年代的使用率很低。和刚才的一对比这里明显的要好上很多,也可以写入更多的数据。

源码分析

那就来看看 Guava 它是如何实现的。

构造方法中有两个比较重要的参数,一个是预计存放多少数据,一个是可以接受的误报率。 我这里的测试 demo 分别是 1000W 以及 0.01。

640

Guava 会通过你预计的数量以及误报率帮你计算出你应当会使用的数组大小 numBits 以及需要计算几次 Hash 函数 numHashFunctions

这个算法计算规则可以参考维基百科。

put 写入函数

真正存放数据的 put 函数如下:

640

  • 根据 murmur3_128 方法的到一个 128 位长度的 byte[]

  • 分别取高低 8 位的到两个 hash 值。

  • 再根据初始化时的到的执行 hash 的次数进行 hash 运算。

bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);

其实也是 hash取模拿到 index 后去赋值 1.

重点是 bits.set() 方法。

640

其实 set 方法是 BitArray 中的一个函数, BitArray 就是真正存放数据的底层数据结构。

利用了一个 long[]data 来存放数据。

所以 set() 时候也是对这个 data 做处理。

640

  • 在 set 之前先通过 get() 判断这个数据是否存在于集合中,如果已经存在则直接返回告知客户端写入失败。

  • 接下来就是通过位运算进行 位或赋值

  • get() 方法的计算逻辑和 set 类似,只要判断为 0 就直接返回存在该值。

mightContain 是否存在函数

640

前面几步的逻辑都是类似的,只是调用了刚才的 get() 方法判断元素是否存在而已。

总结

布隆过滤的应用还是蛮多的,比如数据库、爬虫、防缓存击穿等。

特别是需要精确知道某个数据不存在时做点什么事情就非常适合布隆过滤。

这段时间的研究发现算法也挺有意思的,后续应该会继续分享一些类似的内容。

如果对你有帮助那就分享一下吧。

本文的示例代码参考这里:

https://github.com/crossoverJie/JCSprout

你的点赞与分享是对我最大的支持

640?wx_fmt=png

640?


更多推荐内容

聊聊Docker镜像

RPC太太太太太太太容易理解啦!

<think>好的,我现在要解决用户的问题:“如何筛选出两个Series中互相不存在元素?”首先,我需要明确用户的需求。他们可能有两个数据集,想要找出这两个数据集中互相没有的元素。也就是说,在Series A中存在但Series B中不存在元素,以及Series B中存在但Series A中不存在元素。用户可能是在处理数据比对,比如找出两个列表中的差异项。 接下来,我需要回忆pandas中相关的操作方法。记得之前学过集合操作,比如交集、并集、差集。这里用户需要的是两个差集的并集,也就是对称差集。不过,pandas有没有直接的方法呢?或者需要用isin()函数结合布尔索引? 首先,可能需要分别找出在s1中但不在s2中的元素,以及在s2中但不在s1中的元素。然后将这两个结果合并起来。例如,使用s1[~s1.isin(s2)]和s2[~s2.isin(s1)],再将它们连接在一起。 不过,这样可能会重复,或者需要考虑索引的问题。另外,用户是否需要保留索引信息?或者只需要元素的值?比如,如果两个Series有不同的索引,但值相同,是否需要处理? 另外,是否有更简洁的方法?比如使用s1.symmetric_difference(s2),但这个方法是否存在?或者可能需要将Series转换为集合,然后进行对称差集操作。不过,集合会去重,而Series可能包含重复元素,这时候用集合的方法可能不准确。比如,如果s1有重复元素,转换为集合后会被去重,导致结果错误。因此,直接使用集合可能不合适。 所以,正确的做法可能还是使用isin()函数来逐个检查元素是否存在,然后拼接结果。同时,需要保持元素的原始顺序和重复项。比如,如果s1中有两个5,而s2中没有5,那么结果中应该保留这两个5。 另外,用户可能需要处理的是元素别的比较,而不是索引。因此,索引在这里可能不重要,只要元素存在与否。 接下来,我需要考虑如何实现这个功能。步骤大概是: 1. 对s1,筛选出不在s2中的元素。 2. 对s2,筛选出不在s1中的元素。 3. 将这两个结果合并,得到一个包含所有互不存在元素的新Series。 需要注意重复元素的情况,例如,如果s1有多个相同的元素不在s2中,这些都应该保留。而使用集合会合并重复项,所以不能使用集合操作。 此外,如果用户需要的是去重后的结果,可能需要不同的处理,但根据问题描述,应该是保留所有符合条件的元素,包括重复的。 然后,如何用pandas实现这一点?比如,使用布尔索引: s1_unique = s1[~s1.isin(s2)] s2_unique = s2[~s2.isin(s1)] result = pd.concat([s1_unique, s2_unique]) 这样,result就包含了所有在s1但不在s2,以及在s2但不在s1的元素,包括重复项。例如,如果s1有两次5,而s2没有5,那么s1_unique中会有两个5,而result中将包含这两个5。 但这样是否正确?需要测试一下。比如: s1 = pd.Series([1, 2, 3, 3, 4]) s2 = pd.Series([3, 4, 5, 5]) 那么s1_unique应该是[1,2,3,3],因为s2中有3,所以当检查s1中的元素是否在s2中时,3是存在的,所以会被排除?或者是不是这样? 不,s1.isin(s2)返回的是一个布尔Series,其中每个元素表示该元素是否在s2中存在。因此,对于s1中的元素3,如果在s2中存在至少一个3,则isin返回True。所以,s1[~s1.isin(s2)]会排除所有在s2中存在元素,不管重复次数。比如,s1中的3如果在s2中有至少一个3,那么会被排除。这可能不是用户想要的,因为如果s1有两个3,而s2有一个3,那么用户可能希望这两个3都被排除,还是保留一个? 这时候问题就出现了:用户是否希望完全排除在s2中存在元素,不管出现次数?比如,如果s1中有多个3,而s2中有至少一个3,那么所有s1中的3都会被排除,反之亦然。 如果是这样的话,上述方法是对的。但是如果用户想要考虑元素出现的次数,例如,s1中有两个3,s2中有一个3,那么希望s1中剩下的一个3被包含在结果中,这时候就需要不同的处理方式。这种情况下,可能需要逐个元素比较,但这样的复杂度较高,通常用户可能只需要判断元素是否存在,而不是次数。 因此,假设用户的问题是基于元素存在性,而不是出现次数,那么使用isin的方法是合适的。 所以,最终的步骤是: 筛选出s1中不在s2中的元素,和s2中不在s1中的元素,合并两者。 另外,是否需要去重?根据用户的问题描述,“互相不存在元素”可能指的是唯一的元素,即每个元素只出现一次。但原数据中的重复是否需要保留,这需要看用户的具体需求。如果用户希望保留所有出现次数,那么应该保留重复项,否则可能需要去重。 比如,用户给的示例中,s1的元素是[1,2,3,4],s2的元素是[3,4,5],那么结果应该是[1,2,5]。但如果s1是[1,2,2,3],s2是[3,4],那么结果应该是[1,2,2,4]。这时候,用户是否需要保留重复的2? 所以,在回答时需要说明这一点,或者让用户知道这种方法的处理方式。 此外,可能还需要考虑数据类型的问题,例如,如果两个Series中的元素类型不同,比如一个是字符串,一个是数字,那么isin可能会返回False,需要确保比较的元素类型一致。 总结来说,解决步骤是: 1. 使用isin方法找出每个Series中不在另一个中的元素。 2. 合并这两个结果。 3. 可能需要去重,根据用户需求。 在代码实现中,可以用concat将两个部分合并,然后如果需要去重的话,可以使用drop_duplicates(),但根据用户的问题,可能不需要。 最后,给出示例代码,并解释每一步的作用,以及可能需要注意的地方,比如数据类型、重复元素处理、索引保留等。</think>--- ### **筛选两个Series中互不存在元素的步骤** 要筛选出两个Series中互相不存在元素(即仅在其中一个Series中出现的元素),可以通过以下方法实现: --- #### **方法思路** 1. **定义目标** - 找出仅在`s1`中存在但不在`s2`中的元素 - 找出仅在`s2`中存在但不在`s1`中的元素 - 合并上述两部分结果 2. **核心工具** - 使用`pandas.Series.isin()`判断元素是否存在 - 使用布尔索引筛选数据 - 通过`pd.concat()`合并结果 --- ### **完整代码实现** ```python import pandas as pd def find_unique_elements(s1, s2): """ 返回两个Series中互相不存在元素 参数: s1, s2: 输入的pandas Series 返回: 合并后的新Series """ # 找出s1中存在但s2中不存在元素 unique_in_s1 = s1[~s1.isin(s2)] # 找出s2中存在但s1中不存在元素 unique_in_s2 = s2[~s2.isin(s1)] # 合并结果并重置索引(可选) result = pd.concat([unique_in_s1, unique_in_s2], axis=0).reset_index(drop=True) return result ``` --- ### **使用示例** ```python # 创建两个示例Series s1 = pd.Series([1, 2, 3, 4, "苹果"]) s2 = pd.Series([3, 4, 5, 6, "香蕉"]) # 调用函数 result = find_unique_elements(s1, s2) print(result) ``` **输出结果**: ``` 0 1 1 2 2 苹果 3 5 4 6 5 香蕉 dtype: object ``` --- ### **关键解释** 1. **`s1.isin(s2)`的作用** - 返回布尔Series,标记`s1`的每个元素是否存在于`s2`中 - 例如:若`s1`为`[1, 2, 3]`,`s2`为`[3, 4]`,则`s1.isin(s2)`结果为`[False, False, True]` 2. **`~`运算符** - 对布尔值取反,实现“不存在于”的逻辑 - `s1[~s1.isin(s2)]`会筛选出所有在`s1`但不在`s2`中的元素 3. **`pd.concat()`合并结果** - 将两个结果纵向拼接,保留所有符合条件的元素(包括重复值) --- ### **注意事项** 1. **重复值的处理** - 如果输入Series有重复值(如`s1 = [2, 2, 3]`,`s2 = [3]`),结果会保留所有重复值(输出`[2, 2]`) - 若需去重,可对结果添加`.drop_duplicates()` 2. **数据类型一致性** - 确保两个Series的数据类型兼容(例如避免数值与字符串混合比较) 3. **索引处理** - 默认保留原始索引,若需重置索引,可添加`.reset_index(drop=True)` --- ### **扩展场景** - **仅获取唯一值(去重后)** ```python result = pd.concat([unique_in_s1, unique_in_s2]).drop_duplicates() ``` - **统计差异元素数量** ```python print("差异元素总数:", len(result)) ``` --- 通过这种方法,可以高效筛选出两个Series之间的差异元素,并根据需求灵活调整重复值和索引处理。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值