算法(二):从二分到分治
1.二分:
二分可以看作是特殊的分治,其内容与分治极其像。顾名思义,二分通过将序列分为左右两段进行解题,如此递归(通过递归实现)。那么,像这样解题显然是需要序列的 单调性
I . I. I.二分查找
引入:
猜数游戏:给出一个取值范围为 [ 1 , 100 ] [1,100] [1,100] 的数,请你用平均次数最少的方法猜这个数。每次仅给出你说出的答案与这个数的大小关系。
显然从中间猜次数最少吧。因为期望的次数是 1 次,而最坏次数为 100 次。朴素方法的平均为 n 次。而每次都排除一半肯定稳定性高且每次都能在平均上浮动,最差每次也能排除一半。
介绍:
二分查找是二分的一种利用,其复杂度为 Θ ( l o g n ) \Theta(logn) Θ(logn)
那么为什么有这个复杂度呢?
由于每次二分都有单调性,那么每次将数列从中一分为二,其中一半一定可以排除。于是就每次都除以2,就可记作 l o g n log n logn , 就如猜数游戏一样。
那么,有了以上思路,便可给出模板了
题目:请在给定的含 n n n 个数的单调序列 a a a 中找到 m m m 的位置(下标从1开始)
输入格式:第一行,一个数 n n n( 1 ≤ n ≤ 1 0 5 1 \leq n \leq 10^5 1≤n≤105),第二行 n n n 个互不相同的正整数(保证不会爆 i n t int int ),第三行给出一个数 T T T ( 1 ≤ T ≤ 1 0 3 1 \leq T \leq 10^3 1≤T≤103),表示询问的次数。下面 T T T 行, 每行一个数 m m m (保证不会爆 i n t int int 且一定有这个数)
样例输入:
5
1 2 3 4 5
2
2
3
样例输出:
2
3
根据我们刚刚说的,可以得到的代码如下(尤其注意二分时的上下界更新情况,可以自己推导,亦可以多试是小于还是要取得等)
#include<cstdio>
const int N=1e5+5;
int n,m,T;
int a[N];
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]);
scanf("%d",&T);
while(T--){
scanf("%d",&m);
int l=1,r=n;
while(l<r){
int mid=(l+r)>>1;
if(a[mid]>m)
r=mid-1;
else
l=mid+1;
}
printf("%d\n",r);
}
return 0;
}
请尤其注意
while(l<r){
int mid=(l+r)>>1;
if(a[mid]>m)
r=mid-1;
else
l=mid+1;
}
中的更新,并非每次都要 -1 或 +1 ,而是根据实际情况决定的。
I I . II. II. 二分答案
介绍:
顾名思义,二分答案就是在一定范围内通过二分法的优异效率来枚举答案的。
与二分查找很相似,以下直接给出两道题:
1.luogu:P1873:砍树
伐木工人米尔科需要砍倒 M M M 米长的木材。这是一个对米尔科来说很容易的工作,因为他有一个漂亮的新伐木机,可以像野火一样砍倒森林。不过,米尔科只被允许砍倒单行树木。
米尔科的伐木机工作过程如下:米尔科设置一个高度参数 H H H(米),伐木机升起一个巨大的锯片到高度 H H H,并锯掉所有的树比H高的部分(当然,树木不高于 H 米的部分保持不变)。米尔科就获得树木被锯下的部分。
例如,如果一行树的高度分别为 20,15,10 和 17,米尔科把锯片升到 15 米的高度,切割后树木剩下的高度将是 15,15,10 和 15,而米尔科将从第 1 棵树得到 5 米,从第 4 棵树得到 2 米,共得到 7 米木材。
米尔科非常关注生态保护,所以他不会砍掉过多的木材。这正是他为什么尽可能高地设定伐木机锯片的原因。帮助米尔科找到伐木机锯片的最大的整数高度 H H H,使得他能得到木材至少为 M M M 米。换句话说,如果再升高 1 米,则他将得不到 M M M 米木材。
输入格式
第一行 2 个整数 N N N ( 1 ≤ N ≤ 1 0 5 ) (1\le N \le 10^5) (1≤N≤105)和 M M M ( 1 ≤ M ≤ 2 ⋅ 1 0 9 ) (1\le M \le 2\cdot 10^9) (1≤M≤2⋅109)分别表示树木的数量和需要的木材总长度。
第二行 N 个整数表示每棵树的高度,值均不超过 1 0 9 10^9 109。所有木材长度之和不低于 M,因此必有解。
输出格式
一个整数,表示木机锯片的最大的高度 H H H。
Sample Input
5 20
4 42 40 26 46
Sample Output
36
代码如下:
#include<cstdio>
const int N=1e5+5;
int n,m,T;
int a[N];
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]);
scanf("%d",&T);
while(T--){
scanf("%d",&m);
int l=1,r=n;
while(l<r){
int mid=(l+r)>>1;
if(a[mid]>m)
r=mid-1;
else
l=mid+1;
}
printf("%d\n",r);
}
return 0;
}
那么这就是二分的全部内容了!以下我将会给出一组比较有代表性的题的地址:
那么接下来:
2.分治:
基本思路:
分治,顾名思义,分而治之,那么就应该有类似于二分的过程。先将一个母问题拆分成几个子问题,分别解决;其中子问题也一定能分到一个基本的可以手动求解的问题。此时再把几个问题合并起来,就可以得到母问题的解。
同样的,单向基本操作时间复杂度也为 Θ ( l o g n ) \Theta(logn) Θ(logn) ,为什么要说“基本”呢?因为我们发现,如果额外地进行操作,即进行合并,一定会有 ( n l o g n ) (nlogn) (nlogn) 的时间,如归并排序。
特殊的,分治不需要单调性,因为每次都会按基准数进行分组,相当于是把左右看作两个数,具备单调性了。
实现与例题:
1.快速排序 / 归并排序:
这个很好理解,不给题面了。快排代码与图示如下。
#include<cstdio>
#include<cstdlib>
#include<iostream>
const int N=(1e5)+9;
int a[N];
void qsort(int l,int r){
if(l>r) return ;
int i=l,j=r,T=a[(l+r)/2];
while(i<=j){
while(a[i]<T) i++;
while(a[j]>T) j--;
if(i<=j){
std::swap(a[i],a[j]);
i++;
j--;
}
}
qsort(l,j);
qsort(i,r);
return ;
}
int main(){
int n;
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]);
qsort(1,n);
for(int i=1;i<=n;i++)
printf("%d ",a[i]);
return 0;
}
其中,T为基准数。
而比较麻烦但好用的归并 (merge) 排序如下。
#include<iostream>
#include<cstring>
const int N=1e5+5;
int a[N],tmp[N];
int n;
void merge(int l,int r){
int mid=(l+r)>>1;
if(l==r)
return ;
merge(l,mid);
merge(mid+1,r);
int i=l,j=mid+1,flag=l;
while(i<=mid&&j<=r){
if(a[i]>a[j])
tmp[flag++]=a[j++];
else
tmp[flag++]=a[i++];
}
while(i<=mid)
tmp[flag++]=a[i++];
while(j<=r)
tmp[flag++]=a[j++];
//update
for(int i=l;i<=r;i++)
a[i]=tmp[i];
return ;
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]);
merge(1,n);
for(int i=1;i<=n;++i)
printf("%d ",a[i]);
return 0;
}
代码全程高能,请注意。
2.经典题:P1908:逆序对
肯定不用我多说了,只需要在源代码上加上ans的更新即可:
#include<cstdio>
const int N=1000005;
int n;
long long ans=0;
int a[N],tmp[N];//tmp存排完序的数
void merge(int l,int r){
int mid=(l+r)>>1;
if(l==r)
return ;
merge(l,mid);
merge(mid+1,r);
int i=l,j=mid+1,flag=l;
while(i<=mid&&j<=r){
if(a[i]>a[j]){
ans+=mid-i+1;
tmp[flag++]=a[j++];
}else{
tmp[flag++]=a[i++];
}
}
while(i<=mid){
tmp[flag++]=a[i++];
}
while(j<=r){
tmp[flag++]=a[j++];
}
//update
for(int i=l;i<=r;i++)
a[i]=tmp[i];
return ;
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]);
merge(1,n);
printf("%lld",ans);
return 0;
}
紧接着推荐去做:
P1115 最大子段和
P2345 [USACO04OPEN]MooFest G
然后就是汉诺塔问题,其实也没那个必要。。。
那么,介绍一个小技巧:可以用前缀和来减少额外求和带来的时间复杂度;然后还可以在一些特定的题中套merge或数据结构,如这道题:
题目背景
这是一道模板题,可以使用 bitset,CDQ 分治,KD-Tree 等方式解决。
题目描述
有 n n n 个元素,第 i i i 个元素有 a i , b i , c i a_i,b_i,c_i ai,bi,ci
三个属性,设 f ( i ) f(i) f(i) 表示满足 a j ≤ a i a_j \leq a_i aj≤ai且 b j ≤ b i b_j \leq b_i bj≤bi 且 c j ≤ c i c_j \leq c_i cj≤ci
对于 d ∈ [ 0 , n ) d \in [0, n) d∈[0,n),求 f ( i ) = d f(i) = d f(i)=d 的数量。
输入格式
第一行两个整数 n , k n,k n,k,表示元素数量和最大属性值。
接下来 n n n 行,每行三个整数 a i , b i , c i a_i ,b_i,c_i ai,bi,ci,分别表示三个属性值。
输出格式
n n n 行,第 d + 1 d + 1 d+1 行表示 f ( i ) = d f(i) = d f(i)=d 的 i i i 的数量。
那么这里只讲 CDQ 分治的解法,以后更新到“数据结构”可能会具体讲:
#include<cstdio>
#include<algorithm>
const int N=2*(1e+5)+1;
struct node{
int x,y,z;
int num;
}a[N];
int c[4*N],b[N],tmp[N],f[N];
int n,k;
void updata(int x,int v){
while(x<=k) c[x]+=v,x+=x&(-x);
return ;
}
int sum(int x){
int s=0;
while(x) s+=c[x],x-=x&(-x);
return s;
}
//1sort
bool cmp1(node a,node b){
if(a.x!=b.x) return a.x<b.x;
if(a.y!=b.y) return a.y<b.y;
return a.z<b.z;
}
//2sort
bool cmp2(node a,node b){
if(a.y!=b.y) return a.y<b.y;
if(a.z!=b.z) return a.z<b.z;
return a.x<b.x;
}
void merge(int l,int r){
//普通分治写法
if(l==r)
return ;
int mid=(l+r)>>1,flag;
merge(l,mid);
merge(mid+1,r);
//排序维护(如末尾所说,此处针对二三维进行维护)
std::sort(a+l,a+r+1,cmp2);
//求和更新
for(int i=l;i<=r;i++)
(a[i].x<=mid)? updata(a[i].z,1),flag=i : b[a[i].num]+=sum(a[i].z);
for(int i=l;i<=r;i++)
if(a[i].x<=mid)
updata(a[i].z,-1);
return ;
}
inline int read(){
char c=getchar();
int x=0;
while(c<=32) c=getchar();
while(c<='9'&&c>='0'){
x=x*10+(int)(c-'0');
c=getchar();
}
return x;
}
int main(){
//实测read()仅比scanf快20ms左右,不值得
scanf("%d%d",&n,&k);
for(int i=1;i<=n;i++){
scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].z);
// a[i].x=read(),a[i].y=read(),a[i].z=read();
a[i].num=i;
}
//分治前维护
std::sort(a+1,a+1+n,cmp1);
//printf("!");
for(int i=1;i<=n;){
int j=i+1;
while(j<=n&&a[j].x==a[i].x&&a[j].y==a[i].y&&a[j].z==a[i].z)//不能写if,分组时应可停下来
j++;
while(i<j)
tmp[a[i].num]=a[j-1].num,i++;
}
for(int i=1;i<=n;i++)
a[i].x=i;
//printf("!");
merge(1,n);
//统计
for(int i=1;i<=n;i++)
f[b[tmp[a[i].num]]]++;
for(int i=0;i<n;i++)
printf("%d\n",f[i]);
return 0;
}
以下引用洛谷题解
luogu大佬说这道题要把三维降到二维,并把第三元素用数据结构维护期间需用CDQ分治,如:(luogu题解)
逆序对的问题是二维的,我们只需要对一维排序,然后在用树状数组维护即可。
那么对于三维的陌上花开呢?我们还是可以用这个方法,首先先将数列按第一位排序,这样我们只需要考虑两维的情况。于是我们可以分治做了,将某一个序列 [ l , r ] [l,r] [l,r] , 分成段 [ l , m i d ] [l,mid] [l,mid] 和 [ m i d + 1 , r ] [mid+1,r] [mid+1,r],然后在对 [ l , r ] [l,r] [l,r] 这段区间的
第二维进行排序。若点在排序前属于 [ l , m i d ] [l,mid] [l,mid],树状数组单点修改;否则该点在排序前属于 [ m + 1 , r ] [m+1,r] [m+1,r] ,便统计一次。(其实就是类似于树状数组求逆序对的操作)
cdq分治是一种神奇的分治算法
例如本题,将第二维的区间采取二分,首先递归计算左边内部对自己的贡献,然后计
算左边对右边的贡献,最后递归计算右边自己内部贡献
本人不太会,也不太喜欢树状数组,所以害怕讲不清楚,便引用洛谷题解。
本题还可以用merge嵌套来做,感兴趣的请移步 luogu 。