LeetCode题解 - 动态规划(矩阵路径、数组区间、分割整数)
文章目录
矩阵路径
64. 最小路径和(中等)
给定一个包含非负整数的 *m* x *n*
网格 grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。
class Solution {
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;
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” )。问总共有多少条不同的路径?
输入:m = 3, n = 7
输出:28
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
//base case
for(int i = 0; i < m; i++){
dp[i][0] = 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];
}
}
数组区间
303. 区域和检索-数组不可变(简单)
给定一个整数数组 nums
,求出数组从索引 i
到 j
(i ≤ j
)范围内元素的总和,包含 i
、j
两点。
实现 NumArray
类:
NumArray(int[] nums)
使用数组nums
初始化对象int sumRange(int i, int j)
返回数组nums
从索引i
到j
(i ≤ j
)范围内元素的总和,包含i
、j
两点(也就是sum(nums[i], nums[i + 1], ... , nums[j])
)
输入:
["NumArray", "sumRange", "sumRange", "sumRange"]
[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]
输出:
[null, 1, -1, -3]
解释:
NumArray numArray = new NumArray([-2, 0, 3, -5, 2, -1]);
numArray.sumRange(0, 2); // return 1 ((-2) + 0 + 3)
numArray.sumRange(2, 5); // return -1 (3 + (-5) + 2 + (-1))
numArray.sumRange(0, 5); // return -3 ((-2) + 0 + 3 + (-5) + 2 + (-1))
解题思路:从暴力法出发,逐步思考优化的点,最后过渡到前缀和的引入,看看前缀和到底优化了什么。
简单问题细致分析,『前缀和』优化了什么 | LeetCode.303
-
暴力法:每次调用 sumRange 时,都遍历 i 到 j 之间的元素,进行累加。
class NumArray { int[] sums; public NumArray(int[] nums) { sums = nums; } public int sumRange(int left, int right) { int res = 0; for(int i = left; i <= right; i++){ res += sums[i]; } return res; } }
时间复杂度 O(n),看起来挺好,存在什么问题?
如果 sumRange 方法被反复调用,每次都是 O(n),「查询」的代价有点大
-
第一步优化:初始化 NumArray 时就计算好所有的
sumRange(i, j)
的结果,对应存给res[i][j]
,这样「查询」就只用付出 O(1) 的代价class NumArray { int[][] res; public NumArray(int[] nums) { res = new int[nums.length][nums.length]; for(int i = 0; i < nums.length; i ++){ int sum = 0; for(int j = i; j < nums.length; j++){ sum += nums[j]; res[i][j] = sum; } } } public int sumRange(int left, int right) { return res[left][right]; } }
初始化时:时间复杂度 O(n^2)了,空间复杂度 O(n^2)。查询时:O(1)。
-
第二步优化:引入前缀和
nums 数组的每一项都对应有它的前缀和: nums 的第 0 项到 当前项 的和。
用数组 preSum 表示,
preSum[i]
:第 0 项到 第 i - 1 项 的和, preSum[i]=nums[0]+nums[1]+…+nums[i - 1]易得,nums 的某项 = 两个相邻前缀和的差:nums[i]=preSum[i + 1]−preSum[i]
对于 nums 的 i 到 j 的元素和,上式叠加,有:nums[i]+…+nums[j]=preSum[j + 1]−preSum[i]
所以:sumRange*(i,j) =*preSum*[j+1]−preSum*[i]
class NumArray { int[] sums; public NumArray(int[] nums) { int n = nums.length; sums = new int[n + 1]; for (int i = 0; i < n; i++) { sums[i + 1] = sums[i] + nums[i]; } } public int sumRange(int i, int j) { return sums[j + 1] - sums[i]; } }
413. 等差数列划分(中等)
数组 A 包含 N 个数,且索引从0开始。数组 A 的一个子数组划分为数组 (P, Q),P 与 Q 是整数且满足 0<=P<Q<N 。如果满足以下条件,则称子数组(P, Q)为等差数组:
元素 A[P], A[p + 1], …, A[Q - 1], A[Q] 是等差的。并且 P + 1 < Q (注意,是连续子数组)
函数要返回数组 A 中所有为等差数组的子数组个数。
A = [1, 2, 3, 4]
返回: 3, A 中有三个子等差数组: [1, 2, 3], [2, 3, 4] 以及自身 [1, 2, 3, 4]。
解题思路:定义dp[i] 表示以 A[i] 为结尾的等差递增子区间的个数。
当 A[i] - A[i-1] == A[i-1] - A[i-2],那么 [A[i-2], A[i-1], A[i]] 构成一个等差递增子区间。而且在以 A[i-1] 为结尾的递增子区间的后面再加上一个 A[i],一样可以构成新的递增子区间。
dp[2] = 1
[1, 2, 3]
dp[3] = dp[2] + 1 = 2
[1, 2, 3, 4], // [1, 2, 3] 之后加一个 3
[2, 3, 4] // 新的递增子区间
sum = 1 + 2 = 3;
综上,在 A[i] - A[i-1] == A[i-1] - A[i-2] 时,dp[i] = dp[i-1] + 1。(状态转移方程)
因为递增子区间不一定以最后一个元素为结尾,可以是任意一个元素结尾,因此需要返回 dp 数组累加的结果。
class Solution {
public int numberOfArithmeticSlices(int[] nums) {
int n = nums.length;
if(n <= 2) return 0; // 这两种情况记得写
int[] dp = new int[n];
int sum = 0;
for(int i = 2; i < n; i++){
if(nums[i] - nums[i - 1] == nums[i - 1] - nums[i - 2]){
dp[i] = dp[i - 1] + 1;
sum += dp[i];
}
}
return sum;
}
}
分割整数
343. 整数拆分(中等)
给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。
输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1。
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
解题思路:对于的正整数 n,当 n≥2 时,可以拆分成至少两个正整数的和。令 k 是拆分出的第一个正整数,则剩下的部分是 n-k,n-k可以不继续拆分,或者继续拆分成至少两个正整数的和。由于每个正整数对应的最大乘积取决于比它小的正整数对应的最大乘积(重复子问题),因此可以使用动态规划求解。
创建数组 dp,其中dp[i] 表示将正整数 i 拆分成至少两个正整数的和之后,这些正整数的最大乘积。特别地,0 不是正整数,1 是最小的正整数,0 和 1 都不能拆分,因此dp[0]=dp[1]=0
(base case)
当 i≥2 时,假设对正整数 i 拆分出的第一个正整数是 (1<j<i),则有以下两种方案:
- 将 i 拆分成 j 和 i-j 的和,且 i-j 不再拆分成多个正整数,此时的乘积是 j×(i−j);
- 将 i 拆分成 j 和 i-j 的和,且 i-j 继续拆分成多个正整数,此时的乘积是 j×dp[i−j]。
因此,当 j 固定时,有 dp[i]=max(j×(i−j),j×dp[i−j])
。由于 j 的取值范围是 1 到 i-1,需要遍历所有的 j 得到 dp[i] 的最大值,因此可以得到状态转移方程如下:
最终得到dp[n] 的值即为将正整数 n 拆分成至少两个正整数的和之后,这些正整数的最大乘积。
class Solution {
public int integerBreak(int n) {
int[] dp = new int[n + 1]; //dp[0]与dp[1]初始化为0
for(int i = 2; i <= n; i++){
for(int j = 1; j <= i - 1; j++){
dp[i] = Math.max(dp[i], Math.max(j*(i-j), j*dp[i-j]));
}
}
return dp[n];
}
}
注意:本题与剑指offer 14-1 剪绳子 题目类似
279. 完全平方数(中等)
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...
)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。
给你一个整数 n
,返回和为 n
的完全平方数的 最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1
、4
、9
和 16
都是完全平方数,而 3
和 11
不是。
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4
输入:n = 13
输出:2
解释:13 = 4 + 9
解题思路:和上题思路类似,也是采用动态规划的想法从每个子问题的最优解不断递推来求出整体问题的最优解;
-
首先定义状态
dp[i]
表示正整数 i 的最小平方数 -
base case:因为要求最小值,所以将dp每一项初始化为整数的最大值,且
dp[0]=0
; -
对于每个子问题
i
的最优解,与剪绳子思路类似,可以先假设第一刀为j
,取它的完全平方数j*j
,则剩余长度为i-j*j
,则它的最优解就等于长度为i-j*j
的最优解再加上1,即dp[i]=max(dp[i−j * j] + 1)
-
这道题需要注意的是 i 和 j 的遍历范围,i肯定是从1~n,j 从1开始,且需要始终满足
i-j*j>0
,否则数组会有索引错误;最终返回结果即dp[n]
;
class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for(int i = 1; i <= n; i++){
for(int j = 1; i - j * j >= 0; j++){
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
}
91. 解码方法(中等)
一条包含字母 A-Z
的消息通过以下映射进行了 编码
'A' -> 1
'B' -> 2
...
'Z' -> 26
要 解码 已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,"11106"
可以映射为:
"AAJF"
,将消息分组为(1 1 10 6)
"KJF"
,将消息分组为(11 10 6)
注意,消息不能分组为 (1 11 06)
,因为 "06"
不能映射为 "F"
,这是由于 "6"
和 "06"
在映射中并不等价。
给你一个只含数字的 非空 字符串 s
,请计算并返回 解码 方法的 总数 。
输入:s = "12"
输出:2
解释:它可以解码为 "AB"(1 2)或者 "L"(12)。
输入:s = "226"
输出:3
解释:它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。
解题思路:这其实是一道字符串类的动态规划题,不难发现对于字符串 s 的某个位置 i 而言,我们只关心「位置 i 自己能否形成独立 item 」和「位置 i 能够与上一位置(i-1)能否形成 item」,而不关心 i-1 之前的位置。
有了以上分析,我们可以从前往后处理字符串 s,使用一个数组记录以字符串 s 的每一位作为结尾的解码方案数。即定义 f[i] 为考虑前 i 个字符的解码方案数。对于字符串 s 的任意位置 i 而言,那么会有下面的两种情况:
-
第一种情况是我们使用了一个字符,即 s[i] 进行解码,那么只要 s[i] 不等于0,它就可以被解码成 A∼I 中的某个字母。由于剩余的前 i-1 个字符的解码方法数为
f[i - 1]
,因此我们可以写出状态转移方程f[i] = f[i - 1]
-
第二种情况是我们使用了两个字符,即 s[i-1] 和 s[i] 进行编码。与第一种情况类似,s[i-1] 不能等于0,并且 s[i-1] 和 s[i] 组成的整数必须小于等于 26,这样它们就可以被解码成 J∼Z 中的某个字母。由于剩余的前 i−2 个字符的解码方法数为
f[i - 2]
,因此我们可以写出状态转移方程:f[i] = f[i - 2]
。将上面的两种状态转移方程在对应的条件满足时进行累加,即可得到
f[i]
的值。在动态规划完成后,最终的答案即为f[n]
。
其它细节:动态规划的边界条件为:f[0] = 1
, 即空字符串可以有 1 种解码方法,解码出一个空字符串。
同时,由于在大部分语言中,字符串的下标是从 0 而不是 1 开始的,因此在代码的编写过程中,我们需要将所有字符串的下标减去 1,与使用的语言保持一致。
需要注意的是,只有当 i>1i>1 时才能进行转移,否则 s[i-1]s[i−1] 不存在。
class Solution {
public int numDecodings(String s) {
int n = s.length();
int[] f = new int[n + 1];
f[0] = 1;
for (int i = 1; i <= n; i++) {
if (s.charAt(i - 1) != '0') {
f[i] += f[i - 1];
}
if (i > 1 && s.charAt(i - 2) != '0' && ((s.charAt(i - 2) - '0') * 10 + (s.charAt(i - 1) - '0') <= 26)) {
f[i] += f[i - 2];
}
}
return f[n];
}
}