\(AcWing\) \(1088\) 旅行问题
https://www.acwing.com/solution/content/12796/
一、题意理解
给定一个 环,环 上有 \(n\) 个节点,编号从 \(1∼n\),以及一辆小车车
每一个 节点 \(i\) 有一个 权值 \(p_i\) 表示当车 到达该点 时,可以 获得 的 油量
还有一个 权值 \(d_i\) 表示当车从 节点 \(i\) 到 节点 \(i+1\) 所需要 消耗 的 油量
现有一辆车想从环上 任意点 出发,顺时针 或 逆时针 绕环一圈走回起点
行驶的过程中,油量不能为 负数,初始油量 为 起点 处所能获得的 油量
判断能否完成 环圈行驶
二、暴力怎么做
输入:
5
3 1
1 2
5 2
0 1
5 4
用测试用例进行人脑模拟,以加快对题目的理解:
步骤\(1\):进行破环成链:
\(1->(1)->2->(2)->3->(2)->4->(1)->5 ->(4) ->1\)
\(3~~~~~~~~~~~~~~~~~~~~~~1~~~~~~~~~~~~~~~~~~~~~~ 5~~~~~~~~~~~~~~~~~~~~~~0~~~~~~~~~~~~~~~~~~~~~~5\)
第二行的数字:每个节点可以补充的油量,\(p[i-1]\)
\(()\)中为在行走过程中消耗的油量,\(d[i-1]\)
\(Q\):为什么是\(p[i-1],d[i-1]\),而不是\(p[i],d[i]\)呢?
\(A\):以\(2\)号节点为例,到达了\(2\),还没有加上\(2\)号站点的油时,此时剩余油量为\(3-1=2,\)即\(p[1]-d[1]=3-1=2\),也就是,到达本级节点时的油量,其实是和前一个、前前一个、...相关的,还与本级无关。
利用前缀和的思想优化一下,反正一般这种增量、变量的都用前缀和优化,行不行都可以尝试一下~
定义 \(s[i]=s[i-1]+p[i-1]-d[i-1]\)
则 \(s[i]\)的含义是:从\(1\)号节点出发,到达\(i\)号节点,还未取得\(i\)号节点的油量前,油量剩余值。
道理
如果我们从\(1\)号点出发,事情还是比较简单的,就是研究一下在整条路线上,每个节点到达、还未加上此节点的油量前,是不是剩余油量全部都大于等于\(0\),如果出现某个节点到达时(未加上本节点的油),
油量已经小于\(0\),就意味着不可行,因为中途没油是不可能跑到下一个节点的。
这东西最终看\(s[n+1]\)是不行的,因为可能会出现中途没有了油,不合法的场景出现。如果我们简单的获取\(s[n+1]\),最终的结果可能是大于\(0\),比如我举个简单的栗子:
2
100 200
1000 100
很明显,这个样例是不行的,第一个节点有\(100\)个油,我们加上,走到了第二个节点时,需要消耗200个油,它中途就会没有了油,肯定是跑不到的。
但如果我们看最终的结果 \(s[n+1]=s[2+1]=s[3]=s[2]+p[n]-d[n]=s[2]+p[2]-d[2]=s[1]+p[1]-d[1]+p[2]-d[2]=0+100-200+1000-100=800\)
但这明显是不可能的结果!
\(Q\):如果我们不是从\(1\)号节点出发呢?
\(A\):因为破环成链后,其实就是一个线性的问题,依然可以使用前缀和进行处理。假设我们从i点出发,则就是在问我们,
\(j ∈[i+1,i+2,i+3, ... ,i+n]\) 这\(n\)个点中,\(s[j]-s[i]\)是不是一直大于等于\(0\),如果有一个小于\(0\)的就是不合法
这里有两种暴力计算的办法,分别是:
//1、暴力计算方法I
for (int i = 1; i <= n; i++) { //枚举每个出发点
bool flag = true;
for (int j = i + 1; j <= i + n; j++)
if (s[j] - s[i] < 0) {
flag = false;
break;
}
if (flag) ans[i] = true;
}
// 2、暴力计算方法II
for (int i = 1; i <= n; i++) { //枚举每个出发点
LL Min = LLONG_MAX;
for (int j = i + 1; j <= i + n; j++) Min = min(Min, s[j]); //记录在哪个点是存油量最少的情况
// s[j]-s[i]>=0 则表示一直保持油量大于等于0
if (Min >= s[i]) ans[i] = true;
}
其实这两种计算方法本质上是一样的,但第二种更聪明些,我们来学习一下第二种方法:
我找出这些数中的最小值,如果最小值都比指定的数字小,那么整条路径就都比指定数字小!
这句话是后面优化时采用单调队列的基础,因为这就明显指向了在区间内找出最小值!反倒是第一种思路朴实,无法再想办法优化了,老实人有罪~
暴力大法
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 2000010;
int n;
int p[N];
int d[N];
LL s[N];
bool ans[N];
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d %d", &p[i], &d[i]);
p[i + n] = p[i];
d[i + n] = d[i];
}
// 顺时针
// p[i-1]-d[i-1]:从i-1点出发,可以获取到p[i-1]的油,到i点,走了一段路程,消耗掉d[i-1]的油量,p[i-1]-d[i-1]:从i-1到i的油量增量(增量有可能是正的,也有可能是负的)
// 设 s[i]=s[i-1]+ p[i-1] -d[i-1]
// 则 s[i]理解为增量和
// 假如我们从1号点出发,则i∈[2~n+1] 这些点的s[i]必须全部>=0,一旦出现<0的情况,就是中途没油了~
for (int i = 1; i <= 2 * n; i++) s[i] = s[i - 1] + p[i - 1] - d[i - 1];
// 2、暴力计算方法II
for (int i = 1; i <= n; i++) { //枚举每个出发点
LL Min = LLONG_MAX;
for (int j = i + 1; j <= i + n; j++) Min = min(Min, s[j]); //记录在哪个点是存油量最少的情况
// s[j]-s[i]>=0 则表示一直保持油量大于等于0
if (Min >= s[i]) ans[i] = true;
}
//逆时针
for (int i = 2 * n; i; i--) s[i] = s[i + 1] + p[i + 1] - d[i]; //这里需要注意一下画图理解下为什么是-d[i]
for (int i = n + 1; i <= 2 * n; i++) {
LL Min = LLONG_MAX;
for (int j = i - 1; j >= i - n; j--) Min = min(Min, s[j]);
if (Min >= s[i]) ans[i - n] = true;
}
//枚举输出
for (int i = 1; i <= n; i++) puts(ans[i] ? "TAK" : "NIE");
return 0;
}
三、单调队列优化
假设我们从\(i\)号车站出发,那么一定要确保这段区间\(i+1 − > i + n\)的\(s[i]\)大于等于\(0\),所以我们可以用单调递增队列来维护这长度为\(n\)的区间的所有前缀和,这样,我们每次达到\(n\)区间时,我们都可以通过队头元素来判断是否是大于等于\(0\)的。逆时针同理,逆着来看问题即可。详见\(AC\)代码。
代码实现
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 2000010; //破环成链,双倍长度
int n, p[N], d[N];
ll s[N];
ll a[N];
int q[N];
bool ans[N];
/*
顺时针:s[i] 表示 1 ~ i 的剩余花费
即 s[i] = s[i - 1] - d[i - 1] + p[i - 1]
ans[i] = min(s[i + 1] ~ s[i + n]) - s[i] >= 0
逆时针:s[i] 表示 i ~ n 的剩余花费
即 s[i] = s[i + 1] - d[i] + p[i + 1]
ans[i - n] = min(s[i - n] ~ s[i - 1]) - s[i] >= 0
*/
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d %d", &p[i], &d[i]); //此题目数据量 n<=1e6,数据量大,使用scanf进行读取
p[i + n] = p[i]; //破环成链,复制数据
d[i + n] = d[i];
}
/*s[i]的含义:到达i站点,但未加上i站点的油时的剩余油量。
Q:为什么这样设计概念呢?
A:(1) 从结果出发进行思考:
还没加上此站点的油,那么剩余的油量如果大于等于零,说明可以成功到达i站。剩余的油量如果小于零,说明不可以成功到达i站。而我们想要求的就是是否能成功完成全程,
也就是能成功到达每个站点,如此设计概念,就是从结果出发进行的设计。
(2) 从边界出发进行思考:
假设我们从1号站点出发去往2号站,那么s[2]设计成什么呢?
A:加了1点的油,走了1~2的路,再加了2点油
B:加了1点的油,走了1~2有路,还没加2点的油
看明白了吧,我们肯定要选择B的设计思路啊!A是加了两个点的油,那不就把问题混杂在一起了吗?B才是干干净净的处理方法啊。
Q: s[i]是否存在递推关系呢?
A:肯定存在啊,这就是一个前缀和啊,因为p[i-1]-d[i-1]就是油的增量(当然,可能是负数),s[i-1]就是进入上一个加油站时的剩余油量,这四者之间的关系就是:
s[i]=s[i-1]+p[i-1]-d[i-1]
Q: 为什么下面的代码从i=1一直计算到n*2呢,计算到n不就行了吗?
A: 因为破环成链啊,如果我们不把长度转化为两倍的长度,假设共n=5站,计为 1,2,3,4,5 那么从3号点出发,它应该可以回到3号点,应该走 3-4-5-1-2-3,对吧,这样用1~n去表示,
表示不出来啊,所以我们认为6就是1,7就是2,8就3,也就是让3走到8,相当于回到了3啊。也就是i和i+n其实是同一个站点。思考一下边界情况,
比如我们假设从1号站点出发:
s[1],是什么意思呢?根据定义:s[1]表示到达了1号站点,还没有加上1号站点的油时的剩余油量。还没加上油,应该初始值是0.
s[5],是什么意思呢?根据定义:s[5]表示到达了5号站点,还没有加上5号站点的油时的剩余油量。
s[6],是什么意思呢?根据定义:s[6]表示到达了6号站点,还没有加上6号站点的油时的剩余油量。因为6其实就是1,也就是回到了1号站点。
如果我们是要从5号站点出发呢?
s[5],是什么意思呢?根据定义:s[5]表示到达了5号站点,还没有加上5号站点的油时的剩余油量。还没加上油,应该初始值是0.可是现在s[5]肯定是有值的啊,它不是0啊?怎么办?
这个很好办,从哪个位置出发,就把谁减去这个值就行了,对比思考一下前缀和不就明白了吗?
*/
for (int i = 1; i <= n * 2; i++) s[i] = s[i - 1] + p[i - 1] - d[i - 1];
//顺时针
/*Q:如果我们从1号点出发,怎么算是1号点是合法的,可以完成全程呢?
A: 1号点出发,我们需要考查它的后续 s[2]-s[1],s[3]-s[1],s[4]-s[1],s[5]-s[1],s[6]-s[1]是否最小值都大于等于零,如果是这样,就是合法的起点。
2号点出发,我们需要考查它的后续 s[3]-s[2],s[4]-s[2],s[5]-s[2],s[6]-s[2],s[7]-s[2]是否最小值都大于等于零,如果是这样,就是合法的起点。
...
上面这样写不是很聪明的写法,可以转为:
1号点出发,我们需要考查它的后续 min(s[2],s[3],s[4],s[5],s[6]) >=s[1],如果是这样,就是合法的起点。
2号点出发,我们需要考查它的后续 min(s[3],s[4],s[5],s[6],s[7]) >=s[2],如果是这样,就是合法的起点。
...
区间长度为5,一直要求其区间内的最小值,用单调队列可以优化啊~
在分析考查s[1]时,我们需要知道区间[2~6]之内的最小值,如果我们正序遍布进行DP数据填充的话,1是第1个填充的,它还不知道区间[2~6]之间的最小值是多少,那怎么DP啊?
!!!正难则反!!!,此题告诉我们,当我们发现DP依赖的数据无法正序准备好的时候,我们可以考虑反着来试试:
如果我们从最后一个2*n开始倒序遍历,就会准备好一个滑动窗口,内容就是当前遍历到节点的后序最长范围n之内的s[j]最小值,当前枚举到的位置i,可以以O(1)的时间直接调用q[hh]
就可以获得它后面最长n个长度范围内的s[j]最小值所在的位置!这样滑动窗口就用的上了!
经验总结:依赖的滑动窗口在右侧,倒序枚举计算
*/
int hh = 0, tt = 0;
q[0] = n * 2 + 1; //倒序枚举,哨兵节点是最后一个节点ID+1
//每个节点,都需要考查距离自己i+1~i+n内的后续范围内的s[j]最小值,单调队列在i的右侧,需要倒序遍历
for (int i = n * 2; i; i--) {
/*
Q: 为什么是q[hh] > i + n ?
你看我代码的注释,就讨论顺时针吧,逆时针类似。
讨论从 i 开始,绕一圈等于从 i 走到它倍长的那个点 i+n,所以是找 i+1 到 i+n 的最小值,
[i+1,i+n] 这个区间长度是 n,算完 i 的答案,才会加入 s[i] 到单调队列中
*/
while (hh <= tt && q[hh] > i + n) hh++; //保持窗口的n个最长数据
/*这句是本题的题意相关,与单调队列框架无关
只关心1~n之间的信息(n+1..2*n都是铺垫)
如果[1~n]中的某个位置的右侧n个数据范围内的最小石油剩余量,大于以i为出发点时的石油剩余量s[i],则全部节点可以到达!
*/
if (i <= n && s[q[hh]] >= s[i]) ans[i] = true;
//准备i入队列,保留 活的小+比较小的值
while (hh <= tt && s[q[tt]] >= s[i]) tt--;
q[++tt] = i;
}
//后缀和
/**
s[i] 表示 i ~ n 的剩余油量
*/
for (int i = 2 * n; i; i--) s[i] = s[i + 1] + p[i + 1] - d[i]; //注意一下这个后缀和的计算方法
hh = 0, tt = 0;
q[0] = 0; //哨兵
//每个节点,都需要考查距离自己i+1~i+n内的前序范围,单调队列在i的左侧,需要正序遍历
for (int i = 1; i <= 2 * n; i++) {
while (hh <= tt && q[hh] < i - n) hh++; //保持窗口的n个最长数据
/*这句是本题的题意相关,与单调队列框架无关
只关心n+1~2*n之间的信息(1..n都是铺垫)
如果[n+1~2*n]中的某个位置的左侧n个数据范围内的最小石油剩余量,大于以i为出发点时的石油剩余
量s[i],则全部节点可以到达!
*/
if (i > n && s[q[hh]] >= s[i]) ans[i - n] = true;
//准备i入队列,保留 活的小+比较小的值
while (hh <= tt && s[q[tt]] >= s[i]) tt--;
q[++tt] = i;
}
//输出
for (int i = 1; i <= n; i++) puts(ans[i] ? "TAK" : "NIE");
return 0;
}