今天我想和大家一起梳理快速排序的实现过程。我会从它的基本思想讲起,再逐步介绍双指针划分、三数取中和小区间优化,最后分析它的复杂度和需要注意的情况。
一、快速排序的基本思想
快速排序的核心是"分而治之":先选一个基准值,把数组划分成左右两部分,再分别对这两部分继续排序。
例如,给数组:
6 1 8 3 5 2 7
选择 6 作为基准值。经过一趟划分后,可以得到类似这样的结果:
5 1 2 3 6 8 7
此时,6 左边的元素都不大于它,右边的元素都不小于它。基准值已经处于最终位置,接下来只需要分别排序左右两部分。
因此,快速排序主要分成三步:
- 选择一个基准值。
- 调整元素位置,让基准值归位。
- 递归排序基准值左边和右边的区间。
二、交换函数
我先写一个交换两个整数的函数:
void Swap(int* pa, int* pb)
{
int tmp = *pa;
*pa = *pb;
*pb = tmp;
}
函数接收两个整数的地址,通过指针交换它们的值。划分过程中,我会用它把位置不合适的元素换到另一侧。
三、用三数取中选择基准值
最简单的快速排序可以直接选区间最左边的元素作为基准值。但如果数组已经有序,每次都选最左侧元素,划分结果就可能非常不均匀,快速排序的效率也会下降。
为改善这种情况,我比较当前区间左端、中间和右端的三个元素,选出大小居中的那个元素作为基准值:
int Mid_Search(int* arr, int left, int mid, int right)
{
if (arr[left] < arr[right])
{
if (arr[mid] > arr[right])
{
return right;
}
else if (arr[mid] < arr[left])
{
return left;
}
else
{
return mid;
}
}
else
{
if (arr[mid] < arr[right])
{
return right;
}
else if (arr[mid] > arr[left])
{
return left;
}
else
{
return mid;
}
}
}
例如,三个位置上的值是 6、3、7,那么中间大小的值是 6,函数返回 6 所在的下标。这里返回下标,是为了后面把这个元素交换到区间最左端。
三数取中只能改善一些常见情况下的划分效果,并不能保证每次都划分均匀。不过,它比固定选择区间端点更稳妥。
四、用双指针完成划分
选择基准值后,我把它交换到当前区间最左端,再用两个下标从两侧向中间扫描:
int keyi = Mid_Search(arr, left, mid, right);
Swap(&arr[keyi], &arr[left]);
keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
while (begin < end && arr[end] >= arr[keyi])
{
end--;
}
while (begin < end && arr[begin] <= arr[keyi])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
Swap(&arr[begin], &arr[keyi]);
我先让 end 从右向左移动,跳过所有大于或等于基准值的元素。停下时,如果两个下标还没有相遇,arr[end] 就是一个小于基准值的元素。
接着让 begin 从左向右移动,跳过所有小于或等于基准值的元素。停下时,arr[begin] 就是一个大于基准值的元素。
这时,左边找到了一个较大的数,右边找到了一个较小的数,我把它们交换。重复这个过程,直到 begin 和 end 相遇。
最后,我把最左端的基准值交换到相遇的位置:
Swap(&arr[begin], &arr[keyi]);
这样,基准值就归位了。它左边的元素不大于它,右边的元素不小于它。
五、递归排序左右区间
基准值归位后,它就不需要再参加后续排序。我分别递归处理它左右两侧的区间:
QuickSort(arr, left, begin - 1);
QuickSort(arr, begin + 1, right);
当区间长度为零或一时,区间已经有序,递归就可以结束:
if (left >= right)
{
return;
}
六、小区间使用插入排序
当区间较小时,继续递归调用快速排序会带来额外开销。因此,我在代码里加了一个小区间优化:
if (right - left < 10)
{
InsertSort(arr + left, right - left + 1);
return;
}
right - left < 10 表示当前区间最多有 10 个元素。arr + left 指向当前待排序区间的起点,right - left + 1 是区间长度。
这里假设插入排序的函数形式是:
void InsertSort(int* arr, int n);
它会对从 arr[0] 开始的 n 个元素排序。小区间使用插入排序,是因为它的实现简单,在元素较少时通常比较高效。
七、完整代码
下面是我整理后的快速排序实现:
void Swap(int* pa, int* pb)
{
int tmp = *pa;
*pa = *pb;
*pb = tmp;
}
int Mid_Search(int* arr, int left, int mid, int right)
{
if (arr[left] < arr[right])
{
if (arr[mid] > arr[right])
{
return right;
}
else if (arr[mid] < arr[left])
{
return left;
}
else
{
return mid;
}
}
else
{
if (arr[mid] < arr[right])
{
return right;
}
else if (arr[mid] > arr[left])
{
return left;
}
else
{
return mid;
}
}
}
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
// 小区间使用插入排序
if (right - left < 10)
{
InsertSort(arr + left, right - left + 1);
return;
}
// 三数取中
int mid = left + (right - left) / 2;
int keyi = Mid_Search(arr, left, mid, right);
// 把基准值交换到区间最左端
Swap(&arr[keyi], &arr[left]);
keyi = left;
int begin = left;
int end = right;
// 双指针划分
while (begin < end)
{
while (begin < end && arr[end] >= arr[keyi])
{
end--;
}
while (begin < end && arr[begin] <= arr[keyi])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
// 基准值归位
Swap(&arr[begin], &arr[keyi]);
// 递归排序左右区间
QuickSort(arr, left, begin - 1);
QuickSort(arr, begin + 1, right);
}
计算中间下标时,我写成了:
left + (right - left) / 2
这样比 (left + right) / 2 更不容易在整数相加时发生溢出。
对长度为 n 的数组排序时,可以这样调用:
QuickSort(arr, 0, n - 1);
这里的左右边界都是数组下标,并且 right 也包含在排序区间内。
八、时间复杂度和空间复杂度
如果每次划分都比较均匀,快速排序的递归层数大约是 log n。每一层都需要扫描当前区间,因此平均时间复杂度为:
O(n log n)
如果每次划分都非常不均匀,例如一侧为空,另一侧包含几乎全部元素,时间复杂度就可能退化到:
O(n²)
快速排序通常直接在原数组中交换元素,不需要额外创建同等大小的数组。递归调用会使用栈空间:划分较均匀时通常为 O(log n),极端情况下可能达到 O(n)。
九、总结
我实现的这版快速排序,主要包含四个步骤:用三数取中选择基准值,用双指针划分区间,把基准值放到最终位置,再递归排序左右两侧。对于较小区间,我改用插入排序来减少递归开销。
快速排序平均效率较高,但最坏情况下仍可能退化到 O(n²);它通常也是不稳定排序,相等元素的相对顺序可能改变。理解划分过程和递归边界,是读懂快速排序代码的关键。
十、补充
我想补充一点,关于为什么在begin和end碰面后那个值一定比key小,其实碰面就两种情况,要么是R先走,找小找不到,直到碰面,另一种是R找小找到了,L找大找不到,直到碰面,第二种情况好理解,第一种情况L本身存放的就是比key小的值,那这样一换,就足以证明上述问题。
但是这里有一个至关重要的点,那就是你不能让L先走,或者说在这种情况下你不能让L先走,你想让L先走,就得把key整到右边去,也就是说,你不采用三路优化地方式,那么key就是right,采取那你就要给key赋值right。