大数据算法

大数据算法

参考:http://blog.csdn.net/hguisu/article/details/7856239
http://www.cnblogs.com/allensun/archive/2011/02/16/1956532.html
程序员代码面试指南-第六章html

1、基本概念

  所谓海量,就是数据量很大,多是TB级别甚至是PB级别,致使没法一次性载入内存或者没法在较短期内处理完成。面对海量数据,咱们想到的最简单方法便是分治法,即分开处理,大而化小,小而治之。咱们也能够想到集群分布式处理。java

2、经常使用数据结构和算法

2.1 Bloom Filter

  即布隆过滤器,它能够用于检索一个元素是否在一个集合中。在垃圾邮件的黑白名单过滤、爬虫(Crawler)的网址判重等中常常被用到。
  Bloom Filter(BF)是一种空间效率很高的随机数据结构,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。它是一个判断元素是否存在集合的快速的几率算法。Bloom Filter有可能会出现错误判断,但不会漏掉判断。也就是Bloom Filter判断元素不在集合,那确定不在。若是判断元素存在集合中,有必定的几率判断错误。即:宁肯错杀三千,毫不放过一个。所以,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter相比其余常见的算法(如hash,折半查找),极大的节省了空间。
  它的优势是空间效率和查询时间都优于通常的算法,缺点是有必定的误识别率和删除困难。
  add和query的时间复杂度都为O(k),与集合中元素的多少无关,这是其余数据结构都不能完成的。
  Bloom-Filter算法的核心思想就是利用多个独立的Hash函数来解决“冲突”。Hash函数将一个元素(好比URL)映射到二进制位数组(位图数组)中的某一位,若是该位已经被置为1,只能说明该元素可能已经存在。由于Hash存在一个冲突(碰撞)的问题,即不一样URL的Hash值有可能相同。为了减小冲突,咱们能够多引入几个独立的hash函数,若是经过其中的一个Hash值咱们得出某元素不在集合中,那么该元素确定不在集合中。只有在全部的Hash函数告诉咱们该元素在集合中时,才能在很大几率上认为该元素存在于集合中。
  原理要点:一是m位的bit数组, 二是k个独立均匀分布的hash函数,三是误判几率p。程序员

  • m bits的bit数组:使用一个m比特的数组来保存信息,每个bit位都初始化为0
  • k个独立均匀分布的hash函数:为了添加一个元素,用k个hash函数将它hash获得bloom filter中k个bit位,将这k个bit位置1(超过m的取余%m)。
  • 误判几率p:为了查询一个元素,即判断它是否在集合中,用k个hash函数将它hash获得k个bit位。若这k bits全为1,则此元素以几率(1-p)在集合中;若其中任一位不为1,则此元素必不在集合中(由于若是在,则在添加时已经把对应的k个bits位置为1)。

  不容许移除元素,由于那样的话会把相应的k个bits位全置为0,而其中颇有可能有其余元素对应的位。所以remove会引发误报,这是绝对不被容许的。而删除元素其实能够经过引入白名单解决。
  当k很大时,设计k个独立的hash function是不现实而且困难的。对于一个输出范围很大的hash function(例如MD5产生的128 bits数),若是不一样bit位的相关性很小,则可把此输出分割为k份。或者可将k个不一样的初始值(例如0,1,2, … ,k-1)结合元素,赋值给一个hash 函数从而产生k个不一样的数。
  当add的元素过多时,即n/m过大时(n是元素数,m是bloom filter的bits数),会致使false positive(误判)太高,此时就须要从新组建filter,但这种状况相对少见。
  若元素总数为n,误判率为p,则:
布隆过滤器的大小:

hash函数的个数:

误判率p与m和n的关系:

举个例子,咱们假设错误率为p=0.01,则此时m大概是n的13倍,k大概是8个。
  这里m与n的单位不一样,m是bit为单位,而n则是以元素个数为单位(准确的说是不一样元素的个数)。一般单个元素的长度都是有不少bit的,因此使用bloom filter内存上一般都是节省的。面试

误判几率的证实和计算
  假设布隆过滤器中的hash function知足独立均匀分布地的假设:每一个元素都等几率地hash到m个bit中的任何一个,与其它元素被hash到哪一个bit无关。那么对某一个bit位来讲,一个输入对象在被k个hash function散列后,这个位置依然为0的几率为:

通过n个输入对象后,这个位置依然未被置1的几率为:

该位置被置1的几率:

那么在检查阶段,若对应某个待query元素的k bits所有置位为1,则可断定其在集合中。所以将某元素误判的几率为:
算法

因为,而且当m很大时趋近于0,因此:
sql

如今计算对于给定的m和n,k为什么值时可使得误判率最低。设误判率为k的函数为:

, 则简化为,两边取对数:

, 两边对k求导:

下面求最值:








代入
\(a^{log_a^N}=N\),并两边取对数获得:
数据库

Bloom-Filter的应用
一、 key-value 加快查询
通常key-value存储系统的values存在硬盘,查询就是件费时的事。将Storage的数据都插入Filter,在Filter中查询都不存在时,那就不须要去Storage查询了。当False Position出现时,只是会致使一次多余的Storage查询。
因为Bloom-Filter所用的空间很是小,全部BF能够常驻内存。这样子的话,对于大部分不存在的元素,咱们只须要访问内存中的Bloom-Filter就能够判断出来了,只有一小部分,咱们须要访问在硬盘上的key-value数据库,从而大大地提升了效率。编程

二、垃圾邮件地址过滤
像网易,QQ这样的公众电子邮件(email)提供商,老是须要过滤来自发送垃圾邮件的人(spamer)的垃圾邮件。数组

一个办法就是记录下那些发垃圾邮件的 email地址。因为那些发送者不停地在注册新的地址,全世界少说也有几十亿个发垃圾邮件的地址,将他们都存起来则须要大量的网络服务器。缓存

若是用哈希表,每存储一亿个 email地址,就须要 1.6GB的内存(用哈希表实现的具体办法是将每个 email地址对应成一个八字节的信息指纹,而后将这些信息指纹存入哈希表,因为哈希表的存储效率通常只有 50%,所以一个 email地址须要占用十六个字节。一亿个地址大约要 1.6GB,即十六亿字节的内存)。所以存贮几十亿个邮件地址可能须要上百 GB的内存。

而Bloom Filter只须要哈希表 1/8到 1/4 的大小就能解决一样的问题。

BloomFilter决不会漏掉任何一个在黑名单中的可疑地址。而至于误判问题,常见的补救办法是在创建一个小的白名单,存储那些可能被误判的邮件地址。

2.2 Hash

  Hash,通常翻译作“散列”,也有直接音译为“哈希”的,就是把一个对象的关键字,经过散列算法,映射到一个固定长度的数组中。当咱们想要找到对应的对象时,只须要根据它的关键字在散列表中查找(再计算一次散列值)。这种转换是一种压缩映射,也就是,散列值的空间一般远小于输入的空间,不一样的输入可能会散列成相同的输出,而不可能从散列值来惟一的肯定输入值。输入叫作关键字,输出叫作散列值或者哈希地址(仅仅是在散列表中的地址)。但一般须要总数据量能够放入内存。
   散列表是具备固定大小的数组,其中,表长(即数组的大小)应该为质数。
   冲突:两个不一样的输入计算出了相同的散列值。
散列函数通常应具有如下几个特色:
运算简单;函数的值域必须在散列表内;尽量减小冲突;不一样输入得到的散列值尽可能均匀分散。

经常使用的散列函数:

  • 直接定址法: 是以数据元素关键字k自己或它的线性函数做为它的哈希地址,即:hash(k)=k 或 hash(k)=a*k+b ; (其中a,b为常数)。此法仅适合于:地址集合的大小 = = 关键字集合的大小,好比以年龄为key,以年龄对应的人数为value
  • 数字分析法:假设关键字集合中的每一个关键字都是由 s 位数字组成 (u1, u2, …, us),分析关键字集中的全体,并从中提取分布均匀的若干位或它们的组合做为地址。它只适合于全部关键字值已知的状况。
  • 折叠法: 将关键字分割成若干部分,而后取它们的叠加和,留下t位做为哈希地址。适用于关键字位数较多,并且关键字中每一位上数字分布大体均匀的状况。
  • 平方取中法: 这是一种经常使用的哈希函数构造方法。这个方法是先取关键字的平方,而后根据可以使用空间的大小,选取平方数是中间几位为哈希地址。
  • 减去法:数据的键值减去一个特定的数值以求得数据存储的位置。
  • 除留余数法:这是一种经常使用的哈希函数构造方法。假设哈希表长为m,p为小于等于m的最大素数,则哈希函数为hash(k)=k % p ,其中%为模p取余运算。

Hash处理冲突方法:

  • 开放定址法:这种方法也称探测散列法,其基本思想是:当关键字key的哈希地址p=hash(key)出现冲突时,以p为基础,产生另外一个哈希地址p1,若是p1仍然冲突,再以p1为基础,产生另外一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。这种方法有一个通用的再散列函数形式:
    Hi=(hash(key)+f(i))% m i=1,2,…,n
      其中hash(key)为关键字key的直接散列地址,m 为表长,f(i)称为增量序列,表示每次再探测试时的地址增量。

这种方法如何查找元素x:
  好比咱们采用线性探测法,地址增量f(i)=2。其实就是按照计算hash位置的方法进行查找。首先计算hash(x),若是这里有元素且不为x,则尝试hash(x)+2;若是这里有元素且不为x,则尝试hash(x)+2+2,一直到查找到的位置为null或者等于元素x。

  • 再散列法:首先构造长度为length1的散列表table1,而后利用hash1(key)计算须要插入的元素的散列值。当散列表快要被插满时(好比达到了必定的装填因子,或者当插入失败时),再构造一个长度为length2=1+2*length1(实际不必定是2倍+1)的散列表table2,并将table1的元素按照hash2(key)散列到新表中,持续这样的过程...。

  • 链地址法:基本思想是散列地址i保存一个单链表的头指针,全部散列值为i的元素都保存在i对应的单链表中,于是查找、插入和删除主要在单链表中进行。链地址法适用于常常进行插入和删除或者冲突比较严重的状况。例如,已知一组关键字(32,40,36,53,16,46,71,27,42,24,49,64),哈希表长度为13,哈希函数为:H(key)= key % 13,则用链地址法处理冲突的结果如图:

    本例的平均查找长度 ASL=(17+24+3*1)=1.5

  • 创建公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一概填入溢出表

Hash的应用
一、哈希对于检测数据对象(例如消息)中的修改颇有用。好的哈希算法使得构造两个相互独立且具备相同哈希的输入不能经过计算方法实现。典型的哈希算法包括MD5 和 SHA-1。
二、海量日志数据分析,好比提取出某日访问百度次数最多的那个IP。

2.3 Bit-Map(位图)

  Bit-map法的基本原理是:使用位数组来表示某些元素是否存在,每个bit位能够标记一个元素对应的Value。
  假设咱们要对0-7内的5个元素(4,7,2,5,3)排序(这里假设这些元素没有重复)。那么咱们就能够采用Bit-map的方法来达到排序的目的。要表示8个数,咱们就只须要8个bit(1Bytes),首先咱们开辟1Byte的空间,将这些空间的全部bit位都置为0,以下图:

而后遍历这5个元素,首先第一个元素是4,那么就把4对应的位置为1,由于是从零开始的,因此要把第五位置为一(以下图):

而后再处理第二个元素7,将第八位置为1,,接着再处理第三个元素,一直到最后处理完全部的元素,将相应的位置为1,这时候的内存的bit位的状态以下:

而后咱们遍历一遍bit区域,将值为1的位的编号输出(2,3,4,5,7),这样就达到了排序的目的。

对于排序,优势是:运算效率高,不需进行比较和移位,时间复杂度是O(n);占用内存少,好比N=10000000;只需占用内存为N/8=1250000Byte=1.25M。
      缺点:数据最好是惆集数据(否则空间浪费很大);数据不可重复(会将重复的数据覆盖掉)

位图的应用
一、判断集合中是否存在重复:
好比:
若集合大小为N,首先扫描一遍集合,找到集合中的最大元素max,而后建立一个长度为max+1的bit数组。接着再次扫描原集合,每遇到一个元素,就将新数组中下标为元素值的位置为1,好比,若是遇到元素5,则将新数组的第6个元素置为1,如此下去,当下次再遇到元素5想置位时,发现新数组的第6个元素已经被置为1了,则这个元素必定重复了。该算法的最坏运算次数2N,但若是可以事先知道集合的最大元素值,则运算次数N。

再好比:已知某个文件内包含一些电话号码,每一个号码为8位数字,统计不一样号码的个数。

8位最多99 999 999,大概须要99m个bit,大概10几m字节的内存便可。 (能够理解为从0-99 999 999的数字,每一个数字对应一个bit位,因此只须要99M个bit=12MB多,这样,就用了小小的12M多左右的内存表示了全部的8位数的电话)

二、判断集合中某个元素是否存在:
好比:给你一个文件,里面包含40亿个非负整数,写一个算法找出该文件中不包含的一个整数, 假设你有1GB内存可用。若是你只有10MB的内存呢?
   32位无符号整数的范围是0~4294967295(即2 x 2147483647+1),所以能够申请一个长度为4294967295的bit数组bitArr,bitArr的每一个位置只能够表示0或者1。8个bit为1B,因此长度为4294967295的数组占用内存:40*10^8bit=0.5GB=500MB。
   而后遍历这40亿个无符号数,例如,遇到7000,就把bitArr[7000]置为1。遍历数字完成后,遍历bitArr,哪一个位置的值为0,哪一个数就不在这40亿个数内。

如今咱们来看若是内存要求是10MB呢?
   咱们能够将全部0~4294967295的数据平均分红64个区间,每一个区间保存67108864个数,好比:第0区间[0~67108863],第1区间[67108864~134217727],第i区间为[67108864i~67108864(i+1)-1]...,实际上咱们并不保存这些数,而是给每个区间设置一个计数器。这样每读入一个数,咱们就在它所在的区间对应的计数器加1。处理结束以后, 咱们找到一个区间,它的计数器值小于区间大小(67108864), 说明了这一段里面必定有数字是文件中所不包含的。而后咱们单独处理这个区间便可。接下来咱们就能够用Bit Map算法了。申请长度为67108864的bitArr,记为bitArr[0..67108863],咱们再遍历一遍数据, 把落在这个区间的数对应的位置1(固然数据要通过处理,即落在区间i的数num,则bitArr[num-67108864*i]=1)。 最后咱们找到这个区间中第一个为0的位,其对应的数就是一个没有出如今该文件中的数。

再好比:
2.5亿个整数中找出不重复的整数的个数,内存空间不足以容纳这2.5亿个整数。
将bit-map扩展一下,用2bit表示一个数便可,00表示未出现,01表示出现一次,10表示出现2次及以上,在遍历这些数的时候,若是对应位置的值是00,则将其置为01;若是是01,将其置为10;若是是10,则保持不变。或者咱们不用2bit来进行表示,咱们用两个bit-map便可模拟实现这个2bit-map,都是同样的道理。

2.4 堆(Heap)

   实现能够看排序算法
   概念:堆是一种特殊的二叉树,具有如下两种性质
1)每一个节点的值都大于等于(或者都小于等于,称为小顶堆或最小堆)其子节点的值,称为大顶堆(或最大堆)。
2)树是彻底二叉树,而且最后一层的树叶都在最左边

一个典型的堆结构:

   插入和删除的时间复杂度O(logn)
适用范围
   海量数据前n大(最小堆,不用最大堆是为了保证咱们只操做堆顶元素)或者前n小(最大堆)或者中位数(双堆,一个最大堆与一个最小堆结合),而且n比较小,堆能够放入内存。

   好比海量数据求前n小,利用大顶堆,咱们比较当前元素与最大堆里的最大元素(即堆顶元素),若是它小于最大元素,则应该替换那个最大元素,并调整堆的结构,持续这一过程,最后获得的n个元素就是最小的n个,这样能够扫描一遍便可获得全部的前n小元素,效率很高,具体算法:找到无序数组中最小的k个数
   好比海量数据求前n大,利用小顶堆,咱们比较当前元素与最小堆里的最小元素(即堆顶元素),若是它大于最小元素,则应该替换那个最小元素,并调整堆的结构,持续这一过程,最后获得的n个元素就是最大的n个。
   双堆求中位数:
一、建立两个堆(一个大顶堆、一个小顶堆),并设置两个变量分别记录两个堆的元素的个数;
二、假定变量mid用来保存中位数,取第一个元素,赋值给mid,做为初始的中位数;
三、依次遍历后面的每个数据,若是比mid小,则插入大顶堆;不然插入小顶堆,并调整堆的结构;
四、若是大顶堆和小顶堆上的数据之差的绝对值为2,则将mid插入到元素个数较少的堆中,而后从元素个数较多的堆中删除根节点,并将根节点赋值给mid;
五、重复步骤3和4,直到全部的数据遍历结束;

  此时,mid保存了一个数,再加上两个堆中保存的数,就构成了给定数据的集合。
  若是两个堆中元素个数相等,则mid即为最终的中位数;不然,元素较多的堆的根节点元素与mid的和求平均值,即为最终的中位数。时间复杂度:nlog(n)

2.5 双层桶划分

  双层桶划分不是一种数据结构,而是一种算法设计思想,相似于分治思想。面对大量的数据咱们没法处理的时候,能够将其分红一个个小的单元,而后根据必定的策略来处理这些小单元,从而达到目的。
  基本原理及要点:由于元素范围很大,不能利用直接寻址表,因此经过屡次划分,逐步肯定范围,最后在一个能够接受的范围内进行操做。能够经过屡次缩小,双层只是一个形式,分治才是其根本(只是“只分不治”)。
  常规方法:把大文件经过哈希函数分配到不一样的机器,或者经过哈希函数把大文件拆成小文件,一直进行这种划分,直到划分的结果知足资源限制的要求(即分流)。
  适用范围:
  第k大,中位数,不重复或重复的数字

2.6 数据库优化法

2.6.1 索引

  Mysql索引
  索引是对数据库表中一列或多列的值进行排序的一种结构,使用索引可快速访问数据库表中的特定信息。
  数据库索引比如是一本书前面的目录,能加快数据库的查询速度。
  例如这样一个查询:select * from table1 where id=44。若是没有索引,必须遍历整个表,直到ID等于44的这一行被找到为止;有了索引以后(必须是在ID这一列上创建的索引),直接在索引里面找44(也就是在ID这一列找),就能够得知这一行的位置,也就是找到了这一行,可见,索引是用来定位的。
  优势:
    第一,经过建立惟一性索引,能够保证数据库表中每一行数据的惟一性。
    第二,能够大大加快数据的检索速度,这也是建立索引的最主要的缘由。
    第三,能够加速表和表之间的链接(快速查询到须要链接的数据行),特别是在实现数据的参考完整性方面特别有意义。
    第四,在使用分组和排序子句进行数据检索时,一样能够显著减小查询中分组和排序的时间。
    第五,经过使用索引,能够在查询的过程当中,使用优化隐藏器,提升系统的性能。
  缺点:
    第一,建立和维护索引要耗费时间,这种时间随着数据量的增长而增长。
    第二,增长了数据库的存储空间。
    第三,当对表中的数据进行增长、删除和修改的时候,索引也要动态的维护,这样就下降了数据的维护速度。

通常来讲,应该在这些列上建立索引:

  • 在常常须要搜索的列上,能够加快搜索的速度;
  • 在做为主键的列上,强制该列的惟一性和组织表中数据的排列结构;
  • 在常常用在链接的列上,这些列主要是一些外键,能够加快链接的速度;
  • 在常常须要根据范围进行搜索或者须要排序的列上建立索引,由于索引已经排序,其指定的范围是连续的;
  • 在常用WHERE子句的列上面建立索引,加快条件的判断速度。

2.6.2 缓存机制

  配置缓存能够有效的下降数据库查询读取次数,从而缓解数据库服务器压力。

2.6.3 数据分区

  对海量数据进行分区,以下降须要处理的数据规模。好比,针对按年存取的数据,能够按年进行分区。

2.6.4 切表

  分表包括两种方式:横向分表和纵向分表,其中,横向分表比较有使用意义,故名思议,横向切表就是指把记录分到不一样的表中,而每条记录仍旧是完整的(纵向切表后每条记录是不完整的),例如原始表中有100条记录,我要切成2个表,那么最简单也是最经常使用的方法就是ID取摸切表法,本例中,就把ID为1,3,5,7。。。的记录存在一个表中,ID为2,4,6,8,。。。的记录存在另外一张表中。虽然横向切表能够减小查询强度,可是它也破坏了原始表的完整性,若是该表的统计操做比较多,那么就不适合横向切表。横向切表有个很是典型的用法,就是每一个用户的用户数据通常都比较庞大,可是每一个用户数据之间的关系不大,所以这里很适合横向切表。最后,要记住一句话就是:分表会形成查询的负担,所以在数据库设计之初,要想好是否真的适合切表的优化。

2.6.5 分批处理

  对海量数据进行分批处理,再对处理后的数据进行合并操做,分而治之。

2.6.6 用排序来取代非顺序存取

  磁盘存取臂的来回移动使得非顺序磁盘存取变成了最慢的操做,尽可能保证存取数据的有序性,保证数据良好的局部性

2.6.7 日志分析

  在数据库运行了较长一段时间之后,会积累大量的LOG日志,其实这里面的蕴涵的有用信息仍是不少的。经过分析日志,能够找到系统性能的瓶颈,从而进一步寻找优化方案。

2.7 倒排索引(搜索引擎之基石)

  倒排索引(英语:Inverted index),也常被称为反向索引、置入档案或反向档案,被用来存储在全文搜索下某个单词在一个文档或者一组文档中的存储位置的映射。它是目前搜索引擎公司对搜索引擎最经常使用的存储方式。

有两种不一样的反向索引形式:

  • 一条记录的水平反向索引:包含每一个引用单词的文档的列表。
  • 一个单词的水平反向索引:包含单词的文档列表和每一个单词在一个文档中的位置。

后者的形式提供了更多的兼容性(好比短语搜索),可是须要更多的时间和空间来建立。

例如如今咱们要对三篇文档创建索引(实际应用中,文档的数量是海量的):
文档1(D1):中国移动互联网发展迅速
文档2(D2):移动互联网将来的潜力巨大
文档3(D3):中华民族是个勤劳的民族
那么文档中的词典集合为:{中国,移动,互联网,发展,迅速,将来,的,潜力,巨大,中华,民族,是,个,勤劳}
建好的索引以下图:

在上面的索引中,存储了两个信息,文档号和出现的次数。创建好索引之后,咱们就能够开始查询了。例如如今有一个Query是”中国移动”。首先分词获得Term集合{中国,移动},查倒排索引,分别计算query和d1,d2,d3的距离。倒排索引创建好之后,就不须要再检索整个文档库,而是直接从字典集合中找到“中国”和“移动”,而后遍历后面的列表直接计算。

2.8 外排序

  当待排序的对象数目特别多时,在内存中不能一次处理,必须把它们以文件的形式存放于外存,排序时再把他们一部分一部分的调入内存进行处理,这种方式就是外排序法。
  基本原理及要点:
外部排序的两个独立阶段:
1)首先按内存大小,将外存上含n个记录的文件分红若干长度为L的子文件或段。依次将子文件读入内存并利用有效的内部排序对他们进行排序,并将排序后获得的有序子文件从新写入外存,一般称这些子文件为归并段。
2)对这些归并段进行逐趟归并,使归并段逐渐由小到大,最后在外存上造成整个文件的单一归并段,也就完成了文件的外排序。
  适用范围:
  大数据的排序,去重

一个典型实现:
假设文件被分红L段子文件,须要将全部数据从大到小进行排序(即先将最大的输出,再输出第二大的,直到全部元素的顺序被得到)。
(1)依次读入每一个文件块,在内存中对当前文件块进行排序(应用恰当的内排序算法),并将排序后的结果直接写入外存文件(分别写到不一样的子文件)。此时,每块文件至关于一个由大到小排列的有序队列。
(2)接下来进行多路归并排序,在内存中创建一个L个元素的大顶堆(注意这里的要求不只仅是要得到topK,还要按照从大到小的排序输出,因此没使用小顶堆),建堆的过程就是把L块文件中每一个文件的队列头(每一个文件的最大值)依次加入到堆里,并调整成大顶堆。
(3)弹出堆顶元素,若是堆顶元素来自第i块(怎么知道堆顶元素来自哪一块?能够在内存中创建一个hashMap,以第几个子文件为key,以最近一个加入的元素为value,每次新加入元素则更新value),则从第i块文件中补充一个元素到大顶堆,并调整大顶堆结构。弹出的元素暂存至临时数组。
(4)当临时数组存满时,将数组写至磁盘,并清空数组内容。
(5)重复过程(3)、(4),直至全部文件块读取完毕。

2.9 Trie 树

读音:[t'ri:]
  基本原理及要点:
  Trie树也称字典树,它的优势是:利用字符串的公共前缀来下降存储的空间开销和查询的时间开销。 在字符串查找、统计、排序、前缀匹配等方面应用很普遍。
  它有3个基本性质:

  • 根节点不包含字符,除根节点外每个节点都只包含一个字符。
  • 从根节点到某一节点,路径上通过的字符链接起来,为该节点对应的字符串。
  • 每一个节点的全部子节点包含的字符都不相同。

好比:字符串abc,bd,dda

  适用范围:
  数据量大,重复多,可是数据种类小能够放入内存

问题实例:
1).有10个文件,每一个文件1G, 每一个文件的每一行都存放的是用户的query,每一个文件的query均可能重复。要你按照query的频度排序 。
2).1000万字符串,其中有些是相同的(重复),须要把重复的所有去掉,保留没有重复的字符串。请问怎么设计和实现?
3).寻找热门查询:查询串的重复度比较高,虽然总数是1千万,但若是除去重复后,不超过3百万个,每一个不超过255字节。

实现代码,大量递归的应用:

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class Trie {
    private TrieNode root = new TrieNode();

    protected class TrieNode {
        protected int words; //以此字符为结尾的单词的个数
        protected int prefixes; //以此字符(包括此字符)的全部祖先字符组成的字符串为前缀的单词个数
        protected TrieNode[] childNodes; //此节点的全部子节点

        public TrieNode() {
            this.words = 0;
            this.prefixes = 0;
            childNodes = new TrieNode[26];
            for (int i = 0; i < childNodes.length; i++) {
                childNodes[i] = null;
            }
        }
    }

    /**
     * 获取tire树中全部的词
     */
    public List<String> listAllWords() {
        List<String> words = new ArrayList<String>();
        TrieNode[] childNodes = root.childNodes;

        for (int i = 0; i < childNodes.length; i++) {
            if (childNodes[i] != null) {
                String word = "" + (char) ('a' + i);
                depthFirstSearchWords(words, childNodes[i], word);
            }
        }
        return words;
    }

    private void depthFirstSearchWords(List<String> words, TrieNode trieNode, String wordSegment) {
        if (trieNode.words != 0) {
            words.add(wordSegment);
        }
        TrieNode[] childNodes = trieNode.childNodes;
        for (int i = 0; i < childNodes.length; i++) {
            if (childNodes[i] != null) {
                String newWord = wordSegment + (char) ('a' + i);
                depthFirstSearchWords(words, childNodes[i], newWord);
            }
        }
    }

    /**
     * 计算指定前缀单词的个数
     */
    public int countPrefixes(String prefix) {
        return countPrefixes(root, prefix);
    }

    private int countPrefixes(TrieNode trieNode, String prefixSegment) {
        if (prefixSegment.length() == 0) { 
            return trieNode.prefixes;
        }

        char c = prefixSegment.charAt(0);
        int index = c - 'a';
        if (trieNode.childNodes[index] == null) {
            return 0;
        } else {
            return countPrefixes(trieNode.childNodes[index], prefixSegment.substring(1));
        }
    }

    /**
     * 计算彻底匹配单词的个数
     */
    public int countWords(String word) {
        return countWords(root, word);
    }

    private int countWords(TrieNode trieNode, String wordSegment) {
        if (wordSegment.length() == 0) {
            return trieNode.words;
        }

        char c = wordSegment.charAt(0);
        int index = c - 'a';
        if (trieNode.childNodes[index] == null) {
            return 0;
        } else {
            return countWords(trieNode.childNodes[index], wordSegment.substring(1));
        }
    }

    /**
     * 向tire树添加一个词
     */
    public void addWord(String word) {
        addWord(root, word);
    }

    private void addWord(TrieNode trieNode, String word) {
        if (word.length() == 0) {
            trieNode.words++;
        } else {
            trieNode.prefixes++;
            char c = word.charAt(0);
            c = Character.toLowerCase(c);
            int index = c - 'a';
            if (trieNode.childNodes[index] == null) {
                trieNode.childNodes[index] = new TrieNode();
            }
            addWord(trieNode.childNodes[index], word.substring(1)); 
        }
    }

    /**
     * 返回指定字段前缀匹配最长的单词。
     */
    public String getMaxMatchWord(String word) {
        String s = "";
        String temp = "";// 记录最近一次匹配最长的单词
        char[] w = word.toCharArray();
        TrieNode trieNode = root;
        for (int i = 0; i < w.length; i++) {
            char c = w[i];
            c = Character.toLowerCase(c);
            int index = c - 'a';
            if (trieNode.childNodes[index] == null) {// 若是没有子节点
                if (trieNode.words != 0)// 若是是一个单词,则返回
                    return s;
                else
                    // 若是不是一个单词则返回null
                    return null;
            } else {
                if (trieNode.words != 0){
                    temp = s;
                }
                s += c;
                trieNode = trieNode.childNodes[index];
            }
        }
        // trie中存在比指定单词更长(包含指定词)的单词
        if (trieNode.words == 0)//
            return temp;
        return s;
    }

    public static void main(String args[]){
        Trie trie = new Trie();
        trie.addWord("abcedfddddddd");
        trie.addWord("a");
        trie.addWord("ba");
        trie.addWord("abce");
        trie.addWord("abce");
        trie.addWord("abcedfdddd");
        trie.addWord("abcef");

        String maxMatch = trie.getMaxMatchWord("abcedfddd");
        System.out.println("最大前缀匹配的单词:"+maxMatch);
        List<String> list = trie.listAllWords();
        Iterator<String> listiterator = list.listIterator();
        System.out.println("全部字符串列表:");
        while (listiterator.hasNext()) {
            String s = (String) listiterator.next();
            System.out.println(s);
        }

        int count = trie.countPrefixes("ab");
        int count1 = trie.countWords("abce");
        System.out.println("以ab为前缀的单词数:"+ count);
        System.out.println("单词abce的个数为:" + count1);
    }
}

2.10 分布式处理 MapReduce

云计算的核心技术之一,是一种简化并行计算的分布式编程模型。
基本原理及要点:
其核心操做为Map和Reduce。Map(映射):将数据经过 Map程序映射到不一样的区块。Reduce(化简):将不一样的区块划分到不一样的机器上进行并行处理。最后再对每台机器上的结果进行整合。即数据划分,结果规约。

MapReduce实例:上千万或亿数据,统计其中出现次数最多的前N个数据。
讲解:首先能够根据数据值或者把数据hash后的值,按照范围划分到不一样的子机器,最好让数据划分后能够一次性读入内存,这样不一样的子机器负责处理各自的数值范围。获得结果后,各个子机器只需拿出各自的出现次数最多的前N个数据,而后汇总,选出全部的数据中出现次数最多的前N个数据。

  适用范围:
  数据量很大(一般大于1TB)

3、经典题目

3.1 top K问题

  海量数据中找出出现频度最高的前K的数或者字符串,或者海量数据中找出最大的前K个数。

  如何选择hash函数分流:
一、能够根据数据值或者把数据hash(md5)后的值,按照范围划分到不一样的子集,可是计算md5代价是比较高的,能够考虑其余比较简单的hash。通常应用查询的是字符串,关于String的hash函数,因为字符串计算hashCode相似于31进制转10进制,所以7个左右的字符(接近2^52^52^5...,不用32的缘由是最后算的值有效位太少)就能够达到最大int值,而后利用hashCode的低16位和高16位异或,可见自学Java HashMap源码,再%表长,能够有效分流。
二、直接hash(url)%1000这种形式说明,若是分流后仍是一些集合很大,则再次分流。

  针对top K类问题,一般比较好的方案是分治+Trie树/HashMap+小顶堆,即hash映射分流 + hashMap统计 + 小顶堆求topK,即先将数据集按照Hash映射分解成多个小数据集,而后使用Trie树或者HashMap统计每一个小数据集中的query词频,以后用小顶堆求出每一个数据集中出现频率最高的前K个数,最后在全部top K中求出最终的top K。固然也能够:分流到不一样机器+Trie树/HashMap+小顶堆,即先将数据集按照Hash方法分解成多个小数据集,而后分流到不一样的机器上,具体多少台机器由面试官限制决定。对每一台机器,若是分到的数据量依然很大,好比内存不够或者其余问题,能够再用hash函数把每台机器的分流文件拆成更小的文件处理。而后使用Trie树或者Hash统计每一个小数据集中的query词频,以后用小顶堆求出每一个数据集中出现频率最高的前K个数,最后在全部top K中求出最终的top K。

eg:有1亿个浮点数,若是找出其中最大的10000个?
  第一种是将数据所有排序,而后在排序后的集合中进行查找,最快的排序算法的时间复杂度通常为O(nlogn),如快速排序。可是在32位的机器上,每一个float类型占4个字节,1亿个浮点数就要占用(4B*10^8=0.4GB)400MB的存储空间,对于一些可用内存小于400M的计算机而言,很显然是不能一次将所有数据读入内存进行排序的。其实即便内存可以知足要求,该方法也并不高效,由于题目的目的是寻找出最大的10000个数便可,而排序倒是将全部的元素都排序了,作了不少的无用功。

  第二种方法是分治+快排变体法,将1亿个数据分红100份,每份100万个数据,找到每份数据中最大的10000个,最后在剩下的100 * 10000个数据里面找出最大的10000个。若是100万数据选择足够理想,那么能够过滤掉1亿数据里面99%的数据。100万个数据里面查找最大的10000个数据的方法以下:用快速排序的方法,首先,随便选一个中轴,比他大的数据放到右边,比他小的放到左边,若是大的那堆个数N大于10000个,继续对大堆快速排序一次分红2堆。。。若是大堆个数N小于10000个,就在小的那堆里面快速排序一次,找第10000-N大的数字;递归以上过程,就能够找到第10000大的数(利用快排的变体找到第k大,平均时间复杂度O(N),能够看找到无序数组中第k个最小的数),而后一遍遍历就能够找到前10000大。此种方法须要每次的内存空间为\(10^6*4B=4MB\),一共须要101次这样的比较。

  第三种方法是Hash去重+最小堆法。若是这1亿个数里面有不少重复的数,先经过hashMap,把这1亿个数字去重复,这样若是重复率很高的话,会减小很大的内存用量,从而缩小运算空间,而后经过分治+最小堆法或直接最小堆法查找最大的10000个数。

  第四种方法直接最小堆法。首先读入前10000个数来建立大小为10000的最小堆,建堆的时间复杂度为O(m)(m为数组的大小即为10000),而后遍历后续的数字,并与堆顶(最小)数字进行比较。若是比最小的数小,则继续读取后续数字;若是比堆顶数字大,则替换堆顶元素并从新调整堆为最小堆。重复整个过程直至1亿个数所有遍历完为止。而后按照中序遍历的方式输出当前堆中的全部10000个数字。对每个输入,堆调整的时间复杂度是O(logm),最终时间复杂度为:1次建堆时间+n次堆调整时间=O(m+nlogm)=O(nlogm),空间复杂度是10000(常数)。

实际运行:
  实际上,最优的解决方案应该是最符合实际设计需求的方案,在实际应用中,可能有足够大的内存,那么直接将数据扔到内存中一次性处理便可,也可能机器有多个核,这样能够采用多线程处理整个数据集。
  下面针对不一样的应用场景,分析了适合相应应用场景的解决方案。
(1)单机+单核+足够大内存
  若是须要查找10亿个查询词(每一个占8B)中出现频率最高的10个,每一个查询词占8B,则10亿个查询词所需的内存大约是10^9 * 8B=8GB内存。若是有这么大内存,直接在内存中先用HashMap求出每一个词出现的频率,而后用小顶堆求出频率最大的10个词。

(2)单机+多核+足够大内存
  这时能够直接在内存中使用Hash方法将数据划分红n个partition,每一个partition交给一个线程处理,线程的处理逻辑同(1)相似,最后一个线程将结果归并。
  该方法存在一个瓶颈会明显影响效率,即数据倾斜。每一个线程的处理速度可能不一样,快的线程须要等待慢的线程,最终的处理速度取决于慢的线程。而针对此问题,解决的方法是,将数据划分红c×n个partition(c>1),每一个线程处理完当前partition后主动取下一个partition继续处理,直到全部数据处理完毕,最后由一个线程进行归并。

(3)单机+单核+受限内存
  这种状况下,须要用hash函数将原数据文件映射到一个一个小文件,若是小文件仍大于内存大小,继续采用Hash的方法对数据文件进行分割,直到每一个小文件小于内存大小,这样每一个文件可放到内存中处理。采用(1)的方法依次处理每一个小文件。

(4)多机+受限内存
  这种状况,为了合理利用多台机器的资源,可用hash函数将原数据文件映射到一个一个小文件,而后分发到多台机器上,每台机器采用(3)中的策略解决本地的数据,最后将全部机器处理结果汇总,并利用小顶堆求出频率最大的10个词。

  从实际应用的角度考虑,(1)(2)(3)(4)方案并不可行,由于在大规模数据处理环境下,做业效率并非首要考虑的问题,算法的扩展性和容错性才是首要考虑的。算法应该具备良好的扩展性,以便数据量进一步加大(随着业务的发展,数据量加大是必然的)时,在不修改算法框架的前提下,可达到近似的线性比;算法应该具备容错性,即当前某个文件处理失败后,能自动将其交给另一个线程继续处理,而不是从头开始处理。

   top K问题很适合采用MapReduce框架解决,用户只需编写一个Map函数和两个Reduce 函数,而后提交到Hadoop(采用Mapchain和Reducechain)上便可解决该问题。具体而言,就是首先根据数据值或者把数据hash(MD5)后的值按照范围划分到不一样的机器上,最好可让数据划分后一次读入内存,这样不一样的机器负责处理不一样的数值范围,实际上就是Map。获得结果后,各个机器只需拿出各自出现次数最多的前N个数据,而后汇总,选出全部的数据中出现次数最多的前N个数据,这实际上就是Reduce过程。对于Map函数,采用Hash算法,将Hash值相同的数据交给同一个Reduce task;对于第一个Reduce函数,采用HashMap统计出每一个词出现的频率,对于第二个Reduce 函数,统计全部Reduce task,输出数据中的top K便可。

  直接将数据均分到不一样的机器上进行处理是没法获得正确的结果的。由于一个数据可能被均分到不一样的机器上,而另外一个则可能彻底汇集到一个机器上,同时还可能存在具备相同数目的数据。

如下是一些常常被说起的该类问题。
(1)有一个1G大小的文件,里面每一行是一个词,词的大小不超过16字节,内存限制大小是1M。返回频数最高的100个词。
方案1:
顺序读文件,对于每一个词x,取hash(x)%5000,而后按照该值存到5000个小文件中。这样每一个文件大概是200k左右。若是其中有的文件超过了1M大小,还能够按照相似的方法继续往下分,直到分解获得的小文件的大小都不超过1M。对每一个小文件,统计每一个文件中出现的词以及相应的频率(能够采用trie树/hash_map等),并取出出现频率最大的100个词(能够用含100个结点的最小堆),并把100词及相应的频率存入文件,这样又获得了5000个文件。下一步就是把这5000个文件进行归并(相似于归并排序)的过程了。

(2)搜索的输入信息是一个字符串,统计300万条输入信息中最热门的前10条,每次输入的一个字符串为不超过255B,内存使用只有1GB。
典型的Top K算法,第一步、先对这批海量数据预处理,在O(N)的时间复杂度内用HashMap完成统计;第二步、借助最小堆找出Top K,时间复杂度为N‘logK。 即最终的时间复杂度是:O(N) + N'*O(logK),(N为1000万,N’为300万)

(3)最大间隙问题
给定n个实数,,求这n个实数在实轴上相邻2个数之间的最大差值,要求线性的时间算法。
最早想到的方法就是先对这n个数据进行排序,而后一遍扫描便可肯定相邻的最大间隙。但该方法不能知足线性时间的要求。故采起以下方法:

  • 找到n个数据中最大和最小数据max和min。
  • 用n-2个点等分区间[min, max],即将[min, max]等分为n-1个区间(前闭后开区间),将这些区间看做桶,编号为,且桶i的上界和桶i+1的下界相同,即每一个桶的大小相同。每一个桶的大小为:,且认为将min放入第一个桶,将max放入第n-1个桶。
  • 将n个数放入n-1个桶中:将每一个元素x[i]分配到某个桶(编号为index),其中,而且只须要记录分到每一个桶的最大最小数据
  • 最大间隙:除最大最小数据max和min之外的n-2个数据放入n-1个桶中,由抽屉原理可知至少有一个桶是空的,又由于每一个桶的大小相同,因此最大间隙不会在同一桶中出现,必定是某个桶的最小数据和另外一个桶的最大数据之差,且该两桶之间的桶必定是空桶。也就是说,最大间隙在桶i的最大数据和桶j的最小数据之间产生。向前扫描一遍,找到全部有空桶的地方的最大间隙的最大值,一遍扫描便可完成。

3.2 重复问题

  在海量数据中查找出重复出现的元素或者去除重复出现的元素也是常考的问题。针对此类问题,通常能够经过位图法实现。例如,已知某个文件内包含一些电话号码,每一个号码为8位数字,统计不一样号码的个数。

如下是一些常常被说起的该类问题。
(1)给定a、b两个文件,各存放50亿个url,每一个url各占64字节,内存限制是4G,让你找出a、b文件共同的url?
方案1:
能够估计每一个文件的大小为5G×64=320G,远远大于内存限制的4G。因此不可能将其彻底加载到内存中处理。考虑采起分而治之的方法。

  • 遍历文件a,对每一个url求hash(url)%1000,而后根据所取得的值将url分别存储到1000个小文件(记为a0,a1,a2...a999)中,这样每一个小文件的大约为300M。
  • 遍历文件b,采起和a相同的方式将url分别存储到1000小文件(记为b0,b1,b2...b999)中。这样处理后,全部可能相同的url都在对应(a0-b0,a1-b1,...,a999-b999)的小文件中,不对应的小文件不可能有相同的url。而后咱们只要求出1000对小文件中相同的url便可。
  • 求每对小文件中相同的url时,能够把其中一个小文件的url存储到hash_set中。而后遍历另外一个小文件的每一个url,看其是否在刚才构建的hash_set中,若是是,那么就是共同的url,存到文件里面就能够了。

方案2:
若是容许有必定的错误率,可使用Bloom filter,4G内存大概能够表示340亿bit。将其中一个文件中的url使用Bloom filter映射为这340亿bit,而后逐个读取另一个文件的url,检查它是否在Bloom filter表示的集合中,若是是,那么该url应该是共同的url(注意会有必定的错误率)。

(2)在25亿个整数中找出不重复的整数,内存不足以容纳这25亿个整数。
方案1:
采用2-Bitmap(每一个数分配2bit,00表示不存在,01表示出现一次,10表示屡次,11无心义)进行,共需\(2^{32}*2bit=2^{30}B\)=1GB内存,还能够接受。而后扫描这2.5亿个整数,查看Bitmap中相对应位,若是是00变01,01变10,10保持不变。全部整数遍历完成后,查看bitmap,把对应位是01的整数输出便可。

方案2:
采用映射的方法,好比模1000,把整个大文件映射为1000个小文件,再找出每一个小文件中不重复的整数。对于每一个小文件,用hash_map(int,count)来统计每一个整数出现的次数,输出便可。

(3)1000万字符串,其中有些是重复的,须要把重复的所有去掉,保留没有重复的字符串。请怎么设计和实现?
方案1:这题用trie树比较合适,hash_map也应该能行。

3.3 排序问题

通常采用位图(若为int型的数,最多\(2^{32}\),\(2^{32}*1bit=2^{30}/2B\)=0.5GB=500MB,彻底能够放入内存)或者外排序。
(1)有10个文件,每一个文件1G,每一个文件的每一行存放的都是用户的query,每一个文件的query均可能重复。要求你按照query的频度排序。
方案1:

  • 顺序读取10个文件,按照hash(query)%10的结果将query写入到另外10个文件(记为a0,a1,a2...a9)中。这样新生成的文件每一个的大小大约也1G(假设hash函数是随机的)。
  • 找一台内存在2G左右的机器(若是可用内存很小,则分到更多的文件中),依次对a0,a1,a2...a9用hash_map(query, query_count)来统计每一个query出现的次数,而后利用快速/堆/归并排序按照出现次数进行排序。将排序好的query和对应的query_cout输出到文件中。这样获得了10个排好序的文件(记为b0,b1,b2...b9)。
  • 对b0,b1,b2...b9这10个文件进行归并排序(内排序与外排序相结合)。

方案2:
通常query的总量是有限的,只是重复的次数比较多而已,可能对于全部的query,一次性就能够加入到内存了。这样,咱们就能够采用trie树/hash_map等直接来统计每一个query出现的次数,而后按出现次数作快速/堆/归并排序就能够了

方案3: 与方案1相似,但在作完hash,分红多个文件后,能够交给多个机器来处理,采用分布式的架构来处理(好比MapReduce),最后再进行合并。

相关文章
相关标签/搜索