一、理论基础和模板
动态规划(Dynamic Programming)的理论基础是运筹学;动态规划中有一些概念,比如重叠子问题(Overlapping Subproblems)、和最优子结构(Optimal Substructure)、状态转移方程等。
- 重叠子问题(Overlapping Subproblems)
实际要解决的问题,可以通过解决这个问题的子问题而解决。比如斐波那契数列,F(n)可以通过F(n-1)与F(n-2)的和得到;
- 最优子结构特性(Optimal Substructure Property)
如果问题的最优解,可以通过使用子问题的最优解获取,则说明这个问题具有最优子结构特性。
- 状态(state)与转移(transition)方程
1.1 解决一个动态规划问题的4步曲
- step 1.根据「重叠子问题」和「最优子结构特性」来判断目标问题是否可以通过动态规划解决
- step 2.用最少的参数定义dp数组(状态),并确定dp数据以及对应下标的含义
- step 3.确定dp数组的状态转移方程以及初始化;
- step 4.使用「表格」(Tabulation)或者「备忘录」(Memoization)来保存dp,降低时间复杂度或空间复杂度(定义dp)
最难的就是步骤2和步骤3,如何根据问题,分析重叠子问题和最优子结构,进而定义好dp的含义以及dp的状态转移方程;在寻找递推关系时,可以分析dp[i]与dp[i-1]之前的关系,也有可能是dp[i]和dp[0]~dp[i-1]都有关系。二位的dp数组也类似,可能dp[i][j]与dp[i-1][j-1]有关系,也有可能和0->i-1,0->j-1都有关系,而且是取最值的关系。
1.2 代码模板
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)
二、递推问题引入
斐波那契数列并非严格意义的动态规划,但是它和动态规划类似,有重叠子问题,并且状态转移方程比较明确:
如下:
public int fib(int n) {
if (n <= 1) {
return n;
}
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i < n + 1; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
以上代码只用于演示,实际则有int越界的问题,需要通过
题目如下,详见题目不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
其实从”回溯“算法的角度,这个题目很好写,按照回溯算法的套路,很快就可以枚举所有的路径,如下:
public int uniquePaths(int m, int n) {
bt(0, 0, m, n);
return sum;
}
private int sum = 0;
public void backtrack(int stepX, int stepY, int m, int n) {
//搜集结果
if (stepX == m - 1 && stepY == n - 1) {
sum++;
return;
}
//终止条件
if (stepX > m - 1 || stepY > n - 1) {
return;
}
//做选择,或者向下,或者向右
//先向右做选择:+回溯
if (stepY < n - 1) {
stepY++;
backtrack(stepX, stepY, m, n);
stepY--;
}
//再向下做选择:+回溯
if (stepX < m - 1) {
stepX++;
backtrack(stepX, stepY, m, n);
stepX--;//由于后续不再消费stepX,此代码可以省略
}
}
甚至根据回溯算法,也可以把「路径」保存并打印出来。但回溯算法最大的问题是时间复杂度太高,如果以上述代码提交,会出现超时的情况。此时我们就需要考虑动态规划了。
我们可以通过观察,查找对应的递推关系:当我们要走到{i,j}这个位置时,根据题目表述,我们可以通过向下或者向右得到,所以我们定义dp[i][j]为走到i*j网格需要的路径总数,那么由于走到{i,j}的坐标,可以有坐标需要{i-1,j}和{i,j-1}分别向右和向下走到,因此dp[i][j] = dp[i][j-1]+dp[i-1][j];而问题的解,正好是dp[m-1][n-1];代码如下:
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
//初始化
for (int i = 0; i < dp[0].length; i++) {
dp[0][i] = 1;
}
for (int i = 0; i < dp.length; i++) {
dp[i][0] = 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];
}
初始化时,要记得dp的含义;
三、0-1背包问题
比如题目为:
物品重量分别是{1,3,4},价值分别是{15, 20, 30}。书包的容量为4,书包能够装下的最大价值是多少?
从回溯的角度,其实就是一个组合问题,可以把所有不超过书包容量的物品组合做个遍历,找出价值最大的即可。详见:javascript:void(0);
但上述一个最大的问题就是时间复杂度太高。那么从动态规划的角度,我们则需要分析问题的子问题,并恰当的定义dp数组的含义,然后分析各种情况,推导出各个dp的递推关系。
- (1).定义dp数组:dp[i][j] 表示使用第0->i个物品,书包容量为j时,书包能够装下的最大价值是dp[i][j];
- 确定递推关系:由于dp是有i,j两个变量,则需要分析dp[i][j]和其他dp之间的关系。
- (1.1) case 1:dp[i-1][j]与dp[i][j]的关系:dp[i-1][j]表示只使用第0->(i-1)个物品,就已经装满容量为j的书包,并且获得了最大价值。
- (1.2) case 2:就需要考虑使用第i个物品,能达到的最大价值应该为:dp[i-1][j-w[i]]+v[i]
- (1.3) 因此dp应该是取case 1和case 2的最大值:dp[i][j]=Max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]) 其中i->0~(size-1) j->0->书包容量W;
- (2).对dp数组进行初始化:
- (3).根据递推关系确定遍历顺序
代码如下:
public void testBag() {
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagSize = 4;
int max = maxValue(weight, value, bagSize);
System.out.println(max);
}
public int maxValue(int[] weight, int[] value, int bagSize) {
int[][] dp = new int[weight.length][bagSize + 1];
//初始化:初始化虽然代码写在前面,但是需要根据dp的递推关系和需要来决定如何初始化,并牢记dp数据的含义
for (int j = 0; j < bagSize + 1; j++) {
if (j < weight[0]) {
dp[0][j] = 0;
} else {
dp[0][j] = value[0];
}
}
//根据递推关系进行遍历
for (int i = 1; i < dp.length; i++) {
for (int j = 0; j < bagSize + 1; j++) {
if (j < weight[i]) {
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
return dp[weight.length - 1][bagSize];
}
四、零钱兑换
在回顾下零钱兑换,题目如下:
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
举例子:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
和解决0-1背包问题类似,我们当然可以使用回溯算法,类似暴力破解密码问题,解决该问题。但是考虑到时间复杂度,我们需要使用动态规划;但是和0-1背包问题不同的是,零钱兑换中,每个硬币可以无限制的使用。我们假设使用coins兑换amount需要的最小硬币数为dp[amount],则dp[amount]和其他的dp之前有这说明样的关系呢?也就是dp[amount]可以有哪几种状态可以转换而来呢?以示例做分析:
- case 1:最后一个硬币使用1,也即是dp[amount - 1] + 1
- case 2:最后一个硬币使用的是2,也即是dp[amount -2] + 1
- case 3:最后一个硬币使用的是5,也即是dp[amount- 5] + 1
依次类推,也就是每一种case都与coins数组的大小和元素有关系;
dp[amount] = Min(dp[amount - coins[i]]+1) 其中i=0~(coins.length-1);
以示例为例,也就是:
dp[amount] = min(dp[amount - coins[0]]+1,dp[amount - coins[1]]+1,dp[amount - coins[2]]+1)
也就是使用过去的3个dp值分别+1,然后取最小值;仔细体会下这个递推关系;
而且遍历的时候,先遍历金额,再遍历coins数组。也就是从amount->0开始,一直到amount->目标值;内循环则需要遍历金额,找到dp的最小值;
public class CoinsDp {
public static void main(String[] args) {
int[] coins = { 2};
int amount = 3;
int r = new CoinsDp().coinChange(coins, amount);
System.out.println(r);
}
public int coinChange(int[] coins, int amount) {
//定义dp数据
int[] dp = new int[amount + 1];
//初始化
dp[0] = 0;
//遍历递推关系
for (int i = 1; i < amount + 1; i++) {
//求dp[i]的最小值
int min = Integer.MAX_VALUE / 2;
for (int j = 0; j < coins.length; j++) {
if (i - coins[j] < 0) {
//未能成功兑换
continue;
} else {
//不断更新最小值
if (dp[i - coins[j]] < min) {
min = dp[i - coins[j]] + 1;
}
}
}
dp[i] = min;
//System.out.println("i = " + i + ",dp=" + dp[i]);
}
//如果最小值为默认值,说明没有兑换成功
if (dp[amount] == Integer.MAX_VALUE / 2) {
return -1;
}
return dp[amount];
}
}
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
最简单的方式就是暴力回溯了;并对前后相邻或者不符合条件的进行剪枝;代码如下:
public int rob(int[] numbers) {
backtrack(numbers, 0, 0, new LinkedList<>());
return maxSum;
}
public int maxSum = -1;
/**
* 回溯
* @param numbers
* @param startIndex 深度搜索->往后搜索
* @param sum 累计和
* @param indexList 保存选择的index坐标,用于判断当前的选择和上一次选择的坐标相差要大于1,也就是相邻的不能偷
*/
public void backtrack(int[] numbers, int startIndex, int sum, LinkedList<Integer> indexList) {
//搜集结果
if (sum > maxSum) {
maxSum = sum;
}
//搜索终止条件
if (startIndex >= numbers.length) {
return;
}
//做选择
for (int i = startIndex; i < numbers.length; i++) {
//进行剪纸
if (indexList.size() > 0) {
int beforeIndex = indexList.getLast();
//去除相邻的;进行剪纸
if (i - beforeIndex < 2) {
continue;
}
}
//开始回溯
indexList.add(i);
sum += numbers[i];
backtrack(numbers, startIndex + 2, sum, indexList);
sum -= numbers[i];
indexList.removeLast();
}
}
但上述代码最大的问题就是超时,时间复杂度较高;那么我们考虑使用动态规划进行解决。
5.1 动态规划解题思路
动态规划解决这道题的关键就是分析问题,定义dp以及寻找dp之间的递推关系。面向问题,我们把dp[i]定位为:数组第0->i个元素,所能偷的最大值;那么dp[i]和dp[i-1]、dp[i-2]、…、dp[0]之间是什么关系呢?根据题意分析,相邻的不能选择,那么最后一个元素只有2种可能,分类讨论:
- case 1:a[i]没有被选择,dp[i]=dp[i-1]
- case 2:a[i]被选择,dp[i]=dp[i-2]+nums[i]
因此,我们可以推断出,dp[i] = Max(dp[i-1],dp[i-2]+nums[i])
初始化dp[0]=nums[0],dp[1]=Max{nums[0],nums[1]}
代码如下:
public int rob(int[] nums) {
if (nums.length == 1) {
return nums[0];
}
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(nums[1], nums[0]);
for (int i = 2; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[nums.length - 1];
}
5.2 打家劫舍 II
与上述题目唯一不同时,nums的「头」、「尾」也不能同时选择;如果使用回溯算法,则非常简单,再增加一种剪枝类型即可。即:
if (i - beforeIndex < 2) {
continue;
}
改为
if (i - beforeIndex < 2 || (indexList.get(0) == 0 && i == numbers.length - 1)) {
continue;
}
即可,也就是增加一个首位的判断;
那如果使用动态规划,则需要按照nums[0]和nums[i],进行分类讨论:
1.case 1: 包含首元素,但不包含尾元素;问题转化为对[0 ~ size()-2]问题的求解
2.case 2: 不包含元素,但包含尾部元素:问题转化为对[1 ~ size()-1]问题的求解
将这两种情况取最大值即可。代码如下:
public int rob(int[] nums) {
if (nums.length == 1) {
return nums[0];
}
if (nums.length == 2) {
return Math.max(nums[0], nums[1]);
}
int[] numWithHead = new int[nums.length - 1];
numWithHead[0] = nums[0];
int[] numWithTail = new int[nums.length - 1];
numWithTail[numWithTail.length - 1] = nums[nums.length - 1];
for (int i = 0; i < nums.length - 2; i++) {
numWithHead[i + 1] = nums[i + 1];
numWithTail[i] = nums[i + 1];
}
int res1 = robRaw(numWithHead);
int res2 = robRaw(numWithTail);
return Math.max(res1, res2);
}
public int robRaw(int[] nums) {
if (nums.length == 1) {
return nums[0];
}
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(nums[1], nums[0]);
for (int i = 2; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[nums.length - 1];
}
仔细体会一下打家劫舍II的题目,它其实已经很难想出比较好的办法了,即便是递推关系,也是分类为2中情况的递推关系,难度很大!
六、股票买卖
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
左边不断的更新最小值,右边遍历不断的更新最大值,
public int maxProfit(int[] prices) {
int min = Integer.MAX_VALUE;
int maxValue = 0;
for (int i = 0; i < prices.length; i++) {
if (prices[i] < min) {
min = prices[i];
}
if (min != Integer.MAX_VALUE) {
int value = prices[i] - min;
if (value > maxValue) {
maxValue = value;
}
}
}
return maxValue;
}
上述问题异常简单,也可以使用暴力求解,2次遍历,但时间复杂度较高;
给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
与题目1不同的是,可以多次进行买卖,比如示例:
输入: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 。
public int maxProfit(int[] prices) {
backtrack(0, prices, new LinkedList<>());
return maxSum;
}
private int maxSum = 0;
public void backtrack(int startIndex, int[] prices, LinkedList<Integer> tracking) {
if (startIndex >= prices.length) {
//搜集结果
int sum = 0;
for (int i = 0; i < tracking.size() - 1; i += 2) {
sum += tracking.get(i + 1) - tracking.get(i);
}
if (sum > maxSum) {
maxSum = sum;
}
return;
}
for (int i = startIndex; i < prices.length; i++) {
//剪枝
if (tracking.size() % 2 == 1) {
//第2、4、等偶数要加入时,过滤下买卖亏钱的
if (tracking.getLast() >= prices[i]) {
continue;
}
}
tracking.add(prices[i]);
backtrack(i + 1, prices, tracking);
tracking.removeLast();
}
}
也就是搜集所有的「买卖」键值对,存入到tracking的数组中;然后计算所有赚钱的买卖组合,求出最大值;回溯算法在Leetcode上能通过198/200个测试用例,在第198个测试用例时,出现超时;也就是回溯算法最大的问题就是时间复杂度太高。
TODO
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
7.1 暴力回溯求解
这个题目最简单的方案就是暴力回溯求解,然后进行适当剪枝,如下回溯算法:
public int lengthOfLIS(int[] nums) {
backtrack(0, nums, new LinkedList<>());
return max;
}
private int max = 0;
public void backtrack(int startIndex, int[] numbers, LinkedList<Integer> track) {
//搜集结果
if (track.size() > max) {
max = track.size();
}
//终止条件
if (startIndex >= numbers.length) {
return;
}
//做选择回溯
for (int i = startIndex; i < numbers.length; i++) {
//剪枝
if (track.size() > 0 && numbers[i] <= track.getLast()) {
continue;
}
track.add(numbers[i]);
backtrack(i + 1, numbers, track);
track.removeLast();
}
}
回溯算法在问题求解的正确性上是没问题的,最大的问题还是时间复杂度太高,造成超时;
7.2 动态规划求解
动态规划解决这道题的关键就是分析问题,定义dp以及寻找dp之间的递推关系。面向问题,我们把dp[i]定位为:数组第0->i个元素,并且以nums[i]结尾的LIS长度;那么我们考虑一下dp[i]与dp[i-1]…dp[0]之间的关系。nums[i]如果大于num[i-1]->num[0],那么dp[i]应该在所有nums[i]>nums[0 ~ i-1]对应的dp[0 ~ i-1]+1中取最大值,仔细体会一下这个含义;
dp[i] = max(dp[j]+1) 其中j=0,...,i-1 且当nums[i]>nums[j]时
所有dp[i]的状态转移方案比较复杂,它需要根据nums[i]与0 ~ i-1 所有的dp做比较,取最大值。而问题的目标解,则是dp[0~size-1]的最大值;务必认真体会;
体会到含义之后,代码如下:
public int lengthOfLIS(int[] nums) {
//dp[i] = max(dp[j]+1) 其中j=0,...,i-1 且当nums[i]>nums[j]时
int[] dp = new int[nums.length];
//初始化dp数组为1,原因就是以nums[i]结尾的LIS的长度,最小为1
Arrays.fill(dp, 1);
//先求dp
for (int i = 1; i < nums.length; i++) {
//和dp[0->j-1] 组个做对比
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[j] + 1, dp[i]);
}
}
}
//从dp中寻找最大值
int res = 0;
for (int i = 0; i < dp.length; i++) {
res = Math.max(res, dp[i]);
}
return res;
}
由于dp的定义并不是直面问题,问题的解,还是从dp中再找最大值,所以,这个题目还是比较难的;
参考资料