Evanalysis
3.1嵌入式互動預計閱讀時間: 12 分鐘

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

建立嚴謹的成本模型,正確化簡漸近式,並用具體程式讀出增長率,而唔係估。

課程目錄

CSCI2520:資料結構

針對 CSCI2520 資料結構基礎的結構化筆記,重視操作層級推理與選擇性互動示範。

章節 0程式基礎1
章節 2List 與 recursion1
章節 4Trees 與 BST1

點解要先講複雜度

lecture slides 一開始就講 growth,而唔係先講 micro-optimization,係有 原因嘅。當輸入規模 n 變大時,成本點樣放大通常比某一部機快幾多更 重要。

Big-O 係一個增長模型,唔係計時器。唔同機器會改變 constant factor, 但唔會改變線性、二次、對數呢啲基本形狀。

定義

漸近上界(Big-O)

T(n)=O(f(n))T(n)=O(f(n)) 表示存在常數 C>0C>0n0n_0,令所有 nn0n\ge n_0 都有 T(n)Cf(n)T(n)\le C f(n)

呢個定義係一個 proof obligation。你要證明上界,而唔係只係話「睇落似 二次」。

化簡規則唔係偷步,而係重點

tutorial slides 重複強調兩條規則:

  • n 夠大時,刪走影響愈來愈細嘅項,
  • 刪走 constant factor。

所以

0.1n^3 + 10000n^2 + n + 3

最後仍然係 O(n3)O(n^3)。cubic term 會慢慢主導其他所有項。同樣,

(n^2 + n)/2

嘅增長率仍然係 O(n2)O(n^2),因為 1/2 唔會改變漸近形狀。

例題

點解 n^2 壓過 n

比較 ratio:

n^2 / n = n.

個 ratio 會無界增長,所以 n2n^2 遲早會明顯大過 n。因此當 quadratic term 存在時,linear term 就可以刪走。

CSCI2520 常見增長級別

課程入面反覆用到同一組 class。

  • O(1):直接存取、已知指標重連、簡單算術。
  • O(log n):對半縮減搜尋、平衡樹查找、反覆除 2。
  • O(n):掃描 array 或 linked list。
  • O(n log n):分治加上每一層嘅線性工作。
  • O(n2)O(n^2):雙重掃描,尤其係比較密集嘅 double loop。

邊讀邊試

在同一 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

個 comparator widget 係視覺輔助,唔係 derivation 嘅代替品。你應該先寫 出增長式,再用 widget 去確認 intuition。

由程式讀出複雜度

最快嘅方法,通常係搵出最常執行嘅部分,再數佢跑幾多次。

例題

常數時間語句

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

每一行都只執行一次,所以整個函數係 O(1)。實際 CPU 成本會變,但唔會 隨 n 改變。

例題

線性掃描

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

loop body 會跑 n 次,而每次 body 都係 constant time,所以成個函數係 O(n)

例題

巢狀迴圈會變成二次成本

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

內部 statement 總共會執行 nnn * n 次,所以係 O(n2)O(n^2)

一個具體排序例子:selection sort

tutorial deck 用 selection sort 去示範 quadratic growth 點樣出現。 算法會喺剩餘 suffix 入面搵最細元素,再 swap 去正確位置。

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-1) + (n-2) + ... + 1 = n(n-1)/2.

呢個和係二次,所以演算法係 O(n2)O(n^2)。slide 裡面用文字講嘅重點都一樣: 當 n 加倍,時間大約變成 4 倍。

例題

selection sort 的增長

如果 n 加倍,n2n^2 會變成 4n24n^2。 如果 n 增加 10 倍,n2n^2 會增加 100 倍。

所以 quadratic 算法喺大資料集上會好快變貴。

comparison sort 同 non-comparison sort

sorting slides 再進一步指出:merge sort、heapsort 同 quicksort 都係 comparison sort。

對 comparison sort 而言,worst-case comparisons 有 Ω(n log n) 嘅 lower bound。即係話,任何 comparison sort 都唔可以喺 worst case 打破呢個漸近 界限。

如果想快過呢個 lower bound,就要避免用 comparison 做主要機制。呢個就係 counting sort 同 radix sort 嘅意義。

  • counting sort 喺 key range k 可控時可以係 linear time。
  • radix sort 會逐個 digit 做 stable subroutine sorting。
  • counting sort 唔係 in-place comparison sort。

實際重點唔係「所有 sort 都一樣」,而係資料域同操作模型會決定應該用咩 複雜度類別。

點解 lower terms 同 constants 會消失

tutorial 嘅 simplification slide 有好多類似:

N+1=O(N)N + 1 = O(N)

N3+1000N2+N=O(N3)N^3 + 1000N^2 + N = O(N^3).

原因係最快增長嘅項會主導長遠增長。低階項仍然存在,但當 n 大到某個 程度之後,就唔再重要。

例題

一個乾淨嘅化簡證明

要證明 (n^2 + n)/2 = O(n^2),可以揀 C=1C = 1n0=1n_0 = 1

對所有 n1n \ge 1

(n^2 + n)/2 <= (n^2 + n^2)/2 = n^2.

所以當 n 夠大時,佢可以被某個常數倍嘅 n2n^2 上界住。

best case、worst case、average case

同一個演算法,單靠一個複雜度標籤未必夠。唔同輸入安排,可以令成本表現 完全唔同。

  • Best case:最有利輸入下嘅成本;
  • Worst case:最昂貴輸入下嘅成本;
  • Average case:喺某個概率模型之下嘅平均成本。

binary search 就係一個好例子。

例題

binary search 點解唔使全掃

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

最好情況下,第一個 middle 已經命中,所以成本係 O(1)。最壞情況下, 每一步都將剩餘搜尋區間減半,所以成本係 O(log n)。關鍵唔係逐個候選值 移除,而係每步大概砍走一半搜索空間。

所以你見到複雜度聲稱時,一定要問:而家講緊邊個 case?如果冇特別講, 通常默認係 worst case。

divide-and-conquer 同 n log n 形狀

slide deck 提到 merge-sort 類結構,因為呢個正正係 n log n 最典型嘅例子。

標準推理分兩層:

  • 每一層都處理線性數量嘅資料;
  • 總層數大約係 log n

所以總成本就會變成 n log n

例題

點解 merge 類結構會得到 n log n

假設一個演算法會不斷將輸入對半拆分,直到每個 subproblem 只剩 1 個元素,而每一層都只需要最多 cn 單位嘅合併工作。

如果總共有大約 log2nlog_2 n 層,咁總工作量大約係

cn+cn++cncn + cn + \cdots + cn

重複 log2nlog_2 n 次,所以整體就係

O(nlogn)O(n \log n)

重點唔係常數 c,而係「每層線性工作」乘上「對數層數」呢個結構。

讀 code 時嘅複雜度檢查表

當你見到一段新 code,最穩妥嘅順序係:

  1. 搵出邊個操作重複得最多;
  2. 數清楚外層 loop、recursion,或者 traversal 會令佢跑幾多次;
  3. 分開 constant work 同 size-dependent work;
  4. 等數清楚之後先做化簡。

如果你跳過計數,通常最後就只係估。好嘅 complexity analysis 係先慢慢讀, 再做短而準確嘅分類。

constants 仍然點樣重要

漸近記號描述嘅係長遠形狀,但唔代表 constants 永遠唔重要。當 n 仲細, 一個簡單嘅 O(n2)O(n^2) routine,實際上仍然可能跑贏一個隱藏常數好大嘅 O(n log n) routine。

所以較完整嘅結論通常分兩步:先將增長類別分清楚,再判斷目前輸入規模 係咪仍然受常數因素影響。

stack 同 queue 操作嘅成本

上一節 ADT 內容其實正好提供呢一節嘅具體例子。當 representation 定咗, 同一個 interface 仍然可以有唔同成本。

  • array-based stack push / pop 通常係 constant time,但 resize 時會貴啲。
  • linked-list queue 如果 headtail 維護得好,enqueue / dequeue 可以係 constant time。
  • QueueLength() 如果要掃 linked list,就可能係 linear time;如果有長度 欄位,就可以係 constant time。

所以漸近分析一定要同實作細節一齊睇,唔可以分開。

快速檢查

如果一個 algorithm 有兩層各跑 n 次嘅迴圈,而 loop body 係 O(1),總成本係咩?

直接乘法則。

解答

答案

快速檢查

點解 O(n log n) 會喺足夠大嘅 n 下贏過 O(n2)O(n^2)

比較 (n^2)/(n log n) 嘅 ratio。

解答

答案

快速檢查

0.0001n3+n0.0001n^3 + n 的漸近類別係咩?

刪走常數同低階項。

解答

答案

快速檢查

點解 selection sort 仍然係 quadratic cost,即使每一輪只放一個元素?

數清楚 inner loop 入面嘅 comparisons。

解答

引導答案

重點總結

複雜度分析係用嚟理解 scaling,而唔係估邊個 implementation「感覺上快啲」。 如果你可以搵到主導重複動作、寫出計數、再正確化簡,通常就可以唔跑程式都 分類到演算法。