【算法】快速排序

快速排序(Quicksort)是对冒泡排序的一种改进,由C.A.R.Hoare1962年提出的一种划分交换排序,采用的是分治策略(一般与递归结合使用),以减少排序过程中的比较次数。

一、基本思想

快速排序的基本思想:挖坑填数 + 分治法

首先选一个轴值(pivot,也有叫基准的),通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

二、算法描述

快速排序使用分治策略来把一个序列(list)分为两个子序列(sub-lists)。步骤为:

  1. 从数列中挑出一个元素,称为"基准"(pivot)。
  2. 重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
  3. 递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。

递归到最底部时,数列的大小是零或一,也就是已经排序好了。这个算法一定会结束,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。

quick-sort09.gif

三、算法实现

假设被排序的无序区间为[A[i],......,A[j]]

一、基准元素选取:选择其中的一个记录的关键字v作为基准元素(控制关键字);怎么选取关键字?
二、划分:通过基准元素v把无序区间A[I]......A[j]划分为左右两部分,使得左边的各记录的关键字都小于v;右边的各记录的关键字都大于等于v(如何划分?)
三、递归求解:重复上面的一、二步骤,分别对左边和右边两部分递归进行快速排序。
四、组合:左、右两部分均有序,那么整个序列都有序。

上面的第三、四步不用多说,主要是第一步怎么选取关键字,从而实现第二步的划分?

划分的过程涉及到三个关键字:“基准元素”、“左游标”、“右游标”

基准元素:它是将数组划分为两个子数组的过程中,用于界定大小的值,以它为判断标准,将小于它的数组元素“划分”到一个“小数值的数组”中,而将大于它的数组元素“划分”到一个“大数值的数组”中,这样,我们就将数组分割为两个子数组,而其中一个子数组的元素恒小于另一个子数组里的元素。
左游标:它一开始指向待分割数组最左侧的数组元素,在排序的过程中,它将向右移动。
右游标:它一开始指向待分割数组最右侧的数组元素,在排序的过程中,它将向左移动。

注意:上面描述的基准元素/右游标/左游标都是针对单趟排序过程的,也就是说,在整体排序过程的多趟排序中,各趟排序取得的基准元素/右游标/左游标一般都是不同的。

对于基准元素的选取,原则上是任意的。但是一般我们选取数组中第一个元素为基准元素(假设数组是随机分布的)

四、算法图示

上面表示的是一个无序数组,选取第一个元素6作为基准元素。左游标是i哨兵,右游标是j哨兵。然后左游标向左移动,右游标向右移动,它们遵循的规则如下:

一、左游标扫描,跨过所有小于基准元素的数组元素,直到遇到一个大于或等于基准元素的数组元素,在那个位置停下
二、右游标扫描,跨过所有大于基准元素的数组元素,直到遇到一个小于或等于基准元素的数组元素,在那个位置停下。

第一步:哨兵j先开始出动。因为此处设置的基准数是最左边的数,所以需要让哨兵j先开始出动,哨兵j一步一步的向左挪动,直到找到一个小于6的元素停下来。接下来,哨兵i再一步一步的向右挪动,直到找到一个大于6的元素停下来。最后哨兵i停在了数字7面前,哨兵j停在了数字5面前。

到此,第一次交换结束,接着哨兵j继续向左移动,它发现4比基准数6要小,那么在数字4面前停下来。哨兵i也接着向右移动,然后在数字9面前停下来,然后哨兵i和哨兵j再次进行交换。

第二次交换结束,哨兵j继续向左移动,然后在数字3面前停下来;哨兵i继续向右移动,但是它发现和哨兵j相遇了。那么此时说明探测结束,将数字3和基准数字6进行交换,如下:

到此,第一次探测真正结束,此时已基准点6为分界线,6左边的数组元素都小于等于66右边的数组元素都大于等于6

左边序列为【3,1,2,5,4】,右边序列为【9,7,10,8】。接着对于左边序列而言,以数字3为基准元素,重复上面的探测操作,探测完毕之后的序列为【2,1,3,5,4】;对于右边序列而言,以数字9位基准元素,也重复上面的探测操作。然后一步一步的划分,最后排序完全结束。

通过这一步一步的分解,我们发现快速排序的每一轮操作就是将基准数字归位,知道所有的数都归位完成,排序就结束了。

五、代码实现

5.1 实现代码1

用伪代码描述如下:

①. i = L; j = R; 将基准数挖出形成第一个坑a[i]
②.j--,由后向前找比它小的数,找到后挖出此数填前一个坑a[i]中。
③.i++,由前向后找比它大的数,找到后也挖出此数填到前一个坑a[j]中。
④.再重复执行②,③二步,直到i==j,将基准数填入a[i]

200px-Partition_example.svg.png
快速排序采用“分而治之、各个击破”的观念,此为原地(In-place)分区版本。

/**
 * 快速排序(递归)
 * <p>
 * ①. 从数列中挑出一个元素,称为"基准"(pivot)。
 * ②. 重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任一边)。
 *       在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
 * ③. 递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
 *
 * @param arr  待排序数组
 * @param low  左边界
 * @param high 右边界
 */
public static void quickSort(int[] arr, int low, int high) {
    if (arr.length <= 0) return;
    if (low >= high) return;
    int left = low;
    int right = high;

    int temp = arr[left];   //挖坑1:保存基准的值
    while (left < right) {
        while (left < right && arr[right] >= temp) {  //坑2:从后向前找到比基准小的元素,插入到基准位置坑1中
            right--;
        }
        arr[left] = arr[right];
        while (left < right && arr[left] <= temp) {   //坑3:从前往后找到比基准大的元素,放到刚才挖的坑2中
            left++;
        }
        arr[right] = arr[left];
    }
    arr[left] = temp;   //基准值填补到坑3中,准备分治递归快排
    System.out.println("Sorting: " + Arrays.toString(arr));
    quickSort(arr, low, left - 1);
    quickSort(arr, left + 1, high);
}

上面是递归版的快速排序:通过把基准temp插入到合适的位置来实现分治,并递归地对分治后的两个划分继续快排。那么非递归版的快排如何实现呢?

因为递归的本质是栈,所以我们非递归实现的过程中,可以借助栈来保存中间变量就可以实现非递归了。在这里中间变量也就是通过Pritation函数划分区间之后分成左右两部分的首尾指针,只需要保存这两部分的首尾指针即可。

/**
 * 快速排序(非递归)
 * <p>
 * ①. 从数列中挑出一个元素,称为"基准"(pivot)。
 * ②. 重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任一边)。
 *       在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
 * ③. 把分区之后两个区间的边界(low和high)压入栈保存,并循环①、②步骤
 *
 * @param arr 待排序数组
 */
public static void quickSortByStack(int[] arr) {
    if (arr.length <= 0) return;
    Stack<Integer> stack = new Stack<Integer>();

    //初始状态的左右指针入栈
    stack.push(0);
    stack.push(arr.length - 1);
    while (!stack.isEmpty()) {
        int high = stack.pop();     //出栈进行划分
        int low = stack.pop();

        int pivotIdx = partition(arr, low, high);

        //保存中间变量
        if (pivotIdx > low) {
            stack.push(low);
            stack.push(pivotIdx - 1);
        }
        if (pivotIdx < high && pivotIdx >= 0) {
            stack.push(pivotIdx + 1);
            stack.push(high);
        }
    }
}

private static int partition(int[] arr, int low, int high) {
    if (arr.length <= 0) return -1;
    if (low >= high) return -1;
    int l = low;
    int r = high;

    int pivot = arr[l];    //挖坑1:保存基准的值
    while (l < r) {
        while (l < r && arr[r] >= pivot) {  //坑2:从后向前找到比基准小的元素,插入到基准位置坑1中
            r--;
        }
        arr[l] = arr[r];
        while (l < r && arr[l] <= pivot) {   //坑3:从前往后找到比基准大的元素,放到刚才挖的坑2中
            l++;
        }
        arr[r] = arr[l];
    }
    arr[l] = pivot;   //基准值填补到坑3中,准备分治递归快排
    return l;
}

5.2 实现代码2

public class QuickSort {
     
    //数组array中下标为i和j位置的元素进行交换
    private static void swap(int[] array, int i, int j){
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
     
    private static void recQuickSort(int[] array, int left, int right){
        if(right <= left) {
            return;//终止递归
        } else {
            int partition = partitionIt(array,left,right);
            recQuickSort(array,left,partition-1);// 对上一轮排序(切分)时,基准元素左边的子数组进行递归
            recQuickSort(array,partition+1,right);// 对上一轮排序(切分)时,基准元素右边的子数组进行递归
        }
    }
     
    private static int partitionIt(int[] array,int left,int right){
        //为什么 j加一个1,而i没有加1,是因为下面的循环判断是从--j和++i开始的.
        //而基准元素选的array[left],即第一个元素,所以左游标从第二个元素开始比较
        int i = left;
        int j = right+1;
        int pivot = array[left];// pivot 为选取的基准元素(头元素)
        while(true){
            while(i<right && array[++i] < pivot){}
             
            while(j > 0 && array[--j] > pivot){}
             
            if(i >= j){// 左右游标相遇时候停止, 所以跳出外部while循环
                break;
            }else{
                swap(array, i, j);// 左右游标未相遇时停止, 交换各自所指元素,循环继续
            }
        }
        swap(array, left, j);//基准元素和游标相遇时所指元素交换,为最后一次交换
        return j;// 一趟排序完成, 返回基准元素位置(注意这里基准元素已经交换位置了)
    }
     
    public static void sort(int[] array){
        recQuickSort(array, 0, array.length-1);
    }
     
    //测试
    public static void main(String[] args) {
        //int[] array = {7,3,5,2,9,8,6,1,4,7};
        int[] array = {9,9,8,7,6,5,4,3,2,1};
        sort(array);
        for(int i: array){
            System.out.print(i+" ");
        }
        //打印结果为:1 2 3 4 5 6 7 7 8 9
    }
}

5.2.1 优化分析

假设我们是对一个逆序数组进行排序,选取第一个元素作为基准点,即最大的元素是基准点,那么第一次循环,左游标要执行到最右边,而右游标执行一次,然后两者进行交换。这也会划分成很多的子数组。

那么怎么解决呢?理想状态下,应该选择被排序数组的中值数据作为基准,也就是说一半的数大于基准数,一般的数小于基准数,这样会使得数组被划分为两个大小相等的子数组,对快速排序来说,拥有两个大小相等的子数组是最优的情况。

三项取中划分

为了找到一个数组中的中值数据,一般是取数组中第一个、中间的、最后一个,选择这三个数中位于中间的数。

//取数组下标第一个数、中间的数、最后一个数的中间值
private static int medianOf3(int[] array,int left,int right){
    int center = (right-left)/2+left;
    if(array[left] > array[right]){ //得到 array[left] < array[right]
        swap(array, left, right);
    }
    if(array[center] > array[right]){ //得到 array[left] array[center] < array[right]
        swap(array, center, right);
    }
    if(array[center] > array[left]){ //得到 array[center] <  array[left] < array[right]
        swap(array, center, left);
    }
     
    return array[left]; //array[left]的值已经被换成三数中的中位数, 将其返回
}
private static int partitionIt(int[] array,int left,int right){
    //为什么 j加一个1,而i没有加1,是因为下面的循环判断是从--j和++i开始的.
    //而基准元素选的array[left],即第一个元素,所以左游标从第二个元素开始比较
    int i = left;
    int j = right+1;
    int pivot = array[left];// pivot 为选取的基准元素(头元素)
     
    int size = right - left + 1;
    if(size >= 3){
        pivot = medianOf3(array, left, right); //数组范围大于3,基准元素选择中间值。
    }
    while(true){
        while(i<right && array[++i] < pivot){}
         
        while(j > 0 && array[--j] > pivot){}
         
        if(i >= j){// 左右游标相遇时候停止, 所以跳出外部while循环
            break;
        }else{
            swap(array, i, j);// 左右游标未相遇时停止, 交换各自所指元素,循环继续
        }
    }
    swap(array, left, j);//基准元素和游标相遇时所指元素交换,为最后一次交换
    return j;// 一趟排序完成, 返回基准元素位置(注意这里基准元素已经交换位置了)
}

处理小划分

如果使用三数据取中划分方法,则必须遵循快速排序算法不能执行三个或者少于三个的数据,如果大量的子数组都小于3个,那么使用快速排序是比较耗时的。联想到前面我们讲过简单的排序(冒泡、选择、插入)。

当数组长度小于M的时候(high-low <= M),不进行快排,而进行插入排序。转换参数M的最佳值和系统是相关的,一般来说,5到15间的任意值在多数情况下都能令人满意。

//插入排序
private static void insertSort(int[] array){
    for(int i = 1 ; i < array.length ; i++){
        int temp = array[i];
        int j = i;
        while(j > 0 && array[j-1] > temp){
            array[j] = array[j-1];
            j--;
        }
        array[j] = temp;
    }
}

六、性能分析

快速排序是通常被认为在同数量级(O(nlog2n))的排序方法中平均性能最好的。但若初始序列按关键码有序或基本有序时,快排序反而蜕化为冒泡排序。为改进之,通常以“三者取中法”来选取基准记录,即将排序区间的两个端点与中点三个记录关键码居中的调整为支点记录。快速排序是一个不稳定的排序方法。

以下是快速排序算法复杂度:

平均时间复杂度 最好情况 最坏情况 空间复杂度
O(nlog₂n) O(nlog₂n) O(n²) O(1)(原地分区递归版)

快速排序排序效率非常高。虽然它运行最糟糕时将达到O(n²)的时间复杂度,但通常平均来看,它的时间复杂为O(nlogn),比同样为O(nlogn)时间复杂度的归并排序还要快。快速排序似乎更偏爱乱序的数列,越是乱序的数列,它相比其他排序而言,相对效率更高。

Tips: 同选择排序相似,快速排序每次交换的元素都有可能不是相邻的,因此它有可能打破原来值为相同的元素之间的顺序。因此,快速排序并不稳定。

七、双路快速排序

7.1 概念及其介绍

双路快速排序算法是随机化快速排序的改进版本,partition过程使用两个索引值(i、j)用来遍历数组,将<v的元素放在索引i所指向位置的左边,而将>v的元素放在索引j所指向位置的右边,v代表标定值。

7.2 适用说明

时间和空间复杂度同随机化快速排序。 对于有大量重复元素的数组,如果使用上一节随机化快速排序效率是非常低的,导致partition后大于基点或者小于基点数据的子数组长度会极度不平衡,甚至会退化成O(n*2)时间复杂度的算法,对这种情况可以使用双路快速排序算法。

7.3 过程图示

使用两个索引值(i、j)用来遍历我们的序列,将 <=v 的元素放在索引 i 所指向位置的左边,而将 >=v 的元素放在索引 j 所指向位置的右边,平衡左右两边子数组。

7.4 代码实现

/**
 * 双路快速排序
 */
public class QuickSort2Ways {

    //核心代码---开始
    private static int partition(Comparable[] arr, int l, int r) {
        // 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
        swap(arr, l, (int) (Math.random() * (r - l + 1)) + l);
        Comparable v = arr[l];
        // arr[l+1...i) <= v; arr(j...r] >= v
        int i = l + 1, j = r;
        while (true) {
            while (i <= r && arr[i].compareTo(v) < 0)
                i++;
            while (j >= l + 1 && arr[j].compareTo(v) > 0)
                j--;
            if (i > j)
                break;
            swap(arr, i, j);
            i++;
            j--;
        }
        swap(arr, l, j);
        return j;
    }
    //核心代码---结束

    // 递归使用快速排序,对arr[l...r]的范围进行排序
    private static void sort(Comparable[] arr, int l, int r) {
        if (l >= r) {
            return;
        }
        int p = partition(arr, l, r);
        sort(arr, l, p - 1);
        sort(arr, p + 1, r);
    }

    public static void sort(Comparable[] arr) {
        int n = arr.length;
        sort(arr, 0, n - 1);
    }

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

    // 测试 QuickSort
    public static void main(String[] args) {
        // 双路快速排序算法也是一个O(nlogn)复杂度的算法
        // 可以在1秒之内轻松处理100万数量级的数据

        // Quick Sort也是一个O(nlogn)复杂度的算法
        // 可以在1秒之内轻松处理100万数量级的数据
        int N = 1000000;
        Integer[] arr = SortTestHelper.generateRandomArray(N, 0, 100000);
        sort(arr);
        SortTestHelper.printArray(arr);
    }
}

八、三路快速排序

8.1 概念及其介绍

三路快速排序是双路快速排序的进一步改进版本,三路排序算法把排序的数据分为三部分,分别为小于 v,等于 v,大于 v,v 为标定值,这样三部分的数据中,等于 v 的数据在下次递归中不再需要排序,小于 v 和大于 v 的数据也不会出现某一个特别多的情况),通过此方式三路快速排序算法的性能更优。

8.2 适用说明

时间和空间复杂度同随机化快速排序。

三路快速排序算法是使用三路划分策略对数组进行划分,对处理大量重复元素的数组非常有效提高快速排序的过程。它添加处理等于划分元素值的逻辑,将所有等于划分元素的值集中在一起。

8.3 过程图示

我们分三种情况进行讨论 partiton 过程,i 表示遍历的当前索引位置:

(1)当前处理的元素 e=V,元素 e 直接纳入蓝色区间,同时i向后移一位。

(2)当前处理元素 e<v,e 和等于 V 区间的第一个位置数值进行交换,同时索引 lt 和 i 都向后移动一位

(3)当前处理元素 e>v,e 和 gt-1 索引位置的数值进行交换,同时 gt 索引向前移动一位。

最后当 i=gt 时,结束遍历,同时需要把 v 和索引 lt 指向的数值进行交换,这样这个排序过程就完成了,然后对 <V 和 >V 的数组部分用同样的方法再进行递归排序。

8.4 代码实现

/**
 * 三路快速排序
 */
public class QuickSort3Ways {
    //核心代码---开始
    // 递归使用快速排序,对arr[l...r]的范围进行排序
    private static void sort(Comparable[] arr, int l, int r) {
        if (l >= r) {
            return;
        }
        // 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
        swap(arr, l, (int) (Math.random() * (r - l + 1)) + l);
        Comparable v = arr[l];
        int lt = l;     // arr[l+1...lt] < v
        int gt = r + 1; // arr[gt...r] > v
        int i = l + 1;    // arr[lt+1...i) == v
        while (i < gt) {
            if (arr[i].compareTo(v) < 0) {
                swap(arr, i, lt + 1);
                i++;
                lt++;
            } else if (arr[i].compareTo(v) > 0) {
                swap(arr, i, gt - 1);
                gt--;
            } else { // arr[i] == v
                i++;
            }
        }
        swap(arr, l, lt);
        sort(arr, l, lt - 1);
        sort(arr, gt, r);
    }
    //核心代码---结束

    public static void sort(Comparable[] arr) {
        int n = arr.length;
        sort(arr, 0, n - 1);
    }

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

    // 测试 QuickSort3Ways
    public static void main(String[] args) {
        // 三路快速排序算法也是一个O(nlogn)复杂度的算法
        // 可以在1秒之内轻松处理100万数量级的数据
        int N = 1000000;
        Integer[] arr = SortTestHelper.generateRandomArray(N, 0, 100000);
        sort(arr);
        SortTestHelper.printArray(arr);
    }
}
posted @ 2022-06-24 11:55  夏尔_717  阅读(268)  评论(0编辑  收藏  举报