排序,前K大问题
1. 方法描述
- 我的想法是利用数组存储所有元素,通过堆排序来实现排序功能,设元素总数为n,我们可以通过堆排序,每次得到第N大元素(N指的是次数),初始的大顶堆第i次会将最大的元素移到第n-i+1个位置(我最初的想法是冒泡排序,因为它会将第i大的元素冒到第n-i+1的位置,但是时间复杂度和空间复杂度较高);
2. 预估的时间复杂度和空间复杂度
-
时间复杂度(参考了https://blog.csdn.net/qq_34228570/article/details/80024306/的计算式子):
-
建立初始堆:
-
-
假设我们的数据序列对应的二叉树高度为k,则从倒数第二层右边的节点(第一个非终端节点)开始,每一层的节点都要执行把子节点中较大的那一个节点和父节点的元素大小进行比较,然后看是否交换(如果顺序是对的就不用交换)如果没交换就可以不用再执行下去了。如果交换了,那么又要选择一支子树进行比较和交换;高层也是这样逐渐递归。
-
那么第i层的比较次数计算为:s = 2^( i - 1 ) * 2*( k - i +1-1);其中 i 表示第几层,2^( i - 1) 表示该层上最多有多少个节点,2( k - i) 表示子树上要下调比较的最多次数。设S为建立初始堆的比较总次数,且因为叶子层不用交换,所以i从 k-1 开始到 1;又因为k为完全二叉树的深度,设S为建立初始堆的比较总次数,且因为叶子层不用交换,所以i从 k-1 开始到 1;又因为k为完全二叉树的深度,而log(n) =k,S = 2^(k+1) -2k -2<8log2(n)=4*n;
-
-
所以时间复杂度为:O(n)
-
-
筛选调整:
- 通过调用swap函数,在取出堆顶点放到对应位置并把原堆的最后一个节点填充到堆顶点之后,需要对堆进行重建,只需要对堆的顶点调用sift()函数。
- 每次重建意味着有一个节点出堆,所以需要将堆的容量减一。k为堆的层数。所以在每次重建时,随着堆的容量的减小,层数会下降,函数时间复杂度会变化。我的算法重建堆一共需要K次循环,每次的时间复杂度为O(logk)(k为堆的层数)
- 则得到时间复杂度为O(Klogn)
-
总的时间复杂度
- O(n)+O(Klogn)或者O(4*n + Klogn)
-
-
空间复杂度:O(1),辅助存储空间如temp等与n无关;
3. 伪代码
-
void HeapSort(Rectype s[], int n, int K) { int i; 得到初始堆; 进行K次sift算法; } void sift(Rectype s[], int low, int high) { int i = low, j = i * 2; Rectype temp = s[i];//得到堆的堆顶 while (j <= high) {//调整堆顶元素位置 if (j < high && s[j] < s[j + 1]) { j++;//找堆顶的最大孩子 } if (堆顶小于它最大的孩子) { 孩子调整到双亲节点; 向下筛选调整; } else { break;//无需继续筛选调整 } } s[i] = temp;//堆顶元素找到合适位置 }
4. 代码
-
初步代码(思路来源于书本和ppt)
#include <iostream> #include <map> #define N 100001 typedef int Rectype; using namespace std; void swap(int& a, int& b) { int temp = b; b = a; a = temp; } void sift(Rectype *s, int low, int high) { int i = low, j = i * 2; Rectype temp = s[i]; while (j <= high) { if (j < high && s[j] < s[j + 1]) { j++; } if (temp < s[j]) { s[i] = s[j]; i = j; j = 2 * i;//向下筛选 } else { break; } } s[i] = temp; } void HeapSort(Rectype *s, int n, int K) { int i; for (i = n / 2; i >= 1; i--) { sift(s, i, n);//从下往上调整 } for (i = n; i >= n - K + 1; i--) { swap(s[1], s[i]); sift(s, 1, i - 1); } } //topK里的元素是不唯一的 int main() { int i, n; cin >> n; Rectype* s = new int[n]; for (i = 1; i <= n; i++) { cin >> s[i]; } int K; cin >> K; if (n < K) { cout << "超出范围,无法得到前K大的数"; } else { HeapSort(s, n, K); for (i = n; i >= n - K + 1; i--) { if (i == n) { cout << s[i];//进行输出 } else { cout << " " << s[i]; } } } return 0; }
5. 总结与拓展
-
算法分享(由于topK问题主要应用在海量数据中处理中(参考:1. https://www.cnblogs.com/qlky/p/7512199.html,该博客内容部分有误))
eg: 假如我们有一亿个整数,我们要找到前10000个整数,我们设n为元素总量,我们要找前K大的元素,我们可以用如下方法:
-
全部排序: 当n特别大的时候,我们如果将所有元素进行排序,而我们只需要前K大个元素,则做了很多的无用功;而且最快的排序算法的时间复杂度一般为O(nlogn),如快速排序
-
局部淘汰法:该方法用一个容器(vector)保存前10000个数,然后将剩余的所有数字一一与容器内的最小数字相比,如果所有后续的元素都比容器内的10000个数还小,那么容器内这个10000个数就是最大10000个数。如果某一后续元素比容器内最小数字大,则删掉容器内最小元素(减少排序的元素数量),并将该元素插入容器,最后遍历完这1亿个数,得到的结果容器中保存的数即为最终结果了
- 基于局部淘汰法思想的最小堆
- 首先读入前10000个数来创建大小为10000的最小堆,采用bottom-up方法建堆的时间复杂度为O(K),然后遍历后续的数字,并与堆顶(最小)数字进行比较。如果比最小的数小,则继续读取后续数字;如果比堆顶数字大,则替换堆顶元素并重新调整堆为最小堆。整个过程直至1亿个数全部遍历完为止。
- 该算法的时间复杂度为O(nlogK),空间复杂度是O(1)(常数)。
(C++vector相关链接https://www.cnblogs.com/YJthua-china/p/6550960.html)
- 基于局部淘汰法思想的最小堆
-
分治法:将1亿个数据分成100份,每份100万个数据,找到每份数据中最大的10000个,最后在剩下的10010000个数据里面找出最大的10000个。如果100万数据选择足够理想,那么可以过滤掉1亿数据里面99%的数据。100万个数据里面查找最大的10000个数据的方法如下:用快速排序的方法,将数据分为2堆,如果大的那堆个数N大于10000个,继续对大堆快速排序一次分成2堆,如果大的那堆个数N大于10000个,继续对大堆快速排序一次分成2堆,如果大堆个数N小于10000个,就在小的那堆里面快速排序一次,找第10000-n大的数字;递归以上过程,就可以找到第1w大的数
-
Hash法(去掉无用的重复元素):如果这1亿个数据里面有重复的数无效,先通过Hash法,把这1亿个数字去重复,这样如果元素的重复率很高的话,会减少很大的内存用量,从而缩小运算空间,然后再用局部淘汰法或者分治法。
-
若问题为长度为n(n>=k)的乱序数组中S找出从大到小顺序的第(前)K个数,让我们看一下经典的TopK解法(转载自https://blog.csdn.net/program_developer/article/details/82346599)
-
解法一:时间复杂度O(n*K)
- 思想:利用冒泡利用冒泡排序或者简单选择排序,K次选择后即可得到第k大的数。总的时间复杂度为O(nK)
-
解法二:时间复杂度O(n * logK)
- 思想:维护一个大小为K的最小堆(,对于数组中的每一个元素判断与堆顶的大小,若堆顶较大,则不管,否则,弹出堆顶,将当前值插入到堆中。时间复杂度O(n * logk)
-
解法三:时间复杂度O(n) (解法三的代码来自《算法导论》)
- 思想:利用快速排序的思想,从数组S中随机找出一个元素X,把数组分为两部分Sa和Sb。Sa中的元素大于等于X,Sb中元素小于X。这时有两种情况:
- Sa中元素的个数小于k,则Sb中的第k-|Sa|个元素即为第k大数;
- Sa中元素的个数大于等于k,则返回Sa中的第k大数。
#include<iostream> using namespace std; int Partition(int a[], int i, int j) { int temp = a[i]; if (i < j) { while (i < j) { //从后往前扫找比枢轴值小的交换位置 while (i < j && a[j] >= temp) { } if (i < j) { a[i] = a[j]; } while (i < j && a[i] < temp) { i++; } if (i < j) { a[j] = a[i]; } } a[i] = temp; return i; } } int Search(int a[], int i, int j, int k) { int m = Partition(a, i, j); if (k == m - i + 1) { return a[m]; } else if (k < m - i + 1) { return Search(a, i, m - 1, k); } //后半段 else { return Search(a, m + 1, j, k - (m - i + 1)); } } int main() { int a[7] = { 1,2,3,4,6,7,8 }; int k = 3; cout << Search(a, 2, 6, k); }
- 思想:利用快速排序的思想,从数组S中随机找出一个元素X,把数组分为两部分Sa和Sb。Sa中的元素大于等于X,Sb中元素小于X。这时有两种情况:
-
解法四:时间复杂度O(nlogn+k)
- 思想:对这个乱序数组用堆排序、快速排序、或者归并排序算法按照从大到小先行排序,然后取出前k大,总的时间复杂度为O(nlogn + k)。
-
解法五:时间复杂度O(n*logn)
- 思想:二分[Smin,Smax]查找结果X,统计X在数组中出现,且整个数组中比X大的数目为k-1的数即为第k大 数。时间复杂度平均情况为O(n*logn)。
-
解法六:时间复杂度O(4n + klogn)
- 思想:用O(4n)的方法对原数组建最大堆,然后pop出k次即可。时间复杂度为O(4n + k*logn)
-
解法七:时间复杂度O(n):
- 思想:利用hash保存数组中元素Si出现的次数,利用计数排序的思想,线性从大到小扫描过程中,前面有k-1个数则为第k大数,平均情况下时间复杂度O(n)(关于计数排序写的不错的博客https://www.cnblogs.com/kyoner/p/10604781.html)
- 计数排序代码示例
while (n--) {//我们有一个元素总个数为n的序列 cin >> x; a[x]++;//利用下标排序 }
-
总结:解法三是我觉得比较灵巧的,当然我们也可以通过改变代码将求解TopK问题改为求解topK问题
-
-
-
应用相关:
- topK经常与查找的问题相挂钩,比如,要查找被查询频率topK的记录等
- 当我们的topK问题遇上重复问题,比如海量信息中的重复信息,此时一般使用位图法来消除重复信息,这样可以减少排序的元素个数(关于位图法的使用:https://blog.csdn.net/yangquanhui1991/article/details/52172340)
- 当然,具体应用具体分析,算法最好要拥有比较好的容错性
-
我的反思(设n为元素总数,K为前K大的K):
-
我的算法在海量数据的情境下不适用,占用的运行空间大,我的算法的时间复杂度为O(4*n+Klogn),空间复杂度为O(1)
-
而如上局部淘汰法的具体实现--最小堆就比较适合运用在海量数据中,它不需要一次性读入所有元素,不用建立一个大小为n的数组,可以减少占用的运行空间,它可以通过比较减少它需要参与排序的数字个数(如果通过然后遍历后续的数字,并与堆顶(最小)数字进行比较。如果比最小的数小,则继续读取后续数字;如果比堆顶数字大,则替换堆顶元素并重新调整堆为最小堆),该算法的时间复杂度为O(nlogK),空间复杂度为O(1)
-
如果情境中的重复元素无效,可以用map/Hash来解决
-