现在的位置: 首页 > 综合 > 正文

寻找第K小的数

2014年09月05日 ⁄ 综合 ⁄ 共 3900字 ⁄ 字号 评论关闭

前言

寻找第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次比较,树根即为最小的元素。而第二小的元素一定是在这个过程中与根节点进行过比较的元素。即上图中532。这样的节点最多有个,在这些节点中找到最小的元素需要进行-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);  
}  


抱歉!评论已关闭.