1. 程式人生 > >資料結構與演算法隨筆之------演算法複雜度分析

資料結構與演算法隨筆之------演算法複雜度分析

一.演算法

1.演算法定義

2.什麼是好的演算法 

 

 

  常見的演算法時間複雜度由小到大依次為:Ο(1)<Ο(log2n)<Ο(n)<Ο(nlog2n)<Ο(n2)<Ο(n3)<…<Ο(2n)<Ο(n!)

此外,當你遇到一個Ο(n2)的演算法時,常常要想怎麼把他降為Ο(nlog2n),因為那樣的話時間會快很多

 二.演算法複雜度詳解:

1.演算法時間複雜度

這裡參考了一位大佬的簡書:https://www.jianshu.com/p/f4cca5ce055a

定義: 存在常數 c,使得當 N >= c 時 T(N) <= f(N),表示為 T(n) = O(f(n)) 。 如圖:

當 N >= 2 的時候,f(n) = n^2 總是大於 T(n) = n + 2 的,於是我們說 f(n) 的增長速度是大於或者等於 T(n) 的,也說 f(n) 是 T(n) 的上界,可以表示為 T(n) = O(f(n))。

因為f(n) 的增長速度是大於或者等於 T(n) 的,即T(n) = O(f(n)),所以我們可以用 f(n) 的增長速度來度量 T(n) 的增長速度,所以我們說這個演算法的時間複雜度是 O(f(n))。

演算法的時間複雜度,用來度量演算法的執行時間,記作: T(n) = O(f(n))。它表示隨著 輸入大小n 的增大,演算法執行需要的時間的增長速度可以用 f(n) 來描述。

顯然如果 T(n) = n^2,那麼 T(n) = O(n^2),T(n) = O(n^3),T(n) = O(n^4) 都是成立的,但是因為第一個 f(n) 的增長速度與 T(n) 是最接近的,所以第一個是最好的選擇,所以我們說這個演算法的複雜度是 O(n^2) 。

那麼當我們拿到演算法的執行次數函式 T(n) 之後怎麼得到演算法的時間複雜度呢?

  1. 我們知道常數項對函式的增長速度影響並不大,所以當 T(n) = c,c 為一個常數的時候,我們說這個演算法的時間複雜度為 O(1);如果 T(n) 不等於一個常數項時,直接將常數項省略。
比如
第一個 Hello, World 的例子中 T(n) = 2,所以我們說那個函式(演算法)的時間複雜度為 O(1)。
T(n) = n + 29,此時時間複雜度為 O(n)。
  1. 我們知道高次項對於函式的增長速度的影響是最大的。n^3 的增長速度是遠超 n^2 的,同時 n^2 的增長速度是遠超 n 的。 同時因為要求的精度不高,所以我們直接忽略低此項。
比如
T(n) = n^3 + n^2 + 29,此時時間複雜度為 O(n^3)。
  1. 因為函式的階數對函式的增長速度的影響是最顯著的,所以我們忽略與最高階相乘的常數。
比如
T(n) = 3n^3,此時時間複雜度為 O(n^3)。

綜合起來:如果一個演算法的執行次數是 T(n),那麼只保留最高次項,同時忽略最高項的係數後得到函式 f(n),此時演算法的時間複雜度就是 O(f(n))。為了方便描述,下文稱此為 大O推導法。

由此可見,由執行次數 T(n) 得到時間複雜度並不困難,很多時候困難的是從演算法通過分析和數學運算得到 T(n)。對此,提供下列四個便利的法則,這些法則都是可以簡單推匯出來的,總結出來以便提高效率。

  1. 對於一個迴圈,假設迴圈體的時間複雜度為 O(n),迴圈次數為 m,則這個 迴圈的時間複雜度為 O(n×m)。
void aFunc(int n) {
    for(int i = 0; i < n; i++) {         // 迴圈次數為 n
        printf("Hello, World!\n");      // 迴圈體時間複雜度為 O(1)
    }
}

此時時間複雜度為 O(n × 1),即 O(n)。

  1. 對於多個迴圈,假設迴圈體的時間複雜度為 O(n),各個迴圈的迴圈次數分別是a, b, c...,則這個迴圈的時間複雜度為 O(n×a×b×c...)。分析的時候應該由裡向外分析這些迴圈。
void aFunc(int n) {
    for(int i = 0; i < n; i++) {         // 迴圈次數為 n
        for(int j = 0; j < n; j++) {       // 迴圈次數為 n
            printf("Hello, World!\n");      // 迴圈體時間複雜度為 O(1)
        }
    }
}

此時時間複雜度為 O(n × n × 1),即 O(n^2)。

  1. 對於順序執行的語句或者演算法,總的時間複雜度等於其中最大的時間複雜度。
void aFunc(int n) {
    // 第一部分時間複雜度為 O(n^2)
    for(int i = 0; i < n; i++) {
        for(int j = 0; j < n; j++) {
            printf("Hello, World!\n");
        }
    }
    // 第二部分時間複雜度為 O(n)
    for(int j = 0; j < n; j++) {
        printf("Hello, World!\n");
    }
}

此時時間複雜度為 max(O(n^2), O(n)),即 O(n^2)。

  1. 對於條件判斷語句,總的時間複雜度等於其中 時間複雜度最大的路徑 的時間複雜度。
void aFunc(int n) {
    if (n >= 0) {
        // 第一條路徑時間複雜度為 O(n^2)
        for(int i = 0; i < n; i++) {
            for(int j = 0; j < n; j++) {
                printf("輸入資料大於等於零\n");
            }
        }
    } else {
        // 第二條路徑時間複雜度為 O(n)
        for(int j = 0; j < n; j++) {
            printf("輸入資料小於零\n");
        }
    }
}

此時時間複雜度為 max(O(n^2), O(n)),即 O(n^2)。

時間複雜度分析的基本策略是:從內向外分析,從最深層開始分析。如果遇到函式呼叫,要深入函式進行分析。

再舉幾個例子:

(1)、O(1)

        Temp=i; i=j; j=temp;      

以上三條單個語句的頻度均為1,該程式段的執行時間是一個與問題規模n無關的常數。演算法的時間複雜度為常數階,記作T(n)=O(1)。注意:如果演算法的執行時間不隨著問題規模n的增加而增長,即使演算法中有上千條語句,其執行時間也不過是一個較大的常數。此類演算法的時間複雜度是O(1)。

(2)、O(n2)

2.1. 交換i和j的內容

  1. sum=0;                 (一次)  
    for(i=1;i<=n;i++)     (n+1次)  
       for(j=1;j<=n;j++) (n2次)  
        sum++;            (n2次)  

解:因為Θ(2n2+n+1)=n2(Θ即:去低階項,去掉常數項,去掉高階項的常參得到),所以T(n)= =O(n2);

2.2.   

  1. for (i=1;i<n;i++)  
     {   
         y=y+1;         ①     
         for (j=0;j<=(2*n);j++)      
            x++;         ②        
     }            

解: 語句1的頻度是n-1           語句2的頻度是(n-1)*(2n+1)=2n2-n-1           f(n)=2n2-n-1+(n-1)=2n2-2;

        又Θ(2n2-2)=n2           該程式的時間複雜度T(n)=O(n2).  

  一般情況下,對步進迴圈語句只需考慮迴圈體中語句的執行次數,忽略該語句中步長加1、終值判別、控制轉移等成分,當有若干個迴圈語句時,演算法的時間複雜度是由巢狀層數最多的迴圈語句中最內層語句的頻度f(n)決定的。     

(3)、O(n)                                                              

  1. a=0;  
      b=1;                      ①  
      for (i=1;i<=n;i++) ②  
      {    
         s=a+b;    ③  
         b=a;     ④    
         a=s;     ⑤  
      }  

解: 語句1的頻度:2,                    語句2的頻度: n,                   語句3的頻度: n-1,                   語句4的頻度:n-1,               語句5的頻度:n-1,                                             T(n)=2+n+3(n-1)=4n-1=O(n).(4)、O(log2n)----很重要

  1. i=1;     ①  
    hile (i<=n)  
      i=i*2; ②  

解: 語句1的頻度是1,             設語句2的頻度是f(n),   則:2^f(n)<=n;f(n)<=log2n               取最大值f(n)=log2n,           T(n)=O(log2n )

(5)、O(n3) 

  1. for(i=0;i<n;i++)  
       {    
          for(j=0;j<i;j++)    
          {  
             for(k=0;k<j;k++)  
                x=x+2;    
          }  
       }  

解:當i=m, j=k的時候,內層迴圈的次數為k當i=m時, j 可以取 0,1,...,m-1 , 所以這裡最內迴圈共進行了0+1+...+m-1=(m-1)m/2次所以,i從0取到n, 則迴圈共進行了: 0+(1-1)*1/2+...+(n-1)n/2=n(n+1)(n-1)/6所以時間複雜度為O(n3).

最後,我們來練習一下

一. 基礎題 求該方法的時間複雜度

void aFunc(int n) {
    for (int i = 0; i < n; i++) {
        for (int j = i; j < n; j++) {
            printf("Hello World\n");
        }
    }
}

參考答案: 當 i = 0 時,內迴圈執行 n 次運算,當 i = 1 時,內迴圈執行 n - 1 次運算……當 i = n - 1 時,內迴圈執行 1 次運算。 所以,執行次數 T(n) = n + (n - 1) + (n - 2)……+ 1 = n(n + 1) / 2 = n^2 / 2 + n / 2。 根據上文說的 大O推導法 可以知道,此時時間複雜度為 O(n^2)。

二. 進階題 求該方法的時間複雜度

void aFunc(int n) {
    for (int i = 2; i < n; i++) {
        i *= 2;
        printf("%i\n", i);
    }
}

參考答案: 假設迴圈次數為 t,則迴圈條件滿足 2^t < n。 可以得出,執行次數t = log(2)(n),即 T(n) = log(2)(n),可見時間複雜度為 O(log(2)(n)),即 O(log n)。

三. 再次進階 求該方法的時間複雜度

long aFunc(int n) {
    if (n <= 1) {
        return 1;
    } else {
        return aFunc(n - 1) + aFunc(n - 2);
    }
}

參考答案: 顯然執行次數,T(0) = T(1) = 1,同時 T(n) = T(n - 1) + T(n - 2) + 1,這裡的 1 是其中的加法算一次執行。 顯然 T(n) = T(n - 1) + T(n - 2) 是一個斐波那契數列,通過歸納證明法可以證明,當 n >= 1 時 T(n) < (5/3)^n,同時當 n > 4 時 T(n) >= (3/2)^n。 所以該方法的時間複雜度可以表示為 O((5/3)^n),簡化後為 O(2^n)。 可見這個方法所需的執行時間是以指數的速度增長的。如果大家感興趣,可以試下分別用 1,10,100 的輸入大小來測試下演算法的執行時間,相信大家會感受到時間複雜度的無窮魅力。

演算法時間複雜度小結:

一個經驗規則:其中c是一個常量,如果一個演算法的複雜度為c 、 log2n 、n 、 n*log2n ,那麼這個演算法時間效率比較高 ,如果是2n ,3n ,n!,那麼稍微大一些的n就會令這個演算法不能動了,居於中間的幾個則差強人意。

2、演算法的空間複雜度

        類似於時間複雜度的討論,一個演算法的空間複雜度(Space Complexity)S(n)定義為該演算法所耗費的儲存空間,它也是問題規模n的函式。漸近空間複雜度也常常簡稱為空間複雜度。 空間複雜度(Space Complexity)是對一個演算法在執行過程中臨時佔用儲存空間大小的量度。一個演算法在計算機儲存器上所佔用的儲存空間,包括儲存演算法本身所佔用的儲存空間,演算法的輸入輸出資料所佔用的儲存空間和演算法在執行過程中臨時佔用的儲存空間這三個方面。演算法的輸入輸出資料所佔用的儲存空間是由要解決的問題決定的,是通過引數表由呼叫函式傳遞而來的,它不隨本演算法的不同而改變。儲存演算法本身所佔用的儲存空間與演算法書寫的長短成正比,要壓縮這方面的儲存空間,就必須編寫出較短的演算法。演算法在執行過程中臨時佔用的儲存空間隨演算法的不同而異,有的演算法只需要佔用少量的臨時工作單元,而且不隨問題規模的大小而改變,我們稱這種演算法是“就地\"進行的,是節省儲存的演算法,如這一節介紹過的幾個演算法都是如此;有的演算法需要佔用的臨時工作單元數與解決問題的規模n有關,它隨著n的增大而增大,當n較大時,將佔用較多的儲存單元,例如將在第九章介紹的快速排序和歸併排序演算法就屬於這種情況。

如當一個演算法的空間複雜度為一個常量,即不隨被處理資料量n的大小而改變時,可表示為O(1);當一個演算法的空間複雜度與以2為底的n的對數成正比時,可表示為0(10g2n);當一個演算法的空I司複雜度與n成線性比例關係時,可表示為0(n).若形參為陣列,則只需要為它分配一個儲存由實參傳送來的一個地址指標的空間,即一個機器字長空間;若形參為引用方式,則也只需要為其分配儲存一個地址的空間,用它來儲存對應實參變數的地址,以便由系統自動引用實參變數。

       演算法複雜度分析是一個很重要的問題,任何一個程式設計師都應該熟練掌握其概念和基本方法,而且要善於從數學層面上探尋其本質,才能準確理解其內涵。