动态规划入门

1.动态规划DP

(1)题目特点

dp的无后效性
在这里插入图片描述
PS:上面的问题是“多少种”,如果题目是要求所有方案分别为什么就不能用dp。

DP是一种算法设计技巧,其固定流程:递归的暴力解法–>带备忘录的递归解法–>非递归的动态规划解法。过程层层递进。

I.斐波那契数列

(1)暴力的递归解法

int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}

(2)带备忘录的递归解法(自顶向下)
也即进行了剪枝的递归:

int F(int n){
	if(n==0||n==1) return 1;//递归边界
	if(dp[n]!=-1) return dp[n];
	else{
		dp[n]=F(n-1)+F(n-2);//计算F(n),并保存到dp[i]中
		return dp[n];
	}
}

一般使用数组充当备忘录(也可用哈希表/字典),时间复杂度是O(n)。
(3)动态规划(自底向上)

int fib(int N) {
    vector<int> dp(N + 1, 0);
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[N];
}

不用一个DP数组,只需要用2个变量,空间复杂度为O(1),其实这种做法也即后面二维数组降到一维数组的【滚动数组】做法,能够降低空间复杂度。

状态转移方程, 如斐波那契数列的f(n)想成一个状态n——这个状态n即是由状态n-1和状态n-2相加转移而来,即状态转移。
dp性质:(1)重叠子问题;(2)最优子结构;(3)状态转移方程:明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case
重叠子问题:联想递归,很多重复计算的分支。

关于子问题,如下的青蛙跳阶梯问题,从最后一步出发,推出递归公式:
在这里插入图片描述

最优子结构:原问题的解由子问题的最优解构成,如1、2、5面值硬币拼成n=11元时,求最少需要多少枚硬币拼成11,而f(11)由f(10)、f(9)、f(6)的最优解转移而来。

II.拼硬币

思考如何列出正确的状态转移方程。

第一,先确定「状态」,也就是原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额amount。

第二,确定dp函数的定义:函数 dp(n)表示,当前的目标金额是n,至少需要dp(n)个硬币凑出该金额

第三,确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当前状态。具体到这个问题,无论当的目标金额是多少,选择就是从面额列表coins中选择一个硬币,然后目标金额就会减少:

# 伪码框架
def coinChange(coins: List[int], amount: int):
    # 定义:要凑出金额 n,至少要 dp(n) 个硬币
    def dp(n):
        # 做选择,需要硬币最少的那个结果就是答案
        for coin in coins:
            res = min(res, 1 + dp(n - coin))
        return res
    # 我们要求目标金额是 amount
    return dp(amount)

第四,明确 base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:
(1)暴力解法

int coinChange(vector<int>& coins, int amount) {
    if (amount == 0) return 0;
    int ans = INT_MAX;
    for (int coin : coins) {
        // 金额不可达
        if (amount - coin < 0) continue;
        int subProb = coinChange(coins, amount - coin);
        // 子问题无解
        if (subProb == -1) continue;
        ans = min(ans, subProb + 1);
    }
    return ans == INT_MAX ? -1 : ans;
}

(2)带备忘录的递归
通过备忘录消除子问题:

int coinChange(vector<int>& coins, int amount) {
    // 备忘录初始化为 -2
    vector<int> memo(amount + 1, -2);
    return helper(coins, amount, memo);
}

int helper(vector<int>& coins, int amount, vector<int>& memo) {
    if (amount == 0) return 0;
    if (memo[amount] != -2) return memo[amount];
    int ans = INT_MAX;
    for (int coin : coins) {
        // 金额不可达
        if (amount - coin < 0) continue;
        int subProb = helper(coins, amount - coin, memo);
        // 子问题无解
        if (subProb == -1) continue;
        ans = min(ans, subProb + 1);
    }
    // 记录本轮答案
    memo[amount] = (ans == INT_MAX) ? -1 : ans;
    return memo[amount];
}

(3)动态规划
用自底向上方法使用dp table消除重叠子问题:
dp[i] = x表示,当目标金额为i时,至少需要x枚硬币。
PS:为啥dp数组初始化为amount + 1呢,因为凑成amount金额的硬币数最多只可能等于amount(全用 1 元面值的硬币),所以初始化为amount + 1就相当于初始化为正无穷,便于后续取最小值。

int coinChange(vector<int>& coins, int amount) {
    // 数组大小为 amount + 1,初始值也为 amount + 1
    vector<int> dp(amount + 1, amount + 1);
    // base case
    dp[0] = 0;
    for (int i = 0; i < dp.size(); i++) {
        // 内层 for 在求所有子问题 + 1 的最小值
        for (int coin : coins) {
            // 子问题无解,跳过
            if (i - coin < 0) continue;
            dp[i] = min(dp[i], 1 + dp[i - coin]);
        }
    }
    return (dp[amount] == amount + 1) ? -1 : dp[amount];
}

在这里插入图片描述
(1)最大连续子列和总结
(2)

2.DP解题步骤

在这里插入图片描述

三种硬币,分别面值为2、5、7元(每种硬币都有足够多),需要买一本书27元,求如何用最少的硬币组合正好付清(不需要对方找钱)。

(1)确定状态

开一个数组,数组的每个元素f[i]或者f[i][j]代表什么;
确定状态需要2个意识:1)最后一步;2)子问题。
在这里插入图片描述

(2)转移方程

在这里插入图片描述

(3)初始条件+边界情况

初始条件即用转移方程算不出来(需要手工定义)。
边界:不要数组越界。
在这里插入图片描述
在这里插入图片描述

(4)计算顺序

在这里插入图片描述

小结

在这里插入图片描述

3.背包问题

DFS&剪枝中提到过这个背景:
有n件物品,每件物品的重量为w[i],价值为c[i],现有一容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大,其中每种物品都只有一种。
样例:

5 8 //n=5 ,V=8
3 5 1 2 2 //w[i]
4 5 2 1 3//v[i]

如果按照之前的暴力枚举递归做法(每种物品有放或不放两种选择,然后直接使用递归则时间复杂度为O(2^n)),现用dp降低为O(nV)。

(1)确定状态:

在这里插入图片描述
d p [ i ] [ v ] dp[i][v] dp[i][v]表示前i件物品放入容量为v的背包中可获得的最大价值。
1)若不选第i件物品,则现求前i-1件物品敲好转入容量为v的背包中能获得的最大价值,dp[i-1][v]
2)若选第i件物品,则现求前i-1件物品恰好装入容量为v-w[i]的背包中所能获得的最大价值,即dp[i-1][v-w[i]]+c[i]

(2)转移方程

根据这两种决策即 d p [ i ] [ v ] dp[i][v] dp[i][v]为1)和2)中的最大值,即状态转移方程:

m a x { d p [ i − 1 ] [ v ] , d p [ i − 1 ] [ v − w [ i ] ] + c [ i ] } max\{dp[i-1][v],dp[i-1][v-w[i]]+c[i] \} max{dp[i1][v],dp[i1][vw[i]]+c[i]} ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) (1≤i≤n,w[i]≤v≤V) (1inw[i]vV)

(3)递归边界+初始条件

递归边界 d p [ 0 ] [ v ] = 0 ( 0 ≤ v ≤ V ) dp[0][v]=0(0≤v≤V) dp[0][v]=0(0vV)即前0件物品放入任何容量v的背包中都只能获得价值0。

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

(4)计算顺序

dp[i][v]只与之前的状态dp[i-1][]有关,所以i从1得到n枚举,v从0到V枚举。

PS:滚动数组

d p [ i ] [ v ] dp[i][v] dp[i][v]时总是只 需要 d p [ i − 1 ] [ v ] dp[i-1][v] dp[i1][v]部分的数据(下图阴影部分),即当计算 d p [ i + 1 ] [ ] dp[i+1][] dp[i+1][]时,只用到 d p [ i ] [ ] dp[i][] dp[i][]而用不到 d p [ i − 1 ] [ ] dp[i-1][] dp[i1][]的除了 d p [ i − 1 ] [ v ] dp[i-1][v] dp[i1][v]的部分——所以直接开一维数组dp[v]即可,
(1)现在的计算顺序:将i从1到n枚举,v从V到w[i]逆序枚举
在这里插入图片描述
原来的转移方程
d p [ i ] [ v ] = m a x { d p [ i − 1 ] [ v ] , d p [ i − 1 ] [ v − w [ i ] ] + c [ i ] } dp[i][v]=max\{dp[i-1][v],dp[i-1][v-w[i]]+c[i] \} dp[i][v]=max{dp[i1][v],dp[i1][vw[i]]+c[i]}

( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) (1≤i≤n,w[i]≤v≤V) (1inw[i]vV)

改为一维的转移方程
d p [ v ] = m a x { d p [ v ] , d p [ v − w [ i ] ] + c [ i ] } dp[v]=max\{dp[v],dp[v-w[i]]+c[i] \} dp[v]=max{dp[v],dp[vw[i]]+c[i]}

( i ≤ i ≤ n , w [ i ] ≤ v ≤ V ) (i≤i≤n,w[i]≤v≤V) (iinw[i]vV)
(2)现在的计算顺序
v从右往左, d p [ i ] [ v ] dp[i][v] dp[i][v]右边的部分为刚计算过的需要保存给下一行使用的数据,
d p [ i ] [ v ] dp[i][v] dp[i][v]左上角的阴影部分为当前需要使用的部分。
若把dp[i][v]左上角和右边的部分放在一个数组里,每计算出一个dp[i][v],相当于把dp[i-1][v]舍去(因后面用不到了)。

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

01背包问题用一位数组解决,空间复杂度为O(V)。

4.数塔问题

算法4是逆推法(外层for循环是从大到小遍历,和算法3不一样)。

动态规划系列专题讲义
 
专题一:数塔问题
/*
	Name: 动态规划专题之数塔问题 
	Author: 巧若拙 
	Description:7625_三角形最佳路径问题
描述:如下所示的由正整数数字构成的三角形: 
7 
3 8 
8 1 0 
2 7 4 4 
4 5 2 6 5 
从三角形的顶部到底部有很多条不同的路径。对于每条路径,把路径上面的数加起来可以得到一个和,和最大的路径称为最佳路径。你的任务就是求出最佳路径上的数字之和。 
注意:路径上的每一步只能从一个数走到下一层上和它最近的下边(正下方)的数或者右边(右下方)的数
输入
第一行为三角形高度100>=h>=1,同时也是最底层边的数字的数目。
从第二行开始,每行为三角形相应行的数字,中间用空格分隔。
输出
最佳路径的长度数值。
样例输入
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
样例输出
30
*/
#include<iostream>  
#include<cstring>
  
using namespace std;  
  
const int MAX = 100;   
int map[MAX][MAX];  
int B1[MAX][MAX]; //备忘录,记录从位置(x,y)到达底行所获得的最大值
int B2[MAX][MAX]; //备忘录,记录从顶点到位置(x,y)所获得的最大值 
int bestP; //记录最优解 
  
void DFS(int n, int x, int y, int curP);//n表示行数,x,y表示当前位置坐标,curP表示目前已走路径上的权值和 
int DP_1(int n, int x, int y);//n表示行数,计算从位置(x,y)到达底行所获得的最大值 
int DP_2(int n); //动态规划;顺推法 
int DP_3(int n); //动态规划(逆推法)
  
int main()   
{  
    int n;  
    cin >> n;  
    for (int i=0; i<n; i++)  
    {  
        for (int j=0; j<=i; j++)  
        {  
            cin >> map[i][j];  
        }  
    }  
    
    //回溯算法
    DFS(n, 0, 0, map[0][0]);  
    cout << bestP << endl;  
    
	//记忆化搜索(备忘录算法)
	memset(B1, -1, sizeof(B1)); //先初始化B1的值全为-1  
    cout << DP_1(n, 0, 0) << endl;  
    
    //动态规划(顺推法)
    cout << DP_2(n) << endl;  
    
    //动态规划(逆推法)
    cout << DP_3(n) << endl;  
      
    return 0;  
}  
 
算法1:回溯算法,需要用到全局变量map[MAX][MAX],另有bestP初始化为0void DFS(int n, int x, int y, int curP)//n表示行数,x,y表示当前位置坐标,curP表示目前已走路径上的权值和 
{  
    if (x == n-1) //语句1
    {
		if (curP > bestP)
			bestP =  //语句2
	}
	else
	{
		DFS(n, x+1, y, curP+map[x+1][y]); //向正下方走 
		DFS( ); //语句3 
	}
}  
 
问题1:能否把语句1改为if (x == n)?为什么? 
问题2:将语句2和语句3补充完整。
 
参考答案:
问题1:不能修改,因为递归出口是x == n-1,因为数组的下标是从0开始的,(n-1)表示第n行(底行)。
问题2:语句2:bestP = curP;
       语句3DFS(n, x+1, y+1, curP+map[x+1][y+1]);
 
算法2:记忆化搜索(备忘录算法),需要用到全局变量map[MAX][],另有B1[MAX][]初始化为-1int DP_1(int n, int x, int y)//n表示行数,计算从位置(x,y)到达底行所获得的最大值 
{  
    if (B1[x][y] != -1)  
		return  //语句1
 	
	if (x == n-1)
		B1[x][y] =  //语句2
	else
		B1[x][y] = map[x][y] + max(DP_1(n,x+1,y), DP_1(n,x+1,y+1));
	
    return B1[x][y]; 
}  
 
问题1:将语句1和语句2补充完整。
问题2:与算法1(回溯算法)相比,算法2(备忘录算法)有哪些优越之处?
 
参考答案:
问题1:语句1return B1[x][y];
       语句2:B1[x][y] = map[x][y];
问题2:回溯算法进行了重复搜索,而备忘录算法利用二维数组B1[x][y]记录了已搜索路径,无需重复搜索,大大提高了效率。
 
算法3:动态规划(顺推法),需要用到全局变量map[MAX][],另有B2[MAX][]初始化为0int DP_2(int n)//动态规划;顺推法
{  
	B2[0][0] = map[0][0];
	for (int i=1; i<n; i++)//语句1 
		B2[i][0] = map[i][0] + B2[i-1][0];
		
    for (int i=1; i<n; i++) 
    {  
        for (int j=1; j<=i; j++) //语句2 
        {  
            B2[i][j] = map[i][j] + max(B2[i-1][j], B2[i-1][j-1]);
        }  
    }
	
	int s = B2[n-1][0];
	for (int j=1; j<n; j++) //语句3  
	{
		if (s < B2[n-1][j])
			s = B2[n-1][j];
	}  
	
    return s;  
}  
 
问题1:语句1所在循环体的作用是什么?为什么要单独列出来?能否合成在语句2中?
问题2:语句2能否改为:for (int j=i; j>0; j--) ?为什么?
问题3:语句3能否改为:for (int j=n-1; j>0; j--) ?为什么?
 
参考答案: 
问题1:语句1所在循环体的作用是为第一列赋值。单独列出来的原因是第一列的元素已经处于最左侧,其左上方无元素,故其值直接由其正上方元素决定;而语句2中的元素值由其左上方和正上方元素的较大值来决定。如果把语句1中的循环体并入语句2中,则需要判断其下标是否越界,降低了效率。
问题2:可以。因为算法3是从上往下推的顺推法,其状态方程为:B2[i][j] = map[i][j] + max(B2[i-1][j], B2[i-1][j-1]);是利用上一行元素来计算下一行元素的值,与本行元素无关,故列坐标j递增或递减均可。
问题3:可以。因为语句2所在循环体的作用是在底行寻找最大值,顺序或逆序扫描数组B2[n-1][j]均可,故列坐标j递增或递减均可。
 
算法4:动态规划(逆推法),需要用到全局变量map[MAX][]int DP_3(int n) //动态规划(逆推法) 
{  
    for (int i=n-2; i>=0; i--)  //语句1  
    {  
        for (int j=i; j>=0; j--)   
        {  
            map[i][j] += max(map[i+1][j], map[i+1][j+1]);  
        }  
    }  
    return map[0][0];  
}
 
问题1:语句1能否改为:for (int i=n; i>0; i--) ?为什么?
问题2:与算法3(顺推法)相比,算法4(逆推法)有哪些优越之处?
 
参考答案: 
问题1:不能。因为算法4是从右下角斜向上走,最终到达顶点的逆推法,它直接利用数字三角形的特征,从倒数第2行的最右列元素开始,直接把其正下方和右下方元素中的较大值累加到自身,这样在处理左上角元素的时候,刚好得到最优解。
	语句1是外层循环,从下往上遍历各行,故循环变量i需要递减。
问题2:主要优点有二。一是顺推法得到所有的解后,需要遍历底行找出最大值;而逆推法直接得出最大值,效率更高。
二是顺推法需要先求出第一列的值,然后根据状态方程求出其他元素的值;而逆推法可以直接利用递推式计算出所有元素值,代码相对更简洁。
 
拓展练习:原题只要求算出最佳路径上的数字之和,并未要求输出最佳路径。现在要求在算法4 int DP_3(int n)的基础上,编写函数void PrintPath(int n);//输出最佳路径。
 
参考答案:
void PrintPath(int n) //输出最佳路径 
{  
    int i = 0, j = 0;  
      
    for (int k=1; k<n; k++) //输出前n-1行   
    {  
        if (map[i+1][j] > map[i+1][j+1]) //向正下方走  
        {  
            cout << map[i][j] - map[i+1][j] << "->";  
            i++;  
        }   
        else //向右下方走  
        {  
            cout << map[i][j] - map[i+1][j+1] << "->";  
            i++;  j++;  
        }   
    }  
    //输出底层元素   
    cout << map[i][j] << endl;  
}

5.最大回文子串

状态转移方程:
d p [ i ] [ j ] = dp[i][j]= dp[i][j]=
d p [ i + 1 ] [ j − 1 ] , S [ i ] = S [ j ] dp[i+1][j-1],S[i]=S[j] dp[i+1][j1]S[i]=S[j]
0 , S [ i ] ! = S [ j ] 0,S[i]!=S[j] 0S[i]!=S[j]
如果按照i和j从小到大的顺序枚举子串的两个端点,然后更新 d p [ i ] [ j ] dp[i][j] dp[i][j],会无法保证 d p [ i + 1 ] [ i − 1 ] dp[i+1][i-1] dp[i+1][i1]已经被计算过(从而无法得到正确的 d p [ i ] [ j ] dp[i][j] dp[i][j])。
在这里插入图片描述

总结

(1)01背包中每件物品都可看做一个阶段,该阶段有dp[i][0]~dp[i][V]——均由上一个阶段的状态得到。

对能够划分阶段的问题来说,都可尝试把阶段作为状态的一维。
(2)如果当前设计的状态不满足【无后效性】,则可以增加一维或若干维来表示相应的信息,以满足无后效性。

背包问题从二维数组转为一维数组——直接看最后2段即可。

参考资料

【1】labuladong公众号
【2】https://www.cnblogs.com/kkbill/p/12081172.html
【3】数塔问题https://blog.csdn.net/QiaoRuoZhuo/article/details/76849960?locationNum=8&fps=1
【4】侯卫东上课dp笔记

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

山顶夕景

小哥哥给我买个零食可好

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值