1. 程式人生 > 實用技巧 >算法系列之複雜度

算法系列之複雜度

認識複雜度

評估演算法優劣的核心指標是什麼?

  • 時間複雜度(流程決定)
  • 額外空間複雜度(流程決定)
  • 常數項時間(實現細節決定)

時間複雜度

何為常數時間的操作?

如果一個操作的執行時間不以具體樣本量為轉移,每次執行時間都是固定時間。稱這樣的操作為常數時間操作。

常見的常數時間的操作

  • 常見的算術運算(+、-、*、/、%等)
  • 常見的位運算(>>、>>>、<<、|、&、^等)
  • 賦值、比較、自增、自減操作等
  • 陣列定址操作

總之,執行時間固定的操作,都是常數時間的操作。

反之,執行事件不固定的操作,都不是常數時間的操作。

如何確定演算法流程的總運算元量與樣本數量之間的表示式關係?

  1. 想象該演算法流程所處理的資料狀況,要按照最差情況來。
  2. 把整個流程徹底拆分為一個個基本動作,保證每個動作都是常數時間的操作。
  3. 如果資料量為N,看看基本動作的數量和N是什麼關係。

如何確定演算法流程的時間複雜度?

​ 當完成了表示式的建立,只要把最高階項留下即可。低階項都去掉,高階項的係數也去掉。即為

\[O(忽略掉係數的高階項) \]

時間複雜度的意義:衡量演算法流程的複雜程度的一種指標,該指標只與資料量有關,與過程之外的優化無關。

時間複雜度的估算

  • 選擇排序
  • 氣泡排序
  • 插入排序

選擇排序

選擇排序(Selection-sort)是一種簡單直觀的排序演算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然後,再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。以此類推,直到所有元素均排序完畢。

圖片來自visualgo

實現

public class SelectionSort {

    public static void selectionSort(int[] arr) {
        if (arr == null || arr.length < 2) {
            return;
        }
        // 0 ~ N-1
        // 1 ~ N-1
        // ...
        for (int i = 0; i < arr.length; i++) {
            // 最小值在哪個位置上 i ~ N-1
            int minIndex = i;
            for (int j = i + 1; j < arr.length; j++) {  // i ~ N-1 上找最小值的下標
                minIndex = arr[j] < arr[minIndex] ? j : minIndex;
            }
            swap(arr, minIndex, i);
        }
    }

    private static void swap(int[] arr, int i, int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
}

分析過程:

  • arr[0, n-1]中,找到最小值所在的位置,然後把最小值交換到0位置。
  • arr[1, n-1]中,找到最小值所在的位置,然後把最小值交換到1位置。
  • arr[2, n-1]中,找到最小值所在的位置,然後把最小值交換到2位置。
  • arr[n-1, n-1]中,找到最小值所在的位置,然後把最小值交換到N-1位置。

由上述過程可知,當arr陣列的長度為n時,第一步常數操作的數量為N,第二步常數操作的數量為N-1,第三步常數操作的數量為N-2,以此類推,直到常數操作的數量為1。

很容易看出,此流程的常數操作的數量為一個等差數列

如果一個等差數列的首項記作 a,公差記作 d,那麼該等差數列第 n 項 an 的一般項為:

\[a_n=(a)+(n-1)d \]

一個等差數列的和,等於其首項與末項的和,乘以項數除以2。

\[S_n=\frac{n}{2}(a+a_n) \]

公式證明如下:

將等差數列和寫作以下兩種形式:

\[S_n=(a)+(a+d)+(a+2d)+...+[a+(n-2)d]+[a+(n-1)d] \]

\[S_n=[a_n-(n-1)d]+[a_n-(n-2)d]+...+(a_n-2d)+(a_n-d)+a_n \]

將兩公式相加來消掉公差 d,可得

\[2S_n=n(a+a_n) \]

帶入(2)式,可得第二種及第三種形式。

從上面的第三種形式展開可見,任意一個可以寫成

\[S_n=pn+qn^2 \]

形成的數列和,其原來數列都是一個等差數列,其中公差 d = 2q,首項 a = p + q

時間複雜度,該指標只與資料量有關,與過程之外的優化無關。因此總的常數運算元量為:

\[S_常=an^2+bn+c \]

其中a,b,c為常數,根據(1)式可得:選擇排序的時間複雜度為

\[O(N^2) \]

氣泡排序

氣泡排序是一種簡單的排序演算法。它重複地走訪過要排序的數列,一次比較兩個元素,如果它們的順序錯誤就把它們交換過來。走訪數列的工作是重複地進行直到沒有再需要交換,也就是說該數列已經排序完成。這個演算法的名字由來是因為越小的元素會經由交換慢慢“浮”到數列的頂端。

圖片來自visualgo

實現

public class BubbleSort {

    public static void bubbleSort(int[] arr) {
        if (arr == null | arr.length < 2) {
            return;
        }
        for (int e = arr.length - 1; e > 0; e--) {
            for (int i = 0; i < e; i++) {
                if (arr[i] > arr[i + 1]) {
                    swap(arr, i, i + 1);
                }
            }
        }
    }

    private static void swap(int[] arr, int i, int j) {
        arr[i] = arr[i] ^ arr[j];
        arr[j] = arr[i] ^ arr[j];
        arr[i] = arr[i] ^ arr[j];
    }
}

分析過程:

  • arr[0, n-1]中,i的取值範圍為[0, e),而e的取值範圍為(0, arr.length-1]arr[i]arr[i+1]比較,較大的值向後移
  • arr[0, n-2]中,i的取值範圍為[0, e),而e的取值範圍為(0, arr.length-2]arr[i]arr[i+1]比較,較大的值向後移
  • arr[0, n-3]中,i的取值範圍為[0, e),而e的取值範圍為(0, arr.length-3]arr[i]arr[i+1]比較,較大的值向後移
  • arr[0, 1]中,重複上述過程,執行完畢。

由上述過程可知,當arr陣列的長度為n時,第一步常數操作的數量為n-1,第二步常數操作的數量為N-2,第三步常數操作的數量為N-3,以此類推,直到常數操作的數量為1。

很容易看出,此流程的常數操作的數量為一個等差數列

證明詳見(2)~(8),所以,氣泡排序的時間複雜度為:

\[O(N^2) \]

插入排序

插入排序(Insertion sort)是一種簡單直觀且穩定的排序演算法。如果有一個已經有序的資料序列,要求在這個已經排好的資料序列中插入一個數,但要求插入後此資料序列仍然有序,這個時候就要用到一種新的排序方法——插入排序法,插入排序的基本操作就是將一個數據插入到已經排好序的有序資料中,從而得到一個新的、個數加一的有序資料,演算法適用於少量資料的排序,

圖片來自visualgo

實現

public class InsertionSort {

    public static void insertionSort(int[] arr) {
        if (arr == null || arr.length < 2) {
            return;
        }
        for (int i = 1; i < arr.length; i++) {
            for (int j = i - 1; j >= 0 && arr[j] > arr[j + 1]; j--) {
                swap(arr, j, j + 1);
            }
        }
    }

    private static void swap(int[] arr, int i, int j) {
        arr[i] = arr[i] ^ arr[j];
        arr[j] = arr[i] ^ arr[j];
        arr[i] = arr[i] ^ arr[j];
    }
}

分析過程:

  • arr[0, 0]上有序,這個範圍內只有一個數,預設有序。
  • arr[0, 1]上有序,從arr[1]與之前的元素對比,如果arr[1]<arr[0],交換,否則不交換。
  • arr[0, i]上有序,從arr[i]與之前的元素對比,如果arr[i]<arr[j](j的取值範圍為[0, i-1]),交換,否則不交換,繼續進行對比。
  • 最後,想讓arr[1, n-1]上有序,arr[n-1]不停向左移動,直到陣列左邊的元素比arr[n-1]小時,停止移動。

實際估算時,這個演算法流程的複雜程度,會因資料的排列狀況不同而不同。按處理流程複雜度的原則來看,此時,按照最壞情況處理

即陣列為降序排列,而插入排序將陣列改為升序。

由上述流程可得,,當arr陣列的長度為n時,第一步常數操作的數量為1,第二步常數操作的數量為2,第三步常數操作的數量為3,以此類推,直到常數操作的數量為n-1。

很容易看出,此流程的常數操作的數量為一個等差數列

證明詳見(2)~(8),所以,氣泡排序的時間複雜度為:

\[O(N^2) \]

注意

  1. 演算法的過程,和具體的語言無關。
  2. 若要分析一個演算法流程的時間複雜度,那麼需要對該流程非常熟悉
  3. 一定要確保在拆分演算法流程時,拆分出來的所有行為都是常數時間的操作。

常見的時間複雜度

排序從好到差:

  • O(1)
  • O(logN)
  • O(N)
  • O(N*logN)
  • O(N^2) O(N^3) … O(N^K)
  • O(2^N) O(3^N) … O(K^N)
  • O(N!)

額外空間複雜度

在演算法流程中,需要開闢一些空間來支援演算法流程。如果所需的空間是必要的、與現實目標有關的,則不算額外空間,即:

作為輸入引數的空間,不算額外空間。

作為輸出結果的空間,也不算額外空間。

除此之外,流程中如果還需要開闢空間才能使流程繼續下去,那麼,這部分空間就是額外空間

如果流程中只需要開闢有限幾個變數,額外空間複雜度為O(1)。

常數項時間

在處理時間複雜度時,通常將常數項時間忽略,因為,當n趨於無窮大時,常數項對一個演算法的好壞忽略不計

但是,當兩個演算法對其時間複雜度相比較時,兩個演算法高階項相同,例如:同為O(N)。那麼此時,就需要對比常數項時間

而常數項時間的對比,需要有深厚的功底,有一個簡單的辦法是,直接測試(控制變數),讓後計算時間。