淺入淺出數據結構(2)——簡要討論算法的時間復雜度


       所謂算法的“時間復雜度”,你可以將其理解為算法“要花費的時間量”。比如說,讓你用抹布將家里完完全全打掃一遍(看成算法吧……)大概要5個小時,那么你用抹布打掃家里的“時間復雜度”就是5個小時。

       但是,在對算法進行分析時,並沒有那么簡單。大部分情況下我們不能一眼看出算法執行完需要耗費多少時間,一方面是因為我們很難考慮執行算法的具體機器在各種操作上花費的時間,因為不同機器的運算速度不同,同一機器執行不同操作的所用時間也不一樣。另一方面是我們很難統計算法到底執行了多少個“操作”,比如不起眼的a+=1其實算兩個操作。所以我們對算法進行時間上的分析時,往往需要使用到“大概”這個概念。但即使是推算算法耗費的“大概”時間也是需要一些基本原則的,接下來我們就來看看如何推算算法的時間復雜度。(完整、嚴謹的算法分析比較復雜,本文只是寫一些“入門”的概念與分析方法)

 

 

       即使在現實生活中,我們也會遇到類似於分析算法時間一樣的問題,比如有人問你多久能看完某本書,你可能會說“一個月之內”而不是具體的“19天”,又比如有人問你最快多久能完成某項任務,你可能會說“至少3天”而不是“70小時”。而我們對算法進行時間分析時也會用到類似的“技巧”,即不追求具體的時間耗費,而是追求“上界”或“下界”。

 

        為了找出“上界”與“下界”,我們先要使用兩個定義:

        1.如果存在正常數c和n0,使得當N>=n0時T(N)<=c·f(N),則記為T(N)=O(f(N))

        2.如果存在正常數c和n0,使得當N>=n0時T(N)>=c·f(N),則記為T(N)=Ω(f(N))

       第一個定義的意思就是:當N超過某個值后,c·f(N)總是至少比T(N)要大。忽略常數因子,即f(N)至少與T(N)一樣大。

  類似的,第二個定義意思就是:當N超過某個值后,c·f(N)總是最多和T(N)一樣大。

 

  其實這兩個定義就是為了比較兩個函數之間的“相對增長率”,比如1000x和x2,雖然x<1000時1000x>x2,但是x2以更快的速度增長,因此x2最終會更大。

       當我們說T(N)=O(f(N))時,其實就是說“T(N)是在以不快於f(N)的速度增長”,類似的T(N)=Ω(f(N))即“T(N)是在以不慢於f(N)的速度增長”。不難發現,O(f(N))就是T(N)的“上界”,Ω(f(N))就是T(N)的“下界”。

      舉例來說,N3比N2增長更快,因此N2=O(N3)與N3=Ω(N2)都是對的;2*N2與N2有着相同的相對增長率,因此N2=O(2*N2)與N2=Ω(2*N2)都是正確的。由於對算法進行時間分析時往往考慮“最壞情況”,所以我們通常計算的是O(f(N)),即“上界”,俗稱“大O階”。

 

       正如文章開頭說的,相同的算法在不同的機器上也會有不同的運行時間,同一台機器的不同操作也會有不同的時間開銷。因此,我們假設我們的“計算機”所有運算如加減乘除、比較、賦值等都是耗費相同時間的,並且不考慮內存問題,從而后面討論算法時間復雜度時,我們不再帶單位,只關心“數值”。

 

 

       接下來,讓我們帶着現有的概念與知識,來計算一個簡單的函數可能花費的時間(也可以說時間復雜度,或者大O階)

void  func ( unsigned int N ) { for ( int  i = 0; i < N ; ++i ) { i = i ; } }

 

       顯然這個函數並沒有什么意義,我們也只是拿來練練手算算時間開銷罷了。那么接下來就讓我們一步一步看看它要花費多少時間。

 

       根據我們之前所說,所有運算耗費相同時間且不帶單位,那么,初始化i花費1時間,每次循環需要執行一次比較,一次賦值,一次自增總共3時間,N次循環即3N時間,加上定義i的1時間,算法花費的總時間是3N+1。再回顧之前所說,對於算法,我們一般都是計算大O階(即使這里我們算出了3N+1這樣“比較准確”的時間花費),因此接下來我們要對3N+1計算大O階。

 

       但是3N+1的大O階有很多很多,比如O(N2)、O(N3)等等(因為N2和N3的相對增長率肯定比3N+1大),究竟哪一個才是我們需要的?直覺告訴我們應該是“最接近的”,即O(N)(根據定義一,顯然存在c=1000,n0=1這樣的情況使得N成為3N+1的大O階)。但是選擇這個“最接近”的大O階時有沒有什么原則呢?原則當然還是有的,接下來我們就來說一說計算算法時間復雜度O()時的一些原則(和捷徑)。

 

 

        首先,我們要確定三個關於大O階的法則:

        1.如果T(N)=O(f(N)),G(N)=O(h(N)),那么T(N)+G(N)=max(O(f(N)) , O(h(N)))。T(N)*G(N)=O(f(N)*h(N))。

        2.忽略時間花費中的常數項,比如3*N^2+3,直接簡化為N^2

        通過法則1中的加法規律(和法則2的簡化辦法),我們發現N2=O(N2),N=O(N),那么N2+N=max(O(N2) , O(N)) = O(N2)。因此,我們有了法則3:

        3.如果T(N)是一個k次多項式,那么T(N)=O(N^k)。

 

        法則2與法則3是我們常用的,因為算法的時間復雜度往往是一個多項式,而法則2和法則3告訴了我們如何大大簡化該多項式來獲得大O階。假設一個算法花費時間3*N3+N2+3,那么根據法則2與法則3,我們可以直接得出其大O階為O(N3)。

 

 

         那么接下來的問題就只剩下如何得到那個原始的時間開銷了,比如我們知道了時間花費是3*N2+3,那么我們可以得出大O階為O(N2),但是問題在於3*N2+3該如何得到。其實這也是不難的。回顧之前我們分析了的那個無意義的函數,我們就會發現,時間復雜度中最重要的就是“不確定次數的循環”,因為順序執行時不論有1000個還是10000個賦值、比較、算術運算,最后計算大O階時都會變為常數項從而被忽略掉。至於為什么說是“不確定次數的”循環,原因就是如果次數確定,那么該循環也會變成一個常數項:       

for ( int i = 0 ; i< 10 ;++i );

        不難發現這個循環的時間花費其實是固定的1+10+9=20,是一個常數,而常數項是會被忽略的。

 

        那么對於次數不定的循環(假定循環次數都由算法的輸入參數N決定),那么我們有幾個很簡單的基本原則:

        1.對於循環,運行時間最多為其內部語句的運行時間(比如4次運算)乘以循環次數(N)。

  比如

for ( int i = 0; i < N ;++i );

  的運行時間最多為1*N,即O(N)

 

        2.對於嵌套循環,根據原則1,不難發現就是內部循環的運行時間乘以外部循環次數(N)。

  比如

for ( int i = 0; i < N ; ++i ) for ( int j = 0; j < N ; ++j );

  的運行時間就是N*N,即O(N2)

 

        3.對於順序結構,只需要將各“部分”運行時間相加即可。(對於IF/ELSE結構,我們將整個IF/ELSE的運行時間假定為其中最大的一種情況,這樣也許會比平均運行時間要大,但是保證了“上界”的要求)

  比如

for ( int i = 0; i < N ;++i ); for ( int i = 0; i < N ; ++i ) for ( int j = 0; j < N ; ++j );

  的運行時間就是N+N*N=N^2+N,大O階為O(N^2)

 

        4.對於遞歸,如果其只是“遮了面紗”的循環,比如

 int  func ( int N ) { if ( N<=1 )    return  1; return   N*func ( N - 1 ); }

   那么其運行時間就以其循環形式計算,得出N。但實際情況中遇到的遞歸往往是難以化簡為循環的,這時對遞歸的時間分析將比較復雜,本文不予討論。

 

 

         最后總結,由於諸多現實原因,對於算法的時間分析我們往往只計算個大概,而計算這個大概時我們最在乎的是代表着最壞情況的“上界”,也即大O階。要想計算一個算法的大O階,我們首先要計算其大致的時間花費,比如一個循環N次的循環體中有不確定的常數c次運算,此時我們不計較c的具體大小,直接將該循環體時間花費記為N,然后根據計算大O階的簡化原則將其簡化,得出算法的大O階。

 

         雖然算法千千萬,但是算法的時間復雜度,大O階還是有一些規律的。什么規律呢?就是我們常見的大O階是可以列舉出來的。常見的大O階按照從好到壞,也就是增長率從低到高,列舉出來的話有:

         常數級C

         對數級logN

         對數平方根級logN2

         線性級N

         N*logN

         平方級N2

         立方級N3

         指數級2N

 

         稍加分析就會發現其實它們的順序就是函數增長率的順序,有了這個順序,我們就可以對一些算法的時間復雜度進行比較了。比如完成同一件事,一個算法是O(NlogN),另一個算法是O(N^2),那么顯然當N很大時,前者比后者會快很多(觀察函數圖像也可以很明顯的發現這一點)。

 

 

         但是,對數級logN的復雜度是什么情況出現的呢?一般來說,如果一個算法用常數時間O(1)將問題的大小削減為其一部分,那么該算法就是O(logN)的。

 

         雖然很多時候,一個算法的數據輸入就不得不耗費Ω(N)的時間,因而整個算法最終的時間復雜度不會是O(logN),但為了說明O(logN)的情況,我們假設算法的數據已經輸入到了內存中,那么作為O(logN)的典例就是二分查找(本例中假設數組已按從小到大排好順序,我們要找出某個數在數組中的位置):

int  BinarySearch ( const  int  A[] , int   X,   int  N )       // X為要找的元素,N為數組大小
 { int  Low=0,High=N-1,Mid; while ( Low <= High ) { Mid= ( Low + High ) / 2; if ( A[ Mid ] < X ) Low = Mid + 1; else  if  ( A[ Mid ] > X ) High = Mid - 1; else   return Mid; } }

 

         顯然,循環體內部的運行時間為O(1),接下來分析循環的次數,循環從High-Low=N-1開始,到High-Low=-1結束,每次循環后High-Low的值都會“折半”,符合我們之前說的判斷是否為logN級的條件,因而二分查找是O(logN)的。(即使不是削減為二分之一,而是三分之一、四分之一等,我們也記作logN級別)

 

 

         文章寫到這,相信讀者對於基本的算法分析已經有了概念。但是算法分析並不只是這些東西,比如我們一直沒有提到的類似於O()和Ω()的θ(),還有算法的空間復雜度(比如同一個算法用循環實現和遞歸實現的空間占用就會明顯不同)等,並且在復雜的算法計算中還會用到高等數學的極限思想與計算方法。有相關興趣的讀者可以自行搜索關於算法分析的其它內容來了解。另外,對於不同的場景,算法的分析會有不同的要求,比如我們說忽略常數項,但如果這個常數項真的足夠大而機器又足夠慢,那么即使是常數項也不是隨便忽略的。

         


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM