动态规划算法的复习和应用

今天在leetcode周赛上做了一道题,用到了动态规划的思想,发现这部分已经忘记了,现在复习+整理一下。

动态规划算法的核心是记住已经解决了的子问题的解,从而节省时间。

动态规划算法的条件是:问题可以分成若干个子问题,而且在任一阶段后的行为依赖于该阶段的状态,与该阶段之前的过程如何达到这种状态的方法无关。

动态规划算法的基本要素

  1. 最优子结构  当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。

用动态规划求解最优化问题的第一步就是刻画最优解的结构,如果一个问题的解结构包含其子问题的最优解,就称此问题具有最优子结构性质。因此,某个问题是否适合应用动态规划算法,它是否具有最优子结构性质是一个很好的线索。使用动态规划算法时,用子问题的最优解来构造原问题的最优解。因此必须考查最优解中用到的所有子问题。

例如求一个数组的最大连续子数组和,对于第n个数,可以根据它前面n-1个数的结果推理,在前n-1个数的最大连续子数组和的基础上有加上第n个数和只取第n个数两种选择,max(n)=max(getMax(n-1)+a(n),a(n))

2.重叠子问题  在用递归算法自顶向下求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法利用子问题的重叠性,对每个子问题只解一次,然后保存在表中,当再次需要解此子问题时,只需从表中查看结果即可。

 

动态规划算法的两种形式

  1. 备忘录法(自顶向下)

递归实现的斐波那契数列:

public int fib(int n)
{
    if(n<=0)
        return 0;
    if(n==1)
        return 1;
    return fib( n-1)+fib(n-2);
}

备忘录法:

备忘录法的控制结构和直接递归法一致,但是区别就是它为每个解过的子问题建立备忘录以备查看,避免了相同子问题的重复求解。

2.自底向上的解法

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;
    }

一般当一个问题的所有子问题都至少需要求解一次的时候,用动态规划算法比备忘录法好,因为动态规划法没有任何多余的计算。

当子问题空间中的部分子问题没必要求解时,用备忘录法比较好,因为它只解那些需要求解的问题。

动态规划的应用

  • 钢条切割问题:

Serling公司购买长钢条,将其切割为短钢条出售。切割工序本身没有成本支出。公司管理层希望知道最佳的切割方案。假定我们知道Serling公司出售一段长为i英寸的钢条的价格为pi(i=1,2,…,单位为美元)。钢条的长度均为整英寸。图15-1给出了一个价格表的样例。

钢条切割问题是这样的:给定一段长度为n英寸的钢条和一个价格表pi(i=1,2,…n),求切割钢条方案,使得销售收益rn最大。注意,如果长度为n英寸的钢条的价格pn足够大,最优解可能就是完全不需要切割。

递归

public static int cut(int []p,int n)
    {
        if(n==0)
            return 0;
        int q=Integer.MIN_VALUE;
        for(int i=1;i<=n;i++)
        {
            q=Math.max(q, p[i-1]+cut(p, n-i));  
        }
        return q;
    }

备忘录

public static int cutMemo(int []p)
    {
        int []r=new int[p.length+1];
        for(int i=0;i<=p.length;i++)
            r[i]=-1;                        
        return cut(p, p.length, r);
    }
    public static int cut(int []p,int n,int []r)
    {
        int q=-1;
        if(r[n]>=0)
            return r[n];
        if(n==0)
            q=0;
        else {
            for(int i=1;i<=n;i++)
                q=Math.max(q, cut(p, n-i,r)+p[i-1]);
        }
        r[n]=q;

        return q;
    }

自底向上

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];
    }
  • 线性模型

线性模型的是动态规划中最常用的模型,上文讲到的钢条切割问题就是经典的线性模型,这里的线性指的是状态的排布是呈线性的。【例题1】是一个经典的面试题,我们将它作为线性模型的敲门砖。

【例题1】在一个夜黑风高的晚上,有n(n <= 50)个小朋友在桥的这边,现在他们需要过桥,但是由于桥很窄,每次只允许不大于两人通过,他们只有一个手电筒,所以每次过桥的两个人需要把手电筒带回来,i号小朋友过桥的时间为T[i],两个人过桥的总时间为二者中时间长者。问所有小朋友过桥的总时间最短是多少。

每次过桥的时候最多两个人,如果桥这边还有人,那么还得回来一个人(送手电筒),也就是说N个人过桥的次数为2*N-3(倒推,当桥这边只剩两个人时只需要一次,三个人的情况为来回一次后加上两个人的情况…)。有一个人需要来回跑,将手电筒送回来(也许不是同一个人,realy?!)这个回来的时间是没办法省去的,并且回来的次数也是确定的,为N-2,如果是我,我会选择让跑的最快的人来干这件事情,但是我错了…如果总是跑得最快的人跑回来的话,那么他在每次别人过桥的时候一定得跟过去,于是就变成就是很简单的问题了,花费的总时间:

T = minPTime * (N-2) + (totalSum-minPTime)

来看一组数据 四个人过桥花费的时间分别为 1 2 5 10,按照上面的公式答案是19,但是实际答案应该是17。

具体步骤是这样的:

第一步:1和2过去,花费时间2,然后1回来(花费时间1);

第二歩:3和4过去,花费时间10,然后2回来(花费时间2);

第三部:1和2过去,花费时间2,总耗时17。

所以之前的贪心想法是不对的。我们先将所有人按花费时间递增进行排序,假设前i个人过河花费的最少时间为opt[i],那么考虑前i-1个人过河的情况,即河这边还有1个人,河那边有i-1个人,并且这时候手电筒肯定在对岸,所以opt[i] = opt[i-1] + a[1] + a[i] (让花费时间最少的人把手电筒送过来,然后和第i个人一起过河)如果河这边还有两个人,一个是第i号,另外一个无所谓,河那边有i-2个人,并且手电筒肯定在对岸,所以opt[i] = opt[i-2] + a[1] + a[i] + 2*a[2] (让花费时间最少的人把电筒送过来,然后第i个人和另外一个人一起过河,由于花费时间最少的人在这边,所以下一次送手电筒过来的一定是花费次少的,送过来后花费最少的和花费次少的一起过河,解决问题)
所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2*a[2] }

  • 区间模型

区间模型的状态表示一般为d[i][j],表示区间[i, j]上的最优解,然后通过状态转移计算出[i+1, j]或者[i, j+1]上的最优解,逐步扩大区间的范围,最终求得[1, len]的最优解。

【例题2】给定一个长度为n(n <= 1000)的字符串A,求插入最少多少个字符使得它变成一个回文串。
典型的区间模型,回文串拥有很明显的子结构特征,即当字符串X是一个回文串时,在X两边各添加一个字符’a’后,aXa仍然是一个回文串,我们用d[i][j]来表示A[i…j]这个子串变成回文串所需要添加的最少的字符数,那么对于A[i] == A[j]的情况,很明显有 d[i][j] = d[i+1][j-1] (这里需要明确一点,当i+1 > j-1时也是有意义的,它代表的是空串,空串也是一个回文串,所以这种情况下d[i+1][j-1] = 0);当A[i] != A[j]时,我们将它变成更小的子问题求解,我们有两种决策:

1、在A[j]后面添加一个字符A[i];

2、在A[i]前面添加一个字符A[j];

根据两种决策列出状态转移方程为:

d[i][j] = min{ d[i+1][j], d[i][j-1] } + 1; (每次状态转移,区间长度增加1)

空间复杂度O(n^2),时间复杂度O(n^2), 下文会提到将空间复杂度降为O(n)的优化算法。

  • 背包模型

背包问题是动态规划中一个最典型的问题之一。由于网上有非常详尽的背包讲解,这里只将常用部分抽出来。

【例题3】有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) )。

动态规划题集整理

1、最长单调子序列
Constructing Roads In JG Kingdom★★☆☆☆
Stock Exchange ★★☆☆☆

2、最大M子段和
Max Sum ★☆☆☆☆
最长公共子串 ★★☆☆☆

3、线性模型
Skiing ★☆☆☆☆

leetcode周赛问题和解答

如果是一个数组(不重复k次),代码如下:

vector<int> getMax(vector<int>& arr){//动态规划
        int maxendingHere=0;//目前这一趟搜索的最大值
        int maxsofar=INT_MIN;//全局最大值
        int start=0;
        int end=0;
        for(int i=0;i<arr.size();i++){
            maxendingHere+=arr[i];
            if(maxendingHere<0){//小于0直接终止,因为结果必须>=0
                maxendingHere=0;
                start=i+1;
            }
            if(maxsofar<maxendingHere){//更新maxsofar
                maxsofar=maxendingHere;
                end=i;
            }
        }
        vector<int> retvec;
        retvec.push_back(maxsum);
        retvec.push_back(start);
        retvec.push_back(end);
        printf("%d\t%d\t%d\n",retvec[0],retvec[1],retvec[2]);
        return retvec;
    }

算法动画演示地址https://algorithm-visualizer.org/dynamic-programming/maximum-subarray

重复k次的解法

class Solution {
public:
    int kConcatenationMaxSum(vector<int>& arr, int k) {//不考虑k=1
        if(arr.empty()) return 0;
        int len=arr.size();
        int sum=0;
        int sum2=0;
        int res=INT_MIN;
        for(int i=0;i<2*len;i++){
            if(i<len) sum+=arr[i];
            sum2+=arr[i%len];
            res=max(res,sum2);//求两个重复arr的最大子段和
            if(sum2<=0) sum2=0;//sum2作为遍历过程中的负责存值的变量,如果sum2<0,就舍弃,以下一个值为起点
        }
        if(sum>0){
            for(int i=2;i<k;i++){
                res+=sum;
                res%=1000000007;//不需等全部结果算完再取模
            }·

        }
        return res>0?res:0;
    }
};

O(N)解法
由于原始数组是由arr重复k次得到,我们先来思考k=1和2的情况。显然k<3的时候我们可以直接按照暴力解法得到结果。那么对于重复3次或3次以上的情形,我们的结果会如下图所示:

图片1.png


中间的arr会整体重复若干次,我们考虑sum(arr),如果sum(arr)<=0,由于中间的值都是负数,我们显然可以直接将其去掉而只保留首尾:

图片2.png


而如果sum(arr)> 0,中间的arr整体则需要尽量多的保留(k-2个),因为加上这些显然是可以继续增大我们的最大子段和的:

图片3.png


那么中间的重复部分做法就很明确了,我们根据他的和是否大于0来决定我们是需要保留0个还是保留k-2个,然后我们可以将中间的部分替换为一个数,也就是他的和:

图片4.png


最后,我们只需要在这个新的数组上求最大子段和就可以了。

来分析时间复杂度,由于我们真正计算最大子段和的数组长度最多为N*2+1,所以时间复杂度为O(N)。

题解作者:CodeWeekly
链接:https://leetcode-cn.com/circle/article/rfNDYK/

参考文献:https://blog.csdn.net/u013309870/article/details/75193592

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值