动态规划
概述
- 动态规划通过将复杂问题分解为相互重叠的子问题,并利用存储中间结果来避免重复计算,从而高效解决具有最优子结构特性的问题
- 其核心在于 “状态定义” 和 “状态转移”
实现步骤
- 定义状态 (
State Definition)- 这是动态规划最基础且关键的一步。你需要定义一个或多个状态变量(通常用数组
dp来表示),并明确dp[i]或dp[i][j]所代表的具体含义 - 例如,在经典的斐波那契数列问题中,状态
dp[i]就表示第i个斐波那契数的值
- 这是动态规划最基础且关键的一步。你需要定义一个或多个状态变量(通常用数组
- 确定状态转移方程 (
State Transition Equation)- 状态转移方程描述了较大问题的解如何由其较小子问题的解推导出来,是动态规划的灵魂
- 它建立了不同状态之间的关系
- 例如,在斐波那契数列中,状态转移方程就是
dp[i] = dp[i-1] + dp[i-2]
- 初始化 (
Initialization)- 状态转移方程需要一个起点
- 你需要为最小的、不可再分的子问题(边界条件)设置初始值
- 例如,在斐波那契数列中,我们需要初始化
dp[0] = 0和dp[1] = 1
- 确定计算顺序 (
Calculation Order)- 确定是以自底向上(递推)还是自顶向下(记忆化搜索)的方式填充状态表
- 正确的计算顺序需要确保在计算一个状态时,它所依赖的子状态已经被计算过
- 返回结果 (
Return the Result)- 根据状态定义,从填充好的
dp数组中返回最终问题的解 - 例如,计算第
n个斐波那契数,结果就是dp[n]
- 根据状态定义,从填充好的
实现方式
-
概述
- 动态规划主要有两种实现方式,它们基于相同的原理,但思路和实现代码有所不同
-
自底向上(递推)
- 从最小的子问题开始,逐步推导至原问题
- 效率高,无递归开销;通常空间优化更直接
- 可能需要计算所有子问题,即使有些并非必需
-
自顶向下(记忆化搜索)
- 从原问题开始,递归分解为子问题,并记忆已解子问题
- 代码直观,更符合对问题的自然思考方式;只计算必要的子问题
- 递归存在函数调用开销,对于问题规模极大时可能栈溢出
背包问题
概述
- 背包问题是一类经典的组合优化问题,在有限的背包容量下,如何选择物品才能使得总价值最大
01背包
01背包问题是背包问题中最基础的形式,每种物品最多只能选择一次- 核心思路
- 使用动态规划,定义
dp[i][j]表示前i个物品在背包容量为j时能获得的最大价值
- 使用动态规划,定义
- 状态转移方程
- 其中第一项表示不选第
i个物品 - 第二项表示选择第
i个物品
- 其中第一项表示不选第
|
1 |
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i]) |
- 状态转移方程解析
1- 从
01背包问题的目标出发:在背包容量有限的前提下,从若干件只能选择一次(0或1) 的物品中挑选,使得总价值最大
- 从
| 决策选择 | 前提条件 | 状态转移逻辑 | 结果含义 |
不选第i件物品 |
总是可行 | dp[i][j] = dp[i-1][j] |
当前最大价值继承前i-1件物品在容量j时的最大价值 |
选第i件物品 |
背包剩余容量 j>= 当前物品重量 weight[i] |
dp[i][j] = dp[i-1][j - weight[i]] + value[i] |
当前最大价值 = 前i-1件物品在j - weight[i]容量下的最大价值 + 当前物品的价值 |
| 最终决策 | max(不选的价值, 选的价值) |
在两种决策中取价值更大的方案 |
- 状态转移方程解析
2- 假设我们有
3件物品和一个容量为5的背包 - 物品
1:重量2,价值3 - 物品
2:重量3,价值4 - 物品
3:重量4,价值5
- 假设我们有
| i\j | 0 | 1 | 2 | 3 | 4 | 5 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1(重2值3) | 0 | 0 | max(0, 0+3) = 3 |
3 | 3 | 3 |
| 2(重3值4) | 0 | 0 | 3 | max(3, 0+4)=4 |
max(3, 3+4)=7?不, j=4时,j-weight[2]=1,dp[1][1]=0所以是 max(3, 0+4)=4 |
max(3, 3+4)=7 |
| 3(重4值5) | 0 | 0 | 3 | 4 | max(4, 3+5)=8?不, j=4时,j-weight[3]=0,dp[2][0]=0,所以是 max(4, 0+5)=5 |
max(7, 4+5)=9 |
- 理解
- 如果不选择第
i个物品,那么j个容量的最大价值其实就是第i-1个物品的最大价值,也就是dp[i-1][j],此时第i个物品的最大价值同样也是dp[i-1][j] - 如果选择第
i个物品,首先在容量j上面预留出weight[i]的重量大小,此时前i-1个物品的最大价值是在j-weight[i]容量的前提下的,也就是dp[i-1][j-weight[i]],并由于选择了第i个物品,所以还要加上第i个物品的价值,最后就是dp[i-1][j-weight[i]]+value[i] - 最后,在选择第
i个物品和不选第i个物品时的价值的最大值里面,选大的,就是dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i])
- 如果不选择第
- 实现
1
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <iostream> #include <vector> #include <algorithm> using namespace std; int knapsack_01(int capacity, vector<int>& weights, vector<int>& values, int n) { vector<vector<int>> dp(n + 1, vector<int>(capacity + 1, 0)); for (int i = 1; i <= n; i++) { for (int j = 1; j <= capacity; j++) { if (weights[i-1] <= j) { dp[i][j] = max(dp[i-1][j], values[i-1] + dp[i-1][j - weights[i-1]]); } else { dp[i][j] = dp[i-1][j]; } } } return dp[n][capacity]; } int main() { vector<int> weights = {2, 3, 4, 5}; vector<int> values = {3, 4, 5, 6}; int capacity = 8; int n = weights.size(); cout << "最大价值: " << knapsack_01(capacity, weights, values, n) << endl; return 0; } |
- 实现
2:使用一维数组,将空间复杂度从O(n×W)优化到O(W)- 内层循环必须倒序遍历,防止物品被重复放入
|
1 2 3 4 5 6 7 8 9 10 |
int knapsack_01_optimized(int capacity, vector<int>& weights, vector<int>& values, int n) { vector<int> dp(capacity + 1, 0); for (int i = 0; i < n; i++) { for (int j = capacity; j >= weights[i]; j--) { dp[j] = max(dp[j], dp[j - weights[i]] + values[i]); } } return dp[capacity]; } |
完全背包
- 概述
- 与
01背包类似,但选择物品时每种物品可以多次选择
- 与
- 状态转移方程
- 与
01背包的区别在于第二项是dp[i][j - weight[i]]而不是dp[i-1][j - weight[i]]
- 与
|
1 |
dp[i][j] = max(dp[i-1][j], dp[i][j - weight[i]] + value[i]) |
- 理解
- 如果不包含第
i件物品,那么前i-1件物品在j容量的条件下的最大价值是dp[i-1][j] - 如果包含第
i件物品,预留出1个第i件物品的空间,容量条件变为j-weight[i],
由于完全背包物品可以选择多次,所以可以在容量条件为j-weight[i]的情况下,直接使用有第i件物品的最大价值,就是dp[i][j-weight[i]]
由于为i还预留了一个空间大小,所以还得加上value[i],就是dp[i][j - weight[i]] + value[i]
最后,还要在包含第i件物品和不包含第i件物品之间选最大值
- 如果不包含第
- 实现
- 内层循环为正序遍历,允许物品多次被选择
|
1 2 3 4 5 6 7 8 9 10 |
int complete_knapsack(int capacity, vector<int>& weights, vector<int>& values, int n) { vector<int> dp(capacity + 1, 0); for (int i = 0; i < n; i++) { for (int j = weights[i]; j <= capacity; j++) { dp[j] = max(dp[j], dp[j - weights[i]] + values[i]); } } return dp[capacity]; } |
多重背包:二进制优化
- 概述
- 多重背包问题中,每种物品有数量限制
- 就是说,在将物品装入背包时,每种物品最多只能装入一个预先设定的、有限的数量
- 基本思路
- 可以将多重背包转化为
01背包问题,但这种方法在物品数量较大时效率较低
- 可以将多重背包转化为
- 优化思路
- 二进制优化
- 本质上是一种将任意整数高效拆分为
2的幂次组合的方法
- 公式
| 数列组成部分 | 规律描述 | 以数字10为例 | 作用 |
| 基础幂次项 | 公比为2的等比数列:2^0,2^1,2^2,...,2^(k−1) |
1, 2, 4 |
覆盖基础范围,能组合出从0到 (2^(k−1))的所有整数 |
| 最后的余项 | 剩余的数:n−(2^k−1) |
3,确定了k为3之后,为10-(2^3-1)=3 |
补全范围,与基础项一起组合出从0到n的所有整数 |
-
构造规律
- 寻找最大的基础幂次和
首先,找到最大的整数k,使得所有2的幂次之和1+2+4+...+2*k*−1小于或等于n - 计算余项
然后,用总数n减去这个已经能被幂次项覆盖的最大范围 (2^k−1),剩下的数就是最后一项
- 寻找最大的基础幂次和
-
示例:假设一种物品有
13件。朴素算法需要依次考虑拿0件、1件、2件...一直到13件,共14种情况- 二进制优化后:
- 将
13拆分为:1, 2, 4, 6(因为1+2+4=7, 13-7=6) - 这样,我们得到了
4个“新物品”,它们分别代表1件、2件、4件、6件原物品打包成的“物品包” - 现在,问题转化为了一个
01背包问题:对于这4个“物品包”,每个只能选一次
-
二进制优化实现
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
int multiple_knapsack(int capacity, vector<int>& weights, vector<int>& values, vector<int>& counts, int n) { vector<int> dp(capacity + 1, 0); for (int i = 0; i < n; i++) { // 二进制优化 int count = counts[i]; for (int k = 1; k <= count; k *= 2) { int new_weight = k * weights[i]; int new_value = k * values[i]; for (int j = capacity; j >= new_weight; j--) { dp[j] = max(dp[j], dp[j - new_weight] + new_value); } count -= k; } if (count > 0) { int new_weight = count * weights[i]; int new_value = count * values[i]; for (int j = capacity; j >= new_weight; j--) { dp[j] = max(dp[j], dp[j - new_weight] + new_value); } } } return dp[capacity]; } |
多重背包:单调队列优化
其他
相互重叠的子问题
- 概述
- 指的是在解决一个复杂问题的过程中,相同的、更小规模的子问题会被反复多次遇到和计算
- 就像是拆解一个大任务时,你会发现有些基础步骤是完全一样的,并且需要在不同阶段重复执行
如果使用简单的递归方法,这些子问题就会被重复计算,导致效率急剧下降(例如斐波那契数列的递归解法会出现指数级的时间复杂度)
动态规划通过“记忆化”(存储子问题的解)来避免这种重复劳动,每个子问题只计算一次,后续需要时直接查表即可
具有最优子结构特性的问题
- 最优子结构
- 是指一个问题的最优解包含了其子问题的最优解
- 简单来说,你可以通过组合子问题的最优解来构造出整个问题的最优解
- 意味着
- 一旦我们知道了所有子问题的最佳解决方案,我们就可以有效地构建出原问题的最佳解决方案
- 这种“由下而上”构建最优解的特性是最优子结构的核心
子问题重叠和最优子结构
- 结论
- 一个问题只要是具有最优子结构的,那么可以使用动态规划算法来处理该问题
- 至于该问题是不是子问题重叠的,影响的仅仅是解决问题的效率
| 对比 | 最优子结构 | 子问题重叠 |
| 核心问题 | 问题是否可被分解? | 分解后的子问题是否被重复计算? |
| 角色 | 可行性条件(“入场券”):决定了动态规划方法是否适用于该问题 | 效率条件(“加速器”):决定了动态规划相较于朴素递归是否具有显著的效率优势 |
| 含义 | 一个问题的最优解包含其子问题的最优解 | 在递归求解过程中,相同的子问题会被多次遇到和计算 |
| 不满足时 | 无法使用动态规划方法求解。例如,最长简单路径问题就不具有最优子结构 | 仍然可以使用动态规划,但可能无法显著提升效率(有时甚至不如直接递归)。例如,归并排序的子问题是独立的,没有重叠,因此记忆化不会减少计算量 |
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ 排序_计数排序05/08
- ♥ 查找_二分查找05/09
- ♥ 排序_冒泡排序05/08
- ♥ 数学知识模板03/09
- ♥ Windows机制: 窗口及渲染相关06/15
- ♥ 算法特点、哈希表06/29
热评文章
- 查找_二分查找 0
- 数学知识模板 0
- 数据结构_二叉树节点 0
- 查找_顺序查找 0
- 算法特点、哈希表 0
- 匹配_Rabin_karp匹配算法 0