快速排序(quick sort)是一种基于分治策略的排序算法,运行高效,应用广泛。 快速排序的核心操作是"哨兵划分",其目标是:选择数组中的某个元素作为"基准数",将所有小于基准数 的元素移到其左侧,而大于基准数的元素移到其右侧。
- 选取数组最左端元素作为基准数,初始化两个指针 i 和 j 分别指向数组的两端。 2. 设置一个循环,在每轮中使用 i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。 3. 循环执行步骤 2. ,直到 i 和 j 相遇时停止,最后将基准数交换至两个子数组的分界线。
原数组分成3部分: 左子数组、基准数、右子数组,且满足"左子数组任意元素 ≤ 基 准数 ≤ 右子数组任意元素"。
哨兵划分的实质是将一个较长数组的排序问题简化为两个较短数组的排序问题。
/* 元素交换 */
void swap(int nums[], int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
/* 哨兵划分 */
int partition(int nums[], int left, int right) {
// 以 nums[left] 为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left]) {
j--; // 从右向左找首个小于基准数的元素
}
while (i < j && nums[i] <= nums[left]) {
i++; // 从左向右找首个大于基准数的元素
}
// 交换这两个元素
swap(nums, i, j);
}
// 将基准数交换至两子数组的分界线
swap(nums, i, left);
// 返回基准数的索引
return i;
}
算法流程
- 首先,对原数组执行一次"哨兵划分",得到未排序的左子数组和右子数组。 2. 然后,对左子数组和右子数组分别递归执行"哨兵划分"。 3. 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。
/* 快速排序 */
void quickSort(int nums[], int left, int right) {
// 子数组长度为 1 时终止递归
if (left >= right) {
return;
}
// 哨兵划分
int pivot = partition(nums, left, right);
// 递归左子数组、右子数组
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
算法特性:
时间复杂度为 𝑂(𝑛 log 𝑛)、非自适应排序:在平均情况下,哨兵划分的递归层数为 log 𝑛 ,每层中的 总循环数为 𝑛 ,总体使用 𝑂(𝑛 log 𝑛) 时间。**在最差情况下,每轮哨兵划分操作都将长度为 𝑛 的数组划 分为长度为 0 和 𝑛 − 1 的两个子数组,此时递归层数达到 𝑛 ,每层中的循环数为 𝑛 ,总体使用 𝑂(𝑛2 ) 时间。 ‧ 空间复杂度为 𝑂(𝑛)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度 𝑛 ,使用 𝑂(𝑛) 栈 帧空间。**排序操作是在原数组上进行的,未借助额外数组。 ‧ 非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
出现最差情况的概率很低:虽然快速排序的最差时间复杂度为 𝑂(𝑛2 ) ,没有归并排序稳定,但在绝大 多数情况下,快速排序能在 𝑂(𝑛 log 𝑛) 的时间复杂度下运行。 ‧ 缓存使用效率高:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较 高。而像"堆排序"这类算法需要跳跃式访问元素,从而缺乏这一特性。 ‧ 复杂度的常数系数小:快速排序的比较、赋值、交换等操作的总数量最少。这与 "插入排序"比"冒泡排序"更快的原因类似。
基准数优化:
快速排序在某些输入下的时间效率可能降低。举一个极端例子,假设输入数组是完全倒序的,由于我们选择 最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 𝑛 − 1、 右子数组长度为 0 。如此递归下去,每轮哨兵划分后都有一个子数组的长度为 0 ,分治策略失效,快速排序 退化为"冒泡排序"的近似形式。
为了尽量避免这种情况发生,我们可以优化哨兵划分中的基准数的选取策略。例如,我们可以随机选取一个 元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意。
需要注意的是,编程语言通常生成的是"伪随机数"。如果我们针对伪随机数序列构建一个特定的测试样例, 那么快速排序的效率仍然可能劣化。 为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选 元素的中位数作为基准数。这样一来,基准数"既不太小也不太大"的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 𝑂(𝑛2 ) 的概率大大 降低。
int medianThree(int nums[], int left, int mid, int right) {
int l = nums[left], m = nums[mid], r = nums[right];
if ((l <= m && m <= r) || (r <= m && m <= l))
return mid; // m 在 l 和 r 之间
if ((m <= l && l <= r) || (r <= l && l <= m))
return left; // l 在 m 和 r 之间
return right;
}
/* 哨兵划分(三数取中值) */
int partitionMedian(int nums[], int left, int right) {
// 选取三个候选元素的中位数
int med = medianThree(nums, left, (left + right) / 2, right);
// 将中位数交换至数组最左端
swap(nums, left, med);
// 以 nums[left] 为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
尾递归优化
在某些输入下,快速排序可能占用空间较多。以完全有序的输入数组为例,设递归中的子数组长度为 𝑚 ,每 轮哨兵划分操作都将产生长度为 0 的左子数组和长度为 𝑚 − 1 的右子数组,这意味着每一层递归调用减少 的问题规模非常小(只减少一个元素),递归树的高度会达到 𝑛−1 ,此时需要占用 𝑂(𝑛) 大小的栈帧空间。 为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进 行递归。由于较短子数组的长度不会超过 𝑛/2 ,因此这种方法能确保递归深度不超过 log 𝑛 ,从而将最差空 间复杂度优化至 𝑂(log 𝑛) 。代码如下所示:
/* 快速排序(尾递归优化) */
void quickSortTailCall(int nums[], int left, int right) {
// 子数组长度为 1 时终止
while (left < right) {
// 哨兵划分操作
int pivot = partition(nums, left, right);
// 对两个子数组中较短的那个执行快速排序
if (pivot - left < right - pivot) {
// 递归排序左子数组
quickSortTailCall(nums, left, pivot - 1);
// 剩余未排序区间为 [pivot + 1, right]
left = pivot + 1;
} else {
// 递归排序右子数组
quickSortTailCall(nums, pivot + 1, right);
// 剩余未排序区间为 [left, pivot - 1]
right = pivot - 1;
}
}
}