动态规划之最长递增子序列

  动态规划之最长的单调递增子序列

1. 请给出一个O(n2)时间的算法,使之能找出一个n个数的序列中最长的单调递增子序列.

  要找最长单调递增子序列,其实是一个动态规划的问题.假设原始序列为S1,我们先对其进行排
序,得到已排序的序列S2,然后找S1和S2的最长公共子序列T1,我们可以证明T1就是S1最长的单调
递增子序列.
  因为T1是S2的子序列,所以它是单调递增的.如果有另外的比T1长的S1的单调递增子序列T2,T2
也必定是S2的子序列,这与T1是S1和S2的最长公共子序列相矛盾.所以证得T1就是S1的最长单调
递增子序列.
  对S1排序是O(nlgn),对S1和S2求最长公共子序列是O(n2).整个算法也就是O(n2).

2. 请给出一个O(nlgn)时间的算法,使之能找出一个n个数的序列中最长的单调递增子序列.(提示:
观察长度为i的一个候选子序列的最后一个元素.它最少与长度为i-1的一个候选子序列的最后一个
元素一样大.通过把候选子序列与输入序列相连接来维护它们).

  英文的不知道怎么样,但中文的提示确实看不懂.我不明白候选子序列是什么,你也不能说一个
长度为i的单调递增子序列最后一个元素就一定大于等于长度为i-1的单调递增子序列的最后一个
元素.比如
  2 5 3 4
其长度为 2 的一个单调递增子序列为 2 5
其长度为 3 的一个单调递增子序列为 2 3 4
就没有 4 > 5.
  所以我不理解,你只能说在同一个序列中,它要从i-1的长度变为i的长度,要添在最后的元素一定
要大于等于原来的最后一个元素,或许这才是提示的本意.
  没必要纠缠于此.我们的中心是把算法复杂度从O(n2)提到O(nlgn).如果我们象匹配公共子序列
一样去填一张n*n的表,算法注定是O(n2).我们要充分开发其中一个序列已排序的特点.
  关于斜路.也可能不是,但至少自己走不通.关于这个问题,我有太多的遐想,但大多难以追溯.我
只能回忆大致的思考过程.
  这个问题需要O(n2),一个主要原因就在于我们不知道界限在哪.比如我从后向前推,开始的界限
无穷大,遇到Xn,我如果包含Xn做最后一个元素,序列前面可能就有元素无法包含,所以我要考虑界
限为Xn和无穷大两种情况,在n-1处我要考虑X(n-1),Xn和无穷大三种情况.如此下去,所有的情况
大概有n^2/2个,这是O(n2)的.事实上,我根本不需要考虑这么多情况.等到第一个元素时,我分了
X2,X3...Xn,无穷大共n个情况在它上面.实际上,第1元素只有1个值,最多两种包含情况.
  一个较为简单的考虑是剪枝.我从1遍历到n,每到一个节点i,我计算出X1,X2到Xi中最大的值,存
起来.等到我从后往前遍历的时候,就把它拿出来比较,比它大的统统归于1类.不要看这种做法简
单,在平均情况下可以砍掉一半的遍历.可这也不能解决问题,它还是O(n2)的,剪枝更多的是优化
而不是提升算法.它在本质上仍然是用动态规划实现的.

  当此时刻,我想到了分治.一种先把问题分解,解决,最后合并的算法.如果把一个序列Sa从中间
分开,变成Sb和Sc,先找到Sb和Sc的最长递增子序列,再把它们合并起来,如何?这里的关键问题是
如何合并.这两个最长递增子序列合起来不一定是最长的递增子序列.比如
  2 3 4 5 1 6 1 1
两边分开,其最长递增子序列分别是:
  2 3 4 5  1 1 1
合并了,只能抛弃一个,变成:
  2 3 4 5
但实际的最长递增子序列是:
  2 3 4 5 6
  其实分治的问题就在于此,一个子问题的解不一定是母问题想要的.想要满足需要,就要满足各种
苛刻的要求,起点最高,终点最低,可起点高的不一定是最长的.类似的限制,使得分解子问题变得
很难,我放弃了这样做.这里曾记到: 如果划分,就是枚举其中所有的递增序列,然后进行连接.这
样在向O(2^n)靠拢.
  我们意识到对问题的定义太复杂,子问题的解无法用,最优子结构定义混乱等问题.我们把问题
还原到最开始,单线条的模式.通过在序列后面每次加一个元素来扩充子问题,这样比合并来得简单
得多.比如,
  原来的序列长度为i-1,我们在后面加1个元素,长度变为i.i-1长度的序列就是长度为i的序列的
子问题.对于第n个元素来说,它只想知道一件事,就是我Xn能不能用到你的递增子序列上.对于第
n-1个元素来说,它只想知道X(n-1)能不能用到递增子序列上.我们在分析问题时,无论是从前向后,
还是从后向前,都是一样的.上面的告诉我们什么,子问题的扩充,到第i个元素是,我就看Xi能怎么
用,用不用得上.
  原来的动态规划是怎么用的呢?如果你看到那张n*n的表格,就会发现,其实对元素Xi,它对S2从
长度1到长度n的匹配都进行了计算.它又是从X1爬到Xn,算法当然是O(n2).我们之前想用分治法
跳的,可惜没跳成.我们现在就是想从1爬到n,只能指望对每个元素Xi,用O(lgn)的时间就解决.

  我想把子问题看成是一个一个增加的,但是子问题的解
必须被之后的问题用到.很显然一个最长单调递增序列是远远不够的,我要提供所有可能的序列.
到一个元素时,我提供最长单调递增子序列是毫无意义的,也许后面根本用不上,因为你太大了.
于是,我缩小了要求.每到一个元素Xi,我就要找以Xi为最后一个元素的最长递增序列.
  这是一个突破.你之后的不是不确定自己的范围吗?好,我就提供一个以自己为最后一个元素的
最长递增序列.到第i个元素时,它可以看到前面以自身元素结尾的i-1个序列,其中有些序列是另
一些序列的子序列,这正是一种截取.
  然后,我们设计一个表,每个元素进来时,就查看其中内容,并添加上自己的部分,以记录那个以
自己结尾的最长递增子序列.它的结构如下:
    最高点    长度     位置
  最高点是提供一种度量,表就是以此排序,每个元素都有对应的高度Xi,它进来后查看Xi下面的
子序列,从中挑出最长的,加上本身,组成高度在Xi的新的递增子序列.长度就是记录长度的,位置
是为了链接方便.我们接下来给出具体例子和算法.
 
  一个序列: 2 3 6 1 1 1 4 5
  元素一个个填充进表中,最终的表的内容如下:
    最高点    长度    位置

     6        3        3
     5        5        8
     4        4        7      
     3        2        2
     2        1        1
     1        3        6

  可以看到,如果不去找所有高度比自己小的序列中最长的,4绝不会找到111作为子序列.
  算法:
       find(S:sequence,i:number,T:table)
 {
    int h,maxlength=0;
    for(h=1;h<=S[i];h++){
      if(maxlength<T[h].length)
     maxlength=T[h].length;
    }
    T[S[i]].length=maxlength+1;
    T[S[i]].number=i;
 }
  算法描述的很简陋,有很多需要补充的地方.Xi也不一定就是邻近的整数,但这些可以通过排序
和添加辅助结构来解决.此算法用来生成这张表足够了.
  可惜,这样的算法仍然是O(n2)的.它仍然是一种动态规划,因为仍然用到了子问题的最优解,但
却与找最长公共子序列有很大区别.这个区别只因为一个问题的改变:我要找以Xi结尾的最长递增
子序列.
 
  之后,我做了很多努力,想把它提升到O(nlgn)去.比如,我不想这样被动地查所有低高度的长度,
只想查最近的.那我就需要主动去更新.比如2 3 6 1 1 1 4 5.我查到第1个1时,发现1个1的序列
和上面1个2的序列长度相同,但高度小了,我去更新它,变成 2 1 4.这种更新看似破坏了高度的
定义,但不影响之后的元素.而且只有破坏高度的定义,才可能把遍历变成只找最近的.等到第3
个1查到了,前面的高度为2,3的都变了,这样4一看3的,就知道它下面最长的递增子序列是111了.
最后的表如下:
      最高点       长度      位置
   
    6            3         3
    5            5         8
    4            4         7
    3            3         6
    2            3         6
    1            3         6
  这样的主动改变,使得表中的每一项,都变成了此高度下的最长递增子序列.可实际上,这并不是
一种改进.被动查找和主动修改代价是相似的.都是O(n2).
  之后,还有两种方法被尝试.一种就是分治合并.我把子问题构造成一个表,再把两个表合并起
来.用O(n)中情况开比对各种拼合方案,这样正好是O(nlgn).但正如之前用分治法所遇到的问题,
上边下边两头需要堵.前一半是,不管你起点多低,你上边只能这么高;后一半是,不管你最后多高,
你起点必须高于这个值,不然怎么拼.所以需要两种表.这样说来,虽然复杂了些,但仍是有想法的.
但当时还未想到这许多,而且粗略看来其复杂度也不小,就留待有兴趣的去实践吧.
  再一种方法,是继续简化表的复杂度.我不构造一Xi结尾的最长递增序列,只构造最近的.比如
2 3 6 1 1 1 4 5
  到4时,我不去找 1 1 1 4,只是找到 2 3 4即截止.这样构造表只需要O(n).但这样的表也是破
损的,需要修补.怎么修,肯定是从高度低的往高的修,从位置前的往位置后的修,从长度长的往
长度短的修.这样的修补,我实际试过后,发现位置的不确定性.虽然你高度为1的序列长度为3,
比高度为2的长度1要大,但是你的位置靠后,都是6了.我的后继可能是比你位置靠前的,你不能
直接取代我.这个,并不是大问题,只要查看所有用到它的后继,只对位置比他靠后的修改.或者,
只找位置比他靠后的,查看其直接前缀,再修改.问题是,不是一轮修补就能解决问题,可能你高度
为1的修改完了,高度为2的还要再修改一轮.为什么?因为高度为2的可能位置比高度为1的靠前,
还有只有它能修改的.
  所以说,长度是比较条件,而位置和高度都影响了修改资格.你按高度修改上去,位置不同会导致
这个改了那个改;你按位置修改上去,高度不同会导致这个改了那个改.你拧不到一块.所以这个
修补时O(n2)的.
 
  到这时,我快绝望了.表之前有2种方法,表之后又3种方法.不是失败就是O(n2)的复杂度.这时
再回到表的本源.每到一项,我都要查看高度比它低的,找一个长度最长的.多简单的算法啊.
  这时,我想到了插入排序是怎样变成O(nlgn)的.那是借助了二分查找法.我可以证明它在没有
什么乱七八糟的缓存的情况下,是最坏情况下最快的算法.事实上,利用二分查找是之前想到的,
不过一直没用上.
  可没有排序,怎么二分查找.你不可能对所有n种最高高度都排一个序.但是,我可以建一个树,
它是一个二分树,叶子是各种高度.从左到右依次是高度1,2,3,4...n.节点,就是子树的最大长度.
事实上,它是(子树最大长度,最大长度所在高度),一个二元组.我更新这棵树,很快,从叶子一溜
上去O(lgn).查看呢?不一定了,比如查看高度为i下面的.如果i=2^k,你只需要查看一个节点即可.
也就是说,如果i=2^k1+2^k2+2^k3+...+2^km,那它就有查m个节点.但是不要怕,这m个节点都不在
同一高度,树高lgn,最多查看lgn个节点,而且这节点挨着的,一拐弯就到.所以查看也是O(lgn)的.
  如此说来,整个实现就是O(nlgn)的了.可能大家对于这个二分树还不是很清楚.实现查看的代码
也比较多,这里就只给出一个例子.
   2 3 6 1 1 1 4 5

  二分树(在n确定时就建立好的框架,后来只是查看和修改而已)
   节点的二元组定义(子树最大长度,最大长度所在高度[或者说叶子])
   
                  (5,5)
            /                 /
           /                   / 
         (4,4)               (5,5)
      /           /              /   /
     /             /         (5,5) (3,6)
   (3,1)         (4,4)
  /     /           /     /

(3,1) (1,2)  (2,3) (4,4)
      

  这个树的思想是二分,又只有具体的高度才能充当叶子,所以是2个2个不断累积起来的.
至于中间节点,都被用来加速查找了,哈.
  最后,这个问题得到了解决.不管这个树实际产生或者运用有多复杂,用链表还是数组去实现,
节点内容是否改变些,都无所谓,整个算法是O(nlgn)的.
  是什么造就了它呢?是顺序.开始的输入顺序决定了它位置是从前到后的,后来在高度表中决定
了它只关心高度比他低的(或等于的).位置和高度的顺序性决定了它能随意取用已有的子序列.
我们再利用树来加速.就水到渠成了.

  这大概是自己独立做出的第一道有深度的算法问题,更想到了表和树(虽然都是突发奇想).故
作文以记之.
                                                                      
 

 

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值