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

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

相关文章

python使用watchdog实现文件资源监控

《python使用watchdog实现文件资源监控》watchdog支持跨平台文件资源监控,可以检测指定文件夹下文件及文件夹变动,下面我们来看看Python如何使用watchdog实现文件资源监控吧... python文件监控库watchdogs简介随着Python在各种应用领域中的广泛使用,其生态环境也

el-select下拉选择缓存的实现

《el-select下拉选择缓存的实现》本文主要介绍了在使用el-select实现下拉选择缓存时遇到的问题及解决方案,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的... 目录项目场景:问题描述解决方案:项目场景:从左侧列表中选取字段填入右侧下拉多选框,用户可以对右侧

解决mybatis-plus-boot-starter与mybatis-spring-boot-starter的错误问题

《解决mybatis-plus-boot-starter与mybatis-spring-boot-starter的错误问题》本文主要讲述了在使用MyBatis和MyBatis-Plus时遇到的绑定异常... 目录myBATis-plus-boot-starpythonter与mybatis-spring-b

关于Java内存访问重排序的研究

《关于Java内存访问重排序的研究》文章主要介绍了重排序现象及其在多线程编程中的影响,包括内存可见性问题和Java内存模型中对重排序的规则... 目录什么是重排序重排序图解重排序实验as-if-serial语义内存访问重排序与内存可见性内存访问重排序与Java内存模型重排序示意表内存屏障内存屏障示意表Int

Python pyinstaller实现图形化打包工具

《Pythonpyinstaller实现图形化打包工具》:本文主要介绍一个使用PythonPYQT5制作的关于pyinstaller打包工具,代替传统的cmd黑窗口模式打包页面,实现更快捷方便的... 目录1.简介2.运行效果3.相关源码1.简介一个使用python PYQT5制作的关于pyinstall

使用Python实现大文件切片上传及断点续传的方法

《使用Python实现大文件切片上传及断点续传的方法》本文介绍了使用Python实现大文件切片上传及断点续传的方法,包括功能模块划分(获取上传文件接口状态、临时文件夹状态信息、切片上传、切片合并)、整... 目录概要整体架构流程技术细节获取上传文件状态接口获取临时文件夹状态信息接口切片上传功能文件合并功能小

python实现自动登录12306自动抢票功能

《python实现自动登录12306自动抢票功能》随着互联网技术的发展,越来越多的人选择通过网络平台购票,特别是在中国,12306作为官方火车票预订平台,承担了巨大的访问量,对于热门线路或者节假日出行... 目录一、遇到的问题?二、改进三、进阶–展望总结一、遇到的问题?1.url-正确的表头:就是首先ur

C#实现文件读写到SQLite数据库

《C#实现文件读写到SQLite数据库》这篇文章主要为大家详细介绍了使用C#将文件读写到SQLite数据库的几种方法,文中的示例代码讲解详细,感兴趣的小伙伴可以参考一下... 目录1. 使用 BLOB 存储文件2. 存储文件路径3. 分块存储文件《文件读写到SQLite数据库China编程的方法》博客中,介绍了文

Redis主从复制实现原理分析

《Redis主从复制实现原理分析》Redis主从复制通过Sync和CommandPropagate阶段实现数据同步,2.8版本后引入Psync指令,根据复制偏移量进行全量或部分同步,优化了数据传输效率... 目录Redis主DodMIK从复制实现原理实现原理Psync: 2.8版本后总结Redis主从复制实

JAVA利用顺序表实现“杨辉三角”的思路及代码示例

《JAVA利用顺序表实现“杨辉三角”的思路及代码示例》杨辉三角形是中国古代数学的杰出研究成果之一,是我国北宋数学家贾宪于1050年首先发现并使用的,:本文主要介绍JAVA利用顺序表实现杨辉三角的思... 目录一:“杨辉三角”题目链接二:题解代码:三:题解思路:总结一:“杨辉三角”题目链接题目链接:点击这里