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

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

相关文章

C++使用栈实现括号匹配的代码详解

《C++使用栈实现括号匹配的代码详解》在编程中,括号匹配是一个常见问题,尤其是在处理数学表达式、编译器解析等任务时,栈是一种非常适合处理此类问题的数据结构,能够精确地管理括号的匹配问题,本文将通过C+... 目录引言问题描述代码讲解代码解析栈的状态表示测试总结引言在编程中,括号匹配是一个常见问题,尤其是在

Java实现检查多个时间段是否有重合

《Java实现检查多个时间段是否有重合》这篇文章主要为大家详细介绍了如何使用Java实现检查多个时间段是否有重合,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录流程概述步骤详解China编程步骤1:定义时间段类步骤2:添加时间段步骤3:检查时间段是否有重合步骤4:输出结果示例代码结语作

使用C++实现链表元素的反转

《使用C++实现链表元素的反转》反转链表是链表操作中一个经典的问题,也是面试中常见的考题,本文将从思路到实现一步步地讲解如何实现链表的反转,帮助初学者理解这一操作,我们将使用C++代码演示具体实现,同... 目录问题定义思路分析代码实现带头节点的链表代码讲解其他实现方式时间和空间复杂度分析总结问题定义给定

Java覆盖第三方jar包中的某一个类的实现方法

《Java覆盖第三方jar包中的某一个类的实现方法》在我们日常的开发中,经常需要使用第三方的jar包,有时候我们会发现第三方的jar包中的某一个类有问题,或者我们需要定制化修改其中的逻辑,那么应该如何... 目录一、需求描述二、示例描述三、操作步骤四、验证结果五、实现原理一、需求描述需求描述如下:需要在

部署Vue项目到服务器后404错误的原因及解决方案

《部署Vue项目到服务器后404错误的原因及解决方案》文章介绍了Vue项目部署步骤以及404错误的解决方案,部署步骤包括构建项目、上传文件、配置Web服务器、重启Nginx和访问域名,404错误通常是... 目录一、vue项目部署步骤二、404错误原因及解决方案错误场景原因分析解决方案一、Vue项目部署步骤

如何使用Java实现请求deepseek

《如何使用Java实现请求deepseek》这篇文章主要为大家详细介绍了如何使用Java实现请求deepseek功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录1.deepseek的api创建2.Java实现请求deepseek2.1 pom文件2.2 json转化文件2.2

python使用fastapi实现多语言国际化的操作指南

《python使用fastapi实现多语言国际化的操作指南》本文介绍了使用Python和FastAPI实现多语言国际化的操作指南,包括多语言架构技术栈、翻译管理、前端本地化、语言切换机制以及常见陷阱和... 目录多语言国际化实现指南项目多语言架构技术栈目录结构翻译工作流1. 翻译数据存储2. 翻译生成脚本

如何通过Python实现一个消息队列

《如何通过Python实现一个消息队列》这篇文章主要为大家详细介绍了如何通过Python实现一个简单的消息队列,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录如何通过 python 实现消息队列如何把 http 请求放在队列中执行1. 使用 queue.Queue 和 reque

Python如何实现PDF隐私信息检测

《Python如何实现PDF隐私信息检测》随着越来越多的个人信息以电子形式存储和传输,确保这些信息的安全至关重要,本文将介绍如何使用Python检测PDF文件中的隐私信息,需要的可以参考下... 目录项目背景技术栈代码解析功能说明运行结php果在当今,数据隐私保护变得尤为重要。随着越来越多的个人信息以电子形

使用 sql-research-assistant进行 SQL 数据库研究的实战指南(代码实现演示)

《使用sql-research-assistant进行SQL数据库研究的实战指南(代码实现演示)》本文介绍了sql-research-assistant工具,该工具基于LangChain框架,集... 目录技术背景介绍核心原理解析代码实现演示安装和配置项目集成LangSmith 配置(可选)启动服务应用场景