快速排序
前言
快速排序最大的问题是边界问题,如何合理的理解和分析边界是这道题的重点。
模板
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 ];
//由于使用do—while循环,所以在定义时将边界外移
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);//递归处理问题
}
分析环节
注:分析的内容参照来源于AcWing 785. 快速排序算法的证明与边界分析 - AcWing,本篇分析仅用于个人学习及分享。
原理
快排的原理是在一串数列里任选一个数字,使这个数前面的数都小于这个数,其后面的数都大于这个数(默认升序),然后通过递归,不断改变排序区间,实现排序功能。
问题一:使用while与do-while的区别
do i ++;while(q[i] < x);
do j --;while(q[j] > x);
do-while是先自增再判断,避免了存在判断后自增不执行的情况。当q[i] == q[j]时,i , j 则都不会更新,导致程序跳不出while(i < j)的循环
问题二:边界处理问题
quick_sort(q,l,j);
quick_sort(q,j + 1,r);
每次在调用这个函数的时候,我们都要保证两个区间取到的范围是不相交的,以此处为例,当我们用j来划分区间时,j 的位置满足 q[j] <= x;所以划分到(l,j);大于x 的部分划分到(j + 1,r );使用i的话同理,只不过要分辨他们的区别 。
算法证明
由于这一部分没有学习过,所以先补上有关证明方法循环不变式的链接。
《算法导论》——循环不变式 - 知乎 (zhihu.com)
证明的方法与步骤在最开始链接的题解里面有,但我还是在这里再写一遍,大家可以移步题目开始的链接查看题解。
循环不变式性质
初始化:循环的第一次迭代之前,它为真。
保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。
证明
问题:while 循环结束后,q[l…j] <= x,q[j+1…r] >= x
1.初始化
循环开始之前q[l…i],q[j…r],区间为空,循环不变式成立。
2.保持
执行循环体时
do i++; while(q[i] < x);//会找到大于x的值
do j--; while(q[j] > x);//会找到小于x的值
if(i < j) swap(q[i], q[j]);//当i < j时,交换q[i],q[j]的值,直到i >= j;使问题成立
3.终止
循环结束时,i >= j,所以最后一个swap不执行,此时q[i] > x,q[j]<x,所以q[l … i] < x;q[j … r]>x;
与上述结果相符合。
归并排序
前言
归并排序与快排都是运用分治思想的一个排序算法,归并相对于快排更加稳定,当然,理解重点同样是边界问题。
模板
void merge_sort(int q[], int l, int r)
{
if (l >= r) return;//用于判断执行结束
int mid = l + r >> 1;
merge_sort(q, l, mid), merge_sort(q, mid + 1, r);//先划分区间再排序
int k = 0, i = l, j = mid + 1;
while (i <= mid && j <= r)
if (q[i] <= q[j]) tmp[k ++ ] = q[i ++ ];
else tmp[k ++ ] = q[j ++ ];//处理过程,将数从小到大放入temp
while (i <= mid) tmp[k ++ ] = q[i ++ ];
while (j <= r) tmp[k ++ ] = q[j ++ ];//将未处理完的数放在temp后
for (i = l, j = 0; i <= r; i ++, j ++ ) q[i] = tmp[j];//将排好序的数再赋给q
}
分析环节
注:分析的内容参照来源于AcWing 787. 归并排序的证明与边界分析 - AcWing,本篇分析仅用于个人学习及分享。
原理
通过不断划分区间,使每个区间的值都顺序排列,再不断合并区间,达到排序的目的。
问题一:边界问题
此处边界问题与上相同,不做说明。
算法证明
此处证明仍然用到循环不变式性质。完整证明过程请移步链接查看。
证明
问题:tmp 保存的是 q[l…mid] , q[mid+1…r] 中从小到大排序的所有数
1.初始化:k = 0时,tmp中无值,成立
2.保持
循环过程中,依次从mid的两边取最小值,将排好序的字符存入tmp。
while (i <= mid && j <= r)
if (q[i] <= q[j]) tmp[k ++ ] = q[i ++ ];
else tmp[k ++ ] = q[j ++ ];
然后再处理mid之前或之后未处理的数(必定大于tmp里已存储的数)依次存入tmp。
while (i <= mid) tmp[k ++ ] = q[i ++ ];
while (j <= r) tmp[k ++ ] = q[j ++ ];
最后把处理完的数再赋给q。
for (i = l, j = 0; i <= r; i ++, j ++ ) q[i] = tmp[j];
3.终止
在结束的时候,mid前的的数顺序排列,mid后的数顺序排列,再从中依次找出最小值,就可以得到排好序的数列
知识补充
摊还分析
**摊还分析(amortized analysis)**是一种分析一个操作序列中所执行的所有操作的平均时间分析方法。与一般的平均分析方法不同的是,它不涉及概率的分析,可以保证最坏情况下每个操作的平均性能。
一下信息参考此篇文章
摊还分析:从一个宏观的视角看复杂度 - 知乎 (zhihu.com)
(82条消息) 算法导论随笔(十二):摊还分析(Amortized Analysis)之聚合分析、核算法和势能法_天降风云的博客-CSDN博客_聚合分析 算法导论。
聚合分析(aggregate analysis)
聚合分析证明对于所有的N,一个N个操作的序列最坏情况花费总时间是 T(N) ,因此每个操作的摊还代价是 T(N)/N 。
直白一点说,聚合分析就是抓住最坏情况不是总会发生这个特点,最坏情况往往需要一定的条件,并且以较低的概率发生。
当循环每执行一个操作时,其所花费的的为1。
while(n --) cin >> a[n];
也就是说这个操作n的时间复杂度为O(n)。对于可以推导出递归式的程序来说,我们可以推出来他的时间复杂度。
核算法(accounting method)
如果计算机每执行代价为1的操作,就要收1元钱,而且概不赊欠。
那么我们可以对不同的操作赋予不同的费用。有的操作我们多拿出一点钱,一部分用来进行当前的运算,一部分存起来作为信用,用于以后的支付。
简单理解就是把每一个元素所有将会用到的所有操作的代价算到这个操作头上,从而降低时间复杂度分析的难度。
势能法(potential method)
势能法的思想与核算法类似,依旧是类似于买往返票这种提前支付的模式。不同的是,在核算法中,我们是用微观的方式对每一个操作赋予代价;而在势能法中,我们是用宏观的方式来对整个数据结构来进行代价的评估。
简单来说就是将一个数组类比为一个弹簧,当数组中数量越多时,势能越大,直到数组扩充到满,再将数组扩充到一个新的数组。
二分
前言
二分作为常见的做题思路,在做题过程中会经常遇到。二分可以分为整数二分与浮点数二分,由于整数不能被完美的分成两个部分,所以对于整数二分的边界处理也是最重点理解的地方。
整数二分
模板
说明:此处有两个二分模板,需在不同情况判断使用哪一个。
当我们将区间[l, r]划分成[l, mid]和[mid + 1, r]时,其更新操作是r = mid或者l = mid + 1;,计算mid时不需要加1。
int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1;
if (check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
当我们将区间[l, r]划分成[l, mid - 1]和[mid, r]时,其更新操作是r = mid - 1或者l = mid;,此时为了防止死循环,计算mid时需要加1。
int bsearch_2(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
分析环节
当我们需要在一堆数中查找某个数时,一个一个查找的方式虽然简单,但它会带来巨大的运算量,当面对庞大的数据时,表现会不尽如人意。二分的存在,就是对其的一个优化,每次查找时可以减少一半的运算量,大幅提高了运算效率。
原理
对于一个有序数列,每次找到它的中间一项,判断是否满足题目的要求,然后选择满足的一侧区间,重复进行此操作,直到找出对应答案。
那么问题来了,我们提供了两个二分模板,如何在做题时选择正确的模板使用呢。
问题:选择模板的条件
以提供的例题为例,在1,2,3,3,5中找到3 的区间。
分析第一个模板
if (check(mid)) r = mid;
else l = mid + 1;
当我们满足判断条件时,划分的区间在不断向左边移动,直到找到符合题意的区间边界。
第二个模板
if (check(mid)) l = mid;
else r = mid - 1;
当我们满足判断条件时,划分的区间在不断向右边移动,直到找到符合题意的区间边界。
也就是说,针对例题分析,模板一不断查找的答案的左边界,模板二不断查找的是右边界。
简单理解后,我们就可以根据不同的题目去选择模板啦。
浮点数二分
相对来说,浮点数二分就简单很多了,碰到直接使用就可以了。
模板
while (r - l > 1e-8)
{
int mid = l + r >> 1;
if (check(mid)) r = mid;
else l = mid ;
}
我认为浮点数二分唯一要注意的就是跳出循环的条件了,不过也好理解。
(持续更新ing。。。)