目录
一.知识储备
正如前面一篇博客所提到的那样,首先我们应知道什么是动态规划?动态规划问题的本质是什么?
实际上,dp问题的本质仍然在于穷举
,通过遍历和状态转移方程由底向上求出问题的最优解。
一般来说,dp问题有着三个最核心的要素:重叠子问题、最优子结构和状态转移方程
。只要满足这三个特征的问题基本上都可以归结为dp动态规划问题。
在实际的题目当中,动态规划一般用来求解最值
,做题步骤可分为三到四步:
1.特殊情况下的考虑。比如考虑是否为空等,这种情况一般是直接返回结果。
2.确定dp数组所代表的含义和大小,创建dp数组。
3.考虑边界条件,初始赋值dp数组。
4.遍历求得dp数组(最最关键的一步:需要求得状态转移方程)
代码实例
经典打家劫舍问题
思路很简单:题目的限制条件只有一个——相邻房屋不能被连续偷窃。那么我(小偷)面临一个房屋时一共就只有两种选择:偷或者不偷,而这取决于哪种决策能让利益最大化。
故可建立状态转移方程如下:
dp[i] = Math.max(dp[i - 2] + nums[i],dp[i - 1]);
class Solution {
public int rob(int[] nums) {
//1.先考虑特殊情况
if(nums.length == 0) {
return 0;
}
if(nums.length == 1) {
return nums[0];
}
if(nums.length == 2) {
return Math.max(nums[0],nums[1]);
}
//2.创建dp数组:dp[n]的含义是给定长度为n - 1的数组返回最大金额
int n = nums.length;
int[] dp = new int[n];
//3.边界条件初始化
dp[0] = nums[0];
dp[1] = Math.max(nums[0],nums[1]);
//4.遍历求得dp数组
for(int i = 2;i < n;i++) {
dp[i] = Math.max(dp[i - 2] + nums[i],dp[i - 1]);
}
return dp[n - 1];
}
}
二.关于dp数组
我们一般考虑采用二维或一维数组,在大多数情况下采用的是二维数组:
涉及两个字符串/数组时(比如最长公共子序列
),dp 数组的含义如下:
在子数组 arr1[0…i] 和子数组 arr2[0…j] 中,我们要求的子序列(最长公共子序列)长度为 dp[i][j]。
只涉及一个字符串/数组时(比如最长回文子序列
),dp 数组的含义如下:
在子数组 array[i…j] 中,我们要求的子序列(最长回文子序列)的长度为 dp[i][j]。
代码示例
Demo01
首先来看一道简单的单字符序列的动态规划问题:
dp[i][j]表示从下标为i的位置开始到下标为j的位置结束的字符序列的最长回文子序列。
观察发现,该问题满足dp问题的三要素,故可由子问题的最优解递推求得最终的最优解。建立状态转移方程如下:
1.当序列两端的字符相同时:
dp[i][j] = dp[i + 1][j - 1] + 2;
2.当序列两端的字符不相同时:
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
/**
* 步骤分三步走
*/
class Solution {
public int longestPalindromeSubseq(String s) {
/**
* 1.创建dp数组
*/
int n = s.length();
int[][] dp = new int[n][n];
/**
* 2.初始化dp数组
* 一个字符情况下dp数组值为1
*/
for (int i = 0; i < n; i++) {
dp[i][i] = 1;
}
/**
* 3.遍历求dp数组
*/
for (int i = n - 1; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
//状态转移方程
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
//返回字符串从0位置到n-1位置的最长回文子序列
return dp[0][n - 1];
}
}
Demo02
demo02.01
接着看一道两个字符序列的dp问题:
我们首先明确这道题目的返回值是两个字符序列中的最长公共子序列的长度。涉及子序列的最值问题,一般采用的策略就是动态规划。
第一步:明确dp数组的含义并建立dp数组
这里的dp[i][j]表示长度为i和长度为j的两个字符序列的最长公共子序列长度。考虑到空序列可视为长度为0,故初始化dp数组的容量为length+1
第二步:初始化dp数组的边界条件
只要两个字符序列中存在有任何一个序列长度为0,那么dp数组的返回值一定也是0。所以我们初始化dp[0][j]和dp[i][0]的值为0
第三步:依靠状态转移方程遍历求出dp数组每一项的值
以当前指向的两个字符是否相等做为判定条件,建立状态转移方程如下:
(1)如果当前两字符相等,则最大长度则为上一个dp长度+1
dp[i + 1][j + 1] = dp[i][j] + 1;
(2)如果不相等,则取text1串或text2串右移一个字符后的最大dp长度
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
//建造dp数组,记录下数据,避免每次都进行重复计算
int length1 = text1.length();
int length2 = text2.length();
int[][] dp = new int[length1 + 1][length2 + 1];
//遍历穷举dp数组
for (int i = 0; i < length1; i++) {
for (int j = 0; j < length2; j++) {
char ch1 = text1.charAt(i);
char ch2 = text2.charAt(j);
//状态转移方程
if (ch1 == ch2) {
dp[i + 1][j + 1] = dp[i][j] + 1;
} else {
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
return dp[length1][length2];
}
}
demo02.02
类似的这种双序列建立二维dp数组问题:
这道题目最最困难的地方就是状态转移方程的得出。因为题目所说的三种操作用数学式子表达出来真的是太抽象了!
仔细一想也确实,首先第一个,对A串执行插入操作。
插入A序列其实也就等价于对B序列进行删除操作,此时的dp数组返回的操作数的大小为前一个dp数组值加一,即:dp[i][j - 1] + 1
同理也可想象对A串执行删除操作对应的状态转移方程:dp[i - 1][j] + 1
对于修改操作来说,与插入删除不同。如果当前两串的最后一个字符是相等的,对应的操作数dp[i][j]直接就等于前一项的dp[i][j]值;如果不同,那么操作数(dp数组值)需要在前一项基础上再加一。
class Solution {
public int minDistance(String word1, String word2) {
//1.建立dp数组
int n1 = word1.length();
int n2 = word2.length();
//考虑字符串为空的情况
if (n1 * n2 == 0) {
return n1 > n2 ? n1 : n2;
}
//dp[i][j]代表Word1串的长度为i,word2串的长度为j条件下的最少操作数
//因为0也代表一种长度,所以dp数组大小为length+1
int[][] dp = new int[n1 + 1][n2 + 1];
//2.边界条件初始化
for (int i = 0; i <= n1; i++) {
dp[i][0] = i;
}
for (int j = 0; j <= n2; j++) {
dp[0][j] = j;
}
/**
* 3.循环遍历求dp数组
* 状态转移方程的三个部分分别对应三种操作:
* a.对word1串末尾添加 == 对word2串删除
* b.对word2串末尾添加 == 对word1串删除
* c.对两串中的一项进行修改操作
*/
for (int i = 1; i <= n1; i++) {
for (int j = 1; j <= n2; j++) {
//考虑字符串的最后一项字母是否相同,如果相同则只需要修改倒数第二项。
//即:dp[i][j] = dp[i - 1][j - 1]
if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
dp[i][j] = Math.min(dp[i - 1][j] + 1, Math.min(dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1));
} else {
dp[i][j] = Math.min(dp[i - 1][j] + 1, Math.min(dp[i][j - 1] + 1, dp[i - 1][j - 1]));
}
}
}
return dp[n1][n2];
}
}
三.状态转移方程的几种一般形式
(1)Math.max/min型
dp动态规划一般应用于求解最值的相关问题,所以常见的状态转移方程都是在遍历过程不断求解子问题的最值。
容易看出,每一个位置的到达无非只有两种选择:
1.从上一个位置往下走
2.从左边一个位置往右走
题目要求最短路径,则可建立状态转移方程如下:
dp[i][j] = Math.min(dp[i - 1][j],dp[i][j - 1]) + grid[i][j];
class Solution {
public int minPathSum(int[][] grid) {
//1.创建dp数组
int row = grid.length;
int column = grid[0].length;
int[][] dp = new int[row][column];
//2.赋初值
dp[0][0] = grid[0][0];
//3.循环遍历赋值计算
for(int i = 1;i < row;i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for(int j = 1;j < column;j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
for(int i = 1;i < row;i++) {
for(int j = 1;j < column;j++) {
//状态转移方程
dp[i][j] = Math.min(dp[i - 1][j],dp[i][j - 1]) + grid[i][j];
}
}
return dp[row - 1][column - 1];
}
}
(2)针对不同情况的分段式
class Solution {
public int maxSubArray(int[] nums) {
//1.创建dp数组
int n = nums.length;
int[] dp = new int[n];
//2.dp数组边界条件
dp[0] = nums[0];
int res = dp[0];
//3.穷举遍历
for (int i = 1; i < n; i++) {
/**
* 如果dp[i - 1] > 0,那么对于dp[i]来说,dp[i - 1]对其就有着增益作用
* 相应的状态转移方程就应该为dp[i] = dp[i - 1] + nums[i],反之,dp[i]的取值就应该为nums[i]
*/
dp[i] = dp[i - 1] > 0 ? dp[i - 1] + nums[i] : nums[i];
res = Math.max(res, dp[i]);
}
return res;
}
}
(3)递推表达形式
class Solution {
//背包问题
public int waysToChange(int n) {
//硬币种类集合
int coins[] = new int[]{
1, 5, 10, 25};
//建立dp数组
int dp[] = new int[n + 1];
//初始赋值
Arrays.fill(dp, 0);
dp[0] = 1;
//循环遍历
for (int i = 0; i < 4; i++) {
for (int j = 1; j <= n; j++) {
//状态转移方程 累加
if (j - coins[i] >= 0) {
dp[j] = (dp[j] + dp[j - coins[i]]) % 1000000007;
}
}
}
return dp[n];
}
}