1. 动态规划专题训练概述
今天进入算法训练营的第39天,我们继续深入动态规划这个既让人爱又让人恨的专题。动态规划作为算法领域的重头戏,其重要性怎么强调都不为过——从互联网大厂的算法面试到各类编程竞赛,DP问题永远占据着举足轻重的地位。本专题我们将聚焦几个经典DP问题及其变种,通过拆解问题本质、分析状态转移、优化空间复杂度这三个关键步骤,带你真正掌握动态规划的精髓。
我在刷题和面试辅导过程中发现,很多同学卡在动态规划问题上的根本原因,往往不是coding能力不足,而是缺乏系统化的解题框架。这次我们就用"庖丁解牛"的方式,把每个问题分解到最基础的思考单元,让你看到DP问题背后的通用模式。特别提醒:今天涉及的背包问题变种和股票买卖问题,是面试中出现频率最高的DP题型,务必吃透每个细节。
2. 完全背包问题深度解析
2.1 完全背包与01背包的本质区别
完全背包问题与基础01背包的核心区别在于物品的可重复选取。在LeetCode 518.零钱兑换II这个问题中,我们需要计算用给定面额的硬币凑成指定金额的组合数。这与传统的01背包不同——每种硬币可以无限使用,这直接影响了我们的状态转移方程设计。
我刚开始接触这个问题时,犯过一个典型错误:直接套用01背包的二维DP解法,结果发现计算出的组合数总是偏少。后来通过画状态转移图才明白,完全背包需要调整物品的遍历顺序。具体来说,在01背包中我们逆序遍历容量是为了防止重复计算,而完全背包恰恰需要正序遍历来允许重复选取。
python复制def change(amount, coins):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for i in range(coin, amount + 1):
dp[i] += dp[i - coin]
return dp[amount]
2.2 排列与组合的微妙差异
很多同学容易混淆LeetCode 377.组合总和IV与前面的零钱兑换问题。虽然题目描述相似,但前者求的是排列数,后者求的是组合数。这意味着[1,2]和[2,1]在377题中被视为不同的方案,而在518题中视为相同。
这个差异反映在代码上,只需要调换两层循环的顺序:
python复制def combinationSum4(nums, target):
dp = [0] * (target + 1)
dp[0] = 1
for i in range(1, target + 1):
for num in nums:
if i >= num:
dp[i] += dp[i - num]
return dp[target]
关键提示:当问题涉及顺序差异时,先遍历背包容量再遍历物品;当不考虑顺序时,先遍历物品再遍历背包容量。这个规律在面试中非常实用。
3. 爬楼梯问题的进阶变种
3.1 基础爬楼梯的DP解法
LeetCode 70.爬楼梯是动态规划最经典的入门题。其核心状态转移方程dp[i] = dp[i-1] + dp[i-2]完美诠释了DP的核心思想——将问题分解为子问题。但很多同学可能不知道,这个问题实际上是完全背包问题的一个特例。
我在教学过程中发现,用背包问题的视角重新理解爬楼梯,可以帮助建立知识之间的联系。把每次爬1阶或2阶看作两种物品(重量分别为1和2),楼梯总阶数看作背包容量,问题就转化为求装满背包的方法数。
3.2 进阶变种:步数限制扩展
当题目变为LeetCode 746.使用最小花费爬楼梯时,我们需要在状态转移中加入代价计算。此时dp数组的含义需要调整为到达第i阶的最小花费:
python复制def minCostClimbingStairs(cost):
n = len(cost)
dp = [0] * (n + 1)
for i in range(2, n + 1):
dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
return dp[n]
更复杂的变种如LeetCode 面试题08.01.三步问题,将步数选择扩展到3步。这类问题的通用解法是:
python复制def waysToStep(n):
if n < 3: return n
dp = [0]*(n+1)
dp[1], dp[2], dp[3] = 1, 2, 4
for i in range(4, n+1):
dp[i] = (dp[i-1] + dp[i-2] + dp[i-3]) % 1000000007
return dp[n]
4. 股票买卖问题的动态规划框架
4.1 状态定义的通用模式
股票买卖系列问题(LeetCode 121、122、123、188等)是检验DP理解程度的试金石。这类问题的关键在于定义清晰的状态。经过多次实战,我总结出一个通用框架:
- dp[i][k][0/1]:第i天,最多进行k次交易,0表示不持有股票,1表示持有股票
- 初始状态:
- dp[0][k][0] = 0
- dp[0][k][1] = -prices[0]
- 状态转移方程:
- dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
- dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
4.2 空间复杂度优化技巧
对于LeetCode 121.买卖股票的最佳时机(k=1),我们可以将空间复杂度优化到O(1):
python复制def maxProfit(prices):
min_price = float('inf')
max_profit = 0
for price in prices:
min_price = min(min_price, price)
max_profit = max(max_profit, price - min_price)
return max_profit
对于LeetCode 122.买卖股票的最佳时机II(k=∞),状态转移简化为:
python复制def maxProfit(prices):
dp0, dp1 = 0, -prices[0]
for i in range(1, len(prices)):
dp0, dp1 = max(dp0, dp1 + prices[i]), max(dp1, dp0 - prices[i])
return dp0
实战经验:当k=1或k=∞时,通常可以优化掉一个维度;当k为任意值时,需要保留完整的二维DP表格。在面试中,面试官往往会追问如何优化空间复杂度。
5. 动态规划常见误区与调试技巧
5.1 初始化陷阱
在解决LeetCode 279.完全平方数时,很多同学会错误地初始化dp数组。正确的做法是:
python复制def numSquares(n):
dp = [float('inf')] * (n + 1)
dp[0] = 0 # 必须显式初始化
for i in range(1, int(n**0.5)+1):
square = i*i
for j in range(square, n+1):
dp[j] = min(dp[j], dp[j-square]+1)
return dp[n]
常见错误包括:
- 忘记初始化dp[0]=0
- 将dp数组初始化为0,导致min比较失效
- 平方数的上界计算错误
5.2 遍历顺序的玄机
在解决LeetCode 139.单词拆分时,遍历顺序的选择直接影响解题效率:
python复制def wordBreak(s, wordDict):
wordSet = set(wordDict)
dp = [False] * (len(s)+1)
dp[0] = True
for i in range(1, len(s)+1):
for j in range(i):
if dp[j] and s[j:i] in wordSet:
dp[i] = True
break
return dp[len(s)]
这里外层遍历背包容量(字符串长度),内层遍历物品(单词长度)。如果反过来,会导致时间复杂度飙升。我在实际测试中发现,正确的遍历顺序可以将运行时间从O(n^3)优化到O(n^2)。
5.3 打印DP表格的调试技巧
当DP解法出现错误时,最有效的调试方法是打印完整的DP表格。以LeetCode 72.编辑距离为例:
python复制def minDistance(word1, word2):
m, n = len(word1), len(word2)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(m+1): dp[i][0] = i
for j in range(n+1): dp[0][j] = j
for i in range(1, m+1):
for j in range(1, n+1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
# 调试用打印
for row in dp:
print(row)
return dp[m][n]
通过观察表格中的数值变化,可以快速定位状态转移的错误点。这个方法在面试白板coding时同样适用——先画出小规模例子的DP表格,再编写代码。