Codeforces补题记录(2)

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里的坑给填上。

本来这玩意我都没打算写,结果这两天做题发现这种类型的题目居然还挺多,而且如果第一次做碰到一些有点绕的题目可能会想不到。然后就干脆全部归类进来吧。

题目的意思是有一袋米,里面米粒重量在 [ a b , a + b ] [a-b,a+b] 之间,问 n n 粒米的重量能否在 [ c d , c + d ] [c-d,c+d] 之间。

先上代码。

#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;
}

线段操作问题——顾名思义,就是对一堆线段进行操作。这里的线段和线段树的线段还不太一样,线段树的线段主要是对某段区间进行查询、修改等操作,而这里就是单纯线段上的操作,主要包括交集、并集两种。而且考虑的基本都是闭区间。半开半闭区间讨论起来比较麻烦,而且离散域上是可以把开转成闭的。

而且线段操作类问题往往和贪心有很大关系。

首先定义线段 [ s , t ] [s,t] s t s\le t 。和区间不一样,这里左端点可以和右端点相等。

之后讲交集操作。这个比较好理解。

两条线段 [ s 1 , t 1 ] , [ s 2 , t 2 ] [s1,t1],[s2,t2] 有交集的充要条件是 s 1 t 2 & & s 2 t 1 s1\le t2 \&\& s2\le t1 。可以这么考虑:把左边的线段固定长度慢慢向右滑动,然后考虑产生交集的两种极限情况。之后再变动左边线段的长度,会发现长度对结果没有影响。如果是第一次接触这个结论可能要想象一下,不过也相对简单。

有了这个结论之后我们就能够判断线段是否有交集了。但是这还不够,我们想要求出这个交集。

在上述结论的基础上,我们依旧考虑线段滑动相交,发现交集的左端点只可能由两条线段的左端点之一组成,右端点只可能由右端点之一组成。于是交集为 [ m a x ( s 1 , s 2 ) , m i n ( t 1 , t 2 ) ] [max(s1,s2),min(t1,t2)]

有了交集的表达式,判断有无交集就可以从代数角度理解了。交集要存在,即 m a x ( s 1 , s 2 ) m i n ( t 1 , t 2 ) max(s1,s2)\le min(t1,t2) 。把括号逐层打开: { s 1 m i n ( t 1 , t 2 ) s 2 m i n ( t 1 , t 2 ) \begin{cases} s1\le min(t1,t2) \\ s2 \le min(t1,t2)\end{cases} { s 1 t 1 s 1 t 2 s 2 t 1 s 2 t 2 \begin{cases} s1\le t1 \\ s1\le t2 \\ s2 \le t1 \\ s2\le t2 \end{cases}

第一个和第四个不等式显然是成立的,于是只剩下中间的两个:正好是上面我们推导出的充要条件。

所以判断有无交集时直接代交集的代数表达式,若左端点小于等于右端点则直接返回线段,否则返回空集。

有了这么多铺垫,再回头看这A题的代码就显然中的显然了。所有米粒的重量都在 [ n ( a b ) , n ( a + b ) ] [n*(a-b),n*(a+b)] 之间,我们只要判断一下这个区间和 [ c d , c + d ] [c-d,c+d] 有没有交集就OK了。

下面给几道例题。

Down the Pyramid(GCPC18 D)

题意就是给你长度为 n n 的数列 a a ,问能构造出多少种长为 n + 1 n+1 的数列,使相邻两项和与 a a 中对应元素相等。

我们设新数列为 b b 。很容易发现,一旦 b [ 1 ] b[1] 确定,整个数列都确定了。因此问题转化为求出 b [ 1 ] b[1] 所有的可能取值。

然后我们可以尝试找一下规律。

因为 b b 是非负数列,于是 b [ 1 ] 0 b[1]\ge 0

容易计算得 b [ 2 ] = a [ 1 ] b [ 1 ] b[2]=a[1]-b[1] ,于是 b [ 1 ] a [ 1 ] b[1]\le a[1]

b [ 3 ] = a [ 2 ] a [ 1 ] + b [ 1 ] b[3]=a[2]-a[1]+b[1] ,于是 b [ 1 ] a [ 1 ] a [ 2 ] b[1]\ge a[1]-a[2]

b [ 4 ] = a [ 3 ] a [ 2 ] + a [ 1 ] b [ 1 ] b[4]=a[3]-a[2]+a[1]-b[1] ,于是 b [ 1 ] a [ 1 ] a [ 2 ] + a [ 3 ] b[1]\le a[1]-a[2]+a[3]

这之后的就不再写了。可以看到当把 b [ 1 ] b[1] 放在不等号的左边时,不等号的方向和不等号右端的值都是有规律的。

于是我们就可以看成若干个无穷区间的交集,对于小于号看作右端点取最小,大于号看作左端点取最大。

最后若得到了空集输出0,否则输出 r l + 1 r-l+1

#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了两发);最后这题似乎还有 O ( n ) O(n) 的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)

题意就是餐馆的起始温度是 m m 度。餐馆里有个空调,每整数分钟时可以任意多次改变空调的状态。空调若制热则下一分钟温度+1,制冷则下一分钟温度-1。在 t i t_i 时刻会有客人进入,但客人要求进入时餐馆的温度在 [ l , r ] [l,r] 之间。问是否能通过调整空调的状态满足所有顾客。客人进入的时间题目保证递增。

首先这题得想到去用线段的交集。其实还是稍微有点难想。空调的调节范围和客人的理想范围都是区间,可以从这方面入手想到线段操作。

我们从第零分钟开始模拟。不妨设初始温度为0。若第一个客人到达的时间为 t 1 t_1 ,则空调温度的可达范围为 [ t 1 , t 1 ] [-t_1,t_1] 。若此时该区间和第一个客人理想的温度无交集则直接跳出,否则我们可以计算出当前可达范围和客人理想温度范围之间的交集 [ s , t ] [s,t] 。也就是说此时空调的温度可以是该交集间的任意值。

下面考虑第二个客人到达的时间为 t 2 t_2 。那么我们有 t 2 t 1 t_2-t_1 的时间来调节空调温度。此时空调温度的可达范围为 [ s ( t 2 t 1 ) , t + ( t 2 + t 1 ) ] [s-(t_2-t_1),t+(t_2+t_1)] ,然后再和第二个客人理想温度范围取交集。

于是至此解题方案已经出来了。每次计算当前空调可达范围,并和下一个客人理想温度范围取交集,直到交集为空或输入结束。

#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一个新的线段进去。复杂度 O ( n l o g n ) O(nlogn)

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

这题有两个坑点。第一个是这个 n n t t 都巨大,一开始我以为定义变量时给数组初始化要快一些,但这题如果每次都初始化是会T的。第一次写这题就T了,后来换了个快读模板卡到了998ms过了…后来才发现是初始化耗时太大。对于这种题目用vector比较好。第二个是这个题面实在是难读。我读了快二十分钟才看懂什么意思,在这稍微讲一下。

就是有个无聊的人买了个排列的生成器,生成规则如下:从1开始逐个生成数字。假设当前已生成完的排列为 a [ ] a[] a a 的长度为 n n ,其中可能有空元素,就表示当前位置还没有填入元素。有个 r r 数组, r [ i ] r[i] 表示 a [ i ] a[i] a [ n ] a[n] 之间下标最小的空位置的下标。若从 i i 开始后面的元素都被占满了,那 r [ i ] r[i] 就未定义。举个例子,如 [ × , 3 , × , 1 , 2 ] [\times,3,\times,1,2] 对应的 r r [ 1 , 3 , 3 , × , × ] [1,3,3,\times,\times] 。之后再搞一个 c o u n t count 数组, c o u n t [ i ] count[i] 表示 r r 数组中等于 i i 的元素个数。比如我举的这个例子的 c o u n t count 数组就是 [ 1 , 0 , 2 , 0 , 0 ] [1,0,2,0,0] 。若当前我们要生成的数为 x x ,那么 x x 的位置就会取 c o u n t count 数组里最大元素所对应的下标。若最大元素有多个那就随便选一个。问给你一个排列,能否通过这个生成器把它生成出来。

看起来真的很复杂。主要是 r r c o u n t count 两个数组很抽象。那我们想一想通过这一通操作,最后所填的元素位置有什么规律。

因为当前填的位置是 c o u n t count 中最大元素对应的下标;换句话说是 r r 中的出现次数最多的那个值。

为了让 r r 某个元素的出现次数最多,我们只要让这个元素之前连续填好的数的数量最多就可以了。可以这样考虑:对于连续的已经填好的元素,那么这一串填好的元素对应的 r r 值都是相同的,就是这一串数末尾的那个空格。若这一串数中间有个空格,那么前面的一部分对应的 r r 值就是之前那个空格了,不会是最后的空格。这么说可能有点抽象,拿纸画一画就好了。而若有多个位置前面连续填好的数的数量相同,那么就随便取一个。

分析到这里我们就可以直接把 r r c o u n t count 两个数组扔掉了。下面用例子模拟一下。

起初排列为空。如果我们填入一个数变成 [ × , × , 1 , × , × ] [\times,\times,1,\times,\times] ,那么我们考虑最长的连续填好的数是 1 1 ,于是 2 2 就填在这串最长的数后面变成 [ × , × , 1 , 2 , × ] [\times,\times,1,2,\times] 。现在最长的连续填好的数是 1 , 2 1,2 ,那么 3 3 就填在这串数后面变成 [ × , × , 1 , 2 , 3 ] [\times,\times,1,2,3]

。这时最长的是 1 , 2 , 3 1,2,3 ,但后面已经没位置填了,然后我们就可以在前面两个空着的位置随便选一个填 4 4

然后就发现这填数肯定是连着填的,填到不能填为止。

然后这样就可以模拟了。

一开始写的代码是对位置进行模拟,写得比较难看,后来发现有带哥这样写代码更简单。

找到这个规律之后再看原数组会发现从头扫到尾,如果相邻两数呈递增则每次一定是加一。递减则不用管。这样就很简单就能把程序写出来。

#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,设 f [ i ] [ j ] f[i][j] 为前 i i 个数加 j j 根灯管能组成的最大数,那么 f [ i ] [ j ] = m a x { f [ i 1 ] [ j k ] 10 + a [ i k ] } f[i][j]=max\{f[i-1][j-k]*10+a[i_k]\} ,其中 a [ i k ] a[i_k] 表示的是 a [ i ] a[i] 添加 k k 根灯管所能组成的合法数字。

然后很开心的交了,发现WA。

后来回头一看,这玩意儿位数大得夸张,用ll肯定存不下。

然后把 f f 换成string,空间爆了。然后又花了半个多小时改了个滚动数组,空间倒是没爆结果时间爆了。

后来想想确实,用 s t r i n g string 的话这赋值和修改操作都是 O ( n ) O(n) 的,均摊下来到了 O ( n 2 k ) O(n^2k) ,肯定爆了。

之后一气之下不做了。第二天看大哥的代码。

思路是这样的。既然直接用 d p dp 数组存答案不现实,那我们可以这样考虑:利用 d p dp 数组判断可行性,最后考虑构造答案。

但是这题还是有一个trick,就是定义 d p dp 数组的时候和正常考虑的不太一样。

不过就让我们先用正常方式考虑 d p [ i ] [ j ] dp[i][j] :前 i i 个数用 j j 个灯管能否组成合法的数字。初始化 d p [ 0 ] [ 0 ] = 0 dp[0][0]=0

然后 d p [ i ] [ j ] = d p [ i 1 ] [ j k ] dp[i][j]|=dp[i-1][j-k] ,其中 k k 同样表示 a [ i ] a[i] 添加 k k 根灯管能组成合法数字。之后看 d p [ n ] [ k ] dp[n][k] 是不是等于1就可以了。

因为要求最大的数值,所以我们从最高位开始考虑贪心。如果用贪心,我们从大到小枚举第一个数可能组成的数字,若能组成则跳出。但这里就出现问题了。假如我们第一个数字能构成 9 , 8 , 3 9,8,3 三个数字,那么我们肯定选择 9 9 ,但是这时我们却不知道选择 9 9 之后在剩下的数字中用剩下的灯管能否组成合法的数字。所以如果这么定义 d p dp 只能再跑一遍 d f s dfs ,这样效率就很低了。

为了解决这个问题,我们思考一下修改 d p dp 方程。填完当前这个数字,剩下了 i i 个数字和 j j 个灯管,我们想知道的是这剩下的东西能不能组成合法的数字。这样我们在从高到低枚举数字的时候就很容易判断可行性了。

所以,若我们把 d p dp 方程定义为:后 i i 个数用 j j 个灯管能否组成合法的数字,问题就迎刃而解了。

转移方程为 d p [ i ] [ j ] = d p [ i + 1 ] [ j k ] dp[i][j]|=dp[i+1][j-k] ,初始化为 d p [ n + 1 ] [ 0 ] = 1 dp[n+1][0]=1 。倒着循环一遍就好了。

除了 d p dp 方程以外,这题还可以考虑利用状压 d p dp 的方法优化字符串的比较。把 01 01 串压成二进制,跑得飞快。

另外看到大哥的代码里用了__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;
}

猜你喜欢

转载自blog.csdn.net/qq_36000896/article/details/105953534