理解算法的複雜度

要寫出高效的代碼,理解代碼的複雜度是必要的,本文就聊下算法的複雜度。算法

1 時間複雜度

1.1 理解 O(n)

說句實話,雖然說從剛開始接觸算法開始就知道了大 O 表示法,可是怎麼來的,還真沒怎麼想過,這裏捋一捋。後端

先上段代碼數組

int sum(int n) {
    int sum = 0;
    int i = 1;
    for (; i <= n; i++) {
        sum += i;
    }
    return sum;
}
複製代碼

進行復雜度分析的時候,咱們會假定每行代碼的執行時間是固定的,假設爲 1t。第 2 行代碼執行須要 1t 的時間,第 3 行 1t,第 4 行,因爲是循環,須要 nt 的時間,第 5 行也是 nt,總共須要的時間 T(n) = 2t + 2nt = (2 + 2n)t。也就是說bash

T(n) = O(f(n))
複製代碼

這裏,T(n) 表示代碼的執行時間,f(n) 表示代碼的執行次數,整體理解爲代碼的執行時間和代碼的執行次數成正比。將次數帶入,T(n) = O(2n + 2)。數據結構

再來段代碼ui

int sum(int n) {
    int sum = 0;
    int i = 1;
    for (; i <= n; i++) {
        int j = 1;
        for (; j <= n; j++) {
            sum += i * j;
        }
    }
    return sum;
}
複製代碼

第 二、3 行代碼執行各須要 1t,第 四、5 行各須要 nt,第 六、7 行各須要 n^2t,T(n) = 2t + 2nt + 2n^2t = (2n^2 + 2n + 2)t = O(2n^2 + 2n + 2)。spa

當 n 特別大的時候,常量、低階、係數均可以忽略,因此前一個例子 T(n) = O(n),後一個例子 T(n) = O(n^2)。3d

所謂時間複雜度,就是算法的執行時間與數據規模之間增加關係code

1.2 分析方法

若是咱們每次都以上面的方式去算時間複雜度,難免以爲繁瑣,因此,這裏說兩個法則,方便咱們比較快的算出複雜度。cdn

1.2.1 加法法則

總複雜度等於量級最大的那段代碼的複雜度。1.1 節的第一個例子中,前面一段代碼(第 二、3 行)複雜度爲 O(1),後面(第 4 到 6 行)爲 O(n),最後總的時間複雜度是最大的那個,即 O(n)。

1.2.2 乘法法則

嵌套代碼的複雜度等於嵌套內外代碼複雜度的乘積。1.1 節的第二個例子中,後面一段代碼(第 4 到 9 行),外層循環時間複雜度爲 O(n),內層也爲 O(n),因此,時間複雜度爲 O(n^2)。

1.3 常見 O(n)

複雜度量級能夠大體的分爲兩類:多項式量級和非多項式量級。其中,非多項式量級只有 2 個:指數階 O(2^n) 和 階乘階 O(n!)。因爲非多項式量級的算法執行時間隨數據規模的增加會急劇增長,比較低效,這裏就不作更多說明。下面是常見的多項式量級

1.3.1 O(1) 常量階

int a = n;
int b = 2 * n;
int c = 3 * n;
return a + b + c;
複製代碼

相似這種,代碼的執行次數必定,不會由於數據規模 n 的變化而變化,時間複雜度就是常量階的。

1.3.2 O(logn) 對數階

int i = 1;
while (i < n)  {
    i = i * 2;
}
複製代碼

代碼中,咱們容易得出 i 的變化規律

2^0, 2^1, 2^2, ..., 2^k
複製代碼

循環中的代碼執行次數 k 是能夠計算出來的,知足 2^k = n 便可,得出 k = log_2(n),即時間複雜度爲 O(log_2(n))

若是將代碼改成

int i = 1;
while (i < n)  {
    i = i * 3;
}
複製代碼

相似的能夠推導出時間複雜度爲 O(log_3(n))。若是你喜歡的話,也能夠構造出底爲 五、7 等的對數複雜度,不過因爲對數是能夠互相轉化的,咱們能夠統一下。

以上面的兩個複雜度爲例,O(log_3(n)) = O(log_3(2) * log_2(n)) = O(C * log_2(n))。其中的係數 C 是一個常量,能夠忽略掉,從而獲得 O(log_3(n)) = O(log_2(n))。因此,咱們能夠忽略底數,直接將這種類型的複雜度記爲 O(logn)。

1.3.3 O(n) 線性階

1.1 節的第一個例子中的循環的複雜度就是線性階的,這裏再也不贅述。

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

int sum(int m, int n) {
    int sum1 = 0;
    int i = 1;
    for (; i < m; ++i) {
        sum1 = sum1 + i;
    }

    int sum2 = 0;
    int j = 1;
    for (; j < n; ++j) {
        sum2 = sum2 + j;
    }

    return sum1 + sum2;
}
複製代碼

分析這段代碼的時候就不能使用加法法則,由於數據規模 m 和 n 無法比較大小,也就無法取最大的那個,因此複雜度爲 O(m + n)。

不過,乘法法則依然有效,好比下面這段代碼

int sum(int m, int n) {
    int sum = 0;
    int i = 1;
    for (; i <=m; i++) {
        int j = 1;
        for (; j <=n; j++) {
            sum += i * j;
        }
    }
    return sum;
}
複製代碼

複雜度爲 O(m * n)。

1.3.5 O(nlogn) 線性對數階

當複雜度爲線性階和對數階的代碼嵌套時,使用乘法法則可知此時的複雜度就爲線性對數階,這裏不作過多說明。

1.3.6 O(n^k) k 次方階,k >= 2

當複雜度爲線性階代碼嵌套時,使用乘法法則可知此時的複雜度就爲 k 次方階,這裏也不作過多說明。

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

通常狀況下,複雜度分析知道前面幾節的內容就好了。只有當同一塊代碼,在不一樣狀況下,複雜度有量級差距的時候,咱們纔會用到這幾種複雜度。

int find(int* a, int n, int d) {
    int i = 0;
    int pos = -1;
    for (; i < n; i++) {
        if (a[i] == d) {
            pos = i;
            break;
        }
    }
    return pos;
}
複製代碼

這段代碼的目的是在長度爲 n 的數組 a 中查找目標值 d,若是找到返回其位置,找不到返回 -1。

1.4.1 最好狀況時間複雜度

所謂最好狀況時間複雜度,也就是在最理想狀況下,執行這段代碼的時間複雜度。在這種狀況下,d 恰好是數組的第一個元素,這個時候的複雜度就是最好狀況時間複雜度,這裏爲 O(1)。

1.4.2 最壞狀況時間複雜度

所謂最好狀況時間複雜度,也就是在最糟糕狀況下,執行這段代碼的時間複雜度。若是數組中恰好沒有元素 d,此時須要遍歷完數組才能肯定結果,在這種狀況下對應的就是最壞狀況時間複雜度,這裏爲 O(n)。

1.4.3 平均狀況時間複雜度

固然,最好和最壞狀況時間複雜度都是對應極端狀況下的複雜度,發生的機率都比較低,這裏咱們引入平均狀況時間複雜度來表示平均狀況下的複雜度。爲了方便,後面簡稱爲平均時間複雜度。

d 在數組 a 中的位置有 n + 1 種狀況,在 0 到 n-1 位置中和不在數組中,咱們將每種狀況下需遍歷的元素個數加起來除以 n + 1,能夠獲得遍歷元素個數的平均值

(1 + 2 + 3 + ... + n + n) / (n + 1) = n(n + 3) / 2(n + 1)

去掉常量、低階、係數,就獲得了平均時間複雜度爲 O(n)。

雖然說結果咱們算對了,但計算過程是有些問題的,由於這 n+1 種狀況出現的機率並不相同。要查找的元素 d 在數組 a 中,要麼不在。爲了方便,假定出現的機率都是 1/2。存在狀況下,要查找的元素出如今 0 到 n-1 的位置的機率相同,因此要查找的元素出如今 0 到 n-1 的位置的機率是 1/2n。這時的計算方法爲

1 * 1/2n + 2 * 1/2n + 3 * 1/2n + ... + n * 1/2n + n * 1/2 = (3n + 1) / 4

所以,平均時間複雜度爲 O(n)。

1.4.4 均攤時間複雜度

從新給個例子

int* a = new int[n];
int count = 0;

void insert(int val) {
    if (count == n) {
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum = sum + a[i];
        }
        a[0] = sum;
        count = 1;
    }

    a[count] = val;
    count++;
}
複製代碼

這段代碼的做用是,從數組 a 的第 0 個位置開始插入數字 0,每次插入位置位置和數字都加 1。當數組滿時,將元素累加並將和插入到第 0 個位置,而後從後面繼續插入數字。

這個例子中,不可貴出,最好狀況時間複雜度是 O(1),最壞狀況時間複雜度是 O(n)。平均複雜度呢?此時有 n + 1 種狀況,前 n 種出如今數組沒滿的狀況下,最後一種恰好出如今數組滿的時候,而且這些狀況出現的機率都是同樣的。平均複雜度就是

1 * 1/(n+1) + 1 * 1/(n+1) + ... + 1/(n+1) + n * 1/(n+1) = 2n / (n + 1) = O(1)

其實這裏的平均複雜度分析能夠不用這麼複雜。和前面的 find() 方法不一樣,insert() 方法中,O(1) 和 O(n) 複雜度的插入是十分有規律的,通常是 1 個 O(n) 的插入後跟 n-1 個 O(1) 插入。這種狀況下,有一種更簡單的分析方法:攤還分析法,經過這種方式分析獲得的複雜度就是均攤時間複雜度。好比這個例子中,1 個 O(n) 的插入後跟 n-1 個 O(1) 插入,咱們能夠將耗時多的操做均攤到 n-1 次耗時少的操做上,均攤下來,這一組操做的均攤時間複雜度就是 O(1)。

攤還分析使用的場景比較特殊,通常是在對一個數據結構進行連續操做時,大部分狀況時間複雜度較低,少部分較高,而且操做之間有必定時序關係。這個時候,咱們將這組操做放在一塊兒分析,看可否將耗時高的操做均攤到耗時低的操做上。在可以應用均攤時間複雜度分析的場合,通常均攤時間複雜度就等於平均時間複雜度。

至於均攤時間複雜度和平均時間複雜度有啥區別,將前者理解爲後者的一種特殊狀況就行,不必去死摳,理解這種分析方法就好了。

2 空間複雜度

和時間複雜度相似,所謂空間複雜度,就是算法的存儲空間與數據規模之間的增加關係

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--) {
        std::cout << a[i] << ' ';
    }
}
複製代碼

第 2 行代碼申請了一個空間存儲變量 i,與數據規模 n 沒有關係,因此複雜度是 O(1)。第 3 行申請了一個長度爲 n 的 int 類型數組,後面的代碼沒有使用更多的空間,因此整段代碼的空間複雜度就是 O(n)。

空間複雜度常見的就是 O(1)、O(n)、O(n^2),像 O(logn)、O(nlogn) 的複雜度平時比較少用,因此理解了前三種也差很少了。

3 小結

複雜度有時間複雜度和空間複雜度,複雜度越高階,效率越低。

時間複雜度表示算法執行時間與數據規模之間的增加關係,分析時可使用加法和乘法法則,常見的有 O(1)、O(logn)、O(n)、O(nlogn)、O(n^2)。大體增加趨勢能夠看下下面的圖

另外,對於同一段代碼,若是在不一樣狀況下時間複雜度存在量級的差距的話,能夠分析下最好、最壞、平均和均攤時間複雜度。

空間複雜度表示算法的存儲空間與數據規模之間的增加關係,常見的有 O(1)、O(n)、O(n^2)。

本文首發於公衆號「小小後端」。

相關文章
相關標籤/搜索