演算法分析的正確姿勢

absfree發表於2016-05-08

一、前言

在進一步學習資料結構與演算法前,我們應該先掌握演算法分析的一般方法。演算法分析主要包括對演算法的時空複雜度進行分析,但有些時候我們更關心演算法的實際執行效能如何,此外,演算法視覺化是一項幫助我們理解演算法實際執行過程的實用技能,在分析一些比較抽象的演算法時,這項技能尤為實用。在在本篇博文中,我們首先會介紹如何通過設計實驗來量化演算法的實際執行效能,然後會介紹演算法的時間複雜度的分析方法,我們還會介紹能夠非常便捷的預測演算法效能的倍率實驗。當然,在文章的末尾,我們會一起來做幾道一線網際網路的相關面試/筆試題來鞏固所學,達到學以致用。

二、演算法分析的一般方法

1. 量化演算法的實際執行效能

在介紹演算法的時空複雜度分析方法前,我們先來介紹以下如何來量化演算法的實際執行效能,這裡我們選取的衡量演算法效能的量化指標是它的實際執行時間。通常這個執行時間與演算法要解決的問題規模相關,比如排序100萬個數的時間通常要比排序10萬個數的時間要長。所以我們在觀察演算法的執行時間時,還要同時考慮它所解決問題的規模,觀察隨著問題規模的增長,演算法的實際執行時間時怎樣增長的。這裡我們採用演算法(第4版) (豆瓣)一書中的例子,程式碼如下:

public class ThreeSum {
    public static int count(int[] a) {
        int N = a.length;
        int cnt = 0;
        for (int i = 0; i < N; i++) {
            for (int j = i + 1; j < N; j++) {
                for (int k = j + 1; k < N; k++) {
                    if (a[i] + a[j] + a[k] == 0) {
                        cnt++;
                    }
                }
            }
        }
        return cnt;
    }

    public static void main(String[] args) {
        int[] a = StdIn.readAllInts();
        StdOut.println(count(a));
    }
}

以上程式碼用到的StdIn和StdOut這兩個類都在這裡:https://github.com/absfree/Algo。我們可以看到,以上程式碼的功能是統計標準一個int[]陣列中的所有和為0的三整數元組的數量。採用的演算法十分直接,就是從頭開始遍歷陣列,每次取三個數,若和為0,則計數加一,最後返回的計數值即為和為0的三元組的數量。這裡我們採取含有整數數量分別為1000、2000、4000的3個檔案(這些檔案可以在上面的專案地址中找到),來對以上演算法進行測試,觀察它的執行時間隨著問題規模的增長是怎樣變化的。

測量一個過程的執行時間的一個直接的方法就是,在這個過程執行前後各獲取一次當前時間,兩者的差值即為這個過程的執行時間。當我們的過程本身需要的執行時間很短時間,這個測量方法可能會存在一些誤差,但是我們可以通過執行多次這個過程再取平均數來減小以至可以忽略這個誤差。下面我們來實際測量一下以上演算法的執行時間,相關程式碼如下:

    public static void main(String[] args) {
        int[] a = In.readInts(args[0]);
        long startTime = System.currentTimeMillis();
        int count = count(a);
        long endTime = System.currentTimeMillis();
        double time = (endTime - startTime) / 1000.0;
        StdOut.println("The result is: " + count + ", and takes " + time + " seconds.");
    }

我們分別以1000、2000、4000個整數作為輸入,得到的執行結果如下

The result is: 70, and takes 1.017 seconds. //1000個整數

The result is: 528, and takes 7.894 seconds. //2000個整數

The result is: 4039, and takes 64.348 seconds. //4000個整數

我們從以上結果大概可你看到,當問題的規模變為原來的2倍時,實際執行時間大約變為原來的8倍。根據這個現象我們可以做出一個猜想:程式的執行時間關於問題規模N的函式關係式為T(N) = k*(n^3).

在這個關係式中,當n變為原來的2倍時,T(N)會變為原來的8倍。那麼ThreeSum演算法的執行時間與問題規模是否滿足以上的函式關係呢?在介紹演算法時間複雜度的相關內容後,我們會回過頭來再看這個問題。

2. 演算法的時間複雜度分析

(1)基本概念

關於演算法的時間複雜度,這裡我們先簡單介紹下相關的三種符號記法:

  • 第一種叫Big O notation,它給出了執行時間的”漸進上界“,也就是演算法在最壞情況下執行時間的上限。它的定義如下:對於f(n)和g(n),若存在常數c和N0,使得對於所有n > N0,都有 |f(n)| < c * g(n),則稱f(n)為O(g(n)。
  • 第三種叫做Big Ω notation,它給出了執行時間的“漸進下界”,也就是演算法在最壞情況下執行時間的下限。它的定義如下:對於f(n)和g(n),若存在常數c和N0,使得對於所有n > N0,都有|f(n)| > c * g(n),則稱f(n)為Ω(g(n))。
  • 第三種叫Big Θ notation,它確定了執行時間的”漸進確界“。定義如下:對於f(n)和g(n),若存在常數c和N0,對於所有n> N0,都有|f(n)| = c * g(n),則稱f(n)為Θ為Θ(g(n))。

我們在平常的演算法分析中最常用到的是Big O notation。下面我們將介紹分析演算法的時間複雜度的具體方法,若對Big O notation的概念還不是很瞭解,推薦大家看這篇文章:http://blog.jobbole.com/55184/。

(2)時間複雜度的分析方法

這部分我們將以上面的ThreeSum程式為例,來介紹一下演算法時間複雜度的分析方法。為了方便閱讀,這裡再貼一下上面的程式:

public static int count(int[] a) {
        int N = a.length;
        int cnt = 0;
        for (int i = 0; i < N; i++) {
            for (int j = i + 1; j < N; j++) {
                for (int k = j + 1; k < N; k++) {
                    if (a[i] + a[j] + a[k] == 0) {
                        cnt++;
                    }
                }
            }
        }
        return cnt;
    }

在介紹時間複雜度分析方法前,我們首先來明確下演算法的執行時間究竟取決於什麼。直觀地想,一個演算法的執行時間也就是執行所有程式語句的耗時總和。然而在實際的分析中,我們並不需要考慮所有程式語句的執行時間,我們應該做的是集中注意力於最耗時的部分,也就是執行頻率最高而且最耗時的操作。也就是說,在對一個程式的時間複雜度進行分析前,我們要先確定這個程式中哪些語句的執行佔用的它的大部分執行時間,而那些儘管耗時大但只執行常數次(和問題規模無關)的操作我們可以忽略。我們選出一個最耗時的操作,通過計算這些操作的執行次數來估計演算法的時間複雜度,下面我們來具體介紹這一過程。

首先我們看到以上程式碼的第1行和第2行的語句只會執行一次,因此我們可以忽略它們。然後我們看到第4行到第12行是一個三層迴圈,最記憶體的迴圈體包含了一個if語句。也就是說,這個if語句是以上程式碼中耗時最多的語句,我們接下來只需要計算if語句的執行次數即可估計出這個演算法的時間複雜度。以上演算法中,我們的問題規模為N(輸入陣列包含的元素數目),我們也可以看到,if語句的執行次數與N是相關的。我們不難得出,if語句會執行N * (N – 1) * (N – 2) / 6次,因此這個演算法的時間複雜度為O(n^3)。這也印證了我們之前猜想的執行時間與問題規模的函式關係(T(n) = k * n ^ 3)。由此我們也可以知道,演算法的時間複雜度刻畫的是隨著問題規模的增長,演算法的執行時間的增長速度是怎樣的。在平常的使用中,Big O notation通常都不是嚴格表示最壞情況下演算法的執行時間上限,而是用來表示通常情況下演算法的漸進效能的上限,在使用Big O notation描述演算法最壞情況下執行時間的上限時,我們通常加上限定詞“最壞情況“。

通過以上分析,我們知道分析演算法的時間複雜度只需要兩步(比把大象放進冰箱還少一步:) ):

  • 尋找執行次數多的語句作為決定執行時間的[關鍵操作];
  • 分析關鍵操作的執行次數。

在以上的例子中我們可以看到,不論我們輸入的整型陣列是怎樣的,if語句的執行次數是不變的,也就是說上面演算法的執行時間與輸入無關。而有些演算法的實際執行時間高度依賴於我們給定的輸入,關於這一問題下面我們進行介紹。

3. 演算法的期望執行時間

演算法的期望執行時間我們可以理解為,在通常情況下,演算法的執行時間是多少。在很多時候,我們更關心演算法的期望執行時間而不是演算法在最壞情況下執行時間的上限,因為最壞情況和最好情況發生的概率是比較低的,我們更常遇到的是一般情況。比如說盡管快速排序演算法與歸併排序演算法的時間複雜度都為O(nlogn),但是在相同的問題規模下,快速排序往往要比歸併排序快,因此快速排序演算法的期望執行時間要比歸併排序的期望時間小。然而在最壞情況下,快速排序的時間複雜度會變為O(n^2),快速排序演算法就是一個執行時間依賴於輸入的演算法,對於這個問題,我們可以通過打亂輸入的待排序陣列的順序來避免發生最壞情況。

4. 倍率實驗

下面我們來介紹一下演算法(第4版) (豆瓣)一書中的“倍率實驗”。這個方法能夠簡單有效地預測程式的效能並判斷他們的執行時間大致的增長數量級。在正式介紹倍率實驗前,我們先來簡單介紹下“增長數量級“這一概念(同樣引用自《演算法》一書):

我們用~f(N)表示所有隨著N的增大除以f(N)的結果趨於1的函式。用g(N)~f(N)表示g(N) / f(N)隨著N的增大趨近於1。通常我們用到的近似方式都是g(N) ~ a * f(N)。我們將f(N)稱為g(N)的增長數量級。

我們還是拿ThreeSum程式來舉例,假設g(N)表示在輸入陣列尺寸為N時執行if語句的次數。根據以上的定義,我們就可以得到g(N) ~ N ^ 3(當N趨向於正無窮時,g(N) / N^3 趨近於1)。所以g(N)的增長數量級為N^3,即ThreeSum演算法的執行時間的增長數量級為N^3。

現在,我們來正式介紹倍率實驗(以下內容主要引用自上面提到的《演算法》一書,同時結合了一些個人理解)。首先我們來一個熱身的小程式:

public class DoublingTest {
    public static double timeTrial(int N) {
        int MAX = 1000000;
        int[] a = new int[N];
        for (int i = 0; i < N; i++) {
            a[i] = StdRandom.uniform(-MAX, MAX);
        }
        long startTime = System.currentTimeMillis();
        int count = ThreeSum.count(a);
        long endTime = System.currentTimeMillis();
        double time =  (endTime - startTime) / 1000.0;
        return time;
    }

    public static void main(String[] args) {
        for (int N = 250; true; N += N) {
            double time = timeTrial(N);
            StdOut.printf("%7d %5.1f\n", N, time);
        }
    }
}

以上程式碼會以250為起點,每次講ThreeSum的問題規模翻一倍,並在每次執行ThreeSum後輸出本次問題規模和對應的執行時間。執行以上程式得到的輸出如下所示:

250 0.0
500 0.1
1000 0.6
2000 4.3
4000 30.6

上面的輸出之所以和理論值有所出入是因為實際執行環境是複雜多變的,因而會產生許多偏差,儘可能減小這種偏差的方式就是多次執行以上程式並取平均值。有了上面這個熱身的小程式做鋪墊,接下來我們就可以正式介紹這個“可以簡單有效地預測任意程式執行效能並判斷其執行時間的大致增長數量級”的方法了,實際上它的工作基於以上的DoublingTest程式,大致過程如下:

  • 開發一個[輸入生成器]來產生實際情況下的各種可能的輸入。
  • 反覆執行下面的DoublingRatio程式,直至time/prev的值趨近於極限2^b,則該演算法的增長數量級約為N^b(b為常數)。

DoublingRatio程式如下:

執行倍率程式,我們可以得到如下輸出:

250 0.0 2.0
500 0.1 5.5
1000 0.5 5.4
2000 3.7 7.0
4000 27.4 7.4
8000 218.0 8.0

我們可以看到,time/prev確實收斂到了8(2^3)。那麼,為什麼通過使輸入不斷翻倍而反覆執行程式,執行時間的比例會趨於一個常數呢?答案是下面的[倍率定理]:

若T(N) ~ a * N^b * lgN,那麼T(2N) / T(N) ~2^b。

以上定理的證明很簡單,只需要計算T(2N) / T(N)在N趨向於正無窮時的極限即可。其中,“a * N^b * lgN”基本上涵蓋了常見演算法的增長量級(a、b為常數)。值得我們注意的是,當一個演算法的增長量級為NlogN時,對它進行倍率測試,我們會得到它的執行時間的增長數量級約為N。實際上,這並不矛盾,因為我們並不能根據倍率實驗的結果推測出演算法符合某個特定的數學模型,我們只能夠大致預測相應演算法的效能(當N在16000到32000之間時,14N與NlgN十分接近)。

5. 均攤分析

考慮下我們之前在 深入理解資料結構之連結串列 中提到的ResizingArrayStack,也就是底層用陣列實現的支援動態調整大小的棧。每次新增一個元素到棧中後,我們都會判斷當前元素是否填滿的陣列,若是填滿了,則建立一個尺寸為原來兩倍的新陣列,並把所有元素從原陣列複製到新陣列中。我們知道,在陣列未填滿的情況下,push操作的複雜度為O(1),而當一個push操作使得陣列被填滿,建立新陣列及複製這一工作會使得push操作的複雜度驟然上升到O(n)。

對於上面那種情況,我們顯然不能說push的複雜度是O(n),我們通常認為push的“平均複雜度”為O(1),因為畢竟每n個push操作才會觸發一次“複製元素到新陣列”,因而這n個push把這一代價一均攤,對於這一系列push中的每個來說,它們的均攤代價就是O(1)。這種記錄所有操作的總成本併除以操作總數來講成本均攤的方法叫做均攤分析(也叫攤還分析)。

三、小試牛刀之實戰名企面試題

前面我們介紹了演算法分析的一些姿勢,那麼現在我們就來學以致用,一起來解決幾道一線網際網路企業有關於演算法分析的面試/筆試題。

【騰訊】下面演算法的時間複雜度是____

int foo(int n) {

if (n <= 1) {

return 1;

}

return n * foo(n – 1);

}

看到這道題要我們分析演算法時間複雜度後,我們要做的第一步便是確定關鍵操作,這裡的關鍵操作顯然是if語句,那麼我們只需要判斷if語句執行的次數即可。首先我們看到這是一個遞迴過程:foo會不斷的呼叫自身,直到foo的實參小於等於1,foo就會返回1,之後便不會再執行if語句了。由此我們可以知道,if語句呼叫的次數為n次,所以時間複雜度為O(n)。

【京東】以下函式的時間複雜度為____

void recursive(int n, int m, int o) {

if (n <= 0) {

printf(“%d, %d\n”, m, o);

} else {

recursive(n – 1, m + 1, o);

recursive(n – 1, m, o + 1);

}

}

這道題明顯要比上道題難一些,那麼讓我們來按部就班的解決它。首先,它的關鍵操作時if語句,因此我們只需判斷出if語句的執行次數即可。以上函式會在n > 0的時候不斷遞迴呼叫自身,我們要做的是判斷在到達遞迴的base case(即n <= 0)前,共執行了多少次if語句。我們假設if語句的執行次數為T(n, m, o),那麼我們可以進一步得到:T(n, m, o) = T(n-1, m+1, o) + T(n-1, m, o+1) (當n > 0時)。我們可以看到base case與引數m, o無關,因此我們可以把以上表示式進一步簡化為T(n) = 2T(n-1),由此我們可得T(n) = 2T(n-1) = (2^2) * T(n-2)……所以我們可以得到以上演算法的時間複雜度為O(2^n)。

【京東】如下程式的時間複雜度為____(其中m > 1,e > 0)

x = m;

y = 1;

while (x – y > e) {

x = (x + y) / 2;

y = m / x;

}

print(x);

以上演算法的關鍵操作即while語句中的兩條賦值語句,我們只需要計算這兩條語句的執行次數即可。我們可以看到,當x – y > e時,while語句體內的語句就會執行,x = (x + y) / 2使得x不斷變小(當y<<x時,執行一次這個語句會使x變為約原來的一半),假定y的值固定在1,那麼迴圈體的執行次數即為~logm,而實際情況是y在每次迴圈體最後都會被賦值為m / x,這個值總是比y在上一輪迴圈中的值大,這樣一來x-y的值就會更小,所以以上演算法的時間複雜度為O(logm)。

【搜狗】假設某演算法的計算時間可用遞推關係式T(n) = 2T(n/2) + n,T(1) = 1表示,則該演算法的時間複雜度為____

根據題目給的遞推關係式,我們可以進一步得到:T(n) = 2(2T(n/4) + n/2) + n = … 將遞推式進一步展開,我們可以得到該演算法的時間複雜度為O(nlogn),這裡就不貼上詳細過程了。

相關文章