题目描述
什么是 Top K 问题?简单来说就是在一堆数据里面找到前 K 大(当然也可以是前 K 小)的数。
题解思路
方法一:直接排序
通过如快排等效率较高的排序算法,可以在平均 O(nlogn)的时间复杂度找到结果。
这种方式在数据量不大的时候简单可行,但固然不是最优的方法。
优化:
快排的 partition 划分思想可以用于计算某个位置的数值等问题,例如用来计算中位数;显然,也适用于计算 TopK 问题
每次经过划分,如果中间值等于 K ,那么其左边的数就是 Top K 的数据;
当然,如果不等于,只要递归处理左边或者右边的数即可
该方法的时间复杂度是 O(n) ,简单分析就是第一次划分时遍历数组需要花费 n,而往后每一次都折半(当然不是准确地折半),粗略地计算就是 n + n/2 + n/4 +… < 2n,因此显然时间复杂度是 O(n)
对比第一个方法显然快了不少,随着数据量的增大,两个方法的时间差距会越来越大
缺点:
虽然时间复杂度是 O(n) ,但是缺点也很明显,最主要的就是内存问题,在海量数据的情况下,我们很有可能没办法一次性将数据全部加载入内存,这个时候这个方法就无法完成使命了
#include <iostream>
#include <vector>
using namespace std;
int QuickSort(vector<int>& v, int left, int right, int k){
if(left > right) return -1;
int i = left, j = right, tmp = v[left];
while(i < j){
while(v[j] >= tmp && i < j) j--;
while(v[i] <= tmp && i < j) i++;
if(i < j) swap(v[i], v[j]);
}
v[left] = v[i];
v[i] = tmp;
if(i == k-1) return i;
else if(i < k-1) return QuickSort(v, i+1, right, k);
else return QuickSort(v, left, i-1, k);
}
int main(){
int arr[] = { 6, -1, 12, 71, 29, 33, 41, 25, 110, 8 };
vector<int> v(arr, arr+10);
int k = 3;
cout << "查找前K个大小的数字:";
for(int i = QuickSort(v, 0, 9, 10-k+1); i < v.size(); ++i) cout << v[i] << ' ';
return 0;
}
代码生成图:
方法二:利用分布式思想处理海量数据
面对海量数据,我们就可以放分布式的方向去思考了
我们可以将数据分散在多台机器中(设计一个哈希函数,进行文件拆分),然后每台机器并行计算各自的 TopK 数据,最后汇总,再计算得到最终的 TopK 数据
方法三:利用堆(最经典的解法)
维护一个大小为 K 的小顶堆,依次将数据放入堆中,当堆的大小满了的时候,只需要将堆顶元素与下一个数比较:如果大于堆顶元素,则将当前的堆顶元素抛弃,并将该元素插入堆中。遍历完全部数据,Top K 的元素也自然都在堆里面了。
当然,如果是求前 K 个最小的数,只需要改为大顶堆即可
对于海量数据,我们不需要一次性将全部数据取出来,可以一次只取一部分,因为我们只需要将数据一个个拿来与堆顶比较。
另外还有一个优势就是对于动态数组,我们可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,我们就直接拿它与堆顶的元素对比。这样,无论任何时候需要查询当前的前 K 大数据,我们都可以里立刻返回给他。
整个操作中,遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK),加起来就是 O(nlogK) 的复杂度,换个角度来看,如果 K 远小于 n 的话, O(nlogK) 其实就接近于 O(n) 了,甚至会更快,因此也是十分高效的。
#include <iostream>
#include <vector>
using namespace std;
//小顶堆结点调整
void adjustMinHeap(vector<int>& nums,int root,int len) {
int l_ch=2*root+1; //左子结点
int r_ch=l_ch+1; //右子结点
int index=root; //较大结点
if(r_ch < len && nums[r_ch] < nums[index]) index=r_ch;
if(l_ch < len && nums[l_ch] < nums[index]) index=l_ch;
if(index != root) //当前结点非最小结点
{
swap(nums[index],nums[root]);
adjustMinHeap(nums,index,len);
}
return;
}
vector<int> TopKInHeap(vector<int>& nums,int k,int len){
vector<int>res(nums.begin(),nums.begin()+k); //取出前k个数
for(int i=k/2-1;i>=0;i--) //根据前K个数建立一个小顶堆
{
adjustMinHeap(res,i,k);
}
//将剩下的数与堆顶做比较
for(int i=k;i<len;i++){
if(nums[i]>res[0]) //当前数比堆顶数大
{
res[0]=nums[i]; //将堆顶更新为该数
adjustMinHeap(res,0,k); //重新调整堆
}
}
return res;
}
int main(){
int arr[] = { 6, 1, 2, 7, 9, 3, 4, 5, 10, 8 };
vector<int> v(arr, arr+10);
int k = 5;
cout << endl << "查找前K个大小的数字:";
vector<int> res = TopKInHeap(v, k, 10);
for(int i = 0; i < k; ++i) {
if(i == 0) cout << res[i];
else cout << ' ' << res[i];
}
return 0;
}
代码运行图:
相关习题
- 从一百亿条地址数据中获取数量最多的Top10(这个文件大小大约是 100GB)
分析:
- 100GB 几乎不可能一次加载进内存进行操作,所以必须要拆分
- 那么可以利用分治的思想,把规模大的问题化小,然后解决各个小的问题,最后得出结果。
哈希分治法实现思路:
- ipv4 地址是一个 32 位的整数,可以用 uint 保存。
- 先设计一个哈希函数,把100个G的文件分成10000份,每份大约是 10MB,可以加载进内存了
例如:我设计一个简单的哈希函数是 f(ip) = ip % 10000,(ip 是个32位整数)
那么 5 % 10000 = 5,不管 5 在哪个地方 5 % 10000 的结果都是 5,这就保证了相同的 ip 会被放在同一个子文件中,方便统计,相同的元素经过同一个哈希函数,得出的哈希值是一样的。
那么我把100亿个 ip,都进行 ip % 10000 的操作,就完成了 100GB 文件分解成 10000个子文件的任务了。当然实际中哈希函数的选取很重要,尽量使得元素分布均匀,哈希冲突少的函数才是最好的。
- 记住,我把上面这个分解的过程叫做 Map,由一台叫 master 的计算机完成这个工作。
10MB 的小文件加进内存,统计出出现次数最多的那个ip
10MB 的小文件里面存着很多 ip,他们虽然是乱序的,但是相同的 ip 会映射到同一个文件中来!那么可以用二叉树统计出现次数,二叉树节点保存(ip, count)的信息,把所有 ip 插入到二叉树中,如果这个 ip 不存在,那么新建一个节点, count 标记 1,如果有,那么把 count++,最终遍历一遍树,就能找出 count 最大的 ip 了。
- 我把这个过程叫做 Reduce,由很多台叫 worker 的计算机来完成。
每个 worker 至少要找出最大的前10个 ip 返回给 master,master 最后会收集到 10000 * 10 个 ip,大约 400KB,然后再找出最大的前 10 个 ip 就可以了。
最简单的遍历10遍,每次拿个最大值出来就可以了,或者用快速排序,堆排序,归并排序等等方法,找出最大前 k 个数也行。