【勘误】一个错误的快速排序实现

2024-05-09 22:20

本文主要是介绍【勘误】一个错误的快速排序实现,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

文章目录

    • 问题一:不一致
      • 算法描述部分给出的分划实现
      • 完整程序部分给出的分划实现
    • 问题二:不正确
    • 问题三:把循环条件改为 `i <= j` 程序还是不正确
    • 正确的实现
    • 总结

10 10 10 年前我开始学 C 语言时我就认为快速排序并不是个简单的算法。相比于归并排序,快速排序,具有非常多的容易出错的实现细节。稍有不慎就可能写出时间复杂度不对甚至不具有正确性的算法。

最近读到一本名为《C++面向对象程序设计》的书,机械工业出版社出版,ISBN 为 9787111656708,版次为 2020 2020 2020 6 6 6 月第 1 1 1 版第 1 1 1 次印刷。经过细致地分析,我发现该书第 17.2.1 节给出的快速排序算法有明显错误,在此发表勘误。

追根溯源,我在该书英文版《C++ Programming: An Object-Oriented Approach》中也发现了同样的问题,英文版 ISBN 为 9780073523385

问题一:不一致

该书在算法描述中给出的分划代码实现与完整程序实现中给出的实现不一致,这种不一致导致完整程序中给出的代码明显不能应对被排序数组中存在重复元素的情况。

算法描述部分给出的分划实现

分划实现

完整程序部分给出的分划实现

在这里插入图片描述
在这里插入图片描述

我们可以看到,在完整程序代码第 59 59 59 行以及第 66 66 66 行后,相比于算法描述部分的代码缺少了语句 j-- 以及 i++。倘若调用函数 partition 时,arr[i]arr[j] 在初始时具有相同的值,则程序将陷入死循环。

有人可能会质疑,该算法在数组中元素个数互不相同时是否能够正确地得到排序结果,从而猜想原作者只是想编写一个仅适用于不存在重复元素的数组排序的程序。但下文中我们可以证明,即使原始数组中不存在任何重复元素,我们同样可以构造出一个让该程序无法得出正确结果的反例。

问题二:不正确

即使我们按照算法描述中的部分修改了最终的完整程序,该程序仍然不具有正确性,即存在一个数组使得该程序无法正确地将该数组递增排序。下文中给出的代码出了输入输入的方式外,其余部分均与书中提供的算法一致。

#include <iostream>
using namespace std;void swap(int& x, int& y);
void print(int arr[], int size);
int partition(int arr[], int beg, int end);
void quickSort(int arr[], int beg, int end);int main() { // 为了方便测试,我们对原始程序稍加修改,让其从标准输入读入数组 arr 的内容int n; cin >> n;                 // 输入待排序数组的元素总数int* arr = new int[n];for(int i = 0; i < n; i += 1) {  // 输入待排序数组cin >> arr[i];}cout << "Original array:" << endl;print(arr, n);quickSort(arr, 0, n-1);cout << "Sorted array:" << endl;print(arr, n);delete[] arr;return 0;
}void swap(int& x, int& y) {int temp = x;x = y;y = temp;
}void print(int array[], int size) {for(int i = 0; i < size; i ++) {cout << array[i] << " ";}cout << endl;
}int partition(int arr[], int i, int j) { // 这个分划写得不对int p = i;while(i < j) {while(arr[j] > arr[p]) {j --;}swap(arr[j], arr[p]);p = j;j --; // 这里我们修正了 “问题一” 中指出的问题while(arr[i] < arr[p]) {i ++;}swap(arr[i], arr[p]);p = i;i ++;}return p;
}void quickSort(int arr[], int beg, int end) {if(beg >= end || beg < 0) {return;}int pivot = partition(arr, beg, end);quickSort(arr, beg, pivot - 1);quickSort(arr, pivot+1, end);
}

在修正了 “问题一” 中指出的问题后,我们不难发现,其实这个算法还是不正确。比如我们可以让其排序 4, 5, 1, 3 四个数。程序给出了如下输出:
在这里插入图片描述
程序给出的排序结果为 3, 4, 1, 5,而正确的排序后结果应该为 1, 3, 4, 5。而这个问题是如何产生的呢?

观察分划函数第一次执行的过程:

int partition(int arr[], int i, int j) { // 这个分划写得不对int p = i;while(i < j) {while(arr[j] > arr[p]) {j --;}swap(arr[j], arr[p]);p = j;j --; // 检查点 1while(arr[i] < arr[p]) {i ++;}swap(arr[i], arr[p]);p = i;i ++; // 检查点 2}return p; // 检查点 3
}

我们核心关注上述程序执行 “检查点1”,“检查点2”,“检查点3”,三处语句数组中元素的值以及 i, j, p 三个变量的取值情况。

时刻arrijp
初始{4, 5, 1, 3}030
检查点 1{3, 5, 1, 4}023
检查点 2{3, 4, 1, 5}221
此时 i==j 外层循环退出
检查点 3{3, 4, 1, 5}221

此时程序认定 a r r [ 1 ] = 4 arr[1]=4 arr[1]=4 即当前轮主元已经被放置在了正确的位置上,而实际上由于 arr[2]=1 从来未被比较过,但此时 i==j 已经成立,所以程序认为主元归位。而这个错误源于一个错误的直觉:即,在算法执行的过程中 i i i 以及 i i i 左侧的所有位置一定小于等于主元, j j j 以及 j j j 右侧的元素一定大于等于主元。但实际上,由于检查点 1 处以及检查点 2 处添加的语句 j --i++ 的存在,使得每当进入循环 while(i < j) 时,程序其实仍未对 arr[i]arr[j] 进行过任何比较。因此我们应断言:数组在下标闭区间 [i, j] 内的部分,实际上从未被比较过,因此 i < j 这一循环条件会导致分划程序提前终止。

问题三:把循环条件改为 i <= j 程序还是不正确

需要注意的是,如果仅仅是把循环条件改为 i <= j 程序仍然不正确。修改后的分划函数如下:

int partition(int arr[], int i, int j) { // 这个分划写得不对int p = i;while(i <= j) { // 我们修改了分划的结束条件while(arr[j] > arr[p]) {j --;}swap(arr[j], arr[p]);p = j;j --; // 检查点 1while(arr[i] < arr[p]) {i ++;}swap(arr[i], arr[p]); // 错误来源这里p = i;i ++; // 检查点 2}return p; // 检查点 3
}

在此我们仍然可以给出反例,例如让 arr{1, 2, 1},程序可以得到如下的结果:
在这里插入图片描述
尽管 {2, 1, 1} 看起来是有序的,但我们希望读者记得,我们的排序算法是要将原数组递增排序而不是要将原数组递减排序。因此这个结果也是错误的。而这个错误是由 swap(arr[i], arr[p]); 这条语句导致的。在算法执行的过程中我们确实能够大致证明:

  • i 左侧的所有位置(不含 i)小于等于主元;(条件 1)
  • j 右侧的所有位置(不含 j)大于等于主元;
  • 常见的快速排序一般要保证算法执行过程中上述两个条件总是成立的。
  • 在分划执行过程中由于 swap 以及对 p 的赋值语句总是成对出现,所以 p 指向的位置的值总是与初始的主元值一致。

但是实际上当 ij 十分接近时,在执行语句 swap(arr[i], arr[p]);p 可能已经位于 i 的左侧。此时很可能导致将 i 左侧的一个值与 arr[i] 交换。而这修改了 i 左侧已经扫描过的内容,于是使得条件 1 出现了可能不成立的情况,算法的正确性也就难以保证了。

参照问题二中设置检查点的方式,我们可以追踪 arr, i, j, p 四个变量的值的变化:

时刻arrijp
初始{1, 2, 1}020
检查点 1{1, 2, 1}012
检查点 2{1, 2, 1}110
检查点 1{1, 2, 1}1-10
检查点 2 (*){2, 1, 1}1-11
此时 i==j 外层循环退出
检查点 3{2, 1, 1}1-11

我们可以看到错误的交换出现于 (*) 处,此时 i 左侧的内容本来是符合条件 1 的,但是由于我们不知道 p 也在 i 的左侧,所以错误地将 i 处本身不符合条件 1 的值交换到了 i 的左侧。

正确的实现

修改了上述三个问题后,我们给出一个可能正确的快速排序算法。初始时我们令 i=beg+1 而不是令 i=beg 是为了在证明过程中更方便地构造递归的子结构。因为我们可以看到,每当我们进入 while(i < j) 这一循环时,p 总是等于 i-1。而原书中给出的代码的正确性是更难以证明的,因为原书中第一次进入外层循环体时 p 等于 i 而其他时刻 p 等于 i-1,这为数学证明带来了不必要的 Trivial Exception。

#include <algorithm>
#include <cstdio>
using namespace std;int rand(int l, int r) {return rand() % (r - l + 1) + l;
}int findPos(int arr[], int beg, int end) {int i = beg + 1, j = end; // 这里的修改有利于正确性证明int p = beg;swap(arr[beg], arr[rand(beg, end)]); // 解决 TLE 问题while(i < j) {while(arr[p] < arr[j]) j --;swap(arr[p], arr[j]);p = j;j --;while(arr[p] > arr[i]) i ++;if(p > i) { // 这里要保护 i 左侧的值的正确性swap(arr[p], arr[i]);p = i;i ++;}}if(i == j && p == i - 1) { // 这里要放置 i 和 j 恰好相遇导致存在未被考虑的区间if(arr[i] < arr[p]) {swap(arr[i], arr[p]);p = i;}}return p;
}void quickSort(int arr[], int beg, int end) {if(beg >= end) {return;}int pos = findPos(arr, beg, end);quickSort(arr, beg, pos-1);quickSort(arr, pos+1, end);
}const int maxn = 1e6 + 7;
int arr[maxn];
int main() {int n; scanf("%d", &n);for(int i = 1; i <= n; i += 1) {scanf("%d", &arr[i]);}quickSort(arr, 1, n);for(int i = 1; i <= n; i += 1) {printf(" %d" + (i == 1), arr[i]);}putchar('\n');return 0;
}

总结

无论是对于初学者来说,还是对于经验丰富的程序员来说,写出一个正确的快速排序来总是很难的。当不得不自己手写排序时,我强烈建议选择归并排序而不是快速排序,因为快速排序的各种写法,其实都有莫名其妙的边界条件需要验证。当我们不关注排序的实现细节而只是要使用排序时,能用编程语言的标准库中提供的 sort 函数或者 stable_sort 函数,就不要自行手写,这无疑是一句中肯的忠告。

教编程的这些年里我见过形形色色的快速排序实现,而鲜有人关注这些实现的正确性证明(有的证明也是错的),其中很多实现都有奇奇怪怪的问题。例如有的实现时间复杂度不对,能够被容易地卡成 O ( n 2 ) O(n^2) O(n2) 的时间复杂度(即使随机选择主元)。有的实现在数组中存在重复元素时会出错,有的实现要求在数组末尾添加一个 inf 才能保证算法正确退出…

不要因为快速排序写起来很短就可以不认真地对待它。编程这件事,往往失之毫厘谬以千里。

这篇关于【勘误】一个错误的快速排序实现的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/974667

相关文章

分布式锁在Spring Boot应用中的实现过程

《分布式锁在SpringBoot应用中的实现过程》文章介绍在SpringBoot中通过自定义Lock注解、LockAspect切面和RedisLockUtils工具类实现分布式锁,确保多实例并发操作... 目录Lock注解LockASPect切面RedisLockUtils工具类总结在现代微服务架构中,分布

Java使用Thumbnailator库实现图片处理与压缩功能

《Java使用Thumbnailator库实现图片处理与压缩功能》Thumbnailator是高性能Java图像处理库,支持缩放、旋转、水印添加、裁剪及格式转换,提供易用API和性能优化,适合Web应... 目录1. 图片处理库Thumbnailator介绍2. 基本和指定大小图片缩放功能2.1 图片缩放的

Python使用Tenacity一行代码实现自动重试详解

《Python使用Tenacity一行代码实现自动重试详解》tenacity是一个专为Python设计的通用重试库,它的核心理念就是用简单、清晰的方式,为任何可能失败的操作添加重试能力,下面我们就来看... 目录一切始于一个简单的 API 调用Tenacity 入门:一行代码实现优雅重试精细控制:让重试按我

Redis客户端连接机制的实现方案

《Redis客户端连接机制的实现方案》本文主要介绍了Redis客户端连接机制的实现方案,包括事件驱动模型、非阻塞I/O处理、连接池应用及配置优化,具有一定的参考价值,感兴趣的可以了解一下... 目录1. Redis连接模型概述2. 连接建立过程详解2.1 连php接初始化流程2.2 关键配置参数3. 最大连

Python实现网格交易策略的过程

《Python实现网格交易策略的过程》本文讲解Python网格交易策略,利用ccxt获取加密货币数据及backtrader回测,通过设定网格节点,低买高卖获利,适合震荡行情,下面跟我一起看看我们的第一... 网格交易是一种经典的量化交易策略,其核心思想是在价格上下预设多个“网格”,当价格触发特定网格时执行买

python设置环境变量路径实现过程

《python设置环境变量路径实现过程》本文介绍设置Python路径的多种方法:临时设置(Windows用`set`,Linux/macOS用`export`)、永久设置(系统属性或shell配置文件... 目录设置python路径的方法临时设置环境变量(适用于当前会话)永久设置环境变量(Windows系统

Python对接支付宝支付之使用AliPay实现的详细操作指南

《Python对接支付宝支付之使用AliPay实现的详细操作指南》支付宝没有提供PythonSDK,但是强大的github就有提供python-alipay-sdk,封装里很多复杂操作,使用这个我们就... 目录一、引言二、准备工作2.1 支付宝开放平台入驻与应用创建2.2 密钥生成与配置2.3 安装ali

Spring Security 单点登录与自动登录机制的实现原理

《SpringSecurity单点登录与自动登录机制的实现原理》本文探讨SpringSecurity实现单点登录(SSO)与自动登录机制,涵盖JWT跨系统认证、RememberMe持久化Token... 目录一、核心概念解析1.1 单点登录(SSO)1.2 自动登录(Remember Me)二、代码分析三、

PyCharm中配置PyQt的实现步骤

《PyCharm中配置PyQt的实现步骤》PyCharm是JetBrains推出的一款强大的PythonIDE,结合PyQt可以进行pythion高效开发桌面GUI应用程序,本文就来介绍一下PyCha... 目录1. 安装China编程PyQt1.PyQt 核心组件2. 基础 PyQt 应用程序结构3. 使用 Q

Python实现批量提取BLF文件时间戳

《Python实现批量提取BLF文件时间戳》BLF(BinaryLoggingFormat)作为Vector公司推出的CAN总线数据记录格式,被广泛用于存储车辆通信数据,本文将使用Python轻松提取... 目录一、为什么需要批量处理 BLF 文件二、核心代码解析:从文件遍历到数据导出1. 环境准备与依赖库