动态规划是计算机中解决最优化问题的一种方法
首先给出一个问题:
问一个无序数组,求解最长的递增的子序列的长度,数组为:num = [1,5,2,4,3]
我们最容易想到的就是暴力枚举(暴力搜索),首先画出递归树
从1开始找出所有的子序列,返回最长的子序列长度
代码:
def L(nums, i):
if i == len(nums) - 1
return 1
max_len = 1
for j in range(i + 1, len(nums)):
if nums[j] > nums[i]:
max_len = max(max_len, L(nums, j) + 1)
return max_len
def length_of_LTS(nums):
return max(L(nums, i) for i in range(len(nums)))
nums = [1,5,2,4,3]
print(length_of_LTS(nums))
但是这种方式的弊端在于时间复杂度太高,2的n次方,这是一个指数级别的算法(因为有2的n次方个子序列需要判断是否递增),假设我们使用长度为一百的数组来计算,计算机需要运行五秒才能出现结果。
算法优化,可以使用哈希表存储重复的计算,当遇到重复计算时,直接查表就可以了,不需要重复计算
当我们遍历子序列1,2,4
的时候就已经计算过“从4开始的最大子序列的长度”
在后面遍历1,4
的时候又重复计算了一次
所以我们可以将那些重复的计算的结果事先存到我们的程序里,用空间换时间
memo = {}
def L(nums, i):
if i in memo:
return memo{i}
if i == len(nums) - 1
return 1
max_len = 1
for j in range(i + 1, len(nums)):
if nums[j] > nums[i]:
max_len = max(max_len, L(nums, j) + 1)
memo[i] = max_len
return max_len
def length_of_LTS(nums):
return max(L(nums, i) for i in range(len(nums)))
nums = [1,5,2,4,3]
print(length_of_LTS(nums))
同样还是长度为一百的数组,这次程序只运行了一毫秒就成功了,这是避免了重复节点的计算,使用字典/哈希表保存了中间的结果,所以我们可以称其为记忆化搜索、带备忘录的递归、递归树的剪枝
迭代算法优化
我们计算从1开始的最长递增子序列,1可以与后面的四个数组成递增序列,所以我们需要递归的计算从5,2,4,3开始的最长子序列长度,选出最长的那个加上1就是目标结果,后面的数也是如此
我们可以发现,结果从后往前的计算,就可以把所有答案推算出来
最后我们可以根据上面的式子写出迭代算法,通过两层循环,外层循环表示从下到上的依次计算,内层循环用于遍历大括号里的数值
def length_of_LTS(nums):
n = len(nums)
L = [1]*n
for i in reversed(range(n)):
for j in range(i + 1, n):
if num[j] > num[i]:
L[i] = max(L[i], L[j] + 1)
return max(L)
因为只使用了两个循环,所以时间复杂度为n的平方
并且还可以避免函数的开销
递归的一般思路
- 首先简单粗暴的将所有答案穷举出来,并画出递归树,尝试使用递归函数求解
- 如果发现大量的重复计算,我们可以使用哈希表将结果缓存到程序中,之后遍历到相同的节点就直接查表,避免重复计算
- 最后,将计算的过程表示出来,观察公式求解的顺序,并尝试将递归改写为更简洁高效的迭代形式
- 不管这些子问题以后是否被用到,只要它被计算过,就将其结果填入哈希表中。这就是动态规划法的基本思路。
动态规划的应用场景
- 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
- 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
- 有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
经典的动态规划算法
1、斐波那契数列
斐波那契数列大家都很熟悉,而且知道用递归可以很容易的做出来
if(n == 0){
return 0;
}else if(n == 1){
return 1;
}else{
return solutionFibonacci(n-1)+solutionFibonacci(n-2);
}
如果用动态规划,就是把结果存到一个数组中。
public static int solutionFibonacci(int n){
if(n==0){
return 0;
}else if(n == 1){
return 1;
}else{
int result[] = new int[n+1];
result[0] = 0;
result[1] = 1;
for(int i=2;i<=n;i++){
result[i] = result[i-1] + result[i-2];
}
return result[n];
}
与之类似的还有:跳台阶问题:每次只能跳一个或者两个台阶,跳到n层台阶上有几种方法
填充长方体问题:将一个2*1
的长方体填充到2*n
的长方体中,有多少种方法
2、数组最大不连续递增子序列
arr[] = {3,1,4,1,5,9,2,6,5}的最长递增子序列长度为4。即为:1,4,5,9
设置一个数组temp,长度为原数组长度,数组第i个位置上的数字代表0...i
上最长递增子序列,当增加一个数字时,最大递增子序列可能变成前面最大的递增子序列+1,也可能就是前面最大递增子序列,这需要让新增加进来的数字arr[i]跟前面所有数字比较大小,即当 arr[i] > arr[j],temp[i] = max{temp[j]}+1
,其中,j 的取值范围为:0,1…i-1,当 arr[i] < arr[j],temp[i] = max{temp[j]}
,j 的取值范围为:0,1…i-1,所以在状态转换方程为temp[i]=max{temp[i-1], temp[i-1]+1}
public static int MaxChildArrayOrder(int a[]) {
int n = a.length;
int temp[] = new int[n];//temp[i]代表0...i上最长递增子序列
for(int i=0;i<n;i++){
temp[i] = 1;//初始值都为1
}
for(int i=1;i<n;i++){
for(int j=0;j<i;j++){
if(a[i]>a[j]&&temp[j]+1>temp[i]){
//如果有a[i]比它前面所有的数都大,则temp[i]为它前面的比它小的数的那一个temp+1取得的最大值
temp[i] = temp[j]+1;
}
}
}
int max = temp[0];
//从temp数组里取出最大的值
for(int i=1;i<n;i++){
if(temp[i]>max){
max = temp[i];
}
}
return max;
}
3、数组最大连续子序列和
如arr[] = {6,-1,3,-4,-6,9,2,-2,5}的最大连续子序列和为14。即为:9,2,-2,5
创建一个数组a,长度为原数组长度,不同位置数字a[i]代表0…i上最大连续子序列和,a[0]=arr[0]设置一个最大值max,初始值为数组中的第一个数字。当进来一个新的数字arr[i+1]时,判断到他前面数字子序列和a[i]+arr[i+1]跟arr[i+1]哪个大,前者大就保留前者,后者大就说明前面连续数字加起来都不如后者一个新进来的数字大,前面数字就可以舍弃,从arr[i+1]开始,每次比较完都跟max比较一下,最后的max就是最大值。
public static int MaxContinueArraySum(int a[]) {
int n = a.length;
int max = a[0];
int sum = a[0];
for(int i=1;i<n;i++){
sum = Math.max(sum+a[i], a[i]);
if(sum>=max){
max = sum;
}
}
return max;
}
4、数字塔从上到下所有路径中和最大的路径
数字塔是第i行有i个数字组成,从上往下每个数字只能走到他正下方数字或者正右方数字,求数字塔从上到下所有路径中和最大的路径,如有下数字塔
3
1 5
8 4 3
2 6 7 9
6 2 3 5 1
最大路径是3-5-3-9-5,和为25。我们可以分别从从上往下看跟从下往上看两种动态规划的方式去解这个题
- 从上往下看:当从上往下看时,每进来新的一行,新的一行每个元素只能选择他正上方或者左左方的元素,也就是说,第一个元素只能连他上方的元素,最后一个元素只能连他左上方的元素,其他元素可以有两种选择,所以需要选择加起来更大的那一个数字,并把这个位置上的数字改成相应的路径值,具体过程如下图所示
所以最大值就是最底层的最大值也就是25。
具体运算过程就是,建立一个n*n
的二维数组dp[][]
,n是数字塔最后一行的数字个数,二维数组每一行数字跟数字塔每一行数字个数一样,保存的值是从上方到这一个位置最大路径的值,填入边界值dp[0][0]=3
,每一行除了第一个值跟最后一个值,其他的值选择上方或者左上方更大的值与这个位置上的值相加得来的值,即dp[i][j]=Math.max(dp[i-1][j-1], dp[i-1][j]) + n[i][j]
public static int minNumberInRotateArray(int n[][]) {
int max = 0;
int dp[][] = new int[n.length][n.length];
dp[0][0] = n[0][0];
for(int i=1;i<n.length;i++){
for(int j=0;j<=i;j++){
if(j==0){
//如果是第一列,直接跟他上面数字相加
dp[i][j] = dp[i-1][j] + n[i][j];
}else{
//如果不是第一列,比较他上面跟上面左面数字谁大,谁大就跟谁相加,放到这个位置
dp[i][j] = Math.max(dp[i-1][j-1], dp[i-1][j]) + n[i][j];
}
max = Math.max(dp[i][j], max);
}
}
return max;
}
优化:动态规划中每一个需要创建一个二维数组的解法,都可以换成只创建一个一维数组的滚动数组解法,依据的规则是一般二维数组中存放的是所有的结果,但是一般我们需要的结果实在二维数组的最后一行的某个值,前面几行的值都是为了得到最后一行的值而需要的,所以可以开始就创建跟二维数组最后一行一样大的一维数组,每次存放某一行的值,下一次根据这一行的值算出下一行的值,在存入这个数组,也就是把这个数组滚动了,最后数组存储的结果就是原二维数组中最后一行的值。
拿到本题来说,开始创建一个一维数组dp[n]
,初始值只有dp[0]=3
,新进来一行时,仍然遵循dp[i][j]=Math.max(dp[i-1][j-1], dp[i-1][j]) + n[i][j]
,现在为求dp[j]
,所以现在dp[i-1][j]
其实就是数组中这个位置本来的元素即dp[j]
,而dp[i-1][j-1]
其实就是数组中上一个元素dp[j-1]
,也就是说dp[j]=Math.max(dp[j], dp[j-1])+n[i][j]
public static int minNumberInRotateArray2(int n[][]) {
int[] temp = new int[n.length];
temp[0] = n[0][0];
for(int i=1;i<n.length;i++){
for(int j=i;j>=0;j--){
if(j==i){
temp[i]=temp[i-1]+n[i][j];
}else if(j==0){
temp[0]+=n[i][0];
}else{
temp[j]=Math.max(temp[j], temp[j-1])+n[i][j];
}
}
}
int max = temp[0];
//从temp数组里取出最大的值
for(int i=1;i<temp.length;i++){
if(temp[i]>max){
max = temp[i];
}
}
return max;
}
这样空间复杂度就大幅度下降了。
- 从下往上看时:从下往上看时大体思路跟从上往下看一样,但是要简单一些,因为不用考虑边界数据,从下往上看时,每进来上面一行,上面一行每个数字有两条路径到达下面一行,所以选一条最大的就可以
所以最大值就是最上面数字就是25.
具体方法也是建立一个二维数组,最下面一行数据添到二维数组最后一行,从下往上填数字,所以状态转化方程是dp[i][j]=Math.max(dp[i+1][j+1], dp[i+1][j]) + n[i][j]
,具体解决方法跟从上往下看一样,就不写具体代码了。
优化:滚动数组,只创建一个一维数组,数组初始值是数字塔最下面一行的值,每次新加一行值,将数组中的值改变,最后数组中第一个数字就是最大路径的值。状态转化方程就是temp[j] = Math.max(temp[j], temp[j+1])+n[i][j]。具体代码如下
public static int minNumberInRotateArray3(int n[][]) {
int[] temp = new int[n.length];
for(int i=0;i<n.length;i++){
temp[i] = n[n.length-1][i];
}
for(int i=n.length-2;i>=0;i--){
for(int j=0;j<=i;j++){
temp[j] = Math.max(temp[j], temp[j+1])+n[i][j];
}
}
return temp[0];
}
从下往上看跟从上往下看相比,虽然逻辑较为简单,但是从下往上看时需要得到完整的数字塔之后才能开始计算,而从上往下看时可以随着数字塔的深入来计算,也可以返回任意一层的结果,是最好的方法。
5、两个字符串最大公共子序列
比如字符串1:BDCABA;字符串2:ABCBDAB,则这两个字符串的最长公共子序列长度为4,最长公共子序列是:BCBA
具体思想:设X=(x1,x2,.....xn)和 Y={y1,y2,.....ym}
是两个序列,将 X 和 Y 的最长公共子序列记为LCS(X,Y),如果 xn=ym,即X的最后一个元素与Y的最后一个元素相同,这说明该元素一定位于公共子序列中。因此,现在只需要找:LCS(Xn-1,Ym-1)
就好,LCS(X,Y)=LCS(Xn-1,Ym-1)+1;
如果xn != ym,这下要麻烦一点,因为它产生了两个子问题:LCS(Xn-1,Ym) 和 LCS(Xn,Ym-1)
。
- 动态规划解法:先创建一个解空间即数组,因为给定的是两个字符串即两个一维数组存储的数据,所以要创建一个二维数组,设字符串X有n个值,字符串Y有m个值,需要创建一个m+1*n+1的二维数组,二维数组每个位置(i,j)代表当长度为i的X子串与长度为j的Y的子串他们的最长公共子串,之所以要多创建一个是为了将边界值填入进去,边界值就是第一行跟第一列,指X长度为0或者Y长度为0时,自然需要填0,其他位置填数字时,当这两个位置数字相同,
dp[i][j] = dp[i-1][j-1]+1
;当这两个位置数字不相同时,dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j])
。最后二维数组最右下角的值就是最大子串。
public class MaxTwoArraySameOrder {
public static int MaxTwoArraySameOrderMethod(String str1,String str2) {
int m = str1.length();
int n = str2.length();
/*
* 定义一个二维数组保存公共子序列长度
* dp[i][j]表示字符串1从头开始长度是i,字符串2从头开始长度是j,这两个字符串的最长公共子序列的长度
* 设置数组行列比他们长度大一往二维数组中填写数字时,每个位置的数字跟他上方或者左方或者左上方数字有关系,这样处理边界数字时不用处理这种情况,方便接下来的循环
*/
int dp[][] = new int[m+1][n+1];
/*
* 初始化第一行第一列
* dp[0,j]表示啥?表示字符串1的长度是0,字符串2的长度是j,这两个字符串的最长公共子序列的长度是0,因为,字符串1 根本就没有嘛
*/
for(int i=0;i<=m;i++){
dp[i][0] = 0;
}
for(int i=0;i<=n;i++){
dp[0][i] = 0;
}
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
/*
* 如果当c[i][j]时,字符串1从头开始长度是i,字符串2从头开始长度是j时他们最后一个字符相同
* 就同时把他们向前移动一位,找c[i-1][j-1]时长度最大的再加一
* 表现在二维数组中就是c[i][j]左上方的点
*/
if(str1.charAt(i-1) == str2.charAt(j-1)){
dp[i][j] = dp[i-1][j-1]+1;
/*
* 如果当c[i][j]时,他们最后一个字符不相同
* 要将str1往前移动一位的c[i-1][j]的lcs长度,或者将str2往前移动一位的c[i][j-1]的lcs长度
* 哪个长,将它赋给c[i][j]
* 表现在二维数组中就是c[i][j]上方的点或者左方的点
*/
}else{
dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j]);
}
}
}
return dp[m][n];
}
public static void main(String[] args) {
String str1 = "BDCABA";
String str2 = "ABCBDAB";
int array = MaxTwoArraySameOrderMethod(str1,str2);
System.out.println(array);
}
}
6、背包问题
在N件物品取出若干件放在容量为W的背包里,每件物品的体积为W1,W2……Wn(Wi为整数),与之相对应的价值为P1,P2……Pn(Pi为整数),求背包能够容纳的最大价值。
像这种固定数值的组合问题,比如这个问题的W总容量,跟下个实例零钱问题的总钱数,都是适合用动态规划来解决的问题,对于这样的问题,动态规划的解法就是:创建一个二维数组,横坐标是从1开始到W,纵坐标是组成W的各种元素,本题中就是指W1,W2……Wn
,数组中每个位置(i,j)的数字就是当组成元素只有W1,W2……Wi
,背包可放容量为j时的结果,本题中就是容纳的最大价值。所以很容易分析出,当(i,j)时,如果Wi能放的下,空间减小,但是会增加Pi的价值,如果Wi不能放的下,空间不变,是(i-1,j)的价值,取其中最大值就好了,即状态转化方程为能放的下,dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-w[i]]+p[i])
;放不下,dp[i][j] = dp[i-1][j]
;
public static int PackageHelper(int n,int w[],int p[],int v) {
//设置一个二维数组,横坐标代表从第一个物品开始放到第几个物品,纵坐标代表背包还有多少容量,dp代表最大价值
int dp[][] = new int[n+1][v+1];
for(int i=1;i<n+1;i++){
for(int j=1;j<=v;j++){
if(j>=w[i]){
/*
* 当能放得下这个物品时,放下这个物品,价值增加,但是空间减小,最大价值是dp[i-1][j-w[i]]+p[i]
* 当不放这个物品时,空间大,物品还是到i-1,最大价值是dp[i-1][j]
* 比较这两个大小,取最大的,就是dp[i][j]
*/
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-w[i]]+p[i]);
}else{
//如果放不下,就是放上一个物品时的dp
dp[i][j] = dp[i-1][j];
}
}
}
return dp[n][v];
}
优化:滚动数组,只创建一个一维数组,长度为从1到W,初始值都是0,能装得下i时,dp[j] = Math.max(dp[j], dp[j-w[i]]+p[i])
;装不下时,dp[j] = dp[j]
;
public static int PackageHelper2(int n,int w[],int p[],int v) {
//设置一个二维数组,横坐标代表从第一个物品开始放到第几个物品,纵坐标代表背包还有多少容量,dp代表最大价值
int dp[] = new int[v+1];
for(int i=1;i<=n;i++){
for(int j=v;j>0;j--){
if(j>w[i]){
dp[j] = Math.max(dp[j], dp[j-w[i]]+p[i]);
}else{
dp[j] = dp[j];
}
}
}
return dp[v];
}
7、找零钱问题:有几种方法
具体思路同背包问题,这里只分析一下动态转化方程,能用这种零钱,分为用了这种零钱的方法跟没用到这种零钱的方法,dp[i][j] = dp[i-1][j] + dp[i][j-num[i]]
;如果不能用这种零钱,即所组成的面额小于当前零钱,直接等于不用这种零钱的数值,dp[i][j] = dp[i-1][j]
。这里要特别注意的是。
- 1、开始填写二维数组边界值时,第一行是填写只用第一种面额零钱组成相应数额的方法,要注意是总数额除以第一种面额取余为0才能组成,即如果第一种面额为2,不能组成3,5的数额等;
- 2、填写二维数组第一列时,代表到用到面额为i时,剩余数额为0,即只用i就可以组成相应数额,这也是一种方法,所以第一列的值,第一个为0,后面全为1.
public static int SmallMoney(int num[],int target) {
int m = num.length;
int dp[][] = new int[m][target+1];
dp[0][0] = 1;
for(int i=1;i<=target;i++){
if(i%num[0] == 0){
dp[0][i] = 1;//第一行数值填写
}else{
dp[0][i] = 0;
}
}
for(int i=0;i<m;i++){
dp[i][0] = 1;//第一列数值填写
}
for(int i=1;i<m;i++){
for(int j=1;j<=target;j++){
if(j<num[i]){
dp[i][j] = dp[i-1][j];
}else{
dp[i][j] = dp[i-1][j] + dp[i][j-num[i]];
}
}
}
return dp[m-1][target];
}
优化:动态数组,同背包问题的上述分析。
public static int SmallMoney2(int num[],int target) {
int m = num.length;
int dp[] = new int[target+1];
dp[0] = 1;
for(int i=1;i<=target;i++){
if(i%num[0] == 0){
dp[i] = 1;
}else{
dp[i] = 0;
}
}
for(int i=1;i<m;i++){
for(int j=1;j<=target;j++){
if(j>=num[i]){
dp[j] = dp[j] + dp[j-num[i]];
}
}
}
return dp[target];
}
8、找零钱问题:所用面额数量最少
跟上面思路相同,代码不同点:
- 1、填写边界值时,第一行仍是看取余是不是为0,如果为0,填的是除以它的商,即用了几张。
- 2、填写边界值第一列时, 第一列代表用了这一面额的纸币且剩下的数额为0,代表值用着一种纸币就可以构成这种数额,用的张数应该填到(i,j)处,所以第一列都是0;
- 3、写状态转化方程时,要注意判断,如果数额小于面额,直接等于上一层值,
dp[i][j]=dp[i-1][j]
; 如果数额等于面额,直接等于1;如果数额大于面额,先判断当用掉一张面额时,使用当前面额的剩余数额处是否有值,和不使用当前面额的剩余数额处是否有值,即当dp[i-1][j]!=0&&dp[i][j-num[i]]!=0
即这两处都有值,就看那一个更小,如果不都有,仅仅选择一个有值的就好了,具体见代码:
public static int SmallMoney(int num[],int target) {
int m = num.length;
int dp[][] = new int[m][target+1];
dp[0][0] = 0;
for(int i=1;i<=target;i++){
if(i%num[0] == 0){
dp[0][i] = i/num[0];//填入的是张数
}else{
dp[0][i] = 0;
}
}
for(int i=1;i<m;i++){
dp[i][0] = 0;//第一列应该为0
}
for(int i=1;i<m;i++){
for(int j=1;j<=target;j++){
if(j<num[i]){
dp[i][j] = dp[i-1][j];
}else if(j == num[i]){
dp[i][j] = 1;
}else{
if(dp[i-1][j]!=0&&dp[i][j-num[i]]!=0){//当两处都有值,即两种方法都可以组成当前数额,取用的张数更小的
dp[i][j] = Math.min(dp[i-1][j], dp[i][j-num[i]]+1);
}else{//如果不能,取能组成的就好
dp[i][j] = dp[i-1][j]!=0?dp[i-1][j]:dp[i][j-num[i]];
}
}
}
}
return dp[m-1][target];
}
优化:滚动数组,具体思路一样
public static int SmallMoney2(int num[],int target) {
int m = num.length;
int dp[] = new int[target+1];
dp[0] = 0;
for(int i=1;i<=target;i++){
if(i%num[0] == 0){
dp[i] = i/num[0];
}else{
dp[i] = 0;
}
}
for(int i=1;i<m;i++){
for(int j=1;j<=target;j++){
if(j<num[i]){
dp[j] = dp[j];
}else if(j == num[i]){
dp[j] = 1;
}else{
if(dp[j]!=0&&dp[j-num[i]]!=0){
dp[j] = Math.min(dp[j], dp[j-num[i]]+1);
}else{
dp[j] = dp[j]!=0?dp[j]:dp[j-num[i]];
}
}
}
}
return dp[target];
}
动态规划和分治区别:
- 动态规划算法:它通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。
- 分治法:若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。