题目描述
以前大学食堂都使用餐票吃饭,每顿饭菜钱可以为1角,2角,...,最多为n角。如果规定每次吃饭最多只能使用k张餐票,是否可以设计m种不同面值的餐票,使得餐票从1开始可以连续覆盖的面值范围恰好为 1 - n(角)?满足上述条件的方案有多少?
假设 n 的值不超过500,饭菜钱单位为角。
例如,
m=3, k=2, n=8, 则,面值为:{1,3,4} 恰好覆盖 1,2,...,8,此时,1角只需要1张面值为1的即可,2角需要2张面值为1的,3角只需要1张面值为3的,4角只需要1张面值为4的,5角需要1张面值为1的再加上1张面值为4的,6角需要2张面值为3的,7角需要1张面值为3再加上1张面值为4的,8角需要2张面值为4的。即:只需要2张面值的饭票即可覆盖1-8的范围(注意:一定是连续覆盖)。除了这三种面值之外,再没有其他方案的面值。因此,这样的方案有1种。
若m=3, k=2, n=9, 则不存在面值组合,因此,为0种方案。
若m=3, k=2, n=6,则有 {1,2,3},{1,2,4}和{1,3,5}共3种。
若m=3, k=2, n=5, 则不存在,因此,为0种。
关于输入
第1行输入正整数 P, 表示后面有 P行
后面的P行分别为 m,k,n,其间以空格间隔
关于输出
对应输出 P行,若不存在 m 种面值的饭票,则输出0,若有,则输出方案数。
例子输入
4
3 2 5
3 2 6
3 2 8
3 2 9
例子输出
0
3
1
0
题面解释
可行的一套餐票面值需要恰好连续覆盖区间在1 ~ n的菜钱。
连续覆盖指这套餐票面值(在最多用k张前提下)能够凑到1、2、3、...、n-1、n元,即区间[1,n]中的全部元素,而非“只要凑到n元就行”。具体可参考案例输入的第四组数据“3 2 9”。
恰好指这套餐票面值无法连续覆盖所有[1,m] (m>n)的区间,这里留意到,只要这套面值无法凑出n+1元,那后面所有比[1, n+1]更长的区间也就无法被连续覆盖,因为这些区间都会在n+1处“断开”。具体可参考案例输入的第一组数据“3 2 5”,之所以方案数为0,是因为但凡能连续覆盖[1,5]的面值组合都能连续覆盖[1,6],不满足恰好覆盖。
思路描述
枚举(or搜索)
用递归枚举所有大小为m的面值组合,再判断当中有多少个组合是满足题目要求的。虽然时间复杂度明显无法保证,但这是只学过递归方法的背景下最有可能的解决办法。
伪代码,仅展示代码逻辑
var m,n,k
var combination = [1]
var ans
func verifyCombination() -> bool:
......
func genAllCombination( step )
if len(combination) == m:
if verifyCombination() ans++
else return
for i from step to n+1:
combination.add(i)
genAllCombination(i+1)
combination.remove(i)
main():
var t;
while t--:
...get user input...
ans = 0
genAllCombination(2)
print(ans)
这道题要攻克的一个难点是:即使已经用递归方法构造出来一个大小为m的面值组合,我要如何判断这个组合是否满足要求呢?
此时问题可以转化为
已知一套拥有m种面值的餐票系统,问在最多用k张餐票的情况下,这套系统是否满足:
一、学生能支付任意价格在1~n元内的饭菜,
二、学生无法支付价格为n+1元的教职工专属套餐。
这是很核心的子问题,最粗暴的解决方法同样是枚举,用递归枚举所有可能的餐票使用方案,再判断这些方案是否完整覆盖了1~n元且不覆盖n+1元。
当然这并非最优解,以下引入一个更聪明的解决方法。
动态规划(DP)
省流版
dp[n+1]用于记录凑出i元所需要的最少餐票数。
初始化dp[0] = 0,dp[i] = i,代表面值只有{1}时的情况,接下来用循环把剩余的面值逐个加入,例如有一组面值{1, x1, x2, x3} (1 < x1 < x2 < x3 ),最外层循环的任务就是{1} -> {1, x1} -> {1, x1, x2} -> {1, x1, x2, x3},然后每层循环都把dp更新一次。例如在{1, x1}的循环执行如下操作
for(i=x1; i<=n+1; i++){
dp[i] = min(dp[i], dp[i-x1]+1); // 转移方程
}
代表新加入的面值对最优解的改变。
最终得到的dp数组便储存该餐票系统凑i元的最少餐票数。
详细版
枚举过程中我们会留意到有很多的“方案截然不同,结果如出一辙”。比方现在有1元、2元、3元三种面值,我们会发现方案{1,1,1}、{1,2}和{3}最终凑出来的都是3元,也就是说,对于3元,我们有三种支付方案!同理的对于每个价格i,往往能对应到多种支付方案。
留意到对于各个支付方案,我们只需要保留“使用最少餐票”的方案,因为最终判断面值系统是否可行时,我们只用在意每个价格的“使用最少餐票的方案”是否用了不多于k张餐票!进一步地,由于做最终判断时用不着知道具体的支付方案是什么,甚至只需要保留“凑出一个i元最少使用多少张餐票”这一信息。
例如对于3元,我们的最优方案只需要用1张餐票,所以当k大于等于1,这套面值就能支付3元。如果面值只剩1元和2元,那最优方案就是2张餐票,当k大于等于2,可支付3元,否则就不可支付。
一段伪代码,呈现最终求解判断过程的代码逻辑
var m,n,k;
var best_solution[n+1]; //best_solution[i]表示凑出i元要用至少多少张餐票
getAllBestSolution(1,n+1); //假设有方法求到1~n+1元的最优方案
if best_solution[n+1] <= k: //不满足条件二
print(NOT WORK)
EXIT
for i 从 1 ~ n :
if best_solution[i] > k: //不满足条件一
print(NOT WORK)
EXIT
print(WORK)
此处发现,原问题可以转化为“凑出i元至少用多少张餐票”的策略优化问题,现在要为每个i找出最优解。
解决决策优化问题有个常用方法叫动态规划(Dynamic Programming),“动态”在于算法执行过程中最优解会被反复不断地更新(动态更新)。DP问题通常还有两个特征:执行过程中某一刻的最优解依赖于前面时刻的最优解(存在转移方程),并且某一刻的最优解的确立不会改变前面时刻的最优解(无后效性)。
step 1:寻找动态过程
我们先来寻找一个动态优化过程,也就是“从什么状态(state)到什么状态,我们的策略一定越来越优”。对于这个问题,可以发现“面值种数越多,策略一定越来越优”。
以餐票系统有1元、2元、3元三种面值为例,从状态“只有1元一种面值” 到 “有1元和2元两种面值” 再到“有1元、2元和3元三种面值”,我们“凑出i元”的策略肯定是被优化的(下一状态的最优解只会优于上一状态的)。于是,我们可以把这个面值种数的拓展过程视为一个动态优化过程。
// 一段伪代码,主要呈现代码逻辑
var best_solution[n+1]
while current_state in all_states: // 顺序遍历各状态
for i from 1 ~ n+1 :
upDate(best_solution[i]); // 更新当前当前阶段最优解
current_state = next_state
step 2:确立动态过程
接下来详细地去确立具体的动态过程。
首先,考虑过程中各状态之间的“间隔”,不妨让相邻状态之间只相差一种面值,即一个状态到下一个状态只会新增一种面值,这样我们更新最优解时只需要考虑一个新元素带来的影响。
然后,考虑起始状态,这个起始状态需要对任意情况的面值组合都成立。例如,我们可以把“没有任何面值”当作起始状态,又0元不需要花任何餐票,此时最优解为[0, INF, INF, ..., INF] (用INF来表示不可能凑到某个数)。
此时会发现,对于任何可行的面值组合,必须包含面值1,没有面值1的餐票的话1元的饭肯定吃不了,因为餐票无法对“支付价格比其面值更小的饭”这件事作出任何贡献,例如4元的餐票对于支付3元及以下的饭没有任何作用。
所以不妨让“只有1元一种面值”作为起始状态,此时最优解为[0, 1, 2, 3, ..., n, n+1]
最后,确定状态的更新方式,已知起始状态为{1},考虑到上一步递归枚举得到的组合都是递增排序,不妨就让状态更新时直接把剩余面值中的下一个面值加进去即可(即按递增序{1} -> {1,3} -> {1,3,5}而非随机加{1} -> {1,5} -> {1,5,3} ),加上餐票无法对“支付价格比其面值更小的饭”这件事作出任何贡献这一观察,新状态中只需要更新价格不小于新面值的最优解即可。
这样代码实现更简单、逻辑更清晰。
// 一段伪代码,主要呈现代码逻辑
var best_solution[n+1] = [0, 1, 2, ..., n+1]
var new_face_value = 1
var cnt = 1;
func getAllBestSolution(combination):
while new_face_value != combination[m-1]:
new_face_value = combination[cnt++]
for i from new_face_value ~ n+1 :
upDate(best_solution[i])
step 3:找转移方程
接下来寻找转移方程,即研究动态优化过程中某个状态的最优解和前状态的关联。
假设现在新增了面值x,目前储存最优解的数组best_solution还在储存上个状态时的最优解。
要寻找当前状态的最优解,不难发现
当前最优解 = whichBetter( 不用新面值时的最优解,用上新面值后的最优解)
考虑对于价格i,要寻找当前状态下凑i元的最优解,有
best_solution[i] = whichBetter( 不用x凑i的最优解,用上x凑i的最优解)
其中,“不用x凑i的最优解”即“上一个状态的最优解”,即
不用x凑i时的最优解 = best_solution[i]
而
用上x凑i的最优解 = min( 用1张x凑i时的最优解,用2张x凑i时的最优解,... )
又 “用n张x凑i时的最优解”其实就是n再加上“凑i-nx的最优解”
即
用上x凑i的最优解 = min( best_solution[i - x] + 1,best_solution[i - 2*x] + 2,... )
此时我们注意到best_solution数组内部的逻辑顺序,由于我们是用循环语句顺序更新best_solution的,当程序运行到 i 的时候,在 i 之前的best_solution就已经被更新为当前状态下的最优解了(i之前的best_solution已经用上新面值了!),所以对于i我们只用考虑用1张x和不用x的情况。
最终得到转移方程(当然前提是顺序遍历best_solution)
best_solution[i] = min( best_solution[i], best_solution[i - x] + 1)
通常人们习惯把记录最优解的数组命名为dp
dp[i] = min( dp[i], dp[i - x] + 1)
伪代码
var dp[n+1] = [0, 1, 2, ..., n+1]
var x= 1
var cnt = 1;
func getAllBestSolution(var combination):
while x != combination[m-1]:
x = combination[cnt++]
for i from x~ n+1 :
dp[i] = min(dp[i], dp[i-x]+1)
最后会发现dp的过程和上一步枚举组合的过程可以整合在一起。
伪代码,仅展示代码逻辑
var m,n,k
var ans
var combination = [1]
var dp[n+1] = [0, 1, 2, ..., n+1]
func updateDP(x):
var cnt = 1;
for i from x~ n+1 :
dp[i] = min(dp[i], dp[i-x]+1)
func verifyCombination() -> bool:
if dp[n+1] <= k:
return false
for i from 1 to n :
if dp[i] > k:
return false
return true
func tryAllPosibleCombination( )
if len(combination) == m:
if verifyCombination() ans++
else return
for i from combination[len(combination)-1]+1 to n+1:
temp = copy(dp) // 用来还原dp
combination.add(i)
updateDP(i)
tryAllPosibleCombination(i+1)
// 回溯
combination.remove(i)
dp = copy(temp)
main():
...
剪枝
一般进阶的枚举问题还要搭配适当的剪枝优化,通常在写完整个递归后,发现能通过前面的case但后面大一点的case会Time out,就意味要剪枝了。
通常是研究一下数学规律,找到在什么条件下一个方案会“绝对不可行”,进而把它们排除掉,这样就能缩小我们枚举的范围,目的是降低递归的时间成本,尽量只花时间在那些“潜在的、可行的”的方案上。
这道题目很多位置可以理解为剪枝,通常剪枝都会表现为一堆的判断语句(循环语句的结束条件也算),具体可以看AC代码。
伪代码,仅展示代码逻辑,欢迎抓虫orz
var m,n,k
var ans
var combination = [1]
var dp[n+1] = [0, 1, 2, ..., n+1]
func updateDP(x): //顺便会返回用k张餐票可连续覆盖的最大区间
var r = min(x*k, n+1);
for i from x ~ r: //剪枝3
dp[i] = min(dp[i], dp[i-x]+1)
for i from x ~ r+1:
if dp[i] > k:
return i-1
return -1
func tryAllPosibleCombination( mcover )
if mcover <= 0: return
if len(combination) == m:
if mcover == n: ans++
else return
for i from combination[len(combination)-1]+1 to mcover+1: //剪枝1
temp = copy(dp) // 用来还原dp
combination.add(i)
mcover = updateDP(i)
if dp[n+1] > k: //剪枝2
tryAllPosibleCombination(i+1, mcover)
combination.remove(i)
dp = copy(temp)
main():
......
以上伪代码新增一个变量mcover用于记录当前面值组合能连续覆盖的最长区间,在并让dp数组更新后返回一个新的mcover。
剪枝1:新增的面值若大于mcover+1,那这个combination显然这辈子都凑不到mcover+1元了,已经不满足要求。
剪枝2:若dp更新完后发现dp[n+1]<=k,那这个combination已经不满足题意恰好的条件了
剪枝3:由于最多只能用k张餐票,新加入的面值最多只能凑到k*x元,kx后面的dp值肯定用超过k张凑,不影响后面操作不用更新。
AC代码(注释版)
#include <iostream>
using namespace std;
#define MAX_N 1000
int ac[MAX_N] = {0}; // 记录当前面值组合时,饭钱为i的菜至少要用ac[i]张饭票
int p[MAX_N] = {0}; // 用来记录dfs过程中生成的面值组合的各面值
int n = 0, m = 0, k = 0, ans = 0;
void dfs(int x,int mx) //x表示正在找第几个面值,mx表示当前组合能恰好连续覆盖的最大区间
{
if(mx <= 0)
return;
if(x > m)
{
if(mx == n)
ans++;
return;
}
int *tmp = new int[MAX_N]; // 临时数组,用来保存ac数组,回溯用
for(int i = p[x - 1] + 1;i <= mx + 1; i++) // 剪枝1:加入i > mx+1对于拓展目前“连续”覆盖区间没有意义
{
p[x] = i;
int r = min(i * k, n + 1), j = 0; // 剪枝2: r表示当前面值组合能潜在影响到的最大区间
for(j = i; j <= r; j++) // 当前面值会影响到i到r的区间的ac值,注意这里并不代表当前组合能用k张以下凑到r,即ac[j]是可能大于k的
{
tmp[j] = ac[j];
ac[j] = min(ac[j], ac[j - i] + 1); //如果用了新加入的面值能用更少饭票,更新ac数组
}
if(ac[n + 1] > k) // 剪枝3:合法面值组合不会覆盖到n+1(因为题目要求恰好连续覆盖)
{
for(j = i; j <= r + 1; j++) // 寻找当前组合能连续覆盖的最大区间,r+1是为了防止r刚好是ac数组能覆盖的最大值,注意若r=n+1, 程序运行到j = r就会break
if(ac[j] > k) // 如果当前组合能覆盖到j-1,但无法覆盖到j,则继续寻找下一个面值
{
dfs(x + 1, j - 1);
break;
}
}
for(j = i; j <= r; j++) // 回溯,将新加入的面值移出,所以将ac数组还原
ac[j] = tmp[j];
}
}
int main()
{
int t = 0;
cin >> t;
while(t--)
{
cin >> m >> k >> n;
ans = 0;
for(int i = 1; i <= n + 1; i++)
ac[i] = n + n; // 初始化为一个足够大的值,也可以是n+1,只要是ac[i]实际中不可能大于的值即可
p[1] = 1;
for(int i = 0; i <= k; i++)
ac[i]=i; // 如果面值只有1,那么饭钱为i的菜至少要用i张饭票,当饭钱大于k,不可能用不多于k张饭票凑到。
dfs(2, k); // 寻找第二个面值,目前组合能恰好连续覆盖的区间为k
cout << ans << endl;
}
return 0;
}