位图
应用场景
同一个网页链接被包含在多个页面中时,会导致爬虫在爬取的过程中,重复爬取相同的网页,如何避免这些重复的爬取呢?
解决方案
记录已经爬取过的URL,爬取新网页前查看是否爬取过
已爬取过则忽略
未爬取过则先爬取后记录
注意点:
查询和添加操作要高效
上亿的网页,消耗内存非常高,存储效率要高
满足上述需求的数据结构有:
散列表
红黑树
跳表
在支持快速查询和插入的同时需要关注内存的消耗。
假设有10亿网页,一条URL平均长度64byte,全部存储起来需要大约60GB的内存。散列表需要保证较低的装填因子,采用链表法则还需要保存指针,实际使用的存储空间可能需要超过100GB。大型搜索引擎会使用分治的思想将这100GB的数据保存在多台服务器上。
分治+散列表的思路可以实现,那么在添加、查询和内存消耗上是否还有优化空间?
注意:时间复杂度只是表示执行时间随数据规模的变化趋势,并不能度量在特定的数据规模下,代码执行时间的多少。在数据量很大的情况下,常数、系数和低阶的优化都能带来非常可观的收益。
性能优化
执行效率
分治+散列表思路中耗时的点在于:通过哈希函数定位到某个链表之后,还要依次比对每个链表中的 URL。
一方面,链表中的结点在内存中不是连续存储的,不能一下子加载到 CPU 缓存中,没法很好地利用到 CPU 高速缓存,所以数据访问性能方面会打折扣。
另一方面,链表中的每个数据都是 URL,而 URL 不是简单的数字,是平均长度为 64 字节的字符串,要让待判重的 URL,跟链表中的每个 URL,做字符串匹配。这样一个字符串匹配操作,比起单纯的数字比对,要慢很多。
内存消耗
在内存消耗方面的优化,可以将散列表更换为布隆过滤器。布隆过滤器基于位图,且对位图进行了改进。
问题
如何在数据范围是1~10亿之间的1千万个整数中快速查找某个整数是否存在。
散列表可以解决,位图也可以解决。
解决方案
使用位图来解决。
申请一个大小为 1 亿、数据类型为布尔类型(true 或者 false)的数组
将这 1 千万个整数作为数组下标,将对应的数组值设置成 true,如整数 5 对应下标为 5 的数组值设置为 true(
array[5]=true)查询某个整数 K 是否存在,只要将对应的数组值
array[K]取出来判断是否为true如果等于 true,说明存在整数 K
如果不等于true,说明不存在整数 K
注意:很多编程语言中提供的布尔类型,大小是 1 byte,并不能节省太多内存空间。实际上,表示 true 和 false 两个值,只需要用一个二进制位(bit)就可以。
如何通过编程语言,来表示一个二进制位(bit)可以使用位运算。
借助编程语言中提供的数据类型,比如 int、long、char 等类型,通过位运算,用其中的某个位表示某个数字。如下示例:
位图通过数组下标来定位数据,所以访问效率非常高
每个数字用一个二进制位(bit)表示,在数字范围不大的情况下,需要的内存空间非常节省
数据范围是1~10亿之间的1千万数据:
散列表存储:每个数据占32位(4个字节),总共有1千万个数据需要大约40MB
位图存储:每个数据占1位,数据最大范围1亿需要大约12.5MB
如果数据的范围更大,是1~10亿,数据量还是1千万,那么位图存储的空间是125MB,这就远大于散列表需要的存储空间。
这是就需要布隆过滤器来解决数据范围非常大这个问题了。
布隆过滤器
数据范围是1~10亿之间的1千万数据,使用布隆过滤器的处理策略是:
创建一个 1 亿个二进制大小的位图
通过哈希函数(如
f(x)=x%n,n表示位图大小,即对数字跟位图的大小进行取模求余),对数字进行处理,让它落在这 1 到 1 亿范围内
注意:哈希会存在冲突,如一亿零一和 1 两个数字最终都存储在1这个位置。为了降低这种冲突概率,可以设计一个复杂点、随机点的哈希函数。
布隆过滤器的处理方法是:一个哈希函数可能会存在冲突,用多个哈希函数一块儿定位一个数据,来降低冲突的概率。
使用 K 个哈希函数,对同一个数字进行求哈希值,那会得到 K 个不同的哈希值,分别记作
X1,X2,X3,…,XK把这 K 个数字作为位图中的下标,将对应的
BitMap[X1],BitMap[X2],BitMap[X3],…,BitMap[XK]都设置成 true,用 K 个二进制位,来表示一个数字的存在查询某个数字是否存在的时候,用同样的 K 个哈希函数,对这个数字求哈希值,分别得到
Y1,Y2,Y3,…,YK看这 K 个哈希值,对应位图中的数值是否都为 true
如果都是 true,则说明这个数字存在
如果其中任意一个不为 true,说明这个数字不存在

对于两个不同的数字来说,经过一个哈希函数处理,可能会产生相同的哈希值。但是经过 K 个哈希函数处理之后,K 个哈希值都相同的概率就非常低了。
尽管采用 K 个哈希函数之后,两个数字哈希冲突的概率降低了,但是,这种处理方式又带来了新的问题,那就是容易误判。
bloom filter: False is always false. True is maybe true.

布隆过滤器只会对存在有误判:
如果某个数字经过布隆过滤器判断不存在,那这个数字真的不存在,不会误判
如果某个数字经过布隆过滤器判断存在,这时有可能误判,可能数字并不存在
只要调整哈希函数的个数、位图大小跟要存储数字的个数之间的比例,就可以将这种误判的概率降到非常低。
布隆过滤器的误判率,主要跟哈希函数的个数、位图的大小有关。
往布隆过滤器中不停地加入数据之后,位图中不是 true 的位置就越来越少了,误判率就越来越高。所以,对于无法事先知道要判重的数据个数的情况,需要支持自动扩容的功能
当布隆过滤器中,数据个数与位图大小的比例超过某个阈值,就重新申请一个新的位图。后面来的新数据,会被放置到新的位图中
如果要判断某个数据是否在布隆过滤器中已经存在,就需要查看多个位图,相应的执行效率就降低了一些
一般不用来删除,如果非要支持删除,可以再弄个数据结构记录删除的数据。
应用
尽管布隆过滤器会存在误判,但是,这并不影响它发挥大作用。很多场景对误判有一定的容忍度。
比如要解决的爬虫判重这个问题,即便一个没有被爬取过的网页,被误判为已经被爬取,对于搜索引擎来说,也并不是什么大事情,是可以容忍的,毕竟网页太多了,搜索引擎也不可能 100% 都爬取到。
布隆过滤器非常适合这种不需要 100% 准确的、允许存在小概率误判的大规模判重场景。
比如,统计一个大型网站的每天的 UV(Unique Visitor,独立访客) 数,就可以使用布隆过滤器,对重复访问的用户进行去重。
存储优化
使用布隆过滤器解决爬虫去重问题:
用布隆过滤器来记录已经爬取过的网页链接
假设需要判重的网页有 10 亿,那可以用一个 10 倍大小的位图(100 亿个二进制位,约 1.2GB)来存储
用散列表判重,需要至少 100GB 的空间
相比来讲,布隆过滤器在存储空间的消耗上,降低了非常多
执行优化
布隆过滤器用多个哈希函数对同一个网页链接进行处理
CPU 只需要将网页链接从内存中读取一次,进行多次哈希计算,理论上讲这组操作是 CPU 密集型的
在散列表的处理方式中,需要读取散列值相同(散列冲突)的多个网页链接,分别跟待判重的网页链接,进行字符串匹配。这个操作涉及很多内存数据的读取,所以是内存密集型的
CPU 计算要比内存访问更快速,所以,理论上布隆过滤器的判重方式,更加快速
工业实现
Java 中的 BitSet 类是一个位图
Redis 提供了 BitMap 位图类
Google 的 Guava 工具包提供了 BloomFilter 布隆过滤器的实现
常见二进制操作
位操作符
基础操作
复杂操作
常考考点
只出现一次的数
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
说明:算法应该具有线性时间复杂度,且不使用额外空间来实现。
思路
使用异或运算:
两个相同的数异或后为0
一个数与0异或是它自身
示例代码
只出现一次的数【变形1】
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现了三次。找出那个只出现了一次的元素。
说明:算法应该具有线性时间复杂度,且不使用额外空间来实现。
思路
因为重复的元素出现了三次,不重复的元素只要一个出现一次。
统计整个数组中,每一位中1出现的次数
每一位都对3取余数,这个余数就是唯一的那个元素在该位上是否为1(只可能是0或1)
知道了每一位是1还是0后,还原所在的位值,组合在一起就是这个结果
注意:判断数组中数据的位数时,int类型在32和64位操作系统中长度不同,现在一般都是64位操作系统了。
示例代码
只出现一次的数【变形2】
给定一个整数数组,其中恰好有两个元素只出现一次,其余所有元素均出现两次。 找出只出现一次的那两个元素。
说明:算法应该具有线性时间复杂度,且不使用额外空间来实现。
思路
两个相同的数异或后为0,一个数与0异或还是它自身。因为重复的元素都出现两次,异或后就变成了0。
将所有元素依次异或,等到的结果是a^b
将a^b拆分为a和b,就是结果
因为异或的结果是,两个为都相同就是0,两个为不同就是1,a^b这个结果中的1表示是a和b不同的位置。
找到a和b中第一个不同的位置之后,将这个值与每一个元素进行与运算(将元素区分为该位与a相同和该位与b相同),这两组中分别包含了重复的元素以及a或b
因为相同的元素和自身异或后为0,将a^b的结果与这两组元素分别异或,就能得到a和b分别是多少
因为在异或的过程中重复的元素已经被0化了,就变成
diff := a^b,a ^= diff,b ^= diff,只不过这里的a和b值相互交换了一下。
小结一下用到的位运算:
异或(该位相同为0,该位不同为1)
相同元素异或为0
获取元素最后一个1的位置
取出元素中某一位的值
通过异或交换两个数
示例代码
求位为1的个数
编写一个函数,输入是一个无符号整数,返回其二进制表达式中数字位数为 ‘1’ 的个数(也被称为汉明重量)。
思路
两种方法:
通过右移运算不断取出每一位,累计1的个数
通过
n&(n-1)不断移除最后一个1,累计1的个数
示例代码
比特位计数
给定一个非负整数 num。对于 0 ≤ i ≤ num 范围中的每个数字 i ,计算其二进制数中的 1 的数目并将它们作为数组返回。
思路
也就是计算从0到num之间,这些数的累计1的个数。
两种方法:
上一题已经求出一个数中累计1的个数,用数组将结果保存起来(注意,当前结果是前面结果的和)
动态规划:当前这个数中1的个数比上一个缺1的元素多1
示例代码
颠倒二进制位
颠倒给定的 32 位无符号整数的二进制位。
思路
把最右边的一位取出,左移对应的位数,加到结果中。
示例代码
数字范围按位与
给定范围 [m, n],其中 0 <= m <= n <= 2147483647,返回此范围内所有数字的按位与(包含 m, n 两端点)。
思路
因为是按位与,从m到n的这些数字中,只要有一个数的某一位是0,那么这一位就是0,最终结果就是这一段范围内全是1的那一位,对应的数字。
示例代码
最后更新于