前言
🤫博主计划开一个新的专栏讲解XCPC算法知识,本专栏是博主学习Acwing上y总闫学灿巨佬的算法视频课(基础课、提高课)的笔记,其中代码为yxc巨佬的模板。我结合了视频课的主要内容和博主自己的思考,根据视频顺序,从最基础的部分开始讲解。阅读本专栏之前,你只需要掌握C++基本语法和STL库的简单使用即可。笔者建议,若你想真正把这些文章化为内功,你需要做到:1.深刻理解算法思想。2.这些算法至少打上10-20遍以做到烂熟于心,才能在比赛中临危不乱。那么闲言少叙,就让我们直接开始吧!一.快速排序(以升序为例)
快速排序是一种基于递归(把复杂的问题划分为层层子问题)思想的排序算法,它的步骤如下(以升序为例):1.在数组中随便找一个数x作为分界点(这里指的是值而不是数组下标,我们一般找数组左端点/中点/右端点的值。但是我们后面会看到,取中点的值是最方便的)
2.整理数组,使得x所在下标的左边的数都小于等于x,x所在下标右边的右边的数都大于等于x
3.将左右两边分别排序。由于此时x已经在它应该在的位置(x左边的数都小于等于x,x右边的数都大于等于x),所以只要排好左右两边即可。
这里第一步和第三步都很简单,那么如何实现第二步呢?我们给出的图解如下:
i和j是我们定义的两个分别从左边和右边遍历数组的变量。当i指向的数小于x时,或j指向的数大于x时,说明该数在它应该在的位置上,我们让i和j前进;而若i指向的数大于x且j指向的数小于x时,我们让i和j指向的数进行交换。这样一来,在i、j相遇时,i左边的数都小于等于x,j右边的数都大于等于x了。下面就让我们来看看代码模板吧:
#include <iostream>
using namespace std;
const int N = 1e6+10;
int n;
int q[N];
void quick_sort(int q[],int l,int r)
{
//如果区间中没有数或者只有一个数就直接返回
if(l>=r) return;
int x = q[l+r>>1],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]);
}
quick_sort(q,l,j);
quick_sort(q,j+1,r);
}
int main()
{
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.最外层的while循环条件可以改为i<=j吗?
不可以!!想象只有两个数1、2的场景:
在最后一次循环时的if语句判断中,a[j]会造成越界访问!而即使我们忽略这个越界访问,还有另外一个问题是最后一次执行循环语句的递归调用时还会调用这个数组本身,这样就形成了死递归!!
2.为什么i初始值设为l-1,j初始值设为r+1呢?
这是因为无论a[i]、a[j]与x的大小关系如何,i、j总是要++的(即使只自加一次)。那么为了方便,我们就把它俩初始化在数组外一个单位的地方,这样它们进来的时候就刚刚好啦😉😉
3.可以把i的表达式传入quick_sort函数中么?
当然可以啦🙆!但是为了对称,你必须上面传i-1,下面传i,如下:
quick_sort(q,l,i-1);
quick_sort(q,i,r);
4.(重头戏来啦)那么把起始或者结束位置的值作为x可以吗?
理论上是可以的,只是此时我们应注意以下细节:当下面的递归调用传i相关的表达式时,起始位置的值不能作为x;当下面的递归调用传j相关的表达式时,结束位置的值不能作为x。原因如图:
当只有1、2两个数时,i=j以后若调用quick_sort(q,i,r),会造成死地归!而我们可以发现,模板代码中的x实际上也是0号下标的值,但是恰好我们选择了传j的表达式,恰好避开了这个问题。但是奇怪的是,这样调整以后在原先的Acwing上能过,现在却不能过了。笔者也不明白这是为什么🙅。那为了简便,我们还是按照模板那样取中点吧!
二.归并排序(以升序为例)
与快速排序类似,归并排序也要用到递归的思想。它的步骤如下:
1.选择中间位置作为分界点(注意,这次选择的是下标,而非中间位置的值)/(ㄒoㄒ)/~~
2.将中间位置的左右两边分别进行排序。
3.将排序后的左右两部分归并为一个数组。(这里用到双指针的技巧,类似合成两个有序链表。如果你有链表相关知识储备,不妨参考一下这篇文章:双指针、递归与虚拟头结点三板斧,带你刷穿Leetcode链表OJ!)
那么参考前面的快速排序,我们很容易就可以写出代码:
merge_sort(q,l,mid);
merge_sort(q,mid+1,r);
int i = l,j = mid+1;
//k用来记录tmp数组里当前数据个数
int k = 0;
while(i<=mid&&j<=r)
{
//把小的数据依次放到tmp里
if(q[i]<=q[j]) tmp[k++] = q[i++];
else tmp[k++] = q[j++];
}
//哪部分的“类指针”没走完就接哪部分
while(i<=mid) tmp[k++] = q[i++];
while(j<=r) tmp[k++] = q[j++];
//tmp中的数据拷贝回q中
for(i = l,j = 0;i<=r;i++,j++) q[i] = tmp[j];
}
int main()
{
scanf("%d",&n);
for(int i = 0;i<n;i++) scanf("%d",&q[i]);
merge_sort(q,0,n-1);
for(int i = 0;i<n;i++) printf("%d ",q[i]);
return 0;
}
说明:由于左右两部分数组都已经是升序,那么i、j分别指向的就是自己部分数组剩下数据的最小值。把这两个值中的较小值取出来,就是剩下所有数据中的最小值啦!
下面我们来分析一下归并排序的时间复杂度。由于一共递归logn次后可以使得区间长度被分割为1,而每层递归扫描的时间复杂度都是O(N),那么
有了快速排序作为基础,再看归并排序是不是觉得简单?
三、二分查找
二分查找是这样的算法,它有点类似于数学中连续函数基于零点存在定理的牛顿法找根:在一段具有单调性的序列中,首先取区间中点数据,观察它是否满足条件。若不满足,则取它与其中一个端点(这个端点也不满足条件,但是不满足的方向与中点相反。比如,如果我们要找一段序列找某个数,那么如果中点值大于这个数,我们就找值小于这个这个数的端点,那么这个数就在它俩中间了。)的中点,以此循环,直到最终把符合边界条件的点逼出来。但是这个算法并非只能用于处理已经具有顺序的序列,它其实适用于这样的序列:左半部分具有某种性质P,右半部分具有某种性质Q,而右半部分不具有性质P,左半部分不具有性质Q。这种情况下,我们可以利用二分查找来找到两种性质的分界点。说明:我们认为边界点仍然满足对应的性质。下面让我们先来了解整数二分吧!!~~~
三.1整数二分
整数二分适用的序列如图:
找到p和q刚好对应了整数二分的两个不同模板!让我们来看看吧~~
模板1(找p)
mid = (l+r+1)/2;
if true 则答案在[mid,r]区间内 更新l = mid | |
---|---|
check(mid) | |
if false 则答案在[l,mid-1]区间内 更新r = mid-1 |
说明:这里的check函数用于检验是否满足左半边性质。取中点时为什么要补上+1我们后面会讲到
有了思路,那么我们就可以生成模板啦!
void bSearch_1(int l,int r)
{
while(l<r)
{
int mid = l+r+1>>1;
if(check(mid)) l = mid;
else r = mid-1;
}
return 1;
}
模板2.(找q)
mid = l+r>>2;
if true 则答案在区间【l,mid中 更新 r = mid | |
---|---|
check(mid) | |
if false 则答案在区间【mid+1,r】中,更新l = mid+1 |
那么,第二种模板呼之欲出——
void bSearvh_2(int l,int r)
{
while(l<r)
{
int mid = l+r>>1;
if(check(mid)) r = mid;
else l = mid+1;
}
}
记忆要点:首先写一个check函数,然后根据check(mid)的结果决定如何更新l与r。如果更新方式为l = mid,那么用l+r+1>>1定义mid,否则mid就是l+r>>1。还有,注意l = mid和r = mid-1配套,r = mid和l = mid+1配套。不要记混了。
说明:
1.为什么模板1要用l+r+1>>1来定义mid呢?因为若我们令mid = l+r>>1,当l = r-1时,由于向下取整,mid = r-1 = l。此时如果check成功了,令l = mid,相当于令l = l,会陷入死递归。
2.while里面的判断条件能否改为<=?看了以下的示意图后,我相信读者能给出正确的答案——
如图,假设我们用模板2寻找满足条件Q的边界,而恰好这个边界就在第一个元素处,那么最后将会出现left = right = mid的情况。若此时再次进入循环,更新r = mid仍为r,会陷入死循环。
接下来让我们通过一道题目来练习两个模板的使用吧!
#include<iostream>
using namespace std;
const int N = 1e5+10;
int n,q;
int a[N];
int main()
{
scanf("%d%d",&n,&q);
for(int i = 0;i<n;i++)scanf("%d",&a[i]);
//一共输入q行
while(q--)
{
//输入要查询的数
int x;
scanf("%d",&x);
int l = 0,r = n-1;
while(l<r)
{
int mid = (l+r)/2;
//if括号里的实际上就是check函数
//先找x第一次出现的位置——
//由于升序萍排列,那么我们要找x第一次出现的位置就是找满足>=x的边界,其实找的是Q点。那么我们就用模板2.
//这里不能找<=x的边界!如 3 3 3 3 3 ……这样找到的其实是x最后一次出现的位置!!这里千万注意!
if(a[mid]>=x) r = mid;
else l = mid+1;
}
//如果给出的数组中根本就没有我们想要找的数,那么我们第一次二分得到的其实是该数组中最小的数。这时只要输出-1 -1即可
if(a[l]!=x) cout<<"-1 -1"<<endl;
else
{
cout<<l<<' ';
l = 0;r = n-1;
while(l<r)
{
int mid = (l+r+1)/2;
//以下是找x最后一次出现的位置
//由于是升序排列,那么我们要找<=x的边界,找的是P点,那么套用模板一即可。
//这里不能找>=x的边界,原因类上。
if(a[mid]<=x) l = mid;
else r = mid-1;
}
cout<<l<<' '<<endl;
}
}
return 0;
}
注意,二分一定有结果,但是这个结果未必符合题意。二分可以保证每次答案都落在我们选择的区间中。
三。2 浮点数二分
浮点数的二分比整数的二分要简单很多,因为它没有取整的问题,所以就可以严格取到中点而不用考虑边界条件。那么让我们通过一道例题来学习一下吧!
例题:求一个非负数的平方根(不允许使用库函数)
#include <iostream>
using namespace std;
int main()
{
double x;
cin>>x;
//不能让r = x!因为若x<1时答案是不在0-x范围内的!
double l = 0,r = max(1,x);
//当区间长度足够小时我们认为l与r重合。根据经验,精度一般是题目要求的精度加上2.(单位为1e-1)
//另一种常用写法如下:
//for(int i = 0;i<n;i++)直接暴力循环100次。
while(r-1>1e-8)
{
double mid = (l+r)/2;
if(mid*mid>=x) r = mid;
else l = mid;
}
printf("%lf\n",l);
return 0;
}
好啦,以上就是本篇文章的全部内容!如果你觉得有帮助的话,麻烦点赞+关注+收藏,ICPC之旅,小鲨鱼永远陪伴你!