这道题最简单的思路是排序,时间复杂度是O(nlog(n))。但是这样做在那n-k 个数的排序上浪费了资源。
改进一下,将数组的前k个数作为最小的k数的缓存。从第k+1个数开始遍历,如果有比前k个数小的,就将其和前k个数那个较大交换。
照这个思路,可以引入一个结构,使得前k个数总是最大的数在第一个,这样每次遇到一个数值需要和前k个数中排在第一位的那个最大数比较就可以了。
这个结构就是最大堆。
思路一:维护一个maxSize为k的最大堆,用来存放这k个数,遍历数组,如果堆未满,插入堆中。如果堆已满,如果数字比堆的根节点小,则删除堆的根节点,向堆中插入这个数字。
时间复杂度为 O(nlog(k))。如果求最大的k个数,就是使用最小堆了。
思路二:其实我们也可以在n个整数的范围内构建最小堆,然后每次将堆顶的最小值取走,然后调整,再取走堆顶值,取k次,自然就找到了最小的k个数。
这样做的时间复杂度是多少呢?基于n个无序数构建最小堆,时间是O(n),而取走堆顶元素,将堆末元素放到堆顶重新调整的时间复杂度是 O(h),h为堆高度,这里堆高度h总是logn保持不变。
因此时间复杂度是O(n+k*logn),这个思路可以再改进:每次重新调整,只需要基于堆顶部k层进行调整,堆末元素被放到堆顶后,最多只需要下调至k层即可,因此调整的时间复杂度成了O(k)。这样做的时间复杂度成了 O(n+k*k)。
可以证明O(n+k*k) < O(nlog(k)),但是实际情况下,是否思路二更好呢?
我们别忘了思路二的初始化需要基于整个n个数构建堆,而思路一则不需要。实际情况下,我们往往需要基于大量分布式存储的n个数找出k个数,例如:在分布式存在10台服务器上的总大小大约2T的访问页面记录中找出访问量最高的100个页面。想要跨10台服务器整体构建最大堆,显然不现实,而思路一则只需要维护一个100的最小堆,然后顺序遍历10台服务器上的记录即可。
因此思路一更加具有实际意义。
这里给出思路一的实现。
如果真正写代码,要知道堆是没有STL的,也就是说我们要自己实现。
书中使用了multiset,multiset和set一样,都是基于红黑树实现,区别是set不允许重复而multiset允许重复。
那么multiset和vector区别在哪里?区别在于multiset支持排序。multiset的插入和删除的时间复杂度也都为 O(logk)
树上代码:
typedef multiset<int, greater<int> > intSet; typedef multiset<int, greater<int> >::iterator setIterator; void GetLeastNumbers_Solution2(const vector<int>& data, intSet& leastNumbers, int k) { leastNumbers.clear(); if(k < 1 || data.size() < k) return; vector<int>::const_iterator iter = data.begin(); for(; iter != data.end(); ++ iter) { if((leastNumbers.size()) < k) leastNumbers.insert(*iter); else { setIterator iterGreatest = leastNumbers.begin(); if(*iter < *(leastNumbers.begin())) { leastNumbers.erase(iterGreatest); leastNumbers.insert(*iter); } } } }
还有第三种思路,快速选择 法。这个方法参考了July的文章,以及csdn上huagong_adu所写的另一篇对它的解读。
试想一下,如果存在这么一个数,它在数组的第k位,而且正好前k-1个数比它小,后n-k个数比它大。那么这个时候,我们只需要这个数前面的那k个数即可。
这是不有点像快排中的 pivot?
先选取一个数作为基准比较数(作者称为“枢纽元”,即pivot),用快排方法把数据分为两部分Sa和Sb。
如果K< |Sa|( |Sa|表示Sa的大小),则对Sa部分用同样的方法继续操作;
如果K= |Sa|,则Sa是所求的数;
如果K= |Sa| + 1,则Sa和这个pivot一起构成所求解;
如果K> |Sa| + 1,则对Sb部分用同样的方法查找最小的(K- |Sa|-1)个数(其中Sa和pivot已经是解的一部分了)。
当pivot选择的足够好的时候,可以做到时间复杂度是O(n)
那么如何选择一个好的pivot?
这里必须提BFPRT算法,这个算法就是为了寻找数组中第k小的数而设。
BFPRT是一种获得较优秀pivot的方法。其过程有一个flash动画作为演示。其过程是将n个数5个一组划分,求出没一个5元组的中位数,然后再基于这些中位数继续求中位数,这个重复的次数应该是由k的大小来定,随后将选出的中位数作为pivot,将小于pivot的数交换到数组的左侧。接着基于这些小于pivot的值,继续通过“寻找中位数,定pivot,交换法” 来缩小范围,直到最后在一个较小范围内找到k个最小值。
BFPRT算法的时间复杂度做到了O(n)。这个算法的原文链接在此:Time Bounds for Selection。
附加,最大堆的插入删除操作:
template <typename T> Class MaxHeap(){ public: MaxHeap(int maxSize); ~MaxHeap(); bool Insert(T element); bool DeleteMax(); private: T *elements; int MaxSize = 0; int size = 0; } //创建堆,记住堆的根节点是element[1] //这样做的目的,是为了可以使用i/2来访问其父结点,可以使用2i表示其左结点。 template <typename T> MaxHeap::MaxHeap(int maxSize){ MaxSize = maxSize; elements = new T[maxSize+1]; size = 0; } //插入节点,其实就是从末节点开始,往上找,直到找一个地方,让新元素放进去后,比它的父节点小。 //找的过程中,找过的节点下移到它的子女所在的平台,为了给新结点腾地方 //找到这个地方后,就把新结点安进去 template <typename T> bool MaxHeap::Insert(T ele){ if(size == MaxSize){ return false; } size ++; int i = size; while(i > 1){ if(elements[i/2] >= ele) break; //找到合适的位子了 element[i] = element[i/2]; //没找到,把父节点往子节点上移,腾位置 i = i/2; } element[i] = ele; return true; } //删除节点,就是将根节点删除,但是我们知道,接下来必须调整,因为数组中间不能有空缺。 //调整的过程其实就是将数组末尾的那个元素找个地方放。根节点原来的空位不行,就把空位往下挪, //一直挪到这个空位放末尾节点合适了,所谓合适,就是末尾节点放在这里比其子女都大, //(就算中间没有合适的位置,移到叶节点,必然合适) ,把末尾节点放进去。 template <typename T> bool MaxHeap::DeleteMax(){ if(size == 0){ return false; } T temp = elements[size]; //末尾节点记下来。 size--; int i = 1; while(2*i <= size){ //当i变成叶节点,就会跳出循环 int j = 2 * i; if(j+1 <= size && elements[j+1] > elements[j]) //别忘右子节点可能不存在的情况 j++; //如果存在右子节点,而且比左子节点大,我们就和右子节点比 (就是验证是否temp比左右子节点都大罢了,所以和大的比) if(element[j] <= temp) //符合条件,左右子节点都比temp,也就是末节点大,那么空位置找到了,退出循环 break; elements[i] = elements[j]; //不符合条件,继续往下腾位置 i *= 2; } elementp[i] = temp; //就算一直找到最后也没找到,这个时候i已经是某一个叶节点,而且这个叶节点的值已经转移到父节点上,我们把temp付给它就可以 return true; }