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

文章目录

    • 问题一:不一致
      • 算法描述部分给出的分划实现
      • 完整程序部分给出的分划实现
    • 问题二:不正确
    • 问题三:把循环条件改为 `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.mzph.cn/bicheng/9269.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

系统权限控制插件封装-实现系统权限控制插件化

背景&#xff1a;按照传统的开发方式方式&#xff0c;每次新开发一个系统&#xff0c;就需要花费大量时间精力去搭建权限控制模块&#xff0c;如果我们把权限控制这一整个模块都抽离成一个独立的权限控制插件&#xff0c;支持单命令安装&#xff0c;全面暴露参数与方法&#xf…

k8s 理论知识基本介绍

目录 一 k8s 理论前言 &#xff08;一&#xff09;微服务是什么 1&#xff0c;应用场景 2&#xff0c;API 是什么 &#xff08;二&#xff09;&#xff0c;微服务 如何做版本迭代 1. Docker镜像构建 2. 版本标记 3. Docker Registry 4. 环境一致性 5. 滚动更新…

多进程编程

创建一对父子进程&#xff1a; 父进程负责向文件中写入 长方形的长和宽 子进程负责读取文件中的长宽信息后&#xff0c;计算长方形的面积 代码&#xff1a; #include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #inc…

串口通信---了解

1 串口接线方式 RXD&#xff1a;数据输入引脚&#xff0c;数据接受&#xff1b;STC89系列对应P3.0口 TXD&#xff1a;数据发送引脚&#xff0c;数据发送&#xff1b;STC89系列对应P3.1口 接线方式 串口编程要素 输入/输出数据缓冲器叫做SBUF&#xff0c;都用99H地址码&#x…

Java文件与IO操作

1. 文件与IO操作 1.1 文件 什么是文件: 文件,对我们并不陌生,文件是保存数据的地方,比如大家经常使用的word文档,txt文件.excel文件...都是文件。它既可以保存一张图片,也可以保持视频,声音.… 1.1.1 文件流: 1.1.2 常用的文件操作: 创建文件对象相关构造器和方法: 案例&a…

NeRF算法

目录 算法介绍 基本原理 1. 体渲染 2. 多层感知机&#xff08;MLP&#xff09; 3. 位置编码 4. 两阶段层次化体采样 实验展示 代码解析 算法介绍 NeRF&#xff08;Neural Radiance Fields&#xff09;是一种用于从2D图像中重建3D场景的神经网络模型。它通过训练一个深度…

SAP-ABAP-视图

1、什么是视图&#xff1f; 当需要查询多个表中的某些字段的数据时&#xff0c;就可以使用视图。视图不影响数据库中的数据&#xff0c;仅作为查询手段或工具。 2、视图类型&#xff1a; 数据库视图和维护视图经常使用。 3、创建视图SE11 3.1、数据库视图 可以直接输入表名…

Linux 文件

文章目录 文件操作回顾(C/C)系统调用接口 管理文件认识一切皆文件C/C的文件操作函数与系统调用接口的关系……重定向与缓冲区 -- 认识重定向与缓冲区 -- 理解使用重定向缓冲区实现一个简单的Shell(加上重定向)标准输出和标准错误(在重定向下的意义) 磁盘文件磁盘存储文件操作系…

【BUUCTF】[RoarCTF 2019]Easy Java1

工具&#xff1a;hackbar发包&#xff0c;bp抓包。 解题步骤&#xff1a;【该网站有时候send不了数据&#xff0c;只能销毁靶机重试】 这里的登录界面是个天坑【迷魂弹】 直接点击help&#xff0c;然后进行打开hackbar——通过post请求&#xff0c;再通过bp抓包&#xff0c;…

....comic科学....食用手册....

1.点击链接后&#xff0c;保存漫画至夸克网盘&#xff0c;若是新用户需要用手机注册. 2.在应用商店下载夸克APP. 3.登录APP下载已保存的漫画. 3.1 进入APP点击 夸克网盘 3.2 点击“转存的内容”后&#xff0c;长按 漫画文件夹&#xff0c;点击下载&#xff0c;下载速度400K左…

桥田汉诺威工业展观察:走好脚下更需着眼未来

2024年4月21日&#xff0c;桥田创始人刘小平携核心团队6人共赴“制造业展会天花板”——德国汉诺威工业博览会参观学习&#xff0c;此次参访&#xff0c;是桥田智能组队出海的第二次学习之旅&#xff0c;未来&#xff0c;我们将组织更多优秀员工出海交流学习&#xff0c;让每一…

一套C语言VC + MSSQL开发PACS系统源码 带三维重建和还原的PACS医学影像全套系统源码

一套C语言VC MSSQL开发PACS系统源码 带三维重建和还原的PACS医学影像全套系统源码 本套PACS系统成品源码&#xff0c;自主版权。集成三维影像后处理功能&#xff0c;包括三维多平面重建、三维容积重建、三维表面重建、三维虚拟内窥镜、最大/小密度投影、心脏动脉钙化分析等功…

03-单片机商业项目编程,从零搭建低功耗系统设计

一、本文内容 上一节《02-单片机商业项目编程&#xff0c;从零搭建低功耗系统设计-CSDN博客》引出了伪时间片的概念&#xff0c;这也是再低功耗系统设计中必须使用的程序设计逻辑&#xff0c;本文着重来讲解如何利用伪时间片来设计伪多任务&#xff0c;以及伪时间片多任务内核设…

Abp框架,EF 生成迁移文件时,自动添加表和字段注释内容

在使用 abp 框架&#xff0c;或者ef 的时候都会遇到一个问题&#xff0c;就是建实体后要将实体描述生成到数据库中&#xff0c;就需要手动去添加 [Comment("注释内容")] 注解&#xff0c;这样相当于手动写两次注释&#xff08;即使你是 Ctrl C&#xff09;&#x…

景源畅信电商:抖音小店有哪些比较热门的宣传方法?

抖音小店的热门宣传方法&#xff0c;是许多商家关注的焦点。在数字化营销时代&#xff0c;有效的宣传手段不仅能提升品牌知名度&#xff0c;还能吸引潜在消费者&#xff0c;促进销售。以下是针对抖音小店热门宣传方法的详细阐述&#xff1a; 一、短视频内容营销 作为抖音的核心…

【进程等待】阻塞等待 | options非阻塞等待

目录 waitpid 阻塞等待 options&非阻塞等待 pid_t返回值 阻塞等待VS非阻塞等待 waitpid 回顾上篇&#xff1a; pid_ t waitpid(pid_t pid, int *status, int options); 返回值&#xff1a; 当正常返回的时候waitpid返回收集到的子进程的进程ID&#xff1b;如果设置了…

PSCA电源管理软件栈示例

安全之安全(security)博客目录导读 目录 1、移动通讯系统 2、基础设施系统 本博客就PSCA电源管理软件栈进行举例&#xff0c;主要以移动通讯系统和基础设施系统为例来说明。 1、移动通讯系统 图3.4显示了一个可以在基于Linux的移动设备中实现的电源管理堆栈示例。 在Linux…

使用WPF中的Trigger实现按钮样式动态更改

使用WPF中的Trigger实现按钮样式动态更改 在Windows Presentation Foundation (WPF)中&#xff0c;Trigger 是一种强大的机制&#xff0c;它可以基于控件的属性值来动态更改控件的样式。这篇博客将介绍如何使用Trigger实现按钮在鼠标悬停时样式动态更改的效果。我们将详细讨论为…

Faiss核心解析:提升推荐系统的利器【AI写作免费】

首先&#xff0c;这篇文章是基于笔尖AI写作进行文章创作的&#xff0c;喜欢的宝子&#xff0c;也可以去体验下&#xff0c;解放双手&#xff0c;上班直接摸鱼~ 按照惯例&#xff0c;先介绍下这款笔尖AI写作&#xff0c;宝子也可以直接下滑跳过看正文~ 笔尖Ai写作&#xff1a;…

STM32使用L9110驱动电机自制小风扇

1.1 介绍&#xff1a; 该电机控制模块采用L9110电机控制芯片。该芯片具有两个TTL/CMOS兼容输入端子&#xff0c;并具有抗干扰特性&#xff1a;具有高电流驱动能力&#xff0c;两个输出端子可直接驱动直流电机&#xff0c;每个输出端口可提供750800mA动态电流&#xff0c;其峰值…