AcWing
  • 首页
  • 课程
  • 题库
  • 更多
    • 竞赛
    • 题解
    • 分享
    • 问答
    • 应用
    • 校园
  • 关闭
    历史记录
    清除记录
    猜你想搜
    AcWing热点
  • App
  • 登录/注册

AcWing 785. 快速排序算法的证明与边界分析    原题链接    简单

作者: 作者的头像   clearlife ,  2020-07-21 01:06:12 ,  所有人可见 ,  阅读 82551


1514


1257

更新:

2022.12.10

增加分析 6 和分析7, 修整了格式

2023.4.25

调整结构, 修改细节

2023.5.24

增加 python 版代码

算法证明

算法证明使用算法导论里的循环不变式方法

快排模板(以j为分界)

快排属于分治算法,分治算法都有三步:

  1. 分成子问题
  2. 递归处理子问题
  3. 子问题合并
void quick_sort(int q[], int l, int r)
{
    //递归的终止情况
    if(l >= r) return;

    //第一步:分成子问题
    int i = l - 1, j = r + 1, x = q[l + 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]);
    }

    //第二步:递归处理子问题
    quick_sort(q, l, j), quick_sort(q, j + 1, r);

    //第三步:子问题合并.快排这一步不需要操作,但归并排序的核心在这一步骤
}

待证问题

while 循环结束后,q[l..j] <= x,q[j+1..r] >= x

q[l..j] <= x意为q[l],q[l+1]...q[j-1],q[j]的所有元素都<= x

证明

循环不变式:q[l..i] <= x q[j..r] >= x

1. 初始化

循环开始之前i = l - 1, j = r + 1
则q[l..i],q[j..r]为空,循环不变式显然成立

2. 保持

假设某轮循环开始前循环不变式成立,即q[l..i] <= x, q[j..r] >= x

执行循环体

do i++; while(q[i] < x);
会使得 q[l..i-1] <= x, q[i] >= x

do j--; while(q[j] > x);
会使得 q[j+1..r] >= x, q[j] <= x

if(i < j) swap(q[i], q[j]);
会使得 q[l..i] <= x, q[j..r] >= x

所以,i和j更新之后,下一次循环开始之前,循环不变式依然成立

3. 终止

循环结束时,i >= j

正常情况下,按照循环不变式,我们应该会觉得结果已经显然了

因为i >= j,q[l..i] <= x, q[j..r] >= x

所以按照j来划分的话,q[l..j] <= x, q[j+1..r] >= x是显然的

可是,最后一轮循环有点特殊,因为最后一轮循环的if语句一定不会执行

因为最后一轮循环一定满足 i >= j,不然不会跳出while循环的,所以if语句一定不执行

正确分析:

由于最后一轮的if语句一定不执行

所以,只能保证:

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

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

  • i >= j

由q[l..i-1] <= x,i >= j(i-1 >= j-1) 和 q[j] <= x 可以得到 q[l..j] <= x

又因为q[j+1..r] >= x
所以,q[l..j] <= x,q[j+1..r] >= x,问题得证

总结:只有最后一轮循环结束时,循环不变式不成立,其余的循环都是成立的
但最终要求的问题还是解决了

注意:循环结束时要记得检查是否存在数组越界/无限划分的情况

所以还需要证明 j 最终的取值范围是[l..r-1](即不存在n划分成0和n的无限划分情况),分析过程在分析5

边界情况分析

快排属于分治算法,最怕的就是 n分成0和n,或 n分成n和0,这会造成无限划分

分析1

以j为划分时,x不能选q[r]

若以i为划分,则x不能选q[l]

假设 x = q[r]

关键句子quick_sort(q, l, j), quick_sort(q, j + 1, r);

由于j的最小值是l,所以q[j+1..r]不会造成无限划分

但q[l..j](即quick_sort(q, l, j))却可能造成无限划分,因为j可能取到r
举例来说,若x选为q[r],数组中q[l..r-1] < x,

那么这一轮循环结束时i = r, j = r,显然会造成无限划分

分析2

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

假设q[l..r]全相等

则执行完do i++; while(q[i] <= x);之后,i会自增到r+1

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

并且如果之后的q[i] <= x (此时i > r) 条件也不幸成立,

就会造成一直循环下去(亲身实验),造成内存超限(Memory Limit Exceeded)

现在已经变成 Time Limit Exceeded 了

分析3

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

可以使用if(i <= j) swap(q[i], q[j]);

因为 i = j 时,交换一下q[i],q[j] 无影响,因为马上就会跳出循环了

分析4

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

用i做划分时也是同样的道理

不能

根据之前的证明,最后一轮循环可以得到这些结论

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

所以,q[l..j-1] <= x 是显然成立的,

但quick_sort(q, j, r)中的q[j] 却是 q[j] <= x,这不符合快排的要求

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

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

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

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

分析5

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

证明:
假设 j 最终的值为 r ,说明只有一轮循环(两轮的话 j 至少会自减两次)

说明q[r] <= x (因为要跳出do-while循环)

说明 i >= r(while循环的结束条件), i 为 r 或 r + 1(必不可能成立)

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

得出 q[r] = x 和 q[l..r-1] < x 的结论,但这与 x = q[l + r >> 1]矛盾

反证法得出 j < r
假设 j 可能小于 l 说明 q[l..r] > x ,矛盾

反证法得出 j >= l
所以 j的取值范围为[l..r-1],不会造成无限划分和数组越界

分析6

while(i < j) 能否改为 while(i <= j)

不能

while(i <= j) 意味着我们认为判断循环结束的条件为 i <= j

那么 if(i < j) 也要改为 if(i <= j)

其实 if(i < j) 改不改都可以, 看完分析 6 后再参考分析 3 可以说明这一点

即

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

参考循环不变式的证明, 只有最后一轮循环有所不同

我们可以得到:

  • 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

也就是说, while(i <= j) 并不会改变循环不变式的部分

但修改后的代码提交后却是 Time Limit Exceeded(TLE), 原因在于无限划分

$~$

具体来说, 就是 j 在某些情况下能取到 l-1, 此时就是无限划分

q[l..r] 划分为 q[l..l-1], q[l..r]

某些情况指: 数组只有两个元素 [a, b] 且 a < b

这种情况下,

初始 i = l - 1, j = r + 1

第一轮 while 循环结束 i = l, j = l

第二轮 while 循环结束 i = r, j = l-1

于是 while(i <= j) 就造成了无限划分, 而 while(i < j) 就不会造成这个问题, 因为第一轮 while 循环结束后就跳出去了

所以, 不能用 while(i <= j)

$~$

有些人可能会疑惑: 这种情况看起来比较极端啊, 如果构造数组 [3, 2, 1] 会不会就不会遇到这种情况了

其实不然, 因为快排是分治算法, 往下递归时总会遇到 [a, b], a < b 这种情况

只要有一个这种情况, 就会进入无限划分出不来.

只有在数组元素全相等情况下才遇不到这种情况, 此时算法就能正常运行了, 读者可自行验证

分析7

循环不变式证明过程中

do i++; while(q[i] < x);
会使得 q[l..i-1] <= x, q[i] >= x

会使得 q[l..i-1] <= x, q[i] >= x 能否改为 会使得 q[l..i-1] < x, q[i] >= x

不能

这里的 q[l..i-1] <= x 是配合循环不变式 q[l..i] <= x q[j..r] >= x 的

于是问题就变成了循环不变式中 q[l..i] <= x 能否改为 q[l..i] < x

假定循环不变式是 q[l..i] < x, q[j..r] > x

执行两个 do-while 循环

do i++; while(q[i] < x);
会使得 q[l..i-1] < x, q[i] >= x

do j--; while(q[j] > x);
会使得 q[j+1..r] > x, q[j] <= x

则执行 if 语句后

if(i < j) swap(q[i], q[j]);

就会变成 q[l..i] <= x, q[j..r] >= x, 与假设矛盾

所以, 考虑最全面的描述还是要带上 = 的

分析8

使用 do-while 循环的好处

好处在于循环变量 i和j 一定更新, 循环不会卡死

如果使用while循环, i和j 在特殊情况下不更新的话,循环就会卡死

例:

while(q[i] < x) i++;
while(q[j] > x) j--;
当q[i]和q[j]都为 x 时, i 和 j 都不会更新,导致 while 陷入死循环

其余模板

用i做划分时的模板

// 从小到大
void quick_sort(int q[], int l, int r)
{
    if(l >= r) return;

    int i = l - 1, j = r + 1, x = q[l + r + 1 >> 1];//注意是向上取整,因为向下取整可能使得x取到q[l]
    while(i < j)
    {
        do i++; while(q[i] < x);
        do j--; while(q[j] > x);
        if(i < j) swap(q[i], q[j]);
    }
    quick_sort(q, l, i - 1), quick_sort(q, i, r);//不用q[l..i],q[i+1..r]划分的道理和分析4中j的情况一样
}

// 从大到小(只改两个判断符号)
void quick_sort(int q[], int l, int r)
{
    if(l >= r) return;

    int i = l - 1, j = r + 1, x = q[l + 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]);
    }
    quick_sort(q, l, j), quick_sort(q, j + 1, r);
}

python版

def quick_sort(q, l, r):
    if l >= r:
        return

    i = l - 1
    j = r + 1
    x = q[l + r >> 1]

    while i < j:
        i += 1
        while q[i] < x:
            i += 1

        j -= 1
        while q[j] > x:
            j -= 1

        if i < j:
            q[i], q[j] = q[j], q[i]

    quick_sort(q, l, j)
    quick_sort(q, j + 1, r)


n = int(input())
q = list(map(int, input().split()))

quick_sort(q, 0, n - 1)

print(' '.join(list(map(str, q))))

总结快排思路

  1. 有数组 q, 左端点 l, 右端点r

  2. 确定划分边界 x

  3. 将 q 分为 <=x 和 >=x 的两个小数组

  4. $i$ 的含义: $i$ 之前的元素都 $\leq x$, 即 $q[l..i-1]$ $\leq x$

  5. $j$ 的含义: $j$ 之后的元素都 $\geq x$, 即 $q[j+1..r]$ $\geq x$

  6. 结论: $while$ 循环结束后, $q[l..j]$ $\leq x,q[j+1..r]$ $\geq x$

  7. 简单不严谨证明:

    $while$ 循环结束时, $i \geq j$

    若 $i > j$ , 显然成立

    若 $i = j$

    $\because$ 最后一轮循环中两个 $do-while$ 循环条件都不成立

    $\therefore$ $q[i] \geq x, q[j] \leq x$

    $\therefore$ $q[i] = q[j] = x$

    $\therefore$ 结论成立

  8. 递归处理两个小数组

255 评论


用户头像
是亦晴吖   2022-07-02 19:33      71    踩      回复

曾以为自己会了,看到大佬的分析,才知道什么叫会了。

用户头像
RwChen   2022-12-20 00:08      17    踩      回复

我大概是记住了模板,但细节处应该还是未能理解

用户头像
caixuf   7个月前    回复了 RwChen 的评论         踩      回复

我也是,尴尬


用户头像
WA声闹彻明   2022-07-17 18:55      64    踩      回复

这里总接一下自己的惯性思维错误点:
1.分治的时候不能手贱把分界点写成中点,因为一开始的中点的值在调整后不一定还在那个位置;
2.while(q[i] < x) i++;=while(q[j] > x) j–-;
这么写的话,当遇上q[i]=q[j]=x并且i[HTML_REMOVED] x) j–-;结果发现更离谱,后来想了想,在这样一种情况下,如果第一次循环的时候就有x[j]<=mid,那么右指针便不会左移,同时如果在这一次循环中,左指针如果能够一马平川走到最右端,这种情况下我们的mid时整个数组最大值,那么在这样的情况下,一次循环过后无事发生将陷入无尽的死循环

用户头像
rookie.   10个月前         踩      回复

y总的模板中不是给的是x=q[(l+r)>>1]吗,这不就是分界点x取l,r的中点吗

用户头像
童蒙   8个月前    回复了 rookie. 的评论      1    踩      回复

这里说的应该是最后函数递归调用时的左右分界点

用户头像
acwing_0101   7个月前         踩      回复

明白了谢谢

用户头像
acwing_0101   7个月前    回复了 rookie. 的评论         踩      回复

就是选择分界点时要更新,而不是固定使用初始中点的值。


用户头像
djsnnxnnsa   2022-12-10 14:22      9    踩      回复

大佬,想问问为啥分界点x = q[l]的时候会时间超限x = q[l + r >> 1]就不会

用户头像
clart   2023-01-13 18:26      1    踩      回复

这题数据已经增强了,最好不要取两端点

用户头像
Carrot_8   2023-01-14 21:56    回复了 clart 的评论      1    踩      回复

想问一下这个x = q[l + r >> 1]是什么意思

用户头像
clart   2023-01-14 22:21    回复了 Carrot_8 的评论      6    踩      回复

l + r >> 1 等价于 (l + r) / 2,相当于取数组的中点作为分界点,这样鲁棒性更强

用户头像
Carrot_8   2023-01-15 10:23    回复了 clart 的评论      2    踩      回复

明白了谢谢谢谢

用户头像
冷鸟单推人   2023-03-20 13:27    回复了 clart 的评论      2    踩      回复

那就是说取j为分界点时候x取q[l]虽然不会死循环但是因为数据增强了所以还是超时是吗

用户头像
acwing_3681   7个月前    回复了 clart 的评论         踩      回复

大佬,为什么按视频里的(l+r+1)/2报 Memory Limit Exceeded 呢

用户头像
大新芽子   4个月前    回复了 acwing_3681 的评论         踩      回复

同问 解决了吗


用户头像
绯红之垠   2022-10-09 00:36      7    踩      回复

太强了 无限循环和无限递归我都遇到了,感谢大佬的分享。

用户头像
Chen_acwing_1024   2023-04-12 16:47      2    踩      回复

同学,您好,那个无限递归的分析一,不知道可不可以举个例子呢,我有点没看懂,哈哈

用户头像
童蒙   8个月前    回复了 Chen_acwing_1024 的评论      1    踩      回复

比如1,2,3,x选q[2],也就是3,while循环以后i=2,j=2,然后quick_sort(q,l,j),划分的之后区间长度跟原来还是一样的,那不就会无限递归划分下去

用户头像
杰_6   22天前    回复了 童蒙 的评论         踩      回复

感谢


用户头像
林小胖   2021-09-18 19:33      7    踩      回复

感觉很厉害 但是不懂分析的意义 理解之后有利于我们做什么

用户头像
qgbfhl   2023-02-20 10:53      1    踩      回复

有利于记忆模板呗

用户头像
GrinRain   2023-02-20 21:19      5    踩      回复

有利于面试

用户头像
rsdczhs   3个月前      1    踩      回复

意义可能就在于: 提高认识事物的层次。非常关键


用户头像
顺其自然_0   2022-02-18 09:36      6    踩      回复

大佬研究问题,很透彻,态度很认真。见到大佬,不谈脑力,态度上也有很大差距

用户头像
在下令狐冲   3个月前         踩      回复

确实hh


用户头像
天蓝色的彼岸   2022-10-14 01:58      5    踩      回复

do i++; while(q[i] < x);
会使得 q[l..i-1] <= x, q[i] >= x

这里q[l..i-1] <= x 应该是q[l..i-1] < x把, 可以等于吗?

用户头像
Hfour9   2022-11-01 11:07      2    踩      回复

第一次交换之前的时候是小于的,如果 i 和 j 指向的数都是 x 的话交换以后就有 q[l…i-1] <= x了

用户头像
知行合一_1   2022-11-17 09:39    回复了 Hfour9 的评论      5    踩      回复

不一定i,j都要为x,只要j指向的值与x相同,交换后i指向的值就变为与x等值了,i++后:q[i-1] = x

用户头像
Hfour9   2022-11-17 10:26    回复了 知行合一_1 的评论         踩      回复

对,考虑不周哈哈哈


用户头像
MBH   2022-04-09 02:31      3    踩      回复

while(q[i] < x) i++;
while(q[j] > x) j–;
当q[i]和q[j]都为 x 时, i 和 j 都不会更新,导致 while 陷入死循环

这里改成
while(q[++i ] < x) ;
while(q[–j ] > x) ;
是不是可以

用户头像
clearlife   2022-04-09 09:41      3    踩      回复

这个和 do while 循环应该是等价的

用户头像
acwing_2591   9个月前      1    踩      回复

你在swap后更新指针是不是可以避免

用户头像
acacaccccc   3个月前         踩      回复

这样的话i还是l-1,j还是r+1


用户头像
codeep   2023-07-16 22:25      2    踩      回复

大佬们,我把 mid 写成中点而不是中间的值,思路一样为啥会 WA???

#include <iostream>
using namespace std;

const int N = 1e5 + 10;
int a[N];

void quick_sort(int l, int r){
    if(l >= r)  return;

    int mid = l + r >> 1;
    int i = l - 1, j = r + 1;
    while(i < j){
        do i++; while(a[i] < a[mid]);
        do j--; while(a[j] > a[mid]);
        if(i < j) swap(a[i], a[j]);
    }
    quick_sort(l, j), quick_sort(j + 1, r);
}

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++)  cin >> a[i];

    quick_sort(0, n - 1);

    for(int i = 0; i < n; i++)  cout << a[i] << " ";

}
用户头像
如来到底来没来   2023-07-20 17:36      7    踩      回复

错误的地方应该在你那个a[mid],因为swap可能会修改a[mid]的值,下一次循环的时候位置不变但可能判定条件就变了。按理说应该是中间值不变,以中间值划分左右区间。

用户头像
codeep   12个月前    回复了 如来到底来没来 的评论         踩      回复

原来如此,感谢大佬

用户头像
zwj_123   11个月前         踩      回复

mid位置所在的值会改变


用户头像
pottery   2022-10-12 12:15      2    踩      回复

这个题解写的真的非常好,谢谢作者


用户头像
Joyi   2022-09-25 10:11      2    踩      回复

233


用户头像
晒干了沉默   2022-03-09 12:36      2    踩      回复

本人渣渣,只会调用sort来写,呜呜呜,大佬带带我呀


用户头像
算法newbie   3个月前      1    踩      回复

膜拜大佬


用户头像
农妇山泉有点田   7个月前      1    踩      回复

大佬们,我想问一下为啥我的出这个Time Limit Exceeded这是什么原因导致的

用户头像
goodluck_87   4个月前         踩      回复

你好,我也出现了这个问题,解决了吗

用户头像
安南_5   4个月前    回复了 goodluck_87 的评论      1    踩      回复

你选择x的方式不同会导致时间不同,这里面用l+r>>1来选x比较快,有的数据太大会让你时间超限


用户头像
benny_not_stupid   2023-07-16 12:49      1    踩      回复

就喜欢看这种严谨的证明,自己想不出来,但是总有大佬能理清思路


用户头像
SherlockZhu   2023-06-04 23:17      1    踩      回复

想了一天证明,才知道这个帖子,大佬tql,每个算法都像这样证明就好了


用户头像
2rd   2023-01-01 14:52      1    踩      回复

i和j的范围是怎么确定的呢?退出while的时候i是不是肯定等于j呢?

用户头像
微光_92   2023-01-06 19:42         踩      回复

不是while退出的时候j有可能在i左边一个位置,楼主说了退出时i=j是有条件的

用户头像
2rd   2023-01-07 13:50    回复了 微光_92 的评论         踩      回复

嗯,通常是i > j时候退出,只有最后一次交换发生在q[i] == q[j] == x时才是i = j


用户头像
大大辉将军   2022-09-24 21:30      1    踩      回复

orz


用户头像
mmmmmmm   2022-06-17 21:43      1    踩      回复

明白了


用户头像
C++小郑   2022-05-20 07:46      1    踩      回复

orz


你确定删除吗?

© 2018-2024 AcWing 版权所有  |  京ICP备2021015969号-2
用户协议  |  隐私政策  |  常见问题  |  联系我们
AcWing
请输入登录信息
更多登录方式: 微信图标 qq图标 qq图标
请输入绑定的邮箱地址
请输入注册信息