演算法導論 第二章:演算法入門 筆記 (插入排序、迴圈不變式、演算法分析、最好和最壞時間複雜度、選擇排序、分治法、合併排序)
插入排序:
排序問題的定義如下:
輸入:N個數{a1, a2,..., an }。
輸出:輸入序列的一個排列{a'1 ,a'1 ,...,a'n },使得a'n <=a' n<=...<=a' n。
待排序的數也稱為關鍵字( key) 。
插入排序的虛擬碼為:
INSERTION-SORT for j <- 2 to length[A] key <- A[j] Insert A[j]into the sorted sequence A[l...j-1]. i <- j - 1 while i > 0 and A[i] > key A[i + 1] <- A[i] i <- i - 1 A[i + 1] <- key
C/C++程式碼實現如下:
#include <stdio.h> #include <string.h> int main() { int n; int A[1000]; int k = 0; while (scanf("%d", &n) != EOF) { A[k] = n; k++; } printf("input complete\n"); for (int j = 1; j < k; j++) { int key = A[j]; int i = j - 1; while (i >= 0 && A[i] > key) { A[i + 1] = A[i]; i--; } A[i + 1] = key; for (int i = 0; i < k; i++) printf("%d ", A[i]); printf("\n"); //列印每輪排序後的輸出,這樣看起來更清晰 } printf("print the sorted number\n"); for (int i = 0; i < k; i++) printf("%d ", A[i]); return 0; }
執行結果如下:
8 7 9 2 4
^D
input complete
7 8 9 2 4
7 8 9 2 4
2 7 8 9 4
2 4 7 8 9
print the sorted number
2 4 7 8 9
Process finished with exit code 0
python3程式碼實現如下:
A = [] while True: try: A.append(int(input())) except: print('input complete') break for j in range(len(A)): if j >= 1: key = A[j] i = j - 1 while (i >= 0 and A[i] > key): A[i + 1] = A[i] i = i - 1 A[i + 1] = key for i in range(len(A)): print(A[i], end=' ') print('\n', end='') # 列印每輪排序後的輸出,這樣看起來更清晰 print('print the sorted number') for i in range(len(A)): print(A[i], end=' ') # python3預設列印會換行,加上end=' ',則每次後面會自動加上' '中內容而不是換行
執行結果如下:
8
7
5
9
2
^D
input complete
7 8 5 9 2
5 7 8 9 2
5 7 8 9 2
2 5 7 8 9
print the sorted number
2 5 7 8 9
Process finished with exit code 0
迴圈不變式:
迴圈不變式主要用來幫助我們理解演算法的正確性,對於迴圈不變式,必須具備三個性質:
初始化: 它在迴圈的第一輪迭代開始之前應該是正確的。
保持:如果在迴圈的某個一次迭代開始之前它是正確的,那麼在下一次迭代開始之前,應該保持正確。
中止: 當迴圈結束時,不變式給出了一個有用的性質,它有助於表明演算法是正確的。
當頭兩個性質成立時,就能保證迴圈不變式在迴圈的每一輪迭代開始之前,都是正確的。這類似於數學歸納法的證明。
在數學歸納法中,要證明某一性質是成立的,必須首先證明其基本情況和一個歸納步驟都是成立的。證明不變式在第一輪迭代開始之前是成立的,就有點類似千歸納法中對基本情況的證明;證明不變式在各次迭代之間保持成立, 就有點類似於歸納法中對歸納步驟的證明。
現在我們通過第一重迴圈的不變式來證明排序演算法的正確性,迴圈不變式為:
(注意討論迴圈不變式時陣列下標認為從1開始)
A[1...j-1]是一個包含原陣列第1到j-1元素並已排序的陣列,A[j...n]A[j...n]是待排序陣列。
初始化:
在第一輪迴圈體之前,j=2,那麼A[j-1]只包含一個元素A[1],A[1...j-1]顯然有序,不變式成立;
保持:
A[1...j−1]是已排陣列,通過內層迴圈(這裡我們先不討論第二重迴圈的不變式),A[j]被第j大的數交換,這就使得A[j]大於等於A[1...j−1]的所有數,小於等於A[j+1...n]的所有數。不變式仍然成立。
中止:
當迴圈中止時,假如i = 0,那麼A[1]處是空值,A[2...j]有序,此時j=length[A],且A[2...j]所有元素都大於key,那麼將key放入A[1]將使A[1...j]有序;如果A[i]<=key,那麼由於A[1...i]有序,所以key大於A[1...i]中所有元素,同時由於A[i+2,j]有序且所有元素都大於key,將key放入A[i+1]會使A[1...j]有序,不變式仍然成立。
迴圈中斷的條件和不變式一起,可以證明演算法的正確性。
我們再用迴圈不變式證明第二重while迴圈的正確性(注意分析時陣列下標也認為是從1開始):
第二重迴圈的目的找出一個值0<=i<=j,將key放入A[i+1]將使A[1...j]有序。這裡我們可以認為當執行完"key<-A[j]"之後,A[j]為空,即A[1..j]只包含j-1個元素。同樣“A[i+1]<-A[i]”這句程式碼也會將A[i]置空。
第二重迴圈不變式為:
(1)A[1...i]有序;
(2)A[i+2,j]有序且所有元素不小於key,同時A[i+2...j]中所有元素不小於A[1...i]中的任意元素;
(3)A[i+1]處是空閒位置。
初始:
i = j-1,A[j]被置空,再加上外重迴圈的不變式,性質(1)成立,A[i+2..j]包含0個元素所以(2)也成立。(3)明顯也成立。
保持:
迴圈體將值A[i]轉移到A[i+1]處,且i減小1。條件(1)顯然成立;迴圈執行之前A[i]>key,A[i]是A[0...i]中最大元素,所以執行之後條件(2)仍成立;條件(3)顯然成立。
中止:
當迴圈中止時,假如i = 0,那麼A[1]處為空值,A[2...j]有序(條件2),且A[2...j]所有元素都大於key,那麼將key放入A[1]將使A[1...j]有序;如果A[i]<=key,那麼由於A[1...i]有序,所以key大於A[1...i]中所有元素,同時由於A[i+2,j]有序且所有元素都大於key,將key放入A[i+1]會使A[1...j]有序。
習題:
2.1-2:
重寫過程INSERTION-SORT , 使之按非升序(而不是按非降序)排序。
INSERTION-SORT
for j <- 2 to length[A]
key <- A[j]
Insert A[j]into the sorted sequence A[l...j-1].
i <- j - 1
while i > 0 and A[i] < key
A[i + 1] <- A[i]
i <- i - 1
A[i + 1] <- key
2.1-3:
考慮下面的查詢問題:
輸入:一列數A = ( a1, a2, …, an 〉和一個值v。
輸出: 下標i, 使得v = A[i],或者當v不在A中出現時為NIL 。
寫出針對這個問題的線性查詢的虛擬碼,它順序地掃描整個序列以查詢v。利用迴圈不變式證明演算法的正確性。確保所給出的迴圈不變式滿足三個必要的性質。
LinearSearch(A,v)
for i=1 to length[A]
if A[i]=v
return i
return NIL
迴圈不變式證明:
迴圈不變式:for迴圈的每次迭代開始前,陣列A[1..i-1]中無與v相等的數。
初始化:
在迴圈的第一次迭代開始前,此時i=1,陣列A[1…0]不包含資料,顯然v不在A[1…0]中,迴圈不變式成立。
保持:
在迴圈某次迭代開始時,假設i=k,不變式是正確的,那麼A[1…k-1]不包含值為v的成員,執行此次迭代,假設沒有找到與v匹配的成員(若找到就是終止條件),那麼執行的結果可知A[k]不等於v,所以A[1...k]不包含值為v的成員,那麼在下次迭代開始時,i=k+1,此時A[1...i-1]即A[1...k],由前面可知不包含值為v的成員,迴圈不變式成立。
終止:
終止有兩種情況,第一種,在某次迭代中,假設i=k,找到了值為v的成員,由“保持”的分析可知,此時A[1...k-1]不包含值為v的成員,迴圈不變式成立。第二種,沒有找到值為v的成員,此時i=n+1,那麼A[1...i-1]即為A[1...n],A[1...n]即為原序列並且不包含值為v的成員,迴圈不變式成立。
演算法分析:
演算法分析即指對一個演算法所需要的資源進行預測。通常,資源是指我們希望測度的計算時間。
一般來說,演算法所需的時間與輸入規模同步增長的,因而常常將一個程式的執行時間表示為其輸入函式。輸入規模的概念與具體問題有關,對許多問題來說,最自然的度量標準是輸入元素的個數。
演算法的執行時間是指在特定輸人時,所執行的基本運算元(或步數)。可以很方便地定義獨立於具體機器的“步驟”概念。目前,先採用以下觀點,每執行一行虛擬碼都要花一定量的時間。雖然每一行所花的時間可能不同,但我們假定每次執行第1 行所花的時間都是常量ci,那麼在統計出插入排序演算法中每行程式碼的執行次數,就可以給出演算法執行時間的一個表示式。
下面我們來分析插入排序演算法:
插入排序使用的是演算法設計中的增量法:在排序陣列A[1...j-1]後,將A[j]插入,形成排好序的陣列A[1...j]。
首先給出INSERTION-SORT 過程中,每一條指令的執行時間及執行次數。對j=2, 3, …,n, n=length[A], 設tj為第5 行中while 迴圈所做的測試次數。當for 或while 迴圈以通常方式退出(即因為迴圈頭部的測試條件不滿足而退出)時,測試要比迴圈體多執行1次。另外還假定註解部分是不可執行的,因而不佔執行時間。
該演算法的總體執行時間為每一條語句執行時間之和。如果執行一條語句需要ci 步,又共執行了n 次這條語句,那麼它在總執行時間中佔ci*n 。求和得:
即使是同樣規模下的輸入,不同的輸入資料也會造成不同的執行時間。
如在插入排序演算法中,如果輸入陣列已有序,就會出現最佳情況:
對j = 2, 3, …, n 中的每一個值,我們發現,若在第5行中,當i取其初始值j-1時,都有A[i]小於等於key。那麼對j = 2 , 3, ... , n, 有tj= 1, 最佳執行時間為:
這一執行時間可以表示為an+b , 常量a 和b 依賴於語句的代價Ci,因此,它是n的一個線性函式。
如果輸入陣列是按照逆序排序的(亦即,是按遞降順序排序的),那麼就會出現最壞情況:
我們必須將每個元素A[j] 與整個已排序的子陣列A[1. .j-1] 中的每一個元素進行比較,因而,對j=2, 3, …,n, 有tj=j 。
這一最壞情況執行時間可以表示為an2+bn +c , 常量a 、b 和c 仍依賴於語句的代價ci; 因此,這是一個關於n 的二次函式。
在本書的餘下部分裡,一般考察演算法的最壞情況執行時間。一個演算法的最壞情況執行時間是在任何輸入下執行時間的一個上界。
我們還可以再做進一步的抽象,即執行時間的增長率(rate of growth) , 或稱增長的量級(order of growth) 。這樣,我們就只考慮公式中的最高次項(例如, an2),因為當n很大時,低階項相當來說不太重要。另外.還忽略最高次項的常數係數,因為在考慮較大規模輸人下的計算效率時,相對於增長率來說,係數是次要的。
如,插入排序的最壞情況時間代價為。這個記號用來表示時間複雜度。
習題:
2.2-1:
用形式表示函式n3/1000-100n2-100n+3 。
2.2-2:
考慮對陣列A中的n個數進行排序的問題:首先找出A中的最小元素,並將其與A[1] 中的元素進行交換。接著,找出A中的次最小元素,並將其與A[2] 中的元素進行交換。對A中頭n-1個元素繼續這一過程。寫出這個演算法的虛擬碼,該演算法稱為選擇排序(selection sort) 。對這個演算法來說,迴圈不變式是什麼?為什麼它僅需要在頭n-1個元素上執行,而不是在所有n個元素上執行?以 形式寫出選擇排序的最佳和最壞情況下的執行時間。
SELECTION-SORT{A)
n <- length[A]
for i<-1 to n-1 do
j <-FIND-MIN(A,i,n)
A[j]<->A[i]
end for
迴圈不變式:
在外迴圈的每次迭代開始前,陣列A[1...i-1]儲存A中最小的i-1個數,並且是有序的;子陣列A[i...n]是A中剩餘的未排序元素。
從迴圈不變式中可知,當迴圈結束時,即i=n時,A[1...n-1]中是排序好的A中最小的n-1個數,所以剩下的A[n]必然是A中最大的元素,所以不用再比較了。
程式執行時間分析:
最好和最壞情況下時間複雜度均為:
C/C++程式碼實現如下:
#include <stdio.h>
#include <string.h>
int main() {
int n;
int A[1000];
int k = 0;
while (scanf("%d", &n) != EOF) {
A[k] = n;
k++;
}
for (int i = 0; i < k - 1; i++) {
for (int j = i + 1; j < k; j++) {
if (A[j] < A[i]) {
int temp = A[j];
A[j] = A[i];
A[i] = temp;
}
}
for (int i = 0; i < k; i++)
printf("%d ", A[i]);
printf("\n");
//列印每輪排序後的輸出,這樣看起來更清晰
}
printf("print the sorted number\n");
for (int i = 0; i < k; i++)
printf("%d ", A[i]);
return 0;
}
執行結果如下:
1 7 6 4 2
^D
1 7 6 4 2
1 2 7 6 4
1 2 4 7 6
1 2 4 6 7
print the sorted number
1 2 4 6 7
Process finished with exit code 0
Python3程式碼實現如下:
A = []
while True:
try:
A.append(int(input()))
except:
print('input complete')
break
for i in range(len(A)):
for j in range(len(A)):
if j >= i + 1 and A[j] < A[i]:
A[i], A[j] = A[j], A[i]
for k in range(len(A)):
print(A[k], end=' ')
print('\n', end='')
# 列印每輪排序後的輸出,這樣看起來更清晰
print('print the sorted number')
for i in range(len(A)):
print(A[i], end=' ')
# python3預設列印會換行,加上end=' ',則每次後面會自動加上' '中內容而不是換行
執行結果如下:
8
5
7
4
2
^D
input complete
2 8 7 5 4
2 4 8 7 5
2 4 5 8 7
2 4 5 7 8
2 4 5 7 8
print the sorted number
2 4 5 7 8
Process finished with exit code 0
分治法:
有很多演算法在結構上是遞迴的:為了解決一個給定的問題,演算法要一次或多次地遞迴呼叫其自身來解決相關的子問題。這些演算法通常採用分治策略:將原問題劃分成n 個規模較小而結構與原問題相似的子問題;遞迴地解決這些子問題,然後再合併其結果,就得到原問題的解。
分治法在每一層遞迴上都有三個步驟:
分解: 將原問題分解成一系列子問題;
解決:遞迴地解各子問題,如果子問題足夠小,則直接求解;
合併:將子問題的結果合併成原問題的解。
合併排序:
合併排序應用了分治法,其直觀操作如下:
分解:將n個元素分解成各含n/2個元素的子序列
解決:用合併排序法對兩個子序列遞迴地排序
合併:合併兩個已排序的子序列以得到排序結果
合併排序的關鍵步驟在於合併步驟中的合併兩個已排序子序列。為做合併,引入一個輔助過程MERGE(A, p, q, r), 其中A是一個數組,p、q和r是下標,滿足p小於等於q小於r。該過程假設子陣列A[p...q] 和A[q+1...r]都已排好序,並將它們合併成一個已排好序的子陣列代替當前子陣列A[p.. r] 。
下面來說明該演算法的工作過程:
舉撲克牌這個例子,假設有兩堆牌面朝上地放在桌上,每一堆都是已排序的,最小的牌在最上面。我們希望把這兩堆牌合併成一個排好序的輸出堆,面朝下地放在桌上。基本步驟包括在面朝上的兩堆牌中,選取頂上兩張中較小的一張,將其取出後(它所在堆的頂端又會露出一張新的牌)面朝下地放到輸出堆中。重複這個步驟,直到某一輸入堆為空時為止。這時,把輸入堆中餘下的牌面朝下地放入輸出堆中即可。從計算的角度來看,每一個基本步驟所花時間是個常量,因為我們只是查香並比較頂上的兩張牌。又因為至多進行n次比較,所以合併排序的時間為。
在虛擬碼實現時,我們增加一張“哨兵牌”。在每一堆的底部放上一張“哨兵牌" (sentinel card) , 它包含了一個特殊的值,用於簡化程式碼。此處,利用來作為哨兵值,這樣每當露出一張值為的牌時,它不可能是兩張中較小的牌,除非另一堆也露出了哨兵牌。但是,一且發生這種兩張哨兵牌同時出現的情況時,說明兩堆牌中的所有非哨兵牌都已經被放到輸出堆中去了。因為我們預先知道只有r-p+1張牌會被放到輸出堆中去,因此, 一旦執行了r-p+1個基本步驟後(兩堆牌合併過程中的執行次數),演算法就可以停止下來了。
虛擬碼:
MERGE(A,p,q,r)
n1 <- q-p+1
n2 <- r-q
create arrays L[1...n1+1] and R[1...n2+1]
for i<-1 to n1
do L[i] <- A[p+i-1]
for j<-1 to n2
do R[j] <- A[q+j]
L[n1+1] <- 極大值哨兵元素
R[n2+1] <- 極大值哨兵元素
i<-1
j<-1
for k<- p to r
do if L[i] <= R[j]
then A[k] <- L[i]
i <- i+1
else A[k] <- R[j]
j <- j+1
MERGE-SORT(A,p,r)
if p<r
then q<-(p+r)/2
MERGE-SORT(A,p,q)
MERGE-SORT(A,q+1,r)
MERGE(A,p,q,r)
C/C++程式碼實現如下:
#include <stdio.h>
#include <string.h>
#include <limits.h>
using namespace std;
void Merge(int *A, int p, int q, int r) {
int n1 = q - p + 1, n2 = r - q;
int *L = new int[n1 + 1];
int *R = new int[n2 + 1];
//分成兩部分的子陣列分別存在L和R中
for (int i = 0; i < n1; i++)
L[i] = A[p + i];
for (int j = 0; j < n2; j++)
R[j] = A[q + 1 + j];
L[n1] = R[n2] = INT_MAX;
//L和R的哨兵元素
int i = 0, j = 0;
//當L和R均未遍歷到哨兵元素時,哪個小哪個就先放到陣列A中相應位置
//當其中有一個遍歷到哨兵元素時,由於哨兵元素是極大值,故if選擇時就會將另一個子陣列剩餘元素放到陣列A中剩餘位置中
for (int k = p; k <= r; k++) {
if (L[i] <= R[j]) {
A[k] = L[i];
i = i + 1;
} else {
A[k] = R[j];
j = j + 1;
}
}
}
void MergeSort(int A[], int p, int r) {
if (p < r) {
int q = (p + r) / 2;
//分解,遞迴地呼叫MergeSort函式
// 繼續分解直到子陣列足夠小時(即p和q相差1時,此時再呼叫MergeSort函式已經無法再拆分成更小子問題)開始合併解決子問題
MergeSort(A, p, q);
MergeSort(A, q + 1, r);
//合併子問題的解
Merge(A, p, q, r);
}
}
int main() {
int n;
scanf("%d", &n);
int *A = new int[n];
for (int i = 0; i < n; i++)
scanf("%d", &A[i]);
printf("input complete\n");
MergeSort(A, 0, n - 1);
printf("print the sorted number:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
return 0;
}
執行結果如下:
8
8 7 6 5 4 3 2 1
input complete
print the sorted number
1 2 3 4 5 6 7 8
Process finished with exit code 0
Python3程式碼實現如下:
def merge(a, p, q, r):
L, R = [], []
for k, element in enumerate(a):
if p <= k <= q:
L.append(element)
elif q + 1 <= k <= r:
R.append(element)
# 分成兩部分的子陣列分別存在L和R中
L.append(float('inf'))
R.append(float('inf'))
# 給L和R兩個列表末尾各新增一個無窮大值作為哨兵
i, j = 0, 0
# 當L和R均未遍歷到哨兵元素時,哪個小哪個就先放到陣列A中相應位置
# 當其中有一個遍歷到哨兵元素時,由於哨兵元素時極大值,故if選擇時就會將另一個子陣列剩餘元素放到陣列A中剩餘位置中
for k, element in enumerate(a, p):
if k <= r:
if L[i] <= R[j]:
a[k] = L[i]
i = i + 1
else:
a[k] = R[j]
j = j + 1
def merge_sort(a, p, r):
if p < r:
q = int((p + r) / 2)
# 分解,遞迴地呼叫MergeSort函式
# 繼續分解直到子陣列足夠小時(即p和q相差1時,此時再呼叫MergeSort函式已經無法再拆分成更小子問題)開始合併解決子問題
merge_sort(a, p, q)
merge_sort(a, q + 1, r)
# 合併子問題的解
merge(a, p, q, r)
A = []
while True:
try:
A.append(int(input()))
except:
print('input complete')
break
merge_sort(A, 0, len(A) - 1)
print("print the sorted number:")
for index, item in enumerate(A):
print(item, end=' ')
# python3預設列印會換行,加上end=' ',則每次後面會自動加上' '中內容而不是換行
執行結果如下:
8
7
6
5
4
3
2
1
input complete
print the sorted number:
1 2 3 4 5 6 7 8
Process finished with exit code 0
迴圈不變式:
在第12-17行for迴圈每一輪迭代的開始,子陣列A[p...k-1]包含了L[1...n1+1]和R[1...n2+1] 中的k-p 個最小元素,並且是有序的。此外, L[i]和R[j]是各自所在陣列中,未被複制回陣列A中的最小元素。
證明該迴圈不變式:
初始化:
第一輪迴圈,k=p,i=1,j=1,已排序陣列L、R,比較兩陣列中最小元素L[i]、R[j],取較小的置於A[p],此時子陣列A[p..p]不僅是已排序的(僅有一個元素),而且是所有待排序元素中最小的。若最小元素是L[i],取i=i+1,即i指向L中未排入A的所有元素中最小的一個;同理,j之於R陣列也是如此。
保持:
若A[p..k]是已排序的,由計算方法知,L中i所指、R中j所指及其後任意元素均大於等於A[p..k]中最大元素A[k],當k=k+1,A[k+1]中存入的是L[i]、R[j]中較小的一個,但是仍有A[k] <= A[k+1],而此時,子陣列A[p..k+1]也必是有序的,i、j仍是分別指向L、R中未排入A的所有元素中最小的一個。
終止:
k=r+1時終止跳出迴圈,此時,A[p..r]是已排序的,且此即原待排序子陣列,故演算法正確。
當一個演算法中含有對其自身的遞迴呼叫時,其執行時間可以用一個遞迴方程(或遞迴式)來表示。
合併演算法的遞迴式:
n<=c時,T(n)=Θ(1);否則T(n)=aT(n/b)+D(n)+C(n)
D(n)是分解該問題所用時間,C(n)是合併解的時間;對於合併排序演算法,a和b都是2
T(n)在最壞的情況下合併排序n個數的執行時間分析:
當n>1時,將執行時間如下分解:
分解:這一步僅僅算出子陣列的中間位置,需要常量時間,因而D(n)=
解決:遞迴地解為兩個規模為n/2的子問題,時間為T(n/2)
合併:含有n個元素的子陣列上,MERGE過程的執行時間為C(n) =
故n=1時,T(n)=;n>1時,T(n)=2T(n/2)+
將上式改寫為:
n=1時,T(n)=c;n>1時T(n)=2T(n/2)+ cn
第4 章將介紹“主定理"(master theorem) , 它可以用來證明T(n)為,此處lgn代表log2n。
將這個演算法構造遞迴樹:
其構造的遞迴樹中,頂層總代價為cn(n個點的集合)。往下每層總代價不變,第i層的任一節點代價為c(n/2^i)(共2^i個節點總代價仍然是cn)。最底層有n個節點(n*1),每個點代價為c。此樹共有lgn+1層。
要計算遞迴式給出的總代價,只要將遞迴樹中各層的代價加起來就可以了。
在該樹中,總共有lgn+1層,每一層的代價都是cn, 於是,整棵樹的總代價就是cn(lgn+1) = cnlgn+cn。忽略低階項和常量c, 即得到結果。
習題:
2.3-3:
利用數學歸納法證明:當n是2的整次冪時,遞迴式
的解為
2.3-4:
插入排序可以如下改寫成一個遞迴過程:為排序A[1...n],先遞迴地排序A[1...n-1],然後再將A[n]插入到已排序的陣列中去。對於插入排序的這一遞迴版本,為他的執行時間寫一個遞迴式。