LIS问题
在本题中利用数学归纳可以进行如下构建:
dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。
根据这个定义,我们就可以推出 base case:
dp[i] 初始值为 1,因为以 nums[i] 结尾的最长递增子序列起码要包含它自己。
在这个过程中,我们应该怎么设计算法逻辑来正确计算每个 dp[i] 呢?
假设我们已经知道了 dp[0..4] 的所有结果,我们可以通过这些已知结果推出 dp[5]
根据刚才我们对 dp 数组的定义,现在想求 dp[5] 的值,也就是想求以 nums[5] 为结尾的最长递增子序列。
nums[5] = 3,既然是递增子序列,我们只要找到前面那些结尾比 3 小的子序列,然后把 3 接到最后,就可以形成一个新的递增子序列,而且这个新的子序列长度加一 。
显然,可能形成很多种新的子序列,但是我们只选择最长的那一个,把最长子序列的长度作为 dp[5] 的值即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public int lengthOfLIS (int [] nums) { int [] dp = new int [nums.length]; Arrays.fill(dp, 1 ); for (int i = 0 ; i < nums.length; i++) { for (int j = 0 ; j < i; j++) { if (nums[i] > nums[j]) dp[i] = Math.max(dp[i], dp[j] + 1 ); } } int res = 0 ; for (int i = 0 ; i < dp.length; i++) { res = Math.max(res, dp[i]); } return res; }
总结一下如何找到动态规划的状态转移关系:
1、明确 dp 数组的定义。这一步对于任何动态规划问题都很重要,如果不得当或者不够清晰,会阻碍之后的步骤。
2、根据 dp 数组的定义,运用数学归纳法的思想,假设 dp[0...i-1] 都已知,想办法求出 dp[i],一旦这一步完成,整个题目基本就解决了。
但如果无法完成这一步,很可能就是 dp 数组的定义不够恰当,需要重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组。
二维递增子序列
这道题目其实是最长递增子序列(Longes Increasing Subsequence,简写为 LIS)的一个变种,因为很显然,每次合法的嵌套是大的套小的,相当于找一个最长递增的子序列,其长度就是最多能嵌套的信封个数。
但是难点在于,标准的 LIS 算法只能在数组中寻找最长子序列,而我们的信封是由 (w, h) 这样的二维数对形式表示的。
这道题的解法是比较巧妙的:
先对宽度 w 进行升序排序,如果遇到 w 相同的情况,则按照高度 h 降序排序。之后把所有的 h 作为一个数组,在这个数组上计算 LIS 的长度就是答案。
然后在 h 上寻找最长递增子序列:
这个子序列就是最优的嵌套方案。
这个解法的关键在于,对于宽度 w 相同的数对,要对其高度 h 进行降序排序。因为两个宽度相同的信封不能相互包含的,逆序排序保证在 w 相同的数对中最多只选取一个。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 public int maxEnvelopes (int [][] envelopes) { int n = envelopes.length; Arrays.sort(envelopes, new Comparator<int []>() { public int compare (int [] a, int [] b) { return a[0 ] == b[0 ] ? b[1 ] - a[1 ] : a[0 ] - b[0 ]; } }); int [] height = new int [n]; for (int i = 0 ; i < n; i++) height[i] = envelopes[i][1 ]; return lengthOfLIS(height); } public int lengthOfLIS (int [] nums) { int piles = 0 , n = nums.length; int [] top = new int [n]; for (int i = 0 ; i < n; i++) { int poker = nums[i]; int left = 0 , right = piles; while (left < right) { int mid = (left + right) / 2 ; if (top[mid] >= poker) right = mid; else left = mid + 1 ; } if (left == piles) piles++; top[left] = poker; } return piles; }
最大子序和
定义 dp 数组的含义:
以 nums[i] 为结尾的「最大子数组和」为 dp[i] 。
使用数学归纳法来找状态转移关系,dp[i] 有两种「选择」,要么与前面的相邻子数组连接,形成一个和更大的子数组;要么不与前面的子数组连接,自成一派,自己作为一个子数组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int maxSubArray (int [] nums) { int n = nums.length; if (n == 0 ) return 0 ; int [] dp = new int [n]; dp[0 ] = nums[0 ]; for (int i = 1 ; i < n; i++) { dp[i] = Math.max(nums[i], nums[i] + dp[i - 1 ]); } int res = Integer.MIN_VALUE; for (int i = 0 ; i < n; i++) { res = Math.max(res, dp[i]); } return res; }
注意到 dp[i] 仅仅和 dp[i-1] 的状态有关 ,那么我们可以进行「状态压缩」,将空间复杂度降低:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int maxSubArray (int [] nums) { int n = nums.length; if (n == 0 ) return 0 ; int dp_0 = nums[0 ]; int dp_1 = 0 , res = dp_0; for (int i = 1 ; i < n; i++) { dp_1 = Math.max(nums[i], nums[i] + dp_0); dp_0 = dp_1; res = Math.max(res, dp_1); } return res; }
最长公共子序列
它的解法是典型的二维动态规划,大部分字符串问题都和这个问题是一个思路。
LCS问题就是要我们求两个字符串的LCS长度。
比如说输入 s1 = "zabcde", s2 = "acez",它俩的最长公共子序列是 lcs = "ace",长度为 3,所以算法返回 3。
一个最简单的暴力算法就是,把 s1 和 s2 的所有子序列都穷举出来,然后看看有没有公共的,然后在所有公共子序列里面再寻找一个长度最大的。
正确的思路是不要考虑整个字符串,而是细化到 s1 和 s2 的每个字符。
对于字符串s 1 和s 2 ,一般可以构造一个这样的数组:
d p [ i ] [ j ] ⇒ 对于 s 1 [ 0.. i − 1 ] 和 s 2 [ 0.. j − 1 ] ,他们的LCS长度是d p [ i ] [ j ] 。
让索引值为0 的行和列表示空串,d p [ 0 ] [ .. ] 和 d p [ .. ] [ 0 ] 都应该初始化为0。
再去寻找状态转移方程:
对于s 1 和s 2 中的每个字符,就是根据是否在l cs 中,如果某个字符在l cs 中,那么这个字符肯定同时存在于s 1 ,s 2 。
d p ( i , j ) = { d p ( i − 1. j − 1 ) + 1 , ma x ( d p ( i − 1 , j ) , d p ( i , j − 1 )) , if s1[i] = s 2 [ j ] if s1[i] = s 2 [ j ]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 def longestCommonSubsequence (str1, str2 ) -> int: def dp (i,j ): if i == -1 or j == -1 : return 0 if (str1[i] == str2[j]): return dp(i-1 ,j-1 ) + 1 else : return max (dp(i-1 ,j),dp(i,j-1 )) return dp(len (str1) - 1 , len (str2) - 1 )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int longestCommonSubsequence (string str1, string str2) { int m = str1.size(), n = str2.size(); vector <vector <int >> dp(m + 1 , vector <int >(n + 1 ,0 )); for (int i = 1 ;i <=m; i++) { for (int j = 1 ; j <= n; j++) { if (str1[i - 1 ] == str2[j - 1 ]) { dp[i][j] = dp[i - 1 ][j - 1 ] + 1 ; }else { dp[i][j] = max(dp[i][j - 1 ], dp[i - 1 ][j]); } } } return dp[m][n]; }
编辑距离
编辑距离问题就是给我们两个字符串 s1 和 s2,只能用三种操作,让我们把 s1 变成 s2,求最少的操作数。
解决两个字符串的动态规划问题,一般都是用两个指针 i,j 分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模 。
设两个字符串分别为 “rad” 和 “apple”,为了把 s1 变成 s2,算法会这样进行:
根据上面的 GIF,可以发现操作不只有三个,其实还有第四个操作,就是什么都不要做(skip)。比如这个情况:
因为这两个字符本来就相同,为了使编辑距离最小,显然不应该对它们有任何操作,直接往前移动 i,j 即可。
还有一个很容易处理的情况,就是 j 走完 s2 时,如果 i 还没走完 s1,那么只能用删除操作把 s1 缩短为 s2。比如这个情况:
类似的,如果 i 走完 s1 时 j 还没走完了 s2,那就只能用插入操作把 s2 剩下的字符全部插入 s1。
再整理一下之前的思路:
ba se c a se 是i 走完了s 1 或j 走完了s 2 ,可以直接返回另一个字符串剩下长度。
对于每个字符s 1 [ i ] 和s 2 [ j ] ,可以有4 种操作:
1 2 3 4 5 6 7 8 9 if s1[i] == s2[j]; i -= 1 j -= 1 else : 三选一: 插入(insert) 删除(delete) 替换(replace)
如果 s1[i]!=s2[j],就要对三个操作递归了,稍微需要点思考:
1 2 3 4 5 dp(i - 1 , j - 1 ) + 1
整理一下就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 def minDistance (s1, s2 ) -> int: def dp (i, j ): if i == -1 : return j + 1 if j == -1 : return i + 1 if s1[i] == s2[j]: return dp(i - 1 , j - 1 ) else : return min ( dp(i, j - 1 ) + 1 , dp(i - 1 , j) + 1 , dp(i - 1 , j - 1 ) + 1 ) return dp(len (s1) - 1 , len (s2) - 1 )
如果使用d p t ab l e 记录中间信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 int minDistance (String s1, String s2) { int m = s1.length(), n = s2.length(); int [][] dp = new int [m + 1 ][n + 1 ]; for (int i = 1 ; i <= m; i++) dp[i][0 ] = i; for (int j = 1 ; j <= n; j++) dp[0 ][j] = j; for (int i = 1 ; i <= m; i++) for (int j = 1 ; j <= n; j++) if (s1.charAt(i-1 ) == s2.charAt(j-1 )) dp[i][j] = dp[i - 1 ][j - 1 ]; else dp[i][j] = min( dp[i - 1 ][j] + 1 , dp[i][j - 1 ] + 1 , dp[i-1 ][j-1 ] + 1 ); return dp[m][n]; } int min (int a, int b, int c) { return Math.min(a, Math.min(b, c)); }
最长回文子序列
子序列问题往往比起字串和子数组又更难,因为不要求连续!!!
在涉及两个字符串、数组是,d p 数组含义如下:
在子数组a r r 1 [ 0.. i ] 和子数组a r r 2 [ 0.. j ] 中,要求的子序列长度为d p [ i ] [ j ] 。
在只涉及一个字符串、数组时,d p 数组的含义如下:
在子数组a r r [ i .. j ] 中,要求的子序列长度为d p [ i ] [ j ] 。
最长回文子序列的题目非常好理解:
找出字符串s 中最长的回文子序列长度。
故定义d p [ i , j ] 为在字串s [ i ... j ] 中的最长回文字序列长度
此时再考虑如何从已知到未知,假设已知d p [ i + 1 , j − 1 ] ,要求处d p [ i , j ] 就只需要比较s [ i ] , s [ j ] 的值即可,即比较两端点处的字符是否相等。
若不等,则需要设置其为ma x ( d p [ i + 1 , j ] , d p [ i , j − 1 ]) ,即表示将s [ i ] 或s [ j ] 加入序列是否会更长。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int longestPalindromeSubseq (string s) { int n = s.size(); vector <vector <int >> dp(n, vector <int >(n, 0 )); for (int i = 0 ; i < n; i++) { dp[i][i] = 1 ; } for (int i = n - 2 ; i >= 0 ; i--) { for (int j = i + 1 ; j < n; j++) { if (s[i] == s[j]){ dp[i][j] = dp[i+1 ][j-1 ]+2 ; }else { dp[i][j] = max(dp[i+1 ][j],dp[i][j-1 ]); } } } return dp[0 ][n-1 ]; }