算法複雜度分析的那些事

我是自動化專業的應屆研究生,最終拿到了tplink、華爲、vivo等公司的ssp的offer,分享自己學習過的計算機基礎知識(C語言+操作系統+計算機網絡+linux)以及數據結構與算法的相關知識,保證看完讓你有所成長。
歡迎關注我,學習資料免費分享給你哦!還有其他超多學習資源,都是我自己學習過的,經過過濾之後的資源,免去你還在因爲擁有大量資源不知如何入手的糾結,讓你體系化學習。
在這裏插入圖片描述
對於一個算法來說,我們如何評價它的好壞呢?一般來說就是通過程序解決問題的時間和空間。現在的計算機空間效率一般已經不是關注的重點了,但是時間效率仍然是算法關注的重點。而考查一個程序的運行時間,就需要將程序每次執行的操作表示成輸入規模的函數。這就是時間複雜度分析。時間複雜度的分析也是算法面試中常考的問題,經過最近的學習,總結如下。

什麼是大O記法

舉幾個例子來說,O(f(n))是一個集合,表示的是所有函數的增長趨勢小於等於f(n)的函數集合。比如n屬於O(n^2),500n+10屬於O(n ^ 2).

對於定義來說就是,對於一個函數g(n)屬於O(f(n))中,表示如下的公式:
g(n)O(f(n)) g(n)\in O(f(n))
它表示的條件是:對於足夠大的n,,g(n)的上界由f(n)的常數倍所確定,也就是說,存在大於0的常數c和非負數n0,使得:對於所有的n大於等於n0時,g(n)都是小於等於cf(n)的。

在這裏插入圖片描述
比如前面所說500n+10小於等於510n^2.它的c=501。

大O記法的一些特性

1.如果t1(n)O(g1(n))t_1(n)\in O(g_1(n)) 並且t2(n)O(g2(n))t_2(n)\in O(g_2(n)),那麼 t1(n)+t2(n)O(maxg1(n),g2(n))t_1(n)+t_2(n)\in O(max{g_1(n),g_2(n)})

這也是我們在計算時間複雜度中最常使用到的,比如一個算法的時間複雜度是 n^2+n+2,根據上面的定理,我們可以說它的大O時間複雜度是O(n ^ 2)。

2.對於常數的運行時間,對於大O表示法來說都是O(1)。

3.係數對於大O表示法是沒有用的,比如n^2和1000n ^2來說,它們的大O記法是一樣的,因爲通過定義可以知道,係數其實本質上就是定義中的c,所以它們的大O記法都是O(n ^2)。

常見的時間複雜度

  1. 對於順序結構的程序執行,它的時間複雜度就是O(1).
int a=10;
int b=5;

上面程序雖然執行了兩步,但對於大O表示法來說,都是O(1),因爲2相當於是係數,可以去掉。

  1. 下面這個循環結構的時間複雜度是O(n)
for(int i=0;i<n;i++)
{
    int b=10
}
  1. 下面這個程序就是O(logn)的時間複雜度
int count=1;
while(count<n)
{
	count=count*2;
}

由於每次執行count擴大兩倍,那麼就是2^ x=n,可以解得x=log以2爲底的n的對數,所以時間複雜度就是O(logn)

  1. 雙重循環就是O(n^2)的時間複雜度
for(int i=0;i<n;i++)
{
   for(int j=0;j<n;j++)
   {
       //
   }
}

非遞歸算法的時間複雜度分析

以查找數組中的最大值程序爲例,分析計算一個非遞歸程序時間複雜度是如何計算的。

int MaxElement(int *A,int n)
{
	int maxval=A[0];
	for(int i=0;i<n;i++)
	{
		if(A[i]>maxval)
		{
			maxval=A[i];
		}
	}
	return A[i];
}

可以看到,這個問題的輸入規模是數組元素的個數決定的。由前面的學習可以知道,一個程序的時間複雜度是由程序之中大O最大部分決定的,所以只需要計算最大值就可以了,這個程序中的最大值是由for循環部分決定的。for循環內部進行了一步的比較操作,那麼對於i從0到n-1,一共進行了n-1次比較,那麼整個程序執行的次數就是:
C(n)=i=0n11=n1=O(n) C(n)=\sum_{i=0}^{n-1}1=n-1=O(n)
所以時間複雜度是O(n),這個分析也證明了前面所說的for循環的時間複雜度是O(n).

非遞歸的時間複雜度分析的步驟如下:

1)首先找到問題的輸入規模在哪裏,例如本例的n

2)找出算法的基本操作(一般是最內層的循環)

3)建立一個算法基本操作的求和表達式,利用數學知識得到它的和

4)利用大O的一些準則簡化表達式,得到最終的大O時間複雜度分析結果。

bool uniqueElements(int *A,int n)
{
	for(int i=0;i<n-1;i++)
	{
		for(int j=i+1;j<n;j++)
		{
			if(A[i]==A[j])
			{
				return false;
			}
		}
	}
	return true;
}

我們根據上面的一個算法,找出一個數組中是否包含重複元素,來實踐一下剛纔所說的分析方法,首先找到輸入規模爲n,但是這個算法如果在中間出現了相同元素,就結束了,所以說不僅僅取決於n,還取決於數組中數據的情況,這裏談論一下最壞的情況,就是遍歷了整個數組。

那麼此時的輸入規模就是n,找到關鍵的執行步驟,雙重循環中的判斷語句,每次執行一次。在內循環之中j在i+1和n-1之間的每一個值都會比較一次。在外循環中,i從0到n-2的每個值,都會重複上面的過程一遍。因此可以得到:
Cworst(n)=i=0n2j=i+1n11 C_{worst}(n)=\sum_{i=0}^{n-2}\sum_{j=i+1}^{n-1}1
此時就考驗數學了。首先將內部的j循環展開。
Cworst(n)=i=0n2j=i+1n11=i=0n2[(n1)(i+1)+1]=i=0n2(n1i)=i=0n2(n1)i=0n2i C_{worst}(n)=\sum_{i=0}^{n-2}\sum_{j=i+1}^{n-1}1=\sum_{i=0}^{n-2}[(n-1)-(i+1)+1]=\sum_{i=0}^{n-2}(n-1-i)=\sum_{i=0}^{n-2}(n-1)-\sum_{i=0}^{n-2}i
此時兩個求和式子分別求解
i=0n2(n1)=(n1)i=0n21=(n1)(n20+1)=(n1)2 \sum_{i=0}^{n-2}(n-1)=(n-1)\sum_{i=0}^{n-2}1=(n-1)(n-2-0+1)=(n-1)^2

i=0n2i=0+1+2+3+...+n2=(n2)(n1)2 \sum_{i=0}^{n-2}i=0+1+2+3+...+n-2=\frac{(n-2)(n-1)}{2}

將兩個式子合併,得到
i=0n2(n1)i=0n2i=(n1)2(n2)(n1)2=(n1)n2 \sum_{i=0}^{n-2}(n-1)-\sum_{i=0}^{n-2}i=(n-1)^2-\frac{(n-2)(n-1)}{2}=\frac{(n-1)n}{2}
利用大O的準則可以得到它的最壞時間複雜度就是O(n^2)。當然這種非遞歸的其實很好記,看到循環就可以判斷,這麼推導是能夠清楚的知道到底是如何計算時間複雜度,可以理解其本質,雖然面試不會考推導,但對自己來說是一種提升。

這裏面用到了兩個數學裏的求和公式:
i=lu1=ul+1,ul \sum_{i=l}^{u}1=u-l+1,其中u,l分別是上下界

i=0ni=i=1ni=1+2+3+...+n=n(n+1)2 \sum_{i=0}^{n}i=\sum_{i=1}^{n}i=1+2+3+...+n=\frac{n(n+1)}{2}

遞歸算法的時間複雜度分析

其實我從剛開始學習數據結構,就對這個遞歸程序複雜度的分析很迷惑,不知道如何計算,只是知道去死記硬背一些排序算法,比如歸併排序等的時間複雜度。直到最近重新學習,才發現有主定理這種東西。

遞歸算法的時間複雜度主要有兩種方法來計算,要根據算法的實際情況來選擇,一種是每次遞歸只是將數據規模減一或者減二這種的,比如斐波那契數列。另一種是類型歸併排序和快速排序這種分治思想的遞歸算法,將問題的規模分成幾份,分別求解。

迭代法

比如採用遞歸的算法計算n的階乘。

int F(int n)
{
	if(n==0) return 1;
	else return F(n-1)*n;
}

我們假設F(n)所需要的執行的次數是C(n),那麼就可以將C(n)用如下的公式表示出來了。
C(n)=C(n1)+1 C(n)=C(n-1)+1
其中C(n-1)是用來計算F(n-1)的運算次數,而1是表示乘法的運算此時。而當n==0時,就是C(0)=1,因爲直接返回了值。那麼我們就可以根據上面的式子來進行迭代求解了。
C(n)=C(n1)+1=[C(n2)+1]+1=C(n2)+2 C(n)=C(n-1)+1=[C(n-2)+1]+1=C(n-2)+2

C(n2)+2=[C(n3)+1]+2=C(n3)+3 C(n-2)+2=[C(n-3)+1]+2=C(n-3)+3

逐步迭代可以迭代出以下的式子:
C(n)=C(n1)+1=...=C(ni)+i=...=C(nn)+n=C(0)+n C(n)=C(n-1)+1=...=C(n-i)+i=...=C(n-n)+n=C(0)+n
所以這個算法的時間複雜度是O(n)。

下面我們在以著名的漢諾塔問題爲例,這裏不詳細解釋這個問題了,直接把遞歸程序拿過來借用,學習如何使用迭代法分析時間複雜度。

void hanNuo(int n,char A,char B,char C)
{
	if(n>0)
	{
		hanNuo(A,C,B,n-1);
		cout<<"A to C"<<endl;
		hanNuo(B,A,C,n-1);
	}
}

還是用C(n)來表示hanNuo(n)的執行次數,那麼就可以根據遞歸的公式來表示出C(n).
C(n)=C(n1)+1+C(n1)=2C(n1)+1 C(n)=C(n-1)+1+C(n-1)=2C(n-1)+1
還是按照遞歸的方法逐漸向下展開
C(n)=2C(n1)+1=2[2C(n2)+1]+1=22C(n2)+2+1 C(n)=2*C(n-1)+1=2*[2*C(n-2)+1]+1=2^2*C(n-2)+2+1

C(n)=22C(n2)+2+1=22[2C(n3)+1]+2+1=23C(n3)+22+2+1 C(n)=2^2*C(n-2)+2+1=2^2*[2*C(n-3)+1]+2+1=2^3*C(n-3)+2^2+2+1

C(n)=2iC(ni)+2i1 C(n)=2^i*C(n-i)+2^i-1

因爲最後遞歸到n=1,所以上面式子的i=n-1即可。
C(n)=2n1C(1)+2n11=2n1+2n11=2n1 C(n)=2^{n-1}*C(1)+2^{n-1}-1=2^{n-1}+2^{n-1}-1=2^n-1
所以漢諾塔問題的時間複雜度就是O(2^n)。

這就是迭代法求解遞歸算法的時間複雜度分析的方法。

主定理

當遞歸函數的時間函數滿足如下的關係時,就可以使用主定理了。
T(n)=aT(nb)+f(n) T(n)=a*T(\frac{n}{b})+f(n)
式子中的a就是遞歸子問題的個數,b是每個遞歸子問題是原問題的規模,f(n)表示的是合併遞歸結果所需要的操作。

使用這個定理,只需要記住三種情況即可
O(nlogba)>O(f(n))T(n)=O(nlogba) 情況一:當遞歸部分的執行時間O(n^{log_ba})>O(f(n))的時候,T(n)=O(n^{log_ba})

O(nlogba)<O(f(n))T(n)=O(f(n)) 情況二:當遞歸部分的執行時間O(n^{log_ba})<O(f(n))的時候,T(n)=O(f(n))

O(nlogba)=O(f(n))T(n)=O(nlogba)logn 情況三:當遞歸部分的執行時間O(n^{log_ba})=O(f(n))的時候,T(n)=O(n^{log_ba})logn

下面舉例分析。

以歸併排序爲例,歸併排序的代碼這裏就不貼了,首先是將兩個問題分成相等的兩部分,所以b=2,需要左側遞歸和右側遞歸,兩部分那麼a=2,將兩部分合並的起來的f(n)的操作的時間複雜度是O(n).所以根據主定理可以得到O(n)=O(f(n)),所以時間複雜度爲O(nlogn)。

在看下面的程序

int lianxi(int n)
{
    if(n==0)
    {
        return 0;
    }
    return lianxi(n/4)+lianxi(n/4);
}

根據主定理,可以看到a=2,b=4,而O(f(n))=O(1),因爲只是進行了加法運算。所以比較
O(nlogba)=O(nlog42)=O(n)>O(1) O(n^{log_ba})=O(n^{log_42})=O(\sqrt{n})>O(1)
所以這個時間複雜度就是O(根號n)。

對於快速排序也是一樣可以這麼分析,這裏不在詳細分析了。

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