0. 前言
你們好,我是多選參數的程序鍋,一個正在「研究」操做系統(主要是容器這塊)、學數據結構和算法以及 Java 的硬核菜雞。今天這篇主要是講算法的時間、空間複雜度,參考來源主要是王爭老師的專欄《數據結構與算法之美》以及程序鍋去年上課時老師的課件。node
另外,程序鍋整了一個關於算法的 github 倉庫:https://github.com/DawnGuoDev/algorithm,該倉庫除包含基礎的數據結構和算法實現以外,還會有數據結構和算法的知識內容整理、LeetCode 刷題記錄(多種解法、Java 實現) 、一些優質書籍整理。git
★複雜度分析是整個算法學習的精髓,只要掌握了它,數據結構和算法的內容基本上就掌握了一半。github
」
1. Motivation - 爲何須要複雜度分析
過後統計法(也就是把代碼運行一遍,經過統計、監控獲得運行的時間和佔用的內存大小)的測試結果很是依賴測試環境以及受數據規模的影響程度很是大。可是實際上更須要一個不用具體的測試數據就能夠估計出算法的執行效率的方法。web
2. 大 O 複雜度表示法
算法的執行效率簡單來講就是算法的執行時間。好比下面這段代碼的執行時間,假設每行代碼執行的時間是同樣的,都爲 unit_time。在這個假設的基礎之上,這段代碼的總執行時間爲 (2n + 2)* unit_time。算法
int cal(int n) {
int sum = 0;
int i = 1;
for (;i <= n; ++i) {
sum = sum + i;
}
return sum;
}
經過這個例子,能夠看出總執行時間 T(n) 是與每行代碼的執行次數成正比,便可以知足這個公式 T(n) = O(f(n)),其中 n 是數據規模的大小,f(n) 表示每行代碼執行的總次,O() 表示一個函數,即 T(n) 與 f(n) 成正比。在這個例子中 T(n) = O(2n+2),這種方式就被稱爲大 O 複雜度表示法。可是實際上,大 O 時間複雜度並不具體表示代碼執行真正的執行時間,而是表示代碼執行時間隨數據規模增加的變化趨勢,也叫作漸進時間複雜度,簡稱時間複雜度。那麼,在 2n+2 這個式子中,係數 2 和 常數 2 並不左右增加趨勢,好比它是線性,並非會由於係數 2 或者常數 2 改變它線性增加的趨勢,所以又能夠寫成T(n)=O(n)。又好比 T(n) = O(n^2),那麼表示代碼執行時間隨數據規模 n 增加的變化趨勢是 n 平方。下面這張圖是不一樣時間複雜度,隨數據規模增加的執行時間變化數組

3. 時間複雜度分析
如何對一段代碼的時間複雜度進行分析呢?能夠採用如下幾種方法微信
-
只關注循環次數最多的一段代碼數據結構
由於大 O 複雜度表示法只是表示一種趨勢,因此能夠忽略掉公式中的常數項、低階、係數等,只記錄一個最大的量級就能夠了。所以在分析一個算法、一段代碼的複雜度的時候,只須要關注循環次數最多的那一段代碼就好了。好比下面這段代碼,時間複雜度是 O(n)app
int cal(int n) {
int sum = 0;
int i = 1;
for (;i <= n; ++i) {
sum = sum + i;
}
return sum;
} -
加法法則:總複雜度等於量級最大的那段代碼複雜度數據結構和算法
這個主要是省略掉大 O 複雜度中的低階項。我的感受這個方法跟上面的方法有些重合。好比下面這段代碼中,能夠按照循環分爲三個段,第一個段中有個循環,可是循環次數是個常數項,對增加趨勢無任何影響,所以時間複雜度是 O(1),第二段代碼的時間複雜度是 O(n),第三個段代碼的時間複雜度是 O(n^2)。這三個段中量級最大的那個時間複雜度也就是整段代碼的時間複雜度。
int cal(int n) {
int sum_1 = 0;
int p = 1;
for (; p < 100; ++p) {
sum_1 = sum_1 + p;
}
int sum_2 = 0;
int q = 1;
for (; q < n; ++q) {
sum_2 = sum_2 + q;
}
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;
}
}
return sum_1 + sum_2 + sum_3;
} -
乘法法則:嵌套代碼的複雜度等於嵌套內外代碼複雜度的乘積
好比下面這段代碼中,f() 是一個循環操做,整個複雜度是 O(n),而 cal() 中的循環至關於外層,調用了 f(),假如把 f() 當成一個簡單的操做的話,那麼時間複雜度是 O(n),算上 f() 真實的複雜度以後,整個 cal() 的時間複雜度是 O(n)*O(n)=O(n*n) = O(n^2)。
int cal(int n) {
int ret = 0;
int i = 1;
for (; i < n; ++i) {
ret = ret + f(i);
}
}
int f(int n) {
int sum = 0;
int i = 1;
for (; i < n; ++i) {
sum = sum + i;
}
return sum;
}
3.1. 常見時間複雜度
量階 | 表示 |
---|---|
常量階 | O(1) |
對數階 | O(logn) |
線性階 | O(n) |
線性對數階 | O(nlogn) |
平方階、立方階、k次方階 | O(n^2)、O(n^3)、O(n^k) |
指數階 | O(2^n) |
階乘階 | O(n!) |
其餘階 | O(m+n)、O(m*n) |

下面針對上述的若干種時間複雜度進行闡述:
-
O(1)
O(1)是常量級時間複雜度的一種表示,只要代碼的時間不隨 n 的增大而增大,那麼它的時間複雜也是 O(1)。通常狀況下,只要算法中不存在循環語句、遞歸語句,即便有成千上萬行的代碼,其時間複雜度也是 O(1)。
-
O(logn)、O(nlogn)
對數時間複雜度每每比較難分析,好比下面這段代碼中
i = 1;
while (i <= n) {
i = i * 2;
}從 i 的值從 1 開始取,每循環 1 次就乘以 2,一直到 n 爲止。那麼當執行 x 次時到達 n,那麼 ,推得 ,時間複雜度爲 。假如每循環 1 次變成乘以 3,以下所示
i = 1;
while (i <= n) {
i = i * 3;
}可得 ,時間複雜度爲 。那麼因爲 ,這個時間複雜度又能夠是 。所以在對數階時間複雜度的表示方法裏,能夠忽略「底」,而直接統一成 O(logn)。
O(nlogn) 的時間複雜度就至關於上面說到的「乘法法則」:一段代碼的時間複雜度爲O(logn) ,這段代碼循環 n 次,時間複雜度就是 O(nlogn) 了。
-
O(m+n)、O(m*n)
這種狀況下,代碼的複雜度是由兩個數據規模決定的。以下代碼所示:
int cal(int m, int n) {
int sum_1 = 0;
int i = 1;
for (; i < m; ++i) {
sum_1 = sum_1 + i;
}
int sum_2 = 0;
int j = 1;
for (; j < n; ++j) {
sum_2 = sum_2 + j;
}
return sum_1 + sum_2;
}從這段代碼中能夠看出m 和 n 是兩個數據規模,沒法評估 m 和 n 的量級大小。所以,不能省略其中任何一個,因此就是 O(m+n) 了。
-
O(2^n)、O(n!)
在上述表格中列出的複雜度量級,能夠粗略的分爲兩類:多項式量級和非多項式量級。其中非多項式量級只有這兩個。非多項式量級的算法問題也叫作 NP(Non-Deterministic Ploynomial,非肯定多項式)問題。當 n 愈來愈大時,非多項式量級算法的執行時間會急劇增長,求解問題的執行時間也會無限增加,因此是種很低效的算法。
3.2. 最好、最壞狀況時間複雜度
好比下面這段代碼中,是在數組中查找一個數據,可是並非把整個數組都遍歷一遍,由於有可能中途找到了就能夠提早退出循環。那麼,最好的狀況是若是數組中第一個元素正好是要查找的變量 x ,時間複雜度就是 O(1)。最壞的狀況是遍歷了整個數組都沒有找到想要的 x,那麼時間複雜就成了 O(n)。所以 O(1) 就是這段代碼的最好狀況時間複雜度,也就是在最好的狀況下,執行這段代碼的時間複雜度。O(n) 就是這段代碼的最壞狀況時間複雜度。
// n表示數組array的長度
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;
}
3.3. 平均狀況時間複雜度(加權平均時間複雜度或者指望時間複雜度)
最好和最壞狀況時間複雜度都是極端狀況發生的時間複雜度,並不常見。所以可使用平均狀況時間複雜度來表示。好比上面這段代碼中查找 x 在數組中的位置有兩種狀況,一種是在數組中,另外一種是不在數組中。在數組中又能夠在數組中的 0~n-1 位置。假設在數組中和不在數組中的機率分別爲 1/2,在數組中的 0~n-1 的位置機率都同樣,爲 1/(2 *n)。所以,上述這段的平均狀況時間複雜度(或者叫加權平均時間複雜度、指望時間複雜度)爲
★假如使用以下公式計算複雜度的話,那麼就至關於每種狀況的發生機率都是 1/(n+1) 了,沒有考慮每種的狀況的不一樣機率,存在必定不許確性。
」
3.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;
}
這段代碼想要實現的就是往一個數組中插入數據,若是數組滿了的話,那麼就求和以後的 sum 放到數組的第一個位置,以後繼續將數據插入到數組中。經過分析能夠發現,這段代碼的最好狀況時間複雜度是 O(1),最壞狀況時間複雜度是 O(n),平均時間複雜度是 O(1)。
那麼這段代碼中,在大部分狀況下,時間複雜度是 O(1),只有個別狀況下,複雜度纔是 O(n)。而且,O(1) 和 O(n) 出現的比較規律,通常一個 O(n) 執行以後,會出現 n-1 個 O(1) 的執行。針對這種狀況,可使用攤還分析法,就是把 O(n) 這個耗時多的時間複雜度均攤到接下來的 n-1 個 O(1) 的執行上,均攤下來的時間複雜度爲 O(1),這個時間複雜度就是均攤時間複雜度。
那麼均攤時間複雜度不怎麼常見,常見的場景是:對一個數據結構進行一組連續操做,大部分狀況下時間複雜度都很低,只有個別狀況下時間複雜度比較高。並且這些操做之間存在先後連貫的時序關係,好比上面提到的先是一系列 O(1) 的時間複雜度操做,接下來是 O(n) 的時間複雜度操做。這個時候就能夠採用攤還分析法將較高時間複雜度的那次操做的耗時平攤到其餘時間複雜度比較低的操做上。
通常均攤時間複雜度等於最好狀況時間複雜度。那麼如何區別平均時間複雜度和均攤時間複雜度呢?我以爲看你使用哪一種方法,假如使用攤還分析法算出來的時間複雜度就是均攤時間複雜度,使用加權方式、或者指望值計算方式算出來的時間複雜度就是平均時間複雜度。
4. 空間複雜度分析
空間複雜度分析方法很簡單。時間複雜度的全稱叫作漸進時間複雜度,表示算法的執行時間與數據規模之間的增加關係。那麼空間複雜度全稱叫作漸進空間複雜度,表示算法的存儲空間與數據規模之間的增加關係。
好比下面這段代碼中,首先 int i= 0;
申請一個空間存儲變量,是常量能夠忽略,int[] a = new int[n];
申請了一個大小爲 n 的 int 類型數組,剩下的代碼都沒有佔用更多的空間,所以空間複雜度是 O(n)
void print(int n) {
int i = 0;
int[] a = new int[n];
for (i; i <n; ++i) {
a[i] = i * i;
}
for (i = n-1; i >= 0; --i) {
print out a[i]
}
}
對於空間複雜度分析,其實比較簡單,通常看變量聲明時分配的空間大小便可。
4.1. 經常使用時間複雜度
量階 | 表示 |
---|---|
常數階 | O(1) |
線性階 | O(n) |
平方階 | O(n^2) |
經常使用的空間複雜度就上面 3 種,O(nlogn)、O(logn)這樣的對數階複雜度通常都用不到。
5. 總結
回顧一下複雜度分析,總的來講時間複雜度的 motivation 是咱們想要一個不用具體數據就能夠估算出算法的執行效率的方法。而時間複雜度採用的是大 O 表示法,大 O 表示法其實描述的是一個增加趨勢。好比 n^2 中,當 n 的值愈來愈大時候,O(n^2) 這個算法的執行時間是成平方增加的,而 O(n) 這個算法的執行時間是成直線型增加的,所以 O(n^2) 的時間複雜度是更高(見第一張圖)。以後是幾種經常使用的時間複雜度,平均時間複雜度、最好最壞時間複雜度,均攤時間複雜度(均攤這種思想在操做系統中有必定的體現:RR 調度算法中,在時間片大小選擇上,有着相似的處理方式,由於 RR 是一個搶佔式調度算法,當發生調度以後會發生進程的上下文切換,而進程的上下文切換是須要額外的時間成本,而這個時間成本會均攤到時間片上,當時間片很大時,顯然均攤的效果不錯,所以這個額外的時間成本影響會很小)
爲何說掌握時間複雜度是掌握了根本大法?去年上課的時候,記憶比較深入的是老師好像在講一個比較難的算法問題,而後從最簡單、複雜度最高的解法開始講起,而後跟帶着咱們一步一步分析每一塊代碼的時間複雜度,而後說這塊的代碼的時間複雜度是 O(n^2),咱們能不能想辦法把它給降下來的呢?而後就在那思考了怎麼降了,一句一句代碼看過去,畫圖等等,最終將時間複雜度降下來了。所以我的以爲掌握時間複雜度分析以後,掌握的是算法的分析方法,你能夠分析出每段代碼的複雜度,而後經過思考最終把相應代碼的時間複雜度降下來。假如你複雜度分析掌握不熟,那麼怎麼降都不知道,那麼算法的優化也就沒了。
6. 巨人的肩膀
-
程序鍋上課時老師的課件; -
極客時間-《數據結構與算法》-王爭老師
不甘於「本該如此」,「多選參數 」值得關注
本文分享自微信公衆號 - 多選參數(zhouxintalk)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。