--- title: CP進階 02.complexity tags: 1th,CP進階 --- # CP進階 02.complexity [ppt](https://drive.google.com/file/d/1SamC0saDCvTkmf1jGYddbrSJM3BW1gUj/view?usp=sharing) [題單](https://codeforces.com/contestInvitation/7db5bb8abb218500b29108323faa572fc2664abd) [題解](https://drive.google.com/file/d/1yFdzUP8NrOWNn1yv5btiNUqPL9cJ_U_3/view?usp=sharing) author: __Shioko ## 複雜度(complexity) 在寫程式的時候,我們時常要知道一個程式大概需要跑多久、消耗多少記憶體, 來去評估不同的演算法的效率,其中以直接實作並執行會是最準確的, 但是如果我們有很多個方案要比較,且想要使用跑得最快的那一個, 那直接把每個方案都實作出來就會顯得很多餘、浪費時間, 因此我們需要了解如何在程式寫出來之前估計他們的效能(時間/空間等)。 #### 估計方式 至於該如何估計呢?最直觀的方式就是先假設程式的每個指令都花費相同的時間(實際上當然不是,像取模就會比四則運算慢很多), 再去計算一個程式跑的指令數對於某個變因的函數$f(n)$, 來估計一個程式的效率。 以計算$n$個元素總和的程式為例: ```cpp int n; cin >> n; int sum = 0; for(int i = 0; i < n; i++) { int val; cin >> val; sum += val; } cout << sum << endl; ``` 稍微數一下上面程式跑的指令數, 是$3$(for前面)$+ 1$(宣告for的i)$+ 5 \times n$(for迴圈裡面的指令數乘n)$+ 1$(多一次的check)$+ 1$(cout) 也就是$5 \times n + 6$個指令, 即對於變因$n$(輸入資料量),這個程式的時間複雜度為 $$f(n) = 5n + 6$$ 而另一個計算總和的程式怎麼樣呢? ```cpp int n; cin >> n; vector<int> arr(n); \\建立一個n格的動態陣列 for(int i = 0; i < n; i++) { cin >> arr[i]; } int sum = 0; for(int i = 0; i < n; i++) { sum += arr[i]; } cout << sum << '\n'; ``` 稍微數一下會是$2 + n$(建立n格大的陣列)$+ 1 + 3 \times n + 1 + 1 + 1 + 3 \times n + 1 + 1$ 也就是$7 \times n + 8$個指令, 即對於變因$n$,這個程式的複雜度為 $$f(n) = 7n + 8$$ ps. 雖然開一個$n$格大的陣列是一行指令,可是應該把它視為做$n$次記憶體分配才是對的喔~ 比較這兩者跑的指令數,可以知道前者在時間上明顯比較好, 但是每次都要細細的數有幾行指令要跑不會太麻煩嗎? 而且既然每一種指令跑的時間不太一樣, 且根據執行機器的不同、機況、環境等,同一份程式跑的時間也會不太一樣, 那麼精細的去計算有幾行指令好像也沒什麼意義, 我們只要知道一個程式”大概“會跑多久似乎就足夠了, 因此我們常常用漸進符號之一「大O表示法」(big-O notation)來表示一個程式的時間複雜度。 #### 大O符號(Big-O notation) 所謂的大O符號,為漸進符號的其中一種,是用來描述一個函數$f(n)$在$n$趨近於極限時,函數的成長趨勢。 雖然本身是描述數學上函數的一個符號,但也可以在演算法分析的時候來大略描述一個時間複雜度的函數。 大家在社課的時候應該聽過「把最大量級以外的東西去掉、常數去掉」就是big-O notation的表示,不過這只是一個簡略的說明, 這裡會給出一個更為嚴謹的定義,以更正確地使用這個符號。 $O(g(n))$代表以下函數的集合: $O(g(n))$ $=$ {$f(n): 存在常數c, k使得對於所有n \ge k都滿足0 \le f(n) \le cg(n)$} 白話來說就是如果一個函數$f(n)$是$O(g(n))$($g(n)$是$n^2$等等的...) 那變因$n$在超過某個常數$k$之後,一定可以找到一個$g(n)$的常數倍$cg(n)$使$cg(n)$永遠大於$f(n)$,畫成圖大概像這樣。 ![](https://i.imgur.com/cYq6iqj.png) 舉個例子,假設有一個程式跑的指令數是$f(n) = 3n^2 + 4n + 5$, 那我們可以說他的時間複雜度是$O(n^2)$。 因為我們可以找到一個$cn^2$在$n$足夠大的時候大於$f(n)$。 ex. 取$c = 4$,則$0 \le f(n) \le 4n^2, n \ge k = 5$ 如果再仔細觀察一下,會發現對於一個函數的big-O notation並不需要取到最剛好的那個量級, 像是上面那個$f(n)$,你可以說他是$O(n^2)$,也可以說他是$O(n^3), O(n^{100}), O(n!)...$,不過通常會取最緊的上界。 當我們找到一個$O(g(n))$能夠"卡住"原本的複雜度函數$f(n)$, 那我們就可以直接用$g(n)$來估算程式跑的指令數,且實際跑的數量一定會小於$g(n)$的常數倍。 透過big-O notation,我們可以快速、大略地估計程式的執行效能, 藉此來比較不同演算法的效率,像是比較一下最上面那兩個例子的時間複雜度, 可以發現都是$O(n)$,因此我們可以說他們大略上的效能是差不多的。 ![](https://i.imgur.com/I2PcxHR.png) (一個量級的比較圖) #### 用複雜度估計執行時間 我們知道了一個程式的複雜度之後,還要知道如何轉換成執行的時間, 慣例上一般的judge在$1$秒內大概可以跑$10^8$個指令(CodeForce可以到$10^9$), 依照這個去推就可以了。 ex. $n \le 1000$可以在一秒內跑完$O(n^2)$的演算法, 因為$1000^2 \le 10^8$。 #### 常數(constant factor) 「等等,剛剛那兩個程式明顯就是前者比較快啊,憑什麼說他們兩個的效能差不多?」 沒錯,這就是一個Big-O notation的問題,一個$O(n)$時間複雜度的程式, 實際上是$n, 10n$還是$10000n$,沒有人會知道, 因此當兩個演算法的複雜度相同的時候,硬要比較他們的優劣的話, 就得估計他們項次前面的那個數字$1, 10, 10000...$,也就是常數(constant factor)。 而很遺憾地,我們無法在實作之前確切地知道那個常數, 通常只能依靠經驗判斷(像是遞迴的常數會比較大之類的), 但是也不用太擔心,大部分的題目會給你一個足夠寬鬆的限制,讓你不會因為常數而失敗。 #### 時間複雜度越小就越好嗎? 讀完上面的敘述之後,可能很多人會覺得「量級小的時間複雜度一定比較好」, 其實也不全然,要記得上面的東西都是建立在「$n$足夠大」的前提下, 因此在$n$很小的狀況下,像是$O(n^2)$可能和$O(n)$實際跑的時間是差不多的, 這時候就可以從其他面向(實作難度、消耗空間、自己的熟練程度)等等來做評估。 ex. $n = 10$時,$n^2 = 100, n = 10$,兩個的執行時間只差了十倍。 $n = 1000$時,$n^2 = 10^6, n = 10^3$,執行時間差了一千倍。 #### 空間複雜度(space complexity) 還有一點,大O符號不是只能用在估計時間上面喔~ 估計消耗的記憶體也是常見的用途之一, 以上面例子為例,前者並沒有額外分配空間,因此空間複雜度是$O(1)$, 後者分配了一個$n$格的陣列,空間複雜度是$O(n)$。 #### 其他漸進符號 除了big-O notation(上界)以外,其實還有little-o(嚴格上界)、big-omega(下界)、 little-omega(嚴格下界)、big-theta(嚴格上下界)等等的符號, 不過在CP不太會用到就是了xd。 ## 思考 1. $O(2^n)$和$O(3^n)$是同樣量級嗎? 2. 請從小到大排序$O(lgn), O(1), O(n^2), O(lglgn), O(nlgn)$