归并排序算法详解 通过二叉树的视角描述了归并排序的算法原理以及应用,那我就趁热打铁,今天继续用二叉树的视角讲一讲快速排序算法的原理以及运用。
快速排序算法思路
代码框架:
void sort(int[] nums, int lo, int hi) {
if (lo >= hi) {
return;
}
// 对 nums[lo..hi] 进行切分
// 使得 nums[lo..p-1] <= nums[p] < nums[p+1..hi]
int p = partition(nums, lo, hi);
// 去左右子数组进行切分
sort(nums, lo, p - 1);
sort(nums, p + 1, hi);
}
其实你对比之后可以发现,快速排序就是一个二叉树的前序遍历:
/* 二叉树遍历框架 */
void traverse(TreeNode root) {
if (root == null) {
return;
}
/****** 前序位置 ******/
print(root.val);
/*********************/
traverse(root.left);
traverse(root.right);
}
另外,前文 归并排序详解 用一句话总结了归并排序:先把左半边数组排好序,再把右半边数组排好序,然后把两半数组合并。
同时我提了一个问题,让你一句话总结快速排序,这里说一下我的答案:
快速排序是先将一个元素排好序,然后再将剩下的元素排好序。
从二叉树的视角,我们可以把子数组 nums[lo..hi]
理解成二叉树节点上的值,srot 函数理解成二叉树的遍历函数。
最后形成的这棵二叉树是什么?是一棵二叉搜索树。你甚至可以这样理解:快速排序的过程是一个构造二叉搜索树的过程。
快速排序的代码实现
明白了上述概念,直接看快速排序的代码实现:
class Quick {
public static void sort(int[] nums) {
// 排序整个数组(原地修改)
sort(nums, 0, nums.length - 1);
}
private static void sort(int[] nums, int lo, int hi) {
if (lo >= hi) {
return;
}
// 对 nums[lo..hi] 进行切分
// 使得 nums[lo..p-1] <= nums[p] < nums[p+1..hi]
int p = partition(nums, lo, hi);
sort(nums, lo, p - 1);
sort(nums, p + 1, hi);
}
// 对 nums[lo..hi] 进行切分
private static int partition(int[] nums, int lo, int hi) {
int pivot = nums[lo];
// 关于区间的边界控制需格外小心,稍有不慎就会出错
// 我这里把 i, j 定义为开区间,同时定义:
// [lo, i) <= pivot;(j, hi] > pivot
// 之后都要正确维护这个边界区间的定义
int i = lo + 1, j = hi;
// 当 i > j 时结束循环,以保证区间 [lo, hi] 都被覆盖
while (i <= j) {
while (i < hi && nums[i] <= pivot) {
i++;
// 此 while 结束时恰好 nums[i] > pivot
}
while (j > lo && nums[j] > pivot) {
j--;
// 此 while 结束时恰好 nums[j] <= pivot
}
// 此时 [lo, i) <= pivot && (j, hi] > pivot
if (i >= j) {
break;
}
swap(nums, i, j);
}
// 将 pivot 放到合适的位置,即 pivot 左边元素较小,右边元素较大
swap(nums, lo, j);
return j;
}
// 原地交换数组中的两个元素
private static void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
处理边界细节的一个技巧就是,你要明确每个变量的定义以及区间的开闭情况
接下来分析一下快速排序的时间复杂度。
显然,快速排序的时间复杂度主要消耗在 partition
函数上,因为这个函数中存在循环。
所以 partition
函数到底执行了多少次?每次执行的时间复杂度是多少?总的时间复杂度是多少?
和归并排序类似,需要结合之前画的这幅图来从整体上分析:
partition
执行的次数是二叉树节点的个数,每次执行的复杂度就是每个节点代表的子数组 nums[lo..hi]
的长度,所以总的时间复杂度就是整棵树中「数组元素」的个数。
假设数组元素个数为 N
,那么二叉树每一层的元素个数之和就是 O(N)
;分界点分布均匀的理想情况下,树的层数为 O(logN)
,所以理想的总时间复杂度为 O(NlogN)
。
还有一点需要注意的是,快速排序是「不稳定排序」,与之相对的,前文讲的 归并排序 是「稳定排序」
在实际工程中我们经常会将一个复杂对象的某一个字段作为排序的 key
,所以应该关注编程语言提供的 API 底层使用的到底是什么排序算法,是稳定的还是不稳定的,这很可能影响到代码执行的效率甚至正确性。
快速选择算法
不仅快速排序算法本身很有意思,而且它还有一些有趣的变体,最有名的就是快速选择算法(Quick Select)。
力扣第 215 题「数组中的第 K 个最大元素」
int findKthLargest(int[] nums, int k) {
// 首先随机打乱数组
shuffle(nums);
int lo = 0, hi = nums.length - 1;
// 转化成「排名第 k 的元素」
k = nums.length - k;
while (lo <= hi) {
// 在 nums[lo..hi] 中选一个分界点
int p = partition(nums, lo, hi);
if (p < k) {
// 第 k 大的元素在 nums[p+1..hi] 中
lo = p + 1;
} else if (p > k) {
// 第 k 大的元素在 nums[lo..p-1] 中
hi = p - 1;
} else {
// 找到第 k 大元素
return nums[p];
}
}
return -1;
}
// 对 nums[lo..hi] 进行切分
int partition(int[] nums, int lo, int hi) {
// 见前文
}
// 洗牌算法,将输入的数组随机打乱
void shuffle(int[] nums) {
// 见前文
}
// 原地交换数组中的两个元素
void swap(int[] nums, int i, int j) {
// 见前文
}