《程序员》官方BLOG

欢迎热心的读者随时与我们交流,您的支持是我们最大的动力!

用户操作
[即时聊天] [发私信] [加为好友]
《程序员》编辑ID:programmer_editor
1052001次访问,排名24好友0人,关注者72
programmer_editor的文章
原创 186 篇
翻译 1 篇
转载 3 篇
评论 1364 篇
最近评论
gms810:说的不错
davil_dev:我比较喜欢Firefox!
bluesz56788:有具体的出版日期进行发布吗。
kwlong2008:我是做网站建设的,来看看你的空间,不错,欢迎踩我的网站制作空间!
domemy:Linux 环境下的多核调试
— Intel + Totalview 强强联合!
目前,在软件开发行业,各种性能优异的调试工具层出不穷。但是,它们中的绝大部分都只支持windows环境。即使能支持linux平台,操作起来也很不方便。因此,对于长期在linux上编写程序的开发人员来说,如何调试就成了一个令人头痛的问题!Intel软件 和 Total……
文章分类
收藏
    相册
    《程序员》08年封面秀
    06年《程序员》封面秀
    07年《程序员》封面秀
    Logo
    编辑部合影
    调查分析图表
    模版
    文章用图
    新年祝福,名家寄语
    友情链接
    博文视点
    杂志订阅
    杂志订阅
    征文启事
    存档
    软件项目交易
    订阅我的博客
    XML聚合  FeedSky
    订阅到鲜果
    订阅到Google
    订阅到抓虾
    订阅到BlogLines
    订阅到Yahoo
    订阅到GouGou
    订阅到飞鸽
    订阅到Rojo
    订阅到newsgator
    订阅到netvibes

    原创 《程序员》06年9期智慧擂台 :高频词汇提取收藏

    新一篇: 2006年开源巨作,《开源大本营》整装待发 | 旧一篇: 基于Nios II和eCos的串口通信程序开发

     
    “技术的比拼,思维的碰撞——智慧擂台,给广大程序员展示智慧的舞台。”
    高频词汇提取
    ——对统计纯文本中高频词程序的优化
    / 王尧
     
     
    高频词汇提取是一道很容易让人产生兴趣的题目,同时也是一道典型的计算机算法题目。主要涉及到“排序”和“搜索”两大经典课题。它的速度的快慢也取决于相应数据结构的设计。对于程序员的基本素质训练有很好的帮助。
     
    这个算法可以分成“计数”和“排序”两个部分分别探讨。
    先讨论“计数”的部分:让我们把问题简化一下,假定每个词汇由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位的是“搜索”、“索高”、“高频”、“频词”、“词汇”,其中第二和第四个显然是不合格的,我们需要一个大型的词库,即使是这样,语法分析之类的语言学知识也是必要的,比如:文中出现高频词“高丽人参”,我们显然不应该从中提取出“丽人”。但是作为“智慧擂台”的一道题目,我们就不去做更专业的探讨了。
     

    发表于 @ 2006年09月29日 15:40:00|评论(loading...)|编辑

    新一篇: 2006年开源巨作,《开源大本营》整装待发 | 旧一篇: 基于Nios II和eCos的串口通信程序开发

    评论

    #Sean.Pu 发表于2006-09-29 16:55:00  IP: 58.60.185.*
    第一步“分词”,分词解决了就好办了
    #不懂 发表于2006-09-30 14:42:00  IP: 218.6.147.*
    看不懂,《。本题的“排序”不同于传统意义上的排序,它只要求找出M个数量最多的短语,M个之外的短语间不需要排序,M个之内的短语间也不需要排序。这样,使用经典排序算法都有很多的冗余步骤。〉之后不仅不懂,还糊涂
    #双杯献酒 发表于2006-09-30 15:01:00  IP: 222.128.57.*
    词条数也超过了内存容量

    这是不可能的,全世界的所有词汇加起来,也不会超过现在普通计算机内存。
    除非你在内存受限的嵌入设备上工作。

    #liuweni 发表于2006-09-30 16:15:00  IP: 220.167.24.*
    每期的题,我也想做,就是不知道在那里可以得到
    #过路人 发表于2006-10-01 14:00:00  IP: 60.63.196.*
    如果是中文或日文是不是就要重新设计算法了呀?那计数器要怎么设置呀,当然,如果是学生,可以考虑采用技术性很强的东西,如果是开发应用产品,这种思路恐怕是灾难性,应用软件现在要最主要的就是异维护和扩展
    #JohnBull 发表于2006-10-06 15:38:00  IP: 125.33.107.*
    #define TREEWIDTH 256

    typedef struct node_t {
    struct node_t *next[TREEWIDTH];
    int count;
    } node_t;

    /* 这个函数把一个word导入一个索引树,然后返回这个word以前曾经导入过多少次 */
    int
    indexword(node_t *head, const char *word)
    {
    int i;
    node_t *cur,*new;

    for (i=0,cur=head;;++i) {
    if (word[i]=='\0') {
    cur->count=cur->count+1;
    if (cur->count > 1) {
    return cur->count-1;
    }
    break;
    }
    if (cur->next[word[i]] == NULL) {
    cur->next[word[i]] = (node_t*)malloc(sizeof(node_t));
    if (cur->next[word[i]] == NULL) {
    return -1;
    }
    }
    cur=cur->next[word[i]];
    }

    return 0;
    }
    #lbaby 发表于2006-10-07 19:02:00  IP: 222.88.9.*
    唉,很正常嘛
    #shunan 发表于2006-12-04 22:59:33  IP: 60.166.123.*
    以前做过,不过当时没想到用查找树,自认为hash后那个冲突的链表不会很长,呵呵!
    #siluhoney 发表于2007-03-14 15:10:28  IP: 58.193.0.*
    我也看不懂
    #adio1987 发表于2007-04-28 16:06:56  IP: 218.93.112.*
    很抱歉,我是个新手,不懂!
    #sailissailing 发表于2007-06-02 14:03:08  IP: 60.219.183.*
    这个~~ 很~~
    Sorry!
    #wxcwf 发表于2007-06-22 17:21:00  IP: 218.57.175.*
    太长,建议减短
    #ykrl089 发表于2007-07-31 09:32:43  IP: 222.72.113.*
    大学的时候学过。
    不过这个东西不难,就是需要整合,把代码简单化
    #windlink 发表于2007-08-20 15:49:43  IP: 125.89.50.*
    这是我第一次看,是不是数据结构的啊?
    #rbf18 发表于2007-09-17 11:34:54  IP: 211.136.253.*
    看不懂
    #etzhong 发表于2007-10-03 21:13:58  IP: 220.164.17.*
    大学时学习过,不是很难。
    #etzhong 发表于2007-10-03 21:19:29  IP: 220.164.17.*
    大学时学习过,不是很难。
    #xuehong30899 发表于2007-12-22 13:58:05  IP: 122.194.175.*
    好贴!
    #xuehong30899 发表于2007-12-22 13:58:47  IP: 122.194.175.*
    请多关照
    #xuehong30899 发表于2007-12-22 14:01:37  IP: 122.194.175.*
    还有吗?太少了,也太简单了吗
    #qq282013257 发表于2007-12-24 19:18:08  IP: 125.74.54.*
    好! 我想考程序员! 请高手给我指点下..........
    #llwu 发表于2008-01-09 01:43:02  IP: 86.165.89.*
    如今这种题目拿给高中生都会做啊。请问大哥哥如果要你统计的文本好大好大怎么办呢,您内存ABCD重复的信息存了好多好多次哦,现在内存最大就4G耶,您一次能处理的文档能够多大啊,那我要做像百度叔叔那样的搜索引擎怎么办呢。不要告诉我服务器都是48G内存+24个CPU的啊,我的学校穷买不起滴!
    #haotianzhen 发表于2008-01-09 01:53:56  IP: 86.165.89.*
    如今这种题目拿给高中生都会做啊。请问大哥哥如果要你统计的文本好大好大怎么办呢,您内存ABCD重复的信息存了好多好多次哦,现在内存最大就4G耶,您一次能处理的文档能够多大啊,那我要做像百度叔叔那样的搜索引擎怎么办呢。不要告诉我服务器都是48G内存+24个CPU的啊,我的学校穷买不起滴!
    发表评论  


    当前用户设置只有注册用户才能发表评论。如果你没有登录,请点击登录
    Csdn Blog version 3.1a
    Copyright © 《程序员》编辑