递推算法与递推套路(手撕算法篇)
联系我们:有道技术团队助手:ydtech01 /邮箱:ydtech@rd.netease.com
之前学习基础知识的时候也说了,递推和动态规划有这暧昧不清的关系,可以说,动态规划就是多了一个决策过程的递推。因此,我们今天的刷题也会涉及到一些比较简单的动态规划的题目,同样能够对我们深刻的理解递推算法起到帮助,也为我们之后深入学习动态规划算法和动态规划的优化打下基础。
一、前置知识。
使用场景:当我们下(上)一行的状态可以仅仅通过上(下)一行的信息推导出来,并要求我们求解最后(第)一行时,我们无需将每一行的数据都存储在数组当中,可以通过滚动数组的技巧,节省空间复杂度。
具体实现:假设我们已经知道第一行的数据,并且通过第一行数据经过一定的运算可以得到第二行的数据,那么,我们只需要一个数组临时存储两行数据,就可以将之后的每一行数据的结果计算出来,不断的滚动替换这两行数据,最终求解出最后一行数据的方式。
关键点:推导计算当前行和下(上)一行的索引。由于数组是滚动的,因此,我们目标行的索引也是随时变化的,以下为求取当前行和上(下)一行的通用公式:
当前行: const curIdx = i % 2 由于我们的滚动数组只有 2 行,因此,当前索引只要与 2 求模即可;上(下)一行:const preIdx = +!curIdx 由于滚动数组只有两行,索引要么是 0,要么是 1,我们对当前行取反便可得到上(下)一行的索引(注意,在 js 中,对 1 取反是 false,对 0 取反是 true,因此我们通过一个隐式类型转换将布尔类型转换为数字类型)。
实际使用:后面刷题时会经常用到,详见下文
二、刷题正餐
2.1 LeetCode 120. 三角形最小路径和
2.1.1 解题思路
按照我们之前将的递推套路:
1. 定义递推状态:在这道题中,我们每走一步的路径和主要取决于当前所处的行数 i 和当前的列数 j,因此,我们这道题的递推状态应该是:dp[i, j]
2. 递推公式:确定了递推状态之后,我们就要确定递推公式了。那么,第 i 行第 j 列的数据要如何才能推导出来呢?首先,依据题意,我们要求最小路径和,如果我们从最底下往上走,那么我们可以知道,下一行 i 的数据应该是上一行 i+1 合法路径的最小值加上当前走到节点的值。
因此,我们得到了如下公式:
3. 分析边界条件:我们需要将我们题目已知的条件初始化到我们的递推数组当中作为边界条件。这道题中,边界条件就是最后一行的数据,我们将最后一行的数据先加入到滚动数组当中,这样之后就可以根据最后一行数据不断的往上推导总路径和,从而找到最小路径。
4. 程序实现:我们直接使用循环加滚动数组技巧实现。
2.1.2 代码演示
2.2 LeetCode 119. 杨辉三角 II
2.2.1 解题思路
这道题与上一道题类似,依然可以根据上一行推导出下一行的值,因此还是要祭出滚动数组的技巧,递推状态与递推公式的分析也比较类似,大家可以自己尝试推导。
而这一道题的边界条件,其实就是每一行的第一位都应该是 1。
2.1.2 代码演示
2.3 LeetCode 198. 打家劫舍
2.3.1 解题思路
1. 递推状态分析:既然要求能够偷到的最多的金额,那么,假设最后一家是 n,那么,最大金额与我们是否偷最后一家有直接的关系,我们需要分类讨论:
a. 不偷最后一家:dp[n][0]其中,0 代表不偷 b. 偷最后一家:dp[n][1]其中,1 代表偷
2. 确定递推公式:由于递推状态分为两种情况讨论,因此,我们的递推公式,也应该分成两个部分:
a. 不偷最后一家:由于不能连续偷相邻的两家,如果最后一家不偷,那么,我们倒数第二家就可以偷,因此,此时我们的最大收益就取决于偷倒数第二家的金额与不偷倒数第二家金额的最大值。即:dp[n][0] = max(dp[n-1][0], dp[n-1][1]) b. 偷最后一家:由于要偷最后一家,那么就不能偷倒数第二家,因此,这种情况的最大收益是不偷倒数第二家获得的收益加上偷最后一家带来的收益,即 dp[n][1] = dp[n-1][0] + nums[n]
3. 确定边界条件:
依据题意,我们如果不偷第一家的时候,因为一家都没偷,此时收益应该为 0。如果偷第一家,那么收益就是第一家的钱。至此,我们就确立了最开始的边界条件。
4. 程序实现:这道题由于当前收益只取决于上一家的收益,因此依然使用滚动数组技巧加循环实现。
2.3.2 代码演示
2.4 LeetCode 152. 乘积最大子数组
2.4.1 解题思路
1. 递推状态分析: 我们要求最大子数组的乘积,那么我们可以用 dp[n] 代表最后一位是 n 的最大子数组乘积最大值。
2. 递推公式: 最后一个数是 n 有两种可能性,第一种是让 n-1 的最大乘积再乘上当前 n 的值,第二种则是 n 不与之前的值相乘,自开一国,因此,我们应该在这两种情况中选一个最大值,所以,递推公式应为:dp[n] = max(dp[n-1] * val[n], val[n])
3. 边界条件:由于数组中可能含有负数,有可能使当前值乘上原先的最大值变成最小值,也可能时当前值乘上原先的最小值变成最大值,因此,我们不仅需要记录当前数字之前的最大值,也要记录最小值,方便处理负数的情况。而初始时,由于我们要求的是乘积关系,那么我们的最大值和最小值都初始化为 1 即可。
4. 程序实现:由于第 n 项的最大乘积只跟 n-1 有关,我们可以使用变量方式存储关系,不需要额外开辟递推数组空间。
2.4.2 代码演示
2.5 LeetCode 322. 零钱兑换
2.5.1 解题思路
1. 递推状态:我们使用多少个硬币,取决于要凑的金额的面额有多大,因此,我们的递推状态为:dp[n]
2. 递推公式:假如我们要凑的面额是 n,那么我们能凑够的最小的硬币数量应该是 n-coin 面额硬币数量再加上当前这枚硬币 coin 的数量 1,并且每次都取最少的数量即可。因此,我们最终的递推公式应该是:dp[n] = min(dp[i-coin] + 1),即面额为 n 的钱需要我们在所有的拼凑方案中,取一个最小值,而每一个拼凑方案应该是当前面额 i 减去当前使用的硬币面额 coin 再加上当前硬币的数量 1。
3. 边界条件:当面额为 0 时,我们需要 0 枚硬币。
4. 程序实现:我们再拼凑面额的时候,有一些特殊情况需要考虑,如:如果当前要拼凑的面额比硬币的面额要小,那么我们无法使用当前硬币拼凑成目标面额如果 i-coin 面额都无法拼凑成功的话,那么我们肯定也无法使用 coin 面额的硬币拼凑出目标面额,因为 i-coin 是前置条件。
2.5.2 代码演示
2.6 LeetCode 300. 最长递增子序列
2.6.1 解题思路
概念扫盲:a. 递增子序列:你可以在一个完整的序列中,“跳着”选择元素,并且下一个元素必须不能小于上一个元素。所谓“跳着”,就是指元素索引不需要连续,如下面示例:
b. 严格最长递增子序列:严格递增子序列是在递增子序列的基础上多了一个限定条件,就是下一个元素不能等于上一个元素,只能大于,如下示例:
1. 递推状态:由于我们最长递增子序列的长度与我当前以哪个元素作为最后一个元素有关,因此,我们的递推状态为:dp[n],代表以 n 位置作为结尾的最长递增子序列的长度
2. 递推公式:我们要算出以第 n 个元素作为结尾的最长递增子序列的长度,就要找出他上一个合法的最长递增子序列的最后一个元素 j,而我们第 n 个元素作为结尾的最长递增子序列长度就是上一个最长递增子序列长度加一,我们只需要将所有满足这个条件的最长递增子序列长度的最大值求出来就是最终的结果,即:dp[n] = max(dp[j] + 1) | j<n & val(j) < val(n)
3. 边界条件:n 为 1 时,最长递增子序列长度为 1
4. 程序实现:由于我们的递归状态定义的是以 n 作为结尾的最长递增子序列的长度,因此,我们每一项的初始值默认都是 1,至少要选择当前的数。
2.6.2 代码演示
三、结语
今天的刷题到此暂告一段落。
从上面刷的几道题其实我们不难发现,无论是递推算法还是动态规划,都有一定的套路可循,虽然这个套路学起来也并不简单,但是至少有了明确的学习方向,我们可以通过:递推状态定义、递推公式(状态转移方程)推导、边界条件确立、程序实现这四个通用套路将一个看似复杂的递推或动规程序逐一分析解决。
当然,上面的程序实现,为了更容易理解,没有使用太多的技巧进行优化,并不一定是最优的,未来如果有机会,将和大家分享动态规划的优化技巧。也欢迎大家与我们多多交流~
-END-
评论