快速排序的缺陷及优化

快速排序的缺陷及优化

原理

快速排序(Quick Sort) 是一种基于分治法的排序算法,由Tony Hoare于1960年提出。其基本思想是通过多次划分操作将一个数组分成较小的子数组,每个子数组中的元素比基准值更接近其最终位置。

操作步骤

  1. 选择基准值(Pivot):从数组中选择一个元素作为基准值。
  2. 分区操作(Partitioning):将数组分为两部分,一部分包含小于基准值的元素,另一部分包含大于基准值的元素。所有相等的元素可以放在任一部分或中间。
    • 初始化两个指针,左指针在数组开头,右指针在数组末尾。
    • 移动左指针直到找到一个大于基准值的元素。
    • 移动右指针直到找到一个小于基准值的元素。
    • 交换这两个元素的位置。
    • 继续上述步骤直到左指针和右指针相遇或交叉,此时将基准值放置在合适的位置。
  3. 递归排序:对基准值左右两边的子数组分别进行快速排序。

实现

排序过程

如下图所示,使用快速排序法对指定序列arr [ 3 8 5 1 2 6 9 7 ]进行降序排序。

  1. 选定基准值,arr[0] = 3 (第 1 个元素)作为基准值。
  2. 从右向左移动 right 指针,找到大于基准值(3)的数(7)停止。
  3. 从左向右移动 left 指针,找到小于基准值(3)的数(1)停止。
  4. 交换 left 和 right 指向的数据。
    在这里插入图片描述
  5. 继续从右向左移动 right 指针,找到大于基准值(3)的数(9)停止。
  6. 从左向右移动 left 指针,找到小于基准值(3)的数(2)停止。
  7. 交换 left 和 right 指向的数据。
    在这里插入图片描述
  8. 继续从右向左移动 right 指针,找到大于基准值(3)的数(6)停止。
  9. 从左向右移动 left 指针,未找到小于基准值(3)的数,与 right 相遇。此时 left 指向位置即为基准值(key)应该在的位置。
  10. 交换 left 指向位置 和 key(基准值) 指向的数据。
    在这里插入图片描述
  11. 以基准值 (key) 位置为中心,将数据左右拆开,拆分成两个序列。
  12. 使用同样的方法,分别对左右两个分区进行上述排序操作。
  13. 当待处理序列只有 1 个元素时,此序列一定为有序序列。结束处理。
    在这里插入图片描述

代码实现

根据以上推导过程编写代码

/**
 * @ 快速排序   hoare 版本  
 * @ arr - 待排序的数组
 * @ start_pos - 待处理分区元素起始位置     end_pos - 待处理分区元素结束位置
 * @ 要求从大到小排序数据,选取首元素作为基准值
 * */ 
void quick_hoare_sort_start(int *arr, int start_pos, int end_pos)
{
   
   
    int key = arr[start_pos];   /* 选取分区首元素作为基准值 */
    int right = end_pos;        /* 右指针,从右向左遍历数据 */
    int left = start_pos;       /* 左指针,从左向右遍历数据 */
    
    if (left >= right)
        return;

    while (left < right)  {
   
   
        /* 1. 从右向左遍历,找到大于基准值的元素位置 */
        while ((left < right) && (arr[right] <= key)) 
            right--;

        /* 2. 从左向右遍历,找到小于基准值的元素位置 */
        while ((left < right) && (arr[left] >= key)) 
            left++;

        /* 3. 成功找到元素后,交换位置 */
        if (left < right) {
   
   
            swap_data(&arr[left], &arr[right]);
            /* 此处网上很多代码包含以下两句,实际是有bug */
            // left++;
            // right--;
        }           
    }

    /* 4. left和right相遇,left指向位置为基准值应该所在位置 */
    swap_data(&arr[left], &arr[start_pos]);

    /* 5. 分别对左右分区,进行快速排序处理 */
    quick_hoare_sort_start(arr, start_pos, left-1);
    quick_hoare_sort_start(arr, left+1, end_pos);
}

运行结果

在这里插入图片描述

思考

  • 移动 left 指针和 right 指针的先后顺序有讲究吗?什么情况下先移动 left 指针,什么情况下又先移动 right 指针呢?
  • 若是基准值 key 不选取的第 1 个元素,而选取最后 1 个元素又如何处理呢?
    带着以上两个疑问画出如下排序过程图。
  1. 选定基准值,arr[len-1] = 7 (最后 1 个元素)作为基准值。
  2. 从左向右移动 left 指针,找到小于基准值(7)的数(3)停止。
  3. 从右向左移动 right 指针,找到大于基准值(7)的数(9)停止。
  4. 交换 left 和 right 指向的数据。
    在这里插入图片描述
  5. 继续从左向右移动 left 指针,找到小于基准值(7)的数(5)停止。
  6. 从右向左移动 right 指针,未找到大于基准值(7)的数,与 left 相遇。此时 right 指向位置即为基准值(key)应该在的位置。
  7. 交换 right 指向位置 和 key(基准值) 指向的数据。
    在这里插入图片描述
  8. 以基准值 (key) 位置为中心,将数据左右拆开,拆分成两个序列。
  9. 使用同样的方法,分别对左右两个分区进行上述排序操作。
  10. 当待处理序列只有 1 个元素时,此序列一定为有序序列。结束处理。
    在这里插入图片描述

代码实现

根据以上推导过程编写代码

/**
 * @ 快速排序   hoare 版本
 * @ arr - 待排序的数组
 * @ start_pos - 待处理分区元素起始位置     end_pos - 待处理分区元素结束位置
 * @ 要求从大到小排序数据,选取末元素作为基准值
 * */ 
void quick_hoare_sort_end(int *arr, int start_pos, int end_pos)
{
   
   
    int key = arr[end_pos];     /* 选取分区末元素作为基准值 */
    int right = end_pos;        /* 右指针,从右向左遍历数据 */
    int left = start_pos;       /* 左指针,从左向右遍历数据 */
    
    if (left >= right)
        return;

    while (left < right)  {
   
   
        /* 1. 从左向右遍历,找到小于基准值的元素位置 */
        while ((left < right) && (arr[left] >= key)) 
            left++;

        /* 2. 从右向左遍历,找到大于基准值的元素位置 */
        while ((left < right) && (arr[right] <= key)) 
            right--;
            
        /* 3. 成功找到元素后,交换位置 */
        if (left < right) {
   
   
            swap_data(&arr[left], &arr[right]);
        } 
    }

    /* 4. left和right相遇,left指向位置为基准值应该所在位置 */
    swap_data(&arr[right], &arr[end_pos]);

    /* 5. 分别对左右分区,进行快速排序处理 */
    quick_hoare_sort_end(arr, start_pos, left-1);
    quick_hoare_sort_end(arr, left+1, end_pos);
}

运行结果

在这里插入图片描述

结论

先移动 left 指针还是 先移动 right 指针由基准值选择决定。

  • 当选择第 1 个元素作为基准值时,因为基准值的位置在前面,所以要保证与 left 位置进行交换,此时需要先移动 right 指针,这样就能保证 left 和 right 最后相遇时候指向的值一定该在基准值左边。包含以下情况。
    1. 假设 right 指针找到数据,left 指针未找到,此时 left 移动到 right 指针位置结束遍历,指向数据本身应该交换到基准值左边,所以交换基准值和 left 指向值是合理的。
    2. 假设 right 指针未找到数据,此时 right 指针移动到 left 指针的位置结束遍历。此时的left指向数据本身就存在于基准值左边。将 left 指向数据和基准值位置交换亦是合理的。
  • 当选择最后 1 个元素作为基准值时,因为基准值的位置在后面,所以要保证与 right 位置进行交换,此时需要先移动 left 指针,这样就能保证 left 和 right 最后相遇时候指向的值一定该在基准值右边。包含以下情况。
    3. 假设 left 指针找到数据,right 指针未找到,此时 right 移动到 left 指针位置结束遍历,指向数据本身应该交换到基准值右边,所以交换基准值和 right 指向值是合理的。
    4. 假设 left 指针未找到数据,此时 left 指针移动到 right 指针的位置结束遍历。此时的 right 指向数据本身就存在于基准值右边。将 right 指向数据和基准值位置交换亦是合理的。

优化

优化1. 网上代码 bug

小编在网上查阅相关资料过程中,发现很多示例在找到元素时交换过程中编写代码如下

/* 3. 成功找到元素后,交换位置 */
if (left < right) {
   
   
    swap_data(&arr[left], &arr[right]);
    /* 此处网上很多代码包含以下两句,实际是有bug */
    // left++;
    // right--;
}   

交换元素位置后,将 left 指针和 right 指针 均移动了 1 次,看似合理,实则若遇到下 1 次 left 和 right 均未找到合适的元素时,最后交换基准值时会发生错误。

假设此时数组序列变为 [ 3, 8, 5, 1, 6, 2, 9, 7 ] 运行结果如下
在这里插入图片描述
可以看出档交换 09 和 02 后,right 指针前移指向 09,而 left 指针后移指向 02,left > right 此时交换 left 和 基准值 key 明显可以看出发生错误。

基于此问题,正确代码如下

/* 3. 成功找到元素后,交换位置 */
if (left < right) {
   
   
    swap_data(&arr[left], &arr[right]);
    /* 此处网上很多代码包含以下两句,实际是有bug */
    //left++;   /* 若选取末元素作为基准,移动 left,不移动 right */
    right--;    /* 若选取首元素作为基准,移动 right,不移动 left */
}  

修复后运行结果如下
在这里插入图片描述

优化2. 减少基准值的移动

每 1 次当分区排序遍历结束的时候,此时若 left 指向数据与基准值相等,可以不用移动数据,从而减少数据交换次数。

/* 4. left和right相遇,left指向位置为基准值应该所在位置, 若此时left指向值等于基准值,无需移动 */
if (arr[left] != arr[start_pos]) {
   
   
    swap_data(&arr[left], &arr[start_pos]);
}

优化3. 有序序列优化

在序列本身是有序的情况下,排序运行结果如下
在这里插入图片描述
可以看出在序列本身有序情况下,每次分割时都会导致一个子分区为空,此时快速排序的时间复杂度退化到O(n²),导致性能严重下降,我们可以使用以下两种方式规避这种情况。

有序检查

在快速排序前,先对序列进行有序检查,来规避此种情况。代码设计如下

/**
 * @ 检查数组是否有序(降序)
 * @ arr - 序列     len - 元素个数
 * @ 有序返回0,无序返回1
 * */ 
int check_arr_sort(int *arr, 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不只会拍照的程序猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值