每天一道算法练习题--Day13 第一章 --算法专题 --- ----------动态规划(重置版)

news/2024/5/4 7:15:46/文章来源:https://blog.csdn.net/weixin_43554580/article/details/130335559

动态规划是一个从其他行业借鉴过来的词语。

它的大概意思先将一件事情分成若干阶段,然后通过阶段之间的转移达到目标。由于转移的方向通常是多个,因此这个时候就需要决策选择具体哪一个转移方向。

动态规划所要解决的事情通常是完成一个具体的目标,而这个目标往往是最优解。并且:

  • 阶段之间可以进行转移,这叫做动态。
  • 达到一个可行解(目标阶段) 需要不断地转移,那如何转移才能达到最优解?这叫规划。

每个阶段抽象为状态(用圆圈来表示),状态之间可能会发生转化(用箭头表示)。可以画出类似如下的图:
在这里插入图片描述
那我们应该做出如何的决策序列才能使得结果最优?换句话说就是每一个状态应该如何选择到下一个具体状态,并最终到达目标状态。这就是动态规划研究的问题。

每次决策实际上不会考虑之后的决策,而只会考虑之前的状态。 形象点来说,其实是走一步看一步这种短视思维。为什么这种短视可以来求解最优解呢?那是因为:

  • 我们将所有可能的转移全部模拟了一遍,最后挑了一个最优解。
  • 无后向性(这个我们后面再说,先卖个关子)

而如果你没有模拟所有可能,而直接走了一条最优解,那就是贪心算法了。
所谓贪心,和动态规划的最大区别就是此,就是你每一次都走最优解,是不是就会对应得到最终答案的最优解呢?

没错,动态规划刚开始就是来求最优解的。只不过有的时候顺便可以求总的方案数等其他东西,这其实是动态规划的副产物。

好了,我们把动态规划拆成两部分分别进行解释,或许你大概知道了动态规划是一个什么样的东西。但是这对你做题并没有帮助。那算法上的动态规划究竟是个啥呢?

在算法上,动态规划和查表的递归(也称记忆化递归) 有很多相似的地方。我建议大家先从记忆化递归开始学习。本文也先从记忆化递归开始,逐步讲解到动态规划。

记忆化递归

那么什么是递归?什么是查表(记忆化)?让我们慢慢来看。

什么是递归?

太常见了,但是想用好也是不容易的。

不仅仅是普通的递归函数

本文中所提到的记忆化递归中的递归函数实际上指的是特殊的递归函数,即在普通的递归函数上满足以下几个条件:

  • 递归函数不依赖外部变量
  • 递归函数不改变外部变量

满足这两个条件有什么用呢?这是因为我们需要函数给定参数,其返回值也是确定的。这样我们才能记忆化。关于记忆化,我们后面再讲。

如果大家了解函数式编程,实际上这里的递归其实严格来说是函数式编程中的函数。如果不了解也没关系,这里的递归函数其实就是数学中的函数。

我们来回顾一下数学中的函数:

在一个变化过程中,假设有两个变量 x、y,如果对于任意一个 x 都有唯一确定的一个 y 和它对应,
那么就称 x 是自变量,y 是 x 的函数。
x 的取值范围叫做这个函数的定义域,相应 y 的取值范围叫做函数的值域 。

而本文所讲的所有递归都是指的这种数学中的函数。
比如上面的递归函数:

def f(x):if x == 1: return 1return x + f(x - 1)
  • x 就是自变量,x 的所有可能的返回值构成的集合就是定义域。
  • f(x) 就是函数。
  • f(x) 的所有可能的返回值构成的集合就是值域。

自变量也可以有多个,对应递归函数的参数可以有多个,比如 f(x1, x2, x3)。
通过函数来描述问题,并通过函数的调用关系来描述问题间的关系就是记忆化递归的核心内容。

每一个动态规划问题,实际上都可以抽象为一个数学上的函数。这个函数的自变量集合就是题目的所有取值,值域就是题目要求的答案的所有可能。我们的目标其实就是填充这个函数的内容,使得给定自变量 x,能够唯一映射到一个值 y。(当然自变量可能有多个,对应递归函数参数可能有多个)

解决动态规划问题可以看成是填充函数这个黑盒,使得定义域中的数并正确地映射到值域。

在这里插入图片描述
递归并不是算法,它是和迭代对应的一种编程方法。只不过,我们通常借助递归去分解问题而已。比如我们定义一个递归函数 f(n),用 f(n) 来描述问题。就和使用普通动态规划 f[n] 描述问题是一样的,这里的 f 是 dp 数组。

什么是记忆化?

为了大家能够更好地对本节内容进行理解,我们通过一个例子来切入:
一个人爬楼梯,每次只能爬 1 个或 2 个台阶,假设有 n 个台阶,那么这个人有多少种不同的爬楼梯方法?
思路:
由于第 n 级台阶一定是从 n - 1 级台阶或者 n - 2 级台阶来的,因此到第 n 级台阶的数目就是 到第 n - 1 级台阶的数目加上到第 n - 2 级台阶的数目。

递归代码:

function climbStairs(n) {if (n === 1) return 1;if (n === 2) return 2;return climbStairs(n - 1) + climbStairs(n - 2);
}

我们用一个递归树来直观感受以下(每一个圆圈表示一个子问题):
在这里插入图片描述
红色表示重复的计算。即 Fib(N-2) 和 Fib(N-3) 都被计算了两次,实际上计算一次就够了。比如第一次计算出了 Fib(N-2) 的值,那么下次再次需要计算 Fib(N-2)的时候,可以直接将上次计算的结果返回。之所以可以这么做的原因正是前文提到的我们的递归函数是数学中的函数,也就是说参数一定,那么返回值也一定不会变,因此下次如果碰到相同的参数,我们就可以将上次计算过的值直接返回,而不必重新计算。这样节省的时间就等价于重叠子问题的个数。

以这道题来说,本来需要计算 2 n 2^n 2n 次,而如果使用了记忆化,只需要计算 n 次,就是这么神奇。
代码上,我们可以使用一个 hashtable 去缓存中间计算结果,从而省去不必要的计算。

memo = {}
def climbStairs(n):if n == 1:return 1if n == 2: return 2if n in memo: return memo[n] #用一个数组去作为记忆存储ans = func(n - 1) + func(n-2)memo[n] = ansreturn ans
climbStairs(10)

这里我使用了一个名为 memo 的哈希表来存储递归函数的返回值,其中 key 为参数,value 为递归函数的返回值。
在这里插入图片描述

key 的形式为 (x, y),表示的是一个元祖。通常动态规划的参数有多个,我们就可以使用元祖的方式来记忆化。或者也可采取多维数组的形式。对于上图来说,就可使用二维数组来表示。

小结

使用递归函数的优点是逻辑简单清晰,缺点是过深的调用会导致栈溢出。这里我列举了几道算法题目,这几道算法题目都可以用递归轻松写出来:

  • 递归实现 sum
  • 二叉树的遍历
  • 走楼梯问题
  • 汉诺塔问题
  • 杨辉三角

递归中如果存在重复计算(我们称重叠子问题,下文会讲到),那就是使用记忆化递归(或动态规划)解题的强有力信号之一。可以看出动态规划的核心就是使用记忆化的手段消除重复子问题的计算,如果这种重复子问题的规模是指数或者更高规模,那么记忆化递归(或动态规划)带来的收益会非常大。

为了消除这种重复计算,我们可使用查表的方式。即一边递归一边使用“记录表”(比如哈希表或者数组)记录我们已经计算过的情况,当下次再次碰到的时候,如果之前已经计算了,那么直接返回即可,这样就避免了重复计算。下文要讲的动态规划中 DP 数组其实和这里“记录表”的作用是一样的。

如果你刚开始接触递归, 建议大家先去练习一下递归再往后看。一个简单练习递归的方式是将你写的迭代全部改成递归形式。比如你写了一个程序,功能是“将一个字符串逆序输出”,那么使用迭代将其写出来会非常容易,那么你是否可以使用递归写出来呢?通过这样的练习,可以让你逐步适应使用递归来写程序。

当你已经适应了递归的时候,那就让我们继续学习动态规划吧!

动态规划

动态规划的基本概念

我们先来学习动态规划最重要的两个概念:最优子结构和无后效性。

其中:

  • 无后效性决定了是否可使用动态规划来解决。
  • 最优子结构决定了具体如何解决。

最优子结构

动态规划常常适用于有重叠子问题和最优子结构性质的问题。前面讲了重叠子问题,那么最优子结构是什么?这是我从维基百科找的定义:

如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。

举个例子:如果考试中的分数定义为 f,那么这个问题就可以被分解为语文,数学,英语等子问题。显然子问题最优的时候,总分这个大的问题的解也是最优的。

再比如 01 背包问题:定义 f(weights, values, capicity)。如果我们想要求 f([1,2,3], [2,2,4], 10) 的最优解。从左到右依次考虑是否将每一件物品加入背包。我们可以将其划分为如下子问题:

不将第三件物品装进背包(即仅考虑前两件),也就是 f([1,2], [2,2], 10)
和将第三件物品装进背包,也就是 f([1,2,3], [2,2,4], 10) (即仅考虑前两件的情况下装满容量为 10 - 3 = 7 的背包) 等价于 4 + f([1,2], [2,2], 7),其中 4 为第三件物品的价值,7 为装下第三件物品后剩余可用空间,由于我们仅考虑前三件,因此前两件必须装满 10 - 3 = 7 才行。

显然这两个问题还是复杂,我们需要进一步拆解。不过,这里不是讲如何拆解的。

原问题 f([1,2,3], [2,2,4], 10) 等于以上两个子问题的最大值。只有两个子问题都是最优的时候整体才是最优的,这是因为子问题之间不会相互影响。

无后效性

即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。

继续以上面两个例子来说。

  • 数学考得高不能影响英语(现实其实可能影响,比如时间一定,投入英语多,其他科目就少了)。
  • 背包问题中 f([1,2,3], [2,2,4],10)选择是否拿第三件物品,不应该影响是否拿前面的物品。比如题目规定了拿了第三件物品之后,第二件物品的价值就会变低或变高)。这种情况就不满足无后向性。

动态规划三要素:

状态定义:
动态规划的中心点是什么?如果让我说的话,那就是定义状态。
动态规划解题的第一步就是定义状态。定义好了状态,就可以画出递归树,聚焦最优子结构写转移方程就好了,因此我才说状态定义是动态规划的核心,动态规划问题的状态确实不容易看出。

但是一旦你能把状态定义好了,那就可以顺藤摸瓜画出递归树,画出递归树之后就聚焦最优子结构就行了。但是能够画出递归树的前提是:对问题进行划分,专业点来说就是定义状态。那怎么才能定义出状态呢?

好在状态的定义都有特点的套路。 比如一个字符串的状态,通常是 dp[i] 表示字符串 s 以 i 结尾的 …。 比如两个字符串的状态,通常是 dp[i][j] 表示字符串 s1 以 i 结尾,s2 以 j 结尾的 …。

也就是说状态的定义通常有不同的套路,大家可以在做题的过程中进行学习和总结。但是这种套路非常多,那怎么搞定呢?

说实话,只能多练习,在练习的过程中总结套路。具体的套路参考后面的动态规划的题型 部分内容。之后大家就可以针对不同的题型,去思考大概的状态定义方向。

两个例子

关于状态定义,真的非常重要,以至于我将其列为动态规划的核心。因此我觉得有必要举几个例子来进行说明。我直接从力扣的动态规划专题中抽取前两道给大家讲讲。

在这里插入图片描述

第一道题:《5. 最长回文子串》难度中等

给你一个字符串 s,找到 s 中最长的回文子串。示例 1:输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。
示例 2:输入:s = "cbbd"
输出:"bb"
示例 3:输入:s = "a"
输出:"a"
示例 4:输入:s = "ac"
输出:"a"提示:1 <= s.length <= 1000
s 仅由数字和英文字母(大写和/或小写)组成

这道题入参是一个字符串,那我们要将其转化为规模更小的子问题,那无疑就是字符串变得更短的问题,临界条件也应该是空字符串或者一个字符这样。

因此:

  • 一种定义状态的方式就是 f(s1),含义是字符串 s1 的最长回文子串,其中 s1 就是题目中的字符串 s 的子串,那么答案就是f(s)。
  • 由于规模更小指的是字符串变得更短,而描述字符串我们也可以用两个变量来描述,这样实际上还省去了开辟字符串的开销。两个变量可以是起点索引 + 子串长度,也可以是终点索引 + 子串长度,也可以是起点坐标 + 终点坐标。随你喜欢,这里我就用起点坐标 + 终点坐标。那么状态定义就是 f(start, end),含义是子串 s[start:end+1]的最长回文子串,那么答案就是 f(0, len(s) - 1).

这无疑是一种定义状态的方式,但是一旦我们这样去定义就会发现:状态转移方程会变得难以确定(实际上很多动态规划都有这个问题,比如最长上升子序列问题)。那究竟如何定义状态呢?我会在稍后的状态转移方程继续完成这道题。我们先来看下一道题。

第二道题:《10. 正则表达式匹配》难度困难

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.''*' 的正则表达式匹配。'.' 匹配任意单个字符
'*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。示例 1:输入:s = "aa" p = "a"
输出:false
解释:"a" 无法匹配 "aa" 整个字符串。
示例 2:输入:s = "aa" p = "a*"
输出:true
解释:因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。
示例 3:输入:s = "ab" p = ".*"
输出:true
解释:".*" 表示可匹配零个或多个('*')任意字符('.')。
示例 4:输入:s = "aab" p = "c*a*b"
输出:true
解释:因为 '*' 表示零个或多个,这里 'c'0, 'a' 被重复一次。因此可以匹配字符串 "aab"。
示例 5:输入:s = "mississippi" p = "mis*is*p*."
输出:false提示:0 <= s.length <= 20
0 <= p.length <= 30
s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 .*。
保证每次出现字符 * 时,前面都匹配到有效的字符

这道题入参有两个, 一个是 s,一个是 p。沿用上面的思路,我们有两种定义状态的方式。

  • 一种定义状态的方式就是 f(s1, p1),含义是 p1 是否可匹配字符串 s1,其中 s1 就是题目中的字符串 s 的子串,p1 就是题目中的字符串 p 的子串,那么答案就是 f(s, p)。
  • 另一种是 f(s_start, s_end, p_start, p_end),含义是子串 p1[p_start:p_end+1] 是否可以匹配字符串 s[s_start:s_end+1],那么答案就是 f(0, len(s) - 1, 0, len§ - 1)

而这道题实际上我们也可采用更简单的状态定义方式,不过基本思路都是差不多的。我仍旧卖个关子,后面讲转移方程再揭晓。

搞定了状态定义,你会发现时间空间复杂度都变得很明显了。这也是为啥我反复强调状态定义是动态规划的核心。

时间空间复杂度怎么个明显法了呢?

首先空间复杂度,我刚才说了动态规划其实就是查表的暴力法,因此动态规划的空间复杂度打底就是表的大小。再直白一点就是上面的哈希表 memo 的大小。而 memo的大小基本就是状态的个数。状态个数是多少呢? 这不就取决你状态怎么定义了么?比如上面的 f(s1, p1) 。状态的多少是多少呢?很明显就是每个参数的取值范围大小的笛卡尔积。s1 的所有可能取值有 len(s) 种,p1 的所有可能有 len§种,那么总的状态大小就是 len(s) * len§。那空间复杂度是 O ( m ∗ n ) O(m * n) O(mn),其中 m 和 n 分别为 s 和 p 的大小。

我说空间复杂度打底是状态个数, 这里暂时先不考虑状态压缩的情况。

如果你枚举每一个状态都需要和 s 的每一个字符计算一下,那时间复杂度就是 O ( m 2 ∗ n ) O(m^2 * n) O(m2n)

以上面的爬楼梯的例子来说,我们定义状态 f(n) 表示到达第 n 级台阶的方法数,那么状态总数就是 n,空间复杂度和时间复杂度打底就是 n n n 了。(仍然不考虑滚动数组优化)

再举个例子:62. 不同路径

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。问总共有多少条不同的路径?

这道题是和上面的爬楼梯很像,只不过从一维变成了二维,我把它叫做二维爬楼梯,类似的换皮题还很多,大家慢慢体会。

这道题我定义状态为 f(i, j) 表示机器人到达点 (i,j) 的总的路径数。那么状态总数就是 i 和 j 的取值的笛卡尔积,也就是 m * n 。

在这里插入图片描述
在这里插入图片描述
总的来说,动态规划的空间和时间复杂度打底就是状态的个数,而状态的个数通常是参数的笛卡尔积,这是由动态规划的无后向性决定的。

临界条件是比较容易的
当你定义好了状态,剩下就三件事了:

  • 临界条件
  • 状态转移方程
  • 枚举状态

在上面讲解的爬楼梯问题中,如果我们用 f(n) 表示爬 n 级台阶有多少种方法的话,那么:

f(1) 与 f(2) 就是【边界】
f(n) = f(n-1) + f(n-2) 就是【状态转移公式】

我用动态规划的形式表示一下:

dp[0] 与 dp[1] 就是【边界】
dp[n] = dp[n - 1] + dp[n - 2] 就是【状态转移方程】

可以看出记忆化递归和动态规划是多么的相似。

实际上临界条件相对简单,大家只有多刷几道题,里面就有感觉。困难的是找到状态转移方程和枚举状态。这两个核心点的都建立在已经抽象好了状态的基础上。比如爬楼梯的问题,如果我们用 f(n) 表示爬 n 级台阶有多少种方法的话,那么 f(1), f(2), … 就是各个独立的状态。

搞定了状态的定义,那么我们来看下状态转移方程。

状态转移方程

动态规划中当前阶段的状态往往是上一阶段状态和上一阶段决策的结果。这里有两个关键字,分别是 :

  • 上一阶段状态
  • 上一阶段决策

也就是说,如果给定了第 k 阶段的状态 s[k] 以及决策 choice(s[k]),则第 k+1 阶段的状态 s[k+1] 也就完全确定,用公式表示就是:s[k] + choice(s[k]) -> s[k+1], 这就是状态转移方程。需要注意的是 choice 可能有多个,因此每个阶段的状态 s[k+1]也会有多个。

继续以上面的爬楼梯问题来说,爬楼梯问题由于上第 n 级台阶一定是从 n - 1 或者 n - 2 来的,因此 上第 n 级台阶的数目就是 上 n - 1 级台阶的数目加上 n - 2 级台阶的数目。

上面的这个理解是核心, 它就是我们的状态转移方程,用代码表示就是 f(n) = f(n - 1) + f(n - 2)。

实际操作的过程,有可能题目和爬楼梯一样直观,我们不难想到。也可能隐藏很深或者维度过高。 如果你实在想不到,可以尝试画图打开思路,这也是我刚学习动态规划时候的方法。当你做题量上去了,你的题感就会来,那个时候就可以不用画图了。

比如我们定义了状态方程,据此我们定义初始状态和目标状态。然后聚焦最优子结构,思考每一个状态究竟如何进行扩展使得离目标状态越来越近。

如下图所示:
在这里插入图片描述
理论差不多先这样,接下来来几个实战消化一下。

动态规划 VS 记忆化递归

上面我们用记忆化递归的问题巧妙地解决了爬楼梯问题。 那么动态规划是怎么解决这个问题呢?
答案也是“查表”,我们平常写的 dp table 就是表,其实这个 dp table 和上面的 memo 没啥差别。
而一般我们写的 dp table,数组的索引通常对应记忆化递归的函数参数,值对应递归函数的返回值。

看起来两者似乎没任何思想上的差异,区别的仅仅是写法?? 没错。不过这种写法上的差异还会带来一些别的相关差异,这点我们之后再讲。

如果上面的爬楼梯问题,使用动态规划,代码是怎么样的呢?我们来看下:

function climbStairs(n) {if (n == 1) return 1;const dp = new Array(n);dp[0] = 1;dp[1] = 2;for (let i = 2; i < n; i++) {dp[i] = dp[i - 1] + dp[i - 2];}return dp[dp.length - 1];
}

大家现在不会也没关系,我们将前文的递归的代码稍微改造一下。其实就是将函数的名字改一下:

function dp(n) {if (n === 1) return 1;if (n === 2) return 2;return dp(n - 1) + dp(n - 2);
}

经过这样的变化。我们将 dp[n] 和 dp(n) 对比看,这样是不是有点理解了呢? 其实他们的区别只不过是递归用调用栈枚举状态, 而动态规划使用迭代枚举状态。

如果需要多个维度枚举,那么记忆化递归内部也可以使用迭代进行枚举,比如最长上升子序列问题。

动态规划的查表过程如果画成图,就是这样的:
在这里插入图片描述

滚动数组优化

爬楼梯我们并没有必要使用一维数组,而是借助两个变量来实现的,空间复杂度是 O(1)。代码:

function climbStairs(n) {if (n === 1) return 1;if (n === 2) return 2;let a = 1;let b = 2;let temp;for (let i = 3; i <= n; i++) {temp = a + b;a = b;b = temp;}return temp;
}

之所以能这么做,是因为爬楼梯问题的状态转移方程中当前状态只和前两个状态有关,因此只需要存储这两个即可。 动态规划问题有很多这种讨巧的方式,这个技巧叫做滚动数组。

这道题目是动态规划中最简单的问题了,因为仅涉及到单个因素的变化,如果涉及到多个因素,就比较复杂了,比如著名的背包问题,挖金矿问题等。

对于单个因素的,我们最多只需要一个一维数组即可,对于如背包问题我们需要二维数组等更高维度。

回答上面的问题:记忆化递归和动态规划除了一个用递归一个用迭代,其他没差别。那两者有啥区别呢?我觉得最大的区别就是记忆化递归无法使用滚动数组优化。

不信你用上面的爬楼梯试一下,下面代码如何使用滚动数组优化?

const memo = {};
function dp(n) {if (n === 1) return 1;if (n === 2) return 2;if (n in memo) return memo[n];const ans = dp(n - 1) + dp(n - 2);memo[n] = ans;return ans;
}

本质上来说, 记忆化递归采用的方式是 DFS,因此会一条路走到黑,然后返回来继续其他可行的路一口气再次走到黑。而迭代使用的是类似 BFS 的方式,这样一层层访问, 太远的层可能用不到了,就可以直接抹去,这就是滚动数组的本质。

记忆化调用栈的开销比较大(复杂度不变,你可以认为空间复杂度常数项更大),不过几乎不至于 TLE 或者 MLE。因此我的建议就是没空间优化需求直接就记忆化,否则用迭代 dp。

再次强调一下:

  • 如果说递归是从问题的结果倒推,直到问题的规模缩小到寻常。 那么动态规划就是从寻常入手, 逐步扩大规模到最优子结构。
  • 记忆化递归和动态规划没有本质不同。都是枚举状态,并根据状态直接的联系逐步推导求解。 动态规划性能通常更好。
  • 一方面是递归的栈开销,一方面是滚动数组的技巧。

动态规划的基本类型

  • 背包 DP(这个我们专门开了一个专题讲)
  • 区间 DP

区间类动态规划是线性动态规划的扩展,它在分阶段地划分问题时,与阶段中元素出现的顺序和由前一阶段的哪些元素合并而来有很大的关系。令状态 f ( i , j ) f(i,j) f(i,j) 表示将下标位置 i i i j j j 的所有元素合并能获得的价值的最大值,那么 f ( i , j ) = max ⁡ f ( i , k ) + f ( k + 1 , j ) + c o s t f(i,j)=\max{f(i,k)+f(k+1,j)+cost} f(i,j)=maxf(i,k)+f(k+1,j)+cost c o s t cost cost 为将这两组元素合并起来的代价。

区间 DP 的特点:
合并:即将两个或多个部分进行整合,当然也可以反过来;

特征: 能将问题分解为能两两合并的形式;

求解: 对整个问题设最优值,枚举合并点,将问题分解为左右两个部分,最后合并两个部分的最优值得到原问题的最优值。

什么时候用记忆化递归?

  • 从数组两端同时进行遍历的时候使用记忆化递归方便,其实也就是区间 DP(range dp)。比如石子游戏,再比如这道题 https://binarysearch.com/problems/Make-a-Palindrome-by-Inserting-Characters

如果区间 dp 你的遍历方式大概需要这样:

class Solution:def solve(self, s):n = len(s)dp = [[0] * n for _ in range(n)]# 右边界倒序遍历for i in range(n - 1, -1, -1):# 左边界正序遍历for j in range(i + 1, n):# do somethingreturn  dp[0][m-1] # 一般都是使用这个区间作为答案

如果使用记忆化递归则不需考虑遍历方式的问题。
代码:

class Solution:def solve(self, s):@lru_cache(None)def helper(l, r):if l >= r:return 0if s[l] == s[r]:return helper(l + 1, r - 1)return 1 + min(helper(l + 1, r), helper(l, r - 1))return helper(0, len(s) - 1)
  • 选择 比较离散的时候,使用记忆化递归更好。比如马走棋盘。
  • 那什么时候不用记忆化递归呢?答案是其他情况都不用。因为普通的 dp table 有一个重要的功能,这个功能记忆化递归是无法代替的,那就是滚动数组优化如果你需要对空间进行优化,那一定要用 dp table。

热身开始

理论知识已经差不多了,我们拿一道题来试试手。
我们以一个非常经典的背包问题来练一下手。
题目:322. 零钱兑换

给定不同面额的硬币 coins 和一个总金额 amount。
编写一个函数来计算可以凑成总金额所需的最少的硬币个数。
如果没有任何一种硬币组合能组成总金额,返回 -1。你可以认为每种硬币的数量是无限的。
示例 1:输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

这道题的参数有两个,一个是 coins,一个是 amount。

我们可以定义状态为 f(i, j) 表示用 coins 的前 i 项找 j 元需要的最少硬币数。那么答案就是 f(len(coins) - 1, amount)。

由组合原理,coins 的所有选择状态是 2 n 2^n 2n。状态总数就是 i 和 j 的取值的笛卡尔积,也就是 2^len(coins) * (amount + 1)。

减 1 是因为存在 0 元的情况。

明确了这些,我们需要考虑的就是状态如何转移,也就是如何从寻常转移到 f(len(coins) - 1, amount)。

如何确定状态转移方程?我们需要:

  • 聚焦最优子结构
  • 做选择,在选择中取最优解(如果是计数 dp 则进行计数)

对于这道题来说,我们的选择有两种:

  • 选择 coins[i]
  • 不选择 coins[i]

这无疑是完备的。只不过仅仅是对 coins 中的每一项进行选择与不选择,这样的状态数就已经是 2 n 2^n 2n 了,其中 n 为 coins 长度。

如果仅仅是这样枚举肯定会超时,因为状态数已经是指数级别了。
而这道题的核心在于 coins[i] 选择与否其实没有那么重要,重要的其实是选择的 coins 一共有多少钱。

因此我们可以定义 f(i, j) 表示选择了 coins 的前 i 项(怎么选的不关心),且组成 j 元需要的最少硬币数。
举个例子来说,比如 coins = [1,2,3] 。那么选择 [1,2] 和 选择 [3] 虽然是不一样的状态,但是我们压根不关心。因为这两者没有区别,我们还是谁对结果贡献大就 pick 谁。

以 coins = [1,2,3], amount = 6 来说,我们可以画出如下的递归树。
在这里插入图片描述

因此转移方程就是 min(dp[i-1][j], dp[i][j - coins[i]] + 1),含义就是: min(不选择 coins[i], 选择 coins[i]) 所需最少的硬币数。
用公式表示就是:
在这里插入图片描述

amount 表示无解。因为硬币的面额都是正整数,不可能存在一种需要 amount + 1 枚硬币的方案。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.luyixian.cn/news_show_103941.aspx

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

问卷中多选题如何分析?

一、案例与问卷 本研究选取大学生作为研究对象&#xff0c;旨在通过理财认知、理财现状、理财偏好三个方面&#xff0c;对大学生理财产品了解情况、使用需求进行调查。本次问卷共分为四个部分&#xff1a;第一部分共5道题&#xff0c;为基本信息题&#xff1b;第二部分共3道题…

dubbogo如何实现远程配置管理 -- 阅读官方文档

dubbo-go 中如何实现远程配置管理&#xff1f; 之前在 Apache/dubbo-go&#xff08;以下简称 dubbo-go &#xff09;社区中&#xff0c;有同学希望配置文件不仅可以放于本地&#xff0c;还可以放于配置管理中心里。那么&#xff0c;放在本地和配置管理中心究竟有哪些不一样呢&…

【browserify】一步步教你学会browserify

https://www.cnblogs.com/fsg6/p/13139627.html Browserify browserify的官网是http://browserify.org/&#xff0c;他的用途是将前端用到的众多资源&#xff08;css,img,js,…) 打包成一个js文件的技术。 比如在html中引用外部资源的时候&#xff0c;原来我们可能这样写 &l…

从0搭建Vue3组件库(九):VitePress 搭建部署组件库文档

VitePress 搭建组件库文档 当我们组件库完成的时候,一个详细的使用文档是必不可少的。本篇文章将介绍如何使用 VitePress 快速搭建一个组件库文档站点并部署到GitHub上 安装 首先新建 site 文件夹,并执行pnpm init,然后安装vitepress和vue pnpm install -D vitepress vue安…

年轻不乏野心,想做年薪40万+的软件测试工程师?写给长途漫漫中的你...

本人从事自动化测试10年多&#xff0c;之前在猪场工作&#xff0c;年薪突破40W&#xff0c;算是一个生活过得去的码农。&#xff08;仅代表本人&#xff09; 目前从事自动化测试的薪资待遇还是很不错的&#xff0c;所以如果朋友们真的对自动化感兴趣的话可以坚持学下去&#xf…

一种视频算法插件流水线执行架构

目的 将视频接入进来以后&#xff0c;使用算法对图像进行处理并且输出 1 各种接入 2 解码 3 解码后图像算法 如矫正算法 4 共享输出 方式 使用动态库的方式进行扫描底层&#xff0c;每个动态库为一个插件&#xff0c;每个插件包含特定的函数&#xff0c;通过扫描的方式加载所…

基于springboot的大学生租房系统源码论文数据库

3.1系统功能 现在无论是在PC上还是在手机上&#xff0c;相信全国所有地方都在进行大学生租房管理。随着经济的不断发展&#xff0c;系统管理也在不断增多&#xff0c;大学生租房系统就是其中一种&#xff0c;很多人会登录到相关的租房系统查看租房信息&#xff0c;还能查看房屋…

11.java程序员必知必会类库之word处理库

前言 正常业务中&#xff0c;可能涉及到和合作方签约电子合同&#xff0c;此时&#xff0c;我们需要先设计合同模板&#xff0c;维护固定内容&#xff0c;将可变的内容通过占位符替代&#xff0c;等签章的时候&#xff0c;生成pdf,然后可以根据设计的合同章的坐标&#xff0c;…

Linux基础命令和程序部署

Linux基础命令 ls 可以查看当前目录内容ls 后面跟上一个具体路径可以查看指定目录内容ls -l 可以以列表的形式查看&#xff0c;缩写llpwd 查看当前目录的绝对路径cd 切换目录&#xff08;就是window界面的鼠标双击目录进入动作&#xff09;&#xff0c;cd在切换目录时后面可以…

Android 9.0 系统设置显示主菜单添加屏幕旋转菜单实现旋转屏幕功能

1.前言 在android9.0的系统rom定制化开发中,在对系统设置进行定制开发中,有产品需求要求增加旋转屏幕功能的菜单,就是在点击旋转屏幕菜单后弹窗显示旋转0度,旋转 90度,旋转180度,旋转270度针对不同分辨率的无重力感应的大屏设备的屏幕旋转功能的实现,接下来就来分析实现…

详解子网划分练习题(32道)

目录 1 子网划分概念&#xff1a; 2 划分方法&#xff1a; 子网划分方法&#xff1a;段&#xff0c;块&#xff0c;数的计算三步。 段就是确定ip地址段中既有网络地址&#xff0c;又有主机地址的那一段是四段中的那一段&#xff1f; 块就确定上一步中确定的那一段中的主机…

FreeRTOS(三)——应用开发(一)

文章目录 0x01 FreeRTOS文件夹FreeRTOSConfig.h文件内容上面定义的宏决定FreeRTOS.h文件中的定义0x02 创建任务创建静态任务过程configSUPPORT_STATIC_ALLOCATION创建动态任务过程configSUPPORT_DYNAMIC_ALLOCATION 0x03 FreeRTOS启动流程启动流程概述 0x04 任务管理任务调度器…

CASAIM自动化精密尺寸测量设备全尺寸检测铸件自动化检测铸件

铸造作为现代装备制造工业的基础共性技术之一&#xff0c;铸件产品既是工业制造产品&#xff0c;也是大型机械的重要组成部分&#xff0c;被广泛运用在航空航天、工业船舶、机械电子和交通运输等行业。 铸件形状复杂&#xff0c;一般的三坐标或者卡尺圆规等工具难以获取多特征…

FPGA入门系列5--运算符号

文章简介 本系列文章主要针对FPGA初学者编写&#xff0c;包括FPGA的模块书写、基础语法、状态机、RAM、UART、SPI、VGA、以及功能验证等。将每一个知识点作为一个章节进行讲解&#xff0c;旨在更快速的提升初学者在FPGA开发方面的能力&#xff0c;每一个章节中都有针对性的代码…

【剧前爆米花--爪哇岛寻宝】网络互连,网络通信和网络分层

作者&#xff1a;困了电视剧 专栏&#xff1a;《JavaEE初阶》 文章分布&#xff1a;这是一篇关于网络初识的文章&#xff0c;在这篇文章中讲解了局域网广域网&#xff0c;IP地址&#xff0c;端口以及网络分层等相关内容&#xff0c;希望对你有所帮助&#xff01; 目录 网络互连…

第10届蓝桥杯省赛真题剖析-2019年3月24日Scratch编程初中级组

[导读]&#xff1a;超平老师的《Scratch蓝桥杯真题解析100讲》已经全部完成&#xff0c;后续会不定期解读蓝桥杯真题&#xff0c;这是Scratch蓝桥杯真题解析第126讲。 第10届蓝桥杯省赛&#xff0c;这是2019年3月24日举办的省赛Scratch考试真题&#xff0c;比赛是在线下举办的…

Redis数据库的安装(Windows10)

Redis数据库的安装 前言安装启动命令简单的几条语句 前言 本节开始学习Redis数据库。 Redis数据库的优势如下&#xff1a; 性能极高 – Redis能读的速度是110000次/s,写的速度是81000次/s 。 丰富的数据类型 – Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ord…

测量射频器件噪声系数的三种方法盘点

本文介绍了测量噪声系数的三种方法&#xff1a;增益法、Y系数法和噪声系数测试仪法。这三种方法的比较以表格的形式给出。 在无线通信系统中&#xff0c;噪声系数&#xff08;NF&#xff09;或者相对应的噪声因数(F)定义了噪声性能和对接收机灵敏度的贡献。本篇应用笔记详细阐…

二十、线索关联市场活动(一):查询市场活动

功能需求 用户在线索明细页面,点击"关联市场活动"按钮,弹出线索关联市场活动的模态窗口; 用户在线索关联市场活动的模态窗口,输入搜索条件,每次键盘弹起,根据名称模糊查询市场活动,把所有符合条件的市场活动显示到列表中; 用户选择要关联的市场活动,点击"关联…

史上最全Maven教程(三)

文章目录 &#x1f525;Maven工程测试_Junit使用步骤&#x1f525;Maven工程测试_Junit结果判定&#x1f525;Maven工程测试_Before、After&#x1f525;依赖冲突调解_最短路径优先原则&#x1f525;依赖冲突调解_最先声明原则&#x1f525;依赖冲突调解_排除依赖、锁定版本 &a…