<數據結構與算法分析>讀書筆記--運行時間計算
有幾種方法估計一個程序的運行時間。前面的表是憑經驗得到的(可以參考:<數據結構與算法分析>讀書筆記--要分析的問題)
如果認為兩個程序花費大致相同的時間,要確定哪個程序更快的最好方法很可能將它們編碼並運行。
一般地,存在幾種算法思想,而我們總願意盡早除去那些不好的算法思想,因此,通常需要分析算法。不僅如此,進行分析的能力常常提供對於設計有效算法的洞察能力。一般說來,分析還能準確地確定瓶頸,這些地方值得仔細編碼。
為了簡化分析,我們將采納如下的約定:不存在特定的時間單位。因此,我們拋棄一些前導的常數。我們還將拋棄低階項,從而要做的就計算大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
<數據結構與算法分析>讀書筆記--運行時間計算