【题解】P1156 垃圾陷阱
题目
洛谷:P1156 垃圾陷阱
分析
关于本题,状态有三个影响因素:物品,时间(血量),高度。
物品的使用顺序并不是随意的,必须按它们下落的时间顺序来先后处理。所以时间就确定了,将物品排序后依次使用即可。现在就只剩下血量,高度。选哪个作为 j 呢?
只能挨着尝试,看哪个更好些,动态规划有的时候选错了状态定义是做不出来题目的,但是本题两种都可以。这里就选择枚举高度作为 j 来解题。
于是有:f[i][j]表示处理完第 i 个垃圾之后,高度为 j 时的最大血量。
重点
这道题与常规的背包问题解法是不同的
举个例子:
- 常规背包问题(如0/1背包):
状态定义:f[i][j]表示在前 i 个物品中选,背包容量不超过 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]); // 选第i个物品 }
- 本题垃圾陷阱问题:
状态定义:f[i][j]表示处理完第 i 个垃圾后,高度为 j 时的最大血量
关键区别:
本题的状态表示是“处理完第 i 个垃圾后”!!!不是简单地考虑"选"或"不选",而是考虑每个垃圾怎么用?
每个垃圾有两种使用方式(吃或堆放):
- 堆放垃圾会增加高度(j增加)
- 吃垃圾会增加血量(f[i][j]的值增加)
我们可以发现:在常规的背包问题中,状态表示都是状态转移方程研究的都是对于(i,j)
这个状态的来源,它是由哪些状态转移而来的,研究是什么导致了f[i][j]
。例如在经典01背包中,研究(i,j)
这个状态,可以分两种情况,选择或者不选。于是我们都是在对当前的f[i][j]
做递推。
但是在本题中,根据题意,我们的状态表示是 “处理完第 i 个垃圾后”,这个 “处理完” 就代表着垃圾已经处理了,我们要研究的是垃圾处理后导致了什么。会导致两种可能:要么加血,要么增加高度。于是我们要同时写出这两种状态转移方程。通过堆放垃圾增加高度,或者吃垃圾维持生命,两种操作都会影响不同的状态维度。
代码
dfs
数据范围问题:一般题目限制128MB的时候,开bool类型数组大概能开1e9左右。如果需要开更大的空间就做不到了,这时候可以使用map来储存数据。只不过map的O(logn)相对数组的O(1)就慢一些了。
为什么数据量大时用map?
-
空间效率:实际问题中,DP状态往往是稀疏的,可能只有少量状态会被实际访问,数组方式会浪费大量空间存储未访问的状态。
-
灵活性:可以处理状态值范围很大的情况,不受固定数组大小的限制。
-
避免MLE:当状态空间理论上很大时,例如状态维度是1e5×100×100,数组方式无法存储。
#include<iostream>
#include<algorithm>
#include<map>using namespace std;//bool dp[10010][110][110];
map<int,map<int,map<int,bool>>> dp; //如果题目数据量太大就用mapstruct node
{int t,f,h;
}a[110];int d, g;
int ans1 = 1e9, ans2 = 0;bool cmp(node& a, node& b)
{return a.t < b.t;
}void dfs(int f,int h,int pos)
{if(h >= d) {ans1 = min(ans1, a[pos-1].t);return;}if(f < a[pos].t || pos > g){ans2 = max(ans2, f);return;}if(dp[f][h][pos]) return;dp[f][h][pos] = true;dfs(f + a[pos].f, h, pos + 1);dfs(f, h + a[pos].h, pos + 1);return;
}int main()
{cin >> d >> g;for(int i=1;i<=g;i++){cin >> a[i].t >> a[i].f >> a[i].h;}sort(a + 1,a + 1 + g,cmp);dfs(10,0,1);if(ans1 == 1e9) cout << ans2;else cout << ans1;return 0;
}
背包dp
在绝对时间中不进行扣血操作。
举个例子,现在是中午12点,你醒了,你是一个机器人,你身上有2个小时电量,到了下午1点天上掉了一个补给包给你充了2个小时电,现在的电量够支撑到下午4点,但是下次掉落补给包在下午5点,于是你噶掉了。这就是比较绝对时间;
同样一个故事,还可以这样叙述:现在是中午12点,你醒了,你是一个机器人,你身上有2个小时电量,过去了一个小时,你的电量只剩下1个小时了(绝对时间的下午1点),此时天上掉了一个补给包给你充了2个小时电,现在的电量增加到3个小时了,但是还有4个小时你才能拿到补给包(绝对时间下午5点),于是你噶掉了。
实际上它们是相同的逻辑,都是正确的。我分别写出他们的代码。
比较绝对时间
绝对时间的逻辑:
-
f[i-1][j]表示处理完前i-1个垃圾后,高度为j时的剩余血量
-
这个血量是从时间0开始累积的"剩余生存时间"
-
比较
f[i-1][j] >= a[i].t
实际上是判断:从开始到现在,剩余的生命是否足够支撑到当前垃圾掉落的时间
完整代码如下:
#include <iostream>
#include <algorithm>
#include <cstring>using namespace std; const int N = 110, T = 1e5 + 10;int f[N][T]; //f[i][j]表示处理完第i个垃圾之后,高度为j时的最大血量。 处理完第i个垃圾之后!!
int d,n; //垃圾井深度、垃圾的数量
struct node
{int t,w,h; //扔下的时间、维持生命的时间、能垫高的高度
}a[N];bool cmp(node& a, node& b)
{return a.t < b.t;
}int main()
{cin >> d >> n;for(int i=1;i<=n;i++){cin >> a[i].t >> a[i].w >> a[i].h; } sort(a + 1, a + 1 + n, cmp); //排序哪个垃圾最先落下 f[0][0] = 10;for(int i=1;i<=n;i++){ for(int j=0;j<=d;j++){ if(f[i-1][j] >= a[i].t) //能拿到这个垃圾 {if(j + a[i].h >= d) //拿完就出去了,那就直接返回这个物品掉落的时间 {cout << a[i].t << endl;return 0;}//当前物品不能直接垫出去//记住:状态表示是处理完第i个物品!那么处理完会导致两种可能。而不是在1~i个物品中选择,第i个物品的状态是由两种可能性导致的 f[i][j] = max(f[i][j],f[i-1][j] + a[i].w); //吃掉第i个物品导致的状态f[i][j+a[i].h] = max(f[i][j+a[i].h],f[i-1][j]); //堆起来第i个物品导致的状态 }}}int ret = 10;for(int i=1;i<=n;i++){if(ret < a[i].t) break;else ret += a[i].w;}cout << ret << endl;return 0;
}
问题:为什么不将全部 f 数组初始化为负无穷也是对的?
- 血量永远不会为负值:因为吃垃圾只会增加血量(+a[i].w),而时间判断f[i-1][j] >= a[i].t已经确保了血量足够
- 不会选择无效状态:无法到达的状态会保持为0,而有效状态的值都会≥10
比较相对时间
#include <iostream>
#include <algorithm>
#include <cstring>using namespace std; const int N = 110, T = 1e5 + 10;int f[N][T]; //f[i][j]表示处理完第i个垃圾之后,高度为j时的最大血量。 处理完第i个垃圾之后!!
int d,n; //垃圾井深度、垃圾的数量
struct node
{int t,w,h; //扔下的时间、维持生命的时间、能垫高的高度
}a[N];bool cmp(node& a, node& b)
{return a.t < b.t;
}int main()
{cin >> d >> n;for(int i=1;i<=n;i++){cin >> a[i].t >> a[i].w >> a[i].h; } sort(a + 1, a + 1 + n, cmp); //排序哪个垃圾最先落下 memset(f, -0x3f, sizeof f); //负无穷表示无法到达的状态 f[0][0] = 10;for(int i=1;i<=n;i++){int delta = a[i].t - a[i-1].t;for(int j=0;j<=d;j++){if(f[i-1][j] < delta) continue; //撑不到垃圾来就死了//能拿到垃圾 if(j + a[i].h >= d) //拿完就出去了,那就直接返回这个物品掉落的时间 {cout << a[i].t << endl;return 0;}//当前物品不能直接垫出去//记住:状态表示是处理完第i个物品!那么处理完会导致两种可能。而不是在1~i个物品中选择,第i个物品的状态是由两种可能性导致的 f[i][j] = max(f[i][j], f[i-1][j] - delta + a[i].w); //吃掉第i个物品导致的状态f[i][j+a[i].h] = max(f[i][j+a[i].h], f[i-1][j] - delta); //堆起来第i个物品导致的状态 }}int ret = 10;for(int i=1;i<=n;i++){if(ret < a[i].t) break;else ret += a[i].w;}cout << ret << endl;return 0;
}
重点分析:
将代码写在if(f[i-1][j] >= a[i].t)
(绝对时间代码演示中)的条件下,与直接if(f[i-1][j] < delta) continue;
(相对时间代码演示中)过滤不符合条件的 j 的枚举是等效的。
Q:f(f[i-1][j] < delta) continue;
撑不到垃圾来就死了为什么还要continue ,直接break不行吗?
A:没有理解动态规划的过程。用 continue 是因为我们要枚举所有可能的高度 j(从 0 到 d),每一个 j 都是一个独立的状态。continue 表示跳过当前状态 j 的处理,继续尝试下一个高度。而 break 会直接退出这个 for 循环,不再检查更高的高度,这会导致状态没有被完整地转移,结果就会出错。
初始化分析:
相对时间中 f 数组必须要初始化为负无穷,因为相对时间下,血量可能会出现负数,会影响判断。
小结:对于动态规划的理解,可以从函数的角度来理解动态规划。我们可以把动态规划看作是在构造一个函数,这个函数的输入是某些“状态参数”,输出是该状态下的“最优值”或“可行性判断”等。如果你知道 f[i-1][j] 是准确的;那你可以只基于这个数值去计算 f[i][j],而不用关心“我是怎么走到 f[i-1][j] 这个状态的”。这跟函数的定义是一致的。函数不记录“历史”,它只关注输入和输出。
*无后效性是指:当前状态一旦确定,后续的决策和状态转移就只与当前状态有关,而与之前如何到达这个状态的路径无关。