Leetcode+Java+动态规划VI
139.单词拆分
给你一个字符串
s和一个字符串列表wordDict作为字典。如果可以利用字典中出现的一个或多个单词拼接出s则返回true。注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例 1:
输入: s = "leetcode", wordDict = ["leet", "code"] 输出: true 解释: 返回 true 因为 "leetcode" 可以由 "leet" 和 "code" 拼接成。示例 2:
输入: s = "applepenapple", wordDict = ["apple", "pen"] 输出: true 解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。 注意,你可以重复使用字典中的单词。示例 3:
输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"] 输出: false提示:
1 <= s.length <= 3001 <= wordDict.length <= 10001 <= wordDict[i].length <= 20s和wordDict[i]仅由小写英文字母组成wordDict中的所有字符串 互不相同
原理
- 定义状态:
- 使用布尔数组 dp,其中 dp[i] 表示字符串 s 的前 i 个字符(s[0:i])是否能被 wordDict 中的单词拼接而成。
- 目标是求 dp[s.length()],表示整个字符串 s 是否可拼接。
- 初始化:
- dp[0] = true,表示空字符串(长度为0)总是可以拼接的(不需要任何单词)。
- 其他 dp[i] 初始为 false。
- 状态转移:
- 遍历字符串的每个前缀长度 i(从1到 s.length())。
- 对于每个 i,尝试所有可能的起点 j(从0到 i-1),检查:
- 如果 dp[j] = true(即 s[0:j] 可以被拼接),并且子串 s[j:i](即 s.substring(j, i))存在于 wordDict 中,则 dp[i] = true。
- 一旦找到一个 j 使得 dp[i] = true,可以终止内层循环(因为只需要知道是否可拼接)。
- 这样,dp[i] 表示是否能通过字典中的单词拼接出 s[0:i]。
- 优化:
- 将 wordDict 转换为 HashSet,将字符串查找的时间复杂度从 O(n) 降低到 O(1)。
- 使用 break 在找到一个可行解后跳出内层循环,减少不必要的检查。
- 时间复杂度:
- 构建 HashSet:O(wordDict.length)。
- 外层循环:O(s.length())。
- 内层循环:最多 O(s.length()),子串操作和 HashSet 查找为 O(1)(假设字符串比较为常数时间,实际可能与单词长度相关)。
- 总时间复杂度:O(s.length()^2)(忽略子串操作的字符串长度影响)。
- 如果考虑子串操作的复杂度(与单词长度相关),最坏情况下可能接近 O(s.length()^2 * max(wordDict[i].length))。
- 空间复杂度:
- dp 数组:O(s.length())。
- HashSet:O(wordDict.length)。
- 总空间复杂度:O(s.length() + wordDict.length)。
代码
import java.util.*;
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
// 将wordDict转换为HashSet,提高字符串查找效率
Set<String> wordSet = new HashSet<>(wordDict);
// 创建动态规划数组,dp[i]表示s的前i个字符是否能被wordDict中的单词拼接
boolean[] dp = new boolean[s.length() + 1];
// 初始状态:空字符串(前0个字符)可以拼接
dp[0] = true;
// 遍历s的每个前缀长度i(从1到s.length())
for (int i = 1; i <= s.length(); i++) {
// 尝试所有可能的起点j(从0到i-1)
for (int j = 0; j < i; j++) {
// 如果s[0:j]可以拼接,并且s[j:i]在wordDict中
if (dp[j] && wordSet.contains(s.substring(j, i))) {
// 则s[0:i]可以拼接
dp[i] = true;
// 找到一种可行解后无需继续检查其他j
break;
}
}
}
// 返回s整个字符串是否可以拼接
return dp[s.length()];
}
}
多重背包
题目描述
你是一名宇航员,即将前往一个遥远的行星。在这个行星上,有许多不同类型的矿石资源,每种矿石都有不同的重要性和价值。你需要选择哪些矿石带回地球,但你的宇航舱有一定的容量限制。
给定一个宇航舱,最大容量为 C。现在有 N 种不同类型的矿石,每种矿石有一个重量 w[i],一个价值 v[i],以及最多 k[i] 个可用。不同类型的矿石在地球上的市场价值不同。你需要计算如何在不超过宇航舱容量的情况下,最大化你所能获取的总价值。
输入描述
输入共包括四行,第一行包含两个整数 C 和 N,分别表示宇航舱的容量和矿石的种类数量。
接下来的三行,每行包含 N 个正整数。具体如下:
第二行包含 N 个整数,表示 N 种矿石的重量。
第三行包含 N 个整数,表示 N 种矿石的价格。
第四行包含 N 个整数,表示 N 种矿石的可用数量上限。
输出描述
输出一个整数,代表获取的最大价值。
输入示例
10 3 1 3 4 15 20 30 2 3 2输出示例
90提示信息
数据范围:
1 <= C <= 2000;
1 <= N <= 100;
1 <= w[i], v[i], k[i] <= 1000;
原理
- 定义状态:
- 使用一维数组 dp,其中 dp[j] 表示在容量为 j 的情况下,能获得的最大价值。
- 目标是求 dp[bagWeight],即在容量 C 下能获得的最大价值。
- 初始化:
- dp 数组初始化为0(默认无矿石时价值为0)。
- dp[0] = 0 表示容量为0时价值为0。
- 状态转移:
- 遍历每种矿石 i(从0到 n-1)。
- 对于每种矿石,逆序遍历背包容量 j(从 bagWeight 到 weight[i]),以避免重复使用同一物品(类似01背包的处理方式)。
- 对于每种矿石,尝试选择 k 个(从1到 nums[i],且确保 j - k * weight[i] >= 0),更新状态:
- 如果不选 k 个第 i 种矿石,价值为 dp[j]。
- 如果选 k 个第 i 种矿石,价值为 dp[j - k * weight[i]] + k * value[i]。
- 取两者最大值:dp[j] = Math.max(dp[j], dp[j - k * weight[i]] + k * value[i])。
- 这样,dp[j] 记录了在容量 j 下,考虑前 i+1 种矿石的最大价值。
- 优化:
- 使用逆序遍历背包容量(j 从大到小),确保每个状态只使用前面的 dp 值,模拟01背包的逻辑,避免重复计算。
- 直接在 dp 数组上更新,节省空间(一维数组代替二维)。
- 时间复杂度:
- 外层循环(矿石种类):O(n)。
- 中层循环(背包容量):O(bagWeight)。
- 内层循环(矿石数量):O(max(nums[i]))。
- 总时间复杂度:O(n * bagWeight * max(nums[i]))。
- 在题目范围内(n <= 100, bagWeight <= 2000, nums[i] <= 1000),复杂度可接受。
- 空间复杂度:
- 使用一维 dp 数组:O(bagWeight)。
- 输入数组 weight, value, nums:O(n)。
- 总空间复杂度:O(bagWeight)(忽略输入数组)。
代码
import java.util.Scanner;
class multi_pack {
public static void main(String[] args) {
// 创建Scanner对象读取输入
Scanner sc = new Scanner(System.in);
// 输入宇航舱容量C和矿石种类数N
int bagWeight = sc.nextInt(); // 宇航舱容量
int n = sc.nextInt(); // 矿石种类数
// 初始化数组存储每种矿石的重量、价值和数量上限
int[] weight = new int[n]; // 每种矿石的重量
int[] value = new int[n]; // 每种矿石的价值
int[] nums = new int[n]; // 每种矿石的可用数量
// 读取输入数据
for (int i = 0; i < n; i++) weight[i] = sc.nextInt();
for (int i = 0; i < n; i++) value[i] = sc.nextInt();
for (int i = 0; i < n; i++) nums[i] = sc.nextInt();
// 创建动态规划数组,dp[j]表示容量为j时能获得的最大价值
int[] dp = new int[bagWeight + 1];
// 遍历每种矿石
for (int i = 0; i < n; i++) {
// 逆序遍历背包容量(从bagWeight到weight[i])
for (int j = bagWeight; j >= weight[i]; j--) {
// 遍历当前矿石的可用数量(1到nums[i])
for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) {
// 状态转移:选择k个第i种矿石,更新dp[j]
dp[j] = Math.max(dp[j], dp[j - k * weight[i]] + k * value[i]);
}
}
}
// 输出最大价值
System.out.println(dp[bagWeight]);
}
}
198.打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 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 。提示:
1 <= nums.length <= 1000 <= nums[i] <= 400
原理
- 定义状态:
- 使用一维数组 dp,其中 dp[i] 表示偷窃前 i 间房屋(包括第 i 间,索引从1开始)能获得的最大金额。
- 目标是求 dp[nums.length]。
- 初始化:
- dp[0] = 0(默认,空房屋金额为0)。
- 对于 i = 1,只能偷第1间房屋(nums[0]),所以 dp[1] = nums[0]。
- 状态转移:
- 对于每间房屋 i(i >= 2),有两种选择:
- 偷第 i 间房屋(金额为 nums[i-1]),则不能偷第 i-1 间房屋,最大金额为 dp[i-2] + nums[i-1]。
- 不偷第 i 间房屋,则最大金额为前 i-1 间房屋的最大金额 dp[i-1]。
- 取两者最大值:dp[i] = Math.max(dp[i-2] + nums[i-1], dp[i-1])。
- 对于 i = 1,只能偷第1间房屋,dp[1] = nums[0]。
- 空间优化可能性:
- 由于状态转移只依赖 dp[i-1] 和 dp[i-2],可以用两个变量代替数组,进一步优化空间复杂度到 O(1)。但当前代码使用数组,逻辑更直观。
- 时间复杂度:
- 遍历房屋:O(nums.length)。
- 每次状态转移为常数时间:O(1)。
- 总时间复杂度:O(nums.length)。
- 空间复杂度:
- 使用 dp 数组:O(nums.length)。
代码
import java.util.*;
class Solution {
public int rob(int[] nums) {
// 创建动态规划数组,dp[i]表示偷窃前i间房屋(包括第i间)的最大金额
int[] dp = new int[nums.length + 1];
// 遍历每间房屋
for (int i = 1; i <= nums.length; i++) {
if (i >= 2) {
// 如果i>=2,考虑两种选择:
// 1. 偷第i间(nums[i-1]),加上前i-2间的最大金额(dp[i-2])
// 2. 不偷第i间,取前i-1间的最大金额(dp[i-1])
dp[i] = Math.max(dp[i - 2] + nums[i - 1], dp[i - 1]);
} else {
// 如果i=1,只能偷第1间房屋
dp[i] = nums[i - 1];
}
}
// 返回偷窃前n间房屋的最大金额
return dp[nums.length];
}
}
213.打家劫舍II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例 1:
输入:nums = [2,3,2] 输出:3 解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。示例 2:
输入:nums = [1,2,3,1] 输出:4 解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。 偷窃到的最高金额 = 1 + 3 = 4 。示例 3:
输入:nums = [1,2,3] 输出:3提示:
1 <= nums.length <= 1000 <= nums[i] <= 1000
原理
- 问题分解:
- 由于首尾房屋相邻,偷第0间房屋就不能偷第n-1间房屋,反之亦然。因此,可以将问题分解为两个线性“打家劫舍”子问题:
- 情况1:偷第0间房屋,不偷第n-1间房屋,相当于解决线性问题 nums[0:n-2](前n-1间房屋)。
- 情况2:不偷第0间房屋,偷第n-1间房屋,相当于解决线性问题 nums[1:n-1](从第1间到第n-1间)。
- 最终结果是两种情况的最大值。
- 定义状态:
- 使用两个数组:
- dp1[i]:表示偷窃前 i 间房屋(nums[0:i-1],不包括第n-1间)的最大金额。
- dp2[i]:表示偷窃从第1间到第 i 间房屋(nums[1:i],不包括第0间)的最大金额。
- 目标是比较 dp1[n-1] 和 dp2[n-1],取最大值。
- 初始化:
- dp1[1] = nums[0]:只偷第0间房屋。
- dp2[1] = nums[1]:只偷第1间房屋。
- 特殊情况:
- 如果 n = 1,直接返回 nums[0]。
- 如果 n = 2,返回 max(nums[0], nums[1]),因为不能偷相邻房屋。
- 状态转移:
- 对于 dp1[i](不偷第n-1间):
- 偷第i-1间房屋(nums[i-1]),则不能偷第i-2间,金额为 dp1[i-2] + nums[i-1]。
- 不偷第i-1间房屋,金额为 dp1[i-1]。
- 取最大值:dp1[i] = max(dp1[i-1], dp1[i-2] + nums[i-1])。
- 对于 dp2[i](不偷第0间):
- 偷第i间房屋(nums[i]),则不能偷第i-1间,金额为 dp2[i-2] + nums[i]。
- 不偷第i间房屋,金额为 dp2[i-1]。
- 取最大值:dp2[i] = max(dp2[i-1], dp2[i-2] + nums[i])。
- 时间复杂度:
- 遍历房屋(i 从2到n-1):O(n)。
- 每次状态转移为常数时间:O(1)。
- 总时间复杂度:O(n)。
- 空间复杂度:
- 使用两个 dp 数组:O(n)。
- 注:可以用滚动变量优化到 O(1),但当前代码使用数组更直观。
代码
import java.util.*;
class Solution {
public int rob(int[] nums) {
// 特殊情况处理:如果只有1间房屋,直接返回其金额
if (nums.length == 1) return nums[0];
// 特殊情况处理:如果有2间房屋,返回金额较大的那间
if (nums.length == 2) return Math.max(nums[0], nums[1]);
// dp1[i]表示偷窃[0,i]且不偷第n-1间房屋的最大金额
int[] dp1 = new int[nums.length];
// dp2[i]表示偷窃[1,i]且不偷第0间房屋的最大金额
int[] dp2 = new int[nums.length];
// 初始化:dp1[1]表示只考虑第0间房屋,dp2[1]表示只考虑第1间房屋
dp1[1] = nums[0];
dp2[1] = nums[1];
// 遍历从第2间房屋到第n-1间房屋
for (int i = 2; i < nums.length; i++) {
// dp1:不偷第n-1间房屋,等价于线性打家劫舍[0,i-1]
// 选择偷第i-1间(dp1[i-2]+nums[i-1])或不偷(dp1[i-1])
dp1[i] = Math.max(dp1[i - 1], dp1[i - 2] + nums[i - 1]);
// dp2:不偷第0间房屋,等价于线性打家劫舍[1,i]
// 选择偷第i间(dp2[i-2]+nums[i])或不偷(dp2[i-1])
dp2[i] = Math.max(dp2[i - 1], dp2[i - 2] + nums[i]);
}
// 返回两种情况的最大值:
// 1. 不偷最后一间(dp1[n-1])
// 2. 不偷第一间(dp2[n-1])
return Math.max(dp1[nums.length - 1], dp2[nums.length - 1]);
}
}
337.打家劫舍III
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为
root。除了
root之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。给定二叉树的
root。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。示例 1:
输入: root = [3,2,3,null,3,null,1] 输出: 7 解释: 小偷一晚能够盗取的最高金额 3 + 3 + 1 = 7示例 2:
输入: root = [3,4,5,1,3,null,1] 输出: 9 解释: 小偷一晚能够盗取的最高金额 4 + 5 = 9提示:
- 树的节点数在
[1, 104]范围内0 <= Node.val <= 104
原理
- 定义状态:
- 对于每个节点,返回一个数组 [notRob, rob],其中:
- notRob(res[0]):不偷当前节点时,当前子树能获得的最大金额。
- rob(res[1]):偷当前节点时,当前子树能获得的最大金额。
- 最终答案是根节点的 [notRob, rob] 中的最大值。
- 递归过程:
- 基线情况:如果节点为空,返回 [0, 0],表示不偷或偷都得0。
- 递归步骤:
- 对当前节点的左右子节点递归调用,得到 left[notRob, rob] 和 right[notRob, rob]。
- 计算当前节点的 res[0](不偷当前节点):
- 可以选择偷或不偷左子节点(max(left[0], left[1]))。
- 可以选择偷或不偷右子节点(max(right[0], right[1]))。
- 总金额为 max(left[0], left[1]) + max(right[0], right[1])。
- 计算当前节点的 res[1](偷当前节点):
- 必须不偷左右子节点(因为父子节点相邻)。
- 总金额为 root.val + left[0] + right[0]。
- 返回 [res[0], res[1]]。
- 后序遍历:
- 使用后序遍历(先处理左右子树,再处理当前节点),因为当前节点的状态依赖于子节点的状态。
- 时间复杂度:
- 每个节点访问一次,递归处理 O(n) 个节点(n 为节点数)。
- 每个节点的操作(比较、加法)为 O(1)。
- 总时间复杂度:O(n)。
- 空间复杂度:
- 递归栈深度最坏为 O(n)(树为链状时)。
- 每个递归调用返回固定大小数组(O(1))。
- 总空间复杂度:O(n)(递归栈)。
代码
class Solution {
public int rob(TreeNode root) {
// 调用递归函数,获取包含根节点和不含根节点的最大金额
int[] res = robAction1(root);
// 返回两种情况的最大值:偷根节点或不偷根节点
return Math.max(res[0], res[1]);
}
// 递归函数,返回一个数组res,其中:
// res[0]表示不偷当前节点的最大金额
// res[1]表示偷当前节点的最大金额
int[] robAction1(TreeNode root) {
// 初始化结果数组,res[0]为不偷,res[1]为偷
int[] res = new int[2];
// 基线情况:如果节点为空,返回[0, 0]
if (root == null) {
return res;
}
// 递归处理左子树和右子树
int[] left = robAction1(root.left);
int[] right = robAction1(root.right);
// 不偷当前节点:可以选择偷或不偷左右子节点,取最大值
res[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
// 偷当前节点:必须不偷左右子节点,加上当前节点的值
res[1] = root.val + left[0] + right[0];
// 返回当前节点的两种情况结果
return res;
}
}
121.买股票的最佳时机
给定一个数组
prices,它的第i个元素prices[i]表示一支给定股票第i天的价格。你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回
0。示例 1:
输入:[7,1,5,3,6,4] 输出:5 解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。 注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。示例 2:
输入:prices = [7,6,4,3,1] 输出:0 解释:在这种情况下, 没有交易完成, 所以最大利润为 0。提示:
1 <= prices.length <= 1050 <= prices[i] <= 104
原理
- 定义状态:
- 使用数组 dp[2]:
- dp[0]:表示当前持有股票时的最大利润(可能是负数,买入成本)。
- dp[1]:表示当前不持有股票时的最大利润(卖出或未交易)。
- 目标是求 dp[1],因为最终利润来自不持有股票(卖出或不交易)。
- 初始化:
- 第1天(i=0):
- dp[0] = -prices[0]:买入股票,利润为负价格。
- dp[1] = 0:未交易,利润为0。
- 状态转移:
- 对于每一天 i(从第2天开始,i=1 到 prices.length):
- 持有股票(dp[0]):
- 继续持有:保持前一天的 dp[0]。
- 当天买入:利润为 -prices[i-1](负价格)。
- 取最大值:dp[0] = max(dp[0], -prices[i-1])。
- 不持有股票(dp[1]):
- 继续不持有:保持前一天的 dp[1]。
- 当天卖出:前一天持有(dp[0])加上当天价格(prices[i-1])。
- 取最大值:dp[1] = max(dp[1], dp[0] + prices[i-1])。
- 由于每次循环更新 dp[0] 和 dp[1],使用滚动数组(仅两个变量)即可。
- 优化:
- 原问题可以用二维 dp[i][0/1] 表示第 i 天的状态,但由于状态只依赖前一天,使用两个变量(dp[0], dp[1])即可,空间优化到 O(1)。
- 循环从 i=1 到 prices.length,通过 prices[i-1] 访问第 i 天价格。
- 时间复杂度:
- 遍历数组:O(n),n 为 prices.length。
- 每次操作(比较、加法):O(1)。
- 总时间复杂度:O(n)。
- 空间复杂度:
- 使用固定大小数组 dp[2]:O(1)。
代码
class Solution {
public int maxProfit(int[] prices) {
// 创建dp数组,dp[0]表示持有股票的最大利润,dp[1]表示不持有股票的最大利润
int[] dp = new int[2];
// 初始化:第1天买入股票,利润为负价格
dp[0] = -prices[0]; // 持有股票(买入)
dp[1] = 0; // 不持有股票(未交易)
// 遍历从第2天到最后一天
for (int i = 1; i <= prices.length; i++) {
// dp[0]:持有股票的最大利润
// 1. 前一天已持有(dp[0])
// 2. 当天买入(-prices[i-1])
dp[0] = Math.max(dp[0], -prices[i - 1]);
// dp[1]:不持有股票的最大利润
// 1. 前一天已不持有(dp[1])
// 2. 当天卖出(前一天持有+当天价格:dp[0] + prices[i-1])
dp[1] = Math.max(dp[1], dp[0] + prices[i - 1]);
}
// 返回不持有股票的最大利润(最终卖出或不交易的结果)
return dp[1];
}
}
更多推荐


所有评论(0)