动态规划是一种非常重要的解题方法。相信很多人在刷题的过程中,都会从最开始的暴力法,逐渐开始运用各种数据结构,进而学习各种巧妙的方法。对于各种各样的解题方法,诸如贪心算法,分治,二分查找,回溯等等,我们即便不能精通,也大概明白这是一个怎么样的算法。然而动态规划就像一个曲高和寡的算法,在不懂的时候,那个代码真的是完全看不懂,为什么要定义这几个变量,dp1,dp2,分别代表什么含义?什么时候又要用二维数组,三维数组?那几个神奇的表达式是怎么得来的?这个算法到底是怎么work的?
同时,在一些很难的题目,动态规划是一个很好的解决方法(可以想象一下当你写了几十行的代码,考虑了无数种情况写出来的劣质代码,在别人的dp代码里,仅仅不到20行)。所以,只有我们仔细总结,到底什么是动态规划,它的算法过程是怎么样的,这才能切实地掌握它,而不是“偷看答案,结果看到dp就望而却步”。这篇文章同样地,先简单地讲述什么是动态规划,接着对具体的题目进行分析,在分析题目的过程中熟练掌握。
首先,动态规划的英语是 Dynamic programming,简称 DP,所以你会看到很多人都会以dp来命名动态规划中使用的变量。接着是维基百科的定义:动态规划是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过 把原问题分解为相对简单的子问题 的方式求解复杂问题的方法。 关键的地方就是,把原问题拆分成子问题,通过子问题推导出原问题的答案,即 将大问题的求解转化为对子问题的求解过程。
可能有人会问,动态规划与递归有什么区别 ?有一个很重要的点是,递归是自顶向下,拿最经典的斐波那契数列来举例(虽然这并不是典型的dp问题),如果是递归,那么公式会是f(n) = f(n - 1) + f(n - 2)
,而动态规划是 自底向上,即这时候的推导会是:f(3) = f(2) + f(1), f(4) = f(3) + f(2) ……看起来区别不大,有一个很关键的点:递归会有多余的重复求解动作,而动态规划不会。那么如果是剪枝后的递归呢?
这时候就要明确动态规划的作用机理。递归的作用机理是自顶向下地把问题变小,并且一直调用自身。而动态规划的实质是:有限状态机。实际上动态规划就是把所有的情况都列出来,每一种情况就是一种 状态,状态与状态之间会发生转换,转换过程发生的变化就是 状态转移方程。而为什么动态规划一定要自底向上?因为我们的求解过程就是从状态机的 初始状态(一般叫base case),转换到 最终状态(也就是最后的结果)。如果查看其他的文章,可能会看到很多奇怪的概念,具体但不仅限于”无后效性“,”最优子结构“。这些概念实质上都不关键,我们只需要知道,动态规划实质上就是使用有限状态机的思想去求解,每一个状态实际上就是一个解(子问题的解),在所有的 可行解空间 内,寻找最优解。
DP就是为了列出所有的解空间(每一个状态就是一个子问题的解),进而求出最优解(最终状态的解)。那么看到”所有“这个字眼,看起来时间复杂度会很高,实际上并不是。因为DP自带剪枝,这主要是状态转移方程的功劳,它的转移实质上就是从一个子问题的最优解,得到一个更大问题的最优解,所以它会舍弃一大堆不可能成为最优解的答案。
DP的时间复杂度就是 O(解空间的大小) ,只要合理地设计,那么很多时候时间复杂度会是O(N * k * j),k,j是题目中的常数,所以它的效率很快(只要k,j等等的常数不会太大,那么就是近似O(N))。即列出所有解空间,顺序遍历,从初始状态一直转换到最终状态,最终状态就是结果。
正如上面所说的,动态规划与递归有点类似,所以动态规划的求解过程一般是:暴力的递归解法 -> 带备忘录的递归解法(剪枝) -> 迭代的动态规划解法。
而前两步可以忽略,直接思考动态规划的核心,有限状态机。具体包括:
①定义状态(可行解空间) :要清楚具体题目的操作,把所有的情况都列出来,并且明确每一种情况的具体含义。对于比较简单的,直接用dp1,dp2等等几个变量即可(也可以用一维数组),这又叫一维DP。而有的题目状态更多,需要用二维数组来存储状态,这时候需要用到 dp[x][y],这时候又叫二维DP。对于更复杂的题目,还会出现三维DP,即需要用三维数组来存储所有的状态,dp[x][y][z]。
②状态转移方程:这是DP的难点,也是核心。如果是递归,那么会直接把问题分成子问题,同时进行剪枝,很多情况都不用考虑。而DP是自底向上,因而需要清晰状态的变化,而不是像递归一样把麻烦事交给计算机。值得注意的是,这一个步骤暂时不需要考虑特殊状态(边界的情况),先考虑中间比较泛化的状态变化原理。
③初始状态:又称base case。在起始状态的时候,会存在特殊情况,这时候需要另外考虑。一般有两种方法,其中一种就是手动考虑base case的特殊变化(就像处理数组边界值一样),另一种就是增加一个/一行状态,从而简化base case的变化(就像是给数组的起始增加一些值,简化操作)。如原本我们要定义的状态是dp[m][n],但实质上可以定义成dp[m + 1][n + 1],这样只需要给dp[0][j]以及dp[i][0]设定初始值即可,使得base case也泛化。
题目描述: 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
Leetcode / 力扣
示例 1:
输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
分析: 这算是最简单的DP问题,一维DP问题。这种一维的DP问题代码看起来也会很简单,就像是凭着直觉的数学解决法,但实际上只要看出来这是DP问题,使用DP的解题思路就能很快解决。下面我们根据DP的解题思路,一步一步得出最后的结果。
①定义状态: 以dp[i]一维数组作为状态空间,dp[i] 表示抢到第 i + 1 个住户时的最大抢劫量。(定义状态的时候可能会想,为什么不把前一天是否偷窃,今天是否能偷窃列为状态里?实际上也可以这么尝试,只是后续简化的时候会发现这个无须作为一个状态变量,会完全略去)
②状态转移方程:直觉上的选择会有很多种情况,但如果从左往右推,情况就会简化很多。假设当天必定能偷窃,那么当天有两种选择,1.偷窃,获得当天的数额,然后第二天不能 继续。2.不偷窃,直接跳过当天。因此第i天的状态(最大金额)实际上为: dp[i] = Math.max(dp[i - 1], nums[i] + dp[i - 2])
。即抢到第i个住户时候的最大金额应该为:dp[i - 1]与nums[i] + dp[i - 2]的更大值。dp[i - 1]表示第i天没有抢,因此数额与第i - 1天是一样的。nums[i] + dp[i - 2]表示第i天抢了,于是最大金额就是第i - 2天时候的最大值,加上第i天的金额。
③base case:显然,方程dp[i] = Math.max(dp[i - 1], nums[i] + dp[i - 2])
应该有一个定义域为 i >= 2。所以需要特别地考虑前两天的情况。
这道题的base case也很简单,dp[0] = nums[0], dp[1] = Math.max(nums[0], nums[1])
代码:
class Solution {
public int rob(int[] nums) {
if (nums.length == 0)
return 0;
int[] dp = new int[nums.length];
for (int i = 0; i < nums.length; i++)
if (i == 0)
dp[i] = nums[0];
else if (i == 1)
dp[i] = Math.max(nums[0], nums[1]);
else
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
return dp[dp.length - 1];
}
}
显然,时间复杂度为O(n),空间复杂度为O(n)。但实际上就像斐波那契数列一样,这道题不需要一个完整的数组来存储变量,只需要使用3个变量存储即可。一个代表dp[i - 2]。一个代表dp[i - 1],一个代表dp[i]。所以可以简化:
class Solution {
public int rob(int[] nums) {
if (nums.length == 0)
return 0;
int prev1 = 0, prev2 = 0, current = 0;
for (int i = 0; i < nums.length; i++) {
current = Math.max(prev1, prev2 + nums[i]);
prev2 = prev1;
prev1 = current;
}
return current;
}
}
时间复杂度为O(n),空间复杂度为O(1)。
题目描述:跟上一题相同,只是房屋形成了一个环,即第一家与最后一家相连。
Leetcode / 力扣
分析:与上一题唯一的区别就是,第一家与最后一家不能同时获取,那么很简单,只需要考虑是否获取第一家即可,即把环分成两个队列,一个是0~n-1,另一个是1~n。甚至可以直接复用上一题的代码。值得注意的是,上一题只需要考虑length为0的特殊情况,这一题还有考虑length为1的情况(此时无法分割环,如果不特殊处理,会导致helper循环都直接结束,最后返回的是0,而不是nums[0])
代码:
class Solution {
public int rob(int[] nums) {
if (nums.length == 0)
return 0;
if (nums.length == 1)
return nums[0];
return Math.max(helper(nums, 0, nums.length - 1), helper(nums, 1, nums.length);
}
public int helper(int[] nums, int begin, int end) {
int prev1 = 0, prev2 = 0, current = 0;
for (int i = begin; i < end; i++) {
current = Math.max(prev1, prev2 + nums[i]);
prev2 = prev1;
prev1 = current;
}
return current;
}
}
题目描述:给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
Leetcode / 力扣
示例:
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
分析:显然,也可以用DP递推,使用dp[i][j]二维数组表示到达grid[i][j]时的数字最小总和。因为只能向下或者向右移动,所以当前位置的最小数字总和,显然就是上一个位置(左边一格,或者上边一格)二者的最小值,再加上当前网格的数字,于是状态转换方程可得: dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
至于base case,显然,要担心i - 1或者j - 1变成负数的情况,所以这时候,如果是第一行,则只需要考虑当前行(即j变化的情况),无须考虑列的变化(i的变化),第一列同理。
代码:
class Solution {
public int minPathSum(int[][] grid) {
if (grid.length == 0 || grid[0].length == 0)
return 0;
int[][] dp = new int[grid.length][grid[0].length];
for (int i = 0; i < grid.length; i++)
for (int j = 0; j < grid[0].length; j++)
if (i == 0 && j == 0)
dp[i][j] = grid[i][j];
else if (i == 0)
dp[i][j] = dp[i][j - 1] + grid[i][j];
else if (j == 0)
dp[i][j] = dp[i - 1][j] + grid[i][j];
// 前面都是 base case
else
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
// 状态转移方程
return dp[grid.length - 1][grid[0].length - 1];
// 最终的结果
}
}
题目描述:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
Leetcode / 力扣
分析:与上题一样,只是这题要求的是路径的总数,而不是路径的长度。如果用dp[i][j]代表到达grid[i][j]的路径数,那么到达grid[i][j]的上一步,同样只可能是grid[i - 1][j]或者grid[i][j - 1],假设到达grid[i - 1][j]的路径数为x,那么从grid[i - 1][j]到达grid[i][j]的路径数就为x(因为最后就一步)。而从grid[i][j - 1]路线同理。很容易可以得出状态转移方程:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
。
同样的,base case的处理与上一题的一样,如果超出范围,就只考虑当前行,或者列,而不是行列一起考虑。
代码:
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
if (i == 0 || j == 0)
dp[i][j] = 1;
else
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
return dp[m - 1][n - 1];
}
}
题目描述: 如果一个数列至少有三个元素,并且任意两个相邻元素之差相同,则称该数列为等差数列。给定一个数组,返回数组中所有的等差数列数量。
Leetcode / 力扣
示例:
A = [1, 2, 3, 4]
返回: 3, A 中有三个子等差数组: [1, 2, 3], [2, 3, 4] 以及自身 [1, 2, 3, 4]。
分析:对于DP,很多人的疑惑是,看了答案觉得很合理,但自己想的时候总是想不出转移方程。即使说前面的题都能很快解决,但这题还是很容易会卡住,所以还是要多做题,熟能生巧。对于这道题,很容易第一时间想到的是,使用dp[i]一维数组,表示前i个元素的等差数列的数量,然后进行递推。可是在增加第i + 1个元素之后,如何表现dp[i]与dp[i + 1]之间的关系?感觉是要获取前面最后的等差数列长度,然后进行计算。这样又很麻烦,是否应该用二维数组dp[i, j]来进一步增加状态量,减少计算量?直觉上是可以,但还是回归到同样的问题,如何体现出dp[i, j]与dp[i, j + 1]的关系,i是0还是k,并不影响这个操作。所以看来,这个思路不对。这也是DP里很容易出现的问题,如果状态量设置得不好,那么后续的转移方程,整个问题的解决都会变得十分困难。
那么应该用哪一个状态量才能把问题更简单化?这时候就要观察,dp[i]到dp[i + 1]到底有什么变化。我们想知道新增了多少个等差数列,但为什么dp[i]就一定要是0~i的所有等差数列?就拿i + 1举例,这些新增的等差数列都有一个什么特点?答案是:都是以 nums[i + 1]结尾,并且倒数第二个元素都必定是 nums[i] 。当我们尝试把dp[i]表示整个0~i的等差数列数量,发现很难进行递归,这时候应该把状态量继续细化。所以我们可以得出另一个状态栏的表示方法:使用 dp[i]表示以 nums[i]结尾的数量。这时候进行递推,由上面的第二个特征就很容易看出来,假设dp[i]的数量为K,即以nums[i]为结尾的等差数列一共有K个。显然,这些等差数列,只要再加上一个合适的数(只要判断nums[i - 1], nums[i]和nums[i + 1]
即可),那么这K个都依然为等差数列。特殊情况呢?nums[i - 1]与nums[i],如果长度只有2,不属于等差数列,但加上nums[i + 1],长度刚好变成3,这就是dp[i + 1]与dp[i]的关系。所以我们获得状态转移方程:
① dp[i + 1] = dp[i] + 1, when nums[i + 1] - nums[i] == nums[i] - nums[i - 1]
② dp[i + 1] = 0, when nums[i + 1] - nums[i] != nums[i] - nums[i - 1]
base case,当i <= 1,即dp[0]跟dp[1],设为0即可,无须特殊处理,我们只要从i == 2开始递推即可。
最后,返回值不是dp[n],而是所有dp[i]的和。
代码:
class Solution {
public int numberOfArithmeticSlices(int[] A) {
int[] dp = new int[A.length];
int res = 0;
for (int i = 2; i < A.length; i++)
if (A[i] - A[i - 1] == A[i - 1] - A[i - 2])
dp[i] = dp[i - 1] + 1;
for (int d: dp)
res += d;
return res;
}
}
题目描述: 给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。
分析:很直观地,直接用一维dp数组存储1 ~ n的状态量,通过递推获取dp[n]即可。
那么dp[n]显然是由dp[i] (i ∈ [1, n - 1]到来的,如何判断哪个更大?最简单地,循环一次。
此时状态转移方程为: dp[i] = Math.max(dp[j] * dp[i - j]), j ∈ [1, i], i ∈ [4, n]
这道题的base case有点特别,它并不仅仅是担心超出边界,而且dp[2]跟dp[3]的结果有点特别,它们是唯二的,dp[x]小于x的情况。这会导致什么?拿dp[4]为例,dp[4]可以划分为dp[1]与dp[3],或者dp[2]与dp[2],此时显然答案应该是2 * 2 == 4,答案是4。但因为dp[2]的结果应该是1,导致出错,dp[3]同理。所以我们应该特殊处理这两个值。即dp[2]跟dp[3]里存储的不是真实的答案,而改成dp[2] = 2, dp[3] = 3,避免进行 dp[j] * dp[i - j]
的时候,会导致dp[3]小于3的情况。有一些dp方程甚至懒得处理,直接把方程设置为:
dp[i] = Math.max(dp[j] * (i - j), dp[j] * dp[i - j])
,实际上就只有2或3的时候会小于,特殊处理即可。
代码①:
class Solution {
public int integerBreak(int n) {
if (n == 2)
return 1;
if (n == 3)
return 2; // 如果n == 2,或者3,直接return
int[] dp = new int[n + 1];
dp[2] = 2;
dp[3] = 3; // dp[2]跟dp[3]并不是真实的答案,其他都是
for (int i = 4; i <= n; i++)
for (int j = 1; j <= i; j++) // 循环
dp[i] = Math.max(dp[i], dp[j] * dp[i - j]);
return dp[n];
}
}
此时的时间复杂度实际上到了O(n ^ 2),是比较低下的,那么有没有办法不进行循环?答案是有的,规律是当n大于等于7开始,3作为因子永远是最大的,即此时dp方程会变成dp[i] = 3 * dp[i - 3]
,i ∈ [7, n]。
代码②:
class Solution {
public int integerBreak(int n) {
int[] pre = {
1, 2, 4, 6, 9};
int[] dp = new int[n + 1];
for (int i = 2; i <= n && i <= 6; i++)
dp[i] = pre[i - 2];
for (int i = 7; i <= n; i++)
dp[i] = Math.max(dp[i], 3 * dp[i - 3]);
return dp[n];
}
}
但是这个规律如何得到的呢?只要我们暴力搜寻一次(因为n最大也就58,否则会超出数值),并且不是直接返回结果,而是返回因式分解,我们就能发现3确实是最佳因子。
2 [2]
3 [3]
4 [2, 2]
5 [2, 3]
6 [3, 3]
7 [2, 2, 3]
8 [2, 3, 3]
9 [3, 3, 3]
10 [2, 2, 3, 3]
11 [2, 3, 3, 3]
12 [3, 3, 3, 3]
13 [2, 2, 3, 3, 3]
14 [2, 3, 3, 3, 3]
15 [3, 3, 3, 3, 3]
16 [2, 2, 3, 3, 3, 3]
17 [2, 3, 3, 3, 3, 3]
18 [3, 3, 3, 3, 3, 3]
19 [2, 2, 3, 3, 3, 3, 3]
20 [2, 3, 3, 3, 3, 3, 3]
除此之外,因为提示也让我们找规律,所以我当时还用到了“小学生找规律法”。从n = 2开始,答案为{1, 2, 4, 6, 9, 12, 18, 27, 36, 54}。这其中有什么规律?!
答案是,当n >= 5开始,res[i]为res[i - 1]加上前面最大的偶数的一半。
举例说明,
当n == 5, 答案为4 + (4 / 2)== 6
当 n == 6,答案为6 + ( 6 / 2) == 9
当n == 7,答案为 9 + (6 / 2) == 12 (因为上一个9不是偶数,所以依然取6的一半)
当n == 8,答案为 12 + (12 / 2)== 18
当n == 9, 答案为 18 + (18 / 2) == 27
代码③:(娱乐代码)
class Solution {
public int integerBreak(int n) {
if (n == 2)
return 1;
if (n == 3)
return 2;
if (n == 4)
return 4;
int[] res = new int[n + 1];
res[2] = 1;
res[3] = 2;
res[4] = 4;
int half = res[4] / 2;
for (int i = 5; i <= n; i++) {
if (res[i - 1] % 2 == 0)
half = res[i - 1] / 2;
res[i] = res[i - 1] + half;
}
return res[n];
}
}
前言:
背包问题是算法里的经典题目,并且拥有很多变种,很多题目都可以简化成背包问题。同时背包问题的核心实质也是动态规划,是动态规划的一个典例。因此在继续总结其他题目之前,先来整体地总结一遍背包问题。
背包问题泛指以下这一种问题:
给定一组有固定价值和固定重量的物品,以及一个已知最大承重量的背包,求在不超过背包最大承重量的前提下,能放进背包里面的物品的最大总价值。
这一类问题是典型的使用动态规划解决的问题,我们可以把背包问题分成3种不同的子问题:0-1背包问题、完全背包和多重背包问题。下面对这三种问题分别进行讨论。
题目描述:0-1背包问题是指每一种物品都只有一件,可以选择放或者不放。有编号分别为a,b,c,d,e的五件物品,它们的重量分别是2,2,6,5,4,它们的价值分别是6,3,5,4,6,每件物品数量只有一个,现在给你个承重为10的背包,如何让背包里装入的物品具有最大的价值总和?
分析:这个题目看起来很简单,但其实如果想用常规的“直觉"方法是很难是解决的。如果是”贪心算法“,看起来应该是尽可能多地选取性价比较高的物品,但只选取性价比最高的物品,最后可能会导致背包的容量没有用完,导致依然不是最优解。对于这种 “直接思考有点复杂,但是具体情况都可以罗列出来” 的题目,动态规划是最好的解决方法。有5件物品,我们会犹豫到底选哪一种是最优的。如果我们逐步去考虑,只有第一件物品可选的时候会是怎么样?然后只有前两件呢?只有前三件呢?一直这样推到最后。同理,背包的承重为10,也让选择变多且复杂,同样也是从小一步一步做。如果背包的承重只有1会是怎么样?如果承重是2呢,3呢?一直推到最后。如果用 f[i][j] 来代表当只考虑前i件物品,且背包的承重为j,那么这个就是01背包问题的 子问题解空间,显然这里最后我们要获得的就是f[4][4]。那么关键是递推方程,当i增加时,情况如何变化,j变化又如何?
首先第一层循环是可选的物品从0到n - 1,每当有一个新物品,显然选择只有两个,选择,或者不选择。如果不选择,那么很简单,就是:f[i][j] = f[i - 1][j]
。那么什么情况下会选择?当然就是选择了之后,总价值会比不选择要高的时候去选择。
假设当前背包可承重为j,那么选择的总价值就是:前i - 1个物品在承重为j - weight[i]的背包下的最大价值,加上第i个物品的价值,也就是f[i - 1][j - weight[i]] + value[i]。既然要与不选择作比较,那么最后的递推方程便是:
f[i][j] = Math.max(f[i - 1][j], f[i - 1][j - weight[i]] + value[i])
就像做数学题一样,写出函数,你得考虑定义域呀,显然, j - weight[i] >= 0,因为j是第二层循环,也会一直改变的,所以只有当weight[i] <= j的情况下,才能考虑选择。
base case呢?也就是i为0的时候,如果无法选择,那么dp[i][j]就是0,可以选择就value[0],很好理解。
下面直接看代码:
代码:
// 时间O(nC), 空间O(nC), C为max_weight
public int bag_01(int[] weights, int[] values, int max_weight) {
int n = weights.length;
int[][] dp = new int[n][max_weight + 1];
for (int i = 0; i < n; i++) {
for (int j = 1; j <= max_weight; j++) {
// base case
if (i == 0) {
if (weights[i] > j)
dp[i][j] = 0;
else
dp[i][j] = values[i];
continue;
}
if (weights[i] > j)
dp[i][j] = dp[i - 1][j];
// dp[i][j + x]不可能比dp[i][j]小 (x > 0), 是递增的,所以如果当前放不下,直接获取上一个结果
else
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i]] + values[i]);
// dp[i - 1][j - weights[i]] + values[i] 前 i-1 个物品使用了j - weights[i]重量情况下的最大value
// 再加上当前第 i个物品的value。 与dp[i - 1][j]比较,实质上就是
// 不考虑选择第 i 个物品与 选择第 i 个物品的结果比较
}
}
return dp[n - 1][max_weight];
}
代码分析:n为物品的数量,C为背包最大承重量,于是这里的时间复杂度为O(nC),空间复杂度也是O(nC)。那么是否可以优化?对于递推方程:
f[i][j] = Math.max(f[i - 1][j], f[i - 1][j - weight[i]] + value[i])
每一次求解dp[i][j],只用到了上一行的值:dp[i - 1][v]和dp[i - 1][v - w[i]]。感觉直观上,i是一个多余的变量,可以去掉。于是递推方程就变成了,当不选择第i次循环的物品,dp[j]就不改变。而选择的时候就变成了:
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])
这时候代码就变成了:(有误)
// 错误的版本 。。。 完全背包
public int bag_011(int[] weights, int[] values, int max_weight) {
int n = weights.length;
int[] dp = new int[max_weight + 1];
for (int i = 0; i < n; i++) {
for (int j = 1; j <= max_weight; j++) {
if (weights[i] <= j)
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[max_weight];
}
正确答案是15,为什么会输出了30?关键在于dp[j - weights[i]]。我们这里是0-1背包问题,也就是说,每一个物品都只能选择一种。缺少了i的限制,无法保证 j1 - weights[i1] 与 j2 - weights[i2] 一定不相等。说人话就是,这样会导致 某种物品可能选择了多次。所以这里为什么是30,因为它选取了5个a物品,a物品是性价比最高的物品,重量为2,价值却达到了6,于是选择了5次,最后结果就是30,显然就是重复选取导致的错误。那么是否这个i就无法去掉呢,答案是否定的。有一个巧妙的解决方法就是:把第二层循环改成从后往前循环。
为什么这样可以避免重复选择?我们回到原本有i的递推方程:dp[i][j] = dp[i - 1][j - w[i]] +v[i]
,如果我们要直接去掉i变量,那么是否能确保dp[j - w[i]]依然等于dp[i - 1][j - w[i]]?当然不是。当我们顺序遍历的时候,我们是第i次循环,而非第i - 1次循环,并且显然,dp[j - w[i]]肯定已经在dp[j]之前已经被计算了(j从小到大计算)。所以这时候的结果会变成了dp[i][j] = dp[i][j - w[i]] + v[i]
。因为在第i次循环的时候,dp[i - 1][j - w[i]]已经被更新为dp[i][j - w[i]],i跟i之间没有任何冲突,于是也就可以重复选取。当然”重复选取“这个是操作,最关键的是在去掉某个变量的时候,要仔细观察是否真的可以直接去掉。(在这里,去掉了之后,i - 1却变成了i,导致出错)。如果逆序循环,先计算后面的,那么就能保证,在计算dp[i][j]的时候,dp[i - 1][j - w[i]]的数据肯定还没有改变,于是也就真正地忽略掉了i变量。
倒序遍历,比如从j1 - w[i]到j2 - w[i],因为j1 > j2,所以j1 - w[i] > j2 - w[i]。即当我们使用dp[j1 - w[i]]的时候,可以确保它还没改变,即它仍然是dp[i - 1][j1 - w[i]],而不是dp[i][j1 - w[i]]。(这也使得一次循环只能改变一次,即选择一次,不可重复)
代码如下:
// 时间O(nC), 空间O(C), 第二层循环要倒序
public int bag_012(int[] weights, int[] values, int max_weight) {
int n = weights.length;
int[] dp = new int[max_weight + 1];
for (int i = 0; i < n; i++) {
for (int j = max_weight; j >= 1; j--) {
if (weights[i] <= j)
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[max_weight];
}
代码再优化:其实对于这个问题,一开始我不是很懂,为什么承重也要循环遍历?后来才明白是把大背包”化为“小背包,从小到大地考虑。所以我们也看到了,当背包的V特别大的时候,比如是1000000,那我们就得从1慢慢地循环到1000000。哪怕物品只有4-5个,每一个的重量是几千,也同样需要循环这么多次。换句话说,每次+1实际上很多时候都是做无用功。解决的办法也很简单,对数组进行全排列,然后把每一个排列的和放到一个Set里,这样就无须每次都只是+1了。当然了,除非背包容量真的特别大,或者每一个循环里的操作也比较费时,否则一般也不用考虑这个问题(因为还要考虑全排列是否耗费更少的时间)。目前这里暂时不考虑。
题目描述:与0-1背包的区别是,0-1背包规定每一种物品只能选取一件,而完全背包问题可以选取任意件。
分析:上面优化0-1背包代码的时候,我们写过一个”错误版本“,导致了可以重复选择物品。显然,那就是我们想要的,所以那个就是最优的代码。那么如果暂时不考虑这个最优的代码,如果自己去分析,如何编写递推方程?同样以dp[i][j]作为解空间。显然,拿之前的来做改进:
f[i][j] = Math.max(f[i - 1][j], f[i - 1][j - weight[i]] + value[i])
当weight[i] <= j的时候,执行这条,于是就进行下一个循环,因为我们默认只能选取一个。既然能选取任意个,那么就再多增加一个循环,比如循环变量k,k从1开始,当weight[i] * k <= j,后面的都一样,于是方程为:
f[i][j] = Math.max(f[i - 1][j], f[i - 1][j - k * weight[i]] + k * value[i])
于是就有代码:
public int bag_complete(int[] weights, int[] values, int max_weight) {
int n = weights.length;
int[][] dp = new int[n][max_weight + 1];
for (int i = 0; i < n; i++) {
for (int j = 1; j <= max_weight; j++) {
int tmp = j / weights[i];
if (i == 0 && tmp == 0)
dp[i][j] = 0;
else if (i != 0 && tmp == 0)
dp[i][j] = dp[i - 1][j];
for (int k = 1; k <= tmp; k++) {
// base case
if (i == 0) {
dp[i][j] = values[i] * k;
continue;
}
dp[i][j] = Math.max(dp[i - 1][j],
dp[i - 1][j - k * weights[i]] + values[i] * k);
}
}
}
return dp[n - 1][max_weight];
}
跟0-1背包的差别其实并不大,不过还是要记住,最佳代码并不是这个,是上面0-1背包的那个错误版本。二者最关键的是第二层循环的顺序。
题目描述:与0-1背包和完全背包的区别是,每一种物品的个数即不是1也不是任意,而是一个特定的值。即物品i最多可以拿num[i]个。
分析:递推方程显然和完全背包是差不多的,只是k的范围变得更加明显。所以递推方程为:
f[i][j] = Math.max(f[i - 1][j], f[i - 1][j - k * weight[i]] + k * value[i])
(0 <= k <= num[i])
代码就不贴了,跟上面的几乎一样。但是显然,完全背包问题的时候,就已经透露出,如果不进行简化,那么时间复杂度就为O(nCM),空间复杂度同理。而多重背包也是一样的,于是需要简化。显然,可以把每个物品,无论相同与否,都遍历一遍,这样就直接把多重背包转换成了0-1背包。时间复杂度变为了O(sum(n) * C)
题目描述: 给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。题目链接
分析:5个月前做这道题,一头雾水,毫无思绪。然而分割,实际上就是“选择”,把一部分选择出来,没被选择的就是另一部分,一共形成两个子集。所以这道题可以转换为0-1背包问题。物品(数字)的”价值)显然就是数值,最后我们要获得和为总和的一半,那么这个就是“背包的承重量”了。当然这道题还有一个要求,背包一定要装满。
于是使用dp[i][j]来表示使用前面i个数字的时候,是否可以组成和为j。递推方程同样是,第i个数字有两种情况,选择与不选择。只要有任意一种可行,那么就可行,所以运用到逻辑或运算符。递推方程如下:
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]] (nums[i] <= j)
那么如果nums[i] > j,显然就只有一种选择,此时就是dp[i][j] = dp[i - 1][j]
base case同样很简单。于是我们可以获得代码(很典型的背包问题):
代码:
class Solution {
// dp[i][j]表示靠前i个数字是否能组成和为j. 1表示可行
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num: nums)
sum += num;
if (sum % 2 != 0)
return false;
int weight = sum / 2;
boolean[][] dp = new boolean[nums.length][weight + 1];
for (int i = 0; i < nums.length; i++) {
for (int j = 1; j <= weight; j++) {
if (i == 0) {
dp[i][j] = nums[i] == j;
continue;
}
if (nums[i] <= j)
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
else
dp[i][j] = dp[i - 1][j];
}
}
return dp[nums.length - 1][weight];
}
}
同样,与背包问题同理,可以把i变量去掉,转换第二轮的遍历顺序,于是又有:
改进后的代码:
class Solution {
// dp[i][j]表示靠前i个数字是否能组成和为j. 1表示可行
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num: nums)
sum += num;
if (sum % 2 != 0)
return false;
int weight = sum / 2;
boolean[] dp = new boolean[weight + 1];
for (int i = 0; i < nums.length; i++) {
for (int j = weight; j >= 1; j--) {
if (i == 0) {
dp[j] = nums[i] == j;
continue;
}
if (nums[i] <= j)
dp[j] = dp[j] || dp[j - nums[i]];
else
dp[j] = dp[j];
if (j == weight && dp[j]) // 提前终止循环
return true;
}
}
return dp[weight];
}
}
此时时间复杂度为O(nC),空间复杂度为O©,C为数组总和的一半。在改进前,代码运行需要42ms,而改进后只需要16ms。其实这段代码还可以继续优化,为什么第二轮的循环条件是j >= 1,实际上,只要当j小于nums[i]的时候,已经不需要考虑。因为我们最后要的数据是dp[weight],所以前面dp[1]还是dp[3]的数据断掉一点,对结果不会产生任何影响。同理,base case也没必要那样处理,直接把dp[0]设置为true即可。因为base case是,nums[i]是否在承重范围内,然而循环决定了,它一定存在。(所以上面的代码实际上,那个base case的处理是有一点问题的!套模板痕迹太明显!实际上在循环外设置dp[0] = true即可,然后循环从i 等于1开始。当然你想还是从0开始也是一样的。) (因为dp[j]只与dp[j - nums[i]]有关,所以可优化成 j >= nums[i] )
最终代码:
class Solution {
// dp[i][j]表示靠前i个数字是否能组成和为j. 1表示可行
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num: nums)
sum += num;
if (sum % 2 != 0)
return false;
int weight = sum / 2;
boolean[] dp = new boolean[weight + 1];
dp[0] = true;
for (int i = 1; i < nums.length; i++)
for (int j = weight; j >= nums[i]; j--)
dp[j] = dp[j] || dp[j - nums[i]];
return dp[weight];
}
}
题目描述:给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...
)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。题目链接
分析:最简单的循环DP,通过新增一个循环层,把数字n分解成n - k * k (n - k * k > 0),通过循环获得最小值。
递推方程为: dp[i] = Math.min(dp[i], dp[i - k * k] + 1)
i >= k * k
代码:
class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1];
for (int i = 1; i <= n; i++) {
int val = i;
for (int k = 1; k * k <= i; k++) {
val = Math.min(val, dp[i - k * k] + 1);
}
dp[i] = val;
}
return dp[n];
}
}
题目描述:一条包含字母 A-Z 的消息通过以下方式进行了编码:题目链接
'A' -> 1
'B' -> 2
...
'Z' -> 26
给定一个只包含数字的非空字符串,请计算解码方法的总数。
示例 1:
输入: "12"
输出: 2
解释: 它可以解码为 "AB"(1 2)或者 "L"(12)。
示例 2:
输入: "226"
输出: 3
解释: 它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。
分析:很容易想到用DP,并且状态量也很直观:使用dp[i]表示前i个字符的解码数。递推方程也不难想,当第i个字符可以与第i - 1个字符相连解码,那么这种情况就是dp[i - 2],否则就是dp[i - 1](不用考虑i - 1是否能与i - 2相连,因为dp[i - 1]就已经是确凿的结果)。情况就这两种,所以就是直接相加,状态转移方程就是:
dp[i] = dp[i - 1] + dp[i - 2]
不过这道题难点在于特殊情况处理。0前面必须是1或者2才能进行解码,如果首字符就是0,也不行。
代码:
class Solution {
public int numDecodings(String s) {
int length = s.length();
int[] dp = new int[length + 1];
//dp[i]表示前i个字符的解码数(长度增加了1以便处理base case)
dp[0] = 1;
dp[1] = 1;
char prev = s.charAt(0);
if (prev == '0') // 开头为0,无法解码
return 0;
char current = s.charAt(0);
for (int i = 2; i <= length; i++) {
prev = current;
current = s.charAt(i - 1);
if (prev >= '3' && current == '0' || prev == '0' && current == '0')
return 0; // 0前面不是1或2,无法解码
if (prev >= '3' || (prev == '2' && current >= '7') || prev == '0')
dp[i] = dp[i - 1]; // 第i个字符无法与第i - 1个连起来解码,所以解码数相同
else if (current == '0')
dp[i] = dp[i - 2];
// 0必须与前一个数字结合,所以如果第i - 2个字符与i - 1个字符可以连起来,需要去掉连起来的情况
// 即直接跳过dp[i - 1]的数据.
else
dp[i] = dp[i - 1] + dp[i - 2];
// 如果i与i - 1结合,那么就是dp[i - 2],反之就是dp[i - 1],总数就是加起来.
}
return dp[length];
}
}
题目描述:给定一个无序的整数数组,找到其中最长上升子序列的长度。题目链接
分析:像这种可以把问题划分为子问题逐步推进的,都可以化为DP解决。想状态量的时候,很容易会想成:使用dp[i]表示前i个元素的最长上升子序列的长度。但是在寻找dp[i]与dp[i + 1]的关系却毫无头绪,说明这道题不适合DP或者DP状态量选取不恰当。这让我联想到了413题的寻找等差数列数量那道题。其实寻找状态量最重要的是可以建立起联系,当我们新增了一个元素,会与前面的元素进行什么样的联动?答案是,与前面每一个子序列进行连接,查看哪个长度最大。当然,连接也有条件,就是当前第i个元素必须要大于前面选取子序列的最后一个元素。所以状态量自然也可以改为:使用 dp[i]来表示以 nums[i]结尾的最长上升子序列的长度。这样成功地保存了子序列最后一个元素的信息。递推方程也很直观,如下:
dp[i] = Math.max(dp[i], dp[j] + 1)
j ∈ [0, i)
记住返回的不是dp[n],而是dp[i]的最大值,因为最长上升子序列不一定是以最后一个元素结尾。
代码:
class Solution {
public int lengthOfLIS(int[] nums) {
int[] dp = new int[nums.length];
int max = 1;
if (nums.length == 0)
return 0;
dp[0] = 1;
for (int i = 1; i < nums.length; i++) {
int val = 1;
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i])
val = Math.max(val, dp[j] + 1);
}
dp[i] = val;
max = max >= val ? max : val;
}
return max;
}
}
这样就能得到时间复杂度为O(n ^ 2)的DP算法。虽说是O(n ^ 2),但比起直接普通的数学解法还是要快很多。但是题目说要求使用O(nlogn)的解法,显然,就是第二层的循环不是顺序遍历,而是改成二分法。显然,我们要遍历前面的部分要寻找的最佳dp[j],用通俗的话来描述就是:寻找一个尽可能大的dp[j],并且它的结尾要比nums[i]小。所以我们可以对数组dp稍微改一下定义,改成:dp[i]表示子序列的长度为i,结尾是dp[i] 。很容易证明dp[i]是一个递增数组,也就是有序,所以就能改用二分法来进行搜索了。代码如下:
class Solution {
public int lengthOfLIS(int[] nums) {
int[] dp = new int[nums.length];
// dp[i]表示长度为i+1的上升子序列的结尾,dp[i]显然是递增数组
int max = 0;
if (nums.length == 0)
return 0;
for (int i = 0; i < nums.length; i++) {
int left = 0, right = max; // 此时dp[right]为0
while (left < right) {
// 二分结束时,left == right
int mid = (left + right) / 2;
if (dp[mid] < nums[i]) // 二分搜索,一直找到不大于nums[i]的最大数字
left = mid + 1;
else
right = mid;
}
dp[right] = nums[i];
if (right == max) // 如果此时的right是最右,那么实际上就长度+1
max++;
}
return max;
}
}
这样代码效率快了很多。时间复杂度为O(nlogn)。二分搜索寻找的是不大于nums[i]的最大数字,只有当right为最右,也就是二分搜索的时候一直改变的是left,最终的长度才会+1。否则都是一直在改变前面的数据,并且当数字更小的时候(无法递增,这时候会进行dp[right] = nums[i]
,也就是把使得dp数组保存的是当前长度的最小结尾。如果数字足够大,使得长度增加1,那么right此时是最右,所以也会在正确的地方修改dp[right]。记住,搜索区间为[left, right),左闭右开,而right被初始化为right = max
,即一般不会修改dp[max],但当right == max一直无改变,此时长度+1。
题目描述: 给出 n 个数对。 在每一个数对中,第一个数字总是比第二个数字小。题目链接
现在,我们定义一种跟随关系,当且仅当 b < c 时,数对(c, d) 才可以跟在 (a, b) 后面。我们用这种形式来构造一个数对链。
给定一个对数集合,找出能够形成的最长数对链的长度。你不需要用到所有的数对,你可以以任何顺序选择其中的一些数对来构造。
分析:看起来和上一题一模一样,直接套同样的代码稍作修改,发现出错。因为上一题寻找的是递增子序列,所以数组的顺序是固定的,不能修改。而这道题是可以从后面的数字开始先行,并非是“子序列”的概念,所以我们还需要多加一步排序的操作。(这题不是子序列问题,实质不适合DP)
代码:
class Solution {
public int findLongestChain(int[][] pairs) {
int[] dp = new int[pairs.length];
if (pairs.length == 0)
return 0;
Arrays.sort(pairs, new Comparator<int[]>() {
@Override
public int compare(int[] ints1, int[] ints2) {
int tmp = ints1[1] - ints2[1];
if (tmp != 0)
return tmp;
return ints1[0] - ints2[0];
}
});
dp[0] = 1;
int max = 1;
for (int i = 1; i < pairs.length; i++) {
int val = 1;
for (int j = 0; j < i; j++) {
if (pairs[j][1] < pairs[i][0])
val = Math.max(val, dp[j] + 1);
}
dp[i] = val;
max = max >= val ? max : val;
}
return max;
}
}
确实也成功了,只是效率很低,其实看到需要额外排序的时候基本就猜到了。而且实际上,排序完成了之后,都没有必要用DP了,直接贪心算法就能更高效地解决。所以先多思考各种办法,不要只想着套模板。
贪心算法代码:
class Solution {
public int findLongestChain(int[][] pairs) {
if (pairs.length == 0)
return 0;
Arrays.sort(pairs, new Comparator<int[]>() {
@Override
public int compare(int[] ints1, int[] ints2) {
int tmp = ints1[1] - ints2[1];
if (tmp != 0)
return tmp;
return ints1[0] - ints2[0];
}
});
int res = 1, tmp = pairs[0][1];
for (int i = 1; i < pairs.length; i++)
if (pairs[i][0] > tmp) {
tmp = pairs[i][1];
res++;
}
return res;
}
}
题目描述:如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为摆动序列。第一个差(如果存在的话)可能是正数或负数。少于两个元素的序列也是摆动序列。题目链接
例如, [1,7,4,9,2,5] 是一个摆动序列,因为差值 (6,-3,5,-7,3) 是正负交替出现的。相反, [1,4,7,2,5] 和 [1,7,4,5,5] 不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。
给定一个整数序列,返回作为摆动序列的最长子序列的长度。 通过从原始序列中删除一些(也可以不删除)元素来获得子序列,剩下的元素保持其原始顺序。
分析:根据前面的经验,我们已经知道这种类似于“序列”,“数组”等等的DP题,状态量是不能直接设定为dp[i]表示前i个元素的最长摆动序列长度。因为这种“序列”不一定取第i个元素做结尾,导致我们无法确定dp[i]与dp[i - 1]的关系。也许dp[i - 1]的值是与dp[i - 2]有关联,但dp[i]与dp[i - 1]无关联等等。
于是我们很容易又会想到另一种状态量,定义dp[i]为以nums[i]结尾的最长摆动序列的长度。然而在寻求转移方程的时候,遇到了一个问题:摆动序列有两种情况,如果前面两个元素是递减,那么当前就要递增,反之同理。然而发现状态转移方程还是不直观,感觉应该要分情况,到底之前最后两个元素是递减,还是递增。于是一开始的想法是使用二维数组来存储状态量,并且第二个值为0或1。因为第二个值只有两种取值可能,所以也有更直观的状态量表达法:使用两个一维数组去存储状态量,一个up[i]数组表示最后是上升的摆动序列,一个down[i]数组表示最后是下降的摆动序列,并且都是指以nums[i]为结尾的序列。
使用了两个数组,状态转移方程也直观了起来。我们可以再对i前面进行一次循环,逐个比较。如果nums[i]大于nums[j],说明我们要更新up[i],那么这个更新的值应该是up[i]与down[j] + 1之间的最大值,因为我们假设的是倒数第二个元素是nums[j],最后一个元素是nums[i]。实际上这时候的转移方程便是:
up[i] = Math.max(down[j] + 1, up[i])
( j < i )
down[i] = Math.max(up[j] + 1, down[i])
( j < i )
代码:
class Solution {
public int wiggleMaxLength(int[] nums) {
if (nums.length <= 1)
return nums.length;
int[] up = new int[nums.length];
int[] down = new int[nums.length];
for (int i = 1; i < nums.length; i++)
for (int j = 0; j < i; j++)
if (nums[j] < nums[i])
up[i] = Math.max(up[i], down[j] + 1);
else if (nums[j] > nums[i])
down[i] = Math.max(down[i], up[j] + 1);
return 1 + Math.max(down[nums.length - 1], up[nums.length - 1]);
// 易证,虽然最长摆动序列不一定以nums[nums.length - 1]结尾,但down跟up都是非递减数组.
}
}
时间复杂度为O(n^2),空间复杂度为O(n),效率并不理想。这段代码有一个特别的点,等于的时候为什么不需要处理?其实也可以处理,增加一个up[i] = Math.max(up[i], up[j]), down[i] = Math.max(down[i], down[j])
即可,但没有必要,比如up[j]为3,那么up[j]前面的那两个数字,一定可以与nums[j]组成3,而nums[i] == nums[j]
,因而这两个数肯定也能与nums[i]组成3。但这时候有一个细节,一定要把加1放在最后return,为什么?有的朋友可能会在循环之前把dp[0]跟down[0]直接设置为1,最后的return不加1,然后发现结果会出错。看了测试用例就知道了,比如[0, 0, 0],如果不处理等于的情况,并且如果整个数组的元素都相同,就导致了最后的up[nums.length - 1]为0,down同理。而把加1放在return,避免了这种情况。
可是,既然我们知道了up跟down数组是非递减数组,为什么还要嵌套一层循环?实际上我们只需要一层线性遍历,然后用up[i]表示前面i个元素的最长摆动序列(最后是上升)的长度,down[i]同理。可是直觉上,这样定义的话这个摆动序列的结尾不一定是nums[i],那么拿下一个元素nums[i + 1]与nums[i]进行比较有什么意义?有意义,下面拿一个具体例子来说明:
比如测试用例:[5, 10, 5, 10, 20, 17]
这时候up[3]为4,那么up[4]为多少?也是4。在i为3的时候,我们会认为当时的上升摆动序列是[5, 10, 5, 10],那么i为4的时候,因为10 < 20,所以up[4]同样是4,但这时候上升摆动序列不是以20结尾应该怎么办?很简单,直接把最后一个元素变成20,这时候上升摆动序列就变成了[5, 10, 5, 20]。如果nums[i + 1]小于nums[i](也就是这里的20),那么显然我们要更新down,即down[i] = up[i - 1]) + 1
。
如果nums[i]比nums[i - 1]还要小该怎么办?比如现在的测试用例是:[5, 10, 5, 10, 7, 6]
既然nums[i] < nums[i - 1]
,nums[i + 1] < nums[i]
,那么很显然,nums[i + 1] < nums[i - 1]
,所以此时是一样的。而对于down数组也是同理,因此我们只需要考虑与前一个数的比较就行。但是在每一次比较的时候,up跟down都要进行处理。如果无须+1,那么就是获取上一个值。记住,此时up跟down表示的状态量是:前 i个元素的最长上升/下降摆动序列的长度。同时记得要处理等于的情况,因为前面刚说了,“如果无须+1,那么就是获取上一个值”。
改进后的线性 DP:
class Solution {
public int wiggleMaxLength(int[] nums) {
if (nums.length <= 1)
return nums.length;
int[] up = new int[nums.length];
int[] down = new int[nums.length];
for (int i = 1; i < nums.length; i++)
if (nums[i - 1] < nums[i]) {
up[i] = down[i - 1] + 1;
down[i] = down[i - 1];
}
else if (nums[i - 1] > nums[i]) {
down[i] = up[i - 1] + 1;
up[i] = up[i - 1];
}
else {
down[i] = down[i - 1];
up[i] = up[i - 1];
}
return 1 + Math.max(down[nums.length - 1], up[nums.length - 1]);
}
}
去掉了嵌套循环,也就无须多余的Math.max(…)了。效率也大大提升,时间复杂度为O(n),空间复杂度为O(n)。这时候我们注意到,up[i]跟down[i],只与up[i - 1]和down[i - 1]有关,所以我们还能对空间进一步的优化,只需要常量的空间来存储状态量。代码如下:
空间优化的线性 DP:
class Solution {
public int wiggleMaxLength(int[] nums) {
if (nums.length <= 1)
return nums.length;
int up = 0, down = 0;
for (int i = 1; i < nums.length; i++)
if (nums[i - 1] < nums[i])
up = down + 1;
else if (nums[i - 1] > nums[i])
down = up + 1;
return 1 + Math.max(down, up);
}
}
此时时间复杂度为O(n),空间复杂度为O(1)。代码无比简洁,效率也非常高。这也是LeetCode最高票赞同的答案。如果你对DP不熟练,那么看到这种代码的时候会觉得很吃惊,为什么自己想了半天,在别人眼里竟然“就这 ”?!实际上这也是DP的逐步优化得到的结果,我们这一步一步的简化,这时候看这段代码就清晰很多。当然,如果熟练到一定程度,那估计就是直接水到渠成,一步到位。如果不懂DP思想,即使花了很多精力看懂这段代码的运作机制,下次遇到这种题也是写不出来的。
题目描述:给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列。题目链接
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。
分析:这是一道很经典的题目,Longest Common Subsequence,简称 LCS 。它的解法是典型的二维DP,大部分比较困难的字符串问题都和这个问题一个套路,所以也有的直接称为LCS算法。关于子序列类型的问题,穷举出所有可能的结果基本不可能,而DP的实质其实就是穷举+剪枝。因而涉及子序列相关的问题,都可以考虑使用DP来解决。确定了使用DP,首先要想的是定义状态量。如果是使用一维数组,很容易想到是:dp1[i]表示text1前i个字符的LCS,dp2[i]表示text2前i个字符的LCS。但是这时候我们发现,LCS是要同时考虑两个字符串的,也就是说,dp1[i]与dp2[i]的取值有关系。最直观的,当text2为空,此时dp1[i]肯定为0,无论i取值多少。因为二者是有密切的联系,因而这里需要用的是二维数组来存储状态量:使用 dp[i][j]来表示 text1[0, i]与 text2[0, j]的 LCS。
这里可能会有疑惑,为什么上一题是用两个一维数组,这里一定要用二维数组?因为上一题的两个一维数组实质是独立的,只是在递推改变的时候,使用到另一个数组的值而已,但它们各自是没有影响的,比如up[i]可以单独脱离down[i]存在,而这里只有一个dp1[i]或者dp2[j],没有任何意义,因为这里是最长 公共 子序列。
寻找完状态量,先定义base case。正如前面所说的,如果其中一个的长度为0,那么LCS为0。我们可以把i跟j从1开始,1表示text[0],那么当i或j为0,就表示该字符串为空。
接下来是最关键的状态转移方程。状态转移有两个核心:①状态量的具体含义②状态量的改变情况。
首先LCS的定义,先不考虑“最长”,也就是公共子序列,那么LCS里的字符有什么特征?答:每一个字符都同时出现在text1跟text2之中。那么字符与text1跟text2有哪些可能性?答:字符可能在text1中,也可能不在。text2同理。听起来似乎有点废话,但实际上状态转移就是选择的过程,就像背包问题的选择与不选择。而这里的“选择”就是,一个字符是否存在于text1中,是否存在于text2中。
那么状态量会如何改变?假设dp[i][j],这时候最后的字符为text1[i]与text2[j]。它们有两种情况:
① text1[i] == text2[j] ② text1[i] != text2[j]
对于情况①,显然某个字符同时存在与text1[0, i]和text2[0, j]之中,那么这个字符肯定可以添加到LCS中。但我们知道,并不是说可以添加就一定要添加,比如“abcd"与”bcda“,不能因为a的存在,直接就认定a一定在LCS当中,也就是”可以添加但没必要“。但是我们要注意,我们比较的是最后一个字符,也就是这时候我们考虑的情况会是”a“与”bcda”的LCS,那么这时候的dp[1][4]的LCS是否有“a”?显然易见,就是a。因为是最后一个字符,所以这时候新增的最后两个字符,一定是可以添加,并且一定会存在于LCS当中。所以这时候的状态转移就是前面i - 1与j - 1的LCS,再加上最后的字符。即:d[i][j] = dp[i - 1][j - 1] + 1
。如果i不变,当j增加的时候,出现了前面存在的字符呢?比如这时候的例子是“abcd"与"bcaa"。我们知道"a"与"bca"的LCS是”a“,长度为1。那么"a"与"bcaa"的LCS呢?答案同样是1,因为我们的状态转移是默认 把两个 text的最后相同字符都去掉,再考虑前面的 LCS长度加上1,所以这里的情况会是:"“与"bca"的LCS,再加上"a"与"a”,即1。
所以我们得到了情况①的状态转移方程:
dp[i][j] = dp[i - 1][j - 1] + 1
when text1[i] == text2[j]
对于情况②,如果最后的两个字符不相同,即至少有一个不在LCS中,那么我们就需要丢弃一个。为什么?因为text1[i]与text2[j]已经不可能作为LCS新增加的1长度了,所以显然:
dp[i][j]不会比 dp[i - 1][j],dp[i][j - 1],dp[i - 1][j - 1]的最大值要大。
又显然,dp[i][j]不可能比 dp[i - 1][j],dp[i][j - 1],dp[i - 1][j - 1]小。你text1跟text2新增了字符,也许LCS不会变大,但一定不会变小,很好理解。
综合以上两条信息,可以得出:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
同时显然,dp[i - 1][j - 1]肯定是三者之间最小的,就跟上面第二条信息一样,所以再简化一下就是状态转移方程:
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
when text1[i] != text2[j]
代码:
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length(), length2 = text2.length();
int[][] dp = new int[length1 + 1][length2 + 1];
for (int i = 0; i <= length1; i++)
for (int j = 0; j <= length2; j++)
if (i * j == 0)
dp[i][j] = 0; // base case
else 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[length1][length2];
}
}
因为dp的长度增加了1,使用dp[0][j]跟dp[i][0]表示其中之一为空字符的情况。
所以i等于1时,表示的是text1[0, i - 1],所以代码里是charAt(i - 1),很好理解。
时间复杂度就是可行解空间的大小,也就是状态量,即 O(m * n) ,m和n分别为length1和length2。
题目描述:给定一个非负整数数组,a1, a2, …, an, 和一个目标数,S。现在你有两个符号 +
和 -
。对于数组中的任意一个整数,你都可以从 +
或 -
中选择一个符号添加在前面。题目链接
返回可以使最终数组和为目标数 S 的所有添加符号的方法数。数组非空,且长度不会超过20。初始的数组的和不会超过1000。
分析:每一个数字都可以有两种选择,+或者-。要遍历所有的情况就需要O(2 ^ n)的复杂度,显然不现实,因而用DP进行剪枝会是不错的选择。当然这题也可以用DFS解决,不过这里还是先考虑DP。首先一定要有一个状态变量是i,表示前面i个字符。然后还有两个变量,就是当前的和,以及组成该和的方案数(方案显然不唯一)。于是我们考虑使用二维数组来代表状态量:使用 dp[i][j]表示前 i 个数字,组成和为 j 的方案数。
至于状态转移方程,因为选择有两种,加或者减,很像0-1背包问题,于是可以用0-1背包问题的思维来解决。把每一位数字视为物品,而总和 j 视为容量,于是当改变 j 的时候,对于最新的物品(数字)nums[i],有两种选择,加或者减。加的情况下,就是前面 i - 1个数字要组成了和为 j - nums[i],减的情况就是前面 i - 1个数字组成了和为j + nums[i]。于是可得状态转移方程:
dp[i][j] = dp[i - 1][j - nums[i]] + dp[i - 1][j + nums[i]]
值得注意的是,j的取值范围应该是[-1000, 1000],因为和最大为1000,同理最小为-1000,这些都是要考虑的,不能只考虑[0, 1000],因为也许中间有一个数比较大,这时候就要求前面的和为负数。
然后关键是,j有可能是负数,所以我们需要增大数组的范围,以免因为索引为负数导致出错。从转移方程可以看出来,比如index为 j - nums[i],假设j最小为-1000,nums[i]最大为1000,这时候就是-2000,所以最起码要加2000。负的范围有2000,正的范围也是2000,所以dp数组长度可以直接定为4001。
base case,值得注意的是,base case不能简单地设置为dp[0][nums[0]] = 1, dp[0][-nums[0]] = 1,为什么?
这里要考虑0的情况,+0跟-0的效果是一样的,而且都要计算。在上面的转移方程,显然不会漏算。而在base case,这样写会导致第一个是0的时候会漏算,因而需要做一个小小的改动。
class Solution {
public int findTargetSumWays(int[] nums, int S) {
if (S > 1000)
return 0;
int[][] dp = new int[nums.length][4001];
dp[0][2000 + nums[0]] = 1;
dp[0][2000 - nums[0]] += 1; // 非0的时候相当于==1, 0的时候会赋值为2
for (int i = 1; i < nums.length; i++)
for (int j = -1000; j <= 1000; j++)
dp[i][2000 + j] = dp[i - 1][2000 + j - nums[i]] + dp[i - 1][2000 + j + nums[i]];
return dp[nums.length - 1][S + 2000];
}
}
时间复杂度为O(nm),空间复杂度为O(nm)。这道题跟0-1背包比较相似,唯一的区别是,0-1背包,如果选择就是直接加,而不选择就不改变,但这里的”不选择“是减,这就使得情况有所不同。那么我们是否能把问题转变一下,比如最后的和为S,实际上前面会有两部分,一部分是sumA,全是加号的情况,另一部分是sumB,全是减号的部分。最后我们要达到的目的就是 sumA + sumB == S。当我们要求和为S的时候,对sumA有什么要求?这里有一个很巧妙的转变方法:(既然sumA跟S都不能去掉,那么就是努力消去sumB)
① sumA + sumB == S,我们的目标
② sumA - sumB == sum,也就是全部都为加号,最后就是整个数组的总和sum
两式相加,可得:sumA = (S + sum) / 2。
这时候问题就转变成了,有n个物品,每个物品的价值为nums[i],可以拿走或者不拿走,问有多少种办法使得拿走的总价值为(S + sum) / 2。这就是典型的背包问题,同时因为有除法,因而如果有小数,说明就无法构造成整数的(S + sum) / 2。这时候的状态量为:使用 dp[i]表示组成和为 i的情况
第k个数字同样只有选择,和不选择的情况,无须考虑复杂的减法。因此直接把两种情况相加即可,所以可得状态转移方程:dp[i] = dp[i] + dp[i - nums[k]]
(dp[i]表示不选择第k个数字的情况,dp[i - nums[k]]表示选择第k个数字的情况)
base case,dp[0] = 1,即不选择。
改良后的代码:
class Solution {
public int findTargetSumWays(int[] nums, int S) {
int sum = 0;
for (int num: nums)
sum += num;
int tmp = (sum + S) / 2;
if (sum < S || (sum + S) % 2 != 0)
return 0;
int[] dp = new int[tmp + 1];
dp[0] = 1;
for (int i = 0; i < nums.length; i++)
for (int j = tmp; j >= nums[i]; j--)
dp[j] += dp[j - nums[i]]; // 转变成了只有+的情况
return dp[tmp];
}
}
此时把二维数组转换成了一维数组,因而空间复杂度为O(m)
然后尝试了一下用递归暴力解决,竟然也能通过,当然效率感人:
暴力递归:
class Solution {
public int findTargetSumWays(int[] nums, int S) {
return dfs(nums, 0, S);
}
public int dfs(int[] nums, int start, int S) {
if (start == nums.length) // reach the end
return S == 0 ? 1 : 0; // S == 0 means sum is finally S.
return dfs(nums, start + 1, S + nums[start]) +
dfs(nums, start + 1, S - nums[start]);
}
}
DP就是带有备忘录和剪枝的穷举。与普通暴力穷举不同,它穷举的都是可行解,因而它的效率会提升很多。
题目描述:现在,假设你分别支配着 m 个 0
和 n 个 1
。另外,还有一个仅包含 0
和 1
字符串的数组。
你的任务是使用给定的 m 个 0
和 n 个 1
,找到能拼出存在于数组中的字符串的最大数量。每个 0
和 1
至多被使用一次。题目链接
分析:这道题显然就是常规的0-1背包问题,只是它的限制从1个增加到了2个。之前我们用一维数组来表示限制(背包的负重),那么现在用二维数组来表示限制即可。这里定义状态量:使用 dp[i][j]表示当有 i 个 0元素可用,以及有 j 个 1元素可用时,能构造的字符串数量。
状态转移方程也很显然,同样也是选择是否要拼第i个字符串,然后在循环的过程中获得最优解。如果对着0-1背包优化后的状态转移方程,会发现一模一样,只是"value"变成了+1,因为我们求的是能拼成的字符串数量。
状态转移方程: dp[j][k] = Math.max(dp[j][k], dp[j - nums[i][0]][k - nums[i][1]] + 1)
i从0~length - 1循环。nums[i][0]与nums[i][1]分别表示第i个字符串的0和1的个数。
代码:
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int[][] nums = new int[strs.length][2];
for (int i = 0; i < strs.length; i++) {
String str = strs[i];
for (char c: str.toCharArray())
nums[i][c - '0']++;
}
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i < strs.length; i++)
for (int j = m; j >= nums[i][0]; j--)
for (int k = n; k >= nums[i][1]; k--)
dp[j][k] = Math.max(dp[j][k], dp[j - nums[i][0]][k - nums[i][1]] + 1);
return dp[m][n];
}
}
0-1背包有很多变种,获得最大价值,获得一定价值的可行解数量。变化都不大,关键在于明确状态量的含义,不能死记硬背。可以参考这篇背包 9讲,理清楚背包问题的思路。
题目描述:给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1
。你可以认为每种硬币的数量是无限的。题目链接
分析:典型的完全背包问题,只是要求背包必须装满,并且要求背包中的物品数目最少。我们先用最直观但低效的状态量:使用 dp[i][j]表示前 i 种硬币凑成总金额为 j 的硬币个数。可得状态转移方程:
dp[i][j] = Math.min(dp[i][j], dp[i][j - coins[i] * k] + k)
( k * coins[i] <= j)
因为是取min,所以初始值一起要处理好,否则会导致很多0的情况。
base case我们先对 i == 0 的情况讨论,易得,如果总和 j可以整除 i,便能凑出来,反之不行。但这里无解的情况下不能赋值为0,应该赋值为无限大。当然根据题意,赋值为amount + 1也已经足够。
同时,显然 dp[i][j]不会比 dp[i - 1][j]要小,所以我们可以在循环进行状态转换之前,对当前数据预处理。
而且这里无须处理 j == 0的情况,因为 dp[i][0]的值就是0,看清楚状态量的含义。
代码 ①:
class Solution {
public int coinChange(int[] coins, int amount) {
if (coins.length == 0)
return 0;
int[][] dp = new int[coins.length][amount + 1];
for (int i = 0; i < coins.length; i++)
for (int j = 1; j <= amount; j++) {
if (i == 0) {
dp[i][j] = j % coins[i] == 0 ? j / coins[i] : amount + 1;
continue;
}
dp[i][j] = dp[i - 1][j]; // 避免0的情况
for (int k = 1; k * coins[i] <= j; k++)
dp[i][j] = Math.min(dp[i][j], dp[i][j - coins[i] * k] + k);
}
return dp[coins.length - 1][amount] == amount + 1 ? -1 : dp[coins.length - 1][amount];
}
}
这时候,时间复杂度为O(n * m ^ 2),空间复杂度为O(n * m),实际上是效率非常低下的情况。同时,这个状态量显然与i的关系也不大,于是可以把状态量中的i去掉,此时:使用 dp[i]表示凑成总金额为 i的最少硬币数。
状态转移方程: dp[j] = Math.min(dp[j], dp[j - coins[i] * k] + k)
这时候去掉了i,没有了dp[i][j] = dp[i - 1][j]
这个语句,也不会有影响,因为我们已经消去了i的影响,上面正是因为增加了i的影响,才导致必须清0的情况。而这里在base case处理 i == 0 的时候就已经不会为0了。(dp[0]不会为0)
代码②:(空间优化)
class Solution {
public int coinChange(int[] coins, int amount) {
if (coins.length == 0)
return 0;
int[] dp = new int[amount + 1];
for (int i = 0; i < coins.length; i++)
for (int j = 1; j <= amount; j++) {
if (i == 0) {
dp[j] = j % coins[i] == 0 ? j / coins[i] : amount + 1;
continue;
}
for (int k = 1; k * coins[i] <= j; k++)
dp[j] = Math.min(dp[j], dp[j - coins[i] * k] + k);
}
return dp[amount] == amount + 1 ? -1 : dp[amount];
}
}
此时时间复杂度仍然为O(n * m ^ 2),空间复杂度为O(m),时间复杂度依然很高。实际上,正如完全背包的最优解,k这个变量也是可以省去的,在顺序遍历j的时候,实际上就已经包含了可以重复选择的情况。
这时候的初始化也要注意,dp[0]要定义为0,其余的dp[x]都定义为无限大(amount + 1)。如果dp[0]不设置为0,那么后面的循环就无法进行。base case也无须特殊处理,当 i == 0,实际上也是dp[j]循环到dp[0]的情况。
这时候的状态转移方程:dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1)
代码③:
class Solution {
public int coinChange(int[] coins, int amount) {
if (coins.length == 0)
return 0;
int[] dp = new int[amount + 1];
for (int i = 1; i < dp.length; i++) // begin index is 1
dp[i] = amount + 1;
for (int i = 0; i < coins.length; i++)
for (int j = coins[i]; j <= amount; j++)
dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
return dp[amount] == amount + 1 ? -1 : dp[amount];
}
}
这时候时间复杂度为O(n * m),空间复杂度为O(m)。如果在LeetCode运行一下这三段代码,就知道效率的差距会有多大了。
题目描述:给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。 题目链接
分析:同样先构建最直观的状态量:使用 dp[i][j]表示使用前 i 个硬币组成金额为 j 的组合数。对于第 i 个硬币同样只有选择与不选择的可能。于是可得状态转移方程:
dp[i][j] += dp[i - 1][j - k * coins[i]]
k >= 0 && k * coins[i] <= j
base case:当 i 为0,显然dp为0。但当 j 为0,dp为1,且 dp[0][0] == 1
代码①:
class Solution {
public int change(int amount, int[] coins) {
int[][] dp = new int[coins.length + 1][amount + 1];
dp[0][0] = 1;
for (int i = 1; i <= coins.length; i++) {
for (int j = 0; j <= amount; j++) {
if (j == 0) {
dp[i][j] = 1;
continue;
}
for (int k = 0; k * coins[i - 1] <= j; k++)
dp[i][j] += dp[i - 1][j - k * coins[i - 1]];
}
}
return dp[coins.length][amount];
}
}
时间复杂度为O(n * m ^ 2),空间复杂度为O(n * m)。跟上一题一样,时间和空间效率都不高,于是我们继续改进。首先状态量中的 i 变量显然也可以去掉,于是状态量变为:使用 dp[i]表示组成总和为 i 的组合数。而完全背包问题的第二层循环实际上就默认了可以反复获取,所以这时候dp[i]就包含了n种情况,选择coins[0]的情况,选择coins[1]的情况……于是可得状态转移方法: dp[j] += dp[j - coins[i]]
为什么可以直接略去K?因为 j - coins[i] ,就已经包含了,只使用coins[i],使用coins[i]与coins[i - x]的组合。也就是已经包含了重复获取,k变量也可以直接省略。
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1;
for (int i = 0; i < coins.length; i++)
for (int j = coins[i]; j <= amount; j++)
dp[j] += dp[j - coins[i]];
return dp[amount];
}
}
时间复杂度为O(n * m),空间复杂度为O(m)
题目描述:给定一个非空字符串 s 和一个包含非空单词列表的字典wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。题目链接
说明:
分析:这题显然就是一个完全背包问题。只是这里要注意,这里的物品是有顺序的。之前我们处理完全背包问题,两层循环都是可以随意变换的,但这里并不行。因为对物品(在这里是字符串)的 顺序 有要求,所以这时候应该把物品放在内层的循环里。我们先看一下如果是原本的循环顺序,字符串在外层循环,背包负重在内层循环会怎么样:
错误的代码:
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
boolean[] dp = new boolean[s.length() + 1];
dp[0] = true;
for (String word: wordDict) {
for (int i = 1; i <= s.length(); i++) {
int length = word.length();
if (length <= i && word.equals(s.substring(i - length, i)))
dp[i] = dp[i] || dp[i - length];
}
}
return dp[s.length()];
}
}
比如测试用例"codeleet",[“leet”, “code”]。这时候外层循环先使用leet,结果就是无法匹配到字符串[0, 4],只有在循环到code的时候才匹配到,但此时无法再使用leet,因为循环已经过去。这就是顺序对背包问题的影响。这里只要改正一下循环顺序,就能解决顺序的问题:
代码:
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
boolean[] dp = new boolean[s.length() + 1];
dp[0] = true;
for (int i = 1; i <= s.length(); i++) {
for (String word: wordDict) {
int length = word.length();
if (length <= i && word.equals(s.substring(i - length, i)))
dp[i] = dp[i] || dp[i - length];
}
}
return dp[s.length()];
}
}
这里的状态量是,dp[i]表示是否能组成字符串的前 i 个字符。所以随着 i 的增加,每一次循环我们都能遍历一次所有的word,并且可以重复使用。时间复杂度为O(n * m),空间复杂度为O(n)
题目描述:给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。请注意,顺序不同的序列被视作不同的组合。题目链接
进阶:
如果给定的数组中含有负数会怎么样?
问题会产生什么变化?
我们需要在题目中添加什么限制来允许负数的出现?
分析:这一题也是跟上一题一样的,不同的序列被视为不同的组合,也就是要考虑顺序,所以我们要把和 i 放在外层循环,把对数组的遍历放在内层。状态转移方程,也是选择与不选择的区别。当要考虑nums[x]的时候,实际上情况就是,不选择它的情况,加上选择它的情况。于是可得:
dp[i] += dp[i - nums[j]]
(如果还不清晰,可以先把状态量设置为二维数组,然后写出代码之后再进行空间优化,将状态量化简为一维数组
代码:
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1;
for (int i = 1; i <= target; i++)
for (int num: nums)
if (i >= num)
dp[i] += dp[i - num];
return dp[target];
}
}
对于进阶,其实涉及负数的情况之前也遇到过了,就是第十七段里的LeetCode494,所以也不难处理。
题目描述 :给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:插入一个字符,删除一个字符,替换一个字符 题目链接
分析:一开始可能会想是二者哪个更长,但其实没有关系,因为显然,word1转换成word2,与word2转换成word1所需要的最少操作数是一样的,所以这里一律考虑word1转换成word2的情况即可。前面说过,解决两个字符串的动态规划问题,一般就是用两个指针 i,j 分别指向两个字符串的最后,然后一步一步增大字符串的长度,从子问题的结果推导出总问题的结果。状态量:使用 dp[i][j]表示 word1前 i 位转换成 word2前 j位所需要的最少操作数。
我们先考虑base case,显然,当word1的 i 已经走完,而 j 还没走完,此时就只能“插入”剩下的所有字符。那么当 i或者j为0的时候,显然就可以得到 dp[0][j] = j, dp[i][0] = i (这就是我们的初始化状态量)。而且我们默认是从word1转换成word2,于是 i 在外层循环,j 在内层循环。那么当 i 跟 j 二者增加的时候,我们同样像求取LCS的时候一样,考虑最后一个字符是否相等。有两种情况:
①二者字符串相等,即 word1.charAt(i - 1) == word2.charAt(j - 1)
(这里为什么-1也很好理解吧,把0当作了空,这也是为什么base case是 dp[0][j] = j
的原因)
那么这时候无须进行任何操作,因为这两个相等,所以完全可以抵消,即:
dp[i][j] = dp[i - 1][j - 1]
②二者字符串不相等,即word1.charAt(i - 1) != word2.charAt(j - 1)
那么这里就一定要进行变换,而变换的操作有三种,因为要求最少的操作数,很显然就是:
dp[i][j] = Min(dp(插入), dp(删除), dp(替换)) + 1
那么dp(插入)应该如何表达?要记住,是从word1转换成word2,所以这里是对word1修改,也就是对word1进行insert。所以这里的操作便是,使得word1的前 i 个字符转换成与word2的前 j - 1个字符相同的操作,然后再插入一个word2.charAt(j)字符,于是表达式为:dp[i][j - 1] + 1
对于删除,就是最直观地把word1.charAt(i)去掉,也就是等同于word1的前 i - 1个字符与word2的前 j 个字符转换的操作数,再加上1。于是表达式为:dp[i - 1][j] + 1
对于替换,就是把word1.charAt(i)替换成word2.charAt(j),所以前面 i - 1转换成 j - 1的操作,再加上一个替换操作。于是表达式为: dp[i - 1][j - 1] + 1
代码:
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++)
for (int j = 0; j <= n; j++)
if (i == 0)
dp[i][j] = j;
else if (j == 0)
dp[i][j] = i;
else if (word1.charAt(i - 1) == word2.charAt(j - 1)) // 无须操作
dp[i][j] = dp[i - 1][j - 1];
else // 分别是 delete,insert,replace操作
dp[i][j] = Math.min(dp[i - 1][j],
Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
return dp[m][n];
}
}
时间复杂度为O(nm),空间复杂度为O(nm)
题目描述:最初在一个记事本上只有一个字符 ‘A’。你每次可以对这个记事本进行两种操作:
①Copy All (复制全部) : 你可以复制这个记事本中的所有字符(部分的复制是不允许的)。
②Paste (粘贴) : 你可以粘贴你上一次复制的字符。
给定一个数字 n 。你需要使用最少的操作次数,在记事本中打印出恰好 n 个 ‘A’。输出能够打印出 n 个 ‘A’ 的最少操作次数。题目链接
分析:直接用状态量dp[i]表示 n为 i时的最小操作数。显然,dp[i]不会超过 i,因为即使是一个一个复制,那也就需要 i次操作了。然后要进行的便是内层循环,是否能找到可以被 i 整除的因子,比如 i % j == 0,此时无须一个一个复制,而是先复制出一个dp[j],然后再进行dp[i / j]次复制。所以此时状态转移方程为:
dp[i] = dp[j] + dp[i / j]
j ∈ [2, Math.sqrt(i)] ,也可以不进行sqrt,不过大于次数肯定不会是 i 的因子罢了。
这里值得注意的是,只要找到一个恰当的 j,就可以直接break了。比如dp[16],它可以分解成dp[2] + dp[8],也可以分解成dp[4] + dp[4],实际上二者是一样的。dp[8]又可以分解成 dp[2] + dp[4],dp[4]可以分解成dp[2] + dp[2]。所以找到一个即可停止,后续找的并不会更小。当然如果没想到,直接dp[i] = min(dp[i],……)也可以。
代码:
class Solution {
public int minSteps(int n) {
int[] dp = new int[n + 1];
int factor = (int)Math.sqrt(n); // 最大因子
for (int i = 2; i <= n; i++) {
dp[i] = i; // base case
for (int j = 2; j <= factor; j++) {
if (i % j == 0) {
dp[i] = dp[j] + dp[i / j];
break;
}
}
}
return dp[n];
}
}
时间复杂度为O(n ^ 1.5),最优情况下为O(n),但实际上在分析dp[16]的时候,我们就可以发现,这有点像因式分解,所以可以转换成数学问题:把n分解为m个数字的乘积,且m个数字的和最小。即把一个数分解为n个质数的和,从小到大地去试探,见下面的代码:
数学意义的代码:
public int minSteps(int n) {
int res = 0;
for (int i = 2; i <= n; i++) {
while (n % i == 0) {
res += i;
n /= i;
}
}
return res;
}
时间复杂度为O(n),最优情况下为O(logN)。
描述:这些题目看起来是有很多种做法的,比如贪心算法,DFS,可是当初在前两道就已经思考了许久,后面几道更是望而却步。我还记得当时做第三题的时候,完全没有思路,然后看了一下评论里的答案,简直一脸懵逼。
神仙代码:
class Solution {
public int maxProfit(int[] prices) {
/**
对于任意一天考虑四个变量:
fstBuy: 在该天第一次买入股票可获得的最大收益
fstSell: 在该天第一次卖出股票可获得的最大收益
secBuy: 在该天第二次买入股票可获得的最大收益
secSell: 在该天第二次卖出股票可获得的最大收益
分别对四个变量进行相应的更新, 最后secSell就是最大
收益值(secSell >= fstSell)
**/
int fstBuy = Integer.MIN_VALUE, fstSell = 0;
int secBuy = Integer.MIN_VALUE, secSell = 0;
for(int p : prices) {
fstBuy = Math.max(fstBuy, -p);
fstSell = Math.max(fstSell, fstBuy + p);
secBuy = Math.max(secBuy, fstSell - p);
secSell = Math.max(secSell, secBuy + p);
}
return secSell;
}
}
虽然他加上了注释,让我明白了每个变量的含义,并且自己认真去debug,发现确实如此。代码的逻辑大概终于懂了,算是”终于看懂了答案“。可是自己做题的时候应该如何写出这种代码?根本不可能。然后我又看到了labuladong大佬的文章,虽然他是搬运工,但他解释得还是很好。链接点here。这也是我入门DP的题目,终于明白了什么是DP,什么是状态量,什么是DP Table。同时这系列题也不容易,最后一共需要用到三维数组,也就是三维DP,想想我们前面的题目都没有用到三维数组。然而读起来却是很好理解。这里我也不必继续搬运。但是为了自己掌握,所以就需要自己理解思路,然后自己实现一遍,而不是读完直接抄代码。
①状态量:使用 dp[i][j][k]表示,在第 i 天时,还能进行 j次交易,此时手上是否拥有股票为 k。k = 1表示拥有股票,0表示没有股票(前两个都很好理解,为什么要有k,因为有顺序要求,sell之前必须拥有股票,拥有股票之后无法继续buy。)
②状态转移方程:
每一天都有3种操作,rest,buy,sell。sell的时候k必须为1,buy的时候k必须为0,且进行交易的时候 j 必须大于0。可以把sell的时候改变 j 的值,也可以在buy的时候改变 j 的值,都一样的,只要一次完整的交易改变一次 j 的值即可。这里在buy的时候改变 j 的值。
一共有两种情况,第 i 天的时候拥有股票,第 i 天的时候没有拥有股票。分情况讨论:
当你第 i 天的时候拥有股票,有两种情况,第 i - 1的时候就已经拥有了,但第 i 天什么都不操作,选择rest。或者是第 i - 1的时候没有拥有股票,但在第 i 天的时候买入了股票。所以有:
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i])
(前者是rest操作,后者是buy操作。buy需要扣除一次交易次数,所以是 j - 1)
当第 i 天的时候没有持有股票,有两种情况,第 i - 1的时候就没有,然后第 i 天不操作,选择rest。或者第 i - 1天的时候拥有股票,然后在第 i 天卖出了股票。所以有:
dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i])
(前者是rest操作,后者是sell操作。因为把改变交易数设置为buy,所以这里不用改 j的值)
③ base case
这道题的base case主要是考虑 i 跟 j 对于 k 的限制。
当 i 为 0的时候,此时为第一天,这时候不可能拥有股票的,所以:
dp[0][j][1] = -∞
(设置为负无穷,使得max操作不可能选择它,也就是直接pass)
dp[0][j][0] = 0
(第一天,此时利润当然是0)
当 j 为0的时候,此时已经无法进行交易,所以:
dp[i][0][0] = 0
(无法进行交易,所以k会一直处于 0的状态,利润也为0)
dp[i][0][1] = -∞
(无法进行交易,所以不可能拥有股票。这里可能有疑惑,看一下转移方程就懂了,要想用到dp[i][0][1]的时候,前面会是dp[i][0][0],也就是在不可以进行交易的时候再进行了一次buy,不可行)
④ 返回值
返回值是dp[总天数][最大交易数][0]。因为[1]表示手上还有股票,而[0]表示股票已经卖出去了,肯定利润更大。
这里我们先写出代码框架,然后从简单到难,逐步套用代码:(因为第四题是最泛化的情况,所以当时推导DP也是以第四题来推导的)但是注意,这里并不是最终的代码,还有一点小问题,我们等下再说,但模板确实如此了:
class Solution {
public int maxProfit(int k, int[] prices) {
int[][][] dp = new int[prices.length + 1][k + 1][2];
for (int i = 0; i <= prices.length; i++) {
for (int j = 0; j <= k; j++) {
if (i * j == 0) {
// base case
dp[i][j][0] = 0;
dp[i][j][1] = Integer.MIN_VALUE;
continue;
}
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i - 1]); // 状态转移
dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i - 1]); // prices[i - 1],很好理解
}
}
return dp[prices.length][k][0]; // 返回值
}
}
题目链接
描述:题目只能进行一次交易,即k为1,把k的地方全部换成1,代码就AC了。但显然,k可以化简,于是可得:
化简 k 之后的代码:
class Solution {
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length + 1][2];
for (int i = 0; i <= prices.length; i++) {
if (i == 0) {
// base case
dp[i][0] = 0;
dp[i][1] = Integer.MIN_VALUE;
continue;
}
dp[i][1] = Math.max(dp[i - 1][1], - prices[i - 1]);
// 注意, 这里原本的(j - 1)为0
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i - 1]);
}
return dp[prices.length][0]; // 返回值
}
}
同时,i变量只与前一个有关,因而也能化简。至于0/1状态,直接定义两个变量即可,不需要数组。同时,这时候就已经不用担心 i 为负数的情况,直接从 0 ~ length - 1即可。
最后化简的代码:
class Solution {
public int maxProfit(int[] prices) {
int dp0 = 0, dp1 = Integer.MIN_VALUE;
for (int i = 0; i < prices.length; i++) {
dp1 = Math.max(dp1, - prices[i]); // 注意, 这里原本的(j - 1)为0
dp0 = Math.max(dp0, dp1 + prices[i]);
}
return dp0; // 返回值
}
}
时间复杂度为O(n),空间复杂度为O(1)
题目链接
描述:k变为无穷,所以不存在k为0的情况了,所以上面我们还要担心(j - 1)会变成0,这里完全不用担心,直接完全抹去k(也就是 j 变量)。同样地,i 和 0/1变量也能化简,可得:
class Solution {
public int maxProfit(int[] prices) {
int dp0 = 0, dp1 = Integer.MIN_VALUE;
for (int i = 0; i < prices.length; i++) {
dp1 = Math.max(dp1, dp0 - prices[i]); // 第一题这里的dp0为0
dp0 = Math.max(dp0, dp1 + prices[i]);
}
return dp0; // 返回值
}
}
题目链接
描述:k 为2,直接把代码模板里的 k 全部改成2就可以AC了。(也许第一第二题还有各种更有的方法,比如贪心算法,但随着题目变得复杂,通用的DP才是王道)
class Solution {
public int maxProfit(int[] prices) {
int[][][] dp = new int[prices.length + 1][3][2];
for (int i = 0; i <= prices.length; i++) {
for (int j = 0; j <= 2; j++) {
if (i * j == 0) {
// base case
dp[i][j][0] = 0;
dp[i][j][1] = Integer.MIN_VALUE;
continue;
}
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i - 1]); // 状态转移
dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i - 1]); // prices[i - 1],很好理解
}
}
return dp[prices.length][2][0]; // 返回值
}
}
接下来继续进行化简。对于 i 和 0/1,同样可以化简。那么k呢?因为 k 为2,所以只有两种情况,k为1或者k为2(0只是一种base case,既然都不用数组推导,就可以忽略了)。所以这里直接用 2 * 2 == 4个变量替换所有即可。
class Solution {
public int maxProfit(int[] prices) {
int dpi_1_1 = Integer.MIN_VALUE, dpi_1_0 = 0;
int dpi_2_1 = Integer.MIN_VALUE, dpi_2_0 = 0; // 这里初始化都是以i == 0的情况
for (int price: prices) {
// dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i - 1]);
dpi_1_1 = Math.max(dpi_1_1, -price); // j - 1 == 0
dpi_2_1 = Math.max(dpi_2_1, dpi_1_0 - price);
// dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i - 1]);
dpi_1_0 = Math.max(dpi_1_0, dpi_1_1 + price);
dpi_2_0 = Math.max(dpi_2_0, dpi_2_1 + price);
}
return dpi_2_0; // 返回值
}
}
这个,就是我当时看到的神仙代码了,实际上从泛化的角度去推导,化简,才能真正理解它的真正含义。否则下次把 k 改成3,这时候需要6个变量,谁吃得消?
题目链接
描述:这一题虽然是最泛化的情况,但直接丢代码模板经常会出错。原因是:超出内存。仔细一看,是当 k 特别大的时候,dp数组太大了。现在想想,交易次数 k 最多有多大呢?
一次交易由买入和卖出构成,至少需要两天。所以说有效的限制 k 应该不超过 n/2,如果超过,就没有约束作用了,相当于 k = +infinity。这种情况是之前解决过的。
直接把之前的代码重用:
class Solution {
public int maxProfit(int k, int[] prices) {
if (k > prices.length / 2)
return maxProfit_inf(prices);
int[][][] dp = new int[prices.length + 1][k + 1][2];
for (int i = 0; i <= prices.length; i++) {
for (int j = 0; j <= k; j++) {
if (i * j == 0) {
// base case
dp[i][j][0] = 0;
dp[i][j][1] = Integer.MIN_VALUE;
continue;
}
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i - 1]); // 状态转移
dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i - 1]); // prices[i - 1],很好理解
}
}
return dp[prices.length][k][0]; // 返回值
}
public int maxProfit_inf(int[] prices) {
// 第二题的代码重用
int dp0 = 0, dp1 = Integer.MIN_VALUE;
for (int i = 0; i < prices.length; i++) {
dp1 = Math.max(dp1, dp0 - prices[i]); // 第一题这里的dp0为0
dp0 = Math.max(dp0, dp1 + prices[i]);
}
return dp0; // 返回值
}
}
题目链接
描述:k 为无限,但是卖出股票之后,第二天无法购买股票。这里只需要修改两条状态转移方程之一即可:
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 2][j - 1][0] - prices[i])
(buy改成 i - 2)
这一题直接化简到最后会出错,有一点要变化的地方,我们先套用模板再去掉k:
class Solution {
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length + 1][2];
for (int i = 0; i <= prices.length; i++) {
if (i == 0) {
// base case
dp[i][0] = 0;
dp[i][1] = Integer.MIN_VALUE;
continue;
}
if (i == 1) {
dp[i][1] = Math.max(dp[i - 1][1], - prices[i - 1]); // i - 2 < 0
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i - 1]);
continue;
}
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 2][0] - prices[i - 1]);
// 注意 i - 2
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i - 1]);
}
return dp[prices.length][0]; // 返回值
}
}
这里同样也是可以化简成空间复杂度为O(1)的,那么这个 i - 2应该如何表达?其实就是上一轮循环的的值。至于是 i 还是 i - 1?化成O(1)的时候,其实已经消去了,所以只要是上一轮的就行。初始化为0(正如上面 i 为1的时候,那个dp[i - 2]就是0)
public int maxProfit(int[] prices) {
int dp0 = 0, dp1 = Integer.MIN_VALUE, pre = 0;
for (int price: prices) {
int tmp = dp0; // 这次循环的 dp0, 后面可能会改变
dp1 = Math.max(dp1, pre - price);
dp0 = Math.max(dp0, dp1 + price);
pre = tmp; // 别忘了修改这个 dp[i - 2]
}
return dp0; // 返回值
}
题目链接
描述: 卖出去需要手续费,同样,改一下状态转移方程即可:
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i] - fee)
(一笔交易需要fee手续费,因为我们定义从buy的时候就修改 j,所以在这里减。如果 j 在sell的时候减,那么fee就应该在另一条方程里)
直接在第二题的代码上修改:
class Solution {
public int maxProfit(int[] prices, int fee) {
int dp0 = 0, dp1 = Integer.MIN_VALUE;
for (int i = 0; i < prices.length; i++) {
dp1 = Math.max(dp1, dp0 - prices[i] - fee);
dp0 = Math.max(dp0, dp1 + prices[i]);
}
return dp0; // 返回值
}
}
至此,6道股票题全部解决。