【NOI2024 D1T2】百万富翁 题解(交互, dp)
感觉并不是那么困难。
自己最多获得了 \(82pts\)。
题意
有一个下标为 \(0 \sim N - 1\) 的数组 \(\{x_i\}\),每个位置上的数字都是不同的。你需要求出最大值所在的位置。
你可以询问不超过 \(T\) 次,每次询问你可以给交互库两个长度相同的数组 \(A,B\),并且保证 \(0 \leq A_i < N\) 以及 \(0 \leq B_i < N\)。系统会返回你一个长度与 \(A\) 相同的数组 \(C\),其中 \(C_i\) 是 \(x_{A_i}\) 和 \(x_{B_i}\) 较大数的位置,也就是 \(C_{i} \in \{A_i, B_i\}\)。
设你询问了 \(m\) 次,一次询问的数组长度大小为 \(c_i\),那你需要保证 \(\sum_{i = 1}^{m} c_i \leq S\)。
数据分为两档:
第一档:\(N = 1000, T = 1, S = 499500\)。(15pts)
你只需要保证一组询问问出最大值就可获得满分。
第二档: \(N = 1000000,T=20,S=2000000\) (85pts)
你的分数取决与你的询问组数与询问总次数。
若 \(m > T\) 或 \(\sum c_i > S\) 你会获得 \(0pts\)。
当 \(m \leq 8\) 且 \(\sum c_i \leq 1099944\) 你会获得满分。
交互库自适应。
分析
首先需要知道一点:由于交互库自适应,因此想要一次问出 \(x\) 个数的最大值需要问 \(\frac{x \times (x - 1)}{2}\) 次。也就是问出任意两个数之间的大小关系。
先来考虑第一档:显然直接问出任意两个数之间的大小关系就行了。
int richest(int N, int T, int S) {
n = N - 1;
if(T == 1) {
memset(num, 0, sizeof num);
vector< int > A, B;
for(int i = 0; i <= n; i ++ )
for(int j = i + 1; j <= n; j ++ )
A.pb(i), B.pb(j);
vector< int > C = ask(A, B);
for(int i = 0; i < A.size(); i ++ ) num[C[i]] ++;
for(int i = 0; i <= n; i ++ )
if(num[i] == n) return i;
}
}
再来看第二档:
发现组数是小于 \(\log\),询问次数只比 \(N\) 大一点。
往 \(\log\) 上想,首先可以想到如果我每次问出相邻两个数之间的大小关系,那么个数就会 \(/2\),总共会进行 \(\log_2 N\) 轮,总次数是 \(N/2 + N / 4 + ... =N\) 的。
看起来好像还不错?写了一发,得到了 \(11pts\)。
void Sol1(int n) { // 0 -> n 里面有数
if(n == 0) return;
vector< int > A, B;
for(int i = 0; i <= n; i += 2) {
if(i == n) continue;
A.pb(p[i]), B.pb(p[i + 1]);
}
vector< int > C = ask(A, B);
int tot = 0;
for(int i = 0; i <= n; i += 2) {
if(i == n) q[tot ++] = p[i];
else q[tot] = C[tot], tot ++;
}
tot --;
for(int i = 0; i <= tot; i ++ ) p[i] = q[i];
Sol1(tot);
}
int richest(int N, int T, int S) {
n = N - 1;
if(T == 1) {}
else {
for(int i = 0; i <= n; i ++ ) p[i] = i;
Sol1(n); // 第一次尝试, 每次 / 2
return p[0];
}
}
评测结果告诉我们询问组数达到了 \(20\) 次,总询问次数为 \(999999\)。
主要是轮数太多导致 G 掉了。
那么如果我们每次 \(/3\) 询问论数就是 \(\log_3 N\) 了,总次数是 \(3 \times (N / 3 + N / 9 + ...) \leq N + \frac{2}{3}N\) 的。写了一发,得了 \(9pts\)。
void Sol2(int n) {
if(n == 0) return ;
vector< int > A, B;
for(int i = 0; i <= n; i += 3) {
if(i == n) continue;
else if(i == n - 1) A.pb(p[i]), B.pb(p[i + 1]);
else {
A.pb(p[i]), B.pb(p[i + 1]);
A.pb(p[i]), B.pb(p[i + 2]);
A.pb(p[i + 1]), B.pb(p[i + 2]);
}
}
vector< int > C = ask(A, B);
int tot = 0; int pp = 0;
for(int i = 0; i <= n; i += 3) {
if(i == n) q[tot ++] = p[i];
else if(i == n - 1) q[tot ++] = C[pp];
else {
if(C[pp] == C[pp + 1]) q[tot ++] = C[pp];
else q[tot ++] = C[pp + 2];
pp += 3;
}
}
tot --;
for(int i = 0; i <= tot; i ++ ) p[i] = q[i];
Sol2(tot);
}
int richest(int N, int T, int S) {
n = N - 1;
if(T == 1) {}
else {
for(int i = 0; i <= n; i ++ ) p[i] = i;
Sol2(n); // 第二次尝试, 每次 / 3
return p[0];
}
}
评测信息告诉我们: \(m = 13,\sum c_i = 1499997\)。这下是主要失分原因在与总询问次数太多了。
然后可以发现这样询问导致次数过多的原因在于第一组的次数是 \(N / 3 \times 3 = N\) 次,那么如果第一次 \(/2\),然后再一直 \(/3\),感觉轮数好像并没有太多变化,但是第一组的询问次数就变成 \(N /2\) 了!写了一发,得了 \(15pts\)。
int modify(int n, int k) { // 按照 k 分组
vector< int > A, B;
for(int i = 0; i <= n; i += k)
for(int j = i; j <= min(n, i + k - 1); j ++ )
for(int l = j + 1; l <= min(n, i + k - 1); l ++ )
A.pb(p[j]), B.pb(p[l]);
vector< int > C = ask(A, B);
int tot = 0, pp = 0;
for(int i = 0; i <= n; i += k) {
for(int j = i; j <= min(n, i + k - 1); j ++ )
for(int l = j + 1; l <= min(n, i + k - 1); l ++ )
cnt[C[pp ++]] ++;
int mx = -1, idx = 0;
for(int j = i; j <= min(n, i + k - 1); j ++ )
if(cnt[p[j]] > mx) mx = cnt[p[j]], idx = p[j];
q[tot ++] = idx;
for(int j = i; j <= min(n, i + k - 1); j ++ ) cnt[p[j]] = 0;
}
tot --;
for(int i = 0; i <= tot; i ++ ) p[i] = q[i];
return tot;
}
void solve(int n, int f) {
if(n == 0) return ;
if(f) n = modify(n, 2);
else n = modify(n, 3);
solve(n, 0);
}
int richest(int N, int T, int S) {
n = N - 1;
if(T == 1) {}
else {
for(int i = 0; i <= n; i ++ ) p[i] = i;
solve(n, 1);
return p[0];
}
}
此时询问组数仍然是 \(13\),总次数为 \(1249995\),还是有点大。
调成先做两次 \(/2\),再 \(/3\),得到了 \(18pts\)。
调成先做三次 \(/2\),再 \(/3\),得到了 \(32pts\)!!!
int modify(int n, int k) { // 按照 k 分组
vector< int > A, B;
for(int i = 0; i <= n; i += k)
for(int j = i; j <= min(n, i + k - 1); j ++ )
for(int l = j + 1; l <= min(n, i + k - 1); l ++ )
A.pb(p[j]), B.pb(p[l]);
vector< int > C = ask(A, B);
int tot = 0, pp = 0;
for(int i = 0; i <= n; i += k) {
for(int j = i; j <= min(n, i + k - 1); j ++ )
for(int l = j + 1; l <= min(n, i + k - 1); l ++ )
cnt[C[pp ++]] ++;
int mx = -1, idx = 0;
for(int j = i; j <= min(n, i + k - 1); j ++ )
if(cnt[p[j]] > mx) mx = cnt[p[j]], idx = p[j];
q[tot ++] = idx;
for(int j = i; j <= min(n, i + k - 1); j ++ ) cnt[p[j]] = 0;
}
tot --;
for(int i = 0; i <= tot; i ++ ) p[i] = q[i];
return tot;
}
void solve(int n, int f) {
if(n == 0) return ;
if(f > 0) n = modify(n, 2);
else n = modify(n, 3);
solve(n, f - 1);
}
int richest(int N, int T, int S) {
n = N - 1;
if(T == 1) {}
else {
for(int i = 0; i <= n; i ++ ) p[i] = i;
solve(n, 3);
return p[0];
}
}
此时询问组数已经变成 \(14\) 了,询问总次数为 \(1062496\)。
思考我们现在不断做的过程:发现其实是当 \(N\) 比较大是我们使用较小的段数以减少总询问次数,当 \(N\) 比较小时可以增大段数来减少询问轮数。
因此可以得到一个贪心:我们猜随着 \(N\) 的减小段数是不断增大的。在保证总次数不超的前提下,每次用最大的段长。
怎么找最大段长?枚举一个段长就可以在 \(\log N\) 的复杂度求出往后一直用这个段长需要的次数,不难发现段长最大枚举到 \(\sqrt{S}\),由于我们最多求 \(\log\) 次段长,总复杂度是 \(O(\sqrt{S} \log^2 N)\) 的。
int modify(int n, int k) { // 按照 k 分组
vector< int > A, B;
for(int i = 0; i <= n; i += k)
for(int j = i; j <= min(n, i + k - 1); j ++ )
for(int l = j + 1; l <= min(n, i + k - 1); l ++ )
A.pb(p[j]), B.pb(p[l]);
vector< int > C = ask(A, B);
int tot = 0, pp = 0;
for(int i = 0; i <= n; i += k) {
for(int j = i; j <= min(n, i + k - 1); j ++ )
for(int l = j + 1; l <= min(n, i + k - 1); l ++ )
cnt[C[pp ++]] ++;
int mx = -1, idx = 0;
for(int j = i; j <= min(n, i + k - 1); j ++ )
if(cnt[p[j]] > mx) mx = cnt[p[j]], idx = p[j];
q[tot ++] = idx;
for(int j = i; j <= min(n, i + k - 1); j ++ ) cnt[p[j]] = 0;
}
tot --;
for(int i = 0; i <= tot; i ++ ) p[i] = q[i];
return tot;
}
LL get(int n, int k) {
int c = (n / k), r = n % k;
return 1LL * c * k * (k - 1) / 2 + 1LL * r * (r - 1) / 2;
}
LL calc(int n, int k) {
LL ans = 0;
while(n > 1) {
ans += get(n, k);
n = (n / k) + (n % k > 0);
}
return ans;
}
void Sol4(int n, int rest) { // 当前剩了多少个, 以及剩下多少个
if(n == 0) return ;
int k = 2;
while(k <= n + 1 && calc(n + 1, k) <= rest) k ++;
k --; int cc = get(n + 1, k);
n = modify(n, k);
Sol4(n, rest - cc);
}
int richest(int N, int T, int S) {
n = N - 1;
if(T == 1) {
memset(num, 0, sizeof num);
vector< int > A, B;
for(int i = 0; i <= n; i ++ )
for(int j = i + 1; j <= n; j ++ )
A.pb(i), B.pb(j);
vector< int > C = ask(A, B);
for(int i = 0; i < A.size(); i ++ ) num[C[i]] ++;
for(int i = 0; i <= n; i ++ )
if(num[i] == n) return i;
}
else {
for(int i = 0; i <= n; i ++ ) p[i] = i;
Sol4(n, 1099944);
return p[0];
}
}
这时我们拿到了当前的最高分 \(54pts\)!!!
评测信息告诉我们询问组数为 \(10\),总询问次数为 \(1099937\)。
还能怎么优化?刚才我们保证了总询问次数不超,那么接下来我们保证总询问组数不超试试。
可以写一个 \(dp\):\(dp_{t, n}\) 表示 \(t\) 轮问出 \(n\) 个数最大值的最小询问次数,转移就是每次枚举一个段长 \(l\),那么可以求出这次询问后的个数,这次询问的次数也可以快速算出。
然后求出 \(dp_{8, 1000000} = 1099960\)。并且求出了一个最优的段数顺序:
int len[9] = {0, 2, 2, 2, 2, 3, 6, 19, 183};
交上去,获得了 \(67pts\)!!!
int modify(int n, int k) { // 按照 k 分组
vector< int > A, B;
for(int i = 0; i <= n; i += k)
for(int j = i; j <= min(n, i + k - 1); j ++ )
for(int l = j + 1; l <= min(n, i + k - 1); l ++ )
A.pb(p[j]), B.pb(p[l]);
vector< int > C = ask(A, B);
int tot = 0, pp = 0;
for(int i = 0; i <= n; i += k) {
for(int j = i; j <= min(n, i + k - 1); j ++ )
for(int l = j + 1; l <= min(n, i + k - 1); l ++ )
cnt[C[pp ++]] ++;
int mx = -1, idx = 0;
for(int j = i; j <= min(n, i + k - 1); j ++ )
if(cnt[p[j]] > mx) mx = cnt[p[j]], idx = p[j];
q[tot ++] = idx;
for(int j = i; j <= min(n, i + k - 1); j ++ ) cnt[p[j]] = 0;
}
tot --;
for(int i = 0; i <= tot; i ++ ) p[i] = q[i];
return tot;
}
int len[9] = {0, 2, 2, 2, 2, 3, 6, 19, 183};
void Sol5(int n, int k) {
if(!n) return ;
n = modify(n, len[k]);
Sol5(n, k + 1);
}
int richest(int N, int T, int S) {
n = N - 1;
if(T == 1) {}
else {
for(int i = 0; i <= n; i ++ ) p[i] = i;
Sol5(n, 1);
return p[0];
}
}
此时算上第一档我们已经有 \(82pts\),感觉已经很高了。考场上如果不能很快的想到怎么再进一步就可以跳了。
但是这里我们考虑怎么接着优化次数。
考虑刚才我们那种做法到底哪里会增加询问次数??
其实不是那么容易注意到:
刚才我们的询问方式是:把若干个段长放一起询问,把余数个数放一起询问。但是这样极差可能会很大。
考虑两个数 \(x,y\),它们的代价为 \(x^2 + y^2\)。如果 \(x > y + 1\),那么让 \(x - 1\),\(y + 1\),代价变成了 \((x-1)^2 + (y + 1)^2 = x^2 + y^2 +2(1 - (x - y)) < x^2 + y^2\) 。
也就是说,任意两组的大小之差都不应该超过 \(1\),否则可以调整为更优的方案。
怎么改刚才的做法??
原基础上改起来比较麻烦,因为只枚举段长的话不好确定最后剩几个是最优的。
我们考虑修改一下 \(dp\) 的转移:\(dp_{t, i} = \min(dp_{t, j} + cost(i, j))\)。
其中 \(cost(i, j)\) 表示把 \(i\) 个数分成 \(j\) 组的最小代价和。
那么 \(cost(i, j)\) 是可以 \(O(1)\) 计算的。此时复杂度为 \(O(Tn^2)\)。
由于我们只需要得到一个大小为 \(8\) 的最优划分。相当于提交答案题。
你可以选择让程序运行 \(67min\) 分钟得到一个最优的划分。
但是这样万一写错了一些东西或者程序终止了可能就无法在考试中求出来这个划分了。
总之是存在风险。
考虑以下优化这个 \(dp\):
注意到 \(cost(i, j)\) 等于 \(i \% j\) 个长度为 \(\left \lfloor \frac{i}{j} \right \rfloor+1\) 的段和 \(j - (i \% j)\) 个长度为 \(\left \lfloor \frac{i}{j} \right \rfloor\) 的段的贡献和。
那么 \(\left \lfloor \frac{i}{j} \right \rfloor\) 相同的 \(j\) 的划分的段长是相同的,有影响的两种段的数量。
考虑整除分块优化,我们大胆猜测对于 \(\left \lfloor \frac{i}{j} \right \rfloor\) 相同的 \(j\) 而言,有用的只有一段的左右端点。那么每次转移复杂度就是 \(\sqrt{n}\) 的。总复杂度 \(O(Tn \sqrt{n})\)。
实事证明猜的结论是对的,因为我们确实跑出了 \(1099944\) 的答案。
下面是打表的代码:
#include<bits/stdc++.h> // dp 求一下
using namespace std;
typedef long long LL;
const int N = 1e6 + 10;
const LL lim = 1099944;
int lst[9][N];
LL f[9][N]; // f[t][n] 表示能用 t 组,将 n 个变成 1 个的最小询问总数
void print(int t, int n) {
if(!t) return ;
printf("%d : %d -> %d\n", t, n, lst[t][n]);
print(t - 1, lst[t][n]);
}
inline LL cost(int n, int m) { // n -> m 的贡献
// 有 n % m 个 (n / m) + 1, 有 m - n % m 个 (n / m)
if(m >= lim) return 1e8;
return 1LL * (n % m) * (n / m + 1) * (n / m) / 2LL + 1LL * (m - n % m) * (n / m) * (n / m - 1) / 2LL;
}
int main() {
int n = 1000000;
int lim = 3000;
memset(f, 0x3f, sizeof f);
for(int t = 1; t <= 8; t ++ ) {
if(t == 1)
for(int i = 1; i <= n; i ++ ) f[1][i] = 1LL * i * (i - 1) / 2, lst[1][i] = 0;
else {
for(int i = 1; i <= n; i ++ ) { // 发现 x = (n / m + 1) 只与 n / m 的值有关,猜去到数论分块的两个端点是最优的
int l = 1, r;
while(l < i) {
r = min(i - 1, (i / (i / l)));
if(f[t - 1][l] + cost(i, l) < f[t][i]) {
f[t][i] = min(f[t][i], f[t - 1][l] + cost(i, l));
lst[t][i] = l;
}
if(f[t - 1][r] + cost(i, r) < f[t][i]) {
f[t][i] = min(f[t][i], f[t - 1][r] + cost(i, r));
lst[t][i] = r;
}
l = r + 1;
}
}
}
}
cout << f[8][n] << endl;
print(8, n);
return 0;
}
得到每次的 \(n\) 为:
int rest[9] = {0, 500000, 250000, 125000, 62500, 20833, 3472, 183, 1};
于是得到 \(85pts\) 的程序:
int rest[9] = {0, 500000, 250000, 125000, 62500, 20833, 3472, 183, 1};
void Turn(int n, int m) {
int x = (n / m) + 1;
int a = n % m, b = m - n % m; // a 个 x, b 个 x - 1
vector< int > A, B; int pp = 0;
for(int d = 1; d <= a + b; d ++ ) {
int l = (d <= a ? x : x - 1);
for(int i = pp; i < pp + l; i ++ )
for(int j = i + 1; j < pp + l; j ++ )
A.pb(p[i]), B.pb(p[j]);
pp += l;
}
vector< int > C = ask(A, B);
pp = 0; int now = 0; int qq = 0;
for(int d = 1; d <= a + b; d ++ ) {
int l = (d <= a ? x : x - 1);
for(int i = pp; i < pp + l; i ++ )
for(int j = i + 1; j < pp + l; j ++ )
cnt[C[now ++]] ++;
int mx = -1, id = 0;
for(int i = pp; i < pp + l; i ++ )
if(cnt[p[i]] > mx) {mx = cnt[p[i]], id = p[i];}
for(int i = pp; i < pp + l; i ++ ) cnt[p[i]] = 0;
pp += l; q[qq ++] = id;
}
for(int i = 0; i < qq; i ++ ) p[i] = q[i];
}
void Sol6(int n, int k) {
if(!n) return ;
Turn(n + 1, rest[k]); // 把 n + 1 个数变成 rest[i] 个数
Sol6(rest[k] - 1, k + 1);
}
int richest(int N, int T, int S) {
n = N - 1;
if(T == 1) {}
else {
Sol6(n, 1);
return p[0];
}
}
总结:
这道题并没有那么的难,对码力的要求也是不高。
想得到 \(69pts\) 甚至 \(82pts\) 都是可以做到的。正解或许需要有一定的数学直觉和观察力。但是考场上拿到 \(82pts\) 肯定冲击银牌就是够用的。
如果这道题 \(80+\) 加上 \(T3\) \(64pts\) 就足以达到集训队线。
或者这道题 \(100\) 加上 \(T3\) 的 \(36pts\) 也可以达到队线,但是好像还没上一种方案更高。

浙公网安备 33010602011771号