快速排序的JAVA实现、优化和应用

基本概念

快速排序(Quicksort)是对冒泡排序的一种改进。快速排序由C. A. R. Hoare在1962年提出。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

算法思想

基本思想是分治的思想,说到分治,就应该想到和递归是分不开的。通过一趟排序将要排序的记录分割成两部分,一部分的关键字值比别一部分的所有关键字都小,然后再依次对前后两部分的记录进行快速排序,递归该过程,直到序列中所有记录都是有序为止。

算法基本过程

快速排序

这张GIF来自维基百科,很好的反映了快速排序的基本过程。
快速排序首先需要选择一个关键字(PivotNumber),也就是基准数据。然后将数组以基准数据划分层两份,左边的数据全部小于等于基准数据(数组中可能存在于基准数相同的数字),右边的数组全部大于基准数据。这样我们就完成了第一次数组分割。然后通过递归,我们可以分别对数组的左边和右边再进行分割和排序,这正是分治思想。

分治

百度百科上有一个非常好的例子,对分组的过程描述的非常详细:过程示例

关键数字的选取

关键数字的选取对与算法非常重要,一共存在三种选取方式:

  • 1、选择固定位置的元素。

    每次关键数字都是为当前数组的最右侧或者最左侧的数字。

    接下来我们来看看这种方式的三种JAVA实现,为了方便大家看,我将分组方法(partition)单独拿出来展示:

//参数的意思是(从左到右):数组a,起始点,结束点,关键数字
public static int partition1(int a[], int left, int right, int point) {
        int leftptr = left - 1;
        int rightptr = right;
        while (true) {
            //从左侧开始查找有没有大于point的数字
            while (leftptr < rightptr && a[++leftptr] < point);
            //从右侧开始查找有没有小于point的数字
            while (leftptr < rightptr && a[--rightptr] > point);
            if (leftptr >= rightptr) {
                break;
            } else {
                //将左侧发现的大于point的数字和右侧发现小于point的数字交换
                int temp = a[leftptr];
                a[leftptr] = a[rightptr];
                a[rightptr] = temp;
            }
        }
        //最后将最右侧的关键数字移到中间
        int temp = a[leftptr];
        a[leftptr] = a[right];
        a[right] = temp;
        return leftptr;
    }
public static int partition2(int[] a,int low,int high){
         int start = low;
         int end = high;
         int key = a[high];

         while(end>start){

             while(end>start&&a[start]<=key)
                start++;
             if(a[start]>=key){
                 //将在左边比key大的数字和key交换
                 int temp = a[start];
                 a[start] = a[end];
                 a[end] = temp;
             }

             while(end>start&&a[end]>=key)  
                 end--;
             if(a[end]<=key){
                 int temp = a[end];
                 a[end] = a[start];
                 a[start] = temp;
             }
         }
         return start;
     }
private void partition3(int[] a, int low, int high) {
        if (low >= high)
            return;
        int i = low, j = high; // 设置这两个变量的目的是为了保持low和high不变
        int pivotNum = a[i]; // 基准数
        while (i < j) {
            while (a[j] >= pivotNum && j > i) { 
                j--;
            }
            if (j > i) {    
                a[i] = a[j];
                i++;
            }
            while (a[i] < pivotNum && i < j) { 
                i++;
            }
            if (i < j) {

                a[j] = a[i];

                j--;
            }
        }
        a[i] = pivotNum;
   }

这三种写法其中第三种效率是最高的,第二种是最低,因为其交换次数是最多的。

另外,这三种方法都是选取最右的数字为关键数字。如果读者想选取最左侧,只需要把先从左遍历再右遍历更改为先右遍历即可。

最后在使用递归调用partition方法。(本人使用的是第一种方法,所以下面的递归调用参数沿用了left、right)

public static void sort(int[] a, int left, int right) {
        if (right - left <= 0) {
            return;
        } else {
            int point = a[right];
            int position = partition(a, left, right, point);
            sort(a, left, position - 1);
            sort(a, position + 1, right);
        }
    }
  • 2、使用随机的关键数字

随机选择最左边或最右边的数字为关键数字。

上面版本的快排在选取主元的时候,每次都选取最右边的元素。当序列为有序时,会发现划分出来的两个子序列一个里面没有元素,而另一个则只比原来少一个元素。为了避免这种情况,引入一个随机化量来破坏这种有序状态。

在随机化的快排里面,选取a[left..right]中的随机一个元素作为主元,然后再进行划分,就可以得到一个平衡的划分。

实现起来其实只需要对上面的代码做小小的修改就可以了。 只要每次划分时随机选择最左或最右,然后根据所选择的关键数字位置改变遍历顺序即可。

  • 3、三数取中,取数列中第一个数,中间位置的数,最后一个数取他们的中值作为关键数字。

arr[low],arr[high],arr[(low+high)/2]三者的中值作为关键数字。

程序稍微修改即可实现,这里就不展示了。

算法的复杂度

对于第一种关键数字方式,我们可以看出,每一次调用partition()方法都需要扫描一遍数组长度(注意,在递归的时候这个长度并不是原数组的长度n,而是被分隔出来的小数组,即n*(2^(-i))),其中i为调用深度。而在这一层同样长度的数组有2^i个。那么,每层排序大约需要O(n)复杂度。而一个长度为n的数组,调用深度最多为log(n)层。二者相乘,得到快速排序的平均复杂度为O(n ㏒n)。
通常,快速排序被认为是在所有同数量级的排序方法中,平均性能最好。
从代码中可以很容易地看出,快速排序单个栈的空间复杂度不高,每次调用partition方法时,其额外开销只有O(1)。所以,最好情形下快速排序空间复杂度大约为O(㏒n)。

算法的优化

固定关键数字快速排序算法可以说是最基本的快速排序,因为它并没有考虑任何输入数据。但是,我们很容易发现这个算法的缺陷:这就是在我们输入数据基本有序甚至完全有序的时候,这算法退化为冒泡排序,不再是O(n㏒n),而是O(n^2)了。
究其根源,在于我们的代码实现中,每次只从数组第一个开始取。如果我们采用“三者取中”,即arr[low],arr[high],arr[(low+high)/2]三者的中值作为枢轴记录,则可以大大提高快速排序在最坏情况下的性能。但是,我们仍然无法将它在数组有序情形下的性能提高到O(n)。还有一些方法可以不同程度地提高快速排序在最坏情况下的时间性能。

同样,在一次分割结束后,我们也可以把与基准数相等的元素聚在一起,下次分割时忽略掉这些元素。
对于含有重复元素比较多的序列,这种优化方法效果比较好,可以减少很多跌代次数。
具本过程:
第一步:在划分过程,把与所选取的基准数相等的元素放在数组的两端。
第二步:划分结束后,把两端的与基准数相等的元素移到基准数最终位置的两侧。

此外,快速排序需要一个递归栈,通常情况下这个栈不会很深,为log(n)级别。但是,如果每次划分的两个数组长度严重失衡,则为最坏情况,栈的深度将增加到O(n)。此时,由栈空间带来的空间复杂度不可忽略。如果加上额外变量的开销,这里甚至可能达到恐怖的O(n^2)空间复杂度。所以,快速排序的最差空间复杂度不是一个定值,甚至可能不在一个级别。
为了解决这个问题,我们可以在每次划分后比较两端的长度,并先对短的序列进行排序(目的是先结束这些栈以释放空间),可以将最大深度降回到O(㏒n)级别。

partition()方法在TopK问题上的应用

TopK问题即求序列中最大或最小的K个数。这里以求最小K个数为例。

快速排序的思想是使用一个基准元素将数组划分成两部分,左侧都比基准数小,右侧都比基准数大。

给定数组array[low…high],一趟快排划分后的结果有三种:

1)如果基准数左侧元素个数Q刚好是K-1,那么在基准数左侧(包含基准数本身),即为TopK的所有元素。

2)如果基准数左侧元素个数Q小于K-1,那么说明基准数左侧的Q个数都是TopK里的元素,只需要在基准数的右侧找出剩下的K-Q个元素即可。问题转化成了以基准数下标为起点,高位(high)为终点的Top(K-Q)。递归下去即可。

3)如果基准数左侧元素个数Q大于K-1,说明第K个位置,在基准数的左侧,需要缩小搜索范围,在低位(low)至基准数位置重复递归即可,最终问题会转化成上面两种情况。

性能比较

这里有一篇关于JAVA快速排序中关键词固定和随机关键词两种方式的性能比较,作者写的很详细,所以我就直接给链接了。java实现快速排序和随机快速排序

PS: 本文参考了许多篇博客,并将他们的内容整合起来,以便查阅。以下是引用:
用Java写算法之五:快速排序

快速排序过程、partition应用、三种快排四种优化、Java实现

java实现快速排序和随机快速排序

快速排序及其分析—递归图+复杂度

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值