文章目录
概念
动态规划
动态规划
(Dynamic Programming,简称DP)是一种解决问题的算法思想,通常用于优化问题。它的核心思想是将一个大问题分解成若干个子问题,并通过保存子问题的解来避免重复计算,从而提高效率。
基本思想
-
优化子结构:动态规划适用于那些可以将问题分解为子问题的问题,且这些子问题的解可以用来构建原问题的解。也就是说,问题具有重叠子问题的性质。
-
最优子结构:原问题的最优解可以由子问题的最优解组合而成。即,如果子问题的解是最优的,那么它们的组合也能构成原问题的最优解。
常见步骤
-
定义状态:
确定DP数组(或表)中的状态代表什么。状态通常是对问题的某一方面的描述,可以是一个数组或矩阵中的一个元素。 -
确定状态转移方程:
找出状态之间的关系,通常是用来从一个状态计算出另一个状态的公式或规则。 -
初始化状态:
设置边界条件,通常是最简单的情况或基础情况的解。例如,数组的第一个元素或最小子问题的解。 -
填充DP表:
根据状态转移方程从初始状态开始,逐步计算出所有状态的解,直到得到原问题的解。 -
返回结果:
最终的解通常会保存在DP表的某个位置,根据问题的要求返回相应的值。
常用技巧
-
空间优化:
如果DP表的某一行或某一列只依赖于前一行或列,可以只保留当前行(或列)的状态,减少空间复杂度。例如,二维DP数组可以优化为一维数组。 -
状态压缩:
如果状态转移只依赖于有限个先前状态,可以使用状态压缩技巧将二维状态数组转为一维数组。 -
递推和备忘录:
递归方法与动态规划结合称为备忘录法(Memoization),通过缓存已经计算过的子问题的结果来避免重复计算。 -
按序计算:
按照状态转移的依赖顺序填充DP表,确保计算某一状态时其依赖的状态已经计算完毕。 -
重叠子问题:
动态规划特别适用于存在重叠子问题的情况,即问题可以被分解为多个相同的子问题,这些子问题的解在不同的计算中被多次使用。
常见问题类型
-
路径问题:
比如“最短路径”或“最长路径”,如网格最短路径、背包问题等。 -
选择问题:
比如“选择某些元素使得总和最大”,如背包问题、股票买卖问题等。 -
字符串问题:
如“编辑距离”、“最长公共子序列”、“字符串匹配”等。 -
序列问题:
比如“最大子序列和”、“最长递增子序列”等。
动态规划题目
题目: 斐波那契数
原题链接: 斐波那契数
题解
方法1:递归
public int fib(int n) {
if (n == 1 || n == 2) return 1;
return fib(n - 1) + fib(n - 2);
}
方法1:递归–打表优化
比如n=10。那么fib(8)会算两次,存在很多重叠子问题,用备忘录
将每次计算的结果存储起来,下次遇到相同的子问题时,直接查表返回结果
public int fib(int n) {
int[] table = new int[n + 1]; // 初始化table数组 备忘录
return fibHelper(table, n);
}
public int fibHelper(int[] table, int n) {
if (n == 0 || n == 1) return n;
if (table[n] != 0) return table[n]; //先查询是否计算过
table[n] = fibHelper(table, n - 1) + fibHelper(table, n - 2);
return table[n];
}
方法3:迭代(自底向上)
// 迭代
public int fib(int n) {
if (n == 0 || n == 1) return n;
// ①定义状态 dp[i]表示第i个斐波那契数
int[] dp = new int[n + 1];
// ②初始化状态 base case
dp[0] = 0;
dp[1] = 1;
// ③状态转移
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
方法4:迭代–空间优化
因为每次状态转移只需要前两个状态
,所以我只需记录前两个状态即可
public int fib(int n) {
if (n == 0 || n == 1) return n;
int dpi_2 = 0; // 表示dp[i-2]
int dpi_1 = 1; // 表示dp[i-1]
int dp_i = 0; // 表示dp[i]
for (int i = 2; i <= n; i++) {
dp_i = dpi_1 + dpi_2;
// 滚动更新
dpi_2 = dpi_1;
dpi_1 = dp_i;
}
return dp_i;
}
题目: 爬楼梯
原题链接: 爬楼梯
题解
爬楼梯问题的动态规划解法的步骤如下:
-
定义状态:
dp[i]
表示到达第i
层楼梯的方案数。 -
初始化状态:
dp[0] = 1
:表示在第0层(即不爬楼梯)只有一种方式,即什么都不做。dp[1] = 1
:表示只有一种方式到达第1层,即一步到达。
-
状态转移方程:
dp[i] = dp[i - 1] + dp[i - 2]
:到达第i
层的方案数等于到达第i-1
层的方案数加上到达第i-2
层的方案数。因为从第i-1
层可以一步到达第i
层,从第i-2
层可以两步到达第i
层。
-
填充DP表:
- 从
i = 2
开始,逐步计算到达每一层的方案数,并存储在dp
数组中。
- 从
public static int climbStairs(int n) {
// 定义状态
int[] dp = new int[n + 1];// dp[i]表示爬到第i层楼梯的方案数
// 初始状态
dp[0] = 1;
dp[1] = 1;
// 状态转移方程 dp[i] = dp[i-1]+dp[i-2];
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
我觉得这个题非常适合新手入门动态规划,这个题帮助新手掌握动态规划的核心思想,包括如何定义状态
、初始化状态
、如何进行状态转移
、如何处理边界条件
等
题目: 使用最小花费爬楼梯
原题链接: 使用最小花费爬楼梯
题解
注意:楼梯顶部是cost.length
方法1:用dp数组记录前面状态
public int minCostClimbingStairs(int[] cost) {
// 定义状态 爬到第 i 个台阶的最小花费
int[] dp = new int[cost.length + 1];
// 初始化状态
dp[0] = 0;
dp[1] = 0;
// 状态转移
for (int i = 2; i <= cost.length; i++) {
dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[cost.length];
}
方法2:用两个变量记录前面的状态
public int minCostClimbingStairs(int[] cost) {
int res = 0;
// first second 是目标楼梯下面两个楼梯的最小花费
// 顺序 first(上上个楼梯) second(上一个楼梯) res(目标楼梯)
int first = 0, second = 0;
for (int i = 2; i <= cost.length; i++) {
res = Math.min(second + cost[i - 1], first + cost[i - 2]);
// 只与前两个状态有关 所以用两个变量记录前两个状态即可
first = second;
second = res;
}
return res;
}
题目: 杨辉三角
原题链接: 杨辉三角
题解
public List<List<Integer>> generate1(int numRows) {
List<List<Integer>> res = new ArrayList<>();
// 第一行固定为 [1]
List<Integer> firstRow = new ArrayList<>();
firstRow.add(1); // 初始状态
res.add(firstRow);
// 从第二行开始生成
for (int i = 1; i < numRows; i++) {
List<Integer> prevRow = res.get(i - 1); // 上一行
List<Integer> row = new ArrayList<>();
// 每行的第一个元素是 1
row.add(1);
// 中间的元素是上一行的两个元素相加
for (int j = 1; j < i; j++) {
row.add(prevRow.get(j - 1) + prevRow.get(j)); // 状态转移
}
// 每行的最后一个元素是 1
row.add(1);
res.add(row);
}
return res;
}
题目: 打家劫舍
原题链接: 打家劫舍
题解
只与前两个状态相关,所以可以用两个变量保存即可。
public int rob(int[] nums) {
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
// ①定义状态 dp[i] 表示偷到第 i 个房子能获得的最大金额
int[] dp = new int[nums.length];
// ②初始化状态
dp[0] = nums[0];
dp[1] = Math.max(nums[1], nums[0]);
// ③状态转移 dp[i] = Math.max(dp[i-2]+nums[i],dp[i-1])
for (int i = 2; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[nums.length - 1];
}
题目: 打家劫舍 II
原题链接: 打家劫舍 II
题解
方法:在198题的基础的修改即可。
- 情况一:偷窃第一个房屋,不偷窃最后一个房屋(即考虑从第一个房屋到倒数第二个房屋的情况)。
- 情况二:不偷窃第一个房屋,偷窃最后一个房屋(即考虑从第二个房屋到最后一个房屋的情况)。
第一种方法:
public int rob(int[] nums) {
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
if (nums.length == 2) return Math.max(nums[0], nums[1]);
return Math.max(
extracted(nums, 0, nums.length - 2),
extracted(nums, 1, nums.length - 1)
);
}
private static int extracted(int[] nums, int start, int end) {
// ①定义状态 dp[i] 表示偷到第 i 个房子能获得的最大金额
int[] dp = new int[nums.length];
// ②初始化状态
dp[start] = nums[start];
dp[start + 1] = Math.max(nums[start], nums[start + 1]);
// ③状态转移
for (int i = start + 2; i < end; i++) {
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[end];
}
第二种方法:
这样感觉代码看着更舒服,用两个变量记录前面的两个状态
public int rob(int[] nums) {
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
if (nums.length == 2) return Math.max(nums[0], nums[1]);
return Math.max(
extracted(nums, 0, nums.length - 2),
extracted(nums, 1, nums.length - 1)
);
}
private static int extracted(int[] nums, int start, int end) {
int dpi_2 = nums[start];
int dpi_1 = Math.max(nums[start], nums[start + 1]);
// ③状态转移
for (int i = start + 2; i <= end; i++) {
int temp = Math.max(dpi_2 + nums[i], dpi_1);
dpi_2 = dpi_1;
dpi_1 = temp;
}
return dpi_1;
}
题目: 乘积最大子数组
原题链接: 乘积最大子数组
题解
方法:用动态规划的思路来解决问题。由于负数的存在,最大乘积可能会由于乘以负数变成最小值,因此在处理时需要同时维护一个最大值和一个最小值。
public int maxProduct(int[] nums) {
// 初始化最大乘积、当前最大值、当前最小值
int res = nums[0];
int currentMax = nums[0];
int currentMin = nums[0];
// 从第二个元素开始遍历
for (int i = 1; i < nums.length; i++) {
// 如果当前数是负数,则交换当前最大值和最小值
if (nums[i] < 0) {
int temp = currentMax;
currentMax = currentMin;
currentMin = temp;
}
// 更新当前最大值和最小值
currentMax = Math.max(nums[i], currentMax * nums[i]);
currentMin = Math.min(nums[i], currentMin * nums[i]);
// 更新最大乘积
res = Math.max(res, currentMax);
}
return res;
}
题目: 完全平方数
原题链接: 完全平方数
题解
方法1:
public int numSquares1(int n) {
// ①定义状态 dp[i] 表示和为 i 的完全平方数的最少数量
int[] dp = new int[n + 1];
// ②初始化状态
dp[0] = 0;
// ③状态转移 从 1 开始遍历到 n,依次计算每个数字的最少完全平方数数量
for (int i = 1; i <= n; i++) {
int minn = Integer.MAX_VALUE;
// 遍历所有可能的完全平方数 j*j,使得 j*j <= i
for (int j = 1; j * j <= i; j++) {
// 更新当前数字 i 所需的最少完全平方数数量
minn = Math.min(minn, dp[i - j * j]);
}
// 加 1 是因为我们使用了一个新的完全平方数
dp[i] = minn + 1;
}
return dp[n];
}
方法2:这个代码是基于322 零钱兑换题改动了一点。其实这两个题非常相似,
在这个问题中(完全平方数
),我是通过遍历小于 i 的所有完全平方数 for (int j = 1; j * j <= i; j++)
来求解。如果我预先将所有小于 n 的完全平方数存入数组,那么可以直接遍历这个数组,不就类似于零钱兑换问题中的硬币遍历
完全平方数
这个题是一定有解的,因为有1
。要是零钱兑换
这个题中的coins
有1元的面额,一定有结果。
public int numSquares(int n) {
// ①定义状态 dp[i] 表示和为 i 的完全平方数的最少数量
int[] dp = new int[n + 1];
Arrays.fill(dp, n + 2); // 设置大于n即可
// ②初始化状态
dp[0] = 0;
// ③状态转移
for (int i = 1; i <= n; i++) {
for (int j = 1; j * j <= i; j++) {
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
Arrays.fill(dp, n + 2) 的作用是初始化 dp 数组,使得每个元素的初始值都非常大(大于 n),以便在后续的动态规划过程中能够正确地更新最小值
可能有人问为什么不能直接用 Integer.MAX_VALUE?
虽然使用 Integer.MAX_VALUE 也可以,但在进行 dp[i - j * j] + 1 这样的加法运算时,可能会导致溢出。因此,使用 n + 2 是一种避免溢出且足够大的值
题目: 零钱兑换
原题链接: 零钱兑换
题解
零钱兑换符合最优子结构
,(要符合最优子结构,子问题间必须相互独立)。比如amount=11,如果你知道凑出amount=10,9,6的最少硬币数(子问题),只需要把子问题答案加1,就是原问题答案。
方法:动态规划三步骤
public int coinChange(int[] coins, int amount) {
// ①初始化 dp 数组,dp[i] 表示凑齐金额 i 所需的最少硬币数
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 2);// 因为我们要找的是最小值,所以初始化为一个最大值,之后会被更新
// ②初始化状态
dp[0] = 0;
// ③状态转移
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i - coin >= 0)
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
题目: 单词拆分
原题链接: 单词拆分
题解
方法:
内层循环 j 遍历 i 之前的所有位置,j 用来分割字符串 s。我们把字符串 s 分成两部分:
- s[0:j]:用 dp[j] 来表示前 j 个字符是否可以被字典中的单词拼接而成。
- s[j:i]:用 wordDict.contains(s.substring(j, i)) 来检查 s 的从 j 到 i 这一部分是否在字典中。
只有当 dp[j] 为 true 且 s[j:i] 在字典中时,才说明前 i 个字符可以被拆分,进而将 dp[i] 设为 true。
其中部分过程
:
- 当(i = 1):我们检查 s[0:1] = “l”。字典中没有 “l”,因此 dp[1] = false。
- 当(i = 4):我们检查 s[0:4] = “leet”。字典中有 “leet”,因此 dp[4] = true。
- 当(i = 8):我们检查 s[4:8] = “code”,字典中有 “code”,而且 dp[4] = true,因此 dp[8] = true。
public boolean wordBreak(String s, List<String> wordDict) {
// ① 定义状态 dp[i] 表示 s 的前 i 个字符是否可以被拆分
boolean[] dp = new boolean[s.length() + 1];
// ② 初始化状态 dp[0] 为 true,表示空字符串可以被拆分
dp[0] = true;
// ③ 状态转移
for (int i = 1; i <= s.length(); i++) {
for (int j = 0; j < i; j++) {
// 检查 s[j:i] 是否在字典中,并且 dp[j] 为 true(表示前 j 个字符可以被拆分)
if (dp[j] && wordDict.contains(s.substring(j, i))) {
dp[i] = true;
break;
}
}
}
return dp[s.length()];
}
边界问题有时候容易被忽略
题目: 最长递增子序列
原题链接: 最长递增子序列
题解
注意:
- 子序列就像
跳跃着挑选某些元素,但顺序不能变
- 子数组则是一段
连续
的“切片”
方法:代码形式和 完全平方数
零钱兑换
很相似
public int lengthOfLIS(int[] nums) {
// ①定义状态 dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度
int[] dp = new int[nums.length];
// ②初始化状态
Arrays.fill(dp, 1); // 初始化 dp 数组,每个元素默认长度为 1
// 状态转移
for (int i = 1; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) { // 如果 nums[i] 可以接在 nums[j] 后面
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
// 当然最大值可以在上面的for中动态跟更新 这样核心逻辑更清楚
int maxLength = 1;
for (int i : dp) {
if (i > maxLength) maxLength = i;
}
return maxLength;
}
题目: 分割等和子集
原题链接: 分割等和子集
题解
方法:如果 sum 是偶数直接返回false。如果 sum 是偶数,我们需要判断是否能从数组中选出若干个元素,使得它们的和等于 sum / 2。这就转化成了一个背包问题
,要求找到一个子集,其和为 sum / 2。
类比于背包问题:我从nuns中选一些数字,和为target(背包容量)
public boolean canPartition(int[] nums) {
// 计算数组总和
int sum = 0;
for (int num : nums) {
sum += num;
}
// 如果总和为奇数,无法分成两个子集
if (sum % 2 != 0) return false;
// 目标和是总和的一半
int target = sum / 2;
// ①定义状态 dp[i] 表示是否可以选出若干个元素,使得和为 i
boolean[] dp = new boolean[target + 1];
// ②初始化状态
dp[0] = true;
// ③状态转移
for (int i = 0; i < nums.length; i++) {
for (int j = target; j >= 0; j--) {
if (j - nums[i] >= 0) {
dp[j] = dp[j] | dp[j - nums[i]];
}
}
}
return dp[target];
}
题目: 最长有效括号
原题链接: 最长有效括号
题解
方法1:使用栈
思路:
- 遇到左括号 ‘(’ 时,将其索引压入栈中。
- 遇到右括号 ‘)’ 时,如果栈非空,弹出栈顶元素。然后计算当前有效括号的长度(即当前右括号的索引减去栈顶元素的索引)。
- 特殊情况下,为了处理整个字符串中可能的最外层有效括号子串,我们在栈底先放一个初始值 -1,这样当计算长度时可以避免下标越界的问题。
public static int longestValidParentheses(String s) {
Stack<Integer> stack = new Stack<>();
stack.push(-1); // 初始化栈底元素为-1,作为基准
int maxLength = 0; // 保存最长有效括号子串长度
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '(') {
stack.push(i); // 左括号的索引压入栈
} else {
stack.pop(); // 右括号,弹出栈顶元素
if (stack.isEmpty()) {
// 如果栈为空,将当前右括号的索引作为新的基准
stack.push(i);
} else {
// 计算有效括号的长度
maxLength = Math.max(maxLength, i - stack.peek());
}
}
}
return maxLength;
}
假设输入字符串是 "(()())"
。
i = 0
, 字符是'('
,栈为[-1, 0]
。i = 1
, 字符是'('
,栈为[-1, 0, 1]
。i = 2
, 字符是')'
,弹出栈顶元素,栈变为[-1, 0]
,计算长度2 - 0 = 2
,maxLength = 2
。i = 3
, 字符是'('
,栈为[-1, 0, 3]
。i = 4
, 字符是')'
,弹出栈顶元素,栈变为[-1, 0]
,计算长度4 - 0 = 4
,maxLength = 4
。i = 5
, 字符是')'
,弹出栈顶元素,栈变为[-1]
,计算长度5 - (-1) = 6
,maxLength = 6
。
方法2:动态规划
思路:
- 使用一个 dp 数组,dp[i] 表示以索引 i 结尾的最长有效括号子串长度。
- 当遇到右括号 ‘)’ 时,检查其前一个字符,如果是左括号 ‘(’,则更新 dp[i] 的值。
- 对于 s[i-1] 是右括号的情况,检查前一个有效子串的前一个字符是否是左括号,来更新当前的 dp[i]。
public static int longestValidParentheses(String s) {
int maxLength = 0;
int[] dp = new int[s.length()]; // dp[i] 表示以 i 结尾的最长有效括号子串长度
for (int i = 1; i < s.length(); i++) {
if (s.charAt(i) == ')') {
// 情况1:(),直接加上前面的 dp 值
if (s.charAt(i - 1) == '(') {
// dp[i] = dp[i-2] + 2,如果 i-2 >= 0,则加上之前的有效长度
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
}
// 情况2:)),检查前一个有效括号子串的前一个字符是否是 (
else if (i - dp[i - 1] > 0 && s.charAt(i - dp[i - 1] - 1) == '(') {
// // dp[i] = dp[i-1] + dp[i-dp[i-1]-2] + 2
dp[i] = dp[i - 1] + ((i - dp[i - 1]) >= 2 ? dp[i - dp[i - 1] - 2] : 0) + 2;
}
maxLength = Math.max(maxLength, dp[i]);
}
}
return maxLength;
}
情况 1:当前字符是 )
,且前一个字符是 (
if (s.charAt(i - 1) == '(') {
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
}
- 如果当前字符
s[i]
是右括号)
,并且前一个字符s[i-1]
是左括号(
,则它们组成一对有效括号,长度为2
。 - 如果
i-2 >= 0
,则还需要加上前面的有效括号长度,即dp[i-2]
的值。如果i-2
小于 0,则没有之前的有效长度可加。
情况 2:当前字符是 )
,且前一个字符也是 )
,检查之前的括号是否可以匹配
else if (i - dp[i - 1] > 0 && s.charAt(i - dp[i - 1] - 1) == '(') {
dp[i] = dp[i - 1] + ((i - dp[i - 1]) >= 2 ? dp[i - dp[i - 1] - 2] : 0) + 2;
}
- 如果
s[i-1]
是右括号)
,我们检查在当前有效括号子串的前一个字符是否是左括号(
。这个前一个字符的索引是i - dp[i-1] - 1
,即上一个有效子串之前的一个字符。如果是左括号(
,则它与当前的右括号)
匹配,形成一个更长的有效子串。 - 此时,
dp[i]
等于:- 前一个有效括号子串的长度
dp[i-1]
; - 再加上
dp[i - dp[i - 1] - 2]
(如果存在的话,用于加上之前的有效括号子串); - 再加上 2,表示新匹配的这对括号。
- 前一个有效括号子串的长度
假设输入字符串为 "(()())"
,通过逐步遍历计算 dp
数组的过程如下:
i=1
:s[1]='('
,跳过。i=2
:s[2]=')'
,前一个字符s[1]='('
,满足情况1,dp[2]=2
。i=3
:s[3]='('
,跳过。i=4
:s[4]=')'
,前一个字符s[3]='('
,满足情况1,dp[4]=dp[2]+2=4
。i=5
:s[5]=')'
,前一个字符s[4]=')'
,满足情况2,dp[5]=dp[4]+2=6
。
❤觉得有用的可以留个关注~❤