TopK问题有几种方法?以及这些方案中的优化思路
问题描述:
从 arr[1,n] 这 n 个数中找出最大的 k 个数,这就是经典的TopK问题。
例子:
从 arr[1,12] = {5,3,7,1,8,2,9,4,7,2,6,6} 这n=12 个数中,找出最大的 k=5 个。
一、排序
排序是最容易想到的方法,将 n 个数排序之后,取出最大的 k 个,即为所得。
伪代码:
sort(arr, 1, n);
return arr[1, k];
时间复杂度: O(n*lg(n))
分析: 明明只需要TopK,却将全局都排序了,这也是这个方法复杂度非常高的原因。那能不能不全局排序,而只局部排序呢?这就引出了第二个优化方法。
二:局部排序
不再全局排序,只对最大的 k 个排序。
冒泡排序是一个常见的排序方法,每冒一个泡,找出最大值,冒k个泡,就得到TopK。
伪代码:
for(i=1 to k){
bubble_find_max(arr,i);
}
return arr[1, k];
时间复杂度: O(n*k)
分析: 冒泡,将全局排序优化成了局部排序,非TopK的元素是不需要排序的,节省了计算资源。不少人会想到,既然需求是TopK,那么是否这最大的 k 个元素也不需要排序呢?这就引出第三个优化方法。
三、堆
思路: 只找到TopK,而不去排序TopK。
先用前 k 个元素生成一个小顶堆,这个小顶堆用于存储,当前是最大的 k 各元素。
接着,从第 k+1 个元素开始扫描,和堆顶(堆中最小的元素)比较,如果被扫描的元素大于堆顶,则替换堆顶的元素,并调整堆,以保证堆内的 k 个元素,总是当前最大的 k 个元素。
直到扫描完所有 n-k 个元素,最终堆中的 k 个元素,就是所求的TopK。
伪代码:
heep[k] = make_heap(arr[1,k]);
for(i=k+1 to n){
adjust_heap(heap[k],arr[i]);
}
return heap[k];
时间复杂度: O(n*lg(k)) (假设运气很差的情况下,即每次都需要入堆调整)
分析: 堆,将冒泡的TopK排序优化成了TopK不排序,节省了计算资源。堆,是求TopK的经典算法,还有没有更快的?
四、随机选择
随机选择算法是《算法导论》中一个经典的算法,其时间复杂度为O(n),是一个线性复杂度的方法。
这个方法并不是所有人都知道,为了将算法讲透,先聊一些前序知识,一个经典算法:快速排序。
其伪代码是:
void quick_sort(int []arr, int low, int high){
if(low == high)
return;
int i = partition(arr, low, high);
quick_sort(arr, low, i-1);
quick_sort(arr, i+1, high);
}
其中核心算法思想是,分治法。
分治法(Divide&Conquer),把一个大的问题,转化为若干个子问题(Divide),每个子问题“都”解决,大的问题便随之解决(Conquer)。这里的关键词是“都”。从伪代码里可以看到,快速排序递归时,先通过partition把数组分隔为两个部分,两个部分“都”要再次递归。
分治法有一个特例,叫减治法。
减治法(Reduce&Conquer),把一个大的问题,转化为若干个子问题(Reduce),这些子问题中“只”解决一个,大的问题便随之解决(Conquer)。这里的关键词是“只”。
二分查找binary_search,BS,是一个典型的运用减治法思想的算法,其伪代码是:
int BS(int []arr, int low, int high, int target){
if(low>high) return -1;
mid =low+ (high-low)/2;
if(arr[mid] == target) return mid;
if(arr[mid] > target)
return BS(arr, low, mid-1, target);
esle
return BS(arr, mid+1, high, target);
}
从伪代码可以看到,二分查找,一个大的问题,可以用一个mid元素,分成左半区,右半区两个子问题。而左右两个子问题,只需要解决其中一个,递归一次,就能够解决二分查找全局的问题。
通过分治法与减治法的描述,可以发现,分治法的复杂度一般来说是大于减治法的:
快速排序:O(n*lg(n))
二分查找:O(lg(n))
回到我们的问题,快速排序的核心是:
i = partition(arr, low, high);
partition是干嘛的?
顾名思义,partition会把整体分为两个部分。
具体一些就是会用数组arr中的一个元素(默认是第一个元素t = arr[low])为划分依据,将数据 arr[low, high]划分成左右两个子数组:
int RS(arr, low, high, k){
if(low == high) return arr[low];
i = partition(arr, low, high);
temp = i-low; //数组前半部分元素个数
if(temp >= k)
return RS(arr, low, i-1, k); //求前半部分第k大
else
return RS(arr, i+1, high, k-i); //求后半部分第k-i大
}
这是一个典型的减治算法,递归内的两个分支,最终只会执行一个,它的时间复杂度是O(n)。