算法合集之《二分法与统计问题》Word格式文档下载.docx
《算法合集之《二分法与统计问题》Word格式文档下载.docx》由会员分享,可在线阅读,更多相关《算法合集之《二分法与统计问题》Word格式文档下载.docx(31页珍藏版)》请在冰豆网上搜索。
处理问题的时候,首先抽象出区间的端点,例如说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:
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]分别表示了它的左儿子和右儿子的根编号。
注意,这只是线段树的基本结构。
通常利用线段树的时候需要在每个结点上增加一些特殊的数据域,并且它们是随线段的插入删除进行动态维护的。
这因题而异,同时又往往是解题的灵魂。
线段树的最基本的建立,插入和删除的过程,以静态数据结构为例。
建立线段树(a,b):
设一个全局变量n,来记录一共用到了多少结点。
开始n=0
procedureBUILD(a,b)
begin
n←n+1
v←n
B[v]←a
E[v]←b
C[v]←0
ifb–a>
1then
LSON[v]←n+1
BUILD(a,
)
RSON[v]←n+1
BUILD(
b)
end
将区间[c,d]插入线段树T(a,b),并设T(a,b)的根编号为v:
procedureINSERT(c,d;
v)
ifc≤B[v]andE[v]≤dthenC[v]←C[v]+1
elseifc<
thenINSERT(c,d;
LSON[v]);
ifd>
RSON[v]);
对于此算法的解释:
如果[c,d]完全覆盖了当前线段,那么显然该结点上的基数(即覆盖线段数)加1。
否则,如果[c,d]不跨越区间中点,就只对左树或者右树上进行插入。
否则,在左树和右树上都要进行插入。
注意观察插入的路径,一条待插入区间在某一个结点上进行“跨越”,此后两条子树上都要向下插入,但是这种跨越不可能多次发生。
插入区间的时间复杂度是O(logn)。
在线段上树删除一个区间与插入的方法几乎是完全类似的:
将区间[c,d]删除于线段树T(a,b),并设T(a,b)的根编号为v:
procedureDELETE(c,d;
ifc≤B[v]andE[v]≤dthenC[v]←C[v]-1
thenDELETE(c,d;
特别注意:
只有曾经插入过的区间才能够进行删除。
这样才能保证线段树的维护是正确的。
例如,在先前所示的线段树上不能插入区间[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。
类似的,还有求并区间的个数等等。
这里不再深入列举。
线段树的构造主要是对区间线段的处理,它往往被应用于几何计算问题中。
比如说处理一组矩形问题时,可以用来求矩形并图后的轮廓周长和面积等等,比普通的离散化效率更高。
这些应用可以在相关资料中查到。
这里不作深入。
线段树处理的是区间线段的问题,有些统计问题处理的往往是点的问题。
而点也是可以理解为特殊的区间的。
这时往往将线段树的构造进行变形,也就是说可以转化为记录点的结构。
变形:
将线段树上的初等区间分裂为具体的点,用来计数。
即不存在(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。
这比普通排序的实现要简单得多。
本题还可以采取巧妙的办法,线段树不一定要存帐单的具体面额。
由于我们对1000000种面额都进行了保存,所以线段树显得比较庞大。
采取一种方法:
我们用hash来保存每一种面额的帐单数目,然后对于一个具体的帐单,例如面额为V,我们在线段树中保存V/100的值,也就是说,我们把连续的100种面额的帐单看成是一组。
由于V的范围是[1..1000000],所以线段树中有10000个点。
在找最大的数的时候,首先找到最小的组,然后在hash里对这个组进行搜索,显然这个搜索的规模不会超过100。
由于线段树变小了,所以树的深度只有14左右,整个问题的复杂度极限为N*14+n*14*100*2,对于问题的规模来说,仍然是高效率的。
但这样做比前种方法在一定程度上节省了空间。
同时实际上也提醒了我们对线段树应该加以灵活的应用。
[例二]IOI2001MOBILES
在一个N*N的方格中,开始每个格子里的数都是0。
现在动态地提出一些问题和修改:
提问的形式是求某一个特定的子矩阵(x1,y1)-(x2,y2)中所有元素的和;
修改的规则是指定某一个格子(x,y),在(x,y)中的格子元素上加上或者减去一个特定的值A。
现在要求你能对每个提问作出正确的回答。
1≤N≤1024,提问和修改的总数可能达到60000条。
正如在摘要中所说的,这类题目的意思非常简单明了,而且用几个小循环就可以解决。
但是面对可能将要处理的规模,我们却望而却步了,因为简单的实现效率实在太低了。
本题的一种完美解决方法用到了一种特殊的结构定义。
问题是二维的,注意到降格的思想,我们对一维的问题进行讨论,然后只要稍微进行推广。
一维的序列求和问题是这样的:
设序列的元素存储在a[]中,a的下标是1..n的正整数,需要动态地更新某个a[x]的值,同时要求出a[x1]到a[y1]这一段所有元素的和。
这个问题与MOBILES问题实际上提法是一样的。
本题利用前面讲的线段树实际上就可以得到高效地解决。
因为我们知道计算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]的值。
也许你已经注意到了,对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]的值。
这一串数到底有什么规律呢?
可以发现:
从观察上容易看出这是正确的,从理论上也容易证明它是正确的。
这些数是否包括了所有需要修改的值呢?
从二进制数的特征上考虑,可以发现对于任意的Pi<
Y<
Pi+1,C[Y]所包含的值是a[Pi+1]+…+a[Y]。
C[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)
p←x
while(p<
=n)do
begin
C[p]←C[p]+A
p←p+LOWBIT(p)
end
计算一个提问[x,y]的结果:
我们下面来解决求部分和的问题。
根据以往的经验,把这个问题转化成为求sum(1,y)-sum(1,x-1)。
那么如何根据C的值来求一个sum(1,x)呢?
容易得到如下过程。
functionSUM(x)
ans←0
p←x
while(p>
0)do
ans←ans+C[p]
p←p-LOWBIT(p)
returnans
这个过程与UPDATA十分类似,很容易理解。
同时,它的复杂度显然是lgn。
每次解答一个提问时,只要执行SUM两次,然后相减。
所以一次提问需要的操作次数为2logn。
通过上两步的分析,我们发现,动态维护数组以及求和过程的复杂度通过C的巧妙定义都降到了logn。
这个结果是非常令人惊喜和满意的。
对于我们提出的一维问题,用前面介绍的两个函数就可以轻易地解决了。
注意我们所需要消耗的内存仅是一个很单一的数组,它的构造比起线段树来说要简单得多(很明显,这个问题也可以用前面的线段树结构来解决)。
只要把这个一维的问题很好地推广到二维,就可以解决IOI2001的MOBILES问题。
如何推广呢?
注意在MOBILES问题中我们要修改的是a[x,y]的值,那么模仿一维问题的解法,可以将C[x,y]定义为:
其具体的修改和求和过程实际上是一维过程的嵌套。
这里省略其具体描述。
可以发现,经过这次推广,算法的复杂度为log2n。
而就空间而言,我们仅将一维数组简单地变为二维数组,推广的耗费是比较低的。
可以尝试类似地建立二维线段树来解决这个问题,它的复杂性要比这种静态的方法高得多。
在IOI2001的竞赛规则中,将MOBILES一题的内存限制在5Mb。
用本节介绍的方法,只需要4Mb的C数组以及一些零散的变量。
而如果用蛮力建立第一节中的线段树,其解决问题的瓶颈是可想而知的。
这种特殊的统计方法对于本题很有优势,同时它推广到高维时比较方便,是前面所涉及的线段树不可比的。
但本节的统计方法也存在缺陷,它似乎不太容易推广到其他问题。
仔细研究过线段树会知道它能够支持很多特殊的统计问题。
这些将会通过实践体现出来。
下面再介绍一些其他的实现方法。
前面已经讲过,线段树经过左右分割以后实际上具有二叉排序树的性质。
另一方面,前面也说明过,线段树的建立方式非常适用于处理线段,对于点的问题,可以推广应用,例如例一,但是总有些大材小用的感觉。
一方面,在线段树上需要设立过多的指针来指向左子树和右子树;
另一方面,结点用于表示区间,处理点的时候,不需要保留这样的区间。
线段树上的一个结点分裂为两个半区间的时候实际上是通过一个中间点来分割的,那么在点的统计问题中,只要保留这样的分割点就可以了。
对于处理点的问题,只要建立一棵二叉排序树,使得要处理的点在这棵树上都能够找到相应的节点。
同时由二叉树的性质:
左子树上的所有点的值都比根小,右子树上的所有的点的值都比根大,我们利用这一点把线段树的优点继承过来。
首先要对所有要处理的点建立一棵可用以静态统计的二叉排序树作为模板。
例如对于集合{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结点的下标
if(ID*2≤n)thenBUILD(ID*2);
p←p+1
V[ID]=X[p]
if(ID*2+1≤n)thenBUILD(ID*2+1);
在主程序中调用BUILD
(1)
这个过程即对V先序遍历。
如果对二叉树先序遍历的过程熟悉,也可以不采用递归过程。
只要从先序遍历的第一个结点开始,每次找到它的后继。
第一个结点显然是二叉树最下面一层的最左边的结点。
如果有n个结点,首先通过下面这个过程找到第一个结点:
functionfirst:
integer
level←1,tot=2
while(tot-1<
n)do
level←level+1
tot←tot*2
returnsdiv2
然后我们可以通过下面这个过程对V赋值:
procedureBUILD
now←first
p←0
fori←1tondo
p←p+1,V[now]←X[p]
if(now*2+1≤n)then
now←now*2+1
while(now*2≤n)now←now*2
else
while(now是奇数)now←nowdiv2
now←nowdiv2
从构造的方法可以看出,这是一棵近似满二叉树,因此它也是一棵平衡树,它的深度为logn。
在这棵树中,对于任何将要处理的一个点,它具有值value,我们根据value很容易在树中找到相应的结点。
例如我们要动态维护点的个数,类似例一中提到的,我们在树的每个结点上设一个SUM,表示以该结点为根的二叉树上的点的总数。
最初SUM[i]=0。
插入一个点有如下过程:
procedureINSERT(value)
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
我们可以在logn时间内动态维护SUM,其过程与value的查找是同步的。
这个SUM的设立比较普通。
有些特殊的设定,就比较有大的作用。
比如我们在每个结点上设一个LESS,表示值小于等于根结点值的总数,即根上的点以及左子树上的点的总数。
那么插入时有:
procedureINSERT1(value)
if(value<
=V[now])then
LESS[now]←LESS[now]+1
这个过程与前一个大同小异。
实际上LESS[I]=SUM[I]-SUM[I*2+1]。
举这个例子在于说明利用二叉排序树的结构,是很容易结合具体的问题进行变化的。
我们对其变化,甚至也可以利用来解决例二。
只要将刚才LESS的定义作一点变化,令它为根及其左树上所有点上的权和。
如果要在a[x]上增加A。
可以很容易得到:
procedureINSERT2(x,A)
if(x<
LESS[now]←LESS[now]+A
if(V[now]=x)break
x)now←now*2
同样也很方便,另外如果要求SUM(1,x)的值,只要根据这样一个函数:
functionSUM(x):
longint
now←1
if(V[now]<
=x)ans←ans+LESS[now]
else