动态规划(Dynamic Programming, DP)是一种优化算法设计技术,用于解决具有重叠子问题和最优子结构性质的问题。动态规划通过将问题拆解为子问题,并将子问题的结果保存起来,以避免重复计算,从而提高算法效率。
动态规划的基本思想
- 重叠子问题:将问题分解为重复的子问题,这些子问题在不同的地方被多次求解。
- 最优子结构:问题的最优解可以通过其子问题的最优解来构造。
动态规划的步骤
- 定义状态:确定问题的状态和状态之间的转移关系。
- 状态转移方程:写出状态转移方程,描述如何从一个状态转换到另一个状态。
- 初始化:定义初始状态及其对应的值。
- 计算状态:根据状态转移方程计算所有状态的值。
- 返回结果:从计算的状态中得到最终结果。
经典动态规划问题
以下是几个经典的动态规划问题及其逐步讲解,包括背包问题、最长公共子序列(LCS)和最小路径和。
1. 背包问题(0/1 Knapsack Problem)
问题描述:给定一个背包的容量和若干个物品,每个物品有一个重量和一个价值。问如何选择物品使得在不超过背包容量的情况下,物品的总价值最大。
步骤:
-
定义状态:
dp[i][w]
表示前i
个物品中,在容量为w
的背包中可以获得的最大价值。
-
状态转移方程:
- 如果不选择第
i
个物品:dp[i][w] = dp[i-1][w]
- 如果选择第
i
个物品:dp[i][w] = dp[i-1][w - weight[i]] + value[i]
- 综合考虑:
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])
- 如果不选择第
-
初始化:
dp[0][w] = 0
,表示没有物品时,最大价值为 0。
-
计算状态:
- 根据状态转移方程逐步计算所有状态的值。
-
返回结果:
dp[n][W]
为所求的最大价值,其中n
是物品总数,W
是背包容量。
代码实现:
function knapsack(weights, values, W) {
const n = weights.length;
const dp = Array.from({ length: n + 1 }, () => Array(W + 1).fill(0));
for (let i = 1; i <= n; i++) {
for (let w = 0; w <= W; w++) {
if (weights[i - 1] <= w) {
dp[i][w] = Math.max(dp[i - 1][w], dp[i - 1][w - weights[i - 1]] + values[i - 1]);
} else {
dp[i][w] = dp[i - 1][w];
}
}
}
return dp[n][W];
}
// 示例
const weights = [1, 2, 3];
const values = [60, 100, 120];
const W = 5;
console.log(knapsack(weights, values, W)); // 输出: 220
2. 最长公共子序列(Longest Common Subsequence, LCS)
问题描述:给定两个字符串,找出它们的最长公共子序列(LCS)的长度。子序列是指删除一些字符(可以不删除任何字符),不改变其余字符的顺序,得到的子序列。
步骤:
-
定义状态:
dp[i][j]
表示字符串A
的前i
个字符与字符串B
的前j
个字符的最长公共子序列的长度。
-
状态转移方程:
- 如果
A[i-1] == B[j-1]
:dp[i][j] = dp[i-1][j-1] + 1
- 如果
A[i-1] != B[j-1]
:dp[i][j] = max(dp[i-1][j], dp[i][j-1])
- 如果
-
初始化:
dp[0][j] = 0
和dp[i][0] = 0
,表示一个字符串为空时的 LCS 长度为 0。
-
计算状态:
- 根据状态转移方程逐步计算所有状态的值。
-
返回结果:
dp[m][n]
为所求的 LCS 长度,其中m
和n
是两个字符串的长度。
代码实现:
function longestCommonSubsequence(A, B) {
const m = A.length;
const n = B.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (A[i - 1] === B[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];
}
// 示例
const A = "ABCBDAB";
const B = "BDCAB";
console.log(longestCommonSubsequence(A, B)); // 输出: 4
3. 最小路径和(Minimum Path Sum)
问题描述:给定一个 m x n 的网格,每个格子中有一个非负整数。起始点在左上角,终点在右下角,只能向右或向下移动。求从起始点到终点的最小路径和。
步骤:
-
定义状态:
dp[i][j]
表示从(0, 0)
到(i, j)
的最小路径和。
-
状态转移方程:
dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])
- 需要处理边界条件:
dp[0][j] = dp[0][j-1] + grid[0][j]
dp[i][0] = dp[i-1][0] + grid[i][0]
-
初始化:
dp[0][0] = grid[0][0]
,表示起始点的路径和为其自身的值。
-
计算状态:
- 根据状态转移方程逐步计算所有状态的值。
-
返回结果:
dp[m-1][n-1]
为从起始点到终点的最小路径和。
代码实现:
function minPathSum(grid) {
const m = grid.length;
const n = grid[0].length;
const dp = Array.from({ length: m }, () => Array(n).fill(0));
dp[0][0] = grid[0][0];
// 初始化第一行
for (let j = 1; j < n; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
// 初始化第一列
for (let i = 1; i < m; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
// 填充 dp 表
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[i][j] = grid[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[m - 1][n - 1];
}
// 示例
const grid = [
[1, 3, 1],
[1, 5, 1],
[4, 2, 1]
];
console.log(minPathSum(grid)); // 输出: 7
总结
- 动态规划 适用于具有重叠子问题和最优子结构性质的问题。
- 定义状态:明确每个子问题的状态。
- 状态转移方程:描述如何从子问题的解构造出当前问题的解。
- 初始化:设定边界条件。
- 计算状态:按照状态转移方程计算状态值。
- 返回结果:从计算得到的状态中获取最终结果。通过动态规划,你可以有效地解决许多复杂的优化问题,减少计算时间并提高效率。