动态规划专题

一 动态规划的递归写法和递推写法

1 动态规划

动态规划DP是用来解决一类最优化问题的算法思想。

简单来说,动态规划将一个复杂的问题分解为若干个子问题,通过综合子问题的最优解来得到原问题的最优解。动态规划会将每个求解过程的子问题的解记录下来,这样当下次碰到同样的子问题时,就可以直接使用之前记录的结果,而不是重复计算。

一般可以用递归或递推的方式来解决动态规划问题,其中递归写法又叫做记忆化搜索

最优子结构性质和子问题重叠性质是该问题可用动态规划算法求解的基本要素:

1.最优子结构

       当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。问题的最优子结构性质提供了该问题可用动态规划算法求解的重要线索。

       在动态规划算法中,利用问题的最优子结构性质,以自底向上的方式递归地从子问题的最优解逐步构造出整个问题的最优解。

2.重叠子问题

       可用动态规划算法求解的问题应具备的另一个基本要素是子问题的重叠性质。在用递归算法自顶向下求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,当再次需要此子问题时,只要简单地用常数时间查看一下结果。通常,不同的子问题个数随问题的大小呈多项式增长。因此,用动态规划算法通常只需要多项式时间,从而获得较高的解题效率。

2 动态规划的递归写法

以斐波那契数列为例,常规的递归解法是:

int F(int n)
{
    if(n==0||n==1)
        return 1;
    else
        return F(n-1)+F(n-2);
}

但事实上这个递归存在很多重复的计算。时间复杂度为O(2^n)

为了避免重复计算,我们可以开一个一维数组dp,用以保存已经计算过的结果。其中dp[n]来记录F(n)的结果,如果dp[n]=-1,说明还没有计算过,按照递归式进行递归,如果不等于-1,说明已经计算过,直接返回结果。

int F(int n)
{
    if(n==0||n==1)
        return 1;
    if(dp[n]!=-1)
        return dp[n];
    else
    {
        dp[n]=dp[n-1]+dp[n-2];
        return dp[n];
    }
}

这样就可以把计算结果记录下来,而不用重新计算。这就是记忆化搜索。

这个方法把时间复杂度由O(2^n)降到了O(n),但是占用了O(n)的空间复杂度。

这个例子引出了一个概念,如果一个问题可以被分解为若干个子问题,且这些子问题重复出现,那么称这个问题拥有重叠子问题。因此,一个问题必须拥有重叠子问题,才可以使用动态规划去解决。

3 动态规划的递推写法

数塔问题:将一些数字排成塔的形状,其中第一层有一个数字,第2层有2个数字,.....第n层有n个数字。现在要从第1层走到第n层,每次只可以走向下一层连接的2个数字中的一个。问最后路径上所有数字相加之后得到的和最大是多少?

很显然,我们可以使用暴力解决,穷举所有路径,但每层中数字都可以有2种方式,因此时间复杂度为O(2^n)。

比如,当我们从5-8-7访问到7时,会枚举从7出发到达底部的所有路径,并计算最大值。

当我们从5-3-7访问到7时,又会重复相同的操作。

我们不妨把中间结果纪律下来,令dp[i][j]表示从第i行第j个数字出发到最底层的所有路径中得到的最大和。如dp[3][2]就是数字7到底层的最大路径和。而dp[1][1]就是最后的结果。

很容易知道:dp[1][1]=max(dp[2][1],dp[2][2])+f[1][1],

归纳如下:

dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[i][j];

把dp[i][j]称为问题的状态,把上面的式子称为状态转移方程。它把状态dp[i][j]转化为d[i+1][j]和dp[i+1][j+1];

而dp[i][j]只与第I+1层有关。数塔的最后一层的dp值等于元素本身。即dp[n][j]=f[n][j],这就是边界,而动态规划的递推写法总是从这些边界出发,通过状态转移方程扩散到整个dp数组。

这样可以从最底层各位置的dp值开始,不断向上求取每一层各位置的dp值,最后就会得到dp[1][1]。

#include <cstdio>
#include <algorithm>
using namespace std;

const int maxn=10;
int f[maxn][maxn];
int dp[maxn][maxn];

int main()
{
    int n;
    scanf("%d",&n);
    for(int i=0;i<n;i++)
    {
        for(int j=0;j<=i;j++)
        {
            scanf("%d",&f[i][j]);
        }
    }

    //初始化边界
    for(int i=0;i<n;i++)
    {
        dp[n-1][i]=f[n-1][i];
    }

    for(int i=n-2;i>=0;i--)
    {
        for(int j=0;j<=i;j++)
        {
            dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[i][j];
        }
    }
    printf("%d\n",dp[0][0]);
    return 0;
}

显然使用递归也可以实现上面的例子。2者的区别在于,使用递推的计算方式是自底向上,即从边界开始,不断向上解决问题,直到解决了目标问题。

而使用递归,其计算方式是自顶向下,即从目标问题开始,将其分解为子问题的组合,直到分解到边界为止。

通过这个例子,再引出一个概念:如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么称这个问题拥有最优子结构。最优子结构保证了动态规划的最优解可以由子问题的最优解推导出来。因此一个问题必须拥有最优子结构,才可以用动态规划的思想去解决。

因此,一个问题必须拥有重叠子问题最优子结构才可以用动态规划去解决。

区别:

(1)分治与动态规划

他们都是将原问题分解为子问题,然后合并子问题的解得到原问题的解。

但不同的是,分治法分解出的子问题是不重叠的。因此分治法解决的问题不具有重叠子问题,不可以用动态规划来解决。比如归并排序和快速排序都是使用分治法。另:分治法解决的问题不一定是最优化问题,而动态规划解决的问题一定是最优化问题。

(2)贪心和动态规划

他们都要求原问题必须拥有一个最优子结构。

贪心是自顶向下,(动归是自低向上)并不等待子问题求解完成之后再去选择使用哪一个。而是通过一种策略直接选择一个子问题去求解,没被选择的子问题就不被求解,直接抛弃。他总是在上一步选择的基础上进一步选择。但是贪心算法不一定是最优解。

动态规划本质还是暴力搜索,只不过采用了额外的空间来保存子问题的解,避免重复计算子问题的解,是一种以空间换时间的策略。

动态规划希望复用子问题的解,最好被反复依赖。其本质还是穷举,所以当前并不知道哪个子问题的解会构成最终最优解。但知道这个子问题可能会被反复计算,所以把结果缓存起来。整个过程是树状的搜索过程。

贪心希望每次都能排除一堆子问题。它不需要复用子问题的解,当前最优解从子问题最优解即可得出。整个过程是线性的推导过程。

贪心是求局部最优,以得到全局最优(不一定是正确的,需要证明)

dp是通过一些状态来描述一些子问题,然后通过状态之间的转移来求解(一般只要转移方程是正确的,答案必然是正确的)

贪心与动归的区别

关于递推和递归

1,从程序上看,递归表现为自己调用自己,递推则没有这样的形式。

2,递归是从问题的最终目标出发,逐渐将复杂问题化为简单问题,最终求得问题。是自顶向下的。

递推是从简单问题出发,一步步的向前发展,最终求得问题。是正向的。是自底向上的。

3,递归中,问题的n要求是计算之前就知道的,而递推可以在计算中确定,不要求计算前就知道n。

4,一般来说,递推的效率高于递归(当然是递推可以计算的情况下)

 

 

二 背包问题

1 01背包问题

问题描述:

有n件物品,每件物品的重量为w[i],价值为c[i],现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每件物品只有1件。

显然,这道题目也可以使用穷举法,暴力枚举每一件物品放或不放进去背包,显然时间复杂度为O(2^n)。解法参见DFS深度优先遍历

而动态规划可以将复杂度将为O(nV)

动态规划解法

令dp[i][v]表示前i件物品(1=<i<=n,0=<v<=V)恰好装进容量为v的背包中所能获得的最大价值。

问题转化为如何求取状态dp[i][v]:

考虑对第i件物品的选择策略,有2种策略:

(1)不放第i件物品,那么问题转化为前i-1件物品刚好装进容量为v的背包中所能获得的最大价值。,有

dp[i][v]=dp[i-1][v];

(2)放入第i件物品,那么问题转化为前i-1件物品恰好装进容量为v-w[i]的背包中所获得的最大价值。

dp[i-1][v-w[i]]+c[i];

由于只有这2种策略,且要求获得最大价值。

dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i]]+c[i]);
(1=<i<=n,w[i]<=v<=V)

上述就是状态转移方程,注意到dp[i][v]只与dp[i-1]有关,所以可以枚举i从1到n,v从0到V,通过边界dp[0][v]=0(0<=v<=V)就可以把整个数组dp递推出来。

而由于dp[i][v]表示的是恰好为v的情况,因此需要枚举dp[n][v](0<=v<=V)取其最大值才是最后的结果。

for(int i=1;i<=n;i++)
{
    for(int v=w[i-1];v<=V;v++)
    {
        dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i-1]]+c[i-1]);
    }
}

可以知道时间复杂度为O(nV),其中时间复杂度无法优化,但是空间复杂度可以优化。

空间优化

注意到状态方程中计算dp[i][v]时总是只需要dp[i-1][v]及其左侧的数据,且当计算dp[i+1][]时,dp[i-1]的数据又完全用不到了。因此可以直接开辟一个一维数组即可。枚举方向变为i从1到n,v从V到0(逆序),这样方程变为:

dp[v]=max(dp[v],dp[v-w[i]]+c[i])
(1<=i<=n,w[i]<=v<=V)

为何逆序:

因为dp[v]存储dp[i-1][v]的值,现在存储的是dp[i][v],如果之后在求dp[m]时用到dp[i-1][v]时,发现已被覆盖,所有要使用逆序。

#include <cstdio>
#include <algorithm>

using namespace std;

const int maxn=100;
const int maxv=1000;

int w[maxn],c[maxn],dp[maxv];

int main()
{
    int n,V;
    scanf("%d%d",&n,&V);
    for(int i=0;i<n;i++)
    {
        scanf("%d",&w[i]);   //注意下标范围从0-n-1
    }

    for(int i=0;i<n;i++)
    {
        scanf("%d",&c[i]);    //注意下标范围从0-n-1
    }

    for(int v=0;v<=V;v++)
    {
        dp[v]=0;
    }

    for(int i=1;i<=n;i++)   //i从下标1开始,但对应的目前物品下标是i-1
    {
        for(int v=V;v>=w[i-1];v--)
        {
            dp[v]=max(dp[v],dp[v-w[i-1]]+c[i-1]);  //注意此时w[i-1]表示当前物品,c[i-1]表示当前物品价值;
        }
    }

    int max_=0;
    for(int v=0;v<=V;v++)
    {
        if(dp[v]>max_)
        {
            max_=dp[v];
        }
    }

    printf("%d",max_);
    return 0;
}

01背包中的每一个物品都可以看作是一个阶段,这个阶段中的状态有dp[i][0]到dp[i][v],他们均可以由上一个阶段状态得到。其实,对可以划分阶段的问题来说,都可以尝试把阶段作为状态的一维。这可以更加方便满足无后效性。

如果当前设计的状态不满足无后效性,不妨把状态进行升维,即增加一维或若干维来表示相应信息。

2 完全背包问题

问题描述:

有n件物品,每件物品的重量为w[i],价值为c[i],现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每件物品只有无穷件。

该问题与01背包问题唯一区别在于完全背包问题中每件物品有无穷件,对同一物品可以选择1次,2次....只要不超过容量V即可。

对第i件物品来说,有2种策略:

(1)不放第i件物品,那么dp[i][v]=dp[i-1][v]

(2)放第i件物品,选择第i件物品,并不转到dp[i-1][v-w[i]],而是转到dp[i][v-w[i]],这是因为每件物品理论上可以放任意件。

状态转移方程如下:

dp[i][v]=max(dp[i-1][v],dp[i][v-w[i]]+c[i])
(1<=i<=n,w[i]<=v<=V);

边界为d[0][v]=0(0<=v<=V)

改写为一维之后,与01背包问题完全一样

dp[v]=max(dp[v],dp[v-w[i]]+c[i])
(1<=i<=n,w[i]<=v<=V)

边界为dp[v]=0(0<=v<=V)

唯一区别在于v的枚举是正序的。

因为求解dp[i][v]总是需要它左边的dp[i][v-w[i]]和上方的dp[i-1][v],显然如果让v从小到大枚举,dp[i][v-w[i]]就总是已经计算出的结果。

#include <cstdio>
#include <algorithm>

using namespace std;

const int maxn=100;
const int maxv=1000;

int w[maxn],c[maxn],dp[maxv];

int main()
{
    int n,V;
    scanf("%d%d",&n,&V);
    for(int i=0;i<n;i++)
    {
        scanf("%d",&w[i]);   //注意下标范围从0-n-1
    }

    for(int i=0;i<n;i++)
    {
        scanf("%d",&c[i]);    //注意下标范围从0-n-1
    }

    for(int v=0;v<=V;v++)
    {
        dp[v]=0;
    }

    for(int i=1;i<=n;i++)
    {
        for(int v=w[i-1];v<=V;v++)
        {
            dp[v]=max(dp[v],dp[v-w[i-1]]+c[i-1]);
        }
    }

    int max_=0;
    for(int v=0;v<=V;v++)
    {
        if(dp[v]>max_)
        {
            max_=dp[v];
        }
    }

    printf("%d",max_);
    return 0;
}

题目一:快手2020校园招聘秋招笔试题目:

一开始没有思路,想了好久想到了01背包问题:

背包的总容量是数组元素和/2,问题可以转化为怎么把数字装入背包中使得背包的总价值最大,数字的重量和价值一样都是数字的数值。求出最大的价值,另一个数组和就出来了。

#include <cstdio>
#include <iostream>
#include <algorithm>
#include <vector>
#include <cstring>

using namespace std;

int main()
{
    int n;
    cin>>n;
    vector<int> vt(n);
    int sum=0;
    for(int i=0;i<n;i++)
    {
        cin>>vt[i];
        sum+=vt[i];
    }
    
    int num=sum/2;
    sort(vt.begin(),vt.end());
    
    int dp[num+1];
    memset(dp,0,sizeof(dp));
    
    for(int i=0;i<n;i++)
    {
        for(int v=num;v>=vt[i];v--)
        {
            dp[v]=max(dp[v],dp[v-vt[i]]+vt[i]);
        }
    }
    
    int maxv=-1;
    for(int i=0;i<=num;i++)
    {
        if(dp[i]>maxv)
            maxv=dp[i];
    }
    
    int left=maxv;
    int right=sum-left;
    cout<<abs(left-right);
    
}

 

推荐2篇不错的总结:

动态规划1

动态规划2

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值