三种奖券:从暴力枚举n³到logM
2026-10-17 18:17:48
发布于:浙江
问题描述
给定三种奖券,面额分别为正整数 ,每种可取任意张。目标总分为 ,其中
求方程 的解的个数。
三种奖券按种类区分。即使面额相同,不同的取用数量三元组 仍算不同方案。
答案范围与整数类型
由于 ,任何合法解都满足 。固定 后, 至多有一个合法值,因此
当 时取到等号。于是最大答案为
可以用 long long 保存。下面的代码用 long long 读取输入,系数乘法和整除求和的中间量统一使用 __int128。
五种方法概览
记
记
- 方法 1 :枚举三个变量,
- 方法 2 :枚举两个变量,计算第三个,
- 方法 3 :枚举一种,公式计算二元方程解数,
- 方法 4 :完全背包 DP,
- 方法 5 :同余筛选与整除求和,
以下代码使用 C++17,记 ll = long long、i128 = __int128。方法 3 使用的 floorDiv、invMod 定义见末尾完整代码。
方法 1:三重循环
三个变量分别满足
逐一枚举并检查即可。
ll solve1(ll a, ll b, ll c, ll s) {
ll ans = 0;
for (ll x = 0; x <= s / a; x++)
for (ll y = 0; y <= s / b; y++)
for (ll z = 0; z <= s / c; z++)
if (a * x + b * y + c * z == s)
ans++
return ans;
}
当 时,循环次数为 ,无法处理较大的 。
方法 2:消去一个变量
固定 ,令
若 且 ,则有唯一的合法值 。否则无解。
让内层循环的上界保证剩余分数非负,就只需检查整除条件。
ll solve2(ll a, ll b, ll c, ll s) {
ll ans = 0;
for (ll x = 0; x <= s / a; x++) {
ll r = s - a * x;
for (ll y = 0; y <= r / b; y++)
if ((r - b * y) % c == 0)
ans++;
}
return ans;
}
最坏情况下仍需平方级枚举。下一步是用公式一次算出整个内层循环的贡献。
方法 3:枚举一种,计算二元方程解数
固定 后,需要计算 的非负整数解个数。
1. 约去最大公因数
令 。若 ,则无解;否则令
方程化为
2. 写出全部整数解
取整数 ,满足
当 时,可以取
其中 。当 时,直接取 。
将等式乘以 ,得到全部整数解
3. 用非负条件确定参数范围
由 ,有
利用 ,得到
这里不需要再取 。因为 ,实数区间的长度为
因此整数计数公式不会为负;区间内没有整数时,结果恰好为零。
注意: 可能为负数,必须使用数学意义的向下取整,不能直接依赖 C++ 对负数的整数除法。
代码
把最大面额放在 的位置,使外层枚举次数最少。
ll solve3(ll a, ll b, ll c, ll s) {
array<ll, 3> f{a, b, c};
sort(f.begin(), f.end());
a = f[2]; b = f[0]; c = f[1];
ll g = gcd(b, c), B = b / g, C = c / g;
ll U = 0;
i128 V = 1;
if (C > 1) {
U = invMod(B, C);
V = (1 - (i128)B * U) / C;
}
i128 ans = 0;
for (ll x = 0; x <= s / a; x++) {
ll r = s - a * x;
if (r % g != 0) continue;
ll n = r / g;
ans += 1 + floorDiv((i128)U * n, C) + floorDiv(V * n, B);
}
return (ll)ans;
}
当三种面额都很小时,外层循环仍然较长。方法 5 会把这个循环也转化为公式。
方法 4:完全背包 DP
令 表示使用已经处理的奖券种类,凑出 分的方案数。
初始状态为
对于每一种面额 ,按分数递增更新
ll solve4(ll a, ll b, ll c, ll s) {
vector<ll> f(s + 1, 0);
f[0] = 1;
for (ll v : {a, b, c})
for (ll j = v; j <= s; j++)
f[j] += f[j - v];
return f[s];
}
两层循环各有作用:
- 种类在外层:每个数量组合只计数一次。
- 分数递增:允许当前种类重复取用。
时间和空间复杂度均为 ,不适合本题中最大的目标分数。
方法 5:同余筛选与整除求和
1. 把枚举变量筛成等差数列
仍令 。因为 必须是 的倍数,所以
令
若 ,原方程无解;否则约去 ,得到
由于 ,最小非负解为
所有非负候选都可以写成
如果 ,最小候选已经超出目标,直接返回零。
2. 把每个候选的贡献写成两个取整式
定义
代回原方程,并利用 ,得到
右端非负要求
因此候选个数为
继续使用 ,每个候选的贡献为
定义整除求和函数
其中 允许为负数。总方案数就变为
问题转化为:如何快速计算 ?
3. 整倍数分离
按数学意义的带余除法,写成
其中
逐项分离整数部分,得到
即使 为负数,这个公式也成立。
4. 换序计数
下面假设已经归一化为 。令
若 ,每一项均为零,可以直接结束。特别地, 时一定在这里结束,不会出现除以零。
否则 。把每项取整看成满足 的正整数 的个数,交换计数顺序:
其中第二步使用了 和 。
因此,一轮变换为
新的除数是原来的 。结合下一轮的取模操作,整个过程沿欧几里得算法递降,复杂度为 。
完整代码
#include <algorithm>
#include <array>
#include <iostream>
#include <numeric>
using namespace std;
using ll = long long;
using i128 = __int128;
// m > 0;按数学定义向下取整。
i128 floorDiv(i128 a, i128 m) {
return a / m - (a % m < 0);
}
// m > 1 且 gcd(a, m) = 1。
ll invMod(ll a, ll m) {
i128 r0 = a % m, r1 = m;
i128 u0 = 1, u1 = 0;
while (r1 != 0) {
i128 k = r0 / r1;
i128 r2 = r0 - k * r1;
i128 u2 = u0 - k * u1;
r0 = r1; r1 = r2;
u0 = u1; u1 = u2;
}
u0 %= m;
if (u0 < 0) u0 += m;
return (ll)u0;
}
// n >= 0,m > 0;p、q 可以为负数。
i128 floorSum(i128 n, i128 m, i128 p, i128 q) {
i128 ans = 0;
while (true) {
i128 u = floorDiv(p, m);
i128 v = floorDiv(q, m);
ans += u * n * (n - 1) / 2 + v * n;
p -= u * m;
q -= v * m;
i128 Y = p * n + q;
if (Y < m) return ans;
n = Y / m;
q = Y % m;
swap(m, p);
}
}
ll solve(ll a, ll b, ll c, ll s) {
// 交换面额不会改变解的个数;最大面额放在枚举变量处。
array<ll, 3> f{a, b, c};
sort(f.begin(), f.end());
a = f[2]; b = f[0]; c = f[1];
ll g = gcd(b, c);
ll h = gcd(a, g);
if (s % h != 0) return 0;
ll d = g / h;
ll x0 = 0;
if (d > 1)
x0 = (ll)((i128)(s / h) * invMod(a / h, d) % d);
if ((i128)a * x0 > s)
return 0;
ll A = a / h;
ll B = b / g;
ll C = c / g;
ll R = (ll)(((i128)s - (i128)a * x0) / g);
ll N = R / A + 1;
ll U = 0;
i128 V = 1;
if (C > 1) {
U = invMod(B, C);
V = (1 - (i128)B * U) / C;
}
i128 ans = (i128)N + floorSum(N, C, -(i128)U * A, (i128)U * R) + floorSum(N, B, -V * A, V * R);
return (ll)ans;
}
int main() {
ll a, b, c, s;
if (!(cin >> a >> b >> c >> s)) return 0;
cout << solve(a, b, c, s) << '\n';
}
时间复杂度为 。
这里空空如也











有帮助,赞一个