模拟赛 6 讲评课件
模拟赛 6 讲评课件
目录
| 题目 | 名称 | 核心考点 | 难度 |
|---|---|---|---|
| T1 | 最大公约数 (gcd) | 数学结论 | ★☆☆☆☆ |
| T2 | 数的变换 (number) | 质因数分解、贪心 | ★★☆☆☆ |
| T3 | 回文 (palindrome) | 桶统计、枚举优化 | ★★★★☆ |
| T4 | 序列 (sequence) | 数位 DP、组合计数 | ★★★★★ |
T1 最大公约数 (gcd)
题目简述
在 \(1\sim n\) 中找出两个不同的数 \(x,y\),使得 \(\gcd(x,y)\) 最大,输出最大值。
- \(2\le n\le 10^6\)
正解思路
结论:答案就是 \(\left\lfloor\dfrac{n}{2}\right\rfloor\)。
证明:
- 取 \(x=k,\; y=2k\),则 \(\gcd(x,y)=k\)。
- 为了让 \(y\le n\),需要 \(2k\le n\),即 \(k\le \lfloor n/2\rfloor\)。
- 那么答案能不能比 \(\lfloor n/2\rfloor\) 更大呢?假设答案为 \(ans > \lfloor n/2\rfloor\),则 \(2\cdot ans > n\),那么在 \(1\sim n\) 中无法找到两个不同的数都是 \(ans\) 的倍数(最多只有一个 \(ans\) 自己),所以 \(\gcd\) 不可能为 \(ans\)。
因此最大可能的 \(\gcd\) 就是 \(\lfloor n/2\rfloor\)。
复杂度分析
- 时间复杂度:\(\mathcal{O}(1)\)
- 空间复杂度:\(\mathcal{O}(1)\)
参考代码
#include <iostream>
using namespace std;
int main() {
int n;
cin >> n;
cout << n / 2 << '\n';
return 0;
}
T2 数的变换 (number)
题目简述
给定整数 \(n\),有三种操作(每次代价为 \(1\)):
- 如果 \(2\mid n\),\(n\leftarrow \dfrac{n}{2}\)
- 如果 \(3\mid n\),\(n\leftarrow \dfrac{2n}{3}\)
- 如果 \(5\mid n\),\(n\leftarrow \dfrac{4n}{5}\)
求将 \(n\) 变为 \(1\) 的最少次数,或输出 \(-1\) 表示不可能。
- \(T\le 1000\),\(n\le 10^{18}\)
正解思路
第一步:什么时候无解?
三种操作的本质是 消去质因子:
| 操作 | 效果 |
|---|---|
| \(n\rightarrow n/2\) | 去掉一个质因子 \(2\) |
| \(n\rightarrow 2n/3\) | 增加一个 \(2\),去掉一个 \(3\) |
| \(n\rightarrow 4n/5\) | 增加两个 \(2\),去掉一个 \(5\) |
操作的实质:所有操作都不会引入除了 \(2,3,5\) 以外的新质因子,只会改变 \(2,3,5\) 的指数。
因此,如果 \(n\) 包含除了 \(2,3,5\) 以外的质因子,必定无解(输出 \(-1\))。
第二步:最小步数怎么算?
将 \(n\) 分解为 \(n = 2^{c_2}\times 3^{c_3}\times 5^{c_5}\)。
目标是把 \(n\) 变成 \(1\),即把所有质因子消掉。
观察每个质因子"消耗"的步数:
- 一个 \(3\):先执行操作 2(\(2n/3\),增加一个 \(2\),去掉一个 \(3\)),再执行操作 1(去掉那个多出来的 \(2\)),共需要 \(2\) 步。
- 一个 \(5\):先执行操作 3(\(4n/5\),增加两个 \(2\),去掉一个 \(5\)),再执行两次操作 1(去掉两个多出来的 \(2\)),共需要 \(3\) 步。
- 一个 \(2\):直接操作 1,\(1\) 步。
所以最少步数 = \(c_2 + 2c_3 + 3c_5\)。
复杂度分析
- 时间复杂度:\(\mathcal{O}(T\log n)\)(分解质因数的复杂度)
- 空间复杂度:\(\mathcal{O}(1)\)
参考代码
#include <iostream>
using namespace std;
using ll = long long;
int main() {
int T;
cin >> T;
while (T--) {
ll n;
cin >> n;
int c2 = 0, c3 = 0, c5 = 0;
while (n % 2 == 0) { n /= 2; c2++; }
while (n % 3 == 0) { n /= 3; c3++; }
while (n % 5 == 0) { n /= 5; c5++; }
if (n != 1) cout << "-1\n";
else cout << c2 + 2 * c3 + 3 * c5 << '\n';
}
return 0;
}
T3 回文 (palindrome)
题目简述
给定长度为 \(n\)(偶数)的字符串 \(S\) 和代价数组 \(v_{1..26}\)。
操作:
- 交换:任意交换两个位置上的字符(最多一次,代价 \(0\))
- 修改:将字符 \(c_1\) 改为 \(c_2\),代价为 \(v_{c_2}\)(可多次)
求将 \(S\) 变成回文串的最小总代价。
- \(n\le 10^6\),\(0\le v_i\le 10^9\)
- 字符串仅含小写字母
预备知识:一对字符的最小修改代价
对于对称的一对字符 \((x,y)\),需要把它们变成相同:
- 若 \(x=y\):代价 \(0\)
- 若 \(x\neq y\):
- 方案 A:把 \(x\) 改成 \(y\),代价 \(v_y\)
- 方案 B:把 \(y\) 改成 \(x\),代价 \(v_x\)
- 方案 C:把两者都改成某个 \(z\),代价 \(v_z+v_z = 2v_z\)
取最小值:\(\text{cost}(x,y)= \min(v_x,\; v_y,\; 2\cdot \min_{k} v_k)\)
30% 部分分(\(n\le 500\))
思路
最朴素的做法:
- 枚举所有可能的交换位置 \((i,j)\),共 \(\mathcal{O}(n^2)\) 种
- 对于每次交换,计算新字符串的回文代价(遍历 \(n/2\) 对对称位置)
总复杂度 \(\mathcal{O}(n^3)\),对于 \(n\le 500\) 刚好够用。
参考代码
#include <iostream>
#include <string>
#include <algorithm>
#include <climits>
using namespace std;
using ll = long long;
ll v[26], minv;
// 一对字符变成相同的最小代价
ll pair_cost(char a, char b) {
if (a == b) return 0;
return min({v[a - 'a'], v[b - 'a'], 2 * minv});
}
// 计算字符串的回文代价
ll total_cost(const string &t) {
ll cost = 0;
int len = (int)t.size();
for (int i = 0; i < len / 2; i++) {
cost += pair_cost(t[i], t[len - 1 - i]);
}
return cost;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
minv = LLONG_MAX;
for (int i = 0; i < 26; i++) {
cin >> v[i];
minv = min(minv, v[i]);
}
string s;
cin >> s;
ll ans = total_cost(s);
// 枚举所有交换对 (i, j)
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
swap(s[i], s[j]);
ans = min(ans, total_cost(s));
swap(s[i], s[j]); // 恢复
}
}
cout << ans << '\n';
return 0;
}
50% 部分分(\(n\le 5000\))
思路
观察:一次交换最多影响两对对称位置。
记位置 \(i\) 的对称位置是 \(n-1-i\),位置 \(j\) 的对称位置是 \(n-1-j\)。
交换 \(S[i]\) 和 \(S[j]\) 后,发生变化的只有:
- 对称对 \((i,\; n-1-i)\)
- 对称对 \((j,\; n-1-j)\)
如果 \(j = n-1-i\)(交换发生在同一个对称对内),则交换无效果。
因此不需要每次重新计算整个字符串,只需:
- 预先计算基准代价 \(\text{base}\)(不交换)
- 枚举交换对 \((i,j)\)
- 减去这两对原来的代价,加上交换后的新代价
复杂度 \(\mathcal{O}(n^2)\),对于 \(n\le 5000\) 可在时限内运行。
参考代码
#include <iostream>
#include <string>
#include <algorithm>
#include <climits>
#include <vector>
using namespace std;
using ll = long long;
ll v[26], minv;
int n;
// 一对字符变成相同的最小代价
ll pair_cost(char a, char b) {
if (a == b) return 0;
return min({v[a - 'a'], v[b - 'a'], 2 * minv});
}
// 获取位置 p 所在的对称对编号(0-indexed,左半侧)
int pair_id(int p) {
int mate = n - 1 - p;
if (p > mate) return mate;
return p;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
minv = LLONG_MAX;
for (int i = 0; i < 26; i++) {
cin >> v[i];
minv = min(minv, v[i]);
}
string s;
cin >> s;
// 预先计算每个对称位置对的代价
vector<ll> pos_cost(n / 2, 0);
ll base = 0;
for (int i = 0; i < n / 2; i++) {
pos_cost[i] = pair_cost(s[i], s[n - 1 - i]);
base += pos_cost[i];
}
ll ans = base;
// 枚举所有交换对
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
int p1 = pair_id(i);
int p2 = pair_id(j);
if (p1 == p2) continue; // 同一对称对,交换无效果
ll old_cost = pos_cost[p1] + pos_cost[p2];
swap(s[i], s[j]);
pos_cost[p1] = pair_cost(s[p1], s[n - 1 - p1]);
pos_cost[p2] = pair_cost(s[p2], s[n - 1 - p2]);
ll new_cost = pos_cost[p1] + pos_cost[p2];
ans = min(ans, base - old_cost + new_cost);
swap(s[i], s[j]); // 恢复
pos_cost[p1] = pair_cost(s[p1], s[n - 1 - p1]);
pos_cost[p2] = pair_cost(s[p2], s[n - 1 - p2]);
}
}
cout << ans << '\n';
return 0;
}
另 30% 数据(字符串仅含 a 和 b)
说明
当 \(S\) 中只出现 a 和 b 两种字符时,字母集大小从 \(26\) 降为 \(2\)。
思路
桶统计 + 枚举的思路与 AC 解法完全相同,唯一的区别是枚举量从 \(26^4\) 降为 \(2^4 = 16\)。
具体来说,\(\text{cnt}[a][b]\) 的 \(a,b\in\{0,1\}\)(\(0\) 表示 a,\(1\) 表示 b),枚举所有 \((a,b,c,d)\in\{0,1\}^4\) 即可。
事实上,因为 AC 解法本身只有 \(26^4 \approx 45\) 万次枚举,在任何数据下都很快,所以直接使用 AC 解法就能通过该部分分数据,不需要额外编写特判代码。
100% 正解
优化瓶颈
\(n\) 达到 \(10^6\),\(\mathcal{O}(n^2)\) 的枚举不可行。
关键观察:虽然 \(n\) 很大,但字母只有 26 种。对称对上的 \((x,y)\) 组合只有 \(26\times 27/2 = 351\) 种。
算法思路
第一步:桶统计
遍历所有 \(n/2\) 个对称位置对,用一个桶 \(\text{cnt}[a][b]\)(\(a\le b\))统计每种无序对出现的次数。
ll cnt[26][26] = {0};
for (int i = 0; i < n / 2; i++) {
int a = s[i] - 'a', b = s[n-1-i] - 'a';
if (a > b) swap(a, b);
cnt[a][b]++;
}
第二步:计算基准代价
ll base = 0;
for (int a = 0; a < 26; a++)
for (int b = a; b < 26; b++)
if (cnt[a][b]) base += cnt[a][b] * pair_cost(a, b);
第三步:枚举交换的效果
一次交换的本质:从两对对称位置上各取一个字符,交换到对方的位置。
设有两种对称对 \((a,b)\) 和 \((c,d)\)(无序),交换一个字符后,两个新对为:
- \(\{a,d\}\) 和 \(\{b,c\}\),或者
- \(\{a,c\}\) 和 \(\{b,d\}\)
(取决于取的是每对中的哪个字符交换)
我们取两种方式中代价更小的那个:
ll way1 = pair_cost(a,d) + pair_cost(b,c);
ll way2 = pair_cost(a,c) + pair_cost(b,d);
ll delta = min(way1, way2) - pair_cost(a,b) - pair_cost(c,d);
ans = min(ans, base + delta);
枚举所有 \((a,b,c,d)\) 的组合,共 \(26^4 \approx 45\) 万种,完全可行。
合法条件:
- \(\text{cnt}[a][b] > 0\),\(\text{cnt}[c][d] > 0\)
- 若 \((a,b) = (c,d)\),则 \(\text{cnt}[a][b] \ge 2\)(需要两个不同的对称对来交换)
复杂度分析
- 桶统计:\(\mathcal{O}(n)\)
- 枚举交换:\(\mathcal{O}(26^4)\)
- 总复杂度:\(\mathcal{O}(n + 26^4)\)
参考代码(AC)
#include <iostream>
#include <string>
#include <algorithm>
#include <climits>
using namespace std;
using ll = long long;
ll v[26], minv;
// 一对字符(用 0~25 表示)变成相同的最小代价
ll pair_cost(int a, int b) {
if (a == b) return 0;
return min({v[a], v[b], 2 * minv});
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
minv = LLONG_MAX;
for (int i = 0; i < 26; i++) {
cin >> v[i];
minv = min(minv, v[i]);
}
string s;
cin >> s;
// 桶统计:每种无序对出现的次数
ll cnt[26][26] = {0};
for (int i = 0; i < n / 2; i++) {
int a = s[i] - 'a', b = s[n - 1 - i] - 'a';
if (a > b) swap(a, b);
cnt[a][b]++;
}
ll base = 0;
for (int a = 0; a < 26; a++)
for (int b = a; b < 26; b++)
if (cnt[a][b]) base += cnt[a][b] * pair_cost(a, b);
ll ans = base;
for (int a = 0; a < 26; a++)
for (int b = a; b < 26; b++) {
if (cnt[a][b] == 0) continue;
for (int c = 0; c < 26; c++)
for (int d = c; d < 26; d++) {
if (cnt[c][d] == 0) continue;
if (a == c && b == d && cnt[a][b] < 2) continue;
ll way1 = pair_cost(min(a,d), max(a,d)) + pair_cost(min(b,c), max(b,c));
ll way2 = pair_cost(min(a,c), max(a,c)) + pair_cost(min(b,d), max(b,d));
ll delta = min(way1, way2) - pair_cost(a, b) - pair_cost(c, d);
ans = min(ans, base + delta);
}
}
cout << ans << '\n';
return 0;
}
T4 序列 (sequence)
题目简述
求有多少个长度为 \(n\) 的非负整数序列 \(A\),满足:
- \(l\le \sum_{i=1}^n A_i \le r\)
- \(\bigoplus_{i=1}^n A_i = z\)(异或)
结果对 \(10^9+7\) 取模。
- \(1\le n\le 1000\),\(1\le l\le r\le 10^{18}\),\(1\le z\le 10^{18}\)
预备知识(通俗版)
二进制表示
所有数字在计算机内部用 二进制 存储。例如:
- \(5 = (101)_2 = 1\times 2^2 + 0\times 2^1 + 1\times 2^0\)
- \(13 = (1101)_2 = 1\times 2^3 + 1\times 2^2 + 0\times 2^1 + 1\times 2^0\)
每个二进制位(bit)只能是 \(0\) 或 \(1\)。
异或运算(XOR,符号 \(\oplus\))
两个 bit 的异或:相同为 \(0\),不同为 \(1\)。
| \(x\) | \(y\) | \(x\oplus y\) |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
异或的一个重要性质:\(n\) 个数异或,结果的某一位是 \(1\) 当且仅当该位上 \(1\) 的个数为奇数。
即:\(\left(\bigoplus_{i=1}^n A_i\right)_k = \left(\sum_{i=1}^n (A_i)_k\right) \bmod 2\)
这个性质是解题的关键!
从低位到高位比较大小(核心技巧)
通常我们比较两个数是从最高位开始比。但在这里,我们需要 从低位(LSB)到高位(MSB) 逐位构造一个数并同时比较大小。
怎么办?我们用一个 标记(tag) 来记录"到目前为止,已处理的低位部分的大小关系"。
定义 \(\text{tag}=1\) 表示 "已处理的 \(i\) 个低位构成的数 \(\ge\) 被比较数(比如 \(l\))的对应低位"。
在加入第 \(i+1\) 位时:
| 新位比较 | 新 tag |
|---|---|
| 新位 \(>\) 比较位 | \(\text{tag}=1\)(大于) |
| 新位 \(=\) 比较位 | tag 保持不变 |
| 新位 \(<\) 比较位 | \(\text{tag}=0\)(小于) |
为什么从低位也能比较? 因为当处理到更高位时,高位权重更大,可以"覆盖"低位的比较结果。
实例:比较 \(S=6=(110)_2\) 和 \(l=5=(101)_2\) 是否 \(\ge\):
| 位 | \(S\) 位 | \(l\) 位 | 比较 | tag 变化 | 含义 |
|---|---|---|---|---|---|
| 0 (LSB) | 0 | 1 | \(0<1\) | tag=0 | 目前 \(S\) 低位 \(\ge l\)? 否 |
| 1 | 1 | 0 | \(1>0\) | tag=1 | 被高位覆盖,现在 \(\ge\) 了 |
| 2 (MSB) | 1 | 1 | \(1=1\) | tag=1 | 保持 |
最终 tag=1,表示 \(6\ge 5\),正确!
20% 部分分(\(r\le 30\))
思路
当总和上限 \(r\le 30\) 时,每个 \(A_i\) 也 \(\le 30\)。序列长度 \(n\) 可达 \(1000\)。
直接 DP:\(dp[s][x]\) 表示当前已确定的元素总和为 \(s\)、异或值为 \(x\) 的方案数。
每新加一个数 \(v\),转移:
复杂度:\(\mathcal{O}(n\cdot r^2\cdot \text{XOR范围})\),\(r\le 30\) 时完全可行。
参考代码
#include <iostream>
#include <cstring>
using namespace std;
using ll = long long;
const int MOD = 1e9 + 7;
int main() {
ll n, l, r, z;
cin >> n >> l >> r >> z;
// r ≤ 30,总和小,直接 DP
// dp[s][x] = 当前长度下,总和为 s,异或为 x 的方案数
int R = (int)r;
static int dp[31][64], ndp[31][64];
dp[0][0] = 1;
for (int i = 0; i < n; i++) {
memset(ndp, 0, sizeof(ndp));
for (int s = 0; s <= R; s++) {
for (int x = 0; x < 64; x++) {
if (dp[s][x] == 0) continue;
for (int v = 0; s + v <= R; v++) {
ndp[s + v][x ^ v] = (ndp[s + v][x ^ v] + dp[s][x]) % MOD;
}
}
}
memcpy(dp, ndp, sizeof(dp));
}
ll ans = 0;
for (int s = (int)l; s <= R; s++) {
ans = (ans + dp[s][(int)z]) % MOD;
}
cout << ans << '\n';
return 0;
}
40% 部分分(\(n\le 20\),\(r\le 500\))
思路
\(n\) 很小(\(\le 20\)),但 \(r\) 增大到 \(500\)。异或值的范围 \(\le 511\)(因为 \(500 < 2^9\))。
仍然使用上面的 DP,只是 \(r\) 变大了,三层循环的复杂度为 \(\mathcal{O}(n\cdot r^2\cdot\text{XOR范围})\),对于 \(n=20\) 勉强能过。
注意:如果 \(z\ge 512\),异或值不可能达到 \(z\)(因为所有数 \(\le 500\),异或值被限制在 \(<512\)),直接输出 \(0\) 即可。
参考代码
#include <iostream>
#include <cstring>
using namespace std;
using ll = long long;
const int MOD = 1e9 + 7;
const int MAXR = 500;
int main() {
ll n, l, r, z;
cin >> n >> l >> r >> z;
int R = (int)r;
const int MAX_XOR = 512;
static int dp[MAXR + 1][MAX_XOR], ndp[MAXR + 1][MAX_XOR];
dp[0][0] = 1;
for (int i = 0; i < n; i++) {
memset(ndp, 0, sizeof(ndp));
for (int s = 0; s <= R; s++) {
for (int x = 0; x < MAX_XOR; x++) {
int cur = dp[s][x];
if (cur == 0) continue;
for (int v = 0; s + v <= R; v++) {
ndp[s + v][x ^ v] = (ndp[s + v][x ^ v] + cur) % MOD;
}
}
}
memcpy(dp, ndp, sizeof(dp));
}
ll ans = 0;
int zi = (int)(z & (MAX_XOR - 1));
if (z >= MAX_XOR) { cout << "0\n"; return 0; }
for (int s = (int)l; s <= R; s++) {
ans = (ans + dp[s][zi]) % MOD;
}
cout << ans << '\n';
return 0;
}
\(n=2\) 特殊情况(数位 DP 入门)
问题简化
当 \(n=2\) 时,序列只有两个数 \(A_1, A_2\),要求:
- \(l \le A_1 + A_2 \le r\)
- \(A_1 \oplus A_2 = z\)
令 \(x = A_1\),则 \(A_2 = x \oplus z\)。问题转化为:统计 \(x\ge 0\) 的个数,使得:
如何避免枚举 \(x\)?
\(x\) 可以很大(到 \(10^{18}\) 级别),显然不能枚举。
关键思路:按照二进制位,从低位到高位,逐位确定 \(x\) 的每一位。
状态设计
dp[位][进位][下界标记][上界标记]
- 位(pos):当前处理到第几个二进制位(\(0\) 表示最低位)
- 进位(carry):来自低位的进位(\(0\) 或 \(1\))
- 下界标记(low):已处理低位的和 \(S = x + (x\oplus z)\) 是否 \(\ge l\) 的对应低位
- 上界标记(high):已处理低位的和 \(S\) 是否 \(\le r\) 的对应低位
状态的直观理解
想象你在玩一个拼数字游戏:
- 从最右边(最低位)开始,一位一位往左确定
- 每次确定 \(x\) 的一个二进制位 \(x_k\)
- 根据 \(x_k\) 和 \(z_k\) 可以算出 \((x\oplus z)_k = x_k \oplus z_k\)
- 两个 bit 相加,加上低位来的进位,得到当前位的和 bit 以及向高位的进位
- "下界标记"像一个小本本,记录到目前为止,我们已经拼出的数字是否已经比 \(l\) 大了(或还相等)
- "上界标记"类似,记录是否已经比 \(r\) 小了
转移
对于每个状态,尝试 \(x_k = 0\) 或 \(1\):
总位值 = x_k + (x_k ⊕ z_k) + 旧的进位
和的当前位 = 总位值 % 2
新的进位 = 总位值 / 2
然后更新下界和上界标记(按之前讲的"从低位到高位比较"规则)。
参考代码(\(n=2\) 完整实现)
#include <iostream>
#include <cstring>
using namespace std;
using ll = long long;
const int MOD = 1e9 + 7;
int main() {
ll n, l, r, z;
cin >> n >> l >> r >> z;
const int MAXB = 61; // 2^60 > 10^18
ll dp[2][2][2] = {}; // [carry][low][high]
ll ndp[2][2][2] = {};
// 初始化:0 个低位处理完,0≥0,0≤0,所以两个标记都是 1
dp[0][1][1] = 1;
for (int pos = 0; pos < MAXB; pos++) {
memset(ndp, 0, sizeof(ndp));
int lb = (l >> pos) & 1;
int rb = (r >> pos) & 1;
int zb = (z >> pos) & 1;
for (int carry = 0; carry <= 1; carry++)
for (int low = 0; low <= 1; low++)
for (int high = 0; high <= 1; high++) {
ll cur = dp[carry][low][high];
if (cur == 0) continue;
for (int xb = 0; xb <= 1; xb++) {
int other = xb ^ zb; // (x⊕z) 的当前位
int total = xb + other + carry;
int sb = total & 1; // 和的当前位
int nc = total >> 1; // 新的进位
// 更新下界标记
int nlow = low;
if (sb > lb) nlow = 1;
else if (sb < lb) nlow = 0;
// 更新上界标记
int nhigh = high;
if (sb < rb) nhigh = 1;
else if (sb > rb) nhigh = 0;
ndp[nc][nlow][nhigh] += cur;
}
}
memcpy(dp, ndp, sizeof(dp));
}
// 进位为 0,下界和上界标记都为 1
cout << dp[0][1][1] % MOD << '\n';
return 0;
}
100% 正解
从 \(n=2\) 推广到任意 \(n\)
当 \(n\) 任意时,思路类似,但需要处理两个新问题:
问题 1:进位不再只是 \(0\) 或 \(1\)
\(n\) 个数的当前 bit 最多有 \(n\) 个 \(1\),加上进位,进位最大可达 \(n\)。
问题 2:需要乘以组合数
对于每个二进制位,我们需要从 \(n\) 个数中选 \(t\) 个在该位上放 \(1\),方案数为 \(\mathrm{C}(n,t)\)。
同时异或约束要求 \(t\bmod 2 = z_k\)(该位 \(1\) 的个数为奇数时异或结果为 \(1\))。
状态设计
dp[位][进位][下界标记][上界标记]
与 \(n=2\) 的 DP 状态完全相同,只是进位的范围变成了 \(0\sim n\)。
预处理转移表
对于每个可能的进位 \(c\) 和当前位需要的异或值 \(z_k\),预处理一个转移表:
trans[c][z_k][sb][nc] = 对 t 求和 C[n][t]
// 条件:t % 2 == z_k, (t + c) % 2 == sb, (t + c) / 2 == nc
这样在 DP 中直接查表即可,不需要每次循环枚举 \(t\)。
转移
对于状态 (carry, low, high):
枚举 (sb, nc):
ways = trans[carry][z_k][sb][nc]
nlow = (sb > l_k) ? 1 : (sb < l_k) ? 0 : low
nhigh = (sb < r_k) ? 1 : (sb > r_k) ? 0 : high
dp_next[nc][nlow][nhigh] += dp[carry][low][high] * ways
通俗理解
想象你有 \(n\) 个计数器,每个计数器显示一个二进制数。你从最低位开始,一位一位地确定每个计数器在这一位是 \(0\) 还是 \(1\)。
在位置 \(k\):
- 你要决定有多少个计数器在这一位写 \(1\)(记为 \(t\))
- \(t\) 必须是奇数还是偶数?看 \(z\) 在这一位是 \(1\)(奇数)还是 \(0\)(偶数)
- 把 \(t\) 和低位来的进位 \(c\) 加起来:\(t + c\)
- 结果的个位就是总和的这一位,十位就是新的进位
- 总和这一位影响上下界比较;新的进位传给下一位
- 选择 \(t\) 个计数器的方案有 \(\mathrm{C}(n,t)\) 种
复杂度分析
- 状态数:\(\mathcal{O}(\log r \cdot n)\)(约 \(61\times 1000 \approx 6\times 10^4\))
- 转移:通过预处理,每个状态 \(\mathcal{O}(n)\)(查表的 \(2n\) 种 \((sb,nc)\))
- 总复杂度:\(\mathcal{O}(n^2\log r)\)(约 \(6\times 10^7\) 次操作,可过)
参考代码(AC)
#include <iostream>
#include <cstring>
using namespace std;
using ll = long long;
const int MOD = 1e9 + 7;
const int MAXN = 1000;
int n;
int C[MAXN + 1][MAXN + 1]; // 组合数
int trans[MAXN + 1][2][2][MAXN + 1]; // 转移表 [carry][z_bit][sum_bit][new_carry]
int dp[MAXN + 1][2][2]; // DP 数组
int ndp[MAXN + 1][2][2]; // 滚动数组
int main() {
ll l, r, z;
cin >> n >> l >> r >> z;
// --- 1. 预处理组合数 C[n][k] ---
for (int i = 0; i <= n; i++) {
C[i][0] = C[i][i] = 1;
for (int j = 1; j < i; j++)
C[i][j] = (C[i - 1][j - 1] + C[i - 1][j]) % MOD;
}
// --- 2. 预处理转移表 ---
for (int c = 0; c <= n; c++) {
for (int zb = 0; zb <= 1; zb++) {
for (int t = 0; t <= n; t++) {
if ((t & 1) != zb) continue; // 异或约束
int total = t + c;
int sb = total & 1;
int nc = total >> 1;
if (nc > n) continue;
trans[c][zb][sb][nc] = (trans[c][zb][sb][nc] + C[n][t]) % MOD;
}
}
}
// --- 3. 数位 DP ---
const int MAXB = 61; // 10^18 < 2^60
dp[0][1][1] = 1; // 初始:0 ≥ 0,0 ≤ 0
for (int pos = 0; pos < MAXB; pos++) {
int lb = (l >> pos) & 1;
int rb = (r >> pos) & 1;
int zb = (z >> pos) & 1;
// 清空 ndp
memset(ndp, 0, sizeof(ndp));
for (int c = 0; c <= n; c++) {
for (int low = 0; low <= 1; low++) {
for (int high = 0; high <= 1; high++) {
int cur = dp[c][low][high];
if (cur == 0) continue;
for (int sb = 0; sb <= 1; sb++) {
for (int nc = 0; nc <= n; nc++) {
int ways = trans[c][zb][sb][nc];
if (ways == 0) continue;
int nlow = low;
if (sb > lb) nlow = 1;
else if (sb < lb) nlow = 0;
int nhigh = high;
if (sb < rb) nhigh = 1;
else if (sb > rb) nhigh = 0;
ndp[nc][nlow][nhigh] = (ndp[nc][nlow][nhigh] + (ll)cur * ways) % MOD;
}
}
}
}
}
// 滚动:将 ndp 复制到 dp
memcpy(dp, ndp, sizeof(dp));
}
// 最终:进位为 0,且总和在 [l, r] 范围内
cout << dp[0][1][1] << '\n';
return 0;
}
T4 总结:从暴力到正解的思维链
| 阶段 | 方法 | 关键优化 | 适用条件 |
|---|---|---|---|
| 暴力 | 枚举所有序列 | — | — |
| 20% | \(dp[\text{sum}][\text{xor}]\) | 利用 \(r\) 小 | \(r\le 30\) |
| 40% | 同上 | 利用 \(n\) 小 | \(n\le 20, r\le 500\) |
| \(n=2\) | 数位 DP(低位到高位) | 利用低位比较法 | \(n=2\) |
| 100% | 数位 DP + 组合数 | 预处理转移表,\(\mathcal{O}(n^2\log r)\) | 全部 |
核心思想总结:
- 二进制分解:按位处理,化整为零
- 低位到高位比较:用两个 tag 标记与 \(l\) 和 \(r\) 的大小关系
- 异或转奇偶:\(n\) 个数某位异或 \(\rightarrow\) 该位 \(1\) 的个数的奇偶性
- 组合数加权:选择哪些数在某位为 \(1\) 是组合问题
祝同学们在 CSP-J 中取得好成绩!
浙公网安备 33010602011771号