动态规划总结
问题:
Serling公司购买长钢条,将其切割为短钢条出售。不同的切割方案,收益是不同的,怎么切割才能有最大的收益呢?假设,切割工序本身没有成本支出。 假定出售一段长度为i英寸的钢条的价格为p i (i=1,2,…)。钢条的长度为n英寸。如下给出一个价格表P。
给定一段长度为n英寸的钢条和一个价格表P,求切割钢条方案,使得销售收益 rn 最大。(如果长度为n英寸的钢条的价格p n 足够大,则可能完全不需要切割,出售整条钢条是最好的收益)
自顶向下动态规划算法:
1 public static int buttom_up_cut(int[] p) { 2 int[] r = new int[p.length + 1]; 3 for (int i = 1; i <= p.length; i++) { 4 int q = -1; 5 //① 6 for (int j = 1; j <= i; j++) 7 q = Math.max(q, p[j - 1] + r[i - j]); 8 r[i] = q; 9 } 10 return r[p.length]; 11 }
为什么长度为i时的最大收益 r[i] 可以通过注释①处的循环来求呢?
假设长度为i时钢条被分割为x段{m1,m2,m3,...,mx}可得最大收益 r[i] ,
取出其中一段mk,则最大收益可表示为
r[i] = p[mk] + r[i - mk]
如果r[i - mk] 不是长度为 i - mk 时的最大收益的话,则r[i]是长度为i时的最大收益也就不成立,所以最大收益r[i]一定可以表示成单独切出一段的价格p[mk] 加上余下长度的最大收益 r[i - mk]
以下内容转载自: https://my.oschina.net/szz715/blog/3103246
前言
众所周知,递归算法时间复杂度很高为(2^n),而动态规划算法也能够解决此类问题,动态规划的算法的时间复杂度为(n^2)。动态规划算法是以空间置换时间的解决方式。(实际上有时候动态规划也要借助递归的方式来实现)
一、什么是动态规划
动态规划(Dynamic programming)是一种在数学、计算机科学和经济学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。 动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
动态规划对于子问题重叠的情况特别有效,因为它将子问题的解保存在存储空间中,当需要某个子问题的解时,直接取值即可,从而避免重复计算!
二、基本策略
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
动态规划中的子问题往往不是相互独立的(即子问题重叠)。在求解的过程中,许多子问题的解被反复地使用。为了避免重复计算,动态规划算法采用了填表来保存子问题解的方法。
三、什么问题适合用动态规划来解决呢?
适合用动态规划来解决的问题,都具有下面两个特点:最优子结构、重叠子问题。
如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。某阶段状态(定义的新子问题)一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与其以前的状态有关。子问题之间是不独立的(分治法是独立的),一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)。
如果使用递归算法的时候会反复的求解相同的子问题,不停的调用函数,而不是生成新的子问题。如果递归算法反复求解相同的子问题,就称为具有重叠子问题(overlapping subproblems)性质。在动态规划算法中使用数组来保存子问题的解,这样子问题多次求解的时候可以直接查表不用调用函数递归。
这类问题的求解步骤通常如下:
- 分析最优解的性质,并刻画其结构特征,这一步的开始时一定要从子问题入手;
- 定义最优解变量,定义递归最优解公式;
- 以自底向上计算出最优值(或自顶向下的记忆化方式(即备忘录法));
- 根据计算最优值时得到的信息,构造问题的最优解。
五、动态规划的经典模型
1.线性模型:线性模型的是动态规划中最常用的模型,上文讲到的钢条切割问题就是经典的线性模型,这里的线性指的是状态的排布是呈线性的。
常见的线性模型dp形式:
2.区间模型:区间模型的状态表示一般为d[i][j],表示区间[i, j]上的最优解,然后通过状态转移计算出[i+1, j]或者[i, j+1]上的最优解,逐步扩大区间的范围,最终求得[1, len]的最优解。
3.01背包模型:这种类型的问题往往有一个有限的可选方案域,要在这一堆方案中选出最优方案。往往采用dfs&递归的方式来把所有的可选方案都计算一遍,比较出最优方案。参考【动态规划解决01背包问题】有N种物品(每种物品1件)和一个容量为V的背包。放入第 i 种物品耗费的空间是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。f[i][v]表示前i种物品恰好放入一个容量为v的背包可以获得的最大价值。决策为第i个物品在前i-1个物品放置完毕后,是选择放还是不放,状态转移方程为:
f[i][v] = max{ f[i-1][v], f[i-1][v – Ci] +Wi }
时间复杂度O(VN),空间复杂度O(VN) (空间复杂度可利用滚动数组进行优化达到O(V) )。
注意背包模型,这类题往往都是这样(往往规模相对较小,否则容易超时):
当前的问题规模F,对应有一堆用于减小问题规模的可选项n个{X1,X2,...,Xn}(记为M),对应产生的价值/代价为{V1,V2,...,Vn},选出一组可选项以使答案ans最优化(可能是某种价值最大或者某种代价最小),求这个最优值ans
思路:从这n个可选项中选用一个Xi后,问题规模减小为F',可选项更新为M'(有可能选过的Xi不能再选了,也有可能可以重复选择),然后再递归地从可选项中再选出一个可选项来更新F,直到F被完全解决时,再对比这种选法是否比当前答案更优
value bestSolution(M, F){ if(满足结束递归条件){ return 某种value; }
init(tmp_ans); for(Xi : M){ update(ans); update(M); update(F); tmp_ans = better(tmp_ans, Vi + bestSolution(M', F')); }
return tmp_ans; }
例子:LeetCode 638. 大礼包
六、动态规划与分治法的区别和联系
分治法是指将问题划分成一些独立地子问题,递归地求解各子问题,然后合并子问题的解而得到原问题的解。
动态规划适用于子问题独立且重叠的情况,也就是各子问题包含公共的子子问题。动态规划算法对每个子子问题只求解一次,将其结果保存在一张表中,从而避免每次遇到各个子问题时重新计算答案。
分治法主要在于子问题的独立性,比如排序算法等, 动态规划算法主要适用于处理 子问题重复性和最优子结构的的问题。
七、总结
- 动态规划算法的核心就是记住已经解决过的子问题的解。
- 求解的方式有两种:①自顶向下的备忘录法 ②自底向上。
动态规划可以解决哪些类型的问题?
待解决的原问题较难,但此问题可以被不断拆分成一个个小问题,而小问题的解是非常容易获得的;如果单单只是利用递归的方法来解决原问题,那么采用的是分治法的思想,动态规划具有记忆性,将子问题的解都记录下来,以免在递归的过程中重复计算,从而减少了计算量。
总结:
0. 解题关键:找出nums[i] 与dp[i-1](也可能是其他多个dp[k](k<i)和nums[i]一起推出dp[i],或者dp是一个二维数组时更甚)之间的关系式
例如,最优解涉及到子序列时,子序列由左右两个端点i,j来确定,所以一般dp有可能是个len X len的二维数组,dp[i][j]表示这两个元素下标分别为i,j时的情况
有时要注意dp数组的求解顺序,即先求哪一个元素再求哪一个元素,比如dp[i]由dp[i-2],dp[i+2]推导出,那么在求dp[i]时必须先把这两个值求出来(dp为二维数组时尤其注意),这个影响你遍历序列的方式!(例:LeetCode 5. 最长回文子串)
1. dp[i]可能不仅和dp[i-1]有关,有可能和之前多个子问题的最优解有关!(例如下面的爬楼梯问题)
2. 从dp[i-1]推导dp[i]时可能会有多个选项,需要从这些选项中选出最优解作为dp[i+1] (LeetCode 1043题)
3. 当我从dp[i-1](或者其他dp[k], k<i)很难推导出dp[i]时,也许需要换一个dp[i]定义,或者尝试减小问题的维度(控制变量)
例如:
在寻找可行域更小的问题时,我们可以将某些条件限定死,例如上面的寻找长度为n的序列的最大子序和,其规模更小的问题不是寻找长度为i时的序列的最大子序和(i<n),
而是寻找长度为i时以nums[i]结尾的最大子序和(这里限定子序列必须以Num[i]结尾)
4. 另外,dp数组还可能是二维的(区间模型,LeetCode第6题最长回文串,第1641题:统计字典序元音字符串的数目)
5. 动态规划与滑动窗的区别:滑动窗有明显的移动指针以使窗内的序列满足某一要求的界限,而动态规划没有这个界限
举例:
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
反面教材:
1 class Solution { 2 public: 3 int maxSubArray(vector<int>& nums) { 4 5 int len = nums.size(); 6 vector<int> temp(len, 0); 7 temp[0] = nums[0]; 8 int res = temp[0]; 9 int tmp = 0; 10 for(int i = 1; i < len; ++i){ 11 //tmp += nums[i]; 12 temp[i] = temp[i-1] + nums[i]; 13 res = res > temp[i] ? res : temp[i]; 14 for(int j = 0; j < i; ++j){ 15 tmp = temp[i] - temp[j]; 16 res = res > tmp ? res : tmp;// 错误的做法:这里每一次循环不是把子问题的解res保存起来以供下一次利用而是丢弃掉(注意!一定要记得把子问题的答案保存起来!而temp[i]并不是子问题的答案!) 17 } 18 } 19 20 return res; 21 } 22 };
关键:
找到状态转移方程:假设res[i]是子问题i的最优解,寻找子问题域进一步扩大为i+1时,会多出哪些可行解,找出res[i+1]和这些多出的可行解、res[i]之间的关系式(错)
dp[i]是问题规模为i时的子问题的最优解(一开始我们考虑res[i]表示长度为i时的最大子序和,但这样一来res[i]就无法和nums[i+1], res[i+1]产生关联,所以关键是找到dp[i]的含义!)
如本题中,dp[i]表示:
nums中以nums[i]结尾的最大子序和
而不是长度为i时的最大子序和(并不是将n的大小减小为i就叫子问题,这样的话实际上还是原问题====》其实dp[i]表示长度为i+1的序列的最优解也是可以的,LeetCode 1043题)
res[i]只需要保存以nums[i]结尾的最大子序和
即可。而不是保存长度为i的子序的最大子序和
注意每个子问题的解是不重叠的!子问题的可行解的集合构成问题的可行解
正确做法:
1 class Solution 2 { 3 public: 4 int maxSubArray(vector<int> &nums) 5 { 6 //类似寻找最大最小值的题目,初始值一定要定义成理论上的最小最大值 7 int result = INT_MIN; 8 int numsSize = int(nums.size()); 9 //dp[i]表示nums中以nums[i]结尾的最大子序和 10 vector<int> dp(numsSize); 11 dp[0] = nums[0]; 12 result = dp[0]; 13 for (int i = 1; i < numsSize; i++) 14 { 15 dp[i] = max(dp[i - 1] + nums[i], nums[i]); 16 result = max(result, dp[i]); 17 } 18 19 return result; 20 } 21 };
改进:
1 class Solution 2 { 3 public: 4 int maxSubArray(vector<int> &nums) 5 { 6 //类似寻找最大最小值的题目,初始值一定要定义成理论上的最小最大值 7 int result = INT_MIN; 8 int numsSize = int(nums.size()); 9 //因为只需要知道dp的前一项,我们用int代替一维数组 10 int dp(nums[0]); 11 result = dp; 12 for (int i = 1; i < numsSize; i++) 13 { 14 dp = max(dp + nums[i], nums[i]); 15 result = max(result, dp); 16 } 17 18 return result; 19 } 20 };
爬楼梯问题:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
这个问题中dp[i]表示阶数为i时的爬楼梯方法数,且dp[i] = dp[i-1] + dp[i-2](实际上就是斐波那契数列)
又如这题:
四、使用动态规划算法解决问题举例
1.斐波那契(Fibonacci )
前面我们使用了递归的方式去计算斐波那契,使用递归实现的最大问题是每次调用都要把上一步的结果重新计算一次,如果值很大,很容易造成内存溢出,如果值很大,那递归是不能计算出结果的。这时,我们考虑,可不可以把上一步计算的结果保存起来,那下一步就不需要再次计算了,这个思想其实就是动态规划。下面就看看动态规划的两种方法怎样来解决斐波那契数列问题。
①自顶向下的备忘录法
public static int Fibonacci(int n) {
if (n <= 0)
return n;
int[] Memo = new int[n + 1];
for (int i = 0; i <= n; i++)
Memo[i] = -1;
return fib(n, Memo);
}
public static int fib(int n, int[] Memo) {
if (Memo[n] != -1)
return Memo[n];
//如果已经求出了fib(n)的值直接返回,否则将求出的值保存在Memo备忘录中。
if (n <= 2)
Memo[n] = 1;
else Memo[n] = fib(n - 1, Memo) + fib(n - 2, Memo);
return Memo[n];
}
备忘录法也是比较好理解的,创建了一个n+1大小的数组来保存求出的斐波拉契数列中的每一个值,在递归的时候如果发现前面fib(n)的值计算出来了就不再计算,如果未计算出来,则计算出来后保存在Memo数组中,下次在调用fib(n)的时候就不会重新递归了。
②自底向上的动态规划
备忘录法还是利用了递归,上面算法不管怎样,计算fib(6)的时候最后还是要计算出fib(1),fib(2),fib(3)……,那么何不先计算出fib(1),fib(2),fib(3)……,呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。
public static int fib(int n) {
if (n <= 0)
return n;
int[] Memo = new int[n + 1];
Memo[0] = 0;
Memo[1] = 1;
for (int i = 2; i <= n; i++) {
Memo[i] = Memo[i - 1] + Memo[i - 2];
}
return Memo[n];
}
自底向上方法也是利用数组保存了先计算的值,为后面的调用服务。观察参与循环的只有 i,i-1 , i-2三项,因此该方法的空间可以进一步优化如下。
public static int fib(int n) {
if (n <= 1)
return n;
int Memo_i_2 = 0;
int Memo_i_1 = 1;
int Memo_i = 1;
for (int i = 2; i <= n; i++) {
Memo_i = Memo_i_2 + Memo_i_1;
Memo_i_2 = Memo_i_1;
Memo_i_1 = Memo_i;
}
return Memo_i;
}
一般来说由于备忘录方式的动态规划方法使用了递归,递归的时候会产生额外的开销,使用自底向上的动态规划方法要比备忘录方法好。
2.钢条切割
来自算法导论,我们只看最优解法:自底向上的动态规划
public static int buttom_up_cut(int[] p) {
int[] r = new int[p.length + 1];
for (int i = 1; i <= p.length; i++) {
int q = -1;
//①
for (int j = 1; j <= i; j++)
q = Math.max(q, p[j - 1] + r[i - j]);
r[i] = q;
}
return r[p.length];
}
问题中最重要的是理解注释①处的循环,这里外面的循环是求r[1],r[2]……,里面的循环是求出r[1],r[2]……的最优解,也就是说r[i]中保存的是钢条长度为i时划分的最优解,这里面涉及到了最优子结构问题,也就是一个问题取最优解的时候,它的子问题也一定要取得最优解。下面是长度为4的钢条划分的结构图。