為什么需要復雜度分析
我們可以把代碼跑一遍,然后通過一些工具來統計、監控就能得到算法執行的時間和占用的內存大小。為什么還要做時間、空間復雜度分析呢?這種分析方法能比我實實在在跑一遍得到的數據更准確嗎?
首先,肯定的說這種評估算法執行效率的方法是正確的。很多數據結構和算法書籍還給這種方法起了一個名字,叫事后統計法。但是這種統計方法存在一定的局限性。
1、測試結果依賴測試的環境以及數據規模的影響
比如,我們拿同樣一段代碼,再不同的機器以及不同的數據規模可能會有不同的結果。
2、掌握復雜度分析,將能編寫出性能更優的代碼
所以我們需要一個不用具體的測試環境、測試數據來測試,就可以粗略地估計算法的執行效率的方法,這就是我們需要的時間、空間復雜度分析方法。
復雜度分析提供了一個粗略的分析模型,與實際的性能測試並不沖突,更不會浪費太多時間,重點在於在編程時,要具有這種復雜度分析的思維,有助於產出效率高的代碼。
大 O 復雜度表示法
算法的執行效率,簡單的說就是代碼執行的時間。但是怎么樣在不運行代碼的情況下,用“肉眼”得到一段代碼的執行時間呢?這里有段非常簡單的代碼,求 1,2,3...n 的累加和。現在來估算一下這段代碼的執行時間。
1 function countSum(n) { 2 let sum = 0; 3 console.log(n) 4 for (i = 0; i <= n; ++i) { 5 sum = sum + i; 6 } 7 return sum; 8 }
每行代碼對應的 CPU 執行的個數、執行的時間都不一樣,所以只是粗略估計,我們可以假設每行代碼執行的時間都一樣為 unit_time。在這個假設的基礎之上,這段代碼的總執行時間是多少呢?
第 2、3 行代碼分別需要 1 個 unit_time 的執行時間,第 4、5 行都運行了 n 遍,所以需要 2n * unit_time 的執行時間,所以這段代碼總的執行時間就是 (2n+2) * unit_time。
盡管我們不知道 unit_time 的具體值,但是通過代碼執行時間的推導過程,我們可以得到一個非常重要的規律,那就是所有代碼的執行時間 T(n) 與代碼的執行次數 f(n) 成正比。
我們可以把這個規律總結成一個公式,這個公式就是數據結構書上說的大O表示法。

我來具體解釋一下這個公式:
- T(n) 表示代碼執行的時間
 - n 表示數據規模的大小
 - f(n) 表示代碼執行的次數總和
 
因為這是一個公式,所以用 f(n) 來表示。公式中的 O,表示代碼的執行時間 T(n) 與 f(n) 表達式成正比。
所以,上面例子中的 T(n) = O(2n+2),這就是大 O 時間復雜度表示法。大 O 時間復雜度實際上並不具體表示代碼真正的執行時間,而是表示代碼執行時間隨數據規模增長的變化趨勢,所以,也叫作漸進時間復雜度(asymptotic time complexity),簡稱時間復雜度。
時間復雜度分析
分析大O一般的步驟如下:
- 用常數1代替運行中的所有的加法常數 n + 2 + 3 + 4 等於 n + 1
 - 在修改后的運行次數函數中,只保留最高階項 如 n^3 + n^2 等於 n^3
 - 如果最高階項存在且不為1,則去掉與這個項相乘的常數 如 3n^2 等於 n^2
 
通過上面三個步驟可以總結出幾個方法
1. 只關注循環執行次數最多的一段代碼
大 O 這種復雜度表示方法只是表示一種變化趨勢。通過上面的公式我們會忽略掉公式中的常量、低階、系數,只需要記錄一個最大階的量級。所以我們在分析一個算法、一段代碼的時間復雜度的時候,也只關注循環執行次數最多的那一段代碼就可以了。這段核心代碼執行次數的 n 的量級,就是整段要分析代碼的時間復雜度。
2.加法法則:總復雜度等於量級最大的那段代碼的復雜度
如果是很長的一個代碼段,可以把他們拆分計算時間復雜度,然后再加起來
1 function countSum(n) { 2 let sum_1 = 0; 3 console.log('計算:sum_1') 4 for (let p = 0; p < 100; ++p) { 5 sum_1 = sum_1 + p; 6 } 7 8 let sum_2 = 0; 9 console.log('計算:sum_2') 10 for (let q = 0; q < n; ++q) { 11 sum_2 = sum_2 + q; 12 } 13 14 let sum_3 = 0; 15 console.log('計算:sum_3') 16 for (let i = 0; i <= n; ++i) { 17 j = 1; 18 for (let j = 0; j <= n; ++j) { 19 sum_3 = sum_3 + i * j; 20 } 21 } 22 23 return sum_1 + sum_2 + sum_3; 24 }
這個代碼分為三部分,分別是求 sum_1、sum_2、sum_3。我們可以分別分析每一部分的時間復雜度,然后把相加,再取一個量級最大的作為整段代碼的復雜度。
第一段的時間復雜度是多少呢?這段代碼循環執行了 100 次,所以是一個常量的執行時間,跟 n 的規模無關。強調一下,即便這段代碼循環 10000 次、100000 次,只要是一個已知的數,跟 n 無關,照樣也是常量級的執行時間。當 n 無限大的時候,就可以忽略。盡管對代碼的執行時間會有很大影響,但是回到時間復雜度的概念來說,它表示的是一個算法執行效率與數據規模增長的變化趨勢,所以不管常量的執行時間多大,我們都可以忽略掉。因為它本身對增長趨勢並沒有影響。
那第二段代碼和第三段代碼的時間復雜度應該很容易分析出來是 O(n) 和 O(n^2)。
綜合這三段代碼的時間復雜度,我們取其中最大的量級。所以,整段代碼的時間復雜度就為 O(n^2)。也就是說:總的時間復雜度就等於量級最大的那段代碼的時間復雜度。
3.乘法法則:嵌套代碼的復雜度等於嵌套內外代碼復雜度的乘積
假設有一個嵌套的循環,我們把第一層循環叫T1,那么T1(n)=O(f(n)),第二層循環叫T2,那么T2(n)=O(g(n)),總共時間 T(n)=T1(n)*T2(n) = O(f(n))*O(g(n))= O(f(n) * g(n))
假設 T1(n) = O(n),T2(n) = O(n^2),則 T1(n) * T2(n) = O(n^3)。在具體的代碼上,我們可以把乘法法則看成是嵌套循環
如上面計算sum_3的代碼段 兩個循環為O(n^2)。
常見時間復雜度實例分析

O(1)
O(1) 只是常量級時間復雜度的一種表示方法,並不是指只執行了一行代碼。比如這段代碼,即便有 3 行,它的時間復雜度也是 O(1),而不是 O(3)。
1 const i = 8; 2 const j = 6; 3 const sum = i + j;
只要代碼的執行時間不隨 n 的增大而增長,這樣代碼的時間復雜度我們都記作 O(1)。或者說,一般情況下,只要算法中不存在循環語句、遞歸語句,即使有成千上萬行的代碼,其時間復雜度也是Ο(1)。
O(logn)
對數階時間復雜度非常常見,如
1 i=1; 2 while (i <= n) { 3 i = i * 2; 4 }
根據說的復雜度分析方法,第三行代碼是循環執行次數最多的。所以,我們只要能計算出這行代碼被執行了多少次,就能知道整段代碼的時間復雜度。從代碼中可以看出,變量 i 的值從 1 開始取,每循環一次就乘以 2。當大於 n 時,循環結束。實際上變量 i 的取值就是一個等比數列。如果我把它一個一個列出來,就應該是這個樣子的:

所以,我們只要知道 x 值是多少,就知道這行代碼執行的次數了。通過 2x=n 求解 x 這個問題我們想高中應該就學過了,我就不多說了。x=log2n,所以,這段代碼的時間復雜度就是 O(log2n)。
O(n)
O(n)級別有個非常顯著的特征就,它會存在一個循環,且循環的次數是和n相關
1 function countSum (n) { 2 let sum = 0 3 for (let i = 0; i < n; i++) { 4 sum += i 5 } 6 }
O(n^2)
O(n^2)級別的有雙重循環
function countSum (n) { let sum = 0 for (let i = 0; i < n; i++) { sum += i for (let J = 0; J < n; i++) { // do some thing } } }
不是所有的雙重循環都是n^2
1 function countSum (n, m) { 2 let sum = 0 3 for (let i = 0; i < n; i++) { 4 sum += i 5 for (let J = 0; J < m; i++) { 6 // do some thing 7 } 8 } 9 }
這種是由兩個數據規模n、m來決定的時間復雜度,所以是O(n * m),關鍵還是要分析嵌套的循環跟外面那層循環的關系。
時間復雜度耗費時間從小到大依次排列
O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)
常見排序算法對應的時間復雜度
| 排序方法 | 時間復雜度(平均) | 時間復雜度(最壞) | 時間復雜度(最好) | 空間復雜度 | 穩定性 | 復雜性 | 
|---|---|---|---|---|---|---|
| 直接插入排序 | O(n2)O(n2) | O(n2)O(n2) | O(n)O(n) | O(1)O(1) | 穩定 | 簡單 | 
| 希爾排序 | O(nlog2n)O(nlog2n) | O(n2)O(n2) | O(n)O(n) | O(1)O(1) | 不穩定 | 較復雜 | 
| 直接選擇排序 | O(n2)O(n2) | O(n2)O(n2) | O(n2)O(n2) | O(1)O(1) | 不穩定 | 簡單 | 
| 堆排序 | O(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | O(1)O(1) | 不穩定 | 較復雜 | 
| 冒泡排序 | O(n2)O(n2) | O(n2)O(n2) | O(n)O(n) | O(1)O(1) | 穩定 | 簡單 | 
| 快速排序 | O(nlog2n)O(nlog2n) | O(n2)O(n2) | O(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | 不穩定 | 較復雜 | 
| 歸並排序 | O(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | O(n)O(n) | 穩定 | 較復雜 | 
| 基數排序 | O(d(n+r))O(d(n+r)) | O(d(n+r))O(d(n+r)) | O(d(n+r))O(d(n+r)) | O(n+r)O(n+r) | 穩定 | 較復雜 | 

空間復雜度
時間復雜度的全稱是漸進時間復雜度,表示算法的執行時間與數據規模之間的增長關系。同理,空間復雜度全稱就是漸進空間復雜度(asymptotic space complexity),表示算法的存儲空間與數據規模之間的增長關系,空間復雜度分析跟時間復雜度類似。
1 function run (n) { 2 let name = 'joel' 3 let step= 2 4 const arr = [] 5 6 for (let i = 0; i < n; i++) { 7 arr.push(i * step) 8 } 9 }
再第4行代碼我們初始化一個數組,再第7行代碼對這個數組進行push 操作,這個循環是依賴外部的n,所以這個空間復雜度為 O(n),我們常見的空間復雜度就是 O(1)、O(n)、O(n2 )
