方格图中的矩形
题目的模式常见于,在一片由小方格组成的巨大的方格图中,找出某种要求的矩形。
由于巨大的“长”与“宽”的范围,以及由方格组成矩形的递归性,矩形的位置性,某些格子禁用的制约性,题目的难点多在于较高的枚举量带来的时间复杂度、表示矩形的方法、数矩形个数的策略和递归策略的发掘。
正方形矩阵递归
当题目中给出的矩形是明确的正方形,且其边长长度明确为2^n时,它很可能就要在正方形个体包含全部的特点上去做文章了。
任何一个边长为2^{n}的大正方形在格子中都能被拆分成4个边长为2^{n-1}的小正方形,这也意味着,只要题目的问题是具体到单个格子上的某些要求,不论它给出的初始图形有多大,最后都等效于在一个四格正方形中的单区域问题,而对于从大正方形到小正方形的过程,也一样是一个大正方形的单区域问题。
把一个格子的问题化成一个大的区域,把一个大区域的问题不断递归到一个格子,由小化大,由大化小,这就是2^n边长正方形的解题策略。
ps:此类题目在递归的过程中可能方向有点多,初始分割正方形就要四个方向,每次分割之后如果在小正方形中还需要额外的判断,则单次递归的方向就会达到4n个,但是这依旧是很好的解题策略,耐心把情况清楚就可以了。
例题:
任意矩形找其内的小矩形
在网格图中,大矩形一样是由小矩形构成的,但是,相较于特殊的正方矩形,普通的矩形的大小构成并没有特别的规律,只能采取淳朴的计数法去找矩形。
所以,在这种题目中,能做文章的地方不是矩形,而是有关矩形的计数与判定。
淳朴——找任意矩形内所有矩形的个数
考察矩形的计数与表示法。
给你一个网格图,你该如何快速求出图中所有正方形与长方形的个数呢?
在一个网格中,决定单个矩形的是什么?是什么将它们和其他矩形区别开来?
-
顶点位置
-
长宽的值
没错,这些确实决定了单个矩形,而且是最直观、最容易想到的方法。
但是在经过尝试后会发现,采用这种思想去统计各类矩形的个数相当麻烦,矩形上最多的就是“位置不同的点了”;麻烦,易错,耗时还长。
可是这类题就是根据“决定矩形的事物”去找矩形呀,那还有什么其他表示方法吗?
——当然有。
-
长宽的值
-
长宽的位置
把二维的网格图看成两个一维的轴,长与宽的所有位置便是它们所在线段的所有子线段(个数为n!,其中n是线段总长),把长可能取的值与宽可能取的值相乘,便是矩形所有可能的构成了。
说白了,在一个网格图上矩形的位置的表示,本质上就是被赋予了位置的长和宽。
技巧——矩形特质的寻找与表示
悬线法,矩形的找寻
确定(枚举)矩形的高度,探寻在这个位置、这个高度下,它所能向左右延伸的最大值。
通常存储:悬线长、悬线所能到达的最左列,悬线所能到达的最右列。
特点:
-
通过特定位置的点,记录矩形的长和宽。这种方法能够完全不重不漏的把一个矩形所有的信息浓缩到一个格子里,且每一个“最大”矩形所占据的格子里,一定有一个格子能通过悬线法表示出这个矩形的信息。
-
它通过从网格图从上至下的递推,层层确定(枚举)悬线的长度和左右能达到的范围。其中,左右能达到的范围,都以和上一行相比的最小范围为主。
-
其中,特定位置的点只是为了确保所找矩形的不重不漏、面面俱到,并不能提供具体定位的效果。若需要确定具体位置,还需要存储:该点的悬线顶端所处的那一行。
例题:
山形法,以底格为重
例:
要统计图示中,不含黑色地块的所有矩形的个数,应该采用什么方法?
从上至下,以行为基础向下枚举,找出在以x行为底的情况下,枚举包含x行上某一格的所有矩形的数量。
从上至下设定底,保证了在讨论包含特定格子的所有矩形时格子的不重不漏。
例:假设目前已经讨论到了第4行;
如图:
那么应该统计以第4行为底、枚举包含第4行上一格的所有所有矩形数量
即:
如图所示,以某一格为往上为“悬线”,分别向左右(实际上为避免重复统计,向左右延展的条件不同)扩展至高度小于(小于等于)当前“悬线”高度,然后再对这个扩展后的矩形进行矩形个数的“计数”。
这种算法更像是悬线法的变体。实际上,还是找出以某行为底的小图中的悬线,再以悬线的高度为限制加以计数。
其中所用到的计数原理与我的直觉有些不同,悬线左右个数的排列组合,都是从“0”开始的。
例如:包含目标格的所有底边数 = l*r,其中l范围为0左侧的方格数,r范围为0右侧的方格数,故相乘的实际值实为(左侧的方格数+1)(右侧的方格数+1),即包含某侧取0的情况。
同时,在底边进行这种枚举的同时,还需要注意左右测的大于等于与小于等于,的边界情况,因为枚举的边界相邻,左右区间需要半开半闭以避免重复。
该方法,本质上是把在二维网格数矩形的问题,分化简化成了一个个类似的在一维网格数矩形的问题。
例如,如下一维网格,其中包含1的所有矩形数为
(1+1)*(3+1)或(2-0)*(6-2)
例题:
变化区间内的最值
关键:
-
区间长度一定
-
区间在变化
-
区间的最值
区间所包含的数据中,在最值易主前就被甩出区间的数据是不必存储的。
只需存储区间最值易主后,所有还在区间中的、具备争霸条件的数据。
对应数据结构:单调队列。
单调队列的实现与功能即是对上述思想的模拟。
例题:
以某值为最的最大区间
关键:
-
区间延伸的方向(需单一确定)
-
区间的最值
区间内存储的都是当前区间内最大的、第二大的、第三大的……其中,新加入的数将移除记录中所有比它小的数。
在区间延伸方向一定的情况下,这样的操作能够让区间内的最值有序的记录“相邻两座高峰间较小山峰的数量”并迭代。
同时,这一个过程可分解,即可递归。
对应数据结构:单调栈。
单调栈的实现与功能即是对上述思想的模拟。
例题:
不定区间的最值——ST表
单调队列由其区间变化的规律性决定了它可以排队——
一个王朝随着时间顺序流逝,下一个王的可能人选只能是寿命比当朝皇帝长的人。
而如果失去时间顺序呢?
在一个大集合中,频繁的选取任意子集,要求其中每个子集的王,该怎么办?
首先,明显可以发觉,倘若是大量选取子集,虽说是任意选取,但后选的子集还是能够根据先前求出的区间“王”来拼凑出自己的“王”。
那么现在需要解决的就是,找到一个确切且合理的区间划分规则,使所有初始区间都能快速完成初始化,使所有区间都能通过初始区间快速、方便的拼凑出来。
而ST表就提供了一个方便快捷的方式。
在时间的优化上,它提出了“以指数增长的区间长度”为初始区间,单点初始化的时间复杂度变为O(lnN);
在拼凑方面的优化上,它的区间增长长度采用2^n的形式,使任意一个区间( l , r )都可以由它内部的分别以“ l ”和“ r ”为端点的两个小区间拼凑出来,时间复杂度仅为O(1) 。
所以,ST表的本质就是:它确定了一个方便合理的区间划分规则,明确了区间的存储和取用方式,明确了区间与区间间的关系,充分寻找与利用每个阶段的“王”去寻找新阶段的“王”。
int ST_query(int l,int r){//询问l~r区间的最值
int Lg=Log[r-l+1];//计算l~r之间长度对应的log2值
return max(stMax[l][Lg],stMax[r-(1<<Lg)+1][Lg]);
}
void ST_prework(){//ST表预处理
for(int i=1;i<=n;i++){//f[i][j]=从i开始,长为2^j区间内的最值
f[i][0]=a[i];//长为1时的最值
}
for(int j=1;j<=Log[n];j++){//根据最长区间长度log[n]进行遍历
for(int i=1;i+(1<<j)-1<=n;i++){//遍历开始位置i
f[i][j]=max(f[i][j-1],f[i+(1<<(j-1))][j-1]);//左边的最值与右边最值中较大者为整个区域的最值
}
}
}
-
首先,确定区间长度,遍历区间的开头。因为总区间长度过长,故用对数的形式处理选区间长度这一操作。区间长度皆选用2的次方。
-
第二步,赋初值。从这里开始,区间长度选用2的n次方的优势就显现出来了。任何一个j<
的区间[i,j]都可以被分成[ i ,
]与[ i+
, j ]两个区间去处理。故在初始化时,你可以很明确的看到,它是先初始化区间的长度,枚举区间的开头,因为在递推初始化时,它是需要上一个区间长度、任意的区间开头。
例题:
多次区间修改,单次单点查询——差分数组
修改区间需要的是什么?是确切的改变区间上每一个数据?
不,当区间内的数据变化量相同时,它们间的差值恒定不变,只有区间的开头与结尾和区间外的差值发生了变化。我们通过发掘的这“大量的不变”与“少量的变”,省去了大量的无关操作,找到了对付区间修改“特化”的武器
-
Attention:操作上,对于”区间“的判定
如下图数轴
若题目中说需要给在(1,0)和(3,0)之间的方格染黑,你需要用”1“来表示方格染黑,你该怎么维护差分数组?
只看题而不画图的话,很多人第一直觉都会觉得差分为a[1]++,a[4]--;
但注意了,你的直觉是把它当成了在数组的1~3区间染色,但题目的意思是在坐标轴上1和3之间的区间染色;“区间“二字在坐标轴和数组位置上是两个完全不同的概念。我说给数组中b[1]到b[3]区间全加一,自然是1到3包括3的;而在坐标系中,特殊的格子区间,”点中的区间点的数量少一“的幽灵又出来作妖了——(1,0)到(3,0),只有两个区间。所以实际上是a[1]++,a[3]--;
在任何和”网格图“有联系的题目中,都要慎重其中的”区间“概念。
一维差分数组
差分数组存储数据间的差,具有牵一发而动全身的效果。一旦一个位置的差分改变,相当于后续所有数的增减,故我们可以很明显的发现,想要精确的应用差分数据操作某一个区间,在一维上,一定是要同时改变两个位置——
- 去给予区间影响,并在适当的位置消除影响。
这样才能在影响目标区间的同时,不伤害到其他区间。
二维差分数组
二维差分数组相当于两个一维的组合。从一维组合成二维发生了些许质变,在二维上,差分数组对于数据影响的形式稍稍改变了,其“牵一发而动全身”的效果更为明显了。
于此,在二维平面上,我们需要更多的操作去维护一个精确的矩形区间的修改。
如图,若要让图示”\“格子都进行“+1”操作,则需要对差分数组a进行如图所示的操作:
a[2][2]++, a[2][4]--, a[4][2]--, a[4][4]++;
若差分数组的一维使用相当于从一维线段中“截”一段线,那么差分数组的二维使用就是相当于从一个二维的大矩形中精确的“截”出一个小矩形。
二维差分数组可以由多种方法实现。实际上,只要你能满足差分的意义,这些方法都能用到二维差分数组的实现上;例如,可以把二维差分数组当作n行的一维差分数组,每次遍历与赋值按n个一维的形式,O(n)的独立(大概率不会和其他操作嵌套,导致时间复杂度相乘)时间复杂度对于优化O()也已经够用了。
数据离散化,指的是数据范围很大,但所用到的数据很少,或是并不在意数据本身的具体值,而只在意数据间的相对大小,故而将数据通过映射集中在一个较小的范围;它有些许类似于哈希表,它们同样是需要对数据的区间进行人为的映射与集中。
-
一维离散化
一维离散化适用于对于一条很长线段上的少量线段进行操作。
-
二维离散化数组
二维离散化数组适用于对于巨大的图上的少量矩形进行操作。
离散化的大致操作:
-
录入数据。这一步是为了保存你的原序列。
-
重整数据。
-
重整。把杂乱录入的数据进行排序;这一步是为了把离散化的数据重新集合,我们不需要关注它们在图上的具体方位,我们只用关注这些离散数据的相对方位,即它们间位置/坐标的大小关系。重整的过程中可依照自身方法情况选择是否进行去重。
-
对应。将排好相对位置关系的数据位置与操作中的真实数据对应出来,一般使用二分法查找位置,或是使用map存储来达到对应的效果。
-
代码示例__把数组a中的n个数据按照大小映射为1~n:
#include<bits/stdc++.h>
using namespace std;
#define M 500005
int a[M],b[M];
map<int,int> ma;
void Discretization(int a[],int b[])//把a离散化存放在b中
{
sort(a+1,a+n+1);
for(int i=1;i<=n;i++)
ma[a[i]]=i;//记录排名,等会根据排名进行离散化
for(int i=1;i<=n;i++)
b[i]=ma[b[i]];//离散化 依据数组b的排序还原出原序列并进行映射
}
main()
{
int n;
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>a[i];
b[i]=a[i];//b[i]存储a[i]的备份,离散化时使用
}
Discretization(a,b);
}
可以看到,离散化有个特点,需要在多个数组间反复捯饬,记录大小排序、记录如何映射、记录初始序列。
特别的,在进行二维离散化数组操作时,除了把两个维度分开拿开进行两次一维的重整,而后再合并之外,还可以直接把两个维度放在一起重整(相当于是二维数组的一维化,不过比这在操作上简单明了不少,但是这样重整出来的图空间利用率有所降低,但是的但是这个”降低“可忽略)
例题:
提示:离散化集中数据后进行染色。但由于是矩形染色,即使离散化之后,一个一个染耗时也长,故可在离散化之后采取差分的形式进行染色。
为了书写方便,推荐采用统一离散(即把两个维度放在一起进行离散)
统一离散听起来很抽象,那到底怎么实现这种看起来玄乎的东西?
一切尽在代码中,多看几遍就理解了。
代码示例:
#include<bits/stdc++.h>
using namespace std;
long retangular[1005][5];//记录n次操作,每次操作都要录入x,y,x1,y1,四个数据
long a[5000],m[5000];//m存放所有的操作点,a用来存放m去重后的数据
int num,nu,k;
long long ans;
map<long,int> ma;//记录每个数值的映射值
int judge[4100][4100];//二维差分数组,用于快速区间染色
main()
{
int n;
cin>>n;
for(int i=1;i<=n;i++) //录入数据
{
cin>>retangular[i][1]>>retangular[i][2]>>retangular[i][3]>>retangular[i][4];
for(int j=1;j<=4;j++)
m[++nu]=retangular[i][j]; //都存放进m进行统一离散
}
//重整数据
sort(m+1,m+nu+1);//排序
m[0]=-1e8;
for(int i=1;i<=nu;i++)//对m进行去重,并存放进a中
if(m[i]!=m[i-1])
a[++num]=m[i];
for(int i=1;i<=num;i++)//记录每个真实值重整后的位置,即映射值
ma[a[i]]=i;
//对原序列进行映射
for(int i=1;i<=n;i++)
for(int j=1;j<=4;j++)
retangular[i][j]=ma[retangular[i][j]];
//----------------差分数组进行区间染色----------------------
for(int i=1;i<=n;i++) //枚举操作
for(int j=retangular[i][1];j<retangular[i][3];j++)
judge[j][retangular[i][2]]--,judge[j][retangular[i][4]]++;
//还记得上文提到的”注意网格图的‘区间’含义“吗?注意差分数组的点别维护错
for(int i=1;i<num;i++)
for(int j=1;j<num;j++)
judge[i][j]+=judge[i][j-1];//一次性的差分数组,用完了直接还原
for(int i=1;i<num;i++)
for(int j=1;j<num;j++)
if(judge[i][j])
ans+=(a[i+1]-a[i])*(a[j+1]-a[j]); //展开还原真实距离,得出真实面积
cout<<ans;
}
分治
区别——分治与递归/递推
有没有常常感觉分治/递推/递归的代码和思路看起来是如此的相像,让人分不清为什么要分出这些名词?
在我的理解中,直观的从代码上区别就是,所谓分治,就是一种为了递推的递归。
简单来说,它是以递推为思想纲领,但是是以递推为方法,通过递归来实现的。
分治的核心思想其实并不是传统的”大化小“,而是合并二字;它需要操作的、需要考虑的,只有合并,而并不是”大化小“。
-
例:
这里的”逆序对“正确方法是通过”归并排序“去找。
归并排序有何特点呢?(以下以快速排序进行对比)
-
不需要考虑”拆分“的问题,不需要在”拆分“过程中对数组进行操作(如快速排序就需要考虑拆分中的问题)
-
需要考虑”合并“的问题,需要在”合并“的过程中对数组进行操作(如快速排序就压根不需要合并)
-
发现了什么?
如果把”快速排序“视为一个经典的”递归“模型,它可以被分解为数个情景完全相同的子问题,且在”出口“处不仅终止递归,还直接完成并结束了运算。
而”归并排序“则是一个经典的”分治“模型,对大问题的分解不是为了一步一步化成小问题并逐个解决(个人理解),而是为了去出口找到那个递推的起点,并且明晰中间合并过程的递推式,最后再逐一返回进行递推。
它采用了递归的方法,确定了递推的步骤。
也正因如此,它的“递推”多以“合并”的形式展现。
例:
分析:考虑如何分治来求解:
1.如果 p=0,答案就是;
2. 如果p=2n,即p是一个偶数,那么可以递归求解,然后将求得的结果平
方,即;
3. 如果p=2n+1,即p是一个奇数,那么可以递归求解, 然后将求得的结
果平方,再乘上b,即;
#include<bits/stdc++.h>
using namespace std;
int po(int a,int b)
{
if(b==0) return 1;
long sum=1;
while(b) //这种算法把任意一个数分为“奇数”与“偶数”两个部分,然后分别各自看这两个部分平方了几次
{ //例如:10 →5*2 →2*2+1 →1*2 →0 可以看到,其中“+1 ”处的那部分共翻倍了一次
if(b&1) sum*=a; //那么从10往下到1时,它只需要乘方一次即可
a*=a; //递推与递归的两种写法
b>>=1;
}
return sum;
}
/*void pom(int a)
{
int sum=1;
while(a)
{
if(a&1) cout<<"“1”出现的次数:"<<sum<<endl;
sum++;
a>>=1;
}
}*/
main()
{
int a,b,c;
cin>>a>>b>>c;
cout<<po(a,b)<<endl;
pom(c);
}
高级搜索
搜索是一种通过暴力穷举进行模拟的方法,它的查询过程似一颗树,在走向正确答案的过程中会重复大量的、无关的步骤,以至于它的时间复杂度令人难以接受。
而高级搜索就是采取尽可能的规避多的无关情况,避免多次重复走树上的同一条路,对“搜索”这颗树进行各式各样的“剪枝”,以优化它的复杂度。
对搜索的操作进行限定
-
状态剪枝,挖掘背景信息,减去不必要的状态。
-
迭代加深,限定搜索树的深度,排除不必进行的选择。
-
启发式搜索,常用作搜索最小步骤,通过对当前节点到目标节点所需步骤的估计,来进一步进行可行性剪枝。注意:所需步骤的估计不能超过实际步骤。
双向搜索
一般搜索树的时空复杂度是指数级的,“剪枝”操作大多数也只能将它的指数减小个七八九十,那在面对单次分流操作过多、层数过深的情况,该怎么办呢?
“搜索”这一暴力的方法已经注定了它的时间复杂度是指数级的,而在指数中,参考分治中“快速幂”那一题,一个大指数可以由两个小指数的平方来合并,那么搜索是不是也可以?
当然可以。如果从前后两个方向同时进行搜索,搜索树的深度不就立刻减少了一半吗?以此为基础,我们所要考虑的就只有“合并”操作的维护了。
双向搜索的特点:
-
明确知到搜索的起点与终点,从两端同时出发进行搜索,以找到它们相遇的位置为结束。
-
题目的“目标”可以被划分成两个可合并的部分,而题目的条件也可划分成两个等价的部分,且这种划分不影响结局;以找到两部合并后为“目标”的位置为结束
上述两种情况的双向搜索都需要哈希表,也就是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”前面一样的“信息传递”操作,这是这题的一个坑,常态加法在区间修改时无甚先后顺序,但是加入乘法运算后,除了上文所述的有关延迟变量的操作,还必须在区间修改时就把父节点的影响传递下去(得先乘)。这也可以侧面说明,线段树的目的稍变,整个架构可能都要进行些许适应性变化变化。
除此之外,很明显,线段树的节点是可以具有多个可合并的信息,而可合并的信息每多出来一个,延迟标记点就也至少要多出来一个,线段树的操作与维护的复杂性会上升很多很多。
例题:
分析:很显然啊,题目就是要在一段不停变化的序列中,实时求出里面的最大子段和。而且更显然的是,所有需要求的量都是可合并(以下称“左子区间”为“左区间”,“右子区间”同理):
- 区间的最大字段和可以由——左区间最大字段和、右区间最大子段和、左区间右连续和+右区间左连续和,拼凑比较而得出。
那么问题又来了,这样拆分出来,新增了两个需要求的量——区间的左连续和和右连续和,这俩我也不知道,那不是把问题越分越大了吗?
我知到你很急,但你先别急;不难看出,区间最大的左连续和与右连续和也可以由子区间拼凑比较而出噢;
区间的左连续和可以由三种情况得出:
-
左区间的左连续和;
-
左区间的和;
-
左区间的和+右区间的左连续和;
不难发现,这次并没有引入新的未知量,且以上所需都是可以合并的,都归线段树管;
对于线段树上的每一个节点,你都需要维护:
-
区间左连续和
-
区间右连续和
-
区间和
-
区间最大子段和
啊没错是五个量!
代码交给你们