演算法複雜度分析(上):分析演算法執行時,時間資源及空間資源的消耗
前言
演算法複雜度是指演算法在編寫成可執行程式後,執行時所需要的資源,資源包括時間資源和記憶體資源。
複雜度也叫漸進複雜度,包括時間複雜度和空間複雜度,用來粗略分析執行效率與資料規模之間的增長趨勢關係,越高階複雜度的演算法,執行效率越低。
複雜度分析是資料結構與演算法的核心精髓,指在不依賴硬體、宿主環境、資料集的情況下,粗略推導,考究出演算法的效率和資源消耗情況。
時間&空間複雜度
資料結構和演算法本身解決的是“快”和“省”的問題,即如何讓程式碼執行的更快,如何讓程式碼更節省儲存空間。所以執行效率是演算法非常重要的考量標準。
而衡量一個演算法的執行效率就需要:時間複雜度分析和空間複雜度分析。
事後統計法:是讓程式碼執行一遍,通過統計、監控等,得到演算法執行時間和佔用記憶體大小,事後統計法評估執行效率的方式並沒有問題。但它存在2個侷限性:
1.評估受測試環境所影響
2.評估受資料規模所影響
所以需要一個不依賴具體的執行環境及測試資料就可以粗略地估計執行效率的方法。這就需要:時間複雜度分析和空間複雜度分析。
如何進行復雜度分析
下面有段程式碼非常的簡單,求1,2,3...n的累加和。
如何在不執行程式碼的情況下,粗略的推導程式碼的執行時間呢?以Demo1為例:
1 int Demo1(int n) 2 { 3 int sum = 0; //1個unit_time 4 for (int i = 1; i <= n; ++i)//n個unit_time 5 { 6 sum = sum + i; //n個unit_time 7 } 8 return sum; 9 } 10 /* 11 * 作者:Jonins 12 * 出處:http://www.cnblogs.com/jonins/ 13 */
首先在CPU的角度看待程式,那麼每行程式碼執行的操作都是類似的:
1.讀資料
2.寫資料
3.運算
儘管每段程式碼執行的時間都不一定一樣,但是我們這裡只是粗略的估計
以此假設基礎,我們來分析上段程式碼的執行時間:
第3行:執行了1次,所以需要1個單位時間的執行時間。
第4行:因為執行了n遍,所以需要n個單位時間的執行時間。
第6行:因為執行了n遍,所以需要n個單位時間的執行時間。
所以這段程式碼的總執行時間就是:
(2n+1)*unit_time
本質:若要獨立於機器的軟、硬體系統來分析演算法的時間耗費,則假設每條語句執行一次所需的時間均是單位時間,一個演算法的時間耗費就是該演算法中所有語句的執行時間之和。
按照這個分析方式,再分析下面Demo2程式碼:
1 public int Demo2(int n) 2 { 3 int sum = 0; //1個unit_time 4 for (int i = 1; i <= n; ++i) //n個unit_time 5 { 6 for (int j = 1; j <= n; ++j)//n*n 個unit_time 7 { 8 sum = sum + i* j; //n*n 個unit_time 9 } 10 } 11 return sum; 12 } 13 /* 14 * 作者:Jonins 15 * 出處:http://www.cnblogs.com/jonins/ 16 */
第3行:需要1個單位時間的執行時間。
第4行:執行了n遍,需要n個單位時間的執行時間。
第6,8行:迴圈執行了n2遍,所以需要2n2個單位時間的執行時間。
所以這段程式碼的總執行時間是:
(2n2+n+1)*unit_time
時間複雜度&大O表示法
明白了時間複雜度分析的方法。再瞭解幾個概念。
1.時間頻度
一個演算法執行所耗費的時間,從理論上是不能算出來的,必須上機執行測試才能知道。但我們不可能也沒有必要對每個演算法都上機測試,只需知道哪個演算法花費的時間多,哪個演算法花費的時間少就可以了。
並且一個演算法花費的時間與演算法中語句的執行次數成正比例,哪個演算法中語句執行次數多,它花費時間就多。一個演算法中的語句執行次數稱為語句頻度或時間頻度。記為T(n)。
2.時間複雜度
在剛才提到的時間頻度中,n通稱為問題的規模,當n不斷變化時,時間頻度T(n)也會不斷變化。但有時我們想知道它變化時呈現什麼規律。為此,我們引入時間複雜度概念。
在電腦科學中,演算法的時間複雜度是一個函式,它定性描述了該演算法的執行時間。
一般情況下,演算法中基本操作重複執行的次數是問題規模n的某個函式,用T(n)表示。
若有某個輔助函式f(n),使得當n趨近於無窮大時,T(n)/f(n)的極限值為不等於零的常數,則稱f(n)是T(n)的同數量級函式。記作T(n)=O(f(n)),稱O(f(n))為演算法的漸進時間複雜度,簡稱時間複雜度。
3.大O時間複雜度表示法
當問題規模增長時, 基本操作次數必定也會增長, 而我們關心的是這個執行次數以什麼樣的數量級增長。
所謂數量級可以理解為增長率。這個所謂的數量級本質上就是演算法的漸近時間複雜度(asymptotic time complexity), 簡稱為時間複雜度。
由於基本操作的執行次數是問題規模n的一個函式T(n), 我們要確定這個函式T(n), 然後分析它的數量級。把擁有相同數量級的函式 f(n) 的集合表示為 O(f(n))。
O是數量級的標記(數學公式),函式f(n)為演算法的增率函式(growth-rate function)。
在上面兩個分析示例中,我們雖然不知道單位時間(unit_time)的具體值,但是依然可以推導一個非常重要的規律:所有程式碼的執行時間T(n)與程式碼執行次數成正比。
所以我們根據這個規律總結出一個公式:
T(n)=O(f(n))
大白話解讀公式:
T(n):表示程式碼的總執行時間。
n:表示資料規模大小。
f(n):表示程式碼執行的次數總和,因為是一個公式,所以用f(n)表示。
O:表示程式碼的執行時間T(n)與f(n)表示式成正比。
所以上面的兩個示例,用大O時間複雜度可以這樣推導表示:
Demo1時間複雜度:
T(n)=O(f(n))
因為:f(n)=(2n+1)
所以Demo1時間複雜度:T(n)= O(2n+1)
Demo2時間複雜度:
T(n)=O(f(n))
因為:f(n)=(2n2+n+1)
所以Demo1時間複雜度:T(n)= O(2n2+n+1)
大O時間複雜度,實際上並不具體表示程式碼真正的執行時間,而是用來表示程式碼執行時間隨資料規模增長的變化趨勢。
複雜度分析原則
1.量級最大法則:忽略常量、低階和係數
大O複雜度表示方法只表示一種變化趨勢。所以忽略掉公式中的常量、低階、係數、只關注最大階的量級就可以。
我們在分析一個演算法或一段程式碼的複雜度的時候,也只關注受問題規模n影響越大即相關執行次數最多的那段程式碼即可。
以Demo1為例:
1 int Demo1(int n) 2 { 3 int sum = 0; //1個unit_time 4 for (int i = 1; i <= n; ++i)//n個unit_time 5 { 6 sum = sum + i; //n個unit_time 7 } 8 return sum; 9 } 10 /* 11 * 作者:Jonins 12 * 出處:http://www.cnblogs.com/jonins/ 13 */
第3行程式碼是常量級的執行時間,與問題規模n無關,所以對複雜度並沒有影響。
迴圈次數最多的是第4、6行,所致重點關注這段程式碼。這兩行程式碼各被執行了n次,所以總的時間複雜度就是O(2n)。
說明:
固定的程式碼迴圈,哪怕迴圈一千萬次,只要是一個已知的數,跟問題規模無關,它就是常量級的執行時間。當n無限大的時候常量可以忽略不計。
儘管這部分程式碼對執行時間會有很大影響,但是在我們探討的時間複雜度概念上,時間複雜度表示的是一個演算法執行效率與資料規模之前的一種變化趨勢。
所以不管常量的執行時間多大,我們都可以忽略掉它,因為它本身對增長趨勢並沒有影響。
2.加法法則:總複雜度等於同級程式碼複雜度的總和
同樣的分析思路分析下面Demo3:
1 int Demo3(int n) 2 { 3 int sum_1 = 0; 4 for (int i = 1; i <= 100; i++) 5 { 6 sum_1 = sum_1 + i; 7 } 8 9 int sum_2 = 0; 10 for (int i = 1; i <= n; i++) 11 { 12 sum_2 = sum_2 + i; 13 } 14 15 int sum_3 = 0; 16 for (int j = 1; j <= n; j++) 17 { 18 for (int q = 1; q <= n; q++) 19 { 20 sum_3 = sum_3 + j * q; 21 } 22 } 23 return sum_1 + sum_2 + sum_3; 24 }
這段程式碼分為3部分,分別求sum_1,sum_2,sum_3,這3段程式碼是“同級的”的,所以我們可以分別分析這三段程式碼的複雜度,最後相加在一起,取得總的時間複雜度。
第1段程式碼:執行100次,是一個常量執行時間跟n的規模無關。
第2段程式碼:執行了2n次,所以時間複雜度為O(2n)。
第3段程式碼:執行了2n2+n,所以時間複雜度為O(2n2+n)。
綜合這3段程式碼的時間複雜度,我們省略常量並取其中最大量級。所以整段程式碼的時間複雜度為O(2n2)。
說明:加法法則公式
如果:T1(n)=O(g(n))、T2(n)=O(h(n))。
存在:T(n)=T1(n)+T2(n)。O(f(n))=O(g(n))+O(h(n))。
所以:T(n)=O(max(g(n)+h(n)))。
3.乘法法則:巢狀程式碼的複雜度等於巢狀內外程式碼複雜度的乘積
分析下面的Demo4,我改寫了上面Demo2為了便於理解:
1 public int Demo4(int n) 2 { 3 int sum = 0; 4 for (int i = 1; i <= n; ++i) //n個unit_time 5 { 6 sum = sum + Function(n); //n個unit_time 7 } 8 return sum; 9 } 10 public int Function(int n) 11 { 12 int sum = 0; 13 for (int j = 1; j <= n; ++j)//n 個unit_time 14 { 15 sum = sum + 1; //n 個unit_time 16 } 17 return sum; 18 } 19 /* 20 * 作者:Jonins 21 * 出處:http://www.cnblogs.com/jonins/
獨立的看巢狀外的程式碼,將函式Function看作一個簡單的操作。那麼第4、6行執行了n次,所以這段段程式碼為2n次。
同時獨立看巢狀內的程式碼,第13、15行執行了n次,所以也是2n次。但因為在巢狀內執行了n次個2n次,即n*2n。
所以我們忽略掉常量及取最高階量值,得到整體的複雜度為:T(n)=O(2n2)。
說明:
1.巢狀迴圈並非一定是相乘
1 public int Function1(int n) 2 { 3 int sum = 0; 4 for (int i = 1; i <= n; ++i) 5 { 6 for (int j = 1; j <= i; ++j) 7 { 8 sum = sum + 1; 9 } 10 } 11 return sum; 12 } 13 public int Function2(int n) 14 { 15 int sum = 0; 16 for (int i = 1; i <= n; ++i) 17 { 18 for (int j = 1; j <= n; ++j) 19 { 20 sum = sum + 1; 21 } 22 } 23 return sum; 24 } 25 /* 26 * 作者:Jonins 27 * 出處:http://www.cnblogs.com/jonins/ 28 */
注意觀察巢狀內迴圈的邊界,Function1和Function2的執行次數有極大差異。
執行一下可以看到Function1的內部迴圈次數為n(n+1)/2。Function2的內部迴圈次數才是n2。
2.乘法法則公式
如果:T1(n)=O(g(n))、T2(n)=O(h(n))。
存在:T(n)=T1(n)*T2(n)。O(f(n))=O(g(n))*O(h(n))。
所以:T(n)=O(g(n)*h(n))。
常見覆雜度量級
雖然演算法各有千秋程式碼又千差萬別。但最基礎的複雜度並不多。根據複雜度的量級,我們可以將演算法的複雜度分為兩類:多項式量級、非多項式量級。
1.多項式量級
常量階:O(1)
對數階:O(log(n))
線性階:O(n)
線性對數階:O(nlog(n))
平方階:O(n2)
立次方階:O(n3)
K次方階:O(nk)
2.非多項式量級:
指數階:O(2n)
階乘階:O(n!)
3.一些重要的說明
1.O(1)
O(1)只是常量階複雜度表示方法,並不一定指執行了一次,即使執行多次只要是一個確切的值,且不受問題規模n的影響時,我們就是用複雜度O(1)表示。
2.O(log(n))&O(nlog(n))
對數階複雜度非常常見,二分查詢的時間複雜度為O(log(n)),歸併排序時間複雜度為O(nlog(n))。
通過案例瞭解下對數階
1 public void Function(int n) 2 { 3 int i = 1; 4 while (i < n) 5 { 6 i = i * 2; 7 } 8 }
我們著重分析第6行程式碼,變數i從1開始取,每迴圈一次乘以2,當大於或等於n時迴圈解說。可以推算出變數i的值在整個迴圈過程中是一個等比數列,既:
20,22,23,24,25,26....=n。
而第6行的迴圈次數,就是數學題,存在2x=n,求解x的近似值。我們大致可以得出x=log(2n)。所以這段程式碼的複雜度就是O(log(n))。
如果理解了對數階O(log(n)),那麼線性對數階O(nlog(n))就很好理解,上面的乘法法則,將O(log(n))執行n遍就得到了O(nlog(n))。
3.所有對數階均為O(log2n)
上面已經講過對數階,但實際上,不管是以2為底,還是3為底,還是以x為低(x為已知量),我們都可以把所有的對數階的時間複雜度記為O(log(n))。
我們可以通過換底公式推導:
換底公式:
$ \log(a^b)=\frac{\log(c^b)}{\log(c^a)} $
推導過程:
$\because \log(2^n)=\frac{\log(x^n)}{\log(x^2)}$
$\therefore \log(x^n)=\log(2^n)*\log(x^2)$
我們已知x的值,所以log(x2)為常量,基於分析原則可以忽略係數。所以所有的對數階,我們忽略對數的“底”,統一用O(log(n))表示。
空間複雜度
學會大O表示法和時間複雜度分析,那麼空間複雜度分析就非常簡單。
空間複雜度全稱就是漸進空間複雜度(asymptotic space complexity),表示演算法在執行過程中臨時佔用的儲存空間大小的量度。
分析原則:時間複雜度關注程式碼的執行次數,而空間複雜度主要關注申請空間的數量。
常見的空間複雜度就是:O(1)、O(n)、O(n2),像對數階等量級的空間複雜度平常用不上。
空間複雜度分析相對於時間複雜度分析要簡單很多,所以在這裡不再過多描述。