1. 程式人生 > ><資料結構與演算法分析>讀書筆記--執行時間計算

<資料結構與演算法分析>讀書筆記--執行時間計算

有幾種方法估計一個程式的執行時間。前面的表是憑經驗得到的(可以參考:<資料結構與演算法分析>讀書筆記--要分析的問題)

如果認為兩個程式花費大致相同的時間,要確定哪個程式更快的最好方法很可能將它們編碼並執行。

一般地,存在幾種演算法思想,而我們總願意儘早除去那些不好的演算法思想,因此,通常需要分析演算法。不僅如此,進行分析的能力常常提供對於設計有效演算法的洞察能力。一般說來,分析還能準確地確定瓶頸,這些地方值得仔細編碼。

為了簡化分析,我們將採納如下的約定:不存在特定的時間單位。因此,我們拋棄一些前導的常數。我們還將拋棄低階項,從而要做的就計算大O執行時間。由於大O是一個上界,因此我們必須仔細,絕不要低估程式的執行時間。實際上,分析的結果為程式在一定的時間範圍內能夠終止執行提供了保障。程式可能提起結束,但絕不可能錯後。

 

一、一個簡單的例子

package cn.simple.example;

public class SimpleExample {

    public static int sum(int n) {
        
        int partialSum;
        
1        partialSum = 0;
        
2       for(int i = 1; i <= n;i++) 
3            partialSum = i * i *i;
            
4           return partialSum;
        
    }

}

 

對這個程式段的分析是簡單的。所有的宣告均不計時間。第1行和第4行各佔一個時間單元。第3行每執行一次佔用4個時間單元(兩次乘法,一次加法和一次賦值),而執行N次共佔用4N個時間單元。第2行在初始化i、測試i<=N和對i的自增運算隱含著開銷。所有這些的總開銷是初始化1個單元時間,所有的測試為N+1個單元時間,而所有的自增運算為N個單元時間,共2N+2個時間單元。我們忽略呼叫方法和返回值的開銷,得到總量是6N+4個時間單元。因此,我們說該方法是O(N)。

如果每次分析一個程式都要演示所有這些工作,那麼這項任務很快就會變成不可行的負擔。幸運的是,由於我們有了大O的結果,因此就存在許多可以採取的捷徑,並且不影響最後的結果。例如,第3行(每次執行時)顯然是O(1)語句,因此精確計算它究竟是2、3還是4個時間單元是愚蠢的。這無關緊要。第1行與for迴圈相比顯然是不重要的,所以在這裡花費時間也是不明智的。這使我們得到若干一般法則。

 

二、一般法則

法則1-for迴圈

一個for迴圈的執行時間至多是該for迴圈內部那些語句(包括測試)的執行時間乘以迭代的次數。

法則2-巢狀的for迴圈

從裡向外分析,在一組巢狀迴圈內部的一條語句總的執行時間為該語句的執行時間乘以該組所有的for迴圈的大小的乘積。

例如:下列程式片段為O(N的2次方):

for(i=0;i<n;i++)
    for(j =0;j<n;j++)
        k++

法則3-順序語句

將各個語句的執行時間求和即可(這意味著,其中的最大值就是所得的執行時間)

例如:下面的程式片段先花費O(N),接著是O(N的2次方),因此總量也是O(N的次方):

for(i=0;i<n;i++)
 a[i]=0;
for(i=0;i<n;i++)
    for(j=0;j<n;j++)
        a[i]+=a[j]+i+j;

 

法則4-if/else語句

對於程式片段

if(condition)
    S1
else
    S2

 

一個if/else語句的執行時間從不超過判斷的執行時間再加上S1和S2中執行時間長者的總的執行時間。

顯然在某些情形下這麼估計有些過頭,但決不會估計過低。

其他的法則都是顯然的,但是,分析的基本策略是從內部(或最深層部分)向外展開工作的。如果有方法呼叫,那麼要首先分析這些呼叫。如果有遞迴過程,那麼存在幾種選擇。若遞迴實際上只是被薄棉紗遮住的for迴圈,則分析通常是很簡單的。例如,下面的方法實際上就是一個簡單的迴圈從而其執行時間為O(N):

    public static long factorial(int n) {
        if(n <= 1) 
            return 1;
        else
            return n*factorial(n-1);
    }
    

 

實際上這個例子對遞迴的使用並不好。當遞迴被正常使用時,將其轉換成一個迴圈結構是相當困難的。在這種情況下,分析將涉及求解一個遞推關係。為了觀察到這種可能發生的情形,考慮下列程式,實際上它對遞迴使用的效率低得令人驚詫。

    public static long fib(int n) {
1        if(n<=1)
2            return 1;
       else
3            return fib(n-1) +fib(n-2);
    }

 

初看起來,該程式似乎對遞迴的使用非常聰明。可是,如果將程式編碼並在N值為40左右時執行,那麼這個程式讓人感到低得嚇人。分析是十分簡單的。令T(N)為呼叫函式fib(n)的執行時間。如果N=0或N=1,則執行時間是某個常數值,即第一行上做判斷以及返回所用的時間。因為常數並不重要,所以我們可以說T(0)=T(1)=1。對於N的其他值的執行時間則相對於基準情形的執行時間來度量。若N>2,則執行該方法的時間是第1行上的常數工作加上第3行上的工作。第3行由一次加法和兩次方法呼叫組成。由於方法呼叫不是簡單的運算,因此必須用它們自己來分析它們。第一次方法呼叫是fib(n-1),從而按照T的定義它需要T(N-1)個時間單位。類似的論證指出,第二次方法呼叫需要T(N-2)個時間單位。此時總的時間需求為T(N-1)+T(N-2)+2,其中2指的是第1行上的工作加上第3行上的加法。於是對於N>=2,有下列關於fib(n)的執行時間公式:

T(N)=T(N-1)+T(N-2)+2

但是fib(N)=fib(N-1)+fib(N-2),因此由歸納法容易證明T(N)>=fib(N)。之前我們證明過fib(N)<(5/3)的N次方,類似的計算可以證明(對於N>4)fib(N)>=(3/2)的N次方,從而這個程式的執行時間以指數的速度增長。這大致是最壞的情況。通過保留一個簡單的陣列 並使用一個for迴圈,執行時間可以顯著降低。

這個程式設計師之所以執行緩慢,是因為存在大量多餘的工作要做,違反了之前敘述的遞迴的第四條主要法則(合成效益法則)。注意,在第3行上的第一次呼叫即fib(n-1)實際上在某處計算fib(n-2)。這個資訊被拋棄而在第3行上的第二次呼叫時又重新計算了一遍。拋棄的資訊量遞迴第合成起來並導致巨大的執行時間。這或許是格言,“計算任何事情不要超過一次”的最好例項,但它不應使你被嚇得遠離遞迴而不敢使用。

 

《資料結構與演算法分析》這本書確實不太好讀,通過將邊看邊用記錄,總算還是注意力比較集中。但願能夠使我痛苦的能夠使我變得強大。

 

示例程式碼庫:https://github.com/youcong1996/The-Data-structures-and-algorithms/tree/master/algorithm_analysis