第一轮面试准备到第26题
一 解题步骤
对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
二 基础题模板
递推公式,要站dp【i】的角度去想,想象dp【i】是由哪些前面的状态推倒而来的。
class Solution {
public int fib(int n) {
// 边界处理
if(n <= 1){
return n;
}
//1 确定数组含义
// 下标是指第几个数
// dp[i] 指第i个数对应的值
//2 确定递推公式
// dp[i] = dp[i - 1] + dp[i - 2]
//3 初始dp数组
int dp[] = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
//4 确定遍历顺序
for(int i = 2; i <= n; i++){
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
三 背包类型题目总览
01背包:每件物品只能用一次
完全背包:每件物品无限用
四 0-1背包题模板
1 二维dp数组模板
遍历顺序以及循环顺序无要求
46. 携带研究材料(第六期模拟笔试) (kamacoder.com)
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int n = scanner.nextInt();
int bagweight = scanner.nextInt();
int[] weight = new int[n];
int[] value = new int[n];
for (int i = 0; i < n; ++i) {
weight[i] = scanner.nextInt();
}
for (int j = 0; j < n; ++j) {
value[j] = scanner.nextInt();
}
int[][] dp = new int[n][bagweight + 1];
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
for (int i = 1; i < n; i++) {
for (int j = 0; j <= bagweight; 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]);
}
}
}
System.out.println(dp[n - 1][bagweight]);
}
}
2 一维dp数组模板
两次循环时,必须先循环遍历物品。再倒序遍历空间
46. 携带研究材料(第六期模拟笔试) (kamacoder.com)
import java.util.*;
public class Main{
public static void main (String[] args) {
// 录取 M 代表研究材料的种类, N,代表小明的行李空间。
Scanner input = new Scanner(System.in);
int m = input.nextInt();
int n = input.nextInt();
// M 个正整数,代表每种研究材料的所占空间。
int weight[] = new int[m];
for(int i = 0; i < m; i++){
weight[i] = input.nextInt();
}
// M 个正整数,代表每种研究材料的价值。
int value[] = new int[m];
for(int i = 0; i < m; i++){
value[i] = input.nextInt();
}
//初始化dp数组
int dp[] = new int[n + 1];
for(int i = 0; i <= n; i++){
dp[i] = 0;
}
for(int i = 0; i < m; i++){
for(int j = n; j >= weight[i]; j--){
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
System.out.println(dp[n]);
}
}
3 是否能装满一定容量背包类型
class Solution {
public boolean canPartition(int[] nums) {
if(nums == null || nums.length == 0){
return false;
}
//遍历数组计算总结果
int sum = 0;
for(int i = 0; i < nums.length; i++){
sum += nums[i];
}
//如果结果是奇数,那么无法分割
if(sum % 2 != 0){
return false;
}
int target = sum / 2;
//初始dp数组
int dp[][] = new int[nums.length][target + 1];
//第一行
for(int i = nums[0]; i <= target; i++){
dp[0][i] = nums[0];
}
//第一列
for(int j = 0; j < nums.length; j++){
dp[j][0] = 0;
}
for(int i = 1; i < nums.length; i++){
for(int j = 0; j <= target; j++){
if(j < nums[i]){
dp[i][j] = dp[i - 1][j];
}else{
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i]);
}
}
}
return dp[nums.length - 1][target] == target;
}
}
/*
//边界判断
if(nums == null || nums.length == 0){
return false;
}
//遍历数组计算总结果
int sum = 0;
for(int i = 0; i < nums.length; i++){
sum += nums[i];
}
//如果结果是奇数,那么无法分割
if(sum % 2 != 0){
return false;
}
int target = sum / 2;
//初始01背包
//明确含义:dp[i] 代表 从0到i 的最大价值,也就是最大的和 i是第几个数,i也是数字i的重量
int dp[] = new int[target + 1];
for(int i = 0; i <= target; i++){
dp[i] = 0;
}
for(int i = 0; i < nums.length; i++){
for(int j = target; j >= nums[i]; j--){
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
return dp[target] == target;
*/
4 一定容量尽可能装满背包类型
1049. 最后一块石头的重量 II - 力扣(LeetCode)
class Solution {
public int lastStoneWeightII(int[] stones) {
//处理边界
if(stones.length == 0 || stones == null){
return 0;
}
int n = stones.length;
//计算总和
int sum = 0;
for(int i = 0; i < n; i++){
sum += stones[i];
}
//一半
int target = sum / 2;
//初始dp数组
int dp[] = new int[target + 1];
for(int i = 0; i < n; i++){
for(int j = target; j >= stones[i]; j--){
dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - 2 * dp[target];
}
}
5 装满背包有多少种方法类型
class Solution {
public int findTargetSumWays(int[] nums, int target) {
// 数组的和
int sum = 0;
for(int num : nums){
sum += num;
}
// 背包容量
// 求背包容量公式:int bagsize = (sum + target) / 2;
// 要先判断是否有效
if(Math.abs(target) > sum){
return 0;
}
if((sum + target) % 2 != 0){
return 0;
}
// 背包容量
int bagsize = (sum + target) / 2;
// 定义dp数组
int dp[] = new int[bagsize + 1];
dp[0] = 1;
for(int i = 0; i < nums.length; i++){
for(int j = bagsize; j >= nums[i]; j--){
dp[j] += dp[j - nums[i]];
}
}
return dp[bagsize];
}
}
6 两个维度装满背包类型
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
// 定义dp数组,两个维度
int dp[][] = new int[m + 1][n + 1];
int zeroCount = 0;
int oneCount = 0;
//遍历每一个元素
for(String s : strs){
zeroCount = 0;
oneCount = 0;
// 记录0和1的数量
for(char c : s.toCharArray()){
if(c == '0'){
zeroCount++;
}else{
oneCount++;
}
}
for(int i = m; i >= zeroCount; i--){
for(int j = n; j >= oneCount; j--){
dp[i][j] = Math.max(dp[i][j], dp[i - zeroCount][j - oneCount] + 1);
}
}
}
return dp[m][n];
}
}
五 完全背包模板
52. 携带研究材料(第七期模拟笔试) (kamacoder.com)
用一维数组和0-1背包模板区别:1 完全背包在两层遍历的时候,顺序没有要求
2 第二层遍历,是顺序遍历不是倒叙遍历
import java.util.*;
public class Main{
public static void main (String[] args) {
Scanner input = new Scanner(System.in);
//种类和行李空间
int n = input.nextInt();
int v = input.nextInt();
int value[] = new int[n];
int weight[] = new int[n];
// 记录种类和对应的空间
for(int i = 0; i < n; i++){
int wi = input.nextInt();
int vi = input.nextInt();
value[i] = vi;
weight[i] = wi;
}
int dp[] = new int[v + 1];
for(int i = 0; i < n; i++){
for(int j = weight[i]; j <= v; j++){
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
System.out.println(dp[v]);
}
}
1 求装满一定容量背包的组合数类型
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
class Solution {
public int change(int amount, int[] coins) {
// 记录数组的长度
int len = coins.length;
// 背包容量
int bigSize = amount;
// 初始化dp数组
int dp[] = new int[bigSize + 1];
dp[0] = 1;
for(int i = 0; i < coins.length; i++){
for(int j = coins[i]; j <= bigSize; j++){
dp[j] += dp[j - coins[i]];
}
}
return dp[bigSize];
}
}
2 求装满一定容量背包的排列数类型
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!
class Solution {
public int combinationSum4(int[] nums, int target) {
int len = nums.length;
int dp[] = new int[target + 1];
dp[0] = 1;
for(int i = 0; i <= target; i++){
for(int j = 0; j < nums.length; j++){
if(i >= nums[j]){
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
}
六 普通题概览
斐波那契数
class Solution {
public int fib(int n) {
// 边界处理
if(n <= 1){
return n;
}
//1 确定数组含义
// 下标是指第几个数
// dp[i] 指第i个数对应的值
//2 确定递推公式
// dp[i] = dp[i - 1] + dp[i - 2]
//3 初始dp数组
int dp[] = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
//4 确定遍历顺序
for(int i = 2; i <= n; i++){
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
爬楼梯
class Solution {
public int climbStairs(int n) {
// 边界处理
if(n <= 3){
return n;
}
//1 确定数组的含义
//i 代表第几层楼梯
//dp[i] 代表有多少种方法
//2 初始化数组
int dp[] = new int[n + 1];
dp[1] = 1;
dp[2] = 2;
dp[3] = 3;
dp[4] = 5;
//3 定义dp公式
//dp[i] = dp[i - 1] + dp[i - 2];
//4 从左到右遍历
for(int i = 4; i <= n; i++){
dp[i] = dp[i - 1] + dp[i - 2];
}
//5 打印第n街楼梯可以到达的方法数
return dp[n];
}
}
使用最小花费爬楼梯
class Solution {
public int minCostClimbingStairs(int[] cost) {
//1 处理边界
// if(cost.length <= 1){
// return 0;
// }
//2 初始dp数组
int dp[] = new int[cost.length + 1];
dp[0] = 0;
dp[1] = 0;
//3 确定递推公式
// dp[i] = Math.min(dp[i - 1] + cost[i], dp[i - 2] + cost[i]);
//4 遍历
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];
}
}
不同路径
class Solution {
public int uniquePaths(int m, int n) {
//1 初始化数组
int dp[][] = new int[m + 1][n + 1];
for(int i = 0; i <= m; i++){
dp[i][0] = 1;
}
for(int j = 0; j <= n; j++){
dp[0][j] = 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];
}
}
不同路径 II
class Solution {
// 计算给定障碍物网格中从左上角到右下角的唯一路径数
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
// 获取网格的行数和列数
int x = obstacleGrid.length; // 行数
int y = obstacleGrid[0].length; // 列数
// 初始化动态规划数组,多添加一行和一列是为了处理边界情况
int dp[][] = new int[x + 1][y + 1];
// 初始化第一行,如果遇到障碍物,则该行后续位置的路径数为0
for(int i = 0; i < x; i++){
if(obstacleGrid[i][0] == 1){ // 如果第一列有障碍物
break; // 则该行后续位置的路径数为0,直接跳出循环
}
dp[i][0] = 1; // 如果没有障碍物,则路径数为1(只能从左边来)
}
// 初始化第一列,如果遇到障碍物,则该列后续位置的路径数为0
for(int i = 0; i < y; i++){
if(obstacleGrid[0][i] == 1){ // 如果第一行有障碍物
break; // 则该列后续位置的路径数为0,直接跳出循环
}
dp[0][i] = 1; // 如果没有障碍物,则路径数为1(只能从上边来)
}
// 遍历网格,从第二行第二列开始(因为第一行和第一列已经初始化)
for(int i = 1; i < x; i++){
for(int j = 1; j < y; j++){
// 如果当前位置没有障碍物
if(obstacleGrid[i][j] == 0){
// 则当前位置的路径数为左边和上边路径数之和
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
// 如果当前位置有障碍物,则路径数为0
}
}
// 返回右下角的路径数,即从左上角到右下角的唯一路径数
return dp[x - 1][y - 1];
}
}
整数拆分(难)
class Solution {
/**
* 将一个正整数 n 拆分成 k 个正整数的和,使得这些整数的乘积最大。
* @param n 需要拆分的正整数
* @return 最大乘积
*/
public int integerBreak(int n) {
// 初始化动态规划数组,长度为 n+1,因为问题中 n 从 0 开始
int dp[] = new int[n + 1];
// 特殊情况处理
dp[0] = 0; // 如果 n 为 0,没有数字可以拆分,乘积为 0
dp[1] = 0; // 如果 n 为 1,乘积为 0,因为没有正整数可以拆分
dp[2] = 1; // 如果 n 为 2,最优拆分是 1+1,乘积为 1
// 从 3 开始遍历,因为 0、1、2 已经处理
for(int i = 3; i <= n; i++){
// 尝试所有可能的拆分方式
for(int j = 1; j <= i - j; j++){ // j 表示拆分的第一个数,i-j 表示第二个数
// 更新 dp[i] 的值,取当前值和拆分后乘积的最大值
// 比较直接乘以 (i - j) 和拆分 (i - j) 的两种情况
dp[i] = Math.max(dp[i], Math.max(j * (i - j), j * dp[i - j]));
}
}
// 返回 n 的最大乘积
return dp[n];
}
}
不同的二叉搜索树(难)
二叉搜索树的性质是 左中右 对应的数值是 小中大 所以才有了dp的推导式
class Solution {
/**
* 计算给定数量的节点可以形成的不同二叉搜索树(BST)的数量。
* @param n 节点的数量
* @return 不同二叉搜索树的数量
*/
public int numTrees(int n) {
// 初始化动态规划数组,长度为 n+1,因为问题中 n 从 0 开始
int dp[] = new int[n + 1];
// 特殊情况处理
dp[0] = 1; // 如果没有节点,只有一种空树的情况
dp[1] = 1; // 如果有一个节点,只有一种BST,即单个节点本身
// 从 2 开始遍历,因为 0 和 1 已经处理
for(int i = 2; i <= n; i++){
// 对于每个节点数 i,尝试所有可能的根节点
for(int j = 1; j <= i; j++){
// 计算以 j 为根的二叉搜索树的数量
// 左子树有 j-1 个节点,右子树有 i-j 个节点
// 根据组合数的性质,dp[i] += dp[j - 1] * dp[i - j]
dp[i] += dp[j - 1] * dp[i - j];
}
}
// 返回 n 个节点可以形成的不同二叉搜索树的数量
return dp[n];
}
}
按规则计算统计结果
LCR 191. 按规则计算统计结果 - 力扣(LeetCode)
class Solution {
/**
* 计算数组中每个元素的左乘积和右乘积,并将结果存储在返回数组中。
*
* @param arrayA 输入的整数数组
* @return 一个新数组,其中每个元素是原数组对应位置元素的左乘积和右乘积的乘积
*/
public int[] statisticalResult(int[] arrayA) {
// 检查输入数组是否为空或null,如果是,则直接返回原数组
if (arrayA == null || arrayA.length == 0) {
return arrayA;
}
// 获取输入数组的长度
int len = arrayA.length;
// 初始化两个数组,分别用于存储每个元素的左乘积和右乘积
int left[] = new int[len];
int right[] = new int[len];
// 第一个元素的左乘积为1(因为没有更左边的元素)
left[0] = 1;
// 最后一个元素的右乘积为1(因为没有更右边的元素)
right[len - 1] = 1;
// 计算每个元素的左乘积
for (int i = 1; i < len; i++) {
// 左乘积是当前元素和前一个元素左乘积的乘积
left[i] = left[i - 1] * arrayA[i - 1];
}
// 计算每个元素的右乘积
for (int i = len - 2; i >= 0; i--) {
// 右乘积是当前元素和后一个元素右乘积的乘积
right[i] = right[i + 1] * arrayA[i + 1];
}
// 初始化返回数组,用于存储每个元素的左乘积和右乘积的乘积
int ret[] = new int[len];
// 计算每个元素的左乘积和右乘积的乘积,并存储在返回数组中
for (int i = 0; i < len; i++) {
ret[i] = right[i] * left[i];
}
// 返回计算结果
return ret;
}
}
单词拆分
import java.util.HashSet;
import java.util.List;
import java.util.Set;
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
// 将单词字典转换为一个集合,便于快速查找
Set<String> set = new HashSet<>(wordDict);
// 获取字符串 s 的长度
int n = s.length();
// 创建一个布尔类型的动态规划数组 dp,长度为 n + 1
// dp[i] 表示字符串 s 的前 i 个字符是否可以被拆分成字典中的单词
boolean[] dp = new boolean[n + 1];
// 初始化 dp[0] 为 true,因为空字符串可以被拆分(不需要拆分)
dp[0] = true;
// 外层循环:遍历字符串 s 的所有可能的子串长度
for (int i = 1; i <= n; i++) {
// 内层循环:对于长度为 i 的子串,尝试所有可能的拆分点 j
for (int j = 0; j < i; j++) {
// 检查两个条件:
// 1. dp[j] 为 true,即前 j 个字符可以被拆分
// 2. 从 j 到 i 的子串是否在字典中
if (dp[j] && set.contains(s.substring(j, i))) {
// 如果两个条件都满足,说明前 i 个字符可以被拆分
dp[i] = true;
// 找到一个有效的拆分点后,提前结束内层循环
break;
}
}
}
// 返回 dp[n],表示整个字符串 s 是否可以被拆分
return dp[n];
}
}
三角形最小路径和
import java.util.List;
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
if (n == 0)
return 0;
// dp[i][j] := triangle[0..i] 到达第 i 层第 j 个元素的最小路径和
int[][] dp = new int[n][n];
dp[0][0] = triangle.get(0).get(0);
// 自顶向下填表
for (int i = 1; i < n; i++) {
// 每层第一个只能从上一层第一个下来
dp[i][0] = dp[i - 1][0] + triangle.get(i).get(0);
// 中间位置可从 dp[i-1][j-1] 或 dp[i-1][j] 转移
for (int j = 1; j < i; j++) {
dp[i][j] = Math.min(dp[i - 1][j - 1], dp[i - 1][j])
+ triangle.get(i).get(j);
}
// 每层最后一个只能从上一层最后一个转移
dp[i][i] = dp[i - 1][i - 1] + triangle.get(i).get(i);
}
// 在最后一层取最小
int ans = dp[n - 1][0];
for (int j = 1; j < n; j++) {
ans = Math.min(ans, dp[n - 1][j]);
}
return ans;
}
}
最大正方形
class Solution {
public int maximalSquare(char[][] matrix) {
// 获取矩阵的行数和列数
int n = matrix.length; // 行数
int m = matrix[0].length; // 列数
// 创建一个动态规划数组 dp,dp[i][j] 表示以 (i, j) 为右下角的最大正方形的边长
int dp[][] = new int[n][m];
// 用于记录最大正方形的边长
int max = 0;
// 遍历矩阵的每个元素
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
// 如果当前元素为 '0',则无法构成正方形,dp[i][j] 为 0
if (matrix[i][j] == '0') {
dp[i][j] = 0;
}
// 如果当前元素为 '1'
if (matrix[i][j] == '1') {
// 如果是第一行或第一列,只能构成边长为 1 的正方形
if (i == 0 || j == 0) {
dp[i][j] = 1;
} else {
// 对于其他位置,dp[i][j] 的值取决于其左、上、左上三个位置的最小值加 1
// 这是因为以 (i, j) 为右下角的正方形,其边长受限于这三个位置的最小正方形边长
dp[i][j] = Math.min(
dp[i - 1][j], // 上方的正方形边长
Math.min(dp[i][j - 1], dp[i - 1][j - 1]) // 左方和左上方的正方形边长
) + 1;
}
}
// 更新最大正方形的边长
max = Math.max(max, dp[i][j]);
}
}
// 返回最大正方形的面积(边长的平方)
return max * max;
}
}
最大子数组和
class Solution {
public int maxSubArray(int[] nums) {
// 初始化前缀和变量,用于存储当前子数组的和
int preSum = 0;
// 初始化最大子数组和,初始值为数组的第一个元素
int max = nums[0];
// 遍历数组中的每个元素
for (int x : nums) {
// 更新前缀和:
// 如果前缀和加上当前元素 x 的值大于 x 本身,则保留前缀和加上 x 的值;
// 否则,直接从当前元素 x 开始新的子数组
preSum = Math.max(x, preSum + x);
// 更新最大子数组和:
// 比较当前的最大子数组和与当前的前缀和,取较大值
max = Math.max(max, preSum);
}
// 返回最终的最大子数组和
return max;
}
}
乘积最大子数组
class Solution {
public int maxProduct(int[] nums) {
// 获取数组的长度
int n = nums.length;
// 创建两个数组,分别用于存储以第 i 个元素结尾的子数组的最大乘积和最小乘积
int maxF[] = new int[n];
int minF[] = new int[n];
// 初始化第一个元素的最大乘积和最小乘积
maxF[0] = nums[0];
minF[0] = nums[0];
// 遍历数组,从第二个元素开始
for (int i = 1; i < n; i++) {
// 计算以第 i 个元素结尾的子数组的最大乘积
// 有三种可能:
// 1. 当前元素本身
// 2. 前一个最大乘积与当前元素的乘积
// 3. 前一个最小乘积与当前元素的乘积(负数乘以负数可能变成最大值)
maxF[i] = Math.max(maxF[i - 1] * nums[i], Math.max(nums[i], minF[i - 1] * nums[i]));
// 计算以第 i 个元素结尾的子数组的最小乘积
// 有三种可能:
// 1. 当前元素本身
// 2. 前一个最小乘积与当前元素的乘积
// 3. 前一个最大乘积与当前元素的乘积(正数乘以负数可能变成最小值)
minF[i] = Math.min(minF[i - 1] * nums[i], Math.min(nums[i], maxF[i - 1] * nums[i]));
}
// 初始化结果为第一个元素的最大乘积
int res = maxF[0];
// 遍历 maxF 数组,找到最大值
for (int i = 1; i < n; i++) {
res = Math.max(res, maxF[i]);
}
// 返回最终结果
return res;
}
}