1. 程式人生 > >1. 時間複雜度分析

1. 時間複雜度分析

一. 對資料規模又一個概念

想要在1s內解決問題:

  • O(n2)的演算法可以處理大約104級別的資料
  • O(n)的演算法可以處理大約10^8級別的資料
  • O(nlogn)的演算法可以處理大約10^7級別的資料
  • 保險起見,在實際中最好降一個級

空間複雜度

  • 遞迴呼叫是有空間代價的
空間複雜度O(1):

int sum1(int n){
    assert(m >= 0);
    int ret = 0;
    for(int i = 0; i <= n; i++)
        ret += i;
    return ret;
}



空間複雜度O(n):

int sum2(int n ){
    assert(n >= 0);
    if( n == 0)
        return 0;
    return n + sum2(n-1);
}

二. 簡單的複雜度分析

  • O(n2)O(n2)

選擇排序
void selectionSort(int arr[], int n){
    for(int i=0; i < n; i++){
        int minIndex = i;
        for(int j=i+1; j < n; j++){
            minIndex = j;
        }
        swap( arr[i], arr[minIndex]);
    }
}

  • O(n)O(n)

下面的程式碼是 30n次操作, 是O(n)級別的, 容易被當成O(n^2)
void printInformation(int n) {
    for( int i = 1; i <= n; i++)
        for( int j = 1; j <= 30; j++){
            cout<<"class"<<i<<" - "<<"No. "<<j<<endl;
        }
    return;
}

  • O(logn)O(logn)

二分搜尋
int binarySearch(int arr[], int n, int target){
    int l = 0, r = n-1;
    while( l <= r){
        int mid = l + (r-l)/2;
        if( arr[mid] == target) return mid;
        if( arr[mid] > target) r = mid - 1;
        else l = mid + 1;
    }
    return -1;
}


  • O(logn)O(logn)

正整數轉化為字元
string intToString( int num ){
    string s = "";
    while(num) {
        s += '0' + num%10;
        num /= 10;
    }
    
    reverse(s);
    return s;
}

分析: n經過幾次"除以10"操作後, 等於0?

log10n=O(logn)log10​n=O(logn)


為什麼 log10nlog10​n 和 log2nlog2​n 都是O(logn)O(logn)級別的?

答:

logaNloga​N 和 logbNlogb​N可以相互轉換:

logaN=logab∗logbNloga​N=loga​b∗logb​N

logabloga​b是一個常數


  • O(nlogn)O(nlogn)

void hello(int n){
    for (int sz = 1; sz < n; sz += sz)   // logn
        for( int i = 1; i < n; i++){       //n
            cout<<"hello , Algorithm!"<<endl;
        }
}

第一個forlog2nlog2​n 第二個for是nn, 結合起來就是O(nlogn)O(nlogn)


  • O(n)O(n​)

判斷n是否為素數
bool isPrime( int n){
    for(int x=2; x*x <= n; x++)
        if( n%x == 0)
            return false;
    return true;
}

假如n不是素數, 必然有q<sqrt(n)和p>sqrt(n), p*q=n。
所以不難理解為什麼x*x<=n了。

該程式還可以優化, 利用素數都是滿足6x-1或6x+5的特性。 具體實現google一下即可

三. 遞迴演算法的複雜度分析

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

  • 如果遞迴函式中, 只進行一次遞迴呼叫
  • 遞迴深度為depth
  • 在每個遞迴函式中, 時間複雜度為T
  • 則總體的時間複雜度為O(T*depth)
  • 實際案列
下面程式碼每次遞迴都少一半,所以depth是O(logn)  
T顯然是1
總體就是O(logn)


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)
}

下面程式碼每次遞迴都減少1 ,所以depth為n
T顯然是1
總體就是 O(n)

int sum(int n){
    assert( n >= 0);
    if( n== 0)
        return 0;
    return n + sum(n-1);
}

下面求pow的程式碼: depth=logn    T=1   總體O(logn)
用遞迴的方法比用n個x相乘,for迴圈實現的演算法O(logn), 要快得多


double pow(double x, int n){
    assert(n >= 0)
    if(n == 0)
        return 1.0;
        
    double t = pow(x, n/2);
    if( n%2 )
        return x*t*t ;  // n如果是奇數,n/2會舍掉一個1, 要補上
    
    return t*t;
}

遞迴中進行多次遞迴呼叫

  • 例項一O(2n)O(2n)
int f(int n){
    assert( n >= 0);
    if( n == 0)
        return 1;
    return f(n-1) + f(n-1)
}

該程式的遞迴呼叫過程如下
                    3
                /      \
              2          2
             / \        / \
            1   1      1   1
           / \ / \    / \ / \
          0  0 0  0  0  0 0  0
    
兩次的遞迴呼叫, 過程看作是二叉樹      
每一層處理的數字量是-1的, 一共n+1層
一共進行
2^0 + 2^1 + 2^2 + ... + 2^n
=2^(n+1) - 1 次運算, 
每層運算複雜度是常數, 所以
O(2^n)
  • 例項二O(nlogn)O(nlogn)
歸併排序:
void mergeSort(int arr[], int l, int r){
    if(l >= r)
        return;
    
    int mid = (l+r)/2;
    mergeSort(arr, l, mid);
    mergeSort(arr, mid+1, r);
    merge(arr, l, mid, r);
}

該程式的遞迴呼叫過程如下
                    8
                /      \
              4          4
             / \        / \
            2   2      2   2
           / \ / \    / \ / \
          1  1 1  1  1  1 1  1
          
兩次的遞迴呼叫, 過程看作是二叉樹
每層處理的數字兩是減半的, 一共4層
進行了2^0 + 2^1 + .... + 2^log2(n)  次運算

每次運算處理的量為n,
所以O(nlogn)


四. 均攤複雜度分析

  • 下面是一個動態陣列的程式碼
#ifndef INC_06_AMORTIZED_TIME_MYVECTOR_H
#define INC_06_AMORTIZED_TIME_MYVECTOR_H

template <typename T>
class MyVector{

private:
    T* data;
    int size;       // 儲存陣列中的元素個數
    int capacity;   // 儲存陣列中可以容納的最大的元素個數

    // 複雜度為 O(n)
    void resize(int newCapacity){

        assert(newCapacity >= size);
        T *newData = new T[newCapacity];
        for( int i = 0 ; i < size ; i ++ )
            newData[i] = data[i];
        delete[] data;

        data = newData;
        capacity = newCapacity;
    }

public:
    MyVector(){

        data = new T[100];
        size = 0;
        capacity = 100;
    }

    ~MyVector(){

        delete[] data;
    }

    // 平均複雜度為 O(1)
    void push_back(T e){

        if(size == capacity)
            resize(2 * capacity);

        data[size++] = e;
    }

    // 平均複雜度為 O(1)
    T pop_back(){

        assert(size > 0);
        size --;

        return data[size];
    }

};

#endif //INC_06_AMORTIZED_TIME_MYVECTOR_H

  • 分析
一開始容量capacity=100, 這時候往MyVector加元素時, 時間複雜度是O(1)
當size=capacity時, 我們需要resize,在for中進行了capacity次操作,複雜度為o(n)。
但這中操作只有當size=capacity時,才會做。在這之前進行了capacity次新增元素push_back, 每次複雜度為1。
將resize的操作均攤到之前的push_back上, 每次push_back由1變為了2, 演算法依然是O(1)

五. 避免複雜度的震盪

  • 假如MyVector中儲存了大量元素,突然要刪除部分元素, 我們在pop_back的時候並沒有將多餘的空間縮小。
  • 完善pop_back
    // 平均複雜度為 O(1)
    T pop_back(){

        assert(size > 0);
        T ret = data[size-1];
        size --;

    
        // resize的容量是當前最大容量的1/2
        if(size == capacity / 2)
            resize(capacity / 2);

        return ret;
  • 對現在的pop_back進行均攤分析
假設當前容量capacity為2n, 當pop_back到size=n時, 會resize,複雜度為O(n)
與之前的pop_back均攤以後, pop_back由1變為2, 複雜度依然為O(1)

但是將push_back和pop_back一起看時, 會發現問題

  • 當非常不巧的, 我們在size=capacity的地方,先push_back再pop_back,push_back和pop_back交替進行
  • 這樣每次push_back或pop_back都要resize,複雜度變為了O(n)
  • 這樣就與均攤的複雜度產生矛盾了。形成了複雜度震盪

解決辦法

  • 讓push_back和pop_back錯開
  • push_back的resize判斷條件依然不變
  • pop_back的resize判斷條件變為 size=capacity/4
    // 平均複雜度為 O(1)
    T pop_back(){

        assert(size > 0);
        T ret = data[size-1];
        size --;

        // 在size達到靜態陣列最大容量的1/4時才進行resize
        // resize的容量是當前最大容量的1/2
        // 防止複雜度的震盪
        if(size == capacity / 4)
            resize(capacity / 2);

        return ret;