演算法分析

Hang3發表於2024-10-09

演算法導論

這個文件是學習“演算法設計與分析”課程時做的筆記,文件中包含的內容包括課堂上的一些比較重要的知識、例題以及課後作業的題解。主要的參考資料是 Introduction to algorithms-3rd(Thomas H.)(對應的中文版《演算法導論第三版》),除了這本書,還有的參考資料就是 Algorithms design techniques and analysis (M.H. Alsuwaiyel)。

演算法分析

在設計一個演算法的時候,能夠衡量,或者說至少能夠做出有根據的陳述,演算法的時間和空間複雜度是十分重要的。因為這樣能夠讓我們從對某一個問題的多種可選解決方案中選擇更好的一種,或者確定某種解決方案能夠滿足當前問題下的資源限制的要求。

在衡量時間複雜度的時候,想要預測出絕對執行時間是不現實的。因為演算法執行的時間由很多因素決定,比如實現演算法的程式語言,執行演算法的機器,以及該機器上同時執行的其他的程式等。

因此,我們需要一種機器無關的概念(Machine-independent notion)來衡量演算法的執行時間。

所以當前的演算法分析主要是衡量演算法的相對執行時間,也就是說當某個演算法接收了一個長度為 n 的輸入後,那麼這個演算法的相對執行時間就是達成完成輸出所需要的抽象操作(Abstract Operations)的次數,我們以一個包含 n 的函式來表示這個演算法的相對執行時間。

比如下面的這個例子:

for(int i = 0; i < a.length; i++){
    System.out.println(a[i]);
}

這個演算法接收的輸入的長度為 n = a.length 是一個固定長度的輸入,那麼這個演算法達成輸出所需要的操作次數就為:

  • 1 次變數 i 的初始化
  • n 次 i 與 a.length的比較
  • n 次 i 的增量計算
  • n 次陣列下標計算(to compute a[i])
  • n 次呼叫函式 "System.out.println()" 的操作

所以這個演算法的相對執行時間可以記為:T(n) = 4n + 1

但是在上面的計算中,每個操作所需的時間其實是並不相等的,比如比較操作和函式呼叫操作所需的時間明顯是不同的。而呼叫函式需要的時間也是遠遠大於增量、比較和索引操作需要的時間,所以不妨把這些操作簡化為一個操作,即"比較-索引-列印-增量"操作。那麼這樣的話,這個演算法的時間複雜度就為:T(n) = n + 1。顯然,一次初始化所需的時間並不是那麼重要,所以可以進一步簡化為:T(n) = n。

需要注意的是,這裡之所以能夠將演算法的複雜度簡化為 n ,是因為相較於列印操作,其他操作所花費的時間都是可忽略的。

這裡記錄一下我曾經在程式設計作業(C language)中犯的一個錯誤:假設 a 是一個字串,其長度記為 n,現在要列印出這個字串中的每一個字元。

for(i = 0; i < strlen(a); i++)
 printf("%c ", a[i]);

那麼這種情況下,演算法的時間複雜度就應該是T(n) = 2n,因為在比較操作中呼叫了函式"strlen()",這個函式會遍歷整個字串直到遇到 '\0' 才能計算出字串的長度,所以每次比較操作都需要 n 次陣列索引操作和比較操作。

所以這個時候的演算法的時間複雜度為:T(n) = \(n^2 + n\)

這裡的時間複雜度T(n) = n,有時候也可以記為\(\lambda n.n\),即大小為n的輸入,需要進行n次操作。

這裡再介紹一個比較有趣的例子,計算兩個方陣相乘(Multiply two square matrices)的結果:

給定兩個方陣A, B,這兩個方陣以二維陣列的形式儲存即 a[n][n], b[n][n],然後輸出一個新的矩陣c[n][n]。

那麼根據矩陣相乘的規則,可以得到下面的演算法來計算兩個矩陣相乘的結果:

for(int i = 0; i < n; i++)
{
    for(int j = 0; j < n; j++)
    {
        double sum = 0;
        for(int k = 0; k < n; k++)
        {
            sum += a[i][k] * b[k][j];
        }
        c[i][j] = sum;
    }
}

從裡向外看:

在k-loop中,一共有1次初始化以及n次迴圈,每次迴圈中有1次比較,1次遞增,4次陣列索引,1次乘法,1次加法以及1次賦值;

在j-loop中,有1次初始化,n次比較,n次遞增,n次初始化,n次k-loop,n次賦值;

那麼這是否意味著:T(j-loop) = n * (T(k-loop) + 4) + 1?

其實不然,因為相對於k-loop,其他的操作所花費的時間並不重要,所以透過類似的觀點,我們可以忽略所有簡單的for迴圈中的初始化、測試和遞增操作。而在這個例子中,我們只需要考慮 sum += a[i][k] * b[k][j] 操作的次數,因為相比於其他操作,這個操作所需要的時間明顯使更多的。

所以這個演算法的時間複雜度就是:T(n) = \(n^3\)

但是,這裡的 n 為輸入的寬度,而不是輸入的規模,真正的輸入規模應該是\(2n^2\)

如果以輸入規模來考慮這個演算法的時間複雜度的話,那麼得到的結果就與前面以輸入的寬度來考慮的時間複雜度結果不一樣了。

所以我們令 N = \(2n^2\),那麼T(N) = \(N^{1.5}\)

因此,使用不同的方法來衡量輸入的大小,也會得到不同的結果。

再介紹一個例子,找到陣列中某一個元素的位置:

for(int i = 0; i < a.length; i++)
{
    if(a[i] == x)
    {
        return Optional.of(i);
    }
}
return Optional.empty();

那麼這種場景下就需要分情況討論了,比如最好的情況下,x = a[0],那麼B(n) = 1,最壞的情況下,x不在陣列a中,那麼W(n) = n。

那平均的時間複雜度是多少呢?這個其實就很難判斷的,因為想要知道這個演算法的平均時間複雜度,那麼首先需要知道這些元素的期望分佈。

到目前為止,我們可以分析一個演算法的時間複雜度,將該演算法的時間複雜度表示成一個與輸入規模 n 相關的函式,但是如何透過比較時間複雜度函式來比較哪個演算法更加優秀呢?

所以,我們可以將演算法的時間複雜度按照其"增長率"對其進行分類,比如:

  • 常數增長:時間複雜度是一個常量,即無論輸入的規模如何,演算法都只需要進行固定次數的操作,比如\(\lambda n.1\)
  • 對數增長:時間複雜度是一個對數函式,即需要的操作次數與輸入規模的對數成比例,比如\(\lambda n.\log n\)
  • 多項式增長:時間複雜度是一個多項式,即需要的操作次數是輸入規模的一個多項式,比如\(\lambda n.n^k, k\ge 1\)
  • 指數增長:時間複雜度是一個指數函式,即需要的操作次數是輸入規模的指數,比如\(\lambda n.c^n,c>1\)

所以,一般是透過演算法時間複雜度的增長率來比較不同演算法的效能的,但是函式的增長率依然的表示依然還是一個以 n 為變數函式,所以為了更方便比較不同函式的增長率通常會使用漸進的方法進行比較,比如一個函式可能會收斂於某個上界或者下界,然後我們就可以透過該函式收斂的上界與下界來比較不同的函式。

When we look at input sizes large enough to make only the order of growth of the running time relevant, we are studying the asymptotic efficiency of algorithms. That is, we are concerned with how the running time of an algorithm increases with the size of the input in the limit, as the size of the input increases without bound. Usually, an algorithm that is asymptotically more efficient will be the best choice for all but very small inputs.

-- Introduction to algorithms

如果對時間複雜度的增長率進行了衡量,那麼我們通常會忽略掉

  1. all but the "largest" term, and
  2. any constant multipliers

比如:\(\lambda n.0.4n^5 + 3n^3 + 254\) 的漸進表示就為 \(n^5\),只保留的最大的一項,並且忽略掉所有的常數倍數。

之所以會進行這樣的忽略是因為下面的原因:

  • 6n 和 3n 之間的差距其實是沒有意義的,因為在一個執行速度是兩倍的計算機上,可以讓時間複雜度為 3n 的演算法所花費的時間和在一個普通執行速度的計算機上的執行的時間複雜度為 6n 的演算法所花費的時間一樣;
  • 2n 和 2n + 8之間的差距也是可忽略的,因為 n 可能會變得越來越大;
  • 如果比較 \(\lambda n.n^3\)\(\lambda n.kn^2\)的增長率,那麼無論 k 取多大,總會存在一個N,使得\(\forall n > N, n^3 > kn^2\)

那麼我們應該如何對時間複雜度的漸進表示進行形式化的表達?也就是如何能夠透過一種形式化的方法找到一個時間複雜度表示式的漸進表示,比如\(T(n) \in\) {the set of all quadratic functions}

Big-O: 漸進上界

Def. 稱一個函式 f 屬於 O(g) ,即\(f \in O(g)\),當且僅當,存在常數 cN 能夠滿足 \(\forall n \gt N, f(n)\)的上界為\(g(n)\)的常數倍,即:

\[O(g) =_{def} \{f|\exists c,N. \forall n\gt N. |f(n)| \le c|g(n)|\} \]

比如:\(\lambda n.0.4n^5 + 3n^3 + 253\in O(\lambda n.n^5)\)

為了簡化,一般會丟棄Big-O中的lambda, 比如上面的這個例子就可以寫成\(O(n^5)\)

漸進上限能夠讓我們丟棄掉時間複雜度表示式中的較小的項以及常數因子。

比如:證明,\(7n^4 + 2n^2+n+20\in O(n^4)\)

\[since\space n\ge 1\\ \begin{align} |7n^4 + 2n^2 + n + 20| &\le 7n^4 + 2n^2 + n + 20\\ &\le 7n^4 + 2n^4 + n^4 + 20n^4\\ &\le 30n^4 \end{align} \]

所以我們可以取常數 \(c=30, N=1, \forall n \gt N,7n^4 + 20n^2 +n + 20 \lt c*n^4\)

當輸入的規模 n 比較大的時候,Big-O漸進上限是比較有用的。

但是對於比較小的輸入規模 n,Big-O中捨棄低階項和常數因子的方法就存在誤導性,比如下面的函式:

\(0.00001n^5 + 10^6 n^2\)

n 比較小的時候如果將上面的函式的漸進表達為\(O(n^5)\) 就不太恰當。

如果比較下面兩個時間複雜度:

\(8n\log n\) and \(0.01n^2\)

會發現如果 n < 10701,那麼前者是比後者更大的。

還需要注意的是,Big-O只是上限,而不是嚴格的上限。如果說一個函式屬於\(O(n^2)\),那麼這個函式也屬於\(O(n^3)\), \(O(n^{100})\)或者\(O(2^n)\)

Big-\(\Omega\): 漸進下界

Def. 稱一個函式 f 屬於 \(\Omega(g)\) ,即\(f\in \Omega(g)\),當且僅當,存在常數 cN 能夠滿足 \(\forall n \gt N, f(n)\)的下界為\(g(n)\)的常數倍,即:

\[\Omega(g) =_{def} \{f|\exists c,N. \forall n\gt N. |f(n)| \ge c|g(n)|\} \]

漸進下界是十分有用的,因為漸進下界能夠表示這個演算法的執行至少需要這麼多的時間

比如前面的列印陣列的例子中,我們可以說這個演算法的時間複雜度屬於\(O(2^n)\),因為\(2^n\)確實是這個演算法時間複雜度的一個上限,但是不是一個嚴格的上限。但是我們也可以說這個演算法的時間複雜度屬於\(\Omega(n)\),因為這個演算法至少需要線性的時間來執行,這樣更加準確。

當然,任何演算法的時間複雜度都屬於\(\Omega(1)\)

Big-\(\Theta\):緊漸進界

Def.如果一個函式的漸進上界和漸進下界是相等的,那麼就可以使用Big-\(\Theta\) 的概念:

\[\Theta(g) =_{def} \{f|\exists c1,c2, N.\forall n \gt N.c1|g(n)| \le |f(n)| \le c2|g(n)| \} \]

三種漸進分析的圖形化表示如下圖:

asymptotic_analysis

因此,我們就可以說列印陣列的演算法的時間複雜度屬於\(\Theta(n)\)

不是所有演算法的時間複雜度都可以使用Big-\(\Theta\) 來表示,比如\(\lambda n.n^2\cos n\)就不能使用Big-$\Theta $表示,因為這個時間複雜度沒有漸進下界。

相關文章