题目链接:https://www.luogu.com.cn/problem/P3092
问题重述与难点深度分析
问题场景与核心挑战
Farmer John 去商场购物,携带了 K 个硬币(1 ≤ K ≤ 16),需要按顺序购买 N 个物品(1 ≤ N ≤ 10^5)。每次支付只能使用一个硬币,且支付的硬币面值必须足够覆盖从上次支付后到当前的所有物品费用。关键约束:没有找零,如果硬币面值大于所需费用,多余部分就浪费了。
为什么这个问题如此具有挑战性?
第一眼分析:这似乎是一个简单的支付规划问题,但深入思考后会发现几个关键难点:
-
组合爆炸的恐怖规模:K 个硬币有 K! 种使用顺序,当 K=16 时,16! ≈ 2.09 × 10^13,这是天文数字,暴力枚举完全不可行。
-
支付分段的复杂性:即使固定了硬币使用顺序,还需要决定每个硬币支付哪些物品。N 个物品有 2^(N-1) 种分段方式,当 N=10^5 时根本无法计算。
-
无后效性的缺失:传统的贪心策略在这里失效,因为过早使用大面值硬币可能导致后面无法完成支付,但保留大硬币又可能造成浪费。
-
数据规模的极端差异:K 很小(≤16),但 N 很大(≤10^5),这种不对称性既是挑战也是突破口。
关键洞察:发现问题的特殊结构
仔细分析后,我们发现这个问题的救命稻草在于 K 的取值范围很小(≤16)。这提示我们可能使用状态压缩动态规划(状压DP)。
为什么状态压缩可能有效?这是一个需要深入思考的问题:
-
状态数:2^K ≤ 2^16 = 65536,在可处理范围内
-
我们需要记录的关键信息:哪些硬币已经使用过了
-
对于每个硬币使用状态,我们关心的是:用这些硬币最多能买到第几个物品
更深层的思考:为什么我们不记录具体的支付分段,而只记录买到的位置?这是因为问题具有最优子结构特性——无论前面如何支付,只要到达同一个物品位置且剩余硬币集合相同,后续的最优解是一样的。
从暴力搜索到优化解法的完整思维演进
第一阶段:最朴素的暴力搜索(为什么不可行?)
如果完全不懂算法,最直接的想法是尝试所有可能性:
// 伪代码:暴力搜索所有可能性
long long brute_force(vector<int> used_coins, int current_position) {
if (current_position == n) { // 所有物品已购买
return sum(remaining_coins); // 返回剩余硬币总面值
}
long long best = -1;
for (每个未使用的硬币) {
for (每个可能的结束位置) {
// 尝试用这个硬币支付从current_position到end的物品
result = brute_force(used_coins + [coin], end);
best = max(best, result);
}
}
return best;
}
复杂度分析:O(K! × N^K),当 K=16, N=10^5 时完全不可计算。
为什么暴力搜索不可行?因为状态空间太大,而且有大量重复计算。
第二阶段:发现子问题重叠(动态规划的萌芽)
观察发现,很多不同的硬币使用顺序在到达同一个物品位置时,其后续的决策是相同的。比如:
-
顺序1:硬币A支付物品1-3,硬币B支付物品4-5
-
顺序2:硬币B支付物品1-2,硬币A支付物品3-5
如果两种顺序都买到了第5个物品,且剩余的硬币集合相同,那么它们后续的最优解是一样的。
这引出了动态规划的思路:用状态表示已经使用了哪些硬币以及买到了哪个位置。
第三阶段:状态设计的关键突破(为什么选择位掩码?)
最初可能想到的状态设计:dp[i][j]表示使用前 i 个硬币买到第 j 个物品的最大剩余金额。
问题:硬币的使用顺序很重要,而"前i个硬币"没有考虑具体的硬币组合。
关键突破:用位掩码表示硬币使用情况!
-
用 K 位二进制数表示硬币使用状态
-
第 i 位为1表示第 i 个硬币已使用
-
这样我们就能精确记录具体的硬币组合
状态设计优化为:dp[mask]表示使用硬币状态为 mask 时能买到的最大物品编号。
为什么这样设计更优?因为它抓住了问题的本质——硬币的具体组合比使用顺序更重要。
算法核心:状态压缩DP的深度解析
状态定义与转移方程的推导
状态定义:
-
dp[mask]:使用硬币集合为 mask 时,能够买到的最大物品编号(0-indexed)
状态转移方程推导:
对于每个状态 mask,枚举每个未使用的硬币 j:
prev_mask = mask 去掉硬币j的状态
start = dp[prev_mask] + 1 // 从下一个物品开始购买
end = 从start开始,用硬币j能买到的最大物品编号
dp[mask] = max(dp[mask], end)
为什么这样设计是正确的?这需要从最优子结构的角度理解:
要使用硬币集合mask买到尽量多的物品,必然存在某个硬币j,使得先使用mask{j}买到某个位置,然后用j支付后续物品。这种分解方式保证了不会错过最优解。
预处理优化:前缀和与二分查找的巧妙结合
支付范围计算的优化是关键瓶颈。朴素方法需要O(N)时间计算每个硬币能买多少物品。
优化技巧的深层思考:
-
为什么用前缀和?因为我们需要频繁计算区间和,前缀和可以将O(N)的区间求和优化为O(1)的查询。
-
为什么用二分查找?因为物品费用都是正数,区间和具有单调性,这正好满足二分查找的应用条件。
// 二分查找的深层原理:利用单调性快速定位
bool check(int x, int l, int r) {
return x >= pre[r] - pre[l];
}
二分查找的具体过程分析:
-
初始化:l = st, r = n, best = -1
-
循环条件:l <= r(搜索区间有效)
-
中间点:mid = (l + r) / 2
-
判断:如果硬币能支付到mid,则向右扩展(l = mid + 1),否则向左收缩(r = mid - 1)
-
结果:best记录能支付到的最远位置
算法流程的完整实现
#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N=1e5+7;
int w[20],v[N],pre[N],dp[1<<17];
int n,k;
// 检查函数:判断硬币面值x是否能支付从l到r的物品
bool check(int x,int l,int r){
return x>=pre[r]-pre[l];
}
signed main(){
// 输入处理:为什么需要这样组织数据?
cin>>k>>n;
for(int i=1;i<=k;i++)cin>>w[i]; // 硬币面值存储,索引从1开始更符合直觉
for(int i=1;i<=n;i++)cin>>v[i],pre[i]=pre[i-1]+v[i]; // 前缀和预处理,优化区间查询
// DP状态转移:核心逻辑的逐层分析
for(int sta=1;sta<=(1<<k)-1;sta++){ // 遍历所有状态,为什么从1开始?因为状态0表示没有使用任何硬币,dp[0]=0是初始条件
for(int j=1;j<=k;j++){ // 枚举每个硬币,为什么是1到k?因为我们的硬币索引从1开始
if(sta&(1<<(j-1))){ // 检查状态sta是否包含硬币j,位运算的高效性体现在这里
int lst=sta^(1<<(j-1)); // 计算前一个状态(去掉硬币j),异或运算的妙用
int st=dp[lst]; // 前一个状态能买到的最大物品编号
if(st<n){ // 剪枝:如果已经买完所有物品,不需要继续处理
// 二分查找:确定硬币j能支付的最大范围
int l=st,r=n,best=-1;
while(l<=r){
int mid=(l+r)>>1; // 中间点,位运算代替除法提升效率
if(check(w[j],st,mid))l=mid+1,best=mid; // 能支付到mid,尝试向右扩展
else r=mid-1; // 不能支付到mid,向左收缩
}
dp[sta]=max(dp[sta],best); // 状态转移:更新当前状态能买到的最大物品编号
}
}
}
}
// 答案计算:如何从DP结果提取最终答案?
int ans=-1;
for(int sta=0;sta<=(1<<k)-1;sta++){ // 遍历所有状态
if(dp[sta]==n){ // 关键判断:该状态是否买完了所有物品?
int res=0;
for(int j=1;j<=k;j++){
if(!(sta&(1<<(j-1))))res+=w[j]; // 累加未使用的硬币面值
}
ans=max(ans,res); // 更新最大剩余金额
}
}
cout<<ans<<endl; // 输出结果
}
复杂度分析与优化证明的深度探讨
时间复杂度的严格分析
-
预处理阶段:前缀和计算需要 O(N) 时间,这是必要的初始化工作。
-
DP主循环:状态数 2^K,每个状态枚举 K 个硬币,每个硬币进行二分查找 O(logN)。
-
总复杂度:O(N + 2^K × K × logN)
为什么这个复杂度是可接受的?
代入极值:K=16, N=10^5
-
2^16 × 16 × log2(10^5) ≈ 65536 × 16 × 17 ≈ 1.78 × 10^7
-
现代计算机每秒可处理 10^7-10^8 次操作,因此这个复杂度是可行的。
空间复杂度的优化考量
-
DP数组:需要 O(2^K) 空间,当 K=16 时为 65536,完全可以接受。
-
前缀和数组:需要 O(N) 空间,N=10^5 也是合理的。
-
其他变量:常数空间,可忽略不计。
总空间复杂度:O(2^K + N),在题目约束下完全可行。
关键技巧的深度剖析与原理探究
状态压缩的本质与位运算的威力
状态压缩的核心思想是:用整数表示集合。在这个问题中,我们将硬币集合映射到整数:
硬币: A B C D (K=4)
二进制位: 3 2 1 0
状态 5 = 二进制 0101 = 使用硬币A和C
状态 10 = 二进制 1010 = 使用硬币B和D
位运算的优势分析:
-
存储高效:一个整数代替一个集合,大幅减少内存占用。
-
操作快速:位运算在硬件层面通常只需1个时钟周期,极其高效。
-
枚举方便:可以直接用整数循环枚举所有状态,代码简洁。
关键位运算操作的原理解析:
-
sta & (1 << (j-1)):检查状态sta是否包含硬币j -
sta ^ (1 << (j-1)):从状态sta中移除硬币j -
(l+r) >> 1:计算中间点,比除法更快
二分查找的适用性证明与单调性分析
为什么可以用二分查找确定支付范围?这需要从数学角度严格证明。
单调性证明:
设 f(x) = pre[x] - pre[st]
-
由于物品费用都是正数(v[i] > 0),所以 f(x) 随着 x 增加而严格单调递增。
-
我们要找最大的 x 满足 f(x) ≤ coin_value。
-
这正好满足二分查找的应用条件:在单调序列中查找满足条件的边界。
二分查找的正确性保证:
算法在每次迭代中将搜索区间减半,最终必然收敛到正确解。时间复杂度从 O(N) 优化到 O(logN),这是数量级的提升。
边界情况处理的严谨性分析
重要细节:起始位置 st 可能等于 n,表示已经买完所有物品,这时应该直接跳过。
为什么这个剪枝是可行的?
-
如果已经买完所有物品,再使用硬币只会浪费,不会增加已购买物品数。
-
这个剪枝避免了不必要的计算,提升了算法效率。
-
体现了算法设计中对边界情况的周密考虑。
思维拓展:该算法的通用性分析
这种"状态压缩DP + 二分查找"的模式可以解决一类具有以下特征的问题:
-
资源分配问题:多个资源分配给多个任务,每个资源有特定能力。
-
项目调度问题:多个工人完成多个任务,每个工人有特定技能。
-
投资组合优化:多种投资方式,每种有不同收益和风险。
关键特征:
-
资源数较少(通常≤20),适合状态压缩。
-
任务数较多,需要高效处理。
-
目标函数具有最优子结构性质。

944

被折叠的 条评论
为什么被折叠?



