文章目錄
本章介紹了一個貫穿本書的架構,後續章節的演算法設計和分析都是在這個架構中進行的。 首先分析了一下如何用插入排序來解決排序問題,定義了一種“虛擬碼”來描述演算法。在描述了演算法後,再證明他能正確的完成任務,並對已耗用時間進行分析。引入一種記號,側重於表達已耗用時間是如何隨著待排序的資料項目數而增加的。之後還要介紹演算法設計中的“分治法”,並利用該方法來設計一個稱為合并排序的演算法,對合并演算法的已耗用時間進行了分析。
插入排序演算法
排序問題的定義如下:
輸入:N個數{a1,
a2,...,
an
}。
輸出:輸入序列的一個排列{a'1
,a'1
,...,a'n
},使得a'n
<=a'
n<=...<=a'
n。
插入排序演算法的虛擬碼是一個以數組為參數的過程形式給出的。輸入的各個數字是原地排序的(sorted in place),意即這些數字就是在數組A中進行重新排序的,在任何時候,至多隻有常熟個數字是儲存在數組之外的。
INSERTTION-SORT(A)
1 for j<--2 to length[A]
2 do key<--A[j]
3 i <-- j-1
4 while i>0 and A[i]>key
5 do A[i+1]<-A[i]
6 i<-- i-1
7 A[i+1] <-- key
迴圈不變式與插入演算法的正確性
迴圈不變式主要用來協助我們理解演算法的正確性,對於迴圈不變式,必須具備三個性質:
初始化: 它在迴圈的第一輪迭代開始之前應該是正確的。
保持:如果在迴圈的某個一次迭代開始之前它是正確的,那麼在下一次迭代開始之前,應該保持正確。
中止: 當迴圈結束時,不變式給出了一個有用的性質,它有助於表明演算法是正確的。
PS: 對for語句來說,“第一輪迭代開始之前”指的是初始化賦值和條件檢查之後,”下一次開始之前“指的是自增運算式和條件檢查之後。一輪迴圈指的是條件檢查(第一輪還包括初始化)之後,到下一次條件檢查之間執行的代碼。
迴圈不變式的原理類似於數學歸納法。
現通過第一重循的環不變式來證明排序演算法的正確性,迴圈不變式為:A[1...j-1]是一個包含原數組第1到j-1元素並已排序的數組。
初始化:在第一輪迴圈體之前,j==2,那麼A[j-1]只包含一個元素,且該元素沒有被移動過,不變式成立;
保持:在迴圈的執行過程中,將A[j]插入到A[1...j-1]合適的位置,j增1,(這裡我們咱不討論第二重迴圈的不變式),此時不變式仍然成立。
中止:當迴圈中止的時候,j=length[A]+1,帶入不變式,恰好證明了演算法的正確性。
PS:迴圈中斷的條件和不變式一起,可以證明演算法的正確性。
不妨用迴圈不變式來證明一下第二重迴圈的正確性,第二重迴圈的目的是找出一個值-1<=i<=j-1,將key放入A[i+1]將使A[0...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 = -1,那麼A[0]處時空值,A[1...j]有序(條件2),且A[1...j]所有元素都大於key,那麼將key放入A[0]將使A[0...j]有序;如果A[i]<=key,那麼由於A[0...i]有序,所以
將key大於A[0...i]中所有元素,同時由於A[i+2,j]有序且所有元素都大於key,將key放入A[i+1]會使A[0...j]有序。
PS:由於第二重迴圈只是一個輔助過程,所以它的不變式顯得比較抽象晦澀。像這種簡單明了的過程並不需要不變式來證明,這裡為了練習不變式的使用故而嘗試一下。
演算法分析
演算法分析就是對一個演算法所需的資源進行預測,記憶體,通訊頻寬或電腦硬體資源偶爾是我們關係的,但通常是指我們希望測量的計算時間。演算法的已耗用時間是指在特定輸入時,所執行的基本運算元。
分析演算法要建立有關實現技術的模型,包括描述所用資源及其代價的模型,本書採用一種通用的單一處理器、隨即存取機(random access machine,RAM)計算模型來作為實現技術。RAM模型包含了真實電腦中常見的指令,每條指令所需的時間都為常量。還假設RAM模型中資料的每一個字有著最大長度限制。指數運算2n
在N較小的情況下可以看做常數執行時間。RAM模型沒有考慮儲存空間的層次,並不對快取和虛擬記憶體進行建模。
一般來說,演算法所需的時間與輸入規模同步增長的,因而常常將一個程式的已耗用時間表示為其輸入函數。輸入規模的概念與具體問題有關,對許多問題來說,最自然的度量標準是輸入中元素個數,對另一些問題,如兩個整數相乘,其輸入規模的最佳度量是輸入數在二進位表示下的位元,有時用兩個數表示輸入規模更加合適,比如輸入是一個圖是,輸入規模可以由圖中頂點數和邊數來表示。
假定每一行代碼都要花常量的時間ci
,那麼在統計出插入排序演算法中每行代碼的執行次數,就可以給出演算法執行時間的一個運算式。(細節請參考原書Page14~15)。
插入排序演算法的第二重迴圈的代碼執行次數取決於輸入的特性——“有序程度”,在最好的情況下(輸入有序),第二重迴圈體根本不會被執行,演算法的執行時間可以表示為 an+b。在最壞情況下(輸入逆序),演算法的執行時間可以表示為:an2
+b。
一般考差演算法的”最壞情況下”的執行時間,這是因為:知道了最壞情況下的執行時間,我們就把握了演算法執行時間的上限,不再擔心演算法在某些情形下會超出這個時間;對於某些演算法最壞情況出現得還是比較頻繁的,比如查詢,當查詢的對象不存在時就會出現最壞情況;“平均情況”往往與最壞情況一樣差,在插入排序演算法中,假設每次插入時,A[0...j-1]中有一半元素大於A[i],演算法的執行時間還是n的一個二次函數。
為了簡化分析,做進一步的抽像——已耗用時間的增長率或增長的量級,我們只考慮已耗用時間運算式中最高次項--n2
。在輸入規模n比較小的時候,通過量級來判別演算法的效率可能是不對的,但當n比較大時一個n2
的演算法比n3
的演算法運行要更快。
演算法設計
演算法設計有很多方法,插入排序使用的是增量法:在排序數組A[1...j-1]後,將A[j]插入,形成排好序的數組A[1...j]。本章這裡要介紹“分治法”。
分治法
有很多演算法在結構上是遞迴的,為瞭解決一個給定的問題,演算法要一次或多次地遞迴調用其自身來解決相關的子問題,這些演算法通常採用分治策略:將原問題劃分成為n個規模更小而結構與原問題相似的子問題;遞迴地解決這些子問題,然後再合并其結果,就得到原問題的解。
分治法在每一層遞迴上都有三個步驟:
分解: 將原問題分解成一些列子問題;
解決:遞迴地姐各子問題,如果子問題足夠小,則直接求值;
合并:將子問題的結果合并成原問題的解。
合并排序依據此模式,直觀地操作如下:
分解:將n個元素分解成各含n/2個元素的子序列
解決:用合并排序法對兩個子序列遞迴地排序
合并:合并兩個已排序的子序列以得到排序結果
下面提供合并排序的虛擬碼,輔助過程MERGE(A,p,q,r)將數組A的已排序子數組A[p..q]和A[q+1...r]合并成有序子數組A[p...r]:
MERG(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 i<--i to n2
do R[i] = A[q+i]
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++
else A[k] = R[j]
j++
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)
PS: MERGE和MERGE-SORT的含義一目瞭然,不過權威書籍上的代碼值得模仿,包括哨兵元素的使用
分治法分析
遞迴調用的演算法的已耗用時間可以用一個遞迴方程來表示。分治演算法中的遞迴是基於基本模式中的三個步驟。假設原問題的規模為n,把原問題分解成a個子問題,每一個子問題的規模是b分之一,注意a和b有時候相等,但很多情況下不相等。於是演算法的已耗用時間可以表示如下:
T(n) = aT(n/b) + D(n) + C(n); T(n) 為常量當n足夠小; D(n)為劃分問題所需的時間; C(n)表示合并子問題結果地所需的時間。
對合并排序演算法。 a=2,b=2, D(n)為常量, C(n)的量級為1。
上述公式可表示為 T(n) = 2T(n/2) + Θ(n)+ Θ(1)= 2T(n/2) +Θ(n) 。
第四章的主定理可以證明 T(n) = Θ(n㏒n)。 也可以通過遞迴樹來證明,見原書Page21-22。
PS: 當n並不是偶數的時候分解的兩個子問題規模並不完全相等,這裡假定n為2的冥,這樣每一層的分解都可以一致,第四章證明這種假設並不妨害分析。
習題
2-4 逆序對
設A[1...n]是一個包含n個不同數的數組。如果在i<j的情況下,有A[i]>A[j],則(i,j)就稱為A中的一個逆序對。
(1)列出數組{2,3,8,6,1}的五個逆序。
(2)如果數組的元素取自{1,2...,n},那麼,怎樣的數組含有最多的逆序對?
(3)插入排序的時間與輸入數組中逆序對的數量之間有怎樣的關係?
(4)給出一個演算法,能用Θ(n㏒n)的最壞已耗用時間,確定n個元素的任何排列中你逆序對的數目。(提示:修改合并排序)
解答:
(1) 略
(2)逆序數組
(3)插入排序的時間與輸入數組中逆序對的數量呈線性正相關關係。通過觀察插入排序的演算法偽碼可知,演算法的運行步驟主要取決於內層迴圈中元素移動的次數,而每次移動就意味著數組的逆序數減一,當排序結束時逆序數為零。
(4)依據分治法,如果我們將數組分解成兩個子序列,分別求出兩個子序列的逆序數,再求出兩個子序列之間元素的逆序數,就可以得出整個數組的逆序數了。可以做以下考慮:
分解:將問題分成前後兩個規模為n/2的數組
解決:分別求解各自的逆序對數。如果子問題規模為2或1,可直接求解。
合并:此時雖然知道兩個子序列各自的逆序對數,但兩個子序列之間的逆序對數無法輕易獲知,如果進行兩兩比較的話,合併作業的時間複雜度就是n2
,分治法沒有意義。
再考慮上述“合并”的問題,如果此時兩個子序列都是有序的話,則通過修改合并排序的MERG過程就可以得出子序列之間的逆序數:在MERG對兩個子序列的第一個元素之間進行選則時,如果前一個序列的首元素被選中,則逆序數不變——該元素不會和後一個序列中的剩下元素構成逆序對,如果第二個序列的首元素被選中,則逆序數增加“第一個序列剩下的元素數”——該元素和前一序列中剩下的每個元素構成逆序對,MERG後這些逆序對消除。按著這個思路分治演算法重新設計如下:
分解:將問題分成前後兩個規模為n/2的數組
解決:分別進行遞迴合并排序,並記錄累加排序所消除的的逆序對數。如果子問題規模為2或1,可直接求解。
合并:通過合并排序的MERG進行合并,在MERG過程中按上述方法累加逆序數。
PS:在最初用分治法考慮問題(4)時,排序的作用在一開並不那麼明顯,但通過對“合并”的分析,要求對子問題的求解需要產生“排序”的副作用。這種”副作用“在分治法中是值得注意的。