Dynamic Programming(动态规划)
一、定义
动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。
二、方法论
动态规划的核心是状态及状态转移方程
y = d p ( x 1 , x 2 , x 3 , . . . x n ) y = dp(x1,x2,x3,...x_n) y=dp(x1,x2,x3,...xn)
d p ( x 1 , x 2 , x 3 , . . . , x n ) = k d p ( x 1 1 , x 1 2 , x 1 3 , . . x 1 n ) . . . dp(x_1,x_2,x_3,...,x_n) = kdp(x_11,x_12,x_13,..x_1n)... dp(x1,x2,x3,...,xn)=kdp(x11,x12,x13,..x1n)...
确定状态转移方程需要明确
(1)自变量
确定决定每种状态的变量有几个
例如坐标中的x,y等
(2)因变量的意义
即 dp(x1,x2,x3,…x_n)的含义
(3)状态如何变化
即当前状态与其他状态之间的关系
具体如何实施和把握,我们通过下面的题目来探索
三、题解
在这里借用leetcode上关于动态规划的几个题,谈一些自己的见解。在这里和大家一起share。
按照我自己的理解,我循序渐进得通过几个例子谈一下动态规划
Problem One
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
这是一个简单且经典的动态规划(小学生都会做)问题。
根据上述方法论,
(1)(自变量)
我们可以假定(i,j)代表第i行,第j列
(2)(因变量)
dp[i][j]表示Start到(i,j)的路径数量
注意:(1)(2)的合理选取至关重要,可能影响整个算法的复杂度以及状态转移确定的难度
(3)(状态转移)
dp[i][j]如何由前序状态决定?
分析具体问题发现
d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + d p [ i ] [ j − 1 ] dp[i][j] = dp[i - 1][j] + dp[i][j - 1] dp[i][j]=dp[i−1][j]+dp[i][j−1]
在此之前,我们还必须明确
初始化状态:
dp[0][0] = 1 dp[0][i] = 1 dp[i][0] = 1
变量细节控制
i - 1 > 0
j - 1 > 0
完成上述步骤,我们可以轻而易举写出这道题的代码
public int uniquePaths(int m, int n) {
//state equation
int[][] dp = new int[m][n];
//init
dp[0][0] = 1;
for (int i = 1; i < n; i++) {
dp[0][i] = dp[0][i - 1];
}
for (int j = 1; j < m; j++) {
dp[j][0] = dp[j - 1][0];
}
//transfer
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
Problem Two
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
这是Problem One的一个小变形,大家可以自行练习
只需要简单修改状态转移即可。
即对存在(i,j)位置存在障碍物时,修改dp[i][j] = 0
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
//state equation
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
int[][] dp = new int[m][n];
//init
if(obstacleGrid[0][0] == 1) {
//障碍物
return 0;
} else {
dp[0][0] = 1;
}
for (int i = 1; i < n; i++) {
if (obstacleGrid[0][i] == 1) {
//障碍物
dp[0][i] = 0;
} else {
dp[0][i] = dp[0][i - 1];
}
}
for (int j = 1; j < m; j++) {
if (obstacleGrid[j][0] == 0) {
//障碍物
dp[j][0] = dp[j - 1][0];
} else {
dp[j][0] = 0;
}
}
//transfer
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (obstacleGrid[i][j] == 1) {
//障碍物
dp[i][j] = 0;
} else {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[m - 1][n - 1];
}
前面的题目当然都只是小试牛刀,现在加点难度
Problem Three
给定一个整数数组 nums
,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
我们依然严格执行我们的方法论
(1)自变量是什么?
不妨令(i,j)为从nums的第i个位置到第j个位置的子序列和
(2)因变量是什么?
子序列和
(3)状态怎么转移?
d p [ i ] [ j ] = d p [ i ] [ j − 1 ] + n u m s [ j ] dp[i][j] = dp[i][j - 1] + nums[j] dp[i][j]=dp[i][j−1]+nums[j]
初始化状态
dp[0][0] = nums[0];
dp[i][i] = nums[i]
看到这,so easy!!!!!
但是不妨反思一下,这种做法的复杂度是**O(n^2)**吧
这个方法虽然simple但会不会复杂度太高了呢??
显然如果你在leetcode上使用这个复杂度的代码提交,TLE就会来到你身边
因此,我们需要注意:动态规划最大限度降低复杂的有效方法是
减少自变量数目
思考过后…
发现,其实自变量一个就够了
如果利用逐步求解,以连续数组结束位置i为每一步的解,dp[i]记录了以此位置作为子序列结束位置的最大和。
此时,我们需要的子序列一定是dp中最大的一个。
事情就变得简单了。
d p [ i ] = m a x { d p [ i − 1 ] + n u m s [ i ] , n u m s [ i ] } dp[i] = max\{dp[i - 1] + nums[i], nums[i]\} dp[i]=max{
dp[i−1]+nums[i],nums[i]}
因此,
public int maxSubArray(int[] nums) {
//state euqation
int[] dp = new int[nums.length];
//init
dp[0] = nums[0];
int maxNum = dp[0];
//transfer
for (int i = 1; i < nums.length; i++) {
dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
if (dp[i] > maxNum) {
maxNum = dp[i];
}
}
return maxNum;
}
挑战才刚刚开始,让我们增加一些状态转移方程的难度来感受一下
Problem Four
给定一个字符串 s
,找到 s
中最长的回文子串。你可以假设 s
的最大长度为 1000。
这是一个相当经典的问题,当然解法也有很多种。无论是中心扩散的方法还是Manacher方法思考的难度和实现的难度都不小。不妨来试试动态规划,来简单得解决这个问题。
考虑 “ababa”这个例子。如果我们已经知道 “bab”是回文,那么“ababa”一定是回文,因为它的左首字母和右尾字母是相同的。
因此,我们给出状态转移转移如下:
d p [ i ] [ j ] = t r u e , 如 果 s i , s i + 1 , . . . , s j 是 回 文 串 dp[i][j] = true, 如果s_i,s_{i+1},...,s_j是回文串 dp[i][j]=true,如果si,si+1,...,sj是回文串
于是:
状态转移方程为
d p [ i ] [ j ] = d p [ i + 1 ] [ j − 1 ] a n d s [ i ] = = s [ j ] dp[i][j] = dp[i + 1][j - 1] \space and\space s[i] == s[j] dp[i][j]=dp[i+1][j−1] and s[i]==s[j]
public class Solution {
public String longestPalindrome(String s) {
int len = s.length();
if (len <= 1) {
return s;
}
int longestPalindrome = 1;
String longestPalindromeStr = s.substring(0, 1);
boolean[][] dp = new boolean[len][len];
// abcdedcba
// l r
// 如果 dp[l, r] = true 那么 dp[l + 1, r - 1] 也一定为 true
// 关键在这里:[l + 1, r - 1] 一定至少有 2 个元素才有判断的必要
// 因为如果 [l + 1, r - 1] 只有一个元素,不用判断,一定是回文串
// 如果 [l + 1, r - 1] 表示的区间为空,不用判断,也一定是回文串
// [l + 1, r - 1] 一定至少有 2 个元素 等价于 l + 1 < r - 1,即 r - l > 2
// 写代码的时候这样写:如果 [l + 1, r - 1] 的元素小于等于 1 个,即 r - l <= 2 ,就不用做判断了
// 因为只有 1 个字符的情况在最开始做了判断
// 左边界一定要比右边界小,因此右边界从 1 开始
for (int r = 1; r < len; r++) {
for (int l = 0; l < r; l++) {
// 区间应该慢慢放大
// 状态转移方程:如果头尾字符相等并且中间也是回文
// 在头尾字符相等的前提下,如果收缩以后不构成区间(最多只有 1 个元素),直接返回 True 即可
// 否则要继续看收缩以后的区间的回文性
// 重点理解 or 的短路性质在这里的作用
if (s.charAt(l) == s.charAt(r) && (r - l <= 2 || dp[l + 1][r - 1])) {
dp[l][r] = true;
if (r - l + 1 > longestPalindrome) {
longestPalindrome = r - l + 1;
longestPalindromeStr = s.substring(l, r + 1);
}
}
}
}
return longestPalindromeStr;
}
}
Problem Five
给定一个只包含 '('
和 ')'
的字符串,找出最长的包含有效括号的子串的长度。
这个问题第一眼看到,学过DS的人一定会想到栈,的确利用栈结构可以把本题轻而易举解答,并且把复杂度控制在O(n),然而动态规划,也很好用。
学习到这里,相信大家应该需要把握的就剩下状态转移的确定(接下来重点讲解)以及细节的初始化(这个可以由测试样例覆盖性测试等去解决)。
因此,我们从这里开始将步骤简化为三部分:
(1)建模
我们定义一个 dp数组,其中第 i 个元素表示以下标为 i 的字符结尾的最长有效子字符串的长度。
(2)初始化
dp[0] = 0;
(3)状态转移
( 1 ) d p [ i ] = 0 , i f s [ i ] = = ′ ( ′ ( 2 ) d p [ i ] = d p [ i − 2 ] + 2 , i f s [ i ] = = ′ ) ′ a n d s [ i − 1 ] = ′ ( ′ (1)\space dp[i] = 0, \space if \space s[i] == '('\\(2)\space dp[i] = dp[i - 2] + 2, \space if\space s[i] == ')' \space and\space s[i - 1] = '(' (1) dp[i]=0, if s[i]==′(′(2) dp[i]=dp[i−2]+2, if s[i]==′)′ and s[i−1]=′(′
( 3 ) d p [ i ] = d p [ i − 1 ] + d p [ i − d p [ i − 1 ] − 2 ] + 2 , i f s [ i ] = ′ ) ′ a n d s [ i − 1 ] = ′ ) ′ a n d s [ i − d p [ i − 1 ] − 1 ] = ′ ( ′ (3)\space dp[i]=dp[i−1]+dp[i−dp[i−1]−2]+2,\space\\ if \space s[i]=')' \space and \space s[i−1]=')'\space and \space s[i−dp[i−1]−1]='(' (3) dp[i]=dp[i−1]+dp[i−dp[i−1]−2]+2, if s[i]=′)′ and s[i−1]=′)′ and s[i−dp[i−1]−1]=′(′
class Solution {
public int longestValidParentheses(String s) {
if (s.length() == 0) {
return 0;
}
int[] dp = new int[s.length()];
int maxLen = 0;
dp[0] = 0;
for (int i = 1; i < s.length(); i++) {
if (s.charAt(i) == '(') {
dp[i] = 0;
} else if (s.charAt(i) == ')' && s.charAt(i - 1) == '(') {
if (i - 2 >= 0) {
dp[i] = dp[i - 2] + 2;
} else {
dp[i] = 2;
}
} else if (s.charAt(i) == ')' && s.charAt(i - 1) == ')') {
if (i - dp[i - 1] - 1 >=0 && s.charAt(i - dp[i - 1] - 1) == '(') {
if (i - dp[i - 1] - 2 >= 0) {
dp[i] = dp[i - dp[i - 1] - 2] + dp[i - 1] + 2;
} else {
dp[i] = dp[i - 1] + 2;
}
} else {
dp[i] = 0;
}
} else {
dp[i] = 0;
}
if (dp[i] > maxLen) {
maxLen = dp[i];
}
}
//System.out.println(maxLen);
return maxLen;
}
}
未完待续,更困难的问题即将推出!!!!!
预告:正则匹配与动态规划