java top k_Top K算法

Top K算法

问题描述:

从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个。

一、排序

b80462352a8155bc683e9d2a00a715c1.png

排序是最容易想到的方法,将n个数排序之后,取出最大的k个,即为所得。

伪代码:

sort(arr, 1, n);

return arr[1, k];

时间复杂度:O(n*lg(n))

分析:明明只需要TopK,却将全局都排序了,这也是这个方法复杂度非常高的原因。那能不能不全局排序,而只局部排序呢?这就引出了第二个优化方法。

二、局部排序

不再全局排序,只对最大的k个排序。

57482d417cde3d40563bc45e3ffa001c.png

冒泡是一个很常见的排序方法,每冒一个泡,找出最大值,冒k个泡,就得到TopK。

伪代码:

for(i=1 to k){

​ bubble_find_max(arr,i);

}

return arr[1, k];

时间复杂度:O(n*k)

分析:冒泡,将全局排序优化为了局部排序,非TopK的元素是不需要排序的,节省了计算资源。不少朋友会想到,需求是TopK,是不是这最大的k个元素也不需要排序呢?这就引出了第三个优化方法。

三、堆

思路:只找到TopK,不排序TopK。

4c1de2a8e96a7cb4d7d549fe226ec1cf.png

先用前k个元素生成一个小顶堆,这个小顶堆用于存储,当前最大的k个元素。

1625198391e699a668b410b9aac4fb88.png

接着,从第k+1个元素开始扫描,和堆顶(堆中最小的元素)比较,如果被扫描的元素大于堆顶,则替换堆顶的元素,并调整堆,以保证堆内的k个元素,总是当前最大的k个元素。

09886a6afbeedc0c890b4103f4925e93.png

直到,扫描完所有n-k个元素,最终堆中的k个元素,就是猥琐求的TopK。

伪代码:

heap[k] = make_heap(arr[1, k]);

for(i=k+1 to n){

​ adjust_heap(heep[k],arr[i]);

}

return heap[k];

时间复杂度:O(n*lg(k))

画外音:n个元素扫一遍,假设运气很差,每次都入堆调整,调整时间复杂度为堆的高度,即lg(k),故整体时间复杂度是n*lg(k)。

分析:堆,将冒泡的TopK排序优化为了TopK不排序,节省了计算资源。堆,是求TopK的经典算法,那还有没有更快的方案呢?

四、随机选择

随机选择算在是《算法导论》中一个经典的算法,其时间复杂度为O(n),是一个线性复杂度的方法。

这个方法并不是所有同学都知道,为了将算法讲透,先聊一些前序知识,一个所有程序员都应该烂熟于胸的经典算法:快速排序。

其伪代码是:

void quick_sort(int[]arr, int low, inthigh){

​ 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, inthigh, int target){

​ if(low> high) return -1;

​ mid= (low+high)/2;

​ if(arr[mid]== target) return mid;

​ if(arr[mid]> target)

​ return BS(arr, low, mid-1, target);

​ else

​ 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]划分成左右两个子数组:

左半部分,都比t大

右半部分,都比t小

中间位置i是划分元素

66ed0557aa547543ab165cdfd09988e1.png

以上述TopK的数组为例,先用第一个元素t=arr[low]为划分依据,扫描一遍数组,把数组分成了两个半区:

左半区比t大

右半区比t小

中间是t

partition返回的是t最终的位置i。

很容易知道,partition的时间复杂度是O(n)。

partition和TopK问题有什么关系呢?

TopK是希望求出arr[1,n]中最大的k个数,那如果找到了第k大的数,做一次partition,不就一次性找到最大的k个数了么?

问题变成了arr[1, n]中找到第k大的数。

再回过头来看看第一次partition,划分之后:

i = partition(arr, 1, n);

如果i大于k,则说明arr[i]左边的元素都大于k,于是只递归arr[1, i-1]里第k大的元素即可;

如果i小于k,则说明说明第k大的元素在arr[i]的右边,于是只递归arr[i+1, n]里第k-i大的元素即可;

这就是随机选择算法randomized_select,RS,其伪代码如下:

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大

}

5b17d4735ec01ba028c593e49b3859f2.png

这是一个典型的减治算法,递归内的两个分支,最终只会执行一个,它的时间复杂度是O(n)。

再次强调一下:

分治法,大问题分解为小问题,小问题都要递归各个分支,例如:快速排序

减治法,大问题分解为小问题,小问题只要递归一个分支,例如:二分查找,随机选择

通过随机选择(randomized_select),找到arr[1, n]中第k大的数,再进行一次partition,就能得到TopK的结果。

五、总结

TopK,不难;其思路优化过程,不简单:

全局排序,O(n*lg(n))

局部排序,只排序TopK个数,O(n*k)

堆,TopK个数也不排序了,O(n*lg(k))

分治法,每个分支“都要”递归,例如:快速排序,O(n*lg(n))

减治法,“只要”递归一个分支,例如:二分查找O(lg(n)),随机选择O(n)

TopK的另一个解法:随机选择+partition

仅做笔记用,如有侵权,联系作者删除

以下是一个简单的 Java 实现 k 近邻算法: ```java import java.util.*; public class KNN { private List<Data> trainSet; public KNN(List<Data> trainSet) { this.trainSet = trainSet; } public String classify(Data testData, int k) { List<Data> nearestNeighbors = findNearestNeighbors(testData, k); String label = getMostFrequentLabel(nearestNeighbors); return label; } private List<Data> findNearestNeighbors(Data testData, int k) { PriorityQueue<Data> pq = new PriorityQueue<>(k, new Comparator<Data>() { @Override public int compare(Data d1, Data d2) { return Double.compare(d2.distance, d1.distance); } }); for (Data trainData : trainSet) { double distance = getDistance(testData, trainData); trainData.distance = distance; if (pq.size() < k) { pq.offer(trainData); } else { Data top = pq.peek(); if (distance < top.distance) { pq.poll(); pq.offer(trainData); } } } List<Data> result = new ArrayList<>(); while (!pq.isEmpty()) { result.add(pq.poll()); } return result; } private String getMostFrequentLabel(List<Data> nearestNeighbors) { Map<String, Integer> countMap = new HashMap<>(); for (Data data : nearestNeighbors) { String label = data.label; countMap.put(label, countMap.getOrDefault(label, 0) + 1); } String label = null; int maxCount = 0; for (Map.Entry<String, Integer> entry : countMap.entrySet()) { if (entry.getValue() > maxCount) { label = entry.getKey(); maxCount = entry.getValue(); } } return label; } private double getDistance(Data d1, Data d2) { double sum = 0.0; for (int i = 0; i < d1.features.length; i++) { sum += Math.pow(d1.features[i] - d2.features[i], 2); } return Math.sqrt(sum); } private static class Data { String label; double[] features; double distance; public Data(String label, double[] features) { this.label = label; this.features = features; } } } ``` 使用方法: ```java List<KNN.Data> trainSet = new ArrayList<>(); trainSet.add(new KNN.Data("A", new double[]{1, 2})); trainSet.add(new KNN.Data("A", new double[]{2, 3})); trainSet.add(new KNN.Data("B", new double[]{3, 4})); trainSet.add(new KNN.Data("B", new double[]{4, 5})); KNN knn = new KNN(trainSet); KNN.Data testData = new KNN.Data(null, new double[]{1.5, 2.5}); String label = knn.classify(testData, 3); System.out.println(label); // 输出 "A" ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值