多重背包恰好凑 k 的最少硬币数:按面值分阶段松弛,同余类内换元成滑动窗口最小值,单调队列 O(nk) 完成每种面值的批量更新。
OJ: roj
题目 ID: 1601
难度:提高
标签:动态规划背包多重背包单调队列python
创建: 2026-09-30 21:17
更新: 2026-10-07 13:50
形式化题目
给定
使得硬币总数
样例:
3
2 3 5
2 2 1
10最优付法是
正解
思路
建模。 设
处理完全部
先排除两个直觉做法。 贪心"面值从大到小取"是错的:面值
瓶颈与中间站。 瓶颈在"一种面值用
观察一:转移按同余类封闭。 转移
观察二:换元后是滑动窗口。 令
每个
用样例走一遍,
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 初始 | 0 | ||||||||||
| 加入 |
0 | 1 | 2 | ||||||||
| 加入 |
0 | 1 | 1 | 2 | 2 | 2 | 3 | 3 | 4 | ||
| 加入 |
0 | 1 | 1 | 2 | 1 | 2 | 2 | 2 | 3 | 3 |
注意
再单独看同余类 old 取上表"加入
| 位置 |
键 |
队列(存 |
窗口队首 |
|||
|---|---|---|---|---|---|---|
| 0 | 1 | 0 | ||||
| 1 | 4 | 2 | 1 | 1 | ||
| 2 | 7 | 1 | ||||
| 3 | 10 | 1 |
队列里只有
正确性靠归纳:old 是"只用前
代码
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-09-30 21:17
# update_at: 2026-09-30 21:26
import sys
from collections import deque
INF = 10**9 # 不可达哨兵(真实答案至多 k,远小于它)
def relax(old: list[int], dp: list[int], w: int, c: int, k: int) -> None:
"""加入面值 w、数量不超过 c 的硬币:对每个同余类做单调队列优化的有界松弛。"""
if w > k: # 面值超过目标的硬币一枚也用不上
return
# 位置 r+m*w 上(m 表示"该类里第几个位置"):
# dp[r+m*w] = min_{t<=c} old[r+(m-t)*w] + t = m + min_{u in [m-c, m]} (old[r+u*w] - u)
# 窗口 [m-c, m] 只向右滑动,用单调队列维护键 old[r+u*w] - u 的最小值。
for r in range(w): # 同余类内部位置等距 w,类间互不干扰
dq: deque[int] = deque()
m = 0
while (pos := r + m * w) <= k:
key = old[pos] - m # 候选下标 u=m 的键
while dq and old[r + dq[-1] * w] - dq[-1] >= key:
dq.pop() # 队尾更差的下标不可能再成为最小值
dq.append(m)
while m - dq[0] > c: # 队首滑出窗口 [m-c, m]
dq.popleft()
dp[pos] = m + old[r + dq[0] * w] - dq[0]
m += 1
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
values = [next(data) for _ in range(n)] # 面值 b_i(严格递增)
counts = [next(data) for _ in range(n)] # 各面值数量 c_i
k = next(data) # 要凑的目标面值
dp = [INF] * (k + 1)
dp[0] = 0 # 0 面值付 0 个硬币
for w, c in zip(values, counts):
relax(dp[:], dp, w, c, k) # 松弛前先取快照,同余类内要读"上一轮"的值
print(dp[k])
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-05 12:00
* update_at: 2026-10-05 12:00
*/
// 多重背包恰好凑出 k 的最少硬币数:每种面值一个同余类 + 滑动窗口最小值(单调队列)。
#include <cstdio>
typedef long long ll;
const ll MAXK = 2 * 10 * 1000 + 5; // k <= 2*10^4
const ll INF = 1e9; // 不可达哨兵(真实答案至多 k,远小于它)
ll n, k;
ll val[205], cnt[205]; // val[i] 第 i 种面值,cnt[i] 该面值的数量上限
ll dp[MAXK]; // dp[v] = 恰好凑出面值 v 的最少硬币数(不可达为 INF)
ll olddp[MAXK]; // 本阶段松弛前的快照:转移只能读上一轮的值
ll que[MAXK]; // 单调队列存同余类内的位置编号 m(0,1,2,...),键 old[r+mw]-m 递增
// 加入面值 w、数量上限 c 的一次有界松弛:把 dp 数组更新为"用前若干种面值"的最优表。
// 按模 w 分同余类,类内第 m 个位置是 v = r + m*w:
// dp[r+mw] = min_{0<=t<=min(c,m)} ( old[r+(m-t)w] + t )
// 换元 u = m-t 得 dp[r+mw] = m + min_{u in [m-c, m]} ( old[r+uw] - u ),
// 窗口 [m-c, m] 每步右移一格,用单调队列维护键 old[r+uw]-u 的最小值。
void relax_coin(ll w, ll c) {
if (w > k) return; // 面值超过目标的硬币一枚也用不上,直接跳过这一阶段
for (ll r = 0; r < w; r++) { // 同余类之间互不干扰,一条一条做
ll head = 1, tail = 0; // 队列区间 [head, tail],空队列 head > tail
for (ll m = 0; r + m * w <= k; m++) {
ll pos = r + m * w;
ll key = olddp[pos] - m; // 候选下标 u=m 的键
while (head <= tail && olddp[r + que[tail] * w] - que[tail] >= key)
tail--; // 队尾键不更小,以后永远轮不到当最小值,弹出
que[++tail] = m; // 当前位置入队
while (m - que[head] > c) // 队首滑出窗口 [m-c, m],数量上限在这里生效
head++;
dp[pos] = m + olddp[r + que[head] * w] - que[head];
}
}
}
int main() {
scanf("%lld", &n);
for (ll i = 1; i <= n; i++) scanf("%lld", &val[i]);
for (ll i = 1; i <= n; i++) scanf("%lld", &cnt[i]);
scanf("%lld", &k);
for (ll v = 1; v <= k; v++) dp[v] = INF; // 一开始只有 0 面值可达
dp[0] = 0;
for (ll i = 1; i <= n; i++) {
for (ll v = 0; v <= k; v++) olddp[v] = dp[v]; // 松弛前先取快照
relax_coin(val[i], cnt[i]);
}
printf("%lld\n", dp[k]);
return 0;
}复杂度
- 时间复杂度:
。每个面值 产生 个同余类,类内位置总数 , 单调队列摊还 ⇒ 每面值 ;最坏 次操作。 面值 的硬币一枚也用不上,实现直接跳过这一阶段,实际常数更小。 对比朴素 、二进制拆分 。 - 空间复杂度:
, dp与快照old两个长度的数组加单调队列。 - 实测:21 组真实数据全部 PASS,最慢 0.520s、峰值 16.7MB(OJ 限时 1000ms / 128MB; Python 在慢机上仍有 TLE 风险,属允许范围)。
总结
- 多重背包"数量大、容量小"时,逐枚松弛和二进制拆分都把成本压在
上; 同余类拆分 把内层从"扫全部 "换成"沿一条等差数列滑窗",成本落到 。 - 换元
是关键一步:它把" “变成” ", 窗口长度与 无关,滑动窗口最小值的标准结构才显现出来。 - 单调队列的两句话不变量:队尾淘汰保证队首是最小,队首过期保证最小还活着。
样例表中
展示了数量上限如何由窗口边界 精确表达。 - 贪心在非规范币制下没有最优子结构(
凑 6 的反例),必须依赖 DP 的全局性。