看動畫輕鬆理解時間複雜度(二)

程式設計師吳師兄發表於2018-12-15

上篇文章講述了與複雜度有關的大 O 表示法和常見的時間複雜度量級,這篇文章來講講另外幾種複雜度: 遞迴演算法的時間複雜度(recursive algorithm time complexity),最好情況時間複雜度(best case time complexity)、最壞情況時間複雜度(worst case time complexity)、平均時間複雜度(average case time complexity)和均攤時間複雜度(amortized time complexity)。

遞迴演算法的時間複雜度

如果遞迴函式中,只進行一次遞迴呼叫,遞迴深度為depth;

在每個遞迴的函式中,時間複雜度為T;

則總體的時間複雜度為O(T * depth)

在前面的學習中,歸併排序 與 快速排序 都帶有遞迴的思想,並且時間複雜度都是O(nlogn) ,但並不是有遞迴的函式就一定是 O(nlogn) 級別的。從以下兩種情況進行分析。

① 遞迴中進行一次遞迴呼叫的複雜度分析

二分查詢法

看動畫輕鬆理解時間複雜度(二)

int binarySearch(int arr[], int l, int r, int target){
    if( l > r ) return -1;
    
    int mid = l + (r-l)/2; 
    if( arr[mid] == target ) return mid;  
    else if( arr[mid] > target ) 
    return binarySearch(arr, l, mid-1, target);    // 左邊 
    else
    return binarySearch(arr, mid+1, r, target);   // 右邊

}
複製程式碼

比如在這段二分查詢法的程式碼中,每次在 [ l , r ] 範圍中去查詢目標的位置,如果中間的元素 arr[mid] 不是 target,那麼判斷 arr[mid]是比 target 大 還是 小 ,進而再次呼叫 binarySearch這個函式。

在這個遞迴函式中,每一次沒有找到target時,要麼呼叫 左邊 的 binarySearch函式,要麼呼叫 右邊 的 binarySearch函式。也就是說在此次遞迴中,最多呼叫了一次遞迴呼叫而已。根據數學知識,需要log2n次才能遞迴到底。因此,二分查詢法的時間複雜度為 O(logn)。

求和

看動畫輕鬆理解時間複雜度(二)

int sum (int n) {
  if (n == 0) return 0;
  return n + sum( n - 1 )
}
複製程式碼

在這段程式碼中比較容易理解遞迴深度隨輸入 n 的增加而線性遞增,因此時間複雜度為 O (n)。

求冪

看動畫輕鬆理解時間複雜度(二)

//遞迴深度:logn
//時間複雜度:O(logn)
double pow( double x, int n){
  if (n == 0) return 1.0;
  
  double t = pow(x,n/2);
  if (n %2) return x*t*t;
  return t * t;
}
複製程式碼

遞迴深度為 logn,因為是求需要除以 2 多少次才能到底。

② 遞迴中進行多次遞迴呼叫的複雜度分析

遞迴演算法中比較難計算的是多次遞迴呼叫。

先看下面這段程式碼,有兩次遞迴呼叫。

// O(2^n) 指數級別的數量級,後續動態規劃的優化點
int f(int n){
 if (n == 0) return 1;
 return f(n-1) + f(n - 1);
}
複製程式碼

看動畫輕鬆理解時間複雜度(二)

遞迴樹中節點數就是程式碼計算的呼叫次數。

比如 當 n = 3 時,呼叫次數計算公式為

1 + 2 + 4 + 8 = 15

一般的,呼叫次數計算公式為

2^0 + 2^1 + 2^2 + ...... + 2^n = 2^(n+1) - 1 = O(2^n)

看動畫輕鬆理解時間複雜度(二)

與之有所類似的是 歸併排序 的遞迴樹,區別點在於

    1. 上述例子中樹的深度為 n,而 歸併排序 的遞迴樹深度為logn
    1. 上述例子中每次處理的資料規模是一樣的,而在 歸併排序 中每個節點處理的資料規模是逐漸縮小的

因此,在如 歸併排序 等排序演算法中,每一層處理的資料量為 O(n) 級別,同時有 logn 層,時間複雜度便是 O(nlogn)。

最好、最壞情況時間複雜度

看動畫輕鬆理解時間複雜度(二)
最好、最壞情況時間複雜度指的是特殊情況下的時間複雜度。

動圖表明的是在陣列 array 中尋找變數 x 第一次出現的位置,若沒有找到,則返回 -1;否則返回位置下標。

int find(int[] array, int n, int x) {
  for (  int i = 0 ; i < n; i++) {
    if (array[i] == x) {
        return i;
        break;
    }
  }
  return -1;
}
複製程式碼

在這裡當陣列中第一個元素就是要找的 x 時,時間複雜度是 O(1);而當最後一個元素才是 x 時,時間複雜度則是 O(n)。

最好情況時間複雜度就是在最理想情況下執行程式碼的時間複雜度,它的時間是最短的;最壞情況時間複雜度就是在最糟糕情況下執行程式碼的時間複雜度,它的時間是最長的。

平均情況時間複雜度

最好、最壞時間複雜度反應的是極端條件下的複雜度,發生的概率不大,不能代表平均水平。那麼為了更好的表示平均情況下的演算法複雜度,就需要引入平均時間複雜度。

平均情況時間複雜度可用程式碼在所有可能情況下執行次數的加權平均值表示。

還是以 find 函式為例,從概率的角度看, x 在陣列中每一個位置的可能性是相同的,為 1 / n。那麼,那麼平均情況時間複雜度就可以用下面的方式計算:

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

find 函式的平均時間複雜度為 O(n)。

均攤複雜度分析

我們通過一個動態陣列的 push_back 操作來理解 均攤複雜度

看動畫輕鬆理解時間複雜度(二)

template <typename T>
class MyVector{
private:
    T* data;
    int size;       // 儲存陣列中的元素個數
    int capacity;   // 儲存陣列中可以容納的最大的元素個數
    // 複雜度為 O(n)
    void resize(int newCapacity){
        T *newData = new T[newCapacity];
        for( int i = 0 ; i < size ; i ++ ){
              newData[i] = data[i];
            }
        data = newData;
        capacity = newCapacity;
    }
public:
    MyVector(){
        data = new T[100];
        size = 0;
        capacity = 100;
    }
    // 平均複雜度為 O(1)
    void push_back(T e){
        if(size == capacity)
            resize(2 * capacity);
        data[size++] = e;
    }
    // 平均複雜度為 O(1)
    T pop_back(){
        size --;
        return data[size];
    }

};
複製程式碼

push_back實現的功能是往陣列的末尾增加一個元素,如果陣列沒有滿,直接往後面插入元素;如果陣列滿了,即 size == capacity ,則將陣列擴容一倍,然後再插入元素。

例如,陣列長度為 n,則前 n 次呼叫 push_back 複雜度都為 O(1) 級別;在第 n + 1 次則需要先進行 n 次元素轉移操作,然後再進行 1 次插入操作,複雜度為 O(n)。

因此,平均來看:對於容量為 n 的動態陣列,前面新增元素需要消耗了 1 * n 的時間,擴容操作消耗 n 時間 , 總共就是 2 * n 的時間,因此均攤時間複雜度為 O(2n / n) = O(2),也就是 O(1) 級別了。

可以得出一個比較有意思的結論:一個相對比較耗時的操作,如果能保證它不會每次都被觸發,那麼這個相對比較耗時的操作,它所相應的時間是可以分攤到其它的操作中來的。

看動畫輕鬆理解時間複雜度(二)

相關文章