1 初探动态规划
Q:爬楼梯给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,请问有多少种方案可以爬到楼顶。
如图所示,对于一个 3 阶楼梯,共有 3 种方案可以爬到楼顶。
/* 回溯 */
void backtrack(List<Integer> choices, int state, int n, List<Integer> res) {
// 当爬到第 n 阶时,方案数量加 1
if (state == n)
res.set(0, res.get(0) + 1);
// 遍历所有选择
for (Integer choice : choices) {
// 剪枝:不允许越过第 n 阶
if (state + choice > n)
break;
// 尝试:做出选择,更新状态
backtrack(choices, state + choice, n, res);
// 回退
}
}
/* 爬楼梯:回溯 */
int climbingStairsBacktrack(int n) {
List<Integer> choices = Arrays.asList(1, 2); // 可选择向上爬 1 或 2 阶
int state = 0; // 从第 0 阶开始爬
List<Integer> res = new ArrayList<>();
res.add(0); // 使用 res[0] 记录方案数量
backtrack(choices, state, n, res);
return res.get(0);
}
1.1 方法一:暴力搜索
𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2]
/* 搜索 */
int dfs(int i) {
// 已知 dp[1] 和 dp[2] ,返回之
if (i == 1 || i == 2)
return i;
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1) + dfs(i - 2);
return count;
}
/* 爬楼梯:搜索 */
int climbingStairsDFS(int n) {
return dfs(n);
}
1.2 方法二:记忆化搜索
int dfs(int i, int[] mem) {
// 已知 dp[1] 和 dp[2] ,返回之
if (i == 1 || i == 2)
return i;
// 若存在记录 dp[i] ,则直接返回之
if (mem[i] != -1)
return mem[i];
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1, mem) + dfs(i - 2, mem);
// 记录 dp[i]
mem[i] = count;
return count;
}
/* 爬楼梯:记忆化搜索 */
int climbingStairsDFSMem(int n) {
// mem[i] 记录爬到第 i 阶的方案总数,-1 代表无记录
int[] mem = new int[n + 1];
//将数组所有元素填充为-1
Arrays.fill(mem, -1);
return dfs(n, mem);
}
1.3 方法三:动态规划
/* 爬楼梯:动态规划 */
int climbingStairsDP(int n) {
if (n == 1 || n == 2)
return n;
// 初始化 dp 表,用于存储子问题的解
int[] dp = new int[n + 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];
}
下图模拟了上述代码的执行过程:
1.4 空间优化
/* 爬楼梯:空间优化后的动态规划 */
int climbingStairsDPComp(int n) {
if (n == 1 || n == 2)
return n;
int a = 1, b = 2;
for (int i = 3; i <= n; i++) {
int tmp = b;
b = a + b;
a = tmp;
}
return b;
}
2 动态规划问题特性
1最优子结构:原问题的最优解可以通过子问题的最优解来构造。
2无后效性:一个阶段的状态一旦确定,就不受后续决策的影响,有助于简化问题的建模和解决。
2.1最优子结构
Q:爬楼梯最小代价给定一个楼梯,你每步可以上 1 阶或者 2 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 𝑐𝑜𝑠𝑡 ,其中 𝑐𝑜𝑠𝑡[𝑖] 表示在第 𝑖 个台阶需要付出的代价, 𝑐𝑜𝑠𝑡[0] 为地面起始点。请计算最少需要付出多少代价才能到达顶部?
/* 爬楼梯最小代价:动态规划 */
int minCostClimbingStairsDP(int[] cost) {
//获取楼梯总层数
int n = cost.length - 1;
if (n == 1 || n == 2)
return cost[n];
// 初始化 dp 表,用于存储子问题的解,数组的长度为n+1,表示楼梯的总层数。
int[] dp = new int[n + 1];
// dp[i] 表示爬到第i层楼梯的最小代价,初始状态是第一层和第二层的代价。
dp[1] = cost[1];
dp[2] = cost[2];
// 状态转移:从较小子问题逐步求解较大子问题
for (int i = 3; i <= n; i++) {
// 取 dp[i - 1] 和 dp[i - 2] 中的较小值,因为你可以选择每次爬 1 层或 2 层楼梯,所以选择代价较小的那个。
//+ cost[i] 表示加上爬到第 i 层楼梯的实际代价。因为你需要支付当前层楼梯的代价。
//dp[i - 1] 和 dp[i - 2] 分别表示爬到第 i - 1 层和第 i - 2 层楼梯的最小代价。你可以将它们理解为从底层到
// 第 i - 1 层和从底层到第 i - 2 层的最小步数(或最小代价)。然后,通过比较这两个值,选择其中较小的一个,再加上爬到
// 第 i 层楼梯的代价 cost[i]。这样得到的 dp[i] 就是从底层到第 i 层的最小步数或最小代价。
dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];
}
return dp[n];
}
空间优化:
/* 爬楼梯最小代价:动态规划 */
int minCostClimbingStairsDPComp(int[] cost) {
//获取楼梯总层数
int n = cost.length - 1;
if (n == 1 || n == 2)
return cost[n];
// 初始化 dp 表,用于存储子问题的解,数组的长度为n+1,表示楼梯的总层数。
// int[] dp = new int[n + 1];
// dp[i] 表示爬到第i层楼梯的最小代价,初始状态是第一层和第二层的代价。
int a = cost[1];
int b = cost[2];
// 状态转移:从较小子问题逐步求解较大子问题
for (int i = 3; i <= n; i++) {
int temp=b;
b = Math.min(a, temp) + cost[i];
a=temp;
}
return b;
}
2.2 无后效性
Q:带约束爬楼梯给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶, 但不能连续两轮跳 1 阶 ,请问有多少种方案可以爬到楼顶。
例如下图 ,爬上第 3 阶仅剩 2 种可行方案,其中连续三次跳 1 阶的方案不满足约束条件,因此被舍弃。
最终,返回 𝑑𝑝[𝑛, 1] + 𝑑𝑝[𝑛, 2] 即可,两者之和代表爬到第 𝑛 阶的方案总数。
/* 带约束爬楼梯:动态规划 */
int climbingStairsConstraintDP(int n) {
if (n == 1 || n == 2) {
return 1;
}
// 初始化 dp 表,用于存储子问题的解
int[][] dp = new int[n + 1][3];
// 初始状态:预设最小子问题的解
dp[1][1] = 1;// 表示从底层到第 1 层,且当前层采用从当前层向上爬一层的方式的数量是 1。
dp[1][2] = 0;
dp[2][1] = 0;//: 表示从底层到第 2 层,且当前层采用从当前层向上爬一层的方式的数量是 0。因为不能连续爬1
dp[2][2] = 1;
// 状态转移:从较小子问题逐步求解较大子问题
for (int i = 3; i <= n; i++) {
dp[i][1] = dp[i - 1][2];
dp[i][2] = dp[i - 2][1] + dp[i - 2][2];
}
return dp[n][1] + dp[n][2];
}
Q:爬楼梯与障碍生成
给定一个共有 n 阶的楼梯,你每步可以上 1 阶或者 2 阶。规定当爬到第i阶时,系统自动会
给第 2i阶上放上障碍物,之后所有轮都不允许跳到第 2i 阶上。例如,前两轮分别跳到了第 2
3 阶上,则之后就不能跳到第 4、6 阶上。请问有多少种方案可以爬到楼顶。
3 动态规划解题思路
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题:
1. 如何判断一个问题是不是动态规划问题?
2. 求解动态规划问题该从何处入手,完整步骤是什么?
1. 问题判断
总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常就适合用动态规划求解。然而,我们很难从问题描述上直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决。
适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。在此基础上,动态规划问题还有一些判断的“加分项”:
- 问题包含最大(小)或最多(少)等最优化描述。
- 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。
相应地,也存在一些“减分项”:
- 问题的目标是找出所有可能的解决方案,而不是找出最优解。
- 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。
如果一个问题满足决策树模型,并具有较为明显的“加分项”,我们就可以假设它是一个动态规划问题,并在求解过程中验证它。
2. 求解步骤
Q:给定一个 𝑛 × 𝑚 的二维网格 grid ,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回从左上角到右下角的最小路径和。
第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
第二步:找出最优子结构,进而推导出状态转移方程
根据定义好的 𝑑𝑝 表,思考原问题和子问题的关系,找出通过子问题的最优解来构造原问题的
第三步:确定边界条件和状态转移顺序
边界条件在动态规划中用于初始化 𝑑𝑝 表,在搜索中用于剪枝。状态转移顺序的核心是要保证在计算当前问题的解时,所有它依赖的更小子问题的解都已经被正确地计算出来。
1. 方法一:暴力搜索
从状态 [𝑖, 𝑗] 开始搜索,不断分解为更小的状态 [𝑖 − 1, 𝑗] 和 [𝑖, 𝑗 − 1] ,递归函数包括以下要素。
递归参数:状态[i,j]
返回值:从[0,0,]到[i,j]的最小路径和dp[i,j]
终止条件:当i或j等于0时返回代价grid[0,0]
剪枝:当i<0或j<0索引越界,此时返回代价正无穷,代表不可行
public static void main(String[] args) {
int[][] grid = {
{1, 3, 1, 2},
{1, 5, 1, 5},
{4, 2, 1, 9}
};
int i = minPathSumDFS(grid, 2, 3);
System.out.println(i);//16
}
/* 最小路径和:暴力搜索 */
int minPathSumDFS(int[][] grid, int i, int j) {
// 若为左上角单元格,则终止搜索
if (i == 0 && j == 0) {
return grid[0][0];
}
// 若行列索引越界,则返回 +∞ 代价
if (i < 0 || j < 0) {
return Integer.MAX_VALUE;
}
// 计算从左上角到 (i-1, j) 和 (i, j-1) 的最小路径代价
int up = minPathSumDFS(grid, i - 1, j);
int left = minPathSumDFS(grid, i, j - 1);
// 返回从左上角到 (i, j) 的最小路径代价
return Math.min(left, up) + grid[i][j];
}
我们引入一个和网格 grid 相同尺寸的记忆列表 mem ,用于记录各个子问题的解,并将重叠子问题进行剪枝。
/* 最小路径和:记忆化搜索 */
int minPathSumDFSMem(int[][] grid, int[][] mem, int i, int j) {
// 若为左上角单元格,则终止搜索
if (i == 0 && j == 0) {
return grid[0][0];
}
// 若行列索引越界,则返回 +∞ 代价
if (i < 0 || j < 0) {
return Integer.MAX_VALUE;
}
// 若已有记录,则直接返回
if (mem[i][j] != -1) {
return mem[i][j];
}
// 左边和上边单元格的最小路径代价
int up = minPathSumDFSMem(grid, mem, i - 1, j);
int left = minPathSumDFSMem(grid, mem, i, j - 1);
// 记录并返回左上角到 (i, j) 的最小路径代价
mem[i][j] = Math.min(left, up) + grid[i][j];
return mem[i][j];
}
/* 最小路径和:动态规划 */
//总体来说,这段代码通过动态规划的思想,从左上角到右下角逐步计算出每个位置的最小路径和,最终得到整个矩阵的最小路径和。
int minPathSumDP(int[][] grid) {
//获取矩阵grid有几行几列
int n = grid.length, m = grid[0].length;
//初始化一个与输入矩阵相同大小的二维数组 dp,用于存储从左上角到达每个位置的最小路径和。
int[][] dp = new int[n][m];
//初始化左上角的最小路径和,即 dp 数组的第一个元素
dp[0][0] = grid[0][0];
// 状态转移:首行,对于第一行,从左到右计算最小路径和。
for (int j = 1; j < m; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
// 状态转移:首列
for (int i = 1; i < n; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
// 状态转移:其余行列
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
}
}
return dp[n - 1][m - 1];
}
/* 最小路径和:空间优化后的动态规划 */
int minPathSumDPComp(int[][] grid) {
int n = grid.length, m = grid[0].length;
// 初始化 dp 表
int[] dp = new int[m];
// 状态转移:首行
dp[0] = grid[0][0];
for (int j = 1; j < m; j++) {
dp[j] = dp[j - 1] + grid[0][j];
}
// 状态转移:其余行
for (int i = 1; i < n; i++) {
// 状态转移:首列
dp[0] = dp[0] + grid[i][0];
// 状态转移:其余列
for (int j = 1; j < m; j++) {
dp[j] = Math.min(dp[j - 1], dp[j]) + grid[i][j];
}
}
return dp[m - 1];
}
4 0‑1 背包问题
给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1] 、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝 的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
1. 方法一:暴力搜索
/* 0-1 背包:暴力搜索 */
int knapsackDFS(int[] wgt, int[] val, int i, int c) {
// 若已选完所有物品或背包无容量,则返回价值 0
//i 表示当前物品的索引,c 表示当前背包的剩余容量。
if (i == 0 || c == 0) {
return 0;
}
// 若超过背包容量,则只能不放入背包
if (wgt[i - 1] > c) {
//递归调用 knapsackDFS,排除当前物品。
return knapsackDFS(wgt, val, i - 1, c);
}
// 计算不放入和放入物品 i 的最大价值
int no = knapsackDFS(wgt, val, i - 1, c);// 表示不放入当前物品时的最大价值,即递归调用 knapsackDFS 排除当前物品。
int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1];//yes 表示放入当前物品时的最大价值,即递归调用 knapsackDFS 并减去当前物品的重量。
// 返回两种方案中价值更大的那一个
return Math.max(no, yes);
}
2. 方法二:记忆化搜索
/* 0-1 背包:记忆化搜索 */
int knapsackDFSMem(int[] wgt, int[] val, int[][] mem, int i, int c) {
// 若已选完所有物品或背包无容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
// 若已有记录,则直接返回
if (mem[i][c] != -1) {
return mem[i][c];
}
// 若超过背包容量,则只能不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
// 计算不放入和放入物品 i 的最大价值
int no = knapsackDFSMem(wgt, val, mem, i - 1, c);
int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1];
// 记录并返回两种方案中价值更大的那一个
mem[i][c] = Math.max(no, yes);
return mem[i][c];
}
3. 方法三:动态规划
动态规划实质上就是在状态转移中填充 𝑑𝑝 表的过程,代码如下所示。
/* 0-1 背包:动态规划 */
int knapsackDP(int[] wgt, int[] val, int cap) {
int n = wgt.length;
// 初始化 dp 表
//dp[i][c] 表示在前 i 个物品中,背包容量为 c 时的最大总价值。
//使用一个二维数组 dp 表来存储中间状态,其中 dp[i][c] 是一个状态,表示前 i 个物品放入容量为 c 的背包时的最大总价值。
int[][] dp = new int[n + 1][cap + 1];
// 状态转移
for (int i = 1; i <= n; i++) {//遍历每个物品
for (int c = 1; c <= cap; c++) {// 遍历每个可能的背包容量
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = Math.max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
}
}
}
//最终结果存储在 dp[n][cap] 中,即前 n 个物品,背包容量为 cap 时的最大总价值。
return dp[n][cap];
}
在代码实现中,我们仅需将数组 dp 的第一维 𝑖 直接删除,并且把内循环更改为倒序遍历即可
/* 0-1 背包:空间优化后的动态规划 */
int knapsackDPComp(int[] wgt, int[] val, int cap) {
int n = wgt.length;
// 初始化 dp 表
int[] dp = new int[cap + 1];
// 状态转移
for (int i = 1; i <= n; i++) {
// 倒序遍历
for (int c = cap; c >= 1; c--) {
if (wgt[i - 1] <= c) {
// 不选和选物品 i 这两种方案的较大值
dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}