[新手向] 动态规划引入

[新手向] 动态规划引入

被迫营业qwq,写一篇新手向的动态规划blog

我的博客


小场景(可跳过):

多年后,几个曾经学过\(OI\)但是比较菜的小伙伴,有缘能聚在一起交流

\(A\): 我还记得当初学\(OI\)的时候,一开始我雄心壮志,后面学到递归和搜索,就给我整不会了,研究了好久才搞明白;后面学到动态规划,直接给我劝退了

\(B\): 确实,动态规划给我人整麻了,笑死,就没看懂过

\(C\): 真的,我做dp的题,费了老大劲才看懂题解,结果自己上手啥也不会

\(A\): 式子推不出来啊呜呜呜

\(B\): 状态都设计不出来,还推式子

\(A B C\): 唉!动态规划怎么这么难!

(备注:灵感来源于现实生活)


一、何为动态规划

1. 动态规划的定义

  • 动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法
  • 动态规划常常适用于有重叠子问题最优子结构(英语:Optimal substructure)性质的问题,动态规划方法所耗时间往往远少于朴素解法。 ——摘自维基百科

2. 由递归到动态规划

仔细琢磨一下动态规划的定义,这玩意儿怎么感觉和递归这么像捏??

你瞧,动态规划,是把原问题分解为相对简单的子问题的方式求解复杂问题的方法

递归不就是这样吗?

对于我们经典的爬楼梯问题:有\(n\)级台阶,你每次可以爬1级或者2级,求最终爬到第\(n\)级有多少中不同的爬楼梯方案。我们在C语言课堂中用的是递归来解决这个问题的。解题思路大概就是:

(1)假设我现在在第\(n\)级,我是怎么到这里来的?要么就是从\(n-1\)级台阶走1级到第\(n\)级,要么就是从\(n-2\)级台阶走2级到第\(n\)级;

(2)那么问题就转化成,我是怎么走到第\(n-1\)级台阶和第\(n-2\)级台阶的了,是不是就可以套用第(1)步的方法来求解走到第\(n-1\)级台阶和第\(n-2\)级台阶这两个小问题;

这就是递归,把大问题分成相似的小问题,小问题又可以再分,代码段示例如下(注意递归边界的处理)

int f(int x){
	if(x==1) return 1;//我当前处于第1级台阶,我有几种走法?1种
    if(x==2) return 2;//我当前处于第2级台阶,我有几种走法?2种
    return f(x-1)+f(x-2);//我当前处于第x级台阶,我怎么走来的?从x-1走1级+从x-2走2级
}

但是在上课的时候,应该有同学做过实验,就是输入\(n=100\)或者更大,程序就像陷入了死循环一样,根本出不来答案?这是怎么一回事呢?

其实不是它跑不出来答案,而是计算量实在是太大太大了。我们把式子展开一下:

\(f(n)=f(n-1)+f(n-2)=[f(n-2)+f(n-3)]+[f(n-3)+f(n-4)]=...\)

zghAbV.png

会发现,(不严谨的说法)计算量从2变到4,再变到8,每次都翻倍了,这样下去,\(n\)如果稍微大一点点,程序的计算量就贼大贼大,就要跑很久很久。这对于追求速度的我们,是完全不ok的

细心的同学可能会发现,我们其实重复算了很多东西,比如\(f(n-2)\),我们算了两次,\(f(n-3)\),我们算了3次......那么有没有一种可能,比如说,我们在第一次把\(f(n-2)\)算出来之后,把它的值保存下来,下次再遇到\(f(n-2)\)的时候,直接调用已经保存的值,就不用重复计算了

诶,那么我们的计算过程似乎变成了这样

zghVET.png

是不是大大缩减了计算过程?

代码段如下:

int dp[100005];//dp[x]表示走到第x级台阶的方法数
dp[1]=1,dp[2]=2;//在第1级和第2级的走法数
int f(int x){
    if(dp[x]!=0) return dp[x];//如果曾经走到过第x级台阶并储存下来过它的值,直接调用
    dp[x]=f(x-1)+f(x-2);//我当前处于第x级台阶,我怎么走来的?从x-1走1级+从x-2走2级,用dp[x]存一下
    return dp[x];
}

这样你可以试一下,别说是\(n=100\)了,就是\(n=100000\),也能跑出结果

这就是动态规划以空间换时间,耗费一定的空间把曾经算过的结果存起来,要用的时候直接用,不用再算。

3. 非递归版本的动态规划

上面那种是递归版本的动态规划,更一般递归形式的动态规划我们常把它叫做记忆化搜索,其实是深度优先搜索算法(dfs)的记忆化,更多内容请自行查阅资料。

有人说,可恶我递归学的不好,不想用递归怎么办。别慌,非递归形式的动态规划是存在滴

我们递归形式的动态规划,实际上计算顺序是从\(f(n)\)倒推回去;

只要我们换一个推的顺序,即最开始已知\(f(1)\)\(f(2)\)\(f(3)=f(1)+f(2)\),.......按顺序往后推,它就变成了递推形式的动态规划,也就是广大OIer更喜闻乐见的动态规划的代码实现形式

代码段如下:

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

最后\(dp[n]\)就是所求结果,这玩意儿其实就是个斐波那契数列

4. 一些动态规划的题目

贴个题单在这里,很多题目相对来说有一定难度

有个名叫数字三角形的题目一定要看一看,这个是真正的动态规划入门题目,斐波那契数列属于不入流的;里面也有我们接下来要讲到的背包问题

牛客 动态规划

5. 求解动态规划问题的一般步骤与思路

相关术语请查询百度百科或其他资料

通过分析大量的可以利用动态规划算法的题目,我们可以总结一下,什么样的题目可以用动态规划算法来解决

  • (1) 具有最优子结构性质

    如果子问题的最优解正好可以推出更大问题的最优解,官方一点的说,如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。动态规划的状态转移方程一般从这里入手推起

  • (2) 无后效性

    子问题的解一旦被确定,就不再被影响,不会被改变,后续决策不会对已经求得的子问题的最优解产生影响

  • (3) 子问题的重叠性质

    子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。就像之前的例子中,\(f(n-2)\)被算了多次,可以存起来,之后再遇到时,直接调用。

如何求解动态规划问题?

可以参考1

可以学习2

常用的解题思路为:

  • (1) 确定子问题
  • (2) 确定状态
  • (3) 推状态转移方程
  • (4) 确定边界
  • (5) 确定实现方式
  • (6) (非必要)优化

注意,接下来讨论的问题,需要对于动态规划有一定的了解才行。0基础的,先去练练级再来。

在学习的时候,一定要多多查阅其他的资料,反复研究更多的资料,才会有所长进


二、动态规划的经典问题——背包问题

1. 什么是背包问题

  • 背包问题(英语:Knapsack problem)是一种组合优化的NP完全问题。问题可以描述为:给定一组物品,每种物品都有自己的重量价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。问题的名称来源于如何选择最合适的物品放置于给定背包中,背包的空间有限,但我们需要最大化背包内所装物品的价值。背包问题通常出现在资源分配中,决策者必须分别从一组不可分割的项目或任务中进行选择,而这些项目又有时间或预算的限制。

    ​ ——摘自维基百科

2. 01背包

举一个非常经典的例子

题目链接


1° [NOIP2005 普及组] 采药

题目描述

辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:“孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。”

如果你是辰辰,你能完成这个任务吗?

输入格式

第一行有 \(2\) 个整数 \(T\)\(1 \le T \le 1000\))和 \(M\)\(1 \le M \le 100\)),用一个空格隔开,\(T\) 代表总共能够用来采药的时间,\(M\) 代表山洞里的草药的数目。

接下来的 \(M\) 行每行包括两个在 \(1\)\(100\) 之间(包括 \(1\)\(100\))的整数,分别表示采摘某株草药的时间和这株草药的价值。

输出格式

输出在规定的时间内可以采到的草药的最大总价值。

样例 #1

样例输入 #1

70 3
71 100
69 1
1 2

样例输出 #1

3

提示

【数据范围】

  • 对于 \(30\%\) 的数据,\(M \le 10\)
  • 对于全部的数据,\(M \le 100\)

【题目来源】

NOIP 2005 普及组第三题


上述问题是典型的01背包问题(01 knapsack problem)。01背包问题的基本模型是:

  • 一共有\(N\)件物品,第\(i\)\(i\)从1开始)件物品的重量为\(w[i]\),价值为\(v[i]\)

    在总重量不超过背包承载上限\(W\)的情况下,能够装入背包的最大价值是多少?

我们定义状态\(dp [i] [j]\)意义如下

dp[i][j]表示把前i个物品装入限重为j的背包中,可以获得的最大价值

那么现在对于第\(i\)件物品,我们来做决策:

  • (1) 第\(i\)件物品丢掉,不装进来,那么就有

    dp[i][j]=dp[i-1][j];//第i件物品丢掉
    
  • (2) 第\(i\)件物品装进来,注意,必须要装得下才行,不然只能丢掉

    耗费\(w[i]\)的重量,但使得总价值增加\(v[i]\)

    dp[i][j]=dp[i-1][j-w[i]]+v[i];//装入第i件物品,要保证j-w[i]>=0!即必须要装得下
    

    那么怎么做决策呢?两者之间,选择最大!即:

    dp[i][j]=max(dp[i−1][j], dp[i−1][j−w[i]]+v[i]);//j-w[i]>=0
    

上述过程就是关于01背包问题状态转移方程的推导

2° 一个优化——滚动数组

有趣的是,对于上述01背包的状态转移方程,我们还可以做进一步的优化

从上述状态转移方程不难发现,\(dp [i] [j]\)的值仅与\(dp[i-1] [ 0 ,..., j-1 ]\)有关,于是我们可以采用喜闻乐见的滚动数组方法,对空间进行优化

一般的滚动数组优化采取的是%2的方式进行优化,伪代码如下:

for(int i=1;i<=n;i++){
	...
	dp[i%2][j]=max(dp[(i-1)%2][j],dp[(i-1)%2][j-w[i]]+v[i]);
	...
}

对于01背包我们甚至可以把它优化到一维数组,但是需要注意的是,为了防止之前有用的值被覆盖掉,循环的时候j只能够反向枚举

参考学习3

代码段如下:

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

3. 完全背包

上述01背包问题,每种物品只有一个,可是假设每种物品有无限多个呢,怎么办!!!

这就是接下来要讲到的完全背包问题

1° 经典模型

完全背包(unbounded knapsack problem)与01背包不同就是每种物品可以有无限多个:一共有\(N\)种物品,每种物品有无限多个,第\(i\)\(i\)从1开始)种物品的重量为\(w[i]\),价值为\(v[i]\)。在总重量不超过背包承载上限\(W\)的情况下,能够装入背包的最大价值是多少?

类比01背包,我们还是可以设计状态\(dp [i] [j]\)意义如下:

dp[i][j]表示把前i个物品装入限重为j的背包中,可以获得的最大价值

那么同样的,现在对于第\(i\)件物品,我们来做决策:

  • (1) 第\(i\)件物品丢掉,不装进来,与01背包相同,那么就有

    dp[i][j]=dp[i-1][j];//第i件物品丢掉
    
  • (2) 第\(i\)件物品装进来,此时和01背包就有差别了

    现在每种物品有无限多个,那么等于说,在装入了第\(i\)个物品后,还可以再继续装入第\(i\)个物品,同样注意,必须要装得下才行,不然只能丢掉,即:

    dp[i][j]=dp[i][j-w[i]]+v[i];//装入第i件物品,要保证j-w[i]>=0!即必须要装得下
    

    那么怎么做决策呢?老规矩,两者之间,选择最大!即:

    dp[i][j]=max(dp[i−1][j], dp[i][j−w[i]]+v[i]);//j-w[i]>=0
    

上述过程就是关于完全背包问题状态转移方程的推导

2° 滚动数组优化

对于完全背包,也可以用滚动数组优化至一维,但是在这里与01背包不同,它的\(j\)必须要正向枚举

具体原理可以参考

参考学习4

代码段如下:

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

4. 更深入地研究

如果你对背包问题很感兴趣,只了解这些肯定是不够的,推荐经典的背包问题九讲

背包问题九讲

posted @ 2022-12-07 22:32  Truman_2022  阅读(85)  评论(0编辑  收藏  举报