这是我参与更文挑战的第 19 天,活动详情查看: 更文挑战
快速排序
快速排序也是我们在算法书里面认识的老朋友了。前面介绍了归并排序与二叉树的后序遍历有非常类似的地方,那么快速排序又和什么遍历相似呢?
是二叉树的前序遍历!前序遍历有两个重要的特点:
- 拿到根结点的信息
- 将根结点的信息,传递给左右子树
对于排序来说,有序性就是信息。因此,我们要做的事情就是把能拿到的有序信息,传递给左子数组和右子数组。
分析快排
1. 有序性的传递
对于快排而言,它传递有序性的手段就是将选择一个数 x,并且利用这个数,将数组分成三部分:
- 小于 x 的部分放在数组的最前面
- 等于 x 的部分放在数组的中间
- 大于 x 的部分放在数组的最后面
2. 左右子数组的处理
此时,可以把小于 x 的部分当成二叉树中的左子树,大于 x 的部分当成二叉树的右子树。等于 x 的部分当成二叉树的根结点。
那么接下来要做的事情就是像前序遍历一样,递归地处理左子数组和右子数组。
相对于二叉树的前序遍历来说,快速排序也有它自己的特点:
- 根结点的处理,需要执行“三路切分”操作,将一个数组切分为三段;
- 左右子区间是由切分动态生成的,并不像二叉树那样由指针固定。
可以用伪代码表示如下:
function 前序遍历/快速排序():
获取根结点的信息
将根结点的信息传递左右子树/左右子数组
那么前序遍历/快速排序的考点就可以总结为以下 3 点:
- 如何划分子结构
- 获取根结点的信息
- 如何将根结点的信息,传递给左右子树/左右子数组。
那么接下来我们就从上图中展示的三个方面入手,并且与二叉树的前序遍历的代码对照着一起看。
实操快排
1. 划分
首先我们看一下如何划分子数组。对于二叉树而言,子树的划分是天然的,已经在数据结构里面约定好了,比如 TreeNode.left、TreeNode.right。
但是对于数组而言,切分的时候,如果想到达最优的效率,那么将数组切为平均的两半效率应该是最高的(可以联想到二叉平衡树的效率)。但是快排不能保证选择一个数,就一定能将数组切分成为两半。
切分的结果如下:
利用x将数组 A[]切分为三段,[小于x的部分,等于x的部分,大于x的部分]
左子树 = [小于x的部分] = [b, l)根结点 = [等于x的部分] = [l, i)右 子树 = [大 于x的部分] = [i, e)
2. 子数组的递归
由于这里是排序,就需要分别对左子数组和右子数组进行排序。如果你还能记得“二叉树的前序遍历”,那么对子数组的排序应该也只需要递归就可以了。
// 二叉树的前序遍历拿左右子树的信息
preOrder(root.left);
preOrder(root.right);
快速排序则需要这么写:
// 快速排序去拿左右子数组的信息
qsort(a, b, l);
qsort(a, i, e);
最后,我们还需要考虑一下边界情况:
- 当 b >= e 的时候,说明这个区间是一个空区间,没有必要再排序;
- 当 b + 1 == e 的时候,说明只有一个元素,也没有必要排序。
以上两种边界情况可以对应到当二叉树为空,以及二叉树只有一个结点的情况。
完整代码
让我们一起写一下快速排序的代码吧(解析在注释里):
// 交换数组中两个元素的值
void swap(int[] A, int i, int j) {
int t = A[i];
A[i] = A[j];
A[j] = t;
}
// 将数组[b, e)范围的元素进行排序 void qsort(int[] A, int b, int e) {
// 像二叉树一样,如果空树/只有一个结点,那么不需要再递归了
// 如果给定的区间段为空,或者只有一个结点。
if (b >= e || b + 1 >= e) {
return;
}
// 取数组中间的元素作为x
final int m = b + ((e - b) >> 1);
final int x = A[m];
// 三路切分
int l = b, i = b, r = e - 1;
while (i <= r) {
if (A[i] < x) {
swap(A, l++, i++);
} else if (A[i] == x) {
i++;
} else {
swap(A, r--, i);
}
}
// 像二叉树的前序遍历一样,分别遍历左子树与右子树。
qsort(A, b, l);
qsort(A, i, e);
}
// 主函数,将数组nums排序
void quickSort(int[] nums) {
if (nums == null)
return;
qsort(nums, 0, nums.length);
}
复杂度分析:快速排序在较优情况下是 O(NlgN),在较差情况下是 O(N2)。