點解要先講複雜度
lecture slides 一開始就講 growth,而唔係先講 micro-optimization,係有
原因嘅。當輸入規模 n 變大時,成本點樣放大通常比某一部機快幾多更
重要。
Big-O 係一個增長模型,唔係計時器。唔同機器會改變 constant factor, 但唔會改變線性、二次、對數呢啲基本形狀。
定義
漸近上界(Big-O)
表示存在常數 同 ,令所有 都有 。
呢個定義係一個 proof obligation。你要證明上界,而唔係只係話「睇落似 二次」。
化簡規則唔係偷步,而係重點
tutorial slides 重複強調兩條規則:
- 當
n夠大時,刪走影響愈來愈細嘅項, - 刪走 constant factor。
所以
0.1n^3 + 10000n^2 + n + 3
最後仍然係 。cubic term 會慢慢主導其他所有項。同樣,
(n^2 + n)/2
嘅增長率仍然係 ,因為 1/2 唔會改變漸近形狀。
例題
點解 n^2 壓過 n
比較 ratio:
n^2 / n = n.
個 ratio 會無界增長,所以 遲早會明顯大過 n。因此當 quadratic
term 存在時,linear term 就可以刪走。
CSCI2520 常見增長級別
課程入面反覆用到同一組 class。
O(1):直接存取、已知指標重連、簡單算術。O(log n):對半縮減搜尋、平衡樹查找、反覆除 2。O(n):掃描 array 或 linked list。O(n log n):分治加上每一層嘅線性工作。- :雙重掃描,尤其係比較密集嘅 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 輪,而每一輪仍然會處理線性數量的資料。
| Class | Value 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 總共會執行 次,所以係 。
一個具體排序例子: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.
呢個和係二次,所以演算法係 。slide 裡面用文字講嘅重點都一樣:
當 n 加倍,時間大約變成 4 倍。
例題
selection sort 的增長
如果 n 加倍, 會變成 。
如果 n 增加 10 倍, 會增加 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 大到某個
程度之後,就唔再重要。
例題
一個乾淨嘅化簡證明
要證明 (n^2 + n)/2 = O(n^2),可以揀 同 。
對所有 ,
(n^2 + n)/2 <= (n^2 + n^2)/2 = n^2.
所以當 n 夠大時,佢可以被某個常數倍嘅 上界住。
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 單位嘅合併工作。
如果總共有大約 層,咁總工作量大約係
重複 次,所以整體就係
重點唔係常數 c,而係「每層線性工作」乘上「對數層數」呢個結構。
讀 code 時嘅複雜度檢查表
當你見到一段新 code,最穩妥嘅順序係:
- 搵出邊個操作重複得最多;
- 數清楚外層 loop、recursion,或者 traversal 會令佢跑幾多次;
- 分開 constant work 同 size-dependent work;
- 等數清楚之後先做化簡。
如果你跳過計數,通常最後就只係估。好嘅 complexity analysis 係先慢慢讀, 再做短而準確嘅分類。
constants 仍然點樣重要
漸近記號描述嘅係長遠形狀,但唔代表 constants 永遠唔重要。當 n 仲細,
一個簡單嘅 routine,實際上仍然可能跑贏一個隱藏常數好大嘅
O(n log n) routine。
所以較完整嘅結論通常分兩步:先將增長類別分清楚,再判斷目前輸入規模 係咪仍然受常數因素影響。
stack 同 queue 操作嘅成本
上一節 ADT 內容其實正好提供呢一節嘅具體例子。當 representation 定咗, 同一個 interface 仍然可以有唔同成本。
- array-based stack
push/pop通常係 constant time,但 resize 時會貴啲。 - linked-list queue 如果
head同tail維護得好,enqueue/dequeue可以係 constant time。 QueueLength()如果要掃 linked list,就可能係 linear time;如果有長度 欄位,就可以係 constant time。
所以漸近分析一定要同實作細節一齊睇,唔可以分開。
快速檢查
如果一個 algorithm 有兩層各跑 n 次嘅迴圈,而 loop body 係 O(1),總成本係咩?
直接乘法則。
解答
答案
快速檢查
點解 O(n log n) 會喺足夠大嘅 n 下贏過 ?
比較 (n^2)/(n log n) 嘅 ratio。
解答
答案
快速檢查
的漸近類別係咩?
刪走常數同低階項。
解答
答案
快速檢查
點解 selection sort 仍然係 quadratic cost,即使每一輪只放一個元素?
數清楚 inner loop 入面嘅 comparisons。
解答
引導答案
重點總結
複雜度分析係用嚟理解 scaling,而唔係估邊個 implementation「感覺上快啲」。 如果你可以搵到主導重複動作、寫出計數、再正確化簡,通常就可以唔跑程式都 分類到演算法。