把“某邮资能否贴出”换成“最少需要几张”,用一个大整数的第 v 位表示邮资 v 可达;每轮对所有面值做左移按位或,重复至多 K 次后数连续 1 前缀。
OJ: roj
题目 ID: 2045
难度:普及+/提高-
标签:动态规划背包完全背包位运算python
创建: 2026-10-01 04:54
更新: 2026-10-07 13:50
形式化题目
给定邮票面值集合
求最大的
规模上
正解
思路
先看最直白的做法。 维护一个集合
重复
瓶颈在哪。 两个地方都浪费:
- 整个"张数"维度是多余的。 我们要回答的只有"存在某个
能凑出 ", 而这等价于"凑出 最少需要几张 "。既然只关心最少张数, 就没有必要把 每一层都算一遍。 - 集合的元素级操作太慢。 用 Python 的
set或数组存,每个元素都要单独循环, 最坏是 次操作。
第一步:把问题换成"最少张数"。 记
答案是
这正是"重量为面值、每种邮票可以反复取、求恰好装满时的最少件数"的完全背包:
外层把
第二步:容量上界取
一定不可贴出:凑出它至少要 张邮票(每张面值不超过 )。 - 答案
: 全部可贴出,而任何可贴出的邮资不超过 , 所以 也在 以内。也就是只需在 上记录可达性, "从 1 往后第一个不可达位置"必定在 或更早出现。
这一步很关键:上界由最大面值
第三步:用大整数承载整个集合。 把"可达"编码进一个整数的位:第 reachable << s,"求并"就是按位或。于是上面那个
reachable = LAYER0 # 第 0 层 = 1,只有邮资 0 可达
重复至多 K 次:
nxt = reachable
对每个面值 s:nxt |= (reachable << s) & mask
reachable = nxt一次移位或就覆盖整段
三个实现细节必须注意:
- 内层用的是
reachable而不是nxt。 一轮只加一张邮票,所有平移都以同一层为基准; 若写成nxt << s,一轮里就会连续叠加多张,层数含义就错了。 & mask不能省。 否则位宽每轮增长,整数越来越长,移位越来越慢。 nxt == reachable时提前停。 这说明再贴邮票也贴不出新邮资, 于是之后每一轮都等于当前层,继续迭代纯属浪费。
最后是取答案:reachable 里第
用样例走一遍。
t=0: 1000000000000000
t=1: 1101000000000000
t=2: 1111101000000000
t=3: 1111111101000000
t=4: 1111111111101000
t=5: 1111111111111101
0123456789012345 <- 邮资每一行恰好是"不超过
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 04:56
# update_at: 2026-10-01 04:56
import sys
# 一个整数当成一个"位集合":bit v 为 1 表示邮资 v 可达。
# 这就是第 0 层的值——一张邮票都没贴,只有邮资 0 可达。
LAYER0 = 1
def min_impossible(cap: int, values: list[int], limit: int) -> int:
"""返回从 1 开始不能连续贴出的最小邮资;cap 是答案的搜索上界。"""
# 多贴一张邮票 = 再选一个面值:第 t 层就是第 t-1 层整体左移 s 位后按位或(每个面值 s)。
# 层内只做常数次大整数运算,因此只保留一层整数,不需要 O(K) 层的数组。
mask = (1 << (cap + 1)) - 1 # 只保留邮资 0..cap,位宽不随层数增长
reachable = LAYER0
for _ in range(limit):
nxt = reachable
for s in values:
nxt |= (reachable << s) & mask
if nxt == reachable: # 再贴也贴不出新邮资,更多张数同样贴不出
break
reachable = nxt
# 邮资 v(v >= 1)可达 <=> reachable 的第 v 位为 1,等价于第 v-1 位为 0 的 gaps 里那一位置 1。
gaps = ~(reachable >> 1)
lowest_gap = gaps & -gaps # 最低的 1 位,就是第一个不可达邮资
return lowest_gap.bit_length() - 1
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
stamps, kinds = next(data), next(data) # 可用邮票张数上限 K,面值种数 N
values = sorted({next(data) for _ in range(kinds)}) # 重复面值不影响可达性,先去重
# 答案不会超过 K * 最大面值:K*max+1 至少要 K+1 张邮票,本来就不可能贴出;
# 而一旦出现空隙,它必是最小的不可达值——比它大的邮资都能用减法回到它之前,
# 所以更早处就已经断了。于是搜索上界取 K*max 即可。
cap = stamps * values[-1]
print(min_impossible(cap, values, stamps))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 09:41
* update_at: 2026-10-06 09:41
*/
// 完全背包求"凑出每个邮资最少要几张邮票",从 1 开始数连续可达的邮资个数。
#include <cstdio>
typedef long long ll;
const ll MAXV = 200 * 10000 + 5; // 邮资上界 K * max(面值) + 余量,答案不会超过 K * 最大面值
int k; // 最多能贴的邮票张数
int n; // 邮票面值种数
int val[55]; // val[i] = 第 i 种邮票的面值
int f[MAXV]; // f[v] = 凑出邮资 v 所需的最少邮票张数,INF 表示暂时凑不出
const int INF = 0x3f3f3f3f;
int main() {
scanf("%d %d", &k, &n);
for (int i = 1; i <= n; ++i)
scanf("%d", &val[i]);
int cap = k * 10000; // 每张邮票面值 <= 10000,超过 cap 的邮资一定贴不出,无需计算
// 完全背包:v 从小到大扫(面值可重复选取),f[v] = 1 + min(f[v - s])
f[0] = 0;
for (int v = 1; v <= cap; ++v) {
f[v] = INF;
for (int i = 1; i <= n; ++i) {
int s = val[i];
if (s <= v && f[v - s] + 1 < f[v])
f[v] = f[v - s] + 1;
}
}
// 从 1 开始找第一个"最少张数 > k"的邮资,它前面连续可达的个数就是答案
int ans = 0;
while (ans + 1 <= cap && f[ans + 1] <= k)
++ans;
printf("%d\n", ans);
return 0;
}复杂度
设
- 参考解法(完全背包)的时间:
次元素级转移,也就是本题的标准做法。 时是 次转移,C++ 轻松通过; 在 Python 里逐元素循环实测要 6.1s。 - 本文实现的时间:同一条转移方程,但用位并行执行——外层至多
轮, 每轮对 个面值各做一次 位整数的移位与按位或,共 次大整数运算, 折算成字级操作是 。 这个字级工作量其实是参考解法的 倍( , ): 位并行没有减少理论运算量,它换来的是每次操作由处理器整字完成。 在 Python 里这一换非常划算——同样的 stamps10.in从 6.1s 降到 0.43s, 才留出了对 1000ms 限制的余量。 - 空间:只保存
reachable、nxt、mask三个大整数。时 每个整数实测约 0.25MB,进程峰值内存 17.9MB。作为对照,参考解法那个长度为 的 int列表本身就要约 15MB 的指针数组,进程峰值 30.0MB——两者都能塞进 128MB, 但位集合的余量大得多。 - 实测:13 组真实数据全部 PASS。最强数据
stamps10.in(, ,面值最大 ,答案 )耗时 0.43s / 峰值 17.9MB, 对 1000ms / 128MB 分别有约 2.3 倍和 7 倍余量。
总结
- “能不能"换成"最少要几个”。 题面问的是可达性,但按张数分层会白白重复
遍; 一旦改问"凑出 最少几张",整个张数维度就消失了,一次背包扫完所有 。 - 完全背包的扫描方向由"能不能重复取"决定。 这里的转移
只依赖同一层里更小的 ,所以 必须从小到大扫,读到的就是本轮已更新的值, 面值可以重复选取;若每种面值只能用一次,就必须倒序扫。 - 上界要用
。 它是由"每张邮票至多 "推出来的,与最小面值无关; 写成 会截断答案,样例 、 就是反例( )。 - 面值只能去重,不能删除。 看似"被别的面值覆盖"的面值可能恰恰是答案的关键:
删掉任一个,答案就从 掉到 或 ; 、 时 删掉 会让答案从 变成 。代码只做去重。 - 位运算是"集合平移 + 并"的天然载体。 把可达性编码成第
位, 一次左移就是"全体加 ",一次按位或就是求并, 轮迭代成了 次大整数运算。 代价是要守住三条纪律:以本层为基准、及时截位、遇不动点即停。