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