针对海量数据的处理,可以使用的方法非常多,常见的方法有Hash法、Bit-map法、Bloom filter法、数据库优化法、倒排索引法、外排序法、Trie树、堆、双层桶法以及MapReduce法。
Hash一般被翻译为哈希,也被称为散列,它是一种映射关系,即给定一个数据元素,其关键字为key,按一个确定的哈希函数Hash计算出hash(key),把hash(key)作为关键字key对应元素的存储地址(或称哈希地址),再进行数据元素的插入和检索操作。简而言之,哈希函数就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
哈希表是具有固定大小的数组,其中,表长(即数组的大小)应该为质数。哈希函数是用于关键字与存储地址之间的一种映射关系,但是不能保证每个元素的关键字与函数值是一一对应的,因为极有可能出现对应于不同的元素,却计算出了相同的函数值。冲突是指两个关键字映射到同一个存储地址的情况,即对不同的关键字可能得到同一散列地址,即key1≠key2,而f(key1)=f(key2)。
哈希函数一般应具备以下几个方面的特点:
1)运算应该尽可能简单。
2)函数的值域必须在散列表的范围内。
3)尽可能地减少冲突。
针对哈希函数的这些特点,在构建哈希表时,不仅要设定一个好的哈希函数,而且还要设定一种处理冲突的方法。常用的哈希函数的构建方法一般有以下几种:
(1)直接寻址法。
取关键字或关键字的某个线性函数值为散列地址。即h(key)=key或h(key) = a·key + b,其中a和b均为整型常数,这种散列函数叫做自身函数。例如,有一个人口数字统计表,人的年龄取值范围为1~100岁,其中,年龄作为关键字,哈希函数取关键字自身,但这种方法效率比较低,时间复杂度为O(1),空间复杂度为O(n),n为关键字的个数。
直接寻址法不会产生冲突,但由于它没有压缩映象,因此当关键字集合很大时,使用这种Hash函数是不可能实现地址编码的散列的。
(2)取模法。
选择一个合适的正整数p,令hash(Key)=Key mod p。p如果选择的是比较大的素数,则效果比较好,一般选取p为TableSize,即哈希表的长度。
(3)数字分析法。
设关键字是d位的以r为基的数(如以10为基的十进制数),且共有n个关键字。则关键字的每个位可能有r个不同的数符出现(即0,1,2,…,9),但这r个数符在各个位上出现的频率不一定相同,可能在某些位上分布比较均匀,即每个数符出现的次数接近于n/r,而在另一些位上分布不均匀。因此可选取其中分布比较均匀的那些位,重新组成新的数,用其作为哈希地址。
这种方法比较简单、直观,但是需要预先知道每个关键字的情况,这就限制了它的使用范围。
(4)折叠法。
将关键字分成位数为t的几个部分(最后一部分的位数可能小于t),然后把各部分按位对齐进行相加,将所得的和舍弃进位,留下t位作为哈希地址。当关键字位数很多,而且关键字中每位上数字分布比较均匀时,采用折叠法比较合适。
(5)平方取中法。
这是一种较常用的方法,将关键字进行平方运算,然后从结果的中间取出若干位(位数与散列地址的位数相同),将其作为散列地址,具体取几位由哈希表的表长决定。
(6)除留余数法。
除留余数法是一种比较常用的哈希函数,它的主要原理是取关键字除以某个数p(p不大于哈希表的长度TableSize)的余数作为哈希地址,即Hash(key)=key % p
使用除留余数法时,选取合适的p值很重要,一般要求p≤TableSize,且接近TableSize或等于TableSize,p一般选取质数,也可以是不包含小于20质因子的合数。
(7)随机数法。
选择一个随机函数,然后用关键字key的随机函数值作为哈希地址,即Hash(key)= random(key);
其中,random( )为随机函数。当关键字的长度不相等时采用这种方法比较合适。
在构造哈希表的过程中,不管使用什么样的哈希函数,冲突都不可能完全避免的,所以冲突解决是构造哈希表的一个必不可少的过程。解决冲突的主要途径是当一个关键字映射到哈希表中的某一个地址且该地址上已有关键字时,再为该关键字寻找新的存储地址。常用的冲突解决办法有以下几种:
(1)开放地址法。
开放地址法的基本思想是当发生地址冲突时,则在哈希表中再按照某种方法继续探测其他的存储地址,直到找到空闲的地址为止。该过程可描述为
Hi(key)=(H(key)+di) mod m (i=1,2,……,k (k<=m-1))
其中,H(key)为关键字key的直接哈希地址,m为哈希表的长度,di为每次再探测时的地址增量。
采用这种方法时,首先计算出关键字的直接哈希地址,即H(key),如果该直接哈希地址上已经有其他的关键字,则继续查看地址为[H(key)+di]的存储地址,判断其是否为空。如此反复直至找到空闲的存储地址为止,然后将关键字key存放到该地址。
增量di可以有不同的取法,常用的有以下3种:
1)di=1,2,3,…,m-1,称为线性探测再散列。
2)di=12,-12,22,-22,…,-k2(k≤m/2),称为二次探测再散列。
3)di=伪随机序列,称为伪随机再散列。
注意:对于利用开放地址法处理冲突所产生的哈希表中,删除一个元素时不能直接删除,因为这样将会影响其他具有相同哈希地址的元素的查找地址,所以通常采用设定一个特殊的标志的方法表示该元素已经被删除。
(2)链地址法。
链地址法解决冲突的主要思想是:如果哈希表空间为[0, m-1],则设置一个由m个指针组成的一维数组CH[m],然后在寻找关键字哈希地址的过程中,所有哈希地址为i的数据元素都插入到头指针为CH[i]的链表中。这种方法比较适合于冲突比较严重的情况下使用。
例如,设有8个元素{a,b,c,d,e,f,g,h},采用某种哈希函数得到的地址分别为{0,2,4,1,0,8,7,2},当哈希表长度为10时,采用链地址法解决冲突的哈希表如图14-1所示。
(3)再散列法。
当发生冲突时,使用第二个、第三个哈希函数计算地址,直到无冲突时。但这种方法的缺点是计算时间会大幅增加。
(4)建立一个公共溢出区。
假设哈希函数的值域为[0,m-1],则设向量HashTable[0,…,m-1]为基本表,另外设立存储空间向量OverTable[0,…,v]用以存储发生冲突的记录。
Hash主要是用来进行“快速存取”,在O(1)时间复杂度里就可以查找到目标元素,或者判断其是否存在。Hash数据结构里的数据对外是杂乱无序的,无法得知其具体存储位置,也不知道各个存储元素位置之间的相互关系,但是却可以在常数时间里判断元素位置及存在与否。在海量数据处理中,使用Hash方法一般可以快速存取、统计某些数据,将大量数据进行分类。例如,提取某日访问网站次数最多的IP地址等。
Bit-map(位图)法的基本原理是使用位数组来表示某些元素是否存在,如8位电话号码中查重复号码,它适用于海量数据的快速查找、判重、删除等。
具体而言,位图排序以一个N位长的串,每位上以“1”或“0”表示需要排序的集合(后简称“集合”)中的数。例如,集合为{2,7,4,9,1,10},则生成一个10位的串,将第2、7、4、9、1、10位置为“1”,其余位置为“0”,这样当把串中所有位都置完后,排序也自动完成了(因为字符串的下标是有序的):1101001011。
再例如要排序0~15内的以下元素序列{5,8,1,12,6,2},那么首先开辟两个字节的空间,也就是16位,分别对应0~15这16个数。首先,将这16位置为0。遍历序列,在出现的数字的对应位置上置1,也就是将每个元素对应到了位图的相应位置。再遍历这16位,就完成了对元素的排序,过程如图14-2所示。
图14-2 位图法初始化与赋值
位图排序的时间复杂度是O(n)的,比一般的排序都快,但它是以空间换时间(需要一个N位的串)的,而且有一些限制,即数据状态不是很多。例如,排序前集合大小最好已知,而且集合中元素的最大重复次数必须已知,最好是惆集数据(不然空间浪费很大)。
在程序设计中,经常会遇到判断集合中是否存在重复的问题,数据量比较小时,对时间复杂度可能要求并不高,但当集合中数据量比较大时,则希望能够少进行几次扫描,此时如果还采用双重循环法,显然效率就太低下了,不可取。而位图法则比较适合于这种情况,位图法首先扫描一遍集合,找出集合中的最大元素,然后按照集合中最大元素max创建一个长度为max+1的新数组,接着再次扫描原数组,每遇到一个元素,就将新数组中下标为元素值的位上置1。例如,如果遇到元素5,就将新数组的第6个元素置为1,如此下去,当下次再遇到元素5想置位时,发现新数组的第6个元素已经被置为1了,则说明这次的数据肯定和以前的数据存在着重复。该算法的运算次数最坏的情况为2N,但如果能够事先知道集合的最大元素的值,那么效率还可以提高一倍。
位图法的作用巨大,除了判断数据是否重复以外,也经常使用位图法来判断集合中某个数据是否存在。
日常生活中很多地方都会遇到类似这样的问题:在设计计算机软件系统时,在程序中经常需要判断一个元素是否在一个集合中;在字处理软件中,需要检查一个英语单词是否拼写正确;在 FBI,一个嫌疑人的名字是否已经在嫌疑名单上。
针对这些问题,最直接的解决方法就是将集合中全部的元素都存储在计算机中,每当遇到一个新元素时,就将它和集合中的元素直接进行比较即可。这种做法虽然能够准确无误地完成任务,但存在一个问题,就是比较次数太多,效率比较低下。当数据量不大时,这种效率低的问题并不显著,但是当数据量巨大时,如在海量数据信息处理中,存储效率低的问题就显现出来了。
Bloom filter正是解决这一问题的有效方法,它是一种空间效率和时间效率很高的随机数据结构,它用来检测一个元素是否属于一个集合。但它同样带来一个问题:牺牲了正确率,Bloom filter以牺牲正确率为前提,来换取空间效率与时间效率的提高。当它判断某元素不属于这个集合时,该元素一定不属于这个集合;当它判断某元素属于这个集合时,该元素不一定属于这个集合。具体而言,查询结果有两种可能,即“不属于这个集合(绝对正确)”和“属于这个集合(可能错误)”。所以,Bloom filter适合应用在对于低错误率可以容忍的场合。
它的基本原理是位数组与Hash函数的联合使用。具体而言,首先,Bloom filter是一个包含了m位的位数组,数组的每一位都初始化为0,然后定义k个不同的Hash函数,每个函数都可以将集合中的元素映射到位数组的某一位。当向集合中插入一个元素时,根据k个Hash函数可以得到位数组中的k个位,将这些位设置为1。如果查询某个元素是否属于集合,那么根据k个Hash函数可以得到位数组中的k个位,查看这k个位中的值,如果有的位不为1,那么该元素肯定不在此集合中;如果这k个位全部为1,那么该元素可能在此集合中(在插入其他元素时,可能会将这些位置为1,这样就产生了错误)。
下面通过一个实例具体了解Bloom filter,如图14-3所示。
图14-3 Bloom filter实例解析
所以,使用Bloom filter的难点是如何根据输入元素个数n,来确定位数组m的大小以及Hash函数。当Hash函数个数k=(ln2)×(m/n)时错误率最小,在错误率不大于E的情况下,m至少要等于n×lg(1/E)才能表示任意n个元素的集合。但m还应该更大些,因为还要保证位数组里至少一半为0,则m应该≥nlg(1/E)×lge,大概就是nlg(1/E)的1.44倍(lg表示以2为底的对数)。
例如,假设E为0.01,即错误率为0.01,则此时m应该大约为n的13倍。这样k大约是8个(注意,m与n的单位不同,m的单位是bit,而n则是以元素个数为单位)。通常单个元素的长度都是有很多bit的,所以使用bloom filter内存上通常都是节省的。
Bloom filter的优点是具有很好的空间效率和时间效率。它的插入和查询时间都是常数,另外它不保存元素本身,具有良好的安全性。然而,这些优点都是以牺牲正确率为代价的。当插入的元素越多,错判“元素属于这个集合”的概率就越大。另外,Bloom filter只能插入元素,却不能删除元素,因为多个元素的哈希结果可能共用了Bloom filter结构中的同一个位,如果删除元素,就可能会影响多个元素的检测。所以,Bloom filter可以用来实现数据字典、进行数据的判重或者集合求交集。
CBF与SBF是BF的扩展,Counting Bloom Filter(CBF)将位数组中的每一位扩展为一个counter,从而支持了元素的删除操作。Spectral Bloom Filter(SBF)将其与集合元素的出现次数关联,SBF采用counter中的最小值来近似表示元素的出现频率。
互联网上的数据一般都被存储在数据库中,很多情况下,人们并非对这些海量数据本身感兴趣,而是需要从这些海量数据中提起出对自己有用的信息。例如,从数据中获取访问最多的页面信息等,这就涉及数据的查询技术等相关内容。
数据库管理软件选择是否合理、表结构设计是否规范、索引创建是否恰当都是影响数据库性能的重要因素。所以,对数据库进行优化,是实现海量数据高效处理的有效方法之一。常见的数据库优化方法有以下几种:
(1)优秀的数据库管理工具。
选择一款优秀的数据库管理工具非常重要。现在的数据库工具厂家比较多,对海量数据的处理对所使用的数据库工具要求比较高,一般使用Oracle、DB2、MySQL等。
(2)数据分区。
进行海量数据的查询优化,一种重要方式就是如何有效地存储并降低需要处理的数据规模,所以可以对海量数据进行分区操作提高效率。例如,针对按年份存取的数据,可以按年进行分区,不同的数据库有不同的分区方式,不过处理机制却大体相同。例如,SQL Server的数据库分区是将不同的数据存于不同的文件组下,而不同的文件组存于不同的磁盘分区下,这样将数据分散开,减小磁盘I/O,减小了系统负荷,而且还可以将日志、索引等放于不同的分区下。
(3)索引。
索引一般可以加速数据的检索速度,加速表与表之间的链接,提高性能,所以在对海量数据进行处理时,考虑到信息量比较大,应该对表建立索引,包括在主键上建立聚簇索引,将聚合索引建立在日期列上等。
索引优点很多,但是对于索引的建立,还需要考虑到实际情况,而不是对每一个列建立一个索引。例如,针对大表的分组、排序等字段,都要建立相应的索引,同时还应该考虑建立复合索引。增加索引同时也有很多不利的方面:首先,创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加;其次,索引需要占物理空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间。如果要建立聚簇索引,那么需要的空间就会更大。最后,当对表中的数据进行增加、删除和修改的时候,索引也要动态地维护,这样就降低了数据的维护速度。
所以索引要用到好的时机,索引的填充因子和聚集、非聚集索引都要考虑。
(4)缓存机制。
当数据量增加时,一般的处理工具都要考虑到缓存问题。缓存大小设置的好差也关系到数据处理的成败。例如,在处理2亿条数据聚合操作时,缓存设置为100000条/Buffer可行。
(5)加大虚存。
由于系统资源有限,而需要处理的数据量非常大,所以当内存不足时,可以通过增加虚拟内存来解决。
(6)分批处理。
由于需要处理的信息量巨大,可以对海量数据进行分批处理(类似于云计算中的MapReduce思想),然后再对处理后的数据进行合并操作,分而治之,有利于小数据量的处理,不至于面对大数据量带来的问题。
(7)使用临时表和中间表。
数据量增加时,处理中要考虑提前汇总。这样做的目的是化整为零,大表变小表,分块处理完成后,再利用一定的规则进行合并,处理过程中的临时表的使用和中间结果的保存都非常重要。如果对于超海量的数据,大表处理不了,只能拆分为多个小表。如果处理过程中需要多步汇总操作,可按汇总步骤一步步来。
(8)优化查询语句。
查询语句的性能对查询效率的影响是非常大的。编写高效优良的SQL脚本和存储过程是数据库工作人员的职责,也是检验数据库工作人员水平的一个标准。
(9)使用视图。
视图中的数据来源于基本表,对海量数据的处理,可以将数据按一定的规则分散到各个基本表中,查询或处理过程中可以基于视图进行。
(10)使用存储过程。
在存储过程中尽量使用SQL自带的返回参数,而非自定义的返回参数,减少不必要的参数,避免数据冗余。
(11)用排序来取代非顺序存取。
磁盘存取臂的来回移动使得非顺序磁盘存取变成了最慢的操作,但是在SQL语句中这个现象被隐藏了,这样就使得查询中进行了大量的非顺序页查询,降低了查询速度。
(12)使用采样数据进行数据挖掘。
基于海量数据的数据挖掘正在逐步兴起,面对着超海量的数据,一般的挖掘软件或算法往往采用数据抽样的方式进行处理,这样的误差不会很高,大大提高了处理效率和处理的成功率。一般采样时要注意数据的完整性,防止过大的偏差。
倒排索引是目前搜索引擎公司对搜索引擎最常用的存储方式,也是搜索引擎的核心内容。在搜索引擎实际的引用之中,有时需要按照关键字的某些值查找记录,所以是按照关键字建立索引,这个索引就被称为倒排索引。
倒排索引也常被称为反向索引、置入档案或反向档案,它本质上是一种索引方法,被用来存储在全文搜索下某个单词在一个文档或者一组文档中的存储位置的映射。它是文档检索系统中最常用的数据结构,有两种不同的反向索引形式:第一种形式是一条记录的水平反向索引(或者反向档案索引)包含每个引用单词的文档的列表;第二种形式是一个单词的水平反向索引(或者完全反向索引)又包含每个单词在一个文档中的位置。第二种形式提供了更多的兼容性(如短语搜索),但是需要更多的时间和空间来创建。
一般情况下可以采用矩阵的方式来存储,但会浪费大量的空间。例如,对于如下的内容,
D1: The GDP increased.
D2: The text is this.
D3: My name is.
如果采用矩阵的方式存储,见表14-1。其中,行表示关键词,列表示所有的文件。
表14-1 矩阵方式存储表示
D1 |
D2 |
D3 |
|
The |
1 |
1 |
0 |
GDP |
1 |
0 |
0 |
increased |
1 |
0 |
0 |
text |
0 |
1 |
0 |
is |
0 |
1 |
1 |
name |
0 |
0 |
1 |
而根据表14-1,就能得到下面的倒排索引:
The:{D1, D2};
GDP:{D1};
increased:{D1};
Text:{D2};
is:{D2, D3};
Name:{D3}.
通过比较发现,采用倒排索引比采用矩阵的方式节省很多的空间。
正向索引开发出来用来存储每个文档的单词的列表。正向索引的查询往往满足每个文档有序频繁的全文查询和每个单词在校验文档中的验证查询。在正向索引中,文档占据了中心的位置,每个文档指向了一个它所包含的索引项的序列。也就是说,文档指向了它包含的那些单词,而反向索引则是单词指向了包含它的文档,很容易看到这个反向的关系。而与正向索引相比,倒排索引的优点是在处理复杂的多关键字查询时,可在倒排表中先完成查询的并、交等逻辑运算,得到结果后再对记录进行存取,这样不必对每个记录随机存取,把对记录的查询转换为地址集合的运算,从而提高查找速度。所以,倒排索引一般被应用于文档检索系统,查询哪些文件包含了某个单词,比如常见的学术论文的关键字搜索。
当待排序的对象数目特别多时,在内存中不能一次处理,必须把它们以文件的形式存放于外存,排序时再把它们一部分一部分调入内存进行处理,该种方式就是外排序。
外排序是相对内排序而言的,它是大文件的排序,待排序的记录存储在外存储器上,待排序的文件无法一次装入内存,需要在内存和外部存储器之间进行多次数据交换,以达到排序整个文件的目的。一般采用归并排序等方式实现外排序,主要分成两个步骤:第一步,生成若干初始归并段(顺串),也被称为文件预处理,把含有n个记录的文件,按内存大小划分为若干长度为L的子文件,然后分别将子文件调入内存,采用有效的内排序方法排序后送回外存;第二步进行多路归并,即对这些初始归并段进行多遍归并,使得有序的归并段逐渐扩大,最后在外存上形成整个文件的单一归并段,此时就完成了文件的外排序。
外排序的适用范围是大数据的排序以及去重复。但外排序也存在着很大的缺陷,就是它会消耗大量的IO,效率不会很高。
Trie这个单词来自于“retrieve”,Trie树又称字典树或键树。它是一种用于快速字符串检索的多叉树结构,其原理是利用字符串的公共前缀来降低时空开销,即以空间换时间,从而达到提高程序效率的目的。Trie树的典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
Trie树一般具有以下3个基本特性:
(1)根结点不包含字符,除根结点外每一个结点都只包含一个字符。
(2)从根结点到某一结点,路径上经过的字符连接起来,为该结点对应的字符串。
(3)每个结点的所有子结点包含的字符都不相同。
Trie树可以利用字符串的公共前缀来节约存储空间。如图14-4所示,该Trie树用10个结点保存了5个字符串amy、ann、em、rob、rg.
在该Trie树中,字符串“amy”和“ann”有公共前缀“a”。当然,如果系统中存在大量字符串且这些字符串基本没有公共前缀,则相应的Trie树将非常消耗内存,这也是Trie树的一个缺点。
例如,给一个单词a,如果通过交换单词中字母的顺序可以得到另外的单词b,那么称b是a的兄弟单词。例如,单词army和mary互为兄弟单词。现在要给出一种解决方案,对于用户输入的单词,根据给定的字典找出输入单词有哪些兄弟单词。
一般情况下,Trie树的结构都是采用26叉树进行组织的,每个结点对应一个字母,查找的时候,就是一个字母一个字母地进行匹配,算法的时间复杂度就是单词的长度n,效率很高。本例子可以定义一个Trie树作为数据结构来查询,此时就转化为在一棵Trie树中查找兄弟单词,只要在Trie树中的前缀中再存储一个vector结构的容器,就可以大大降低时间复杂度。
具体求解兄弟单词的程序代码如下:
#include <iostream>
#include <vector>
#include <string>
#include <stdlib.h>
using namespace std;
struct TrieNode
{
vector<string> bwords;
TrieNode *next[26];
TrieNode( )
{
for (int i = 0; i < 26; i++)
{
next[i] = NULL;
}
}
};
int CmpChar(const void *arg1, const void *arg2)
{
return (*(char *)arg1) - (*(char *)arg2);
}
void InsertNode(TrieNode **root, string wd)
{
if (wd.size( ) == 0)
{
return;
}
string t;
//VC6 的 string 拷贝构造函数采用引用计数,如果两个串一样的就不复制。为了使两个字符串指向的位置不一样
t += wd;
char *swd = const_cast<char*>(t.c_str( ));
qsort(swd, wd.size( ), sizeof(char), CmpChar);
if (*root == NULL)
{
*root = new TrieNode( );
}
int i = 0;
TrieNode * next = *root;
while (i < wd.size( ))
{
if (next->next[swd[i]-'a'] == NULL)
{
TrieNode * nn = new TrieNode( );
next->next[swd[i]-'a'] = nn;
}
next = next->next[swd[i]-'a'];
i++;
}
next->bwords.push_back(wd);
}
bool SearchNode(TrieNode *root, string wd)
{
char *swd = const_cast<char*>(wd.c_str( ));
qsort(swd, wd.size( ), sizeof(char), CmpChar);
int i = 0;
while (i < wd.size( ))
{
if (root->next[swd[i]-'a'] != NULL)
{
root = root->next[swd[i]-'a'];
i++;
}else{
break;
}
}
if (i == wd.size( ))
{
for (int j = 0; j < root->bwords.size( ); j++)
{
cout << root->bwords[j] << " ";
}
cout<<endl;
return true;
}
return false;
}
int main( )
{
TrieNode * root = new TrieNode;
InsertNode(&root, "hehao");
InsertNode(&root, "ehaoh");
InsertNode(&root, "haohe");
InsertNode(&root, "aoheh");
InsertNode(&root, "facri");
InsertNode(&root, "et");
SearchNode(root, "oheha");
return 0;
}
程序输出结果:
hehao ehaoh haohe aoheh
上例中,Trie树的构建是在预处理阶段完成的,首先根据字典中的单词来建立字典树,当建立完字典树后,查询兄弟单词的效率就会提高很多,比Hash法效率还要高。
Trie树适用数据量大、重复多,但是数据种类小可以放入内存的情况。例如,已知n(n很大)个由小写字母构成的平均长度为10的单词,判断其中是否存在某个字符串是另一个字符串的前缀子串。针对这种问题,一般可以采用以下3种方法。
(1)迭代法。
对于每一个单词,都要去查找它前面的单词中是否包含它,看每个字符串是否为字符串集中某个字符串的前缀,由于需要不停地进行迭代比较,所以此时的时间复杂度为O(n2)。
(2)Hash法。
使用Hash方法存储所有字符串的所有前缀子串。而建立存有子串Hash的时间复杂度为O(n*len),查询的复杂度为O(n)* O(1)= O(n)。
(3)Trie树。
假设要查询的单词是abcd,那么在它前面的单词中,以b、c、d、f之类开头的单词则不必考虑,而只要找以a开头的单词中是否存在abcd就可以了。同样,在以a开头的单词中,只要考虑以b作为第二个字母的单词即可,所以建立Trie树的复杂度为O(n*len),而建立操作与查询操作在Trie树中是可以同时执行的。所以,总的复杂度为O(n*len),实际查询的复杂度只是O(len)。例如,有串911,911456输入,如果要同时执行建立与查询,过程如下:首先查询911,没有;然后存入9、91、911,再查询911456,没有;然后存入9114、91145、911456,而程序没有记忆功能,并不知道911在输入数据中出现过,所以使用Hash必须先存入所有子串,然后for循环查询。而Trie树则可以,存入911后,已经记录911为出现的字符串,在存入911456的过程中就能发现而输出答案。反过来也可以,先存入911456,再存入911时,当指针指向最后一个1时,程序会发现这个1已经存在,说明911必定是某个字符串的前缀。
堆是一种树形数据结构,每个结点都有一个值,而通常所说的堆,一般是指二叉堆。在堆中,以大顶堆为例,堆的根结点的值最大,且根结点的两个子树也是一个大顶堆,基于以上特点,堆适用于海量数据求前N大(用小顶堆)或者前N小(用大顶堆)数问题,其中N一般比较小。例如,当求海量数据前N小的数据时,使用大顶堆,比较当前元素与大顶堆的最大元素(即堆顶元素),如果该元素小于最大元素,则应该替换该最大元素,并调整堆的结构(具体过程见13.5.7小节的内容)。当求海量数据前N大的数据时,思路一样。由于采用堆,只需要扫描一遍即可得到所有的前n元素,所以在海量信息处理中,效率非常高。
在海量数据处理中,堆的作用见表14-2。
表14-2 堆及其描述
描 述 |
堆 类 型 |
作 用 |
|
堆 |
求海量数据中前n大/小的值中位数 | 最大堆 |
求前n小 |
最小堆 |
求前n大 |
||
双堆 |
中位数 |
双层桶不是一种数据结构,而是一种算法思想,类似于分治思想。因为元素范围很大,不能利用直接寻址表,所以通过多次划分,逐步确定范围,最后在一个可以接受的范围内进行。
本文以桶排序进行分析,桶排序的基本思想是把[0,1)划分为n个大小相同的子区间,每一子区间是一个桶,然后将n个记录分配到各个桶中。因为关键字序列是均匀分布在[0,1)上的,所以一般不会有很多个记录落入同一个桶中。由于同一桶中的记录其关键字不尽相同,所以必须采用关键字比较的排序方法(通常用插入排序)对各个桶进行排序,然后依次将各非空桶中的记录连接(收集)起来即可。这种排序思想的前提是假设输入的n个关键字序列随机分布在区间[0,1)之上,若关键字序列的取值范围不是该区间,只要其取值均非负,总能将所有关键字除以某一合适的数,将关键字映射到该区间上,但要保证映射后的关键字是均匀分布在[0,1)上的。
桶排序的平均时间复杂度是O(n),最坏情况仍有可能是O(n2),一般只适用于关键字取值范围较小的情况,否则所需桶的数目m太多导致浪费存储空间和计算时间。例如,n=10,被排序的记录关键字ki取值范围是0~99之间的整数(36,5,16,98,95,47, 32,36,48)时,要用100个箱子来做一趟排序。
一个桶排序的实例如下:
#include <stdio.h>
#include <stdlib.h>
typedef struct node
{
int key;
node * next;
}KeyNode;
void IncSort(int keys[],int size,int bucketsize)
{
KeyNode **bucket_table=(KeyNode **)malloc(bucketsize*sizeof(KeyNode *));
for(int i=0;i<bucketsize;i++)
{
bucket_table[i]=(KeyNode *)malloc(sizeof(KeyNode));
bucket_table[i]->key=0; //记录当前桶中的数据量
bucket_table[i]->next=NULL;
}
for(int j=0;j<size;j++)
{
KeyNode *node=(KeyNode *)malloc(sizeof(KeyNode));
node->key=keys[j];
node->next=NULL;
int index=keys[j]/10;
KeyNode *p=bucket_table[index];
if(p->key==0)
{
bucket_table[index]->next=node;
(bucket_table[index]->key)++;
}
else
{
//链表结构的插入排序
while(p->next!=NULL&&p->next->key<=node->key)
p=p->next;
node->next=p->next;
p->next=node;
(bucket_table[index]->key)++;
}
}
//打印结果
for(int b=0;b<bucketsize;b++)
for(KeyNode *k=bucket_table[b]->next; k!=NULL; k=k->next)
printf("%d ",k->key);
printf("\n");
}
int main( )
{
int array[]={49,38,65,97,76,13,27,49};
int size=sizeof(array)/sizeof(int);
IncSort(array,size,10);
return 0;
}
程序输出结果:
13 27 38 49 49 65 76 97
桶排序一般适用于寻找第k大的数、寻找中位数、寻找不重复或重复的数字等情况。例如:
(1)在一个文件中有10亿个整数,乱序排列,要求找出中位数,内存限制为2GB。
(2)现在有一个0~30000的随机数生成器。请根据这个随机数生成器,设计一个抽奖范围是0~350000彩票中奖号码列表,其中要包含20000个中奖号码。
MapReduce是云计算的核心技术之一,是一种简化并行计算的分布式编程模型。它为并行系统的数据处理提供了一个简单、高效的解决方案,其主要目的是为了大型集群的系统能在大数据集上进行并行工作,并用于大规模数据的并行运算。
MapReduce适用于大规模数据集(通常大于1TB)的并行运算,它的核心操作是Map和Reduce,即MapReduce拆开为“Map(映射)”和“Reduce(化简)”。其中,Map函数独立地对每个元素进行操作,它用于把一组键值对映射成一组新的键值对,即先通过Map程序将数据切割成不相关的区块,分配(调度)给大量计算机处理达到分布计算的效果,然后通过指定并发的Reduce函数来将结果汇总,保证所有映射键值对中的每一个共享相同的键组。
简而言之,一个映射函数就是对一些独立元素组成的概念上的列表(如一个测试成绩的列表)的每一个元素进行指定的操作(例如,有人发现所有学生的成绩都被低估了一分,他可以定义一个“加1”的映射函数,用来修正这个错误)。而Map操作与Reduce操作都可以高度并行运行,Map是把一组数据一对一地映射为另外的一组数据,其映射的规则由一个函数来指定。例如,对[1,2,4,8]进行乘2的映射就变为[2,4,8,16],Reduce是对一组数据进行规约,这个规约的规则是由另外一个函数指定的。例如,对[1,2,4,8]进行求和规约得到的结果是15,而对它进行求积的规约是64。
通过MapReduce,不会分布式并行编程的程序员也能很容易地将自己的程序运行在分布式系统上。同时,通过该模型,能够充分高效地利用集群中每个机器的资源,适合在集群中处理大规模数据的计算任务,这些优点使得其已经成为云计算平台的主流编程模型。
在架构中,MapReduce API提供Map和Reduce处理、GFS分布式文件系统和BigTable分布式数据库提供数据存取。
面对海量数据的处理,分布式的计算方式会导致网络间大量频繁的数据交换,在这种情况下网络带宽相对属于稀缺资源。输入的数据存储在集群中机器的本地磁盘上,这样对有限的带宽来说是有利的。系统按照一个的大小划分数据段,原始文件被划分到各个数据段中。对每个数据段进行备份,分布在不同的机器上。管理机存储这些文件的位置信息,并安排处理这些文件或文件副本的映射任务。如果操作失败,管理机将重新安排映射任务给包含原始文件副本的工作执行。当在集群的工作站运行大型的MapReduce操作时,大部分输入数据都可以在本地读取,这样减小了对网络带宽的占用。
海量数据处理的最大难题在于数据规模巨大,使得传统处理方式面临计算能力不足和存储能力不足的瓶颈问题,而基于Hadoop可以非常轻松和方便地完成处理海量数据的分布式并行程序,并运行于大规模集群上。
原创文章,转载请注明: 转载自新书《程序员面试笔试宝典》官网
本文链接地址: 海量数据处理之基本方法