快速排序作为十大排序算法的重要一节,可以在的时间内完成排序操作。它不仅是笔试和面试场上常见的考点,也是《数据结构和算法》课程的重点考察内容。你知道荷兰国旗问题吗?你知道快排有哪三个版本吗?你知道快排的时间复杂度为什么是吗?无论你是在准备笔试、面试,还是在准备期末考或是考研,看完这篇文章,相信都能给你带来帮助。
荷兰国旗问题
版本一
荷兰国旗问题一共有两个版本,版本一的问题是这样的:随机给定一个无序数组nums
和一个基准值base
,请你在的时间内把数组调整成这样的状态:<= base
的元素全都在数组的左侧,> base
的元素全都在数组的右侧,而且要求不能使用额外存储空间。
举个例子,对这样的一个数组[5, 3, 7, 4, 2, 6, 1, 8, 4]
,使用数组最右侧的4
作为基准,希望经过一番操作之后数组被拆分成左右两部分,左侧的元素都是<=4
的,我们称之为小于等于区
,右侧的元素都是>4
的,我们称之为大于区
,而针对这两个区域的内部,我们并不要求有序。例如,数组[1, 2, 3, 4, 4, 5, 6, 7, 8]
是符合要求的,[4, 3, 4, 1, 2, 8, 6, 5, 7]
也是符合要求的。
设置一个指针p
,含义是nums[0...p]
是数组的小于等于区
,当算法流程结束后,nums[p+1...n-1]
是数组的大于区
。初始状态p = -1
,意味着小于等于区
为空。设置一个指针i
指向nums[0]
,如果nums[i] <= base
,那么将nums[i]
和小于等于区
的下一个元素交换位置,小于等于区
右扩一位,也就是p
指针向右偏移一位,i
指针也向右偏移一位;否则不做交换,i
指针向右偏移一位。
以上面的数组为例,[5, 3, 7, 4, 2, 6, 1, 8, 4]
,初始状态p = -1
,指针i
指向数组0
位置的5
。5 > 4
,所以不做交换,i
指针右移一位。
此时,i
指针指向1
位置的3
,3 < 4
,所以将1
位置的3
和小于等于区
的下一个元素,也就是0
位置的5
交换位置,i
指针再向右偏移一位,p
指针也向右偏移,小于等于区
右扩一位。
此时,i
指针指向2
位置的7
,7 > 4
,所以不做交换,i
指针直接右移一位。
i
指针指向3
位置的4
,4 = 4
,和小于等于区
的下一个元素交换,i
和p
指针均偏移一位,小于等于区
右扩一位。
i
指针指向4
位置的2
,2 < 4
,和小于等于区
的下一个元素交换,i
和p
指针向右偏移,小于等于区
右扩一位。
i
指针指向5
位置的6
,6 > 4
,位置不交换,i
指针右移。
i
指针指向6
位置的1
,1 < 4
,交换位置,i
、p
均右移,小于等于区
右扩。
i
指针指向7
位置的8
,8 > 4
,不交换位置,i
指针右移。
i
指针指向8
位置的4
,4 = 4
,交换位置,i
、p
均右移,小于等于区
右扩。
至此,i
指针已经超出了数组范围,流程结束,p
指针指向的就是小于等于区
的最后一个元素。
需要强调的是,由于我们是以数组的最右侧元素作为基准的,所以i
走到最后一个位置时,一定会命中“nums[i] <= base
,交换位置,小于等于区
右扩”这个分支,所以整个流程结束之后,p
指针指向的元素一定等于base
。
上述流程的代码如下
/**
* @return 小于等于区的最右侧元素位置
*/
private static int dutchNationalFlag(int[] nums, int base) {
if (nums == null || nums.length < 1) {
return -1;
}
int n = nums.length;
// nums[0...p] 小于区,nums[p+1 ... n-1]等于区,nums[p+1...n-1]大于区
int p = -1;
for (int i=0; i<n; i++) {
if (nums[i] <= base) {
DUtils.swap(nums, ++p, i);
}
}
return p;
}
版本二(leetcode75 颜色排序)
通常情况下的荷兰国旗问题我们一般是指下面这个版本:还是随机给定一个无序数组nums
和一个基准值base
,请你在的时间内把数组调整成这样的状态:<base
的元素全都在数组的左侧,=base
的元素在中间,>base
的元素在数组的右侧,同样要求不能使用额外存储空间。
大家可以网上搜一下,荷兰国旗就是上中下三个颜色分成三个部分,这个问题也是将数组分成三个部分,所以被称为荷兰国旗问题。
这次我们设置两个指针p1
和p2
,意思是nums[0...p1]
是数组的小于区
,里面的元素都是小于base
的;nums[p2...n-1]
是数组的大于区
,里面的元素均大于base
;当整个算法流程结束之后,nums[p1+1...p2-1]
是数组的等于区
,里面的元素均等于base
。再设置一个指针i
遍历数组,遍历的结束条件是i
指针碰到了p2
指针。那么针对遍历到的元素nums[i]
有以下三种情况
nums[i] < base
,此时将nums[i]
和小于区
下一个位置的元素交换,p1
和i
指针均右移一位,小于区
右扩;nums[i] == base
,此时i
指针右移一位即可;nums[i] > base
,此时将nums[i]
和大于区
前一个位置的元素交换,p2
指针左移一位,大于区
左扩,i
指针不动。
还是以上面的数组[5, 3, 7, 4, 2, 6, 1, 8, 4]
为例,使用数组最右侧的4
作为基准。初始状态下p1 = -1, p2 = n
,意味着小于区
和大于区
均为空,i
指针指向数组0
位置的5
,发现5 > 4
,命中第三个分支,所以将该元素与大于区
的前一个元素交换位置,p2
指针左移一位,大于区
左扩,i
指针不动。诶,大家看出来i
指针为什么不动了吗?因为交换过来的这个元素也是还没有判断过的,如果i
指针动了那么这个元素就漏判了。
接下来i
指向0
位置的4
,4 = 4
,命中分支2
,i
指针右移。
此时,i
指向1
位置的3
,3 < 4
,命中分支1
,将该元素与小于区
的下一个元素交换位置,i
指针右移,p1
指针右移,小于区
右扩。
i
指向2
位置的7
,7 > 4
,命中分支3
,该元素与大于区
的前一个元素交换位置,p2
指针左移,大于区
左扩,i
指针不动。
此时,i
指向2
位置的8
,8 > 4
,命中分支3
,该元素与大于区
的前一个元素交换位置,p2
指针左移,大于区
左扩,i
指针不动。
i
指向2
位置的1
,1 < 4
,命中分支1
,该元素与小于区
的后一个元素交换位置,p1
指针右移,小于区
右扩,i
指针右移。
i
指向3
位置的4
,命中分支2
,i
指针右移。
i
指向4
位置的2
,命中分支1
,与小于区
的下一个元素交换位置,小于区右扩,i
指针右移。
i
指针指向5
位置的6
,命中分支3
,与大于区
的前一个元素交换位置,大于区左扩,i
指针不动。
到目前为止,i
指针已经碰到了p2
指针,说明数组中所有的元素都已经遍历完了,流程到此结束。
上述流程的代码实现如下
/**
* @return 一个长度为2的数组,res[0]是最后一个小于base元素的index,res[1]是第一个大于base元素的index
*/
private static int[] dutchNationalFlag(int[] nums, int base) {
if (nums == null || nums.length < 1) {
return new int[] {-1, -1};
}
int n = nums.length;
// nums[0...p1] 小于区,nums[p1+1 ... p2-1]等于区,nums[p2...n-1]大于区
int p1 = -1, p2 = n;
int i = 0;
while (i < p2) {
if (nums[i] < base) {
DUtils.swap(nums, ++p1, i++);
} else if (nums[i] == base) {
i++;
} else {
// nums[i] > base
DUtils.swap(nums, --p2, i);
}
}
return new int[] {p1, p2};
}
我们也可以使用上述流程解决leetcode75题,大家快去试试吧!
快速排序
快速排序1.0
明白了上述的两个问题之后,我们就要回到本篇文章的主题:快速排序。解决荷兰国旗问题的流程在快速排序中又叫做partition
,其实只要你明白了整个流程,叫什么并不重要。快速排序其实是一个递归的过程,首先在nums
数组的全范围内做partition1.0
,这样数组就被分成了小于等于区
和大于区
两个部分,并且最终返回的指针p
指向的元素一定等于base
。因此,nums[p]
左侧的元素都<= nums[p]
,右侧的元素都> nums[p]
,所以nums[p]
一定已经来到了正确的位置。
然后我们在数组被partition
分成的小于等于区
和大于区
两个部分再分别递归地去做快排。递归的出口是排序部分的元素数量<= 1
,这样我们就得到了如下所示的代码。
private static void quickSort(int[] nums) {
if (nums == null || nums.length < 2) {
return;
}
process(nums, 0, nums.length - 1);
}
/**
* 在nums[left...right]区域内做快排
*/
private static void process(int[] nums, int left, int right) {
if (left >= right) {
return;
}
int mid = partition(nums, left, right);
process(nums, left, mid - 1);
process(nums, mid + 1, right);
}
/**
* base = nums[right],将nums[left...right]分成两部分:
* <= base > base
*
* @return <=base的最后一个元素
*/
private static int partition(int[] nums, int left, int right) {
// nums[left ... p1]: <= 区域
int p1 = left - 1;
int i = left;
int base = nums[right];
while (i <= right) {
if (nums[i] <= base) {
DUtils.swap(nums, ++p1, i);
}
i++;
}
return p1;
}
在分析算法时间复杂度之前我们要先分析一下partition
过程的时间复杂度。版本一的parition
中,无论哪个分支,i
指针都一定向后移动,所以最多执行n
次;版本二的partition
流程中,要么i
指针向后移动,要么p2
指针向前移动,循环条件是i < p2
,所以循环的最大执行此时也是n
次,所以无论是哪个版本的partition
,时间复杂度都是。
然后分析一下快排1.0的时间复杂度。这个时间复杂度其实取决于每次递归时基准的选择。根据我们上面的分析,每一次递归的调用,都至少能确定一个元素的位置,也就是nums[p]
。
如果数组原本是有序的,也就是说我们每次确定的元素的位置都在排序范围的最边上,那么递归函数的调用次数等于n
,递归函数的主体是partition
,时间复杂度是,所以此时整个递归调用的时间复杂度就是。
如果我们每次确定的元素位置都恰好在范围的最中间,将数组分成了均匀的两部分,根据递归函数的master公式
- 当时,递归调用的时间复杂度为
- 当 时,递归调用的时间复杂度为
- 当时,递归调用的时间复杂度为
这个递归调用的master公式就可以看成是
命中第二个分支,所以此时递归调用的时间复杂度就是
所以快排1.0的最好时间复杂度是,最差时间复杂度是
一个算法的时间复杂度一般都是以最差时间复杂度来估算的,所以快排1.0的时间复杂度是
快速排序2.0
快排2.0
是在1.0
的基础上做了优化,使用版本二的partition
代替1.0
中版本一partition
。这样做的好处是,1.0
中的一次递归调用只能确定一个元素的位置,但2.0
中的一次partition
会把所有等于base
的元素都放到数组的中间位置,也就是说一次递归可以确定一批元素的位置,时间复杂度也就得到了优化。
快排2.0的代码如下
private static void quickSort(int[] nums) {
if (nums == null || nums.length < 2) {
return;
}
process(nums, 0, nums.length - 1);
}
private static void process(int[] nums, int left, int right) {
if (left >= right) {
return;
}
int[] res = partition(nums, left, right);
process(nums, left, res[0]);
process(nums, res[1], right);
}
private static int[] partition(int[] nums, int left, int right) {
int base = nums[right];
int p1 = left - 1;
int p2 = right + 1;
int i = left;
while (i < p2) {
if (nums[i] < base) {
DUtils.swap(nums, ++p1, i++);
} else if (nums[i] == base) {
i++;
} else {
// nums[i] > base
DUtils.swap(nums, --p2, i);
}
}
return new int[] {p1, p2};
}
但2.0版本的快排也没有解决当数组原本有序时,每次选择的base
都位于范围的最边上,所以快排的最差时间复杂度也是
快速排序3.0
快排的1.0
和2.0
版本中,我们都能找到一个存在的case
,使得算法命中最差情况,时间复杂度是。
快排3.0又叫随机快排,它不再采用1.0
和2.0
两个版本中使用范围最右侧元素作为基准的策略,而是在nums[left...right]
中随机选择一个元素作为base
,再进行版本二的partition
,然后分别在小于区
和大于区
递归地进行快速排序,由于随机行为的存在,我们就没办法再找到一个实际的case
,使其命中最差情况。这种时候再使用最差情况去估算算法的时间复杂度是不合理的,所以就使用平均复杂度作为算法的时间复杂度。经过计算,上述流程的平均复杂度也是。所以快排3.0版本的时间复杂度就是
随机快排的代码如下所示
private static void quickSort(int[] nums) {
if (nums == null || nums.length < 2) {
return;
}
process(nums, 0, nums.length - 1);
}
private static void process(int[] nums, int left, int right) {
if (left >= right) {
return;
}
int[] res = partition(nums, left, right);
process(nums, left, res[0]);
process(nums, res[1], right);
}
private static int[] partition(int[] nums, int left, int right) {
int base = nums[DUtils.random(left, right)];
int p1 = left - 1;
// int p2 = right;
int p2 = right + 1;
int i = left;
while (i < p2) {
if (nums[i] < base) {
DUtils.swap(nums, ++p1, i++);
} else if (nums[i] == base) {
i++;
} else {
// nums[i] > base
DUtils.swap(nums, --p2, i);
}
}
return new int[] {p1, p2};
}