二分法与统计问题.docx
《二分法与统计问题.docx》由会员分享,可在线阅读,更多相关《二分法与统计问题.docx(30页珍藏版)》请在冰豆网上搜索。
二分法与统计问题
二分法与统计问题
淮阴中学李睿
[关键字]
线段树二叉树二分法
[摘要]
我们经常遇到统计的问题。
这些问题的特点是,问题表现得比较简单,一般是对一定范围内的数据进行处理,用基本的方法就可以实现,但是实际处理的规模却比较大,粗劣的算法只能导致低效。
为了解决这种困难,在统计中需要借助一些特殊的工具,如比较有效的数据结构来帮助解决。
本文主要介绍的是分治的思想结合一定的数据结构,使得统计的过程存在一定的模式,以到达提高效率的目的。
首先简要介绍线段树的基础,它是一种很适合计算几何的数据结构,同时也可以扩充到其他方面。
然后将介绍IOI2001中涉及的一种特殊的统计方法。
接着将会介绍一种与线段树有所不同的构造模式,它的形式是二叉排序树,将会发现这种方法是十分灵活的,进一步,我们将略去对它的构造,在有序表中进行虚实现。
目录
一线段树
1.1线段树的构造思想
1.2线段树处理数据的基本方法
1.3应用的优势
1.4转化为对点的操作
二一种解决动态统计的静态方法
2.1问题的提出
2.2数据结构的构造和设想
2.3此种数据结构的维护
2.4应用的分析
三在二叉排序树上实现统计
3.1构造可用于统计的静态二叉排序树
3.2进行统计的方法分析
3.3一个较复杂的例子
四虚二叉树
4.1虚二叉树实现的形态
4.2一个具体的例子
4.3最长单调序列的动态规划优化问题
[正文]
一线段树
在一类问题中,我们需要经常处理可以映射在一个坐标轴上的一些固定线段,例如说映射在OX轴上的线段。
由于线段是可以互相覆盖的,有时需要动态地取线段的并,例如取得并区间的总长度,或者并区间的个数等等。
一个线段是对应于一个区间的,因此线段树也可以叫做区间树。
1.1线段树的构造思想
线段树处理的是一定的固定线段,或者说这些线段是可以对应于有限个固定端点的。
处理问题的时候,首先抽象出区间的端点,例如说N个端点ti(1≤i≤N)。
那么对于任何一个要处理的线段(区间)[a,b]来说,总可以找到相应的i,j,使得ti=a,tj=b,1≤i≤j≤N。
这样的转换就使得线段树上的区间表示为整数,通过映射转换,可以使得原问题实数区间得到同样的处理。
下图显示了一个能够表示[1,10]的线段树:
线段树是一棵二叉树,树中的每一个结点表示了一个区间[a,b]。
每一个叶子节点上a+1=b,这表示了一个初等区间。
对于每一个内部结点b-a>1,设根为[a,b]的线段树为T(a,b),则进一步将此线段树分为左子树T(a,(a+b)/2),以及右子树T((a+b)/2,b),直到分裂为一个初等区间为止。
线段树的平分构造,实际上是用了二分的方法。
线段树是平衡树,它的深度为lg(b-a)。
如果采用动态的数据结构来实现线段树,结点的构造可以用如下数据结构:
Type
Tnode=^Treenode;
Treenode=record
B,E:
integer;
Count:
integer;
LeftChild,Rightchild:
Tnode;
End;
其中B和E表示了该区间为[B,E];Count为一个计数器,通常记录覆盖到此区间的线段的个数。
LeftChild和RightChild分别是左右子树的根。
或者为了方便,我们也采用静态的数据结构。
用数组B[],E[],C[],LSON[],RSON[]。
设一棵线段树的根为v。
那么B[v],E[v]就是它所表示区间的界。
C[v]仍然用来作计数器。
LSON[v],RSON[v]分别表示了它的左儿子和右儿子的根编号。
注意,这只是线段树的基本结构。
通常利用线段树的时候需要在每个结点上增加一些特殊的数据域,并且它们是随线段的插入删除进行动态维护的。
这因题而异,同时又往往是解题的灵魂。
1.2线段树处理数据的基本方法
线段树的最基本的建立,插入和删除的过程,以静态数据结构为例。
建立线段树(a,b):
设一个全局变量n,来记录一共用到了多少结点。
开始n=0
end
end
将区间[c,d]插入线段树T(a,b),并设T(a,b)的根编号为v:
procedureINSERT(c,d;v)
begin
ifc≤B[v]andE[v]≤dthenC[v]←C[v]+1
elseifc<
thenINSERT(c,d;LSON[v]);
ifd>
thenINSERT(c,d;RSON[v]);
end
对于此算法的解释:
如果[c,d]完全覆盖了当前线段,那么显然该结点上的基数(即覆盖线段数)加1。
否则,如果[c,d]不跨越区间中点,就只对左树或者右树上进行插入。
否则,在左树和右树上都要进行插入。
注意观察插入的路径,一条待插入区间在某一个结点上进行“跨越”,此后两条子树上都要向下插入,但是这种跨越不可能多次发生。
插入区间的时间复杂度是O(logn)。
在线段上树删除一个区间与插入的方法几乎是完全类似的:
将区间[c,d]删除于线段树T(a,b),并设T(a,b)的根编号为v:
procedureDELETE(c,d;v)
begin
ifc≤B[v]andE[v]≤dthenC[v]←C[v]-1
elseifc<
thenDELETE(c,d;LSON[v]);
ifd>
thenDELETE(c,d;RSON[v]);
end
特别注意:
只有曾经插入过的区间才能够进行删除。
这样才能保证线段树的维护是正确的。
例如,在先前所示的线段树上不能插入区间[1,10],然后删除区间[2,3],这显然是不能得到正确结果的。
线段树的作用主要体现在可以动态维护一些特征,例如说要得到线段树上线段并集的长度,增加一个数据域M[v],讨论:
如果C[v]>0,M[v]=E[v]-B[v];
C[v]=0且v是叶子结点,M[v]=0;
C[v]=0且v是内部结点,M[v]=M[LSON[v]]+M[RSON[v]];
只要每次插入或删除线段区间时,在访问到的结点上更新M的值,不妨称之为UPDATA,就可以在插入和删除的同时维持好M。
求整个线段树的并集长度时,只要访问M[ROOT]的值。
这在许多动态维护的题目中是非常有用的,它使得每次操作的维护费用只有logn。
类似的,还有求并区间的个数等等。
这里不再深入列举。
1.3应用的优势
线段树的构造主要是对区间线段的处理,它往往被应用于几何计算问题中。
比如说处理一组矩形问题时,可以用来求矩形并图后的轮廓周长和面积等等,比普通的离散化效率更高。
这些应用可以在相关资料中查到。
这里不作深入。
1.4转化为对点的操作
线段树处理的是区间线段的问题,有些统计问题处理的往往是点的问题。
而点也是可以理解为特殊的区间的。
这时往往将线段树的构造进行变形,也就是说可以转化为记录点的结构。
变形:
将线段树上的初等区间分裂为具体的点,用来计数。
即不存在(a,a+1)这样的区间,每个点分裂为a和a+1。
同时按照区间的中点分界时,点要么落在左子树上,要么落在右子树上。
如下图:
原线段数记录基数的C[v]这时就可以用来计算落在定区间内的点个数了。
原搜索路径也发生了改变,不存在“跨越”的情况。
同时插入每个点的时候都必须深入到叶结点,因此一般来说都要有logn的复杂度。
应用这样的线段树一方面是方便计数。
另一方面由于它实际上是排序二叉树,所以容易找出最大和最小来。
下面就看一个找最大最小的例子。
[例一]PROMOTION问题(POI0015)
问题大意:
一位顾客要进行n(1≤n≤5000)天的购物,每天他会有一些帐单。
每天购物以后,他从以前的所有帐单中挑出两张帐单,分别是面额最大的和面额最小的一张,并把这两张帐单从记录中去掉。
剩下的帐单留在以后继续统计。
输入的数据保证,所有n天的帐单总数不超过1000000,并且每份帐单的面额值是1到1000000之间的整数。
保证每天总可以找到两张帐单。
解决方法:
本题明显地体现了动态维护的特性,即每天都要插入一些面额随机的帐单,同时还要找出最大和最小的两张。
不妨建立前面所说的线段树,这棵线段树的范围是[1,1000000],即我们把所有面额的帐单设为一个点。
插入和删除一份帐单是显然的。
如何找到最大的帐单呢?
显然,对于一个树v来说,如果C[LSON[v]]>0,那么树v中的最小值一定在它的左子树上。
同样,如果C[RSON[v]]>0,它的最大值在右子树上;否则,如果C[LSON[v]]=0,那么最大最小的两份帐单都在右子树上。
所以线段树的计数其实为我们提供了线索。
显然对于一个特定面额来说。
它的插入,删除,查找路径是相同的,长度为树的深度,即log1000000=20。
如果总共有N张帐单,那么考虑极限时的复杂度为N*20+n*20*2。
这比普通排序的实现要简单得多。
2.2数据结构的构造和设想
本题利用前面讲的线段树实际上就可以得到高效地解决。
因为我们知道计算a[x1]到a[y1]这一段所有元素的和,可以用sum(1,y1)-sum(1,x1-1),即用部分和求差的技术。
而求sum(1,x)这种形式在线段树上是容易快速得到的。
并且修改元素的值的方法也类似。
这里不详细说明。
我们希望再构造一种特殊的形式,因为它的实现比线段树要来得简单得多。
同时这种思想也是非常有趣和巧妙的。
对于序列a[],我们设一个数组C,其中
(k为i在二进制下末尾0的个数)。
于是我们的操作显然与这个特定的C有着特殊的联系。
那么在这个用来记录的数组中,C[K]到底是怎样的表现呢?
举一个例子,C[56],将56写成二进制的形式为111000,那么C[56]表示的最小的数是110001,即49,C[56]表示的是a[49]到a[56]的所有元素的和。
可以发现C的每个元素表示是无具体规律的,例如对C[7],它只能表示a[7]的值。
2.3此种数据结构的维护
也许你已经注意到了,对C的定义非常奇特,似乎看不出什么规律。
下面将具体研究C的特性,考察如何在其中修改一个元素的值,以及如何求部分和,之后我们会发现,C的功用是非常的巧妙的。
如何计算C[x]对应的2^k?
k为x在二进制数下末尾0的个数。
由定义可以看出,这一计算是经常用到的。
有无简单的操作可以得到这个结果呢?
我们可以利用这样一个计算式子:
2^k=xand(xxor(x-1))
这里巧妙地利用了位操作,只需要进行两步的简单计算。
其证明只要联系位操作的具体用法以及x的特征就可以得到。
在下面的叙述中我们把这个计算式子用函数LOWBIT(x)来表示。
修改一个a[x]的值
在前面提出的问题中,我们其实要解决的是两个问题:
修改a[x]的值,以及求部分和。
我们已经借用C来表示a的一些和,所以这两个问题的解决,就是要更新C的相关量。
对于一个a[x]的修改,只要修改所有与之有关系,即能够包含a[x]的C[i]值,那么具体哪些C[i]是能够包含a[x]的呢?
举一个数为例,如x=1001010,从形式上进行观察,可以得到:
p1=1001010
p2=1001100
p3=1010000
p4=1100000
p5=1000000
这里的每一个pi都是能够包含x的,也就是说,任意的C[pi]的值,包含a[x]的值。
这一串数到底有什么规律呢?
可以发现:
从观察上容易看出这是正确的,从理论上也容易证明它是正确的。
这些数是否包括了所有需要修改的值呢?
从二进制数的特征上考虑,可以发现对于任意的PiC[Y]是不可能包含a[x]的。
再注意观察P序列的生成,我们每次其实是在最后一个1上进位得到下一个数。
所以P序列所含的数最多为lgn,这里n是a表的长度,或者说是C表的长度。
因为我们记录的值是C[1]—C[n],当P序列中产生的数大于n时,我们已经不需要继续这个过程了。
在很多情况下对a[x]进行修改时,涉及到的P序列长度要远小于logn。
对于一般可能遇到的n来说,都是几步之内就可以完成的。
修改一个元素a[x],使其加上A,变成a[x]+A,可以有如下的过程:
procedureUPDATA(x,A)
begin
p←x
while(p<=n)do
begin
C[p]←C[p]+A
p←p+LOWBIT(p)
end
end
计算一个提问[x,y]的结果:
我们下面来解决求部分和的问题。
根据以往的经验,把这个问题转化成为求sum(1,y)-sum(1,x-1)。
那么如何根据C的值来求一个sum(1,x)呢?
容易得到如下过程。
functionSUM(x)
begin
ans←0
p←x
while(p>0)do
begin
ans←ans+C[p]
p←p-LOWBIT(p)
end
returnans
end
这个过程与UPDATA十分类似,很容易理解。
同时,它的复杂度显然是lgn。
每次解答一个提问时,只要执行SUM两次,然后相减。
所以一次提问需要的操作次数为2logn。
通过上两步的分析,我们发现,动态维护数组以及求和过程的复杂度通过C的巧妙定义都降到了logn。
这个结果是非常令人惊喜和满意的。
2.4应用的分析
对于我们提出的一维问题,用前面介绍的两个函数就可以轻易地解决了。
注意我们所需要消耗的内存仅是一个很单一的数组,它的构造比起线段树来说要简单得多(很明显,这个问题也可以用前面的线段树结构来解决)。
只要把这个一维的问题很好地推广到二维,就可以解决IOI2001的MOBILES问题。
如何推广呢?
注意在MOBILES问题中我们要修改的是a[x,y]的值,那么模仿一维问题的解法,可以将C[x,y]定义为:
其具体的修改和求和过程实际上是一维过程的嵌套。
这里省略其具体描述。
可以发现,经过这次推广,算法的复杂度为log2n。
而就空间而言,我们仅将一维数组简单地变为二维数组,推广的耗费是比较低的。
可以尝试类似地建立二维线段树来解决这个问题,它的复杂性要比这种静态的方法高得多。
在IOI2001的竞赛规则中,将MOBILES一题的内存限制在5Mb。
用本节介绍的方法,只需要4Mb的C数组以及一些零散的变量。
而如果用蛮力建立第一节中的线段树,其解决问题的瓶颈是可想而知的。
这种特殊的统计方法对于本题很有优势,同时它推广到高维时比较方便,是前面所涉及的线段树不可比的。
但本节的统计方法也存在缺陷,它似乎不太容易推广到其他问题。
仔细研究过线段树会知道它能够支持很多特殊的统计问题。
这些将会通过实践体现出来。
下面再介绍一些其他的实现方法。
三在二叉排序树上实现统计
前面已经讲过,线段树经过左右分割以后实际上具有二叉排序树的性质。
另一方面,前面也说明过,线段树的建立方式非常适用于处理线段,对于点的问题,可以推广应用,例如例一,但是总有些大材小用的感觉。
一方面,在线段树上需要设立过多的指针来指向左子树和右子树;另一方面,结点用于表示区间,处理点的时候,不需要保留这样的区间。
线段树上的一个结点分裂为两个半区间的时候实际上是通过一个中间点来分割的,那么在点的统计问题中,只要保留这样的分割点就可以了。
3.1构造可用于统计的静态二叉排序树
对于处理点的问题,只要建立一棵二叉排序树,使得要处理的点在这棵树上都能够找到相应的节点。
同时由二叉树的性质:
左子树上的所有点的值都比根小,右子树上的所有的点的值都比根大,我们利用这一点把线段树的优点继承过来。
首先要对所有要处理的点建立一棵可用以静态统计的二叉排序树作为模板。
例如对于集合{3,4,5,8,19,23,6},可以建立一棵包含7个点的二叉排序统计树:
注意到每个节点上所标的就是它对应的点值。
建立二叉统计树的第一步,是把所有要处理的点坐标离散化,形成一个排序的映射,例如我们称为X映射,并且设其中一共有n个不同的对象。
例如在上例中,X={3,4,5,6,8,19,23},n=7。
现在要把X映射中的点值填入到树中,使它有上面的构造。
这里我们选择静态结构作为对二插树的支持。
将二叉树的结点从上到下,从左到右进行编号,并令根结点的编号为1。
即上图中对应的编号应该是:
这与静态堆的实现是十分类似的。
对于任何一个编号为i的结点,它的左儿子编号自然为i*2,右二子编号为i*2+1。
现在要把X的映射填入到数组V中去。
V[1..n]应该保存相应位置上的点值。
注意到对V对应的二叉树进行中序遍历的结果就对应X中的映射,所以可以通过递归的方法建立V:
p←0作为X映射中的指针
procedureBUILD(ID:
integer)ID是V结点的下标
begin
if(ID*2≤n)thenBUILD(ID*2);
p←p+1
V[ID]=X[p]
if(ID*2+1≤n)thenBUILD(ID*2+1);
end
在主程序中调用BUILD
(1)
这个过程即对V先序遍历。
如果对二叉树先序遍历的过程熟悉,也可以不采用递归过程。
只要从先序遍历的第一个结点开始,每次找到它的后继。
第一个结点显然是二叉树最下面一层的最左边的结点。
如果有n个结点,首先通过下面这个过程找到第一个结点:
functionfirst:
integer
begin
level←1,tot=2
while(tot-1begin
level←level+1
tot←tot*2
end
returnsdiv2
end
然后我们可以通过下面这个过程对V赋值:
procedureBUILD
begin
now←first
p←0
fori←1tondo
begin
p←p+1,V[now]←X[p]
if(now*2+1≤n)then
begin
now←now*2+1
while(now*2≤n)now←now*2
end
else
begin
while(now是奇数)now←nowdiv2
now←nowdiv2
end
end
end
从构造的方法可以看出,这是一棵近似满二叉树,因此它也是一棵平衡树,它的深度为logn。
3.2进行统计的方法分析
在这棵树中,对于任何将要处理的一个点,它具有值value,我们根据value很容易在树中找到相应的结点。
例如我们要动态维护点的个数,类似例一中提到的,我们在树的每个结点上设一个SUM,表示以该结点为根的二叉树上的点的总数。
最初SUM[i]=0。
插入一个点有如下过程:
procedureINSERT(value)
begin
now←1
repeat
SUM[now]←SUM[now]+1
if(V[now]=value)break
if(V[now]>value)now←now*2
elsenow←now*2+1
untilfalse
end
我们可以在logn时间内动态维护SUM,其过程与value的查找是同步的。
这个SUM的设立比较普通。
有些特殊的设定,就比较有大的作用。
比如我们在每个结点上设一个LESS,表示值小于等于根结点值的总数,即根上的点以及左子树上的点的总数。
那么插入时有:
procedureINSERT1(value)
begin
now←1
repeat
if(value<=V[now])then
LESS[now]←LESS[now]+1
if(V[now]=value)break
if(V[now]>value)now←now*2
elsenow←now*2+1
untilfalse
end
这个过程与前一个大同小异。
实际上LESS[I]=SUM[I]-SUM[I*2+1]。
举这个例子在于说明利用二叉排序树的结构,是很容易结合具体的问题进行变化的。
我们对其变化,甚至也可以利用来解决例二。
只要将刚才LESS的定义作一点变化,令它为根及其左树上所有点上的权和。
如果要在a[x]上增加A。
可以很容易得到:
procedureINSERT2(x,A)
begin
now←1
repeat
if(x<=V[now])then
LESS[now]←LESS[now]+A
if(V[now]=x)break
if(V[now]>x)now←now*2
elsenow←now*2+1
untilfalse
end
同样也很方便,另外如果要求SUM(1,x)的值,只要根据这样一个函数:
functionSUM(x):
longint
begin
ans←0
now←1
repeat
if(V[now]<=x)ans←ans+LESS[now]
if(V[now]=x)break
if(V[now]>x)now←now*2
elsenow←now*2+1
untilfalse
returnans
end
可以发现这几个过程基本相似,这种实现对例二解决的效率并不亚于第二节中介绍的方法,而且它对内存的消耗也是1个单一的数组,可以很容易地推广到二维解决MOBILES的问题,最后主要的内存消耗也是4Mb的静态数组,而它的效率也是较高的。
用二叉排序树来实现,其思路和线段树是一样的,因为二者本质上是相似的。
这种方法经常被应用到离散化的统计问题中,尤其是平面问题的统计。
3.3一个较复杂的例子
[例三]采矿(KOP)
金矿的老师傅年底要退休了。
经理为了奖赏他的尽职尽责的工作,决定送他一块长方形地。
长度为S,宽度为W。
老师傅可以自己选择这块地。
显然其中包含的采金点越多越好。
你的任务就是计算最多能得到多少个采金点。
如果一个采金点的位置在长方形的边上,它也应当被计算在内。
任务:
读入采金点的位置。
计算最大的价值。
输入:
文件KOP.IN的第一行是S和W,(1<=s,w<=10 000);他们分别平行于OX坐标和OY坐标,指明了地域的尺寸。
接下来一行是整数n(1<=n<=15 000),表示采金点的总数。
然后是n行,每行两个整数,给出了一个采金点的坐标。
坐标范围是(-30 000<=x,y<=30 000)。
输出:
一个整数,最多的采金点数。
样例输入
12
12
00
11
22
33
45
55
42
14
05
50
23
32
样例输出
4
分析:
题目中的样例实际上对应了下图:
这是一个针对点进行扫描的问题。
容易想到离散化,例如用两根线来进行扫描,使得两根线之间的区域在X坐标上相差不超过S。
然后再统计这一个带状区域中的每一个宽度为W的矩形。
如下图:
图中是两条扫描线L1和L2。
L2在L1前面动,通过调整,使得L1到L2的距离不大于S。
这时中间带状区域的点成为进一步研究的对象。
可以发现,每个点进出要处理的带状区域各一次。
对于带状区域中的所有点,由于他们的横坐标差不会大于S,所以我们可以忽略所有的横坐标,仅考虑他们的纵坐标。
例如在一个带状区域内有5个点的纵坐标分别是{5,3,9,1,9},w=2,很自然地,考虑将这几个坐标排序成{1,3,5