Evanalysis
3.1預計閱讀時間: 22 分鐘

3.1 複雜度增長與演算法成本

明確成本模型,證明漸近界,並從執行次數而非程式碼外形推導緊確增長類別。

課程目錄

動機

「程式 A 較快」並不是完整結論:我們必須說明輸入規模、計費操作、合法輸入, 以及所討論的情形。課件以選擇排序作為動機;在某一部機器的測量中,陣列規模 加倍時,時間約增至四倍。這可以顯示增長趨勢,卻不是證明,因為硬件、編譯器、 記憶體行為,以及有限規模下的常數都會影響計時結果。

複雜度分析把問題轉化為數學問題。我們先定義成本函數 T(n)T(n),再計算基本操作 次數,最後研究 nn 增大時成本如何變化。結論必須依賴明確模型:固定長度 key 的比較可以視為常數時間,但任意長字串的比較則未必如此。本節要求先寫出精確 計數或可驗證的界,再給出緊確的漸近類別,而不是憑迴圈層數猜測答案。

定義

定義

輸入規模與 RAM 成本模型

對輸入 II,令 n=∣I∣n=|I| 為明確指定的規模參數,T(I)T(I) 為被計費的基本操作數。 除非另有說明,本節採用 unit-cost RAM model:固定字長算術、索引運算、陣列 存取、賦值,以及固定長度 key 的比較,均具有常數成本。T(n)T(n) 也必須標明 所討論的情形;例如 worst-case cost 是在所有規模為 nn 的合法輸入上取最大值。

時間與輔助空間是兩種不同資源。對運行時間的結論不會自動說明記憶體用量; helper function 的完整成本也必須計入,不能因為呼叫在原始碼中只佔一行便視為 常數時間。

定義

漸近上界、下界與緊確界

設 f,g:N→R≥0f,g:\mathbb N\to\mathbb R_{\ge0} 最終非負,而且 gg 最終為正。

  • 若存在 C>0,n0C>0,n_0,使所有 n≥n0n\ge n_0 都有 f(n)≤Cg(n)f(n)\le Cg(n),則 f(n)=O(g(n))f(n)=O(g(n));
  • 若存在 c>0,n0c>0,n_0,使所有 n≥n0n\ge n_0 都有 f(n)≥cg(n)f(n)\ge cg(n),則 f(n)=Ω(g(n))f(n)=\Omega(g(n));
  • 若同時存在上下界,即 cg(n)≤f(n)≤Cg(n)cg(n)\le f(n)\le Cg(n),則 f(n)=Θ(g(n))f(n)=\Theta(g(n))。

Big-O 只是最終上界,並不是增長率的唯一名稱。線性函數也屬於 O(n2)O(n^2); 若要表達與 gg 相同的緊確漸近增長率,應使用 Θ(g)\Theta(g)。

定義

情形、期望成本與攤還成本

Best-case 與 worst-case cost 分別在相同規模的合法輸入上取最小值與最大值。 Average-case cost 是期望值,因此必須明確指定輸入的機率分佈。Amortized cost 則不假設隨機輸入,而是分析指定操作序列。例如,令一個初始為 empty 的 dynamic array 具有固定正 initial capacity;每當容量用盡,就按固定因子 ρ>1\rho>1 擴容, 並採用一致的整數 rounding rule。對只包含 mm 次 push 的 sequence,普通 push 收取 constant cost,resize 時每複製一個 element 收取一個 unit。一次 resize push 可以是 Θ(n)\Theta(n),但整個 sequence 的總成本為 Θ(m)\Theta(m),所以每次 push 的攤還成本為 Θ(1)\Theta(1)。

從程式碼推導成本函數

可靠的分析可以依照固定次序進行。首先聲明合法輸入與規模參數。陣列程式通常 以長度作為 nn;圖演算法可能同時依賴 ∣V∣|V| 與 ∣E∣|E|;數值演算法則可能依賴 輸入的 bit 數,而不是數值本身。若把真正的雙參數問題強行壓縮成一個符號, 便可能隱藏決定成本的情形。

其次要說明被計費的操作。選擇排序特別適合計算 key comparison 次數,因為 迴圈邊界令該次數不依賴輸入排列。若賦值、記憶體配置或複製 key 的成本也很 重要,就應分別計算,待成本模型清楚後才合併。「迴圈成本是 nn」仍不完整; 還必須說明這 nn 次執行各自進行了甚麼工作。

然後先把控制流程翻譯成算術,再作漸近化簡。連續程式區塊的成本相加;固定 成本的迴圈主體在矩形 iteration space 中重複時形成乘積;內層邊界隨外層 index 變化時形成求和,例如 ∑i=0n−1(n−i−1)\sum_{i=0}^{n-1}(n-i-1)。遞歸程式只有在遞歸呼叫的 數量、子問題規模及非遞歸工作都已說明後,才可以寫出 recurrence。

最後,若要聲稱緊確類別,就必須同時證明上界與下界。只有上界時,結論可能 刻意很寬鬆;相符的下界才說明該增長對指定 implementation 與指定情形無法 避免。這種先推導再分類的做法也會揭示隱藏前提,例如隨機存取是否為常數時間、 key 長度是否有界,以及 helper function 是否暗中完成了一次完整掃描。

固定底數 a,b>1a,b>1 時,log⁡an=log⁡bn/log⁡ba\log_a n=\log_b n/\log_b a,換底只改變常數。對固定 參數 p>0p>0、0<ε<q0<\varepsilon<q、A>1A>1,增長層級可以寫成

1≺(log⁡n)p≺nε≺nq≺An,1 \prec (\log n)^p \prec n^\varepsilon \prec n^q \prec A^n,

因為 (log⁡n)p/nε→0(\log n)^p/n^\varepsilon\to0 且 nq/An→0n^q/A^n\to0。這裏比較的是函數比值 或緊確類別,不能只憑兩個 Big-O 集合的歸屬便斷言哪一個「較快」。

邊讀邊試

在同一 n 比較漸進增長

這個工具現在把增長級別綁到具體程式形狀上,讓讀者可以改變 n,並把當前範例與比較表對照。

選擇演算法形狀

程式範例

for (int width = 1; width < n; width *= 2) {
  for (int i = 0; i < n; i += 2 * width) {
    merge_block(i, width);
  }
}

增長級別: O(n log n)

估計基本步數: 64.00

如何理解: 大約有 log n 輪,而每一輪仍然會處理線性數量的資料。

ClassValue at n=16
O(1)1.00
O(log n)4.00
O(n)16.00
O(n log n)64.00
O(n^2)256.00

負責任地比較增長類別

增長層級描述的是最終趨勢,並不是每一個有限輸入上的實測時間。常數很小的 二次 implementation 可能在有限範圍內快過線性 implementation,cache 與 compiler 也會改變兩者的交叉點。漸近記號刻意忽略這些固定常數,以便獨立於 某一部機器比較長期擴展性;實際工程判斷仍應結合漸近結論與目標輸入範圍內的 測量。

當兩個成本都有正的緊確界時,比值可以給出精確比較。若 f(n)/g(n)→0f(n)/g(n)\to0,則 f=o(g)f=o(g),即 ff 的階嚴格小於 gg;若比值趨向有限正數,兩者屬於同一 Θ\Theta 類,但精確運行時間仍可不同;若比值無界增長,則 ff 的階較大。 這個方法比直接排列兩個 Big-O 歸屬更可靠,因為其中任何一個上界都可能並不 緊確。

定理 / 命題

定理

正首項多項式由最高次項控制

設 f(n)=∑k=0daknkf(n)=\sum_{k=0}^{d}a_kn^k,其中 dd 為非負整數,所有系數 都是固定實數,ad>0a_d>0,而且 ff 最終非負,則 f(n)=Θ(nd)f(n)=\Theta(n^d)。

定理

選擇排序的比較次數是三角形求和

對 n≥1n\ge1 的隨機存取陣列,在每次 key comparison 為 Θ(1)\Theta(1) 的模型中, 下文的 selection sort 對每一個輸入都恰好執行 ∑i=1n−1(n−i)=n(n−1)/2\sum_{i=1}^{n-1}(n-i)=n(n-1)/2 次比較,因此時間為 Θ(n2)\Theta(n^2)。條件交換 最多執行 n−1n-1 次,不會改變緊確界。

證明思路或證明

證明

最高次項定理的證明

若 d=0d=0,則每個 nn 都有 f(n)=a0>0f(n)=a_0>0,所以 f(n)=Θ(1)=Θ(n0)f(n)=\Theta(1)=\Theta(n^0)。以下設 d≥1d\ge1。當 n≥1n\ge1 時,每個 nkn^k(k<dk<d)都不超過 ndn^d,所以 f(n)≤∣f(n)∣≤(ad+∑k<d∣ak∣)ndf(n)\le |f(n)|\le(a_d+\sum_{k<d}|a_k|)n^d,得到所需的上界。另一方面, nk/nd→0n^k/n^d\to0,所以存在 threshold n0n_0,使低次項絕對值之和不超過 (ad/2)nd(a_d/2)n^d。於是 f(n)≥(ad/2)ndf(n)\ge(a_d/2)n^d。同一個正函數給出上下常數倍界, 所以 f(n)=Θ(nd)f(n)=\Theta(n^d)。固定系數、正首項與最終非負條件說明「刪去低次項」 並不是可以任意套用的代數規則。

證明

選擇排序比較次數的證明

外層第 00 輪比較 n−1n-1 次,第 11 輪比較 n−2n-2 次,最後一輪比較一次。 迴圈邊界不依賴 key 的排列,所以已排序、逆序與任意輸入的比較次數相同:

(n−1)+(n−2)+⋯+1=∑i=1n−1(n−i)=n(n−1)2.(n-1)+(n-2)+\cdots+1 =\sum_{i=1}^{n-1}(n-i) =\frac{n(n-1)}2.

對 n≥2n\ge2,這個值介乎 n2/4n^2/4 與 n2/2n^2/2 之間,因此比較次數為 Θ(n2)\Theta(n^2)。常數時間的迴圈控制及至多線性次交換只增加 O(n2)O(n^2) 工作, 而比較次數本身已給出 Ω(n2)\Omega(n^2) 下界。

一般 comparison sorting 的 Ω(nlog⁡n)\Omega(n\log n) 下界屬於比較 decision-tree model,並假設任意可排序輸入。它的證明,以及 quickselect、counting sort 和 radix sort 的參數化分析,都留待後續排序筆記;本節只釐清模型邊界,不會在 缺少這些前提時套用該結論。

例題詳解

例題

為甚麼 n^2 支配 n

比較低次項與所提出的主導項:

nn2=1n⟶0.\frac{n}{n^2}=\frac1n\longrightarrow0.

這並不是說線性項從精確公式中消失,而是說它相對 n2n^2 愈來愈小;配合正 首項,便可支持緊確的 Θ(n2)\Theta(n^2) 結論。

例題

常數時間語句

int x = a + b;
int y = x * 2;
return y;

在固定字長 RAM model 中,每句只執行一次,連續語句的成本相加後仍是 Θ(1)\Theta(1)。若 a、b 是 bit 長度隨輸入增長的 arbitrary-precision integer,就必須另外計算算術操作的成本。

例題

線性掃描

int sum(const int a[], int n) {
   int total = 0;
   for (int i = 0; i < n; i++) {
      total += a[i];
   }
   return total;
}

迴圈主體恰好執行 nn 次,而且每次都有常數成本,所以總成本是 an+b=Θ(n)an+b=\Theta(n),其中 a>0a>0 及 bb 為固定常數。課件中的 Average function 也是一次完整掃描加上最後一次除法;呼叫它是 Θ(n)\Theta(n) 操作, 不能因為原始碼只寫一行 function call 便當作常數成本。

例題

巢狀迴圈產生二次成本

int countPairs(int n) {
   int c = 0;
   for (int i = 0; i < n; i++) {
      for (int j = 0; j < n; j++) {
         c++;
      }
   }
   return c;
}

這段程式的內層語句恰好執行 n⋅nn\cdot n 次,所以成本為 Θ(n2)\Theta(n^2); 結論來自迴圈邊界,而不是只因為看見兩個 loop 關鍵字。

Function call 也可能把乘法隱藏起來。課件中的 naive variance routine 在 nn 次迴圈中,每次都呼叫成本為 Θ(n)\Theta(n) 的 Average(array,n),所以 主體工作為 Θ(n2)\Theta(n^2);最後再計算一次 average 只增加 Θ(n)\Theta(n)。 若先在迴圈外計算 mean,多個連續線性階段的成本相加便成為 Θ(n)\Theta(n)。 如果使用臨時儲存,還須另行報告 auxiliary-space cost。

同一計數方法也適用於非矩形迴圈。如果內層由 i + 1 運行至 n - 1, 執行次數是 ∑i=0n−2(n−i−1)\sum_{i=0}^{n-2}(n-i-1),即三角形求和;如果 index 每一輪 加倍,則由 2k<n2^k<n 得到 k=Θ(log⁡n)k=\Theta(\log n);如果內層上界為 i,就必須 計算 ∑ii\sum_i i,不能寫成 nn 乘一個固定數。因此真正需要分析的是迴圈 邊界描述的 iteration space,而不是原始碼中出現多少個 for。

例題

選擇排序的增長

void selectionSort(int a[], int n) {
   for (int i = 0; i < n - 1; i++) {
      int min = i;
      for (int j = i + 1; j < n; j++) {
         if (a[j] < a[min]) min = j;
      }
      if (min != i) {
         int tmp = a[i];
         a[i] = a[min];
         a[min] = tmp;
      }
   }
}

比較次數恰好是 n(n−1)/2n(n-1)/2。把 nn 換成 2n2n 或 10n10n,主導二次式的比值 分別趨近 44 與 100100。這解釋了課件中的計時趨勢;真正證明漸近類別的是 計數定理,而不是有限數據表。

例題

一個完整的化簡證明

取 C=1,n0=1C=1,n_0=1,則所有 n≥1n\ge1 都有

n2+n2≤n2+n22=n2.\frac{n^2+n}{2}\le\frac{n^2+n^2}{2}=n^2.

再由 (n2+n)/2≥n2/2(n^2+n)/2\ge n^2/2 得到下界,所以緊確結論是 Θ(n2)\Theta(n^2)。O(n3)O(n^3) 雖然也正確,卻不夠精確。

例題

二分搜尋不需要完整掃描

int binarySearch(const int a[], int n, int target) {
   int left = 0, right = n - 1;
   while (left <= right) {
      int mid = left + (right - left) / 2;
      if (a[mid] == target) return mid;
      if (a[mid] < target) left = mid + 1;
      else right = mid - 1;
   }
   return -1;
}

前提是已排序、可隨機存取的陣列,而且 key comparison 為常數時間。第一次 probe 便命中時,best-case cost 為 Θ(1)\Theta(1);worst case 中,每一輪至多 保留一半候選項,因此為 Θ(log⁡n)\Theta(\log n)。這個 midpoint 寫法避免 left + right overflow。

搜尋失敗時仍有相同的對數 worst-case bound:候選區間不斷縮小,直至變成空。 若允許重複 key,這個版本可以回傳任何一個 matching position;尋找第一個或 最後一個 matching position 則需要修改 invariant。Binary search 也不會免費 把未排序搜尋變成對數成本:預先排序的成本只有在足夠多次後續查詢共同分擔時 才值得支付。Linked list 不能以常數時間定位 midpoint,因此 random-access assumption 是複雜度結論的一部分,而不是無關的 implementation detail。

例題

為甚麼合併式結構得到 n log n

若演算法不斷把問題對半分解,直至 subproblem 的規模為一,而且每一遞歸層的 總合併工作為 Θ(n)\Theta(n),則共有 Θ(log⁡n)\Theta(\log n) 層,總成本為 Θ(nlog⁡n)\Theta(n\log n)。等價地,對二次冪規模, T(n)=2T(n/2)+Θ(n)T(n)=2T(n/2)+\Theta(n) 有這個解;一般規模的 ceilings 與 floors 只會改變 常數。必須證明「每層線性工作」這個前提;divide-and-conquer 形式本身並不 保證此界。

逐層求和描述的是時間,不會自動給出空間界。若兩個 recursive call 依次完成, active call depth 為 Θ(log⁡n)\Theta(\log n),而 merge 使用的臨時儲存仍可能達到 Θ(n)\Theta(n)。改變 implementation 或改為 parallel execution,可能改變空間 輪廓,卻不一定改變計算 total work 的 recurrence,所以必須始終清楚指出正在 界定哪一種資源。

常見錯誤

常見錯誤

把 Big-O 當作緊確類別

nn 同時屬於 O(n)O(n) 與 O(n2)O(n^2)。只憑兩個上界不能比較最終增長;應比較實際 函數的比值,或先證明 Θ\Theta 界。

常見錯誤

數迴圈關鍵字,而不數執行次數

兩個獨立、長度為 nn 的巢狀迴圈產生 n2n^2 次主體執行;連續迴圈的成本相加, 三角形邊界需要求和,而每輪減半的迴圈只有對數輪。應先寫計數式,再作化簡。

常見錯誤

忽略前提、helper cost 或情形標籤

Binary search 要求已排序的 random-access input;average case 需要機率分佈; function call 貢獻完整成本。每個結論都應註明合法輸入、成本模型,以及 best、worst、expected 或 amortized case。

常見錯誤

把捨去低次項當作任意代數刪除

低次項仍然影響精確成本與有限規模。只有在固定系數、最終非負等條件下,並經 不等式或比值論證後,才可以使用主導項作漸近化簡。

常見錯誤

聲稱每次 dynamic-array push 都是常數時間

一次觸發 resize 的 push 可以是 Θ(n)\Theta(n)。在幾何擴容下,指定操作序列中 每次 push 的攤還成本是 Θ(1)\Theta(1);這不等於每一次 push 的 worst-case cost 都是常數。

總結

  • 先聲明 nn、被計費的操作、資源及輸入情形;
  • OO、Ω\Omega、Θ\Theta 分別表示上界、下界與緊確界;
  • 固定底數的對數只相差常數,固定正冪支配對數,固定底數的指數函數支配 多項式;
  • 連續成本相加,獨立重複工作相乘,依賴外層 index 的邊界求和,遞歸工作則 寫 recurrence 或逐層計數;
  • 選擇排序恰好比較 n(n−1)/2n(n-1)/2 次,在上述模型中為 Θ(n2)\Theta(n^2);實測時間 只可說明而不能證明這個結論。

練習

思考檢查

若兩個互相獨立的巢狀迴圈都運行 n 次,總成本的緊確類別是甚麼?

假設迴圈主體一定執行,而且成本為 Θ(1)\Theta(1)。

思考檢查

為甚麼 Theta(n log n) 成本在 n 足夠大時增長慢過 Theta(n^2)?

比較代表函數的比值。

思考檢查

0.0001n^3 + n 的緊確漸近類別是甚麼?

使用正首項多項式定理,而不只是說「刪去低次項」。

思考檢查

為甚麼選擇排序每輪只放置一個元素,成本仍然是二次?

計算逐輪縮短的內層比較次數。

答案與解答

解答 · 答案 1

迴圈主體執行 n⋅n=n2n\cdot n=n^2 次,所以在給定獨立性與常數成本前提下,總成本 為 Θ(n2)\Theta(n^2)。

解答 · 答案 2

比值為 (nlog⁡n)/n2=(log⁡n)/n→0(n\log n)/n^2=(\log n)/n\to0。配合正的緊確界,二次成本最終增長 得更快。

解答 · 答案 3

首項系數為正,其餘項次數較低,所以是 Θ(n3)\Theta(n^3)。它也屬於 O(n4)O(n^4),但後者不是緊確類別。

解答 · 引導解答 4

各輪分別比較 (n−1),(n−2),…,1(n-1),(n-2),\ldots,1 次,總和為 n(n−1)/2=Θ(n2)n(n-1)/2=\Theta(n^2),而且不依賴輸入排列。每輪放置一個元素之前,仍須 掃描餘下 suffix。