算法导论
1、动态规划
动态规划与递归有些神似,适用场景不同而已。
关于动态规划,《算法导论》上给出好几个例子,第一个就是装配线调度问题,抽象成如下形式:
递归的出口在j=1处,需要求解的则是f[1,n]和f[2,n]。
上式已经是递归的形式。但实际上,若采用传统的递归方法,计算代价会非常的高,因为的值需要重复的被计算次。
下图说明为什么会被重复计算,并且是2的幂指数形式(假设n=3):
从这张图就看的比较清楚了。原因在于,递归每一“层”的结果需要被计算的次数,是上一“层”结果被计算次数的两倍;
例如f[2,j-1]要同时被f[1,j]和f[2,j]使用,这一点直接来自递归式。图中第4层的f[2,1]被计算四次,而其上一层的结果f[1,2]和f[2,2]则分别被计算两次。
坑爹之处在于,这种倍数关系在树中就变成了幂指数关系。
动态规划的思路是:先计算小的子任务,再计算较大的子任务;在计算较大子任务时,所需的小子任务的值已经计算好,这样便能直接拿来用,无需重复计算。
例如这里先计算出f[1,0]和f[2,0]并保存结果;随后计算较大的子任务f[1,1]和f[2,1]时,它们所需要的就是f[1,0]和f[2,0]的值,已经是现成的。
而多数情况下,我们都可以放心大胆的用递归方法。具体来说,若递归每一“层”结果的计算次数是上一“层”的1倍的时候,就可以使用传统递归方法。因为1倍的倍数关系转换为幂指数后,也不可怕。
举个简单的例子。f(i)=f(i-1)+2,递归出口是f(1)=0,那么递归求解f(5)大概是这样的:
以上说完了动态规划和递归的区别。装配线调度的问题,使用动态规划方法,就是“自底向上”的计算:先计算f[1,0]和f[2,0];再逐次向上计算。
《算法导论》还提到了矩阵乘法的问题,简介如下。待计算的问题抽象为:
待求的是m[1,n]。注意,“需要被用到的m[i,j]”铺满了1≤i<j≤n的所有取值。正常情况下采用的递归格式为:
但是这里必须得“自底向上”的计算,即先计算i和j相距较小的m[i,j](否则可能出现计算某个m[i,j]时,所需的m[i,k]或m[k+1,j]尚未被计算的情况);迭代方式变为:
其余没什么好说的。
动态规划适用的场合,有两个主要因素:最优子结构、重叠子问题。
最优子结构是说,一个问题的最优解中包含了其子问题的最优解。在贪心算法中也用到了最优子结构;不同的是,动态规划是“自底向上”的使用,而贪心法则是“自顶向下”的使用:先做(当前看起来是最优的)选择,再求解子问题。
重叠子问题是说,同一个子问题被反复调用。例如在装配线调度上,f[2,1]被反复调用达4次。这时动态规划就能派上用场,它只需计算一次其余均直接查找该值。相反,常见的递归例如f(i)=f(i-1)+2,每个只被调用一次,因此用传统递归方法求解即可,不会造成计算量爆炸。
一个动态规划算法的运行时间,大致等于"子问题的总数量"×"每个子问题面临多少个选择"。例如矩阵乘法问题,子问题数量有(取遍1≤i<j≤n的所有值)个;而每个子问题面临个选择(1≤k<j);所以整体时间复杂度为。
使用动态规划的一个前提条件是各子问题是独立的。所谓独立,是指各子问题之间绝不会共享资源,从而一个子问题的解不会干扰到另一个子问题。一个反例是“求解图中最长路径”,如下:
为了求B到C的最长路径,我们试图将其分解为两个子问题:B到D的最长路径、D到C的最长路径,连接两者得到结果。这是动态规划的典型思路,却行不通,因为会造成回路,例如"B→C→D"、"D→A→B→C"。之所以如此,就是因为分解得到的两个子问题不独立,彼此的结果干扰,导致结果不可信。
而“最短路径”就不会出现共享资源的问题,这很明显就不加说明了。因此求解最短路径时,可以采用动态规划方法。
2、贪心算法
贪心算法与动态规划同是求解最优化问题,并且思路相通。之所以使用贪心算法,是因为不少场合下用它就足以解决问题,并且比动态规划的代价要低。
贪心算法的套路是:(1)设计原问题的一个递归解;(2)证明在递归的任一步骤下,贪心选择总是最优选择之一;(3)证明通过该贪心选择,所有子问题仅一个非空。
《算法导论》给出的“活动选择问题”。有n个活动,每个活动都有起止时间(这里假设n个活动已按终止时间升序排序)。我们期望在一定时间里安排尽可能多的活动。
这个明显可以用动态规划解决。这里只说用贪心算法如何求解。
首先设计递归解:用表示在到的时间里的最优安排,并设是在该时间段内可以完成的活动。那么有:
贪心选择很直接:找出当前时间段内可以做、并且结束时间最早的活动。
我们需要证明:通过这种选择,一定可以构造出一个最优解;换句话说,某个最优解里一定包含,注意当考察的是任意步骤,因此所有步骤的贪心选择拼到一起就是一个最优解。
假设某个最优解不包含活动,并假设最优解的诸活动中,最早结束的是。那么很显然,也是可行的活动安排,并且与先前最优解的活动数量相等。因此说,一定存在某个最优解,其中包含。
再证该贪心选择下,仅有一个子问题非空。递归解的表达式右边含两个子问题,但实际上,因为被筛选出的是最早结束的那个,所以这个子问题必定是空集,所以只需考虑即可。
以上。证明贪心选择的正确性的方法可以推广开来:考察一个最优解,对其做微动,引入贪心选择将其变为一个相似的、更小的最优解。
此外,贪心选择后仅剩余一个子问题,这正是它相对于动态规划的优越性的一方面。后者往往面临两个或者更多的子问题。
至此可以理解“自顶向下采用最优子结构”的含义了:为求解,先挑出,再求解。被考察的子问题是越来越小的,所以称为“自顶向下”。而动态规划里被考察的子问题越来越大。
3、红黑树
这是一种保证在最坏情况下,基本操作(查找、插入、删除、找前驱后继等)时间复杂度仍为的二叉查找树。
红黑树的基本性质:(1)每个节点或者红、或者黑,根节点必须黑;(2)红节点的两个子节点都为黑;(3)对任意节点,从它到它所有可达叶子节点的路径上,黑节点数量相等。
正是这些基本性质,保证了红黑树的高度为;又因为它仅是一种特殊的二叉查找树,因此基本操作的时间复杂度为。
证明如下:设bh(x)是以x为根的子树的“黑高度”(从x到叶子的路径上黑节点个数,不包括x自身),则该子树至少包含个内节点(由数学归纳法易证);子树的高度记作h,而性质(2)保证了根到叶子的路径上黑节点至少占半数,因此;所以有,即。
在插入删除等操作时,红黑性质很容易被破坏。这时就需要“旋转”操作来进一步补救被破坏的红黑平衡。
插入节点的过程,就是在普通二叉查找树的插入后做一步fixup操作,使得新树依旧满足红黑树的基本性质。
FIXUP()中又分为6种情况。如下:
有关这里的说明:
(1)循环体内z的祖父一定存在,因为仅p[z]为红才执行循环,而根节点为黑,所以p[z]不是根,所以z的祖父存在;
(2)红黑性质仅可能在z和p[z]处被破坏。所有可能被破坏的红黑性质有两个:①新插入的z是根(注意此时p[z]是黑色的NULL哨兵),违反"根节点必须黑";②z和p[z]均红,违反"红节点的两个孩子必须黑"。
(3)z指针始终指向红色节点,因此若p[z]为黑就可以退出循环,再处理"根节点为黑"这一条就万事大吉。
"case 1"如下图所示。其中z是p[z]的左孩子或右孩子无所谓,重点是z的父亲p[z]及叔父y都是红色。既然z的祖父是黑的(那儿并未被破坏,所以由红黑性质,p[p[z]]一定是黑),那可以将p[z]和y置黑、将p[p[z]]置红;这种一则"红节点两个孩子均黑"被保证、二则"黑高度"不变;但是p[p[z]]置红可能引起更高层混乱,所以z←p[p[z]]继续迭代。
"case 2"如下图左边所示。它与"case 3"唯一区别在于z是p[z]的左孩子OR右孩子,因此做左旋(并不影响αβγ子树的黑高度等性质),转化为"case 3"再统一处理。注意z指向的节点有变。
"case 3"如上图右边所示。叔父y(δ的根)一定是黑色,因为否则就执行"case 1"去了。这里改变p[z]、p[p[z]]的颜色并做右旋。从图中可以看到αβγδ四棵子树的黑高度均未变;四棵树均有黑根(αβγ处不违反红黑性质且父节点红因此有黑根)所以节点A、C符合红黑性质;z的父亲已是黑色。此前做过说明,p[z]为黑即可退出循环。
删除节点的过程如下。其中y是待删除节点(z的后继或自身)、x是用来顶替y位置的节点。
先统计下可能被违反的红黑性质:①y的红色孩子x成为新根;②x和p[y]均为红;③删除黑色y使得包含y的路径上,黑高度减1。
主要考察情况③,方法是将x视为有一层额外的黑色,即x为"红加黑"(实际是红)或"双重黑"(实际是黑),效果是包含x的路径上黑高度加1(对应"红+黑")或2(对应"双重黑"),以维持原有平衡。
FIXUP()反复迭代,直到两种情况下,此时"额外黑色"的问题被解决:一是x为根,这时毫无压力;二是x指向"红加黑",此时即使考虑额外黑色也只是将通过x的路径的黑高度加1,而x又为红,因此将其置黑即可。
FIXUP()共分8种情况,如下:
"case 1"会被转化为另3种情况,即转换后x的兄弟是黑节点。转换的方法是改变p[x]和w的颜色并对p[x]左旋,如图所示,所有红黑性质不变;注意x的新兄弟是此前w的左孩子,必定为黑(因为此前w是红),因此"case 1"成功化为另3种情况。
"case 2"与后两种情况不同点在于,w的两个孩子均黑;这样的好处是将w置红不违反"红节点两个孩子均黑"的要求,仅是减少了黑高度。因为x"双重黑"而w黑,可以将这层额外黑色沿树往上移,即p[x]变为"红加黑"或"双重黑"。
"case 3"会被转化为"case 4"。通过修改w及其左孩子的颜色并做右旋,可以在不违反红黑性质的情况下变为"case 4"。
"case 4"可以略加改动使得x指向"红加黑"节点,从而简单的将x置黑便可消去额外的黑色,退出循环。具体的修改颜色及旋转见图。可以看到右图的x置黑(不再是"双重黑")后,所有子树、节点的黑高度均不变,且红黑性质得以满足。
4、二项堆
先描述二项树:①共有个节点;②树的深度为k;③在深度i处刚好有个节点。如下图所示:
另外可以看作由两个组合而来。
二项堆是由一拨按照度数严格递增(同一度数即k值最多有一棵树)的、最小堆有序的二项树组成。
其中最小堆有序是指二项树里,任意节点的值都大于等于其父节点的值,因此整棵树的最小值是树根。
上图是一个含13个节点的二项堆。注意刚好对应图中的、和。
这样便很好说明,若堆H含有n个节点,则它最多含棵树。因为n的二进制表示有位。
此外在编程实现时,每棵树可以存储“左孩子、右兄弟、父节点”;即存储最左边的孩子,以及紧邻着的右边兄弟,并存储父节点。这是为了适应二项堆的操作,例如UNION时连接一棵树为新的最左孩子,用这种存储方法很容易编程。
合并两个二项堆的操作:
以上是整体逻辑。
首先将两个二项堆简单的合并,注意要按度不递减的顺序,即左边的树的度一定小于等于右边的树的度;这样的堆可能在同一个度上有不止一棵树,后续处理就是为了解决这一问题。
"case 1"时,可以简单的将x右移,不解释。
"case 2"则是连续三棵树的度一样,并且x指向第一棵。为了符合二项堆的性质,应将后两棵树合并起来;不过这里就是简单的将x右移便进入下一轮迭代;将后两棵树合并的操作将在下一轮迭代中,由"case 3"或"case 4"完成。下图便是一个示例。
在最初的MERGE()之后,新堆中同一度数k最多有两棵树;这里形成三棵树,是因为之前有两棵树做了“组合”多形成了一个。
"case 3"和"case 4"的场景都是:x与其后继的度相等,且需要组合二者("case 2"也有此相等关系却不该连接);区别在于x和next[x]值的相对大小,使得组合顺序不同。
二项堆还有几个基本操作,都很简单。因此只做列举,不详细说明了:
(1)创建新堆;
(2)插入一个节点(新建一个单节点堆,调用UNION()函数);
(3)寻找最小值节点(只需查找诸子树的根节点即可);
(4)删除最小值节点(删除该节点为根的子树;注意在丢掉树根后变成多棵树,将其组成一个堆;将与做UNION即可);
(5)减小节点的值(在二项树中沿着父节点向上遍历,直到不违反"节点值大于等于父节点值"即可);
(6)删除一个节点(通过(5)将该节点赋值为﹣∞,再通过(4)删除即可)。
二项堆最大优势就在于UNION操作是复杂度,否则直接采用二叉堆即可。注意无论是二叉堆还是二项堆,“查找”操作的时间效率都很低。
5、最大流算法
含一个源点和一个汇点的有向图,每条边有最大承载容量。要求计算从源点到汇点的最大流量及相应的路径。
首先定义“残留图”、“增广路径”和“割”。
残留图是指,原始图G每条边扣除一定的流量(因为路径f消耗了一部分流量)后得到的图。若初始时图中A点到B点最大承载容量,当前已现的路径path经过边AB并且流量为;则残留图中A点到B点容量记作,并建一条B点到A点的有向边,容量相应记作。
增广路径是指残留图中一条从源点出发到达汇点的路径。
图的割(S,T)将顶点划分为两个集合,其中且。割(S,T)的最大承载容量记作c(S,T);流过它的流量记作f(S,T)。
割的两个性质:
(1)流过任意割的净流量相等。直观上来看,除了源点汇点,其余任意顶点的流入等于流出;真正起作用的仅源点汇点而已,所以具体怎样切割都毫无压力。
(2)任意流的流量小于等于任意割的最大承载容量。不解释。
Ford-Fulkerson方法:初始流设为空;每次迭代从残留图中找出一条增广路径,添加到总流里,重复这个过程直到不存在增广路径;这时的总流就是最大流,其流量就是从源点到汇点的最大流量。
算法的证明如下:
①f是图G的一个最大流;
②残留图不包含增广路径;
③对图G的某个割,有。
我们试图证明即可。
不解释;
,构造这样一个割:S为残留图中源点可达的顶点集合、T为V-S;②保证了T不为空集且汇点在T中。此时,对和都有c(s,t)=f(s,t)(否则中存在s到t的通路,t应当属于S集合,矛盾)。因此对这个割有。
不解释。
这样,由便证明了Ford-Fulkerson方法的正确性。
Ford-Fulkerson方法在编程实现时,主要问题在于,如何从残留图中找出源点到汇点的一条路径。采用递归实现,简要说明一下:
(1)该函数如何处理下一级别的递归。如下:
(2)递归出口,若当前顶点的某个直达点就是汇点,那就是递归出口了。
狗血的是,这个递归可能出现死循环。
如图,源点是node-0、汇点是node-5。源点可直达顶点2,因此询问顶点2"是否能到达汇点";顶点2再去询问顶点4。顶点4自然也是询问自己能直达的顶点,因此它会向2发出同样的询问,这样便在node-2和node-4间形成死锁。
解决办法也很简单,阻止节点往回试探。例如这里不允许节点4回头询问节点2,因为这个询问不可能有结果,若节点2知道,它就不会来问你了。
需要提及的是,直接实现的Ford-Fulkerson方法可能是低效的。例如下图中极端情况下可能会迭代太多次。
改进的出发点就在于,每次都寻找一个很牛X的增广路径(流量大、步数少等),以避免极端情况下迭代次数过多的情况。其中一个具体实现为Edmonds-Karp算法,它采用广度优先搜索来寻找源点到汇点的增广路径,时间复杂度为。
最后,最大流方法可以用来寻找“最大二分匹配”。将二分图左侧虚构一个源点并与左侧诸点连接(有向边),右侧虚构一个汇点并与右侧诸点连接,这样便构成一个有向图。找出该有向图的最大流,则最大二分匹配立即可得。