Quicksort

Quicksort  (sometimes called  partition-exchange sort ) is an efficient sorting algorithm, serving as a systematic method for placing the elements of a random access file or an array in order. Developed by British computer scientist  Tony Hoare  in 1959 and published in 1961 it is still a commonly used algorithm for sorting. When implemented well, it can be about two or three times faster than its main competitors, merge sort and heapsort .

Quicksort is a comparision sort, meaning that it can sort items of any type for which a "less-than" relation (formally, a total order) is defined. In efficient implementations it is not a stable sort , meaning that the relative order of equal sort items is not preserved. Quicksort can operate in-place on an arra(原地排序), requiring small additional amounts of memory to perform the sorting. It is very similar to selection sort, except that it does not always choose worst- case partition.

Mathematical analysis of quicksort shows that, on average, the algorithm takes O( n  log  n ) comparisons to sort  n  items. In the worst case, it makes O( n^ 2) comparisons, though this behavior is rare.

我们今天要讨论的算法是快速排序,快速排序应该是使用最广泛的算法了。快速排序流行的原因在于该算法实现起来简单,对于不同特征类型的数据(完全随机、近乎有序、大量重复)且在一般应用中都比其他的排序算法快得多。快速排序吸引人的特定是它的是它的时间复杂度为O(nlogn)并且它是原地排序(只需要一个很小的辅助空间),其他排序算法都无法将这两个特性结合起来。它的缺点是非常脆弱,需要十分小心才能避免低劣的性能(In the worst case, it makes O( n^ 2) comparisons, though this behavior is rare)。快速排序是一种分治算法,它先将一个很大的数组划分成两个子数组,然后递归的分别对这两个子数组进行排序。步骤如下:

  1. 在待排序的数组中挑选一个元素作为枢纽(pivot)。
  2. 对数组进行划分,将比枢纽值小的元素移动到数组的左侧,比枢纽值大的元素移动到数组的右侧,这样一来,枢纽值就被放入到来正确的位置。整个数组被枢纽值分成了两个子数组,枢纽值左边的子数组中的元素值都比枢纽值小,枢纽值右边的子数组中的元素值比枢纽值大。这步操作可以被称之为partition operation。
  3. 然后递归的对两个子数组执行步骤1 、2。

递归的终止条件是每个子数组的元素个数为0或者1时,显然数组是有序的。步骤二中的pivot的选择和partition operation可以通过不同的方式来实现,不同的实现方式会很大程度上影响最终的Quicksort算法的性能。Quicksort算法的核心在于partition operation 和如何选择pivot,因此我们值得花些时间了解不同的方案,这些方案使用了不同的实现方式,也深远的影响了算法的性能。

  • Lomuto partition scheme

该方案是由Nico Lomuto提出并在Bentley的书籍《Programming Pearls》和  Cormen  et al的《算法导论》被熟知。 This scheme chooses a pivot that is typically the last element in the array.The algorithm maintains index i as it scans the array using another index j such that the elements lo through i-1 (inclusive) are less than the pivot, and the elements i through j (inclusive) are equal to or greater than the pivot. 

该方案在选择pivot时典型的选择数组中的第一个(最后一个)元素。对于数组[lt......rt],选取索引为lt的元素作为pivot,算法维护一个变量i用于从索引为lt+1开始遍历数组,维护变量j,在区间[lt+1. .....j]的元素的值都小于pivot。在变量i遍历数组时,如果元素的值小于pivot,那么i指向的元素和j的下一个位置的元素交换,同时不增变量i,j。当变量i指向的元素大于等于pivot时,变量i直接步增。当遍历完成整个数组时,数组[lt+1.....j]中的所有元素的值都小于pivot,数组[lj+1.....rt]中的所有元素的值都大于等于pivot。再交换索引lt和索引j的元素,因为索引j就是pivot在最终排序完成的数组中应该在的位置。通过这样划分,数组[lt......rt]就被划分为[lt......j-1]和[j+1......rt]两部分。然后在对这两个子数组重复这些工作,就能得出最后的排序数组。

输入: KRATELEPUIMQCXOS

切分: ECA I E  K LPUTMQRXOS

将左边排序: ACEEI 

将左边排序: LMOPQRSTUX

结果: ACEEIKLMOPQRSTUX

算法实现:

public void sort(int[] array, int lt, int rt) {
        if(lt < rt){ //递归终止条件
            int p = partition(array,lt,rt);
            sort(array,lt,p-1);
            sort(array,p+1,rt);
        }
    }


    private int partition(int[] array, int lt, int rt) {
        int temp = array[lt];//选择最左边的元素作为pivot
        int left = lt;  //[lt+1......left]的元素都小于pivot
        for (int i = lt+1; i <=rt ; i++) { //变量i用于扫描整个数组[lt+1......rt]
            if(array[i] < temp){  
                swap(array,++left,i);//交换数组中两个索引出的元素
            }
        }
        swap(array,left,lt);//交换数组中两个索引出的元素
        return left;
    }

这种方案可能会退化为O(n^2)的时间复杂度,因为每次选择的是第一个元素,因此对于一组降序排序的数据,每次都选择第一个元素作为pivot,则会进行O(n^2)次比较。鉴于此可以进行改进,使用随机算法选择pivot,而不是每次选择第一个元素(最后一个元素)作为pivot。改进如下:

 private int partition(int[] array, int lt, int rt) {
        /*随机算法确定pivot的位置,并将它交换到数组的起始位置*/
        int left = ((int)(Math.random()*(rt - lt- 1) + lt)) ;
        swap(array,lt,left);
        /*选择最左边的元素作为pivot,pivot是随机选择的*/
        int temp = array[lt];
        /*[lt+1......left]的元素都小于pivot*/
        int left = lt;  
        for (int i = lt+1; i <=rt ; i++) { /*变量i用于扫描整个数组[lt+1......rt]*/
            if(array[i] < temp){  
                /*交换数组中两个索引出的元素*/
                swap(array,++left,i);
            }
        }
        /*交换数组中两个索引出的元素*/
        swap(array,left,lt);
        return left;
    }

 

这种实现方式又称之为一路快速排序,一个很严重的问题是经过划分后,大多数情况下会出现左右两边不平衡的情况(图a)。因此我们需要想办法让两个子数组的分布相对而言更加均衡(图b)

  • Hoare partition scheme

The original partition scheme described by CAR Hoare uses two indices that start at the ends of the array being partitioned, then move toward each other, until they detect an inversion: a pair of elements, one greater than or equal to the pivot, one lesser or equal, that are in the wrong order relative to each other. The inverted elements are then swapped.When the indices meet, the algorithm stops and returns the final index. Hoare's scheme is more efficient than Lomuto's partition scheme because it does three times fewer swaps on average , and it creates efficient partitions even when all values are equal.Like Lomuto's partition scheme, Hoare's partitioning also would cause Quicksort to degrade to  O ( n2) for already sorted input, if the pivot was chosen as the first or the last element. With the middle element as the pivot, however, sorted data results with (almost) no swaps in equally sized partitions leading to best case behavior of Quicksort , ie  O ( n  log( n )). Like others, Hoare's partitioning doesn't produce a stable sort. Note that in this scheme, the pivot's final location is not necessarily at the index that was returned, and the next two segments that the main algorithm recurs on are (lo..p) and (p+1..hi) as opposed to (lo..p-1) and (p+1..hi) as in Lomuto's scheme. However, the partitioning algorithm guarantees lo ≤ p < hi which implies both resulting partitions are non-empty, hence there's no risk of infinite recursion.

该方案使用两个指针i,j分别从数组的两端向中间移动,知道指针i,j相遇。对于指针i,如果指向的元素的值小于等于pivot,那么指针i继续向后移动并且没有和指针j相遇,直到指向的元素大于pivot。同理指针j指向的元素如果大于等于pivot并且没有和指针i相遇,那么指针j继续向前移动,直到指向的元素小于pivot。现在,指针i和指针j都停下了,原因有两个。1、指针i和指针j相遇了,那么扫描工作结束,直接将pivot放入合适的索引处(i or j)。2、指针i指向的元素大于pivot,指针j指向的元素小于pivot,此时交换指针i处和指针j处的元素,并将指针i向右移,将指针j向左移,重复上前面的工作。这种方式我们称之为2路快速排序。代码如下。

//这里只给出partition时的代码,其他的代码与上面的scheme一致

 private int partition(int[] array, int left, int right) {

        //使用随机算法避免每次选择第一个或者最后一个元素作为pivot
        swap(array,left,(int)(Math.random()*(right-left-1) + left));

        //指针lt rt用于从数组两端向中间变量数组元素
        int lt = left + 1,rt = right;
        while (true){
            while ((lt <= rt) && (array[lt] < array[left])){
                lt++;
            }
            while ((lt <= rt) && (array[rt] > array[left])){
                rt--;
            }
            if(lt > rt) break;
            swap(array,lt++,rt--);
        }
        //lt 与 rt相遇,算结束,此时rt为pivot的最终位置,因次交换left,rt处的元素
        swap(array,left,rt);
        return rt;
    }

初始序列: 35 27 9 0 28 15 0 38 27 18    

使用随机算法选择pivot: 27 27 9 0 28 15 0 38 35 18

扫描左右部分: 27 27 9 0     28     15 0 38 35    18

交换: 27 27 9 0     18     15 0 38 35    28

扫描左右部分: 27 27 9 0 18 15 0 38     35   28     

交换: 27 27 9 0 18 15 0 38     35   28     

最后一次交换:                          27     27 9 0 18 15     0    35 38     28

结果:                                       0     27 9 0 18 15     27    35 38     28

以pivot为分界将原数组分成两个子数组,在两个子数组进行相同的工作。对比Lomuto's scheme,我们可以看出左右两个子数组更加平衡。

  • 三路快速排序

在前面两种实现中我们可以看到,子数组包含了一些等于pivot的元素,实际上这些元素不需要排序,因此我们可以作如下的改进。实际需要的处理数据通常也包含大量重复的元素,因此如果我们能够对序列作如下划分,那么我们的排序工作工作量将大大减少,因为很多元素已经排好序,我们使用劲量少的排序工作就能使得整个序列都是有序的。它左到右遍历数组一次,使用职指针i进行遍历。维护一个指针lt使得array[left+1,lt]中的元素都小于V,一个指针rt使得array[rt,right]中的元素都大于V,array[lt+1,i-1]中的元素都等于V,[i,rt-1]为待考察的元素。对于每个元素作如下考察:

  1. array[i] 小于V,交换array[lt+1]和array[i],将lt和i加一。
  2. array[i] 等于V,将i加一。
  3. array[i] 打于V,交换array[rt-1]和array[i],将rt减一。

当最终指针i和指针rt相遇时,遍历数组的工作就完成了,整个数组被分成三部分【left+1,lt】 | 【lt+1 ,rt-1】 | 【rt,right】以及一个位于left位置的V。交换array[left]和array[lt]使得数组被分成【left,lt-1】 | 【lt ,rt-1】 | 【rt,right】这样三部分,在【lt,rt-1】中的元素都不需要在进行排序,这对于包含大量重复元素的序列能够减少很多次的递归。下面是该算法的实现:

 public void sort(int[] array, int lt, int rt) {
        if(lt < rt){
            int[] indexes = partition(array,lt,rt);
            /*递归的排序子数组*/
            sort(array,lt,indexes[0]);
            sort(array,indexes[1],rt);
        }
    }



    /**
     *  lt是数组中指向小于pivot的最后一个元素,gt指向数组中大于pivot的第一个元素,
     *  [left+1......lt]都小于piovt,[rt.....right]都大于pivot
     * @param array 待排序的数组
     * @param left  待排序的数组的左边界
     * @param right 待排序的数组的右边界
     * @return 两个分界索引
     */
    private int[] partition(int[] array, int left, int right) {
        /*使用随机算法避免快速排序算法退化成O(n^2)*/
        swap(array,left,(int)(Math.random()*(right-left-1) + left));
        int lt = left  ,rt = right+1;
        int i = left + 1;
        while (i < rt){
            if(array[i] < array[left]){
                swap(array,++lt,i++);
            }else if(array[i] > array[left]){
                swap(array,i,--rt);
            }else {
                i++;
            }
        }
        /*[left+1,lt] < array[left]   [rt,right] > array[left]*/
        swap(array,left,lt);
        //[left,lt-1] < array[left] | [rt,right] > array[left] | [lt,rt-1] = pivot
        /*这里返回两个索引,只需要在对[left.... lt-1]和[rt......left]再次排序就好了, 
        [lt.......rt-1]都中的元素已经排序完成,因此返回lt-1和rt*/
        return new int[]{lt-1,rt};
    }

快速排序和归并排序是互补的:归并排序将数组分成两个子数组并分别进行排序,并将有序的子数组归使得整个数组有序。而快速排序的排序方式是,两个子数组是有序的,那么整个数组也就是有序的。

下面我们通过一个简单的性能测试来感性的认识下对于三种方式的快速排序和归并排序的性能。对于归并排序,三种类型的数据(完全随机、近乎有序、大量重复),都能做到很好的性能。对于一路快速排序,在面对大量重复元素时性能退化为了O(n^2),其原因是对于数组的划分出现了极度不平衡的情况。二路快速排序和三路快速排序都表现处理极好的性能,三路快速排序在面对大量重复类型数据时性能表项最好,因为省去了很多不必要的排序(特别的,如果序列是完全有序的,三路快排排序时间为O(n))。因此我们也可以看出,归并排序是中规中矩的,不会出现性能特别好或者性能特别不好的情况。对于快速排序,需要小心避开其算法的缺陷,才能使得算法的性能有很大的提升。前面也提到过,影响快速排序的两个极为重要的操作是:对于pivot的选择和partition operation,对于这两个操作进行很好的优化(使用随机化算法选择pivot、二路快排、三路快排)能够大大的提升快速排序的性能。

**********************************************************************************
     * 测试用例为一组近乎有序的数组(对有序序列进行少量次交换),元素个数 = 5000000,交换次数 = 100       
     * mergeSort3 排序 5000000 个元素共耗时:0.451263341s                                              
     * 排序结果:true                                                                                  
     * ---------------------------------------------------------------------------
     * 测试用例为一组随机元素的数组 ,元素个数 = 5000000
     * mergeSort3 排序 5000000 个元素共耗时:0.782915417s
     * 排序结果:true
     * ---------------------------------------------------------------------------
     * 测试用例为一组包含大量重复元素的数组,元素个数 = 5000000,数组元素值的范围 [10,20]
     * mergeSort3 排序 5000000 个元素共耗时:0.435751621s
     * 排序结果:true

     
     **********************************************************************************
     * 测试用例为一组近乎有序的数组(对有序序列进行少量次交换),元素个数 = 5000000,交换次数 = 100
     * quickSortWay1 排序 5000000 个元素共耗时:0.458459057s
     * 排序结果:true
     * ---------------------------------------------------------------------------
     * 测试用例为一组随机元素的数组 ,元素个数 = 5000000
     * quickSortWay1 排序 5000000 个元素共耗时:0.660962532s
     * 排序结果:true
     * ---------------------------------------------------------------------------
     * 测试用例为一组包含大量重复元素的数组,元素个数 = 5000000,数组元素值的范围 [10,20]
     * quickSortWay1 排序 5000000 个元素共耗时:530.58539322s
     * 排序结果:true

     **********************************************************************************
     * 测试用例为一组近乎有序的数组(对有序序列进行少量次交换),元素个数 = 5000000,交换次数 = 100
     * quickSortWays2 排序 5000000 个元素共耗时:0.329605479s
     * 排序结果:true
     * ---------------------------------------------------------------------------
     * 测试用例为一组随机元素的数组 ,元素个数 = 5000000
     * quickSortWays2 排序 5000000 个元素共耗时:0.788670265s
     * 排序结果:true
     * ---------------------------------------------------------------------------
     * 测试用例为一组包含大量重复元素的数组,元素个数 = 5000000,数组元素值的范围 [10,20]
     * quickSortWays2 排序 5000000 个元素共耗时:0.403020168s
     * 排序结果:true

     **********************************************************************************
     *测试用例为一组近乎有序的数组(对有序序列进行少量次交换),元素个数 = 5000000,交换次数 = 100
     * quickSortWays3 排序 5000000 个元素共耗时:0.499891878s
     * 排序结果:true
     * ---------------------------------------------------------------------------
     * 测试用例为一组随机元素的数组 ,元素个数 = 5000000
     * quickSortWays3 排序 5000000 个元素共耗时:0.783184326s
     * 排序结果:true
     * ---------------------------------------------------------------------------
     * 测试用例为一组包含大量重复元素的数组,元素个数 = 5000000,数组元素值的范围 [10,20]
     * quickSortWays3 排序 5000000 个元素共耗时:0.083839168s
     * 排序结果:true
  • O(n^2)和O(nlogn)比较

介绍的排序算法中,插入排序、冒泡排序、选择排序的时间复杂度为O(n^2),归并排序和快速排序是O(nlogn)。我们可以通过如下表格看出随着n值的不断增大,n^2和nlogn的差距。随着n越来越大,n^2和nlogn的差距越拉越大,这体现在排序算法上可能是对于排序一组100000个元素的系列,使用O(nlogn)的排序算法可能需要1s,而使用O(n^2)的排序算法可能需要大约6000s。并且这还只是在n=100000的情况下,那么对于n=100000,10000000时,这种差距将会体现的更加明显。同时,通过这个问题说明了对于原本可能需要花很长很长的时间才能完成的任务,在我们对算法进行认真分析改进后,可能在极端的时间内就解决了问题,这或许也就是算法本身的魅力。不断完善算法,使原本低效的工作能够高效的完成。

 n^2nlognfaster
n=10100333
n=1001000066415
n=100010^69966100
n=1000010^8132877753
n=10000010^1016609646020
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值