笔记_常见优化技巧3

高级搜索

搜索是一种通过暴力穷举进行模拟的方法,它的查询过程似一颗,在走向正确答案的过程中会重复大量的、无关的步骤,以至于它的时间复杂度令人难以接受。

而高级搜索就是采取尽可能的规避多的无关情况,避免多次重复走树上的同一条路,对“搜索”这颗树进行各式各样的“剪枝”,以优化它的复杂度。

对搜索的操作进行限定

  • 状态剪枝,挖掘背景信息,减去不必要的状态。

  • 迭代加深,限定搜索树的深度,排除不必进行的选择。

  • 启发式搜索,常用作搜索最小步骤,通过对当前节点到目标节点所需步骤的估计,来进一步进行可行性剪枝。注意:所需步骤的估计不能超过实际步骤。

双向搜索

一般搜索树的时空复杂度是指数级的,“剪枝”操作大多数也只能将它的指数减小个七八九十,那在面对单次分流操作过多、层数过深的情况,该怎么办呢?

“搜索”这一暴力的方法已经注定了它的时间复杂度是指数级的,而在指数中,参考分治中“快速幂”那一题,一个大指数可以由两个小指数的平方来合并,那么搜索是不是也可以?

当然可以。如果从前后两个方向同时进行搜索,搜索树的深度不就立刻减少了一半吗?以此为基础,我们所要考虑的就只有“合并”操作的维护了。

双向搜索的特点:

  • 明确知到搜索的起点与终点,从两端同时出发进行搜索,以找到它们相遇的位置为结束。

  • 题目的“目标”可以被划分成两个可合并的部分,而题目的条件也可划分成两个等价的部分,且这种划分不影响结局;以找到两部合并后为“目标”的位置为结束

上述两种情况的双向搜索都需要哈希表,也就是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. 这颗树是完全二叉树

其中,性质1保证了它能快速取出最值(根节点就是最值),而性质2是为了便于维护二叉堆中数据的添加与删除

二叉堆的操作:

  1. 取最值;如上,不再赘述。

  2. 添加元素;在数组末尾直接添加数据,而后与父节点进行比较交换,递归完成。

  3. 删除元素;直接把根节点删去,把末尾节点移到根节点上,然后从根开始与子节点进行比较交换,递归完成。

二叉堆的中心规则其实很朴素,就是拆东墙补西墙——

它要满足自己设立的两条铁律,而在使用过程中,无论是添加元素还是删除元素都一定会破坏这两条规则,二叉堆所做的,就是通过一系列操作,去缓解或消除这些破坏(如换位置等)。

代码示例:

#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);
}

关于二叉堆的理解和解释:

  • 为什么采用二叉树的形式来达成这个目的?三岔树、四岔树、或其他数据结构不行吗?

    其他数据结构当然可以,但所谓术业有专攻。二叉堆成为区间最值解法的代表,是因为它在操作简便,无冗余的同时,依旧能以比较方便的思路和合理的时间复杂度完成这些操作;而其他的数据结构不是实现操作没有它简单,就是“杀鸡焉用牛刀”。

  • 为什么“二叉树”这种形式能够达成这个目的,它有什么特点和特殊使它能够胜任?

    在一开始学二叉树的时候,遇到过一个“淘汰赛制”的问题时,二叉树的特点就已经初现端倪:

    如图。不难发现,在二叉树状的晋级赛中,只有冠军所得的名次是名副其实的。事实上,二叉堆中,父子节点的关系保证了每课树的值一定是一个上升的趋势;而兄弟节点之间的关系和优先级完全并列,并无任何联系,或者说,二叉堆并没有尝试去编排兄弟节点之间的关系,它跳过、略去了这个操作。(个人理解)

    而正是二叉堆对于父子节点与兄弟节点关系间的取舍的思想,使它不同于其他树状数据结构,在放弃维护兄弟间秩序的同时,保证了父子节点的上升,利用了冠军永远都“名副其实”的特点,确定且只确定了冠军。(个人理解)

线段树

线段树通过区间的分割与合并,在信息可合并的前提下,能够高效的进行区间/单点的所有查询/修改操作,可以以较低的时间复杂度解决这类问题。

简单来说,线段树是分治实体化体现。

它的出现也让其他一些分治的问题的多了一个解决方案(一步步扩展),如逆序对。

  • 线段树的关键要素与操作

    1. 明确需求,清晰数的节点需要存储什么东西,大多数时候节点至少要存储:左右端点、节点的权值、延迟标记;至少是四个元素。

    2. 明晰操作量与操作细节。在修改权值时,需要递归找到目标区间的子区间,而后修改权值与延迟标记,并在之后从该节点返回它的父节点合并更新;在查找区间时,需要递归找到目标区间的子区间,而后从该节点返回它的父节点并更新本次查找的答案,同时,在每次递归时,都要优先传递父节点的lazy标记

    3. 明确单次递归所要完成的任务,避免在递归途中混淆不同操作的完成者。例如赋值、更新、判断终点、判断交集等等操作是在父/子节点完成

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”前面一样的“信息传递”操作,这是这题的一个坑,常态加法在区间修改时无甚先后顺序,但是加入乘法运算后,除了上文所述的有关延迟变量的操作,还必须在区间修改时就把父节点的影响传递下去(得先乘)。这也可以侧面说明,线段树的目的稍变,整个架构可能都要进行些许适应性变化变化。

除此之外,很明显,线段树的节点是可以具有多个可合并的信息,而可合并的信息每多出来一个,延迟标记点就也至少要多出来一个,线段树的操作与维护的复杂性会上升很多很多。

例题:

分析:很显然啊,题目就是要在一段不停变化的序列中,实时求出里面的最大子段和。而且更显然的是,所有需要求的量都是可合并(以下称“左子区间”为“左区间”,“右子区间”同理):

  • 区间的最大字段和可以由——左区间最大字段和、右区间最大子段和、左区间右连续和+右区间左连续和,拼凑比较而得出。

那么问题又来了,这样拆分出来,新增了两个需要求的量——区间的左连续和和右连续和,这俩我也不知道,那不是把问题越分越大了吗?

我知到你很急,但你先别急;不难看出,区间最大的左连续和与右连续和也可以由子区间拼凑比较而出噢;

区间的左连续和可以由三种情况得出:

  • 左区间的左连续和;

  • 左区间的和;

  • 左区间的和+右区间的左连续和;

不难发现,这次并没有引入新的未知量,且以上所需都是可以合并的,都归线段树管;

对于线段树上的每一个节点,你都需要维护:

  • 区间左连续和

  • 区间右连续和

  • 区间和

  • 区间最大子段和

啊没错是五个量!

代码交给你们

  • 50
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值