目录
动态规划解题思路:思考与实现
解决动态规划(DP)问题可以分为两个主要阶段:思考阶段(构思解题逻辑)和编码阶段(将逻辑转化为代码)。清晰地划分这两个阶段有助于我们更系统地解决问题。
第一部分:思考阶段 (构思核心逻辑)
这个阶段的目标是弄清楚问题的核心结构和递推关系,细节可以相对简化,很多编码时候的边界条件都可以不用考虑。主要包括以下三个核心步骤:
动态规划的思考部分步骤如下:
1.定义状态
2.寻找状态转移函数
3.寻找basecase
定义状态 (State Definition)
- 核心: 确定 dp 数组或表格的含义,dp[...] 代表了什么子问题的解。
- 如何定义: 通常是根据问题中会变化的“变量”以及我们最终希望求解的“目标”来确定。常见的形式是使用一维或二维数组,dp[i] 或 dp[i][j]。其中dp下标表示的是父问题和子问题之间变化的量(通常是问题规模的变化量),dp数组保存的值是我们要求解的问题(比如零钱兑换中求的是最少的硬币个数,那么dp数组元素保存的就是硬币数量)
- 挑战: 某些问题的状态定义可能不直观,需要经验积累。遇到这类问题时,记录下它的状态定义是一个很好的学习方法(相信记住题目的状态是如何定义还是比较轻松的)。
在思考的过程中,定义状态这一步对于有些题目的话很好定义,就是看能变的变量,以及最终要求解的东西来定义,一般就是一维dp数组/二维dp数组。有些题目的状态定义不太好想,这个时候就是要看我们的经验了(遇到不好定义状态的题目就将这道题状态是如何定义的记下来,相信记住状态定义还是比较轻松的)
寻找状态转移方程
我们要知状态肯定是规模大的状态由状态小的状态作为基础来进行推导出来的,(由最优子结构这个性质来决定的)。那么我们是如何知道大状态会用到哪些小状态来进行推导出来的呢?我认为主要就是观察题目,看题目中给出我们的所有选择是什么?
思考为了达到当前状态,我有哪些可能的“最后一步”或者“选择”?
零钱兑换的文章:动态规划介绍,零钱兑换,最长递增子序列-CSDN博客
比如在零钱兑换的时候,在考虑如何到达dp[ i ]状态(即如何凑齐金额为i),我们肯定需要通过做出一个选择才能从更小规模的状态——>dp[ i ]状态,所有可能的选择就是题目中给我们的金币的所有面额。
这里我们在求dp[ i ]状态的时候都会将题目中可以做的选择都尝试一下,然后从所有选择策略中挑选出最优的结果来作为记录。
让我们实际地举个例子,还是零钱兑换问题,假如我们最终求解凑齐金额11需要最少的硬币数量,硬币的面额有:1,2,5面值。
那么我们站在11的状态考虑,想要达到这个状态的话必须要做出一次选择才能由子状态到达,可以做的选择就是1,2,5,如下图所示:
通过所有可能的选择,我们也能知道一个大状态所依赖的所有小状态(和所有可以做出的选择息息相关!!!),状态转移方程就找到啦!
如此一来我们就回答了:状态转移方程一般可以如何来寻找。
答案就是:借助我们题目给出的所有选择,自上向下来进行思考状态转移!!
PS:补充一下嘻嘻
你有没有想过为什么是所有的选择都应该被尝试一下呢?
其实本质上是因为dp问题都是暴力求解的问题,必须将所有可能性都试一遍才能得到最优解!那么这里为什么dp暴力解题比纯纯暴力解题效率高那么多呢?因为dp这里巧妙引入了状态,可以通过保存状态来避免很多计算,但是dp本质上还是一种暴力解题的思路,不过思路比较巧妙。
理解dp暴力求解的本质的话,做题的时候会让思路更加清晰!!!
寻找basecase(basecase可以确保子状态可靠)
那么有人就要说了,你怎么知道这个小状态就是可靠的呢?你的小状态是怎么来的呢?很多博主可能就会说小状态由更小的状态来进行推导出来,在考虑状态的时候要坚信小状态保存的是最优解了,更小的状态又由更更小的状态可以推导出来,直到到达基本状态basecase,道理确实是这么个道理,但是我觉得这种从始至终都是从上到下的理解方式不利于我们进行代码框架的思考,并且总是感觉云里雾里的,让人始终感觉这种小状态是虚的,感觉这些大状态建立在了虚的小状态上面,做题的时候始终都不能有透彻的理解,尤其是初学者不好理解,这种逐层递归,从始至终都在从上到下进行理解。
我认为应该在思考完成状态转移方程之后,我们可以自底向上进行思考一下,就是我们要利用可以直接想到结果的basecase,对于basecase情况对应的dp值我们肯定是毫无置疑的,一定正确。那么试想一下,将上面状态转移方程运用到直接利用basecase状态的层次中,我们肯定就可以确信自底向上的第二层一定是正确的,如此一来,对于第二层我们也会不会有所质疑了。按照状态转移方程依次递推,后面我们的每一层状态其实就不是虚的了,都是建立在切实可行的状态结果之上(这些小状态的值一定可以被算出来并且一定是正确的)。
如此一来想必你会觉得我们整个动态规划的题目不再是虚幻的了,而是真真切切有根据,看得见摸得着的。
- 自顶向下的分解思路可以帮助我们容易地确定出状态转移方程,掌握解题的核心逻辑;
- 从确定的Base Case出发,自底向上地构建出所有状态的解,是一种更具说服力、更能建立信心的视角。
我们应该在思考问题的时候将这两种视角结合使用!!!
第二部分:编码阶段
在编码阶段的话:
细节比较多,但是我认为主要有两个比较重要的点:
1. 在动态规划问题的思考阶段,我们思维主要是一个自顶向下的过程。但是在编码的时候,我们需要把思维完完全全落回到自底向上的过程啦。因为我们首先需要确定计算小状态的dp值,才能计算规模更大的状态的dp值。
2. 除了思维上,编码阶段的实操上我们需要注重很多的细节问题,比如各种边界问题,数组下标越界问题,以及dp数组是否需要整体初始化以及对应的初始化值为多大的问题(思考阶段我们只考虑了basecase问题),编码阶段需要考虑很多具体的实现问题。
这一块的话,就是真的需要很多细节了,需要我们多记忆一些经典题目对应的细节,这样在真正受敲代码的时候才会比较顺利。
动态规划道路上的迷茫(写给dp小白的一段话)
相信读这篇文章的各位有些人可能也是像我一样,是一枚动态规划的小白,在动态规划题目中可能就是道理别人讲的都懂,但是有时候就是容易陷入到某些点中,啊哈哈哈,我学习动态规划的时候也是这样的,不过没关系,相信坚持下去后面总会有所收获,随着我们技艺的不断精湛,动态规划的思想也会越来越清晰!!!
下面我就说一下当我还是dp小白的时候踩过的两个比较大的坑叭,希望可以帮到大家~(虽然现在依然是dp小白呜呜)
Nom.1 坑
这里就是还想说一个事情了,当我是dp小白的时候,在想动态规划问题的时候,有时候喜欢从半中间想,既不站在状态的最高点自顶向下想,又不站在状态的最低点(basecase)自底向上想,有时候会瞻前顾后向上面大状态想想,然后又向下面小状态想想。
“从半中间想,瞻前顾后”的无序状态是不好的,应该按照一个顺序,从最高点顶部自上而下想状态转移,从最低点basecase自下而上想一下坚信子状态都是正确可推导的。
当你在思考动态规划的时候如果有时候思路不清晰胡思乱想,没有特定的思路的话,就可以考虑一下上面我说的两点:
1. 自顶向下想一下状态转移方程是正确的,
2. 自底向下利用basecase再想一下大状态依赖的小状态都是可靠的,并且这些小状态得到的结果都是最优的!!!由于动态规划条件最优子结构这个性质,大状态一定是依赖子状态的最优解。
Nom.2 坑
想来和大家分享的第二个坑真的很有趣很有趣。
在动态规划dp问题中,我之前有想:大状态在求最优解的时候有没有可能依赖的不是子状态的最优解,而依赖的是子状态的非最优解,这样的话虽然我dp中保存了子问题的最优解,但是求大问题的最优解的时候可能需要的是子问题的非最优解而不是最优解。然后就开始怀疑算法的正确性。
不知道你们会不会这样想,反正我是这样想过好几次。啊哈哈哈哈哈
事实上已经确定可以使用动态规划dp来解决问题的话,大状态求最优解的时候只可能依赖子状态的最优解,不可能依赖的是子状态的非最优解!!!
如果题目中:大状态最优解可能依赖子问题的非最优解的话,说明这道题不能使用动态规划的方式来解决!!!因为这就证明了本题不满足使用动态规划求解的前置条件——问题必须有最优子结构性质!!!
最优子结构性质告诉我们“大问题的最优解”一定可以从“小问题的最优解”推导出来,如果问题拥有最优子结构,大问题的最优解一定不可能是由小问题的非最优解”推导出来的!!!
说到最优子结构,在算法刷题中,特别是当你明确知道或者强烈怀疑这是一道动态规划题目时,大多数情况下问题确实是满足最优子结构性质的。
这些题目通常都是经过精心设计的,目的是为了考察你对动态规划思想和技巧的应用。这些题目自然会具备动态规划所需的核心性质,包括满足最优子结构,所以不需要我们证明问题是否满足最优子结构!!!放心大胆去用叭!!!
写在最后
本篇文章算是目前博主对于动态规划问题的一些理解,非常适合那种题目中可以做的选择类型比较明确的这一类动态规划问题,比如零钱兑换,编辑距离,下降路径最小和,完全平方数,苹果的最大价值,单词拆分这些题目。
有些动态规划中可能就是没有明确说选择,对于这种题目的话,上面我说的那种寻找状态转移方程可以看题目中说明的所有选择这个小技巧,可能就不是很适用啦。动态规划的问题种类很多,多刷题后面应该可以总结一下的。
其中我可能有一些理解偏差的地方,欢迎大家指出,大家关于有不同想法的见解也欢迎讨论。
emm,dp这种题目感觉还是需要多练,上面只是总结了一下dp题目的理论知识,实践是要比理论知识更重要的!在实际的实践中你会发现有很多的变种题目,OK,话不多数,刷题去啦,大家加油!
多刷题!多思考!