第 2 章 算法基礎

本章將要介紹一個貫穿本書的框架,後續的算法設計與分析都是在這個框架中進行的。

2.1 插入排序

輸入:n個數的一個序列< a1, a2, … , an >。
輸出:輸入序列的一個排列< a1’, a2’, … , an’>,滿足a1’ <= a2’ <=…<=an’。
我們希望排序的數也稱爲關鍵詞
本書中通常將算法描述爲用一種僞代碼書寫的程序。將僞代碼過程命名爲INSERTION-SORT,其中的參數是一個數組A[1…n],包含長度爲n的要排序的一個序列。(在代碼中,A中元素的數目n用A.length來表示)
INSERTION-SORT(A)

for j = 2 to A.length
    key = A[j]
    //Insert A[j]into the sorted sequence A[1..j-1].
    i = j - 1
    while i > 0 and A[i] > key
        A[i+1] = A[i]
        i = i - 1
    A[i+1] = key

循環不變式與插入排序的正確性
循環不變式主要用來幫助我們理解算法的正確性,循環不變式必須證明的三條性質:
初始化:循環的第一次迭代之前,它爲真。
保持:如果循環的某次迭代之前它爲真,那麼下次迭代之前它扔爲真。
終止:在循環終止時,不變式爲我們提供了一個有用的性質,該性質有助於證明算法是正確的。
僞代碼中的一些約定

  • 縮進表示塊結構。可以大大提高代碼的清晰性。
  • while、for與repeat-until等循環結構以及if-else等條件結構與C、C++中的那些結構具有類似的解釋。不同在於,退出循環後,循環計數器保持其值。當一個for循環每次迭代增加其循環計數器時,使用關鍵詞to;減少時,使用downto。當循環計數器以大於 1 的一個量改變時,該改變量跟在可選關鍵詞by之後。
  • 符號‘//’表示該行後面部分是個註釋。
  • 形如i=j=e的多重賦值表達式將表達式e的值賦給變量i和j;它應該被處理成等價於賦值j=e後面跟着i=j。
  • 變量是局部於給定過程的。若無顯式說明,我們不適用全局變量。
  • 數組元素通過“數組名[下標]”這樣的形式來訪問。記號“..”用於表示數組中值的一個範圍,這樣,A[1..j]表示A的一個子數組,它包含j個元素A[1],A[2],…,A[j]。
  • 複合數據通常被組織成對象,對象又由屬性組成。對象後跟一個點再跟屬性名。例如,數組可以看成是一個對象,它具有屬性length,表示數組包含多少元素,如A.length就表示數組A中的元素數目。
      我們把表示一個數組或對象的變量看做指向表示數組或對象的數據的一個指針。對於某個對象x的所有屬性f,在賦值y=x後,x和y指向相同的對象。
      我們的屬性記號可以“串聯”。例如:假設屬性 f 本身是指向某種類型的具有屬性 g 的對象的一個指針。那麼記號想x.f.g被隱含地加括號(x.f).g。歡聚話說,如果已經賦值y=x.f,那麼x.f.g與y.g相同。
      有時一個指針根本不指向任何對象。這時,我們賦給它特殊值NULL。
  • 我們按值把參數傳遞給過程:被調用過程接受其參數自身的副本。如果它對某個參數賦值,調用過程看不到這種改變。當對象被傳遞時,指向表示對象數據的指針被複制,而對象的屬性卻未被複制。例如,如果x是某個被調用過程的參數,在被調用過程中的複製x=y對調用過程是不可見的。然而,賦值x.f=3卻是可見的。類似地,數組通過指針來傳遞,結果指向數組的一個指針被傳遞,而不是整個數組,單個數組元素的改變對調用過程是可見的。
  • 一個return語句立即將控制返回到調用過程的調用點。大多數return語句也將一個值傳遞會調用者。我們的僞代碼與許多編程語言不同,因爲我們允許在單一的return語句中返回多個值。
  • 布爾運算符“and”和“or”都是短路的。也就是說,當求值表達式“x and y”時,首先求值x。如果x值爲FALSE,那麼整個表達式不可能求值爲TRUE,所以不再求值y。另外,如果x求值爲TRUE,那麼就必須求值y以確定整個表達式的值。類似地,對表達式“x or y”,僅當x求值爲FALSE時,才求值表達式y。
  • 關鍵詞error表示因爲已被調用的過程情況不對而出現了一個錯誤。調用過程負責處理該錯誤,所以我們不用說明將採取什麼行動。

2.2 分析算法

  在能夠分析一個算法之前,我們必須有一個要使用的實現技術的模型,包括描述所用的資源及其代價的模型。對本書的大多數章節中,我們假定一種通用的單處理器計算模型——隨機訪問機(random-access machine,RAM)來作爲我們的實現技術,算法可以用計算機程序來實現。在RAM模型中,指令一條一條地執行,沒有併發操作。
  我們的指導性意見是真實的計算機如果設計,RAM就如何設計。RAM模型包含真實計算機中常見的指令:算數指令(加法、減法、乘法、除法、取餘、向下取整、向上取整)、數據移動指令(裝入、存儲、複製)和控制指令(條件與無條件轉移、子程序調用與返回)。每條這樣的指令所需時間都爲常量。RAM模型中的數據類型有整數型和浮點數類型。
插入排序算法的分析
一般來說,算法需要的時間與輸入規模同步增長,所以通常把一個程序的運行時間描述其輸入規模的函數。
  輸入規模的最佳概念依賴於研究的問題。對許多問題,如排序或計算離散傅里葉變換,最自然的量度是輸入中的項數,例如:待排序的數組的規模n。有時,用兩個數而不是一個數來描述輸入規模更合適。例如:若某個算法的輸入是一個圖,則輸入規模可以用改圖中的定點數和邊數來描述。對於研究的每個問題,我們將指出所使用的輸入規模量度。
  一個算法在特定輸入上的運行時間是指執行的基本操作數或步數。定義“步”的概念以便儘量獨立於機器是方便的。目前,讓我們採納以下觀點:執行每行僞代碼需要常量時間。
  我們首先給出過程INSERT_SORT中,每條語句的執行時間和執行次數。對j=2,3,…,n。其中n=A.length,假設 tj 表示對那個值 j 第五行執行while循環測試的次數。當一個for或while循環按通常的方式(即由於循環頭中的測試)退出時,執行測試的次數比執行循環體的次數多 1。我們假定註釋是不可執行的語句,所以它們不需要時間。

INSERTION-SORT(A)                                   代價   次數
for j = 2 to A.length                               c1   n                  
    key = A[j]                                      c2   n-1                                                      
    //Insert A[j]into the sorted sequence A[1..j-1].0    n-1
    i = j - 1                                       c4   n-1
    while i > 0 and A[i] > key                      c5        
        A[i+1] = A[i]                               c6   
        i = i - 1                                   c7   
    A[i+1] = key                                    c8   n-1

其中c5=nj=2tj ,c6=nj=2(tj1) ,c7=nj=2(tj1) .
該算法的運行時間是執行每條語句的運行時間之和。爲計算在具有n個值的輸入上INSERTION-SORT的運行時間T[n],我們將代價與次數列對應元素之積求和,得:
T(n)=c1n + c2(n-1) + c4(n-1) + c5nj=2tj +c6nj=2(tj1) +c7nj=2(tj1) +c8(n-1)
  即使對給定規模的輸入,一個算法的運行時間也可能依賴於給定的是該規模下的哪個輸入。例如,在INSERTION-SORT中,若輸入數組已排好序,則出現最佳情況。這時,對每個j=2,3,…,n,有tj=1,該最佳情況的運行時間爲:
  T(n)=c1n + c2(n-1) + c4(n-1)+c5(n-1)+c8(n-1)
    =(c1+c2+c4+c5+c8)n-(c2+c4+c5+c8)
我們把該運行的時間表示爲an+b。因此,它是 n 的 線性函數。若輸入函數已反相排序,則導致最壞情況。我們必須將每個元素A[j] 與整個已排序子數組A[1..j-1]中的每個元素進行比較,所以,對j=2,3,…,n,有tj=j。注意到:

j=2nj=n(n+1)21

j=2n(j1)=n(n1)2

我們發現在最壞情況下,INSETION-SORT的運行時間爲:
T(n)=c1n + c2(n-1) + c4(n-1) + c5(n(n+1)21) +c6(n(n1)2) +c7(n(n1)2) +c8(n-1)
  =(c52 +c62 +c72 )n2 + (c1+c2 + c4 + c52 - c62 - c72 + c8)n - (c2+c4+c5+c8)
也可以把最壞情況運行時間表示爲an2+bn+c ,因此,它是n的二次函數。  
最歡情況與平均情況分析
在本書的餘下部分中,往往集中於只求最壞情況運行時間,即對規模爲n的任何輸入,算法的最長運行時間。
增長量級
我們做出一種更簡化的抽象:即我們真正感興趣的運行時間的增長率增長量級。所以我們只考慮公式中最重要的項,因爲當n的值很大時,低價項相對來說不太重要。我們也忽略最重要項的常係數,因爲對大的輸入,在確定計算效率時常量因子不如增長率重要。對於插入排序,我們記插入排序具有最壞情況運行時間Θ(n2 )。
  如果一個算法的最壞情況運行時間具有比另一個算法更低的增長量級,通常認爲前者比後者更有效。

2.3 設計算法

  我們可以選擇使用的算法設計技術有很多。插入排序使用了增量方法:在排序子數組A[1..j-1]後,將單個元素A[j]插入子數組的適當位置,產生排序好的子數組A[1..j]。
  本節我們考查另一種“分治發”的設計方法。我們將用分治法來設計一個排序算法,該算法的最壞情況運行時間比插入排序要少得多。分治算法的優點之一是,通過使用第 4 章介紹的技術往往很容易確定其運行時間。

2.3.1 分治法

  許多有用的算法在結構上是遞歸的:爲了解決一個給定的問題,算法一次或多次遞歸地調用其自身以解決緊密相關的若干子問題。這些算法典型地遵循分治法的思想:將原問題分解爲幾個規模較小但類似原問題的子問題,遞歸地求解這些問題,然後在合併這些子問題的解來建立原問題的解。
  分治模式在每層遞歸時都有三個步驟:
  分解原問題爲若干子問題,這些子問題是原問題的規模較小的實例。
  解決這些子問題,遞歸地解決各子問題。然而,若干子問題的規模足夠小,則直接求解。
  合併這些子問題的解成原問題的解。
  
  歸併排序算法完全遵循分治模式。直觀上其操作如下:
  分解:分解待排序的n個元素的序列成個具n/2個元素的兩個子序列。
  解決:使用歸併排序遞歸地排序兩個子序列。
  合併:合併兩個已排序的子序列以產生已排序的答案。
  歸併排序算法的關鍵操作是“合併”步驟中兩個已排序序列的合併。通過調用一個輔助過程MERGE(A,p,q,r)來完成合並,其中A是一個數組,p、q和r是數組下標,滿足p<=q

n1 = q - p + 1
n2 = r - q
let L[1..n1 + 1] and R[1..n2 + 1] be new arrays
for i = 1 to n1
    L[i] = A[p + i -1]
for j = 1 to n2
    R[j] = A[q + j]
L[n1 + 1] = ∞
R[n2 + 1] = ∞
i = 1
j = 1
for k = p to r
    if L[i] <= R[j]
    A[k] = L[i]
    i = i + 1
    else A[k] = R[j]
    j = j + 1

現在我們可以把過程MERGE作爲歸併排序算法中的一個子程序來用。下面的過程MERGE-SORT(A,p,r)排序子數組A[p..r]中的元素。若p>=r,則該數組最多有一個元素,所以已經排好序。否則,分解步驟簡單地計算一個下標q,將A[p..r]分成兩個子數組A[p..q]和A[q+1..r],前者包含[n/2]個元素,後者包含[n/2]個元素。
MERGE-SORT(A,p,r)

if p < r
    q = [(p+r)/2]
    MERGE-SORT(A,p,q)
    MERGE-SORT(A,q+1,r)
    MERGE(A, p, q, r)

2.3.2 分析分治算法

  當一個算法包含對其自身的遞歸調用時,我們往往可以用遞歸方程遞歸式來描述其運行時間,該方程根據在較小輸入上的運行時間來描述在規模爲n的問題上的總運行時間。然後,我們可以使用數學工具來求解該遞歸式並給出算法性能的界。
  分治算法運行時間的遞歸式來自基本模式的三個步驟。如前所述,我們假設T(n)是規模爲n的一個問題的運行時間。若問題規模足夠小,如對某個常量 c,n <= c,則直接求解需要常量時間,我們將其寫作Θ(1)。假設把原問題分解成 a 個子問題,每個子問題的規模是原問題的 1/b。(對歸併排序,a和b都爲2,然而,我們將看到在許多分治算法中,a ≠b)爲了求解一個規模爲n/b的子問題,需要T(n/b)的時間,所以需要aT(n/b)的時間來求解a 個子問題。如果分解問題成子問題需要時間D(n),合併子問題的解成原問題的解需要時間C(n),那麼得到遞歸式:

f(n)={Θ(1),aTn/b+D(n)+C(n),n<=c

  歸併排序算法的分析
  下面我們分析建立歸併排序 n 個數的最壞情況運行時間T(n) 的遞歸式。歸併排序一個元素需要常量時間。當有n > 1 個元素時,我們分解運行時間如下:
  分解:分解步驟僅僅計算子數組的中間位置,需要常量時間,因此,D(n)= Θ(1)
  解決:遞歸地求解兩個規模均爲n/2的子問題,將貢獻2T(n/2)的運行時間。
  合併:我們已經注意到在一個具有n 個元素的子數組上過程MERGER需要Θ(n)的時間,所以C(n)=Θ(n)。
  當爲了分析歸併排序而把函數D(n)與C(n)相加時,我們是在一個Θ(n)函數與另一個Θ(1)函數相加。相加的和是n 的一個線性函數,即Θ(n)。把它與來自“解決”步驟項的2T(n/2)相加,將給出歸併排序的最壞情況運行時間T(n)的遞歸式:
T(n)={Θ(1),2T(n/2)+Θ(n),n == 1n >

在第 4 章中,我們將看到“主定理”,可以用該定理來證明T(n) 爲Θ(nlgn),其中,lgn代表。因爲對數函數比任何線性函數增長要慢,所以對足夠大的輸入,在最壞情況下,運行時間爲Θ(nlgn)的歸併排序將優於運行時間爲Θ(n2 )的插入排序。
發佈了50 篇原創文章 · 獲贊 3 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章