【數據結構與算法】快速排序(三種代碼實現以及工程優化)


概念

快速排序是一種分治的排序算法。它將一個數組分成兩個子數組,將兩個部分獨立地排序。遞歸調用發生在處理整個數組之后。

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

image

代碼實現

單向掃描分區法

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 = partition(A, p, r);
        quickSort(A, p, q - 1);
        quickSort(A, q + 1, r);
      }
    }
  }

隨機快排

在數組范圍中,等概率隨機選一個數作為划分值,然后把數組分成三個部分:
左側<划分值、中間==划分值、右側>划分值

    public static void main(String[] args) {
        int[] arr = new int[]{2, 5, 3, 6, 1, 4};
        quickSort(arr, 0, arr.length - 1);
        System.out.println(Arrays.toString(arr));
    }

    public 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);
        quickSort(arr, j[1] + 1, high);
    }

    public static int[] partition(int[] arr, int low, int high) {
        int p = (int) Math.random() * (high - low + 1) + low;  //隨機選取一個基准值
        swap(arr, p, low);    //把選出的基准值和數組第一個數交換
        int i = low + 1;
        int j = high;
        int pivot = arr[low];
        int e = low + 1;
        while (i <= j) {
            if (arr[i] < pivot) {
                swap(arr, i, e);
                i++;
                e++;
            } else if (arr[i] == pivot) {
                i++;
            } else {
                swap(arr, i, j);
                j--;
            }
        }
        swap(arr, low, e - 1);
        return new int[]{e, j};
    }

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

題目

奇數在左

調整數組順序使奇數位於偶數前面:輸入一個整數數組,調整數組中數字的順序,使得所有奇數位於數組的前半部分,所有偶數位於數組的后半部分。要求時間復雜度為O(n)。

思路:利用雙向掃描分區思路,左右指針相向移動,左指針遇到偶數,右指針遇到奇數,左右指針所知指向元素交換,循環直到i>j。

第k個大的元素

以盡量高的效率求出一個亂序數組中按數值順序的第K個元素值

思路一:先用快速排序法把數組排序,取出第k個元素即可。時間復雜度O(n^2)

思路二(改進):利用快排的分區partition思想,三點取中法,每次求出一個pivot值將其與目標元素比較大小,pivot就是已經確定數組中絕對位置元素值(利用pivot下標可以計算出其是第幾大的數)。若小於目標元素,則只需要遞歸pivot左側數組;若大於目標元素,則只需要遞歸pivot右側數組。

時間復雜度的遞推式:
T(N)=T(n/2)+O(n)

利用Master公式
a = 1,b = 2,d = 1 log(b,a)<d,時間復雜度 O(n),為線性復雜度。

private static int selectK(int[] arr,int low,int high,int k){   //
        int q = partition(arr,low,high);   //調用雙向掃描分區法的partition方法
        int qk = q-low+1;  //主元是第幾大的元素
        if(qk==k) return arr[q];    //找到返回
        else if(qk>k) return selectK(arr,low,q-1,k);  //小於遞歸左邊
        else return selectK(arr,q+1,high,k-qk);   //大於遞歸右邊,注意k的相對位置變化,要改變坐標,表示右側數組第幾大元素
		//相當於剪掉了qk個元素,求第k-qk個元素
}

超過一半的數字

數組中有一個數字出現的次數超過了數組長度的一半,找出這個數字。

思路一:先用快排,求出中間大小元素即為答案。時間復雜度O(nlogn)

思路二:順序統計。時間復雜度O(n)
類比上一個題,就是選出k是第中間大的元素

//調用的時候傳參k=arr.length/2
private static int selectK(int[] arr,int low,int high,int k){   //
        int q = partition(arr,low,high);   //調用雙向掃描分區法的partition方法
        int qk = q-low+1;  //主元是第幾大的元素
        if(qk==k) return arr[q];    //找到返回
        else if(qk>k) return selectK(arr,low,q-1,k);  //小於遞歸左邊
        else return selectK(arr,q+1,high,k-qk);   //大於遞歸右邊,注意k的相對位置變化,要改變坐標,表示右側數組第幾大元素
		//相當於剪掉了qk個元素,求第k-qk個元素
}


免責聲明!

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



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