【算法】动态规划(一)——由理论到例题逐步理解

目录

一、前言

1.什么是动态规划

2.动态规划能解决那些问题

二、动态规划解题思路

1.如何定义dp

2.递推公式

3.初始化

4.遍历顺序

三、路径问题

(1) 746使用最小花费爬楼梯

• 整体代码

(2)  63.不同路径Ⅱ

• 整体代码

四、01背包

什么是01背包?

(1) 分割等和子集

• 整体代码

(2) 目标和

• 题目分析 

• 整体代码

 Summery💐💐💐

一、前言

本篇文章还是跟上篇文章《回溯算法》的讲解思路相似—— 认识 + 思路 + 习题!

1.什么是动态规划

动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策问题最优化的过程。20世纪50年代初,美国数学家贝尔曼(R.Bellman)等人在研究多阶段决策过程的优化问题时,提出了著名的最优化原理,从而创立了动态规划。动态规划的应用极其广泛,包括工程技术、经济、工业生产、军事以及自动化控制等领域,并在背包问题、生产经营问题、资金管理问题、资源分配问题最短路径问题和复杂系统可靠性问题等中取得了显著的效果

2.动态规划能解决那些问题

最短路径01背包
完全背包打家劫舍

二、动态规划解题思路

1.如何定义dp

我们往往定义一个数组来表示到一个位置时得到最少次数(或最大数值等),需要根据题意我们根据题意来判断和定义;

dp数组需要多大空间也需要考虑清楚;

一步步递推之后,最后的答案往往是dp数组的最后一个空间;

2.递推公式

一个位置的状态需要前一个或者前几个位置的状态决定,这时我们可以根据题意写出这个位置是如何推导的;

例如我们最熟悉的斐波那契数列: dp[n] = dp[n-1] + dp[n-2] ;

3.初始化

我们知道了后一个位置由之前的位置决定,所以dp数组最开始的位置就需要对其初始化一个合适的值了(特别注意:不能一来就将第一个位置初始化为0或者1,不同题目初始化不同);

对其他位置往往初始化为0,C语言通常用memset函数;

4.遍历顺序

dp数组是从前往后遍历还是从后往前遍历?

二维dp数组是先行后列还是先列后行?

dp背包是先物品后背包,还是先背包后物品?

这些问题我们从之后的问题里具体分析⬇️

接下来我们用几个类型的题来深入掌握这四个解题步骤


三、路径问题

(1) 746使用最小花费爬楼梯

leetcode传送➡️https://leetcode.cn/problems/min-cost-climbing-stairs/

给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。

你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。

请你计算并返回达到楼梯顶部的最低花费。

示例 1:

输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。

1. dp数组定义:

由题可知,站在阶梯上是不花费的,需要离开这个台阶才消费;所以如果有n个台阶我们就需要n+1大小的dp数组;dp[n]即为走到第n阶时的最小花费;

2. 递推公式:

一次可以调一阶或者两阶,所以一个位置的状态由前两个状态决定;

dp[i] = min( dp[i-1] + cost[i-1], dp[i-2] + cost[i-2] );

3. 初始化:

由于站在阶梯上时不花费,所以直接对所有dp数组初始化为0即可;

4. 遍历顺序:
基于递推公式,我们直接从第二个dp数组的位置开始往后遍历即可;

• 整体代码

#define min(x,y) (x) > (y) ? (y) : (x)
int minCostClimbingStairs(int* cost, int costSize) {
    int dp[costSize + 1];
    memset(dp, 0, sizeof(dp));
    for (int i = 2; i <= costSize; i++)
    {
        dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
    }
    return dp[costSize];
}

(2)  63.不同路径Ⅱ

leetcode传送➡️https://leetcode.cn/problems/unique-paths-ii/description/

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次 只能向下或者向右移动 一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 1 和 0 来表示。

示例 1:

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

1. dp数组定义

题目要求求出到右下角的不同路径总和,所以我们的dp数组就可以定义成走到该位置不同路径的数量

2. 递推公式 

由题,机器人只能往下或者往右走,所以dp数组一个位置的状态右由他的上方和左方决定;

又因为dp数组的含义是到这个位置的路径数量,所以将上方的数量与左方的数量相加即可

dp[i][j] = dp[i-1][j] + dp[i][j-1];

3. 初始化

由上述递推公式可知,如果从第一行第一列开始遍历,那么dp数组就会越界(dp[ -1]),所以我们需要对第一行第一列特别初始化:

 如图:dp[1][1]共有两种不同路径(dp[0][1] + dp[1][0]),以此类推,第一行和第一列除障碍物的位置都得初始化为1,有障碍物得位置初始化为0(意为没有可到达该位置得路径)

4. 遍历顺序

本题在行列得先后顺序上被有区别,大家可自行画图分析

• 整体代码

int uniquePathsWithObstacles(int** obstacleGrid, int obstacleGridSize, int* obstacleGridColSize)
{
    int r = obstacleGridSize;
    int c = * obstacleGridColSize;
    int dp[r][c];

    //初始化
    memset(dp,0,sizeof(dp));
    for(int i = 0; i<r && obstacleGrid[i][0] == 0; i++)
    {
        dp[i][0] = 1;
    }
    for(int j = 0; j<c && obstacleGrid[0][j] == 0; j++)
    {
        dp[0][j] = 1;
    }

    //遍历dp数组
    for(int i = 1; i < r; i++)
    {
        for(int j = 1; j < c; j++)
        {
            if(obstacleGrid[i][j] == 1)  //遇到障碍物跳过该位置
            continue;

            dp[i][j] = dp[i-1][j] + dp[i][j-1];
        }
    }

    return dp[r-1][c-1];
}

四、01背包

什么是01背包?

01背包是在M件物品取出若干件放在空间为W的背包里,每件物品的体积为W1,W2至Wn,与之相对应的价值为P1,P2至Pn。01背包是背包问题中最简单的问题。01背包的约束条件是给定几种物品,每种物品有且只有一个,并且有权值和体积两个属性。在01背包问题中,因为每种物品只有一个,对于每个物品只需要考虑选与不选两种情况。如果不选择将其放入背包中,则不需要处理。如果选择将其放入背包中,由于不清楚之前放入的物品占据了多大的空间,需要枚举将这个物品放入背包后可能占据背包空间的所有情况。 

总的来说01背包需要考虑的有:是否选则该物品?物品体积?物品价值?背包体积?

接下来的两道题表面上不易看出是01背包的问题,但经过层层解刨不难看出是动态规划中01背包的问题

(1) 分割等和子集

leetcode传送➡️https://leetcode.cn/problems/partition-equal-subset-sum/

给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 

1. dp数组定义

由题,我们需要将nums这个数组分成两个总和相等的数组,用之前学习的回溯法也可以暴力的将其搜索出来,但是在leetcode上会超时;

我们换个思路,既然是分成等和,我们就将所有nums中的数相加除二,作为dp背包的体积,每一个数即使体积又是他的价值,若最后背包体积装满则返回true;

所以这里dp数组的含义为:背包所能存放的最大价值(总和)

2. 递推公式 

因为一个数只有选与不选两种选择,当不选时该背包位置只有原本存放的价值( dp[ j ] ),如果要选择就需要背包腾出该物品所需的体积再将该物品的价值加在这个背包中( dp[ j - nums[ i ] ] + nums[ i ] ) ),最后保持该背包体积所能存放做大价值就行了;

dp[j] = max(dp[j], (dp[j - nums[i]] + nums[i]));

3. 初始化

由题,dp[0]表示背包体积为0是所能存放的最大价值,自然就是0了,其余背包空间没有赋值时也是0,所以dp数组全部初始化为0即可

int dp[target + 1];
memset(dp, 0, sizeof(dp));

4. 遍历顺序

因为每个“物品”只能使用一次,所以背包需要从后往前遍历;

举一个简单的例子,物品体积价值均为1,如果从前往后遍历,dp[2] == 2 ,此时将体积为2的背包装满一共使用了两次 ‘ 1 ’ ,固然是错的

• 整体代码

#define max(x, y) (x) > (y) ? (x) : (y)

bool canPartition(int* nums, int numsSize) {
    //计算总和
    int sum = 0;
    for (int i = 0; i < numsSize; i++)
    {
        sum += nums[i];
    }
    
    if (sum % 2 != 0)
        return false;
    //初始化dp数组
    int target = sum / 2;
    int dp[target + 1];
    memset(dp, 0, sizeof(dp));
    //先物品后背包,从后往前遍历背包
    for (int i = 0; i < numsSize; i++)
    {
        for (int j = target; j >= nums[i]; j--)
        {
            dp[j] = max(dp[j], (dp[j - nums[i]] + nums[i]));
        }
    }

    if (dp[target] == target)
        return true;
    else
        return false;
}

(2) 目标和

leetcode传送➡️https://leetcode.cn/problems/target-sum/

给你一个整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

• 题目分析 

通读这道题,我们不难知道,其实这道题就是让我们求m个‘ + ’ 与 n-m个‘ - ’如何放置

草稿如下⬇️

 由上面草稿分析出来,我们只需要求出nums数组中能够凑成大小为Add的方法数即可;

1. dp数组定义

由上述分析可得,我们可以将Add算出得大小作为我们背包得体积,题目要求达到目标和的方法,则dp数组的含义为能够装满背包的方法数。

2. 递推公式 

跟上一题不一样,我们需要明确dp数组的含义;同样nums数组中的数即使此物品的体积,也是它的价值,要求的是方法数,而不是最大价值,所以装满一个体积的背包的方法数,是减去物品体积的其他dp的方法数之和;

 dp[j] += dp[j - nums[i]];

3. 初始化

初始化和递推公式都是非常巧妙的,必要时可以通过举特例分析⬇️

例如,物品为 ‘ 1 ’ ,装满dp[1]的方法数为  : dp[1 - 1] == dp[ 0 ] == 1;

及所有物品从dp[0] 装到dp[ j ] 都是一种方法,所以dp[ 0 ]需要初始化为1,其余初始化为0

int dp[bagSize + 1];
memset(dp, 0, sizeof(dp));
dp[0] = 1;

4. 遍历顺序

每个物品只能使用一次,同上一题一样,需要从后往前遍历背包;

• 整体代码

int findTargetSumWays(int* nums, int numsSize, int target)
{
    int sum = 0;
    for (int i = 0; i < numsSize; i++)
    {
        sum += nums[i];
    }
    int bagSize = (sum + abs(target)) / 2;    //加法的总和
    
    //特殊数据处理
    if (target < 0 && -target > sum)
        return 0;
    if ((sum + target) % 2)
        return 0;

    //初始化
    int dp[bagSize + 1];
    memset(dp, 0, sizeof(dp));
    dp[0] = 1;
    //遍历顺序及递推公式
    for (int i = 0; i < numsSize; i++)
    {
        for (int j = bagSize; j >= nums[i]; j--)
        {
            dp[j] += dp[j - nums[i]];
        }
    }
    return dp[bagSize];
}

 Summery💐💐💐

动态规划是一个比较抽象的算法,需要大家反复咀嚼,从根本上理解递推公式以及dp数组的含义

最近不久我就会更新动态规划(二)包含了完全背包与打家劫舍问题

感谢你能将文章耐心的读到这里💐

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Dusong_

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值