动态规划DP:从硬币问题到挤牛奶问题的算法实战
1.一般性的最少硬币组成问题【动态规划 DP专题训练】
题目描述
从n种币值为a[1..n]的硬币中,任选几个硬币组成价值为V的一堆货币,问最少需要几个硬币?其中每种硬币的数量没有限制。1<=n<=100,1<=v<=100000,1<=a[i]<=100000
输入格式
输入中有两行:第一行有两个数v和n;第二行有n个以空格分隔的数,表示n个币值.
输出格式
输出只有一行,该行只有一个数,表示所需的最少硬币数, 如果无论如何选取硬币,均不能得到币值v,则输出0.
输入样例
10 2
3 5
输出样例
2
#include<bits/stdc++.h>
using namespace std;
#define MAX 100010
int k,n;
int dp[MAX],a[MAX];
int mi=MAX;
int main()
{memset(dp,0,sizeof(dp));dp[0]=0;scanf("%d %d",&k,&n);for(int i=1;i<=n;i++)scanf("%d",&a[i]);for(int i=1;i<=n;i++){for(int j=a[i];j<=k;j++){if(dp[j]==0||dp[j]>dp[j-a[i]]+1){if(dp[j-a[i]]!=0||j-a[i]==0){dp[j]=dp[j-a[i]]+1;}}}}if(dp[k]==0){cout<<"0";return 0;}printf("%d",dp[k]);return 0;
}
一道简单的入门题
1. 代码整体功能
- 问题描述:这是一个经典的优化问题。假设有$n$种硬币,每种硬币面值为$a_i$($i$从1到$n$),硬币数量无限。需要计算组成目标金额$k$所需的最小硬币数。如果无法组成,则输出0。
- 算法核心:使用动态规划,通过构建一个
dp
数组来存储子问题的解。dp[j]
表示组成金额$j$所需的最小硬币数量。 - 输出结果:代码输出
dp[k]
,即组成$k$的最小硬币数;如果$k$不可达,输出0。
2. 代码结构和关键变量
- 头文件和常量:
#include<bits/stdc++.h>
:包含所有标准库头文件,简化代码。using namespace std;
:使用标准命名空间,避免前缀std::
。#define MAX 100010
:定义常量MAX
为100010,表示数组最大大小(足够大以处理金额$k$)。
- 变量定义:
int k, n;
:k
是目标金额,n
是硬币种类数。int dp[MAX];
:动态规划数组,dp[j]
存储金额$j$的最小硬币数。int a[MAX];
:硬币面值数组,a[i]
表示第$i$种硬币的面值($i$从1到$n$)。int mi = MAX;
:初始化为MAX
,但在代码中未使用,可能是冗余变量(不影响功能)。
- 输入输出:使用
scanf
读取输入,cout
或printf
输出结果。
3. 动态规划算法逻辑
算法基于状态转移方程。核心思想是:对于每个金额$j$,尝试使用每种硬币$a_i$来更新dp[j]
,取最小值。状态转移方程可表示为: $$dp[j] = \min(dp[j], dp[j - a_i] + 1) \quad \text{for all} \quad i \in [1, n]$$ 但代码中通过条件判断实现此最小化过程。以下是分步解释:
(1) 初始化
memset(dp, 0, sizeof(dp));
:将dp
数组所有元素初始化为0(表示初始时所有金额均不可达)。dp[0] = 0;
:基础情况,金额0不需要任何硬币。- 输入
k
和n
,然后读取硬币面值到数组a
。
(2) 状态转移(双重循环)
- 外层循环:
for(int i=1; i<=n; i++)
,遍历每种硬币(索引$i$从1到$n$)。 - 内层循环:
for(int j=a[i]; j<=k; j++)
,遍历金额$j$(从当前硬币面值$a_i$开始到目标$k$)。- 在循环内部,检查是否需要更新
dp[j]
:- 条件1:
if(dp[j]==0 || dp[j]>dp[j-a[i]]+1)
:如果dp[j]
未被设置(0)或当前方案(使用硬币$a_i$)的硬币数更小。 - 条件2:
if(dp[j-a[i]]!=0 || j-a[i]==0)
:确保子问题$j - a_i$可达(dp[j-a[i]]
不为0)或子问题为金额0(基础情况)。
- 条件1:
- 如果两个条件都满足,则更新
dp[j] = dp[j-a[i]] + 1
:表示使用一个$a_i$硬币,加上组成$j - a_i$的最小硬币数。 - 逻辑含义:这相当于在状态转移中取最小值,但避免了直接使用$\min$函数,而是通过条件判断实现。
- 在循环内部,检查是否需要更新
(3) 结果输出
if(dp[k]==0)
:检查目标金额$k$是否可达(dp[k]
为0表示不可达)。- 如果不可达,输出
"0"
。 - 否则,输出
dp[k]
,即最小硬币数。
- 如果不可达,输出
4. 算法实现细节
- 时间复杂度:$O(n \times k)$。因为双重循环:外层$n$次,内层最多$k$次(平均情况)。对于$k \leq 10^5$,代码在合理时间内运行。
- 空间复杂度:$O(k)$。主要空间由
dp
数组占用,大小为MAX
。 - 正确性保证:
- 动态规划从金额0开始逐步构建解。
- 状态转移确保每个
dp[j]
都是最优解(通过比较所有可能硬币选择)。
- 边界处理:
- 金额0直接设为0。
- 如果$k$不可达,输出0(符合问题要求)。
总结
这段代码高效地解决了硬币找零问题的最小硬币数计算,使用动态规划方法。核心在于状态转移的双重循环,确保每个金额的最优解
2.
挤牛奶【动态规划 DP专题训练】
题目描述
小卡卡终于帮农夫John找到了走丢的那一头奶牛,John为了感谢小卡卡,不仅告诉了他在 Pascal山脉上可能存在Pascal圣地最大的宝藏,还说要请小卡卡喝牛奶。可是农夫John发现他家里所储藏的牛奶已经喝光了,所以要临时给奶牛挤奶。小卡卡实在是太好心了,说要帮农夫John一起挤牛奶。John答应了,他把他的n头奶牛中的n/2头(n是个偶数)分给小卡卡挤,他自己挤剩下的n/2头奶牛。但是每一头奶牛都有不同的产奶量,农夫John为了让小卡卡减轻负担,他希望他们两个所挤的牛奶的总量之差最小。小卡卡得知了每头奶牛的产奶情况之后很快就解决了这个问题。
输入格式
测试数据第一行一个整数n,n为偶数且小于100。表示有n头奶牛。第二行n个整数分别给出每头奶牛的产奶量(产奶量的总和不超过2000)。
输出格式
输出小卡卡和农夫所挤的牛奶量的最小差值。
输入样例
6
7 9 2 6 4 2
输出样例
0
#include<bits/stdc++.h>
using namespace std;
#define MAX 2010
int n;
int dp[MAX][MAX];
int a[MAX],v[MAX];
long long sum;
int main()
{memset(dp,0,sizeof(dp));dp[0][0]=1;scanf("%d",&n);for(int i=1;i<=n;i++){scanf("%d",&a[i]);sum+=a[i];} for(int i=1;i<=n;i++){for(int k=n;k>=0;k--){for(int j=sum-a[i];j>=0;j--){if(dp[k][j])dp[k+1][j+a[i]]=1;}} } for(int i=sum/2;i>=0;i--){if(dp[n/2][i]){printf("%d",sum-i-i);return 0;}}return 0;
}
1. 问题定义与目标
- 输入:$n$个整数,存储在数组
a
中。 - 目标:将数组划分成两个子集$A$和$B$,每个子集大小恰好为$n/2$(假设$n$为偶数),最小化两个子集和的差的绝对值,即最小化$| \text{sum}(A) - \text{sum}(B) |$。
- 由于总和$\text{sum}$固定,如果子集$A$的和为$i$,则子集$B$的和为$\text{sum} - i$,差的绝对值为$|\text{sum} - 2i|$。代码的目标是找到$i$,使得$|\text{sum} - 2i|$最小。
2. 代码变量解释
n
:输入整数的个数。a[MAX]
:存储输入整数的数组,索引从1到$n$。sum
:所有整数的总和,类型为long long
以防大数。dp[MAX][MAX]
:二维DP数组,dp[k][j]
表示是否可能选取$k$个元素,使得它们的和恰好为$j$。其中:- $k$ 表示选取的元素个数(范围 $0$ 到 $n$)。
- $j$ 表示当前和(范围 $0$ 到 $\text{sum}$)。
dp[k][j] = 1
表示状态可达,0
表示不可达。
v[MAX]
:在代码中未使用,可能是冗余变量。MAX
:定义为2010,限制数组大小,假设$n \leq 2000$。
3. 代码逻辑分步讲解
(1) 初始化和输入
memset(dp,0,sizeof(dp)); // 初始化DP数组所有元素为0
dp[0][0] = 1; // 基础状态:选取0个元素,和为0是可能的
scanf("%d",&n); // 输入n
for(int i=1;i<=n;i++) {scanf("%d",&a[i]); // 输入每个整数sum += a[i]; // 累加总和
}
dp[0][0] = 1
是动态规划的起点,表示“不选任何元素时和为0”是可行的。- 输入循环计算总和$\text{sum}$,为后续DP提供上限。
(2) 动态规划状态转移
for(int i=1; i<=n; i++) { // 遍历每个元素a[i]for(int k=n; k>=0; k--) { // 逆序遍历k(选取元素个数),从大到小避免覆盖for(int j=sum - a[i]; j>=0; j--) { // 逆序遍历j(当前和),从大到小if(dp[k][j]) { // 如果状态(k, j)可达dp[k+1][j + a[i]] = 1; // 则新状态(k+1, j + a[i])也可达}}}
}
4. 算法复杂度与注意事项
5. 示例说明
假设输入$n=4$,数组$a = [1, 2, 3, 4]$,总和$\text{sum}=10$。
这段代码高效地解决了子集划分问题,适合中等规模输入。如果$n$很大或$\text{sum}$过大,可能需要优化(如位运算压缩状态),但当前实现清晰易读。
- 外层循环:遍历每个元素$a_i$($i$从1到$n$),表示当前考虑添加的元素。
- 中层和内层循环:逆序更新$k$和$j$(从大到小),这是背包问题的标准优化,防止同一元素被重复使用。
- 状态转移:
- 如果
dp[k][j] == 1
(即选取$k$个元素和$j$是可行的),则选择当前元素$a_i$后,新状态为选取$k+1$个元素,和变为$j + a_i$,所以设置dp[k+1][j + a[i]] = 1
。
- 如果
- 数学表达:状态转移方程为: $$\text{dp}[k+1][j + a_i] = \text{dp}[k+1][j + a_i] \lor \text{dp}[k][j]$$ 其中$\lor$表示逻辑或(这里用赋值实现)。
- 这个过程填充DP表,记录了所有可能的$(k, j)$组合。
- (3) 结果提取
for(int i = sum/2; i >= 0; i--) { // 从sum/2向下遍历iif(dp[n/2][i]) { // 如果存在大小为n/2的子集和为iprintf("%d", sum - i - i); // 输出差的绝对值(即sum - 2*i)return 0; // 找到解,立即结束} }
- 循环逻辑:从$i = \text{sum}/2$向下遍历到$0$,目的是找到最大的$i$(接近$\text{sum}/2$),使得
dp[n/2][i] == 1
(即存在大小为$n/2$的子集和为$i$)。 - 输出:
sum - 2*i
表示两个子集和的差$|\text{sum} - 2i|$。由于$i$从大到小遍历,第一个满足条件的$i$会使$|\text{sum} - 2i|$最小(即差最小)。 - 为什么有效:假设子集$A$和为$i$,子集$B$和为$\text{sum} - i$,则差为$|(\text{sum} - i) - i| = |\text{sum} - 2i|$。遍历从$\text{sum}/2$开始,确保找到的$i$使$|\text{sum} - 2i|$最小化。
- 时间复杂度:$O(n \times n \times \text{sum})$。外层循环$n$次,中层循环$n$次,内层循环$\text{sum}$次($\text{sum}$是总和)。由于$\text{sum}$可能较大,但代码中
MAX=2010
限制了$n$和$\text{sum}$的范围。 - 空间复杂度:$O(n \times \text{sum})$,由DP数组大小决定。
- 关键假设:
- $n$必须是偶数,否则
n/2
是整数除法(如$n=5$时n/2=2
),可能不是最优划分。代码未处理$n$为奇数的 case。 - 输入整数非负,否则DP状态转移可能出错(但代码未显式检查)。
- $n$必须是偶数,否则
- DP过程会记录所有$(k, j)$状态,例如:
- 选取2个元素和为5(如子集${1,4}$),则
dp[2][5] = 1
。
- 选取2个元素和为5(如子集${1,4}$),则
- 结果提取:遍历$i$从$5$($\text{sum}/2=5$)向下,当$i=5$时
dp[2][5] == 1
,输出10 - 2*5 = 0
(即差为0,最优解)。- 优化点:逆序更新$k$和$j$避免了重复计算,是经典背包问题技巧。