推荐学习labuladong大佬的动态规划系列文章:先弄明白什么是动态规划即可,不必一次看完。接着尝试自己做,没有思路了再回过头看相应的文章。
动态规划一般可以由 递归 + 备忘录 一步步转换而来,不必被名字唬住。通常只要找到状态转移方程问题就解决了一大半,至于状态的选择只要题目做多了自然就会形成经验,通常是问什么就设什么为状态。
常见四种类型
- Matrix DP (10%)
- Sequence (40%)
- Two Sequences DP (40%)
- Backpack (10%)
注意:
- 贪心算法大多题目靠背答案,所以如果能用动态规划就尽量用动规,不用贪心算法。一般可以先尝试用动态规划,如果超时再用贪心。
1、矩阵类型(10%)
120. 三角形最小路径和
给定一个三角形
triangle
,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标
i
,那么下一步可以移动到下一行的下标i
或i + 1
。输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]] 输出:11 解释:如下面简图所示: 2 3 4 6 5 7 4 1 8 3 自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
class Solution {
public int minimumTotal(List<list<integer>> triangle) {
/**
* 状态:当前位置到底部最小路径和
* 状态转移方程:dp[i][j] = triangle[i][j] + min(dp[i + 1][j], dp[i + 1]j[j + 1]);i为行,j为列
* base case:最后一行dp[i][j] = triangle[i][j];
*/
int m = triangle.size();
int[][] dp = new int[m][m];
//base case
for (int i = 0; i < m; i++) {
dp[m - 1][i] = triangle.get(m - 1).get(i);
}
//倒数第二行开始转移(递推)
for (int i = m - 2; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
dp[i][j] = triangle.get(i).get(j) + Math.min(dp[i + 1][j], dp[i + 1][j + 1]);
}
}
return dp[0][0];
}
}
该解法空间复杂度为dp
表的大小,为 O(N2) 。容易发现当前行dp
的值只与下一行的相关,我们不必将所有dp
值通过二维数组存下来,可以通过复用一个一维数组来实现。
class Solution {
public int minimumTotal(List<list<integer>> triangle) {
int m = triangle.size();
int[] dp = new int[m];
//base case,先只存最后一行的dp值
for (int i = 0; i < m; i++) {
dp[i] = triangle.get(m - 1).get(i);
}
//倒数第二行开始转移(递推)
for (int i = m - 2; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
dp[j] = triangle.get(i).get(j) + Math.min(dp[j], dp[j + 1]);
}
}
return dp[0];
}
}
64. 最小路径和
给定一个包含非负整数的
m x n
网格grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。
示例 1:
输入:grid = [[1,3,1],[1,5,1],[4,2,1]] 输出:7 解释:因为路径 1→3→1→1→1 的总和最小。
思路:动态规划,和上一题相似。
-
状态:起点到当前结点的最小路径和
-
转移方程:起点到当前结点最小路径和
dp[i][j]
等于:min
(起点到其相邻左结点最小路径和dp[i][j - 1]
,起点到其相邻上结点最小路径和dp[i - 1][j]
) + 当前结点值grid[i][j]
-
base case:
dp[0][0] = grid[0][0]
; 第一行dp[0][x]
都为其相邻左结点dp[0][x -1]
+ 自身结点值grid[0][x]
,x >= 1
;第一列dp[x][0]
都为其相邻上结点dp[x - 1][0]
+ 自身结点值grid[x][0]
,x >= 1
。
class Solution {
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
//从左上角到i, j的最短路径和
int[][] dp = new int[m][n];
//base case
dp[0][0] = grid[0][0];
for (int i = 1; i < m; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (int i = 1; i < n; i++) {
dp[0][i] = dp[0][i - 1] + grid[0][i];
}
//转移方程
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = Math.min(dp[i - 1][j], dp [i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
}
62. 不同路径
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
输入:m = 3, n = 7
输出:28
思路:动态规划
-
状态:从起点到当前结点的不同路径数。
-
转移方程:起点到当前点的不同路径数
dp[i][j]
等于:起点到当前结点相邻左结点dp[i][j - 1]
和相邻上结点dp[i - 1][j]
不同路径数之和。 -
base case:第0行
dp[0][x]
和0列dp[x][0]
都为1,前者只能通过其相邻左节点到达,后者只能通过相邻上结点到达。
class Solution {
public int uniquePaths(int m, int n) {
//状态
int dp[][] = new int[m][n];
//base case
for (int i = 0; i < n; i++) {
dp[0][i] = 1;
}
for (int i = 1; i < m; i++) {
dp[i][0] = 1;
}
//转移方程
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
}
}
return dp[m - 1][n - 1];
}
}
63. 不同路径 II
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用
1
和0
来表示。示例 1:
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]] 输出:2 解释: 3x3 网格的正中间有一个障碍物。 从左上角到右下角一共有 2 条不同的路径: 1. 向右 -> 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 -> 向右
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
int dp[][] = new int[m][n];
// base case
if (obstacleGrid[0][0] != 1) { // 起点不是障碍
dp[0][0] = 1;
}
for (int i = 1; i < n; i++) {
if (obstacleGrid[0][i] != 1) {
dp[0][i] = dp[0][i - 1];
}
}
for (int i = 1; i < m; i++) {
if (obstacleGrid[i][0] != 1) {
dp[i][0] = dp[i - 1][0];
}
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (obstacleGrid[i][j] != 1) {
dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
}
}
}
return dp[m - 1][n - 1];
}
}
2、序列类型(40%)
70. 爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
思路:动态规划
-
状态:从第0个台阶跳到当前台阶的不同方式
-
转移方程:第0个台阶到当前台阶的不同方式
dp[i]
等于:第0个台阶到当前台阶下面两个台阶的不同方式之和(dp[i - 1] + dp[i - 2]
) -
base case:
dp[0] = dp[1] = 1
class Solution {
public int climbStairs(int n) {
//状态:从第0个台阶跳到当前台阶的不同方式
int[] dp = new int[n + 1];
//base case
dp[0] = 1;
dp[1] = 1;
//转移方程
for (int i = 2; i < n + 1; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
55. 跳跃游戏
给定一个非负整数数组
nums
,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标。
class Solution {
public boolean canJump(int[] nums) {
int len = nums.length;
//起始位置能否跳至当前位置
boolean[] dp = new boolean[len];
//base case
dp[0] = true;
//转移方程:i前面所有的点只要有一个能跳到当前结点就说明当前结点可达
for (int i = 1; i < len; i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && (j + nums[j] >= i)) {
dp[i] = true;
break;
}
}
}
return dp[len - 1];
}
}
45. 跳跃游戏 II
给定一个非负整数数组,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
你的目标是使用最少的跳跃次数到达数组的最后一个位置。
说明:
假设你总是可以到达数组的最后一个位置。
class Solution {
//状态:从下标为0的位置跳到i所需的最小跳跃次数
//转移方程:从下标为0的位置跳到i所需的最小跳跃次数等于:i前面一次跳跃就能到达i的所有结点中的最小dp值 + 1
//base case:dp[0] = 0
public int jump(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, n); //最多跳n - 1次,求最小值,先将其初始化为足够大
//base case
dp[0] = 0;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] + j >= i) {
dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
}
return dp[n - 1];
}
}
132. 分割回文串 II
给你一个字符串
s
,请你将s
分割成一些子串,使每个子串都是回文。返回符合要求的 最少分割次数 。
class Solution {
// 状态:从头字符到以当前字符结尾形成的字符串分割成回文子串需要的最少分割次数
// 转移方程:dp[i] = min(dp[i], dp[j] + 1), j < i 且 [j + 1, i]区间的子串为回文子串
// base case:dp[0] = 0
public int minCut(String s) {
int len = s.length();
// 先使用动态规划获得任意两个区间的字符串是否为回文字符串
boolean[][] isPalindrome = getPalindrome(s);
// 求最小值,先初始化足够大(最多s最多分割 len - 1 次)
int[] dp = new int[len];
Arrays.fill(dp, len);
for (int j = 0; j < len; j++) {
//无需分割
if (isPalindrome[0][j]) {
dp[j] = 0;
continue;
}
for (int i = 1; i <= j; i++) {
if (isPalindrome[i][j]) {
dp[j] = Math.min(dp[j], dp[i - 1] + 1);
}
}
}
return dp[len - 1];
}
private boolean[][] getPalindrome(String s) {
int len = s.length();
// 区间i,j的字符串是否为回文字符串(左右都为闭区间)
boolean[][] dp = new boolean[len][len];
for (int j = 0; j < len; j++) {
for (int i = 0; i <= j; i++) {
if (s.charAt(i) == s.charAt(j) && (j - i <= 2 || dp[i + 1][j - 1])) {
dp[i][j] = true;
}
}
}
return dp;
}
}
300. 最长递增子序列
给你一个整数数组
nums
,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,
[3,6,2,7]
是数组[0,3,1,6,2,2,7]
的子序列。
-
思路:动态规划
-
状态:以当前字符结尾的字符串中最长递增子序列的长度
-
转移方程:
dp[i] = max(dp[j] + 1, dp[i])
,其中j < i
且nums[j] < nums[i]
-
base case:
dp[i] = 1
class Solution {
public int lengthOfLIS(int[] nums) {
int len = nums.length;
// dp[i] 表示以当前字符结尾的字符串中最长递增子序列的长度
int[] dp = new int[len];
//base case, 最少长度为1
Arrays.fill(dp, 1);
int maxLen = 0;
for (int i = 0; i < len; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
}
139. 单词拆分
给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
说明:
- 拆分时可以重复使用字典中的单词。
- 你可以假设字典中没有重复的单词。
class Solution {
public boolean wordBreak(String s, List<string> wordDict) {
Set<string> set = new HashSet<>();
for (String str : wordDict) {
set.add(str);
}
int len = s.length();
// 状态: s 中前 i 个字符能否拆分成功
boolean[] dp = new boolean[len + 1];
// base case
dp[0] = true;
// 状态转移
// s[0, i]能否被分割取决于:区间[j, i]是否属于set和dp[j]的值(前j个字符 [0, j - 1] 能否被分割),j <= i
for (int i = 1; i < len + 1; i++) {
for (int j = 0; j < i; j++) {
if (set.contains(s.substring(j, i)) && dp[j]) {
dp[i] = true;
break;
}
}
}
return dp[len];
}
}
推荐题解:「手画图解」剖析三种解法: DFS, BFS, 动态规划 |139.单词拆分
3、双序列(40%)
1143. 最长公共子序列
给定两个字符串
text1
和text2
,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回0
。一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
- 例如,
"ace"
是"abcde"
的子序列,但"aec"
不是"abcde"
的子序列。两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
//状态:text1前m个和text2前n个字符的最长公共子序列长度
int[][] dp = new int[m + 1][n + 1];
//base case, dp[x][0] = dp[0][x] = 0; 默认值即可
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
//如果当前两个字符相同
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
//不等说明有其中一个字符不在最长公共子序列中
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
}
72. 编辑距离
给你两个单词
word1
和word2
,请你计算出将word1
转换成word2
所使用的最少操作数 。你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
//word1 前m个字符和 word2 前n个字符之间的编辑距离,注意下标对应关系
int[][] dp = new int[m + 1][n + 1];
//base case
for (int i = 0; i <= m; i++) {
dp[i][0] = i;
}
for (int i = 0; i <= n; i++) {
dp[0][i] = i;
}
// 状态转移
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// 最后一个字符相等
if (word1.charAt(i - 1) == word2.charAt(j -1)) {
dp[i][j] = dp[i - 1][j - 1];
// 不等则 在word1后增加word2的最后一个字符、删除word1中最后一个字符,或将word1最后一个字符修改成和word2最后一个字符相同;取代价最小的一个
} else {
dp[i][j] = Math.min(Math.min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
}
}
}
return dp[m][n];
}
}
4、零钱和背包(10%)
322. 零钱兑换
给定不同面额的硬币
coins
和一个总金额amount
。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回-1
。你可以认为每种硬币的数量是无限的。
class Solution {
public int coinChange(int[] coins, int amount) {
// 状态:dp[i] 表示凑够i需要的最少硬币数
int[] dp = new int[amount + 1];
// 求最小值,先初始为足够大。(若能凑成,最多需要amount枚硬币)
Arrays.fill(dp, amount + 1);
// base case
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int j = 0; j < coins.length; j++) {
// 当前背包(总金额)若能装下物品(硬币面额)
if (i >= coins[j]) {
dp[i] = Math.min(dp[i - coins[j]] + 1, dp[i]);
}
}
}
return dp[amount] >= amount + 1 ? -1 : dp[amount];
}
}
92 · 背包问题
在 n 个物品中挑选若干物品装入背包,最多能装多满?假设背包的大小为 m,每个物品的大小为 A[i]
public class Solution {
public int backPack(int m, int[] A) {
int n = A.length;
//背包容量为m,有前n个物品,能否将背包装满
boolean[][] dp = new boolean[m + 1][n + 1];
//base case, 背包容量为0时dp[0][x] = true; 背包容量大于0但没有物品时dp[x][0] = false,x > 0
for (int i = 0; i <= n; i++) {
dp[0][i] = true;
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
//如果前 j - 1 个就可以装满 i
if (dp[i][j - 1]) {
dp[i][j] = true;
} else if (i >= A[j - 1] && dp[i - A[j - 1]][j - 1]) {
dp[i][j] = true;
}
}
}
for (int i = m; i > 0; i--) {
if (dp[i][n]) {
return i;
}
}
return 0;
}
}
125 · 背包问题 II
有
n
个物品和一个大小为m
的背包. 给定数组A
表示每个物品的大小和数组V
表示每个物品的价值. 问最多能装入背包的总价值是多大?
public class Solution {
public int backPackII(int m, int[] A, int[] V) {
int n = A.length;
//背包容量为m,有前n个物品时能装入的最大价值
int[][] dp = new int[m + 1][n + 1];
//base case, dp[x][0] = dp[0][x] = 0
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
//当前背包能容纳
if (i >= A[j - 1]) {
dp[i][j] = Math.max(dp[i - A[j - 1]][j - 1] + V[j - 1], dp[i][j - 1]);
} else {
dp[i][j] = dp[i][j - 1];
}
}
}
return dp[m][n];
}
}
</list