跳至主要內容

skill-10-动态规划

holic-x...大约 206 分钟算法算法

难度说明:🟢简单🟡中等🔴困难

学习资料

学习目标

  • 掌握数据结构核心基础
  • 借助数据结构完成常见题型

skill-10-动态规划

理论基础

1.核心理论

贪心 VS 动态规划

​ 动态规划(DP,Dynamic Programming),如果某一问题有很多重叠子问题,使用动态规划是最有效的。所以动态规划中每一个状态一定是由上一个状态推导出来的,对比贪心,贪心没有状态推导,而是从局部直接选最优的

​ 例如动态规划最经典的问题:【背包问题】有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i]每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。这个题目的解答可以从两个角度切入:

  • 动态规划思路:dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])

  • 贪心思路:从最大的物品开始拿,在不超过背包总容量的情况下依次装入,直到背包装不下,和上一个状态无关

  • 但是显然贪心无法解决动态规划问题(例如4 3 2,背包容量为5这种情况)

    • 贪心思路:取4装入,那么后面的3、2肯定时装不下的,此时贪心思路得到结果为4。但实际上我可以不要4,选择3+2的组合就能找到更适合的方案,因此仅仅依靠贪心思路是无法解决动态规划问题的

实际做题中不用死扣两者的区别,主要抓住核心:

  • 贪心是局部最优推导全局最优
  • 动态规划是用于解决重叠子问题,其是基于前一个状态推导出来的

动态规划解题步骤

dp核心解题步骤:

  • (1)定义dp[]数组(明确其含义)
  • (2)确定递推公式
  • (3)初始化dp
  • (4)构建dp(确定遍历顺序)
  • (5)验证dp(举例推导dp数组)

​ 此处先确定递推公式,然后再初始化dp,主要是因为递推公式的定义往往决定着如何去初始化dp,但有时候不必纠结于着两步的严格先后,主要是理解dp数组的定义和其演变(即如何构建),然后在验证的过程中去调整(对于dp问题要善于枚举、发现规律、推导,然后基于这个公式去构建,所以有些场景下对于dp问题会出现很极端的两种情况,就是会的都会,错的一定不对。因为理解题意和发现推导公式之后,整个思路就会顺畅很多,但是如果没能理解规律和递推公式,就算死记硬背也是于事无补)

dp空间优化版本:

​ 基于上述对dp问题解决方案的分析,可以看到需要构建一个dp数组来保存整个数据状态的演变,但是一些问题场景中可能仅仅是需要取到某个结果,而不关心这个过程的全部内容。因此此处可以考虑要获取dp[i]的话可否可以优化空间占用,也就是说看需不需要记录整个dp[]的内容。对于一些场景来说,主要关注递推公式dp[i]会受到什么影响,如果说dp[i]仅和一些特定的前几个状态或者当前遍历元素有关,那么可以考虑通过借助滚动变量的思路来滚动存储前几个状态的内容

​ 例如dp[i] = max{dp[i-1] , nums[i]}这种形式,可以看到dp[i]仅和dp[i-1]nums[i]相关,其中nums[i]是当前遍历元素可以直接拿到,而dp[i-1]是上一状态的值,因此可以考虑不需要构建整个dp[],而是通过定义滚动变量的思路来不断利用这个临时空间,例如定义pre来始终存储上一状态的值dp[i-1]

动态规划如何 debug?

​ 对于一开始接触dp题型,可能最常见的做法就是直接刷题解,然后按照自己的理解过一遍AC就完事。可能一次两次作对是运气,但真正需要debug的时候就会陷入一种混沌态,不知道debug如何下手。因此在前期可以尝试依赖题解去刷,但是后期重刷题目的时候一定要带着自己的理解和模板规范去刷题,要做到条理清晰,而不是简单把dp递推公式背下来就完事,需要对全局进行把控,精确到每一步直到自己想要的答案是什么,如果没有得到预期结果该从哪里切入调整,不要死记硬背

​ 最基础的方式就是打印dp数组,递推公式的推导也是基于枚举构建的,可以根据一些基础的例子试图去推导发现规律,得到递推公式之后,在遍历的过程中就可以将这个演变的过程打印出来,以便跟踪定位和后续的验证。如果发现打印的内容和自身预先推导的数据不一致,则需要从dp定义、初始化、递推公式、遍历顺序等多个方面切入,看具体是哪个环节出现问题(调整代码细节)

debug断点定位也是一个技巧,但要对debug的内容做到心中有数

⚽动态规划背包问题(递推公式总结)

此处是针对一维数组的解题思路构建的递推公式:

2.技巧总结

① 基础题目

🚀 基础题目:

  • 509-斐波那契数
  • 070-爬楼梯
  • 746-使用最小花费爬楼梯
  • 062-不同路径
  • 063-不同路径II
  • 343-整数拆分
  • 096-不同的二叉搜索树

② 背包问题(01背包、完全背包、多重背包)

对于背包问题的处理,基于dp 五部曲去构建,结合题意理清背包问题的所属分类,随后定义dp、确定遍历顺序进行构建

🚀 背包问题:核心掌握01背包问题和完全背包问题

  • 背包问题核心
    • ① 01背包:选择装、不装
      • 01背包核心:判断当前容量j是否可以放入物品i,分情况讨论
        • dp[i][j] 表示 背包容量为j的背包选择放入物品i时可以获得的最大物品价值(可以选择放或不放)
          • j≥nums[i](可以放入物品i):dp[i][j]=max{dp[i-1][j],dp[i-1][j-nums[i]] + nums[i]}(可选择放或不放,择选获得最大价值的策略)
          • j<nums[i](容量不足以放入物品):dp[i][j]=dp[i-1][j](继承上一状态的最大容量)
      • 01背包问题的二维、一维转化
        • 二维:内外层选择(先物品后背包或者先背包后物品均可)、从小到大遍历
        • 一维:内外层选择(必须先物品后背包)、背包是逆序遍历(避免值覆盖的影响)
    • ② 完全背包:装满背包 (完全背包问题与01背包问题的算法主要体现在遍历顺序的不同
      • 完全背包问题的组合、排列、最值问题
        • 一维:内外层选择(取决于求解的场景)、正序遍历
          • 对于【组合】问题:先物品后背包 + 正序遍历
          • 对于【排列】问题:先背包后物品 + 正序遍历
          • 其他最值求解场景,内外层顺序不限 + 正序遍历
    • ③ 多重背包:平展成普通的背包问题进行处理
      • 多重背包(平展成01背包进行处理):将相关数组(weight[]value[] 根据各自值的数量平展成一个数组,转化为01背包问题处理)

背包问题解题模板参考open in new window

image-20241125112609140
  • 01背包(核心基础)
    • 01背包核心基础:二维数组解法、一维数组(滚动存储)解法
      • 二维数组:先物品后背包、先背包后物品均可
      • 一维数组:先物品后背包(滚动存储,避免数据被覆盖),背包逆序(确保每个物品只能被取用1次)
    • 416-分割等和子集dp[i][j](m为物品个数nums.length,n为sum/2(此处sum如果为奇数是无法划分等和子集的可以直接排除))
      • dp 定义:dp[i][j] 表示背包j装入物品i所能获得的最大物品价值
      • dp 递推:
        • j<nums[i](容量j不足以存放物品i):dp[i][j] = dp[i-1][j]
        • j≥nums[i](容量j可以存放物品i,选择放或者不放):dp[i][j] = max{dp[i-1][j],dp[i][j-nums[i]] + nums[i]}
      • 返回值:return dp[m-1][sum/2]==sum/2
    • 1049-最后一块石头的重量II:思路与【416】概念类似,此处将题目转化为return sum - 2*dp[m-1][bagSize](此处bagSize=sum/2
      • 动态规划:参考【416】的动态规划思路,构建dp数组
      • 返回值:return sum - 2*dp[m-1][bagSize]
    • 494-目标和
      • 思路1:回溯思路(基于回溯法计算元素和),当遍历到数组尾部则收集结果
      • 思路2:动态规划思路(left = (target + sum)/2,求dp[m-1][left]
        • dp 定义:dp[i][j] 表示[0,i]的物品凑满容量j的方法数
        • dp 递推:
          • j<nums[i](容量j不足以存放物品i):dp[i][j] = dp[i-1][j]
          • j≥nums[i](容量j可以存放物品i,选择放或者不放):dp[i][j] = max{dp[i-1][j],dp[i][j-nums[i]] + nums[i]}
        • 核心思路:理解left = (sum + target)/2(left - right = targetleft + right = sum 转化得到)
        • 返回值:return dp[m-1][left]
    • 474-一和零
      • dp[i][j] 基于"01背包问题的一维数组"思路,此处ij均表示背包维度(两个背包,分别表示0、1的最多个数/背包容量)
      • dp[i][j] = max{dp[i][j],dp[i-zeroNum][j-oneNum] + 1}(基于一维数组的遍历顺序:先物品后背包逆序)
  • 完全背包(由0-1背包演变而来,主要区别在于完全背包的物品数量是无限的
    • 322-零钱兑换(典型完全背包,不强调组合或者排列)
    • 518-零钱兑换II(组合数):先物品后背包 + 正序遍历
    • 377-组合总和IV(排列数):先背包后物品 + 正序遍历
    • 070-爬楼梯(完全背包解法)
    • 279-完全平方数(完全背包解法)
    • 139-单词拆分(完全背包思路)
      • 核心思路:如果(0,i)范围字符串可以被拆分,则引入一个中间点j也应满足(0,j)(j,i)均可被拆分 =》dp[i] = dp[j] && wd.contains(s.subString(j,i))
      • dp 定义:dp[i] 表示(0,i)的范围内字符串可以被拆分,基于递推公式梳理,此处s的长度为背包容量、字典集合为物品,因此采用先遍历背包后遍历物品的思路进行处理较好理解
  • 多重背包

③ 打家劫舍

🚀 打家劫舍:

  • 198-打家劫舍

    • dp[i]偷窃到第i间房屋可得的最大金额,从两种方案中择选dp[i] = max{dp[i-1],dp[i-2]+nums[i]}(偷或者不偷)
  • 213-打家劫舍II

    • 平展换:将环平展,转化为【198】思路,两次遍历偷窃区域,选择最大的方案max{robRange[0,n-2], robRange[1,n-1]}
  • 337-打家劫舍III

    • 树形:递归(dfs):基础dfs、记忆化递推(优化)

      • 遍历到每个节点,根据节点偷、不偷的两种情况讨论,返回可偷窃的最大金额

        • 偷该节点:跳过子节点,考虑孙子节点(注意null判断处理)
          • val1 += (node.val + dfs(node.left.left) + dfs(node.left.right) + dfs(node.right.left) + dfs(node.right.right))
        • 不偷该节点:考虑子节点
          • val2 += (dfs(node.left) + dfs(node.right))
        • 选择max{val1,val2}
      • 记忆化递推优化

        • 问题分析:基于上述dfs方式进行递归,会造成节点的重复推导(因为在处理子节点的时候已经将孙子节点的推导一遍了,重复递归处理就会导致算法执行超时,处理效率低下)
        • 定义map用于存储已经遍历过的节点及其最大偷窃方案的金额(Map<TreeNode,Integer>
    • 动态规划:本质上也是基于dfs遍历的思路(LRD),此处定义的dp数组是用于表示每个节点的不同偷窃方案下的最大金额

      • dp 定义:int[] dp = new int[2]dp[0] 表示不偷该节点可获得的最大金额,dp[1]表示偷该节点可获得的最大金额)
      • 递归出口:node==null =》return new int[]{0,0}
      • 递推公式:LRD 处理顺序
        • Lint[] left = dfs(node.left)
        • Rint[] right= dfs(node.right)
        • D
          • 不偷(则考虑偷其左、右节点,对于其子节点也可选择偷或不偷):dp[0] = max{left[0],left[1]} + max{right[0],right[1]}
          • 偷(则不能偷其左、右节点):dp[1] = node.val + left[0] + right[0]
          • 最大偷窃方案:return max{dp[0],dp[1]}

④ 股票问题

🚀 股票问题:

  • 121-买卖股票的最佳时机(只能买卖一次)

    • 贪心思路:低买高卖(以历史最低价买入,日内价格卖出所获得的利润,基于此校验获取最大利润)
    • 动态规划:dp[i][2](第i天持有股票、不持有股票的情况讨论,只能买卖一次)
  • 122-买卖股票的最佳时机II(可以买卖多次)

    • 贪心思路:收集做T正利润(相邻交易日如果做T存在正利润则收集)
    • 动态规划:dp[i][2](第i天持有股票、不持有股票的情况讨论,可以买卖多次)
    • 区分:【121】【122】算法的核心区别在于股票是否可以交易多次,【121】由于限定只能交易1次则对于日内买入的前导状态只能是初始化状态下买入,而对于【122】可以交易多次,因此对于其日内买入的签到状态应该为前一日未持有状态下买入
  • 123-买卖股票的最佳时机III(最多买卖2次)

    • 动态规划:dp[i][5](分状态讨论:0、第1次持有、第1次不持有、第2次持有、第2次不持有)
  • 188-买卖股票的最佳时机IV(最多买卖k次)

    • 动态规划:dp[i][2*k+1](分状态讨论:0、【k次持有】的状态讨论、【k次不持有】的状态讨论,共2*k+1种状态)
  • 309-最佳买卖股票时机含冷冻期(买卖多次,卖出有1天冷冻期)

    • 动态规划:dp[i][4](分状态讨论:0-持有;1-不持有(保持状态);2-不持有(今日卖出);3-冷冻),先勾勒状态转移图,然后进行推导
      • 此处将不持有状态拆分,是考虑到冷冻期概念,其前置状态是前一天卖出,因此要将不持有状态进一步细分(也可以理解为冷冻期是特殊的一种不持有状态,冷冻期只有1天,因此可以可以从冷冻状态买入到持有状态)
  • 714-最佳买卖股票时机含手续费(买卖多次,每次交易有手续费)

    • 动态规划:dp[i][2](分状态讨论:第i天持有股票、不持有股票的情况讨论,可以买卖多次,需在卖出的时候注意手续费处理(要么在买入的时候处理,要么在卖出的时候处理,只处理一次))

⑤ 子序列问题

🚀 子序列问题

​ 对于子序列的问题,选择定义一维还是二维dp则是取决于问题目标是计算一个字符串还是比较两个字符串

  • 如果是求一个字符串的最长递增的子序列(连续/不连续)问题,则是基于一维dp来解决

    • 例如dp[i] 表示以i位置元素结尾的(连续/不连续)子序列的最大长度
  • 如果是求两个字符串的公共问题(公共最长子序列、最长重复子数组),则是基于二维dp来解决

    • 例如公共子序列/子数组问题:dp[i][j] 表示以下标i-1结尾的A、j-1结尾的B 最长公共子序列 / 最长重复子数组长度
    • 例如编辑距离问题(通过增删元素校验两个字符串)
  • 如果是回文子序列问题,基于二维dp来解决,此处二维dp的定义表示的是区间(画图、举例分析,理解推导核心)

    • 例如【647-回文子串(暴力检索、动态规划)】,boolean[][] dpdp[i][j]表示[i,j]区间的字符串是否为回文序列)
    • 例如【516-最长回文子序列】,int[][] dpdp[i][j]表示[i,j]区间的字符串的最长回文子序列长度)
  • 子序列(不连续)

    • 300-最长上升子序列(对标【674-最长连续递增序列】)
      • dp[i]表示以i位置元素结尾的元素,dp[i]=max{dp[i],dp[j]+1}(dp[i]的取值要么是自身,要么是考虑拼在上一个最长的上升子子序列元素后面)
    • 1143-最长公共子序列(对标【718-最长重复子数组】)
      • dp[i][j]表示[0,i-1]的A、[0,j-1]的B的最长公共子序列的长度
        • A[i-1]==B[j-1]dp[i][j]=dp[i-1][j-1]+1
        • A[i-1]!=B[j-1]dp[i][j]=max{左侧,上方}=max{dp[i][j-1],dp[i-1][j]}(可以理解为将累加值一直传递的概念)
    • 1035-不相交的线(与【1143-最长公共子序列】思路完全一致,转化为求最长公共子序列问题)
  • 子序列(连续)

    • 674-最长连续递增序列
      • dp[i]表示以i位置元素结尾的元素,dp[i]=max{dp[i],dp[i-1]+1}(dp[i]的取值要么是自身,要么是考虑拼在其前一个元素后面)
    • 718-最长重复子数组
      • dp[i][j]表示[0,i-1]的A、[0,j-1]的B的最长重复子数组的长度
        • A[i-1]==B[j-1]dp[i][j]=dp[i-1][j-1]+1 (可以理解为累加统计连续1的概念)
        • A[i-1]==B[j-1]dp[i][j]=0(可以理解为连续重复子数组断掉了)
    • 053-最大子序和
      • dp[i]表示以元素i结尾的最大子数组和
      • dp[i]=max{dp[i-1] + nums[i],nums[i]}(判断是否可以拼接在前一位,否则自成一派)
  • 编辑距离

    • 392-判断子序列(双指针、动态规划)
      • dp[i][j]表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]
      • 递推公式:
        • s[i - 1] == t[j - 1]dp[i][j]=dp[i-1][j-1]+1
        • s[i - 1] != t[j - 1]dp[i][j]=dp[i][j-1]
    • 115-不同的子序列
      • dp[i][j]i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]
      • 递推公式:s[i-1]匹配为参考
        • s[i-1]==t[j-1]dp[i][j]=dp[i-1][j-1] + dp[i-1][j](可由两部分构成:选择使用或者不使用s[i-1]的情况)
        • s[i-1]!=t[j-1]dp[i][j]=dp[i-1][j](只有一部分:无法使用s[i-1]的情况)
    • 583-两个字符串的删除操作
      • 动态规划(【最小删除步数】维度)
        • i-1为结尾的字符串word1,和以j-1为结尾的字符串word2,想要达到相等,所需要删除元素的最少次数
        • 递推公式:
          • word1[i-1]==word2[j-1]:当前元素相等,不需要删除元素(i、j向前移动,继续判断下一个位置)=》dp[i][j]=dp[i-1][j-1]
          • word1[i-1]!=word2[j-1]:当前元素不相等,需要删除元素,有3种情况操作分析 =》dp[i][j]=min{dp[i-1][j]+1,dp[i][j-1]+1,dp[i-1][j-1]+2}
      • 动态规划(【最长公共子序列】维度)
        • 此处【最小删除步数】可以转化为【1143-最长公共子序列】的思路,求达到相等的最小删除步数,转化为先求出最长公共子序列长度x,那么达到相等时的最小删除步数为len1+len2-2*x
    • 072-编辑距离
      • dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]
      • 递推公式:
        • word1[i-1]==word2[j-1]dp[i][j]=dp[i-1][j-1](元素匹配,无需操作,继承上一匹配状态的最近编辑距离)
        • word1[i-1]!=word2[j-1]dp[i][j]=min{dp[i-1][j-1],dp[i-1][j],dp[i][j-1]}+1(元素不匹配,需考虑删除、添加、替换三种情况讨论)
  • 回文

    • 647-回文子串(暴力检索、动态规划)
      • dp[i][j]表示区间[i,j]范围内的字符串是否为回文(true:是、false:否)
      • 递推公式:根据s[i]s[j]的值是否相等分情况讨论,进一步判断ij的间隔位置选择递推方案
        • s[i]!=s[j]dp[i][j]=false(非回文)
        • s[i]==s[j]
          • 情况①:i==j,此时s[i]==s[j]肯定成立 =》 dp[i][j]=true
          • 情况②:i与j相差1,例如aa形式也是回文字符串 =》 dp[i][j]=true
          • 情况③:i与j相差大于1,例如abcba形式 =》dp[i][j]=dp[i+1][j-1]
      • 遍历顺序:基于递推公式分析,此处应采用从下往上、从左往右的遍历顺序,且在这个过程中需要注意对结果的收集(即不同情况下如果推导发现dp[i][j]true时则需统计回文个数)
    • 516-最长回文子序列
      • 字符串s在[i, j]范围内最长的回文子序列的长度dp[i][j](在回文基础上判断,只关注长度取值,不用额外校验回文)
      • 递推公式:
        • s[i]==s[j]dp[i][j] = dp[i + 1][j - 1] + 2 (此处不讨论i、j差值问题是因为此处的推导基础是回文子序列,不需要额外判断是否回文,只关注取值)
        • s[i]!=s[j]dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])s[i]s[j]的同时加入并不能增加回文子序列的长度,因此看加入s[i]s[j]哪一个可以组成最长的回文子序列)
      • 遍历顺序:基于递推公式分析,此处采用从下往上、从左往右的遍历顺序,因此第一行的最后一个元素为结果(基于遍历顺序的最后一个递推结果为最大,因为它是基于其他元素推导过来的最后一个值)

常见题型

🚀基础题目

🟢509-斐波那契数

1.题目内容open in new window

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 01 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给定 n ,请计算 F(n)

示例 1:

输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

输入:n = 3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

输入:n = 4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3
2.题解思路
👻方法1:动态规划
  • 思路分析:
    • (1)dp定义:dp[i] 表示第i个斐波那契数
    • (2)递推公式:dp[i]=dp[i-1]+dp[i-2]
    • (3)初始化dpdp[0]dp[1]初始化
    • (4)构建dp(遍历顺序):从2开始遍历到n(因为要计算到下标n,因此dp定义长度为n+1
    • (5)验证dp
/**
 * 509 斐波那契数
 */
public class Solution1 {

    // 动态规划版本
    public int fib(int n) {
        // 特例校验
        if (n == 0 || n == 1) {
            return n;
        }

        // 1.定义dp
        int[] dp = new int[n + 1]; // dp[i]表示前两个数之和(i>2)

        /**
         * 2.推导公式
         * dp[0]=0,dp[1]=1,dp[i]=dp[i-1]+dp[i-2]
         */

        // 3.dp初始化
        dp[0] = 0;
        dp[1] = 1;

        // 4.构建dp(遍历顺序)
        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }

        // 返回第n个斐波那契数
        return dp[n];
    }
}
  • 复杂度分析

    • 时间复杂度:O(n)

    • 空间复杂度:O(n)需构建dp数组存储

空间优化版本

/**
 * 509 斐波那契数
 */
public class Solution2 {

    /**
     * 动态规划:空间优化版本
     */
    public int fib(int n) {
        // 特例校验
        if (n == 0 || n == 1) {
            return n;
        }

        int p = 0; // 原dp[i-2]
        int q = 1; // 原dp[i-1]
        int r = 0; // 原dp[i]

        // 4.构建dp(遍历顺序)
        for (int i = 2; i <= n; i++) {
            r = p + q; // 计算dp[i]
            // 变量往前滚动
            p = q;
            q = r;
        }

        // 返回第n个斐波那契数
        return r;
    }
}

🟢070-爬楼梯

1.题目内容open in new window

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

2.题解思路
👻方法1:
  • 思路分析:
    • (1)dp定义:dp[i]表示爬到第i阶的时候有多少种方案
    • (2)递推公式:基于枚举分析 =》dp[i]=dp[i-1]+dp[i-2]
      • 0阶:
      • 1阶:
      • 2阶:{1+1}、
      • 3阶:{1+2}、{1+1+1}、
      • 4阶:{1+1+2}、{2+2}、{1+2+1}、{1+1+1+1}、
      • 5阶:因为每阶只能选择爬1阶或2阶,因此要爬当前阶i则是由第i-2阶爬2层或者第i-1阶爬1层得到,所以第i阶的方案 = 第i-1的方案 + 第i-2的方案
    • (3)初始化dpdp[0]=1dp[1]=1
    • (4)构建dp(遍历顺序):
    • (5)验证dp
/**
 * 070-爬楼梯
 */
public class Solution1 {

    // 动态规划
    public int climbStairs(int n) {
        // 1.定义dp(dp[i]表示爬到第i阶的方案)
        int[] dp = new int[n + 1]; // 此处要计算n下标,则数组需要加长
        // 2.递推公式:dp[i] = dp[i-1] + dp[i-2]
        // 3.初始化dp
        dp[0] = 1;
        dp[1] = 1;
        // 4.构建dp
        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        // 返回结果
        return dp[n];
    }

}
  • 复杂度分析

    • 时间复杂度:O(n)

    • 空间复杂度:O(n)构建dp数组

动态规划:空间优化版本

/**
 * 070-爬楼梯
 */
public class Solution2 {

    // 动态规划:空间优化版本
    public int climbStairs(int n) {
        if (n == 0 || n == 1) {
            return 1;
        }
        int p = 1; // 对应dp[i-2]
        int q = 1; // 对应dp[i-1]
        int r = 0; // 对应dp[i]
        for (int i = 2; i <= n; i++) {
            r = p + q; // 处理dp[i]
            // 变量滚动
            p = q;
            q = r;
        }
        // 返回结果
        return r;
    }

}

🟢746-使用最小花费爬楼梯

1.题目内容open in new window

给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。

你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。

请你计算并返回达到楼梯顶部的最低花费。

示例 1:

输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。

示例 2:

输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。
2.题解思路
👻方法1:
  • 思路分析:

    • (1)dp定义:dp[i]表示爬到第i阶所需要支付的最低花费(i>1,前两阶需选择起点)

    • (2)递推公式:枚举推导

      • [10,15,20]为例
        • 出发点:需选择一个花费最少的作为起点,start=min{nums[0],nums[1]}
        • 第0阶:0
        • 第1阶:0
        • 第2阶:
          • 方案1:{0+2} 10
          • 方案2:{1+1} 15
          • .... 可以看到这个是爬楼梯的演进版本,此处一方面需要关注爬楼梯的方案,一方面需要关注爬楼梯的消费(不是越过不需要收费,也就是说到达第i阶这个点不用花费cost[i],而是越过它才会收费)。因为dp[i]爬楼梯的方案是i-1阶+i-2阶的方案数之和,因此此处只需要从这些方案中选择一个花费最少的消耗,即dp[i] = min{dp[i-1]+nums[i-1],dp[i-2]+nums[i-2]}dp[i-1]表示爬到第i-1层的最小消耗,需要加上本层消耗到达下一目标阶)
      • [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]为例
        • 第0阶:0
        • 第1阶:0
        • 第2阶:{0+2}:0+1(越过第0阶需要花费1)=1;{1+1}:0+100(越过第1阶需要花费100)=100 =》选择1
        • 第3阶:{0+1+2}:100;{0+2+1}:1、{1+1+1}:100 =》选择1
        • .....已经淘汰的方案无需重复讨论,每次只关心最小花费的方案(即第i-2阶爬2阶和第i-1阶爬1阶的方案中花费最小的二选一即可)

      image-20241123141605756

    • (3)初始化dpdp[0]=0dp[1]=0(起点无消耗)

    • (4)构建dp(遍历顺序):

    • (5)验证dp

/**
 * 746 使用最小花费爬楼梯
 */
public class Solution1 {
    public int minCostClimbingStairs(int[] cost) {
        int n = cost.length;
        // 1.构建dp(dp[i]表示爬到第i阶的最小消耗)
        int[] dp = new int[n + 1];
        // 2.递推公式dp[i] = min{dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]}

        // 3.初始化dp
        dp[0] = 0; // 起点无消耗
        dp[1] = 0; // 起点无消耗

        // 4.构建dp
        for (int i = 2; i <= n; i++) {
            dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
        }

        // 返回到达顶楼的最低消耗
        return dp[n];
    }
}
  • 复杂度分析

    • 时间复杂度:O(n)

    • 空间复杂度:O(n)构建辅助dp

动态规划:空间优化版本

/**
 * 746 使用最小花费爬楼梯
 */
public class Solution2 {
    // 动态规划:空间优化版本
    public int minCostClimbingStairs(int[] cost) {
        int n = cost.length;

        int p = 0; // 起点无消耗 dp[i-2]
        int q = 0; // 起点无消耗 dp[i-1]
        int r = 0; // dp[i] 到达第i阶所需要的最低消耗

        for (int i = 2; i <= n; i++) {
            r = Math.min(p + cost[i - 2], q + cost[i - 1]);
            // 滚动变量更新
            p = q;
            q = r;
        }

        // 返回到达顶楼的最低消耗
        return r;
    }
}

🟡062-不同路径

1.题目内容open in new window

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。

问总共有多少条不同的路径?

image-20241123143400071

2.题解思路
👻方法1:深度优先遍历(❌超时)
  • 思路分析:将机器人走过的路径抽象为一棵树,而终点对应的就是叶子节点,那么路径总数就是对应这颗树叶子节点个数。回归图论的dfs的做法
/**
 * 062 不同路径
 */
public class Solution1 {

    public int uniquePaths(int m, int n) {
        return dfs(1, 1, m, n);
    }

    /**
     * 深度优先搜索
     * i、j 表示当前遍历索引位置,m、n为对应边界
     */
    public int dfs(int i, int j, int m, int n) {
        // 递归出口
        if (i > m || j > n) {
            return 0; // 越界
        }
        // 走到右下角,说明找到一条路径
        if (i == m && j == n) {
            return 1;
        }
        // 递归
        return dfs(i + 1, j, m, n) + dfs(i, j + 1, m, n);
    }
}
  • 复杂度分析

    • 时间复杂度:树的深度为m+n-1(深度从1开始计算),二叉树节点的个数为2(m+n-1)-1,深度搜索的算法是遍历了整个二叉树(此处是近似遍历二叉树,可以看到深度搜索是指数级别的复杂度)

    • 空间复杂度:取决于递归的深度

👻方法2:动态规划
  • 思路分析:

    • (1)dp定义:dp[i][j] 表示从(0,0)出发到(i,j)有多少条不同的路径

    • (2)递推公式:

      • dp[i][j]的推导:其只能从dp[i-1][j](往下走1步)或者dp[i][j-1](往右走一步)得到(因为机器人行进的方向只能是向右或者向下,因此此处要推导某个位置的来源可以通过方向来分析)
      • 基于上述分析,同理得到dp[i-1][j](表示从(0,0)出发到(i-1,j)有多少条不同的路径)、dp[i][j-1](表示从(0,0)出发到(i,j-1)有多少条不同的路径),因此得到推导公式:dp[i][j]=dp[i-1][j] + dp[i][j+1](即dp[i][j]的取值由其相邻的两个节点的路径之和所决定)
    • (3)初始化dpdp[0][i]=1dp[j][0]=1

      • 此处为什么是dp[0][i]dp[j][0]初始化为1而不是dp[0][1]dp[1][0]:这是特殊性的一个考虑,因为从(0,0)往同一行或者同一列的方向行走时,其路径是重复覆盖的,始终只有1条。如果单纯只设置dp[0][1]dp[1][0]就会导致路径重复计算的问题
    • (4)构建dp(遍历顺序):双层嵌套循环遍历从(1,1)位置开始的所有节点,最终找到(m-1,n-1)位置

      image-20241123162417752
    • (5)验证dp

/**
 * 062 不同路径
 */
public class Solution2 {

    /**
     * 动态规划
     */
    public int uniquePaths(int m, int n) {
        // 1.定义dp[i][j]:表示从(0,0)到(i,j)位置共有多少条路径
        int[][] dp = new int[m][n]; // 如果m、n要作为数组下标的话,此处长度要扩宽为[m+1][n+1]
        /**
         * 2.递推公式
         * dp[i,j] 只能从两个方向过来:(i-1,j)向下走1步;(i,j-1)向右走1步
         * 基于此构建递推公式为:dp[i][j]=dp[i-1][j] + dp[i][j-1]
         */
        // 3.dp 初始化((0,0)往同一行或者同一列方向行进,路径始终只有1条)
        for (int i = 0; i < m; i++) {
            dp[i][0] = 1;
        }
        for (int j = 0; j < n; j++) {
            dp[0][j] = 1;
        }
        // 4.构建dp(从(1,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];
            }
        }
        // 返回结果((0,0)->(m-1,n-1)共有多少条路径)
        return dp[m - 1][n - 1];
    }

}
  • 复杂度分析

    • 时间复杂度:O(m×n)

    • 空间复杂度:O(m×n)

🟡063-不同路径II

1.题目内容open in new window

给定一个 m x n 的整数数组 grid。一个机器人初始位于 左上角(即 grid[0][0])。机器人尝试移动到 右下角(即 grid[m - 1][n - 1])。机器人每次只能向下或者向右移动一步。

网格中的障碍物和空位置分别用 10 来表示。机器人的移动路径中不能包含 任何 有障碍物的方格。

返回机器人能够到达右下角的不同路径数量。

测试用例保证答案小于等于 2 * 109

2.题解思路
👻方法1:动态规划
  • 思路分析:和【062-不同路径】类似,只不过此处需要注意的是路径中可能会出现障碍物,需要排除障碍物的影响(初始化及遍历时遇到障碍物的处理:初始化第0行/列遇到障碍物则不可继续行进,遍历处理时遇到障碍物则将当前障碍物点位的可达路径置为0)

    • 核心细节:①遇到障碍物dp[i][j]==0 ②初始化时需注意障碍之后路径不可达的情况(即一旦遇到障碍,则后面的路径不可达均设为0)

    • (1)dp定义:dp[i][j] 表示从(0,0)出发到(i,j)有多少条不同的路径

    • (2)递推公式:

      • dp[i][j]的推导:其只能从dp[i-1][j](往下走1步)或者dp[i][j-1](往右走一步)得到(因为机器人行进的方向只能是向右或者向下,因此此处要推导某个位置的来源可以通过方向来分析)
      • 基于上述分析,同理得到dp[i-1][j](表示从(0,0)出发到(i-1,j)有多少条不同的路径)、dp[i][j-1](表示从(0,0)出发到(i,j-1)有多少条不同的路径),因此得到推导公式:dp[i][j]=dp[i-1][j] + dp[i][j+1](即dp[i][j]的取值由其相邻的两个节点的路径之和所决定)
    • (3)初始化dp:基于(0,0)的第0行、第0列进行初始化(需注意此处障碍的设定,不是逐个设置1,而是要考虑障碍的影响,一旦出现了障碍物,则后面的路径都走不通)

      • // ❌❌❌原始思路(遇到障碍设为0)
        for (int i = 0; i < m; i++) {
            dp[i][0] = obstacleGrid[i][0] == 1 ? 0 : 1;
        }
        for (int j = 0; j < n; j++) {
            dp[0][j] = obstacleGrid[0][j] == 1 ? 0 : 1;
        }
        
        // 🟢🟢🟢正确思路(一旦遇到障碍,其后的均为0)
        for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
            dp[i][0] = 1; // 指定位置无障碍才有可通行路径
        }
        for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
            dp[0][j] = 1; // 指定位置无障碍才有可通行路径
        }
        
      • 此处为什么是dp[0][i]dp[j][0]初始化为1而不是dp[0][1]dp[1][0]:这是特殊性的一个考虑,因为从(0,0)往同一行或者同一列的方向行走时,其路径是重复覆盖的,始终只有1条。如果单纯只设置dp[0][1]dp[1][0]就会导致路径重复计算的问题,且此处由于障碍物的设定,在同一行或者同一列的方向上如果遇到障碍物,则其后的路径也是走不通的,因此for循环的处理是一旦遇到障碍物则赋值1的操作直接中断

    • (4)构建dp(遍历顺序):双层嵌套循环遍历从(1,1)位置开始的所有节点,最终找到(m-1,n-1)位置

      image-20241125084857051
/**
 * 063 不同路径II
 */
public class Solution2 {

    /**
     * 动态规划
     */
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m = obstacleGrid.length, n = obstacleGrid[0].length;

        // 特例判断:如果是起点或者终点遇到障碍则返回0
        if (obstacleGrid[0][0] == 1 || obstacleGrid[m - 1][n - 1] == 1) {
            return 0;
        }

        // 1.定义dp[i][j]:表示从(0,0)到(i,j)位置共有多少条路径
        int[][] dp = new int[m][n]; // 如果m、n要作为数组下标的话,此处长度要扩宽为[m+1][n+1]
        /**
         * 2.递推公式
         * dp[i,j] 只能从两个方向过来:(i-1,j)向下走1步;(i,j-1)向右走1步
         * 基于此构建递推公式为:dp[i][j]=dp[i-1][j] + dp[i][j-1]
         */
        // 3.dp 初始化((0,0)往同一行或者同一列方向行进,路径始终只有1条)
        for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
            dp[i][0] = 1; // 指定位置无障碍才有可通行路径
        }
        for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
            dp[0][j] = 1; // 指定位置无障碍才有可通行路径
        }
        // 4.构建dp(从(1,1)节点开始)
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                // 判断障碍物:如果(i,j)位置存在障碍物说明此路不通,则(0,0)到(i,j)的路径应为0
                dp[i][j] = (obstacleGrid[i][j] == 0) ? dp[i - 1][j] + dp[i][j - 1] : 0;
            }
        }
        // 返回结果((0,0)->(m-1,n-1)共有多少条路径)
        return dp[m - 1][n - 1];
    }

}
  • 复杂度分析

    • 时间复杂度:O(m×n)

    • 空间复杂度:O(m×n)

/**
 * 🟡 063 不同路径II - https://leetcode.cn/problems/unique-paths-ii/
 */
public class Solution1 {

    /**
     * 动态规划思路:对于每个点(i,j),到达该点的不同数量可由其左侧和上侧两个方向推导处理
     * 但是如果该点事障碍物所在点说明该点不可达,此时到达该点的路径为0
     */
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m = obstacleGrid.length, n = obstacleGrid[0].length;

        // 1.dp 定义(dp[i][j] 表示到达(i,j)点的不同路径和(不包括障碍物))
        int[][] dp = new int[m][n];

        /**
         * 2.dp推导:需根据当前点(i,j)是否存在障碍物进行判断
         * obstacleGrid[i][j] = 1(存在障碍物):该点不可达 =》dp[i][j]=0
         * obstacleGrid[i][j] = 0(不存在障碍物):该点可达 =》dp[i][j] = dp[i][j-1] + dp[i-1][j]
         */

        // 3.dp 初始化
        boolean hasRowObstacle = false;
        for (int j = 0; j < n; j++) {
            // 对于首行:一旦出现障碍物则当前点及后面的点都不可达
            if (obstacleGrid[0][j] == 1) {
                hasRowObstacle = true;
            }
            dp[0][j] = hasRowObstacle ? 0 : 1;
        }

        boolean hasColObstacle = false;
        for (int i = 0; i < m; i++) {
            // 对于首列,一旦出现障碍物则当前点及后面的点都不可达
            if (obstacleGrid[i][0] == 1) {
                hasColObstacle = true;
            }
            dp[i][0] = hasColObstacle ? 0 : 1;
        }

        // 4.dp 构建
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                // 根据当前点是否存在障碍物进行处理
                if (obstacleGrid[i][j] == 1) {
                    dp[i][j] = 0;
                } else if (obstacleGrid[i][j] == 0) {
                    dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
                }
            }
        }

        // 返回到达右下角的有效路径数
        return dp[m - 1][n - 1];
    }

}

🟡343-整数拆分

1.题目内容open in new window

给定一个正整数 n ,将其拆分为 k正整数 的和( k >= 2 ),并使这些整数的乘积最大化。

返回 你可以获得的最大乘积

示例 1:

输入: n = 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1。

示例 2:

输入: n = 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
2.题解思路
👻方法1:动态规划
  • 思路分析:
    • (1)dp定义:dp[i]表示分拆数字i可以得到的最大乘积

    • (2)递推公式:

      • i个数的拆分的乘积有多种表示方式(从1遍历到i):
        • dp[i] = dp[i-j]*j(基于动态规划思路的推导:可以理解为是将一个数拆分为4个数及以上的乘积)
        • dp[i] = (i-j)*j(基于数学公式的推导:可以理解为最单纯的拆分为两个数的乘积)
        • dp[]递推公式:dp[i] = max{dp[i],max{dp[i-j]*j,(i-j)*j}} 因为是在遍历递推过程中更新每次计算dp[i]取到最大的值
          • 外层遍历i用于计算
          • 内层遍历用于拆分:每次拆分从起点j开始拆分,直到拆分到i位置,在内部循环过程中不断寻找拆分的dp[i]的最大值
    • (3)初始化dp

      • 一些题解中会将dp[0]dp[1]初始化为1,目的是为了通过用例,有点强行解读的概念,但实际上此处的初始化无意义也无逻辑,应将其划分为特例情况讨论
      • dp[2]=11*1=1 从2开始进行初始化,随后确定遍历顺序构建dp[]
    • (4)构建dp(遍历顺序):

      • 双层遍历:
        • 外层遍历i用于计算:表示遍历到第i个数
        • 内层遍历用于拆分:每次拆分从起点j开始拆分,直到拆分到i位置,在内部循环过程中不断寻找拆分的dp[i]的最大值(j∈[1,i]
    • (5)验证dp

/**
 * 343 整数拆分
 */
public class Solution1 {
    public int integerBreak(int n) {
        // 特例判断
        if (n == 0 || n == 1) {
            return n;
        }
        // 1.定义dp(dp[i]表示第i个数拆分多个整数之后可构成的最大乘积)
        int[] dp = new int[n + 1]; // 需要计算到n为下标索引的位置,此处扩宽数组到n+1
        /**
         * 2.递推公式推导
         * a.两个数的数学拆分:(i-j)*j (拆分为2个数)
         * b.基于dp的概念拆分:dp[i-j]*j (拆分为多个数)
         * 在推导过程中演变(记录dp[i]的最大值)=》 dp[i] = max{dp[i],max{dp[i-j]*j,(i-j)*j}}
         */

        // 3.dp初始化
        dp[2] = 1; // 1*1=1

        // 4.构建dp
        for (int i = 3; i <= n; i++) { // 外部循环:遍历i用于计算(限制终点)
            for (int j = 1; j <= i/2; j++) { // 内部循环:遍历j用于拆分整数(控制起点:[1,i](优化只需要遍历一半的区间即可,后面都是对称的))
                dp[i] = Math.max(dp[i], Math.max(dp[i - j] * j, (i - j) * j));
            }
        }

        // 返回结果
        return dp[n];
    }
}
  • 复杂度分析

    • 时间复杂度:O(n2

    • 空间复杂度:O(n)

优化点:对于内层循环j的取值,此处设定是[1,i/2]。实际上此处只需要遍历一半的数据即可(后面都是对称的,没必要再计算一遍)

🟡096-不同的二叉搜索树

1.题目内容open in new window

给你一个整数 n ,求恰由 n 个节点组成且节点值从 1n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。

image-20241125095038692

2.题解思路
👻方法1:动态规划
  • 子问题分析:核心:dp[i] += L * R(即dp[j-1] * dp[i-j],L、R分别表示以j为根节点时左、右子树的节点个数构成的搜索树数量)
    • 有1个节点:只有1种情况
    • 有2个节点:有2种情况
    • 有3个节点:根据不同节点作为根节点的情况进行讨论
      • 当①为根节点的时候,其两种情况的左子树、右子树各自的布局和n==2的布局一致(此处只关注布局,不关注具体的数值)
      • 当②为根节点的时候,其左右节点都只有1个节点,布局和n==1的一致
      • 当③为根节点的时候,其两种情况的左子树、右子树各自有两个节点,其布局和n==2的布局一致
      • 基于上述分析,可以拆分重叠子问题(dp[i]表示拥有i个节点可以表示为多少个二叉搜索树)
        • dp[1]==1
        • dp[2]==2
        • dp[3]的表示为:①为头节点的二叉搜索树数量+②为头节点的二叉搜索树数量+③为头节点的二叉搜索树数量
          • ①为头节点的二叉搜索树数量:右子树有2个元素的搜索树数量 × 左子树有0个元素的搜索树数量
          • ②为头节点的二叉搜索树数量:右子树有1个元素的搜索树数量 × 左子树有1个元素的搜索树数量
          • ③为头节点的二叉搜索树数量:右子树有0个元素的搜索树数量 × 左子树有2个元素的搜索树数量
    • 同理,如果有4个节点,则讨论根据不同节点作为根节点的情况,最终将子树拆为有0、1、2个元素的搜索树情况讨论
      • ①为头节点的二叉搜索树数量:左0右3
      • ②为头节点的二叉搜索树数量:左1右2
      • ③为头节点的二叉搜索树数量:左2右1
      • ④为头节点的二叉搜索树数量:左3右0
      • dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]j为选定的头节点元素,左侧元素构建左子树,右侧构建右子树)
        • 选定头节点(分别构建左右子树,以当前节点为头节点构建的搜索树数量 = 左子树的搜索树数量 × 右子树的搜索树数量)
        • i个节点,则可以有i个根节点的选择方案,将这些方案构建的搜索树数量进行累加,即可得到dp[i]
image-20241125110641409
  • 思路分析:

    • (1)dp定义:dp[i]表示由i个节点构成的二叉搜索树数量

    • (2)递推公式:dp[i] += dp[j-1] * dp[i-j]j表示选择第j个节点作为根节点,分别构建其左右子树得到的二叉搜索树情况)

      • 以第j个节点作为根节点,[1,j-1]构建左子树L(有j-1个元素)、[j+1,i]构建右子树R(有i-j个元素),则其二叉搜索树的构建个数为L*R
      • dp[i]则是表示[0,i]之间所有节点都轮一遍作为根节点时可构建的二叉搜索树之和(即j∈[0,i]
    • (3)初始化dp:初始化dp[0]==1(表示子树节点为0个节点的时候可以构建1个二叉搜索树,所有的推导都是基于这个基础构建)

    • (4)构建dp(遍历顺序):

      • 外层遍历:确定i
      • 内层遍历:确定[0,i]之间每个节点作为根节点的情况
    • (5)验证dp

/**
 * 096 不同的二叉搜索树
 */
public class Solution1 {

    // 动态规划
    public int numTrees(int n) {
        // 1.dp定义(dp[i]表示i个节点构成的二叉搜索树的数量)
        int[] dp = new int[n + 1]; // 需计算到n,此处扩展到n+1长度

        /**
         * 2.dp推导
         * i个节点构成的二叉搜索树的数量 = 以每个节点作为根节点的二叉搜索树数量之和
         * 即 dp[i] += dp[j-1] * dp[i-j](左子树数量 * 右子树数量) 累加二叉搜索树之和
         */

        // 3.dp 初始化
        dp[0] = 1; // dp[0]表示子树元素个数为0的情况下课构成的二叉搜索树数量为1

        // 4.dp 构建
        for (int i = 1; i <= n; i++) { // 外层循环:确定i
            for (int j = 1; j <= i; j++) { // 内层循环:确定以哪个节点作为根节点(j∈[1,i],其中[1,j-1]用于构建左子树、[j+1,i]用于构建右子树)
                dp[i] += dp[j - 1] * dp[i - j]; // L左j-1个元素、R右i-j个元素 =》dp[i] += L * R
            }
        }

        // 返回结果
        return dp[n];
    }

}
  • 复杂度分析

    • 时间复杂度:O(n × n)

    • 空间复杂度:O(n)

🚀背包问题-①-01背包

⚽01背包问题基础(二维数组思路)

​ 01背包问题:有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i]每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大?

​ 这个问题的就基础解法是暴力回溯搜索列出所有可能的情况(时间复杂度O(n2))

案例分析:weight{1,3,4}value{15,20,30} 背包最大容量为4,求解背包能背的物品的最大价值?

【1】dp[]数组定义(二维数组:需要表示两个维度(物品,背包容量))

dp[i][j]表示将任意物品i放入背包容量为j的所能获得的最大价值总和(需判断容量是否可以放下物品i的情况),然后基于此填充二维数组

dp[i][j]表示容量为j的背包任意装入[0-i]的物品可以获得的最大价值总和

【2】dp[]推导

​ 对于每一件物品,可以选择放或者不放,如果放就要空出可以存放这个物品的容量,如果不放则其价值和上一状态相同,而最大价值就是要从这两个值当中择选:==且需注意对于放与不放的选择都是基于当前容量可以存放物品i的情况下讨论,如果本身j不足够存放物品i的情况下只能延续上一状态

  • 背包容量j本身不足以存放物品i

    • 这种情况下只能延续上一背包状态:dp[i][j]=dp[i-1][j]
  • 背包容量j本身足以存放物品i,可以选择放或者不放

    • 【情况1】不放物品idp[i][j]=dp[i-1][j]
    • 【情况2】放物品i:则需要先空出可以足够存放物品i的容量
      • 背包空出物品i的容量后剩余背包容量xx = j-weight[i]
      • 用这个剩余的背包容量x装载最大价值的内容(子问题拆分)+ 物品i的价值 即可得到【放物品i】的情况下可以得到的最大价值
      • dp[i][j]=dp[i-1][x] + value[i] => dp[i][j] = dp[i-1][j-weight[i]] + value[i]
  • 综合上述情况讨论,可以得到dp[i][j]的推导公式为:dp[i][j] = max {dp[i-1][j],dp[i-1][j-weight[i]] + value[i]}

image-20241125121016339

【3】dp初始化

​ 从二维数组的角度分析,需要初始化首行、首列,即对dp[i][0]dp[0][j]进行初始化

​ 基于推导公式可知,dp[i][j]的状态依赖于上一个物品的存放情况,因此对于dp的初始化起码要将首行进行初始化dp[0][j],而对于首列,由于j==0的情况下肯定是不能存放任何物品的,因此dp[i][0]0

【4】构建dp

外层物品内层背包容量 VS 外层背包容量内层物品?

dp的构建就是按照递推公式思路依次遍历按行或者按列填充数据。那么此处的按行或者按列填充实际上就是物品、背包容量的遍历顺序:

  • 按行填充:外层物品,内层背包容量(先外层遍历物品,然后内层遍历背包容量)
  • 按列填充:外层背包容量,内层物品(先外层遍历背包容量,然后内层遍历物品)

​ 从推导公式来分析:dp[i][j]的取值关注的是其左上角的值内容,因此无论是按行还是按列遍历,其左上角的值肯定是优先推导出来的,因此不管是哪种遍历方式,只要通过同样的推导公式进行处理,得到的结果一定是一致的,因此遍历顺序的选择相对自由(一般选择先物品后容量理解起来较为顺畅

  • j<weight[i]dp[i][j]=dp[i-1][j]

  • j>=weight[i]dp[i][j]=max{dp[i-1][j],dp[i-1][j-weight[i]]+value[i]}

image-20241125134834874

/**
 * 0-1 背包问题
 */
public class Solution1 {

    /**
     * 0-1 背包问题:每个物品只有1个
     *
     * @param bagWeight 背包容量
     * @param weight    物品重量
     * @param value     物品价值
     * @return
     */
    public int maxValue(int bagWeight, int[] weight, int[] value) {
        int m = weight.length; // 物品个数(物品种类:一个物品只有一个)
        int n = bagWeight; // 背包最大容量


        // 1.定义dp(dp[i][j]表示用背包容量为j的背包装物品i可以得到的最大物品价值)
        int[][] dp = new int[m][n + 1]; // i 为指定物品,j 为指定背包容量(背包容量需要计算到n,因此此处扩展为n+1容量)

        /**
         * 2.dp[i][j]推导
         * 当状态第i个物品时,需先判断当前容量可否装入物品i,在可装入物品i的前提下再去讨论放或者不放的情况
         * j<weight[i](当前容量j不足以装物品i):直接延续上一状态的最大价值 dp[i][j] = dp[i-1][j]
         * j>=weight[i](当前容量j足以装物品i,可选择放|不放两种方案中可以获得最大价值的方案):
         * - 不放:直接延续上一状态的最大价值 dp[i][j] = dp[i-1][j]
         * - 放:需要先空出存放当前物品的容量,然后判断剩余容量可获得的最大价值:dp[i][j] = dp[i-1][x] + value[i] = dp[i-1][j-weight[i]] + value[i]
         */

        // 3.dp初始化(首行、首列初始化)
        for (int j = 0; j <= n; j++) {
            // 判断当前背包容量是否可存放物品0
            dp[0][j] = (j >= weight[0]) ? value[0] : 0;
        }
        for (int i = 0; i < m; i++) {
            dp[i][0] = 0; // 背包容量为0时不能存放任何物品
        }

        // 4.构建dp(按行填充:外层物品、内层背包容量)
        for (int i = 1; i < m; i++) { // 外层物品
            for (int j = 1; j <= n; j++) { // 内层背包容量
                if (weight[i] > j) {
                    // 如果j无法存放物品i
                    dp[i][j] = dp[i - 1][j]; // 延续上一背包状态
                } else {
                    // 如果j可以存放物品i,可以选择放|不放,从两个方案中选择价值最大的方案
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
                }

            }
        }

        // 在所有的方案中选择价值最大的方案(打印dp检查状态)
        int maxVal = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j <= n; j++) {
                maxVal = Math.max(maxVal, dp[i][j]);
            }
        }

        print(dp);

        // 返回结果
        return maxVal; // 或者return dp[m-1][n]; 因为当前状态都是基于前面的状态选择最大的价值,因此其要么是继承前面的背包状态,要么是更新更大的背包价值,因此右下角的数一定会大于等于其左上角的值
    }

    public void print(int[][] dp){
        for (int i = 0; i < dp.length; i++) {
            for (int j = 0; j < dp[0].length; j++) {
                System.out.print(dp[i][j] + "-");
            }
            System.out.println(); // 换行
        }
    }

    public static void main(String[] args) {
        // 测试数据
        int[] weight = new int[]{1, 3, 4};
        int[] value = new int[]{15, 20, 30};
        Solution1 solution1 = new Solution1();
        System.out.println(solution1.maxValue(4, weight, value));
    }
}

// output:
0-15-15-15-15-
0-15-15-20-35-
0-15-15-20-35-
35

image-20241125140806187

​ 可以看到,在构建dp的时候,不管是哪种顺序遍历,推导公式都是一样的,唯一不同的处理在于遍历顺序的处理。通过两种方式遍历填充得到的数据是一致的

⚽01背包问题基础(滚动一维数组思路)

​ 对于背包问题其实状态都是可以进行压缩的。关注上述核心递推公式:dp[i][j]=max{dp[i-1][j],dp[i-1][j-weight[j]]+value[i]}

​ 可以看到dp[i][j]的状态依赖于上一行的状态,如果说在按行遍历的基础上,如果可以在遍历某一行的情况下将dp[i-1]拷贝到dp[i]那一层,那么这个递推公式就可以变成dp[i][j]=max{dp[i][j],dp[i][j-weight[j]]+value[i]},但显然基于对拷贝成本的思考,这个方案是不建议采纳的

​ 换个角度思考,既然每次只需要关注上一行的状态,是不是可以考虑用一个滚动的一维数组来进行存储,重复利用一维数组的存储空间

dp[i][j]表示容量为j的背包任意装入[0-i]的物品可以获得的最大价值总和

01背包问题基础(滚动一维数组思路)

【1】dp定义:dp[j] 表示容量为j的背包,所背的物品的最大价值为dp[j]

【2】dp递推公式:滚动一维数组的递推公式是基于二维数组演变的,实际上就是去掉i这个维度的内容:dp[j]=max{dp[j],dp[j-weight[i]]+value[i]}

  • dp[j]:为容量为j的背包所背的物品的最大价值
  • dp[j-weight[i]]+value[i]:这个数值可以从基于dp[x]概念推导

【3】dp数组初始化:dp[j]表示容量为j的背包容量可存放的最大价值

  • dp[0]表示容量为0不能装下这些物品,因此dp[0]应该初始化为0
  • 对于其他下标的值初始化则取决于j能否装下物品0(按照正常的递推公式处理即可)

【4】构建dp:外层物品,内层背包(逆序遍历背包容量:背包从大到小)

// 一维数组遍历(用物品遍历背包)
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}
  • 为什么要倒序遍历?:背包容量倒序遍历是为了确保物品i只背放入1次(正序遍历时会将上一状态的内容覆盖(因为后面的内容也可能会依赖前面的上一状态,如果正序遍历就会把这个值先覆盖掉了,那么后面拿到的就是覆盖后的内容而非原来的dp[x]进而导致出错),体现出来的效果就是物品被重复装入,而逆序遍历的话每次从后往前更新,并不会影响到前面的状态)本质上是一维滚动数组的重复利用,要避免正序遍历的覆盖影响,因此采用倒序填充的方式
    • 例如正序遍历情况下:
      • dp[1]=dp[1 - weight[0]] + value[0] = 15
      • dp[2] = dp[2 - weight[0]] + value[0] = 30(可以看到物品0被放入了两次)
    • 倒序遍历情况下:
      • dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
      • dp[1] = dp[1 - weight[0]] + value[0] = 15
  • 可否调整遍历顺序?先背包容量后物品?:基于上述分析,背包容量是倒序遍历的,如果将背包容量放在上一层,就会导致每个dp[j]只装入1个物品,计算错误
/**
 * 0-1 背包问题
 */
public class Solution3 {

    /**
     * 0-1 背包问题:每个物品只有1个
     *
     * @param bagWeight 背包容量
     * @param weight    物品重量
     * @param value     物品价值
     * @return
     */
    public int maxValue(int bagWeight, int[] weight, int[] value) {
        int m = weight.length; // 物品个数(物品种类:一个物品只有一个)
        int n = bagWeight; // 背包最大容量


        // 1.定义dp(dp[j]表示用背包容量为j的背包可以得到的最大物品价值)
        int[] dp = new int[n + 1]; // i 为指定物品,j 为指定背包容量(背包容量需要计算到n,因此此处扩展为n+1容量)

        /**
         * 2.dp[j]推导 实际上是基于二维数组的基础上去掉i维度概念
         * dp[j] = max{dp[j],dp[j-weight[i]]+value[i]}
         */

        // 3.dp初始化(对于dp[0]设为0,其他下标可以根据递推公式来进行初始化)

        // 4.构建dp
        for (int i = 0; i < m; i++) { // 外层物品(从0开始遍历)
            for (int j = n; j >= weight[i]; j--) { // 内层背包容量(逆序遍历:用于保证一个物品只能被取1次)
                // 如果j可以存放物品i,可以选择放|不放,从两个方案中选择价值最大的方案
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
            }
        }

        print(dp);

        // 返回结果
        return dp[n];
    }

    public void print(int[] dp) {
        for (int i = 0; i < dp.length; i++) {
            System.out.print(dp[i] + "-");
        }
    }


    public static void main(String[] args) {
        // 测试数据
        int[] weight = new int[]{1, 3, 4};
        int[] value = new int[]{15, 20, 30};
        Solution3 solution1 = new Solution3();
        System.out.println(solution1.maxValue(4, weight, value));
    }
}
// output:
0-15-15-20-35-35

对比二维数组,滚动一维数组的要点是什么?

  • 滚动一维数组的思路在于:去掉原有的i维度,滚动存储背包容量为j的背包装入物品i时的最大价值(dp[j]=max{dp[j],dp[j-weight[i]]+value[i]}
  • 遍历顺序的选择:
    • 二维数组可以选择【先物品后背包容量】或者【先背包容量后物品】的思路进行遍历,数据都有独立的存储,不会被覆盖影响,正序、逆序遍历填充均可
    • 一维数组是滚动存储概念,必须是【先物品后背包】+【背包逆序遍历】的顺序才能获取正确的结果
      • 在遍历新的物品i时,dp[j]需要依赖于上一状态的dp[x],如果采用正序遍历的思路,就会导致后面需要依赖的dp[x]被前面的更新操作覆盖了(体现出来的效果就是物品被重复添加进背包)进而导致出错。因此采用逆序遍历背包的思路,避免数据的覆盖影响
      • 同理,由于背包是逆序遍历的,如果采用【先背包容量后物品】的话,就会导致dp[j]只有一个物品加入(与01背包问题相悖)
        • 可以从二维数组的遍历角度切入:按行(先物品后背包容量)、按列(先背包容量后物品),而一维数组的引入是基于dp数组按行生成切入滚动存储的思路,如果选择了【先背包容量后物品】的遍历顺序,就和原切入点相悖了,所以此处滚动一维数组的遍历顺序不能是【先背包容量后物品】

🟡416-分割等和子集

1.题目内容open in new window

给你一个 只包含正整数非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。

示例 2:

输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。
2.题解思路

题型分析

​ 本题【416-分割等和子集】和【698-划分为k个相等的子集】、【473-火柴拼正方形】的思路是类似的,可以用回溯法进行暴力搜索,也可以采用01背包问题的思路。本题目的是找是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。假设集合元素总和为sum,也就是说,只要集合中出现【子集和为sum/2】的子集即满足条件

  • 【1】设定元素总和为sum,查找目标满足sum/2的子集

    • sum为奇数:不可能拆分为2个相等的子集

    • sum为偶数:进一步确认哪些元素可以构成sum/2,进一切换到01背包问题(从一堆物品中装入背包,看是否可以恰好装满背包)

  • 【2】转换为01背包问题:因此此处如果要从01背包问题的点切入的话,需思路如何套入到01背包问题模板,即明确对照的要素:

    • 背包体积:sum/2

    • 物品:集合中的元素(其重量为元素值、价值也为元素值)

    • 物品个数:每个物品不可重复放入(即只能选择放或者不放,经典的0-1背包问题)

    • 验证:如果背包正好装满(物品价值为sum/2),说明找到了总和为(sum/2)的子集

👻方法1:动态规划(01背包问题)
  • 思路分析:

    • (1)dp定义:dp[i][j] 表示容量为j的背包从[0,i]中取任意物品装入时的最大价值

    • (2)递推公式:

      • 原递推公式:dp[i][j]=max{dp[i-1][j],dp[i-1][j-[weight[i]]+value[i]]}(j>=weight[i])
      • 此处结合题意的设定是价值即为重量,因此上述递推公式为:
        • j<nums[i]dp[i][j]=dp[i-1][j](当前容量j不足以存放物品i,直接继承上一个状态)
        • j>=nums[i]dp[i][j]=max{dp[i-1][j],dp[i-1][j-[nums[i]]+nums[i]]}(当前容量j可以存放物品i,可以选择放或不放)
    • (3)初始化dp:首行、首列初始化

    • (4)构建dp(遍历顺序):按行 | 按列进行遍历

    • (5)验证dp

/**
 * 416 分割等和子集
 */
public class Solution1 {

    /**
     * 动态规划思路:切换为01背包问题
     *
     * @param nums 既是weight又是value
     *             背包容量设定为sum/2(表示分割成两个元素和相等的子集)
     */
    public boolean canPartition(int[] nums) {
        int n = nums.length;

        // 前置:遍历一遍数组元素,先计算出nums元素总和,得到目标的背包容量
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum += nums[i];
        }
        if (sum % 2 == 1) {
            return false; // 总和为奇数,不可能凑到
        }
        int bagSize = sum / 2;

        // 1.dp定义(dp[i][j])表示容量为j的背包,从[0-i]中选择任意物品装入可获得的最大容量
        int[][] dp = new int[n][bagSize + 1]; // i物品 j背包容量

        // 2.dp推导:dp = max{dp[i-1][j],dp[i-1][j-nums[i]]+nums[i]} (j>=nums[i])


        // 3.dp初始化
        for (int j = 0; j <= bagSize; j++) {
            // 首行初始化
            dp[0][j] = (j >= nums[0]) ? nums[0] : 0; // 此处nums[0]表示value[0]
        }

        // 4.构建dp
        for (int i = 1; i < n; i++) { // 外层物品
            for (int j = 1; j <= bagSize; 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]);
                }
                // 判断是否出现了dp[i][j]==sum/2,出现则说明满足
                /*
                if (dp[i][j] == bagSize) {
                    return true;
                }
                */
            }
        }
        // return false;
        return dp[n-1][bagSize]==bagSize;
    }

}
  • 复杂度分析

    • 时间复杂度:O(m×n) m 为物品个数(数组元素个数)、n为背包容量(元素总和/2)

    • 空间复杂度:O(m×n)

动态规划:01背包问题(空间优化版本)

dp[j]:容量为j的背包的最大价值(根据放入物品i进行滚动存储),重复利用一维数组空间(先物品后背包容量(按行遍历)+逆序遍历背包容量(避免覆盖)

/**
 * 416 分割等和子集
 */
public class Solution2 {

    /**
     * 动态规划思路:切换为01背包问题
     *
     * @param nums 既是weight又是value
     *             背包容量设定为sum/2(表示分割成两个元素和相等的子集)
     */
    public boolean canPartition(int[] nums) {
        int n = nums.length;

        // 前置:遍历一遍数组元素,先计算出nums元素总和,得到目标的背包容量
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum += nums[i];
        }
        if (sum % 2 == 1) {
            return false; // 总和为奇数,不可能凑到
        }
        int bagSize = sum / 2;

        // 1.dp定义(dp[j])表示容量为j的背包,可装入的最大容量
        int[] dp = new int[bagSize + 1]; // i物品 j背包容量

        // 2.dp推导:dp = max{dp[j],dp[j-nums[i]]+nums[i]} (j>=nums[i])

        // 3.dp初始化(dp[0]=0,其他按照下标元素进行更新)

        // 4.构建dp
        for (int i = 1; i < n; i++) { // 外层物品
            for (int j = bagSize; j >= nums[i]; j--) { // 内层背包(逆序遍历:确保同一个物品只有一个放入,避免重复覆盖)
                dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
                /*
                // 判断是否出现了dp[i][j]==sum/2,出现则说明满足
                if (dp[j] == bagSize) {
                    return true;
                }
                */
            }
        }
        // return false;
        return dp[bagSize] == bagSize;
    }

}
  • 复杂度分析

    • 时间复杂度:O(m×n) m 为物品个数(数组元素个数)、n为背包容量(元素总和/2)

      • 空间复杂度:O(n)n 为背包容量

🟡1049-最后一块石头的重量II

1.题目内容open in new window

有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。

每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 xy,且 x <= y。那么粉碎的可能结果如下:

  • 如果 x == y,那么两块石头都会被完全粉碎;
  • 如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x

最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0

示例 1:

输入:stones = [2,7,4,1,8,1]
输出:1
解释:
组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1],
组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1],
组合 2 和 1,得到 1,所以数组转化为 [1,1,1],
组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。

示例 2:

输入:stones = [31,26,33,21,40]
输出:5
2.题解思路

题意剖析

​ 本题类型和【416-分割等和子集】思路类似,只不过【416】的目的在于【判断是否可以切割为两个等和子集】,而【1049】的目的是【背包最多可以装多少】

​ 将本题看做:背包容量为sum/2weight[]value[]stones[]的【0-1背包问题】,最终返回的结果是sum - 2 * dp[m - 1][bagSize]

核心:尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,以此化解成01背包问题

👻方法1:动态规划(01背包问题)
  • 思路分析:

    • (1)dp定义:dp[i][j] 表示容量为j的背包从[0,i]中取任意物品装入时的最大价值

    • (2)递推公式:

      • 原递推公式:dp[i][j]=max{dp[i-1][j],dp[i-1][j-[weight[i]]+value[i]]}(j>=weight[i])
      • 此处结合题意的设定是价值即为重量,因此上述递推公式为:dp[i][j]=max{dp[i-1][j],dp[i-1][j-[stones[i]]+stones[i]]}(j>=stones[i])
    • (3)初始化dp:首行、首列初始化

    • (4)构建dp(遍历顺序):按行 | 按列进行遍历

    • (5)验证dp

/**
 * 1049 最后一块石头的重量II
 */
public class Solution1 {
	// 动态规划:二维数组思路
    public int lastStoneWeightII(int[] stones) {
        int m = stones.length;

        // 获取重量之和
        int sum = 0;
        for (int i = 0; i < m; i++) {
            sum += stones[i];
        }
        int bagSize = sum / 2;

        // 1.dp[]定义(dp[i][j] 表示容量为j的背包放入物品i时的最大价值)
        int[][] dp = new int[m][bagSize + 1];

        // 2.递推公式:dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-stones[i]]+stones[i])

        // 3.dp[]初始化(首行初始化)
        for (int j = 0; j <= bagSize; j++) {
            dp[0][j] = (j >= stones[0]) ? stones[0] : 0;
        }

        // 4.dp[]构建
        for (int i = 1; i < m; i++) {
            for (int j = 0; j <= bagSize; j++) {
                if (j < stones[i]) {
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - stones[i]] + stones[i]);
                }
            }
        }

        // 返回结果
        return sum - 2 * dp[m - 1][bagSize];
    }

}
  • 复杂度分析

    • 时间复杂度:O(m×n) m 为物品个数(数组元素个数)、n为背包容量(元素总和/2)

      • 空间复杂度:O(n)n 为背包容量

空间优化版本(一维滚动数组)

/**
 * 1049 最后一块石头的重量II
 */
public class Solution2 {

    // 动态规划:一维数组版本
    public int lastStoneWeightII(int[] stones) {
        int m = stones.length;

        // 获取重量之和
        int sum = 0;
        for (int i = 0; i < m; i++) {
            sum += stones[i];
        }
        int bagSize = sum / 2;

        // 1.dp[]定义(dp[j] 表示容量为j的背包中物品的最大价值)
        int[] dp = new int[bagSize + 1];

        // 2.递推公式:dp[j] = Math.max(dp[j],dp[j-stones[i]]+stones[i])

        // 3.dp[]初始化(dp[0]为0,其余按照正常条件递推)

        // 4.dp[]构建
        for (int i = 0; i < m; i++) { // 外层物品
            for (int j = bagSize; j >= stones[i]; j--) { // 内层背包容量(逆序遍历)
                dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }

        // 返回结果
        return sum - 2 * dp[bagSize];
    }

}

🟡494-目标和

1.题目内容open in new window

给你一个非负整数数组 nums 和一个整数 target

向数组中的每个整数前添加 '+''-' ,然后串联起所有整数,可以构造一个 表达式

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1"

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

输入:nums = [1], target = 1
输出:1
2.题解思路
👻方法1:回溯法(超时)
  • 思路分析:回溯法
// 回溯算法模板
public void backTrack(路径,选择列表){
    if(满足结束条件){
        if(符合结果集要求){
            res.add(...);
        }
        return ;
    }
    // 递归回溯处理
    for(选择 in 选择列表){
        1.选择
        2.递归处理 backTrack(路径,选择列表)
        3.回溯(撤销选择,复原现场)
    }
}

​ 对于此处的选择,遍历nums[]数组,从{+1、-1}当中做选择,选择加上这个数或者减去这个数。因此伪代码可以表示为:

// 回溯算法模板
int cnt = 0; // 定义结果
int curPathSum = 0; // 定义当前路径和
public void backTrack(nums,idx){
    if(idx==nums.length){
        if(curPathSum==target){
            cnt+1; // 计数加1
        }
        return ;
    }
    // 递归回溯处理
    for(选择 in {+1-1}){
        1.选择: curPathSum += (+1/-1)*nums[idx]
        2.递归处理: backTrack(nums,idx+1)
        3.回溯(撤销选择,复原现场): curPathSum -= (+1/-1)*nums[idx]
    }
}
public class Solution1 {

    public int cnt = 0; // 满足目标和条件的计数器(结果统计)
    public int curPathSum = 0; // 当前路径和

    // 回溯法
    public int findTargetSumWays(int[] nums, int target) {
        backTrack(nums, target, 0);
        return cnt;

    }

    public void backTrack(int[] nums, int target, int idx) {
        // 递归出口
        if (idx == nums.length) {
            if (curPathSum == target) {
                // 记录结果
                cnt++;
            }
            return;
        }

        // 处理:选择+
        curPathSum += nums[idx];
        // 递归
        backTrack(nums, target, idx + 1);
        // 回溯
        curPathSum -= nums[idx];

        // 处理:选择-
        curPathSum -= nums[idx];
        // 递归
        backTrack(nums, target, idx + 1);
        // 回溯
        curPathSum += nums[idx];
    }

}


/**
 * 494 目标和
 */
public class Solution2 {

    public int cnt = 0; // 满足目标和条件的计数器(结果统计)

    // 回溯法
    public int findTargetSumWays(int[] nums, int target) {
        dfs(nums, target, 0, 0);
        return cnt;
    }

    public void dfs(int[] nums, int target, int idx, int sum) {
        // 递归出口
        if (idx == nums.length) {
            if (sum == target) {
                // 记录结果
                cnt++;
            }
            return;
        }

        // 递归回溯
        dfs(nums, target, idx + 1, sum + nums[idx]);

        // 递归回溯
        dfs(nums, target, idx + 1, sum - nums[idx]);
    }
}
  • 复杂度分析

    • 时间复杂度:O(2n),其中 n 是数组 nums 的长度。回溯需要遍历所有不同的表达式,共有 2n种不同的表达式,每种表达式计算结果需要 O(1) 的时间
  • 空间复杂度:O(n),其中 n 是数组 nums 的长度。空间复杂度主要取决于递归调用的栈空间,栈的深度不超过 n

👻方法2:动态规划(组合问题:01背包应用之有多少种不同的填满背包最大容量的方法)

如何将本题转化为01背包问题的思路?

​ 要使得表达式结果为target,则一定会有left[]组合-right[]组合=target,且left[]+right[]=sum由这两个公式推导出来left=(target+sum)/2,因此可以将问题转化为在集合nums[]中寻找出和为left的组合,进而转化为01背包问题

​ 从另一个角度切入:假设加法的总和为x,则减法的总和为sum-x,则有x-(sum-x)=target此处的x即为bagSize。基于上述方程式分析得到的式子存在特例情况需要判断(例如需要考虑(target+sum)/2向下取整有没有影响)

// 特例1:target + sum 为奇数时没有方案
if ((target + sum) % 2 == 1) return 0; // 此时没有方案

// 特例2:如果target的绝对值大于sum,那么这些现有元素不可能组成和为target
if (abs(target) > sum) return 0; // 此时没有方案
  • 思路分析:本题的核心不同于前面的求最大价值,而是在于求解装满有多少种方法

    • (1)dp定义:dp[i][j]表示使用 下标为[0, i]nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法

    • (2)递推公式:

      物品\背包容量01234
      物品 011000
      物品 112100
      物品 213310
      物品 3
      物品 4
      • 抽象递推过程:遍历到第i个物品,j容量时(此处的物品重量和价值指的是nums[i],背包容量则指的是前面提到的left
        • j<weight[i]说明当前背包放不下物品i,因此dp[i][j]=dp[i-1][j] 继承上一个状态
        • j>=weight[i]说明当前背包可放下物品i,那么它有两种方案(放或者不放),总的方案数应为两者相加
          • 不放物品i:即背包容量为j,里面不放物品i,装满有dp[i - 1][j]种方法
          • 放物品i:先空出物品i的容量,背包剩余容量为j-weight[i],放满背包有dp[i-1][j-weight[i]]种方法
          • 因此dp[i][j]=dp[i-1][j] + dp[i-1][j-nums[i]](本题中物品的重量是nums[i]、价值也是nums[i]
    • (3)初始化dp:首行、首列填充

      • 首行:dp[0][j]表示只放物品0,将容量为j的背包填满有几种方法
        • 只有背包容量j为物品0的容量nums[0]的时候可以正好装满,其他情况要么装不满要么装不下。因此只有dp[0][nums[0]]==1,其余均为0
      • 首列:dp[i][0]表示背包容量为0,放物品[0,i],装满有几种方法
        • 物品数值均不为0的情况下,都是1种方法,就是放0件物品
        • 如果物品数值为0,则要根据物品数值为0的个数进一步确定方案
          • 假设有两个物品:物品0为0、物品1为0,那么装满背包容量为0的方案有22=4种
            • 放0件物品
            • 放1件物品:放物品0不放物品1;放物品1不放物品0
            • 放2件物品
          • 即计算物品中数值为0的个数,按照组合数量求解方案总数
    • (4)构建dp(遍历顺序):先物品后背包容量 或者 先背包容量后物品

    • (5)验证dp

/**
 * 494 目标和
 */
public class Solution4 {

    // 动态规划(二维数组)
    public int findTargetSumWays(int[] nums, int target) {
        // 前置:动态规划思路转化(求解和为left组合个数)
        int m = nums.length; // 物品数量
        int sum = 0;
        for (int i = 0; i < m; i++) {
            sum += nums[i];
        }
        int left = (sum + target) / 2;

        // 特例情况判断:sum + target 的和为奇数,则找不到2个left的组合
        if ((sum + target) % 2 == 1) {
            return 0; // 这种情况方案数为0
        }
        // 特例情况判断:如果target的绝对值大于sum,则现有这些元素组合不可能构成target
        if (Math.abs(target) > sum) {
            return 0; // 这种情况方案数为0
        }

        // 动态规划处理
        // 1.dp定义:dp[i][j] 表示背包容量为j 装入[0,i]的物品的 装满的方案组合数
        int[][] dp = new int[m][left + 1];

        /**
         * 2.递推公式
         * j<nums[i] 装不下物品i,则dp[i][j]继承上一状态:dp[i][j] = dp[i-1][j]
         * j>=nums[i] 可以装下物品i,选择装或者不装:
         *  - 不装物品i:dp[i][j] = dp[i-1][j]
         *  - 装物品i:dp[i][j] = dp[i-1]][j-nums[i]] (先空出可以装物品i的容量)
         *  - 这种情况下的组合总数为:dp[i][j] = dp[i-1][j] +dp[i-1]][j-nums[i]]
         */

        // 3.dp初始化(首行、首列初始化)
        // 对于首行初始化,只有当容量j==nums[0]的情况下才能装满(方案数为1),其他情况要么装不满、要么装不下
        for (int j = 0; j <= left; j++) {
            dp[0][j] = (j == nums[0]) ? 1 : 0;
        }
        // 对于首列初始化,如果nums均不为0则容量j==0只有1种方案就是不装,但是如果nums中存在元素为0的情况则要根据0的个数计算方案数(2^t^,t为0的个数)
        int zeroNum = 0; // 累计0的个数
        for (int i = 0; i < m; i++) {
            if (nums[i] == 0) {
                zeroNum++;
            }
            // 根据当前0的个数来决定方案数
            dp[i][0] = (int) Math.pow(2, zeroNum);
        }

        // 4.构建dp(先物品后背包)
        for (int i = 1; i < m; i++) {
            for (int j = 1; j <= left; j++) {
                if (j < nums[i]) {
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
                }
            }
        }

        // 返回结果
        return dp[m - 1][left];
    }
}

动态规划:空间优化版本(一维数组优化:dp[j]

  • (1)dp定义:dp[j] 表示容量为j的背包装满的方案数
  • (2)dp推导:
    • j<nums[i]dp[i][j]=dp[i-1][j] =》去掉i维度
    • j>=nums[i]dp[i][j]=dp[i-1][j] + dp[i-1][j-nums[i]] =》去掉i维度:dp[j]=dp[j] + dp[j-nums[i]]即得到dp[j] += dp[j-nums[i]]
  • (3)dp初始化:dp[0]初始化为1(装满背包为0的方法有一种,放0件物品)
  • (4)构建dp:01背包的一维数组(外层物品内层背包+背包逆序
  • (5)验证dp
/**
 * 494 目标和
 */
public class Solution5 {

    // 动态规划(一维数组)
    public int findTargetSumWays(int[] nums, int target) {
        // 前置:动态规划思路转化(求解和为left组合个数)
        int m = nums.length; // 物品数量
        int sum = 0;
        for (int i = 0; i < m; i++) {
            sum += nums[i];
        }
        int left = (sum + target) / 2;

        // 特例情况判断:sum + target 的和为奇数,则找不到2个left的组合
        if ((sum + target) % 2 == 1) {
            return 0; // 这种情况方案数为0
        }
        // 特例情况判断:如果target的绝对值大于sum,则现有这些元素组合不可能构成target
        if (Math.abs(target) > sum) {
            return 0; // 这种情况方案数为0
        }

        // 动态规划处理
        // 1.dp定义:dp[j] 表示背包容量为j 装满的方案组合数
        int[] dp = new int[left + 1];

        /**
         * 2.递推公式
         * j<nums[i] 装不下物品i,则dp[i][j]继承上一状态:dp[i][j] = dp[i-1][j]
         * j>=nums[i] 可以装下物品i,选择装或者不装:
         *  - 不装物品i:dp[i][j] = dp[i-1][j]
         *  - 装物品i:dp[i][j] = dp[i-1]][j-nums[i]] (先空出可以装物品i的容量)
         *  - 这种情况下的组合总数为:dp[i][j] = dp[i-1][j] +dp[i-1]][j-nums[i]]
         */

        // 3.dp初始化
        dp[0] = 1; // 装满容量为0的方案数为1

        // 4.构建dp(先物品后背包)
        for (int i = 0; i < m; i++) {
            for (int j = left; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }

        // 返回结果
        return dp[left];
    }
}

🟡474-一和零

1.题目内容open in new window

给你一个二进制字符串数组 strs 和两个整数 mn

请你找出并返回 strs 的最大子集的长度,该子集中 最多m0n1

如果 x 的所有元素也是 y 的元素,集合 x 是集合 y子集

示例 1:

输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
输出:4
解释:最多有 5 个 0 和 3 个 1 的最大子集是 {"10","0001","1","0"} ,因此答案是 4 。
其他满足题意但较小的子集包括 {"0001","1"} 和 {"10","1","0"} 。{"111001"} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。

示例 2:

输入:strs = ["10", "0", "1"], m = 1, n = 1
输出:2
解释:最大的子集是 {"0", "1"} ,所以答案是 2 。
2.题解思路
👻方法1:动态规划(两个维度的01背包物品)

问题分析

​ 本题中strs数组中的元素就是物品,每个物品都是1个,m和n相当于是一个基于两个维度的背包,不同长度的字符串实际上是不同大小的待装物品

  • 思路分析:
    • (1)dp定义:dp[i][j]:最多有i个0j个1的strs的最大子集的大小为dp[i][j](注意此处的ij都表示不同维度的背包容量,不是01背包问题的二维数组概念,而是基于"一维数组"思路的定义)
    • (2)递推公式:dp[i][j] 可以由前一个strs里的字符串推导出来,当前遍历的字符串中有zeroNum个0oneNum个1那么需要空出当前字符串的数字个数才能放下该字符串,从而得到dp[i][j] = dp[i-zeroNum][j-oneNum] + 1,在遍历的过程中需要求dp[i][j]的最大值:即dp[i][j]=max{dp[i][j],dp[i-zeroNum][j-oneNum] + 1}(此处的zeroNumoneNum相当于01背包问题中的weight[i],字符串本身的个数相当于物品价值value[i]
    • (3)初始化dp:物品价值不会是负数,因此初始化设为0
    • (4)构建dp(遍历顺序):于01背包问题的"一维数组思路",遍历顺序必须是先物品后背包容量 + 背包逆序遍历
    • (5)验证dp
/**
 * 474-一和零
 */
public class Solution1 {

    /**
     * 动态规划:基于01背包的一维数组思路构建
     *
     * @param strs 物品(重量分别为0、1的个数,价值为字符串长度)
     * @param m    最多0的个数(0背包容量)
     * @param n    最多1的个数(1背包容量)
     * @return
     */
    public int findMaxForm(String[] strs, int m, int n) {
        // 1.dp定义(dp[i][j]表示最多有i个0、j个1的最大子集长度)
        int[][] dp = new int[m + 1][n + 1];

        /**
         * 2.递推公式
         * dp[i][j] = max{dp[i][j],dp[i-zeroNum][j-oneNum] + 1}
         */

        // 3.dp 初始化:初始化为0,后续递推更新
        dp[0][0] = 0;

        // 4.构建dp
        for (String str : strs) { // 外层遍历物品
            // 分别统计当前字符串str中0、1的个数
            int zeroNum = 0, oneNum = 0;
            for (char ch : str.toCharArray()) {
                if (ch == '0') {
                    zeroNum++;
                } else if (ch == '1') {
                    oneNum++;
                }
            }

            // 内层遍历背包(背包逆序)
            for (int i = m; i >= zeroNum; i--) {
                for (int j = n; j >= oneNum; j--) {
                    dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }

        // 返回结果
        return dp[m][n];

    }
}
  • 复杂度分析

    • 时间复杂度:O(m×n)
    • 空间复杂度:O(m×n)

🚀背包问题-②-完全背包

完全背包问题基础

​ 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。完全背包和01背包问题唯一不同的地方就是,每种物品有无限件

案例分析:weight{1,3,4}value{15,20,30} 每件商品都有无限个

01背包和完全背包唯一不同就是体现在遍历顺序上(五部曲概念核心处理都一样)

​ 以【一维数组】思路分析【01背包】和【完全背包】

// m 为物品数量;n为背包容量

// 01背包:外层物品内层背包容量 + 背包从大到小遍历
for(int i=0;i<m;i++){ // 外层物品
    for(int j=n;j>=0;j--){ // 内层背包容量,限定j>=weight[i]
        dp[j] = max{dp[j],dp[j-weight[i]]+value[i]}
    }
}

// 完全背包:内外层无特定顺序(可以先物品后背包容量,也可以先背包容量后物品)+背包从小到大遍历
for(int i=0;i<m;i++){ // 外层物品
    for(int j=0;j<=n;j++){ // 内层背包容量,限定j>=weight[i]
        if(j>=weight[i]){
            dp[j] = max{dp[j],dp[j-weight[i]]+value[i]}
        }
    }
    
    /*
    for(int j=weight[i];j<=n;j++){ // 内层背包容量,限定j>=weight[i]
        dp[j] = max{dp[j],dp[j-weight[i]]+value[i]}
    }
    */
}

// 完全背包:先遍历背包再遍历物品
for(int i=0;i<m;i++){ // 外层物品
    for(int j=0;j<=n;j++){ // 内层背包容量,限定j>=weight[i]
        if(j>=weight[i]){
            dp[j] = max{dp[j],dp[j-weight[i]]+value[i]}
        }
    }
}
  • 物品和背包容量遍历的先后问题
    • 01背包问题:先物品后背包容量,因为dp[i]的设定依赖于前一行的内容,因此要外层循环要先遍历物品
    • 完全背包问题:两种顺序都可以
  • 背包容量遍历的正序和逆序选择
    • 01背包问题:背包容量遍历选择逆序(避免前面数据的覆盖影响,因为遍历的过程中需要依赖上一状态的dp[i],如果正序遍历会覆盖导致影响后面的数据遍历)
    • 完全背包问题:背包容量遍历选择正序(因为物品可以有无限个)

​ 对于纯粹的完全背包问题,不管是【先物品后背包】还是【先背包后物品】其遍历顺序都是基于dp[j](且物品是可以重复拿的,选择正序遍历,因此不管是哪种遍历顺序,其依赖的dp[j]都已经填充好,且不存在01背包的覆盖问题(允许物品重复放入))

​ 也就是说,对于纯完全背包问题求装满背包的最大价值是多少,它和凑成总和的元素没有顺序依赖,也就是说有顺序和无顺序并不影响结果。但是对于附带【组合】或者【排列】概念的完全背包问题,遍历顺序的选择是关键

  • 例如【求凑成总和的组合数】,组合无顺序(即元素之间要求没有顺序),那么选用的是【先物品后背包】的遍历顺序
  • 如果是【求排列数】,则采用【先背包后物品】的遍历顺序
// 先物品后背包容量
for (int i = 0; i < coins.size(); i++) { // 遍历物品
    for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
        dp[j] += dp[j - coins[i]];
    }
}


// 先背包容量后物品
for (int j = 0; j <= amount; j++) { // 遍历背包容量
    for (int i = 0; i < coins.size(); i++) { // 遍历物品
        if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
    }
}


// 采用两种不同的遍历方式分析:假设coins[0]=1,coins[1]=5
// 先物品后背包:得到的方法数量只有{1,5}这种情况,而不会存在{5,1}
// 先背包后物品:背包容量中的每一个值都是经过1,5的计算包含了{1,5}、{5,1}这两种情况

image-20241126082734511

🟡518-零钱兑换II

1.题目内容open in new window

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0

假设每一种面额的硬币有无限个。

题目数据保证结果符合 32 位带符号整数。

2.题解思路
👻方法1:动态规划(完全背包(组合))
  • 思路分析:

    • (1)dp定义:d[j]表示凑成的j金额的货币组合数

    • (2)递推公式:dp[j] += dp[j-coins[i]](累加)

      • 构成dp有两种情况:
        • j<coins[i]:继承上一状态(整体体现就是不变)
        • j>=coins[i]:有两种方案可选:一种是加入当前硬币(先空出容量,然后看可以凑成剩余金额的组合数)+ 一种是不加入当前硬币(继承上一状态组合数)
          • dp[j] = dp[j] + dp[j-coins[i]](整体体现就是累加)
    • (3)初始化dpdp[0]=1(如果设置为0,后面所有的内容推导出来都是0,此处设定dp[0]==1可以解释为凑成总金额为0的货币组合总数为1种)

    • (4)构建dp(遍历顺序):

      • 遍历顺序求组合数(先物品后背包)+背包正序遍历
    • (5)验证dp

/**
 * 518 零钱兑换
 */
public class Solution1 {
    // 动态规划(一维数组版本)
    public int change(int amount, int[] coins) {
        // 1.dp[j]: 凑成总金额为j的硬币组合数
        int[] dp = new int[amount + 1];

        /**
         * 2.推导公式:
         * dp[j] += dp[j-coins[i]] 组合数累加
         */

        // 3.初始化
        dp[0] = 1; // 表示凑成总金额为0的方案有1种

        // 4.构建dp(完全背包求组合:先物品后背包+背包正序遍历)
        for (int i = 0; i < coins.length; i++) { // 先物品
            for (int j = coins[i]; j <= amount; j++) { // 后背包(j>=coins[i]条件下需处理)
                dp[j] += dp[j - coins[i]]; // 构成金额j的组合数有两种情况:一种是不加入当前硬币,一种是加上当前硬币,这两种组合之和构成dp[j]
            }
        }

        // 结果返回
        return dp[amount];
    }
}
  • 复杂度分析

    • 时间复杂度:O(m × n)

    • 空间复杂度:O(n) n 为背包容量(金额amount)

动态规划:二维数组版本

  • 对比01背包二维数组版本,此处初始化和遍历构建dp的处理有点细节差别
    • 首行、首列初始化
      • 首列:j==0,凑成金额为0的方案只有一种
      • 首行:i==0,用coins[0]刚好凑成j(取余数)
    • 递推公式:
      • j<coins[i]: dp[i][j] = dp[i-1][j]
      • j>=coins[i-1]: dp[i][j] = dp[i-1][j] + dp[i][j-coins[i]]
/**
 * 518 零钱兑换
 */
public class Solution2 {
    // 动态规划(二维数组版本)
    public int change(int amount, int[] coins) {
        int m = coins.length;
        // 1.dp[i][j]: [0-i]的硬币凑成总金额为j的硬币组合数
        int[][] dp = new int[m][amount + 1];

        /**
         * 2.推导公式: 
         *  - j<coins[i]: dp[i][j] = dp[i-1][j]
         *  - j>=coins[i]: dp[i][j] = dp[i-1][j] + dp[i][j-coins[i]]
         */

        // 3.初始化(首行、首列初始化)
        // 首列初始化
        for (int i = 0; i < m; i++) {
            dp[i][0] = 1; // 凑成金额为0的方案只有一种
        }
        // 首行初始化:只用硬币coins[0]凑成金额j
        for (int j = 0; j <= amount; j++) {
            // 金额j对coins[0]求余如果为0得到1种方案
            if (j % coins[0] == 0) {
                dp[0][j] = 1;
            }
        }

        // 4.构建dp(完全背包求组合:先物品后背包+背包正序遍历)
        for (int i = 1; i < m; i++) { // 先物品
            for (int j = 1; j <= amount; j++) { // 后背包(j>=coins[i]条件下需处理)
                if (j < coins[i]) {
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]];
                }
            }
        }

        // 结果返回
        return dp[m - 1][amount];
    }
}

🟡377-组合总和IV

1.题目内容open in new window

给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。

题目数据保证答案符合 32 位整数范围。

示例 1:

输入:nums = [1,2,3], target = 4
输出:7
解释:
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。

示例 2:

输入:nums = [9], target = 3
输出:0
2.题解思路

​ 给定nums,元素可以重复利用,找出和为target的元素组合个数

👻方法1:回溯法(❌超时)
/**
 * 377 组合总和IV
 */
public class Solution1 {

    public int cnt; // 组合数统计
    public int curPathSum; // 当前路径和

    public int combinationSum4(int[] nums, int target) {
        Arrays.sort(nums);
        backTrack(nums, target);
        return cnt;
    }

    // 回溯法
    public void backTrack(int[] nums, int target) {
        // 结果统计
        if (curPathSum == target) {
            cnt++;
            return;
        }

        // 处理
        for (int i = 0; i < nums.length; i++) {
            // 剪枝
            if (curPathSum + nums[i] > target) {
                break;
            }

            // 1.选择
            curPathSum += nums[i];
            // 2.递归处理下一位
            backTrack(nums, target);
            // 3.复原现场
            curPathSum -= nums[i];
        }
    }

}
👻方法2:动态规划(完全背包(排列))
  • 思路分析:
    • (1)dp定义:dp[j]凑成j的组合数
    • (2)递推公式:dp[j] += dp[j-nums[i]]
    • (3)初始化dpdp[0]=1
    • (4)构建dp(遍历顺序):先背包后物品 + 背包正序遍历
    • (5)验证dp
/**
 * 377 组合总和IV
 */
public class Solution2 {
    // 动态规划(一维数组版本)
    public int combinationSum4(int[] nums, int target) {
        int m = nums.length;
        // 1.dp[j] 构成容量为j的组合方案
        int[] dp = new int[target + 1];

        /**
         * 2.dp[] 推导
         * j<nums[i] dp[j]=dp[j]
         * j>=nums[i] dp[j]+=dp[j-nums[i]] // 加上当前元素`i`的构成方案
         */

        // 3.dp初始化
        dp[0] = 1; // 构成容量为0的有1种组合方案(表示不需要任何数这种情况)

        // 4.构建dp
        for (int j = 0; j <= target; j++) { 
            for (int i = 0; i < m; i++) {   
                if (j >= nums[i]) {
                    dp[j] += dp[j - nums[i]];
                }
            }
        }

        // 返回结果
        return dp[target];
    }
}

🟡KMW057-爬楼梯(升级版)

1.题目内容open in new window

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬至多m (1 <= m < n)个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

​ 此处题意可以理解为:每次可以爬 1 、 2、.....、m 个台阶。问有多少种不同的方法可以爬到楼顶呢?此处的1阶,2阶,.... m阶就是物品,楼顶就是背包。每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶

2.题解思路
👻方法1:动态规划(完全背包(排列))
  • 思路分析:
    • (1)dp定义:dp[j] 表示爬到j有多少种不同的组合
    • (2)递推公式:每次至多爬m阶(即物品对应为[0,m]
      • j>=mdp[j] += dp[j-i]
    • (3)初始化dpdp[0]=1(爬0阶有1种方案,就是不爬)
    • (4)构建dp(遍历顺序):完全背包的组合问题(先背包容量后物品+背包正序遍历)
    • (5)验证dp
/**
 * kmw 057-爬楼梯升级版
 */
public class Solution057 {

    public int climbStairs(int m, int n) {
        // 1.定义dp(dp[j]表示爬j阶楼梯的组合数)
        int[] dp = new int[n + 1];

        /**
         * 2.dp推导:
         * j>=i: dp[j]+=dp[j-i] (累加:爬+不爬的组合之和)
         */

        // 3.初始化
        dp[0] = 1; // 表示爬0阶楼梯有一种方案,就是不爬

        // 4.dp构建(先背包容量后物品 + 正序遍历背包)
        for (int j = 1; j <= n; j++) {
            for (int i = 1; i <= m; i++) {
                if (j >= i) {
                    dp[j] += dp[j - i];
                }
            }
        }
        // 返回爬楼梯组合数
        return dp[n];
    }

    public static void main(String[] args) {
        int m = 2, n = 3;
        Solution057 solution057 = new Solution057();
        System.out.println(solution057.climbStairs(m, n));
    }
}
  • 复杂度分析

    • 时间复杂度:O(m × n)

    • 空间复杂度:O(n)n 为背包容量

🟡322-零钱兑换

1.题目内容open in new window

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

2.题解思路
👻方法1:动态规划(完全背包问题)

此处的完全背包概念理解,指的是任意选择物品,恰好凑成背包容量的场景

  • 思路分析:基于题目内容可以看到这是一道典型的完全背包问题,非组合也非排列概念(求钱币的最小个数,钱币的顺序不影响这个最小个数

    • (1)dp定义:dp[j]表示凑成总金额j所需的最少硬币个数

    • (2)递推公式:

      • j>=coins[i]dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
    • (3)初始化dp

      • dp[0]=0
      • 其他元素填充max(过程中求的是min,此处只要填充一个amount+1的数,填充一个相对较大的数即可)
    • (4)构建dp(遍历顺序):最少硬币个数求解(不强调顺序,两种遍历先后顺序均可,背包容量需正序遍历)

    • (5)验证dp

/**
 * 322 零钱兑换
 */
public class Solution1 {

    /**
     * 完全背包问题转化
     *
     * @param coins  物品数
     * @param amount 背包容量
     * @return
     */
    public int coinChange(int[] coins, int amount) {
        // 1.dp 定义:dp[j]表示凑满j金额所需的最少硬币个数
        int[] dp = new int[amount + 1];

        /**
         * 2.dp 推导
         * dp[j] = min{dp[j], dp[j-coins[i]]+1 }
         */

        // 3.dp 初始化
        dp[0] = 0; // 凑满金额0的最少硬币个数为0
        for (int j = 1; j <= amount; j++) {
            dp[j] = amount + 1; // 初始化设定一个不可能出现的较大值
        }

        // 4.构建dp
        // 先物品后背包容量 + 背包容量正序方案
        /*
        for (int i = 0; i < coins.length; i++) {
            for (int j = 0; j <= amount; j++) {
                if (j >= coins[i]) {
                    dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
                }
            }
        }
         */
        // 先背包容量后物品 + 背包容量正序方案
        for (int j = 0; j <= amount; j++) {
            for (int i = 0; i < coins.length; i++) {
                if (j >= coins[i]) {
                    dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
                }
            }
        }

        // 返回结果(对初始值过滤)
        return dp[amount] > amount ? -1 : dp[amount]; // 如果为初始值说明没有满足的方案
    }

}
  • 复杂度分析

    • 时间复杂度:O(m × n)

    • 空间复杂度:O(n)n 为背包容量

🟡279-完全平方数

1.题目内容open in new window

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,14916 都是完全平方数,而 311 不是。

2.题解思路
👻方法1:动态规划
  • 思路分析:

    • (1)dp定义:dp[j]凑成j数字的完全平方数的最少数量

    • (2)递推公式:

      • dp[j] = Math.min(dp[j],dp[j-nums[i]*nums[i]]+1)
    • (3)初始化dp

      • dp[0]=0
      • dp[j] 初始化为一个n+1的数(不可能出现的较大数)
    • (4)构建dp(遍历顺序):求完全平方数的最少数量,无关顺序(物品和背包先后均可,背包采用正序遍历)

    • (5)验证dp

/**
 * 279 完全平方数
 */
public class Solution1 {

    /**
     * 完全背包问题
     *
     * @param n 背包容量 | 物品从1-x中取(x*x=n)
     * @return
     */
    public int numSquares(int n) {
        // 1.dp定义(dp[j] 表示凑成数字j的完全平方数的最少数量)
        int[] dp = new int[n + 1];

        /**
         * 2.dp递推
         * dp[j] = Math.min(dp[j],dp[j-i*i]+1)
         */

        // 3.dp初始化
        dp[0] = 0;
        for (int j = 1; j <= n; j++) {
            dp[j] = Integer.MAX_VALUE;  // 其余数组元素初始化为最大值,避免递推过程中被min覆盖 Integer.MAX_VALUE
        }

        // 4.构建dp
        // 先物品后背包容量 + 背包正序
        for (int i = 1; i * i <= n; i++) {
            for (int j = 1; j <= n; j++) {
                if (j >= i * i) {
                    dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
                }
            }
        }

        /*
        // 先背包容量后物品 + 背包正序
        for (int j = 1; j <= n; j++) {
            for (int i = 1; i * i <= j; i++) { // i的取值受限于背包容量
                dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
            }
        }
         */

        // 返回结果
        return dp[n];
    }
}
  • 复杂度分析

    • 时间复杂度:O(n × n)

    • 空间复杂度:O(n)n 为背包容量

🟡139-单词拆分

1.题目内容open in new window

给你一个字符串 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
2.题解思路
👻方法1:动态规划
  • 问题分析:转化为背包问题,字符串s是背包,单词能否组成字符串即表示物品可否装满背包,拆分时单词可以重复使用说明是完全背包

  • 思路分析:如果(0,i)可被拆分,则中间引入一个点(0,j)(j,i)也能够被拆分

    • (1)dp定义:dp[i]定义 表示(0,i)位置的字符串可以被拆分

    • (2)递推公式:状态转移方程分析:如果(0,i)可被拆分,则中间引入一个点(0,j)(j,i)也能够被拆分

    • (3)初始化dp:初始化数组(dp[0]设为true(不具备含义,作为推导基础) 其他默认设为false)

    • (4)构建dp(遍历顺序):先背包后物品 + 正序遍历

    • (5)验证dp

/**
 * 139 单词拆分
 */
public class Solution1 {

    public boolean wordBreak(String s, List<String> wordDict) {
        int n = s.length();

        // 1.dp[i] 表示(0,i)的位置可拆分
        boolean[] dp = new boolean[n + 1];

        // 2.dp推导:如果(0,i)的位置可拆分,则在中间插入一个切割点的位置也是可拆分的,即(0,j)(j,i)是可拆分的

        // 3.初始化
        Arrays.fill(dp, false); // 初始化默认设定为不可拆分
        dp[0] = true; // 第一个为止设置为true(作为后续推导的基础)

        // 4.构建dp
        for (int i = 1; i <= n; i++) {
            for (int j = 0; j < i; j++) {
                if (dp[j] && wordDict.contains(s.substring(j, i))) {
                    dp[i] = true;
                }
            }
        }

        // 返回结果
        return dp[n];
    }
}
  • 复杂度分析

    • 时间复杂度:O(n2)n为字符串长度

    • 空间复杂度:O(n)n为字符串长度

🚀背包问题-③-多重背包

⚽多重背包问题基础

​ 有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大

​ 多重背包和01的包是很相像的,关注每件物品最多有Mi件可用,将Mi件摊开,实际上就是一个01背包问题

案例分析:背包最大重量为10:weight{1,3,4}value{15,20,30}num{2,3,2},背包可以背的物品的最大价值是多少

​ 此处如果切换为01背包,实际上就是num{1,1,1}这种类型,如果说将上述num{2,3,2}平摊开来,就会转化成一个01背包物品,每个物品只用一次

重量价值数量
物品01151
物品01151
物品13201
物品13201
物品13201
物品24301
物品24301

多重背包解决思路核心:将多重背包平展开,转化为01背包问题

​ 此处涉及到数组动态扩容问题,可以选择更加灵活的数据结果适配,例如此处为了动态展开元素,可以使用List<Integer>来处理平展开的元素,避免数组的频繁扩容

/**
 * 多重背包问题
 */
public class Solution1 {

    /**
     * 多重背包:每个物品最多有mi件可用
     *
     * @param weight  物品重量
     * @param value   物品价值
     * @param num     物品数量
     * @param bagSize 背包容量
     * @return
     */
    public int maxValue(int[] weight, int[] value, int[] num, int bagSize) {

        // 将多重背包平展成01背包(也就是说将所有物品展开来)
        int len = num.length;
        List<Integer> newWeight = new ArrayList<>(); // 初始化
        List<Integer> newValue = new ArrayList<>(); // 初始化

        for (int i = 0; i < len; i++) {
            int curNum = num[i];
            // 展开weight
            for (int k = 0; k < curNum; k++) {
                newWeight.add(weight[i]); // 扩展weight数组
                newValue.add(value[i]); // 扩展value数组
            }
        }

        // 动态规划思路切换为:处理01背包问题
        // 1.dp[j] 表示装满容量为j的背包的最大价值
        int[] dp = new int[bagSize + 1];

        /**
         * 2.递推公式:
         * j<newWeight[i]: 继承上一状态
         * j>=newWeight[i]: dp[j] = max{dp[j],dp[j-newWeight[i]]+newValue[i]};
         */

        // 3.初始化
        dp[0] = 0; // 装满背包为0的最大价值为0

        // 4.构建dp (先物品后背包容量 + 背包逆序)
        for (int i = 0; i < newWeight.size(); i++) {
            for (int j = bagSize; j >= 0; j--) {
                if (j >= newWeight.get(i)) {
                    dp[j] = Math.max(dp[j], dp[j - newWeight.get(i)] + newValue.get(i));
                }
            }
        }

        // 返回结果
        return dp[bagSize];

    }

    public static void main(String[] args) {
        int[] weight = new int[]{1, 3, 4};
        int[] value = new int[]{15, 20, 30};
        int[] num = new int[]{2, 3, 2};
        int bagSize = 10;
        Solution1 solution1 = new Solution1();
        System.out.println(solution1.maxValue(weight, value, num, bagSize));
    }

}

另一种思路:01背包中在嵌套一层k遍历数量

//先遍历物品再遍历背包,作为01背包处理
for (int i = 0; i < n; i++) {
    for (int j = bagWeight; j >= weight[i]; j--) {
        //遍历每种物品的个数
        for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) {
            dp[j] = Math.max(dp[j], dp[j - k * weight[i]] + k * value[i]);
        }
    }
}

🚀打家劫舍

🟡198-打家劫舍

1.题目内容open in new window

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 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 。
2.题解思路
👻方法1:动态规划
  • 思路分析:
    • (1)dp定义:dp[i]表示当前在第i间房屋可以偷到的最高金额
    • (2)递推公式:
      • 对于每一间房dp[i]的处理是基于【是否要偷这间房屋】以及不能连着偷
        • ① 偷:dp[i]=dp[i-2]+nums[i]
        • ② 不偷:dp[i]=dp[i-1]
        • 因此偷窃方案的选择是从这两个方案中选择偷窃金额最高的:dp[i]=max{dp[i-1],dp[i-2]+nums[i]}
    • (3)初始化dp:结合递推公式分析,需要初始化dp[0]dp[1]
      • dp[0]:当前只有1间房,只能选择偷才能拿到最高金额
      • dp[1]:考虑到不能连着偷的情况,因此对于第1件房屋的偷窃方案选择只能是第01间房选择最高金额的来偷
    • (4)构建dp(遍历顺序):遍历所有房屋,填充dp(从前往后扫荡)
    • (5)验证dp
/**
 * 198 打家劫舍
 */
public class Solution1 {

    // 动态规划思路
    public int rob(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int n = nums.length;
        if (n == 1) {
            return nums[0];
        }
        if (n == 2) {
            return Math.max(nums[0], nums[1]);
        }

        // 1.dp定义(dp[i]为偷到当前第i间房屋可以偷窃的最高金额)
        int[] dp = new int[nums.length];

        /**
         * 2.递推公式:对于每一间房屋都可以选择偷或者不偷
         * - 偷:dp[i] = dp[i-2] + nums[i]
         * - 不偷:dp[i] = dp[i-1]
         * - 偷窃方案:dp[i] = max {dp[i-1],dp[i-2] + nums[i]}
         */

        // 3.初始化
        dp[0] = nums[0]; // 只有一件房屋,必须偷
        dp[1] = Math.max(nums[0], nums[1]); // 有两间房屋,选择金额高的偷

        // 4.dp构建
        int max = -1; // 定义偷窃的最大金额
        for (int i = 2; i < nums.length; i++) {
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
            max = Math.max(max, dp[i]);
        }

        // 返回结果
        return max;
    }
}
  • 复杂度分析

    • 时间复杂度:O(n)
    • 空间复杂度:O(n)

动态规划版本(空间优化版本)

/**
 * 198 打家劫舍
 */
public class Solution2 {

    // 动态规划思路:空间优化版本
    public int rob(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int n = nums.length;
        if (n == 1) {
            return nums[0];
        }
        if (n == 2) {
            return Math.max(nums[0], nums[1]);
        }

        int p = nums[0]; // 只有一件房屋,必须偷 (对应dp[i-2])
        int q = Math.max(nums[0], nums[1]); // 有两间房屋,选择金额高的偷(对应dp[i-1])
        int r = 0;
        int max = -1; // 定义偷窃的最大金额

        for (int i = 2; i < nums.length; i++) {
            r = Math.max(q, p + nums[i]);
            max = Math.max(max, r);
            // 更滚动更新变量
            p = q;
            q = r;
        }

        // 返回结果
        return max;
    }
}

🟡213-打家劫舍II

1.题目内容open in new window

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。

示例 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
2.题解思路

​ 对比【198-打家劫舍】问题,此处核心要点在于【房屋是围成一圈的】,也就是说首尾相连的情况(认为首尾也是相邻的),那么可以考虑将这个"环"进行平展,以第一间房屋为参考,选择偷、不偷来决定后面的偷窃方案:

  • 偷第一间房屋:则偷窃范围在[0,n-2] => 转化为 【198-打家劫舍】 思路
  • 不偷第一间房屋,则偷窃范围在[1,n-1] => 转化为 【198-打家劫舍】 思路

​ 然后选择金额最高的偷窃方案

👻方法1:动态规划 + 两次遍历(平展环)
  • 思路分析:此处关注的点是平展环(划分偷窃区域,基于偷窃区域选择偷窃方案),然后基于各个偷窃区域的偷窃方案选择最大值
    • 误区:此处有一个容易陷进去的点,就是认为偷第1间房屋,接着要考虑第2、3、4,就会混淆了dp的概念,因此只需要将关注点放在偷窃区域,具体怎么偷是由具体的偷窃方案来决定的,此处只需要划分偷窃区域,遍历两次数组得到各个区域的最大值,然后选择最大金额方案
/**
 * 198 打家劫舍
 */
public class Solution1 {

    public int rob(int[] nums) {
        // 特例判断
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int n = nums.length;
        if (n == 1) {
            return nums[0];
        }
        if (n == 2) {
            return Math.max(nums[0], nums[1]);
        }
        
        // 划分区域选择偷窃方案
        int rob1 = robByRange(Arrays.copyOfRange(nums,0,n-1));
        int rob2 = robByRange(Arrays.copyOfRange(nums,1,n));
        return Math.max(rob1,rob2);
    }

    // 动态规划思路
    public int robByRange(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int n = nums.length;
        if (n == 1) {
            return nums[0];
        }
        if (n == 2) {
            return Math.max(nums[0], nums[1]);
        }

        // 1.dp定义(dp[i]为偷到当前第i间房屋可以偷窃的最高金额)
        int[] dp = new int[nums.length];

        /**
         * 2.递推公式:对于每一间房屋都可以选择偷或者不偷
         * - 偷:dp[i] = dp[i-2] + nums[i]
         * - 不偷:dp[i] = dp[i-1]
         * - 偷窃方案:dp[i] = max {dp[i-1],dp[i-2] + nums[i]}
         */

        // 3.初始化
        dp[0] = nums[0]; // 只有一件房屋,必须偷
        dp[1] = Math.max(nums[0], nums[1]); // 有两间房屋,选择金额高的偷

        // 4.dp构建
        int max = -1; // 定义偷窃的最大金额
        for (int i = 2; i < nums.length; i++) {
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
            max = Math.max(max, dp[i]);
        }

        // 返回结果
        return max;
    }
}
  • 复杂度分析

    • 时间复杂度:O(n)

    • 空间复杂度:O(n)

动态规划:空间优化版本(数组拷贝优化)

​ 上述是基于数组拷贝来限定访问范围,此处调整robByRange方法,限定访问数组范围

/**
 * 🟡 213 打家劫舍II - https://leetcode.cn/problems/house-robber-ii/description/
 */
public class Solution213_01 {

    /**
     * 思路:环形房屋的偷盗方案
     * 偷盗范围:[0,n-1]的环形范围,因此可以将其平展为两个偷盗的范围
     * ① 如果偷了0,则不能偷n-1(0与n-1紧紧挨着),因此偷盗范围为[0,n-2]
     * ② 如果投了n-1,则不能偷0,因此偷盗范围为[1,n-1]
     * 基于上述两种情况,分别计算两种情况的偷盗金额的最大值
     */
    public int rob(int[] nums) {
        int n = nums.length;
        if (n < 3) {
            if (n == 0) return 0;
            if (n == 1) return nums[0];
            if (n == 2) return Math.max(nums[0], nums[1]);
        }

        // ① 计算[0,n-2]范围偷窃的最大金额
        int robAmount1 = robByRange(nums, 0, n - 2);
        // ② 计算[1,n-1]范围偷窃的最大金额
        int robAmount2 = robByRange(nums, 1, n - 1);
        // 返回两种情况的最大值
        return Math.max(robAmount1, robAmount2);
    }


    /**
     * 计算指定范围[start,end]的偷窃方案的最大值
     */
    private int robByRange(int[] nums, int start, int end) {
        int n = end - start + 1;
        if (n < 2) {
            return n == 0 ? 0 : nums[0];
        }

        // 1.dp 定义:dp[i] 表示偷到第i间房屋可获得的最大金额
        int[] dp = new int[n];
        // 2.dp 递推:dp[i] = max{dp[i-1],dp[i-2]+nums[i]}

        // 3.dp 初始化
        dp[0] = nums[start];
        dp[1] = Math.max(nums[start], nums[start + 1]);

        // 4.dp 构建
        for (int i = 2; i < n; i++) { // 注意此处遍历封装的dp下标索引和nums[]下标索引的对照关系
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[start + i]);
        }

        // 返回指定范围区域偷窃的最大金额
        return dp[n - 1];
    }

}
  • 复杂度分析
    • 时间复杂度:根据限定范围需要进行两次数组检索,检索范围分别为[0,n-2][1,n-1],因此总时间复杂度为O(2×n)=》O(n)
    • 空间复杂度:此处需要依赖dp[n]进行记录,因此空间复杂度为O(n)

动态规划:空间优化版本

  • 上述的思路是最简单的基于基础【打家劫舍】版本的思路实现,涉及到数组拷贝、遍历,此处可以进一步进行空间优化(例如不拷贝数组,而是直接根据索引值来进行遍历)
  • 对于robByRange需要关注索引处理(startend概念的引入)
/**
 * 198 打家劫舍
 */
public class Solution2 {

    public int rob(int[] nums) {
        // 特例判断
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int n = nums.length;
        if (n == 1) {
            return nums[0];
        }
        if (n == 2) {
            return Math.max(nums[0], nums[1]);
        }

        // 划分区域选择偷窃方案
        int rob1 = robByRange(nums, 0, n - 1); // 偷窃范围[0,n-1)(即[0,n-2])
        int rob2 = robByRange(nums, 1, n); // 偷窃范围[1,n)(即[1,n-1])
        return Math.max(rob1, rob2);
    }

    // 动态规划思路
    public int robByRange(int[] nums, int start, int end) {
        if (end - start == 1) {
            return nums[start];
        }
        if (end - start == 2) {
            return Math.max(nums[start], nums[start + 1]);
        }

        int p = nums[start]; // 最开始只有一件房屋,必须偷 (对应dp[i-2])
        int q = Math.max(nums[start], nums[start + 1]); // 有两间房屋,选择金额高的偷(对应dp[i-1])
        int r = 0;
        int max = -1; // 定义偷窃的最大金额

        for (int i = start + 2; i < end; i++) { // 此处遍历的是[start,end]范围
            r = Math.max(q, p + nums[i]);
            max = Math.max(max, r);
            // 更滚动更新变量
            p = q;
            q = r;
        }

        // 返回结果
        return max;
    }
}

🟡337-打家劫舍III

1.题目内容open in new window

小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root

除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。

给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额

image-20241126190249094

2.题解思路
👻方法1:暴力搜索(递归)(超时❌)

​ 关键还是在遍历的过程中讨论当前节点该偷还是不偷的问题:

  • 【方案1】如果偷了当前节点,则不能偷其子节点(分别跳过左、右节点)
  • 【方案2】如果没有偷当前节点,则可以考虑其子节点
  • 最终选择的是两个偷窃方案中偷窃金额最大的那个方案
/**
 * 337 打家劫舍III
 */
public class Solution1 {

    /**
     * 思路:基于深度优先遍历思路,遍历每个节点,确定偷窃方案
     * ① 偷 当前节点:则不能偷其子节点,只能偷其子节点的子节点
     * ② 不偷 当前节点:则考虑偷其子节点的方案
     */
    public int rob(TreeNode root) {
        if (root == null) {
            return 0;
        }
        /*
        if (root.left == null && root.right == null) {
            return root.val;
        }
        */

        // 偷父节点
        int val1 = root.val;
        if (root.left != null) {
            val1 += rob(root.left.left) + rob(root.left.right); // 跳过root的左节点
        }
        if (root.right != null) {
            val1 += rob(root.right.left) + rob(root.right.right); // 跳过root的右节点
        }

        // 不偷父节点
        int val2 = rob(root.left) + rob(root.right); // 考虑root的左右孩子

        // 两种方案选择最大的方案
        return Math.max(val1, val2);
    }

}
  • 复杂度分析

    • 时间复杂度:O(n2)这个时间复杂度不太标准,也不容易准确化,例如越往下的节点重复计算次数就越多

    • 空间复杂度:O(log n),算上递推系统栈的空间

​ 上述递归过程中涉及到重复计算就会导致算法超时(计算了root的四个孙子(左右孩子的孩子)为头结点的子树的情况,又计算了root的左右孩子为头结点的子树的情况,计算左右孩子的时候其实又把孙子计算了一遍)

优化版本:记忆化递推

​ 使用一个map将计算过的结果保存(如果已经计算过孙子,那么计算孩子的时候就可以复用孙子节点的结果,而不需要重复计算)。记忆化递推的思路就是借助一个Map<TreeNode,Integer>即可存储节点的计算结果

/**
 * 337 打家劫舍III
 */
public class Solution2 {

    public Map<TreeNode, Integer> map = new HashMap<>();

    /**
     * 遍历每个节点选择偷或者不偷(dfs)
     * 计算优化:记忆化递推(记录已遍历节点的结果,避免重复计算)
     */
    public int rob(TreeNode root) {
        return dfs(root);
    }

    public int dfs(TreeNode node) {
        if (node == null) {
            return 0;
        }
        if (node.left == null && node.right == null) {
            return node.val;
        }
        // 递归出口补充(记忆化搜索,如果已经遍历过的节点直接返回结果值,不重复检索)
        if (map.containsKey(node)) {
            return map.get(node);
        }

        // 偷父节点
        int val1 = node.val;
        if (node.left != null) {
            val1 += rob(node.left.left) + rob(node.left.right); // 跳过node的左节点
        }
        if (node.right != null) {
            val1 += rob(node.right.left) + rob(node.right.right); // 跳过node的右节点
        }

        // 不偷父节点
        int val2 = rob(node.left) + rob(node.right); // 考虑node的左右孩子

        int maxVal = Math.max(val1, val2);
        map.put(node, maxVal); // 记录当前节点的最大偷窃方案(最高偷窃金额)

        // 两种方案选择最大的方案,返回结果
        return maxVal;
    }

}
  • 复杂度分析

    • 时间复杂度:O(n)对于已经遍历的节点不会重复递归计算

      • 空间复杂度:O(log n),算上递推系统栈的空间
👻方法2:动态规划
  • 思路分析:上述的递归是通过实时计算的方式来得到结果,而动态规划则是使用状态转移容器来记录状态的变化。此处可以使用一个长度为2的数组记录当前节点偷与不偷所得到的最大金钱。此处需要结合递归和动态规划来分析过程,以递归三部曲为框架,融合动态规划五部曲进行解答

    • (1)确定递归函数的参数和返回值

      • 此处要求一个节点偷、不偷的两个状态所得到得金钱,因此可以定义一个长度为2的数组作为返回值(而这个dp[]表示:dp[0]表示记录不偷该节点所得到的最大金钱,dp[1]表示记录偷该节点所得到的最大金钱)
    • (2)确定终止条件

      • 如果遇到空节点,不管是偷还是不偷都是0(此处也相当于dp的初始化)
    • (3)确定遍历顺序

      • 后序遍历LRD(需要通过递归函数的返回值来左进一步计算)
        • 递归左节点,得到偷与不偷的金钱
        • 递归右节点,得到偷与不偷的金钱
/**
 * 337 打家劫舍III
 */
public class Solution3 {
    /**
     * 遍历每个节点选择偷或者不偷(dfs)
     * 动态规划+递归思路
     */
    public int rob(TreeNode root) {
        int[] res = dfs(root);
        return Math.max(res[0], res[1]);
    }

    // 返回值为一个dp[]数组,长度为2
    public int[] dfs(TreeNode node) {
        // dp数组定义:dp[0]表示不偷、dp[1]表示偷
        int[] dp = new int[2];

        if (node == null) { /// 递归出口
            return new int[]{0, 0}; // dp初始化
        }

        // 递归处理左节点(获取偷、不偷的最大金额)
        int[] left = dfs(node.left);

        // 递归处理右节点(获取偷、不偷的最大金额)
        int[] right = dfs(node.right);

        // 当前节点不偷:则递归计算左孩子、右孩子可能的方案(可以偷也可以不偷,选择较大的方案),Max(左孩子不偷,左孩子偷) + Max(右孩子不偷,右孩子偷)
        dp[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
        // 偷:左孩子不偷、右孩子不偷、当前节点偷
        dp[1] = node.val + left[0] + right[0];

        return dp;
    }
}
  • 复杂度分析

    • 时间复杂度:

    • 空间复杂度:

🚀股票问题

🟢121-买卖股票的最佳时机

1.题目内容

给定一个数组 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。
2.题解思路
👻方法1:模拟法(暴力思路)

​ 思路分析:此处由于设定只能买入卖出一次,因此相当于求数组中的元素的最大价差,可以基于双层循环遍历的思路,求每个元素的差值然后获取最大值

/**
 * 🟢 121.买卖股票的最佳时机(只能做一次买卖操作) - https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/
 */
public class Solution1 {

    // 思路1:暴力法
    public int maxProfit(int[] prices) {
        // 依次遍历判断任意两个元素之间的差值,记录最大利润值
        int maxProfit = 0;
        for (int i = 0; i < prices.length; i++) {
            for (int j = i + 1; j < prices.length; j++) { // 卖出时间要晚于买入时间
                if (prices[j] > prices[i]) { // 至少要获取利润
                    maxProfit = Math.max(maxProfit, prices[j] - prices[i]);
                }
            }
        }
        // 返回结果
        return maxProfit;
    }

}
  • 复杂度分析
    • 时间复杂度:O(n2)双层循环遍历,求元素之间的价差
    • 空间复杂度:O(1)占用常数级的空间
👻方法2:贪心算法(低买高卖)

​ 因为股票只能买卖一次,因此基于贪心的想法是【左取最小值(历史最小值),右取最大值】得到的差值是最大利润

/**
 * 121 买卖股票的最佳时机
 */
public class Solution1 {

    /**
     * 思路:贪心算法(低买高卖)
     * 核心:
     * 1.假设自己当日卖出股票,且该股票是在历史最低点买入的情况下所获得的利润差(局部最优);
     * 2.计算每天基于【假设】所能得到的最大利润(由局部最优->全局最优)
     */
    public int maxProfit(int[] prices) {
        int minHistoryPrice = prices[0]; // 股票的历史最低点(以第1日开始)
        int maxProfit = 0; // 最大利润

        // 遍历(股票需隔日卖出,因此是从第2日开始计算利润)
        for (int i = 1; i < prices.length; i++) {
            // 计算当前利润
            int profit = prices[i] - minHistoryPrice; // 当日卖出价格-历史最低点=当日所得最大利润
            maxProfit = Math.max(maxProfit, profit); // 更新最大利润
            // 更新历史最低点
            minHistoryPrice = Math.min(minHistoryPrice, prices[i]);
        }

        // 返回最大利润
        return maxProfit;
    }
}
  • 复杂度分析

    • 时间复杂度:O(n)
    • 空间复杂度:O(1)
👻方法3:动态规划
  • 思路分析:

    • (1)dp定义:构建二维数组存储dp[i][2]注意此处的持有概念,持有不代表是当天买入,持有是保持持有的状态

      • dp[i][0]:表示第i天持有股票所得最多现金
      • dp[i][1]:表示第i天不持有股票所得最多现金
      • dp[i][0]持有dp[i][1]不持有顺序有没有限定?=》卖出股票是基于上一状态是否持有股票的基础上来推导的,因此当顺序遍历的时候计算【不持有】状态时需要依托的是【昨日持有状态】的内容,这点也可以通过后续的推导体现。因此基于这种设计,再结合推导,只需要把重点放在推导上即可(其概念和贪心算法中:当日所得现金最大收益=日内卖出价格-历史最低价,关注区间价差的最大值)
    • (2)递推公式:基于第i天是否持有股票进行情况讨论,因为买卖只能操作一次,还要结合前一天是否持有股票来讨论

      • 如果第i天持有股票即dp[i][0],其可以由两个状态推导出来

        • 昨日已经持有股票,今日选择继续持有:那么继续继承上一状态(所得现金就是昨天持有股票所得现金状态)=》dp[i][0]=dp[i-1][0]

        • 昨日还没有股票,今日选择买入股票持有:因为只能买卖一次,也就是说本次是第一次买入,那么所得现金状态为-price[i](所得初始为0减去当日买入股票的价格)

        • 对于dp[i][0]应该选择两者中所得现金最大的:dp[i][0] = max {dp[i-1][0],-price[i]}

      • 如果第i天不持有股票即dp[i][1],其也可以由两个状态推导出来

        • 昨日就已经不持有股票了,股票只能卖出一次,则继承上一状态(所得现金就是昨天不持有股票所得现金状态)=》dp[i][1]=dp[i-1][1]

        • 昨日还持有股票,今日选择卖出股票,所得现金收益为【昨日持有股票的现金收益】+【当日卖出价格】=》dp[i][1]=dp[i-1][0] + price[i]

        • 对于dp[i][1]应该选择两者中所得现金最大的:dp[i][0] = max {dp[i-1][1],dp[i-1][0] + price[i]}

    • (3)初始化dp

      • 基于上述递推公式分析,所有的推导基础需要依赖dp[0][0]dp[0][1]
        • dp[0][0]:表示第0天持有股票所得最多现金,此时第0天如果要持有股票只能是当日买入(因为其是最早开始的时间,无法从前面的状态中推导),因此dp[0][0]=-price[i]

        • dp[0][1]:表示第i天不持有股票所得最多现金,不持有股票,现金初始为0,因此dp[0][1]=0

    • (4)构建dp(遍历顺序):

      • 正序遍历每一天,然后分别封装dp[i][0]dp[i][1]的值
    • (5)验证dp

/**
 * 121 买卖股票的最佳时机
 */
public class Solution2 {

    /**
     * 思路:动态规划
     * dp[i][0]: 表示第`i`天持有股票所得最大现金
     * dp[i][1]: 表示第`i`天不持有股票所得最大现金
     */
    public int maxProfit(int[] prices) {
        int m = prices.length;
        // 1.dp 定义(dp[i][0]持有、dp[i][1]不持有)
        int[][] dp = new int[m][2];

        /**
         * 2.推导公式
         * 2.1 dp[i][0] 第`i`天持有股票所得最大现金
         * - 昨日已持有,今日继续持有:dp[i][0] = dp[i-1][0](股票只能买入一次,继承昨日的状态)
         * - 昨日未持有,今日买入持有:dp[i][0] = -price[i] (只能买入一次,因此是初始持有现金减去当日买入股票的价格,为所得现金)
         * 2.2 dp[i][1] 第`i`天不持有股票所得最大现金
         * - 昨日已未持有,今日无操作:dp[i][1] = dp[i-1][1] (股票只能卖出一次,如果昨日已经卖出,则今日无操作,继承昨日的状态)
         * - 昨日还持有,今日卖出:dp[i][1] = dp[i-1][0] + price[i](如果昨日还持有,选择今日卖出,则所得现金为【昨日持有状态下所得最大现金】+【当日卖出价格】)
         */

        // 3.dp 初始化(dp[0][0]\dp[0][1]是推导基础)
        dp[0][0] = 0 - prices[0]; // 第0天持有股票,则只能是当日买入(前面没有可推导的基础)
        dp[0][1] = 0; // 第0天不持有股票,现金为初始状态

        // 4.构建dp
        for (int i = 1; i < m; i++) {
            // 1.计算【第`i`天持有股票所得最大现金】
            dp[i][0] = Math.max(dp[i - 1][0], -prices[i]);

            // 2.计算【第`i`天不持有股票所得最大现金】
            dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
        }

        // 返回结果
        return dp[m - 1][1]; // 不持有股票的状态所得金钱一定更多,因此最后一天的不持有股票时所得现金一定是最多的
    }

}

// output
[-7]-[0]-
[-1]-[0]-
[-1]-[4]-
[-1]-[4]-
[-1]-[5]-
[-1]-[5]-
  • 复杂度分析

    • 时间复杂度:O(m)m 为数组长度

    • 空间复杂度:O(m × 2)构建二维数组dp[m][2]辅助存储

动态规划(一维数组:空间优化版本)

​ 此处使用【一维数组】空间优化版本,实际上就是将[i]维度给去除,重复利用dp[2]的空间。

​ ==为什么可以直接去除[i]维度?==可以从两点去切入:

  • 此处每一天所得现金的最大金额是基于前一天的状态来进行推导的,对于每一天的状态是由两种情况构成:当日持有或者当日不持有,因此可以定义dp[2]来滚动存储上一天的持有、不持有的所得现金最大金额的状态
  • 在推导过程中,后面的每一天都会基于前一天的状态选择继承上一天状态或者更新更优的方案,因此此处最优解就是【最后一天不持有股票的状态所得现金】,所以当所有price遍历完成之后,dp[1]即为最优解
/**
 * 121 买卖股票的最佳时机
 */
public class Solution3 {

    /**
     * 思路:动态规划(空间优化版本)
     * dp[0]: 表示持有股票所得最大现金
     * dp[1]: 表示不持有股票所得最大现金
     */
    public int maxProfit(int[] prices) {
        int m = prices.length;
        // 1.dp 定义(dp[0]持有、dp[1]不持有)
        int[] dp = new int[2];

        /**
         * 2.推导公式
         * 2.1 dp[0] 第`i`天持有股票所得最大现金
         * - 昨日已持有,今日继续持有:dp[0] = dp[0](股票只能买入一次,继承昨日的状态)
         * - 昨日未持有,今日买入持有:dp[0] = -price (只能买入一次,因此是初始持有现金减去当日买入股票的价格,为所得现金)
         * 2.2 dp[1] 第`i`天不持有股票所得最大现金
         * - 昨日已未持有,今日无操作:dp[1] = dp[1] (股票只能卖出一次,如果昨日已经卖出,则今日无操作,继承昨日的状态)
         * - 昨日还持有,今日卖出:dp[1] = dp[0] + price[i](如果昨日还持有,选择今日卖出,则所得现金为【昨日持有状态下所得最大现金】+【当日卖出价格】)
         */

        // 3.dp 初始化(dp[0]、dp[0] : 初始化第0天持有、不持有股票所能获得的最大现金)
        dp[0] = 0 - prices[0]; // 第0天持有股票,则只能是当日买入(前面没有可推导的基础)
        dp[1] = 0; // 第0天不持有股票,现金为初始状态
        PrintDPUtil.print(dp); // 打印状态变化

        // 4.构建dp
        for (int i = 1; i < m; i++) {
            // 1.计算【第`i`天持有股票所得最大现金】
            dp[0] = Math.max(dp[0], -prices[i]);

            // 2.计算【第`i`天不持有股票所得最大现金】
            dp[1] = Math.max(dp[1], dp[0] + prices[i]);

            // 打印状态变化
            PrintDPUtil.print(dp);
        }

        // 返回结果
        return dp[1]; // 不持有股票的状态所得金钱一定更多,因此最后一天的不持有股票时所得现金一定是最多的
    }


    public static void main(String[] args) {
        int[] price = new int[]{7, 1, 5, 3, 6, 4};
        Solution3 solution2 = new Solution3();
        solution2.maxProfit(price);
    }
}

// output
[-7]-[0]-
[-1]-[0]-
[-1]-[4]-
[-1]-[4]-
[-1]-[5]-
[-1]-[5]-

🟡122-买卖股票的最佳时机II

1.题目内容open in new window

给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格

在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售

返回 你能获得的 最大 利润

2.题解思路
👻方法1:贪心思路

​ 贪心思路的演变:对比【121-买卖股票的最佳时机I】中的设定是选择区间的最大价差概念(即在[0,i]范围内,取price[i]-minHistoryPrice,当遍历完所有的i这个最大价差也随之确定)

​ 针对此处【122】问题,可以通过日内做T的方式来不断收集利润,以price[3]为例,假设是在第0天买入,第2天卖出,则其可收集的利润差可以表示为:

price[2]-price[0]=(price[2]-price[1]) + (price[1]-price[0]) 对于左侧此处不要纯粹理解为在第0天买入,第2天卖出的一次操作价差,而是第一天拿到股票,最后一天才还回去,这样就和后面的收集正利润对上号,也不用纠结什么时候买什么时候卖的问题;右侧是【日内做T的利润收集】概念,为了让右侧的等式拿到最大值,此处贪心的思路则在于【收集日内做T的正利润】,对于没有正利润的操作则选择持有不动。

image-20241127103507659

/**
 * 122 买卖股票的最佳时机II
 */
public class Solution1 {

    /**
     * 每日可操作股票,但最多只能持有一股
     */
    public int maxProfit(int[] prices) {
        /**
         * 贪心思路:收集正利润,如果有利润就做T,没利润就不动
         * nums[2]-nums[0] = (nums[2]-nums[1]) + (nums[1]-nums[0])
         * 即可以理解为假设每天做T能得到正利润,那么就去做,如果不能则继续持有等待,进而使得这个数值最大
         */
        int maxProfit = 0; // 初始化最大利润

        // 计算每日做T利润,如果存在正利润就累加,不存在正利润就继续持有
        for (int i = 1; i < prices.length; i++) { // 第1天没有利润
            int curProfit = prices[i] - prices[i - 1];
            if (curProfit > 0) {
                maxProfit += curProfit; // 累加正利润(做T)
            }
        }

        // 返回结果
        return maxProfit;
    }
}
  • 复杂度分析

    • 时间复杂度:O(n) n 为数组大小
    • 空间复杂度:O(1)
👻方法2:动态规划
  • 思路分析:
    • (1)dp定义:构建二维数组存储dp[i][2]注意此处的持有概念,持有不代表是当天买入,持有是保持持有的状态
      • dp[i][0]:表示第i天持有股票所得最多现金
      • dp[i][1]:表示第i天不持有股票所得最多现金
    • (2)递推公式:基于第i天是否持有股票进行情况讨论,因为买卖可以操作多次,所以此处是收益累加的概念
      • 如果第i天持有股票即dp[i][0],其可以由两个状态推导出来

        • 昨日已经持有股票,今日选择继续持有:那么继续继承上一状态(所得现金就是昨天持有股票所得现金状态)=》dp[i][0]=dp[i-1][0]

        • 昨日还没有股票,今日选择买入股票持有:因为可以买卖多次,也就是说本次不一定是第一次买入,那么所得现金状态为pre-price[i](所得为【昨日不持有股票的最大现金所得】减去【当日买入股票的价格】)=》dp[i][0]=dp[i-1][1]-price[i]与【121-买卖股票的最佳时机】的动态规划版本唯一不同的地方,基于前置状态推导的场景分析不同

        • 对于dp[i][0]应该选择两者中所得现金最大的:dp[i][0] = max {dp[i-1][0],dp[i-1][1]-price[i]}

      • 如果第i天不持有股票即dp[i][1],其也可以由两个状态推导出来

        • 昨日就已经不持有股票了,则继承上一状态(所得现金就是昨天不持有股票所得现金状态)=》dp[i][1]=dp[i-1][1]

        • 昨日还持有股票,今日选择卖出股票,所得现金收益为【昨日持有股票的现金收益】+【当日卖出价格】=》dp[i][1]=dp[i-1][0] + price[i]

        • 对于dp[i][1]应该选择两者中所得现金最大的:dp[i][0] = max {dp[i-1][1],dp[i-1][0] + price[i]}

    • (3)初始化dp
      • 基于上述递推公式分析,所有的推导基础需要依赖dp[0][0]dp[0][1]
        • dp[0][0]:表示第0天持有股票所得最多现金,此时第0天如果要持有股票只能是当日买入(因为其是最早开始的时间,无法从前面的状态中推导),因此dp[0][0]=-price[i]

        • dp[0][1]:表示第i天不持有股票所得最多现金,不持有股票,现金初始为0,因此dp[0][1]=0

    • (4)构建dp(遍历顺序):
      • 正序遍历每一天,然后分别封装dp[i][0]dp[i][1]的值
    • (5)验证dp
/**
 * 122 买卖股票的最佳时机II
 */
public class Solution2 {

    /**
     * 思路:动态规划
     * dp[i][0]: 表示第`i`天持有股票所得最大现金
     * dp[i][1]: 表示第`i`天不持有股票所得最大现金
     */
    public int maxProfit(int[] prices) {
        int m = prices.length;
        // 1.dp 定义(dp[i][0]持有、dp[i][1]不持有)
        int[][] dp = new int[m][2];

        /**
         * 2.推导公式
         * 2.1 dp[i][0] 第`i`天持有股票所得最大现金
         * - 昨日已持有,今日继续持有:dp[i][0] = dp[i-1][0](不能重复持有,继承昨日的状态)
         * - 昨日未持有,今日买入持有:dp[i][0] = dp[i-1][1] - price[i] (由于可以重复买入卖出操作,所以此处不一定是第一次买入,因此选择【昨日不持有股票状态的最大现金】-【当日价格】)
         * 2.2 dp[i][1] 第`i`天不持有股票所得最大现金
         * - 昨日已未持有,今日无操作:dp[i][1] = dp[i-1][1] (如果昨日已经卖出,则今日无操作,继承昨日的状态)
         * - 昨日还持有,今日卖出:dp[i][1] = dp[i-1][0] + price[i](如果昨日还持有,选择今日卖出,则所得现金为【昨日持有状态下所得最大现金】+【当日卖出价格】)
         */

        // 3.dp 初始化(dp[0][0]\dp[0][1]是推导基础)
        dp[0][0] = 0 - prices[0]; // 第0天持有股票,则只能是当日买入(前面没有可推导的基础)
        dp[0][1] = 0; // 第0天不持有股票,现金为初始状态

        // 4.构建dp
        for (int i = 1; i < m; i++) {
            // 1.计算【第`i`天持有股票所得最大现金】
            dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] - prices[i]);// todo 和【121】问题处理唯一不同的地方

            // 2.计算【第`i`天不持有股票所得最大现金】
            dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
        }

        // 打印矩阵
        PrintDPUtil.printMatrix(dp);

        // 返回结果
        return dp[m - 1][1]; // 不持有股票的状态所得金钱一定更多,因此最后一天的不持有股票时所得现金一定是最多的
    }

}

// output
[-7]-[0]-
[-1]-[0]-
[-1]-[4]-
[1]-[4]-
[1]-[7]-
[3]-[7]-
  • 复杂度分析

    • 时间复杂度:O(n)n 为数组长度

    • 空间复杂度:O(n × 2)需要构建dp[n][2]的大小

动态规划:空间优化版本

​ 同理,空间优化版本是去掉[i]维度,重复利用一维数组空间不断更新最大利益

/**
 * 122 买卖股票的最佳时机II
 */
public class Solution3 {

    /**
     * 思路:动态规划 空间优化版本
     * dp[0]: 表示持有股票所得最大现金
     * dp[1]: 表示不持有股票所得最大现金
     */
    public int maxProfit(int[] prices) {
        int m = prices.length;
        // 1.dp 定义(dp[0]持有、dp[1]不持有)
        int[] dp = new int[2];

        /**
         * 2.推导公式
         * 2.1 dp[0] 持有股票所得最大现金
         * - 昨日已持有,今日继续持有:dp[0] = dp[0](不能重复持有,继承昨日的状态)
         * - 昨日未持有,今日买入持有:dp[0] = dp[1] - price (由于可以重复买入卖出操作,所以此处不一定是第一次买入,因此选择【昨日不持有股票状态的最大现金】-【当日价格】)
         * 2.2 dp[1] 不持有股票所得最大现金
         * - 昨日已未持有,今日无操作:dp[1] = dp[1] (如果昨日已经卖出,则今日无操作,继承昨日的状态)
         * - 昨日还持有,今日卖出:dp[1] = dp[0] + price[i](如果昨日还持有,选择今日卖出,则所得现金为【昨日持有状态下所得最大现金】+【当日卖出价格】)
         */

        // 3.dp 初始化(dp[0]\dp[1]是推导基础)
        dp[0] = 0 - prices[0]; // 第0天持有股票,则只能是当日买入(前面没有可推导的基础)
        dp[1] = 0; // 第0天不持有股票,现金为初始状态
        PrintDPUtil.print(dp); // 打印dp数组

        // 4.构建dp
        for (int i = 1; i < m; i++) {
            // 1.计算【持有股票所得最大现金】
            dp[0] = Math.max(dp[0], dp[1] - prices[i]);// todo 和【121】问题处理唯一不同的地方

            // 2.计算【不持有股票所得最大现金】
            dp[1] = Math.max(dp[1], dp[0] + prices[i]);

            PrintDPUtil.print(dp); // 打印dp数组
        }

        // 返回结果
        return dp[1]; // 不持有股票的状态所得金钱一定更多,因此最后一天的不持有股票时所得现金一定是最多的
    }

    public static void main(String[] args) {
        int[] price = new int[]{7, 1, 5, 3, 6, 4};
        Solution3 solution2 = new Solution3();
        solution2.maxProfit(price);
    }
}

// output
[-7]-[0]-
[-1]-[0]-
[-1]-[4]-
[1]-[4]-
[1]-[7]-
[3]-[7]-

🔴123-买卖股票的最佳时机III

1.题目内容open in new window

给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。

**注意:**你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

2.题解思路

问题分析:

​ 此处一开始可能容易陷入贪心的思路误区是仿【122】的思路,收集两个最大的正利润,但实际上此处的难点在于"最多可以完成两笔交易"(而【122】是不限制做T次数),如果仅仅是收集两个最大的做T正利润,实际上它并不能覆盖所有的情况。

​ 以{1,2,3,4,5}为例:

  • 做T利润为{0,1,1,1,1}
    • 对于【122】而言,收集正利润是因为不限制做T次数可以覆盖整个区间,最终最高利润为4
    • 对于【123】而言,如果采用上述正利润排序,选择最大的两个正利润进行收集的话,得到的结果是2,但实际上如果采取只做1笔交易的话(在第0天买入,最后一天卖出,可以获得最高利润为4),因此收集正利润的思路已经不适用于这个场景

​ 采用动态规划的思路来分析,对于每一天i确认不同的状态,原【121】【122】是持有或者不持有股票的状态,此处由于最多可以完成两笔交易的限制,需要设定5个状态来进行表示:

  • 状态0:不执行任何操作
  • 状态1:第1次持有股票
  • 状态2:第1次不持有股票
  • 状态3:第2次持有股票
  • 状态4:第2次不持有股票

​ 然后基于上述状态构建dp[i][4]二维数组存储每个状态下的所剩的最大现金,递推公式推导如下:

  • 状态0:不执行任何操作
    • 继承前一天的状态:dp[i][0]=dp[i-1][0]
  • 状态1:第1次持有股票
    • 昨日已持有股票,今日继续持有:dp[i][1]=dp[i-1][0]
    • 昨日未持有股票,今日买入股票:dp[i][1]=0-price[i](也可以是dp[i-1][0]-price[i])此处理解为第1次买入股票,起始资金为0更为准确
    • 选择max{dp[i-1][1],dp[i-1][0]-price[i]}
  • 状态2:第1次不持有股票
    • 昨日已持有股票,今日卖出:dp[i][2]=dp[i-1][1]+price[i](第一次持有股票的剩余现金加上卖出股票的价格)
    • 昨日未持有股票,今日不动,状态继承:dp[i][2]=dp[i-1][2]
  • 状态3:第2次持有股票
    • 昨日已持有股票(第一次持有股票),今日继续持有:dp[i][3]=dp[i-1][2]
    • 昨日未持有股票(第一次不持有股票),今日买入股票(第2次买入):dp[i][3]=dp[i-1][2]-price[i]
  • 状态4:第2次不持有股票
    • 昨日已持有股票(第2次持有股票),今日卖出:dp[i][4]=dp[i-1][3]+price[i](第二次持有股票的剩余现金加上卖出股票的价格)
    • 昨日未持有股票,今日不动,状态继承:dp[i][4]=dp[i-1][4]
👻方法2:
  • 思路分析:

    • (1)dp定义:dp[i][j] 表示第i天不同状态下的剩余的最大现金

      • dp[i][0]:不做任何操作
      • dp[i][1]:第1次持有
      • dp[i][2]:第1次不持有
      • dp[i][3]:第2次持有
      • dp[i][4]:第2次不持有
    • (2)递推公式:状态递推基于【当日持有状态】+【前一日是否持有股票】来更新最新的现金状态,选择可以获得最大价值的操作

      • 状态0:不执行任何操作
        • 继承前一天的状态:dp[i][0]=dp[i-1][0]
      • 状态1:第1次持有股票
        • 昨日已持有股票,今日继续持有:dp[i][1]=dp[i-1][0]
        • 昨日未持有股票,今日买入股票:dp[i][1]=0-price[i](也可以是dp[i-1][0]-price[i])此处理解为第1次买入股票,起始资金为0更为准确
        • 选择max{dp[i-1][1],0-price[i]}
      • 状态2:第1次不持有股票
        • 昨日已持有股票,今日卖出:dp[i][2]=dp[i-1][1]+price[i](第一次持有股票的剩余现金加上卖出股票的价格)
        • 昨日未持有股票,今日不动,状态继承:dp[i][2]=dp[i-1][2]
      • 状态3:第2次持有股票
        • 昨日已持有股票(第一次持有股票),今日继续持有:dp[i][3]=dp[i-1][2]
        • 昨日未持有股票(第一次不持有股票),今日买入股票(第2次买入):dp[i][3]=dp[i-1][2]-price[i]
      • 状态4:第2次不持有股票
        • 昨日已持有股票(第2次持有股票),今日卖出:dp[i][4]=dp[i-1][3]+price[i](第二次持有股票的剩余现金加上卖出股票的价格)
        • 昨日未持有股票,今日不动,状态继承:dp[i][4]=dp[i-1][4]
    • (3)初始化dp:初始化dp[0][j]即分析第0天的操作结果,后续内容都是基于这个基础推导

      • 【不做任何操作】dp[0][0]:0
      • 【第1次买入】dp[0][1]:只能是当日买入,-price[0]
      • 【第1次卖出】dp[0][2]:0 日内执行"买入 卖出",一正一负抵消相当于无操作,因此为0
      • 【第2次买入】dp[0][3]:在第0天执行了"买入 卖出 买入"操作,也就是说日内完成了第1次的买入卖出操作,然后执行第2次买入,因此为-price[0]
      • 【第2次卖出】dp[0][4]:同理,此处是在日内完成了两次买入卖出操作,一正一负抵消,因此为0
    • (4)构建dp(遍历顺序):

      • 正序遍历股票价格列表,根据递推公式构建dp数组
    • (5)验证dp

/**
 * 123 买卖股票的最佳时机III
 */
public class Solution2 {

    /**
     * 动态规划思路
     */
    public int maxProfit(int[] prices) {
        int m = prices.length;
        // 1.dp[i][5] 构建每一天的不同状态(5种状态)下的最大现金价值
        int[][] dp = new int[m][5];

        /**
         * 2.dp 推导
         * 0:不做任何操作
         * - 直接继承上一状态:dp[i][0]: dp[i][0] = dp[i-1][0]
         * 1:第1次持有
         * - 昨日未持股,今日买入:dp[i][1]= 0-prices[i] // dp[i][1]= dp[i-1][0]-prices[i]也可行,但此处理解为第1次买入股票,起始资金为0更为准确
         * - 昨日已持股,继续持有:dp[i][1]=dp[i-1][1]
         * 2:第1次未持有
         * - 昨日未持股,继承状态:dp[i][2]=dp[i-1][2]
         * - 昨日已持股(第一次买入),今日卖出:dp[i][2] = dp[i-1][1] + prices[i]
         * 3:第2次持有
         * - 昨日未持股(第1次不持有),今日买入: dp[i][3] = dp[i-1][2] - prices[i]
         * - 昨日已持股,继续持有:dp[i][3]=dp[i-1][3]
         * 4:第2次未持有
         * - 昨日未持股,继承状态:dp[i][4]=dp[i-1][4]
         * - 昨日已持股(第2次持有),今日卖出:dp[i][4]=dp[i-1][3] + prices[i]
         */

        // 3.dp初始化(dp[0][j]初始化)
        dp[0][0] = 0; // 日内不做任何操作
        dp[0][1] = 0 - prices[0]; // 第1次持有,买入股票
        dp[0][2] = 0; // 第1次不持有,日内执行了买入卖出操作,一正一负抵消
        dp[0][3] = 0 - prices[0]; // 第2次持有,日内执行了第1次买入卖出操作,然后又进行了第2次买入操作
        dp[0][4] = 0; // 第2次不持有,日内分别执行了两次买入卖出操作,一正一负抵消

        // 4.dp构建(根据dp推导公式填充dp)
        for (int i = 1; i < m; i++) {
            dp[i][0] = dp[i - 1][0];
            dp[i][1] = Math.max(0 - prices[i], dp[i - 1][1]); // Math.max(dp[i - 1][0] - prices[i], dp[i - 1][1]);
            dp[i][2] = Math.max(dp[i - 1][1] + prices[i], dp[i - 1][2]);
            dp[i][3] = Math.max(dp[i - 1][2] - prices[i], dp[i - 1][3]);
            dp[i][4] = Math.max(dp[i - 1][3] + prices[i], dp[i - 1][4]);
        }

        PrintDPUtil.printMatrix(dp); // 打印dp数组信息

        // 结果返回(最后一日不持有股票的状态下现金最多)
        return dp[m - 1][4];
    }

    public static void main(String[] args) {
//        int[] prices = new int[]{3, 3, 5, 0, 0, 3, 1, 4};
        int[] prices = new int[]{1, 2, 3, 4, 5};
        Solution2 solution2 = new Solution2();
        solution2.maxProfit(prices);
    }

}

// output
[0]-[-1]-[0]-[-1]-[0]-
[0]-[-1]-[1]-[-1]-[1]-
[0]-[-1]-[2]-[-1]-[2]-
[0]-[-1]-[3]-[-1]-[3]-
[0]-[-1]-[4]-[-1]-[4]-
  • 复杂度分析

    • 时间复杂度:O(m)m 为数组长度

    • 空间复杂度:O(m × 5),需要构建dp[m][5]大小的数组存储状态

动态规划:空间优化版本(去除[i]维度)

/**
 * 123 买卖股票的最佳时机III
 */
public class Solution3 {

    /**
     * 动态规划思路(空间优化版本)
     */
    public int maxProfit(int[] prices) {
        int m = prices.length;
        // 1.dp[5] 构建每一天的不同状态(5种状态)下的最大现金价值
        int[] dp = new int[5];

        /**
         * 2.dp 推导
         * 0:不做任何操作
         * - 直接继承上一状态:dp: dp = dp[0]
         * 1:第1次持有
         * - 昨日未持股,今日买入:dp[1]= dp[0]-prices[i]
         * - 昨日已持股,继续持有:dp[1]=dp[1]
         * 2:第1次未持有
         * - 昨日未持股,继承状态:dp[2]=dp[2]
         * - 昨日已持股(第一次买入),今日卖出:dp[2] = dp[1] + prices[i]
         * 3:第2次持有
         * - 昨日未持股(第1次不持有),今日买入: dp[3] = dp[2] - prices[i]
         * - 昨日已持股,继续持有:dp[3]=dp[3]
         * 4:第2次未持有
         * - 昨日未持股,继承状态:dp[4]=dp[4]
         * - 昨日已持股(第2次持有),今日卖出:dp[4]=dp[3] + prices[i]
         */

        // 3.dp初始化(dp[j]初始化)
        dp[0] = 0; // 日内不做任何操作
        dp[1] = 0 - prices[0]; // 第1次持有,买入股票
        dp[2] = 0; // 第1次不持有,日内执行了买入卖出操作,一正一负抵消
        dp[3] = 0 - prices[0]; // 第2次持有,日内执行了第1次买入卖出操作,然后又进行了第2次买入操作
        dp[4] = 0; // 第2次不持有,日内分别执行了两次买入卖出操作,一正一负抵消
        PrintDPUtil.print(dp);

        // 4.dp构建(根据dp推导公式填充dp)
        for (int i = 1; i < m; i++) {
            // dp[0]表示不做任何操作
            dp[1] = Math.max(dp[0] - prices[i], dp[1]);
            dp[2] = Math.max(dp[1] + prices[i], dp[2]);
            dp[3] = Math.max(dp[2] - prices[i], dp[3]);
            dp[4] = Math.max(dp[3] + prices[i], dp[4]);
            PrintDPUtil.print(dp); // 打印dp
        }

        // 结果返回(最后一日不持有股票的状态下现金最多)
        return dp[4];
    }

    public static void main(String[] args) {
//        int[] prices = new int[]{3, 3, 5, 0, 0, 3, 1, 4};
        int[] prices = new int[]{1, 2, 3, 4, 5};
        Solution2 solution2 = new Solution2();
        solution2.maxProfit(prices);
    }

}

// output
[0]-[-1]-[0]-[-1]-[0]-
[0]-[-1]-[1]-[-1]-[1]-
[0]-[-1]-[2]-[-1]-[2]-
[0]-[-1]-[3]-[-1]-[3]-
[0]-[-1]-[4]-[-1]-[4]-
  • 时间复杂度:O(m)m 为数组长度

  • 空间复杂度:O(5),需要构建dp[5]大小的数组存储状态

image-20241127165940230

​ 遍历到最后,不持有的状态一定是现金最多的情况,那么应该选择【第1次不持有】还是【第2次不持有】?可以理解一种情况,如果【第1次不持有】已经是最大值的情况下,那么【第2次不持有】的方案可以是日内连续买入卖出操作两次,拿到的也是最大值,也就是说可以理解为【第2次不持有】的场景包含了【第1次不持有】的场景,因此最终的选择为【第2次不持有】

🔴188-买卖股票的最佳时机IV

1.题目内容open in new window

给你一个整数数组 prices 和一个整数 k ,其中 prices[i] 是某支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。也就是说,你最多可以买 k 次,卖 k 次。

**注意:**你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入:k = 2, prices = [2,4,1]
输出:2
解释:在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2 。

示例 2:

输入:k = 2, prices = [3,2,6,5,0,3]
输出:7
解释:在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4 。
     随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。
2.题解思路
👻方法1:动态规划

思路演进

​ 基于【123-买卖股票的最佳时机III】题型总结规律,对dp[i][j]的状态j做文章

  • 状态为0(无操作):dp[i][0]=dp[i-1][0]
  • 状态为1(第1次持有) :dp[i][1] = max{dp[i-1][1],dp[i-1][0]-price[i]}(原已持有就不动,原无持有则买入)
  • 状态为2(第1次不持有):dp[i][2] = max{dp[i-1][2],dp[i-1][1]+price[i]}(原未持有就不动(卖无可卖),原已持有则卖出(需一一对应:即第一次持有就要用第一次买入的来卖))
  • 状态为3(第2次持有) :dp[i][3] = max{dp[i-1][3],dp[i-1][2]-price[i]}(原已持有就不动,原无持有则买入(基于上一次的基础上操作))
  • 状态为4(第2次不持有):dp[i][4] = max{dp[i-1][4],dp[i-1][3]+price[i]}(原未持有就不动(卖无可卖),原已持有则卖出(需一一对应:即第一次持有就要用第一次买入的来卖))

​ 基于上述分析,可以进一步总结规律,可以看到除了0之外,状态为奇数的表示持有,状态为偶数的表示不持有,限定为k次操作,可以继续基于二维数组来构建

  • 第k次持有==(k从1开始计数,注意计数规则)==
    • 昨日未持股(第k-1次不持有),今日买入: dp[i][2*k-1] = dp[i-1][2*k-2] - prices[i]
    • 昨日已持股,继续持有:dp[i][2*k-1]=dp[i-1][2*k-1]
  • 第k次未持有
    • 昨日未持股,继承状态:dp[i][2*k]=dp[i-1][2*k]
    • 昨日已持股(第k次持有),今日卖出:dp[i][2*k]=dp[i-1][2*k-1] + prices[i]
  • 思路分析:

    • (1)dp定义:dp[i][2*k+1](一共有2*k+1种状态,0 + k次持有 + k次不持有

    • (2)递推公式:

      • 状态为0(无操作):dp[i][0]=dp[i-1][0]
      • 状态为第k次持有(k从1开始计数)
        • 昨日未持股(第k-1次不持有),今日买入: dp[i][2*k-1] = dp[i-1][2*k-2] - prices[i]
        • 昨日已持股,继续持有:dp[i][2*k-1]=dp[i-1][2*k-1]
      • 状态为第k次未持有
        • 昨日未持股,继承状态:dp[i][2*k]=dp[i-1][2*k]
        • 昨日已持股(第k次持有),今日卖出:dp[i][2*k]=dp[i-1][2*k-1] + prices[i]
    • (3)初始化dp

      dp[0][0] = 0; // 日内不做任何操作
      for (int x = 1; x <= k; x++) {
          dp[0][2 * x - 1] = 0 - prices[0]; // 第k次持有,买入股票
          dp[0][2 * x] = 0; // 第k次不持有,日内执行了买入卖出操作,一正一负抵消
      }
      
    • (4)构建dp(遍历顺序):

      // 4.dp构建(根据dp推导公式填充dp)
      for (int i = 1; i < m; i++) {
          dp[i][0] = dp[i - 1][0]; // 不操作
          // k 次 操作封装
          for (int x = 1; x <= k; x++) {
              dp[i][2 * x - 1] = Math.max(dp[i - 1][2 * x - 2] - prices[i], dp[i - 1][2 * x - 1]); // 第k次持有
              dp[i][2 * x] = Math.max(dp[i - 1][2 * x - 1] + prices[i], dp[i - 1][2 * x]); // 第k次不持有
          }
      }
      
    • (5)验证dp

/**
 * 188 买卖股票的最佳时机IV
 */
public class Solution1 {

    /**
     * 动态规划思路
     */
    public int maxProfit(int k, int[] prices) {
        int m = prices.length;
        // 1.dp[i][2*k+1] 构建每一天的不同状态(2k+1种状态:0、k次持有、k次不持有)下的最大现金价值
        int[][] dp = new int[m][2 * k + 1];

        /**
         * 2.dp 推导
         * 0:不做任何操作
         * - 直接继承上一状态:dp[i][0]: dp[i][0] = dp[i-1][0]
         * 第k次:第k次持有(2k-1)、第k次未持有(2k) k=1 开始
         * 1:第1次持有
         * - 昨日未持股,今日买入:dp[i][1]= dp[i-1][0]-prices[i] // dp[i][1]= dp[i-1][0]-prices[i]也可行,但此处理解为第1次买入股票,起始资金为0更为准确
         * - 昨日已持股,继续持有:dp[i][1]=dp[i-1][1]
         * 2:第1次未持有
         * - 昨日未持股,继承状态:dp[i][2]=dp[i-1][2]
         * - 昨日已持股(第一次买入),今日卖出:dp[i][2] = dp[i-1][1] + prices[i]
         * 3:第k次持有
         * - 昨日未持股(第k-1次不持有),今日买入: dp[i][2*k-1] = dp[i-1][2*k-2] - prices[i]
         * - 昨日已持股,继续持有:dp[i][2*k-1]=dp[i-1][2*k-1]
         * 4:第k次未持有
         * - 昨日未持股,继承状态:dp[i][2*k]=dp[i-1][2*k]
         * - 昨日已持股(第k次持有),今日卖出:dp[i][2*k]=dp[i-1][2*k-1] + prices[i]
         */

        // 3.dp初始化(dp[0][j]初始化)
        dp[0][0] = 0; // 日内不做任何操作
        for (int x = 1; x <= k; x++) {
            dp[0][2 * x - 1] = 0 - prices[0]; // 第k次持有,买入股票
            dp[0][2 * x] = 0; // 第k次不持有,日内执行了买入卖出操作,一正一负抵消
        }

        // 4.dp构建(根据dp推导公式填充dp)
        for (int i = 1; i < m; i++) {
            dp[i][0] = dp[i - 1][0]; // 不操作
            // k 次 操作封装
            for (int x = 1; x <= k; x++) {
                dp[i][2 * x - 1] = Math.max(dp[i - 1][2 * x - 2] - prices[i], dp[i - 1][2 * x - 1]); // 第k次持有
                dp[i][2 * x] = Math.max(dp[i - 1][2 * x - 1] + prices[i], dp[i - 1][2 * x]); // 第k次不持有
            }
        }

        // PrintDPUtil.printMatrix(dp); // 打印dp数组信息

        // 结果返回(最后一日不持有股票的状态下现金最多)
        return dp[m - 1][2 * k];
    }

    public static void main(String[] args) {
//        int[] prices = new int[]{3, 3, 5, 0, 0, 3, 1, 4};
        int[] prices = new int[]{1, 2, 3, 4, 5};
        Solution1 solution2 = new Solution1();
        solution2.maxProfit( 2,prices);
    }

}
  • 复杂度分析

    • 时间复杂度:O(m)m 为数组长度

    • 空间复杂度:O(m × (2 × k + 1)),需要构建dp[m][2*k+1]大小的数组存储状态

动态规划(空间优化版本)去掉i维度

/**
 * 188 买卖股票的最佳时机IV
 */
public class Solution2 {

    /**
     * 动态规划思路:空间优化版本
     */
    public int maxProfit(int k, int[] prices) {
        int m = prices.length;
        // 1.dp[2*k+1] 构建每一天的不同状态(2k+1种状态:0、k次持有、k次不持有)下的最大现金价值
        int[] dp = new int[2 * k + 1];

        /**
         * 2.dp 推导
         * 0:不做任何操作
         * - 直接继承上一状态:dp[0]: dp[0] = dp[0]
         * 第k次:第k次持有(2k-1)、第k次未持有(2k) k=1 开始
         * 1:第1次持有
         * - 昨日未持股,今日买入:dp[1]= dp[0]-prices[i] // dp[1]= dp[0]-prices[i]也可行,但此处理解为第1次买入股票,起始资金为0更为准确
         * - 昨日已持股,继续持有:dp[1]=dp[1]
         * 2:第1次未持有
         * - 昨日未持股,继承状态:dp[2]=dp[2]
         * - 昨日已持股(第一次买入),今日卖出:dp[2] = dp[1] + prices[i]
         * 3:第k次持有
         * - 昨日未持股(第k-1次不持有),今日买入: dp[2*k-1] = dp[2*k-2] - prices[i]
         * - 昨日已持股,继续持有:dp[2*k-1]=dp[2*k-1]
         * 4:第k次未持有
         * - 昨日未持股,继承状态:dp[2*k]=dp[2*k]
         * - 昨日已持股(第k次持有),今日卖出:dp[2*k]=dp[2*k-1] + prices[i]
         */

        // 3.dp初始化(dp[0][j]初始化)
        dp[0] = 0; // 日内不做任何操作
        for (int x = 1; x <= k; x++) {
            dp[2 * x - 1] = 0 - prices[0]; // 第k次持有,买入股票
            dp[2 * x] = 0; // 第k次不持有,日内执行了买入卖出操作,一正一负抵消
        }
        PrintDPUtil.print(dp); // 打印dp数组信息

        // 4.dp构建(根据dp推导公式填充dp)
        for (int i = 1; i < m; i++) {
            dp[0] = dp[0]; // 不操作
            // k 次 操作封装
            for (int x = 1; x <= k; x++) {
                dp[2 * x - 1] = Math.max(dp[2 * x - 2] - prices[i], dp[2 * x - 1]); // 第k次持有
                dp[2 * x] = Math.max(dp[2 * x - 1] + prices[i], dp[2 * x]); // 第k次不持有
            }
            PrintDPUtil.print(dp); // 打印dp数组信息
        }

        // 结果返回(最后一日不持有股票的状态下现金最多)
        return dp[2 * k];
    }

    public static void main(String[] args) {
//        int[] prices = new int[]{3, 3, 5, 0, 0, 3, 1, 4};
        int[] prices = new int[]{1, 2, 3, 4, 5};
        Solution2 solution2 = new Solution2();
        solution2.maxProfit(2, prices);
    }

}

🟡309-最佳买卖股票时机含冷冻期

1.题目内容open in new window

给定一个整数数组prices,其中第 prices[i] 表示第 *i* 天的股票价格 。

设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

  • 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。

**注意:**你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: prices = [1,2,3,0,2]
输出: 3 
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]

示例 2:

输入: prices = [1]
输出: 0
2.题解思路

状态分析

​ 对每一天的状态进行梳理,以及状态和状态之间的切换。此处根据3个角度(持有、不持有、冷冻期)可以划分为4个状态:

  • 持有状态:
    • 【状态0】持有
      • 之前没有持有,今天买入
      • 之前有持有,继续持有
  • 不持有状态:
    • 【状态1】不持有(状态保持)
      • 之前就不持有(两天前卖出,度过了一天冷冻期;昨日卖出后没有操作),继续保持
    • 【状态2】不持有(今日卖出)(此处单独将这个状态摘出来是因为考虑到冷冻期概念,冷冻期限定只有一天,针对冷冻期的前一天只能是【今日卖出股票】不持有状态,如果仅仅是不持有状态,则概念就会很模糊,因为此时前一天就不一定是卖出股票的操作)
      • 今日卖出
  • 冷冻期状态:
    • 【状态3】冷冻(冷冻期状态不可持续,只有1天)

image-20241128082356715

递推公式推导

​ 基于上述状态说明,此处进一步确认递推公式(结合上述状态转移图示理解),分析每个状态的推导过程:

【0】持有0

  • 昨日已持有,继续持有:dp[i][0]=dp[i-1][0]
  • 昨日未持有,今日买入:
    • 【1】->【0】:dp[i][0]=dp[i-1][1]-price[i]
    • 【3】->【0】:dp[i][0]=dp[i-1][3]-price[i]
  • => dp[i][0]=max{dp[i-1][0],dp[i-1][1]-price[i],dp[i-1][3]-price[i]}

【1】不持有(状态保持)1

  • 【1】->【1】前两日前已卖出,过了冷冻期:dp[i][1]=dp[i-1][1]
  • 【3】->【1】昨日是冷冻期:dp[i][1]=dp[i-1][3]
  • =>dp[i][1]=max{dp[i-1][1],dp[i-1][3]}

【2】不持有(今日卖出)2

  • 【0】->【2】:dp[i][2]=dp[i-1][0]+price[i]

【3】冷冻3

  • 【2】->【3】(冷冻期只有一天,所以只能是昨天卖出股票后状态保持才能切到冷冻期):dp[i][3]=dp[i-1][2]
👻方法1:动态规划
  • 思路分析:
    • (1)dp定义:dp[i][4]表示每一天不同状态下的剩余的最大现金价值
    • (2)递推公式:根据上述状态转移分析推导递推公式
    • (3)初始化dp:初始第0日的各个状态,dp[0][0]=0-price[i]其余dp[i][j]状态设置为0
    • (4)构建dp(遍历顺序):正常处理封装dp[i][j]
      • 此处最终返回结果应该是【卖出股票】的所有状态中选择max,因此res = max{dp[m - 1][3],dp[m - 1][1], dp[m - 1][2]}
    • (5)验证dp
/**
 * 309 股票交易的最佳时机含冷冻期
 */
public class Solution1 {

    public int maxProfit(int[] prices) {
        int m = prices.length;

        // 1.定义dp(dp[i][4]:每一天不同状态下的剩余的最大现金价值)
        int[][] dp = new int[m][4];

        /**
         * 2.dp推导
         * 0-持有
         * - 原已持有,继续保持:dp[i][0]=dp[i-1][0]
         * - 原未持有,今日买入:
         * - - 1->0: dp[i][0]=dp[i-1][1]-price[i]
         * - - 3->0: dp[i][0]=dp[i-1][3]-price[i]
         *
         * 1-不持有(状态保持)
         * - 前两日卖出,过了冷冻期(2->1):dp[i][1]=dp[i-1][1]
         * - 昨日是冷冻期(3->1):dp[i][1]=dp[i-1][3]
         *
         * 2-不持有(今日卖出)
         * - 原已持有,今日卖出(0->2):dp[i][2]=dp[i-1][0]+price[i]
         *
         * 3-冷冻期
         * - 昨日卖出,今日为冷冻期(2->3):dp[i][3]=dp[i-1][2](冷冻期只有1日,继续保持前一日【当日卖出】的最大现金)
         */

        // 3.初始化dp(初始化第0天的4个初始状态)
        dp[0][0] = 0 - prices[0]; // 无前置推导,只能是今日买入
        dp[0][1] = 0; // 无前置推导,只能是初始为0
        dp[0][2] = 0; // 无前置推导,只能是初始为0
        dp[0][3] = 0; // 无前置推导,只能是初始为0

        // 4.构建dp(根据递推公式进行处理)
        for (int i = 1; i < m; i++) {
            dp[i][0] = Math.max(dp[i - 1][0], Math.max(dp[i - 1][1] - prices[i], dp[i - 1][3] - prices[i]));
            dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][3]);
            dp[i][2] = dp[i - 1][0] + prices[i];
            dp[i][3] = dp[i - 1][2];
        }

        PrintDPUtil.printMatrix(dp); // 打印dp数组

        // 返回结果(从3种卖出状态中选择最大)
        return Math.max(dp[m - 1][3], Math.max(dp[m - 1][1], dp[m - 1][2]));
    }

    public static void main(String[] args) {
        int[] prices = new int[]{1, 2, 3, 0, 2};
        Solution1 solution1 = new Solution1();
        solution1.maxProfit(prices);
    }

}
// output
[-1]-[0]-[0]-[0]-
[-1]-[0]-[1]-[0]-
[-1]-[0]-[2]-[1]-
[1]-[1]-[-1]-[2]-
[1]-[2]-[3]-[-1]-
  • 复杂度分析

    • 时间复杂度:O(m)
  • 空间复杂度:O(m × 4) 需要构建dp[m][4]数组存储状态

动态计划(空间优化版本:一维数组)

​ 此处在去除[i]维度的同时需要注意如果选择从前往后遍历的顺序,是否会覆盖掉后面要使用的数据,如果是则需要先预存数据

  • dp[0] = max{dp[0], dp[1] - prices[i], dp[3] - prices[i]};
  • dp[1] = max{dp[1], dp[3]};
  • dp[2] = dp[0] + prices[i]; // 此处要使用的dp[0]数据被前面的操作覆盖了,应该先用tempDp0 预存dp[0]数据便于后续使用 =>dp[2] = tempDp0 + prices[i];
  • dp[3] = dp[2];// 此处要使用的dp[2]数据被前面的操作覆盖了,应该先用tempDp2 预存dp[2]数据便于后续使用 =>dp[2] = tempDp2;
/**
 * 309 股票交易的最佳时机含冷冻期
 */
public class Solution2 {

    // 动态规划(空间优化版本)
    public int maxProfit(int[] prices) {
        int m = prices.length;

        // 1.定义dp(dp[4]:每一天不同状态下的剩余的最大现金价值)
        int[] dp = new int[4];

        /**
         * 2.dp推导
         * 0-持有
         * - 原已持有,继续保持:dp=dp
         * - 原未持有,今日买入:
         * - - 1->0: dp=dp[1]-price[i]
         * - - 3->0: dp=dp[3]-price[i]
         *
         * 1-不持有(状态保持)
         * - 前两日卖出,过了冷冻期(2->1):dp[1]=dp[1]
         * - 昨日是冷冻期(3->1):dp[1]=dp[3]
         *
         * 2-不持有(今日卖出)
         * - 原已持有,今日卖出(0->2):dp[2]=dp+price[i]
         *
         * 3-冷冻期
         * - 昨日卖出,今日为冷冻期(2->3):dp[3]=dp[2](冷冻期只有1日,继续保持前一日【当日卖出】的最大现金)
         */

        // 3.初始化dp(初始化第0天的4个初始状态)
        dp[0] = 0 - prices[0]; // 无前置推导,只能是今日买入
        dp[1] = 0; // 无前置推导,只能是初始为0
        dp[2] = 0; // 无前置推导,只能是初始为0
        dp[3] = 0; // 无前置推导,只能是初始为0
        PrintDPUtil.print(dp); // 打印dp数组

        // 4.构建dp(根据递推公式进行处理)
        for (int i = 1; i < m; i++) {
            // 需要用临时变量存储dp[0]、dp[2] 因为从前往后遍历会覆盖掉后面要使用的dp[0]、dp[2]
            int tempDp0 = dp[0];
            int tempDp2 = dp[2];
            // 递推填充
            dp[0] = Math.max(dp[0], Math.max(dp[1] - prices[i], dp[3] - prices[i]));
            dp[1] = Math.max(dp[1], dp[3]);
            dp[2] = tempDp0 + prices[i]; // dp[0] + prices[i];
            dp[3] = tempDp2; // dp[2];
            PrintDPUtil.print(dp); // 打印dp数组
        }

        // 返回结果(从3种卖出状态中选择最大)
        return Math.max(dp[3], Math.max(dp[1], dp[2]));
    }

    public static void main(String[] args) {
        int[] prices = new int[]{1, 2, 3, 0, 2};
        Solution2 solution1 = new Solution2();
        solution1.maxProfit(prices);
    }

}

// output
[-1]-[0]-[0]-[0]-
[-1]-[0]-[1]-[0]-
[-1]-[0]-[2]-[1]-
[1]-[1]-[-1]-[2]-
[1]-[2]-[3]-[-1]-

复杂度分析

  • 时间复杂度:O(m)
  • 空间复杂度:O(4) 需要构建dp[4]数组滚动存储状态,还需借助临时变量预存一些可能被覆盖的值

🟡714-买卖股票的最佳时机含手续费

1.题目内容open in new window

给定一个整数数组 prices,其中 prices[i]表示第 i 天的股票价格 ;整数 fee 代表了交易股票的手续费用。

你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。

返回获得利润的最大值。

**注意:**这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。

示例 1:

输入:prices = [1, 3, 2, 8, 4, 9], fee = 2
输出:8
解释:能够达到的最大利润:  
在此处买入 prices[0] = 1
在此处卖出 prices[3] = 8
在此处买入 prices[4] = 4
在此处卖出 prices[5] = 9
总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8

示例 2:

输入:prices = [1,3,7,5,10,3], fee = 3
输出:6
2.题解思路

推导分析

dp[i][2]:表示每一天持有、不持有股票的最大现金额(最终卖出股票留存现金就是最大利润),此处注意每笔交易都会有手续费

  • 0:持有状态
    • 原未持有(可能是初始,可以是已经买入卖出,选择未持有状态下的最大现金),今日买入(买入不需要处理手续费):dp[i][0]=dp[i-1][1]-price[i]
    • 原已持有,继续持有:dp[i][0]=dp[i-1][0]
  • 1:未持有状态
    • 原未持有,继续保持:dp[i][1]=dp[i-1][1]
    • 原已持有,今日卖出(卖出需处理手续费):dp[i][1]=dp[i-1][0]+price[i]-fee

手续费处理情况讨论:

  • 如果是买入的时候处理手续费:需注意
    • 初始化买入的时候需处理手续费:dp[0] = 0 - prices[0] - fee;
    • 只有在买入操作的时候处理手续费:dp[i][0]=dp[i-1][1]-price[i]-fee
    • 结果处理:res=max{dp[m-1][0],dp[m-1][1]}(需要判断最后一天持有和不持有两种状态下所得最大现金)
  • 如果是卖出的时候处理手续费:
    • 初始化无卖出不需要处理手续费:dp[1]=0
    • 只有在卖出操作的时候处理手续费:dp[i][1]=dp[i-1][0]+price[i]-fee
    • 结果处理:res=dp[m-1][1](最后一天不持有股票所得现金最大)
👻方法1:动态规划
  • 思路分析:
    • (1)dp定义:dp[i][2]:表示每一天持有、不持有股票的最大现金额
    • (2)递推公式:
      • 0-持有状态dp[i][0]=max{dp[i-1][0],dp[i-1][1]-price[i]}
      • 1-未持有状态dp[i][1]=max{dp[i-1][1],dp[i-1][0]+price[i]-fee}
    • (3)初始化dp:初始化第0的两种状态
      • dp[0][0]:无前置状态,只能是第一次买入=》dp[0][0]=-price[i]
      • dp[0][1]:无前置状态,初始化为0
    • (4)构建dp(遍历顺序):顺序遍历,构建dp数组
    • (5)验证dp
/**
 * 714 买卖股票的最佳时机含手续费
 */
public class Solution1 {

    // 动态规划
    public int maxProfit(int[] prices, int fee) {
        int m = prices.length;
        // 1.dp定义(dp[i][2]表示每一天的持有、未持有状态下的最大现金)
        int[][] dp = new int[m][2];

        /**
         * 2.dp推导
         * - `0`:持有状态
         *   - 原未持有(可能是初始,可以是已经买入卖出,选择未持有状态下的最大现金),今日买入(买入不需要处理手续费):`dp[i][0]=dp[i-1][1]-price[i]`
         *   - 原已持有,继续持有:`dp[i][0]=dp[i-1][0]`
         * - `1`:未持有状态
         *   - 原未持有,继续保持:`dp[i][1]=dp[i-1][1]`
         *   - 原已持有,今日卖出(卖出需处理手续费):`dp[i][1]=dp[i-1][0]+price[i]-fee`
         */

        // 3.dp初始化(初始化第0天的持有、未持有状态)
        dp[0][0] = 0 - prices[0]; // 第0天无前置状态,只能是当日买入
        dp[0][1] = 0; // 第0天无前置状态,保持未持有,初始化为0

        // 4.dp构建
        for (int i = 1; i < m; i++) {
            dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
            dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i] - fee); // 卖出时处理手续费
        }

        // 返回结果 
        return dp[m - 1][1]; // 卖出时处理手续费,最大利润为最后一天的不持有状态
    }

}
  • 复杂度分析

    • 时间复杂度:O(m) m 数组长度

    • 空间复杂度:O(m × 2)需构建dp[m][2]长度的数组存储状态变化

动态规划(空间优化版本:一维数组)

/**
 * 714 买卖股票的最佳时机含手续费
 */
public class Solution2 {

    // 动态规划:空间优化版本
    public int maxProfit(int[] prices, int fee) {
        int m = prices.length;
        // 1.dp定义(dp[2]表示每一天的持有、未持有状态下的最大现金)
        int[] dp = new int[2];

        /**
         * 2.dp推导
         * - `0`:持有状态
         *   - 原未持有(可能是初始,可以是已经买入卖出,选择未持有状态下的最大现金),今日买入(买入不需要处理手续费):`dp[0]=dp[1]-price[i]`
         *   - 原已持有,继续持有:`dp[0]=dp[0]`
         * - `1`:未持有状态
         *   - 原未持有,继续保持:`dp[1]=dp[1]`
         *   - 原已持有,今日卖出(卖出需处理手续费):`dp[1]=dp[0]+price[i]-fee`
         */

        // 3.dp初始化(初始化第0天的持有、未持有状态)
        dp[0] = 0 - prices[0]; // 第0天无前置状态,只能是当日买入
        dp[1] = 0; // 第0天无前置状态,保持未持有,初始化为0

        // 4.dp构建
        for (int i = 1; i < m; i++) {
            // dp[0] = Math.max(dp[0], dp[1] - prices[i]);
            // dp[1] = Math.max(dp[1], dp[0] + prices[i] - fee);
            int tempDp0 = dp[0];
            int tempDp1 = dp[1];
            dp[0] = Math.max(tempDp0, tempDp1 - prices[i]);
            dp[1] = Math.max(tempDp1, tempDp0 + prices[i] - fee); // 卖出时处理手续费
        }

        // 返回结果
        return dp[1]; // 卖出时处理手续费,最大利润为最后一天的不持有状态
    }

}
  • 复杂度分析

    • 时间复杂度:O(m) m 数组长度

    • 空间复杂度:O(2)需构建dp[2]长度的数组存储状态变化

如果是买入时处理手续费

image-20241128101245345

🚀子序列问题-01-子序列(不连续)

🟡300-最长上升子序列

1.题目内容open in new window

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

2.题解思路
👻方法1:动态规划
  • 案例分析:[0,1,0,3,2]

    i=0:全初始化为1
    i=1:[1]-[2]-[1]-[1]-[1]-
    i=2:[1]-[2]-[1]-[1]-[1]-
    i=3:[1]-[2]-[1]-[3]-[1]-
    i=4:[1]-[2]-[1]-[3]-[3]-
    
  • 思路分析:

    • (1)dp定义:dp[i]表示以当前第i元素结尾的递增子序列的最大长度**(不连续)**

    • (2)递推公式:

      • dp[i]有两种选择,第一种就是自身(以自身结尾的仅有自身一个元素的子序列,元素本身就是一个递增子序列);第二种就是拼在[0,i]范围内某个递增序列的后面,构成一个更大的递增子序列
    • (3)初始化dpdp[i]=1(元素本身就是一个递增子序列)

    • (4)构建dp(遍历顺序):

      • 外层遍历:选择i
      • 内层遍历:j[0,i]位置遍历,判断当前nums[i]可以接在哪个元素后面构成一个新的递增子序列,还是说以其本身结尾
    • (5)验证dp

/**
 * 300 最长递增子序列
 */
public class Solution1 {
    // 最长递增子序列
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        // 1.dp定义(dp[i]表示以`i`位置元素结尾的最大严格递增子序列长度(不连续))
        int[] dp = new int[n];

        /**
         * 2.dp递推
         * 以nums[i]结尾只有两种可能
         * 1.要么为元素自身构成一个序列:dp[i] = 1
         * 2.要么是可以拼接在[0,i]内元素后面,并且构成最大严格递增子序列(不连续):dp[i] = max{dp[i],dp[j] + 1}  (j∈{0,i])
         */

        // 3.dp初始化
        dp[0] = 1; // 元素自身为一个递增子序列

        // 4.dp构建
        for (int i = 1; i < n; i++) { // 外层确定i
            int curMax = 1; // 初始化为【元素为自身的情况】
            for (int j = 0; j < i; j++) { // 内层从[0,i]中择选可以构成最大连续递增子序列的元素,选择最长的那个
                // 只有nums[i]>nums[j]才能构建连续递增
                if (nums[i] > nums[j]) {
                    // 选择最大的长度
                    curMax = Math.max(curMax, dp[j] + 1);
                }
            }
            // 更新dp[i]
            dp[i] = curMax;
        }

        // 结果处理
        int maxLen = 0;
        for (int i = 0; i < dp.length; i++) {
            maxLen = Math.max(maxLen, dp[i]);
        }
        return maxLen;
    }
}
  • 复杂度分析

    • 时间复杂度:O(n)n为数组长度

    • 空间复杂度:O(n)构建dp[n]数组

代码结构优化

/**
 * 300 最长递增子序列
 */
public class Solution2 {
    // 最长递增子序列
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        // 特例判断
        if(n==1){
            return 1;
        }
        
        // 1.dp定义(dp[i]表示以`i`位置元素结尾的最大严格递增子序列长度(不连续))
        int[] dp = new int[n];

        /**
         * 2.dp递推
         * 以nums[i]结尾只有两种可能
         * 1.要么为元素自身构成一个序列:dp[i] = 1
         * 2.要么是可以拼接在[0,i]内元素后面,并且构成最大严格递增子序列(不连续):dp[i] = max{dp[i],dp[j] + 1}  (j∈{0,i])
         */

        // 3.dp初始化
        Arrays.fill(dp, 1); // 初始化:元素自身为一个递增子序列

        // 4.dp构建
        int maxLen = 0;
        for (int i = 1; i < n; i++) { // 外层确定i
            for (int j = 0; j < i; j++) { // 内层从[0,i]中择选可以构成最大连续递增子序列的元素,选择最长的那个
                // 只有nums[i]>nums[j]才能构建连续递增
                if (nums[i] > nums[j]) {
                    // 选择最大的长度
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            // 遍历过程中同步更新最大长度
            maxLen = Math.max(maxLen, dp[i]);
            // 打印数组状态变化
            System.out.print("i=" + i + ":");
            PrintDPUtil.print(dp);
        }

        // 结果处理
        return maxLen;
    }

    public static void main(String[] args) {
        int[] nums = new int[]{0, 1, 0, 3, 2};
        Solution2 solution2 = new Solution2();
        solution2.lengthOfLIS(nums);
    }
}

// output:
i=1:[1]-[2]-[1]-[1]-[1]-
i=2:[1]-[2]-[1]-[1]-[1]-
i=3:[1]-[2]-[1]-[3]-[1]-
i=4:[1]-[2]-[1]-[3]-[3]-

🟡1143-最长公共子序列

1.题目内容open in new window

给定两个字符串 text1text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

  • 例如,"ace""abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。

两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

示例 1:

输入:text1 = "abcde", text2 = "ace" 
输出:3  
解释:最长公共子序列是 "ace" ,它的长度为 3 。

示例 2:

输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。

示例 3:

输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0 。
2.题解思路
👻方法1:动态规划
  • 思路分析:
    • (1)dp定义:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列dp[i][j]
      • 此处设定为[0,i-1][0,j-1]是为了简化初始化以及一些边界校验问题
    • (2)递推公式:判断text[i-1]text2[j-1]是否相同
      • text[i-1]==text2[j-1]dp[i][j] = dp[i - 1][j - 1] + 1(此处和连续重复子数组概念类似,累加处理)
      • text[i-1]!=text2[j-1]dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])(可以理解为将不连续的情况处理,将累加的最大值一直往右、往下传递下去)
    • (3)初始化dp:首行首列遍历,此处当i==0或者j==0时,[-1]是无意义的,因此此处首行首列都初始化为0(dp[i][0]=0dp[0][j]=0
    • (4)构建dp(遍历顺序):对于dp而言从1下标开始遍历,对应text而言是从0开始的
    • (5)验证dp
/**
 * 1143 最长公共子序列
 */
public class Solution1 {

    public int longestCommonSubsequence(String text1, String text2) {
        int n1 = text1.length(), n2 = text2.length();
        // 1.dp定义(dp[i][j]表示text1[0,i-1]、text2[0-j-1]的最长公共子序列(不连续))
        int[][] dp = new int[n1 + 1][n2 + 1]; // 冗余首行首列空间,简化初始化和边界校验

        /**
         * 2.dp推导: dp[i][j]位置的处理 与 text1[i-1]、text2[j-1]的位置是一一对应的(结合二维矩阵理解),当要处理dp[i][j]的时候,比较的就是 text1[i-1]、text2[j-1]
         *  dp[i][j]的推导有两种情况:
         *  当text1[i-1]==text2[j-1](当前位置元素相等):dp[i][j]=dp[i-1][j-1]+1 (这种情况和【连续重复子数组】概念类似,累加)
         *  当text1[i-1]!=text2[j-1](当前位置元素不相等)dp[i][j]=max{左侧,上方}=max{dp[i][j-1],dp[i-1][j]}(相当于对于不连续的情况,将累加的数值从两个方向传递下去)
         */

        // 3.dp初始化(同理,对于同行同列的初始化,[-1]是无意义的,初始化为0即可)

        // 4.dp构建(对于dp而言从1开始遍历,对于text1、text2是从0开始的)
        for (int i = 1; i <= n1; i++) { // 此处以构建dp为维度(i < n1 + 1),也可以以遍历text为维度(i<=n1) 于j处理而言同理
            for (int j = 1; j <= n2; j++) { // j< n2+1
                // 判断当前位置元素是否相等
                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][j - 1], dp[i - 1][j]);
                }
            }
        }

        // 返回结果
        return dp[n1][n2];
    }
}

image-20250109101907424

  • 复杂度分析

    • 时间复杂度:O(m×n)

    • 空间复杂度:O(m×n)

🟡1035-不相交的线(同1143)

1.题目内容open in new window

在两条独立的水平线上按给定的顺序写下 nums1nums2 中的整数。

现在,可以绘制一些连接两个数字 nums1[i]nums2[j] 的直线,这些直线需要同时满足:

  • nums1[i] == nums2[j]
  • 且绘制的直线不与任何其他连线(非水平线)相交。

请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。

以这种方法绘制线条,并返回可以绘制的最大连线数。

image-20241128164630559

​ 这个公共子序列指的是相对顺序不变(也就是画出的直线不能交叉的概念)(即数字4在字符串A中数字1的后面,那么数字4也应该在字符串B数字1的后面)

2.题解思路
👻方法1:动态规划(与【1143-最长公共子序列】思路一致)
  • 思路分析:与【1143-最长公共子序列】思路一致,转化为求两个数组的最长公共子序列(求绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度
    • (1)dp定义:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列dp[i][j]
      • 此处设定为[0,i-1][0,j-1]是为了简化初始化以及一些边界校验问题
    • (2)递推公式:判断text[i-1]text2[j-1]是否相同
      • text[i-1]==text2[j-1]dp[i][j] = dp[i - 1][j - 1] + 1(此处和连续重复子数组概念类似,累加处理)
      • text[i-1]!=text2[j-1]dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])(可以理解为将不连续的情况处理,将累加的最大值一直往右、往下传递下去)
    • (3)初始化dp:首行首列遍历,此处当i==0或者j==0时,[-1]是无意义的,因此此处首行首列都初始化为0(dp[i][0]=0dp[0][j]=0
    • (4)构建dp(遍历顺序):对于dp而言从1下标开始遍历,对应text而言是从0开始的
    • (5)验证dp
/**
 * 1035 不相交的线
 */
public class Solution1 {

    public int maxUncrossedLines(int[] nums1, int[] nums2) {
        int n1 = nums1.length, n2 = nums2.length;
        // 1.dp定义(dp[i][j]表示text1[0,i-1]、text2[0-j-1]的最长公共子序列(不连续))
        int[][] dp = new int[n1 + 1][n2 + 1]; // 冗余首行首列空间,简化初始化和边界校验

        /**
         * 2.dp推导: dp[i][j]位置的处理 与 text1[i-1]、text[j-1]的位置是一一对应的(结合二维矩阵理解),当要处理dp[i][j]的时候,比较的就是 text1[i-1]、text[j-1]
         *  dp[i][j]的推导有两种情况:
         *  当text1[i-1]==text[j-1](当前位置元素相等):dp[i][j]=dp[i-1][j-1]+1 (这种情况和【连续重复子数组】概念类似,累加)
         *  当text1[i-1]!=text[j-1](当前位置元素不相等)dp[i][j]=max{左侧,上方}=max{dp[i][j-1],dp[i-1][j]}(相当于对于不连续的情况,将累加的数值从两个方向传递下去)
         */

        // 3.dp初始化(同理,对于同行同列的初始化,[-1]是无意义的,初始化为0即可)

        // 4.dp构建(对于dp而言从1开始遍历,对于text1、text2是从0开始的)
        for (int i = 1; i <= n1; i++) { // 此处以构建dp为维度(i < n1 + 1),也可以以遍历text为维度(i<=n1) 于j处理而言同理
            for (int j = 1; j <= n2; j++) { // j< n2+1
                // 判断当前位置元素是否相等
                if (nums1[i - 1] == nums2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
                }
            }
        }

        // 返回结果
        return dp[n1][n2];
    }
}
  • 复杂度分析

    • 时间复杂度:

    • 空间复杂度:

🚀子序列问题-02-子序列(连续)

🟢674-最长连续递增序列

1.题目内容open in new window

给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。

连续递增的子序列 可以由两个下标 lrl < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。

示例 1:

输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。 

示例 2:

输入:nums = [2,2,2,2,2]
输出:1
解释:最长连续递增序列是 [2], 长度为1。
2.题解思路
👻方法1:动态规划
  • 思路分析:

    • (1)dp定义:dp[i]表示以i位置所在元素结尾的最长连续递增子序列的长度(连续

    • (2)递推公式:dp[i]有两种选择

      • 【1】就是自身(以自身结尾的仅有自身一个元素的子序列,元素本身就是一个递增子序列);
      • 【2】考虑到连续递增,就是看其是否可以拼在其前一个元素后面,构成一个更大的递增子序列 dp[i] = Math.max(dp[i], dp[i - 1] + 1);
    • (3)初始化dpdp[i]=1(元素本身就是一个递增子序列)

    • (4)构建dp(遍历顺序):

      • 外层遍历:选择i
      • 内层遍历:当前nums[i]是否可以接在前一个元素后面构成一个新的递增子序列,还是说以其本身结尾
    • (5)验证dp

public class Solution2 {
    public int findLengthOfLCIS(int[] nums) {
        int n = nums.length;
        // 特例判断
        if (n == 1) {
            return 1;
        }

        // 1.dp定义:dp[i]表示以i位置所在元素结尾的最长连续递增子序列的长度(连续)
        int[] dp = new int[n];

        /**
         * 2.dp推导
         * dp[i]=1(为自身的情况)
         * dp[i]= Math.max(dp[i], dp[i - 1] + 1);(判断是否可以接在前一个元素上,不能接则断开)
         */

        // 3.dp初始化
        Arrays.fill(dp, 1);

        // 4.dp构建
        int maxLen = 0;
        for (int i = 1; i < n; i++) {
            // 此处只需要判断可否接在前一个元素上
            if (nums[i] > nums[i - 1]) {
                dp[i] = Math.max(dp[i], dp[i - 1] + 1);
            }
            // 更新max
            maxLen = Math.max(maxLen, dp[i]);
        }

        // 返回结果
        return maxLen;
    }

}
  • 复杂度分析

    • 时间复杂度:O(n)n为数组长度

    • 空间复杂度:O(n)构建dp[n]数组

🟡718-最长重复子数组

1.题目内容open in new window

给两个整数数组 nums1nums2 ,返回 两个数组中 公共的 、长度最长的子数组的长度

示例 1:

输入:nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]
输出:3
解释:长度最长的公共子数组是 [3,2,1] 。

示例 2:

输入:nums1 = [0,0,0,0,0], nums2 = [0,0,0,0,0]
输出:5
2.题解思路
👻方法1:暴力枚举(三层嵌套循环)

​ 暴力遍历的思路,需要先用两层for分别确定两个数组的起始位置,然后用第3个for或者while来从这两个起始位置开始比较,判断公共前缀的长度(也就是获取重复子数组的长度),在整个遍历过程中不断更新最长前缀(需注意第3层循环一轮结束后要将计数器重置,用于下一个起点的前缀长度判断)

  • 思路分析:寻找两个数组的公共起点,然后同时出发遍历判断最长公共前缀,找到每个最长公共前缀,取这些最长公共前缀的最大值即可
    • 第1层循环:遍历nums1判断以每个元素nums1[i]为起点的最长前缀
    • 第2层循环:遍历nums2找到与nums1[i]相等的nums2[j]的可能起点
    • 第3层循环:计算nums1[]i位置、nums2[]j位置为起点的最长公共前缀(注意数组越界问题i + k < n1 && j + k < n2
/**
 * 718 最长重复子数组
 */
public class Solution1 {

    /**
     * 暴力枚举
     */
    public int findLength(int[] nums1, int[] nums2) {
        int n1 = nums1.length, n2 = nums2.length;
        int maxLen = 0;
        // 遍历A数组,确定每个起点
        for (int i = 0; i < n1; i++) {
            // 遍历B数组,同步比较最长前缀
            for (int j = 0; j < n2; j++) {
                if (nums1[i] == nums2[j]) { // 找到起点,开始计数判断最长前缀
                    int k = 0;
                    while (i + k < n1 && j + k < n2 && nums1[i + k] == nums2[j + k]) {
                        k++;
                    }
                    maxLen = Math.max(maxLen, k); // 更新以每个可能的元素为起点的最长前缀
                    k = 0; // 重置计数器,等待下一个点的校验
                }
            }
        }
        // 返回结果
        return maxLen;
    }

}
  • 复杂度分析

    • 时间复杂度:O(n3
    • 空间复杂度:O(1)
👻方法2:动态规划(左上->右下的连续1累加)
  • 思路分析:
    • (1)dp定义:dp[i][j] 表示以下标i-1结尾的A、j-1结尾的B 最长重复子数组长度(此处ij从1开始遍历)
    • (2)递推公式:如果num1[i-1]==nums2[j-1]条件满足则进行累加,dp[i][j]=dp[i-1][j-1]+1
      • 为什么是i-1j-1:因为dp数组的设定,从1开始遍历,所以对于nums来说要从0开始处理(此处的位置关系是一一对应的,dp[i,j]比较的是nums1[i-1]nums2[j-1]
    • (3)初始化dp
      • 根据dp[i][j]的定义可知,dp[0][j]dp[i][0]是没有意义的,但为了方便递推,此处还是要将首行、首列初始化为0
    • (4)构建dp(遍历顺序):外层A内层B(内层B外层A也是可行)
      • 从上往下、从左到右的顺序遍历,dp[i][j]关注的是其左上角的dp[i-1][j-1]内容(即如果nums1[i-1]==nums2[j-1],则继续累加)
    • (5)验证dp

image-20241128150325445

/**
 * 718 最长重复子数组
 */
public class Solution2 {

    /**
     * 动态规划
     */
    public int findLength(int[] nums1, int[] nums2) {
        int n1 = nums1.length, n2 = nums2.length;
        int maxLen = 0;

        // 1.定义dp[][](dp[i][j]表示以下标`i-1`结尾的A、下标`j-1`结尾的B的最长重复子数组长度)
        int[][] dp = new int[n1 + 1][n2 + 1];

        /**
         * 2.dp 推导
         * dp[i][j] = dp[i-1][j-1]+1 (遍历从1位置开始)
         */

        // 3.dp 初始化(dp[0][j]和dp[i][j]本身为实际意义,但为了使推导公式有效,此处选择初始化为0)

        // 4.dp 构建(先A后B或者先B后A都可以)
        for (int i = 1; i <= n1; i++) {
            for (int j = 1; j <= n2; j++) {
                // 如果当前指向位置元素值相等则进行递推
                if (nums1[i - 1] == nums2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                }
                // 更新最值
                maxLen = Math.max(maxLen, dp[i][j]);

                // 打印dp
                System.out.println("---------------start---------------");
                PrintDPUtil.printMatrix(dp);
                System.out.println("---------------end---------------" + "\n");
            }
        }

        // 返回结果
        return maxLen;
    }


    public static void main(String[] args) {
        int[] nums1 = new int[]{1, 2, 3, 2, 1};
        int[] nums2 = new int[]{3, 2, 1, 4, 7};
        Solution2 solution2 = new Solution2();
        solution2.findLength(nums1, nums2);
    }

}
  • 复杂度分析

    • 时间复杂度:O(n2

    • 空间复杂度:O(n2

🟡053-最大子数组和

1.题目内容open in new window

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组是数组中的一个连续部分。

示例 1:

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

示例 2:

输入:nums = [1]
输出:1

示例 3:

输入:nums = [5,4,-1,7,8]
输出:23
2.题解思路
👻方法1:
  • 思路分析:
    • (1)dp定义:dp[i]表示以元素i结尾的最大子数组和
    • (2)递推公式:dp[i]有两种情况
      • 【1】自成一派:表示只有其一个元素 =》dp[i]=nums[i]
      • 【2】拼接尾部:表示拼接在其前一个元素然后得到一个连续子数组 =》dp[i]=dp[i-1] + nums[i]
      • 两种情况选择最大的:dp[i]=max{dp[i-1] + nums[i],nums[i]}
    • (3)初始化dp
      • dp[0]=nums[0] i=0只有一种情况(自成一派),因此初始化为nums[0]
    • (4)构建dp(遍历顺序):从左到右遍历
    • (5)验证dp
      • 最终结果是从这些可能的最大子数组中选择一个最大值(maxVal可以在遍历过程中同步更新)
/**
 * 053 最大子数组和
 */
public class Solution1 {

    // 动态规划
    public int maxSubArray(int[] nums) {
        int n = nums.length;
        // 特例判断
        if (n == 1) {
            return nums[0];
        }

        // 1.dp定义(表示以i位置元素结尾的最大连续子数组和)
        int[] dp = new int[n];

        /**
         * 2.dp推导
         * 【1】自成一派:dp[i]=nums[i]
         * 【2】拼接尾部:dp[i]=dp[i-1]+nums[i]
         * max{【1】,【2】}
         */

        // 3.初始化dp
        dp[0] = nums[0]; // i=0只有一种情况(自成一派),因此初始化为nums[0]

        // 4.dp构建
        int maxVal = dp[0]; // 从第一个元素开始同步更新
        for (int i = 1; i < n; i++) {
            dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
            maxVal = Math.max(maxVal, dp[i]); // 更新最大值
        }

        // 返回结果
        return maxVal; // 需要从每个可能结尾的最大连续子数组中择选最大
    }

}
  • 复杂度分析

    • 时间复杂度:O(n)n为数组长度

    • 空间复杂度:O(n)构建dp[n]

动态规划(空间优化版本)

/**
 * 053 最大子数组和
 */
public class Solution4 {

    // 动态规划
    public int maxSubArray(int[] nums) {
        int n = nums.length;
        // 特例判断
        if (n == 1) {
            return nums[0];
        }

        // 1.定义preMax始终指向以上一个元素结尾构成的连续子数组的最大值(滚动变量)
        int preMax = nums[0]; // i=0只有一种情况(自成一派),因此初始化为nums[0]
        // 2.遍历处理
        int maxVal = nums[0]; // 从第一个元素开始同步更新
        for (int i = 1; i < n; i++) {
            int curMax = Math.max(nums[i], preMax + nums[i]);
            maxVal = Math.max(maxVal, curMax); // 更新最大值
            // 操作完成,更新preMax
            preMax = curMax;
        }

        // 返回结果
        return maxVal; // 需要从每个可能结尾的最大连续子数组中择选最大
    }

}

🚀子序列问题-03-编辑距离

🟢392-判断子序列

1.题目内容open in new window

给定字符串 st ,判断 s 是否为 t 的子序列。

字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace""abcde"的一个子序列,而"aec"不是)。

进阶:

如果有大量输入的 S,称作 S1, S2, ... , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?

2.题解思路
👻方法1:双指针法
  • 思路分析:采用双指针思路判断s是否为t的子序列,定义两个指针分别用于指向st序列,然后以s为参考比较两个指针当前指向元素是否相同,如果相同则双指针均往前移动继续比较下一个元素,如果不相同则只移动t指针,直到两个字符串中有一个遍历到末尾,最终判断sPointer指向是否为s.length()即可(如果循环遍历结束,sPointer指向s的字符串末尾,则说明符合匹配子序列)
/**
 * 392 判断子序列
 */
public class Solution1 {

    /**
     * 判断 s 是否为 t 的子序列
     */
    public boolean isSubsequence(String s, String t) {
        int sPointer = 0, tPointer = 0;
        while (sPointer < s.length() && tPointer < t.length()) {
            // 判断s、t当前指针指向元素是否相同
            if (s.charAt(sPointer) == t.charAt(tPointer)) {
                // 指针向后移动继续比较下一个元素
                sPointer++;
                tPointer++;
            } else {
                // 如果不相同,则tPointer向前移动
                tPointer++;
            }
        }
        // 比较完成,如果sPointer指向字符串末尾说明完全匹配对应子序列要求
        return sPointer == s.length();
    }

    public static void main(String[] args) {
        Solution1 solution1 = new Solution1();
        // solution1.isSubsequence("abc","ahbgdc");
        solution1.isSubsequence("axc", "ahbgdc");
    }
}
  • 复杂度分析

    • 时间复杂度:O(m+n)m、n分别为两个字符串的长度

    • 空间复杂度:O(1)定义双指针辅助遍历,占用常数级别空间复杂度

👻方法2:动态规划法

动态规划分析

​ 本题可以理解为【编辑距离】的入门题目,掌握基于动态规划的解法,为后续【编辑距离】题目打下基础

dp 定义dp[i][j]表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]

  • 此处是判断s是否为t的子序列,因此t的长度是大于等于s
  • 选用i-1j-1的目的也是为了避免一些边界条件的判断处理,此处冗余一部分空间便于统一处理

dp 递推

  • s[i - 1] == t[j - 1]t中找到了一个字符在s中也出现了
    • dp[i][j] = dp[i - 1][j - 1] + 1(找到一个相同字符,则dp[i][j]的相同子序列长度为dp[i-1][j-1]的基础上加上1)
  • s[i - 1] != t[j - 1]:相当于t要删除元素,继续匹配
    • dp[i][j] = dp[i][j-1](此时相当于t要删除元素,则如果t将当前元素t[j-1]删除,则dp[i][j]的数值是看s[i-1]t[j-2]的比较结果,因此此处dp[i][j]=dp[i][j-1])(基于此可以看到此处的递推和【1143-最长公共子序列】的思路有异曲同工之处,只不过【1143】是求公共子序列,两个字符串都可以删除元素。而此处是判断子序列,因此限定删除的是t字符串)

dp 初始化:结合递推公式分析,dp[i][j]的取值是依赖于i-1j-1因此此处首先要初始化首行首列的内容(此处首行首列是dp数组定义时预留的空间,因此可以初始化为0,后续通过递推公式求得其他矩阵元素的值)

dp 构建:根据递推公式进行推导

dp 验证

image-20241202102044584

  • 思路分析:
    • (1)dp定义:dp[i][j]表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]
    • (2)递推公式:
      • s[i - 1] == t[j - 1]dp[i][j]=dp[i-1][j-1]+1
      • s[i - 1] != t[j - 1]dp[i][j]=dp[i][j-1]
    • (3)初始化dp:首行首列初始化为0
    • (4)构建dp(遍历顺序):根据递推公式进行推导
    • (5)验证dp
/**
 * 392-判断子序列
 */
public class Solution2 {
    /**
     * 判断 s 是否为 t 的子序列
     */
    public boolean isSubsequence(String s, String t) {

        // 1.dp定义:dp[i][j]表示以i-1结尾的s,j-1结尾的t
        int[][] dp = new int[s.length() + 1][t.length() + 1];

        /**
         * 2.dp推导:根据dp[i][j]所在位置的s[i-1]与t[j-1]的值比较结果分析
         * s[i-1]==t[j-1]: dp[i][j]=dp[i-1][j-1]+1 (匹配字符,序列+1)
         * s[i-1]!=t[j-1]: dp[i][j]=dp[i][j-1](字符不匹配,删除t[j-1],则取值看s[i-1]和t[j-2]的比较结果)
         */

        // 3.dp初始化(首行首列初始化:结合递推公式判断,初始化为0)

        // 4.dp构建:
        for (int i = 1; i <= s.length(); i++) {
            for (int j = 1; j <= t.length(); j++) {
                if (s.charAt(i - 1) == t.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    dp[i][j] = dp[i][j - 1];
                }
            }
        }

        // 返回结果
        return dp[s.length()][t.length()] == s.length();
    }
}
  • 复杂度分析

    • 时间复杂度:O(mn)m、n分别为s、t字符串的长度

    • 空间复杂度:O(mn)构建dp[m+1][n+1]辅助数组

🔴115-不同的子序列

1.题目内容open in new window

给你两个字符串 st ,统计并返回在 s子序列t 出现的个数,结果需要对 109 + 7 取模。

字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,"ACE" 是 "ABCDE" 的一个子序列,而 "AEC" 不是)

示例 1:

输入:s = "rabbbit", t = "rabbit"
输出:3
解释:
如下所示, 有 3 种可以从 s 中得到 "rabbit" 的方案。
[rabb]b[it]
[ra]b[bbit]
[rab]b[bit]

示例 2:

输入:s = "babgbag", t = "bag"
输出:5
解释:
如下所示, 有 5 种可以从 s 中得到 "bag" 的方案。 
[ba]b[g]bag
[ba]bgba[g]
[b]abgb[ag]
ba[b]gb[ag]
babg[bag]
2.题解思路
👻方法1:动态规划

动态规划分析

  • dp 定义:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]
  • dp 递推:此类问题主要分析两种情况,即判断s[i-1]t[j-1]是否相等
    • s[i-1]!=t[j-1]dp[i][j]=dp[i-1][j](只有一部分:不匹配s[i-1]的情况)
    • s[i-1]==t[j-1]时,dp[i][j]由两部分构成:
      • 一部分是用s[i-1]来匹配 =》dp[i][j]=dp[i-1][j-1](即不需要考虑当前s子串和t子串的最后一位字母的情况)
      • 一部分是不用s[i-1]来匹配 =》dp[i][j]=dp[i-1][j]
      • ==为什么要拆两部分?==举例分析:以s:baggt:bag为例,此处s[3]t[2]相等可以有两种匹配方式:
        • 如果用s[3]来匹配:[b][a][g]s[0]s[1]s[3]组成)
        • 如果不用s[3]来匹配:[b][a][g]s[0]s[1]s[2]组成)
        • 综上dp[i][j]=dp[i-1][j-1] + dp[i-1][j]
      • 为什么用s[i-1]来判断匹配关系?而不是t[j-1]?:因为此处题目求的是s当中有多少个满足条件的t,而非t中有多少个s,因此只考虑s中删除元素的情况,即使用s[i-1]进行匹配
  • dp 初始化:回归递推公式,dp[0][j]dp[i][0]是一定要初始化的,首先理解这首行首列的代表含义,以及特殊位置dp[0][0]
    • dp[i][0]表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数 => 1
    • dp[0][j]表示:空字符串s可以随便删除元素,出现以j-1为结尾的t的个数 => 0(此时s无论如何都变不了t
    • dp[0][0]:此处dp[0][0]应该理解为空字符串s可以随便删除元素,出现空字符串的个数为1
  • dp 构建:根据递推公式构建

image-20241202095656586

  • 思路分析:
    • (1)dp定义:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]
    • (2)递推公式:s[i-1]匹配为参考
      • s[i-1]==t[j-1]dp[i][j]=dp[i-1][j-1] + dp[i-1][j](可由两部分构成:匹配或者不匹配s[i-1]的情况)
      • s[i-1]!=t[j-1]dp[i][j]=dp[i-1][j](只有一部分:不匹配s[i-1]的情况)
    • (3)初始化dp
      • dp[0][0]=1(特殊位置初始化,空字符串s可以随意删除元素,构成空字符串t的个数为1)
      • dp[i][0]=1(以i-1结尾的字符串s可以随意删除元素,构成空字符串t的个数为1)
      • dp[0][j]=0(空字符串s无法组成t
    • (4)构建dp(遍历顺序):结合递推公式分析,此处遍历需确保从上到下、从左到右的遍历顺序,让dp[i][j]可以依赖此前的计算结果进行计算
    • (5)验证dp
/**
 * 115 不同的子序列
 */
public class Solution1 {

    /**
     * s 的子序列 在 t 中出现的个数
     *
     * @param s
     * @param t
     * @return
     */
    public int numDistinct(String s, String t) {
        int sLen = s.length(), tLen = t.length();
        // 1.dp 定义(dp[i][j]表示以`i-1`结尾的s的子序列在以`j-1`结尾的t中出现的个数)
        int[][] dp = new int[sLen + 1][tLen + 1];

        /**
         * 2.dp 推导
         * 以s[i-1]是否匹配进行讨论(此处是校验s当中有多少个t)
         * s[i-1]==t[j-1]:分两种情况讨论,即自由选择匹配和不匹配的情况分析 =》dp[i][j] = dp[i-1][j-1](选择匹配) + dp[i-1][j](选择不匹配)
         * s[i-1]!=t[j-1]:只有一种不匹配的情况 =》dp[i][j] = dp[i-1][j]
         */

        // 3.dp 初始化(dp[0][0]、dp[0][j]首行、dp[i][j]首列)
        dp[0][0] = 1; // 空字符串s可以删除任意元素,其在空字符串t中出现的个数为1
        // dp[0][j] 空字符串s可以删除任意元素,其出现`j-1`结尾的字符串t的个数为0(空字符串无法构成t)
        for (int j = 1; j < dp[0].length; j++) {
            dp[0][j] = 0;
        }
        // dp[i][0] 以`i-1`结尾的字符串s可以删除任意元素,其出现空字符串t的个数为1
        for (int i = 1; i < dp.length; i++) {
            dp[i][0] = 1;
        }

        // 4.dp 构建
        for (int i = 1; i < dp.length; i++) { // i<=sLen
            for (int j = 1; j < dp[0].length; j++) { // j<=tLen
                // 根据s[i-1]是否匹配分析
                if (s.charAt(i - 1) == t.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }

        // 返回结果
        return dp[sLen][tLen];
    }

}
  • 复杂度分析

    • 时间复杂度:O(mn)m、n分别为s、t字符串的长度

    • 空间复杂度:O(mn)构建dp[m+1][n+1]辅助数组

动态规划思路分析(简化版本)

  • ① dp 定义:dp[i][j]表示s的前i个字符和t的前j个字符两者之间的匹配数(检验s的子序列中有多少个t,因此从删除元素的角度使其匹配t也是s执行删除操作)

  • ② dp 递推:根据s[i-1]t[j-1]的关系分情况讨论

    • s[i-1]==t[j-1]:此时可以有两种选择(可以选择使用或者不使用当前s[i-1]去匹配t[j-1]
      • 选择用:dp[i][j]=dp[i-1][j-1](可以理解为不需要执行操作,继承上一状态)
      • 选择不用:dp[i][j]=dp[i-1][j](可以理解为删除s[i-1]
    • s[i-1]!=t[j-1]:此时只能有一种选择,就是无法使用当前s[i-1]去匹配t[j-1],得到dp[i][j]=dp[i-1][j]
  • ③ dp 初始化:

    • dp[0][0]=1:空字符串可以为空字符串的子序列
    • 首行初始化(s为空字符串),空字符串s无法通过删除元素构成非空字符串t =>dp[0][j]=0(j∈[1,n))
    • 首列初始化(t为空字符串),空字符串t为任何子序列的子序列 =>dp[i][0]=1(j∈[1,m))

🟡583-两个字符串的删除操作

1.题目内容open in new window

给定两个单词 word1word2 ,返回使得 word1word2 相同所需的最小步数

每步 可以删除任意一个字符串中的一个字符。

示例 1:

输入: word1 = "sea", word2 = "eat"
输出: 2
解释: 第一步将 "sea" 变为 "ea" ,第二步将 "eat "变为 "ea"

示例 2:

输入:word1 = "leetcode", word2 = "etco"
输出:4
2.题解思路
👻方法1:动态规划(【最小删除步数】维度 - 基于增删操作角度让字符串相同)

动态规划分析

  • dp定义:以i-1为结尾的字符串word1,和以j-1为结尾的字符串word2,想要达到相等,所需要删除元素的最少次数
  • dp递推:根据word1[i-1]word2[j-1]是否匹配进行分析
    • word1[i-1]==word2[j-1]:当前元素相等,不需要删除元素(i、j向前移动,继续判断下一个位置)=》dp[i][j]=dp[i-1][j-1]
    • word1[i-1]!=word2[j-1]:当前元素不相等,需要删除元素,有3种情况操作分析
      • ①删除word1(走1步)=》dp[i][j]=dp[i-1][j]+1
      • ②删除word2(走1步)=》dp[i][j]=dp[i][j-1]+1
      • ③同时删除word1和word2(走2步)=》dp[i][j]=dp[i-1][j-1]+2
      • 基于上述情况,选择最少次数=》dp[i][j]=min{dp[i-1][j]+1,dp[i][j-1]+1,dp[i-1][j-1]+2}
  • dp初始化:基于递推公式分析,首行、首列、dp[0][0]分析
  • dp构建

image-20241202110423051

  • 思路分析:
    • (1)dp定义:以i-1为结尾的字符串word1,和以j-1为结尾的字符串word2,想要达到相等,所需要删除元素的最少次数
    • (2)递推公式:
      • word1[i-1]==word2[j-1]:当前元素相等,不需要删除元素(i、j向前移动,继续判断下一个位置)=》dp[i][j]=dp[i-1][j-1]
      • word1[i-1]!=word2[j-1]:当前元素不相等,需要删除元素,有3种情况操作分析 =》dp[i][j]=min{dp[i-1][j]+1,dp[i][j-1]+1,dp[i-1][j-1]+2}
      • PS:此处dp[i-1][j-1]+2实际可转化为dp[i-1][j]+1概念
    • (3)初始化dp:首行、首列、dp[0][0]
      • dp[0][0]:word1、word2都为空字符串,不需要删除=》dp[0][0]=0
      • dp[0][j]:word1为空字符串,word2长度为j,需要将j删除到为空字符串=》dp[0][j]=j
      • dp[i][0]:word2为空字符串,word1长度为i,需要将i删除到为空字符串=》dp[i][0]=i
    • (4)构建dp(遍历顺序):结合递推公式分析,dp[i][j]的取值依赖于其左侧、左上、上侧的值,因此遍历顺序需要从上往下、从左往右进行构建
    • (5)验证dp
/**
 * 583 两个字符串的删除操作
 */
public class Solution1 {

    /**
     * 动态规划:dp 设定存储针对的是"最小删除步数"
     */
    public int minDistance(String word1, String word2) {
        int len1 = word1.length(), len2 = word2.length();
        // 1.dp定义(dp[i][j]表示以i-1结尾的word1、j-1结尾的word2 达到相等所需删除的最小步数)
        int[][] dp = new int[len1 + 1][len2 + 1];

        /**
         * 2.dp推导:根据当前校验位置元素是否相等决定是否要执行删除操作
         * [a] word1[i-1]==word2[j-1] : 当前位置元素相等,不需要执行删除操作,继承状态 =》 dp[i][j]=dp[i-1][j-1]
         * [b] word1[i-1]!=word[j-1] : 当前位置元素不相等,需要执行删除操作,有3种删除操作可供选择:
         * - 删除word1:dp[i][j] = dp[i-1][j] + 1
         * - 删除word2:dp[i][j] = dp[i][j-1] + 1
         * - 同时删除word1和word2:dp[i][j] = dp[i-1][j-1] + 2
         * => dp[i][j] = min{dp[i-1][j] + 1,dp[i][j-1] + 1,dp[i-1][j-1] + 2}
         */

        // 3.dp初始化(dp[0][0]、首行、首列)
        // 两者均为空字符串不需要执行删除操作,本身就相等,所需最小步数为0
        dp[0][0] = 0;
        // 当一个word为空,另一个不为空,若要达到相等的状态,需要将不为空的字符串删除至空(所需删除的最小步数为非空字符串的长度)
        for (int i = 1; i <= len1; i++) { // 需注意此处的dp范围,以构建dp为基础,避免漏掉边界情况
            dp[i][0] = i;
        }
        for (int j = 1; j <= len2; j++) {
            dp[0][j] = j;
        }

        // 4.dp构建(根据递推公式分析,需从上往下、从左往右)
        for (int i = 1; i <= len1; i++) {
            for (int j = 1; j <= len2; j++) {
                if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                    // 元素相等,不需要删除
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    // 元素不相等,有3种情况考虑
                    dp[i][j] = Math.min(dp[i - 1][j - 1] + 2, Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
                }
            }
        }

        PrintDPUtil.printMatrix(dp);

        // 返回结果
        return dp[len1][len2];
    }

    public static void main(String[] args) {
        Solution1 solution1 = new Solution1();
        solution1.minDistance("a", "b");
    }

}
  • 复杂度分析

    • 时间复杂度:O(mn)m、n分别为word1、word2的长度
    • 空间复杂度:O(mn)需构建dp[m+1][n+1]的二维数组辅助
👻方法2:动态规划(【最长公共子序列】维度 - 思路转化)

此处【最小删除步数】可以转化为【1143-最长公共子序列】的思路,求达到相等的最小删除步数,转化为先求出最长公共子序列长度x,那么达到相等时的最小删除步数为len1+len2-2*x

  • 思路分析:按照【1143-最长公共子序列】求解,返回结果为len1+len2-2*x
    • (1)dp定义:dp[i][j]表示以i-1结尾的word1、以j-1结尾的word2 两个字符串的最长公共子序列的长度
    • (2)递推公式:根据word1[i-1]word2[j-1]是否匹配进行校验
      • word1[i-1]==word2[j-1]:匹配则计数+1 =》dp[i][j]=dp[i-1][j-1]+1
      • word1[i-1]!=word2[j-1]:不匹配=》dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])(可以理解为将不连续的情况处理,将累加的最大值一直往右、往下传递下去)
    • (3)初始化dpdp[0][0]dp[i][0]dp[0][j]
      • dp[0][0]:两者均为空字符串,最长公共子序列也为空字符串,长度为0
      • dp[i][0]dp[0][j]:两个字符串中其中一个为空字符串,最长公共子序列也为空字符串,长度为0
    • (4)构建dp(遍历顺序):结合递推公式分析,需从上到下、从左到右进行构建
    • (5)验证dp:最终结果len1+len2-2*x
/**
 * 583 两个字符串的删除操作
 */
public class Solution2 {

    /**
     * 动态规划:dp 设定存储针对的是"最长公共子序列"
     */
    public int minDistance(String word1, String word2) {
        int len1 = word1.length(), len2 = word2.length();
        // 1.dp定义(dp[i][j]表示以i-1结尾的word1、j-1结尾的word2 最长公共子序列的长度)
        int[][] dp = new int[len1 + 1][len2 + 1];

        /**
         * 2.dp推导:根据当前校验位置元素是否相等进行情况讨论
         * [a] word1[i-1]==word2[j-1] : 当前位置元素相等,元素匹配则公共子序列长度+1 =》 dp[i][j]=dp[i-1][j-1] + 1
         * [b] word1[i-1]!=word[j-1] : 当前位置元素不相等,相当于将【相等累加的情况】向下、左右传递下去 => dp[i][j] = max{dp[i-1][j] ,dp[i][j-1]}
         */

        // 3.dp初始化(dp[0][0]、首行、首列)
        // 两者均为空字符串,最长公共子序列也为空字符串,长度为0
        dp[0][0] = 0;
        // 两个字符串中其中一个为空字符串,最长公共子序列也为空字符串,长度为0
        for (int i = 1; i <= len1; i++) { // 需注意此处的dp范围,以构建dp为基础,避免漏掉边界情况
            dp[i][0] = 0;
        }
        for (int j = 1; j <= len2; j++) {
            dp[0][j] = 0;
        }

        // 4.dp构建(根据递推公式分析,需从上往下、从左往右)
        for (int i = 1; i <= len1; i++) {
            for (int j = 1; j <= len2; j++) {
                if (word1.charAt(i - 1) == word2.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 len1 + len2 - 2 * dp[len1][len2];
    }

}
  • 复杂度分析

    • 时间复杂度:O(mn)m、n分别为word1、word2的长度

    • 空间复杂度:O(mn)需构建dp[m+1][n+1]的二维数组辅助

🟡072-编辑距离

1.题目内容open in new window

给你两个单词 word1word2请返回将 word1 转换成 word2 所使用的最少操作数

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

示例 1:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

示例 2:

输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')
2.题解思路
👻方法1:动态规划

与【583-两个字符串的删除操作】不同的点在于,此处要让两个字符串相同可以有新增、删除、替换三种方式,而583则限定是删除操作

动态规划分析

(1)dp定义:dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]

(2)dp推导:根据word1[i-1]==word2[j-1]是否成立来讨论不同情况

  • word1[i-1]==word2[j-1]:元素匹配,不做操作,则继承上一校验位置的状态 =》dp[i][j]=dp[i-1][j-1]
  • word1[i-1]==word2[j-1]:元素匹配,需要考虑做什么操作能达到"最近编辑距离"(不同于其他题型的删除操作,此处涉及到增、删、换三个操作的情况讨论),则讨论下述不同的编辑情况
    • 删除:
      • word1删除一个元素 :dp[i][j]=dp[i-1][j]+1(以下标i - 2为结尾的word1 与 j-1为结尾的word2的最近编辑距离 再加上【一个操作】)
      • word2删除一个元素 :dp[i][j]=dp[i][j-1]+1(以下标i - 1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 再加上【一个操作】)
    • 添加:
      • 此处word1删除一个元素实际上等价于word2添加一个元素的操作,同理word2删除一个元素实际上等价于word1添加一个元素的操作,操作数量是等价的,因此删除和添加的情况谈论是一样的
    • 替换:
      • word1中将word1[i-1]替换掉,使得word1[i-1]==word2[j-1]成立,此处只需要在原基础上做1次替换操作 =》dp[i][j]=dp[i-1][j-1]+1
    • 因此这种情况下,基于上述3种不同的情况讨论得到:dp[i][j]=min{dp[i-1][j]+1,dp[i][j-1]+1,dp[i][j]=dp[i-1][j-1]+1}

(3)dp初始化:dp[0][0]dp[0][j]dp[i][0]初始化

  • dp[0][0]:本身相等不需要操作,dp[0][0]=0
  • dp[0][j]:word1为空字符串,需要对word2做删除操作(删至为空,操作次数为j
  • dp[i][0]:word2为空字符串,需要对word1做删除操作(删至为空,操作次数为i

(4)dp构建:基于递推公式推导,dp[i][j]依赖于其左侧、左上、上侧的数据,因此遍历顺序为从左到右、从上到下

(5)dp验证

  • 思路分析:
    • (1)dp定义:dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]
    • (2)递推公式:
      • word1[i-1]==word2[j-1]dp[i][j]=dp[i-1][j-1](元素匹配,无需操作,继承上一匹配状态的最近编辑距离)
      • word1[i-1]!=word2[j-1]dp[i][j]=min{dp[i-1][j-1],dp[i-1][j],dp[i][j-1]}+1(元素不匹配,需考虑删除、添加、替换三种情况讨论)
    • (3)初始化dpdp[0][0]dp[0][j]dp[i][0]
      • dp[0][0]:本身相等不需要操作,dp[0][0]=0
      • dp[0][j]:word1为空字符串,需要对word2做删除操作(删至为空,操作次数为j
      • dp[i][0]:word2为空字符串,需要对word1做删除操作(删至为空,操作次数为i
    • (4)构建dp(遍历顺序):从上往下、从左往右
    • (5)验证dp
public int minDistance(String word1, String word2) {
        int len1 = word1.length(), len2 = word2.length();
        // 1.dp定义(dp[i][j]表示以i-1结尾的word1、j-1结尾的最近编辑距离)
        int[][] dp = new int[len1 + 1][len2 + 1];

        /**
         * 2.dp推导
         * word1[i-1]==word2[j-1]: 元素匹配,无需操作,继承上一校验位置的状态 =》 dp[i][j] = dp[i-1][j-1]
         * word1[i-1]!=word2[j-1]: 元素不匹配,分析编辑操作的不同情况(删除、添加、替换)
         * ① 删除:
         * - word1删除一个元素:dp[i][j]=dp[i-1][j]+1
         * - word2删除一个元素:dp[i][j]=dp[i][j-1]+1 (加1表示上一状态的值加上一个操作得到dp[i][j])
         * ② 添加:添加操作和删除操作的操作次数可以看做是等价的,word1删除一个元素的操作可以看做word2添加一个元素,同理word2删除一个元素的操作可以看做word1添加一个元素
         * ③ 替换:替换的目的在于替换1次使得word1[i-1]=word2[j-1],因此dp[i][j]=dp[i-1][j-1]+1 (加1表示上一状态的值加上一个操作得到dp[i][j])
         */

        // 3.dp初始化
        dp[0][0] = 0;
        for (int i = 1; i <= len1; i++) {
            dp[i][0] = i;
        }
        for (int j = 1; j <= len2; j++) {
            dp[0][j] = j;
        }

        // 4.dp构建
        for (int i = 1; i <= len1; i++) {
            for (int j = 1; j <= len2; j++) {
                if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1])) + 1;
                }
            }
        }

        // 返回结果
        return dp[len1][len2];
    }
}

// output
// word1:horse    word2:ros
初始化:
[0]-[1]-[2]-[3]-
[1]-[0]-[0]-[0]-
[2]-[0]-[0]-[0]-
[3]-[0]-[0]-[0]-
[4]-[0]-[0]-[0]-
[5]-[0]-[0]-[0]-
构建后:
[0]-[1]-[2]-[3]-
[1]-[1]-[2]-[3]-
[2]-[2]-[1]-[2]-
[3]-[2]-[2]-[2]-
[4]-[3]-[3]-[2]-
[5]-[4]-[4]-[3]-
  • 复杂度分析

    • 时间复杂度:O(n2

    • 空间复杂度:O(n2

🚀子序列问题-04-回文

🟡647-回文子串

1.题目内容open in new window

给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。

回文字符串 是正着读和倒过来读一样的字符串。

子字符串 是字符串中的由连续字符组成的一个序列。

2.题解思路
👻方法1:模拟法 - 暴力检索(嵌套循环判断每一个子串是否为回文字符串并统计)
  • 思路分析:
    • 确定子串的起点、终点,判断每个子串是否为回文字符串
    • 回文字符串校验:双指针法
/**
 * 647 回文子串
 */
public class Solution1 {
    // 暴力搜索:嵌套循环判断每一个子串是否为回文字符串
    public int countSubstrings(String s) {
        int cnt = 0;
        // 遍历判断每一个子串是否为回文字符串(i确定子串起点;j确定子串终点)
        for (int i = 0; i < s.length(); i++) {
            for (int j = i; j < s.length(); j++) {
                String subStr = s.substring(i, j + 1); // 注意substring截断位置[i,j)
                if (validHuiwen(subStr)) {
                    cnt++;
                }
            }
        }
        // 返回结果
        return cnt;
    }

    // 双指针法校验回文
    public boolean validHuiwen(String s) {
        int left = 0, right = s.length() - 1;
        while (left <= right) {
            // 如果对应位置匹配则继续遍历下一位,如果不匹配则说明非回文
            if (s.charAt(left) != s.charAt(right)) {
                return false;
            }
            // 匹配则继续遍历下一个为止
            left++;
            right--;
        }
        // 校验通过
        return true;
    }

}
  • 复杂度分析

    • 时间复杂度:O(n3)此处需要双层遍历确定区间起点、终点,然后再加一层回文判断

    • 空间复杂度:O(1)常数级别空间占用

👻方法2:动态规划法

动态规划分析

​ 按照正常的逻辑思维,如果此处设定dp[i]为以下标位置i结尾的回文子串个数的话,此处很难联想dp[i-1]dp[i+1]dp[i]的关系,因此此处设定要结合回文字符串的定义去切入。

​ 可以看到,判断[i,j]范围内字符串是否为回文。则其依赖于[i+1,j-1]范围内是否为回文,且s[i]==s[j]是否成立,如果这两个条件成立,则[i,j]为回文。基于上述分析,需要构建二维数组dp[i][j]表示区间[i,j]范围内的字符串是否为回文(true:是、false:否)

(1)dp 定义:dp[i][j]表示区间[i,j]范围内的字符串是否为回文(true:是、false:否)

(2)dp 递推:理论上是dp[i][j]=dp[i+1][j-1] && s[i]==s[j]成立则表示回文(图示特例),但对于推导而言是以s[i]==s[j]作为基础条件判断,根据ij的取值分为3种情况分析

  • s[i]!=s[j]dp[i][j]=false(非回文)
  • s[i]==s[j]
    • 情况①:i==j,此时s[i]==s[j]肯定成立 =》 dp[i][j]=true
    • 情况②:i与j相差1,例如aa形式也是回文字符串 =》 dp[i][j]=true
    • 情况③:i与j相差大于1,例如abcba形式,此时s[i]==s[j]已经成立,只需要关注bcb是否为回文字符串,即关注([i+1,j-1]是否为回文字符串)=》dp[i][j]=dp[i+1][j-1]

(3)dp 初始化:dp[i][j] 全初始化为false

(4)dp 构建:根据递推公式判断,以dp[i][j]=dp[i+1][j-1]为参考,可以看到dp[i][j]推导的基础是以其左下角的数据进行推导的,因此遍历的顺序应该为"从下到上,从左到右",才能确保所有推导的基础都是经过计算得到的结果,而不是未计算的初始化的状态

image-20241202145557212
  • 思路分析:
    • (1)dp定义:dp[i][j]表示区间[i,j]范围内的字符串是否为回文(true:是、false:否)
    • (2)递推公式:
      • s[i]!=s[j]dp[i][j]=false(非回文)
      • s[i]==s[j]
        • 情况①:i==j,此时s[i]==s[j]肯定成立 =》 dp[i][j]=true
        • 情况②:i与j相差1,例如aa形式也是回文字符串 =》 dp[i][j]=true
        • 情况③:i与j相差大于1,例如abcba形式 =》dp[i][j]=dp[i+1][j-1]
    • (3)初始化dpdp[i][j]初始化为false
    • (4)构建dp(遍历顺序):根据递推公式,确定遍历顺序为"从下往上,从左往右"(dp[i][j]=dp[i+1][j-1]
      • 且由于[i,j]区间的设定,此处内层循环j的取值应该从i的位置开始
    • (5)验证dp
/**
 * 647 回文子串
 */
public class Solution2 {
    // 动态规划
    public int countSubstrings(String s) {
        int cnt = 0;

        int len = s.length();

        // 1.dp定义(dp[i][j]表示区间[i,j]的字符串是否否为回文字符串)
        boolean[][] dp = new boolean[len][len];

        /**
         * 2.dp推导:根据s[i]与s[j]的值进行比较
         * s[i]!=s[j]:肯定为非回文 =》 dp[i][j]=false
         * s[i]==s[j]:需考虑3种情况进行分析
         * ①:i==j(a)            => dp[i][j]=true
         * ②:i与j相差1(aba)      => dp[i][j]=true
         * ③:i与j相差大于1(abcba) => dp[i][j]=dp[i+1][j-1]
         */

        // 3.dp初始化(默认初始化为false)

        // 4.dp构建(从下往上,从左往右)
        for (int i = len - 1; i >= 0; i--) {
            for (int j = i; j < len; j++) { // 区间[i,j]
                if (s.charAt(i) == s.charAt(j)) {
                    if (i == j || j - i == 1) { // 情况①②
                        dp[i][j] = true;
                        cnt++; // 回文计数+1
                    } else if (j - i > 1) {
                        dp[i][j] = dp[i + 1][j - 1]; // 情况③
                        cnt = dp[i][j] ? cnt + 1 : cnt; // 判断回文计数+1
                    }
                } else {
                    dp[i][j] = false;
                }
            }
        }

        // 返回结果
        return cnt;
    }

}
  • 复杂度分析

    • 时间复杂度:O(n2

    • 空间复杂度:O(n2)构建dp[n][n]数组存储回文状态

🟡516-最长回文子序列

1.题目内容open in new window

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。

示例 1:

输入:s = "bbbab"
输出:4
解释:一个可能的最长回文子序列为 "bbbb" 。

示例 2:

输入:s = "cbbd"
输出:2
解释:一个可能的最长回文子序列为 "bb" 。
2.题解思路
👻方法1:
  • 思路分析:
    • (1)dp定义:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]
    • (2)递推公式:
      • s[i]==s[j]dp[i][j] = dp[i + 1][j - 1] + 2 (此处不讨论i、j差值问题是因为此处的推导基础是回文子序列,不需要额外判断是否回文,只关注取值
      • s[i]!=s[j]dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])s[i]s[j]的同时加入并不能增加回文子序列的长度,因此看加入s[i]s[j]哪一个可以组成最长的回文子序列)
    • (3)初始化dp
      • i==j的时候,回文子序列为元素本身,此时dp[i]][j]=1,其余情况初始化为0
    • (4)构建dp(遍历顺序):从递推公式分析,dp[i][j]的取值依赖于3个方向的推导,因此推导顺序必须是从下到上、从左到右方能满足
      • 因为[i,j]的区间设定,因此构建的时候j的取值从i+1开始(填充【右下分界线】右侧的数据封装)
    • (5)验证dp

image-20241202154807955

/**
 * 516 最长回文子序列
 */
public class Solution1 {

    public int longestPalindromeSubseq(String s) {
        int len = s.length();
        // 1.dp 定义 (字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j])
        int[][] dp = new int[len][len];
        /**
         * 2.dp 递推
         * s[i]!=s[j]: dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); (s[i]、s[j]的同时加入并不能增加回文子序列的长度,因此看加入s[i]、s[j]哪一个可以组成最长的回文子序列)
         * s[i]==s[j]: dp[i][j] = dp[i + 1][j - 1] + 2
         */

        // 3.dp 初始化(当`i==j`的时候,回文子序列为元素本身,此时`dp[i]][j]=1`,其余情况初始化为0)
        for (int i = 0; i < len; i++) {
            dp[i][i] = 1;//i==j时元素自身为一个回文子序列
        }
        System.out.println("初始化:");
        PrintDPUtil.printMatrix(dp);

        // 4.dp 构建(从下往上,从左往右)
        for (int i = len - 1; i >= 0; i--) {
            for (int j = i + 1; j < len; j++) {
                if (s.charAt(i) == s.charAt(j)) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                } else {
                    dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
                }
            }
        }
        System.out.println("构建后:");
        PrintDPUtil.printMatrix(dp);

        // 返回结果(遍历顺序是从下往上,从左往右,因此第一行的最后一个元素为结果)
        return dp[0][len - 1];
    }

    public static void main(String[] args) {
        Solution1 solution1 = new Solution1();
        solution1.longestPalindromeSubseq("cbbd");
    }
}

// output
初始化:
[1]-[0]-[0]-[0]-
[0]-[1]-[0]-[0]-
[0]-[0]-[1]-[0]-
[0]-[0]-[0]-[1]-
构建后:
[1]-[1]-[2]-[2]-
[0]-[1]-[2]-[2]-
[0]-[0]-[1]-[1]-
[0]-[0]-[0]-[1]-
  • 复杂度分析
    • 时间复杂度:O(n2

    • 空间复杂度:O(n2)构建dp[n][n]数组存储回文状态

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3