線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

Amαdeus 發表於 2022-11-24

前言

本篇文章我將介紹 期望為線性時間 的選擇演算法和 最壞情況為線性時間 的選擇演算法,即分別為 平均情況下時間複雜度為O(n)最壞情況下時間複雜度為O(n) 的線性時間選擇。以下包含了我自己的全部思考和學習過程,參考書籍為 演算法導論(第三版)。😊



線性時間選擇問題

問題概述

線性時間選擇 :一個由 n互異的數字構成的集合a,選擇在這個集合中第 k 小的數 x,即集合中恰好有 k-1 個數字小於 x,並輸出這個數字 x
比如 我們有一個集合 {4, 2, 1, 7, 5},我們要找到集合中的第 3 小的元素,那麼答案就是 4 。

解決思路

相信大部分童鞋都學習過了 快速排序演算法 ,而我們的 線性時間選擇演算法 就是基於快速排序的。(如有對快速排序還不瞭解的童鞋,可以來看看這裡喲~ 快速排序)🥰🥰🥰

線上性時間選擇演算法中,我們會延用快速排序中使用到的劃分函式 Partition,我們將用這個 Partition 函式來遞迴劃分陣列。與快速排序不同的是,我們在呼叫這個 Partition 函式的時候,只會對陣列的其中一邊進行不斷劃分,而快速排序需要對兩邊都不斷進行劃分,最後得到正序序列。
在後面的分析中我們會發現,我們選擇的第 k 小的元素,可能就是當前這個劃分位置對應的元素,如果不在當前劃分位置,要麼就是在陣列劃分處的左半部分,要麼就是在陣列劃分處的右半部分。所以我們只需要對一邊進行操作,不斷去判斷當前劃分位置的元素是否是我們要找的第 k 小元素 x 即可。



平均情況為O(n)的線性時間選擇

演算法步驟

1、我們將 Partition 函式進行一個小小的改進,採用隨機取基準值的方式對陣列進行劃分,即 Randomized-Partition ,這樣有可能避免一些極端的劃分情況。(Partition 在後續的實現程式碼中,我將使用左右指標法)

2、得到當前基準值的劃分位置 mid,定義一個 res 記錄當前這個元素在 [left, right] 範圍中是第幾小元素。

3、如果 k == res,那麼這個這個劃分位置的元素就是我們要找的第 k 小的元素。
如果不是,有以下兩種情況:
k < res 時,我們要從當前 [left, right] 的左半部分進行尋找,即 [left, mid - 1]。不難發現,之後我們依舊找的是此範圍的第 k 小元素。
k > res 時,我們要從當前 [left, right] 的右半部分進行尋找,即 [mid + 1, right]但是要注意的是,對於整個集合範圍的第 k 小元素,此時我們要找的應該是 [mid + 1, right] 中的第 k - res 小元素。🥺



程式實現

原始碼

#include<iostream>
#include<algorithm>
#include<ctime>
using namespace std;

//原始劃分函式
int Partition(int a[], int left, int right){
    int i = left;
    int j = right;
    int key = a[left];

    while(i != j){
	while(i < j && a[j] >= key)      //向左找到小於基準值的值的下標
            j--;
        while(i < j && a[i] <= key)      //向右找到大於基準值的值的下標
            i++;
	swap(a[i], a[j]);
    }
    /*   i等於j時跳出迴圈 當前基準值此時在下標為i的位置(合適的位置)   */
    swap(a[left], a[i]);	         //最左邊的元素變為處於當前合適位置的元素,把基準值放在合適位置                                     
    return i;                            //返回合適位置(i,j都可以)
}

//隨機取基準值 劃分函式
int Randomized_Partition(int a[], int left, int right){
    int random = rand() % (right - left + 1) + left;  
    swap(a[random], a[left]);

    return Partition(a, left, right);
}

//平均情況O(n²)的線性時間選擇
int Randomized_Select(int a[], int left, int right, int k){
    if(left == right)
	return a[left];

    int mid = Randomized_Partition(a, left, right);
    int res = mid - left + 1;    //res表示當前是範圍內的第幾小
    if(k == res)
	return a[mid];
    if(k < res)
	return Randomized_Select(a, left, mid - 1, k);
    else
	return Randomized_Select(a, mid + 1, right, k - res);
}


main(){
    int a[27] = {25,11,9,1,13,21,3,10,27,15,19,8,30,35,22,12,31,2,7,23,26,5,14,37,4,34,17};
    cout<<"The 10th number is "<<Randomized_Select(a, 0, 26, 10)<<endl;
}

執行結果圖

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)



時間複雜度分析

最壞時間複雜度

假如我們每次劃分的位置的元素恰好每次都不是當前的第 k 小元素,我們要進行 n - 1 的劃分,而劃分的時間複雜度為 O(n),所以我們可以得到此時的最壞時間複雜度為 O(n²) 。舉個簡單一點的例子,假如我們要找的是當前集合中的最小的元素,即第 1 小元素,若我們每次劃分的位置的元素恰好是當前範圍內的最大元素,那麼可想而知我們的最壞時間複雜度就是 O(n²)

平均時間複雜度

現在我們重點分析隨機取基準值的線性時間選擇的平均時間複雜度,即如何求得平均時間複雜度為 O(n)。🤔

在分析過程中,我們需要用到一些機率論的知識(機率論好難的額😭😭😭)。

我們可以假設這個演算法在陣列 a[left ... right] 上執行的時間是一個隨機變數,記作 T(n) 。我們視每一次劃分返回的基準值是等可能性的,由此我們可以得到對於每一個 k (0<k<=n),子陣列 a[left, mid]恰好有 k 個元素小於基準值的機率為 1/n。我們可以得到下列關係式:
線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

現在我們具體計算 T(n)
假設 T(n) 是單調遞增的,我們可以評估得到其遞迴呼叫所需的上界。而對於我們之前設的隨機變數 Xk,當 kXk 相對應時,Xk1 ,在其他情況下都是取 0。當 Xk = 1 時,我們有可能要處理的是最半部分長度為 k - 1 的陣列,也有可能是右半部分長度為 n - k 的陣列,為了得到 T(n) 的上界,我們需要取兩種可能中的較大時間。同時不要忘了劃分本身消耗的時間。由此我們可以得到如下的關係式:
線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

對於 max(k-1, n-k) 的取值我們有如下的思考:
線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

我們可以很容易發現,當 n 為偶數時,T(n/2)T(n-1) 都各自出現了兩次;如果 n 為奇數呢,除了偶數情況下各自出現兩次之外,還有一個 T((n-1)/2) 出現了一次,但是並不影響上界的證明。我們總結之前得到的關係式,可以得到以下不等式:
線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

此時,E(T(n)) 最多是 cn,當常數 c > 4a 時,n >= 2c/(c-4a)。由此可以得到,當 n < 2c/(c-4a) 時,T(n) = O(1),這就滿足了我們在上面圖中做出的存在常數時間複雜度的假設;同時,因為 E(T(n)) 最多是 cn,我們的常數 c 也存在,那麼期望時間複雜度 E(T(n)) = O(n)

綜上,隨機取基準值的線性時間選擇的平均時間複雜度為 O(n)

分析過程中的思考

為什麼 E(Xk) 與 T(max(k-1, n-k)) 相互獨立

注意 Xk 表示的是子陣列 a[left, mid] 恰好有 k 個元素的機率,而我們所設的 T(n) 是在這個長度的子陣列上操作所執行的時間,這兩者是有本質上差別的。通俗地來講,我們可以把子陣列 a[left, mid] 看成是長度為 mid-left+1 的一列地磚,地磚上只有前 k 個上有數字,我們現在要從當前起點勻速走過這些地磚,不會去看這些數字是什麼和多少個,那麼我們走過去的時間一定是一個常量,即我們走過去的時間就是速度×地磚長度。可見 XkT(n) 是毫不相干的兩個量,所以自然是相互獨立地咯~。



最壞情況為O(n)的線性時間選擇

演算法思考

先前我們已經知道了平均情況下時間複雜度為 O(n) 的線性時間選擇演算法,但是它的最壞時間複雜度是 O(n²),那麼是否有方法可以使最壞時間複雜度降低到 O(n) 呢?🧐

接下來將介紹所謂的 最壞情況為O(n) 的選擇演算法,Select 選擇演算法。



演算法步驟

1、我們將集合中的元素進行每 5 個進行分組,剩餘於的 n mod 5 個元素組成單獨一個組;

2、對每一組單獨進行排序(比較簡單的排序方式都可以),取出每一組的中位數,並和序列的前幾個進行交換(為了後期方便);

3、將取出的所有組的中位數,遞迴呼叫 Select 函式,找出所有中位數的中位數 x

4、按照這個中位數 x,對當前 [left, right] 範圍序列進行劃分;

5、定義 res = mid - left + 1 判斷是否是當前的第 k 小元素,若是直接返回 a[mid]
否則,有以下兩種情況:
k < res,就在左半部分遞迴呼叫 Select 函式,尋找 [left, mid - 1] 內的第 k 小元素;
k > res,就在右半部分遞迴呼叫 Select 函式,尋找 [mid + 1, right] 內的第 k-res 小元素。



動態演示

我們以集合 a[27] = {25,11,9,1,13,21,3,10,27,15,19,8,30,35,22,12,31,2,7,23,26,5,14,37,4,34,17}, k = 10為例

初始元素,從左到右每五個分一組

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

每組排序找到中位數,最後一組不處理

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

中位數和前面交換,找出中位數的中位數x

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

按照x進行劃分並判斷

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

遞迴呼叫找左半部分並找出中位數

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

中位數和前面交換,找出此時的x

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

中位數的中位數x進行劃分

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

遞迴呼叫找右半部分並找出中位數

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

此時中位數就一個,作為x進行劃分找到第10小元素

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)



程式實現

原始碼

#include<iostream>
#include<algorithm>
#include<ctime>
using namespace std;

//最壞情況O(n)的線性時間選擇 劃分函式
int Partition(int a[], int left, int right, int x){
    //找到基準值並和第一個位置交換	
    for(int index = left; index <= right; index++){
	if(a[index] == x){
	    swap(a[index], a[left]);
	    break;
	}
    }
    //找到基準值位置確定劃分處(左右指標法)  
    int i = left, j = right;
    while(i != j){
	while(i < j && a[j] >= x)
	    j--;
	while(i < j && a[i] <= x)
	    i++;
	swap(a[i], a[j]);
    }
    swap(a[left], a[i]);
    return i;
}

//最壞情況O(n)的線性時間選擇
int Select(int a[], int left, int right, int k){
    //當小於五個元素時直接進行C++自帶排序(其他排序方式也可以)
    if(right - left < 5){
	sort(a + left, a + right + 1);   	//記得sort左閉右開
	return a[k + left - 1];
    }

    //每五個一組,找到各組中位數,並儲存到前(r-l-4)/5的位置
    for(int i = 0; i <= (right - left - 4)/5; i++){
	//當前 有(r-l-4)/5向下取整+1個 中位數
	sort(a + left + i * 5, a + left + i*5 + 4 + 1);
	//每組的中位數交換到前面
	swap(a[left + i], a[left + i*5 + 2]);      
    }

    //遞迴呼叫Select找出所有中位數的中位數 x	
    int x = Select(a, left, left + (right-left-4)/5, (right-left-4)/10 + 1);

    //以 x 為基準值找到所在的劃分位置
    int mid = Partition(a, left, right, x);
    int res = mid - left + 1;
    if(k == res) 
	return a[mid];				  //找到第 k 小該元素	
    if(k < res)
	return Select(a, left, mid - 1, k);       //繼續在左半部分尋找
    else
	return Select(a, mid + 1, right, k - res);//繼續在右半部分尋找
}


main(){
    int a[27] = {25,11,9,1,13,21,3,10,27,15,19,8,30,35,22,12,31,2,7,23,26,5,14,37,4,34,17};
    cout<<"第 10 小元素為: "<<Select(a, 0, 26, 10)<<endl;
}

執行結果圖

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)



時間複雜度分析

最壞情況時間複雜度

我們需要求得最壞時間複雜度,就是需要求得這個時間複雜度 T(n) 的上界。在確定了一個劃分基準值 x 時,我們不難發現,這個時候的 n/5向上取整 組中,除了那個不滿五個元素的組 和 x 自己所在的組,我們至少有一半的組有 三個元素 大於 x。那麼減去這兩個特殊的組,只算那至少一半的組,我們可以得到大於 x 的元素的個數如下:
線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

那麼由這個上界,在之前的 演算法步驟 5 中,我們遞迴呼叫的最大時間是 T(7n/10 + 6)
這個時候我們整理一下每個步驟需要的時間:
首先,之前的 演算法步驟 1 中,我們分組的時間複雜度很明顯是 O(n)

其次 演算法步驟 2 對每組的五個元素進行排序,我們視其為對相對於整個問題(可以把整個問題看成有非常多的元素,例如一億個資料)為 O(1) 規模大小的每組進行排序,對此我們要進行 O(n) 時間複雜度次數的排序,那麼我們在此步驟中相對於整個問題而言時間複雜度也是同級的O(n)

然後是 演算法步驟 3,遞迴呼叫 Select,以為有 n/5向上取整 箇中位數,顯然我們要消耗 T(n/5向上取整)

那麼綜合上面的幾個時間複雜度,我們可以得到如下關係式:
線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

現在我們要再次用到先前在 平均情況O(n)的線性時間選擇 板塊所用到的假設法來證明這個關係式最後解出的 T(n) 是線性的。🤔

我們假設存在足夠大的常數 c 使得對所有 n>0T(n)<=cn,還要假設某個小於常數的 n 使得T(n) = O(1);同時還需要選擇一個常數 a,使得對所有 n>0,關係式中的 O(n) 存在上界 an。由此我們可以得到如下不等式:
線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)

綜上,我們可以得到當前演算法的 最壞時間複雜度O(n),也許上面的證明有一些顛覆人們的認知,然後事實就是如此。😉



程式改進

程式改進點

我們在上面得到了當 n > 70 時的時間複雜度為 O(n);當 n <= 70 的時候只要 O(1) 的時間。

當我們解決問題的規模非常大時,假如有100000個互異的元素,我們不妨定一個數字,若當前陣列範圍小於這個數字時,直接進行簡單的排序,返回這個第 k 小的元素。我們之前得到的 n < 70,那麼我們可以用一個與之相近並大於它的數字來替代這個 70,就比如 75 吧。當資料規模小於 75 時,我們直接進行排序即可。

改程式序原始碼

下面是改進後的實現程式碼,測試用例規模 100000 。對於元素互異的問題,我們由於資料量較大,所以可以假設元素都是互異的。

#include<iostream>
#include<algorithm>
#include<ctime>
using namespace std;

//最壞情況O(n)的線性時間選擇 劃分函式
int Partition(int a[], int left, int right, int x){
    //找到基準值並和第一個位置交換	
    for(int index = left; index <= right; index++){
	if(a[index] == x){
	    swap(a[index], a[left]);
	    break;
	}
    }
    //找到基準值位置確定劃分處(左右指標法)  
    int i = left, j = right;
    while(i != j){
	while(i < j && a[j] >= x)
	    j--;
	while(i < j && a[i] <= x)
	    i++;
	swap(a[i], a[j]);
    }
    swap(a[left], a[i]);
    return i;
}

//最壞情況O(n)的線性時間選擇
int Select(int a[], int left, int right, int k){
    //當g規模小於 75 時直接進行排序(其他排序方式也可以)
    if(right - left < 75){
	sort(a + left, a + right + 1);   	//記得sort左閉右開
	return a[k + left - 1];
    }

    //每五個一組,找到各組中位數,並儲存到前(r-l-4)/5的位置
    for(int i = 0; i <= (right - left - 4)/5; i++){
	//當前 有(r-l-4)/5向下取整+1個 中位數
	sort(a + left + i * 5, a + left + i*5 + 4 + 1);
	//每組的中位數交換到前面
	swap(a[left + i], a[left + i*5 + 2]);      
    }

    //遞迴呼叫Select找出所有中位數的中位數 x	
    int x = Select(a, left, left + (right-left-4)/5, (right-left-4)/10 + 1);

    //以 x 為基準值找到所在的劃分位置
    int mid = Partition(a, left, right, x);
    int res = mid - left + 1;
    if(k == res) 
	return a[mid];				  //找到第 k 小該元素	
    if(k < res)
	return Select(a, left, mid - 1, k);       //繼續在左半部分尋找
    else
	return Select(a, mid + 1, right, k - res);//繼續在右半部分尋找
}


int *a = new int[1000 * 100];   //全域性變數,防止棧溢位
main(){
    srand((int)time(0));
    int index = 0;
    while(index < 100000){
	int num = rand() % 500000;
	a[index++] = num;
    }

    for(int i = 0; i < 100000; i++)
	cout<<a[i]<<" ";
    cout<<endl<<endl;

    cout<<"第 66666 小元素為: "<<Select(a, 0, 99999, 66666)<<endl;
}

程式執行結果圖

線性時間選擇(含平均情況O(n)和最壞情況O(n)演算法)