数据结构的选择与算法效率.docx
- 文档编号:28793990
- 上传时间:2023-07-19
- 格式:DOCX
- 页数:24
- 大小:74.76KB
数据结构的选择与算法效率.docx
《数据结构的选择与算法效率.docx》由会员分享,可在线阅读,更多相关《数据结构的选择与算法效率.docx(24页珍藏版)》请在冰豆网上搜索。
数据结构的选择与算法效率
数据结构的选择与算法效率——从IOI98试题PICTURE谈起
【关键字】
数据结构的选择线性结构树形结构
【摘要】
算法+数据结构=程序。
设计算法与选择合适的数据结构是程序设计中相辅相成的两方面,缺一不可。
数据结构的选择一直是程序设计中的重点、难点,正确地应用数据结构,往往能带来意想不到的效果。
反之,如果忽视了数据结构的重要性,对某些问题有时就得不到满意的解答。
通过对IOI98试题Picture的深入讨论,我们可以看到两种不同的数据结构在解题中的应用,以及由此得到的不同的算法效率。
本文以Picture问题为例,探讨数据结构的选择对算法效率的影响。
【正文】
引言
算法通常是决定程序效率的关键,但一切算法最终都要在相应的数据结构上实现,许多算法的精髓就是在于选择了合适的数据结构作为基础。
在程序设计中,不但要注重算法设计,也要正确地选择数据结构,这样往往能够事半功倍。
在算法时间与空间效率的两方面,着重分析时间效率,即算法的时间复杂度,因为我们总是希望程序在较短的时间内给出我们所希望的输出。
如果在空间上过于“吝啬”而使得时间上无法承受,对解题并无益处。
本文对IOI98的试题Picture作一些分析,通过两种不同数据结构的选择,将了解到数据结构对算法本身及算法效率的影响。
Picture问题及算法设计
一、Picture问题
Picture问题是IOI98的一道试题,描述如下:
墙上贴着一些海报、照片等矩形,所有的边都为垂直或水平。
每个矩形可以被其它矩形部分或完全遮盖,所有矩形合并成区域的边界周长称为轮廓周长。
例如图1的三个矩形轮廓周长为30:
图1
要求编写程序计算轮廓周长。
数据量限制:
0≤矩形数目<5000;
坐标数值为整数,X围是[-10000,10000]。
二、算法描述
在算法的大体描述中,将不涉及到具体的数据结构,便于数据结构的进一步选择和比较分析。
(一)、轮廓的定义
在描述算法前,我们先明确一下“轮廓”的定义:
1、轮廓由有限条线段组成,线段是矩形边或者矩形边的一部分。
2、组成矩形边的线段不应被任何矩形遮盖。
图2与图3分别是遮盖的两种情况。
图2图3图4
(AB被遮盖)(CD被遮盖)
(二)、元线段
本题的一大特征是分析矩形的边,而边的端点(即矩形的顶点)坐标为整数,且坐标取值X围已经限定在[-10000,10000]之间。
这样,就可以把这个平面理解成为一个网格。
由于给出的坐标是整数,所以矩形边一定在网格线上。
在网格中,对于一条线段我们最关心其绝对坐标。
如图4,我们认为矩形边AB由线段L1、L2、L3组成。
像L1、L2、L3这样连接相邻网格顶点的基本线段,称之为“元线段”,这样就把矩形边离散化了。
显然,有限的元线段覆盖了所有的网格线,且元线段是组成矩形边乃至组成轮廓的基本单位。
一条元线段要么完全属于轮廓,要么完全不属于轮廓。
这种定义使我们对问题的研究具体到每一条元线段,这样的离散化处理有利于问题的进一步讨论。
(三)、超元线段
元线段的引入,使问题更加具体。
但也应当看到,平面中共有20001*20000*2条元线段,研究的对象过多,而且计算量受到网格大小的影响,如果顶点坐标X围是[-1,000,000,1,000,000],元线段数目将达到8*10^12,这是天文数字。
因此有必要对“元线段”进行优化。
受到元线段的启发,我们定义一种改进后的元线段——“超元线段”,它将由对平面的“切割”得到。
具体做法是,根据每个矩形纵向边的横坐标纵向地对平面进行2*N次切割、根据矩形横向边的纵坐标横向地对矩形进行2*N次切割(N为矩形个数)。
显然,经过切割后的平面被分成了(2*N+1)^2个区域,如图5所示:
图5图6
其中像横向边AB、纵向边CD这样的线段就是“超元线段”。
超元线段与元线段有着相似的性质,也是组成轮廓的基本单位。
所不同的是,超元线段的数目较少,一般为4*N条左右,且超元线段数目不受网格大小的影响。
基于超元线段的优点,算法最终将研究超元线段。
(一)、离散化及算法框架
算法的研究对象是超元线段,但这并不等于逐一枚举,那样耗时过大,而整体考虑又使得问题无从下手。
有一种考虑方法是折中的,即既不研究每一条超元线段,也不同时研究所有的超元线段,而是再进一步优化问题的离散化,即将超元线段分组研究。
如图6所示,夹在两条纵向分割边的超元线段自然地分为一组,它们的共同点是长度相同,并且端点的横坐标相同。
纵向线段也可以进行类似的离散化。
这样的离散化处理后,使得问题规模降低,以此为基础,算法的框架可以基本确定为:
1、对平面进行分割。
2、累加器ans←0。
3、研究每组超元线段,检测其中属于轮廓的部分的长度,并把这一长度累加入ans。
4、输出ans的值。
以上只是算法的基本框架,还很粗糙,求精部分有赖于数据结构的具体选择。
三、Picture问题的数据结构选择之一:
线性结构
(一)、映射结构的建立
算法的基础是问题的离散化,要进行平面“分割”,一般需要记录分割点,通常采用映射来记录分割点。
直观的做法是采用一维数组形式,下标表示分割点的编号,数组元素表示分割点的坐标。
利用下标与数组元素的自然对应,实现映射。
应该说,这样表示是比较自然的,实现也比较方便。
数组的优点主要是存取方便,且可以在O(NlogN)时间内排序。
映射结构定义如下:
Type
Mapped_TYPE=Object
Len:
0..Max;{记下分割点的个数}
Coord:
array[1..Max]ofinteger;{记下分割点坐标}
ProcedureCreat;{映射初始化}
ProcedureInsert(X:
integer);{插入分割坐标X}
ProcedureSort;{对坐标排序}
End
以下是三个过程的描述与解释:
ProcedureMapped_TYPE.Creat
1Len←0
{Creat用于初始化该映射}
ProcedureMapped_TYPE.Insert(X:
Integer)
1Len←Len+1
2Coord[Len]←X
{Insert用于插入一个分割坐标,此时坐标之间是无序的}
ProcedureMapped_TYPE.Sort
略
{Sort用于将Len个坐标排序。
由于Coord是一维数组,Sort容易实现,例如快速排序。
设N=Len,Sort效率可达O(NlogN)。
针对整数,也可以采用筒排序得到更好的效率,但这不是问题的关键部分。
}
Var
X_map,Y_map:
Mapped_TYPE{分别记录横纵坐标的映射}
以横坐标为例,在程序处理时,首先执行X_map.Creat初始化映射。
而后通过X_map.Insert将每个矩形纵向边的横坐标作为分割坐标插入X_map.Coord,最后执行X_map.Sort进行排序。
至此,映射建立完毕。
应该说,这一部分完全可以满足算法要求,且执行效率较高。
三个过程中的Creat与Insert耗时均为O
(1),Sort耗时为O(NlogN),但它只需执行一次。
(二)、线性结构的建立
映射建立后,相当于完成了对平面的切割。
现在的主要问题是如何描述一组超元线段的状态。
由于最终要计算轮廓周长,我们最关心的是一组超元线段中究竟有多少条属于轮廓。
由分组的方法可知,每组超元线段长度相同。
以下均以横向超元线段为例进行说明。
设:
超元线段组编号1——N*2-1(N是矩形数目)
编号为S的超元线段组中的线段长度为Length(S)
编号为S的超元线段组中属于图形轮廓的超元线段数目为Belong(S)
则:
其中Lenth(s)容易求得。
如果超元线段组编号以网格中从左到右为原则,那么Length(s)就可以表示为:
X_Map.coord[s]–X_map.Coord[s-1],算式①只需求得Belong(s)即可。
如图6,可以看到在问题的离散化之后,一组横向超元线段从上到下,数目是有限的,约有N*2条。
这使得我们很容易想到线性结构。
例如用一维数组来描述这么一组线段,用数组下标表示该线段从上到下的编号。
数组雏形定义如下
Var
A:
array[1..MaxSize]ofinteger
基于对一维数组的使用,可以得到一个称为“累计扫描”的过程,来求解Belong(s)。
累计扫描的思想是,将一维数组的元素看作计数器,计数器A[I]的内容是覆盖超元线段I的矩形上边的数目—覆盖超元线段I的矩形下边的数目。
形象表示如图7:
图7
同时,设立累加器add,从上至下扫描超元线段,累加a[I]的值。
由图7中可以看出,一条超元线段I属于轮廓的情况有两种:
1、A[I]≠0且扫描到该超元线段未累加时add=0(超元线段I是矩形上边的情况)
2、A[I]≠0且扫描到该超元线段累加之后add=0(超元线段I是矩形下边的情况)
这样,对于一组超元线段求解Belong(s)可以分为两部分:
1、对A[I]赋值,即累计过程。
2、从上至下扫描一组超元线段并累加add,即扫描过程。
Belong(s)的值在扫描过程中得到。
至此,描述一组超元线段状态的数据结构基本确立,存储结构是线性一维数组,定义的操作包括累计与扫描两个部分。
定义如下:
Type
Group_TYPE=Object
A:
array[1..MaxSize]ofInteger;{线性地记录一组超元线段的信息,如图7}
ProcedureCount;{累计的过程}
FunctionAdding;{扫描的过程,即求解Belong(s)的过程}
End
ProcedureGroup_TYPE.Count{累计的过程}
1数组A清零
2forI←1toN
3doif矩形I跨越了超元线段组S
{即矩形的左右边分别在线段两侧}
4thenA[矩形I的上边]←A[矩形I的上边]+1
5A[矩形I的下边]←A[矩形I的下边]-1
{所谓“矩形I的上边”指矩形I上边纵坐标的映射编号,“矩形I的下边”同}
FunctionGroup_TYPE.Adding{扫描的过程,函数值即为Belong(S)的值}
1调用Count
2add←0
3sum←0
4forI←1to纵坐标的最大映射编号
5doifa[I]≠0
6thenifadd=0
7thensum←sum+1
{该线段是矩形的上边}
8add←add+a[I]
9ifadd=0
10thensum←sum+1
{该线段是矩形的下边}
11returnsum
{Count与Adding用于一组超元线段的累计扫描}
Var
Scan:
Group_TYPE
数据结构确立后,Belong(s)通过调用Scan.Adding来计算,算式①得以实现。
以上的操作针对一维数组而设计,用于进行一组超元线段的累计扫描过程。
执行Scan.Adding的时间复杂度为O(N)。
横向超元线段分为2*N-1组,固求解横向轮廓周长的算法时间复杂度为O(N^2)。
同理,求解纵向轮廓周长的复杂度也为O(N^2),则Picture问题的算法时间复杂度为O(N^2)。
虽然这是一个多项式阶,但在最坏情况下(N接近5000时)还有一定的计算量。
对数据结构选择的进一步分析
累计扫描过程体现了一种认识和思维方式,以一维数组作为数据结构基础,这里是否有更好的做法,我们将作进一步分析。
通过求解问题对数据结构选择作的分析中,我们注意到在选择数据结构需要考虑的几个方面:
1、数据结构要适应问题的状态描述。
解决问题时需要对状态进行描述,在程序中,要涉及到状态的存储、转换等。
选择的数据结构必需先适用于描述状态,并使对状态的各种操作能够明确地定义在数据结构上。
在Picture问题中,涉及到算法的状态是关于一组“超元线段”的描述,目的是要确定该组超元线段的数目,我们选择了线性结构,采用计数扫描的方法,统计超元线段属于轮廓的数目。
这种表示法直观、易于实现,可以说基本适用于描述状态。
但采用一维数组,效率并不高,一次扫描耗时较大。
其中主要的原因是各组超元线段的扫描分别独立,后面的扫描并不能利用前面的结论。
2、数据结构应与所选择的算法相适应。
数据结构是为算法服务的,其选择要充分考虑算法的各种操作,同时数据结构的选择也影响着算法的设计。
我们有这样的认识和经历,如果算法是对一个队列进行堆排序,就应当选择能够迅速定位的数据结构,如一维数组等,而不应选择像链表这样定位耗时的数据结构,反之,如果要对一个链表进行排序,则基于链表结构的基数排序应当是首选对象。
Picture问题的算法思想基于问题的离散化,需要对平面进行分割,记录分割点的坐标。
通常,使用映射来记录分割点。
采用数组形式,利用其下标与数组元素的自然对应,实现映射,直截了当。
这样选择基本可以满足算法要求。
同时,在选择数据结构时,也要考虑其对算法的影响。
数据结构对算法的影响主要在两方面:
◆数据结构的存储能力。
如果数据结构存储能力强、存储信息多,算法将会较好设计。
反之对于过于简单的数据结构,可能就要设计一套比较复杂的算法了。
在这一点上,经常体现时间与空间的矛盾,往往存储能力是与所使用的空间大小成正比的。
◆定义在数据结构上的操作。
“数据结构”一词之所以不同于“变量”,主要在于数据结构上定义了基本操作,这些操作都有较强的实际意义。
这些操作就好比工具,有了好的工具,算法设计也会比较轻松。
Picture问题中选择了线性结构,它定义的操作比较简单,因此无法很好地将不同组的超元线段统计联系起来。
3、数据结构的选择同时要兼顾编程的方便。
许多复杂的数据结构能够得到较好的效率,但编程复杂,不易实现且容易出错。
在这种情况下,如果能够选择一种我们较为熟悉的又不会过多地降低程序效率的数据结构,倒不失为一种折中的办法。
如Picture问题中的Group_TYPE.Count过程的4、5两步,要求出某个矩形边对应的映射编号。
我们定义的映射仅仅是编号→坐标值,并不是坐标值→编号。
如果再实现这一映射,势必增加编程难度。
所以编程求精时,可以认为以整数而不是以顶点坐标对平面进行横向切割。
这样映射关系很好建立,坐标值本身就是编号,减少了编程难度。
如果进一步以顶点坐标作横向切割,当然会提高程序效率,但效果并不明显——扫描计数仍需要O(N)的时间,这是很昂贵的,所以进一步切割并不影响算法主要部分的效率,另一方面,编程难度却会大大提高,得不偿失。
由此看出,在算法效率“大局已定”的情况下,有时也需要适当地牺牲程序效率来减少编程不必要的麻烦。
4、灵活应用已有知识。
我们对编程都积累了一定的经验,对以后的解题有很大帮助。
一个“新问题”有时与“旧问题”有许多内在的联系,往往能够将新问题转化为所学过的知识,或者由所学过的知识得到启发,从而解决问题。
所谓“新”数据结构的构造,有时可以是几种基本数据结构的有机结合,或者由基本数据结构得到启发而得到。
做到“温故而知新”,是对算法设计者创新意识的要求。
当然,对一个问题,要首先考虑现成的、经典的数据结构。
如队列、栈、链表等等,其标准结构与标准运算已经有了“公论”,程序实现也经过了“千锤百炼”,效率已经很完美。
如果找到一种可行的经典数据结构,那么算法实现一般来说就比较轻松。
要做到这一点,要求我们有扎实的基础知识,对各种算法及数据结构了然于胸。
在计数扫描过程中采用了经典的线性一维数组,是一个很自然的考虑方向,并且可以很容易上机实现,不足之处在于其效率较低。
总地来说,Picture问题算法思想的方向还是基本正确的。
Picture问题最大数据应包含近5000个矩形,这样大的数据量决定了要降低规模是“大势所趋”,所以对问题的离散化处理是合理的选择。
至于效率不高,应当是线性数据结构的选择造成的。
由此,我们可以看到使用线性结构来实现Picture问题还有一些缺陷,其中最主要的是各组超元线段的统计相互独立,联系不紧,这是算法效率不高的“瓶颈”。
为了解决这个问题,我们尝试用其它的数据结构来实现算法,像前面一样,这个数据结构应该符合以下的条件:
1、同线性结构一样,新数据结构要适用于描述一组超元线段的状态。
至少,新结构要合理地表示一组超元线段属于轮廓的部分,或者说它要能准确地且较快地计算出算式①中Belong(s)的值。
2、新结构也要与基本算法相适应。
新结构仍然以问题的离散化为基础,映射结构应当保留——事实上映射结构在时间效率上并没有缺点。
新结构在描述超元线段组时则要设法将不同组超元线段的统计有机结合起来。
3、新结构还要兼顾编程的方便。
如果选择的数据结构编程难度太大,以至于无法上机实现,或者只是理论上的“高效率”,对解题没有实际意义。
这么说也许太夸X,但实际上也常常存在“鱼与熊掌不可兼得”的情况。
大部分高级数据结构的实现都需要一定的编程技巧。
就像问题在时间效率与空间效率上的矛盾一样,算法效率与编程难度也有矛盾,一般来说算法效率越高,编程难度也会越大。
考虑新结构时希望能找到实现较为容易的数据结构。
综合以上分析,由于线性结构并不能给我们带来令人满意的效率,所以我们尝试用树形结构来描述一组超元线段的状态,实现Picture问题的基本算法。
为了提高效率,采用的树结构必需是平衡树,我们姑且称之为“超元线段树”。
Picture问题的深入讨论
基于对数据结构选择的进一步分析,我们来重新考虑一下Picture问题的数据结构的选择,即采用树形结构来描述一组超元线段的状态。
一、线段树
受到累计扫描过程的启发,一组超元线段属于轮廓的数目,它与跨越该组超元线段的矩形的纵向边位置关系密切。
不妨把矩形的纵向边投影到Y轴上,这样就把矩形的纵向边看作闭区间,并称之为闭区间Q。
我们以“线段树”的树形数据结构来描述闭区间Q。
作为工具,先简单研究线段树的特点。
线段树是描述单个或若干区间并的树形结构,属于平衡树的一种。
使用线段树要求知道所描述的区间端点可能取到的值。
换句话说,设A[1..N]是从小到大排列的区间端点集合,对于任意一个待描述的闭区间P=[x,y],存在1≤i≤j≤N使得x=a[i]且y=a[j],这里i,j称为x,y的编号。
可以看到,即使是实数坐标,在线段树中也只有整数含义。
以下所说的区间[x,y]如无特殊说明,x、y均是整数,即原始区间顶点坐标的编号。
线段树是一棵二叉树,将数轴划分成一系列的初等区间[I,I+1](I=1—N-1)。
每个初等区间对应于线段树的一个叶结点。
线段树的内部结点对应于形如[I,J](J–I>1)的一般区间。
一般情况下,线段树的结点类型定义如下:
Type
Lines_Tree=Object
i,j:
integer;{结点表示的区间的顶点标号I,J}
count:
integer;{覆盖这一结点的区间数}
leftchild,rightchild:
↑Lines_Tree;{二叉树的两个子结点}
end
关于Lines_Tree的其它数据域与定义的运算将陆续添加。
图8是一棵线段树,描述的区间端点可以有10种取值。
其中记录着一个区间[3,6],它用红色的[3,5]及[5,6]的并采表示。
图中红色结点的count域值为1,黑色结点的count域值为0。
图8
直观地看,子结点就是父结点区间平均分成两部分。
设L,R是父结点的区间端点,我们可以增加Lines_Tree.Build(l,r:
integer)递归地定义线段树如下:
ProcedureLines_tree.Build(l,r:
integer)
1I←l{左端点}
2J←r{右端点}
3Count←0{初始化}
4Ifr-l>1{是否需要生成子结点,若r-l=1则是初等区间}
5thenk←(l+r){平均分为两部分}
6new(leftchild)
7leftchild↑.Build(l,k){建立左子树}
8new(rightchild)
9rightchild↑.Build(k,r){建立右子树}
10elseleftchild←nil
11rightchild←nil
设根结点是Root,建树需要执行Root.Build。
由递归定义看出,线段树是一棵平衡树,高度为┌logN┐。
建立整棵树需要的时间为O(N)。
以上着重说明了线段树的存储原理,我们还应建立线段树的基本运算。
线段树可以存储多个区间,所以支持区间插入运算Lines_Tree.Insert(l,r:
integer),定义如下:
ProcedureLines_Tree.Insert(l,r:
integer)
{[l,r]是待插入区间,l、r都是原始顶点坐标}
1if(l<=a[i])and(a[j]<=r)
2thencount←count+1{盖满整个结点区间}
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 数据结构 选择 算法 效率