动态规划之——最长递增子序列
最长递增子序列(Longest Increasing Subsequence)是指在给定的一组数字中,按照从左向右顺序,由递增的数字组成的子序列(中间可以有间隔)中,取长度最大的子序列即为最长递增子序列。
如给定“1 2 4 3”,则递增的子序列有“1 2”、“1 4”、“1 3”、“2 4”、“2 3”、“1 2 4”、“1 2 3”,则最长的为“1 2 4”和“1 2 3”,长度为3。
如何求这个最长递增子序列呢?
由于是递增序列,数字往右是不断变大的,左侧的数必然小于右侧数,即最右侧的数字是最大的。
我们先分析个简单的例子“1 2 4”:
4左侧为“1 2”,都小于4,如果不考虑1 2之间的大小关系(即无论组合是1 4、2 4还是1 2 4),则4左侧至少有一个数字可以组成递增子序列,最长递增子序列至少为2(加上4自身长度1)。即只要左侧存在小于4的数字,就会导致递增子序列的长度增加。
我们再往回看数字2,位于整个序列的第二位,因此它只需要对比一次,2大于1,所以截止到“2”这个数字,最长递增子序列长度为2,且这个长度是固定的,即后续数字无论如何组合,也不会影响2和之前数字1构成的最长递增子序列(子问题的最优解)。
也就说明后续如果有数字大于2,可以参考这个最长递增子序列。
这时我们再返回到4,4大于2,因此可以直接取左侧小于4的数字对应的最长递增子序列,再加1后就是当前的最长递增子序列,如图-1,顶部的红色数字表示对应序列中数字的最长递增子序列的长度。
图-1
但还有可能是,左侧数字小于4的可能有多个,距离4最近的不一定就是最长的,因此需要挨个对比这些数字的最大递增子序列,取一个最大值。如图-2中的演示数据,这里显然要取4左侧的“3”对应的最长子序列长度,而不是“1”的对应值。
图-2
因此我们总结出如下步骤:
1 从左侧开始处理数字,且每个数字对应的默认最长子序列都是1。
2 从第二个数字a开始,在左侧所有小于a的数字的最长子序列长度中,取最大的长度+1,即为当前最长递增子序列的长度。
3 后续数字参考步骤2依次处理。
我们先用递归的方式来实现,代码如下。对应演示数据为“5, 2, 8, 6, 3, 6, 9, 5”
1 import java.util.Arrays; 2 3 public class LongestIncreasingSubsequence { 4 public static void main(String[] args) { 5 int[] numbers = {5, 2, 8, 6, 3, 6, 9, 5}; 6 System.out.println(Arrays.toString(numbers)); 7 recursive(numbers); 8 } 9 10 public static void recursive(int[] numbers) { 11 //从左到右依次计算截止到每个数字的最长递增子序列的长度 12 for (int i = 0; i < numbers.length; i++) { 13 System.out.printf("index=%d num=%d max=%d\n", i, numbers[i], cmp(numbers, i)); 14 } 15 } 16 17 private static int cmp(int[] numbers, int k) { 18 //数据中第一个位置的最长子序列是1 19 if (k == 0) { 20 return 1; 21 } 22 //小于当前数字(基准数字)的列表中最大的子序列长度,默认长度为1 23 int max = 1; 24 //从左侧第一个位置开始依次和基准数字对比 25 for (int i = 0; i < k; i++) { 26 //小于基准数字,说明可以组成递增子序列 27 if (numbers[i] < numbers[k]) { 28 //取截至到k对应数字的最大子序列长度。反应到当前基准数字的列长度需要返回结果+1 29 int length = cmp(numbers, i) + 1; 30 //取所有小于基准数字中最大的那一个数 31 if (length > max) { 32 max = length; 33 } 34 } 35 } 36 return max; 37 } 38 }
输出
[5, 2, 8, 6, 3, 6, 9, 5] index=0 num=5 max=1 index=1 num=2 max=1 index=2 num=8 max=2 index=3 num=6 max=2 index=4 num=3 max=2 index=5 num=6 max=3 index=6 num=9 max=4 index=7 num=5 max=3
可以看到第29行的 cmp(numbers, i) + 1,是通过递归获取依赖的最长递增子序列。而由于外层循环变量 i 每次都从0开始,就可能存在重复调用。因此我们可以参考动态规划方法做一些优化,代码如下。
1 public static void dp(int[] numbers) { 2 //保存每个数字的下标对应的最长递增子序列长度值 3 int[] dp = new int[numbers.length]; 4 //数据初始化:每个数字对应的默认序列长度都是1 5 for (int i = 0; i < dp.length; i++) { 6 dp[i] = 1; 7 } 8 System.out.println(Arrays.toString(dp)); 9 10 //由于第一个位置(index=0)不存在前驱数字,也就无需比较,直接从index=1开始即可。 11 for (int i = 1; i < numbers.length; i++) { 12 System.out.printf("index=%d num=%d\n", i, numbers[i]); 13 //当前数字和之前的依次比较 14 for (int j = 0; j < i; j++) { 15 //小于当前数字说明是一个递增序列,序列长度可以加1 16 if (numbers[i] > numbers[j]) { 17 System.out.printf("\t%d>%d dp[%d]=%d dp[%d]=%d\n", numbers[i], numbers[j], i, dp[i], j, dp[j]); 18 //但需要取最大的 19 if (dp[j] + 1 > dp[i]) { 20 dp[i] = dp[j] + 1; 21 } 22 } else { 23 System.out.printf("\t%d<=%d skip\n", numbers[i], numbers[j]); 24 } 25 } 26 System.out.printf("\tdp[%d]=%d %s\n", i, dp[i], Arrays.toString(dp)); 27 } 28 }
增加对应调用dp(numbers),输出如下
[dp] [1, 1, 1, 1, 1, 1, 1, 1] index=1 num=2 2<=5 skip dp[1]=1 [1, 1, 1, 1, 1, 1, 1, 1] index=2 num=8 8>5 dp[2]=1 dp[0]=1 8>2 dp[2]=2 dp[1]=1 dp[2]=2 [1, 1, 2, 1, 1, 1, 1, 1] index=3 num=6 6>5 dp[3]=1 dp[0]=1 6>2 dp[3]=2 dp[1]=1 6<=8 skip dp[3]=2 [1, 1, 2, 2, 1, 1, 1, 1] index=4 num=3 3<=5 skip 3>2 dp[4]=1 dp[1]=1 3<=8 skip 3<=6 skip dp[4]=2 [1, 1, 2, 2, 2, 1, 1, 1] index=5 num=6 6>5 dp[5]=1 dp[0]=1 6>2 dp[5]=2 dp[1]=1 6<=8 skip 6<=6 skip 6>3 dp[5]=2 dp[4]=2 dp[5]=3 [1, 1, 2, 2, 2, 3, 1, 1] index=6 num=9 9>5 dp[6]=1 dp[0]=1 9>2 dp[6]=2 dp[1]=1 9>8 dp[6]=2 dp[2]=2 9>6 dp[6]=3 dp[3]=2 9>3 dp[6]=3 dp[4]=2 9>6 dp[6]=3 dp[5]=3 dp[6]=4 [1, 1, 2, 2, 2, 3, 4, 1] index=7 num=5 5<=5 skip 5>2 dp[7]=1 dp[1]=1 5<=8 skip 5<=6 skip 5>3 dp[7]=2 dp[4]=2 5<=6 skip 5<=9 skip dp[7]=3 [1, 1, 2, 2, 2, 3, 4, 3]
最终各数字的依赖关系如图-3:
图-3
从图-3中可以看到,这些数字可以理解为数据结构“图”中的“顶点”,对应为一张有向无环图,每个顶点的前驱顶点都是固定的(由序列本身的顺序来决定),依赖关系由它前驱顶点(小于顶点值的数)决定。
参考资料