算法效率的度量方法
注: 本系列筆記的圖片來自小甲魚的[數據結構與算法]
之前提到設計算法要盡量提高效率,這里的效率高一般指的是算法的執行時間.
事后統計法
通過設計好的測試程序和數據,利用計算機計時器對不同算法編制的程序的運行時間進行比較,從而確定算法效率的高低.
缺陷:
- 必須依據算法實現編制好測試程序
- 不同測試環境差別不是一般的大
事前分析估算方法
在j計算機程序編寫前,依據統計方法對算法進行估算.
經過總結,我們發現一個高級語言編寫的程序在計算機上運行時所消耗的時間取決於下列因素:
- 算法采用的策略和方案
- 編譯產生的代碼質量
- 問題的輸入規模
- 機器執行指令的速度
由此可見,拋開這些與計算機硬件,軟件有關的因素,一個程序的運行時間依賴於算法的好壞和問題的輸入規模.(所謂的問題輸入規模是指輸入量的多少)
例如,計算等差序列1-100的和:
// 第一種
int i, sum = 0, n = 100; // 執行1次
for( i = 1; i<= n; i++ ) // 執行n+1次
{
sum = sum + i; // 執行n次
}
// 第二種
int sum = 0, n = 100; // 執行1次
sum = (1+n)*n/2 // 執行1次
// 第一種算法執行了1+(n+1)+n=2n+2次
// 第二種算法執行了1+1=2次
如果我們把循環看做一個整體,忽略頭尾判斷的開銷,那么這兩個算法其實就是n和1的差距.
延申的例子:
int i, j, x=0, sum=0, n=100;
for( i=1; i <= n; i++ )
{
for( j=1; j <= n; j++ )
{
x++;
sum = sum + x;
}
}
這個例子中,循環條件i從1到100,每次都要讓j循環100次,如果非常較真的研究總共精確執行次數,那是非常累的。
另一方面,我們研究算法的復雜度,側重的是研究算法隨着輸入規模擴大增長量的一個抽象,而不是精確地定位需要執行多少次,因為如果這樣的話,我們就又得考慮回編譯器優化等問題,然后,然后就永遠也沒有然后了!
所以,對於剛才例子的算法,我們可以果斷判定需要執行100²次。
我們不關心編寫程序所用的語言是什么,也不關心這些程序將跑在什么樣的計算機上,我們只關心它所實現的算法。
這樣,不計那些循環索引的遞增和循環終止條件、變量聲明、打印結果等操作。最終,在分析程序的運行時間時,最重要的是把程序看成是獨立於程序設計語言的算法或一系列步驟。
我們在分析一個算法的運行時間時,重要的是把基本操作的數量和輸入模式關聯起來。
函數的漸近增長:
假設兩個算法的輸入規模都是n,算法A要做2n+3次操作,你可以這么理解:先執行n次的循環,執行完成后再有一個n次的循環,最后有3次運算。
算法B要做3n+1次操作,理解同上,你覺得它們哪一個更快些呢?
規模 | 算法A1 (2n+3) | 算法A2 (2n) | 算法B1 (3n+1) | 算法B2 (3n) |
---|---|---|---|---|
n=1 | 5 | 2 | 4 | 3 |
n=2 | 7 | 4 | 7 | 6 |
n=3 | 9 | 6 | 10 | 9 |
n=10 | 23 | 20 | 31 | 30 |
n=100 | 203 | 200 | 301 | 300 |
當n=1時,算法A1效率不如算法B1,當n=2時,兩者效率相同;當n>2時,算法A1就開始優於算法B1了,隨着n的繼續增加,算法A1比算法B1逐步拉大差距。所以總體上算法A1比算法B1優秀。
A1和A2基本上是覆蓋的,B1和B2基本上是覆蓋的.
函數的漸近增長:給定兩個函數f(n)和g(n),如果存在一個整數N,使得對於所有的n>N,f(n)總是比g(n)大,那么,我們說f(n)的增長漸近快於g(n)。
從剛才的對比中我們還發現,隨着n的增大,后面的+3和+1其實是不影響最終的算法變化曲線的。所以,我們可以忽略這些加法常數。
第二個測試: 算法C是4n+8,算法D是2n²+1
次數 | 算法C1(4n+8) | 算法C2(n) | 算法D1(2n^2+1) | 算法D2(n^2) |
---|---|---|---|---|
n=1 | 12 | 1 | 3 | 1 |
n=2 | 16 | 2 | 9 | 4 |
n=3 | 20 | 3 | 19 | 9 |
n=10 | 48 | 10 | 201 | 100 |
n=100 | 408 | 100 | 20001 | 10000 |
n=1000 | 4008 | 1000 | 2000001 | 1000000 |
我們觀察發現,哪怕去掉與n相乘的常數,兩者的結果還是沒有改變,算法C2的次數隨着n的增長,還是遠小於算法D2。
也就是說,與最高次項相乘的常數並不重要,也可以忽略。
第三個測試: 算法E是2n2+3n+1,算法F是2n3+3n+1
次數 | 算法E1(2n^2+3n+1) | 算法E2(n^2) | 算法F1(2n^3+3n+1) | 算法F2(n^3) |
---|---|---|---|---|
n=1 | 6 | 1 | 6 | 1 |
n=2 | 15 | 4 | 23 | 8 |
n=3 | 28 | 9 | 64 | 27 |
n=10 | 231 | 100 | 2031 | 1000 |
n=100 | 20301 | 10000 | 2000301 | 1000000 |
我們通過觀察又發現,最高次項的指數大的,函數隨着n的增長,結果也會變得增長特別快。而在比較算法E和F時,n的最高次項的乘數以及其余項均可以忽略
第四個測試:算法G是2n^2,算法H是3n+1,算法I是 2n^+3n+1
次數 | 算法G(2n^2) | 算法H(3n+1) | 算法I(2n^2+3n+1) |
---|---|---|---|
n=1 | 2 | 4 | 6 |
n=2 | 8 | 7 | 15 |
n=5 | 50 | 16 | 66 |
n=10 | 200 | 31 | 231 |
n=100 | 2000 | 301 | 20301 |
n=1000 | 2000000 | 3001 | 200301 |
n=10000 | 200000000 | 30001 | 200030001 |
n=100000 | 20000000000 | 300001 | 20000300001 |
n=1000000 | 2000000000000 | 3000001 | 2000003000001 |
算法H已經看不到了,讓我們看一下當數據量比較小的時候.
這組數據我們看得很清楚,當n的值變得非常大的時候,3n+1已經沒法和2n^2的結果相比較,最終幾乎可以忽略不計。而算法G跟算法I基本已經重合了。
於是我們可以得到這樣一個結論,判斷一個算法的效率時,函數中的常數和其他次要項常常可以忽略,而更應該關注主項(最高項)的階數。
注: 測試算法時需要大量的數據,越多越好.
算法時間復雜度
算法時間復雜度的定義:在進行算法分析時,語句總的執行次數T(n)是關於問題規模n的函數,進而分析T(n)隨n的變化情況並確定T(n)的數量級。算法的時間復雜度,也就是算法的時間量度,記作:T(n)= O(f(n))。它表示隨問題規模n的增大,算法執行時間的增長率和f(n)的增長率相同,稱作算法的漸近時間復雜度,簡稱為時間復雜度。其中f(n)是問題規模n的某個函數。
關鍵需要知道執行次數==時間
這樣用大寫O()來體現算法時間復雜度的記法,我們稱之為大O記法。
一般情況下,隨着輸入規模n的增大,T(n)增長最慢的算法為最優算法。
分析算法時間復雜度的步驟:
- 用常數1取代運行時間中的所有加法常數。
- 在修改后的運行次數函數中,只保留最高階項。
- 如果最高階項存在且不是1,則去除與這個項相乘的常數。
- 得到的最后結果就是大O階。
常數階:
int sum = 0, n = 100;
printf(“常數階\n”);
printf(“常數階\n”);
printf(“常數階\n”);
printf(“常數階\n”);
printf(“常數階\n”);
printf(“常數階\n”);
sum = (1+n)*n/2;
按照概念"T(n)是關於問題規模n的函數",所以記作O(1).
線性階:
一般含有非嵌套循環涉及線性階,線性階就是隨着問題規模n的擴大,對應計算次數呈直線增長。
int i , n = 100, sum = 0;
for( i=0; i < n; i++ )
{
sum = sum + i;
}
上面這段代碼,它的循環的時間復雜度為O(n),因為循環體中的代碼需要執行n次。
平方階:
int i, j, n = 100;
for( i=0; i < n; i++ )
{
for( j=0; j < n; j++ )
{
printf(“I love FishC.com\n”);
}
}
n等於100,也就是說外層循環每執行一次,內層循環就執行100次,那總共程序想要從這兩個循環出來,需要執行100*100次,也就是n的平方。所以這段代碼的時間復雜度為O(n^2)
如果,循環的次數不一樣:
int i, j, n = 100;
for( i=0; i < n; i++ )
{
for( j=i; j < n; j++ )
{
printf(“不一樣\n”);
}
}
由於當i=0時,內循環執行了n次,當i=1時,內循環則執行n-1次……當i=n-1時,內循環執行1次,所以總的執行次數應該是:
n+(n-1)+(n-2)+…+1 = n(n+1)/2 = n^2/2+n/2
忽略最高項n2的乘數以及其余項,得出O(n2).
總結得出,循環的時間復雜度等於循環體的復雜度乘以該循環運行的次數。
對數階:
int i = 1, n = 100;
while( i < n )
{
i = i * 2;
}
由於每次i*2之后,就距離n更近一步,假設有x個2相乘后大於或等於n,則會退出循環。
於是由2^x = n得到x = log₂n,所以這個循環的時間復雜度為O(log₂n)。
函數調用的時間復雜度分析:
int i, j;
for(i=0; i < n; i++) { // 執行n+1次
function(i);
}
void function(int count) {
printf(“%d”, count); // 執行n次
}
// function函數的時間復雜度是O(1),所以整體的時間復雜度就是循環的次數O(n)
int i, j;
for(i=0; i < n; i++) {
function(i); // 執行n次
}
void function(int count) {
int j;
for(j=count; j < n; j++) {
printf(“%d”, j); // 執行n - count次
}
}
// count為0,1,2...n-1,函數的執行次數為n,n-1,..1,總次數n*(n+1)/2 =>算法的時間復雜度為O(n^2)
void function(int count) {
int j;
for(j=1; j < n; j++) {
printf(“%d”, j);
}
}
n++; // O(1)
function(n); // O(n)
for(i=0; i < n; i++) {
function(i); // O(n^2)
}
for(i=0; i < n; i++) {
for(j=i; j < n; j++) {
printf(“%d”, j); // O(n^2)
}
}
常見的時間復雜度:
例子 | 時間復雜度 | 裝逼術語 |
---|---|---|
5201314 | O(1) | 常數階 |
3n+4 | O(n) | 線性階 |
3n^2+4n+5 | O(n^2) | 平方階 |
3log(2)n+4 | O(logn) | 對數階 |
2n+3nlog(2)n+14 | O(nlogn) | nlogn階 |
n3+2n2+4n+6 | O(n^3) | 立方階 |
2^n | O(2^n) | 指數階 |
常用的時間復雜度所耗費的時間從小到大依次是:
O(1) < O(logn) < (n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)
最壞情況與平均情況:
我們查找一個有n個隨機數字數組中的某個數字,最好的情況是第一個數字就是,那么算法的時間復雜度為O(1),但也有可能這個數字就在最后一個位置,那么時間復雜度為O(n)。
平均運行時間是期望的運行時間。
最壞運行時間是一種保證。在應用中,這是一種最重要的需求,通常除非特別指定,我們提到的運行時間都是最壞情況的運行時間。
算法的空間復雜度
算法的空間復雜度通過計算算法所需的存儲空間實現,算法的空間復雜度的計算公式記作:S(n)=O(f(n)),其中,n為問題的規模,f(n)為語句關於n所占存儲空間的函數。
通常,我們都是用“時間復雜度”來指運行時間的需求,是用“空間復雜度”指空間需求。
當直接要讓我們求“復雜度”時,通常指的是時間復雜度。