Java 排序算法 - 為什么快速排序要比歸並排序更受歡迎呢?


Java 排序算法 - 為什么快速排序要比歸並排序更受歡迎呢?

數據結構與算法目錄(https://www.cnblogs.com/binarylei/p/10115867.html)

上一節分析了冒泡排序、選擇排序、插入排序這三種排序算法,它們的時間復雜度都是 O(n2),適合小規模數據排序。今天,本文繼續分析兩種時間復雜度為 O(nlogn) 的排序算法:歸並排序快速排序。這兩種排序算法都用到分治思想,適合大規模數據排序,比上一節講的那三種排序算法要更常用。

  • 歸並排序:將數列遞歸分解成只有一個元素。核心的算法是合並函數 merge:將兩個有序數組合並后仍然有序。merge 函數決定了歸並排序的空間復雜度和穩定性。
  • 快速排序:任意選擇一個元素作為分區占,分為小於,等於,大於三部分,然后依次對小於和大於部分遞歸排序。核心的算法是分區函數 partition:將數列分為左中右三部分。partition 函數同樣決定了快速排序的空間復雜度和穩定性。

1. 歸並排序

歸並排序使用的就是分治思想,分治是一種解決問題的處理思想,遞歸是一種編程技巧。我們現在就來看看如何用遞歸代碼來實現歸並排序。

1.1 工作原理

把整個數列等分成兩半:first, mid, last

  1. 給 first 到 mid 部分排序(遞歸調用歸並排序)
  2. 給 mid + 1 到 last 部分排序(遞歸調用歸並排序)
  3. 歸並這兩個序列
  4. 遞歸到足夠小時,需要排列的數列只包含一個數,直接返回即可。倒數第二小的遞歸,是歸並兩個序列,每個序列各一個數。

遞歸公式:

# 對下標 p~r 之間的數組進行排序:arr[p] ~ arr[r],其中 q=(p+r)/2 表示中間下標位置 
遞推公式:mergeSort(p…r) = merge(merge_sort(p…q), mergeSort(q+1…r))
        
終止條件:p >= r 不用再繼續分解

歸並排序實現代碼如下:

// 歸並排序
public class MergeSort implements Sortable {
    @Override
    public void sort(Integer[] arr) {
        mergeSort(arr, 0, arr.length - 1);
    }

    /**
     * @param arr   要排序的數組
     * @param left  要排序數組的最小位置(包含)
     * @param right 要排序數組的最大位置(包含)
     */
    private void mergeSort(Integer[] arr, int left, int right) {
        if (left >= right) {
            return;
        }

        int middle = (left + right) / 2;
        mergeSort(arr, left, middle);
        mergeSort(arr, middle + 1, right);

        merge(arr, left, middle, right);
    }

    /**
     * 歸並排序核心算法:合並兩個有序數組,結果仍是有序。需要使用額外的數組空間,因此空間復雜度是 O(n)
     */
    private void merge(Integer[] arr, int left, int middle, int right) {
        // 為了避免頻繁分配臨時數組空間,可以將臨時數組空間的開辟提前到sort方法中
        int[] tmpArray = new int[arr.length];

        int index = left;
        int leftIndex = left;
        int rightIndex = middle + 1;
        while (leftIndex <= middle && rightIndex <= right) {
            // 保證值相同時順序不變
            if (arr[leftIndex] <= arr[rightIndex]) {
                tmpArray[index++] = arr[leftIndex++];
            } else {
                tmpArray[index++] = arr[rightIndex++];
            }
        }

        while (leftIndex <= middle) {
            tmpArray[index++] = arr[leftIndex++];
        }
        while (rightIndex <= right) {
            tmpArray[index++] = arr[rightIndex++];
        }

        index = left;
        while (index <= right) {
            arr[index] = tmpArray[index];
            index++;
        }
    }
}

1.2 三大指標

(1)時間復雜度

我們先感性認識分析一下歸並排序的時間復雜度。歸並排序分兩層遞歸,外層遞歸使用二分法,時間復雜度為 logn,內層遞歸為合並兩個有序數組,時間復雜度為 n,總的時間復雜度為 O(nlogn)

下面理性分析歸並排序的時間復雜度。歸並排序遞歸的時間復雜度如下:

f(n) = 2*f(n/2)+n
     = 2*[2*f(n/4)+n/2]+n=4*f(n/4)+2*n
     = 4*[2*f(n/8)+n/4]+2*n=8*f(n/8)+3*n
     = 16*f(n/16)+4*n
     = ...
     = (2^logn)*f(n/(2^logn))+n*logn
     = n*f(1)+n*logn
所以時間復雜度為 O(n*logn)

(2)空間復雜度

merge 合並函數需要額外的空間進行臨時合並數組的存儲,即空間復雜度為 O(n)

(3)穩定性

merge 合並函數通過比較相鄰元素進行合並,相等元素的順序沒有發生改變,因此是穩定算法

2. 快速排序

快速排序是 Hoare 在 1962 年提出。它的基本思想是:通過一趟排序將要排序的數據分割成獨立的兩部分,其中一部分的所有數據都比另外一部分的所有數據都要小,然后再按此方法對這兩部分數據分別進行快速排序,整個排序過程可以遞歸進行,以此達到整個數據變成有序序列。

2.1 工作原理

  1. 每次選擇任意分區點 key,通常選最前、最后、中間的元素值。如果元素是剛好全部逆序,最前或最后則會導致分區算法效率非常底下,一個好的辦法是選擇中間元素值作為分區點。
  2. 每輪排序把比該數小的排在前邊,大的排在后邊。key 的位置就排好了。
  3. 再對前半段和后半段遞歸使用快速排序。當排序的內容只有1到 2 個數時,一輪排序即可有序。

遞歸公式:

# 對下標 p~r 之間的數組進行排序:arr[p] ~ arr[r],其中 q 表示分區點
遞推公式:quickSort(p…r) = quickSort(p…q-1) + quickSort(q+1… r)
        
終止條件:p >= r 不用再繼續分解

快速排序實現代碼如下:

public class QuickSort implements Sortable {
    @Override
    public void sort(Integer[] arr) {
        quickSort(arr, 0, arr.length - 1);
    }

    private void quickSort(Integer[] arr, int left, int right) {
        if (left >= right) return;

        // 注意,middle 已經排序,不需要重新排序
        int middle = paritition(arr, left, right);
        quickSort(arr, left, middle - 1);
        quickSort(arr, middle + 1, right);
    }

    /**
     * 快速排序核心算法:分區算法,以任意元素為分區點 pivot,將小於等於 pivot 放到右邊,大於 pivot 放到左邊
     * 分區算法:1. 如果使用多個數組進行分區計算,雖然非常簡單,但空間復雜度為 O(n),和歸並算法沒有本質的提升
     *          2. 如果使用原地算法,空間復雜度為 O(1),但也會導致相等元素亂序,是不穩定算法
     *
     * @param arr   要分區的數組
     * @param left  數組最小位置
     * @param right 數組最在位置
     * @return 中間值所有位置
     */
    private int paritition(Integer[] arr, int left, int right) {
        int pivot = arr[right];
        int i = left;
        for (int j = left; j < right; j++) {
            if (arr[j] <= pivot) {
                swap(arr, i, j);
                i++;
            }
        }
        swap(arr, i, right);
        return i;
    }

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

說明: 快速排序的核心是分區算法,本例中分區算法采用的是原地算法,也是空間復雜度為 O(1)。但這是犧牲穩定性換來的,由於存在非相鄰元素的比較交換,相等元素的順序會發生亂序。

paritition 分區函數原理:和插入算法的思想類似,將數組分為兩部分,已經處理部分和未處理部分。已處理部分,指小於和大於分區點的元素已經排序完成。然后循環將非處理部分的元素插入已經處理的部分,此時和插入算法不同,分區函數直接交換位置即可,不需要遞歸搬移元素。

如下圖所示,有 "2 0 6 9 1 5 4" 數組有 7 個元素,分區點的值為 4,其中 "2 0 6 9" 為已經處理的部分(前兩個元素都小於 4,后兩個元素都大於 4),"1 5" 則是未處理部分。當處理 1 時,將 1 和 4 進行比較,由於小於 4,則會將 swapIndex 的元素和 元素1 直接交換位置,並且 swapIndex++。如果大於 4 ,比如 5 則不作任務處理。處理完成后數組會分為小於,大於分區點值的兩部分。

這種分區算法屬於原地算法,效率很高。同時也要思考一下,如果元素值也為 4 會怎么處理呢?我們也可以看出這各分區算法並不能保證值相等的元素有序性,屬於不穩定算法。

2.2 三大指標

(1)時間復雜度

  1. 最好情況:每次選的 key 值正好等分當前數列,遞歸 O(logn) 次,每次 i 移動的總長度是 O(n),時間復雜度是 O(nlogn)。
  2. 最壞情況:每次 key 值只分出 1 個元素在小端(或每次在大端),遞歸 n 次,每次 i 移動的總長度是 O(n),時間復雜度 O(n2)。
  3. 平均復雜度:O(nlogn)。

(2)空間復雜度

使用原來的數組進行排序,是原地排序算法,即 O(1)。

(3)穩定性

由於分區函數屬於不穩定算法,所以快速排序也屬性不穩定排序。

參考:

  1. 排序動畫演示:http://www.jsons.cn/sort/

每天用心記錄一點點。內容也許不重要,但習慣很重要!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM