读《算法导论》总结
2013-04-17 21:02 zmkeil 阅读(763) 评论(0) 编辑 收藏 举报算法基础
---读《算法导论》前2部分小结
1.算法基础
算法的概念就不必多讲了,这里要指出的是:一般情况下,我们讲算法是面向大数据量的,算法的优劣通常是由其执行的时间复杂度来决定的(当然实际中还包括编程复杂度、计算机资源限制等等)。
举个例子,对n个数据进行排序操作,算法1需2n2条指令,算法2需50nlgn条指令,若n=8,显然算法1更好,若n=1000000,假设计算机执行速度为109条指令/s,则
算法1需要时间 2*(106)2/109 = 2000s
算法2需要时间 50*106*lg106/109 = 100s
显然算法2的效率更高,一般在评价上述两算法时,肯定会认为算法2的时间复杂度更优,因为复杂度是f(n)的多项式大小,说白了就是:n趋于很大后,多项式的大小比较。
分治法是算法中最常用的方法,以一个简单的排序例子说明,对n个数据排序的时间为T(n),则对n/2个数据排序的时间为T(n/2),把两个n/2序列合并到一起又需要时间n/2(最坏的情况是两序列逐个比较一遍),这样就可以得到
T(n) = 2T(n/2) + n/2
这就是分治法的基本模型,若一个算法问题可以用分治法来解决,那么它的时间复杂度常常可以用主定理来直接得出,下面就是大名鼎鼎的主定理:
(主定理)设a>=1,b>1为常数,T(n)有递归式T(n) = a*T(n/b) + f(n)定义,则t(n) = nlogb a,
- f(n)多项式小于t(n),则T(n)以t(n)为确界;
- f(n)多项式等于t(n),则T(n)以t(n)*lgn为确界;
- f(n)多项式大于t(n),且a*f(n/b)<=f(n),则T(n)以f(n)为确界。
可以看出,主定理并不能包含所有情况,而是存在沟,但一般情况下,主定理是非常好用的,可以直接得出算法的复杂度。
对于主定理的证明,《算法导论》书中的证明很复杂,网上有人说,用组合数学的方法证明很简单,这里不深究了。
另外,算法问题常常和概率分析联系起来,很容易想到,就比如排序问题,执行的实际时间是和输入有关的,若输入本来就差不多排好了,执行时间就会很短,相反则会很长。因此算法复杂度实际上是有概率的,尤其是那些于输入有很大关系的算法,常常我们关注的是其期望复杂度。
为了达到期望复杂度,常常可以人为地对输入进行随机化,对n个数据,要达到真正的随机化,就应该使得到每一种排序的概率都是1/n!,通常有两种方法。
(方法一优先级法)随机得到n个优先级,并赋予这n个数,然后对这n个数按优先级排序。
(方法二原地排序法)对每个数A[i],do swap (A[i] ) (A[RANDOM(i,n)])。
可以证明这两种方法都可以得到随机序列。
2.排序算法
排序算法是非常基本的算法问题,前人已有很多关于此的研究,目前很多排序算法也相当成熟,被写进教科书的,对排序算法的研究可作为算法学习的入门。
2.1堆排序
2.1.1算法概述
堆可以看成是一个完全二叉树,每一层都是满的,除了最下面一层可能有空隙,对于堆,可以得到下面2条性质:
- 按照从上往下,从左向右的顺序对堆数据编号,则堆顶的编号为1,对于节点编号为i的节点,其父为i/2,其左子为2i,其右子为2i+1;
- 对一个有n个节点堆,任意一个子堆最大为2n/3,即当对最后一层半满时,堆顶的左子树大小为2n/3。(这可以用数学式子推出来);
- 没有子节点的称为叶,一个堆中,叶子的个数为n/2。
堆算法中一个重要的子操作是保持堆性质MAX-HEAPIFY(A,i),即使堆A中,以节点i为根的子堆为一个最大堆(即父节点的值要比子节点大)。其操作非常简单,就是
- 在i、LEFT(i)、RIGHT(i)中找到最大的largest,
- 若largest != i,则将它与i交换,然后递归执行MAX-HEAPIFY(A,largest),
- 否则结束。
由上述的性质2可得,T(n) <= T(2n/3) + 常数,属于主定理的第二种情况,可直接得到其复杂度为lgn,其实就是堆的高度,可以想象最坏情况每次得到的largest != i,每次都递归MAX-HEAPIFY操作。
另外可以看出,MAX-HEAPIFY操作是在默认两棵子树已经是最大堆了,为它们加一个堆顶的操作,这是有建堆操作决定的。
建堆操作BUILD-MAX-HEAP(A),就是把一个初始堆变为一个最大堆。其操作也很简单:
- for(i=length(A)/2;i>0;i--) { do MAX-HEAPIFY(A,i) }
有上述性质3可看出,这里是从原堆中第一个非叶子的节点开始,逐步由底向顶执行MAX-HEAPIFY()操作的,这也就是为什么MAX-HEAPIFY()可以默认其自堆是最大堆。
即执行了n/2次MAX-HEAPIFY(A,i)操作,每次MAX-HEAPIFY(A,i)操作时间为lgn,所以总时间为nlgn,不过这个时间会大很多的,因为刚开始时,子堆是很小的,根本没有n量级。
可以从另一个角度看,第h层上最多有n/2h+1个元素,每个元素执行一次MAX-HEAPIFY()操作,总共次数为sum(h* n/2h+1),h=(0,lgn)最大lgn层,简单的数学推导可得这个时间是n量级的,即可在线性时间内建立一个最大堆。
有了最大堆后,排序就很简单了:取出堆顶的数,就是最大的,然后把堆尾的数放到堆顶,并执行MAX-HEAPIFY操作,在取出堆顶的数,就是第二大,依次。。。
MAX-HEAPIFY操作的时间为lgn,共n个数,执行n次,总时间为nlgn,不过总觉得很怪怪的,每次都把堆尾的很小的数放到堆顶,再执行MAX-HEAPIFY操作不是很浪费吗,这其实是堆的数据结构所决定的,它的复杂度为nlgn,真正运行时的期望复杂度就真的会是nlgn,因为每次MAX-HEAPIFY操作都极有可能是lgn次(不会取巧碰上好的)。
而实际上,建立了最大堆后,光用来排序时比较浪费的,堆还有很多其它非常好的功能。
2.1.2优先级队列
堆的一个重要应用就是优先级队列,这在调度程序中常常用到,取出最高优先级的任务运行,只要取出堆顶的任务即可,再把堆尾的任务放到堆顶,执行MAX-HEAPIFY操作。
咦,这样不是烦了吗?倒不如就用一个数组结构,按优先级排好序,取任务就从头开始取就行了吗。其实不然,姑且不看B UILD-MAX-HEAP(A)的时间仅为n,而排序的话可能远不止这个时间;对于其他一些操作来讲,堆结构有很大的优越性。
HEAP-CHANGE-KEY()操作就是动态改变一个任务的优先级,改变之后要保持堆性质,若是decrease,只要在该节点执行MAX-HEAPIFY操作即可,时间为lgn;若是increase,则和其父exchange,直到小于其父,时间也为lgn。
而若是数组的话,要保持排序性质,很可能时间为n(从最头逐个逼到最尾)。
HEAP-INSERT-KEY()操作就是动态加入一个任务,可以首先把它的优先级设为很小,加在堆尾,然后执行HEAP-CHANGE-KEY()操作即可,时间为lgn。
而若是数组的话,同样很可能时间为n(从最头逐个逼到最尾)。
这里的lgn其实就是二叉树的高度,对这样的结构进行一些基本的操作,时间一般为lgn,这与后面要讲的二叉树是相似的。
2.2快速排序
QUICKSORT其思想很简单,就是每次取一个数为基准,把原数组分为两截PARTITION,那个基准数就在中间,再对前后两段递归调用该方法。
要保证在原地排序,如何PARTITION,如何使基准数在中间,这些都是编程技巧了,可以很简单的实现。一般去序列尾的数为基准,维护一个i = 0,大的为A[i]交换,i++,最后队尾与A[i]交换即可。
关于他的效率,可以这样看,把划分看成是一层一层的,每一层划分总的元素个数仍是n,执行时间为n;那问题就是怎样的划分,才能使层数最小,直观的想,应该是均匀一点分,不能走单极化。
最坏的单极化就是每次都分出1个,那会共有n层,总时间就为n2,最佳的则是每次都均分,则共lgn层,总时间就是nlgn。
实际中,很有可能是一层划分的不太均匀,下一层可能均匀一点,层数会是lgxn,x>2,总时间复杂度仍为nlgn(不同底数的lgn的复杂度是一样的,就差一个常数积),不过常数因子可能随每次运行情况而改变。
实际中,为了尽量使每层划分均匀一点,会对输入数组进行一个随机化,可以证明,对于随机输入序列,QUICKSORT的期望复杂度为nlgn,证明过程要用到概率知识,推导蛮复杂的,这里不深究。
2.3线性时间排序
通常会有这样一个理论,时间来不及时,可以用空间来换取时间,这在排序算法中也适用。前面讲的两种算法都是在原地排序,通过相互比较来得到结果,这种策略通常通过递归划分来实现,由主定理这种方法的复杂度为nlgn。那么,怎么在空间上做文章,不用相互比较,并在更短的时间内完成排序呢。
2.3.1计数排序
思想很简单,对n个数排序,若n个数在1~m的范围内(m>>n),则大可以建立一个m长的数组,把n个数据按key值塞入数组中,然后遍历该数组,每个元素的位置应该就是该元素之前的元素个数+1,这样就能直接得到序列的排序。总时间应该就是n+m,一般我们取m=kn,所以该算法的复杂度为线性时间。
明显,n很大时,这种方法很浪费存储。
2.3.2基数排序
对整数而言,不管有多少位,没位上无非是0-9这十个数字中的一个,从低位开始(注意是低位开始),依次用该位上的数字来排序(这可以用计数排序),最后就能得到结果。
很明显该算法的复杂度为n。
2.3.3桶排序
思想是,把n个数据按规则(如10,20,30.。。这样一段段的)划分成一个个的桶,每个桶里的数据就非常小了,分别对它们排序,可以用已有的方法(如复杂度为klgk,k1+k2+。。。=n),最后把这一段段的拼接起来即可。
这与后面要讲的hash散列的思想类似。
可以证明出,及时划分桶后使用复杂度为k2的算法排序,总的排序复杂度的期望值仍能保持在线性时间内,关键就是证明E(sum(k2)) = n。
3.中位数和顺序统计学
很多时候,我们并不需要对整个序列排序,而仅是要找到其中第几大的元素。很容易联想到利用QUICKSORT的方法,随机选一个元素为基准,进行PARTITION,得到该基准数是第几个,若偏大,则在左序列中递归PARTITION;若偏小,则在右序列中递归PARTITION。
最坏情况下,每次都得到的基准数都市子序列中的最后一个,那么总时间为n-1+n-2+n-3+…1,是n2复杂度,而期望的复杂度则只有n。
最简单的中位数就是MAX,MIN,那就大可不比用上述方法,只要进行依次遍历比较即可得到,复杂度为n。
4.小结
学习了解了算法问题的一些基本概念,和研究方法。着重以排序算法为例,练习了算法问题的解决方法,并动手编写了相应的代码实现。
对于其中期望复杂度的问题,即概率分析法的适用还不是很熟,以后加强。
算法基础2
---读《算法导论》3、4部分小结
1.数据结构
很多问题都可归结为对一个集合的一系列操作,这些操作包括查询、插入、删除,支持这些操作的动态集合称为字典。另一些问题可能还需要支持更复杂的操作,为了支持这些操作,并且使得操作效率很高,以怎样的方式来组织这个集合就至关重要了,这就是数据结构所研究的内容。
1.1简单数据结构
最容易想到的数据结构当然是把集合的所有数据组合一个序列,这种方式几乎不要额外添加任何枝节,很简单。我们常用到的栈和队列就是这种结构。
栈是单出口的,采用先进后出的方式,在计算机系统中经常用到。
队列是双出口的,采用先进先出的方式。
这样的简单结构,注定它的查询复杂度大,为n。
1.2散列表
散列表是数组的扩展,其主要目的是对一个有零散元素构成的集合进行快速寻址。举例来讲,如果一个集合的key值是1,2,3…n,那么只需要建立一个数组即可对其中每个元素进行直接寻址。但若集合的key值是1,5,7,101,84,…怎么办呢?也建立一个数组,那么数组会很大,而其中真正存储元素的却很少,造成存储浪费。
散列表正是解决这样的问题的。如上面的问题,还是建立一个数组表,但仅有m长,对每个key值,不是直接映射到表中,而是通过一个散列函数h计算后,再映射到表中。
这时就可能发生这样的情况,两个key值不同的元素,被h计算后,映射到了表的同一位置。如共有n个元素,表长m,则定义装载因子a=n/m,若散列函数h对每个key值的映射都是独立的,那么平均来看,表的每个槽中有a个元素,若a>1,那么必然有些槽中会有碰撞。
解决碰撞的最直接的方法就是列表,即把有碰撞的槽内的元素构成一个列表,很容易得出寻址一个元素需要的平均时间为1+a/2。真正证明还是需要用到概率的知识,稍复杂。
其实还有很多其它方法,如数组,如后面要讲到的二叉树等等,散列的真正含义就在于把一个凌乱的集合先通过h归归类,然后每个类中的元素就少很多,可以用其它一些结构组织,以满足实际需求。
1.2.1散列函数
散列表的优劣关键在于散列函数,首先看一下散列函数一定要满足的条件:对于每个key值,其散列结果不能超过表长。例如一个散列表长3,则设计一个最简单的散列函数为h(k)=k mod 3。
散列函数应(近似地)满足简单一致散列的建设:每个关键字都等可能地被散列到m个槽中去,并与其它关键字散列到哪个槽中无关。这个条件一般很难被满足,因为你设计h时并不知道输入数据的概率分布。比如上述的h(k),若输入数据是1,4,7,10…,那么所有数都会被散列到槽1中去。
除法散列是一种简单的散列方法,其实就是上面的h(k)=k mod m,m的选择尽量为质数,且离2p不是很近。
乘法散列法为 h(k) = floor(m*(kA mod 1)),0<A<1,A的值没有什么特别的要求,Knuth认为A=(sqrt5 -1)/2是一个比较理想的值。
1.2.2全域散列
上面已经讲了如果使用单一的散列函数,对于一个集合,根本无法预知其分布情况,所获得的散列效果可能并不好,这就是全域散列的提出背景。
既然不能改变集合的输入,那就在散列函数上做文章。设H为一组有限的散列函数,它们将集合中的key值映射到{0,1,2…m-1},我们称这样一组函数为全域的:如果对于任一对不同的key值k、l(k != l),H中使得h(k) = h(l)的h之多有|H|/m个。
散列时,从H中随机选择一个h,则两不同的key值发生碰撞的概率为1/m。另外再看,对于两个不同的key值,随机地在{0,1,2…m-1}中选择槽,发生的概率是多少呢?也是1/m。YES,从概率结果上看,全域散列的作用就相当于使每个key值都随机地、等概率地映射到任一槽上,即变相地使h满足了简单一致散列的假设。
要注意的是,这里仍是概率上的,即即使使用了全域散列,所选的h仍然可能导致很差的散列结果(虽然各个h之间有互斥,但同一个h对所有的key值只有概率上互斥),但这种情况的发生概率很小。
至于全域散列函数的设计,已经有成熟的方法了,需要一点数论的知识就可以很容易理解,不过这里先放一放。
1.2.3开放寻址
开放寻址的设计主要是避免碰撞的发生,从而不需要二次工序,只在一级散列中就把问题全解决掉。
其设计思想是这样的:对每个key值,h将它映射到某槽中,若该槽中已有元素,则再次计算h的值,从而映射到下一个槽中去,依次类推,直到找到一个空槽。这种方式也称为探查。
很明显,这要求每次计算h的值要不一样,怎么做到这点呢,为h加一个参数h(k,i),i=0,1,…m-1,且要求对任意k值,这m个h的值正好是{0,1,2…m-1}的一种排序。另外要求有尽量多的探查排序(一致散列要求有m!个探查序列),这样有益于不同key值探查时尽量不相关(即尽量不要有一段探查顺序是一样的),从而避免多次碰撞,提高效率。这一点在下面的例子中有体现。
常用的探查方法有3种。
线性探查:h(k,i) = (h’(k) + i) mod m,i=0,1,2…m-1。很容易看出,探查序列只取决于首个映射槽,后面的就是依次往下找,到尾后摆到头继续来,因此只有m中不同的探查序列,太少啦!这称为一次群集,这就有一个问题:不同key的探查序列中有大片相同的片段,若前面已经有很多key值已经被映射好了,后来的key值不得不重复那些路径,经多次碰撞后才能找到空槽。
二次探查:h(k,i) = (h’(k) +c1i + c2i2) mod m,这同样也是由首次映射槽决定整个探查序列,因此只有m个不同的探查序列,也会出现群集现象。
双重散列:h(k,i) = (h1(k) + i*h2(k)) mod m,由于每次探查由i,k共同决定,因此同会有m2种排列。
1.2.4完全散列
前面讲到的散列问题,都会提高概率,那是因为输入的集合是不确定的,所以散列的结果一定会由概率决定,我们只能找到期望散列效果较理想的方法,而不能绝对保证。
而对于静态集合,比如某编程语言的保留关键字,则可以设计出一个完全散列,即每个key值都映射到唯一的一个槽位,不存在碰撞,查找只要在常数时间内就能完成。
那么怎么做到完全散列呢?一次散列就做到比较困难,那必须精心设计散列函数h。多级的方式可能比较靠谱,即稍微精心一点设计一级散列函数(比如用一个全域散列),使得所有key值尽量均匀地分配到各个槽中,然后对每个槽中的数据进行二级散列(每个槽的二级散列函数都不同)。
一种常用的方法是,一级散列表大小为m=n,二级散列时,若某个槽内的元素有nj个,构造一个大小mj = nj2的散列表,下面两个定理说明这种方法的有效性。
定理1 :用全域散列的方法,将n个元素散列到m=n2大小的表中,那么出现碰撞的概率小于1/2。
再加上输入时静态的,所以很容易找到一个二级散列函数,使得不发生碰撞。
定理2:nj为用全域散列法映射到一个大小为n的散列表中后,第j个槽内的元素个数,则n1+n2+…nm-1=n=m,E(su且n(nj2))< 2n。
1.3二叉树查找
每个节点可以有左右两个子节点,没有子节点的为叶子,这种存储结构就是二叉树。二叉树存储的一个关键性质是:任一节点的左子树中的所有key值都小于该节点,任一节点的右子树中的所有key值大于该节点。
1.3.1查询二叉树
查找:根据二叉树的关键性质,很容易去搜索某一元素,时间复杂度应该是该树的高度h,因为二叉树并没有额外的限制条件,因此它的高度h不好确定,在n-lgn之间。
最大最小:从根开始,最大就往右找,直到没有右儿子的节点即为最大;最小就往左找,直到没有左儿子的节点就是最小的。时间复杂度也为高度h。
前趋后继:从该节点开始,先早相应的子节点,若没有则向上找相应的祖宗。前趋就找左子树中的最大元素,若没有左子树,则向上找父节点,直到它为父亲的右儿子为止,这个父亲就是它的前趋(比它小的中的最大的);后继就找右子树中的最小的,若没有右子树,则向上找父节点,直到它为父节点的左节点为止,这个父亲就是他的后继(比它大的中的最小的)。
同样,复杂度为高度h。
1.3.2插入和删除
插入操作很简单,根据二叉树的关键性质,可以很容易找到相应的位置,把该元素插入到该地方。时间复杂度为为h。
可以看到,从空树开始,插入同一个集合的元素,不同的插入顺序会得到不同的二叉树,而且树的高度不可控制,最坏的情况下,若按排列好的顺序插入的,得到的数的高度将会是n。
插入前先对集合进行随机化,得到的随机构造的二叉树,可以证明这样的二叉树的高度的期望为lgn。
删除操作稍微复杂一点,分3中情况,若没有子节点,则直接删除;若只有一个子节点,也直接删除,并把子节点变为其父亲的子节点,左右与它和父亲的关系同;若有两个子节点,则找到它的后继,删除掉,并把其数据覆盖到该节点处,对于后继的子节点(最多只有一个子节点)同第二种情况。
编程时有个技巧,先找后继,再判断属于哪种情况。
1.4红黑树
刚才也说了二叉树的高度不确定,即使是随机构造二叉树,也只保证高度的期望为lgn,红黑树是对二叉树的改进,在二叉树的基础上加上红黑性质,从而保证高度至多为2lg(n+1),具体性质如下:
- 每个节点为红,或黑
- 根节点为黑
- 每个叶节点都为nil,为黑
- 如果一个节点为红,则它的两个儿子都为黑
- 对任一节点,从它到它所有的子孙节点的所有路径上包含相同数目的黑节点。
无疑,4、5两条是关键。
红黑树的一般查询操作和二叉树一样,插入和删除操作的前半段也和二叉树相同,只是为了保证性质,要进行后半段的调整RB-FIXUP()。
首先介绍旋转操作,它把父节点的一侧儿子拉上来变为父,并把其的一个儿子作为它的儿子;而自己则旋转到另一侧为儿子。旋转操作不会改变二叉树的性质,但却可以不断地平衡各枝蔓路径的长度,只要再改变相应color属性,就可以保持红黑性质,使得数的高度小于2lg(n+1)。
至于怎么调整,比较复杂,要考虑的情况也很多,不过只要记住一点就行,就是不断地通过旋转操作来平衡各枝蔓路径长度,变化color使得一枝蔓上的黑多了,则增加其他枝蔓的黑,少了,则减少其他枝蔓的黑。
下面是一张我用android系统实现的红黑树的截图
1.5数据结构扩张
本节介绍了一些算法问题中常用的基本的数据结构,在实际工程中,可能不能用标准的数据结构来解决,需要对数据结构进行扩张,扩展主要体现在每个元素增加一些域。扩展的原则就是增了一些域后,不对该结构的一般操作产生影响,即像查询、插入、删除等操作仍只需要原来的那些域就可计算获得。
例如红黑树要扩展出一个独立的satellite域完全没有问题。但有时扩展一个域对某些操作有影响,只要该操作在本工程中是用不到的,那也是可以考虑的。
2.高级设计和分析技术
这部分内容,理论上理解起来还是比较简单的,但要真正在实践中恰到好处的适用这些方法并不简单,需要多次的练习才能做到,这里就不多讲了,只简单描述其思想,具体的分析在今后结合具体的专题来详细学习。
动态规划是一种寻找最优解的有效方法。有一类问题可以划分子问题,不同的划分又可以再次划分,依次…,直到最后的原子子问题,可以先迭代计算出各原子问题的最优解,然后逐步选择组合成最优解,即从最优子解出发,组合出上一层的最优解(从末向前的方向)。
讲起来还是有点抽象,说不清楚,具体问题可能有些不同。
贪心算法也是一种寻找最优解的有效方法。它不像动态规划那样复杂,要把所有子问题的解多罗列出来,再从末向前,逐个组合出整个问题的最优解。它用了一种简化的方法,不用事先规划,而是从头开始,选择当前的最优解,依次往下,每次都选择当前的最优解,直到最终找到整个问题的最优解。
这种方法看起来很盲目,因为选择当前最优解,很可能毁了那些在后期越发优越的解。但有一个美妙的理论能支撑算法,这就是拟阵理论。
即一个问题如果能规划为一个拟阵,则它可以用贪心算法,所得的解一定是最优的。若不满足拟阵,则不能盲目地用贪心算法;另外有些问题不能化为拟阵,但仍可以用贪心算法来解决。
平摊分析是一种分析由一系列操作构成的算法的工具,可以更准确的描述算法的实际运行复杂度。有些算法执行时,是由一些列操作构成的,由于操作的不对称性,有些操作时间长,有些则短,一般估计算法复杂度时,就以那些最长时间操作为基,乘以操作的个数。
这样往往会多估,因为这个系列的操作是有联系的,一个操作若时间长了,则必然会导致其他一些操作时间短,这是平摊分析就可以发挥作用了,想象书中提供的那个MULTIPOP的例子。
平摊分析有3种常用方法:
凝聚分析,即总体来看分析算法,分析其中长短错落的操作的规律;
记账方法,把操作分为两种,而且他们是互补的,则给其中一种操作记双份的代价,一份给自己,另一份给互补的那个操作,这种方法用的好很巧妙;
势能方法,和记账方法有同妙。
3.数学知识补充
3.1全域散列函数类设计
上面提高了,其实只要稍有点数论的知识,就可以简单地构造一个全域散列函数类,构造的宗旨就是,一类函数中,使得对任意两个不同的key值发生碰撞的个数最多为总函数个数的1/m。
构造方法很简单,选取一个较大的质数p,使得所有的key值都小于p,设ZP={0,1,2,…p-1},ZP*={1,2,…P-1},任意选取a属于ZP*,b属于ZP,且选取的散列表大小为m,利用一次线性变换
ha,b(k) = ((a*k+b) mod p) mod m
这样对所有的(a,b),共可得到p(p-1)个散列函数,它们是全域的,下面来证明。
对于两个不同的key值k,l,先看第一级的mod p,设
r = (a*k + b) mod p, s = (a*l + b) mod p
对于k!=l,可以得到r!=s,因为假设r=s,则r-s = a*(k-l) mod p =0,这是不可能的。所以得出第一级的mod时,不会产生碰撞。
其次再看第二级mod m,先说明对于不同的一对(a1,b1),(a2,b2),不可能得到相同的一对(r,s),假设得到相同的一对(r,s),则((a1-a2)*k+b1-b2) mod p = 0,((a1-a2)*l+b1-b2) mod p = 0,这是不可能的,就是上面的那种情况。所以得出:p(p-1)种(a,b)对应了p(p-1)种(r,s),它们是一一对应的,更进一步,就是说得到的(r,s)是随机分布的,即r,s都是{0,1,2…p-1}中随机的。
则对于任意的r,s有p-1种可能(r!=s),那这么多种里面,有多少种会使得r mod m = s mod m呢,答案显然是最多有(p-1)/m种,之间的比值为1/m。
这就是说,对于k!=l,任意取(a,b)构成的散列函数中使得h(k)=h(l)的函数的个数是总函数个数的1/m,即这类函数集是全域的。
3.2拟阵
贪心算法的一种理论基础是拟阵,定义拟阵是一个满足一下条件的序对M = (S,l):
- S是一个又穷集合;
- l是S的一个满足特定条件非空子集族,即由很多S的子集构成的集合(集合的集合),l满足遗传性,即若B属于l,而A包含于B,则A属于l;
- 满足交换性,即若A,B属于l,且A的个数小于B,则一定存在某个元素x属于B-A,使得A并上x仍属于l(即仍满足那个特定的条件)。
这样讲起来很抽象,一个最简单的例子就是极大线性无关组,容易想明白了吧!!!再回想一下求一个线性组合的极大无关组的过程,不是像动态规划那样,先找出所有无关的组合,再看哪种组合的个数最多;而是像贪心算法那样,先随便选一个,再贪心地找到另一个与已找到的向量都无关的(而不必关心选了这个后,是否会对以后的选择有影响),依次,直到找不到已找到的向量都无关的向量为止。
4.高级数据结构
《算法基础2》报告中已经讲过了一些基本的数据结构,主要有栈、队列、散列表、二叉树、红黑树,还介绍了如何扩展数据结构。本节要讲的是另外一些高级的数据结构,它们多是为了某种特定的需求而开发的,针对该需求有很良好的性能。
本小节只作一些简要介绍,具体要用到这些数据结构时再详细分析。
4.1B数
B数是专门针对磁盘存储而设计的一种数据结构,前面讲过的数据结构,都是旨在提高查询元素及其它一些操作的效率,它们都是默认所有的数据存储在内存中,所以设计的数据结构很少考虑到存储结构。
对于磁盘,存储结构是必须要考虑的,因为大部分数据存储在磁盘中,CPU读取磁盘的代价是很高的,比CPU计算查找的代价高得多。B数的设计就是针对这种情况,它的查找效率可能并不高,但其可以在尽可能少地读取磁盘的情况下找到所要找的元素。
考虑到磁盘读取一般以一个存储页为单位(一般为4K,8个扇区),所以一个B数的节点就设计成一个存储页的大小,且存放在磁盘中以存储页对齐的空间内,根节点一般常驻在内存中。
其基本思想就是,一个节点内有很多的元素(key值),同时这些key值把整个key值域分割成很多块,节点内维护多个指针指向各个下属块的地址。查询时,先依次寻找该节点的所有key值(一般就是链表查找,所以效率不高),若该key值不在该节点中,则肯定落在它所分隔某个区间内,根据相应指针就可以找到下一层对应的节点。读取该节点到内存,依照上述方法递归查询,直到找到。
首先看一下b树节点x都有哪些域:
- n[x]表示当前存储在x中的key值数;
- n[x]个key值本身,以非降序存放,即key1<=key2<=…keyn[x];
- leaf[x]是一个bool值,若x是叶子节点,则leaf[x]为TRUE,否则为FALSE;
- n[x]+1个指针,c1[x],c2[x],…cn[x]+1[x],分别指向各个分割开的块。
B树有以下几个最基本的性质:
- 若一个key值k,keyi-1[x]< k <keyi[x],则k存放在ci[x]所指向的内存区域内;
- 每个叶节点具有相同的高度h;
- 每个节点中包含的关键字数量被一个固定整数t决定,该整数称为B树的最小度数,具体如下:每个节点最多包含2t-1个key值,即把key域分解为2t份,若一个节点正好有2t-1个key值,就说它是满节点;每个非根节点最少包含t-1个key值,即把key域分解成t份;非空B树的根节点至少有1个key值。
根据上述性质3,可以知道一个B树是怎样由一颗空树构建起来的,首先每次INSERT的元素应该是在leaf上,且根据key值大小非降序插入。但问题在于插入了元素有可能使一个节点的key值数大于2t-1,因此可以想到,在插入到叶子节点前,先判断该叶子是否是满的,若是,则先将它分成两个,然后再插入。那么怎么分呢?
满节点的key值数位2t-1,是奇数个,可以取中间一个,上升到上一层的节点中,并根据大小放在相应位置,然后剩下2t-2个key值正好分成两份,每份t-1个,符合性质,且这两份刚好被上升到上一层节点中去的那个中间key值隔开。又有另一个问题,往上一层节点中插入了一个元素,若上一层节点本来是满的怎么办,所以应该在从根节点开始往下搜寻到叶子的路径上,每遇到一个满节点,就事先把它分割成两个节点,这样就不会出问题了。
删除一个节点和插入的意思差不多,要注意节点key值数的问题,只不过实现起来麻烦一点,因为删除的节点不一定在叶子上,而是可能在任何内节点中。这里就不多讲了。
最后看一下B树的操作时间复杂度。首先看高度h上界,设一个b树有n个节点,要是高度最大,根节点一个元素,所有其它节点t-1个元素,另外除根节点外,第i层的节点数位2ti-1,即有
n >= 1 + (t-1)sum(2ti-1) = 1 + 2(t-1)(th-1)/(t-1) = 2th – 1
所以h <= logt((n+1)/2),t通常是很大的,如1000,则即使n很大,h的值也是很小的。对B数的一次操作最多需要h次磁盘存取,所以B树有助于大大减少磁盘存取的次数。
如B树的查询操作,复杂度通常从两方面来表示,一是存取磁盘的次数,为O(h) = O(logtn),而是CPU操作的次数,查询h层,每层操作O(t),总时间为O(t logtn)。插入操作的复杂度也是一样的。
4.2二项堆
这里的堆与堆排序那里讲的堆的意义不太相同,这里指有一组有根树构成的数据结构,具体的,就是把各棵树的根串成一个链,就像一根杆子一下,下面挂着若干个树。这种数据结构的最大优点就是可以很方便的合并,只要把两个堆的根表join成一个根表即可,当然,为了满足堆的一些性质,可能需要一些其他操作。另一方面,各棵树的key值间没有什么关系,所以这种数据结构对SEARCH的支持是低效的,只能遍历查询,一般在应用程序中都应维护各个节点的引用,而不是用时再去堆中SEARCH。
二项树,二项树结构是递归构成的(B1,B2…BK-1,BK),B0为单个节点,Bk由两颗Bk-1构成,其中一颗作为另一颗根的最左子树。二项树Bk有如下性质:
- 共有2k个节点;
- 树的高度为k;
- 在深度i层,有Cki个节点;
- 根的度数为k。结合1,可得出含有n个节点的二项树最大度数log2n。
二项堆是有一组二项树构成的。需满足以下3个性质:
- 每个二项树都应是最小堆有序的,即树中每个节点的key值大于等于其父节点的key值;
- 对任意整数k,堆中最多只有一颗二项树的度数为k;
- 各颗二项树的排列顺序:按度数从小到大排。
对二项堆的最重要的操作就是合并,很容易想到,这种堆形式是把一组树串起来,因此合并只需将两个堆的根表合并即可,不过同时要满足二项堆的性质不变,满足以下规则:首先合并根表时,应按各树的度数重新排列;然后依次查询各树,若两相邻树的度数相同,则把它们合并,若连续3个树度数相同,则把后两个树合并(每次合并后都有一个回退查看)。
二项堆的其它很多操作都是基于合并基础的。
INSERT操作,就是把只有一个元素的堆与原堆合并即可。
EXTRACT-MIN操作,首先选取最小元素,因为各颗树都是最小堆有序数,所以最小key值只要在根表中轮询依次即可;然后删除这个最小点,有二项树的性质可知,一个Bk的根删除后,被分为B0,B1,…BK-1,把这些树组成的堆与原堆合并即可。
DELETE操作,则先把它的key值设为负无穷,则按最小堆的性质,它会被调整到该树的根的位置,然后调用EXTRACT-MIN操作即可。
4.3斐波那契堆
与二项堆一样,这也是一种堆的形式,不过这种堆很松散,即不要求各树只要是最小堆有序树即可,另外也不要求根表中排序有什么规则,实际上它的根表中所有树是随便排的,且构成一个双向环形链表,表头HEAD指向其中最小的元素。每个树的亲戚关系是典型的左孩子,右兄弟的形式,所有兄弟都构成双向环形链表。
由于斐波那契堆如此松散,它的操作和二项堆非常相似,但所需的复杂度却很小,可以看出INSERT、UNION、DELETE操作都可在常数时间内完成,所有的整合工作都有EXTRACT-MIN操作来完成。
EXTRACT-MIN首先和二项堆的操作一样,通常情况下,此时的堆已经是非常松散了。建立一个节点数组,A[D(H)],D(H)是堆中各树最大的度数。然后依次查询根表中的各树,若度数为k,则把它填入A(k),若原A(k)中已经有元素了,则把两个树合并,合并数也很简单,为满足最小堆性质,把根较小的树作为另一个树的最左子树。
可以用势能的方法证明EXTRART-MIN操作的平摊复杂度为lgn,不过这种堆结构对于编程实现而言比较复杂,目前还仅具有理论价值。
4.4用于不相交集合的数据结构
不相交集合数据结构保持一组不相交的动态集合S={S1,S2,…SK},每个集合不相交,通常它支持一下3种操作,MAKE-SET(x)建立一个新结合,仅包含x;UNION(x,y)将包含x、y的两结合合并起来;FIND-SET(x)返回一个指针,指向包含x的集合。
一个典型的应用就是对一个无向图G(V,E),找出所有连通子图的顶点。
- 首先对所有节点V,都调用MAKE-SET操作,建立一个单独的集合;
- 然后轮询每一条边E,其两个顶点u、v,若FIND-SET(u) != FIND-SET(v),则调用UNION(u,v)把这两个集合合并。
至于一个集合内的元素以什么结构组织,可以另作研究,通常的做法有链表,以及树的形式。应尽量满足两个特性,一是要很容易查询出一个节点x是否属于这个集合,二是要能够很好地支持合并操作。
5.小结
主要整理学习了几种基本的数据结构,了解了数据结构在算法设计中的关键作用,知道各种数据结构是怎样支持各种操作的。
另外简单介绍了3种高级设计分析技术的思想,为以后利用这些方法解决相应专题打下基础。