【資料結構與演算法】快速排序(三種程式碼實現以及工程優化)

gonghr發表於2021-08-04

概念

快速排序是一種分治的排序演算法。它將一個陣列分成兩個子陣列,將兩個部分獨立地排序。遞迴呼叫發生在處理整個陣列之後。

快速排序演算法首先會在序列中隨機選擇一個基準值(pivot),然後將除了基準值以外的數分為“比基準值小的數”和“比基準值大的數”這兩個類別,再將其排列成以下形式。
[ 比基準值小的數] 基準值 [ 比基準值大的數]

程式碼實現

單向掃描分割槽法

image

  • 第一個元素也就是下標low所指元素作為基準值pivot
  • 左指標i開始指向第二個元素。
  • 右指標j開始指向最後一個元素。
  • 如果i所指向元素小於等於pivot,則i向右移動一位
  • 如果i所指向元素大於pivot,則i不動,i所指向元素與j所指向元素交換,j向左移動一位
  • 最終狀態是i和j相鄰,並且j位於i的左邊,j指向小於等於區域的最後一個元素,i指向大於區域的第一個元素。
  • 把pivot和j所指向元素交換,即可把pivot放到中間位置(每個區域內部不用考慮有序性)。
public static void main(String[] args) {
        int[] arr = {2, 2, 2, 0, 0, 0, 1};
        quickSort(arr, 0, arr.length - 1);
        for (int i : arr) {
            System.out.print(i + " ");
        }
    }

    private static void quickSort(int[] arr, int low, int high) {
        if (arr == null || arr.length < 2 || high <= low) return;
        int j = partition(arr, low, high);  //切分
        quickSort(arr, low, j - 1);  //將左半部分arr[low...j-1]排序
        quickSort(arr, j + 1, high); //將右半部分arr[j+1...high]排序
    }

    private static int partition(int[] arr, int low, int high) {
        int i = low + 1, j = high;  //左右掃描指標
        int pivot = arr[low];       //切分元素,設定基準值為從左向右第一個元素的下標
        while (i <= j) {
            if (arr[i] <= pivot)  //掃描元素小於基準值,左指標右移
                i++;
            else {
                swap(arr, i, j);  //掃描元素大於基準值,兩個指標的元素交換,右指標左移。使該元素到基準值右側(確定i所指向元素屬於大於區域,那就把它放到大於區域)
                j--;              
            }
        }                   //j最後所指向的位置是小於區域的最後一個元素
        swap(arr, low, j);  //將基準值插入到相應位置,也就是把基準值和小於區域的最後一個元素交換。使得基準值右側就是大於區域
        return j;
    }

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

雙向掃描分割槽法

雙向掃描的思路是,頭尾指標往中間掃描,從左找到大於主元的元素,從右找到小於等於主元的元素二者交換,繼續掃描,直到左側無大元素,右側無小元素

  • 第一個元素也就是下標low所指元素作為基準值pivot
  • 左指標i開始指向第二個元素。
  • 右指標j開始指向最後一個元素。
  • 開始外層迴圈,條件是i<=j
    • 如果i所指向元素小於等於pivot,則i向右移動一位,迴圈進行,直到i所指向元素大於等於pivot
    • 如果j所指向元素大於pivot,則j向左移動一位,迴圈進行,直到j所指向元素小於pivot
    • 交換i和j所指向元素
  • 最終狀態是i和j相鄰,並且j位於i的左邊,j指向小於等於區域的最後一個元素,i指向大於區域的第一個元素。
  • 把pivot和j所指向元素交換,即可把pivot放到中間位置(每個區域內部不用考慮有序性)。
public static void main(String[] args) {
        int[] arr = {1,5,6,3,2,1,4,5,2};
        quickSort(arr, 0, arr.length - 1);
        for (int i : arr) {
            System.out.print(i + " ");
        }
    }
    private static void quickSort(int[] arr, int low, int high) {
        if (arr == null || arr.length < 2 || high <= low) return;
        int j = partition(arr, low, high);  //切分
        quickSort(arr, low, j - 1);  //將左半部分arr[low...j-1]排序
        quickSort(arr, j + 1, high); //將右半部分arr[j+1...high]排序
    }
    private static int partition(int[] arr,int low,int high){
        int i = low + 1, j = high;  //左右掃描指標
        int pivot = arr[low];       //切分元素,設定基準值為從左向右第一個元素
        while(i<=j){
            while(i<=j&&arr[i]<=pivot) //掃描元素小於基準值,左指標右移(注意保證i<=j)
                i++;
            while(i<=j&&arr[j]>pivot)  //掃描元素大於基準值,右指標左移(注意保證i<=j)
                j--;
            if(i<j)  //注意該處判斷
                swap(arr,i,j);    //左右指標指向元素交換
        }
        swap(arr,low,j);     //將基準值插入到相應位置,也就是把基準值和小於區域的最後一個元素交換。使得基準值右側就是大於區域
        return j;
    }
    private static void swap(int[] arr,int a,int b){
        int tmp = arr[a];
        arr[a]=arr[b];
        arr[b]=tmp;
    }

有相同元素的快速排序——三分法

雙向掃描的思路是,多考慮相等的情況,i 指標從左向右掃描,j 指標從右向左掃描,e永遠指向相等區域的第一個元素(小於區域的後面第一個元素)。

  • 第一個元素也就是下標low所指元素作為基準值pivot
  • 左指標i開始指向第二個元素。
  • 右指標j開始指向最後一個元素。
  • 中間指標e開始指向第二個元素
  • 開始外層迴圈,條件是i<=j
    • 如果i所指向元素小於pivot,i所指向元素和e所指向元素交換(e左邊是小於區域,把i的元素放到小於區域),i向右移動一位,e向右移動一位。
    • 如果i所指向元素等於pivot,i向右移動一位。(相等於等於區域擴大一位)
    • 如果j所指向元素大於pivot,交換i所指向元素和j所指向元素,j左移一位。(相當於把i的元素放到大於區域)
  • 最終狀態是i和j相鄰,並且j位於i的左邊,j指向等於區域的最後一個元素,i指向大於區域的第一個元素,e指向等於區域的第一個元素(即小於區域的後面第一個元素)。
  • 把pivot和(e-1)所指向元素交換,即可把pivot放到中間位置(每個區域內部不用考慮有序性)。
  • 返回等於區域左邊第一個元素下標和等於區域右邊最後一個元素下標。

image

public static void main(String[] args) {
        int[] arr = {5,2,1,3,6,7};
        quickSort(arr, 0, arr.length - 1);
        for (int i : arr) {
            System.out.print(i + " ");
        }
    }
    private static void quickSort(int[] arr,int low,int high){
        if(arr==null||arr.length<2||low>=high) return;
        int []j = partition(arr,low,high); //返回兩個座標
        quickSort(arr,low,j[0]-1);  //將左半部分arr[low...j[0]-1]排序
        quickSort(arr,j[1]+1,high); //將右半部分arr[j[0]+1...high]排序
    }
    private static int[] partition(int[] arr,int low,int high){
        int i = low+1;  
        int j = high;
        int pivot = arr[low];
        int e = low+1;
        while(i<=j){
            if(arr[i]<pivot){  //小於pivot,i位置和e位置交換,e++,i++
                swap(arr,i,e);
                e++;
                i++;
            }
            else if(arr[i]==pivot){ //等於pivot,i++
                i++;
            }else{
                swap(arr,i,j);   //大於pivot,s位置和j位置交換,j--
                j--;
            }
        }
        swap(arr,low,e-1);    //將基準值插入到相應位置
        return new int[]{e,j};  //返回等於區域左邊第一個元素下標和等於區域右邊最後一個元素下標。
    }
    private static void swap(int[] arr,int a,int b){
        int tmp = arr[a];
        arr[a]=arr[b];
        arr[b]=tmp;
    }

工程實踐中的其他優化

優化策略

分析一下上面的雙向掃描分割槽法,在定義pivot時都指定第一個元素為pivot的值,有可能pivot的值不在陣列中居中,有可能退化成O(n^2)時間複雜度。

舉個極端情況的例子:

image

每次選取pivot都為首元素,而pivot的值恰好為最大元素,則pivot最後需要到最右的位置,那麼下一次遞迴呼叫右半部分arr[j+1...high]就沒有了,只有左半部分arr[low...j-1]。而不巧下一次遞迴pivot選取第一個元素又是最大的,又要重複以上步驟。

資料規模類似從n 到n-1 到n-2 ……到1 ,做n層,最後時間複雜度是O(n^2)級,然而理想的時間複雜度是O(nlogn)級別。

理想情況:

如果每次pivot恰好是中間大小元素,那每次資料規模都變為n/2。寫出時間複雜度的遞推式:
T(n) = 2T(n/2)+O(n) (O(n)是遍歷陣列的複雜度,T(n/2)是遞迴一個分支的複雜度)

利用Master公式:

T(N) = a*T(N/b) + O(N^d)

  • log(b,a) > d -> 複雜度為O(N^log(b,a))
  • log(b,a) = d -> 複雜度為O(N^d * logN)
  • log(b,a) < d -> 複雜度為O(N^d)

其中 a >= 1 and b > 1 是常量,其表示的意義是n表示問題的規模,a表示遞迴的次數也就是生成的子問題數,b表示每次遞迴是原來的1/b之一個規模,O(N^d)表示分解和合並等其他操作所要花費的時間複雜度。
使用前提是遞迴子問題規模相同。

可以得到,a=2,b=2,d=1,log(b,a)=d,複雜度為O(N^d * logN)也就是O(nlogn)

所以我們要做的就是盡力讓pivot每次都能選到陣列中間大小元素位置。

三點中值法

在low,high,midIndex(low和high的中間元素下標)之間,選一箇中間大小值作為主元。

優化一下雙向掃描分割槽法的patition函式:

private static int partition(int[] arr, int low, int high) {
        int i = low + 1, j = high;  //左右掃描指標
        int midIndex = low + ((high - low) >> 2);  //中間下標
        int midValueIndex = -1;  //中值的下標

        if ((arr[low] <= arr[midIndex] && arr[high] >= arr[midIndex]) || (arr[low] >= arr[midIndex] && arr[high] <= arr[midIndex]))
            midValueIndex = midIndex;
        else if ((arr[high] <= arr[low] && arr[midIndex] >= arr[low]) || (arr[high] >= arr[low] && arr[midIndex] <= arr[low]))
            midValueIndex = low;
        else midValueIndex = high;

        swap(arr,low,midValueIndex);  //交換中間大小值和low位的值,讓pivot依然位於low的位置,但其值變為中間大小值
        int pivot = arr[low];       //切分元素,設定基準值為從左向右第一個元素
        while (i <= j) {
            while (i <= j && arr[i] <= pivot) //掃描元素小於基準值,左指標右移(注意保證i<=j)
                i++;
            while (i <= j && arr[j] > pivot)  //掃描元素大於基準值,右指標左移(注意保證i<=j)
                j--;
            if (i < j)  //注意該處判斷
                swap(arr, i, j);    //左右指標指向元素交換
        }
        swap(arr, low, j);     //將基準值插入到相應位置,也就是把基準值和小於區域的最後一個元素交換。使得基準值右側就是大於區域
        return j;
    }

三點中值法使用比較廣。java中使用三點中值法。

絕對中值法

保證pivot是陣列的絕對中值
但會使複雜度的的常數因子擴大,有可能得不償失。

把陣列按照每五個元素為一組分組,使用插入排序選出每組中的中值,再把這些中值放到一個陣列中使用插入排序選出中值也就是pivot,將其與low的值做交換,保證程式剩下部分正常執行。

private static void quickSort(int[] arr, int low, int high) {
        if (arr == null || arr.length < 2 || high <= low) return;
        int j = partition(arr, low, high);  //切分
        quickSort(arr, low, j - 1);  //將左半部分arr[low...j-1]排序
        quickSort(arr, j + 1, high); //將右半部分arr[j+1...high]排序
}

private static int partition(int[] arr, int low, int high) {
        int i = low + 1, j = high;  //左右掃描指標
        int midValueIndex = getMedian(arr, low, high);
        swap(arr,midValueIndex,low);
        int pivot = arr[low];
        while (i <= j) {
            while (i <= j && arr[i] <= pivot) //掃描元素小於基準值,左指標右移(注意保證i<=j)
                i++;
            while (i <= j && arr[j] > pivot)  //掃描元素大於基準值,右指標左移(注意保證i<=j)
                j--;
            if (i < j)  //注意該處判斷
                swap(arr, i, j);    //左右指標指向元素交換
        }
        swap(arr, low, j);     //將基準值插入到相應位置,也就是把基準值和小於區域的最後一個元素交換。使得基準值右側就是大於區域
        return j;
}

private static int getMedian(int[] arr, int p, int r) {  //獲取中值方法
        int size = r - p + 1;  //陣列長度
        //每五個元素一組
        int groupSize = (size % 5 == 0) ? (size / 5) : (size / 5 + 1);
        //儲存各小組中值
        int medians[] = new int[groupSize];
        int indexOfMedians = 0;
        //對每一組進行插入排序
        for (int j = 0; j < groupSize; j++) {
            //單獨處理最後一組,因為最後一組可能不滿5個元素
            if (j == groupSize - 1) {
                InsertionSort(arr, p + j * 5, r);  //排序最後一組
                medians[indexOfMedians++] = arr[(p + j * 5 + r) / 2];  //最後一組的中間那個
            } else {
                InsertionSort(arr, p + j * 5, p + j * 5 + 4);  //排序非最後一個組的某個組
                medians[indexOfMedians++] = arr[p + j * 5 + 2];  //當前組(排序後)的中間那個
            }
        }
        InsertionSort(medians, 0, medians.length - 1);
        return medians[medians.length / 2];
}
private static void InsertionSort(int[] arr,int begin,int end){  //插入排序
        if(arr==null||arr.length<2) return;     //去除無效情況
        for(int i = begin+1; i < end; 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 a, int b) {
        int tmp = arr[a];
        arr[a] = arr[b];
        arr[b] = tmp;
}

用的較少,看需求。

待排序列表較短時,使用插入排序

插入排序的真實複雜度是n(n-1)/2,快速排序的真實複雜度是n(logn+1)
估計一下:

  • n<8 時用插入排序更快
  • n>8 時用快排更快
  public static void quickSort(int[] A, int p, int r) {
    if (p < r) {
      //待排序個數小於等於8的時候,插入排序
      if (p - r + 1 <= 8) {
        InsertionSort(A, p, r);   //插入排序
      } else {                    //快排
        int q = partition2(A, p, r);
        quickSort(A, p, q - 1);
        quickSort(A, q + 1, r);
      }
    }
  }

相關文章