动态规划1(DP)总结

递归是最简单也是最直接的思路,分解小问题,然后得到最终问题的答案。但是递归代码简单,但是比较耗时。所以我们思路可以用递归, 但是出于代码性能考虑,还是要提升效率。这当然是后期的优化了。
动态规划,回溯都是比较常用也比较常见的算法。这篇来讲动态规划。
动态规划的核心问题的最优解可以由子问题的最优解得到,那么我们就可以出这些子问题的最优解,然后构造出最终解。如果在拆分时,一个子问题重复出现,也就是说需要重复求解一个问题几次。那么,就采用自底而上的思路。先求小解,保存,在大问题用到此解时直接调用即可,省去了重复求解的时间。举个常见的例子吧,归并排序就是动态规划的一种,最后的排序依赖于每一个归并时的有序,所以子问题的最优解就是每两个归并时都要有序,将所有的都归并完成后自然就是一个排序好的数组。
从上述粗字可以推出,动态规划的使用情景就是可以拆分为相关子问题。所以在使用动态规划时要先分析问题的最优解是否跟子问题的最优解有关。
这里面有两种思路:自顶向下和自底向上,也就是从大问题到小问题和从小问题到大问题,是不是有点晕,不是子问题吗?举个例子就知道了。
有2、3、5三种钱币,要凑成20,。我们将结果表示为f(20),f(20)有三种情况:f(20)=f(18)+1;f(20)=f(17)+1;f(20)=f(15)+1;也就是18的币数+一张2,17的加3,15的加5。这样继续分解,这其实就是递归的子问题,但这样有个不足就是,会有子问题的重复计算,18的币数可能是15的币数+一张3,17的可能是15的币数+一张2,由于递归时两个单独计算,也就是说15的最少币数情况只有一个解,但是求了两次。这种叫自顶而下。但是我们反过来,先求f(1),到15,然后记录下来,那么之后再有关15的币数时直接使用即可,无需再求。这就是自底而上的好处。二者其实无本质区别,只是在子问题有重叠时,自底而上会减少计算从而提升效率。

dp的使用逻辑,也就是代码组织思路

  • 拆分子问题,分析最优解的组成
  • 定义最优解
  • 计算子问题最优解,并构造最优解路线
  • 根据最优解路线求出最优解

这里面子问题最优解容易理解,但是构造最优路线是什么意思。专业点来说其实叫状态转移方程。从一个状态到另一个状态需要一个选择或转换,那么这个规则就是能得到最优解的关键,也就是子问题怎么样一步步走向最终问题。
我们还是以上面问题举例。15的币数怎么走向20,也就是从15的状态走向20的最终状态,应该怎么转换。
实战一下:
题目:有2、3、5三种货币,凑够20的最少货币数是多少?
思路:最后是要f(20),按照上面分析的,我们从f(1)开始求。然后求出每个货币数的最少张数,到20就是20的币数了。
因此我们需要一个20的数组来保存结果。便于分析,我们dp[21],我们先用2来统计张数。

我们维持一个21的数组,动态的更新数组,当新的状态比旧的状态小时,就替换原位置状态。因为要更新为最少,所以我们开始以一个不可能的大值初始化数组,这样就可以进行比较,这里我以最大数初始化数组,0处置0,因为其实0只有一种情况,0.
主要分析的是状态转移方程。我们先分析子问题,怎么用2更新数组。因为2一张就加2,所以我们在比较时,就是前两个位置的张数+1张和当前位置比较,若是小于当前位置,说明此事的状态更好,更新。从2开始,0处+1和当前的无限大比较,小,所以2处更新为1,3处为1处+1和当前状态,无限大。4处为2处+1和当前,更新为2。直至到了20.此时2的情况已经全部求出,我们现在加入3,还是之前的思路,每加一张3就会大3,所以需要和三个位置前的比较。3处为0处+1和当前,更新为1,4处为1处+1和当面,无限大,5处为2处+1和当前,更新为2.也就是一张3和一张2是当前5的最小币数。然后更新到最后为7.6个3和一个2,是最少币数。
加入5,同样的道理。
代码如下:

#include <bits/stdc++.h>
using namespace std;
int main(){
    int n,aim;
    cin>>n>>aim;
    vector<long long> dp(aim+1,INT_MAX);//longlong型防止数据过大时报错
    dp[0]=0;
    for(int i=1;i<=n;i++)
    {
        long long value;
        cin>>value;
        for(int j=value;j<=aim;j++)
            dp[j]=min(dp[j-value]+1,dp[j]);//状态转移方程
    }
    if(dp[aim]==INT_MAX)//不存在
    {
        cout<<"-1"<<endl;
    }
    else
    {
        cout<<dp[aim]<<endl;
    }
    return 0;
}

其实dp使用最多的情况时路径规划问题,也可以说是矩阵遍历问题。这种问题在使用动态规划时可以很好地改善性能。
直接实战吧,剑指offer的礼物价值题很经典。
礼物的最大价值
这是典型的动态规划。因为每次都只能向右或向下走,所以这就是递归的方向。显然,每个动态规划问题都可以写成这样形式:f(i,j)=max(f(i-1,j),f(i,j-1))+value(i,j);然后递归解下去即可。从之前的分析可以看出,这样会有大量的重复计算,所以我们还是用循环从下而上的解决问题,为此,我们需要一个辅助数组来存取结果。
代码献上:

int valuesum(vector<vector<int>>& gift)
{
	int rows=gift.size();
	int cols=gift[0].size();
	if(rows==0||cols==0)
		return 0;
	vector<vector<int>> dp(rows+1,vecot<int>(cols+1,0));//此处辅助数组多申请一行一列的原因之后会分析
	for(int i=0;i<rows;i++)
	{
		for(int j=0;j<cols;j++)
		{
			dp[i+1][j+1]=max(dp[i][j+1],dp[i+1][j])+gift[i][j];//若是申请原空间大小的数组此处就需要有上方无数和左方无数的判断,这样申请数组,就无需判断,自动将超出数组的数置0,直接用即可
		}
	}
	return dp[rows][cols];
}

再深入分析,第二行的状态依赖于第一行,而第三行的依赖于第二行,也就是说最多只需要维持一行的数据动态更新就可以了,这样就可以对空间进行优化。我们在更新i,j处的数据时,需要知道左面和上面的数,如果用一个一位数组a[cols]来保存,那么i,j处前方的就是已经更新过得左值,后面就是未更新的上值。用代码来解释吧。

int valuesum(vector<vector<int>>& gift)
{
	int rows=gift.size();
	int cols=gift[0].size();
	if(rows==0||cols==0)
		return 0;
	vector<int> dp(cols,0);
	for(int i=0;i<rows;i++)
	{
		for(int j=0;j<cols;j++)
		{
			int left=0;
			int up=0;
			if(i>0)
				up=dp[j];//上面分析第i行需要依赖第i-1行,所以此处未更新,为i-1行j处数据,也就是上值
			if(j>0)
				left=dp[j-1];//此时已更新,所以是第i行j-1个数据,也就是左值
			dp[j]=max(dp[j-1],dp[j])+gift[i][j];
		}
	}
	return dp[cols-1];
}

可以分析出动态数组的时间效率基本是最小了,但是空间的优化是不一样的,这个需要具体问题分析。
好了,今天就到这了。之后继续写。感兴趣的可以继续看之后的文章,会有动态规划2,+贪心的讲解。

发布了22 篇原创文章 · 获赞 0 · 访问量 359

猜你喜欢

转载自blog.csdn.net/yanchenzhi/article/details/105018247