动态规划设计方法
Posted Debroon
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了动态规划设计方法相关的知识,希望对你有一定的参考价值。
设计方法
- 可用DP的题目:输入对象、所求的解带有从左至右的次序(如字符串字符、树的叶子),原问题分解成子问题满足无后效性
- 最简单的算例:寻找子问题的重复性
- 定义状态:确定优化目标(问题要求什么最多或者最少),想办法把当前局面描述出来(可能有多种模式)
- 定义状态转移方程:如何决策出最优解(某个状态从哪里来?到哪里去?)
- 确定边界条件:递归+备忘录的终止条件、递推式的初始条件或者边界条件
- 如何定义 DP数组/函数 的含义来表述整个过程:正确的编写程序。
怎么用计算机科学的语言描述当前局面:
-
设计状态,把面临的局面用变量x描述,局面要求的结果记为f(x)。
-
设计状态转移方程,找出f(x)与哪些局面有关记为变量p。
状态x,从哪里来?到哪里去?
得到x的状态转移方程,状态x与相关局面p,结合再一起就是可以描述最优解的状态转移方程f(x)。
求解原理
假设我们有无限数量面值为 1、5、10 的 硬币,凑成 15 个硬币请问最少需要几个硬币 ?
15 的组合,
- 15 = 10 + 5
- 15 = 5 + 5 + 5
- 15 = 10 + 1 + 1 + 1 + 1 +1
……
大家应该想到了,用 “贪心思想” 从高到低。尽量先用最大的10,再尽量用次大的 5,…
可如果我们有无限数量面值为 1、5、11 的 硬币,凑成 15 个硬币请问最少需要几个硬币 ?此次国家设计的硬币并没有按适合贪心的倍数规则来设计~
依然用 “贪心”,15 = 11 + 1 + 1 + 1 + 1 这样的组合花费了 5 枚。
可是,15 = 5 + 5 + 5 这样的组合花费了 3 枚,显然这才是最优解。
emmm… 咋办 ?枚举找到 15 的所有硬币组合吧,可以使用回溯算法吧(dfs)。
#include<iostream>
#include<vector>
using namespace std;
int sum = 0;
vector<int> sum_arr; // 存储当前sum值,是哪些数组成
int min_sum = 99999; // 存储目标结果,硬币最少组合数
void dfs(vector<int>input, int n){
if(sum == n){
min_sum = min_sum > sum_arr.size()?sum_arr.size():min_sum; // 更新最少组合数
}
if(sum > n) // 结束条件
return;
for(int i=0; i<3; i++){ // 从选择列表里
sum += input[i]; // 做选择
sum_arr.push_back(input[i]); // 添加
dfs(input, n); // 枚举下一个
sum -= input[i]; // 撤销当前选择
sum_arr.pop_back(); // 删除
}
}
int main(){
vector<int> input = {1, 5, 11};
dfs(input, 15);
cout << "硬币最少组合数:" << min_sum; // 15 时,值为 3
}
但枚举时间复杂度太高,它把有用没用的信息都包含进去了。要提高算法效率,就少做无用功,我们只提取出有用的信息。
我们分析一下,15这个数可以从哪里来 ?
因为我们只有 1、5、11 的面值,所以 15 只能从 3 种面值过来也就是 14:(15-1)、10:(15-5)、4:(15-11)。
我们用计算机科学语言(变量、表达式、分支、函数)描述当前局面:
-
设计状态,把面临的局面用状态x描述,对于状态x,在某个阶段把我们需要的答案记为f(x)。
状态x:硬币问题中是硬币凑的数额
某个阶段的答案f(x):硬币问题中是凑硬币的最少数量,硬币问题是求f(15)
-
设计状态转移方程,找出f(x)与哪些局面有关记为变量p。目标记为f(T),所有阶段组合起来
我们要推出目标f(T),就需要知道状态x是怎么变化的 — 请问状态x,从哪里来?到哪里去?
比如 x=15 时,f(15) 可以写成:
- f(14) +1,+1 是取了面值为 1 的硬币,现在变成凑 14;
- f(10) +1,+1 是取了面值为 5 的硬币,现在变成凑 10;
- f(4) +1,+1 是取了面值为 11 的硬币,现在变成凑 4;
那么,f(15)的最优解即 f(15) = { min( f(14) , f(11) , f(4) ) + 1 },那么我们知道了f(15),可是不知道 f(14) , f(11) , f(4)。那么,我们再分析 f(14) , f(11) , f(4) 从哪里来 ?
同理呀,这三个也都只能从 1、5、11 的面值取,所以一直分解即可。
f(15) 与 f(14) , f(11) , f(4) 有关,如此反复,每个大问题的最优解可由小问题的最优解推出,用归纳思想就可以得到。
与f(x)相关的状态有 f(x-1),f(x-5),f(x-11) ,那f(x)是从那个状态推过来的呢?
最少硬币数的话,状态转移方程f(T):f(T) = min( f(x-1), f(x-5), f(x-11) ) + 1
动态规划的基本设计思路就是如此,我们分析的时候是从后往前,具体代码需要写成从前往后。
所以,我们还得从前往后推一遍,方便程序编写。
- 当 x = 0 时,很显然我们可以知道 f(0) = 0。因为不要凑钱了嘛,当然也不需要任何硬币了。注意这是很重要的一步,其后所有的结果都从这一步延伸开来。
- 当 x = 1 时,因为我们有 1 元的硬币,所以直接在第 1 步的基础上,加上 1 个 1 元硬币,得出 f(1) = 1。
- 当 x = 2 时,因为我们并没有 2 元的硬币,所以只能拿 1 元的硬币来凑。在第 2 步的基础上,加上 1 个 1 元硬币,得出 f(2) = 2。
- 当 x = 3 时,我们可以在第 3 步的基础上加上 1 个 1 元硬币,得到 3 这个结果,得出 f(3) = 3。
- 当 x = 4 时,我们可以在第 4 步的基础上加上 1 个 1 元硬币,得到 4 这个结果,得出 f(4) = 4。
- 当 x = 5 时,我们可以在第 5 步的基础上加上 1 个 1 元硬币,得到 5 这个结果,得出 f(5) = 5。但是,但其实我们有 5 元硬币,所以这一步的最优结果不是建立在第 5 步(前一步)的结果上得来的,而是应该建立在第 1 步上,加上 1 个 5 元硬币,得到 f(5) = 1。
- …
#include <iostream>
using namespace std;
int min( int x, int y, int z ) { // 求三位数最小值
return (x>y?y:x)>z?z:(x>y?y:x);
}
void DP( int nums ) {
int dp[1000] = {0}; // 存储部分结果,实现记忆化搜索
for( int x = 1; x <= nums; x ++ ) { // x 从哪里来,从 1 考虑到 nums 递推实现
if( x >= 11 ) // 先试试 11,再试试 5,最后试试 1,贪心
dp[x] = min( dp[x-1]+1, dp[x-5]+1, dp[x-11]+1 );
else if( x >= 5 )
dp[x] = min( dp[x-1]+1, dp[x-5]+1 );
else if( x >= 1 )
dp[x] = dp[x-1]+1;
printf(" %-3d, 最少需要 %-3d 枚硬币组成 \\n", x, dp[x]);
}
}
int main(){
int nums;
scanf("%d", &nums); // 输入测试数据,如 15
DP(nums);
}
运行结果:
输入:
15
输出:
1 , 最少需要 1 枚硬币组成
2 , 最少需要 2 枚硬币组成
3 , 最少需要 3 枚硬币组成
4 , 最少需要 4 枚硬币组成
5 , 最少需要 1 枚硬币组成
6 , 最少需要 2 枚硬币组成
7 , 最少需要 3 枚硬币组成
8 , 最少需要 4 枚硬币组成
9 , 最少需要 5 枚硬币组成
10 , 最少需要 2 枚硬币组成
11 , 最少需要 1 枚硬币组成
12 , 最少需要 2 枚硬币组成
13 , 最少需要 3 枚硬币组成
14 , 最少需要 4 枚硬币组成
15 , 最少需要 3 枚硬币组成
LeetCode相关的练习:
- 322.零钱兑换:求最少的硬币个数
- 322.零钱兑换 II:求凑成总金额的硬币组合数
我们来对比一下,回溯和动态规划,只看代码量您可能感觉俩者差不多,但是从时间复杂度来说,动态规划是把一个平方复杂度问题,变成了一个线性复杂度问题。
其实回溯和动态规划都是在穷举,只是动态规划发现了硬币问题的最优子结构,解决了重叠子问题的计算,以前计算过的子问题,可以直接用,而不用像回溯一样重新计算。
-
硬币问题的最优子结构:f(15) 与 f(14) , f(10) , f(4) 有关,当前问题的最优解可由过去问题的最优解推出。
-
硬币问题的重叠子问题:动态规划用备忘录保存了子问题的解,避免重复计算。
f(15) 的解来自 f(14) , f(10) , f(4)
f(14) 的解来自 f(13),f(9),f(3)
f(13) 的解来自 f(12),f(8),f(2)
f(12) 的解来自 f(11),f(7),f(1)
f(11) 的解来自 f(10),f(6),0
f(10) 的解来自 f(9),f(5),\\
…可以发现,f(10)、f(14) 都有计算 f(9),他们之间重叠子问题,如果是回溯的话,这个重叠的子问题会计算很多次,而动态规划用一个数组保存了这个值,只会计算一次。
动态规划:将原问题拆解为若干个子问题,同时保存子问题的答案,使得每个子问题的只求解一次,最终获得原问题的答案。
此外,并不是只有用递推关系式的方式实现的代码才是动态规划,只要子问题满足无后向性,用递归辅助备忘录的形式也是动态规划。
根据我玩 OJ(算法游戏)的经验,一些问题如果最优解递推关系式不明显,直接编写代码有困难,可以尝试用分治 + 穷举的方法,只要子问题满足无后向性要求,用辅助备忘录的形式消除子问题的重复求解开销,基本上都能满足 OJ 题目对运行时间的要求。
常见递推关系式
动态规划算法有三个要素:
- 所有不同的子问题所组成的表(它包含的问题数目称为问题的大小,即 size);
- 问题解决的依赖关系可以看成是一个图;
- 填充子问题的顺序(实际上就是(2)所得到的图的一个拓扑排序)。
如果子问题的数组为 θ ( n t ) \\theta(n^{t}) θ(nt),且每个子问题需要依赖于 θ ( n e ) \\theta(n^{e}) θ(ne) 个其他子问题,则称这个问题为 t D e D \\frac{tD}{eD} eDtD 问题。
总结起来可得到四种典型的动态规划关系递推方程。
1 D 1 D \\frac{1D}{1D} 1D1D
定义一个实函数 w ( i , j ) ( 1 ≤ i < j ≤ n ) w(i,j)(1\\leq i< j\\leq n) w(i,j)(1≤i<j≤n),已知 D [ 0 ] D[0] D[0],状态转移方程:
- E [ j ] = o p t 0 ≤ i < j { D [ i ] + w ( i , j ) } , i ≤ j ≤ n E[j]=\\underset {0\\leq i<j}{opt} \\{D[i]+w(i,j)\\},i\\leq j\\leq n E[j]=0≤i<jopt{D[i]+w(i,j)},i≤j≤n
P.S. opt 是最优关系,可能是 max,也可能是 min。
其中, D [ i ] D[i] D[i] 可以根据 E [ i ] E[i] E[i] 在常数时间内计算出来。
《算法导论》里的切分钢条,锯木头问题都是这种结构,具体的理解,您要在行动中来思考。
2 D 0 D \\frac{2D}{0D} 0D2D
已知 D [ i , 0 ] D[i,0] D[i,0] 和 D [ 0 , j ] ( 0 ≤ i , j ≤ n ) D[0,j](0\\leq i,j\\leq n) D[0,j](0≤i,j≤n),状态转移方程为:
- E [ i , j ] = o p t { D [ i − 1 , j ] + x i , D [ i , j − 1 ] + y i , D [ i − 1 , j − 1 ] + z i j } E[i,j]=opt\\{D[i-1,j]+x_{i},D[i,j-1]+y_{i}, ~D[i-1,j-1]+z_{ij}\\} E[i,j]=opt{D[i−1,j]+xi,D[i,j−1]+yi, D[i−1,j−1]+zij}
其中, x i , y i , z i x_{i},y_{i},z_{i} xi,yi,zi 都是可以在常数时间内算出来的。
线性DP、串DP 问题,多是这种结构,具体的理解,您要在行动中来思考。