动态规划入门


一、案例引入:数塔问题

在这里插入图片描述

1.DFS深搜求解

思路:dfs枚举所有从起点走到最后一层的路径,最终取和最大的路径

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 500 + 10;
int a[N][N];
int n;
int ans = -1e9;

//从起点走到最后一层的路径的和
void dfs(int x, int y, int sum)
{
    if(x > n)// 递归出口
    {
        ans = max(ans, sum);
        return ;
    }
    dfs(x + 1, y, sum + a[x + 1][y]);// 向下
    dfs(x + 1, y + 1, sum + a[x + 1][y + 1]);// 右下
}
int main()
{
    cin >> n;
    for(int i = 1; i <= n; i ++)
        for(int j = 1; j <= i; j ++)
            cin >> a[i][j];
            
            
    dfs(1, 1, a[1][1]);
    cout << ans;
    return 0;
}

缺点:

虽然是一种可行的方案,但是当数据庞大,递归的层数就越多,重重计算也越多,很容易就超时了。本题,n=30时就over了。

因此,dfs一般适用于数据量不是很庞大的情况!

2.动规:递推求解

如何优化上述dfs搜索遇到的大量重复计算问题呢?递推就是一个不错的选择,将计算过的数值用一个数组记录下来,避免重复计算了!(动态规划也是利用了这一思想,俗称存表)。

定义一个二维数组d[x][y],表示由(x,y)点到最后一层的值的最大和是多少。

从后往前推,即答案为d[1][1],表示由(1,1)点到最后一层的值的最大和。

  • 不难发现,最后一层的点到最后一层的最大距离即为自己对应的值a[n - 1][y],这个就是问题的边界。
  • 从后往前推,观察发现当前点的状态只与正下方和右下方的状态有关,因此得出递推式(状态转移方程):d[i][j] = a[i][j] + max(a[i+1][j],a[i + 1][j + 1])

【代码实现】

时间复杂度:O(n*n)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 500 + 10;
int a[N][N];
int d[N][N];
int n;
int ans = -1e9;


int main()
{
    cin >> n;
    for(int i = 1; i <= n; i ++)
        for(int j = 1; j <= i; j ++)
            cin >> a[i][j];
    
    //最后一层
    for(int j = 1; j <= n; j ++) d[n][j] = a[n][j];        
    
    // 从后往前推
    for(int i = n - 1; i >= 1; i --)
        for(int j = 1; j <= i; j ++)
        {
            d[i][j] = a[i][j] + max(d[i + 1][j], d[i + 1][j + 1]);
        }
    cout << d[1][1];
    return 0;
}

3.动规:递归求解

既然有了上述的递推式,我们直到递归和递推其实是相互的,因此递推可以改写成递归的形式。

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 500 + 10;
int a[N][N];
int d[N][N];
int n;
int ans = -1e9;

// 求从点(x,y)开始,走到最后一层,经过数字的最大和。
int fun(int x, int y)
{
    if(x == n) return a[x][y];// 最后一层的解就是自己
    return a[x][y] + max(fun(x + 1, y), fun(x + 1, y + 1));// 如果不是最后一行就递归计算
}

int main()
{
    cin >> n;
    for(int i = 1; i <= n; i ++)
        for(int j = 1; j <= i; j ++)
            cin >> a[i][j];

    cout << fun(1, 1);
    return 0;
}

缺点:

  • 效率太低了,并不是因为递归就效率低,而是因为存在了大量的重复计算。(类比递归式的斐波那契数列)

  • 递归存在着大量不必要的重复计算,,递归的层数就越多,算的就越多,重复的计算更多!

在这里插入图片描述

4.动规:记忆化搜索

上述方法3,存在着大量的重复计算,那我们如何在使用递归的情况下去优化,免去那些不必要的重复计算呢?

如上图,我们在求d[2][1]的时候就把d[3][2]计算过了,而我们再求d[2][2]的时候,又把d[3][2]再计算了一次,这就造成了重复计算?那如何解决呢?

在计算d[2][1]的时候就把d[3][2]计算过了,可以把d[3][2]用一个数组存下来,当再次需要d[3][2]时,就不需要再去递归求解了,直接用数组中备份过的数据就行——这就是记忆化搜索!

动规:记忆化搜索:

  • 求解每一个点的值,先判断该点的值是否曾经求解过,如果曾经求解过,直接拿过来使用;如果没求解过,递归求解,并存储该解!
  • 将计算过的值存储到一个数组中
  • 如何判断是否求解过呢?——做标记判断

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 500 + 10;
int a[N][N];
int d[N][N];
int n;
int ans = -1e9;

//动规,记忆化搜索:先将d数组初始化为-1,方便判断有没有求解过
int fun(int x, int y)
{
    if(x == n) return a[x][y];// 最后一层的解就是自己
    else
    {
        if(d[x][y] != -1) return d[x][y];// 曾经求解过
        else 
        {
            //求解(x,y)点走到底层经过的数字和的最大值,并存储
            d[x][y] = a[x][y] + max(fun(x + 1, y), fun(x + 1, y + 1));
            return d[x][y];
        }
    }
}

int main()
{
    cin >> n;
    for(int i = 1; i <= n; i ++)
        for(int j = 1; j <= i; j ++)
            cin >> a[i][j];
    
    memset(d, -1, sizeof d);
    cout << fun(1, 1);
    return 0;
}

总结

二、基本介绍

三、巩固练习

1.前缀最大值问题

题目描述

求一个数列的所有前缀最大值之和。
即:给出长度为n的数列a[i],求出对于所有1<=i<=n,max(a[1],a[2],…,a[i])的和。
比如,有数列:666 304 692 188 596,前缀最大值为:666 666 692 692 692,和为3408。
对于每个位置的前缀最大值解释如下:对于第1个数666,只有一个数,一定最大;对于第2个数,求出前两个数的最大数,还是666;对于第3个数,求出前3个数的最大数是692……其余位置依次类推,最后求前缀最大值得和。

由于读入较大,数列由随机种子生成。
其中a[1]=x,a[i]=(379*a[i-1]+131)%997。

输入

一行两个正整数n,x,分别表示数列的长度和随机种子。(n<=100000,x<997)

输出

一行一个正整数表示该数列的前缀最大值之和。

样例

输入复制

5 666

输出复制

3408

说明

数列为{666,304,692,188,596},前缀最大值为{666,666,692,692,692},和为3408。

思路:

在这里插入图片描述

解题步骤:

  1. 划分阶段:求前i个数的最大前缀值取决于前i-1个数的最大前缀值为多少
  2. 确定状态和状态变量:状态:前i项的最大前缀值是多少,dp[i]
  3. 确定决策和决策方程:dp[i] = max(dp[i - 1], a[i]);
  4. 寻找边界条件:dp[1] = a[1]

最后将dp[i]求和,即为答案!

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int a[N];
int dp[N];// dp[i]:表示包含i之前的最大值是多少 


int main()
{
	int n, x;
	cin >> n >> x;
	a[1] = x;
	for(int i = 2; i <= n; i ++)
	{
		a[i] = (379 * a[i - 1] + 131) % 997;
	}
	
	
	dp[1] = a[1];// 边界 
	int ans = dp[1];
	for(int i = 2; i <= n; i ++)
	{
		dp[i] = max(a[i], dp[i - 1]);
		ans += dp[i];
	} 
	
	cout << ans;		
    return 0;
}

2.取数问题

题目描述

设有N 个正整数(1 <= N <= 50),其中每一个均是大于等于1、小于等于300的数。
从这N个数中任取出若干个数(不能取相邻的数),要求得到一种取法,使得到的和为最大。
例如:当N=5时,有5个数分别为:13,18,28,45,21
此时,有许多种取法,如: 13,28,21 和为62
13, 45 和为58
18,45 和为63
……….

和为63应该是满足要求的一种取法

输入

第一行是一个整数N
第二行有N个符合条件的整数。

输出

一个整数,即最大和

样例

输入复制

5
13 18 28 45 21

输出复制

63

题意:求前n个数,在不连续的情况下求最大和是多少。

在这里插入图片描述

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int a[N];
int dp[N];// dp[i]:表示第i个数选不选的情况下,最大不连续数的和 

int n;

int main()
{
	
	cin >> n;
	for(int i = 1; i <= n; i ++) cin >> a[i];
	
	dp[1] = a[1];
	dp[2] = max(a[1], a[2]);
	
	
	for(int i = 3; i <= n; i ++)
	{
		dp[i] = max(dp[i - 1], a[i] + dp[i - 2]);
	}
	
	cout << dp[n];		
    return 0;
}

3.最大子段和(连续部分和)

在这里插入图片描述

【做法一】

暴力做法:枚举每一个区间,求出最大区间和。

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 110;
int a[N];
int sum , ans = -1e9;

int main()
{
	int n;
	cin >> n;
	for(int i = 1; i <= n; i ++) cin >> a[i];
	
	//从每个数开始连续数的和 
	for(int i = 1; i <= n; i ++)
	{
		sum = 0;
		for(int j = i; j <= n; j ++)
		{
			sum += a[j];
			ans = max(ans, sum);
		}
		
	}
		
	cout << ans;		
    return 0;
}

缺点:时间复杂度较高,容易超时

【解法二】动态规划法

dp[N]:存储状态,表示包括下标i之前的最大连续子序列和为dp[i]

  • 1
  • 2
  • 3
  • 4

在这里插入图片描述

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 110;
int a[N];
int dp[N];// dp[i]:表示包含i之前的最大连续子段和 
int  ans = -1e9;

int main()
{
	int n;
	cin >> n;
	for(int i = 1; i <= n; i ++) cin >> a[i];

	dp[1] = a[1];// 边界 
	ans = a[1]; 
	for(int i = 2; i <= n; i ++)
	{
		dp[i] = max(a[i], dp[i - 1] + a[i]);
		ans = max(ans, dp[i]); 
	}
		
	cout << ans;		
    return 0;
}

4.最长上升子序列问题

(LIS)最长上升子序列 I

给定一个长度为 N 的数列,求数值严格单调递增的子序列的长度最长是多少。

输入格式

第一行包含整数 N。

第二行包含 N 个整数,表示完整序列。

输出格式

输出一个整数,表示最大长度。

数据范围

1≤N≤1000,
−109≤数列中的数≤109

输入样例:
7
3 1 2 1 8 5 6
输出样例:

最长递增序列为:1 2 5 6

4

这题与上一题的明显区别就是,数可以跳着取,且保证序列是严格递增的!

在这里插入图片描述

【代码实现】

时间复杂度:O(n * n)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int a[N];
int dp[N];// dp[i]:表示包含i之前的最长递增子序列的长度 


int main()
{
	int n;
	cin >> n;
	for(int i = 1; i <= n; i ++)
	{
		cin >> a[i];
		dp[i] = 1;// 初始化	
	} 

	int ans = -1e9;
	for(int i = 1; i <= n; i ++)
		for(int j = 1; j <= i - 1; j ++)// 遍历1~i-1的数,找到能接的(递增)且长度最大的序列
		{
			if(a[i] > a[j])
			{
				dp[i] = max(dp[j] + 1, dp[i]);
				ans = max(ans, dp[i]);
			}
		}
	
		
	cout << ans;		
    return 0;
}

(LIS)最长递增子序列 II

上述第一种LIS解法存在那些不足呢?时间复杂度为O(n*n),当数据量一大就爆炸了!为了解决这一烦恼,我们可以贪心加二分的思想来进行优化!

题目描述

给定一个长度为N的数列,求数值严格单调递增的子序列的长度最长是多少。

输入

第一行包含整数N。
第二行包含N个整数,表示完整序列。
1≤N≤100000,−109≤数列中的数≤109

输出

输出一个整数,表示最大长度。

样例

输入复制

6
1 3 2 8 5 6

输出复制

4

思路:

在这里插入图片描述

【代码实现】

时间复杂度:O(nlongn)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int dp[N];
int a[N];
int n, cnt;

//二分:找到dp数组中第一个大于等于a[i]的数
int find(int x)
{
    int l = 1, r = cnt;
    while(l < r)
    {
        int mid = (l + r) / 2;
        if(dp[mid] >= x) r = mid;
        else l = mid + 1;
    }
    return l;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i ++) cin >> a[i];
	
	dp[++ cnt] = a[1];// 边界:dp[1] = a[1]
	for(int i = 2; i <= n; i ++)
	{
	    // 如果a[i]大于dp数组的最后一位,LIS长度增加,续到最后一位
		if(a[i] > dp[cnt]) dp[++ cnt] = a[i];
		else // 在dp数组中二分找到第一个大于等于a[i]的数,进行替换 
		{
            int pos = find(a[i]);
			dp[pos] = a[i]; 
		 } 
	}
	
	cout << cnt;

    return 0; 
}

LIS练习:合唱队形

题目描述

N位同学站成一排,音乐老师要请其中的(N-K)位同学出列,使得剩下的K位同学不交换位置就能排成合唱队形。
合唱队形是指这样的一种队形:设K位同学从左到右依次编号为1, 2, …, K,他们的身高分别为T1, T2, …, TK,则他们的身高满足T1 < T2 < … < Ti , Ti > Ti+1 > … > TK (1 <= i <= K)。
你的任务是,已知所有N位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。

输入

输入的第一行是一个整数N(2 <= N <= 100),表示同学的总数。
第一行有n个整数,用空格分隔,第i个整数Ti(130 <= Ti <= 230)是第i位同学的身高(厘米)。

输出

输出包括一行,这一行只包含一个整数,就是最少需要几位同学出列。

样例

输入复制

8
186 186 150 200 160 130 197 220

输出复制

4

思路:求最长上升子序列的扩展应用题,以每一个数为中心,求取它各个数的左右两边的最长递增子序列是多少,剩下的就是要筛掉的最少人数。

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int a[N];
int dpa[N], dpb[N];//dpa[i] : 存储包含i的左边的各个数的最长递增子序列;dpb[i]:右边 
bool st[N];
int n;
int ans = -1e9;

int main()
{
	
	cin >> n;
	for(int i = 1; i <= n; i ++) 
	{
		cin >> a[i];
		dpa[i] = dpb[i] = 1;
	}
	// 以每一个数为中心,求出它左后两边的最长递增子序列长度
	// 最多留下的人数:dpa[i] + dpb[i] - 1
	// 剔除最少人数 n - max 
	for(int i = 1; i <= n; i ++)
	{
		for(int j = 1; j < i; j ++)
		{
			if(a[i] > a[j]) dpa[i] = max(dpa[i], dpa[j] + 1);
		}
	}
	
	for(int i = n; i >= 1; i --)
	{
		for(int j = n; j > i; j --)
		{
			if(a[i] > a[j]) dpb[i] = max(dpb[i], dpb[j] + 1);
		}
	}
	
	//求解最多留下来的人数
	for(int i = 1; i <= n; i ++)
		ans = max(ans, dpa[i] + dpb[i] - 1);
	cout << n - ans; // 需要剔除的最少人数	 
		
    return 0;
}

LIS练习:导弹拦截

题目描述

某国为了防御敌国的导弹袭击,发展出一种导弹拦截系统。但是这种导弹拦截系统有一个缺陷:虽然它的第一发炮弹能够到达任意的高度,但是以后每一发炮弹都不能高于前一发的高度。某天,雷达捕捉到敌国的导弹来袭。由于该系统还在试用阶段,所以只有一套系统,因此有可能不能拦截所有的导弹。
输入导弹的枚数和导弹依次飞来的高度(雷达给出的高度数据是不大于30000的正整数,每个数据之间至少有一个空格),计算这套系统最多能拦截多少导弹。

输入

第1行有1个整数n,代表导弹的数量。(n<=1000)
第2行有n个整数,代表导弹的高度。(雷达给出的高度数据是不大于30000的正整数)

输出

输出这套系统最多能拦截多少导弹。

样例

输入复制

8
389  207  155  300  299  170  158  65

输出复制

6

思路:要拦截的导弹越多,前面拦截的导弹要高,从左到右求取最长递增子序列即为答案!

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int dp[N];
int a[N], b[N], c[N];
int n, cnt;
int ans = -1e9;

int find(int x)
{
	int l = 1, r = cnt;
	while(l < r)
	{
		int mid = (l + r) / 2;
		if(dp[mid] >= x) r = mid;
		else l = mid + 1;
	}
	return l;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i ++) cin >> a[i];

	//从右往左求最长递增子序列
	dp[++ cnt] = a[n]; 
	for(int i = n - 1; i >= 1; i --)
	{
		if(a[i] > dp[cnt]) dp[++ cnt] = a[i];
		else
		{
			int pos = find(a[i]);
			dp[pos] = a[i];
		}
	}
	cout << cnt;	
    return 0; 
}

LIS练习:最少的修改次数

题目描述

现有整数 A1,A2,…An,修改最少的数字为实数(整数或者小数),使得数列严格单调递增。

输入

第一行,一个整数n。(n≤10^5)
第二行,n个整数Ai。(Ai≤10^9)

输出

1个整数,表示最少修改的数字的数量。

样例

输入复制

3
1 3 2

输出复制

1

思路:

由于修改可以变为整数或者小数,所以求出最长LIS,剩下数的个数即为要修的最少个数!

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int dp[N];
int a[N], b[N], c[N];
int n, cnt;
int ans = -1e9;

int find(int x)
{
	int l = 1, r = cnt;
	while(l < r)
	{
		int mid = (l + r) / 2;
		if(dp[mid] >= x) r = mid;
		else l = mid + 1;
	}
	return l;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i ++) cin >> a[i];

	//从左往右最长递增子序列
	dp[++ cnt] = a[1]; 
	for(int i = 2; i <= n; i ++)
	{
		if(a[i] > dp[cnt]) dp[++ cnt] = a[i];
		else
		{
			int pos = find(a[i]);
			dp[pos] = a[i];
		}
	}
	cout << n - cnt; 	
    return 0; 
}

5.最长公共子序列

(LCS)最长公共子序列 I

题目描述

给出1-n的两个排列P1和P2,求它们的最长公共子序列。

输入

第一行是一个数n;(n是5~1000之间的整数)
接下来两行,每行为n个数,为自然数1-n的一个排列(1-n的排列每行的数据都是1-n之间的数,但顺序可能不同,比如1-5的排列可以是:1 2 3 4 5,也可以是2 5 4 3 1)。

输出

一个整数,即最长公共子序列的长度。

样例

输入复制

5 
3 2 1 4 5
1 2 3 4 5

输出复制

3

思路:

在这里插入图片描述

【代码实现】

时间复杂度:O(n*n)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1010;
char a[N], b[N];
int dp[N][N];
int n, m;

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> a[i];
    for (int i = 1; i <= m; i ++ ) cin >> b[i];
    
    for (int i = 1; i <= n; i ++ )
        for (int j = 1; j <= m; j ++ )
        {
			if(a[i] == b[j]) dp[i][j] = dp[i - 1][j - 1] + 1;// a数组和b数组同时扔掉相同的那个数
			else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);// a数组扔掉一个数,b数组扔掉一个数,那种情况更大
        }
    cout << dp[n][m];
    return 0;
}

(LCS)最长公共子序列 II

给出1-n的两个排列P1和P2,求它们的最长公共子序列。
和最长公共子序列(LCS)(1)问题不同的是,本题的n在5-100000之间。

输入

第一行是一个数n;(n是5-100000之间的整数)
接下来两行,每行为n个数,为自然数1-n的一个排列(1-n的排列每行的数据都是1-n之间的数,但顺序可能不同,比如1-5的排列可以是:1 2 3 4 5,也可以是2 5 4 3 1)。

输出

一个整数,即最长公共子序列的长度。

样例

输入复制

5 
3 2 1 4 5
1 2 3 4 5

输出复制

3

说明

对于50%的数据,n≤1000
对于100%的数据,n≤100000

版本一求最长LCS的时间复杂度为O(n*n),当数据量庞大就为超时,而且二维空间也有限。那我们如何改进呢?

这种算法主要是将最长公共子序列转为最长上升子序列,然后用最长上升子序列O(nlogn)算法来做.

在这里插入图片描述

【代码实现】

时间复杂度:O(nlogn)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int dp[N];
int a[N], b[N], c[N];
int n, cnt;
int ans = -1e9;

int find(int x)
{
	int l = 1, r = cnt;
	while(l < r)
	{
		int mid = (l + r) / 2;
		if(dp[mid] >= x) r = mid;
		else l = mid + 1;
	}
	return l;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i ++)
	{
		cin >> a[i];
		c[a[i]] = i;
	} 
	for(int i = 1; i <= n; i ++) cin >> b[i];
	
	//求b数组的每一个数在a数组的位置(c[b[i]])的LIS 
	dp[++ cnt] = c[b[1]];
	for(int i = 2; i <= n; i ++)
		if(c[b[i]] > dp[cnt]) dp[++ cnt] = c[b[i]];
		else
		{
			int pos = find(c[b[i]]);
			dp[pos] = c[b[i]];
		}
	cout << cnt;	
    return 0; 
}

6.背包问题

01背包

01背包问题:每种物品只有一件,要么取要么不取

(1)状态dp[i][j]定义:前i个物品,背包容量j下的最优解(最大价值):

  • 当前的状态依赖于之前的状态,可以理解为从初始状态dp[0][0] = 0开始决策,有 N 件物品,则需要 N 次决 策,每一次对第 i 件物品的决策,状态dp[i][j]不断由之前的状态更新而来。

(2)当前背包容量不够(j < v[i]),没得选,因此前 i 个物品最优解即为前 i−1 个物品最优解:

  • 对应代码:dp[i][j] = dp[i - 1][j]。

(3)当前背包容量够,可以选,因此需要决策选与不选第 i 个物品:

  • 选:dp[i][j] = dp[i - 1][j - v[i]] + w[i]
  • 不选:dp[i][j] = dp[i - 1][j]
  • 我们的决策是如何取到最大价值,因此以上两种情况取max()

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1010;
int n, c;
int w[N], v[N];
int dp[N][N]; // dp[i][j]数组含义:前i件物品装入容量为j的背包的最大价值是多少

int main()
{
    cin >> n >> c;
    for (int i = 1; i <= n; i ++ ) cin >> w[i] >> v[i];

    for (int i = 1; i <= n; i ++ ){
        for (int j = 1; j <= c; j ++ ){
            // 如果j < w[i](当前背包的容量不足以装下物品i):不放入,则最大价值为dp[i - 1][j]
            if(j < w[i]) dp[i][j] = dp[i - 1][j];
            else{// 如果j > w[i]背包容量足够装下w[i],存在放和不放两种情况,取这两种情况的最大价值
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
            }
        }
    }    

    // 输出最大价值
    cout << dp[n][c];

    return 0;
}

01背包优化:一维滚动数组优化

将状态dp[i][j]优化到一维dp[j],实际上只需要做一个等价变形。

为什么可以这样变形呢?我们定义的状态dp[i][j]可以求得任意合法的ij最优解,但题目只需要求得最终状态dp[n][m],因此我们只需要一维的空间来更新状态。

(1)状态dp[j]定义:N 件物品,背包容量j下的最优解。

(2)注意枚举背包容量j必须从逆序开始

(3)为什么一维情况下枚举背包容量需要逆序?在二维情况下,状态dp[i][j]是由上一轮i - 1的状态得来的,dp[i][j]f[i - 1][j]是独立的。而优化到一维后,如果我们还是正序,则有dp[较小体积]更新到dp[较大体积],则有可能本应该用第i-1轮的状态却用的是第i轮的状态。

(4)简单来说,一维情况正序更新状态f[j]需要用到前面计算的状态已经被「污染」,逆序则不会有这样的问题。

状态转移方程为:dp[j] = max(dp[j], dp[j - v[i]] + w[i]

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int n, c;
int w[N], v[N];
int dp[N]; // dp[j]数组含义:容量为j的背包能存放物品的最大价值

//二维:
//dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]); 都只与上一行(i-1)的物品有关,因此这一项可优化省略

int main()
{
    cin >> n >> c;
    for (int i = 1; i <= n; i ++ ) cin >> w[i] >> v[i];
   

    for (int i = 1; i <= n; i ++ )
        for (int j = c; j >= w[i]; j -- )// 倒着过来循环!
		{
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
       

    // 输出最大价值
    cout << dp[c];

    return 0;
}

完全背包问题

完全背包问题:每种物品可以取无限次,在背包容量一定的情况下求取最大价值。

(1)01背包:

  • 二维:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])`
  • 一维:**dp[j] = max(dp[j], d[j - w[i]] + v[i])滚动优化:从背包容量c循环降序**到当前物品重量w[i]

(2)完全背包:

  • 三维:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - k * w[i]] + k * v[i]),三维非常容易就炸了

  • 二维:由上述三维变形得:dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i]] + v[i]),(注意与01的二分及其相似但又存在区别)

  • 一维:**dp[j] = max(dp[j], dp[j - w[i]] + v[i])滚动优化:从当前物品重量w[i]循环正序**升到背包容量c

原理可以不会证明,但状态转移一定要记熟!

我们列举一下更新次序的内部关系(三维到二维):

f[i , j ] = max( f[i-1,j] , f[i-1,j-v]+w , f[i-1,j-2v]+2w , f[i-1,j-3v]+3w , …)
f[i , j-v]= max( f[i-1,j-v] , f[i-1,j-2v] + w , f[i-1,j-3v]+2*w , …),我们发现少了一个w
由上两式,可得出如下递推关系:
f[i][j]=max(f[i-1][j], f[i,j-v]+w)

二维版本代码如下:

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

一维优化代码如下:

    for (int i = 1; i <= n; i ++ )
        for (int j = w[i]; j <= m; j ++ )// 正序循环 
		{
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }

题目描述

仙岛上种了无数的不同种类的灵芝,小芳跟着爷爷来到仙岛采摘灵芝。由于他们带的食物和饮用水有限,必须在时间t内完成采摘。
假设岛上有m种不同种类的灵芝,每种灵芝都有无限多个,已知每种灵芝采摘需要的时间,以及这种灵芝的价值;
请你编程帮助小芳计算,在有限的时间t内,能够采摘到的灵芝的最大价值是多少?

输入

输入第一行有两个整数T(1 <= T <= 100000)和M(1 <= M <= 2000),用一个空格隔开,T代表总共能够用来采灵芝的时间,M代表岛上灵芝的种类数。接下来的M行每行包括两个在1到10000之间(包括1和10000)的整数,分别表示采摘某种灵芝的时间和这种灵芝的价值。

输出

输出一行,这一行只包含一个整数,表示在规定的时间内,可以采到的灵芝的最大总价值。

样例

输入复制

70 3
71 100
69 1
1 2

输出复制

140

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int n, c;
int w[N], v[N];
int dp[N]; 

int main()
{
    cin >> c >> n;
    for (int i = 1; i <= n; i ++ ) cin >> w[i] >> v[i];
  

    for (int i = 1; i <= n; i ++ )
        for (int j = w[i]; j <= c; j ++ )// 正序循环 
		{
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
       

    // 输出最大价值
    cout << dp[c];

    return 0;
}

多重背包问题

多重背包问题:每种物品有si件(有限次数)

思路:将多重背包转化为01背包

将si件物品都存起来,转换为有si个物品,每个物品有一件

题目描述

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

输入

第一行两个整数,N,V,用空格隔开,分别表示物品种数和背包容积。

接下来有 N 行,每行三个整数 vi,wi,si,用空格隔开,分别表示第 i 种物品的体积、价值和数量。

0<N,V≤100
0<vi,wi,si≤100

输出

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

样例

输入复制

4 10
3 2 2
4 3 2
2 2 1
5 3 4

输出复制

8

【解法一】将多重背包的si件物品,装入w和v数组中,直接转换为01背包!(第一种比较好理解!)

时间复杂度:O(n * v * s)~O(n^3)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int dp[N], v[N], w[N];

int vi, wi, si;
int n, c;
int k; // k代表数组下标 

int main()
{
    cin >> n >> c;
    for (int i = 1; i <= n; i ++ ) 
    {
    	cin >> vi >> wi >> si;
    	//将第i个物品有si件,都存入数组中
		for(int j = 1; j <= si; j ++)
		{
			k ++;
			v[k] = vi;
			w[k] = wi;
		} 
	}
	
	//01背包
	for(int i = 1; i <= k; i ++)
		for(int j = c; j >= v[i]; j --)	// 逆序
			dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
  
    // 输出最大价值
    cout << dp[c];

    return 0;
}

【解法二】与解法一大同小异,换一种形式。在做01背包时,体现一下每一件物品有si件。

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int v[N], w[N], s[N];
int dp[N]; 

int n, c;


int main()
{
    cin >> n >> c;
    //有n件物品 
    for (int i = 1; i <= n; i ++ ) 
    {
		cin >> v[i] >> w[i] >> s[i];
		
		//第i件物品有si件,01背包 
		for(int k = 1; k <= s[i]; k ++)
			for(int j = c; j >= v[i]; j --)	
				dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
		
	}
	
    // 输出最大价值
    cout << dp[c];

    return 0;
}

多重背包优化:二进制优化(状态压缩)

第一种方式直接将si件物品暴力放入数组中,当数据量比较大时就爆炸了,那我们如何有效转换到01背包问题呢?

在这里插入图片描述

题目描述

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

输入

第一行两个整数,N,V,用空格隔开,分别表示物品种数和背包容积。

接下来有 N 行,每行三个整数 vi,wi,si,用空格隔开,分别表示第 i 种物品的体积、价值和数量。

输出

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

0<N≤1000

0<V≤2000
0<vi,wi,si≤2000

样例

输入复制

4 5
1 2 3
2 4 1
3 4 3
4 5 2

输出复制

10

【代码实现】

时间复杂度:O(n*v*log(s)) ~ O(nlongn)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int v[N], w[N], s[N];
int dp[N]; 
int n, c;
int vi, wi, si;
int cnt;

int main()
{
    cin >> n >> c;
    //有n件物品(枚举第i件物品选多少个) 
    for (int i = 1; i <= n; i ++ ) 
    {
		cin >> vi >> wi >> si;
		
		// 对si进行二进制化 比如有10件一样的物品 
		// 我们转换为4组不同物品 1 2 4 3
		// 这4组物品对应的体积分别为:1*v1 2*v2 4*v3 3*v4 
		
		int t = 1; // 进位 
		while(t <= si)
		{
			cnt ++;
			v[cnt] = t * vi;
			w[cnt] = t * wi;
			si -= t;
			t *= 2;
		}
		if(si > 0)// 如果si还有剩余,加上最后一位
		{
			cnt ++;
			v[cnt] = si * vi;
			w[cnt] = si * wi; 
		} 		
	}
	// 01背包 
	for(int i = 1; i <= cnt; i ++)
		for(int j = c; j >= v[i]; j --)	
			dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
	
    // 输出最大价值
    cout << dp[c];

    return 0;
}

分组背包问题

分组背包问题:有n组物品,每组物品里边有若干个物品,同样一组物品里边最多选择一个物品!

在这里插入图片描述

分析:

在这里插入图片描述

【代码实现】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 110;
int v[N][N], w[N][N], s[N];
int dp[N];
int n, m;

int main()
{
    cin >> n >> m;
    for(int i = 1; i <= n; i ++)
    {
        cin >> s[i];
        for(int j = 0; j < s[i]; j ++) 
            cin >> v[i][j] >> w[i][j];
    }
    
    //类似01背包的处理
    for(int i = 1; i <= n; i ++)
        for(int j = m; j >= 0; j --)// 逆序
            for(int k = 0; k < s[i]; k ++)
                if(v[i][k] <= j)
                dp[j] = max(dp[j], dp[j - v[i][k]] + w[i][k]);
    
    cout << dp[m];            
            
        
    return 0;
}

四、总结

上述这些都是经典的dp问题,要理解并熟记状态转移方程,并能快速码出代码。多练、多总结,见得多了自然就熟了,QAQ~~

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值