模拟赛 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\)):

  1. 如果 \(2\mid n\)\(n\leftarrow \dfrac{n}{2}\)
  2. 如果 \(3\mid n\)\(n\leftarrow \dfrac{2n}{3}\)
  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}\)

操作:

  1. 交换:任意交换两个位置上的字符(最多一次,代价 \(0\)
  2. 修改:将字符 \(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\)

思路

最朴素的做法:

  1. 枚举所有可能的交换位置 \((i,j)\),共 \(\mathcal{O}(n^2)\)
  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\)(交换发生在同一个对称对内),则交换无效果。

因此不需要每次重新计算整个字符串,只需:

  1. 预先计算基准代价 \(\text{base}\)(不交换)
  2. 枚举交换对 \((i,j)\)
  3. 减去这两对原来的代价,加上交换后的新代价

复杂度 \(\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\) 中只出现 ab 两种字符时,字母集大小从 \(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\),满足:

  1. \(l\le \sum_{i=1}^n A_i \le r\)
  2. \(\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\),转移:

\[dp'[s+v][x\oplus v] \mathrel{+}= dp[s][x] \]

复杂度\(\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\) 的个数,使得:

\[l \le x + (x\oplus z) \le r \]

如何避免枚举 \(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. 你要决定有多少个计数器在这一位写 \(1\)(记为 \(t\)
  2. \(t\) 必须是奇数还是偶数?看 \(z\) 在这一位是 \(1\)(奇数)还是 \(0\)(偶数)
  3. \(t\) 和低位来的进位 \(c\) 加起来:\(t + c\)
  4. 结果的个位就是总和的这一位,十位就是新的进位
  5. 总和这一位影响上下界比较;新的进位传给下一位
  6. 选择 \(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)\) 全部

核心思想总结

  1. 二进制分解:按位处理,化整为零
  2. 低位到高位比较:用两个 tag 标记与 \(l\)\(r\) 的大小关系
  3. 异或转奇偶\(n\) 个数某位异或 \(\rightarrow\) 该位 \(1\) 的个数的奇偶性
  4. 组合数加权:选择哪些数在某位为 \(1\) 是组合问题

祝同学们在 CSP-J 中取得好成绩!

posted @ 2026-07-15 14:24  surprise_ying  阅读(26)  评论(0)    收藏  举报