前言
动态规划这块,我从一开始看到题目就懵,到现在能独立写出三道经典题,中间踩了不少坑。这篇文章把我真实的思考过程和犯错记录写下来,希望能帮到和我一样刚开始接触DP的同学。
第一步:爬楼梯 ------ 理解"最后一步分类"
题目:每次可以爬1级或2级台阶,爬到第n级有多少种不同的方法?
我的理解过程
一开始我看到递推公式 dp[i] = dp[i-1] + dp[i-2] 完全不知道是怎么来的。后来老师用"最后一步"的思路帮我拆解:
- 要走到第i级台阶,最后一步要么是从第i-1级跨1步上来,要么是从第i-2级跨2步上来
- 所以走到第i级的方法数 = 走到第i-1级的方法数 + 走到第i-2级的方法数
这个"最后一步分类"的思路是DP的核心思想之一:站在终点往前看,把问题拆成几种可能性。
滚动变量的优化
基础版本用数组存储所有状态,空间复杂度O(n)。优化后只用三个变量滚动:
def climbStairs(n):
if n <= 2:
return n
a, b = 1, 2
for i in range(3, n + 1):
c = a + b
a, b = b, c
return b
老师当时打了个比方:就像滚雪球,a和b不断往前滚,旧的数字就被丢掉了。这个比喻让我一下就记住了。
关键领悟
DP的本质是反向思考、正向计算。我们从目标出发分析可能性,然后从起点开始一步步算到终点。
第二步:打家劫舍 ------ 理解"状态定义"
题目:一排房子,相邻不能同时偷,求能偷到的最大金额。
状态定义
dp[i] 表示偷到第i间房子时的最大金额。注意,不一定非要偷第i间。
递推公式的推导
对于第i间房子,有两种选择:
- 不偷 :金额等于
dp[i-1] - 偷 :金额等于
dp[i-2] + nums[i](因为相邻不能偷,所以只能从i-2转移过来)
所以:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
我踩的坑
我第一次写的时候,边界条件写成了:
if n <= 1:
return 1 # ❌ 应该是 return nums[0]
别笑,我当时真的以为返回1就行了。后来才发现,如果只有一个房子,应该返回它的金额,而不是固定值1。
滚动变量的应用
由于递推只依赖前两个状态,同样可以用滚动变量:
def rob(nums):
if not nums:
return 0
if len(nums) == 1:
return nums[0]
prev2, prev1 = nums[0], max(nums[0], nums[1])
for i in range(2, len(nums)):
curr = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, curr
return prev1
关键领悟
状态定义决定了递推公式的写法。定义清楚了,公式自然就出来了。
第三步:最大子数组和 ------ 最折磨人的一道
题目:给定整数数组,找出和最大的连续子数组,返回其和。
这道题为什么难
前两道题的递推公式都比较直观,但这道题的递推思路不太一样。它不是简单的"选或不选",而是要决定是否延续前面的子数组。
状态定义
dp[i] 表示以 nums[i] 结尾的最大子数组和。注意,子数组必须包含 nums[i]。
递推公式
dp[i] = max(nums[i], dp[i-1] + nums[i])
翻译成人话就是:对于当前元素,要么重新开始 (只取当前元素),要么延续前面的子数组(带上前面一起)。
我反复踩的坑
这道题我折腾了很久,核心问题就是:在两个候选值里,我总是下意识选小的那个。
比如数组 2 -1 4 -6 3,第1步 cur = -1,两个选项:
- 延续:
2 + (-1) = 1 - 重新开始:
-1
我选了-1。但明明1更大啊!后来老师给了我一句口诀:
前面是负,不如单干;前面是正,带上更猛。
这句话的意思是:如果上一步的 dp[i-1] 是负数,那延续只会拖累当前元素,不如重新开始;如果是正数,延续通常更有利。
全负数的情况
还有一个极端情况:数组全是负数。比如 -3 -1 -5 -2 -4。
这时候每一步都应该选重新开始,因为延续只会让负数变得更小。最终答案就是数组中最大的那个负数(最接近0的那个)。
最终代码
def maxSubArray(nums):
prev = nums[0]
result = nums[0]
for num in nums[1:]:
prev = max(num, prev + num)
result = max(result, prev)
return result
注意两点:
- 循环从第二个元素开始(
nums[1:]),因为第一个元素已经在初始化时处理了 result只记录历史最大值,不是每一步都更新
一个有用的比喻
可以把这道题想象成做生意:
- 每天都有一个利润(可能是正也可能是负)
- 你可以选择今天重新开张 (只算今天的利润),也可以选择接着昨天的生意继续做(加上今天的利润)
- 你的目标是找到某一段连续时间里的最大总利润
- 如果昨天亏了,今天重新开张更明智;如果昨天赚了,接着做更划算
总结:DP三步法
经过这三道题的洗礼,我总结出DP的通用步骤:
- 定义状态 :搞清楚
dp[i]代表什么 - 推导递推公式:站在第i步,思考可以从哪些状态转移过来
- 确定初始条件和边界:前几个状态怎么定,特殊值怎么处理
至于空间优化(滚动变量),那是锦上添花的事,先把基本逻辑跑通再说。
写在最后
说实话,最大子数组和这道题我差点放弃了。学了整整两天,反复算错,一度觉得自己不是学算法的料。但坚持下来之后发现,所谓的"顿悟"其实就是把同一个道理想通了无数遍之后,突然不再怀疑自己了。