更新:
2022.12.10
增加分析 6 和分析7, 修整了格式
2023.4.25
调整结构, 修改细节
2023.5.24
增加 python 版代码
算法证明
算法证明使用算法导论里的循环不变式方法
快排模板(以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);
//第三步:子问题合并.快排这一步不需要操作,但归并排序的核心在这一步骤
}
待证问题
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))))
总结快排思路
-
有数组
q
, 左端点l
, 右端点r
-
确定划分边界
x
-
将
q
分为<=x
和>=x
的两个小数组 -
$i$ 的含义: $i$ 之前的元素都 $\leq x$, 即 $q[l..i-1]$ $\leq x$
-
$j$ 的含义: $j$ 之后的元素都 $\geq x$, 即 $q[j+1..r]$ $\geq x$
-
结论: $while$ 循环结束后, $q[l..j]$ $\leq x,q[j+1..r]$ $\geq x$
-
简单不严谨证明:
$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$ 结论成立
-
递归处理两个小数组
曾以为自己会了,看到大佬的分析,才知道什么叫会了。
我大概是记住了模板,但细节处应该还是未能理解
我也是,尴尬
这里总接一下自己的惯性思维错误点:
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时整个数组最大值,那么在这样的情况下,一次循环过后无事发生将陷入无尽的死循环
y总的模板中不是给的是x=q[(l+r)>>1]吗,这不就是分界点x取l,r的中点吗
这里说的应该是最后函数递归调用时的左右分界点
明白了谢谢
就是选择分界点时要更新,而不是固定使用初始中点的值。
大佬,想问问为啥分界点x = q[l]的时候会时间超限x = q[l + r >> 1]就不会
这题数据已经增强了,最好不要取两端点
想问一下这个x = q[l + r >> 1]是什么意思
l + r >> 1 等价于 (l + r) / 2,相当于取数组的中点作为分界点,这样鲁棒性更强
明白了谢谢谢谢
那就是说取j为分界点时候x取q[l]虽然不会死循环但是因为数据增强了所以还是超时是吗
大佬,为什么按视频里的(l+r+1)/2报 Memory Limit Exceeded 呢
同问 解决了吗
太强了 无限循环和无限递归我都遇到了,感谢大佬的分享。
同学,您好,那个无限递归的分析一,不知道可不可以举个例子呢,我有点没看懂,哈哈
比如1,2,3,x选q[2],也就是3,while循环以后i=2,j=2,然后quick_sort(q,l,j),划分的之后区间长度跟原来还是一样的,那不就会无限递归划分下去
感谢
感觉很厉害 但是不懂分析的意义 理解之后有利于我们做什么
有利于记忆模板呗
有利于面试
意义可能就在于: 提高认识事物的层次。非常关键
do i++; while(q[i] < x);
会使得 q[l..i-1] <= x, q[i] >= x
这里q[l..i-1] <= x 应该是q[l..i-1] < x把, 可以等于吗?
第一次交换之前的时候是小于的,如果 i 和 j 指向的数都是 x 的话交换以后就有 q[l…i-1] <= x了
不一定i,j都要为x,只要j指向的值与x相同,交换后i指向的值就变为与x等值了,i++后:q[i-1] = x
对,考虑不周哈哈哈
大佬研究问题,很透彻,态度很认真。见到大佬,不谈脑力,态度上也有很大差距
确实hh
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) ;
是不是可以
这个和 do while 循环应该是等价的
你在swap后更新指针是不是可以避免
这样的话i还是l-1,j还是r+1
大佬们,我把
mid
写成中点而不是中间的值,思路一样为啥会 WA???错误的地方应该在你那个
a[mid]
,因为swap可能会修改a[mid]
的值,下一次循环的时候位置不变但可能判定条件就变了。按理说应该是中间值不变,以中间值划分左右区间。原来如此,感谢大佬
mid位置所在的值会改变
大佬,递归排序中,把j改成mid行吗
这个题解写的真的非常好,谢谢作者
233
本人渣渣,只会调用sort来写,呜呜呜,大佬带带我呀
膜拜大佬
大佬们,我想问一下为啥我的出这个Time Limit Exceeded这是什么原因导致的
你好,我也出现了这个问题,解决了吗
你选择x的方式不同会导致时间不同,这里面用l+r>>1来选x比较快,有的数据太大会让你时间超限
就喜欢看这种严谨的证明,自己想不出来,但是总有大佬能理清思路
想了一天证明,才知道这个帖子,大佬tql,每个算法都像这样证明就好了
i和j的范围是怎么确定的呢?退出while的时候i是不是肯定等于j呢?
不是while退出的时候j有可能在i左边一个位置,楼主说了退出时i=j是有条件的
嗯,通常是i > j时候退出,只有最后一次交换发生在q[i] == q[j] == x时才是i = j
orz
明白了
orz