以纯小白的角度看 动态规划(DP学习总结)

本篇文章参考和引用处较多,均已给出出处,侵权则删

该篇文章乃当初初学dp,查阅众多博客有感总结,其中图片、资料部分来源他处,却也是自己花了大量时间总结和归纳写下的。文章以一个小白的角度理解动态规划用途、适用条件、使用方法、常见模型,思路是比较符合一个初学者逻辑认知的,希望能够对他人有帮助

引言

我们先来看一个栗子

问:
1+1+1+1+1+1+1+1=?
我们数了一遍,答案是8,没错

那么再+1是多少?
我们立刻报出9

我们刚才就潜意识地用了一次动态规划

动态规划说白了就是记住了过去的答案,避免了重复计算

但是也有人会说,我就是要计算机重新算一遍,反正计算机速度快,再让它多数一遍又能怎样,累的是它又不是我?

但是一旦问题一大,这个时间消耗将会呈现指数增长,计算机再快也会数不过来

看一个例子:

斐波那契

我们都知道斐波那契数列
在这里插入图片描述
这是一个明显具有最优子结构的问题,即问题的解是建立在规模更小的子问题上的解的。

我们可以用一个简单的递归实现:

 int fib(int n)
{
    if(n<=1)
        return 1;
    else   
    	return fib( n-1)+fib(n-2);
}

但是这个程序是特别特别不好的,当我们输入的n一大,这个程序就会爆炸
为什么?

我们来看一下它的递归过程:
在这里插入图片描述
我们看到在计算f(6)的时候计算机需要计算f(5),f(4),而计算f(5)又要计算f(3),f(4),,从上图可以看到一个同样的问题计算机重复求解了多次,这就使得程序效率大大降低,因为我们都在做一些已经做过的事情,所以这个时间复杂度可以到o(2^n),指数大爆炸。

就像我们刚开始1+1一样,我们为什么不让计算机记住它算过的东西呢?有句话叫做“好记性不如烂笔头”,我们事情太多了记不住怎么办?拿个小本本记下来,所以我们现在就是要把做过的事情用一张表或者备忘录存起来,避免重复计算。

自顶向下备忘录:

#define Max 1000 
int memory[Max]={0}; 
//  备忘录 
int fib(n) 
{
	if(memory[n]!=0)
	return memory[n];
	//表中已有,直接返回 
	//  否则,写入备忘录
	if(n<=1)
	memory[n]=1;
	else
	memory[n]=fib(n-1)+fib(n-2);
	 
	return memory[n];
}

和之前的递归很像,只不过多了一个备忘录,但是产生的效果是无法想象的。
可是这个代码还是不够好,因为我们都知道递归这个东西是很不友好的,不管是时间还是空间,所以也就有了我们下面一种方法:

自底向上查表法
什么叫自底向上?我们刚才不停地调用递归就是一个从大问题出发去解决小问题的过程,那为什么我们不一开始就从小问题开始解决呢?

#define Max 1000 
int tab[Max]={0}; 
// 表 
int fib(n) 
{
	tab[0]=tab[1]=1
	for(int i=2;i<=n;i++)
	tab[i]=tab[i-1]+tab[i-2];
	
	return tab[n];
}

自底向上的方法我们一开始就从小问题开始出发,递推获得更大问题的解,无疑要比递归地查备忘录的方法要更加好,但是他们归根结底都是用了查表的方法,记住做过的事情,每次做之前先查表,这也就是动态规划的核心。

接下来我们来几道题目熟悉以下动态规划的三个模型:线性模型、区间模型、背包模型

1.切原木问题–线性模型:

在这里插入图片描述
在这里插入图片描述

我们这里假设这个价格表是无限长的,也就是任何一个任何一段长度都能在这张表查到它直接卖出去的价格。那我们在求解Ri的时候就可以写出他的状态转移方程:
在这里插入图片描述
对于任何一段长度为n的原木来说,我们可以直接不切卖出去,也就是对应这里的pi,或者切长度为1和n-1,2和n-2。。。
我们的最优解就在这写情况中去一个Max.
当然这个方程还可以简化,(长度为8的原木,切左1右7和切左7右1是重复的)我们可以换个角度考虑,可以切最左边长度为i的一段,然后剩下右边的n-i段继续递归切割,而对左边切下来这段i不再切割,和上面的效果是一样的(因为递归调用的时候都会考虑到一段原长,那么在一开始进入递归前就把原长的那一段拎出来参加比较)
所以状态方程可以简化为:
在这里插入图片描述
所以根据这个方程我们还是可以很容易地写出递归:

#define Max 1000 
#define Min_value -1 
int p[Max]; 
//价格表 
int max(int a,int b)
{
	return a>b?a:b;
} 
int cut(n) 
{
	if(n==0)
	return 0;
	if(n==1)
	return 1;
	
	int q=Min_value;
	//最大价格 
	for(int i=1;i<=n;i++)
	{
		q=max(q,p[i-1]+cut(n-i));
	}
	
	return q;
}

类似于回溯,利用递归遍历解空间,和斐波那契不同的是这里在每一层上面都进行了最优解的选择。
所以我们可以在这个自上向下递归的基础上加一个备忘录,类似于上面的斐波那契;

#define Max 1000 
#define Min_value -1 
int p[Max]; 
//价格表 
int memory[Max]={0};
//备忘录 
int max(int a,int b)
{
	return a>b?a:b;
} 
int cut(n) 
{
	if(memory[n]!=0)
	return memory[n];
	
	if(n==1)
	memory[n]=p[1];
	else
	{
		int q=Min_value;
		//最大价格 
		for(int i=1;i<=n;i++)
		{
			q=max(q,p[i-1]+cut(n-i));
		}
		memory[n]=q;
	}
	
	return memory[n];
}

所以我们可以看到我们前后的代码唯一区别就在于加了一个备忘录的数组,这样我们就可以在算出一个值的时候就存起来,可以避免重复计算(再强调一遍)

当然我们最好用的是自底向上的动态规划(反递归):

#define Max 1000 
#define Min_value -1 
int p[Max]; 
//价格表 
int r[Max];
//最优解表  
int max(int a,int b)
{
	return a>b?a:b;
} 
int cut(n) 
{
	if(n<=0)return 0;
	 
	for(int i=1;i<=n;i++)
	{
		int q=Min_value;
		//自底向上动态规划的精髓 
		for(int j=1;j<=i;j++)
		{
			q=max(q,p[j]+cut[i-j]);
		}
		r[i]=q;
	} 
	
	return r[n];
}

我们先来看外层循环,q是每次算出来的最优解,循环的每一次把q这个最优解赋值给r[i],所以外层循环是在产生最优解;所以这个q是怎么算出来的呢?我们来看看内层循环,动态规划的精髓就在于里层的for循环,我们每次切一段长为j的原木作为左边的那一段不再切割的部分,剩下n-j继续切割(递归)得到右边那段最优解,两个合起来就是这种长度j下的最优解,然后j作循环,比较更新q,最终得到该阶段所有切法中的最优解。

所以通过刚才两个例子我们可以大致总结一下利用动态规划解决问题的基本思路:

1.划分子问题:
把原问题分解为若干个子问题,子问题和原问题形式相同或类似,只不过规模变小了。子问题都解决,原问题即解决(斐波那契)。
子问题的解一旦求出就会被保存,所以每个子问题只需求解一次。
2.确定状态
在用动态规划解题时,我们往往将和子问题相关的各个变量的一组取值,称之为一个“状态”。一个“状态”对应于一个或多个子问题, 所谓某个“状态”下的“值”,就是这个“状态”所对应的子问题的解。
什么叫做状态呢?比方说向上面那题切原木,我们要求长度为n的原木的最优价格r[n],这是该阶段的状态,之后我们可以把它切成两段,把它分为子问题r[i]和r[n-i],这就是下一个阶段的状态。所以个人理解状态其实就是子问题的取值状态。对于我们初学者而言,我们可以简单地把它理解为问题的一种指代
3.确定一些初始状态(边界状态)的值
也就是递推出口。只不过在递归里面这个叫做出口,在动规里面它是入口。
我们在递归里面,就是一层层地调用递归,直到遇到边界状态,然后一层一层返回出来,所以它在递归里面叫做出口;但是动规本身就是一个自底向上的过程,我么一开始就从边界状态出发,从小问题开始一点点把大问题解决,所以它在动规里面就是入口。
4. 确定状态转移方程
定义出什么是“状态”,以及在该“状态”下的“值”后,就要找出不同的状态之间如何迁移――即如何从一个或多个“值”已知的 “状态”,求出另一个“状态”的“值”(递推型)。状态的迁移可以用递推公式表示,此递推公式也可被称作“状态转移方程”。
重点在于“转移“两个字,我们之前都知道一般而言,规模大的问题往往比规模小的问题难解决,所以我们很多时候都是把大问题拆分为小问题去解决,所以这其实就是一种转移,是我们解决问题的视角的转移。
比方说斐波那契,它本身的定义就是一个状态转移方程。
这一步是动态规划解题过程中最难的一步,之所以说动态规划难,难就难在有时候我们根本写不出状态转移方程,为了这一个方程可能要想好久。

像刚才那个问题其实是一个典型的动态规划线性模型,什么叫线性模型?就是问题在每一个阶段的各种状态都是线性排布的

所以我们接下来看看动态规划的第二种模型——区间模型。

2.构造回文串问题–区间模型

和线性模型不一样,线性模型状态都是呈现线性分布,比方说像上面那题r[n]=r[i]+r[n-i]的形式,用一个一维数组来表示问题的状态。
而区间模型一般都是把状态表示为d[i][j]的二维数组形式,表示区间[i,j]上的最优解,然后通过状态转移计算出[i+1, j]或者[i, j-1]上的最优解,即问题的状态是呈现区间状分布。

给定一个长度为n(n <= 1000)的字符串A,求插入最少多少个字符使得它变成一个回文串。

我们都知道回文串,a0和an相等,a1和an-1相等。。。这就是一个典型的拥有子结构的特征。
我们用 d[ i ][ j ] 来表示A[i…j]这个子串变成回文串所需要添加的最少的字符数。
当A[i] == A[j]时,那么d[i][j] = d[i+1][j-1] (i+1 > j-1时,d代表空串,空串也是回文串,d为0)
当A[i] != A[j]时,这个时候有两种方案:
1、在A[j]后面添加一个字符A[i];
2、在A[i]前面添加一个字符A[j];

因此状态转移方程为
d[i][j] = min{ d[i+1][j], d[i][j-1] } + 1

因此可以很简单地写出备忘录写法

#define Max 1000
char temp[Max];
int d[Max][Max]={0};
int min(int a,int b)
{
	return a<b?a:b;
}
int minPut(int i,int j)
{	
	 if(d[i][j]!=0)return d[i][j];//查到
	 
	if(i>j)return 0;//空串 
	
	if(temp[i]==temp[j])
	d[i][j]=minPut(i+1,j-1);
	else
	d[i][j]=min(minPut(i+1,j),minPut(i,j-1))+1;
	
	return d[i][j];
} 

我们再来看一下自底向上

#define Max 1000
char temp[Max];
int d[Max][Max]={0};
int min(int a,int b)
{
	return a<b?a:b;
}
int minPut(int i,int j)
{ 
	for(int k=1;k<=j-i;k++)
	{
		//不同阶段
		for(int q=i;q<=j-k;q++)
		{
			//求该阶段的状态 
			if(temp[q]==temp[q+k])
			{
				d[q][q+k]=d[q+1][q+k-1]; 
			}
			else
			{
				d[q][q+k]=min(d[q+1][q+k],d[q][q+k-1])+1;
			}
		}	
	}	
	return d[i][j];
} 

因为子问题和i、j有关,所以在这里,d[i][j]就是问题的状态,d[i][j]的求解要依靠更小区间的d[i+1][j]或者d[i][j-1],还有d[i+1][j-1],如果自上向下就是一个逐渐缩小区间的过程,所以我们可以自底向上,从最小的区间出发,逐渐扩大区间来解决问题,而这个所谓的最小区间就是问题的边界状态,也就是区间长度为1。
所以外层循环k从1开始到i-j(也就是初始问题的区间长度),对于每一个k值(也就是每一个阶段),内层循环按照这个区间值k,得到该区间长度下所有子问题的解(也就是该阶段下的所有状态的值),然后继续进行下一个阶段的操作,直到区间扩展到i-j的长度。

3.背包模型

背包问题我们之前在其他算法里都有涉及到过,其中比较有代表性的就是0/1背包问题。也就是给定一个容量大小为V的背包,对于每一件物品选择放还是不放,找到最优的选择。
如果选择回溯法,那就是构造一个解集树,树里每一层的两个分支代表0和1,然后搜索解空间,是以一种暴力枚举思维去解决问题的(只不过是优化版的枚举)。
但是用动态规划,首先就应该去找子问题,看能不能把大的问题分解为规模小的问题。我们还是可以从每一件物品选择放或者不放的思维去做,但是我们的目标是将问题转化为子问题,这里的子问题是什么?
比方说现在有n件物品,容量为v的背包,每件物品的价值分别为Pi,体积分别为Ci,对于物品i来说,如果我们已经知道了前i-1件物品的最优放法得到的价值是f[i-1][v],那么我们可以得到状态转移方程:
f[i][v] = max{ f[i-1][v], f[i-1][v – Ci] +Pi }
对于第i件物品来说,它要么放,要么不放。如果选择不放,那么f[i][v] = f[i-1][v];如果选择放入,那么前提必须是这个背包当前必须有足够的剩余空间放下物品i,所以我们必须强制让前面i-1件物品挪出足够的空间,这个空间至少是Ci,所以前i-1件物品只能牺牲Ci的空间预留给i,所以此时f[i][v] = f[i-1][v – Ci] +Pi 。所以这道题目的状态是f[i][v],而我们每个阶段的决策就是在这两种放法里选择受益最大的。

假设山洞里共有a,b,c,d ,e这5件宝物(不是5种宝物),它们的体积分别是2,2,6,5,4,它们的价值分别是6,3,5,4,6,现在给你个大小为10的背包, 求最多能带走多少价值的宝物。

int n=5;//数量 
int bag=10;//容量   
int v[6]={0,2,2,6,5,4};//体积 
int p[6]={0,6,3,5,4,6};//价值 
int f[6][11]={0};//状态

int max(int a,int b)
{
	return a>b?a:b;
}

int take()
{
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=bag;j++)
		{
			if(j>=p[i])
				f[i][j]=max(f[i-1][j],f[i-1][j-v[i]]+p[i]);
			else
				f[i][j]=f[i-1][j];
		} 
	} 
	return f[n][bag];
}

外层循环i表示前i件物品,内层循环j代表可放入的背包空间,如果当前的背包空间j是比第i件物品的体积p[i]来得大的话,那么我们就要考虑第i件物品是否放入,也就是我们上面推导出来的状态转移方程;如果当前背包空间不足的话,那么只能选择不放入。一步步把问题扩大,最终把要求的f[n][bag]推导出来。

以上三种模型是动态规划最常见的几种情况,我们到这里可以来总结一下动态规划适用的一些问题的特征:

(1) 最优子结构:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。

(2) 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。

(3)有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(不是动态规划施展的必要条件,但是一旦问题中有重叠子问题存在,动态规划往往比别的算法有更好的效益)

各算法之间的比较

我们初步了解了动态规划,会发现它和其他三种算法总有那么些说不清的关系,好像和它们都有点像,却又好像很不一样。
和贪心算法相比,都是通过局部最优来推导全局最优;
和分而治之相比,都是要把大问题分为小问题去解决;
和回溯剪枝相比,又都要在之前的所有状态中去比较;
对于一个问题,我们到底该采用哪种算法合适呢?

1.相较于贪心法:
我们中学都学过一种很有用的推理方法,叫做数学归纳法。
具体思路是这样的:
对于一个问题An
1)当n=1时,问题成立.
2)假设当n=i 时问题成立,
则证当n=i +1 时,该式也成立.
由(1)(2)得,原命题对任意情况均成立

我们把上面这个推理过程形象化:
在这里插入图片描述
也就是对于Ai+1,只需要它的上一个状态Ai即可完成整个推理过程(而不需要更前序的状态)。我们将这一模型称为马尔科夫模型。对应的推理过程叫做“贪心法”。
没错,就是我们这里讲的这种贪心算法,可以看出它是一种很直接的推理,每次只需要考虑上一层阶段的状态。
在这里插入图片描述
而动态规划就不是那么直接的,它往往显得需要考虑到很多。
在这里插入图片描述
可以看到对于Ai+1我们需要进行如下的递推:
{A1->A2}; {A1, A2->A3}; {A1,A2,A3->A4};……; {A1,A2,…,Ai}->Ai+1. 即对于Ai+1需要前面的所有前序状态才能完成推理过程。这种方式就是第二数学归纳法,我们将这一模型称为高阶马尔科夫模型。对应的推理过程叫做“动态规划法”

所以其实对于这两种算法,之所以相像是因为他们都用到了最优子结构,但是递推方式不同。

2.相较于分而治之

比方说一开始的斐波那契,我们完全可以用递归的方式去做,把f(n)的大问题分解为f(n-1)和f(n-2)的较小规模问题,然后直接把小问题合并,这就是一种分而治之的思想。
但是我们也看到了,对于这种有大量重叠子结构的问题,简单地去使用分治,往往会出现一个子问题大量重复计算的现象,也就是当子问题之间相互交杂、干扰,并不是互相独立的时候,分治往往不如动态规划。因为对于分治来说,“分”好,“治”好 还不够,还要能“并”起来,子问题之间一旦出现“剪不断,理还乱”的交织现象,在“并”的时候就会显得很困难,还要额外去考虑子问题之间的互相干扰。

int Max3( int A, int B, int C )
{ /* 返回3个整数中的最大值 */
    return A > B ? A > C ? A : C : B > C ? B : C;
}
int DivideAndConquer( int List[], int left, int right )
{ /* 分治法求List[left]到List[right]的最大子列和 */
    int MaxLeftSum, MaxRightSum; /* 存放左右子问题的解 */
    int MaxLeftBorderSum, MaxRightBorderSum; /*存放跨分界线的结果*/
 
    int LeftBorderSum, RightBorderSum;
    int center, i;

    if( left == right )  { /* 递归的终止条件,子列只有1个数字 */
        if( List[left] > 0 )  return List[left];
        else return 0;
    }
 
    /* 下面是"分"的过程 */
    center = ( left + right ) / 2; /* 找到中分点 */
    /* 递归求得两边子列的最大和 */
    MaxLeftSum = DivideAndConquer( List, left, center );
    MaxRightSum = DivideAndConquer( List, center+1, right );
 
    /* 下面求跨分界线的最大子列和 */
    MaxLeftBorderSum = 0; LeftBorderSum = 0;
    for( i=center; i>=left; i-- ) { /* 从中线向左扫描 */
        LeftBorderSum += List[i];
        if( LeftBorderSum > MaxLeftBorderSum )
            MaxLeftBorderSum = LeftBorderSum;
    } /* 左边扫描结束 */
 
    MaxRightBorderSum = 0; RightBorderSum = 0;
    for( i=center+1; i<=right; i++ ) { /* 从中线向右扫描 */
        RightBorderSum += List[i];
        if( RightBorderSum > MaxRightBorderSum )
            MaxRightBorderSum = RightBorderSum;
    } /* 右边扫描结束 */
 
    /* 下面返回"治"的结果 */
    return Max3( MaxLeftSum, MaxRightSum, MaxLeftBorderSum + MaxRightBorderSum );
}

比方说最大子列和问题,分为两部分子序列,将子序列求解出最大值之后,并不能直接合并得到整个序列的最大子列和。

其实对于最大子列和问题我们可以把它的状态转移方程写出来:

r[i]=max(A[i],r[i-1]+A[i]);

数组A表示序列,r[i]表示以A[i]为结尾的连续序列的最大和,注意必须要以A[i]为结尾,不然就不满足无后效性的原则。对于每一个r[i]来说,我们的决策就是在A[i]和r[i-1]+A[i]中找出较大的那个。所以我们可以得到下面这段DP代码:

#define Max 1000
#define Min -1000
int A[Max]={Min,-2,11,-4,13,-5,-2}; 
int r[Max];//表示以A[i]为末尾的连续序列的最大和(必须以A[i]为结尾)
int max(int a,int b)
{
	return a>b?a:b;
}
int DP(int n)
{
	int ans=Min;
	for(int i=1;i<=n;i++)
	{
		r[i]=max(A[i],r[i-1]+A[i]);
		//在所有以A[i]结尾的连续最大序列和中找出最大的r[i]
		ans=max(ans,r[i]); 
	}
	return ans;
} 

一层循环,对于r[i]来说在A[i]和r[i-1]+A[i]中选择,但是我们这里的r[i]是以当前的A[i]为结尾的最大和,所以必须要在所有的r[i]里找出一个最大的,这样才是整个序列的最大子列和。

可以看出在子问题具有重叠性的时候,动态规划的性能就被很好的凸显出来。当然它的代价就是太消耗程序员的脑子了。
3.相较于回溯剪枝
回溯算法其实本质是一种暴力枚举,只不过进行了优化(加了剪枝),所以它的最优解是在之前所有阶段的所有状态的最优解组合比较得到的,相比于动态规划需要考虑更多(应该说是要去考虑到全部),因此采用了搜索解空间的方式。

4.总结如下:
一个问题是该用贪心、回溯、分治还是动态规划,完全是由这个问题本身阶段间状态的转移方式决定的

每个阶段的最优状态都是由上一个阶段的最优状态直接得到的->贪心;
每个阶段的最优状态是由之前所有阶段的状态的组合得到的->回溯;
每个阶段的最优状态是由上个阶段某些互不相干的状态直接合并得到->分治;
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划

参考博客:

算法-动态规划 Dynamic Programming–从菜鸟到老鸟
教你彻底学会动态规划——入门篇
六大算法之三:动态规划

  • 1
    点赞
  • 0
    评论
  • 0
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

©️2022 CSDN 皮肤主题:撸撸猫 设计师:马嘣嘣 返回首页

打赏作者

Reza.

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

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

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

打赏作者

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

抵扣说明:

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

余额充值