文章目录
一、概述
动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,并且记录所有子问题的结果,因此动态规划方法所耗时间往往远少于朴素解法。
动态规划有自底向上和自顶向下两种解决问题的方式。自顶向下即记忆化递归,自底向上就是递推。
使用动态规划解决的问题有个明显的特点,一旦一个子问题的求解得到结果,以后的计算过程就不会修改它,这样的特点叫做无后效性,求解问题的过程形成了一张有向无环图。动态规划只解决每个子问题一次,具有天然剪枝的功能,从而减少计算量。
二、常见算法
1. 汉诺塔问题
在经典汉诺塔问题中,有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。一开始,所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制:
(1) 每次只能移动一个盘子;
(2) 盘子只能从柱子顶端滑出移到下一根柱子;
(3) 盘子只能叠在比它大的盘子上。
请编写程序,用栈将所有盘子从第一根柱子移到最后一根柱子。
你需要原地修改栈。
示例:
输入:A = [2, 1, 0], B = [], C = []
输出:C = [2, 1, 0]
解题思路:
将问题进行拆解:
- 将n-1个盘子从移动到auxiliary
move(n-1,start,auxiliary,target)- 将第n个盘子添加到target
target.add(start.remove(start.size()-1));- 将n-1个盘子放到target
move(n-1,auxiliary,start,target);
public void hanota(List<Integer> A, List<Integer> B, List<Integer> C) {
move(A.size(), A, B, C);
}
public void move(int size, List<Integer> A, List<Integer> B, List<Integer> C) {
// 剩余1个的时候,退出递归。
if (size == 0) {
// 这里需要使用A.size()重新计算size,而不能使用参数size。
C.add(A.remove(A.size()-1));
return;
}
move(size-1, A, C, B);
C.add(A.remove(A.size()-1));
move(size-1, B, A, C);
}
2. 爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
解题思路:
动态规划的状态转移方程为:dp[i] = dp[i-1] + dp[i-2],i>=2。
public int climbStairs(int n) {
if (n <= 2) {
return n;
}
int[] dp = new int[n];
dp[0] = 1;
dp[1] = 2;
for (int i=2; i<n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n-1];
}
3. 跳跃游戏
给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标。
示例 :
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
解题思路:
这一题相对于上一题来说,有了一些变化。主要区别在于每一次走的步数为数组中的元素值。
状态转移方程为:rightMost = Math.max(i+nums[i], rightMost)。
public boolean canJump(int[] nums) {
int rightMost = 0;
int len = nums.length;
for (int i=0; i<len; i++) {
if (rightMost < i) {
return false;
}
rightMost = Math.max(i+nums[i], rightMost);
}
return true;
}
4. 跳跃游戏 II
给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。
- 每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:
- 0 <= j <= nums[i]
- i + j < n
返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]。
示例:
输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
解题思路:
上一题主要是判断能否跳到最后一个位置,这里是判断跳到最后一个位置最少的步数。
计算当前可以跳到的最远距离的方式与上一题一致。下面我们就需要对步数进行定义,我们在数组的第 i 到 i+nums[i] 位置之间做一次决策,每次决策算执行一步。
public int jump(int[] nums) {
int minStep = 0;
int end = 0;
int maxPosition = 0;
int len = nums.length;
// 这里要跳到最后一个位置,所以不需要取到最后一个值。
for (int i=0; i<len-1; i++) {
maxPosition = Math.max(i+nums[i], maxPosition);
// 在第i到i+nums[i]位置做一次决策,判断可以达到的最远距离,以便决定下一次决策的范围。
if (end == i) {
end = maxPosition;
minStep++;
}
}
return minStep;
}
5. 不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
解题思路:
- 状态转移方程:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
- 第1行全为1,因为只有1种走法(即只能往右走)。
- 第1列全为1,因为只有1种走法(即只能往下走)。
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
// 第1列全为1,因为只有1种走法(即只能往下走)。
for(int i=0; i<m; i++) {
dp[i][0] = 1;
}
// 第1行全为1,因为只有1种走法(即只能往右走)。
for(int i=0; i<n; i++) {
dp[0][i] = 1;
}
// 填充剩余格子的可能性走法。
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];
}
6. 不同路径 II
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
解题思路:
这一题与上一题类似,区别在于在 mxn 的网格中存在障碍物,也就是说在障碍物所在的位置,可达性为0。同时,如果障碍物出现在边界上,则障碍物后续的边界位置可达性全为0。
- 状态转移方程:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
- 当有障碍物时,且在左边界时:dp[i][0] 到 dp[m][0] 都为 0。
- 当有障碍物时,且在上边界时:dp[0][j] 到 dp[0][n] 都为 0。
- 当有障碍物时,且不在边界时:
dp[i][j] = 0
。
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
// 获取网格范围:m,n
int m = obstacleGrid.length;
if (m <= 0) {
return 0;
}
int n = obstacleGrid[0].length;
// 设定dp表。
int[][] dp = new int[m][n];
// 第1列全为1,如果边界出现障碍物时,后序的边界可达性全为0。
boolean lost = false;
for(int i=0; i<m; i++) {
if (obstacleGrid[i][0] == 1 || lost) {
dp[i][0] = 0;
// 设置个标记,让障碍物后续全为0;
lost = true;
} else {
dp[i][0] = 1;
}
}
// 第1行全为1,如果边界出现障碍物时,后序的边界可达性全为0。
lost = false;
for(int i=0; i<n; i++) {
if (obstacleGrid[0][i] == 1 || lost) {
dp[0][i] = 0;
// 设置个标记,让障碍物后续全为0;
lost = true;
} else {
dp[0][i] = 1;
}
}
// 填充剩余格子的可能性走法。
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];
}
7. 最小路径和
给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
解题思路:
上面两题我们计算到达目标点的路径种类,这里我们需要计算的是到达目标点的路径最小和。因为路径方向就两种,从上到下,从左到右。所以我们只要求出每一步的局部最小和就可以得出最终的最小和。
- 状态转移方程:
dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
- 左边界时:
dp[i][0] = grid[i][0] + dp[i-1][0]
- 上边界时:
dp[0][i] = grid[0][i] + dp[0][i-1]
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
// 走到当前网格的最小路径和。
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0];
for(int i=1; i<m; i++) {
// 左边界累加
dp[i][0] = grid[i][0] + dp[i-1][0];
}
for(int i=1; i<n; i++) {
// 上边界累加
dp[0][i] = grid[0][i] + dp[0][i-1];
}
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];
}
8. 买卖股票的最佳时机
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
示例:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
解题思路:
状态转移方程:
dp[i] = Math.max(prices[i] - minPrice, dp[i-1])
public int maxProfit(int[] prices) {
int len = prices.length;
// 假设dp存放的是可以获取的最大利润。
int[] dp = new int[len];
int minPrice = prices[0];
dp[0] = 0;
for (int i=1; i<len; i++) {
dp[i] = Math.max(prices[i] - minPrice, dp[i-1]);
if (minPrice > prices[i]) {
minPrice = prices[i];
}
}
return dp[len-1];
}
9. 买卖股票的最佳时机 II
给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
示例:
输入:prices = [7,1,5,3,6,4]
输出:7
解释:在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6 - 3 = 3 。
总利润为 4 + 3 = 7 。
解题思路:
这一题的解题思路与“最小路径和”类似,就是求局部正向收益的累加和。而上一题“买卖股票的最佳时机”求的是全局最大收益。
状态转移方程:dp[i] = Math.max(prices[i] - prePrice, 0) + Math.max(dp[i-1], 0)
,即当前的正向收益+之前的正向收益。
public int maxProfit(int[] prices) {
int len = prices.length;
int[] dp = new int[len];
int prePrice = prices[0];
dp[0] = 0;
for (int i=1; i<len; i++) {
// 方法1:
// if (dp[i-1] > 0) {
// int value = Math.max(prices[i] - prePrice, 0);
// dp[i] = value + dp[i-1];
// } else {
// dp[i] = Math.max(prices[i] - prePrice, 0);
// }
// prePrice = prices[i];
// 方法2(等效):
int value = Math.max(prices[i] - prePrice, 0);
dp[i] = Math.max(dp[i-1], 0) + value;
prePrice = prices[i];
}
return dp[len-1];
}
10. 二叉树中的最大路径和
路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。
- 路径和 是路径中各节点值的总和。
- 给你一个二叉树的根节点 root ,返回其 最大路径和 。
解题思路:
根据上图可以知道,最大路径和不一定是根节点+左侧,或者根节点+右侧,还有可能是根节点+左侧+右侧。所以可以记录局部的最大值(根节点+左侧+右侧),并返回该 根节点 + max(左节点,右节点)的值。为了比较左右节点的值大小,所以可以采取后序遍历的模板写法。
public int maxPathSum(TreeNode root) {
traversal(root);
return max;
}
// 最大路径和
// 注意点:这里需要设置默认值为最小值。
int max = Integer.MIN_VALUE;
public int traversal(TreeNode root) {
if (root == null) {
return 0;
}
// 先获取左侧的最大值,如果是负数则返回0,表示丢弃。
int left = Math.max(traversal(root.left), 0);
// 先获取右侧的最大值,如果是负数则返回0,表示丢弃。
int right = Math.max(traversal(root.right), 0);
// 返回左右值较大的节点 + 根节点值;
// 如果左右节点如果都为负数,则丢弃;直接将当前根节点作为局部最大值返回。
int localMax = Math.max(left, right) + root.val;
// 更新最大值。
max = Math.max(left+right+root.val, max);
// 返回上一层节点继续遍历。
return localMax;
}
11. 零钱兑换
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
解题思路:
这道题可以利用动态规划来实现,dp[i] 定义为要拼出面额为 i 的数最少需要的硬币数。
状态转移方程:dp[i] = Math.min(dp[i], 1+dp[i-coin])
public int coinChange(int[] coins, int amount) {
if (coins.length == 0) return -1;
// 1.先定义dp数组,长度为amount+1,主要是多出了一个0面额的初始值。
int[] dp = new int[amount+1];
// 2.需要求出最小值,因此这里默认初始值设置为一个不小于amount+1的值即可,因为就算面额全为1也无法达到amount+1这个值。
for (int i=0; i< dp.length; i++) {
dp[i] = amount+1;
}
// 3.初始化dp[0]的值。
dp[0] = 0;
// 4.开始遍历并更新dp表。
for (int i=1; i<dp.length; i++) {
// 5.每一个面值的dp表都要遍历一次coins数组,以便获取到最小硬币数。
for (int coin : coins) {
// 6.如果硬币面额大于我们的目标面额,说明无法拼出来,保留默认值amount+1。
if (i < coin) continue;
// 7. 遍历更新获取dp[i]的最小值,在dp[i-coin]的次数上再加1次就可以拼出来。
dp[i] = Math.min(dp[i], 1+dp[i-coin]);
}
}
// 获取dp[dp.length-1]的值。如果值为amount+1,则这个面额无法使用硬币拼凑出来。
return dp[dp.length-1] == amount+1 ? -1 : dp[dp.length-1];
}
12. 最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
解题思路:
解题思路与上一题凑硬币类似,凑硬币时由于是求最小个数,所以初始化dp表时需要设置一个大于目标面额的数字作为是否能凑出的初始值。然后开始遍历更新整个dp表,在更新每一个dp[i]时都需要遍历硬币面额数组,以便获取到最小的硬币数量。
本题也使用dp表来简化重复操作,由于求的是最大子序列,所以初始化dp表时,默认都是长度为1。然后遍历更新每一个dp[i]的值。在更新每一个dp[i]时,都需要重新遍历第0到i位置的数字,获取小于nums[i]的个数并更新到dp[i]上。
public int lengthOfLIS(int[] nums) {
int len = nums.length;
// dp[i]表示i位置之前小于nums[i]的个数。
int[] dp = new int[nums.length];
// 设置默认值为1,表示默认每一个数字单独为1个子序列。
Arrays.fill(dp, 1);
// 更新每一个dp[i]的值
for (int i=0; i<len; i++) {
// 比较0-(i-1)处的值与第i处的值的大小,并更新dp[i]。
for (int j=0; j<i; j++) {
if (nums[i] > nums[j]) {
// 更细dp[i]。
dp[i] = Math.max(dp[i], dp[j]+1);
}
}
}
// 查询dp表,找到dp表中最大的值即为最大子序列。
int maxLen = 0;
for (int i=0; i<dp.length; i++) {
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
13. 最长回文串
给定一个包含大写字母和小写字母的字符串 s ,返回 通过这些字母构造成的 最长的回文串 。
在构造过程中,请注意 区分大小写 。比如 “Aa” 不能当做一个回文字符串。
示例:
输入:s = “abccccdd”
输出:7
解释:
我们可以构造的最长的回文串是"dccaccd", 它的长度是 7。
解题思路:
- 利用HashMap,从给定的字符串中统计各个字符出现的次数。
- 如果某个字符出现了偶数次,则它本身的最大回文长度即为该字符出现的次数。
- 如果某个字符出现了奇数次,则它本身的最大回文长度即为该字符出现的次数-1。
- 上述两步统计出来的回文总长度为偶数。所以如果上述两步统计出来的回文长度小于字符串长度,则在计算出来的回文总长度基础上+1,此时才是最大的回文长度。
public int longestPalindrome(String s) {
if (s == null) {
return 0;
}
if (s.length() <= 1) {
return s.length();
}
// 统计各个字符出现的次数。
char[] arr = s.toCharArray();
HashMap<Character, Integer> map = new HashMap();
for (int i=0; i<arr.length; i++) {
Integer count = map.get(arr[i]);
if (count == null) {
map.put(arr[i], 1);
} else {
map.put(arr[i], ++count);
}
}
// 遍历map集合。
int sum = 0;
for (Map.Entry<Character, Integer> kv : map.entrySet()) {
// 统计回文长度
int count = kv.getValue();
if (count % 2 == 0) {//如果该字符出现偶数次,则直接相加。
sum += count;
} else {//如果该字符出现奇数次,则相加后再减1,表示偶数次。
sum += (count-1);
}
}
// 如果回文总长度比字符串小,说明存在个别字符出现奇数次,则最长回文长度需要加1。
if (sum < arr.length) {
sum += 1;
}
return sum;
}
14. 最长回文子串
给你一个字符串 s,找到 s 中最长的回文子串。
如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。
示例:
输入:s = “babad”
输出:“bab”
解释:“aba” 同样是符合题意的答案。
解题思路:
- 中心扩散法。
- 动态规划(二维数组)。
// 中心扩散法:非动态规划
public String longestPalindrome(String s) {
if (s == null || s.length() == 1) {
return s;
}
int start = 0;
int end = 0;
for (int i=0; i<s.length(); i++) {
int len1 = expend(i, i, s);
int len2 = expend(i, i+1, s);
int len = Math.max(len1, len2);
if (len > end - start + 1) {
start = i - (len - 1)/2;
end = i + (len)/2;
}
}
return s.substring(start, end+1);
}
public int expend(int l, int r, String s) {
int length = s.length();
int left = l;
int right = r;
while (left >= 0 && right < length && s.charAt(left) == s.charAt(right)) {
left--;
right++;
}
return right - left -1;
}