文章目录
Codeforces补题记录(2)
实在受不了每次比赛掉分的感觉了。于是怒开始补CF。
大概两到三天补一场,题目不一定全补。
和dp里一样,打星号的题比较重要。
1、Codeforces Round #637 (Div. 2) - Thanks, Ivan Belonogov!(2020.5.3-2020.5.4)
最近沉迷CF,发现dp已经好久没补叻。过两天继续玩dp去。
不过可能再做几道dp就要暂时告一段落了,要去补VJ和图论。今天讲的LCA、倍增和强连通分量还是有点费解。
再说点题外话。本来没打算补这场,结果是五月三号那场Div2CF系统崩了然后移到了三天后。乐了。本来打算刚好趁五一把题目打掉,结果又没打成。一气之下打了一场虚拟场。
回到这里。这场的题目质量都比较高,感觉挺不错的。虽然据说这场Div1的某道题出成了错题。
*A、Nastya and Rice
这题本身很简单,但是从这题出发我们引出线段操作类问题,把之前在GCPC18里的坑给填上。
本来这玩意我都没打算写,结果这两天做题发现这种类型的题目居然还挺多,而且如果第一次做碰到一些有点绕的题目可能会想不到。然后就干脆全部归类进来吧。
题目的意思是有一袋米,里面米粒重量在 之间,问 粒米的重量能否在 之间。
先上代码。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main()
{
int t; cin >> t;
while (t--)
{
int n, a, b, c, d; cin >> n >> a >> b >> c >> d;
ll minimum = (a - b) * n, maximum = (a + b) * n;
if (maximum >= c - d && minimum <= c + d) cout << "Yes" << endl;
else cout << "No" << endl;
}
return 0;
}
线段操作问题——顾名思义,就是对一堆线段进行操作。这里的线段和线段树的线段还不太一样,线段树的线段主要是对某段区间进行查询、修改等操作,而这里就是单纯线段上的操作,主要包括交集、并集两种。而且考虑的基本都是闭区间。半开半闭区间讨论起来比较麻烦,而且离散域上是可以把开转成闭的。
而且线段操作类问题往往和贪心有很大关系。
首先定义线段 , 。和区间不一样,这里左端点可以和右端点相等。
之后讲交集操作。这个比较好理解。
两条线段 有交集的充要条件是 。可以这么考虑:把左边的线段固定长度慢慢向右滑动,然后考虑产生交集的两种极限情况。之后再变动左边线段的长度,会发现长度对结果没有影响。如果是第一次接触这个结论可能要想象一下,不过也相对简单。
有了这个结论之后我们就能够判断线段是否有交集了。但是这还不够,我们想要求出这个交集。
在上述结论的基础上,我们依旧考虑线段滑动相交,发现交集的左端点只可能由两条线段的左端点之一组成,右端点只可能由右端点之一组成。于是交集为 。
有了交集的表达式,判断有无交集就可以从代数角度理解了。交集要存在,即 。把括号逐层打开: ,
第一个和第四个不等式显然是成立的,于是只剩下中间的两个:正好是上面我们推导出的充要条件。
所以判断有无交集时直接代交集的代数表达式,若左端点小于等于右端点则直接返回线段,否则返回空集。
有了这么多铺垫,再回头看这A题的代码就显然中的显然了。所有米粒的重量都在 之间,我们只要判断一下这个区间和 有没有交集就OK了。
下面给几道例题。
Down the Pyramid(GCPC18 D)
题意就是给你长度为 的数列 ,问能构造出多少种长为 的数列,使相邻两项和与 中对应元素相等。
我们设新数列为 。很容易发现,一旦 确定,整个数列都确定了。因此问题转化为求出 所有的可能取值。
然后我们可以尝试找一下规律。
因为 是非负数列,于是 。
容易计算得 ,于是 。
,于是 。
,于是 。
这之后的就不再写了。可以看到当把 放在不等号的左边时,不等号的方向和不等号右端的值都是有规律的。
于是我们就可以看成若干个无穷区间的交集,对于小于号看作右端点取最小,大于号看作左端点取最大。
最后若得到了空集输出0,否则输出 。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1e6 + 10;
int main()
{
int n; cin >> n;
static int a[MAXN];
for (int i = 1; i <= n; ++i) scanf("%d", a + i);
int sum = 0, l = 0, r = a[1];
for (int i = 1; i <= n; ++i)
{
if (i & 1) sum += a[i], r = min(r, sum);
else sum -= a[i], l = max(l, sum);
}
if (l > r) cout << 0 << endl;
else cout << r - l + 1 << endl;
return 0;
}
线段的重叠(51nod 1091)
中文题面就不翻译了。
这道题目说实话和之前说的模拟线段交集其实没什么关系。
这题考察的是一个贪心。不止于这一道题,与线段有关的题目很大一部分都是贪心。一般而言都是先对左端点排序,更新右端点的最值。当然还是得具体问题具体分析,有些题目可以dp,有些题目甚至要用到网络流。
而这题就是非常标准的贪心。首先我们对左端点进行排序。起初选择排完序后的第一条线段。因为左端点此时已经有序了,所以此时影响答案的因素就是右端点——显然在当前位置后面的线段左端点一定比当前所选择的线段大,所以所选择的线段要保证右端点尽可能大,这样才能使交集最大。
因此,遍历数组里的所有线段,每次和当前所选择的线段取交集,之后若右端点比所选线段大则更新。最后输出答案即可。
(P.S. 这题我去年九月份就AC了一遍,现在回头看当时写的代码简直惨不忍睹…这题写了90多行,还充满着浓浓的Pascal色彩)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 5e4 + 10;
struct seg
{
int s, t;
};
bool cmp(seg a, seg b)
{
return a.s < b.s;
}
int main()
{
int n; cin >> n;
seg a[MAXN];
for (int i = 1; i <= n; ++i)
scanf("%d%d", &a[i].s, &a[i].t);
sort(a + 1, a + 1 + n, cmp);
int ans = 0, l = a[1].s, r = a[1].t;
for (int i = 2; i <= n; ++i)
{
if (a[i].s <= r) ans = max(ans, min(r, a[i].t) - a[i].s);//取线段交集
if (a[i].t > r) l = a[i].s, r = a[i].t;
}
cout << ans << endl;
return 0;
}
线段覆盖加强版(51nod 3086)
同样中文题面不翻译。
这道题我们依旧考虑贪心。为了删尽可能少的线段使剩下的不相交,那么我们还是排序,之后只要每次选择右端点最大的删掉就可以了。原理和上一题类似。
不过这题有几个问题说明一下:第一点是数据规模很大,要用读入优化;其次这里两条线段仅端点重合不看作相交(我因为这玩意儿WA了两发);最后这题似乎还有 的dp做法,有时间了我会去研究一下带哥的代码。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2e6 + 10;
inline int Read()
{
int x = 0, f = 1; char c = getchar();
while (c > '9' || c < '0')
{
if (c == '-') f = -1; c = getchar();
}
while (c >= '0' && c <= '9')
{
x = x * 10 + c - '0'; c = getchar();
}
return x * f;
}
struct seg
{
int s, t;
};
bool cmp(seg a, seg b)
{
return a.s < b.s;
}
int main()
{
int n; cin >> n;
static seg a[MAXN];
for (int i = 1; i <= n; ++i)
{
a[i].s = Read(); a[i].t = Read();
if (a[i].s > a[i].t) swap(a[i].s, a[i].t);
}
sort(a + 1, a + 1 + n, cmp);
int ans = 0, l = a[1].s, r = a[1].t;
for (int i = 2; i <= n; ++i)
{
if (a[i].s < r)
{
if (a[i].t < r) l = a[i].s, r = a[i].t;
++ans;
}
else l = a[i].s, r = a[i].t;
}
cout << n - ans << endl;
return 0;
}
Air Conditioner(Codeforces Round #620 (Div. 2) C)
这题就是我最开始萌生写线段操作类题目总结的根源。当时因为第一次做这种题,在赛场上遇到直接懵逼了。还好后来灵光一闪想到了线段交集。可以说前面那么多铺垫就是为了解决这道题。(线段交集的集大成者www)
题意就是餐馆的起始温度是 度。餐馆里有个空调,每整数分钟时可以任意多次改变空调的状态。空调若制热则下一分钟温度+1,制冷则下一分钟温度-1。在 时刻会有客人进入,但客人要求进入时餐馆的温度在 之间。问是否能通过调整空调的状态满足所有顾客。客人进入的时间题目保证递增。
首先这题得想到去用线段的交集。其实还是稍微有点难想。空调的调节范围和客人的理想范围都是区间,可以从这方面入手想到线段操作。
我们从第零分钟开始模拟。不妨设初始温度为0。若第一个客人到达的时间为 ,则空调温度的可达范围为 。若此时该区间和第一个客人理想的温度无交集则直接跳出,否则我们可以计算出当前可达范围和客人理想温度范围之间的交集 。也就是说此时空调的温度可以是该交集间的任意值。
下面考虑第二个客人到达的时间为 。那么我们有 的时间来调节空调温度。此时空调温度的可达范围为 ,然后再和第二个客人理想温度范围取交集。
于是至此解题方案已经出来了。每次计算当前空调可达范围,并和下一个客人理想温度范围取交集,直到交集为空或输入结束。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 110;
struct seg
{
int s, t;
};
seg intersec(seg a, seg b)
{
return {max(a.s, b.s), min(a.t, b.t)};
}
int main()
{
int q; cin >> q;
while (q--)
{
int n, m, t[MAXN] = {0};
seg cus[MAXN];
cin >> n >> m;
for (int i = 1; i <= n; ++i) scanf("%d%d%d", &t[i], &cus[i].s, &cus[i].t);
seg range = {m, m};
for (int i = 1; i <= n; ++i)
{
range.s -= t[i] - t[i - 1], range.t += t[i] - t[i - 1];
range = intersec(range, cus[i]);
if (range.s > range.t)
{
cout << "NO" << endl;
goto OUT_LOOP;
}
}
cout << "YES" << endl;
OUT_LOOP:;
}
return 0;
}
而并集的实现相对比较复杂。我们仍然考虑贪心。(这里还要说一下,某些固定类型的贪心题也是有套路的。再埋个坑)
(再说一下,线段并集应该是基础的贪心例题,这里仅作为整理记录。理解的可以跳过。)
对于求一整个线段数组的并集,我们把线段数组先关于左端点排序。这样考虑并集的时候只要考虑左端点小于右端点的情况了。然后对整个线段数组进行遍历,若当前线段与目前正在处理的线段有交集,那么更新右端点最大值;若无交集,我们另外push一个新的线段进去。复杂度 。
bool cmp(seg a, seg b)
{
return a.s < b.s;
}
vector <seg> combine(seg *a, int size)
{
vector <seg> ans;
sort(a + 1, a + 1 + size, cmp);
int l = a[1].s, r = a[1].t;
for (int i = 2; i <= size; ++i)
{
if (a[i].s <= r) r = max(r, a[i].t);
else
{
ans.push_back({l, r});
l = a[i].s, r = a[i].t;
}
}
ans.push_back({l, r}); return ans;//注意把最后一个处理的线段也push进去
}
线段并集的题看了一下好像比较少,没找到有类似的题。之后如果还有会再补。
B、Nastya and Door
把所有可能为peak的点打上一个tag,之后把区间从头滑到尾就可以了。
注意区间的两端不能成为peak。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2e5 + 10;
int main()
{
int t; cin >> t;
while (t--)
{
int n, k; cin >> n >> k;
int a[MAXN] = {0};
for (int i = 1; i <= n; ++i) scanf("%d", a + i);
int tag[MAXN] = {0};
for (int i = 2; i < n; ++i) if (a[i - 1] < a[i] && a[i] > a[i + 1]) tag[i]++;
int peak = 0;
for (int i = 2; i < k; ++i) peak += tag[i];// r = k
int sum = peak, l = 1;
for (int i = k + 1; i <= n; ++i)//i = r, l = i - k + 1
{
peak = peak - tag[i - k + 1] + tag[i - 1];
if (peak > sum) sum = peak, l = i - k + 1;
}
cout << sum + 1 << ' ' << l << endl;
}
return 0;
}
C、Nastya and Strange Generator
这题有两个坑点。第一个是这个 和 都巨大,一开始我以为定义变量时给数组初始化要快一些,但这题如果每次都初始化是会T的。第一次写这题就T了,后来换了个快读模板卡到了998ms过了…后来才发现是初始化耗时太大。对于这种题目用vector比较好。第二个是这个题面实在是难读。我读了快二十分钟才看懂什么意思,在这稍微讲一下。
就是有个无聊的人买了个排列的生成器,生成规则如下:从1开始逐个生成数字。假设当前已生成完的排列为 , 的长度为 ,其中可能有空元素,就表示当前位置还没有填入元素。有个 数组, 表示 到 之间下标最小的空位置的下标。若从 开始后面的元素都被占满了,那 就未定义。举个例子,如 对应的 为 。之后再搞一个 数组, 表示 数组中等于 的元素个数。比如我举的这个例子的 数组就是 。若当前我们要生成的数为 ,那么 的位置就会取 数组里最大元素所对应的下标。若最大元素有多个那就随便选一个。问给你一个排列,能否通过这个生成器把它生成出来。
看起来真的很复杂。主要是 和 两个数组很抽象。那我们想一想通过这一通操作,最后所填的元素位置有什么规律。
因为当前填的位置是 中最大元素对应的下标;换句话说是 中的出现次数最多的那个值。
为了让 某个元素的出现次数最多,我们只要让这个元素之前连续填好的数的数量最多就可以了。可以这样考虑:对于连续的已经填好的元素,那么这一串填好的元素对应的 值都是相同的,就是这一串数末尾的那个空格。若这一串数中间有个空格,那么前面的一部分对应的 值就是之前那个空格了,不会是最后的空格。这么说可能有点抽象,拿纸画一画就好了。而若有多个位置前面连续填好的数的数量相同,那么就随便取一个。
分析到这里我们就可以直接把 和 两个数组扔掉了。下面用例子模拟一下。
起初排列为空。如果我们填入一个数变成 ,那么我们考虑最长的连续填好的数是 ,于是 就填在这串最长的数后面变成 。现在最长的连续填好的数是 ,那么 就填在这串数后面变成
。这时最长的是 ,但后面已经没位置填了,然后我们就可以在前面两个空着的位置随便选一个填 。
然后就发现这填数肯定是连着填的,填到不能填为止。
然后这样就可以模拟了。
一开始写的代码是对位置进行模拟,写得比较难看,后来发现有带哥这样写代码更简单。
找到这个规律之后再看原数组会发现从头扫到尾,如果相邻两数呈递增则每次一定是加一。递减则不用管。这样就很简单就能把程序写出来。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main()
{
int t; cin >> t;
while (t--)
{
int n; cin >> n;
vector <int> a(n + 1, 0);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
for (int i = 2; i <= n; ++i)
if (a[i] - a[i - 1] > 1)
{
cout << "No" << endl;
goto OUT_LOOP;
}
cout << "Yes" << endl;
OUT_LOOP:;
}
return 0;
}
*D、Nastya and Scoreboard
这D题也是一个很好的题。当时考虑的是dp,设 为前 个数加 根灯管能组成的最大数,那么 ,其中 表示的是 添加 根灯管所能组成的合法数字。
然后很开心的交了,发现WA。
后来回头一看,这玩意儿位数大得夸张,用ll肯定存不下。
然后把 换成string,空间爆了。然后又花了半个多小时改了个滚动数组,空间倒是没爆结果时间爆了。
后来想想确实,用 的话这赋值和修改操作都是 的,均摊下来到了 ,肯定爆了。
之后一气之下不做了。第二天看大哥的代码。
思路是这样的。既然直接用 数组存答案不现实,那我们可以这样考虑:利用 数组判断可行性,最后考虑构造答案。
但是这题还是有一个trick,就是定义 数组的时候和正常考虑的不太一样。
不过就让我们先用正常方式考虑 :前 个数用 个灯管能否组成合法的数字。初始化 。
然后 ,其中 同样表示 添加 根灯管能组成合法数字。之后看 是不是等于1就可以了。
因为要求最大的数值,所以我们从最高位开始考虑贪心。如果用贪心,我们从大到小枚举第一个数可能组成的数字,若能组成则跳出。但这里就出现问题了。假如我们第一个数字能构成 三个数字,那么我们肯定选择 ,但是这时我们却不知道选择 之后在剩下的数字中用剩下的灯管能否组成合法的数字。所以如果这么定义 只能再跑一遍 ,这样效率就很低了。
为了解决这个问题,我们思考一下修改 方程。填完当前这个数字,剩下了 个数字和 个灯管,我们想知道的是这剩下的东西能不能组成合法的数字。这样我们在从高到低枚举数字的时候就很容易判断可行性了。
所以,若我们把 方程定义为:后 个数用 个灯管能否组成合法的数字,问题就迎刃而解了。
转移方程为 ,初始化为 。倒着循环一遍就好了。
除了 方程以外,这题还可以考虑利用状压 的方法优化字符串的比较。把 串压成二进制,跑得飞快。
另外看到大哥的代码里用了__builtint_popcount,这个函数是计算某个数二进制下1的个数。学到了。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const string original[10] = {"1110111", "0010010", "1011101", "1011011", "0111010",
"1101011", "1101111", "1010010", "1111111", "1111011"};
const int MAXN = 2020, MAXK = 2020;
int main()
{
int dig[10] = {0};
for (int i = 0; i < 10; ++i)
for (int j = 0; j < 7; ++j)
if (original[i][j] == '1') dig[i] |= (1 << j);
int n, k; cin >> n >> k;
string x; int light[MAXN] = {0};
for (int i = 1; i <= n; ++i)
{
cin >> x;
for (int j = 0; j < 7; ++j)
if (x[j] == '1') light[i] |= (1 << j);
}
static int dp[MAXN][MAXK] = {0}; dp[n + 1][0] = 1;
for (int i = n; i >= 1; --i)
{
for (int t = 0; t < 10; ++t)
if ((dig[t] & light[i]) == light[i])//如果当前的灯可以组成数字
{
int diff = __builtin_popcount(dig[t] ^ light[i]);//计算1的个数
for (int j = diff; j <= k; ++j)//当前至少用diff个灯管,枚举使用灯管数j
dp[i][j] |= dp[i + 1][j - diff];
}
}
if (dp[1][k])//可以组成数字
{
//下面考虑贪心构造
int used = k;
for (int i = 1; i <= n; ++i)//两回啊两回
{
for (int t = 9; t >= 0; --t)
if ((dig[t] & light[i]) == light[i])
{
int diff = __builtin_popcount(dig[t] ^ light[i]);//计算1的个数
if (dp[i + 1][used - diff])
{
cout << t; used -= diff; break;
}
}
}
cout << endl;
}
else cout << -1 << endl;
return 0;
}