动态规划算法深度解析:从状态转移方程到工业级优化

动态规划深度解析:从状态转移方程到工业级优化

文章标签: #java #算法 #动态规划 #DP #背包问题 #面试 #状态压缩 #优化

首发地址 csdn 青山师https://blog.csdn.net/zixiao217

转载请注明出处!

目录


引言:动态规划的本质是什么

动态规划(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适用条件:

  1. 最优子结构:问题的最优解包含子问题的最优解
  2. 重叠子问题:递归求解时,子问题重复出现
  3. 无后效性:当前状态一旦确定,后续过程不受影响

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:动态规划适用的三个条件是什么?

答:

  1. 最优子结构:问题的最优解包含子问题的最优解
  2. 重叠子问题:递归求解时子问题重复出现
  3. 无后效性:当前状态一旦确定,后续过程不受影响

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的递增子序列的最小末尾元素。

关键性质:

  1. tails数组是有序的(递增)
  2. 对于新元素num,用二分查找找到第一个 >= num 的位置pos
  3. 替换tails[pos] = num:保持长度pos+1的最小末尾
  4. 如果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
  • 通常按区间长度从小到大遍历
  • 转移时枚举分割点

解题技巧:

  1. 确定区间表示的含义
  2. 枚举区间长度(从短到长)
  3. 枚举区间起点i,计算终点j
  4. 枚举分割点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. 无后效性:当前状态一旦确定,后续过程不受影响

    • 测试方法:未来是否依赖于到达当前状态的路径?

如果满足1和2,但3不满足: 尝试增加状态维度来消除后效性。

Q14:DP问题的常见优化方向有哪些?

答:

  1. 空间优化

    • 滚动数组:只保留必要的前几行/列
    • 状态压缩:用位运算表示集合状态
    • 一维优化:二维降为一维
  2. 时间优化

    • 单调队列优化:将O(n)的转移降到O(1)
    • 斜率优化:处理形如dpi = min(dpj + cost(j,i))的转移
    • 四边形不等式优化:处理区间DP
  3. 状态设计优化

    • 减少状态维度
    • 改变状态定义方式
    • 利用对称性减少状态数

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状态,常见方法:

  1. 滚动数组

    • 只保留最近的一行/列,覆盖旧数据
    • 例如:0-1背包从二维降到一维
  2. 位运算压缩

    • 用整数的二进制位表示集合
    • 例如:TSP中的mask表示已访问城市集合
  3. 对称性压缩

    • 利用问题的对称性减少状态数
    • 例如:某些区间DP只计算上半矩阵

Q18:如何调试DP代码?

答: DP代码调试技巧:

  1. 打印DP表格

    java 复制代码
    for (int i = 0; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            System.out.print(dp[i][j] + "\t");
        }
        System.out.println();
    }
  2. 用小例子手动推导

    • 选择n=3-5的小例子
    • 手动填写DP表格
    • 与程序输出对比
  3. 检查边界条件

    • dp0、dp1等初始值是否正确
    • 循环的起止范围是否正确
  4. 验证最优子结构

    • 检查状态转移是否漏掉了某些情况
    • 确保max/min选择正确

此文原创,转载请注明出处。

相关推荐
zhangjw341 小时前
第15篇:Java多线程零基础入门,进程线程、线程创建方式、线程生命周期、线程安全彻底吃透
java·开发语言·面试
Raink老师1 小时前
【AI面试临阵磨枪-086】什么是 AI Agent Skill?与传统 Function Calling、Tool 的区别?
人工智能·面试·职场和发展
黎阳之光2 小时前
数智透明·安全兜底|黎阳之光透明矿山,AI+数字孪生守护矿山生命线
人工智能·物联网·算法·安全·数字孪生
吴可可1232 小时前
控制弦高精度的样条离散化方法
算法
wuweijianlove2 小时前
算法设计中的空间复用与数据对齐优化的技术5
算法
李剑一3 小时前
小红书前端架构面试问的挺深入啊!面试官:Vue中组合式API与选项式API的设计权衡
vue.js·面试
yuan199973 小时前
基于 MATLAB PSO 工具箱的函数寻优算法
开发语言·算法·matlab
YUANQIANG20243 小时前
博弈论中势函数与势博弈构造:为什么看似 “先射箭后画靶”
算法·信息与通信
WBluuue3 小时前
Codeforces 1096 Div3(ABCDEFGH)
c++·算法