算法基礎篇(一)——算法時間和空間複雜度

算法基礎篇(一)——算法時間和空間複雜度

1.閱讀這篇文章我能學到什麼?
你可能想知道一些計算算法時間或空間複雜度的基本方法,那麼就請閱讀這篇文章。這篇文章例舉了非常簡單的例子帶你一步步得出複雜度結果,將會簡單又輕鬆。

——如果您覺得這是一篇不錯的博文,希望您能給一個小小的贊,感謝您的支持。

幾年前還在學校學習編程相關知識時,對算法的複雜度有過一段時間基本的瞭解,到了工作後雖實際用的不多,偶爾涉及到會上網查查。一直想找個時間去系統的學習下,弄懂它。
針對我們要解決的問題,如何選擇一個最適合的算法是一個問題,我們需要綜合考略很多因素,其中算法的時間和空間複雜度就是很重要的一個衡量標準。我們的計算機有兩個寶貴的資源,一個是運行的時間,另一個就是空間的開銷。學會衡量算法的時間空間複雜度將有利於我們寫出或選擇最適合的解決問題方法。

1. 時間複雜度

1.1 時間複雜度概念

時間複雜度反應執行一個算法對時間的開銷程度。複雜度越低表明算法效率越高,一般能花更少的時間完成任務。這裏我用了一般這個詞,很多書上都以語句執行的次數來衡量兩個算法誰花費的時間更多,還引入了一個概念叫時間頻度T(n)。實際這個概念是有條件的,100次int類型的加法花費的時間是會比50次int類型加法多,但是在我們嵌入式芯片進行運算時,除了運算的次數還需要考慮運算的類型,對於一些CPU 50次浮點運算花費的時間很可能會遠大於200次整數運算。單條語句的執行時間也是有區別的。我們看書的時候一定要結合實際不可盲目信書,開發中選擇解決問題的算法要綜合考慮多種因素。
如果我們用n表示問題的規模。一個算法花費的時間與算法中語句的執行次數成正比,我們將一個算法的語句執行次數稱爲時間頻度T(n),它是關於n的函數。一般算法的執行次數與相關執行參數有關,如果用T(n)來比較兩個算法誰的計算時間短,那必須先確定參數才能計算出次數進行比較。爲了更好的描述算法的效率,我們一般用T(n)隨n變化的數量級來作爲算法的時間度量,我們引入大O來描述算法執行時間和規模n的這種數量級變化關係。
一般情況下,n規模增大而T(n)增長最慢的算法我們認爲其時間複雜度更低。

1.2 幾種常見的算法時間複雜度

O(1)O(log22n)O(n)O(nlog22n)O(n2)O(n3)O(2n)O(n!)Ο(1)<Ο(\log_2{2n})<Ο(n)<Ο(n\log_2{2n})<Ο(n^2)<Ο(n^3)<…<Ο(2^n)<Ο(n!)

  • O(1)——常數階
  • O(n)——線性階
  • O(log2n)——對數階
  • O(nlog2n)——線性對數階
  • O(n^2)——平方階

提示:計算機領域對數默認是以2爲底,所以看到log省略底數時它表示2爲底。

在這裏插入圖片描述

python3繪圖代碼:

import numpy as np
import math
import matplotlib.pyplot as plt

n = np.arange(1, 40, 1)

y1 = [1 for m in n]                                  #O(1)
y2 = [math.log(2 * m, 2) for m in n]                 #O(log2n)
y3 = n                                               #O(n)
y4 = [m * math.log(2 * m, 2) for m in n]             #O(nlog2n)
y5 = [math.pow(m, 2) for m in n]                     #O(n^2)
y6 = [math.pow(m, 3) for m in n]                     #O(n^3)
y7 = [math.pow(2, m) for m in n]                     #O(2^n)
y8 = [math.factorial(m) for m in n]                  #O(n!)

plt.plot(n, y1, color = "red", label = "O(1)")
plt.plot(n, y2, color = "orange", label = "O(log2n)")
plt.plot(n, y3, color = "yellow", label = "O(n)")
plt.plot(n, y4, color = "green", label = "O(nlog2n)")
plt.plot(n, y5, color = "blue", label = "O(n^2)")
plt.plot(n, y6, color = "violet", label = "O(n^3)")
plt.plot(n, y7, color = "coral", label = "O(2^n)")
plt.plot(n, y8, color = "cyan", label = "O(n!)")
plt.xlabel("n")
plt.ylabel("T(n)")
plt.title("Complexity")
plt.ylim(0, 300)
plt.xlim(0, 40)
plt.legend()
plt.show()

1.3 如何計算時間複雜度

通過前面的學習我們已經知道大O表示法表示的算法時間複雜度,實質是表示的運算執行次數的數量級數。
如何去計算時間複雜度呢?我們可以分四種情況:

1.3.1 不含循環

算法的執行次數就是算法的時間頻度。這類算法的時間複雜度是O(1)。

//交換a和b的值
temp = a;         //執行1次
a = b;            //執行1次
b = temp;         //執行1次

這段代碼總共執行了3次運算,確定的常數次運算算法的複雜度是O(1)。

1.3.2 含有循環

這種情況算法的時間複雜度取決於最內層循環執行的次數。

1.3.2.1 只含一個循環的情況:
//查找數組a中值等於target的
for(i = 0; i < n; i++)            //執行1~n次
{
    if(a[i] == target)            //執行1~n次
    {
        break;
    }
}

//判斷查詢結果
if(i < n)                         //執行1次
{
    Ret = i                       //執行0~1次
}
else
{
    Ret = -1                      //執行0~1次
}

這段代碼最糟糕的情況下執行了n+n+1+1n+n+1+1即頻度T(n)=2n+2T(n)=2n+2次,對於時間複雜度我們關注的是數量級,2n+22n+2屬於什麼數量級呢?可以按下面兩條法則進行求解數量級:

  • 運算次數如果是常數則變1。
  • 運算次數如果關於n,則去除常數項,只保留高階項且去掉高階項的係數。
    我們說的某算法的複雜度是按其最壞情況下執行的次數求得,所以上面示例代碼的時間複雜度爲O(n)。
1.3.2.2 循環嵌套的情況
for(i = 0; i < n; i++)                      //執行n次
{
    for(j = 0; j < n; j++)                  //執行n次
    {
      a++;                                  //執行1次
    }
}

不管這段代碼有啥實際用途。我們先來計算其頻度。先考慮循環的最內層printf每次循環只會執行一次,但如果嵌套在循環j內則將執行1×n1\times n次,是乘積關係,而循環j也執行了n次(嚴格來說for循環裏是三個子句,n次循環裏j = 0執行了1次,j < n比較了n+1n+1次,j++執行了n次,爲了便於分析問題我們可以假設for循環每次執行都只算一次,因爲這不會影響最終的時間複雜度的數量級),所以第內層循環總共執行了1×n+n=2n1\times n+n=2n次。由於外層i循環有n,總執行次數爲n×2n=2n2n\times 2n=2n^2。其頻度爲2n22n^2,則算法時間複雜度爲O(n2)O(n^2)。如果把內外循環分開來看,當循環嵌套時,其複雜度通常是內外循環複雜度的乘積。
再看一個例子:

//冒泡排序
for(i = 0; i < n - 1; i++)                  //執行n-1次
{
    for(j = 0; j < n - i - 1; j++)          //執行n - i - 1次
    {
        if(a[j] > a[j + 1])                 //執行1次
        {
            temp = a[j];                    //執行0~1次
            a[j] = a[j + 1];                //執行0~1次
            a[j + 1] = temp;                //執行0~1次
        }
    }
}

內層循環內的變量值交換需要滿足一定條件纔會執行,我們一般分析複雜度時按最壞的情況(除非刻意求最好的情況或平均情況)。最壞情況下內存循環裏的語句每次都會執行,1個比較和3次賦值共4次運算(這裏也是忽略j + 1運算,因爲這也不會影響最終的時間複雜度數量級,爲了便於分析和描述問題後文默認for循環括號內每次執行算一次)。更復雜的情況來了,內層j循環的次數並不確定,與外層i循環有關。i值從0到n2n-2n1n-1次,j值從ni2n-i-2到0即ni1n-i-1次,實際嵌套後的循環次數就構成了級數,(n1)+(n2)+(n3)+...+2+1=n=1n1an=n×(n1)2(n-1)+(n-2)+(n-3)+...+2+1=\sum_{n=1}^{n-1}a_n=\frac{n\times (n-1)}{2},由於內層循環內還有4個運算要執行因此還要×4\times 4纔是頻度,則算法時間複雜度爲O(n2)O(n^2)

1.3.2.3 循環並列的情況

for(i = 0; i < n; i++)                      //執行n次
{
    a++;                                    //執行1次
}

for(j = 0; j < n; j++)                      //執行n次
{
    a++;                                    //執行1次
}

不管這段代碼有沒有實際用途(也不是完全沒用,事實上單片機編程中可以用來用作粗略的Delay函數)。先分析i循環,a++放在n次循環內總共執行了n×1=nn\times 1=n次,而for循環括號內算一次的話也執行了n次,所以i循環的頻度爲n+n=2nn+n=2n次,同理j循環頻度也是2n次。並列的循環總頻度等於各個循環頻度相加,那麼這段並列循環代碼的總頻度就是2n+2n=4n2n+2n=4n次,算法的時間複雜度爲O(n)O(n)

以上就是分析時間複雜度的最基本的思路,分析更復雜算法的時間複雜度需要關鍵就是分析好頻度的級數。要是把循環換成遞歸也是同樣的分析思路。

2. 空間複雜度

2.1 空間複雜度概念

時間複雜度和空間複雜度是衡量算法優劣的兩個重要維度。空間複雜度反應算法所耗費存儲空間的程度,它也是規模n的函數。算法的空間複雜度是算法在運行過程中佔用存儲空間大小的度量,而算法在執行過程中佔用的存儲空間可以分爲三類:

    1. 算法輸入輸出數據所佔用的存儲空間 。這部分空間由所要解決的問題決定,與用什麼算法無關。
    1. 存儲算法本身所佔用的存儲空間 。就和任何文本數據一樣都需要空間來存儲這部分代碼,顯然越簡短的算法一般會佔用更少的存儲空間。
    1. 算法在運行過程中佔用的臨時存儲空間 ,一些算法的臨時存儲空間爲常數且不隨問題的規模變化,我們稱這類算法是就地進行的,另一類算法的臨時存儲空間隨着問題規模n而變化。
      類似前面的時間複雜度,空間複雜度是關注空間開銷的級數,而不是具體個數。空間複雜度也是用大O表示法。以上三種算法的空間開銷,我們主要關注的是第三種運行過程中算法佔用的臨時存儲空間。

2.2 如何計算空間複雜度

  • 當佔用的存儲空間爲常數時(不隨問題規模n變化)時,空間複雜度爲O(1)。
  • 與時間複雜度不同,空間複雜度循環時和遞歸時是不同的情況,因爲遞歸會消耗空間(尾調用除外)。使用遞歸時空間複雜度=遞歸深度N*單次遞歸佔用的空間。

_爲了方便描述,下面都假設變量是int類型,佔用2字節

2.2.1 佔用常數空間

int a = 1;                      //創建a佔用2Bytes
int b = 2;                      //創建b佔用2Bytes
int temp = 0;                   //創建temp佔用2Bytes

a = temp;
temp = b;
b = a;

總共出現了3個變量,共佔用常數空間6Bytes,所以空間複雜度是O(1)。

2.2.2 含有循環時的空間複雜度計算

2.2.2.1 變量定義在循環外
int a = 0;                      //創建a變量佔用2Bytes
for(i = 0; i < n; i++)          //創建i變量佔用2Bytes
{
    a++;
}

總共出現了2個變量,共佔用常數空間4Bytes,所以空間複雜度是O(1)。

2.2.2.2 變量定義在循環內
for(i = 0; i < n; i++)          //創建i變量佔用2Bytes
{
    int a = 0;                  //創建a變量佔用2Bytes,但是創建了n次
    a++;
}

我們知道內存的分配是在變量定義的時候,局部變量從定義後保持存在知道局部的作用域結束時將其釋放,而將變量定義在循環內,每次循環都會分配一次變量空間,釋放需要等到循環完全結束。所以這段代碼的空間複雜度爲:O(n)。我們應該避免在循環內定義變量。

2.2.3 含遞歸(不考慮尾調用)時的空間複雜度計算

void fun(int a)                 //創建a變量佔用2Bytes,遞歸了n次
{
    int sum  = 13;              ////創建a變量佔用2Bytes,遞歸了n次

    if(a >= sum)
    {
        return a;
    }
    else
    {
        a++;
        return fun(a);
    }

這段代碼使用了遞歸,每次執行遞歸函數都要創建兩個變量,佔用4Bytes空間,假設遞歸了n次則空間複雜度爲O(n)。我們知道函數是需要是要消耗棧空間的,調用了n次遞歸函數還需要消耗n次的棧空間。因儘量避免在遞歸函數內定義變量,sum可以用宏或靜態變量代替。

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