演算法複雜度分析

陌客&發表於2021-09-19

為什麼要進行演算法分析?

  • 預測演算法所需的資源
    • 計算時間(CPU 消耗)
    • 記憶體空間(RAM 消耗)
    • 通訊時間(頻寬消耗)
  • 預測演算法的執行時間
    • 在給定輸入規模時,所執行的基本運算元量
    • 或者稱為演算法複雜度(Algorithm Complexity)

如何衡量演算法複雜度?

  • 記憶體(Memory)
  • 時間(Time)
  • 指令的數量(Number of Steps)
  • 特定操作的數量
    • 磁碟訪問數量
    • 網路包數量
  • 漸進複雜度(Asymptotic Complexity)

演算法的執行時間與什麼相關?

  • 取決於輸入的資料。(例如:如果資料已經是排好序的,時間消耗可能會減少。)
  • 取決於輸入資料的規模。(例如:6 和 6 * 109
  • 取決於執行時間的上限。(因為執行時間的上限是對使用者的承諾。)

演算法分析的種類:

  • 最壞情況(Worst Case):任意輸入規模的最大執行時間。(Usually)
  • 平均情況(Average Case):任意輸入規模的期待執行時間。(Sometimes)
  • 最佳情況(Best Case):通常最佳情況不會出現。(Bogus)

例如,在一個長度為 n 的列表中順序搜尋指定的值,則

  • 最壞情況:n 次比較
  • 平均情況:n/2 次比較
  • 最佳情況:1 次比較

而實際中,我們一般僅考量演算法在最壞情況下的執行情況,也就是對於規模為 n 的任何輸入,演算法的最長執行時間。這樣做的理由是:

  1. 一個演算法的最壞情況執行時間是在任何輸入下執行時間的一個上界(Upper Bound)。
  2. 對於某些演算法,最壞情況出現的較為頻繁。
  3. 大體上看,平均情況通常與最壞情況一樣差。

演算法分析要保持大局觀(Big Idea),其基本思路:

  1. 忽略掉那些依賴於機器的常量。
  2. 關注執行時間的增長趨勢。

比如:T(n) = 73n3+ 29n3 + 8888 的趨勢就相當於 T(n) = Θ(n3)。

漸近記號(Asymptotic Notation)通常有 O、 Θ 和 Ω 記號法。Θ 記號漸進地給出了一個函式的上界和下界,當只有漸近上界時使用 O 記號,當只有漸近下界時使用 Ω 記號。儘管技術上 Θ 記號較為準確,但通常仍然使用 O 記號表示。

使用 O 記號法(Big O Notation)表示最壞執行情況的上界。例如,

  • 線性複雜度 O(n) 表示每個元素都要被處理一次。
  • 平方複雜度 O(n2) 表示每個元素都要被處理 n 次。

例如:

  • T(n) = O(n3) 等同於 T(n) ∈ O(n3)
  • T(n) = Θ(n3) 等同於 T(n) ∈ Θ(n3).

相當於:

  • T(n) 的漸近增長不快於 n3
  • T(n) 的漸近增長與 n3 一樣快。

注1:快速的數學回憶,logab = y 其實就是 ay = b。所以,log24 = 2,因為 22 = 4。同樣 log28 = 3,因為 23 = 8。我們說,log2n 的增長速度要慢於 n,因為當 n = 8 時,log2n = 3。

注2:通常將以 10 為底的對數叫做常用對數。為了簡便,N 的常用對數 log10 N 簡寫做 lg N,例如 log10 5 記做 lg 5。

注3:通常將以無理數 e 為底的對數叫做自然對數。為了方便,N 的自然對數 loge N 簡寫做 ln N,例如 loge 3 記做 ln 3。

注4:在演算法導論中,採用記號 lg n = log2 n ,也就是以 2 為底的對數。改變一個對數的底只是把對數的值改變了一個常數倍,所以當不在意這些常數因子時,我們將經常採用 “lg n”記號,就像使用 O 記號一樣。計算機工作者常常認為對數的底取 2 最自然,因為很多演算法和資料結構都涉及到對問題進行二分。

而通常時間複雜度與執行時間有一些常見的比例關係:

計算程式碼塊的漸進執行時間的方法有如下步驟:

  1. 確定決定演算法執行時間的組成步驟。
  2. 找到執行該步驟的程式碼,標記為 1。
  3. 檢視標記為 1 的程式碼的下一行程式碼。如果下一行程式碼是一個迴圈,則將標記 1 修改為 1 倍於迴圈的次數 1 * n。如果包含多個巢狀的迴圈,則將繼續計算倍數,例如 1 * n * m。
  4. 找到標記到的最大的值,就是執行時間的最大值,即演算法複雜度描述的上界。

示例程式碼(1):

decimal Factorial(int n)
    {
      if (n == 0)
        return 1;
      else
        return n * Factorial(n - 1);
    }

階乘(factorial),給定規模 n,演算法基本步驟執行的數量為 n,所以演算法複雜度為 O(n)。

示例程式碼(2):

int FindMaxElement(int[] array)
    {
      int max = array[0];
      for (int i = 0; i < array.Length; i++)
      {
        if (array[i] > max)
        {
          max = array[i];
        }
      }
      return max;
    }

這裡,n 為陣列 array 的大小,則最壞情況下需要比較 n 次以得到最大值,所以演算法複雜度為 O(n)。

示例程式碼(3):

long FindInversions(int[] array)
    {
      long inversions = 0;
      for (int i = 0; i < array.Length; i++)
        for (int j = i + 1; j < array.Length; j++)
          if (array[i] > array[j])
            inversions++;
      return inversions;
    }

這裡,n 為陣列 array 的大小,則基本步驟的執行數量約為 n*(n-1)/2,所以演算法複雜度為 O(n2)。

示例程式碼(4):

long SumMN(int n, int m)
    {
      long sum = 0;
      for (int x = 0; x < n; x++)
        for (int y = 0; y < m; y++)
          sum += x * y;
      return sum;
    }

給定規模 n 和 m,則基本步驟的執行數量為 n*m,所以演算法複雜度為 O(n2)。

示例程式碼(5):

decimal Sum3(int n)
    {
      decimal sum = 0;
      for (int a = 0; a < n; a++)
        for (int b = 0; b < n; b++)
          for (int c = 0; c < n; c++)
            sum += a * b * c;
      return sum;
    }

這裡,給定規模 n,則基本步驟的執行數量約為 n*n*n ,所以演算法複雜度為 O(n3)。

示例程式碼(6)

decimal Calculation(int n)
    {
      decimal result = 0;
      for (int i = 0; i < (1 << n); i++)
        result += i;
      return result;
    }

這裡,給定規模 n,則基本步驟的執行數量為 2n,所以演算法複雜度為 O(2n)。

示例程式碼(7):

斐波那契數列:

  • Fib(0) = 0
  • Fib(1) = 1
  • Fib(n) = Fib(n-1) + Fib(n-2)

F() = 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 …

int Fibonacci(int n)
    {
      if (n <= 1)
        return n;
      else
        return Fibonacci(n - 1) + Fibonacci(n - 2);
    }

這裡,給定規模 n,計算 Fib(n) 所需的時間為計算 Fib(n-1) 的時間和計算 Fib(n-2) 的時間的和。

T(n<=1) = O(1)

T(n) = T(n-1) + T(n-2) + O(1)

                     fib(5)   
                 /             \     
           fib(4)                fib(3)   
         /      \                /     \
     fib(3)      fib(2)         fib(2)    fib(1)
    /     \        /    \       /    \  

通過使用遞迴樹的結構描述可知演算法複雜度為 O(22)。

示例程式碼(8):

int Fibonacci(int n)
    {
      if (n <= 1)
        return n;
      else
      {
        int[] f = new int[n + 1];
        f[0] = 0;
        f[1] = 1;

        for (int i = 2; i <= n; i++)
        {
          f[i] = f[i - 1] + f[i - 2];
        }

        return f[n];
      }
    }

同樣是斐波那契數列,我們使用陣列 f 來儲存計算結果,這樣演算法複雜度優化為 O(n)。

示例程式碼(9):

int Fibonacci(int n)
    {
      if (n <= 1)
        return n;
      else
      {
        int iter1 = 0;
        int iter2 = 1;
        int f = 0;

        for (int i = 2; i <= n; i++)
        {
          f = iter1 + iter2;
          iter1 = iter2;
          iter2 = f;
        }

        return f;
      }
    }

同樣是斐波那契數列,由於實際只有前兩個計算結果有用,我們可以使用中間變數來儲存,這樣就不用建立陣列以節省空間。同樣演算法複雜度優化為 O(n)。

示例程式碼(10):

通過使用矩陣乘方的演算法來優化斐波那契數列演算法。

static int Fibonacci(int n)
    {
      if (n <= 1)
        return n;

      int[,] f = { { 1, 1 }, { 1, 0 } };
      Power(f, n - 1);

      return f[0, 0];
    }

    static void Power(int[,] f, int n)
    {
      if (n <= 1)
        return;

      int[,] m = { { 1, 1 }, { 1, 0 } };

      Power(f, n / 2);
      Multiply(f, f);

      if (n % 2 != 0)
        Multiply(f, m);
    }

    static void Multiply(int[,] f, int[,] m)
    {
      int x = f[0, 0] * m[0, 0] + f[0, 1] * m[1, 0];
      int y = f[0, 0] * m[0, 1] + f[0, 1] * m[1, 1];
      int z = f[1, 0] * m[0, 0] + f[1, 1] * m[1, 0];
      int w = f[1, 0] * m[0, 1] + f[1, 1] * m[1, 1];

      f[0, 0] = x;
      f[0, 1] = y;
      f[1, 0] = z;
      f[1, 1] = w;
    }

優化之後演算法複雜度為O(log2n)。

示例程式碼(11):

在 C# 中更簡潔的程式碼如下。

static double Fibonacci(int n)
    {
      double sqrt5 = Math.Sqrt(5);
      double phi = (1 + sqrt5) / 2.0;
      double fn = (Math.Pow(phi, n) - Math.Pow(1 - phi, n)) / sqrt5;
      return fn;
    }

示例程式碼(12):

插入排序的基本操作就是將一個資料插入到已經排好序的有序資料中,從而得到一個新的有序資料。演算法適用於少量資料的排序,時間複雜度為 O(n2)。

private static void InsertionSortInPlace(int[] unsorted)
    {
      for (int i = 1; i < unsorted.Length; i++)
      {
        if (unsorted[i - 1] > unsorted[i])
        {
          int key = unsorted[i];
          int j = i;
          while (j > 0 && unsorted[j - 1] > key)
          {
            unsorted[j] = unsorted[j - 1];
            j--;
          }
          unsorted[j] = key;
        }
      }
    }

轉載自:演算法複雜度分析

相關文章