同冒泡排序一样,快速排序属于交换类排序,但是,因为加入了分而治之的思想,快速排序的平均复杂度可以达到O(nlogn);本文将用大白话讲述一下快速排序的实现原理;同样,末尾将附上完整可行的快速排序算法代码(Java版本)。
注:文中有些图可能来自于其他链接,会在文末附上。
为了方便介绍快速排序的整体思想,这里先定义一个数组:
int[] array = new int[] { 45, 38, 65, 97, 76, 13, 27, 49, 20, 54 };
接下来的数据分析,都以该数组为基础:
一个基本概念:基数。
什么是基数呢?就是我们从待排序的数列中,随机选出一个数,就可以称为基数,它没什么特殊的含义,就是完全随机挑出来的一个数,我们只是称其为基数;通常呢,我们会挑选第一个数字作为基数,对于讨论的数组,就是45,其作为基数。
好地,接下来真正进入快速排序的流程,全是大白话,绝对通俗易懂:
当前:数组元素为:45 38 65 97 76 13 27 49 20 54;10个数字,前文已经选定了基数为45。
第一步,我们把45单独挖出来,其所在的位置,就形成了一个坑(__符号表示此处是坑,没有萝卜(数字));挖掉之后的效果如下:注:上面一行是索引值;这里以0为开始索引。
0 1 2 3 4 5 6 7 8 9
__ 38 65 97 76 13 27 49 20 54
第二步,从右侧开始遍历,寻找第一个比基数,就是我们定义的那个基数45小的数字,我们从右向左走,找到的数字是20,这是第一个遇到的比45小的数字,接着,将其挖掉,则原地就留下了另一个坑,这时候,效果如下:
0 1 2 3 4 5 6 7 8 9
__ 38 65 97 76 13 27 49 __ 54 :注意,目前图中有了两个深坑。
第三步,把拿出来的20放入到原先45所在的位置,实质就是从右侧找到第一个小于基数的数字,与基数交换,这一步执行后的结果如下:
0 1 2 3 4 5 6 7 8 9
20 38 65 97 76 13 27 49 __ 54 :注意,目前45,也就是基数,还未放入坑。
第四步,从左侧开始遍历,寻找第一个比基数45更大的数,找到的数字是65,然后把该数字放入右边空着的坑里;执行结果如下:
0 1 2 3 4 5 6 7 8 9
20 38 __ 97 76 13 27 49 65 54 :注意,这时候65的位置留下了一个坑;这时候,45依旧保持悬空状态,没有放坑里;此时左侧索引到达的位置为2;即原先65所在的位置
第五步,再次从右侧开始遍历,注意:这时候要以第二步骤为基础,因为第二步已经遍历到了倒数第二个位置;此次要在此基础上,继续往左走:然后找到了数字27;把该数字拿出来,放入第四步的坑里:执行结果如下:
0 1 2 3 4 5 6 7 8 9
20 38 27 97 76 13 __ 49 65 54 :注意,此次操作是在前面操作的基础之上的,逻辑跟第二步是一样的,但是在第二步基础上后续操作;此时,右侧索引到达的位置为6,即原先27所在的位置。
第六步:第二次右侧遍历操作结束,现在开始第二次左侧遍历操作,按照第四步的逻辑,找到了数字97;这里,也是在第四步操作基础上执行的,把97挖出来,然后放在第五步形成的坑里;执行结果如下:
0 1 2 3 4 5 6 7 8 9
20 38 27 __ 76 13 97 49 65 54 :注意,这时候左侧索引所在的位置为3
第七步:第三次右侧遍历,记住,每次遍历的操作都是从右边找小于基数的元素,从左边找大于基数的元素,而且操作都是在原先遍历操作后的位置继续操作的;然后找到了13,放到第六步形成的坑里:执行结果如下:
0 1 2 3 4 5 6 7 8 9
20 38 27 13 76 __ 97 49 65 54 :注意,这时候右侧遍历到了索引为5;左侧遍历到的位置为3,两个索引还未曾相遇,所以可以继续遍历,如果索引相遇,则代表左边全部是小于45的数字,右边全部是大于45的数字,则遍历停止了
第八步:第三次左侧遍历,发现了大于45的数字,76,这时候,左侧索引为4,还未曾碰到右边的索引,所以可以继续进行,把76挖出来,放在第七步的坑里:执行结果如下:
0 1 2 3 4 5 6 7 8 9
20 38 27 13 __ 76 97 49 65 54 :注意,这时候左侧索引为4;而右侧索引为5
第九步:程序继续执行,开始第四次右侧遍历,结果,右侧索引为4,左侧索引同样为4,二者相遇了,代表这一次执行结束,而此时,数组中仍然有一个坑,这个坑,就放入我们选定的基数:45。
到此,第一趟排序完成。
快速排序,没有想象中的那么难,这第一趟排序,轻轻松松就完成了,接下来,要总结下这趟排序的要点,因为我们还要把这部分逻辑递归调用,提高快速排序的效率:
- 快速排序的实质:首先挖坑,然后遍历填坑;我们先取出一个基数,无论这个基数的位置在哪儿,拿出来之后就形成一个坑;然后遍历从右侧开始,寻找比基数更小的数,挖出来(留出一个坑),填到原先的坑里;然后从左侧寻找比基数大的数,填到上一步的坑里;这就是主体逻辑
- 仔细想一下第一步达到的结果,那就是最后整个数列以基数为标准,划分成了两部分?是不是呢,仔细想一下,右边小于基数的数字,现在都放到了左边;而左边大于基数的数字,都被放到了右边,达成了我们想要的效果。
- 执行的过程中,必须注意左右的索引是否相遇了,只有左右索引未曾相遇,才能继续执行;相遇了,一次循环就终止了。
好,现在,最复杂的一趟讲完了,然后呢?
前文说了,整个队列已经划分为两个部分了,然后,就是针对这两部分,再度调用快速排序算法,如此反复,很快,整个算法就执行完毕,而整个数列,也就变成完全有序的了。
下面是完整代码(Java版本):内有详细注释:
public class QuickSorting {
public static void main(String[] args) {
int[] array = new int[] { 45, 38, 65, 97, 76, 13, 27, 49, 20, 54,47 };
sort(array, 0, array.length - 1);
for (int ele : array)
System.out.print(ele + " ");
}
/**
* @param a
* @param start
* @param end
*/
public static void sort(int[] a, int start, int end) {
// 只有可以继续划分的条件下,才会继续排序操作
if (start < end) {
int partition = quickSort(start, end, a);
sort(a, start, partition - 1);
sort(a, partition + 1, end);
}
}
/**
* @param begin
* @param end
* @param array
* @return 返回值为此次基准值所在的索引位置
*/
public static int quickSort(int begin, int end, int[] array) {
// 选择基数
int base = array[begin];
// begin和end就是作为索引的,继续循环的条件就是二者不得相遇
while (begin < end) {
// 从右侧开始遍历;如果元素大于基数,则继续
// 同时需要继续保证begin<end
// 因为在内部执行过程中,可能会出现begin>=end的情况,需要限制
while (array[end] > base && begin < end) {
end--;
}
// 因为左侧的元素已经挖出来,留个坑了,直接进行填坑,即赋值
array[begin] = array[end];
// 从左侧开始遍历,如果元素小于基数,则继续
while (array[begin] < base && begin < end) {
begin++;
}
// 否则,把元素放入右侧的坑里
array[end] = array[begin];
}
// 这时候,begin=end,整个数列划分为两部分了
// 进行赋值操作
array[begin] = base;
// 返回begin位置的索引,在接下来的循环中,用于继续划分数据,进行递归排序
return begin;
}
}
复杂度分析:- 时间复杂度:O(nlogn);在最糟糕的情况下,能够达到O(n2);平均情况下,复杂度为O(nlogn);具体推算另外开文叙述。
- 就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为log2n,其空间复杂度也就为O(logn),最坏情况,需要进行n‐1递归调用,其空间复杂度为O(n),平均情况,空间复杂度也为O(logn)。
但一般情况来说,快速排序的性能还是很好的,但是因为跳跃性的数据交换,导致算法实际上不能保证原先的两个相等的元素的顺序,所以快速排序是不稳定的排序算法。