快速排序详解01

今天我想和大家一起梳理快速排序的实现过程。我会从它的基本思想讲起,再逐步介绍双指针划分、三数取中和小区间优化,最后分析它的复杂度和需要注意的情况。

一、快速排序的基本思想

快速排序的核心是"分而治之":先选一个基准值,把数组划分成左右两部分,再分别对这两部分继续排序。

例如,给数组:

复制代码
6 1 8 3 5 2 7

选择 6 作为基准值。经过一趟划分后,可以得到类似这样的结果:

复制代码
5 1 2 3 6 8 7

此时,6 左边的元素都不大于它,右边的元素都不小于它。基准值已经处于最终位置,接下来只需要分别排序左右两部分。

因此,快速排序主要分成三步:

  1. 选择一个基准值。
  2. 调整元素位置,让基准值归位。
  3. 递归排序基准值左边和右边的区间。

二、交换函数

我先写一个交换两个整数的函数:

复制代码
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。

相关推荐
Matlab程序猿1 小时前
【MATLAB源码-第459期】基于matlab的D-H模型与阻尼最小二乘逆运动学的六自由度机械臂五次轨迹规划及动力学前馈PD控制仿真。
人工智能·算法·matlab·d-h建模·六自由度串联机械臂
信奥卷王1 小时前
[GESP202606 六级] 满二叉树
数据结构·算法
积流成海1 小时前
用代码实现哈希计算器:Web Crypto API 实战
前端·算法·哈希算法·哈希函数·盘子工具站
Nil2081 小时前
leetcode 300最长递增子序列
算法·leetcode·职场和发展
垆边人似月.2 小时前
华为机试题 :最大数
c++·算法·华为
念越3 小时前
题库、练习与错题本代码阅读
开发语言·数据结构
zhenaibo5213 小时前
如何与导师有效沟通选题?
算法
熊猫钓鱼>_>3 小时前
大学图论第 6 讲:网络流与二分图匹配——对偶性让瓶颈自己现形
开发语言·算法·面试·职场和发展·图论·校招·面经
没有钱的钱仔3 小时前
最大似然估计(MLE)通俗理解
算法·机器学习·概率论