把购物清单压成不超过 5 维的剩余需求向量,用记忆化搜索在该状态图上求最短路:每次转移要么按原价买一件,要么套用一个不超过剩余需求的优惠套餐。
OJ: roj
题目 ID: 2053
难度:普及+/提高-
标签:动态规划记忆化搜索背包python
创建: 2026-10-01 05:22
更新: 2026-10-07 13:50
形式化题目
给定若干种优惠套餐,每个套餐是一组商品的数量组合加上一个总价;再给定一张购物清单,列出要买的商品种类、每种的数量和它的原价。
购买时可以对同一套餐使用任意多次,也可以用原价单独购买任意一件商品。要求恰好买齐清单上的每一种商品,不允许顺带多买清单之外的商品,也不允许为了凑优惠而多买清单内的商品。
求买齐清单所需的最小总花费。
形式化地,设清单共有
正解
思路
朴素的枚举。 最直接的想法是从需求向量
这个搜索是正确的,但会爆炸。优惠套餐最多
瓶颈在哪里。 真正的问题不是"方案多",而是同一个剩余需求被反复求解。用样例来说,清单要买 3 朵花、2 个花瓶,下面两条完全不同的搜索路径会到达同一个剩余向量
- 按原价买 3 次花,单价 2z,共花 6z;
- 套一次"3 朵花 = 5z"的套餐,花 5z。
到达
状态有多大。 清单最多 5 种商品,每种最多买 5 件,所以每个维度取值范围是
把
转移的两条边。 把问题看成一张状态图,节点是剩余需求向量 left,从 left 出发有两种边:
- 原价边:把某一维减 1,代价是该商品单价
。 - 套餐边:如果
left - v^{(j)}每一维都非负,就走到那个状态,代价是套餐价。这个非负条件正是题面"不能多买"的直接翻译。
因为每个套餐在每一维上的份数都不超过该商品的总需求(两者都受 5 的约束),所以同一个套餐无论重复几次,都会被"某一维扣成负数"挡住——用完即止,天然有界,不需要额外记录"某套餐用了几次"的维度,状态依然只有
为什么不会死循环。 两类转移都把
于是得到值函数
边界
以样例验证。 样例清单是 3 朵花(单价 2z)和 2 个花瓶(单价 5z),套餐有"3 朵花 5z"和"2 花瓶 + 1 花 10z"。下表列出两维状态网格中每个
| 花瓶 0 | 花瓶 1 | 花瓶 2 | |
|---|---|---|---|
| 花 0 | 0 | 5 | 10 |
| 花 1 | 2 | 7 | 10 |
| 花 2 | 4 | 9 | 12 |
| 花 3 | 5 | 10 | 14 |
读者可以重点观察三个格子。
注意
实现细节。 优惠套餐里可能出现购物清单之外的商品,比如清单只有编号 7,套餐却是"1 件 7 号 + 2 件 8 号"。这样的套餐一旦使用就必然多买 8 号,与题面矛盾,必须在预处理阶段丢弃:把套餐的编号对逐一代入清单映射,任何一个编号不在映射里就整条丢弃。剩下的套餐写成维度向量;若两条套餐扣的向量相同,只保留便宜的那条。
状态用元组表示,元组可哈希,正好可以直接当 functools.cache 的键,省掉手工进制编码。
代码
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 05:22
# update_at: 2026-10-01 05:22
import sys
from functools import cache
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
# offer = ((单品下标, 份数), ...) + 优惠价;只保留被购物清单覆盖的方案,
# 这样状态向量的每一维都恰好对应一种要买的商品。
raw: list[tuple[list[tuple[int, int]], int]] = []
for _ in range(next(data)):
n = next(data)
pairs = [(next(data), next(data)) for _ in range(n)] # 必须立即求值,否则 p 会先被读走
raw.append((pairs, next(data)))
count_of_species = next(data)
order = [next(data) for _ in range(count_of_species * 3)]
# 清单按商品编号排序,方便下面把优惠方案里的编号换算成状态维度下标。
species = sorted((order[i * 3], order[i * 3 + 1], order[i * 3 + 2]) for i in range(count_of_species))
index_of = {c: i for i, (c, _, _) in enumerate(species)}
want = tuple(k for _, k, _ in species)
# 每个方案表示成 len(species) 长的份数向量 + 价格;同一个向量可能有多个报价,取最小。
deals: dict[tuple[int, ...], int] = {}
for pairs, price in raw:
need = [0] * count_of_species
for c, k in pairs:
if c not in index_of: # 方案里出现清单外的商品:题面禁止顺带购买,丢弃
break
need[index_of[c]] = k
else:
key = tuple(need)
if key != tuple([0] * count_of_species): # 空方案等于白送,不合题意
deals[key] = min(deals.get(key, price), price)
options = [(key, price) for key, price in deals.items()]
@cache
def best(left: tuple[int, ...]) -> int:
"""left 为各商品还差几件时的最低花费:要么按原价买一件,要么用某个能装下的方案。"""
if not any(left):
return 0
ans = 10**9
for i, rest in enumerate(left):
if rest: # 按原价补一件,保证状态一定向 0 收敛
ans = min(ans, species[i][2] + best(left[:i] + (rest - 1,) + left[i + 1:]))
for key, price in options:
nxt = tuple(a - b for a, b in zip(left, key))
if min(nxt) >= 0: # 方案不超过剩余需求,题面禁止多买
ans = min(ans, price + best(nxt))
return ans
print(best(want))
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:59
* update_at: 2026-10-06 09:59
*/
#include <algorithm>
#include <iostream>
using namespace std;
typedef long long ll;
const int MAXS = 100; // 优惠套餐数上限
const int MAXDIM = 5; // 清单商品种类数上限
const int STATE = 7776; // 6^5:每种商品最多买 5 件,状态编码上界
struct Species {
ll id; // 商品编号
ll num; // 需要购买的件数
ll price; // 原价
};
ll species_cnt;
Species species[MAXDIM]; // 购物清单,排序后第 i 项对应状态的第 i 维
int index_of[1000]; // index_of[商品编号] = 维度下标,不在清单中则为 -1
ll raw_cnt[MAXS]; // 第 t 个套餐包含的商品种类数
ll raw_id[MAXS][MAXDIM]; // 第 t 个套餐的第 i 种商品编号
ll raw_num[MAXS][MAXDIM]; // 第 t 个套餐的第 i 种商品份数
ll raw_price[MAXS]; // 第 t 个套餐的优惠价
ll deal_code[MAXS]; // 保留的套餐编码成 6 进制数
ll deal_digit[MAXS][MAXDIM]; // 保留的套餐在各维上的份数
ll deal_price[MAXS]; // 保留的套餐的优惠价
ll deal_cnt; // 去重后保留的套餐数
ll best_price[STATE]; // best_price[code] 存同一份数向量的最低套餐价,-1 表示无
ll memo[STATE]; // memo[code] = 剩余需求为 code 时的最低花费,-1 表示未算过
ll pow6[MAXDIM + 1]; // pow6[i] = 6^i,用于状态与向量的进位编码
// 按商品编号升序排列,使清单维度与编号顺序对应,便于把套餐换算成维度向量。
bool cmp_species(Species a, Species b) {
return a.id < b.id;
}
// 把一份剩余需求向量编码成 6 进制整数。
ll encode(ll left[]) {
ll code = 0;
for (ll i = 0; i < species_cnt; i++) {
code = code + left[i] * pow6[i];
}
return code;
}
// 读取套餐与购物清单,并把套餐换算成清单维度上的份数向量。
void read_input() {
ll s;
cin >> s;
for (ll t = 0; t < s; t++) {
cin >> raw_cnt[t];
for (ll i = 0; i < raw_cnt[t]; i++) {
cin >> raw_id[t][i] >> raw_num[t][i];
}
cin >> raw_price[t];
}
cin >> species_cnt;
for (ll i = 0; i < species_cnt; i++) {
cin >> species[i].id >> species[i].num >> species[i].price;
}
sort(species, species + species_cnt, cmp_species);
for (ll i = 0; i < 1000; i++) {
index_of[i] = -1;
}
for (ll i = 0; i < species_cnt; i++) {
index_of[species[i].id] = (int)i;
}
pow6[0] = 1;
for (ll i = 1; i <= MAXDIM; i++) {
pow6[i] = pow6[i - 1] * 6;
}
for (ll i = 0; i < STATE; i++) {
best_price[i] = -1;
memo[i] = -1;
}
for (ll t = 0; t < s; t++) {
ll digit[MAXDIM];
for (ll i = 0; i < MAXDIM; i++) {
digit[i] = 0;
}
bool valid = true;
for (ll i = 0; i < raw_cnt[t]; i++) {
int pos = index_of[raw_id[t][i]];
if (pos == -1) { // 套餐含清单外商品,题面禁止顺带购买,整条丢弃
valid = false;
break;
}
digit[pos] = raw_num[t][i];
}
if (!valid) {
continue;
}
bool empty = true;
for (ll i = 0; i < species_cnt; i++) {
if (digit[i] > 0) {
empty = false;
}
}
if (empty) { // 空套餐等于白送,会形成零代价自环,丢弃
continue;
}
ll code = encode(digit);
if (best_price[code] == -1 || raw_price[t] < best_price[code]) {
best_price[code] = raw_price[t];
}
}
for (ll code = 0; code < STATE; code++) {
if (best_price[code] != -1) {
deal_code[deal_cnt] = code;
deal_price[deal_cnt] = best_price[code];
ll rest = code;
for (ll i = 0; i < species_cnt; i++) {
deal_digit[deal_cnt][i] = rest % 6;
rest = rest / 6;
}
deal_cnt++;
}
}
}
// 记忆化搜索:left 为各商品还差几件时的最低花费。
// 每次要么按原价买一件,要么套用一个不超过剩余需求的套餐。
ll dfs(ll code) {
if (code == 0) {
return 0;
}
if (memo[code] != -1) {
return memo[code];
}
ll left[MAXDIM];
ll rest = code;
for (ll i = 0; i < species_cnt; i++) {
left[i] = rest % 6;
rest = rest / 6;
}
ll ans = -1;
for (ll i = 0; i < species_cnt; i++) {
if (left[i] > 0) { // 按原价补一件,保证状态一定向 0 收敛
ll cand = species[i].price + dfs(code - pow6[i]);
if (ans == -1 || cand < ans) {
ans = cand;
}
}
}
for (ll j = 0; j < deal_cnt; j++) {
bool fit = true;
for (ll i = 0; i < species_cnt; i++) {
if (left[i] < deal_digit[j][i]) { // 套餐超过剩余需求,题面禁止多买
fit = false;
break;
}
}
if (!fit) {
continue;
}
ll cand = deal_price[j] + dfs(code - deal_code[j]);
if (ans == -1 || cand < ans) {
ans = cand;
}
}
memo[code] = ans;
return ans;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
ll want[MAXDIM];
for (ll i = 0; i < species_cnt; i++) {
want[i] = species[i].num;
}
cout << dfs(encode(want)) << endl;
return 0;
}复杂度
设清单种类数
- 状态数
,每个状态转移一次不再重算。 - 每个状态枚举
条原价边与至多 条套餐边,每条转移构造向量需要 。 - 时间
,最坏约 次基本操作;空间 ,递归深度不超过 。
与标准的多维背包正解同阶。
总结
这道题的突破口是认清剩余需求向量
三个容易写错的地方:一是套餐含清单外商品必须整条丢弃,二是空套餐要排除以免形成零代价自环,三是"不能多买"要落成 left - v 各维非负的判定。原价边既是保底(保证有解),也是保证状态图无环的收敛边。