3.动态规划.基础

基础理论

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。(很多讲解动态规划的文章都会讲最优子结构啊和重叠子问题这些)

状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。对于动态规划问题,我将拆解为如下五步曲

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化;因为一些情况是递推公式决定了dp数组要如何初始化
  4. 确定遍历顺序;这一点在背包问题中可以体现
  5. 举例推导dp数组;找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的

debug时,发出这样的问题之前,其实可以自己先思考这三个问题:(如果这灵魂三问自己都做到了,基本上这道题目也就解决了)
1.这道题目我举例推导状态转移公式了么?
2.我打印dp数组的日志了么?
3.打印出来了dp数组和我想的一样么?

动态规划解决的经典问题:

  • 背包问题
  • 打家劫舍
  • 股票问题
  • 子序列问题
  • 其他(区间DP,概率DP)

背包基础理论

1.01背包(基础-重中之重) 2.完全背包 3.多重背包(知道含义,解法即可)
在这里插入图片描述

01背包

01背包-二维dp数组
有n种物体数量为1个,每个物品有各自的重量,价值,目前有一个载重量为m的背包,尝试尽可能装满的背包并使价值最大。
在这里插入图片描述
暴力解法:每个物品只有取和不取两种状态,所以可以使用回溯算法枚举所有可能,统计出最大的价值。时间复杂度为O(2^n)
在这里插入图片描述
动态规划:
1.dp数组的意义:dp[i][j]的定义为:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
2.递推公式:1.不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。);2.放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值;因此得到 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3.初始化:dp[0][slice], dp[slice][0]=0——对于0下标的初始化0;对于非0下标的初始化为任意值都可以。
4.遍历顺序:有两层for循环(一个是物品i,一个背包容量j);根据dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])需要上方和左上方的数据,因此哪个先遍历都可以。其实都可以!! 但是先遍历物品更好理解
从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的。

// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}
// 完整代码
void test_2_wei_bag_problem1() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagweight = 4;

    // 二维数组
    vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));

    // 初始化
    for (int j = weight[0]; j <= bagweight; j++) {
        dp[0][j] = value[0];
    }

    // weight数组的大小 就是物品个数
    for(int i = 1; i < weight.size(); i++) { // 遍历物品
        for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
            if (j < weight[i]) dp[i][j] = dp[i - 1][j];
            else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

        }
    }

    cout << dp[weight.size() - 1][bagweight] << endl;
}

01背包-滚动数组-一维dp数组
1.dp数组的意义:dp[j]的定义为:放进容量为j的背包,价值总和最大是多少。
2. 递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]) ,一个是一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],另一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值
3. 初始化么:dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0;对于非0下标,为了有意义,需要初始化为非零值,又防止dp[j]会覆盖下一层的dp值,因此统一赋值为0即可。
4.遍历顺序:正序遍历 or 倒序遍历,正序遍历时会使得物品会被重复添加-而违背了01背包的规则(因为dp[i]是由dp[i-1]推导出来的)
与二维dp数组不同,二维数组每层数据都是独立的,但在一维dp数组时,数据的更新会利用就使用新的数据,如果使用正序则会使用更新的数值dp[i-1]去更新dp[i];倒叙则能避免这一影响。
一维数组dp只能先遍历物品,再遍历背包;如果遍历顺序发生改变,一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。确实,尽管dp[j]的深入,dp[j-1]的数值还处于初始层的数值。

void test_1_wei_bag_problem() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;

    // 初始化
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}

完全背包

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
在这里插入图片描述

完全背包和01背包很大的差别体现在遍历顺序上:对于01背包——01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量;完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的

但如果题目稍稍有点变化,就会体现在遍历顺序上。如果问装满背包有几种方式的话? 那么两个for循环的先后顺序就有很大区别了,而leetcode上的题目都是这种稍有变化的类型。

最后,又可以出一道面试题了,就是纯完全背包,要求先用二维dp数组实现,然后再用一维dp数组实现,最后再问,两个for循环的先后是否可以颠倒?为什么? 这个简单的完全背包问题,估计就可以难住不少候选人了

// 先遍历背包,再遍历物品
void test_CompletePack(vector<int> weight, vector<int> value, int bagWeight) {

    vector<int> dp(bagWeight + 1, 0);

    for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
        for(int i = 0; i < weight.size(); i++) { // 遍历物品
            if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}
int main() {
    int N, V;
    cin >> N >> V;
    vector<int> weight;
    vector<int> value;
    for (int i = 0; i < N; i++) {
        int w;
        int v;
        cin >> w >> v;
        weight.push_back(w);
        value.push_back(v);
    }
    test_CompletePack(weight, value, V);
    return 0;
}

多重背包

题目链接
有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
在这里插入图片描述
对于多重背包,对每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。在摊开M[i]时,比较耗时的步骤是在vector的动态底层扩容上push_back()。(其实这里也可以优化,先把 所有物品数量都计算好,一起申请vector的空间。
另外一个做法是从代码里可以看出是01背包里面在加一个for循环遍历一个每种商品的数量。 和01背包还是如出一辙的。

    vector<int> dp(bagWeight + 1, 0);

    for(int i = 0; i < n; i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            // 以上为01背包,然后加一个遍历个数
            for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
                dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
            }
        }
    }

多重背包在面试中基本不会出现,力扣上也没有对应的题目,大家对多重背包的掌握程度知道它是一种01背包,并能在01背包的基础上写出对应代码就可以了。


题目

1.斐波那契数

(题目链接)
斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是: F(0) = 0,F(1) = 1 F(n) = F(n - 1) + F(n - 2),其中 n > 1 给你n ,请计算 F(n)
1.dp数组的意义:dp[i]的定义为:第i个数的斐波那契数值是dp[i];
2.递推公式:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]
3.初始化:dp[0] = 0, dp[1]=1
4.遍历顺序:从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的。
5.打印DP数据:

	// 动态规划
    int fib(int n) {
        if(n<=1) return n;
        int dp[2];
        dp[0] = 0;
        dp[1] = 1;
        for(int i=2; i<=n; i++){
            int cur = dp[0]+dp[1];
            dp[0] = dp[1];
            dp[1] = cur;
        }
        return dp[1];
    }
	// 递归
    int fib(int n) {
        if(n<=1) return n;
        return fib(n-1)+fib(n-2);
    }

2.爬楼梯

(题目链接)

    int climbStairs(int n) {
        if(n<=1) return n;
        int dp[2];
        dp[0] = 1;
        dp[1] = 1;
        for(int i=2; i<=n; i++){
            int cur = dp[0]+dp[1];
            dp[0] = dp[1];
            dp[1] = cur;
        }
        return dp[1];
    }

3.使用最小花费爬楼梯

(题目链接)
给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。示例 2:输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1];输出:6;解释:最低花费方式是从 cost[0] 开始,逐个经过那些 1 ,跳过 cost[3] ,一共花费 6 。

    int minCostClimbingStairs(vector<int>& cost) {
        if(cost.size()<=1) return 0;
        int dp[2];
        dp[0] = 0;
        dp[1] = 0;
        for(int i=2; i<=cost.size(); i++){
            int cur = min(dp[0]+cost[i-2], dp[1]+cost[i-1]);
            dp[0] = dp[1];
            dp[1] = cur;
        }
        return dp[1];
    }

4.不同路径

(题目链接)
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。问总共有多少条不同的路径?
在这里插入图片描述
1.dp数组的意义:dp[i]的定义为
2.递推公式:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]
3.初始化:dp[0] = 0, dp[1]=1
4.遍历顺序:从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的。

	// 动态规划法
    int uniquePaths(int m, int n) {
        std::vector<std::vector<int>> dp(m, std::vector<int>(n, 0));
        for(int i=0; i<m; i++) dp[i][0] = 1;
        for(int j=0; j<n; j++) dp[0][j] = 1;
        for(int i=1; i<m; i++){
            for(int j=1; j<n; j++){
                dp[i][j] = dp[i-1][j]+dp[i][j-1];
            }
        }
        return dp[m-1][n-1];
    }
    // 动态规划-改善内存
    int uniquePaths(int m, int n) {
        std::vector<int> dp(n,1);
        for(int i=1; i<m; i++){
            for(int j=1; j<n; j++){
                dp[j] = dp[j-1] + dp[j];
            }
        }
        return dp[n-1];
    }
    // 数论-分布理论-两数相乘存在溢出的情况:求组合的时候,要防止两个int相乘溢出!
    int uniquePaths(int m, int n) {
        long long res = 1;
        int denominator = m-1;
        for(int i=0; i<m-1; i++){
            res *= (m+n-2-i);
            while(denominator!=0 && res%denominator == 0){
                res /= denominator;
                denominator--;
            }
        }
        return res;
    }

5.不同路径2

(题目链接)
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
在这里插入图片描述

    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
        if(obstacleGrid[0][0]==1) return 0;
        std::vector<int> dp(obstacleGrid[0].size());
        // 初始化第一行
        for(int i=0; i<dp.size(); i++){
            if(obstacleGrid[0][i]==1) dp[i] = 0;
            else if(i==0) dp[i] = 1;
            else dp[i] = dp[i-1];
        }

        for(int i=1; i<obstacleGrid.size(); i++){
            for(int j=0; j<dp.size(); j++){
                if(obstacleGrid[i][j]==1) dp[j]=0;
                else if(j!=0) dp[j] = dp[j]+dp[j-1];
            }
        }
        return dp.back();
    }

时间复杂度:O(n × m),n、m 分别为obstacleGrid 长度和宽度;空间复杂度:O(m)
与题4的区别是遇到障碍dp[i][j]保持0就可以了;

  • 16
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值