算法设计方法4:动态规划经典例题总结

前言:各大公司关于动态规划的笔试题太多了,必须得掌握。上篇文章提过,使用动态规划的五大步骤:

1. 判题题意是否为找出一个问题的最优解
2. 将原问题分解为子问题
3. 从下往上分析问题 ,找出这些问题之间的关联(状态转移方程),如何从一个或多个已知状态求出另一个未知状态的值。(递推型)
4. 讨论底层的边界问题,确定一些初始状态(边界状态)的值,并用数组存储初始子问题的解
5. 解决问题(通常使用数组进行迭代求出最优解)

自下向上的动态规划:一般需要恰当定义子问题“规模”的概念,使得任何子问题的求解都只依赖于“更小的”子问题的求解。因此,我们可以将子问题按照规模顺序,由小至大顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已求解完毕,结果已经保存。每个子问题只需求解一次,当我们求解它时,它的所有前提子问题都已求解完成。

一、剪绳子

来源:剑指Offer(第二版)面试题14:剪绳子

问题描述:给你一根长度为n的绳子,请把绳子剪成m段 (m和n都是整数,n>1并且m>1)。每段绳子的长度记为k[0],k[1],…,k[m],请问k[0]k[1]…*k[m]可能的最大乘积是多少?

例如,当绳子的长度为8时,我们把它剪成长度分别为2,3,3的三段,此时得到的最大乘积是18.

看完题目,我们按照上面提到的“动态规划五部曲”解决问题
1、判题题意是否为找出一个问题的最优解
看到字眼是“可能的最大乘积是多少”,判断是求最优解问题,可以用动态规划解决;

2、从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题
题目中举了个例子:当绳子的长度为8时,我们把它剪成长度分别为2,3,3的三段,此时得到的最大乘积是18;我们可以从这里开始突破,把长度为8绳子的最大乘积分解为数个子问题,长度为8我们可以把它看成长度为1和7的绳子的和,或者长度 为2和6的绳子的和,或者长度为3和5的绳子的和and so on!
到这里,相信大家已经看到一丝真理了吧?

3. 从下往上分析问题 ,找出这些问题之间的关联(状态转移方程)
在第二点时,我们已经从上到下分析问题了,现在我们要从下往上分析问题了。分析可知,
f(8) 的值就是f(1)*f(7),f(2)*f(6),f(3)*f(5),f(4)*f(4)它们之中的最大值,即f(8) = Max{f(1)*f(7),f(2)*f(6),f(3)*f(5),f(4)*f(4)}
只要知道f(1)到f(7)的值就能求出f(8);对于f(7),只要知道f(1)到f(6)的值就能求出f(6);对于f(6),只要知道f(1)到f(5)的值就能求出f(6);以此类推,我们只要知道前几个边界的值,就能一步步迭代出后续的结果!
状态转移方程: f(n)=Max{f(n-i)*f(i)} i={1,2,3,…,n/2}

4. 讨论底层的边界问题
底层的边界问题说的就是最小的前几个数值的f(n)的值,本题中就是f(0)、f(1)、f(2)、f(3)的值
对于f(0),长度为0的绳子,没办法剪,没有意义
对于f(1),长度为1的绳子,没办法剪,设为1
对于f(2),长度为2的绳子,只有一种剪法,剪成两段长度为1的绳子,但剪后的乘积为1,比自身更小;如果不是求自身的值,要求乘积最大值的话就没必要剪。
对于f(3),长度为3的绳子,只有一种剪法,剪成两段长度为1和2的绳子,但剪后的乘积为2,比自身更小;如果不是求自身的值,要求乘积最大值的话也没必要剪。

Java代码:
算法设计方法4:动态规划经典例题总结_第1张图片

二、青蛙跳台阶

问题描述:一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个n级台阶总共有多少种跳法。

1、判题题意是否为找出一个问题的最优解
本质上dp算法是一种高效的枚举算法,只不过有时我们的问题是找出所有可能枚举值中满足某个最优条件的那个状态,所有很多没有经过系统的运筹学数学知识培养的同学会把dp纯粹当成了优化问题的算法。许多非优化问题,比如需要枚举出所有可能结果的问题,dp是非常适用的。

2、从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题
题目中没有给粟子,我们可以自己举点粟子。例如,跳上一个6级台阶台阶,有多少种跳法;由于青蛙一次可以跳两阶,也可以跳一阶,所以我们可以分成两个情况
1、青蛙最后一次跳了两阶,问题变成了“跳上一个4级台阶台阶,有多少种跳法”
2、青蛙最后一次跳了一阶,问题变成了“跳上一个5级台阶台阶,有多少种跳法”
由上可得f(6) = f(5) + f(4);
由此类推,f(4)=f(3) +f(2)

3.、从下往上分析问题 ,找出这些问题之间的关联(状态转移方程)
跟上面的例题一相同,可以由f(1)逐渐迭代上去
由2可得,状态转移方程为:f(n)=f(n-1)+f(n-2)

4、边界情况分析
跳一阶时,只有一种跳法,所以f(1)=1
跳两阶时,有两种跳法,直接跳2阶,两次每次跳1阶,所以f(2)=2
跳两阶以上可以分解成上面的情况

Java代码:

public static int jump(int n) 
{ 
   //无意义的情况 
   if(n <= 0) 
     return 0; 
   if(n == 1) 
     return 1; 
   if(n == 2)
     return 2; 

  //数组用于存储跳n阶的跳法数 
   int[] value = new int[n + 1]; 
   value[0] = 0; 
   value[1] = 1;
   value[2] = 2;

   for(int i = 3; i <= n; i++) 
   { 
     value[i] = value[i - 1] + value[i - 2]; 
   }

   return value[n]; 
}

三、01背包问题

问题描述: 有n个物品(每种物品仅有一件),每种物品i都有自己的重量Wi和价值Vi。现有给定容量为M的背包,我们应该如何选择物品装入背包,使得装入背包的物品总价值最大?

假定每件物品i的装入情况为Xi,得到的效益是Vi*Xi。

(1)部分背包问题:在选择物品时,可以将物品分割为部分装入背包,即0≤x≤1 (贪心算法)。

(2) 0/ 1背包问题:和部分背包问题相似,但是在选择物品装入时要么不装,要么全装入,即x=1或0。(动态规划算法)。

01背包问题分析:设我们的背包里面的物品价值为b,给背包添加两个参数:k和c,即b(k,c),那么b(k,c)又表示什么什么意思呢?

k表示你面对的物品编号,即1~5,
c表示你面对k号物品时,背包的剩余容量
b(k,c)表示面对k号物品,并作出拿或不拿的选择之后,背包里面的物品总价值

举个例子,b(2,20)表示的是,在你的背包容量为20的情况下,当你面对2号物品时并作出拿或者不拿的选择后,背包中物品的总价值。

了解了这个概念后我们继续:
假设你现在遇见了第k号物品,此时你的背包容量为c,你得做出一个决策,到底要不要拿走第k件物品呢?那么拿不拿的前提是啥?当然是这个物品重不重,能不能塞到包里。

第一种情况:

如果第k件物品的重量w[k]比此时的背包的剩余重量c大了,那我肯定是拿不动了,即w[k]>c。所以此时包中物品的价值就是我拿的前一个物品之后包中的价值,即 b(k,c)=b(k-1,c).包中剩余空间不变,还是c。 

第二种情况:

如果我拿得动第k件物品,即第k件物品的重量w[k],面对k号物品,无外乎两种选择,拿或者不拿,这时我就要根据拿走之后产生的效益进行决策了:

  1. 不拿k号物品,那么此时包中物品的总价值b(k,c)=b(k-1,c),和第一种拿不动k号物品的一样。

  2. 拿走k号物品,那么此时包中物品的总价值b(k,c)=b(k-1,c-w[k])+v[k]拿了第k件物品后,那我的包中的价值肯定就是原先的价值再加上第k件物品的价值(动态规划),而且拿了之后包中的剩余容量就为c-w[k]了。 总结一下,就是如下的公式了:b(k,c)=max{b(k-1,c),b(k-1,c-w[k])+v[k]},即剩下的只需要比较这两种方式谁的效益大即可。

思维导图如下:

算法设计方法4:动态规划经典例题总结_第2张图片
Java代码:

class Main
{
    public static void main(String[] args) 
   {
        int[] w = { 0, 2, 3, 4, 5, 9 };//每种物品的重量
        int[] v = {0, 3, 4, 5, 8, 10 };//每种物品的价值
        int N = 6,
        int capacity = 21;//背包容量

        int[][] b = new int[N][capacity];
        for (int k = 1; k < N; k++) 
       {
            for (int c = 1; c  c) {
                    b[k][c] = b[k - 1][c];
                }
                else {
                    int value1 = b[k - 1][c - w[k]] + v[k]; // 拿第k件物品
                    int value2 = b[k - 1][c]; // 不拿第k件物品
                    b[k][c] = Math.max(value1, value2);
                }
            }
        }

        System.out.println(b[5][21]);//输出26
    }
}

最优解即是b(n,capacity),但是我们并不清楚具体选择哪几样物品能获得最大价值。

另起一个 x[ ] 数组,x[i]=0表示不拿,x[i]=1表示拿。

m[n][c]为最优值,如果m[n][c]=m[n-1][c] ,说明有没有第n件物品都一样,则x[n]=0 ; 否则 x[n]=1。当x[n]=0时,由x[n-1][c]继续构造最优解;当x[n]=1时,则由x[n-1][c-w[i]]继续构造最优解。以此类推,可构造出所有的最优解。

void traceback()
{
    for(int i=n;i>1;i--)
    {
        if(m[i][c]==m[i-1][c])
            x[i]=0;
        else
        {
            x[i]=1;
            c-=w[i];
        }
    }
    x[1]=(m[1][c]>0)?1:0;
}

参考链接:总结——01背包问题 (动态规划算法)

1、为什么贪心算法不适于解0/ 1背包问题?

0/1背包问题有好几种贪婪策略,每种贪婪策略都要多个步骤来完成,每一步都利用贪婪准则选择一个物品装入背包。

一种价值贪婪准则为:从剩余的物品中,选出可以装入背包的价值最大的物品,利用这种规则,价值最大的物品首先被装入(假设有足够容量),然后是下一个价值最大的物品,如此继续下去。这种策略不能保证得到最优解。例如,考虑n= 2w=[10010,10],p=[2015,15],c=105当利用价值贪婪准则时,获得的解为x=[ 1, 0,0],这种方案的总价值为20。 而最优解为[ 0,1, 1],其总价值为30。

另一种方案是重量贪婪准则:  从剩下的物品中选择可装入背包的重量最小的物品。虽然这种规则对于前面的例子能产生最优解,但在一般情况下则不一定能得到最优解。考虑n=2,w=[1020],p=[,5100],c=25当利用重量贪婪策略时,获得的解为x=[,10],比最优解[01]要差。还可以利用另一方案,价值密度p; / w贪婪算法,这种选择准则为:从剩余物品中选择可装入包的p; / w值最大的物品,这种策略也不能保证得到最优解。

2、贪心算法适用于部分背包问题

这里适用于可拆分的物品,部分背包问题可以用贪心算法求解,且能够得到最优解。

贪心策略:将物品按单位重量所具有的价值比排序,总是优先选择单位重量下价值最大的物品。

所以我们可以通过求出所有物品的性价比并排序,然后从性价比最大的开始选择,尽可能多拿该物品,直到装满为止。

部分背包问题可以用贪心算法求解,且能够得到最优解。

贪心策略是什么呢?将物品按单位重量所具有的价值排序。总是优先选择单位重量下价值最大的物品。

单位重量所具有的价值:Vi / Wi

举个例子:假设背包可容纳50Kg的重量,物品信息如下:

物品 i      重量(Kg)      价值           单位重量的价值

1             10          60                 6

2             20          100               5

3             30          120               4

按照我们的贪心策略,单位重量的价值排序: 物品1 > 物品2 > 物品3

因此,我们尽可能地多拿物品1,直到将物品1拿完之后,才去拿物品2.....

最终贪心选择的结果是这样的:物品1全部拿完,物品2也全部拿完,物品3拿走10Kg(只拿走了物品3的一部分!!!),这种选择获得的价值是最大的。
而对于0-1背包问题,如果也按“优先选择单位重量下价值最大的物品”这个贪心策略,那么,在拿了物品1和物品2之后,就不能在拿物品3了。因为,在拿了物品1和物品2之后,背包中已经装了10+20=30Kg的物品了,已经装不下物品3了(50-30 < 30)(0-1背包:一件物品要么拿,要么不拿,否能只拿一部分),此时得到的总价值是 160。而如果拿物品2和物品3,得到的价值为220。这说明,该贪心策略对0-1背包问题,不能求得最优解。这时如果想得到全部的最优解,我们就要用到动态规划解决这一题。

C++代码:

/*
* 贪心算法解决部分背包问题:用每个物品价值除以重量然后按照从大到小的顺序依次装入,直至背包装满。
**/
using namespace std;
 
/*排序函数,将物品按照从大到小排序(价值/重量)*/
void swap (float ave[], int s[], int n)
{
    int i, j;
    for (i = 0; i < n; i++)
    {
        for (j = i+1; j < n; j++)
        {
            /*如果前面的值小于后面的值则调换位置*/
            if (ave[s[i]] <= ave[s[j]])
            {
                /*注意这里互换的是下标并不是真正的值,因为后面还要用到重量*/
                int temp = s[i];
                s[i] = s[j];
                s[j] = temp;
            }
        }
    }
}
 
/*求背包的最大价值*/
void bag (float w[], float p[], int s[], float volume, int n)
{
    int i;
    float totalV = 0; //总价值
    for (i = 0; i < n; i++)
    {
        /*如果当前的容量能装下i物品,则全部装入*/
        if (volume >= w[s[i]])
        {
            volume -= w[s[i]]; //背包的容量减去装入的重量
            totalV += p[s[i]]; //将当前背包的价值加上装入的物品的价值
            cout << "重量为" << w[s[i]] << ",价值为" << p[s[i]] << "的物品被全部拿走" << endl;
        }
        else
        {
            /*如果不能全部装入则装入部分直至全部装满*/
            totalV += volume/ w[s[i]] * p[s[i]]; //相应的价值按照相应的比例乘以重量
            cout << "重量为" << w[s[i]] << ",价值为" << p[s[i]] << "的物品拿走" << volume / w[s[i]] << endl;
            volume = 0;
            break;
        }
    }
    cout << "能装满的最大价值为:" << totalV << endl;
}
int main()
{
    int s[20], i;
    float w[20], p[20]; //w为重量,p为价值
 
    cout << "请输入最大重量:" << endl;
    float volume; //最大重量
    cin >> volume;
 
    cout << "请输入商品的种类数量:" << endl;
    int n; //商品种类数量
    cin >> n;
 
    cout << "请输入各个商品的重量和价值:" << endl;
    for (i = 0; i < n; i++)
    {
        cin >> w[i] >> p[i];
    }
 
    float ave[20]; //ave为单位重量上的价值
    for (i = 0; i < n; i++)
    {
        ave[i] = p[i] / w[i]; //重量除以价值
    }
 
    for (i = 0; i < n; i++)
    {
        /*下标函数*/
        s[i] = i;
    }
 
    swap(ave, s, n);
    bag(w, p, s, volume, n);
    return 0;
}

3、贪心算法与动态规划的区别
共同点:两者都具有最优子结构性质
不同点:

1) 动态规划算法中,每步所做的选择往往依赖于相关子问题的解,因而只有在解出相关子问题时才能做出选择。而贪心算法,仅在当前状态下做出最好选择,即局部最优选择,然后再去解做出这个选择后产生的相应的子问题。

2) 动态规划算法通常以自底向上的方式解各子问题,而贪心算法则通常自顶向下的方式进行。

总结:

动态规划和贪心算法一样,在动态规划中,可将一个问题的解决方案视为一系列决策的结果。不同的是,在贪婪算法中,每用一次贪心准则便做出一个不可撤回的决策,而在动态规划中,还要考察每个最优决策序列中是否包含一个最优子序列。当一个问题具有最优子结构时,我们会想到用动态规划法去解它,但是有些问题存在着更简单有效的方法,只要我们总是做出当前看来最好的选择就可以了。贪心算法所作的选择可以依赖于以往所作过的选择,但决不依赖于将来的选择,也不依赖于子问题的解,这使得算法在编码和执行的过程中都有着一定的速度优势。

如果一个问题可以同时用几种方法解决,贪心算法应该是最好的选择之一;但是贪心算法并不是对所有的问题都能得到整体最优解或最理想的近似解,与动态规划相比较,它的适用区域相对狭窄许多,因此正确地判断它们的应用时机十分重要。

四、最长公共子串和最长公共子序列

子串应该比较好理解,至于什么是子序列,这里给出一个例子:有两个母串

  • cnblogs
  • belong

比如序列bo, bg, lg在母串cnblogs与belong中都出现过并且出现顺序与母串保持一致,我们将其称为公共子序列。最长公共子序列(Longest Common Subsequence,LCS),顾名思义,是指在所有的子序列中最长的那一个。子串是要求更严格的一种子序列,要求在母串中连续地出现。在上述例子的中,最长公共子序列为blog(cnblogs,belong),最长公共子串为lo(cnblogs, belong)。

问题描述:求2个字符串的最长公共子串和最长公共子序列。

两道题都可以用动态规划的方法做,只是状态转移方程不同。

五、最长回文子串

回文串就是一个正读和反读都一样的字符串,比如“level”或者“noon”等等就是回文串。回文子串,顾名思义,即字符串中满足回文性质的子串。比如输入字符串 "google”,由于该字符串里最长的对称子字符串是 "goog”,因此输出4。

问题描述:给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为1000。

示例 1:                                                 示例 2:

输入: "babad"                                         输入: "cbbd"
输出: "bab"                                             输出: "bb"
注意: "aba"也是一个有效答案 


注意:最长公共序列、最长公共子串和最长回文子串属于字符串处理的常见操作,我会单独写篇博客来学习一下。

没有符合的翻译结果!


请确认选中的文本是完整的单词或句子。

目前仅谷歌翻译支持汉译英。

轻灵划译

数据来源:

你可能感兴趣的:(数据结构与算法)