算法竞赛入门——动态规划

记忆化搜索与动态规划

01背包问题

题目描述

有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。
第 i 件物品的体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。

输入格式

第一行两个整数,N,V,用空格隔开,分别表示物品数量和背包容积。
接下来有 N 行,每行两个整数 vi,wi,用空格隔开,分别表示第 i 件物品的体积和价值。

输出格式

输出一个整数,表示最大价值。

数据范围

0 0i,wi≤1000

输入样例

4 5
1 2
2 4
3 4
4 5

输出样例

8

分析
首先考虑朴素做法,即枚举所有可能的情况,输出其中价值最大的那一个
时间复杂度O(2n)

代码如下

#include 
using namespace std;
const int N=1010;
int V[N],W[N];
int n,v;
int res;

void dfs(int k,int w,int m)  //k代表当前选的是第k+1个物品,w是当前的物品价值,m是剩余背包容量
{
     
	if(k==n)   //所有物品都选过一遍
	{
     
		res=max(res,w);
		return ;
	}
	if(m>=V[k])
	{
     
		dfs(k+1,w+W[k],m-V[k]);
		dfs(k+1,w,m);
	}
	else
	{
     
		dfs(k+1,w,m);
	}
}

int main()
{
     
	cin>>n>>v;
	for(int i=0;i<n;i++)
	{
     
		cin>>V[i]>>W[i];
	}
	dfs(0,0,v);
	cout<<res<<endl;
	
	return 0;
}

提交结果:Time Limit Exceeded
这种写法的时间复杂度是指数级别的,当N=100的时候,最坏就需要O(2100)的时间,而题目中N<=1000,接下来就需要考虑对这种暴力解法进行优化。

在上述搜索的过程中,有些中间过程是相同的,它们的结果是相同的,但在搜索的过程中,并不会记录这些值,每次都需要重新计算。从这个层面,可以考虑记录中间结果,对上述算法进行优化。

优化后的代码

#include 
#include 
using namespace std;

const int N=1010;
int V[N],W[N];
int dp[N][N]; 
int n,v;

int dfs(int i,int j)
{
     
	if(dp[i][j]>=0)
	{
     
		return dp[i][j];
	}
	int res;
	if(i==n)
	{
     
		res=0;
	}
	else if(j<V[i])
	{
     
		res=dfs(i+1,j);
	}
	else
	{
     
		res=max(dfs(i+1,j),dfs(i+1,j-V[i])+W[i]);
	}
	return dp[i][j]=res;
}

int main()
{
     
	cin>>n>>v;
	memset(dp,-1,sizeof(dp));
	for(int i=0;i<n;i++)
	{
     
		cin>>V[i]>>W[i];
	}
	cout<<dfs(0,v)<<endl;
	
	return 0;
}

提交结果:Accepted
时间复杂度:O(NV)
对于同样的参数只会在第一次被调用时执行递归部分,第二次之后都会直接返回。这种方法被称为记忆化搜索。
根据上面的算法用到的记忆化数组,记dp[i][j]为从前i个物品中选出总体积不超过j的物品时总价值的最大值,可得以下递推式
d p [ 0 ] [ j ] = 0 dp[0][j]=0 dp[0][j]=0
d p [ i ] [ j ] = { d p [ i − 1 ] [ j ] , j < V [ i ] m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − V [ i ] ] + W [ i ] ) , j > = V [ i ] dp[i][j]=\left\{ \begin{aligned} &dp[i-1][j], & j=V[i] \end{aligned} \right. dp[i][j]={ dp[i1][j]max(dp[i1][j],dp[i1][jV[i]]+W[i])j<V[i]j>=V[i]

AC代码

#include 
#include 
using namespace std;

const int N=1010;
int V[N],W[N];
int dp[N][N]; 
int n,v;

int main()
{
     
	cin>>n>>v;
	for(int i=1;i<=n;i++)
	{
     
		cin>>V[i]>>W[i];
	}
	for(int i=1;i<=n;i++)
	{
     
		for(int j=1;j<=v;j++)
		{
     
			if(j<V[i]) dp[i][j]=dp[i-1][j];
			else       dp[i][j]=max(dp[i-1][j],dp[i-1][j-V[i]]+W[i]);
		}
	}
	cout<<dp[n][v]<<endl;
	
	return 0;
}

这个算法的时间复杂度也是O(NV),但是简洁了很多。以这种方式一步步按顺序求出问题的解的方法被称作动态规划法。
观察上面的代码发现,dp[i][j]的取值只与上一层(即第i-1行)的值有关,且j滚动数组。

AC代码

#include 
#include 
using namespace std;
const int N=1010;
int V[N],W[N];
int dp[N]; 
int n,v;
int main()
{
     
	cin>>n>>v;
	for(int i=1;i<=n;i++)
	{
     
		cin>>V[i]>>W[i];
	}
	for(int i=1;i<=n;i++)
	{
     
		for(int j=v;j>=V[i];j--)
		{
     
			dp[j]=max(dp[j],dp[j-V[i]]+W[i]);
		}
	}
	cout<<dp[v]<<endl;
	
	return 0;
}


需要注意的是,使用滚动数组时,j应该反过来循环,即从后面向前面覆盖,且循环的条件是j>=V[i],这是因为j

装箱问题

题目描述
有一个箱子容量为V(正整数,0 ≤ V ≤ 20000),同时有n个物品(0<n ≤ 30),每个物品有一个体积(正整数)。
要求n个物品中,任取若干个装入箱内,使箱子的剩余空间为最小。

输入描述:

1个整数,表示箱子容量
1个整数,表示有n个物品
接下来n行,分别表示这n个物品的各自体积

输出描述:

1个整数,表示箱子剩余空间。

示例1

输入

24
6
8
3
12
7
9
7

输出

0

分析

可以看作是简化的01背包问题,将物品的体积看作价值,求出最大的价值,再用总体积减去最大的价值,可求得箱子剩余空间的最小值。首先可以考虑采用记忆化搜索的方法,采用记忆化搜索的方法注意进行初始化,可以初始化为-1,而不能是0,因为f[i][j]==0时,无法确定当前状态的最大价值是0,还是没有搜索过。

#include 

using namespace std;

const int N=35;
int v,n;

int a[N];
int f[N][20010];

int dfs(int i,int j)
{
     
	if(f[i][j]>=0) return f[i][j];
	if(i==n) return 0;
	int res;
	if(j<a[i]) return dfs(i+1,j);
	else return max(dfs(i+1,j),dfs(i+1,j-a[i])+a[i]);
	
	return f[i][j]=res;
}


int main()
{
     
	cin>>v>>n;
	for(int i=0;i<n;i++)
		cin>>a[i];
	memset(f,-1,sizeof(f));
	cout<<v-dfs(0,v)<<endl;
	
	return 0;
}

还可以采用01背包状态转移那样的方法做,将体积当做权值,数组可以是二维的,也可以是一维的(滚动数组),这两种方法的核心代码如下

二维数组

 for(int i=1;i<=n;i++)
{
     
    for(int j=1;j<=v;j++)
    {
     
        if(j<a[i]) f[i][j]=f[i-1][j];
        else f[i][j]=max(f[i-1][j],f[i-1][j-a[i]]+a[i]);
    }
}

滚动数组

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

此题还有另外一种思路,用bool数组f[i][j]表示前i个物品能否放满体积为j的背包,记录最大的j,即为能放下的最大体积,再用容量减去最大体积,就可以得到空间的最小值。按照这个思路,仍有二维数组和滚动数组两种做法。

二维数组

#include 
using namespace std;
const int N=35;
int v,n;
int a[N];
bool f[N][20010];
int main()
{
     
	cin>>v>>n;
	for(int i=1;i<=n;i++)
		cin>>a[i];
	int ans=0;
    f[0][0]=1;
    for(int i=1;i<=n;i++)
    {
     
        for(int j=0;j<=v;j++)
        {
     
        	if(j<a[i]) f[i][j]=f[i-1][j];
            else f[i][j]=f[i-1][j]||f[i-1][j-a[i]];
            if(f[i][j]) ans=max(ans,j);
        }
    }
    cout<<v-ans<<endl;
	
	return 0;
}

滚动数组

#include 
using namespace std;
const int N=35;
int v,n;
int a[N];
bool f[20010];
int main()
{
     
	cin>>v>>n;
	for(int i=1;i<=n;i++)
		cin>>a[i];
	int ans=0;
    f[0]=1;
    for(int i=1;i<=n;i++)
    {
     
        for(int j=v;j>=a[i];j--)
        {
     
            if(!f[j]) f[j]=f[j-a[i]];
            if(f[j]) ans=max(ans,j);
        }
    }
    cout<<v-ans<<endl;
	
	return 0;
}


注意
f[0][0]或f[0]初值为1,体积为0的时候认为背包是放满的,这样设定保证j=a[i]时,f[i][j]可以赋值为1

滑雪

Description

Michael喜欢滑雪百这并不奇怪, 因为滑雪的确很刺激。可是为了获得速度,滑的区域必须向下倾斜,而且当你滑到坡底,你不得不再次走上坡或者等待升降机来载你。Michael想知道载一个区域中最长底滑坡。区域由一个二维数组给出。数组的每个数字代表点的高度。下面是一个例子

算法竞赛入门——动态规划_第1张图片
一个人可以从某个点滑向上下左右相邻四个点之一,当且仅当高度减小。在上面的例子中,一条可滑行的滑坡为24-17-16-1。当然25-24-23-…-3-2-1更长。事实上,这是最长的一条。

Input

输入的第一行表示区域的行数R和列数C(1 <= R,C <= 100)。下面是R行,每行有C个整数,代表高度h,0<=h<=10000。

Output

输出最长区域的长度。

Sample Input

5 5
1 2 3 4 5
16 17 18 19 6
15 24 25 20 7
14 23 22 21 8
13 12 11 10 9

Sample Output

25

分析
记忆化搜索,用f[i][j]表示从(i,j)滑下的最长路径长度,对于每个点(i,j)能滑的最长长度与周围四个点有关,f[i][j]的初始值为1,代表自身的长度为1,然后用(i,j)点的高度与周围四个点的高度比较,状态转移方程如下。

F [ i ] [ j ] = m a x { f [ i − 1 ] [ j ] + 1 if  ( a [ i − 1 ] [ j ] < a [ i ] [ j ] ) f [ i + 1 ] [ j ] + 1 if  ( a [ i + 1 ] [ j ] < a [ i ] [ j ] ) f [ i ] [ j − 1 ] + 1 if  ( a [ i ] [ j − 1 ] < a [ i ] [ j ] ) f [ i ] [ j + 1 ] + 1 if  ( a [ i ] [ j + 1 ] < a [ i ] [ j ] ) F[i][j] = max\begin{cases} f[i - 1][j] + 1 &\text{if } (a[i - 1][j] F[i][j]=maxf[i1][j]+1f[i+1][j]+1f[i][j1]+1f[i][j+1]+1if (a[i1][j]<a[i][j])if (a[i+1][j]<a[i][j])if (a[i][j1]<a[i][j])if (a[i][j+1]<a[i][j])

AC代码

#include 
#include 
using namespace std;

const int N=105;
int a[N][N];
int f[N][N];
int r,c;

int calc(int i,int j)
{
     
	if(f[i][j]!=0) return f[i][j];
	f[i][j]=1;
	if(a[i-1][j]<a[i][j]&&i-1>0) f[i][j]=max(f[i][j],calc(i-1,j)+1);
	if(a[i+1][j]<a[i][j]&&i+1<=r) f[i][j]=max(f[i][j],calc(i+1,j)+1);
	if(a[i][j-1]<a[i][j]&&j-1>0) f[i][j]=max(f[i][j],calc(i,j-1)+1);
	if(a[i][j+1]<a[i][j]&&j+1<=c) f[i][j]=max(f[i][j],calc(i,j+1)+1);
	return f[i][j];
}

int main()
{
     
	cin>>r>>c;
	for(int i=1;i<=r;i++)
	{
     
		for(int j=1;j<=c;j++)
		{
     
			cin>>a[i][j];
		}
	}
	int ans=0;
	for(int i=1;i<=r;i++)
	{
     
		for(int j=1;j<=c;j++)
			ans=max(ans,calc(i,j));
	}
	cout<<ans<<endl;
	
	return 0;
}

注意
如果(i,j)位于边界要注意判断四周的位置是否合法,而且最大值的位置不确定,要取所有位置上的最大值。此外本题没有采用递推的方式,即用两重for循环遍历,而是采用记忆化搜索,这是因为每个点的取值与周围四个点有关,而在01背包中每个点的取值只与上一层有关,因此此题适合用记忆化搜索。

总结

1.记忆化搜索和动态规划从根本上来讲就是一个东西,两者的核心思想均为:利用对于相同参数答案相同的特性,对于相同的参数(循环式的dp体现为数组下标),记录其答案,免去重复计算,从而起到优化时间复杂度的作用

2.做动态规划的一般步骤:

第一步,结合原问题和子问题确定状态
状态的参数一般有
1)描述位置的:前(后)i单位,第i到第j单位,坐标为(i,j),第i个之前(后)且必须
取第i个等
2)描述数量的:取i个,不超过i个,至少i个等
3)描述对后有影响的:状态压缩的,一些特殊的性质

第二步,确定转移方程
1)检查参数是否足够;
2)分情况:最后一次操作的方式,取不取,怎么样取——前一项是什么
3)初始边界是什么
4)注意无后效性。比如说,求A就要求B,求B就要求C,而求C就要求A,这就不符合无后效性了
根据状态枚举最后一次决策(即当前状态怎么来的)就可确定出状态转移方程!

第三步,考虑需不需优化

第四步,确定编程实现方式
1)递推
2)记忆化搜索

你可能感兴趣的:(算法,动态规划,算法)