动态规划、斐波那契数列、数塔问题、01背包
动态规划从某种意义上来说不算是一种算法,更像是一种奇思妙想,它没有固定的写法、极其灵活,常常需要根据具体问题具体分析。
什么是动态规划
动态规划,简称DP,是一种用来解决一类最优化问题的算法思想。简单来说,动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。
注意:
- DP会将每个求解的子问题的解记录下来,这样,当下一次碰到同样的子问题时,就可以直接使用之前记录的结果,而不是重复计算
- 虽然DP采用上述方式提高了计算效率,但并不能说这就是DP的核心
- 一般可以使用递归或者地推的写法来实现DP,其中递归写法又常常被成为记忆化搜索
DP的递归(斐波那契数列)
以斐波那契数列问题为例,我们应该考虑的时如何记录子问题的解,避免下次遇到相同的子问题时仍然进行重复计算。
斐波那契数列
定义:
F
0
=
1
,
F
1
=
1
,
F
n
=
F
n
−
1
+
F
n
−
2
(
n
≥
2
)
F_0 = 1,F_1 = 1,F_n = F_{n-1}+F_{n-2} (n \ge 2)
F0=1,F1=1,Fn=Fn−1+Fn−2(n≥2)
// C简单构造斐波那契数列
int F(int n)
{
if(n == 0 || n == 1)
return 1;
else
return F(n-1) + F(n-2)
}
事实上,这样的递归会涉及到很多重复的计算
比如,当 n = 5时,可以得到F(5) = F(4) + F(3),接下来计算F(4)时
又会有F(4) = F(3) + F(2),F(3)被计算了两次。
当n非常大的时候,复杂度就会变得很大。
所以,为了避免重复计算,我们可以使用DP的思想,创建一个一维数组dp,用以保存已经计算过的结果。
其中dp[n]记录F(n)的结果,并用dp[n]=-1表示当前还没有被计算过,这样的话,就可以在递归当中判断dp[n]是否是-1:
- 不是-1,说明已经计算过F(n),直接返回dp[n]就是我们需要的结果
- 是-1,则继续按照递归式进行递归
int dp[MAXN];
int F(int n)
{
if(n == 0 || n == 1) //设置一个递归边界
return 1;
if(dp[n] != -1) //已经计算过的值,直接返回结果,不需要重复计算
return dp[n];
else
{
dp[n] = F(n-1) + F(n-2); //第一次遇到的情况,计算F(n),并存放至dp[n]
return dp[n];
}
}
通过记忆化搜索,记录重叠子问题的解,避免了大量的重复计算
DP的递推(数塔问题)
例:如下图所示,将一些数字排成数塔的形状,其中第一层有一个数字,第二层有两个数字……第n层有n个数字。现在要从第一层走到第n层,每次智能走向下一层链接的两个数字中的一个,求最后将路径上所有数字相加后得到的和的最大值
由题可知,从第一层走到第n层,有很多条路径可以走,如果我们按照统计做法,把每条路径都从头到尾走一遍,再求最大值,无疑会有很多重复的计算,那复杂度就太大了
按照题目的描述,如果开一个二维数组f,其中f[i][j]存放第i层第j个数字
那么就有f[1][1]=5、f[2][1]=8……f[4][4]=6
注意到一个细节:
如果要求出“从位置(1,1)到达最底层的最大和dp[1][1]”,那么一定要先求出它的两个子问题“从位置(2,1)到达最底层的最大和dp[2][1]”和“从位置(2,2)到达最底层的最大和dp[2][2]”,即在这个过程中进行了一次决策:是走数字5的左下路径还是右下路径。于是dp[1][1]就是dp[2][1]和dp[2][2]的较大数值加上5,写成式子如下:
dp[1][1] = max(dp[2][1],dp[2][2])+f[1][1];
归纳可得:
如果要求出dp[i][j],那么一定要先求出它的两个子问题“从位置(i+1,j)到达最底层的最大和dp[i+1][j]”和“从位置(i+1,j+1)到达最底层的最大和dp[i+1][j+1]”,即在这个过程中进行了一次决策:是走位置(i,j)的左下路径还是右下路径。于是dp[i][j]就是dp[i+1][j]和dp[i+1][j+1]的较大数值加上f[i][j],写成式子如下:
dp[i][j] = max(dp[i+1][j],dp[i++1][j+1])+f[1][1];
我们把dp[i][j]称为问题的状态,那么上述式子就是该问题的状态转移方程
可以发现,状态dp[i][j]只与第i+1层的状态有关,与其他层的状态无关,这样层号i的状态就总是由层号为i+1的两个子状态得到。
即数塔的最后一层的dp值总是等于元素本身:
dp[n][j]=f[n][j](1<=j<=n)
把这种可以直接确定其结果的部分称为边界,而DP的递推写法总是从这些边界出发,通过状态转移方程扩散到整个dp数组
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 1000;
int f[maxn][maxn],dp[maxn][maxn];
int main()
{
int n;
scanf("%d",&n);
for(int i = 0;i <= n;i++) //输入数塔
{
for(int j = 1; j <= i;j++)
{
scanf("%d",&f[i][j]);
}
}
//边界
for(int j = 1;j <= n;j++)
{
dp[n][j] = f[n][j];
}
//从第n-1层不断往上计算出dp[i][j]
for(int i = n-1; i >=1; i--)
{
for(int j = 1;j <= i;j++)
{
//状态转移方程
dp[i][j] = max(dp[i+1][j],dp[i+1][j+1]) + f[i][j];
}
}
print("%d\n",dp[i][i]);
return 0;
}
浅浅总结一下吧
经典DP其实可以说是有套路可以走的:
- 发现重叠子问题
- 找出边界
- 求出状态转移方程
典中典——01背包
问题:
有n件物品,每件物品的重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有1件
如果我们采用暴力枚举的方式,那就会跟我们最初学习数塔问题时的想法一样,将每种情况列举一遍,这样的话他的复杂度无疑是非常糟糕的,所以我们当然会想到使用DP来解决这个问题。
简单分析,容易得出一下两种策略:
- 不放入第i件物品,那么问题转化为前i-1件物品恰好装入容量为v的背包中所能获得的最大价值,即dp[i-1][v]
- 放入第i件物品,那么问题转化为前i-1件物品恰好装入容量为v-w[i]的背包中所能获得的最大价值,即dp[i-1][v-w[i]]+c[i]
所以我们只需要在这两种策略中做一个决策。求出最大的那一个就行:
//状态转移方程
dp[i][v] = max(dp[i-1][v],dp[i-1][v-w[i]]+c[i])
根据我们在数塔问题中的学习,我们可以直接写出他的代码:
// 01背包 经典DP
# include <cstdio>
#include <studio.h>
#include <stdlib.h>
int maxn = 100;
int maxv = 1000;
int w[maxn],c[maxn],dp[maxv];
int main()
{
int n ,V;
scanf("%d%d",&n, &V);
for(int i =1;i<=n;i++)
{
scanf("%d", &w[i]);
}
for(int i =1;i<=n;i++)
{
scanf("%d", &c[i]);
}
// 边界
for(int v =0; v<=V;v++)
{
dp[v]=0;
}
for(int i =1;i<=n;i++)
{
for(int v =V;v>=w[i];v--)
{
//状态转移方程
dp[v] = max(dp[v] , dp[v-w[i]]+c[i[);
}
}
//寻找dp[0...V]中最大的即为答案
int max = 0;
for(int v =0;v<=V;v++)
{
if(dp[v] > max)
{
max = dp[v];
}
}
printf("%d\n",max);
return 0;
}