前言
寻找第K小的数属于顺序统计学范畴,通常我们可以直接在O(NlgN)的时间内找到第K小的数,使用归并排序或者堆排序对输入数据按从小到大进行排序,然后选择第K个即可。然而,我们还有更好的算法。
一、热身—最大值和最小值
首先来看一个简单的问题,在一个有n个元素的集合中,需要多少次比较才能确定其最小值呢?这可以很容易退出需要n-1次这个上界。伪代码如下所示:
MINIMUM(A)
min = A[1]
for i=2 to n
if A[i] < min
min = A[i]
return min
寻找最小值需要进行n-1次比较,这已经是最优结果。如果需要同时找出最大值和最小值,可以直接进行两次查询,一次最大值一次最小值,共需要2(n-1)次比较。而事实上,我们可以只通过3*[n/2]次比较就足以同时找到最大值和最小值。通过成对的处理元素,先将一对输入元素比较,找到较大值和较小值。然后将较大值与当前最大值比较,较小值与当前最小值比较,这样每两个元素需要比较3次,一共需要3*[n/2]次比较。我们可以用递归算法写一个寻找最大值和最小值的程序,代码如下:
void max_min(int a[], int l, int r, int &minValue, int &maxValue)
{
if (l == r) { //l与r之间只有1个元素
minValue = maxValue = a[l];
return;
}
if (l + 1 == r) { //l与r之间一共只有2个元素
if (a[l] > a[r]) {
minValue = a[r];
maxValue = a[l];
} else {
minValue = a[l];
maxValue = a[r];
}
return;
}
int lmax, lmin;
int m = (l + r) / 2;
max_min(a, l, m, lmin, lmax); //找出左边的最大值和最小值
int rmax, rmin;
max_min(a, m+1, r, rmin, rmax); //找出右边的最大值和最小值
maxValue = max(lmax, rmax); //最终的最大值
minValue = min(lmin, rmin); //最终的最小值
}
扩展
题目:最坏情况下如何利用n+-2次比较找到n个元素中的第2小的元素?
答案:在最坏情况下利用 n + - 2次比较,找出n个元素中第二小的元素。其方法叫做 tournament method, 算法实现如下:
对数组a[1…n]中元素成对的做比较,每次比较后讲较小的数拿出,形成的数组再继续这样处理,直到剩下最后的一个,就是数组中最小的那个。将这个过程以一个树的形式表现出来,如下图:
在这个过程中,非树叶节点就是比较的次数,一共进行了n-1次比较,树根即为最小的元素。而第二小的元素一定是在这个过程中与根节点进行过比较的元素。即上图中5,3和2。这样的节点最多有个,在这些节点中找到最小的元素需要进行-1次比较。因此总共所需的比较次数为n-1 + -1 =n+-2次。
二、主题—寻找第K小的数
寻找最大的数和最小的数很容易实现,但是寻找第K小的数不是那么简单。陈皓老大曾经写过一篇文章批判纯算法题的面试,说的是他们公司在面试新员工时出的算法题是找数组中第二小的数,当时提出排序思想的都直接被刷了,但是在实际应用中,可能排序又是用的最多的方法,因为实际应用往往需要多次查找第K小的值,而不是单纯的一次。如果给数组排序,则以后每次找第K小的数复杂度都为O(1)了。吐槽归吐槽,还是从算法角度来看看这个题目究竟能优化到什么程序,
寻找数组中第K小的数各种算法:
1)排序,然后选择第K个即可。时间复杂度为O(NlgN+K)。
2)用大小为K的数组存前K个数,然后找出这K个数中最大的数设为kmax,用时O(K)。遍历后面的N-K个数,如果有小于kmax的数,则将其替换kmax,并从新的K个数中重新选取最大的值kmax,更新用时O(K)。所以总的时间为O(NK)。
3)一个更好的方法是建堆。建立一个包含K个元素的最大堆,将后续的N-K每个数与堆顶元素比较,如果小于堆顶元素,则将其替换堆顶元素并调整堆得结构维护最大堆的性质,最后堆中包含有最小的K个元素,从而堆顶元素就是第K小的数。建堆的时间为O(K),每次调整最大堆结构时间为O(lgK),从而总的时间复杂度为O(K + (N-K)lgK)。
4)类快速排序方法。采用类此快速排序的方法,从数组中随机选择一个数将数组S划分为两部分Sa和Sb,如果Sa大小等于K,则直接返回,否则根据大小情况在两部分进行递归查找。
前两种方法比较简单,重点是后两种方法,在下面详细分析。
三、建堆求第K小的数
关于建堆详细过程参见自己动手写二叉堆,我们用前面的K个元素建立一个K个元素的最大堆,而后遍历数组后面的N-K个数,若有小于堆顶的元素,则将其与堆顶元素替换并调整堆的结构,维持最大堆的性质。
/*得到第K小的数*/
int minK(int A[], int N, int K) {
int heap[K+1];
build_max_heap(heap, A, K); //以数组A的前K个元素建立大小为K的最大堆
for (int i=K+1; i<=N; i++) { //遍历A[K+1]到A[N],如果有小于堆顶元素,则替换堆顶元素并保持堆的性质。
if (A[i] < heap[1]) {
heap[1] = A[i];
max_heapify(heap, 1, K);
}
}
return heap[1];
}
/*采用插入方法建立堆*/
void build_max_heap(int heap[], int A[], int K)
{
int heapsize = 0;
for (int i=1; i<=K; i++) {
max_heap_insert(heap, A[i], heapsize);
}
}
/*将key插入到堆中,并保证最大堆的性质*/
void max_heap_insert(int heap[], int key, int &heapsize)
{
heapsize++;
heap[heapsize] = INT_MIN;
heap_increase_key(heap, heapsize, key);
}
/*将堆中heap[i]的关键字增大为key*/
void heap_increase_key(int heap[], int i, int key)
{
assert(key >= heap[i]);
heap[i] = key;
while (i>1 && heap[parent(i)]<heap[i]) {
swap(heap, i, parent(i));
i = parent(i);
}
}
/*保持堆的性质*/
void max_heapify(int A[], int i, int heapsize)
{
int l = left(i);
int r = right(i);
int largest = i;
if (l<=heapsize && A[l]>A[i])
largest = l;
if (r<=heapsize && A[r]>A[largest])
largest = r;
if (largest != i) {
swap(A, i, largest);
max_heapify(A, largest, heapsize);
}
}
四、类快速排序算法
随机选择一个元素作为枢纽元,然后将数组分为左右两部分,根据左右两部分的大小来决定递归的区间, 平均时间复杂度为O(N),但是最坏时间还是O(N^2)。 这里跟快速排序有所不同,快速排序平均时间复杂度为O(NlgN),因为它需要递归调用两个部分,而寻找第K小的元素只需要考虑其中的一半。算法伪代码如下:
RAND-SELECT(A, l, u, k) ⊳ k th smallest of A[l .. u]
if l = u then return A[l]
p ← RAND-PARTITION(A, l, u)
i ← p – l + 1 ⊳ k = rank(A[p])
if i = k then return A[p]
if i < k
then return RAND-SELECT(A, p+1, u, k-i )
else return RAND-SELECT(A, l, p-1, k)
算法思想:采用类似快速排序的方法随机选择一个枢纽元进行划分,数组以p为界分成两个部分。如果左边部分的数目i(包括A[p])等于k,则返回A[p]。否则如果左边部分元素数目小于k,则递归调用该函数从右边即[p+1, u]选择第k-i小元素。如果左边元素数目大于k,则从范围[l, p-1]中选择第k小元素。
int random_partition(int a[], int l, int u)
{
swap(a, l, randint(l, u));
int i = l;
int j = u+1;
int t = a[l];
while (1) {
do {
i++;
} while (a[i] < t && i <= u);
do {
j--;
} while (a[j] > t);
if (i > j) break;
swap(a, i, j);
}
swap(a, l, j);
return j;
}
int random_select(int a[], int l, int u, int k)
{
assert(l <= u && k>=1 && k <= u-l+1); //确保选择的第k小的数范围不超过数组大小
if (l == u) return a[l]; //如果只有1个元素,可以直接返回
int p = random_partition(a, l, u); //划分
int i = p - l + 1;
if (i == k) //左边数目等于k,返回a[p]
return a[p];
else if (i < k) //左边数目小于k,从右边选择k-i小的元素
return random_select(a, p+1, u, k-i);
else //左边数目大于k,从左边选择第k小的元素
return random_select(a, l, p-1, k);
}