66算法系列-66漫谈topK 问题 很感谢,在5-6月份,和算法爱好者ZhangYu探讨了一些算法,并且一起参加了度娘的 编程大赛,虽没大的斩获,但是对自己的算法修养有很大的提高。 我试着总结其中的一些,希望与大家共同学习。 首先给出topK的经典学习资料,粮食如下: v_july_v的//不仅仅有算法,也有实践 《程序员编程艺术:第三章续、Top K算法问题的实现》 http://blog.csdn.net/v_july_v/article/details/6403777 《程序员面试题狂想曲:第三章、寻找最小的k个数》 http://blog.csdn.net/v_JULY_v/archive/2011/04/28/6370650.aspx 经典的《编程之美》 //下文有时简称bop 这里总结的问题是: topK这里以求N个数种求 前k大为 例子,求前k小可以类推。 这里排序的内容就是 整数, 如果排序内容不仅仅有整数,可以参考《编程之美》解法2和解法5. 下面是我个人的读后感, 首先说,我认为topk,其实是排序算法应用的集大成者。 其实更精确说是部分的堆排序和快速划分(欲知详情,请看下文) 简言之,思考topk的过程,其实是对各种算法进行pk的过程,也是认清算法优劣的过程。 下面我们来详细思考这个问题。 1按照排序算法复杂度逐渐递减的观点看 a. 一般,来个全排序(不妨使用快速排序(基于比较的最快的之一)), 然后输出前k大的数据。 这是一个初步的,当然我们大都不会采用的算法哈。 尤其海量数据时,空间占用为o(n),直接会不予考虑。 我认为这个算法最大的弱点 就是 做了过多的排序,其实只需部分排序。 --------------------------------------------------- b. k趟输出最大 每次取出集合中的最大,然后集合中排出最大,如此操作k次。 原本说要写 k趟冒泡排序,但是我想在冒泡中有不少swap操作,不如直接比较操作好。 关于如何实现,我会尝试如下: 标示已经选择的元素方法可以考虑: a)使用标志数组标示已经找到的最大值,每次输出最大后,为标志数组置位。(空间花销大) b)或者找出最大值后,直接输出,然后将该位置置为特殊数字或者字符。(排序的内容受限) c)或者用stl中的stack存储,用max寻找最值,后输出max,用popup操作去除找到的值。(综合排序和标示) 这种算法比第一种全排序好点,但是依然没有优化,排序策略太简单。前后两次两次 求极值没有关联(除了排序的元素少了一个而已)。 一个优秀的排序,应该做到前后的排序做到衔接,比如:快速排序就是每趟 将数据分成2个集合,而堆排序是每次调整都做到局部有序以至于每次调整操作放得复杂度只有logN. 下面我将会介绍基于堆排序和 快速排序的topK的解法。 --------------------------------------------------- c. 堆排序的变形:部分堆排序 千呼万唤始出来,其实如果要一句话回答topk的解法,很多人会条件反射的说 不就是个堆排序。 当然! 使用堆排序求topk,方法详细见bop书中的解法4。 堆排序的核心操作是建立初始堆和更新堆。 或者换个角度说是函数siftdown 和函数siftup(来自于编程珠玑函数命名) ,如果再抓重点,我会说是siftdown,原因是: siftup功能其实就是siftdown。值得注意的事siftdown一般实践为递归函数,这里可以换成非递归以提升性能。 需要注意的是这里的堆排序并非完全的堆排序。 第一次我接触到这个方法时,我始终想不通 要输出最大值,为什么要使用最小堆。 后来才明白这个问题与传统的堆排序不一样 a)传统的堆是要把所有的元素放入堆, 而这里是只放入k个,而不是N个。 b)更新堆的操作中, 传统的堆排序有: i)传统的堆排序是取自于堆末尾,堆尾巴与堆头交换后开始调整。 Ii)每次调整后堆尾巴往前面移动一位,堆的大小减一。 Iii)终止条件是最后移到堆头部为止。 topK(max k)的堆排序,其实简言之是部分的堆排序。 i)每次与堆头部交换的数据来自于外部,不来自于堆内部。并且交换并不是必须的,只有大于堆头部的才会被交换 Ii)每次调整堆后,堆的大小不变, Iii)终止条件是处理了剩下的N-k的数据。 最后列出复杂度 ps:还有一个使用大顶堆来排 前k大问题.,复杂度尽然达到了O(k*k) 但是作者说虽然复杂度剧减,但是常数因子不可忽视,还是推荐使用常用的小顶堆,具体情况请看v_july_v的博客。 -----------------------------end c---------------------- d.快速排序的变形:快速划分 快速排序这个算法再经典不过了,经典的问题可以反复品味,关于这个专题,v_july_v就写了至少2个博客, 写一个快速排序容易,但是写一个高效的快速排序可不是一件容易的事情啊。关于快速排序的优化,后面我会写个专题来讨论一下(待补充)。 首先说,我们不会照搬快速排序, 如果只是使用快速排序,可以看第一种情况,时间复杂度和空间复杂度都很高。 不妨我们来看看快速排序的实现过程, 先划分区间,然后分两个区间再次调用快速排序。 那么快速排序又是怎么实现的?其实代码会说话,详细的请看: 《程序员编程艺术:第三章续、Top K算法问题的实现》 http://blog.csdn.net/v_july_v/article/details/6403777 这里贴出 以使用随机划分算法的快速排序重要代码,个人觉得觉得比bop书 上清晰。 int random_select(int array[], int left, int right, int k) { // 处理异常情况 if (k < 1 || k > (right - left + 1)) return -1; // 主元在数组中的位置 int pos = random_partition(array, left, right); /* 对三种情况进行处理:(m = i - left + 1) 1、如果m=k,即返回的主元即为我们要找的第k小的元素,那么直接返回主元array即可; 2、如果m>k,那么接下来要到低区间array[left....pos-1]中寻找,丢掉高区间; 3、如果m<k,那么接下来要到高区间array[pos+1...right]中寻找,丢掉低区间。 */ int m = pos - left + 1; if(m == k) return array[pos]; else if (m > k) return random_select(array, left, pos - 1, k); else return random_select(array, pos + 1, right, k - m); } 这里我想说的快速排序和快速分划有哪些不同: a)递归出口不一样。(这里讨论的正常出口,等于 ) 前者是区间长度为1返回,后者是区间长度为k时。 b)主函数中,快速排序中必须对两个区间进行递归。 而快速划分只对某一种情况进行递归。 那么快排和快速分化有那些相同点: a)可以这么说,快速排序和快速排序的分划函数是一样的。 b)关于异常区间的处理一样:区间长度小于0,二者递归都结束。区间长度小于k值也退出。 最后说一下算法复杂度 个人认为:平时使用中 使用三元取中法既可以了,使用五分化中项枢元法(详细请看 算法导论 快速排序章节)和随机选取枢纽元法较之比较复杂。 这里解释一下什么是快速划分的最坏情况,其实这跟快速排序最坏情况一样,元素排列完全逆序下。 最后小结下,这里快速分划直接可以达到了线性复杂度!! 那么还有那些算法可以达到线性复杂度。 下面一一理出。 -----------------------------end d---------------------- e.线性算法系列 除开上面使用的快速分划算法,还有计数排序,位图法。 a)计数排序。 核心思想就是被排序值做数组下标,数组里的值装入对应数的频率数,最后按照频率输出前k个。 b)位图法。 如果所有数字最多出现一次,使用位图法最好不过。这是最节约空间的一种。 两者算法都对排序的数据集有要求,所有书都是正整数,并且取值范围不要太大。 -----------------------------end e---------------------- f.其他 以上都是以排序的角度看待问题, 在bop书上,可以看到还有使用查找算法来解决。 二分查找的变形(复杂度n*logk) 主要思想是:找出前k大元素 等价于 找出第k大元素 。 2.按照k和n的相对数量级来选择算法(待完善) 这个观点来源于《编程之美》该问题最后的小节提出的见解。 我试着写出我的见解。 其实除了k.n的相对大小,还有数据集组成情况。 a)只有正整数 ; 只有整数;含浮点数; b)数据集中与否 c)数据有重复吗 当前考虑一般情况,所以两种特殊的线性算法(计数排序和位图)暂不考虑 如果k=100 //用于测试 一句话,大多数情况,除了内部不足,我都会考虑 快速划分的。 上述表格是我今天总结的,大家觉得有什么不妥的,尽管提意见。 3.小结: topk问题说大也不大,说笑也不小,从上午开始写,各种汇总资料,到现在过去5个小时,写之前以为自己懂了,写之后发现依然还有问题的缺口,比如算法的选择上,希望与网友共勉。 |