写两种聪明的解法(更推荐第二种,原因见优缺点)。计数或者排序这些朴素的解法就不说了。
1. 利用partition函数的O(n)解法
实现
- 定义
partition
函数,partition
函数必须满足:传入一个数组和一个范围,返回一个index
,在index
左边的所有数字小于等于array[index]
,在index
右边的所有数字大于等于array[index]
(这里和快速排序中partition函数的要求不一样,在快速排序中如果使用Hoare Scheme或其变体形式的快速排序[注:即递归排序的两部分是相连的,而非被index隔开],仅仅要求被index分割的左半边数组小于等于右半边数组) - 不断地循环进行partition,直到
index == n - 1
,此时array[0...index]
就是最小的n个数
private static int[] firstN(int[] a, int k) {
int[] aCopy = Arrays.copyOf(a, a.length);
int left = 0, right = aCopy.length - 1;
int index = partition(aCopy, left, right);
while (index != k - 1) {
if (index < k - 1) {
left = index + 1;
index = partition(aCopy, left, right);
} else {
right = index - 1;
index = partition(aCopy, left, right);
}
}
int[] result = new int[k];
System.arraycopy(aCopy, 0, result, 0, k);
return result;
}
private static int partition(int[] a, int left, int right) {
swap(a, right, left + new Random(0).nextInt(right - left + 1));
int pivotValue = a[right];
int divideIndex = left;
// array[left...divideIndex - 1] < pivotValue
for (int i = left; i < right; i++) {
if (a[i] < pivotValue) {
swap(a, i, divideIndex);
divideIndex++;
}
}
swap(a, right, divideIndex);
// array[left...divideIndex - 1] < pivotValue && array[divideIndex] == pivotValue
return divideIndex;
}
private static void swap(int[] a, int i, int j) {
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
时间复杂度分析
平均时间复杂度:假设数组长度为n,partition所用时间为pn,那么由于每次都大约把原来数组划分为两半,平均所用时间 T(n) = p * (n + n / 2 + n / 4 + …) = O(n)
优缺点
- 优点
- 快
- 缺点
partition
会改变数组,因此需要拷贝原来的数组- 当数组很大以至于无法全部读取到内存中时不能使用
有Stack Overflow的风险(勘误:此条错误)- 最坏情况下会退化到O(n^2)的时间复杂度,这和快排一样
2. 利用最大堆的O(nlogk)解法
最大堆的定义:
- 最大堆是一颗完全二叉树
- 最大堆的根的值大于等于左右孩子的值
- 最大堆左右子树也是最大堆
最大堆可以被放进一个数组。如果把根放在index为0的位置,那么heap[i]
的左右孩子分别是heap[i * 2 + 1]
和heap[i * 2 + 2]
,父亲为heap[(i - 1) / 2]
,最后一片叶子为heap[heap.length - 1]
)
实现
- 建立一个最大堆
- 对于数组中每一个元素,如果该元素大于等于最大堆的根,那么这个元素肯定不是最小的k个数之一,直接丢弃;如果该元素小于最大堆的根,那么把最大堆的根替换为该元素,并调整最大堆以使其符合定义
- 迭代完数组所有的元素后,堆中所存的就是最小的k个元素(可以通过数学归纳法/循环不变量来证明)
private static int[] firstN(int[] a, int k) {
int[] heap = new int[k];
System.arraycopy(a, 0, heap, 0, k);
// build heap
for (int i = (k - 1) / 2; i >= 0; i--)
adjustHeap(heap, i);
for (int i = k; i < a.length; i++) {
if (a[i] < heap[0]) {
heap[0] = a[i];
adjustHeap(heap, 0);
}
}
return heap;
}
private static void adjustHeap(int[] heap, int root) {
int max = root;
int left = root * 2 + 1, right = root * 2 + 2;
if (left < heap.length && heap[left] > heap[max]) {
max = left;
swap(heap, root, left);
}
if (right < heap.length && heap[right] > heap[max]) {
max = right;
swap(heap, root, right);
}
if (max != root) {
adjustHeap(heap, max);
}
}
private static void swap(int[] array, int i, int j) {
int tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
public static void main(String[] args) {
int[] a = {3, 5, 1, 2, 4};
int n = 3;
int[] result = firstN(a, n);
System.out.println(Arrays.toString(result));
}
时间复杂度分析
调整堆的时间复杂度为O(logk),在整个过程中调整了n次堆,所以时间复杂度为O(nlogk),平均和最坏情况下都是这样的
优缺点
- 优点
- 稳定可靠,最坏情况下也有O(nlogk)的速度
- 可以排序很大的数组
- 实现简单,不容易出bug
- 缺点
- 平均比第一种方式稍微慢一点