新的一年,先給大家整理分享一個簡單而又重要的知識點:時間複雜度和空間複雜度。因為在前幾篇文章中,提到了時間複雜度,也許有些小夥伴還不清楚。(ps:希望在我上篇文章留言的那位小夥伴別失望哦,慢慢來。)
先給大家出個思考題,題目:sum = 1+2+3+…+n ,計算 sum 的值。
為什麼需要複雜度分析
- 學習資料結構和演算法就是為了解“快”和“省”的問題,也就是如何設計你的程式碼才能使運算效率更快,佔用空間更小。那如何來計算程式碼執行效率呢?這裡就會用到複雜度分析。
- 雖然我們可以用程式碼準確的計算出執行時間,但是這也會有很多侷限性。
- 資料規模的不同會直接影響到測試結果。比如說同一個排序演算法,排序順序不一樣,那麼最後的計算效率的結果也會不一樣;如果恰好已經是排序好的了陣列,那麼執行時間就會更短。又比如說如果資料規模比較小的話,測試結果可能也無法反應演算法的效能。
- 測試的環境不同也會影響到測試結果。比如說同一套程式碼分別在 i3 和 i7 處理器上進行測試,那麼 i7 上的測試時間肯定會比 i3 上的短。
所以需要一個不用準確的測試結果來衡量,就可以粗略地估計程式碼執行時間的方法。這就是複雜度分析。
大 O 複雜度表示法
以一個例子開始,請估算下面程式碼的執行時間
function total(n) { // 1
var sum = 0; // 2
for (var i = 0; i < n; i++) { // 3
sum += i; // 4
} //5
} //6
複製程式碼
我們假設每行程式碼執行的時間都一樣,記做 t,那麼上面的函式中的第 2 行需要 1 個 t 的時間,第 3 行 和 第 4 行分別需要 n 個 t 的時間,那麼這段程式碼總的執行時間為 (2n+1)*t。
那麼按照上面的分析方法,請估算下面程式碼的執行時間
function total(n) { // 1
var sum = 0; // 2
for (var i = 0; i < n; i++) { // 3
for (var j = 0; j < n; j++) { // 4
sum = sum + i + j; // 5
}
}
}
複製程式碼
第 2 行需要一個 t 的時間,第 3 行需要 n 個 t 的時間,第 4 行和第 5 行分別需要 n2 個的時間,那麼這段程式碼總的執行時間為 (2n2+n+1)*t 的時間。
從數學角度來看,我們可以得出個規律:程式碼的總執行時間 T(n) 與每行程式碼的執行次數成正比
T(n) = O(f(n))
在這個公式中,T(n) 表示程式碼的執行時間;n 表示資料規模的大小;f(n) 表示每行程式碼執行的次數總和;O 表示程式碼的執行時間 T(n) 與 f(n) 表示式成正比。
所以上邊兩個函式的執行時間可以標記為 T(n) = O(2n+1) 和 T(n) = O(2n2+n+1)。這就是大 O 時間複雜度表示法,它不代表程式碼真正的執行時間,而是表示程式碼隨資料規模增長的變化趨勢,簡稱時間複雜度。
而且當 n 很大時,我們可以忽略常數項,只保留一個最大量級即可。所以上邊的程式碼執行時間可以簡單標記為 T(n) = O(n) 和 T(n) = O(n2)。
時間複雜度分析
那如何分析一段程式碼的時間複雜度呢,可以利用下面的幾個方法
1.只關注迴圈執行次數最多的一段程式碼
我們在分析一段程式碼的時間複雜度時,我們只要關注迴圈次數最多的那一段程式碼就 ok 了。
比如說在第一段程式碼中
function total(n) { // 1
var sum = 0; // 2
for (var i = 0; i < n; i++) { // 3
sum += i; // 4
} //5
} //6
複製程式碼
只有第 3 行和第 4 行是執行次數最多的,分別執行了 n 次,那麼忽略常數項,所以此段程式碼的時間複雜度就是 O(n)。
2.加法法則:總複雜度等於量級最大的那段程式碼的複雜度。
比如說,看下面這段程式碼的時間複雜度。
function total(n) {
// 第一個 for 迴圈
var sum1 = 0;
for (var i = 0; i < n; i++) {
for (var j = 0; j < n; j++) {
sum1 = sum1 + i + j;
}
}
// 第二個 for 迴圈
var sum2 = 0;
for(var i=0;i<1000;i++) {
sum2 = sum2 + i;
}
// 第三個 for 迴圈
var sum3 = 0;
for (var i = 0; i < n; i++) {
sum3 = sum3 + i;
}
}
複製程式碼
我們先分別分析每段 for 迴圈的時間複雜度,再取他們中最大的量級來作為整段程式碼的時間複雜度。
第一段 for 迴圈的時間複雜度為 O(n2)。
第二段 for 迴圈執行了 1000 次,是個常數量級,儘管對程式碼的執行時間會有影響,但是當 n 無限大的時候,就可以忽略。因為它本身對增長趨勢沒有影響,所以這段程式碼的時間複雜度可以忽略。
第三段 for 迴圈的時間複雜度為 O(n)。
總上,取最大量級,所以整段程式碼的時間複雜度為 O(n2)。
3.乘法法則:巢狀程式碼的複雜度等於巢狀內外程式碼複雜度的乘積。
比如說,看下面這段程式碼的時間複雜度
function f(i) {
var sum = 0;
for (var j = 0; j < i; j++) {
sum += i;
}
return sum;
}
function total(n) {
var res = 0;
for (var i = 0; i < n; i++) {
res = res + f(i); // 呼叫 f 函式
}
}
複製程式碼
單獨看 total 函式的時間複雜度就是為 T1(n)=O(n),但是考慮到 f 函式的時間複雜度也為 T2(n)=O(n)。
所以整段程式碼的時間複雜度為 T(n) = T1(n) * T2(n) = O(n*n)=O(n2)。
幾種常見的時間複雜度分析
只看最高量級的複雜度,下圖中效率是遞減的
如上圖可以粗略的分為兩類,多項式量級和非多項式量級。其中,非多項式量級只有兩個:O(2n) 和 O(n!)
對應的增長率如下圖所示
當資料規模 n 增長時,非多項式量級的執行時間就會急劇增加,所以,非多項式量級的程式碼演算法是非常低效的演算法。
1. O(1)
O(1) 只是常量級時間複雜度表示法,並不是程式碼只有一行,比如說下面這段程式碼
function total() {
var sum = 0;
for(var i=0;i<100;i++) {
sum += i;
}
}
複製程式碼
雖然有這麼多行,即使 for 迴圈執行了 100 次,但是程式碼的執行時間不隨 n 的增大而增長,所以這樣的程式碼複雜度就為 O(1)。
2. O(logn)、O(nlogn)
對數階時間複雜度的常見程式碼如下
function total1(n) {
var sum = 0;
var i = 1;
while (i <= n) {
sum += i;
i = i * 2;
}
}
function total2(n) {
var sum = 0;
for (var i = 1; i <= n; i = i * 2) {
sum += i;
}
}
複製程式碼
上面兩個函式都有一個相同點,變數 i 從 1 開始取值,每迴圈一次乘以 2,當大於 n 時,迴圈結束。實際上,i 的取值就是一個等比數列,就像下面這樣
20 21 22 … 2k… 2x =n;
所以只要知道 x 的值,就可以知道這兩個函式的執行次數了。那由 2x = n 可以得出 x = log2n,所以這兩個函式的時間複雜度為 O(log2n)。
再看下面兩個函式的時間複雜度
function total1(n) {
var sum = 0;
var i = 1;
while (i <= n) {
sum += i;
i = i * 3;
}
}
function total2(n) {
var sum = 0;
for (var i = 1; i <= n; i = i * 3) {
sum += i;
}
}
複製程式碼
由上可以得知,這兩個函式的時間複雜度為 O(log3n) 。
由於我們可以忽略常數,也可以忽略對數中的底數,所以在對數階複雜度中,統一表示為 O(logn);那 O(nlogn) 的含義就很明確了,時間複雜度 為O(logn) 的程式碼執行了 n 次。
3. O(m+n)、O(m*n)
再來看一段特殊的程式碼時間複雜度,比如說
function total(m,n) {
var sum1 = 0;
for (var i = 0; i < n; i++) {
sum1 += i;
}
var sum2 = 0;
for (var i = 0; i < m; i++) {
sum2 += i;
}
return sum1 + sum2;
}
複製程式碼
因為我們無法評估 m 和 n 誰的量級比較大,所以就不能忽略掉其中一個,這個函式的複雜度是有兩個資料的量級來決定的,所以此函式的時間複雜度為 O(m+n);那麼 O(m*n) 的時間複雜度類似。
空間複雜度分析
空間複雜度的話和時間複雜度類似推算即可。
所謂空間複雜度就是表示演算法的儲存空間和資料規模之間的關係。
比如說分析下面程式碼的空間複雜度:
function initArr(n) {
var arr = [];
for (var i = 0; i < n; i++) {
arr[i] = i;
}
}
複製程式碼
根據時間複雜度的推算,忽略掉常數量級,每次陣列賦值都會申請一個空間儲存變數,所以此函式的空間複雜度為 O(n)。
常見的空間複雜度只有 O(1)、O(n)、O(n2)。其他的話很少會用到。
思考題解答
現在我們回到開始的思考題上,程式碼實現很簡單:
function total(n) {
var sum = 0;
for (var i = 1; i <= n; i++) {
sum += i;
}
return sum;
}
複製程式碼
此函式的時間複雜度你現在應該很容易就能看出來了,為 O(n)。
我覺得這個時間複雜度有點高了,我想要 O(1) 的時間複雜度函式來實現這個演算法,可以嗎?
可以的,小數學神通高斯教會我們一招,如下
function total(n) {
var sum = n*(n+1)/2
return sum;
}
複製程式碼
此函式的時間複雜度僅僅為 O(1),在資料規模比較龐大的時候,下面的函式是不是明顯比上面的函式運算效率更高呢。
總結
複雜度也叫漸進複雜度,包括時間複雜度和空間複雜度,一個表示執行的快慢,一個表示記憶體的消耗,用來分析演算法執行效率與資料規模之間的增長關係,可以粗略的表示,越高階複雜度的演算法,執行效率越低。
學習了複雜度分析後,是不是能避免寫出效率低的程式碼呢?來給你的程式碼做個分析吧。
重點
如果有錯誤或者錯別字,還請給我留言指出,謝謝。
我們下期見。