【DP知识点小结】

动态规划系列

背包问题

背包问题是个非常经典的问题,但是其变种非常之多,也非常灵活。

这里结合算法进阶的知识点和ACwing的题目,一起学习和练习相关知识点。

1. 0-1背包问题

问题描述:
给定N个物品,其中第i个物品的体积为Vi,价值为Wi。有一容积为M的背包,要求选择一些物品放入背包中,使得物品总体及不超过M的情况下,物品的价值总和最大。

因为在该题目描述中,不能部分选取一个物品,要么选择一个物品,要么不能选择该物品,所以这样的一种模型也叫做0-1背包问题(0-1蕴含每件物品最多只用一次)。
完全背包问题:每件物品可以选取有无限个;
多重背包问题:每件物品是有限个数;
分组背包问题:有N组,每组只能选择一个;
//动态规划(线性DP)
DP问题主要由两点:
1.状态表示;  表示什么样的集合(集合角度考察),属性(最小,最大,数量等等)
2.状态计算(状态迁移);
3.DP优化,主要是等价形式变换; 

集合的选法:怎么确定选取条件;
在本题中,f(i, j) = 只从前i个物品中选取,且总体积小于等于j, 总价值的最大值;
那么根据条件定义,所求解应该是f(N, V):从N个物品中选取,且总体积小于等于V,获得的最大价值;

状态计算:集合划分

f(i, j) = f(不包含i的集合) + f(包含i的集合);  //不漏, 重复(有可能不需要)
f(不包含i的集合) :应该是f(i - 1, j), 表示在前i-1个物品中,选取体积<= j的最大价值;
f(包含i的集合):f(i, j), 但因为在此时未知,但是我们可以用之前的已知状态已知来计算。
因为在计算f(i, j), 那么在(i, j)状态之前的状态值都是已知的,因为选取了第i个物品,那么对于f(i - 1, j - Vi)是已知的,
所以f(i, j) = f(i -1, j - Vi) + Wi;
在两者之间取最大值,就是当前状态的计算方式。

note:DP问题也需要确定初始值,也就是初始状态;
DP问题其实可以看成是有限状态机,但是是无后效性的(也就是不能形成环状);
//朴素DP算法
f[0][0] = 0; //初始化起始状态;
for (int i = 1; i <= n ; i++)
	for (int j = 1; j <= m; j++) {
		f[i][j] = f[i - 1][j]; // 初始化为不带i的情况;
		if (j >= v[i]) {
			f[i][j] = max(f[i][j], f[i-1][j - v[i]] + w[i]);
		}
	}
	cout << f[n][m] << endl; //输出最终结果;

//优化版本的DP算法
因为f(i, x)只用到f(i - 1, xx),所以可以变换为下面:
f[0] = 0; //初始化起始状态;
for (int i = 1; i <= n ; i++)
	for (int j = 1; j <= m; j++) {
		//f[j] = f[j]; // 删掉i的这一阶; 删掉之后变为恒等式,进而可以省略;
		if (j >= v[i]) {
			f[j] = max(f[j], f[j - v[i]] + w[i]);   ....(1)
		}
	}
	cout << f[m] << endl; //输出最终结果;
	
//又因为(1)式只有在j >= v[i]的情况下才会成立,当循环从v[i]开始遍历时,就不需要判断条件了;
进化如下:
f[0] = 0; //初始化起始状态;
for (int i = 1; i <= n ; i++)
	for (int j = v[i]; j <= m; j++) {
		//f[j] = f[j]; // 删掉i的这一阶; 删掉之后变为恒等式,进而可以省略;
		//if (j >= v[i]) {
			f[j] = max(f[j], f[j - v[i]] + w[i]);   ....(1)
		//}
	}
cout << f[m] << endl; //输出最终结果;

需要注意(1)中式子含义
f[j] = max(f[j], f[j - v[i]] + w[i]); 
在此时,等价于f[i][j] = max(f[i][j], f[i][j - v[i]] + w[i]), 这里的[j - v[i]]是第i层的状态,之前分析的状态含义应该是第i-1层的才对
所以这么做就是错误的解法

自习分析一下为啥状态出现错误
可以尝试枚举模拟一下计算,
在i-1次外循环执行完毕后,那么f[j]中保存的都是第i-1次时计算的状态,
那么在第i次外循环时,
当j = v[i],那么max(f[j],f[j - v[i]] + w[i] = f[0] = w[i]) = f[j] = f[v[i]],此时更新为第i次的状态值
当j = v[i] + 1, f[j] = max(f[j], f[j - v[i]] + w[i]) ,也更新为第i次的状态值;
...
当j = 2v[i], 那么f[j] = max(f[j], f[j - v[i]] + w[i]) = max(f[j], f[v[i]] + w[i])
而f[v[i]]在前面已经更新为第i次的状态了,所以这个状态转移方程就计算错误了

可以将j从m开始往下遍历,
f[0] = 0; //初始化起始状态;
for (int i = 1; i <= n ; i++)
	for (int j = m; j >= v[i]; j--) {
		f[j] = max(f[j], f[j - v[i]] + w[i]);   ....(2)
	}
cout << f[m] << endl; //输出最终结果

这么做的含义在哪里,因为如果j从m开始遍历时,对应的f[j  - v[i]],不妨可以尝试枚举几个j的值试试
当j = m, f[j - v[i]] = f[m - v[i]] 还是第i-1次时保存的状态,那么就可以得到f[m] = max(f[m], f[m - v[i]] + w[i]); 
此时计算完后,f[m]的状态是第i次的,f[1 ~ m-1]还是第i-1次的,
那么同理,可由j = m-1, f[j - v[i]] = f[m - 1 - v[i]] != f[m], 所以其值还是第i-1的值,
所以f[m-1] = max(f[m-1], f[m - 1 - v[i]] + w[i]); 计算过后更新f[m-1]为第i次的状态;
...
j = 2v[i], f[j] = max(f[j], f[j - v[i]] + w[i]) = max(f[j], f[v[i]] + w[i]); 因为前面更新第i次状态时都没有更新到f[v[i]],所以这里
f[v[i]]依旧是第i-1次循环的状态,所以这里f[j] = f[2v[i]]更新为第i次的状态值;
...
j = v[i], f[j] = max(f[j], f[j - v[i]] + w[i]) = max(f[j], f[0] + w[i]);中依旧取得是第i-1次循环状态的值,并更新f[v[i]]的状态值;
从而可以得到其没有破坏状态转移方程的值
最终优化的结果如下:
f[0] = 0; //初始化起始状态;
for (int i = 1; i <= n ; i++)
	for (int j = m; j >= v[i]; j--) {
			f[j] = max(f[j], f[j - v[i]] + w[i]);   ....(1)
	}
cout << f[m] << endl; //输出最终结果;
[Acwing02]:
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。

第 i 件物品的体积是 vi,价值是 wi。

求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。

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

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

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

数据范围
0<N,V≤1000
0<vi,wi≤1000
输入样例
4 5
1 2
2 4
3 4
4 5
输出样例:
8
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

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

int f[N];

int main()
{
    cin >> n >> V;
    
    for (int i = 1; i <= n; i++) {
        cin >> v[i] >> w[i];
    }
    
    //初始化状态
    f[0] = 0; //全局变量一般默认为零初始化;
    for (int i = 1; i <= n; i ++ ) {
        for (int j = V; j >= v[i]; j--)
            f[j] = max(f[j], f[j - v[i]] + w[i]);
    }
    
    cout << f[V] << endl;
    
    return 0;
}

这里还有一个有意思的知识点,介于朴素DP和一维优化DP之间,就是滚动数组的优化方式,降低空间开销。

//滚动数组优化方式
const int MAX_N = 100010;
int f[2][MAX_N];

int main()
{
	//初始化初始状态;
	memset(f, 0xcf, sizeof f);
	//设置起始状态;
	f[0][0] = 0;
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) 
		{
			f[ i & 1][j] = f[(i - 1) & 1][j];
		}
		for (int j = v[i]; j <= m; j++) 
		{
			f[i & 1][j] = max(f[i & 1][j], f[(i-1) & 1][j - v[i]] + w[i]);
		}
	}
	
	int ans = 0;
	for (int j = 0; j <= m; j++) {
		ans = max(ans, f[n & 1][j]);
	}	
}
//可以这么做的原因在于,把第i次循环的状态存储在第一维下标为i&1的二维数组中,当i为技术, i&1 = 1, 当i为偶数, i&1 = 0.
//因此,两个状态在f[0][]和f[1][]两个数组中交替使用,空间复杂度使用为O(m);

2.完全背包问题

问题描述:

给定N种物品,其中第i种物品的体积为Vi,价值为Wi,并且有无数个。有一个容积为M的背包,要求选择若干个物品放入背包中,使得物品总体积不超过M的前提下,物品的价值总和最大。

分析:
这个模型描述和0-1背包问题类似,但是其物品可以任意选择;
那么怎么确定状态选择呢?
这个因为和0-1背包问题类似,不妨可以选择状态为:前i个物品,总体积不大于j的所有选法;
属性:求Max;

状态转移方程:
0-1背包问题,状态划分为选i和不选i两种情况,
我们在完全背包也可以这么来做,
分别表示对第i个物品选0个, 选1个, 选2个,..., 
那么上限是多少呢?不妨假设上限为k;
那么f[i][j] = max(f[ i - 1][j], f[i - 1][j - k*v[i]] + k*w[i]);
这里f[i - 1][j]表示对物品i选择0个, 后面的f[i - 1][j - k*v[i]] + k*w[i],表示选择物品i共k个;
所以: j - k* v[i] >= 0 => k <= j/v[i];

那么对于朴素DP的代码为:
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;
const  int N = 1010;
int n, m;
int v[N], w[N];
int f[N][N];

int main()
{
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		cin >> v[i] >> w[i];
	}
	f[0][0] = 0;
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			for (int k = 0; k * v[i] <= j; k++) {
				f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i]);
			}
		}
	}
	
	cout << f[n][m] << endl;
	return 0;
}

//优化
因为
f[i][j] = Max(f[i-1][j], f[i-1][j - v[i]] + w, f[i-1][j - 2v[i]] + 2w, ...,);   ... (1)
f[i][j - v[i]] = Max(f[i-1][j - v[i]], f[i-1][j - 2v[i]] + 2*w, ...)            ... (2)
可以发现(1)和(2)后面部分相似;

所以f[i][j] = max(f[i - 1][j], f[i][j - v[i]] + w); //注意这里,转移的是第i次的循环状态转移的, 0-1背包转移的是i-1转移的;
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;
const  int N = 1010;
int n, m;
int v[N], w[N];
int f[N][N];

int main()
{
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		cin >> v[i] >> w[i];
	}
	f[0][0] = 0;
	for (int i = 1; i <= n; i++) {
		for (int j = 0; 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]);
		}
	}
	
	cout << f[n][m] << endl;
	return 0;
}

//继续优化
//可仿照0-1背包变为一维;
for (int i = 1; i <= n; i++) {
	for (int j = v[i]; j <= m; j++) {  //注意顺序,这里因为要求的是第i次循环状态,所以需要从小到大的遍历;参考之前0-1背包的枚举推断;
		f[j] = max(f[j], f[j - v[i]] + w[i]);  
	}
}

3.多重背包问题

问题描述:

给定N种物品,其中第i种物品的体积为Vi,价值为Wi,并且有Ci个。有容积为M的背包,要求选择若干物品放入背包,使得物品总体积不超过M的前提下,物品的价值总和最大。

[分析]:
按照之前的0-1背包问题,定义问题状态:
f[i][j]:所有只从前i个物品中选择,并且总体积不超过j的选法;
属性:Max;

状态转移:
f[i][j]:可以选物品i 0个, 1个, 2个, ...., Ci个;
所以状态转移方程
f[i][j] = max(f[i-1][j - k*v[i]] + k*w[i]), k = 0, 1,2, ..., Ci;

//朴素多重背包问题,和完全背包问题一样的;
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N= 110;

int n, m;
int v[N], w[N], s[N];
int f[N][N];


int main()
{
    cin >> n >>m;
    for (int i = 1; i <= n; i++) cin >> v[i] >> w[i] >> s[i];
    
    for (int i = 1; i <= n; i++) 
    {
        for (int j = 0; j <= m; j++)
        {
            for (int k = 0; k <= s[i] && k*v[i] <= j; k++) 
            {
                f[i][j] = max(f[i][j], f[i-1][j - k*v[i]] + k* w[i]);   
                
            }
        }
        
    }
    cout << f[n][m] << endl;
    
    return 0;
}

优化:
f[i, j] = max(f[i-1, j], f[i-1][j-v]+w, f[i-1][j-2v]+2w,..., f[i-1][j-kv]+kw);
f[i, j - v] = max(       f[i-1][j-v]  , f[i-1][j-2v]+w ,..., f[i-1][j-kv]+(k-1)w, f[i-1][j-(k+1)v] + kw);
可以看见很多项还是重复的,除了首尾两项的差异。
但因为是求max,无法直接做减法。
这里采用二进制拆分法来做:
从2^0, 2^1, 2^2, ..., 2^(k-1)这k个2的次幂中选出若干个相加,可以得到范围在[1, 2^k - 1]的数。
进一步,求出满足2^0+2^1 +2^2 + ... + 2^p <= Ci的最大正整数p。
设Ri = Ci - 2^0 - 2^1 -... - 2^p,那么:
1.根据p的最大型,有2^0 + 2^1 + ... + 2^(p+1) > Ci, 可以得到2^(p+1) > Ri ,因此可以在2^0, 2^1, ..., 2^p中选出若干个相加可以表示出0-Ri之间的任何整数;
2.从2^0, 2^1, ..., 2^p 及Ri中选取若干个相加,可以表示出Ri ~ Ri + 2^(p+1) - 1范围内的数,而又因为Ri的定义
 Ri + 2^(p+1) - 1 = Ci - 2^0 - 2^1 -... - 2^p + 2^(p+1) - 1 = Ci,因此从2^0, 2^1,..., 2^p, Ri中选取出来的若干数可以表示出Ri到Ci之间的任何整数。
 
 综上所述,可以将数量为Ci的第i中物品拆分成p+2个物品,它们的体积分别为:
 2^0 *vi , 2^1 * vi, ..., 2^p * vi, Ri *vi,
 这p+2个物品可以凑成0~Ci*vi之间所有被vi整除的数,并且不能凑成大于Ci * vi的数。这样就可将原问题转换为体积为Vi的物品可以使用0~Ci次,该方法把每种物品拆分成O(logCi)组, 对这些新的物品组做一次0-1背包问题即可。
本来朴素完全背包问题时间复杂度为O(N * V *S), 现在变为了O(N*V* logS), S越大优化效果越好。
//二进制拆分法优化
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 25000, M = 2010;


int  n,m;
int v[N], w[N];
int f[N];

int main()
{
    cin >> n >> m;
    int cnt = 0;
    for (int i = 1; i <= n; i++)
    {
        int a, b, s;
        cin >> a>>b >>s;
        int k = 1;
        while (k <= s) {
            cnt++;
            v[cnt] = a*k;
            w[cnt] = b*k;
            s -= k;
            k *= 2;
        }
        
        if (s > 0) {
            cnt++;
            v[cnt] = a*s;
            w[cnt] = b*s;
        }
    }
    
    n = cnt;
    //执行0-1背包问题
    for(int i = 1; i <= n; i++)
        for (int j = m ; j >= v[i]; j--)
            f[j] =max(f[j], f[j - v[i]] + w[i]);
    
    cout << f[m] << endl;
    return 0;
}

4.分组背包问题

问题描述:

给定N组物品,其中第i组物品有Ci个物品。第i组的的第j个物品的体积为Vij,价值为Wij。有一容积为M的背包,要求选择若干物品进入背包,是的每组至多选择一个物品并且物品总体积不超过M的前提下,物品的价值总和最大。

[分析]:
状态表示:只从前i组物品中选,且总体积不大于j的所有选法;
属性:Max;

状态转移:
f[i, j]:
1.不从第i组选择物品,f[i -1, j];
2.从第i组选取物品,f[i - 1, j - vij] + wij
因此, f[i, j] = max(F[i - 1, j], f[i - 1 , j - vij] + wij), j = 1, 2, ..., k;
//朴素算法:
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

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

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];
    }
    
    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) 
                {
                    f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);    
                }   
            }
            
        }
    }
    
    cout << f[m] << endl;
    
    return 0;
}
posted @   zhanghanLeo  阅读(146)  评论(0编辑  收藏  举报
编辑推荐:
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示