1. 程式人生 > >資料結構與演算法學習--複雜度分析

資料結構與演算法學習--複雜度分析

什麼是複雜度分析

  1. 資料結構和演算法解決是“如何讓計算機更快時間、更省空間的解決問題”。
  2. 因此需從執行時間和佔用空間兩個維度來評估資料結構和演算法的效能。
  3. 分別用時間複雜度和空間複雜度兩個概念來描述效能問題,二者統稱為複雜度。
  4. 複雜度描述的是演算法執行時間(或佔用空間)與資料規模的增長關係。

為什麼需要複雜度分析

  1. 和效能測試相比,複雜度分析有不依賴執行環境、成本低、效率高、易操作、指導性強的特點。
  2. 掌握複雜度分析,將能編寫出效能更優的程式碼,有利於降低系統開發和維護成本。

如何進行復雜度分析

對於時間複雜度的分析,通常使用大O複雜度表示法,表示程式碼執行時間隨資料規模增長的變化趨勢,所以,也叫作漸進時間複雜度(asymptotic time complexity),簡稱時間複雜度。

用公式表示,就是 T(n) = O(f(n))表示,其中 T(n) 表示演算法執行總時間,f(n) 表示每行程式碼執行總次數,而 n 表示資料的規模。

由於時間複雜度描述的是演算法執行時間與資料規模的增長變化趨勢,所以常量階、低階以及係數實際上對這種增長趨勢不產決定性影響,所以在做時間複雜度分析時可以忽略這些項。

具體分析的時候,有下列三個方法:

  1. 單段程式碼只看迴圈次數最多的部分;
  2. 多段程式碼取複雜度最高的:即有個多個迴圈,但只看迴圈次數量級最高的那段程式碼
  3. 乘法法則--巢狀程式碼進行乘積:多個迴圈巢狀,就是相乘

常見的時間複雜度

按照數量級遞增,常見的時間複雜度量級有:

  • 常量階 O(1)
  • 對數階 O(logn)
  • 線性階 O(n)
  • 線性對數階 O(nlogn)
  • 平方階 O(n^2),立方階 O(n^3)...k次階 O(n^k)
  • 指數階 O(2^n)
  • 階乘階 O(n!)

其中,最後兩種情況是非常糟糕的情況,當然 O(n^2) 也是一個可以繼續進行優化的情況。

接下來簡單介紹上述複雜度中的幾種比較常見的:

O(1)

O(1) 表示的是常量級時間複雜度,也就是隻要程式碼的執行時間不隨 n 的增大而增長,都記作 O(1) 。一般只要演算法不包含迴圈語句和遞迴語句,時間複雜度都是 O(1)

像下列程式碼,有 3 行,但時間複雜度依然是O(1),而非 O(3)

a = 3
b = 4
print(a + b)
O(logn)、O(nlogn)

O(logn) 也是一個常見的時間複雜度,下面是一個 O(logn) 的程式碼例子:

i = 1
count = 0
n = 20
while i <= n:
    count += 1
    i *= 2
print('while 迴圈運行了 {} 次'.format(count))

這段程式碼其實就是每次迴圈都讓變數 i 乘以 2,直到其大於等於 n,這裡我設定 n=20,然後運行了後,輸出結果是迴圈運行了 5 次。

實際上這段程式碼的結束條件,就是求 2^x=n 中的 x 是等於多少,那麼迴圈次數也就知道了,而求 x 的數值,方法就是 ,那麼時間複雜度就是

假如上述程式碼進行簡單的修改,將 i *= 2 修改為 i *= 3 ,那麼同理可以得到時間複雜度就是

但在這裡,無論是以哪個為對數的底,我們都把對數階的時間複雜度記為 O(logn)

這裡主要原因有兩個:

  1. 對數可以互換,比如 ,也就是 ,常量
  2. 基於前面的理論,係數可以被忽略,也就是這裡的常量 C 可以忽略

基於這兩個原因,對數階的時間複雜度都忽略了底,統一為 O(logn)

至於 O(nlogn) ,根據乘法法則,只需要將對數階複雜度的程式碼,執行 n 次,就可以得到這個線性對數階複雜度了。

注意, O(nlogn) 是非常常見的時間複雜度,常用的排序演算法如歸併排序、快速排序的時間複雜度都是 O(nlogn)

O(m+n)、O(m*n)

前面介紹的情況都是隻有一個數據規模 n ,但這裡介紹有兩個資料規模的情況--mn

# O(m+n)
def cal(n, m):
    result = 0
    for i in range(n):
        result += i

    for j in range(m):
        result += j * 2

    return result

簡單的程式碼示例如上述所示,如果事先無法評估 mn 的量級大小,那麼這裡的時間複雜度就沒法選擇量級最大的,所以其時間複雜度就是 O(m+n)

同理,對於巢狀迴圈,就是 O(m*n) 的時間複雜度了。

最好、最壞、平均、均攤時間複雜度

這四種複雜度的定義如下:

  • 最好情況時間複雜度:程式碼在最理想的情況下執行的時間複雜度;
  • 最壞情況時間複雜度:程式碼在最壞情況下執行的時間複雜度;
  • 平均情況時間複雜度:程式碼在所有情況下執行的次數的加權平均值表示;
  • 均攤時間複雜度:程式碼執行的所有複雜度情況中,絕大多數都是低級別的複雜度,個別情況會發生最高級別複雜度且發生具有時序關係時,可以將個別高級別複雜度均攤到低級別複雜度上。基本上均攤複雜度就等於低級別複雜度,也可以看作是特殊的平均時間複雜度。

為什麼會有這四種複雜度呢?原因是:

同一段程式碼在不同情況下時間複雜度會出現量級差異,為了更全面、更準確描述程式碼的時間複雜度,引入這四種複雜度的概念;

但通常除非程式碼是出現量級差別的時間複雜度,才需要區分這四種複雜度,大多數情況都不需要區分它們。

下面是給出第一個程式碼例子:

# 在陣列 arr 中查詢目標數值 x
def find(arr, x):
    for val in arr:
        if val == x:
            return True
    return False

這個例子假設陣列 arr 的長度是 n ,那麼它最好的情況,就是第一個數值就是需要查詢的 x ,此時複雜度是 O(1) ,但最壞情況就是最後一個數值或者不存在需要查詢的 x ,那麼此時就遍歷一遍陣列,複雜度就是 O(n) ,因此這段程式碼最好和最壞情況是會出現量級差別的,O(1)O(n) 分別是最好情況複雜度和最壞情況複雜度。

而這段程式碼的平均情況時間複雜度是 O(n) ,具體分析就是首先考慮所有可能的情況以及對應出現的概率,可能發生的情況先分為兩種,存在和不存在需要查詢的數值 x ,也就是分別是 1/2 的概率,然後對於存在的情況下,又有 n 種情況,即出現在陣列任意位置的概率都是均等的,那麼它們的概率乘以存在的概率就是 1/2n ,接著再考慮每種情況需要搜尋的元素個數,其實就是程式碼執行的次數,這個分別就是從 1 到 n,並且對於不存在的情況,也是 n ,需要遍歷一遍陣列才發現不存在,所以平均時間複雜度的計算過程如下:

計算得到的就是概率論中的加權平均值,也叫期望值,所以平均時間複雜度的全稱應該叫加權平均時間複雜度或者期望時間複雜度。

這裡用大 O 表示法表示,並且去掉常量和係數後,就是 O(n)。

最後介紹下均攤時間複雜度,需要滿足以下兩個條件才使用:

1)程式碼在絕大多數情況下是低級別複雜度,只有極少數情況是高級別複雜度;

2)低級別和高級別複雜度出現具有時序規律。均攤結果一般都等於低級別複雜度。

空間複雜度分析

和時間複雜度的定義類似,空間複雜度全稱就是漸進空間複雜度(asymptotic space complexity),表示演算法的儲存空間與資料規模之間的增長關係。

簡單介紹下一個程式所需要的空間主要由以下幾個部分構成:

  • 指令空間:是值用來儲存經過編譯之後的程式指令所需要的空間。
  • 資料空間:是指用來儲存所有常量和變數值所需的空間。其主要由兩個部分構成:
    • 儲存常量和簡單變數所需要的空間
    • 儲存複合變數所需要的空間。這一類空間包括資料結構所需要的動態分配的空間
  • 環境棧空間:用來儲存函式呼叫返回時恢復執行所需要的資訊。例如,如果函式 fun1 呼叫了函式 fun2,那麼至少必須儲存 fun2 結束時 fun1 將要繼續執行的指令的地址。

參考:

  • 極客時間資料結構課程