动态规划深度解析:从状态转移方程到工业级优化
文章标签: #java #算法 #动态规划 #DP #背包问题 #面试 #状态压缩 #优化
首发地址 csdn 青山师 : https://blog.csdn.net/zixiao217
转载请注明出处!
目录
- 引言:动态规划的本质是什么
- 来龙去脉:动态规划的发展史
- 理论基础:最优子结构与无后效性
- DP解题框架:五步法则
- 经典问题深度解析
- 背包问题家族:0-1、完全、多重
- 状态压缩DP与位运算
- 树形DP与区间DP
- 实战案例:股票问题与路径规划
- [对比分析:DP vs 贪心 vs 分治](#对比分析:DP vs 贪心 vs 分治)
- 性能分析:时间空间优化技巧
- 常见陷阱与最佳实践
- 面试题与参考答案
引言:动态规划的本质是什么
动态规划(Dynamic Programming,DP)是算法设计中最重要的思想之一。它通过将复杂问题分解为相对简单的子问题,并保存子问题的解来避免重复计算,从而将指数级复杂度降为多项式级。
核心认知:
动态规划的本质:
┌─────────────────────────────────────────┐
│ 递归(自顶向下) │
│ ↓ 遇到重叠子问题 │
│ 记忆化搜索(递归 + 缓存) │
│ ↓ 去掉递归栈,改为循环 │
│ 动态规划(自底向上) │
│ ↓ 发现只依赖前几个状态 │
│ 状态压缩(滚动数组) │
└─────────────────────────────────────────┘
关键洞察:
- DP不是某种特定算法,而是一种"解题方法论"
- 核心在于"状态定义"和"状态转移"
- 所有DP问题都可以抽象为"填表"过程
为什么叫"动态规划"?
1953年,Richard Bellman提出"Dynamic Programming":
- "Programming"不是编程,而是"规划"(决策序列)
- "Dynamic"表示问题状态随时间/步骤动态变化
- 最初用于多阶段决策优化问题
与线性规划(Linear Programming)的关系:
- LP:连续变量的优化
- DP:离散多阶段决策的优化
来龙去脉:动态规划的发展史
第一阶段:理论奠基(1940s-1950s)
1940年代:
- Richard Bellman在研究多阶段决策过程时提出DP概念
- 最初应用于军事运筹学(导弹轨迹优化)
1953年:
- Bellman正式发表"Dynamic Programming"理论
- 提出最优性原理(Principle of Optimality)
- 奠定了DP的数学基础
关键突破:
- 将复杂问题分解为子问题
- 引入"状态"和"决策"的概念
- 证明最优子结构性质
第二阶段:经典问题涌现(1960s-1980s)
1960s:
- 最短路径问题(Floyd-Warshall, 1962)
- 最长公共子序列(LCS, 1970s)
- 背包问题(Knapsack, 1970s)
1970s-1980s:
- 字符串编辑距离(Edit Distance, 1974)
- 矩阵链乘法(Matrix Chain Multiplication, 1981)
- 最优二叉搜索树(Optimal BST, 1973)
DP成为算法教科书的必修内容
第三阶段:计算机科学普及(1990s-2000s)
1990s:
- 生物信息学:序列比对(Needleman-Wunsch算法)
- 自然语言处理:隐马尔可夫模型(HMM)
- 语音识别:Viterbi算法
2000s:
- 强化学习:Q-learning、Value Iteration
- 编译优化:指令调度、寄存器分配
- 图算法:最大流、最小割
DP的应用领域爆发式增长
第四阶段:现代算法竞赛与工程(2010s-2026)
2010s:
- LeetCode等平台推动DP普及
- 状态压缩DP、树形DP、数位DP等高级技巧
- DP在机器学习中的应用(动态规划神经网络)
2020s-2026:
- 大规模DP的并行化(GPU加速)
- 近似DP用于强化学习
- DP与深度学习的结合(Neural DP)
现代特征:
- DP不再只是理论工具,而是工程实践的核心方法
- 从"会不会做"到"能不能优化"
- 空间优化和状态设计成为关键竞争力
理论基础:最优子结构与无后效性
1. DP适用条件
DP适用条件:
- 最优子结构:问题的最优解包含子问题的最优解
- 重叠子问题:递归求解时,子问题重复出现
- 无后效性:当前状态一旦确定,后续过程不受影响
2. 最优子结构证明
以最短路径问题为例:
定理:如果P是从A到B的最短路径,经过中间点C,
那么P中A到C的部分也是从A到C的最短路径。
证明(反证法):
假设A到C存在更短的路径P',
那么P' + C到B的路径比P更短,
与P是最短路径矛盾。
因此,最短路径问题具有最优子结构。
3. 无后效性
无后效性定义:
给定当前状态,未来状态只与当前状态有关,
与如何到达当前状态无关。
示例:爬楼梯问题
- 状态:dp[i]表示到达第i阶的方法数
- 无后效性:到达第i阶后,如何爬到第i+1阶只与当前位置i有关
- 不满足无后效性的例子:需要记住路径的TSP问题(原始版本)
解决方案:
- 增加状态维度来消除后效性
- TSP中增加"已访问集合"作为状态
4. DP vs 递归 vs 贪心
| 算法思想 | 特点 | 适用场景 | 时间复杂度 |
|---|---|---|---|
| 递归 | 自顶向下,可能重复计算 | 问题可分解 | 指数级 |
| 记忆化搜索 | 递归+缓存,自顶向下 | 重叠子问题 | O(状态数) |
| 动态规划 | 自底向上,填表 | 最优子结构+重叠子问题 | O(状态数) |
| 贪心 | 局部最优选择 | 具有贪心选择性质 | O(n log n) |
DP解题框架:五步法则
1. 定义状态
dp[i]或dp[i][j]代表什么?
关键:状态定义必须"无歧义、可推导、能归约"
好的状态定义示例:
- 爬楼梯:dp[i] = 到达第i阶的方法数
- 背包:dp[i][j] = 前i个物品,容量j时的最大价值
- LIS:dp[i] = 以nums[i]结尾的最长递增子序列长度
坏的状态定义示例:
- dp[i] = "考虑前i个元素"(太模糊,无法转移)
- dp[i] = "前i个元素的最优解"(未明确最优解的含义)
2. 状态转移方程
dp[i]与dp[i-1]等的关系是什么?
示例:
- 爬楼梯:dp[i] = dp[i-1] + dp[i-2]
- 0-1背包:dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
- LCS:dp[i][j] = dp[i-1][j-1] + 1(如果匹配)或 max(dp[i-1][j], dp[i][j-1])
3. 初始化
边界条件是什么?
示例:
- 爬楼梯:dp[0] = 1, dp[1] = 1
- 背包:dp[0][j] = 0(前0个物品价值为0)
- 编辑距离:dp[i][0] = i, dp[0][j] = j
4. 遍历顺序
如何遍历确保计算dp[i]时,所需的状态已计算?
关键原则:
- 计算当前状态时,依赖的状态必须已经计算
- 通常按照状态定义的自然顺序遍历
示例:
- 一维DP:从左到右
- 二维DP:外层遍历i,内层遍历j
- 背包问题:注意0-1背包要倒序遍历容量
5. 返回结果
最终答案是dp[n]还是其他?
示例:
- 爬楼梯:dp[n]
- 背包:dp[N][W]
- LIS:max(dp[i]) for all i
经典问题深度解析
1. 爬楼梯问题
问题:有n阶楼梯,每次可以爬1或2阶,有多少种爬法?
n=1: [1] → 1种
n=2: [1,1], [2] → 2种
n=3: [1,1,1], [1,2], [2,1] → 3种
n=4: [1,1,1,1], [1,1,2], [1,2,1], [2,1,1], [2,2] → 5种
n=5: ... → 8种
发现规律:f(n) = f(n-1) + f(n-2),即斐波那契数列!
状态定义: dp[i] = 爬到第i阶的方法数
状态转移方程: dp[i] = dp[i-1] + dp[i-2]
边界条件: dp[0] = 1, dp[1] = 1
java
/**
* 爬楼梯问题
* 时间复杂度:O(n)
* 空间复杂度:O(n)
*/
public int climbStairs(int n) {
if (n <= 2) return n;
int[] dp = new int[n + 1];
dp[0] = 1;
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
/**
* 空间优化:只用两个变量
* 时间复杂度:O(n)
* 空间复杂度:O(1)
*/
public int climbStairsOptimized(int n) {
if (n <= 2) return n;
int prev2 = 1; // dp[i-2]
int prev1 = 2; // dp[i-1]
for (int i = 3; i <= n; i++) {
int curr = prev1 + prev2; // dp[i] = dp[i-1] + dp[i-2]
prev2 = prev1; // 更新dp[i-2]
prev1 = curr; // 更新dp[i-1]
}
return prev1;
}
扩展: 如果每次可以爬1、2或3阶?
java
dp[i] = dp[i-1] + dp[i-2] + dp[i-3]
2. 最长公共子序列(LCS)
问题:求两个字符串的最长公共子序列(LCS)长度。
s1 = "ABCDE"
s2 = "ACE"
LCS = "ACE",长度3
状态定义: dp[i][j] = text10...i-1和text20...j-1的LCS长度
状态转移方程:
如果 text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
否则:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
java
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
LCS执行追踪:
输入:text1="ABCBDAB",text2="BDCABA"
初始化dp[8][7]:
'' B D C A B A
'' 0 0 0 0 0 0 0
A 0 0 0 0 1 1 1
B 0 1 1 1 1 2 2
C 0 1 1 2 2 2 2
B 0 1 1 2 2 3 3
D 0 1 2 2 2 3 3
A 0 1 2 2 3 3 4
B 0 1 2 2 3 4 4
结果:LCS长度 = 4
其中一个LCS:"BCBA"
3. 最长递增子序列(LIS)
问题:求数组的最长递增子序列(LIS)长度。
输入:[10, 9, 2, 5, 3, 7, 101, 18]
LIS:[2, 3, 7, 101],长度4
动态规划解法(O(n²)):
java
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1);
int maxLen = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
二分优化解法(O(n log n)):
java
public int lengthOfLISBinary(int[] nums) {
int[] tails = new int[nums.length];
int size = 0;
for (int num : nums) {
int left = 0, right = size;
while (left < right) {
int mid = left + (right - left) / 2;
if (tails[mid] < num) {
left = mid + 1;
} else {
right = mid;
}
}
tails[left] = num;
if (left == size) size++;
}
return size;
}
原理: tails[i]表示长度为i+1的递增子序列的最小末尾元素。数组tails是有序的,可以用二分查找。
背包问题家族:0-1、完全、多重
1. 0-1背包问题
问题:有N个物品,重量wi,价值vi,背包容量W,求能装的最大价值。每个物品只能选一次。
状态定义: dp[i][j] = 前i个物品,容量为j时能获得的最大价值
状态转移方程:
不选第i个物品:dp[i][j] = dp[i-1][j]
选第i个物品(如果容量够):dp[i][j] = dp[i-1][j-w[i]] + v[i]
dp[i][j] = max(不选, 选)
java
public int knapsack(int W, int[] weights, int[] values) {
int n = weights.length;
int[][] dp = new int[n + 1][W + 1];
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= W; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= weights[i - 1]) {
dp[i][j] = Math.max(dp[i][j],
dp[i - 1][j - weights[i - 1]] + values[i - 1]);
}
}
}
return dp[n][W];
}
空间优化(滚动数组):
java
public int knapsackOptimized(int W, int[] weights, int[] values) {
int n = weights.length;
int[] dp = new int[W + 1];
for (int i = 0; i < n; i++) {
for (int j = W; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[W];
}
为什么倒序?
正序遍历时,dp[j - weights[i]]可能已经被当前轮次更新,
导致同一个物品被多次选择(变成完全背包)。
倒序保证dp[j - weights[i]]还是上一轮(i-1)的值。
0-1背包执行追踪:
物品:重量[1, 3, 4],价值[15, 20, 30],容量W=4
初始化:dp[0..4] = [0, 0, 0, 0, 0]
第1个物品(w=1, v=15):
j=4: dp[4] = max(0, dp[3]+15) = 15
j=3: dp[3] = max(0, dp[2]+15) = 15
j=2: dp[2] = max(0, dp[1]+15) = 15
j=1: dp[1] = max(0, dp[0]+15) = 15
dp = [0, 15, 15, 15, 15]
第2个物品(w=3, v=20):
j=4: dp[4] = max(15, dp[1]+20) = max(15, 35) = 35
j=3: dp[3] = max(15, dp[0]+20) = max(15, 20) = 20
dp = [0, 15, 15, 20, 35]
第3个物品(w=4, v=30):
j=4: dp[4] = max(35, dp[0]+30) = max(35, 30) = 35
dp = [0, 15, 15, 20, 35]
结果:dp[4] = 35(选物品1和2:15+20=35)
2. 完全背包问题
每个物品可以选无限次:
java
public int completeKnapsack(int W, int[] weights, int[] values) {
int[] dp = new int[W + 1];
for (int i = 0; i < weights.length; i++) {
for (int j = weights[i]; j <= W; j++) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[W];
}
与0-1背包的区别: 正序遍历!允许重复选择。
3. 多重背包问题
每个物品有数量限制numsi:
java
public int multipleKnapsack(int W, int[] weights, int[] values, int[] nums) {
int[] dp = new int[W + 1];
int n = weights.length;
for (int i = 0; i < n; i++) {
int count = nums[i];
int k = 1;
while (count > 0) {
int use = Math.min(k, count);
int weight = use * weights[i];
int value = use * values[i];
for (int j = W; j >= weight; j--) {
dp[j] = Math.max(dp[j], dp[j - weight] + value);
}
count -= use;
k *= 2;
}
}
return dp[W];
}
二进制优化: 将数量拆分为1, 2, 4, ...的二进制组合,转化为0-1背包问题。
状态压缩DP与位运算
1. 状态压缩DP
当DP状态只依赖前一行或前几行时,可以压缩空间。
示例:最小路径和
java
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[] dp = new int[n];
dp[0] = grid[0][0];
for (int j = 1; j < n; j++) {
dp[j] = dp[j - 1] + grid[0][j];
}
for (int i = 1; i < m; i++) {
dp[0] += grid[i][0];
for (int j = 1; j < n; j++) {
dp[j] = Math.min(dp[j], dp[j - 1]) + grid[i][j];
}
}
return dp[n - 1];
}
2. 位运算状态压缩
java
/**
* 旅行商问题(TSP)状态压缩DP
* dp[mask][i] = 已经访问过的城市集合为mask,当前在i的最短距离
* mask用二进制表示城市访问状态
* 时间复杂度:O(2^n × n²)
*/
public int tsp(int[][] dist) {
int n = dist.length;
int[][] dp = new int[1 << n][n];
for (int[] row : dp) {
Arrays.fill(row, Integer.MAX_VALUE / 2);
}
dp[1][0] = 0; // 从城市0出发
for (int mask = 1; mask < (1 << n); mask++) {
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) == 0) continue;
for (int j = 0; j < n; j++) {
if ((mask & (1 << j)) != 0) continue;
int newMask = mask | (1 << j);
dp[newMask][j] = Math.min(dp[newMask][j],
dp[mask][i] + dist[i][j]);
}
}
}
int ans = Integer.MAX_VALUE;
for (int i = 1; i < n; i++) {
ans = Math.min(ans, dp[(1 << n) - 1][i] + dist[i][0]);
}
return ans;
}
树形DP与区间DP
1. 树形DP
java
/**
* 打家劫舍 III(二叉树版)
* 不能同时偷相邻节点(父子节点)
*/
public int rob(TreeNode root) {
int[] result = robHelper(root);
return Math.max(result[0], result[1]);
}
private int[] robHelper(TreeNode node) {
if (node == null) return new int[]{0, 0};
int[] left = robHelper(node.left);
int[] right = robHelper(node.right);
// result[0]: 不偷当前节点的最大值
// result[1]: 偷当前节点的最大值
int[] result = new int[2];
result[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
result[1] = node.val + left[0] + right[0];
return result;
}
2. 区间DP
java
/**
* 最长回文子序列
* dp[i][j] = s[i..j]的最长回文子序列长度
*/
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
for (int i = n - 1; i >= 0; i--) {
dp[i][i] = 1;
for (int j = i + 1; j < n; j++) {
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
实战案例:股票问题与路径规划
1. 股票问题系列
java
/**
* 股票问题:只能买卖一次
* dp[i][0] = 第i天持有股票的最大利润
* dp[i][1] = 第i天不持有股票的最大利润
*/
public int maxProfit(int[] prices) {
int n = prices.length;
if (n == 0) return 0;
int hold = -prices[0]; // 持有股票
int notHold = 0; // 不持有股票
for (int i = 1; i < n; i++) {
hold = Math.max(hold, -prices[i]); // 买入或不操作
notHold = Math.max(notHold, hold + prices[i]); // 卖出或不操作
}
return notHold;
}
/**
* 股票问题:可以买卖多次(无限制)
*/
public int maxProfitUnlimited(int[] prices) {
int n = prices.length;
if (n == 0) return 0;
int hold = -prices[0];
int notHold = 0;
for (int i = 1; i < n; i++) {
hold = Math.max(hold, notHold - prices[i]); // 可以多次买入
notHold = Math.max(notHold, hold + prices[i]);
}
return notHold;
}
/**
* 股票问题:最多买卖两次
*/
public int maxProfitTwice(int[] prices) {
int n = prices.length;
if (n == 0) return 0;
int buy1 = -prices[0], sell1 = 0;
int buy2 = -prices[0], sell2 = 0;
for (int i = 1; i < n; i++) {
buy1 = Math.max(buy1, -prices[i]);
sell1 = Math.max(sell1, buy1 + prices[i]);
buy2 = Math.max(buy2, sell1 - prices[i]);
sell2 = Math.max(sell2, buy2 + prices[i]);
}
return sell2;
}
股票问题执行追踪(只能买卖一次):
输入:prices = [7, 1, 5, 3, 6, 4]
初始化:
hold = -7(第0天买入)
notHold = 0
第1天(price=1):
hold = max(-7, -1) = -1(在更低点买入)
notHold = max(0, -1 + 1) = 0(不卖出)
第2天(price=5):
hold = max(-1, -5) = -1(保持买入状态)
notHold = max(0, -1 + 5) = 4(卖出,利润4)
第3天(price=3):
hold = max(-1, -3) = -1
notHold = max(4, -1 + 3) = 4
第4天(price=6):
hold = max(-1, -6) = -1
notHold = max(4, -1 + 6) = 5(卖出,利润5)
第5天(price=4):
hold = max(-1, -4) = -1
notHold = max(5, -1 + 4) = 5
结果:最大利润 = 5
(在第1天买入price=1,第4天卖出price=6,利润=5)
2. 路径规划
java
/**
* 最小路径和
* dp[i][j] = 到达(i,j)的最小路径和
*/
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0];
for (int i = 1; i < m; i++) dp[i][0] = dp[i-1][0] + grid[i][0];
for (int j = 1; j < n; j++) dp[0][j] = dp[0][j-1] + grid[0][j];
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
}
}
return dp[m-1][n-1];
}
/**
* 不同路径(机器人从左上到右下,只能向右或向下)
*/
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
/**
* 不同路径 II(有障碍物)
*/
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length, n = obstacleGrid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = obstacleGrid[0][0] == 0 ? 1 : 0;
for (int i = 1; i < m; i++)
dp[i][0] = obstacleGrid[i][0] == 0 ? dp[i-1][0] : 0;
for (int j = 1; j < n; j++)
dp[0][j] = obstacleGrid[0][j] == 0 ? dp[0][j-1] : 0;
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (obstacleGrid[i][j] == 0) {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
}
return dp[m-1][n-1];
}
路径规划执行追踪(不同路径 3×3):
初始化:
1 1 1
1 0 0
1 0 0
第1行:
1 1 1
1 2 3(dp[1][1]=1+1=2, dp[1][2]=2+1=3)
1 0 0
第2行:
1 1 1
1 2 3
1 3 6(dp[2][1]=1+2=3, dp[2][2]=3+3=6)
结果:6条不同路径
路径列举:
1. 右 → 右 → 下 → 下
2. 右 → 下 → 右 → 下
3. 右 → 下 → 下 → 右
4. 下 → 右 → 右 → 下
5. 下 → 右 → 下 → 右
6. 下 → 下 → 右 → 右
3. 打家劫舍问题
java
/**
* 打家劫舍:不能偷相邻房屋
* dp[i] = 偷到第i个房屋时的最大金额
*/
public int rob(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
if (n == 1) return nums[0];
int prev2 = nums[0];
int prev1 = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
int curr = Math.max(prev1, prev2 + nums[i]);
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
状态转移: dp[i] = max(dp[i-1], dp[i-2] + nums[i])
- 不偷第i个:金额 = dpi-1
- 偷第i个:金额 = dpi-2 + numsi
执行追踪(nums = 2, 7, 9, 3, 1):
初始化:
prev2 = 2(偷第0个)
prev1 = max(2, 7) = 7(偷第1个更优)
第2个房屋(金额9):
curr = max(7, 2 + 9) = 11(偷第0个和第2个)
prev2 = 7, prev1 = 11
第3个房屋(金额3):
curr = max(11, 7 + 3) = 11(不偷第3个更优)
prev2 = 11, prev1 = 11
第4个房屋(金额1):
curr = max(11, 11 + 1) = 12(偷第4个)
prev2 = 11, prev1 = 12
结果:12(偷第0、2、4个房屋:2+9+1=12)
4. 解码方法
java
/**
* 数字解码:1-26对应A-Z,求解码方法数
* s = "226" → "BBF", "BZ", "VF" → 3种
*/
public int numDecodings(String s) {
int n = s.length();
if (n == 0 || s.charAt(0) == '0') return 0;
int prev2 = 1; // 空字符串有1种解码
int prev1 = 1; // 第一个字符非'0',有1种解码
for (int i = 2; i <= n; i++) {
int curr = 0;
// 单独解码当前数字(如果当前数字不是'0')
if (s.charAt(i - 1) != '0') {
curr += prev1;
}
// 与前一个数字组合解码(如果组合在10-26之间)
int twoDigit = (s.charAt(i - 2) - '0') * 10 + (s.charAt(i - 1) - '0');
if (twoDigit >= 10 && twoDigit <= 26) {
curr += prev2;
}
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
对比分析:DP vs 贪心 vs 分治
| 算法思想 | 子问题关系 | 子问题重叠 | 适用场景 |
|---|---|---|---|
| 分治 | 独立 | 否 | 快速排序、归并排序 |
| 贪心 | 独立 | 否 | 具有贪心选择性质 |
| DP | 重叠 | 是 | 最优子结构 |
经典对比:
0-1背包:DP(物品不可分割)
分数背包:贪心(物品可分割)
最短路径:DP(Bellman-Ford)或贪心(Dijkstra)
最小生成树:贪心(Prim、Kruskal)
性能分析:时间空间优化技巧
1. 滚动数组优化
java
/**
* 0-1背包一维优化
* 空间从O(N×W)降到O(W)
*/
public int knapsack(int W, int[] w, int[] v) {
int[] dp = new int[W + 1];
for (int i = 0; i < w.length; i++) {
for (int j = W; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[W];
}
2. 单调队列优化
java
/**
* 多重背包单调队列优化
* 时间复杂度:O(N×W)
*/
public int knapsackMonotone(int W, int[] w, int[] v, int[] nums) {
int[] dp = new int[W + 1];
for (int i = 0; i < w.length; i++) {
for (int r = 0; r < w[i]; r++) {
Deque<Integer> deque = new LinkedList<>();
for (int j = r, k = 0; j <= W; j += w[i], k++) {
int val = dp[j] - k * v[i];
while (!deque.isEmpty() && deque.peekLast() < val) {
deque.pollLast();
}
deque.offerLast(val);
if (deque.peekFirst() == dp[j - nums[i] * w[i]] - (k - nums[i]) * v[i]) {
deque.pollFirst();
}
dp[j] = deque.peekFirst() + k * v[i];
}
}
}
return dp[W];
}
3. 贪心预处理
java
/**
* 如果背包容量很大,但物品价值总和不大
* 可以转换维度:dp[i] = 达到价值i所需的最小重量
*/
public int knapsackValue(int W, int[] w, int[] v) {
int sumV = Arrays.stream(v).sum();
int[] dp = new int[sumV + 1];
Arrays.fill(dp, Integer.MAX_VALUE / 2);
dp[0] = 0;
for (int i = 0; i < w.length; i++) {
for (int j = sumV; j >= v[i]; j--) {
dp[j] = Math.min(dp[j], dp[j - v[i]] + w[i]);
}
}
for (int i = sumV; i >= 0; i--) {
if (dp[i] <= W) return i;
}
return 0;
}
常见陷阱与最佳实践
陷阱1:状态定义不清晰
java
// 错误:dp[i]表示"考虑前i个元素"
// 这种定义太模糊,无法写出转移方程
// 正确:dp[i]表示"以第i个元素结尾的子数组的最大和"
// 转移:dp[i] = max(nums[i], dp[i-1] + nums[i])
最佳实践:
- 先用文字明确描述状态,确保无歧义再写代码
- 用小例子验证状态定义是否合理
陷阱2:0-1背包正序遍历
java
// 错误:正序遍历
for (int j = w[i]; j <= W; j++) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]); // 变成完全背包!
}
最佳实践:
- 0-1背包倒序遍历容量:
for (int j = W; j >= w[i]; j--) - 完全背包正序遍历:
for (int j = w[i]; j <= W; j++)
陷阱3:忽略初始化条件
java
// 错误:
int[] dp = new int[n + 1];
// dp[0]默认为0,但爬楼梯dp[0]应该是1
// 正确:
int[] dp = new int[n + 1];
dp[0] = 1; // 地面有一种方法(不动)
dp[1] = 1; // 第一阶只有一种方法
最佳实践:
- 仔细分析边界情况,如dp0、dp1等
- 用小例子验证初始化是否正确
陷阱4:硬套模板而不理解题意
java
// 看到"最大价值"就用背包
// 实际是贪心问题(分数背包)
最佳实践:
- 先画表格手动推导小例子,理解状态转移的本质
- 优先尝试滚动数组优化
- 理解0-1背包、完全背包、多重背包的核心区别
陷阱5:整数溢出
java
// 错误:
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
// 如果dp[j]初始化为Integer.MAX_VALUE,相加可能溢出
// 正确:
Arrays.fill(dp, Integer.MIN_VALUE); // 或根据题意初始化
dp[0] = 0;
最佳实践:
- 初始化时考虑边界值
- 使用long类型处理大数
- 检查dp值是否在有效范围内
陷阱6:空间优化过度导致错误
java
// 错误:二维DP压缩成一维,但遍历顺序不对
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= W; j++) { // 正序遍历!
if (j >= w[i]) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
}
最佳实践:
- 先写出二维版本,验证正确后再压缩
- 压缩后用小例子验证
- 注意遍历顺序的调整
面试题与参考答案
Q1:动态规划适用的三个条件是什么?
答:
- 最优子结构:问题的最优解包含子问题的最优解
- 重叠子问题:递归求解时子问题重复出现
- 无后效性:当前状态一旦确定,后续过程不受影响
Q2:0-1背包和完全背包的代码区别是什么?
答: 核心区别是遍历顺序不同
0-1背包(每个物品只能选一次):
java
for (int j = W; j >= w[i]; j--) { // 倒序!
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
完全背包(每个物品可以选无限次):
java
for (int j = w[i]; j <= W; j++) { // 正序!
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
原因:0-1背包倒序保证dpj-w\[i]还是上一轮(i-1)的值,防止同一个物品被重复选择。完全背包正序允许使用当前轮次已经更新的值,实现重复选择。
Q3:爬楼梯问题,状态转移方程是什么?如何优化空间?
答:
状态转移方程:dp[i] = dp[i-1] + dp[i-2]
空间优化:
java
public int climbStairs(int n) {
if (n <= 2) return n;
int prev2 = 1; // dp[i-2]
int prev1 = 2; // dp[i-1]
for (int i = 3; i <= n; i++) {
int curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
Q4:最长公共子序列(LCS)的状态转移方程是什么?
答:
如果 text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
否则:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
Q5:什么情况下可以对DP进行状态压缩?
答: 当当前状态只依赖前一行(或前几行)时,可以压缩空间。
示例1:0-1背包
- 原始:dpij依赖dpi-1j和dpi-1j-w\[i]
- 压缩:一维dpj,倒序遍历
示例2:最小路径和
- 原始:dpij依赖dpi-1j和dpij-1
- 压缩:一维dpj,利用dpj存上一行同列的值
Q6:LIS(最长递增子序列)的O(n log n)解法原理是什么?
答: 维护一个数组tails,其中tails[i]表示长度为i+1的递增子序列的最小末尾元素。
关键性质:
tails数组是有序的(递增)- 对于新元素num,用二分查找找到第一个 >= num 的位置pos
- 替换
tails[pos] = num:保持长度pos+1的最小末尾 - 如果pos等于当前长度,说明可以扩展更长的子序列
时间复杂度:O(n log n),每个元素二分查找O(log n)。
Q7:记忆化搜索和递推DP有什么区别?如何选择?
答:
| 特性 | 记忆化搜索 | 递推DP |
|---|---|---|
| 方向 | 自顶向下 | 自底向上 |
| 代码直观性 | 高(接近原始递归) | 中 |
| 空间开销 | 递归栈+备忘录 | 仅DP数组 |
| 状态访问 | 只计算需要的状态 | 计算所有状态 |
| 适用场景 | 状态多但只访问部分 | 状态都需要计算 |
选择建议:
- 状态转移复杂、理解困难 → 记忆化搜索
- 追求极致性能、状态都需要 → 递推DP
- 大部分状态不会被访问到 → 记忆化搜索
Q8:股票问题系列的核心思路是什么?
答: 股票问题的通用框架:
状态定义:
dp[i][k][0] = 第i天,最多交易k次,不持有股票的最大利润
dp[i][k][1] = 第i天,最多交易k次,持有股票的最大利润
状态转移:
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])
(不操作,或买入)
base case:
dp[0][k][0] = 0
dp[0][k][1] = -prices[0]
Q9:区间DP的特点和解题技巧?
答: 区间DP的特点:
- 状态表示区间:dpij
- 通常按区间长度从小到大遍历
- 转移时枚举分割点
解题技巧:
- 确定区间表示的含义
- 枚举区间长度(从短到长)
- 枚举区间起点i,计算终点j
- 枚举分割点k,进行状态转移
Q10:树形DP的状态设计技巧?
答: 树形DP的核心思想:
以节点为根进行DP:
- 状态通常与"选/不选当前节点"相关
- 先递归处理子树,再合并结果
- 利用后序遍历的顺序
常见状态设计:
- dp[node][0] = 不选当前节点的最优值
- dp[node][1] = 选当前节点的最优值
转移时考虑子节点的状态组合。
Q11:编辑距离(Edit Distance)的DP解法?
答: 编辑距离是将一个字符串转换为另一个字符串所需的最少操作数(插入、删除、替换)。
java
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(dp[i - 1][j - 1], // 替换
Math.min(dp[i][j - 1], // 插入
dp[i - 1][j])) // 删除
+ 1;
}
}
}
return dp[m][n];
}
状态定义: dp[i][j] = word10...i-1转换为word20...j-1的最少操作数
Q12:矩阵链乘法的DP解法?
答: 给定n个矩阵的链,找到最优的乘法顺序使得标量乘法次数最少。
java
public int matrixChainOrder(int[] dims) {
int n = dims.length - 1;
int[][] dp = new int[n][n];
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
dp[i][j] = Integer.MAX_VALUE;
for (int k = i; k < j; k++) {
int cost = dp[i][k] + dp[k + 1][j] + dims[i] * dims[k + 1] * dims[j + 1];
dp[i][j] = Math.min(dp[i][j], cost);
}
}
}
return dp[0][n - 1];
}
状态定义: dp[i][j] = 计算矩阵Ai到Aj的最小乘法次数
Q13:如何判断一个问题是否可以用DP解决?
答: 检查三个条件:
-
最优子结构:问题的最优解可以由子问题的最优解构造
- 测试方法:假设已知子问题的最优解,能否构造原问题的最优解?
-
重叠子问题:递归求解时,相同的子问题被重复计算
- 测试方法:画出递归树,观察是否有重复的子问题
-
无后效性:当前状态一旦确定,后续过程不受影响
- 测试方法:未来是否依赖于到达当前状态的路径?
如果满足1和2,但3不满足: 尝试增加状态维度来消除后效性。
Q14:DP问题的常见优化方向有哪些?
答:
-
空间优化
- 滚动数组:只保留必要的前几行/列
- 状态压缩:用位运算表示集合状态
- 一维优化:二维降为一维
-
时间优化
- 单调队列优化:将O(n)的转移降到O(1)
- 斜率优化:处理形如dpi = min(dpj + cost(j,i))的转移
- 四边形不等式优化:处理区间DP
-
状态设计优化
- 减少状态维度
- 改变状态定义方式
- 利用对称性减少状态数
Q15:爬楼梯问题的通项公式是什么?
答: 爬楼梯问题对应斐波那契数列。
递推关系:dp[n] = dp[n-1] + dp[n-2]
通项公式:dp[n] = (φ^n - (1-φ)^n) / √5
其中 φ = (1+√5)/2 ≈ 1.618(黄金比例)
因此 dp[n] ≈ O(1.618^n)
DP算法时间复杂度: O(n)(每个状态计算一次)
空间复杂度: O(1)(优化后)
Q16:多重背包的二进制优化原理是什么?
答: 将数量numsi拆分为1, 2, 4, ..., 2^k, remainder的形式。
原理: 任何整数都可以表示为二进制数的和。
例如:nums[i] = 13
拆分:1, 2, 4, 6(注意最后一个不是8,而是13-1-2-4=6)
这样13个物品被拆分为4组:
- 第1组:1个物品(重量w,价值v)
- 第2组:2个物品(重量2w,价值2v)
- 第3组:4个物品(重量4w,价值4v)
- 第4组:6个物品(重量6w,价值6v)
通过选择这些组的子集,可以组合出0-13中的任意数量。
例如:选3个 = 第1组 + 第2组(1+2=3)
选5个 = 第1组 + 第4组(1+4=5)
选7个 = 第1组 + 第2组 + 第4组(1+2+4=7)
时间复杂度从O(N×W×C)降到O(N×W×logC)
Q17:DP中的"状态压缩"是什么意思?
答: 状态压缩是指用更紧凑的方式表示DP状态,常见方法:
-
滚动数组:
- 只保留最近的一行/列,覆盖旧数据
- 例如:0-1背包从二维降到一维
-
位运算压缩:
- 用整数的二进制位表示集合
- 例如:TSP中的mask表示已访问城市集合
-
对称性压缩:
- 利用问题的对称性减少状态数
- 例如:某些区间DP只计算上半矩阵
Q18:如何调试DP代码?
答: DP代码调试技巧:
-
打印DP表格
javafor (int i = 0; i <= n; i++) { for (int j = 0; j <= W; j++) { System.out.print(dp[i][j] + "\t"); } System.out.println(); } -
用小例子手动推导
- 选择n=3-5的小例子
- 手动填写DP表格
- 与程序输出对比
-
检查边界条件
- dp0、dp1等初始值是否正确
- 循环的起止范围是否正确
-
验证最优子结构
- 检查状态转移是否漏掉了某些情况
- 确保max/min选择正确
此文原创,转载请注明出处。