高级搜索
搜索是一种通过暴力穷举进行模拟的方法,它的查询过程似一颗树,在走向正确答案的过程中会重复大量的、无关的步骤,以至于它的时间复杂度令人难以接受。
而高级搜索就是采取尽可能的规避多的无关情况,避免多次重复走树上的同一条路,对“搜索”这颗树进行各式各样的“剪枝”,以优化它的复杂度。
对搜索的操作进行限定
-
状态剪枝,挖掘背景信息,减去不必要的状态。
-
迭代加深,限定搜索树的深度,排除不必进行的选择。
-
启发式搜索,常用作搜索最小步骤,通过对当前节点到目标节点所需步骤的估计,来进一步进行可行性剪枝。注意:所需步骤的估计不能超过实际步骤。
双向搜索
一般搜索树的时空复杂度是指数级的,“剪枝”操作大多数也只能将它的指数减小个七八九十,那在面对单次分流操作过多、层数过深的情况,该怎么办呢?
“搜索”这一暴力的方法已经注定了它的时间复杂度是指数级的,而在指数中,参考分治中“快速幂”那一题,一个大指数可以由两个小指数的平方来合并,那么搜索是不是也可以?
当然可以。如果从前后两个方向同时进行搜索,搜索树的深度不就立刻减少了一半吗?以此为基础,我们所要考虑的就只有“合并”操作的维护了。
双向搜索的特点:
-
明确知到搜索的起点与终点,从两端同时出发进行搜索,以找到它们相遇的位置为结束。
-
题目的“目标”可以被划分成两个可合并的部分,而题目的条件也可划分成两个等价的部分,且这种划分不影响结局;以找到两部合并后为“目标”的位置为结束
上述两种情况的双向搜索都需要哈希表,也就是map的帮助去存储它们每一个节点的值,以判断“相遇”或“合并”与否。
例题:
此题明确知到终点,且单次变换状态较多(0最多可以往四个方向移动),适合进行双向搜索。
代码示例:
#include<bits/stdc++.h>
#include<time.h>
using namespace std;
int goal[10],goll;//目标状态 数组/数字
int now[10],noww;//当前状态 数组/数字
int hans,eans;//前半段步数、后半段步数
int none[10];//空数组模板
int finding=0;
struct s{
int n,step;
};
map<int,s> ma;//记录答案、前后
queue<s> hd,ed;
int num(int d[])//把数组d转换为数字并返回
{
int sum=0;
for(int i=0;i<9;i++)
sum+=d[i]*pow(10,8-i);
return sum;
}
void arry(int c,int d[])//把数字c转换为数组存入d中
{
for(int i=8;i>=0;i--)
{
d[i]=c%10;
c/=10;
}
}
void copyary(int a[],int b[])//复制b数组到a数组
{
for(int i=0;i<9;i++)
a[i]=b[i];
}
// 队列 当前的答案 当前的步数 当前是从前搜还是从后搜
void change(queue<s> &a, int* ans, int* nn, int b)//单次广搜搜索模板
{
s tep=a.front();
int step=tep.step;
*nn=tep.n;
int aa=tep.n;
*ans=step; //记录当前步数
a.pop();
if(ma.find(aa)!=ma.end()) //要扩展的队列是否已经到达过
{
if(ma[aa].n!=b) //前后已经相遇了
{
if(b==1)//如果这次是从前搜的
eans=ma[aa].step;//把从后搜的答案更新
else
hans=ma[aa].step;//同上
finding=1;//找到了
return;
}
else return; //已经到达过,不用在扩展了,返回
}
else ma[aa]={b,step}; //如果没到达过,则记录此次到达
copyary(now,none); //队列清0,防止队首无元素而出错
arry(*nn,now);//数字化队列
int i=0;
for(;i<9;i++) //找0的位置
if(now[i]==0) break;
int no[10]; //临时存储
//------------------四个方向——上下左右-------------------
if((i+1)/3==i/3) //0前面有数
{
copyary(no,none);//队列清0,防止队首无元素而出错
copyary(no,now); //复制队列
no[i]=no[i+1]; //交换位置
no[i+1]=0;
int te=num(no); //队列转数字
a.push({te,step+1});
}
if(i>0&&(i-1)/3==i/3)//0后面有数
{
copyary(no,none);
copyary(no,now);
no[i]=no[i-1];
no[i-1]=0;
int te=num(no);
a.push({te,step+1});
}
if(i-3>=0)//0上面有数
{
copyary(no,none);
copyary(no,now);
no[i]=no[i-3];
no[i-3]=0;
int te=num(no);
a.push({te,step+1});
}
if(i+3<=8)//下面有数
{
copyary(no,none);
copyary(no,now);
no[i]=no[i+3];
no[i+3]=0;
int te=num(no);
a.push({te,step+1});
}
}
main()
{
goll=123804765;
cin>>noww;
arry(noww,now);//存进数组
arry(goll,goal);//同上
hd.push({noww,0});
ed.push({goll,0});
while(!finding)//双向广搜
{
if(hd.size()<=ed.size())//队列长度短的进行扩展,保证平衡
change(hd,&hans,&noww,1);
else
change(ed,&eans,&goll,2);
}
cout<<hans+eans;
}
搜索的难度并不体现在思想和理解上,而是体现在操作、挖掘具体的题意关系与实现上,细节繁杂,与模拟题有些许相似,考察的是思维的严密性和对代码的操作能力。
二叉堆
“最值”往往都是一个区间中最引人注目的因素,在许多问题中,我们需要的,可能仅仅是一个序列当前的最值,而并不关心其他的值。但在需要反复获取最值的时候,用一般的方法往往会被其他无关紧要的值干扰,造成较高的时间复杂度。怎么化解这些干扰呢?二叉堆能够满足我们的诉求。
二叉堆是通过维护一颗树,使其满足:
-
所有父节点都比它子节点的值大/小;
-
这颗树是完全二叉树。
其中,性质1保证了它能快速取出最值(根节点就是最值),而性质2是为了便于维护二叉堆中数据的添加与删除。
二叉堆的操作:
-
取最值;如上,不再赘述。
-
添加元素;在数组末尾直接添加数据,而后与父节点进行比较交换,递归完成。
-
删除元素;直接把根节点删去,把末尾节点移到根节点上,然后从根开始与子节点进行比较交换,递归完成。
二叉堆的中心规则其实很朴素,就是拆东墙补西墙——
它要满足自己设立的两条铁律,而在使用过程中,无论是添加元素还是删除元素都一定会破坏这两条规则,二叉堆所做的,就是通过一系列操作,去缓解或消除这些破坏(如换位置等)。
代码示例:
#include<bits/stdc++.h>
using namespace std;
void change_up(int x,int last);
int h[5000000];
int k;
void push(int n) //添加元素n
{
h[++k]=n;
change_up(k/2,k);
}
//------------------比较交换函数--------------------
void change_up(int x,int last)//x:当前询问的节点; last:上一个节点
{
if(x==0) return; //到顶了,退出
if(h[x]>h[last]) //父节点大了,交换
{
int t=h[x];
h[x]=h[last];
h[last]=t;
change_up(x/,x); //继续访问父节点
}
else return;
}
void change_down(int x,int last)//x:当前查询的节点; last:上一个节点
{
if(x>k) return; //到底了,退出
int now=0;
if(x==k) now=k;
else
now=h[x]<h[x+1]?x:x+1; //当前两个子节点(相对上一个父节点)选一个值小的来比较
if(h[now]<h[last]) //子节点更小,交换
{
int t=h[now];
h[now]=h[last];
h[last]=t;
change_down(2*now,now); //继续访问子节点
}
else return;
}
//-----------------------------------------------------
void pop()//删除节点
{
if(k!=1)
h[1]=h[k--];
else //需要对把堆删空的情况特殊处理
{
h[1]=-1;
k=0;
}
change_down(2,1);
}
关于二叉堆的理解和解释:
-
为什么采用二叉树的形式来达成这个目的?三岔树、四岔树、或其他数据结构不行吗?
其他数据结构当然可以,但所谓术业有专攻。二叉堆成为区间最值解法的代表,是因为它在操作简便,无冗余的同时,依旧能以比较方便的思路和合理的时间复杂度完成这些操作;而其他的数据结构不是实现操作没有它简单,就是“杀鸡焉用牛刀”。
-
为什么“二叉树”这种形式能够达成这个目的,它有什么特点和特殊使它能够胜任?
在一开始学二叉树的时候,遇到过一个“淘汰赛制”的问题时,二叉树的特点就已经初现端倪:
如图。不难发现,在二叉树状的晋级赛中,只有冠军所得的名次是名副其实的。事实上,二叉堆中,父子节点的关系保证了每课树的值一定是一个上升的趋势;而兄弟节点之间的关系和优先级完全并列,并无任何联系,或者说,二叉堆并没有尝试去编排兄弟节点之间的关系,它跳过、略去了这个操作。(个人理解)
而正是二叉堆对于父子节点与兄弟节点关系间的取舍的思想,使它不同于其他树状数据结构,在放弃维护兄弟间秩序的同时,保证了父子节点的上升,利用了冠军永远都“名副其实”的特点,确定且只确定了冠军。(个人理解)
线段树
线段树通过区间的分割与合并,在信息可合并的前提下,能够高效的进行区间/单点的所有查询/修改操作,可以以较低的时间复杂度解决这类问题。
简单来说,线段树是分治的实体化体现。
它的出现也让其他一些分治的问题的多了一个解决方案(一步步扩展),如逆序对。
-
线段树的关键要素与操作
-
明确需求,清晰数的节点需要存储什么东西,大多数时候节点至少要存储:左右端点、节点的权值、延迟标记;至少是四个元素。
-
明晰操作量与操作细节。在修改权值时,需要递归找到目标区间的子区间,而后修改权值与延迟标记,并在之后从该节点返回它的父节点合并更新;在查找区间时,需要递归找到目标区间的子区间,而后从该节点返回它的父节点并更新本次查找的答案,同时,在每次递归时,都要优先传递父节点的lazy标记。
-
明确单次递归所要完成的任务,避免在递归途中混淆不同操作的完成者。例如赋值、更新、判断终点、判断交集等等操作是在父/子节点完成
-
struct treee{
int l,r;
long long cnt=0;
long long lazy=0;
};
treee t[200000];
// 节点 目标左 目标右 对目标区间操作的值
int addtree(int x,int l,int r,int d) //更新节点值
{
int ad=0;
int rr=t[x].r,ll=t[x].l;
if(t[x].l>r||t[x].r<l)//当前范围与目标范围毫无关系,终止
return 0;
if(t[x].l>=l&&t[x].r<=r) //当前范围是目标范围的一部分,返回
{
t[x].cnt+=d*(rr-ll+1);
t[x].lazy+=d;
return d*(rr-ll+1); //区间修改,修改的大小要乘上区间长度
}
//更新后从下往上维护节点
ad+=addtree(2*x,l,r,d);
ad+=addtree(2*x+1,l,r,d);
t[x].cnt+=ad;
return ad; //依据下层得到的增量,直接加
}
long long findtree(int x,int l,int r,long long lazy)
{
int rr=t[x].r,ll=t[x].l;
long long sum=0;
//查找时从上往下维护节点
t[x].cnt+=lazy*(rr-ll+1); //更新当前节点的权值
t[x].lazy+=lazy; //更新当前节点的往下的影响
//-------------------以上线段树的默认操作优先于其他目标操作------------------------
if(t[x].l>=l&&t[x].r<=r) //当前节点是目标范围的一部分,返回
return t[x].cnt;
if(t[x].l>r||t[x].r<l) //当前节点与目标范围完全无关,终止
return 0;
//其他只要与目标范围有联系,就两条子树都找
//这是为了清晰方便,也是为了能够将lazy直接更新到两颗子树,避免只更新一颗的麻烦
t[x].lazy=0; //影响已传达到子树了,归零
sum+=findtree(2*x,l,r,t[x].lazy);
sum+=findtree(2*x+1,l,r,t[x].lazy);
return sum;
}
从以上代码可以看出,在线段树中,因为操作的繁杂与大量,对于目标区间与当前区间相交程度的判断,往往不是在当前函数得出,而是在子区间函数,通过简单的判断有/无交集就得出结论,大大减去了各种相交状态的分析。
递归函数,突出一个“只管当前层的事”,只管当前层有没有相交,只管当前层应不应该返回,而不会去管我往下能不能递归。
线段树的应用
线段树的强大大家都有目共睹,能够以优秀的复杂度执行单点/区间的查询/修改。这么厉害的数据结构,肯定要能用就用起来(不然白学了)。
有关线段树的题目往往都在“合并”二字上下文章。既然线段树可以维护“能合并”的数据,那找出和抽象化各种合并的形式就显得很重要;
对于单次修改涉及多次不同等级的运算操作的情况,延迟标记lazy的维护同样要寻找方法进行变化,让lazy依旧可以正确下放。
例题:
lazy能单独存储“乘”和“加”吗?很显然不能。lazy向下传递信息的过程中没法区分“先后”。那如果建造一个结构体,去专门存储这些运算的先后呢?很显然,又冗杂又可能在面对一些数据时大量浪费时空。
此时可以注意到,lazy只能传递同级运算符(不分先后的),目前的操作虽然有乘法和加法,但不难发现,所谓“乘法”,也不过是另一种程度上的“加法”;
当接受到乘法操作时,我们可以直接将“要加上的数”提前与乘法操作相乘,而后向下的传递就是确定的先后了(因为加法已经提前乘过了,所以是先乘再加)
代码示例:
#include<bits/stdc++.h>
using namespace std;
int n;
int m;
struct treee{
int l,r;
long long cnt=0;
long long lazy=0;
long long mul=1;
};
treee t[500000];
void buildtree(int x,int l,int r)
{
t[x].l=l,t[x].r=r;
if(l==r) return;
long long mid=(l+r)>>1;
buildtree(2*x,l,mid);
buildtree(2*x+1,mid+1,r);
}
// /节点//目标左//目标右//修改值//加or乘/ /乘传递/ /“加”传递/
long long addtree(int x,int l,int r,int d,int j,long long mul,long long lazy) //不是新增节点,而是更新节点值
{
int rr=t[x].r,ll=t[x].l;
//查找时从上往下维护节点
t[x].cnt=(t[x].cnt*mul)%m;//更新当前节点的权值
t[x].cnt=(t[x].cnt+lazy*(rr-ll+1))%m;
t[x].lazy=t[x].lazy*mul%m; //更新当前节点的往下的影响
t[x].lazy=(lazy+t[x].lazy)%m;
t[x].mul=t[x].mul*mul%m;
//-------------------以上线段树的默认操作优先于其他目标操作------------------------
long long ad=0;
long long noww=t[x].cnt;
if(t[x].l>r||t[x].r<l)//当前范围与目标范围毫无关系,终止
return 0;
if(t[x].l>=l&&t[x].r<=r) //当前范围是目标范围的一部分,返回
{
long long ll=t[x].l,rr=t[x].r;
if(j==1)
{
t[x].cnt=(t[x].cnt+d*(rr-ll+1))%m;
t[x].lazy=(t[x].lazy+d)%m;
}
else
{
t[x].cnt=t[x].cnt*d%m;
t[x].lazy=t[x].lazy*d%m;
t[x].mul=t[x].mul*d%m;
}
// cout<<"oprate_ "<<"x:"<<x<<" d:"<<d<<" cnt:"<<t[x].cnt<<" lazy:"<<t[x].lazy<<" mul:"<<t[x].mul<<endl;
if(j==1) return (d*(rr-ll+1))%m; //区间修改,修改的大小要乘上区间长度
if(j==2) return ((d-1)*noww)%m;
}
//更新后从下往上维护节点
long long le=addtree(2*x,l,r,d,j,t[x].mul,t[x].lazy);
long long ri=addtree(2*x+1,l,r,d,j,t[x].mul,t[x].lazy);
t[x].lazy=0;
t[x].mul=1;
ad+=le+ri;
t[x].cnt=(t[x].cnt+ad)%m;
// cout<<"x:"<<x<<" left:"<<le<<" right:"<<ri<<" cnt:"<<t[x].cnt<<" lazy:"<<t[x].lazy<<" mul:"<<t[x].mul<<endl;
return ad%m; //依据下层得到的增量,直接加
}
// /节点//目标左//目标右/ /"加"传递/ /乘传递/
long long findtree(int x,int l,int r,long long lazy,long long mul)
{
int rr=t[x].r,ll=t[x].l;
long long sum=0;
//查找时从上往下维护节点
t[x].cnt=(t[x].cnt*mul)%m;//更新当前节点的权值
t[x].cnt=(t[x].cnt+lazy*(rr-ll+1))%m;
t[x].lazy=t[x].lazy*mul%m; //更新当前节点的往下的影响
t[x].lazy=(lazy+t[x].lazy)%m;
t[x].mul=t[x].mul*mul%m;
//-------------------以上线段树的默认操作优先于其他目标操作------------------------
if(t[x].l>=l&&t[x].r<=r) //当前节点是目标范围的一部分,返回
return t[x].cnt;
if(t[x].l>r||t[x].r<l) //当前节点与目标范围完全无关,终止
return 0;
//其他只要与目标范围有联系,就两条子树都找
//这是为了清晰方便,也是为了能够将lazy直接更新到两颗子树,避免只更新一颗的麻烦
sum+=findtree(2*x,l,r,t[x].lazy,t[x].mul);
sum+=findtree(2*x+1,l,r,t[x].lazy,t[x].mul);
t[x].lazy=0; //影响已传达到子树了,归零
t[x].mul=1;
return sum%m;
}
main()
{
int q=0;
int a=0;
int l,r;
int N;
cin>>N>>q>>m;
buildtree(1,1,N);
for(int i=1;i<=N;i++)
{
cin>>a;
addtree(1,i,i,a,1,1,0);
}
for(int i=1;i<=q;i++)
{
// cout<<"-------------i:"<<i<<endl;
// cout<<"tree:";
// for(int i=1;i<=2*N-1;i++)
// cout<<"i:"<<i<<" cnt:"<<t[i].cnt<<endl;
cin>>n;
if(n==1)
{
cin>>l>>r>>a;
addtree(1,l,r,a,2,1,0);
}
else if(n==2)
{
cin>>l>>r>>a;
addtree(1,l,r,a,1,1,0);
}
else
{
cin>>l>>r;
cout<<findtree(1,l,r,0,1)<<endl;
}
}
}
可以看出,增加了仅仅一个需要额外维护的条件(乘法和加法混合)之后,整个线段树的操作量不说简洁明了吧,也至少是混沌一片(也可能是我太菜了)。正是因为线段树强大的功能,所以对初始模板的改动哪怕一点,最后得出的代码都要比最初的形式复杂很多。可能有人注意到了,这题的代码除了多处理了一些新的延迟变量,在维护线段树增加元素的“addtree”函数里也多出了一段和“findtree”前面一样的“信息传递”操作,这是这题的一个坑,常态加法在区间修改时无甚先后顺序,但是加入乘法运算后,除了上文所述的有关延迟变量的操作,还必须在区间修改时就把父节点的影响传递下去(得先乘)。这也可以侧面说明,线段树的目的稍变,整个架构可能都要进行些许适应性变化变化。
除此之外,很明显,线段树的节点是可以具有多个可合并的信息,而可合并的信息每多出来一个,延迟标记点就也至少要多出来一个,线段树的操作与维护的复杂性会上升很多很多。
例题:
分析:很显然啊,题目就是要在一段不停变化的序列中,实时求出里面的最大子段和。而且更显然的是,所有需要求的量都是可合并(以下称“左子区间”为“左区间”,“右子区间”同理):
- 区间的最大字段和可以由——左区间最大字段和、右区间最大子段和、左区间右连续和+右区间左连续和,拼凑比较而得出。
那么问题又来了,这样拆分出来,新增了两个需要求的量——区间的左连续和和右连续和,这俩我也不知道,那不是把问题越分越大了吗?
我知到你很急,但你先别急;不难看出,区间最大的左连续和与右连续和也可以由子区间拼凑比较而出噢;
区间的左连续和可以由三种情况得出:
-
左区间的左连续和;
-
左区间的和;
-
左区间的和+右区间的左连续和;
不难发现,这次并没有引入新的未知量,且以上所需都是可以合并的,都归线段树管;
对于线段树上的每一个节点,你都需要维护:
-
区间左连续和
-
区间右连续和
-
区间和
-
区间最大子段和
啊没错是五个量!
代码交给你们