算法基础1.1.1快速排序

前言

千万不要小看这个快速排序,虽然思路很简单,一听感觉代码就能写出来,但是其实里面的坑还是很多的。

同时,算法题对于时间的要求也十分苛刻,只能说这次给我带来了一点小小的算法震撼。

如果想深究的话,几乎每一行都能问出来几个问题,我也是看了好多题解,搜了好多资料才把基本会想出的疑问解决了

快速排序是一种分治策略的排序算法,是由英国计算机科学家 Tony Hoare 发明的,该算法被发布在 1961 年的 Communications of the ACM 国际计算机学会月刊。

快速排序是对冒泡排序的一种改进,也属于交换类的排序算法。它也是八大排序算法中最常用的经典排序算法之一。其广泛应用的主要原因是高效,核心算法思想是「分而治之」。快速排序经常会被作为面试题进行考察,通常的考察思路是快排思想、编码实践之手写快排以及进一步对快排的优化。

正文

这里直接放出一张动态图便于理解。

img

快速排序的思路很简单,就是双指针。快速排序的思想就是我们选定一个数字为标准,这个标准称之为轴(pivot)。那么我们通过双指针的遍历,将小于pivot的放在pivot的左边,将大于pivot的放在pivot的右边。这样的话左侧的数据就整体上小于右侧的数据。接着我们再对左侧的数据继续执行上述的操作,直到整体有序。

这里用到了递归的方法,我们不断地细分由轴分割出来的两个区域。每次都能保证前个区域整体上小于后个区域,那么随着不断细分到区域内只有一个数的时候,我们就完成了快速排序。我们将这种一直细分的思想叫做微分,而算法上我们叫做分治

分治算法基本都是三步

  1. 分成子问题
  2. 递归处理子问题
  3. 子问题合并

现在我们来看老师的代码:

#include<bits/stdc++.h>

using namespace std;

//将数组定义放在外面,防止栈溢出
const int N = 1e6 + 10;
int q[N];

void quick_sort(int q[],int l,int r){
    if(l>=r)
        return;
    //选取中值为轴,提高速度
    int x = q[(l+r)/2], i = l - 1,j = r + 1;
    while(i<j){
        do
            i++;
        while (q[i] < x);
        do
            j--;
        while (q[j] > x);
        if(i<j)
            swap(q[i], q[j]);
    }
    //使用i也可以,但是要对称,同时防止边界问题
    quick_sort(q, l, j);
    quick_sort(q, j + 1, r);
}

int main(){
    int n;
    //scanf的运行速度是比cin要快的,算法题中一般用scanf
    scanf("%d", &n);
    for (int i = 0; i < n;i++){
        scanf("%d", &q[i]);
    }
    quick_sort(q, 0, n - 1);
    for (int i = 0; i < n;i++){
        printf("%d ", q[i]);
    }
    return 0;
}

逐步分析

菜单

  1. 问:为何数组定义要写在主函数外面?
  2. 问:为什么使用scanf而不是用cin
  3. 正常情况下,轴可以为最左,最右,中间。那么如果我们选最左最右,有什么要求
  4. 为什么最后选择将轴取为中间值而不是两边,为什么去两边会TLE(超过时间限制)?
  5. 老师写的(l+r )>> 1为什么相当于(l+r)/2
  6. 问:do i++; while(q[i] < x)do j--; while(q[j] > x) 中不能用q[i] <= x q[j] >= x
  7. 问:do i++; while(q[i] < x)do j--; while(q[j] > x) 中不能加上条件i<j
  8. if(i < j) swap(q[i], q[j])能否使用 i <= j
  9. 最后一句能否改用quick_sort(q, l, j-1), quick_sort(q, j, r)作为划分
  10. 证明j的取值范围为[l..r-1]
  11. 分析10中引出的问题:为什么不可能是r+1
  12. 主循环的判断条件while(i < j) 能否改为 while(i <= j)
  13. 为什么要用do...while

分析1

问:为何数组定义要写在主函数外面?

正常情况下我们都是将数组写道主函数内部,尤其是对于初学者来说一个习惯。但是算法题中要谨慎:算法题的测试集中的数据往往很大,比如要输入100000个数据,那么如果我们真的写在主函数内部,而且把数组大小定义为100000,就会出现数组栈溢出的问题

数组栈溢出:对于C语言的内存分配,可以分为以下五个区域:

  1. 代码区:存放编译后的代码的二进制机器码
  2. 堆区:用于动态内存分配,一般有程序员分配和释放。如果最后没有释放,那么可能由操作系统回收,但也有可能会出现问题。C中是malloc-free,C++中式new-delete
  3. 栈区:由编译器自动分配和释放,一般存放局部变量和函数参数
  4. 全局初始化数据区/静态数据区(Data Segment):就是存放全局变量和静态变量的地方,这一块区域被整个进程共享
  5. 未初始化数据区:在运行时改变值。改变值(初始化)的时候,会根据它是全局变量还是局部变量,进到他们该去的区

栈给数组的内存一般只有1-2M,我们按2M来思考,也就是也就是210241024=2097152字节,局部变量空间顶多放得下下524288个int类型,所以我们设置的过大就会栈溢出

解决方法:最简单的就是在主函数外部定义,把他作为全局变量交给Data Segment,它能给到很大很大的内存。或者也可以通过static关键词把他变成静态变量。

分析2

问:为什么使用scanf而不是用cin

在同样输入一个数,cin的编译时间大约是scanf的3~4倍。所以,在使用大量数据的时候,cin的运算速度明显要满于scanf

简单来讲scanfcin在时间效率上差别很大的原因是:

  • scanf元素的类型我们已经告知了,机器不用再去查找元素类型,scanf需要自己写格式,是一种格式化输入。

  • 而在cin元素类型由机器自己查找,cin是字符流输入,需要先存入缓冲区再输入

但是,值得一提的是,cin的安全性比scanf强。

分析3

正常情况下,轴可以为最左,最右,中间。那么如果我们选最左最右,有什么要求

这里面出现了第一个边界问题,也就是无限划分。把n分成了0和n或者n和0,既然第一次把整体分完还是整体,那么就意味着以后还是这样,也就进入了死循环。这个并不止于第一次划分,往后的细分中只要有一次,你的代码就g了。

正确做法是如果下面递归用的是quick_sort(q, l, j), quick_sort(q, j + 1, r);,那么我们不能将轴设置为q[r],也就是选用右指针作为递归分界时,轴不能是最右边。反之同理。

我们假设用j划分,然后轴取最右边

j+1绝对大于0(分析10会证明j的取值范围),所以quick_sort(q, j + 1, r)不会出现无限划分的问题

但是quick_sort(q, l, j)就有可能了,我们下面举个例子:

如果q[l...r-1]<x(数组q中从l到r-1的数都小于x),那么左边指针i会一直走到最右边,而j也会停在最右边(一开始j指向的是最右边+1,经过do后到最右边,while不成立所以没有继续移动)。那么这次结束后的下一次的两个递归就分别是quick_sort(q, l, j)quick_sort(q, j + 1, r)。后者相当于里面没有值,会直接return,而前者则跟本次循环内容一模一样,其实就是quick_sort(q, l, r)

而如果我们取最左边为轴

那么上面的那种特殊情况就会变成q[l...r-1]>x(如果还讨论上面的就没有意义了,因为他代表的不是一个情况,而是多个)。那么最后结果会是i=j=l,拆分出来的两个区间一个是l----l一个是l+1-----r所以没有问题

分析4

为什么最后选择将轴取为中间值而不是两边,为什么去两边会TLE(超过时间限制)?

这个要看出题人了,正常是三种取法都可以,但是如果出题人加强了数据,对时间的限制提高,那么就会有几组测试数据超过了出题人的要求。下面解释以下原因:

正常来说快速排序的时间复杂度是O(n logn),这里的log底是2,原因如下

理想的状态下我们每次选取的分界点都是这个区间的中位数,也就是每次都能将这个区间分为两个数据数量相等的子区间。

递归的第一层,n个数被划分为2个子区间,每个子区间的数字个数为n/2;
递归的第二层,n个数被划分为4个子区间,每个子区间的数字个数为n/4;
递归的第三层,n个数被划分为8个子区间,每个子区间的数字个数为n/8;
  …

递归的第logn层,n个数被划分为n个子区间,每个子区间的数字个数为1;

而对于每一层,我们都要处理所有的子区间,也就是我们要遍历一遍所有数,所以每一层的时间复杂度都是n。

综上,理想情况下整体的时间复杂度就是n logn

最差情况下快速排序的时间复杂度是O(n^2):

我们知道,冒泡排序每次其实相当于都是找出未处理数据中的最大/最小值将他移动到一边,比如第一次把最大值移动到第一位,第二次把第二大的值移动到第二位…他一共要移动n次,而每次都要遍历剩下的所以元素,所以它的时间复杂度是O(n^2)。

而最坏的情况就是快速排序演变的和冒泡排序一样:如果待排序数是随机的,那么选择第一个或者最后一个作基准是没有什么问题的,这也是我们最常见到的选择方案。但如果待排序数据已经排好序的,就会产生一个很糟糕的分割。因为这会导致你选择的轴是一个最值,一个指针只移动两次(有一次要抵消赋值时对他的+1/-1),而另一个指针会一直移动直至两指针重合。相当于经过一次处理,分出来的两个区间,一个只有一个数,一个有n-1个未处理的数。这样的情况下,时间花费了,却没有做太多实事。而它的时间复杂度就是最差的情况O(n^2)。

然而即使是选择中间位置来尽量避免这种区间一个过大一个过小的情况,面对由相同数构成的数组,依然还是最差的时间复杂度。

分析5

老师写的(l+r )>> 1为什么相当于(l+r)/2

这是位运算的知识,位运算就是将十进制转化为二进制,然后将其进行左移或者右移

**左移<<:**每左移一位相当于乘2,但是需要注意的是只适用于符号位不改变的情况(原码最高位符号0为正,1为负),如果符号发生改变就不能做乘2的运算,否则会溢出,得到的数就不是乘2的结果

右移>>: 每右移一位相当于除2, 算术右移等于除以2向下取整,(-3)>> 1 = -2 ,3 >> 1 = 1。值得一提的是,整数/采用的是向0取整,(-3)/2=-1

其实最重要的就是右移作为除法,某些算法竞赛中不允许开O2优化,因此手写位运算能提升1~1.5倍的效率,如果你能理解自己写的是什么东西,那写位运算挺好的。实际开发中,需要考虑代码的易读性,而且编译器会优化,因此不建议这么写。

位运算的技巧

详情见: c++之位运算(详解,初学者绝对能看懂)c++ 位运算?!??的博客-CSDN博客

这里只转载其中的几个:

1.判断奇偶性

如果把 n 以二进制的形式展示的话,其实我们只需要判断最后一个二进制位是 1 还是 0 就行了,如果是 1 的话,代表是奇数,如果是 0 则代表是偶数,所以采用位运算的方式的话,代码如下:

if(n&1){    //若n&1==1(为真)
   //n是奇数
}
if(!(n&1)){ //若n&1!=1(为假)
  //n是偶数
}

2.求a的b次方

本题涉及数论数论数论,在次不详细解释,有兴趣的话可以自己了解.

int pow(int a,int b){
    int ans = 1;
    while(b != 0){
        if(b & 1 == 1){
            ans *= a;
        }
        a *= a;
        b = b >> 1;
    }
    
    return ans;
}

注意,若数据过大,应改为long long

举例: b = 13,则 b 的二进制表示为 1101, 那么 a 的 13 次方可以拆解为:

a^1101 = a^0001 * a^0100 * a^1000。

我们可以通过 & 1和 >>1 来逐位读取 1101,为1时将该位代表的乘数累乘到最终结果。

这样时间复杂度是O(logn),比库里的pow函数O(n)快

3.找出未重复的数

数组中,只有一个数出现奇数次,剩下都出现偶数次,找出出现奇数次的。

**思路:**两个相同的数异或的结果是 0,一个数和 0 异或的结果是它本身,所以我们把这一组整型全部异或一下。也就是说,那些出现了偶数次的数异或之后会变成0,那个出现奇数次的数,和 0 异或之后就等于它本身。

#include<iostream>
using namespace std;
int main(){
    int a[9]={4,3,2,2,2,2,5,4,3};
    int ans=0;
    for(int i=0;i<9;i++){
        ans^=a[i];
    }
    cout<<ans;
    return 0;
}

4.用O(1)时间检测整数n是否是2的幂次

思路

  • n如果是2的幂次,则:
    1.n>0
    2.n的二进制表示中只有一个1
    一位n的二进制表示中只有一个1,所以使用n&(n-1)将唯一的一个1消去。

  • 如果n是2的幂次,那么n&(n-1)得到结果为0,即可判断。
    eg: 8的二进制位1000,7的二进制位0111,8&7==0,所以8是2的幂次

    #include<iostream>
    using namespace std;
    int main(){
        int n;
        cin>>n;
        if(!(n&(n-1))) cout<<"YES";
        if(n&(n-1)) cout<<"NO";
        return 0;
    }
    

分析6

问:do i++; while(q[i] < x)do j--; while(q[j] > x) 中不能用q[i] <= x q[j] >= x

我们假设数组中的数全相等,则执行完do i++; while(q[i] <= x);之后,i会自增到r+1

然后继续执行q[i] <= x 判断条件,造成数组下标越界(但这貌似不会报错)

并且如果之后的q[i] <= x (此时i > r) 条件也不幸成立,就会造成一直循环下去

分析7

问:do i++; while(q[i] < x)do j--; while(q[j] > x) 中不能加上条件i<j

如果这样操作的话,会导致最后i=j,

假如我们的递归是像代码一样以j为分界点,他们现在所指向的值大于轴,那么意味着i停下来的原因是这个数并不小于轴,而j停下来的原因是因为不满足i<j。而此时进行递归的话,第一个递归式子的区间是l---j这个区间最后一个数是一个大于x的值

不过如果使用的是i<=j就不会出现这种情况,因为判定停止的时候j=i-1,而此时j指向的数一定是小于轴的,不然i到这里的时候就会因为不满足小于轴而停下来。

分析8

if(i < j) swap(q[i], q[j])能否使用 i <= j

可以,相同了交换一下没有什么影响

分析9

最后一句能否改用quick_sort(q, l, j-1), quick_sort(q, j, r)作为划分

这个问题对于i同理

现在我们单独分析一轮的过程。我们要注意一点,在该轮之前所有循环中,由于我们在检测完成后交换了ij的值,所以可以保证q[l..i] <= x, q[j..r] >= x

但是对于最后一次循环,他是不可能进行交换的。如果能进行交换的话,那么说明两个指针还没相遇,也就是说这不是最后一轮。

因此,所有循环结束后,我们只能得到保证

  • q[l..i-1] <= x, q[i] >= x

  • q[j+1..r] >= x, q[j] <= x

  • i >= j

那么如果使用的是上面提出的想法,对于quick_sort(q, j, r)来说,q[j]是小于轴的,这明显就不满足快排思想了

另外一点,注意quick_sort(q, l, j-1), quick_sort(q, j, r)可能会造成无线划分

x选为q[l]时会造成无限划分,报错为(MLE),

如果手动改为 x = q[r],可以避免无限划分

但是上面所说的q[j] <= x 的问题依然不能解决,这会造成 WA (Wrong Answer)

分析10

证明j的取值范围为[l..r-1]

假设j最终值为r,那么说明只经历了一次循环(j在执行完do语句后将最初的+1抵消掉,此时指向r)。

说明q[r]<=x(由j在此处停下,也就是退出do…while语句可知)。

说明i>=r(由整体在执行一次后就退出循环体可知)

所以irr+1,然而r+1的情况一定不会成立(分析11)

说明i自增到了r,说明q[r]>=x 和 q[l...r-1]<x

我们上面还得出了q[r]<=x的结论,所以q[r]=xq[l..r-1] < x,而这与我们定义轴的时候的x = q[l + r >> 1]矛盾

反证法得出j<r

然后我们假设j可能小于r,说明q[l...r]>x,这个显然也是矛盾的,与上面同理,区间里一定有一个值等于x(被选中作为轴),所以不可能全大于,矛盾。

反证法得出j>l

综上所述,证明j的取值范围为[l..r-1],这也说明了我们的方法并不会导致无限划分和数组越界。

分析11

分析10中引出的问题:为什么不可能是r+1

假设ir+1,那么说明q[l...r]<x,反证的思路与分析10相同,都是利用区间中一定存在一个数值为x,他既不是小于也不是大于来推出矛盾。

分析12

主循环的判断条件while(i < j) 能否改为 while(i <= j)

先说结论:不能

while(i <= j)
{
    do i++; while(q[i] < x);
    do j--; while(q[j] > x);
    if(i < j) swap(q[i], q[j]);
}

分析9中我们证明过:

  • q[l..i-1] <= x, q[i] >= x

  • q[j+1..r] >= x, q[j] <= x

  • i > j

最终我们还能证明出q[l..j] <= x,q[j+1..r] >= x,所以这个改动并不影响循环体的部分

但是它会导致TLE(Time Limit Exceeded),原因是里面产生了无限划分

目前代码产生无限划分的具体情形就是j可能会取到l-1,这样在第二个递归就是无限划分

例子如下:

假如我们的区间是[1,2]

那么此时的轴为1,经过第一次循环后i=l同时j=l,改动后就会进行下一次循环

第二次循环后i=l+1同时j=l-1,问题就出现了

虽然这个例子看起来很极端,其实他代表的是[a,b]a<b这个大类。同时值得注意的是,快速排序是分治的过程,区间在不断减小,所以往下递归时遇到这种情况几乎是必然的。只要分治过程中有一个这样的区间就会导致无限划分。

分析13

为什么要用do...while

如果不使用do...while那么ij的初始值就应该是lr。因为while进入的时候就要判断了,所以必须让指针已经指向区间内的数据。

改动后会出现死循环

还是[1,2],此时会发现两者都无法进入各自的循环,但是此时两个指针也没相遇所以无法退出主循环,所以就会一直循环下去

结语

一共用了两天,刚听老师讲完思想我就直接自信的去敲代码了,然后一直排错2,3个小时也没成功

然后当天整个晚上加上第二天上午和中午,终于把我能想出来的问题点都通过查资料和别人的题解分析出来了(剩下的问题点之所以想不出来是因为我不想再想出来了)

最后用一个大佬的总结作为我文章的总结大佬的题解

image-20230225152610548

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值