abc339 整场详解

第一篇整场题解纪念我第一次 AK 的 abc!

A

从后往前找到第一个 '.' 然后输出 '.' 到字符串结尾构成的字符串。

cpp 复制代码
#include <iostream>
using namespace std;

int main(int argc, const char * argv[]) {
    string str;
    cin >> str;
    int len = (int)str.length();
    string out;
    for (int i = len - 1; i >= 0; --i) {
        if (str[i] == '.') {
            break;
        }
        out += str[i];
    }
    reverse(out.begin(), out.end());
    cout << out << endl;
    return 0;
}

B

用 ways 数组和 way 变量控制方向是一个快速模拟技巧

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

const int N = 1e2 + 1;
const int ways[4][2] = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};

char str[N][N];
int a[N];

int main(int argc, const char * argv[]) {
    int n, m, k;
    scanf("%d%d%d", &n, &m, &k);
    memset(str, '.', sizeof(str));
    int x = 1, y = 1, way = 0;
    while (k--) {
        if (str[x][y] == '.') {
            str[x][y] = '#';
            (++way) %= 4;
        } else {
            str[x][y] = '.';
            ((--way) += 4) %= 4;
        }
        x += ways[way][0]; y += ways[way][1];
        if (y == 0) {
            y = m;
        }
        if (x == 0) {
            x = n;
        }
        if (y == m + 1) {
            y = 1;
        }
        if (x == n + 1) {
            x = 1;
        }
    }
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            putchar(str[i][j]);
        }
        putchar('\n');
    }
    return 0;
}

C

注意到一开始可能的最小的在车上的人数就是 a 数组前缀和数组中的最小值的相反数和 0 的较大值。

cpp 复制代码
#include <iostream>
#include <algorithm>
using namespace std;
typedef long long ll;

const int N = 2e5 + 1;

ll s[N];

int main(int argc, const char * argv[]) {
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) {
        int x;
        scanf("%d", &x);
        s[i] = s[i - 1] + x;
    }
    printf("%lld\n", max(0ll, -*min_element(s + 1, s + n + 1)) + s[n]);
    return 0;
}

D

思路

一看到最短路,就想到 bfs。那有两个人,怎么 bfs 呢?最暴力的想法就是记 \(dis[x_1][y_1][x_2][y_2]\) 表示初始状态到第一个玩家在 {\(x_1\), \(y_1\)}, 第二个玩家在 {\(x_2\), \(y_2\)} 的最短路。每次枚举四个方向两个玩家尝试一起走,如果有一个玩家面前不能走留在原地即可。最后答案枚举 \(i\) 和 \(j\),取 \(dis[i][j][i][j]\) 中的最小值即可。时空复杂度 \(O(n^4)\)。

实现

提供我赛时简洁实现

cpp 复制代码
#include <iostream>
#include <cstring>
#include <climits>
#include <queue>
using namespace std;

struct Pos {
    int x, y;
    
    bool operator == (const Pos &p) const {
        return x == p.x && y == p.y;
    }
    Pos operator + (const int a[2]) const {
        Pos hero = *this;
        hero.x += a[0]; hero.y += a[1];
        return hero;
    }
    void operator += (const int a[2]) {
        *this = *this + a;
    }
};
typedef pair<Pos, Pos> ppp;

const int N = 61;
const int ways[4][2] = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};

char str[N][N + 1];
int dis[N][N][N][N];

int n;
inline bool inArea(const Pos &p) {
    return p.x >= 1 && p.x <= n && p.y >= 1 && p.y <= n;
}

int bfs(const Pos &p1, const Pos &p2) {
    memset(dis, 127, sizeof(dis));
    
    dis[p1.x][p1.y][p2.x][p2.y] = 0;
    
    queue<ppp> que;
    que.push({p1, p2});
    
    while (!que.empty()) {
        Pos nowP1 = que.front().first, nowP2 = que.front().second; que.pop();
        int &nowDis = dis[nowP1.x][nowP1.y][nowP2.x][nowP2.y];
        for (int i = 0; i < 4; ++i) {
            Pos newP1 = nowP1; newP1 += ways[i];
            if (!inArea(newP1) || str[newP1.x][newP1.y] == '#') {
                newP1 = nowP1;
            }
            Pos newP2 = nowP2; newP2 += ways[i];
            if (!inArea(newP2) || str[newP2.x][newP2.y] == '#') {
                newP2 = nowP2;
            }
            int &newDis = dis[newP1.x][newP1.y][newP2.x][newP2.y];
            if (nowDis + 1 < newDis) {
                newDis = nowDis + 1;
                que.push({newP1, newP2});
            }
        }
    }
    
    int ans = 1 << 30;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= n; ++j) {
                ans = min(ans, dis[i][j][i][j]);
            }
        }
    }
    return ans < 1 << 30 ? ans : -1;
}

int main(int argc, const char * argv[]) {
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) {
        scanf("%s", str[i] + 1);
    }
    
    vector<Pos> ps;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (str[i][j] == 'P') {
                ps.push_back({i, j});
            }
        }
    }
    Pos p1 = ps.front(), p2 = ps.back();
    
    printf("%d\n", bfs(p1, p2));
    return 0;
}

E

思路

首先考虑 \(O(n^2)\) dp。设 \(f[i]\) 以 \(i\) 结尾的最长合法子序列的长度。于是很自然列出转移方程为 \(f[i] = max\{[i < j \wedge \lvert a[i] - a[j] \rvert \le d] \times f[j] + 1\}\)。接下来我们就想怎么优化这个式子。

诶也先别急着一步优化成复杂度正确。我们想一下,如果它是一道 OI 题,出题人是不是有可能放一个值域做法部分分?我们尝试思考一下 \(O(nd)\) 的 dp 怎么做。我们在从左往右做的过程中,设 \(g[i]\) 为使得 \(a[p] = i\) 的 \(f[p]\) 的最大值,那么 \(f[i] = max([j\in[a[i] - d, a[i] + d]] \times g[j] + 1)\),之后再用 \(f[i]\) 试着更新 \(g[a[i]]\) 即可。但因为如果序列中有两个数相同,那么后出现的一定能拼在前面那个数的后面,所以必定更优,直接赋值即可。从左往右做的过程中自然保证了 \(i < j\),所以限制条件都满足,正确。时间复杂度为 \(O(nd)\)。

我们再想一下 \(O(nd)\) 中解法我们在干一件什么事。\(f[i] = max([j\in[a[i] - d, a[i] + d]] \times g[j] + 1)\),也就是查询 \(g\) 一段区间中的最大值。\(g[a[i]] = f[i]\),也就是单点修改。

区间最大值,单点修改,我们想到什么?没错!这道题现在就被我们转换成了一道比较基础的线段树运用来优化 dp,lazytag 都用不到。每次查询一次最大值再单点修改,时间复杂度 \(O(n \log d)\)。

代码

提供一个很简单的实现

cpp 复制代码
#include <iostream>
using namespace std;

const int V = 5e5;

int tree[(V + 1) << 2];
inline void pushup(int pos) {
    tree[pos] = max(tree[pos << 1], tree[pos << 1 | 1]);
}
void pointModify(int x, int v, int l = 1, int r = V, int pos = 1) {
    if (l == r) {
        tree[pos] = max(tree[pos], v);
        return ;
    }
    int mid = (l + r) >> 1;
    if (x <= mid) {
        pointModify(x, v, l, mid, pos << 1);
    } else {
        pointModify(x, v, mid + 1, r, pos << 1 | 1);
    }
    pushup(pos);
}
int rangeMax(int x, int y, int l = 1, int r = V, int pos = 1) {
    if (x <= l && r <= y) {
        return tree[pos];
    }
    int res = 0;
    int mid = (l + r) >> 1;
    if (x <= mid) {
        res = max(res, rangeMax(x, y, l, mid, pos << 1));
    }
    if (y >= mid + 1) {
        res = max(res, rangeMax(x, y, mid + 1, r, pos << 1 | 1));
    }
    return res;
}

int main(int argc, const char * argv[]) {
    int n, d;
    scanf("%d%d", &n, &d);
    for (int i = 1; i <= n; ++i) {
        int x;
        scanf("%d", &x);
        pointModify(x, 1 + rangeMax(max(1, x - d), min(x + d, V)));//实际上用不到 a 和 dp 数组,线段树上面就存了以前状态的信息
    }
    printf("%d\n", rangeMax(1, V));
    return 0;
}

F

思路

值域实在太大了,想要用调和级数之类的预处理肯定是不行的,所以我们只能在判定上做文章。注意到 \(n = 10^3\),也就是说枚举二元组是允许的。如果说枚举每对相乘 \(O(n^3)\) 在 atc 神机上还能卡一卡,那么开一个桶存每个乘积出现次数是无法避免空间爆炸的。

看似没有任何办法了,但我们可以这样想:既然每次回答一个二元组乘积是否等于一个数,要做到 100% 正确是很难的,但是我们如果稍微放宽一点要求,做到 99.999999999% 正确,那么至少在这道题上面是很够用的,而且做法会简单很多。

于是我们可以自然地想到哈希的思想。你可能说,这也可以哈希?但别急,先听。我们选定一个模数 \(P\),则 \((a\times b) \% P = c \% P\) 是 \(a \times b = c\) 的必要不充分条件。如果有 \(a \times b = c\),那么一定有 \((a \times b) \% P = c \% P\),反之亦然:如果没有 \((a \times b) \% P = c \% P\),也一定没有 \(a \times b = c\)。这是必要性。但是没有充分性,也就是如果有 \((a \times b) \% P = c \% P\),那么不一定有 \(a \times b = c\),反之亦然。哈希就是把难判定充分必要的问题转换成简单极高概率判定必要但是不充分的问题的思想,并不只是作用于字符串或者哈希表的运用上。

如果此题仍然使用 \(10^9\) 级别的素数,正确性显然是不够的。此题因为是对乘积做哈希,如果不对着卡在模意义下分布是较为均匀的。\(\frac{10^6}{10^9}\),碰撞率是不是看起来就很紧张。所以可以扩大模数规模或者使用多哈希。推荐使用多哈希,因为这样可以避免对着一个特定素数卡,所以这道题取 3 个 \(10^9\) 级别的素数或者 2 个 \(10^{18}\) 的素数是较为保险的。在打 CF 的时候为了避免被模数对着 hack,更推荐使用随机而不是固定模数。

实现

赛时用了 \(10^{18} + 3\) 当模数单哈希就过了,这里提供一种写法优美双哈希的实现。

cpp 复制代码
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
typedef unsigned long long ull;

const int N = 1e3 + 1;
const ull p1 = 200820252011, p2 = 201020080601;

template <class T>
pair<T, T> uread() {
    char c = getchar();
    while (c < '0' || c > '9') {
        c = getchar();
    }
    T num1 = 0, num2 = 0;
    while (c >= '0' && c <= '9') {
        num1 = ((num1 << 1) + (num1 << 3) + (c ^ '0')) % p1;
        num2 = ((num2 << 1) + (num2 << 3) + (c ^ '0')) % p2;//边读入边取模
        c = getchar();
    }
    return {num1, num2};
}

pair<ull, ull> a[N];

int main(int argc, const char * argv[]) {
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) {
        a[i] = uread<ull>();
    }
    
    vector<pair<ull, ull>> vec;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= n; ++j) {
            vec.push_back({(a[i].first * (unsigned __int128)a[j].first) % p1, (a[i].second * (unsigned __int128)a[j].second) % p2});//也可以使用快速乘
        }
    }
    sort(vec.begin(), vec.end());
    ull ans = 0;
    for (int i = 1; i <= n; ++i) {
        ans += upper_bound(vec.begin(), vec.end(), a[i]) - lower_bound(vec.begin(), vec.end(), a[i]);//等于 a[i] 的有多少个
    }
    printf("%llu\n", ans);
    return 0;
}

G

如果把题目弱化,每次询问整个序列 \(\leq x\) 的数的和,怎么做?

这不是普及组选手必须要会的内容吗?把整个数组排序,每次二分找到最后一个小于等于 \(x\) 的位置 \(i\),答案就是前 \(i\) 个数的和,先预处理一遍前缀和就行。

很可惜,这道题有区间,不能对整个数组排序。那怎么想办法把上述思想推广到这道题呢?我们想一下,如果一个子区间(下面称为块)被 \(l,r\) 完全包括,那么对它排序肯定是不影响的。所以我们可以在整个序列上划分成若干个大小相同的块,每个块先预处理:把区间内每个数排序后做前缀和,在询问的时候如果整块都被区间包括了,那么可以直接在整块上按照弱化版的思路获取答案。那如果一些数所在的块没有被完整包含,那么就直接暴力统计答案。

那如何调整每一块的长度可以获得最优的复杂度呢?我们把每一块的块长设为 \(b\) 再进行分析。

首先来看预处理的复杂度。我们将整个序列分成了 \(\frac{n}{b}\) 块,每一块进行了一次复制原数组,一次排序,一次统计前缀和。所以时间复杂度是 \(O(\frac{n}{b} \times b + (\frac{n}{b} \times \log \frac{n}{b}) \times b + \frac{n}{b} \times b)\) = \(O(n \log n)\),这里与 \(b\) 无关。

接下来来看单次询问的复杂度。每次询问时最坏情况会暴力统计 \(2b\) 个数,也就是把左端点正好是一块的起点,右端点正好是一块的终点。块最坏情况下有 \(\frac{n}{b}\) 个,每块二分一下,复杂度为每次 \(\log \frac{n}{b}\)。所以单次询问复杂度为 \(O(b + \frac{n}{b} \times \log \frac{n}{b})\)。

我们发现只有询问的复杂度和 \(b\) 相关,所以我们来分析这一块。我们再看一遍式子:\(O(b + \frac{n}{b} \times \log \frac{n}{b})\)。注意到随着 \(b\) 的减小,\(\frac{n}{b} \times \log \frac{n}{b}\) 是越大的。如果我们保证了一个小另一个大,或者一个大另一个小,复杂度上都是不优的。因为出题人可以出比较大的极端情况。所以最优时,我们应该将两个取等。所以解出 \(b = \frac{n}{b} \times \log \frac{n}{b}\),即可得出最优块长。为了简便我们把 \(\log \frac{n}{b}\) 看成 \(\log n\),原式变化为 \(b = \frac{n}{b} \times \log n\),两边同时乘 \(b\),得 \(b^2 = n \log n\),所以最优块长 \(b\) 定为 \(\sqrt{n \log n}\) 时间复杂度最优,为 \(O(n \log n + q \sqrt{n \log n})\)。空间复杂度为 \(O(n)\)。这种把序列分成若干块,每一块预处理,询问时对于整块用预处理的结果查询,散块暴力,平衡预处理、散块暴力、整块查询的思想就叫做分块。

实现

cpp 复制代码
#include <iostream>
#include <algorithm>
#include <vector>
#include <cmath>
using namespace std;
typedef long long ll;

template <class T>
T uread() {
    char c = getchar();
    while (c < '0' || c > '9') {
        c = getchar();
    }
    T num = 0;
    while (c >= '0' && c <= '9') {
        num = (num << 1) + (num << 3) + (c ^ 48);
        c = getchar();
    }
    return num;
}

const int N = 2e5 + 1;
const int B = 1877 + 1;

int a[N], bel[N];//第 i 个数属于的块的编号
vector<int> bNum[107 + 1];//每个块排好序的数字们
ll preSum[107 + 1][B];//第 i 个块前 j 个数的和

int main(int argc, const char * argv[]) {
    int n = uread<int>();
    int blockSize = ceil(sqrt(n * log2(n)));//最大为 1877
    for (int i = 1; i <= n; ++i) {
        scanf("%d", &a[i]);
        bNum[bel[i] = (i + blockSize - 1) / blockSize].push_back(a[i]);
    }
    for (int i = 1; i <= bel[n]/*最大为 107*/; ++i) {
        sort(bNum[i].begin(), bNum[i].end());
        int len = (int)bNum[i].size();
        for (int j = 0; j < len; ++j) {
            preSum[i][j] = preSum[i][j - 1] + bNum[i][j];
        }
    }
    
    int m = uread<int>();
    ll last = 0;
    for (int _ = 1; _ <= m; ++_) {
        ll A = uread<ll>(), B = uread<ll>(), Y = uread<ll>();

        int l = (int)(A ^ last), r = (int)(B ^ last), x = (int)(Y ^ last);
        if (bel[l] == bel[r]) {//同一块直接暴力
            ll ans = 0;
            for (int i = l; i <= r; ++i) {
                if (a[i] <= x) {
                    ans += a[i];
                }
            }
            printf("%lld\n", (last = ans));
            continue;
        }
        ll ans = 0;
        for (int i = l; bel[i] == bel[l]; ++i) {
            if (a[i] <= x) {
                ans += a[i];
            }
        }//解决左端点散块
        for (int i = bel[l] + 1; i < bel[r]; ++i) {
            int pos = (int)(upper_bound(bNum[i].begin(), bNum[i].end(), x) - bNum[i].begin());
            if (pos != 0) {//注意别写越界
                ans += preSum[i][pos - 1];
            }
        }//解决整块
        for (int i = r; bel[i] == bel[r]; --i) {
            if (a[i] <= x) {
                ans += a[i];
            }
        }//解决右端点散块
        printf("%lld\n", (last = ans));
    }
    return 0;
}

总结

这场比赛总体来说还是比较 edu 的。打好 abc 的技巧就在于多看题,多做题,多总结,多思考为什么要这样做。这样以后看到相似题才能很快地转换过去。我这次能 AK abc 的原因可能也有运气------每一道题我都见过的相似的题并弄懂思考明白了。