DP 学习笔记(一):DP 基础知识,基础 DP 类型

基本概念

动态规划是一种非常常见的算法,它将大问题划分为与它一样但数据规模更小的小问题,而大问题的的最优解决方案又来自于小问题的最优解决方案。简称为 DP (Dynamic Programming)

动态规划优于暴力枚举的原因是它对于每一个问题,不是从头开始解决,而是基于之前解决的规模更小问题计算得来,这可以大大降低时间复杂度,而思维难度则上升了不止一个难度,而且它可以与数学 (概率 DP)、字符串 (自动机 DP)、图论 (最短路算法)、数据结构 (数据结构优化 DP) 等多种信息竞赛中的重要版块进行深度融合,因此需要我们认真学习。

一些定义

状态:当前所求问题的信息;

函数:当前所求问题的答案,一般叫做 DP 值;

状态转移方程:如何通过当前所求问题的状态,找到它可以由哪几个小问题推出,并通过那几个小问题的函数推出当前问题的函数。一般用一个递推式子表示。

时间复杂度:= 状态个数 × 转移时间复杂度。


DP 能解决的问题一般具有以下 3 点性质,下面通过斐波那契数列的求解过程来说明。

斐波那契数列是一种具有递推关系的数列,它的每一个数字都是前两个数字的和:1,1,2,3,5,8,。用函数表示出来就是 fi=fi1+fi2,那么它具有以下性质:

重叠子问题

简单来说,就是求解大问题的最优解决方案时,需要将大问题拆分成若干个小问题,小问题会被拆分成更小的问题,这些拆分出的小问题可能会有重复。比如求解斐波那契数列的第五项 f5

可以发现,f3 被计算了两遍,其实只用计算一遍就可以了,这就是 DP 可以实现较优复杂度的原因,这在小数据规模时还不明显,在数据规模大时就可以极大优化代码。

最优子结构

首先,大问题的最优解包含小问题的最优解,也就是当大问题取得最优解时,小问题也取得最优解。其次,小问题的最优解可以推出大问题的最优解,这就是最优子结构。

在斐波那契数列的求解过程中,求 fi 可以拆成求规模更小的 fi1fi2,而 fi1fi2 又可以加起来等于 fi,这样就符合最优子结构。

无后效性

简单来说,就是当我们求出某个问题的最优解时,我们就不再关心这个最优解是如何得到的,也就不再改变这个值了,而是将这个解作为已知继续推出其它问题的最优解。

求解斐波那契数列的过程中,当我们求出 fi 的值以后,这个值我们就不再改了。当我们要求 fi+1fi+2,直接把 fi 的值拿来用就行了,这就符合最优子结构。

无后效性是可以使用 DP 的前提条件,当后续的操作会影响到之前操作的值时,就无法通过重叠子问题来优化枚举的复杂度,也就无法使用 DP。一般求解 DP 问题都需要考虑 DP 的顺序,让问题没有后效性。

DP 一般有以下 3 种写法:

记忆化搜索

在搜索时,如果遇到之前求解过的状态,就直接将它的 DP 值拿来用,而不用继续往下递归。

求解斐波那契数列的记忆化搜索代码:

int f[N];
int fib(int x){
	if(x == 1 || x == 2)
		return 1;
	if(f[x])
		return x;
	else
		return fib(x - 1) + fib(x - 2);
} 

填表法

考虑当前状态是由哪几个状态转移而来。

求解斐波那契数列的填表法代码:

int f[N];
f[1] = f[2] = 1;
for(int i = 3; i <= n; i++)
	f[i] = f[i - 1] + f[i - 2];

填表法也是最常见的 DP 写法。

刷表法

考虑当前状态会影响到后续哪几个状态的求解。

求解斐波那契数列的刷表法代码:

int f[N];
f[1] = 1;
for(int i = 1; i <= n; i++){
	f[i + 1] += f[i];
	f[i + 2] += f[i];
}

背包DP

一类非常经典的线性 DP 题,因此专门提出来讲。

一些定义:n 表示物品个数,m 表示背包总容量,wi 表示物品重量,vi 表示物品价值,ci 表示物品个数,f 表示 DP 函数(代码中为 dp)。

01背包

P1048 [NOIP2005 普及组] 采药

fi,j 表示考虑前 i 个物品,总重量为 j 的最大价值,考虑当前物品选还是不选,那么可以很轻松写出状态转移方程:fi,j=max(fi1,j,fi1,jwi+vi)

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int M = 109, T = 1009;
int w[M], v[M], dp[M][T], n, m;
int main(){
	scanf("%d%d", &m, &n);
	for(int i = 1; i <= n; i++)
		scanf("%d%d", &w[i], &v[i]);
	for(int i = 1; i <= n; i++)
		for(int j = 0; j <= m; j++){
			if(w[i] > j)
				dp[i][j] = dp[i - 1][j];
			else
				dp[i][j] = max(dp[i - 1][j - w[i]] + v[i], dp[i - 1][j]);
		}
	printf("%d", dp[n][t]);
	return 0;
}

滚动数组优化01背包

滚动数组可以优化背包的空间复杂度。

可以发现,fi,j 的值只与 fi1 这一行有关系,与 i2,i3,,1 这些行都没有关系,那么我们可以只存当前枚举的行上一行的信息,就可以实现空间优化。滚动数组一般有以下两种写法:

交替滚动

开两行数组,一行存计算过的旧的一行,一行存当前计算的一行。

完整代码:

int dp[2][N];
int now = 0, old = 1;
for(int i = 1; i <= n; i++){
	swap(old, now);
	for(int j = 0; j <= m; j++){
		if(w[i] > j)
			dp[now][j] = dp[old][j];
		else
			dp[now][j] = max(dp[old][j], dp[old][j - w[i]] + v[i]);
	}
}

自我滚动

只开一行,一边计算,一边更新。这时候内层循环要倒着来枚举,下面来说明。

考虑当前状态由那些状态更新来:

发现 fi,j 可能会从 fi1,jk 转移过来,而从前往后枚举会更新掉 fi1,jk,导致转移错误,因此只能从后往前枚举

完整代码:

int dp[N];
for(int i = 1; i <= n; i++)
	for(int j = m; j >= w[i]; j--)
		dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

bitset优化01背包

当我们在求解某类 01 背包时,只需要判断某种状态是否可以达到(比如[ABC221G] Jumping sequence),这时就可以用 bitset 将时间复杂度优化成 O(n2ω),其中 ω=32

首先,这种可行性的背包的转移方程就是 fj=max(fj,fjwi+vi),因为只用判断当前状态可否到达,因此 fj 的值只有 01 两种,那么转移方程可以简化成 fj=fjwifj

bitset 顾名思义,就是存储 01 的集合,其内部是一个值只有 01 的数组,可以用 bitset <size> name 来定义,可以发现,fjfjwi 相当于是将当前 DP 数组左移 wi 位,再或起来,而 bitset 也支持将整个数组左右移动,复杂度为 O(lenω),那么,我们只需要将循环的第二层改为将当前 bitset 异或上 bitset 左移 wj 位,就可以将复杂度优化了。

完整代码:

bitset <50000> b;
b.set(0, 1);
for(int i = 1; i <= n; i++)
	b |= (b << w[i]);

完全背包

P1616 疯狂的采药

考虑现在不止一个物品,而是有无穷个物品,但背包有个总容量,因此每个物品最多放 mwi 个,那么考虑在 01 背包的基础上再枚举一遍物品个数。

还是记 fi,j 表示考虑前 i 个物品,总重量为 j 的最大价值,考虑当前物品选几个,那么可以写出状态转移方程 fi,j=fi1,jk×wi+k×vi(0kjwi),不过复杂度是 O(n3),需要优化。

其实,考虑 fi,j 的转移方程 fi,j=fi1,jk×wi+k×vi(0kjwi) 可以写作 fi,j=max(fi1,j,vi+fi1,jwik×wi+k×vi)(0kjwiwi),将 max 中后半部分与 fi,jwi 的转移方程 fi,jwi=fi1,jwik×wi+k×vi)(0kjwiwi) 相比较,可以发现原先的表达式被简化成了 fi,j=max(fi1,j,fi,jwi+vi),现在就可以 O(n2) 通过本题了。

由于考虑到 fi,j 可能会从同一排靠前的位置转移而来,因此用滚动数组优化时,j 这一维需要正序枚举。

当然,可行性的完全背包也可以用 bitset 优化,这里不再赘述。

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 1e7 + 5;
long long v[N], w[N], dp[N], n, m;
int main() {
    scanf("%d%d", &n, &m);
    memset(dp, 0, sizeof(dp));
    for(int i = 1; i <= n; i++)
        cin >> w[i] >> v[i];
    for(int i = 1; i <= n; i++)
        for(int j = 0; j <= m; j++)
            if(j >= w[i])
                dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
    printf("%d", dp[m]);
    return 0;
}

由于完全背包中每个物品也不是能选任意多个,因此也可以套用接下来多重背包的优化方式。

多重背包

P1776 宝物筛选

可以发现这和完全背包很像,但有可能物品取不到 jwi 这么多个,那么此处就无法通过完全背包的方式优化时间复杂度,因为你无法判断 fi,jwi 转移时是否已经选满了 ci 个。因此只能枚举当前物品选几个,转移方程 fi,j=fi1,jk×wi+k×vi(0kmin(ci,jwi))

注意此时的状态是由上一排转移过来,和 01 背包类似,因此滚动数组优化时需要倒序枚举。

完整代码(会 TLE):

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 9;
int w[N], v[N], c[N], dp[N], n, m;
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d%d%d", &v[i], &w[i], &c[i]);
	for(int i = 1; i <= n; i++)
		for(int j = m; j >= w[i]; j--)
			for(int k = 1; k <= c[i] && k * w[i] <= j; k++)
				dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
	printf("%d", dp[m]);
	return 0;
}

二进制拆分优化多重背包

考虑一个物品价值为 v,重量为 w,数量为 c,那么可以发现:

  • 当取 1 个该物品时,重量为 w,价值为 v

  • 当取两个该物品时,重量为 2×w,价值为 2×v

  • 当取 3 个该物品时,重量为 3×w=2×w+w,价值为 3×v=2×v+v

  • 当取 4 个该物品时,重量为 4×w,价值为 4×v

  • 当取 5 个该物品时,重量为 5×w=4×w+w,价值为 5×v=4×v+v

可以发现,对于同一种物品,可以把它二进制拆分成 log2ci 个物品,这些物品的重量为 2k×w,价值为 2k×v,而且它们组合在一起就变成任意个该物品,此时可以通过 01 背包来做了,复杂度降低为 O(mi=1nlog2ci)

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n, m, dp[N];
int v[N], w[N], c[N];
int new_n;
int new_v[N], new_w[N], new_c[N];
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d%d%d", &v[i], &w[i], &c[i]);
	int new_n = 0;
	for(int i = 1; i <= n; i++){
		for(int j = 1; j <= c[i]; j <<= 1){
			c[i] -= j;
			new_w[++new_n] = j * w[i];
			new_v[new_n] = j * v[i];
		}
		if(c[i]){
			new_w[++new_n] = c[i] * w[i];
			new_v[new_n] = c[i] * v[i];
		}
	}
	for(int i = 1; i <= new_n; i++)
		for(int j = m; j >= new_w[i]; j--)
			dp[j] = max(dp[j], dp[j - new_w[i]] + new_v[i]);
	printf("%d", dp[m]);
	return 0;
}

单调队列优化多重背包

DP学习笔记(三)(2024.7.29)

混合背包

P1833 樱花

01 背包部分看成每个物品最多只能选 1 个,完全背包部分看成每个物品最多只能选 mwi 个,多重背包部分不变,那么可以直接转化为多重背包。

完整代码(使用了单调队列优化):

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 9;
int n, m, h1, h2, m1, m2;
int dp[N], q[N], num[N];
int w, v, c;
int main(){
	scanf("%d:%d %d:%d %d", &h1, &m1, &h2, &m2, &n);
	m = 60 * (h2 - h1) + m2 - m1;
	for(int i = 1; i <= n; i++){
		scanf("%d%d%d", &w, &v, &c);
		if(c == 0)
			c = 100000000;
		if(c > m / w)
			c = m / w;
		for(int b = 0; b < w; b++){
			int head = 1, tail = 1;
			for(int y = 0; y <= (m - b) / w; y++){
				int tmp = dp[b + y * w] - y * v;
				while(head < tail && q[tail - 1] <= tmp)
					tail--;
				q[tail] = tmp;
				num[tail++] = y;
				while(head < tail && y - num[head] > c)
					head++;
				dp[b + y * w] = max(dp[b + y * w], q[head] + y * v);
			}
		}
	}
	printf("%d", dp[m]);
	return 0;
}

二维费用背包

P1855 榨取kkksc03

01 背包的基础上再开一维就可以了。

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 209;
int dp[N][N], w[N], t[N], n, m, T;
int main(){
	scanf("%d%d%d", &n, &m, &T);
	for(int i = 1; i <= n; i++)
		scanf("%d%d", &w[i], &t[i]);
	for(int i = 1; i <= n; i++)
		for(int j = m; j >= w[i]; j--)
			for(int k = T; k >= t[i]; k--)
				dp[j][k] = max(dp[j][k], dp[j - w[i]][k - t[i]] + 1);
	printf("%d", dp[m][T]);
	return 0;
}

分组背包

P1757 通天之分组背包

树形背包 (有依赖的背包)

P2014 [CTSC1997] 选课

线性DP

状压DP

普通状压DP

轮廓线DP

数位DP

树形DP

普通树形DP

换根DP

参考资料

  • 算法竞赛 罗勇军、郭卫斌
posted @   JPGOJCZX  阅读(6)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· NetPad:一个.NET开源、跨平台的C#编辑器
· PowerShell开发游戏 · 打蜜蜂
· 凌晨三点救火实录:Java内存泄漏的七个神坑,你至少踩过三个!
点击右上角即可分享
微信分享提示