面试被问,一千万个整数里面快速查找某个整数,你会怎么去做?

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
简介: 面试被问,一千万个整数里面快速查找某个整数,你会怎么去做?

最近小林在求职面试中被询问了这么一个有趣的面试题:


假设当我们需要在一千万个整数(整数的范围在1-1亿之间)里面快速查找某个整数是否存在于其中的话,如何快速查找进行判断会比较方便呢?


ps: int 类型的数据存储的时候是会占用四个字节空间,假设存储的数据范围在1-1亿之间,那么数据存储的时候大概占用空间为:100 * 100 * 1000 * 4 byte 大概存储空间大小消耗为 :40000 kb 40mb左右。


1.散列表结构方案


通过散列表来实现该功能当然是可以的,但是散列表里面除了需要存储对应的数字以外,还需要存储对应的链表指针,每个数字都采用int类型来进行存储的话,光是数字占用的内存大小大概是40mb左右,如果是加上了指针的存储空间的话,那么存储的内存大小大概是80mb左右了。


2.布尔数组结构方案


通过使用布尔类型的数组来进行存储。首先创建一个一千万长度的布尔类型数组,然后根据整数,定位到相应的下标赋值true,那么以后在遍历的时候,根据相应的下标查找到指定的变量,如果该变量的值是true的话,那么就证明该数字存在。


布尔类型变量在存储的时候只有true和false存在,但是在数组中存储的时候实际上是占用了1个字节,该类型的变量在被编译之后实际上是以int类型的数据0000 0000和0000 0001存储的,因此还是占用了较多的内存空间。


3.使用bitmap来进行存储


例如说一个长度是10的bitmap,每一个bit位都存储着对应的0到9的十个整形数字,此时每个bitmap所对应的位都是0。当我们存入的数字是3的时候,位于3的位置会变为1。


image.png


那么这个时候你可能就会疑惑了,到底在代码上边该如何通过计算来获取一个数字的索引定位呢?


首先我们将核心思路的代码先贴上来:


public void set(int number) {
        //相当于对一个数字进行右移动3位,相当于除以8
        int index = number >> 3;
        //相当于 number % 8 获取到byte[index]的位置
        int position = number & 0x07;
        //进行|或运算  参加运算的两个对象只要有一个为1,其值为1。
        bytes[index] |= 1 << position;
    }


为了方便理解核心思想,所以还是通过图例来理解会比较好:


例如说往bitmap里面存储一个数字11,那么首先需要通过向右移位(因为一个byte相当于8个bit),计算出所存储的byte[]数组的索引定位,这里计算出index是1。由于一个byte里面存储了八个bit位,所以通过求余的运算来计算postion,算出来为3。


这里假设原有的bitmap里面存储了4和12这2个数字,那么它的结构如下所示:


image.png


同理,当我们判断数字是否存在的时候,也需要进行相应的判断,代码如下:


public boolean contain(int number) {
        int index = number >> 3;
        int position = number & 0x07; 
        return (bytes[index] & (1<<position)) !=0;
    }


image.png


整合一下,简单版的一个bitmap代码如下:

public class MyBitMap {
    private byte[] bytes;
    private int initSize;
    public MyBitMap(int size) {
        if (size <= 0) {
            return;
        }
        initSize = size / (8) + 1;
        bytes = new byte[initSize];
    }
    public void set(int number) {
        //相当于对一个数字进行右移动3位,相当于除以8
        int index = number >> 3;
        //相当于 number % 8 获取到byte[index]的位置
        int position = number & 0x07;
        //进行|或运算  参加运算的两个对象只要有一个为1,其值为1。
        bytes[index] |= 1 << position;
    }
    public boolean contain(int number) {
        int index = number >> 3;
        int position = number & 0x07;
        return (bytes[index] & (1 << position)) != 0;
    }
    public static void main(String[] args) {
        MyBitMap myBitMap = new MyBitMap(32);
        myBitMap.set(30);
        myBitMap.set(13);
        myBitMap.set(24);
        System.out.println(myBitMap.contain(2));
    }
}


从刚刚位图结构的讲解中,你应该可以发现,位图通过数组下标来定位数据,所以,访问效率非常高。而且,每个数字用一个二进制位来表示,在数字范围不大的情况下,所需要的内存空间非常节省。


比如刚刚那个例子,如果用散列表存储这 1 千万的数据,数据是 32 位的整型数,也就是需要 4 个字节的存储空间,那总共至少需要 40MB 的存储空间。如果我们通过位图的话,数字范围在 1 到 1 亿之间,只需要 1 亿个二进制位,也就是 12MB 左右的存储空间就够了。


但是实际应用中,却并非如我们所想象的那么简单,假设我们的实际场景进行改变一下:

还是刚刚的那个情况:


还是一千万个数字,但是数字范围不是 1 到 1 亿,而是 1 到 10 亿,那位图的大小就是 10 亿个二进制位,也就是 120MB 的大小,消耗的内存空间反而更加大了,而且在bitmap里面还会有部分空间浪费的情况存在。


假设我们对每一个数字都进行一次hash计算,然后通过hash将计算后的结果范围限制在1千万里面,那么就不需要再定义10亿个二进制位了。


但是这样子还是会有相应的弊端,例如说hash冲突。那么这个时候如果我们采用多个hash函数来进行处理的话,理论上是可以大大降低冲突的概率的。于是就有了下边所说的布隆过滤器一说。



布隆过滤器


image.png


布隆过滤器通过使用多次的hash计算来进行数值是否存在的判断,虽然大大降低了hash冲突的情况,但是还是存在一定的缺陷,那就是容易会有误判的情况。例如说如下如所示:


image.png


布隆过滤器的误判有一个特点,那就是,它只会对存在的情况有误判。如果某个数字经过布隆过滤器判断不存在,那说明这个数字真的不存在,不会发生误判;如果某个数字经过布隆过滤器判断存在,这个时候才会有可能误判,有可能并不存在。


不过,只要我们调整哈希函数的个数、位图大小跟要存储数字的个数之间的比例,那就可以将这种误判的概率降到非常低。


那么该怎么调整这个位图大小和存储数字之间的比例呢?


这里可能就需要用到一些数学公式来进行计算了。


在位数组长度m的BF中插入一个元素,相应的位可能会被标志为1。所以说,在插入元素后,该比特仍然为0的概率是:


image.png


当n比较大时,在查询了相关资料之后,可以近似得出误判率的公式大致如下:(本人数学不是太好,这段公式是请教其他大佬得出的)


image.png


所以,在哈希函数的个数k一定的情况下:


  • 位数组长度m越大,误判率越低;


  • 已插入元素的个数n越大,误判率越高。


尽管说布隆过滤器在使用的时候会有误判的情况发生,但是在对于数据的准确性有一定容忍度的情况下,使用布隆过滤器还是会比较推荐的。


在实际的项目应用中,布隆过滤器经常会被用在一些大规模去重,但又允许有小概率误差的场景中,例如说我们对一组爬虫网页地址的去重操作,或者统计某些大型网站每天的用户访问数量(需要对相同用户的多次访问进行去重)。


实际上,关于bitmap和布隆过滤器这类工具在大型互联网企业上已经受到了广泛使用,例如说java里面提供了BitSet类,Redis也提供了相应的位图类,Google里面的guava工具包中的BloomFilter也已经实现类布隆过滤器,所以在实际应用的时候只需要直接使用这些现有的组件即可,避免重复造轮子的情况发生。


4.Redis提供的BitMap


在redis里有一个叫做bitmap的数据结构,使用技巧如下:


setbit指令


语法:setbit key offset value
127.0.0.1:6379> setbit bitmap-01 999 0
(integer) 0
127.0.0.1:6379> setbit bitmap-01 999 1
(integer) 0
127.0.0.1:6379> setbit bitmap-01 1003 1
(integer) 0
127.0.0.1:6379> setbit bitmap-01 1003 0
(integer) 1
127.0.0.1:6379> setbit bitmap-01 1004 0
(integer) 0



注意:


1.offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。


2.因为 Redis 字符串的大小被限制在 512 兆(megabytes)以内, 所以用户能够使用的最大偏移量为 2^29-1(536870911) , 如果你需要使用比这更大的空间, 请使用多个 key。



3.当生成一个很长的字符串时, Redis 需要分配内存空间, 该操作有时候可能会造成服务器阻塞(block)。在2010年出产的Macbook Pro上, 设置偏移量为 536870911(512MB 内存分配)将耗费约 300 毫秒, 设置偏移量为 134217728(128MB 内存分配)将耗费约 80 毫秒, 设置偏移量 33554432(32MB 内存分配)将耗费约 30 毫秒, 设置偏移量为 8388608(8MB 内存分配)将耗费约 8 毫秒。


getbit指令


语法:getbit key offset


返回值:


127.0.0.1:6379> setbit bm 0 1
(integer) 0
127.0.0.1:6379> getbit bm 0
(integer) 1


bitcount指令


语法:bitcount key [start] [end]  ,这里的start和end值为可选项


返回值:被设置为 1 的位的数量



127.0.0.1:6379> bitcount user18 
(integer) 4


bitop指令


语法:bitop operation destkey key [key ...]


operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种:


BITOP AND destkey key [key ...] ,对一个或多个 key 求逻辑并,并将结果保存到 destkey 。
BITOP OR destkey key [key ...] ,对一个或多个 key 求逻辑或,并将结果保存到 destkey 。
BITOP XOR destkey key [key ...] ,对一个或多个 key 求逻辑异或,并将结果保存到 destkey 。
BITOP NOT destkey key ,对给定 key 求逻辑非,并将结果保存到 destkey 。


实战案例


统计某个用户在xx论坛的签到次数,这里假设用户的id为u18


127.0.0.1:6379> setbit user18 1 1
(integer) 0
127.0.0.1:6379> setbit user18 2 1
(integer) 0
127.0.0.1:6379> setbit user18 5 1
(integer) 0
127.0.0.1:6379> setbit user18 15 1
(integer) 0
127.0.0.1:6379> bitcount user18 
(integer) 4


通过使用bitcount指令可以快速从redis中计算出user18的签到天数。


( ps:但是如果要计算出连续签到的天数,或者记录更多的签到信息,并且考虑上数据存储的可靠稳定性,那么此时bitmap就不太适用了,这里我只是模拟了一个案例供大家学习这条指令的使用。)


按天统计网站活跃用户


设计思路,用天来作为统计的key,然后以用户ID作为Offset,一旦用户登录访问网站,则根据其用户id计算出对应的offset并且将其设置为1。


//2020年10月21日 用户11034访问网站
127.0.0.1:6379> setbit 20201021 11034 1
(integer) 0
//2020年10月21日 用户11089访问网站
127.0.0.1:6379> setbit 20201021 11089 1
(integer) 0
//2020年10月21日 用户11034访问网站
127.0.0.1:6379> setbit 20201022 11034 1
(integer) 0
//2020年10月21日 用户11032访问网站
127.0.0.1:6379> setbit 20201023 11032 1
(integer) 0
//通过使用bitop or 做多个集合的交集运算,计算出2020年10月21日 至2020年10月22日
//内连续登录的用户id,并且将其放入到名为active_user的bitmap中
127.0.0.1:6379> bitop or active_user 20201021 20201022
(integer) 1387
//提取活跃用户信息
127.0.0.1:6379> bitcount active_user
(integer) 2
127.0.0.1:6379>


用户在线状态、在线人数统计


//模拟用户98321访问系统
127.0.0.1:6379> setbit online_member 98321 1
(integer) 0
127.0.0.1:6379> setbit online_member 13284 1
(integer) 0
127.0.0.1:6379> setbit online_member 834521 1
(integer) 0
127.0.0.1:6379> bitcount online_member
(integer) 3
//模拟用户13284离开系统,在线人数减1
127.0.0.1:6379> setbit online_member 13284 0
(integer) 1
127.0.0.1:6379> bitcount online_member
(integer) 2
127.0.0.1:6379>


这段指令案例将会员id作为来offset位置,存入到来bitmap中,通过设置相应到bitmap,当有对应会员登录访问的时候,对应offset位置则置为1,最后通过bitcount来统计该bitmap中有多少个项为1,从而计算出用户在线的人数。


Guava提供的布隆过滤器


对应的依赖:


<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>22.0</version>
</dependency>


引入相关的依赖之后,可以通过编写一个简单的案例来理解guava里面提供的布隆过滤器组件:


packageorg.idea.guava.filter;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
/**
* @author idea
* @date created in 11:05 上午 2020/10/25
*/
public class GuavaBloomFilter {
public static void main(String[] args) {
//创建布隆过滤器时要传入期望处理的元素数量,及最期望的误报的概率。
    BloomFilter<Integer> bloomFilter = BloomFilter.create(
Funnels.integerFunnel(),
100,  //希望存储的元素个数
0.01  //希望误报的概率
);
bloomFilter.put(1);
bloomFilter.put(2);
bloomFilter.put(3);
bloomFilter.put(4);
bloomFilter.put(5);
        //判断过滤器内部是否存在对应的元素
System.out.println(bloomFilter.mightContain(1));
System.out.println(bloomFilter.mightContain(2));
System.out.println(bloomFilter.mightContain(1000));
}
}


这段代码中你可能会疑惑,为什么是mightContain而不是contain呢?

这一点其实和布隆过滤器本身的误判机制有关。


布隆过滤器常用的场景为:

对于一些缓存穿透的场景可以用于做为预防手段。


本身的优点:

存储空间消耗较少。

时间效率高,查询和插入的时间复杂度均为O(h)。(h为hash函数的个数)


缺点:

查询元素是否存在的概率不能保证100%准确,会有部分误判的情况存在。

只能插入和查询元素,不允许进行删除操作。


END


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
9月前
|
存储 Java
LeetCode150道面试经典题--罗马数字转整数(简单)
罗马数字包含以下七种字符: I, V, X, L,C,D 和 M。其按规律组合成一个罗马数字后将其转换成整数并输出。
44 0
|
算法
【面试:基础篇01:整数二分查找】
【面试:基础篇01:整数二分查找】
100 0
|
存储 JavaScript 前端开发
Day01 JS整数是怎么表示的 | 面试打卡365
今天开始每天打卡一道面试题
185 0
|
存储
LeetCode面试系列 第7天:No.13 - 罗马数字转整数
LeetCode面试系列 第7天:No.13 - 罗马数字转整数
76 0
|
前端开发 JavaScript Java
ABAP面试问题 - 不使用加减乘除等操作比较两个整数大小
ABAP面试问题 - 不使用加减乘除等操作比较两个整数大小
114 0
ABAP面试问题 - 不使用加减乘除等操作比较两个整数大小
|
25天前
|
存储 安全 Java
大厂面试题详解:java中有哪些类型的锁
字节跳动大厂面试题详解:java中有哪些类型的锁
51 0
|
2月前
|
Java 程序员
java线程池讲解面试
java线程池讲解面试
62 1
|
10天前
|
Java 调度
Java面试必考题之线程的生命周期,结合源码,透彻讲解!
Java面试必考题之线程的生命周期,结合源码,透彻讲解!
38 1
|
10天前
|
监控 Java 测试技术
面试准备不充分,被Java守护线程干懵了,面试官主打一个东西没用但你得会
面试准备不充分,被Java守护线程干懵了,面试官主打一个东西没用但你得会
19 1
|
10天前
|
Java
Java面试挂在线程创建后续,不要再被八股文误导了!创建线程的方式只有1种
Java面试挂在线程创建后续,不要再被八股文误导了!创建线程的方式只有1种
14 1