第一講 複雜度分析

鄭子銘發表於2022-05-17

概要

本文是王爭的演算法訓練營《第一講 複雜度分析》的學習筆記,分享了時間複雜度的由來,大 O 時間複雜度表示法,幾種常見的時間複雜度量級,最好、最壞、平均時間複雜度,均攤時間複雜度和攤還分析,空間複雜度分析等等

目錄

  • 時間複雜度的由來
  • 大 O 時間複雜度表示法
  • 幾種常見的時間複雜度量級
  • 最好、最壞、平均時間複雜度
  • 均攤時間複雜度和攤還分析
  • 空間複雜度分析

時間複雜度的由來

如何計算下面一段程式碼的執行效率?

public int sum(int n){
    int result = 0;
    for (int i = 1; i <= n; i++){
        result = result + i;
    }
    return result;
}

有一種方法就是在程式碼的前後加上時間的統計語句

public int sum(int n){
    long startTime = System.currentTimeMillis();
    int result = 0;
    for (int i = 1; i <= n; i++){
        result = result + i;
    }
    long endTime = System.currentTimeMillis();
    long costTime = endTime - startTime;
    System.out.println("執行程式碼花費時間為:" + costTime + "ms");
    return result;
}

這種方法有兩個問題

  • 依賴機器、測試資料(或資料規模)等測試環境
  • 需要寫測試程式碼,需要真的執行程式碼測試

有沒有更加簡單地統計執行效率的方法呢?

時間複雜度分析

  • 不依賴機器、測試資料(或資料規模)等測試環境
  • 不需要寫測試程式碼,需要真的執行程式碼測試
  • 通過肉眼、讀程式碼、粗略來分析

估算下面一段程式碼的執行效率

public int sum(int n){
    int result = 0; // k1 * unit_time
    for (int i = 1; i <= n; i++){ // k2 * unit_time
        result = result + i; // k3 * unit_time
    }
    return result; // // k4 * unit_time
}
  • 程式->編譯成->機器指令->CPU執行
  • 機器指令平均執行時間 unit_time

總執行時間 = k1 * unit_time + n * k2 * unit_time + n * k3 * unit_time + k4 * unit_time
= (k1 + k4) * unit_time + n * (k2 + k3) * unit_time

用程式碼的執行時間來表示執行效率,還是比較繁瑣的,比較不方便拿來溝通的

大 O 時間複雜度表示法

什麼是大 O 時間複雜度表示法

  • 只表示資料規模 n 很大時候的執行效率
  • 忽略低階、常量、係數,只保留最高“量級”
  • 表示執行時間隨資料規模的增長趨勢,而不是具體的執行時間

90 + 6 * n + 7 * n^2 + 8 * n^3 ≈ n^3

大 O 時間複雜度表示法的計算方法

public int sum(int n){
    int result = 0; // k1 * unit_time 1次
    for (int i = 1; i <= n; i++){ // k2 * unit_time n次
        result = result + i; // k3 * unit_time n次
    }
    return result; // // k4 * unit_time 1次
}

總執行時間 = k1 * unit_time + n * k2 * unit_time + n * k3 * unit_time + k4 * unit_time
= (k1 + k4) * unit_time + n * (k2 + k3) * unit_time

O(n) 忽略低階、常量、係數

時間複雜度分析練習

public int f(int n) {
    int result = 0; // k1 * unit_time 1次
    for (int i = 1; i <= n; i++){ // k2 * unit_time n次
        for (int j = 1; j <= n; ++j){ // k3 * unit_time n^2次
            result = result + i * j; // k4 * unit_time n^2次
        }
    }
    return result; // // k5 * unit_time 1次
}

總執行時間 = (k1 + k5) * unit_time + n * k2 * unit_time + n^2 * (k3 + k4) * unit_time

O(n^2) 忽略低階、常量、係數

實際上,時間複雜度跟執行次數最多的那段程式碼的執行次數成正比

能不能進行一些優化呢?

result = (1 * 1 + 1 * 2 + ... + 1 * n) + (2 * 1 + 2 * 2 + ... + 2 * n) + ... + (n * 1 + n * 2 + ... + n * n)

計算過程優化為:

result = 1 * (1 + 2 + ... + n) + 2 * (1 + 2 + ... + n) + ... + n * (1 + 2 + ... + n)

然後抽出同樣的 (1 + 2 + ... + n) 為 temp,修改程式碼如下

public int f2(int n) {
    int tmp = 0;
    for (int i = 1; i <= n; i++){
        tmp = tmp + 1;
    }
    int result = 0;
    for (int i = 1; i <= n; i++){
        result = result + i * tmp;
    }
    return result;
}

兩端程式碼完成同樣的功能,程式碼 A 的時間複雜度是 O(n^2) ,程式碼 B 的時間複雜度是 O(n)。那麼,程式碼 B 的執行效率比程式碼 A 高。

時間複雜度的比較僅限於功能相同的程式碼之間,如果兩段程式碼功能都不同,比較時間複雜度就沒有意義了

幾種常見的時間複雜度量級

  • O(1) 常量級 雜湊表上的各種操作
  • O(logn) 對數級 二分查詢、平衡二叉查詢樹、跳錶
  • O(n) 線性 陣列和連結串列的遍歷、二叉樹遍歷
  • O(nlogn) 快速排序、歸併排序、堆排序
  • O(n^2) 冒泡、插入、選擇排序
  • O(2^n)指數級 回溯去窮舉演算法、比如八皇后問題、斐波那契數列
  • O(n!) 比較少見,求全排列,實際上跟 n^n 同階

On(logn) 對數級時間複雜度

// 返回第一個比 n 大並且為 2 的 k 次方的數
public int f4(int n) { // k1 * unit_time 1次
    int i = 1; // k2 * unit_time 1次
    while (i <= n){ // k3 * unit_time ?次
        i = i * 2; // k4 * unit_time ?次
    }
    return i; // // k5 * unit_time 1次
}

i = 1, 2, 4, 8, ... 2^k = 2^0 、2^1, 2^2, 2^3 ... 2^k

i = 2^k > n 時,while 迴圈結束

以 2 為底求對數

log2 2^k > log2 n

等於

k > log2 n

k = log2 n O(log2 n)-> 統一為 O(logn)

為什麼要把底數省略掉,統一表示為 O(logn) 呢?

我們來看下面這個例子

// 返回第一個比 n 大並且為 3 的 k 次方的數
public int f4(int n) { // k1 * unit_time 1次
    int i = 1; // k2 * unit_time 1次
    while (i <= n){ // k3 * unit_time ?次
        i = i * 3; // k4 * unit_time ?次
    }
    return i; // // k5 * unit_time 1次
}

i = 1, 3, 9, 27, ... 3^k = 3^0 、3^1, 3^2, 3^3 ... 3^k

i = 3^k > n 時,while 迴圈結束

k = log3 n

O(log3 n) = O(log3 2 * log2 n)

常數可以省略,所以統一為 O(logn)

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

分析下面這段程式碼的時間複雜度

public int search(int a[], int n, int target) {
    for (int i = 0; i < n; i++) { // ?次
        if (a[i] == target) { // ?次
            return i; // 1次
        }
    }
    return -1; // 1次
}

第 2、3 行程式碼有可能執行了 1 次、2 次、3 次 ... n 次

執行效率並不是穩定的,分情況來看,有的時候很快,有的時候很慢

在不同的情況下,執行效率不同,針對這種情況,如何表示程式碼的執行效率

類比介面的響應時間,我們選取三個不同統計值來表示這段程式碼的執行效率:

  • 最好情況下的時間複雜度 O(1),類比介面最小響應時間
  • 最差情況下的時間複雜度 O(n),類比介面最大響應時間
  • 平均情況下的時間複雜度 (1 + 2 + 3 + ... + n)/n = O(n),類比介面平均響應時間

均攤時間複雜度和攤還分析

均攤時間複雜度:一種特殊的平均時間複雜度

public class Demo{
    private int n = 10;
    private int a[] = new int[n];
    private int count = 0;
    public void insert(int data){
        if (count == n){
            int b[] = new int[n * 2];
            for (int i = 0; i < n; i++) {
                b[i] = a[i];
            }
            a = b;
            n = n * 2;
        }
        a[count] = data;
        count ++ ;
    }
}

在我們不停呼叫 insert 方法的時候,耗時有一定的規律

Demo demo = new Demo();
demo.insert(1); // O(1)
demo.insert(2); // O(1)
// ...
demo.insert(10); // O(1)
demo.insert(11); // O(n) n = 10
demo.insert(12); // O(1)
// ...
demo.insert(20); // O(1)
demo.insert(21); // O(n) n = 20
demo.insert(22); // O(1)
// ...
demo.insert(40); // O(1)
demo.insert(41); // O(n) n = 40
demo.insert(42); // O(1)
// ...

當資料超過陣列容量的時候需要申請一個更大的空間,並把原來的資料拷貝到新的陣列裡面

申請空間不會特別耗時,但是迴圈拷貝資料比較耗時

我們把耗時比較多的操作,比如插入第 11 個元素的時候,因為需要申請空間,拷貝資料,把它的耗時均攤到耗時比較少的操作上面,均攤到插入第 12 個到第 20 個元素上

經過均攤之後,每個操作的耗時都是 O(1),所以時間複雜度就是 O(1)

總結一下

對某個資料結構進行一組連續的操作,大部分情況下時間複雜度都很低,只有個別情況下時間複雜度比較高,而且,這些操作之間存在前後連貫的時序關係,這個時候,我們就可以將這一組操作放在一塊分析,看是否能將耗時多的那次操作的耗時,均攤到其他耗時少的操作上

利用攤還分析法分析得到的平均時間複雜度,我們給它起了一個有區分度的名字:均攤時間複雜度。實際上,均攤時間複雜度就是一種特殊的平均時間複雜度。能夠應用攤還分析法分析均攤時間複雜度的程式碼不多,常見的就是支援動態擴容的一些資料結構

在能夠應用均攤時間複雜度分析的場景中,一般均攤(平均)時間複雜度就等於最好時間複雜度

空間複雜度分析

// 反轉陣列
public void reverse(int a[], int n){
    int tmp[] = new int[n];
    for (int i = 0; i < n; i++) {
        tmp[i] = a[n-i-1];
    }
    for (int i = 0; i < n; i++) {
        a[i] = tmp[i];
    }
}

空間複雜度 O(n)

// 反轉陣列
public void reverse2(int a[], int n){
    for (int i = 0; i < n/2; i++) {
        int tmp = a[i];
        a[i] = a[n-i-1];
        a[n-i-1] = tmp;
    }
}

空間複雜度 O(1)

  • 空間複雜度 -> 峰值
  • 時間複雜度 -> 累加值

原始碼

https://github.com/MingsonZheng/algorithm

參考

王爭的演算法訓練營(第5期)
https://www.xzgedu.com

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com) 。

相關文章