前言
本篇文章我將介紹 期望為線性時間 的選擇演算法和 最壞情況為線性時間 的選擇演算法,即分別為 平均情況下時間複雜度為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;
}
執行結果圖
時間複雜度分析
最壞時間複雜度
假如我們每次劃分的位置的元素恰好每次都不是當前的第 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。我們可以得到下列關係式:
現在我們具體計算 T(n):
假設 T(n) 是單調遞增的,我們可以評估得到其遞迴呼叫所需的上界。而對於我們之前設的隨機變數 Xk,當 k 與 Xk 相對應時,Xk 取 1 ,在其他情況下都是取 0。當 Xk = 1 時,我們有可能要處理的是最半部分長度為 k - 1 的陣列,也有可能是右半部分長度為 n - k 的陣列,為了得到 T(n) 的上界,我們需要取兩種可能中的較大時間。同時不要忘了劃分本身消耗的時間。由此我們可以得到如下的關係式:
對於 max(k-1, n-k) 的取值我們有如下的思考:
我們可以很容易發現,當 n 為偶數時,T(n/2) 到 T(n-1) 都各自出現了兩次;如果 n 為奇數呢,除了偶數情況下各自出現兩次之外,還有一個 T((n-1)/2) 出現了一次,但是並不影響上界的證明。我們總結之前得到的關係式,可以得到以下不等式:
此時,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 個上有數字,我們現在要從當前起點勻速走過這些地磚,不會去看這些數字是什麼和多少個,那麼我們走過去的時間一定是一個常量,即我們走過去的時間就是速度×地磚長度。可見 Xk 和 T(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為例
初始元素,從左到右每五個分一組
每組排序找到中位數,最後一組不處理
中位數和前面交換,找出中位數的中位數x
按照x進行劃分並判斷
遞迴呼叫找左半部分並找出中位數
中位數和前面交換,找出此時的x
中位數的中位數x進行劃分
遞迴呼叫找右半部分並找出中位數
此時中位數就一個,作為x進行劃分找到第10小元素
程式實現
原始碼
#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;
}
執行結果圖
時間複雜度分析
最壞情況時間複雜度
我們需要求得最壞時間複雜度,就是需要求得這個時間複雜度 T(n) 的上界。在確定了一個劃分基準值 x 時,我們不難發現,這個時候的 n/5向上取整 組中,除了那個不滿五個元素的組 和 x 自己所在的組,我們至少有一半的組有 三個元素 大於 x。那麼減去這兩個特殊的組,只算那至少一半的組,我們可以得到大於 x 的元素的個數如下:
那麼由這個上界,在之前的 演算法步驟 5 中,我們遞迴呼叫的最大時間是 T(7n/10 + 6) 。
這個時候我們整理一下每個步驟需要的時間:
首先,之前的 演算法步驟 1 中,我們分組的時間複雜度很明顯是 O(n);
其次 演算法步驟 2 對每組的五個元素進行排序,我們視其為對相對於整個問題(可以把整個問題看成有非常多的元素,例如一億個資料)為 O(1) 規模大小的每組進行排序,對此我們要進行 O(n) 時間複雜度次數的排序,那麼我們在此步驟中相對於整個問題而言時間複雜度也是同級的O(n);
然後是 演算法步驟 3,遞迴呼叫 Select,以為有 n/5向上取整 箇中位數,顯然我們要消耗 T(n/5向上取整);
那麼綜合上面的幾個時間複雜度,我們可以得到如下關係式:
現在我們要再次用到先前在 平均情況O(n)的線性時間選擇 板塊所用到的假設法來證明這個關係式最後解出的 T(n) 是線性的。?
我們假設存在足夠大的常數 c 使得對所有 n>0 有 T(n)<=cn,還要假設某個小於常數的 n 使得T(n) = O(1);同時還需要選擇一個常數 a,使得對所有 n>0,關係式中的 O(n) 存在上界 an。由此我們可以得到如下不等式:
綜上,我們可以得到當前演算法的 最壞時間複雜度 為 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;
}