快速排序中的partition函数的枢纽元选择,代码细节,以及其标准实现

很多笔试面试都喜欢考察快排,叫你手写一个也不是啥事。我很早之前就学了这个,对快速排序的过程是很清楚的。但是最近自己尝试手写,发现之前对算法的细节把握不够精准,很多地方甚至只是大脑中的一个映像,而没有理解其真正的本质意图。于是今天结合了《数据结构》(严蔚敏),和《算法导论》进行一番探究。

首先先给出快速排序的严蔚敏版的实现(实际上这部分的partition也是算法导论里面思考题的实现方式,细节可能不一样):

 1 public class QuickSort implements Sortable {
 2
 3     public void sort(int[] array) {
 4         if (array == null || array.length == 0)
 5             return;
 6         quickSort(array, 0, array.length - 1);
 7     }
 8
 9     private void quickSort(int[] array, int left, int right) {
10
11         if (right <= left)
12             return;
13         int pivot = partition(array, left, right);
14         quickSort(array, left, pivot - 1);
15         quickSort(array, pivot + 1, right);
16
17     }
18
19     private int partition(int[] array, int left, int right) {
20
21         int pivot = array[left];
22         int i = left;
23         int j = right;
24
25         while (i < j) {
26
27             while (i < j && array[j] >= pivot)
28                 j--;
29             while (i < j && array[i] <= pivot)
30                 i++;
31
32             if (i < j) {
33                 int tem = array[i];
34                 array[i] = array[j];
35                 array[j] = tem;
36             }
37         }
38
39         int tem = array[i];
40         array[i] = array[left];
41         array[left] = tem;
42         return i;
43     }
44 }

以上部分我们着重研究partition这个方法。

这个方法的思路是先找一个枢纽元(这个方法实现里面找的是第一个元素,具体其实大有文章不过这里先简化描述),再从数组的两边(具体从哪里到哪里由传进来额参数决定)生成两个指针i和j,每次发现左边的元素大于枢纽元则i停下来,右边的元素小于枢纽元j就停下来,并且交换这个两个数的位置。直到两个指针i,j相遇。再把枢纽元插入i的位置,也就是它应该在的位置。

这么做最后的结果是让数组的[left,right]部分呈现出2部分,枢纽元最终位置以左都是小于等于枢纽元的,以右都是大于等于枢纽元的。而枢纽元则被插入到了一个绝对正确的位置(以后也不会变化)。

一下开始说这种算法重要的要点:

1,枢纽元的选择

说起这个,其实有很大学问,为什么快速排序快,就是它在定位枢纽元的准确位置的同时进行了一个操作让数组分为两部分。那么其实我们把这部分去掉,每次只是单单确定一个枢纽元的位置(而不再对其左右的元素进行调整),这也是一种排序,不过效率低很多就是了,O(n^2)。不管输入是什么都是这个算法复杂度。那么枢纽元选得好不好就影响到了我们的快速排序具体的优化的程度的大小(再具体一点就是算法复杂度的一个常数参数)。最最优化的结果是O(n*log2(n))。

这里还是要解释一下这个问题,算法复杂度里面的具体参数。理论上来说,算法复杂度不很关注具体参数大小:O(2n)可以写为O(n)。具体的原理是由算法复杂度的定义决定的,简单来说就是当n的大小大到某一个级别,2个函数复杂度之间起根本作用的不是常数参数的大小,而是其本质的区别。比如O(10000*n)<O(n^2),因为当n足够大,必然会导致后者比前者大。

那么为什么最优化的情况(枢纽元取得最好的情况)log的底数是2呢?很简单,快速排序最优化的情况就是每一次你都刚好取到当前数组部分的“中点”的情况。试想:每一轮Partition的算法复杂度是O(n),然后每次把原数组分为相等的两部分,所以最后会有log2(n)次partition(其实就和一颗节点数为n的满二叉树的高度是log2(n)一样),最终算法复杂度就是O(n*log2(n))。

那么如果不是每次都二分呢?当然真实的情况是确实也做不到这个,那么我们考虑一个比较差的情况每次都把原数组分为1:9的划分。此时的递归层数是log(10/9)(n),具体证明可以看前面的二叉树的说明,画个图就能明白。最终的算法复杂度是O(n*log(10/9)(n))--->O(n*log(n))。也就是说,即使是每次都这么糟糕的划分,最终的结果依然是O(n*log(n))这个量级的,只不过有一个隐藏的参数。而实际上即使是1:99也是这样的结果。而且实际的运行中划分肯定是不会这么夸张而接近于二分。那么什么情况下会真正导致一个最坏的情况呢?就是你不好好选枢纽元,每次都选最小(大)的那个。举个例子就是每次你枢纽元选第一个元素,而这个数组又恰恰是非递减数组,那么算法复杂度就是O(n^2)。

所以,最后我们就知道,快速排序的枢纽元选不好,就完全丧失了快速排序“快”的优点。最好的选法有两种:

1,在当前数组中随机一个

2,在当前数组的第一个元素,中间的元素,最后的元素中挑大小居中的那一个。

这样就可以有效避免每次都挑一个最小(大)的元素了。当然我的代码里面用了了一个比较蠢的做法(每次拿第一个元素)。

2,边界的控制

这部分其实才是我今天最想表述的重点,前面的是为了避免误人子弟让别人也去选第一个元素。

所谓边界的控制是指,i和j这两个指针什么时候停止,停止之后枢纽元又应该插入哪里。这一部分为什么重要呢?因为这个快速排序的很多细节其实隐藏在里面。

比如说以上代码的partition部分,一个问题是为什么要先j--而不是先i++。其实这牵扯到另外一个终极问题:i和j在往中间靠拢的过程中什么时候停下来,停下来的状态又是什么,停下来之后又怎么确定枢纽元的具体位置(i?,j?,i+1?,i-1?)。

首先这里举个例子说明i和j遍历的过程:抓壮丁。试想这个数组中的[left, right]部分其实是个存在,两个集团i和j来抓壮丁,它们有自己的一个评判方法判断遍历的某个人天生应该是哪个集团的人,如果i发现我竟然找到了一个j的人,对不起,你等会,我把你和j找到的i的人交换。那么这i和j找着找着自己的队伍就壮大起来了,到最后,身后跟着一帮i的人,j身后跟着一帮j的人。最后会发生什么呢?就是i和j的碰头。这个过程揭示的partition的重要意义就是划分数组。

接下来详细解释一下为什么要先j--而不是i++,这里的逻辑是一个分析的过程,一环扣一环。

我们先假设如果是i++先于j--看会发生什么。

假设当前枢纽元是4,i遍历到了5,j遍历到了3,5和3之间没有其他元素了。这个时候i首先停在5,j停在3,交换。然后i进一步,i++了之后发现i==j,因为循环条件(i < j)的限制,接下来就是跳出整个循环。这个时候是不是该把枢纽元放在i的位置(也是j的位置)?不是,这时候应该放到i-1。为什么呢?因为这个时候其实i踏足了j的领域。j之所以在j这个位置的原因就是之前发生过一次交换,此时j这个位置上的数字一定大于枢纽元(注意下面会说这里的隐患!),如果i==j,然后把枢纽元和i交换,那么就会把一个大于枢纽元的数字交换到数组的开头(注意我这里用第一个元素为枢纽元)。那不就出错了。所以应该交换到i-1这个位置。

然而最关键的问题来了,以上的做法是对的吗?其实还是错的。

因为交换到i-1这个位置的前提是j确确实实是一个“有效”的数组的第二部分的数组(值大于等于枢纽元)。举一个最简单的例子:{9,1,2,3}这个数组。i一开始在9,j在3,如果i先动,i会直接跑到3的位置和j相等(数组下标为3)。可是这个时候应该把枢纽元和谁交换?明显是i而不是i-1。为什么?因为此时的j是个“无效的”数字。它根本没有经过遍历,所以此时有可能是i也有可能是i-1。

那么问题就比较严重了,这样写代码(把i的循环放在j的循环的前面)会导致这样一个问题。(事实上现在网上流传的很多快速排序都有点问题,只是测试数据下自己发现不了)。

那为什么把j写在前面,先遍历j就没事呢?注意到i不是left+1开始,而是从left开始的。如果先从j开始减,不管是在中间相遇,还是一j下子直接减到了left,都是可以直接把枢纽元和i相交换。中间相遇:j会先进入i的领域然后停下来,此时i==j,都是小于等于枢纽元的,同时这种算法枢纽元取自第一个值,把当前小于等于枢纽元的值交换到前面是可以的,于是可以在i停下来交换。如果在最后遇到,也就是j直接一路减到了i,这个时候发现i<j不满足,依然退出循环,还是可以把i和枢纽元交换。这样就统一了最后交换枢纽元的index。

最后简单来说,就是i从left开始,先递减j,可以让i一开始就是“有效的”(可以理解为i第一个就是已经遍历过的了),有点类似一个哨兵的作用,保证及时j一路到底也会交换正确。这其实也是《算法导论》的一道思考题。

另外这一种partition的实现如下:

 1 private int partition(int[] array, int left, int right) {
 2
 3     int i = left - 1;
 4     int j = left;
 5     int pivot = array[right];
 6
 7     for (; j < right; j++) {
 8         if (array[j] < pivot) {
 9             i++;
10             int tem = array[i];
11             array[i] = array[j];
12             array[j] = tem;
13         }
14     }
15
16     int tem = array[i + 1];
17     array[i + 1] = array[right];
18     array[right] = tem;
19
20     return i + 1;
21 }

这是《算法导论》正文的做法,特点是代码清晰明了。思路是从前往后遍历,没遍历到一个新元素,先进行甄别,属于小于枢纽元的话,把他交给数组的left部分,否则right部分。具体如何交给left呢?那就是先让left增加,这个时候left部分会持有一个不属于它的元素(大于等于pivot),这时候把这个元素和才遍历到的元素交换就行了,而如果这个元素本来就属于right部分,则不用做任何处理。

辨析的过程也是思考的过程。只有对快排真正理解才能“随手”写出来,而不是背下来。

时间: 2024-08-06 11:54:01

快速排序中的partition函数的枢纽元选择,代码细节,以及其标准实现的相关文章

快速排序中的partition函数详解

快速排序的精髓就在partition函数的实现.我们构建两个指针,将数组分为三部分,黑色部分全部小于pivot,中间蓝色部分都大于pivot,后面红色部分未知.i指针遍历整个数组,只要它指向的元素小于pivot就交换两个指针指向的元素,然后递增. // arr[]为数组,start.end分别为数组第一个元素和最后一个元素的索引 // povitIndex为数组中任意选中的数的索引 int partition(int arr[], int start, int end, int pivotInd

快速排序里的学问:枢纽元选择与算法效率

选择首尾元素做枢纽元 通常的.没有经过充分考虑的选择是将第一个或最后一个元素用作枢纽元.选择第一个元素作为枢纽元的程序例子可以参考专题的前一篇<快速排序里的学问:霍尔快排的实现>,而选择最后一个元素用作枢纽元的程序例子则可以参考<快速排序里的学问:快速排序的过程>这个算法导论里的例子. 选择最后一个元素作为枢纽元的排序过程是这样的: 如果输入是随机的,那么这是可以接受的,但是如果输入是预排序的或者是反序的,那么这样的枢纽元就产生一个劣质的分割,因为所有的元素不是被划入S1就是被划入

快速排序中的partition.

经典快速排序中的partition, 将最后一个元素作为划分点. 维护两个区域. <= x 的, >x 的区域. 划分过程中还有个待定的区域. [L,less] 区域小于x, [less+1,cur) 区域大于x.   [cur,R] 待定区域. /* * 将数组中的数字, 小于等于num 的在左边, 大于num的在右边. * num为 数组中的最后一个数. */ public static int partion(int[] arr, int L, int R){ int less = L

枢纽元选择与算法效率

选择首尾元素做枢纽元 通常的.没有经过充分考虑的选择是将第一个或最后一个元素用作枢纽元.选择第一个元素作为枢纽元的程序例子可以参考专题的前一篇<快速排序里的学问:霍尔快排的实现>,而选择最后一个元素用作枢纽元的程序例子则可以参考<快速排序里的学问:快速排序的过程>这个算法导论里的例子. 选择最后一个元素作为枢纽元的排序过程是这样的:威尼斯人赌场 如果输入是随机的,那么这是可以接受的,但是如果输入是预排序的或者是反序的,那么这样的枢纽元就产生一个劣质的分割,因为所有的元素不是被划入S

java学习中,匿名函数、构造方法、构造代码块、构造方法中调用构造方法(java 学习中的小记录)

java学习中,匿名函数.构造方法.构造代码块.构造方法中调用构造方法(java 学习中的小记录) 作者:王可利(Star·星星) 匿名函数 匿名对象:没有名字的对象 匿名对象使用的注意点: 1.一般不会用匿名对象给属性赋值,无法获取属性值,每次new 都是一个新的对象. 2.匿名对象永远都不可能是一个对象. 如:person new().name = "星星":是不行的 匿名对象的好处:书写简单. 匿名对象使用的场景: 1.如果一个对象调用一个方法一次的时候,就可以用匿名对象来调用.

【转载】如何在 C#中访问 JavaScript函数?

如何在 C#中访问 JavaScript函数? 时间:13-10-17 栏目:Unity3D教程 作者:zqcyou 评论:0 如何在 C#中访问 JavaScript函数?答案如下:c#代码中执行 javaScript函数: 方法一:1. 1 Page.RegisterStartupScript("ggg","<script>SetVisible(1);</script>"); 方法二:使用 Literal类,然后 1 2 3 4 5 6

快速排序代码(选择最右值最为枢纽元)

        参考了<数据结构域算法分析>书上部分代码,结合自己的理解写出的快速排序程序.书上用三数中值分割法来选择枢纽元,有它的好处,但我觉得使得代码很多地方不够直观.我选择数组的最后一个元素作为枢纽元,然后实现了快排. 关于快排的原理可以去看我博客转载的文章,很直观:点击打开链接 快排的步骤就是: 1.选择枢纽元   2.将小于枢纽元的数放前面,大于枢纽元的数放后面,枢纽元放中间                  3.然后对枢纽元左右两个部分继续进行1和2   下面程序中最主要的是Qui

快速排序的Partition函数

1 //数组中两个数的交换 2 static void swap(int[] nums, int pos1, int pos2){ 3 int temp = nums[pos1]; 4 nums[pos1] = nums[pos2]; 5 nums[pos2] = temp; 6 } 7 /** 8 * 快速排序中,在数组中选择一个数字,将数组中的数字分为两部分 9 * start, end 介于 0 与 nums.length之间 10 */ 11 static int partition(i

快速排序 partition函数的所有版本比较

partition函数是快排的核心部分 它的目的就是将数组划分为<=pivot和>pivot两部分,或者是<pivot和>=pivot 其实现方法大体有两种,单向扫描版本和双向扫描版本,但是具体到某个版本,其实现方法也是千差万别,参差不齐.本着严谨治学的态度,我将目前所接触的所有实现列举出来,并作出比较.除了伪代码,我也会给出相应的C&C++实现,供读者参考. 单向扫描: 下面是算法导论中例子 PARTITION(A, p, r) x = A[r] i = p - 1 fo