蓝桥杯精选算法赛题——DP(动态规划)——背包问题

今天我们来讲一个重要算法就是DP(动态规划)。
首先,在讲这一讲之前,我们再看一看之前讲的贪心法中的硬币问题。我们以硬币问题为例,引入我们今天的主角——动态规划。
前面用贪心法解决的最少硬币问题,要求硬币面值是特殊的。而对于任意面值的硬币问题,就需要用 DP 来彻底解决。

题目描述:有 n 种硬币,面值分别为V1 ,V2 ,⋯,Vn ,数量无限。输入非负整数 S,请你选用硬币,使其和为
S。要求输出最少的硬币组合。

我们先定义一个数组 int cnt[M],其中 cnt[i]​ 是金额 i 对应的最少硬币数量。如果程序能计算出所有的 cnt[i]​,0<i<M​,那么对输入的某个金额 i,只要查 cnt[i]​ 就得到了答案。
那么我们该如何计算 cnt[i] 呢? cnt[i] 和cnt[i−1] 又是否有关系呢?这里涉及到一个“递推”的思路,从小问题 cnt[i−1] 的解决递推到大问题cnt[i] 的解决。小问题的解决能推导出大问题的解决。下面我们以 5 种面值 {1,5,10,25,50} 的硬币为例,讲解从 i−1 到 i​​ 的递推过程:
(1) 只使用最小面值的 1 分硬币
初始值cnt[0]=0,其他的 cnt[i] 为无穷大。下面计算cnt[1]。只用1分硬币只用1分硬币

i=0,cnt[0]=0,表示金额为 0,硬币数量为 0。在这个基础上,加一个 1 分硬币,就前进到金额 i=1,硬币数量 cnt[1] = cnt[0]+1 = cnt[1-1]+1= 1 的情况。同理, i=2 时,相当于在 cnt[1] 的基础上加1个硬币,得到 cnt[2] = cnt[2- 1]+1 = 2。继续这个过程,结果是:在这里插入图片描述
我们分析上述过程,可以得到递推关系:cnt[i] = min(cnt[i], cnt[i - 1] + 1);要认真理解这个递推过程,确保完全明白哦。这可是 DP 的重要步骤—“状态转移”。

(2)在使用面值1分硬币的基础上,增加使用第二大面值的5分硬币
此时应该从 cnt[5] 开始,因为比 5 分硬币小的金额,不可能用 5 分硬币实现。在这里插入图片描述加上5分硬币

i=5 分时,相当于在 i=0 的基础上加一个 5 分硬币,得到 cnt[5] = cnt[5 - 5] + 1 = 1。而 cnt[5] 也可以是在 i=4 的基础上加上一个 1 ,得到 cnt[5]=5,我们取最小值,得 cnt[5]=1。同理,i=6 分时,有 cnt[6] = cnt[6-5] + 1 = 2,对比原来的 cnt[6]=6,取最小值。继续这个过程,结果是:请添加图片描述
根据上述过程得到递归关系cnt[i]=min(cnt[i],cnt[i−5]+1)

(3)继续处理其它面值的硬币
在 DP 中,把 cnt[i] 这样的记录子问题最优解的数据称为“状态”;从 cnt[i−1] 或 cnt[i−5] 到 cnt[i] 的递推,称为“状态转移”。用前面子问题的结果,推导后续子问题的解,逻辑清晰、计算高效,这就是 DP 的特点。
这里我本人能力有限,找到一个c++版本的答案,大家看一看理解一下,之后会改成python1版本。

#include<bits/stdc++.h>
using namespace std;
const int M = 251;                   //定义最大金额
const int N = 5;                     //5种硬币
int type[N] = {
    
    1, 5, 10, 25, 50};    //5种面值
int cnt[M];                          //每个金额对应最少的硬币数量
const int INF = 0x1FFFFFFF;

void solve(){
    
    
    for(int k = 0; k< M; k++)        //初始值为无穷大
       cnt[k] = INF;
    cnt[0] = 0;
    for(int j = 0; j < N; j++)
        for(int i = type[j]; i < M; i++)
            cnt[i] = min(cnt[i], cnt[i - type[j]] + 1);    //递推式
}
int main(){
    
    
    int s;
    solve();               //计算出所有金额对应的最少硬币数量。打表
    while(cin >> s){
    
    
        if(cnt[s] == INF) cout <<"No answer" << endl;
        else              cout << cnt[s] << endl;
    }
    return 0;
}

题目数据读入前就开始调用 solve() 函数,这是用到了“打表”的处理方法。即在输入金额之前,提前用 solve() 算出所有的解,得到 cnt[M] 这个“表”,然后再读取金额 s,查表直接输出结果,查一次表的复杂度只有 O(1)。
这样做的原因是,如果有很多组测试数据,例如 10000 个,那么总复杂度是O(N×M+10000),没有增加多少。如果不打表,每次读一个 s,就用 solve() 算一次,那么总复杂度是 O(N×M×10000),时间几乎多了 1 万倍。
但是现在只打印了最少硬币数量,还没打印出到底需要哪些面值的硬币。
在 DP 中,除求最优解的数量之外,往往还要求输出最优解本身,此时状态表需要适当扩展,以包含更多信息。而在最少硬币问题中,如果要求打印组合方案,需要增加一个记录表 path[i],记录金额 i 需要的最后一个硬币。再利用 path[] 逐步倒推,就能得到所有的硬币。
举个例子:金额 i=6,path[6]=5​,表示最后一个硬币是 5​ 分;然后,path[6-5] = path[1],查path[1]=1,表示接下来的最后一个硬币是 1 分;继续 path[1-1] = 0,不需要硬币了,结束。输出结果:硬币组合是“5分 + 1分”。请添加图片描述
这个也是c++版本,大家可以看一看理解大概。

#include<bits/stdc++.h>
using namespace std;
const int M = 251;               //定义最大金额
const int N = 5;                 //5种硬币
int type[N] = {
    
    1,5,10,25,50};    //5种面值
int cnt[M];                      //每个金额对应最少的硬币数量
int path[M]={
    
    0};                 //记录最小硬币的路径
const int INF = 0x1FFFFFFF;
void solve(){
    
    
    for(int k=0; k< M;k++)
        cnt[k] = INF;
    cnt[0]=0;
    for(int j = 0;j < N;j++)
        for(int i = type[j]; i < M; i++)
            if(cnt[i] >  cnt[i - type[j]]+1){
    
    
               path[i] = type[j];   //在每个金额上记录路径,即某个硬币的面值
               cnt[i] = cnt[i - type[j]] + 1;  //递推式
            }
}
void print_ans(int *path, int s) {
    
          //打印硬币组合
    while(path[s]!=0 && s>0){
    
    
        cout << path[s] << " ";
        s = s - path[s];
    }
}
int main() {
    
    
    int s;
    solve();
    while(cin >> s){
    
    
        if(cnt[s] == INF)
            cout <<"No answer"<<endl;
        else{
    
    
            cout << cnt[s] << endl;     //输出最少硬币个数
            print_ans(path,s);             //打印硬币组合
        }
    }
    return 0;
}

上面用 DP 解决了硬币问题,看罗勇军老师的博客,罗老师有以下总结:
两个特征:重叠子问题、最优子结构,就可以用 DP​ 高效率地处理、解决。

1.重叠子问题

首先,子问题是原大问题的小版本,计算步骤完全一样;其次,计算大问题的时候,需要多次重复计算小问题。这就是“重叠子问题”。 一个子问题的多次计算,耗费了大量时间。用 DP 处理重叠子问题,每个子问题只需要计算一次,从而避免了重复计算,这就是 DP 效率高的原因。具体的做法是:首先分析得到最优子结构,然后用递推或者带记忆化搜索的递归进行编程,从而实现了高效的计算。

2.最优子结构

最优子结构的意思是:首先,大问题的最优解包含小问题的最优解;其次,可以通过小问题的最优解推导出大问题的最优解。在硬币问题中,我们把 cnt[i] 的计算,减小为cnt[i−1] 的计算,也就是把原来为i的大问题,减小为i−1的小问题,这是硬币问题的最优子结构。
解析来就是一道DP经典题背包问题
它是 DP​ 的经典问题。下面通过它来说下有关 DP​ 的:

状态设计
状态转移方程
两种编码方法
滚动数组

题目描述

小明有一个容量为 V 的背包。

这天他去商场购物,商场一共有 N 件物品,第 i 件物品的体积为 wi,价值为vi。

小明想知道在购买的物品总体积不超过 V 的情况下所能获得的最大价值为多少,请你帮他算算。

输入描述

输入第 1 行包含两个正整数 N,V,表示商场物品的数量和小明的背包容量。

第 2∼N+1 行包含 2 个正整数 w,v,表示物品的体积和价值。

1≤N≤102,1≤V≤103,1≤wi,vi≤103

输出描述

输出一行整数表示小明所能获得的最大价值。

样例输入

5 20
1 6
2 5
3 8
5 15
3 3

样例输出

37

题目分析

我们得先设计好 DP 的状态,才能进行 DP 的转移等
那这题 DP 的状态要如何设计呢?
对于这题,我们可以定义二维数组 dp[][],大小为 N×C(dp[i][j] 表示把前 i 个物品(从第 1 个到第 i个)装入容量为 j 的背包中获得的最大价值)。我们把每个 dp[i][j] 都看成一个背包:背包容量为 j,装 1∼i 这些物品。最后得到的 dp[N][C] 就是问题的答案:把 N 个物品装进容量 C 的背包的最大价值。
假设现在已经递推计算到 dp[i][j]​ 了,那么我们考虑 2 种情况:
1.第 i​ 个物品的体积比容量 j 还大,不能装进容量 j 的背包。那么直接继承前i−1 个物品装进容量 j 的背包的情况即可:dp[i][j]=dp[i−1][j]。

2.第 i 个物品的体积比容量 j 小,能装进背包。此时又可以分为 2 种情况:装或者不装第 i个:

a.装第 i 个。从前 i−1 个物品的情况下推广而来,前i−1 个物品是 dp[i−1][j]。第 i 个物品装进背包后,背包容量减少 c[i],价值增加w[i]。所以有:dp[i][j] = dp[i-1][j-c[i]] + w[i]。
b.不装第 i 个。那么:dp[i][j] = dp[i-1][j]。

我们取两种情况的最大值,得状态转移方程是:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - c[i]] + w[i])。

总结一下上述分析:背包问题的重叠子问题是 dp[i][j],最优子结构是 dp[i][j] 的状态转移方程。而对于算法的复杂度:算法需要计算二维矩阵 dp[][],二维矩阵的大小是O(NC) 的,每一项计算时间是 O(1),所以总时间复杂度是O(NC),空间复杂度是 O(NC)。
举个例子详细说明一下:
假设现在有 4 个物品,其体积分别是 {2,3,6,5},价值分别为{6,3,5,4},背包的容量为 9。接下来考虑下填 dp[][] 表的过程,我们按照只装第 1 个物品、只放前 2 个、只放前 3 个…的顺序,一直到装完,这就是从小问题扩展到大问题的过程。表格横向 j,纵向 i,按先横向递增 j,再纵向递增 i 的顺序填表。请添加图片描述图 dp 矩阵

我们分步来分析:
1.步骤 1:只装第 1 个物品。由于物品 1 的体积是 2,所以背包容量小于 2 的,都放不进去,得
dp[1][0] = dp[1][1] = 0;而当物品 1 的体积等于背包容量时,能装进去,背包价值就等于物品 1 的价值,得 dp[1][2] = 6;而对于容量大于 2 的背包,多余的容量用不到,所以价值和容量 2 的背包一样。
请添加图片描述图 装第1个物品
2.步骤 2:只装前 2 个物品。如果物品 2 体积比背包容量大,那么不能装物品 2,情况和只装第 1 个一样。见下图中的 dp[2][0] = dp[2][1] = 0,dp[2][2] = 6。接着填 dp[2][3]。物品 2 体积等于背包容量,那么可以装物品 2,也可以不装:
(a)如果装物品 2(体积是 3,价值也是 3),那么可以变成一个更小的问题,即只把物品 1 装到(容量j−3​​)的背包中。请添加图片描述图 装第2个物品

(b)如果不装物品 2,那么相当于只把物品 1 装到背包中。
请添加图片描述图 完成dp矩阵
最后的答案是dp[4][9] :把 4 个物品装到容量为 9 的背包,最大价值是 11。

上面表格中的数字是背包的最大价值,那么如何输出背包方案呢?

要想知道具体装了哪些物品,需要倒过来观察:
dp[4][9]=max(dp[3][4]+4,dp[3][9])=dp[3][9],说明没有装物品 4,用 x4=0 表示;

dp[3][9]=max(dp[2][3]+5,dp[2][9])=dp[2][3]+5=11,说明装了物品 3,x3=1;

dp[2][3]=max(dp[1][0]+3,dp[1][3])=dp[1][3],说明没有装物品 2,x2 =0;

dp[1][3]=max(dp[0][1]+6,dp[0][3])=dp[0][1]+6=6,说明装了物品 1,x1=1​。
下图中的实线箭头标识了方案的转移路径。请添加图片描述

记忆化代码和递推代码

接下来说说动态规划最重要的代码实现部分(有两种实现方法)。处理 DP 中的大问题和小问题,有两种思路:自顶向下(Top-Down,先大问题再小问题)、自下而上(Bottom-Up,先小问题再大问题)。而在编码实现 DP 时,自顶向下用带记忆化搜索的递归编码,自下而上用递推编码。

两种方法的复杂度是一样的,每个子问题都计算一遍,而且只计算一遍。下面我们分别实现自下而上的递推和自上而下的记忆化递归实现。

自下而上
递推代码 - Python

这种“自下而上”的方法,先解决子问题,再递推到大问题。通常填写多维表格来完成,编码时用若干 for循环语句填表。根据表中的结果,逐步计算出大问题的解决方案。这就是上面详解中的递推式的直接实现。

N=3011
dp = [[0 for i in range(N)] for j in range(N)]
w = [0 for i in range(N)]
c = [0 for i in range(N)]

def solve(n,C):
    for i in range(1,n+1):
        for j in range (0,C+1):
            if c[i]>j :
                dp[i][j] = dp[i-1][j]
            else :
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-c[i]]+w[i])
    return dp[n][C]   

n, C = map(int, input().split())
for i in range(1, n+1):
    c[i], w[i] = map(int, input().split())
print(solve(n, C))

自上而下
记忆化代码 - Python

N=3011
dp = [[0 for i in range(N)] for j in range(N)]
w = [0 for i in range(N)]
c = [0 for i in range(N)]

def solve(n,C):
    for i in range(1,n+1):
        for j in range (0,C+1):
            if c[i]>j :
                dp[i][j] = dp[i-1][j]
            else :
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-c[i]]+w[i])
    return dp[n][C]   

n, C = map(int, input().split())
for i in range(1, n+1):
    c[i], w[i] = map(int, input().split())
print(solve(n, C))

滚动数组

DP 的状态方程常常是二维和二维以上,占用了太多的空间。例如前面代码使用了二维矩阵 int dp[N][C],设 N = 103,C = 104,都不算大。但 int 型有 4 字节,需要的空间是 4×103×104 = 40M,会超过部分竞赛题的空间限制。
不过我们可以用滚动数组大大减少空间。它能把二维状态方程的 O(n2) 空间复杂度,优化到 O(n),更高维的数组也可以优化后减少一维。
这个叫滚动数组的玩意儿是如何优化的?

从状态转移方程 dp[i][j] = max(dp[i-1][j], dp[i-1][j - c[i]] + w[i]) 可以看出,dp[i][] 只和dp[i−1][] 有关,和前面的 dp[i−2][]、dp[i−3][]、⋯ 都没有关系。从前面的图表也可以看出,每一行是从上面一行算出来的,跟更前面的行没有关系。而那些用过的已经无用的 dp[i−2][]、dp[i−3][]、⋯​ 多余了,那么干脆就复用这些空间,用新的一行覆盖已经无用的一行(滚动),这样只需要两行就够了。
下面给出滚动数组的两种实现方法,两种实现都很常用:

交替滚动

定义 dp[2][j],用 dp[0][] 和 dp[1][] 交替滚动。这种方法的优点是逻辑清晰、编码不易出错,建议作为初学者的你采用这个方法。
交替滚动 - Python

N = 3011
dp = [[0 for i in range(N)] for j in range(2)]    #注意先后
w = [0 for i in range(N)]
c = [0 for i in range(N)]

def solve(n,C):
    now = 0
    old =1
    for i in range(1,n+1):
        old,now = now,old            #交换
        for j in range (0,C+1,1):
            if c[i] > j:
                dp[now][j]=dp[old][j]
            else:
                dp[now][j] = max(dp[old][j], dp[old][j-c[i]]+w[i])
    return dp[now][C]   

n, C = map(int, input().split())
for i in range(1, n+1):
    c[i], w[i] = map(int, input().split())
print(solve(n, C))

自我滚动

用两行交替滚动是很符合逻辑的做法,其实还能继续精简:用一个一维的dp[] 就够了,自己滚动自己。
自我滚动 - Python

N=3011
dp = [0 for i in range(N)]
w = [0 for i in range(N)]
c = [0 for i in range(N)]

def solve(n,C):
    for i in range(1,n+1):
        for j in range (C,c[i]-1,-1):
            dp[j] = max(dp[j], dp[j-c[i]]+w[i])
    return dp[C]   

n, C = map(int, input().split())
for i in range(1, n+1):
    c[i], w[i] = map(int, input().split())
print(solve(n, C))

不过滚动数组也有缺点。它覆盖了中间转移状态,只留下了最后的状态,损失了很多信息,无法回溯,导致不能输出具体的方案。不过,竞赛题目一般不要求输出具体方案,因为可能有多种方案,不方便判题。
以上就是动态规划的基本内容了。

猜你喜欢

转载自blog.csdn.net/m0_51951121/article/details/123694864