程序员编程艺术第一~二十七章集锦与总结(教你如何编程)定稿版


1 程序员编程艺术第一~二十七章集锦与总结 (教你如何编程) 作者:July、编程艺术室。 时间:2011.04.14—2012.04.03。 出处:http://blog.csdn.net/v_JULY_v 声明:版权所有,侵权定究。 本文档制作者:有鱼网 www.youyur.com CEO 吴超 & 花明月暗 前言: 围绕“面试”、“算法”、“编程”三个主题的程序员编程艺术系列(简称 TAOPP 系列)从 今年 4 月写第一篇起,至今快有一年。近 1 年的创作中,写了二十七章,共计 22 篇文章。 这是本人的第 4 大原创作品,不过与之前微软面试 100 题系列,红黑树系列,及十三个经 典算法研究系列相比,编程艺术系列的某些篇文章的作者除了我本人自己,或多或少还得到 了不少朋友的支持,我把这些朋友组织起来,成立了一个工作室,它的名字叫做编程艺术室。 编程艺术系列最初名为程序员面试题狂想曲,即为面试服务,后来随着加入与我一起创 作的人越来越多,我们逐渐意识到,为面试服务不应该成为我们最终或最主要的目的,而应 该注重提高广大初学者的编程能力,以及如何运用编程技巧和高效的算法解决实际应用问 题。这才是计算机科学与编程的本质。于是,我们便把程序员面试题狂想曲系列更名为程序 员编程艺术系列,然后把狂想曲创作组确定为编程艺术室。并提出了我们的宗旨,即如下, 编程艺术室致力于以下三点工作: 1. 针对一个问题,不断寻找更高效的算法,并予以编程实现。 2. 解决实际中会碰到的应用问题。 3. 经典算法的研究与实现。 总体突出一点:编程,如何高效的编程解决实际问题。 刚开始的时候,我们是不敢给自己戴艺术这个高帽子的,因为艺术的提炼是一个非常非 常艰难的过程,且我们全部都是一群庸人。但是我们很想也非常乐意接受这个挑战。所以, 一边带着万分的惶恐,一边认真细心的创作每一章,等到发布后,再对每一章任何一个细节 2 仔细推敲与琢磨,反复思考,反复修正,反复完善,绝不轻易放过任何一个问题,漏洞,和 bug。但即便如此,仍然冒出了很多的问题。幸运的是,有广大的读者朋友们对编程艺术系 列和我们给予热心的指导与优化建议,更重要的是他们还耐心细致的对编程艺术系列提出了 非常多的且异常宝贵的批评指正与修订完善的意见。 没有编程艺术室全部人员的加入创作,编程艺术系列将比现在所呈现在大家面前的还要 糟糕(至少我个人现在是这么认为的),而如果没有众多网友,朋友们的修正与完善,编程 艺术系列将更显不足,从而失去它本身该有的持久动力与明天。所以,非常感谢所有热心的 朋友给予编程艺术系列所有的指导和意见,你们的反馈给了我们的创作很大很大的帮助,同 时,也感谢本社区编辑的推荐。非常感谢。最后,恳请广大读者对编程艺术系列继续监督, 并随时予以批评指正(我们不能残留任何一个 bug)。因为编程艺术系列最后可能要写到第 六十章。谢谢。 ok,以下是已经写了的编程艺术系列的前二十七章,共 21 篇文章,希望你能从中感受到 编程的技巧与乐趣(点击链接,即可跳转到相应页面): 无私分享,造福天下 第一章、左旋转字符串 ....................................................................................................... 4 第二章、字符串是否包含及相关问题扩展 ..................................................................... 34 第三章、寻找最小的 k 个数 ............................................................................................. 63 第三章续、Top K 算法问题的实现 ................................................................................. 107 第三章再续:快速选择 SELECT 算法的深入分析与实现 .............................................. 148 第三章三续、求数组中给定下标区间内的第 K 小(大)元素 ................................... 167 第四章、现场编写类似 strstr/strcpy/strpbrk 的函数 .................................................... 178 第五章、寻找满足条件的两个或多个数 ....................................................................... 195 第六章、亲和数问题--求解 500 万以内的亲和数 ......................................................... 208 第七章、求连续子数组的最大和 ................................................................................... 215 第八章、从头至尾漫谈虚函数 ....................................................................................... 222 第九章、闲话链表追赶问题 ........................................................................................... 237 第十章、如何给 10^7 个数据量的磁盘文件排序 ......................................................... 246 第十一章:最长公共子序列(LCS)问题 ........................................................................... 280 第十二~十五章:中签概率,IP 访问次数,回文等问题(初稿) ............................. 291 第十六~第二十章:全排列,跳台阶,奇偶排序,第一个只出现一次等问题 ......... 305 3 第二十一~二十二章:出现次数超过一半的数字,最短摘要的生成 ......................... 322 第二十三、四章:杨氏矩阵查找,倒排索引关键词 Hash 不重复编码实践 ............. 340 第二十五章:二分查找实现(Jon Bentley:90%程序员无法正确实现) .................. 363 第二十六章:基于给定的文档生成倒排索引的编码与实践 ....................................... 366 第二十七章:不改变正负数之间相对顺序重新排列数组.时间 O(N),空间 O(1) ...... 393 编程艺术室 编程艺术系列已经发布的上二十七章,仍有很多很多的问题与不足,但永久勘误,永久 优化。如果读者朋友对编程艺术系列任何一章有任何问题,和建议,或者发现了以上任何一 章的问题,错误,漏洞,和 bug,欢迎及时反馈给我们,我们将感激不尽。当然,如果有兴 趣,我们也欢迎您加入我们--编程艺术室: 1. 编程能力较强 2. 有一定的业余时间 3. 工作经验越长越好(能力出众的在读研究生或 ACM 人员也可以考虑) 4. 愿意分享平时工作中的项目经验或性能优化建议 5. 热爱算法者优先。 符合以上条件的朋友欢迎加入编程艺术室。有意者,可给我:zhoulei0907@yahoo.cn 投一 篇稿子(稿子主题,内容,形式不限,只要你觉得自己能写出像已有的 27 章那样的文章便 可投稿),审阅之后,邀请加入,以为大家创造更多的价值,更好的服务。谢谢 版权所有,侵权必究。严禁用于任何商业用途,违者定究法律责任。 特别提醒: 本编程艺术系列文档中有任何一处错误,bug,或漏洞,读者朋友一经发现,欢迎随时 来信指导,或 blog 内留言,评论,我将感激不尽。 我的联系方式如下: 邮箱:zhoulei0907@yahoo.cn 微博:http://weibo.com/julyweibo Blog:http://blog.csdn.net/v_JULY_v。 本结构之法算法之道 blog,若无意外,永久更新,永久勘误。 感谢关注本编程艺术系列的读者朋友们,感谢所有编程艺术室内的朋友,咱们 blog 上 第二十八章再见。 4 第一章、左旋转字符串 作者:July,yansha。 时间:二零一一年四月十四日。 说明:(狂想曲,有三层意思:1、思绪纷飞,行文杂乱无章,想到什么,记下什么。2、简单问题深入 化,复杂问题精细化,不惧汪洋,不惧艰深,洋洋洒洒,纵横千里。3、依托一道面试题展开来,思维放任 不羁,逐步深入,细致入微,反复修正,绝不含糊,以期给读者一个彻彻底底明明白白的交待)原为狂想 曲,现在已改为编程艺术系列。 微博:http://weibo.com/julyweibo。 出处:http://blog.csdn.net/v_JULY_v。 ------------------------------------------- 目录 序 前言 第一节、左旋转字符串 第二节、两个指针逐步翻转 第三节、通过递归转换,缩小问题之规模 第四节、stl::rotate 算法的步步深入 第五节、总结 序 一个懒散的午夜,程序员躺在椅子上,静静点上一支烟,瞅着屏幕上那一行一行如行云 流水般的代码,赏心悦目,渐觉困意,便慢慢闭上了俩眼,养神...。 而后,冥冥中房间里似缓缓响起一首钢琴曲,叫不出名字,却铿锵有力且清脆无比,忽 而激荡,忽而平静。激荡处,如波涛翻滚,怒洋咆哮,平静处,如潺潺流水,鸟语花香。 半响,程序员突然睁开双眼,关掉编译器,打开记事本,信笔由缰,急速记录下他那杂 乱无章,和奇特跳跃的思绪,他怕此刻不赶紧记下来,以后,风吹云散.....于是,世间就有 了,程序员面试题狂想曲一乐章的诞生。 此曲终日弹奏,绵绵不绝,终至广为流传,飘进了千万人的耳中,余音不去.... 前言 5 本人整理微软等公司面试 100 题系列,包括原题整理,资源上传,帖子维护,答案整理, 勘误,修正与优化工作,包括后续全新整理的 80 道,总计 180 道面试题,已有半年的时间 了。 关于这 180 道面试题的一切详情,请参见:横空出世,席卷 Csdn [评微软等数据结构+ 算法面试 180 题]。 一直觉得,这 180 道题中的任何一题都值得自己反复思考,反复研究,不断修正,不断 优化。之前的答案整理由于时间仓促,加之受最开始的认识局限,更兼水平有限,所以,这 180 道面试题的答案,有很多问题都值得进一步商榷与完善。 特此,想针对这 180 道面试题,再写一个系列,叫做:程序员编程艺术系列。如你所见, 我一般确定要写成一个系列的东西,一般都会永久写下去的。 “他似风儿一般奔跑,很多人渐渐的停下来了,而只有他一直在飞,一直在飞....” ok,本次程序员编程艺术系列以之前本人最初整理的微软面试 100 题中的第 26 题、左 旋转字符串,为开篇,希望就此问题进行彻底而深入的阐述。然以下所有任何代码仅仅只是 全部测试正确了而已,还有很多的优化工作要做。欢迎任何人,不吝赐教。谢谢。 第一节、左旋转字符串 题目描述: 定义字符串的左旋转操作:把字符串前面的若干个字符移动到字符串的尾部。 如把字符串 abcdef 左旋转 2 位得到字符串 cdefab。 请实现字符串左旋转的函数,要求对长度为 n 的字符串操作的时间复杂度为 O(n),空间复杂度为 O(1)。 编程之美上有这样一个类似的问题,咱们先来看一下: 设计一个算法,把一个含有 N 个元素的数组循环右移 K 位,要求时间复杂度为 O(N), 且只允许使用两个附加变量。 分析: 6 我们先试验简单的办法,可以每次将数组中的元素右移一位,循环 K 次。 abcd1234→4abcd123→34abcd12→234abcd1→1234abcd。 RightShift(int* arr, int N, int K) { while(K--) { int t = arr[N - 1]; for(int i = N - 1; i > 0; i --) arr[i] = arr[i - 1]; arr[0] = t; } } 虽然这个算法可以实现数组的循环右移,但是算法复杂度为 O(K * N),不符合题目的要 求,要继续探索。 假如数组为 abcd1234,循环右移 4 位的话,我们希望到达的状态是 1234abcd。 不妨设 K 是一个非负的整数,当 K 为负整数的时候,右移 K 位,相当于左移(-K)位。 左移和右移在本质上是一样的。 解法一: 大家开始可能会有这样的潜在假设,K N,右移 K-N 之后的数组序列跟右移 K 位的结果是一样的。 进而可得出一条通用的规律: 右移 K 位之后的情形,跟右移 K’= K % N 位之后的情形一样,如代码清单 2-34 所示。 //代码清单 2-34 RightShift(int* arr, int N, int K) { K %= N; while(K--) { 7 int t = arr[N - 1]; for(int i = N - 1; i > 0; i --) arr[i] = arr[i - 1]; arr[0] = t; } } 可见,增加考虑循环右移的特点之后,算法复杂度降为 O(N^2),这跟 K 无关,与题目 的要求又接近了一步。但时间复杂度还不够低,接下来让我们继续挖掘循环右移前后,数组 之间的关联。 解法二: 假设原数组序列为 abcd1234,要求变换成的数组序列为 1234abcd,即循环右移了 4 位。 比较之后,不难看出,其中有两段的顺序是不变的:1234 和 abcd,可把这两段看成两个整 体。右移 K 位的过程就是把数组的两部分交换一下。 变换的过程通过以下步骤完成: 逆序排列 abcd:abcd1234 → dcba1234; 逆序排列 1234:dcba1234 → dcba4321; 全部逆序:dcba4321 → 1234abcd。 伪代码可以参考清单 2-35。 //代码清单 2-35 Reverse(int* arr, int b, int e) { for(; b < e; b++, e--) { int temp = arr[e]; arr[e] = arr[b]; arr[b] = temp; } } RightShift(int* arr, int N, int k) { K %= N; Reverse(arr, 0, N – K - 1); Reverse(arr, N - K, N - 1); 8 Reverse(arr, 0, N - 1); } 这样,我们就可以在线性时间内实现右移操作了。 稍微总结下: 编程之美上, (限制书中思路的根本原因是,题目要求:“且只允许使用两个附加变量”,去掉这个限制, 思路便可如泉喷涌) 1、第一个想法 ,是一个字符一个字符的右移,所以,复杂度为 O(N*K) 2、后来,它改进了,通过这条规律:右移 K 位之后的情形,跟右移 K’= K % N 位之后的情 形一样 复杂度为 O(N^2) 3、直到最后,它才提出三次翻转的算法,得到线性复杂度。 下面,你将看到,本章里我们的做法是: 1、三次翻转,直接线性 2、两个指针逐步翻转,线性 3、stl 的 rotate 算法,线性 好的,现在,回到咱们的左旋转字符串的问题中来,对于这个左旋转字符串的问题,咱们可 以如下这样考虑: 1.1、思路一: 对于这个问题,咱们换一个角度,可以这么做: 将一个字符串分成两部分,X 和 Y 两个部分,在字符串上定义反转的操作 X^T,即把 X 的 所有字符反转(如,X="abc",那么 X^T="cba"),那么我们可以得到下面的结论: (X^TY^T)^T=YX。显然我们这就可以转化为字符串的反转的问题了。 不是么?ok,就拿 abcdef 这个例子来说(非常简短的三句,请细看,一看就懂): 1、首先分为俩部分,X:abc,Y:def; 2、X->X^T,abc->cba, Y->Y^T,def->fed。 3、(X^TY^T)^T=YX,cbafed->defabc,即整个翻转。 我想,这下,你应该了然了。 然后,代码可以这么写(已测试正确): 1. //Copyright@ 小桥流水 && July 9 2. //c 代码实现,已测试正确。 3. //http://www.smallbridge.co.cc/2011/03/13/100%E9%A2%98 4. //_21-%E5%B7%A6%E6%97%8B%E8%BD%AC%E5%AD%97%E7%AC%A6%E4%B8%B2.html 5. //July、updated,2011.04.17。 6. #include 7. #include 8. 9. char * invert(char *start, char *end) 10. { 11. char tmp, *ptmp = start; 12. while (start != NULL && end != NULL && start < end) 13. { 14. tmp = *start; 15. *start = *end; 16. *end = tmp; 17. start ++; 18. end --; 19. } 20. return ptmp; 21. } 22. 23. char *left(char *s, int pos) //pos 为要旋转的字符个数,或长度,下面主函数测试中, pos=3。 24. { 25. int len = strlen(s); 26. invert(s, s + (pos - 1)); //如上,X->X^T,即 abc->cba 27. invert(s + pos, s + (len - 1)); //如上,Y->Y^T,即 def->fed 28. invert(s, s + (len - 1)); //如上,整个翻转,(X^TY^T)^T=YX, 即 cbafed->defabc。 29. return s; 30. } 31. 32. int main() 33. { 34. char s[] = "abcdefghij"; 35. puts(left(s, 3)); 36. return 0; 37. } 1.2、答案 V0.3 版中,第 26 题勘误: 之前的答案 V0.3 版[第 21-40 题答案]中,第 26 题、贴的答案有误,那段代码的问题,最 早是被网友 Sorehead 给指出来的: 10 第二十六题: 楼主的思路确实很巧妙,我真没想到还有这种方法,学习了。 不过楼主代码中存在问题,主要是条件判断部分: 函数 LeftRotateString 中 if (nLength > 0 || n == 0 || n > nLength) 函数 ReverseString 中 if (pStart == NULL || pEnd == NULL) 当时,以答案整理因时间仓促,及最开始考虑问题不够周全为由,没有深入细看下去。 后来,朋友达摩流浪者再次指出了上述代码的问题: 26 题 这句 if(nLength > 0 || n == 0 || n > nLength),有问题吧? 还有一句,应该是 if(!(pStart == NULL || pEnd == NULL)),吧。 而后,修改如下(已测试正确) 1. //zhedahht 2. //July、k,updated 3. //copyright @2011.04.14,by July。 4. //引用,请注明原作者,出处。 5. #include 6. #include 7. using namespace std; 8. 9. void Swap(char* a,char* b) //特此把交换函数,独立抽取出来。当然,不排除会 有人认为,此为多此一举。 10. { 11. char temp =*a; 12. *a = *b; 13. *b = temp; 14. } 15. 16. // Reverse the string between pStart and pEnd 17. void ReverseString(char* pStart, char* pEnd) 18. { 19. if(*pStart != '/0' && *pEnd != '/0') 20. //这句也可以是:if(pStart != NULL && pEnd != NULL)。 21. { 22. while(pStart <= pEnd) 23. { 24. Swap(pStart,pEnd); //交换 25. 26. pStart++; 27. pEnd--; 11 28. } 29. } 30. } 31. 32. // Move the first n chars in a string to its end 33. char* LeftRotateString(char* pStr, unsigned int n) 34. { 35. if(pStr != NULL) 36. { 37. int nLength = static_cast(strlen(pStr)); 38. if(nLength >0 && n != 0 && nnLength,当然, 就是错的了。July、k,updated。 41. { 42. char* pFirstStart = pStr; 43. char* pFirstEnd = pStr + n - 1; 44. char* pSecondStart = pStr + n; 45. char* pSecondEnd = pStr + nLength - 1; 46. 47. // reverse the first part of the string 48. ReverseString(pFirstStart, pFirstEnd); 49. // reverse the second part of the strint 50. ReverseString(pSecondStart, pSecondEnd); 51. // reverse the whole string 52. ReverseString(pFirstStart, pSecondEnd); 53. } 54. } 55. return pStr; 56. } 57. 58. int main() 59. { 60. char a[11]="hello July"; //2、修正,以一个数组实现存储整个字符串 61. char *ps=a; 62. LeftRotateString(ps, 6); 63. for(;*ps!='/0';ps++) 64. cout<<*ps; 65. cout<0 && n 5. #include 6. 7. void rotate(char *start, char *end) 8. { 9. while(start != NULL && end !=NULL && start0&&m<=len) 26. { 27. char *xfirst,*xend; 28. char *yfirst,*yend; 29. xfirst=p; 30. xend=p+m-1; 31. yfirst=p+m; 32. yend=p+len-1; 33. rotate(xfirst,xend); 34. rotate(yfirst,yend); 35. rotate(p,p+len-1); 36. } 37. } 38. 39. int main(void) 40. { 41. char str[]="abcdefghij"; 42. leftrotate(str,3); 43. printf("%s/n",str); 44. return 0; 45. } 第二节、两指针逐步翻转 先看下网友 litaoye 的回复:26.左旋转字符串跟 panda 所想,是一样的,即, 以 abcdef 为例 1. ab->ba 2. cdef->fedc 原字符串变为 bafedc 3. 整个翻转:cdefab //只要俩次翻转,且时间复杂度也为 O(n)。 14 2.1、在此,本人再奉献另外一种思路,即为本思路二: abc defghi,要 abc 移动至最后 abc defghi->def abcghi->def ghiabc 定义俩指针,p1 指向 ch[0],p2 指向 ch[m]; 一下过程循环 m 次,交换 p1 和 p2 所指元素,然后 p1++, p2++;。 第一步,交换 abc 和 def , abc defghi->def abcghi 第二步,交换 abc 和 ghi, def abcghi->def ghiabc 整个过程,看起来,就是 abc 一步一步 向后移动 abc defghi def abcghi def ghi abc //最后的 复杂度是 O(m+n) 以下是朋友颜沙针对上述过程给出的图解: 15 2.2、各位读者注意了: 由上述例子九个元素的序列 abcdefghi,您已经看到,m=3 时,p2 恰好指到了数组最后 一个元素,于是,上述思路没有问题。但如果上面例子中 i 的后面还有元素列? 即,如果是要左旋十个元素的序列:abcdefghij,ok,下面,就举这个例子,对 abcdefghij 序列进行左旋转操作: 如果 abcdef ghij 要变成 defghij abc: abcdef ghij 1. def abc ghij 2. def ghi abc j //接下来,j 步步前移 3. def ghi ab jc 4. def ghi a j bc 5. def ghi j abc 下面,再针对上述过程,画个图清晰说明下,如下所示: 16 ok,咱们来好好彻底总结一下此思路二:(就 4 点,请仔细阅读): 1、首先让 p1=ch[0],p2=ch[m],即让 p1,p2 相隔 m 的距离; 2、判断 p2+m-1 是否越界,如果没有越界转到 3,否则转到 4(abcdefgh 这 8 个字母的字符 串,以 4 左旋,那么初始时 p2 指向 e,p2+4 越界了,但事实上 p2 至 p2+m-1 是 m 个字符, 可以再做一个交换)。 3、不断交换*p1 与*p2,然后 p1++,p2++,循环 m 次,然后转到 2。 17 4、此时 p2+m-1 已经越界,在此只需处理尾巴。过程如下: 4.1 通过 n-p2 得到 p2 与尾部之间元素个数 r,即我们要前移的元素个数。 4.2 以下过程执行 r 次: ch[p2]<->ch[p2-1],ch[p2-1]<->ch[p2-2],....,ch[p1+1]<->ch[p1];p1++; p2++; (特别感谢 tctop 组成员 big 的指正,tctop 组的修订 wiki 页面为: http://tctop.wikispaces.com/) 所以,之前最初的那个左旋转九个元素 abcdefghi 的思路在末尾会出现问题的(如 果 p2 后面有元素就不能这么变,例如,如果是处理十个元素,abcdefghij 列?对的,就是 这个意思),解决办法有两个: 方法一(即如上述思路总结所述): def ghi abc jk 当 p1 指向 a,p2 指向 j 时,由于 p2+m 越界,那么此时 p1,p2 不要变 这里 p1 之后(abcjk)就是尾巴,处理尾巴只需将 j,k 移到 abc 之前,得到最终序列,代码 编写如下: 1. //copyright@July、颜沙 2. //最终代码,July,updated again,2011.04.17。 3. #include 4. #include 5. using namespace std; 6. 7. void rotate(string &str, int m) 8. { 9. 10. if (str.length() == 0 || m <= 0) 11. return; 12. 13. int n = str.length(); 14. 15. if (m % n <= 0) 16. return; 17. 18. int p1 = 0, p2 = m; 19. int k = (n - m) - n % m; 20. 21. // 交换 p1,p2 指向的元素,然后移动 p1,p2 22. while (k --) 23. { 24. swap(str[p1], str[p2]); 18 25. p1++; 26. p2++; 27. } 28. 29. // 重点,都在下述几行。 30. // 处理尾部,r 为尾部左移次数 31. int r = n - p2; 32. while (r--) 33. { 34. int i = p2; 35. while (i > p1) 36. { 37. swap(str[i], str[i-1]); 38. i--; 39. } 40. p2++; 41. p1++; 42. } 43. //比如一个例子,abcdefghijk 44. // p1 p2 45. //当执行到这里时,defghi a b c j k 46. //p2+m 出界 了, 47. //r=n-p2=2,所以以下过程,要执行循环俩次。 48. 49. //第一次:j 步步前移,abcjk->abjck->ajbck->jabck 50. //然后,p1++,p2++,p1 指 a,p2 指 k。 51. // p1 p2 52. //第二次:defghi j a b c k 53. //同理,此后,k 步步前移,abck->abkc->akbc->kabc。 54. } 55. 56. int main() 57. { 58. string ch="abcdefghijk"; 59. rotate(ch,3); 60. cout< 2. #include 3. using namespace std; 4. 5. //颜沙,思路二之方案二, 6. //July、updated,2011.04.16。 7. void rotate(string &str, int m) 8. { 9. if (str.length() == 0 || m < 0) 10. return; 11. 12. //初始化 p1,p2 13. int p1 = 0, p2 = m; 14. int n = str.length(); 15. 16. // 处理 m 大于 n 17. if (m % n == 0) 18. return; 19. 20. // 循环直至 p2 到达字符串末尾 21. while(true) 22. { 23. swap(str[p1], str[p2]); 24. p1++; 25. if (p2 < n - 1) 26. p2++; 27. else 28. break; 29. } 30. 31. // 处理尾部,r 为尾部循环左移次数 32. int r = m - n % m; // r = 1. 33. while (r--) //外循环执行一次 34. { 35. int i = p1; 36. char temp = str[p1]; 20 37. while (i < p2) //内循环执行俩次 38. { 39. str[i] = str[i+1]; 40. i++; 41. } 42. str[p2] = temp; 43. } 44. //举一个例子 45. //abcdefghijk 46. //当执行到这里的时候,defghiabcjk 47. // p1 p2 48. //defghi a b c j k,a 与 j 交换,jbcak,然后,p1++,p2++ 49. // p1 p2 50. // j b c a k,b 与 k 交换,jkcab,然后,p1++,p2 不动, 51. 52. //r = m - n % m= 3-11%3=1,即循环移位 1 次。 53. // p1 p2 54. // j k c a b 55. //p1 所指元素 c 实现保存在 temp 里, 56. //然后执行此条语句:str[i] = str[i+1]; 即 a 跑到 c 的位置处,a_b 57. //i++,再次执行:str[i] = str[i+1],ab_ 58. //最后,保存好的 c 填入,为 abc,所以,最终序列为 defghi jk abc。 59. //July、updated,2011.04.17 晚,送走了她。 60. } 61. 62. int main() 63. { 64. string ch="abcdefghijk"; 65. rotate(ch,3); 66. cout< 8. #include 9. #define positiveMod(m,n) ((m) % (n) + (n)) % (n) 10. 11. /* 12. *左旋字符串 str,m 为负数时表示右旋 abs(m)个字母 13. */ 14. void rotate(std::string &str, int m) { 15. if (str.length() == 0) 16. return; 17. int n = str.length(); 18. //处理大于 str 长度及 m 为负数的情况,positiveMod 可以取得 m 为负数时对 n 取 余得到正数 19. m = positiveMod(m,n); 20. if (m == 0) 21. return; 22. // if (m % n <= 0) 23. // return; 24. int p1 = 0, p2 = m; 25. int round; 26. //p2 当前所指和之后的 m-1 个字母共 m 个字母,就可以和 p2 前面的 m 个字母交 换。 27. while (p2 + m - 1 < n) { 28. round = m; 29. while (round--) { 30. std::swap(str[p1], str[p2]); 31. p1++; 32. p2++; 33. } 34. } 35. //剩下的不足 m 个字母逐个交换 36. int r = n - p2; 37. while (r--) { 38. int i = p2; 39. while (i > p1) { 40. std::swap(str[i], str[i - 1]); 41. i--; 42. } 43. p2++; 44. p1++; 45. } 46. } 47. 48. //测试 22 49. int main(int argc, char **argv) { 50. // std::cout << ((-15) % 7 + 7) % 7 << std::endl; 51. // std::cout << (-15) % 7 << std::endl; 52. std::string ch = "abcdefg"; 53. int len = ch.length(); 54. for (int m = -2 * len; m <= len * 2; m++) { 55. //由于传给 rotate 的是 string 的引用,所以这里每次调用都用了一个新的 字符串 56. std::string s = "abcdefg"; 57. rotate(s, m); 58. std::cout << positiveMod(m,len) << ": " << s << std::endl; 59. } 60. 61. return 0; 62. } 第三节、通过递归转换,缩小问题之规模 本文最初发布时,网友留言 bluesmic 说:楼主,谢谢你提出的研讨主题,很有学术和实 践价值。关于思路二,本人提一个建议:思路二的代码,如果用递归的思想去简化,无论代 码还是逻辑都会更加简单明了。 就是说,把一个规模为 N 的问题化解为规模为 M(M"abc123defg"->"abcdef123g"。这时,"123"无法和"g"作对调,该问题递归 转化为:将“123g”右旋转为"g123",即总长度为 4,旋转部("g")长度为 1 的右旋转。 updated: Ys: 23 Bluesmic 的思路没有问题,他的思路以前很少有人提出。思路是通过递归 将问题规模变小。当字符串总长度为 n,左侧要旋转的部分长度为 m,那么当从 左向右循环交换长度为 m 的小段直到剩余部分为 m’(n % m),此时 m’ < m, 已不能直接交换了。 此后,我们换一个思路,把该问题递归转化成规模大小为 m’ +m,方向相反 的同一问题。随着递归的进行,直到满足结束条件 n % m==0。 举个具体事例说明,如下: 1、对于字符串 abc def ghi gk, 将 abc 右移到 def ghi gk 后面,此时 n = 11,m = 3,m’ = n % m = 2; abc def ghi gk -> def ghi abc gk 2、问题变成 gk 左移到 abc 前面,此时 n = m’ + m = 5,m = 2, m’ = n % m 1; abc gk -> a gk bc 3、问题变成 a 右移到 gk 后面,此时 n = m’ + m = 3,m = 1, m’ = n % m = 0; a gk bc-> gk a bc。 由于此刻,n % m = 0,满足结束条件,返回结果。 即从左至右,后从右至左,再从左至右,如此反反复复,直到满足条件, 返回退出。 代码如下,已测试正确(有待优化): 1. //递归, 2. //感谢网友 Bluesmic 提供的思路 3. 4. //copyright@ yansha 2011.04.19 5. //July,updated,2011.04.20. 6. #include 7. using namespace std; 8. 9. void rotate(string &str, int n, int m, int head, int tail, bool flag) 10. { 11. //n 待处理部分的字符串长度,m:待处理部分的旋转长度 12. //head:待处理部分的头指针,tail:待处理部分的尾指针 13. //flag = true 进行左旋,flag = false 进行右旋 14. 15. // 返回条件 16. if (head == tail || m <= 0) 24 17. return; 18. 19. if (flag == true) 20. { 21. int p1 = head; 22. int p2 = head + m; //初始化 p1,p2 23. 24. //1、左旋:对于字符串 abc def ghi gk, 25. //将 abc 右移到 def ghi gk 后面,此时 n = 11,m = 3,m’ = n % m = 2; 26. //abc def ghi gk -> def ghi abc gk 27. //(相信,经过上文中那么多繁杂的叙述,此类的转换过程,你应该是了如指掌了。) 28. 29. int k = (n - m) - n % m; //p1,p2 移动距离,向右移六步 30. 31. /*--------------------- 32. 解释下上面的 k = (n - m) - n % m 的由来: 33. yansha: 34. 以 p2 为移动的参照系: 35. n-m 是开始时 p2 到末尾的长度,n%m 是尾巴长度 36. (n-m)-n%m 就是 p2 移动的距离 37. 比如 abc def efg hi 38. 开始时 p2->d,那么 n-m 为 def efg hi 的长度 8, 39. n%m 为尾巴 hi 的长度 2, 40. 因为我知道 abc 要移动到 hi 的前面,所以移动长度是 41. (n-m)-n%m = 8-2 = 6。 42. */ 43. 44. for (int i = 0; i < k; i++, p1++, p2++) 45. swap(str[p1], str[p2]); 46. 47. rotate(str, n - k, n % m, p1, tail, false); //flag 标志变为 false,结 束左旋,下面,进入右旋 48. } 49. else 50. { 51. //2、右旋:问题变成 gk 左移到 abc 前面,此时 n = m’ + m = 5,m = 2, m’ = n % m 1; 52. //abc gk -> a gk bc 53. 54. int p1 = tail; 55. int p2 = tail - m; 56. 57. // p1,p2 移动距离,向左移俩步 58. int k = (n - m) - n % m; 25 59. 60. for (int i = 0; i < k; i++, p1--, p2--) 61. swap(str[p1], str[p2]); 62. 63. rotate(str, n - k, n % m, head, p1, true); //再次进入上面的左旋部 分, 64. //3、左旋:问题变成 a 右移到 gk 后面,此时 n = m’ + m = 3,m = 1, m’ = n % m = 0; 65. //a gk bc-> gk a bc。 由于此刻,n % m = 0,满足结束条件,返回结果。 66. 67. } 68. } 69. 70. int main() 71. { 72. int i=3; 73. string str = "abcdefghijk"; 74. int len = str.length(); 75. rotate(str, len, i % len, 0, len - 1, true); 76. cout << str.c_str() << endl; //转化成字符数组的形式输出 77. return 0; 78. } 非常感谢。 稍后,由下文,您将看到,其实上述思路二的本质即是下文将要阐述的 stl rotate 算法, 详情,请继续往下阅读。 第四节、stl::rotate 算法的步步深入 思路三: 3.1、数组循环移位 下面,我将再具体深入阐述下此 STL 里的 rotate 算法,由于 stl 里的 rotate 算法,用到 了 gcd 的原理,下面,我将先介绍此辗转相除法,或欧几里得算法,gcd 的算法思路及原 理。 gcd,即辗转相除法,又称欧几里得算法,是求最大公约数的算法,即求两个正整数之最 大公因子的算法。此算法作为 TAOCP 第一个算法被阐述,足见此算法被重视的程度。 26 gcd 算法:给定俩个正整数 m,n(m>=n),求它们的最大公约数。(注意,一般要求 m>=n,若 mn。下文,会具体解释)。以下,是此算法的具体流程: 1、[求余数],令 r=m%n,r 为 n 除 m 所得余数(0<=rr=m%n=119%68=51; 置 m<-68,n<-51,=>r=m%n=68%51=17; 置 m<-51,n<-17,=>r=m%n=51%17=0,算法结束, 此时的 n=17,即为 m=544,n=119 所求的俩个数的最大公约数。 再解释下上述 gcd(m,n)算法开头处的,要求 m>=n 的原因:举这样一个例子,如 m=n。 ok,我想,现在,你已经彻底明白了此 gcd 算法,下面,咱们进入主题,stl 里的 rotate 算法的具体实现。//待续。 熟悉 stl 里的 rotate 算法的人知道,对长度为 n 的数组(ab)左移 m 位,可以用 stl 的 rotate 函数(stl 针对三种不同的迭代器,提供了三个版本的 rotate)。但在某些情况下,用 stl 的 rotate 效率极差。 对数组循环移位,可以采用的方法有(也算是对上文思路一,和思路二的总结): flyinghearts: ① 动态分配一个同样长度的数组,将数据复制到该数组并改变次序,再复制回原数组。 (最最普通的方法) ② 利用 ba=(br)^T(ar)^T=(arbr)^T,通过三次反转字符串。(即上述思路一,首先对序 列前部分逆序,再对序列后部分逆序,再对整个序列全部逆序) ③ 分组交换(尽可能使数组的前面连续几个数为所要结果): 若 a 长度大于 b,将 ab 分成 a0a1b,交换 a0 和 b,得 ba1a0,只需再交换 a1 和 a0。 27 若 a 长度小于 b,将 ab 分成 ab0b1,交换 a 和 b0,得 b0ab1,只需再交换 a 和 b0。 通过不断将数组划分,和交换,直到不能再划分为止。分组过程与求最大公约数很相似。 ④ 所有序号为 (j+i *m) % n (j 表示每个循环链起始位置,i 为计数变量,m 表示左旋转 位数,n 表示字符串长度),会构成一个循环链(共有 gcd(n,m)个,gcd 为 n、m 的最大公 约数),每个循环链上的元素只要移动一个位置即可,最后整个过程总共交换了 n 次(每一 次循环链,是交换 n/gcd(n,m)次,总共 gcd(n,m)个循环链。所以,总共交换 n 次)。 stl 的 rotate 的三种迭代器,即是,分别采用了后三种方法。 在给出 stl rotate 的源码之前,先来看下我的朋友 ys 对上述第④ 种方法的评论: ys:这条思路个人认为绝妙,也正好说明了数学对算法的重要影响。 通过前面思路的阐述,我们知道对于循环移位,最重要的是指针所指单元不能重复。例 如要使 abcd 循环移位变成 dabc(这里 m=3,n=4),经过以下一系列眼花缭乱的赋值过程就可 以实现: ch[0]->temp, ch[3]->ch[0], ch[2]->ch[3], ch[1]->ch[2], temp->ch[1]; (*) 字符串变化为:abcd->_bcd->dbc_->db_c->d_bc->dabc; 是不是很神奇?其实这是有规律可循的。 请先看下面的说明再回过头来看。 对于左旋转字符串,我们知道每个单元都需要且只需要赋值一次,什么样的序列能保证每 个单元都只赋值一次呢? 1、对于正整数 m、n 互为质数的情况,通过以下过程得到序列的满足上面的要求: for i = 0: n-1 k = i * m % n; end 举个例子来说明一下,例如对于 m=3,n=4 的情况, 1、我们得到的序列:即通过上述式子求出来的 k 序列,是 0, 3, 2, 1。 2、然后,你只要只需按这个顺序赋值一遍就达到左旋 3 的目的了: ch[0]->temp, ch[3]->ch[0], ch[2]->ch[3], ch[1]->ch[2], temp->ch[1]; (*) ok,这是不是就是按上面(*)式子的顺序所依次赋值的序列阿?哈哈,很巧妙吧。当然, 以上只是特例,作为一个循环链,相当于 rotate 算法的一次内循环。 28 2、对于正整数 m、n 不是互为质数的情况(因为不可能所有的 m,n 都是互质整数对), 那么我们把它分成一个个互不影响的循环链,正如 flyinghearts 所言,所有序号为 (j + i * m) % n(j 为 0 到 gcd(n, m)-1 之间的某一整数,i = 0:n-1)会构成一个循环链,一共有 gcd(n, m)个循环链,对每个循环链分别进行一次内循环就行了。 综合上述两种情况,可简单编写代码如下: 1. //④ 所有序号为 (j+i *m) % n (j 表示每个循环链起始位置,i 为计数变量,m 表示左旋转位 数,n 表示字符串长度), 2. //会构成一个循环链(共有 gcd(n,m)个,gcd 为 n、m 的最大公约数), 3. 4. //每个循环链上的元素只要移动一个位置即可,最后整个过程总共交换了 n 次 5. //(每一次循环链,是交换 n/gcd(n,m)次,共有 gcd(n,m)个循环链,所以,总共交换 n 次)。 6. 7. void rotate(string &str, int m) 8. { 9. int lenOfStr = str.length(); 10. int numOfGroup = gcd(lenOfStr, m); 11. int elemInSub = lenOfStr / numOfGroup; 12. 13. for(int j = 0; j < numOfGroup; j++) 14. //对应上面的文字描述,外循环次数 j 为循环链的个数,即 gcd(n, m)个循环链 15. { 16. char tmp = str[j]; 17. 18. for (int i = 0; i < elemInSub - 1; i++) 19. //内循环次数 i 为,每个循环链上的元素个数,n/gcd(m,n)次 20. str[(j + i * m) % lenOfStr] = str[(j + (i + 1) * m) % lenOfStr]; 21. str[(j + i * m) % lenOfStr] = tmp; 22. } 23. } 后来有网友针对上述的思路④,给出了下述的证明: 1、首先,直观的看肯定是有循环链,关键是有几条以及每条有多长,根据(i+j *m) % n 这个表达式可以推出一些东东,一个 j 对应一条循环链,现在要证明(i+j *m) % n 有 n/gcd(n,m) 个不同的数。 2、假设 j 和 k 对应的数字是相同的, 即(i+j*m)%n = (i+k*m)%n, 可以推出 n|(j-k)*m, m=m’*gcd(n.m), n=n’*gcd(n,m), 可以推出 n’|(j-k)*m’,而 m’和 n’互素,于是 n’|(j-k),即 (n/gcd(n,m))|(j-k), 3、所以(i+j*m) % n 有 n/gcd(n,m)个不同的数。则总共有 gcd(n,m)个循环链。符号“|” 29 是整除的意思。 以上的 3 点关于为什么一共有 gcd(n, m)个循环链的证明,应该是来自 qq3128739xx 的,非 常感谢这位朋友。 3.2、以下,便是摘自 sgi stl v3.3 版中的 stl_algo_h 文件里,有关 rotate 的实现 的代码: 1. // rotate and rotate_copy, and their auxiliary functions 2. template 3. _EuclideanRingElement __gcd(_EuclideanRingElement __m, 4. _EuclideanRingElement __n) 5. { //gcd(m,n)实现 6. while (__n != 0) { 7. _EuclideanRingElement __t = __m % __n; 8. __m = __n; 9. __n = __t; 10. } 11. return __m; //.... 12. } 13. 14. //③ 分组交换(尽可能使数组的前面连续几个数为所要结果): 15. //若 a 长度大于 b,将 ab 分成 a0a1b,交换 a0 和 b,得 ba1a0,只需再交换 a1 和 a0。 16. //若 a 长度小于 b,将 ab 分成 ab0b1,交换 a 和 b0,得 b0ab1,只需再交换 a 和 b0。 17. //通过不断将数组划分,和交换,直到不能再划分为止。分组过程与求最大公约数很相 似。 18. template 19. _ForwardIter __rotate(_ForwardIter __first, 20. _ForwardIter __middle, 21. _ForwardIter __last, 22. _Distance*, 23. forward_iterator_tag) 24. { 25. if (__first == __middle) 26. return __last; 27. if (__last == __middle) 28. return __first; 29. 30. _ForwardIter __first2 = __middle; 31. do { 32. swap(*__first++, *__first2++); // 33. if (__first == __middle) 34. __middle = __first2; 35. } while (__first2 != __last); 30 36. 37. _ForwardIter __new_middle = __first; 38. __first2 = __middle; 39. 40. while (__first2 != __last) 41. { 42. swap (*__first++, *__first2++); // 43. if (__first == __middle) 44. __middle = __first2; 45. else if (__first2 == __last) 46. __first2 = __middle; 47. } 48. 49. return __new_middle; 50. } 51. 52. //②利用 ba=(br)^T(ar)^T=(arbr)^T,通过三次反转字符串。 53. //(即上述思路一,首先对序列前部分逆序,再对序列后部分逆序,再对整个序列全部 逆序) 54. template 55. _BidirectionalIter __rotate(_BidirectionalIter __first, 56. _BidirectionalIter __middle, 57. _BidirectionalIter __last, 58. _Distance*, 59. bidirectional_iterator_tag) 60. { 61. __STL_REQUIRES(_BidirectionalIter, _Mutable_BidirectionalIterator ); 62. if (__first == __middle) 63. return __last; 64. if (__last == __middle) 65. return __first; 66. 67. __reverse(__first, __middle, bidirectional_iterator_tag()); // 交换序列前半部分 68. __reverse(__middle, __last, bidirectional_iterator_tag()); // 交换序列后半部分 69. 70. while (__first != __middle && __middle != __last) 71. swap (*__first++, *--__last); //整个序列全部交换 72. 73. if (__first == __middle) // 74. { 31 75. __reverse(__middle, __last, bidirectional_iterator_tag()); 76. return __last; 77. } 78. else { 79. __reverse(__first, __middle, bidirectional_iterator_tag()); 80. return __first; 81. } 82. } 83. 84. //④ 所有序号为 (i+t*k) % n (i 为指定整数,t 为任意整数), 85. //会构成一个循环链(共有 gcd(n,k)个,gcd 为 n、k 的最大公约数), 86. //每个循环链上的元素只要移动一个位置即可,总共交换了 n 次。 87. template 88. _RandomAccessIter __rotate(_RandomAccessIter __first, 89. _RandomAccessIter __middle, 90. _RandomAccessIter __last, 91. _Distance *, _Tp *) 92. { 93. __STL_REQUIRES(_RandomAccessIter, _Mutable_RandomAccessIterator); 94. _Distance __n = __last - __first; 95. _Distance __k = __middle - __first; 96. _Distance __l = __n - __k; 97. _RandomAccessIter __result = __first + (__last - __middle); 98. 99. if (__k == 0) 100. return __last; 101. 102. else if (__k == __l) { 103. swap_ranges(__first, __middle, __middle); 104. return __result; 105. } 106. 107. _Distance __d = __gcd(__n, __k); //令 d 为 gcd(n,k) 108. 109. for (_Distance __i = 0; __i < __d; __i++) { 110. _Tp __tmp = *__first; 111. _RandomAccessIter __p = __first; 112. 113. if (__k < __l) { 114. for (_Distance __j = 0; __j < __l/__d; __j++) { 115. if (__p > __first + __l) { 32 116. *__p = *(__p - __l); 117. __p -= __l; 118. } 119. 120. *__p = *(__p + __k); 121. __p += __k; 122. } 123. } 124. else { 125. for (_Distance __j = 0; __j < __k/__d - 1; __j ++) { 126. if (__p < __last - __k) { 127. *__p = *(__p + __k); 128. __p += __k; 129. } 130. 131. *__p = * (__p - __l); 132. __p -= __l; 133. } 134. } 135. 136. *__p = __tmp; 137. ++__first; 138. } 139. 140. return __result; 141. } 由于上述 stl rotate 源码中,方案④ 的代码,较复杂,难以阅读,下面是对上述第④ 方案 的简单改写: 1. //对上述方案 4 的改写。 2. //④ 所有序号为 (i+t*k) % n (i 为指定整数,t 为任意整数),.... 3. //copyright@ hplonline && July 2011.04.18。 4. //July、sahala、yansha,updated,2011.06.02。 5. void my_rotate(char *begin, char *mid, char *end) 6. { 7. int n = end - begin; 8. int k = mid - begin; 9. int d = gcd(n, k); 10. int i, j; 11. for (i = 0; i < d; i ++) 12. { 13. int tmp = begin[i]; 14. int last = i; 15. 33 16. //i+k 为 i 右移 k 的位置,%n 是当 i+k>n 时从左重新开始。 17. for (j = (i + k) % n; j != i; j = (j + k) % n) //多谢 laocpp 指正。 18. { 19. begin[last] = begin[j]; 20. last = j; 21. } 22. begin[last] = tmp; 23. } 24. } 对上述程序的解释:关于第二个 for 循环中,j 初始化为(i+)%n,程序注释中已经说了, i+k 为 i 右移 k 的位置,%n 是当 i+k>n 时从左重新开始。为什么要这么做呢?很简单,n 个 数的数组不管循环左移多少位,用上述程序的方法一共需要交换 n 次。当 i+k>=n 时 i+k 表 示的位置在数组中不存在了,所以又从左边开始的(i+k)%n 是下一个交换的位置。 1. 好比 5 个学生,,编号从 0 开始,即 0 1 2 3 4,老师说报数,规则是从第一个 学生开始,中间隔一个学生报数。报数的学生编号肯定是 0 2 4 1 3。这里就 相当于 i 为 0,k 为 2,n 为 5 2. 然后老师又说,编号为 0 的学生出列,其他学生到在他前一个报数的学生位置 上去,那么学生从 0 1 2 3 4=》2 3 4 _ 1,最后老师说,编号 0 到剩余空位 去,得到最终排位 2 3 4 0 1。此时的结果,实际上就是相当于上述程序中左 移 k=2 个位置了。而至于为什么让 编号为 0 的学生 出列。实际是这句:int last = i; 因为要达到这样的效果 0 1 2 3 4 => 2 3 4 0 1,那么 2 3 4 必须要 移到前面去。怎么样,明白了么?。 关于本题,不少网友也给出了他们的意见,具体请参见此帖子微软 100 题,维护地址。 第五节、总结 如 nossiac 所说,对于这个数组循环移位的问题,真正最靠谱的其实只有俩种:一种是 上文的思路一,前后部分逆置翻转法,第二种是思路三,即 stl 里的 rotate 算法,其它的思 路或方法,都是或多或少在向这俩种方法靠拢。 下期更新:程序员面试题狂想曲:第二章。时间:本周周日 04.24 晚。非常感谢各位朋友 的,支持与关注。本人宣告:本程序员面试题狂想曲系列,永久更新。 本章完。 34 版权声明:转载本 BLOG 内任何文章和内容,务必以超链接形式注明出处。 第二章、字符串是否包含及相关问题扩展 作者:July,yansha。 时间:二零一一年四月二十三日。 致谢:老梦,nossiac,Hession,Oliver,luuillu,雨翔,啊菜,及微软 100 题实现小组所 有成员。 微博:http://weibo.com/julyweibo。 出处:http://blog.csdn.net/v_JULY_v。 ------------------------------------------- 目录 曲之前奏 第一节、一道俩个字符串是否包含的问题 1.1、O(n*m)的轮询方法 1.2、O(mlogm)+O(nlogn)+O(m+n)的排序方法 1.3、O(n+m)的计数排序方法 第二节 2.1、O(n+m)的 hashtable 的方法 2.2、O(n+m)的数组存储方法 第三节、O(n)到 O(n+m)的素数方法 第四节、字符串是否包含问题的继续补充 4.1、Bit-map 4.2、移位操作 第五节、字符串相关问题扩展 5.1、字符串匹配问题 5.2、在字符串中查找子串 35 扩展:在一个字符串中找到第一个只出现一次的字符 5.3、字符串转换为整数 5.4、字符串拷贝 前奏 前一章,请见这:程序员面试题狂想曲:第一章、左旋转字符串。本章里出现的所有代 码及所有思路的实现,在此之前,整个网上都是没有的。 文中的思路,聪明点点的都能想到,巧的思路,大师也已奉献了。如果你有更好的思路, 欢迎提供。如果你对此狂想曲系列有任何建议,欢迎微博上交流或来信指导。任何人,有任 何问题,欢迎随时不吝指正。 如果此狂想曲系列对你有所帮助,我会非常之高兴,并将让我有了永久坚持写下去的动 力。谢谢。 第一节、一道俩个字符串是否包含的问题 1.0、题目描述: 假设这有一个各种字母组成的字符串,假设这还有另外一个字符串,而且这个字符串里的字 母数相对少一些。从算法是讲,什么方法能最快的查出所有小字符串里的字母在大字符串里 都有? 比如,如果是下面两个字符串: String 1: ABCDEFGHLMNOPQRS String 2: DCGSRQPOM 答案是 true,所有在 string2 里的字母 string1 也都有。 如果是下面两个字符串: String 1: ABCDEFGHLMNOPQRS String 2: DCGSRQPOZ 答案是 false,因为第二个字符串里的 Z 字母不在第一个字符串里。 点评: 1、题目描述虽长,但题意简单明了,就是给定一长一短的俩个字符串 A,B,假设 A 长 B 短,现在,要你判断 B 是否包含在字符串 A 中,即 B?(-A。 36 2、题意虽简单,但实现起来并不轻松,且当如果面试官步步紧逼,一个一个否决你能想 到的方法,要你给出更好、最好的方案时,你恐怕就要伤不少脑筋了。 ok,在继续往下阅读之前,您最好先想个几分钟,看你能想到的最好方案是什么,是否 与本文最后实现的方法一致。 1.1、O(n*m)的轮询方法 判断 string2 中的字符是否在 string1 中?: String 1: ABCDEFGHLMNOPQRS String 2: DCGSRQPOM 判断一个字符串是否在另一个字符串中,最直观也是最简单的思路是,针对第二个字符 串 string2 中每一个字符,一一与第一个字符串 string1 中每个字符依次轮询比较,看它是否 在第一个字符串 string1 中。 假设 n 是字符串 string1 的长度,m 是字符串 string2 的长度,那么此算法,需要 O(n*m) 次操作,拿上面的例子来说,最坏的情况下将会有 16*8 = 128 次操作。 我们不难写出以下代码: 1. #include 2. using namespace std; 3. 4. int CompareSting(string LongSting,string ShortSting) 5. { 6. for (int i=0; i>之后,此感尤甚。个人会不断完善和规范此类代码风格。有任何问题,欢迎随时指正。 谢谢大家。) 1.2、O(mlogm)+O(nlogn)+O(m+n)的排序方法 一个稍微好一点的方案是先对这两个字符串的字母进行排序,然后同时对两个字串依次 轮询。两个字串的排序需要(常规情况)O(m log m) + O(n log n)次操作,之后的线性扫描需 要 O(m+n)次操作。 同样拿上面的字串做例子,将会需要 16*4 + 8*3 = 88 加上对两个字串线性扫描的 16 + 8 = 24 的操作。(随着字串长度的增长,你会发现这个算法的效果会越来越好) 关于采用何种排序方法,我们采用最常用的快速排序,下面的快速排序的代码用的是以 前写的,比较好懂,并且,我执意不用库函数的 qsort 代码。唯一的问题是,此前写的代码 是针对整数进行排序的,不过,难不倒我们,稍微改一下参数,即可,如下: 1. //copyright@ 2011 July && yansha 2. //July,updated,2011.04.23. 3. #include 4. #include 5. using namespace std; 6. 38 7. //以前的注释,还让它保留着 8. int partition(string &str,int lo,int hi) 9. { 10. int key = str[hi]; //以最后一个元素,data[hi]为主元 11. int i = lo - 1; 12. for(int j = lo; j < hi; j++) ///注,j 从 p 指向的是 r-1,不是 r。 13. { 14. if(str[j] <= key) 15. { 16. i++; 17. swap(str[i], str[j]); 18. } 19. } 20. swap(str[i+1], str[hi]); //不能改为 swap(&data[i+1],&key) 21. return i + 1; 22. } 23. 24. //递归调用上述 partition 过程,完成排序。 25. void quicksort(string &str, int lo, int hi) 26. { 27. if (lo < hi) 28. { 29. int k = partition(str, lo, hi); 30. quicksort(str, lo, k - 1); 31. quicksort(str, k + 1, hi); 32. } 33. } 34. 35. //比较,上述排序 O(m log m) + O(n log n),加上下面的 O(m+n), 36. //时间复杂度总计为:O(mlogm)+O(nlogn)+O(m+n)。 37. void compare(string str1,string str2) 38. { 39. int posOne = 0; 40. int posTwo = 0; 41. while (posTwo < str2.length() && posOne < str1.length()) 42. { 43. while (str1[posOne] < str2[posTwo] && posOne < str1.length() - 1) 44. posOne++; 45. //如果和 str2 相等,那就不能动。只有比 str2 小,才能动。 46. 47. if (str1[posOne] != str2[posTwo]) 48. break; 49. 50. //posOne++; 39 51. //归并的时候,str1[str1Pos] == str[str2Pos]的时候,只能 str2Pos++,str1Pos 不可以自增。 52. //多谢 helloword 指正。 53. 54. posTwo++; 55. } 56. 57. if (posTwo == str2.length()) 58. cout << "true" << endl; 59. else 60. cout << "false" << endl; 61. } 62. 63. int main() 64. { 65. string str1 = "ABCDEFGHLMNOPQRS"; 66. string str2 = "DCGDSRQPOM"; 67. //之前上面加了那句 posOne++之所以有 bug,是因为,@helloword: 68. //因为 str1 如果也只有一个 D,一旦 posOne++,就到了下一个不是'D'的字符上去了, 69. //而 str2 有俩 D,posTwo++后,下一个字符还是'D',就不等了,出现误判。 70. 71. quicksort(str1, 0, str1.length() - 1); 72. quicksort(str2, 0, str2.length() - 1); //先排序 73. compare(str1, str2); //后线性扫描 74. return 0; 75. } 1.3、O(n+m)的计数排序方法 此方案与上述思路相比,就是在排序的时候采用线性时间的计数排序方法,排序 O (n+m),线性扫描 O(n+m),总计时间复杂度为:O(n+m)+O(n+m)=O(n+m)。 代码如下: 1. #include 2. #include 3. using namespace std; 4. 5. // 计数排序,O(n+m) 6. void CounterSort(string str, string &help_str) 7. { 8. // 辅助计数数组 9. int help[26] = {0}; 40 10. 11. // help[index]存放了等于 index + 'A'的元素个数 12. for (int i = 0; i < str.length(); i++) 13. { 14. int index = str[i] - 'A'; 15. help[index]++; 16. } 17. 18. // 求出每个元素对应的最终位置 19. for (int j = 1; j < 26; j++) 20. help[j] += help[j-1]; 21. 22. // 把每个元素放到其对应的最终位置 23. for (int k = str.length() - 1; k >= 0; k--) 24. { 25. int index = str[k] - 'A'; 26. int pos = help[index] - 1; 27. help_str[pos] = str[k]; 28. help[index]--; 29. } 30. } 31. 32. //线性扫描 O(n+m) 33. void Compare(string long_str,string short_str) 34. { 35. int pos_long = 0; 36. int pos_short = 0; 37. while (pos_short < short_str.length() && pos_long < long_str.length()) 38. { 39. // 如果 pos_long 递增直到 long_str[pos_long] >= short_str[pos_short] 40. while (long_str[pos_long] < short_str[pos_short] && pos_long < long_ str.length 41. 42. () - 1) 43. pos_long++; 44. 45. // 如果 short_str 有连续重复的字符,pos_short 递增 46. while (short_str[pos_short] == short_str[pos_short+1]) 47. pos_short++; 48. 49. if (long_str[pos_long] != short_str[pos_short]) 50. break; 51. 52. pos_long++; 41 53. pos_short++; 54. } 55. 56. if (pos_short == short_str.length()) 57. cout << "true" << endl; 58. else 59. cout << "false" << endl; 60. } 61. 62. int main() 63. { 64. string strOne = "ABCDAK"; 65. string strTwo = "A"; 66. string long_str = strOne; 67. string short_str = strTwo; 68. 69. // 对字符串进行计数排序 70. CounterSort(strOne, long_str); 71. CounterSort(strTwo, short_str); 72. 73. // 比较排序好的字符串 74. Compare(long_str, short_str); 75. return 0; 76. } 不过上述方法,空间复杂度为 O(n+m),即消耗了一定的空间。有没有在线性时间,且 空间复杂度较小的方案列? 第二节、寻求线性时间的解法 2.1、O(n+m)的 hashtable 的方法 上述方案中,较好的方法是先对字符串进行排序,然后再线性扫描,总的时间复杂度已 经优化到了:O(m+n),貌似到了极限,还有没有更好的办法列? 我们可以对短字串进行轮询(此思路的叙述可能与网上的一些叙述有出入,因为我们最 好是应该把短的先存储,那样,会降低题目的时间复杂度),把其中的每个字母都放入一个 Hashtable 里(我们始终设 m 为短字符串的长度,那么此项操作成本是 O(m)或 8 次操作)。 然后轮询长字符串,在 Hashtable 里查询短字符串的每个字符,看能否找到。如果找不到, 说明没有匹配成功,轮询长字符串将消耗掉 16 次操作,这样两项操作加起来一共只有 8+16=24 次。 42 当然,理想情况是如果长字串的前缀就为短字串,只需消耗 8 次操作,这样总共只需 8+8=16 次。 或如梦想天窗所说: 我之前用散列表做过一次,算法如下: 1、hash[26],先全部清零,然后扫描短的字符串,若有相应的置 1, 2、计算 hash[26]中 1 的个数,记为 m 3、扫描长字符串的每个字符 a;若原来 hash[a] == 1 ,则修改 hash[a] = 0,并将 m 减 1; 若 hash[a] == 0,则不做处理 4、若 m == 0 or 扫描结束,退出循环。 代码实现,也不难,如下: 1. //copyright@ 2011 yansha 2. //July、updated,2011.04.25。 3. #include 4. #include 5. using namespace std; 6. 7. int main() 8. { 9. string str1="ABCDEFGHLMNOPQRS"; 10. string str2="DCGSRQPOM"; 11. 12. // 开辟一个辅助数组并清零 13. int hash[26] = {0}; 14. 15. // num 为辅助数组中元素个数 16. int num = 0; 17. 18. // 扫描短字符串 19. for (int j = 0; j < str2.length(); j++) 20. { 21. // 将字符转换成对应辅助数组中的索引 22. int index = str1[j] - 'A'; 23. 24. // 如果辅助数组中该索引对应元素为 0,则置 1,且 num++; 25. if (hash[index] == 0) 26. { 27. hash[index] = 1; 28. num++; 29. } 30. } 43 31. 32. // 扫描长字符串 33. for (int k = 0; k < str1.length(); k++) 34. { 35. int index = str1[k] - 'A'; 36. 37. // 如果辅助数组中该索引对应元素为 1,则 num--;为零的话,不作处理(不写语句)。 38. if(hash[index] ==1) 39. { 40. hash[index] = 0; 41. num--; 42. if(num == 0) //m==0,即退出循环。 43. break; 44. } 45. } 46. 47. // num 为 0 说明长字符串包含短字符串内所有字符 48. if (num == 0) 49. cout << "true" << endl; 50. else 51. cout << "false" << endl; 52. return 0; 53. } 2.2、O(n+m)的数组存储方法 有两个字符串 short_str 和 long_str。 第一步:你标记 short_str 中有哪些字符,在 store 数组中标记为 true。(store 数组起一 个映射的作用,如果有 A,则将第 1 个单元标记 true,如果有 B,则将第 2 个单元标记 true,... 如果有 Z, 则将第 26 个单元标记 true) 第二步:遍历 long_str,如果 long_str 中的字符包括 short_str 中的字符则将 store 数组 中对应位置标记为 false。(如果有 A,则将第 1 个单元标记 false,如果有 B,则将第 2 个单 元标记 false,... 如果有 Z, 则将第 26 个单元标记 false),如果没有,则不作处理。 第三步:此后,遍历 store 数组,如果所有的元素都是 false,也就说明 store_str 中字符 都包含在 long_str 内,输出 true。否则,输出 false。 举个简单的例子好了,如 abcd,abcdefg 俩个字符串, 1、先遍历短字符串 abcd,在 store 数组中想对应的 abcd 的位置上的单元元素置为 true, 2、然后遍历 abcdefg,在 store 数组中相应的 abcd 位置上,发现已经有了 abcd,则前 44 4 个的单元元素都置为 false,当我们已经遍历了 4 个元素,等于了短字符串 abcd 的 4 个数 目,所以,满足条件,退出。 (不然,继续遍历的话,我们会发现 efg 在 store 数组中没有元素,不作处理。最后,自 然,就会发现 store 数组中的元素单元都是 false 的。) 3、遍历 store 数组,发现所有的元素都已被置为 false,所以程序输出 true。 其实,这个思路和上一节中,O(n+m)的 hashtable 的方法代码,原理是完全一致的, 且本质上都采用的数组存储(hash 表也是一个数组),但我并不认为此思路多此一举,所 以仍然贴出来。ok,代码如下: 1. //copyright@ 2011 Hession 2. //July、updated,2011.04.23. 3. #include 4. #include 5. using namespace std; 6. 7. int main() 8. { 9. char long_ch[]="ABCDEFGHLMNOPQRS"; 10. char short_ch[]="DEFGHXLMNOPQ"; 11. int i; 12. bool store[58]; 13. memset(store,false,58); 14. 15. //前两个 是 遍历 两个字符串, 后面一个是 遍历 数组 16. for(i=0;i 2. #include 46 3. #include "BigInt.h" 4. using namespace std; 5. 6. // 素数数组 7. int primeNumber[26] = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 4 7, 53, 59, 8. 61, 67, 71, 73, 79, 83, 89, 97, 101}; 9. 10. int main() 11. { 12. string strOne = "ABCDEFGHLMNOPQRS"; 13. string strTwo = "DCGSRQPOM"; 14. 15. // 这里需要用到大整数 16. CBigInt product = 1; //大整数除法的代码,下头给出。 17. 18. // 遍历长字符串,得到每个字符对应素数的乘积 19. for (int i = 0; i < strOne.length(); i++) 20. { 21. int index = strOne[i] - 'A'; 22. product = product * primeNumber[index]; 23. } 24. 25. // 遍历短字符串 26. for (int j = 0; j < strTwo.length(); j++) 27. { 28. int index = strTwo[j] - 'A'; 29. 30. // 如果余数不为 0,说明不包括短字串中的字符,跳出循环 31. if (product % primeNumber[index] != 0) 32. break; 33. } 34. 35. // 如果积能整除短字符串中所有字符则输出"true",否则输出"false"。 36. if (strTwo.length() == j) 37. cout << "true" << endl; 38. else 39. cout << "false" << endl; 40. return 0; 41. } 上述程序待改进的地方: 1. 只 考 虑 大 些 字符,如果考虑小写字符和数组的话,素数数组需要更多素数 2.没有考虑重复的字符,可以加入判断重复字符的辅助数组。 47 以下的大整数除法的代码,虽然与本题目无多大关系,但为了保证文章的完整性,我还是决 定把它贴出来, 代码如下(点击展开): 说明:此次的判断字符串是否包含问题,来自一位外国网友提供的 gofish、google 面试 题,这个题目出自此篇文章:http://www.aqee.net/2011/04/11/google-interviewing-story/, 文章记录了整个面试的过程,比较有趣,值得一读。 扩展:正如网友安逸所说:其实这个问题还可以转换为:a 和 b 两个字符串,求 b 串包含 a 串的最小长度。包含指的就是 b 的字串包含 a 中每个字符。 第四节、字符串是否包含问题的继续补充 updated:本文发布后,得到很多朋友的建议和意见,其中 nossiac,luuillu 等俩位网友 除了给出具体的思路之外,还给出了代码,征得同意,下面,我将引用他们的的思路及代码, 继续就这个字符串是否包含问题深入阐述。 4.1、在引用 nossiac 的思路之前,我得先给你介绍下什么是 Bit-map? Oliver:所谓的 Bit-map 就是用一个 bit 位来标记某个元素对应的 Value, 而 Key 即是该 元素。由于采用了 Bit 为单位来存储数据,因此在存储空间方面,可以大大节省。 如果看了以上说的还没明白什么是 Bit-map,那么我们来看一个具体的例子,假设我们要 对 0-7 内的 5 个元素(4,7,2,5,3)排序(这里假设这些元素没有重复)。那么我们就可以采用 Bit-map 的方法来达到排序的目的。要表示 8 个数,我们就只需要 8 个 Bit(1Bytes),首 先我们开辟 1Byte 的空间,将这些空间的所有 Bit 位都置为 0,如下图: 然后遍历这 5 个元素,首先第一个元素是 4,那么就把 4 对应的位置为 1(可以这样操作: p+(i/8)|(0x01<<(i%8))当然了这里的操作涉及到 Big-ending 和 Little-ending 的情况,这里 默认为 Big-ending),因为是从零开始的,所以要把第五位置为一(如下图): 48 接着再处理第二个元素 7,将第八位置为 1,,接着再处理第三个元素,一直到最后处理 完所有的元素,将相应的位置为 1,这时候的内存的 Bit 位的状态如下: 最后我们现在遍历一遍 Bit 区域,将该位是一的位的编号输出(2,3,4,5,7),这样 就达到了排序的目的。 代码示例: 1. //位图的一个示例 2. //copyright@ Oliver && July 3. //http://blog.redfox66.com/post/2010/09/26/mass-data-4-bitmap.aspx 4. //July、updated,2011.04.25. 5. 6. #include 7. #include 8. //定义每个 Byte 中有 8 个 Bit 位 9. #define BYTESIZE 8 10. 11. void SetBit(char *p, int posi) 12. { 13. for(int i=0; i < (posi/BYTESIZE); i++) 14. { 15. p++; 16. } 17. *p = *p|(0x01<<(posi%BYTESIZE)); //将该 Bit 位赋值 1 18. return; 19. } 20. 21. void BitMapSortDemo() 22. { 23. //为了简单起见,我们不考虑负数 24. int num[] = {3,5,2,10,6,12,8,14,9}; 49 25. 26. //BufferLen 这个值是根据待排序的数据中最大值确定的 27. //待排序中的最大值是 14,因此只需要 2 个 Bytes(16 个 Bit) 28. //就可以了。 29. const int BufferLen = 2; 30. char *pBuffer = new char[BufferLen]; 31. 32. //要将所有的 Bit 位置为 0,否则结果不可预知。 33. memset(pBuffer,0,BufferLen); 34. 35. for(int i=0;i<9;i++) 36. { 37. //首先将相应 Bit 位上置为 1 38. SetBit(pBuffer,num[i]); 39. } 40. 41. //输出排序结果 42. for(i=0;i 4. #include 5. 6. #define getbit(x) (1<<(x-'a')) 7. 8. void a_has_b(char * a, char * b) 9. { 10. int i = 0; 11. int dictionary = 0; 12. int alen = strlen(a); 13. int blen = strlen(b); 14. 15. for(i=0;i 4. using namespace std; 5. 6. //判断 des 是否包含在 src 中 7. bool compare(char *des,char * src) 8. { 9. unsigned index[26]={1,2,4,8,16,32,64,128,256,512,1024,1<<11, 10. 1<<12,1<<13,1<<14,1<<15,1<<16,1<<17,1<<18,1<<19, 11. 1<<20,1<<21,1<<22,1<<23,1<<24,1<<25}; //2 的 n 次 幂 12. 13. unsigned srcdata=0; 14. unsigned desdata=0; 15. 16. while( *src) 17. srcdata|=index[(*src++)-'A']; 18. while(*des) 19. desdata|=index[(*des++)-'A']; 20. 21. return (srcdata|desdata) == srcdata ; 22. 23. } 24. 25. int main() 26. { 27. char *src="ABCDEFGHLMNOPQRS"; 28. char *des="DCGSRQPOM"; 29. cout< 4. #include 5. using namespace std; 6. 7. bool Is_Match(const char *strOne,const char *strTwo) 8. { 9. int lenOfOne = strlen(strOne); 10. int lenOfTwo = strlen(strTwo); 11. 12. // 如果长度不相等则返回 false 13. if (lenOfOne != lenOfTwo) 14. return false; 15. 16. // 开辟一个辅助数组并清零 17. int hash[26] = {0}; 18. 19. // 扫描字符串 20. for (int i = 0; i < strlen(strOne); i++) 21. { 22. // 将字符转换成对应辅助数组中的索引 23. int index = strOne[i] - 'A'; 24. 25. // 辅助数组中该索引对应元素加 1,表示该字符的个数 26. hash[index]++; 55 27. } 28. 29. // 扫描字符串 30. for (int j = 0; j < strlen(strTwo); j++) 31. { 32. int index = strTwo[j] - 'A'; 33. 34. // 如果辅助数组中该索引对应元素不为 0 则减 1,否则返回 false 35. if (hash[index] != 0) 36. hash[index]--; 37. else 38. return false; 39. } 40. return true; 41. } 42. 43. int main() 44. { 45. string strOne = "ABBA"; 46. string strTwo = "BBAA"; 47. 48. bool flag = Is_Match(strOne.c_str(), strTwo.c_str()); 49. 50. // 如果为 true 则匹配,否则不匹配 51. if (flag == true) 52. cout << "Match" << endl; 53. else 54. cout << "No Match" << endl; 55. return 0; 56. } 5.2、在字符串中查找子串 题目描述: 给定一个字符串 A,要求在 A 中查找一个子串 B。 如 A="ABCDF",要你在 A 中查找子串 B=“CD”。 分析:比较简单,相当于实现 strstr 库函数,主体代码如下: 1. //copyright@ 2011 July && luoqitai 2. //string 为模式串,substring 为要查找的子串 3. int strstr(char *string,char *substring) 4. { 56 5. int len1=strlen(string); 6. int len2=strlen(substring); 7. for (int i=0; i<=len1-len2; i++) //复杂度为 O(m*n) 8. { 9. for (int j=0; j 2. using namespace std; 3. 4. //查找第一个只出现一次的字符,第 1 个程序 5. //copyright@ Sorehead && July 6. //July、updated,2011.04.24. 7. char find_first_unique_char(char *str) 8. { 9. int data[256]; 10. char *p; 11. 12. if (str == NULL) 13. return '/0'; 14. 15. memset(data, 0, sizeof(data)); //数组元素先全部初始化为 0 16. p = str; 17. while (*p != '/0') 18. data[(unsigned char)*p++]++; //遍历字符串,在相应位置++,(同时,下标强制 转换) 19. 20. while (*str != '/0') 57 21. { 22. if (data[(unsigned char)*str] == 1) //最后,输出那个第一个只出现次数为 1 的字符 23. return *str; 24. 25. str++; 26. } 27. 28. return '/0'; 29. } 30. 31. int main() 32. { 33. char *str = "afaccde"; 34. cout << find_first_unique_char(str) << endl; 35. return 0; 36. } 当然,代码也可以这么写(测试正确): 1. //查找第一个只出现一次的字符,第 2 个程序 2. //copyright@ yansha 3. //July、updated,2011.04.24. 4. char FirstNotRepeatChar(char* pString) 5. { 6. if(!pString) 7. return '/0'; 8. 9. const int tableSize = 256; 10. int hashTable[tableSize] = {0}; //存入数组,并初始化为 0 11. 12. char* pHashKey = pString; 13. while(*(pHashKey) != '/0') 14. hashTable[*(pHashKey++)]++; 15. 16. while(*pString != '/0') 17. { 18. if(hashTable[*pString] == 1) 19. return *pString; 20. 21. pString++; 22. } 23. return '/0'; //没有找到满足条件的字符,退出 24. } 58 5.3、字符串转换为整数 题目:输入一个表示整数的字符串,把该字符串转换成整数并输出。 例如输入字符串"345",则输出整数 345。 分析:此题看起来,比较简单,每扫描到一个字符,我们把在之前得到的数字乘以 10 再 加上当前字符表示的数字。这个思路用循环不难实现。然其背后却隐藏着不少陷阱,正如 zhedahht 所说,有以下几点需要你注意: 1、由于整数可能不仅仅之含有数字,还有可能以'+'或者'-'开头,表示整数的正负。如果 第一个字符是'+'号,则不需要做任何操作;如果第一个字符是'-'号,则表明这个整数是个负 数,在最后的时候我们要把得到的数值变成负数。 2、如果使用的是指针的话,在使用指针之前,我们要做的第一件是判断这个指针是不是 为空。如果试着去访问空指针,将不可避免地导致程序崩溃(此第 2 点在下面的程序不需注 意,因为没有用到指针)。 3、输入的字符串中可能含有不是数字的字符。 每当碰到这些非法的字符,我们就没有必要再继续转换。 4、溢出问题。由于输入的数字是以字符串的形式输入,因此有可能输入一个很大的数字 转换之后会超过能够表示的最大的整数而溢出。 总结以上四点,代码可以如下编写: 1. //字符串转换为整数 2. //copyright@ yansha 3. #include 4. #include 5. using namespace std; 6. 7. int str_2_int(string str) 8. { 9. if (str.size() == 0) 10. exit(0); 11. 12. int pos = 0; 13. int sym = 1; 14. 15. // 处理符号 16. if (str[pos] == '+') 17. pos++; 18. else if (str[pos] == '-') 19. { 20. pos++; 59 21. sym = -1; 22. } 23. 24. int num = 0; 25. // 逐位处理 26. while (pos < str.length()) 27. { 28. // 处理数字以外的字符 29. if (str[pos] < '0' || str[pos] > '9') 30. exit(0); 31. 32. num = num * 10 + (str[pos] - '0'); 33. 34. // 处理溢出 35. if (num < 0) 36. exit(0); 37. pos++; 38. } 39. 40. num *= sym; 41. return num; 42. } 43. 44. int main() 45. { 46. string str = "-3450"; 47. int num = str_2_int(str); 48. cout << num << endl; 49. return 0; 50. } @helloword:这个的实现非常不好,当输入字符串参数为非法时,不是抛出异常不是返 回 error code,而是直接 exit 了。直接把进程给终止了,想必现实应用中的实现都不会这样。 建议您改改,不然拿到面试官那,会被人喷死的。ok,听从他的建议,借用 zhedahht 的代 码了: 1. //http://zhedahht.blog.163.com/blog/static/25411174200731139971/ 2. enum Status {kValid = 0, kInvalid}; 3. int g_nStatus = kValid; 4. 5. int StrToInt(const char* str) 6. { 7. g_nStatus = kInvalid; 8. long long num = 0; 60 9. 10. if(str != NULL) 11. { 12. const char* digit = str; 13. 14. // the first char in the string maybe '+' or '-' 15. bool minus = false; 16. if(*digit == '+') 17. digit ++; 18. else if(*digit == '-') 19. { 20. digit ++; 21. minus = true; 22. } 23. 24. // the remaining chars in the string 25. while(*digit != '/0') 26. { 27. if(*digit >= '0' && *digit <= '9') 28. { 29. num = num * 10 + (*digit - '0'); 30. 31. // overflow 32. if(num > std::numeric_limits::max()) 33. { 34. num = 0; 35. break; 36. } 37. 38. digit ++; 39. } 40. // if the char is not a digit, invalid input 41. else 42. { 43. num = 0; 44. break; 45. } 46. } 47. 48. if(*digit == '/0') 49. { 50. g_nStatus = kValid; 51. if(minus) 52. num = 0 - num; 61 53. } 54. } 55. 56. return static_cast(num); 57. } updated: yansha 看到了上述 helloword 的所说的后,修改如下: 1. #include 2. #include 3. #include 4. using namespace std; 5. 6. int str_2_int(string str) 7. { 8. assert(str.size() > 0); 9. 10. int pos = 0; 11. int sym = 1; 12. 13. // 处理符号 14. if (str[pos] == '+') 15. pos++; 16. else if (str[pos] == '-') 17. { 18. pos++; 19. sym = -1; 20. } 21. 22. int num = 0; 23. // 逐位处理 24. while (pos < str.length()) 25. { 26. // 处理数字以外的字符 27. assert(str[pos] >= '0'); 28. assert(str[pos] <= '9'); 29. 30. num = num * 10 + (str[pos] - '0'); 31. 32. // 处理溢出 33. assert(num >= 0); 34. 62 35. pos++; 36. } 37. 38. num *= sym; 39. 40. return num; 41. } 42. 43. int main() 44. { 45. string str = "-1024"; 46. int num = str_2_int(str); 47. cout << num << endl; 48. return 0; 49. } 5.4、字符串拷贝 题目描述: 要求实现库函数 strcpy, 原型声明:extern char *strcpy(char *dest,char *src); 功能:把 src 所指由 NULL 结束的字符串复制到 dest 所指的数组中。 说明:src 和 dest 所指内存区域不可以重叠且 dest 必须有足够的空间来容纳 src 的字符串。 返回指向 dest 的指针。 分析:如果编写一个标准 strcpy 函数的总分值为 10,下面给出几个不同得分的答案: 1. //2 分 2. void strcpy( char *strDest, char *strSrc ) 3. { 4. while( (*strDest++ = * strSrc++) != '/0' ); 5. } 6. 7. //4 分 8. void strcpy( char *strDest, const char *strSrc ) 9. { 10. //将源字符串加 const,表明其为输入参数,加 2 分 11. while( (*strDest++ = * strSrc++) != '/0' ); 12. } 13. 14. //7 分 15. void strcpy(char *strDest, const char *strSrc) 63 16. { 17. //对源地址和目的地址加非 0 断言,加 3 分 18. assert( (strDest != NULL) && (strSrc != NULL) ); 19. while( (*strDest++ = * strSrc++) != '/0' ); 20. } 21. 22. //10 分 23. //为了实现链式操作,将目的地址返回,加 3 分! 24. char * strcpy( char *strDest, const char *strSrc ) 25. { 26. assert( (strDest != NULL) && (strSrc != NULL) ); 27. char *address = strDest; 28. while( (*strDest++ = * strSrc++) != '/0' ); 29. return address; 30. } 联系作者: 微博:http://weibo.com/julyweibo @ July,http://weibo.com/yanshazi @ yansha。 邮箱:zhoulei0907@yahoo.cn @ July,yansha0@hotmail.com @ yansha。 预告:程序员面试题狂想曲、第三章,4 月底之前发布。 ok,以上,有任何问题,欢迎任何人不吝指正。谢谢。完。 版权声明:1、严禁用于任何商业用途;2、未经许可,严禁出版;3、转载,务必注明出处。 第三章、寻找最小的 k 个数 作者:July。 时间:二零一一年四月二十八日。 致谢:litaoye, strugglever,yansha,luuillu,Sorehead,及狂想曲创作组。 微博:http://weibo.com/julyweibo。 64 出处:http://blog.csdn.net/v_JULY_v。 ---------------------------------- 前奏 @July_____:1、当年明月:“我写文章有个习惯,由于早年读了太多学究书,所以很痛 恨那些故作高深的文章,其实历史本身很精彩,所有的历史都可以写得很好看,...。”2、IT 技术文章,亦是如此,可以写得很通俗,很有趣,而非故作高深。希望,我可以做到。 下面,我试图用最清晰易懂,最易令人理解的思维或方式阐述有关寻找最小的 k 个数这 个问题(这几天一直在想,除了计数排序外,这题到底还有没有其它的 O(n)的算法? )。 希望,有任何问题,欢迎不吝指正。谢谢。 寻找最小的 k 个数 题目描述:5.查找最小的 k 个元素 题目:输入 n 个整数,输出其中最小的 k 个。 例如输入 1,2,3,4,5,6,7 和 8 这 8 个数字,则最小的 4 个数字为 1,2,3 和 4。 第一节、各种思路,各种选择  0、 咱们先简单的理解,要求一个序列中最小的 k 个数,按照惯有的思维方式,很 简单,先对这个序列从小到大排序,然后输出前面的最小的 k 个数即可。  1、 至于选取什么的排序方法,我想你可能会第一时间想到快速排序,我们知道, 快速排序平均所费时间为 n*logn,然后再遍历序列中前 k 个元素输出,即可,总的 时间复杂度为 O(n*logn+k)=O(n*logn)。  2、 咱们再进一步想想,题目并没有要求要查找的 k 个数,甚至后 n-k 个数是有序 的,既然如此,咱们又何必对所有的 n 个数都进行排序列? 这时,咱们想到了用选择或交换排序,即遍历 n 个数,先把最先遍历到得 k 个 数存入大小为 k 的数组之中,对这 k 个数,利用选择或交换排序,找到 k 个数中的 最大数 kmax(kmax 设为 k 个元素的数组中最大元素),用时 O(k)(你应该知道, 插入或选择排序查找操作需要 O(k)的时间),后再继续遍历后 n-k 个数,x 与 kmax 比较:如果 xkmax,则不更新数组。这样,每次 65 更新或不更新数组的所用的时间为 O(k)或 O(0),整趟下来,总的时间复杂度平 均下来为:n*O(k)=O(n*k)。  3、 当然,更好的办法是维护 k 个元素的最大堆,原理与上述第 2 个方案一致, 即用容量为 k 的最大堆存储最先遍历到的 k 个数,并假设它们即是最小的 k 个数, 建堆费时 O(k)后,有 k11,那么可得 O(n*logk)>O(n+k*logn),也就是 O(n+k*logn)< O(n*logk)。虽然这 有违我们惯常的思维,然事实最终证明的确如此,这个极值 T=logk>1,即采取建立 n 个元素的 最小堆后取其前k 个数的方法的复杂度小于采取常规的建立k 个元素最大堆后通过比较寻找最小 的 k 个数的方法的复杂度。但,最重要的是,如果建立 n 个元素的最小堆的话,那么其空间复杂 度势必为 O(N),而建立 k 个元素的最大堆的空间复杂度为 O(k)。所以,综合考虑,我们 一般还是选择用建立 k 个元素的最大堆的方法解决此类寻找最小的 k 个数的问题。 也可以如 gbb21 所述粗略证明:要证原式 k+n*logk-n-k*logn>0,等价于证(logk-1) n-k*logn+k>0。当 when n -> +/inf(n 趋向于正无穷大)时,logk-1-0-0>0,即只要满足 logk-1>0 即可。原式得证。即 O(k+n*logk)>O(n+k*logn) =>O(n+k*logn)< O(n*logk),与上 面得到的结论一致。 事实上,是建立最大堆还是建立最小堆,其实际的程序运行时间相差并不大,运行时间都在 一个数量级上。因为后续,我们还专门写了个程序进行测试,即针对 1000w 的数据寻找其中最 小的 k 个数的问题,采取两种实现,一是采取常规的建立 k 个元素最大堆后通过比较寻找最小的 k 个数的方案,一是采取建立 n 个元素的最小堆,然后取其前 k 个数的方法,发现两相比较,运 行时间实际上相差无几。结果可看下面的第二幅图。 67  8、 @lingyun310:与上述思路 7 类似,不同的是在对元素数组原地建最小堆 O(n) 后,然后提取 K 次,但是每次提取时,换到顶部的元素只需要下移顶多 k 次就足够 了,下移次数逐次减少(而上述思路 7 每次提取都需要 logn,所以提取 k 次,思路 68 7 需要 k*logn。而本思路 8 只需要 K^2)。此种方法的复杂度为 O(n+k^2)。@July: 对于这个 O(n+k^2)的复杂度,我相当怀疑。因为据我所知,n 个元素的堆,堆中 任何一项操作的复杂度皆为 logn,所以按理说,lingyun310 方法的复杂度应该跟下 述思路 8 一样,也为 O(n+k*logn),而非 O(n+k*k)。 ok,先放到这,待时间考证。 06.02。 updated: 经过和几个朋友的讨论,已经证实,上述思路 7lingyun310 所述的思路应该是完全可以的。 下面,我来具体解释下他的这种方法。 我们知道,n 个元素的最小堆中,可以先取出堆顶元素得到我们第 1 小的元素,然后把 堆中最后一个元素(较大的元素)上移至堆顶,成为新的堆顶元素(取出堆顶元素之后,把 堆中下面的最后一个元素送到堆顶的过程可以参考下面的第一幅图。至于为什么是怎么做, 为什么是把最后一个元素送到堆顶成为堆顶元素,而不是把原来堆顶元素的儿子送到堆顶呢? 具体原因可参考相关书籍)。 此时,堆的性质已经被破坏了,所以此后要调整堆。怎么调整呢?就是一般人所说的针对 新的堆顶元素 shiftdown,逐步下移(因为新的堆顶元素由最后一个元素而来,比较大嘛, 既然是最小堆,当然大的元素就要下沉到堆的下部了)。下沉多少步呢?即如 lingyun310 所 说的,下沉 k 次就足够了。 下移 k 次之后,此时的堆顶元素已经是我们要找的第 2 小的元素。然后,取出这个第 2 小的元素(堆顶元素),再次把堆中的最后一个元素送到堆顶,又经过 k-1 次下移之后(此 后下移次数逐步减少,k-2,k-3,...k=0 后算法中断)....,如此重复 k-1 趟操作,不断取出的 堆顶元素即是我们要找的最小的 k 个数。虽然上述算法中断后整个堆已经不是最小堆了,但 是求得的 k 个最小元素已经满足我们题目所要求的了,就是说已经找到了最小的 k 个数,那 么其它的咱们不管了。 我可以再举一个形象易懂的例子。你可以想象在一个水桶中,有很多的气泡,这些气泡 从上到下,总体的趋势是逐渐增大的,但却不是严格的逐次大(正好这也符合最小堆的性质)。 ok,现在我们取出第一个气泡,那这个气泡一定是水桶中所有气泡中最小的,把它取出来, 然后把最下面的那个大气泡(但不一定是最大的气泡)移到最上面去,此时违反了气泡从上 到下总体上逐步变大的趋势,所以,要把这个大气泡往下沉,下沉到哪个位置呢?就是下沉 k 次。下沉 k 次后,最上面的气泡已经肯定是最小的气泡了,把他再次取出。然后又将最下 面最后的那个气泡移至最上面,移到最上面后,再次让它逐次下沉,下沉 k-1 次...,如此循 环往复,最终取到最小的 k 个气泡。 69 ok,所以,上面方法所述的过程,更进一步来说,其实是第一趟调整保持第 0 层到第 k 层是最小堆,第二趟调整保持第 0 层到第 k-1 层是最小堆...,依次类推。但这个思路只是下 述思路 8 中正规的最小堆算法(因为它最终对全部元素都进行了调整,算法结束后,整个堆 还是一个最小堆)的调优,时间复杂度 O(n+k^2)没有量级的提高,空间复杂度为 O(N) 也不会减少。 原理理解透了,那么写代码,就不难了,完整粗略代码如下(有问题烦请批评指正): 1. //copyright@ 泡泡鱼 2. //July、2010.06.02。 3. 4. //@lingyun310:先对元素数组原地建最小堆,O(n)。然后提取 K 次,但是每次提取时, 5. //换到顶部的元素只需要下移顶多 k 次就足够了,下移次数逐次减少。此种方法的复杂度为 O (n+k^2)。 6. #include 7. #include 8. #define MAXLEN 123456 9. #define K 100 10. 11. // 12. void HeapAdjust(int array[], int i, int Length) 13. { 14. int child,temp; 70 15. for(temp=array[i];2*i+1array[child]) 21. array[i]=array[child]; 22. else 23. break; 24. array[child]=temp; 25. } 26. } 27. 28. void Swap(int* a,int* b) 29. { 30. *a=*a^*b; 31. *b=*a^*b; 32. *a=*a^*b; 33. } 34. 35. int GetMin(int array[], int Length,int k) 36. { 37. int min=array[0]; 38. Swap(&array[0],&array[Length-1]); 39. 40. int child,temp; 41. int i=0,j=k-1; 42. for (temp=array[0]; j>0 && 2*i+1array[child]) 48. array[i]=array[child]; 49. else 50. break; 51. array[child]=temp; 52. } 53. 54. return min; 55. } 56. 57. void Kmin(int array[] , int Length , int k) 58. { 71 59. for(int i=Length/2-1;i>=0;--i) 60. //初始建堆,时间复杂度为 O(n) 61. HeapAdjust(array,i,Length); 62. 63. int j=Length; 64. for(i=k;i>0;--i,--j) 65. //k 次循环,每次循环的复杂度最多为 k 次交换,复杂度为 o(k^2) 66. { 67. int min=GetMin(array,j,i); 68. printf("%d,", min); 69. } 70. } 71. 72. int main() 73. { 74. int array[MAXLEN]; 75. for(int i=MAXLEN;i>0;--i) 76. array[MAXLEN-i] = i; 77. 78. Kmin(array,MAXLEN,K); 79. return 0; 80. } 在算法导论第 6 章有下面这样一张图,因为开始时曾一直纠结过这个问题,“取出堆顶元素之 后,把堆中下面的最后一个元素送到堆顶”。因为算法导论上下面这张图给了我一个假象,从 a) ->b)中,让我误以为是取出堆顶元素之后,是把原来堆顶元素的儿子送到堆顶。而事实上不是 这样的。因为在下面的图中,16 被删除后,堆中最后一个元素 1 代替 16 成为根结点,然后 1 下沉(注意下图所示的过程是最大堆的堆排序过程,不再是上面的最小堆了,所以小的元素当然 要下移),14 上移到堆顶。所以,图中小图图 b)是已经在小图 a)之和被调整过的最大堆了, 只是调整了 logn 次,非上面所述的 k 次。 72 ok,接下来,咱们再着重分析下上述思路 4。或许,你不会相信上述思路 4 的观点,但 我马上将用事实来论证我的观点。这几天,我一直在想,也一直在找资料查找类似快速排序 的 partition 过程的分治算法(即上述在编程之美上提到的第 4 点思路),是否能做到 O(N) 的论述或证明, 然找了三天,不但在算法导论上找到了 RANDOMIZED-SELECT,在平均情况下为线性 期望时间 O(N)的论证(请参考本文第二节),还在 mark allen weiss 所著的数据结构与 算法分析--c 语言描述一书(还得多谢朋友 sheguang 提醒)中,第 7 章第 7.7.6 节(本文 下面的第 4 节末,也有关此问题的阐述)也找到了在最坏情况下,为线性时间 O(N)(是 的,不含期望,是最坏情况下为 O(N))的快速选择算法(此算法,本文文末,也有阐述), 请看下述文字(括号里的中文解释为本人添加): Quicksort can be modified to solve the selection problem, which we have seen in chapters 1 and 6. Recall that by using a priority queue, we can find the kth largest (or smallest) element in O(n + k log n)(即上述思路 7). For the special case of finding the median, this gives an O(n log n) algorithm. Since we can sort the file in O(nlog n) time, one might expect to obtain a better time bound for selection. The algorithm we present to find the kth smallest element in a set S is almost identical to quicksort. In fact, the first three steps are the same. We will call this algorithm quickselect(叫做快速选择). Let |Si| denote the number of elements in Si(令|Si| 73 为 Si 中元素的个数). The steps of quickselect are(快速选择,即上述编程之美一书上的, 思路 4,步骤如下): 1. If |S| = 1, then k = 1 and return the elements in S as the answer. If a cutoff for small files is being used and |S| <=CUTOFF, then sort S and return the kth smallest element. 2. Pick a pivot element, v (- S.(选取一个枢纽元 v 属于 S) 3. Partition S - {v} into S1 and S2, as was done with quicksort. (将集合 S-{v}分割成 S1 和 S2,就像我们在快速排序中所作的那样) 4. If k <= |S1|, then the kth smallest element must be in S1. In this case, return quickselect (S1, k). If k = 1 + |S1|, then the pivot is the kth smallest element and we can return it as the answer. Otherwise, the kth smallest element lies in S2, and it is the (k - |S1| - 1)st smallest element in S2. We make a recursive call and return quickselect (S2, k - |S1| - 1). (如果 k<=|S1|,那么第 k 个最小元素必然在 S1 中。在这种情况下,返回 quickselect(S1,k)。 如果 k=1+|S1|,那么枢纽元素就是第 k 个最小元素,即找到,直接返回它。否则,这第 k 个最小元素就在 S2 中,即 S2 中的第(k-|S1|-1)(多谢王洋提醒修正)个最小元素,我们 递归调用并返回 quickselect(S2,k-|S1|-1))。 In contrast to quicksort, quickselect makes only one recursive call instead of two. The worst case of quickselect is identical to that of quicksort and is O(n2). Intuitively, this is because quicksort's worst case is when one of S1 and S2 is empty; thus, quickselect(快 速选择) is not really saving a recursive call. The average running time, however, is O(n) (不过,其平均运行时间为 O(N)。看到了没,就是平均复杂度为 O(N)这句话). The analysis is similar to quicksort's and is left as an exercise. The implementation of quickselect is even simpler than the abstract description might imply. The code to do this shown in Figure 7.16. When the algorithm terminates, the kth smallest element is in position k. This destroys the original ordering; if this is not desirable, then a copy must be made. 并给出了代码示例: 1. //copyright@ mark allen weiss 2. //July、updated,2011.05.05 凌晨. 3. 4. //q_select places the kth smallest element in a[k] 5. void q_select( input_type a[], int k, int left, int right ) 74 6. { 7. int i, j; 8. input_type pivot; 9. if( left + CUTOFF <= right ) 10. { 11. pivot = median3( a, left, right ); 12. //取三数中值作为枢纽元,可以消除最坏情况而保证此算法是 O(N)的。不过,这还 只局限在理论意义上。 13. //稍后,除了下文的第二节的随机选取枢纽元,在第四节末,您将看到另一种选取枢纽 元的方法。 14. 15. i=left; j=right-1; 16. for(;;) 17. { 18. while( a[++i] < pivot ); 19. while( a[--j] > pivot ); 20. if (i < j ) 21. swap( &a[i], &a[j] ); 22. else 23. break; 24. } 25. swap( &a[i], &a[right-1] ); /* restore pivot */ 26. if( k < i) 27. q_select( a, k, left, i-1 ); 28. else 29. if( k > i ) 30. q-select( a, k, i+1, right ); 31. } 32. else 33. insert_sort(a, left, right ); 34. } 结论: 1. 与快速排序相比,快速选择只做了一次递归调用而不是两次。快速选择的最坏情况 和快速排序的相同,也是 O(N^2),最坏情况发生在枢纽元的选取不当,以致 S1, 或 S2 中有一个序列为空。 2. 这就好比快速排序的运行时间与划分是否对称有关,划分的好或对称,那么快速排 序可达最佳的运行时间 O(n*logn),划分的不好或不对称,则会有最坏的运行时间 为 O(N^2)。而枢纽元的选取则完全决定快速排序的 partition 过程是否划分对称。 3. 快速选择也是一样,如果枢纽元的选取不当,则依然会有最坏的运行时间为 O(N^2) 的情况发生。那么,怎么避免这个最坏情况的发生,或者说就算是最坏情况下,亦 能保证快速选择的运行时间为 O(N)列 ?对了,关键,还是看你的枢纽元怎么选取。 75 4. 像上述程序使用三数中值作为枢纽元的方法可以使得最坏情况发生的概率几乎可以 忽略不计。然而,稍后,在本文第四节末,及本文文末,您将看到:通过一种更好 的方法,如“五分化中项的中项”,或“中位数的中位数”等方法选取枢纽元,我们将能 彻底保证在最坏情况下依然是线性 O(N)的复杂度。 至于编程之美上所述:从数组中随机选取一个数 X,把数组划分为 Sa 和 Sb 俩部分,那 么这个问题就转到了下文第二节 RANDOMIZED-SELECT,以线性期望时间做选择,无论 如何,编程之美上的解法二的复杂度为 O(n*logk)都是有待商榷的。至于最坏情况下一种 全新的,为 O(N)的快速选择算法,直接跳转到本文第四节末,或文末部分吧)。 不过,为了公正起见,把编程之美第 141 页上的源码贴出来,由大家来评判: 1. Kbig(S, k): 2. if(k <= 0): 3. return [] // 返回空数组 4. if(length S <= k): 5. return S 6. (Sa, Sb) = Partition(S) 7. return Kbig(Sa, k).Append(Kbig(Sb, k – length Sa) 8. 9. Partition(S): 10. Sa = [] // 初始化为空数组 11. Sb = [] // 初始化为空数组 12. Swap(s[1], S[Random()%length S]) // 随机选择一个数作为分组标准,以 13. // 避免特殊数据下的算法退化,也可 14. // 以通过对整个数据进行洗牌预处理 15. // 实现这个目的 16. p = S[1] 17. for i in [2: length S]: 18. S[i] > p ? Sa.Append(S[i]) : Sb.Append(S[i]) 19. // 将 p 加入较小的组,可以避免分组失败,也使分组 20. // 更均匀,提高效率 21. length Sa < length Sb ? Sa.Append(p) : Sb.Append(p) 22. return (Sa, Sb) 你已经看到,它是随机选取数组中的任一元素为枢纽的,这就是本文下面的第二节 RANDOMIZED-SELECT 的问题了,只是要修正的是,此算法的平均时间复杂度为线性期 望 O(N)的时间。而,稍后在本文的第四节或本文文末,您还将会看到此问题的进一步阐 述(SELECT 算法,即快速选择算法),此 SELECT 算法能保证即使在最坏情况下,依然 是线性 O(N)的复杂度。 76 updated: 1、为了照顾手中没编程之美这本书的 friends,我拍了张照片,现贴于下供参考(提醒: 1、书上为寻找最大的 k 个数,而我们面对的问题是寻找最小的 k 个数,两种形式,一个本 质(该修改的地方,上文已经全部修改)。2、书中描述与上文思路 4 并无原理性出入,不 过,勿被图中记的笔记所误导,因为之前也曾被书中的这个 n*logk 复杂度所误导过。ok, 相信,看完本文后,你不会再有此疑惑): 2、同时,在编程之美原书上此节的解法五的开头提到,“上面类似快速排序的方法平均时 间复杂度是线性的”,我想上面的类似快速排序的方法,应该是指解法(即如上所述的类似 快速排序 partition 过程的方法),但解法二得出的平均时间复杂度却为 O(N*logk),明摆 着前后矛盾(参见下图)。 77 3、此文创作后的几天,已把本人的意见反馈给邹欣等人,下面是编程之美 bop1 的改版 修订地址的页面截图(本人也在参加其改版修订的工作),下面的文字是我的记录(同时, 本人声明,此狂想曲系列文章系我个人独立创作,与其它的事不相干): 第二节、Randomized-Select,线性期望时间 下面是 RANDOMIZED-SELECT(A, p, r)完整伪码(来自算法导论),我给了注释,或许 能给你点启示。在下结论之前,我还需要很多的时间去思量,以确保结论之完整与正确。 PARTITION(A, p, r) //partition 过程 p 为第一个数,r 为最后一个数 1 x ← A[r] //以最后一个元素作为主元 2 i ← p - 1 78 3 for j ← p to r - 1 4 do if A[j] ≤ x 5 then i ← i + 1 6 exchange A[i] <-> A[j] 7 exchange A[i + 1] <-> A[r] 8 return i + 1 RANDOMIZED-PARTITION(A, p, r) //随机快排的 partition 过程 1 i ← RANDOM(p, r) //i 随机取 p 到 r 中个一个值 2 exchange A[r] <-> A[i] //以随机的 i 作为主元 3 return PARTITION(A, p, r) //调用上述原来的 partition 过程 RANDOMIZED-SELECT(A, p, r, i) //以线性时间做选择,目的是返回数组 A[p..r]中的第 i 小的元素 1 if p = r //p=r,序列中只有一个元素 2 then return A[p] 3 q ← RANDOMIZED-PARTITION(A, p, r) //随机选取的元素 q 作为主元 4 k ← q - p + 1 //k 表示子数组 A[p…q]内的元素个数,处于划分低区的元素个 数加上一个主元元素 5 if i == k //检查要查找的 i 等于子数组中 A[p....q]中的元素个数 k 6 then return A[q] //则直接返回 A[q] 7 else if i < k 8 then return RANDOMIZED-SELECT(A, p, q - 1, i) //得到的 k 大于要查找的 i 的大小,则递归到低区间 A[p,q-1]中去查找 9 else return RANDOMIZED-SELECT(A, q + 1, r, i - k) //得到的 k 小于要查找的 i 的大小,则递归到高区间 A[q+1,r]中去查找。 写此文的目的,在于起一个抛砖引玉的作用。希望,能引起你的重视及好的思路,直到 有个彻底明白的结果。 updated:算法导论原英文版有关于 RANDOMIZED-SELECT(A, p, r)为 O(n)的证明。 为了一个彻底明白的阐述,我现将其原文的证明自个再翻译加工后,阐述如下: 此 RANDOMIZED-SELECT 最坏情况下时间复杂度为Θ(n2),即使是要选择最小元素也是如此, 因为在划分时可能极不走运,总是按余下元素中的最大元素进行划分,而划分操作需要 O(n) 的时间。 然而此算法的平均情况性能极好,因为它是随机化的,故没有哪一种特别的输入会导致其 79 最坏情况的发生。 算法导论上,针对此 RANDOMIZED-SELECT 算法平均时间复杂度为 O(n)的证明,引用如 下,或许,能给你我多点的启示(本来想直接引用第二版中文版的翻译文字,但在中英文对照阅 读的情况下,发现第二版中文版的翻译实在不怎么样,所以,得自己一个一个字的敲,最终敲完 修正如下),分 4 步证明: 1、当 RANDOMIZED-SELECT 作用于一个含有 n 个元素的输入数组 A[p ..r]上时,所需时间是 一个随机变量,记为 T(n), 我们可以这样得到线性期望值 E [T(n)] 的 下 界 : 程 序 RANDOMIZED-PARTITION 会以等同的可能性返回数组中任何一个元素为主元,因此,对于每一个 k,( 1 ≤k ≤n),子数组 A[p ..q]有 k 个元素,它们全部小于或等于主元元素的概率为 1/n.对 k = 1, 2,...,n,我们定指示器 Xk,为: Xk = I{子数组 A[p ..q]恰有 k 个元素} , 我们假定元素的值不同,因此有 E[Xk]=1/n 当调用 RANDOMIZED-SELECT 并且选择 A[q]作为主元元素的时候,我们事先不知道是否会立 即找到我们所想要的第 i 小的元素,因为,我们很有可能需要在子数组 A[p ..q - 1], 或 A[q + 1 ..r] 上递归继续进行寻找.具体在哪一个子数组上递归寻找,视第 i 小的元素与 A[q]的相对位置而定. 2、假设 T(n)是单调递增的,我们可以将递归所需时间的界限限定在输入数组时可能输入的 所需递归调用的最大时间(此句话,原中文版的翻译也是有问题的).换言之,我们断定,为得到一 个上界,我们假定第 i 小的元素总是在划分的较大的一边,对一个给定的 RANDOMIZED-SELECT, 指示器 Xk 刚好在一个 k 值上取 1,在其它的 k 值时,都是取 0.当 Xk =1 时,可能要递归处理的俩 个子数组的大小分别为 k-1,和 n-k,因此可得到递归式为 取期望值为: 为了能应用等式(C.23),我们依赖于 Xk 和 T(max(k - 1,n - k))是独立的随机变量(这个可以证明, 80 证明此处略)。 3、下面,我们来考虑下表达式 max(k - 1,n -k)的结果.我们有: 如果 n 是偶数,从 T(⌉)到 T(n - 1)每个项在总和中刚好出现俩次,T(⌋)出现一次。因此,有 我们可以用替换法来解上面的递归式。假设对满足这个递归式初始条件的某个常数 c,有 T(n) ≤cn。我们假设对于小于某个常数 c(稍后再来说明如何选取这个常数)的 n,有 T(n) =O(1)。 同 时,还要选择一个常数 a,使得对于所有的 n>0,由上式中 O(n)项(用来描述这个算法的运行时间 中非递归的部分)所描述的函数,可由 an 从上方限界得到(这里,原中文版的翻译的确是有点含 糊)。利用这个归纳假设,可以得到: (此段原中文版翻译有点问题,上述文字已经修正过来,对应的此段原英文为:We solve the recurrence by substitution. Assume thatT(n) ≤ cn for some constant c that satisfies the initial conditions of the recurrence. We assume thatT(n) =O(1) forn less than some constant; we shall pick this constant later. We also pick a constanta such that the function described by theO(n) term above (which describes the non-recursive component of the running time of the algorithm) is bounded from above byan for alln> 0. Using this inductive hypothesis, we have) 4、为了完成证明,还需要证明对足够大的 n,上面最后一个表达式最大为 cn,即要证明: cn/4 -c/2 -an ≥ 0.如果在俩边加上 c/2,并且提取因子 n,就可以得到 n(c/4 -a) ≥c/2.只要我们选 择的常数 c 能满足 c/4 -a > 0, i.e.,即 c > 4a,我们就可以将俩边同时除以 c/4 -a, 最终得到: 81 综上,如果假设对 n < 2c/(c -4a),有 T(n) =O(1),我们就能得到 E[T(n)] =O(n)。所以,最终我 们可以得出这样的结论,并确认无疑:在平均情况下,任何顺序统计量(特别是中位数)都可 以在线性时间内得到。 结论: 如你所见,RANDOMIZED-SELECT 有线性期望时间 O(N)的复杂度,但此 RANDOMIZED-SELECT 算法在最坏情况下有 O(N^2)的复杂度。所以,我们得找出一种 在最坏情况下也为线性时间的算法。稍后,在本文的第四节末,及本文文末部分,你将看到 一种在最坏情况下是线性时间 O(N)的复杂度的快速选择 SELECT 算法。 第三节、各执己见,百家争鸣 updated :本文昨晚发布后,现在朋友们之间,主要有以下几种观点(在彻底弄清之前, 最好不要下结论): 1. luuillu:我不认为随机快排比直接快排的时间复杂度小。使用快排处理数据前,我 们是不知道数据的排列规律的,因此一般情况下,被处理的数据本来就是一组随机 数据,对于随机数据再多进行一次随机化处理,数据仍然保持随机性,对排序没有 更好的效果。 对一组数据采用随选主元的方法,在极端的情况下,也可能出现每 次选出的主元恰好是从大到小排列的,此时时间复杂度为 O(n^2).当然这个概率 极低。随机选主元的好处在于,由于在现实中常常需要把一些数据保存为有序数据, 因此,快速排序碰到有序数据的概率就会高一些,使用随机快排可以提高对这些数 据的处理效率。这个概率虽然高一些,但仍属于特殊情况,不影响一般情况的时间 复杂度。我觉得楼主上面提到的的思路 4 和思路 5 的时间复杂度是一样的。 2. 571 楼 得分:0 Sorehead 回复于:2011-03-09 16:29:58 关于第五题: Sorehead: 这两天我总结了一下,有以下方法可以实现: 1、第一次遍历取出最小的元素,第二次遍历取出第二小的元素,依次直到第 k 次遍历取出第 k 小的元素。这种方法最简单,时间复杂度是 O(k*n)。看上去效率很 差,但当 k 很小的时候可能是最快的。 2、对这 n 个元素进行排序,然后取出前 k 个数据即可,可以采用比较普遍的堆 排序或者快速排序,时间复杂度是 O(n*logn)。这种方法有着很大的弊端,题目并没 82 有要求这最小的 k 个数是排好序的,更没有要求对其它数据进行排序,对这些数据 进行排序某种程度上来讲完全是一种浪费。而且当 k=1 时,时间复杂度依然是 O(n*logn)。 3、可以把快速排序改进一下,应该和楼主的 kth_elem 一样,这样的好处是不 用对所有数据都进行排序。平均时间复杂度应该是 O(n*logk)。( 在本文最后一节, 你或将看到,复杂度可能应该为 O(n)) 4、使用我开始讲到的平衡二叉树或红黑树,树只用来保存 k 个数据即可,这样 遍历所有数据只需要一次。时间复杂度为 O(n*logk)。后来我发现这个思路其实可以 再改进,使用堆排序中的堆,堆中元素数量为 k,这样堆中最大元素就是头节点, 遍历所有数据时比较次数更少,当然时间复杂度并没有变化。 5、使用计数排序的方法,创建一个数组,以元素值为该数组下标,数组的值为 该元素在数组中出现的次数。这样遍历一次就可以得到这个数组,然后查询这个数 组就可以得到答案了。时间复杂度为 O(n)。如果元素值没有重复的,还可以使用位 图方式。这种方式有一定局限性,元素必须是正整数,并且取值范围不能太大,否 则就造成极大的空间浪费,同时时间复杂度也未必就是 O(n)了。当然可以再次改进, 使用一种比较合适的哈希算法来代替元素值直接作为数组下标。 3. litaoye:按照算法导论上所说的,最坏情况下线性时间找第 k 大的数。证明一下: 把数组中的元素,5 个分为 1 组排序,排序需要进行 7 次比较(2^7 > 5!),这样需要 1.4 * n 次比较,可以完成所有组的排序。取所有组的中位数,形成一个新的数组, 有 n/5 个元素,5 个分为 1 组排序,重复上面的操作,直到只剩下小于 5 个元素, 找出中位数。根据等比数列求和公式,求出整个过程的比较次数:7/5 + 7/25 + 7/125 +...... = 7/4,用 7/4 * n 次比较可以找出中位数的中位数 M。能够证明,整个数组 中>=M 的数超过 3*n / 10 - 6,<=M 的数超过 3*n / 10 - 6。以 M 为基准,执行上面 的 PARTITION,每次至少可以淘汰 3*n / 10 - 6,约等于 3/10 * n 个数,也就是说是 用(7/4 + 1) * n 次比较之后,最坏情况下可以让数据量变为原来的 7/10,同样根据 等比数列求和公式,可以算出最坏情况下找出第 k 大的数需要的比较次数,1 + 7/10 + 49/100 + .... = 10/3, 10/3 * 11/4 * n = 110/12 * n,也就是说整个过程是 O(n)的, 尽管隐含的常数比较大 。 总结: 关于 RANDOMIZED-SELECT(A, q + 1, r, i - k),期望运行时间为 O(n)已经没有疑问 了,更严格的论证在上面的第二节也已经给出来了。 83 ok,现在,咱们剩下的问题是,除了此 RANDOMIZED-SELECT(A, q + 1, r, i - k)方 法(实用价值并不大)和计数排序,都可以做到 O(n)之外,还有类似快速排序的 partition 过程,是否也能做到 O(n)? 第四节、类似 partition 过程,最坏亦能做到 O(n)? 我想,经过上面的各路好汉的思路轰炸,您的头脑和思维肯定有所混乱了。ok,下面,我 尽量以通俗易懂的方式来继续阐述咱们的问题。上面第三节的总结提出了一个问题,即类似 快速排序的 partition 过程,是否也能做到 O(n)? 我们说对 n 个数进行排序,快速排序的平均时间复杂度为 O(n*logn),这个 n*logn 的 时间复杂度是如何得来的列? 经过之前我的有关快速排序的三篇文章,相信您已经明了了以下过程:快速排序每次选取 一个主元 X,依据这个主元 X,每次把整个序列划分为 A,B 俩个部分,且有 Axk,那么接下来要到低区间 A[0....m-1]中寻找,丢掉高区间; 3、如果 mk 的时候,好说,区间总是被不断的均分为俩个区间(理想情况),那么最后 的时间复杂度如 luuillu 所说,T(n)=n + T(n/2) = n + n/2 + n/4 + n/8 + ...+1 . 式中一共 logn 项。可得出:T(n)为 O(n)。 但当 m 5. #include 6. using namespace std; 7. 8. int kth_elem(int a[], int low, int high, int k) 9. { 10. int pivot = a[low]; 11. //这个程序之所以做不到 O(N)的最最重要的原因,就在于这个枢纽元的选取。 85 12. //而这个程序直接选取数组中第一个元素作为枢纽元,是做不到平均时间复杂度为 O(N) 的。 13. 14. //要 做到,就必须 把上面选取枢纽元的 代码改掉,要么是随机选择数组中某一元素作为 枢纽元,能达到线性期望的时间 15. //要么是选取数组中中位数的中位数作为枢纽元,保证最坏情况下,依然为线性 O(N)的平 均时间复杂度。 16. int low_temp = low; 17. int high_temp = high; 18. while(low < high) 19. { 20. while(low < high && a[high] >= pivot) 21. --high; 22. a[low] = a[high]; 23. while(low < high && a[low] < pivot) 24. ++low; 25. a[high] = a[low]; 26. } 27. a[low] = pivot; 28. 29. //以下就是主要思想中所述的内容 30. if(low == k - 1) 31. return a[low]; 32. else if(low > k - 1) 33. return kth_elem(a, low_temp, low - 1, k); 34. else 35. return kth_elem(a, low + 1, high_temp, k); 36. } 37. 38. int main() //以后尽量不再用随机产生的数组进行测试,没多大必要。 39. { 40. for (int num = 5000; num < 50000001; num *= 10) 41. { 42. int *array = new int[num]; 43. 44. int j = num / 10; 45. int acc = 0; 46. for (int k = 1; k <= num; k += j) 47. { 48. // 随机生成数据 49. srand(unsigned(time(0))); 50. for(int i = 0; i < num; i++) 51. array[i] = rand() * RAND_MAX + rand(); 86 52. //”如果数组本身就是利用随机化产生的话,那么选择其中任何一个元素作为枢轴 都可以看作等价于随机选择枢轴, 53. //(虽然这不叫随机选择枢纽)”,这句话,是完全不成立的,是错误的。 54. 55. //“因为你总是选择 随机数组中第一个元素 作为枢纽元,不是 随机选择枢纽 元” 56. //相当于把上面这句话中前面的 “随机” 两字去掉,就是: 57. //因为 你总是选择数组中第一个元素作为枢纽元,不是 随机选择枢纽元。 58. //所以,这个程序,始终做不到平均时间复杂度为 O(N)。 59. 60. //随机数组和给定一个非有序而随机手动输入的数组,是一个道理。稍后,还将 就程序的运行结果继续解释这个问题。 61. //July、updated,2011.05.18。 62. 63. // 计算一次查找所需的时钟周期数 64. clock_t start = clock(); 65. int data = kth_elem(array, 0, num - 1, k); 66. clock_t end = clock(); 67. acc += (end - start); 68. } 69. cout << "The average time of searching a date in the array size of " << num << " is " << acc / 10 << endl; 70. } 71. return 0; 72. } 关于上述程序的更多阐述,请参考此文第三章续、Top K 算法问题的实现中,第一节有关实 现三的说明。 updated: 近日,再次在 Mark Allen Weiss 的数据结构与算法分析一书上,第 10 章,第 10.2.3 节 看到了关于此分治算法的应用,平均时间复杂度为 O(N)的阐述与证明,可能本文之前的 叙述将因此而改写(July、updated,2011.05.05): The selection problem requires us to find the kth smallest element in a list S of n elements(要求我们找出含 N 个元素的表 S 中的第 k 个最小的元素). Of particular interest is the special case of finding the median. This occurs when k = |-n/2-|(向上取整).(我们 对找出中间元素的特殊情况有着特别的兴趣,这种情况发生在 k=|-n/2-|的时候) In Chapters 1, 6, 7 we have seen several solutions to the selection problem. The solution in Chapter 7 uses a variation of quicksort and runs in O(n) average time(第 7 章 87 中的解法,即本文上面第 1 节所述的思路 4,用到快速排序的变体并以平均时间 O(N)运 行). Indeed, it is described in Hoare's original paper on quicksort. Although this algorithm runs in linear average time, it has a worst case of O (n2)(但它 有一个 O(N^2)的最快情况). Selection can easily be solved in O(n log n) worst-case time by sorting the elements, but for a long time it was unknown whether or not selection could be accomplished in O(n) worst-case time. The quickselect algorithm outlined in Section 7.7.6 is quite efficient in practice, so this was mostly a question of theoretical interest. Recall that the basic algorithm is a simple recursive strategy. Assuming that n is larger than the cutoff point where elements are simply sorted, an element v, known as the pivot, is chosen. The remaining elements are placed into two sets, S1 and S2. S1 contains elements that are guaranteed to be no larger than v, and S2 contains elements that are no smaller than v. Finally, if k <= |S1|, then the kth smallest element in S can be found by recursively computing the kth smallest element in S1. If k = |S1| + 1, then the pivot is the kth smallest element. Otherwise, the kth smallest element in S is the (k - |S1| -1 )st smallest element in S2. The main difference between this algorithm and quicksort is that there is only one subproblem to solve instead of two(这个快速选择算法与快速排序之间的 主要区别在于,这里求解的只有一个子问题,而不是两个子问题)。 定理 10.9 The running time of quickselect using median-of-median-of-five partitioning is O(n)。 The basic idea is still useful. Indeed, we will see that we can use it to improve the expected number of comparisons that quickselect makes. To get a good worst case, however, the key idea is to use one more level of indirection. Instead of finding the median from a sample of random elements, we will find the median from a sample of medians. The basic pivot selection algorithm is as follows: 1. Arrange the n elements into |_n/5_| groups of 5 elements, ignoring the (at most four) extra elements. 2. Find the median of each group. This gives a list M of |_n/5_| medians. 3. Find the median of M. Return this as the pivot, v. We will use the term median-of-median-of-five partitioning to describe the quickselect algorithm that uses the pivot selection rule given above. (我们将用术语“五分化中项的中 项”来描述使用上面给出的枢纽元选择法的快速选择算法)。We will now show that 88 median-of-median-of-five partitioning guarantees that each recursive subproblem is at most roughly 70 percent as large as the original(现在我们要证明,“五分化中项的中项”, 得保证每个递归子问题的大小最多为原问题的大约 70%). We will also show that the pivot can be computed quickly enough to guarantee an O (n) running time for the entire selection algorithm(我们还要证明,对于整个选择算法,枢纽元可以足够快的算出,以确 保 O(N)的运行时间。看到了没,这再次佐证了我们的类似快速排序的 partition 过程的分 治方法为 O(N)的观点)。 .......... 证明从略,更多,请参考 Mark Allen Weiss 的数据结构与算法分析--c 语言描述一书上, 第 10 章,第 10.2.3 节。 updated again: 为了给读者一个彻彻底底、明明白白的论证,我还是决定把书上面的整个论证过程全 程贴上来,下面,接着上面的内容,然后直接从其中文译本上截两张图来说明好了(更清晰 明了): 89 90 关于上图提到的定理 10.8,如下图所示,至于证明,留给读者练习(可参考本文第二节关 于 RANDOMIZED-SELECT 为线性时间的证明): ok,第四节,有关此问题的更多论述,请参见下面的本文文末 updated again 部分。 第五节、堆结构实现,处理海量数据 91 文章,可不能这么完了,咱们还得实现一种靠谱的方案,从整个文章来看,处理这个寻 找最小的 k 个数,最好的方案是第一节中所提到的思路 3:当然,更好的办法是维护 k 个元 素的最大堆,原理与上述第 2 个方案一致,即用容量为 k 的最大堆存储最小的 k 个数,此时, k1 5. #include 6. using namespace std; 7. void MaxHeap(int heap[], int i, int len); 8. /*------------------- 9. BUILD-MIN-HEAP(A) 10. 1 heap-size[A] ← length[A] 11. 2 for i ← |_length[A]/2_| downto 1 12. 3 do MAX-HEAPIFY(A, i) 13. */ 14. // 建立大根堆 15. void BuildHeap(int heap[], int len) 16. { 17. if (heap == NULL) 18. return; 19. 20. int index = len / 2; 21. for (int i = index; i >= 1; i--) 22. MaxHeap(heap, i, len); 23. } 24. /*---------------------------- 25. PARENT(i) 26. return |_i/2_| 92 27. LEFT(i) 28. return 2i 29. RIGHT(i) 30. return 2i + 1 31. MIN-HEAPIFY(A, i) 32. 1 l ← LEFT(i) 33. 2 r ← RIGHT(i) 34. 3 if l ≤ heap-size[A] and A[l] < A[i] 35. 4 then smallest ← l 36. 5 else smallest ← i 37. 6 if r ≤ heap-size[A] and A[r] < A[smallest] 38. 7 then smallest ← r 39. 8 if smallest ≠ i 40. 9 then exchange A[i] <-> A[smallest] 41. 10 MIN-HEAPIFY(A, smallest) 42. */ 43. //调整大根堆 44. void MaxHeap(int heap[], int i, int len) 45. { 46. int largeIndex = -1; 47. int left = i * 2; 48. int right = i * 2 + 1; 49. 50. if (left <= len && heap[left] > heap[i]) 51. largeIndex = left; 52. else 53. largeIndex = i; 54. 55. if (right <= len && heap[right] > heap[largeIndex]) 56. largeIndex = right; 57. 58. if (largeIndex != i) 59. { 60. swap(heap[i], heap[largeIndex]); 61. MaxHeap(heap, largeIndex, len); 62. } 63. } 64. int main() 65. { 66. // 定义数组存储堆元素 67. int k; 68. cin >> k; 69. int *heap = new int [k+1]; //注,只需申请存储 k 个数的数组 93 70. FILE *fp = fopen("data.txt", "r"); //从文件导入海量数据(便于测试,只截取了 9M 的数据大小) 71. assert(fp); 72. 73. for (int i = 1; i <= k; i++) 74. fscanf(fp, "%d ", &heap[i]); 75. 76. BuildHeap(heap, k); //建堆 77. 78. int newData; 79. while (fscanf(fp, "%d", &newData) != EOF) 80. { 81. if (newData < heap[1]) //如果遇到比堆顶元素 kmax 更小的,则更新堆 82. { 83. heap[1] = newData; 84. MaxHeap(heap, 1, k); //调整堆 85. } 86. 87. } 88. 89. for (int j = 1; j <= k; j++) 90. cout << heap[j] << " "; 91. cout << endl; 92. 93. fclose(fp); 94. return 0; 95. } 咱们用比较大量的数据文件测试一下,如这个数据文件: 94 输入 k=4,即要从这大量的数据中寻找最小的 k 个数,可得到运行结果,如下图所示: 至于,这 4 个数,到底是不是上面大量数据中最小的 4 个数,这个,咱们就无从验证了, 非人力之所能及也。毕。 第六节、stl 之_nth_element ,逐步实现 以下代码摘自 stl 中_nth_element 的实现,且逐步追踪了各项操作,其完整代码如下: 95 1. //_nth_element(...)的实现 2. template 3. void __nth_element(RandomAccessIterator first, RandomAccessIterator nth, 4. RandomAccessIterator last, T*) { 5. while (last - first > 3) { 6. RandomAccessIterator cut = __unguarded_partition //下面追踪 __unguarded_partition 7. (first, last, T(__median(*first, *(first + (last - first)/2), 8. *(last - 1)))); 9. if (cut <= nth) 10. first = cut; 11. else 12. last = cut; 13. } 14. __insertion_sort(first, last); //下面追踪__insertion_sort(first, last) 15. } 16. 17. //__unguarded_partition()的实现 18. template 19. RandomAccessIterator __unguarded_partition(RandomAccessIterator first, 20. RandomAccessIterator last, 21. T pivot) { 22. while (true) { 23. while (*first < pivot) ++first; 24. --last; 25. while (pivot < *last) --last; 26. if (!(first < last)) return first; 27. iter_swap(first, last); 28. ++first; 29. } 30. } 31. 32. //__insertion_sort(first, last)的实现 33. template 34. void __insertion_sort(RandomAccessIterator first, RandomAccessIterator last) { 35. if (first == last) return; 36. for (RandomAccessIterator i = first + 1; i != last; ++i) 37. __linear_insert(first, i, value_type(first)); //下面追踪 __linear_insert 38. } 39. 40. //_linear_insert()的实现 41. template 96 42. inline void __linear_insert(RandomAccessIterator first, 43. RandomAccessIterator last, T*) { 44. T value = *last; 45. if (value < *first) { 46. copy_backward(first, last, last + 1); //这个追踪,待续 47. *first = value; 48. } 49. else 50. __unguarded_linear_insert(last, value); //最后,再追踪 __unguarded_linear_insert 51. } 52. 53. //_unguarded_linear_insert()的实现 54. template 55. void __unguarded_linear_insert(RandomAccessIterator last, T value) { 56. RandomAccessIterator next = last; 57. --next; 58. while (value < *next) { 59. *last = *next; 60. last = next; 61. --next; 62. } 63. *last = value; 64. } 第七节、再探 Selection_algorithm,类似 partition 方法 O(n)再次求证 网友反馈: stupidcat:用类似快排的 partition 的方法,只求 2 边中的一边,在 O(N)时间得到第 k 大 的元素 v; 弄完之后,vector &data 的前 k 个元素,就是最小的 k 个元素了。 时间复杂度是 O(N),应该是最优的算法了。并给出了代码示例: 1. //copyright@ stupidcat 2. //July、updated,2011.05.08 3. int Partition(vector &data, int headId, int tailId) 4. //这里,采用的是算法导论上的 partition 过程方法 5. { 6. int posSlow = headId - 1, posFast = headId; //一前一后,俩个指针 7. for (; posFast < tailId; ++posFast) 8. { 9. if (data[posFast] < data[tailId]) //以最后一个元素作为主元 10. { 97 11. ++posSlow; 12. swap(data[posSlow], data[posFast]); 13. } 14. } 15. ++posSlow; 16. swap(data[posSlow], data[tailId]); 17. return posSlow; //写的不错,命名清晰 18. } 19. 20. void FindKLeast(vector &data, int headId, int tailId, int k) 21. //寻找第 k 小的元素 22. { 23. if (headId < tailId) 24. { 25. int midId = Partition(data, headId, tailId); 26. //可惜这里,没有随机或中位数的方法选取枢纽元(主元),使得本程序思路虽对,却 不达 O(N)的目标 27. 28. if (midId > k) 29. { 30. FindKLeast(data, headId, midId - 1, k); //kmidid,递归到高 区间找 38. } 39. } 40. } 41. } 42. 43. void FindKLeastNumbers(vector &data, unsigned int k) 44. { 45. int len = data.size(); 46. if (k > len) 47. { 48. throw new std::exception("Invalid argument!"); 49. } 50. FindKLeast(data, 0, len - 1, k); 51. } 98 看来,这个问题,可能会因此纠缠不清了,近日,在维基百科的英文页面上,找到有关 Selection_algorithm 的资料,上面给出的示例代码为: 1. function partition(list, left, right, pivotIndex) 2. pivotValue := list[pivotIndex] 3. swap list[pivotIndex] and list[right] // Move pivot to end 4. storeIndex := left 5. for i from left to right 6. if list[i] < pivotValue 7. swap list[storeIndex] and list[i] 8. increment storeIndex 9. swap list[right] and list[storeIndex] // Move pivot to its final place 10. return storeIndex 11. 12. function select(list, left, right, k) 13. if left = right 14. return list[left] 15. select pivotIndex between left and right 16. pivotNewIndex := partition(list, left, right, pivotIndex) 17. pivotDist := pivotNewIndex - left + 1 18. if pivotDist = k 19. return list[pivotNewIndex] 20. else if k < pivotDist 21. return select(list, left, pivotNewIndex - 1, k) 22. else 23. return select(list, pivotNewIndex + 1, right, k - pivotDist) 这个算法,其实就是在本人这篇文章:当今世界最受人们重视的十大经典算法里提到的: 第三名:BFPRT 算法: A worst-case linear algorithm for the general case of selecting the kth largest element was published by Blum, Floyd, Pratt, Rivest and Tarjan in their 1973 paper "Time bounds for selection", sometimes called BFPRT after the last names of the authors. It is based on the quickselect algorithm and is also known as the median-of-medians algorithm. 同时据维基百科上指出,若能选取一个好的 pivot,则此算法能达到 O(n)的最佳时间 复杂度。 99 The median-calculating recursive call does not exceed worst-case linear behavior because the list of medians is 20% of the size of the list, while the other recursive call recurs on at most 70% of the list, making the running time T(n) ≤ T(n/5) + T(7n/10) + O(n) The O(n) is for the partitioning work (we visited each element a constant number of times, in order to form them into O(n) groups and take each median in O(1) time). From this, one can then show that T(n) ≤ c*n*(1 + (9/10) + (9/10)2 + ...) = O(n). 当然,上面也提到了用堆这个数据结构,扫描一遍数组序列,建 k 个元素的堆 O(k)的 同时,调整堆(logk),然后再遍历剩下的 n-k 个元素,根据其与堆顶元素的大小比较,决 定是否更新堆,更新一次 logk,所以,最终的时间复杂度为 O(k*logk+(n-k)*logk)=O (n*logk)。 Another simple method is to add each element of the list into an ordered set data structure, such as a heap or self-balancing binary search tree, with at most k elements. Whenever the data structure has more than k elements, we remove the largest element, which can be done in O(log k) time. Each insertion operation also takes O(log k) time, resulting in O(nlog k) time overall. 而如果上述类似快速排序的 partition 过程的 BFPRT 算法成立的话,则将最大限度的优 化了此寻找第 k 个最小元素的算法复杂度(经过第 1 节末+第二节+第 4 节末的 updated, 以及本节的论证,现最终确定,运用类似快速排序的 partition 算法寻找最小的 k 个元素能做 到 O(N)的复杂度,并确认无疑。July、updated,2011.05.05.凌晨)。 updated again: 为了再次佐证上述论证之不可怀疑的准确性,我再原文引用下第九章第 9.3 节全部内容 (最坏情况线性时间的选择),如下(我酌情对之参考原中文版做了翻译,下文中括号内的 中文解释,为我个人添加): 9.3 Selection in worst-case linear time(最坏情况下线性时间的选择算法) We now examine a selection algorithm whose running time isO(n) in the worst case(现在来看,一 100 个最坏情况运行时间为 O(N)的选择算法 SELECT). Like RANDOMIZED-SELECT, the algorithm SELECT finds the desired element by recursively partitioning the input array. The idea behind the algorithm, however, is toguarantee a good split when the array is partitioned. SELECT uses the deterministic partitioning algorithm PARTITION from quicksort (seeSection 7.1), modified to take the element to partition around as an input parameter(像 RANDOMIZED-SELECT 一样,SELECTT 通过输入数组的递 归划分来找出所求元素,但是,该算法的基本思想是要保证对数组的划分是个好的划分。SECLECT 采用了取自快速排序的确定性划分算法 partition,并做了修改,把划分主元元素作为其参数). The SELECT algorithm determines theith smallest of an input array ofn > 1 elements by executing the following steps. (Ifn = 1, then SELECT merely returns its only input value as theith smallest.)(算法 SELECT 通过执行下列步骤来确定一个有 n>1 个元素的输入数组中的第 i 小的元素。(如果 n=1, 则 SELECT 返回它的唯一输入数值作为第 i 个最小值。)) 1. Divide then elements of the input array into⌋ groups of 5 elements each and at most one group made up of the remainingn mod 5 elements. 2. Find the median of each of the⌉ groups by first insertion sorting the elements of each group (of which there are at most 5) and then picking the median from the sorted list of group elements. 3. Use SELECT recursively to find the medianx of the⌉ medians found in step 2. (If there are an even number of medians, then by our convention,x is the lower median.) 4. Partition the input array around the median-of-mediansx using the modified version of PARTITION. Letk be one more than the number of elements on the low side of the partition, so thatx is thekth smallest element and there aren-k elements on the high side of the partition.(利用修改过的 partition 过程,按中位数的中位数 x 对输入数组进行划分,让 k 比划低去的元素数目多 1,所以,x 是第 k 小的元素,并且有 n-k 个元素在划分的高区) 5. Ifi =k, then returnx. Otherwise, use SELECT recursively to find theith smallest element on the low side ifi k.(如果要找的第 i 小的元 素等于程序返回的 k,即 i=k,则返回 x。否则,如果 ik,则在高区间找第(i-k)个最小元素) 101 (以上五个步骤,即本文上面的第四节末中所提到的所谓“五分化中项的中项”的方法。) To analyze the running time of SELECT, we first determine a lower bound on the number of elements that are greater than the partitioning element x. (为了分析 SELECT 的运行时间,先来确 定大于划分主元元素 x 的的元素数的一个下界)Figure 9.1 is helpful in visualizing this bookkeeping. At least half of the medians found in step 2 are greater than[1] the median-of-medians x. Thus, at least half of the ⌉ groupscontribute 3 elements that are greater than x, except for the one group that has fewer than 5 elements if 5 does not dividen exactly, and the one group containingx itself. Discounting these two groups, it follows that the number of elements greater thanx is at least: (Figure 9.1: 对上图的解释或称对 SELECT 算法的分析:n 个元素由小圆圈来表示,并且每一个 组占一纵列。组的中位数用白色表示,而各中位数的中位数 x 也被标出。(当寻找偶数数目元素 的中位数时,使用下中位数)。箭头从比较大的元素指向较小的元素,从中可以看出,在 x 的右 边,每一个包含 5 个元素的组中都有 3 个元素大于 x,在 x 的左边,每一个包含 5 个元素的组中 有 3 个元素小于 x。大于 x 的元素以阴影背景表示。 ) Similarly, the number of elements that are less thanx is at least 3n/10 - 6. Thus, in the worst case, SELECT is called recursively on at most 7n/10 + 6 elements in step 5. We can now develop a recurrence for the worst-case running timeT(n) of the algorithm SELECT. Steps 1, 2, and 4 take O(n) time. (Step 2 consists ofO(n) calls of insertion sort on sets of sizeO(1).) Step 3 takes timeT(⌉), and step 5 takes time at mostT(7n/10+ 6), assuming thatT is monotonically increasing. We make the assumption, which seems unmotivated at first, that any input of 140 or fewer elements requiresO(1) time; the origin of the magic constant 140 will be clear shortly. We can therefore obtain 102 the recurrence: We show that the running time is linear by substitution. More specifically, we will show thatT(n) ≤ cn for some suitably large constant c and alln > 0. We begin by assuming thatT(n) ≤cn for some suitably large constantc and alln ≤ 140; this assumption holds ifc is large enough. We also pick a constanta such that the function described by theO(n) term above (which describes the non-recursive component of the running time of the algorithm) is bounded above byan for alln > 0. Substituting this inductive hypothesis into the right-hand side of the recurrence yields T(n) ≤ c⌉ +c(7n/10 + 6) +an ≤ cn/5 +c + 7cn/10 + 6c +an = 9cn/10 + 7c +an = cn + (-cn/10 + 7c +an) , which is at mostcn if Inequality (9.2) is equivalent to the inequalityc ≥ 10a(n/(n - 70)) when n > 70. Because we assume thatn ≥ 140, we have n/(n - 70) ≤ 2, and so choosing c ≥ 20a will satisfyinequality (9.2). (Note that there is nothing special about the constant 140; we could replace it by any integer strictly greater than 70 and then choosec accordingly.) The worst-case running time of SELECT is therefore linear(因 此,此 SELECT 的最坏情况的运行时间是线性的). As in a comparison sort (seeSection 8.1), SELECT and RANDOMIZED-SELECT determine information about the relative order of elements only by comparing elements. Recall fromChapter 8 that sorting requiresΩ(n lgn) time in the comparison model, even on average (see Problem 8-1). The linear-time sorting algorithms in Chapter 8 make assumptions about the input. In contrast, the linear-time selection algorithms in this chapter do not require any assumptions about the input. They are not subject to the Ω(nlgn) lower bound because they manage to solve the selection problem without sorting. (与比较排序(算法导论 8.1 节)中的一样,SELECT 和 RANDOMIZED-SELECT 仅通过元素间的比 较来确定它们之间的相对次序。在算法导论第 8 章中,我们知道在比较模型中,即使在平均情况 下,排序仍然要 O(n*logn)的时间。第 8 章得线性时间排序算法在输入上做了假设。相反地, 本节提到的此类似 partition 过程的 SELECT 算法不需要关于输入的任何假设,它们不受下界 O (n*logn)的约束,因为它们没有使用排序就解决了选择问题(看到了没,道出了此算法的本质 阿)) Thus, the running time is linear because these algorithms do not sort; the linear-time behavior is not a result of assumptions about the input, as was the case for the sorting algorithms inChapter 8. 103 Sorting requiresΩ(n lgn) time in the comparison model, even on average (see Problem 8-1), and thus the method of sorting and indexing presented in the introduction to this chapter is asymptotically inefficient.(所以,本节中的选择算法之所以具有线性运行时间,是因为这些算法没有进行排序; 线性时间的结论并不需要在输入上所任何假设,即可得到。.....) ok,综述全文,根据选取不同的元素作为主元(或枢纽)的情况,可简单总结如下: 1、RANDOMIZED-SELECT,以序列中随机选取一个元素作为主元,可达到线性期望时间 O(N) 的 复 杂 度 。 这个在本文第一节有关编程之美第 2.5 节关于寻找最大的 k 个元素(但其 n*logk 的复杂 度是严重错误的,待勘误,应以算法导论上的为准,随机选取主元,可达线性期望时间的复 杂度),及本文第二节中涉及到的算法导论上第九章第 9.2 节中(以线性期望时间做选择), 都是以随机选取数组中任一元素作为枢纽元的。 2、SELECT,快速选择算法,以序列中“五分化中项的中项”,或“中位数的中位数”作为主元 ( 枢纽元),则不容置疑的可保证在最坏情况下亦为 O(N) 的 复 杂 度 。 这个在本文第四节末,及本文第七节,本文文末中都有所阐述,具体涉及到了算法导论 一书中第九章第 9.3 节的最快情况线性时间的选择,及 Mark Allen Weiss 所著的数据结构与 算法分析--c 语言描述一书的第 10 章第 10.2.3 节(选择问题)中,都有所阐述。 本文结论:至此,可以毫无保留的确定此问题之结论:运用类似快速排序的 partition 的快速选择 SELECT 算法寻找最小的 k 个元素能做到 O(N)的复杂度。 RANDOMIZED-SELECT 可能会有 O(N^2)的最坏的时间复杂度,但上面的 SELECT 算 法,采用如上所述的“中位数的中位数”的取元方法,则可保证此快速选择算法在最坏情况下 是线性时间 O(N)的复杂度。 最终验证: 1、我想,我想,是的,仅仅是我猜想,你可能会有这样的疑问:经过上文大量严谨的 论证之后,利用 SELECT 算法,以序列中“五分化中项的中项”,或“中位数的中位数”作为主 元(枢纽元),的的确确在最坏情况下 O(N)的时间复杂度内找到第 k 小的元素,但是, 但是,咱们的要面对的问题是什么?咱们是要找最小的 k 个数阿!不是找第 k 小的元素,而 是找最小的 k 个数(即不是要你找 1 个数,而是要你找 k 个数)?哈哈,问题提的非常之好 阿。 2、事实上,在最坏情况下,能在 O(N)的时间复杂度内找到第 k 小的元素,那么, 亦能保证最坏情况下在 O(N)的时间复杂度内找到前最小的 k 个数,咱们得找到一个理论 依据,即一个证明(我想,等你看到找到前 k 个数的时间复杂度与找第 k 小的元素,最坏情 104 况下,同样是 O(N)的时间复杂度后,你便会 100%的相信本文的结论了,然后可以通告 全世界,你找到了这个世界上最靠谱的中文算法 blog,ok,这是后话)。 算法导论第 9 章第 9.3 节练习里,有 2 个题目,与我们将要做的证明是一个道理,请 看: Exercises 9.3-4: ⋆ Suppose that an algorithm uses only comparisons to find the ith smallest element in a set of n elements. Show that it can also find the i - 1 smaller elements and the n - i larger elements without performing any additional comparisons.(假设对一个含有 n 个元素的集 合,某算法只需比较来确定第 i 小的元素。证明:无需另外的比较操作,它也能找到比 i 小 的 i-1 个元素和比 i 大的 n-i 个元素)。 Exercises 9.3-7 Describe an O(n)-time algorithm that, given a set S of n distinct numbers and a positive integer k ≤ n, determines the k numbers in S that are closest to the median of S.(给出一 个 O(N)时间的算法,在给定一个有 n 个不同数字的集合 S 以及一个正整数 K<=n 后, 它能确定出 S 中最接近其中位数的 k 个数。) 怎么样,能证明么?既然通过本文,咱们已经证明了上述的 SELECT 算法在最坏情况下 O(N)的时间内找到第 k 小的元素,那么距离咱们确切的问题:寻找最小的 k 个数的证明, 只差一步之遥了。 给点提示: 1、找到了第 K 小的数 Xk 为 O(n),再遍历一次数组,找出所有比 k 小的元素 O(N) (比较 Xk 与数组中各数的大小,凡是比 Xk 小的元素,都是我们要找的元素),最终时间 复杂度即为: O(N)(找到第 k 小的元素) + 遍历整个数组 O(N)=O(N)。这个结 论非常之简单,也无需证明(但是,正如上面的算法导论练习题 9.3-7 所述,能否在找到第 k 小的元素后,能否不需要再比较元素列?)。 2、我们的问题是,找到 第 k 小的元素后 Xk,是否 Xk 之前的元素就是我们 要找的最 小 的 k 个数,即,Xk 前面的数,是否都<=Xk?因为 那样的话,复杂度则 变为:O(N) +O(K)(遍历找到的第 k 小元素 前面的 k 个元素)=O(N+K)=O(N),最坏情况下, 亦是线性时间。 终极结论:证明只有一句话:因为本文我们所有的讨论都是基于快速排序的 partition 方 法,而这个方法,每次划分之后,都保证了 枢纽元 Xk 的前边元素统统小于 Xk,后边元素 105 统统大于 Xk(当然,如果你是属于那种打破沙锅问到底的人,你可能还想要我证明 partition 过程中枢纽元素为何能把整个序列分成左小右大两个部分。但这个不属于本文讨论范畴。读 者可参考算法导论第 7 章第 7.1 节关于 partition 过程中循环不变式的证明)。所以,正如本 文第一节思路 5 所述在 0(n)的时间内找到第 k 小的元素,然后遍历输出前面的 k 个小的 元素。如此,再次验证了咱们之前得到的结论:运用类似快速排序的 partition 的快速选择 SELECT 算法寻找最小的 k 个元素,在最坏情况下亦能做到 O(N)的复杂度。 5、RANDOMIZED-SELECT,每次都是随机选取数列中的一个元素作为主元,在 0(n) 的时间内找到第 k 小的元素,然后遍历输出前面的 k 个小的元素。 如果能的话,那么总的 时间复杂度为线性期望时间:O(n+k)=O(n)(当 k 比较小时)。 所以列,所以,恭喜你,你找到了这个世界上最靠谱的中文算法 blog。 updated: 我假设,你并不认为并赞同上述那句话:你找到了这个世界上最靠谱的中文算法 blog。 ok,我再给你一个证据:我再次在编程珠玑 II 上找到了 SELECT 算法能在平均时间 O(N) 内找出第 k 小元素的第三个证据。同时,依据书上所说,由于 SELECT 算法采取 partition 过程划分整个数组元素,所以在找到第 k 小的元素 Xk 之后,Xk+Xk 前面的 k 个元素即为所 要查找的 k 个元素(下图为编程珠玑 II 第 15 章第 15.2 节的截图,同时各位还可看到,快 速排序是递归的对俩个子序列进行操作,而选择算法只对含有 K 的那一部分重复操作)。 106 再多余的话,我不想说了。我知道我的确是一个庸人自扰的 P 民,即没有问题的事情却 硬要弄出一堆问题出来,然后再矢志不渝的论证自己的观点不容置疑之正确性。ok,毕。 备注:  快速选择 SELECT 算法,虽然复杂度平均是 o(n),但这个系数比较大,与用一个最 大堆 0(n*logk)不见得就有优势)  当 K 很小时,O(N*logK)与 O(N)等价,当 K 很大时,当然也就不能忽略掉了。 也就是说,在我们这个具体寻找 k 个最小的数的问题中,当我们无法确定 K 的具体 值时(是小是大),咱们便不能简单的从表面上忽略。也就是说:O(N*logK)就是 O(N*logK),非 O(N)。 1. 如果 n=1024,k=n-1,最差情况下需比较 2n 次,而 nlog(k-1)=10n,所以不相同。实 际上,这个算法时间复杂度与 k 没有直接关系。且只在第一次划分的时候用到了 K, 后面几次划分,是根据实际情况确定的,与 K 无关了。 2. 但 k=n/2 时也不是 nlogk,因为只在第一次划分的时候用到了 K,后面几次划分,是根据 实际情况确定的,与 K 无关了。比如 a[1001].k=500,第一次把把 a 划分成两部分,b 和 c ,不妨设 b 元素个数为 400 个,c 中元素为 600 个,则下一步应该舍掉 a,然后在 c 中 寻找 top100,此时 k 已经变成了 100,因此与 k 无关。  所以,咱们在表述快速选择算法的平均时间复杂度时,还是要写成 O(N)的,断 不可写成 O(N*logK)的。 参考文献: 1、Mark Allen Weiss 的数据结构与算法分析--c 语言描述,第 7 章第 7.7.6 节,线性期望时 间的选择算法,第 10 章第 10.2.3 节,选择问题 2、算法导论,第九章第 9.2 节,以线性期望时间做选择,第九章第 9.3 节,最快情况线性 时间的选择 3、编程之美第一版,第 141 页,第 2.5 节 寻找最大的 k 个数(找最大或最小,一个道理) 4、维基百科,http://en.wikipedia.org/wiki/Selection_algorithm。 5、M. Blum, R.W. Floyd, V. Pratt, R. Rivest and R. Tarjan, "Time bounds for selection," J. Comput. System Sci. 7 (1973) 448-461. 6、当今世界最受人们重视的十大经典算法里提到的,BFPRT 算法。 7、编程珠玑 II 第 15 章第 15.2 节程序。顺便大赞此书。July、updated,2011.05.07。 107 预告: 程序员面试题狂想曲、第四章(更多有关海量数据处理,及 Top K 算法问题(此 问题已作为第三章续),第四章,择日发布。),五月份发布(近期内事情较多,且昨夜因 修正此文足足熬到了凌晨 4 点(但室内并无海棠花),写一篇文章太耗精力和时间,见谅。 有关本人动态,可关注本人微博:http://weibo.com/julyweibo。谢谢。July、updated, 2011.05.05)。 ok,有任何问题,欢迎随时指出。谢谢。完。 。版权声明:严禁用于商业用途,严禁出版。转载,请注明出处。违者,追究法律责任。 第三章续、Top K 算法问题的实现 作者:July,zhouzhenren,yansha。 致谢:微软 100 题实现组,狂想曲创作组。 时间:2011 年 05 月 08 日 微博:http://weibo.com/julyweibo 。 出处:http://blog.csdn.net/v_JULY_v 。 wiki:http://tctop.wikispaces.com/。 ----------------------------------------------- 前奏 在上一篇文章,程序员面试题狂想曲:第三章、寻找最小的 k 个数中,后来为了论证类 似快速排序中 partition 的方法在最坏情况下,能在 O(N)的时间复杂度内找到最小的 k 个 数,而前前后后 updated 了 10 余次。所谓功夫不负苦心人,终于得到了一个想要的结果。 108 简单总结如下(详情,请参考原文第三章): 1、RANDOMIZED-SELECT,以序列中随机选取一个元素作为主元,可达到线性期望时 间 O(N)的复杂度。 2、SELECT,快速选择算法,以序列中“五分化中项的中项”,或“中位数的中位数”作为 主元(枢纽元),则不容置疑的可保证在最坏情况下亦为 O(N)的复杂度。 本章,咱们来阐述寻找最小的 k 个数的反面,即寻找最大的 k 个数,但此刻可能就有读 者质疑了,寻找最大的 k 个数和寻找最小的 k 个数,原理不是一样的么? 是的,的确是一样,但这个寻找最大的 k 个数的问题的实用范围更广,因为它牵扯到了 一个 Top K 算法问题,以及有关搜索引擎,海量数据处理等广泛的问题,所以本文特意对 这个 Top K 算法问题,进行阐述以及实现(侧重实现,因为那样看起来,会更令人激动人 心),算是第三章的续。ok,有任何问题,欢迎随时不吝指正。谢谢。 说明 关于寻找最小 K 个数能做到最坏情况下为 O(N)的算法及证明,请参考原第三章,寻 找最小的 k 个数,本文的代码不保证 O(N)的平均时间复杂度,只是根据第三章有办法可 以做到而已(如上面总结的,2、SELECT,快速选择算法,以序列中“五分化中项的中项”, 或“中位数的中位数”作为主元或枢纽元的方法,原第三章已经严格论证并得到结果)。 第一节、寻找最小的第 k 个数 在进入寻找最大的 k 个数的主题之前,先补充下关于寻找最 k 小的数的三种简单实现。 由于堆的完整实现,第三章:第五节,堆结构实现,处理海量数据中已经给出,下面主要给 出类似快速排序中 partition 过程的代码实现: 寻找最小的 k 个数,实现一: 1. //copyright@ mark allen weiss && July && yansha 2. //July,yansha、updated,2011.05.08. 3. 4. //本程序,后经飞羽找出错误,已经修正。 5. //随机选取枢纽元,寻找最小的第 k 个数 109 6. #include 7. #include 8. using namespace std; 9. 10. int my_rand(int low, int high) 11. { 12. int size = high - low + 1; 13. return low + rand() % size; 14. } 15. 16. //q_select places the kth smallest element in a[k] 17. int q_select(int a[], int k, int left, int right) 18. { 19. if(k>right) 20. { 21. cout<<"---------"< pivot) 47. j--; 48. if (i < j) 49. swap(a[i], a[j]); 110 50. else 51. break; 52. } 53. swap(a[i], a[right]); 54. 55. /* 对三种情况进行处理:(m = i - left + 1) 56. 1、如果 m=k,即返回的主元即为我们要找的第 k 小的元素,那么直接返回主元 a[i]即可; 57. 2、如果 m>k,那么接下来要到低区间 A[0....m-1]中寻找,丢掉高区间; 58. 3、如果 m=0;i--) 65. cout< k) 68. return q_select(a, k, left, i - 1); 69. else 70. return q_select(a, k - m, i + 1, right); 71. } 72. 73. int main() 74. { 75. int i; 76. int a[] = {7, 8, 9, 54, 6, 4, 11, 1, 2, 33}; 77. q_select(a, 4, 0, sizeof(a) / sizeof(int) - 1); 78. return 0; 79. } 寻找最小的第 k 个数,实现二: 1. //copyright@ July 2. //yansha、updated,2011.05.08。 3. // 数组中寻找第 k 小元素,实现二 4. #include 5. using namespace std; 6. 7. const int numOfArray = 10; 8. 9. // 这里并非真正随机 10. int my_rand(int low, int high) 11. { 12. int size = high - low + 1; 13. return low + rand() % size; 111 14. } 15. 16. // 以最末元素作为主元对数组进行一次划分 17. int partition(int array[], int left, int right) 18. { 19. int pos = right; 20. for(int index = right - 1; index >= left; index--) 21. { 22. if(array[index] > array[right]) 23. swap(array[--pos], array[index]); 24. } 25. swap(array[pos], array[right]); 26. return pos; 27. } 28. 29. // 随机快排的 partition 过程 30. int random_partition(int array[], int left, int right) 31. { 32. // 随机从范围 left 到 right 中取一个值作为主元 33. int index = my_rand(left, right); 34. swap(array[right], array[index]); 35. 36. // 对数组进行划分,并返回主元在数组中的位置 37. return partition(array, left, right); 38. } 39. 40. // 以线性时间返回数组 array[left...right]中第 k 小的元素 41. int random_select(int array[], int left, int right, int k) 42. { 43. // 处理异常情况 44. if (k < 1 || k > (right - left + 1)) 45. return -1; 46. 47. // 主元在数组中的位置 48. int pos = random_partition(array, left, right); 49. 50. /* 对三种情况进行处理:(m = i - left + 1) 51. 1、如果 m=k,即返回的主元即为我们要找的第 k 小的元素,那么直接返回主元 array[i]即 可; 52. 2、如果 m>k,那么接下来要到低区间 array[left....pos-1]中寻找,丢掉高区间; 53. 3、如果 m k) 59. return random_select(array, left, pos - 1, k); 60. else 61. return random_select(array, pos + 1, right, k - m); 62. } 63. 64. int main() 65. { 66. int array[numOfArray] = {7, 8, 9, 54, 6, 4, 2, 1, 12, 33}; 67. cout << random_select(array, 0, numOfArray - 1, 4) << endl; 68. return 0; 69. } 寻找最小的第 k 个数,实现三: 1. //求取无序数组中第 K 个数,本程序枢纽元的选取有问题,不作推荐。 2. //copyright@ 飞羽 3. //July、yansha,updated,2011.05.18。 4. #include 5. #include 6. using namespace std; 7. 8. int kth_elem(int a[], int low, int high, int k) 9. { 10. int pivot = a[low]; 11. //这个程序之所以做不到 O(N)的最最重要的原因,就在于这个枢纽元的选取。 12. //而这个程序直接选取数组中第一个元素作为枢纽元,是做不到平均时间复杂度为 O(N) 的。 13. 14. //要 做到,就必须 把上面选取枢纽元的 代码改掉,要么是随机选择数组中某一元素作为 枢纽元,能达到线性期望的时间 15. //要么是选取数组中中位数的中位数作为枢纽元,保证最坏情况下,依然为线性 O(N)的平 均时间复杂度。 16. int low_temp = low; 17. int high_temp = high; 18. while(low < high) 19. { 20. while(low < high && a[high] >= pivot) 21. --high; 22. a[low] = a[high]; 23. while(low < high && a[low] < pivot) 24. ++low; 25. a[high] = a[low]; 113 26. } 27. a[low] = pivot; 28. 29. //以下就是主要思想中所述的内容 30. if(low == k - 1) 31. return a[low]; 32. else if(low > k - 1) 33. return kth_elem(a, low_temp, low - 1, k); 34. else 35. return kth_elem(a, low + 1, high_temp, k); 36. } 37. 38. int main() //以后尽量不再用随机产生的数组进行测试,没多大必要。 39. { 40. for (int num = 5000; num < 50000001; num *= 10) 41. { 42. int *array = new int[num]; 43. 44. int j = num / 10; 45. int acc = 0; 46. for (int k = 1; k <= num; k += j) 47. { 48. // 随机生成数据 49. srand(unsigned(time(0))); 50. for(int i = 0; i < num; i++) 51. array[i] = rand() * RAND_MAX + rand(); 52. //”如果数组本身就是利用随机化产生的话,那么选择其中任何一个元素作为枢轴 都可以看作等价于随机选择枢轴, 53. //(虽然这不叫随机选择枢纽)”,这句话,是完全不成立的,是错误的。 54. 55. //“因为你总是选择 随机数组中第一个元素 作为枢纽元,不是 随机选择枢纽 元” 56. //相当于把上面这句话中前面的 “随机” 两字去掉,就是: 57. //因为 你总是选择数组中第一个元素作为枢纽元,不是 随机选择枢纽元。 58. //所以,这个程序,始终做不到平均时间复杂度为 O(N)。 59. 60. //随机数组和给定一个非有序而随机手动输入的数组,是一个道理。稍后,还将 就程序的运行结果继续解释这个问题。 61. //July、updated,2011.05.18。 62. 63. // 计算一次查找所需的时钟周期数 64. clock_t start = clock(); 65. int data = kth_elem(array, 0, num - 1, k); 66. clock_t end = clock(); 114 67. acc += (end - start); 68. } 69. cout << "The average time of searching a date in the array size of " << num << " is " << acc / 10 << endl; 70. } 71. return 0; 72. } 测试: The average time of searching a date in the array size of 5000 is 0 The average time of searching a date in the array size of 50000 is 1 The average time of searching a date in the array size of 500000 is 12 The average time of searching a date in the array size of 5000000 is 114 The average time of searching a date in the array size of 50000000 is 1159 Press any key to continue 通过测试这个程序,我们竟发现这个程序的运行时间是线性的? 或许,你还没有意识到这个问题,ok,听我慢慢道来。 我们之前说,要保证这个算法是线性的,就一定要在枢纽元的选取上下足功夫: 1、要么是随机选取枢纽元作为划分元素 2、要么是取中位数的中位数作为枢纽元划分元素 现在,这程序直接选取了数组中第一个元素作为枢纽元 竟然,也能做到线性 O(N)的复杂度,这不是自相矛盾么? 你觉得这个程序的运行时间是线性 O(N),是巧合还是确定会是如此? 哈哈,且看 1、@well:根据上面的运行结果不能判断线性,如果人家是 O(n^1.1) 也有可能啊,而且部分数据始终是拟合,还是要数学证明才可靠。2、@July: 同时,随机数组中选取一个元素作为枢纽元!=> 随机数组中随机选取一个元素 作为枢纽元(如果是随机选取随机数组中的一个元素作为主元,那就不同了,跟 随机选取数组中一个元素作为枢纽元一样了)。3、@飞羽:正是因为数组本身 是随机的,所以选择第一个元素和随机选择其它的数是等价的(由等概率产生保 证),这第 3 点,我与飞羽有分歧,至于谁对谁错,待时间让我考证。 关于上面第 3 点我和飞羽的分歧,在我们进一步讨论之后,一致认定(不过, 相信,你看到了上面程序更新的注释之后,你应该有几分领会了): 1. 我们说输入一个数组的元素,不按其顺序输入:如,1,2,3,4,5,6,7, 而是这样输入:5,7,6,4,3,1,2,这就叫随机输入,而这种情况就 相当于上述程序主函数中所产生的随机数组。然而选取随机输入的 115 数组或随机数组中第一个元素作为主元,我们不能称之为说是随机 选取枢纽元。 2. 因为,随机数产生器产生的数据是随机的,没错,但你要知道,你 总是选取随机数组的第一个元素作为枢纽元,这不叫随机选取枢纽 元。 3. 所以,上述程序的主函数中随机产生的数组对这个程序的算法而言, 没有任何意义,就是帮忙产生了一个随机数组,帮助我们完成了测 试,且方便我们测试大数据量而已,就这么简单。 4. 且一般来说,我们看一个程序的 时间复杂度,是不考虑 其输入情 况的,即不考虑主函数,正如这个 kth number 的程序所见,你每 次都是随机选取数组中第一个元素作为枢纽元,而并不是随机选择 枢纽元,所以,做不到平均时间复杂度为 O(N)。 所以:想要保证此快速选择算法为 O(N)的复杂度,只有两种途径,那就是保 证划分的枢纽元元素的选取是: 1、随机的(注,此枢纽元随机不等同于数组随机) 2、五分化中项的中项,或中位数的中位数。 所以,虽然咱们对于一切心知肚明,但上面程序的运行结果说明不了任何问题, 这也从侧面再次佐证了咱们第三章中观点的正确无误性。 updated: 非常感谢飞羽等人的工作,将上述三个版本综合到了一起(待进一步测试): 1. ///下面的代码对 July 博客中的三个版本代码进行重新改写。欢迎指出错误。 2. ///先把它们贴在这里,还要进行随机化数据测试。待发... 3. 4. //modified by 飞羽 at 2011.5.11 5. /////Top_K_test 6. 7. //修改了下命名规范,July、updated,2011.05.12。 8. #include 9. #include 10. using namespace std; 11. 12. inline int my_rand(int low, int high) 13. { 14. int size = high - low + 1; 116 15. return low + rand() % size; 16. } 17. 18. int partition(int array[], int left, int right) 19. { 20. int pivot = array[right]; 21. int pos = left-1; 22. for(int index = left; index < right; index++) 23. { 24. if(array[index] <= pivot) 25. swap(array[++pos], array[index]); 26. } 27. swap(array[++pos], array[right]); 28. return pos;//返回 pivot 所在位置 29. } 30. 31. bool median_select(int array[], int left, int right, int k) 32. { 33. //第 k 小元素,实际上应该在数组中下标为 k-1 34. if (k-1 > right || k-1 < left) 35. return false; 36. 37. //真正的三数中值作为枢纽元方法,关键代码就是下述六行 38. int midIndex=(left+right)/2; 39. if(array[left] k-1) 52. return median_select(array, left, pos-1, k); 53. else return median_select(array, pos+1, right, k); 54. } 55. 56. bool rand_select(int array[], int left, int right, int k) 57. { 58. //第 k 小元素,实际上应该在数组中下标为 k-1 117 59. if (k-1 > right || k-1 < left) 60. return false; 61. 62. //随机从数组中选取枢纽元元素 63. int Index = my_rand(left, right); 64. swap(array[Index], array[right]); 65. 66. int pos = partition(array, left, right); 67. 68. if (pos == k-1) 69. return true; 70. else if (pos > k-1) 71. return rand_select(array, left, pos-1, k); 72. else return rand_select(array, pos+1, right, k); 73. } 74. 75. bool kth_select(int array[], int left, int right, int k) 76. { 77. //直接取最原始的划分操作 78. if (k-1 > right || k-1 < left) 79. return false; 80. 81. int pos = partition(array, left, right); 82. if(pos == k-1) 83. return true; 84. else if(pos > k-1) 85. return kth_select(array, left, pos-1, k); 86. else return kth_select(array, pos+1, right, k); 87. } 88. 89. int main() 90. { 91. int array1[] = {7, 8, 9, 54, 6, 4, 11, 1, 2, 33}; 92. int array2[] = {7, 8, 9, 54, 6, 4, 11, 1, 2, 33}; 93. int array3[] = {7, 8, 9, 54, 6, 4, 11, 1, 2, 33}; 94. 95. int numOfArray = sizeof(array1) / sizeof(int); 96. for(int i=0; ikmin,则 x 代替 kmin,并再次重新找出 k 个元 素的数组中最大元素 kmin‘(多谢 jiyeyuran 提醒修正);如果 xk2>...kmin(kmin 设为小顶堆中最小元 素)。继续遍历数列,每次遍历一个元素 x,与堆顶元素比较,若 x>kmin,则 更新堆(用时 logk),否则不更新堆。这样下来,总费时 O(k*logk+(n-k)*logk) =O(n*logk)。此方法得益于在堆中,查找等各项操作时间复杂度均为 logk(不 然,就如上述思路 2 所述:直接用数组也可以找出最大的 k 个元素,用时 O(n*k))。 4、按编程之美第 141 页上解法二的所述,类似快速排序的划分方法,N 个数 存储在数组 S 中,再从数组中随机选取一个数 X,把数组划分为 Sa 和 Sb 俩部 分,Sa>=X>=Sb,如果要查找的 k 个元素小于 Sa 的元素个数,则返回 Sa 中较 大的 k 个元素,否则返回 Sa 中所有的元素+Sb 中最大的 k-|Sa|个元素。不断递 归下去,把问题分解成更小的问题,平均时间复杂度为 O(N)(编程之美所述 的 n*logk 的复杂度有误,应为 O(N),特此订正。其严格证明,请参考第三章: 程序员面试题狂想曲:第三章、寻找最小的 k 个数、updated 10 次)。 ......... 其它的方法,在此不再重复了,同时,寻找最小的 k 个数借助堆的实现,代码在上一篇文 章第三章已有给出,更多,可参考第三章,只要把最大堆改成最小堆,即可。 120 第三节、Top K 算法问题 3.1、搜索引擎热门查询统计 题目描述: 搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的 长度为 1-255 字节。 假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是 1 千万,但如果除 去重复后,不超过 3 百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越 热门。),请你统计最热门的 10 个查询串,要求使用的内存不能超过 1G。 分析:这个问题在之前的这篇文章十一、从头到尾彻底解析 Hash 表算法里,已经有所 解答。方法是: 第一步、先对这批海量数据预处理,在 O(N)的时间内用 Hash 表完成统计(之前写成 了排序,特此订正。July、2011.04.27); 第二步、借助堆这个数据结构,找出 Top K,时间复杂度为 N‘logK。 即,借助堆结构,我们可以在 log 量级的时间内查找和调整/移动。因此,维护一个 K(该 题目中是 10)大小的小根堆(K1>K2>....Kmin,Kmin 设为堆顶元素),然后遍历 300 万的 Query,分别和根元素 Kmin 进行对比比较(如上第 2 节思路 3 所述,若 X>Kmin,则更新 并调整堆,否则,不更新),我们最终的时间复杂度是:O(N) + N'*O(logK),(N 为 1000 万,N’为 300 万)。ok,更多,详情,请参考原文。 或者:采用 trie 树,关键字域存该查询串出现的次数,没有出现为 0。最后用 10 个元素 的最小推来对出现频率进行排序。 ok,本章里,咱们来实现这个问题,为了降低实现上的难度,假设这些记录全部是一些 英文单词,即用户在搜索框里敲入一个英文单词,然后查询搜索结果,最后,要你统计输入 单词中频率最大的前 K 个单词。ok,复杂问题简单化了之后,编写代码实现也相对轻松多 了,画的简单示意图(绘制者,yansha),如下: 121 完整源码: 1. //copyright@yansha &&July 2. //July、updated,2011.05.08 3. 4. //题目描述: 5. //搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的 6. //长度为 1-255 字节。假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是 1 千 万,但如果 7. //除去重复后,不超过 3 百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越 热门), 8. //请你统计最热门的 10 个查询串,要求使用的内存不能超过 1G。 9. 10. #include 11. #include 12. #include 13. using namespace std; 122 14. 15. #define HASHLEN 2807303 16. #define WORDLEN 30 17. 18. // 结点指针 19. typedef struct node_no_space *ptr_no_space; 20. typedef struct node_has_space *ptr_has_space; 21. ptr_no_space head[HASHLEN]; 22. 23. struct node_no_space 24. { 25. char *word; 26. int count; 27. ptr_no_space next; 28. }; 29. 30. struct node_has_space 31. { 32. char word[WORDLEN]; 33. int count; 34. ptr_has_space next; 35. }; 36. 37. // 最简单 hash 函数 38. int hash_function(char const *p) 39. { 40. int value = 0; 41. while (*p != '/0') 42. { 43. value = value * 31 + *p++; 44. if (value > HASHLEN) 45. value = value % HASHLEN; 46. } 47. return value; 48. } 49. 50. // 添加单词到 hash 表 51. void append_word(char const *str) 52. { 53. int index = hash_function(str); 54. ptr_no_space p = head[index]; 55. while (p != NULL) 56. { 57. if (strcmp(str, p->word) == 0) 123 58. { 59. (p->count)++; 60. return; 61. } 62. p = p->next; 63. } 64. 65. // 新建一个结点 66. ptr_no_space q = new node_no_space; 67. q->count = 1; 68. q->word = new char [strlen(str)+1]; 69. strcpy(q->word, str); 70. q->next = head[index]; 71. head[index] = q; 72. } 73. 74. 75. // 将单词处理结果写入文件 76. void write_to_file() 77. { 78. FILE *fp = fopen("result.txt", "w"); 79. assert(fp); 80. 81. int i = 0; 82. while (i < HASHLEN) 83. { 84. for (ptr_no_space p = head[i]; p != NULL; p = p->next) 85. fprintf(fp, "%s %d/n", p->word, p->count); 86. i++; 87. } 88. fclose(fp); 89. } 90. 91. // 从上往下筛选,保持小根堆 92. void sift_down(node_has_space heap[], int i, int len) 93. { 94. int min_index = -1; 95. int left = 2 * i; 96. int right = 2 * i + 1; 97. 98. if (left <= len && heap[left].count < heap[i].count) 99. min_index = left; 100. else 101. min_index = i; 124 102. 103. if (right <= len && heap[right].count < heap[min_index].count) 104. min_index = right; 105. 106. if (min_index != i) 107. { 108. // 交换结点元素 109. swap(heap[i].count, heap[min_index].count); 110. 111. char buffer[WORDLEN]; 112. strcpy(buffer, heap[i].word); 113. strcpy(heap[i].word, heap[min_index].word); 114. strcpy(heap[min_index].word, buffer); 115. 116. sift_down(heap, min_index, len); 117. } 118. } 119. 120. // 建立小根堆 121. void build_min_heap(node_has_space heap[], int len) 122. { 123. if (heap == NULL) 124. return; 125. 126. int index = len / 2; 127. for (int i = index; i >= 1; i--) 128. sift_down(heap, i, len); 129. } 130. 131. // 去除字符串前后符号 132. void handle_symbol(char *str, int n) 133. { 134. while (str[n] < '0' || (str[n] > '9' && str[n] < 'A') || (str[n] > 'Z' && str[n] < 'a') || str[n] > 'z') 135. { 136. str[n] = '/0'; 137. n--; 138. } 139. 140. while (str[0] < '0' || (str[0] > '9' && str[0] < 'A') || (str[0] > 'Z' && str[0] < 'a') || str[0] > 'z') 141. { 142. int i = 0; 143. while (i < n) 125 144. { 145. str[i] = str[i+1]; 146. i++; 147. } 148. str[i] = '/0'; 149. n--; 150. } 151. } 152. 153. int main() 154. { 155. char str[WORDLEN]; 156. for (int i = 0; i < HASHLEN; i++) 157. head[i] = NULL; 158. 159. // 将字符串用 hash 函数转换成一个整数并统计出现频率 160. FILE *fp_passage = fopen("string.txt", "r"); 161. assert(fp_passage); 162. while (fscanf(fp_passage, "%s", str) != EOF) 163. { 164. int n = strlen(str) - 1; 165. if (n > 0) 166. handle_symbol(str, n); 167. append_word(str); 168. } 169. fclose(fp_passage); 170. 171. // 将统计结果输入文件 172. write_to_file(); 173. 174. int n = 10; 175. ptr_has_space heap = new node_has_space [n+1]; 176. 177. int c; 178. 179. FILE *fp_word = fopen("result.txt", "r"); 180. assert(fp_word); 181. for (int j = 1; j <= n; j++) 182. { 183. fscanf(fp_word, "%s %d", &str, &c); 184. heap[j].count = c; 185. strcpy(heap[j].word, str); 186. } 187. 126 188. // 建立小根堆 189. build_min_heap(heap, n); 190. 191. // 查找出现频率最大的 10 个单词 192. while (fscanf(fp_word, "%s %d", &str, &c) != EOF) 193. { 194. if (c > heap[1].count) 195. { 196. heap[1].count = c; 197. strcpy(heap[1].word, str); 198. sift_down(heap, 1, n); 199. } 200. } 201. fclose(fp_word); 202. 203. // 输出出现频率最大的单词 204. for (int k = 1; k <= n; k++) 205. cout << heap[k].count << " " << heap[k].word << endl; 206. 207. return 0; 208. } 程序测试:咱们接下来,来对下面的通过用户输入单词后,搜索引擎记录下来,“大量”单词 记录进行统计(同时,令 K=10,即要你找出 10 个最热门查询的单词): 127 运行结果:根据程序的运行结果,可以看到,搜索引擎记录下来的查询次数最多的 10 个单 词为(注,并未要求这 10 个数要有序输出):in(312 次),it(384 次),a(432),that (456),MPQ(408),of(504),and(624),is(456),the(1008),to(936)。 3.2、统计出现次数最多的数据 题目描述: 给你上千万或上亿数据(有重复),统计其中出现次数最多的前 N 个数据。 分析:上千万或上亿的数据,现在的机器的内存应该能存下(也许可以,也许不可以)。 所以考虑采用 hash_map/搜索二叉树/红黑树等来进行统计次数。然后就是取出前 N 个出现 次数最多的数据了。当然,也可以堆实现。 ok,此题与上题类似,最好的方法是用 hash_map 统计出现的次数,然后再借用堆找出 出现次数最多的 N 个数据。不过,上一题统计搜索引擎最热门的查询已经采用过 hash 表统 计单词出现的次数,特此,本题咱们改用红黑树取代之前的用 hash 表,来完成最初的统计, 然后用堆更新,找出出现次数最多的前 N 个数据。 同时,正好个人此前用 c && c++ 语言实现过红黑树,那么,代码能借用就借用吧。 完整代码: 128 1. //copyright@ zhouzhenren &&July 2. //July、updated,2011.05.08. 3. 4. //题目描述: 5. //上千万或上亿数据(有重复),统计其中出现次数最多的前 N 个数据 6. 7. //解决方案: 8. //1、采用红黑树(本程序中有关红黑树的实现代码来源于@July)来进行统计次数。 9. //2、然后遍历整棵树,同时采用最小堆更新前 N 个出现次数最多的数据。 10. 11. //声明:版权所有,引用必须注明出处。 12. #define PARENT(i) (i)/2 13. #define LEFT(i) 2*(i) 14. #define RIGHT(i) 2*(i)+1 15. 16. #include 17. #include 18. #include 19. 20. typedef enum rb_color{ RED, BLACK }RB_COLOR; 21. typedef struct rb_node 22. { 23. int key; 24. int data; 25. RB_COLOR color; 26. struct rb_node* left; 27. struct rb_node* right; 28. struct rb_node* parent; 29. }RB_NODE; 30. 31. RB_NODE* RB_CreatNode(int key, int data) 32. { 33. RB_NODE* node = (RB_NODE*)malloc(sizeof(RB_NODE)); 34. if (NULL == node) 35. { 36. printf("malloc error!"); 37. exit(-1); 38. } 39. 40. node->key = key; 41. node->data = data; 42. node->color = RED; 43. node->left = NULL; 44. node->right = NULL; 129 45. node->parent = NULL; 46. 47. return node; 48. } 49. 50. /** 51. * 左旋 52. * 53. * node right 54. * / / ==> / / 55. * a right node y 56. * / / / / 57. * b y a b 58. */ 59. RB_NODE* RB_RotateLeft(RB_NODE* node, RB_NODE* root) 60. { 61. RB_NODE* right = node->right; // 指定指针指向 right<--node->right 62. 63. if ((node->right = right->left)) 64. right->left->parent = node; // 好比上面的注释图,node 成为 b 的父母 65. 66. right->left = node; // node 成为 right 的左孩子 67. 68. if ((right->parent = node->parent)) 69. { 70. if (node == node->parent->right) 71. node->parent->right = right; 72. else 73. node->parent->left = right; 74. } 75. else 76. root = right; 77. 78. node->parent = right; //right 成为 node 的父母 79. 80. return root; 81. } 82. 83. /** 84. * 右旋 85. * 86. * node left 87. * / / / / 88. * left y ==> a node 130 89. * / / / / 90. * a b b y 91. */ 92. RB_NODE* RB_RotateRight(RB_NODE* node, RB_NODE* root) 93. { 94. RB_NODE* left = node->left; 95. 96. if ((node->left = left->right)) 97. left->right->parent = node; 98. 99. left->right = node; 100. 101. if ((left->parent = node->parent)) 102. { 103. if (node == node->parent->right) 104. node->parent->right = left; 105. else 106. node->parent->left = left; 107. } 108. else 109. root = left; 110. 111. node->parent = left; 112. 113. return root; 114. } 115. 116. /** 117. * 红黑树的 3 种插入情况 118. * 用 z 表示当前结点, p[z]表示父母、p[p[z]]表示祖父, y 表示叔叔. 119. */ 120. RB_NODE* RB_Insert_Rebalance(RB_NODE* node, RB_NODE* root) 121. { 122. RB_NODE *parent, *gparent, *uncle, *tmp; //父母 p[z]、祖父 p[p[z]]、叔叔 y、 临时结点*tmp 123. 124. while ((parent = node->parent) && parent->color == RED) 125. { // parent 为 node 的父母,且当父母的颜色为红时 126. gparent = parent->parent; // gparent 为祖父 127. 128. if (parent == gparent->left) // 当祖父的左孩子即为父母时,其实上述几行 语句,无非就是理顺孩子、父母、祖父的关系。 129. { 131 130. uncle = gparent->right; // 定义叔叔的概念,叔叔 y 就是父母的右孩 子。 131. if (uncle && uncle->color == RED) // 情况 1:z 的叔叔 y 是红色的 132. { 133. uncle->color = BLACK; // 将叔叔结点 y 着为黑色 134. parent->color = BLACK; // z 的父母 p[z]也着为黑色。解决 z,p[z] 都是红色的问题。 135. gparent->color = RED; 136. node = gparent; // 将祖父当做新增结点 z,指针 z 上移俩层,且 着为红色。 137. // 上述情况 1 中,只考虑了 z 作为父母的右孩子的情况。 138. } 139. else // 情况 2:z 的叔叔 y 是黑色的, 140. { 141. if (parent->right == node) // 且 z 为右孩子 142. { 143. root = RB_RotateLeft(parent, root); // 左旋[结点 z,与父母 结点] 144. tmp = parent; 145. parent = node; 146. node = tmp; // parent 与 node 互换角色 147. } 148. // 情况 3:z 的叔叔 y 是黑色的,此时 z 成为了左孩子。 149. // 注意,1:情况 3 是由上述情况 2 变化而来的。 150. // ......2:z 的叔叔总是黑色的,否则就是情况 1 了。 151. parent->color = BLACK; // z 的父母 p[z]着为黑色 152. gparent->color = RED; // 原祖父结点着为红色 153. root = RB_RotateRight(gparent, root); // 右旋[结点 z,与祖父结 点] 154. } 155. } 156. 157. else 158. { 159. // 这部分是特别为情况 1 中,z 作为左孩子情况,而写的。 160. uncle = gparent->left; // 祖父的左孩子作为叔叔结点。[原理还是与上部 分一样的] 161. if (uncle && uncle->color == RED) // 情况 1:z 的叔叔 y 是红色的 162. { 163. uncle->color = BLACK; 164. parent->color = BLACK; 165. gparent->color = RED; 166. node = gparent; // 同上 167. } 132 168. else // 情况 2:z 的叔叔 y 是黑色 的, 169. { 170. if (parent->left == node) // 且 z 为左孩子 171. { 172. root = RB_RotateRight(parent, root); // 以结点 parent、 root 右旋 173. tmp = parent; 174. parent = node; 175. node = tmp; // parent 与 node 互换角色 176. } 177. // 经过情况 2 的变化,成为了情况 3. 178. parent->color = BLACK; 179. gparent->color = RED; 180. root = RB_RotateLeft(gparent, root); // 以结点 gparent 和 root 左旋 181. } 182. } 183. } 184. 185. root->color = BLACK; // 根结点,不论怎样,都得置为黑色。 186. return root; // 返回根结点。 187. } 188. 189. /** 190. * 红黑树查找结点 191. * rb_search_auxiliary:查找 192. * rb_node_t* rb_search:返回找到的结点 193. */ 194. RB_NODE* RB_SearchAuxiliary(int key, RB_NODE* root, RB_NODE** save) 195. { 196. RB_NODE* node = root; 197. RB_NODE* parent = NULL; 198. int ret; 199. 200. while (node) 201. { 202. parent = node; 203. ret = node->key - key; 204. if (0 < ret) 205. node = node->left; 206. else if (0 > ret) 207. node = node->right; 208. else 133 209. return node; 210. } 211. 212. if (save) 213. *save = parent; 214. 215. return NULL; 216. } 217. 218. /** 219. * 返回上述 rb_search_auxiliary 查找结果 220. */ 221. RB_NODE* RB_Search(int key, RB_NODE* root) 222. { 223. return RB_SearchAuxiliary(key, root, NULL); 224. } 225. 226. /** 227. * 红黑树的插入 228. */ 229. RB_NODE* RB_Insert(int key, int data, RB_NODE* root) 230. { 231. RB_NODE* parent = NULL; 232. RB_NODE* node = NULL; 233. 234. parent = NULL; 235. if ((node = RB_SearchAuxiliary(key, root, &parent))) // 调用 RB_SearchAuxiliary 找到插入结点的地方 236. { 237. node->data++; // 节点已经存在 data 值加 1 238. return root; 239. } 240. 241. node = RB_CreatNode(key, data); // 分配结点 242. node->parent = parent; 243. 244. if (parent) 245. { 246. if (parent->key > key) 247. parent->left = node; 248. else 249. parent->right = node; 250. } 251. else 134 252. { 253. root = node; 254. } 255. 256. return RB_Insert_Rebalance(node, root); // 插入结点后,调用 RB_Insert_Rebalance 修复红黑树的性质 257. } 258. 259. typedef struct rb_heap 260. { 261. int key; 262. int data; 263. }RB_HEAP; 264. const int heapSize = 10; 265. RB_HEAP heap[heapSize+1]; 266. 267. /** 268. * MAX_HEAPIFY 函数对堆进行更新,使以 i 为根的子树成最大堆 269. */ 270. void MIN_HEAPIFY(RB_HEAP* A, const int& size, int i) 271. { 272. int l = LEFT(i); 273. int r = RIGHT(i); 274. int smallest = i; 275. 276. if (l <= size && A[l].data < A[i].data) 277. smallest = l; 278. if (r <= size && A[r].data < A[smallest].data) 279. smallest = r; 280. 281. if (smallest != i) 282. { 283. RB_HEAP tmp = A[i]; 284. A[i] = A[smallest]; 285. A[smallest] = tmp; 286. MIN_HEAPIFY(A, size, smallest); 287. } 288. } 289. 290. /** 291. * BUILD_MINHEAP 函数对数组 A 中的数据建立最小堆 292. */ 293. void BUILD_MINHEAP(RB_HEAP* A, const int& size) 294. { 135 295. for (int i = size/2; i >= 1; --i) 296. MIN_HEAPIFY(A, size, i); 297. } 298. 299. 300. /* 301. 3、维护 k 个元素的最小堆,原理与上述第 2 个方案一致, 302. 即用容量为 k 的最小堆存储最先在红黑树中遍历到的 k 个数,并假设它们即是最大的 k 个数, 建堆费时 O(k), 303. 然后调整堆(费时 O(logk))后,有 k1>k2>...kmin(kmin 设为小顶堆中最小元素)。 304. 继续中序遍历红黑树,每次遍历一个元素 x,与堆顶元素比较,若 x>kmin,则更新堆(用时 logk), 否则不更新堆。 305. 这样下来,总费时 O(k*logk+(n-k)*logk)=O(n*logk)。 306. 此方法得益于在堆中,查找等各项操作时间复杂度均为 logk)。 307. */ 308. 309. //中序遍历 RBTree 310. void InOrderTraverse(RB_NODE* node) 311. { 312. if (node == NULL) 313. { 314. return; 315. } 316. else 317. { 318. InOrderTraverse(node->left); 319. if (node->data > heap[1].data) // 当前节点 data 大于最小堆的最小元素时, 更新堆数据 320. { 321. heap[1].data = node->data; 322. heap[1].key = node->key; 323. MIN_HEAPIFY(heap, heapSize, 1); 324. } 325. InOrderTraverse(node->right); 326. } 327. } 328. 329. void RB_Destroy(RB_NODE* node) 330. { 331. if (NULL == node) 332. { 333. return; 334. } 335. else 136 336. { 337. RB_Destroy(node->left); 338. RB_Destroy(node->right); 339. free(node); 340. node = NULL; 341. } 342. } 343. 344. int main() 345. { 346. RB_NODE* root = NULL; 347. RB_NODE* node = NULL; 348. 349. // 初始化最小堆 350. for (int i = 1; i <= 10; ++i) 351. { 352. heap[i].key = i; 353. heap[i].data = -i; 354. } 355. BUILD_MINHEAP(heap, heapSize); 356. 357. FILE* fp = fopen("data.txt", "r"); 358. int num; 359. while (!feof(fp)) 360. { 361. fscanf(fp, "%d", &num); 362. root = RB_Insert(num, 1, root); 363. } 364. fclose(fp); 365. 366. InOrderTraverse(root); //递归遍历红黑树 367. RB_Destroy(root); 368. 369. for (i = 1; i <= 10; ++i) 370. { 371. printf("%d/t%d/n", heap[i].key, heap[i].data); 372. } 373. return 0; 374. } 程序测试:咱们来对下面这个小文件进行测试: 137 运行结果:如下图所示, 问题补遗: ok,由于在遍历红黑树采用的是递归方式比较耗内存,下面给出一个非递归遍历的程序 (下述代码若要运行,需贴到上述程序之后,因为其它的代码未变,只是在遍历红黑树的时 候,采取非递归遍历而已,同时,主函数的编写也要稍微修改下): 1. //copyright@ zhouzhenren 138 2. //July、updated,2011.05.08. 3. #define STACK_SIZE 1000 4. typedef struct 5. { // 栈的结点定义 6. RB_NODE** top; 7. RB_NODE** base; 8. }*PStack, Stack; 9. 10. bool InitStack(PStack& st) // 初始化栈 11. { 12. st->base = (RB_NODE**)malloc(sizeof(RB_NODE*) * STACK_SIZE); 13. if (!st->base) 14. { 15. printf("InitStack error!"); 16. exit(1); 17. } 18. st->top = st->base; 19. return true; 20. } 21. 22. bool Push(PStack& st, RB_NODE*& e) // 入栈 23. { 24. if (st->top - st->base >= STACK_SIZE) 25. return false; 26. *st->top = e; 27. st->top++; 28. return true; 29. } 30. 31. bool Pop(PStack& st, RB_NODE*& e) // 出栈 32. { 33. if (st->top == st->base) 34. { 35. e = NULL; 36. return false; 37. } 38. e = *--st->top; 39. return true; 40. } 41. 42. bool StackEmpty(PStack& st) // 栈是否为空 43. { 44. if (st->base == st->top) 45. return true; 139 46. else 47. return false; 48. } 49. 50. bool InOrderTraverse_Stack(RB_NODE*& T) // 中序遍历 51. { 52. PStack S = (PStack)malloc(sizeof(Stack)); 53. RB_NODE* P = T; 54. InitStack(S); 55. while (P != NULL || !StackEmpty(S)) 56. { 57. if (P != NULL) 58. { 59. Push(S, P); 60. P = P->left; 61. } 62. else 63. { 64. Pop(S, P); 65. if (P->data > heap[1].data) // 当前节点 data 大于最小堆的最小元素时, 更新堆数据 66. { 67. heap[1].data = P->data; 68. heap[1].key = P->key; 69. MIN_HEAPIFY(heap, heapSize, 1); 70. } 71. P = P->right; 72. } 73. } 74. free(S->base); 75. S->base = NULL; 76. free(S); 77. S = NULL; 78. 79. return true; 80. } 81. 82. bool PostOrderTraverse_Stack(RB_NODE*& T) //后序遍历 83. { 84. PStack S = (PStack)malloc(sizeof(Stack)); 85. RB_NODE* P = T; 86. RB_NODE* Pre = NULL; 87. InitStack(S); 88. while (P != NULL || !StackEmpty(S)) 140 89. { 90. if (P != NULL) // 非空直接入栈 91. { 92. Push(S, P); 93. P = P->left; 94. } 95. else 96. { 97. Pop(S, P); // 弹出栈顶元素赋值给 P 98. if (P->right == NULL || P->right == Pre) // P 的右子树空或是右子树 是刚访问过的 99. { // 节点,则释放当前节点内存 100. free(P); 101. Pre = P; 102. P = NULL; 103. } 104. else // 反之,当前节点重新入栈,接着判断右子树 105. { 106. Push(S, P); 107. P = P->right; 108. } 109. } 110. } 111. free(S->base); 112. S->base = NULL; 113. free(S); 114. S = NULL; 115. 116. return true; 117. } 118. 119. //主函数稍微修改如下: 120. int main() 121. { 122. RB_NODE* root = NULL; 123. RB_NODE* node = NULL; 124. 125. // 初始化最小堆 126. for (int i = 1; i <= 10; ++i) 127. { 128. heap[i].key = i; 129. heap[i].data = -i; 130. } 131. BUILD_MINHEAP(heap, heapSize); 141 132. 133. FILE* fp = fopen("data.txt", "r"); 134. int num; 135. while (!feof(fp)) 136. { 137. fscanf(fp, "%d", &num); 138. root = RB_Insert(num, 1, root); 139. } 140. fclose(fp); 141. 142. //若上面的程序后面加上了上述的非递归遍历红黑树的代码,那么以下几行代码,就得 修改如下: 143. //InOrderTraverse(root); //此句去掉(递归遍历树) 144. InOrderTraverse_Stack(root); // 非递归遍历树 145. 146. //RB_Destroy(root); //此句去掉(通过递归释放内存) 147. PostOrderTraverse_Stack(root); // 非递归释放内存 148. 149. for (i = 1; i <= 10; ++i) 150. { 151. printf("%d/t%d/n", heap[i].key, heap[i].data); 152. } 153. return 0; 154. } updated: 后来,我们狂想曲创作组中的 3 又用 hash+堆实现了上题,很明显比采用上面的红黑树, 整个实现简洁了不少,其完整源码如下: 完整源码: 1. //Author: zhouzhenren 2. //Description: 上千万或上亿数据(有重复),统计其中出现次数最多的钱 N 个数据 3. 4. //Algorithm: 采用 hash_map 来进行统计次数+堆(找出 Top K)。 5. //July,2011.05.12。纪念汶川地震三周年,默哀三秒。 6. 7. #define PARENT(i) (i)/2 8. #define LEFT(i) 2*(i) 9. #define RIGHT(i) 2*(i)+1 10. 11. #define HASHTABLESIZE 2807303 142 12. #define HEAPSIZE 10 13. #define A 0.6180339887 14. #define M 16384 //m=2^14 15. 16. #include 17. #include 18. 19. typedef struct hash_node 20. { 21. int data; 22. int count; 23. struct hash_node* next; 24. }HASH_NODE; 25. HASH_NODE* hash_table[HASHTABLESIZE]; 26. 27. HASH_NODE* creat_node(int& data) 28. { 29. HASH_NODE* node = (HASH_NODE*)malloc(sizeof(HASH_NODE)); 30. 31. if (NULL == node) 32. { 33. printf("malloc node failed!/n"); 34. exit(EXIT_FAILURE); 35. } 36. 37. node->data = data; 38. node->count = 1; 39. node->next = NULL; 40. return node; 41. } 42. 43. /** 44. * hash 函数采用乘法散列法 45. * h(k)=int(m*(A*k mod 1)) 46. */ 47. int hash_function(int& key) 48. { 49. double result = A * key; 50. return (int)(M * (result - (int)result)); 51. } 52. 53. void insert(int& data) 54. { 55. int index = hash_function(data); 143 56. HASH_NODE* pnode = hash_table[index]; 57. while (NULL != pnode) 58. { // 以存在 data,则 count++ 59. if (pnode->data == data) 60. { 61. pnode->count += 1; 62. return; 63. } 64. pnode = pnode->next; 65. } 66. 67. // 建立一个新的节点,在表头插入 68. pnode = creat_node(data); 69. pnode->next = hash_table[index]; 70. hash_table[index] = pnode; 71. } 72. 73. /** 74. * destroy_node 释放创建节点产生的所有内存 75. */ 76. void destroy_node() 77. { 78. HASH_NODE* p = NULL; 79. HASH_NODE* tmp = NULL; 80. for (int i = 0; i < HASHTABLESIZE; ++i) 81. { 82. p = hash_table[i]; 83. while (NULL != p) 84. { 85. tmp = p; 86. p = p->next; 87. free(tmp); 88. tmp = NULL; 89. } 90. } 91. } 92. 93. typedef struct min_heap 94. { 95. int count; 96. int data; 97. }MIN_HEAP; 98. MIN_HEAP heap[HEAPSIZE + 1]; 99. 144 100. /** 101. * min_heapify 函数对堆进行更新,使以 i 为跟的子树成最大堆 102. */ 103. void min_heapify(MIN_HEAP* H, const int& size, int i) 104. { 105. int l = LEFT(i); 106. int r = RIGHT(i); 107. int smallest = i; 108. 109. if (l <= size && H[l].count < H[i].count) 110. smallest = l; 111. if (r <= size && H[r].count < H[smallest].count) 112. smallest = r; 113. 114. if (smallest != i) 115. { 116. MIN_HEAP tmp = H[i]; 117. H[i] = H[smallest]; 118. H[smallest] = tmp; 119. min_heapify(H, size, smallest); 120. } 121. } 122. 123. /** 124. * build_min_heap 函数对数组 A 中的数据建立最小堆 125. */ 126. void build_min_heap(MIN_HEAP* H, const int& size) 127. { 128. for (int i = size/2; i >= 1; --i) 129. min_heapify(H, size, i); 130. } 131. 132. /** 133. * traverse_hashtale 函数遍历整个 hashtable,更新最小堆 134. */ 135. void traverse_hashtale() 136. { 137. HASH_NODE* p = NULL; 138. for (int i = 0; i < HASHTABLESIZE; ++i) 139. { 140. p = hash_table[i]; 141. while (NULL != p) 142. { // 如果当前节点的数量大于最小堆的最小值,则更新堆 143. if (p->count > heap[1].count) 145 144. { 145. heap[1].count = p->count; 146. heap[1].data = p->data; 147. min_heapify(heap, HEAPSIZE, 1); 148. } 149. p = p->next; 150. } 151. } 152. } 153. 154. int main() 155. { 156. // 初始化最小堆 157. for (int i = 1; i <= 10; ++i) 158. { 159. heap[i].count = -i; 160. heap[i].data = i; 161. } 162. build_min_heap(heap, HEAPSIZE); 163. 164. FILE* fp = fopen("data.txt", "r"); 165. int num; 166. while (!feof(fp)) 167. { 168. fscanf(fp, "%d", &num); 169. insert(num); 170. } 171. fclose(fp); 172. 173. traverse_hashtale(); 174. 175. for (i = 1; i <= 10; ++i) 176. { 177. printf("%d/t%d/n", heap[i].data, heap[i].count); 178. } 179. 180. return 0; 181. } 程序测试:对 65047kb 的数据量文件,进行测试统计(不过,因其数据量实在太大,半天 没打开): 146 运行结果:如下, 147 第四节、海量数据处理问题一般总结 关于海量数据处理的问题,一般有 Bloom filter,Hashing,bit-map,堆,trie 树等方法 来处理。更详细的介绍,请查看此文:十道海量数据处理面试题与十个方法大总结。 余音 反馈:此文发布后,走进搜索引擎的作者&&深入搜索引擎-海量信息的压缩、索引和查 询的译者,梁斌老师,对此文提了点意见,如下:1、首先 TopK 问题,肯定需要有并发的, 否则串行搞肯定慢,IO 和计算重叠度不高。其次在 IO 上需要一些技巧,当然可能只是验证 算法,在实践中 IO 的提升会非常明显。最后上文的代码可读性虽好,但机器的感觉可能就 会差,这样会影响性能。2、同时,TopK 可以看成从地球上选拔 k 个跑的最快的,参加奥 林匹克比赛,各个国家自行选拔,各个大洲选拔,层层选拔,最后找出最快的 10 个。发挥 多机多核的优势。 预告:程序员面试题狂想曲、第四章,本月月底之前发布(尽最大努力)。 修订 程序员面试题狂想曲-tctop(the crazy thingking of programers)的修订 wiki (http://tctop.wikispaces.com/)已于今天建立,我们急切的想得到读者的反馈,意见,建 议,以及更好的思路,算法,和代码优化的建议。所以,  如果你发现了狂想曲系列中的任何一题,任何一章(http://t.cn/hgVPmH)中 的错误,问题,与漏洞,欢迎告知给我们,我们将感激不尽,同时,免费赠 送本 blog 内的全部博文集锦的 CHM 文件 1 期; 148  如果你能对狂想曲系列的创作提供任何建设性意见,或指导,欢迎反馈给我们, 并真诚邀请您加入到狂想曲的 wiki 修订工作中;  如果你是编程高手,对狂想曲的任何一章有自己更好的思路,或算法,欢迎加 入狂想曲的创作组,以为千千万万的读者创造更多的价值,更好的服务。 Ps:狂想曲 tctop 的 wiki 修订地址为:http://tctop.wikispaces.com/。欢迎围观,更欢迎 您加入到狂想曲的创作或 wiki 修订中。 联系 July •email,zhoulei0907@yahoo.cn •blog,http://blog.csdn.net/v_JULY_v 。 •weibo,http://weibo.com/julyweibo 。 作者按:有任何问题,或建议,欢迎以上述联系方式 call me,真诚的谢谢各位。 July、狂想曲创作组,二零一一年五月十日。 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。实要转载,请以链接形式注 明出处。 第三章再续:快速选择 SELECT 算法的深入分析与实现 作者:July。 出处:http://blog.csdn.net/v_JULY_v 。 149 前言 经典算法研究系列已经写了十三个算法,共计 22 篇文章(详情,见这:十三个经典算法 研究与总结、目录+索引),我很怕我自己不再把这个算法系列给继续写下去了。沉思良久, 到底是不想因为要创作狂想曲系列而耽搁这个经典算法研究系列,何况它,至今反响还不错。 ok,狂想曲第三章提出了一个算法,就是快速选择 SELECT 算法,关于这个 SELECT 算法通过选取数组中中位数的中位数作为枢纽元能保证在最坏情况下,亦能做到线性 O(N) 的时间复杂度的证明,在狂想曲第三章也已经给出。 本文咱们从快速排序算法分析开始(因为如你所知,快速选择算法与快速排序算法在 partition 划分过程上是类似的),参考 Mark 的数据结构与算法分析-c 语言描述一书,而后 逐步深入分析快速选择 SELECT 算法,最后,给出 SELECT 算法的程序实现。 同时,本文有部分内容来自狂想曲系列第三章,也算是对第三章、寻找最小的 k 个数的 一个总结。yeah,有任何问题,欢迎各位批评指正,如果你挑出了本文章或本 blog 任何一 个问题或错误,当即免费给予单独赠送本 blog 最新一期第 6 期的博文集锦 CHM 文件,谢 谢。 第一节、快速排序 1.1、快速排序算法的介绍 关于快速排序算法,本人已经写了 3 篇文章(可参见其中的两篇:1、十二、快速排序 算法之所有版本的 c/c++实现,2、一之续、快速排序算法的深入分析),为何又要旧事重 提列?正如很多事物都有相似的地方,而咱们面临的问题--快速选择算法中的划分过程等同 于快速排序,所以,在分析快速选择 SELECT 算法之前,咱们先再来简单回顾和分析下快 速排序,ok,今天看到 Mark 的数据结构与算法分析-c 语言描述一书上对快速排序也有不错 的介绍,所以为了增加点新鲜感,就不用自己以前的文章而改为直接引用 Mark 的叙述了: As its name implies, quicksort is the fastest known sorting algorithm in practice. Its average running time is O(n log n)(快速排序是实践中已知的最快的排序算法,他的平均运行时间为 O (N*logN)). It is very fast, mainly due to a very tight and highly optimized inner loop. It has O(n2) worst-case performance(最坏情形的性能为 O(N^2)), but this can be made exponentially unlikely with a little effort. 150 The quicksort algorithm is simple to understand and prove correct, although for many years it had the reputation of being an algorithm that could in theory be highly optimized but in practice was impossible to code correctly (no doubt because of FORTRAN). Like mergesort, quicksort is a divide-and-conquer recursive algorithm(像归并排序一样,快 速排序也是一种采取分治方法的递归算法). The basic algorithm to sort an array S consists of the following four easy steps(通过下面的 4 个步骤将数组 S 排序的算法如下): 1. If the number of elements in S is 0 or 1, then return(如果 S 中元素个数是 0 或 1,则返回). 2. Pick any element v in S. This is called the pivot(取 S 中任一元素 v,作为枢纽元). 3. Partition S - {v} (the remaining elements in S) into two disjoint groups(枢纽元 v 将 S 中其余 的元素分成两个不想交的集合): S1 = {x(- S-{v}| x <= v}, and S2 = {x(- S-{v}| x >= v}. 4. Return { quicksort(S1) followed by v followed by quicksort(S2)}. 下面依据上述步骤对序列 13,81,92,43,65,31,57,26,75,0 进行第一趟划分处理,可得到如下 图所示的过程: 151 1.2、选取枢纽元的几种方法 1、糟糕的方法 通常的做法是选择数组中第一个元素作为枢纽元,如果输入是随机的,那么这是可以接 受的。但是,如果输入序列是预排序的或者是反序的,那么依据这样的枢纽元进行划分则会 出现相当糟糕的情况,因为可能所有的元素不是被划入 S1,就是都被划入 S2 中。 2、较好的方法 一个比较好的做法是随机选取枢纽元,一般来说,这种策略是比较妥当的。 3、三数取取中值方法 例如,输入序列为 8, 1, 4, 9, 6, 3, 5, 2, 7, 0 ,它的左边元素为 8,右边元素为 0,中间 位置|_left+right)/2_|上的元素为 6,于是枢纽元为 6.显然,使用三数中值分割法消除了预 152 排序输入的坏情形,并且减少了快速排序大约 5%(此为前人实验所得数据,无法具体证明) 的运行时间。 1.3、划分过程 下面,我们再对序列 8, 1, 4, 9, 6, 3, 5, 2, 7, 0 进行第一趟划分,我们要达到的划分目的 就是为了把所有小于枢纽元(据三数取中分割法取元素 6 为枢纽元)的元素移到数组的左边, 而把所有大于枢纽元的元素全部移到数组的右边。 此过程,如下述几个图所示: 8 1 4 9 0 3 5 2 7 6 i j 8 1 4 9 0 3 5 2 7 6 i j After First Swap: ---------------------------- 2 1 4 9 0 3 5 8 7 6 i j Before Second Swap: ---------------------------- 2 1 4 9 0 3 5 8 7 6 i j After Second Swap: ---------------------------- 2 1 4 5 0 3 9 8 7 6 i j Before Third Swap ---------------------------- 2 1 4 5 0 3 9 8 7 6 j i //i,j 在元素 3 处碰头之后,i++指向了 9,最后与 6 交换后,得到: 153 2 1 4 5 0 3 6 8 7 9 i pivot 至此,第一趟划分过程结束,枢纽元 6 将整个序列划分成了左小右大两个部分。 1.4、四个细节 下面,是 4 个值得你注意的细节问题: 1、我们要考虑一下,就是如何处理那些等于枢纽元的元素,问题在于当 i 遇到第一个等 于枢纽元的关键字时,是否应该停止移动 i,或者当 j 遇到一个等于枢纽元的元素时是否应 该停止移动 j。 答案是:如果 i,j 遇到等于枢纽元的元素,那么我们就让 i 和 j 都停止移动。 2、对于很小的数组,如数组的大小 N<=20 时,快速排序不如插入排序好。 3、只通过元素间进行比较达到排序目的的任何排序算法都需要进行 O(N*logN)次比较, 如快速排序算法(最坏 O(N^2),最好 O(N*logN)),归并排序算法(最坏 O(N*logN, 不过归并排序的问题在于合并两个待排序的序列需要附加线性内存,在整个算法中,还要将 数据拷贝到临时数组再拷贝回来这样一些额外的开销,放慢了归并排序的速度)等。 4、下面是实现三数取中的划分方法的程序: //三数取中分割法 input_type median3( input_type a[], int left, int right ) //下面的快速排序算法实现之一,及通过三数取中分割法寻找最小的 k 个数的快速选择 SELECT 算法都要调用这个 median3 函数 { int center; center = (left + right) / 2; if( a[left] > a[center] ) swap( &a[left], &a[center] ); if( a[left] > a[right] ) swap( &a[left], &a[right] ); if( a[center] > a[right] ) swap( &a[center], &a[right] ); /* invariant: a[left] <= a[center] <= a[right] */ swap( &a[center], &a[right-1] ); /* hide pivot */ 154 return a[right-1]; /* return pivot */ } 下面的程序是利用上面的三数取中分割法而运行的快速排序算法: //快速排序的实现之一 void q_sort( input_type a[], int left, int right ) { int i, j; input_type pivot; if( left + CUTOFF <= right ) { pivot = median3( a, left, right ); //调用上面的实现三数取中分割法的 median3 函数 i=left; j=right-1; //第 8 句 for(;;) { while( a[++i] < pivot ); while( a[--j] > pivot ); if( i < j ) swap( &a[i], &a[j] ); else break; //第 16 句 } swap( &a[i], &a[right-1] ); /*restore pivot*/ q_sort( a, left, i-1 ); q_sort( a, i+1, right ); //如上所见,在划分过程(partition)后,快速排序需要两次递归,一次对左边递归 //一次对右边递归。下面,你将看到,快速选择 SELECT 算法始终只对一边进行递归。 //这从直观上也能反应出:此快速排序算法(O(N*logN))明显会比 //下面第二节中的快速选择 SELECT 算法(O(N))平均花费更多的运行时间。 } } 如果上面的第 8-16 句,改写成以下这样: 155 i=left+1; j=right-2; for(;;) { while( a[i] < pivot ) i++; while( a[j] > pivot ) j--; if( i < j ) swap( &a[i], &a[j] ); else break; } 那么,当 a[i] = a[j] = pivot 则会产生无限,即死循环(相信,不用我多余解释,:D)。ok, 接下来,咱们将进入正题--快速选择 SELECT 算法。 第二节、线性期望时间的快速选择 SELECT 算法 2.1、快速选择 SELECT 算法的介绍 Quicksort can be modified to solve the selection problem, which we have seen in chapters 1 and 6. Recall that by using a priority queue, we can find the kth largest (or smallest) element in O(n + k log n)(以用最小堆初始化数组,然后取这个优先队列前 k 个值,复杂度 O(n)+k*O(log n)。 实际上,最好采用最大堆寻找最小的 k 个数,那样,此时复杂度为 n*logk。更多详情,请参见: 狂想曲系列第三章、寻找最小的 k 个数). For the special case of finding the median, this gives an O(n log n) algorithm. Since we can sort the file in O(nlog n) time, one might expect to obtain a better time bound for selection. The algorithm we present to find the kth smallest element in a set S is almost identical to quicksort. In fact, the first three steps are the same. We will call this algorithm quickselect(叫做快速选择). Let |Si| denote the number of elements in Si(令|Si|为 Si 中元素 的个数). The steps of quickselect are: 1. If |S| = 1, then k = 1 and return the elements in S as the answer. If a cutoff for small files is being used and |S| <=CUTOFF, then sort S and return the kth smallest element. 2. Pick a pivot element, v (- S.(选取一个枢纽元 v 属于 S) 3. Partition S - {v} into S1 and S2, as was done with quicksort. (将集合 S-{v}分割成 S1 和 S2,就像我们在快速排序中所作的那样) 156 4. If k <= |S1|, then the kth smallest element must be in S1. In this case, return quickselect (S1, k). If k = 1 + |S1|, then the pivot is the kth smallest element and we can return it as the answer. Otherwise, the kth smallest element lies in S2, and it is the (k - |S1| - 1)st smallest element in S2. We make a recursive call and return quickselect (S2, k - |S1| - 1). (如果 k<=|S1|,那么第 k 个最小元素必然在 S1 中。在这种情况下,返回 quickselect(S1,k)。 如果 k=1+|S1|,那么枢纽元素就是第 k 个最小元素,即找到,直接返回它。否则,这第 k 个最 小元素就在 S2 中,即 S2 中的第(k-|S1|-1)个最小元素,我们递归调用并返回 quickselect(S2, k-|S1|-1))(下面几节的程序关于 k 的表述可能会有所出入,但无碍,抓住原理即 ok)。 In contrast to quicksort, quickselect makes only one recursive call instead of two. The worst case of quickselect is identical to that of quicksort and is O(n2). Intuitively, this is because quicksort's worst case is when one of S1 and S2 is empty; thus, quickselect(快速选择) is not really saving a recursive call. The average running time, however, is O(n)(不过,其平均运行 时间为 O(N)。看到了没,就是平均复杂度为 O(N)这句话). The analysis is similar to quicksort's and is left as an exercise. The implementation of quickselect is even simpler than the abstract description might imply. The code to do this shown in Figure 7.16. When the algorithm terminates, the kth smallest element is in position k. This destroys the original ordering; if this is not desirable, then a copy must be made. 2.2、三数中值分割法寻找第 k 小的元素 第一节,已经介绍过此三数中值分割法,有个细节,你要注意,即数组元素索引是从“0...i” 开始计数的,所以第 k 小的元素应该是返回 a[i]=a[k-1].即 k-1=i。换句话就是说,第 k 小元 素,实际上应该在数组中对应下标为 k-1。ok,下面给出三数中值分割法寻找第 k 小的元素 的程序的两个代码实现: 1. //代码实现一 2. //copyright@ mark allen weiss 3. //July、updated,2011.05.05 凌晨. 4. 5. //三数中值分割法寻找第 k 小的元素的快速选择 SELECT 算法 6. void q_select( input_type a[], int k, int left, int right ) 7. { 8. int i, j; 9. input_type pivot; 10. if( left /*+ CUTOFF*/ <= right ) //去掉 CUTOFF 常量,无用 11. { 157 12. pivot = median3( a, left, right ); //调用 1、4 节里的实现三数取中分割法 的 median3 函数 13. //取三数中值作为枢纽元,可以消除最坏情况而保证此算法是 O(N)的。不过,这还 只局限在理论意义上。 14. //稍后,您将看到另一种选取枢纽元的方法。 15. 16. i=left; j=right-1; 17. for(;;) //此句到下面的九行代码,即为快速排序中的 partition 过程的实现之 一 18. { 19. while( a[++i] < pivot ){} 20. while( a[--j] > pivot ){} 21. if (i < j ) 22. swap( &a[i], &a[j] ); 23. else 24. break; 25. } 26. swap( &a[i], &a[right-1] ); /* restore pivot */ 27. if( k < i) 28. q_select( a, k, left, i-1 ); 29. else 30. if( k-1 > i ) //此条语句相当于:if(k>i+1) 31. q-select( a, k, i+1, right ); 32. //1、希望你已经看到,通过上面的 if-else 语句表明,此快速选择 SELECT 算法 始终只对数组的一边进行递归, 33. //这也是其与第一节中的快速排序算法的本质性区别。 34. 35. //2、这个区别则直接决定了:快速排序算法最快能达到 O(N*logN), 36. //而快速选择 SELECT 算法则最坏亦能达到 O(N)的线性时间复杂度。 37. //3、而确保快速选择算法最坏情况下能做到 O(N)的根本保障在于枢纽元元素的 选取, 38. //即采取稍后的 2.3 节里的五分化中项的中项,或 2.4 节里的中位数的中外位数 的枢纽元选择方法达到 O(N)的目的。 39. //后天老爸生日,孩儿深深祝福。July、updated,2011.05.19。 40. } 41. else 42. insert_sort(a, left, right-left+1 ); 43. } 44. 45. 46. //代码实现二 47. //copyright @ 飞羽 48. //July、updated,2011.05.11。 49. //三数中值分割法寻找第 k 小的元素 158 50. bool median_select(int array[], int left, int right, int k) 51. { 52. //第 k 小元素,实际上应该在数组中下标为 k-1 53. if (k-1 > right || k-1 < left) 54. return false; 55. 56. //三数中值作为枢纽元方法,关键代码就是下述六行: 57. int midIndex=(left+right)/2; 58. if(array[left] k-1) 71. return median_select(array, left, pos-1, k); 72. else return median_select(array, pos+1, right, k); 73. } 上述程序使用三数中值作为枢纽元的方法可以使得最坏情况发生的概率几乎可以忽略不 计。然而,稍后,您将看到:通过一种更好的方法,如“五分化中项的中项”,或“中位数的 中位数”等方法选取枢纽元,我们将能彻底保证在最坏情况下依然是线性 O(N)的复杂度。 即,如稍后 2.3 节所示。 2.3、五分化中项的中项,确保 O(N) The selection problem requires us to find the kth smallest element in a list S of n elements (要求我们找出含 N 个元素的表 S 中的第 k 个最小的元素). Of particular interest is the special case of finding the median. This occurs when k = |-n/2-|(向上取整).(我们对找出中间元素的 特殊情况有着特别的兴趣,这种情况发生在 k=|-n/2-|的时候) In Chapters 1, 6, 7 we have seen several solutions to the selection problem. The solution in Chapter 7 uses a variation of quicksort and runs in O(n) average time(第 7 章中的解法,即本 文上面第 1 节所述的思路 4,用到快速排序的变体并以平均时间 O(N)运行). Indeed, it is described in Hoare's original paper on quicksort. 159 Although this algorithm runs in linear average time, it has a worst case of O (n2)(但它有一 个 O(N^2)的最快情况). Selection can easily be solved in O(n log n) worst-case time by sorting the elements, but for a long time it was unknown whether or not selection could be accomplished in O(n) worst-case time. The quickselect algorithm outlined in Section 7.7.6 is quite efficient in practice, so this was mostly a question of theoretical interest. Recall that the basic algorithm is a simple recursive strategy. Assuming that n is larger than the cutoff point where elements are simply sorted, an element v, known as the pivot, is chosen. The remaining elements are placed into two sets, S1 and S2. S1 contains elements that are guaranteed to be no larger than v, and S2 contains elements that are no smaller than v. Finally, if k <= |S1|, then the kth smallest element in S can be found by recursively computing the kth smallest element in S1. If k = |S1| + 1, then the pivot is the kth smallest element. Otherwise, the kth smallest element in S is the (k - |S1| -1 )st smallest element in S2. The main difference between this algorithm and quicksort is that there is only one subproblem to solve instead of two(这个快速选择算法与快速排序之间的主要区别在于,这里求解的只有一个子问题,而不是 两个子问题)。 定理 10.9 The running time of quickselect using median-of-median-of-five partitioning is O(n)。 The basic idea is still useful. Indeed, we will see that we can use it to improve the expected number of comparisons that quickselect makes. To get a good worst case, however, the key idea is to use one more level of indirection. Instead of finding the median from a sample of random elements, we will find the median from a sample of medians. The basic pivot selection algorithm is as follows: 1. Arrange the n elements into |_n/5_| groups of 5 elements, ignoring the (at most four) extra elements. 2. Find the median of each group. This gives a list M of |_n/5_| medians. 3. Find the median of M. Return this as the pivot, v. We will use the term median-of-median-of-five partitioning to describe the quickselect algorithm that uses the pivot selection rule given above. (我们将用术语“五分化中项的中项” 来描述使用上面给出的枢纽元选择法的快速选择算法)。We will now show that median-of-median-of-five partitioning guarantees that each recursive subproblem is at most roughly 70 percent as large as the original(现在我们要证明,“五分化中项的中项”,得保证每 个递归子问题的大小最多为原问题的大约 70%). We will also show that the pivot can be 160 computed quickly enough to guarantee an O (n) running time for the entire selection algorithm (我们还要证明,对于整个选择算法,枢纽元可以足够快的算出,以确保 O(N)的运行时间。 看到了没,这再次佐证了我们的类似快速排序的 partition 过程的分治方法为 O(N)的观点)(更 多详细的证明,请参考:第三章、寻找最小的 k 个数)。 2.4、中位数的中位数,O(N)的再次论证 以下内容来自算法导论第九章第 9.3 节全部内容(最坏情况线性时间的选择),如下(我 酌情对之参考原中文版做了翻译,下文中括号内的中文解释,为我个人添加): 9.3 Selection in worst-case linear time(最坏情况下线性时间的选择算法) We now examine a selection algorithm whose running time is O(n) in the worst case(现在来看, 一个最坏情况运行时间为 O(N)的选择算法 SELECT). Like RANDOMIZED-SELECT, the algorithm SELECT finds the desired element by recursively partitioning the input array. The idea behind the algorithm, however, is to guarantee a good split when the array is partitioned. SELECT uses the deterministic partitioning algorithm PARTITION from quicksort (see Section 7.1), modified to take the element to partition around as an input parameter(像 RANDOMIZED-SELECT 一样,SELECTT 通过输 入数组的递归划分来找出所求元素,但是,该算法的基本思想是要保证对数组的划分是个好的划 分。SECLECT 采用了取自快速排序的确定性划分算法 partition,并做了修改,把划分主元元素作 为其参数). The SELECT algorithm determines the ith smallest of an input array of n > 1 elements by executing the following steps. (If n = 1, then SELECT merely returns its only input value as the ith smallest.)(算法 SELECT 通过执行下列步骤来确定一个有 n>1 个元素的输入数组中的第 i 小的元素。(如果 n=1, 则 SELECT 返回它的唯一输入数值作为第 i 个最小值。)) 1. Divide the n elements of the input array into ⌊n/5⌋ groups of 5 elements each and at most one group made up of the remaining n mod 5 elements. 2. Find the median of each of the ⌈n/5⌉ groups by first insertion sorting the elements of each group (of which there are at most 5) and then picking the median from the sorted list of group elements. 3. Use SELECT recursively to find the median x of the ⌈n/5⌉ medians found in step 2. (If there are an even number of medians, then by our convention, x is the lower median.) 4. Partition the input array around the median-of-medians x using the modified version of PARTITION. Let k be one more than the number of elements on the low side of the partition, so that x is the kth smallest element and there are n-kelements on the high side of the partition.(利用修改过的 partition 过程,按中位数的中位数 x 对输入数组进行划分,让 k 比划低去的元素数目多 1,所以,x 是第 k 小的元素,并且有 n-k 个元素在划分的高区) 161 5. If i = k, then return x. Otherwise, use SELECT recursively to find the ith smallest element on the low side if i < k, or the (i - k)th smallest element on the high side if i > k.(如果要找的第 i 小的元素等于程序返回的 k,即 i=k,则返回 x。否则,如果 ik,则在高区间找第(i-k)个最小元素) (以上五个步骤,即本文上面的第四节末中所提到的所谓“五分化中项的中项”的方法。) To analyze the running time of SELECT, we first determine a lower bound on the number of elements that are greater than the partitioning element x. (为了分析 SELECT 的运行时间,先来确 定大于划分主元元素 x 的的元素数的一个下界)Figure 9.1 is helpful in visualizing this bookkeeping. At least half of the medians found in step 2 are greater than[1] the median-of-medians x. Thus, at least half of the ⌈n/5⌉ groups contribute 3 elements that are greater than x, except for the one group that has fewer than 5 elements if 5 does not divide n exactly, and the one group containing x itself. Discounting these two groups, it follows that the number of elements greater than x is at least: 162 (Figure 9.1: 对上图的解释或称对 SELECT 算法的分析:n 个元素由小圆圈来表示,并且每一个 组占一纵列。组的中位数用白色表示,而各中位数的中位数 x 也被标出。(当寻找偶数数目元素 的中位数时,使用下中位数)。箭头从比较大的元素指向较小的元素,从中可以看出,在 x 的右 边,每一个包含 5 个元素的组中都有 3 个元素大于 x,在 x 的左边,每一个包含 5 个元素的组中 有 3 个元素小于 x。大于 x 的元素以阴影背景表示。 ) Similarly, the number of elements that are less than x is at least 3n/10 - 6. Thus, in the worst case, SELECT is called recursively on at most 7n/10 + 6 elements in step 5. We can now develop a recurrence for the worst-case running time T(n) of the algorithm SELECT. Steps 1, 2, and 4 take O(n) time. (Step 2 consists of O(n) calls of insertion sort on sets of size O(1).) Step 3 takes time T(⌈n/5⌉), and step 5 takes time at most T(7n/10+ 6), assuming that T is monotonically increasing. We make the assumption, which seems unmotivated at first, that any input of 140 or fewer elements requires O(1) time; the origin of the magic constant 140 will be clear shortly. We can therefore obtain the recurrence: We show that the running time is linear by substitution. More specifically, we will show that T(n) ≤ cn for some suitably large constant c and all n > 0. We begin by assuming that T(n) ≤ cn for some suitably large constant c and all n ≤ 140; this assumption holds if c is large enough. We also pick a constant a such that the function described by the O(n) term above (which describes the non-recursive component of the running time of the algorithm) is bounded above by an for all n > 0. Substituting this inductive hypothesis into the right-hand side of the recurrence yields T(n) ≤ c ⌈n/5⌉ + c(7n/10 + 6) + an ≤ cn/5 + c + 7cn/10 + 6c + an = 9cn/10 + 7c + an = cn + (-cn/10 + 7c + an) , which is at most cn if Inequality (9.2) is equivalent to the inequality c ≥ 10a(n/(n - 70)) when n > 70. Because we assume that n ≥ 140, we have n/(n - 70) ≤ 2, and so choosing c ≥ 20a will satisfy inequality (9.2). (Note that there is nothing special about the constant 140; we could replace it by any integer strictly greater than 70 and then choose caccordingly.) The worst-case running time of SELECT is therefore linear(因 此,此 SELECT 的最坏情况的运行时间是线性的). As in a comparison sort (see Section 8.1), SELECT and RANDOMIZED-SELECT determine information about the relative order of elements only by comparing elements. Recall from Chapter 8 that sorting requires Ω(n lg n) time in the comparison model, even on average (see Problem 8-1). The linear-time 163 sorting algorithms in Chapter 8 make assumptions about the input. In contrast, the linear-time selection algorithms in this chapter do not require any assumptions about the input. They are not subject to the Ω(nlg n) lower bound because they manage to solve the selection problem without sorting. (与比较排序(算法导论 8.1 节)中的一样,SELECT 和 RANDOMIZED-SELECT 仅通过元素间的比 较来确定它们之间的相对次序。在算法导论第 8 章中,我们知道在比较模型中,即使在平均情况 下,排序仍然要 O(n*logn)的时间。第 8 章得线性时间排序算法在输入上做了假设。相反地, 本节提到的此类似 partition 过程的 SELECT 算法不需要关于输入的任何假设,它们不受下界 O (n*logn)的约束,因为它们没有使用排序就解决了选择问题(看到了没,道出了此算法的本质 阿)) Thus, the running time is linear because these algorithms do not sort; the linear-time behavior is not a result of assumptions about the input, as was the case for the sorting algorithms in Chapter 8. Sorting requires Ω(n lg n) time in the comparison model, even on average (see Problem 8-1), and thus the method of sorting and indexing presented in the introduction to this chapter is asymptotically inefficient.(所以,本节中的选择算法之所以具有线性运行时间,是因为这些算法没有进行排序; 线性时间的结论并不需要在输入上所任何假设,即可得到。.....) 第三节、快速选择 SELECT 算法的实现 本节,咱们将依据下图所示的步骤,采取中位数的中位数选取枢纽元的方法来实现此 SELECT 算法, 不过,在实现之前,有个细节我还是必须要提醒你,即上文中 2.2 节开头处所述,“数组 元素索引是从“0...i”开始计数的,所以第 k 小的元素应该是返回 a[i]=a[k-1].即 k-1=i。换句话 就是说,第 k 小元素,实际上应该在数组中对应下标为 k-1”这句话,我想,你应该明白了: 返回数组中第 k 小的元素,实际上就是返回数组中的元素 array[i],即 array[k-1]。ok,最后 164 请看此快速选择 SELECT 算法的完整代码实现(据我所知,在此之前,从没有人采取中位 数的中位数选取枢纽元的方法来实现过这个 SELECT 算法): 1. //copyright@ yansha && July && 飞羽 2. //July、updated,2011.05.19.清晨。 3. //版权所有,引用必须注明出处:http://blog.csdn.net/v_JULY_v。 4. #include 5. #include 6. using namespace std; 7. 8. const int num_array = 13; 9. const int num_med_array = num_array / 5 + 1; 10. int array[num_array]; 11. int midian_array[num_med_array]; 12. 13. //冒泡排序(晚些时候将修正为插入排序) 14. /*void insert_sort(int array[], int left, int loop_times, int compare_times) 15. { 16. for (int i = 0; i < loop_times; i++) 17. { 18. for (int j = 0; j < compare_times - i; j++) 19. { 20. if (array[left + j] > array[left + j + 1]) 21. swap(array[left + j], array[left + j + 1]); 22. } 23. } 24. }*/ 25. 26. /* 27. //插入排序算法伪代码 28. INSERTION-SORT(A) cost times 29. 1 for j ← 2 to length[A] c1 n 30. 2 do key ← A[j] c2 n - 1 31. 3 Insert A[j] into the sorted sequence A[1 ‥ j - 1]. 0...n - 1 32. 4 i ← j - 1 c4 n - 1 33. 5 while i > 0 and A[i] > key c5 34. 6 do A[i + 1] ← A[i] c6 35. 7 i ← i - 1 c7 36. 8 A[i + 1] ← key c8 n - 1 37. */ 38. //已修正为插入排序,如下: 39. void insert_sort(int array[], int left, int loop_times) 165 40. { 41. for (int j = left; j < left+loop_times; j++) 42. { 43. int key = array[j]; 44. int i = j-1; 45. while ( i>left && array[i]>key ) 46. { 47. array[i+1] = array[i]; 48. i--; 49. } 50. array[i+1] = key; 51. } 52. } 53. 54. int find_median(int array[], int left, int right) 55. { 56. if (left == right) 57. return array[left]; 58. 59. int index; 60. for (index = left; index < right - 5; index += 5) 61. { 62. insert_sort(array, index, 4); 63. int num = index - left; 64. midian_array[num / 5] = array[index + 2]; 65. } 66. 67. // 处理剩余元素 68. int remain_num = right - index + 1; 69. if (remain_num > 0) 70. { 71. insert_sort(array, index, remain_num - 1); 72. int num = index - left; 73. midian_array[num / 5] = array[index + remain_num / 2]; 74. } 75. 76. int elem_aux_array = (right - left) / 5 - 1; 77. if ((right - left) % 5 != 0) 78. elem_aux_array++; 79. 80. // 如果剩余一个元素返回,否则继续递归 81. if (elem_aux_array == 0) 82. return midian_array[0]; 83. else 166 84. return find_median(midian_array, 0, elem_aux_array); 85. } 86. 87. // 寻找中位数的所在位置 88. int find_index(int array[], int left, int right, int median) 89. { 90. for (int i = left; i <= right; i++) 91. { 92. if (array[i] == median) 93. return i; 94. } 95. return -1; 96. } 97. 98. int q_select(int array[], int left, int right, int k) 99. { 100. // 寻找中位数的中位数 101. int median = find_median(array, left, right); 102. 103. // 将中位数的中位数与最右元素交换 104. int index = find_index(array, left, right, median); 105. swap(array[index], array[right]); 106. 107. int pivot = array[right]; 108. 109. // 申请两个移动指针并初始化 110. int i = left; 111. int j = right - 1; 112. 113. // 根据枢纽元素的值对数组进行一次划分 114. while (true) 115. { 116. while(array[i] < pivot) 117. i++; 118. while(array[j] > pivot) 119. j--; 120. if (i < j) 121. swap(array[i], array[j]); 122. else 123. break; 124. } 125. swap(array[i], array[right]); 126. 127. /* 对三种情况进行处理:(m = i - left + 1) 167 128. 1、如果 m=k,即返回的主元即为我们要找的第 k 小的元素,那么直接返回主元 a[i]即可; 129. 2、如果 m>k,那么接下来要到低区间 A[0....m-1]中寻找,丢掉高区间; 130. 3、如果 m k) 136. //上条语句相当于 if( (i-left+1) >k),即 if( (i-left) > k-1 ),于此就与 2.2 节里的代码实现一、二相对应起来了。 137. return q_select(array, left, i - 1, k); 138. else 139. return q_select(array, i + 1, right, k - m); 140. } 141. 142. int main() 143. { 144. //srand(unsigned(time(NULL))); 145. //for (int j = 0; j < num_array; j++) 146. //array[j] = rand(); 147. 148. int array[num_array]={0,45,78,55,47,4,1,2,7,8,96,36,45}; 149. // 寻找第 k 最小数 150. int k = 4; 151. int i = q_select(array, 0, num_array - 1, k); 152. cout << i << endl; 153. 154. return 0; 155. } 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。实要转载,请以链接形式注 明出处。 第三章三续、求数组中给定下标区间内的第 K 小(大)元素 作者:July、上善若水、编程艺术室。 出处:http://blog.csdn.net/v_JULY_v 。 168 前奏 原狂想曲系列已更名为:程序员编程艺术系列。原狂想曲创作组更名为编程艺术室。编 程艺术室致力于以下三点工作:1、针对一个问题,不断寻找更高效的算法,并予以编程实 现。2、解决实际中会碰到的应用问题,如第十章、如何给磁盘文件排序。3、经典算法的 研究与实现。总体突出一点:编程,如何高效的编程解决实际问题。欢迎有志者加入。 ok,扯远了。在上一章,我们介绍了第十章、如何给 10^7 个数据量的磁盘文件排序, 下面介绍下本章的主题。我们知道,通常来讲,寻找给定区间内的第 k 小(大)的元素的问 题是 ACM 中一类常用的数据结构的一个典型例题,即划分树/逆向归并树,通常用线段树的 结构存储。 当然这里暂且不表,尚不说划分树思想的神奇,就是线段树的结构,一般没有 ACM 基础 的人也都觉得难以理解。所以,这里提供一个时间效率尚可,空间代价还要略小的巧妙解法 —伴随数组。 如果看过此前程序员编程艺术:第六章、求解 500 万以内的亲和数中,有关亲和数的那 个题目的伴随数组的解法,也就是利用数组下标作为伴随数组,相信就会对这个方法有一定 程度的理解。 第一节、寻找给定区间内的第 k 小(大)的元素 给定数组,给定区间,求第 K 小的数如何处理?常规方法请查阅:程序员编程艺术:第 三章、寻找最小的 k 个数。 1、排序,快速排序。我们知道,快速排序平均所费时间为 n*logn,从小到大 排序这 n 个数,然后再遍历序列中后 k 个元素输出,即可,总的时间复杂度为 O (n*logn+k)=O(n*logn)。 2、排序,选择排序。用选择或交换排序,即遍历 n 个数,先把最先遍历到得 k 个数存入大小为 k 的数组之中,对这 k 个数,利用选择或交换排序,找到 k 个 数中的最小数 kmax(kmax 设为 k 个元素的数组中最小元素),用时 O(k)(你 应该知道,插入或选择排序查找操作需要 O(k)的时间),后再继续遍历后 n-k 个数,x 与 kmax 比较:如果 x 5. #include 6. using namespace std; 7. 8. struct node{ 9. int num,data; 10. bool operator < (const node &p) const 171 11. { 12. return data < p.data; 13. } 14. }; 15. node p[100001]; 16. 17. int main() 18. { 19. int n=7; 20. int i,j,a,b,c;//c:flag; 21. 22. for(i=1;i<=n;i++) 23. { 24. scanf("%d",&p[i].data); 25. p[i].num = i; 26. } 27. sort(p+1,p+1+n); //调用库函数 sort 完成排序,复杂度 n*logn 28. 29. scanf("%d %d %d",&a,&b,&c); 30. for(i=1;i<=n;i++) //扫描一遍,复杂度 n 31. { 32. if(p[i].num>=a && p[i].num<=b) 33. c--; 34. if(c == 0) 35. break; 36. } 37. printf("%d/n",p[i].data); 38. return 0; 39. } 程序测试:输入的第 1 行数字 1 5 2 6 3 7 4 代表给定的数组,第二行的数字中,2 5 代表给 定的下标区间 2~5,3 表示要在给定的下标区间 2~5 中寻找第 3 小的数,第三行的 5 表示找 到的第 3 小的数。程序运行结果如下: 172 水原来写的代码(上面我的改造,是为了达到后来扫描时 O(N)的视觉效果): 1. //copyright@ 水 2. #include 3. #include 4. using namespace std; 5. 6. struct node{ 7. int num,data; 8. bool operator < (const node &p) const 9. { 10. return data < p.data; 11. } 12. }; 13. node p[100001]; 14. 15. int main() 16. { 17. int n,m,i,j,a,b,c;//c:flag; 18. while(scanf("%d %d",&n,&m)!=EOF) 19. { 20. for(i=1;i<=n;i++) 21. { 22. scanf("%d",&p[i].data); 23. p[i].num = i; 173 24. } 25. sort(p+1,p+1+n); 26. 27. for(j=1;j<=m;j++) 28. { 29. scanf("%d %d %d",&a,&b,&c); 30. for(i=1;i<=n;i++) 31. { 32. if(p[i].num>=a && p[i].num<=b) 33. c--; 34. if(c == 0) 35. break; 36. } 37. printf("%d/n",p[i].data); 38. } 39. } 40. return 0; 41. } 第三节、直接排序给定下标区间的数 你可能会忽略一个重要的事实,不知读者是否意识到。题目是要求我们在数组中求给定 下标区间内某一第 k 小的数,即我们只要找到这个第 k 小的数,就够了。但上述程序显示的 一个弊端,就是它先对整个数组进行了排序,然后采用伴随数组的解法寻找到第 k 小的数。 而事实是,我们不需要对整个数组进行排序,我们只需要对我们要寻找的那个数的数组中给 定下标区间的数进行部分排序,即可。 对,事情就是这么简单。我们摒弃掉伴随数组的方法,只需要直接对数组中给定的那部 分下标区间中的数进行排序,而不是对整个数组进行排序。如此的话,算法的时间复杂度降 到了 L*logK。其中,L=|b-a+1|,L 为给定下标区间的长度,相对整个数组的程度 n,L<=n。 程序代码如下。 1. //copyright@ 苍狼 2. //直接对给定区间的数进行排序,没必要用伴随数组。 3. #include 4. #include 5. using namespace std; 6. 7. struct node{ 8. int data; 9. bool operator < (const node &p) const 10. { 174 11. return data < p.data; 12. } 13. }; 14. node p[100001]; 15. 16. int main() 17. { 18. int n=7; 19. int i,a,b,c;//c:flag; 20. 21. for(i=1;i<=n;i++) 22. { 23. scanf("%d",&p[i].data); 24. } 25. 26. scanf("%d%d%d", &a, &b, &c); //b,a 为原数组的下标索引 27. sort(p+a, p+b+1); //直接对给定区间进行排序,|b-a+1|*log(b-a+1) 28. 29. printf("The number is %d/n", p[a-1+c].data); 30. return 0; 31. } 程序测试:我们同样采取第二节的测试用例。输入的第 1 行数字 1 5 2 6 3 7 4 代表给定的 数组,第二行的数字中,2 5 代表给定的下标区间 2~5,3 表示要在给定的下标区间 2~5 中 的数,即从 a[2]~a[5]中寻找第 3 小的数,第三行的 5 表示找到的第 3 小的数。程序运行结 果如下。 175 貌似上述直接对给定区间内的数进行排序,效率上较第二节的伴随数组方案更甚一筹。 既然如此,那么伴随数组是不是多此一举呢?其实不然,@水:假如,我对 2-5 之间进行了 排序,那么数据就被摧毁了,怎么进行 2 次的操作?就是现在的 2 位置已经不是初始的 2 位置的数据了。也就是说,快排之后下标直接定位的方法明显只能用一次。 ok,更多请看下文第四节中的“百家争鸣”与“经典对白”。 第四节、伴随数组的优势所在 百家争鸣  @雨翔:伴随数组这种方式确实比较新颖 ,伴随数组的前提是在排序后的 ,但总 的复杂度还是 0(N*logN+N)=O(N*logN), 找第 K 大的数的此类面试题都是有 这几点限制:1、数很多,让你在内存中放不下,2、复杂度严格要求,即不能用排 序。当然,即便第三节中,直接对给定下标区间进行排序,复杂度同样为 L*logL,L 为给定区间的长度。事实上,我们在解决 “从给定下标区间中的数找寻第 k 小(大) 的元素” 这个问题,还是选择堆为好,在之前的基础上:入堆的时候 只需检测这个 元素的下标是否是给定下标区间内的,不是则不入这样的复杂度会低,不需要排序。 然后便是平均时间复杂度虽为 O(N),但并不常用的快速选择 SELECT 算法,参考: 第三章再续:快速选择 SELECT 算法的深入分析与实现。  @水:伴随数组的解法是为了达到预处理开销换查找开销目的。直接对给定不同的 下标区间的数进行排序,在小数据量处理时复杂度还可以接受,但当面临大数据量, 即海量数据处理时,比如 10G 的数据量,每次取 1G 的段的问题,则使用伴随数组 的方法会凸显优势,只不过预处理的开销的确是大了点。伴随数组的精髓就是稳定 的时间之内解决对相同数据的多次访问查找。说白了,就是同一个数组,要不断查 找数组中给定的不同下标区间中的第 k 小的数时优势明显。具体,还可以看看这道 题:http://poj.org/problem?id=2104。  @July:不用看我了,基本上同意上述水的观点。雨翔之所以认为伴随数组不可取, 是因为没有考虑到水提出的问题,即如果要多次或不断的从数组中不同的下标区间 中寻找第 k 小的数的情况。这时,伴随数组的优势就体现出来了。ok,读者还可以 继续看下面的经典对白。相信,你能找到你想要的答案。 经典对白 176  查找 a[0]~a[n-1]内第 K 小,然后再找 a[1]~a[n]内第 K 小,依次往复,找个几次就优 势明显了。其实是比较采取伴随数组解法 n log n +m*n 的代价(m 为给定不同区间 的个数)和直接排序 m*(L*log L )(L 为给定下标区间的长度)的代价,哪个更低。 其中,采用伴随数组查找最差情况是 nlogn + m(n-1),而直接排序代价,最差情况 为 m*(( n-1)*log(n-1))。当 m>>0 且 n>>0 时,排序时间-伴随时间 =m*n*logn-n*logn-mn =(m-1)n*logn -mn 恒正,结论:即在需要不断的从不同给 定下标区间中寻找第 k 小数的情况下,当数据规模大的时候伴随数组效果恒优于每 次都直接对给定的下标区间的部分数进行排序。  是的,好比我现在给定不同的另外一个下标区间,要你从中查找第 k 小的数,你总 不能每次都排序吧。而采取伴随数组的方案的话,由于伴随数组记下了各自给定的 下标区间对应的数。所以,第二次在不同的下标区间中查找第 k 小的数时,还是只 要扫描一遍即可找到,复杂度还是 O(N)。从而,给定不同的下标区间查找第 k 小 的数,复杂度为 m*N 加上之前排序预处理的复杂度,N*logN,总的时间复杂度为 O (N*logN+m*N)( m 为给定不同区间的个数)。而直接对给定下标区间中的数进行 排序的代价则为 l1*logl1+l2*logl2+...+li*logli。当 m>>0 且 n>>0 时,哪个复杂度谁 大谁小,一眼就看出来了伴随数组所体现的巨大优势。  恩,实际样例是这样的,我们有每天超过 100 万次点击的网页,我们常见的来源有 n 种,然后,我们要确定每天的每个时段和一周乃至整个月的点击来源地分析。数 据库的库存数据量庞大,copy 花销很大,内排序花销更大,如果要做出这样的统计 图,我擦泪,如果每次都排序,玩死了。 原例重现 ok,说了这么多,你可能还根本就不明白到底是怎么一回事。让我们从第一节举的那个 例子说起。我们要找给定下标区间 2~5 的数中第 3 小的数,诚然,此时,我们有两种选择, 1、如上第一节、第二节所述的伴随数组,2、直接对下标区间 2-5 的数进行排序。下面,只 回顾下伴随数组的方案。 伴随数组 a[i].data 1 5 2 6 3 7 4 a[i].num 1 2 3 4 5 6 7 第一次排序后: a [i].data 1 2 3 4 5 6 7 a [i].num 1 3 5 7 2 4 6 177 伴随数组方案查找: a [i].data 1 2 3 4 5 6 7 a [i].num 1 3 5 7 2 4 6 k 3 2 1 1 0 好的,那么现在,如果题目要求你在之前数组的下标区间 3~6 的数中找第 3 小的数呢(答 案很明显,为 6)? a[i].data 1 5 2 6 3 7 4 a[i].num 1 2 3 4 5 6 7 1. 直接排序么?ok,退万一步讲,假设有的读者可能还是会依然选择直接排序下标 3~6 之间的数。但你是否可曾想到,每次对不同的下标区间所对应的数进行排序,你不 但破坏了原有的数据,而且如果区间有覆盖的话,那么将使得我们无法再能依靠原 有的直接的下标定位找到原来的数据,且每进行一次排序,都要花费平均时间复杂 度为 N*logN 的时间开销。如上面的经典对白所述,这样下去的开销将非常大,将 为 l1*logl1+l2*logl2+...+li*logli。 2. 那么,如果是采取伴随数组的方法,我们要怎么做呢?如下所示,我们在 k=0 的时 候,同样找到了第 3 小的数 6,如此是不是只要在之前的一次排序,以后不论是换 各种不同的下标区间时都能扫描一遍 O(N)搞定?复杂度为 O(N*logN+m*N)(m 为给定不同的下标区间的区间数)。 3. 由上面的经典对白里面的内容,我们已经知道,当 m>>0 且 n>>0 时(m 为给定不 同的下标区间的区间数,n 为数组大小),排序时间-伴随时间=m*n*logn-n*logn-mn =(m-1)n*logn -mn 恒正。yeah,相信,你已经明白了。 伴随数组 原第一次排序后: a [i].data 1 2 3 4 5 6 7 a [i].num 1 3 5 7 2 4 6 再次扫描,直接 O(N)搞定: 178 a [i].data 1 2 3 4 5 6 7 a [i].num 1 3 5 7 2 4 6 k 3 2 1 1 1 0 (而之前有的读者意识不到伴随数组的意义,是因为一般的人只考虑找 一次,不会想到第二次或多次查找) 编程独白 给你 40 分钟的时间,你可以思考十分钟,然后用三十分钟的时间来写代码,最后浪费在 无谓的调试上;你也可以思考半个小时,彻底弄清问题的本质与程序的脉络,然后用十分钟 的时间来编写代码,体会代码如行云流水而出的感觉。 本章完。 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。网络转载,请以链接形式注 明出处。 第四章、现场编写类似 strstr/strcpy/strpbrk 的函数 作者:July。 说明:注意关键字,“十分钟”,“现场编写”。 本文诸多函数的编写都存在问题,如\0 写 成 /0,待日后统一修正。July、2011.10.11。 微博:http://weibo.com/julyweibo 。 出处:http://blog.csdn.net/v_JULY_v 。 wiki:http://tctop.wikispaces.com/。 ---------------------------------------------- 前奏 179 有网友向我反应,之前三章(http://t.cn/hgVPmH)的面试题目,是否有点太难了。诚 如他所说,绝大部分公司的面试题不会像微软等公司的面试题目出的那么变态,或复杂。 面试考察的是你对基础知识的掌握程度,及编程能力是否过硬的一种检测,所以,扎实 基础知识,提高编程能力,比去看什么所谓的面经,或去背面试题目的答案强多了。 很多中、小型公司自己的创造能力,包括人力,物力资源都有限,所以,他们的面试题 目除了 copy 一些大公司的题库之外(当然,考察你对基础知识的掌握情况,是肯定不会放 过的),还有一个途径就是让你在限定时间内(如十分钟),当场实现一些类似 strcpy/strcat/strpbrk 等库函数,这个主要看你对细节的把握,以及编程能力是否之扎实了。 同时,本章里出现的代码(除了第 4 节的 c 标准库部分源码)都是个人限定在短时间内 (正好,突出现场感)编写的,很多问题,难免有所考虑不周。所以,如果你发现本章任何 一段代码有任何问题,恳请不吝指正。 第一节、字符串查找 1.1 题目描述: 给定一个字符串 A,要求在 A 中查找一个子串 B。 如 A="ABCDF",要你在 A 中查找子串 B=“CD”。 分析:比较简单,相当于实现 strstr 库函数,主体代码如下: 1. //在字符串中查找指定字符串的第一次出现,不能找到则返回-1 2. int strstr(char *string, char *substring) 3. { 4. if (string == NULL || substring == NULL) 5. return -1; 6. 7. int lenstr = strlen(string); 8. int lensub = strlen(substring); 9. 10. if (lenstr < lensub) 11. return -1; 12. 13. int len = lenstr - lensub; 14. for (int i = 0; i <= len; i++) //复杂度为 O(m*n) 15. { 16. for (int j = 0; j < lensub; j++) 17. { 18. if (string[i+j] != substring[j]) 180 19. break; 20. } 21. if (j == lensub) 22. return i + 1; 23. } 24. return -1; 25. } 上述程序已经实现了在字符串中查找第一个子串的功能,时间复杂度为 O(n*m),也 可以用 KMP 算法,复杂度为 O(m+n)。具体的,在此不再赘述。 希望此狂想曲系列能给各位带来的是一种方法,一种创造力,一种举一反三的能力,而 不是机械的只是为大家提供答案。那样的话,一切永远都只是邯郸学步,你我都无从进步(而 这同时却是许多所谓的面经或面试宝典之类的书很乐意做的事,有点不解)。 为人打通思路,提高他人创造力,我想,这是狂想曲与其它的面试解答所不同的地方, 也是我们写狂想曲系列文章的意义与价值之所在。 1.2、题目描述 在一个字符串中找到第一个只出现一次的字符。如输入 abaccdeff,则输出 b。 代码则可以如下编写: \0\0 1. //查找第一个只出现一次的字符, 2. //copyright@ yansha 3. //July、updated,2011.04.24. 4. char FirstNotRepeatChar(char* pString) 5. { 6. if(!pString) 7. return '\0'; 8. 9. const int tableSize = 256; 10. //有点要提醒各位注意,一般常数的空间消耗,如这里的 256,我们也认为此空间复杂度为 O(1)。 11. int hashTable[tableSize] = {0}; //存入数组,并初始化为 0 12. 13. char* pHashKey = pString; 181 14. while(*(pHashKey) != '\0') 15. hashTable[*(pHashKey++)]++; 16. 17. while(*pString != '\0') 18. { 19. if(hashTable[*pString] == 1) 20. return *pString; 21. 22. pString++; 23. } 24. return '\0'; //没有找到满足条件的字符,退出 25. } 代码二,bitmap: 1. # include 2. # include 3. 4. const int N = 26; 5. int bit_map[N]; 6. 7. void findNoRepeat(char *src) 8. { 9. int pos; 10. char *str = src; 11. int i ,len = strlen(src); 12. 13. //统计 14. for(i = 0 ; i < len ;i ++) 15. bit_map[str[i]-'a'] ++; 16. 17. //从字符串开始遍历 其 bit_map==1 那么就是结果 18. for(i = 0 ; i < len ; i ++) 19. { 20. if(bit_map[str[i]-'a'] == 1) 21. { 22. printf("%c",str[i]); 23. return ; 24. } 25. } 26. } 27. 28. int main() 29. { 182 30. char *src = "abaccdeff"; 31. findNoRepeat(src); 32. printf("\n"); 33. return 0; 34. } 第二节、字符串拷贝 题目描述: 要求实现库函数 strcpy, 原型声明:extern char *strcpy(char *dest,char *src); 功能:把 src 所指由 NULL 结束的字符串复制到 dest 所指的数组中。 说明:src 和 dest 所指内存区域不可以重叠且 dest 必须有足够的空间来容纳 src 的字符串。 返回指向 dest 的指针。 分析:如果编写一个标准 strcpy 函数的总分值为 10,下面给出几个不同得分的答案: 1. //得 2 分 2. void strcpy( char *strDest, char *strSrc ) 3. { 4. while( (*strDest++ = * strSrc++) != '\0' ); 5. } 6. 7. //得 4 分 8. void strcpy( char *strDest, const char *strSrc ) 9. { 10. //将源字符串加 const,表明其为输入参数,加 2 分 11. while( (*strDest++ = * strSrc++) != '\0'); 12. } 13. 14. //得 7 分 15. void strcpy(char *strDest, const char *strSrc) 16. { 17. //对源地址和目的地址加非 0 断言,加 3 分 18. assert( (strDest != NULL) && (strSrc != NULL) ); 19. while( (*strDest++ = * strSrc++) != '\0' ); 20. } 21. 22. //得 9 分 23. //为了实现链式操作,将目的地址返回,加 2 分! 24. char * strcpy( char *strDest, const char *strSrc ) 183 25. { 26. assert( (strDest != NULL) && (strSrc != NULL) ); 27. char *address = strDest; 28. while( (*strDest++ = * strSrc++) != '\0' ); 29. return address; 30. } 31. 32. //得 10 分,基本上所有的情况,都考虑到了 33. //如果有考虑到源目所指区域有重叠的情况,加 1 分! 34. char * strcpy( char *strDest, const char *strSrc ) 35. { 36. if(strDest == strSrc) { return strDest; } 37. assert( (strDest != NULL) && (strSrc != NULL) ); 38. char *address = strDest; 39. while( (*strDest++ = * strSrc++) != '\0'); 40. return address; 41. } 第三节、小部分库函数的实现 考察此类编写同库函数一样功能的函数经常见于大大小小的 IT 公司的面试题目中,以下 是常见的字符串库函数的实现,希望,对你有所帮助,有任何问题,欢迎不吝指正: 1. //@yansha:字串末尾要加结束符'/0',不然输出错位结果 2. char *strncpy(char *strDes, const char *strSrc, unsigned int count) 3. { 4. assert(strDes != NULL && strSrc != NULL); 5. char *address = strDes; 6. while (count-- && *strSrc != '\0') 7. *strDes++ = *strSrc++; 8. *strDes = '\0'; 9. return address; 10. } 11. 12. //查找字符串 s 中首次出现字符 c 的位置 13. char *strchr(const char *str, int c) 184 14. { 15. assert(str != NULL); 16. for (; *str != (char)c; ++ str) 17. if (*str == '\0') 18. return NULL; 19. return str; 20. } 21. 22. int strcmp(const char *s, const char *t) 23. { 24. assert(s != NULL && t != NULL); 25. while (*s && *t && *s == *t) 26. { 27. ++ s; 28. ++ t; 29. } 30. return (*s - *t); 31. } 32. 33. char *strcat(char *strDes, const char *strSrc) 34. { 35. assert((strDes != NULL) && (strSrc != NULL)); 36. char *address = strDes; 37. while (*strDes != '\0') 38. ++ strDes; 39. while ((*strDes ++ = *strSrc ++) != '\0') 40. NULL; 41. return address; 42. } 43. 44. int strlen(const char *str) 45. { 46. assert(str != NULL); 47. int len = 0; 48. while (*str ++ != '\0') 49. ++ len; 50. return len; 51. } 52. 53. //此函数,梦修改如下 54. char *strdup_(char *strSrc) 55. //将字符串拷贝到新的位置 56. { 57. if(strSrc!=NULL) 185 58. { 59. char *start=strSrc; 60. int len=0; 61. while(*strSrc++!='\0') 62. len++; 63. 64. char *address=(char *)malloc(len+1); 65. assert(address != NULL); 66. 67. while((*address++=*start++)!='\0' 68. return address-(len+1); 69. } 70. return NULL; 71. } 72. 73. //多谢 laoyi19861011 指正 74. char *strstr(const char *strSrc, const char *str) 75. { 76. assert(strSrc != NULL && str != NULL); 77. const char *s = strSrc; 78. const char *t = str; 79. for (; *strSrc != '\0'; ++ strSrc) 80. { 81. for (s = strSrc, t = str; *t != '\0' && *s == *t; ++s, ++t) 82. NULL; 83. if (*t == '\0') 84. return (char *) strSrc; 85. } 86. return NULL; 87. } 88. 89. char *strncat(char *strDes, const char *strSrc, unsigned int count) 90. { 91. assert((strDes != NULL) && (strSrc != NULL)); 92. char *address = strDes; 93. while (*strDes != '\0') 94. ++ strDes; 95. while (count -- && *strSrc != '\0' ) 96. *strDes ++ = *strSrc ++; 97. *strDes = '\0'; 98. return address; 99. } 100. 101. int strncmp(const char *s, const char *t, unsigned int count) 186 102. { 103. assert((s != NULL) && (t != NULL)); 104. while (*s && *t && *s == *t && count --) 105. { 106. ++ s; 107. ++ t; 108. } 109. return (*s - *t); 110. } 111. 112. char *strpbrk(const char *strSrc, const char *str) 113. { 114. assert((strSrc != NULL) && (str != NULL)); 115. const char *s; 116. while (*strSrc != '\0') 117. { 118. s = str; 119. while (*s != '\0') 120. { 121. if (*strSrc == *s) 122. return (char *) strSrc; 123. ++ s; 124. } 125. ++ strSrc; 126. } 127. return NULL; 128. } 129. 130. int strcspn(const char *strSrc, const char *str) 131. { 132. assert((strSrc != NULL) && (str != NULL)); 133. const char *s; 134. const char *t = strSrc; 135. while (*t != '\0') 136. { 137. s = str; 138. while (*s != '\0') 139. { 140. if (*t == *s) 141. return t - strSrc; 142. ++ s; 143. } 144. ++ t; 145. } 187 146. return 0; 147. } 148. 149. int strspn(const char *strSrc, const char *str) 150. { 151. assert((strSrc != NULL) && (str != NULL)); 152. const char *s; 153. const char *t = strSrc; 154. while (*t != '\0') 155. { 156. s = str; 157. while (*s != '\0') 158. { 159. if (*t == *s) 160. break; 161. ++ s; 162. } 163. if (*s == '\0') 164. return t - strSrc; 165. ++ t; 166. } 167. return 0; 168. } 169. 170. char *strrchr(const char *str, int c) 171. { 172. assert(str != NULL); 173. const char *s = str; 174. while (*s != '\0') 175. ++ s; 176. for (-- s; *s != (char) c; -- s) 177. if (s == str) 178. return NULL; 179. return (char *) s; 180. } 181. 182. char* strrev(char *str) 183. { 184. assert(str != NULL); 185. char *s = str, *t = str, c; 186. while (*t != '\0') 187. ++ t; 188. for (-- t; s < t; ++ s, -- t) 189. { 188 190. c = *s; 191. *s = *t; 192. *t = c; 193. } 194. return str; 195. } 196. 197. char *strnset(char *str, int c, unsigned int count) 198. { 199. assert(str != NULL); 200. char *s = str; 201. for (; *s != '\0' && s - str < count; ++ s) 202. *s = (char) c; 203. return str; 204. } 205. 206. char *strset(char *str, int c) 207. { 208. assert(str != NULL); 209. char *s = str; 210. for (; *s != '\0'; ++ s) 211. *s = (char) c; 212. return str; 213. } 214. 215. //@heyaming 216. //对原 strtok 的修改,根据 MSDN,strToken 可以为 NULL.实际上第一次 call strtok 给定 一字串, 217. //再 call strtok 时可以输入 NULL 代表要接着处理给定字串。 218. //所以需要用一 static 保存没有处理完的字串。同时也需要处理多个分隔符在一起的情 况。 219. char *strtok(char *strToken, const char *str) 220. { 221. assert(str != NULL); 222. static char *last; 223. 224. if (strToken == NULL && (strToken = last) == NULL) 225. return (NULL); 226. 227. char *s = strToken; 228. const char *t = str; 229. while (*s != '\0') 230. { 231. t = str; 189 232. while (*t != '\0') 233. { 234. if (*s == *t) 235. { 236. last = s + 1; 237. if (s - strToken == 0) { 238. strToken = last; 239. break; 240. } 241. *(strToken + (s - strToken)) = '/0'; 242. return strToken; 243. } 244. ++ t; 245. } 246. ++ s; 247. } 248. return NULL; 249. } 250. 251. char *strupr(char *str) 252. { 253. assert(str != NULL); 254. char *s = str; 255. while (*s != '\0') 256. { 257. if (*s >= 'a' && *s <= 'z') 258. *s -= 0x20; 259. s ++; 260. } 261. return str; 262. } 263. 264. char *strlwr(char *str) 265. { 266. assert(str != NULL); 267. char *s = str; 268. while (*s != '\0') 269. { 270. if (*s >= 'A' && *s <= 'Z') 271. *s += 0x20; 272. s ++; 273. } 274. return str; 275. } 190 276. 277. void *memcpy(void *dest, const void *src, unsigned int count) 278. { 279. assert((dest != NULL) && (src != NULL)); 280. void *address = dest; 281. while (count --) 282. { 283. *(char *) dest = *(char *) src; 284. dest = (char *) dest + 1; 285. src = (char *) src + 1; 286. } 287. return address; 288. } 289. 290. void *memccpy(void *dest, const void *src, int c, unsigned int count) 291. { 292. assert((dest != NULL) && (src != NULL)); 293. while (count --) 294. { 295. *(char *) dest = *(char *) src; 296. if (* (char *) src == (char) c) 297. return ((char *)dest + 1); 298. dest = (char *) dest + 1; 299. src = (char *) src + 1; 300. } 301. return NULL; 302. } 303. 304. void *memchr(const void *buf, int c, unsigned int count) 305. { 306. assert(buf != NULL); 307. while (count --) 308. { 309. if (*(char *) buf == c) 310. return (void *) buf; 311. buf = (char *) buf + 1; 312. } 313. return NULL; 314. } 315. 316. int memcmp(const void *s, const void *t, unsigned int count) 317. { 318. assert((s != NULL) && (t != NULL)); 191 319. while (*(char *) s && *(char *) t && *(char *) s == *(char *) t && coun t --) 320. { 321. s = (char *) s + 1; 322. t = (char *) t + 1; 323. } 324. return (*(char *) s - *(char *) t); 325. } 326. 327. //@big: 328. //要处理 src 和 dest 有重叠的情况,不是从尾巴开始移动就没问题了。 329. //一种情况是 dest 小于 src 有重叠,这个时候要从头开始移动, 330. //另一种是 dest 大于 src 有重叠,这个时候要从尾开始移动。 331. void *memmove(void *dest, const void *src, unsigned int count) 332. { 333. assert(dest != NULL && src != NULL); 334. char* pdest = (char*) dest; 335. char* psrc = (char*) src; 336. 337. //pdest 在 psrc 后面,且两者距离小于 count 时,从尾部开始移动. 其他情况从头部开 始移动 338. if (pdest > psrc && pdest - psrc < count) 339. { 340. while (count--) 341. { 342. *(pdest + count) = *(psrc + count); 343. } 344. } else 345. { 346. while (count--) 347. { 348. *pdest++ = *psrc++; 349. } 350. } 351. return dest; 352. } 353. 354. void *memset(void *str, int c, unsigned int count) 355. { 356. assert(str != NULL); 357. void *s = str; 358. while (count --) 359. { 360. *(char *) s = (char) c; 192 361. s = (char *) s + 1; 362. } 363. return str; 364. } 测试:以上所有的函数,都待进一步测试,有任何问题,欢迎任何人随时不 吝指出。 第四节、c 标准库部分源代码 为了给各位一个可靠的参考,以下,我摘取一些 c 标准框里的源代码,以飨各位: 1. char * __cdecl strcat (char * dst,const char * src) 2. { 3. char * cp = dst; 4. 5. while( *cp ) 6. cp++; /* find end of dst */ 7. 8. while( *cp++ = *src++ ) ; /* Copy src to end of dst */ 9. 10. return( dst ); /* return dst */ 11. 12. } 13. 14. int __cdecl strcmp (const char * src,const char * dst) 15. { 16. int ret = 0 ; 17. 18. while( ! (ret = *(unsigned char *)src - *(unsigned char *)dst) && *dst) 19. ++src, ++dst; 20. 21. if ( ret < 0 ) 22. ret = -1 ; 23. else if ( ret > 0 ) 24. ret = 1 ; 25. 26. return( ret ); 27. } 28. 29. size_t __cdecl strlen (const char * str) 30. { 31. const char *eos = str; 193 32. 33. while( *eos++ ) ; 34. 35. return( (int)(eos - str - 1) ); 36. } 37. 38. char * __cdecl strncat (char * front,const char * back,size_t count) 39. { 40. char *start = front; 41. 42. while (*front++) 43. ; 44. front--; 45. 46. while (count--) 47. if (!(*front++ = *back++)) 48. return(start); 49. 50. *front = '/0'; 51. return(start); 52. } 53. 54. int __cdecl strncmp (const char * first,const char * last,size_t count) 55. { 56. if (!count) 57. return(0); 58. 59. while (--count && *first && *first == *last) 60. { 61. first++; 62. last++; 63. } 64. 65. return( *(unsigned char *)first - *(unsigned char *)last ); 66. } 67. 68. /* Copy SRC to DEST. */ 69. char * 70. strcpy (dest, src) 71. char *dest; 72. const char *src; 73. { 74. reg_char c; 75. char *__unbounded s = (char *__unbounded) CHECK_BOUNDS_LOW (src); 194 76. const ptrdiff_t off = CHECK_BOUNDS_LOW (dest) - s - 1; 77. size_t n; 78. 79. do 80. { 81. c = *s++; 82. s[off] = c; 83. } 84. while (c != '\0'); 85. 86. n = s - src; 87. (void) CHECK_BOUNDS_HIGH (src + n); 88. (void) CHECK_BOUNDS_HIGH (dest + n); 89. 90. return dest; 91. } 92. 93. char * __cdecl strncpy (char * dest,const char * source,size_t count) 94. { 95. char *start = dest; 96. 97. while (count && (*dest++ = *source++)) /* copy string */ 98. count--; 99. 100. if (count) /* pad out with zeroes */ 101. while (--count) 102. *dest++ = '\0'; 103. 104. return(start); 105. } 有关狂想曲的修订 程序员面试题狂想曲-tctop(the crazy thinking of programers)的修订 wiki (http://tctop.wikispaces.com/)已于今天建立,我们急切的想得到读者的反馈,意见,建议, 以及更好的思路,算法,和代码优化的建议。所以,  如果你发现了狂想曲系列中的任何一题,任何一章(http://t.cn/hgVPmH)中的 错误,问题,与漏洞,欢迎告知给我们,我们将感激不尽,同时,免费赠送本 blog 内的全部博文集锦的 CHM 文件 1 期;  如果你能对狂想曲系列的创作提供任何建设性意见,或指导,欢迎反馈给我们, 并真诚邀请您加入到狂想曲的 wiki 修订工作中; 195  如果你是编程高手,对狂想曲的任何一章有自己更好的思路,或算法,欢迎加入 狂想曲的创作组,以为千千万万的读者创造更多的价值,更好的服务。 Ps:狂想曲 tctop 的 wiki 修订地址为:http://tctop.wikispaces.com/。欢迎围 观,更欢迎您加入到狂想曲的创作或 wiki 修订中。 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。实要转载,请以链接形式注 明出处。 第五章、寻找满足条件的两个或多个数 作者:July,yansha,zhouzhenren。 致谢:微软 100 题实现组,编程艺术室。 微博:http://weibo.com/julyweibo 。 出处:http://blog.csdn.net/v_JULY_v 。 wiki:http://tctop.wikispaces.com/。 ------------------------------ 前奏 希望此编程艺术系列能给各位带来的是一种方法,一种创造力,一种举一反三的能力。 本章依然同第四章一样,选取比较简单的面试题,恭祝各位旅途愉快。同样,有任何问题, 欢迎不吝指正。谢谢。 第一节、寻找满足条件的两个数 第 14 题(数组): 题目:输入一个数组和一个数字,在数组中查找两个数,使得它们的和正好是输入的那个数 字。 196 要求时间复杂度是 O(n)。如果有多对数字的和等于输入的数字,输出任意一对即可。 例如输入数组 1、2、4、7、11、15 和数字 15。由于 4+11=15,因此输出 4 和 11。 分析: 咱们试着一步一步解决这个问题(注意阐述中数列有序无序的区别): 1. 直接穷举,从数组中任意选取两个数,判定它们的和是否为输入的那个数字。此举 复杂度为 O(N^2)。很显然,我们要寻找效率更高的解法。 2. 题目相当于,对每个 a[i],然后查找判断 sum-a[i]是否也在原始序列中,每一次要查 找的时间都要花费为 O(N),这样下来,最终找到两个数还是需要 O(N^2)的复 杂度。那如何提高查找判断的速度列?对了,二分查找,将原来 O(N)的查找时间 提高到 O(logN),这样对于 N 个 a[i],都要花 logN 的时间去查找相对应的 sum-a[i] 是否在原始序列中,总的时间复杂度已降为 O(N*logN),且空间复杂度为 O(1)。 (如果有序,直接二分 O(N*logN),如果无序,先排序后二分,复杂度同样为 O (N*logN+N*logN)=O(N*logN),空间总为 O(1))。 3. 有没有更好的办法列?咱们可以依据上述思路 2 的思想,a[i]在序列中,如果 a[i]+a[k]=sum 的话,那么 sum-a[i](a[k])也必然在序列中,,举个例子,如下: 原始序列:1、 2、 4、 7、11、15 用输入数字 15 减一下各个数,得到对应的 序列为: 对应序列:14、13、11、8、4、 0 第一个数组以一指针 i 从数组最左端开始向右扫描,第二个数组以一指针 j 从数组 最右端开始向左扫描,如果下面出现了和上面一样的数,即 a[*i]=a[*j],就找出这俩 个数来了。如上,i,j 最终在第一个,和第二个序列中找到了相同的数 4 和 11,, 所以符合条件的两个数,即为 4+11=15。怎么样,两端同时查找,时间复杂度瞬间 缩短到了 O(N),但却同时需要 O(N)的空间存储第二个数组(@飞羽:要达 到 O(N)的复杂度,第一个数组以一指针 i 从数组最左端开始向右扫描, 第二个数组以一指针 j 从数组最右端开始向左扫描,首先初始 i 指向元素 1,j 指向元素 0,谁指的元素小,谁先移动,由于 1(i)>0(j),所以 i 不动,j 向左移动。然后 j 移动到元素 4 发现大于元素 1,故而停止移动 j, 开始移动 i,直到 i 指向 4,这时,i 指向的元素与 j 指向的元素相等,故而 判断 4 是满足条件的第一个数;然后同时移动 i,j 再进行判断,直到它们 到达边界)。 4. 当然,你还可以构造 hash 表,正如编程之美上的所述,给定一个数字,根据 hash 映射查找另一个数字是否也在数组中,只需用 O(1)的时间,这样的话,总体的算 法通上述思路 3 一样,也能降到 O(N),但有个缺陷,就是构造 hash 额外增加 197 了 O(N)的空间,此点同上述思路 3。不过,空间换时间,仍不失为在时间要求 较严格的情况下的一种好办法。 5. 如果数组是无序的,先排序(n*logn),然后用两个指针 i,j,各自指向数组的首尾 两端,令 i=0,j=n-1,然后 i++,j--,逐次判断 a[i]+a[j]?=sum,如果某一刻 a[i]+a[j]>sum, 则要想办法让 sum 的值减小,所以此刻 i 不动,j--,如果某一刻 a[i]+a[j]x) 13. { 14. --end; 15. } 16. else if(*begin+*end begin) 42. { 43. long current_sum = data[begin] + data[end]; 44. 45. if(current_sum == sum) 46. { 47. first_num = data[begin]; 48. second_num = data[end]; 199 49. return true; 50. } 51. else if(current_sum > sum) 52. end--; 53. else 54. begin++; 55. } 56. return false; 57. } 扩展: 1、如果在返回找到的两个数的同时,还要求你返回这两个数的位置列? 2、如果把题目中的要你寻找的两个数改为“多个数”,或任意个数列?(请看下面第二节) 3、二分查找时: left <= right,right = middle - 1;left < right,right = middle; //算法所操作的区间,是左闭右开区间,还是左闭右闭区间,这个区间,需要在循环初始化, //循环体是否终止的判断中,以及每次修改 left,right 区间值这三个地方保持一致,否则就可能 出错. //二分查找实现一 int search(int array[], int n, int v) { int left, right, middle; left = 0, right = n - 1; while (left <= right) { middle = left + (right-left)/2; if (array[middle] > v) { right = middle - 1; } else if (array[middle] < v) { 200 left = middle + 1; } else { return middle; } } return -1; } //二分查找实现二 int search(int array[], int n, int v) { int left, right, middle; left = 0, right = n; while (left < right) { middle = left + (right-left)/2; if (array[middle] > v) { right = middle; } else if (array[middle] < v) { left = middle + 1; } else { return middle; } } 201 return -1; } 第二节、寻找满足条件的多个数 第 21 题(数组) 2010 年中兴面试题 编程求解: 输入两个整数 n 和 m,从数列 1,2,3.......n 中 随意取几个数, 使其和等于 m ,要求将其中所有的可能组合列出来。 解法一 我想,稍后给出的程序已经足够清楚了,就是要注意到放 n,和不放 n 个区别,即可,代码 如下: 1. // 21 题递归方法 2. //copyright@ July && yansha 3. //July、yansha,updated。 4. #include 5. #include 6. using namespace std; 7. 8. listlist1; 9. void find_factor(int sum, int n) 10. { 11. // 递归出口 12. if(n <= 0 || sum <= 0) 13. return; 14. 15. // 输出找到的结果 16. if(sum == n) 17. { 18. // 反转 list 19. list1.reverse(); 20. for(list::iterator iter = list1.begin(); iter != list1.end(); i ter++) 21. cout << *iter << " + "; 22. cout << n << endl; 23. list1.reverse(); 24. } 25. 202 26. list1.push_front(n); //典型的 01 背包问题 27. find_factor(sum-n, n-1); //放 n,n-1 个数填满 sum-n 28. list1.pop_front(); 29. find_factor(sum, n-1); //不放 n,n-1 个数填满 sum 30. } 31. 32. int main() 33. { 34. int sum, n; 35. cout << "请输入你要等于多少的数值 sum:" << endl; 36. cin >> sum; 37. cout << "请输入你要从 1.....n 数列中取值的 n:" << endl; 38. cin >> n; 39. cout << "所有可能的序列,如下:" << endl; 40. find_factor(sum,n); 41. return 0; 42. } 解法二 @zhouzhenren: 这个问题属于子集和问题(也是背包问题)。本程序采用 回溯法+剪枝 X 数组是解向量,t=∑(1,..,k-1)Wi*Xi, r=∑(k,..,n)Wi 若 t+Wk+W(k+1)<=M,则 Xk=true,递归左儿子(X1,X2,..,X(k-1),1);否则剪枝; 若 t+r-Wk>=M && t+W(k+1)<=M,则置 Xk=0,递归右儿子(X1,X2,..,X(k-1),0);否则剪枝; 本题中 W 数组就是(1,2,..,n),所以直接用 k 代替 WK 值。 代码编写如下: 1. //copyright@ 2011 zhouzhenren 2. 3. //输入两个整数 n 和 m,从数列 1,2,3.......n 中 随意取几个数, 4. //使其和等于 m ,要求将其中所有的可能组合列出来。 5. 6. #include 7. #include 8. #include 9. 10. /** 11. * 输入 t, r, 尝试 Wk 12. */ 13. void sumofsub(int t, int k ,int r, int& M, bool& flag, bool* X) 14. { 15. X[k] = true; // 选第 k 个数 203 16. if (t + k == M) // 若找到一个和为 M,则设置解向量的标志位,输出解 17. { 18. flag = true; 19. for (int i = 1; i <= k; ++i) 20. { 21. if (X[i] == 1) 22. { 23. printf("%d ", i); 24. } 25. } 26. printf("/n"); 27. } 28. else 29. { // 若第 k+1 个数满足条件,则递归左子树 30. if (t + k + (k+1) <= M) 31. { 32. sumofsub(t + k, k + 1, r - k, M, flag, X); 33. } 34. // 若不选第 k 个数,选第 k+1 个数满足条件,则递归右子树 35. if ((t + r - k >= M) && (t + (k+1) <= M)) 36. { 37. X[k] = false; 38. sumofsub(t, k + 1, r - k, M, flag, X); 39. } 40. } 41. } 42. 43. void search(int& N, int& M) 44. { 45. // 初始化解空间 46. bool* X = (bool*)malloc(sizeof(bool) * (N+1)); 47. memset(X, false, sizeof(bool) * (N+1)); 48. int sum = (N + 1) * N * 0.5f; 49. if (1 > M || sum < M) // 预先排除无解情况 50. { 51. printf("not found/n"); 52. return; 53. } 54. bool f = false; 55. sumofsub(0, 1, sum, M, f, X); 56. if (!f) 57. { 58. printf("not found/n"); 59. } 204 60. free(X); 61. } 62. 63. int main() 64. { 65. int N, M; 66. printf("请输入整数 N 和 M/n"); 67. scanf("%d%d", &N, &M); 68. search(N, M); 69. return 0; 70. } 扩展: 1、从一列数中筛除尽可能少的数使得从左往右看,这些数是从小到大再从大到小的(网易)。 2、有两个序列 a,b,大小都为 n,序列元素的值任意整数,无序; 要求:通过交换 a,b 中的元素,使[序列 a 元素的和]与[序列 b 元素的和]之间的差最小。 例如: var a=[100,99,98,1,2, 3]; var b=[1, 2, 3, 4,5,40];(微软 100 题第 32 题)。 @well:[fairywell]: 给出扩展问题 1 的一个解法: 1、从一列数中筛除尽可能少的数使得从左往右看,这些数是从小到大再从大到 小的(网易)。 双端 LIS 问题,用 DP 的思想可解,目标规划函数 max{ b[i] + c[i] - 1 }, 其中 b[i] 为从左到右, 0 ~ i 个数之间满足递增的数字个数; c[i] 为从右到左, n-1 ~ i 个数之间满足递增的数字个数。最后结果为 n - max + 1。其中 DP 的时候, 可以维护一个 inc[] 数组表示递增数字序列,inc[i] 为从小到大第 i 大的数字, 然后在计算 b[i] c[i] 的时候使用二分查找在 inc[] 中找出区间 inc[0] ~ inc[i-1] 中小于 a[i] 的元素个数(low)。 源代码如下: 1. /** 2. * The problem: 3. * 从一列数中筛除尽可能少的数使得从左往右看,这些数是从小到大再从大到小的(网 易)。 4. * use binary search, perhaps you should compile it with -std=c99 5. * fairywell 2011 205 6. */ 7. #include 8. 9. #define MAX_NUM (1U<<31) 10. 11. int 12. main() 13. { 14. int i, n, low, high, mid, max; 15. 16. printf("Input how many numbers there are: "); 17. scanf("%d/n", &n); 18. /* a[] holds the numbers, b[i] holds the number of increasing num bers 19. * from a[0] to a[i], c[i] holds the number of increasing numbers 20. * from a[n-1] to a[i] 21. * inc[] holds the increasing numbers 22. * VLA needs c99 features, compile with -stc=c99 23. */ 24. double a[n], b[n], c[n], inc[n]; 25. 26. printf("Please input the numbers:/n"); 27. for (i = 0; i < n; ++i) scanf("%lf", &a[i]); 28. 29. // update array b from left to right 30. for (i = 0; i < n; ++i) inc[i] = (unsigned) MAX_NUM; 31. //b[0] = 0; 32. for (i = 0; i < n; ++i) { 33. low = 0; high = i; 34. while (low < high) { 35. mid = low + (high-low)*0.5; 36. if (inc[mid] < a[i]) low = mid + 1; 37. else high = mid; 38. } 39. b[i] = low + 1; 40. inc[low] = a[i]; 41. } 42. 43. // update array c from right to left 44. for (i = 0; i < n; ++i) inc[i] = (unsigned) MAX_NUM; 45. //c[0] = 0; 46. for (i = n-1; i >= 0; --i) { 47. low = 0; high = i; 48. while (low < high) { 206 49. mid = low + (high-low)*0.5; 50. if (inc[mid] < a[i]) low = mid + 1; 51. else high = mid; 52. } 53. c[i] = low + 1; 54. inc[low] = a[i]; 55. } 56. 57. max = 0; 58. for (i = 0; i < n; ++i ) 59. if (b[i]+c[i] > max) max = b[i] + c[i]; 60. printf("%d number(s) should be erased at least./n", n+1-max); 61. return 0; 62. } @yansha:fairywell 的程序很赞,时间复杂度 O(nlogn),这也是我能想到的时 间复杂度最优值了。不知能不能达到 O(n)。 扩展题第 2 题 当前数组 a 和数组 b 的和之差为 A = sum(a) - sum(b) a 的第 i 个元素和 b 的第 j 个元素交换后,a 和 b 的和之差为 A' = sum(a) - a[i] + b[j] - (sum(b) - b[j] + a[i]) = sum(a) - sum(b) - 2 (a[i] - b[j]) = A - 2 (a[i] - b[j]) 设 x = a[i] - b[j],得 |A| - |A'| = |A| - |A-2x| 假设 A > 0, 当 x 在 (0,A)之间时,做这样的交换才能使得交换后的 a 和 b 的和之差变小, x 越接近 A/2 效果越好, 如果找不到在(0,A)之间的 x,则当前的 a 和 b 就是答案。 所以算法大概如下: 在 a 和 b 中寻找使得 x 在(0,A)之间并且最接近 A/2 的 i 和 j,交换相应的 i 和 j 元素,重新计算 A 后,重复前面的步骤直至找不到(0,A)之间的 x 为止。 207 接上,@yuan: a[i]-b[j]要接近 A/2,则可以这样想, 我们可以对于 a 数组的任意一个 a[k],在数组 b 中找出与 a[k]-C 最接近的数(C 就是常数,也就是 0.5*A) 这个数要么就是 a[k]-C,要么就是比他稍大,要么比他稍小,所以可以要二分查 找。 查找最后一个小于等于 a[k]-C 的数和第一个大于等于 a[k]-C 的数, 然后看哪一个与 a[k]-C 更加接近,所以 T(n) = nlogn。 本章完。 程序员面试题狂想曲-tctop(the crazy thinking of programers)的修订 wiki (http://tctop.wikispaces.com/)已建立,我们急切的想得到读者的反馈,意 见,建议,以及更好的思路,算法,和代码优化的建议。所以, •如果你发现了狂想曲系列中的任何一题,任何一章(http://t.cn/hgVPmH)中 的错误,问题,与漏洞,欢迎告知给我们,我们将感激不尽,同时,免费赠送本 blog 内的全部博文集锦的 CHM 文件 1 期; •如果你能对狂想曲系列的创作提供任何建设性意见,或指导,欢迎反馈给我们, 并真诚邀请您加入到狂想曲的 wiki 修订工作中; •如果你是编程高手,对狂想曲的任何一章有自己更好的思路,或算法,欢迎加 入狂想曲的创作组,以为千千万万的读者创造更多的价值,更好的服务。 Ps:狂想曲 tctop 的 wiki 修订地址为:http://tctop.wikispaces.com/。欢迎围 观,更欢迎您加入到狂想曲的创作或 wiki 修订中。 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。实要转载,请以链接形式注 明出处。 208 第六章、亲和数问题--求解 500 万以内的亲和数 作者:上善若水、July、yansha。 出处:http://blog.csdn.net/v_JULY_v 。 前奏 本章陆续开始,除了继续保持原有的字符串、数组等面试题之外,会有意识的间断性节 选一些有关数字趣味小而巧的面试题目,重在突出思路的“巧”,和“妙”。本章亲和数问题之 关键字,“500 万”,“线性复杂度”。 第一节、亲和数问题 题目描述: 求 500 万以内的所有亲和数 如果两个数 a 和 b,a 的所有真因数之和等于 b,b 的所有真因数之和等于 a,则称 a,b 是一对 亲和数。 例如 220 和 284,1184 和 1210,2620 和 2924。 分析: 首先得明确到底是什么是亲和数? 亲和数问题最早是由毕达哥拉斯学派发现和研究的。他们在研究数字的规律的时候发现有以 下性质特点的两个数: 220 的真因子是:1、2、4、5、10、11、20、22、44、55、110; 284 的真因子是:1、2、4、71、142。 而这两个数恰恰等于对方的真因子各自加起来的和(sum[i]表示数 i 的各个真因子的和), 即 220=1+2+4+71+142=sum[284], 284=1+2+4+5+10+11+20+22+44+55+110=sum[220]。 得 284 的真因子之和 sum[284]=220,且 220 的真因子之和 sum[220]=284,即有 sum[220]=sum[sum[284]]=284。 209 如此,是否已看出丝毫端倪? 如上所示,考虑到 1 是每个整数的因子,把出去整数本身之外的所有因子叫做这个数的“真 因子”。如果两个整数,其中每一个真因子的和都恰好等于另一个数,那么这两个数,就构 成一对“亲和数”(有关亲和数的更多讨论,可参考这:http://t.cn/hesH09)。 求解: 了解了什么是亲和数,接下来咱们一步一步来解决上面提出的问题(以下内容大部引自 水的原话,同时水哥有一句原话,“在你真正弄弄懂这个范例之前,你不配说你懂数据结构 和算法”)。 1. 看到这个问题后,第一想法是什么?模拟搜索+剪枝?回溯?时间复杂度有多大?其 中 bn 为 an 的伪亲和数,即 bn 是 an 的真因数之和大约是多少?至少是 10^13 (@iicup:N^1.5 对于 5*10^6 , 次数大致 10^10 而不是 10^13.)的数量级的。 那么对于每秒千万次运算的计算机来说,大概在 1000 多天也就是 3 年内就可以搞 定了(iicup 的计算: 10^13 / 10^7 =1000000(秒) 大约 278 小时. )。如果是基于这 个基数在优化,你无法在一天内得到结果的。 2. 一个不错的算法应该在半小时之内搞定这个问题,当然这样的算法有很多。节约时 间的做法是可以生成伴随数组,也就是空间换时间,但是那样,空间代价太大,因 为数据规模庞大。 3. 在稍后的算法中,依然使用的伴随数组,只不过,因为题目的特殊性,只是它方便 和巧妙地利用了下标作为伴随数组,来节约时间。同时,将回溯的思想换成递推的 思想(预处理数组的时间复杂度为 logN(调和级数)*N,扫描数组的时间复杂度为 线性 O(N)。所以,总的时间复杂度为 O(N*logN+N)(其中 logN 为调和级数) )。 第二节、伴随数组线性遍历 依据上文中的第 3 点思路,编写如下代码: 1. //求解亲和数问题 2. 3. //第一个 for 和第二个 for 循环是 logn(调和级数)*N 次遍历,第三个 for 循环扫描 O(N)。 4. //所以总的时间复杂度为 O(n*logn)+O(n)=O(N*logN)(其中 logN 为调和级数)。 5. 6. //关于第一个 for 和第二个 for 寻找中,调和级数的说明: 7. //比如给 2 的倍数加 2,那么应该是 n/2 次,3 的倍数加 3 应该是 n/3 次,... 210 8. //那么其实就是 n*(1+1/2+1/3+1/4+...1/(n/2))=n*(调和级数)=n*logn。 9. 10. //copyright@ 上善若水 11. //July、updated,2011.05.24。 12. #include 13. 14. int sum[5000010]; //为防越界 15. 16. int main() 17. { 18. int i, j; 19. for (i = 0; i <= 5000000; i++) 20. sum[i] = 1; //1 是所有数的真因数所以全部置 1 21. 22. for (i = 2; i + i <= 5000000; i++) //预处理,预处理是 logN(调和级数)*N。 23. //@litaoye:调和级数 1/2 + 1/3 + 1/4......的和近似为 ln(n), 24. //因此 O(n *(1/2 + 1/3 + 1/4......)) = O(n * ln(n)) = O(N*log(N))。 25. { 26. //5000000 以下最大的真因数是不超过它的一半的 27. j = i + i; //因为真因数,所以不能算本身,所以从它的 2 倍开始 28. while (j <= 5000000) 29. { 30. //将所有 i 的倍数的位置上加 i 31. sum[j] += i; 32. j += i; 33. } 34. } 35. 36. for (i = 220; i <= 5000000; i++) //扫描,O(N)。 37. { 38. // 一次遍历,因为知道最小是 220 和 284 因此从 220 开始 39. if (sum[i] > i && sum[i] <= 5000000 && sum[sum[i]] == i) 40. { 41. //去重,不越界,满足亲和 42. printf("%d %d/n",i,sum[i]); 43. } 44. } 45. return 0; 46. } 运行结果: 211 @上善若水: 1、可能大家理解的还不是很清晰,我们建立一个 5 000 000 的数组,从 1 到 2 500 000 开始,在每一个下标是 i 的倍数的位置上加上 i,那么在循环结束 之后,我们得到的是什么?是 类似埃斯托拉晒求素数的数组(当然里面有真的 亲和数),然后只需要一次遍历就可以轻松找到所有的亲和数了。时间复杂度, 线性。 2、我们可以清晰的发现连续数据的映射可以通过数组结构本身的特点替代, 用来节约空间,这是数据结构的艺术。在大规模连续数据的回溯处理上,可以通 过转化为递推生成的方法,逆向思维操作,这是算法的艺术。 3、把最简单的东西运用的最巧妙的人,要比用复杂方法解决复杂问题的人要 头脑清晰。 第三节、程序的构造与解释 212 我再来具体解释下上述程序的原理,ok,举个例子,假设是求 10 以内的亲和数,求解步 骤如下: 因为所有数的真因数都包含 1,所以,先在各个数的下方全部置 1 1. 然后取 i=2,3,4,5(i<=10/2), j 依次对应的位置为 j=(4、6、8、10),(6、9),(8), (10)各数所对应的位置。 2. 依据 j 所找到的位置,在 j 所指的各个数的下面加上各个真因子 i(i=2、3、4、5)。 整个过程,即如下图所示(如 sum[6]=1+2+3=6,sum[10]=1+2+5=8.): 1 2 3 4 5 6 7 8 9 10 1 1 1 1 1 1 1 1 1 1 2 2 2 2 3 3 4 5 3. 然后一次遍历 i 从 220 开始到 5000000,i 每遍历一个数后, 将 i 对应的数下面的各个真因子加起来得到一个和 sum[i],如果这个和 sum[i]==某 个 i’,且 sum[i‘]=i, 那么这两个数 i 和 i’,即为一对亲和数。 4. i=2;sum[4]+=2,sum[6]+=2,sum[8]+=2,sum[10]+=2,sum[12]+=2... i=3,sum[6]+=3,sum[9]+=3... ...... 5. i=220 时,sum[220]=284,i=284 时,sum[284]=220;即 sum[220]=sum[sum[284]]=284, 得出 220 与 284 是一对亲和数。所以,最终输出 220、284,... 特别鸣谢 litaoye 专门为本亲和数问题开帖子继续阐述,有兴趣的朋友可继续参见: http://topic.csdn.net/u/20110526/21/129c2235-1f44-42e9-a55f-878920c21e19.html。同时, 任何人对本亲和数问题有任何问题,也可以回复到上述帖子上。 1. //求解亲和数问题 2. //copyright@ litaoye 3. //July、胡滨,updated,2011.05.26。 4. using System; 5. using System.Collections.Generic; 6. 213 7. namespace CSharpTest 8. { 9. class Program 10. { 11. public static void Main() 12. { 13. int max = 5000000; 14. DateTime start = DateTime.Now; 15. int[] counter = CreateCounter(max); 16. 17. for (int i = 0; i < counter.Length; i++) 18. { 19. int num = counter[i] - i; 20. //if (num < counter.Length && num > i && counter[num] == cou nter[i]) 21. // Console.WriteLine("{0} {1}", i, num); 22. } 23. Console.WriteLine((DateTime.Now - start).TotalSeconds); 24. 25. Console.ReadKey(); 26. } 27. 28. static int[] CreateCounter(int n) 29. { 30. List primes = new List(); 31. int[] counter = new int[n + 1]; 32. counter[1] = 1; 33. 34. for (int i = 2; i <= n; i++) 35. { 36. if (counter[i] == 0) 37. { 38. counter[i] = i + 1; 39. primes.Add(i); 40. } 41. 42. for (int j = 0; j < primes.Count; j++) 43. { 44. if (primes[j] * i > n) 45. break; 46. 47. if (i % primes[j] == 0) 48. { 49. int k = i; 214 50. int l = primes[j] * primes[j]; 51. 52. while (k % primes[j] == 0) 53. { 54. l *= primes[j]; 55. k /= primes[j]; 56. } 57. 58. counter[primes[j] * i] = counter[k] * (l - 1) / (pri mes[j] - 1); 59. break; 60. } 61. else 62. counter[primes[j] * i] = counter[i] * (primes[j] + 1 ); 63. } 64. } 65. 66. return counter; 67. } 68. } 69. } 70. 71. /* 72. 测试结果: 73. 0.484375 74. 0.484375 75. 0.46875 76. 单位 second。 77. */ 本章完。 3.3 续、求给定区间内的第 K 小(大)元素 第九章、闲话链表追赶问题 第十章、如何给 10^7 个数据量的磁盘文件排序 面试题征集令 1. 十三个经典算法研究系列+附、红黑树系列(国内有史以来最为经典的红黑树教程), 共计 20+6=26 篇文章,带目录+标签的 PDF 文档,耗时近一个星期,足足 346 页 (够一本书的分量了),已在花明月暗的帮助下,正式制作完成。 215 2. 想要的,发一道你自认为较好的面试题(c,c++,数据结构,算法,智力题,数字 逻辑或运算题)至我的邮箱:zhoulei0907@yahoo.cn,即可。我收到后,三天之内 传送此 PDF 文件。July、20110.5.24.此声明永久有效。 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。实要转载,请以链接形式注 明出处。 第七章、求连续子数组的最大和 作者:July。 出处:http://blog.csdn.net/v_JULY_v 。 前奏  希望更多的人能和我一样,把本狂想曲系列中的任何一道面试题当做一道简单的编 程题或一个实质性的问题来看待,在阅读本狂想曲系列的过程中,希望你能尽量暂 时放下所有有关面试的一切包袱,潜心攻克每一道“编程题”,在解决编程题的过程 中,好好享受编程带来的无限乐趣,与思考带来的无限激情。--By@July_____。  原狂想曲系列已更名为:程序员编程艺术系列。原狂想曲创作组更名为编程艺术室。 编程艺术室致力于以下三点工作:1、针对一个问题,不断寻找更高效的算法,并予 以编程实现。2、解决实际中会碰到的应用问题,如第十章、如何给 10^7 个数据量 的磁盘文件排序。3、经典算法的研究与实现。总体突出一点:编程,如何高效的编 程解决实际问题。欢迎有志者加入。 第一节、求子数组的最大和 3.求子数组的最大和 216 题目描述: 输入一个整形数组,数组里有正数也有负数。 数组中连续的一个或多个整数组成一个子数组,每个子数组都有一个和。 求所有子数组的和的最大值。要求时间复杂度为 O(n)。 例如输入的数组为 1, -2, 3, 10, -4, 7, 2, -5,和最大的子数组为 3, 10, -4, 7, 2, 因此输出为该子数组的和 18。 分析:这个问题在各大公司面试中出现频率之频繁,被人引用次数之多,非一般面试题可与 之匹敌。单凭这点,就没有理由不入选狂想曲系列中了。此题曾作为本人之前整理的微软 100 题中的第 3 题,至今反响也很大。ok,下面,咱们来一步一步分析这个题: 1、求一个数组的最大子数组和,如此序列 1, -2, 3, 10, -4, 7, 2, -5,我想最最直观也是 最野蛮的办法便是,三个 for 循环三层遍历,求出数组中每一个子数组的和,最终求出这些 子数组的最大的一个值。 记 Sum[i, …, j]为数组 A 中第 i 个元素到第 j 个元素的和(其中 0 <= i <= j < n),遍历所有 可能的 Sum[i, …, j],那么时间复杂度为 O(N^3): //本段代码引自编程之美 int MaxSum(int* A, int n) { int maximum = -INF; int sum=0; for(int i = 0; i < n; i++) { for(int j = i; j < n; j++) { for(int k = i; k <= j; k++) { sum += A[k]; } if(sum > maximum) maximum = sum; sum=0; //这里要记得清零,否则的话 sum 最终存放的是所有子数组的和。也就是编程 之美上所说的 bug。多谢苍狼。 } 217 } return maximum; } 2、其实这个问题,在我之前上传的微软 100 题,答案 V0.2 版[第 1-20 题答案],便直接 给出了以下 O(N)的算法: 1. //copyright@ July 2010/10/18 2. //updated,2011.05.25. 3. #include 4. 5. int maxSum(int* a, int n) 6. { 7. int sum=0; 8. //其实要处理全是负数的情况,很简单,如稍后下面第 3 点所见,直接把这句改成: "int sum=a[0]"即可 9. //也可以不改,当全是负数的情况,直接返回 0,也不见得不行。 10. int b=0; 11. 12. for(int i=0; isum,则更新 sum=b; 46. 若 b 4. #define n 4 //多定义了一个变量 5. 6. int maxsum(int a[n]) 7. //于此处,你能看到上述思路 2 代码(指针)的优势 8. { 9. int max=a[0]; //全负情况,返回最大数 10. int sum=0; 11. for(int j=0;j=0) //如果加上某个元素,sum>=0 的话,就加 14. sum+=a[j]; 15. else 16. sum=a[j]; //如果加上某个元素,sum<0 了,就不加 17. if(sum>max) 18. max=sum; 19. } 20. return max; 21. } 22. 23. int main() 24. { 25. int a[]={-1,-2,-3,-4}; 26. cout<MaxSum) 16. MaxSum=ThisSum; 17. } 18. return MaxSum; 19. } 20. 21. //Algorithm 2:时间效率为 O(n*n) 220 22. int MaxSubsequenceSum2(const int A[],int N) 23. { 24. int ThisSum=0,MaxSum=0,i,j,k; 25. for(i=0;iMaxSum) 32. MaxSum=ThisSum; 33. } 34. } 35. return MaxSum; 36. } 37. 38. //Algorithm 3:时间效率为 O(n*log n) 39. //算法 3 的主要思想:采用二分策略,将序列分成左右两份。 40. //那么最长子序列有三种可能出现的情况,即 41. //【1】只出现在左部分. 42. //【2】只出现在右部分。 43. //【3】出现在中间,同时涉及到左右两部分。 44. //分情况讨论之。 45. static int MaxSubSum(const int A[],int Left,int Right) 46. { 47. int MaxLeftSum,MaxRightSum; //左、右部分最大连续子序列值。对应 情况【1】、【2】 48. int MaxLeftBorderSum,MaxRightBorderSum; //从中间分别到左右两侧的最大连续子 序列值,对应 case【3】。 49. int LeftBorderSum,RightBorderSum; 50. int Center,i; 51. if(Left == Right)Base Case 52. if(A[Left]>0) 53. return A[Left]; 54. else 55. return 0; 56. Center=(Left+Right)/2; 57. MaxLeftSum=MaxSubSum(A,Left,Center); 58. MaxRightSum=MaxSubSum(A,Center+1,Right); 59. MaxLeftBorderSum=0; 60. LeftBorderSum=0; 61. for(i=Center;i>=Left;i--) 62. { 63. LeftBorderSum+=A[i]; 221 64. if(LeftBorderSum>MaxLeftBorderSum) 65. MaxLeftBorderSum=LeftBorderSum; 66. } 67. MaxRightBorderSum=0; 68. RightBorderSum=0; 69. for(i=Center+1;i<=Right;i++) 70. { 71. RightBorderSum+=A[i]; 72. if(RightBorderSum>MaxRightBorderSum) 73. MaxRightBorderSum=RightBorderSum; 74. } 75. int max1=MaxLeftSum>MaxRightSum?MaxLeftSum:MaxRightSum; 76. int max2=MaxLeftBorderSum+MaxRightBorderSum; 77. return max1>max2?max1:max2; 78. } 79. 80. //Algorithm 4:时间效率为 O(n) 81. //同上述第一节中的思路 3、和 4。 82. int MaxSubsequenceSum(const int A[],int N) 83. { 84. int ThisSum,MaxSum,j; 85. ThisSum=MaxSum=0; 86. for(j=0;jMaxSum) 90. MaxSum=ThisSum; 91. else if(ThisSum<0) 92. ThisSum=0; 93. } 94. return MaxSum; 95. } 本章完。 3.3 续、求给定区间内的第 K 小(大)元素 第九章、闲话链表追赶问题 第十章、如何给 10^7 个数据量的磁盘文件排序 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。实要转载,请以链接形式注 明出处。 222 第八章、从头至尾漫谈虚函数 作者:July。 出处:http://blog.csdn.net/v_JULY_v 。 前奏 有关虚函数的问题层出不穷,有关虚函数的文章千篇一律,那为何还要写这一篇有关虚 函数的文章呢?看完本文后,相信能懂其意义之所在。同时,原狂想曲系列已经更名为程序 员编程艺术系列,因为不再只专注于“面试”,而在“编程”之上了。ok,如果有不正之处,望 不吝赐教。谢谢。 第一节、一道简单的虚函数的面试题 题目要求:写出下面程序的运行结果? 1. //谢谢董天喆提供的这道百度的面试题 2. #include 3. using namespace std; 4. class A{ 5. public:virtual void p() 6. { 7. cout << "A" << endl; 8. } 9. }; 10. 11. class B : public A 12. { 13. public:virtual void p() 14. { cout << "B" << endl; 15. } 16. }; 17. 223 18. int main() 19. { 20. A * a = new A; 21. A * b = new B; 22. a->p(); 23. b->p(); 24. delete a; 25. delete b; 26. return 0; 27. } 我想,这道面试题应该是考察虚函数相关知识的相对简单的一道题目了。然后,希望你 碰到此类有关虚函数的面试题,不论其难度是难是易,都能够举一反三,那么本章的目的也 就达到了。ok,请跟着我的思路,咱们步步深入(上面程序的输出结果为 A B)。 第二节、有无虚函数的区别 1、当上述程序中的函数 p()不是虚函数,那么程序的运行结果是如何?即如下代码所示: class A { public: void p() { cout << "A" << endl; } }; class B : public A { public: void p() { cout << "B" << endl; 224 } }; 对的,程序此时将输出两个 A,A。为什么? 我们知道,在构造一个类的对象时,如果它有基类,那么首先将构造基类的对象,然后才构 造派生类自己的对象。如上,A* a=new A,调用默认构造函数构造基类 A 对象,然后调用 函数 p(),a->p();输出 A,这点没有问题。 然后,A * b = new B;,构造了派生类对象 B,B 由于是基类 A 的派生类对象,所以会先 构造基类 A 对象,然后再构造派生类对象,但由于当程序中函数是非虚函数调用时,B 类 对象对函数 p()的调用时在编译时就已静态确定了,所以,不论基类指针 b 最终指向的是基 类对象还是派生类对象,只要后面的对象调用的函数不是虚函数,那么就直接无视,而调用 基类 A 的 p()函数。 2、那如果加上虚函数呢?即如最开始的那段程序那样,程序的输出结果,将是什么? 在此之前,我们还得明确以下两点: a、通过基类引用或指针调用基类中定义的函数时,我们并不知道执行函数的对象的确切 类型,执行函数的对象可能是基类类型的,也可能是派生类型的。 b、如果调用非虚函数,则无论实际对象是什么类型,都执行基类类型所定义的函数(如 上述第 1 点所述)。如果调用虚函数,则直到运行时才能确定调用哪个函数,运行的虚函数 是引用所绑定的或指针所指向的对象所属类型定义的版本。 根据上述 b 的观点,我们知道,如果加上虚函数,如上面这道面试题, class A { public: virtual void p() { cout << "A" << endl; } }; class B : public A { public: virtual void p() 225 { cout << "B" << endl; } }; int main() { A * a = new A; A * b = new B; a->p(); b->p(); delete a; delete b; return 0; } 那么程序的输出结果将是 A B。 所以,至此,咱们的这道面试题已经解决。但虚函数的问题,还没有解决。 第三节、虚函数的原理与本质 我们已经知道,虚(virtual)函数的一般实现模型是:每一个类(class)有一个虚表(virtual table),内含该 class 之中有作用的虚(virtual)函数的地址,然后每个对象有一个 vptr, 指向虚表(virtual table)的所在。 请允许我援引自深度探索 c++对象模型一书上的一个例子: class Point { public: virtual ~Point(); virtual Point& mult( float ) = 0; float x() const { return _x; } //非虚函数,不作存储 virtual float y() const { return 0; } 226 virtual float z() const { return 0; } // ... protected: Point( float x = 0.0 ); float _x; }; 1、在 Point 的对象 pt 中,有两个东西,一个是数据成员_x,一个是_vptr_Point。其中 _vptr_Point 指向着 virtual table point,而 virtual table(虚表)point 中存储着以下东西:  virtual ~Point()被赋值 slot 1,  mult() 将被赋值 slot 2.  y() is 将被赋值 slot 3  z() 将被赋值 slot 4. class Point2d : public Point { public: Point2d( float x = 0.0, float y = 0.0 ) : Point( x ), _y( y ) {} ~Point2d(); //1 //改写 base class virtual functions Point2d& mult( float ); //2 float y() const { return _y; } //3 protected: float _y; }; 2、在 Point2d 的对象 pt2d 中,有三个东西,首先是继承自基类 pt 对象的数据成员_x, 然后是 pt2d 对象本身的数据成员_y,最后是_vptr_Point。其中_vptr_Point 指向着 virtual table point2d。由于 Point2d 继承自 Point,所以在 virtual table point2d 中存储着:改写了 的其中的~Point2d()、Point2d& mult( float )、float y() const,以及未被改写的 Point::z()函 数。 class Point3d: public Point2d { public: 227 Point3d( float x = 0.0, float y = 0.0, float z = 0.0 ) : Point2d( x, y ), _z( z ) {} ~Point3d(); // overridden base class virtual functions Point3d& mult( float ); float z() const { return _z; } // ... other operations ... protected: float _z; }; 3、在 Point3d 的对象 pt3d 中,则有四个东西,一个是_x,一个是_vptr_Point,一个是 _y,一个是_z。其中_vptr_Point 指向着 virtual table point3d。由于 point3d 继承自 point2d, 所以在 virtual table point3d 中存储着:已经改写了的 point3d 的~Point3d(),point3d::mult() 的函数地址,和 z()函数的地址,以及未被改写的 point2d 的 y()函数地址。 ok,上述 1、2、3 所有情况的详情,请参考下图。 228 (图:virtual table(虚表)的布局:单一继承情况) 本文,日后可能会酌情考虑增补有关内容。ok,更多,可参考深度探索 c++对象模型一书第 四章。 最近几章难度都比较小,是考虑到狂想曲有深有浅的原则,后续章节会逐步恢复到相应难度。 第四节、虚函数的布局与汇编层面的考察 ivan、老梦的两篇文章继续对虚函数进行了一番深入,我看他们已经写得很好了,我就 不饶舌了。ok,请看:1、VC 虚函数布局引发的问题,2、从汇编层面深度剖析 C++虚函数、 http://blog.csdn.net/linyt/archive/2011/04/20/6336762.aspx。 229 第五节、虚函数表的详解 本节全部内容来自淄博的共享,非常感谢。注@molixiaogemao:只有发生继承的时候 且父类子类都有 virtual 的时候才会出现虚函数指针,请不要忘了虚函数出现的目的是为了 实现多态。 一般继承(无虚函数覆盖) 下面,再让我们来看看继承时的虚函数表是什么样的。假设有如下所示的一个继承关系: 请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中, 对于实例:Derive d; 的虚函数表如下: 我们可以看到下面几点: 1)虚函数按照其声明顺序放于表中。 2)父类的虚函数在子类的虚函数前面。 我相信聪明的你一定可以参考前面的那个程序,来编写一段程序来验证。 230 一般继承(有虚函数覆盖) 覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。 下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设, 我们有下面这样的一个继承关系。 为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f() 。 那么,对于派生类的实例,其虚函数表会是下面的一个样子: 我们从表中可以看到下面几点, 1)覆盖的 f()函数被放到了虚表中原来父类虚函数的位置。 2)没有被覆盖的函数依旧。 这样,我们就可以看到对于下面这样的程序, Base *b = new Derive(); b->f(); 由 b 所指的内存中的虚函数表的 f()的位置已经被 Derive::f()函数地址所取代, 于是在实际调用发生时,是 Derive::f()被调用了。这就实现了多态。 多重继承(无虚函数覆盖) 231 下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系(注意:子类并没 有覆盖父类的函数): 对于子类实例中的虚函数表,是下面这个样子: 我们可以看到: 1) 每个父类都有自己的虚表。 2) 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的) 这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。 多重继承(有虚函数覆盖) 下面我们再来看看,如果发生虚函数覆盖的情况。 下图中,我们在子类中覆盖了父类的 f()函数。 232 下面是对于子类实例中的虚函数表的图: 我们可以看见,三个父类虚函数表中的 f()的位置被替换成了子类的函数指针。 这样,我们就可以任一静态类型的父类来指向子类,并调用子类的 f()了。如: Derive d; Base1 *b1 = &d; Base2 *b2 = &d; Base3 *b3 = &d; b1->f(); //Derive::f() b2->f(); //Derive::f() b3->f(); //Derive::f() b1->g(); //Base1::g() b2->g(); //Base2::g() b3->g(); //Base3::g() 233 安全性 每次写 C++的文章,总免不了要批判一下 C++。 这篇文章也不例外。通过上面的讲述,相信我们对虚函数表有一个比较细致的了解了。 水可载舟,亦可覆舟。下面,让我们来看看我们可以用虚函数表来干点什么坏事吧。 一、通过父类型的指针访问子类自己的虚函数 我们知道,子类没有重载父类的虚函数是一件毫无意义的事情。因为多态也是要基于函数重载的。 虽然在上面的图中我们可以看到 Base1 的虚表中有 Derive 的虚函数,但我们根本不可能使用下 面的语句来调用子类的自有虚函数: Base1 *b1 = new Derive(); b1->g1(); //编译出错 任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法,即 基类指针不能调用子类自己定义的成员函数。所以,这样的程序根本无法编译通过。 但在运行时,我们可以通过指针的方式访问虚函数表来达到违反 C++语义的行为。 (关于这方面的尝试,通过阅读后面附录的代码,相信你可以做到这一点) 二、访问 non-public 的虚函数 另外,如果父类的虚函数是 private 或是 protected 的,但这些非 public 的虚函数同样会存在于 虚函数表中, 所以,我们同样可以使用访问虚函数表的方式来访问这些 non-public 的虚函数,这是很容易做 到的。 如: class Base { private: virtual void f() { cout << "Base::f" << endl; } }; class Derive : public Base{ }; typedef void(*Fun)(void); void main() { Derive d; Fun pFun = (Fun)*((int*)*(int*)(&d)+0); 234 pFun(); } 对上面粗体部分的解释(@a && x): 1. (int*)(&d)取 vptr 地址,该地址存储的是指向 vtbl 的指针 2. (int*)*(int*)(&d)取 vtbl 地址,该地址存储的是虚函数表数组 3. (Fun)*((int*)*(int*)(&d) +0),取 vtbl 数组的第一个元素,即 Base 中第一个虚函数 f 的地址 4. (Fun)*((int*)*(int*)(&d) +1),取 vtbl 数组的第二个元素(这第 4 点,如下图所示)。 下图也能很清晰的说明一些东西(@5): ok,再来看一个问题,如果一个子类重载的虚拟函数为 privete,那么通过父类的指针可以访问 到它吗? #include class B 235 { public: virtual void fun() { std::cout << "base fun called"; }; }; class D : public B { private: virtual void fun() { std::cout << "driver fun called"; }; }; int main(int argc, char* argv[]) { B* p = new D(); p->fun(); return 0; } 运行时会输出 driver fun called 从这个实验,可以更深入的了解虚拟函数编译时的一些特征: 在编译虚拟函数调用的时候,例如 p->fun(); 只是按其静态类型来处理的, 在这里 p 的类型就是 B,不会考虑其实际指向的类型(动态类型)。 也就是说,碰到 p->fun();编译器就当作调用 B 的 fun 来进行相应的检查和处理。 因为在 B 里 fun 是 public 的,所以这里在“访问控制检查”这一关就完全可以通过了。 然后就会转换成(*p->vptr[1])(p)这样的方式处理, p 实际指向的动态类型是 D, 所以 p 作为参数传给 fun 后(类的非静态成员函数都会编译加一个指针参数,指向调用该函数 的对象,我们平常用的 this 就是该指针的值), 实际运行时 p->vptr[1]则获取到的是 D::fun()的地 址,也就调用了该函数, 这也就是动态运行的机理。 236 为了进一步的实验,可以将 B 里的 fun 改为 private 的,D 里的改为 public 的,则编译就会出错。 C++的注意条款中有一条" 绝不重新定义继承而来的缺省参数值" (Effective C++ Item37, never redefine a function's inherited default parameter value) 也是 同样的道理。 可以再做个实验 class B { public: virtual void fun(int i = 1) { std::cout << "base fun called, " << i; }; }; class D : public B { private: virtual void fun(int i = 2) { std::cout << "driver fun called, " << i; }; }; 则运行会输出 driver fun called, 1 关于这一点,Effective 上讲的很清楚“virtual 函数系动态绑定, 而缺省参数却是静态绑定”, 也就是说在编译的时候已经按照 p 的静态类型处理其默认参数了,转换成了(*p->vptr[1])(p, 1)这 样的方式。 补遗 237 一个类如果有虚函数,不管是几个虚函数,都会为这个类声明一个虚函数表,这个虚表是一个 含有虚函数的类的,不是说是类对象的。一个含有虚函数的类,不管有多少个数据成员,每个对 象实例都有一个虚指针,在内存中,存放每个类对象的内存区,在内存区的头部都是先存放这个 指针变量的(准确的说,应该是:视编译器具体情况而定),从第 n(n 视实际情况而定)个字 节才是这个对象自己的东西。 下面再说下通过基类指针,调用虚函数所发生的一切: One *p; p->disp(); 1、上来要取得类的虚表的指针,就是要得到,虚表的地址。存放类对象的内存区的前四个字节 其实就是用来存放虚表的地址的。 2、得到虚表的地址后,从虚表那知道你调用的那个函数的入口地址。根据虚表提供的你要找的 函数的地址。并调用函数;你要知道,那个虚表是一个存放指针变量的数组,并不是说,那个虚 表中就是存放的虚函数的实体。 本章完。 3.3 续、求给定区间内的第 K 小(大)元素 第九章、闲话链表追赶问题 第十章、如何给 10^7 个数据量的磁盘文件排序 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。网络转载,请以链接形式注 明出处。 第九章、闲话链表追赶问题 作者:July、狂想曲创作组。 出处:http://blog.csdn.net/v_JULY_v 。 238 前奏 有这样一个问题:在一条左右水平放置的直线轨道上任选两个点,放置两个机器人,请 用如下指令系统为机器人设计控制程序,使这两个机器人能够在直线轨道上相遇。(注意两 个机器人用你写的同一个程序来控制)。 指令系统:只包含 4 条指令,向左、向右、条件判定、无条件跳转。其中向左(右)指 令每次能控制机器人向左(右)移动一步;条件判定指令能对机器人所在的位置进行条件测 试,测试结果是如果对方机器人曾经到过这里就返回 true,否则返回 false;无条件跳转, 类似汇编里面的跳转,可以跳转到任何地方。 ok,这道很有意思的趣味题是去年微软工程院的题,文末将给出解答(如果急切想知道 此问题的答案,可以直接跳到本文第三节)。同时,我们看到其实这个题是一个典型的追赶 问题,那么追赶问题在哪种面试题中比较常见?对了,链表追赶。本章就来阐述这个问题。 有不正之处,望不吝指正。 第一节、求链表倒数第 k 个结点 第 13 题、题目描述: 输入一个单向链表,输出该链表中倒数第 k 个结点, 链表的倒数第 0 个结点为链表的尾指针。 分析:此题一出,相信,稍微有点 经验的同志,都会说到:设置两个指针 p1,p2,首先 p1 和 p2 都指向 head,然后 p2 向前走 k 步,这样 p1 和 p2 之间就间隔 k 个节点,最后 p1 和 p2 同时向前移动,直至 p2 走到链表末尾。 前几日有朋友提醒我说,让我讲一下此种求链表倒数第 k 个结点的问题。我想,这种问 题,有点经验的人恐怕都已了解过,无非是利用两个指针一前一后逐步前移。但他提醒我说, 如果参加面试的人没有这个意识,它怎么也想不到那里去。 那在平时准备面试的过程中如何加强这一方面的意识呢?我想,除了平时遇到一道面试 题,尽可能用多种思路解决,以延伸自己的视野之外,便是平时有意注意观察生活。因为, 相信,你很容易了解到,其实这种链表追赶的问题来源于生活中长跑比赛,如果平时注意多 多思考,多多积累,多多发现并体味生活,相信也会对面试有所帮助。 ok,扯多了,下面给出这个题目的主体代码,如下: 239 struct ListNode { char data; ListNode* next; }; ListNode* head,*p,*q; ListNode *pone,*ptwo; //@heyaming, 第一节,求链表倒数第 k 个结点应该考虑 k 大于链表长度的 case。 ListNode* fun(ListNode *head,int k) { assert(k >= 0); pone = ptwo = head; for( ; k > 0 && ptwo != NULL; k--) ptwo=ptwo->next; if (k > 0) return NULL; while(ptwo!=NULL) { pone=pone->next; ptwo=ptwo->next; } return pone; } 扩展: 这是针对链表单项链表查找其中倒数第 k 个结点。试问,如果链表是双向的,且可能存在环 呢?请看第二节、编程判断两个链表是否相交。 第二节、编程判断两个链表是否相交 题目描述:给出两个单向链表的头指针(如下图所示), 240 比如 h1、h2,判断这两个链表是否相交。这里为了简化问题,我们假设两个链表均不带环。 分析:这是来自编程之美上的微软亚院的一道面试题目。请跟着我的思路步步深入(部分文 字引自编程之美): 1. 直接循环判断第一个链表的每个节点是否在第二个链表中。但,这种方法的时间复 杂度为 O(Length(h1) * Length(h2))。显然,我们得找到一种更为有效的方法,至少 不能是 O(N^2)的复杂度。 2. 针对第一个链表直接构造 hash 表,然后查询 hash 表,判断第二个链表的每个结点 是否在 hash 表出现,如果所有的第二个链表的结点都能在 hash 表中找到,即说明 第二个链表与第一个链表有相同的结点。时间复杂度为为线性:O(Length(h1) + Length(h2)),同时为了存储第一个链表的所有节点,空间复杂度为 O(Length(h1))。 是否还有更好的方法呢,既能够以线性时间复杂度解决问题,又能减少存储空间? 3. 进一步考虑“如果两个没有环的链表相交于某一节点,那么在这个节点之后的所有节 点都是两个链表共有的”这个特点,我们可以知道,如果它们相交,则最后一个节点 一定是共有的。而我们很容易能得到链表的最后一个节点,所以这成了我们简化解 法的一个主要突破口。那么,我们只要判断俩个链表的尾指针是否相等。相等,则 链表相交;否则,链表不相交。 所以,先遍历第一个链表,记住最后一个节点。然后遍历第二个链表,到最后一个 节点时和第一个链表的最后一个节点做比较,如果相同,则相交,否则,不相交。 这样我们就得到了一个时间复杂度,它为 O((Length(h1) + Length(h2)),而且只用 了一个额外的指针来存储最后一个节点。这个方法时间复杂度为线性 O(N),空 间复杂度为 O(1),显然比解法三更胜一筹。 4. 上面的问题都是针对链表无环的,那么如果现在,链表是有环的呢?还能找到最后 一个结点进行判断么?上面的方法还同样有效么?显然,这个问题的本质已经转化为 判断链表是否有环。那么,如何来判断链表是否有环呢? 总结: 所以,事实上,这个判断两个链表是否相交的问题就转化成了: 1.先判断带不带环 2.如果都不带环,就判断尾节点是否相等 241 3.如果都带环,判断一链表上俩指针相遇的那个节点,在不在另一条链表上。 如果在,则相交,如果不在,则不相交。 1、那么,如何编写代码来判断链表是否有环呢?因为很多的时候,你给出了问题的思路 后,面试官可能还要追加你的代码,ok,如下(设置两个指针(p1, p2),初始值都指向头, p1 每次前进一步,p2 每次前进二步,如果链表存在环,则 p2 先进入环,p1 后进入环,两 个指针在环中走动,必定相遇): 1. //copyright@ KurtWang 2. //July、2011.05.27。 3. struct Node 4. { 5. int value; 6. Node * next; 7. }; 8. 9. //1.先判断带不带环 10. //判断是否有环,返回 bool,如果有环,返回环里的节点 11. //思路:用两个指针,一个指针步长为 1,一个指针步长为 2,判断链表是否有环 12. bool isCircle(Node * head, Node *& circleNode, Node *& lastNode) 13. { 14. Node * fast = head->next; 15. Node * slow = head; 16. while(fast != slow && fast && slow) 17. { 18. if(fast->next != NULL) 19. fast = fast->next; 20. 21. if(fast->next == NULL) 22. lastNode = fast; 23. if(slow->next == NULL) 24. lastNode = slow; 25. 26. fast = fast->next; 27. slow = slow->next; 28. 29. } 30. if(fast == slow && fast && slow) 31. { 32. circleNode = fast; 33. return true; 242 34. } 35. else 36. return false; 37. } 2&3、如果都不带环,就判断尾节点是否相等,如果都带环,判断一链表上俩指针相遇的 那个节点,在不在另一条链表上。下面是综合解决这个问题的代码: 1. //判断带环不带环时链表是否相交 2. //2.如果都不带环,就判断尾节点是否相等 3. //3.如果都带环,判断一链表上俩指针相遇的那个节点,在不在另一条链表上。 4. bool detect(Node * head1, Node * head2) 5. { 6. Node * circleNode1; 7. Node * circleNode2; 8. Node * lastNode1; 9. Node * lastNode2; 10. 11. bool isCircle1 = isCircle(head1,circleNode1, lastNode1); 12. bool isCircle2 = isCircle(head2,circleNode2, lastNode2); 13. 14. //一个有环,一个无环 15. if(isCircle1 != isCircle2) 16. return false; 17. //两个都无环,判断最后一个节点是否相等 18. else if(!isCircle1 && !isCircle2) 19. { 20. return lastNode1 == lastNode2; 21. } 22. //两个都有环,判断环里的节点是否能到达另一个链表环里的节点 23. else 24. { 25. Node * temp = circleNode1->next; //updated,多谢苍 狼 and hyy。 26. while(temp != circleNode1) 27. { 28. if(temp == circleNode2) 29. return true; 30. temp = temp->next; 31. } 32. return false; 33. } 34. 35. return false; 243 36. } 扩展 2:求两个链表相交的第一个节点 思路:在判断是否相交的过程中要分别遍历两个链表,同时记录下各自长度。 @Joshua:这个算法需要处理一种特殊情况,即:其中一个链表的头结点在另一个链表的 环中,且不是环入口结点。这种情况有两种意思:1)如果其中一个链表是循环链表,则另一 个链表必为循环链表,即两个链表重合但头结点不同;2)如果其中一个链表存在环(除去循 环链表这种情况),则另一个链表必在此环中与此环重合,其头结点为环中的一个结点,但 不是入口结点。在这种情况下我们约定,如果链表 B 的头结点在链表 A 的环中,且不是环 入口结点,那么链表 B 的头结点即作为 A 和 B 的第一个相交结点;如果 A 和 B 重合(定义 方法时形参 A 在 B 之前),则取 B 的头结点作为 A 和 B 的第一个相交结点。 ok,下面,回到本章前奏部分的那道非常有趣味的智力题。 第三节、微软工程院面试智力题 题目描述: 在一条左右水平放置的直线轨道上任选两个点,放置两个机器人,请用如下指令系统为 机器人设计控制程序,使这两个机器人能够在直线轨道上相遇。(注意两个机器人用你写的 同一个程序来控制) 指令系统:只包含 4 条指令,向左、向右、条件判定、无条件跳转。其中向左(右)指 令每次能控制机器人向左(右)移动一步;条件判定指令能对机器人所在的位置进行条件测 试,测试结果是如果对方机器人曾经到过这里就返回 true,否则返回 false;无条件跳转, 类似汇编里面的跳转,可以跳转到任何地方。 分析:我尽量以最清晰的方式来说明这个问题(大部分内容来自 ivan,big 等人的讨论): 1、首先题目要求很简单,就是要你想办法让 A 最终能赶上 B,A 在后,B 在前,都向 右移动,如果它们的速度永远一致,那 A 是永远无法追赶上 B 的。但题目给出了一个条件 判断指令,即如果 A 或 B 某个机器人向前移动时,若是某个机器人经过的点是第二个机器 人曾经经过的点,那么程序返回 true。对的,就是抓住这一点,A 到达曾经 B 经过的点后, 发现此后的路是 B 此前经过的,那么 A 开始提速两倍,B 一直保持原来的一倍速度不变, 那样的话,A 势必会在|AB|/move_right 个单位时间内,追上 B。ok,简单伪代码如下: start: if(at the position other robots have not reached) move_right 244 if(at the position other robots have reached) move_right move_right goto start 再简单解释下上面的伪代码(@big): A------------B | | 在 A 到达 B 点前,两者都只有第一条 if 为真,即以相同的速度向右移动,在 A 到达 B 后, A 只满足第二个 if,即以两倍的速度向右移动,B 依然只满足第一个 if,则速度保持不变, 经过|AB|/move_right 个单位时间,A 就可以追上 B。 2、有个细节又出现了,正如 ivan 所说, if(at the position other robots have reached) move_right move_right 上面这个分支不一定能提速的。why?因为如果 if 条件花的时间很少,而 move 指令发的时 间很大(实际很可能是这样),那么两个机器人的速度还是基本是一样的。 那作如何修改呢?: start: if(at the position other robots have not reached) move_right move_left move_right if(at the position other robots have reached) move_right goto start ------- 这样改后,A 的速度应该比 B 快了。 245 3、然要是说每个指令处理速度都很快,AB 岂不是一直以相同的速度右移了?那到底该 作何修改呢?请看: go_step() { 向右 向左 向右 } -------- 三个时间单位才向右一步 go_2step() { 向右 } ------ 一个时间单向右一步向左和向右花的时间是同样的,并且会占用一定时间。 如果条件判 定指令时间比移令花的时间较少的话,应该上面两种步法,后者比前者快。至此,咱们的问 题已经得到解决。 最后,感谢蜜蜂提供的这道有意思的面试题: 246 本章完。 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。网络转载,请以链接形式注 明出处。 第十章、如何给 10^7 个数据量的磁盘文件排序 作者:July,yansha,5,编程艺术室。 出处:http://blog.csdn.net/v_JULY_v 。 前奏 247 经过几天的痛苦沉思,最终决定,把原程序员面试题狂想曲系列正式更名为程序员编程 艺术系列,同时,狂想曲创作组更名为编程艺术室。之所以要改名,我们考虑到三点:1、 为面试服务不能成为我们最终或最主要的目的,2、我更愿把解答一道道面试题,ACM 题等 各类程序设计题目的过程,当做一种艺术来看待,3、艺术的提炼本身是一个非常非常艰难 的过程,但我们乐意接受这个挑战。 同时,本系列程序编程艺术-算法卷,大致分为三个部分:第一部分--程序设计,大凡如 面试题目/ACM 题目/poj 的题目等各类程序设计的题,只要是好的,值得设计或深究的题目, 我们都不拒绝。同时,紧扣实际,不断寻找更高效的算法解决实际问题。第二部分--算法研 究,主要以我个人此前写的原创作品-十三个经典算法研究系列为题材,力争通俗易懂,详 略得当的剖析各类经典的算法,并予以编程实现。第三部分--编码素养,主要包括程序员编 码过程中一些编码规范等各类及其需要注意的问题。 如果有可能的话,此 TAOPP 系列将采取 TAOCP 那样的形式,出第一卷、第二卷、...。 编程艺术来自哪里?编程采取合适的数据结构?寻求更高效的算法?或者,好的编码规范?希 望,本 TAOPP 系列最终能给你一个完整的答复。 ok,如果任何人对本编程艺术系列有任何意见,或发现了本编程艺术系列任何问题,漏 洞,bug,欢迎随时提出,我们将虚心接受并感激不尽,以为他人创造更好的价值,更好的 服务。 第一节、如何给磁盘文件排序 问题描述: 输入:一个最多含有 n 个不重复的正整数(也就是说可能含有少于 n 个不重复正整数)的 文件,其中每个数都小于等于 n,且 n=10^7。 输出:得到按从小到大升序排列的包含所有输入的整数的列表。 条件:最多有大约 1MB 的内存空间可用,但磁盘空间足够。且要求运行时间在 5 分钟以下, 10 秒为最佳结果。 分析:下面咱们来一步一步的解决这个问题, 1、归并排序。你可能会想到把磁盘文件进行归并排序,但题目要求你只有 1MB 的内存 空间可用,所以,归并排序这个方法不行。 2、位图方案。熟悉位图的朋友可能会想到用位图来表示这个文件集合。例如正如编程珠 玑一书上所述,用一个 20 位长的字符串来表示一个所有元素都小于 20 的简单的非负整数 集合,边框用如下字符串来表示集合{1,2,3,5,8,13}: 248 0 1 1 1 0 1 0 0 1 0 0 0 0 1 0 0 0 0 0 0 上述集合中各数对应的位置则置 1,没有对应的数的位置则置 0。 参考编程珠玑一书上的位图方案,针对我们的 10^7 个数据量的磁盘文件排序问题,我们 可以这么考虑,由于每个 7 位十进制整数表示一个小于 1000 万的整数。我们可以使用一个 具有 1000 万个位的字符串来表示这个文件,其中,当且仅当整数 i 在文件中存在时,第 i 位为 1。采取这个位图的方案是因为我们面对的这个问题的特殊性:1、输入数据限制在相 对较小的范围内,2、数据没有重复,3、其中的每条记录都是单一的整数,没有任何其它 与之关联的数据。 所以,此问题用位图的方案分为以下三步进行解决:  第一步,将所有的位都置为 0,从而将集合初始化为空。  第二步,通过读入文件中的每个整数来建立集合,将每个对应的位都置为 1。  第三步,检验每一位,如果该位为 1,就输出对应的整数。 经过以上三步后,产生有序的输出文件。令 n 为位图向量中的位数(本例中为1000 0000), 程序可以用伪代码表示如下: 1. //磁盘文件排序位图方案的伪代码 2. //copyright@ Jon Bentley 3. //July、updated,2011.05.29。 4. 5. //第一步,将所有的位都初始化为 0 6. for i ={0,....n} 7. bit[i]=0; 8. //第二步,通过读入文件中的每个整数来建立集合,将每个对应的位都置为 1。 9. for each i in the input file 10. bit[i]=1; 11. 12. //第三步,检验每一位,如果该位为 1,就输出对应的整数。 13. for i={0...n} 14. if bit[i]==1 15. write i on the output file 上面只是为了简单介绍下位图算法的伪代码之抽象级描述。显然,咱们面对的问题,可 不是这么简单。下面,我们试着针对这个要分两趟给磁盘文件排序的具体问题编写完整代码, 如下。 1. //copyright@ yansha 2. //July、2010.05.30。 249 3. //位图方案解决 10^7 个数据量的文件的排序问题 4. //如果有重复的数据,那么只能显示其中一个 其他的将被忽略 5. #include 6. #include 7. #include 8. #include 9. using namespace std; 10. 11. const int max_each_scan = 5000000; 12. 13. int main() 14. { 15. clock_t begin = clock(); 16. bitset bit_map; 17. bit_map.reset(); 18. 19. // open the file with the unsorted data 20. FILE *fp_unsort_file = fopen("data.txt", "r"); 21. assert(fp_unsort_file); 22. int num; 23. 24. // the first time scan to sort the data between 0 - 4999999 25. while (fscanf(fp_unsort_file, "%d ", &num) != EOF) 26. { 27. if (num < max_each_scan) 28. bit_map.set(num, 1); 29. } 30. 31. FILE *fp_sort_file = fopen("sort.txt", "w"); 32. assert(fp_sort_file); 33. int i; 34. 35. // write the sorted data into file 36. for (i = 0; i < max_each_scan; i++) 37. { 38. if (bit_map[i] == 1) 39. fprintf(fp_sort_file, "%d ", i); 40. } 41. 42. // the second time scan to sort the data between 5000000 - 9999999 43. int result = fseek(fp_unsort_file, 0, SEEK_SET); 44. if (result) 45. cout << "fseek failed!" << endl; 46. else 250 47. { 48. bit_map.reset(); 49. while (fscanf(fp_unsort_file, "%d ", &num) != EOF) 50. { 51. if (num >= max_each_scan && num < 10000000) 52. { 53. num -= max_each_scan; 54. bit_map.set(num, 1); 55. } 56. } 57. for (i = 0; i < max_each_scan; i++) 58. { 59. if (bit_map[i] == 1) 60. fprintf(fp_sort_file, "%d ", i + max_each_scan); 61. } 62. } 63. 64. clock_t end = clock(); 65. cout<<"用位图的方法,耗时:"< 7. #include 8. #include 9. using namespace std; 10. 11. const int size = 10000000; 12. int num[size]; 13. 14. int main() 15. { 16. int n; 17. FILE *fp = fopen("data.txt", "w"); 18. assert(fp); 19. 20. for (n = 1; n <= size; n++) 21. //之前此处写成了 n=0;n 4. #include 5. #include 6. //#include "ExternSort.h"using namespace std; 7. //使用多路归并进行外排序的类 8. //ExternSort.h 256 9. /** 大数据量的排序* 多路归并排序* 以千万级整数从小到大排序为例* 一个比较简单的例子, 没有建立内存缓冲区*/ 10. #ifndef EXTERN_SORT_H 11. #define EXTERN_SORT_H 12. 13. #include class ExternSort 14. { 15. public: 16. void sort() 17. { 18. time_t start = time(NULL); 19. //将文件内容分块在内存中排序,并分别写入临时文件 20. int file_count = memory_sort(); 21. //归并临时文件内容到输出文件 22. merge_sort(file_count); 23. time_t end = time(NULL);printf("total time:%f/n", (end - start) * 10 00.0/ CLOCKS_PER_SEC); 24. } 25. 26. //input_file:输入文件名 27. //out_file:输出文件名 28. //count: 每次在内存中排序的整数个数 29. ExternSort(const char *input_file, const char * out_file, int count) 30. { 31. m_count = count; 32. m_in_file = new char[strlen(input_file) + 1]; 33. strcpy(m_in_file, input_file); 34. m_out_file = new char[strlen(out_file) + 1]; 35. strcpy(m_out_file, out_file); 36. } 37. virtual ~ExternSort() 38. { 39. delete [] m_in_file; 40. delete [] m_out_file; 41. } 42. private: 43. int m_count; 44. //数组长度 char *m_in_file; 45. //输入文件的路径 46. char *m_out_file; 47. //输出文件的路径 48. protected: 49. int read_data(FILE* f, int a[], int n) 50. { 257 51. int i = 0; 52. while(i < n && (fscanf(f, "%d", &a[i]) != EOF)) 53. i++; 54. printf("read:%d integer/n", i); 55. return i; 56. } 57. void write_data(FILE* f, int a[], int n) 58. { 59. for(int i = 0; i < n; ++i) 60. fprintf(f, "%d ", a[i]); 61. } 62. char* temp_filename(int index) 63. { 64. char *tempfile = new char[100]; 65. sprintf(tempfile, "temp%d.txt", index); 66. return tempfile; 67. } 68. static int cmp_int(const void *a, const void *b) 69. { 70. return *(int*)a - *(int*)b; 71. } 72. 73. int memory_sort() 74. { 75. FILE* fin = fopen(m_in_file, "rt"); 76. int n = 0, file_count = 0;int *array = new int[m_count]; 77. 78. //每读入 m_count 个整数就在内存中做一次排序,并写入临时文件 79. while(( n = read_data(fin, array, m_count)) > 0) 80. { 81. qsort(array, n, sizeof(int), cmp_int); //这里,调用了库函数阿,在 第四节的 c 实现里,不再调 qsort。 82. char *fileName = temp_filename(file_count++); 83. FILE *tempFile = fopen(fileName, "w"); 84. free(fileName); 85. write_data(tempFile, array, n); 86. fclose(tempFile); 87. } 88. delete [] array; 89. fclose(fin); 90. return file_count; 91. } 92. 93. void merge_sort(int file_count) 258 94. { 95. if(file_count <= 0) 96. return; 97. //归并临时文件 FILE *fout = fopen(m_out_file, "wt"); 98. FILE* *farray = new FILE*[file_count]; 99. int i; 100. for(i = 0; i < file_count; ++i) 101. { 102. char* fileName = temp_filename(i); 103. farray[i] = fopen(fileName, "rt"); 104. free(fileName); 105. } 106. int *data = new int[file_count]; 107. //存储每个文件当前的一个数字 108. bool *hasNext = new bool[file_count]; 109. //标记文件是否读完 110. memset(data, 0, sizeof(int) * file_count); 111. memset(hasNext, 1, sizeof(bool) * file_count); 112. for(i = 0; i < file_count; ++i) 113. { 114. if(fscanf(farray[i], "%d", &data[i]) == EOF) 115. //读每个文件的第一个数到 data 数组 116. hasNext[i] = false; 117. } 118. 119. while(true) 120. { 121. //求 data 中可用的最小的数字,并记录对应文件的索引 122. int min = data[0]; 123. int j = 0; 124. while (j < file_count && !hasNext[j]) 125. j++; 126. if (j >= file_count) 127. //没有可取的数字,终止归并 128. break; 129. for(i = j + 1; i < file_count; ++i) 130. { 131. if(hasNext[i] && min > data[i]) 132. { 133. min = data[i]; 134. j = i; 135. } 136. } 137. if(fscanf(farray[j], "%d", &data[j]) == EOF) 259 138. //读取文件的下一个元素 139. hasNext[j] = false; 140. fprintf(fout, "%d ", min); 141. } 142. 143. delete [] hasNext; 144. delete [] data; 145. for(i = 0; i < file_count; ++i) 146. { 147. fclose(farray[i]); 148. } 149. delete [] farray; 150. fclose(fout); 151. } 152. }; 153. #endif 154. 155. //测试主函数文件 156. /** 大文件排序* 数据不能一次性全部装入内存* 排序文件里有多个整数,整数之间用空格隔开 */ 157. 158. const unsigned int count = 10000000; 159. // 文件里数据的行数 const unsigned int number_to_sort = 1000000; 160. //在内存中一次排序的数量 161. const char *unsort_file = "unsort_data.txt"; 162. //原始未排序的文件名 163. const char *sort_file = "sort_data.txt"; 164. //已排序的文件名 165. void init_data(unsigned int num); 166. 167. //随机生成数据文件 168. 169. int main(int argc, char* *argv) 170. { 171. srand(time(NULL)); 172. init_data(count); 173. ExternSort extSort(unsort_file, sort_file, number_to_sort); 174. extSort.sort(); 175. system("pause"); 176. return 0; 177. } 178. 179. void init_data(unsigned int num) 180. { 260 181. FILE* f = fopen(unsort_file, "wt"); 182. for(int i = 0; i < num; ++i) 183. fprintf(f, "%d ", rand()); 184. fclose(f); 185. } 程序测试:读者可以继续用小文件小数据量进一步测试。 第三节、磁盘文件排序的编程实现 ok,接下来,我们来编程实现上述磁盘文件排序的问题,本程序由两部分构成: 1、内存排序 由于要求的可用内存为 1MB,那么每次可以在内存中对 250K 的数据进行排序,然后将有 序的数写入硬盘。 261 那么 10M 的数据需要循环 40 次,最终产生 40 个有序的文件。 2、归并排序 1. 将每个文件最开始的数读入(由于有序,所以为该文件最小数),存放在一个大小为 40 的 first_data 数组中; 2. 选择 first_data 数组中最小的数 min_data,及其对应的文件索引 index; 3. 将 first_data 数组中最小的数写入文件 result,然后更新数组 first_data(根据 index 读取该文件下一个数代替 min_data); 4. 判断是否所有数据都读取完毕,否则返回 2。 所以,本程序按顺序分两步,第一步、Memory Sort,第二步、Merge Sort。程序的流程图, 如下图所示(感谢 F 的绘制)。 262 然后,编写的完整代码如下: 263 1. //copyright@ yansha 2. //July、updated,2011.05.28。 3. #include 4. #include 5. #include 6. #include 7. using namespace std; 8. 9. int sort_num = 10000000; 10. int memory_size = 250000; 11. 12. //每次只对 250k 个小数据量进行排序 13. int read_data(FILE *fp, int *space) 14. { 15. int index = 0; 16. while (index < memory_size && fscanf(fp, "%d ", &space[index]) != EOF) 17. index++; 18. return index; 19. } 20. 21. void write_data(FILE *fp, int *space, int num) 22. { 23. int index = 0; 24. while (index < num) 25. { 26. fprintf(fp, "%d ", space[index]); 27. index++; 28. } 29. } 30. 31. // check the file pointer whether valid or not. 32. void check_fp(FILE *fp) 33. { 34. if (fp == NULL) 35. { 36. cout << "The file pointer is invalid!" << endl; 37. exit(1); 38. } 39. } 40. 41. int compare(const void *first_num, const void *second_num) 42. { 43. return *(int *)first_num - *(int *)second_num; 44. } 264 45. 46. string new_file_name(int n) 47. { 48. char file_name[20]; 49. sprintf(file_name, "data%d.txt", n); 50. return file_name; 51. } 52. 53. int memory_sort() 54. { 55. // open the target file. 56. FILE *fp_in_file = fopen("data.txt", "r"); 57. check_fp(fp_in_file); 58. int counter = 0; 59. while (true) 60. { 61. // allocate space to store data read from file. 62. int *space = new int[memory_size]; 63. int num = read_data(fp_in_file, space); 64. // the memory sort have finished if not numbers any more. 65. if (num == 0) 66. break; 67. 68. // quick sort. 69. qsort(space, num, sizeof(int), compare); 70. // create a new auxiliary file name. 71. string file_name = new_file_name(++counter); 72. FILE *fp_aux_file = fopen(file_name.c_str(), "w"); 73. check_fp(fp_aux_file); 74. 75. // write the orderly numbers into auxiliary file. 76. write_data(fp_aux_file, space, num); 77. fclose(fp_aux_file); 78. delete []space; 79. } 80. fclose(fp_in_file); 81. 82. // return the number of auxiliary files. 83. return counter; 84. } 85. 86. void merge_sort(int file_num) 87. { 88. if (file_num <= 0) 265 89. return; 90. // create a new file to store result. 91. FILE *fp_out_file = fopen("result.txt", "w"); 92. check_fp(fp_out_file); 93. 94. // allocate a array to store the file pointer. 95. FILE **fp_array = new FILE *[file_num]; 96. int i; 97. for (i = 0; i < file_num; i++) 98. { 99. string file_name = new_file_name(i + 1); 100. fp_array[i] = fopen(file_name.c_str(), "r"); 101. check_fp(fp_array[i]); 102. } 103. 104. int *first_data = new int[file_num]; 105. //new 出个大小为 0.1 亿/250k 数组,由指针 first_data 指示数组首地址 106. bool *finish = new bool[file_num]; 107. memset(finish, false, sizeof(bool) * file_num); 108. 109. // read the first number of every auxiliary file. 110. for (i = 0; i < file_num; i++) 111. fscanf(fp_array[i], "%d ", &first_data[i]); 112. while (true) 113. { 114. int index = 0; 115. while (index < file_num && finish[index]) 116. index++; 117. 118. // the finish condition of the merge sort. 119. if (index >= file_num) 120. break; 121. //主要的修改在上面两行代码,就是 merge sort 结束条件。 122. //要保证所有文件都读完,必须使得 finish[0]...finish[40]都为真 123. //July、yansha,555,2011.05.29。 124. 125. int min_data = first_data[index]; 126. // choose the relative minimum in the array of first_data. 127. for (i = index + 1; i < file_num; i++) 128. { 129. if (min_data > first_data[i] && !finish[i]) 130. //一旦发现比 min_data 更小的数据 first_data[i] 131. { 132. min_data = first_data[i]; 266 133. //则置 min_data<-first_data[i]index = i; 134. //把下标 i 赋给 index。 135. } 136. } 137. 138. // write the orderly result to file. 139. fprintf(fp_out_file, "%d ", min_data); 140. if (fscanf(fp_array[index], "%d ", &first_data[index]) == EOF) 141. finish[index] = true; 142. } 143. 144. fclose(fp_out_file); 145. delete []finish; 146. delete []first_data; 147. for (i = 0; i < file_num; i++) 148. fclose(fp_array[i]); 149. delete [] fp_array; 150. } 151. 152. int main() 153. { 154. clock_t start_memory_sort = clock(); 155. int aux_file_num = memory_sort(); 156. clock_t end_memory_sort = clock(); 157. cout << "The time needs in memory sort: " << end_memory_sort - start_mem ory_sort << endl; 158. clock_t start_merge_sort = clock(); 159. merge_sort(aux_file_num); 160. clock_t end_merge_sort = clock(); 161. cout << "The time needs in merge sort: " << end_merge_sort - start_merge _sort << endl; 162. system("pause"); 163. return 0; 164. } 其中,生成数据文件 data.txt 的代码在第一节已经给出。 程序测试: 267 1、咱们对 1000W 数据进行测试,打开半天没看到数据, 2、编译运行上述程序后,data 文件先被分成 40 个小文件 data[1....40],然后程序再对这 40 个小文件进行归并排序,排序结果最终生成在 result 文件中,自此 result 文件中便是由 data 文件的数据经排序后得到的数据。 3、且,我们能看到,data[i],i=1...40 的每个文件都是有序的,如下图: 268 4、最终的运行结果,如下,单位统一为 ms: 269 由上观之,我们发现,第一节的位图方案的程序效率是最快的,约为 14s,而采用上述 的多路归并算法的程序运行时间约为 25s。时间主要浪费在读写磁盘 IO 上,且程序中用的 库函数 qsort 也耗费了不少时间。所以,总的来说,采取位图方案是最佳方案。 小数据量测试: 我们下面针对小数据量的文件再测试一次,针对 20 个小数据,每趟对 4 个数据进行排序,即 5 路归并,程序的排序结果如下图所示。 运行时间: 0ms,可以忽略不计了,毕竟是对 20 个数的小数据量进行排序: 270 沙海拾贝: 我们不在乎是否能把一个软件产品或一本书最终完成,我们更在乎的是,在 完成这个产品或创作这本书的过程中,读者学到了什么,能学到什么?所以,不 要一味的马上就想得到一道题目的正确答案,请跟着我们一起逐步走向山巅。 第四节、多路归并算法的 c 实现 本多路归并算法的 c 实现原理与上述 c++实现一致,不同的地方体现在一些细节处理上, 且对临时文件的排序,不再用系统提供的快排,即上面的 qsort 库函数,是采用的三数中值 的快速排序(个数小于 3 用插入排序)的。而我们知道,纯正的归并排序其实就是比较排序, 在归并过程中总是不断的比较,为了从两个数中挑小的归并到最终的序列中。ok,此程序的 详情请看: 1. //copyright@ 555 2. //July、2011.05.29。 3. #include 4. #include 5. #include 6. #include 7. #include 8. 9. void swap_int(int* a,int* b) 10. { 11. int c; 271 12. c = *a; 13. *a = *b; 14. *b = c; 15. } 16. 17. //插入排序 18. void InsertionSort(int A[],int N) 19. { 20. int j,p; 21. int tmp; 22. for(p = 1; p < N; p++) 23. { 24. tmp = A[p]; 25. for(j = p;j > 0 && A[j - 1] >tmp;j--) 26. { 27. A[j] = A[j - 1]; 28. } 29. 30. A[j] = tmp; 31. } 32. } 33. 34. //三数取中分割法 35. int Median3(int A[],int Left,int Right) 36. { 37. int Center = (Left + Right) / 2; 38. if (A[Left] > A[Center]) 39. swap_int(&A[Left],&A[Center]); 40. if (A[Left] > A[Right]) 41. swap_int(&A[Left],&A[Right]); 42. if (A[Center] > A[Right]) 43. swap_int(&A[Center],&A[Right]); 44. swap_int(&A[Center],&A[Right - 1]); 45. return A[Right - 1]; 46. } 47. 48. //快速排序 49. void QuickSort(int A[],int Left,int Right) 50. { 51. int i,j; 52. int Pivot; 53. const int Cutoff = 3; 54. if (Left + Cutoff <= Right) 55. { 272 56. Pivot = Median3(A,Left,Right); 57. i = Left; 58. j = Right - 1; 59. while (1) 60. { 61. while(A[++i] < Pivot){;} 62. while(A[--j] > Pivot){;} 63. if (i < j) 64. swap_int(&A[i],&A[j]); 65. else 66. break; 67. } 68. swap_int(&A[i],&A[Right - 1]); 69. 70. QuickSort(A,Left,i - 1); 71. QuickSort(A,i + 1,Right); 72. } 73. else 74. { 75. InsertionSort(A+Left,Right - Left + 1); 76. } 77. } 78. 79. //const int KNUM = 40; 80. //分块数 81. const int NUMBER = 10000000; 82. //输入文件最大读取的整数的个数 83. //为了便于测试,我决定改成小文件小数据量进行测试。 84. const int KNUM = 4; 85. //分块数 const int NUMBER = 100; 86. //输入文件最大读取的整数的个数 87. const char *in_file = "infile.txt"; 88. const char *out_file = "outfile.txt"; 89. //#define OUTPUT_OUT_FILE_DATA 90. //数据量大的时候,没必要把所有的数全部打印出来,所以可以把上面这句注释掉。 91. void gen_infile(int n) 92. { 93. int i; 94. FILE *f = fopen(in_file, "wt"); 95. for(i = 0;i < n; i++) 96. fprintf(f,"%d ",rand()); 97. fclose(f); 98. } 99. 273 100. int read_data(FILE *f,int a[],int n) 101. { 102. int i = 0; 103. while ((i < n) && (fscanf(f,"%d",&a[i]) != EOF)) 104. i++; 105. printf("read: %d integer/n",i); 106. return i; 107. } 108. 109. void write_data(FILE *f,int a[],int n) 110. { 111. int i;for(i = 0; i< n;i++) 112. fprintf(f,"%d ",a[i]); 113. } 114. 115. char* temp_filename(int index) 116. { 117. char *tempfile = (char*) malloc(64*sizeof(char)); 118. assert(tempfile); 119. sprintf(tempfile, "temp%d.txt", index); 120. return tempfile; 121. } 122. 123. //K 路串行读取 124. void k_num_read(void) 125. { 126. char* filename; 127. int i,cnt,*array; 128. FILE* fin; 129. FILE* tmpfile; 130. //计算 knum,每路应读取的整数个数 int n = NUMBER/KNUM; 131. if (n * KNUM < NUMBER)n++; 132. 133. //建立存储分块读取的数据的数组 134. array = (int*)malloc(n * sizeof(int));assert(array); 135. //打开输入文件 136. fin = fopen(in_file,"rt"); 137. i = 0; 138. 139. //分块循环读取数据,并写入硬盘上的临时文件 140. while ( (cnt = read_data(fin,array,n))>0) 141. { 142. //对每次读取的数据,先进行快速排序,然后写入硬盘上的临时文件 143. QuickSort(array,0,cnt - 1); 274 144. filename = temp_filename(i++); 145. tmpfile = fopen(filename,"w"); 146. free(filename); 147. write_data(tmpfile,array,cnt); 148. fclose(tmpfile); 149. } 150. assert(i == KNUM); 151. //没有生成 K 路文件时进行诊断 152. //关闭输入文件句柄和临时存储数组 153. fclose(fin); 154. free(array); 155. } 156. 157. //k 路合并(败者树) 158. void k_num_merge(void) 159. { 160. FILE *fout; 161. FILE **farray; 162. char *filename; 163. int *data; 164. char *hasNext; 165. int i,j,m,min; 166. #ifdef OUTPUT_OUT_FILE_DATAint id; 167. #endif 168. //打开输出文件 169. fout = fopen(out_file,"wt"); 170. //打开各路临时分块文件 171. farray = (FILE**)malloc(KNUM*sizeof(FILE*)); 172. assert(farray); 173. for(i = 0; i< KNUM;i++) 174. { 175. filename = temp_filename(i); 176. farray[i] = fopen(filename,"rt"); 177. free(filename); 178. } 179. 180. //建立 KNUM 个元素的 data,hasNext 数组,存储 K 路文件的临时数组和读取结束状态 181. data = (int*)malloc(KNUM*sizeof(int)); 182. assert(data); 183. hasNext = (char*)malloc(sizeof(char)*KNUM); 184. assert(hasNext); 185. memset(data, 0, sizeof(int) * KNUM); 186. memset(hasNext, 1, sizeof(char) * KNUM); 187. 275 188. //读 K 路文件先读取第一组数据,并对读取结束的各路文件设置不可再读状态 189. for(i = 0; i < KNUM; i++) 190. { 191. if(fscanf(farray[i], "%d", &data[i]) == EOF) 192. { 193. hasNext[i] = 0; 194. } 195. } 196. 197. //读取各路文件,利用败者树从小到大输出到输出文件 198. #ifdef OUTPUT_OUT_FILE_DATAid = 0; 199. #endif 200. 201. j = 0;F_LOOP: 202. if (j < KNUM) 203. //以下这段代码嵌套过深,日后应尽量避免此类问题。 204. { 205. while(1==1) 206. { 207. min = data[j]; 208. m = j; 209. for(i = j+1; i < KNUM; i++) 210. { 211. if(hasNext[i] == 1 && min > data[i]) 212. { 213. min = data[i];m = i; 214. } 215. } 216. 217. if(fscanf(farray[m], "%d", &data[m]) == EOF) 218. { 219. hasNext[m] = 0; 220. } 221. fprintf(fout, "%d ", min); 222. #ifdef OUTPUT_OUT_FILE_DATAprintf("fout :%d %d/n",++id,min); 223. #endif 224. if (m == j && hasNext[m] == 0) 225. { 226. for (i = j+1; i < KNUM; i++) 227. { 228. if (hasNext[m] != hasNext[i]) 229. { 230. m = i; 231. //第 i 个文件未读完,从第 i 个继续往下读 276 232. break; 233. } 234. } 235. if (m != j) 236. { 237. j = m; 238. goto F_LOOP; 239. } 240. break; 241. } 242. } 243. } 244. 245. //关闭分配的数据和数组 246. free(hasNext); 247. free(data); 248. for(i = 0; i < KNUM; ++i) 249. { 250. fclose(farray[i]); 251. } 252. free(farray); 253. fclose(fout); 254. } 255. 256. int main() 257. { 258. time_t start = time(NULL),end,start_read,end_read,start_merge,end_merge; 259. gen_infile(NUMBER); 260. end = time(NULL); 261. printf("gen_infile data time:%f/n", (end - start) * 1000.0/ CLOCKS_PER_S EC); 262. start_read = time(NULL);k_num_read(); 263. end_read = time(NULL); 264. printf("k_num_read time:%f/n", (end_read - start_read) * 1000.0/ CLOCKS_ PER_SEC); 265. start_merge = time(NULL); 266. k_num_merge(); 267. end_merge = time(NULL); 268. printf("k_num_merge time:%f/n", (end_merge - start_merge) * 1000.0/ CLOC KS_PER_SEC); 269. end = time(NULL); 270. printf("total time:%f/n", (end - start) * 1000.0/ CLOCKS_PER_SEC); 271. return 0; 277 272. } 程序测试: 在此,我们先测试下对 10000000 个数据的文件进行 40 趟排序,然后再对 100 个数据的文 件进行 4 趟排序(读者可进一步测试)。如弄几组小点的数据,输出 ID 和数据到屏幕,再看 程序运行效果。 1. 10 个数, 4 组 2. 40 个数, 5 组 3. 55 个数, 6 组 4. 100 个数, 7 组 278 (备注:1、以上所有各节的程序运行环境为 windows xp + vc6.0 + e5200 cpu 2.5g 主频,2、 感谢 5 为本文程序所作的大量测试工作) 全文总结: 1、关于本章中位图和多路归并两种方案的时间复杂度及空间复杂度的比较,如下: 时间复杂度 空间复杂度 位图 O(N) 0.625M 多位归并 O(Nlogn) 1M (多路归并,时间复杂度为 O(k*n/k*logn/k ),严格来说,还要加上读写磁盘的时间,而 此算法绝大部分时间也是浪费在这上面) 2、bit-map 适用范围:可进行数据的快速查找,判重,删除,一般来说数据范围是 int 的 10 倍以下 基本原理及要点:使用 bit 数组来表示某些元素是否存在,比如 8 位电话号码 扩展:bloom filter 可以看做是对 bit-map 的扩展 279 问题实例: 1)已知某个文件内包含一些电话号码,每个号码为 8 位数字,统计不同号码的个数。 8 位最多 99 999 999,大概需要 99m 个 bit,大概 10 几 m 字节的内存即可。 2)2.5 亿个整数中找出不重复的整数的个数,内存空间不足以容纳这 2.5 亿个整数。 将 bit-map 扩展一下,用 2bit 表示一个数即可,0 表示未出现,1 表示出现一次,2 表示出 现 2 次及以上。或者我们不用 2bit 来进行表示,我们用两个 bit-map 即可模拟实现这个 2bit-map。 3、[外排序适用范围]大数据的排序,去重基本原理及要点:外排序的归并方法,置换选择 败者树原理,最优归并树扩展。问题实例:1).有一个 1G 大小的一个文件,里面每一行是一 个词,词的大小不超过 16 个字节,内存限制大小是 1M。返回频数最高的 100 个词。这个 数据具有很明显的特点,词的大小为 16 个字节,但是内存只有 1m 做 hash 有些不够,所 以可以用来排序。内存可以当输入缓冲区使用。 4、海量数据处理 有关海量数据处理的方法或面试题可参考此文,十道海量数据处理面试题与十个方法大 总结。日后,会逐步实现这十个处理海量数据的方法。同时,送给各位一句话,解决问题的 关键在于熟悉一个算法,而不是某一个问题。熟悉了一个算法,便通了一片题目。 本章完。 updated:有一读者朋友针对本文写了一篇文章为,海量数据多路归并排序的 c++实现 (归并时利用了败者树),地址为: http://www.cnblogs.com/harryshayne/archive/2011/07/02/2096196.html。谢谢,欢 迎参考。 版权所有,本人对本 blog 内所有任何内容享有版权及著作权。网络转载,请以链接形式注 明出处。 280 第十一章:最长公共子序列(LCS)问题 前言 程序员编程艺术系列重新开始创作了(前十章,请参考程序员编程艺术第一~十章集锦与 总结)。回顾之前的前十章,有些代码是值得商榷的,因当时的代码只顾阐述算法的原理或 思想,所以,很多的与代码规范相关的问题都未能做到完美。日后,会着力修善之。 搜遍网上,讲解这个 LCS 问题的文章不计其数,但大多给读者一种并不友好的感觉,稍 感晦涩,且代码也不够清晰。本文力图避免此些情况。力保通俗,阐述详尽。同时,经典算 法研究系列的第三章(三、dynamic programming)也论述了此 LCS 问题。有任何问题, 欢迎不吝赐教。 第一节、问题描述 什么是最长公共子序列呢?好比一个数列 S,如果分别是两个或多个已知数列的子序列, 且是所有符合此条件序列中最长的,则 S 称为已知序列的最长公共子序列。 举个例子,如:有两条随机序列,如 1 3 4 5 5 ,and 2 4 5 5 7 6,则它们的最长公共子 序列便是:4 5 5。 注意最长公共子串(Longest CommonSubstring)和最长公共子序列(LongestCommon Subsequence, LCS)的区别:子串(Substring)是串的一个连续的部分,子序列 (Subsequence)则是从不改变序列的顺序,而从序列中去掉任意的元素而获得的新序列; 更简略地说,前者(子串)的字符的位置必须连续,后者(子序列 LCS)则不必。比如字 符串 acdfg 同 akdfc 的最长公共子串为 df,而他们的最长公共子序列是 adf。LCS 可以使用 动态规划法解决。下文具体描述。 第二节、LCS 问题的解决思路  穷举法 解最长公共子序列问题时最容易想到的算法是穷举搜索法,即对 X 的每一个子序列,检 查它是否也是 Y 的子序列,从而确定它是否为 X 和 Y 的公共子序列,并且在检查过程中选 出最长的公共子序列。X 和 Y 的所有子序列都检查过后即可求出 X 和 Y 的最长公共子序列。 281 X 的一个子序列相应于下标序列{1, 2, …, m}的一个子序列,因此,X 共有 2m 个不同子序列 (Y 亦如此,如为 2^n),从而穷举搜索法需要指数时间(2^m * 2^n)。  动态规划算法 事实上,最长公共子序列问题也有最优子结构性质。 记: Xi=﹤x1,⋯,xi﹥即 X 序列的前 i 个字符 (1≤i≤m)(前缀) Yj=﹤y1,⋯,yj﹥即 Y 序列的前 j 个字符 (1≤j≤n)(前缀) 假定 Z=﹤z1,⋯,zk﹥∈LCS(X , Y)。  若 xm=yn(最后一个字符相同),则不难用反证法证明:该字符必是 X 与 Y 的任 一最长公共子序列 Z(设长度为 k)的最后一个字符,即有 zk = xm = yn 且显然有 Zk-1∈LCS(Xm-1 , Yn-1)即 Z 的前缀 Zk-1 是 Xm-1 与 Yn-1 的最长公共子序列。此 时,问题化归成求 Xm-1 与 Yn-1 的 LCS(LCS(X , Y)的长度等于 LCS(Xm-1 , Yn-1) 的长度加 1)。  若xm≠yn,则亦不难用反证法证明:要么Z∈LCS(Xm-1, Y),要么Z∈LCS(X , Yn-1)。 由于 zk≠xm 与 zk≠yn 其中至少有一个必成立,若 zk≠xm 则有 Z∈LCS(Xm-1 , Y), 类似的,若 zk≠yn 则有 Z∈LCS(X , Yn-1)。此时,问题化归成求 Xm-1 与 Y 的 LCS 及X与Yn-1的LCS。LCS(X , Y)的长度为:max{LCS(Xm-1 , Y)的长度, LCS(X , Yn-1) 的长度}。 由于上述当 xm≠yn 的情况中,求 LCS(Xm-1 , Y)的长度与 LCS(X , Yn-1)的长度,这两 个问题不是相互独立的:两者都需要求 LCS(Xm-1,Yn-1)的长度。另外两个序列的 LCS 中 包含了两个序列的前缀的 LCS,故问题具有最优子结构性质考虑用动态规划法。 也就是说,解决这个 LCS 问题,你要求三个方面的东西:1、LCS(Xm-1,Yn-1)+1; 2、LCS(Xm-1,Y),LCS(X,Yn-1);3、max{LCS(Xm-1,Y),LCS(X,Yn-1)}。 行文至此,其实对这个 LCS 的动态规划解法已叙述殆尽,不过,为了成书的某种必要性, 下面,我试着再多加详细阐述这个问题。 282 第三节、动态规划算法解 LCS 问题 3.1、最长公共子序列的结构 最长公共子序列的结构有如下表示: 设序列 X=和 Y=的一个最长公共子序列 Z=, 则: 1. 若 xm=yn,则 zk=xm=yn 且 Zk-1 是 Xm-1 和 Yn-1 的最长公共子序列; 2. 若 xm≠yn 且 zk≠xm ,则 Z 是 Xm-1 和 Y 的最长公共子序列; 3. 若 xm≠yn 且 zk≠yn ,则 Z 是 X 和 Yn-1 的最长公共子序列。 其中 Xm-1=,Yn-1=,Zk-1=。 3、2.子问题的递归结构 由最长公共子序列问题的最优子结构性质可知,要找出 X=和Y=的最长公共子序列,可按以下方式递归地进行:当 xm=yn 时,找出 Xm-1 和 Yn-1 的最长公 共子序列,然后在其尾部加上 xm(=yn)即可得 X 和 Y 的一个最长公共子序列。当 xm≠yn 时, 必须解两个子问题,即找出 Xm-1 和 Y 的一个最长公共子序列及 X 和 Yn-1 的一个最长公共子 序列。这两个公共子序列中较长者即为 X 和 Y 的一个最长公共子序列。 由此递归结构容易看到最长公共子序列问题具有子问题重叠性质。例如,在计算 X 和 Y 的最长公共子序列时,可能要计算出 X 和 Yn-1 及 Xm-1 和 Y 的最长公共子序列。而这两个子 问题都包含一个公共子问题,即计算 Xm-1 和 Yn-1 的最长公共子序列。 与矩阵连乘积最优计算次序问题类似,我们来建立子问题的最优值的递归关系。用 c[i,j] 记录序列 Xi 和 Yj 的最长公共子序列的长度。其中 Xi=,Yj=。当 i=0 或 j=0 时,空序列是 Xi 和 Yj 的最长公共子序列,故 c[i,j]=0。其他情况下,由定理可建 立递归关系如下: 283 3、3.计算最优值 直接利用上节节末的递归式,我们将很容易就能写出一个计算 c[i,j]的递归算法,但其计 算时间是随输入长度指数增长的。由于在所考虑的子问题空间中,总共只有 θ(m*n)个不同 的子问题,因此,用动态规划算法自底向上地计算最优值能提高算法的效率。 计算最长公共子序列长度的动态规划算法 LCS_LENGTH(X,Y)以序列 X= 和 Y=作为输入。输出两个数组 c[0..m ,0..n]和 b[1..m ,1..n]。其中 c[i,j]存储 Xi 与 Yj 的最长公共子序列的长度,b[i,j]记录指示 c[i,j]的值是由哪一个子问题的解达到的,这 在构造最长公共子序列时要用到。最后,X 和 Y 的最长公共子序列的长度记录于 c[m,n]中。 1. Procedure LCS_LENGTH(X,Y); 2. begin 3. m:=length[X]; 4. n:=length[Y]; 5. for i:=1 to m do c[i,0]:=0; 6. for j:=1 to n do c[0,j]:=0; 7. for i:=1 to m do 8. for j:=1 to n do 9. if x[i]=y[j] then 10. begin 11. c[i,j]:=c[i-1,j-1]+1; 12. b[i,j]:="↖"; 13. end 14. else if c[i-1,j]≥c[i,j-1] then 15. begin 16. c[i,j]:=c[i-1,j]; 17. b[i,j]:="↑"; 18. end 19. else 20. begin 21. c[i,j]:=c[i,j-1]; 22. b[i,j]:="←" 23. end; 24. return(c,b); 25. end; 由算法 LCS_LENGTH 计算得到的数组 b 可用于快速构造序列 X=和 Y=的最长公共子序列。首先从 b[m,n]开始,沿着其中的箭头所指的方向在数 组 b 中搜索。 284  当 b[i,j]中遇到"↖"时(意味着 xi=yi 是 LCS 的一个元素),表示 Xi 与 Yj 的最长公共 子序列是由 Xi-1 与 Yj-1 的最长公共子序列在尾部加上 xi 得到的子序列;  当 b[i,j]中遇到"↑"时,表示 Xi 与 Yj 的最长公共子序列和 Xi-1 与 Yj 的最长公共子序列 相同;  当 b[i,j]中遇到"←"时,表示 Xi 与 Yj 的最长公共子序列和 Xi 与 Yj-1 的最长公共子序列 相同。 这种方法是按照反序来找 LCS 的每一个元素的。由于每个数组单元的计算耗费 Ο(1)时 间,算法 LCS_LENGTH 耗时 Ο(mn)。 3、4.构造最长公共子序列 下面的算法 LCS(b,X,i,j)实现根据 b 的内容打印出 Xi 与 Yj 的最长公共子序列。通过算法 的调用 LCS(b,X,length[X],length[Y]),便可打印出序列 X 和 Y 的最长公共子序列。 1. Procedure LCS(b,X,i,j); 2. begin 3. if i=0 or j=0 then return; 4. if b[i,j]="↖" then 5. begin 6. LCS(b,X,i-1,j-1); 7. print(x[i]); {打印 x[i]} 8. end 9. else if b[i,j]="↑" then LCS(b,X,i-1,j) 10. else LCS(b,X,i,j-1); 11. end; 在算法 LCS 中,每一次的递归调用使 i 或 j 减 1,因此算法的计算时间为 O(m+n)。 例如,设所给的两个序列为 X=和 Y=。 由算法 LCS_LENGTH 和 LCS 计算出的结果如下图所示: 285 我来说明下此图(参考算法导论)。在序列 X={A,B,C,B,D,A,B}和 Y={B,D, C,A,B,A}上,由 LCS_LENGTH 计算出的表 c 和 b。第 i 行和第 j 列中的方块包含了 c[i, j]的值以及指向 b[i,j]的箭头。在 c[7,6]的项 4,表的右下角为 X 和 Y 的一个 LCS的长度。对于 i,j>0,项 c[i,j]仅依赖于是否有 xi=yi,及项 c[i-1,j]和 c[i,j-1]的值,这 几个项都在 c[i,j]之前计算。为了重构一个 LCS 的元素,从右下角开始跟踪 b[i,j]的箭头即 可,这条路径标示为阴影,这条路径上的每一个“↖”对应于一个使 xi=yi 为一个 LCS 的成员 的项(高亮标示)。 所以根据上述图所示的结果,程序将最终输出:“B C B A”。 3、5.算法的改进 对于一个具体问题,按照一般的算法设计策略设计出的算法,往往在算法的时间和空间 需求上还可以改进。这种改进,通常是利用具体问题的一些特殊性。 例如,在算法 LCS_LENGTH 和 LCS 中,可进一步将数组 b 省去。事实上,数组元素 c[i,j]的值仅由 c[i-1,j-1],c[i-1,j]和 c[i,j-1]三个值之一确定,而数组元素 b[i,j]也只是用来指示 c[i,j]究竟由哪个值确定。因此,在算法 LCS 中,我们可以不借助于数组 b 而借助于数组 c 本身临时判断 c[i,j]的值是由 c[i-1,j-1],c[i-1,j]和 c[i,j-1]中哪一个数值元素所确定,代价是 Ο(1) 时间。既然 b 对于算法 LCS 不是必要的,那么算法 LCS_LENGTH 便不必保存它。这一来, 可节省 θ(mn)的空间,而 LCS_LENGTH 和 LCS 所需要的时间分别仍然是 Ο(mn)和 Ο(m+n)。 286 不过,由于数组 c 仍需要 Ο(mn)的空间,因此这里所作的改进,只是在空间复杂性的常数 因子上的改进。 另外,如果只需要计算最长公共子序列的长度,则算法的空间需求还可大大减少。事实 上,在计算 c[i,j]时,只用到数组 c 的第 i 行和第 i-1 行。因此,只要用 2 行的数组空间就可 以计算出最长公共子序列的长度。更进一步的分析还可将空间需求减至 min(m, n)。 第四节、编码实现 LCS 问题 动态规划的一个计算最长公共子序列的方法如下,以两个序列 X、Y 为例子: 设有二维数组 f[i][j] 表示 X 的 i 位和 Y 的 j 位之前的最长公共子序列的长度,则有: f[1][1] = same(1,1) f[i][j] = max{f[i − 1][j − 1] +same(i,j), f[i − 1][j] ,f[i][j − 1]} 其中,same(a,b)当 X 的第 a 位与 Y 的第 b 位完全相同时为“1”,否则为“0”。 此时,f[i][j]中最大的数便是 X 和 Y 的最长公共子序列的长度,依据该数组回溯,便可找出 最长公共子序列。 该算法的空间、时间复杂度均为 O(n2),经过优化后,空间复杂度可为 O(n),时间复杂度为 O(nlogn)。 以下是此算法的 java 代码: 1. 2. import java.util.Random; 3. 4. public class LCS{ 5. public static void main(String[] args){ 6. 7. //设置字符串长度 8. int substringLength1 = 20; 9. int substringLength2 = 20; //具体大小可自行设置 10. 11. // 随机生成字符串 12. String x = GetRandomStrings(substringLength1); 13. String y = GetRandomStrings(substringLength2); 14. 15. Long startTime = System.nanoTime(); 287 16. // 构造二维数组记录子问题 x[i]和 y[i]的 LCS 的长度 17. int[][] opt = new int[substringLength1 + 1][substringLength2 + 1]; 18. 19. // 动态规划计算所有子问题 20. for (int i = substringLength1 - 1; i >= 0; i--){ 21. for (int j = substringLength2 - 1; j >= 0; j--){ 22. if (x.charAt(i) == y.charAt(j)) 23. opt[i][j] = opt[i + 1][j + 1] + 1; //参考上文我给的公式。 24. else 25. opt[i][j] = Math.max(opt[i + 1][j], opt[i][j + 1]); //参考上文我给的公式。 26. } 27. } 28. 29. -------------------------------------------------------------------- ----------------- 30. 31. 理解上段,参考上文我给的公式: 32. 33. 根据上述结论,可得到以下公式, 34. 35. 如果我们记字符串 Xi 和 Yj 的 LCS 的长度为 c[i,j],我们可以递归地求 c[i,j]: 36. 37. / 0 if i<0 or j<0 38. c[i,j]= c[i-1,j-1]+1 if i,j>=0 and xi=xj 39. / max(c[i,j-1],c[i-1,j] if i,j>=0 and xi≠xj 40. 41. -------------------------------------------------------------------- ----------------- 42. 43. System.out.println("substring1:"+x); 44. System.out.println("substring2:"+y); 45. System.out.print("LCS:"); 46. 47. int i = 0, j = 0; 48. while (i < substringLength1 && j < substringLength2){ 49. if (x.charAt(i) == y.charAt(j)){ 50. System.out.print(x.charAt(i)); 51. i++; 52. j++; 53. } else if (opt[i + 1][j] >= opt[i][j + 1]) 288 54. i++; 55. else 56. j++; 57. } 58. Long endTime = System.nanoTime(); 59. System.out.println(" Totle time is " + (endTime - startTime) + " ns" ); 60. } 61. 62. //取得定长随机字符串 63. public static String GetRandomStrings(int length){ 64. StringBuffer buffer = new StringBuffer("abcdefghijklmnopqrstuvwxyz") ; 65. StringBuffer sb = new StringBuffer(); 66. Random r = new Random(); 67. int range = buffer.length(); 68. for (int i = 0; i < length; i++){ 69. sb.append(buffer.charAt(r.nextInt(range))); 70. } 71. return sb.toString(); 72. } 73. } 第五节、改进的算法 下面咱们来了解一种不同于动态规划法的一种新的求解最长公共子序列问题的方法,该 算法主要是把求解公共字符串问题转化为求解矩阵 L(p,m)的问题,在利用定理求解矩阵的 元素过程中(1)while(i i L (k-1)。 矩阵中元素 L(k,i)=Li(k),这里(1L[k-1][i]){ 11. L[k][i+1]=(j>27 = 0x11 = 3 ip1 的其余 27 位是 value1 = ip1 &0x07ffffff = 0x074e2342 所以把 value1 保存在 tmp3 文件中. 由 id1 和 value1 可以还原成 ip1, 即 ip1 =(id1<<27)|value1 按照上面的方法可以得到 32 个临时文件,每个临时文件中的 IP 地址的取值范围属于 [0-128M),因此可以统计出每个 IP 地址的访问次数.从而找到访问次数最大的 IP 地址 程序源码: 299 test.cpp 是 c++源码. 1. #include 2. #include 3. #include 4. 5. using namespace std; 6. #define N 32 //临时文件数 7. 8. #define ID(x) (x>>27) //x 对应的文件编号 9. #define VALUE(x) (x&0x07ffffff) //x 在文件中保存的值 10. #define MAKE_IP(x,y) ((x<<27)|y) //由文件编号和值得到 IP 地址. 11. 12. #define MEM_SIZE 128*1024*1024 //需分配内存的大小 为 MEM_SIZE*sizeof(unsigned) 13. 14. char* data_path="D:/test/ip.dat"; //ip 数据 15. 16. //产生 n 个随机 IP 地址 17. void make_data(const int& n) 18. { 19. ofstream out(data_path,ios::out|ios::binary); 20. srand((unsigned)(time(NULL))); 21. if (out) 22. { 23. for (int i=0; i>1) - 1) >= 0 ? (n>>1) - 1 : 0; // m is themiddle point of s 10. first = s + m; second = s + n - 1 - m; 11. while (first >= s) 303 12. if (s[first--] !=s[second++]) return false; // not equal, so it's not apalindrome 13. return ture; // check over, it's a palindrome 14. } 代码略有些小技巧,不过相信我们聪明的读者已经看清了意思,这里就是从中间开始、 向两边扩展查看字符是否相等的一种方法,时空复杂度和上一个方法是一模一样的,既然一 样,那么我们为什么还需要这种方法呢?首先,世界的美存在于它的多样性;其次,我们很 快会看到,在某些回文问题里面,这个方法有着自己的独到之处,可以方便的解决一类问题。 那么除了直接用数组,我们还可以采用其他的数据结构来判断回文吗呢?请读者朋友稍 作休息想想看。相信我们聪明的读者肯定想到了不少好方法吧,也一定想到了经典的单链表 和栈这两种方法吧,这也是面试中常常出现的两种回文数据结构类型。 对于单链表结构,处理的思想不难想到:用两个指针从两端或者中间遍历并判断对应字 符是否相等。所以这里的关键就是如何朝两个方向遍历。单链表是单向的,所以要向两个方 向遍历不太容易。一个简单的方法是,用经典的快慢指针的方法,定位到链表的中间位置, 将链表的后半逆置,然后用两个指针同时从链表头部和中间开始同时遍历并比较即可。 对于栈就简单些,只需要将字符串全部压入栈,然后依次将各字符出栈,这样得到的就 是原字符串的逆置串,分别和原字符串各个字符比较,就可以判断了。 二、回文的应用 我们已经了解了回文的判断方法,接下来可以来尝试回文的其他应用了。回文不是很简 单的东西吗,还有其他应用?是的,比如:查找一个字符串中的最长回文字串 Hum,还是请读者朋友们先自己想想看看。嗯,有什么好方法了吗?枚举所有的子串, 分别判断其是否为回文?这个思路是正确的,但却做了很多无用功,如果一个长的子串包含 另一个短一些的子串,那么对子串的回文判断其实是不需要的。 那么如何高效的进行判断呢?既然对短的子串的判断和包含它的长的子串的判断重复 了,我们何不复用下短的子串的判断呢(哈,算法里也跑出软件工程了),让短的子串的判 断成为长的子串的判断的一个部分!想到怎么做了吗?Aha,没错,扩展法。从一个字符开 始,向两边扩展,看看最多能到多长,使其保持为回文。这也就是为什么我们在上一节里面 要提出 IsPalindrome2 的原因。 具体而言,我们可以枚举中心位置,然后再在该位置上用扩展法,记录并更新得到的最 长的回文长度,即为所求。代码如下: 304 1. /** 2. *find the longest palindrome in a string, n is the length of string s 3. *Copyright(C) fairywell 2011 4. */ 5. int LongestPalindrome(const char *s, int n) 6. { 7. int i, j, max; 8. if (s == 0 || n < 1) return 0; 9. max = 0; 10. for (i = 0; i < n; ++i) { // i is the middle point of the palindrome 11. for (j = 0; (i-j >= 0) && (i+j < n); ++j) // if the lengthof the pali ndrome is odd 12. if (s[i-j] != s[i+j]) break; 13. if (j*2+1 > max) max = j * 2 + 1; 14. for (j = 0; (i-j >= 0) && (i+j+1 < n); ++j) // for theeven case 15. if (s[i-j] != s[i+j+1]) break; 16. if (j*2+2 > max) max = j * 2 + 2; 17. } 18. return max; 19. } 代码稍微难懂一点的地方就是内层的两个 for 循环,它们分别对于以 i 为中心的,长度 为奇数和偶数的两种情况,整个代码遍历中心位置 i 并以之扩展,找出最长的回文。 当然,还有更先进但也更复杂的方法,比如用 s 和逆置 s' 的组合 s$s' 来建立后缀树 的方法也能找到最长回文,但构建的过程比较复杂,所以在实践中用的比较少,感兴趣的朋 友可以参考相应资料。 回文的内容还有不少,但主要的部分通过上面的内容相信大家已经掌握,希望大家能抓 住实质,在实践中灵活运用,回文的内容我就暂时介绍到这里了,谢谢大家--well。 附注:  如果读者对本文的内容或形式,语言和表达不甚满意。完全理解。我之前也跟编程 艺术室内的朋友们开玩笑的说:我暂不做任何修改,题目会标明为初稿。这样的话, 你们才会相信或者知晓,你们的才情只有通过我的语言和表达才能最大限度的发挥 出来,被最广泛的人轻而易举的所认同和接受(不过,以上 4 位兄弟的思维灵活度 都在本人之上)。呵呵,开个玩笑。  本文日后会抽取时间再做修补和完善。若有任何问题,欢迎随时不吝指正。谢谢。 完。 305 第十六~第二十章:全排列,跳台阶,奇偶排序,第一个只 出现一次等问题 作者:July 、 2011.10.16 。 出处:http://blog.csdn.net/v_JULY_v。 引言 最近这几天闲职在家,一忙着投简历,二为准备面试而搜集整理各种面试题。故常常关 注个人所建的 Algorithms1-14 群内朋友关于笔试,面试,宣讲会,offer,薪资的讨论以及 在群内发布的各种笔/面试题,常感言道:咱们这群人之前已经在学校受够了学校的那种应 试教育,如今出来找工作又得东奔西走去参加各种笔试/面试,着实亦不轻松。幻想,如果 在企业与求职者之间有个中间面试服务平台就更好了。 ok,闲话少扯。在上一篇文章中,已经说过,“个人正在针对那 100 题一题一题的写文 章,多种思路,不断优化,即成程序员编程艺术系列。”现本编程艺术系列继续开始创作, 你而后自会和我有同样的感慨:各种面试题千变万化,层出不穷,但基本类型,解决问题的 思路基本一致。 本文为程序员编程艺术第十六章~第二十章,包含以下 5 个问题: 1. 全排列; 2. 跳台阶; 3. 奇偶排序; 4. 第一个只出现一次的字符; 5. 一致性哈希算法。 同时,本文会在解答去年微软面试 100 题的部分题目时,尽量结合今年最近各大 IT 公 司最新的面试题来讲解,两相对比,彼此对照,相信你会更加赞同我上面的话。且本文也不 奢望读者能从中学到什么高深技术之类的东西,只求读者看此文看着舒服便可,通顺流畅以 致一口气读完而无任何压力。ok,有任何问题,欢迎不吝指正。谢谢。 306 第一部分、全排列问题 53.字符串的排列。 题目:输入一个字符串,打印出该字符串中字符的所有排列。 例如输入字符串 abc,则输出由字符 a、b、c 所能排列出来的所有字符串 abc、acb、bac、bca、cab 和 cba。 分析:此题最初整理于去年的微软面试 100 题中第 53 题,第二次整理于微软、Google 等公司非常好的面试题及解答[第 61-70 题] 第 67 题。无独有偶,这个问题今年又出现于今 年的 2011.10.09 百度笔试题中。ok,接下来,咱们先好好分析这个问题。  一、递归实现 从集合中依次选出每一个元素,作为排列的第一个元素,然后对剩余的元素进行全 排列,如此递归处理,从而得到所有元素的全排列。以对字符串 abc 进行全排列为 例,我们可以这么做:以 abc 为例 固定 a,求后面 bc 的排列:abc,acb,求好后,a 和 b 交换,得到 bac 固定 b,求后面 ac 的排列:bac,bca,求好后,c 放到第一位置,得到 cba 固定 c,求后面 ba 的排列:cba,cab。代码可如下编写所示: 1. template 2. void CalcAllPermutation_R(T perm[], int first, int num) 3. { 4. if (num <= 1) { 5. return; 6. } 7. 8. for (int i = first; i < first + num; ++i) { 9. swap(perm[i], perm[first]); 10. CalcAllPermutation_R(perm, first + 1, num - 1); 11. swap(perm[i], perm[first]); 12. } 13. } 或者如此编写,亦可: 1. void Permutation(char* pStr, char* pBegin); 2. 3. void Permutation(char* pStr) 4. { 5. Permutation(pStr, pStr); 6. } 7. 307 8. void Permutation(char* pStr, char* pBegin) 9. { 10. if(!pStr || !pBegin) 11. return; 12. 13. if(*pBegin == '\0') 14. { 15. printf("%s\n", pStr); 16. } 17. else 18. { 19. for(char* pCh = pBegin; *pCh != '\0'; ++ pCh) 20. { 21. // swap pCh and pBegin 22. char temp = *pCh; 23. *pCh = *pBegin; 24. *pBegin = temp; 25. 26. Permutation(pStr, pBegin + 1); 27. // restore pCh and pBegin 28. temp = *pCh; 29. *pCh = *pBegin; 30. *pBegin = temp; 31. } 32. } 33. }  二、字典序排列 把升序的排列(当然,也可以实现为降序)作为当前排列开始,然后依次计算当前 排列的下一个字典序排列。 对当前排列从后向前扫描,找到一对为升序的相邻元素,记为 i 和 j(i < j)。如果不 存在这样一对为升序的相邻元素,则所有排列均已找到,算法结束;否则,重新对 当前排列从后向前扫描,找到第一个大于 i 的元素 k,交换 i 和 k,然后对从 j 开始 到结束的子序列反转,则此时得到的新排列就为下一个字典序排列。这种方式实现 得到的所有排列是按字典序有序的,这也是 C++ STL 算法 next_permutation 的思 想。算法实现如下: 1. template 2. void CalcAllPermutation(T perm[], int num) 3. { 4. if (num < 1) 5. return; 308 6. 7. while (true) { 8. int i; 9. for (i = num - 2; i >= 0; --i) { 10. if (perm[i] < perm[i + 1]) 11. break; 12. } 13. 14. if (i < 0) 15. break; // 已经找到所有排列 16. 17. int k; 18. for (k = num - 1; k > i; --k) { 19. if (perm[k] > perm[i]) 20. break; 21. } 22. 23. swap(perm[i], perm[k]); 24. reverse(perm + i + 1, perm + num); 25. 26. } 27. } 扩展:如果不是求字符的所有排列,而是求字符的所有组合,应该怎么办呢?当输入的字 符串中含有相同的字符串时,相同的字符交换位置是不同的排列,但是同一个组合。举个例 子,如果输入 abc,它的组合有 a、b、c、ab、ac、bc、abc。 第二部分、跳台阶问题 27.跳台阶问题 题目:一个台阶总共有 n 级,如果一次可以跳 1 级,也可以跳 2 级。 求总共有多少总跳法,并分析算法的时间复杂度。 分析:在九月腾讯,创新工场,淘宝等公司最新面试十三题中第 23 题又出现了这个问 题,题目描述如下:23、人人笔试 1:一个人上台阶可以一次上 1 个,2 个,或者 3 个,问 这个人上 n 层的台阶,总共有几种走法?咱们先撇开这个人人笔试的问题(其实差别就在于 人人笔试题中多了一次可以跳三级的情况而已),先来看这个第 27 题。 首先考虑最简单的情况。如果只有 1 级台阶,那显然只有一种跳法。如果有 2 级台阶, 那就有两种跳的方法了:一种是分两次跳,每次跳 1 级;另外一种就是一次跳 2 级。 现在我们再来讨论一般情况。我们把 n 级台阶时的跳法看成是 n 的函数,记为 f(n)。当 n>2 时,第一次跳的时候就有两种不同的选择:一是第一次只跳 1 级,此时跳法数目等于后 309 面剩下的 n-1 级台阶的跳法数目,即为 f(n-1);另外一种选择是第一次跳 2 级,此时跳法数 目等于后面剩下的 n-2 级台阶的跳法数目,即为 f(n-2)。因此 n 级台阶时的不同跳法的总数 f(n)=f(n-1)+(f-2)。 我们把上面的分析用一个公式总结如下: / 1 n=1 f(n)= 2 n=2 \ f(n-1)+(f-2) n>2 原来上述问题就是我们平常所熟知的 Fibonacci 数列问题。可编写代码,如下: 1. long long Fibonacci_Solution1(unsigned int n) 2. { 3. int result[2] = {0, 1}; 4. if(n < 2) 5. return result[n]; 6. 7. return Fibonacci_Solution1(n - 1) + Fibonacci_Solution1(n - 2); 8. } 那么,如果是人人笔试那道题呢?一个人上台阶可以一次上 1 个,2 个,或者 3 个,岂 不是可以轻而易举的写下如下公式: / 1 n=1 f(n)= 2 n=2 4 n=3 //111, 12, 21, 3 \ f(n-1)+(f-2)+f(n-3) n>3 行文至此,你可能会认为问题已经解决了,但事实上没有: 1. 用递归方法计算的时间复杂度是以 n 的指数的方式递增的,我们可以尝试用递推方 法解决。具体如何操作,读者自行思考。 2. 有一种方法,能在 O(logn)的时间复杂度内求解 Fibonacci 数列问题,你能想到么? 310 3. 同时,有朋友指出对于这个台阶问题只需求幂就可以了(求复数幂 C++库里有),不 用任何循环且复杂度为 O(1),如下图所示,是否真如此?: 第三部分、奇偶调序 54. 调整数组顺序使奇数位于偶数前面。 题目:输入一个整数数组,调整数组中数字的顺序,使得所有奇数位于数组的前半部分, 所有偶数位于数组的后半部分。要求时间复杂度为 O(n)。 分析: 1. 你当然可以从头扫描这个数组,每碰到一个偶数时,拿出这个数字,并把位于这个 数字后面的所有数字往前挪动一位。挪完之后在数组的末尾有一个空位,这时把该 偶数放入这个空位。由于碰到一个偶数,需要移动 O(n)个数字,只是这种方法总的 时间复杂度是 O(n2),不符合要求,pass。 2. 很简单,维护两个指针,一个指针指向数组的第一个数字,向后移动;一个个指针 指向最后一个数字,向前移动。如果第一个指针指向的数字是偶数而第二个指针指 向的数字是奇数,我们就交换这两个数字。 思路有了,接下来,写代码实现: 1. //思路,很简答,俩指针,一首一尾 2. //如果第一个指针指向的数字是偶数而第二个指针指向的数字是奇数, 3. //我们就交换这两个数字 311 4. 5. // 2 1 3 4 6 5 7 6. // 7 1 3 4 6 5 2 7. // 7 1 3 5 6 4 2 8. 9. //如果限制空间复杂度为 O(1),时间为 O(N),且奇偶数之间相对顺序不变,就相当于正负数 间顺序调整的那道题了。 10. 11. //copyright@2010 zhedahht。 12. void Reorder(int *pData, unsigned int length, bool (*func)(int)); 13. bool isEven(int n); 14. void ReorderOddEven(int *pData, unsigned int length) 15. { 16. if(pData == NULL || length == 0) 17. return; 18. 19. Reorder(pData, length, isEven); 20. } 21. void Reorder(int *pData, unsigned int length, bool (*func)(int)) 22. { 23. if(pData == NULL || length == 0) 24. return; 25. int *pBegin = pData; 26. int *pEnd = pData + length - 1; 27. while(pBegin < pEnd) 28. { 29. // if *pBegin does not satisfy func, move forward 30. if(!func(*pBegin)) //偶数 31. { 32. pBegin ++; 33. continue; 34. } 35. 36. // if *pEnd does not satisfy func, move backward 37. if(func(*pEnd)) //奇数 38. { 39. pEnd --; 40. continue; 41. } 42. // if *pBegin satisfy func while *pEnd does not, 43. // swap these integers 44. int temp = *pBegin; 45. *pBegin = *pEnd; 46. *pEnd = temp; 312 47. } 48. } 49. bool isEven(int n) 50. { 51. return (n & 1) == 0; 52. } 细心的读者想必注意到了上述程序注释中所说的“如果限制空间复杂度为 O(1),时间 为 O(N)就相当于正负数间顺序调整的那道题了”,没错,它与个人之前整理的一文中的第 5 题极其类似:5、一个未排序整数数组,有正负数,重新排列使负数排在正数前面,并且 要求不改变原来的正负数之间相对顺序 比如: input: 1,7,-5,9,-12,15 ans: -5,-12,1,7,9,15 要求时间复杂度 O(N),空间 O(1) 。( 此题一直没看到令我满意的答案,一般达不到题目所要 求的:时间复杂度 O(N),空间 O(1),且保证原来正负数之间的相对位置不变)。 如果你想到了绝妙的解决办法,不妨在本文评论下告知于我,或者来信指导 (zhoulei0907@yahoo.cn),谢谢。 第四部分、第一个只出现一次的字符 第 17 题:题目:在一个字符串中找到第一个只出现一次的字符。如输入 abaccdeff,则输 出 b。 分析:这道题是 2006 年 google 的一道笔试题。它在今年又出现了,不过换了一种形 式。即最近的搜狐笔试大题:数组非常长,如何找到第一个只出现一次的数字,说明算法复 杂度。此问题已经在程序员编程艺术系列第二章中有所阐述,在此不再作过多讲解。 代码,可编写如下: 1. #include 2. using namespace std; 3. 4. //查找第一个只出现一次的字符,第 1 个程序 5. //copyright@ Sorehead && July 6. //July、updated,2011.04.24. 7. char find_first_unique_char(char *str) 8. { 9. int data[256]; 10. char *p; 11. 12. if (str == NULL) 13. return '\0'; 14. 15. memset(data, 0, sizeof(data)); //数组元素先全部初始化为 0 16. p = str; 17. while (*p != '\0') 313 18. data[(unsigned char)*p++]++; //遍历字符串,在相应位置++,(同时,下标强制 转换) 19. 20. while (*str != '\0') 21. { 22. if (data[(unsigned char)*str] == 1) //最后,输出那个第一个只出现次数为 1 的字符 23. return *str; 24. 25. str++; 26. } 27. 28. return '\0'; 29. } 30. 31. int main() 32. { 33. char *str = "afaccde"; 34. cout << find_first_unique_char(str) << endl; 35. return 0; 36. } 当然,代码也可以这么写(测试正确): 1. //查找第一个只出现一次的字符,第 2 个程序 2. //copyright@ yansha 3. //July、updated,2011.04.24. 4. char FirstNotRepeatChar(char* pString) 5. { 6. if(!pString) 7. return '\0'; 8. 9. const int tableSize = 256; 10. int hashTable[tableSize] = {0}; //存入数组,并初始化为 0 11. 12. char* pHashKey = pString; 13. while(*(pHashKey) != '\0') 14. hashTable[*(pHashKey++)]++; 15. 16. while(*pString != '\0') 17. { 18. if(hashTable[*pString] == 1) 19. return *pString; 20. 21. pString++; 22. } 314 23. return '\0'; //没有找到满足条件的字符,退出 24. } 第五部分、一致性哈希算法 tencent2012 笔试题附加题 问题描述: 例如手机朋友网有 n 个服务器,为了方便用户的访问会在服务器上缓存数 据,因此用户每次访问的时候最好能保持同一台服务器。 已有的做法是根据 ServerIPIndex[QQNUM%n]得到请求的服务器,这种方法很方便将用户 分到不同的服务器上去。但是如果一台服务器死掉了,那么 n 就变为了 n-1,那么 ServerIPIndex[QQNUM%n]与 ServerIPIndex[QQNUM%(n-1)]基本上都不一样了,所以 大多数用户的请求都会转到其他服务器,这样会发生大量访问错误。 问: 如何改进或者换一种方法,使得: (1)一台服务器死掉后,不会造成大面积的访问错误, (2)原有的访问基本还是停留在同一台服务器上; (3)尽量考虑负载均衡。(思路:往分布式一致哈希算法方面考虑。) 1. 最土的办法还是用模余方法:做法很简单,假设有 N 台服务器,现在完好的是 M (M<=N),先用 N 求模,如果不落在完好的机器上,然后再用 N-1 求模,直到 M.这 种方式对于坏的机器不多的情况下,具有更好的稳定性。 2. 一致性哈希算法。 下面,本文剩下部分重点来讲讲这个一致性哈希算法。 应用场景 在做服务器负载均衡时候可供选择的负载均衡的算法有很多,包括: 轮循算法(Round Robin)、哈希算法(HASH)、最少连接算法(Least Connection)、响应速度算法(Response Time)、加权法(Weighted )等。其中哈希算法是最为常用的算法. 典型的应用场景是: 有 N 台服务器提供缓存服务,需要对服务器进行负载均衡,将请求 平均分发到每台服务器上,每台机器负责 1/N 的服务。 常用的算法是对 hash 结果取余数 (hash() mod N):对机器编号从 0 到 N-1,按照自定 义的 hash()算法,对每个请求的 hash()值按 N 取模,得到余数 i,然后将请求分发到编号为 i 的机器。但这样的算法方法存在致命问题,如果某一台机器宕机,那么应该落在该机器的 请求就无法得到正确的处理,这时需要将当掉的服务器从算法从去除,此时候会有(N-1)/N 的服务器的缓存数据需要重新进行计算;如果新增一台机器,会有 N /(N+1)的服务器的缓存 数据需要进行重新计算。对于系统而言,这通常是不可接受的颠簸(因为这意味着大量缓存 315 的失效或者数据需要转移)。那么,如何设计一个负载均衡策略,使得受到影响的请求尽可 能的少呢? 在 Memcached、Key-Value Store、Bittorrent DHT、LVS 中都采用了 Consistent Hashing 算法,可以说 Consistent Hashing 是分布式系统负载均衡的首选算法。 Consistent Hashing 算法描述 下面以 Memcached 中的 Consisten Hashing 算法为例说明。 consistent hashing 算法早在 1997 年就在论文 Consistent hashing and random trees 中被提出,目前在 cache 系统中应用越来越广泛; 1 基本场景 比如你有 N 个 cache 服务器(后面简称 cache ),那么如何将一个对象 object 映射 到 N 个 cache 上呢,你很可能会采用类似下面的通用方法计算 object 的 hash 值,然后均 匀的映射到到 N 个 cache ; hash(object)%N 一切都运行正常,再考虑如下的两种情况; 1. 一个 cache 服务器 m down 掉了(在实际应用中必须要考虑这种情况),这样所有 映射到 cache m 的对象都会失效,怎么办,需要把 cache m 从 cache 中移除,这 时候 cache 是 N-1 台,映射公式变成了 hash(object)%(N-1) ; 2. 由于访问加重,需要添加 cache ,这时候 cache 是 N+1 台,映射公式变成 了 hash(object)%(N+1) ; 1 和 2 意味着什么?这意味着突然之间几乎所有的 cache 都失效了。对于服务器而言, 这是一场灾难,洪水般的访问都会直接冲向后台服务器;再来考虑第三个问题,由于硬件能 力越来越强,你可能想让后面添加的节点多做点活,显然上面的 hash 算法也做不到。 有什么方法可以改变这个状况呢,这就是 consistent hashing。 2 hash 算法和单调性 Hash 算法的一个衡量指标是单调性( Monotonicity ),定义如下: 316 单调性是指如果已经有一些内容通过哈希分派到了相应的缓冲中,又有新的缓冲加入到 系统中。哈希的结果应能够保证原有已分配的内容可以被映射到新的缓冲中去,而不会被映 射到旧的缓冲集合中的其他缓冲区。 容易看到,上面的简单 hash 算法 hash(object)%N 难以满足单调性要求。 3 consistent hashing 算法的原理 consistent hashing 是一种 hash 算法,简单的说,在移除 / 添加一个 cache 时,它能够 尽可能小的改变已存在 key 映射关系,尽可能的满足单调性的要求。 下面就来按照 5 个步骤简单讲讲 consistent hashing 算法的基本原理。 3.1 环形 hash 空间 考虑通常的 hash 算法都是将 value 映射到一个 32 为的 key 值,也即是 0~2^32-1 次方 的数值空间;我们可以将这个空间想象成一个首( 0 )尾( 2^32-1 )相接的圆环,如下面 图 1 所示的那样。 图 1 环形 hash 空间 3.2 把对象映射到 hash 空间 接下来考虑 4 个对象 object1~object4 ,通过 hash 函数计算出的 hash 值 key 在环上的 分布如图 2 所示。 hash(object1) = key1; … … hash(object4) = key4; 317 图 2 4 个对象的 key 值分布 3.3 把 cache 映射到 hash 空间 Consistent hashing 的基本思想就是将对象和 cache 都映射到同一个 hash 数值空间 中,并且使用相同的 hash 算法。 假设当前有 A,B 和 C 共 3 台 cache ,那么其映射结果将如图 3 所示,他们在 hash 空 间中,以对应的 hash 值排列。 hash(cache A) = key A; … … hash(cache C) = key C; 318 图 3 cache 和对象的 key 值分布 说到这里,顺便提一下 cache 的 hash 计算,一般的方法可以使用 cache 机器的 IP 地 址或者机器名作为 hash 输入。 3.4 把对象映射到 cache 现在 cache 和对象都已经通过同一个 hash 算法映射到 hash 数值空间中了,接下来要 考虑的就是如何将对象映射到 cache 上面了。 在这个环形空间中,如果沿着顺时针方向从对象的 key 值出发,直到遇见一个 cache , 那么就将该对象存储在这个 cache 上,因为对象和 cache 的 hash 值是固定的,因此这 个 cache 必然是唯一和确定的。这样不就找到了对象和 cache 的映射方法了吗?! 依然继续上面的例子(参见图 3 ),那么根据上面的方法,对象 object1 将被存储到 cache A 上; object2 和 object3 对应到 cache C ; object4 对应到 cache B ; 3.5 考察 cache 的变动 前面讲过,通过 hash 然后求余的方法带来的最大问题就在于不能满足单调性, 当 cache 有所变动时,cache 会失效,进而对后台服务器造成巨大的冲击,现在就来分析 分析 consistent hashing 算法。 319 3.5.1 移除 cache 考虑假设 cache B 挂掉了,根据上面讲到的映射方法,这时受影响的将仅是那些 沿 cache B 逆时针遍历直到下一个 cache ( cache C )之间的对象,也即是本来映射 到 cache B 上的那些对象。 因此这里仅需要变动对象 object4 ,将其重新映射到 cache C 上即可;参见图 4 。 图 4 Cache B 被移除后的 cache 映射 3.5.2 添加 cache 再考虑添加一台新的 cache D 的情况,假设在这个环形 hash 空间中, cache D 被映射 在对象 object2 和 object3 之间。这时受影响的将仅是那些沿 cache D 逆时针遍历直到下一 个 cache ( cache B )之间的对象(它们是也本来映射到 cache C 上对象的一部分),将 这些对象重新映射到 cache D 上即可。 因此这里仅需要变动对象 object2 ,将其重新映射到 cache D 上;参见图 5 。 320 图 5 添加 cache D 后的映射关系 4 虚拟节点 考量 Hash 算法的另一个指标是平衡性 (Balance) ,定义如下: 平衡性 平衡性是指哈希的结果能够尽可能分布到所有的缓冲中去,这样可以使得所有的缓冲空 间都得到利用。 hash 算法并不是保证绝对的平衡,如果 cache 较少的话,对象并不能被均匀的映射 到 cache 上,比如在上面的例子中,仅部署 cache A 和 cache C 的情况下,在 4 个对象 中, cache A 仅存储了 object1 ,而 cache C 则存储了 object2 、 object3 和 object4 ;分 布是很不均衡的。 为了解决这种情况, consistent hashing 引入了“虚拟节点”的概念,它可以如下定义: “虚拟节点”( virtual node )是实际节点在 hash 空间的复制品( replica ),一实际个节点 对应了若干个“虚拟节点”,这个对应个数也成为“复制个数”,“虚拟节点”在 hash 空间中 以 hash 值排列。 仍以仅部署 cache A 和 cache C 的情况为例,在图 4 中我们已经看到, cache 分布并 不均匀。现在我们引入虚拟节点,并设置“复制个数”为 2 ,这就意味着一共会存在 4 个“虚 拟节点”, cache A1, cache A2 代表了 cache A ; cache C1, cache C2 代表了 cache C ; 假设一种比较理想的情况,参见图 6 。 321 图 6 引入“虚拟节点”后的映射关系 此时,对象到“虚拟节点”的映射关系为: objec1->cache A2 ; objec2->cache A1 ; objec3->cache C1 ; objec4->cache C2 ; 因此对象 object1 和 object2 都被映射到了 cache A 上,而 object3 和 object4 映射到 了 cache C 上;平衡性有了很大提高。 引入“虚拟节点”后,映射关系就从 { 对象 -> 节点 } 转换到了 { 对象 -> 虚拟节点 } 。查询 物体所在 cache 时的映射关系如图 7 所示。 图 7 查询对象所在 cache 322 “虚拟节点”的 hash 计算可以采用对应节点的 IP 地址加数字后缀的方式。例如假 设 cache A 的 IP 地址为 202.168.14.241 。 引入“虚拟节点”前,计算 cache A 的 hash 值: Hash(“202.168.14.241”); 引入“虚拟节点”后,计算“虚拟节”点 cache A1 和 cache A2 的 hash 值: Hash(“202.168.14.241#1”); // cache A1 Hash(“202.168.14.241#2”); // cache A2 后记 1. 以上部分代码思路有参考自此博客:http://zhedahht.blog.163.com/blog/。特此注明 下。 2. 上文第五部分来源:http://blog.csdn.net/sparkliang/article/details/5279393; 3. 行文仓促,若有任何问题或漏洞,欢迎不吝指正或赐教。谢谢。转载,请注明出处。 完。 第二十一~二十二章:出现次数超过一半的数字,最短摘要 的生成 前言 咱们先来看两个问题: 第一个问题来自编程之美上,Tango 是微软亚洲研究院的一个试验项目,如图 1 所示。 研究院的员工和实习生们都很喜欢在 Tango 上面交流灌水。传说,Tango 有一大“水王”,他 323 不但喜欢发帖,还会回复其他 ID 发的每个帖子。坊间风闻该“水王”发帖数目超过了帖子总 数的一半。如果你有一个当前论坛上所有帖子(包括回帖)的列表,其中帖子作者的 ID 也 在表中,你能快速找出这个传说中的 Tango 水王吗? 图 1 Tango 第二个问题来自各位读者的手中,你我在百度或谷歌搜索框中敲入本博客名称的前 4 个 字“结构之法”,便能在第一个选项看到本博客的链接,如下图 2 所示: 图 2 谷歌中搜索关键字“结构之法” 在上面所示的图 2 中,搜索结果“结构之法算法之道-博客频道-CSDN.NET”下有一段说明 性的文字:“程序员面试、算法研究、编程艺术、红黑树 4 大经典原创系列集锦与总结 作者: July--结构之法算法...”,我们把这段文字称为那个搜索结果的摘要,亦即最短摘要。我们的 问题是,请问,这个最短摘要是怎么生成的呢? 324 ok,看本文之前,你尚不知道怎么解决上述两个问题的话不要紧,本文即要阐述上述两个 问题。若有任何问题,欢迎随时不吝指正。谢谢。 第二十一章、发帖水王及其扩展 第一节、74.数组中超过出现次数超过一半的数字 题目:数组中有一个数字出现的次数超过了数组长度的一半,找出这个数字。 分析:编程之美上也有这道题,不过它变换了题目的表述形式,即是如本文前言所述的寻找 发帖水王的问题。 ok,咱们来解决上述这道题,以微软面试 100 题第 74 题的阐述为准(本程序员编程艺 术系列就是按照之前整理的微软 100 题一题一题展开而来的)。 一个数组中有很多数,现在我们要找出这个数组中那个超过出现次数一半的数字,怎么找呢? 大凡当我们碰到某一个杂乱无序的东西时,我们人的内心本质期望是希望把它梳理成有序 的。所以,我们得分两种情况来讨论,无序和有序: 1. 如果无序,那么我们是不是可以先把数组中所有这些数字先进行排序,至于选取什 么排序方法则不在话下,最常用的快速排序 O(N*logN)即可。排完序呢,直接遍 历。在遍历整个数组的同时统计每个数字的出现次数,然后把那个出现次数超过一 半的数字直接输出,题目便解答完成了。总的时间复杂度为 O(N*logN+N)。 2. 但各位再想想,如果是有序的数组呢或者经过上述由无序的数组变成有序后的数组 呢?是否在排完序 O(N*logN)后,真的还需要再遍历一次整个数组么?我们知道, 既然是数组的话,那么我们可以根据数组索引支持直接定向到某一个数。我们发现, 一个数字在数组中的出现次数超过了一半,那么在已排好序的数组索引的 N/2 处(从 零开始编号),就一定是这个数字。自此,我们只需要对整个数组排完序之后,然后 直接输出数组中的第 N/2 处的数字即可,这个数字即是整个数组中出现次数超过一 半的数字,总的时间复杂度由于少了最后一次整个数组的遍历,缩小到 O(N*logN)。 3. 然不论是上述思路一的 O(N*logN+N),还是思路二的 O(N*logN),时间复杂度 并无本质性的改变。我们需要找到一种更为有效的思路或方法。既要缩小总的时间 复杂度,那么就用查找时间复杂度为 O(1),事先预处理时间复杂度为 O(N)的 hash 表。哈希表的键值(Key)为数组中的数字,值(Value)为该数字对应的次数。 然后直接遍历整个 hash 表,找出每一个数字在对应的位置处出现的次数,输出那个 出现次数超过一半的数字即可。 4. Hash 表需要 O(N)的开销空间,且要设计 hash 函数,还有没有更好的办法呢?我 们可以试着这么考虑,如果每次删除两个不同的数(不管是不是我们要查找的那个 325 出现次数超过一半的数字),那么,在剩下的数中,我们要查找的数(出现次数超过 一半)出现的次数仍然超过总数的一半。通过不断重复这个过程,不断排除掉其它 的数,最终找到那个出现次数超过一半的数字。这个方法,免去了上述思路一、二 的排序,也避免了思路三空间 O(N)的开销,总得说来,时间复杂度只有 O(N), 空间复杂度为 O(1),不失为最佳方法。 或许,你还没有明白上述思路 4 的意思,举个简单的例子吧,如数组 a[5]={0,1,2,1,1}; 很显然,若我们要找出数组 a 中出现次数超过一半的数字,这个数字便是 1,若根据上 述思路 4 所述的方法来查找,我们应该怎么做呢?通过一次性遍历整个数组,然后每次删除 不相同的两个数字,过程如下简单表示: 0 1 2 1 1 =>2 1 1=>1,最终,1 即为所找。 但是如果是 5,5,5,5,1,还能运用上述思路么?额,别急,请看下文思路 5。 5. 咱们根据数组的特性进一步考虑@zhedahht: 数组中有个数字出现的次数超过 了数组长度的一半。也就是说,有个数字出现的次数比其他所有数字出现次数的和还要多。 因此我们可以考虑在遍历数组的时候保存两个值:一个是数组中的一个数字,一个是次 数。当我们遍历到下一个数字的时候,如果下一个数字和我们之前保存的数字相同,则次数 加 1。 如果下一个数字和我们之前保存的数字不同,则次数减 1。如果次数为零,我们需要 保存下一个数字,并把次数重新设为 1 。 下面,举二个例子:  第一个例子,5,5,5,5,1 : 不同的相消,相同的累积。遍历到第四个数字时,candidate 是 5, nTimes 是 4; 遍历 到第五个数字时,candidate 是 5, nTimes 是 3; nTimes 不为 0,那么 candidate 就是超过 半数的。  第二个例子,0,1,2,1,1: 开始时,保存 candidate 是数字 0,ntimes 为 1,遍历到数字 1 后,与数字 0 不同,则 ntime 减 1 变为零,;接下来,遍历到数字 2,2 与 1 不同,candidate 保存数字 2,且 ntimes 重新设为 1;继续遍历到第 4 个数字 1 时,与 2 不同,ntimes 减一为零,同时 candidate 保存为 1;最终遍历到最后一个数字还是 1,与我们之前 candidate 保存的数字 1 相同,ntime 加一为 1。最后返回的是之前保存的 candidate 为 1。 326 针对上述程序,我再说详细点,0,1,2,1,1: 1. i=0,candidate=0,nTimes=1; 2. i=1,a[1]=0 != candidate,nTimes--,=0; 3. i=2,candidate=2,nTimes=1; 4. i=3,a[3] != candidate,nTimes--,=0; 5. i=4,candidate=1,nTimes=1; 6. 如果是 0,1,2,1,1,1 的话,那么 i=5,a[5]=1=candidate,nTimes++,=2;...... Ok,思路清楚了,完整的代码如下: 1. //改自编程之美 2010 2. Type Find(Type* a, int N) //a 代表数组,N 代表数组长度 3. { 4. Type candidate; 5. int nTimes, i; 6. for(i = nTimes = 0; i < N; i++) 7. { 8. if(nTimes == 0) 327 9. { 10. candidate = a[i], nTimes = 1; 11. } 12. else 13. { 14. if(candidate == a[i]) 15. nTimes++; 16. else 17. nTimes--; 18. } 19. } 20. return candidate; 21. } 或者: 1. //copyright@zhedahht 2. //July,updated, 3. //2011.04.16。 4. #include 5. using namespace std; 6. 7. bool g_Input = false; 8. 9. int Num(int* numbers, unsigned int length) 10. { 11. if(numbers == NULL && length == 0) 12. { 13. g_Input = true; 14. return 0; 15. } 16. g_Input = false; 17. 18. int result = numbers[0]; 19. int times = 1; 20. for(int i = 1; i < length; ++i) 21. { 22. if(numbers[i] == result) 23. times++; 24. else 25. times--; 26. if(times == 0) 27. { 28. result = numbers[i]; 328 29. times = 1; 30. } 31. } 32. 33. //检测输入是否有效。 34. times = 0; 35. for(i = 0; i < length; ++i) 36. { 37. if(numbers[i] == result) 38. times++; 39. } 40. if(times * 2 <= length) 41. //检测的标准是:如果数组中并不包含这么一个数字,那么输入将是无效的。 42. { 43. g_Input = true; 44. result = 0; 45. } 46. return result; 47. } 48. 49. int main() 50. { 51. int a[10]={1,2,3,4,6,6,6,6,6}; 52. int* n=a; 53. cout< 2. using namespace std; 3. 4. int Find(int* a, int N) 5. { 6. int candidate1,candidate2; 7. int nTimes1, nTimes2, i; 8. 9. for(i = nTimes1 = nTimes2 =0; i < N; i++) 10. { 11. if(nTimes1 == 0) 12. { 13. candidate1 = a[i], nTimes1 = 1; 14. } 15. else if(nTimes2 == 0) 16. { 17. 18. candidate2 = a[i], nTimes2 = 1; 19. } 20. else 21. { 22. if(candidate1 == a[i]) 332 23. nTimes1++; 24. else if(candidate2 == a[i]) 25. nTimes2++; 26. else 27. { 28. nTimes1--; 29. nTimes2--; 30. } 31. } 32. } 33. return nTimes1>nTimes2?candidate1:candidate2; 34. } 35. 36. int main() 37. { 38. int a[4]={0,1,2,1}; 39. cout<= N) 28. Break; 29. } 小结:上述思路二相比于思路一,很明显提高了不小效率。我们在匹配的过程中利用了 可以省去其中某些死板的步骤,这让我想到了 KMP 算法的匹配过程。同样是经过观察,比较, 最后总结归纳出的高效算法。我想,一定还有更好的办法,只是我们目前还没有看到,想到, 待我们去发现,创造。 思路三: 以下是读者 jiaotao1983 回复于本文评论下的反馈,非常感谢。 关于最短摘要的生成,我觉得 July 的处理有些简单,我以 July 的想法为基础,提出了 自己的一些想法,这个问题分以下几步解决: 1,将传入的 key words[]生成哈希表,便于以后的字符串比较。结构为 KeyHash,如下: struct KeyHash { int cnt; char key[]; int hash; } 结构体中的 hash 代表了关键字的哈希值,key 代表了关键字,cnt 代表了在当前的扫描过程 中,扫描到的该关键字的个数。 当然,作为哈希表结构,该结构体中还会有其它值,这里不赘述。 初始状态下,所有哈希结构的 cnt 字段为 0。 2,建立一个 KeyWord 结构,结构体如下: struct KeyWord { 338 int start; KeyHash* key; KeyWord* next; KeyWord* prev; } key 字段指向了建立的一个 KeyWord 代表了当前扫描到的一个关键字,扫描到的多个关键字 组成一个双向链表。 start 字段指向了关键字在文章中的起始位置。 3,建立几个全局变量: KeyWord* head,指向了双向链表的头,初始为 NULL。 KeyWord* tail,指向了双向链表的尾,初始为 NULL。 int minLen,当前扫描到的最短的摘要的长度,初始为 0。 int minStartPos,当前扫描到的最短摘要的起始位置。 int needKeyCnt,还需要几个关键字才能够包括全部的关键字,初始为关键字的个数。 4,开始对文章进行扫描。每扫描到一个关键字时,就建立一个 KeyWord 的结构并且将其连 入到扫描到的双向链表中,更新 head 和 tail 结构,同时将对应的 KeyHash 结构中的 cnt 加 1,表示扫描到了关键字。如果 cnt 由 0 变成了 1,表示扫描到一个新的关键字,因此 needKeyCnt 减 1。 5,当 needKeyCnt 变成 0 时,表示扫描到了全部的关键字了。此时要进行一个操作:链表 头优化。 链表头指向的 word 是摘要的起始点,可是如果对应的 KeyHash 结构中的 cnt 大于 1,表示 扫描到的摘要中还有该关键字,因此可以跳过该关键字。因此,此时将链表头更新为下一个 关键字,同时,将对应的 KeyHash 中的结构中的 cnt 减 1,重复这样的检查,直至某个链 表头对应的 KeyHash 结构中的 cnt 为 1,此时该结构不能够少了。 6,如果找到更短的 minLength,则更新 minLength 和 minStartPos。 7,开始新一轮的搜索。此时摘除链表的第一个节点,将 needKeyCnt 加 1,将下一个节点 作为链表头,同样的开始链表头优化措施。搜索从上一次的搜索结束处开始,不用回溯。就 是所,搜索在整个算法的过程中是一直沿着文章向下的,不会回溯。,直至文章搜索完毕。 这样的算法的复杂度初步估计是 O(M+N)。 339 8,另外,我觉得该问题不具备实际意义,要具备实际意义,摘要应该包含完整的句子,所 以摘要的起始和结束点应该以句号作为分隔。 这里,新建立一个结构:Sentence,结构体如下: struct Sentence { int start; //句子的起始位置 int end; //句子的结束位置 KeyWord* startKey; //句子包含的起始关键字 KeyWord* endKey; //句子包含的结束关键字 Sentence* prev; //下一个句子结构 Sentence* next; //前一个句子结构 } 扫描到的多个句子结构组成一个链表。增加两个全局变量,分别指向了 Sentence 链表的头 和尾。 扫描时,建立关键字链表时,也要建立 Sentence 链表。当扫描到包含了所有的关键字时, 必须要扫描到一个完整句子的结束。开始做 Sentence 头节点优化。做法是:查看 Sentence 结构中的全部 key 结构,如果全部的 key 对应的 KeyHash 结构的 cnt 属性全部大于 1,表 明该句子是多余的,去掉它,去掉它的时候更新对应的 HashKey 结构的关键字,因为减去 了很多的关键字。然后对下一个 Sentence 结构做同样的操作,直至某个 Sentence 结构是 必不可少的,就是说它包含了当前的摘要中只出现过一次的关键字! 扫描到了一个摘要后,在开始新的扫描。更新 Sentence 链表的头结点为下一个节点,同时 更新对应的 KeyHash 结构中的 cnt 关键字,当某个 cnt 变成 0 时,就递增 needKeycnt 变量。 再次扫描时仍然是从当前的结束位置开始扫描。 初步估计时间也是 O(M+N)。 ok,留下一个编程之美一书上的扩展问题:当搜索一索一个词语后,有许多的相似页面 出现,如何判断两个页面相似,从而在搜索结果中隐去这类结果? 本文参考: 1. 编程之美第二章第 2.3 节寻找发帖水王; 2. 编程之美第三章第 3.5 节最短摘要的生成; 3. http://zhedahht.blog.163.com/blog/static/25411174201085114733349/。 340 后记 编程艺术系列从今年 4 月开始创作,已写了二十二章。此系列最初是我一个人写,后 来我的一些朋友加入进来了,便成立了程序员编程艺术室,是我和一些朋友们一起写了,但 到如今一直在坚持的又只剩下自己了。近些天,常常发呆胡乱思考一些东西,如个人写博刚 过一年,有时候也看得一些有关互联网创业的文章,便有了下面写博 VS 创业这个话题: 1. 读者第一(用户至上); 2. 站在读者角度和思维方式阐述问题,文不易懂死不休(重视用户体验,用户不喜欢 不会用的产品便是废品); 3. 只写和创作读者最最需要的文章,东西(别人不需要,便没有市场,没有市场,一 切免谈); 4. 写博贵在坚持(创业贵在坚持)。 编程艺术系列一如之前早已说过,“因为编程艺术系列最后可能要写到第六十章”(语 出自:程序员编程艺术第一~十章集锦与总结--面试、算法、编程)。期待,编程艺术室的朋 友能早日继续加入共同创作。以诸君为傲。ok,若有任何问题,欢迎随时不吝指正。转载请 注明出处。完。July、2011.10。 第二十三、四章:杨氏矩阵查找,倒排索引关键词 Hash 不 重复编码实践 作者:July、yansha。编程艺术室出品。 出处:结构之法算法之道。 前言 本文阐述两个问题,第二十三章是杨氏矩阵查找问题,第二十四章是有关倒排索引中关 键词 Hash 编码的问题,主要要解决不重复以及追加的功能,同时也是经典算法研究系列十 一、从头到尾彻底解析 Hash 表算法之续。 341 OK,有任何问题,也欢迎随时交流或批评指正。谢谢。 第二十三章、杨氏矩阵查找 杨氏矩阵查找 先看一个来自算法导论习题里 6-3 与剑指 offer 的一道编程题(也被经常用作面试题,本 人此前去搜狗二面时便遇到了): 在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递 增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含 有该整数。 例如下面的二维数组就是每行、每列都递增排序。如果在这个数组中查找数字 6,则返 回 true;如果查找数字 5,由于数组不含有该数字,则返回 false。 本 Young 问题解法有二(如查找数字 6): 1、分治法,分为四个矩形,配以二分查找,如果要找的数是 6 介于对角线上相邻的 两个数 4、10,可以排除掉左上和右下的两个矩形,而递归在左下和右上的两个矩形继续找, 如下图所示: 342 2、首先直接定位到最右上角的元素,再配以二分查找,比要找的数(6)大就往左走, 比要找数(6 )的小就往下走,直到找到要找的数字( 6 ) 为 止 , 如 下 图 所 示 : 上述方法二的关键代码+程序运行如下图所示: 343 试问,上述算法复杂么?不复杂,只要稍微动点脑筋便能想到,还可以参看友人老梦 的文章,Young 氏矩阵:http://blog.csdn.net/zhanglei8893/article/details/6234564,以及 IT 练兵场的:http://www.jobcoding.com/array/matrix/young-tableau-problem/,除此之外, 何海涛先生一书剑指 offer 中也收集了此题,感兴趣的朋友也可以去看看。 第三十四章、经典算法十一 Hash 表算法(续)、倒排索引关键词不重复 Hash 编 码 本章要介绍这样一个问题,对倒排索引中的关键词进行编码。那么,这个问题将分 为两个个步骤: 1. 首先,要提取倒排索引内词典文件中的关键词; 344 2. 对提取出来的关键词进行编码。本章采取 hash 编码的方式。既然要用 hash 编码, 那么最重要的就是要解决 hash 冲突的问题,下文会详细介绍。 有一点必须提醒读者的是,倒排索引包含词典和倒排记录表两个部分,词典一般有词 项(或称为关键词)和词项频率(即这个词项或关键词出现的次数),倒排记录表则记录着 上述词项(或关键词)所出现的位置,或出现的文档及网页 ID 等相关信息。 34.1、正排索引与倒排索引 咱们先来看什么是倒排索引,以及倒排索引与正排索引之间的区别: 我们知道,搜索引擎的关键步骤就是建立倒排索引,所谓倒排索引一般表示为一个关 键词,然后是它的频度(出现的次数),位置(出现在哪一篇文章或网页中,及有关的日期, 作者等信息),它相当于为互联网上几千亿页网页做了一个索引,好比一本书的目录、标签 一般。读者想看哪一个主题相关的章节,直接根据目录即可找到相关的页面。不必再从书的 第一页到最后一页,一页一页的查找。 接下来,阐述下正排索引与倒排索引的区别: 一般索引(正排索引) 正排表是以文档的 ID 为关键字,表中记录文档中每个字的位置信息,查找时扫描表 中每个文档中字的信息直到找出所有包含查询关键字的文档。正排表结构如图 1 所示,这种 组织方法在建立索引的时候结构比较简单,建立比较方便且易于维护;因为索引是基于文档 建立的,若是有新的文档假如,直接为该文档建立一个新的索引块,挂接在原来索引文件的 后面。若是有文档删除,则直接找到该文档号文档对因的索引信息,将其直接删除。但是在 查询的时候需对所有的文档进行扫描以确保没有遗漏,这样就使得检索时间大大延长,检索 效率低下。 尽管正排表的工作原理非常的简单,但是由于其检索效率太低,除非在特定情况下, 否则实用性价值不大。 345 倒排索引 倒排表以字或词为关键字进行索引,表中关键字所对应的记录表项记录了出现这个字 或词的所有文档,一个表项就是一个字表段,它记录该文档的 ID 和字符在该文档中出现的 位置情况。由于每个字或词对应的文档数量在动态变化,所以倒排表的建立和维护都较为复 杂,但是在查询的时候由于可以一次得到查询关键字所对应的所有文档,所以效率高于正排 表。在全文检索中,检索的快速响应是一个最为关键的性能,而索引建立由于在后台进行, 尽管效率相对低一些,但不会影响整个搜索引擎的效率。 倒排表的结构图如图 2: 倒排表的索引信息保存的是字或词后继数组模型、互关联后继数组模型条在文档内的 位置,在同一篇文档内相邻的字或词条的前后关系没有被保存到索引文件内。 346 34.2、倒排索引中提取关键词 倒排索引是搜索引擎之基石。建成了倒排索引后,用户要查找某个 query,如在搜索 框输入某个关键词:“结构之法”后,搜索引擎不会再次使用爬虫又一个一个去抓取每一个 网页,从上到下扫描网页,看这个网页有没有出现这个关键词,而是会在它预先生成的倒排 索引文件中查找和匹配包含这个关键词“结构之法”的所有网页。找到了之后,再按相关性 度排序,最终把排序后的结果显示给用户。 如下,即是一个倒排索引文件(不全),我们把它取名为 big_index, 文件中每一较短的,不包含有“#####”符号的便是某个关键词,及这个关键词的出现次数。 现在要从这个大索引文件中提取出这些关键词,--Firelf--,-11,-Winter-,.,007,007: 天降杀机,02Chan..如何做到呢?一行一行的扫描整个索引文件么? 何意?之前已经说过:倒排索引包含词典和倒排记录表两个部分,词典一般有词项(或 称为关键词)和词项频率(即这个词项或关键词出现的次数),倒排记录表则记录着上述词 项(或关键词)所出现的位置,或出现的文档及网页 ID 等相关信息。 最简单的讲,就是要提取词典中的词项(关键词):--Firelf--,-11,-Winter-,., 007,007:天降杀机,02Chan...。 --Firelf--(关键词) 8(出现次数) 347 我们可以试着这么解决:通过查找#####便可判断某一行出现的词是不是关键词,但 如果这样做的话,便要扫描整个索引文件的每一行,代价实在巨大。如何提高速度呢?对了, 关键词后面的那个出现次数为我们问题的解决起到了很好的作用,如下注释所示: // 本身没有##### 的行判定为关键词行,后跟这个关键词的行数 N(即词项频率) // 接下来,截取关键词--Firelf--,然后读取后面关键词的行数 N // 再跳过 N 行(滤过和避免扫描中间的倒排记录表信息) // 读取下一个关键词.. 有朋友指出,上述方法虽然减少了扫描的行数,但并没有减少 I0 开销。读者是否有 更好地办法?欢迎随时交流。 34.2、为提取出来的关键词编码 爱思考的朋友可能会问,上述从倒排索引文件中提取出那些关键词(词项)的操作是 为了什么呢?其实如我个人微博上 12 月 12 日所述的 Hash 词典编码: 词典文件的编码:1、词典怎么生成(存储和构造词典);2、如何运用 hash 对输入 的汉字进行编码;3、如何更好的解决冲突,即不重复以及追加功能。具体例子为:事先构 348 造好词典文件后,输入一个词,要求找到这个词的编码,然后将其编码输出。且要有不断能 添加词的功能,不得重复。 步骤应该是如下:1、读索引文件;2、提取索引中的词出来;3、词典怎么生成,存 储和构造词典;4、词典文件的编码:不重复与追加功能。编码比如,输入中国,他的编码 可以为 10001,然后输入银行,他的编码可以为 10002。只要实现不断添加词功能,以及不 重复即可,词典类的大文件,hash 最重要的是怎样避免冲突。 也就是说,现在我要对上述提取出来后的关键词进行编码,采取何种方式编码呢?暂 时用 hash 函数编码。编码之后的效果将是每一个关键词都有一个特定的编码,如下图所示 (与上文 big_index 文件比较一下便知): --Firelf-- 对应编码为:135942 -11 对应编码为:106101 .... 但细心的朋友一看上图便知,其中第 34~39 行显示,有重复的编码,那么如何解决这 个不重复编码的问题呢? 349 用 hash 表编码?但其极易产生冲突碰撞,为什么?请看: 哈希表是一种查找效率极高的数据结构,很多语言都在内部实现了哈希表。PHP 中的 哈希表是一种极为重要的数据结构,不但用于表示 Array 数据类型,还在 Zend 虚拟机内部 用于存储上下文环境信息(执行上下文的变量及函数均使用哈希表结构存储)。 理想情况下哈希表插入和查找操作的时间复杂度均为 O(1),任何一个数据项可以在一 个与哈希表长度无关的时间内计算出一个哈希值(key),然后在常量时间内定位到一个桶 (术语 bucket,表示哈希表中的一个位置)。当然这是理想情况下,因为任何哈希表的长 度都是有限的,所以一定存在不同的数据项具有相同哈希值的情况,此时不同数据项被定为 到同一个桶,称为碰撞(collision)。哈希表的实现需要解决碰撞问题,碰撞解决大体有两 种思路,第一种是根据某种原则将被碰撞数据定为到其它桶,例如线性探测——如果数据在 插入时发生了碰撞,则顺序查找这个桶后面的桶,将其放入第一个没有被使用的桶;第二种 策略是每个桶不是一个只能容纳单个数据项的位置,而是一个可容纳多个数据的数据结构 (例如链表或红黑树),所有碰撞的数据以某种数据结构的形式组织起来。 不论使用了哪种碰撞解决策略,都导致插入和查找操作的时间复杂度不再是 O(1)。以 查找为例,不能通过 key 定位到桶就结束,必须还要比较原始 key(即未做哈希之前的 key) 是否相等,如果不相等,则要使用与插入相同的算法继续查找,直到找到匹配的值或确认数 据不在哈希表中。 PHP 是使用单链表存储碰撞的数据,因此实际上 PHP 哈希表的平均查找复杂度为 O(L),其中 L 为桶链表的平均长度;而最坏复杂度为 O(N),此时所有数据全部碰撞,哈希 表退化成单链表。下图 PHP 中正常哈希表和退化哈希表的示意图。 哈希表碰撞攻击就是通过精心构造数据,使得所有数据全部碰撞,人为将哈希表变成 一个退化的单链表,此时哈希表各种操作的时间均提升了一个数量级,因此会消耗大量 CPU 资源,导致系统无法快速响应请求,从而达到拒绝服务攻击(DoS)的目的。 可以看到,进行哈希碰撞攻击的前提是哈希算法特别容易找出碰撞,如果是 MD5 或者 SHA1 那基本就没戏了,幸运的是(也可以说不幸的是)大多数编程语言使用的哈希算法都 十分简单(这是为了效率考虑),因此可以不费吹灰之力之力构造出攻击数据(引自: http://www.codinglabs.org/html/hash-collisions-attack-on-php.html)。 350 34.4、暴雪的 Hash 算法 值得一提的是,在解决 Hash 冲突的时候,搞的焦头烂额,结果今天上午在自己的博 客内的一篇文章(十一、从头到尾彻底解析 Hash 表算法)内找到了解决办法:网上流传甚 广的暴雪的 Hash 算法。 OK,接下来,咱们回顾下暴雪的 hash 表算法: “接下来,咱们来具体分析一下一个最快的 Hash 表算法。 我们由一个简单的问题逐步入手:有一个庞大的字符串数组,然后给你一个单独的字符 串,让你从这个数组中查找是否有这个字符串并找到它,你会怎么做? 有一个方法最简单,老老实实从头查到尾,一个一个比较,直到找到为止,我想只要学 过程序设计的人都能把这样一个程序作出来,但要是有程序员把这样的程序交给用户,我只 能用无语来评价,或许它真的能工作,但...也只能如此了。 最合适的算法自然是使用 HashTable(哈希表),先介绍介绍其中的基本知识,所谓 Hash, 一般是一个整数,通过某种算法,可以把一个字符串"压缩" 成一个整数。当然,无论如何, 一个 32 位整数是无法对应回一个字符串的,但在程序中,两个字符串计算出的 Hash 值相等 的可能非常小,下面看看在 MPQ 中的 Hash 算法: 函数 prepareCryptTable 以下的函数生成一个长度为 0x500(合 10 进制数:1280) 的 cryptTable[0x500] 1. //函数 prepareCryptTable 以下的函数生成一个长度为 0x500(合 10 进制数:1280)的 cryptTable[0x500] 2. void prepareCryptTable() 3. { 4. unsigned long seed = 0x00100001, index1 = 0, index2 = 0, i; 5. 6. for( index1 = 0; index1 < 0x100; index1++ ) 7. { 8. for( index2 = index1, i = 0; i < 5; i++, index2 += 0x100 ) 9. { 10. unsigned long temp1, temp2; 11. 12. seed = (seed * 125 + 3) % 0x2AAAAB; 13. temp1 = (seed & 0xFFFF) << 0x10; 14. 15. seed = (seed * 125 + 3) % 0x2AAAAB; 16. temp2 = (seed & 0xFFFF); 17. 18. cryptTable[index2] = ( temp1 | temp2 ); 19. } 351 20. } 21. } 函数 HashString 以下函数计算 lpszFileName 字符串的 hash 值,其中 dwHashType 为 hash 的类型, 1. //函数 HashString 以下函数计算 lpszFileName 字符串的 hash 值,其中 dwHashType 为 hash 的类型, 2. unsigned long HashString(const char *lpszkeyName, unsigned long dwHashType ) 3. { 4. unsigned char *key = (unsigned char *)lpszkeyName; 5. unsigned long seed1 = 0x7FED7FED; 6. unsigned long seed2 = 0xEEEEEEEE; 7. int ch; 8. 9. while( *key != 0 ) 10. { 11. ch = *key++; 12. seed1 = cryptTable[(dwHashType<<8) + ch] ^ (seed1 + seed2); 13. seed2 = ch + seed1 + seed2 + (seed2<<5) + 3; 14. } 15. return seed1; 16. } Blizzard 的这个算法是非常高效的,被称为"One-Way Hash"( A one-way hash is a an algorithm that is constructed in such a way that deriving the original string (set of strings, actually) is virtually impossible)。举个例子,字符串"unitneutralacritter.grp"通过这个算法得 到 的 结 果 是 0xA26067F3 。 是不是把第一个算法改进一下,改成逐个比较字符串的 Hash 值就可以了呢,答案是,远 远不够,要想得到最快的算法,就不能进行逐个的比较,通常是构造一个哈希表(Hash Table) 来解决问题,哈希表是一个大数组,这个数组的容量根据程序的要求来定义, 例如 1024,每一个 Hash 值通过取模运算 (mod) 对应到数组中的一个位置,这样,只 要比较这个字符串的哈希值对应的位置有没有被占用,就可以得到最后的结果了,想想这是 什么速度?是的,是最快的 O(1),现在仔细看看这个算法吧: 1. typedef struct 2. { 3. int nHashA; 4. int nHashB; 5. char bExists; 6. ...... 7. } SOMESTRUCTRUE; 8. //一种可能的结构体定义? 函数 GetHashTablePos 下述函数为在 Hash 表中查找是否存在目标字符串,有则返回要 查找字符串的 Hash 值,无则,return -1. 352 1. //函数 GetHashTablePos 下述函数为在 Hash 表中查找是否存在目标字符串,有则返回要查找字 符串的 Hash 值,无则,return -1. 2. int GetHashTablePos( har *lpszString, SOMESTRUCTURE *lpTable ) 3. //lpszString 要在 Hash 表中查找的字符串,lpTable 为存储字符串 Hash 值的 Hash 表。 4. { 5. int nHash = HashString(lpszString); //调用上述函数 HashString,返回要查找字 符串 lpszString 的 Hash 值。 6. int nHashPos = nHash % nTableSize; 7. 8. if ( lpTable[nHashPos].bExists && !strcmp( lpTable[nHashPos].pString, lpszString ) ) 9. { //如果找到的 Hash 值在表中存在,且要查找的字符串与表中对应位置的字符串相同, 10. return nHashPos; //返回找到的 Hash 值 11. } 12. else 13. { 14. return -1; 15. } 16. } 看到此,我想大家都在想一个很严重的问题:“如果两个字符串在哈希表中对应的位置相 同怎么办?”,毕竟一个数组容量是有限的,这种可能性很大。解决该问题的方法很多,我首 先想到的就是用“链表”,感谢大学里学的数据结构教会了这个百试百灵的法宝,我遇到的很多 算法都可以转化成链表来解决,只要在哈希表的每个入口挂一个链表,保存所有对应的字符 串就 OK 了。事情到此似乎有了完美的结局,如果是把问题独自交给我解决,此时我可能就 要开始定义数据结构然后写代码了。 然而 Blizzard 的程序员使用的方法则是更精妙的方法。基本原理就是:他们在哈希表中 不是用一个哈希值而是用三个哈希值来校验字符串。 ” “MPQ 使用文件名哈希表来跟踪内部的所有文件。但是这个表的格式与正常的哈希表有 一些不同。首先,它没有使用哈希作为下标,把实际的文件名存储在表中用于验证,实际上 它根本就没有存储文件名。而是使用了 3 种不同的哈希:一个用于哈希表的下标,两个用于 验证。这两个验证哈希替代了实际文件名。 当然了,这样仍然会出现 2 个不同的文件名哈希到 3 个同样的哈希。但是这种情况发生 的概率平均是:1:18889465931478580854784,这个概率对于任何人来说应该都是足够小 的。现在再回到数据结构上,Blizzard 使用的哈希表没有使用链表,而采用"顺延"的方式来 解 决 问 题 。 ” 下面,咱们来看看这个网上流传甚广的暴雪 hash 算法: 函数 GetHashTablePos 中,lpszString 为要在 hash 表中查找的字符串;lpTable 为存 储字符串 hash 值的 hash 表;nTableSize 为 hash 表的长度: 1. //函数 GetHashTablePos 中,lpszString 为要在 hash 表中查找的字符串;lpTable 为存储 字符串 hash 值的 hash 表;nTableSize 为 hash 表的长度: 353 2. int GetHashTablePos( char *lpszString, MPQHASHTABLE *lpTable, int nTableSize ) 3. { 4. const int HASH_OFFSET = 0, HASH_A = 1, HASH_B = 2; 5. 6. int nHash = HashString( lpszString, HASH_OFFSET ); 7. int nHashA = HashString( lpszString, HASH_A ); 8. int nHashB = HashString( lpszString, HASH_B ); 9. int nHashStart = nHash % nTableSize; 10. int nHashPos = nHashStart; 11. 12. while ( lpTable[nHashPos].bExists ) 13. { 14. // 如果仅仅是判断在该表中时候存在这个字符串,就比较这两个 hash 值就可以了,不用对 结构体中的字符串进行比较。 15. // 这样会加快运行的速度?减少 hash 表占用的空间?这种方法一般应用在什么场 合? 16. if ( lpTable[nHashPos].nHashA == nHashA 17. && lpTable[nHashPos].nHashB == nHashB ) 18. { 19. return nHashPos; 20. } 21. else 22. { 23. nHashPos = (nHashPos + 1) % nTableSize; 24. } 25. 26. if (nHashPos == nHashStart) 27. break; 28. } 29. return -1; 30. } 上述程序解释: 1. 计算出字符串的三个哈希值(一个用来确定位置,另外两个用来校验) 2. 察看哈希表中的这个位置 3. 哈希表中这个位置为空吗?如果为空,则肯定该字符串不存在,返回-1。 4. 如果存在,则检查其他两个哈希值是否也匹配,如果匹配,则表示找到了该字符串,返 回其 Hash 值。 5. 移到下一个位置,如果已经移到了表的末尾,则反绕到表的开始位置起继续查询 6. 看看是不是又回到了原来的位置,如果是,则返回没找到 7. 否则,回到 3。 354 34.4、不重复 Hash 编码 有了上面的暴雪 Hash 算法。咱们的问题便可解决了。不过,有两点必须先提醒读者: 1、Hash 表起初要初始化;2、暴雪的 Hash 算法对于查询那样处理可以,但对插入就不能那 么解决。 关键主体代码如下: 1. //函数 prepareCryptTable 以下的函数生成一个长度为 0x500(合 10 进制数:1280)的 cryptTable[0x500] 2. void prepareCryptTable() 3. { 4. unsigned long seed = 0x00100001, index1 = 0, index2 = 0, i; 5. 6. for( index1 = 0; index1 <0x100; index1++ ) 7. { 8. for( index2 = index1, i = 0; i < 5; i++, index2 += 0x100) 9. { 10. unsigned long temp1, temp2; 11. seed = (seed * 125 + 3) % 0x2AAAAB; 12. temp1 = (seed & 0xFFFF)<<0x10; 13. seed = (seed * 125 + 3) % 0x2AAAAB; 14. temp2 = (seed & 0xFFFF); 15. cryptTable[index2] = ( temp1 | temp2 ); 16. } 17. } 18. } 19. 20. //函数 HashString 以下函数计算 lpszFileName 字符串的 hash 值,其中 dwHashType 为 hash 的类型, 21. unsigned long HashString(const char *lpszkeyName, unsigned long dwHashType ) 22. { 23. unsigned char *key = (unsigned char *)lpszkeyName; 24. unsigned long seed1 = 0x7FED7FED; 25. unsigned long seed2 = 0xEEEEEEEE; 26. int ch; 27. 28. while( *key != 0 ) 29. { 30. ch = *key++; 31. seed1 = cryptTable[(dwHashType<<8) + ch] ^ (seed1 + seed2); 32. seed2 = ch + seed1 + seed2 + (seed2<<5) + 3; 355 33. } 34. return seed1; 35. } 36. 37. ///////////////////////////////////////////////////////////////////// 38. //function: 哈希词典 编码 39. //parameter: 40. //author: lei.zhou 41. //time: 2011-12-14 42. ///////////////////////////////////////////////////////////////////// 43. MPQHASHTABLE TestHashTable[nTableSize]; 44. int TestHashCTable[nTableSize]; 45. int TestHashDTable[nTableSize]; 46. key_list test_data[nTableSize]; 47. 48. //直接调用上面的 hashstring,nHashPos 就是对应的 HASH 值。 49. int insert_string(const char *string_in) 50. { 51. const int HASH_OFFSET = 0, HASH_C = 1, HASH_D = 2; 52. unsigned int nHash = HashString(string_in, HASH_OFFSET); 53. unsigned int nHashC = HashString(string_in, HASH_C); 54. unsigned int nHashD = HashString(string_in, HASH_D); 55. unsigned int nHashStart = nHash % nTableSize; 56. unsigned int nHashPos = nHashStart; 57. int ln, ires = 0; 58. 59. while (TestHashTable[nHashPos].bExists) 60. { 61. // if (TestHashCTable[nHashPos] == (int) nHashC && TestHashDTable[nHas hPos] == (int) nHashD) 62. // break; 63. // //... 64. // else 65. //如之前所提示读者的那般,暴雪的 Hash 算法对于查询那样处理可以,但对插入就不 能那么解决 66. nHashPos = (nHashPos + 1) % nTableSize; 67. 68. if (nHashPos == nHashStart) 69. break; 70. } 71. 72. ln = strlen(string_in); 73. if (!TestHashTable[nHashPos].bExists && (ln < nMaxStrLen)) 74. { 356 75. TestHashCTable[nHashPos] = nHashC; 76. TestHashDTable[nHashPos] = nHashD; 77. 78. test_data[nHashPos] = (KEYNODE *) malloc (sizeof(KEYNODE) * 1); 79. if(test_data[nHashPos] == NULL) 80. { 81. printf("10000 EMS ERROR !!!!\n"); 82. return 0; 83. } 84. 85. test_data[nHashPos]->pkey = (char *)malloc(ln+1); 86. if(test_data[nHashPos]->pkey == NULL) 87. { 88. printf("10000 EMS ERROR !!!!\n"); 89. return 0; 90. } 91. 92. memset(test_data[nHashPos]->pkey, 0, ln+1); 93. strncpy(test_data[nHashPos]->pkey, string_in, ln); 94. *((test_data[nHashPos]->pkey)+ln) = 0; 95. test_data[nHashPos]->weight = nHashPos; 96. 97. TestHashTable[nHashPos].bExists = 1; 98. } 99. else 100. { 101. if(TestHashTable[nHashPos].bExists) 102. printf("30000 in the hash table %s !!!\n", string_in); 103. else 104. printf("90000 strkey error !!!\n"); 105. } 106. return nHashPos; 107. } 接下来要读取索引文件 big_index 对其中的关键词进行编码(为了简单起见,直接一 行一行扫描读写,没有跳过行数了): 1. void bigIndex_hash(const char *docpath, const char *hashpath) 2. { 3. FILE *fr, *fw; 4. int len; 5. char *pbuf, *p; 6. char dockey[TERM_MAX_LENG]; 7. 357 8. if(docpath == NULL || *docpath == '\0') 9. return; 10. 11. if(hashpath == NULL || *hashpath == '\0') 12. return; 13. 14. fr = fopen(docpath, "rb"); //读取文件 docpath 15. fw = fopen(hashpath, "wb"); 16. if(fr == NULL || fw == NULL) 17. { 18. printf("open read or write file error!\n"); 19. return; 20. } 21. 22. pbuf = (char*)malloc(BUFF_MAX_LENG); 23. if(pbuf == NULL) 24. { 25. fclose(fr); 26. return ; 27. } 28. 29. memset(pbuf, 0, BUFF_MAX_LENG); 30. 31. while(fgets(pbuf, BUFF_MAX_LENG, fr)) 32. { 33. len = GetRealString(pbuf); 34. if(len <= 1) 35. continue; 36. p = strstr(pbuf, "#####"); 37. if(p != NULL) 38. continue; 39. 40. p = strstr(pbuf, " "); 41. if (p == NULL) 42. { 43. printf("file contents error!"); 44. } 45. 46. len = p - pbuf; 47. dockey[0] = 0; 48. strncpy(dockey, pbuf, len); 49. 50. dockey[len] = 0; 51. 358 52. int num = insert_string(dockey); 53. 54. dockey[len] = ' '; 55. dockey[len+1] = '\0'; 56. char str[20]; 57. itoa(num, str, 10); 58. 59. strcat(dockey, str); 60. dockey[len+strlen(str)+1] = '\0'; 61. fprintf (fw, "%s\n", dockey); 62. 63. } 64. free(pbuf); 65. fclose(fr); 66. fclose(fw); 67. } 主函数已经很简单了,如下: 1. int main() 2. { 3. prepareCryptTable(); //Hash 表起初要初始化 4. 5. //现在要把整个 big_index 文件插入 hash 表,以取得编码结果 6. bigIndex_hash("big_index.txt", "hashpath.txt"); 7. system("pause"); 8. 9. return 0; 10. } 程序运行后生成的 hashpath.txt 文件如下: 359 如上所示,采取暴雪的 Hash 算法并在插入的时候做适当处理,当再次对上文中的索 引文件 big_index 进行 Hash 编码后,冲突问题已经得到初步解决。当然,还有待更进一步 更深入的测试。 后续添上数目索引 1~10000... 后来又为上述文件中的关键词编了码一个计数的内码,不过,奇怪的是,同样的代码, 在 Dev C++ 与 VS2010 上运行结果却不同(左边 dev 上计数从"1"开始,VS 上计数从 “1994014002”开始),如下图所示: 360 在上面的 bigIndex_hashcode 函数的基础上,修改如下,即可得到上面的效果: 1. void bigIndex_hashcode(const char *in_file_path, const char *out_file_path) 2. { 3. FILE *fr, *fw; 4. int len, value; 5. char *pbuf, *pleft, *p; 6. char keyvalue[TERM_MAX_LENG], str[WORD_MAX_LENG]; 7. 8. if(in_file_path == NULL || *in_file_path == '\0') { 9. printf("input file path error!\n"); 10. return; 11. } 12. 13. if(out_file_path == NULL || *out_file_path == '\0') { 14. printf("output file path error!\n"); 361 15. return; 16. } 17. 18. fr = fopen(in_file_path, "r"); //读取 in_file_path 路径文件 19. fw = fopen(out_file_path, "w"); 20. 21. if(fr == NULL || fw == NULL) 22. { 23. printf("open read or write file error!\n"); 24. return; 25. } 26. 27. pbuf = (char*)malloc(BUFF_MAX_LENG); 28. pleft = (char*)malloc(BUFF_MAX_LENG); 29. if(pbuf == NULL || pleft == NULL) 30. { 31. printf("allocate memory error!"); 32. fclose(fr); 33. return ; 34. } 35. 36. memset(pbuf, 0, BUFF_MAX_LENG); 37. 38. int offset = 1; 39. while(fgets(pbuf, BUFF_MAX_LENG, fr)) 40. { 41. if (--offset > 0) 42. continue; 43. 44. if(GetRealString(pbuf) <= 1) 45. continue; 46. 47. p = strstr(pbuf, "#####"); 48. if(p != NULL) 49. continue; 50. 51. p = strstr(pbuf, " "); 52. if (p == NULL) 53. { 54. printf("file contents error!"); 55. } 56. 57. len = p - pbuf; 58. 362 59. // 确定跳过行数 60. strcpy(pleft, p+1); 61. offset = atoi(pleft) + 1; 62. 63. strncpy(keyvalue, pbuf, len); 64. keyvalue[len] = '\0'; 65. value = insert_string(keyvalue); 66. 67. if (value != -1) { 68. 69. // key value 中插入空格 70. keyvalue[len] = ' '; 71. keyvalue[len+1] = '\0'; 72. 73. itoa(value, str, 10); 74. strcat(keyvalue, str); 75. 76. keyvalue[len+strlen(str)+1] = ' '; 77. keyvalue[len+strlen(str)+2] = '\0'; 78. 79. keysize++; 80. itoa(keysize, str, 10); 81. strcat(keyvalue, str); 82. 83. // 将 key value 写入文件 84. fprintf (fw, "%s\n", keyvalue); 85. 86. } 87. } 88. free(pbuf); 89. fclose(fr); 90. fclose(fw); 91. } 小结 本文有一点值得一提的是,在此前的这篇文章(十一、从头到尾彻底解析 Hash 表算 法)之中,只是对 Hash 表及暴雪的 Hash 算法有过学习和了解,但尚未真正运用过它,而今 在本章中体现,证明还是之前写的文章,及之前对 Hash 表等算法的学习还是有一定作用的。 同时,也顺便对暴雪的 Hash 函数算是做了个测试,其的确能解决一般的冲突性问题,创造 这个算法的人不简单呐。 363 后记 再次感谢老大 xiaoqi,以及艺术室内朋友 xiaolin,555,yansha 的指导。没有他们的帮 助,我将寸步难行。日后,自己博客内的文章要经常回顾,好好体会。同时,写作本文时, 刚接触倒排索引等相关问题不久,若有任何问题,欢迎随时交流或批评指正。 最后,基于本 blog 的分为程序语言,数据结构,算法讨论,面试题库,编程技巧五大板 块交流的论坛正在加紧建设当中(总负责人:scott && yinhex && 网络骑士),相信不久以 后便会与大家见面。谢谢。完。 第二十五章:二分查找实现(Jon Bentley:90%程序员无法 正确实现) 作者:July 出处:结构之法算法之道 引言 Jon Bentley:90%以上的程序员无法正确无误的写出二分查找代码。也许很多人都早已 听说过这句话,但我还是想引用《编程珠玑》上的如下几段文字: “二分查找可以解决(预排序数组的查找)问题:只要数组中包含 T(即要查找的值), 那么通过不断缩小包含 T 的范围,最终就可以找到它。一开始,范围覆盖整个数组。将数 组的中间项与 T 进行比较,可以排除一半元素,范围缩小一半。就这样反复比较,反复缩 小范围,最终就会在数组中找到 T,或者确定原以为 T 所在的范围实际为空。对于包含 N 个元素的表,整个查找过程大约要经过 log(2)N 次比较。 多数程序员都觉得只要理解了上面的描述,写出代码就不难了;但事实并非如此。如果 你不认同这一点,最好的办法就是放下书本,自己动手写一写。试试吧。 我在贝尔实验室和 IBM 的时候都出过这道考题。那些专业的程序员有几个小时的时间, 可以用他们选择的语言把上面的描述写出来;写出高级伪代码也可以。考试结束后,差不多 所有程序员都认为自己写出了正确的程序。于是,我们花了半个钟头来看他们编写的代码经 过测试用例验证的结果。几次课,一百多人的结果相差无几:90%的程序员写的程序中有 364 bug(我并不认为没有 bug 的代码就正确)。 我很惊讶:在足够的时间内,只有大约 10%的专业程序员可以把这个小程序写对。但 写不对这个小程序的还不止这些人:高德纳在《计算机程序设计的艺术 第 3 卷 排序和查找》 第 6.2.1 节的“历史与参考文献”部分指出,虽然早在 1946 年就有人将二分查找的方法公诸 于世,但直到 1962 年才有人写出没有 bug 的二分查找程序。 ”——乔恩·本特利,《编程珠 玑(第 1 版)》第 35-36 页。 你能正确无误的写出二分查找代码么?不妨一试。 二分查找代码 二分查找的原理想必不用多解释了,不过有一点必须提醒读者的是,二分查找是针对的 排好序的数组。OK,纸上读来终觉浅,觉知此事要躬行。我先来写一份,下面是我写的一 份二分查找的实现(之前去某一家公司面试也曾被叫当场实现二分查找,不过结果可能跟你 一样,当时就未能完整无误写出),有任何问题或错误,恳请不吝指正: 1. //二分查找 V0.1 实现版 2. //copyright@2011 July 3. //随时欢迎读者找 bug,email:zhoulei0907@yahoo.cn。 4. 5. //首先要把握下面几个要点: 6. //right=n-1 => while(left <= right) => right=middle-1; 7. //right=n => while(left < right) => right=middle; 8. //middle 的计算不能写在 while 循环外,否则无法得到更新。 9. 10. int binary_search(int array[],int n,int value) 11. { 12. int left=0; 13. int right=n-1; 14. //如果这里是 int right = n 的话,那么下面有两处地方需要修改,以保证一一对应: 15. //1、下面循环的条件则是 while(left < right) 16. //2、循环内当 array[middle]>value 的时候,right = mid 17. 18. while (left<=right) //循环条件,适时而变 19. { 20. int middle=left + ((right-left)>>1); //防止溢出,移位也更高效。同时,每 次循环都需要更新。 21. 22. if (array[middle]>value) 23. { 365 24. right =middle-1; //right 赋值,适时而变 25. } 26. else if(array[middle] while(left <= right) => right=middle-1; 3. //right=n => while(left < right) => right=middle; 4. //middle 的计算不能写在 while 循环外,否则无法得到更新。 2. 还有一个最最常犯的错误是@土豆: middle= (left+right)>>1; 这样的话 left 与 right 的值比较大的时候,其和可能溢出。 各位继续努力。 updated:各位,可以到此处 0 积分下载本 blog 最新博文集锦第 6 期 CHM 文件: http://download.csdn.net/detail/v_july_v/4020172。 第二十六章:基于给定的文档生成倒排索引的编码与实践 367 作者:July、yansha。 出处:结构之法算法之道 引言 本周实现倒排索引。实现过程中,寻找资料,结果发现找份资料诸多不易:1、网上搜 倒排索引实现,结果千篇一律,例子都是那几个同样的单词;2、到谷歌学术上想找点稍微 有价值水平的资料,结果下篇论文还收费或者要求注册之类;3、大部分技术书籍只有理论, 没有实践。于是,朋友戏言:网上一般有价值的东西不多。希望,本 blog 的出现能稍稍改 变此现状。 在第二十四章、倒排索引关键词不重复 Hash 编码中,我们针对一个给定的倒排索引文 件,提取出其中的关键词,然后针对这些关键词进行 Hash 不重复编码。本章,咱们再倒退 一步,即给定一个正排文档(暂略过文本解析,分词等步骤,日后会慢慢考虑这些且一并予 以实现),要求生成对应的倒排索引文件。同时,本章还是基于 Hash 索引之上(运用暴雪 的 Hash 函数可以比较完美的解决大数据量下的冲突问题),日后自会实现 B+树索引。 与此同时,本编程艺术系列逐步从为面试服务而转到实战性的编程当中了,教初学者如 何编程,如何运用高效的算法解决实际应用中的编程问题,将逐步成为本编程艺术系列的主 旨之一。 OK,接下来,咱们针对给定的正排文档一步一步来生成倒排索引文件,有任何问题,欢 迎随时不吝赐教或批评指正。谢谢。 第一节、索引的构建方法 根据信息检索导论(Christtopher D.Manning 等著,王斌译)一书给的提示,我们可以 选择两种构建索引的算法:BSBI 算法,与 SPIMI 算法。 BSBI 算法,基于磁盘的外部排序算法,此算法首先将词项映射成其 ID 的数据结构,如 Hash 映射。而后将文档解析成词项 ID-文档 ID 对,并在内存中一直处理,直到累积至放满一个 固定大小的块空间为止,我们选择合适的块大小,使之能方便加载到内存中并允许在内存中 快速排序,快速排序后的块转换成倒排索引格式后写入磁盘。 建立倒排索引的步骤如下: 1. 将文档分割成几个大小相等的部分; 368 2. 对词项 ID-文档 ID 进行排序; 3. 将具有同一词项 ID 的所有文档 ID 放到倒排记录表中,其中每条倒排记录仅仅是一 个文档 ID; 4. 将基于块的倒排索引写到磁盘上。 此算法假如说最后可能会产生 10 个块。其伪码如下: 1. BSBI NDEXConSTRUCTION() 2. n <- 0 3. while(all documents have not been processed) 4. do n<-n+1 5. block <- PARSENEXTBLOCK() //文档分析 6. BSBI-INVERT(block) 7. WRITEBLOCKTODISK(block,fn) 8. MERGEBLOCKS(f1,...,fn;fmerged) (基于块的排序索引算法,该算法将每个块的倒排索引文件存入文件 f1,...,fn 中,最后合并 成 fmerged 如果该算法应用最后一步产生了 10 个块,那么接下来便会将 10 个块索引同时合并成一个 索 引 文 件 。 ) 合并时,同时打开所有块对应的文件,内存中维护了为 10 个块准备的读缓冲区和一个 为最终合并索引准备的写缓冲区。每次迭代中,利用优先级队列(如堆结构或类似的数据结 构)选择最小的未处理的词项 ID 进行处理。如下图所示(图片引自深入搜索引擎--海里信 息的压缩、索引和查询,梁斌译),分块索引,分块排序,最终全部合并(说实话,跟 MapReduce 还是有些类似的): 369 读入该词项的倒排记录表并合并,合并结果写回磁盘中。需要时,再次从文件中读入数 据到每个读缓冲区(基于磁盘的外部排序算法的更多可以参考:程序员编程艺术第十章、如 何给 10^7 个数据量的磁盘文件排序 )。 BSBI 算法主要的时间消耗在排序上,选择什么排序方法呢,简单的快速排序足矣,其 时间复杂度为 O(N*logN),其中 N 是所需要排序的项(词项 ID-文档 ID 对)的数目的上 界。 SPIMI 算法,内存式单遍扫描索引算法 与上述 BSBI 算法不同的是:SPIMI 使用词项而不是其 ID,它将每个块的词典写入磁盘, 对于写一块则重新采用新的词典,只要硬盘空间足够大,它能索引任何大小的文档集。 倒排索引 = 词典(关键词或词项+词项频率)+倒排记录表。建倒排索引的步骤如下: 1. 从头开始扫描每一个词项-文档 ID(信息)对,遇一词,构建索引; 370 2. 继续扫描,若遇一新词,则再建一新索引块(加入词典,通过 Hash 表实现,同时, 建一新的倒排记录表);若遇一旧词,则找到其倒排记录表的位置,添加其后 3. 在内存内基于分块完成排序,后合并分块; 4. 写入磁盘。 其伪码如下: 1. SPIMI-Invert(Token_stream) 2. output.file=NEWFILE() 3. dictionary = NEWHASH() 4. while (free memory available) 5. do token <-next(token_stream) //逐一处理每个词项-文档 ID 对 6. if term(token) !(- dictionary 7. then postings_list = AddToDictionary(dictionary,term(token)) //如果词项是第一次出现,那么加入 hash 词典,同时,建立一个新的倒排索引表 8. else postings_list = GetPostingList(dictionary,term(token)) //如果不是第一次出现,那么直接返回其倒排记录表,在下面添加其后 9. if full(postings_list) 10. then postings_list =DoublePostingList(dictionary,term(token)) 11. AddToPosTingsList (postings_list,docID(token)) //SPIMI 与 BSBI 的 区别就在于此,前者直接在倒排记录表中增加此项新纪录 12. sorted_terms <- SortTerms(dictionary) 13. WriteBlockToDisk(sorted_terms,dictionary,output_file) 14. return output_file SPIMI 与 BSBI 的 主 要 区 别 : SPIMI 当发现关键词是第一次出现时,会直接在倒排记录表中增加一项(与 BSBI 算法 不同)。同时,与 BSBI 算法一开始就整理出所有的词项 ID-文档 ID,并对它们进行排序的 做法不同(而这恰恰是 BSBI 的做法),这里的每个倒排记录表都是动态增长的(也就是说, 倒排记录表的大小会不断调整),同时,扫描一遍就可以实现全体倒排记录表的收集。 SPIMI 这 样 做 有 两 点 好 处 : 1. 由于不需要排序操作,因此处理的速度更快, 2. 由于保留了倒排记录表对词项的归属关系,因此能节省内存,词项的 ID 也不需要保 存。这样,每次单独的 SPIMI-Invert 调用能够处理的块大小可以非常大,整个倒排 索引的构建过程也可以非常高效。 但不得不提的是,由于事先并不知道每个词项的倒排记录表大小,算法一开始只能分配 一个较小的倒排记录表空间,每次当该空间放满的时候,就会申请加倍的空间, 与此同时,自然而然便会浪费一部分空间(当然,此前因为不保存词项 ID,倒也省下一 点空间,总体而言,算作是抵销了)。 不过,至少 SPIMI 所用的空间会比 BSBI 所用空间少。当内存耗尽后,包括词典和倒排 记录表的块索引将被写到磁盘上,但在此之前,为使倒排记录表按照词典顺序来加快最后的 合并操作,所以要对词项进行排 序 操 作 。 371 小数据量与大数据量的区别 在小数据量时,有足够的内存保证该创建过程可以一次完成; 数据规模增大后,可以采用分组索引,然后再归并索 引的策略。该策略是, 1. 建立索引的模块根据当时运行系统所在的计算机的内存大小,将索引分为 k 组,使 得每组运算所需内存都小于系统能够提供的最大使用内存的大小。 2. 按照倒排索引的生成算法,生成 k 组倒排索引。 3. 然后将这 k 组索引归并,即将相同索引词对应的数据合并到一起,就得到了以索引 词为主键的最终的倒排文件索引,即反向索引。 为了测试的方便,本文针对小数据量进行从正排文档到倒排索引文件的实现。而且针对 大数量的 K 路归并算法或基于磁盘的外部排序算法本编程艺术系列第十章中已有详细阐述。 第二节、Hash 表的构建与实现 如下,给定如下图所示的正排文档,每一行的信息分别为(中间用##########隔开): 文档 ID、订阅源(子频道)、 频道分类、 网站类 ID(大频道)、时间、 md5、文档权值、 关键词、作者等等。 372 要求基于给定的上述正排文档。生成如第二十四章所示的倒排索引文件(注,关键词所 在的文章如果是同一个日期的话,是挨在同一行的,用“#”符号隔开): 我们知道:为网页建立全文索引是网页预处理的核心部分,包括分析网页和建立倒排文 件。二者是顺序进行,先分析网页,后建立倒排文件(也称为反向索引),如图所示: 正如上图粗略所示,我们知道倒排索引创建的过程如下: 1. 写爬虫抓取相关的网页,而后提取相关网页或文章中所有的关键词; 2. 分词,找出所有单词; 3. 过滤不相干的信息(如广告等信息); 4. 构建倒排索引,关键词=>(文章 ID 出现次数 出现的位置) 5. 生成词典文件 频率文件 位置文件 6. 压缩。 因为已经给定了正排文档,接下来,咱们跳过一系列文本解析,分词等中间步骤,直接 根据正排文档生成倒排索引文档(幸亏有 yansha 相助,不然,寸步难行,其微博地址为: http://weibo.com/yanshazi , 欢 迎 关 注 他 ) 。 OK,闲不多说,咱们来一步一步实现吧。 373 建相关的数据结构 根据给定的正排文档,我们可以建立如下的两个结构体表示这些信息:文档 ID、订阅源 (子频道)、 频道分类、 网站类 ID(大频道)、时间、 md5、文档权值、关键词、作者 等等。如下所示: 1. typedef struct key_node 2. { 3. char *pkey; // 关键词实体 4. int count; // 关键词出现次数 5. int pos; // 关键词在 hash 表中位置 6. struct doc_node *next; // 指向文档结点 7. }KEYNODE, *key_list; 8. 9. key_list key_array[TABLE_SIZE]; 10. 11. typedef struct doc_node 12. { 13. char id[WORD_MAX_LEN]; //文档 ID 14. int classOne; //订阅源(子频道) 15. char classTwo[WORD_MAX_LEN]; //频道分类 16. int classThree; //网站类 ID(大频道) 17. char time[WORD_MAX_LEN]; //时间 18. char md5[WORD_MAX_LEN]; //md5 19. int weight; //文档权值 20. struct doc_node *next; 21. }DOCNODE, *doc_list; 我们知道,通过第二十四章的暴雪的 Hash 表算法,可以比较好的避免相关冲突的问题。 下面,我们再次引用其代码: 基于暴雪的 Hash 之上的改造算法 1. //函数 prepareCryptTable 以下的函数生成一个长度为 0x100 的 cryptTable[0x100] 2. void PrepareCryptTable() 3. { 4. unsigned long seed = 0x00100001, index1 = 0, index2 = 0, i; 5. 6. for( index1 = 0; index1 <0x100; index1++ ) 7. { 8. for( index2 = index1, i = 0; i < 5; i++, index2 += 0x100) 9. { 10. unsigned long temp1, temp2; 11. seed = (seed * 125 + 3) % 0x2AAAAB; 12. temp1 = (seed & 0xFFFF)<<0x10; 374 13. seed = (seed * 125 + 3) % 0x2AAAAB; 14. temp2 = (seed & 0xFFFF); 15. cryptTable[index2] = ( temp1 | temp2 ); 16. } 17. } 18. } 19. 20. //函数 HashString 以下函数计算 lpszFileName 字符串的 hash 值,其中 dwHashType 为 hash 的类型, 21. unsigned long HashString(const char *lpszkeyName, unsigned long dwHashType ) 22. { 23. unsigned char *key = (unsigned char *)lpszkeyName; 24. unsigned long seed1 = 0x7FED7FED; 25. unsigned long seed2 = 0xEEEEEEEE; 26. int ch; 27. 28. while( *key != 0 ) 29. { 30. ch = *key++; 31. seed1 = cryptTable[(dwHashType<<8) + ch] ^ (seed1 + seed2); 32. seed2 = ch + seed1 + seed2 + (seed2<<5) + 3; 33. } 34. return seed1; 35. } 36. 37. //按关键字查询,如果成功返回 hash 表中索引位置 38. key_list SearchByString(const char *string_in) 39. { 40. const int HASH_OFFSET = 0, HASH_C = 1, HASH_D = 2; 41. unsigned int nHash = HashString(string_in, HASH_OFFSET); 42. unsigned int nHashC = HashString(string_in, HASH_C); 43. unsigned int nHashD = HashString(string_in, HASH_D); 44. unsigned int nHashStart = nHash % TABLE_SIZE; 45. unsigned int nHashPos = nHashStart; 46. 47. while (HashTable[nHashPos].bExists) 48. { 49. if (HashATable[nHashPos] == (int) nHashC && HashBTable[nHashPos] == (int) nHashD) 50. { 51. break; 52. //查询与插入不同,此处不需修改 53. } 375 54. else 55. { 56. nHashPos = (nHashPos + 1) % TABLE_SIZE; 57. } 58. 59. if (nHashPos == nHashStart) 60. { 61. break; 62. } 63. } 64. 65. if( key_array[nHashPos] && strlen(key_array[nHashPos]->pkey)) 66. { 67. return key_array[nHashPos]; 68. } 69. 70. return NULL; 71. } 72. 73. //按索引查询,如果成功返回关键字(此函数在本章中没有被用到,可以忽略) 74. key_list SearchByIndex(unsigned int nIndex) 75. { 76. unsigned int nHashPos = nIndex; 77. if (nIndex < TABLE_SIZE) 78. { 79. if(key_array[nHashPos] && strlen(key_array[nHashPos]->pkey)) 80. { 81. return key_array[nHashPos]; 82. } 83. } 84. 85. return NULL; 86. } 87. 88. //插入关键字,如果成功返回 hash 值 89. int InsertString(const char *str) 90. { 91. const int HASH_OFFSET = 0, HASH_A = 1, HASH_B = 2; 92. unsigned int nHash = HashString(str, HASH_OFFSET); 93. unsigned int nHashA = HashString(str, HASH_A); 94. unsigned int nHashB = HashString(str, HASH_B); 95. unsigned int nHashStart = nHash % TABLE_SIZE; 96. unsigned int nHashPos = nHashStart; 97. int len; 376 98. 99. while (HashTable[nHashPos].bExists) 100. { 101. nHashPos = (nHashPos + 1) % TABLE_SIZE; 102. 103. if (nHashPos == nHashStart) 104. break; 105. } 106. 107. len = strlen(str); 108. if (!HashTable[nHashPos].bExists && (len < WORD_MAX_LEN)) 109. { 110. HashATable[nHashPos] = nHashA; 111. HashBTable[nHashPos] = nHashB; 112. 113. key_array[nHashPos] = (KEYNODE *) malloc (sizeof(KEYNODE) * 1); 114. if(key_array[nHashPos] == NULL) 115. { 116. printf("10000 EMS ERROR !!!!\n"); 117. return 0; 118. } 119. 120. key_array[nHashPos]->pkey = (char *)malloc(len+1); 121. if(key_array[nHashPos]->pkey == NULL) 122. { 123. printf("10000 EMS ERROR !!!!\n"); 124. return 0; 125. } 126. 127. memset(key_array[nHashPos]->pkey, 0, len+1); 128. strncpy(key_array[nHashPos]->pkey, str, len); 129. *((key_array[nHashPos]->pkey)+len) = 0; 130. key_array[nHashPos]->pos = nHashPos; 131. key_array[nHashPos]->count = 1; 132. key_array[nHashPos]->next = NULL; 133. HashTable[nHashPos].bExists = 1; 134. return nHashPos; 135. } 136. 137. if(HashTable[nHashPos].bExists) 138. printf("30000 in the hash table %s !!!\n", str); 139. else 140. printf("90000 strkey error !!!\n"); 141. return -1; 377 142. } 有了这个 Hash 表,接下来,我们就可以把词插入 Hash 表进行存储了。 第三节、倒排索引文件的生成与实现 Hash 表实现了(存于 HashSearch.h 中),还得编写一系列的函数,如下所示(所有代 码还只是初步实现了功能,稍后在第四部分中将予以改进与优化): 1. //处理空白字符和空白行 2. int GetRealString(char *pbuf) 3. { 4. int len = strlen(pbuf) - 1; 5. while (len > 0 && (pbuf[len] == (char)0x0d || pbuf[len] == (char)0x0a || pbuf[len] == ' ' || pbuf[len] == '\t')) 6. { 7. len--; 8. } 9. 10. if (len < 0) 11. { 12. *pbuf = '\0'; 13. return len; 14. } 15. pbuf[len+1] = '\0'; 16. return len + 1; 17. } 18. 19. //重新 strcoll 字符串比较函数 20. int strcoll(const void *s1, const void *s2) 21. { 22. char *c_s1 = (char *)s1; 23. char *c_s2 = (char *)s2; 24. while (*c_s1 == *c_s2++) 25. { 26. if (*c_s1++ == '\0') 27. { 28. return 0; 29. } 30. } 31. 32. return *c_s1 - *--c_s2; 33. } 378 34. 35. //从行缓冲中得到各项信息,将其写入 items 数组 36. void GetItems(char *&move, int &count, int &wordnum) 37. { 38. char *front = move; 39. bool flag = false; 40. int len; 41. move = strstr(move, "#####"); 42. if (*(move + 5) == '#') 43. { 44. flag = true; 45. } 46. 47. if (move) 48. { 49. len = move - front; 50. strncpy(items[count], front, len); 51. } 52. items[count][len] = '\0'; 53. count++; 54. 55. if (flag) 56. { 57. move = move + 10; 58. } else 59. { 60. move = move + 5; 61. } 62. } 63. 64. //保存关键字相应的文档内容 65. doc_list SaveItems() 66. { 67. doc_list infolist = (doc_list) malloc(sizeof(DOCNODE)); 68. strcpy_s(infolist->id, items[0]); 69. infolist->classOne = atoi(items[1]); 70. strcpy_s(infolist->classTwo, items[2]); 71. infolist->classThree = atoi(items[3]); 72. strcpy_s(infolist->time, items[4]); 73. strcpy_s(infolist->md5, items[5]); 74. infolist->weight = atoi(items[6]); 75. return infolist; 76. } 77. 379 78. //得到目录下所有文件名 79. int GetFileName(char filename[][FILENAME_MAX_LEN]) 80. { 81. _finddata_t file; 82. long handle; 83. int filenum = 0; 84. //C:\Users\zhangxu\Desktop\CreateInvertedIndex\data 85. if ((handle = _findfirst("C:\\Users\\zhangxu\\Desktop\\CreateInvertedInd ex\\data\\*.txt", &file)) == -1) 86. { 87. printf("Not Found\n"); 88. } 89. else 90. { 91. do 92. { 93. strcpy_s(filename[filenum++], file.name); 94. } while (!_findnext(handle, &file)); 95. } 96. _findclose(handle); 97. return filenum; 98. } 99. 100. //以读方式打开文件,如果成功返回文件指针 101. FILE* OpenReadFile(int index, char filename[][FILENAME_MAX_LEN]) 102. { 103. char *abspath; 104. char dirpath[] = {"data\\"}; 105. abspath = (char *)malloc(ABSPATH_MAX_LEN); 106. strcpy_s(abspath, ABSPATH_MAX_LEN, dirpath); 107. strcat_s(abspath, FILENAME_MAX_LEN, filename[index]); 108. 109. FILE *fp = fopen (abspath, "r"); 110. if (fp == NULL) 111. { 112. printf("open read file error!\n"); 113. return NULL; 114. } 115. else 116. { 117. return fp; 118. } 119. } 120. 380 121. //以写方式打开文件,如果成功返回文件指针 122. FILE* OpenWriteFile(const char *in_file_path) 123. { 124. if (in_file_path == NULL) 125. { 126. printf("output file path error!\n"); 127. return NULL; 128. } 129. 130. FILE *fp = fopen(in_file_path, "w+"); 131. if (fp == NULL) 132. { 133. printf("open write file error!\n"); 134. } 135. return fp; 136. } 最后,主函数编写如下: 1. int main() 2. { 3. key_list keylist; 4. char *pbuf, *move; 5. int filenum = GetFileName(filename); 6. FILE *fr; 7. pbuf = (char *)malloc(BUF_MAX_LEN); 8. memset(pbuf, 0, BUF_MAX_LEN); 9. 10. FILE *fw = OpenWriteFile("index.txt"); 11. if (fw == NULL) 12. { 13. return 0; 14. } 15. 16. PrepareCryptTable(); //初始化 Hash 表 17. 18. int wordnum = 0; 19. for (int i = 0; i < filenum; i++) 20. { 21. fr = OpenReadFile(i, filename); 22. if (fr == NULL) 23. { 24. break; 25. } 381 26. 27. // 每次读取一行处理 28. while (fgets(pbuf, BUF_MAX_LEN, fr)) 29. { 30. int count = 0; 31. move = pbuf; 32. if (GetRealString(pbuf) <= 1) 33. continue; 34. 35. while (move != NULL) 36. { 37. // 找到第一个非'#'的字符 38. while (*move == '#') 39. move++; 40. 41. if (!strcmp(move, "")) 42. break; 43. 44. GetItems(move, count, wordnum); 45. } 46. 47. for (int i = 7; i < count; i++) 48. { 49. // 将关键字对应的文档内容加入文档结点链表中 50. if (keylist = SearchByString(items[i])) //到 hash 表内查 询 51. { 52. doc_list infolist = SaveItems(); 53. infolist->next = keylist->next; 54. keylist->count++; 55. keylist->next = infolist; 56. } 57. else 58. { 59. // 如果关键字第一次出现,则将其加入 hash 表 60. int pos = InsertString(items[i]); //插入 hash 表 61. keylist = key_array[pos]; 62. doc_list infolist = SaveItems(); 63. infolist->next = NULL; 64. keylist->next = infolist; 65. if (pos != -1) 66. { 67. strcpy_s(words[wordnum++], items[i]); 68. } 382 69. } 70. } 71. } 72. } 73. 74. // 通过快排对关键字进行排序 75. qsort(words, WORD_MAX_NUM, WORD_MAX_LEN, strcoll); 76. 77. // 遍历关键字数组,将关键字及其对应的文档内容写入文件中 78. for (int i = 0; i < WORD_MAX_NUM; i++) 79. { 80. keylist = SearchByString(words[i]); 81. if (keylist != NULL) 82. { 83. fprintf(fw, "%s %d\n", words[i], keylist->count); 84. doc_list infolist = keylist->next; 85. for (int j = 0; j < keylist->count; j++) 86. { 87. //文档 ID,订阅源(子频道) 频道分类 网站类 ID(大频道) 时间 md5, 文档权值 88. fprintf(fw, "%s %d %s %d %s %s %d\n", infolist->id, infolist ->classOne, 89. infolist->classTwo, infolist->classThree, infolist->time , infolist->md5, infolist->weight); 90. infolist = infolist->next; 91. } 92. } 93. } 94. 95. free(pbuf); 96. fclose(fr); 97. fclose(fw); 98. system("pause"); 99. return 0; 100. } 383 程序编译运行后,生成的倒排索引文件为 index.txt,其与原来给定的正排文档对照如下: 有没有发现关键词奥恰洛夫出现在的三篇文章是同一个日期 1210 的,貌似与本文开头 指定的倒排索引格式要求不符?因为第二部分开头中,已明确说明:“注,关键词所在的文 章如果是同一个日期的话,是挨在同一行的,用“#”符号隔开”。OK,有疑问是好事,代表你 思考了,请直接转至下文第 4 部分。 384 第四节、程序需求功能的改进 4.1、对相同日期与不同日期的处理 细心的读者可能还是会注意到:在第二部分开头中,要求基于给定的上述正排文档。生 成如第二十四章所示的倒排索引文件是下面这样子的,即是: 也就是说,上面建索引的过程本该是如下的: 与第一部分所述的 SMIPI 算法有什么区别?对的,就在于对在同一个日期的出现的关键 词的处理。如果是遇一旧词,则找到其倒排记录表的位置:相同日期,添加到之前同一日期 的记录之后(第一个记录的后面记下同一日期的记录数目);不同日期,另起一行新增记 录。 相同(单个)日期,根据文档权值排序 不同日期,根据时间排序 代码主要修改如下: 385 1. //function: 对链表进行冒泡排序 2. void ListSort(key_list keylist) 3. { 4. doc_list p = keylist->next; 5. doc_list final = NULL; 6. while (true) 7. { 8. bool isfinish = true; 9. while (p->next != final) { 10. if (strcmp(p->time, p->next->time) < 0) 11. { 12. SwapDocNode(p); 13. isfinish = false; 14. } 15. p = p->next; 16. } 17. final = p; 18. p = keylist->next; 19. if (isfinish || p->next == final) { 20. break; 21. } 22. } 23. } 24. 25. int main() 26. { 27. key_list keylist; 28. char *pbuf, *move; 29. int filenum = GetFileName(filename); 30. FILE *frp; 31. pbuf = (char *)malloc(BUF_MAX_LEN); 32. memset(pbuf, 0, BUF_MAX_LEN); 33. 34. FILE *fwp = OpenWriteFile("index.txt"); 35. if (fwp == NULL) { 36. return 0; 37. } 38. 39. PrepareCryptTable(); 40. 41. int wordnum = 0; 42. for (int i = 0; i < filenum; i++) 43. { 44. frp = OpenReadFile(i, filename); 386 45. if (frp == NULL) { 46. break; 47. } 48. 49. // 每次读取一行处理 50. while (fgets(pbuf, BUF_MAX_LEN, frp)) 51. { 52. int count = 0; 53. move = pbuf; 54. if (GetRealString(pbuf) <= 1) 55. continue; 56. 57. while (move != NULL) 58. { 59. // 找到第一个非'#'的字符 60. while (*move == '#') 61. move++; 62. 63. if (!strcmp(move, "")) 64. break; 65. 66. GetItems(move, count, wordnum); 67. } 68. 69. for (int i = 7; i < count; i++) { 70. // 将关键字对应的文档内容加入文档结点链表中 71. // 如果关键字第一次出现,则将其加入 hash 表 72. if (keylist = SearchByString(items[i])) { 73. doc_list infolist = SaveItems(); 74. infolist->next = keylist->next; 75. keylist->count++; 76. keylist->next = infolist; 77. } else { 78. int pos = InsertString(items[i]); 79. keylist = key_array[pos]; 80. doc_list infolist = SaveItems(); 81. infolist->next = NULL; 82. keylist->next = infolist; 83. if (pos != -1) { 84. strcpy_s(words[wordnum++], items[i]); 85. } 86. } 87. } 88. } 387 89. } 90. 91. // 通过快排对关键字进行排序 92. qsort(words, WORD_MAX_NUM, WORD_MAX_LEN, strcoll); 93. 94. // 遍历关键字数组,将关键字及其对应的文档内容写入文件中 95. int rownum = 1; 96. for (int i = 0; i < WORD_MAX_NUM; i++) { 97. keylist = SearchByString(words[i]); 98. if (keylist != NULL) { 99. doc_list infolist = keylist->next; 100. 101. char date[9]; 102. 103. // 截取年月日 104. for (int j = 0; j < keylist->count; j++) 105. { 106. strncpy_s(date, infolist->time, 8); 107. date[8] = '\0'; 108. strncpy_s(infolist->time, date, 9); 109. infolist = infolist->next; 110. } 111. 112. // 对链表根据时间进行排序 113. ListSort(keylist); 114. 115. infolist = keylist->next; 116. int *count = new int[WORD_MAX_NUM]; 117. memset(count, 0, WORD_MAX_NUM); 118. strcpy_s(date, infolist->time); 119. int num = 0; 120. // 得到单个日期的文档数目 121. for (int j = 0; j < keylist->count; j++) 122. { 123. if (strcmp(date, infolist->time) == 0) { 124. count[num]++; 125. } else { 126. count[++num]++; 127. } 128. strcpy_s(date, infolist->time); 129. infolist = infolist->next; 130. } 131. fprintf(fwp, "%s %d %d\n", words[i], num + 1, rownum); 132. WriteFile(keylist, num, fwp, count); 388 133. rownum++; 134. } 135. } 136. 137. free(pbuf); 138. // fclose(frp); 139. fclose(fwp); 140. system("pause"); 141. return 0; 142. } 修改后编译运行,生成的 index.txt 文件如下: 4.2、为关键词添上编码 如上图所示,已经满足需求了。但可以再在每个关键词的背后添加一个计数表示索引到 了第多少个关键词: 389 第五节、算法的二次改进 5.1、省去二次 Hash 针对本文评论下读者的留言,做了下思考,自觉可以省去二次 hash: 1. for (int i = 7; i < count; i++) 2. { 3. // 将关键字对应的文档内容加入文档结点链表中 4. //也就是说当查询到 hash 表中没有某个关键词之,后便会插入 5. //而查询的时候,search 会调用 hashstring,得到了 nHashC ,nHashD 6. //插入的时候又调用了一次 hashstring,得到了 nHashA,nHashB 7. //而如果查询的时候,是针对同一个关键词查询的,所以也就是说 nHashC&nHashD,与 nHashA&nHashB 是相同的,无需二次 hash 8. //所以,若要改进,改的也就是下面这个 if~else 语句里头。July, 2011.12.30。 9. if (keylist = SearchByString(items[i])) //到 hash 表内查 询 10. { 11. doc_list infolist = SaveItems(); 12. infolist->next = keylist->next; 13. keylist->count++; 14. keylist->next = infolist; 15. } 16. else 17. { 390 18. // 如果关键字第一次出现,则将其加入 hash 表 19. int pos = InsertString(items[i]); //插入 hash 表 20. keylist = key_array[pos]; 21. doc_list infolist = SaveItems(); 22. infolist->next = NULL; 23. keylist->next = infolist; 24. if (pos != -1) 25. { 26. strcpy_s(words[wordnum++], items[i]); 27. } 28. } 29. } 30. } 31. } 32. 33. // 通过快排对关键字进行排序 34. qsort(words, WORD_MAX_NUM, WORD_MAX_LEN, strcoll); 5.2、除去排序,针对不同日期的记录直接插入 1. //对链表进行冒泡排序。这里可以改成快速排序:等到统计完所有有关这个关键词的文章之后, 才能对他集体快排。 2. //但其实完全可以用插入排序,不同日期的,根据时间的先后找到插入位置进行插入: 3. //假如说已有三条不同日期的记录 A B C 4. //来了 D 后,发现 D 在 C 之前,B 之后,那么就必须为它找到 B C 之间的插入位置, 5. //A B D C。July、2011.12.31。 6. void ListSort(key_list keylist) 7. { 8. doc_list p = keylist->next; 9. doc_list final = NULL; 10. while (true) 11. { 12. bool isfinish = true; 13. while (p->next != final) { 14. if (strcmp(p->time, p->next->time) < 0) //不同日期的按最早到最晚排 序 15. { 16. SwapDocNode(p); 17. isfinish = false; 18. } 19. p = p->next; 20. } 21. final = p; 22. p = keylist->next; 23. if (isfinish || p->next == final) { 391 24. break; 25. } 26. } 27. } 综上 5.1、5.2 两节免去冒泡排序和,省去二次 hash 和免去冒泡排序,修改后如下: 1. for (int i = 7; i < count; i++) { 2. // 将关键字对应的文档内容加入文档结点链表中 3. // 如果关键字第一次出现,则将其加入 hash 表 4. InitHashValue(items[i], hashvalue); 5. if (keynode = SearchByString(items[i], hashvalue)) { 6. doc_list infonode = SaveItems(); 7. doc_list p = keynode->next; 8. // 根据时间由早到晚排序 9. if (strcmp(infonode->time, p->time) < 0) { 10. //考虑 infonode 插入 keynode 后的情况 11. infonode->next = p; 12. keynode->next = infonode; 13. } else { 14. //考虑其他情况 15. doc_list pre = p; 16. p = p->next; 17. while (p) 18. { 19. if (strcmp(infonode->time, p->time) > 0) { 20. p = p->next; 21. pre = pre->next; 22. } else { 23. break; 24. } 25. } 26. infonode->next = p; 27. pre->next = infonode; 28. } 29. keynode->count++; 30. } else { 31. int pos = InsertString(items[i], hashvalue); 32. keynode = key_array[pos]; 33. doc_list infolist = SaveItems(); 34. infolist->next = NULL; 35. keynode->next = infolist; 36. if (pos != -1) { 37. strcpy_s(words[wordnum++], items[i]); 392 38. } 39. } 40. } 41. } 42. } 43. 44. // 通过快排对关键字进行排序 45. qsort(words, WORD_MAX_NUM, WORD_MAX_LEN, strcoll); 修改后编译运行的效果图如下(用了另外一份更大的数据文件进行测试): 本章全部源码请到以下两处任一一处下载(欢迎读者朋友们继续优化,若能反馈于我, 则幸甚不过了): 1. http://download.csdn.net/detail/v_july_v/4012605(csdn 下载处) 2. https://github.com/fuxiang90/CreateInvertedIndex.(github 下载处) 393 后记 本文代码还有很多的地方可以改进和优化,请待后续更新。当然,代码看起来也很青嫩, 亟待提高阿。 近几日后,准备编程艺术室内 38 位兄弟的靓照和 blog 或空间地址公布在博客内,给读 者一个联系他们的方式,顺便还能替他们征征友 招招婚之类的。ys,土豆,水哥,老梦,3, 飞羽,风清扬,well,weedge,xiaolin,555 等等三十八位兄弟皆都对编程艺术系列贡献卓 著。 最后说一句,读者朋友们中如果是初学编程的话切勿跟风学算法,夯实编程基础才是最 重要的。预祝各位元旦快乐。谢谢,本章完。 第二十七章:不改变正负数之间相对顺序重新排列数组.时间 O(N),空间 O(1) 前言 在这篇文章:九月腾讯,创新工场,淘宝等公司最新面试十三题的第 5 题(一个未排序整 数数组,有正负数,重新排列使负数排在正数前面,并且要求不改变原来的正负数之间相对 顺序),自从去年九月收录了此题至今,一直未曾看到令人满意的答案,为何呢? 因为一般达不到题目所要求的:时间复杂度 O(N),空间 O(1),且保证原来正负数之间的 相对位置不变。本编程艺术系列第 27 章就来阐述这个问题,若有任何漏洞,欢迎随时不吝 指正。谢谢。 重新排列使负数排在正数前面 原题是这样的: 一个未排序整数数组,有正负数,重新排列使负数排在正数前面,并且要求不改变原来的正 负数之间相对顺序。 比如: input: 1,7,-5,9,-12,15 ,ans: -5,-12,1,7,9,15 。且要求时间复杂度 O(N), 空间 O(1) 。 394 OK,下面咱们就来试着一步一步解这道题,如下 5 种思路(从复杂度 O(N^2)到 O(N*logN),从不符合题目条件到一步步趋近于条件)): 1. 最简单的,如果不考虑时间复杂度,最简单的思路是从头扫描这个数组,每碰到一 个正数时,拿出这个数字,并把位于这个数字后面的所有数字往前挪动一位。挪完 之后在数组的末尾有一个空位,这时把该正数放入这个空位。由于碰到一个正,需 要移动 O(n)个数字,因此总的时间复杂度是 O(n2)。 2. 既然题目要求的是把负数放在数组的前半部分,正数放在数组的后半部分,因此所 有的负数应该位于正数的前面。也就是说我们在扫描这个数组的时候,如果发现有 正数出现在负数的前面,我们可以交换他们的顺序,交换之后就符合要求了。 因此我们可以维护两个指针,第一个指针初始化为数组的第一个数字,它只向后移 动;第二个指针初始化为数组的最后一个数字,它只向前移动。在两个指针相遇之 前,第一个指针总是位于第二个指针的前面。如果第一个指针指向的数字是正而第 二个指针指向的数字是负数,我们就交换这两个数字。 但遗憾的是上述方法改变了原来正负数之间的相对顺序。所以,咱们得另寻良策。 3. 首先,定义这样一个过程为“翻转”:(a1,a2,...,am,b1,b2,...,bn) --> (b1,b2,...,bn,a1,a2,...,am)。其次,对于待处理的未排序整数数组,从头到尾进行扫 描,寻找(正正...正负...负负)串;每找到这样一个串,则计数器加 1;若计数为奇数, 则对当前串做一个“翻转”;反复扫描,直到再也找不到(正正...正负...负负)串。 此思路来自朋友胡果果,空间复杂度虽为 O(1),但其时间复杂度 O(N*logN)。更多 具体细节参看原文: http://qing.weibo.com/1570303725/5d98eeed33000hcb.html。故,不符合题 目要求,继续寻找。 4. 我们可以这样,设置一个起始点 j, 一个翻转点 k,一个终止点 L,从右侧起,起始点 在第一个出现的负数, 翻转点在起始点后第一个出现的正数,终止点在翻转点后出现 的第一个负数(或结束)。 如果无翻转点, 则不操作,如果有翻转点, 则待终止点出现后, 做翻转, 即 ab => ba 这样的操作。翻转后, 负数串一定在左侧, 然后从负数串的右侧开始记录起始点, 继 续往下找下一个翻转点。 例子中的就是(下划线代表要交换顺序的两个数字): 1, 7, -5, 9, -12, 15 第一次翻转: 1, 7, -5, -12,9, 15 => 1, -12, -5, 7, 9, 15 第二次翻转: -5, -12, 1, 7, 9, 15 395 此思路 2 果真解决了么?NO,用下面这个例子试一下,我们就能立马看出了漏洞: 1, 7, -5, -6 , 9, -12, 15 ( 此 种 情 况 未 能 处 理 ) 1 7 -5 -6 -12 9 15 1 -12 -5 -6 7 9 15 -6 -12 -5 1 7 9 15 (此时,正负数之间的相对顺序已经改变,本应该是-5,-6,-12, 而现在是-6 -12 -5) 5. 看来这个问题的确有点麻烦,不过我们最终貌似还是找到了另外一种解决办法,正 如朋友超越神所说的:从后往前扫描,遇到负数,开始记录负数区间,然后遇到正 数,记录前面的正数区间,然后把整个负数区间与前面的正数区间进行交换,交换 区间但保序的算法类似(a,bc->bc,a)的字符串原地翻转算法。交换完之后要继续向 前一直扫描下去,每次碰到负数区间在正数区间后面,就翻转区间。下面,将详细 阐述此思路 4。 思路 5 之区间翻转 其实上述思路 5 非常简单,既然单个翻转无法解决问题,那么咱们可以区间翻转阿。什 么叫区间翻转?不知读者朋友们是否还记得本 blog 之前曾经整理过这样一道题,微软面试 100 题系列第 10 题,如下: 10、翻转句子中单词的顺序。 题目:输入一个英文句子,翻转句子中单词的顺序,但单词内字符的顺序不变。句子中单词 以空格符隔开。为简单起见,标点符号和普通字母一样处理。例如输入“I am a student.”, 则输出“student. a am I”。而此题可以在 O(N)的时间复杂度内解决: 由于本题需要翻转句子,我们先颠倒句子中的所有字符。这时,不但翻转了句子中单词 的顺序,而且单词内字符也被翻转了。我们再颠倒每个单词内的字符。由于单词内的字符被 翻转两次,因此顺序仍然和输入时的顺序保持一致。 以上面的输入为例:翻转“I am a student.”中所有字符得到“.tneduts a ma I”,再翻转每 个单词中字符的顺序得到“students. a am I”,正是符合要求的输出(编码实现,可以参看此 文:http://zhedahht.blog.163.com/blog/static/254111742007289205219/)。 对的,上述思路 3 就是这个意思,单词翻转便相当于于区间翻转,既如此,咱们来验证 下上述思路 2 中那个测试用例,如下: 1, 7, -5, -6, 9, -12, 15 1 7 -5 -6 -12 9 15 -12 -6 -5 7 1 9 15 (借用单词翻转的方法,先逐个数字翻转,后正负数整体原地翻转) -5 -6 -12 1 7 9 15 396 思路 5 再次被质疑 但是,我还想再问,问题至此被解决了么?真的被 KO 了么?NO,咱们来看这样一种情 况,正如威士忌所说: 看看这个数据,+-+-+-+-+------+,假如 Nminus 等于 n/2,由于前面都是+-+-+-,区间交换 需要 n/2/2 = n/4 次,每次交换是 T(2*(Nminus + Nplus)) >= T(n),n/4 * T(n) = T(n*n/4)=O(n^2)。 还有一种更坏的情况,就是+-+-+-+-+------+这种数据可能,后面一大堆的负数,前面正 负交替。所以,咱们的美梦再次破灭,路漫漫其修远兮,此题仍然未找到一个完全解决了的 方案。 公开征集本题思路 下面公开征集解决思路:如果你能想到完全满足和符合题目三个条件的思路(不改变相 对顺序&时间 O(N)&空间 O(1)),欢迎随时在本文下留言或评论,或者发至我邮箱: zhoulei0907@yahoo.cn。 如果验证完全正确属实,或者要么你就证明在那三个条件下此题无解,当然,你若提出 了本文内没有的思路,虽然严格论证下可能并不符合题目三个条件,但我将依然邀请您作为 听众来参加读书会第 2 期(到场嘉宾可能包括为 pongba,xlvector,penny,以届时现场为 准),再或者,你能指出本文文末 updated 部分:本题思路征集中的那些思路的不符要求与 错误,也行,限前 10 位,额满为止。 具体时间、场地待后续确定(微博上和本 blog 内会届时通知)。 updated:本题思路征集中之一解一点评 关于本题不改变正负数相对顺序重新排列数组,陆陆续续有不少朋友或发来了邮件,或在 本文评论下提供了他们自己的思路或解法,思维理性碰撞,共同享受思考的乐趣,我觉挺有意思, 精选其中一些解答贴出来,让大家评判、讨论,如下:  第 1 解:from Muqi:Hi July, 397 很高兴看到你的问题,真的很有意思! 我这里想到一种解法,主要的思路是通过改变数字内容来实现保留数字之间相对的 顺序: 比方说数列 3 4 -1 -3 5 2 -7 6 1 那负数相对顺序是: -1 -3 -7, 正数是 3 4 5 2 6 1 我们可以做变形: 负数成为 -1.1 -2.3, -3.7 (整数部分为相对顺序,小数部分为原来的数字) 同理 正数为: 1.3 2.4 3.5 4.2 5.6 7.1 现在数组变成: 1.3 2.4 -1.1 -2.3 3.5 4.2 -3.7 4.6 5.1 接下去 先通过置换把所有负数排到前面:具体方法为从前往后扫描数组,每碰到一 个负数 就和数组最前面的正数交换, 结果如下: -1.1 -2.3 -3.7 2.4 3.5 4.2 1.3 4.6 5.1 可以看到负数部分已经完成题目要求(只需要把整数部分去掉即可),接下去对于正 数数列 2.4 3.5 4.2 1.3 4.6 5.1, 也用类似的方法还原先前的顺序:具体方法 为一次遍历每个数字,检查其整数部分是否与其所在的位置相同,如不相同,将该 数字与位置为该数字整数部分的交换, 比如说 2.4 整数部分为 2,但是现在位于数 列第一位,所以与位于第二位的 3.5 交换,得到:3.5 2.4 4.2 1.3 4.6 5.1(最 多只需要 O(n)因为每次交换都保证一个数字回到原来的位置,而总共有 n 个数字), 最后和前面负数的处理相同,即去掉整数部分(+1,邀请来参加读书会第 2 期)。 点评:但此方法在本文评论下马上有人指出:不过,整数变成浮点数,存储空间要扩大 一倍,跟申请一个大小为 n 的数组一个道理,空间复杂度 O(N)不符要求。更多请看本文评 论下第 18 楼。(zj060607 & topskycen,+2)。  第 2 解:form 立宋(+7): 398  July 巨巨,由于在 csdn 上那贴删改留言次数有点多,csdn 不让留言了,就发邮件 给您吧。应该是最终稿了。 稍微改动下 Muqi 的方法,可以得到平均时间 O(n),最坏时间 O(n^2),空间复杂 度 O(1)的。当把负数放到数列前半部分操作时,这个负数是和前面的一个正数交换 的。交换过后,把这个正数变成他的相反数(5 变成-5 这种)。那么当第一轮把负数放 到前面过后,剩下的部分又形成了一个相同的子问题。当然,后面几轮把负数放到 前面后,得把他们重新恢复成相应的正数。 还是用 3 4 -1 -3 5 2 -7 6 1 为例子: 第一轮: -1 -3 -7 [ -3 -4 2 -5 6 1]. 第二轮: -1 -3 -7 -(-3) -(-4) -(-5) [-2 6 1]. 第三轮: 1 3 7 3 4 5 2 6 1 最终添上-号,-1 -3 -7 3 4 5 2 6 1 平均时间复杂度(假设数组是随机的): T(n)=T(n/2)+O(n). T(n)=O(n). 如果遇到+++++...+-这种情况,就会导致最坏的时间复杂度。 这也不算是完美的解法。有点怀疑完美的解是不存在的,但不知道怎么证明。 谢谢, mourisl 点评:from litaoye:我的想法(见下文之综合点评)也许同上述解法 2 类似,但我确实没 看明白解法 2 的操作过程,并且我认为解法 2 十有八九是错的。举个例子来说,如 1,2,-4,-5,3,-6 -4,2,1,-5,3,-6 -4,-5,1,-2,3,-6 -4,-5,-6,-2,3,-1 这样的话-2 同-1 的顺序就乱了。更多请看本文评论下第 29 楼。  第 3 解:jiangbin00cn 在其 blog 中提出了一种新的思路:假设全体数据为 n 个,正 数 m 个分别映射到 1--m,这 m 个数是分散分布在空间 n 中,利用桶排序使得其排列 在 n-m--n 中,这个过程用到了桶排序的思想,只不过每个桶中只有一个元素。具体 步骤如下: (1)桶排序能够在 时间 O(N),空间 O(1) 实现,那么能否利用桶排序解决该问题, 即如何将该问题转换为桶排序问题 (2)通过可逆的修改元素使得数组满足桶排序要求 (3)利用桶排序实现 (4)恢复元素 假设原数组中的全体正数按顺序依次为:a[0],...a[n] (a[0],a[1],....a[n]) = f(x) => (b[0],b[1],....b[n])= g(x) => (0,1,....,n) ==> 桶 排序 原始正数(可能相同) (修改为全不相同正数) (0,1,....,n) =g'(x)=> (b[0],b[1],....b[n])=f'(x)=> (a[0],a[1],....a[n]) 可逆运算恢复数据 可逆运算恢复数据 399 结论: 由于桶排序能够在 时间 O(N),空间 O(1) 实现,若可逆函数 f(x)、g(x)能够在 时间 O(N),空间 O(1)中找到并实现,那么就能够解决该问题(+3)。具体代码实现,请参 见原文:http://blog.csdn.net/jiangbin00cn/article/details/7331387。 点评:from 威士忌(+5),jiangbin00cn 和 Muqi 的方法都很取巧。其实他们的方法都是 压缩了整数值域或者扩大值域来保存附加信息,虽然符合时间 O(N)和空间 O(1)的要求,但 是并不适用所有 int 值。 这些方法的思路其实很简单,比如: num[] = {1,7,-5,9,-12,15}; pos[]={2,3,0,4,1,5}; pos 的计算扫描 2 遍 num 数组即可,有了 pos 数组当然排序不成问题。 关键解决 pos 空间问题时,两位做法分别是,Muqi 保存到 double 浮点域,jiangbin00cn 是 利用进制方法保存到 int 高位(其实根本不需桶排了),更明显的做法就是 flag(num[i])*pos[i]*1000+num[i]转换为 3007,-0005,-1012。 很高兴看到如此让人眼前一亮的方法,但是仔细想想的话,就觉得还是不符合要求。  第 4 解:from topskychen & acmerfight(+6): 首先明确题目的题意要求空间复杂度是 O(1),我的理解就是只能有一个空间来存储 数据,其他的任何临时变量都不能出现,包括循环变量和临时开辟的空间(例如数 字交换时)。 下边我的解法是在 允许自己输入数据,可以利用数组大小 n 的情况下产生的,只 包含一个额外的变量。 用 pos 记录正数的最最左位置减一, a[pos]记录负数最右的位置加一 基本步骤: 1 让 pos 代表最后一个数据的位置 2 然后输入一个数据存储在最后的位置 a[pos] 3 如果输入的数据 a[pos]是正数,我们就让 pos = pos - 1;如果输入的是负数就把 这个负数挪到前边,让 a[pos]-1 来记录负数最右的位置。 4 发生相应交换 代码在本文评论下第 28 楼。 综合点评 1. from 威士忌,感觉最近的几种解法越来越倒退了,还不如之前 nlogn 的来的有价 值。 400 2. from litaoye:只想到了 n*log(n),O(1)的方法。双指针分别指向头和尾,头指针找 到的正数同尾指针找到的负数交换,直到 2 个指针相遇。交换过程中将所有交换元 素 * -1,也就是正数变负数,负数变正数。此时被换到尾部的正数(*-1 后已经变为 负数),顺序正好倒过来了,把这部分反转一下。整个过程 O(n),把原问题转化为 两个规模为 n/2 的子问题。因此根据主定理,整个过程应当是 nlog(n)的,即最坏情 况下是 n^2 的,不过平均情况下也只是 nlog(n)的,达不到 O(n)。用迭代的方法写, 应该可以做到 O(1)(用递归,空间复杂度就是 log(n)了),感觉这个问题很难找到 O(n),O(1)的解法,类似的问题有完美洗牌问题,LZ 可以看一下,解法比较复杂, 是通过原根构造置换群来解的。本来还有个原地归并的思路,后来发现有问题,没 有继续深入。 3. from sbwwkmyd:除非也能找到划分固定环的方法,一直没找到办法将原根的特性 应用到这个问题上,完美洗牌问题 是这个问题的一个很小的子集。这个问题应该无 解。 4. from July:有 friends 反应,算法导论第 8 章线性时间排序思考题 8-2:以线性时 间原地置换排序,是此题原型。说运行时间为 O(n)、稳定、不使用额外空间原地排 序,这 3 个条件中,三者只能满足其二,由此推出第 27 章此题无解?果真如此么? 如何证明?此题作为面试题,能当场 K 掉 99%的面试者/面试官。 也有朋友反应,根据算导第 8 章中定理 8.1:任意一个比较排序算法在最坏情况下,都需 要 nlgn 次比较。即给定 n 个不同的输入元素,对于任何确定或随机的比较排序算法,其期 望运行时间都有下界 O(nlgn)。由此推出此题无解。但他们忽略了:不一定非要排序非要比 较。 也就是说,尽管: 1. 插入.归并.堆.快速排序皆是基于比较排序,且除归并排序外,皆是原地排序算法。 2. 堆/归并排序运行时间上界皆为 O(nlgn)。 3. 计数排序非基于比较,非原地排序,但稳定,是基数排序算法的一个子过程。 4. 计数/基数等非原地(需借助外部空间)排序,空间换时间。 但本题统统与这些无关,因为追根究底,本题实质性上只是一个排列,重新组合问题, 与排序无关。 更多还可参考此论文:《 STABLE MINIMUM SPACE PARTITIONING IN LINEAR TIME》。待后续验证。 401 后记 关于本文档的制作者:有鱼网 CEO 吴超 & 花明月暗 有鱼网 有鱼网--虚拟雇佣和在线工作平台,是我在北京的 3 个朋友的创业项目,他们于去年 8 月份获得天使湾第一笔投资而后正式开始开发,核心团队 3 人,一人负责产品(好友吴超), 一人负责后端架构(Peter),一人负责前端(建猛),历经六个多月的开发,终于前不久即 2 月 22 日零点上线,网站名叫有鱼网(http://www.youyur.com/),一个虚拟雇佣与在线工作的 平台,理念很简单:就是把有一部分的工作搬到网上做,包括个人应聘公司,公司招纳个人, 项目交付,工资结算,办公地点一律放在网上.(有鱼网在我 blog 内文章:三五杆枪,可干革 命,三五个人,可以创业,中曾报道过)。 希望更多的人可以获得自由工作的机会,希望更多的人可以不再疲惫奔波于往返公司那 长达 4 个小时的上班路途中,希望更多的人可以享受随性自由。北冥有鱼,其名为鲲… 再次感谢有鱼网 CEO 吴超(微博@youyur 吴超),衷心希望有鱼网越办越好! 花明月暗 花明月暗(微博@花明月暗)是此前十三个经典算法研究带目录+标签的 PDF 文档的制作 者,如今,他又参与此编程艺术系列 PDF 电子版最后的目录+标签的工作,表示非常感谢。 关于本 TAOPP 系列 程序员编程艺术系列(简称 TAOPP 系列),围绕“面试”、“算法”、“编程”三个主题,注重 提高广大初学者的编程能力,以及如何运用编程技巧和高效的算法解决实际应用问题。 在此编程艺术系列的基础上,将来可能出的第一本书,便是:程序员编程艺术(第二本可 能跟纯算法相关),如果确定真出的话,大概还需要 3 年左右。我当然也可以在一年之内搞 定,只是大凡好的东西都需要沉淀。 咱们下次再见,有任何问题 blog 上留言,或邮箱:zhoulei0907@yahoo.cn 联系,谢谢。 July、2012.04.03。 版权所有,侵权必究。严禁用于任何商业用途,违者定究法律责任。 在开源盛世的当下,让我们竭力享受共同思考,一 起编程的乐趣,共同迎接互联网无私分享时代的来临! --July,2012.04.03 做客有鱼网公司所题。
还剩400页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 10 金币 [ 分享pdf获得金币 ] 1 人已下载

下载pdf

pdf贡献者

wu1g119

贡献于2015-04-19

下载需要 10 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf