寻找第K小的数

寻找第K小的数

分类: 数据结构与算法 面试题系列 477人阅读 评论(0) 收藏 举报

前言

寻找第K小的数属于顺序统计学范畴,通常我们可以直接在O(NlgN)的时间内找到第K小的数,使用归并排序或者堆排序对输入数据按从小到大进行排序,然后选择第K个即可。然而,我们还有更好的算法。


一、热身—最大值和最小值

首先来看一个简单的问题,在一个有n个元素的集合中,需要多少次比较才能确定其最小值呢?这可以很容易退出需要n-1次这个上界。伪代码如下所示:

  1. MINIMUM(A)
  2. min = A[1]
  3. for i=2 to n
  4. if A[i] < min
  5. min = A[i]
  6. return min
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]次比较。我们可以用递归算法写一个寻找最大值和最小值的程序,代码如下:

  1. void max_min(int a[], int l, int r, int &minValue, int &maxValue)
  2. {
  3. if (l == r) { //l与r之间只有1个元素
  4. minValue = maxValue = a[l];
  5. return;
  6. }
  7. if (l + 1 == r) { //l与r之间一共只有2个元素
  8. if (a[l] > a[r]) {
  9. minValue = a[r];
  10. maxValue = a[l];
  11. } else {
  12. minValue = a[l];
  13. maxValue = a[r];
  14. }
  15. return;
  16. }
  17. int lmax, lmin;
  18. int m = (l + r) / 2;
  19. max_min(a, l, m, lmin, lmax); //找出左边的最大值和最小值
  20. int rmax, rmin;
  21. max_min(a, m+1, r, rmin, rmax); //找出右边的最大值和最小值
  22. maxValue = max(lmax, rmax); //最终的最大值
  23. minValue = min(lmin, rmin); //最终的最小值
  24. }
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个数,若有小于堆顶的元素,则将其与堆顶元素替换并调整堆的结构,维持最大堆的性质。

  1. /*得到第K小的数*/
  2. int minK(int A[], int N, int K) {
  3. int heap[K+1];
  4. build_max_heap(heap, A, K); //以数组A的前K个元素建立大小为K的最大堆
  5. for (int i=K+1; i<=N; i++) { //遍历A[K+1]到A[N],如果有小于堆顶元素,则替换堆顶元素并保持堆的性质。
  6. if (A[i] < heap[1]) {
  7. heap[1] = A[i];
  8. max_heapify(heap, 1, K);
  9. }
  10. }
  11. return heap[1];
  12. }
  13. /*采用插入方法建立堆*/
  14. void build_max_heap(int heap[], int A[], int K)
  15. {
  16. int heapsize = 0;
  17. for (int i=1; i<=K; i++) {
  18. max_heap_insert(heap, A[i], heapsize);
  19. }
  20. }
  21. /*将key插入到堆中,并保证最大堆的性质*/
  22. void max_heap_insert(int heap[], int key, int &heapsize)
  23. {
  24. heapsize++;
  25. heap[heapsize] = INT_MIN;
  26. heap_increase_key(heap, heapsize, key);
  27. }
  28. /*将堆中heap[i]的关键字增大为key*/
  29. void heap_increase_key(int heap[], int i, int key)
  30. {
  31. assert(key >= heap[i]);
  32. heap[i] = key;
  33. while (i>1 && heap[parent(i)]<heap[i]) {
  34. swap(heap, i, parent(i));
  35. i = parent(i);
  36. }
  37. }
  38. /*保持堆的性质*/
  39. void max_heapify(int A[], int i, int heapsize)
  40. {
  41. int l = left(i);
  42. int r = right(i);
  43. int largest = i;
  44. if (l<=heapsize && A[l]>A[i])
  45. largest = l;
  46. if (r<=heapsize && A[r]>A[largest])
  47. largest = r;
  48. if (largest != i) {
  49. swap(A, i, largest);
  50. max_heapify(A, largest, heapsize);
  51. }
  52. }
/*得到第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小的元素只需要考虑其中的一半。算法伪代码如下:

  1. RAND-SELECT(A, l, u, k) ⊳ k th smallest of A[l .. u]
  2. if l = u then return A[l]
  3. p ← RAND-PARTITION(A, l, u)
  4. i ← p – l + 1 ⊳ k = rank(A[p])
  5. if i = k then return A[p]
  6. if i < k
  7. then return RAND-SELECT(A, p+1, u, k-i )
  8. else return RAND-SELECT(A, l, p-1, 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小元素。

  1. int random_partition(int a[], int l, int u)
  2. {
  3. swap(a, l, randint(l, u));
  4. int i = l;
  5. int j = u+1;
  6. int t = a[l];
  7. while (1) {
  8. do {
  9. i++;
  10. } while (a[i] < t && i <= u);
  11. do {
  12. j--;
  13. } while (a[j] > t);
  14. if (i > j) break;
  15. swap(a, i, j);
  16. }
  17. swap(a, l, j);
  18. return j;
  19. }
  20. int random_select(int a[], int l, int u, int k)
  21. {
  22. assert(l <= u && k>=1 && k <= u-l+1); //确保选择的第k小的数范围不超过数组大小
  23. if (l == u) return a[l]; //如果只有1个元素,可以直接返回
  24. int p = random_partition(a, l, u); //划分
  25. int i = p - l + 1;
  26. if (i == k) //左边数目等于k,返回a[p]
  27. return a[p];
  28. else if (i < k) //左边数目小于k,从右边选择k-i小的元素
  29. return random_select(a, p+1, u, k-i);
  30. else //左边数目大于k,从左边选择第k小的元素
  31. return random_select(a, l, p-1, k);
  32. }
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);
}


五、最坏情况下线性时间选择第k小的元素

参见算法导论第九章内容即可,下面是书中关于最坏情况下线性选择算法的描述:



可以参见上图对该算法的时间复杂度进行分析,其中箭头由大元素指向小元素。其中每组5个数(最后一个组除外),黄色表示的是每组的中位数,而红色的x则是这些所有中位数的中位数。可以知道至少有[N/5] / 2 = [N/10] (取下整) 组的中位数<=x,从而至少有3*[N/10] = [3N/10]个数<=x,如图中着色的区域为<=x的数字。同理至少有[3N/10]个数字>=x。对于N>50,有[3N/10] >= N/4,因此最后执行递归的时候元素最多为3N/4。算法伪代码和时间分析如下图所示:



最终时间复杂度为O(N),需要注意的是,算法中每个分组大小为5,如果改成3是不行的(每组为3的时间复杂度为O(NlgN)。如果分成组数为奇数的话,每组大小要>=5才能保证O(N)的时间。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值