日常生活中,你會如何描述處理事情的效率?
「原來她五分鐘內可以吃掉一頭牛!」
「房間這麼小你還能擺一堆雜物?還不快收拾!」
這些描述方法,著重在處理事情的花費時間,或單位空間內的儲存量。描述演算法的效率也如此,就是「測量演算法的執行成本」,例如這個排序法花了 10 秒鐘跑完兩萬筆資料,或是這個模擬演算法很吃資源需要 32 GB 的記憶體。
然而,在不同的機器規格、環境溫濕度、程式語言、實作方式,以及有沒有放乖乖的變異影響下,相同演算法的執行成本常常不一致。為了消弭這些外部因素,讓分析演算法能夠更科學化。科學家抽絲剝繭,發明一個方法:
「統計演算法內所需操作步驟的數目。」
這是最簡單,最粗淺比較不同演算法效率的作法。
「計算步驟數目」很像中小學的數學題目:某公司有三個能力相異的工程師,有的工程師一天解決一個 bug,有的工程師連續工作後效率大幅滑落。每個工程師的除蟲效率可以畫成「bug 數 - 解決 bug 所需時數」函數,橫軸為待處理的臭蟲數,縱軸為解決臭蟲所需時數,如圖一與表所示。
時數 | |||
---|---|---|---|
2.236 | 5 | 8.046 | |
5.477 | 30 | 102.036 |
不論從圖或表,我們都可以明確看出,當 bug 數目小時,每個工程師耗時差不多;當 bug 數目成長到一定程度時,效率好與效率差的工程師差距就很明顯了。
我們把場景拉回演算法的範疇,再闡明一次。上述的除蟲效率函數關係,可以簡單視為為「輸入資料量 - 運算成本」關係之函數。例如
了解每個演算法的時間複雜度之後,就能比較何者效率佳。但往往天不從人願,給了我們兩個演算法進行比較。
「天啊!這樣要怎麼分析執行效率呀!」
為了有統一的加薪標準,我們不能假定產品只會產生特定數量的臭蟲,也不能以單一天的工作表現判定員工能力,我們知道老舊系統有無限多個 bug,因此,優秀的老闆關心的是工程師長期處理「海量臭蟲」,在極限下的成長趨勢,這些成長趨勢才是衡量 KPI 的關鍵。再次強調,優秀老闆關心如何榨出是工程師的「極限成長趨勢」,而非一時半刻賣弄學識。
同樣地,有太多因素干擾影響一個演算法的複雜度,假使我們只觀察當輸入資料量
我們比較兩個簡單函數,$f(x) = 10x + 29$ 以及
若以
這裡介紹常見的幾種漸進符號:
當我們談論演算法複雜度時,通常關心的是演算法「最糟糕的情況下」,「最多」需要執行多久。Big O 就是描述演算法複雜度上界的漸進符號,當一個演算法「實際」的複雜度(或執行成本對輸入資料量函數)為
假設有一演算法實際複雜度為
意思是「$f(n)$ 的複雜度上界成長趨勢最終不會超過
再多看一個例子,若
則此函數的複雜度為
注意:也寫作
$f(n) \in O(g(n))$ ,因為實際上$O(g(n))$ 是所有可描述演算法成長趨勢,並滿足上述條件的函數之「集合」。
相較於 Big O 描述演算法成長趨勢的上界,Big Omega 則是對應成長趨勢的「下界」,定義如下:
以
Big Theta 則是 Big O 與 Big Omega 兩個漸進上下界所夾出的範圍,表示該演算法在輸入資料夠大時,最終的複雜度會成長到這個範圍中。其定義如下:
繼續以
可得知,$f(n) = 3n + 4 \in \Theta(n)$,表示「該演算法的成長趨勢與
看完了讓人昏昏欲睡的數學定義,現在來認識一些常見的複雜度,從最快最有效率,到最慢最拖台錢的通通一起認識。
-
$O(1)$ :常數時間,演算法執行時間與資料量毫無瓜葛。例如讀取 array 首個元素。 -
$O(\log n)$ :執行時間隨資料量呈對數比例成長。常見的例子是二元搜索(Binary search)。 -
$O(n)$ :執行時間隨資料量呈線性成長,例如在無序的 array 中尋找特定值。 -
$O(n \log n)$ :執行時間隨資料量呈線性對數成長,常見的合併排序(Mergesort)的複雜度即如斯。 -
$O(n^2)$ :執行時間隨資料量呈平方成長,例如一些效率不彰的排序法如氣泡排序(Bubble sort)。 -
$O(n^3)$ :執行時間隨資料量呈立方成長,常見例子為 naïve 實作的矩陣乘法。 -
$O(c^n)$ :執行時間隨資料量呈指數成長。 -
$O(n!)$ :執行時間隨資料量呈階乘成長,大部分情況下,這是非常差勁的複雜度。
若想一窺各種常見演算法的複雜度,可以參考這個最全面的 Big-O Cheat Sheet,圖表非常精美直觀!
再次強調,漸進符號也可以代表其他執行成本如記憶體空間,並不一定代表執行時間。
其他的漸進符號還有 little-o、little-omega 等等,有興趣的朋友可以參考文末的資料。
善用漸進符號,可以讓原本複雜艱澀的實際複雜度,簡化至人類容易理解的簡單數學符號,也讓分析演算法效率更為客觀。但實際上,漸進符號省略了常數項與低次項,僅保留最高次項,這種「漸進行為下」的效能表現,在真實世界中,若輸入資料量不夠大,實際複雜度的低次項係數又比高次項大上許多,很可能這個演算法實際上根本沒辦法使用。
另外,漸進符號僅考慮最差與最佳複雜度,沒有考慮到平均複雜度。舉例來說,Quicksort 最差複雜度為
還有,漸進符號也沒有考慮到不同語言、平台的基礎操作開銷,例如實作排序法時,有些語言「比較」兩個元素的開銷比「置換」來得大,實作上就需要盡量減少置換元素。同樣的,CPU 快取也非常容易忽略,一些快速的搜尋法很可能因為不是線性搜尋,沒辦法充分利用 CPU cache,效能不一定理想。
總之,漸進符號只能告訴你「當輸入資料量夠大時,演算法的複雜度表現如何」,並不總是適用每個情境,端看你怎麼使用他。