动态规划(DP)学习和思考

一、总体说明

最近在备战蓝桥杯,感觉蓝桥杯很多题目都可以尝试用暴力搜索,暴力枚举的方法尝试得出最终的结果,但是暴力的方式效率并不是很高,而且容易超时。在以前学回溯算法的时候,也是一脸懵,不理解IndexStart到底什么意思,不知道如何去找参数和递归逻辑,好在通过慢慢的学习和做题,逐渐对这类算法有了进一步的认识,下次可以单独出一期讲回溯算法。

本次博客主要讲述的是动态规划算法,也就是那个明明知道什么叫动态,什么叫规划,连起来就不知道它到底是什么的算法了。其实本人也还在一个在学习的过程,但通过几天的学习和思考,逐渐对DP有了一个新的认识,以下我将慢慢来进行说明我对DP的一个学习认识过程。

二、动态规划的概念

本人习惯于对任何问题,无论是学高数还是大物,甚至是数据结构与算法,都首先从其最初始的定义开始。那么什么叫动态规划呢?

动态规划(Dynamic Programming,简称DP)是一种解决多阶段决策问题的数学优化方法。它将原问题分解成若干个子问题,通过解决子问题只需解决一次并将结果保存下来,从而避免了重复计算,提高了算法效率。总的来讲,动态规划算法使用时,这个问题得具备两个要素:1.重叠子问题2.最优子结构。

三、具体示例

大体来说,判断一个题目适不适合动态规划,就得去尝试寻找对于这个题目来说,他存不存在最优子结构和重叠子问题。如果确实存在,那么如何去尝试用DP算法去解决这个问题呢?接下来跟着我的思路,去尝试看三个DP的问题,相信你会有更多的收获。

3.1 找出连续子序列的最大和

笔者是通过一个视频认识这道题的,这个视频在哔站,在搜索框中输入动态规划,第一个视频就是它。视频链接在这。10分钟彻底搞懂“动态规划”算法_哔哩哔哩_bilibiliicon-default.png?t=N7T8https://www.bilibili.com/video/BV1AB4y1w7eT/?spm_id_from=333.337.search-card.all.click&vd_source=09d0a5241128639946dcdfb5bf184df2感兴趣的小伙伴可以去看看,相信会对你有所帮助。

这道题目是这样说的,给定一个一维数组,尝试找出这个一维数组的连续子序列的最大和。如给出一维数组[3,-4,2,-1,2,6,-5,4],这个一维数组的最大连续子序列和为2+(-1)+2+6=9。

那么怎么去做这道题目呢?想开始我拿到这道题目也是一脸懵。一直反复在脑子里面思考“连续子序列的最大和,连续子序列的最大和,连续子序列的最大和”。但是重点在于这个嘛?重点在于你不要被他所谓的标签给束缚了,有人告诉你这道题可以用动态规划去解决,你就反反复复地去尝试用动态规划的思想去解决。首先尝试去定义一个DP数组,确定这个DP数组的意思是什么,然后去找到状态转移方程,然后定义初始化的条件????是这样做的嘛?显然不是。

对于这道题,我认为最好的方式是不要认为他可以用DP去解决,假装你自己并不知道这道题可以用DP,你甚至不知道这道题怎么去解决。到底能用数据结构或者算法中的哪一个去解决,你自己完全不知道。你首先第一步需要做的事情是去分析。这道题目说的是连续子序列,那么我们就从最开始的3开始,3+(-4),3+(-4)+2,3+(-4)+2+(-1).........,一直往后,然后从-4开始,从2开始,你发现了什么?在算3+(-4)+2+(-1)的时候,我是不是算过了3+(-4),我从2开始的话是不是算过了2+(-1)?所以,你发现了什么?一个很长的连续序列长串,他确确实实是由一个个连续序列短串加起来得到的,所以是不是存在子结构?显然,长串的最优解也是由子串的最优解得出,也就是说是存在最优子结构,不用多说,必然也是一个重叠子问题。那么说明了什么,这道题可以用DP的思想来进行解决。

那么DP首先实现三部曲,第一确定你的DP数组的含义,其次找到状态转移方程,接着初始化,最后进行相应的操作,实现整个算法思路和过程就OK了。那么问题来了,DP数组的含义我们怎么去确定呢?我觉得最简单的方式就是,他要求什么,我就设什么。既然这道题目让我求的就是子序列的和,我就定义这个dp数组的值就是子序列的和。那么怎么去确定这个dp数组是一维还是二维呢,也就是具体的含义是什么?这就得看你上面的分析了,第一个重点在于你的连续子序列从哪里开始,也就是数组的索引值是什么,其次在于你的连续子序列的长度是什么,所以这个dp就是一个二维数组,因为你的这个子序列的值和这两个因素有关。那么我们就可以定义dp[i][j]的含义为从数组的索引值为i处开始的长度为j的连续子序列的和。

那么问题来了,这个状态转移方程式什么呢?其实你加以分析一下,你要分析,要去动手,不然永远凭脑子去想的话永远都想不出来。dp[i][j]表示数组的索引值为i处开始的长度为j的连续子序列的和,那么dp[i-1][j]等于什么呢?dp[i][j-1]的含义是什么?索引值为i处开始的长度和j-1的连续子序列的和,所以dp[i][j]=dp[i][j-1]+arr[i+j-1]。这里把原数组定义为arr。

dp数组定义和状态转移方程已经写完了,那么接下去就是去实现dp数组的初始化。你可以将dp数组都初始化为0,也可以dp[0][1]=arr[0]都可以。这其实是和你的状态转移方程有一定的联系,可以多思考思考,初始化很重要。

代码的具体实现如下:

#include <iostream>
#include <vector>
using namespace std;

int dp[50][50];
vector<int> arr;


int main()
{
	int n, result;
	result = 0;
	cin >> n;
	for (int i = 0; i < n; i++)
	{
		int num;
		cin >> num;
		arr.push_back(num);
	}

	for (int i = 0; i < n; i++)
	{
		for (int j = 1; j <= n - i; j++)
		{
			dp[i][j] = dp[i][j - 1] + arr[j + i - 1];
			if (dp[i][j] > result)
			{
				result = dp[i][j];
			}
		}
	}

	cout << result << endl;

	return 0;
}

运行结果如下:

3.2 数组分割

我们再来看一道蓝桥杯真题。一样的,我们假装不知道这题目可以用动态规划来解决,通过自己的分析和探索,来看看这道题的原理和内层逻辑是什么,可不可以用动态规划去实现,如果可以用动态规划去实现,我们该怎么去实现?

具体题目如下:

题目链接为用户登录icon-default.png?t=N7T8https://www.lanqiao.cn/problems/3535/learning/

遇到任何题目,第一步最重要的不是看他到底是用什么算法去解决,最重要的点在于自己去分析。分析问题,看看这道题适合用什么方式去解决。所以,让我们开始动手去做,去尝试,看看怎么具体解决这个问题。

首先理解题目意思,先不管有几组数据,我们先尝试一组数据。给出一个数组,随便在这个数组里面选择数字,使数字之和为一个偶数,有多少种不同的选择方式。当然,需要保证S1、S2都为偶数,那么第一,整个数组的和是不是得为偶数。这就出现了第一个判断的条件了,脑子就可以开始写if判断了。

接下来,继续去思考,假如说对于一个数组[1,3,5,7,12,6,8]。我需要随便找数字,让他的和为偶数,我该怎么去找呢?可以选择1,可以选择3,可以选择5,甚至是7,这就显的很杂乱无章。如果我选择了1+3+5,那么后面再遇到1+3+5+7的时候,我是不是可以用前面已经算好的1+3+5得到的结果,再去加7呢?当然可以,那么就往这个方向去思考,存在子结构,重叠子问题,可以尝试。那么dp数组的定义就是所选数字之和,但是有个新问题,和找连续子序列的最大和这个问题不一样,他是一个连续的子序列,我这里是不连续的,是可以随机取的,是无序的,这里的i,j就无法定义,或者说定义了,你也没法找到状态转移方程。所以这道题,和以前写过的都不一样。

那么我们该怎么去思考呢?尝试从问题的结果出发,去寻找问题。这道题的问题是什么?是让我们去求选择的R1有多少种可能的情况。好,那我们这样来思考,既然这个数组他很长,那么我们就选择其中i个数吧,反正i小于等于这个数组的长度。从i个数中随便选择数,使选择的数之和为偶数的方式有x种,那你再去思考一下,我这时候再从整个数组中(除去那已经拿出的i个数)拿出一个数,对于这个数来说,是不是有两种可能,第一种,选择这个数,第二种,不选择这个数。那如果你这个数他是偶数,前面i个数中随便选择的数之和也为偶数,偶数+偶数当然是符号条件的,所以你把这个数加入i个数中,那么在i+1个数中任意选择数,使之和为偶数的情况有多少种?思考下,没错,就是2x种。同样,如果是奇数,可以自行按着这个思路进行思考下去,我就不再赘述。

那么思考完了之后,你就会发现,这不是一个动态规划的思路嘛?存在最优子结构,也是一个最优子问题,还有相应的状态转移方程,所以就是一个DP问题。

那么开始吧,我们尝试去写代码实现,具体代码如下:

#include <iostream>
#include <vector>
using namespace std;

//dp[i][0] 从数组前面i个数任意组合,得到结果和为偶数记为0,dp的值为从数组前面i个数任意组合,得到结果和为偶数的子集个数
//dp[i][1] 从数组前面i个数任意组合,得到结果和为奇数记为1,dp的值为从数组前面i个数任意组合,得到结果和为奇数的子集个数
//如果arr[i]为偶数,那么dp[i+1][0]=dp[i][0]*2
//如果arr[i]为奇数,那么dp[i+1][0]=dp[i][0]+dp[i][1]
long long mod = 1e9 + 7;

int main()
{
	int n, q;
	cin >> n;
	q = 0;
	vector<int> storge(n);
	while ( q < n)
	{
		int m, sum;
		cin >> m;
		vector<int> arr;
		sum = 0;
		for (int i = 0; i < m; i++)
		{
			int num;
			cin >> num;
			sum += num;
			arr.push_back(num);
		}
		
		if (sum % 2 == 0)
		{
			int dp[1010][2];
			dp[0][0] = 1;
			dp[0][1] = 0;
			for (int i = 1; i <= m; i++)
			{
				if (arr[i-1] % 2 == 0)
				{
					dp[i][0] = 2 * (dp[i - 1][0]) % mod;
					dp[i][1] = 2 * (dp[i - 1][0]) % mod;
				}
				else
				{
					dp[i][0] = (dp[i - 1][0] + dp[i - 1][1]) % mod;
					dp[i][1] = (dp[i - 1][0] + dp[i - 1][1]) % mod;
				}
			}
			storge[q] = dp[m][0];
		}
		else
		{
			storge[q] = 0;
		}

		q += 1;
	}

	for (int i = 0; i < q; i++)
	{
		cout << storge[i] << endl;
	}

	return 0;
}

 测试用例运行效果截图:

蓝桥测试用例只通过了20%,最近一直苦恼于样式用例好像都能通过,但是一旦测试用例,就有一部分能过,或者都不能过这种。有知道为什么的小伙伴也可以写在评论区或者私信我,告诉我一下怎么解决这个问题。(我觉得我写的这个代码的思路是没有问题的。)

蓝桥测试截图:

3.3 保险箱问题

题目链接在这

用户登录icon-default.png?t=N7T8https://www.lanqiao.cn/problems/3545/learning/首先一样的,先假装不知道这道题能用什么方式去解决,去动脑子,去思考,尝试对问题从最基础的地方开始剖析。

一个保险箱有n个数字,x为初始输入数字,y为我们需要变成的数字。根据题目的意思,实际上也就是说anan-1an-2......a0其实也就是为an*pow(10,n-1)+.....a0,所以第一个性质,无所谓你从哪个数字开始进行调整,可以是an,也可以是an-1,你随便调整。比如对于所给样例,输入12349,得到54321,你可以选择从1开始先调整到5,也可从2开始调整到4,依次类推都行。

既然怎么调整都行,我们尽量做到有序,那么要保证有序,可以从左到右,或者从右往左。假如从左往右去进行调整,思考一个问题,对于一个数,比如从9变成1,有两种可能,第一种我直接给9+2,是不是可以将这个位上的数变成1,或者说我从9直接-8也能变成1,反正不管怎么样,我要将x上的某一位变成y上的某一位,无非就是这两种操作。既然如此如果从左往右开始进行,如果对于第二位你进行了进位操作,势必会影响到第一位,那么第一位刚处理完岂不是白处理了。但是反过来,你从末尾开始从右往左进行调整,你可以发现,我只要调整好了这一位,影响的是前一位,但是前一位并没有进行调整过,因此,我们选择从右开始向左调整。

所以经过分析,我觉得可以用暴力搜索的方式去尝试解决这个问题,事实上递归的深度也就是整个数字的个数n,每一次操作,都有两种可能,要么进位/退位,要么直接在对应位置上进行+/-的操作。

以下是我实现的dfs的代码:

#include <iostream>
#include <string>
using namespace std;

int n;//数字位数
string x, y;//x为输入数字,y为目标数字
int arr[50];//1代表该数字直接加/减,2代表该数字进位/退位

int ninef(int a, int b, int flag)
{
	int res;

	//flag为1,说明直接进行加/减
	if (a > b && flag == 1)
	{
		res = a - b;
		return res;
	}
	//flag为1,进行退位/进位操作
	if (a > b && flag == 2)
	{
		res = 10 + b - a;
		return res;
	}

	if (a < b && flag == 1)
	{
		res = b - a;
		return res;
	}

	if (a < b && flag == 2)
	{
		res = 10 + a - b;
		return res;
	}
	
}


void dfs(int startIndex,int result, int& max_num)
{
	if (startIndex < 0)
	{
		if (result < max_num)
		{
			max_num = result;
		}
		return;
	}

	int a = x[startIndex] - '0';
	int b = y[startIndex] - '0';
	//递归逻辑
	if (a > b)
	{
		arr[startIndex] = 1;
		result += ninef(a, b, 1);
		dfs(startIndex - 1, result, max_num);
		arr[startIndex] = 0;
		result -= ninef(a, b, 1);

		arr[startIndex] = 2;
		result += ninef(a, b, 2);
		if (startIndex != 0)
		{
			x[startIndex - 1] += 1;
			
		}	
		dfs(startIndex - 1, result, max_num);
		arr[startIndex] = 0;
		result -= ninef(a, b, 2);
		if (startIndex != 0)
		{
			x[startIndex - 1] -= 1;

		}
	}

	if (a < b)
	{
		arr[startIndex] = 1;
		result += ninef(a, b, 1);
		dfs(startIndex - 1, result, max_num);
		arr[startIndex] = 0;
		result -= ninef(a, b, 1);

		arr[startIndex] = 2;
		result += ninef(a, b, 2);
		if (startIndex != 0)
		{
			x[startIndex - 1] -= 1;
			
		}
		dfs(startIndex - 1, result, max_num);
		arr[startIndex] = 0;
		result -= ninef(a, b, 2);
		if (startIndex != 0)
		{
			x[startIndex - 1] += 1;

		}
	}
	if (a == b)
	{
		dfs(startIndex - 1, result, max_num);
	}
}

int main()
{
	cin >> n;
	cin >> x >> y;
	int max_num = 100000;
	int result = 0;
	dfs(n - 1, result, max_num);

	cout << max_num << endl;
	
	return 0;
}

运行样例如下:

样例运行没问题,再蓝桥官方运行的话只通过了百分之二十的样例

既然如此,通过上述分析,其实不难发现一个DP的思想在里面,且听我慢慢道来。

我以简单一点的实例进行说明。

n=3 ,x=129 ,y=531,结合上述分析,不难画出如下图所示的树状结构图

剩下的节点可以自行画完。整个最小方案就是9+2,然后3不变,最后1变5,最小方案为6。其实对每个结点来说,无非是两种情况,到底是+/-得来的,还是进位/退位得来的呢?这里以x和y的从左往右数的第二位来说明吧。假设现状9已经变为了1,那么9怎么样变为1呢?是+还是减?+了必然会影响他的前一位,减了并不影响前一位,所以我第二位有几种变来的方式?取决于上一位怎么变化。同时,通过树结构不难发现,整个最优解其实就在一条路径上,你会发现,整体的最优解,其实也就是每一步的最优方案。对于9而言,总体最优的方案就是9+2,而实际单独拿出9来说,其最优解也是对9+2,变成1。那么存在最优子结构,存在重叠子问题,又何尝不是一个DP问题呢。

那么既然整道题目是一个DP问题,首先得确定DP数组的含义到底是什么。回到树结构,观察每一个结点,DP问题不就是找到前后之间的联系嘛,试着去找一下。第一个节点有两种方式变为目标节点,但实际上他自己如何变会影响到下一节点,那么这就是前后之间的关系。那么就可以定义了,DP[i][j]为目标为i的节点通过j的方式得到,而j有什么可能,要么+,要么-,所以j的值可为0/1。具体代码如下:

#include<bits/stdc++.h>

using namespace std;

int n;

int x[100010];//存放输入:1 2 3 4 9
int y[100010];//存放答案:5 4 3 2 1

int dp[100010][2];//dp[i,j]表示(从后向前数)第i位达到了目标,且第i位是靠+实现的(y==0)或者第i位是靠-实现的(y==1)
 


void fun()
{
    dp[0][0] = (y[0]-x[0]+10)%10;//9加到2
    dp[0][1] = (x[0]-y[0]+10)%10;//9减到2
    
    for( int i=1;i<=n-1;i++ )//以i=1为例: 无非就是当前是加减,上一位是加减,四种排列组合状态 
    {
        dp[i][0] = min( (y[i] - x[i] - (y[i-1]<x[i-1]) +10 )%10 + dp[i-1][0],//(2-4-(1<9)+10)%10+2 = 9:9先加到1,5再加到2 
                         (y[i] - x[i] + (y[i-1]>x[i-1]) +10 )%10 + dp[i-1][1]);//(2-4+(1>9)+10)%10+7 = 16:9先减到1,4再加到2 
        
        dp[i][1] = min( (x[i] - y[i] + (y[i-1]<x[i-1]) +10 )%10 + dp[i-1][0],//(4-2+(1<9)+10)%10+2 = 5: 9先加到1,5再减到2 
                         (x[i] - y[i] - (y[i-1]>x[i-1]) +10 )%10 + dp[i-1][1]);//(4-2-(1>9)-2+10)%10+2 = 10:9先减到1,4再减到2 
    }
    
    cout<<min( dp[n-1][0],dp[n-1][1] )<<endl;//最终的结果,可能是加来的,也有可能是减来的。 
}


int main()
{
    cin>>n;
    char c;
    for( int i=n-1;i>=0;i-- )
    {
        cin>>c;
        x[i] = c-'0';
    }
    
    for( int i=n-1;i>=0;i-- )
    {
        cin>>c;
        y[i] = c-'0';
    }
    
    fun(); 
    
}

四、总结

动态规划算法的变形有很多,题型有很多,解题的方法也有很多。想要把动态规划算法用的如鱼得水,我觉得还是得多刷题目并不断思考。

学习算法时,重点不在于这道题的标签是什么,更重要的在于去思考这道题目的底层逻辑,尝试去对问题进行剖析,而不是停留在用DP或者回溯或者别的算法的公式去套。题目是活的,人也是活的,希望我们都能在不断解决问题的快乐中继续前行。

  • 31
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值