資料結構與演算法之排序

龍韜發表於2020-11-07

排序

  • 氣泡排序(Bubble Sort)
  • 插入排序(Insertion Sort)
  • 歸併排序(Merge Sort)
  • 快速排序(Quick Sort)
  • 堆排序(Heap Sort)
  • 計數排序(Counting Sort)
  • 桶排序(Bucket Sort)
  • 拓撲排序(Topological Sort)

氣泡排序(Bubble Sort)

基本思想

給定一個陣列,我們把陣列裡的元素通通倒入到水池中,這些元素將通過相互之間的比較,按照大小順序一個一個地像氣泡一樣浮出水面。

實現

每一輪,從雜亂無章的陣列頭部開始,每兩個元素比較大小並進行交換,直到這一輪當中最大或最小的元素被放置在陣列的尾部,然後不斷地重複這個過程,直到所有元素都排好位置。其中,核心操作就是元素相互比較。

例題分析

給定陣列 [2, 1, 7, 9, 5, 8],要求按照從左到右、從小到大的順序進行排序。

解題思路

從左到右依次冒泡,把較大的數往右邊挪動即可。

  1. 首先指標指向第一個數,比較第一個數和第二個數的大小,由於 2 比 1 大,所以兩兩交換,[1, 2, 7, 9, 5, 8]。

  2. 接下來指標往前移動一步,比較 2 和 7,由於 2 比 7 小,兩者保持不動,[1, 2, 7, 9, 5, 8]。到目前為止,7 是最大的那個數。

  3. 指標繼續往前移動,比較 7 和 9,由於 7 比 9 小,兩者保持不動,[1, 2, 7, 9, 5, 8]。現在,9 變成了最大的那個數。

  4. 再往後,比較 9 和 5,很明顯,9 比 5 大,交換它們的位置,[1, 2, 7, 5, 9, 8]。

  5. 最後,比較 9 和 8,9 比 8 大,交換它們的位置,[1, 2, 7, 5, 8, 9]。經過第一輪的兩兩比較,9 這個最大的數就像冒泡一樣冒到了陣列的最後面。

  6. 接下來進行第二輪的比較,把指標重新指向第一個元素,重複上面的操作,最後,陣列變成了:[1, 2, 5, 7, 8, 9]。

  7. 在進行新一輪的比較中,判斷一下在上一輪比較的過程中有沒有發生兩兩交換,如果一次交換都沒有發生,就證明其實陣列已經排好序了。

實現程式碼

    public static void bubbleSort(int[] nums) {
        // 定義一個布林變數 hasChange,用來標記每輪遍歷中是否發生了交換
        boolean hasChange = true;
        for (int i = 0; i < nums.length - 1 && hasChange; i++) {
            // 每輪遍歷開始,將 hasChange 設定為 false
            hasChange = false;
            // 進行兩兩比較,如果發現當前的數比下一個數還大,那麼就交換這兩個數,同時記錄一下有交換髮生
            for (int j = 0; j < nums.length - 1 - i; j++) {
                if (nums[j] > nums[j+1]) {
                    swap(nums, j, j+1);
                    hasChange = true;
                }
            }
        }
    }
	// 交換陣列中的兩個數
    public static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

演算法分析

空間複雜度

假設陣列的元素個數是 n,由於在整個排序的過程中,我們是直接在給定的陣列裡面進行元素的兩兩交換,所以空間複雜度是 O(1)。

時間複雜度

  1. 給定的陣列按照順序已經排好

    在這種情況下,我們只需要進行 n−1 次的比較,兩兩交換次數為 0,時間複雜度是 O(n)。這是最好的情況。

  2. 給定的陣列按照逆序排列

    在這種情況下,我們需要進行 n(n-1)/2 次比較,時間複雜度是 O(n2)。這是最壞的情況。

  3. 給定的陣列雜亂無章

    在這種情況下,平均時間複雜度是 O(n2)。

由此可見,氣泡排序的時間複雜度是 O(n2)。它是一種穩定的排序演算法。(穩定是指如果陣列裡兩個相等的數,那麼排序前後這兩個相等的數的相對位置保持不變。)

插入排序(Insertion Sort)

基本思想

不斷地將尚未排好序的數插入到已經排好序的部分。

特點

在氣泡排序中,經過每一輪的排序處理後,陣列後端的數是排好序的;而對於插入排序來說,經過每一輪的排序處理後,陣列前端的數都是排好序的。

例題分析

對陣列 [2, 1, 7, 9, 5, 8] 進行插入排序。

解題思路

首先將陣列分成左右兩個部分,左邊是已經排好序的部分,右邊是還沒有排好序的部分,剛開始,左邊已排好序的部分只有第一個元素 2。接下來,我們對右邊的元素一個一個進行處理,將它們放到左邊。

  1. 先來看 1,由於 1 比 2 小,需要將 1 插入到 2 的前面,做法很簡單,兩兩交換位置即可,[1, 2, 7, 9, 5, 8]。
  2. 然後,我們要把 7 插入到左邊的部分,由於 7 已經比 2 大了,表明它是目前最大的元素,保持位置不變,[1, 2, 7, 9, 5, 8]。
  3. 同理,9 也不需要做位置變動,[1, 2, 7, 9, 5, 8]。
  4. 接下來,如何把 5 插入到合適的位置。首先比較 5 和 9,由於 5 比 9 小,兩兩交換,[1, 2, 7, 5, 9, 8],繼續,由於 5 比 7 小,兩兩交換,[1, 2, 5, 7, 9, 8],最後,由於 5 比 2 大,此輪結束。
  5. 最後一個數是 8,由於 8 比 9 小,兩兩交換,[1, 2, 5, 7, 8, 9],再比較 7 和 8,發現 8 比 7 大,此輪結束。到此,插入排序完畢。

實現程式碼

    public static void insertionSort(int[] nums) {
        // 將陣列的第一個元素當作已經排好序的,從第二個元素,即 i 從 1 開始遍歷陣列
        for (int i = 1, j, current; i < nums.length; i++) {
            // 外圍迴圈開始,把當前 i 指向的值用 current 儲存
            current = nums[i];
            // 指標 j 內迴圈,和 current 值比較,若 j 所指向的值比 current 值大,則該數右移一位
            for (j = i - 1; j >= 0 && nums[j] > current; j--) {
                nums[j + 1] = nums[j];
            }
            // 內迴圈結束,j+1 所指向的位置就是 current 值插入的位置
            nums[j + 1] = current;
        }
    }

演算法分析

空間複雜度

假設陣列的元素個數是 n,由於在整個排序的過程中,是直接在給定的陣列裡面進行元素的兩兩交換,空間複雜度是 O(1)。

時間複雜度

  1. 給定的陣列按照順序已經排好

    只需要進行 n-1 次的比較,兩兩交換次數為 0,時間複雜度是 O(n)。這是最好的情況。

  2. 給定的陣列按照逆序排列

    在這種情況下,我們需要進行 n(n-1)/2 次比較,時間複雜度是 O(n2)。這是最壞的情況。

  3. 給定的陣列雜亂無章

    在這種情況下,平均時間複雜度是 O(n2)。

由此可見,和氣泡排序一樣,插入排序的時間複雜度是 O(n2),並且它也是一種穩定的排序演算法。

歸併排序(Merge Sort)

基本思想

核心是分治,就是把一個複雜的問題分成兩個或多個相同或相似的子問題,然後把子問題分成更小的子問題,直到子問題可以簡單的直接求解,最原問題的解就是子問題解的合併。歸併排序將分治的思想體現得淋漓盡致。

實現

一開始先把陣列從中間劃分成兩個子陣列,一直遞迴地把子陣列劃分成更小的子陣列,直到子陣列裡面只有一個元素,才開始排序。

排序的方法就是按照大小順序合併兩個元素,接著依次按照遞迴的返回順序,不斷地合併排好序的子陣列,直到最後把整個陣列的順序排好。

例題分析

例題:利用歸併排序演算法對陣列 [2, 1, 7, 9, 5, 8] 進行排序。

解題思路

首先不斷地對陣列進行切分,直到各個子陣列裡只包含一個元素。

接下來遞迴地按照大小順序合併切分開的子陣列,遞迴的順序和二叉樹裡的前序遍歷類似。

  1. 合併 [2] 和 [1] 為 [1, 2]。
  2. 子陣列 [1, 2] 和 [7] 合併。
  3. 右邊,合併 [9] 和 [5]。
  4. 然後合併 [5, 9] 和 [8]。
  5. 最後合併 [1, 2, 7] 和 [5, 8, 9] 成 [1, 2, 5, 8, 9],就可以把整個陣列排好序了。

合併陣列 [1, 2, 7] 和 [5, 8, 9] 的操作步驟如下。

  1. 把陣列 [1, 2, 7] 用 L 表示,[5, 8, 9] 用 R 表示。
  2. 合併的時候,開闢分配一個新陣列 T 儲存結果,陣列大小應該是兩個子陣列長度的總和
  3. 然後下標 i、j、k 分別指向每個陣列的起始點。
  4. 接下來,比較下標i和j所指向的元素 L[i] 和 R[j],按照大小順序放入到下標 k 指向的地方,1 小於 5。
  5. 移動 i 和 k,繼續比較 L[i] 和 R[j],2 比 5 小。
  6. i 和 k 繼續往前移動,5 比 7 小。
  7. 移動 j 和 k,繼續比較 L[i] 和 R[j],7 比 8 小。
  8. 這時候,左邊的陣列已經處理完畢,直接將右邊陣列剩餘的元素放到結果陣列裡就好。

合併之所以能成功,先決條件必須是兩個子陣列都已經分別排好序了。

實現程式碼

    public static void mergeSort(int[] arr, int lo, int hi) {
        // 判斷是否只剩下最後一個元素
        if (lo >= hi) {
            return;
        }
        // 從中間將陣列分成兩個部分
        int mid = lo + (hi - lo) / 2;
        // 分別遞迴地將左右兩半排好序
        mergeSort(arr, lo, mid);
        mergeSort(arr, mid + 1, hi);
        // 將排好序的左右兩半合併
        merge(arr, lo, mid, hi);
    }
    // 歸併
    public static void merge(int[] nums, int lo, int mid, int hi) {
        // 複製一份原來的陣列
        int[] copy = nums.clone();
        // 定義一個 k 指標表示從什麼位置開始修改原來的陣列,i 指標表示左半邊的起始位置,j 表示右半邊的起始位置
        int k = lo, i = lo, j = mid + 1;
        while(k <= hi) {
            if(i > mid) {
                nums[k++] = copy[j++];
            } else if(j > hi) {
                nums[k++] = copy[i++];
            } else if(copy[j] < copy[i]) {
                nums[k++] = copy[j++];
            } else {
                nums[k++] = copy[i++];
            }
        }
    }

其中,While 語句比較,一共可能會出現四種情況。

  • 左半邊的數都處理完畢,只剩下右半邊的數,只需要將右半邊的數逐個拷貝過去。
  • 右半邊的數都處理完畢,只剩下左半邊的數,只需要將左半邊的數逐個拷貝過去就好。
  • 右邊的數小於左邊的數,將右邊的數拷貝到合適的位置,j 指標往前移動一位。
  • 左邊的數小於右邊的數,將左邊的數拷貝到合適的位置,i 指標往前移動一位。

演算法分析

空間複雜度

由於合併 n 個元素需要分配一個大小為 n 的額外陣列,合併完成之後,這個陣列的空間就會被釋放,所以演算法的空間複雜度就是 O(n)。歸併排序也是穩定的排序演算法。

時間複雜度

歸併演算法是一個不斷遞迴的過程。

舉例:陣列的元素個數是 n,時間複雜度是 T(n) 的函式。

解法:把這個規模為 n 的問題分成兩個規模分別為 n/2 的子問題,每個子問題的時間複雜度就是 T(n/2),那麼兩個子問題的複雜度就是 2×T(n/2)。當兩個子問題都得到了解決,即兩個子陣列都排好了序,需要將它們合併,一共有 n 個元素,每次都要進行最多 n-1 次的比較,所以合併的複雜度是 O(n)。由此我們得到了遞迴複雜度公式:T(n) = 2×T(n/2) + O(n)。

對於公式求解,不斷地把一個規模為 n 的問題分解成規模為 n/2 的問題,一直分解到規模大小為 1。如果 n 等於 2,只需要分一次;如果 n 等於 4,需要分 2 次。這裡的次數是按照規模大小的變化分類的。

以此類推,對於規模為 n 的問題,一共要進行 log(n) 層的大小切分。在每一層裡,我們都要進行合併,所涉及到的元素其實就是陣列裡的所有元素,因此,每一層的合併複雜度都是 O(n),所以整體的複雜度就是 O(nlogn)。

快速排序(Quick Sort)

基本思想

快速排序也採用了分治的思想。

實現

把原始的陣列篩選成較小和較大的兩個子陣列,然後遞迴地排序兩個子陣列。

舉例:把班裡的所有同學按照高矮順序排成一排。

解法:老師先隨機地挑選了同學 A,讓所有其他同學和 A 比高矮,比 A 矮的都站在 A 的左邊,比 A 高的都站在 A 的右邊。接下來,老師分別從左邊和右邊的同學裡選擇了同學 B 和 C,然後不斷地篩選和排列下去。

在分成較小和較大的兩個子陣列過程中,如何選定一個基準值(也就是同學 A、B、C 等)尤為關鍵。

例題分析

對陣列 [2, 1, 7, 9, 5, 8] 進行排序。

解題思路

  1. 按照快速排序的思想,首先把陣列篩選成較小和較大的兩個子陣列。
  2. 隨機從陣列裡選取一個數作為基準值,比如 7,於是原始的陣列就被分成了兩個子陣列。注意:快速排序是直接在原始陣列裡進行各種交換操作,所以當子陣列被分割出來的時候,原始陣列裡的排列也被改變了。
  3. 接下來,在較小的子陣列裡選 2 作為基準值,在較大的子陣列裡選 8 作為基準值,繼續分割子陣列。
  4. 繼續將元素個數大於 1 的子陣列進行劃分,當所有子陣列裡的元素個數都為 1 的時候,原始陣列也被排好序了。

實現程式碼

	public static void quickSort(int[] nums, int lo, int hi) {
        // 判斷是否只剩下一個元素,是則直接返回
        if (lo >= hi) {
            return;
        }
        // 利用partition函式找到一個隨機基準點
        int p = partition(nums, lo, hi);
        // 遞迴地對基準點左半邊和右半邊的數進行排序
        quickSort(nums, lo, p - 1);
        quickSort(nums, p + 1, hi);
    }
    // 獲得基準值
    public static int partition(int[] nums, int lo, int hi) {
        // 隨機選擇一個數作為基準值,nums[hi] 就是基準值
        swap(nums, randRange(lo, hi), hi);
        int i, j;
        // 從左到右用每個數和基準值比較,若比基準值小,則放到指標 i 所指向的位置。迴圈完畢後,i 指標之前的數都比基準值小
        for (i = lo, j = lo; j < hi; j++) {
            if (nums[j] <= nums[hi]) {
                swap(nums, i++, j);
            }
        }
        // 末尾的基準值放置到指標 i 的位置,i 指標之後的數都比基準值大
        swap(nums, i, j);
        // 返回指標 i,作為基準點的位置
        return i;
    }
    // 獲取隨機值
    public static int randRange(int lo, int hi) {
        return (int) (lo + Math.random() * (hi - lo + 1));
    }

    // 交換陣列中的兩個數
    public static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

演算法分析

空間複雜度

和歸併排序不同,快速排序在每次遞迴的過程中,只需要開闢 O(1) 的儲存空間來完成交換操作實現直接對陣列的修改,又因為遞迴次數為 logn,所以它的整體空間複雜度完全取決於壓堆疊的次數,因此它的空間複雜度是 O(logn)。

時間複雜度

  1. 最優情況:被選出來的基準值都是當前子陣列的中間數。

    這樣的分割,能保證對於一個規模大小為 n 的問題,能被均勻分解成兩個規模大小為 n/2 的子問題(歸併排序也採用了相同的劃分方法),時間複雜度就是:T(n) = 2×T(n/2) + O(n)。

    把規模大小為 n 的問題分解成 n/2 的兩個子問題時,和基準值進行了 n-1 次比較,複雜度就是 O(n)。很顯然,在最優情況下,快速排序的複雜度也是 O(nlogn)。

  2. 最壞情況:基準值選擇了子陣列裡的最大或者最小值

    每次都把子陣列分成了兩個更小的子陣列,其中一個的長度為 1,另外一個的長度只比原子陣列少 1。劃分過程和氣泡排序的過程類似,演算法複雜度為 O(n2)。

tips:可以通過隨機地選取基準值來避免出現最壞的情況。

堆排序(Heap Sort)

基本思想

堆排序是指利用堆這種資料結構所設計的一種排序演算法。堆是一個近似完全二叉樹的結構,並同時滿足堆的性質:即子結點的鍵值或索引總是小於(或者大於)它的父節點。

實現

  • 將初始待排序關鍵字序列(R1,R2….Rn)構建成大頂堆,此堆為初始的無序區;
  • 將堆頂元素R[1]與最後一個元素R[n]交換,此時得到新的無序區(R1,R2,……Rn-1)和新的有序區(Rn),且滿足R[1,2…n-1]<=R[n];
  • 由於交換後新的堆頂R[1]可能違反堆的性質,因此需要對當前無序區(R1,R2,……Rn-1)調整為新堆,然後再次將R[1]與無序區最後一個元素交換,得到新的無序區(R1,R2….Rn-2)和新的有序區(Rn-1,Rn)。不斷重複此過程直到有序區的元素個數為n-1,則整個排序過程完成。

實現程式碼

    public static void heapSort(int[] arr) {
        len = arr.length;
        if (len < 1) {
            return;
        }
        //1.構建一個大根堆
        buildMaxHeap(arr);
        //2.迴圈將堆首位(最大值)與末位交換,然後再重新調整最大堆
        while(len > 0) {
            swap(arr, 0, len-1);
            len--;
            adjustHeap(arr, 0);
        }
    }
    // 建立大根堆
    public static void buildMaxHeap(int[] arr) {
        // 從最後一個非葉子節點開始向上構造大根堆
        for (int i = (len / 2 -1); i >= 0; i--) {
            adjustHeap(arr, i);
        }
    }
    // 調整使之成為大根堆
    public static void adjustHeap(int[] arr, int i ) {
        int maxIndex = i;
        // 如果有左子樹且左子樹大於父節點,則將最大指標指向左子樹
        if (i * 2 < len && arr[i * 2] > arr[maxIndex]) {
            maxIndex = i * 2;
        }
        // 如果有右子樹且右子樹大於父節點,則將最大指標指向右子樹
        if (i * 2 + 1 < len && arr[i * 2 + 1] > arr[maxIndex]) {
            maxIndex = i * 2 + 1;
        }
        // 如果父節點不是最大值,則將父節點與最大值交換並遞迴調整與父節點交換的位置
        if (maxIndex != i) {
            swap(arr, maxIndex, i);
            adjustHeap(arr, maxIndex);
        }
    }
    // 交換陣列中的兩個數
    public static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

演算法分析

時間複雜度

 堆排序是一種選擇排序,整體主要由構建初始堆+交換堆頂元素和末尾元素並重建堆兩部分組成。其中構建初始堆經推導複雜度為O(n),在交換並重建堆的過程中,需交換n-1次,而重建堆的過程中,根據完全二叉樹的性質,[log2(n-1),log2(n-2)...1]逐步遞減,近似為nlogn。所以堆排序時間複雜度一般認為就是O(nlogn)級。

計數排序(Counting Sort)

基本思想

計數排序的核心在於將輸入的資料值轉化為鍵儲存在額外開闢的陣列空間中。作為一種線性時間複雜度的排序,計數排序要求輸入的資料必須是有確定範圍的整數。

計數排序(Counting sort)是一種穩定的排序演算法。計數排序使用一個額外的陣列C,其中第i個元素是待排序陣列A中值等於i的元素的個數。然後根據陣列C來將A中的元素排到正確的位置。它只能對整數進行排序。

實現

  • 找出待排序的陣列中最大和最小的元素;
  • 統計陣列中每個值為i的元素出現的次數,存入陣列C的第i項;
  • 對所有的計數累加(從C中的第一個元素開始,每一項和前一項相加);
  • 反向填充目標陣列:將每個元素i放在新陣列的第C(i)項,每放一個元素就將C(i)減去1。

動圖演示

實現程式碼

    public static void countingSort(int[] arr) {
        if (arr.length == 0) {
            return;
        }
        int bias, min = arr[0], max = arr[0];
        // 1.確認陣列中的最大值最小值
        for (int i = 1; i < arr.length; i++) {
            if (arr[i] > max) {
                max = arr[i];
            }
            if (arr[i] < min) {
                min = arr[i];
            }
        }
        bias = 0 - min; // bias記錄新陣列的下標偏移量
        int[] bucket = new int[max - min + 1];
        // 2.統計並存入新陣列
        for (int i = 0; i < arr.length; i++) {
            bucket[arr[i] + bias]++;
        }
        int index = 0, i = 0;
        // 3.反向填充目標陣列
        while(index < arr.length) {
            if (bucket[i] != 0) {
                arr[index] = i - bias;
                bucket[i]--;
                index++;
            } else {
                i++;
            }
        }
    }

演算法分析

當輸入的元素是 n 個 0 到 k 之間的整數時,它的執行時間是 O(n + k)。計數排序不是比較排序,排序的速度快於任何比較排序演算法。由於用來計數的陣列C的長度取決於待排序陣列中資料的範圍(等於待排序陣列的最大值與最小值的差加上1),這使得計數排序對於資料範圍很大的陣列,需要大量時間和記憶體。

最佳情況:T(n) = O(n+k) 最差情況:T(n) = O(n+k) 平均情況:T(n) = O(n+k)

桶排序(Bucket Sort)

思想

桶排序是計數排序的升級版。當數列取值範圍過大,或者不是整數時不能適用計數排序,這時可以使用桶排序來解決問題。它利用了函式的對映關係,高效與否的關鍵就在於這個對映函式的確定。

桶排序 (Bucket sort)的工作的原理:假設輸入資料服從均勻分佈,將資料分到有限數量的桶裡,每個桶再分別排序(有可能再使用別的排序演算法或是以遞迴方式繼續使用桶排序進行排序)

實現

每一個桶(bucket)代表一個區間範圍,裡面可以承載一個或多個元素。桶排序的第一步,就是建立這些桶,確定每一個桶的區間範圍:

具體建立多少個桶,如何確定桶的區間範圍,有很多不同的方式。我們這裡建立的桶數量等於原始數列的元素數量,除了最後一個桶只包含數列最大值,前面各個桶的區間按照比例確定。

區間跨度 = (最大值-最小值)/ (桶的數量 - 1)

第二步,遍歷原始數列,把元素對號入座放入各個桶中:

第三步,每個桶內部的元素分別排序(顯然,只有第一個桶需要排序):

第四步,遍歷所有的桶,輸出所有元素:

0.5,0.84,2.18,3.25,4.5

到此為止,排序結束。

實現程式碼

    public static void bucketSort(double[] array){
        //1.得到數列的最大值和最小值,並算出差值d
        double max = array[0];
        double min = array[0];
        for(int i=1; i<array.length; i++) {
            if(array[i] > max) {
                max = array[i];
            }
            if(array[i] < min) {
                min = array[i];
            }
        }
        double d = max - min;
        //2.初始化桶
        int bucketNum = array.length;
        ArrayList<LinkedList<Double>> bucketList = new ArrayList<LinkedList<Double>>(bucketNum);
        for(int i = 0; i < bucketNum; i++){
            bucketList.add(new LinkedList<Double>());
        }
        //3.遍歷原始陣列,將每個元素放入桶中
        for(int i = 0; i < array.length; i++){
            int num = (int)((array[i] - min) * (bucketNum-1) / d);
            bucketList.get(num).add(array[i]);
        }
        //4.對每個通內部進行排序
        for(int i = 0; i < bucketList.size(); i++){
            //JDK底層採用了歸併排序或歸併的優化版本
            Collections.sort(bucketList.get(i));
        }
        //5.輸出全部元素
        int index = 0;
        for(LinkedList<Double> list : bucketList){
            for(double element : list){
                array[index] = element;
                index++;
            }
        }
    }

演算法分析

時間複雜度:O(N + C)

對於待排序序列大小為 N,共分為 M 個桶,主要步驟有:

  • N 次迴圈,將每個元素裝入對應的桶中
  • M 次迴圈,對每個桶中的資料進行排序(平均每個桶有 N/M 個元素)

一般使用較為快速的排序演算法,時間複雜度為 O ( N l o g N ),實際的桶排序過程是以連結串列形式插入的。

整個桶排序的時間複雜度為:

O ( N ) + O ( M ∗ ( N / M ∗ l o g ( N / M ) ) ) = O ( N ∗ ( l o g ( N / M ) + 1 ) )

當 N = M 時,複雜度為 O ( N )

空間複雜度:O(N+M)

拓撲排序(Topological Sort)

基本思想

和前面介紹的幾種排序不同,拓撲排序應用的場合不再是一個簡單的陣列,而是研究圖論裡面頂點和頂點連線之間的性質。拓撲排序就是要將這些頂點按照相連的性質進行排序。

要能實現拓撲排序,得有幾個前提:

  1. 圖必須是有向圖
  2. 圖裡面沒有環

拓撲排序一般用來理清具有依賴關係的任務。

舉例:假設有三門課程 A、B、C,如果想要學習課程 C 就必須先把課程 B 學完,要學習課程 B還得先學習課程 A,所以得出課程的學習順序應該是 A -> B -> C。

實現

  1. 將問題用一個有向無環圖(DAG, Directed Acyclic Graph)進行抽象表達,定義出哪些是圖的頂點,頂點之間如何互相關聯。
  2. 可以利用廣度優先搜尋或深度優先搜尋來進行拓撲排序。

例題分析

有一個學生想要修完 5 門課程的學分,這 5 門課程分別用 1、2、3、4、5 來表示,現在已知學習這些課程有如下的要求:

  • 課程 2 和 4 依賴於課程 1

  • 課程 3 依賴於課程 2 和 4

  • 課程 4 依賴於課程 1 和 2

  • 課程 5 依賴於課程 3 和 4

那麼這個學生應該按照怎樣的順序來學習這 5 門課程呢?

解題思路

可以把 5 門課程看成是一個圖裡的 5 個頂點,用有向線段按照它們的相互關係連起來,於是得出下面的有向圖。

首先可以看到,這個有向圖裡沒有環,無論從哪個頂點出發,都不會再回到那個頂點。並且,這個圖裡並沒有孤島的出現,因此,我們可以對它進行拓撲排序。

方法就是,一開始的時候,對每個頂點統計它們各自的前驅(也就是入度):1(0),2(1),3(2),4(1),5(2)。

  1. 選擇其中一個沒有前驅(也就是入度為 0)的頂點,在這道題裡面,頂點 1 就是我們要找的那個點,將它作為結果輸出。同時刪除掉該頂點和所有以它作為起始點的有向邊,更新頂點的入度表。
  2. 接下來,頂點 2 就是下一個沒有前驅的頂點,輸出頂點 2,並將以它作為起點的有向邊刪除,同時更新入度表。
  3. 再來,頂點 4 成為了沒有前驅的頂點,輸出頂點 4,刪除掉它和頂點 3 和 5 的有向邊。
  4. 然後,頂點 3 沒有了前驅,輸出它,並刪除它與 5 的有向邊。
  5. 最後,頂點 5 沒有前驅,輸出它,於是得出最後的結果為:1,2,4,3,5。

一般來說,一個有向無環圖可以有一個或多個拓撲排序的序列。

實現程式碼

運用廣度優先搜尋的方法對這個圖的結構進行遍歷。在構建這個圖的過程中,用一個連結矩陣 adj 來表示這個圖的結構,用一個 indegree 的陣列統計每個頂點的入度,重點看如何實現拓撲排序。

void topologicalSort() {
    Queue<Integer> q = new LinkedList(); // 定義一個佇列 q

    // 將所有入度為 0 的頂點加入到佇列 q
    for (int v = 0; v < V; v++) {
        if (indegree[v] == 0) q.add(v);
    }

    // 迴圈,直到佇列為空
    while (!q.isEmpty()) {
        int v = q.poll();
        // 每次迴圈中,從佇列中取出頂點,即為按照入度數目排序中最小的那個頂點
        print(v);
        
        // 將跟這個頂點相連的其他頂點的入度減 1,如果發現那個頂點的入度變成了 0,將其加入到佇列的末尾
        for (int u = 0; u < adj[v].length; u++) {
            if (--indegree[u] == 0) {
                q.add(u);
            }
        }
    }
}

演算法分析

時間複雜度

統計頂點的入度需要 O(n) 的時間,接下來每個頂點被遍歷一次,同樣需要 O(n) 的時間,所以拓撲排序的時間複雜度是 O(n)。

相關文章