很多笔试面试都喜欢考察快排,叫你手写一个也不是啥事。我很早之前就学了这个,对快速排序的过程是很清楚的。但是最近自己尝试手写,发现之前对算法的细节把握不够精准,很多地方甚至只是大脑中的一个映像,而没有理解其真正的本质意图。于是今天结合了《数据结构》(严蔚敏),和《算法导论》进行一番探究。
首先先给出快速排序的严蔚敏版的实现(实际上这部分的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部分,则不用做任何处理。
辨析的过程也是思考的过程。只有对快排真正理解才能“随手”写出来,而不是背下来。