【算法】算法性能分析

1 時間複雜度

1.1 知識點

時間複雜度是一個函數,它定性描述該算法的運行時間

通常會估算算法的操作單元數量來代表程序消耗的時間。假設算法的問題規模爲n,那麼操作單元數量便用函數f(n)來表示,隨着數據規模n的增大,算法執行時間的增長率和f(n)的增長率相同,這稱作爲算法的漸近時間複雜度,簡稱時間複雜度,記爲 O(f(n))。

大O用來表示上界,就是對任意數據輸入算法的運行時間的上界。數據用例不一樣,時間複雜度也會不同。

面試中算法的時間複雜度指的都是一般情況:

時間複雜度省略常數項係數,是因爲一般情況下都默認數據規模足夠大,基於這樣的事實,給出的算法時間複雜度的一個排行如下所示

O(1)常數階 < O(logn)對數階 < O(n)線性階 < O(nlogn)線性對數階 < O(n^2)平方階 < O(n^3)立方階 < O(2^n)指數階

1.2 例子

題目:找出n個字符串中相同的兩個字符串(假設這裏只有兩個相同的字符串)。

解法一:暴力枚舉,時間複雜度是O(m × n × n)

解法二:先排序再遍歷(兩個相同的字符串挨在一起),總共的時間複雜度是 O(m × n × logn + n × m),簡化後是O(m × n × log n)

很明顯O(m × n × logn) 要優於O(m × n × n)!

2 算法超時

一般OJ超時時間是1s,如果是$O(n)$的算法 ,可估算出n是多大的時候算法會超時 ⇒ 考慮log(n)解法

3 遞歸的時間複雜度

題目:求x的n次方。

解法一

int function1(int x, int n) {
    int result = 1;  // 注意 任何數的0次方等於1
    for (int i = 0; i < n; i++) {
        result = result * x;
    }
    return result;
}

時間複雜度爲O(n)

解法二

int function2(int x, int n) {
    if (n == 0) {
        return 1; // return 1 同樣是因爲0次方是等於1的
    }
    return function2(x, n - 1) * x;
}

遞歸算法的時間複雜度本質上看: 遞歸的次數 * 每次遞歸中的操作次數

時間複雜度爲 n × 1 = O(n)

解法三

int function3(int x, int n) {
    if (n == 0) return 1;
    if (n == 1) return x;
    if (n % 2 == 1) {
        return function3(x, n / 2) * function3(x, n / 2)*x;
    }
    return function3(x, n / 2) * function3(x, n / 2);
}

把遞歸抽象成一棵滿二叉樹,假設n=16:

這棵樹上每一個節點就代表着一次遞歸併進行了一次相乘操作。如果是求x的n次方,這個遞歸樹的節點計算如下圖所示(m爲深度,從0開始):

時間複雜度忽略掉常數項-1之後,這個遞歸算法的時間複雜度依然是O(n)

解法四

int function4(int x, int n) {
    if (n == 0) return 1;
    if (n == 1) return x;
    int t = function4(x, n / 2);// 這裏相對於function3,是把這個遞歸操作抽取出來
    if (n % 2 == 1) {
        return t * t * x;
    }
    return t * t;
}

這裏僅僅有一個遞歸調用,且每次都是n/2 ,所以這裏我們一共調用了$log_2n$次,每次遞歸都做了一次乘法操作,也是一個常數項的操作**,那麼這個遞歸算法的時間複雜度纔是真正的O(logn)**

4 空間複雜度

空間複雜度是對一個算法在運行過程中佔用內存空間大小的量度,記做S(n)=O(f(n)。

!空間複雜度是考慮程序運行時佔用內存的大小,而不是可執行文件的大小

!空間複雜度是預先大體評估程序內存使用的大小,而不是準確算出內存

空間複雜度O(1):

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

隨着n的變化,所需開闢的內存空間並不隨着n的變化而變化,算法空間複雜度爲一個常量。

空間複雜度O(n):

int* a = new int(n);
for (int i = 0; i < n; i++) {
    a[i] = i;
}

定義的數組大小爲n,開闢的內存大小和輸入參數n保持線性增長。

在遞歸的時候,會出現空間複雜度爲logn的情況

遞歸算法的空間複雜度 = 每次遞歸的空間複雜度 * 遞歸深度

5 遞歸算法的性能分析

5.1 求斐波那契數

解法一:

int fibonacci(int i) {
       if(i <= 0) return 0;
       if(i == 1) return 1;
       return fibonacci(i-1) + fibonacci(i-2);
}

一棵深度爲k(按根節點深度爲1)的二叉樹最多可以有 $2^k - 1$ 個節點,該算法時間複雜度爲$O(2^n)$

每次遞歸的空間複雜度是O(1), 調用棧深度爲n,這段代碼的空間複雜度是O(n)

解法二:

int fibonacci(int first, int second, int n) {
    if (n <= 0) {
        return 0;
    }
    if (n < 3) {
        return 1;
    }
    else if (n == 3) {
        return first + second;
    }
    else {
        return fibonacci(second, first + second, n - 1);
    }
}

用first和second來記錄當前相加的兩個數值,不用兩次遞歸;遞歸了n次,時間複雜度是 O(n)

同理遞歸的深度依然是n,每次遞歸所需的空間是常數,空間複雜度是O(n)

解法三(非遞歸):

int fibonacci(int n) {
    if (n <= 0) {
        return 0;
    } else if (n == 1) {
        return 1;
    } else {
        int prev = 0;
        int curr = 1;
        for (int i = 2; i <= n; ++i) {
            int next = prev + curr;
            prev = curr;
            curr = next;
        }
        return curr;
    }
}

5.2 二分查找法

int binary_search( int arr[], int l, int r, int x) {
    if (r >= l) {
        int mid = l + (r - l) / 2;
        if (arr[mid] == x)
            return mid;
        if (arr[mid] > x)
            return binary_search(arr, l, mid - 1, x);
        return binary_search(arr, mid + 1, r, x);
    }
    return -1;
}

時間複雜度是O(logn)

空間複雜度看每次遞歸的空間複雜度和遞歸深度。需要注意在C/C++中函數傳遞數組參數,不是整個數組拷貝一份而是傳入數組首元素地址,每一層遞歸都公用一塊數組地址空間。所以每次遞歸的空間複雜度是常數O(1)。遞歸深度是logn,空間複雜度爲 1 * logn = O(logn)。

❗注意所用語言在傳遞函數參數時,是拷貝整個數值還是拷貝地址。如果是拷貝整個數值,那麼該算法的空間複雜度就是O(nlogn)

6 內存對齊

爲什麼會有內存對齊?

  1. 平臺原因:不是所有的硬件平臺都能訪問任意內存地址上的任意數據,某些硬件平臺只能在某些地址處取某些特定類型的數據,否則拋出硬件異常。爲了同一個程序可以在多平臺運行,需要內存對齊。
  2. 硬件原因:經過內存對齊後,CPU訪問內存的速度大大提升。

CPU讀取內存不是一次讀取單個字節,而是一塊一塊的來讀取內存,塊的大小可以是2,4,8,16個字節,具體取多少個字節取決於硬件。

此時,直接將地址4,5,6,7處的四個字節數據讀取到即可。

此時一共需要兩次尋址,一次合併的操作。

編譯器一般都會做內存對齊的優化操作,當考慮程序真正佔用的內存大小的時候,也需要認識到內存對齊的影響。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章