算法時間復雜度分析
在看一個算法是否優秀時,我們一般都要考慮一個算法的時間復雜度和空間復雜度。現在隨着空間越來越大,時間復雜度成了一個算法的重要指標,那么如何估計一個算法的時間復雜度呢?
時間復雜度直觀體現
首先看一個時間復雜度不同的兩個算法,解決同一個問題,會有多大的區別。
下面兩個算法都是用來計算斐波那契數列的,兩個算法會有多大的差異。
斐波那契數列(Fibonacci sequence),又稱黃金分割數列、因數學家列昂納多·斐波那契(Leonardoda Fibonacci)以兔子繁殖為例子而引入,故又稱為“兔子數列”,指的是這樣一個數列:1、1、2、3、5、8、13、21、34、……在數學上,斐波那契數列以如下被以遞推的方法定義:F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)
- 第一種:使用遞歸方式
/**
* 使用遞歸方式計算斐波拉契數列
* @param index 計算的項數
*/
public static long fibonacciUseRecursion(int index){
if(index <= 1){
return index;
}
return fibonacciUseRecursion(index-1) + fibonacciUseRecursion(index-2);
}
- 第二種:使用非遞歸方式
/**
* 不使用遞歸方式計算斐波拉契數列
* @param index 計算的項數
*/
public static long fibonacciNoUseRecursion(int index){
if (index <= 1){
return index;
}
long first = 0;
long second = 1;
for (int i = 0; i < index - 1;i++){
second = first + second;
first = second - first;
}
return second;
}
對上面兩種算法進行簡單的運行時間統計,我們使用下面的代碼進行簡單的測試
public static void main(String[] args) {
// 獲取當前時間
long begin = System.currentTimeMillis();
// 計算第50項斐波拉契數列的值
System.out.println(fibonacciUseRecursion(50));
// 計算時間差,算法執行所花的時間
System.out.println("time:" + (System.currentTimeMillis() - begin) / 1000 +"s");
begin = System.currentTimeMillis();
System.out.println(fibonacciNoUseRecursion(50));
System.out.println("time:" + (System.currentTimeMillis() - begin) / 1000 + "s");
}
測試結果如下:
可以看到,在計算第50項的時候,第一種遞歸方式花費了48秒的時間,而第二種不到一秒,雖然這種方式不太科學,但也看出來了兩者巨大的差距,並且隨着計算的值越大,時間的差異越明顯。由此可見,時間復雜度是決定一個算法好壞的重要指標。
如何衡量一個算法的好壞
- 正確性、可讀性、健壯性。
算法必須要保證正確,不正確的算法是沒有必要衡量其好壞的;算法也要保證良好的可讀性,能夠讓閱讀者明白內在實現與邏輯;健壯性為對不合理輸入的反應能力和處理能力,比如非法輸入,要有相應的處理,而不應該程序奔潰等。這些都是一個良好的算法必備的條件。 - 時間復雜度
時間復雜度也是一個衡量算法優劣的重要條件,不同的算法的執行時間可能會存在很大的差別。 - 空間復雜度
空間復雜度表示一個算法執行過程中,需要的空間(內存)數量,也是衡量一個算法的重要指標,尤其是在嵌入式等程序中的算法,內存是非常寶貴的,有時候寧願提高時間復雜度,也要保證不占用太多的空間。
如何計算時間復雜度
第一種:事后統計法
上面我們使用了一種計算執行前后時間差的方式,直觀的來看一個算法的復雜度,比較不同算法對同一組輸入的執行時間,這種方法也叫作"事后統計法",但是這種方法也存在一些問題,主要問題有:
- 執行時間嚴重依賴於硬件已經運行時各種不確定的環境因素。
比如兩個算法在不同的硬件機器上進行測試,硬件不同,運行時間也會存在差異,即使就在一台機器上執行,也會存在運行時機器的CPU、內存使用情況不同等因素。 - 必須要編寫相應的測試代碼。
- 測試數據的選擇難以保證公正性。
比如有兩個算法,一個在數據量小的時候占優,一個在大數據量的時候運行較快,這樣便難以選擇一個公正的測試數據。
第二種:估算代碼指令執行次數
那么我們可以使用代碼的每個指令的執行次數,可以簡單估算代碼的執行次數,一般情況下,執行次數少的肯定要比執行次數多的花的時間更少。看如下的示例:
public static void test1(int n) {
if (n > 10) {
System.out.println("n > 10");
} else if (n > 5) {
System.out.println("n > 5");
} else {
System.out.println("n <= 5");
}
for (int i = 0; i < 4; i++) {
System.out.println("test");
}
}
上面這個方法,我們計算它的執行次數。
- 最上面的if...else if...else這個判斷,判斷會執行一次、判斷成立的代碼會執行一次。
- 下面的for循環,i=0這句賦值會執行一次,i<4這個判斷條件會執行4次,i++也會執行4次,循環體(輸出語句)也會執行4次。
- 因此,整個方法的執行次數為:1+1+1+4+4+4 = 15次。
public static void test2(int n) {
for (int i = 0; i < n; i++) {
System.out.println("test");
}
}
上面這個方法,我們計算它的執行次數。
- 在for循環中,i=0這句賦值會執行一次,i < n執行n次,i++執行n次,循環體執行n次。
- 因此,整個方法的執行次數為:1+n+n+n = 3n+1 次
public static void test3(int n) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
System.out.println("test");
}
}
}
上面這個方法,我們計算它的執行次數。
- 在外層for循環中,i=0這句賦值會執行一次,i < n執行n次,i++執行n次,循環體執行n次。
- 在內層循環中,j=0這句賦值會執行一次,j < n執行n次,j++執行n次,循環體執行n次。
- 因此,整個方法的執行次數為 1+n+n+n*(1+n+n+n)=3n2+3n+1 次
public static void test4(int n) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < 15; j++) {
System.out.println("test");
}
}
}
上面這個方法,我們計算它的執行次數。
- 在外層for循環中,i=0這句賦值會執行一次,i < n執行n次,i++執行n次,循環體執行n次。
- 在內層循環中,j=0這句賦值會執行一次,j < 15執行15次,j++執行15次,循環體執行15次。
- 因此,整個方法的執行次數為 1+n+n+n*(1+15+15+15)=48n+1 次
public static void test5(int n) {
while ((n = n / 2) > 0) {
System.out.println("test");
}
}
上面這個方法,我們計算它的執行次數。
- 在while循環中,每次對n取一半,相當於對n取以二為底的對數,因此n = n / 2 會執行log2(n)次,判斷條件也會執行log2(n)次。
- 在循環體中,這個輸出語句也會執行log2(n)次。
- 因此,整個方法的執行次數為 log2(n) + log2(n) + log2(n) = 3log2(n)次
public static void test6(int n) {
while ((n = n / 5) > 0) {
System.out.println("test");
}
}
上面這個方法,我們計算它的執行次數。
- 在while循環中,每次對n取五分之一,相當於對n取以五為底的對數,因此n = n / 5 會執行log5(n)次,判斷條件也會執行log5(n)次。
- 在循環體中,這個輸出語句也會執行log5(n)次。
- 因此,整個方法的執行次數為 log5(n) + log5(n) + log5(n) = 3log5(n)次
public static void test7(int n) {
for (int i = 1; i < n; i = i * 2) {
for (int j = 0; j < n; j++) {
System.out.println("test");
}
}
}
上面這個方法,我們計算它的執行次數。
- 在外層for循環中,i= 1執行一遍,每次i翻倍,執行次數為log2(n),因此i < n會執行log2(n)次,i=i*2會執行log2(n)次,循環體執行log2(n)。
- 在內層for循環中,j=0執行一次,j < n執行n次,j++執行n次,內層循環條件執行n次。
- 因此,整個方法的執行次數為 1+ log2(n) + log2(n) + log2(n)*(1+n+n+n) = 3nlog2(n) + 3log2(n)+1次
public static void test8(int n) {
int a = 10;
int b = 20;
int c = a + b;
int[] array = new int[n];
for (int i = 0; i < array.length; i++) {
System.out.println(array[i] + c);
}
}
上面這個方法,我們計算它的執行次數。
- a=10執行一次,b=20執行一次,c=a+b執行一次,初始化數組執行一次。
- 在for循環中,i=0執行一次,i < 數組長度執行n次,i++執行n次,內層循環條件執行n次。
- 因此,整個方法的執行次數為 1+1+1+1+1+n+n+n =3n +5次。
使用這種方法我們發現計算會特別麻煩,而且不同的時間復雜度表達書也比較復雜,我們也不好比較兩個時間復雜度的具體優劣,因此為了更簡單、更好的比較不同算法的時間復雜度優劣,提出了一種新的時間
復雜度表示法---大O表示法。
大O表示法
大O表示法:算法的時間復雜度通常用大O符號表述,定義為T[n] = O(f(n))。稱函數T(n)以f(n)為界或者稱T(n)受限於f(n)。 如果一個問題的規模是n,解這一問題的某一算法所需要的時間為T(n)。T(n)稱為這一算法的“時間復雜度”。當輸入量n逐漸加大時,時間復雜度的極限情形稱為算法的“漸近時間復雜度”。
大O表示法,用來描述復雜度,它表示的是數據規模n對應的復雜度,大O表示法有以下的一些特性:
- 忽略表達式常數、系數、低階項。
忽略常數,常數直接為1,比如上面第一個方法的復雜度為15,因此直接取1,其時間復雜度使用大O表示為O(1)。
忽略系數,忽略表達式的系數,比如第二個方法的時間復雜度為3n+1,忽略系數和常數,其時間復雜度為O(n)。
忽略低階項,比如第三個方法的時間復雜度為3n2+3n+1,忽略低階項3n,忽略常數1,忽略系數3,則其時間復雜度為O(n2)。 - 對數階一般忽略底數
對於對數直接的轉換,一個對數都可以乘以一個常數項成為一個沒有底數的對數,比如
log2n = log29 * log9n,因此可以省略底數,比如上面第五個方法的時間復雜度為log2(n),可以忽略底數2,則其時間負責度為logn。 - 大O表示法僅僅是一種粗略的分析模型,是一種估算,能幫助我們短時間內估算一個算法的時間復雜度。
常見的復雜度
執行次數 | 復雜度 | 非正式術語 |
---|---|---|
12 | O(1) | 常數階 |
2n+3 | O(n) | 線性階 |
4n2+zn+2 | O(n2) | 平方階 |
4log2n+21 | O(logn) | 對數階 |
3n+2log3n+15 | O(nlogn) | nlogn階 |
4n3+3n2+22n+11 | O(n3) | 立方階 |
2n | O(2n) | 指數階 |
復雜度的大小關系
O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)。
因此上面的十個方法的復雜度如下:
方法名稱 | 復雜度 | 大O表式 |
---|---|---|
test1 | 15 | O(1) |
test2 | 3n+1 | O(n) |
test3 | 3n2+3n+1 | O(n2) |
test4 | 48n+1 | O(n) |
test5 | 3log2(n) | O(logn) |
test6 | 3log5(n) | O(logn) |
test7 | 3nlog2(n) + 3log2(n) + 1 | O(nlogn) |
test8 | 3n+5 | O(n) |
直觀對比復雜的的大小
直接看表達式,還是很難判斷一些復雜度的大小關系,我們可以借助可視化的一些工具來查看比如https://zh.numberempire.com/graphingcalculator.php,通過該網站我們看到在n變化的情況下,不同表達式的變換情況。
遞歸斐波拉契數列計算方法的時間復雜度分析
第一層計算5,只需要計算1次;第二層計算3和4,2次;計算第3層,4次;計算第4層,8次。所以總共計算1+2+4+8 =15= 25-1 = 1/2 * 22 -1
第一層計算6,只需要計算1次;第二層計算5和4,2次;計算第3層,4次;計算第4層,8次;第5層,計算10次。所以總共計算1+2+4+8+10 =25 = 25 - 7 = 1/2 * 26 - 7。
所以計算第n項,它的時間復雜度為O(2^n)。
所以最開始的兩個算法,第一個的算法復雜度為O(2n),一個為O(n)。
他們的差別有多大?
- 如果有一台1GHz的普通計算機,運算速度109次每秒(n為64)
- O(n)大約耗時6.4 ∗ 10-8秒
- O(2n)大約耗時584.94年
- 有時候算法之間的差距,往往比硬件方面的差距還要大
算法的優化方向
- 用盡量少的存儲空間,即空間復雜度低。
- 用盡量少的執行步驟,即時間復雜度低。
- 一定情況下,時間復雜度和空間復雜度可以互換。
關於復雜度的更多概念
- 最好、最壞復雜度
- 均攤復雜度
- 復雜度震盪
- 平均復雜度
- ......