2021牛客寒假算法基础集训营2 解题补题报告
A题 牛牛与牛妹的RMQ
单调栈+线段树+树状数组,数据结构小综合
B题 牛牛抓牛妹
分层图+最短路树+状态压缩
C题 牛牛与字符串border
思维题,比较考验智商,溜了溜了
D题 牛牛与整除分块 (找规律)
对于给定的 \(n\),显然小于 \(\sqrt{n}\) 的部分是连续的,我们可以直接硬数。
大于 \(\sqrt{n}\) 的,仔细研究会发现,它们和小于 \(\sqrt{n}\) 的具有某种对称性,具体证明略(因为我是打表看出来的)。
不过有一个特殊点需要考虑:当 \(n\) 是一个完全平方数的时候,需要特判一下(我暴力和规律的程序都没注意到,难怪对拍了一个小时都没拍出问题来)
#include<bits/stdc++.h>
using namespace std;
long long solve(long long n, long long x)
{
long long k = n / x, s = sqrt(n);
if (x <= s) return x;
else if (s * s == n) return 2 * s - k;
else return s + n /(s + 1) + 1 - k;
}
int main()
{
long long T, n, x;
scanf("%lld", &T);
while (T--) {
scanf("%lld%lld", &n, &x);
printf("%lld\n", solve(n, x));
}
return 0;
}
E题 牛牛与跷跷板 (BFS+分组思想+双指针)
这题显然是一个BFS(边权全部为 \(1\) 的最短路),但是朴素建图的复杂度是 \(O(n^2)\) ,显然不行。
我们注意到,两个板子相邻,要么是左右相邻(\(y\) 相同),要么是上下相邻(\(y\) 相差 \(1\)),这意味着我们可以对我们的 \(O(n^2)\) 建图算法进行优化。恰好 \(0 \leq y_i \leq 10^5\) ,那么我们显然可以将所有跷跷板按照 \(y_i\) 分组,然后处理即可。
-
左右相邻
在每一组里面线性处理即可,所有组处理完的总复杂度为 \(O(n)\)
-
上下相邻
类似于双指针(尺取法),\(i,j\) 根据情况自行推进(说起来轻松,但是写起来嘛),复杂度 \(O(n)\)
建完图之后直接 \(BFS\) 即可,复杂度 \(O(n)\) 。
#include<bits/stdc++.h>
using namespace std;
const int N = 100010;
int n;
struct Block {
int id, l, r;
bool operator < (const Block &rhs) const {
return r < rhs.r;
}
};
vector<Block> a[N];
bool can(Block u, Block v) {
if (u.r > v.r) swap(u, v);
return v.l < u.r;
}
//
namespace BFS {
vector<int> e[N];
void addEdge(int u, int v) {
e[u].push_back(v);
e[v].push_back(u);
}
int d[N];
queue<int> q;
void solve() {
memset(d, -1, sizeof(d));
d[1] = 0, q.push(1);
while (!q.empty()) {
int x = q.front(); q.pop();
for (int i = 0; i < e[x].size(); ++i) {
int to = e[x][i];
if (d[to] == -1)
d[to] = d[x] + 1, q.push(to);
}
}
}
}
int main()
{
//
scanf("%d", &n);
for (int i = 1; i <= n; ++i) {
int y, l, r;
scanf("%d%d%d", &y, &l, &r);
a[y].push_back((Block){i, l, r});
}
//
for (int i = 0; i < N; ++i)
sort(a[i].begin(), a[i].end());
//
for (int h = 0; h < N; ++h)
for (int k = 1; k < a[h].size(); ++k)
if (a[h][k - 1].r == a[h][k].l)
BFS::addEdge(a[h][k - 1].id, a[h][k].id);
//
for (int h = 0; h < N - 1; ++h) {
int i = 0, j = 0;
while (i < a[h].size() && j < a[h + 1].size()) {
if (can(a[h][i], a[h + 1][j]))
BFS::addEdge(a[h][i].id, a[h + 1][j].id);
a[h][i] < a[h + 1][j]? ++i : ++j;
}
}
//
BFS::solve();
printf("%d", BFS::d[n]);
return 0;
}
F题 牛牛与交换排序 (双端队列)
这题思路有点小烦,细节有点多,我直接给一个样例:
对于 \(\text{5 2 1 4 3}\),注意到第一位不是 \(1\),那么我们必须将 \(1\) 反转到第一位(必须这时候反转,不然后面反转不了),这一次的区间长度为 \(3\) ,然后变为 \(\text{1 2 5 4 3}\) 。
第二位就是 \(2\),可以跳过。
第三位不是 \(3\),那么我们必须将 \(3\) 反转到第三位,这次区间长度是 \(3\),然后变为 \(\text{1 2 3 4 5}\)。
第四位和第五位正常,略。
那么我们的任务就是不断对某个区间进行反转,而且得随时掌握每个数的所在位置。
前一个暴力 \(O(n^2)\),用 \(\text{splay}\) 可以降到 \(O(n\log n)\),后一个不知道能不能用 \(\text{splay}\) 优化下来。(不管咋样,都不是很可做)。
其实我们可以先预处理出每次操作的长度 \(k\) ,这样就不用不停维护每个数的位置了。
重点在于翻转的复杂度是 \(O(n)\) 的,这是该题超时的主要因素,必须想办法优化掉。
突然想到了 滑动窗口 一题:一个窗口中增增减减,但是始终保持着一定的性质;这题也是同样的:不断增增减减,始终维护着整个数列的一段区间。那(并不是)很显然,我们不妨用一个队列来模拟这个操作。
将一个队列的元素翻转?那显然是 \(\text{deque}\) 了!我们直接用 \(\text{deque}\) 封装一个新类,简单维护一下反转的功能就好了!(做个标记,来分辨操作到底是从头还是从尾进行)
(实际上,大多数人在维护区间的时候更容易想到双指针,如果是暴力的话,这样写确实更加简单方便,但是当优化反转的复杂度时则会一脸懵;反之,队列的思路在反转上似乎显得更加清晰明了。)
小细节有点多,下面给出代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n, k, a[N], pos[N];
bool flag = true;
struct deQue
{
deque<int> q;
bool rev;
bool empty() {
return q.empty();
}
int size() {
return q.size();
}
int front() {
return rev ? q.back() : q.front();
}
int back() {
return rev ? q.front() : q.back();
}
void push_front(int x) {
rev ? q.push_back(x) : q.push_front(x);
}
void push_back(int x) {
rev ? q.push_front(x) : q.push_back(x);
}
void pop_back() {
rev ? q.pop_front() : q.pop_back();
}
void pop_front() {
rev ? q.pop_back() : q.pop_front();
}
void reverse() {
rev ^= 1;
}
} q;
int main()
{
scanf("%d", &n);
for (int i = 1; i <= n; ++i) {
scanf("%d", &a[i]);
pos[a[i]] = i;
}
bool isOK = true;
//求出k
for (int i = 1; i <= n; ++i)
if (a[i] != i) {
k = pos[i] - i + 1;
isOK = false;
break;
}
//已经排好序了
if (isOK) {
printf("yes\n1\n");
return 0;
}
//主程序
for (int i = 1; i <= k; ++i)
q.push_back(a[i]);
for (int i = 1; i <= n; ++i) {
if (q.front() != i && q.size() == k)
q.reverse();
if (q.front() != i) {
flag = false;
break;
}
q.pop_front();
if (i + k <= n)
q.push_back(a[i + k]);
}
if (flag) printf("yes\n%d\n", k);
else printf("no\n");
return 0;
}
G题 牛牛与比赛颁奖 (离散化+扫描线+差分)
如果 \(n\) 的范围能降到 \(10^6\) 这一级,那题目思路就很显然了:直接差分就好了,然后维护出来每个队伍写出来的题目的数量,然后排序之后输出。
如果降到 \(10^7\) 这一级,那么排序会 \(T\) 掉。这时候我们应该转换下思路:我们不需要具体了解每个队伍的得分情况,我们只需要维护排名即可。这样的话,我们不如开一个桶,维护写出 \(i\) 题的队伍的数量为 \(cnt_i\) 即可。
但尴尬的是,\(n\) 的范围是 \(10^9\),在大多数题目里面,必须要上 \(O(\sqrt{n})\) 或者 \(O(1)\) 或者 \(O(\log {n})\)的算法了,但这题显然跟这些都没啥关系。显然,我们只能用一下离散化,把这个规模就硬降下来。
这玩意我也不知道咋用语言来描述,看起来挺像扫描线的,但又不是那回事(逃
建议直接看代码,如果看不懂代码就去看官方题解
#include<bits/stdc++.h>
using namespace std;
const int M = 100010;
int n, m;
struct node {
int val, pos;
bool operator < (const node &rhs) const {
return pos < rhs.pos;
}
} a[M<<1];
int sum[M];
int main()
{
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; ++i) {
int l, r;
scanf("%d%d", &l, &r);
a[i].pos = l, a[i + m].pos = r + 1;
a[i].val = 1, a[i + m].val = -1;
}
sort(a + 1, a + 2*m + 1);
int now = 0;
for (int i = 1; i < 2*m; ++i) {
now += a[i].val;
if (a[i].pos != a[i+1].pos)
sum[now] += a[i+1].pos - a[i].pos;
}
int Au = (n + 9) / 10, Ag = (n + 3) / 4, Cu = (n + 1) / 2;
int ans[3]={0, 0, 0};
now = 0;
for (int i = m; i >= 1; --i) {
if (now < Au) ans[0] += sum[i];
else if (Au <= now && now < Ag) ans[1] += sum[i];
else if (Ag <= now && now < Cu) ans[2] += sum[i];
now += sum[i];
}
printf("%d %d %d", ans[0], ans[1], ans[2]);
return 0;
}
H题 牛牛与棋盘 (签到)
签到题,有手就行
#include<bits/stdc++.h>
using namespace std;
int n;
int main()
{
cin>>n;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n / 2; ++j)
printf(i % 2 ? "01" : "10");
puts("");
}
return 0;
}
I题 牛牛的“质因数” (数论,记忆化搜索/DP)
不管咋样,把所有素数筛出来总没错。
然后......就没有然后了。
老实说,这题当时卡的我想砸键盘,后来张佬一句话点醒了我:“这题可以暴力DFS。”(逃
(并不)显然,根据算术基本定理,每个数的质因数分解方式是唯一的(听起来像是废话)。
另外,我们有一个小思路:如果已知一个数 \(x\) 的表示,即 \(F(x)\) ,同时知道 \(x\) 的最大质因数是 \(p_0\),并且 \(p_1,p_2,......\) 等等都是大于 \(p_0\) 的质因数,那么我们就可以推出 \(p_0x,p_0^2x,p_0^3x,p_1^2x,p_0p_1x\) 等等一系列数字的 \(F\) 表示。
这两个思路并在一起(稍加)思索,我们会发现一个惊人的事实:我们可以 \(O(n)\) 的递推求出区间 \([2,n]\) 内所有数字的 \(F\) 表示(如果 \(DFS\) 一次,每个数会且仅会被扫到一次,实际上就是一个标准的搜索树)。
给出考场代码(严格说,这是一个记忆化搜索,甚至可以称之为动态规划,虽然我觉得就是普通递推):
#include<bits/stdc++.h>
using namespace std;
const int N = 4000010;
const long long mod = 1e9 + 7;
long long n;
int cnt, prime[N], vis[N];
void generate_prime() {
memset(vis, 0, sizeof(vis));
cnt = 0;
for (int i = 2; i <= 4000005; ++i)
if (!vis[i]) {
prime[++cnt] = i;
for (int j = 2; i * j <= 4000005; ++j) vis[i*j] = 1;
}
}
int g(int x) {
int val = 1;
while (x) x /= 10, val *= 10;
return val;
}
long long res[N];
void dfs(long long val, int p, long long fa) {
res[val] = (res[fa] * g(prime[p]) + prime[p]) % mod;
for (int i = p; val * prime[i] <= n && i <= cnt; ++i)
dfs(val * prime[i], i, val);
}
int main()
{
generate_prime();
cin>>n;
for (int i = 1; prime[i] <= n && i <= cnt; ++i)
dfs(prime[i], i, 0);
long long ans = 0;
for (int i = 2; i <= n; ++i)
ans = (ans + res[i]) % mod;
printf("%lld", ans);
return 0;
}
说是搜索树,其实具体化到本题中,这玩意可以用一个专业点的名词来描述:筛法树(除了 \(1\),每个数都和一个比他小的数有一个关系,这个关系就是一个质数。 \(n\) 点 \(n-1\) 边,我 DNA 动了)
这就是一个大小为 \(10\) 的筛法树(借用一下官方题解的图,希望不要介意)(逃
构造方式仔细研究一下,会发现构造方式和线性素数筛有异曲同工之妙(发现一个符合要求的数,然后线性递推,把后面的数字都打上标记),这也就是 筛法树 名字的由来。
J题 牛牛想要成为hacker (构造)
看样例,可以显然找出一种构造方法:一个等比数列,可以保证整个循环里面都找不出来能构成三角形的
可惜的是,题目限制范围,莫得办法(逃
能够保持这种相加不构成三角形的性质的数列,增长最慢的是啥数列?显然是斐波那契数列(证明显然)
当然,斐波那契数列的增长速度也是幂级别的,估计到了第 \(40\) 项左右就会超过 \(10^9\) 的限制了,我们记为 \(cnt\)。
这里几种方案:
-
构造等差数列
这玩意就很烦了,不谈
-
第一位放弃1,从2开始,后面全部铺1
我真不知道出题人咋想到这个逆天思路的,但是确实有用,可以保证 \(\text{isTriangle}\) 总调用次数不少于 \(n^2\log n\) 次(\(1\leq i \leq cnt\),\(1 \leq j,k \leq n\),显然 \(cnt > \log n\))。
#include <bits/stdc++.h>
using namespace std;
int n, a[50];
int main()
{
scanf("%d", &n);
a[0]= 1, a[1]= 2;
for (int i = 1; i <= min(40, n); ++i) {
printf("%d ", a[i]);
a[i + 1] = a[i] + a[i - 1];
}
for (int i = 41; i <= n; i++)
printf("1 ");
return 0;
}