“技术的比拼,思维的碰撞——智慧擂台,给广大程序员展示智慧的舞台。”
高频词汇提取
——对统计纯文本中高频词程序的优化
文 / 王尧
 
 
高频词汇提取是一道很容易让人产生兴趣的题目,同时也是一道典型的计算机算法题目。主要涉及到“排序”和“搜索”两大经典课题。它的速度的快慢也取决于相应数据结构的设计。对于程序员的基本素质训练有很好的帮助。
 
这个算法可以分成“计数”和“排序”两个部分分别探讨。
先讨论“计数”的部分:让我们把问题简化一下,假定每个词汇由5个字母组成,共有26^5=11881376种可能的排列组合。可以定义一个int型数组,大小为11881376,占用内存大小约为47M,用来作为计数器。我们遍历一次文件,把当前位置处的5个字母作为int数组的索引(例如,这5个字母为abcde,可以用每个字母的ASCⅡ码减去97,得到序列0、1、2、3、4,再计算[4 + 3*26 + 2*(26^2) + 1*(26^3) + 0*(26^4)],得到19010即为索引值),根据这个索引,把数组中相关的项的值加1,遍历结束后,计数部分也就完成了。现在的问题在于我们所要计数的词汇并不限于5个字母,每增加一个字母,可能的排列组合数都要以指数方式增长,以仅仅8个字母为例,26^8约为208G,每个int型数据4字节,计数器所需内存为832G,这不是我们所能承受的。况且,词汇是由n个单词组成,我们并不知道每个单词包含多少个字母。于是,我们又回到起点重新思考计数的算法。
首先,我们肯定需要有一个计数器,计数器中的每一项都对应着一个词汇,计数器的大小必须是内存中能容纳得下的。而这是可以做到的,一个10M的文本,其中实际包含的短语种类,决不会超过10M,现代计算机的内存是可以满足大多数的文本的,问题只在于计数器的空间使用效率(当然,对于一个超大的文本,不但它本身的文件大小超过了内存容量,而且文件本身包含的词条数也超过了内存容量,可以分段读取文件并分段计数,每当计数器达到一定大小,就写入磁盘,然后使用新的计数器,最后把这些计数器综合起来。这相当于使用了缓存技术。操作系统本身也带有缓存机制,可以只管使用内存,把缓存的问题交给操作系统,但是对于我们的这个应用,操作系统的缓存机制未必是高效的。本文不展开这方面的讨论,只假定文件中的实际词条数,能够容纳在内存中)。计数器又必须是便于索引的,也就是说,对于文件中每个当前位置出现的由n个单词组成的词条,都能高效地在计数器中查找到相应的位置,以便于计数。最快的搜索算法莫过于使用哈希表,它的查找时间是固定的,而与词条的总量无关,我们前面提过的那个计数器实际上就是一个哈希表,计算索引的公式就是哈希函数,而每个数组元素就是哈希表中的一个“桶”,而且每个桶中最多只有一个项,是十分快速和理想的。但是既然内存是有限的,就必须考虑把哈希表的大小控制在能够承受的范围,比如,固定为前面说到的11881376,那么,当“可能的”词条数远远大于11M时,每个“桶”里就可能要容纳多个项目,然而一个11881376字节的文本文件,其实际的词条数不会超过11881376,如果哈希函数设计得好,能把各个“实际”词条均匀地分入各个桶中,每个桶中仍然只有一个项目,那搜索速度仍然是很快的,所以哈希函数是一个关键。我们自然而然对哈希函数的设计提出两个要求:1)不同的实际词条应尽量分入不同的桶中;避免许多不同的实际词条集中到某些桶中,而另一些是空桶,使搜索的效率降低。2)计算尽可能地简单,因为每次“插入”和“搜索”哈希表都要执行哈希函数,哈希函数的复杂度直接影响程序的速度。可以采用这样的方案:每个词条均匀地取出5个字节(比如词条包括十个字节,就从中取出第1、3、5、7、9个字节),按照前面的方法进行索引。少于5个字节的词条在后面用z补足(考虑到词条中包含空格,每个字节其实有27种可能的取值,所以哈希表的大小修改为27^5=14348907,空格的数值置为26,a~z的数值为0~25,忽略大小写)。提示:在内存够的情况下,哈希表可以设得更大,以减少每个桶中可能的词条数,提高时间效率。所以也可以设为几十M,并相应地修改一下哈希函数。
但是无论如何,我们只能期望每个桶中“一般来说”有一个词条。只要哈希表小于“可能的”词条数(前面分析过,这几乎是肯定的),我们仍然要面对一个“桶”中出现几个词条的情况,而且由于测试的文件的不同,我们不能事先预测一个桶中“最多”会达到几个词条。所以我们需要使用链表来放置这些无法确定数量的词条们。这里尤其需要注意的是,不应该采用每次增加词条时都临时申请内存的方式来实现链表,因为在系统的管理内存的机制中有所谓“最先适合法”、“最优适合法”之类的方法,或者是这些方法的混合,实现起来比较复杂,所以不应该把申请内存视为一项如加法一样快速的工作,频繁申请或释放内存肯定是比较低效率的做法。于是,我们需要开出第二块内存来存放这些不确定数量的词条们(且称之为内存B,而把哈希表称之为内存A)。这样哈希表中存放的既不是词条的内容,也不是词条的计数,而是指向这些词条的链接。每遇到一个新的词条,就顺序放入这块内存,并把它在这块内存中的位置(指针)计入哈希表的相应的桶中,如果相应的桶中已经有了一个指针,就顺着指针找到相应的项,把这项的后续指针指向新词条,这样,哈希表的每个非空的桶都有一个指针,指向内存B中的某一项,这项也带有一个指针,指向同一个桶中的第二项,以此类推,如果没有后续的项,就为NULL,这就是我们熟悉的哈希表的链式存储。为了避免频繁的申请内存,内存B可以尽量申请得大一些,但能容纳的项目数不需要比文件的字节数更大,因为,5M的文件绝不可能包含6M的词条,实际上,我们保守地估计,我们的程序可能要处理最大20M的文件,其中每个单词约4个字母,算上空格符和词条重复的因素,那么内存B能放4M个项目,大概是足够了,我们还可以用上动态内存的技术,当内存B不够用时,申请更大的内存(比如二倍大小),把数据拷过去,释放旧内存,再不够时可以考虑自己管理缓存(本文不展开讨论了)。现在来考虑哈希表内每个桶中的项如何查找,用链式的线性的数据结构,需要去遍历这个链,每次查找和插入的时间都和桶中的项的数目有关,这是个不经济的做法。改进的方法是使用二叉查找树,具体做法是这样:内存B中每一项都含有词条字符串、计数字段以及LLink和RLink两个指针,分别指向同一个桶中的一个比它小的项和比它大的项(或为空),对当前的词条应用哈希函数,找到哈希表中相应的桶,如果桶中有指针,就和指针对应的项(项A)去比较,相等就在这一项的计数字段上加1,大于就去比较这一项的RLink指向的项(项B)……,依此类推,如果小于项B,就把当前词条放在内存B已有的所有项的最后,并把项B的LLink指向它。《计算机程序设计》中对二叉查找树有过数学上的分析,对数据结构和查找算法熟悉的读者想必都知道:这种算法的插入和查找的时间和lgN相关(N为单棵树上的项目数,lg以2为底),是值得推荐的算法,某些极端情况下,二叉查找树会变成蜕化树(所有节点都只有最多一个子节点,于是成为变相的线性链表),但是发生的概率很小,有一种被称作“平衡树(AVL tree)”的算法,可以防止出现蜕化树,但是同时也降低了平均速度(因为算法变复杂了),只有在树的节点多于256时,才可能体现出优势,而由于我们在二叉查找树之前已经先使用了哈希表,所以“平衡树”也就不需要了。
另外,我们还需要一个内存块(内存C),以存放字符串,当我们把项目加入内存B的时候,要把词条字符串存入内存C(依次存放,并在字符串结尾放上“/0”),并在内存B的相应项里纪录下该字符串指针。之所以不把字符串放在内存B里,是因为词条的字节长短是可变的,而我们后面将对内存B进行排序算法,每个项目拥有相同的数据量,便于遍历和移动。内存C初始时也应该大一些(比如10M),可以像内存B一样应用动态内存的方法,它们的原则是相同的。
 
现在来讨论排序。本题的“排序”不同于传统意义上的排序,它只要求找出M个数量最多的短语,M个之外的短语间不需要排序,M个之内的短语间也不需要排序。这样,使用经典排序算法都有很多的冗余步骤。
我们可以从排序算法的基本思想开始思考。首先容易想到的是通过比较进行排序:我们把内存B上出现次数大于1的项目全选出来,排列到新内存上(内存D),在这个过程中记下最大的数i,如果内存D上的项目数大于M,就把内存D上大于i=(i/2)的项目排到B上(覆盖B即可),如果D上的项目不足M个,就从B上补足。如此交替使用内存B和D,每次挑出一部分数据,排除其余,不断地缩小范围,最终挑出M个最大的项目。如果最大的数为1024,这个算法的“搬家”次数不会超过lg1024=10次,每次搬家的项目数也会大批量地减少。
一个更好的改进是通过分布进行排序:把i十等分,把所有的项目按照次数分到这10个区域中,从次数大的区域开始,把各个区域的项目数逐个累加起来,直到大于等于M,如果等于M,成功结束,如果大于M,就把加上后刚好会大于M的这个区域中的项目拿出来划分……。这样最多需要划分log1024次(log以10为底)。用这个算法,一般的文件可能划分3到4次足矣。
 
题外话:其实就搜索高频词汇这个话题深入下去,还需要更多的考量,比如,文中出现最多的短语为“搜索高频词汇”,N为2,M为5,那排名前5位的是“搜索”、“索高”、“高频”、“频词”、“词汇”,其中第二和第四个显然是不合格的,我们需要一个大型的词库,即使是这样,语法分析之类的语言学知识也是必要的,比如:文中出现高频词“高丽人参”,我们显然不应该从中提取出“丽人”。但是作为“智慧擂台”的一道题目,我们就不去做更专业的探讨了。
 
Logo

20年前,《新程序员》创刊时,我们的心愿是全面关注程序员成长,中国将拥有新一代世界级的程序员。20年后的今天,我们有了新的使命:助力中国IT技术人成长,成就一亿技术人!

更多推荐