選擇問題(求第k個最小元素)

ARM的程式設計師敲著詩歌的夢發表於2020-04-04

什麼是選擇問題

選擇問題(selection problem)是求一個n個數列表的第k個最小元素的問題。這個數字被稱為第k個順序統計量(order statistic)。當然,對於k=1或者k=n的情況,我們可以掃描整個列表,找出最小或者最大的元素。對於其他情況,我們可以對列表進行排序,然後返回第k個元素。

可是,對於整個列表進行排序是不是小題大做?因為該問題僅僅是要找出第k小的元素,而不是要求把列表從小到大排列。

劃分的思路

我們可以將給定的列表根據某個值p(例如列表的第一個元素)進行劃分。一般來說,這是對列表元素的重新整理,使左邊部分包含所有小於等於p的元素,緊接著是中軸(pivot)p本身,再接著是所有大於等於p的元素。如下圖所示
這裡寫圖片描述

有兩種主要的劃分方法,本文討論 Lomuto 劃分,以後會介紹更有名的 Hoare 劃分。

Lomuto 劃分

Lomuto(洛穆託)劃分的虛擬碼如下:

// 演算法:Lomuto_Partition(A[l..r])
// 用第一個元素作為中軸對子陣列進行劃分
// 輸入:陣列A[0..n-1]的一個子陣列A[l..r],它由左右兩邊的索引l和r(l<=r)定義
// 輸出:A[l..r]的劃分和中軸的新位置

p = A[l]
s = l
for i=l+1 to r do
    if A[i] < p
        s = s+1;
        swap(A[s], A[i])
swap(A[l], A[s])
return s

利用劃分求第k小元素

我們如何利用劃分列表來尋找其第k小元素呢?

假設列表是以陣列實現的,其元素索引從0開始,那麼第k小的元素就是把此列表從小到大排序後,索引在k-1位置上的元素。

假設首次劃分此列表,s是分割位置,也就是劃分後中軸元素的索引。我們分3種情況進行討論:

[1]. 當s=k-1 ,那麼中軸p本身顯然就是第k小的元素;
這裡寫圖片描述

[2]. 如果s>k-1,那麼整個列表的第k小元素就是左邊部分的第k小元素;

這裡寫圖片描述

[3]. 如果s<k-1,那麼問題就轉換為求右邊部分的第(k-s-1)小元素;推導過程是這樣的:本來是求第k小,通過劃分,篩除了最前面的(s+1)個元素,所以只用求右邊部分(藍色)的第 k-(s+1)小。

這裡寫圖片描述

可以看出,第2種情況和第3種情況雖然沒有徹底解決問題,但是使問題的例項變小了。對於這個較小的例項可以用同樣的方法來解決,即遞迴求解。這個演算法被稱為“快速選擇”,在演算法思想中屬於減可變規模演算法(減治法的一種)。

C語言實現

// 交換*a和*b
void swap(int* a, int* b) 
{
    int temp = *a;
    *a = *b;
    *b = temp;
}
// a是陣列首地址,l和r分別是兩端的索引,要求l<=r
int Lomuto_partition(int a[], int l, int r) 
{
    int pivot = a[l];
    int j = l;

    for(int i = l + 1; i <= r; ++i)
    {
        if( a[i] < pivot )
        {
            swap( &a[++j], &a[i]);
        }
    }

    swap(&a[l], &a[j]);
    return j;   // j是下標
}


// 返回第k小的元素值。
// 注意:k不是下標,表示第k個
int __quick_select(int a[], int l, int r, int k) 
{
    // s是分裂點,中軸的下標
    int s = Lomuto_partition(a, l, r);

    if(s == l+k-1)
        return a[s];
    else if( s > (l+k-1) )// 處理左邊的部分
        return __quick_select(a, l, s-1, k);
    else // 處理右邊的部分
        return __quick_select(a, s+1, r, k-(s-l+1)); 
}

// 快速選擇主函式
int quick_select_min(int a[], int len, int k) 
{
    return __quick_select(a, 0, len-1, k);
}

測試程式碼如下:

#include <stdio.h>
#include <stdlib.h>

int main(int argc,char *argv[])
{

    int k = atoi(argv[1]); //把命令列輸入的字串轉為整數
    printf("k = %d \n",k);

    int array[] = {9,9,8,7,6,5,4,3,3,3,2,2,2,1,1,0,}; 
    int len = sizeof array/sizeof array[0];

    printf("%dth min = %d\n", k, quick_select_min(array,len,k) );

    return 0;
}

執行截圖:
這裡寫圖片描述

改進

該演算法也可以不用遞迴實現。在非遞迴版本中,甚至不需要調整k的值,只要一直呼叫Lomuto_partition,直到s=k-1為止。

程式碼如下:

int quick_select_min_2(int a[], int len, int k)
{
    int left = 0;
    int right = len - 1;
    int s;
    // 直到返回的下標是 k-1 為止
    while( (s = Lomuto_partition(a, left, right)) != (k-1) )
    {
        if(s < k-1)
            left = s+1; //處理右邊的部分
        else
            right = s-1; // 處理左邊的部分
    }
    return a[s];
}

參考資料

《演算法設計與分析基礎(第3版)》(清華大學出版社)

相關文章