數據結構與算法之美--1.時間複雜度分析

爲什麼要進行時間複雜度的分析

如果我們直接將代碼跑一遍,通過統計,監控,就能夠得到算法執行的時間和佔有的內存,爲什麼還需要進行時間、空間複雜度的分析?
因爲上述評估算法的方式稱爲事後統計法,具有很大侷限性。

  • 測試結果非常依賴測試環境。
  • 測試結果受數據規模影響很大。舉個例子,就像我們的排序算法。對於小規模的數據排序,插入排序可能反倒會比快速排序還快。

所以,我們需要一個不用具體的測試數據來測試,就可以粗略的估計算法的執行效率的方法。

大O複雜度表示法

算法執行效率,其實我們可以即將它等價爲,算法代碼的執行時間。
我們從一個例子入手:估算下列代碼的執行時間。

public int cal(int n){
	int sum=0;
	for(int i=1;i<=n;i++){
		sum+=i;
	}
	return sum;
}

從cpu的角度來看,這段代碼的每一行都執行着類似的操作:讀數據-運算-寫數據。儘管每一行對應的cpu執行的個數,執行的時間都不一樣,但是,我們這裏只是粗略估計,假設每行代碼執行的時間都一樣,爲unit_time.那麼這行代碼的總執行時間是多少呢?

分析思路:第2行代碼分別需要1個unit_time的執行時間,第3、4行代碼都運行了n次,所以需要2nunit_time。所以這段代碼總的執行時間就是(2n+2)*unit_time。可以看出來,所有代碼的執行時間T(n)與每行代碼的執行次數成正比。

按照上述分析思路,我們分析下列代碼。

	public int cal(int n) {
		int sum=0;     //1
		int i=1;       //1
		int j=1;       //1
		for(;i<=n;i++) {  //n
			j=1;//n
			for(;j<=n;j++) {//n*n
				sum=sum+i*j;  //n*n
			}
		
		}
		return sum;
	}

T(n)T(n)=(2n22n^2+2n2n+3)個unit_time。
通過兩段代碼的推導過程,我們可以得出結論:
所有代碼的執行時間T(n)與每行代碼的執行次數成正比。

T(n)=O(f(n))
f(n)代表代碼執行的次數總和。

第一個例子:T(n)=O(2n+2)
第二個例子:T(n)=O(2n22n^2+2n2n+33
當n很大時,公式裏的低階、常量、係數三部分並不左右增長趨勢。所以可以忽略。也就是
第一個例子:T(n)=O(n)
第二個例子:T(n)=O(n2n^2

如何分析一段代碼的時間複雜度?

  1. 只關注循環執行次數最多的一段代碼。
  2. 加法法則:總複雜度等於量級最大的那段代碼的複雜度。
public int cal(int n) {
		int sum_1=0;
		int p=1;
		for(;p<100;p++) {
			sum_1=sum_1+p;   //100
		}
		
		int sum_2=0;
		int q=1;
		for(;q<n;++q) {
			sum_2=sum_2+q;  //n
		}
		
		int sum_3=0;
		int i=1;
		int j=1;
		for(;i<=n;i++) {
			j=1;
			for(;j<=n;j++) {
				sum_3=sum_3+i*j;  //n*n
			}
		}
		return sum_1+sum_2+sum_3;
	}

代碼分爲3部分,第一部分的時間複雜度是常量的,因爲100是已知的,就算代碼執行1000000次,只要它是已知的,他就是常量級的執行時間;第二、三段代碼很容易分析出的O(n)和O(n2n^2)。
綜合三段代碼。我們取最大的量級,所以整段代碼的時間複雜度是O(n2n^2)

T(n)=max(O(f(n)),O(g(n)));

  1. 乘法法則:嵌套代碼的複雜度等於嵌套內外代碼複雜度的乘積

幾種常見時間複雜度實例分析。

非多項式量級和多項式量級。
在這裏插入圖片描述

非多項式量級只有兩種。O(2n2^n) 和O(n!n!),我們把時間複雜度爲非多項式量級的算法問題叫做NP問題,NP時間複雜度的算法其實是非常低效的算法,因爲隨着數據規模增大,執行時間會急劇增加。

  1. O(1)
    一般情況下,只要算法不存在循環語句,遞歸語句,即使有成千上萬行代碼,其時間複雜度也是O(1)。
  2. O(logn)、O(nlogn)
i=1;
while(i<=n){
	i=i*2;  //
}

我們分析上述代碼的時間複雜度,因爲第三行代碼是執行循環次數最多的,所以我們只要分析第三行代碼的執行時間,亦即第三行代碼執行的次數。
202^0,212^1,222^2,232^3,242^4,252^5,2x2^x=n,
第三行代碼執行的次數是x,x=log2n。
所以代碼的時間複雜度爲O(log2n),在計算機科學中,我們認爲logn是log2n的縮寫。

實際上無論以2爲底還是以3爲底甚至以10爲底,都記爲O(logn),因爲代數之間是可以互相轉換的。log3n=log32*log2n,前面的常數可以將其忽略。

  1. O(m+n)、O(m*n)
    代碼的複雜度由兩個數據的規模來決定。
		int sum_1=0;
		int p=1;
		for(;p<m;p++) {
			sum_1=sum_1+p;   //m
		}
		
		int sum_2=0;
		int q=1;
		for(;q<n;++q) {
			sum_2=sum_2+q;  //n
		}

從代碼中可以看出,我們無法事先評估m和n誰的量級大,所以我們在表示複雜度的時候,就不能簡單地利用加法法則,省略掉其中一個。所以上面代碼地時間複雜度是O(m+n)。這種情況下加法法則就不有效了。但是乘法法則同樣有效。

空間複雜度

我們常見的空間複雜度就是O(1)、O(n)和O(n2)。空間複雜度的分析比時間複雜度的分析要簡單的多。

最好、最壞時間複雜度

	public int find(int[] array,int n,int x) {
		int i=0;
		int pos=-1;
		for(;i<n;i++) {
			if(array[i]==x)
				pos=i;
		}
		return pos;
	}

根據上述分析方法,我們可以看出這段代碼的複雜度爲O(n)
我們在數組中查找一個數據,並不需要每次把整個數據都遍歷一遍,因爲有可能中途找到就可以提前結束循環了,但是這段代碼寫得不夠漂亮。我們可以這樣優化這段查找代碼。

	public int find(int[] array,int n,int x) {
		int i=0;
		int pos=-1;
		for(;i<n;i++) {
			if(array[i]==x){
				pos=i;
				break;
			}
		}
		return pos;
	}

這時間複雜度不再是O(n),因爲,如果我們找到了,就會退出循環,但是我們並不知道什麼時候會找到。最好的情況是第一個元素就是我們要找的元素,那麼時間複雜度就是O(1)。最壞的情況是找不到。那麼時間複雜度是O(n)。

平均時間複雜度

我們都知道,最好時間複雜度和最壞時間複雜度是極端的情況。所以我們引入平均時間複雜度。
我們知道,要查找的變量x,要麼在數組中,要麼不在數組中。我們假設在數組中在與不在數組中的概率都爲1/2。另外,要查找的數據出現在0~n-1這n個位置的概率也是一樣的,爲1/n。所以要查找的數據出現在 0~n-1中任意位置的概率爲1/2n。

因此平均複雜度的計算過程是
11/2n+21/2n+31/2n+...+n1/2n=(3n+1)/41*1/2n+2*1/2n+3*1/2n+...+n*1/2n=(3n+1)/4

均攤時間複雜度

均攤時間複雜度和攤還分析應用場景比較特殊,所以我們並不會經常用到。簡單總結一下它們的應用場景。對一個數據結構進行一組連續操作中,

  • 大部分情況下時間複雜度都很低,只有個別情況下時間複雜度比較高
  • 這些操作之間存在前後連貫的時序關係。

這個時候,我們就可以將這一組操作放在一塊分析,看是否能將較高時間複雜度那次操作的耗時,平攤到其他那些時間複雜度比較低的操作上。而且,在能夠應用均攤時間複雜度分析的場合,一般均攤時間複雜度就等於最好情況時間複雜度

舉個例子:


 // array表示一個長度爲n的數組
 // 代碼中的array.length就等於n
 int[] array = new int[n];
 int count = 0;
 
 void insert(int val) {
 //如果元素插入完畢,我們將數組的元素累加,然後將和存儲到數組的第一位上
    if (count == array.length) {
       int sum = 0;
       for (int i = 0; i < array.length; ++i) {
          sum = sum + array[i];
       }
       array[0] = sum;
       count = 1;
    }
//將元素插入數組。
    array[count] = val;
    ++count;
 }

分析這段代碼的時間複雜度。
最好的情況:數組有空閒的位置,元素直接插入,所以是O(1)
最壞的情況:數組沒有空閒的位置,元素需要累加求和,然後將和賦給數組第一個元素。
平均情況:利用概率論。
假設數組的長度是 n,根據數據插入的位置的不同,我們可以分爲 n 種情況,每種情況的時間複雜度是 O(1)。除此之外,還有一種“額外”的情況,就是在數組沒有空閒空間時插入一個數據,這個時候的時間複雜度是 O(n)。而且,這 n+1 種情況發生的概率一樣,都是 1/(n+1)。所以,根據加權平均的計算方法,我們求得的平均時間複雜度就是:

在這裏插入圖片描述

我們引入第四種複雜度的分析,就是均攤時間複雜度。其實均攤時間複雜度就是特殊的平均時間複雜度。它特殊在哪呢?
我們和上述的find()作比較。

  1. find() 是在一個數組裏找元素,如果在第一位,我們只需要消耗1個unit_time。這是很極端的情況,只有在第一位纔有這麼好的情況。如果在第二位,就需要消耗2個unit_time。
    但是insert()它大部分情況(只要數組有空閒)只需要消耗1個unit_time。這就滿足我們應用場景第一個特點。大部分情況下時間複雜度都很低,只有個別情況下時間複雜度比較高。
  2. find()中最差的情況O(n)和最好的情況O(1)之間並沒有什麼關係。但是在insert()裏,我們可以很清楚的發現,每次在經歷n個O(1)後,就會出現一次O(n),所以這裏滿足我們應用場景的第二個特點。這些操作之間存在前後連貫的時序關係。

既然它這麼特殊,那我們是不是就不用用那麼複雜的方法分析。,我們引入了一種更加簡單的分析方法:攤還分析法。
通過攤還分析得到的時間複雜度,叫均攤時間複雜度。
那究竟如何使用攤還分析法來分析算法的均攤時間複雜度呢?
我們還是繼續看在數組中插入數據的這個例子。每一次 O(n) 的插入操作,都會跟着 n-1 次 O(1) 的插入操作,所以把耗時多的那次操作均攤到接下來的 n-1 次耗時少的操作上,均攤下來,這一組連續的操作的均攤時間複雜度就是 O(1)。這就是均攤分析的大致思路。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章