大礼包 图解题解
这道题到底在问什么
- 输入
- price=[2,5], 礼包=[3A0B¥5],[1A2B¥10], needs=[3,2]
- 输出
- 14
- 输入
- price=[2,3,4], 礼包=[1A1B¥4],[2A2B1C¥9], needs=[1,2,1]
- 输出
- 11
最优解:为什么这么做
一句话答案:买齐购物清单、可用打包礼包,求最低总花费,就是 LeetCode 638 大礼包。状态设成还需向量,先按原价单买兜底,再逐个试不超需的礼包递归取最小并记忆化,时间 O(状态数×礼包数×n)、空间 O(状态数)。
大礼包这道题,买齐清单最少花多少钱
每件商品有原价,另有几种大礼包,每个礼包打包几件给一个打包价,可无限次买。清单 needs 写明每件要买几个,求买齐的最低花费,且每件买到手都不能超过清单。price=[2,5]、礼包 [3 个 A 价 5] 和 [1 个 A 加 2 个 B 价 10]、needs=[3,2],原价单买 16,巧用礼包压到 14。
为什么贪心挑最便宜的礼包会算错
贪心最顺手,每步挑当下最划算的礼包,但会挑反:某礼包眼下最省,买下去却让剩下凑不成别的组合,反倒更贵。想稳妥就把所有买法试一遍,一路分叉;可礼包一多、清单一长买法就成倍翻、试不完,更糟的是同一个『还差多少没买』的局面会被反复重算。
状态设成『还差多少没买』,答案怎么拆
不纠结先买哪个后买哪个,只盯一件事:现在还差多少没买,写成一个还需向量,还要 3 个 A、2 个 B 就记成 [3,2]。任何买法走到某步,只要剩下还需相同,后面怎么买最省就一样,和之前买过什么、什么顺序都无关。
定义 dfs(还需向量) 为从这个局面买齐的最低花费。dfs 就是一个自己调用自己的函数(递归),拿它当键把结果存下来,这就是记忆化搜索(算过的局面存进表,再遇到直接取值)。
礼包买不买,为什么取 min 就对
算 dfs(还需) 从两种来路取小。第一种是原价单买,剩下每件按原价乘还需加起来,是不用礼包的兜底价,先当当前最优;有时所有礼包都不划算,这条退路必须留。
第二种是逐个试礼包,能不能买只看一条:礼包每件要拿的数量都不超过对应还需,有一件超了就超买,题目不许,直接跳过。买得起就扣掉礼包数量得到新还需,花费 = 礼包价 + dfs(新还需),和当前最优比谁小留谁。能不能买只管超不超买,划不划算交给取小裁。
拿 needs=[3,2] 这组示例手工走一遍
顶层 dfs([3,2]):原价单买是 2×3+5×2=16,先记 16。试礼包1[3 个 A]:A 3≥3、B 2≥0 买得起,扣成 [0,2],花费 5+dfs([0,2]);dfs([0,2]) 里 A 已是 0、两礼包都要 A 拿不出,只能原价买 2 个 B 得 10,即 5+10=15 比 16 省。
再试礼包2[1 个 A 加 2 个 B]:A 3≥1、B 2≥2 买得起,扣成 [2,0],花费 10+dfs([2,0]);dfs([2,0]) 里 B 已是 0,礼包2 要 2 个 B 超买、礼包1 要 3 个 A 也不够,只能原价买 2 个 A 得 4,即 10+4=14 比 15 更省。dfs([3,2]) 取小得 14 就是答案。
状态数为什么不会爆,超买和兜底别漏
状态数是关键账:每件还需只能从 0 到清单上限,各件(还需+1)连乘即上限,『不能超买』死死压住它,本例只走出 [3,2]、[0,2]、[2,0] 三个。每状态遍历礼包各 n 件,记忆化只算一次,时间 O(状态数×礼包数×n)。漏了原价单买这条兜底最要命,所有礼包都不划算时就彻底没退路。漏判超买同样致命:礼包数量超过还需会让还需变负,整个答案跟着错。清单为空花费 0、无礼包时全按原价单买,边界都自动对。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3核心一句话:状态是「还需向量」,答案 = min(原价单买, 每个买得起的礼包价 + 递归剩余)。算过就记下来。
- 4先把输入摆上台:要买 3 个 A、2 个 B,写成状态 [3,2]。右边面板是备忘录,现在空着。下面从顶层 dfs([3,2]) 开始递归。
- 5进入第一层 dfs([3,2])。按套路,第一步永远先算不用任何礼包的原价兜底,心里有个底再去试礼包。
- 6原价单买 3 个 A、2 个 B 共 16 元,先把当前最优 ans 记成 16。礼包只是可选项,这个 16 必须先占着位置。
- 7试礼包1:每件拿的数量都不超过当前还需,买得起。能不能买只看会不会超买,划不划算交给后面取最小。
- 8买下礼包1,扣掉 3 个 A,清单从 [3,2] 变成 [0,2],先付礼包价 5 元,剩下的 [0,2] 丢给下一层递归。
- 9进入第二层 dfs([0,2]):A 不用买了,只要凑齐 2 个 B。流程照旧,先算原价兜底再逐个试礼包。
- 10这层原价兜底:A 要 0 个不花钱,B 要 2 个共 10 元。先把这层 ans 记成 10,再看礼包能不能帮上忙。
- 11试礼包1,它要 3 个 A,可现在 A 的还需已是 0,拿不出 3 个,一买就超买,买不起,直接跳过。
- 12再试礼包2,它要 1 个 A,A 的还需仍是 0,连 1 个都拿不出,同样超买。礼包2 也买不起。
- 13dfs([0,2]) 两个礼包都买不起,只能用兜底 10 元。把状态 [0,2] 对应 10 元记进备忘录,返回上一层。
- 14结果回到 dfs([3,2])。走礼包1 这条路 = 礼包价 5 + 子状态 [0,2] 的 10 = 15 元,比 16 省,ans 更新成 15。
- 15试礼包2:还需 A=3≥1、还需 B=2≥2,两件都不超买,正好都够,礼包2 买得起。
- 16买下礼包2,扣掉 1 个 A、2 个 B,清单从 [3,2] 变成 [2,0],先付 10 元,剩下的 [2,0] 丢给下一层。
- 17进入 dfs([2,0]),只要凑齐 2 个 A。套路照旧,先兜底再试礼包,这次走快一点。
- 18原价兜底:2 个 A 每个 2 元共 4 元,B 不用买。这层 ans 先记成 4。
- 19礼包1 要 3 个 A,可手上只还需 2 个 A,凑不够 3,买了就超清单,买不起。
- 20礼包2 要 2 个 B,而 B 的还需已是 0,一个都不能再买,这回卡在 B 上,也买不起。
- 21两个礼包又都落空,dfs([2,0]) 用兜底 4 元。把 [2,0] 对应 4 元记进备忘录,返回上一层。备忘录攒到两条。
- 22回到 dfs([3,2])。走礼包2 这条路 = 礼包价 10 + 子状态 [2,0] 的 4 = 14 元,比 15 省,ans 更新成 14。
- 23dfs([3,2]) 能试的礼包全试过,最低花费锁定 14 元。把顶层状态 [3,2] 对应 14 记进备忘录,返回主程序。三个状态全算完。
- 24回看备忘录三条记录都在。本例每个状态只出现一次,但商品、礼包一多,同一剩余状态会被反复递归到,那时直接取值,这正是记忆化的价值。
- 25最优买法:花 10 元买礼包2 拿到 1A2B,剩下 2 个 A 原价单买 4 元,合计 14 元,比原价单买的 16 元省了 2 元。
⚠️ 容易写错的地方
✗ 错:允许买超过清单的数量
✓ 对:礼包每件拿的数量都要 ≤ 当前还需才可买
题目规定不能多买,买超了状态会变负、答案失真
✗ 错:忘了「全原价单买」这个兜底
✓ 对:每个状态先用原价算出 base 再和礼包比
有时所有礼包都不划算,必须保留原价单买这条路
✗ 错:不做记忆化
✓ 对:用还需向量当键缓存结果
同一个剩余状态会被不同买法反复递归到,不缓存会指数级爆炸
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def shoppingOffers(
self, price: List[int], special: List[List[int]], needs: List[int]
) -> int:
@cache
def dfs(cur: int) -> int:
ans = sum(p * (cur >> (i * bits) & 0xF) for i, p in enumerate(price))
for offer in special:
nxt = cur
for j in range(len(needs)):
if (cur >> (j * bits) & 0xF) < offer[j]:
break
nxt -= offer[j] << (j * bits)
else:
ans = min(ans, offer[-1] + dfs(nxt))
return ans
bits, mask = 4, 0
for i, need in enumerate(needs):
mask |= need << i * bits
return dfs(mask)C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
int shoppingOffers(vector<int>& price, vector<vector<int>>& special, vector<int>& needs) {
const int bits = 4;
int n = needs.size();
unordered_map<int, int> f;
int mask = 0;
for (int i = 0; i < n; ++i) {
mask |= needs[i] << (i * bits);
}
function<int(int)> dfs = [&](int cur) {
if (f.find(cur) != f.end()) {
return f[cur];
}
int ans = 0;
for (int i = 0; i < n; ++i) {
ans += price[i] * ((cur >> (i * bits)) & 0xf);
}
for (const auto& offer : special) {
int nxt = cur;
bool ok = true;
for (int j = 0; j < n; ++j) {
if (((cur >> (j * bits)) & 0xf) < offer[j]) {
ok = false;
break;
}
nxt -= offer[j] << (j * bits);
}
if (ok) {
ans = min(ans, offer[n] + dfs(nxt));
}
}
f[cur] = ans;
return ans;
};
return dfs(mask);
}
};Java
import java.util.*;
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(){} TreeNode(int val){this.val=val;} TreeNode(int val, TreeNode left, TreeNode right){this.val=val;this.left=left;this.right=right;} }
class ListNode { int val; ListNode next; ListNode(){} ListNode(int val){this.val=val;} ListNode(int val, ListNode next){this.val=val;this.next=next;} }
class Solution {
private final int bits = 4;
private int n;
private List<Integer> price;
private List<List<Integer>> special;
private Map<Integer, Integer> f = new HashMap<>();
public int shoppingOffers(
List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
n = needs.size();
this.price = price;
this.special = special;
int mask = 0;
for (int i = 0; i < n; ++i) {
mask |= needs.get(i) << (i * bits);
}
return dfs(mask);
}
private int dfs(int cur) {
if (f.containsKey(cur)) {
return f.get(cur);
}
int ans = 0;
for (int i = 0; i < n; ++i) {
ans += price.get(i) * (cur >> (i * bits) & 0xf);
}
for (List<Integer> offer : special) {
int nxt = cur;
boolean ok = true;
for (int j = 0; j < n; ++j) {
if ((cur >> (j * bits) & 0xf) < offer.get(j)) {
ok = false;
break;
}
nxt -= offer.get(j) << (j * bits);
}
if (ok) {
ans = Math.min(ans, offer.get(n) + dfs(nxt));
}
}
f.put(cur, ans);
return ans;
}
}复杂度
时间
O(状态数 × 礼包数 × n)
状态最多 ∏(needs[i]+1) 个,每个状态遍历所有礼包、每礼包查 n 件
空间
O(状态数)
备忘录给每个出现过的还需向量存一条;递归栈深度不超过总购买次数
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 大礼包 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么这题要记忆化搜索,不能直接贪心选最便宜的礼包?+
贪心选眼下单价最便宜的礼包会错:某个礼包此刻最划算,但它扣完商品后会让剩下的凑不成别的优惠组合,总价反而更高。正确做法是把所有买法都搜一遍取最小,记忆化只是保证同一个剩余还需向量不被反复重算,不改变『搜遍取小』这个本质。
参考代码为什么把还需向量压成一个整数?+
商品最多 6 件、每件还需不超过 10,用 4 个二进制位就能存下一件的数量,6 件拼进一个整数,正好当哈希表的键,比拿数组当键更快更省内存。这只是状态的编码方式,dfs 先算原价兜底、再试不超需的礼包递归取最小的逻辑,和用还需向量 [3,2] 完全一致。
判断礼包买不买时,为什么只看会不会超买,不看划不划算?+
划不划算已经由『取最小』这一步统一裁决了:每个买得起的礼包都递归算出它这条路的总花费,最后取小自然把不划算的丢掉。所以逐个礼包时只需过一道硬门槛,每件拿的数量不超过对应还需就能买,超了就跳过。把能不能买和值不值分开,逻辑才不打架。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 大礼包 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。